stego-cli 0.1.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/.cspell.json +14 -0
- package/.gitignore +8 -0
- package/.markdownlint.json +5 -0
- package/.vscode/tasks.json +72 -0
- package/README.md +179 -0
- package/dist/exporters/exporter-types.js +1 -0
- package/dist/exporters/markdown-exporter.js +14 -0
- package/dist/exporters/pandoc-exporter.js +65 -0
- package/dist/stego-cli.js +1803 -0
- package/docs/conventions.md +182 -0
- package/docs/workflow.md +78 -0
- package/package.json +50 -0
- package/projects/docs-demo/README.md +20 -0
- package/projects/docs-demo/dist/.gitkeep +0 -0
- package/projects/docs-demo/manuscript/100-what-stego-is.md +37 -0
- package/projects/docs-demo/manuscript/200-writing-workflow.md +69 -0
- package/projects/docs-demo/manuscript/300-quality-gates.md +36 -0
- package/projects/docs-demo/manuscript/400-build-and-export.md +42 -0
- package/projects/docs-demo/notes/implementation-notes.md +17 -0
- package/projects/docs-demo/notes/style-guide.md +7 -0
- package/projects/docs-demo/package.json +10 -0
- package/projects/docs-demo/stego-project.json +9 -0
- package/projects/plague-demo/.markdownlint.json +4 -0
- package/projects/plague-demo/README.md +19 -0
- package/projects/plague-demo/dist/.gitkeep +0 -0
- package/projects/plague-demo/manuscript/100-the-commission.md +24 -0
- package/projects/plague-demo/manuscript/200-at-the-wards.md +38 -0
- package/projects/plague-demo/manuscript/300-the-hearing.md +38 -0
- package/projects/plague-demo/manuscript/400-the-final-account.md +30 -0
- package/projects/plague-demo/notes/style-guide.md +7 -0
- package/projects/plague-demo/package.json +10 -0
- package/projects/plague-demo/spine/characters.md +31 -0
- package/projects/plague-demo/spine/locations.md +33 -0
- package/projects/plague-demo/spine/sources.md +28 -0
- package/projects/plague-demo/stego-project.json +41 -0
- package/stego.config.json +56 -0
|
@@ -0,0 +1,1803 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { markdownExporter } from "./exporters/markdown-exporter.js";
|
|
9
|
+
import { createPandocExporter } from "./exporters/pandoc-exporter.js";
|
|
10
|
+
const STATUS_RANK = {
|
|
11
|
+
draft: 0,
|
|
12
|
+
revise: 1,
|
|
13
|
+
"line-edit": 2,
|
|
14
|
+
proof: 3,
|
|
15
|
+
final: 4
|
|
16
|
+
};
|
|
17
|
+
const RESERVED_COMMENT_PREFIX = "CMT";
|
|
18
|
+
const ROOT_CONFIG_FILENAME = "stego.config.json";
|
|
19
|
+
const PROJECT_EXTENSION_RECOMMENDATIONS = [
|
|
20
|
+
"matt-gold.stego-extension",
|
|
21
|
+
"matt-gold.saurus-extension"
|
|
22
|
+
];
|
|
23
|
+
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const packageRoot = path.resolve(scriptDir, "..");
|
|
25
|
+
let repoRoot = "";
|
|
26
|
+
let config;
|
|
27
|
+
main();
|
|
28
|
+
function main() {
|
|
29
|
+
const { command, options } = parseArgs(process.argv.slice(2));
|
|
30
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
31
|
+
printUsage();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
switch (command) {
|
|
36
|
+
case "init":
|
|
37
|
+
initWorkspace({ force: readBooleanOption(options, "force") });
|
|
38
|
+
return;
|
|
39
|
+
case "list-projects":
|
|
40
|
+
activateWorkspace(options);
|
|
41
|
+
listProjects();
|
|
42
|
+
return;
|
|
43
|
+
case "new-project":
|
|
44
|
+
activateWorkspace(options);
|
|
45
|
+
createProject(readStringOption(options, "project"), readStringOption(options, "title"));
|
|
46
|
+
return;
|
|
47
|
+
case "validate": {
|
|
48
|
+
activateWorkspace(options);
|
|
49
|
+
const project = resolveProject(readStringOption(options, "project"));
|
|
50
|
+
const report = inspectProject(project, config, { onlyFile: readStringOption(options, "file") });
|
|
51
|
+
printReport(report.issues);
|
|
52
|
+
exitIfErrors(report.issues);
|
|
53
|
+
if (report.chapters.length === 1) {
|
|
54
|
+
logLine(`Validation passed for '${report.chapters[0].relativePath}'.`);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
logLine(`Validation passed for '${project.id}'.`);
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
case "build": {
|
|
62
|
+
activateWorkspace(options);
|
|
63
|
+
const project = resolveProject(readStringOption(options, "project"));
|
|
64
|
+
const report = inspectProject(project, config);
|
|
65
|
+
printReport(report.issues);
|
|
66
|
+
exitIfErrors(report.issues);
|
|
67
|
+
const outputPath = buildManuscript(project, report.chapters, report.compileStructureLevels);
|
|
68
|
+
logLine(`Build output: ${outputPath}`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
case "check-stage": {
|
|
72
|
+
activateWorkspace(options);
|
|
73
|
+
const project = resolveProject(readStringOption(options, "project"));
|
|
74
|
+
const stage = readStringOption(options, "stage") || "draft";
|
|
75
|
+
const requestedFile = readStringOption(options, "file");
|
|
76
|
+
const report = runStageCheck(project, config, stage, requestedFile);
|
|
77
|
+
printReport(report.issues);
|
|
78
|
+
exitIfErrors(report.issues);
|
|
79
|
+
if (requestedFile && report.chapters.length === 1) {
|
|
80
|
+
logLine(`Stage check passed for '${report.chapters[0].relativePath}' at stage '${stage}'.`);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
logLine(`Stage check passed for '${project.id}' at stage '${stage}'.`);
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
case "export": {
|
|
88
|
+
activateWorkspace(options);
|
|
89
|
+
const project = resolveProject(readStringOption(options, "project"));
|
|
90
|
+
const format = (readStringOption(options, "format") || "md").toLowerCase();
|
|
91
|
+
const report = inspectProject(project, config);
|
|
92
|
+
printReport(report.issues);
|
|
93
|
+
exitIfErrors(report.issues);
|
|
94
|
+
const inputPath = buildManuscript(project, report.chapters, report.compileStructureLevels);
|
|
95
|
+
const outputPath = runExport(project, format, inputPath, readStringOption(options, "output"));
|
|
96
|
+
logLine(`Export output: ${outputPath}`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
default:
|
|
100
|
+
throw new Error(`Unknown command '${command}'. Run with 'help' for usage.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (error instanceof Error) {
|
|
105
|
+
console.error(`ERROR: ${error.message}`);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
console.error(`ERROR: ${String(error)}`);
|
|
109
|
+
}
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function readStringOption(options, key) {
|
|
114
|
+
const value = options[key];
|
|
115
|
+
return typeof value === "string" ? value : undefined;
|
|
116
|
+
}
|
|
117
|
+
function readBooleanOption(options, key) {
|
|
118
|
+
return options[key] === true;
|
|
119
|
+
}
|
|
120
|
+
function activateWorkspace(options) {
|
|
121
|
+
const workspace = resolveWorkspaceContext(readStringOption(options, "root"));
|
|
122
|
+
repoRoot = workspace.repoRoot;
|
|
123
|
+
config = workspace.config;
|
|
124
|
+
return workspace;
|
|
125
|
+
}
|
|
126
|
+
function isStageName(value) {
|
|
127
|
+
return Object.hasOwn(STATUS_RANK, value);
|
|
128
|
+
}
|
|
129
|
+
function isExportFormat(value) {
|
|
130
|
+
return value === "md" || value === "docx" || value === "pdf" || value === "epub";
|
|
131
|
+
}
|
|
132
|
+
function resolveSpineSchema(project) {
|
|
133
|
+
const issues = [];
|
|
134
|
+
const projectFile = path.relative(repoRoot, path.join(project.root, "stego-project.json"));
|
|
135
|
+
const rawCategories = project.meta.spineCategories;
|
|
136
|
+
if (rawCategories == null) {
|
|
137
|
+
return { schema: { categories: [], inlineIdRegex: null }, issues };
|
|
138
|
+
}
|
|
139
|
+
if (!Array.isArray(rawCategories)) {
|
|
140
|
+
issues.push(makeIssue("error", "metadata", "Project 'spineCategories' must be an array when defined.", projectFile));
|
|
141
|
+
return { schema: { categories: [], inlineIdRegex: null }, issues };
|
|
142
|
+
}
|
|
143
|
+
const categories = [];
|
|
144
|
+
const keySet = new Set();
|
|
145
|
+
const prefixSet = new Set();
|
|
146
|
+
const notesSet = new Set();
|
|
147
|
+
for (const [index, categoryEntry] of rawCategories.entries()) {
|
|
148
|
+
if (!isPlainObject(categoryEntry)) {
|
|
149
|
+
issues.push(makeIssue("error", "metadata", `Invalid spineCategories entry at index ${index}. Expected object with key, prefix, notesFile.`, projectFile));
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const key = typeof categoryEntry.key === "string" ? categoryEntry.key.trim() : "";
|
|
153
|
+
const prefix = typeof categoryEntry.prefix === "string" ? categoryEntry.prefix.trim() : "";
|
|
154
|
+
const notesFile = typeof categoryEntry.notesFile === "string" ? categoryEntry.notesFile.trim() : "";
|
|
155
|
+
if (!/^[a-z][a-z0-9_-]*$/.test(key)) {
|
|
156
|
+
issues.push(makeIssue("error", "metadata", `Invalid spine category key '${key || "<empty>"}'. Use lowercase key names like 'cast' or 'incidents'.`, projectFile));
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!/^[A-Z][A-Z0-9-]*$/.test(prefix)) {
|
|
160
|
+
issues.push(makeIssue("error", "metadata", `Invalid spine category prefix '${prefix || "<empty>"}'. Use uppercase prefixes like 'CHAR' or 'STATUTE'.`, projectFile));
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (prefix.toUpperCase() === RESERVED_COMMENT_PREFIX) {
|
|
164
|
+
issues.push(makeIssue("error", "metadata", `Invalid spine category prefix '${prefix}'. '${RESERVED_COMMENT_PREFIX}' is reserved for Stego comment IDs (e.g. CMT-0001).`, projectFile));
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (!/^[A-Za-z0-9._-]+\.md$/.test(notesFile)) {
|
|
168
|
+
issues.push(makeIssue("error", "metadata", `Invalid notesFile '${notesFile || "<empty>"}'. Use markdown filenames like 'characters.md' (resolved in spine/).`, projectFile));
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (keySet.has(key)) {
|
|
172
|
+
issues.push(makeIssue("error", "metadata", `Duplicate spine category key '${key}'.`, projectFile));
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (prefixSet.has(prefix)) {
|
|
176
|
+
issues.push(makeIssue("error", "metadata", `Duplicate spine category prefix '${prefix}'.`, projectFile));
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (notesSet.has(notesFile)) {
|
|
180
|
+
issues.push(makeIssue("error", "metadata", `Duplicate spine category notesFile '${notesFile}'.`, projectFile));
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
keySet.add(key);
|
|
184
|
+
prefixSet.add(prefix);
|
|
185
|
+
notesSet.add(notesFile);
|
|
186
|
+
categories.push({
|
|
187
|
+
key,
|
|
188
|
+
prefix,
|
|
189
|
+
notesFile,
|
|
190
|
+
idPattern: new RegExp(`^${escapeRegex(prefix)}-[A-Z0-9-]+$`)
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
schema: {
|
|
195
|
+
categories,
|
|
196
|
+
inlineIdRegex: buildInlineIdRegex(categories)
|
|
197
|
+
},
|
|
198
|
+
issues
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function resolveRequiredMetadata(project, runtimeConfig) {
|
|
202
|
+
const issues = [];
|
|
203
|
+
const projectFile = path.relative(repoRoot, path.join(project.root, "stego-project.json"));
|
|
204
|
+
const raw = project.meta.requiredMetadata;
|
|
205
|
+
if (raw == null) {
|
|
206
|
+
return { requiredMetadata: runtimeConfig.requiredMetadata, issues };
|
|
207
|
+
}
|
|
208
|
+
if (!Array.isArray(raw)) {
|
|
209
|
+
issues.push(makeIssue("error", "metadata", "Project 'requiredMetadata' must be an array of metadata keys.", projectFile));
|
|
210
|
+
return { requiredMetadata: runtimeConfig.requiredMetadata, issues };
|
|
211
|
+
}
|
|
212
|
+
const requiredMetadata = [];
|
|
213
|
+
const seen = new Set();
|
|
214
|
+
for (const [index, entry] of raw.entries()) {
|
|
215
|
+
if (typeof entry !== "string") {
|
|
216
|
+
issues.push(makeIssue("error", "metadata", `Project 'requiredMetadata' entry at index ${index} must be a string.`, projectFile));
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const key = entry.trim();
|
|
220
|
+
if (!key) {
|
|
221
|
+
issues.push(makeIssue("error", "metadata", `Project 'requiredMetadata' entry at index ${index} cannot be empty.`, projectFile));
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (seen.has(key)) {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
seen.add(key);
|
|
228
|
+
requiredMetadata.push(key);
|
|
229
|
+
}
|
|
230
|
+
return { requiredMetadata, issues };
|
|
231
|
+
}
|
|
232
|
+
function resolveCompileStructure(project) {
|
|
233
|
+
const issues = [];
|
|
234
|
+
const projectFile = path.relative(repoRoot, path.join(project.root, "stego-project.json"));
|
|
235
|
+
const raw = project.meta.compileStructure;
|
|
236
|
+
if (raw == null) {
|
|
237
|
+
return { levels: [], issues };
|
|
238
|
+
}
|
|
239
|
+
if (!isPlainObject(raw)) {
|
|
240
|
+
issues.push(makeIssue("error", "metadata", "Project 'compileStructure' must be an object.", projectFile));
|
|
241
|
+
return { levels: [], issues };
|
|
242
|
+
}
|
|
243
|
+
const rawLevels = raw.levels;
|
|
244
|
+
if (!Array.isArray(rawLevels)) {
|
|
245
|
+
issues.push(makeIssue("error", "metadata", "Project 'compileStructure.levels' must be an array.", projectFile));
|
|
246
|
+
return { levels: [], issues };
|
|
247
|
+
}
|
|
248
|
+
const levels = [];
|
|
249
|
+
const seenKeys = new Set();
|
|
250
|
+
for (const [index, entry] of rawLevels.entries()) {
|
|
251
|
+
if (!isPlainObject(entry)) {
|
|
252
|
+
issues.push(makeIssue("error", "metadata", `Invalid compileStructure level at index ${index}. Expected object.`, projectFile));
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const key = typeof entry.key === "string" ? entry.key.trim() : "";
|
|
256
|
+
const label = typeof entry.label === "string" ? entry.label.trim() : "";
|
|
257
|
+
const titleKeyRaw = typeof entry.titleKey === "string" ? entry.titleKey.trim() : "";
|
|
258
|
+
const headingTemplateRaw = typeof entry.headingTemplate === "string" ? entry.headingTemplate.trim() : "";
|
|
259
|
+
if (!key || !/^[a-z][a-z0-9_-]*$/.test(key)) {
|
|
260
|
+
issues.push(makeIssue("error", "metadata", `compileStructure.levels[${index}].key must match /^[a-z][a-z0-9_-]*$/.`, projectFile));
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (!label) {
|
|
264
|
+
issues.push(makeIssue("error", "metadata", `compileStructure.levels[${index}].label is required.`, projectFile));
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (seenKeys.has(key)) {
|
|
268
|
+
issues.push(makeIssue("error", "metadata", `Duplicate compileStructure level key '${key}'.`, projectFile));
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (titleKeyRaw && !/^[a-z][a-z0-9_-]*$/.test(titleKeyRaw)) {
|
|
272
|
+
issues.push(makeIssue("error", "metadata", `compileStructure.levels[${index}].titleKey must match /^[a-z][a-z0-9_-]*$/.`, projectFile));
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const pageBreakRaw = typeof entry.pageBreak === "string" ? entry.pageBreak.trim() : "none";
|
|
276
|
+
if (pageBreakRaw !== "none" && pageBreakRaw !== "between-groups") {
|
|
277
|
+
issues.push(makeIssue("error", "metadata", `compileStructure.levels[${index}].pageBreak must be 'none' or 'between-groups'.`, projectFile));
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const injectHeading = typeof entry.injectHeading === "boolean" ? entry.injectHeading : true;
|
|
281
|
+
const headingTemplate = headingTemplateRaw || "{label} {value}: {title}";
|
|
282
|
+
seenKeys.add(key);
|
|
283
|
+
levels.push({
|
|
284
|
+
key,
|
|
285
|
+
label,
|
|
286
|
+
titleKey: titleKeyRaw || undefined,
|
|
287
|
+
injectHeading,
|
|
288
|
+
headingTemplate,
|
|
289
|
+
pageBreak: pageBreakRaw
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return { levels, issues };
|
|
293
|
+
}
|
|
294
|
+
function buildInlineIdRegex(categories) {
|
|
295
|
+
if (categories.length === 0) {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
const prefixes = categories.map((category) => escapeRegex(category.prefix)).join("|");
|
|
299
|
+
return new RegExp(`\\b(?:${prefixes})-[A-Z0-9-]+\\b`, "g");
|
|
300
|
+
}
|
|
301
|
+
function escapeRegex(value) {
|
|
302
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
303
|
+
}
|
|
304
|
+
function isPlainObject(value) {
|
|
305
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
306
|
+
}
|
|
307
|
+
function parseArgs(argv) {
|
|
308
|
+
const [command, ...rest] = argv;
|
|
309
|
+
const options = { _: [] };
|
|
310
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
311
|
+
const token = rest[i];
|
|
312
|
+
if (!token.startsWith("--")) {
|
|
313
|
+
options._.push(token);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const key = token.slice(2);
|
|
317
|
+
const next = rest[i + 1];
|
|
318
|
+
if (!next || next.startsWith("--")) {
|
|
319
|
+
options[key] = true;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
options[key] = next;
|
|
323
|
+
i += 1;
|
|
324
|
+
}
|
|
325
|
+
return { command, options };
|
|
326
|
+
}
|
|
327
|
+
function resolveWorkspaceContext(rootOption) {
|
|
328
|
+
if (rootOption) {
|
|
329
|
+
const explicitRoot = path.resolve(process.cwd(), rootOption);
|
|
330
|
+
if (!fs.existsSync(explicitRoot) || !fs.statSync(explicitRoot).isDirectory()) {
|
|
331
|
+
throw new Error(`Workspace root does not exist or is not a directory: ${explicitRoot}`);
|
|
332
|
+
}
|
|
333
|
+
const explicitConfigPath = path.join(explicitRoot, ROOT_CONFIG_FILENAME);
|
|
334
|
+
if (!fs.existsSync(explicitConfigPath)) {
|
|
335
|
+
const legacyConfigPath = path.join(explicitRoot, "writing.config.json");
|
|
336
|
+
if (fs.existsSync(legacyConfigPath)) {
|
|
337
|
+
throw new Error(`Found legacy 'writing.config.json' at '${explicitRoot}'. Rename it to '${ROOT_CONFIG_FILENAME}'.`);
|
|
338
|
+
}
|
|
339
|
+
throw new Error(`No Stego workspace found at '${explicitRoot}'. Expected '${ROOT_CONFIG_FILENAME}'.`);
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
repoRoot: explicitRoot,
|
|
343
|
+
configPath: explicitConfigPath,
|
|
344
|
+
config: readJson(explicitConfigPath)
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
const discoveredConfigPath = findNearestFileUpward(process.cwd(), ROOT_CONFIG_FILENAME);
|
|
348
|
+
if (!discoveredConfigPath) {
|
|
349
|
+
const legacyConfigPath = findNearestFileUpward(process.cwd(), "writing.config.json");
|
|
350
|
+
if (legacyConfigPath) {
|
|
351
|
+
throw new Error(`Found legacy '${path.basename(legacyConfigPath)}' at '${path.dirname(legacyConfigPath)}'. Rename it to '${ROOT_CONFIG_FILENAME}'.`);
|
|
352
|
+
}
|
|
353
|
+
throw new Error(`No Stego workspace found from '${process.cwd()}'. Run 'stego init' or pass --root <path>.`);
|
|
354
|
+
}
|
|
355
|
+
const discoveredRoot = path.dirname(discoveredConfigPath);
|
|
356
|
+
return {
|
|
357
|
+
repoRoot: discoveredRoot,
|
|
358
|
+
configPath: discoveredConfigPath,
|
|
359
|
+
config: readJson(discoveredConfigPath)
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
function findNearestFileUpward(startPath, filename) {
|
|
363
|
+
let current = path.resolve(startPath);
|
|
364
|
+
if (!fs.existsSync(current)) {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
if (!fs.statSync(current).isDirectory()) {
|
|
368
|
+
current = path.dirname(current);
|
|
369
|
+
}
|
|
370
|
+
while (true) {
|
|
371
|
+
const candidate = path.join(current, filename);
|
|
372
|
+
if (fs.existsSync(candidate)) {
|
|
373
|
+
return candidate;
|
|
374
|
+
}
|
|
375
|
+
const parent = path.dirname(current);
|
|
376
|
+
if (parent === current) {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
current = parent;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
function initWorkspace(options) {
|
|
383
|
+
const targetRoot = process.cwd();
|
|
384
|
+
const entries = fs
|
|
385
|
+
.readdirSync(targetRoot, { withFileTypes: true })
|
|
386
|
+
.filter((entry) => entry.name !== "." && entry.name !== "..");
|
|
387
|
+
if (entries.length > 0 && !options.force) {
|
|
388
|
+
throw new Error(`Target directory is not empty: ${targetRoot}. Re-run with --force to continue.`);
|
|
389
|
+
}
|
|
390
|
+
const copiedPaths = [];
|
|
391
|
+
copyTemplateAsset(".gitignore", targetRoot, copiedPaths);
|
|
392
|
+
copyTemplateAsset(".markdownlint.json", targetRoot, copiedPaths);
|
|
393
|
+
copyTemplateAsset(".cspell.json", targetRoot, copiedPaths);
|
|
394
|
+
copyTemplateAsset(ROOT_CONFIG_FILENAME, targetRoot, copiedPaths);
|
|
395
|
+
copyTemplateAsset("docs", targetRoot, copiedPaths);
|
|
396
|
+
copyTemplateAsset("projects", targetRoot, copiedPaths);
|
|
397
|
+
copyTemplateAsset(path.join(".vscode", "tasks.json"), targetRoot, copiedPaths);
|
|
398
|
+
copyTemplateAsset(path.join(".vscode", "extensions.json"), targetRoot, copiedPaths, { optional: true });
|
|
399
|
+
rewriteTemplateProjectPackageScripts(targetRoot);
|
|
400
|
+
writeInitRootPackageJson(targetRoot);
|
|
401
|
+
logLine(`Initialized Stego workspace in ${targetRoot}`);
|
|
402
|
+
for (const relativePath of copiedPaths) {
|
|
403
|
+
logLine(`- ${relativePath}`);
|
|
404
|
+
}
|
|
405
|
+
logLine("- package.json");
|
|
406
|
+
logLine("");
|
|
407
|
+
logLine("Next steps:");
|
|
408
|
+
logLine(" npm install");
|
|
409
|
+
logLine(" npm run list-projects");
|
|
410
|
+
logLine(" npm run validate -- --project plague-demo");
|
|
411
|
+
}
|
|
412
|
+
function copyTemplateAsset(sourceRelativePath, targetRoot, copiedPaths, options) {
|
|
413
|
+
const sourcePath = path.join(packageRoot, sourceRelativePath);
|
|
414
|
+
if (!fs.existsSync(sourcePath)) {
|
|
415
|
+
if (options?.optional) {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
throw new Error(`Template asset is missing from stego-cli package: ${sourceRelativePath}`);
|
|
419
|
+
}
|
|
420
|
+
const destinationPath = path.join(targetRoot, sourceRelativePath);
|
|
421
|
+
const stats = fs.statSync(sourcePath);
|
|
422
|
+
if (stats.isDirectory()) {
|
|
423
|
+
fs.mkdirSync(destinationPath, { recursive: true });
|
|
424
|
+
fs.cpSync(sourcePath, destinationPath, {
|
|
425
|
+
recursive: true,
|
|
426
|
+
force: true,
|
|
427
|
+
filter: (currentSourcePath) => shouldCopyTemplatePath(currentSourcePath)
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
|
432
|
+
fs.copyFileSync(sourcePath, destinationPath);
|
|
433
|
+
}
|
|
434
|
+
copiedPaths.push(sourceRelativePath);
|
|
435
|
+
}
|
|
436
|
+
function shouldCopyTemplatePath(currentSourcePath) {
|
|
437
|
+
const relativePath = path.relative(packageRoot, currentSourcePath);
|
|
438
|
+
if (!relativePath || relativePath.startsWith("..")) {
|
|
439
|
+
return true;
|
|
440
|
+
}
|
|
441
|
+
const parts = relativePath.split(path.sep);
|
|
442
|
+
const name = parts[parts.length - 1] || "";
|
|
443
|
+
if (name === ".DS_Store") {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
if (parts[0] === "projects") {
|
|
447
|
+
if (parts[parts.length - 2] === ".vscode" && name === "settings.json") {
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
const distIndex = parts.indexOf("dist");
|
|
451
|
+
if (distIndex >= 0) {
|
|
452
|
+
const isDistRoot = distIndex === parts.length - 1;
|
|
453
|
+
const isGitkeep = name === ".gitkeep";
|
|
454
|
+
return isDistRoot || isGitkeep;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
function rewriteTemplateProjectPackageScripts(targetRoot) {
|
|
460
|
+
const projectsRoot = path.join(targetRoot, "projects");
|
|
461
|
+
if (!fs.existsSync(projectsRoot)) {
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
for (const entry of fs.readdirSync(projectsRoot, { withFileTypes: true })) {
|
|
465
|
+
if (!entry.isDirectory()) {
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
const projectRoot = path.join(projectsRoot, entry.name);
|
|
469
|
+
const packageJsonPath = path.join(projectsRoot, entry.name, "package.json");
|
|
470
|
+
if (!fs.existsSync(packageJsonPath)) {
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
const projectPackage = readJson(packageJsonPath);
|
|
474
|
+
const scripts = isPlainObject(projectPackage.scripts)
|
|
475
|
+
? { ...projectPackage.scripts }
|
|
476
|
+
: {};
|
|
477
|
+
if (typeof projectPackage.name === "string" && projectPackage.name.startsWith("writing-project-")) {
|
|
478
|
+
projectPackage.name = projectPackage.name.replace(/^writing-project-/, "stego-project-");
|
|
479
|
+
}
|
|
480
|
+
scripts.validate = "npx --no-install stego validate";
|
|
481
|
+
scripts.build = "npx --no-install stego build";
|
|
482
|
+
scripts["check-stage"] = "npx --no-install stego check-stage";
|
|
483
|
+
scripts.export = "npx --no-install stego export";
|
|
484
|
+
projectPackage.scripts = scripts;
|
|
485
|
+
fs.writeFileSync(packageJsonPath, `${JSON.stringify(projectPackage, null, 2)}\n`, "utf8");
|
|
486
|
+
ensureProjectExtensionsRecommendations(projectRoot);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
function ensureProjectExtensionsRecommendations(projectRoot) {
|
|
490
|
+
const vscodeDir = path.join(projectRoot, ".vscode");
|
|
491
|
+
const extensionsPath = path.join(vscodeDir, "extensions.json");
|
|
492
|
+
fs.mkdirSync(vscodeDir, { recursive: true });
|
|
493
|
+
let existingRecommendations = [];
|
|
494
|
+
if (fs.existsSync(extensionsPath)) {
|
|
495
|
+
try {
|
|
496
|
+
const parsed = readJson(extensionsPath);
|
|
497
|
+
if (Array.isArray(parsed.recommendations)) {
|
|
498
|
+
existingRecommendations = parsed.recommendations.filter((value) => typeof value === "string");
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
existingRecommendations = [];
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
const mergedRecommendations = [
|
|
506
|
+
...new Set([...PROJECT_EXTENSION_RECOMMENDATIONS, ...existingRecommendations])
|
|
507
|
+
];
|
|
508
|
+
const extensionsConfig = {
|
|
509
|
+
recommendations: mergedRecommendations
|
|
510
|
+
};
|
|
511
|
+
fs.writeFileSync(extensionsPath, `${JSON.stringify(extensionsConfig, null, 2)}\n`, "utf8");
|
|
512
|
+
}
|
|
513
|
+
function writeInitRootPackageJson(targetRoot) {
|
|
514
|
+
const cliPackage = readJson(path.join(packageRoot, "package.json"));
|
|
515
|
+
const cliVersion = typeof cliPackage.version === "string" ? cliPackage.version : "0.1.0";
|
|
516
|
+
const manifest = {
|
|
517
|
+
name: path.basename(targetRoot) || "stego-workspace",
|
|
518
|
+
private: true,
|
|
519
|
+
type: "module",
|
|
520
|
+
description: "Stego writing workspace",
|
|
521
|
+
engines: {
|
|
522
|
+
node: ">=20"
|
|
523
|
+
},
|
|
524
|
+
scripts: {
|
|
525
|
+
"list-projects": "stego list-projects",
|
|
526
|
+
"new-project": "stego new-project",
|
|
527
|
+
validate: "stego validate",
|
|
528
|
+
build: "stego build",
|
|
529
|
+
"check-stage": "stego check-stage",
|
|
530
|
+
export: "stego export"
|
|
531
|
+
},
|
|
532
|
+
devDependencies: {
|
|
533
|
+
"stego-cli": `^${cliVersion}`,
|
|
534
|
+
cspell: "^9.6.4",
|
|
535
|
+
"markdownlint-cli": "^0.47.0"
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
fs.writeFileSync(path.join(targetRoot, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
539
|
+
}
|
|
540
|
+
function printUsage() {
|
|
541
|
+
console.log(`Stego CLI\n\nCommands:\n init [--force]\n list-projects [--root <path>]\n new-project --project <project-id> [--title <title>] [--root <path>]\n validate --project <project-id> [--file <project-relative-manuscript-path>] [--root <path>]\n build --project <project-id> [--root <path>]\n check-stage --project <project-id> --stage <draft|revise|line-edit|proof|final> [--file <project-relative-manuscript-path>] [--root <path>]\n export --project <project-id> --format <md|docx|pdf|epub> [--output <path>] [--root <path>]\n`);
|
|
542
|
+
}
|
|
543
|
+
function listProjects() {
|
|
544
|
+
const ids = getProjectIds();
|
|
545
|
+
if (ids.length === 0) {
|
|
546
|
+
console.log("No projects found.");
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
console.log("Projects:");
|
|
550
|
+
for (const id of ids) {
|
|
551
|
+
console.log(`- ${id}`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
function createProject(projectIdOption, titleOption) {
|
|
555
|
+
const projectId = (projectIdOption || "").trim();
|
|
556
|
+
if (!projectId) {
|
|
557
|
+
throw new Error("Project id is required. Use --project <project-id>.");
|
|
558
|
+
}
|
|
559
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(projectId)) {
|
|
560
|
+
throw new Error("Project id must match /^[a-z0-9][a-z0-9-]*$/.");
|
|
561
|
+
}
|
|
562
|
+
const projectRoot = path.join(repoRoot, config.projectsDir, projectId);
|
|
563
|
+
if (fs.existsSync(projectRoot)) {
|
|
564
|
+
throw new Error(`Project already exists: ${projectRoot}`);
|
|
565
|
+
}
|
|
566
|
+
fs.mkdirSync(path.join(projectRoot, config.chapterDir), { recursive: true });
|
|
567
|
+
const spineDir = path.join(projectRoot, config.spineDir);
|
|
568
|
+
fs.mkdirSync(spineDir, { recursive: true });
|
|
569
|
+
const notesDir = path.join(projectRoot, config.notesDir);
|
|
570
|
+
fs.mkdirSync(notesDir, { recursive: true });
|
|
571
|
+
fs.mkdirSync(path.join(projectRoot, config.distDir), { recursive: true });
|
|
572
|
+
const projectJson = {
|
|
573
|
+
id: projectId,
|
|
574
|
+
title: titleOption?.trim() || toDisplayTitle(projectId),
|
|
575
|
+
requiredMetadata: ["status"],
|
|
576
|
+
compileStructure: {
|
|
577
|
+
levels: [
|
|
578
|
+
{
|
|
579
|
+
key: "chapter",
|
|
580
|
+
label: "Chapter",
|
|
581
|
+
titleKey: "chapter_title",
|
|
582
|
+
injectHeading: true,
|
|
583
|
+
headingTemplate: "{label} {value}: {title}",
|
|
584
|
+
pageBreak: "none"
|
|
585
|
+
}
|
|
586
|
+
]
|
|
587
|
+
},
|
|
588
|
+
spineCategories: [
|
|
589
|
+
{
|
|
590
|
+
key: "characters",
|
|
591
|
+
prefix: "CHAR",
|
|
592
|
+
notesFile: "characters.md"
|
|
593
|
+
}
|
|
594
|
+
]
|
|
595
|
+
};
|
|
596
|
+
const projectJsonPath = path.join(projectRoot, "stego-project.json");
|
|
597
|
+
fs.writeFileSync(projectJsonPath, `${JSON.stringify(projectJson, null, 2)}\n`, "utf8");
|
|
598
|
+
const projectPackage = {
|
|
599
|
+
name: `stego-project-${projectId}`,
|
|
600
|
+
private: true,
|
|
601
|
+
scripts: {
|
|
602
|
+
validate: "npx --no-install stego validate",
|
|
603
|
+
build: "npx --no-install stego build",
|
|
604
|
+
"check-stage": "npx --no-install stego check-stage",
|
|
605
|
+
export: "npx --no-install stego export"
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
const projectPackagePath = path.join(projectRoot, "package.json");
|
|
609
|
+
fs.writeFileSync(projectPackagePath, `${JSON.stringify(projectPackage, null, 2)}\n`, "utf8");
|
|
610
|
+
const charactersNotesPath = path.join(spineDir, "characters.md");
|
|
611
|
+
fs.writeFileSync(charactersNotesPath, "# Characters\n\n", "utf8");
|
|
612
|
+
const projectExtensionsPath = path.join(projectRoot, ".vscode", "extensions.json");
|
|
613
|
+
ensureProjectExtensionsRecommendations(projectRoot);
|
|
614
|
+
logLine(`Created project: ${path.relative(repoRoot, projectRoot)}`);
|
|
615
|
+
logLine(`- ${path.relative(repoRoot, projectJsonPath)}`);
|
|
616
|
+
logLine(`- ${path.relative(repoRoot, projectPackagePath)}`);
|
|
617
|
+
logLine(`- ${path.relative(repoRoot, charactersNotesPath)}`);
|
|
618
|
+
logLine(`- ${path.relative(repoRoot, projectExtensionsPath)}`);
|
|
619
|
+
}
|
|
620
|
+
function getProjectIds() {
|
|
621
|
+
const projectsDir = path.join(repoRoot, config.projectsDir);
|
|
622
|
+
if (!fs.existsSync(projectsDir)) {
|
|
623
|
+
return [];
|
|
624
|
+
}
|
|
625
|
+
return fs
|
|
626
|
+
.readdirSync(projectsDir, { withFileTypes: true })
|
|
627
|
+
.filter((entry) => entry.isDirectory())
|
|
628
|
+
.map((entry) => entry.name)
|
|
629
|
+
.filter((id) => fs.existsSync(path.join(projectsDir, id, "stego-project.json")))
|
|
630
|
+
.sort();
|
|
631
|
+
}
|
|
632
|
+
function resolveProject(explicitProjectId) {
|
|
633
|
+
const ids = getProjectIds();
|
|
634
|
+
const projectId = explicitProjectId ||
|
|
635
|
+
process.env.STEGO_PROJECT ||
|
|
636
|
+
process.env.WRITING_PROJECT ||
|
|
637
|
+
inferProjectIdFromCwd(process.cwd()) ||
|
|
638
|
+
(ids.length === 1 ? ids[0] : null);
|
|
639
|
+
if (!projectId) {
|
|
640
|
+
throw new Error("Project id is required. Use --project <project-id>.");
|
|
641
|
+
}
|
|
642
|
+
const projectRoot = path.join(repoRoot, config.projectsDir, projectId);
|
|
643
|
+
if (!fs.existsSync(projectRoot)) {
|
|
644
|
+
throw new Error(`Project not found: ${projectRoot}`);
|
|
645
|
+
}
|
|
646
|
+
return {
|
|
647
|
+
id: projectId,
|
|
648
|
+
root: projectRoot,
|
|
649
|
+
manuscriptDir: path.join(projectRoot, config.chapterDir),
|
|
650
|
+
spineDir: path.join(projectRoot, config.spineDir),
|
|
651
|
+
notesDir: path.join(projectRoot, config.notesDir),
|
|
652
|
+
distDir: path.join(projectRoot, config.distDir),
|
|
653
|
+
meta: readJson(path.join(projectRoot, "stego-project.json"))
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
function inferProjectIdFromCwd(cwd) {
|
|
657
|
+
const projectsRoot = path.resolve(repoRoot, config.projectsDir);
|
|
658
|
+
const relative = path.relative(projectsRoot, path.resolve(cwd));
|
|
659
|
+
if (!relative || relative === "." || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
const projectId = relative.split(path.sep)[0];
|
|
663
|
+
if (!projectId) {
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
const projectJsonPath = path.join(projectsRoot, projectId, "stego-project.json");
|
|
667
|
+
if (!fs.existsSync(projectJsonPath)) {
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
return projectId;
|
|
671
|
+
}
|
|
672
|
+
function inspectProject(project, runtimeConfig, options = {}) {
|
|
673
|
+
const issues = [];
|
|
674
|
+
const emptySpineState = { ids: new Set(), issues: [] };
|
|
675
|
+
const spineSchema = resolveSpineSchema(project);
|
|
676
|
+
const requiredMetadataState = resolveRequiredMetadata(project, runtimeConfig);
|
|
677
|
+
const compileStructureState = resolveCompileStructure(project);
|
|
678
|
+
issues.push(...spineSchema.issues);
|
|
679
|
+
issues.push(...requiredMetadataState.issues);
|
|
680
|
+
issues.push(...compileStructureState.issues);
|
|
681
|
+
let chapterFiles = [];
|
|
682
|
+
const onlyFile = options.onlyFile?.trim();
|
|
683
|
+
if (onlyFile) {
|
|
684
|
+
const resolvedPath = path.resolve(project.root, onlyFile);
|
|
685
|
+
const relativeToProject = path.relative(project.root, resolvedPath);
|
|
686
|
+
if (!relativeToProject || relativeToProject.startsWith("..") || path.isAbsolute(relativeToProject)) {
|
|
687
|
+
issues.push(makeIssue("error", "structure", `Requested file is outside the project: ${onlyFile}`, null));
|
|
688
|
+
return { chapters: [], issues, spineState: emptySpineState, compileStructureLevels: compileStructureState.levels };
|
|
689
|
+
}
|
|
690
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
691
|
+
issues.push(makeIssue("error", "structure", `Requested file does not exist: ${onlyFile}`, null));
|
|
692
|
+
return { chapters: [], issues, spineState: emptySpineState, compileStructureLevels: compileStructureState.levels };
|
|
693
|
+
}
|
|
694
|
+
if (!fs.statSync(resolvedPath).isFile() || !resolvedPath.endsWith(".md")) {
|
|
695
|
+
issues.push(makeIssue("error", "structure", `Requested file must be a markdown file: ${onlyFile}`, null));
|
|
696
|
+
return { chapters: [], issues, spineState: emptySpineState, compileStructureLevels: compileStructureState.levels };
|
|
697
|
+
}
|
|
698
|
+
const relativeToManuscript = path.relative(project.manuscriptDir, resolvedPath);
|
|
699
|
+
if (relativeToManuscript.startsWith("..") || path.isAbsolute(relativeToManuscript)) {
|
|
700
|
+
issues.push(makeIssue("error", "structure", `Requested file must be inside manuscript directory: ${project.manuscriptDir}`, null));
|
|
701
|
+
return { chapters: [], issues, spineState: emptySpineState, compileStructureLevels: compileStructureState.levels };
|
|
702
|
+
}
|
|
703
|
+
chapterFiles = [resolvedPath];
|
|
704
|
+
}
|
|
705
|
+
else {
|
|
706
|
+
if (!fs.existsSync(project.manuscriptDir)) {
|
|
707
|
+
issues.push(makeIssue("error", "structure", `Missing manuscript directory: ${project.manuscriptDir}`));
|
|
708
|
+
return { chapters: [], issues, spineState: emptySpineState, compileStructureLevels: compileStructureState.levels };
|
|
709
|
+
}
|
|
710
|
+
chapterFiles = fs
|
|
711
|
+
.readdirSync(project.manuscriptDir, { withFileTypes: true })
|
|
712
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
|
|
713
|
+
.map((entry) => path.join(project.manuscriptDir, entry.name))
|
|
714
|
+
.sort();
|
|
715
|
+
if (chapterFiles.length === 0) {
|
|
716
|
+
issues.push(makeIssue("error", "structure", `No manuscript files found in ${project.manuscriptDir}`));
|
|
717
|
+
return { chapters: [], issues, spineState: emptySpineState, compileStructureLevels: compileStructureState.levels };
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
const chapters = chapterFiles.map((chapterPath) => parseChapter(chapterPath, runtimeConfig, requiredMetadataState.requiredMetadata, spineSchema.schema.categories, spineSchema.schema.inlineIdRegex, compileStructureState.levels));
|
|
721
|
+
for (const chapter of chapters) {
|
|
722
|
+
issues.push(...chapter.issues);
|
|
723
|
+
}
|
|
724
|
+
const orderMap = new Map();
|
|
725
|
+
for (const chapter of chapters) {
|
|
726
|
+
if (chapter.order == null) {
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
if (orderMap.has(chapter.order)) {
|
|
730
|
+
issues.push(makeIssue("error", "ordering", `Duplicate filename order prefix '${chapter.order}' in ${chapter.relativePath} and ${orderMap.get(chapter.order)}`, chapter.relativePath));
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
orderMap.set(chapter.order, chapter.relativePath);
|
|
734
|
+
}
|
|
735
|
+
chapters.sort((a, b) => {
|
|
736
|
+
if (a.order == null && b.order == null) {
|
|
737
|
+
return a.relativePath.localeCompare(b.relativePath);
|
|
738
|
+
}
|
|
739
|
+
if (a.order == null) {
|
|
740
|
+
return 1;
|
|
741
|
+
}
|
|
742
|
+
if (b.order == null) {
|
|
743
|
+
return -1;
|
|
744
|
+
}
|
|
745
|
+
return a.order - b.order;
|
|
746
|
+
});
|
|
747
|
+
const spineState = readSpine(project.spineDir, spineSchema.schema.categories, spineSchema.schema.inlineIdRegex);
|
|
748
|
+
issues.push(...spineState.issues);
|
|
749
|
+
for (const chapter of chapters) {
|
|
750
|
+
issues.push(...findUnknownSpineIds(chapter.referenceIds, spineState.ids, chapter.relativePath));
|
|
751
|
+
}
|
|
752
|
+
return {
|
|
753
|
+
chapters,
|
|
754
|
+
issues,
|
|
755
|
+
spineState,
|
|
756
|
+
compileStructureLevels: compileStructureState.levels
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
function parseChapter(chapterPath, runtimeConfig, requiredMetadata, spineCategories, inlineIdRegex, compileStructureLevels) {
|
|
760
|
+
const relativePath = path.relative(repoRoot, chapterPath);
|
|
761
|
+
const raw = fs.readFileSync(chapterPath, "utf8");
|
|
762
|
+
const { metadata, body, comments, issues } = parseMetadata(raw, chapterPath, false);
|
|
763
|
+
const chapterIssues = [...issues];
|
|
764
|
+
for (const requiredKey of requiredMetadata) {
|
|
765
|
+
if (metadata[requiredKey] == null || metadata[requiredKey] === "") {
|
|
766
|
+
chapterIssues.push(makeIssue("warning", "metadata", `Missing required metadata key '${requiredKey}'. Validation and stage checks that depend on '${requiredKey}' are skipped for this file.`, relativePath));
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
const title = deriveEntryTitle(metadata.title, chapterPath);
|
|
770
|
+
if (metadata.order != null && metadata.order !== "") {
|
|
771
|
+
chapterIssues.push(makeIssue("warning", "metadata", "Metadata 'order' is ignored. Ordering is derived from filename prefix.", relativePath));
|
|
772
|
+
}
|
|
773
|
+
const order = parseOrderFromFilename(chapterPath, relativePath, chapterIssues);
|
|
774
|
+
const status = String(metadata.status || "").trim();
|
|
775
|
+
if (status && !isStageName(status)) {
|
|
776
|
+
chapterIssues.push(makeIssue("error", "metadata", `Invalid file status '${status}'. Allowed: ${runtimeConfig.allowedStatuses.join(", ")}.`, relativePath));
|
|
777
|
+
}
|
|
778
|
+
const groupValues = {};
|
|
779
|
+
for (const level of compileStructureLevels) {
|
|
780
|
+
const groupValue = normalizeGroupingValue(metadata[level.key], relativePath, chapterIssues, level.key);
|
|
781
|
+
if (groupValue) {
|
|
782
|
+
groupValues[level.key] = groupValue;
|
|
783
|
+
}
|
|
784
|
+
if (level.titleKey) {
|
|
785
|
+
void normalizeGroupingValue(metadata[level.titleKey], relativePath, chapterIssues, level.titleKey);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
const referenceValidation = extractReferenceIds(metadata, relativePath, spineCategories);
|
|
789
|
+
chapterIssues.push(...referenceValidation.issues);
|
|
790
|
+
chapterIssues.push(...findInlineSpineIdMentions(body, relativePath, inlineIdRegex));
|
|
791
|
+
chapterIssues.push(...validateMarkdownBody(body, chapterPath));
|
|
792
|
+
return {
|
|
793
|
+
path: chapterPath,
|
|
794
|
+
relativePath,
|
|
795
|
+
title,
|
|
796
|
+
order,
|
|
797
|
+
status,
|
|
798
|
+
referenceIds: referenceValidation.ids,
|
|
799
|
+
groupValues,
|
|
800
|
+
metadata,
|
|
801
|
+
body,
|
|
802
|
+
comments,
|
|
803
|
+
issues: chapterIssues
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
function normalizeGroupingValue(rawValue, relativePath, issues, key) {
|
|
807
|
+
if (rawValue == null || rawValue === "") {
|
|
808
|
+
return undefined;
|
|
809
|
+
}
|
|
810
|
+
if (Array.isArray(rawValue)) {
|
|
811
|
+
issues.push(makeIssue("error", "metadata", `Metadata '${key}' must be a scalar value.`, relativePath));
|
|
812
|
+
return undefined;
|
|
813
|
+
}
|
|
814
|
+
const normalized = String(rawValue).trim();
|
|
815
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
816
|
+
}
|
|
817
|
+
function deriveEntryTitle(rawTitle, chapterPath) {
|
|
818
|
+
if (typeof rawTitle === "string" && rawTitle.trim()) {
|
|
819
|
+
return rawTitle.trim();
|
|
820
|
+
}
|
|
821
|
+
const basename = path.basename(chapterPath, ".md");
|
|
822
|
+
const withoutPrefix = basename.replace(/^\d+[-_]?/, "");
|
|
823
|
+
const normalized = withoutPrefix.replace(/[-_]+/g, " ").trim();
|
|
824
|
+
if (!normalized) {
|
|
825
|
+
return basename;
|
|
826
|
+
}
|
|
827
|
+
return normalized.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
|
828
|
+
}
|
|
829
|
+
function parseOrderFromFilename(chapterPath, relativePath, issues) {
|
|
830
|
+
const basename = path.basename(chapterPath, ".md");
|
|
831
|
+
const match = basename.match(/^(\d+)[-_]/);
|
|
832
|
+
if (!match) {
|
|
833
|
+
issues.push(makeIssue("error", "ordering", "Filename must start with a numeric prefix followed by '-' or '_' (for example '100-scene.md').", relativePath));
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
if (match[1].length !== 3) {
|
|
837
|
+
issues.push(makeIssue("warning", "ordering", `Filename prefix '${match[1]}' is valid but non-standard. Use three digits like 100, 200, 300.`, relativePath));
|
|
838
|
+
}
|
|
839
|
+
return Number(match[1]);
|
|
840
|
+
}
|
|
841
|
+
function extractReferenceIds(metadata, relativePath, spineCategories) {
|
|
842
|
+
const issues = [];
|
|
843
|
+
const ids = new Set();
|
|
844
|
+
for (const category of spineCategories) {
|
|
845
|
+
const rawValue = metadata[category.key];
|
|
846
|
+
if (rawValue == null || rawValue === "") {
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
if (!Array.isArray(rawValue)) {
|
|
850
|
+
issues.push(makeIssue("error", "metadata", `Metadata '${category.key}' must be an array, for example: [\"${category.prefix}-...\"]`, relativePath));
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
for (const entry of rawValue) {
|
|
854
|
+
if (typeof entry !== "string") {
|
|
855
|
+
issues.push(makeIssue("error", "metadata", `Metadata '${category.key}' entries must be strings.`, relativePath));
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
const id = entry.trim();
|
|
859
|
+
if (!category.idPattern.test(id)) {
|
|
860
|
+
issues.push(makeIssue("error", "metadata", `Invalid ${category.key} reference '${id}'. Expected pattern '${category.idPattern.source}'.`, relativePath));
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
ids.add(id);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
return { ids: [...ids], issues };
|
|
867
|
+
}
|
|
868
|
+
function findInlineSpineIdMentions(body, relativePath, inlineIdRegex) {
|
|
869
|
+
const issues = [];
|
|
870
|
+
if (!inlineIdRegex) {
|
|
871
|
+
return issues;
|
|
872
|
+
}
|
|
873
|
+
const lines = body.split(/\r?\n/);
|
|
874
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
875
|
+
const matches = lines[index].match(inlineIdRegex);
|
|
876
|
+
if (!matches) {
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
for (const id of matches) {
|
|
880
|
+
issues.push(makeIssue("error", "continuity", `Inline ID '${id}' found in prose. Move canon IDs to metadata fields only.`, relativePath, index + 1));
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
return issues;
|
|
884
|
+
}
|
|
885
|
+
function parseMetadata(raw, chapterPath, required) {
|
|
886
|
+
const relativePath = path.relative(repoRoot, chapterPath);
|
|
887
|
+
const issues = [];
|
|
888
|
+
if (!raw.startsWith("---\n") && !raw.startsWith("---\r\n")) {
|
|
889
|
+
const commentsResult = parseStegoCommentsAppendix(raw, relativePath, 1);
|
|
890
|
+
if (!required) {
|
|
891
|
+
return {
|
|
892
|
+
metadata: {},
|
|
893
|
+
body: commentsResult.bodyWithoutComments,
|
|
894
|
+
comments: commentsResult.comments,
|
|
895
|
+
issues: commentsResult.issues
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
return {
|
|
899
|
+
metadata: {},
|
|
900
|
+
body: commentsResult.bodyWithoutComments,
|
|
901
|
+
comments: commentsResult.comments,
|
|
902
|
+
issues: [
|
|
903
|
+
makeIssue("error", "metadata", "Missing metadata block at top of file.", relativePath),
|
|
904
|
+
...commentsResult.issues
|
|
905
|
+
]
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
909
|
+
if (!match) {
|
|
910
|
+
return {
|
|
911
|
+
metadata: {},
|
|
912
|
+
body: raw,
|
|
913
|
+
comments: [],
|
|
914
|
+
issues: [makeIssue("error", "metadata", "Metadata opening delimiter found, but closing delimiter is missing.", relativePath)]
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
const metadataText = match[1];
|
|
918
|
+
const body = raw.slice(match[0].length);
|
|
919
|
+
const metadata = {};
|
|
920
|
+
const lines = metadataText.split(/\r?\n/);
|
|
921
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
922
|
+
const line = lines[i].trim();
|
|
923
|
+
if (!line || line.startsWith("#")) {
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
const separatorIndex = line.indexOf(":");
|
|
927
|
+
if (separatorIndex === -1) {
|
|
928
|
+
issues.push(makeIssue("error", "metadata", `Invalid metadata line '${line}'. Expected 'key: value' format.`, relativePath, i + 1));
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
const key = line.slice(0, separatorIndex).trim();
|
|
932
|
+
const value = line.slice(separatorIndex + 1).trim();
|
|
933
|
+
if (!value) {
|
|
934
|
+
let lookahead = i + 1;
|
|
935
|
+
while (lookahead < lines.length) {
|
|
936
|
+
const nextTrimmed = lines[lookahead].trim();
|
|
937
|
+
if (!nextTrimmed || nextTrimmed.startsWith("#")) {
|
|
938
|
+
lookahead += 1;
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
break;
|
|
942
|
+
}
|
|
943
|
+
if (lookahead < lines.length) {
|
|
944
|
+
const firstValueLine = lines[lookahead];
|
|
945
|
+
const firstValueTrimmed = firstValueLine.trim();
|
|
946
|
+
const firstValueIndent = firstValueLine.length - firstValueLine.trimStart().length;
|
|
947
|
+
if (firstValueIndent > 0 && firstValueTrimmed.startsWith("- ")) {
|
|
948
|
+
const items = [];
|
|
949
|
+
let j = lookahead;
|
|
950
|
+
while (j < lines.length) {
|
|
951
|
+
const candidateRaw = lines[j];
|
|
952
|
+
const candidateTrimmed = candidateRaw.trim();
|
|
953
|
+
if (!candidateTrimmed || candidateTrimmed.startsWith("#")) {
|
|
954
|
+
j += 1;
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
const indent = candidateRaw.length - candidateRaw.trimStart().length;
|
|
958
|
+
if (indent === 0) {
|
|
959
|
+
break;
|
|
960
|
+
}
|
|
961
|
+
if (!candidateTrimmed.startsWith("- ")) {
|
|
962
|
+
issues.push(makeIssue("error", "metadata", `Unsupported metadata list line '${candidateTrimmed}'. Expected '- value'.`, relativePath, j + 1));
|
|
963
|
+
j += 1;
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
const itemValue = candidateTrimmed.slice(2).trim().replace(/^['"]|['"]$/g, "");
|
|
967
|
+
items.push(itemValue);
|
|
968
|
+
j += 1;
|
|
969
|
+
}
|
|
970
|
+
metadata[key] = items;
|
|
971
|
+
i = j - 1;
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
metadata[key] = coerceMetadataValue(value);
|
|
977
|
+
}
|
|
978
|
+
const bodyStartLine = match[0].split(/\r?\n/).length;
|
|
979
|
+
const commentsResult = parseStegoCommentsAppendix(body, relativePath, bodyStartLine);
|
|
980
|
+
issues.push(...commentsResult.issues);
|
|
981
|
+
return {
|
|
982
|
+
metadata,
|
|
983
|
+
body: commentsResult.bodyWithoutComments,
|
|
984
|
+
comments: commentsResult.comments,
|
|
985
|
+
issues
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
function parseStegoCommentsAppendix(body, relativePath, bodyStartLine) {
|
|
989
|
+
const lineEnding = body.includes("\r\n") ? "\r\n" : "\n";
|
|
990
|
+
const lines = body.split(/\r?\n/);
|
|
991
|
+
const startMarker = "<!-- stego-comments:start -->";
|
|
992
|
+
const endMarker = "<!-- stego-comments:end -->";
|
|
993
|
+
const issues = [];
|
|
994
|
+
const startIndexes = findTrimmedLineIndexes(lines, startMarker);
|
|
995
|
+
const endIndexes = findTrimmedLineIndexes(lines, endMarker);
|
|
996
|
+
if (startIndexes.length === 0 && endIndexes.length === 0) {
|
|
997
|
+
return { bodyWithoutComments: body, comments: [], issues };
|
|
998
|
+
}
|
|
999
|
+
if (startIndexes.length !== 1 || endIndexes.length !== 1) {
|
|
1000
|
+
if (startIndexes.length !== 1) {
|
|
1001
|
+
issues.push(makeIssue("error", "comments", `Expected exactly one '${startMarker}' marker.`, relativePath));
|
|
1002
|
+
}
|
|
1003
|
+
if (endIndexes.length !== 1) {
|
|
1004
|
+
issues.push(makeIssue("error", "comments", `Expected exactly one '${endMarker}' marker.`, relativePath));
|
|
1005
|
+
}
|
|
1006
|
+
return { bodyWithoutComments: body, comments: [], issues };
|
|
1007
|
+
}
|
|
1008
|
+
const start = startIndexes[0];
|
|
1009
|
+
const end = endIndexes[0];
|
|
1010
|
+
if (end <= start) {
|
|
1011
|
+
issues.push(makeIssue("error", "comments", `'${endMarker}' must appear after '${startMarker}'.`, relativePath, bodyStartLine + end));
|
|
1012
|
+
return { bodyWithoutComments: body, comments: [], issues };
|
|
1013
|
+
}
|
|
1014
|
+
const blockLines = lines.slice(start + 1, end);
|
|
1015
|
+
const comments = parseStegoCommentThreads(blockLines, relativePath, bodyStartLine + start + 1, issues);
|
|
1016
|
+
let removeStart = start;
|
|
1017
|
+
if (removeStart > 0 && lines[removeStart - 1].trim().length === 0) {
|
|
1018
|
+
removeStart -= 1;
|
|
1019
|
+
}
|
|
1020
|
+
const kept = [...lines.slice(0, removeStart), ...lines.slice(end + 1)];
|
|
1021
|
+
while (kept.length > 0 && kept[kept.length - 1].trim().length === 0) {
|
|
1022
|
+
kept.pop();
|
|
1023
|
+
}
|
|
1024
|
+
return {
|
|
1025
|
+
bodyWithoutComments: kept.join(lineEnding),
|
|
1026
|
+
comments,
|
|
1027
|
+
issues
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
function parseStegoCommentThreads(lines, relativePath, baseLine, issues) {
|
|
1031
|
+
const comments = [];
|
|
1032
|
+
let index = 0;
|
|
1033
|
+
while (index < lines.length) {
|
|
1034
|
+
const trimmed = lines[index].trim();
|
|
1035
|
+
if (!trimmed) {
|
|
1036
|
+
index += 1;
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
const headingMatch = trimmed.match(/^###\s+(CMT-\d{4})\s*$/);
|
|
1040
|
+
if (!headingMatch) {
|
|
1041
|
+
issues.push(makeIssue("error", "comments", "Invalid comments appendix line. Expected heading like '### CMT-0001'.", relativePath, baseLine + index));
|
|
1042
|
+
index += 1;
|
|
1043
|
+
continue;
|
|
1044
|
+
}
|
|
1045
|
+
const id = headingMatch[1];
|
|
1046
|
+
index += 1;
|
|
1047
|
+
const rowLines = [];
|
|
1048
|
+
const rowLineNumbers = [];
|
|
1049
|
+
while (index < lines.length) {
|
|
1050
|
+
const nextTrimmed = lines[index].trim();
|
|
1051
|
+
if (/^###\s+CMT-\d{4}\s*$/.test(nextTrimmed)) {
|
|
1052
|
+
break;
|
|
1053
|
+
}
|
|
1054
|
+
rowLines.push(lines[index]);
|
|
1055
|
+
rowLineNumbers.push(baseLine + index);
|
|
1056
|
+
index += 1;
|
|
1057
|
+
}
|
|
1058
|
+
let resolved;
|
|
1059
|
+
let sawMeta64 = false;
|
|
1060
|
+
const thread = [];
|
|
1061
|
+
let rowIndex = 0;
|
|
1062
|
+
while (rowIndex < rowLines.length) {
|
|
1063
|
+
const rawRow = rowLines[rowIndex];
|
|
1064
|
+
const lineNumber = rowLineNumbers[rowIndex];
|
|
1065
|
+
const trimmedRow = rawRow.trim();
|
|
1066
|
+
if (!trimmedRow) {
|
|
1067
|
+
rowIndex += 1;
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
if (thread.length > 0) {
|
|
1071
|
+
issues.push(makeIssue("error", "comments", `Multiple message blocks found for ${id}. Create a new CMT id for each reply.`, relativePath, lineNumber));
|
|
1072
|
+
break;
|
|
1073
|
+
}
|
|
1074
|
+
if (!sawMeta64) {
|
|
1075
|
+
const metaMatch = trimmedRow.match(/^<!--\s*meta64:\s*(\S+)\s*-->\s*$/);
|
|
1076
|
+
if (!metaMatch) {
|
|
1077
|
+
issues.push(makeIssue("error", "comments", `Invalid comment metadata row '${trimmedRow}'. Expected '<!-- meta64: <base64url-json> -->'.`, relativePath, lineNumber));
|
|
1078
|
+
rowIndex += 1;
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
sawMeta64 = true;
|
|
1082
|
+
const decoded = decodeCommentMeta64(metaMatch[1], id, relativePath, lineNumber, issues);
|
|
1083
|
+
if (decoded) {
|
|
1084
|
+
resolved = decoded.resolved;
|
|
1085
|
+
}
|
|
1086
|
+
rowIndex += 1;
|
|
1087
|
+
continue;
|
|
1088
|
+
}
|
|
1089
|
+
const headerQuote = extractQuotedLine(rawRow);
|
|
1090
|
+
if (headerQuote === undefined) {
|
|
1091
|
+
issues.push(makeIssue("error", "comments", `Invalid thread header '${trimmedRow}'. Expected blockquote header like '> _timestamp | author_'.`, relativePath, lineNumber));
|
|
1092
|
+
rowIndex += 1;
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
const header = parseThreadHeader(headerQuote);
|
|
1096
|
+
if (!header) {
|
|
1097
|
+
issues.push(makeIssue("error", "comments", `Invalid thread header '${headerQuote.trim()}'. Expected '> _timestamp | author_'.`, relativePath, lineNumber));
|
|
1098
|
+
rowIndex += 1;
|
|
1099
|
+
continue;
|
|
1100
|
+
}
|
|
1101
|
+
rowIndex += 1;
|
|
1102
|
+
while (rowIndex < rowLines.length) {
|
|
1103
|
+
const separatorRaw = rowLines[rowIndex];
|
|
1104
|
+
const separatorTrimmed = separatorRaw.trim();
|
|
1105
|
+
if (!separatorTrimmed) {
|
|
1106
|
+
rowIndex += 1;
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
const separatorQuote = extractQuotedLine(separatorRaw);
|
|
1110
|
+
if (separatorQuote !== undefined && separatorQuote.trim().length === 0) {
|
|
1111
|
+
rowIndex += 1;
|
|
1112
|
+
}
|
|
1113
|
+
break;
|
|
1114
|
+
}
|
|
1115
|
+
const messageLines = [];
|
|
1116
|
+
while (rowIndex < rowLines.length) {
|
|
1117
|
+
const messageRaw = rowLines[rowIndex];
|
|
1118
|
+
const messageLineNumber = rowLineNumbers[rowIndex];
|
|
1119
|
+
const messageTrimmed = messageRaw.trim();
|
|
1120
|
+
if (!messageTrimmed) {
|
|
1121
|
+
rowIndex += 1;
|
|
1122
|
+
if (messageLines.length > 0) {
|
|
1123
|
+
break;
|
|
1124
|
+
}
|
|
1125
|
+
continue;
|
|
1126
|
+
}
|
|
1127
|
+
const messageQuote = extractQuotedLine(messageRaw);
|
|
1128
|
+
if (messageQuote === undefined) {
|
|
1129
|
+
issues.push(makeIssue("error", "comments", `Invalid thread line '${messageTrimmed}'. Expected blockquote content starting with '>'.`, relativePath, messageLineNumber));
|
|
1130
|
+
rowIndex += 1;
|
|
1131
|
+
if (messageLines.length > 0) {
|
|
1132
|
+
break;
|
|
1133
|
+
}
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1136
|
+
if (parseThreadHeader(messageQuote)) {
|
|
1137
|
+
break;
|
|
1138
|
+
}
|
|
1139
|
+
messageLines.push(messageQuote);
|
|
1140
|
+
rowIndex += 1;
|
|
1141
|
+
}
|
|
1142
|
+
while (messageLines.length > 0 && messageLines[messageLines.length - 1].trim().length === 0) {
|
|
1143
|
+
messageLines.pop();
|
|
1144
|
+
}
|
|
1145
|
+
if (messageLines.length === 0) {
|
|
1146
|
+
issues.push(makeIssue("error", "comments", `Thread entry for comment ${id} is missing message text.`, relativePath, lineNumber));
|
|
1147
|
+
continue;
|
|
1148
|
+
}
|
|
1149
|
+
const message = messageLines.join("\n").trim();
|
|
1150
|
+
thread.push(`${header.timestamp} | ${header.author} | ${message}`);
|
|
1151
|
+
}
|
|
1152
|
+
if (!sawMeta64) {
|
|
1153
|
+
issues.push(makeIssue("error", "comments", `Comment ${id} is missing metadata row ('<!-- meta64: <base64url-json> -->').`, relativePath));
|
|
1154
|
+
resolved = false;
|
|
1155
|
+
}
|
|
1156
|
+
if (thread.length === 0) {
|
|
1157
|
+
issues.push(makeIssue("error", "comments", `Comment ${id} is missing valid blockquote thread entries.`, relativePath));
|
|
1158
|
+
}
|
|
1159
|
+
comments.push({ id, resolved: Boolean(resolved), thread });
|
|
1160
|
+
}
|
|
1161
|
+
return comments;
|
|
1162
|
+
}
|
|
1163
|
+
function decodeCommentMeta64(encoded, commentId, relativePath, lineNumber, issues) {
|
|
1164
|
+
let rawJson = "";
|
|
1165
|
+
try {
|
|
1166
|
+
rawJson = Buffer.from(encoded, "base64url").toString("utf8");
|
|
1167
|
+
}
|
|
1168
|
+
catch {
|
|
1169
|
+
issues.push(makeIssue("error", "comments", `Invalid meta64 payload for comment ${commentId}; expected base64url-encoded JSON.`, relativePath, lineNumber));
|
|
1170
|
+
return undefined;
|
|
1171
|
+
}
|
|
1172
|
+
let parsed;
|
|
1173
|
+
try {
|
|
1174
|
+
parsed = JSON.parse(rawJson);
|
|
1175
|
+
}
|
|
1176
|
+
catch {
|
|
1177
|
+
issues.push(makeIssue("error", "comments", `Invalid meta64 JSON for comment ${commentId}.`, relativePath, lineNumber));
|
|
1178
|
+
return undefined;
|
|
1179
|
+
}
|
|
1180
|
+
if (!isPlainObject(parsed)) {
|
|
1181
|
+
issues.push(makeIssue("error", "comments", `Invalid meta64 object for comment ${commentId}.`, relativePath, lineNumber));
|
|
1182
|
+
return undefined;
|
|
1183
|
+
}
|
|
1184
|
+
const allowedKeys = new Set(["status", "anchor", "paragraph_index", "signature", "excerpt"]);
|
|
1185
|
+
for (const key of Object.keys(parsed)) {
|
|
1186
|
+
if (!allowedKeys.has(key)) {
|
|
1187
|
+
issues.push(makeIssue("error", "comments", `meta64 for comment ${commentId} contains unsupported key '${key}'.`, relativePath, lineNumber));
|
|
1188
|
+
return undefined;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
const status = typeof parsed.status === "string" ? parsed.status.trim().toLowerCase() : "";
|
|
1192
|
+
if (status !== "open" && status !== "resolved") {
|
|
1193
|
+
issues.push(makeIssue("error", "comments", `meta64 for comment ${commentId} must include status 'open' or 'resolved'.`, relativePath, lineNumber));
|
|
1194
|
+
return undefined;
|
|
1195
|
+
}
|
|
1196
|
+
return { resolved: status === "resolved" };
|
|
1197
|
+
}
|
|
1198
|
+
function extractQuotedLine(raw) {
|
|
1199
|
+
const quoteMatch = raw.match(/^\s*>\s?(.*)$/);
|
|
1200
|
+
if (!quoteMatch) {
|
|
1201
|
+
return undefined;
|
|
1202
|
+
}
|
|
1203
|
+
return quoteMatch[1];
|
|
1204
|
+
}
|
|
1205
|
+
function parseThreadHeader(value) {
|
|
1206
|
+
const match = value.trim().match(/^_(.+?)\s*\|\s*(.+?)_\s*$/);
|
|
1207
|
+
if (!match) {
|
|
1208
|
+
return undefined;
|
|
1209
|
+
}
|
|
1210
|
+
const timestamp = match[1].trim();
|
|
1211
|
+
const author = match[2].trim();
|
|
1212
|
+
if (!timestamp || !author) {
|
|
1213
|
+
return undefined;
|
|
1214
|
+
}
|
|
1215
|
+
return { timestamp, author };
|
|
1216
|
+
}
|
|
1217
|
+
function findTrimmedLineIndexes(lines, marker) {
|
|
1218
|
+
const indexes = [];
|
|
1219
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1220
|
+
if (lines[index].trim() === marker) {
|
|
1221
|
+
indexes.push(index);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
return indexes;
|
|
1225
|
+
}
|
|
1226
|
+
function coerceMetadataValue(value) {
|
|
1227
|
+
if (!value) {
|
|
1228
|
+
return "";
|
|
1229
|
+
}
|
|
1230
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
1231
|
+
return value.slice(1, -1);
|
|
1232
|
+
}
|
|
1233
|
+
if (value.startsWith("[") && value.endsWith("]")) {
|
|
1234
|
+
const inner = value.slice(1, -1).trim();
|
|
1235
|
+
if (!inner) {
|
|
1236
|
+
return [];
|
|
1237
|
+
}
|
|
1238
|
+
return inner.split(",").map((entry) => entry.trim().replace(/^['\"]|['\"]$/g, ""));
|
|
1239
|
+
}
|
|
1240
|
+
if (/^-?\d+$/.test(value)) {
|
|
1241
|
+
return Number(value);
|
|
1242
|
+
}
|
|
1243
|
+
if (value === "true") {
|
|
1244
|
+
return true;
|
|
1245
|
+
}
|
|
1246
|
+
if (value === "false") {
|
|
1247
|
+
return false;
|
|
1248
|
+
}
|
|
1249
|
+
return value;
|
|
1250
|
+
}
|
|
1251
|
+
function validateMarkdownBody(body, chapterPath) {
|
|
1252
|
+
const relativePath = path.relative(repoRoot, chapterPath);
|
|
1253
|
+
const issues = [];
|
|
1254
|
+
const lines = body.split(/\r?\n/);
|
|
1255
|
+
let openFence = null;
|
|
1256
|
+
let previousHeadingLevel = 0;
|
|
1257
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
1258
|
+
const line = lines[i];
|
|
1259
|
+
const fenceMatch = line.match(/^(```+|~~~+)/);
|
|
1260
|
+
if (fenceMatch) {
|
|
1261
|
+
const marker = fenceMatch[1][0];
|
|
1262
|
+
const length = fenceMatch[1].length;
|
|
1263
|
+
if (!openFence) {
|
|
1264
|
+
openFence = { marker, length, line: i + 1 };
|
|
1265
|
+
}
|
|
1266
|
+
else if (openFence.marker === marker && length >= openFence.length) {
|
|
1267
|
+
openFence = null;
|
|
1268
|
+
}
|
|
1269
|
+
continue;
|
|
1270
|
+
}
|
|
1271
|
+
const headingMatch = line.match(/^(#{1,6})\s+/);
|
|
1272
|
+
if (headingMatch) {
|
|
1273
|
+
const level = headingMatch[1].length;
|
|
1274
|
+
if (previousHeadingLevel > 0 && level > previousHeadingLevel + 1) {
|
|
1275
|
+
issues.push(makeIssue("warning", "style", `Heading level jumps from H${previousHeadingLevel} to H${level}.`, relativePath, i + 1));
|
|
1276
|
+
}
|
|
1277
|
+
previousHeadingLevel = level;
|
|
1278
|
+
}
|
|
1279
|
+
if (/\[[^\]]+\]\([^\)]*$/.test(line.trim())) {
|
|
1280
|
+
issues.push(makeIssue("error", "structure", "Malformed markdown link, missing closing ')'.", relativePath, i + 1));
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
if (openFence) {
|
|
1284
|
+
issues.push(makeIssue("error", "structure", `Unclosed code fence opened at line ${openFence.line}.`, relativePath, openFence.line));
|
|
1285
|
+
}
|
|
1286
|
+
issues.push(...checkLocalMarkdownLinks(body, chapterPath));
|
|
1287
|
+
issues.push(...runStyleHeuristics(body, relativePath));
|
|
1288
|
+
return issues;
|
|
1289
|
+
}
|
|
1290
|
+
function checkLocalMarkdownLinks(body, chapterPath) {
|
|
1291
|
+
const relativePath = path.relative(repoRoot, chapterPath);
|
|
1292
|
+
const issues = [];
|
|
1293
|
+
const linkRegex = /!?\[[^\]]*\]\(([^)]+)\)/g;
|
|
1294
|
+
let match;
|
|
1295
|
+
while ((match = linkRegex.exec(body)) !== null) {
|
|
1296
|
+
let target = match[1].trim();
|
|
1297
|
+
if (!target) {
|
|
1298
|
+
continue;
|
|
1299
|
+
}
|
|
1300
|
+
if (target.startsWith("<") && target.endsWith(">")) {
|
|
1301
|
+
target = target.slice(1, -1).trim();
|
|
1302
|
+
}
|
|
1303
|
+
target = target.split(/\s+"/)[0].split(/\s+'/)[0].trim();
|
|
1304
|
+
if (isExternalTarget(target) || target.startsWith("#")) {
|
|
1305
|
+
continue;
|
|
1306
|
+
}
|
|
1307
|
+
const cleanTarget = target.split("#")[0];
|
|
1308
|
+
if (!cleanTarget) {
|
|
1309
|
+
continue;
|
|
1310
|
+
}
|
|
1311
|
+
const resolved = path.resolve(path.dirname(chapterPath), cleanTarget);
|
|
1312
|
+
if (!fs.existsSync(resolved)) {
|
|
1313
|
+
issues.push(makeIssue("warning", "links", `Broken local link/image target '${cleanTarget}'.`, relativePath));
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
return issues;
|
|
1317
|
+
}
|
|
1318
|
+
function isExternalTarget(target) {
|
|
1319
|
+
return (target.startsWith("http://") ||
|
|
1320
|
+
target.startsWith("https://") ||
|
|
1321
|
+
target.startsWith("mailto:") ||
|
|
1322
|
+
target.startsWith("tel:"));
|
|
1323
|
+
}
|
|
1324
|
+
function runStyleHeuristics(body, relativePath) {
|
|
1325
|
+
const issues = [];
|
|
1326
|
+
const prose = body
|
|
1327
|
+
.replace(/```[\s\S]*?```/g, "")
|
|
1328
|
+
.replace(/~~~[\s\S]*?~~~/g, "");
|
|
1329
|
+
const paragraphs = prose
|
|
1330
|
+
.split(/\n\s*\n/)
|
|
1331
|
+
.map((paragraph) => paragraph.trim())
|
|
1332
|
+
.filter((paragraph) => paragraph.length > 0)
|
|
1333
|
+
.filter((paragraph) => !paragraph.startsWith("#"))
|
|
1334
|
+
.filter((paragraph) => !paragraph.startsWith("- "));
|
|
1335
|
+
for (const paragraph of paragraphs) {
|
|
1336
|
+
const words = countWords(paragraph);
|
|
1337
|
+
if (words > 180) {
|
|
1338
|
+
issues.push(makeIssue("warning", "style", `Long paragraph detected (${words} words).`, relativePath));
|
|
1339
|
+
}
|
|
1340
|
+
const sentences = paragraph.split(/[.!?]+\s+/).map((sentence) => sentence.trim()).filter(Boolean);
|
|
1341
|
+
for (const sentence of sentences) {
|
|
1342
|
+
const sentenceWords = countWords(sentence);
|
|
1343
|
+
if (sentenceWords > 45) {
|
|
1344
|
+
issues.push(makeIssue("warning", "style", `Long sentence detected (${sentenceWords} words).`, relativePath));
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
return issues;
|
|
1349
|
+
}
|
|
1350
|
+
function countWords(text) {
|
|
1351
|
+
return text.trim().split(/\s+/).filter(Boolean).length;
|
|
1352
|
+
}
|
|
1353
|
+
function readSpine(spineDir, spineCategories, inlineIdRegex) {
|
|
1354
|
+
const issues = [];
|
|
1355
|
+
const ids = new Set();
|
|
1356
|
+
if (spineCategories.length === 0) {
|
|
1357
|
+
return { ids, issues };
|
|
1358
|
+
}
|
|
1359
|
+
if (!fs.existsSync(spineDir)) {
|
|
1360
|
+
issues.push(makeIssue("warning", "continuity", `Missing spine directory: ${spineDir}`));
|
|
1361
|
+
return { ids, issues };
|
|
1362
|
+
}
|
|
1363
|
+
for (const category of spineCategories) {
|
|
1364
|
+
const fullPath = path.join(spineDir, category.notesFile);
|
|
1365
|
+
const relativePath = path.relative(repoRoot, fullPath);
|
|
1366
|
+
if (!fs.existsSync(fullPath)) {
|
|
1367
|
+
issues.push(makeIssue("warning", "continuity", `Missing spine file '${category.notesFile}' for category '${category.key}'.`, relativePath));
|
|
1368
|
+
continue;
|
|
1369
|
+
}
|
|
1370
|
+
const text = fs.readFileSync(fullPath, "utf8");
|
|
1371
|
+
if (!inlineIdRegex) {
|
|
1372
|
+
continue;
|
|
1373
|
+
}
|
|
1374
|
+
const matches = text.match(inlineIdRegex) || [];
|
|
1375
|
+
for (const id of matches) {
|
|
1376
|
+
ids.add(id);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
return { ids, issues };
|
|
1380
|
+
}
|
|
1381
|
+
function findUnknownSpineIds(referenceIds, knownIds, relativePath) {
|
|
1382
|
+
const issues = [];
|
|
1383
|
+
for (const id of referenceIds) {
|
|
1384
|
+
if (!knownIds.has(id)) {
|
|
1385
|
+
issues.push(makeIssue("warning", "continuity", `Metadata reference '${id}' does not exist in the spine files.`, relativePath));
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
return issues;
|
|
1389
|
+
}
|
|
1390
|
+
function runStageCheck(project, runtimeConfig, stage, onlyFile) {
|
|
1391
|
+
if (!isStageName(stage)) {
|
|
1392
|
+
throw new Error(`Unknown stage '${stage}'. Allowed: ${Object.keys(runtimeConfig.stagePolicies).join(", ")}.`);
|
|
1393
|
+
}
|
|
1394
|
+
const policy = runtimeConfig.stagePolicies[stage];
|
|
1395
|
+
const report = inspectProject(project, runtimeConfig, { onlyFile });
|
|
1396
|
+
const issues = [...report.issues];
|
|
1397
|
+
const minimumRank = STATUS_RANK[policy.minimumChapterStatus];
|
|
1398
|
+
for (const chapter of report.chapters) {
|
|
1399
|
+
if (!isStageName(chapter.status)) {
|
|
1400
|
+
continue;
|
|
1401
|
+
}
|
|
1402
|
+
const chapterRank = STATUS_RANK[chapter.status];
|
|
1403
|
+
if (chapterRank == null) {
|
|
1404
|
+
continue;
|
|
1405
|
+
}
|
|
1406
|
+
if (chapterRank < minimumRank) {
|
|
1407
|
+
issues.push(makeIssue("error", "stage", `File status '${chapter.status}' is below required stage '${policy.minimumChapterStatus}'.`, chapter.relativePath));
|
|
1408
|
+
}
|
|
1409
|
+
if (stage === "final" && chapter.status !== "final") {
|
|
1410
|
+
issues.push(makeIssue("error", "stage", "Final stage requires all chapters to be status 'final'.", chapter.relativePath));
|
|
1411
|
+
}
|
|
1412
|
+
if (policy.requireResolvedComments) {
|
|
1413
|
+
const unresolvedComments = chapter.comments.filter((comment) => !comment.resolved);
|
|
1414
|
+
if (unresolvedComments.length > 0) {
|
|
1415
|
+
const unresolvedLabel = unresolvedComments.slice(0, 5).map((comment) => comment.id).join(", ");
|
|
1416
|
+
const remainder = unresolvedComments.length > 5 ? ` (+${unresolvedComments.length - 5} more)` : "";
|
|
1417
|
+
issues.push(makeIssue("error", "comments", `Unresolved comments (${unresolvedComments.length}): ${unresolvedLabel}${remainder}. Resolve or clear comments before stage '${stage}'.`, chapter.relativePath));
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
if (policy.requireSpine) {
|
|
1422
|
+
for (const spineIssue of report.issues.filter((issue) => issue.category === "continuity")) {
|
|
1423
|
+
if (spineIssue.message.startsWith("Missing spine file")) {
|
|
1424
|
+
issues.push({ ...spineIssue, level: "error" });
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
if (policy.enforceLocalLinks) {
|
|
1429
|
+
for (const linkIssue of issues.filter((issue) => issue.category === "links" && issue.level !== "error")) {
|
|
1430
|
+
linkIssue.level = "error";
|
|
1431
|
+
linkIssue.message = `${linkIssue.message} (strict in stage '${stage}')`;
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
const chapterPaths = report.chapters.map((chapter) => chapter.path);
|
|
1435
|
+
const spineWords = collectSpineWordsForSpellcheck(report.spineState.ids);
|
|
1436
|
+
if (policy.enforceMarkdownlint) {
|
|
1437
|
+
issues.push(...runMarkdownlint(project, chapterPaths, true));
|
|
1438
|
+
}
|
|
1439
|
+
else {
|
|
1440
|
+
issues.push(...runMarkdownlint(project, chapterPaths, false));
|
|
1441
|
+
}
|
|
1442
|
+
if (policy.enforceCSpell) {
|
|
1443
|
+
issues.push(...runCSpell(chapterPaths, true, spineWords));
|
|
1444
|
+
}
|
|
1445
|
+
else {
|
|
1446
|
+
issues.push(...runCSpell(chapterPaths, false, spineWords));
|
|
1447
|
+
}
|
|
1448
|
+
return { chapters: report.chapters, issues };
|
|
1449
|
+
}
|
|
1450
|
+
function runMarkdownlint(project, files, required) {
|
|
1451
|
+
const markdownlintCommand = resolveCommand("markdownlint");
|
|
1452
|
+
if (!markdownlintCommand) {
|
|
1453
|
+
if (required) {
|
|
1454
|
+
return [
|
|
1455
|
+
makeIssue("error", "tooling", "markdownlint is required for this stage but not installed. Run 'npm i' in the repo root.")
|
|
1456
|
+
];
|
|
1457
|
+
}
|
|
1458
|
+
return [];
|
|
1459
|
+
}
|
|
1460
|
+
const projectConfigPath = path.join(project.root, ".markdownlint.json");
|
|
1461
|
+
const markdownlintConfigPath = fs.existsSync(projectConfigPath)
|
|
1462
|
+
? projectConfigPath
|
|
1463
|
+
: path.join(repoRoot, ".markdownlint.json");
|
|
1464
|
+
const prepared = prepareFilesWithoutComments(files);
|
|
1465
|
+
try {
|
|
1466
|
+
const result = spawnSync(markdownlintCommand, ["--config", markdownlintConfigPath, ...prepared.files], {
|
|
1467
|
+
cwd: repoRoot,
|
|
1468
|
+
encoding: "utf8"
|
|
1469
|
+
});
|
|
1470
|
+
if (result.status === 0) {
|
|
1471
|
+
return [];
|
|
1472
|
+
}
|
|
1473
|
+
const details = remapToolOutputPaths(compactToolOutput(result.stdout, result.stderr), prepared.pathMap);
|
|
1474
|
+
return [makeIssue(required ? "error" : "warning", "lint", `markdownlint reported issues. ${details}`)];
|
|
1475
|
+
}
|
|
1476
|
+
finally {
|
|
1477
|
+
prepared.cleanup();
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
function collectSpineWordsForSpellcheck(ids) {
|
|
1481
|
+
const words = new Set();
|
|
1482
|
+
for (const id of ids) {
|
|
1483
|
+
const parts = id
|
|
1484
|
+
.split("-")
|
|
1485
|
+
.map((part) => part.trim())
|
|
1486
|
+
.filter(Boolean);
|
|
1487
|
+
for (const part of parts.slice(1)) {
|
|
1488
|
+
if (!/[A-Za-z]/.test(part)) {
|
|
1489
|
+
continue;
|
|
1490
|
+
}
|
|
1491
|
+
words.add(part.toLowerCase());
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
return Array.from(words).sort();
|
|
1495
|
+
}
|
|
1496
|
+
function runCSpell(files, required, extraWords = []) {
|
|
1497
|
+
const cspellCommand = resolveCommand("cspell");
|
|
1498
|
+
if (!cspellCommand) {
|
|
1499
|
+
if (required) {
|
|
1500
|
+
return [
|
|
1501
|
+
makeIssue("error", "tooling", "cspell is required for this stage but not installed. Run 'npm i' in the repo root.")
|
|
1502
|
+
];
|
|
1503
|
+
}
|
|
1504
|
+
return [];
|
|
1505
|
+
}
|
|
1506
|
+
let tempConfigDir = null;
|
|
1507
|
+
let cspellConfigPath = path.join(repoRoot, ".cspell.json");
|
|
1508
|
+
if (extraWords.length > 0) {
|
|
1509
|
+
const baseConfig = readJson(cspellConfigPath);
|
|
1510
|
+
const existingWords = Array.isArray(baseConfig.words) ? baseConfig.words.filter((word) => typeof word === "string") : [];
|
|
1511
|
+
const mergedWords = new Set([...existingWords, ...extraWords]);
|
|
1512
|
+
tempConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "stego-cspell-"));
|
|
1513
|
+
cspellConfigPath = path.join(tempConfigDir, "cspell.generated.json");
|
|
1514
|
+
fs.writeFileSync(cspellConfigPath, `${JSON.stringify({ ...baseConfig, words: Array.from(mergedWords).sort() }, null, 2)}\n`, "utf8");
|
|
1515
|
+
}
|
|
1516
|
+
const prepared = prepareFilesWithoutComments(files);
|
|
1517
|
+
try {
|
|
1518
|
+
const result = spawnSync(cspellCommand, ["--no-progress", "--no-summary", "--config", cspellConfigPath, ...prepared.files], {
|
|
1519
|
+
cwd: repoRoot,
|
|
1520
|
+
encoding: "utf8"
|
|
1521
|
+
});
|
|
1522
|
+
if (result.status === 0) {
|
|
1523
|
+
return [];
|
|
1524
|
+
}
|
|
1525
|
+
const details = remapToolOutputPaths(compactToolOutput(result.stdout, result.stderr), prepared.pathMap);
|
|
1526
|
+
return [
|
|
1527
|
+
makeIssue(required ? "error" : "warning", "spell", `cspell reported issues. ${details} Words from spine identifiers are auto-whitelisted. For additional terms, add them to '.cspell.json' under the 'words' array.`)
|
|
1528
|
+
];
|
|
1529
|
+
}
|
|
1530
|
+
finally {
|
|
1531
|
+
prepared.cleanup();
|
|
1532
|
+
if (tempConfigDir) {
|
|
1533
|
+
fs.rmSync(tempConfigDir, { recursive: true, force: true });
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
function prepareFilesWithoutComments(files) {
|
|
1538
|
+
if (files.length === 0) {
|
|
1539
|
+
return {
|
|
1540
|
+
files,
|
|
1541
|
+
pathMap: new Map(),
|
|
1542
|
+
cleanup: () => undefined
|
|
1543
|
+
};
|
|
1544
|
+
}
|
|
1545
|
+
const tempDir = fs.mkdtempSync(path.join(repoRoot, ".stego-tooling-"));
|
|
1546
|
+
const pathMap = new Map();
|
|
1547
|
+
const preparedFiles = [];
|
|
1548
|
+
for (let index = 0; index < files.length; index += 1) {
|
|
1549
|
+
const filePath = files[index];
|
|
1550
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
1551
|
+
const relativePath = path.relative(repoRoot, filePath);
|
|
1552
|
+
const parsed = parseStegoCommentsAppendix(raw, relativePath, 1);
|
|
1553
|
+
const sanitized = parsed.bodyWithoutComments.endsWith("\n")
|
|
1554
|
+
? parsed.bodyWithoutComments
|
|
1555
|
+
: `${parsed.bodyWithoutComments}\n`;
|
|
1556
|
+
const relativeTarget = relativePath.startsWith("..")
|
|
1557
|
+
? `external/file-${index + 1}-${path.basename(filePath)}`
|
|
1558
|
+
: relativePath;
|
|
1559
|
+
const targetPath = path.join(tempDir, relativeTarget);
|
|
1560
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
1561
|
+
fs.writeFileSync(targetPath, sanitized, "utf8");
|
|
1562
|
+
preparedFiles.push(targetPath);
|
|
1563
|
+
pathMap.set(targetPath, filePath);
|
|
1564
|
+
}
|
|
1565
|
+
return {
|
|
1566
|
+
files: preparedFiles,
|
|
1567
|
+
pathMap,
|
|
1568
|
+
cleanup: () => {
|
|
1569
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
1570
|
+
}
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
function remapToolOutputPaths(output, pathMap) {
|
|
1574
|
+
if (!output || pathMap.size === 0) {
|
|
1575
|
+
return output;
|
|
1576
|
+
}
|
|
1577
|
+
let mapped = output;
|
|
1578
|
+
for (const [preparedPath, originalPath] of pathMap.entries()) {
|
|
1579
|
+
if (preparedPath === originalPath) {
|
|
1580
|
+
continue;
|
|
1581
|
+
}
|
|
1582
|
+
mapped = mapped.split(preparedPath).join(originalPath);
|
|
1583
|
+
const preparedRelative = path.relative(repoRoot, preparedPath);
|
|
1584
|
+
const originalRelative = path.relative(repoRoot, originalPath);
|
|
1585
|
+
const preparedRelativeNormalized = preparedRelative.split(path.sep).join("/");
|
|
1586
|
+
const originalRelativeNormalized = originalRelative.split(path.sep).join("/");
|
|
1587
|
+
mapped = mapped.split(preparedRelative).join(originalRelative);
|
|
1588
|
+
mapped = mapped.split(preparedRelativeNormalized).join(originalRelativeNormalized);
|
|
1589
|
+
}
|
|
1590
|
+
return mapped;
|
|
1591
|
+
}
|
|
1592
|
+
function resolveCommand(command) {
|
|
1593
|
+
const localCommandPath = path.join(repoRoot, "node_modules", ".bin", process.platform === "win32" ? `${command}.cmd` : command);
|
|
1594
|
+
if (fs.existsSync(localCommandPath)) {
|
|
1595
|
+
return localCommandPath;
|
|
1596
|
+
}
|
|
1597
|
+
return null;
|
|
1598
|
+
}
|
|
1599
|
+
function compactToolOutput(stdout, stderr) {
|
|
1600
|
+
const text = `${stdout || ""}\n${stderr || ""}`.trim();
|
|
1601
|
+
if (!text) {
|
|
1602
|
+
return "No details provided by tool.";
|
|
1603
|
+
}
|
|
1604
|
+
return text
|
|
1605
|
+
.split(/\r?\n/)
|
|
1606
|
+
.filter(Boolean)
|
|
1607
|
+
.slice(0, 4)
|
|
1608
|
+
.join(" | ");
|
|
1609
|
+
}
|
|
1610
|
+
function buildManuscript(project, chapters, compileStructureLevels) {
|
|
1611
|
+
fs.mkdirSync(project.distDir, { recursive: true });
|
|
1612
|
+
const generatedAt = new Date().toISOString();
|
|
1613
|
+
const title = project.meta.title || project.id;
|
|
1614
|
+
const subtitle = project.meta.subtitle || "";
|
|
1615
|
+
const author = project.meta.author || "";
|
|
1616
|
+
const tocEntries = [];
|
|
1617
|
+
const previousGroupValues = new Map();
|
|
1618
|
+
const previousGroupTitles = new Map();
|
|
1619
|
+
const entryHeadingLevel = Math.min(6, 2 + compileStructureLevels.length);
|
|
1620
|
+
const lines = [];
|
|
1621
|
+
lines.push(`<!-- generated: ${generatedAt} -->`);
|
|
1622
|
+
lines.push("");
|
|
1623
|
+
lines.push(`# ${title}`);
|
|
1624
|
+
lines.push("");
|
|
1625
|
+
if (subtitle) {
|
|
1626
|
+
lines.push(`_${subtitle}_`);
|
|
1627
|
+
lines.push("");
|
|
1628
|
+
}
|
|
1629
|
+
if (author) {
|
|
1630
|
+
lines.push(`Author: ${author}`);
|
|
1631
|
+
lines.push("");
|
|
1632
|
+
}
|
|
1633
|
+
lines.push(`Generated: ${generatedAt}`);
|
|
1634
|
+
lines.push("");
|
|
1635
|
+
lines.push("## Table of Contents");
|
|
1636
|
+
lines.push("");
|
|
1637
|
+
if (compileStructureLevels.length === 0) {
|
|
1638
|
+
lines.push(`- [Manuscript](#${slugify("Manuscript")})`);
|
|
1639
|
+
}
|
|
1640
|
+
lines.push("");
|
|
1641
|
+
for (let chapterIndex = 0; chapterIndex < chapters.length; chapterIndex += 1) {
|
|
1642
|
+
const entry = chapters[chapterIndex];
|
|
1643
|
+
let insertedBreakForEntry = false;
|
|
1644
|
+
const levelChanged = [];
|
|
1645
|
+
for (let levelIndex = 0; levelIndex < compileStructureLevels.length; levelIndex += 1) {
|
|
1646
|
+
const level = compileStructureLevels[levelIndex];
|
|
1647
|
+
const explicitValue = entry.groupValues[level.key];
|
|
1648
|
+
const previousValue = previousGroupValues.get(level.key);
|
|
1649
|
+
const currentValue = explicitValue ?? previousValue;
|
|
1650
|
+
const explicitTitle = level.titleKey ? toScalarMetadataString(entry.metadata[level.titleKey]) : undefined;
|
|
1651
|
+
const previousTitle = previousGroupTitles.get(level.key);
|
|
1652
|
+
const currentTitle = explicitTitle ?? previousTitle;
|
|
1653
|
+
const parentChanged = levelIndex > 0 && levelChanged[levelIndex - 1] === true;
|
|
1654
|
+
const changed = parentChanged || currentValue !== previousValue;
|
|
1655
|
+
levelChanged.push(changed);
|
|
1656
|
+
if (!changed || !currentValue) {
|
|
1657
|
+
previousGroupValues.set(level.key, currentValue);
|
|
1658
|
+
previousGroupTitles.set(level.key, currentTitle);
|
|
1659
|
+
continue;
|
|
1660
|
+
}
|
|
1661
|
+
if (level.pageBreak === "between-groups" && chapterIndex > 0 && !insertedBreakForEntry) {
|
|
1662
|
+
lines.push("\\newpage");
|
|
1663
|
+
lines.push("");
|
|
1664
|
+
insertedBreakForEntry = true;
|
|
1665
|
+
}
|
|
1666
|
+
if (level.injectHeading) {
|
|
1667
|
+
const heading = formatCompileStructureHeading(level, currentValue, currentTitle);
|
|
1668
|
+
tocEntries.push({ level: levelIndex, heading });
|
|
1669
|
+
const headingLevel = Math.min(6, 2 + levelIndex);
|
|
1670
|
+
lines.push(`${"#".repeat(headingLevel)} ${heading}`);
|
|
1671
|
+
lines.push("");
|
|
1672
|
+
}
|
|
1673
|
+
previousGroupValues.set(level.key, currentValue);
|
|
1674
|
+
previousGroupTitles.set(level.key, currentTitle);
|
|
1675
|
+
}
|
|
1676
|
+
lines.push(`${"#".repeat(entryHeadingLevel)} ${entry.title}`);
|
|
1677
|
+
lines.push("");
|
|
1678
|
+
lines.push(`<!-- source: ${entry.relativePath} | order: ${entry.order} | status: ${entry.status} -->`);
|
|
1679
|
+
lines.push("");
|
|
1680
|
+
lines.push(entry.body.trim());
|
|
1681
|
+
lines.push("");
|
|
1682
|
+
}
|
|
1683
|
+
if (tocEntries.length > 0) {
|
|
1684
|
+
const tocStart = lines.indexOf("## Table of Contents");
|
|
1685
|
+
if (tocStart >= 0) {
|
|
1686
|
+
const insertAt = tocStart + 2;
|
|
1687
|
+
const tocLines = tocEntries.map((entry) => `${" ".repeat(entry.level)}- [${entry.heading}](#${slugify(entry.heading)})`);
|
|
1688
|
+
lines.splice(insertAt, 0, ...tocLines);
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
const outputPath = path.join(project.distDir, `${project.id}.md`);
|
|
1692
|
+
fs.writeFileSync(outputPath, `${lines.join("\n")}\n`, "utf8");
|
|
1693
|
+
return outputPath;
|
|
1694
|
+
}
|
|
1695
|
+
function formatCompileStructureHeading(level, value, title) {
|
|
1696
|
+
const resolvedTitle = title || "";
|
|
1697
|
+
if (!resolvedTitle && level.headingTemplate === "{label} {value}: {title}") {
|
|
1698
|
+
return `${level.label} ${value}`;
|
|
1699
|
+
}
|
|
1700
|
+
return level.headingTemplate
|
|
1701
|
+
.replaceAll("{label}", level.label)
|
|
1702
|
+
.replaceAll("{value}", value)
|
|
1703
|
+
.replaceAll("{title}", resolvedTitle)
|
|
1704
|
+
.replace(/\s+/g, " ")
|
|
1705
|
+
.replace(/:\s*$/, "")
|
|
1706
|
+
.trim();
|
|
1707
|
+
}
|
|
1708
|
+
function toScalarMetadataString(rawValue) {
|
|
1709
|
+
if (rawValue == null || rawValue === "" || Array.isArray(rawValue)) {
|
|
1710
|
+
return undefined;
|
|
1711
|
+
}
|
|
1712
|
+
const normalized = String(rawValue).trim();
|
|
1713
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
1714
|
+
}
|
|
1715
|
+
function slugify(value) {
|
|
1716
|
+
return value
|
|
1717
|
+
.toLowerCase()
|
|
1718
|
+
.replace(/[^a-z0-9\s-]/g, "")
|
|
1719
|
+
.trim()
|
|
1720
|
+
.replace(/\s+/g, "-");
|
|
1721
|
+
}
|
|
1722
|
+
function runExport(project, format, inputPath, explicitOutputPath) {
|
|
1723
|
+
if (!isExportFormat(format)) {
|
|
1724
|
+
throw new Error(`Unsupported export format '${format}'. Use md, docx, pdf, or epub.`);
|
|
1725
|
+
}
|
|
1726
|
+
const exporters = {
|
|
1727
|
+
md: markdownExporter,
|
|
1728
|
+
docx: createPandocExporter("docx"),
|
|
1729
|
+
pdf: createPandocExporter("pdf"),
|
|
1730
|
+
epub: createPandocExporter("epub")
|
|
1731
|
+
};
|
|
1732
|
+
const exporter = exporters[format];
|
|
1733
|
+
const targetPath = explicitOutputPath || path.join(project.distDir, "exports", `${project.id}.${format === "md" ? "md" : format}`);
|
|
1734
|
+
const capability = exporter.canRun();
|
|
1735
|
+
if (!capability.ok) {
|
|
1736
|
+
throw new Error(capability.reason || `Exporter '${exporter.id}' cannot run.`);
|
|
1737
|
+
}
|
|
1738
|
+
exporter.run({
|
|
1739
|
+
inputPath,
|
|
1740
|
+
outputPath: path.resolve(repoRoot, targetPath)
|
|
1741
|
+
});
|
|
1742
|
+
return path.resolve(repoRoot, targetPath);
|
|
1743
|
+
}
|
|
1744
|
+
function printReport(issues) {
|
|
1745
|
+
if (issues.length === 0) {
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
for (const issue of issues) {
|
|
1749
|
+
const filePart = issue.file ? ` ${issue.file}` : "";
|
|
1750
|
+
const linePart = issue.line ? `:${issue.line}` : "";
|
|
1751
|
+
console.log(`[${issue.level.toUpperCase()}][${issue.category}]${filePart}${linePart} ${issue.message}`);
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
function exitIfErrors(issues) {
|
|
1755
|
+
if (issues.some((issue) => issue.level === "error")) {
|
|
1756
|
+
process.exit(1);
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
function makeIssue(level, category, message, file = null, line = null) {
|
|
1760
|
+
return { level, category, message, file, line };
|
|
1761
|
+
}
|
|
1762
|
+
function readJson(filePath) {
|
|
1763
|
+
if (!fs.existsSync(filePath)) {
|
|
1764
|
+
throw new Error(`Missing JSON file: ${filePath}`);
|
|
1765
|
+
}
|
|
1766
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
1767
|
+
try {
|
|
1768
|
+
return JSON.parse(raw);
|
|
1769
|
+
}
|
|
1770
|
+
catch (error) {
|
|
1771
|
+
if (error instanceof Error) {
|
|
1772
|
+
throw new Error(`Invalid JSON at ${filePath}: ${error.message}`);
|
|
1773
|
+
}
|
|
1774
|
+
throw new Error(`Invalid JSON at ${filePath}: ${String(error)}`);
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
function toDisplayTitle(value) {
|
|
1778
|
+
return value
|
|
1779
|
+
.split(/[-_]+/)
|
|
1780
|
+
.filter(Boolean)
|
|
1781
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
1782
|
+
.join(" ");
|
|
1783
|
+
}
|
|
1784
|
+
function toBoundedInt(value, fallback, min, max) {
|
|
1785
|
+
let parsed = null;
|
|
1786
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1787
|
+
parsed = value;
|
|
1788
|
+
}
|
|
1789
|
+
else if (typeof value === "string" && value.trim() !== "") {
|
|
1790
|
+
const next = Number(value.trim());
|
|
1791
|
+
if (Number.isFinite(next)) {
|
|
1792
|
+
parsed = next;
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
if (parsed == null) {
|
|
1796
|
+
return fallback;
|
|
1797
|
+
}
|
|
1798
|
+
const rounded = Math.round(parsed);
|
|
1799
|
+
return Math.min(max, Math.max(min, rounded));
|
|
1800
|
+
}
|
|
1801
|
+
function logLine(message) {
|
|
1802
|
+
console.log(message);
|
|
1803
|
+
}
|