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 +55 -7
- package/dotmd.config.example.mjs +1 -1
- package/package.json +1 -1
- package/src/completions.mjs +4 -2
- package/src/config.mjs +12 -18
- package/src/diff.mjs +15 -5
- package/src/fix-refs.mjs +113 -0
- package/src/frontmatter.mjs +18 -1
- package/src/git.mjs +13 -1
- package/src/index.mjs +22 -10
- package/src/lifecycle.mjs +151 -57
- package/src/lint.mjs +5 -8
- package/src/migrate.mjs +0 -1
- package/src/new.mjs +4 -6
- package/src/rename.mjs +3 -6
- package/src/render.mjs +14 -9
- package/src/util.mjs +12 -2
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
|
|
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 +
|
|
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,
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
356
|
+
process.stderr.write(`${err.message}\n`);
|
|
357
|
+
process.exitCode = 1;
|
|
310
358
|
});
|
package/dotmd.config.example.mjs
CHANGED
|
@@ -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', '
|
|
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
package/src/completions.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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', [
|
package/src/fix-refs.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/frontmatter.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 =>
|
|
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>');
|
|
16
|
-
if (!config.validStatuses.has(newStatus)) { die(`Invalid status: ${newStatus}\nValid: ${[...config.validStatuses].join(', ')}`);
|
|
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)}`);
|
|
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)}`);
|
|
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.');
|
|
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)}`);
|
|
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.');
|
|
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>');
|
|
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)}`);
|
|
96
|
-
if (filePath.includes(`/${config.archiveDir}/`)) { die(`Already archived: ${toRepoPath(filePath, config.repoRoot)}`);
|
|
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)}`);
|
|
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
|
-
//
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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.');
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)}`);
|
|
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
|
|
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
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>]');
|
|
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);
|
|
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
|
|
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
|
-
|
|
60
|
-
process.exitCode = 1;
|
|
70
|
+
throw new DotmdError(message);
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
export function resolveDocPath(input, config) {
|