Stamp the site-wide build time post-render

Hakyll caches per-page outputs, so pages whose dependencies have not
changed are not recompiled and the rendered $build-time$ in their
footer goes stale relative to a fresh build. The right granularity for
"last built at" is site-wide rather than per-page; wrapping the footer
timestamp in <span data-build-time> and rewriting it after Hakyll lets
every page reflect the current build without paying recompilation cost.

* tools/stamp-build-time.py walks _site/**/*.html after Hakyll runs and
  rewrites each element wrapped in [data-build-time] to the same format
  Contexts.hs:buildTimeField emits, so a fresh render and the post-pass
  agree.
* templates/partials/footer.html wraps $build-time$ in
  <span class="footer-build-time" data-build-time>...</span> so the
  sweep has a stable selector.
* Makefile invokes the sweep between embed.py and compress-assets so
  the .gz/.br sidecars include the fresh stamp.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-05-23 12:05:28 -04:00
parent 154b47a4cb
commit fad8719045
3 changed files with 84 additions and 1 deletions

View File

@ -60,6 +60,12 @@ build:
else \ else \
echo "Embedding skipped: run 'uv sync' to enable similar-links (build continues)"; \ echo "Embedding skipped: run 'uv sync' to enable similar-links (build continues)"; \
fi fi
# Site-wide footer timestamp: rewrite every <span data-build-time>
# in _site/**/*.html so cached (un-recompiled) pages don't show a
# stale per-page build time. See tools/stamp-build-time.py for the
# full rationale. Must run before compress-assets so the .gz/.br
# sidecars include the fresh stamp.
@python3 tools/stamp-build-time.py _site
@./tools/compress-assets.sh _site @./tools/compress-assets.sh _site
> IGNORE.txt > IGNORE.txt
@BUILD_END=$$(date +%s); \ @BUILD_END=$$(date +%s); \

View File

@ -7,7 +7,7 @@
<span class="footer-license"><a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" rel="license">CC&nbsp;BY-NC-SA&nbsp;4.0</a> · <a href="https://git.levineuwirth.org/neuwirth/levineuwirth.org">MIT</a> · <a href="/memento-mori.html" class="footer-mm">MM</a></span> <span class="footer-license"><a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" rel="license">CC&nbsp;BY-NC-SA&nbsp;4.0</a> · <a href="https://git.levineuwirth.org/neuwirth/levineuwirth.org">MIT</a> · <a href="/memento-mori.html" class="footer-mm">MM</a></span>
</div> </div>
<div class="footer-right"> <div class="footer-right">
<a href="/build/" class="footer-build-link" aria-label="Build telemetry">build</a> $build-time$ <a href="/build/" class="footer-build-link" aria-label="Build telemetry">build</a> <span class="footer-build-time" data-build-time>$build-time$</span>
· <a href="$url$.sig" class="footer-sig-link" aria-label="PGP signature for this page" title="Ed25519 signing subkey C9A42A6F AD444FBE 566FD738 531BDC1C C2707066 · public key at /gpg/pubkey.asc">sig</a> · <a href="$url$.sig" class="footer-sig-link" aria-label="PGP signature for this page" title="Ed25519 signing subkey C9A42A6F AD444FBE 566FD738 531BDC1C C2707066 · public key at /gpg/pubkey.asc">sig</a>
</div> </div>
</footer> </footer>

77
tools/stamp-build-time.py Executable file
View File

@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Post-build sweep: stamp the site-wide build time into every footer.
Why this exists
---------------
build/Contexts.hs binds $build-time$ to getCurrentTime at item-compile
time, but Hakyll caches outputs. Pages whose dependencies have not
changed are not recompiled, so the previously-rendered timestamp stays
on disk and the footer drifts per page. We want one site-wide
"last built at" stamp, so this script walks _site/**/*.html after
Hakyll runs and rewrites the contents of every wrapped element.
Format must match build/Contexts.hs:buildTimeField exactly so a fresh
build (where Hakyll renders the timestamp itself) and the sweep agree.
"""
from __future__ import annotations
import os
import re
import sys
from datetime import datetime, timezone
def ordinal_suffix(day: int) -> str:
if 11 <= day <= 13:
return "th"
return {1: "st", 2: "nd", 3: "rd"}.get(day % 10, "th")
def format_now() -> str:
now = datetime.now(timezone.utc)
return (
f"{now.strftime('%A, %B')} "
f"{now.day}{ordinal_suffix(now.day)}, "
f"{now.strftime('%Y %H:%M:%S')}"
)
PATTERN = re.compile(
rb'(<span class="footer-build-time" data-build-time>)[^<]*(</span>)'
)
def stamp_file(path: str, replacement_bytes: bytes) -> bool:
with open(path, "rb") as f:
data = f.read()
new_data, count = PATTERN.subn(
lambda m: m.group(1) + replacement_bytes + m.group(2),
data,
)
if count and new_data != data:
with open(path, "wb") as f:
f.write(new_data)
return True
return False
def main(root: str) -> int:
if not os.path.isdir(root):
print(f"stamp-build-time: {root} not found", file=sys.stderr)
return 1
timestamp = format_now().encode("utf-8")
rewritten = 0
scanned = 0
for dirpath, _, files in os.walk(root):
for name in files:
if not name.endswith(".html"):
continue
scanned += 1
if stamp_file(os.path.join(dirpath, name), timestamp):
rewritten += 1
print(f"stamp-build-time: rewrote {rewritten}/{scanned} HTML files")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "_site"))