aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKumar Damani <me@kumardamani.net>2026-08-03 18:12:27 +0000
committerKumar Damani <me@kumardamani.net>2026-08-03 18:12:27 +0000
commit27bf1933e922f8f083fb4437c1797089b1ab5bbc (patch)
tree466f55edc6d42d9c6f52b6145caa1b4cdf67d8cf
parent952676a35c7428623f557894ebd56992d9a6c4e6 (diff)
manage posts interactively using script
-rw-r--r--README.md62
-rwxr-xr-xposts.sh346
2 files changed, 407 insertions, 1 deletions
diff --git a/README.md b/README.md
index 21bc829..aa1b5e4 100644
--- a/README.md
+++ b/README.md
@@ -20,4 +20,64 @@ Gitlab CI will automatically inject a `ci.json` file:
"link": "https://gitlab.com/.../job/42"
}
```
-used by the `compile` step for the final output.
+is used by the `compile` step to show the link to the render job in the footer
+of the blog. Just for fun :)
+
+
+Management of blog posts is scripted and will drop you into your `$EDITOR` once it
+creates the required files with settings you chose.
+
+Example `create` run:
+```
+❮ ./posts.sh
+posts.sh - blog post management
+1) create
+2) list
+3) edit
+4) update
+5) remove
+6) quit
+#? 1
+Title: New test post
+Slug [new-test-post]:
+Long title (blank = same as title) [New test post]:
+Description: Testing out a new post
+Date [2026-08-03]:
+
+ slug: new-test-post
+ title: New test post
+ long title: New test post
+ description: Testing out a new post
+ date: 2026-08-03
+
+Create this post? [y/N]: y
+created post/new-test-post.typ
+
+<$EDITOR opens post/new-test-post.typ>
+
+site rebuild: OK
+#? 6
+```
+
+Example `remove` run:
+```
+❯ ./posts.sh
+posts.sh - blog post management
+1) create
+2) list
+3) edit
+4) update
+5) remove
+6) quit
+#? 5
+1) new-test-post 3) framework-server-p2 5) listing-my-fav-advice 7) nixos-p1 9) self-host
+2) a-rack-finally 4) nas-upgrade 6) framework-server 8) programming-as-art 10) programs
+#? 1
+about to remove: new-test-post - New test post
+Really remove 'new-test-post'? [y/N]: y
+also delete image directory post/new-test-post/? [y/N]: y
+deleted post/new-test-post/
+removed new-test-post
+site rebuild: OK
+#? 6
+```
diff --git a/posts.sh b/posts.sh
new file mode 100755
index 0000000..80cb6df
--- /dev/null
+++ b/posts.sh
@@ -0,0 +1,346 @@
+#!/usr/bin/env bash
+# posts.sh - manage blog posts for kumardamani.net.
+#
+# A post is wired into four places, and this script keeps them in sync:
+# site.typ metadata entry in site-info.pages ("post/<slug>": (...))
+# main.typ #import line + #document block (label <post-<slug>>)
+# index.typ - #pageref("post/<slug>") list item
+# post/<slug>.typ the content file itself
+#
+# Run without arguments for an interactive menu, or use subcommands:
+# create, list, edit <slug>, update <slug> <field> [value], remove <slug>
+
+set -euo pipefail
+cd "$(dirname "$0")"
+
+SITE=site.typ
+MAIN=main.typ
+INDEX=index.typ
+
+die() { echo "error: $*" >&2; exit 1; }
+
+usage() {
+ cat <<'EOF'
+Usage: posts.sh [command] [args]
+
+Run without arguments for an interactive menu.
+
+Commands:
+ create Create a new post (interactive prompts),
+ then open it in $EDITOR
+ list List all posts (date, slug, title)
+ edit <slug> Open an existing post in $EDITOR
+ update <slug> <field> [value] Update a field: title, long-title,
+ description or date (prompts if no value)
+ remove <slug> Remove a post: content file, metadata
+ entry, and all page wiring
+ help Show this help
+EOF
+}
+
+# --- helpers ---------------------------------------------------------------
+
+# Escape a string for use inside a Typst "..." string.
+typst_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; }
+
+# Escape a string for use in a sed |s|...|...| replacement.
+sed_escape() { printf '%s' "$1" | sed 's/[\\&|]/\\&/g'; }
+
+slug_re='^[a-z0-9][a-z0-9-]*$'
+
+get_slugs() { sed -n 's/^ "post\/\([a-z0-9-]*\)": ($/\1/p' "$SITE"; }
+
+slug_exists() { grep -q '^ "post/'"$1"'": ($' "$SITE"; }
+
+post_file() { [ -f "post/$1.typ" ]; }
+
+prompt() { # prompt <varname> <text> [default]
+ local var=$1 text=$2 default=${3:-} input
+ if [ -n "$default" ]; then
+ read -r -p "$text [$default]: " input || input=""
+ else
+ read -r -p "$text: " input || input=""
+ fi
+ printf -v "$var" '%s' "${input:-$default}"
+}
+
+confirm() { # confirm <question>
+ local ans
+ read -r -p "$1 [y/N]: " ans || ans=""
+ [ "$ans" = "y" ] || [ "$ans" = "Y" ]
+}
+
+# Menu to pick a post slug; prints the choice on stdout.
+pick_slug() {
+ local slugs s
+ mapfile -t slugs < <(get_slugs)
+ [ ${#slugs[@]} -gt 0 ] || die "no posts found"
+ select s in "${slugs[@]}"; do
+ [ -n "$s" ] && { printf '%s' "$s"; return 0; }
+ done
+}
+
+# Menu to pick a metadata field; prints the choice on stdout.
+pick_field() {
+ local f
+ select f in title long-title description date; do
+ [ -n "$f" ] && { printf '%s' "$f"; return 0; }
+ done
+}
+
+# Insert the contents of file $3 before the first line matching regex $2
+# in file $1. Falls back to appending (with a warning) if no match.
+insert_before_first() {
+ local file=$1 regex=$2 content=$3
+ if awk -v f="$content" -v regex="$regex" '
+ $0 ~ regex && !done {
+ while ((getline l < f) > 0) print l
+ close(f)
+ done = 1
+ }
+ { print }
+ END {
+ if (!done) {
+ while ((getline l < f) > 0) print l
+ close(f)
+ print "posts.sh: warning: anchor not found, appended at end" > "/dev/stderr"
+ }
+ }
+ ' "$file" > "$file.tmp"; then
+ mv "$file.tmp" "$file"
+ else
+ rm -f "$file.tmp"
+ die "insert into $file failed"
+ fi
+}
+
+# Rebuild the site to catch wiring mistakes early.
+validate_build() {
+ if ./build.sh >/dev/null 2>&1; then
+ echo "site rebuild: OK"
+ else
+ echo "posts.sh: WARNING: site rebuild failed - run ./build.sh to see the errors" >&2
+ fi
+}
+
+# --- commands --------------------------------------------------------------
+
+cmd_list() {
+ awk '
+ BEGIN { printf "%-10s %-24s %s\n", "DATE", "SLUG", "TITLE" }
+ /^ "post\/[a-z0-9-]+": \($/ {
+ slug = $0
+ sub(/^ *"post\//, "", slug); sub(/": \($/, "", slug)
+ title = date = ""
+ }
+ slug != "" && /^ title: "/ {
+ title = $0; sub(/^ *title: "/, "", title); sub(/",$/, "", title)
+ }
+ slug != "" && /^ date: "/ {
+ date = $0; sub(/^ *date: "/, "", date); sub(/",$/, "", date)
+ }
+ /^ \),$/ && slug != "" {
+ printf "%-10s %-24s %s\n", date, slug, title
+ slug = ""
+ }
+ ' "$SITE"
+}
+
+cmd_create() {
+ local title slug long_title desc date
+ prompt title "Title"
+ [ -n "$title" ] || die "title is required"
+
+ local default_slug
+ default_slug=$(printf '%s' "$title" | tr 'A-Z' 'a-z' | sed 's/[^a-z0-9]\+/-/g; s/^-\+//; s/-\+$//')
+ prompt slug "Slug" "$default_slug"
+ [[ "$slug" =~ $slug_re ]] || die "invalid slug '$slug' (use lowercase letters, digits, dashes)"
+ slug_exists "$slug" && die "post '$slug' already exists in $SITE"
+ post_file "$slug" && die "post/$slug.typ already exists"
+
+ prompt long_title "Long title (blank = same as title)" "$title"
+ prompt desc "Description"
+ [ -n "$desc" ] || die "description is required"
+ prompt date "Date" "$(date +%F)"
+ [[ "$date" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || die "invalid date '$date' (want YYYY-MM-DD)"
+
+ echo
+ echo " slug: $slug"
+ echo " title: $title"
+ echo " long title: $long_title"
+ echo " description: $desc"
+ echo " date: $date"
+ echo
+ confirm "Create this post?" || die "aborted"
+
+ local e_title e_long e_desc
+ e_title=$(typst_escape "$title")
+ e_long=$(typst_escape "$long_title")
+ e_desc=$(typst_escape "$desc")
+
+ # 1. content file from template
+ mkdir -p "post/$slug"
+ cat > "post/$slug.typ" <<EOF
+#import "/global/common.typ": *
+
+#let doc = [
+= First heading
+
+Start writing here.
+// Put images in post/$slug/ and embed them with:
+// #fig("$slug/file.webp", alt: "description", caption: [a caption]).
+]
+EOF
+
+ # 2. metadata entry in site.typ (posts are newest-first, so it goes
+ # right after the contact entry, at the top of the posts section)
+ local tmp
+ tmp=$(mktemp)
+ {
+ echo " \"post/$slug\": ("
+ echo " title: \"$e_title\","
+ [ "$long_title" = "$title" ] || echo " long-title: \"$e_long\","
+ echo " date: \"$date\","
+ echo " description: \"$e_desc\","
+ echo " hidden: true,"
+ echo " ),"
+ } > "$tmp"
+ sed -i '/^ contact: (title: "Contact"),$/r '"$tmp" "$SITE"
+ rm -f "$tmp"
+
+ # 3. import + document block in main.typ (newest first)
+ tmp=$(mktemp)
+ echo "#import \"/post/$slug.typ\" as post-$slug" > "$tmp"
+ insert_before_first "$MAIN" '^#import "/post/' "$tmp"
+ {
+ echo "#document(\"post/$slug.html\", title: title-for(\"post/$slug\"))["
+ echo " #post-page(\"post/$slug\", site-info, post-$slug.doc)"
+ echo "] <post-$slug>"
+ echo
+ } > "$tmp"
+ insert_before_first "$MAIN" '^#document[(]"post/' "$tmp"
+ rm -f "$tmp"
+
+ # 4. index page listing (newest first)
+ tmp=$(mktemp)
+ echo "- #pageref(\"post/$slug\")" > "$tmp"
+ insert_before_first "$INDEX" '^- #pageref[(]"post/' "$tmp"
+ rm -f "$tmp"
+
+ echo "created post/$slug.typ"
+ validate_build
+
+ "${EDITOR:-vi}" "post/$slug.typ"
+}
+
+cmd_edit() {
+ local slug=${1:-}
+ [ -n "$slug" ] || die "usage: posts.sh edit <slug>"
+ post_file "$slug" || die "no such post: $slug"
+ "${EDITOR:-vi}" "post/$slug.typ"
+}
+
+cmd_update() {
+ local slug=${1:-} field=${2:-} value=${3:-}
+ [ -n "$slug" ] && [ -n "$field" ] || die "usage: posts.sh update <slug> <field> [value]"
+ slug_exists "$slug" || die "no such post: $slug"
+ case "$field" in
+ title|long-title|description|date) ;;
+ *) die "unknown field '$field' (want: title, long-title, description, date)" ;;
+ esac
+
+ local range='/^ "post\/'"$slug"'": ($/,/^ ),$/'
+ local current
+ current=$(sed -n "${range}p" "$SITE" | sed -n 's/^ '"$field"': "\(.*\)",$/\1/p')
+
+ if [ -z "$value" ]; then
+ prompt value "New $field" "$current"
+ fi
+ [ -n "$value" ] || die "empty value"
+ if [ "$field" = date ]; then
+ [[ "$value" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || die "invalid date '$value' (want YYYY-MM-DD)"
+ fi
+
+ local esc
+ esc=$(sed_escape "$(typst_escape "$value")")
+
+ if [ -n "$current" ]; then
+ sed -i "${range}s|^ $field: \".*\",\$| $field: \"$esc\",|" "$SITE"
+ else
+ # field absent (e.g. long-title): add it after the title line
+ sed -i "${range}s|^ title: \".*\",\$|&\n $field: \"$esc\",|" "$SITE"
+ fi
+
+ # verify the edit actually landed
+ local newval
+ newval=$(sed -n "${range}p" "$SITE" | sed -n 's/^ '"$field"': "\(.*\)",$/\1/p')
+ [ "$newval" = "$(typst_escape "$value")" ] || die "failed to update $field in $SITE"
+
+ echo "updated $slug: $field = \"$value\""
+ if [ "$field" = title ] && sed -n "${range}p" "$SITE" | grep -q '^ long-title: '; then
+ echo "house-keeping note: $slug also has a long-title - update it too if it should change"
+ fi
+ validate_build
+}
+
+cmd_remove() {
+ local slug=${1:-}
+ [ -n "$slug" ] || die "usage: posts.sh remove <slug>"
+ slug_exists "$slug" || die "no such post: $slug"
+
+ local title
+ title=$(sed -n '/^ "post\/'"$slug"'": ($/,/^ ),$/p' "$SITE" \
+ | sed -n 's/^ title: "\(.*\)",$/\1/p')
+ echo "about to remove: $slug - $title"
+ confirm "Really remove '$slug'?" || die "aborted"
+
+ # metadata entry (fixed-shape block: key line through the closing "),")
+ sed -i '/^ "post\/'"$slug"'": ($/,/^ ),$/d' "$SITE"
+ # import line + document block (through the blank line that follows it)
+ sed -i '/^#import "\/post\/'"$slug"'\.typ" as /d' "$MAIN"
+ sed -i '/^#document("post\/'"$slug"'\.html"/,/^$/d' "$MAIN"
+ # index listing
+ sed -i '/^- #pageref("post\/'"$slug"'")$/d' "$INDEX"
+
+ rm -f "post/$slug.typ"
+ if [ -d "post/$slug" ]; then
+ if confirm "also delete image directory post/$slug/?"; then
+ rm -r "post/$slug"
+ echo "deleted post/$slug/"
+ fi
+ fi
+
+ echo "removed $slug"
+ validate_build
+}
+
+# --- interactive menu --------------------------------------------------------
+
+interactive() {
+ local cmd
+ echo "posts.sh - blog post management"
+ select cmd in create list edit update remove quit; do
+ case "$cmd" in
+ create) cmd_create ;;
+ list) cmd_list ;;
+ edit) cmd_edit "$(pick_slug)" ;;
+ update) cmd_update "$(pick_slug)" "$(pick_field)" ;;
+ remove) cmd_remove "$(pick_slug)" ;;
+ quit) break ;;
+ *) continue ;;
+ esac
+ done
+}
+
+# --- dispatch ----------------------------------------------------------------
+
+case ${1:-} in
+ "") interactive ;;
+ create) shift; cmd_create "$@" ;;
+ list|ls) shift; cmd_list "$@" ;;
+ edit) shift; cmd_edit "$@" ;;
+ update) shift; cmd_update "$@" ;;
+ remove|rm) shift; cmd_remove "$@" ;;
+ help|-h|--help) usage ;;
+ *) usage >&2; exit 1 ;;
+esac