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:
1 parent
492a64fa43
commit
4f07c2d3ac
2 files changed
+128
-8
No files matched your search
@@ -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
@@ -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)
|
||||||
|
|
||||||
Reference in new issue
Block a user