living-docs-kit 0.1.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.
Files changed (33) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +85 -0
  3. package/agents/docs-reviewer.md +33 -0
  4. package/agents/docs-writer.md +31 -0
  5. package/bin/cli.mjs +68 -0
  6. package/package.json +21 -0
  7. package/skills/docs-design/SKILL.md +91 -0
  8. package/skills/docs-design/references/theme-schema.md +108 -0
  9. package/skills/docs-guide/SKILL.md +83 -0
  10. package/skills/docs-init/SKILL.md +136 -0
  11. package/skills/docs-init/assets/DOCS-GUIDE.template.md +81 -0
  12. package/skills/docs-init/assets/agents-snippet.md +7 -0
  13. package/skills/docs-init/assets/deploy-github-pages.yml +43 -0
  14. package/skills/docs-init/assets/site-template/content/index.md +4 -0
  15. package/skills/docs-init/assets/site-template/docs.config.json +36 -0
  16. package/skills/docs-init/assets/site-template/engine/assets/app.js +414 -0
  17. package/skills/docs-init/assets/site-template/engine/assets/base.css +229 -0
  18. package/skills/docs-init/assets/site-template/engine/build.mjs +432 -0
  19. package/skills/docs-init/assets/site-template/engine/check.mjs +100 -0
  20. package/skills/docs-init/assets/site-template/engine/dev.mjs +49 -0
  21. package/skills/docs-init/assets/site-template/engine/facts.mjs +157 -0
  22. package/skills/docs-init/assets/site-template/engine/i18n.mjs +99 -0
  23. package/skills/docs-init/assets/site-template/engine/lib.mjs +368 -0
  24. package/skills/docs-init/assets/site-template/engine/palette.mjs +149 -0
  25. package/skills/docs-init/assets/site-template/engine/theme-tool.mjs +201 -0
  26. package/skills/docs-init/assets/site-template/engine/vendor/mermaid.min.js +3636 -0
  27. package/skills/docs-init/assets/site-template/package.json +24 -0
  28. package/skills/docs-init/assets/site-template/themes/atlas.json +40 -0
  29. package/skills/docs-init/assets/site-template/themes/fjord.json +40 -0
  30. package/skills/docs-init/assets/site-template/themes/graphite.json +45 -0
  31. package/skills/docs-write/SKILL.md +109 -0
  32. package/skills/docs-write/references/authoring.md +125 -0
  33. package/skills/docs-write/references/page-types.md +104 -0
