dotmd-cli 0.1.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/LICENSE +21 -0
- package/README.md +187 -0
- package/bin/dotmd.mjs +202 -0
- package/dotmd.config.example.mjs +116 -0
- package/package.json +37 -0
- package/src/color.mjs +10 -0
- package/src/config.mjs +197 -0
- package/src/extractors.mjs +60 -0
- package/src/frontmatter.mjs +55 -0
- package/src/git.mjs +18 -0
- package/src/index-file.mjs +104 -0
- package/src/index.mjs +151 -0
- package/src/init.mjs +60 -0
- package/src/lifecycle.mjs +224 -0
- package/src/query.mjs +178 -0
- package/src/render.mjs +248 -0
- package/src/util.mjs +55 -0
- package/src/validate.mjs +157 -0
package/src/config.mjs
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const CONFIG_FILENAMES = ['dotmd.config.mjs', '.dotmd.config.mjs', 'dotmd.config.js'];
|
|
6
|
+
|
|
7
|
+
const DEFAULTS = {
|
|
8
|
+
root: '.',
|
|
9
|
+
archiveDir: 'archived',
|
|
10
|
+
excludeDirs: [],
|
|
11
|
+
|
|
12
|
+
statuses: {
|
|
13
|
+
order: ['active', 'ready', 'planned', 'research', 'blocked', 'reference', 'archived'],
|
|
14
|
+
staleDays: {
|
|
15
|
+
active: 14,
|
|
16
|
+
ready: 14,
|
|
17
|
+
planned: 30,
|
|
18
|
+
blocked: 30,
|
|
19
|
+
research: 30,
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
lifecycle: {
|
|
24
|
+
archiveStatuses: ['archived'],
|
|
25
|
+
skipStaleFor: ['archived', 'reference'],
|
|
26
|
+
skipWarningsFor: ['archived'],
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
taxonomy: {
|
|
30
|
+
surfaces: null,
|
|
31
|
+
moduleRequiredFor: [],
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
index: null,
|
|
35
|
+
|
|
36
|
+
context: {
|
|
37
|
+
expanded: ['active'],
|
|
38
|
+
listed: ['ready', 'planned'],
|
|
39
|
+
counted: ['blocked', 'research', 'reference', 'archived'],
|
|
40
|
+
recentDays: 3,
|
|
41
|
+
recentStatuses: ['active', 'ready', 'planned'],
|
|
42
|
+
recentLimit: 10,
|
|
43
|
+
truncateNextStep: 80,
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
display: {
|
|
47
|
+
lineWidth: 0,
|
|
48
|
+
truncateTitle: 30,
|
|
49
|
+
truncateNextStep: 80,
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
referenceFields: {
|
|
53
|
+
bidirectional: [],
|
|
54
|
+
unidirectional: [],
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
presets: {
|
|
58
|
+
stale: ['--status', 'active,ready,planned,blocked,research', '--stale', '--sort', 'updated', '--all'],
|
|
59
|
+
actionable: ['--status', 'active,ready', '--has-next-step', '--sort', 'updated', '--all'],
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function findConfigFile(startDir) {
|
|
64
|
+
let dir = path.resolve(startDir);
|
|
65
|
+
const root = path.parse(dir).root;
|
|
66
|
+
|
|
67
|
+
while (dir !== root) {
|
|
68
|
+
for (const filename of CONFIG_FILENAMES) {
|
|
69
|
+
const candidate = path.join(dir, filename);
|
|
70
|
+
if (existsSync(candidate)) return candidate;
|
|
71
|
+
}
|
|
72
|
+
dir = path.dirname(dir);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function deepMerge(defaults, overrides) {
|
|
79
|
+
const result = { ...defaults };
|
|
80
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
81
|
+
if (value != null && typeof value === 'object' && !Array.isArray(value) &&
|
|
82
|
+
result[key] != null && typeof result[key] === 'object' && !Array.isArray(result[key])) {
|
|
83
|
+
result[key] = deepMerge(result[key], value);
|
|
84
|
+
} else {
|
|
85
|
+
result[key] = value;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function resolveConfig(cwd, explicitConfigPath) {
|
|
92
|
+
const configPath = explicitConfigPath
|
|
93
|
+
? path.resolve(cwd, explicitConfigPath)
|
|
94
|
+
: findConfigFile(cwd);
|
|
95
|
+
|
|
96
|
+
let userConfig = {};
|
|
97
|
+
let hooks = {};
|
|
98
|
+
let configDir = cwd;
|
|
99
|
+
|
|
100
|
+
if (configPath && existsSync(configPath)) {
|
|
101
|
+
const configUrl = pathToFileURL(configPath).href;
|
|
102
|
+
const mod = await import(configUrl);
|
|
103
|
+
|
|
104
|
+
configDir = path.dirname(configPath);
|
|
105
|
+
|
|
106
|
+
for (const [key, value] of Object.entries(mod)) {
|
|
107
|
+
if (key === 'default') continue;
|
|
108
|
+
if (typeof value === 'function') {
|
|
109
|
+
hooks[key] = value;
|
|
110
|
+
} else {
|
|
111
|
+
userConfig[key] = value;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Backwards compat: `readme` config key maps to `index`
|
|
117
|
+
if (userConfig.readme && !userConfig.index) {
|
|
118
|
+
userConfig.index = userConfig.readme;
|
|
119
|
+
delete userConfig.readme;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const config = deepMerge(DEFAULTS, userConfig);
|
|
123
|
+
|
|
124
|
+
const docsRoot = path.resolve(configDir, config.root);
|
|
125
|
+
|
|
126
|
+
// Find repo root by walking up looking for .git
|
|
127
|
+
let repoRoot = configDir;
|
|
128
|
+
{
|
|
129
|
+
let dir = configDir;
|
|
130
|
+
const fsRoot = path.parse(dir).root;
|
|
131
|
+
while (dir !== fsRoot) {
|
|
132
|
+
if (existsSync(path.join(dir, '.git'))) {
|
|
133
|
+
repoRoot = dir;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
dir = path.dirname(dir);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const statusOrder = config.statuses.order;
|
|
141
|
+
const validStatuses = new Set(statusOrder);
|
|
142
|
+
const staleDaysByStatus = {};
|
|
143
|
+
for (const status of statusOrder) {
|
|
144
|
+
staleDaysByStatus[status] = config.statuses.staleDays?.[status] ?? null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const validSurfaces = config.taxonomy.surfaces
|
|
148
|
+
? new Set(config.taxonomy.surfaces)
|
|
149
|
+
: null;
|
|
150
|
+
const moduleRequiredStatuses = new Set(config.taxonomy.moduleRequiredFor);
|
|
151
|
+
|
|
152
|
+
const indexPath = config.index?.path
|
|
153
|
+
? path.resolve(repoRoot, config.index.path)
|
|
154
|
+
: null;
|
|
155
|
+
|
|
156
|
+
// Compute docs root relative path for index link stripping
|
|
157
|
+
const docsRootRelative = path.relative(repoRoot, docsRoot).split(path.sep).join('/');
|
|
158
|
+
const docsRootPrefix = docsRootRelative ? docsRootRelative + '/' : '';
|
|
159
|
+
|
|
160
|
+
// Lifecycle config
|
|
161
|
+
const lifecycle = config.lifecycle;
|
|
162
|
+
const archiveStatuses = new Set(lifecycle.archiveStatuses);
|
|
163
|
+
const skipStaleFor = new Set(lifecycle.skipStaleFor);
|
|
164
|
+
const skipWarningsFor = new Set(lifecycle.skipWarningsFor);
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
raw: config,
|
|
168
|
+
|
|
169
|
+
docsRoot,
|
|
170
|
+
repoRoot,
|
|
171
|
+
configDir,
|
|
172
|
+
configPath: configPath ?? null,
|
|
173
|
+
archiveDir: config.archiveDir,
|
|
174
|
+
excludeDirs: new Set(config.excludeDirs),
|
|
175
|
+
docsRootPrefix,
|
|
176
|
+
|
|
177
|
+
statusOrder,
|
|
178
|
+
validStatuses,
|
|
179
|
+
staleDaysByStatus,
|
|
180
|
+
|
|
181
|
+
lifecycle: { archiveStatuses, skipStaleFor, skipWarningsFor },
|
|
182
|
+
|
|
183
|
+
validSurfaces,
|
|
184
|
+
moduleRequiredStatuses,
|
|
185
|
+
|
|
186
|
+
indexPath,
|
|
187
|
+
indexStartMarker: config.index?.startMarker ?? '<!-- GENERATED:dotmd:start -->',
|
|
188
|
+
indexEndMarker: config.index?.endMarker ?? '<!-- GENERATED:dotmd:end -->',
|
|
189
|
+
archivedHighlightLimit: config.index?.archivedLimit ?? 8,
|
|
190
|
+
|
|
191
|
+
context: config.context,
|
|
192
|
+
display: config.display,
|
|
193
|
+
referenceFields: config.referenceFields,
|
|
194
|
+
presets: config.presets,
|
|
195
|
+
hooks,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export function extractFirstHeading(body) {
|
|
2
|
+
return body.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? null;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function extractSummary(body) {
|
|
6
|
+
const blockquoteLines = body
|
|
7
|
+
.split('\n')
|
|
8
|
+
.filter(line => line.startsWith('> '))
|
|
9
|
+
.map(line => line.slice(2).trim())
|
|
10
|
+
.filter(Boolean);
|
|
11
|
+
|
|
12
|
+
const nonStatusLine = blockquoteLines.find(line => !/^Status note\b/i.test(line));
|
|
13
|
+
return nonStatusLine ?? blockquoteLines[0] ?? null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function extractStatusSnapshot(body) {
|
|
17
|
+
const statusNoteMatch = body.match(/^>\s+Status note(?:\s+\([^)]+\))?:\s*(.+)$/m);
|
|
18
|
+
if (statusNoteMatch) return statusNoteMatch[1].trim();
|
|
19
|
+
|
|
20
|
+
const boldStatusMatch = body.match(/^\*\*Status:\*\*\s*(.+)$/m);
|
|
21
|
+
if (boldStatusMatch) return boldStatusMatch[1].trim();
|
|
22
|
+
|
|
23
|
+
const plainStatusMatch = body.match(/^-\s+Status:\s*(.+)$/m);
|
|
24
|
+
if (plainStatusMatch) return plainStatusMatch[1].trim();
|
|
25
|
+
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function extractNextStep(body) {
|
|
30
|
+
const match = body.match(/^##+\s+(?:Suggested\s+)?Next Step\s*$([\s\S]*?)(?=^##+\s|\Z)/m);
|
|
31
|
+
if (!match) return null;
|
|
32
|
+
|
|
33
|
+
const lines = match[1]
|
|
34
|
+
.split('\n')
|
|
35
|
+
.map(line => line.trim())
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
.map(line => line.replace(/^[-*]\s+/, ''));
|
|
38
|
+
|
|
39
|
+
return lines[0] ?? null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function extractChecklistCounts(body) {
|
|
43
|
+
const matches = [...body.matchAll(/^\s*[-*]\s+\[([ xX])\]\s+/gm)];
|
|
44
|
+
let completed = 0;
|
|
45
|
+
let open = 0;
|
|
46
|
+
|
|
47
|
+
for (const match of matches) {
|
|
48
|
+
if (match[1].toLowerCase() === 'x') {
|
|
49
|
+
completed += 1;
|
|
50
|
+
} else {
|
|
51
|
+
open += 1;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
completed,
|
|
57
|
+
open,
|
|
58
|
+
total: completed + open,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export function extractFrontmatter(raw) {
|
|
2
|
+
if (!raw.startsWith('---\n')) {
|
|
3
|
+
return { frontmatter: '', body: raw };
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const endMarker = raw.indexOf('\n---\n', 4);
|
|
7
|
+
if (endMarker === -1) {
|
|
8
|
+
return { frontmatter: '', body: raw };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
frontmatter: raw.slice(4, endMarker),
|
|
13
|
+
body: raw.slice(endMarker + 5),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function parseSimpleFrontmatter(text) {
|
|
18
|
+
const data = {};
|
|
19
|
+
let currentArrayKey = null;
|
|
20
|
+
|
|
21
|
+
for (const rawLine of text.split('\n')) {
|
|
22
|
+
const line = rawLine.replace(/\r$/, '');
|
|
23
|
+
if (!line.trim()) continue;
|
|
24
|
+
|
|
25
|
+
const keyMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
26
|
+
if (keyMatch) {
|
|
27
|
+
const [, key, rawValue] = keyMatch;
|
|
28
|
+
if (!rawValue.trim()) {
|
|
29
|
+
data[key] = [];
|
|
30
|
+
currentArrayKey = key;
|
|
31
|
+
} else {
|
|
32
|
+
data[key] = parseScalar(rawValue.trim());
|
|
33
|
+
currentArrayKey = null;
|
|
34
|
+
}
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (currentArrayKey) {
|
|
39
|
+
const itemMatch = line.match(/^\s*-\s+(.*)$/);
|
|
40
|
+
if (itemMatch) {
|
|
41
|
+
data[currentArrayKey].push(parseScalar(itemMatch[1].trim()));
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return data;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseScalar(value) {
|
|
51
|
+
const unquoted = value.replace(/^['"]|['"]$/g, '');
|
|
52
|
+
if (unquoted === 'true') return true;
|
|
53
|
+
if (unquoted === 'false') return false;
|
|
54
|
+
return unquoted;
|
|
55
|
+
}
|
package/src/git.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
export function getGitLastModified(relPath, repoRoot) {
|
|
4
|
+
const result = spawnSync('git', ['log', '-1', '--format=%aI', '--', relPath], {
|
|
5
|
+
cwd: repoRoot,
|
|
6
|
+
encoding: 'utf8',
|
|
7
|
+
});
|
|
8
|
+
if (result.status !== 0 || !result.stdout.trim()) return null;
|
|
9
|
+
return result.stdout.trim();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function gitMv(source, target, repoRoot) {
|
|
13
|
+
const result = spawnSync('git', ['mv', source, target], {
|
|
14
|
+
cwd: repoRoot,
|
|
15
|
+
encoding: 'utf8',
|
|
16
|
+
});
|
|
17
|
+
return { status: result.status, stderr: result.stderr };
|
|
18
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { capitalize, escapeTable } from './util.mjs';
|
|
3
|
+
import { formatSnapshot } from './render.mjs';
|
|
4
|
+
|
|
5
|
+
export function renderIndexFile(index, config) {
|
|
6
|
+
const current = readFileSync(config.indexPath, 'utf8');
|
|
7
|
+
const start = current.indexOf(config.indexStartMarker);
|
|
8
|
+
const end = current.indexOf(config.indexEndMarker);
|
|
9
|
+
|
|
10
|
+
if (start === -1 || end === -1 || end < start) {
|
|
11
|
+
throw new Error(`${config.indexPath} is missing generated block markers.`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const before = current.slice(0, start + config.indexStartMarker.length);
|
|
15
|
+
const after = current.slice(end);
|
|
16
|
+
const generated = `\n\n${renderGeneratedBlock(index, config)}\n`;
|
|
17
|
+
return `${before}${generated}${after}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function renderGeneratedBlock(index, config) {
|
|
21
|
+
const lines = [];
|
|
22
|
+
const prefix = config.docsRootPrefix;
|
|
23
|
+
|
|
24
|
+
for (const status of config.statusOrder) {
|
|
25
|
+
const docs = index.docs.filter(doc => doc.status === status);
|
|
26
|
+
if (docs.length === 0) continue;
|
|
27
|
+
|
|
28
|
+
if (config.lifecycle.archiveStatuses.has(status)) {
|
|
29
|
+
lines.push(...renderArchivedSection(docs, config, status));
|
|
30
|
+
lines.push('');
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
lines.push(`## ${capitalize(status)}`);
|
|
35
|
+
lines.push('');
|
|
36
|
+
lines.push('| Doc | Status Snapshot |');
|
|
37
|
+
lines.push('|-----|-----------------|');
|
|
38
|
+
for (const doc of docs) {
|
|
39
|
+
const snapshot = formatSnapshot(doc, config);
|
|
40
|
+
const linkPath = prefix ? doc.path.replace(prefix, '') : doc.path;
|
|
41
|
+
lines.push(`| [${escapeTable(doc.title)}](${linkPath}) | ${escapeTable(snapshot)} |`);
|
|
42
|
+
}
|
|
43
|
+
lines.push('');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return lines.join('\n').trimEnd();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function renderArchivedSection(docs, config, status) {
|
|
50
|
+
const lines = [];
|
|
51
|
+
const limit = config.archivedHighlightLimit;
|
|
52
|
+
const prefix = config.docsRootPrefix;
|
|
53
|
+
const highlights = docs
|
|
54
|
+
.filter(doc => doc.currentState && doc.currentState !== 'No current_state set')
|
|
55
|
+
.sort((a, b) => {
|
|
56
|
+
const aUpdated = a.updated ?? '';
|
|
57
|
+
const bUpdated = b.updated ?? '';
|
|
58
|
+
return bUpdated.localeCompare(aUpdated);
|
|
59
|
+
})
|
|
60
|
+
.slice(0, limit);
|
|
61
|
+
|
|
62
|
+
lines.push(`## ${capitalize(status)}`);
|
|
63
|
+
lines.push('');
|
|
64
|
+
lines.push(`${capitalize(status)} docs are indexed by the CLI/JSON output. Showing ${highlights.length} recent or high-signal highlights out of ${docs.length} ${status} docs:`);
|
|
65
|
+
lines.push('');
|
|
66
|
+
lines.push('| Doc | Status Snapshot |');
|
|
67
|
+
lines.push('|-----|-----------------|');
|
|
68
|
+
for (const doc of highlights) {
|
|
69
|
+
const linkPath = prefix ? doc.path.replace(prefix, '') : doc.path;
|
|
70
|
+
lines.push(`| [${escapeTable(doc.title)}](${linkPath}) | ${escapeTable(formatSnapshot(doc, config))} |`);
|
|
71
|
+
}
|
|
72
|
+
lines.push('');
|
|
73
|
+
lines.push('- Use `dotmd list` or `dotmd json` for the full inventory.');
|
|
74
|
+
|
|
75
|
+
return lines;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function writeIndex(content, config) {
|
|
79
|
+
writeFileSync(config.indexPath, content, 'utf8');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function checkIndex(docs, config) {
|
|
83
|
+
const warnings = [];
|
|
84
|
+
const errors = [];
|
|
85
|
+
|
|
86
|
+
if (!config.indexPath) return { warnings, errors };
|
|
87
|
+
|
|
88
|
+
const current = readFileSync(config.indexPath, 'utf8');
|
|
89
|
+
const start = current.indexOf(config.indexStartMarker);
|
|
90
|
+
const end = current.indexOf(config.indexEndMarker);
|
|
91
|
+
|
|
92
|
+
if (start === -1 || end === -1 || end < start) {
|
|
93
|
+
errors.push({ path: config.indexPath, level: 'error', message: 'Missing generated index block markers.' });
|
|
94
|
+
return { warnings, errors };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const index = { docs };
|
|
98
|
+
const expected = renderIndexFile(index, config);
|
|
99
|
+
if (expected !== current) {
|
|
100
|
+
errors.push({ path: config.indexPath, level: 'error', message: 'Generated index block is stale. Run `dotmd index`.' });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { warnings, errors };
|
|
104
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts } from './extractors.mjs';
|
|
5
|
+
import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath } from './util.mjs';
|
|
6
|
+
import { validateDoc, checkBidirectionalReferences, checkGitStaleness, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate } from './validate.mjs';
|
|
7
|
+
import { checkIndex } from './index-file.mjs';
|
|
8
|
+
|
|
9
|
+
export function buildIndex(config) {
|
|
10
|
+
const docs = collectDocFiles(config).map(f => parseDocFile(f, config));
|
|
11
|
+
const warnings = [];
|
|
12
|
+
const errors = [];
|
|
13
|
+
|
|
14
|
+
for (const doc of docs) {
|
|
15
|
+
warnings.push(...doc.warnings);
|
|
16
|
+
errors.push(...doc.errors);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (config.hooks.validate) {
|
|
20
|
+
const ctx = { config, allDocs: docs, repoRoot: config.repoRoot };
|
|
21
|
+
for (const doc of docs) {
|
|
22
|
+
const result = config.hooks.validate(doc, ctx);
|
|
23
|
+
if (result?.errors) {
|
|
24
|
+
doc.errors.push(...result.errors);
|
|
25
|
+
errors.push(...result.errors);
|
|
26
|
+
}
|
|
27
|
+
if (result?.warnings) {
|
|
28
|
+
doc.warnings.push(...result.warnings);
|
|
29
|
+
warnings.push(...result.warnings);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const transformedDocs = config.hooks.transformDoc
|
|
35
|
+
? docs.map(d => config.hooks.transformDoc(d) ?? d)
|
|
36
|
+
: docs;
|
|
37
|
+
|
|
38
|
+
const countsByStatus = Object.fromEntries(config.statusOrder.map(status => [
|
|
39
|
+
status,
|
|
40
|
+
transformedDocs.filter(doc => doc.status === status).length,
|
|
41
|
+
]));
|
|
42
|
+
|
|
43
|
+
if (config.indexPath) {
|
|
44
|
+
const indexCheck = checkIndex(transformedDocs, config);
|
|
45
|
+
warnings.push(...indexCheck.warnings);
|
|
46
|
+
errors.push(...indexCheck.errors);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const refCheck = checkBidirectionalReferences(transformedDocs, config);
|
|
50
|
+
warnings.push(...refCheck.warnings);
|
|
51
|
+
|
|
52
|
+
const gitWarnings = checkGitStaleness(transformedDocs, config);
|
|
53
|
+
warnings.push(...gitWarnings);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
generatedAt: new Date().toISOString(),
|
|
57
|
+
docs: transformedDocs,
|
|
58
|
+
countsByStatus,
|
|
59
|
+
warnings,
|
|
60
|
+
errors,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function collectDocFiles(config) {
|
|
65
|
+
const files = [];
|
|
66
|
+
const skipPaths = new Set();
|
|
67
|
+
if (config.indexPath) skipPaths.add(config.indexPath);
|
|
68
|
+
walkMarkdownFiles(config.docsRoot, files, config.excludeDirs, skipPaths);
|
|
69
|
+
return files.sort((a, b) => a.localeCompare(b));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function walkMarkdownFiles(directory, files, excludedDirs, skipPaths) {
|
|
73
|
+
let entries;
|
|
74
|
+
try {
|
|
75
|
+
entries = readdirSync(directory, { withFileTypes: true });
|
|
76
|
+
} catch {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
if (entry.isDirectory()) {
|
|
81
|
+
if (excludedDirs && excludedDirs.has(entry.name)) continue;
|
|
82
|
+
walkMarkdownFiles(path.join(directory, entry.name), files, excludedDirs, skipPaths);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const fullPath = path.join(directory, entry.name);
|
|
86
|
+
if (!entry.isFile() || !entry.name.endsWith('.md') || skipPaths.has(fullPath)) continue;
|
|
87
|
+
files.push(fullPath);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function parseDocFile(filePath, config) {
|
|
92
|
+
const relativePath = toRepoPath(filePath, config.repoRoot);
|
|
93
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
94
|
+
const { frontmatter, body } = extractFrontmatter(raw);
|
|
95
|
+
const parsedFrontmatter = parseSimpleFrontmatter(frontmatter);
|
|
96
|
+
const headingTitle = extractFirstHeading(body);
|
|
97
|
+
const title = asString(parsedFrontmatter.title) ?? headingTitle ?? path.basename(filePath, '.md');
|
|
98
|
+
const summary = asString(parsedFrontmatter.summary) ?? extractSummary(body) ?? null;
|
|
99
|
+
const currentState = asString(parsedFrontmatter.current_state) ?? extractStatusSnapshot(body) ?? 'No current_state set';
|
|
100
|
+
const nextStep = asString(parsedFrontmatter.next_step) ?? extractNextStep(body) ?? null;
|
|
101
|
+
const blockers = normalizeBlockers(parsedFrontmatter.blockers);
|
|
102
|
+
const surface = asString(parsedFrontmatter.surface) ?? null;
|
|
103
|
+
const surfaces = normalizeStringList(parsedFrontmatter.surfaces);
|
|
104
|
+
const moduleName = asString(parsedFrontmatter.module) ?? null;
|
|
105
|
+
const modules = normalizeStringList(parsedFrontmatter.modules);
|
|
106
|
+
const domain = asString(parsedFrontmatter.domain) ?? null;
|
|
107
|
+
const audience = asString(parsedFrontmatter.audience) ?? null;
|
|
108
|
+
const executionMode = asString(parsedFrontmatter.execution_mode) ?? null;
|
|
109
|
+
const checklist = extractChecklistCounts(body);
|
|
110
|
+
|
|
111
|
+
// Dynamic reference field extraction
|
|
112
|
+
const refFields = {};
|
|
113
|
+
for (const field of [...(config.referenceFields.bidirectional || []), ...(config.referenceFields.unidirectional || [])]) {
|
|
114
|
+
refFields[field] = normalizeStringList(parsedFrontmatter[field]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const doc = {
|
|
118
|
+
path: relativePath,
|
|
119
|
+
status: asString(parsedFrontmatter.status) ?? null,
|
|
120
|
+
owner: asString(parsedFrontmatter.owner) ?? null,
|
|
121
|
+
surface,
|
|
122
|
+
surfaces: mergeUniqueStrings(surface ? [surface] : [], surfaces),
|
|
123
|
+
module: moduleName,
|
|
124
|
+
modules: mergeUniqueStrings(moduleName ? [moduleName] : [], modules),
|
|
125
|
+
domain,
|
|
126
|
+
audience,
|
|
127
|
+
executionMode,
|
|
128
|
+
title,
|
|
129
|
+
summary,
|
|
130
|
+
currentState,
|
|
131
|
+
nextStep,
|
|
132
|
+
blockers,
|
|
133
|
+
updated: asString(parsedFrontmatter.updated) ?? null,
|
|
134
|
+
created: asString(parsedFrontmatter.created) ?? null,
|
|
135
|
+
audited: asString(parsedFrontmatter.audited) ?? null,
|
|
136
|
+
auditLevel: asString(parsedFrontmatter.audit_level) ?? null,
|
|
137
|
+
sourceOfTruth: asString(parsedFrontmatter.source_of_truth) ?? null,
|
|
138
|
+
checklist,
|
|
139
|
+
refFields,
|
|
140
|
+
checklistCompletionRate: computeChecklistCompletionRate(checklist),
|
|
141
|
+
hasNextStep: Boolean(nextStep),
|
|
142
|
+
hasBlockers: blockers.length > 0,
|
|
143
|
+
daysSinceUpdate: computeDaysSinceUpdate(asString(parsedFrontmatter.updated) ?? null),
|
|
144
|
+
isStale: computeIsStale(asString(parsedFrontmatter.status), asString(parsedFrontmatter.updated) ?? null, config),
|
|
145
|
+
warnings: [],
|
|
146
|
+
errors: [],
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
validateDoc(doc, parsedFrontmatter, headingTitle, config);
|
|
150
|
+
return doc;
|
|
151
|
+
}
|
package/src/init.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { green, dim } from './color.mjs';
|
|
4
|
+
|
|
5
|
+
const STARTER_CONFIG = `// dotmd.config.mjs — document management configuration
|
|
6
|
+
// All exports are optional. See dotmd.config.example.mjs for full reference.
|
|
7
|
+
|
|
8
|
+
export const root = 'docs';
|
|
9
|
+
|
|
10
|
+
export const index = {
|
|
11
|
+
path: 'docs/docs.md',
|
|
12
|
+
startMarker: '<!-- GENERATED:dotmd:start -->',
|
|
13
|
+
endMarker: '<!-- GENERATED:dotmd:end -->',
|
|
14
|
+
archivedLimit: 8,
|
|
15
|
+
};
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
const STARTER_INDEX = `# Docs
|
|
19
|
+
|
|
20
|
+
<!-- GENERATED:dotmd:start -->
|
|
21
|
+
|
|
22
|
+
_No docs yet. Run \`dotmd list\` after creating your first document._
|
|
23
|
+
|
|
24
|
+
<!-- GENERATED:dotmd:end -->
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
export function runInit(cwd) {
|
|
28
|
+
const configPath = path.join(cwd, 'dotmd.config.mjs');
|
|
29
|
+
const docsDir = path.join(cwd, 'docs');
|
|
30
|
+
const indexPath = path.join(docsDir, 'docs.md');
|
|
31
|
+
|
|
32
|
+
process.stdout.write('\n');
|
|
33
|
+
|
|
34
|
+
if (existsSync(configPath)) {
|
|
35
|
+
process.stdout.write(` ${dim('exists')} dotmd.config.mjs\n`);
|
|
36
|
+
} else {
|
|
37
|
+
writeFileSync(configPath, STARTER_CONFIG, 'utf8');
|
|
38
|
+
process.stdout.write(` ${green('create')} dotmd.config.mjs\n`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (existsSync(docsDir)) {
|
|
42
|
+
process.stdout.write(` ${dim('exists')} docs/\n`);
|
|
43
|
+
} else {
|
|
44
|
+
mkdirSync(docsDir, { recursive: true });
|
|
45
|
+
process.stdout.write(` ${green('create')} docs/\n`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (existsSync(indexPath)) {
|
|
49
|
+
process.stdout.write(` ${dim('exists')} docs/docs.md\n`);
|
|
50
|
+
} else {
|
|
51
|
+
writeFileSync(indexPath, STARTER_INDEX, 'utf8');
|
|
52
|
+
process.stdout.write(` ${green('create')} docs/docs.md\n`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
56
|
+
process.stdout.write(`\nReady. Create your first doc:\n`);
|
|
57
|
+
process.stdout.write(` printf '---\\nstatus: active\\nupdated: ${today}\\n---\\n\\n# My Doc\\n' > docs/my-doc.md\n`);
|
|
58
|
+
process.stdout.write(` dotmd list\n\n`);
|
|
59
|
+
}
|
|
60
|
+
|