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/fix-refs.mjs CHANGED
@@ -1,14 +1,14 @@
1
1
  import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
4
- import { toRepoPath, warn } from './util.mjs';
4
+ import { toRepoPath, escapeRegex, warn } from './util.mjs';
5
5
  import { buildIndex, collectDocFiles } from './index.mjs';
6
6
  import { green, dim, yellow } from './color.mjs';
7
7
 
8
8
  export function runFixRefs(argv, config, opts = {}) {
9
9
  const { dryRun } = opts;
10
10
  const result = fixBrokenRefs(config, { dryRun });
11
- if (result.totalFixed === 0 && result.unfixableCount === 0) {
11
+ if (result.totalFixed === 0 && result.bodyLinksFixed === 0 && result.unfixableCount === 0) {
12
12
  process.stdout.write(green('No broken references found.') + '\n');
13
13
  }
14
14
  }
@@ -34,80 +34,130 @@ export function fixBrokenRefs(config, opts = {}) {
34
34
  }
35
35
  }
36
36
 
37
- // Find broken ref errors
37
+ const prefix = dryRun ? dim('[dry-run] ') : '';
38
+ let totalFixed = 0;
39
+ let unfixableCount = 0;
40
+ let bodyLinksFixed = 0;
41
+
42
+ // ── Fix broken frontmatter references ────────────────────────────────
43
+
38
44
  const brokenRefErrors = index.errors.filter(e =>
39
45
  e.message.includes('does not resolve to an existing file')
40
46
  );
41
47
 
