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:
co-authored by
Claude Opus 5.5
parent
7e13068dd2
commit
e58a11c2b4
Executable
+8
@@ -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
|
||||
Executable
+61
@@ -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
|
||||
Executable
+21
@@ -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
|
||||
@@ -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.');
|
||||
}
|
||||
Executable
+77
@@ -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()
|
||||
Executable
+103
@@ -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()
|
||||
Executable
+157
@@ -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()
|
||||
Executable
+75
@@ -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()
|
||||
@@ -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)));
|
||||
});
|
||||
@@ -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.`);
|
||||
Executable
+65
@@ -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())
|
||||
Reference in New Issue
Block a user