From ea07f346a9344bf71cfe51c706242943459b679c Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Wed, 26 Aug 2026 15:49:43 +0200 Subject: [PATCH] Cache-busting for temafiler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Malene får asset('theme/style.css') som gir en relativ URL med innholdshash (?v=…), beregnet ved bygging. Temafiler serveres nå med max-age=1 år + immutable; URL-en endres når innholdet gjør det, så besøkende får ny stil umiddelbart etter oppgradering. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JcEy43fNYpwg6K6oTKakWR --- README.md | 3 ++- soapbox/app.py | 5 +++- soapbox/build.py | 30 ++++++++++++++++++++---- soapbox/themes/green/templates/base.html | 4 ++-- tests/test_app.py | 25 ++++++++++++++++---- 5 files changed, 53 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 8ccc7eb..e4d962b 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,8 @@ cp soapbox/themes/green/static/style.css data/content/themes/skog/static/ # re Et tema kan være **delvis**: bare filene som finnes overstyrer det innebygde `green`-temaet. Vil du endre malene, legg `templates/base.html`, `index.html` eller `post.html` i temamappen. Malene får `site`, `posts`/`post`, `html`, `source_url` og `rel(sti)` (relativ lenke fra -gjeldende side), samt filtrene `date`, `excerpt(n)` og `tagslug`. `static/` havner under `/theme/`. +gjeldende side), samt filtrene `date`, `excerpt(n)` og `tagslug`. Bruk `asset('theme/fil.css')` for temafiler: +den gir en relativ URL med innholdshash (`?v=…`) for cache-busting. `static/` havner under `/theme/`. ## Lisens diff --git a/soapbox/app.py b/soapbox/app.py index ea0c2fb..cdbe15a 100644 --- a/soapbox/app.py +++ b/soapbox/app.py @@ -109,7 +109,10 @@ def _serve_public(cfg: Config, path: str): return redirect(request.path + "/", code=301) path = f"{path}index.html" if path else "index.html" resp = send_from_directory(root, path) - if path.startswith("theme/") or "." in os.path.basename(path) and not path.endswith(".html"): + if path.startswith("theme/"): + # URL-en inneholder innholdshash (?v=), så filene kan caches lenge + resp.headers["Cache-Control"] = "public, max-age=31536000, immutable" + elif "." in os.path.basename(path) and not path.endswith(".html"): resp.headers["Cache-Control"] = "public, max-age=3600" else: resp.headers["Cache-Control"] = "public, max-age=60" diff --git a/soapbox/build.py b/soapbox/build.py index 12dd142..8f814d3 100644 --- a/soapbox/build.py +++ b/soapbox/build.py @@ -14,6 +14,7 @@ URL-er; den bruker ``SOAPBOX_SITE_URL``. from __future__ import annotations +import hashlib import shutil from datetime import UTC, datetime from email.utils import format_datetime @@ -63,9 +64,33 @@ def build_site(cfg: Config) -> int: shutil.rmtree(tmp) tmp.mkdir(parents=True) + # Statiske temafiler kopieres først, så vi kan hashe dem for cache-busting: + # theme/style.css?v= endrer seg bare når innholdet gjør det, og + # nettleseren kan dermed cache filene lenge uten å vise gammel stil etter + # en oppgradering. Innebygd tema først, eget tema oppå (overskriver). + for d in reversed(cfg.theme_dirs): + if (d / "static").is_dir(): + shutil.copytree(d / "static", tmp / "theme", dirs_exist_ok=True) + versions = ( + { + f"theme/{f.relative_to(tmp / 'theme').as_posix()}": hashlib.sha256( + f.read_bytes() + ).hexdigest()[:8] + for f in (tmp / "theme").rglob("*") + if f.is_file() + } + if (tmp / "theme").is_dir() + else {} + ) + def render(template: str, page_path: str, **ctx) -> str: + def asset(target: str) -> str: + url = relative_to(page_path, target) + return f"{url}?v={versions[target]}" if target in versions else url + return env.get_template(template).render( site=site, + asset=asset, source_url=SOURCE_URL, site_url=cfg.site_url, # absolutt; kun for Open Graph/canonical page_path=page_path, @@ -138,11 +163,6 @@ def build_site(cfg: Config) -> int: _sitemap(cfg, posts, sorted(guests), sorted(tags), [u.username for u in users]), ) - # Statiske filer lagvis: innebygd først, eget tema oppå (overskriver). - for d in reversed(cfg.theme_dirs): - if (d / "static").is_dir(): - shutil.copytree(d / "static", tmp / "theme", dirs_exist_ok=True) - if out.exists(): old = out.with_name(out.name + ".old") if old.exists(): diff --git a/soapbox/themes/green/templates/base.html b/soapbox/themes/green/templates/base.html index c2c5d6c..b6bf9aa 100644 --- a/soapbox/themes/green/templates/base.html +++ b/soapbox/themes/green/templates/base.html @@ -5,8 +5,8 @@ {% block title %}{{ site.title }}{% endblock %} {% if site.description %}{% endif %} - - + + {# Open Graph krever absolutte URL-er; site_url er det eneste stedet det brukes utenom feed/sitemap. #} {% block og %} diff --git a/tests/test_app.py b/tests/test_app.py index 6e9f1b1..ad1ea11 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -60,8 +60,11 @@ def test_full_flow_owner(client, cfg): assert r.status_code == 301 and unquote(r.headers["Location"]).endswith("/grønt-er-skjønt/") html = client.get("/grønt-er-skjønt/").text assert "verden" in html - assert 'href="../theme/style.css"' in html - assert 'href="../theme/favicon.svg"' in html + m = re.search(r'href="\.\./theme/style\.css\?v=([0-9a-f]{8})"', html) + assert m, html[:600] + assert re.search(r'href="\.\./theme/favicon\.svg\?v=[0-9a-f]{8}"', html) + r = client.get(f"/theme/style.css?v={m.group(1)}") + assert r.status_code == 200 and "immutable" in r.headers["Cache-Control"] assert 'href="../"' in html # intern lenke omskrevet til relativ assert client.get("/grønt-er-skjønt/mitt-bilde.png").status_code == 200 front = client.get("/").text @@ -105,7 +108,7 @@ def test_guest_flow_and_isolation(client, cfg): assert (cfg.content_dir / "guests/gjest1/post-1/index.md").exists() html = client.get("/gjest1/post-1/").text - assert 'href="../../theme/style.css"' in html + assert re.search(r'href="\.\./\.\./theme/style\.css\?v=', html) assert "Gjest En" in html assert "Post 1" in client.get("/gjest1/").text assert "Post 1" in client.get("/").text # gjesteinnlegg vises også på forsiden @@ -146,7 +149,7 @@ def test_subpath_mount(tmp_path): r = client.post("/blog/admin/new", data={"csrf": csrf(client, "/blog")}) assert r.headers["Location"].startswith("/blog/admin/edit/") html = client.get("/blog/").text - assert 'href="theme/style.css"' in html + assert re.search(r'href="theme/style\.css\?v=', html) assert re.search(r'href="admin/"', html) assert client.get("/blog/theme/style.css").status_code == 200 @@ -215,7 +218,7 @@ def test_user_theme_in_content_repo(client, cfg): assert 'theme = "skog"' in (cfg.content_dir / "site.toml").read_text() assert cfg.theme == "skog" - assert client.get("/").text.startswith("SKOG theme/style.css") + assert client.get("/").text.startswith("SKOG theme/style.css") # rel() gir fortsatt ren sti assert client.get("/theme/style.css").text == ":root{--bg:#000}" assert client.get("/theme/favicon.svg").status_code == 200 # arvet fra green # post.html finnes ikke i skog → arves fra green @@ -428,3 +431,15 @@ def test_user_pages(client, cfg): data={"csrf": token, "title": "brukere", "body": "x", "action": "publish"}, ) assert not (cfg.content_dir / "posts/brukere").exists() + + +def test_asset_hash_changes_with_content(client, cfg): + login(client) + token = csrf(client) + before = re.search(r"style\.css\?v=([0-9a-f]{8})", client.get("/").text).group(1) + theme = cfg.content_dir / "themes" / "green2" / "static" + theme.mkdir(parents=True) + (theme / "style.css").write_text("body{color:red}") + client.post("/admin/settings", data={"csrf": token, "title": "T", "theme": "green2"}) + after = re.search(r"style\.css\?v=([0-9a-f]{8})", client.get("/").text).group(1) + assert before != after