@kontourai/corpus 0.0.0-bootstrap.0 → 0.8.0

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.
package/src/build.mjs ADDED
@@ -0,0 +1,535 @@
1
+ import { copyFileSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { Marked, Renderer } from 'marked';
4
+ import sanitizeHtml from 'sanitize-html';
5
+ import { createAssetPolicy } from './assets.mjs';
6
+ import { assembleBundle, defaultSiteUrl, normalizeBaseUrl } from './bundle.mjs';
7
+ import { foldPath, formatCheckReport, publishedSources, runChecks } from './check.mjs';
8
+ import { EXIT_OK, GateFailure, UsageError } from './errors.mjs';
9
+ import { gitEnv, headSha, repoPrefix, trackedFiles } from './git.mjs';
10
+ import { execFileSync } from 'node:child_process';
11
+ import { outputPathFor } from './manifest.mjs';
12
+ import { analyzeMarkdown, annotateSourceLines, decodeEntities, removeHtmlAttributes, documentTitle, extractHtmlAnchors, extractHtmlReferences, firstParagraph, resolveLink, rewriteHtmlReferences } from './markdown.mjs';
13
+ import { encodePath, relativeUrl, statInRepo, toFsPath } from './paths.mjs';
14
+ import { SITE_CSS, SITE_SCRIPT, SITE_SCRIPT_FILE, escapeHtml, sitePage } from './site-template.mjs';
15
+
16
+ const IMAGE_FILE = /\.(?:png|jpe?g|gif|svg|webp|avif|ico|bmp)$/i;
17
+ const SAFE_EXTERNAL = /^(?:https?:|mailto:|\/\/)/i;
18
+ const SAFE_DATA_IMAGE = /^data:image\/(?:png|jpe?g|gif|webp|avif);/i;
19
+
20
+ /**
21
+ * Every product site shares the https://kontourai.github.io origin, so a
22
+ * script in one product's docs could act on every other product's pages.
23
+ * Rendered Markdown is sanitized to GitHub-like output: no script/style, no
24
+ * on* handlers, and only http(s)/mailto URLs (plus data: images).
25
+ */
26
+ const TEMPLATE_ONLY_ATTRIBUTES = new Set(['data-src-line']);
27
+ const SOURCE_LINE_TAGS = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'tr', 'pre', 'blockquote'];
28
+
29
+ const SANITIZE_OPTIONS = {
30
+ allowedTags: [
31
+ ...new Set([
32
+ ...sanitizeHtml.defaults.allowedTags,
33
+ 'img', 'picture', 'source', 'video', 'h1', 'h2', 'details', 'summary', 'del', 'ins', 's', 'kbd', 'sup', 'sub', 'input', 'div', 'span',
34
+ ]),
35
+ ],
36
+ allowedAttributes: {
37
+ '*': ['id', 'class', 'title', 'align', 'dir', 'lang', 'aria-label', 'aria-hidden'],
38
+ a: ['href', 'name'],
39
+ img: ['src', 'srcset', 'alt', 'width', 'height', 'loading'],
40
+ source: ['src', 'srcset', 'media', 'type'],
41
+ video: ['src', 'controls', 'width', 'height', 'poster', 'muted', 'loop', 'autoplay', 'playsinline'],
42
+ details: ['open'],
43
+ input: ['type', 'checked', 'disabled'],
44
+ td: ['colspan', 'rowspan'],
45
+ th: ['colspan', 'rowspan'],
46
+ ol: ['start'],
47
+ // Source provenance for the selection toolbar, written by renderMarkdown.
48
+ // Exactly this one data-* name, on exactly the elements that carry it.
49
+ ...Object.fromEntries(SOURCE_LINE_TAGS.map((tag) => [tag, ['data-src-line']])),
50
+ },
51
+ allowedSchemes: ['http', 'https', 'mailto'],
52
+ allowedSchemesByTag: { img: ['http', 'https', 'data'], source: ['http', 'https', 'data'], video: ['http', 'https'] },
53
+ allowProtocolRelative: true,
54
+ };
55
+
56
+ /**
57
+ * Map a link written in `fromSource` to its URL on the generated site:
58
+ * published docs become their pages, local images and embedded media are copied, other repo
59
+ * files go to GitHub at the built commit, and unsafe schemes are neutralized.
60
+ * Unresolvable local targets are left as written so the generated-site check
61
+ * reports them.
62
+ */
63
+ function createHrefRewriter({ root, manifest, sha, prefix, published }) {
64
+ const blobBase = `https://github.com/${manifest.repo}`;
65
+ const assetPolicy = createAssetPolicy(root);
66
+ return (href, fromSource, { image = false, pending } = {}) => {
67
+ const fromOutput = outputPathFor(manifest, fromSource);
68
+ const target = resolveLink(href, fromSource);
69
+ if (target.kind === 'external') {
70
+ const trimmed = href.trim();
71
+ if (SAFE_EXTERNAL.test(trimmed) || (image && SAFE_DATA_IMAGE.test(trimmed))) return trimmed;
72
+ return '#';
73
+ }
74
+ if (target.kind !== 'local') return href;
75
+ const hash = target.rawFragment ? `#${target.rawFragment}` : '';
76
+ if (published.has(target.path)) return `${relativeUrl(fromOutput, outputPathFor(manifest, target.path))}${hash}`;
77
+ const stat = statInRepo(root, target.path);
78
+ if (!stat) return href;
79
+ // Embedded resources (Markdown images, src, srcset, poster) and links to
80
+ // images are copied when the shared asset policy allows it.
81
+ const policy = assetPolicy(target.path, stat.kind, { embed: image || IMAGE_FILE.test(target.path) });
82
+ if (policy.action === 'unpublishable') throw new GateFailure(`${fromSource}: unpublishable reference: ${target.path} ${policy.reason}`);
83
+ if (policy.action === 'copy') {
84
+ // Recorded as pending; copied only if the URL survives sanitization.
85
+ const url = relativeUrl(fromOutput, target.path);
86
+ pending?.set(url, target.path);
87
+ return url;
88
+ }
89
+ const kind = stat.kind === 'dir' ? 'tree' : 'blob';
90
+ return `${blobBase}/${kind}/${sha}/${encodePath(`${prefix}${target.path}`).replace(/\/$/, '')}${hash}`;
91
+ };
92
+ }
93
+
94
+ /** True for a fenced block whose info string's first word is `mermaid`, as on GitHub. */
95
+ function isMermaidFence(token) {
96
+ return /^mermaid(?:\s|$)/.test(token.lang ?? '');
97
+ }
98
+
99
+ /**
100
+ * Render one page. Assets are collected from the sanitized HTML, so a file
101
+ * referenced only by a tag the sanitizer strips (iframe, embed, script, ...)
102
+ * is never copied. Returns { html, mermaid }, where `mermaid` says whether a
103
+ * mermaid fence was rendered, so the page template loads Mermaid only there.
104
+ */
105
+ function renderMarkdown(markdown, fromSource, rewrite, assets, lineOffset = 0) {
106
+ const pending = new Map();
107
+ let mermaid = false;
108
+ const md = new Marked({ gfm: true });
109
+ // `data-src-line` on the first tag of a default rendering. The value is
110
+ // computed by annotateSourceLines ("start-end" digits), never from content.
111
+ const withLine = (html, line) => (line ? html.replace(/^<([a-z][a-z0-9]*)/, `<$1 data-src-line="${line}"`) : html);
112
+ md.use({
113
+ renderer: {
114
+ // Mermaid reads the diagram source from the element's text, so it is
115
+ // escaped like any code block and passes the sanitizer as plain text.
116
+ code(token) {
117
+ if (!isMermaidFence(token)) return withLine(Renderer.prototype.code.call(this, token), token.srcLine);
118
+ mermaid = true;
119
+ const line = token.srcLine ? ` data-src-line="${token.srcLine}"` : '';
120
+ return `<pre class="mermaid"${line}>${escapeHtml(token.text)}</pre>\n`;
121
+ },
122
+ heading(token) {
123
+ const inner = this.parser.parseInline(token.tokens);
124
+ const id = escapeHtml(token.slugId);
125
+ const line = token.srcLine ? ` data-src-line="${token.srcLine}"` : '';
126
+ return `<h${token.depth} id="${id}"${line}>${inner}<a class="anchor" href="#${id}" aria-label="Link to this section">#</a></h${token.depth}>\n`;
127
+ },
128
+ paragraph(token) {
129
+ return withLine(Renderer.prototype.paragraph.call(this, token), token.srcLine);
130
+ },
131
+ blockquote(token) {
132
+ return withLine(Renderer.prototype.blockquote.call(this, token), token.srcLine);
133
+ },
134
+ listitem(item) {
135
+ return withLine(Renderer.prototype.listitem.call(this, item), item.srcLine);
136
+ },
137
+ // GFM table cells cannot hold a table, so one queue of row lines is enough.
138
+ table(token) {
139
+ this.docsKitRows = token.srcRows ? [...token.srcRows] : [];
140
+ try {
141
+ return Renderer.prototype.table.call(this, token);
142
+ } finally {
143
+ this.docsKitRows = [];
144
+ }
145
+ },
146
+ tablerow(row) {
147
+ return withLine(Renderer.prototype.tablerow.call(this, row), this.docsKitRows?.shift());
148
+ },
149
+ },
150
+ });
151
+ // analyzeMarkdown assigns GitHub slugs in walk order; reuse it so page ids
152
+ // and the anchors the gates validated are the same function.
153
+ const { tokens } = analyzeMarkdown(markdown);
154
+ annotateSourceLines(tokens, lineOffset);
155
+ md.walkTokens(tokens, (token) => {
156
+ if (token.type === 'link' || token.type === 'image') token.href = rewrite(token.href, fromSource, { image: token.type === 'image', pending });
157
+ else if (token.type === 'html' && token.text) {
158
+ // Provenance comes only from annotateSourceLines: raw HTML cannot set its own.
159
+ const rewritten = rewriteHtmlReferences(removeHtmlAttributes(token.text, TEMPLATE_ONLY_ATTRIBUTES), (url, attr) => rewrite(url, fromSource, { image: attr !== 'href', pending }));
160
+ token.text = rewritten;
161
+ if ('raw' in token) token.raw = rewritten;
162
+ }
163
+ });
164
+ const html = sanitizeHtml(md.parser(tokens), SANITIZE_OPTIONS);
165
+ for (const { url } of extractHtmlReferences(html)) if (pending.has(url)) assets.add(pending.get(url));
166
+ return {
167
+ html: html
168
+ .replaceAll('<table>', '<div class="table-scroll" tabindex="0" role="region" aria-label="Scrollable table"><table>')
169
+ .replaceAll('</table>', '</table></div>'),
170
+ mermaid,
171
+ };
172
+ }
173
+
174
+ /** Section -> page sources, in manifest order. Bundle-only documents have no page, so no entry. */
175
+ function pageSections(manifest) {
176
+ const sections = new Map();
177
+ for (const document of manifest.documents) {
178
+ if (!document.publish) continue;
179
+ if (!sections.has(document.section)) sections.set(document.section, []);
180
+ sections.get(document.section).push(document.source);
181
+ }
182
+ return sections;
183
+ }
184
+
185
+ function navigation(manifest, titles, activeSource) {
186
+ const fromOutput = outputPathFor(manifest, activeSource);
187
+ const link = (source, label) => {
188
+ const current = source === activeSource ? ' aria-current="page"' : '';
189
+ return `<li><a href="${relativeUrl(fromOutput, outputPathFor(manifest, source))}"${current}>${escapeHtml(label)}</a></li>`;
190
+ };
191
+ const sections = pageSections(manifest);
192
+ const groups = [`<h2>Overview</h2>\n<ul>\n${link(manifest.landing, 'Home')}\n</ul>`];
193
+ for (const [section, sources] of sections)
194
+ groups.push(`<h2>${escapeHtml(section)}</h2>\n<ul>\n${sources.map((source) => link(source, titles.get(source))).join('\n')}\n</ul>`);
195
+ return groups.join('\n');
196
+ }
197
+
198
+ function sectionCards(manifest, titles, descriptions) {
199
+ const sections = pageSections(manifest);
200
+ if (sections.size === 0) return '';
201
+ const blocks = [...sections].map(([section, sources]) => {
202
+ const cards = sources
203
+ .map(
204
+ (source) =>
205
+ `<a class="card" href="${relativeUrl('index.html', outputPathFor(manifest, source))}"><strong>${escapeHtml(titles.get(source))}</strong>${descriptions.get(source) ? `<span>${escapeHtml(descriptions.get(source))}</span>` : ''}</a>`,
206
+ )
207
+ .join('\n');
208
+ return `<h3>${escapeHtml(section)}</h3>\n<div class="cards">\n${cards}\n</div>`;
209
+ });
210
+ return `<section class="section-cards" aria-labelledby="docs-heading">\n<h2 id="docs-heading">Documentation</h2>\n${blocks.join('\n')}\n</section>`;
211
+ }
212
+
213
+ function gitIn(cwd, args) {
214
+ return execFileSync('git', args, { cwd, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
215
+ }
216
+
217
+ export const BUILD_MARKER = '.docs-kit-build';
218
+
219
+ /**
220
+ * `build` deletes --out before writing, so only delete what docs-kit made:
221
+ * refuse the repository root or an ancestor, anything inside a .git
222
+ * directory, a directory holding git-tracked files, and any existing
223
+ * non-empty directory unless every file in it is listed in the BUILD_MARKER
224
+ * manifest a previous build wrote (a hand-added file blocks the rebuild).
225
+ * This holds inside and outside git, git-ignored or not. A forged marker is
226
+ * trusted.
227
+ */
228
+ function assertSafeOut(root, out) {
229
+ const resolvedRoot = path.resolve(root);
230
+ const resolvedOut = path.resolve(out);
231
+ const relative = path.relative(resolvedOut, resolvedRoot);
232
+ if (relative === '' || !relative.startsWith('..'))
233
+ throw new UsageError(`--out ${out} is the repository root or one of its parents; refusing to delete it`);
234
+ if (resolvedOut.split(path.sep).includes('.git')) throw new UsageError(`--out ${out} is inside a .git directory; refusing to delete it`);
235
+ let stat;
236
+ try {
237
+ stat = statSync(resolvedOut);
238
+ } catch {
239
+ return; // does not exist yet
240
+ }
241
+ if (!stat.isDirectory()) throw new UsageError(`--out ${out} exists and is not a directory; refusing to delete it`);
242
+ try {
243
+ const gitDir = path.resolve(resolvedOut, gitIn(resolvedOut, ['rev-parse', '--absolute-git-dir']).trim());
244
+ const insideGitDir = path.relative(gitDir, resolvedOut);
245
+ if (insideGitDir === '' || (!insideGitDir.startsWith('..') && !path.isAbsolute(insideGitDir)))
246
+ throw new UsageError(`--out ${out} is inside a .git directory; refusing to delete it`);
247
+ const tracked = trackedFiles(resolvedOut) ?? [];
248
+ if (tracked.length > 0)
249
+ throw new UsageError(`--out ${out} contains ${tracked.length} git-tracked file(s) (e.g. ${tracked[0]}); refusing to delete it`);
250
+ } catch (error) {
251
+ if (error instanceof UsageError) throw error;
252
+ // Not inside a git checkout: the marker rule below still applies.
253
+ }
254
+ const present = filesUnder(resolvedOut);
255
+ if (present.length === 0) return;
256
+ let listed;
257
+ try {
258
+ const marker = JSON.parse(readFileSync(path.join(resolvedOut, BUILD_MARKER), 'utf8'));
259
+ if (!['@kontourai/docs-kit', '@kontourai/corpus'].includes(marker?.generator) || !Array.isArray(marker.files)) throw new Error('not a docs-kit marker');
260
+ listed = new Set(marker.files);
261
+ } catch {
262
+ throw new UsageError(
263
+ `--out ${out} is a non-empty directory without a valid ${BUILD_MARKER} manifest, so docs-kit did not create it; refusing to delete it. ` +
264
+ 'Choose an empty or new directory.',
265
+ );
266
+ }
267
+ const unexpected = present.find((file) => file !== BUILD_MARKER && !listed.has(file));
268
+ if (unexpected)
269
+ throw new UsageError(`--out ${out} contains ${unexpected}, which the previous docs-kit build did not write; refusing to delete it`);
270
+ }
271
+
272
+ /** Every file under `dir`, dotfiles included, as sorted POSIX paths relative to it. */
273
+ function filesUnder(dir) {
274
+ const found = [];
275
+ const visit = (fsDir, relative) => {
276
+ for (const entry of readdirSync(fsDir, { withFileTypes: true })) {
277
+ const child = relative ? `${relative}/${entry.name}` : entry.name;
278
+ if (entry.isDirectory()) visit(path.join(fsDir, entry.name), child);
279
+ else found.push(child);
280
+ }
281
+ };
282
+ visit(dir, '');
283
+ return found.sort();
284
+ }
285
+
286
+ /**
287
+ * A string as a JavaScript literal safe inside an HTML <script> element.
288
+ * JSON.stringify quotes and escapes backslashes, quotes, and control characters;
289
+ * escaping every "<" as \u003c rules out "</script" and "<!--", the only ways
290
+ * text can leave or confuse a script element. U+2028 and U+2029 are escaped
291
+ * too: legal in string literals since ES2019, but line terminators to older
292
+ * parsers. The literal means the same string.
293
+ */
294
+ export function scriptString(value) {
295
+ return JSON.stringify(String(value)).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
296
+ }
297
+
298
+ /**
299
+ * A redirect stub for a page that moved. GitHub Pages cannot send HTTP
300
+ * redirects, so the stub redirects immediately, names the canonical URL, asks
301
+ * not to be indexed, and shows a plain link.
302
+ *
303
+ * A meta refresh drops the incoming #fragment, so an inline script runs first
304
+ * and carries it over with location.replace (which also keeps the stub out of
305
+ * history). A target that has its own #anchor keeps it: the manifest named
306
+ * that anchor on purpose, and an old page's fragment may not exist on the new
307
+ * page. The meta refresh stays as the no-JS fallback. The script is template
308
+ * output, not document content, so it does not pass through the sanitizer;
309
+ * the target is embedded with scriptString.
310
+ */
311
+ export function redirectStub({ href, canonical }) {
312
+ const url = escapeHtml(href);
313
+ return `<!doctype html>
314
+ <html lang="en">
315
+ <head>
316
+ <meta charset="utf-8">
317
+ <title>Redirecting…</title>
318
+ <script>(function(){var t=${scriptString(href)};location.replace(t.indexOf("#")<0?t+location.hash:t);})();</script>
319
+ <meta http-equiv="refresh" content="0; url=${url}">
320
+ <link rel="canonical" href="${escapeHtml(canonical)}">
321
+ <meta name="robots" content="noindex">
322
+ <meta name="generator" content="@kontourai/corpus">
323
+ </head>
324
+ <body>
325
+ <p>This page has moved to <a href="${url}">${url}</a>.</p>
326
+ </body>
327
+ </html>
328
+ `;
329
+ }
330
+
331
+ /** Write one stub per redirects key; returns the count. Refuses to overwrite a copied asset. */
332
+ function writeRedirects({ manifest, out, siteUrl, assets }) {
333
+ const lowerAssets = new Map([...assets].map((asset) => [foldPath(asset), asset]));
334
+ for (const { from, source, anchor, url } of manifest.redirects) {
335
+ const clash = lowerAssets.get(foldPath(from)) ?? [...lowerAssets].find(([folded]) => folded.startsWith(`${foldPath(from)}/`))?.[1];
336
+ if (clash) throw new GateFailure(`docs-kit build: redirects["${from}"] would overwrite the copied asset ${clash}`);
337
+ let href = url;
338
+ let canonical = url;
339
+ if (source !== undefined) {
340
+ const output = outputPathFor(manifest, source);
341
+ href = `${relativeUrl(from, output)}${anchor ? `#${encodeURIComponent(anchor)}` : ''}`;
342
+ canonical = new URL(encodePath(output), siteUrl).href;
343
+ }
344
+ const target = toFsPath(out, from);
345
+ mkdirSync(path.dirname(target), { recursive: true });
346
+ writeFileSync(target, redirectStub({ href, canonical }));
347
+ }
348
+ return manifest.redirects.length;
349
+ }
350
+
351
+ const REFRESH_URL = /<meta http-equiv="refresh" content="0; url=([^"]*)">/g;
352
+ const STUB_SCRIPT = /<script>\(function\(\)\{var t=("(?:[^"\\\n]|\\.)*");location\.replace\(/g;
353
+
354
+ /**
355
+ * Every internal href/src in the generated site must resolve, including
356
+ * #fragments, and so must the URL of a redirect stub's meta refresh. A stub's
357
+ * script target must be readable and equal its meta refresh URL, so the JS and
358
+ * no-JS paths cannot diverge.
359
+ */
360
+ export function checkGeneratedSite(out) {
361
+ const files = [];
362
+ const visit = (directory) => {
363
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
364
+ const full = path.join(directory, entry.name);
365
+ if (entry.isDirectory()) visit(full);
366
+ else if (entry.name.endsWith('.html')) files.push(full);
367
+ }
368
+ };
369
+ visit(out);
370
+ const idCache = new Map();
371
+ const idsOf = (file) => {
372
+ if (!idCache.has(file)) idCache.set(file, new Set(extractHtmlAnchors(readFileSync(file, 'utf8'))));
373
+ return idCache.get(file);
374
+ };
375
+ const failures = [];
376
+ for (const file of files.sort()) {
377
+ const html = readFileSync(file, 'utf8');
378
+ const page = path.relative(out, file).split(path.sep).join('/');
379
+ const references = extractHtmlReferences(html);
380
+ const refreshes = [...html.matchAll(REFRESH_URL)].map(([, url]) => decodeEntities(url));
381
+ for (const url of refreshes) references.push({ attr: 'refresh', url });
382
+ const scripts = [];
383
+ for (const [, literal] of html.matchAll(STUB_SCRIPT)) {
384
+ try {
385
+ scripts.push(JSON.parse(literal));
386
+ } catch {
387
+ failures.push(`${page}: redirect script target ${literal} - not a readable string literal`);
388
+ }
389
+ }
390
+ for (const url of scripts) references.push({ attr: 'script', url });
391
+ if (refreshes.length > 0) {
392
+ const scriptCount = (html.match(/<script\b/gi) ?? []).length;
393
+ if (scriptCount !== 1 || scripts.length !== 1) failures.push(`${page}: redirect stub needs exactly one redirect script, found ${scriptCount}`);
394
+ else if (scripts[0] !== refreshes[0]) failures.push(`${page}: script="${scripts[0]}" - does not match refresh="${refreshes[0]}"`);
395
+ }
396
+ for (const { attr, url } of references) {
397
+ const value = url.trim();
398
+ const label = `${attr}="${value}"`;
399
+ if (/^[a-z][a-z\d+.-]*:/i.test(value) || value.startsWith('//')) continue;
400
+ const hashIndex = value.indexOf('#');
401
+ const pathPart = (hashIndex === -1 ? value : value.slice(0, hashIndex)).split('?')[0];
402
+ let fragment = hashIndex === -1 ? '' : value.slice(hashIndex + 1);
403
+ let decodedPath;
404
+ try {
405
+ decodedPath = decodeURIComponent(pathPart);
406
+ fragment = decodeURIComponent(fragment);
407
+ } catch {
408
+ failures.push(`${page}: ${label} - malformed percent-encoding`);
409
+ continue;
410
+ }
411
+ let target = file;
412
+ if (decodedPath !== '') {
413
+ target = decodedPath.startsWith('/') ? path.join(out, decodedPath) : path.resolve(path.dirname(file), decodedPath);
414
+ const inside = path.relative(out, target);
415
+ if (inside.startsWith('..') || path.isAbsolute(inside)) {
416
+ failures.push(`${page}: ${label} - points outside the generated site`);
417
+ continue;
418
+ }
419
+ let stat;
420
+ try {
421
+ stat = statSync(target);
422
+ } catch {
423
+ failures.push(`${page}: ${label} - missing generated target`);
424
+ continue;
425
+ }
426
+ if (stat.isDirectory()) target = path.join(target, 'index.html');
427
+ try {
428
+ statSync(target);
429
+ } catch {
430
+ failures.push(`${page}: ${label} - directory without index.html`);
431
+ continue;
432
+ }
433
+ }
434
+ if (fragment && target.endsWith('.html') && !idsOf(target).has(fragment)) failures.push(`${page}: ${label} - missing anchor #${fragment}`);
435
+ }
436
+ }
437
+ return { files: files.length, failures };
438
+ }
439
+
440
+ /**
441
+ * `docs-kit build`: gates 1-4, render the site, write docs-bundle.json, then
442
+ * verify the generated links. Returns { exitCode, report }.
443
+ */
444
+ export function runBuild({ root, out, baseUrl }) {
445
+ const result = runChecks({ root, freshness: false });
446
+ if (result.exitCode !== EXIT_OK) {
447
+ const report = `${formatCheckReport(result, root)}\ndocs-kit build: refusing to build until check passes`;
448
+ return { exitCode: result.exitCode, report };
449
+ }
450
+ const { manifest, read } = result;
451
+ const siteUrl = baseUrl ? normalizeBaseUrl(baseUrl) : defaultSiteUrl(manifest.product);
452
+ const sha = headSha(root);
453
+ const prefix = repoPrefix(root);
454
+ assertSafeOut(root, out);
455
+
456
+ rmSync(out, { recursive: true, force: true });
457
+ mkdirSync(out, { recursive: true });
458
+
459
+ const published = new Set(publishedSources(manifest));
460
+ const assets = new Set();
461
+ const rewrite = createHrefRewriter({ root, manifest, sha, prefix, published });
462
+ const titles = new Map();
463
+ const descriptions = new Map();
464
+ const overrides = new Map(manifest.documents.map((document) => [document.source, document.title]));
465
+ for (const source of published) {
466
+ const { analysis } = read(source);
467
+ titles.set(source, documentTitle(analysis, source, overrides.get(source)));
468
+ descriptions.set(source, firstParagraph(analysis));
469
+ }
470
+
471
+ for (const source of published) {
472
+ const { markdown, lineOffset } = read(source);
473
+ const output = outputPathFor(manifest, source);
474
+ const depth = output.split('/').length - 1;
475
+ const toRoot = '../'.repeat(depth);
476
+ const isLanding = source === manifest.landing;
477
+ const { html: body, mermaid } = renderMarkdown(markdown, source, rewrite, assets, lineOffset);
478
+ const sourceUrl = `https://github.com/${manifest.repo}/blob/${sha}/${encodePath(`${prefix}${source}`)}`;
479
+ const content = [
480
+ `<article class="doc">\n${body}\n</article>`,
481
+ isLanding ? sectionCards(manifest, titles, descriptions) : '',
482
+ `<p class="doc-meta">Source: <a href="${escapeHtml(sourceUrl)}">${escapeHtml(source)}</a></p>`,
483
+ ].join('\n');
484
+ const html = sitePage({
485
+ manifest,
486
+ title: isLanding
487
+ ? manifest.tagline
488
+ ? `${manifest.title} — ${manifest.tagline}`
489
+ : `${manifest.title} documentation`
490
+ : `${titles.get(source)} · ${manifest.title}`,
491
+ description: isLanding ? manifest.tagline || descriptions.get(source) : descriptions.get(source),
492
+ navHtml: navigation(manifest, titles, source),
493
+ content,
494
+ toRoot,
495
+ sha,
496
+ mermaid,
497
+ sourcePath: `${prefix}${source}`,
498
+ });
499
+ const target = toFsPath(out, output);
500
+ mkdirSync(path.dirname(target), { recursive: true });
501
+ writeFileSync(target, html);
502
+ }
503
+
504
+ for (const asset of [...assets].sort()) {
505
+ const target = toFsPath(out, asset);
506
+ mkdirSync(path.dirname(target), { recursive: true });
507
+ copyFileSync(toFsPath(root, asset), target);
508
+ }
509
+ const redirects = writeRedirects({ manifest, out, siteUrl, assets });
510
+ writeFileSync(path.join(out, 'docs-kit.css'), SITE_CSS);
511
+ writeFileSync(path.join(out, SITE_SCRIPT_FILE), SITE_SCRIPT);
512
+ writeFileSync(path.join(out, '.nojekyll'), '');
513
+
514
+ const bundle = assembleBundle({ manifest, read, sha, siteUrl, root, prefix });
515
+ writeFileSync(path.join(out, 'docs-bundle.json'), `${JSON.stringify(bundle, null, 2)}\n`);
516
+
517
+ // The marker lists every file this build wrote; the next build replaces
518
+ // the directory only if nothing else has appeared in it.
519
+ writeFileSync(
520
+ path.join(out, BUILD_MARKER),
521
+ `${JSON.stringify({ generator: '@kontourai/corpus', note: 'Written by Corpus build; the directory is replaced on the next build only if it holds exactly these files.', files: filesUnder(out) }, null, 2)}\n`,
522
+ );
523
+ const generated = checkGeneratedSite(out);
524
+ if (generated.failures.length > 0)
525
+ throw new GateFailure(
526
+ `docs-kit build: generated site has ${generated.failures.length} broken internal link${generated.failures.length === 1 ? '' : 's'}:\n` +
527
+ generated.failures.map((failure) => ` - ${failure}`).join('\n'),
528
+ );
529
+ return {
530
+ exitCode: EXIT_OK,
531
+ report:
532
+ `docs-kit build: ${published.size} pages, ${assets.size} images, ${bundle.topics.length} topics${redirects ? `, ${redirects} redirects` : ''} -> ${out}\n` +
533
+ ` verified ${generated.files} generated pages; bundle ${bundle.sha.slice(0, 7)} digest ${bundle.digest.slice(0, 12)} for ${siteUrl}`,
534
+ };
535
+ }