dotmd-cli 0.7.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/deps.mjs ADDED
@@ -0,0 +1,249 @@
1
+ import path from 'node:path';
2
+ import { buildGraph } from './graph.mjs';
3
+ import { buildIndex } from './index.mjs';
4
+ import { resolveDocPath, toSlug, toRepoPath, die, warn } from './util.mjs';
5
+ import { bold, dim } from './color.mjs';
6
+
7
+ export function runDeps(argv, config) {
8
+ const positional = [];
9
+ let json = false;
10
+ let maxDepth = 5;
11
+
12
+ for (let i = 0; i < argv.length; i++) {
13
+ if (argv[i] === '--json') { json = true; continue; }
14
+ if (argv[i] === '--depth' && argv[i + 1]) { maxDepth = Number.parseInt(argv[++i], 10) || 5; continue; }
15
+ if (argv[i] === '--config') { i++; continue; }
16
+ if (argv[i].startsWith('-')) continue;
17
+ positional.push(argv[i]);
18
+ }
19
+
20
+ const index = buildIndex(config);
21
+ const graph = buildGraph(index, config);
22
+ const docByPath = new Map(index.docs.map(d => [d.path, d]));
23
+
24
+ // Build adjacency maps
25
+ const forwardMap = new Map(); // source → [{target, field, broken}]
26
+ const reverseMap = new Map(); // target → [{source, field}]
27
+
28
+ for (const edge of graph.edges) {
29
+ if (!forwardMap.has(edge.source)) forwardMap.set(edge.source, []);
30
+ forwardMap.get(edge.source).push({ target: edge.target, field: edge.field, broken: edge.broken });
31
+ if (!edge.broken) {
32
+ if (!reverseMap.has(edge.target)) reverseMap.set(edge.target, []);
33
+ reverseMap.get(edge.target).push({ source: edge.source, field: edge.field });
34
+ }
35
+ }
36
+
37
+ const input = positional[0];
38
+
39
+ if (input) {
40
+ // Tree view for a specific doc
41
+ const filePath = resolveDocPath(input, config);
42
+ if (!filePath) die(`File not found: ${input}`);
43
+ const repoPath = toRepoPath(filePath, config.repoRoot);
44
+ const doc = docByPath.get(repoPath);
45
+ if (!doc) die(`Doc not in index: ${repoPath}`);
46
+
47
+ if (json) {
48
+ renderTreeJson(doc, forwardMap, reverseMap, docByPath, maxDepth);
49
+ } else {
50
+ renderTree(doc, forwardMap, reverseMap, docByPath, maxDepth);
51
+ }
52
+ } else {
53
+ // Flat overview
54
+ if (json) {
55
+ renderFlatJson(graph, forwardMap, reverseMap, docByPath);
56
+ } else {
57
+ renderFlat(graph, forwardMap, reverseMap, docByPath);
58
+ }
59
+ }
60
+ }
61
+
62
+ // ── Tree view ──────────────────────────────────────────────────────────
63
+
64
+ function renderTree(doc, forwardMap, reverseMap, docByPath, maxDepth) {
65
+ const slug = path.basename(doc.path, '.md');
66
+ process.stdout.write(`${bold(slug)} ${dim(`(${doc.status})`)}\n`);
67
+
68
+ const forward = forwardMap.get(doc.path) || [];
69
+ const reverse = reverseMap.get(doc.path) || [];
70
+
71
+ if (forward.length > 0) {
72
+ process.stdout.write(`\n${bold('Depends on:')}\n`);
73
+ for (const edge of forward) {
74
+ printTreeNode(edge.target, edge.field, edge.broken, forwardMap, docByPath, maxDepth, 1, new Set([doc.path]));
75
+ }
76
+ }
77
+
78
+ if (reverse.length > 0) {
79
+ process.stdout.write(`\n${bold('Depended on by:')}\n`);
80
+ for (const edge of reverse) {
81
+ const targetSlug = path.basename(edge.source, '.md');
82
+ const targetDoc = docByPath.get(edge.source);
83
+ const status = targetDoc?.status ?? 'unknown';
84
+ process.stdout.write(` ${targetSlug} ${dim(`(${status})`)} ${dim(`via ${edge.field}`)}\n`);
85
+ }
86
+ }
87
+
88
+ if (doc.blockers?.length > 0) {
89
+ process.stdout.write(`\n${bold('Blockers:')}\n`);
90
+ for (const b of doc.blockers) {
91
+ process.stdout.write(` - ${b}\n`);
92
+ }
93
+ }
94
+
95
+ if (forward.length === 0 && reverse.length === 0 && !doc.blockers?.length) {
96
+ process.stdout.write(`\n${dim('No dependencies found.')}\n`);
97
+ }
98
+
99
+ process.stdout.write('\n');
100
+ }
101
+
102
+ function printTreeNode(targetPath, field, broken, forwardMap, docByPath, maxDepth, depth, visited) {
103
+ const indent = ' '.repeat(depth);
104
+ const targetSlug = path.basename(targetPath, '.md');
105
+ const targetDoc = docByPath.get(targetPath);
106
+ const status = targetDoc?.status ?? 'unknown';
107
+
108
+ let suffix = dim(` via ${field}`);
109
+ if (broken) suffix += ' ' + dim('[broken]');
110
+ if (visited.has(targetPath)) {
111
+ process.stdout.write(`${indent}${targetSlug} ${dim(`(${status})`)}${suffix} ${dim('[cycle]')}\n`);
112
+ return;
113
+ }
114
+
115
+ process.stdout.write(`${indent}${targetSlug} ${dim(`(${status})`)}${suffix}\n`);
116
+
117
+ if (depth >= maxDepth) return;
118
+
119
+ const children = forwardMap.get(targetPath) || [];
120
+ const nextVisited = new Set(visited);
121
+ nextVisited.add(targetPath);
122
+ for (const child of children) {
123
+ printTreeNode(child.target, child.field, child.broken, forwardMap, docByPath, maxDepth, depth + 1, nextVisited);
124
+ }
125
+ }
126
+
127
+ function renderTreeJson(doc, forwardMap, reverseMap, docByPath, maxDepth) {
128
+ const slug = path.basename(doc.path, '.md');
129
+
130
+ function walkForward(docPath, depth, visited) {
131
+ const edges = forwardMap.get(docPath) || [];
132
+ return edges.map(e => {
133
+ const d = docByPath.get(e.target);
134
+ const node = {
135
+ path: e.target,
136
+ slug: path.basename(e.target, '.md'),
137
+ status: d?.status ?? 'unknown',
138
+ field: e.field,
139
+ broken: e.broken || false,
140
+ cycle: visited.has(e.target),
141
+ };
142
+ if (!node.cycle && !node.broken && depth < maxDepth) {
143
+ const next = new Set(visited);
144
+ next.add(e.target);
145
+ node.dependsOn = walkForward(e.target, depth + 1, next);
146
+ }
147
+ return node;
148
+ });
149
+ }
150
+
151
+ const result = {
152
+ path: doc.path,
153
+ slug,
154
+ status: doc.status,
155
+ dependsOn: walkForward(doc.path, 1, new Set([doc.path])),
156
+ dependedOnBy: (reverseMap.get(doc.path) || []).map(e => {
157
+ const d = docByPath.get(e.source);
158
+ return { path: e.source, slug: path.basename(e.source, '.md'), status: d?.status ?? 'unknown', field: e.field };
159
+ }),
160
+ blockers: doc.blockers || [],
161
+ };
162
+
163
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
164
+ }
165
+
166
+ // ── Flat overview ──────────────────────────────────────────────────────
167
+
168
+ function renderFlat(graph, forwardMap, reverseMap, docByPath) {
169
+ process.stdout.write(bold('Deps') + dim(` — ${graph.stats.nodeCount} docs, ${graph.stats.edgeCount} edges`) + '\n\n');
170
+
171
+ if (graph.stats.edgeCount === 0) {
172
+ process.stdout.write(dim('No dependencies found. Add referenceFields to your config.') + '\n');
173
+ return;
174
+ }
175
+
176
+ // Most blocking: nodes with the most reverse edges
177
+ const blockingCounts = [...reverseMap.entries()]
178
+ .map(([p, edges]) => ({ path: p, count: edges.length }))
179
+ .sort((a, b) => b.count - a.count)
180
+ .slice(0, 10);
181
+
182
+ if (blockingCounts.length > 0) {
183
+ process.stdout.write(bold('Most blocking') + dim(' (depended on by the most docs):') + '\n');
184
+ for (const { path: p, count } of blockingCounts) {
185
+ const doc = docByPath.get(p);
186
+ const slug = path.basename(p, '.md').padEnd(24);
187
+ process.stdout.write(` ${slug} ${dim(`(${doc?.status ?? '?'})`)} blocks ${count}\n`);
188
+ }
189
+ process.stdout.write('\n');
190
+ }
191
+
192
+ // Most blocked: nodes with the most forward edges
193
+ const blockedCounts = [...forwardMap.entries()]
194
+ .map(([p, edges]) => ({ path: p, count: edges.filter(e => !e.broken).length }))
195
+ .filter(e => e.count > 0)
196
+ .sort((a, b) => b.count - a.count)
197
+ .slice(0, 10);
198
+
199
+ if (blockedCounts.length > 0) {
200
+ process.stdout.write(bold('Most blocked') + dim(' (depends on the most docs):') + '\n');
201
+ for (const { path: p, count } of blockedCounts) {
202
+ const doc = docByPath.get(p);
203
+ const slug = path.basename(p, '.md').padEnd(24);
204
+ process.stdout.write(` ${slug} ${dim(`(${doc?.status ?? '?'})`)} depends on ${count}\n`);
205
+ }
206
+ process.stdout.write('\n');
207
+ }
208
+
209
+ // Docs with blockers
210
+ const withBlockers = [...docByPath.values()].filter(d => d.blockers?.length > 0);
211
+ if (withBlockers.length > 0) {
212
+ process.stdout.write(bold('With blockers:') + '\n');
213
+ for (const doc of withBlockers) {
214
+ const slug = path.basename(doc.path, '.md');
215
+ process.stdout.write(` ${slug}: ${doc.blockers.join('; ')}\n`);
216
+ }
217
+ process.stdout.write('\n');
218
+ }
219
+
220
+ // Orphans
221
+ if (graph.orphans.length > 0) {
222
+ const orphanSlugs = graph.orphans.map(p => path.basename(p, '.md'));
223
+ process.stdout.write(`${dim('Orphans (no dependencies)')}: ${orphanSlugs.join(', ')}\n`);
224
+ process.stdout.write('\n');
225
+ }
226
+ }
227
+
228
+ function renderFlatJson(graph, forwardMap, reverseMap, docByPath) {
229
+ const blocking = [...reverseMap.entries()]
230
+ .map(([p, edges]) => ({ path: p, slug: path.basename(p, '.md'), blocksCount: edges.length }))
231
+ .sort((a, b) => b.blocksCount - a.blocksCount);
232
+
233
+ const blocked = [...forwardMap.entries()]
234
+ .map(([p, edges]) => ({ path: p, slug: path.basename(p, '.md'), dependsOnCount: edges.filter(e => !e.broken).length }))
235
+ .filter(e => e.dependsOnCount > 0)
236
+ .sort((a, b) => b.dependsOnCount - a.dependsOnCount);
237
+
238
+ const withBlockers = [...docByPath.values()]
239
+ .filter(d => d.blockers?.length > 0)
240
+ .map(d => ({ path: d.path, slug: path.basename(d.path, '.md'), blockers: d.blockers }));
241
+
242
+ process.stdout.write(JSON.stringify({
243
+ stats: graph.stats,
244
+ mostBlocking: blocking,
245
+ mostBlocked: blocked,
246
+ withBlockers,
247
+ orphans: graph.orphans,
248
+ }, null, 2) + '\n');
249
+ }
package/src/diff.mjs CHANGED
@@ -1,9 +1,9 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import { spawnSync } from 'node:child_process';
3
2
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
3
  import { asString, toRepoPath, resolveDocPath, die, warn } from './util.mjs';
