dotmd-cli 0.4.0 → 0.6.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/bin/dotmd.mjs CHANGED
@@ -17,6 +17,7 @@ import { runDiff } from '../src/diff.mjs';
17
17
  import { runLint } from '../src/lint.mjs';
18
18
  import { runRename } from '../src/rename.mjs';
19
19
  import { runMigrate } from '../src/migrate.mjs';
20
+ import { runFixRefs, fixBrokenRefs } from '../src/fix-refs.mjs';
20
21
  import { die, warn } from '../src/util.mjs';
21
22
 
22
23
  const __filename = fileURLToPath(import.meta.url);
@@ -29,15 +30,16 @@ const HELP = {
29
30
  Commands:
30
31
  list [--verbose] List docs grouped by status (default)
31
32
  json Full index as JSON
32
- check Validate frontmatter and references
33
+ check [flags] Validate frontmatter and references
33
34
  coverage [--json] Metadata coverage report
34
35
  context Compact briefing (LLM-oriented)
35
36
  focus [status] Detailed view for one status group
36
37
  query [filters] Filtered search
37
38
  index [--write] Generate/update docs.md index block
38
39
  status <file> <status> Transition document status
39
- archive <file> Archive (status + move + index regen)
40
+ archive <file> Archive (status + move + update refs)
40
41
  touch <file> Bump updated date
42
+ fix-refs Auto-fix broken reference paths
41
43
  lint [--fix] Check and auto-fix frontmatter issues
42
44
  rename <old> <new> Rename doc and update references
43
45
  migrate <f> <old> <new> Batch update a frontmatter field
@@ -82,10 +84,33 @@ regenerates the index (if configured).
82
84
 
83
85
  Use --dry-run (-n) to preview changes without writing anything.`,
84
86
 
87
+ check: `dotmd check — validate frontmatter and references
88
+
89
+ Options:
90
+ --errors-only Show only errors, suppress warnings
91
+ --fix Auto-fix broken references and regenerate index`,
92
+
85
93
  archive: `dotmd archive <file> — archive a document
86
94
 
87
- Sets status to 'archived', moves to the archive directory, regenerates
88
- the index, and scans for stale references.
95
+ Sets status to 'archived', moves to the archive directory, auto-updates
96
+ references in other docs, and regenerates the index.
97
+
98
+ Use --dry-run (-n) to preview changes without writing anything.`,
99
+
100
+ 'fix-refs': `dotmd fix-refs — auto-fix broken reference paths
101
+
102
+ Scans all docs for reference fields that point to non-existent files,
103
+ then attempts to resolve them by matching the basename against all known
104
+ docs. Fixes are applied by rewriting the frontmatter path.
105
+
106
+ Use --dry-run (-n) to preview changes without writing anything.`,
107
+
108
+ touch: `dotmd touch <file> — bump updated date
109
+ dotmd touch --git — bulk-sync dates from git history
110
+
111
+ Without --git, updates a single file's frontmatter updated date to today.
112
+ With --git, scans all docs (or a specific file) and syncs their updated
113
+ date to match the last git commit date, fixing date drift warnings.
89
114
 
90
115
  Use --dry-run (-n) to preview changes without writing anything.`,
91
116
 
@@ -243,6 +268,7 @@ async function main() {
243
268
  if (command === 'lint') { runLint(restArgs, config, { dryRun }); return; }
244
269
  if (command === 'rename') { runRename(restArgs, config, { dryRun }); return; }
245
270
  if (command === 'migrate') { runMigrate(restArgs, config, { dryRun }); return; }
271
+ if (command === 'fix-refs') { runFixRefs(restArgs, config, { dryRun }); return; }
246
272
 
247
273
  const index = buildIndex(config);
248
274
 
@@ -265,7 +291,29 @@ async function main() {
265
291
  }
266
292
 
267
293
  if (command === 'check') {
268
- process.stdout.write(renderCheck(index, config));
294
+ const fix = args.includes('--fix');
295
+ const errorsOnly = args.includes('--errors-only');
296
+
297
+ if (fix) {
298
+ // Auto-fix: broken refs, then lint, then rebuild index
299
+ const refResult = fixBrokenRefs(config, { dryRun, quiet: false });
300
+ if (!dryRun) {
301
+ runLint(['--fix'], config, { dryRun });
302
+ }
303
+ if (!dryRun && config.indexPath) {
304
+ const { renderIndexFile: rif, writeIndex: wi } = await import('../src/index-file.mjs');
305
+ const freshIndex = buildIndex(config);
306
+ wi(rif(freshIndex, config), config);
307
+ process.stdout.write('Index regenerated.\n');
308
+ }
309
+ // Show remaining issues
310
+ const freshIndex = buildIndex(config);
311
+ process.stdout.write('\n' + renderCheck(freshIndex, config, { errorsOnly }));
312
+ if (freshIndex.errors.length > 0) process.exitCode = 1;
313
+ return;
314
+ }
315
+
316
+ process.stdout.write(renderCheck(index, config, { errorsOnly }));
269
317
  if (index.errors.length > 0) process.exitCode = 1;
270
318
  return;
271
319
  }
@@ -282,7 +330,6 @@ async function main() {
282
330
  if (command === 'index') {
283
331
  if (!config.indexPath) {
284
332
  die('Index generation is not configured. Add an `index` section to your dotmd.config.mjs.');
285
- return;
286
333
  }
287
334
  const write = args.includes('--write');
288
335
  const rendered = renderIndexFile(index, config);
@@ -306,5 +353,6 @@ async function main() {
306
353
  }
307
354
 
308
355
  main().catch(err => {
309
- die(err.message);
356
+ process.stderr.write(`${err.message}\n`);
357
+ process.exitCode = 1;
310
358
  });
@@ -35,7 +35,7 @@ export const lifecycle = {
35
35
 
36
36
  // Taxonomy validation — set fields to null to skip validation
37
37
  export const taxonomy = {
38
- surfaces: ['frontend', 'backend', 'mobile', 'docs', 'ops', 'platform'],
38
+ surfaces: ['web', 'ios', 'android', 'mobile', 'full-stack', 'frontend', 'backend', 'api', 'docs', 'ops', 'platform', 'infra', 'design'],
39
39
  moduleRequiredFor: ['active', 'ready', 'planned', 'blocked'],
40
40
  };
41
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Zero-dependency CLI for managing markdown documents with YAML frontmatter — index, query, validate, lifecycle.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -3,7 +3,7 @@ import { die } from './util.mjs';
3
3
  const COMMANDS = [
4
4
  'list', 'json', 'check', 'coverage', 'context', 'focus', 'query',
5
5
  'index', 'status', 'archive', 'touch', 'lint', 'rename', 'migrate',
6
- 'watch', 'diff', 'init', 'new', 'completions',
6
+ 'fix-refs', 'watch', 'diff', 'init', 'new', 'completions',
7
7
  ];
8
8
 
9
9
  const GLOBAL_FLAGS = ['--config', '--dry-run', '--verbose', '--help', '--version'];
@@ -17,9 +17,12 @@ const COMMAND_FLAGS = {
17
17
  coverage: ['--json'],
18
18
  new: ['--status', '--title'],
19
19
  diff: ['--stat', '--since', '--summarize', '--model'],
20
+ check: ['--errors-only', '--fix'],
20
21
  lint: ['--fix'],
21
22
  rename: [],
22
23
  migrate: [],
24
+ 'fix-refs': [],
25
+ touch: ['--git'],
23
26
  };
24
27
 
25
28
  function bashCompletion() {
@@ -90,7 +93,6 @@ export function runCompletions(argv) {
90
93
  const shell = argv[0];
91
94
  if (!shell) {
92
95
  die('Usage: dotmd completions <bash|zsh>');
93
- return;
94
96
  }
95
97
  if (shell === 'bash') {
96
98
  process.stdout.write(bashCompletion() + '\n');
package/src/config.mjs CHANGED
@@ -100,6 +100,15 @@ function validateConfig(userConfig, config, validStatuses, indexPath) {
100
100
  }
101
101
  }
102
102
 
103
+ // staleDays keys must exist in validStatuses
104
+ if (config.statuses?.staleDays) {
105
+ for (const key of Object.keys(config.statuses.staleDays)) {
106
+ if (!validStatuses.has(key)) {
107
+ warnings.push(`Config: statuses.staleDays contains unknown status '${key}'.`);
108
+ }
109
+ }
110
+ }
111
+
103
112
  // taxonomy.surfaces must be null or array
104
113
  if (config.taxonomy?.surfaces !== undefined && config.taxonomy.surfaces !== null && !Array.isArray(config.taxonomy.surfaces)) {
105
114
  warnings.push('Config: taxonomy.surfaces must be null or an array.');
@@ -149,22 +158,6 @@ export async function resolveConfig(cwd, explicitConfigPath) {
149
158
  mod = await import(configUrl);
150
159
  } catch (err) {
151
160
  die('Failed to load config: ' + configPath + '\n' + err.message + '\nRun `dotmd init` to create a starter config.');
152
- // Return defaults so caller can still function
153
- const defaults = deepMerge(DEFAULTS, {});
154
- const defaultStatusOrder = defaults.statuses.order;
155
- return {
156
- raw: defaults, docsRoot: cwd, repoRoot: cwd, configDir: cwd,
157
- configPath: configPath ?? null, configFound: Boolean(configPath),
158
- archiveDir: defaults.archiveDir, excludeDirs: new Set(defaults.excludeDirs),
159
- docsRootPrefix: '', statusOrder: defaultStatusOrder,
160
- validStatuses: new Set(defaultStatusOrder), staleDaysByStatus: {},
161
- lifecycle: { archiveStatuses: new Set(defaults.lifecycle.archiveStatuses), skipStaleFor: new Set(defaults.lifecycle.skipStaleFor), skipWarningsFor: new Set(defaults.lifecycle.skipWarningsFor) },
162
- validSurfaces: null, moduleRequiredStatuses: new Set(),
163
- indexPath: null, indexStartMarker: '<!-- GENERATED:dotmd:start -->', indexEndMarker: '<!-- GENERATED:dotmd:end -->', archivedHighlightLimit: 8,
164
- context: defaults.context, display: defaults.display,
165
- referenceFields: defaults.referenceFields, presets: defaults.presets, hooks: {},
166
- configWarnings: [],
167
- };
168
161
  }
169
162
 
170
163
  configDir = path.dirname(configPath);
@@ -189,8 +182,9 @@ export async function resolveConfig(cwd, explicitConfigPath) {
189
182
 
190
183
  const docsRoot = path.resolve(configDir, config.root);
191
184
 
185
+ const earlyWarnings = [];
192
186
  if (!existsSync(docsRoot)) {
193
- warn('Docs root does not exist: ' + docsRoot);
187
+ earlyWarnings.push('Config: docs root does not exist: ' + docsRoot);
194
188
  }
195
189
 
196
190
  // Find repo root by walking up looking for .git
@@ -233,7 +227,7 @@ export async function resolveConfig(cwd, explicitConfigPath) {
233
227
  const skipStaleFor = new Set(lifecycle.skipStaleFor);
234
228
  const skipWarningsFor = new Set(lifecycle.skipWarningsFor);
235
229
 
236
- const configWarnings = validateConfig(userConfig, config, validStatuses, indexPath);
230
+ const configWarnings = [...earlyWarnings, ...validateConfig(userConfig, config, validStatuses, indexPath)];
237
231
 
238
232
  return {
239
233
  raw: config,
package/src/diff.mjs CHANGED
@@ -27,7 +27,6 @@ export function runDiff(argv, config) {
27
27
  const filePath = resolveDocPath(file, config);
28
28
  if (!filePath) {
29
29
  die(`File not found: ${file}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`);
30
- return;
31
30
  }
32
31
 
33
32
  const raw = readFileSync(filePath, 'utf8');
@@ -37,7 +36,6 @@ export function runDiff(argv, config) {
37
36
 
38
37
  if (!since) {
39
38
  die(`No updated date found in ${file} and no --since provided.`);
40
- return;
41
39
  }
42
40
 
43
41
  const relPath = toRepoPath(filePath, config.repoRoot);
@@ -80,9 +78,15 @@ function printFileDiff(relPath, since, diffOutput, opts) {
80
78
  process.stdout.write(bold(relPath) + dim(` (updated: ${since})`) + '\n');
81
79
 
82
80
  if (opts.summarize) {
83
- const summary = opts.config?.hooks?.summarizeDiff
84
- ? opts.config.hooks.summarizeDiff(diffOutput, relPath)
85
- : summarizeWithMLX(diffOutput, relPath, opts.model);
81
+ let summary;
82
+ try {
83
+ summary = opts.config?.hooks?.summarizeDiff
84
+ ? opts.config.hooks.summarizeDiff(diffOutput, relPath)
85
+ : summarizeWithMLX(diffOutput, relPath, opts.model);
86
+ } catch (err) {
87
+ warn(`Hook 'summarizeDiff' threw: ${err.message}`);
88
+ summary = null;
89
+ }
86
90
  if (summary) {
87
91
  process.stdout.write(dim(` Summary: ${summary}`) + '\n');
88
92
  } else {
@@ -95,6 +99,12 @@ function printFileDiff(relPath, since, diffOutput, opts) {
95
99
  }
96
100
 
97
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
+
98
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)}`;
99
109
 
100
110
  const result = spawnSync('uv', [
@@ -0,0 +1,113 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
4
+ import { toRepoPath, warn } from './util.mjs';
5
+ import { buildIndex, collectDocFiles } from './index.mjs';
6
+ import { green, dim, yellow } from './color.mjs';
7
+
8
+ export function runFixRefs(argv, config, opts = {}) {
9
+ const { dryRun } = opts;
10
+ const result = fixBrokenRefs(config, { dryRun });
11
+ if (result.totalFixed === 0 && result.unfixableCount === 0) {
12
+ process.stdout.write(green('No broken references found.') + '\n');
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Core logic for fixing broken references. Returns { totalFixed, unfixableCount }.
18
+ * Shared by `dotmd fix-refs` and `dotmd check --fix`.
19
+ */
20
+ export function fixBrokenRefs(config, opts = {}) {
21
+ const { dryRun, quiet } = opts;
22
+ const index = buildIndex(config);
23
+ const allFiles = collectDocFiles(config);
24
+
25
+ // Build a map of basename → absolute path for all docs
26
+ const basenameMap = new Map();
27
+ const duplicateBasenames = new Set();
28
+ for (const filePath of allFiles) {
29
+ const basename = path.basename(filePath);
30
+ if (basenameMap.has(basename)) {
31
+ duplicateBasenames.add(basename);
32
+ } else {
33
+ basenameMap.set(basename, filePath);
34
+ }
35
+ }
36
+
37
+ // Find broken ref errors
38
+ const brokenRefErrors = index.errors.filter(e =>
39
+ e.message.includes('does not resolve to an existing file')
40
+ );
41
+
42
+ if (brokenRefErrors.length === 0) {
43
+ return { totalFixed: 0, unfixableCount: 0 };
44
+ }
45
+
46
+ // Group fixes by doc path
47
+ const fixesByDoc = new Map();
48
+ let unfixableCount = 0;
49
+
50
+ for (const err of brokenRefErrors) {
51
+ const match = err.message.match(/entry `([^`]+)` does not resolve/);
52
+ if (!match) { unfixableCount++; continue; }
53
+
54
+ const brokenRef = match[1];
55
+ const brokenBasename = path.basename(brokenRef);
56
+
57
+ if (duplicateBasenames.has(brokenBasename)) {
58
+ unfixableCount++;
59
+ continue;
60
+ }
61
+
62
+ const resolved = basenameMap.get(brokenBasename);
63
+ if (!resolved) { unfixableCount++; continue; }
64
+
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('/');
68
+
69
+ if (correctRelPath === brokenRef) { unfixableCount++; continue; }
70
+
71
+ if (!fixesByDoc.has(err.path)) {
72
+ fixesByDoc.set(err.path, []);
73
+ }
74
+ fixesByDoc.get(err.path).push({ brokenRef, correctRelPath });
75
+ }
76
+
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;
85
+
86
+ let newFm = fm;
87
+ for (const { brokenRef, correctRelPath } of fixes) {
88
+ newFm = newFm.split(brokenRef).join(correctRelPath);
89
+ }
90
+
91
+ if (newFm !== fm && !dryRun) {
92
+ raw = replaceFrontmatter(raw, newFm);
93
+ writeFileSync(absPath, raw, 'utf8');
94
+ }
95
+
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`);
100
+ }
101
+ }
102
+ totalFixed += fixes.length;
103
+ }
104
+
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
+ }
110
+ }
111
+
112
+ return { totalFixed, unfixableCount };
113
+ }
@@ -14,6 +14,14 @@ export function extractFrontmatter(raw) {
14
14
  };
