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 +2 -2
- package/package.json +1 -1
- package/src/completions.mjs +0 -1
- package/src/config.mjs +12 -18
- package/src/diff.mjs +15 -5
- package/src/frontmatter.mjs +18 -1
- package/src/git.mjs +13 -1
- package/src/index.mjs +22 -10
- package/src/lifecycle.mjs +24 -22
- 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 +9 -5
- package/src/util.mjs +12 -2
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
|
-
|
|
308
|
+
process.stderr.write(`${err.message}\n`);
|
|
309
|
+
process.exitCode = 1;
|
|
310
310
|
});
|
package/package.json
CHANGED
package/src/completions.mjs
CHANGED
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/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,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>');
|
|
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,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)}`);
|
|
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
|
-
|
|
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.');
|
|
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>');
|
|
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)}`);
|
|
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
|
|
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
|
}
|
|
@@ -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
|
-
|
|
60
|
-
process.exitCode = 1;
|
|
70
|
+
throw new DotmdError(message);
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
export function resolveDocPath(input, config) {
|