docs site: content-hash the site's own stylesheets (cache-proof URLs)

brand.css and typography.css are referenced at fixed URLs via extra_css, so a
warm browser cache could keep serving the previous stylesheet after a deploy.
Add on_post_build to publishing/hooks.py: at build time copy each extra_css
file to <name>.<sha256-8>.css in the output and rewrite the <link> in the built
HTML. Any content change now yields a new URL. Sources keep their names; hashed
copies exist only in the build output and stale ones are pruned on rebuild.
This commit is contained in:
docs-agent committed 2026-11-04 00:23:28 +00:00
1 parent 492a64fa43
commit 4f07c2d3ac
2 files changed
+128 -8

No files matched your search

+14
View File
@@ -16,6 +16,7 @@ repository root.
| `fonts/` | Figtree woff2 files and the OFL licence text | | `fonts/` | Figtree woff2 files and the OFL licence text |
| `logo.svg` | Co-op logo (shared with the website repo) | | `logo.svg` | Co-op logo (shared with the website repo) |
| `build.sh` | Stage the Markdown into `.site-src/`, render to `.site-build/` | | `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 | | `deploy.sh` | Push the build into the Surfer app container |
| `poll-watcher.sh` | Polls Gitea main every 60s; deploys on change (agent host) | | `poll-watcher.sh` | Polls Gitea main every 60s; deploys on change (agent host) |
| `docs-deploy-poll.service` | systemd unit for the poller | | `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. Nothing external is loaded by the site: no CDN, no third-party font service.
Fonts are served from this repository's own output. 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
`<name>.<sha256-8>.css` in the output and rewrites the `<link>` 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 ## Build
```sh ```sh
+114 -8
View File
@@ -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. Two jobs, both publishing-layer only — neither edits the documents or the
infrastructure.md contains [`incidents/`](incidents/). On a static site with authored stylesheets in the repository.
directory URLs, that href resolves relative to the *page* (giving
/infrastructure/incidents/ and a 404). This hook rewrites such links to the 1. Directory-style links. The documents sometimes link to a directory the way
generated landing page for that directory (build.sh creates Gitea renders it, e.g. infrastructure.md contains [`incidents/`](incidents/).
<dir>/index.md for documented directories that lack one). It never edits the On a static site with directory URLs, that href resolves relative to the
documents themselves. *page* (giving /infrastructure/incidents/ and a 404). This hook rewrites such
links to the generated landing page for that directory (build.sh creates
<dir>/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.<hash>.min.css) are content-hashed and immune; ours
were not. This hook gives ours the same property: at build time it copies
each to <name>.<sha256-8>.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 posixpath
import re import re
import shutil
from mkdocs.utils import get_relative_url 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 f'{match.group(1)}"{get_relative_url(index_urls[target], page.file.url)}"'
return _HREF.sub(fix, html) 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<stem>.+)\.(?P<hash>[0-9a-f]{%d})(?P<ext>\.[^.]+)$" % _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 <link href="…brand.css"> 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)