explainmyrepo 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,631 @@
1
+ #!/usr/bin/env node
2
+ // assemble-page.mjs — Station 6 (ADR-0005): THE central render.
3
+ //
4
+ // Compose BuildContext.content + the per-repo theme (concept "expression knobs") + every asset path
5
+ // onto the shared assets/design-system/design-system.css + its section archetypes -> ONE
6
+ // self-contained, accessible site/ (index.html · styles.css · sitemap.xml · robots.txt · llms.txt).
7
+ // Rendered ONCE from typed slots — no string-coupled HTML markers (INV-10). Pure + fail-loud
8
+ // (tools/CONTRACT.md (a)/(b)/(c); ADR-0005 D4 / Station 6; DDD §8.6, INV-09/10/13/14/15).
9
+ //
10
+ // Usage: node tools/assemble-page.mjs <build-dir>
11
+ //
12
+ // Reads (declared inputs, read-only):
13
+ // <build-dir>/build.json slices → repo · concept · content · visuals · brand · kb.primerPath
14
+ // (+ pack.zipPath if present, for the download link only)
15
+ // assets/design-system/design-system.css (the shared recipe stylesheet — fixed dependency)
16
+ // the asset files named in visuals.* / brand.* / kb.primerPath (copied into site/, never mutated)
17
+ //
18
+ // Writes (outputs):
19
+ // <build-dir>/site/{index.html, styles.css, sitemap.xml, robots.txt, llms.txt}
20
+ // <build-dir>/site/assets/* (copies of the referenced images / SVGs / favicons / social card)
21
+ // merges ONLY the `page` slot back into build.json (every other slot byte-for-byte untouched)
22
+ //
23
+ // stdout: ONE JSON result object. stderr: diagnostics. exit 0 iff ok:true; any failure → exit!=0.
24
+ //
25
+ // ── content schema this renderer expects (the brain/render contract; missing essentials fail loud) ──
26
+ // concept: { metaphor, tagline, copyVoice?, heroConcept?,
27
+ // palette:{ <knob>:value, … } // knob ∈ design-system expression knobs
28
+ // typePersonality?:{ display?,sans?,mono?,fontHref?,
29
+ // displayWeight?,displayCase?,displayTracking? } }
30
+ // content.sections:
31
+ // hero: { eyebrow?, headline | headlineHtml, lede, sub?, ctas?[{label,href,ghost?}],
32
+ // meta?[{label,value}], plain? }
33
+ // problem: { title, lead?, paragraphs?, note?:{kind?,label?,text} }
34
+ // whatItIs: { title, lead?, paragraphs?, table?:{caption?,head[],rows[][]} }
35
+ // insight: { title, lead?, paragraphs?, oh }
36
+ // howItWorks: { title, lead?, paragraphs? } // arch + flow SVGs are MANDATORY
37
+ // useCases: { title, intro?, cases:[{title,tag?,paragraphs?|body?,code?,dl?[{term,desc}]}] }
38
+ // getStarted: { title, intro?, install?, steps:[ string | {strong,text} ] }
39
+ // pack: { title, intro?, tree?, downloadLabel? } // human primer SHIPS IN the pack zip, never inlined
40
+ // content.arc?: [{ question, section, altitude }] // per-section arc question override
41
+
42
+ import fs from 'node:fs';
43
+ import path from 'node:path';
44
+ import { fileURLToPath } from 'node:url';
45
+
46
+ const HERE = path.dirname(fileURLToPath(import.meta.url)); // tools/
47
+ const REPO_ROOT = path.resolve(HERE, '..');
48
+ const DS_CSS = path.join(REPO_ROOT, 'assets', 'design-system', 'design-system.css');
49
+
50
+ // ── result / failure plumbing (CONTRACT (b)·5/6) ─────────────────────────────────────────────
51
+ function ok(outputs) {
52
+ process.stdout.write(JSON.stringify({ ok: true, outputs, error: null }) + '\n');
53
+ process.exit(0);
54
+ }
55
+ function fail(error) {
56
+ process.stderr.write(`assemble-page: ${error}\n`);
57
+ process.stdout.write(JSON.stringify({ ok: false, outputs: {}, error: String(error) }) + '\n');
58
+ process.exit(1);
59
+ }
60
+
61
+ // ── small helpers (kb/ style) ────────────────────────────────────────────────────────────────
62
+ function readJSON(p) {
63
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
64
+ catch (e) { throw new Error(`cannot read JSON ${p}: ${e.message}`); }
65
+ }
66
+ function must(p, what) { if (!p || !fs.existsSync(p)) throw new Error(`missing ${what || 'input'}: ${p}`); return p; }
67
+ function reqStr(v, name) { if (typeof v !== 'string' || !v.trim()) throw new Error(`${name} is required (non-empty string)`); return v; }
68
+ function reqArr(v, name) { if (!Array.isArray(v) || v.length === 0) throw new Error(`${name} is required (non-empty array)`); return v; }
69
+ function reqObj(v, name) { if (!v || typeof v !== 'object' || Array.isArray(v)) throw new Error(`${name} is required (object)`); return v; }
70
+
71
+ const esc = (s) => String(s)
72
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
73
+
74
+ // minimal, SAFE inline markdown: `code`, [text](url), **bold**, *italic*. Everything escaped first.
75
+ function inline(s) {
76
+ let t = esc(String(s));
77
+ t = t.replace(/`([^`]+)`/g, (_, c) => `<code>${c}</code>`);
78
+ t = t.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, txt, url) => {
79
+ const safe = /^(https?:|#|\/|mailto:)/.test(url) ? url : '#';
80
+ const ext = /^https?:/.test(safe) ? ' target="_blank" rel="noopener"' : '';
81
+ return `<a href="${esc(safe)}"${ext}>${txt}</a>`;
82
+ });
83
+ t = t.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
84
+ t = t.replace(/\*([^*]+)\*/g, '<em>$1</em>');
85
+ return t;
86
+ }
87
+ function paras(x) {
88
+ if (!x) return '';
89
+ const arr = Array.isArray(x) ? x : String(x).split(/\n\s*\n/);
90
+ return arr.filter((s) => String(s).trim()).map((s) => `<p>${inline(String(s).trim())}</p>`).join('\n ');
91
+ }
92
+
93
+ // resolve an asset path from build.json (absolute, or relative to the build dir) → copy into site/assets.
94
+ function copyAsset(rawPath, buildDir, siteAssets, what) {
95
+ const src = path.isAbsolute(rawPath) ? rawPath : path.resolve(buildDir, rawPath);
96
+ must(src, what);
97
+ const base = path.basename(src);
98
+ fs.copyFileSync(src, path.join(siteAssets, base));
99
+ return base;
100
+ }
101
+
102
+ // ── theme block: concept.palette / typePersonality → :root expression-knob overrides ───────────
103
+ const KNOBS = new Set([
104
+ 'bg', 'bg-2', 'surface', 'surface-2', 'ridge', 'ink', 'ink-2', 'muted', 'faint', 'on-accent',
105
+ 'accent', 'accent-2', 'accent-3', 'spectrum', 'ok', 'warn', 'bad',
106
+ 'display', 'sans', 'mono', 'display-weight', 'display-case', 'display-tracking',
107
+ 'radius', 'radius-s', 'ease', 'hero-grad-angle',
108
+ ]);
109
+ function normKnob(k) { return String(k).replace(/^--/, '').trim().toLowerCase(); }
110
+ function safeCssValue(v, knob) {
111
+ const val = String(v).trim();
112
+ if (!val) throw new Error(`concept.palette['${knob}'] is empty`);
113
+ if (/[;{}<>]/.test(val) || /javascript:/i.test(val) || /url\(/i.test(val)) {
114
+ throw new Error(`concept.palette['${knob}'] contains a disallowed value: ${val}`);
115
+ }
116
+ return val;
117
+ }
118
+ function buildTheme(concept) {
119
+ const palette = reqObj(concept.palette, 'concept.palette');
120
+ const decls = [];
121
+ const tokensUsed = [];
122
+ let colorScheme = null;
123
+ for (const [rawK, rawV] of Object.entries(palette)) {
124
+ const k = normKnob(rawK);
125
+ if (k === 'color-scheme' || k === 'colorscheme') { colorScheme = safeCssValue(rawV, 'color-scheme'); continue; }
126
+ if (!KNOBS.has(k)) { process.stderr.write(`assemble-page: ignoring unknown palette knob '${rawK}'\n`); continue; }
127
+ decls.push([`--${k}`, safeCssValue(rawV, k)]);
128
+ tokensUsed.push(`--${k}`);
129
+ }
130
+ if (!tokensUsed.includes('--accent')) throw new Error("concept.palette must define 'accent' (the cohesion anchor)");
131
+
132
+ const tp = concept.typePersonality;
133
+ let fontHref = null;
134
+ if (tp && typeof tp === 'object') {
135
+ const map = { display: 'display', sans: 'sans', mono: 'mono', displayWeight: 'display-weight', displayCase: 'display-case', displayTracking: 'display-tracking' };
136
+ for (const [key, knob] of Object.entries(map)) {
137
+ if (tp[key] != null && String(tp[key]).trim()) { decls.push([`--${knob}`, safeCssValue(tp[key], knob)]); tokensUsed.push(`--${knob}`); }
138
+ }
139
+ const fh = tp.fontHref || tp.fontImport;
140
+ if (fh) {
141
+ const m = String(fh).match(/https:\/\/[^\s'")]+/);
142
+ if (!m) throw new Error('concept.typePersonality.fontHref must be an https font URL');
143
+ fontHref = m[0];
144
+ }
145
+ }
146
+ const rootBlock = [
147
+ '/* ── per-repo THEME (expression knobs only — skeleton untouched) ── */',
148
+ ':root {',
149
+ colorScheme ? ` color-scheme: ${colorScheme};` : null,
150
+ ...decls.map(([k, v]) => ` ${k}: ${v};`),
151
+ '}',
152
+ ].filter(Boolean).join('\n');
153
+
154
+ return { css: rootBlock, tokensUsed, fontHref, accent: (decls.find(([k]) => k === '--accent') || [])[1] };
155
+ }
156
+
157
+ // ── section render helpers ─────────────────────────────────────────────────────────────────────
158
+ const ARC = {
159
+ problem: { id: 'problem', q: 'Why does this exist?' },
160
+ whatItIs: { id: 'what-it-is', q: 'What does it actually do?' },
161
+ insight: { id: 'the-insight', q: 'Why is it elegant?' },
162
+ howItWorks: { id: 'how-it-works', q: 'How is it built?' },
163
+ useCases: { id: 'use-cases', q: 'Could I use this?' },
164
+ getStarted: { id: 'get-started', q: 'How do I start?' },
165
+ pack: { id: 'the-pack', q: 'Does my AI get it too?' },
166
+ };
167
+ function noteHtml(n) {
168
+ if (!n || !n.text) return '';
169
+ const kind = n.kind === 'warn' ? ' warn' : n.kind === 'honest' ? ' honest' : '';
170
+ const lab = n.label ? `<span class="lab">${esc(n.label)}</span>` : '';
171
+ return `\n <p class="note${kind}">${lab}${inline(n.text)}</p>`;
172
+ }
173
+ function figureHtml(base, alt, caption, opts = {}) {
174
+ const cls = opts.diagram ? (opts.concept ? 'figure diagram concept' : 'figure diagram') : 'figure';
175
+ const tier = opts.tier ? `\n <span class="tier ${opts.tier.cls}">${esc(opts.tier.label)}</span>` : '';
176
+ const cap = caption ? `\n <figcaption>${inline(caption)}</figcaption>` : '';
177
+ return `\n <figure class="${cls}">${tier}\n <img src="assets/${esc(base)}" alt="${esc(alt)}" loading="lazy">${cap}\n </figure>`;
178
+ }
179
+ function tableHtml(t) {
180
+ if (!t || !Array.isArray(t.head) || !Array.isArray(t.rows)) return '';
181
+ const head = `<tr>${t.head.map((h) => `<th>${inline(h)}</th>`).join('')}</tr>`;
182
+ const body = t.rows.map((r) => `<tr>${r.map((c) => `<td>${inline(c)}</td>`).join('')}</tr>`).join('\n ');
183
+ const cap = t.caption ? `<caption class="visually-hidden">${esc(t.caption)}</caption>` : '';
184
+ return `\n <table class="tbl">${cap}\n <thead>${head}</thead>\n <tbody>\n ${body}\n </tbody>\n </table>`;
185
+ }
186
+ function sectionShell(key, num, sec, arcQ, bodyHtml) {
187
+ const meta = ARC[key];
188
+ const q = arcQ || meta.q;
189
+ return `
190
+ <details class="section" id="${meta.id}" open>
191
+ <summary>
192
+ <span class="num">${num}</span>
193
+ <span class="head-text">
194
+ <h2>${inline(sec.title)}</h2>
195
+ <span class="q">${esc(q)}</span>
196
+ </span>
197
+ <span class="chev" aria-hidden="true">&rsaquo;</span>
198
+ </summary>
199
+ <div class="body">
200
+ ${bodyHtml}
201
+ </div>
202
+ </details>`;
203
+ }
204
+
205
+ // ── MAIN ─────────────────────────────────────────────────────────────────────────────────────
206
+ function main() {
207
+ const buildDirArg = process.argv[2];
208
+ if (!buildDirArg) throw new Error('usage: node tools/assemble-page.mjs <build-dir>');
209
+ const buildDir = path.resolve(process.cwd(), buildDirArg);
210
+ must(buildDir, 'build directory');
211
+ const buildJsonPath = path.join(buildDir, 'build.json');
212
+ const ctx = readJSON(must(buildJsonPath, 'build.json'));
213
+
214
+ // --- declared inputs (read-only) ---
215
+ const repo = reqObj(ctx.repo, 'repo');
216
+ const slug = reqStr(repo.slug, 'repo.slug');
217
+ const owner = reqStr(repo.owner, 'repo.owner');
218
+ const repoName = reqStr(ctx.content?.meta?.repoName || repo.name, 'repo.name');
219
+ const repoUrl = reqStr(repo.url, 'repo.url');
220
+
221
+ const concept = reqObj(ctx.concept, 'concept');
222
+ const tagline = reqStr(concept.tagline, 'concept.tagline');
223
+ reqStr(concept.metaphor, 'concept.metaphor');
224
+
225
+ const content = reqObj(ctx.content, 'content');
226
+ const S = reqObj(content.sections, 'content.sections');
227
+ const arcQ = {};
228
+ if (Array.isArray(content.arc)) for (const a of content.arc) if (a && a.section) arcQ[a.section] = a.question;
229
+
230
+ const visuals = reqObj(ctx.visuals, 'visuals');
231
+ const brand = reqObj(ctx.brand, 'brand');
232
+ // validate the authored human primer exists — it SHIPS INSIDE the pack zip (it is NOT inlined into
233
+ // the page body; inlining it produced a multi-screen wall of text — the Generation-1 height defect).
234
+ must(path.isAbsolute(ctx.kb?.primerPath || '')
235
+ ? ctx.kb.primerPath
236
+ : path.resolve(REPO_ROOT, ctx.kb?.primerPath || `kb/stores/${slug}/${slug}-primer.md`), 'kb.primerPath (authored primer, shipped in the pack)');
237
+
238
+ must(DS_CSS, 'design-system.css');
239
+
240
+ // --- prepare site/ + site/assets/ (idempotent: overwrite, never append) ---
241
+ const siteDir = path.join(buildDir, 'site');
242
+ const siteAssets = path.join(siteDir, 'assets');
243
+ fs.mkdirSync(siteAssets, { recursive: true });
244
+
245
+ // --- theme + stylesheet (design-system base + per-repo theme override) ---
246
+ const theme = buildTheme(concept);
247
+ const dsCss = fs.readFileSync(DS_CSS, 'utf8');
248
+ fs.writeFileSync(path.join(siteDir, 'styles.css'), `${dsCss}\n\n${theme.css}\n`);
249
+
250
+ // --- copy mandatory + optional visual assets (fail loud on a declared-but-broken file) ---
251
+ const heroFile = copyAsset(reqStr(visuals.hero?.file, 'visuals.hero.file'), buildDir, siteAssets, 'hero image');
252
+ const heroAlt = visuals.hero?.altText || visuals.hero?.alt || `${repoName}: ${concept.heroConcept || concept.metaphor}`;
253
+
254
+ const arch = reqObj(visuals.architectureDiagram, 'visuals.architectureDiagram (MANDATORY)');
255
+ const archFile = copyAsset(reqStr(arch.svgPath, 'visuals.architectureDiagram.svgPath'), buildDir, siteAssets, 'architecture diagram');
256
+ const archAlt = reqStr(arch.altText, 'visuals.architectureDiagram.altText');
257
+
258
+ // flow diagram is OPTIONAL — a pure library repo (no runtime entrypoints) has no flow; make-diagrams skips it there
259
+ const flow = (visuals.flowDiagram && typeof visuals.flowDiagram === 'object') ? visuals.flowDiagram : null;
260
+ const flowFile = (flow && flow.svgPath) ? copyAsset(reqStr(flow.svgPath, 'visuals.flowDiagram.svgPath'), buildDir, siteAssets, 'flow diagram') : null;
261
+ const flowAlt = flow ? (flow.altText || '') : '';
262
+
263
+ // optional ladder rungs — rendered only when present (no silent placeholder; broken file = loud)
264
+ const optDiagram = (d) => (d && d.svgPath) ? { file: copyAsset(d.svgPath, buildDir, siteAssets, 'diagram'), alt: d.altText || '' } : null;
265
+ const bigIdea = optDiagram(visuals.bigIdeaDiagram);
266
+ const insightDia = optDiagram(visuals.insightDiagram);
267
+ const rungs = Array.isArray(visuals.sections) ? visuals.sections : [];
268
+ const findRung = (re) => rungs.find((r) => re.test(String(r.id || '')) || re.test(String(r.role || '')));
269
+ const problemRung = findRung(/problem/i);
270
+ const useCaseRung = findRung(/use.?case|scenario/i);
271
+ const problemImg = problemRung?.file ? { file: copyAsset(problemRung.file, buildDir, siteAssets, 'problem illustration'), alt: problemRung.alt || problemRung.altText || `${repoName}: the problem` } : null;
272
+ const useCaseImg = useCaseRung?.file ? { file: copyAsset(useCaseRung.file, buildDir, siteAssets, 'use-case scenario'), alt: useCaseRung.alt || useCaseRung.altText || `${repoName} in use` } : null;
273
+
274
+ // --- social card + favicons ---
275
+ const card = reqObj(brand.socialCard, 'brand.socialCard');
276
+ const cardFile = copyAsset(reqStr(card.file, 'brand.socialCard.file'), buildDir, siteAssets, 'social card');
277
+ const fav = reqObj(brand.favicon, 'brand.favicon');
278
+ const favSet = reqArr(fav.set, 'brand.favicon.set');
279
+ const favItems = [...favSet];
280
+ if (fav.appleTouchIcon) favItems.push(fav.appleTouchIcon);
281
+ const favLinks = [];
282
+ for (const item of favItems) {
283
+ const base = copyAsset(path.isAbsolute(item) ? item : path.join(buildDir, 'assets', item), buildDir, siteAssets, 'favicon');
284
+ if (/apple/i.test(base)) favLinks.push(`<link rel="apple-touch-icon" href="assets/${base}">`);
285
+ else if (/\.ico$/i.test(base)) favLinks.push(`<link rel="icon" href="assets/${base}" sizes="any">`);
286
+ else { const sz = (base.match(/(\d{2,3})/) || [])[1]; favLinks.push(`<link rel="icon" type="image/png"${sz ? ` sizes="${sz}x${sz}"` : ''} href="assets/${base}">`); }
287
+ }
288
+
289
+ // --- SEO surface (INV-13/14) ---
290
+ const BASE = `https://${slug}-explainer.netlify.app`;
291
+ const canonical = `${BASE}/`;
292
+ const cardUrl = `${BASE}/assets/${cardFile}`;
293
+ const clip = (s, n) => { const t = String(s).replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1).trimEnd() + '…' : t; };
294
+ const seoTitle = clip(content.meta?.title || `${repoName} — ${tagline}`, 70);
295
+ const seoDesc = clip(content.meta?.description || tagline, 158);
296
+ const packZip = (ctx.pack && ctx.pack.zipPath) ? path.basename(ctx.pack.zipPath) : `${slug}-knowledge-pack.zip`;
297
+
298
+ const jsonLd = {
299
+ '@context': 'https://schema.org',
300
+ '@type': 'SoftwareApplication',
301
+ name: repoName,
302
+ description: seoDesc,
303
+ url: canonical,
304
+ applicationCategory: 'DeveloperApplication',
305
+ operatingSystem: 'Cross-platform',
306
+ image: cardUrl,
307
+ author: { '@type': 'Person', name: owner },
308
+ codeRepository: repoUrl,
309
+ offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
310
+ };
311
+ const jsonLdScript = JSON.stringify(jsonLd, null, 2).replace(/</g, '\\u003c');
312
+
313
+ // ── <head> ────────────────────────────────────────────────────────────────────────────────
314
+ const fontPreconnect = theme.fontHref
315
+ ? `\n <link rel="preconnect" href="https://fonts.googleapis.com">\n <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>\n <link rel="stylesheet" href="${esc(theme.fontHref)}">`
316
+ : '';
317
+ const themeColor = theme.accent && /^#/.test(theme.accent) ? `\n <meta name="theme-color" content="${esc(theme.accent)}">` : '';
318
+ const head = `<head>
319
+ <meta charset="utf-8">
320
+ <meta name="viewport" content="width=device-width, initial-scale=1">
321
+ <title>${esc(seoTitle)}</title>
322
+ <meta name="description" content="${esc(seoDesc)}">
323
+ <meta name="author" content="Independent explainer for ${esc(owner)}'s ${esc(repoName)} — built by Stuart Kerr at ISOvision.ai">
324
+ <link rel="canonical" href="${esc(canonical)}">${themeColor}
325
+ <meta property="og:type" content="website">
326
+ <meta property="og:title" content="${esc(seoTitle)}">
327
+ <meta property="og:description" content="${esc(tagline)}">
328
+ <meta property="og:url" content="${esc(canonical)}">
329
+ <meta property="og:image" content="${esc(cardUrl)}">
330
+ <meta property="og:image:width" content="1200">
331
+ <meta property="og:image:height" content="630">
332
+ <meta name="twitter:card" content="summary_large_image">
333
+ <meta name="twitter:title" content="${esc(seoTitle)}">
334
+ <meta name="twitter:description" content="${esc(tagline)}">
335
+ <meta name="twitter:image" content="${esc(cardUrl)}">
336
+ ${favLinks.join('\n ')}${fontPreconnect}
337
+ <link rel="stylesheet" href="styles.css">
338
+ <script type="application/ld+json">
339
+ ${jsonLdScript}
340
+ </script>
341
+ </head>`;
342
+
343
+ // ── header: provenance band + sticky nav ────────────────────────────────────────────────────
344
+ const logo = `<svg class="logo" viewBox="0 0 32 32" aria-hidden="true">
345
+ <rect x="3" y="3" width="26" height="26" rx="8" fill="none" stroke="var(--accent)" stroke-width="2"></rect>
346
+ <rect x="9" y="9" width="14" height="14" rx="4" fill="var(--accent-2)"></rect>
347
+ <circle cx="16" cy="16" r="3" fill="var(--on-accent)"></circle>
348
+ </svg>`;
349
+ const header = `
350
+ <div class="prov-banner">
351
+ <div class="wrap prov-banner-inner">
352
+ <p class="prov-attr">An independent explainer for <strong>${esc(owner)}</strong>'s <a href="${esc(repoUrl)}" target="_blank" rel="noopener">${esc(repoName)}</a> — built to help you actually implement it.</p>
353
+ <p class="prov-live"><span class="prov-dot" aria-hidden="true">&#9679;</span> source <code>${esc(repoUrl.replace(/^https?:\/\//, ''))}</code></p>
354
+ </div>
355
+ </div>
356
+ <header class="site-head">
357
+ <div class="wrap">
358
+ <a class="brand" href="#top" aria-label="${esc(repoName)} explainer home">
359
+ ${logo}
360
+ <span>${esc(repoName)}</span>
361
+ </a>
362
+ <nav class="nav-links" aria-label="Sections">
363
+ <a href="#what-it-is">What it is</a>
364
+ <a href="#how-it-works">How it works</a>
365
+ <a href="#use-cases">Use cases</a>
366
+ <a href="#get-started">Get started</a>
367
+ <a href="#the-pack">AI pack</a>
368
+ <a href="${esc(repoUrl)}" target="_blank" rel="noopener">GitHub &#8599;</a>
369
+ </nav>
370
+ </div>
371
+ </header>`;
372
+
373
+ // ── hero ────────────────────────────────────────────────────────────────────────────────────
374
+ const hero = reqObj(S.hero, 'content.sections.hero');
375
+ const headline = hero.headlineHtml ? hero.headlineHtml : inline(reqStr(hero.headline, 'content.sections.hero.headline'));
376
+ reqStr(hero.lede, 'content.sections.hero.lede');
377
+ const ctas = Array.isArray(hero.ctas) && hero.ctas.length ? hero.ctas : [
378
+ { label: 'See how it works →', href: '#how-it-works' },
379
+ { label: 'View the source ↗', href: repoUrl, ghost: true },
380
+ ];
381
+ const ctaHtml = ctas.map((c) => {
382
+ const ext = /^https?:/.test(c.href || '') ? ' target="_blank" rel="noopener"' : '';
383
+ return `<a class="cta${c.ghost ? ' ghost' : ''}" href="${esc(c.href || '#')}"${ext}>${inline(c.label)}</a>`;
384
+ }).join('\n ');
385
+ const metaRow = Array.isArray(hero.meta) && hero.meta.length
386
+ ? `\n <div class="meta-row">${hero.meta.map((m) => `<span><b>${esc(m.label)}</b> ${esc(m.value)}</span>`).join('')}</div>` : '';
387
+ const plainBand = hero.plain ? `
388
+ <div class="wrap">
389
+ <div class="plainband"><p>${inline(hero.plain)}</p></div>
390
+ </div>` : '';
391
+ const heroSection = `
392
+ <a id="top"></a>
393
+ <section class="hero">
394
+ <div class="wrap">
395
+ <div class="hero-grid">
396
+ <div>
397
+ ${hero.eyebrow ? `<span class="eyebrow">${inline(hero.eyebrow)}</span>` : `<span class="eyebrow">${esc(concept.metaphor)}</span>`}
398
+ <h1>${headline}</h1>
399
+ <p class="lede">${inline(hero.lede)}</p>
400
+ ${hero.sub ? `<p class="sub">${inline(hero.sub)}</p>` : ''}
401
+ <p class="attrib-lede">An <strong>independent explainer</strong> for <strong>${esc(owner)}</strong>'s <a href="${esc(repoUrl)}" target="_blank" rel="noopener">${esc(repoName)}</a> — built to take you from "never seen it" to "ready to implement".</p>
402
+ <div class="cta-row">
403
+ ${ctaHtml}
404
+ </div>${metaRow}
405
+ </div>
406
+ <figure class="hero-art">
407
+ <img src="assets/${esc(heroFile)}" alt="${esc(heroAlt)}">
408
+ <figcaption>${esc(concept.heroConcept || concept.metaphor)}</figcaption>
409
+ </figure>
410
+ </div>
411
+ </div>${plainBand}
412
+ </section>`;
413
+
414
+ // ── arc sections ────────────────────────────────────────────────────────────────────────────
415
+ const out = [];
416
+
417
+ // 1 · problem
418
+ const problem = reqObj(S.problem, 'content.sections.problem');
419
+ reqStr(problem.title, 'content.sections.problem.title');
420
+ out.push(sectionShell('problem', '01', problem, arcQ.problem, [
421
+ problem.lead ? `<p class="lead-in">${inline(problem.lead)}</p>` : '',
422
+ paras(problem.paragraphs),
423
+ problemImg ? figureHtml(problemImg.file, problemImg.alt, problemRung?.caption, { tier: { cls: 'friendly', label: 'The problem' } }) : '',
424
+ noteHtml(problem.note),
425
+ ].filter(Boolean).join('\n ')));
426
+
427
+ // 2 · what it is (big-idea diagram)
428
+ const whatItIs = reqObj(S.whatItIs, 'content.sections.whatItIs');
429
+ reqStr(whatItIs.title, 'content.sections.whatItIs.title');
430
+ out.push(sectionShell('whatItIs', '02', whatItIs, arcQ.whatItIs, [
431
+ whatItIs.lead ? `<p class="lead-in">${inline(whatItIs.lead)}</p>` : '',
432
+ paras(whatItIs.paragraphs),
433
+ bigIdea ? figureHtml(bigIdea.file, bigIdea.alt || `${repoName}: the whole idea in one picture`, whatItIs.figureCaption, { diagram: true, concept: true, tier: { cls: 'tech', label: 'The big idea' } }) : '',
434
+ tableHtml(whatItIs.table),
435
+ ].filter(Boolean).join('\n ')));
436
+
437
+ // 3 · insight (the "oh" + insight diagram)
438
+ const insight = reqObj(S.insight, 'content.sections.insight');
439
+ reqStr(insight.title, 'content.sections.insight.title');
440
+ reqStr(insight.oh, 'content.sections.insight.oh');
441
+ out.push(sectionShell('insight', '03', insight, arcQ.insight, [
442
+ insight.lead ? `<p class="lead-in">${inline(insight.lead)}</p>` : '',
443
+ paras(insight.paragraphs),
444
+ insightDia ? figureHtml(insightDia.file, insightDia.alt || `${repoName}: the one clever move`, insight.figureCaption, { diagram: true, concept: true, tier: { cls: 'tech', label: 'The aha' } }) : '',
445
+ `<p class="oh">${inline(insight.oh)}</p>`,
446
+ ].filter(Boolean).join('\n ')));
447
+
448
+ // 4 · how it works (MANDATORY architecture + flow, side by side)
449
+ const howItWorks = reqObj(S.howItWorks, 'content.sections.howItWorks');
450
+ reqStr(howItWorks.title, 'content.sections.howItWorks.title');
451
+ out.push(sectionShell('howItWorks', '04', howItWorks, arcQ.howItWorks, [
452
+ howItWorks.lead ? `<p class="lead-in">${inline(howItWorks.lead)}</p>` : '',
453
+ paras(howItWorks.paragraphs),
454
+ flowFile
455
+ ? `<div class="dual">${
456
+ figureHtml(archFile, archAlt, 'Architecture — modules, components and how they depend on each other.', { diagram: true, tier: { cls: 'tech', label: 'Architecture' } })
457
+ }${
458
+ figureHtml(flowFile, flowAlt, 'Data flow — how a request moves through the system at runtime.', { diagram: true, tier: { cls: 'tech', label: 'Data flow' } })
459
+ }\n </div>`
460
+ : figureHtml(archFile, archAlt, 'Architecture — modules, components and how they depend on each other.', { diagram: true, tier: { cls: 'tech', label: 'Architecture' } }),
461
+ ].filter(Boolean).join('\n ')));
462
+
463
+ // 5 · use cases (collapsible cases + scenario raster)
464
+ const useCases = reqObj(S.useCases, 'content.sections.useCases');
465
+ reqStr(useCases.title, 'content.sections.useCases.title');
466
+ const cases = reqArr(useCases.cases, 'content.sections.useCases.cases');
467
+ const casesHtml = cases.map((c, i) => {
468
+ reqStr(c.title, `content.sections.useCases.cases[${i}].title`);
469
+ const dl = Array.isArray(c.dl) && c.dl.length
470
+ ? `\n <dl>${c.dl.map((d) => `<dt>${esc(d.term)}</dt><dd>${inline(d.desc)}</dd>`).join('\n ')}</dl>` : '';
471
+ const code = c.code ? `\n <pre class="code-block"><code>${esc(c.code)}</code></pre>` : '';
472
+ return `
473
+ <details class="case" open>
474
+ <summary>
475
+ <span class="uc-num">${i + 1}</span>
476
+ <span class="uc-title">${inline(c.title)}</span>
477
+ ${c.tag ? `<span class="uc-tag">${esc(c.tag)}</span>` : ''}
478
+ <span class="chev" aria-hidden="true">&rsaquo;</span>
479
+ </summary>
480
+ <div class="uc-body">
481
+ ${paras(c.paragraphs || c.body)}${code}${dl}
482
+ </div>
483
+ </details>`;
484
+ }).join('');
485
+ out.push(sectionShell('useCases', '05', useCases, arcQ.useCases, [
486
+ useCases.intro ? `<p class="lead-in">${inline(useCases.intro)}</p>` : '',
487
+ useCaseImg ? figureHtml(useCaseImg.file, useCaseImg.alt, useCaseRung?.caption, { tier: { cls: 'friendly', label: 'In the real world' } }) : '',
488
+ `<div class="gallery"><div class="grid">${casesHtml}\n </div></div>`,
489
+ ].filter(Boolean).join('\n ')));
490
+
491
+ // 6 · get started (install block + numbered steps)
492
+ const getStarted = reqObj(S.getStarted, 'content.sections.getStarted');
493
+ reqStr(getStarted.title, 'content.sections.getStarted.title');
494
+ const steps = reqArr(getStarted.steps, 'content.sections.getStarted.steps');
495
+ const stepsHtml = steps.map((s) => {
496
+ if (s && typeof s === 'object') return `<li>${s.strong ? `<strong>${inline(s.strong)}</strong> ` : ''}${inline(s.text || '')}</li>`;
497
+ return `<li>${inline(s)}</li>`;
498
+ }).join('\n ');
499
+ out.push(sectionShell('getStarted', '06', getStarted, arcQ.getStarted, [
500
+ getStarted.intro ? `<p class="lead-in">${inline(getStarted.intro)}</p>` : '',
501
+ getStarted.install ? `<div class="install-block"><pre class="code-block"><code>${esc(getStarted.install)}</code></pre></div>` : '',
502
+ `<ol class="steps">\n ${stepsHtml}\n </ol>`,
503
+ ].filter(Boolean).join('\n ')));
504
+
505
+ // 7 · the AI pack — a TIGHT, visual DOWNLOAD block: a file-tree TEASER of what's inside
506
+ // (for-ai/ KB + search + MCP · for-humans/ primer) + the download CTA + dropzone. The full
507
+ // human primer SHIPS INSIDE the zip; it is deliberately NOT inlined here (inlining it made the
508
+ // page a multi-screen wall of text — the Generation-1 height defect this fix removes).
509
+ const pack = reqObj(S.pack, 'content.sections.pack');
510
+ reqStr(pack.title, 'content.sections.pack.title');
511
+ // white-space:pre keeps the file-tree teaser an actual TREE (the .tree class ships
512
+ // overflow-x:auto for exactly this but omits white-space:pre — set it inline so the
513
+ // teaser reads as a tree instead of a run-on line; .tree's overflow-x:auto contains it).
514
+ const tree = pack.tree ? `<div class="tree" style="white-space:pre">${esc(pack.tree)}</div>` : `<div class="tree" style="white-space:pre"><span class="cmt"># ${esc(packZip)}</span>
515
+ <span class="d">for-ai/</span> <span class="cmt"># wire this into your agent</span>
516
+ <span class="f">${esc(slug)}-kb.rvf</span> <span class="cmt"># 384-dim vector brain (semantic search)</span>
517
+ <span class="f">${esc(slug)}-kb.passages.jsonl</span> <span class="cmt"># full passage text (search returns TEXT)</span>
518
+ <span class="f">${esc(slug)}-symbols.json</span> <span class="cmt"># exact public API</span>
519
+ <span class="f">${esc(slug)}-dep-graph.json</span> <span class="cmt"># what depends on what</span>
520
+ <span class="f">${esc(slug)}-entrypoints.json</span> <span class="cmt"># build / test / run commands</span>
521
+ <span class="f">ask-kb.mjs</span> · <span class="f">kb-mcp-server.mjs</span> <span class="cmt"># CLI + MCP search server</span>
522
+ <span class="d heart">for-humans/</span> <span class="cmt"># read first</span>
523
+ <span class="f heart">${esc(slug)}-primer.md</span> <span class="cmt"># the human orientation</span></div>`;
524
+ out.push(sectionShell('pack', '07', pack, arcQ.pack, [
525
+ pack.intro ? `<p class="lead-in">${inline(pack.intro)}</p>` : '',
526
+ tree,
527
+ `<div class="dl-cta"><a class="cta" href="${esc(packZip)}" download>${inline(pack.downloadLabel || 'Download the AI knowledge pack')}</a><span class="dl-meta">RVF vector KB + MCP server — drop it into your own agent.</span></div>`,
528
+ `<a class="dropzone" href="${esc(packZip)}" download><span class="dz-icon" aria-hidden="true">&darr;</span><strong>Give your AI the same understanding</strong><span class="dz-hint">${esc(packZip)}</span></a>`,
529
+ ].filter(Boolean).join('\n ')));
530
+
531
+ // ── mandated ISOvision attribution + CTA footer (verbatim per design-system §16b) ─────────────
532
+ const footer = `
533
+ <footer class="explainer-footer" role="contentinfo">
534
+ <div class="wrap explainer-footer-inner">
535
+ <p class="ef-credit">
536
+ Explainer page created by <strong><a href="https://stuart-kerr-card.netlify.app" rel="author noopener" target="_blank">Stuart Kerr</a></strong> at
537
+ <a href="https://isovision.ai" rel="noopener" target="_blank">ISOvision</a>
538
+ </p>
539
+ <a class="ef-cta" href="https://explainmyrepo.isovision.ai" target="_blank" rel="noopener">
540
+ <span class="ef-cta-lead">Would you like your own repo explainer page?</span>
541
+ <strong>Generate one here</strong>
542
+ <span class="ef-arrow" aria-hidden="true">&rarr;</span>
543
+ </a>
544
+ </div>
545
+ </footer>`;
546
+
547
+ // ── assemble the single HTML document (rendered ONCE — INV-10) ───────────────────────────────
548
+ const html = `<!DOCTYPE html>
549
+ <html lang="en">
550
+ ${head}
551
+ <body>
552
+ <a class="skip-link" href="#main">Skip to content</a>
553
+ ${header}
554
+ <main id="main">
555
+ ${heroSection}
556
+ <div class="sections">
557
+ <div class="wrap">${out.join('\n')}
558
+ </div>
559
+ </div>
560
+ </main>
561
+ ${footer}
562
+ </body>
563
+ </html>
564
+ `;
565
+
566
+ // ── guard: zero template tokens / dangling refs (CONTRACT (b)·6, Station-6 cue) ───────────────
567
+ // Mask legit escaped code/pre content before scanning: author code samples and the inlined primer
568
+ // routinely contain `${...}` template literals and `{ ... }` JSON braces — that is CONTENT, never a
569
+ // render leak. For this renderer a literal `${` in the output can ONLY come from esc()'d data (every
570
+ // `${x}` in the renderer's own template strings is evaluated when the HTML is built), so masking the
571
+ // <pre>/<code> regions removes the false positives while still catching real leaks in prose/markup.
572
+ const scannable = html
573
+ .replace(/<pre[\s\S]*?<\/pre>/gi, '')
574
+ .replace(/<code[\s\S]*?<\/code>/gi, '');
575
+ const leakRe = /\{\{|\}\}|\$\{|\[object Object\]|(?:^|[\s">])(?:undefined|NaN)(?:[\s"<]|$)|lorem ipsum|\bTODO\b|\bPLACEHOLDER\b/i;
576
+ const leak = scannable.match(leakRe);
577
+ if (leak) throw new Error(`unresolved token / placeholder leaked into the page: "${leak[0].trim()}"`);
578
+ for (const m of html.matchAll(/(?:src|href)="(assets\/[^"]+)"/g)) {
579
+ const ref = path.join(siteDir, m[1]);
580
+ if (!fs.existsSync(ref)) throw new Error(`dangling asset reference: ${m[1]} (no file at ${ref})`);
581
+ }
582
+
583
+ // ── write the page + the site-level discovery files ──────────────────────────────────────────
584
+ const htmlPath = path.join(siteDir, 'index.html');
585
+ fs.writeFileSync(htmlPath, html);
586
+
587
+ const today = new Date().toISOString().slice(0, 10);
588
+ fs.writeFileSync(path.join(siteDir, 'sitemap.xml'),
589
+ `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n <url>\n <loc>${canonical}</loc>\n <lastmod>${today}</lastmod>\n <changefreq>monthly</changefreq>\n <priority>1.0</priority>\n </url>\n</urlset>\n`);
590
+ fs.writeFileSync(path.join(siteDir, 'robots.txt'),
591
+ `User-agent: *\nAllow: /\n\nSitemap: ${BASE}/sitemap.xml\n`);
592
+ fs.writeFileSync(path.join(siteDir, 'llms.txt'),
593
+ `# ${repoName}\n\n> ${tagline}\n\n${seoDesc}\n\nThis is an independent, art-directed explainer for ${owner}'s ${repoName} — it takes a newcomer from "never seen it" to "ready to implement", grounded in a real RVF knowledge base of the source.\n\n## What it is\n${repoName}: ${concept.metaphor}\n\n## For your AI\nA downloadable knowledge pack (RVF vector KB + MCP server) ships with this page so your agent can search the source too: ${BASE}/${packZip}\n\n## Links\n- Live explainer: ${canonical}\n- Source repository: ${repoUrl}\n- AI knowledge pack: ${BASE}/${packZip}\n`);
594
+
595
+ // ── merge ONLY the page slot back into build.json (read-modify-write; others untouched) ───────
596
+ const pageSlot = {
597
+ dir: siteDir,
598
+ htmlPath,
599
+ cssPath: path.join(siteDir, 'styles.css'),
600
+ tokensUsed: theme.tokensUsed,
601
+ seo: {
602
+ title: seoTitle,
603
+ description: seoDesc,
604
+ canonical,
605
+ jsonLd: 'SoftwareApplication',
606
+ sitemap: 'sitemap.xml',
607
+ robots: 'robots.txt',
608
+ llmsTxt: 'llms.txt',
609
+ },
610
+ social: {
611
+ og: { title: seoTitle, description: tagline, image: cardUrl, url: canonical },
612
+ twitter: { card: 'summary_large_image', image: cardUrl },
613
+ },
614
+ };
615
+ const fresh = readJSON(buildJsonPath);
616
+ fresh.page = pageSlot;
617
+ fs.writeFileSync(buildJsonPath, JSON.stringify(fresh, null, 2) + '\n');
618
+
619
+ ok({
620
+ dir: siteDir,
621
+ htmlPath,
622
+ cssPath: pageSlot.cssPath,
623
+ sitemap: path.join(siteDir, 'sitemap.xml'),
624
+ robots: path.join(siteDir, 'robots.txt'),
625
+ llmsTxt: path.join(siteDir, 'llms.txt'),
626
+ tokensUsed: theme.tokensUsed,
627
+ slot: 'page',
628
+ });
629
+ }
630
+
631
+ try { main(); } catch (e) { fail(e.message || e); }