5
4
  import { gitDiffSince } from './git.mjs';
6
5
  import { buildIndex } from './index.mjs';
6
+ import { summarizeDiffText } from './ai.mjs';
7
7
  import { bold, dim, green } from './color.mjs';
8
8
 
9
9
  export function runDiff(argv, config) {
@@ -82,7 +82,7 @@ function printFileDiff(relPath, since, diffOutput, opts) {
82
82
  try {
83
83
  summary = opts.config?.hooks?.summarizeDiff
84
84
  ? opts.config.hooks.summarizeDiff(diffOutput, relPath)
85
- : summarizeWithMLX(diffOutput, relPath, opts.model);
85
+ : summarizeDiffText(diffOutput, relPath, opts.model);
86
86
  } catch (err) {
87
87
  warn(`Hook 'summarizeDiff' threw: ${err.message}`);
88
88
  summary = null;
@@ -98,29 +98,3 @@ function printFileDiff(relPath, since, diffOutput, opts) {
98
98
  process.stdout.write('\n');
99
99
  }
100
100
 
101
- function summarizeWithMLX(diffText, filePath, model) {
102
- const uvCheck = spawnSync('uv', ['--version'], { encoding: 'utf8' });
103
- if (uvCheck.error) {
104
- warn('uv is not installed. Install it to enable --summarize: https://docs.astral.sh/uv/');
105
- return null;
106
- }
107
-
108
- const prompt = `Summarize this git diff in 1-2 sentences. Focus on what changed semantically, not line counts.\n\nFile: ${filePath}\n\n${diffText.slice(0, 4000)}`;
109
-
110
- const result = spawnSync('uv', [
111
- 'run', '--with', 'mlx-lm',
112
- 'python3', '-m', 'mlx_lm', 'generate',
113
- '--model', model,
114
- '--prompt', prompt,
115
- '--max-tokens', '150',
116
- '--verbose', 'false',
117
- ], { encoding: 'utf8', timeout: 120000 });
118
-
119
- if (result.status !== 0) {
120
- return null;
121
- }
122
-
123
- const output = result.stdout.trim();
124
- const lines = output.split('\n').filter(l => !l.includes('Fetching') && !l.includes('Warning:') && !l.includes('=========='));
125
- return lines.join(' ').trim() || null;
126
- }
package/src/export.mjs ADDED
@@ -0,0 +1,344 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractFrontmatter } from './frontmatter.mjs';
4
+ import { buildIndex } from './index.mjs';
5
+ import { buildGraph } from './graph.mjs';
6
+ import { resolveDocPath, toRepoPath, capitalize, die } from './util.mjs';
7
+
8
+ export function runExport(argv, config) {
9
+ const positional = [];
10
+ let format = 'md';
11
+ let output = null;
12
+ let statusFilter = null;
13
+ let moduleFilter = null;
14
+ let rootFilter = null;
15
+
16
+ for (let i = 0; i < argv.length; i++) {
17
+ if (argv[i] === '--format' && argv[i + 1]) { format = argv[++i]; continue; }
18
+ if (argv[i] === '--output' && argv[i + 1]) { output = argv[++i]; continue; }
19
+ if (argv[i] === '--status' && argv[i + 1]) { statusFilter = argv[++i]; continue; }
20
+ if (argv[i] === '--module' && argv[i + 1]) { moduleFilter = argv[++i]; continue; }
21
+ if (argv[i] === '--root' && argv[i + 1]) { rootFilter = argv[++i]; continue; }
22
+ if (argv[i] === '--config') { i++; continue; }
23
+ if (argv[i].startsWith('-')) continue;
24
+ positional.push(argv[i]);
25
+ }
26
+
27
+ if (!['md', 'html', 'json'].includes(format)) {
28
+ die(`Invalid format: ${format}\nValid: md, html, json`);
29
+ }
30
+
31
+ const index = buildIndex(config);
32
+ let docs;
33
+
34
+ if (positional[0]) {
35
+ // Single doc + deps mode
36
+ const filePath = resolveDocPath(positional[0], config);
37
+ if (!filePath) die(`File not found: ${positional[0]}`);
38
+ const repoPath = toRepoPath(filePath, config.repoRoot);
39
+ const graph = buildGraph(index, config);
40
+ const depPaths = collectDeps(repoPath, graph);
41
+ docs = index.docs.filter(d => depPaths.has(d.path));
42
+ } else {
43
+ // All docs, filtered
44
+ docs = index.docs;
45
+ if (statusFilter) {
46
+ const statuses = statusFilter.split(',').map(s => s.trim());
47
+ docs = docs.filter(d => statuses.includes(d.status));
48
+ }
49
+ if (moduleFilter) {
50
+ const m = moduleFilter.toLowerCase();
51
+ docs = docs.filter(d => (d.module ?? '').toLowerCase() === m || (d.modules ?? []).some(mod => mod.toLowerCase() === m));
52
+ }
53
+ if (rootFilter) {
54
+ docs = docs.filter(d => d.root === rootFilter || d.root?.endsWith('/' + rootFilter) || d.root?.split('/').pop() === rootFilter);
55
+ }
56
+ }
57
+
58
+ if (docs.length === 0) {
59
+ die('No docs to export.');
60
+ }
61
+
62
+ // Load bodies
63
+ const docsWithBody = docs.map(d => loadDocWithBody(d, config));
64
+
65
+ if (format === 'md') {
66
+ const content = exportMarkdown(docsWithBody, config);
67
+ if (output) {
68
+ writeFileSync(output, content, 'utf8');
69
+ process.stdout.write(`Exported ${docs.length} docs to ${output}\n`);
70
+ } else {
71
+ process.stdout.write(content);
72
+ }
73
+ } else if (format === 'json') {
74
+ const content = exportJson(docsWithBody);
75
+ if (output) {
76
+ writeFileSync(output, content, 'utf8');
77
+ process.stdout.write(`Exported ${docs.length} docs to ${output}\n`);
78
+ } else {
79
+ process.stdout.write(content);
80
+ }
81
+ } else if (format === 'html') {
82
+ const outDir = output ?? 'dotmd-export';
83
+ exportHtml(docsWithBody, config, outDir);
84
+ process.stdout.write(`Exported ${docs.length} docs to ${outDir}/\n`);
85
+ }
86
+ }
87
+
88
+ function loadDocWithBody(doc, config) {
89
+ const raw = readFileSync(path.join(config.repoRoot, doc.path), 'utf8');
90
+ const { body } = extractFrontmatter(raw);
91
+ return { ...doc, body: body ?? '' };
92
+ }
93
+
94
+ function collectDeps(docPath, graph) {
95
+ const visited = new Set();
96
+ const queue = [docPath];
97
+ while (queue.length) {
98
+ const p = queue.shift();
99
+ if (visited.has(p)) continue;
100
+ visited.add(p);
101
+ for (const e of graph.edges) {
102
+ if (e.source === p && !e.broken && !visited.has(e.target)) {
103
+ queue.push(e.target);
104
+ }
105
+ }
106
+ }
107
+ return visited;
108
+ }
109
+
110
+ // ── Markdown export ────────────────────────────────────────────────────
111
+
112
+ function exportMarkdown(docs, config) {
113
+ const today = new Date().toISOString().slice(0, 10);
114
+ const lines = [`# Docs Export (${today})`, '', `${docs.length} documents`, ''];
115
+
116
+ const byStatus = {};
117
+ for (const d of docs) {
118
+ const s = d.status ?? 'unknown';
119
+ if (!byStatus[s]) byStatus[s] = [];
120
+ byStatus[s].push(d);
121
+ }
122
+
123
+ for (const status of config.statusOrder) {
124
+ const group = byStatus[status];
125
+ if (!group?.length) continue;
126
+ lines.push(`## ${capitalize(status)} (${group.length})`, '');
127
+ for (const doc of group) {
128
+ lines.push(`### ${doc.title}`);
129
+ const meta = [`Status: ${doc.status}`];
130
+ if (doc.updated) meta.push(`Updated: ${doc.updated}`);
131
+ if (doc.module) meta.push(`Module: ${doc.module}`);
132
+ if (doc.surface) meta.push(`Surface: ${doc.surface}`);
133
+ if (doc.owner) meta.push(`Owner: ${doc.owner}`);
134
+ lines.push(`> ${meta.join(' | ')}`, '');
135
+ if (doc.body.trim()) lines.push(doc.body.trim());
136
+ lines.push('', '---', '');
137
+ }
138
+ }
139
+
140
+ // Statuses not in config order
141
+ for (const [status, group] of Object.entries(byStatus)) {
142
+ if (config.statusOrder.includes(status)) continue;
143
+ lines.push(`## ${capitalize(status)} (${group.length})`, '');
144
+ for (const doc of group) {
145
+ lines.push(`### ${doc.title}`);
146
+ lines.push(`> Status: ${doc.status}`, '');
147
+ if (doc.body.trim()) lines.push(doc.body.trim());
148
+ lines.push('', '---', '');
149
+ }
150
+ }
151
+
152
+ return lines.join('\n').trimEnd() + '\n';
153
+ }
154
+
155
+ // ── JSON export ────────────────────────────────────────────────────────
156
+
157
+ function exportJson(docs) {
158
+ return JSON.stringify({
159
+ exportedAt: new Date().toISOString(),
160
+ count: docs.length,
161
+ docs: docs.map(d => ({
162
+ path: d.path,
163
+ root: d.root,
164
+ title: d.title,
165
+ status: d.status,
166
+ updated: d.updated,
167
+ created: d.created,
168
+ owner: d.owner,
169
+ module: d.module,
170
+ modules: d.modules,
171
+ surface: d.surface,
172
+ surfaces: d.surfaces,
173
+ domain: d.domain,
174
+ summary: d.summary,
175
+ currentState: d.currentState,
176
+ nextStep: d.nextStep,
177
+ blockers: d.blockers,
178
+ body: d.body,
179
+ })),
180
+ }, null, 2) + '\n';
181
+ }
182
+
183
+ // ── HTML export ────────────────────────────────────────────────────────
184
+
185
+ const CSS = `
186
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 800px; margin: 0 auto; padding: 2rem 1rem; color: #1a1a1a; line-height: 1.6; }
187
+ nav { margin-bottom: 2rem; font-size: 0.9rem; }
188
+ nav a { color: #0066cc; text-decoration: none; }
189
+ nav a:hover { text-decoration: underline; }
190
+ h1 { border-bottom: 2px solid #e0e0e0; padding-bottom: 0.5rem; }
191
+ h2 { color: #333; margin-top: 2rem; }
192
+ h3 { color: #555; }
193
+ table.meta { border-collapse: collapse; margin: 1rem 0; font-size: 0.9rem; }
194
+ table.meta td { padding: 0.25rem 1rem 0.25rem 0; }
195
+ table.meta td:first-child { font-weight: 600; color: #666; }
196
+ .badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 3px; font-size: 0.8rem; font-weight: 600; }
197
+ .badge-active { background: #d4edda; color: #155724; }
198
+ .badge-ready { background: #cce5ff; color: #004085; }
199
+ .badge-planned { background: #fff3cd; color: #856404; }
200
+ .badge-blocked { background: #f8d7da; color: #721c24; }
201
+ .badge-research { background: #e2d5f1; color: #4a2572; }
202
+ .badge-archived { background: #e9ecef; color: #495057; }
203
+ article { margin-top: 1rem; }
204
+ pre { background: #f5f5f5; padding: 1rem; border-radius: 4px; overflow-x: auto; }
205
+ code { background: #f0f0f0; padding: 0.15rem 0.3rem; border-radius: 3px; font-size: 0.9em; }
206
+ pre code { background: none; padding: 0; }
207
+ blockquote { border-left: 3px solid #ddd; margin-left: 0; padding-left: 1rem; color: #666; }
208
+ hr { border: none; border-top: 1px solid #e0e0e0; margin: 2rem 0; }
209
+ a { color: #0066cc; }
210
+ ul.toc { list-style: none; padding-left: 0; }
211
+ ul.toc li { padding: 0.3rem 0; }
212
+ ul.toc .status-group { font-weight: 600; margin-top: 1rem; }
213
+ `.trim();
214
+
215
+ function exportHtml(docs, config, outDir) {
216
+ mkdirSync(outDir, { recursive: true });
217
+
218
+ // Build index page
219
+ const indexHtml = buildIndexPage(docs, config);
220
+ writeFileSync(path.join(outDir, 'index.html'), indexHtml, 'utf8');
221
+
222
+ // Build individual doc pages
223
+ for (const doc of docs) {
224
+ const slug = path.basename(doc.path, '.md');
225
+ const html = buildDocPage(doc);
226
+ writeFileSync(path.join(outDir, slug + '.html'), html, 'utf8');
227
+ }
228
+ }
229
+
230
+ function buildIndexPage(docs, config) {
231
+ const today = new Date().toISOString().slice(0, 10);
232
+ const byStatus = {};
233
+ for (const d of docs) {
234
+ const s = d.status ?? 'unknown';
235
+ if (!byStatus[s]) byStatus[s] = [];
236
+ byStatus[s].push(d);
237
+ }
238
+
239
+ let toc = '';
240
+ for (const status of [...config.statusOrder, ...Object.keys(byStatus).filter(s => !config.statusOrder.includes(s))]) {
241
+ const group = byStatus[status];
242
+ if (!group?.length) continue;
243
+ toc += `<li class="status-group">${capitalize(status)} (${group.length})</li>\n`;
244
+ for (const doc of group) {
245
+ const slug = path.basename(doc.path, '.md');
246
+ toc += `<li><a href="${slug}.html">${escHtml(doc.title)}</a></li>\n`;
247
+ }
248
+ }
249
+
250
+ return `<!DOCTYPE html>
251
+ <html lang="en"><head>
252
+ <meta charset="utf-8">
253
+ <meta name="viewport" content="width=device-width, initial-scale=1">
254
+ <title>Docs Export</title>
255
+ <style>${CSS}</style>
256
+ </head><body>
257
+ <h1>Docs Export</h1>
258
+ <p>${docs.length} documents &middot; ${today}</p>
259
+ <ul class="toc">
260
+ ${toc}</ul>
261
+ </body></html>
262
+ `;
263
+ }
264
+
265
+ function buildDocPage(doc) {
266
+ const slug = path.basename(doc.path, '.md');
267
+ const badgeClass = `badge-${doc.status ?? 'unknown'}`;
268
+
269
+ let meta = `<table class="meta">`;
270
+ meta += `<tr><td>Status</td><td><span class="badge ${badgeClass}">${doc.status ?? 'unknown'}</span></td></tr>`;
271
+ if (doc.updated) meta += `<tr><td>Updated</td><td>${escHtml(doc.updated)}</td></tr>`;
272
+ if (doc.module) meta += `<tr><td>Module</td><td>${escHtml(doc.module)}</td></tr>`;
273
+ if (doc.surface) meta += `<tr><td>Surface</td><td>${escHtml(doc.surface)}</td></tr>`;
274
+ if (doc.owner) meta += `<tr><td>Owner</td><td>${escHtml(doc.owner)}</td></tr>`;
275
+ meta += `<tr><td>Path</td><td><code>${escHtml(doc.path)}</code></td></tr>`;
276
+ meta += `</table>`;
277
+
278
+ const bodyHtml = mdToHtml(doc.body);
279
+
280
+ return `<!DOCTYPE html>
281
+ <html lang="en"><head>
282
+ <meta charset="utf-8">
283
+ <meta name="viewport" content="width=device-width, initial-scale=1">
284
+ <title>${escHtml(doc.title)}</title>
285
+ <style>${CSS}</style>
286
+ </head><body>
287
+ <nav><a href="index.html">&larr; Index</a></nav>
288
+ <article>
289
+ <h1>${escHtml(doc.title)}</h1>
290
+ ${meta}
291
+ ${bodyHtml}
292
+ </article>
293
+ </body></html>
294
+ `;
295
+ }
296
+
297
+ function mdToHtml(body) {
298
+ if (!body?.trim()) return '';
299
+ let html = escHtml(body);
300
+
301
+ // Fenced code blocks (must be before other replacements)
302
+ html = html.replace(/^```(\w*)\n([\s\S]*?)^```/gm, (_, lang, code) =>
303
+ `<pre><code>${code.trimEnd()}</code></pre>`
304
+ );
305
+
306
+ // Headings
307
+ html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
308
+ html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
309
+ html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
310
+ html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
311
+
312
+ // Bold and italic
313
+ html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
314
+ html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
315
+
316
+ // Inline code (skip already-processed pre/code blocks)
317
+ html = html.replace(/(?<!<code>)`([^`]+)`/g, '<code>$1</code>');
318
+
319
+ // Links
320
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
321
+
322
+ // Blockquotes
323
+ html = html.replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>');
324
+
325
+ // Horizontal rules
326
+ html = html.replace(/^---$/gm, '<hr>');
327
+
328
+ // Lists
329
+ html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
330
+ html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>');
331
+
332
+ // Checklists
333
+ html = html.replace(/<li>\[x\] /gi, '<li>&#9745; ');
334
+ html = html.replace(/<li>\[ \] /g, '<li>&#9744; ');
335
+
336
+ // Paragraphs (lines not already wrapped in HTML)
337
+ html = html.replace(/^(?!<[hublopa]|<pre|<li|<hr|<block|$)(.+)$/gm, '<p>$1</p>');
338
+
339
+ return html;
340
+ }
341
+
342
+ function escHtml(text) {
343
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
344
+ }