15
15
  }
16
16
 
17
+ export function replaceFrontmatter(raw, newFrontmatter) {
18
+ if (!raw.startsWith('---\n')) return raw;
19
+ const endMarker = raw.indexOf('\n---\n', 4);
20
+ if (endMarker === -1) return raw;
21
+ const body = raw.slice(endMarker + 5);
22
+ return `---\n${newFrontmatter}\n---\n${body}`;
23
+ }
24
+
17
25
  export function parseSimpleFrontmatter(text) {
18
26
  const data = {};
19
27
  let currentArrayKey = null;
@@ -25,6 +33,10 @@ export function parseSimpleFrontmatter(text) {
25
33
  const keyMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
26
34
  if (keyMatch) {
27
35
  const [, key, rawValue] = keyMatch;
36
+ if (Object.prototype.hasOwnProperty.call(data, key)) {
37
+ currentArrayKey = null;
38
+ continue;
39
+ }
28
40
  if (!rawValue.trim()) {
29
41
  data[key] = [];
30
42
  currentArrayKey = key;
@@ -48,7 +60,12 @@ export function parseSimpleFrontmatter(text) {
48
60
  }
49
61
 
50
62
  function parseScalar(value) {
51
- const unquoted = value.replace(/^['"]|['"]$/g, '');
63
+ let unquoted = value;
64
+ if (value.length > 1 &&
65
+ ((value.startsWith("'") && value.endsWith("'")) ||
66
+ (value.startsWith('"') && value.endsWith('"')))) {
67
+ unquoted = value.slice(1, -1);
68
+ }
52
69
  if (unquoted === 'true') return true;
53
70
  if (unquoted === 'false') return false;
54
71
  return unquoted;
package/src/git.mjs CHANGED
@@ -1,15 +1,26 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
 
3
+ let gitChecked = false;
4
+ function ensureGit() {
5
+ if (gitChecked) return;
6
+ const result = spawnSync('git', ['--version'], { encoding: 'utf8' });
7
+ if (result.error) {
8
+ throw new Error('git is not installed or not found in PATH. dotmd requires git for this operation.');
9
+ }
10
+ gitChecked = true;
11
+ }
12
+
3
13
  export function getGitLastModified(relPath, repoRoot) {
4
14
  const result = spawnSync('git', ['log', '-1', '--format=%aI', '--', relPath], {
5
15
  cwd: repoRoot,
6
16
  encoding: 'utf8',
7
17
  });
8
- if (result.status !== 0 || !result.stdout.trim()) return null;
18
+ if (result.error || result.status !== 0 || !result.stdout.trim()) return null;
9
19
  return result.stdout.trim();
10
20
  }
11
21
 
12
22
  export function gitMv(source, target, repoRoot) {
23
+ ensureGit();
13
24
  const result = spawnSync('git', ['mv', source, target], {
14
25
  cwd: repoRoot,
15
26
  encoding: 'utf8',
@@ -18,6 +29,7 @@ export function gitMv(source, target, repoRoot) {
18
29
  }
19
30
 
20
31
  export function gitDiffSince(relPath, sinceDate, repoRoot, opts = {}) {
32
+ ensureGit();
21
33
  // Find the last commit at or before sinceDate
22
34
  const baseline = spawnSync('git', [
23
35
  'log', '-1', '--before=' + sinceDate + 'T23:59:59', '--format=%H', '--', relPath
package/src/index.mjs CHANGED
@@ -2,7 +2,7 @@ import { readdirSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
4
  import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts } from './extractors.mjs';
5
- import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath } from './util.mjs';
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';
8
8
 
@@ -19,20 +19,32 @@ export function buildIndex(config) {
19
19
  if (config.hooks.validate) {
20
20
  const ctx = { config, allDocs: docs, repoRoot: config.repoRoot };
21
21
  for (const doc of docs) {
22
- const result = config.hooks.validate(doc, ctx);
23
- if (result?.errors) {
24
- doc.errors.push(...result.errors);
25
- errors.push(...result.errors);
26
- }
27
- if (result?.warnings) {
28
- doc.warnings.push(...result.warnings);
29
- warnings.push(...result.warnings);
22
+ try {
23
+ const result = config.hooks.validate(doc, ctx);
24
+ if (result?.errors) {
25
+ doc.errors.push(...result.errors);
26
+ errors.push(...result.errors);
27
+ }
28
+ if (result?.warnings) {
29
+ doc.warnings.push(...result.warnings);
30
+ warnings.push(...result.warnings);
31
+ }
32
+ } catch (err) {
33
+ const hookError = { path: doc.path, level: 'error', message: `Hook 'validate' threw: ${err.message}` };
34
+ doc.errors.push(hookError);
35
+ errors.push(hookError);
30
36
  }
31
37
  }
32
38
  }
33
39
 
34
40
  const transformedDocs = config.hooks.transformDoc
35
- ? docs.map(d => config.hooks.transformDoc(d) ?? d)
41
+ ? docs.map(d => {
42
+ try { return config.hooks.transformDoc(d) ?? d; }
43
+ catch (err) {
44
+ warnings.push({ path: d.path, level: 'warning', message: `Hook 'transformDoc' threw: ${err.message}` });
45
+ return d;
46
+ }
47
+ })
36
48
  : docs;
37
49
 
38
50
  const countsByStatus = Object.fromEntries(config.statusOrder.map(status => [
package/src/lifecycle.mjs CHANGED
@@ -1,22 +1,22 @@
1
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
- import { asString, toRepoPath, die, resolveDocPath } from './util.mjs';
5
- import { gitMv } from './git.mjs';
3
+ import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
4
+ import { asString, toRepoPath, die, warn, resolveDocPath, escapeRegex } from './util.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;
12
12
  const input = argv[0];
13
13
  const newStatus = argv[1];
14
14
 
15
- if (!input || !newStatus) { die('Usage: dotmd status <file> <new-status>'); return; }
16
- if (!config.validStatuses.has(newStatus)) { die(`Invalid status: ${newStatus}\nValid: ${[...config.validStatuses].join(', ')}`); return; }
15
+ if (!input || !newStatus) { die('Usage: dotmd status <file> <new-status>'); }
16
+ if (!config.validStatuses.has(newStatus)) { die(`Invalid status: ${newStatus}\nValid: ${[...config.validStatuses].join(', ')}`); }
17
17
 
18
18
  const filePath = resolveDocPath(input, config);
19
- if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); return; }
19
+ if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
20
20
 
21
21
  const raw = readFileSync(filePath, 'utf8');
22
22
  const { frontmatter } = extractFrontmatter(raw);
@@ -57,18 +57,19 @@ export function runStatus(argv, config, opts = {}) {
57
57
  updateFrontmatter(filePath, { status: newStatus, updated: today });
58
58
 
59
59
  if (isArchiving) {
60
+ mkdirSync(archiveDir, { recursive: true });
60
61
  const targetPath = path.join(archiveDir, path.basename(filePath));
61
- if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); return; }
62
+ if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
62
63
  const result = gitMv(filePath, targetPath, config.repoRoot);
63
- if (result.status !== 0) { die(result.stderr || 'git mv failed.'); return; }
64
+ if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
64
65
  finalPath = targetPath;
65
66
  }
66
67
 
67
68
  if (isUnarchiving) {
68
69
  const targetPath = path.join(config.docsRoot, path.basename(filePath));
69
- if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); return; }
70
+ if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
70
71
  const result = gitMv(filePath, targetPath, config.repoRoot);
71
- if (result.status !== 0) { die(result.stderr || 'git mv failed.'); return; }
72
+ if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
72
73
  finalPath = targetPath;
73
74
  }
74
75
 
@@ -79,21 +80,21 @@ export function runStatus(argv, config, opts = {}) {
79
80
 
80
81
  process.stdout.write(`${green(toRepoPath(finalPath, config.repoRoot))}: ${oldStatus ?? 'unknown'} → ${newStatus}\n`);
81
82
 
82
- config.hooks.onStatusChange?.({ path: toRepoPath(finalPath, config.repoRoot), oldStatus, newStatus }, {
83
+ try { config.hooks.onStatusChange?.({ path: toRepoPath(finalPath, config.repoRoot), oldStatus, newStatus }, {
83
84
  oldPath: toRepoPath(filePath, config.repoRoot),
84
85
  newPath: toRepoPath(finalPath, config.repoRoot),
85
- });
86
+ }); } catch (err) { warn(`Hook 'onStatusChange' threw: ${err.message}`); }
86
87
  }
87
88
 
88
89
  export function runArchive(argv, config, opts = {}) {
89
90
  const { dryRun } = opts;
90
91
  const input = argv[0];
91
92
 
92
- if (!input) { die('Usage: dotmd archive <file>'); return; }
93
+ if (!input) { die('Usage: dotmd archive <file>'); }
93
94
 
94
95
  const filePath = resolveDocPath(input, config);
95
- if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); return; }
96
- if (filePath.includes(`/${config.archiveDir}/`)) { die(`Already archived: ${toRepoPath(filePath, config.repoRoot)}`); return; }
96
+ if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
97
+ if (filePath.includes(`/${config.archiveDir}/`)) { die(`Already archived: ${toRepoPath(filePath, config.repoRoot)}`); }
97
98
 
98
99
  const raw = readFileSync(filePath, 'utf8');
99
100
  const { frontmatter } = extractFrontmatter(raw);
@@ -109,34 +110,28 @@ export function runArchive(argv, config, opts = {}) {
109
110
  if (dryRun) {
110
111
  const prefix = dim('[dry-run]');
111
112
  process.stdout.write(`${prefix} Would update frontmatter: status: ${oldStatus} → archived, updated: ${today}\n`);
112
- if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); return; }
113
+ if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
113
114
  process.stdout.write(`${prefix} Would move: ${oldRepoPath} → ${newRepoPath}\n`);
114
115
  if (config.indexPath) process.stdout.write(`${prefix} Would regenerate index\n`);
115
116
 
116
- // Reference scan is read-only, still useful in dry-run
117
- const basename = path.basename(filePath);
118
- const references = [];
119
- for (const docFile of collectDocFiles(config)) {
120
- if (docFile === targetPath) continue;
121
- const docRaw = readFileSync(docFile, 'utf8');
122
- const { frontmatter: docFm } = extractFrontmatter(docRaw);
123
- if (docFm.includes(basename)) {
124
- references.push(toRepoPath(docFile, config.repoRoot));
125
- }
126
- }
127
- if (references.length > 0) {
128
- process.stdout.write('\nThese docs reference the old path — would need updating:\n');
129
- 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`);
130
121
  }
131
122
  return;
132
123
  }
133
124
 
134
125
  updateFrontmatter(filePath, { status: 'archived', updated: today });
135
126
 
136
- if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); return; }
127
+ mkdirSync(targetDir, { recursive: true });
128
+ if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
137
129
 
138
130
  const result = gitMv(filePath, targetPath, config.repoRoot);
139
- if (result.status !== 0) { die(result.stderr || 'git mv failed.'); return; }
131
+ if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
132
+
133
+ // Auto-update references in other docs
134
+ const updatedRefCount = updateRefsAfterMove(filePath, targetPath, config);
140
135
 
141
136
  if (config.indexPath) {
142
137
  const index = buildIndex(config);
@@ -144,36 +139,72 @@ export function runArchive(argv, config, opts = {}) {
144
139
  }
145
140
 
146
141
  process.stdout.write(`${green('Archived')}: ${oldRepoPath} → ${newRepoPath}\n`);
142
+ if (updatedRefCount > 0) process.stdout.write(`Updated references in ${updatedRefCount} file(s).\n`);
147
143
  if (config.indexPath) process.stdout.write('Index regenerated.\n');
148
144
 
149
- const basename = path.basename(filePath);
150
- const references = [];
151
- for (const docFile of collectDocFiles(config)) {
152
- if (docFile === targetPath) continue;
153
- const docRaw = readFileSync(docFile, 'utf8');
154
- const { frontmatter: docFm } = extractFrontmatter(docRaw);
155
- if (docFm.includes(basename)) {
156
- references.push(toRepoPath(docFile, config.repoRoot));
157
- }
158
- }
159
-
160
- if (references.length > 0) {
161
- process.stdout.write('\nThese docs reference the old path — update reference entries:\n');
162
- for (const ref of references) process.stdout.write(`- ${ref}\n`);
163
- }
164
-
165
- process.stdout.write('\nNext: commit, then update references if needed.\n');
166
- config.hooks.onArchive?.({ path: newRepoPath, oldStatus }, { oldPath: oldRepoPath, newPath: newRepoPath });
145
+ try { config.hooks.onArchive?.({ path: newRepoPath, oldStatus }, { oldPath: oldRepoPath, newPath: newRepoPath }); } catch (err) { warn(`Hook 'onArchive' threw: ${err.message}`); }
167
146
  }
168
147
 
169
148
  export function runTouch(argv, config, opts = {}) {
170
149
  const { dryRun } = opts;
171
- 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];
172
158
 
173
- if (!input) { die('Usage: dotmd touch <file>'); return; }
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'); }
174
205
 
175
206
  const filePath = resolveDocPath(input, config);
176
- if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); return; }
207
+ if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
177
208
 
178
209
  const today = new Date().toISOString().slice(0, 10);
179
210
 
@@ -185,7 +216,70 @@ export function runTouch(argv, config, opts = {}) {
185
216
  updateFrontmatter(filePath, { updated: today });
186
217
  process.stdout.write(`${green('Touched')}: ${toRepoPath(filePath, config.repoRoot)} (updated → ${today})\n`);
187
218
 
188
- config.hooks.onTouch?.({ path: toRepoPath(filePath, config.repoRoot) }, { path: toRepoPath(filePath, config.repoRoot), date: today });
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}`); }
220
+ }
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;
189
283
  }
190
284
 
191
285
  export function updateFrontmatter(filePath, updates) {
@@ -199,7 +293,7 @@ export function updateFrontmatter(filePath, updates) {
199
293
  const body = raw.slice(endMarker + 5);
200
294
 
201
295
  for (const [key, value] of Object.entries(updates)) {
202
- const regex = new RegExp(`^${key}:.*$`, 'm');
296
+ const regex = new RegExp(`^${escapeRegex(key)}:.*$`, 'm');
203
297
  if (regex.test(frontmatter)) {
204
298
  frontmatter = frontmatter.replace(regex, `${key}: ${value}`);
205
299
  } else {
package/src/lint.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, writeFileSync } from 'node:fs';
2
- import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
3
- import { asString, toRepoPath } from './util.mjs';
2
+ import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
3
+ import { asString, toRepoPath, escapeRegex, warn } from './util.mjs';
4
4
  import { buildIndex, collectDocFiles } from './index.mjs';
5
5
  import { updateFrontmatter } from './lifecycle.mjs';
6
6
  import { bold, green, yellow, dim } from './color.mjs';
@@ -132,7 +132,7 @@ export function runLint(argv, config, opts = {}) {
132
132
  const { frontmatter: fm } = extractFrontmatter(raw);
133
133
  let newFm = fm;
134
134
  for (const kr of keyRenames) {
135
- const regex = new RegExp(`^${kr.oldValue}:`, 'm');
135
+ const regex = new RegExp(`^${escapeRegex(kr.oldValue)}:`, 'm');
136
136
  newFm = newFm.replace(regex, `${kr.newValue}:`);
137
137
  }
138
138
  for (const tf of trimFixes) {
@@ -140,7 +140,7 @@ export function runLint(argv, config, opts = {}) {
140
140
  newFm = newFm.replace(regex, `$1${tf.newValue}`);
141
141
  }
142
142
  if (newFm !== fm) {
143
- raw = raw.replace(`---\n${fm}\n---`, `---\n${newFm}\n---`);
143
+ raw = replaceFrontmatter(raw, newFm);
144
144
  writeFileSync(filePath, raw, 'utf8');
145
145
  }
146
146
  }
@@ -174,13 +174,10 @@ export function runLint(argv, config, opts = {}) {
174
174
  totalFixes += fixes.length;
175
175
 
176
176
  if (!dryRun) {
177
- config.hooks.onLint?.({ path: repoPath, fixes });
177
+ try { config.hooks.onLint?.({ path: repoPath, fixes }); } catch (err) { warn(`Hook 'onLint' threw: ${err.message}`); }
178
178
  }
179
179
  }
180
180
 
181
181
  process.stdout.write(`\n${prefix}${totalFixes} fix${totalFixes !== 1 ? 'es' : ''} applied across ${fixable.length} file(s).\n`);
182
182
  }
183
183
 
184
- function escapeRegex(str) {
185
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
186
- }
package/src/migrate.mjs CHANGED
@@ -21,7 +21,6 @@ export function runMigrate(argv, config, opts = {}) {
21
21
 
22
22
  if (!field || !oldValue || !newValue) {
23
23
  die('Usage: dotmd migrate <field> <old-value> <new-value>');
24
- return;
25
24
  }
26
25
 
27
26
  const allFiles = collectDocFiles(config);
package/src/new.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { toRepoPath, die } from './util.mjs';
3
+ import { toRepoPath, die, warn } from './util.mjs';
4
4
  import { green, dim } from './color.mjs';
5
5
 
6
6
  export function runNew(argv, config, opts = {}) {
@@ -17,17 +17,16 @@ export function runNew(argv, config, opts = {}) {
17
17
  }
18
18
 
19
19
  const name = positional[0];
20
- if (!name) { die('Usage: dotmd new <name> [--status <s>] [--title <t>]'); return; }
20
+ if (!name) { die('Usage: dotmd new <name> [--status <s>] [--title <t>]'); }
21
21
 
22
22
  // Validate status
23
23
  if (!config.validStatuses.has(status)) {
24
24
  die(`Invalid status: ${status}\nValid: ${[...config.validStatuses].join(', ')}`);
25
- return;
26
25
  }
27
26
 
28
27
  // Slugify
29
28
  const slug = name.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
30
- if (!slug) { die('Name resolves to empty slug: ' + name); return; }
29
+ if (!slug) { die('Name resolves to empty slug: ' + name); }
31
30
 
32
31
  // Title
33
32
  const docTitle = title ?? name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
@@ -38,7 +37,6 @@ export function runNew(argv, config, opts = {}) {
38
37
 
39
38
  if (existsSync(filePath)) {
40
39
  die(`File already exists: ${repoPath}`);
41
- return;
42
40
  }
43
41
 
44
42
  if (dryRun) {
@@ -52,5 +50,5 @@ export function runNew(argv, config, opts = {}) {
52
50
  writeFileSync(filePath, content, 'utf8');
53
51
  process.stdout.write(`${green('Created')}: ${repoPath}\n`);
54
52
 
55
- config.hooks.onNew?.({ path: repoPath, status, title: docTitle });
53
+ try { config.hooks.onNew?.({ path: repoPath, status, title: docTitle }); } catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
56
54
  }
package/src/rename.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, 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 { toRepoPath, resolveDocPath, die, warn } from './util.mjs';
5
5
  import { collectDocFiles } from './index.mjs';
6
6
  import { gitMv } from './git.mjs';
@@ -21,14 +21,12 @@ export function runRename(argv, config, opts = {}) {
21
21
 
22
22
  if (!oldInput || !newInput) {
23
23
  die('Usage: dotmd rename <old> <new>');
24
- return;
25
24
  }
26
25
 
27
26
  // Resolve old path
28
27
  const oldPath = resolveDocPath(oldInput, config);
29
28
  if (!oldPath) {
30
29
  die(`File not found: ${oldInput}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`);
31
- return;
32
30
  }
33
31
 
34
32
  // Compute new path in same directory as old
@@ -109,7 +107,6 @@ export function runRename(argv, config, opts = {}) {
109
107
  const result = gitMv(oldPath, newPath, config.repoRoot);
110
108
  if (result.status !== 0) {
111
109
  die(result.stderr || 'git mv failed.');
112
- return;
113
110
  }
114
111
 
115
112
  // Update references in frontmatter of other docs
@@ -127,7 +124,7 @@ export function runRename(argv, config, opts = {}) {
127
124
  }).join('\n');
128
125
 
129
126
  if (newFm !== fm) {
130
- raw = raw.replace(`---\n${fm}\n---`, `---\n${newFm}\n---`);
127
+ raw = replaceFrontmatter(raw, newFm);
131
128
  writeFileSync(filePath, raw, 'utf8');
132
129
  updatedCount++;
133
130
  }
@@ -145,5 +142,5 @@ export function runRename(argv, config, opts = {}) {
145
142
  }
146
143
  }
147
144
 
148
- config.hooks.onRename?.({ oldPath: oldRepoPath, newPath: newRepoPath, referencesUpdated: updatedCount });
145
+ try { config.hooks.onRename?.({ oldPath: oldRepoPath, newPath: newRepoPath, referencesUpdated: updatedCount }); } catch (err) { warn(`Hook 'onRename' threw: ${err.message}`); }
149
146
  }
package/src/render.mjs CHANGED
@@ -1,10 +1,11 @@
1
- import { capitalize, toSlug, truncate } from './util.mjs';
1
+ import { capitalize, toSlug, truncate, warn } from './util.mjs';
2
2
  import { bold, red, yellow, green } from './color.mjs';
3
3
 
4
4
  export function renderCompactList(index, config) {
5
5
  const defaultRenderer = (idx) => _renderCompactList(idx, config);
6
6
  if (config.hooks.renderCompactList) {
7
- return config.hooks.renderCompactList(index, defaultRenderer);
7
+ try { return config.hooks.renderCompactList(index, defaultRenderer); }
8
+ catch (err) { warn(`Hook 'renderCompactList' threw: ${err.message}`); }
8
9
  }
9
10
  return defaultRenderer(index);
10
11
  }
@@ -63,7 +64,8 @@ export function renderVerboseList(index, config) {
63
64
  export function renderContext(index, config) {
64
65
  const defaultRenderer = (idx) => _renderContext(idx, config);
65
66
  if (config.hooks.renderContext) {
66
- return config.hooks.renderContext(index, defaultRenderer);
67
+ try { return config.hooks.renderContext(index, defaultRenderer); }
68
+ catch (err) { warn(`Hook 'renderContext' threw: ${err.message}`); }
67
69
  }
68
70
  return defaultRenderer(index);
69
71
  }
@@ -141,15 +143,17 @@ function _renderContext(index, config) {
141
143
  return `${lines.join('\n').trimEnd()}\n`;
142
144
  }
143
145
 
144
- export function renderCheck(index, config) {
145
- const defaultRenderer = (idx) => _renderCheck(idx);
146
+ export function renderCheck(index, config, opts = {}) {
147
+ const defaultRenderer = (idx) => _renderCheck(idx, opts);
146
148
  if (config.hooks.renderCheck) {
147
- return config.hooks.renderCheck(index, defaultRenderer);
149
+ try { return config.hooks.renderCheck(index, defaultRenderer); }
150
+ catch (err) { warn(`Hook 'renderCheck' threw: ${err.message}`); }
148
151
  }
149
152
  return defaultRenderer(index);
150
153
  }
151
154
 
152
- function _renderCheck(index) {
155
+ function _renderCheck(index, opts = {}) {
156
+ const { errorsOnly } = opts;
153
157
  const lines = ['Check', ''];
154
158
  lines.push(`- docs scanned: ${index.docs.length}`);
155
159
  lines.push(`- errors: ${index.errors.length}`);
@@ -164,7 +168,7 @@ function _renderCheck(index) {
164
168
  lines.push('');
165
169
  }
166
170
 
167
- if (index.warnings.length > 0) {
171
+ if (!errorsOnly && index.warnings.length > 0) {
168
172
  lines.push(yellow('Warnings'));
169
173
  for (const issue of index.warnings) {
170
174
  lines.push(`- ${issue.path}: ${issue.message}`);
@@ -234,7 +238,8 @@ export function renderProgressBar(checklist) {
234
238
  export function formatSnapshot(doc, config) {
235
239
  const defaultFormatter = (d) => _formatSnapshot(d);
236
240
  if (config.hooks.formatSnapshot) {
237
- return config.hooks.formatSnapshot(doc, defaultFormatter);
241
+ try { return config.hooks.formatSnapshot(doc, defaultFormatter); }
242
+ catch (err) { warn(`Hook 'formatSnapshot' threw: ${err.message}`); }
238
243
  }
239
244
  return defaultFormatter(doc);
240
245
  }
package/src/util.mjs CHANGED
@@ -47,6 +47,10 @@ export function mergeUniqueStrings(...lists) {
47
47
  return [...new Set(lists.flat().filter(Boolean))];
48
48
  }
49
49
 
50
+ export function escapeRegex(str) {
51
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
52
+ }
53
+
50
54
  export function toRepoPath(absolutePath, repoRoot) {
51
55
  return path.relative(repoRoot, absolutePath).split(path.sep).join('/');
52
56
  }
@@ -55,9 +59,15 @@ export function warn(message) {
55
59
  process.stderr.write(`${dim(message)}\n`);
56
60
  }
57
61
 
62
+ export class DotmdError extends Error {
63
+ constructor(message) {
64
+ super(message);
65
+ this.name = 'DotmdError';
66
+ }
67
+ }
68
+
58
69
  export function die(message) {
59
- process.stderr.write(`${message}\n`);
60
- process.exitCode = 1;
70
+ throw new DotmdError(message);
61
71
  }
62
72
 
63
73
  export function resolveDocPath(input, config) {