42
- if (brokenRefErrors.length === 0) {
43
- return { totalFixed: 0, unfixableCount: 0 };
44
- }
48
+ if (brokenRefErrors.length > 0) {
49
+ const fixesByDoc = new Map();
45
50
 
46
- // Group fixes by doc path
47
- const fixesByDoc = new Map();
48
- let unfixableCount = 0;
51
+ for (const err of brokenRefErrors) {
52
+ const match = err.message.match(/entry `([^`]+)` does not resolve/);
53
+ if (!match) { unfixableCount++; continue; }
49
54
 
50
- for (const err of brokenRefErrors) {
51
- const match = err.message.match(/entry `([^`]+)` does not resolve/);
52
- if (!match) { unfixableCount++; continue; }
55
+ const brokenRef = match[1];
56
+ const brokenBasename = path.basename(brokenRef);
53
57
 
54
- const brokenRef = match[1];
55
- const brokenBasename = path.basename(brokenRef);
58
+ if (duplicateBasenames.has(brokenBasename)) { unfixableCount++; continue; }
56
59
 
57
- if (duplicateBasenames.has(brokenBasename)) {
58
- unfixableCount++;
59
- continue;
60
+ const resolved = basenameMap.get(brokenBasename);
61
+ if (!resolved) { unfixableCount++; continue; }
62
+
63
+ const docAbsPath = path.join(config.repoRoot, err.path);
64
+ const docDir = path.dirname(docAbsPath);
65
+ const correctRelPath = path.relative(docDir, resolved).split(path.sep).join('/');
66
+
67
+ if (correctRelPath === brokenRef) { unfixableCount++; continue; }
68
+
69
+ if (!fixesByDoc.has(err.path)) fixesByDoc.set(err.path, []);
70
+ fixesByDoc.get(err.path).push({ brokenRef, correctRelPath });
60
71
  }
61
72
 
62
- const resolved = basenameMap.get(brokenBasename);
63
- if (!resolved) { unfixableCount++; continue; }
73
+ for (const [docPath, fixes] of fixesByDoc) {
74
+ const absPath = path.join(config.repoRoot, docPath);
75
+ let raw = readFileSync(absPath, 'utf8');
76
+ const { frontmatter: fm } = extractFrontmatter(raw);
77
+ if (!fm) continue;
64
78
 
65
- const docAbsPath = path.join(config.repoRoot, err.path);
66
- const docDir = path.dirname(docAbsPath);
67
- const correctRelPath = path.relative(docDir, resolved).split(path.sep).join('/');
79
+ let newFm = fm;
80
+ for (const { brokenRef, correctRelPath } of fixes) {
81
+ newFm = newFm.split(brokenRef).join(correctRelPath);
82
+ }
68
83
 
69
- if (correctRelPath === brokenRef) { unfixableCount++; continue; }
84
+ if (newFm !== fm && !dryRun) {
85
+ raw = replaceFrontmatter(raw, newFm);
86
+ writeFileSync(absPath, raw, 'utf8');
87
+ }
70
88
 
71
- if (!fixesByDoc.has(err.path)) {
72
- fixesByDoc.set(err.path, []);
89
+ if (!quiet) {
90
+ process.stdout.write(`${prefix}${green('Fixed')}: ${docPath} (${fixes.length} ref${fixes.length > 1 ? 's' : ''})\n`);
91
+ for (const { brokenRef, correctRelPath } of fixes) {
92
+ process.stdout.write(`${prefix} ${dim(`${brokenRef} → ${correctRelPath}`)}\n`);
93
+ }
94
+ }
95
+ totalFixed += fixes.length;
73
96
  }
74
- fixesByDoc.get(err.path).push({ brokenRef, correctRelPath });
75
97
  }
76
98
 
77
- const prefix = dryRun ? dim('[dry-run] ') : '';
78
- let totalFixed = 0;
79
-
80
- for (const [docPath, fixes] of fixesByDoc) {
81
- const absPath = path.join(config.repoRoot, docPath);
82
- let raw = readFileSync(absPath, 'utf8');
83
- const { frontmatter: fm } = extractFrontmatter(raw);
84
- if (!fm) continue;
99
+ // ── Fix broken body links ────────────────────────────────────────────
85
100
 
86
- let newFm = fm;
87
- for (const { brokenRef, correctRelPath } of fixes) {
88
- newFm = newFm.split(brokenRef).join(correctRelPath);
89
- }
101
+ for (const doc of index.docs) {
102
+ const brokenBodyWarnings = doc.warnings.filter(w =>
103
+ w.message.startsWith('body link') && w.message.includes('does not resolve')
104
+ );
105
+ if (!brokenBodyWarnings.length) continue;
90
106
 
91
- if (newFm !== fm && !dryRun) {
92
- raw = replaceFrontmatter(raw, newFm);
93
- writeFileSync(absPath, raw, 'utf8');
107
+ const absPath = path.join(config.repoRoot, doc.path);
108
+ let raw = readFileSync(absPath, 'utf8');
109
+ const { frontmatter: fm, body } = extractFrontmatter(raw);
110
+ let newBody = body;
111
+ const docDir = path.dirname(absPath);
112
+ const bodyFixes = [];
113
+
114
+ for (const warn of brokenBodyWarnings) {
115
+ const match = warn.message.match(/body link `([^`]+)` does not resolve/);
116
+ if (!match) continue;
117
+
118
+ const brokenHref = match[1];
119
+ const brokenBasename = path.basename(brokenHref);
120
+
121
+ if (duplicateBasenames.has(brokenBasename)) continue;
122
+ const resolved = basenameMap.get(brokenBasename);
123
+ if (!resolved) continue;
124
+
125
+ const correctHref = path.relative(docDir, resolved).split(path.sep).join('/');
126
+ if (correctHref === brokenHref) continue;
127
+
128
+ const linkRegex = new RegExp(
129
+ '(?<!!)\\[([^\\]]+)\\]\\(' + escapeRegex(brokenHref) + '(#[^)]*)?\\)',
130
+ 'g'
131
+ );
132
+ newBody = newBody.replace(linkRegex, (_, text, anchor) =>
133
+ `[${text}](${correctHref}${anchor ?? ''})`
134
+ );
135
+ bodyFixes.push({ brokenHref, correctHref });
94
136
  }
95
137
 
96
- if (!quiet) {
97
- process.stdout.write(`${prefix}${green('Fixed')}: ${docPath} (${fixes.length} ref${fixes.length > 1 ? 's' : ''})\n`);
98
- for (const { brokenRef, correctRelPath } of fixes) {
99
- process.stdout.write(`${prefix} ${dim(`${brokenRef} → ${correctRelPath}`)}\n`);
138
+ if (bodyFixes.length > 0 && newBody !== body) {
139
+ if (!dryRun) {
140
+ writeFileSync(absPath, `---\n${fm}\n---\n${newBody}`, 'utf8');
141
+ }
142
+ if (!quiet) {
143
+ process.stdout.write(`${prefix}${green('Fixed')}: ${doc.path} (${bodyFixes.length} body link${bodyFixes.length > 1 ? 's' : ''})\n`);
144
+ for (const { brokenHref, correctHref } of bodyFixes) {
145
+ process.stdout.write(`${prefix} ${dim(`${brokenHref} → ${correctHref}`)}\n`);
146
+ }
100
147
  }
148
+ bodyLinksFixed += bodyFixes.length;
101
149
  }
102
- totalFixed += fixes.length;
103
150
  }
104
151
 
105
- if (!quiet) {
106
- process.stdout.write(`\n${prefix}${totalFixed} reference${totalFixed !== 1 ? 's' : ''} fixed across ${fixesByDoc.size} file(s).\n`);
107
- if (unfixableCount > 0) {
108
- process.stdout.write(`${yellow(`${unfixableCount} broken reference(s) could not be auto-resolved`)} (file not found by basename).\n`);
109
- }
152
+ // ── Summary ──────────────────────────────────────────────────────────
153
+
154
+ const allFixed = totalFixed + bodyLinksFixed;
155
+ if (!quiet && allFixed > 0) {
156
+ process.stdout.write(`\n${prefix}${allFixed} fix${allFixed !== 1 ? 'es' : ''} applied.\n`);
157
+ }
158
+ if (!quiet && unfixableCount > 0) {
159
+ process.stdout.write(`${yellow(`${unfixableCount} broken reference(s) could not be auto-resolved`)} (file not found by basename).\n`);
110
160
  }
111
161
 
112
- return { totalFixed, unfixableCount };
162
+ return { totalFixed, unfixableCount, bodyLinksFixed };
113
163
  }
package/src/index.mjs CHANGED
@@ -77,11 +77,15 @@ export function collectDocFiles(config) {
77
77
  const files = [];
78
78
  const skipPaths = new Set();
79
79
  if (config.indexPath) skipPaths.add(config.indexPath);
80
- walkMarkdownFiles(config.docsRoot, files, config.excludeDirs, skipPaths);
80
+ const roots = config.docsRoots || [config.docsRoot];
81
+ const seen = new Set();
82
+ for (const root of roots) {
83
+ walkMarkdownFiles(root, files, config.excludeDirs, skipPaths, seen);
84
+ }
81
85
  return files.sort((a, b) => a.localeCompare(b));
82
86
  }
83
87
 
84
- function walkMarkdownFiles(directory, files, excludedDirs, skipPaths) {
88
+ function walkMarkdownFiles(directory, files, excludedDirs, skipPaths, seen = new Set()) {
85
89
  let entries;
86
90
  try {
87
91
  entries = readdirSync(directory, { withFileTypes: true });
@@ -91,11 +95,12 @@ function walkMarkdownFiles(directory, files, excludedDirs, skipPaths) {
91
95
  for (const entry of entries) {
92
96
  if (entry.isDirectory()) {
93
97
  if (excludedDirs && excludedDirs.has(entry.name)) continue;
94
- walkMarkdownFiles(path.join(directory, entry.name), files, excludedDirs, skipPaths);
98
+ walkMarkdownFiles(path.join(directory, entry.name), files, excludedDirs, skipPaths, seen);
95
99
  continue;
96
100
  }
97
101
  const fullPath = path.join(directory, entry.name);
98
- if (!entry.isFile() || !entry.name.endsWith('.md') || skipPaths.has(fullPath)) continue;
102
+ if (!entry.isFile() || !entry.name.endsWith('.md') || skipPaths.has(fullPath) || seen.has(fullPath)) continue;
103
+ seen.add(fullPath);
99
104
  files.push(fullPath);
100
105
  }
101
106
  }
@@ -127,8 +132,14 @@ export function parseDocFile(filePath, config) {
127
132
  refFields[field] = normalizeStringList(parsedFrontmatter[field]);
128
133
  }
129
134
 
135
+ // Tag doc with its root
136
+ const roots = config.docsRoots || [config.docsRoot];
137
+ const docRoot = roots.find(r => filePath.startsWith(r)) ?? config.docsRoot;
138
+ const rootLabel = path.relative(config.repoRoot, docRoot).split(path.sep).join('/');
139
+
130
140
  const doc = {
131
141
  path: relativePath,
142
+ root: rootLabel,
132
143
  status: asString(parsedFrontmatter.status) ?? null,
133
144
  owner: asString(parsedFrontmatter.owner) ?? null,
134
145
  surface,
package/src/init.mjs CHANGED
@@ -1,5 +1,6 @@
1
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
3
4
  import { green, dim } from './color.mjs';
4
5
 
5
6
  const STARTER_CONFIG = `// dotmd.config.mjs — document management configuration
@@ -24,6 +25,84 @@ _No docs yet. Run \`dotmd list\` after creating your first document._
24
25
  <!-- GENERATED:dotmd:end -->
25
26
  `;
26
27
 
28
+ function scanExistingDocs(dir) {
29
+ const statuses = new Set();
30
+ const surfaces = new Set();
31
+ const modules = new Set();
32
+ const refFieldNames = new Set();
33
+ let docCount = 0;
34
+
35
+ function walk(d) {
36
+ let entries;
37
+ try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
38
+ for (const entry of entries) {
39
+ if (entry.isDirectory()) { walk(path.join(d, entry.name)); continue; }
40
+ if (!entry.name.endsWith('.md')) continue;
41
+ let raw;
42
+ try { raw = readFileSync(path.join(d, entry.name), 'utf8'); } catch { continue; }
43
+ const { frontmatter } = extractFrontmatter(raw);
44
+ if (!frontmatter) continue;
45
+ const parsed = parseSimpleFrontmatter(frontmatter);
46
+ docCount++;
47
+ if (parsed.status) statuses.add(String(parsed.status).toLowerCase());
48
+ if (parsed.surface) surfaces.add(String(parsed.surface));
49
+ if (Array.isArray(parsed.surfaces)) parsed.surfaces.forEach(s => surfaces.add(String(s)));
50
+ if (parsed.module) modules.add(String(parsed.module));
51
+ if (Array.isArray(parsed.modules)) parsed.modules.forEach(m => modules.add(String(m)));
52
+ for (const [key, val] of Object.entries(parsed)) {
53
+ if (Array.isArray(val) && val.some(v => String(v).endsWith('.md'))) {
54
+ refFieldNames.add(key);
55
+ }
56
+ }
57
+ }
58
+ }
59
+
60
+ walk(dir);
61
+ return { docCount, statuses, surfaces, modules, refFieldNames };
62
+ }
63
+
64
+ function generateDetectedConfig(scan, rootPath) {
65
+ const lines = [`// dotmd.config.mjs — auto-detected from ${scan.docCount} existing docs`, ''];
66
+ lines.push(`export const root = '${rootPath}';`);
67
+ lines.push('');
68
+
69
+ const defaultOrder = ['active', 'ready', 'planned', 'research', 'blocked', 'reference', 'archived'];
70
+ const ordered = defaultOrder.filter(s => scan.statuses.has(s));
71
+ const extra = [...scan.statuses].filter(s => !defaultOrder.includes(s)).sort();
72
+ const allStatuses = [...ordered, ...extra];
73
+ if (allStatuses.length > 0) {
74
+ lines.push('export const statuses = {');
75
+ lines.push(` order: [${allStatuses.map(s => `'${s}'`).join(', ')}],`);
76
+ lines.push('};');
77
+ lines.push('');
78
+ }
79
+
80
+ if (scan.surfaces.size > 0) {
81
+ lines.push('export const taxonomy = {');
82
+ lines.push(` surfaces: [${[...scan.surfaces].sort().map(s => `'${s}'`).join(', ')}],`);
83
+ lines.push('};');
84
+ lines.push('');
85
+ }
86
+
87
+ if (scan.refFieldNames.size > 0) {
88
+ const names = [...scan.refFieldNames].sort();
89
+ lines.push('export const referenceFields = {');
90
+ lines.push(` bidirectional: [${names.map(n => `'${n}'`).join(', ')}],`);
91
+ lines.push(' unidirectional: [],');
92
+ lines.push('};');
93
+ lines.push('');
94
+ }
95
+
96
+ lines.push('export const index = {');
97
+ lines.push(` path: '${rootPath}/docs.md',`);
98
+ lines.push(` startMarker: '<!-- GENERATED:dotmd:start -->',`);
99
+ lines.push(` endMarker: '<!-- GENERATED:dotmd:end -->',`);
100
+ lines.push('};');
101
+ lines.push('');
102
+
103
+ return lines.join('\n');
104
+ }
105
+
27
106
  export function runInit(cwd) {
28
107
  const configPath = path.join(cwd, 'dotmd.config.mjs');
29
108
  const docsDir = path.join(cwd, 'docs');
@@ -34,8 +113,14 @@ export function runInit(cwd) {
34
113
  if (existsSync(configPath)) {
35
114
  process.stdout.write(` ${dim('exists')} dotmd.config.mjs\n`);
36
115
  } else {
37
- writeFileSync(configPath, STARTER_CONFIG, 'utf8');
38
- process.stdout.write(` ${green('create')} dotmd.config.mjs\n`);
116
+ const scan = existsSync(docsDir) ? scanExistingDocs(docsDir) : null;
117
+ if (scan && scan.docCount > 0) {
118
+ writeFileSync(configPath, generateDetectedConfig(scan, 'docs'), 'utf8');
119
+ process.stdout.write(` ${green('create')} dotmd.config.mjs (detected ${scan.docCount} docs)\n`);
120
+ } else {
121
+ writeFileSync(configPath, STARTER_CONFIG, 'utf8');
122
+ process.stdout.write(` ${green('create')} dotmd.config.mjs\n`);
123
+ }
39
124
  }
40
125
 
41
126
  if (existsSync(docsDir)) {
@@ -57,4 +142,3 @@ export function runInit(cwd) {
57
142
  process.stdout.write(` printf '---\\nstatus: active\\nupdated: ${today}\\n---\\n\\n# My Doc\\n' > docs/my-doc.md\n`);
58
143
  process.stdout.write(` dotmd list\n\n`);
59
144
  }
60
-
package/src/lifecycle.mjs CHANGED
@@ -7,6 +7,11 @@ import { buildIndex, collectDocFiles } from './index.mjs';
7
7
  import { renderIndexFile, writeIndex } from './index-file.mjs';
8
8
  import { green, dim, yellow } from './color.mjs';
9
9
 
10
+ function findFileRoot(filePath, config) {
11
+ const roots = config.docsRoots || [config.docsRoot];
12
+ return roots.find(r => filePath.startsWith(r)) ?? config.docsRoot;
13
+ }
14
+
10
15
  export function runStatus(argv, config, opts = {}) {
11
16
  const { dryRun } = opts;
12
17
  const input = argv[0];
@@ -29,7 +34,8 @@ export function runStatus(argv, config, opts = {}) {
29
34
  }
30
35
 
31
36
  const today = new Date().toISOString().slice(0, 10);
32
- const archiveDir = path.join(config.docsRoot, config.archiveDir);
37
+ const fileRoot = findFileRoot(filePath, config);
38
+ const archiveDir = path.join(fileRoot, config.archiveDir);
33
39
  const isArchiving = config.lifecycle.archiveStatuses.has(newStatus) && !filePath.includes(`/${config.archiveDir}/`);
34
40
  const isUnarchiving = !config.lifecycle.archiveStatuses.has(newStatus) && filePath.includes(`/${config.archiveDir}/`);
35
41
  let finalPath = filePath;
@@ -43,7 +49,7 @@ export function runStatus(argv, config, opts = {}) {
43
49
  finalPath = targetPath;
44
50
  }
45
51
  if (isUnarchiving) {
46
- const targetPath = path.join(config.docsRoot, path.basename(filePath));
52
+ const targetPath = path.join(fileRoot, path.basename(filePath));
47
53
  process.stdout.write(`${prefix} Would move: ${toRepoPath(filePath, config.repoRoot)} → ${toRepoPath(targetPath, config.repoRoot)}\n`);
48
54
  finalPath = targetPath;
49
55
  }
@@ -66,7 +72,7 @@ export function runStatus(argv, config, opts = {}) {
66
72
  }
67
73
 
68
74
  if (isUnarchiving) {
69
- const targetPath = path.join(config.docsRoot, path.basename(filePath));
75
+ const targetPath = path.join(fileRoot, path.basename(filePath));
70
76
  if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
71
77
  const result = gitMv(filePath, targetPath, config.repoRoot);
72
78
  if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
@@ -102,7 +108,8 @@ export function runArchive(argv, config, opts = {}) {
102
108
  const oldStatus = asString(parsed.status) ?? 'unknown';
103
109
 
104
110
  const today = new Date().toISOString().slice(0, 10);
105
- const targetDir = path.join(config.docsRoot, config.archiveDir);
111
+ const archiveFileRoot = findFileRoot(filePath, config);
112
+ const targetDir = path.join(archiveFileRoot, config.archiveDir);
106
113
  const targetPath = path.join(targetDir, path.basename(filePath));
107
114
  const oldRepoPath = toRepoPath(filePath, config.repoRoot);
108
115
  const newRepoPath = toRepoPath(targetPath, config.repoRoot);
package/src/new.mjs CHANGED
@@ -44,10 +44,12 @@ export function runNew(argv, config, opts = {}) {
44
44
  let status = 'active';
45
45
  let title = null;
46
46
  let templateName = null;
47
+ let rootName = null;
47
48
  for (let i = 0; i < argv.length; i++) {
48
49
  if (argv[i] === '--status' && argv[i + 1]) { status = argv[++i]; continue; }
49
50
  if (argv[i] === '--title' && argv[i + 1]) { title = argv[++i]; continue; }
50
51
  if (argv[i] === '--template' && argv[i + 1]) { templateName = argv[++i]; continue; }
52
+ if (argv[i] === '--root' && argv[i + 1]) { rootName = argv[++i]; continue; }
51
53
  if (argv[i] === '--list-templates') {
52
54
  listTemplates(config);
53
55
  return;
@@ -73,8 +75,20 @@ export function runNew(argv, config, opts = {}) {
73
75
  // Title
74
76
  const docTitle = title ?? name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
75
77
 
78
+ // Resolve target root
79
+ let targetRoot = config.docsRoot;
80
+ if (rootName) {
81
+ const roots = config.docsRoots || [config.docsRoot];
82
+ const match = roots.find(r => r.endsWith(rootName) || path.basename(r) === rootName);
83
+ if (!match) {
84
+ const available = roots.map(r => path.basename(r)).join(', ');
85
+ die(`Unknown root: ${rootName}\nAvailable: ${available}`);
86
+ }
87
+ targetRoot = match;
88
+ }
89
+
76
90
  // Path
77
- const filePath = path.join(config.docsRoot, slug + '.md');
91
+ const filePath = path.join(targetRoot, slug + '.md');
78
92
  const repoPath = toRepoPath(filePath, config.repoRoot);
79
93
 
80
94
  if (existsSync(filePath)) {