dotmd-cli 0.4.0 → 0.5.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
@@ -282,7 +282,6 @@ async function main() {
282
282
  if (command === 'index') {
283
283
  if (!config.indexPath) {
284
284
  die('Index generation is not configured. Add an `index` section to your dotmd.config.mjs.');
285
- return;
286
285
  }
287
286
  const write = args.includes('--write');
288
287
  const rendered = renderIndexFile(index, config);
@@ -306,5 +305,6 @@ async function main() {
306
305
  }
307
306
 
308
307
  main().catch(err => {
309
- die(err.message);
308
+ process.stderr.write(`${err.message}\n`);
309
+ process.exitCode = 1;
310
310
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",
@@ -90,7 +90,6 @@ export function runCompletions(argv) {
90
90
  const shell = argv[0];
91
91
  if (!shell) {
92
92
  die('Usage: dotmd completions <bash|zsh>');
93
- return;
94
93
  }
95
94
  if (shell === 'bash') {
96
95
  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', [
@@ -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,7 +1,7 @@
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
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
- import { asString, toRepoPath, die, resolveDocPath } from './util.mjs';
4
+ import { asString, toRepoPath, die, warn, resolveDocPath, escapeRegex } from './util.mjs';
5
5
  import { gitMv } from './git.mjs';
6
6
  import { buildIndex, collectDocFiles } from './index.mjs';
7
7
  import { renderIndexFile, writeIndex } from './index-file.mjs';
@@ -12,11 +12,11 @@ export function runStatus(argv, config, 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,7 +110,7 @@ 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
 
@@ -133,10 +134,11 @@ export function runArchive(argv, config, opts = {}) {
133
134
 
134
135
  updateFrontmatter(filePath, { status: 'archived', updated: today });
135
136
 
136
- if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); return; }
137
+ mkdirSync(targetDir, { recursive: true });
138
+ if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
137
139
 
138
140
  const result = gitMv(filePath, targetPath, config.repoRoot);
139
- if (result.status !== 0) { die(result.stderr || 'git mv failed.'); return; }
141
+ if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
140
142
 
141
143
  if (config.indexPath) {
142
144
  const index = buildIndex(config);
@@ -163,17 +165,17 @@ export function runArchive(argv, config, opts = {}) {
163
165
  }
164
166
 
165
167
  process.stdout.write('\nNext: commit, then update references if needed.\n');
166
- config.hooks.onArchive?.({ path: newRepoPath, oldStatus }, { oldPath: oldRepoPath, newPath: newRepoPath });
168
+ try { config.hooks.onArchive?.({ path: newRepoPath, oldStatus }, { oldPath: oldRepoPath, newPath: newRepoPath }); } catch (err) { warn(`Hook 'onArchive' threw: ${err.message}`); }
167
169
  }
168
170
 
169
171
  export function runTouch(argv, config, opts = {}) {
170
172
  const { dryRun } = opts;
171
173
  const input = argv[0];
172
174
 
173
- if (!input) { die('Usage: dotmd touch <file>'); return; }
175
+ if (!input) { die('Usage: dotmd touch <file>'); }
174
176
 
175
177
  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; }
178
+ if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
177
179
 
178
180
  const today = new Date().toISOString().slice(0, 10);
179
181
 
@@ -185,7 +187,7 @@ export function runTouch(argv, config, opts = {}) {
185
187
  updateFrontmatter(filePath, { updated: today });
186
188
  process.stdout.write(`${green('Touched')}: ${toRepoPath(filePath, config.repoRoot)} (updated → ${today})\n`);
187
189
 
188
- config.hooks.onTouch?.({ path: toRepoPath(filePath, config.repoRoot) }, { path: toRepoPath(filePath, config.repoRoot), date: today });
190
+ try { config.hooks.onTouch?.({ path: toRepoPath(filePath, config.repoRoot) }, { path: toRepoPath(filePath, config.repoRoot), date: today }); } catch (err) { warn(`Hook 'onTouch' threw: ${err.message}`); }
189
191
  }
190
192
 
191
193
  export function updateFrontmatter(filePath, updates) {
@@ -199,7 +201,7 @@ export function updateFrontmatter(filePath, updates) {
199
201
  const body = raw.slice(endMarker + 5);
200
202
 
201
203
  for (const [key, value] of Object.entries(updates)) {
202
- const regex = new RegExp(`^${key}:.*$`, 'm');
204
+ const regex = new RegExp(`^${escapeRegex(key)}:.*$`, 'm');
203
205
  if (regex.test(frontmatter)) {
204
206
  frontmatter = frontmatter.replace(regex, `${key}: ${value}`);
205
207
  } 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
  }
@@ -144,7 +146,8 @@ function _renderContext(index, config) {
144
146
  export function renderCheck(index, config) {
145
147
  const defaultRenderer = (idx) => _renderCheck(idx);
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
  }
@@ -234,7 +237,8 @@ export function renderProgressBar(checklist) {
234
237
  export function formatSnapshot(doc, config) {
235
238
  const defaultFormatter = (d) => _formatSnapshot(d);
236
239
  if (config.hooks.formatSnapshot) {
237
- return config.hooks.formatSnapshot(doc, defaultFormatter);
240
+ try { return config.hooks.formatSnapshot(doc, defaultFormatter); }
241
+ catch (err) { warn(`Hook 'formatSnapshot' threw: ${err.message}`); }
238
242
  }
239
243
  return defaultFormatter(doc);
240
244
  }
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) {