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,157 @@
1
+ // Deterministic facts about the project, so the agent documents what exists instead of guessing.
2
+ //
3
+ // node engine/facts.mjs JSON: stack, structure, tests, git, docs coverage
4
+ // node engine/facts.mjs changes JSON: files changed since last generation + affected pages
5
+ // node engine/facts.mjs stamp <page.md…> record that these pages were verified against their sources NOW
6
+ // node engine/facts.mjs mark record HEAD as the last full generation point
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import matter from 'gray-matter';
10
+ import { SITE_DIR, CONTENT_DIR, loadConfig, saveConfig, loadPages, freshness, hashSources, parseCodeRef, git } from './lib.mjs';
11
+
12
+ const cfg = loadConfig();
13
+ const [cmd = 'facts', ...rest] = process.argv.slice(2);
14
+
15
+ const MANIFESTS = ['package.json', 'pnpm-workspace.yaml', 'tsconfig.json', 'pyproject.toml', 'requirements.txt', 'setup.py', 'Pipfile', 'go.mod', 'Cargo.toml', 'pom.xml', 'build.gradle', 'build.gradle.kts', 'composer.json', 'Gemfile', 'mix.exs', 'pubspec.yaml', 'Package.swift', 'Dockerfile', 'docker-compose.yml', 'compose.yaml', 'openapi.yaml', 'openapi.json', 'schema.prisma', '.env.example'];
16
+ const TEST_RE = /(^|\/)(tests?|__tests__|spec|specs)\/|\.(test|spec)\.[a-z0-9]+$|_test\.(go|py|rb|exs)$|(^|\/)test_[^/]+\.py$|Tests?\.(cs|java|kt|swift)$/i;
17
+ const ENTRY_RE = /(^|\/)(main|index|app|server|cli|program|manage|wsgi|asgi)\.[a-z]+$|(^|\/)cmd\/[^/]+\/main\.go$|(^|\/)src\/main\//i;
18
+
19
+ function listProjectFiles() {
20
+ const viaGit = git(cfg, ['ls-files', '--cached', '--others', '--exclude-standard']);
21
+ let files;
22
+ if (viaGit !== null) files = viaGit.split('\n').filter(Boolean);
23
+ else {
24
+ files = [];
25
+ const ex = new Set(cfg.excludeAll.map((e) => e.split('/').pop()));
26
+ (function rec(d) {
27
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
28
+ if (e.name.startsWith('.') || ex.has(e.name)) continue;
29
+ const f = path.join(d, e.name);
30
+ if (e.isDirectory()) rec(f);
31
+ else files.push(path.relative(cfg.projectRootAbs, f).split(path.sep).join('/'));
32
+ }
33
+ })(cfg.projectRootAbs);
34
+ }
35
+ return files.filter((f) => !cfg.excludeAll.some((e) => f === e || f.startsWith(e + '/') || f.includes('/' + e + '/')));
36
+ }
37
+
38
+ function facts() {
39
+ const files = listProjectFiles();
40
+ const langs = {};
41
+ const dirs = {};
42
+ for (const f of files) {
43
+ const ext = path.extname(f).slice(1).toLowerCase() || '(no extension)';
44
+ langs[ext] = (langs[ext] || 0) + 1;
45
+ const parts = f.split('/');
46
+ const key = parts.length > 2 ? parts.slice(0, 2).join('/') : parts.length === 2 ? parts[0] : '.';
47
+ dirs[key] = (dirs[key] || 0) + 1;
48
+ }
49
+ const tests = files.filter((f) => TEST_RE.test(f));
50
+ const pages = loadPages();
51
+ const covered = new Set();
52
+ for (const p of pages) for (const s of p.sources) covered.add(parseCodeRef(s).file.replace(/\/$/, ''));
53
+ const isCovered = (f) => [...covered].some((c) => f === c || f.startsWith(c + '/'));
54
+ const codeFiles = files.filter((f) => /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|cs|rb|php|swift|ex|exs|dart|vue|svelte|scala|c|cc|cpp|h|hpp|sql)$/i.test(f) && !TEST_RE.test(f));
55
+ const statuses = pages.map((p) => ({ page: 'content/' + p.rel, title: p.title, ...freshness(cfg, p) }));
56
+ return {
57
+ projectRoot: cfg.projectRootAbs,
58
+ site: path.relative(cfg.projectRootAbs, SITE_DIR) || '.',
59
+ git: {
60
+ available: git(cfg, ['rev-parse', '--is-inside-work-tree']) === 'true',
61
+ head: git(cfg, ['rev-parse', 'HEAD']),
62
+ branch: git(cfg, ['rev-parse', '--abbrev-ref', 'HEAD']),
63
+ remote: git(cfg, ['remote', 'get-url', 'origin']),
64
+ uncommitted: (git(cfg, ['status', '--porcelain']) || '').split('\n').filter(Boolean).length,
65
+ },
66
+ totals: { files: files.length, codeFiles: codeFiles.length, testFiles: tests.length },
67
+ languages: Object.fromEntries(Object.entries(langs).sort((a, b) => b[1] - a[1]).slice(0, 15)),
68
+ structure: Object.fromEntries(Object.entries(dirs).sort((a, b) => b[1] - a[1]).slice(0, 40)),
69
+ manifests: files.filter((f) => MANIFESTS.includes(path.basename(f))).slice(0, 40),
70
+ readmes: files.filter((f) => /(^|\/)(readme|agents|claude|contributing|architecture)[^/]*\.md$/i.test(f)).slice(0, 60),
71
+ entryCandidates: files.filter((f) => ENTRY_RE.test(f) && !TEST_RE.test(f)).slice(0, 30),
72
+ tests: { sample: tests.slice(0, 40) },
73
+ docs: {
74
+ pages: pages.length,
75
+ guideApproved: cfg.state.guideApproved,
76
+ lastGeneratedCommit: cfg.state.lastGeneratedCommit,
77
+ lastGeneratedAt: cfg.state.lastGeneratedAt,
78
+ stale: statuses.filter((s) => s.status === 'stale').map((s) => s.page),
79
+ unverified: statuses.filter((s) => s.status === 'unverified').map((s) => s.page),
80
+ missingSources: statuses.filter((s) => s.missing && s.missing.length).map((s) => ({ page: s.page, missing: s.missing })),
81
+ uncoveredCodeFiles: codeFiles.filter((f) => !isCovered(f)).slice(0, 80),
82
+ uncoveredCount: codeFiles.filter((f) => !isCovered(f)).length,
83
+ },
84
+ };
85
+ }
86
+
87
+ function changes() {
88
+ const since = rest[0] || cfg.state.lastGeneratedCommit;
89
+ const base = since && git(cfg, ['cat-file', '-e', since + '^{commit}']) !== null ? since : 'HEAD';
90
+ let changed = [];
91
+ {
92
+ changed = (git(cfg, ['diff', '--name-status', base]) || '').split('\n').filter(Boolean).map((l) => {
93
+ const [st, ...p] = l.split('\t');
94
+ return { status: st[0], file: p.at(-1) };
95
+ });
96
+ }
97
+ const untracked = (git(cfg, ['ls-files', '--others', '--exclude-standard']) || '').split('\n').filter(Boolean).map((f) => ({ status: '?', file: f }));
98
+ const all = [...changed, ...untracked].filter((c) => !cfg.excludeAll.some((e) => c.file === e || c.file.startsWith(e + '/')));
99
+ const pages = loadPages();
100
+ const affected = [];
101
+ for (const p of pages) {
102
+ const hits = all.filter((c) => p.sources.some((s) => { const f = parseCodeRef(s).file.replace(/\/$/, ''); return c.file === f || c.file.startsWith(f + '/'); }));
103
+ const st = freshness(cfg, p).status;
104
+ if (hits.length || st === 'stale') affected.push({ page: 'content/' + p.rel, status: st, files: hits.map((h) => h.file) });
105
+ }
106
+ const coveredBySomePage = (f) => pages.some((p) => p.sources.some((s) => { const x = parseCodeRef(s).file.replace(/\/$/, ''); return f === x || f.startsWith(x + '/'); }));
107
+ return {
108
+ since: since || null,
109
+ head: git(cfg, ['rev-parse', 'HEAD']),
110
+ log: since ? (git(cfg, ['log', '--oneline', '--no-merges', `${since}..HEAD`]) || '').split('\n').filter(Boolean).slice(0, 50) : [],
111
+ changedFiles: all,
112
+ affectedPages: affected,
113
+ uncoveredChanges: all.filter((c) => c.status !== 'D' && !coveredBySomePage(c.file)).map((c) => c.file),
114
+ deletedFiles: all.filter((c) => c.status === 'D').map((c) => c.file),
115
+ };
116
+ }
117
+
118
+ function stamp(targets) {
119
+ if (!targets.length) throw new Error('stamp: pass at least one page, e.g. content/architecture/api.md');
120
+ const today = new Date().toISOString().slice(0, 10);
121
+ const head = git(cfg, ['rev-parse', '--short', 'HEAD']);
122
+ const out = [];
123
+ for (const t of targets) {
124
+ const file = path.isAbsolute(t) ? t : fs.existsSync(path.resolve(t)) ? path.resolve(t) : path.join(SITE_DIR, t);
125
+ if (!fs.existsSync(file)) throw new Error('not found: ' + t);
126
+ const src = fs.readFileSync(file, 'utf8');
127
+ const parsed = matter(src, {});
128
+ const sources = [].concat(parsed.data.sources || []);
129
+ if (!sources.length) { out.push({ page: t, skipped: 'no sources' }); continue; }
130
+ const { hash, missing } = hashSources(cfg, sources);
131
+ if (missing.length) throw new Error(`${t}: sources not found: ${missing.join(', ')}`);
132
+ parsed.data.sourcesHash = hash;
133
+ parsed.data.updated = today;
134
+ if (head) parsed.data.commit = head;
135
+ fs.writeFileSync(file, matter.stringify(parsed.content, parsed.data, { lineWidth: -1 }));
136
+ out.push({ page: path.relative(SITE_DIR, file), hash });
137
+ }
138
+ return out;
139
+ }
140
+
141
+ function mark() {
142
+ const head = git(cfg, ['rev-parse', 'HEAD']);
143
+ const at = new Date().toISOString();
144
+ saveConfig((c) => {
145
+ c.state = { ...(c.state || {}), lastGeneratedCommit: head, lastGeneratedAt: at };
146
+ });
147
+ return { lastGeneratedCommit: head, lastGeneratedAt: at };
148
+ }
149
+
150
+ try {
151
+ const result = cmd === 'facts' ? facts() : cmd === 'changes' ? changes() : cmd === 'stamp' ? stamp(rest) : cmd === 'mark' ? mark() : null;
152
+ if (!result) throw new Error(`unknown command "${cmd}". Use: facts | changes [commit] | stamp <pages…> | mark`);
153
+ console.log(JSON.stringify(result, null, 2));
154
+ } catch (e) {
155
+ console.error('✗ ' + e.message);
156
+ process.exit(1);
157
+ }
@@ -0,0 +1,99 @@
1
+ // UI strings. Add a language by copying one block; set "uiLanguage" in docs.config.json.
2
+ export const STRINGS = {
3
+ ro: {
4
+ home: 'Acasă',
5
+ search: 'Caută în documentație',
6
+ searchNoResults: 'Niciun rezultat. Încearcă alt cuvânt.',
7
+ theme: 'Temă',
8
+ themeDefault: 'Implicită',
9
+ fontSize: 'Mărime text',
10
+ fontSizeHint: 'Temporar: se resetează la reîncărcare. Pentru a o păstra, cere agentului să salveze mărimea fontului în design (skill-ul docs-design).',
11
+ smaller: 'Micșorează textul',
12
+ larger: 'Mărește textul',
13
+ reset: 'Mărime implicită',
14
+ menu: 'Meniu',
15
+ onThisPage: 'Pe această pagină',
16
+ deeper: 'Mai în detaliu',
17
+ sources: 'Cod documentat pe această pagină',
18
+ openInEditor: 'Deschide în editor',
19
+ openOnWeb: 'web',
20
+ recent: 'Actualizate recent',
21
+ fresh: 'Verificat cu codul',
22
+ stale: 'Posibil învechit: codul s-a schimbat după ultima verificare',
23
+ unverified: 'Neverificat cu codul',
24
+ missing: 'Fișiere sursă lipsă',
25
+ generated: 'Generat',
26
+ fromCommit: 'din commit',
27
+ status: 'Starea documentației',
28
+ statusIntro: 'Paginile marcate „posibil învechit” descriu cod care s-a schimbat după ultima verificare. Cere agentului să actualizeze documentația.',
29
+ page: 'Pagină',
30
+ state: 'Stare',
31
+ updated: 'Actualizat',
32
+ noSources: 'fără surse',
33
+ zoom: 'Mărește diagrama',
34
+ close: 'Închide',
35
+ zoomIn: 'Apropie',
36
+ zoomOut: 'Depărtează',
37
+ fit: 'Încadrează',
38
+ diagramError: 'Diagrama nu a putut fi desenată. Sursa ei:',
39
+ decisionAny: '(oricare)',
40
+ decisionResult: 'Rezultat',
41
+ decisionNoMatch: 'Nicio regulă nu se potrivește cu această combinație.',
42
+ decisionPartial: '{n} reguli posibile. Alege toate valorile pentru un rezultat exact.',
43
+ decisionHint: 'Alege valori pentru a vedea ce regulă se aplică. Regulile se verifică în ordine; prima potrivire câștigă.',
44
+ rule: 'Regula',
45
+ note: 'Notă',
46
+ code: 'Cod',
47
+ callout: { NOTE: 'Notă', TIP: 'Sfat', WARNING: 'Atenție', EDGE: 'Caz particular', RISK: 'Risc', UNVERIFIED: 'Neverificat', DECISION: 'Decizie' },
48
+ },
49
+ en: {
50
+ home: 'Home',
51
+ search: 'Search the docs',
52
+ searchNoResults: 'No results. Try another word.',
53
+ theme: 'Theme',
54
+ themeDefault: 'Default',
55
+ fontSize: 'Text size',
56
+ fontSizeHint: 'Temporary: resets on reload. To keep it, ask the agent to save the font size into the design (docs-design skill).',
57
+ smaller: 'Smaller text',
58
+ larger: 'Larger text',
59
+ reset: 'Default size',
60
+ menu: 'Menu',
61
+ onThisPage: 'On this page',
62
+ deeper: 'Go deeper',
63
+ sources: 'Code documented on this page',
64
+ openInEditor: 'Open in editor',
65
+ openOnWeb: 'web',
66
+ recent: 'Recently updated',
67
+ fresh: 'Verified against code',
68
+ stale: 'Possibly outdated: code changed after last verification',
69
+ unverified: 'Not verified against code',
70
+ missing: 'Missing source files',
71
+ generated: 'Generated',
72
+ fromCommit: 'from commit',
73
+ status: 'Documentation status',
74
+ statusIntro: 'Pages marked “possibly outdated” describe code that changed after their last verification. Ask the agent to update the docs.',
75
+ page: 'Page',
76
+ state: 'State',
77
+ updated: 'Updated',
78
+ noSources: 'no sources',
79
+ zoom: 'Enlarge diagram',
80
+ close: 'Close',
81
+ zoomIn: 'Zoom in',
82
+ zoomOut: 'Zoom out',
83
+ fit: 'Fit',
84
+ diagramError: 'The diagram could not be drawn. Its source:',
85
+ decisionAny: '(any)',
86
+ decisionResult: 'Result',
87
+ decisionNoMatch: 'No rule matches this combination.',
88
+ decisionPartial: '{n} possible rules. Pick every value for an exact result.',
89
+ decisionHint: 'Pick values to see which rule applies. Rules are checked in order; the first match wins.',
90
+ rule: 'Rule',
91
+ note: 'Note',
92
+ code: 'Code',
93
+ callout: { NOTE: 'Note', TIP: 'Tip', WARNING: 'Warning', EDGE: 'Edge case', RISK: 'Risk', UNVERIFIED: 'Unverified', DECISION: 'Decision' },
94
+ },
95
+ };
96
+
97
+ export function strings(lang) {
98
+ return STRINGS[lang] || STRINGS.en;
99
+ }
@@ -0,0 +1,368 @@
1
+ // Shared helpers for the living-docs engine: config, pages, themes, hashing, git.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+ import matter from 'gray-matter';
8
+
9
+ export const SITE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
+ export const CONTENT_DIR = path.join(SITE_DIR, 'content');
11
+ export const THEMES_DIR = path.join(SITE_DIR, 'themes');
12
+ export const CONFIG_FILE = path.join(SITE_DIR, 'docs.config.json');
13
+ export const GUIDE_FILE = path.join(SITE_DIR, 'DOCS-GUIDE.md');
14
+
15
+ const CONFIG_DEFAULTS = {
16
+ id: 'docs',
17
+ title: 'Documentation',
18
+ description: '',
19
+ uiLanguage: 'en',
20
+ projectRoot: '..',
21
+ defaultTheme: 'fjord',
22
+ codeLinks: { mode: 'editor', editor: 'cursor', webBase: '' },
23
+ limits: { summaryMaxChars: 280, pageMaxWords: 900 },
24
+ exclude: ['node_modules', 'dist', 'build', 'out', '.git', 'vendor', '.venv', 'venv', '__pycache__', 'coverage', '.next', 'target'],
25
+ state: { guideApproved: false, lastGeneratedCommit: null, lastGeneratedAt: null },
26
+ };
27
+
28
+ export function loadConfig() {
29
+ let raw = {};
30
+ if (fs.existsSync(CONFIG_FILE)) raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
31
+ const cfg = {
32
+ ...CONFIG_DEFAULTS,
33
+ ...raw,
34
+ codeLinks: { ...CONFIG_DEFAULTS.codeLinks, ...(raw.codeLinks || {}) },
35
+ limits: { ...CONFIG_DEFAULTS.limits, ...(raw.limits || {}) },
36
+ state: { ...CONFIG_DEFAULTS.state, ...(raw.state || {}) },
37
+ };
38
+ cfg.projectRootAbs = path.resolve(SITE_DIR, cfg.projectRoot);
39
+ const siteRel = path.relative(cfg.projectRootAbs, SITE_DIR).split(path.sep).join('/');
40
+ cfg.excludeAll = [...new Set([...(cfg.exclude || []), siteRel].filter(Boolean))];
41
+ return cfg;
42
+ }
43
+
44
+ export function saveConfig(mutator) {
45
+ const raw = fs.existsSync(CONFIG_FILE) ? JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')) : {};
46
+ mutator(raw);
47
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(raw, null, 2) + '\n');
48
+ return raw;
49
+ }
50
+
51
+ // ---------- pages ----------
52
+
53
+ export function walk(dir, filter = () => true, acc = []) {
54
+ if (!fs.existsSync(dir)) return acc;
55
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
56
+ if (entry.name.startsWith('.')) continue;
57
+ const full = path.join(dir, entry.name);
58
+ if (entry.isDirectory()) walk(full, filter, acc);
59
+ else if (filter(full)) acc.push(full);
60
+ }
61
+ return acc;
62
+ }
63
+
64
+ /** url is '' for home, 'a/' for a/index.md, 'a/b/' for a/b.md */
65
+ export function fileToUrl(rel) {
66
+ const noExt = rel.replace(/\\/g, '/').replace(/\.md$/i, '');
67
+ if (noExt === 'index') return '';
68
+ if (noExt.endsWith('/index')) return noExt.slice(0, -'index'.length);
69
+ return noExt + '/';
70
+ }
71
+
72
+ export function loadPages() {
73
+ const files = walk(CONTENT_DIR, (f) => f.toLowerCase().endsWith('.md')).sort();
74
+ return files.map((file) => {
75
+ const rel = path.relative(CONTENT_DIR, file).split(path.sep).join('/');
76
+ const src = fs.readFileSync(file, 'utf8');
77
+ let parsed;
78
+ try {
79
+ parsed = matter(src, {});
80
+ } catch (e) {
81
+ parsed = { data: {}, content: src, error: e.message };
82
+ }
83
+ const url = fileToUrl(rel);
84
+ const segs = url.split('/').filter(Boolean);
85
+ const isIndex = /(^|\/)index\.md$/i.test(rel);
86
+ const data = { ...(parsed.data || {}) };
87
+ for (const [k, v] of Object.entries(data)) if (v instanceof Date) data[k] = v.toISOString().slice(0, 10);
88
+ return {
89
+ file,
90
+ rel,
91
+ url,
92
+ segs,
93
+ depth: segs.length,
94
+ isIndex,
95
+ parentUrl: parentOf(url),
96
+ data,
97
+ body: parsed.content || '',
98
+ frontmatterError: parsed.error || null,
99
+ title: String(data.title || (segs.at(-1) || 'Home')),
100
+ summary: data.summary ? String(data.summary).trim() : '',
101
+ order: typeof data.order === 'number' ? data.order : 1000,
102
+ sources: normalizeSources(data.sources),
103
+ };
104
+ });
105
+ }
106
+
107
+ export function parentOf(url) {
108
+ if (!url) return null;
109
+ const segs = url.split('/').filter(Boolean);
110
+ segs.pop();
111
+ return segs.length ? segs.join('/') + '/' : '';
112
+ }
113
+
114
+ export function normalizeSources(src) {
115
+ if (!src) return [];
116
+ const list = Array.isArray(src) ? src : [src];
117
+ return list.map((s) => String(s).trim()).filter(Boolean);
118
+ }
119
+
120
+ /** Resolve a site-absolute href ('/a/b/#x') to a relative one from a page url. */
121
+ export function relHref(fromUrl, href) {
122
+ if (!href.startsWith('/')) return href;
123
+ const depth = fromUrl.split('/').filter(Boolean).length;
124
+ const prefix = depth ? '../'.repeat(depth) : './';
125
+ let [p, hash] = splitHash(href.slice(1));
126
+ if (p === '' || p.endsWith('/')) p += 'index.html';
127
+ else if (!path.posix.extname(p)) p += '/index.html';
128
+ return prefix + p + (hash ? '#' + hash : '');
129
+ }
130
+
131
+ export function rootPrefix(fromUrl) {
132
+ const depth = fromUrl.split('/').filter(Boolean).length;
133
+ return depth ? '../'.repeat(depth) : './';
134
+ }
135
+
136
+ function splitHash(s) {
137
+ const i = s.indexOf('#');
138
+ return i === -1 ? [s, ''] : [s.slice(0, i), s.slice(i + 1)];
139
+ }
140
+
141
+ /** Normalise an internal link target to a page url key ('a/b/'). */
142
+ export function hrefToUrlKey(href) {
143
+ let [p] = splitHash(href.replace(/^\//, ''));
144
+ p = p.replace(/index\.html$/, '');
145
+ if (p && !p.endsWith('/') && !path.posix.extname(p)) p += '/';
146
+ return p;
147
+ }
148
+
149
+ // ---------- code references ----------
150
+
151
+ /** 'src/a.ts#L10-20' | 'src/a.ts:10' -> { file, line, lineEnd } */
152
+ export function parseCodeRef(ref) {
153
+ let s = ref.replace(/^code:/, '').trim();
154
+ let line = null;
155
+ let lineEnd = null;
156
+ let m = s.match(/#L(\d+)(?:-L?(\d+))?$/);
157
+ if (m) {
158
+ line = +m[1];
159
+ lineEnd = m[2] ? +m[2] : null;
160
+ s = s.slice(0, m.index);
161
+ } else if ((m = s.match(/:(\d+)(?:-(\d+))?$/))) {
162
+ line = +m[1];
163
+ lineEnd = m[2] ? +m[2] : null;
164
+ s = s.slice(0, m.index);
165
+ }
166
+ return { file: s.replace(/^\.?\//, ''), line, lineEnd };
167
+ }
168
+
169
+ export function codeUrls(cfg, ref, { hosted = false } = {}) {
170
+ const { file, line, lineEnd } = parseCodeRef(ref);
171
+ const mode = hosted ? 'web' : cfg.codeLinks.mode;
172
+ const out = {};
173
+ if ((mode === 'editor' || mode === 'both') && cfg.codeLinks.editor !== 'none') {
174
+ const abs = path.join(cfg.projectRootAbs, file).split(path.sep).join('/');
175
+ const scheme = cfg.codeLinks.editor === 'vscode' ? 'vscode' : 'cursor';
176
+ const absUrl = abs.startsWith('/') ? abs : '/' + abs;
177
+ out.editor = `${scheme}://file${encodeURI(absUrl)}${line ? ':' + line : ''}`;
178
+ }
179
+ if ((mode === 'web' || mode === 'both') && cfg.codeLinks.webBase) {
180
+ const base = cfg.codeLinks.webBase.replace(/\/?$/, '/');
181
+ out.web = base + encodeURI(file) + (line ? `#L${line}${lineEnd ? '-L' + lineEnd : ''}` : '');
182
+ }
183
+ out.label = file + (line ? `:${line}${lineEnd ? '-' + lineEnd : ''}` : '');
184
+ out.file = file;
185
+ out.line = line;
186
+ return out;
187
+ }
188
+
189
+ // ---------- hashing / freshness ----------
190
+
191
+ function listSourceFiles(cfg, rel) {
192
+ const abs = path.join(cfg.projectRootAbs, rel);
193
+ if (!fs.existsSync(abs)) return { files: [], missing: true };
194
+ const st = fs.statSync(abs);
195
+ if (st.isFile()) return { files: [abs], missing: false };
196
+ const ex = new Set(cfg.excludeAll.map((e) => e.split('/').pop()));
197
+ const files = [];
198
+ (function rec(d) {
199
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
200
+ if (e.name.startsWith('.') || ex.has(e.name)) continue;
201
+ const f = path.join(d, e.name);
202
+ if (e.isDirectory()) rec(f);
203
+ else files.push(f);
204
+ }
205
+ })(abs);
206
+ return { files: files.sort(), missing: false };
207
+ }
208
+
209
+ export function hashSources(cfg, sources) {
210
+ const h = crypto.createHash('sha1');
211
+ const missing = [];
212
+ for (const s of [...sources].sort()) {
213
+ const { file } = parseCodeRef(s);
214
+ const { files, missing: miss } = listSourceFiles(cfg, file);
215
+ if (miss) {
216
+ missing.push(file);
217
+ h.update('MISSING:' + file + '\n');
218
+ continue;
219
+ }
220
+ for (const f of files) {
221
+ h.update(path.relative(cfg.projectRootAbs, f).split(path.sep).join('/') + '\n');
222
+ h.update(fs.readFileSync(f));
223
+ }
224
+ }
225
+ return { hash: h.digest('hex').slice(0, 16), missing };
226
+ }
227
+
228
+ /** status: 'fresh' | 'stale' | 'unverified' | 'none' */
229
+ export function freshness(cfg, page) {
230
+ if (!page.sources.length) return { status: 'none' };
231
+ const { hash, missing } = hashSources(cfg, page.sources);
232
+ if (!page.data.sourcesHash) return { status: 'unverified', missing };
233
+ return { status: page.data.sourcesHash === hash ? 'fresh' : 'stale', missing, hash };
234
+ }
235
+
236
+ // ---------- git ----------
237
+
238
+ export function git(cfg, args) {
239
+ try {
240
+ return execFileSync('git', args, { cwd: cfg.projectRootAbs, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
241
+ } catch {
242
+ return null;
243
+ }
244
+ }
245
+
246
+ // ---------- themes ----------
247
+
248
+ export const REQUIRED_COLORS = ['bg', 'surface', 'text', 'textMuted', 'border', 'accent', 'accentText', 'link'];
249
+ export const OPTIONAL_COLORS = ['heading', 'surfaceAlt', 'codeBg', 'codeText', 'success', 'warning', 'danger', 'info', 'highlight'];
250
+
251
+ export function loadThemes() {
252
+ const files = fs.existsSync(THEMES_DIR) ? fs.readdirSync(THEMES_DIR).filter((f) => f.endsWith('.json')).sort() : [];
253
+ return files.map((f) => {
254
+ const t = JSON.parse(fs.readFileSync(path.join(THEMES_DIR, f), 'utf8'));
255
+ t._file = f;
256
+ if (!t.id) t.id = f.replace(/\.json$/, '');
257
+ return t;
258
+ });
259
+ }
260
+
261
+ export function hexToRgb(hex) {
262
+ let h = String(hex).trim().replace('#', '');
263
+ if (h.length === 3) h = h.split('').map((c) => c + c).join('');
264
+ if (!/^[0-9a-fA-F]{6}$/.test(h)) return null;
265
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
266
+ }
267
+ export function rgbToHex([r, g, b]) {
268
+ return '#' + [r, g, b].map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')).join('');
269
+ }
270
+ export function mix(a, b, t) {
271
+ const A = hexToRgb(a);
272
+ const B = hexToRgb(b);
273
+ if (!A || !B) return a;
274
+ return rgbToHex(A.map((v, i) => v + (B[i] - v) * t));
275
+ }
276
+ export function luminance(hex) {
277
+ const rgb = hexToRgb(hex);
278
+ if (!rgb) return null;
279
+ const [r, g, b] = rgb.map((v) => {
280
+ v /= 255;
281
+ return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
282
+ });
283
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
284
+ }
285
+ export function contrast(a, b) {
286
+ const la = luminance(a);
287
+ const lb = luminance(b);
288
+ if (la == null || lb == null) return null;
289
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
290
+ }
291
+
292
+ /** Fill optional tokens so every theme exposes the full variable set. */
293
+ export function resolveTheme(t) {
294
+ const c = { ...(t.colors || {}) };
295
+ const dark = t.mode === 'dark';
296
+ c.heading ||= c.text;
297
+ c.surfaceAlt ||= mix(c.surface, c.text, dark ? 0.08 : 0.05);
298
+ c.codeBg ||= c.surfaceAlt;
299
+ c.codeText ||= c.text;
300
+ c.success ||= dark ? '#5fbf8f' : '#1f7a4d';
301
+ c.warning ||= dark ? '#e3b04b' : '#8a5a00';
302
+ c.danger ||= dark ? '#ef7b7b' : '#b42318';
303
+ c.info ||= c.link;
304
+ c.highlight ||= mix(c.bg, c.accent, dark ? 0.28 : 0.18);
305
+ const fonts = {
306
+ body: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",
307
+ heading: null,
308
+ mono: "ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace",
309
+ googleFontsUrl: '',
310
+ ...(t.fonts || {}),
311
+ };
312
+ fonts.heading ||= fonts.body;
313
+ const typography = { baseSize: 16, lineHeight: 1.65, headingWeight: 700, ...(t.typography || {}) };
314
+ const layout = { contentMaxWidth: 760, sidebarWidth: 280, radius: 8, ...(t.layout || {}) };
315
+ const d = t.diagram || {};
316
+ const diagram = {
317
+ nodeBg: d.nodeBg || c.surfaceAlt,
318
+ nodeBorder: d.nodeBorder || c.accent,
319
+ nodeText: d.nodeText || c.text,
320
+ lineColor: d.lineColor || c.textMuted,
321
+ clusterBg: d.clusterBg || c.surface,
322
+ noteBg: d.noteBg || c.highlight,
323
+ };
324
+ return { ...t, colors: c, fonts, typography, layout, diagram };
325
+ }
326
+
327
+ const kebab = (s) => s.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
328
+ const px = (v) => (typeof v === 'number' ? v + 'px' : String(v));
329
+
330
+ export function themeToCss(raw) {
331
+ const t = resolveTheme(raw);
332
+ const vars = [];
333
+ for (const [k, v] of Object.entries(t.colors)) vars.push(`--c-${kebab(k)}:${v}`);
334
+ for (const [k, v] of Object.entries(t.diagram)) vars.push(`--d-${kebab(k)}:${v}`);
335
+ vars.push(`--font-body:${t.fonts.body}`, `--font-heading:${t.fonts.heading}`, `--font-mono:${t.fonts.mono}`);
336
+ vars.push(`--base-size:${px(t.typography.baseSize)}`, `--line-height:${t.typography.lineHeight}`, `--heading-weight:${t.typography.headingWeight}`);
337
+ vars.push(`--content-max:${px(t.layout.contentMaxWidth)}`, `--sidebar-w:${px(t.layout.sidebarWidth)}`, `--radius:${px(t.layout.radius)}`);
338
+ vars.push(`color-scheme:${t.mode === 'dark' ? 'dark' : 'light'}`);
339
+ let css = `:root[data-theme="${t.id}"]{${vars.join(';')}}\n`;
340
+ if (t.extraCss) css += t.extraCss.trim() + '\n';
341
+ return css;
342
+ }
343
+
344
+ // ---------- misc ----------
345
+
346
+ export function slugify(s) {
347
+ return String(s)
348
+ .normalize('NFD')
349
+ .replace(/[\u0300-\u036f]/g, '')
350
+ .toLowerCase()
351
+ .replace(/<[^>]+>/g, '')
352
+ .replace(/[^a-z0-9\s-]/g, '')
353
+ .trim()
354
+ .replace(/\s+/g, '-')
355
+ .replace(/-+/g, '-') || 'sectiune';
356
+ }
357
+
358
+ export function escapeHtml(s) {
359
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
360
+ }
361
+
362
+ export function wordCount(md) {
363
+ return md
364
+ .replace(/```[\s\S]*?```/g, ' ')
365
+ .replace(/[#>*_`|\-]/g, ' ')
366
+ .split(/\s+/)
367
+ .filter(Boolean).length;
368
+ }