feat: write the site for Starlight behind a flag

generate_site.py --engine starlight writes the pages, navigation and
data files of an Astro Starlight project in website/. Links resolve to
site addresses, every heading keeps its MkDocs id, and pages split by
size are measured in the MkDocs Markdown so both builds cut the same
files. The 839 pages and their 45,613 ids match the MkDocs build.
This commit is contained in:
Abdessamad Derraz committed 2026-10-22 15:41:39 +02:00
1 parent d2974660eb
commit 342d0f7271
26 files changed
+7817 -60

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
// axe-core (WCAG 2.2 A/AA rules) in light and dark, one line per page and scheme.
// node axe.mjs <base url> <page>...
import { chromium } from 'playwright-core';
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const axeSource = await readFile(require.resolve('axe-core/axe.min.js'), 'utf8');
const [base, ...pages] = process.argv.slice(2);
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
for (const scheme of ['light', 'dark']) {
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, colorScheme: scheme });
const page = await context.newPage();
for (const path of pages) {
await page.goto(new URL(path, base).href, { waitUntil: 'networkidle' });
await page.addScriptTag({ content: axeSource });
const result = await page.evaluate(() =>
window.axe.run(document, { runOnly: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'] }));
const violations = result.violations.map((v) => `${v.id}(${v.nodes.length})`);
console.log(JSON.stringify({ page: path || '/', scheme, violations }));
}
await context.close();
}
await browser.close();
+44
View File
@@ -0,0 +1,44 @@
// End-to-end check of the two interactive pages, in a real browser.
// node check_tools.mjs <base url>
import { chromium } from 'playwright-core';
import assert from 'node:assert/strict';
const base = process.argv[2] ?? 'http://127.0.0.1:4321/retrobios/';
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
// Hash lookup: an MD5 lists every layer, a SHA1 permalink opens the card.
await page.goto(`${base}files/?hash=490f666e1afb15b7362b406ed1cea246`, { waitUntil: 'networkidle' });
const lookup = await page.textContent('#rb-file-lookup-status');
assert.match(lookup, /match/);
console.log('lookup md5:', lookup.slice(0, 160).replace(/\s+/g, ' '));
await page.goto(`${base}files/?sha1=0555c6fae8906f3f09baf5988f00e55f88e9f30b`, { waitUntil: 'networkidle' });
assert.ok(page.url().includes('systems/sony/#sha1-0555c6fae890'), page.url());
console.log('permalink sha1 ->', page.url());
// Picker on Get your files: Batocera, PlayStation only.
const transfers = [];
page.on('response', async (response) => {
if (response.url().includes('/data/picker/')) transfers.push([response.url(), (await response.body()).length]);
});
await page.goto(`${base}which-pack/?platform=batocera&system=sony-playstation`, { waitUntil: 'networkidle' });
await page.waitForSelector('.rb-pick-output:not([hidden])');
const result = await page.textContent('.rb-pick-result');
const command = await page.textContent('.rb-pick-cmd code');
const rows = await page.locator('.rb-pick-output tbody tr').count();
console.log('picker:', result);
console.log('command:', command);
console.log('rows:', rows, 'fetched:', transfers.map(([url, bytes]) => `${url.split('/').pop()} ${bytes} B`).join(', '));
assert.match(command, /--platform batocera --system sony-playstation$/);
assert.ok(rows > 0);
// Unchecking everything empties the result; "All" restores the full command.
await page.click('[data-none]');
assert.match(await page.textContent('.rb-pick-result'), /Choose at least one system/);
await page.click('[data-all]');
assert.match(await page.textContent('.rb-pick-cmd code'), /--platform batocera$/);
assert.deepEqual(errors, []);
console.log('ok');
await browser.close();
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Lighthouse mobile (default form factor) on a list of pages, one summary line each.
python lighthouse.py <out dir> <name>=<base url> ... -- <page> ...
Needs `npx lighthouse` and Chrome at /usr/bin/google-chrome.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
CATEGORIES = ("performance", "accessibility", "best-practices", "seo")
def main() -> int:
args = sys.argv[1:]
out = Path(args.pop(0))
split = args.index("--")
engines = dict(item.split("=", 1) for item in args[:split])
pages = args[split + 1:]
out.mkdir(parents=True, exist_ok=True)
env = {**os.environ, "CHROME_PATH": "/usr/bin/google-chrome"}
for name, base in engines.items():
for page in pages:
slug = page.strip("/").replace("/", "_") or "home"
report = out / f"{name}-{slug}.json"
# Under WSL chrome-launcher names its profile folder with a Windows path,
# created relative to the working directory: run it inside the output folder.
subprocess.run(
["npx", "--prefix", str(Path(__file__).resolve().parent.parent), "lighthouse", base + page,
"--quiet", "--output=json", f"--output-path={report.resolve()}",
"--chrome-flags=--headless=new --no-sandbox",
f"--only-categories={','.join(CATEGORIES)}"],
env=env, cwd=out, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
data = json.loads(report.read_text())
cats, audits = data["categories"], data["audits"]
failed = [k for k, a in audits.items()
if a.get("score") is not None and a["score"] < 1 and a.get("scoreDisplayMode") == "binary"]
print(json.dumps({
"engine": name, "page": page or "/",
**{c: round(cats[c]["score"] * 100) for c in CATEGORIES},
"lcp": audits["largest-contentful-paint"]["displayValue"],
"tbt": audits["total-blocking-time"]["displayValue"],
"cls": audits["cumulative-layout-shift"]["displayValue"],
"bytes": audits["total-byte-weight"]["displayValue"],
"dom": audits["dom-size"]["displayValue"],
"failed": failed,
}))
return 0
if __name__ == "__main__":
sys.exit(main())
+106
View File
@@ -0,0 +1,106 @@
// Page weight and search measurements in a real browser.
// node measure.mjs weight <base url> <page>...
// node measure.mjs search <base url> starlight|mkdocs <query>...
// Transfer sizes are bytes on the wire (gzip from serve.mjs), by type.
import { chromium } from 'playwright-core';
const [mode, base, ...rest] = process.argv.slice(2);
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
const track = (page) => {
const rows = [];
page.on('response', async (response) => {
const request = response.request();
const headers = await response.allHeaders();
let bytes = Number(headers['content-length'] ?? 0);
if (!bytes) {
try { bytes = (await response.body()).length; } catch { bytes = 0; }
}
rows.push({ url: response.url(), type: request.resourceType(), bytes });
});
return rows;
};
const sum = (rows, test) => rows.filter(test).reduce((total, row) => total + row.bytes, 0);
if (mode === 'weight') {
for (const path of rest) {
const context = await browser.newContext({ viewport: { width: 390, height: 844 } });
const page = await context.newPage();
const rows = track(page);
await page.goto(new URL(path, base).href, { waitUntil: 'networkidle' });
await page.waitForTimeout(500);
const dom = await page.evaluate(() => document.getElementsByTagName('*').length);
const kb = (n) => (n / 1024).toFixed(1);
console.log(JSON.stringify({
page: path,
html_kb: kb(sum(rows, (r) => r.type === 'document')),
js_kb: kb(sum(rows, (r) => r.type === 'script')),
css_kb: kb(sum(rows, (r) => r.type === 'stylesheet')),
other_kb: kb(sum(rows, (r) => !['document', 'script', 'stylesheet'].includes(r.type))),
total_kb: kb(sum(rows, () => true)),
requests: rows.length,
dom_nodes: dom,
}));
await context.close();
}
} else if (mode === 'search') {
const [engine, ...queries] = rest;
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await context.newPage();
const rows = track(page);
await page.goto(base, { waitUntil: 'networkidle' });
await page.waitForTimeout(500);
const before = rows.length;
const first = [];
for (const [n, query] of queries.entries()) {
const mark = rows.length;
let results;
if (engine === 'starlight-ui') {
// The reader's path: open the dialog, type, wait for the result list.
if (n === 0) await page.click('button[data-open-modal]');
const input = page.locator('dialog input[type="text"], dialog input[type="search"]').first();
await input.fill(query);
await page.waitForSelector('.pagefind-ui__result', { timeout: 15000 });
await page.waitForTimeout(1200);
results = await page.evaluate(() => {
const items = [...document.querySelectorAll('.pagefind-ui__result')];
return {
count: document.querySelector('.pagefind-ui__message')?.textContent ?? '',
top: items.slice(0, 5).map((r) => `${r.querySelector('.pagefind-ui__result-link')?.textContent?.trim()} | ${r.querySelector('.pagefind-ui__result-link')?.getAttribute('href')}`),
};
});
} else if (engine === 'starlight') {
results = await page.evaluate(async ({ query, root, ranking }) => {
if (!window.__pf) {
window.__pf = await import(`${root}pagefind/pagefind.js`);
if (ranking) await window.__pf.options({ ranking });
}
const search = await window.__pf.search(query);
const top = await Promise.all(search.results.slice(0, 5).map((r) => r.data()));
return { count: search.results.length, top: top.map((d) => `${d.meta.title} | ${d.url}`) };
}, { query, root: new URL('/retrobios/', base).href, ranking: process.env.RB_RANKING ? JSON.parse(process.env.RB_RANKING) : null });
} else {
// Material: open the search through its share link (?q=) and read the result list.
await page.goto(`${base}?q=${encodeURIComponent(query)}`, { waitUntil: 'networkidle' });
await page.waitForFunction(
(q) => document.querySelector('input[data-md-component="search-query"]')?.value === q
&& /matching|No matching/.test(document.querySelector('.md-search-result__meta')?.textContent ?? ''),
query, { timeout: 30000 },
);
await page.waitForTimeout(800);
results = await page.evaluate(() => {
const items = [...document.querySelectorAll('.md-search-result__item > a')];
const meta = document.querySelector('.md-search-result__meta')?.textContent ?? '';
return { count: meta, top: items.slice(0, 5).map((a) => `${a.querySelector('h1,h2')?.textContent?.trim()} | ${a.getAttribute('href')}`) };
});
}
await page.waitForTimeout(300);
const transfer = sum(rows.slice(mark), () => true);
if (n === 0) first.push(...rows.slice(before));
console.log(JSON.stringify({ query, transfer_kb: (transfer / 1024).toFixed(1), ...results }));
}
console.log(JSON.stringify({ first_search_kb: (sum(first, () => true) / 1024).toFixed(1), first_search_requests: first.length }));
await context.close();
}
await browser.close();
+51
View File
@@ -0,0 +1,51 @@
// Static server shaped like GitHub Pages for measurements: gzip, no brotli,
// max-age=600, the site under /retrobios/, real 404 page.
// node serve.mjs <site dir> <port>
import { createServer } from 'node:http';
import { readFile, stat } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { gzipSync } from 'node:zlib';
const [root = 'dist', port = '4321'] = process.argv.slice(2);
const BASE = '/retrobios/';
const TYPES = {
'.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript',
'.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
'.xml': 'application/xml', '.txt': 'text/plain', '.wasm': 'application/wasm',
};
const COMPRESS = new Set(['.html', '.css', '.js', '.json', '.svg', '.xml', '.txt']);
const resolveFile = async (path) => {
let file = join(root, normalize(path).replace(/^(\.\.[/\\])+/, ''));
try {
if ((await stat(file)).isDirectory()) file = join(file, 'index.html');
await stat(file);
return file;
} catch {
return null;
}
};
createServer(async (req, res) => {
const url = new URL(req.url, 'http://x');
if (!url.pathname.startsWith(BASE)) {
res.writeHead(302, { location: BASE }).end();
return;
}
const rel = decodeURIComponent(url.pathname.slice(BASE.length));
let file = await resolveFile(rel);
let status = 200;
if (!file) {
status = 404;
file = await resolveFile('404.html');
}
const ext = extname(file);
let body = await readFile(file);
const headers = { 'content-type': TYPES[ext] ?? 'application/octet-stream', 'cache-control': 'max-age=600' };
if (COMPRESS.has(ext) && /\bgzip\b/.test(req.headers['accept-encoding'] ?? '')) {
body = gzipSync(body);
headers['content-encoding'] = 'gzip';
}
headers['content-length'] = body.length;
res.writeHead(status, headers).end(body);
}).listen(Number(port), '127.0.0.1');
+25
View File
@@ -0,0 +1,25 @@
// Screenshots at 390 and 1440 px, light and dark.
// node shots.mjs <base url> <out dir> <page>...
import { chromium } from 'playwright-core';
import { mkdir } from 'node:fs/promises';
const [base, out, ...pages] = process.argv.slice(2);
await mkdir(out, { recursive: true });
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
for (const scheme of ['light', 'dark']) {
for (const width of [390, 1440]) {
const context = await browser.newContext({
viewport: { width, height: width === 390 ? 844 : 900 },
deviceScaleFactor: 1,
colorScheme: scheme,
});
const page = await context.newPage();
for (const path of pages) {
await page.goto(new URL(path, base).href, { waitUntil: 'networkidle' });
const name = path.replace(/^\/|\/$/g, '').replace(/[/?=&]+/g, '_') || 'home';
await page.screenshot({ path: `${out}/${name}-${width}-${scheme}.png`, fullPage: false });
}
await context.close();
}
}
await browser.close();