git » homepage.git » commit 530e5d1

python3-ify tools/

author Alan Dipert
2025-10-08 22:55:47 UTC
committer Alan Dipert
2025-10-08 22:55:47 UTC
parent 541ee1418257d65031af89bd5d640d26009eac02

python3-ify tools/

.gitignore +1 -0
Makefile +8 -11
tools/build_page.py +104 -0
tools/build_page.sh +0 -91
tools/buildinfo.py +112 -0
tools/buildinfo.sh +0 -35
tools/gen_index.py +126 -0
tools/gen_index.sh +0 -42
tools/mdlink2html.awk +0 -47
tools/mdlink2html.py +71 -0
tpl/foot.html +3 -1
tpl/style.css +12 -0

diff --git a/.gitignore b/.gitignore
index 61351e0..8126d18 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ tramp
 .\#*
 
 /out/
+__pycache__/
diff --git a/Makefile b/Makefile
index 9e7c942..647dc2f 100644
--- a/Makefile
+++ b/Makefile
@@ -7,10 +7,10 @@ FOOT := tpl/foot.html
 CSS := tpl/style.css
 MD2HTML ?= /usr/bin/cmark-gfm
 CMARK_FLAGS := --to html --extension table --validate-utf8 --unsafe
-BUILDINFO := tools/buildinfo.sh
-BUILDINFO_STAMP := $(OUT)/.buildinfo
-GIT_HEAD_REF := $(strip $(shell git symbolic-ref -q HEAD 2>/dev/null))
-GIT_HEAD_FILE := $(if $(GIT_HEAD_REF),.git/$(GIT_HEAD_REF),.git/HEAD)
+PYTHON ?= python3
+BUILDINFO := tools/buildinfo.py
+BUILD_PAGE := tools/build_page.py
+GEN_INDEX := tools/gen_index.py
 INDEX_HTML := $(OUT)/Index.html
 DEPLOY_HOST ?= arsien23i2@dreamhost:tailrecursion.com/~alan
 
@@ -19,9 +19,6 @@ HTML := $(patsubst $(SRC)/%.md,$(OUT)/%.html,$(MD_FILES))
 
 all: assets $(OUT)/style.css $(HTML) $(INDEX_HTML)
 
-$(BUILDINFO_STAMP): $(BUILDINFO) .git/HEAD $(GIT_HEAD_FILE) | $(OUT)
-	$(BUILDINFO) > $@
-
 $(OUT)/style.css: $(CSS)
 	mkdir -p $(OUT)
 	cp $(CSS) $@
@@ -29,16 +26,16 @@ $(OUT)/style.css: $(CSS)
 $(OUT):
 	mkdir -p $(OUT)
 
-$(OUT)/%.html: $(SRC)/%.md $(HEAD) $(FOOT) tools/mdlink2html.awk tools/build_page.sh $(BUILDINFO_STAMP) | $(OUT)
+$(OUT)/%.html: $(SRC)/%.md $(HEAD) $(FOOT) tools/mdlink2html.py $(BUILD_PAGE) $(BUILDINFO) | $(OUT)
 	OUT_DIR="$(OUT)" MD2HTML="$(MD2HTML)" CMARK_FLAGS="$(CMARK_FLAGS)" \
-	tools/build_page.sh "$@" "$<" "$(HEAD)" "$(FOOT)" "$(BUILDINFO_STAMP)" tools/mdlink2html.awk
+	$(PYTHON) $(BUILD_PAGE) "$@" "$<" "$(HEAD)" "$(FOOT)" tools/mdlink2html.py
 
 assets:
 	mkdir -p $(OUT)
 	rsync -a --include='*/' --exclude='*.md' --exclude='*.MD' --prune-empty-dirs $(SRC)/ $(OUT)/
 
