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
+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.
|
||||
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
|
||||
<dir>/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
|
||||
<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 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<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