Cache-busting: immutable bare på versjonerte URL-er, streng asset()
Fra code review av ea07f34:
- /theme/* fikk max-age=1 år + immutable også uten ?v=; url("favicon.svg")
inne i CSS ville da sittet fast etter en temaendring. Nå bare med hash.
- asset() feiler ved bygging på ukjent temafil i stedet for å gi en
uversjonert URL i stillhet.
- rel() defineres én gang og gjenbrukes av asset(); dødt is_dir-guard
fjernet; docstring peker på asset() for temafiler.
- Tester: uversjonert forespørsel skal ikke være immutable; hash-format
sjekkes på nestede sider; ukjent asset gir feil.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcEy43fNYpwg6K6oTKakWR
This commit is contained in:
parent
ea07f346a9
commit
42b296ca06
3 changed files with 38 additions and 21 deletions
|
|
@ -109,8 +109,9 @@ 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/"):
|
if path.startswith("theme/") and request.args.get("v"):
|
||||||
# URL-en inneholder innholdshash (?v=), så filene kan caches lenge
|
# URL-en inneholder innholdshash (?v=), så filen kan caches «for alltid».
|
||||||
|
# Uten hash (f.eks. url("favicon.svg") inne i CSS) gjelder vanlig cache.
|
||||||
resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||||
elif "." in os.path.basename(path) and not path.endswith(".html"):
|
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"
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,10 @@ Utdata er rene filer som kan serveres av hva som helst – appen selv, Caddy
|
||||||
eller en CDN.
|
eller en CDN.
|
||||||
|
|
||||||
Alle lenker i genererte sider er relative. Malene får en ``rel()``-funksjon
|
Alle lenker i genererte sider er relative. Malene får en ``rel()``-funksjon
|
||||||
som regner ut stien fra *denne* siden til et mål, så ``rel("theme/style.css")``
|
som regner ut stien fra *denne* siden til et mål, så ``rel("post2/")`` blir
|
||||||
blir ``theme/style.css`` på forsiden og ``../../theme/style.css`` i et
|
``post2/`` på forsiden og ``../../post2/`` i et gjesteinnlegg. Temafiler
|
||||||
gjesteinnlegg. Dermed kan hele ``public/`` flyttes til hvilken som helst
|
refereres med ``asset("theme/style.css")``, som i tillegg legger på en
|
||||||
|
innholdshash (``?v=…``) for cache-busting. Dermed kan hele ``public/`` flyttes til hvilken som helst
|
||||||
sub-path uten rebuild. Unntaket er ``feed.xml``, der RSS krever absolutte
|
sub-path uten rebuild. Unntaket er ``feed.xml``, der RSS krever absolutte
|
||||||
URL-er; den bruker ``SOAPBOX_SITE_URL``.
|
URL-er; den bruker ``SOAPBOX_SITE_URL``.
|
||||||
"""
|
"""
|
||||||
|
|
@ -71,22 +72,24 @@ def build_site(cfg: Config) -> int:
|
||||||
for d in reversed(cfg.theme_dirs):
|
for d in reversed(cfg.theme_dirs):
|
||||||
if (d / "static").is_dir():
|
if (d / "static").is_dir():
|
||||||
shutil.copytree(d / "static", tmp / "theme", dirs_exist_ok=True)
|
shutil.copytree(d / "static", tmp / "theme", dirs_exist_ok=True)
|
||||||
versions = (
|
versions = {
|
||||||
{
|
f"theme/{f.relative_to(tmp / 'theme').as_posix()}": hashlib.sha256(
|
||||||
f"theme/{f.relative_to(tmp / 'theme').as_posix()}": hashlib.sha256(
|
f.read_bytes()
|
||||||
f.read_bytes()
|
).hexdigest()[:8]
|
||||||
).hexdigest()[:8]
|
for f in (tmp / "theme").rglob("*")
|
||||||
for f in (tmp / "theme").rglob("*")
|
if f.is_file()
|
||||||
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 rel(target: str) -> str:
|
||||||
|
return relative_to(page_path, target)
|
||||||
|
|
||||||
def asset(target: str) -> str:
|
def asset(target: str) -> str:
|
||||||
url = relative_to(page_path, target)
|
# Feil raskt: en skrivefeil i temaet skal synes ved lagring, ikke
|
||||||
return f"{url}?v={versions[target]}" if target in versions else url
|
# ende som en 404 som er cachet i et år.
|
||||||
|
if target not in versions:
|
||||||
|
raise ValueError(f"asset(): fant ikke temafilen {target!r}")
|
||||||
|
return f"{rel(target)}?v={versions[target]}"
|
||||||
|
|
||||||
return env.get_template(template).render(
|
return env.get_template(template).render(
|
||||||
site=site,
|
site=site,
|
||||||
|
|
@ -94,7 +97,7 @@ def build_site(cfg: Config) -> int:
|
||||||
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,
|
||||||
rel=lambda target: relative_to(page_path, target),
|
rel=rel,
|
||||||
now=datetime.now(UTC),
|
now=datetime.now(UTC),
|
||||||
**ctx,
|
**ctx,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@ def test_full_flow_owner(client, cfg):
|
||||||
assert re.search(r'href="\.\./theme/favicon\.svg\?v=[0-9a-f]{8}"', html)
|
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)}")
|
r = client.get(f"/theme/style.css?v={m.group(1)}")
|
||||||
assert r.status_code == 200 and "immutable" in r.headers["Cache-Control"]
|
assert r.status_code == 200 and "immutable" in r.headers["Cache-Control"]
|
||||||
|
# uten hash (f.eks. url("favicon.svg") fra CSS) skal cachen være kort
|
||||||
|
assert "immutable" not in client.get("/theme/favicon.svg").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
|
||||||
|
|
@ -108,7 +110,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 re.search(r'href="\.\./\.\./theme/style\.css\?v=', html)
|
assert re.search(r'href="\.\./\.\./theme/style\.css\?v=[0-9a-f]{8}"', 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
|
||||||
|
|
@ -149,7 +151,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 re.search(r'href="theme/style\.css\?v=', html)
|
assert re.search(r'href="theme/style\.css\?v=[0-9a-f]{8}"', 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
|
||||||
|
|
||||||
|
|
@ -443,3 +445,14 @@ def test_asset_hash_changes_with_content(client, cfg):
|
||||||
client.post("/admin/settings", data={"csrf": token, "title": "T", "theme": "green2"})
|
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)
|
after = re.search(r"style\.css\?v=([0-9a-f]{8})", client.get("/").text).group(1)
|
||||||
assert before != after
|
assert before != after
|
||||||
|
|
||||||
|
|
||||||
|
def test_asset_unknown_file_fails_build(client, cfg):
|
||||||
|
theme = cfg.content_dir / "themes" / "feil" / "templates"
|
||||||
|
theme.mkdir(parents=True)
|
||||||
|
(theme / "index.html").write_text("{{ asset('theme/finnes-ikke.css') }}")
|
||||||
|
login(client)
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="finnes-ikke"):
|
||||||
|
client.post("/admin/settings", data={"csrf": csrf(client), "title": "T", "theme": "feil"})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue