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