diff options
| author | Kumar Damani <me@kumardamani.net> | 2026-08-03 18:12:27 +0000 |
|---|---|---|
| committer | Kumar Damani <me@kumardamani.net> | 2026-08-03 18:12:27 +0000 |
| commit | 27bf1933e922f8f083fb4437c1797089b1ab5bbc (patch) | |
| tree | 466f55edc6d42d9c6f52b6145caa1b4cdf67d8cf /posts.sh | |
| parent | 952676a35c7428623f557894ebd56992d9a6c4e6 (diff) | |
manage posts interactively using script
Diffstat (limited to 'posts.sh')
| -rwxr-xr-x | posts.sh | 346 |
1 files changed, 346 insertions, 0 deletions
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 |
