diff --git a/publishing/README.md b/publishing/README.md index b87afd4..7468276 100644 --- a/publishing/README.md +++ b/publishing/README.md @@ -16,6 +16,7 @@ repository root. | `fonts/` | Figtree woff2 files and the OFL licence text | | `logo.svg` | Co-op logo (shared with the website repo) | | `build.sh` | Stage the Markdown into `.site-src/`, render to `.site-build/` | +| `hooks.py` | MkDocs hooks: directory-link rewriting + content-hashed stylesheet URLs | | `deploy.sh` | Push the build into the Surfer app container | | `poll-watcher.sh` | Polls Gitea main every 60s; deploys on change (agent host) | | `docs-deploy-poll.service` | systemd unit for the poller | @@ -26,6 +27,19 @@ repository root. Nothing external is loaded by the site: no CDN, no third-party font service. Fonts are served from this repository's own output. +### Cache-proof stylesheets + +`brand.css` and `typography.css` are listed under `extra_css`, so at a fixed URL +they would be vulnerable to a stale browser cache after a deploy — a warm cache +keeps serving the old file because the URL never changes. `hooks.py`'s +`on_post_build` fixes this at build time: it copies each stylesheet to +`..css` in the output and rewrites the `` in every built +page to the hashed name. A change to a stylesheet's content therefore always +produces a new URL. The authored sources keep their names and locations; the +hashed copies exist only in the build output. The hashed copy sits in the same +directory as the original, so the `@font-face` `url("fonts/…")` references keep +resolving. The deploy is unchanged. + ## Build ```sh diff --git a/publishing/hooks.py b/publishing/hooks.py index b453b6f..d4a6280 100644 --- a/publishing/hooks.py +++ b/publishing/hooks.py @@ -1,16 +1,32 @@ -"""MkDocs hook: make directory-style links work on a static site. +"""MkDocs hooks for the publishing layer. -The documents sometimes link to a directory the way Gitea renders it, e.g. -infrastructure.md contains [`incidents/`](incidents/). On a static site with -directory URLs, that href resolves relative to the *page* (giving -/infrastructure/incidents/ and a 404). This hook rewrites such links to the -generated landing page for that directory (build.sh creates -/index.md for documented directories that lack one). It never edits the -documents themselves. +Two jobs, both publishing-layer only — neither edits the documents or the +authored stylesheets in the repository. + +1. Directory-style links. The documents sometimes link to a directory the way + Gitea renders it, e.g. infrastructure.md contains [`incidents/`](incidents/). + On a static site with directory URLs, that href resolves relative to the + *page* (giving /infrastructure/incidents/ and a 404). This hook rewrites such + links to the generated landing page for that directory (build.sh creates + /index.md for documented directories that lack one). + +2. Content-hashed stylesheet URLs. The site's own stylesheets (brand.css, + typography.css) are listed in mkdocs.yml under extra_css, so every page + links them at a FIXED URL (publishing/brand.css). A fixed URL means a browser + with a warm cache keeps serving the old stylesheet after a deploy — the URL + never changes even when the content does. Material's own assets + (assets/stylesheets/main..min.css) are content-hashed and immune; ours + were not. This hook gives ours the same property: at build time it copies + each to ..css in the output and rewrites the links in the + built HTML, so any change to a stylesheet's content necessarily yields a new + URL. The authored sources keep their names and locations. """ +import hashlib +import os import posixpath import re +import shutil from mkdocs.utils import get_relative_url @@ -36,3 +52,93 @@ def on_page_content(html, page, config, files): return f'{match.group(1)}"{get_relative_url(index_urls[target], page.file.url)}"' return _HREF.sub(fix, html) + + +# --- Content-hashed stylesheet URLs ---------------------------------------- + +# Material's own asset names use 8 hex characters (main.57a0d79d.min.css); match +# that so the site's naming is uniform and the hash is short but collision-safe +# for a handful of stylesheets. +_HASH_LEN = 8 +# A hashed sibling of brand.css is brand.<8 hex>.css. +_HASHED_SIBLING = re.compile(r"^(?P.+)\.(?P[0-9a-f]{%d})(?P\.[^.]+)$" % _HASH_LEN) + + +def _hashed_links(config, files=None): + """Build each extra_css file to a content-hashed name in the site output. + + Returns {original-relative-path: hashed-relative-path}. The hashed copy + lands in the *same directory* as the original, so relative references + inside the CSS (e.g. brand.css's @font-face url("fonts/figtree-400.woff2")) + keep resolving. Stale hashed siblings from an earlier build are removed, so + the output does not accumulate files across builds. + """ + site_dir = config["site_dir"] + replacements = {} + + for css in config.get("extra_css") or []: + rel = css.lstrip("/").replace("\\", "/") + original = os.path.join(site_dir, *rel.split("/")) + if not os.path.isfile(original): + continue + + with open(original, "rb") as fh: + digest = hashlib.sha256(fh.read()).hexdigest()[:_HASH_LEN] + + stem, ext = posixpath.splitext(rel) + hashed_rel = f"{stem}.{digest}{ext}" + hashed = os.path.join(site_dir, *hashed_rel.split("/")) + + # Copy to the hashed name (a no-op if it is already correct). + if os.path.abspath(hashed) != os.path.abspath(original): + shutil.copyfile(original, hashed) + + # Drop stale hashed siblings of the original (same stem/ext, any hash). + directory = os.path.dirname(original) + base_stem, base_ext = posixpath.splitext(os.path.basename(rel)) + keep = os.path.basename(hashed) + for name in os.listdir(directory): + m = _HASHED_SIBLING.match(name) + if not m or name == keep: + continue + if m.group("stem") == base_stem and m.group("ext") == base_ext: + os.remove(os.path.join(directory, name)) + + replacements[rel] = hashed_rel + + return replacements + + +def _rewrite_stylesheet_links(site_dir, replacements): + """Point every at the hashed file, in place. + + The href is relative to each page (root pages use "publishing/brand.css", + nested pages "../publishing/brand.css"), so match any prefix up to the + known relative path and swap only the path tail, preserving the page's + own relative prefix. + """ + patterns = [ + (re.compile(r'(href="[^"]*)' + re.escape(orig) + r'(")'), hashed) + for orig, hashed in replacements.items() + ] + + for root, _dirs, filenames in os.walk(site_dir): + for name in filenames: + if not name.endswith(".html"): + continue + path = os.path.join(root, name) + with open(path, "r", encoding="utf-8") as fh: + html = fh.read() + new = html + for pattern, hashed in patterns: + new = pattern.sub(lambda m, h=hashed: m.group(1) + h + m.group(2), new) + if new != html: + with open(path, "w", encoding="utf-8") as fh: + fh.write(new) + + +def on_post_build(config): + replacements = _hashed_links(config) + if replacements: + _rewrite_stylesheet_links(config["site_dir"], replacements) +