#!/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/": (...)) # main.typ #import line + #document block (label >) # index.typ - #pageref("post/") list item # post/.typ the content file itself # # Run without arguments for an interactive menu, or use subcommands: # create, list, edit , update [value], remove 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 Open an existing post in $EDITOR update [value] Update a field: title, long-title, description or date (prompts if no value) remove 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 [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 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" < "$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 "] " 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 " 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 [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_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