.PHONY: all site assets clean deploy help test tree check-git-clean check-output-path check-pushed check-links check-tools archive-audit archive-techworks-audit archive-personal-audit archive-checksums archive-export archive-fetch-forums archive-fetch-github-inventory archive-bundle-github archive-fetch-archive-org archive-fetch-gist-footprint archive-fetch-sourceforge-jd4x archive-fetch-open-source-evidence \
fresh container-image container-build container-check-links \
container-archive-audit verify-reproducible verify-reproducible-inner
.DELETE_ON_ERROR:
.NOTPARALLEL:
SRC := md
OUT ?= out
HEAD := tpl/head.html
FOOT := tpl/foot.html
CSS := tpl/style.css
MD2HTML ?= cmark-gfm
CMARK_FLAGS := --to html --extension table --validate-utf8 --unsafe
PYTHON ?= python3
SITEGEN := tools/sitegen.py
NORMALIZE_OUTPUT := tools/normalize_output.py
VERIFY_OUTPUT := tools/verify_reproducible.py
DEPLOY_HOST ?= arsien23i2@pdx1-shared-a2-13.dreamhost.com:tailrecursion.com/~alan
SITE_BASE_URL ?= https://tailrecursion.com/~alan/
CONTAINER_ENGINE ?= podman
BUILDER_IMAGE ?= localhost/homepage-builder:2026-07-15
SOURCE_DATE_EPOCH ?= $(shell git log -1 --format=%ct HEAD 2>/dev/null)
export SOURCE_DATE_EPOCH
all: check-tools assets $(OUT)/style.css site
check-tools:
@for command in "$(MD2HTML)" "$(PYTHON)" git rsync; do \
command -v "$$command" >/dev/null 2>&1 || { \
echo "Missing build tool: $$command" >&2; \
echo 'Use make container-build or install the documented local tools.' >&2; \
exit 1; \
}; \
done
@$(PYTHON) -c 'import sys; assert sys.version_info >= (3, 11), "Python 3.11 or newer is required"'
$(OUT)/style.css: $(CSS)
mkdir -p $(OUT)
cp $(CSS) $@
assets:
mkdir -p $(OUT)
rsync -a --include='*/' --exclude='*.md' --exclude='*.MD' \
--exclude='*~' --exclude='.#*' --exclude='#*#' --exclude='*.swp' \
--exclude='.DS_Store' --prune-empty-dirs $(SRC)/ $(OUT)/
site:
OUT_DIR="$(OUT)" MD2HTML="$(MD2HTML)" CMARK_FLAGS="$(CMARK_FLAGS)" \
SITE_BASE_URL="$(SITE_BASE_URL)" $(PYTHON) $(SITEGEN) \
--src "$(SRC)" --out "$(OUT)" --head "$(HEAD)" --foot "$(FOOT)"
fresh: check-output-path test
@set -eu; \
test -n "$(SOURCE_DATE_EPOCH)" || { echo 'SOURCE_DATE_EPOCH is empty' >&2; exit 1; }; \
stage=$$(mktemp -d "$${TMPDIR:-/tmp}/homepage-out.XXXXXX"); \
trap 'rm -rf "$$stage"' EXIT HUP INT TERM; \
$(MAKE) OUT="$$stage" all; \
$(PYTHON) $(NORMALIZE_OUTPUT) "$$stage" "$(SOURCE_DATE_EPOCH)"; \
mkdir -p "$(OUT)"; \
rsync -a --delete --checksum --chmod=F644,D755 "$$stage/" "$(OUT)/"
container-image:
$(CONTAINER_ENGINE) build --pull=missing --tag "$(BUILDER_IMAGE)" --file Containerfile .
container-build: container-image
mkdir -p "$(OUT)"
$(CONTAINER_ENGINE) run --rm --userns=keep-id \
--volume "$(CURDIR):/workspace:Z" --workdir /workspace \
--env SOURCE_DATE_EPOCH="$(SOURCE_DATE_EPOCH)" \
"$(BUILDER_IMAGE)" make fresh OUT="$(OUT)"
container-check-links: container-image
$(CONTAINER_ENGINE) run --rm --userns=keep-id \
--volume "$(CURDIR):/workspace:Z" --workdir /workspace \
"$(BUILDER_IMAGE)" make check-links
container-archive-audit: container-image
$(CONTAINER_ENGINE) run --rm --userns=keep-id \
--volume "$(CURDIR):/workspace:Z" --workdir /workspace \
"$(BUILDER_IMAGE)" make archive-audit
verify-reproducible: container-image
$(CONTAINER_ENGINE) run --rm --userns=keep-id \
--volume "$(CURDIR):/workspace:Z" --workdir /workspace \
--env SOURCE_DATE_EPOCH="$(SOURCE_DATE_EPOCH)" \
"$(BUILDER_IMAGE)" make verify-reproducible-inner
verify-reproducible-inner: test
@set -eu; \
first=$$(mktemp -d "$${TMPDIR:-/tmp}/homepage-a.XXXXXX"); \
second=$$(mktemp -d "$${TMPDIR:-/tmp}/homepage-b.XXXXXX"); \
trap 'rm -rf "$$first" "$$second"' EXIT HUP INT TERM; \
$(MAKE) OUT="$$first" all; \
$(MAKE) OUT="$$second" all; \
$(PYTHON) $(NORMALIZE_OUTPUT) "$$first" "$(SOURCE_DATE_EPOCH)"; \
$(PYTHON) $(NORMALIZE_OUTPUT) "$$second" "$(SOURCE_DATE_EPOCH)"; \
$(PYTHON) $(VERIFY_OUTPUT) "$$first" "$$second"
deploy: check-git-clean check-pushed
$(MAKE) container-build
@if [ -z "$(DEPLOY_HOST)" ]; then \
echo "DEPLOY_HOST is not set"; \
exit 1; \
fi
rsync -a --delete --delay-updates --chmod=F644,D755 $(OUT)/ $(DEPLOY_HOST)
tree:
printf 'Markdown sources: ' && find $(SRC) -type f -name '*.md' | wc -l
printf 'HTML outputs: ' && if [ -d $(OUT) ]; then find $(OUT) -type f -name '*.html' | wc -l; else echo 0; fi
test:
PYTHONPATH=tools $(PYTHON) -m unittest discover -s tests -v
$(PYTHON) -m compileall -q tools tests
clean: check-output-path
rm -rf $(OUT)
help:
@echo 'Static site generator targets:'
@echo ' make container-build - reproducibly build $(OUT)/ with Podman'
@echo ' make verify-reproducible - compare two isolated container builds'
@echo ' make container-check-links - check outbound links with container tools'
@echo ' make container-archive-audit - verify all preserved artifacts'
@echo ' make assets - copy non-Markdown assets into $(OUT)/'
@echo ' make - build locally (requires cmark-gfm)'
@echo ' make deploy - container-build, then publish from the host'
@echo ' make tree - count source and generated files'
@echo ' make test - run standard-library unit tests'
@echo ' make archive-audit - verify all preserved artifacts'
@echo ' make archive-fetch-forums - refresh wooby forum captures (network)'
@echo ' make archive-fetch-github-inventory - refresh GitHub metadata (network)'
@echo ' make archive-bundle-github - preserve all inventoried Git repositories (network)'
@echo ' make archive-fetch-archive-org - preserve every public Archive.org link (network)'
@echo ' make archive-fetch-gist-footprint - capture gist use evidence (network)'
@echo ' make archive-fetch-sourceforge-jd4x - preserve credited JD4X pages and sources (network)'
@echo ' make archive-fetch-open-source-evidence - preserve pages supporting public role claims (network)'
@echo ' make archive-export DEST= - create a portable preservation copy'
@echo ' make clean - remove $(OUT)/'
@echo ' (deploy requires a clean worktree matching its upstream branch)'
check-git-clean:
@if [ -n "$$(git status --porcelain)" ]; then \
echo 'Refusing to build: git worktree has uncommitted changes.'; \
git status --short; \
exit 1; \
fi
check-output-path:
@case "$(OUT)" in ''|'/'|'.'|'..') \
echo "Refusing unsafe output path: $(OUT)" >&2; exit 1 ;; \
esac
check-pushed:
@upstream=$$(git rev-parse --abbrev-ref '@{upstream}' 2>/dev/null) || { \
echo 'Current branch has no upstream.' >&2; exit 1; \
}; \
test "$$(git rev-parse HEAD)" = "$$(git rev-parse "$$upstream")" || { \
echo "Refusing to deploy: HEAD does not match $$upstream. Push first." >&2; \
exit 1; \
}
check-links:
@python3 tools/check_links.py
archive-audit:
$(MAKE) archive-techworks-audit
$(MAKE) archive-personal-audit
archive-techworks-audit:
$(PYTHON) tools/archive_techworks.py audit
archive-personal-audit:
$(PYTHON) tools/archive_personal.py audit
archive-checksums:
$(PYTHON) tools/archive_techworks.py checksums
$(PYTHON) tools/archive_personal.py checksums
archive-export:
@test -n "$(DEST)" || { echo 'DEST must be an absolute path' >&2; exit 1; }
$(PYTHON) tools/archive_techworks.py export --dest "$(DEST)"
archive-fetch-forums:
$(PYTHON) tools/archive_personal.py fetch-hacker-news
$(PYTHON) tools/archive_personal.py fetch-slashdot
$(PYTHON) tools/archive_personal.py fetch-halfbakery
archive-fetch-github-inventory:
$(PYTHON) tools/archive_personal.py fetch-github-inventory
archive-bundle-github:
$(PYTHON) tools/archive_personal.py bundle-github
archive-fetch-archive-org:
$(PYTHON) tools/archive_personal.py fetch-archive-org-links
archive-fetch-gist-footprint:
$(PYTHON) tools/archive_personal.py fetch-gist-footprint
archive-fetch-sourceforge-jd4x:
$(PYTHON) tools/archive_personal.py fetch-sourceforge-jd4x
archive-fetch-open-source-evidence:
$(PYTHON) tools/archive_personal.py fetch-open-source-evidence