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.
145 lines
5.6 KiB
Python
145 lines
5.6 KiB
Python
"""MkDocs hooks for the publishing layer.
|
|
|
|
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
|
|
|
|
_HREF = re.compile(r'(href=)("([^"]+)/")')
|
|
|
|
|
|
def on_page_content(html, page, config, files):
|
|
index_urls = {}
|
|
for f in files:
|
|
src = f.src_path
|
|
if src.endswith("/index.md"):
|
|
index_urls[posixpath.dirname(src)] = f.url
|
|
|
|
page_dir = posixpath.dirname(page.file.src_path)
|
|
|
|
def fix(match):
|
|
href = match.group(3)
|
|
if href.startswith(("http://", "https://", "/", "#")):
|
|
return match.group(0)
|
|
target = posixpath.normpath(posixpath.join(page_dir, href))
|
|
if target in (".", "..") or target not in index_urls:
|
|
return match.group(0)
|
|
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)
|
|
|