msos/tools/cache_bust.py

81 lines
3.1 KiB
Python

#!/usr/bin/env python3
"""
cache_bust.py -- stamp a content-hash version onto local CSS/JS references so a
deploy is picked up immediately instead of serving stale cached assets.
Why: assets are served without far-future immutable caching AND `main.css` pulls
its 25 partials via `@import` (whose URLs the browser caches independently). So a
CSS/JS change could stay invisible until the browser revalidated. This appends
`?v=<hash>` to:
- every local `<link href=... .css>` / `<script src=... .js>` in the pages, and
- every `@import '...css'` inside css/main.css (so partial edits bust too).
The version is one global short md5 of ALL css+js content (with any existing
`?v=` stripped first), so it is deterministic and idempotent: same content ->
same stamp (no diff); any asset edit -> new stamp everywhere. External
(https://) references are never touched.
Run last in the build (after content/SEO/listings). Idempotent.
"""
import io, os, re, sys, glob, hashlib
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
VER = re.compile(r'\?v=[0-9a-f]{8}')
def rd(p):
with io.open(p, encoding="utf-8") as f: return f.read()
def wr(p, s):
with io.open(p, "w", encoding="utf-8", newline="\n") as f: f.write(s)
def asset_files():
files = glob.glob(os.path.join(ROOT, "css", "**", "*.css"), recursive=True)
files += glob.glob(os.path.join(ROOT, "*.css"))
files += glob.glob(os.path.join(ROOT, "*.js"))
return sorted(set(files))
def compute_hash():
h = hashlib.md5()
for p in asset_files():
rel = os.path.relpath(p, ROOT).replace("\\", "/")
# strip any existing ?v= so the hash depends only on real content
h.update((rel + "\0" + VER.sub("", rd(p))).encode("utf-8"))
h.update(b"\0")
return h.hexdigest()[:8]
def html_pages():
pats = []
for lang in ("en", "mk", "si"):
pats += glob.glob(os.path.join(ROOT, lang, "**", "*.html"), recursive=True)
pats += glob.glob(os.path.join(ROOT, "*.html"))
return sorted(set(pats))
# local href="....css" / src="....js" (not http), optional existing ?v
REF = re.compile(r'((?:href|src)=")(?!https?:)([^"?]+\.(?:css|js))(?:\?v=[0-9a-f]{8})?(")')
# @import '....css' (single- or double-quoted), optional existing ?v
IMP = re.compile(r"""(@import\s+['"][^'"?]+?\.css)(?:\?v=[0-9a-f]{8})?(['"])""")
def stamp(text, ver):
text = REF.sub(lambda m: f'{m.group(1)}{m.group(2)}?v={ver}{m.group(3)}', text)
text = IMP.sub(lambda m: f'{m.group(1)}?v={ver}{m.group(2)}', text)
return text
def run(check=False):
ver = compute_hash()
changed = 0
targets = html_pages() + [os.path.join(ROOT, "css", "main.css")]
for p in targets:
s = rd(p)
s2 = stamp(s, ver)
if s2 != s:
changed += 1
if not check:
wr(p, s2)
print(f"cache-bust v={ver}: {'would update' if check else 'updated'} {changed} file(s) of {len(targets)}")
return changed
if __name__ == "__main__":
check = "--check" in sys.argv
n = run(check=check)
# In --check mode, a non-zero count means the site is out of sync.
sys.exit(1 if (check and n) else 0)