dotmd-cli 0.5.0 → 0.7.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/graph.mjs ADDED
@@ -0,0 +1,268 @@
1
+ import path from 'node:path';
2
+ import { toSlug, toRepoPath, warn } from './util.mjs';
3
+ import { bold, red, green, dim } from './color.mjs';
4
+
5
+ const STATUS_COLORS = {
6
+ active: '#b3e6b3',
7
+ ready: '#b3d9ff',
8
+ planned: '#ffffb3',
9
+ research: '#e6ccff',
10
+ blocked: '#ffb3b3',
11
+ reference: '#d9d9d9',
12
+ archived: '#e6e6e6',
13
+ };
14
+ const DEFAULT_COLOR = '#f2f2f2';
15
+
16
+ export function buildGraph(index, config, filters = {}) {
17
+ const biFields = new Set(config.referenceFields.bidirectional || []);
18
+ const uniFields = new Set(config.referenceFields.unidirectional || []);
19
+ const allRefFields = [...biFields, ...uniFields];
20
+
21
+ // Filter docs
22
+ let docs = index.docs;
23
+ if (filters.statuses?.length) {
24
+ docs = docs.filter(d => filters.statuses.includes(d.status));
25
+ }
26
+ if (filters.module) {
27
+ const m = filters.module.toLowerCase();
28
+ docs = docs.filter(d => (d.modules ?? []).some(mod => mod.toLowerCase() === m) || (d.module ?? '').toLowerCase() === m);
29
+ }
30
+ if (filters.surface) {
31
+ const s = filters.surface.toLowerCase();
32
+ docs = docs.filter(d => (d.surfaces ?? []).some(sf => sf.toLowerCase() === s) || (d.surface ?? '').toLowerCase() === s);
33
+ }
34
+
35
+ const docPathSet = new Set(docs.map(d => d.path));
36
+ const allDocPaths = new Set(index.docs.map(d => d.path));
37
+ const docByPath = new Map(index.docs.map(d => [d.path, d]));
38
+
39
+ // Build nodes
40
+ const nodes = docs.map(d => ({
41
+ id: d.path,
42
+ slug: toSlug(d),
43
+ title: d.title,
44
+ status: d.status,
45
+ module: d.module,
46
+ surface: d.surface,
47
+ edgeCount: 0,
48
+ }));
49
+ const nodeMap = new Map(nodes.map(n => [n.id, n]));
50
+
51
+ // Build edges
52
+ const edges = [];
53
+ const edgeKeys = new Set();
54
+ const referencedPaths = new Set();
55
+
56
+ for (const doc of docs) {
57
+ const docDir = path.dirname(path.join(config.repoRoot, doc.path));
58
+
59
+ for (const field of allRefFields) {
60
+ for (const relPath of (doc.refFields[field] || [])) {
61
+ const resolved = path.resolve(docDir, relPath);
62
+ const targetPath = toRepoPath(resolved, config.repoRoot);
63
+ const edgeKey = `${doc.path}|${targetPath}|${field}`;
64
+ if (edgeKeys.has(edgeKey)) continue;
65
+ edgeKeys.add(edgeKey);
66
+
67
+ const broken = !allDocPaths.has(targetPath);
68
+ const external = !broken && !docPathSet.has(targetPath);
69
+
70
+ edges.push({
71
+ source: doc.path,
72
+ target: targetPath,
73
+ field,
74
+ type: biFields.has(field) ? 'bidirectional' : 'unidirectional',
75
+ broken,
76
+ external,
77
+ });
78
+
79
+ referencedPaths.add(targetPath);
80
+ referencedPaths.add(doc.path);
81
+ }
82
+ }
83
+ }
84
+
85
+ // Count edges per node
86
+ for (const edge of edges) {
87
+ if (nodeMap.has(edge.source)) nodeMap.get(edge.source).edgeCount++;
88
+ }
89
+
90
+ // Find orphans (no outgoing or incoming edges among filtered docs)
91
+ const connectedPaths = new Set();
92
+ for (const edge of edges) {
93
+ connectedPaths.add(edge.source);
94
+ if (!edge.broken && !edge.external) connectedPaths.add(edge.target);
95
+ }
96
+ const orphans = docs.filter(d => !connectedPaths.has(d.path)).map(d => d.path);
97
+
98
+ const brokenEdges = edges.filter(e => e.broken);
99
+
100
+ return {
101
+ nodes,
102
+ edges,
103
+ orphans,
104
+ brokenEdges,
105
+ stats: {
106
+ nodeCount: nodes.length,
107
+ edgeCount: edges.length,
108
+ orphanCount: orphans.length,
109
+ brokenEdgeCount: brokenEdges.length,
110
+ },
111
+ };
112
+ }
113
+
114
+ // ── Text renderer ──────────────────────────────────────────────────────
115
+
116
+ export function renderGraphText(graph, config) {
117
+ const defaultRenderer = (g) => _renderGraphText(g, config);
118
+ if (config.hooks.renderGraph) {
119
+ try { return config.hooks.renderGraph(graph, defaultRenderer); }
120
+ catch (err) { warn(`Hook 'renderGraph' threw: ${err.message}`); }
121
+ }
122
+ return defaultRenderer(graph);
123
+ }
124
+
125
+ function _renderGraphText(graph, config) {
126
+ const { nodes, edges, orphans, stats } = graph;
127
+
128
+ if (stats.nodeCount === 0) return 'No documents found.\n';
129
+
130
+ const allRefFields = [
131
+ ...(config.referenceFields.bidirectional || []),
132
+ ...(config.referenceFields.unidirectional || []),
133
+ ];
134
+ if (allRefFields.length === 0) {
135
+ return `Graph — ${stats.nodeCount} docs, no reference fields configured\n\nAdd \`referenceFields\` to your config to enable relationship tracking.\n`;
136
+ }
137
+
138
+ const lines = [];
139
+ const parts = [`${stats.nodeCount} docs`, `${stats.edgeCount} edges`];
140
+ if (stats.orphanCount > 0) parts.push(`${stats.orphanCount} orphans`);
141
+ if (stats.brokenEdgeCount > 0) parts.push(`${stats.brokenEdgeCount} broken`);
142
+ lines.push(bold(`Graph`) + dim(` — ${parts.join(', ')}`));
143
+ lines.push('');
144
+
145
+ // Compute max field name length for alignment
146
+ const fieldNames = [...new Set(edges.map(e => e.field))];
147
+ const maxFieldLen = fieldNames.length > 0 ? Math.max(...fieldNames.map(f => f.length)) : 0;
148
+
149
+ // Group edges by source
150
+ const edgesBySource = new Map();
151
+ for (const edge of edges) {
152
+ if (!edgesBySource.has(edge.source)) edgesBySource.set(edge.source, []);
153
+ edgesBySource.get(edge.source).push(edge);
154
+ }
155
+
156
+ // Build a slug lookup for targets
157
+ const allDocByPath = new Map();
158
+ for (const n of nodes) allDocByPath.set(n.id, n.slug);
159
+
160
+ // Render each node with edges
161
+ const nodesWithEdges = nodes.filter(n => edgesBySource.has(n.id));
162
+ const nodesWithoutEdges = nodes.filter(n => !edgesBySource.has(n.id) && !orphans.includes(n.id));
163
+
164
+ for (const node of nodesWithEdges) {
165
+ lines.push(`${node.slug} ${dim(`(${node.status})`)}`);
166
+ for (const edge of edgesBySource.get(node.id)) {
167
+ const targetSlug = allDocByPath.get(edge.target) ?? path.basename(edge.target, '.md');
168
+ const fieldPad = edge.field.padEnd(maxFieldLen);
169
+ let line = ` ${'──'} ${fieldPad} ${'──'} ${targetSlug}`;
170
+ if (edge.broken) line += ' ' + red('[broken]');
171
+ if (edge.external) line += ' ' + dim('[external]');
172
+ lines.push(line);
173
+ }
174
+ lines.push('');
175
+ }
176
+
177
+ if (orphans.length > 0) {
178
+ const orphanSlugs = orphans.map(p => path.basename(p, '.md'));
179
+ lines.push(`${dim('Orphans')}: ${orphanSlugs.join(', ')}`);
180
+ lines.push('');
181
+ }
182
+
183
+ return `${lines.join('\n').trimEnd()}\n`;
184
+ }
185
+
186
+ // ── DOT renderer ───────────────────────────────────────────────────────
187
+
188
+ export function renderGraphDot(graph, config) {
189
+ const { nodes, edges } = graph;
190
+ const lines = [];
191
+ lines.push('digraph dotmd {');
192
+ lines.push(' rankdir=LR;');
193
+ lines.push(' node [shape=box, style="rounded,filled", fontname="Helvetica"];');
194
+ lines.push('');
195
+
196
+ // Nodes
197
+ const nodeSet = new Set(nodes.map(n => n.slug));
198
+ for (const node of nodes) {
199
+ const color = STATUS_COLORS[node.status] ?? DEFAULT_COLOR;
200
+ lines.push(` "${node.slug}" [label="${node.slug}\\n(${node.status ?? 'unknown'})", fillcolor="${color}"];`);
201
+ }
202
+
203
+ // Synthesize broken/external target nodes
204
+ const syntheticNodes = new Set();
205
+ for (const edge of edges) {
206
+ const targetSlug = path.basename(edge.target, '.md');
207
+ if (!nodeSet.has(targetSlug) && !syntheticNodes.has(targetSlug)) {
208
+ syntheticNodes.add(targetSlug);
209
+ if (edge.broken) {
210
+ lines.push(` "${targetSlug}" [label="${targetSlug}\\n(unknown)", style="rounded,dashed,filled", fillcolor="#ffb3b3"];`);
211
+ } else if (edge.external) {
212
+ lines.push(` "${targetSlug}" [label="${targetSlug}\\n(filtered)", style="rounded,dashed,filled", fillcolor="#e6e6e6"];`);
213
+ }
214
+ }
215
+ }
216
+
217
+ lines.push('');
218
+
219
+ // Detect mutual bidirectional edges for dir=both rendering
220
+ const biEdgeIndex = new Map();
221
+ for (const edge of edges) {
222
+ if (edge.type !== 'bidirectional') continue;
223
+ const key = `${edge.source}|${edge.target}|${edge.field}`;
224
+ biEdgeIndex.set(key, edge);
225
+ }
226
+
227
+ const rendered = new Set();
228
+ for (const edge of edges) {
229
+ const sourceSlug = path.basename(edge.source, '.md');
230
+ const targetSlug = path.basename(edge.target, '.md');
231
+ const edgeKey = [edge.source, edge.target, edge.field].sort().join('|');
232
+
233
+ if (rendered.has(edgeKey)) continue;
234
+ rendered.add(edgeKey);
235
+
236
+ if (edge.broken) {
237
+ lines.push(` "${sourceSlug}" -> "${targetSlug}" [style=dashed, color=red, label="${edge.field}"];`);
238
+ } else if (edge.type === 'bidirectional') {
239
+ // Check if reverse edge exists
240
+ const reverseKey = `${edge.target}|${edge.source}|${edge.field}`;
241
+ if (biEdgeIndex.has(reverseKey)) {
242
+ lines.push(` "${sourceSlug}" -> "${targetSlug}" [dir=both, label="${edge.field}", color="#666666"];`);
243
+ } else {
244
+ lines.push(` "${sourceSlug}" -> "${targetSlug}" [label="${edge.field}", color="#666666"];`);
245
+ }
246
+ } else {
247
+ const style = edge.external ? ', style=dashed' : '';
248
+ lines.push(` "${sourceSlug}" -> "${targetSlug}" [label="${edge.field}", color="#999999"${style}];`);
249
+ }
250
+ }
251
+
252
+ lines.push('}');
253
+ return lines.join('\n') + '\n';
254
+ }
255
+
256
+ // ── JSON renderer ──────────────────────────────────────────────────────
257
+
258
+ export function renderGraphJson(graph) {
259
+ return JSON.stringify({
260
+ generatedAt: new Date().toISOString(),
261
+ stats: graph.stats,
262
+ nodes: graph.nodes,
263
+ edges: graph.edges.map(({ source, target, field, type, broken }) => ({
264
+ source, target, field, type, broken,
265
+ })),
266
+ orphans: graph.orphans,
267
+ }, null, 2) + '\n';
268
+ }
package/src/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readdirSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
- import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts } from './extractors.mjs';
4
+ import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts, extractBodyLinks } from './extractors.mjs';
5
5
  import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn } from './util.mjs';
