review round 2: deterministic checks (npm run check: build + zero-dep link checker; tsconfig with docs-recommended include/exclude, astro check dropped — OOMs at 12GB on a 200-entry collection), sitemap static pages derived from route files, non-recursive content glob (id/slug trap), --header-h token, honest CDN comments (sveltia UI fonts), preview.css drift (prose a wrap, img block, h1 size)

This commit is contained in:
2026-09-03 15:11:43 +02:00
parent cec8018c18
commit c4d21b59be
8 changed files with 1068 additions and 13 deletions
+999
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -6,9 +6,14 @@
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
"preview": "astro preview",
"check": "astro build && node scripts/check-links.mjs"
},
"dependencies": {
"astro": "^7.2.10"
},
"devDependencies": {
"@astrojs/check": "^0.9.10",
"typescript": "^6.0.3"
}
}
+38
View File
@@ -0,0 +1,38 @@
// Deterministic internal-link check over the built site (zero dependencies).
// Run AFTER `astro build`: scans dist/**/*.html for root-relative href/src
// values and fails (exit 1) if any target has no file in dist/. External
// URLs, mailto:, data: and pure-#fragment links are out of scope.
import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
import { join } from 'node:path';
const DIST = new URL('../dist', import.meta.url).pathname;
if (!existsSync(DIST)) { console.error('dist/ not found — run `npm run build` first'); process.exit(1); }
const htmlFiles = [];
(function walk(dir) {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) walk(p);
else if (name.endsWith('.html')) htmlFiles.push(p);
}
})(DIST);
const resolves = (path) => {
const clean = decodeURI(path.split(/[?#]/)[0]);
if (clean === '/') return true;
return ['', '.html', '/index.html'].some((suffix) =>
existsSync(join(DIST, clean.replace(/\/$/, '') + suffix)));
};
let broken = 0;
for (const file of htmlFiles) {
const html = readFileSync(file, 'utf8');
for (const [, , url] of html.matchAll(/\s(href|src)="(\/[^"]*)"/g)) {
if (!resolves(url)) {
console.error(`broken: ${url} (in ${file.slice(DIST.length + 1)})`);
broken++;
}
}
}
console.log(broken ? `${broken} broken internal link(s)` : `OK — ${htmlFiles.length} pages, all internal links resolve`);
process.exit(broken ? 1 : 0);
+3 -1
View File
@@ -5,7 +5,9 @@ import { glob } from 'astro/loaders';
// This is exactly what Sveltia CMS edits — the CMS writes these files, Astro
// renders them to static HTML at build time.
const news = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/news' }),
// Top-level only ('*.md', not '**'): entry ids feed a non-rest [slug]
// route, and an id from a subfolder would contain '/' and break it.
loader: glob({ pattern: '*.md', base: './src/content/news' }),
schema: z.object({
title: z.string(),
description: z.string(),
+2 -1
View File
@@ -122,7 +122,8 @@ const readMin = readMinOf(post);
let observer;
const observe = () => {
observer?.disconnect();
// Narrow band below the sticky header (56px) — Starlight's rootMargin trick
// Narrow band below the sticky header (--header-h 56px + 12px slack;
// JS can't read the token cheaply, keep in sync) — Starlight's rootMargin trick
const top = 68, band = 64;
observer = new IntersectionObserver(onIntersect, {
rootMargin: `-${top}px 0px ${top + band - document.documentElement.clientHeight}px`,
+10 -3
View File
@@ -13,10 +13,17 @@ export async function GET(context) {
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
const pages = await getCollection('pages');
// Go-live note: /login and /dashboard are mock pages — drop them from this
// list (or gate them) before robots.txt stops disallowing everything.
// Static pages come from the actual route files, so a new src/pages/*.astro
// can never be forgotten here. 404 is excluded; /admin (subdirectory) is
// intentionally unlisted. Go-live note: /login and /dashboard are mock
// pages — filter them out before robots.txt stops disallowing everything.
const staticPages = Object.keys(import.meta.glob('./*.astro'))
.map((p) => p.slice(1, -'.astro'.length))
.filter((p) => p !== '/404')
.map((p) => (p === '/index' ? '/' : p))
.sort();
const urls = [
'/', '/news', '/proposals', '/dashboard', '/support', '/search', '/login',
...staticPages, '/news',
...Array.from({ length: newsPageCount(news.length) - 1 }, (_, i) => `/news/${i + 2}`),
...pages.map((p) => `/pages/${p.id}`),
].map((path) => ` <url><loc>${new URL(path, site).href}</loc></url>`);
+7 -6
View File
@@ -45,6 +45,7 @@
--space-m: 24px;
--space-l: clamp(24px, 2vw + 16px, 40px);
--space-xl: clamp(56px, 7vw, 120px); /* section rhythm */
--header-h: 56px; /* sticky header; anchors, TOC rail, progress bar derive from it */
--space-band: clamp(72px, 10vw, 160px); /* hero / auth / landing-head padding */
/* Elevation — navy-tinted, soft/large/low-alpha; the ONLY two shadows allowed */
--shadow-ambient: 0 1px 2px rgba(0, 0, 74, 0.04), 0 8px 24px rgba(0, 0, 74, 0.06);
@@ -121,7 +122,7 @@ html { -webkit-text-size-adjust: 100%; }
it — so each typed character walked the page toward 0. Scoping the offset
to the in-content anchor targets fixes typing and keeps every jump
(TOC headings, skip link → #main) landing below the header. */
main, main [id] { scroll-margin-top: 76px; }
main, main [id] { scroll-margin-top: calc(var(--header-h) + 20px); }
/* Guard, not a solution (DESIGN.md): every wide element is contained at its own
level (tables scroll in their box, long words wrap); clip only catches
regressions so one offender can never remove the page gutters. `clip`, unlike
@@ -197,7 +198,7 @@ img { max-width: 100%; height: auto; display: block; }
-webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px);
}
}
.nav { display: flex; align-items: center; gap: 4px; min-height: 56px; flex-wrap: nowrap; }
.nav { display: flex; align-items: center; gap: 4px; min-height: var(--header-h); flex-wrap: nowrap; }
.nav > nav { display: flex; align-items: center; gap: 4px; }
/* One-row guarantee: everything keeps its natural size except the spacer
(links are single words, they can never wrap) */
@@ -568,9 +569,9 @@ span.page-step { color: var(--muted); } /* disabled end stop — non-interactive
.article-layout { grid-template-columns: minmax(0, 70ch) 15rem; column-gap: var(--space-xl); justify-content: space-between; }
.toc--inline { display: none; }
.toc--rail {
display: block; position: sticky; top: 80px; align-self: start;
display: block; position: sticky; top: calc(var(--header-h) + 24px); align-self: start;
margin-top: calc(var(--space-l) + 44px); /* aligns with the article h1 */
max-height: calc(100dvh - 104px); overflow: auto;
max-height: calc(100dvh - var(--header-h) - 48px); overflow: auto;
border-left: 1px solid var(--border); padding-left: var(--space-m);
}
/* Active rule segment: 2px Cherenkov over the hairline, tracking the link */
@@ -705,7 +706,7 @@ mark { background: var(--tint-cherenkov); color: inherit; border-radius: calc(va
/* Auth (login mock) — nebula backdrop + floating card; the ONLY navy band
besides the home hero */
.auth-band {
min-height: calc(100dvh - 56px); position: relative;
min-height: calc(100dvh - var(--header-h)); position: relative;
display: grid; place-items: center; padding: var(--space-band) var(--gutter);
background: var(--nebula), var(--galaxy);
}
@@ -819,7 +820,7 @@ mark { background: var(--tint-cherenkov); color: inherit; border-radius: calc(va
/* Article reading progress — functional state, same Cherenkov budget line
as the TOC scrollspy rule (navigation feedback, not decoration). */
.read-progress {
position: fixed; top: 56px; left: 0; width: 100%; height: 3px; z-index: 9;
position: fixed; top: var(--header-h); left: 0; width: 100%; height: 3px; z-index: 9;
background: var(--cherenkov); transform-origin: 0 50%;
animation: progress linear both; animation-timeline: scroll(root);
}
+3 -1
View File
@@ -1,3 +1,5 @@
{
"extends": "astro/tsconfigs/base"
"extends": "astro/tsconfigs/base",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}