-$(INDEX_HTML): $(HTML) tools/gen_index.sh tools/index_list.awk $(HEAD) $(FOOT) $(BUILDINFO_STAMP) | $(OUT)
-	tools/gen_index.sh $@ $(SRC) $(BUILDINFO_STAMP)
+$(INDEX_HTML): $(HTML) $(GEN_INDEX) $(HEAD) $(FOOT) $(BUILDINFO) | $(OUT)
+	$(PYTHON) $(GEN_INDEX) $@ $(SRC)
 
 deploy: check-git-clean assets all
 	@if [ -z "$(DEPLOY_HOST)" ]; then \
diff --git a/tools/build_page.py b/tools/build_page.py
new file mode 100755
index 0000000..4eefa00
--- /dev/null
+++ b/tools/build_page.py
@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+"""Render a Markdown file into HTML using project templates."""
+
+from __future__ import annotations
+
+import argparse
+import html
+import os
+import shlex
+import subprocess
+import sys
+from pathlib import Path
+from typing import List
+
+TOOLS_DIR = Path(__file__).resolve().parent
+if str(TOOLS_DIR) not in sys.path:
+    sys.path.insert(0, str(TOOLS_DIR))
+
+import buildinfo  # noqa: E402
+import mdlink2html  # noqa: E402
+
+
+def parse_args() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(description="Render Markdown to HTML")
+    parser.add_argument("output")
+    parser.add_argument("input_md")
+    parser.add_argument("head_tpl")
+    parser.add_argument("foot_tpl")
+    parser.add_argument(
+        "mdlink_script",
+        nargs="?",
+        default=str(TOOLS_DIR / "mdlink2html.py"),
+        help="Path to the Markdown link rewriter script (informational)",
+    )
+    return parser.parse_args()
+
+
+def run_cmark(md_content: str, md2html: str, cmark_flags: List[str]) -> str:
+    result = subprocess.run(
+        [md2html, *cmark_flags],
+        input=md_content,
+        text=True,
+        stdout=subprocess.PIPE,
+        check=True,
+    )
+    return result.stdout
+
+
+def compute_root_prefix(output_path: Path, out_dir: Path) -> str:
+    try:
+        relative_path = output_path.relative_to(out_dir)
+    except ValueError:
+        return ""
+
+    relative_dir = relative_path.parent
+    if not relative_dir or relative_dir == Path('.'):
+        return ""
+
+    depth = len([part for part in relative_dir.parts if part])
+    return "../" * depth
+
+
+def render_template(template_path: Path, root_prefix: str, page_title: str, build_info_html: str) -> str:
+    template = template_path.read_text()
+    rendered = template.replace("@ROOT@", root_prefix)
+    rendered = rendered.replace("@TITLE@", html.escape(page_title))
+    return rendered.replace("@BUILDINFO@", build_info_html)
+
+
+def main() -> None:
+    args = parse_args()
+
+    output_path = Path(args.output)
+    input_md = Path(args.input_md)
+    head_tpl = Path(args.head_tpl)
+    foot_tpl = Path(args.foot_tpl)
+    mdlink_script = Path(args.mdlink_script)
+    if not mdlink_script.exists():
+        raise FileNotFoundError(f"Markdown link rewriter not found: {mdlink_script}")
+
+    md2html = os.environ.get("MD2HTML", "/usr/bin/cmark-gfm")
+    cmark_flags = shlex.split(os.environ.get("CMARK_FLAGS", "--to html --extension table --validate-utf8 --unsafe"))
+    out_dir = Path(os.environ.get("OUT_DIR", "out"))
+
+    output_path.parent.mkdir(parents=True, exist_ok=True)
+
+    rewritten_md = mdlink2html.rewrite_file(input_md)
+    body_html = run_cmark(rewritten_md, md2html, cmark_flags)
+
+    page_title = input_md.stem
+    build_info_html = buildinfo.build_info(input_md.as_posix())
+    root_prefix = compute_root_prefix(output_path, out_dir)
+
+    head_html = render_template(head_tpl, root_prefix, page_title, build_info_html)
+    foot_html = render_template(foot_tpl, root_prefix, page_title, build_info_html)
+
+    with output_path.open("w") as outf:
+        outf.write(head_html)
+        outf.write(body_html)
+        outf.write(foot_html)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tools/build_page.sh b/tools/build_page.sh
deleted file mode 100755
index ff662a4..0000000
--- a/tools/build_page.sh
+++ /dev/null
@@ -1,91 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-usage() {
-  cat <<'USAGE'
-Usage: build_page.sh OUTPUT INPUT HEAD FOOT BUILDINFO MDLINK_AWK
-
-Render a Markdown INPUT file into OUTPUT HTML using provided HEAD and FOOT templates.
-
-Environment:
-  MD2HTML      Path to the cmark-gfm executable (default: /usr/bin/cmark-gfm)
-  CMARK_FLAGS  Flags passed to cmark-gfm (default matches Makefile)
-  OUT_DIR      Root output directory used to compute relative links (default: out)
-USAGE
-}
-
-if [[ $# -ne 6 ]]; then
-  usage >&2
-  exit 1
-fi
-
-output=$1
-input_md=$2
-head_tpl=$3
-foot_tpl=$4
-buildinfo_stamp=$5
-mdlink_awk=$6
-
-MD2HTML=${MD2HTML:-/usr/bin/cmark-gfm}
-CMARK_FLAGS=${CMARK_FLAGS:---to html --extension table --validate-utf8 --unsafe}
-OUT_DIR=${OUT_DIR:-out}
-
-tmpdir=$(mktemp -d)
-cleanup() {
-  rm -rf "$tmpdir"
-}
-trap cleanup EXIT INT TERM
-
-mkdir -p "$(dirname "$output")"
-
-rewritten_md="$tmpdir/rewritten.md"
-body_html="$tmpdir/body.html"
-head_html="$tmpdir/head.html"
-foot_html="$tmpdir/foot.html"
-
-awk -f "$mdlink_awk" "$input_md" > "$rewritten_md"
-
-# shellcheck disable=SC2206 # we intentionally split CMARK_FLAGS into array words
-read -r -a cmark_args <<< "$CMARK_FLAGS"
-"$MD2HTML" "${cmark_args[@]}" "$rewritten_md" > "$body_html"
-
-page_title=$(basename "$input_md" .md)
-build_info=$(cat "$buildinfo_stamp")
-
-escape_sed() {
-  printf '%s\n' "$1" | sed 's/[\\/&]/\\&/g'
-}
-
-build_info_esc=$(escape_sed "$build_info")
-page_title_esc=$(escape_sed "$page_title")
-
-relative_path="${output#$OUT_DIR}"
-relative_path="${relative_path#/}"
-if [[ "$relative_path" == "$output" ]]; then
-  relative_path=""
-fi
-relative_dir="$relative_path"
-if [[ -n "$relative_dir" ]]; then
-  relative_dir=${relative_dir%/*}
-  if [[ "$relative_dir" == "$relative_path" ]]; then
-    relative_dir=""
-  fi
-fi
-
-root_prefix=""
-if [[ -n "$relative_dir" && "$relative_dir" != "." ]]; then
-  IFS='/' read -r -a segments <<< "$relative_dir"
-  for _ in "${segments[@]}"; do
-    root_prefix+="../"
-  done
-fi
-
-sed -e "s|@ROOT@|$root_prefix|g" \
-    -e "s|@BUILDINFO@|$build_info_esc|g" \
-    -e "s|@TITLE@|$page_title_esc|g" "$head_tpl" > "$head_html"
-
-sed -e "s|@ROOT@|$root_prefix|g" \
-    -e "s|@BUILDINFO@|$build_info_esc|g" \
-    -e "s|@TITLE@|$page_title_esc|g" "$foot_tpl" > "$foot_html"
-
-cat "$head_html" "$body_html" "$foot_html" > "$output"
diff --git a/tools/buildinfo.py b/tools/buildinfo.py
new file mode 100755
index 0000000..f4fca87
--- /dev/null
+++ b/tools/buildinfo.py
@@ -0,0 +1,112 @@
+#!/usr/bin/env python3
+"""Generate build metadata for site footers."""
+
+from __future__ import annotations
+
+import argparse
+import getpass
+import html
+import os
+import socket
+import subprocess
+from datetime import datetime
+from pathlib import Path
+from typing import Optional
+
+
+def ordinal_suffix(day: int) -> str:
+    if 11 <= day % 100 <= 13:
+        return "th"
+    return {1: "st", 2: "nd", 3: "rd"}.get(day % 10, "th")
+
+
+def format_month_day_year(dt: datetime) -> str:
+    return f"{dt.strftime('%B')} {dt.day}{ordinal_suffix(dt.day)} {dt.year}"
+
+
+def run_git(args: list[str], cwd: Path, check: bool = True) -> str:
+    result = subprocess.run(
+        ["git", *args],
+        cwd=cwd,
+        text=True,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+        check=False,
+    )
+    if check and result.returncode != 0:
+        raise subprocess.CalledProcessError(result.returncode, result.args, result.stdout, result.stderr)
+    return result.stdout.strip()
+
+
+def resolve_repo_root(start_dir: Path) -> Path:
+    output = run_git(["rev-parse", "--show-toplevel"], cwd=start_dir)
+    if not output:
+        raise RuntimeError("Unable to locate git repository root")
+    return Path(output)
+
+
+def resolve_commit(repo_root: Path, target: Optional[str]) -> str:
+    if target:
+        rel = Path(target)
+        if not rel.is_absolute():
+            rel = (repo_root / rel).resolve()
+        try:
+            rel = rel.relative_to(repo_root)
+        except ValueError:
+            rel = Path(target)
+        commit = run_git(["log", "-n1", "--format=%H", "--", rel.as_posix()], cwd=repo_root, check=False)
+        if commit:
+            return commit
+    return run_git(["rev-parse", "HEAD"], cwd=repo_root)
+
+
+def build_info(target: Optional[str] = None) -> str:
+    script_dir = Path(__file__).resolve().parent
+    repo_root = resolve_repo_root(script_dir)
+
+    commit_sha = resolve_commit(repo_root, target)
+    if not commit_sha:
+        raise RuntimeError("Failed to resolve commit")
+
+    short_sha = run_git(["rev-parse", "--short", commit_sha], cwd=repo_root)
+    commit_iso = run_git(["show", "-s", "--format=%cI", commit_sha], cwd=repo_root)
+    commit_subject = run_git(["show", "-s", "--format=%s", commit_sha], cwd=repo_root)
+
+    commit_dt = datetime.fromisoformat(commit_iso)
+    commit_date_str = format_month_day_year(commit_dt)
+
+    user = getpass.getuser()
+    host = socket.gethostname()
+    build_dt = datetime.now().astimezone()
+    build_date_str = format_month_day_year(build_dt)
+    build_time_str = build_dt.strftime("%I:%M %p %Z").lstrip("0")
+
+    commit_url = f"https://tailrecursion.com/git-arr/r/homepage.git/c/{commit_sha}/"
+    commit_msg_html = html.escape(commit_subject)
+
+    updated_line = (
+        f"<p class=\"foot-updated\">Updated {commit_date_str} · "
+        f"<a href=\"{commit_url}\">{short_sha}</a> — &ldquo;{commit_msg_html}&rdquo;</p>"
+    )
+
+    built_line = (
+        f"<p class=\"foot-built\">Built {build_date_str} at {build_time_str} by {user}@{host}</p>"
+    )
+
+    indent = "      "
+    return f"{indent}{updated_line}\n{indent}{built_line}"
+
+
+def parse_args() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(description="Emit build metadata HTML for footers")
+    parser.add_argument("target", nargs="?", help="Optional path to resolve last commit from")
+    return parser.parse_args()
+
+
+def main() -> None:
+    args = parse_args()
+    print(build_info(args.target))
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tools/buildinfo.sh b/tools/buildinfo.sh
deleted file mode 100755
index 9bac515..0000000
--- a/tools/buildinfo.sh
+++ /dev/null
@@ -1,35 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-ordinal_suffix() {
-  local day="$1"
-  case $day in
-    11|12|13) printf 'th'; return ;;
-  esac
-  case ${day: -1} in
-    1) printf 'st' ;;
-    2) printf 'nd' ;;
-    3) printf 'rd' ;;
-    *) printf 'th' ;;
-  esac
-}
-
-main() {
-  local user host day suffix dow month year time sha_full sha_short repo_url
-  user=$(whoami)
-  host=$(hostname)
-  day=$(date '+%-d')
-  suffix=$(ordinal_suffix "$day")
-  dow=$(date '+%A')
-  month=$(date '+%B')
-  year=$(date '+%Y')
-  time=$(date '+%-I:%M%p %Z')
-  sha_full=$(git rev-parse HEAD)
-  sha_short=$(git rev-parse --short HEAD)
-  repo_url="https://tailrecursion.com/git-arr/r/homepage.git/c/${sha_full}/"
-
-  printf 'Built by %s@%s on %s, %s %s%s %s at %s (git <a href="%s">%s</a>)\n' \
-    "$user" "$host" "$dow" "$month" "$day" "$suffix" "$year" "$time" "$repo_url" "$sha_short"
-}
-
-main "$@"
diff --git a/tools/gen_index.py b/tools/gen_index.py
new file mode 100755
index 0000000..ebb2e33
--- /dev/null
+++ b/tools/gen_index.py
@@ -0,0 +1,126 @@
+#!/usr/bin/env python3
+"""Generate the site index page."""
+
+from __future__ import annotations
+
+import argparse
+import html
+import os
+import sys
+from collections import OrderedDict
+from pathlib import Path
+from typing import Iterable
+
+TOOLS_DIR = Path(__file__).resolve().parent
+if str(TOOLS_DIR) not in sys.path:
+    sys.path.insert(0, str(TOOLS_DIR))
+
+import buildinfo  # noqa: E402
+
+
+class Node:
+    def __init__(self, name: str) -> None:
+        self.name = name
+        self.href: str | None = None
+        self.children: "OrderedDict[str, Node]" = OrderedDict()
+
+    def child(self, part: str) -> "Node":
+        if part not in self.children:
+            self.children[part] = Node(part)
+        return self.children[part]
+
+
+def parse_args() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(description="Generate the index HTML page")
+    parser.add_argument("output")
+    parser.add_argument("src_dir", nargs="?", default="md")
+    parser.add_argument("commit_target", nargs="?", default=None)
+    return parser.parse_args()
+
+
+def collect_markdown(src_dir: Path) -> Iterable[str]:
+    return sorted(
+        (path.relative_to(src_dir).as_posix() for path in src_dir.rglob("*.md")),
+        key=lambda p: p,
+    )
+
+
+def build_tree(paths: Iterable[str]) -> Node:
+    root = Node("")
+    for rel_path in paths:
+        node = root
+        parts = rel_path[:-3].split("/")  # strip .md
+        for part in parts:
+            node = node.child(part)
+        node.href = rel_path
+    return root
+
+
+def render_children(node: Node, depth: int) -> list[str]:
+    indent = "  " * depth
+    lines: list[str] = []
+    for child in node.children.values():
+        display = html.escape(child.name)
+        if child.href:
+            href = html.escape("./" + child.href[:-3] + ".html")
+            line = f"{indent}<li><a href=\"{href}\">{display}</a>"
+        else:
+            line = f"{indent}<li>{display}"
+
+        if child.children:
+            lines.append(line)
+            lines.append(f"{indent}  <ul>")
+            lines.extend(render_children(child, depth + 1))
+            lines.append(f"{indent}  </ul>")
+            lines.append(f"{indent}</li>")
+        else:
+            lines.append(f"{line}</li>")
+    return lines
+
+
+def render_index_list(root: Node) -> str:
+    lines = ["<ul class=\"site-index\">"]
+    lines.extend(render_children(root, 0))
+    lines.append("</ul>")
+    return "\n".join(lines)
+
+
+def render_template(template_path: Path, build_info_html: str) -> str:
+    template = template_path.read_text()
+    template = template.replace("@ROOT@", "")
+    template = template.replace("@TITLE@", "Index")
+    return template.replace("@BUILDINFO@", build_info_html)
+
+
+def main() -> None:
+    args = parse_args()
+
+    output_path = Path(args.output)
+    src_dir = Path(args.src_dir)
+    commit_target = args.commit_target if args.commit_target is not None else args.src_dir
+
+    root_dir = TOOLS_DIR.parent
+    os.chdir(root_dir)
+
+    build_info_html = buildinfo.build_info(commit_target)
+    head_html = render_template(root_dir / "tpl" / "head.html", build_info_html)
+    foot_html = render_template(root_dir / "tpl" / "foot.html", build_info_html)
+
+    body_lines = [
+        "<h1>Index of Alan's Homepage</h1>",
+        "<p>Browse every page in this wiki-style site:</p>",
+    ]
+
+    markdown_paths = list(collect_markdown(src_dir))
+    tree = build_tree(markdown_paths)
+    body_lines.append(render_index_list(tree))
+
+    output_path.parent.mkdir(parents=True, exist_ok=True)
+    with output_path.open("w") as outf:
+        outf.write(head_html)
+        outf.write("\n".join(body_lines) + "\n")
+        outf.write(foot_html)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tools/gen_index.sh b/tools/gen_index.sh
deleted file mode 100755
index 453524e..0000000
--- a/tools/gen_index.sh
+++ /dev/null
@@ -1,42 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-output=${1:?"usage: gen_index.sh out/Index.html [src-dir] [buildinfo-file]"}
-src_dir=${2:-md}
-buildinfo_file=${3:-}
-script_dir=$(cd "$(dirname "$0")" && pwd)
-root_dir=$(cd "$script_dir/.." && pwd)
-cd "$root_dir"
-
-if [[ -n "$buildinfo_file" && -f "$buildinfo_file" ]]; then
-  build_info=$(<"$buildinfo_file")
-else
-  build_info=$(tools/buildinfo.sh)
-fi
-build_info_esc=$(printf '%s\n' "$build_info" | sed 's/[\/&]/\\&/g')
-css_prefix=""
-page_title="Index"
-
-head_tmp=$(mktemp)
-foot_tmp=$(mktemp)
-body_tmp=$(mktemp)
-
-sed -e "s|@ROOT@|$css_prefix|g" \
-    -e "s|@BUILDINFO@|$build_info_esc|g" \
-    -e "s|@TITLE@|$page_title|g" \
-    tpl/head.html > "$head_tmp"
-
-sed -e "s|@ROOT@|$css_prefix|g" \
-    -e "s|@BUILDINFO@|$build_info_esc|g" \
-    -e "s|@TITLE@|$page_title|g" \
-    tpl/foot.html > "$foot_tmp"
-
-{
-  echo "<h1>Index of Alan's Homepage</h1>"
-  echo "<p>Browse every page in this wiki-style site:</p>"
-  find "$src_dir" -type f -name '*.md' | LC_ALL=C sort | awk -v prefix="$src_dir" -f tools/index_list.awk
-} > "$body_tmp"
-
-cat "$head_tmp" "$body_tmp" "$foot_tmp" > "$output"
-
-rm -f "$head_tmp" "$body_tmp" "$foot_tmp"
diff --git a/tools/mdlink2html.awk b/tools/mdlink2html.awk
deleted file mode 100755
index 315fa5b..0000000
--- a/tools/mdlink2html.awk
+++ /dev/null
@@ -1,47 +0,0 @@
-#!/usr/bin/env awk -f
-# Rewrite Markdown links to point at generated HTML pages.
-
-function rewrite_url(url, path, frag, hash_pos, lower) {
-  if (url ~ /:\/\//) {
-    return url
-  }
-  if (substr(url, 1, 1) == "#") {
-    return url
-  }
-
-  hash_pos = index(url, "#")
-  if (hash_pos) {
-    frag = substr(url, hash_pos)
-    path = substr(url, 1, hash_pos - 1)
-  } else {
-    frag = ""
-    path = url
-  }
-
-  lower = tolower(path)
-  if (lower ~ /\.md$/) {
-    path = substr(path, 1, length(path) - 3) ".html"
-  } else if (path != "") {
-    if (lower ~ /\.(html|png|jpg|jpeg|gif|svg|pdf|css|js|zip)$/) {
-      # leave as-is
-    } else if (path !~ /\.[A-Za-z0-9]+$/ && path ~ /^[A-Za-z0-9._\-\/]+$/) {
-      path = path ".html"
-    }
-  }
-
-  return path frag
-}
-
-{
-  output = ""
-  remaining = $0
-  while (match(remaining, /\]\([^)]+\)/)) {
-    prefix = substr(remaining, 1, RSTART - 1)
-    url = substr(remaining, RSTART + 2, RLENGTH - 3)
-    rewritten = rewrite_url(url)
-    output = output prefix "](" rewritten ")"
-    remaining = substr(remaining, RSTART + RLENGTH)
-  }
-  output = output remaining
-  print output
-}
diff --git a/tools/mdlink2html.py b/tools/mdlink2html.py
new file mode 100755
index 0000000..4462e79
--- /dev/null
+++ b/tools/mdlink2html.py
@@ -0,0 +1,71 @@
+#!/usr/bin/env python3
+"""Rewrite Markdown links to point at generated HTML pages."""
+
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+from pathlib import Path
+
+_LINK_PATTERN = re.compile(r"\]\(([^)]+)\)")
+_ALLOWED_PATH_PATTERN = re.compile(r"^[A-Za-z0-9._\-/]+$")
+_KNOWN_EXTENSIONS = (".html", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".pdf", ".css", ".js", ".zip")
+
+
+def _rewrite_url(url: str) -> str:
+    if "://" in url:
+        return url
+    if url.startswith("#"):
+        return url
+
+    frag = ""
+    path = url
+    hash_pos = url.find("#")
+    if hash_pos != -1:
+        path = url[:hash_pos]
+        frag = url[hash_pos:]
+
+    lower = path.lower()
+    if lower.endswith(".md"):
+        path = path[:-3] + ".html"
+    elif path:
+        if lower.endswith(_KNOWN_EXTENSIONS):
+            pass
+        elif not re.search(r"\.[A-Za-z0-9]+$", path) and _ALLOWED_PATH_PATTERN.match(path):
+            path = f"{path}.html"
+
+    return f"{path}{frag}"
+
+
+def rewrite_text(text: str) -> str:
+    def _replace(match: re.Match[str]) -> str:
+        url = match.group(1)
+        rewritten = _rewrite_url(url)
+        return f"]({rewritten})"
+
+    return _LINK_PATTERN.sub(_replace, text)
+
+
+def rewrite_file(path: Path) -> str:
+    text = path.read_text()
+    return rewrite_text(text)
+
+
+def parse_args() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("input", nargs="?", help="Markdown file to process (defaults to stdin)")
+    return parser.parse_args()
+
+
+def main() -> None:
+    args = parse_args()
+    if args.input:
+        path = Path(args.input)
+        sys.stdout.write(rewrite_file(path))
+    else:
+        sys.stdout.write(rewrite_text(sys.stdin.read()))
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tpl/foot.html b/tpl/foot.html
index 31e606f..3cd5a90 100644
--- a/tpl/foot.html
+++ b/tpl/foot.html
@@ -1,7 +1,9 @@
       </div>
     </div>
     <footer class="site-foot">
-      <p>@BUILDINFO@</p>
+      <div class="foot-meta">
+@BUILDINFO@
+      </div>
     </footer>
   </div>
 </body>
diff --git a/tpl/style.css b/tpl/style.css
index 6d90da2..031184b 100644
--- a/tpl/style.css
+++ b/tpl/style.css
@@ -86,4 +86,16 @@ img[src*="200px"]{width:200px;}
   color:var(--muted);
   font-size:.875rem;
 }
+.site-foot .foot-meta{
+  display:flex;
+  flex-direction:column;
+  gap:.35rem;
+}
 .site-foot p{margin:0;}
+.site-foot a{
+  color:inherit;
+  text-decoration:none;
+}
+.site-foot a:hover{
+  text-decoration:underline;
+}