build: content-hash cache-busting for local CSS/JS (?v=hash)
Deploy to Production on Develop Push / deploy (push) Successful in 20s
Deploy to Production on Develop Push / deploy (push) Successful in 20s
Stops deploys being served stale from browser cache — the recurring "changes do not show up until a hard refresh" problem. - New tools/cache_bust.py stamps a global content hash onto every local <link>/<script> CSS/JS ref AND the @import lines in css/main.css (so partial edits bust too). External (https://) refs are untouched. Deterministic and idempotent: same content -> same stamp, any asset edit -> new stamp sitewide. - Wired as the final step of tools/build_site.py so every build/deploy stamps. - Initial stamp applied across 259 pages + main.css (v=64a53dd6). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7c2110d8a5
commit
954f186761
262 files changed
+1518
-1435
No files matched your search
@@ -37,6 +37,9 @@ BUILD_STEPS = [
|
||||
("Inject SEO + rebuild sitemap/robots", ["tools/seo_inject.py", "--all", "--sitemap"]),
|
||||
("Rebuild bot/AI discovery files", ["tools/build_botlinks.py"]),
|
||||
("Rebuild offline assistant search index", ["tools/build_search_index.py"]),
|
||||
# Last: stamp a content-hash ?v= on local CSS/JS refs so deploys aren't
|
||||
# served stale from cache. Must run after every step that writes pages.
|
||||
("Cache-bust CSS/JS references", ["tools/cache_bust.py"]),
|
||||
]
|
||||
IMAGES_STEP = ("Optimise images to WebP + update refs", ["optimize_images.py"])
|
||||
CHECK_STEP = ("Verify rendered pages match content/", ["tools/build_content.py", "--check"])
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/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)
|
||||
Reference in new issue
Block a user