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/README.md +121 -42
- package/bin/dotmd.mjs +99 -3
- package/package.json +15 -5
- package/src/ai.mjs +50 -0
- package/src/completions.mjs +12 -5
- package/src/config.mjs +10 -3
- package/src/deps.mjs +249 -0
- package/src/diff.mjs +2 -28
- package/src/export.mjs +344 -0
- package/src/fix-refs.mjs +102 -52
- package/src/index.mjs +15 -4
- package/src/init.mjs +88 -4
- package/src/lifecycle.mjs +11 -4
- package/src/new.mjs +15 -1
- package/src/notion.mjs +528 -0
- package/src/query.mjs +36 -4
- package/src/render.mjs +22 -4
- package/src/stats.mjs +161 -0
- package/src/summary.mjs +63 -0
- package/src/util.mjs +5 -2
- package/src/watch.mjs +12 -9
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
|
-
:
|
|
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 · ${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">← 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(/^> (.+)$/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>☑ ');
|
|
334
|
+
html = html.replace(/<li>\[ \] /g, '<li>☐ ');
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
344
|
+
}
|