Cache-busting for temafiler

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcEy43fNYpwg6K6oTKakWR
This commit is contained in:
Ole-Morten Duesund 2026-08-26 15:49:43 +02:00
commit ea07f346a9
5 changed files with 53 additions and 14 deletions

View file

@ -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. 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. 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 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 ## Lisens

View file

@ -109,7 +109,10 @@ def _serve_public(cfg: Config, path: str):
return redirect(request.path + "/", code=301) return redirect(request.path + "/", code=301)
path = f"{path}index.html" if path else "index.html" path = f"{path}index.html" if path else "index.html"
resp = send_from_directory(root, path) 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" resp.headers["Cache-Control"] = "public, max-age=3600"
else: else:
resp.headers["Cache-Control"] = "public, max-age=60" resp.headers["Cache-Control"] = "public, max-age=60"

View file

@ -14,6 +14,7 @@ URL-er; den bruker ``SOAPBOX_SITE_URL``.
from __future__ import annotations from __future__ import annotations
import hashlib
import shutil import shutil
from datetime import UTC, datetime from datetime import UTC, datetime
from email.utils import format_datetime from email.utils import format_datetime
@ -63,9 +64,33 @@ def build_site(cfg: Config) -> int:
shutil.rmtree(tmp) shutil.rmtree(tmp)
tmp.mkdir(parents=True) tmp.mkdir(parents=True)
# Statiske temafiler kopieres først, så vi kan hashe dem for cache-busting:
# theme/style.css?v=<hash> 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 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( return env.get_template(template).render(
site=site, site=site,
asset=asset,
source_url=SOURCE_URL, source_url=SOURCE_URL,
site_url=cfg.site_url, # absolutt; kun for Open Graph/canonical site_url=cfg.site_url, # absolutt; kun for Open Graph/canonical
page_path=page_path, 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]), _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(): if out.exists():
old = out.with_name(out.name + ".old") old = out.with_name(out.name + ".old")
if old.exists(): if old.exists():

View file

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ site.title }}{% endblock %}</title> <title>{% block title %}{{ site.title }}{% endblock %}</title>
{% if site.description %}<meta name="description" content="{{ site.description }}">{% endif %} {% if site.description %}<meta name="description" content="{{ site.description }}">{% endif %}
<link rel="stylesheet" href="{{ rel('theme/style.css') }}"> <link rel="stylesheet" href="{{ asset('theme/style.css') }}">
<link rel="icon" type="image/svg+xml" href="{{ rel('theme/favicon.svg') }}"> <link rel="icon" type="image/svg+xml" href="{{ asset('theme/favicon.svg') }}">
<link rel="alternate" type="application/rss+xml" title="{{ site.title }}" href="{{ rel('feed.xml') }}"> <link rel="alternate" type="application/rss+xml" title="{{ site.title }}" href="{{ rel('feed.xml') }}">
{# Open Graph krever absolutte URL-er; site_url er det eneste stedet det brukes utenom feed/sitemap. #} {# Open Graph krever absolutte URL-er; site_url er det eneste stedet det brukes utenom feed/sitemap. #}
{% block og %} {% block og %}

View file

@ -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/") 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 html = client.get("/grønt-er-skjønt/").text
assert "<strong>verden</strong>" in html assert "<strong>verden</strong>" in html
assert 'href="../theme/style.css"' in html m = re.search(r'href="\.\./theme/style\.css\?v=([0-9a-f]{8})"', html)
assert 'href="../theme/favicon.svg"' in 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 'href="../"' in html # intern lenke omskrevet til relativ
assert client.get("/grønt-er-skjønt/mitt-bilde.png").status_code == 200 assert client.get("/grønt-er-skjønt/mitt-bilde.png").status_code == 200
front = client.get("/").text 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() assert (cfg.content_dir / "guests/gjest1/post-1/index.md").exists()
html = client.get("/gjest1/post-1/").text 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 "Gjest En" in html
assert "Post 1" in client.get("/gjest1/").text assert "Post 1" in client.get("/gjest1/").text
assert "Post 1" in client.get("/").text # gjesteinnlegg vises også på forsiden 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")}) r = client.post("/blog/admin/new", data={"csrf": csrf(client, "/blog")})
assert r.headers["Location"].startswith("/blog/admin/edit/") assert r.headers["Location"].startswith("/blog/admin/edit/")
html = client.get("/blog/").text 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 re.search(r'href="admin/"', html)
assert client.get("/blog/theme/style.css").status_code == 200 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 'theme = "skog"' in (cfg.content_dir / "site.toml").read_text()
assert cfg.theme == "skog" 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/style.css").text == ":root{--bg:#000}"
assert client.get("/theme/favicon.svg").status_code == 200 # arvet fra green assert client.get("/theme/favicon.svg").status_code == 200 # arvet fra green
# post.html finnes ikke i skog → arves 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"}, data={"csrf": token, "title": "brukere", "body": "x", "action": "publish"},
) )
assert not (cfg.content_dir / "posts/brukere").exists() 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