feat: flyt sitet fra Astro til Zola

Samme indhold, URL'er og udseende, bygget af en enkelt zola-binær og Tailwind
standalone-CLI uden Node. Kanonisk domæne er asger.weirsoe.dk.

Blogindlæg ligger som page bundles med frontmatter Zola forstår, så de gamle
stempler er ugyldige, indtil scripts/omstempl.py er kørt med PUBLISH_KEY.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Asger Geel Weirsøe
2026-11-01 16:47:08 +01:00
co-authored by Claude Opus 5.5
parent 7e13068dd2
commit e58a11c2b4
159 changed files with 8031 additions and 3574 deletions
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
# Bygger sitet til public/. PW_PREVIEW=1 giver preview-buildet under /udkast.
set -eu
TOOLS="${TOOLS:-.tools}"
python3 scripts/prebuild.py
"$TOOLS/tailwindcss" --input assets/site.css --output static/css/site.css --minify
"$TOOLS/zola" build
python3 scripts/postbuild.py
+61
View File
@@ -0,0 +1,61 @@
"""Godkendelsesstemplet på blogindlæg: HMAC-SHA256 over sti, indhold og billeder.
canonical() skal matche godkendelsestjenesten (service/app.py i agw/pw-drafts) byte for byte.
"""
import hashlib
import hmac
import os
import re
BLOG = "content/blog"
def canonical(rel, text, assets=()):
"""assets: [(sti, sha256-hex)] for billederne i bundlen, sorteret efter sti."""
lines = text.replace("\r\n", "\n").split("\n")
if lines and lines[0] == "---" and "---" in lines[1:]:
end = lines.index("---", 1)
lines = [l for i, l in enumerate(lines) if not (0 < i < end and re.match(r"\s*godkendt:", l))]
return f"{rel}\n" + "\n".join(lines) + "".join(f"\n@asset {p} {h}" for p, h in assets)
def sign(key, rel, text, assets=()):
return "v1:" + hmac.new(key, canonical(rel, text, assets).encode(), hashlib.sha256).hexdigest()
def sha256_file(path):
with open(path, "rb") as f:
return hashlib.sha256(f.read()).hexdigest()
def assets_for(slug):
"""Alt i bundlen ud over index.md er billeder, og de ligger fladt."""
d = f"{BLOG}/{slug}"
out = []
for name in os.listdir(d):
if name == "index.md":
continue
p = f"{d}/{name}"
if not os.path.isfile(p):
raise ValueError(f"{p}: undermapper understøttes ikke")
if name.endswith(".md"):
raise ValueError(f"{p}: kun index.md må være markdown i en bundle")
out.append((p, sha256_file(p)))
return sorted(out)
def read_text(path):
with open(path, "rb") as f:
return f.read().decode()
def stamp_of(text):
"""(stempel eller None, om indlægget er markeret draft: true)"""
lines = text.replace("\r\n", "\n").split("\n")
end = lines.index("---", 1) if lines[0] == "---" and "---" in lines[1:] else -1
if end < 0:
return None, False
fm = lines[1:end]
g = next((l for l in fm if re.match(r"\s*godkendt:", l)), None)
draft = any(re.fullmatch(r"draft:\s*true\s*", l) for l in fm)
return (re.sub(r"^\s*godkendt:\s*", "", g).strip("'\"") if g else None), draft
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
# Henter zola og Tailwind standalone-CLI til .tools/, fastlåst på version og sha256.
# Bruges af CI her og i agw/pw-drafts.
set -eu
ZOLA=0.23.6
ZOLA_SHA256=7d77a4220a4f699cba2fc0e0c7859f780e887e269159ad4fab90e11ea16c906f
TAILWIND=4.3.3
TAILWIND_SHA256=dc61b3ac6b8c9ca874c0cc4c57b2409791a64c5540404ca5f5367360babc313a
mkdir -p .tools
curl -fsSL -o .tools/zola.tar.gz \
"https://github.com/getzola/zola/releases/download/v$ZOLA/zola-v$ZOLA-x86_64-unknown-linux-musl.tar.gz"
curl -fsSL -o .tools/tailwindcss \
"https://github.com/tailwindlabs/tailwindcss/releases/download/v$TAILWIND/tailwindcss-linux-x64"
sha256sum -c - <<EOF
$ZOLA_SHA256 .tools/zola.tar.gz
$TAILWIND_SHA256 .tools/tailwindcss
EOF
tar -xzf .tools/zola.tar.gz -C .tools
rm .tools/zola.tar.gz
chmod +x .tools/zola .tools/tailwindcss
-90
View File
@@ -1,90 +0,0 @@
// Pinger IndexNow med de sider der er ændret i dette push.
// Kør efter `astro build` (bruger dist/sitemap-0.xml).
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
const HOST = 'asger.xn--weirse-fya.dk';
const KEY = 'b68b31655cb9c1a662d53905dd0b1b06';
const ORIGIN = `https://${HOST}`;
const git = (args) => {
try {
return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
} catch {
return null;
}
};
const sitemapUrls = () => {
const xml = fs.readFileSync('dist/sitemap-0.xml', 'utf8');
return new Set([...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]));
};
// src/pages/foo.astro -> /foo/ ; src/content/blog/bar.md -> /blog/bar/
const routeForFile = (f) => {
let m = f.match(/^src\/pages\/(.+)\.astro$/);
if (m) {
if (m[1].includes('[')) return null; // dynamiske ruter kan vi ikke slå op
if (m[1] === 'index') return '/';
return `/${m[1].replace(/\/index$/, '')}/`;
}
m = f.match(/^src\/content\/blog\/(.+)\.md$/);
if (m) return `/blog/${m[1]}/`;
return null;
};
const changedFiles = () => {
const { GITHUB_EVENT_BEFORE, GITHUB_SHA } = process.env;
const zero = /^0*$/;
if (GITHUB_EVENT_BEFORE && !zero.test(GITHUB_EVENT_BEFORE)) {
const d = git(['diff', '--name-only', GITHUB_EVENT_BEFORE, GITHUB_SHA || 'HEAD']);
if (d !== null) return d ? d.split('\n') : [];
}
const d = git(['diff', '--name-only', 'HEAD~1', 'HEAD']);
return d ? d.split('\n') : null;
};
const files = changedFiles();
if (files === null) {
console.log('IndexNow: kan ikke bestemme hvad der ændrede sig (shallow clone?) — springer over.');
process.exit(0);
}
const valid = sitemapUrls();
const urls = [...new Set(files.map(routeForFile).filter(Boolean).map((r) => ORIGIN + r))].filter((u) =>
valid.has(u),
);
if (!urls.length) {
console.log('IndexNow: ingen sider med ændret indhold i dette push — springer over.');
process.exit(0);
}
const body = {
host: HOST,
key: KEY,
keyLocation: `${ORIGIN}/${KEY}.txt`,
urlList: urls,
};
console.log(`IndexNow: indsender ${urls.length} URL(er):`);
for (const u of urls) console.log(' ', u);
// `node scripts/indexnow.mjs --dry-run` viser hvad der ville blive sendt.
if (process.argv.includes('--dry-run')) {
console.log('IndexNow: --dry-run, sender ikke.');
process.exit(0);
}
const res = await fetch('https://api.indexnow.org/indexnow', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(body),
});
console.log(`IndexNow: HTTP ${res.status} ${res.statusText}`);
if (![200, 202].includes(res.status)) {
console.log(await res.text().catch(() => ''));
console.log('IndexNow: afvist — men deployet er allerede ude, så vi fejler ikke buildet.');
}
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Pinger IndexNow med de sider der er ændret i dette push. Kør efter buildet
(bruger public/sitemap-0.xml). --dry-run viser hvad der ville blive sendt."""
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.request
HOST = "asger.weirsoe.dk"
KEY = "b68b31655cb9c1a662d53905dd0b1b06"
ORIGIN = f"https://{HOST}"
def git(*args):
r = subprocess.run(["git", *args], capture_output=True, text=True)
return r.stdout.strip() if r.returncode == 0 else None
def sitemap_urls():
with open("public/sitemap-0.xml") as f:
return set(re.findall(r"<loc>([^<]+)</loc>", f.read()))
def route_for_file(f):
"""Kanonisk form uden afsluttende skråstreg, som i sitemap'et."""
m = re.fullmatch(r"content/(.+?)(/index|/_index|_index)?\.md", f)
if m:
return "/" + m[1].rstrip("/") if m[1] != "_index" else "/"
m = re.fullmatch(r"templates/sider/(.+)\.html", f) or re.fullmatch(r"data/(.+)\.json", f)
if m:
name = {"forside": "", "forside-en": "en", "profiles": "profiler"}.get(m[1], m[1])
return "/en/" + name[:-3] if name.endswith("-en") else "/" + name
return None
def changed_files():
before, sha = os.environ.get("GITHUB_EVENT_BEFORE"), os.environ.get("GITHUB_SHA")
if before and not re.fullmatch(r"0*", before):
d = git("diff", "--name-only", before, sha or "HEAD")
if d is not None:
return d.split("\n") if d else []
d = git("diff", "--name-only", "HEAD~1", "HEAD")
return d.split("\n") if d else None
def main():
files = changed_files()
if files is None:
print("IndexNow: kan ikke bestemme hvad der ændrede sig (shallow clone?), springer over.")
return
valid = sitemap_urls()
urls = sorted({ORIGIN + r for r in map(route_for_file, files) if r} & valid)
if not urls:
print("IndexNow: ingen sider med ændret indhold i dette push, springer over.")
return
print(f"IndexNow: indsender {len(urls)} URL(er):")
for u in urls:
print(" ", u)
if "--dry-run" in sys.argv:
print("IndexNow: --dry-run, sender ikke.")
return
body = {"host": HOST, "key": KEY, "keyLocation": f"{ORIGIN}/{KEY}.txt", "urlList": urls}
req = urllib.request.Request("https://api.indexnow.org/indexnow", data=json.dumps(body).encode(),
headers={"Content-Type": "application/json; charset=utf-8"}, method="POST")
try:
with urllib.request.urlopen(req, timeout=30) as r:
print(f"IndexNow: HTTP {r.status} {r.reason}")
except urllib.error.URLError as e:
# Deployet er allerede ude, så vi fejler ikke buildet.
print(f"IndexNow: afvist: {e}")
if __name__ == "__main__":
main()
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Stempler de udgivne indlæg om efter flytningen fra src/content/blog/<slug>.md til
content/blog/<slug>/index.md. Stien og frontmatter indgår i stemplet, så de gamle er ugyldige.
Kør på zola-branchen, med master stadig på Astro-versionen:
PUBLISH_KEY=<hex> python3 scripts/omstempl.py [--fra master]
PUBLISH_KEY=<hex> python3 scripts/verify-approvals.py
git commit -am "blog: stempl indlæg om efter flytning til Zola"
Scriptet underskriver kun det der allerede var godkendt. For hvert indlæg tjekker det at
det gamle stempel er gyldigt, og at den nye fil er præcis den gamle i nyt format. Fejler
ét indlæg, skrives der intet.
"""
import argparse
import hashlib
import hmac
import os
import re
import subprocess
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from godkendelse import BLOG, assets_for, read_text, sign, stamp_of # noqa: E402
OLD = "src/content/blog"
def git(*args, binary=False):
return subprocess.run(["git", *args], check=True, capture_output=True, text=not binary).stdout
def old_sign(key, rel, text, assets):
"""Det gamle format: stemplet stod som topniveau-linje og er det eneste der skæres fra."""
lines = text.replace("\r\n", "\n").split("\n")
end = lines.index("---", 1)
lines = [l for i, l in enumerate(lines) if not (0 < i < end and l.startswith("godkendt:"))]
tail = "".join(f"\n@asset {p} {h}" for p, h in assets)
body = f"{rel}\n" + "\n".join(lines) + tail
return "v1:" + hmac.new(key, body.encode(), hashlib.sha256).hexdigest()
def migrated(text, stamp):
"""Den gamle fil i det nye format: tags under taxonomies, stemplet under extra."""
lines = text.split("\n")
end = lines.index("---", 1)
head = []
for l in lines[1:end]:
if l.startswith("godkendt:"):
continue
head += ["taxonomies:", " " + l] if l.startswith("tags:") else [l]
return "\n".join(["---", *head, "extra:", f' godkendt: "{stamp}"', *lines[end:]])
def old_assets(ref, slug):
out = git("ls-tree", "--name-only", ref, f"{OLD}/{slug}/").split()
return sorted((p, hashlib.sha256(git("show", f"{ref}:{p}", binary=True)).hexdigest()) for p in out)
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--fra", default="master", help="ref med de gamle, godkendte indlæg")
args = ap.parse_args()
if not os.environ.get("PUBLISH_KEY"):
sys.exit("PUBLISH_KEY mangler.")
key = bytes.fromhex(os.environ["PUBLISH_KEY"])
slugs = sorted(n for n in os.listdir(BLOG) if os.path.isdir(f"{BLOG}/{n}"))
new, errors = {}, []
for slug in slugs:
rel = f"{BLOG}/{slug}/index.md"
old_rel = f"{OLD}/{slug}.md"
try:
old = git("show", f"{args.fra}:{old_rel}")
except subprocess.CalledProcessError:
errors.append(f"{rel}: findes ikke i {args.fra} som {old_rel}; kun godkendelsesflowet må stemple nyt")
continue
text = read_text(rel)
stamp_gl, draft = stamp_of(old)
o_assets = old_assets(args.fra, slug)
n_assets = assets_for(slug)
if draft or not stamp_gl or not hmac.compare_digest(stamp_gl, old_sign(key, old_rel, old, o_assets)):
errors.append(f"{old_rel}: det gamle stempel er ikke gyldigt med denne nøgle")
continue
if text != migrated(old, stamp_of(text)[0]):
errors.append(f"{rel}: indholdet er ændret i forhold til {args.fra}")
continue
if [h for _, h in o_assets] != [h for _, h in n_assets]:
errors.append(f"{rel}: billederne er ændret i forhold til {args.fra}")
continue
new[rel] = migrated(old, sign(key, rel, text, n_assets))
if errors:
print("\n".join(f"✗ {e}" for e in errors), file=sys.stderr)
sys.exit(f"\n{len(errors)} fejl. Intet er skrevet.")
for rel, text in new.items():
with open(rel, "w", encoding="utf-8", newline="") as f:
f.write(text)
print(f"✓ {rel}")
print(f"\n{len(new)} indlæg stemplet om. Kør scripts/verify-approvals.py og commit.")
if __name__ == "__main__":
main()
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Efterbehandling af Zolas output i public/:
- overskrift-id'er som på den gamle side (bevarer æøå, så gamle #ankre stadig virker)
- sitemap-index.xml + sitemap-0.xml med lastmod fra git og frontmatter
- sw.js med precache-liste
- PW_PREVIEW=1: hele sitet flyttes ind under /udkast, uden sitemap og service worker
"""
import hashlib
import html
import json
import os
import pathlib
import re
import subprocess
OUT = pathlib.Path("public")
SITE = "https://asger.weirsoe.dk"
PREVIEW = os.environ.get("PW_PREVIEW") == "1"
BASE = "/udkast"
def pages():
return sorted(OUT.rglob("*.html"))
def slug(text):
"""Samme regler som github-slugger, som den gamle build brugte."""
s = html.unescape(re.sub(r"<[^>]+>", "", text)).strip().lower()
s = re.sub(r"[^\w\- ]", "", s)
return s.replace(" ", "-")
def heading_ids():
for f in pages():
src = f.read_text()
if 'class="prose' not in src:
continue
seen = {}
def fix(m):
s = slug(m[3])
n = seen.get(s, 0)
seen[s] = n + 1
return f'<h{m[1]} id="{s}-{n}"{m[2]}>' if n else f'<h{m[1]} id="{s}"{m[2]}>'
f.write_text(re.sub(r'<h([1-6]) id="[^"]*"([^>]*)>(.*?)(?=</h\1>)',
lambda m: fix(m) + m[3], src, flags=re.S))
def git_date(*files):
if int(subprocess.run(["git", "rev-list", "--count", "HEAD"], capture_output=True, text=True).stdout or 0) < 2:
return None
out = subprocess.run(["git", "log", "-1", "--format=%cI", "--", *files], capture_output=True, text=True).stdout
return out.strip() or None
def frontmatter(path):
text = path.read_text()
head = text.split("---\n", 2)[1]
return {k: v.strip().strip("'\"") for k, v in re.findall(r"^\s*(\w+):(.*)$", head, re.M)}
def iso(date):
return f"{date}T00:00:00.000Z"
def lastmods():
"""Rute -> lastmod: sidste commit for statiske sider, frontmatter for blogindlæg."""
routes = {}
for md in pathlib.Path("content").rglob("*.md"):
fm = frontmatter(md)
if "blog" in md.parts or md.parts[1] == "projekter" or "projects" in md.parts:
continue
route = "/" + str(md.relative_to("content").with_suffix("")).removesuffix("_index")
route = route.rstrip("/") or "/"
tpl = fm.get("template")
d = git_date(str(md), f"templates/{tpl}") if tpl else git_date(str(md))
if d:
routes[route] = d
posts = []
for md in pathlib.Path("content/blog").glob("*/index.md"):
fm = frontmatter(md)
d = iso(fm.get("updated") or fm["date"])
routes[f"/blog/{md.parent.name}"] = d
posts.append(d)
if posts:
routes["/blog"] = max(posts)
return routes
def sitemap():
skip = ("/tags", "/peppol")
skip_exact = ("/cookies", "/status", "/chat")
routes = []
for f in pages():
rel = f.relative_to(OUT)
if rel.name != "index.html":
continue
r = "/" + str(rel.parent).replace(".", "", 1) if str(rel.parent) == "." else f"/{rel.parent}"
if r.startswith(skip) or r in skip_exact:
continue
routes.append(r)
routes.append("/tarballz/")
mods = lastmods()
urls = []
for r in sorted(routes):
mod = mods.get(r)
urls.append(f"<url><loc>{SITE}{r}</loc>" + (f"<lastmod>{mod}</lastmod>" if mod else "")
+ f"<changefreq>weekly</changefreq><priority>{'1.0' if r == '/' else '0.7'}</priority></url>")
ns = 'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"'
(OUT / "sitemap-0.xml").write_text(f'<?xml version="1.0" encoding="UTF-8"?><urlset {ns}>{"".join(urls)}</urlset>')
newest = max(mods.values(), default=None)
(OUT / "sitemap-index.xml").write_text(
f'<?xml version="1.0" encoding="UTF-8"?><sitemapindex {ns}><sitemap><loc>{SITE}/sitemap-0.xml</loc>'
+ (f"<lastmod>{newest}</lastmod>" if newest else "") + "</sitemap></sitemapindex>")
def service_worker():
"""Precache-listen afhænger af buildet, resten af sw.js ligger i scripts/sw.js."""
exts = {".html", ".css", ".js", ".svg", ".png", ".woff2", ".xml", ".txt", ".webmanifest"}
files = sorted(p for p in OUT.rglob("*") if p.is_file() and p.suffix in exts
and p.name not in ("sw.js", "mermaid.min.js"))
h = hashlib.sha256()
for p in files:
h.update(p.read_bytes())
urls = ["/" + str(p.relative_to(OUT)) for p in files]
src = pathlib.Path("scripts/sw.js").read_text()
src = src.replace("__VERSION__", h.hexdigest()[:12]).replace("__URLS__", json.dumps(urls, ensure_ascii=False))
(OUT / "sw.js").write_text(src)
def rebase():
"""Rod-relative links og billeder i HTML peger ind under /udkast."""
attr = re.compile(r'\b(href|src|action)="/(?!/|udkast)')
srcset = re.compile(r'\bsrcset="([^"]*)"')
def fix_set(m):
return 'srcset="' + re.sub(r"(^|, )/(?!/)", rf"\1{BASE}/", m[1]) + '"'
for f in pages():
src = f.read_text()
f.write_text(srcset.sub(fix_set, attr.sub(rf'\1="{BASE}/', src)))
def main():
(OUT / "sitemap.xml").unlink(missing_ok=True)
heading_ids()
if PREVIEW:
rebase()
else:
sitemap()
service_worker()
if __name__ == "__main__":
main()
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Henter det sitet viser fra Gitea ved build og skriver data/gitea.json:
repos til /projekter og aktivitets-heatmap til /now. Er Gitea nede, bygges sitet
alligevel, med en fejlbesked på /projekter og en tom graf på /now."""
import datetime as dt
import json
import urllib.request
BASE, USER = "https://gitea.weircon.dk", "agw"
HIDE = set() # repo-navne der skjules helt
PINNED = ["ubl-tools", "gtk-ahfail", "pi3-smart-workspace"] # vises først, i den rækkefølge
WEEKS = 26
MAANEDER = ["januar", "februar", "marts", "april", "maj", "juni", "juli", "august",
"september", "oktober", "november", "december"]
def get(path):
req = urllib.request.Request(f"{BASE}/api/v1{path}", headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
def repos():
data = get(f"/users/{USER}/repos?limit=50&sort=updated")
rs = [r for r in data if not (r["fork"] or r["archived"] or r["private"] or r["name"].lower() in HIDE)]
pinned = [p.lower() for p in PINNED]
rs.sort(key=lambda r: r["updated_at"], reverse=True)
rs.sort(key=lambda r: pinned.index(r["name"].lower()) if r["name"].lower() in pinned else len(pinned))
keep = ("name", "description", "html_url", "language", "stars_count")
return [{k: r[k] for k in keep} for r in rs]
def level(n):
return 0 if n == 0 else 1 if n < 3 else 2 if n < 6 else 3 if n < 12 else 4
def heatmap():
per_day = {}
try:
for b in get(f"/users/{USER}/heatmap"):
d = dt.datetime.fromtimestamp(b["timestamp"], dt.timezone.utc).date().isoformat()
per_day[d] = per_day.get(d, 0) + b["contributions"]
except Exception as e:
print(f"heatmap: {e}")
# 26 uger bagud, uger som kolonner (man-søn)
today = dt.date.today()
cur = today - dt.timedelta(days=WEEKS * 7 - 1 + today.weekday())
weeks = []
while cur <= today:
week = []
for _ in range(7):
if cur > today:
break
n = per_day.get(cur.isoformat(), 0)
week.append({"date": cur.isoformat(), "n": n, "level": level(n)})
cur += dt.timedelta(days=1)
weeks.append(week)
return weeks, sum(per_day.values()), f"{today.day}. {MAANEDER[today.month - 1]} {today.year}"
def main():
try:
rs, error = repos(), False
except Exception as e:
print(f"repos: {e}")
rs, error = [], True
weeks, total, updated = heatmap()
with open("data/gitea.json", "w") as f:
json.dump({"repos": rs, "error": error, "weeks": weeks, "weeks_shown": WEEKS,
"total": total, "updated": updated}, f, ensure_ascii=False)
print(f"gitea: {len(rs)} repos, {total} bidrag")
if __name__ == "__main__":
main()
+24
View File
@@ -0,0 +1,24 @@
// Håndskrevet service worker: precacher sitet, så det virker offline.
// postbuild.py sætter version og URL-liste ind. Alt der ikke er precachet, går til netværket.
const CACHE = 'pw-__VERSION__';
const URLS = __URLS__;
const known = new Set(URLS);
self.addEventListener('install', (e) => {
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(URLS)).then(() => self.skipWaiting()));
});
// Rydder forrige version, også de gamle workbox-caches.
self.addEventListener('activate', (e) => {
e.waitUntil(caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim()));
});
self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url);
if (e.request.method !== 'GET' || url.origin !== location.origin) return;
const p = url.pathname;
const key = [p, p.replace(/\/?$/, '/index.html')].find((k) => known.has(k));
if (key) e.respondWith(caches.match(key).then((r) => r || fetch(e.request)));
});
-79
View File
@@ -1,79 +0,0 @@
// Stopper buildet hvis et blogindlæg mangler et gyldigt `godkendt:`-stempel
// (HMAC-SHA256 over sti, indhold og billeder).
//
// PUBLISH_KEY=<hex> node scripts/verify-approvals.mjs
import crypto from 'node:crypto';
import fs from 'node:fs';
const DIR = 'src/content/blog';
const key = process.env.PUBLISH_KEY;
if (!key) {
console.error('PUBLISH_KEY mangler; kan ikke verificere godkendelser.');
process.exit(1);
}
// Skal matche godkendelsestjenesten byte for byte.
const canonical = (relPath, text, assets = []) => {
const lines = text.replace(/\r\n/g, '\n').split('\n');
if (lines[0] === '---') {
const end = lines.indexOf('---', 1);
if (end > 0) {
for (let i = end - 1; i > 0; i--) if (/^godkendt:/.test(lines[i])) lines.splice(i, 1);
}
}
const tail = assets.map(([p, h]) => `\n@asset ${p} ${h}`).join('');
return `${relPath}\n${lines.join('\n')}${tail}`;
};
const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
// Billeder til <slug>.md ligger fladt i mappen <slug>/ ved siden af.
const assetsFor = (rel) => {
const dir = rel.replace(/\.mdx?$/, '');
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir, { withFileTypes: true })
.map((e) => {
if (!e.isFile()) throw new Error(`${dir}/${e.name}: undermapper understøttes ikke`);
const p = `${dir}/${e.name}`;
return [p, sha256(fs.readFileSync(p))];
})
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
};
const stampOf = (text) => {
const lines = text.replace(/\r\n/g, '\n').split('\n');
const end = lines[0] === '---' ? lines.indexOf('---', 1) : -1;
if (end < 0) return { stamp: null, draft: false };
const fm = lines.slice(1, end);
const g = fm.find((l) => /^godkendt:/.test(l));
const draft = fm.some((l) => /^draft:\s*true\s*$/.test(l));
return { stamp: g ? g.replace(/^godkendt:\s*/, '').replace(/^['"]|['"]$/g, '') : null, draft };
};
const entries = fs.readdirSync(DIR, { withFileTypes: true });
const files = entries.filter((e) => e.isFile() && /\.mdx?$/.test(e.name)).map((e) => `${DIR}/${e.name}`);
let bad = 0;
// En billedmappe uden et indlæg ved siden af er aldrig blevet godkendt.
for (const e of entries.filter((e) => e.isDirectory())) {
if (!files.some((f) => f.replace(/\.mdx?$/, '') === `${DIR}/${e.name}`)) {
console.error(`✗ ${DIR}/${e.name}/: filer uden et godkendt indlæg`); bad++;
}
}
for (const rel of files) {
const text = fs.readFileSync(rel, 'utf8');
const { stamp, draft } = stampOf(text);
if (draft) { console.error(`✗ ${rel}: udkast (draft: true) må ikke ligge i det offentlige repo`); bad++; continue; }
if (!stamp) { console.error(`✗ ${rel}: mangler godkendelse`); bad++; continue; }
let assets;
try { assets = assetsFor(rel); } catch (err) { console.error(`✗ ${err.message}`); bad++; continue; }
const want = 'v1:' + crypto.createHmac('sha256', Buffer.from(key, 'hex')).update(canonical(rel, text, assets)).digest('hex');
const ok = stamp.length === want.length && crypto.timingSafeEqual(Buffer.from(stamp), Buffer.from(want));
if (!ok) { console.error(`✗ ${rel}: godkendelsen passer ikke til indholdet (ændret efter godkendelse?)`); bad++; continue; }
console.log(`✓ ${rel}${assets.length ? ` (+${assets.length} billeder)` : ''}`);
}
if (bad) {
console.error(`\n${bad} indlæg er ikke godkendt. Intet bliver udgivet.`);
process.exit(1);
}
console.log(`\nAlle ${files.length} indlæg er godkendt.`);
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Stopper buildet hvis et blogindlæg mangler et gyldigt `godkendt:`-stempel.
PUBLISH_KEY=<hex> python3 scripts/verify-approvals.py
"""
import hmac
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from godkendelse import BLOG, assets_for, read_text, sign, stamp_of # noqa: E402
def main():
key = os.environ.get("PUBLISH_KEY")
if not key:
print("PUBLISH_KEY mangler; kan ikke verificere godkendelser.", file=sys.stderr)
return 1
key = bytes.fromhex(key)
bad = ok = 0
def fail(msg):
nonlocal bad
print(f"✗ {msg}", file=sys.stderr)
bad += 1
for name in sorted(os.listdir(BLOG)):
path = f"{BLOG}/{name}"
if name == "_index.md":
continue
# Zola bygger enhver .md under content/blog, så intet må ligge uden for en bundle.
if not os.path.isdir(path):
fail(f"{path}: et indlæg skal ligge som {BLOG}/<slug>/index.md")
continue
rel = f"{path}/index.md"
if not os.path.isfile(rel):
fail(f"{path}/: filer uden et godkendt indlæg")
continue
text = read_text(rel)
stamp, draft = stamp_of(text)
if draft:
fail(f"{rel}: udkast (draft: true) må ikke ligge i det offentlige repo")
continue
if not stamp:
fail(f"{rel}: mangler godkendelse")
continue
try:
assets = assets_for(name)
except ValueError as e:
fail(str(e))
continue
if not hmac.compare_digest(stamp, sign(key, rel, text, assets)):
fail(f"{rel}: godkendelsen passer ikke til indholdet (ændret efter godkendelse?)")
continue
ok += 1
print(f"✓ {rel}" + (f" (+{len(assets)} billeder)" if assets else ""))
if bad:
print(f"\n{bad} indlæg er ikke godkendt. Intet bliver udgivet.", file=sys.stderr)
return 1
print(f"\nAlle {ok} indlæg er godkendt.")
return 0
if __name__ == "__main__":
sys.exit(main())