6
6
  import { validateDoc, checkBidirectionalReferences, checkGitStaleness, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate } from './validate.mjs';
7
7
  import { checkIndex } from './index-file.mjs';
@@ -119,6 +119,7 @@ export function parseDocFile(filePath, config) {
119
119
  const audience = asString(parsedFrontmatter.audience) ?? null;
120
120
  const executionMode = asString(parsedFrontmatter.execution_mode) ?? null;
121
121
  const checklist = extractChecklistCounts(body);
122
+ const bodyLinks = extractBodyLinks(body);
122
123
 
123
124
  // Dynamic reference field extraction
124
125
  const refFields = {};
@@ -148,6 +149,7 @@ export function parseDocFile(filePath, config) {
148
149
  auditLevel: asString(parsedFrontmatter.audit_level) ?? null,
149
150
  sourceOfTruth: asString(parsedFrontmatter.source_of_truth) ?? null,
150
151
  checklist,
152
+ bodyLinks,
151
153
  refFields,
152
154
  checklistCompletionRate: computeChecklistCompletionRate(checklist),
153
155
  hasNextStep: Boolean(nextStep),
package/src/lifecycle.mjs CHANGED
@@ -1,11 +1,11 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
3
+ import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
4
4
  import { asString, toRepoPath, die, warn, resolveDocPath, escapeRegex } from './util.mjs';
5
- import { gitMv } from './git.mjs';
5
+ import { gitMv, getGitLastModified } from './git.mjs';
6
6
  import { buildIndex, collectDocFiles } from './index.mjs';
7
7
  import { renderIndexFile, writeIndex } from './index-file.mjs';
8
- import { green, dim } from './color.mjs';
8
+ import { green, dim, yellow } from './color.mjs';
9
9
 
10
10
  export function runStatus(argv, config, opts = {}) {
11
11
  const { dryRun } = opts;
@@ -114,20 +114,10 @@ export function runArchive(argv, config, opts = {}) {
114
114
  process.stdout.write(`${prefix} Would move: ${oldRepoPath} → ${newRepoPath}\n`);
115
115
  if (config.indexPath) process.stdout.write(`${prefix} Would regenerate index\n`);
116
116
 
117
- // Reference scan is read-only, still useful in dry-run
118
- const basename = path.basename(filePath);
119
- const references = [];
120
- for (const docFile of collectDocFiles(config)) {
121
- if (docFile === targetPath) continue;
122
- const docRaw = readFileSync(docFile, 'utf8');
123
- const { frontmatter: docFm } = extractFrontmatter(docRaw);
124
- if (docFm.includes(basename)) {
125
- references.push(toRepoPath(docFile, config.repoRoot));
126
- }
127
- }
128
- if (references.length > 0) {
129
- process.stdout.write('\nThese docs reference the old path — would need updating:\n');
130
- for (const ref of references) process.stdout.write(`- ${ref}\n`);
117
+ // Preview reference updates
118
+ const refCount = countRefsToUpdate(filePath, targetPath, config);
119
+ if (refCount > 0) {
120
+ process.stdout.write(`${prefix} Would update references in ${refCount} file(s)\n`);
131
121
  }
132
122
  return;
133
123
  }
@@ -140,39 +130,78 @@ export function runArchive(argv, config, opts = {}) {
140
130
  const result = gitMv(filePath, targetPath, config.repoRoot);
141
131
  if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
142
132
 
133
+ // Auto-update references in other docs
134
+ const updatedRefCount = updateRefsAfterMove(filePath, targetPath, config);
135
+
143
136
  if (config.indexPath) {
144
137
  const index = buildIndex(config);
145
138
  writeIndex(renderIndexFile(index, config), config);
146
139
  }
147
140
 
148
141
  process.stdout.write(`${green('Archived')}: ${oldRepoPath} → ${newRepoPath}\n`);
142
+ if (updatedRefCount > 0) process.stdout.write(`Updated references in ${updatedRefCount} file(s).\n`);
149
143
  if (config.indexPath) process.stdout.write('Index regenerated.\n');
150
144
 
151
- const basename = path.basename(filePath);
152
- const references = [];
153
- for (const docFile of collectDocFiles(config)) {
154
- if (docFile === targetPath) continue;
155
- const docRaw = readFileSync(docFile, 'utf8');
156
- const { frontmatter: docFm } = extractFrontmatter(docRaw);
157
- if (docFm.includes(basename)) {
158
- references.push(toRepoPath(docFile, config.repoRoot));
159
- }
160
- }
161
-
162
- if (references.length > 0) {
163
- process.stdout.write('\nThese docs reference the old path — update reference entries:\n');
164
- for (const ref of references) process.stdout.write(`- ${ref}\n`);
165
- }
166
-
167
- process.stdout.write('\nNext: commit, then update references if needed.\n');
168
145
  try { config.hooks.onArchive?.({ path: newRepoPath, oldStatus }, { oldPath: oldRepoPath, newPath: newRepoPath }); } catch (err) { warn(`Hook 'onArchive' threw: ${err.message}`); }
169
146
  }
170
147
 
171
148
  export function runTouch(argv, config, opts = {}) {
172
149
  const { dryRun } = opts;
173
- const input = argv[0];
150
+ const useGit = argv.includes('--git');
151
+ const positional = [];
152
+ for (let i = 0; i < argv.length; i++) {
153
+ if (argv[i] === '--config') { i++; continue; }
154
+ if (argv[i].startsWith('-')) continue;
155
+ positional.push(argv[i]);
156
+ }
157
+ const input = positional[0];
174
158
 
175
- if (!input) { die('Usage: dotmd touch <file>'); }
159
+ // --git mode: bulk-sync frontmatter dates from git history
160
+ if (useGit) {
161
+ const allFiles = input ? [resolveDocPath(input, config)].filter(Boolean) : collectDocFiles(config);
162
+ if (input && allFiles.length === 0) { die(`File not found: ${input}`); }
163
+
164
+ const prefix = dryRun ? dim('[dry-run] ') : '';
165
+ let synced = 0;
166
+
167
+ for (const filePath of allFiles) {
168
+ const repoPath = toRepoPath(filePath, config.repoRoot);
169
+ const raw = readFileSync(filePath, 'utf8');
170
+ const { frontmatter } = extractFrontmatter(raw);
171
+ if (!frontmatter) continue;
172
+
173
+ const parsed = parseSimpleFrontmatter(frontmatter);
174
+ const status = asString(parsed.status);
175
+ if (config.lifecycle.skipStaleFor.has(status)) continue;
176
+
177
+ const fmUpdated = asString(parsed.updated);
178
+ const gitDate = getGitLastModified(repoPath, config.repoRoot);
179
+ if (!gitDate) continue;
180
+
181
+ const gitDay = gitDate.slice(0, 10);
182
+ if (fmUpdated === gitDay) continue;
183
+
184
+ // Only sync if git is newer than frontmatter
185
+ const gitMs = new Date(gitDate).getTime();
186
+ const fmMs = fmUpdated ? new Date(fmUpdated).getTime() : 0;
187
+ if (fmMs >= gitMs) continue;
188
+
189
+ if (!dryRun) {
190
+ updateFrontmatter(filePath, { updated: gitDay });
191
+ }
192
+ process.stdout.write(`${prefix}${green('Synced')}: ${repoPath} (updated → ${gitDay})\n`);
193
+ synced++;
194
+ }
195
+
196
+ if (synced === 0) {
197
+ process.stdout.write(green('All frontmatter dates are in sync with git.') + '\n');
198
+ } else {
199
+ process.stdout.write(`\n${prefix}${synced} file(s) synced.\n`);
200
+ }
201
+ return;
202
+ }
203
+
204
+ if (!input) { die('Usage: dotmd touch <file>\n dotmd touch --git Bulk-sync dates from git history'); }
176
205
 
177
206
  const filePath = resolveDocPath(input, config);
178
207
  if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
@@ -190,6 +219,69 @@ export function runTouch(argv, config, opts = {}) {
190
219
  try { config.hooks.onTouch?.({ path: toRepoPath(filePath, config.repoRoot) }, { path: toRepoPath(filePath, config.repoRoot), date: today }); } catch (err) { warn(`Hook 'onTouch' threw: ${err.message}`); }
191
220
  }
192
221
 
222
+ /**
223
+ * After a file moves (archive/unarchive), update frontmatter references in all
224
+ * docs that pointed to the old location so they point to the new one.
225
+ */
226
+ function updateRefsAfterMove(oldPath, newPath, config) {
227
+ const basename = path.basename(oldPath);
228
+ const allFiles = collectDocFiles(config);
229
+ let updatedCount = 0;
230
+
231
+ for (const docFile of allFiles) {
232
+ if (docFile === newPath) continue;
233
+ let raw = readFileSync(docFile, 'utf8');
234
+ const { frontmatter: fm } = extractFrontmatter(raw);
235
+ if (!fm || !fm.includes(basename)) continue;
236
+
237
+ const docDir = path.dirname(docFile);
238
+ const oldRelPath = path.relative(docDir, oldPath).split(path.sep).join('/');
239
+ const newRelPath = path.relative(docDir, newPath).split(path.sep).join('/');
240
+
241
+ let newFm = fm;
242
+
243
+ // Replace exact relative path
244
+ if (newFm.includes(oldRelPath)) {
245
+ newFm = newFm.split(oldRelPath).join(newRelPath);
246
+ }
247
+
248
+ // Also handle ./ prefix variant
249
+ const dotSlashOld = './' + oldRelPath;
250
+ if (newFm.includes(dotSlashOld)) {
251
+ newFm = newFm.split(dotSlashOld).join(newRelPath);
252
+ }
253
+
254
+ if (newFm !== fm) {
255
+ raw = replaceFrontmatter(raw, newFm);
256
+ writeFileSync(docFile, raw, 'utf8');
257
+ updatedCount++;
258
+ }
259
+ }
260
+
261
+ return updatedCount;
262
+ }
263
+
264
+ function countRefsToUpdate(oldPath, newPath, config) {
265
+ const basename = path.basename(oldPath);
266
+ const allFiles = collectDocFiles(config);
267
+ let count = 0;
268
+
269
+ for (const docFile of allFiles) {
270
+ if (docFile === newPath) continue;
271
+ const raw = readFileSync(docFile, 'utf8');
272
+ const { frontmatter: fm } = extractFrontmatter(raw);
273
+ if (!fm || !fm.includes(basename)) continue;
274
+
275
+ const docDir = path.dirname(docFile);
276
+ const oldRelPath = path.relative(docDir, oldPath).split(path.sep).join('/');
277
+ if (fm.includes(oldRelPath) || fm.includes('./' + oldRelPath)) {
278
+ count++;
279
+ }
280
+ }
281
+
282
+ return count;
283
+ }
284
+
193
285
  export function updateFrontmatter(filePath, updates) {
194
286
  const raw = readFileSync(filePath, 'utf8');
195
287
  if (!raw.startsWith('---\n')) throw new Error(`${filePath} has no frontmatter block.`);
package/src/new.mjs CHANGED
@@ -1,7 +1,40 @@
1
1
  import { existsSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { toRepoPath, die, warn } from './util.mjs';
4
- import { green, dim } from './color.mjs';
4
+ import { green, dim, bold } from './color.mjs';
5
+
6
+ const BUILTIN_TEMPLATES = {
7
+ default: {
8
+ description: 'Minimal document with status and updated date',
9
+ frontmatter: (s, d) => `status: ${s}\nupdated: ${d}`,
10
+ body: (t) => `\n# ${t}\n`,
11
+ },
12
+ plan: {
13
+ description: 'Execution plan with module, surface, and cross-references',
14
+ frontmatter: (s, d) => `status: ${s}\nupdated: ${d}\nsurface:\nmodule:\ncurrent_state:\nrelated_plans:`,
15
+ body: (t) => `\n# ${t}\n\n## Overview\n\n\n\n## Implementation Plan\n\n- [ ] \n\n## Open Questions\n\n\n`,
16
+ },
17
+ adr: {
18
+ description: 'Architecture Decision Record',
19
+ frontmatter: (s, d) => `status: ${s}\nupdated: ${d}\ndecision_date:\ndeciders:`,
20
+ body: (t) => `\n# ${t}\n\n## Context\n\n\n\n## Decision\n\n\n\n## Consequences\n\n\n`,
21
+ },
22
+ rfc: {
23
+ description: 'Request for Comments',
24
+ frontmatter: (s, d) => `status: ${s}\nupdated: ${d}\nowner:\nreviewers:`,
25
+ body: (t) => `\n# ${t}\n\n## Summary\n\n\n\n## Motivation\n\n\n\n## Detailed Design\n\n\n\n## Alternatives\n\n\n\n## Open Questions\n\n\n`,
26
+ },
27
+ audit: {
28
+ description: 'Codebase audit or research investigation',
29
+ frontmatter: (s, d) => `status: research\nupdated: ${d}\naudited: ${d}\naudit_level: pass1\nmodule:\nsource_of_truth: code\nsupports_plans:`,
30
+ body: (t) => `\n# ${t}\n\n## Scope\n\n\n\n## Findings\n\n\n\n## Recommendations\n\n\n`,
31
+ },
32
+ design: {
33
+ description: 'Design document with goals, non-goals, and implementation plan',
34
+ frontmatter: (s, d) => `status: ${s}\nupdated: ${d}\nowner:\nsurface:\nmodule:\nrelated_plans:`,
35
+ body: (t) => `\n# ${t}\n\n## Overview\n\n\n\n## Goals\n\n\n\n## Non-Goals\n\n\n\n## Design\n\n\n\n## Implementation Plan\n\n- [ ] \n`,
36
+ },
37
+ };
5
38
 
6
39
  export function runNew(argv, config, opts = {}) {
7
40
  const { dryRun } = opts;
@@ -10,20 +43,29 @@ export function runNew(argv, config, opts = {}) {
10
43
  const positional = [];
11
44
  let status = 'active';
12
45
  let title = null;
46
+ let templateName = null;
13
47
  for (let i = 0; i < argv.length; i++) {
14
48
  if (argv[i] === '--status' && argv[i + 1]) { status = argv[++i]; continue; }
15
49
  if (argv[i] === '--title' && argv[i + 1]) { title = argv[++i]; continue; }
50
+ if (argv[i] === '--template' && argv[i + 1]) { templateName = argv[++i]; continue; }
51
+ if (argv[i] === '--list-templates') {
52
+ listTemplates(config);
53
+ return;
54
+ }
16
55
  if (!argv[i].startsWith('-')) positional.push(argv[i]);
17
56
  }
18
57
 
19
58
  const name = positional[0];
20
- if (!name) { die('Usage: dotmd new <name> [--status <s>] [--title <t>]'); }
59
+ if (!name) { die('Usage: dotmd new <name> [--template <t>] [--status <s>] [--title <t>]\n dotmd new --list-templates'); }
21
60
 
22
61
  // Validate status
23
62
  if (!config.validStatuses.has(status)) {
24
63
  die(`Invalid status: ${status}\nValid: ${[...config.validStatuses].join(', ')}`);
25
64
  }
26
65
 
66
+ // Resolve template
67
+ const template = resolveTemplate(templateName ?? 'default', config);
68
+
27
69
  // Slugify
28
70
  const slug = name.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
29
71
  if (!slug) { die('Name resolves to empty slug: ' + name); }
@@ -39,16 +81,57 @@ export function runNew(argv, config, opts = {}) {
39
81
  die(`File already exists: ${repoPath}`);
40
82
  }
41
83
 
84
+ const today = new Date().toISOString().slice(0, 10);
85
+
86
+ // Generate content
87
+ let content;
88
+ if (typeof template === 'function') {
89
+ content = template(name, { status, title: docTitle, today });
90
+ } else {
91
+ const fm = template.frontmatter(status, today);
92
+ const body = template.body(docTitle);
93
+ content = `---\n${fm}\n---\n${body}`;
94
+ }
95
+
42
96
  if (dryRun) {
43
97
  process.stdout.write(`${dim('[dry-run]')} Would create: ${repoPath}\n`);
98
+ if (templateName) process.stdout.write(`${dim('[dry-run]')} Template: ${templateName}\n`);
44
99
  return;
45
100
  }
46
101
 
47
- const today = new Date().toISOString().slice(0, 10);
48
- const content = `---\nstatus: ${status}\nupdated: ${today}\n---\n\n# ${docTitle}\n`;
49
-
50
102
  writeFileSync(filePath, content, 'utf8');
51
- process.stdout.write(`${green('Created')}: ${repoPath}\n`);
103
+ process.stdout.write(`${green('Created')}: ${repoPath}`);
104
+ if (templateName) process.stdout.write(` ${dim(`(template: ${templateName})`)}`);
105
+ process.stdout.write('\n');
52
106
 
53
- try { config.hooks.onNew?.({ path: repoPath, status, title: docTitle }); } catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
107
+ try { config.hooks.onNew?.({ path: repoPath, status, title: docTitle, template: templateName }); } catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
108
+ }
109
+
110
+ function resolveTemplate(name, config) {
111
+ // Config templates take priority
112
+ const configTemplates = config.raw?.templates ?? {};
113
+ if (configTemplates[name]) return configTemplates[name];
114
+ if (BUILTIN_TEMPLATES[name]) return BUILTIN_TEMPLATES[name];
115
+
116
+ const available = [...new Set([...Object.keys(BUILTIN_TEMPLATES), ...Object.keys(configTemplates)])];
117
+ die(`Unknown template: ${name}\nAvailable: ${available.join(', ')}`);
118
+ }
119
+
120
+ function listTemplates(config) {
121
+ const configTemplates = config.raw?.templates ?? {};
122
+ const all = { ...BUILTIN_TEMPLATES };
123
+ for (const [k, v] of Object.entries(configTemplates)) {
124
+ all[k] = v;
125
+ }
126
+
127
+ process.stdout.write(bold('Available templates') + '\n\n');
128
+ for (const [name, tmpl] of Object.entries(all)) {
129
+ const desc = typeof tmpl === 'function'
130
+ ? '(custom function)'
131
+ : (tmpl.description ?? '');
132
+ const source = configTemplates[name] ? dim(' (config)') : '';
133
+ process.stdout.write(` ${name}${source}\n`);
134
+ if (desc) process.stdout.write(` ${dim(desc)}\n`);
135
+ process.stdout.write('\n');
136
+ }
54
137
  }
package/src/render.mjs CHANGED
@@ -143,8 +143,8 @@ function _renderContext(index, config) {
143
143
  return `${lines.join('\n').trimEnd()}\n`;
144
144
  }
145
145
 
146
- export function renderCheck(index, config) {
147
- const defaultRenderer = (idx) => _renderCheck(idx);
146
+ export function renderCheck(index, config, opts = {}) {
147
+ const defaultRenderer = (idx) => _renderCheck(idx, opts);
148
148
  if (config.hooks.renderCheck) {
149
149
  try { return config.hooks.renderCheck(index, defaultRenderer); }
150
150
  catch (err) { warn(`Hook 'renderCheck' threw: ${err.message}`); }
@@ -152,7 +152,8 @@ export function renderCheck(index, config) {
152
152
  return defaultRenderer(index);
153
153
  }
154
154
 
155
- function _renderCheck(index) {
155
+ function _renderCheck(index, opts = {}) {
156
+ const { errorsOnly } = opts;
156
157
  const lines = ['Check', ''];
157
158
  lines.push(`- docs scanned: ${index.docs.length}`);
158
159
  lines.push(`- errors: ${index.errors.length}`);
@@ -167,7 +168,7 @@ function _renderCheck(index) {
167
168
  lines.push('');
168
169
  }
169
170
 
170
- if (index.warnings.length > 0) {
171
+ if (!errorsOnly && index.warnings.length > 0) {
171
172
  lines.push(yellow('Warnings'));
172
173
  for (const issue of index.warnings) {
173
174
  lines.push(`- ${issue.path}: ${issue.message}`);
package/src/validate.mjs CHANGED
@@ -76,6 +76,14 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
76
76
  }
77
77
  }
78
78
  }
79
+
80
+ // Validate body links resolve to existing files
81
+ for (const link of (doc.bodyLinks || [])) {
82
+ const resolved = path.resolve(docDir, link.href);
83
+ if (!existsSync(resolved)) {
84
+ doc.warnings.push({ path: doc.path, level: 'warning', message: `body link \`${link.href}\` does not resolve to an existing file.` });
85
+ }
86
+ }
79
87
  }
80
88
 
81
89
  export function checkBidirectionalReferences(docs, config) {