@@ -0,0 +1,432 @@
1
+ // Builds content/**/*.md into a static site in dist/.
2
+ // Usage: node engine/build.mjs [--hosted] [--out dist]
3
+ // --hosted code links point to codeLinks.webBase instead of local editor paths
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import yaml from 'js-yaml';
7
+ import { Marked } from 'marked';
8
+ import {
9
+ SITE_DIR, CONTENT_DIR, loadConfig, loadPages, loadThemes, resolveTheme, themeToCss, relHref, rootPrefix,
10
+ codeUrls, freshness, git, slugify, escapeHtml, walk, parentOf,
11
+ } from './lib.mjs';
12
+ import { strings } from './i18n.mjs';
13
+
14
+ const CALLOUTS = ['NOTE', 'TIP', 'WARNING', 'EDGE', 'RISK', 'UNVERIFIED', 'DECISION'];
15
+
16
+ export async function build({ outDir = path.join(SITE_DIR, 'dist'), hosted = false, dev = false, quiet = false } = {}) {
17
+ const t0 = Date.now();
18
+ const cfg = loadConfig();
19
+ const S = strings(cfg.uiLanguage);
20
+ const themes = loadThemes();
21
+ if (!themes.length) throw new Error('No theme found in themes/. Use the docs-design skill to create one.');
22
+ if (!themes.some((t) => t.id === cfg.defaultTheme)) {
23
+ console.warn(`! defaultTheme "${cfg.defaultTheme}" does not exist; using "${themes[0].id}".`);
24
+ cfg.defaultTheme = themes[0].id;
25
+ }
26
+
27
+ let pages = loadPages();
28
+ pages = addMissingSections(pages);
29
+ const byUrl = new Map(pages.map((p) => [p.url, p]));
30
+ const tree = buildTree(pages);
31
+ const head = git(cfg, ['rev-parse', '--short', 'HEAD']);
32
+ const builtAt = new Date();
33
+ const warnings = [];
34
+
35
+ for (const p of pages) p.fresh = freshness(cfg, p);
36
+
37
+ fs.rmSync(outDir, { recursive: true, force: true });
38
+ fs.mkdirSync(path.join(outDir, 'assets'), { recursive: true });
39
+
40
+ // assets
41
+ for (const f of fs.readdirSync(path.join(SITE_DIR, 'engine', 'assets'))) {
42
+ fs.copyFileSync(path.join(SITE_DIR, 'engine', 'assets', f), path.join(outDir, 'assets', f));
43
+ }
44
+ const mermaidSrc = path.join(SITE_DIR, 'engine', 'vendor', 'mermaid.min.js');
45
+ const hasMermaid = fs.existsSync(mermaidSrc);
46
+ if (hasMermaid) fs.copyFileSync(mermaidSrc, path.join(outDir, 'assets', 'mermaid.min.js'));
47
+ fs.writeFileSync(path.join(outDir, 'assets', 'themes.css'), themes.map(themeToCss).join('\n'));
48
+
49
+ // non-markdown files inside content/ (images, attachments)
50
+ for (const f of walk(CONTENT_DIR, (x) => !x.toLowerCase().endsWith('.md'))) {
51
+ const rel = path.relative(CONTENT_DIR, f);
52
+ fs.mkdirSync(path.dirname(path.join(outDir, rel)), { recursive: true });
53
+ fs.copyFileSync(f, path.join(outDir, rel));
54
+ }
55
+
56
+ const clientCfg = {
57
+ id: cfg.id,
58
+ defaultTheme: cfg.defaultTheme,
59
+ themes: themes.map((t) => {
60
+ const r = resolveTheme(t);
61
+ return { id: t.id, label: t.label || t.id, mode: t.mode || 'light', font: r.fonts.googleFontsUrl || '', baseSize: r.typography.baseSize };
62
+ }),
63
+ s: S,
64
+ mermaid: hasMermaid ? 'assets/mermaid.min.js' : 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js',
65
+ };
66
+
67
+ const searchIndex = [];
68
+ const recent = pages
69
+ .filter((p) => p.url !== '' && p.data.updated && !p.synthetic)
70
+ .sort((a, b) => String(b.data.updated).localeCompare(String(a.data.updated)))
71
+ .slice(0, 6);
72
+
73
+ for (const page of pages) {
74
+ const r = renderMarkdown(page, cfg, S, hosted, warnings);
75
+ const html = pageHtml({ page, cfg, S, tree, byUrl, clientCfg, rendered: r, recent, head, builtAt, hosted, dev });
76
+ const outFile = path.join(outDir, page.url, 'index.html');
77
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
78
+ fs.writeFileSync(outFile, html);
79
+ if (page.data.search !== false) {
80
+ searchIndex.push({
81
+ t: page.title,
82
+ u: page.url,
83
+ s: page.summary,
84
+ h: r.headings.map((h) => h.text).join(' · '),
85
+ x: r.html.replace(/<pre class="diagram-src">[\s\S]*?<\/pre>/g, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').slice(0, 2000),
86
+ });
87
+ }
88
+ }
89
+
90
+ // status page
91
+ const statusPage = {
92
+ url: '_status/', segs: ['_status'], depth: 1, title: S.status, summary: S.statusIntro, data: {}, sources: [], synthetic: true, isIndex: false, fresh: { status: 'none' },
93
+ };
94
+ const statusHtml = pageHtml({
95
+ page: statusPage, cfg, S, tree, byUrl, clientCfg, recent: [], head, builtAt, hosted, dev,
96
+ rendered: { html: statusTable(pages, S), headings: [] },
97
+ });
98
+ fs.mkdirSync(path.join(outDir, '_status'), { recursive: true });
99
+ fs.writeFileSync(path.join(outDir, '_status', 'index.html'), statusHtml);
100
+
101
+ fs.writeFileSync(path.join(outDir, 'assets', 'search-index.js'), 'window.__LD_SEARCH__=' + JSON.stringify(searchIndex) + ';');
102
+
103
+ const stale = pages.filter((p) => p.fresh.status === 'stale').length;
104
+ if (!quiet) {
105
+ console.log(`✓ ${pages.length} pages, ${themes.length} themes → ${path.relative(process.cwd(), outDir) || outDir} (${Date.now() - t0} ms)`);
106
+ if (stale) console.log(`! ${stale} pages possibly outdated (see /_status/)`);
107
+ for (const w of warnings) console.log('! ' + w);
108
+ }
109
+ return { pages: pages.length, warnings, stale };
110
+ }
111
+
112
+ // ---------- structure ----------
113
+
114
+ function addMissingSections(pages) {
115
+ const urls = new Set(pages.map((p) => p.url));
116
+ const extra = [];
117
+ for (const p of pages) {
118
+ let u = p.parentUrl;
119
+ while (u !== null && u !== undefined) {
120
+ if (!urls.has(u)) {
121
+ urls.add(u);
122
+ const segs = u.split('/').filter(Boolean);
123
+ extra.push({
124
+ url: u, segs, depth: segs.length, isIndex: true, parentUrl: parentOf(u), data: {}, body: '', sources: [],
125
+ title: u === '' ? 'Home' : prettify(segs.at(-1)), summary: '', order: 1000, synthetic: true, rel: null,
126
+ });
127
+ }
128
+ u = parentOf(u);
129
+ }
130
+ }
131
+ return [...pages, ...extra];
132
+ }
133
+
134
+ function prettify(seg) {
135
+ const s = seg.replace(/[-_]+/g, ' ');
136
+ return s.charAt(0).toUpperCase() + s.slice(1);
137
+ }
138
+
139
+ function buildTree(pages) {
140
+ const nodes = new Map(pages.map((p) => [p.url, { page: p, children: [] }]));
141
+ for (const p of pages) {
142
+ if (p.url === '') continue;
143
+ const parent = nodes.get(p.parentUrl);
144
+ if (parent) parent.children.push(nodes.get(p.url));
145
+ }
146
+ const sort = (n) => {
147
+ n.children.sort((a, b) => a.page.order - b.page.order || a.page.title.localeCompare(b.page.title));
148
+ n.children.forEach(sort);
149
+ };
150
+ const root = nodes.get('');
151
+ sort(root);
152
+ return { root, nodes };
153
+ }
154
+
155
+ // ---------- markdown ----------
156
+
157
+ function renderMarkdown(page, cfg, S, hosted, warnings) {
158
+ const headings = [];
159
+ const used = new Map();
160
+ const where = page.rel ? `content/${page.rel}` : page.url;
161
+ const m = new Marked({ gfm: true });
162
+ let diagramCount = 0;
163
+
164
+ m.use({
165
+ renderer: {
166
+ heading({ tokens, depth, text }) {
167
+ const inner = this.parser.parseInline(tokens);
168
+ let id = slugify(text);
169
+ const n = used.get(id) || 0;
170
+ used.set(id, n + 1);
171
+ if (n) id += '-' + n;
172
+ if (depth === 2 || depth === 3) headings.push({ depth, id, text: inner.replace(/<[^>]+>/g, '') });
173
+ return `<h${depth} id="${id}">${inner}<a class="anchor" href="#${id}" aria-label="link">#</a></h${depth}>\n`;
174
+ },
175
+ link({ href, title, tokens }) {
176
+ const text = this.parser.parseInline(tokens);
177
+ const t = title ? ` title="${escapeHtml(title)}"` : '';
178
+ if (href.startsWith('code:')) return codeLink(cfg, href, text, S, hosted);
179
+ if (href.startsWith('/')) return `<a href="${escapeHtml(relHref(page.url, href))}"${t}>${text}</a>`;
180
+ if (/^https?:\/\//.test(href)) return `<a href="${escapeHtml(href)}"${t} target="_blank" rel="noopener">${text}</a>`;
181
+ return `<a href="${escapeHtml(href)}"${t}>${text}</a>`;
182
+ },
183
+ image({ href, title, text }) {
184
+ const src = href.startsWith('/') ? relHref(page.url, href).replace(/\/index\.html$/, '') : href;
185
+ return `<img src="${escapeHtml(src)}" alt="${escapeHtml(text || '')}"${title ? ` title="${escapeHtml(title)}"` : ''} loading="lazy">`;
186
+ },
187
+ code({ text, lang }) {
188
+ const l = (lang || '').trim().split(/\s+/)[0];
189
+ if (l === 'mermaid') {
190
+ diagramCount++;
191
+ return diagramHtml(page, text, S);
192
+ }
193
+ if (l === 'decision') return decisionHtml(page, text, cfg, S, hosted, warnings, where);
194
+ return `<pre class="code"><code${l ? ` class="language-${escapeHtml(l)}"` : ''}>${escapeHtml(text)}</code></pre>\n`;
195
+ },
196
+ },
197
+ });
198
+
199
+ let html = m.parse(page.body || '');
200
+ html = html.replace(
201
+ new RegExp(`<blockquote>\\s*<p>\\[!(${CALLOUTS.join('|')})\\][ \\t]*([^\\n<]*)\\n?`, 'g'),
202
+ (_, type, title) => {
203
+ const label = S.callout[type] + (title.trim() ? ': ' + title.trim() : '');
204
+ return `<blockquote class="callout callout-${type.toLowerCase()}"><p class="callout-title">${label}</p><p>`;
205
+ },
206
+ );
207
+ html = html.replace(/<p>\s*<\/p>/g, '');
208
+ html = html.replace(/<table>/g, '<div class="table-wrap"><table>').replace(/<\/table>/g, '</table></div>');
209
+ return { html, headings, diagramCount };
210
+ }
211
+
212
+ function codeLink(cfg, href, text, S, hosted) {
213
+ const u = codeUrls(cfg, href, { hosted });
214
+ const label = text && text !== href ? text : `<code>${escapeHtml(u.label)}</code>`;
215
+ const primary = u.editor || u.web;
216
+ let out = primary
217
+ ? `<a class="code-link" href="${escapeHtml(primary)}" title="${escapeHtml((u.editor ? S.openInEditor : S.openOnWeb) + ': ' + u.label)}">${label}</a>`
218
+ : `<span class="code-link" title="${escapeHtml(u.label)}">${label}</span>`;
219
+ if (u.editor && u.web) out += ` <a class="code-web" href="${escapeHtml(u.web)}" target="_blank" rel="noopener">${S.openOnWeb}</a>`;
220
+ return out;
221
+ }
222
+
223
+ function diagramHtml(page, src, S) {
224
+ let caption = '';
225
+ const lines = src.split('\n').map((line) => {
226
+ const cap = line.match(/^\s*%%\s*caption:\s*(.+)$/i);
227
+ if (cap) {
228
+ caption = cap[1].trim();
229
+ return line;
230
+ }
231
+ // click Node "/site/path/" ["tooltip"] → relative link
232
+ return line.replace(/^(\s*click\s+\S+\s+(?:href\s+)?)"(\/[^"]*)"/, (_, pre, href) => `${pre}"${relHref(page.url, href)}"`);
233
+ });
234
+ const fixed = lines.join('\n');
235
+ const cap = caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : '';
236
+ return `<figure class="diagram">${cap}<div class="diagram-canvas" aria-label="${escapeHtml(caption || 'diagram')}"></div><pre class="diagram-src">${escapeHtml(fixed)}</pre><button type="button" class="diagram-zoom" aria-label="${S.zoom}" title="${S.zoom}">⤢</button></figure>\n`;
237
+ }
238
+
239
+ function decisionHtml(page, src, cfg, S, hosted, warnings, where) {
240
+ let d;
241
+ try {
242
+ d = yaml.load(src);
243
+ if (!d || !Array.isArray(d.inputs) || !Array.isArray(d.rules)) throw new Error('requires "inputs" and "rules"');
244
+ } catch (e) {
245
+ warnings.push(`${where}: invalid decision block (${e.message})`);
246
+ return `<div class="callout callout-warning"><p class="callout-title">decision</p><p>${escapeHtml(e.message)}</p><pre>${escapeHtml(src)}</pre></div>`;
247
+ }
248
+ const inputs = d.inputs.map((i) => (typeof i === 'string' ? { id: i, label: i } : { label: i.id, ...i }));
249
+ const rules = d.rules.map((r) => ({ when: r.when || {}, then: r.then ?? '', note: r.note || '', code: r.code || '' }));
250
+ for (const inp of inputs) {
251
+ const vals = new Set((inp.values || []).map(String));
252
+ for (const r of rules) if (r.when[inp.id] !== undefined) vals.add(String(r.when[inp.id]));
253
+ inp.values = [...vals];
254
+ }
255
+ const data = { inputs: inputs.map(({ id, values }) => ({ id, values })), rules: rules.map((r) => ({ when: Object.fromEntries(Object.entries(r.when).map(([k, v]) => [k, String(v)])), then: String(r.then) })) };
256
+ const controls = inputs
257
+ .map((i) => `<label><span>${escapeHtml(i.label)}</span><select data-input="${escapeHtml(i.id)}"><option value="">${S.decisionAny}</option>${i.values.map((v) => `<option>${escapeHtml(v)}</option>`).join('')}</select></label>`)
258
+ .join('');
259
+ const rows = rules
260
+ .map((r, idx) => {
261
+ const cells = inputs.map((i) => `<td>${r.when[i.id] !== undefined ? escapeHtml(String(r.when[i.id])) : '<span class="muted">—</span>'}</td>`).join('');
262
+ const note = [r.note ? escapeHtml(r.note) : '', r.code ? codeLink(cfg, 'code:' + r.code, '', S, hosted) : ''].filter(Boolean).join(' ');
263
+ return `<tr data-rule="${idx}"><td class="num">${idx + 1}</td>${cells}<td class="then">${escapeHtml(String(r.then))}</td><td>${note}</td></tr>`;
264
+ })
265
+ .join('');
266
+ return `<div class="decision" data-decision="${escapeHtml(JSON.stringify(data))}">
267
+ ${d.title ? `<p class="decision-title">${escapeHtml(d.title)}</p>` : ''}<p class="decision-hint">${S.decisionHint}</p>
268
+ <div class="decision-controls">${controls}</div><div class="decision-result" aria-live="polite"></div>
269
+ <div class="table-wrap"><table><thead><tr><th>#</th>${inputs.map((i) => `<th>${escapeHtml(i.label)}</th>`).join('')}<th>${escapeHtml(d.output || S.decisionResult)}</th><th>${S.note}</th></tr></thead><tbody>${rows}</tbody></table></div></div>\n`;
270
+ }
271
+
272
+ // ---------- page chrome ----------
273
+
274
+ function navHtml(node, page, root, depth = 0) {
275
+ return node.children
276
+ .filter((c) => c.page.data.nav !== false)
277
+ .map((c) => {
278
+ const p = c.page;
279
+ const href = relHref(page.url, '/' + p.url);
280
+ const current = p.url === page.url;
281
+ const inPath = page.url.startsWith(p.url) && p.url !== '';
282
+ const a = `<a href="${href}"${current ? ' aria-current="page"' : ''}>${escapeHtml(p.title)}</a>`;
283
+ const kids = c.children.filter((k) => k.page.data.nav !== false);
284
+ if (!kids.length) return `<li>${a}</li>`;
285
+ return `<li><details${inPath || depth === 0 && p.data.navOpen ? ' open' : ''}><summary>${a}</summary><ul>${navHtml(c, page, root, depth + 1)}</ul></details></li>`;
286
+ })
287
+ .join('');
288
+ }
289
+
290
+ function breadcrumbs(page, byUrl, S) {
291
+ if (page.url === '') return '';
292
+ const parts = [];
293
+ let u = parentOf(page.url);
294
+ while (u !== null) {
295
+ const p = byUrl.get(u);
296
+ parts.unshift(`<a href="${relHref(page.url, '/' + u)}">${escapeHtml(p ? (u === '' ? S.home : p.title) : u)}</a>`);
297
+ u = parentOf(u);
298
+ }
299
+ return `<nav class="crumbs" aria-label="breadcrumb">${parts.join('<span aria-hidden="true">/</span>')}</nav>`;
300
+ }
301
+
302
+ function badge(page, S) {
303
+ const f = page.fresh || { status: 'none' };
304
+ if (f.status === 'none') return '';
305
+ const date = page.data.updated ? ` · ${escapeHtml(String(page.data.updated))}` : '';
306
+ const miss = f.missing && f.missing.length ? ` <span class="badge badge-stale">${S.missing}: ${escapeHtml(f.missing.join(', '))}</span>` : '';
307
+ const txt = { fresh: S.fresh + date, stale: S.stale, unverified: S.unverified }[f.status];
308
+ return `<p class="freshness"><span class="badge badge-${f.status}">${txt}</span>${miss}</p>`;
309
+ }
310
+
311
+ function childrenHtml(page, tree, S) {
312
+ if (!page.isIndex || page.data.children === false) return '';
313
+ const node = tree.nodes.get(page.url);
314
+ if (!node) return '';
315
+ const kids = node.children.filter((k) => k.page.data.nav !== false);
316
+ if (!kids.length) return '';
317
+ const items = kids
318
+ .map((k) => `<li><a href="${relHref(page.url, '/' + k.page.url)}"><span class="child-title">${escapeHtml(k.page.title)}</span>${k.page.summary ? `<span class="child-summary">${escapeHtml(k.page.summary)}</span>` : ''}</a></li>`)
319
+ .join('');
320
+ return `<section class="children" aria-labelledby="deeper"><h2 id="deeper">${S.deeper}</h2><ul>${items}</ul></section>`;
321
+ }
322
+
323
+ function sourcesHtml(page, cfg, S, hosted) {
324
+ if (!page.sources.length) return '';
325
+ return `<aside class="sources"><p>${S.sources}</p><ul>${page.sources.map((s) => `<li>${codeLink(cfg, 'code:' + s, '', S, hosted)}</li>`).join('')}</ul></aside>`;
326
+ }
327
+
328
+ function recentHtml(page, recent, S) {
329
+ if (page.url !== '' || !recent.length) return '';
330
+ return `<section class="recent"><h2 id="recent">${S.recent}</h2><ul>${recent
331
+ .map((p) => `<li><a href="${relHref('', '/' + p.url)}">${escapeHtml(p.title)}</a> <span class="muted">${escapeHtml(String(p.data.updated))}</span></li>`)
332
+ .join('')}</ul></section>`;
333
+ }
334
+
335
+ function statusTable(pages, S) {
336
+ const order = { stale: 0, unverified: 1, fresh: 2, none: 3 };
337
+ const label = { stale: S.stale, unverified: S.unverified, fresh: S.fresh, none: S.noSources };
338
+ const rows = pages
339
+ .filter((p) => !p.synthetic)
340
+ .sort((a, b) => order[a.fresh.status] - order[b.fresh.status] || a.url.localeCompare(b.url))
341
+ .map((p) => `<tr><td><a href="${relHref('_status/', '/' + p.url)}">${escapeHtml(p.title)}</a><br><span class="muted">/${escapeHtml(p.url)}</span></td><td><span class="badge badge-${p.fresh.status}">${label[p.fresh.status]}</span></td><td>${escapeHtml(String(p.data.updated || '—'))}</td></tr>`)
342
+ .join('');
343
+ return `<div class="table-wrap"><table><thead><tr><th>${S.page}</th><th>${S.state}</th><th>${S.updated}</th></tr></thead><tbody>${rows}</tbody></table></div>`;
344
+ }
345
+
346
+ function tocHtml(headings, S) {
347
+ if (headings.length < 2) return '';
348
+ return `<nav class="toc" aria-label="${S.onThisPage}"><p>${S.onThisPage}</p><ul>${headings
349
+ .map((h) => `<li class="toc-${h.depth}"><a href="#${h.id}">${h.text}</a></li>`)
350
+ .join('')}</ul></nav>`;
351
+ }
352
+
353
+ function pageHtml({ page, cfg, S, tree, byUrl, clientCfg, rendered, recent, head, builtAt, hosted, dev }) {
354
+ const root = rootPrefix(page.url);
355
+ const siteTitle = escapeHtml(cfg.title);
356
+ const title = page.url === '' ? siteTitle : `${escapeHtml(page.title)} · ${siteTitle}`;
357
+ const boot = `window.__LD__=${JSON.stringify({ ...clientCfg, root, url: page.url })};
358
+ (function(){var c=window.__LD__,d=document.documentElement,t=null;d.classList.add('js');try{t=localStorage.getItem('ld:'+c.id+':theme')}catch(e){}
359
+ if(!t||!c.themes.some(function(x){return x.id===t}))t=c.defaultTheme;d.setAttribute('data-theme',t);
360
+ var th=c.themes.filter(function(x){return x.id===t})[0];if(th&&th.font){var l=document.createElement('link');l.rel='stylesheet';l.id='ld-font';l.href=th.font;document.head.appendChild(l);}})();`;
361
+ const date = builtAt.toISOString().slice(0, 16).replace('T', ' ');
362
+ const home = `<a class="brand" href="${root}index.html">${siteTitle}</a>`;
363
+ const reload = dev ? `<script>try{new EventSource('/__reload').onmessage=function(){location.reload()}}catch(e){}</script>` : '';
364
+ return `<!doctype html>
365
+ <html lang="${escapeHtml(cfg.uiLanguage)}" data-theme="${escapeHtml(cfg.defaultTheme)}">
366
+ <head>
367
+ <meta charset="utf-8">
368
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
369
+ <title>${title}</title>
370
+ ${page.summary ? `<meta name="description" content="${escapeHtml(page.summary)}">` : ''}
371
+ <link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
372
+ <link rel="stylesheet" href="${root}assets/themes.css">
373
+ <link rel="stylesheet" href="${root}assets/base.css">
374
+ <script>${boot}</script>
375
+ </head>
376
+ <body>
377
+ <a class="skip" href="#main">Skip</a>
378
+ <header class="topbar">
379
+ <button class="nav-toggle" type="button" aria-expanded="false" aria-controls="sidebar">${S.menu}</button>
380
+ ${home}
381
+ <div class="search" role="search">
382
+ <input id="search" type="search" placeholder="${S.search}" aria-label="${S.search}" autocomplete="off" spellcheck="false">
383
+ <kbd>/</kbd>
384
+ <div class="search-results" id="search-results" hidden></div>
385
+ </div>
386
+ <div class="controls">
387
+ <div class="fontsize" role="group" aria-label="${S.fontSize}" title="${escapeHtml(S.fontSizeHint)}">
388
+ <button type="button" data-fs="-1" aria-label="${S.smaller}">A−</button>
389
+ <button type="button" data-fs="0" class="fs-value" aria-label="${S.reset}">100%</button>
390
+ <button type="button" data-fs="1" aria-label="${S.larger}">A+</button>
391
+ </div>
392
+ <label class="theme-pick"><span class="sr">${S.theme}</span><select id="theme-select" aria-label="${S.theme}"></select></label>
393
+ </div>
394
+ </header>
395
+ <div class="layout">
396
+ <aside class="sidebar" id="sidebar">
397
+ <nav aria-label="${S.menu}"><ul class="nav-root"><li><a href="${root}index.html"${page.url === '' ? ' aria-current="page"' : ''}>${S.home}</a></li>${navHtml(tree.root, page, tree.root)}</ul></nav>
398
+ </aside>
399
+ <main id="main">
400
+ <article class="page">
401
+ ${breadcrumbs(page, byUrl, S)}
402
+ <h1>${escapeHtml(page.url === '' ? page.title || cfg.title : page.title)}</h1>
403
+ ${page.summary ? `<p class="summary">${escapeHtml(page.summary)}</p>` : ''}
404
+ ${badge(page, S)}
405
+ <div class="prose">${rendered.html}</div>
406
+ ${childrenHtml(page, tree, S)}
407
+ ${recentHtml(page, recent, S)}
408
+ ${sourcesHtml(page, cfg, S, hosted)}
409
+ </article>
410
+ <footer class="footer">${S.generated} ${date}${head ? ` ${S.fromCommit} <code>${escapeHtml(head)}</code>` : ''} · <a href="${root}_status/index.html">${S.status}</a></footer>
411
+ </main>
412
+ ${tocHtml(rendered.headings, S)}
413
+ </div>
414
+ <div class="zoom-overlay" hidden><div class="zoom-bar"><button type="button" data-z="in" aria-label="${S.zoomIn}">+</button><button type="button" data-z="out" aria-label="${S.zoomOut}">−</button><button type="button" data-z="fit">${S.fit}</button><button type="button" data-z="close">${S.close} (Esc)</button></div><div class="zoom-stage"></div></div>
415
+ <script src="${root}assets/search-index.js" defer></script>
416
+ <script src="${root}assets/app.js" defer></script>
417
+ ${reload}
418
+ </body>
419
+ </html>
420
+ `;
421
+ }
422
+
423
+ // ---------- cli ----------
424
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname);
425
+ if (isMain || process.argv[1]?.endsWith('build.mjs')) {
426
+ const args = process.argv.slice(2);
427
+ const outIdx = args.indexOf('--out');
428
+ build({ hosted: args.includes('--hosted'), outDir: outIdx > -1 ? path.resolve(args[outIdx + 1]) : undefined }).catch((e) => {
429
+ console.error('✗ ' + e.message);
430
+ process.exit(1);
431
+ });
432
+ }
@@ -0,0 +1,100 @@
1
+ // Validates content/: frontmatter, internal links, diagram links, code references, limits, freshness.
2
+ // Usage: node engine/check.mjs [--json] exit code 1 when there are errors
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import yaml from 'js-yaml';
6
+ import { loadConfig, loadPages, freshness, parseCodeRef, hrefToUrlKey, wordCount, CONTENT_DIR, GUIDE_FILE } from './lib.mjs';
7
+
8
+ const cfg = loadConfig();
9
+ const pages = loadPages();
10
+ const urls = new Set(pages.map((p) => p.url));
11
+ // directories without index.md still get an auto-generated section page
12
+ for (const p of pages) { let u = p.parentUrl; while (u) { urls.add(u); u = u.split('/').filter(Boolean).slice(0, -1).join('/'); u = u ? u + '/' : null; } }
13
+ urls.add('');
14
+ urls.add('_status/');
15
+
16
+ const issues = [];
17
+ const add = (level, page, msg) => issues.push({ level, page: page ? 'content/' + page.rel : '-', msg });
18
+ const DIAGRAM_TYPES = /^(flowchart|graph|sequenceDiagram|classDiagram|stateDiagram(-v2)?|erDiagram|journey|gantt|pie|mindmap|timeline|gitGraph|C4Context|C4Container|C4Component|C4Dynamic|C4Deployment|quadrantChart|requirementDiagram|sankey(-beta)?|xychart(-beta)?|block(-beta)?|packet(-beta)?|architecture(-beta)?|kanban|radar(-beta)?|treemap(-beta)?)\b/;
19
+
20
+ function checkCodeRef(page, ref, where) {
21
+ const { file, line } = parseCodeRef(ref);
22
+ const abs = path.join(cfg.projectRootAbs, file);
23
+ if (!fs.existsSync(abs)) return add('error', page, `${where}: file does not exist: ${file}`);
24
+ if (line && fs.statSync(abs).isFile()) {
25
+ const n = fs.readFileSync(abs, 'utf8').split('\n').length;
26
+ if (line > n) add('error', page, `${where}: ${file} has only ${n} lines (reference to line ${line})`);
27
+ }
28
+ }
29
+
30
+ function checkInternal(page, href, where) {
31
+ const key = hrefToUrlKey(href);
32
+ if (path.posix.extname(key.replace(/\/$/, ''))) {
33
+ if (!fs.existsSync(path.join(CONTENT_DIR, key))) add('error', page, `${where}: missing file in content/: /${key}`);
34
+ return;
35
+ }
36
+ if (!urls.has(key)) add('error', page, `${where}: link to a page that does not exist: ${href}`);
37
+ }
38
+
39
+ if (!fs.existsSync(GUIDE_FILE)) add('warning', null, 'DOCS-GUIDE.md is missing: the docs-guide skill can recreate it');
40
+
41
+ for (const page of pages) {
42
+ const d = page.data;
43
+ if (page.frontmatterError) { add('error', page, 'frontmatter YAML invalid: ' + page.frontmatterError); continue; }
44
+ if (!d.title) add('error', page, 'missing "title" in frontmatter');
45
+ if (!d.summary) add('error', page, 'missing "summary" (1–2 sentences, used for overviews and search)');
46
+ else if (String(d.summary).length > cfg.limits.summaryMaxChars) add('warning', page, `summary has ${String(d.summary).length} characters (limit ${cfg.limits.summaryMaxChars})`);
47
+ if (/<!--\s*stub\s*-->/.test(page.body)) add('warning', page, 'stub page, not written yet');
48
+ const words = wordCount(page.body);
49
+ if (words > cfg.limits.pageMaxWords) add('warning', page, `page has ~${words} words (limit ${cfg.limits.pageMaxWords}); split it into subpages`);
50
+ for (const s of page.sources) checkCodeRef(page, s, 'sources');
51
+ const f = freshness(cfg, page);
52
+ if (f.status === 'stale') add('info', page, 'possibly outdated: its sources changed after the last stamp');
53
+ if (f.status === 'unverified') add('info', page, 'has sources but was never verified (run facts.mjs stamp after verifying)');
54
+
55
+ const body = page.body.replace(/\r\n/g, '\n');
56
+ // fenced blocks first, then strip them for link scanning
57
+ const fences = [...body.matchAll(/^```([^\n]*)\n([\s\S]*?)^```/gm)];
58
+ for (const [, info, code] of fences) {
59
+ const lang = info.trim().split(/\s+/)[0];
60
+ if (lang === 'mermaid') {
61
+ const first = code.split('\n').map((l) => l.trim()).find((l) => l && !l.startsWith('%%'));
62
+ if (!first || !DIAGRAM_TYPES.test(first)) add('error', page, `mermaid diagram without a valid type on its first line ("${(first || '').slice(0, 30)}")`);
63
+ for (const m of code.matchAll(/^\s*click\s+\S+\s+(?:href\s+)?"([^"]+)"/gm)) {
64
+ if (m[1].startsWith('/')) checkInternal(page, m[1], 'diagram click');
65
+ }
66
+ const nodes = (code.match(/^\s*\w+\s*[\[\(\{>]/gm) || []).length;
67
+ if (nodes > 25) add('warning', page, `diagram with ~${nodes} nodes; above ~15 it gets hard to read, split it into levels`);
68
+ }
69
+ if (lang === 'decision') {
70
+ try {
71
+ const dd = yaml.load(code);
72
+ if (!Array.isArray(dd?.inputs) || !Array.isArray(dd?.rules)) throw new Error('requires "inputs" and "rules"');
73
+ dd.rules.forEach((r, i) => { if (r.code) checkCodeRef(page, r.code, `decision rule ${i + 1}`); });
74
+ } catch (e) {
75
+ add('error', page, 'invalid decision block: ' + e.message);
76
+ }
77
+ }
78
+ }
79
+ const prose = body.replace(/^```[\s\S]*?^```/gm, '').replace(/`[^`\n]*`/g, '');
80
+ for (const m of prose.matchAll(/\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) {
81
+ const href = m[1];
82
+ if (href.startsWith('code:')) checkCodeRef(page, href, 'code link');
83
+ else if (href.startsWith('/')) checkInternal(page, href, 'link');
84
+ else if (!/^(https?:|mailto:|#)/.test(href)) add('warning', page, `relative link "${href}": use site-absolute paths (/section/page/)`);
85
+ }
86
+ }
87
+
88
+ const sections = new Set(pages.map((p) => p.parentUrl).filter((u) => u !== null && u !== undefined));
89
+ for (const u of sections) if (u && !pages.some((p) => p.url === u)) add('warning', null, `section /${u} has no index.md (an empty page is generated)`);
90
+
91
+ if (process.argv.includes('--json')) console.log(JSON.stringify(issues, null, 2));
92
+ else {
93
+ const icon = { error: '✗', warning: '!', info: '·' };
94
+ for (const i of issues.sort((a, b) => ['error', 'warning', 'info'].indexOf(a.level) - ['error', 'warning', 'info'].indexOf(b.level))) {
95
+ console.log(`${icon[i.level]} ${i.level.toUpperCase().padEnd(7)} ${i.page}: ${i.msg}`);
96
+ }
97
+ const c = (l) => issues.filter((i) => i.level === l).length;
98
+ console.log(`\n${pages.length} pages · ${c('error')} errors · ${c('warning')} warnings · ${c('info')} notes`);
99
+ }
100
+ process.exit(issues.some((i) => i.level === 'error') ? 1 : 0);
@@ -0,0 +1,49 @@
1
+ // Local preview: builds, serves dist/ and rebuilds + reloads the browser on every change.
2
+ // Usage: node engine/dev.mjs [--port 4321]
3
+ import fs from 'node:fs';
4
+ import http from 'node:http';
5
+ import path from 'node:path';
6
+ import { build } from './build.mjs';
7
+ import { SITE_DIR } from './lib.mjs';
8
+
9
+ const args = process.argv.slice(2);
10
+ const port = +(args[args.indexOf('--port') + 1] || process.env.PORT || 4321) || 4321;
11
+ const dist = path.join(SITE_DIR, 'dist');
12
+ const clients = new Set();
13
+ const types = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' };
14
+
15
+ async function rebuild() {
16
+ try {
17
+ await build({ dev: true });
18
+ for (const res of clients) res.write('data: reload\n\n');
19
+ } catch (e) {
20
+ console.error('✗ ' + e.message);
21
+ }
22
+ }
23
+
24
+ await rebuild();
25
+
26
+ let timer;
27
+ const schedule = () => { clearTimeout(timer); timer = setTimeout(rebuild, 150); };
28
+ for (const p of ['content', 'themes', 'engine']) {
29
+ const dir = path.join(SITE_DIR, p);
30
+ if (fs.existsSync(dir)) fs.watch(dir, { recursive: true }, (_, f) => { if (!String(f).includes('vendor')) schedule(); });
31
+ }
32
+ fs.watch(path.join(SITE_DIR, 'docs.config.json'), schedule);
33
+
34
+ http.createServer((req, res) => {
35
+ const url = decodeURIComponent(req.url.split('?')[0]);
36
+ if (url === '/__reload') {
37
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
38
+ res.write(': ok\n\n');
39
+ clients.add(res);
40
+ req.on('close', () => clients.delete(res));
41
+ return;
42
+ }
43
+ let file = path.join(dist, url);
44
+ if (!file.startsWith(dist)) { res.writeHead(403); return res.end(); }
45
+ if (fs.existsSync(file) && fs.statSync(file).isDirectory()) file = path.join(file, 'index.html');
46
+ if (!fs.existsSync(file)) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('404 — page not found'); }
47
+ res.writeHead(200, { 'Content-Type': types[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store' });
48
+ fs.createReadStream(file).pipe(res);
49
+ }).listen(port, () => console.log(`→ http://localhost:${port} (Ctrl+C to stop)`));