stego-cli 0.3.4 → 0.4.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/.vscode/extensions.json +7 -0
- package/README.md +45 -0
- package/dist/metadata/metadata-command.js +127 -0
- package/dist/metadata/metadata-domain.js +209 -0
- package/dist/spine/spine-command.js +129 -0
- package/dist/spine/spine-domain.js +274 -0
- package/dist/stego-cli.js +358 -187
- package/package.json +3 -2
- package/projects/fiction-example/spine/characters/CHAR-AGNES.md +17 -0
- package/projects/fiction-example/spine/characters/CHAR-ETIENNE.md +17 -0
- package/projects/fiction-example/spine/characters/CHAR-MATTHAEUS.md +17 -0
- package/projects/fiction-example/spine/characters/CHAR-RAOUL.md +17 -0
- package/projects/fiction-example/spine/characters/_category.md +6 -0
- package/projects/fiction-example/spine/locations/LOC-CHARNEL.md +17 -0
- package/projects/fiction-example/spine/locations/LOC-COLLEGE.md +17 -0
- package/projects/fiction-example/spine/locations/LOC-HOTELDIEU.md +17 -0
- package/projects/fiction-example/spine/locations/LOC-QUAY.md +17 -0
- package/projects/fiction-example/spine/locations/_category.md +6 -0
- package/projects/fiction-example/spine/sources/SRC-CONJUNCTION.md +20 -0
- package/projects/fiction-example/spine/sources/SRC-GALEN.md +20 -0
- package/projects/fiction-example/spine/sources/SRC-WARD-DATA.md +20 -0
- package/projects/fiction-example/spine/sources/_category.md +6 -0
- package/projects/fiction-example/stego-project.json +1 -18
- package/projects/stego-docs/manuscript/500-project-configuration.md +3 -3
- package/projects/stego-docs/spine/commands/CMD-BUILD.md +11 -0
- package/projects/stego-docs/spine/commands/CMD-CHECK-STAGE.md +11 -0
- package/projects/stego-docs/spine/commands/CMD-EXPORT.md +11 -0
- package/projects/stego-docs/spine/commands/CMD-INIT.md +11 -0
- package/projects/stego-docs/spine/commands/CMD-LIST-PROJECTS.md +10 -0
- package/projects/stego-docs/spine/commands/CMD-NEW-PROJECT.md +10 -0
- package/projects/stego-docs/spine/commands/CMD-NEW.md +11 -0
- package/projects/stego-docs/spine/commands/CMD-VALIDATE.md +11 -0
- package/projects/stego-docs/spine/commands/_category.md +6 -0
- package/projects/stego-docs/spine/concepts/CON-COMPILE-STRUCTURE.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-DIST.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-MANUSCRIPT.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-METADATA.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-NOTES.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-PROJECT.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-SPINE-CATEGORY.md +11 -0
- package/projects/stego-docs/spine/concepts/CON-SPINE.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-STAGE-GATE.md +10 -0
- package/projects/stego-docs/spine/concepts/CON-WORKSPACE.md +9 -0
- package/projects/stego-docs/spine/concepts/_category.md +6 -0
- package/projects/stego-docs/spine/configuration/CFG-ALLOWED-STATUSES.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-COMPILE-LEVELS.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-COMPILE-STRUCTURE.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-REQUIRED-METADATA.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-SPINE-CATEGORIES.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-STAGE-POLICIES.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-STEGO-CONFIG.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-STEGO-PROJECT.md +9 -0
- package/projects/stego-docs/spine/configuration/_category.md +6 -0
- package/projects/stego-docs/spine/integrations/INT-CSPELL.md +9 -0
- package/projects/stego-docs/spine/integrations/INT-MARKDOWNLINT.md +9 -0
- package/projects/stego-docs/spine/integrations/INT-PANDOC.md +9 -0
- package/projects/stego-docs/spine/integrations/INT-SAURUS-EXTENSION.md +9 -0
- package/projects/stego-docs/spine/integrations/INT-STEGO-EXTENSION.md +9 -0
- package/projects/stego-docs/spine/integrations/INT-VSCODE.md +9 -0
- package/projects/stego-docs/spine/integrations/_category.md +6 -0
- package/projects/stego-docs/spine/workflows/FLOW-BUILD-EXPORT.md +10 -0
- package/projects/stego-docs/spine/workflows/FLOW-DAILY-WRITING.md +10 -0
- package/projects/stego-docs/spine/workflows/FLOW-INIT-WORKSPACE.md +9 -0
- package/projects/stego-docs/spine/workflows/FLOW-NEW-PROJECT.md +10 -0
- package/projects/stego-docs/spine/workflows/FLOW-PROOF-RELEASE.md +10 -0
- package/projects/stego-docs/spine/workflows/FLOW-STAGE-PROMOTION.md +10 -0
- package/projects/stego-docs/spine/workflows/_category.md +6 -0
- package/projects/stego-docs/stego-project.json +1 -28
- package/projects/fiction-example/spine/characters.md +0 -35
- package/projects/fiction-example/spine/locations.md +0 -37
- package/projects/fiction-example/spine/sources.md +0 -31
- package/projects/stego-docs/spine/commands.md +0 -71
- package/projects/stego-docs/spine/concepts.md +0 -72
- package/projects/stego-docs/spine/configuration.md +0 -57
- package/projects/stego-docs/spine/integrations.md +0 -43
- package/projects/stego-docs/spine/workflows.md +0 -48
package/dist/stego-cli.js
CHANGED
|
@@ -11,6 +11,9 @@ import { createPandocExporter } from "./exporters/pandoc-exporter.js";
|
|
|
11
11
|
import { parseCommentAppendix } from "./comments/comment-domain.js";
|
|
12
12
|
import { runCommentsCommand } from "./comments/comments-command.js";
|
|
13
13
|
import { CommentsCommandError } from "./comments/errors.js";
|
|
14
|
+
import { runMetadataCommand } from "./metadata/metadata-command.js";
|
|
15
|
+
import { runSpineCommand } from "./spine/spine-command.js";
|
|
16
|
+
import { readSpineCatalog } from "./spine/spine-domain.js";
|
|
14
17
|
const STATUS_RANK = {
|
|
15
18
|
draft: 0,
|
|
16
19
|
revise: 1,
|
|
@@ -22,6 +25,7 @@ const RESERVED_COMMENT_PREFIX = "CMT";
|
|
|
22
25
|
const DEFAULT_NEW_MANUSCRIPT_SLUG = "new-document";
|
|
23
26
|
const ROOT_CONFIG_FILENAME = "stego.config.json";
|
|
24
27
|
const PROSE_FONT_PROMPT = "Switch workspace to proportional (prose-style) font? (recommended)";
|
|
28
|
+
const COMMENT_AUTHOR_PROMPT = "Default comment author for stego.comments.author?";
|
|
25
29
|
const SCAFFOLD_GITIGNORE_CONTENT = `node_modules/
|
|
26
30
|
/dist/
|
|
27
31
|
.DS_Store
|
|
@@ -89,6 +93,103 @@ stego new-project --project my-book --title "My Book"
|
|
|
89
93
|
stego new --project fiction-example
|
|
90
94
|
\`\`\`
|
|
91
95
|
`;
|
|
96
|
+
const SCAFFOLD_AGENTS_CONTENT = `# AGENTS.md
|
|
97
|
+
|
|
98
|
+
## Purpose
|
|
99
|
+
|
|
100
|
+
This workspace is designed to be AI-friendly for writing workflows.
|
|
101
|
+
|
|
102
|
+
## Canonical CLI Interface
|
|
103
|
+
|
|
104
|
+
- Run \`stego --help\` for the full command reference.
|
|
105
|
+
- Run \`stego --version\` to confirm which CLI is active.
|
|
106
|
+
- Run project docs commands in \`projects/stego-docs\` when available.
|
|
107
|
+
|
|
108
|
+
## CLI Resolution Rules
|
|
109
|
+
|
|
110
|
+
- Prefer local CLI over global CLI:
|
|
111
|
+
- \`npm exec -- stego ...\`
|
|
112
|
+
- \`npx --no-install stego ...\`
|
|
113
|
+
- At the start of mutation tasks, run \`stego --version\` and report the version used.
|
|
114
|
+
|
|
115
|
+
## Workspace Discovery Checklist
|
|
116
|
+
|
|
117
|
+
1. Confirm workspace root contains \`stego.config.json\`.
|
|
118
|
+
2. Run \`stego list-projects\`.
|
|
119
|
+
3. Use explicit \`--project <id>\` for project-scoped commands.
|
|
120
|
+
|
|
121
|
+
## CLI-First Policy (Required)
|
|
122
|
+
|
|
123
|
+
When asked to edit Stego project content, use documented Stego CLI commands first.
|
|
124
|
+
|
|
125
|
+
Typical targets:
|
|
126
|
+
|
|
127
|
+
- manuscript files
|
|
128
|
+
- spine categories and entries
|
|
129
|
+
- frontmatter metadata
|
|
130
|
+
- comments
|
|
131
|
+
- stage/build/export workflows
|
|
132
|
+
|
|
133
|
+
Preferred commands include:
|
|
134
|
+
|
|
135
|
+
- \`stego new\`
|
|
136
|
+
- \`stego spine read\`
|
|
137
|
+
- \`stego spine new-category\`
|
|
138
|
+
- \`stego spine new\`
|
|
139
|
+
- \`stego metadata read\`
|
|
140
|
+
- \`stego metadata apply\`
|
|
141
|
+
- \`stego comments ...\`
|
|
142
|
+
|
|
143
|
+
## Machine-Mode Output
|
|
144
|
+
|
|
145
|
+
- For automation and integrations, prefer \`--format json\` and parse structured output.
|
|
146
|
+
- Use text output only for human-facing summaries.
|
|
147
|
+
|
|
148
|
+
## Mutation Protocol
|
|
149
|
+
|
|
150
|
+
1. Read current state first (\`metadata read\`, \`spine read\`, \`comments read\`).
|
|
151
|
+
2. Mutate via CLI commands.
|
|
152
|
+
3. Verify after writes (\`stego validate --project <id>\` and relevant read commands).
|
|
153
|
+
|
|
154
|
+
## Manual Edit Fallback
|
|
155
|
+
|
|
156
|
+
Manual file edits are a last resort.
|
|
157
|
+
|
|
158
|
+
If manual edits are required, the agent must:
|
|
159
|
+
|
|
160
|
+
1. warn that CLI was bypassed,
|
|
161
|
+
2. explain why CLI could not be used, and
|
|
162
|
+
3. list which files were manually edited.
|
|
163
|
+
|
|
164
|
+
## Failure Contract
|
|
165
|
+
|
|
166
|
+
When CLI fails:
|
|
167
|
+
|
|
168
|
+
1. show the attempted command,
|
|
169
|
+
2. summarize the error briefly,
|
|
170
|
+
3. report the recovery attempt, and
|
|
171
|
+
4. if fallback is required, apply the Manual Edit Fallback policy.
|
|
172
|
+
|
|
173
|
+
## Validation Expectations
|
|
174
|
+
|
|
175
|
+
After mutations, run relevant checks when feasible (for example \`stego validate --project <id>\`) and report results.
|
|
176
|
+
|
|
177
|
+
## Scope Guardrails
|
|
178
|
+
|
|
179
|
+
- Do not manually edit \`dist/\` outputs or compiled export artifacts.
|
|
180
|
+
- Do not modify files outside the requested project scope unless the user explicitly asks.
|
|
181
|
+
|
|
182
|
+
## Task To Command Quick Map
|
|
183
|
+
|
|
184
|
+
- New manuscript: \`stego new --project <id> [--filename <name>]\`
|
|
185
|
+
- Read spine: \`stego spine read --project <id> --format json\`
|
|
186
|
+
- New spine category: \`stego spine new-category --project <id> --key <category>\`
|
|
187
|
+
- New spine entry: \`stego spine new --project <id> --category <category> [--filename <path>]\`
|
|
188
|
+
- Read metadata: \`stego metadata read <markdown-path> --format json\`
|
|
189
|
+
- Apply metadata: \`stego metadata apply <markdown-path> --input <path|-> --format json\`
|
|
190
|
+
- Read comments: \`stego comments read <manuscript> --format json\`
|
|
191
|
+
- Mutate comments: \`stego comments add|reply|set-status|delete|clear-resolved|sync-anchors ... --format json\`
|
|
192
|
+
`;
|
|
92
193
|
const PROSE_MARKDOWN_EDITOR_SETTINGS = {
|
|
93
194
|
"[markdown]": {
|
|
94
195
|
"editor.fontFamily": "Inter, Helvetica Neue, Helvetica, Arial, sans-serif",
|
|
@@ -102,7 +203,8 @@ const PROSE_MARKDOWN_EDITOR_SETTINGS = {
|
|
|
102
203
|
};
|
|
103
204
|
const PROJECT_EXTENSION_RECOMMENDATIONS = [
|
|
104
205
|
"matt-gold.stego-extension",
|
|
105
|
-
"matt-gold.saurus-extension"
|
|
206
|
+
"matt-gold.saurus-extension",
|
|
207
|
+
"streetsidesoftware.code-spell-checker"
|
|
106
208
|
];
|
|
107
209
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
108
210
|
const packageRoot = path.resolve(scriptDir, "..");
|
|
@@ -111,6 +213,12 @@ let config;
|
|
|
111
213
|
void main();
|
|
112
214
|
async function main() {
|
|
113
215
|
const { command, options } = parseArgs(process.argv.slice(2));
|
|
216
|
+
if (command === "version" || command === "--version" || command === "-v") {
|
|
217
|
+
const cliPackage = readJson(path.join(packageRoot, "package.json"));
|
|
218
|
+
const version = typeof cliPackage.version === "string" ? cliPackage.version : "0.0.0";
|
|
219
|
+
console.log(version);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
114
222
|
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
115
223
|
printUsage();
|
|
116
224
|
return;
|
|
@@ -126,13 +234,31 @@ async function main() {
|
|
|
126
234
|
return;
|
|
127
235
|
case "new-project":
|
|
128
236
|
activateWorkspace(options);
|
|
129
|
-
await createProject(
|
|
237
|
+
await createProject({
|
|
238
|
+
projectId: readStringOption(options, "project"),
|
|
239
|
+
title: readStringOption(options, "title"),
|
|
240
|
+
proseFont: readStringOption(options, "prose-font"),
|
|
241
|
+
outputFormat: readStringOption(options, "format")
|
|
242
|
+
});
|
|
130
243
|
return;
|
|
131
244
|
case "new": {
|
|
132
245
|
activateWorkspace(options);
|
|
133
246
|
const project = resolveProject(readStringOption(options, "project"));
|
|
134
247
|
const createdPath = createNewManuscript(project, readStringOption(options, "i"), readStringOption(options, "filename"));
|
|
135
|
-
|
|
248
|
+
const outputFormat = parseTextOrJsonFormat(readStringOption(options, "format"));
|
|
249
|
+
if (outputFormat === "json") {
|
|
250
|
+
process.stdout.write(`${JSON.stringify({
|
|
251
|
+
ok: true,
|
|
252
|
+
operation: "new",
|
|
253
|
+
result: {
|
|
254
|
+
projectId: project.id,
|
|
255
|
+
filePath: createdPath
|
|
256
|
+
}
|
|
257
|
+
}, null, 2)}\n`);
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
logLine(`Created manuscript: ${createdPath}`);
|
|
261
|
+
}
|
|
136
262
|
return;
|
|
137
263
|
}
|
|
138
264
|
case "validate": {
|
|
@@ -202,6 +328,20 @@ async function main() {
|
|
|
202
328
|
case "comments":
|
|
203
329
|
await runCommentsCommand(options, process.cwd());
|
|
204
330
|
return;
|
|
331
|
+
case "metadata":
|
|
332
|
+
await runMetadataCommand(options, process.cwd());
|
|
333
|
+
return;
|
|
334
|
+
case "spine": {
|
|
335
|
+
activateWorkspace(options);
|
|
336
|
+
const project = resolveProject(readStringOption(options, "project"));
|
|
337
|
+
runSpineCommand(options, {
|
|
338
|
+
id: project.id,
|
|
339
|
+
root: project.root,
|
|
340
|
+
spineDir: project.spineDir,
|
|
341
|
+
meta: project.meta
|
|
342
|
+
});
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
205
345
|
default:
|
|
206
346
|
throw new Error(`Unknown command '${command}'. Run with 'help' for usage.`);
|
|
207
347
|
}
|
|
@@ -232,6 +372,28 @@ function readStringOption(options, key) {
|
|
|
232
372
|
function readBooleanOption(options, key) {
|
|
233
373
|
return options[key] === true;
|
|
234
374
|
}
|
|
375
|
+
function parseTextOrJsonFormat(raw) {
|
|
376
|
+
if (!raw || raw === "text") {
|
|
377
|
+
return "text";
|
|
378
|
+
}
|
|
379
|
+
if (raw === "json") {
|
|
380
|
+
return "json";
|
|
381
|
+
}
|
|
382
|
+
throw new Error("Invalid --format value. Use 'text' or 'json'.");
|
|
383
|
+
}
|
|
384
|
+
function parseProseFontMode(raw) {
|
|
385
|
+
const normalized = (raw || "prompt").trim().toLowerCase();
|
|
386
|
+
if (normalized === "yes" || normalized === "true" || normalized === "y") {
|
|
387
|
+
return "yes";
|
|
388
|
+
}
|
|
389
|
+
if (normalized === "no" || normalized === "false" || normalized === "n") {
|
|
390
|
+
return "no";
|
|
391
|
+
}
|
|
392
|
+
if (normalized === "prompt" || normalized === "ask") {
|
|
393
|
+
return "prompt";
|
|
394
|
+
}
|
|
395
|
+
throw new Error("Invalid --prose-font value. Use 'yes', 'no', or 'prompt'.");
|
|
396
|
+
}
|
|
235
397
|
function activateWorkspace(options) {
|
|
236
398
|
const workspace = resolveWorkspaceContext(readStringOption(options, "root"));
|
|
237
399
|
repoRoot = workspace.repoRoot;
|
|
@@ -244,75 +406,6 @@ function isStageName(value) {
|
|
|
244
406
|
function isExportFormat(value) {
|
|
245
407
|
return value === "md" || value === "docx" || value === "pdf" || value === "epub";
|
|
246
408
|
}
|
|
247
|
-
function resolveSpineSchema(project) {
|
|
248
|
-
const issues = [];
|
|
249
|
-
const projectFile = path.relative(repoRoot, path.join(project.root, "stego-project.json"));
|
|
250
|
-
const rawCategories = project.meta.spineCategories;
|
|
251
|
-
if (rawCategories == null) {
|
|
252
|
-
return { schema: { categories: [], inlineIdRegex: null }, issues };
|
|
253
|
-
}
|
|
254
|
-
if (!Array.isArray(rawCategories)) {
|
|
255
|
-
issues.push(makeIssue("error", "metadata", "Project 'spineCategories' must be an array when defined.", projectFile));
|
|
256
|
-
return { schema: { categories: [], inlineIdRegex: null }, issues };
|
|
257
|
-
}
|
|
258
|
-
const categories = [];
|
|
259
|
-
const keySet = new Set();
|
|
260
|
-
const prefixSet = new Set();
|
|
261
|
-
const notesSet = new Set();
|
|
262
|
-
for (const [index, categoryEntry] of rawCategories.entries()) {
|
|
263
|
-
if (!isPlainObject(categoryEntry)) {
|
|
264
|
-
issues.push(makeIssue("error", "metadata", `Invalid spineCategories entry at index ${index}. Expected object with key, prefix, notesFile.`, projectFile));
|
|
265
|
-
continue;
|
|
266
|
-
}
|
|
267
|
-
const key = typeof categoryEntry.key === "string" ? categoryEntry.key.trim() : "";
|
|
268
|
-
const prefix = typeof categoryEntry.prefix === "string" ? categoryEntry.prefix.trim() : "";
|
|
269
|
-
const notesFile = typeof categoryEntry.notesFile === "string" ? categoryEntry.notesFile.trim() : "";
|
|
270
|
-
if (!/^[a-z][a-z0-9_-]*$/.test(key)) {
|
|
271
|
-
issues.push(makeIssue("error", "metadata", `Invalid spine category key '${key || "<empty>"}'. Use lowercase key names like 'cast' or 'incidents'.`, projectFile));
|
|
272
|
-
continue;
|
|
273
|
-
}
|
|
274
|
-
if (!/^[A-Z][A-Z0-9-]*$/.test(prefix)) {
|
|
275
|
-
issues.push(makeIssue("error", "metadata", `Invalid spine category prefix '${prefix || "<empty>"}'. Use uppercase prefixes like 'CHAR' or 'STATUTE'.`, projectFile));
|
|
276
|
-
continue;
|
|
277
|
-
}
|
|
278
|
-
if (prefix.toUpperCase() === RESERVED_COMMENT_PREFIX) {
|
|
279
|
-
issues.push(makeIssue("error", "metadata", `Invalid spine category prefix '${prefix}'. '${RESERVED_COMMENT_PREFIX}' is reserved for Stego comment IDs (e.g. CMT-0001).`, projectFile));
|
|
280
|
-
continue;
|
|
281
|
-
}
|
|
282
|
-
if (!/^[A-Za-z0-9._-]+\.md$/.test(notesFile)) {
|
|
283
|
-
issues.push(makeIssue("error", "metadata", `Invalid notesFile '${notesFile || "<empty>"}'. Use markdown filenames like 'characters.md' (resolved in spine/).`, projectFile));
|
|
284
|
-
continue;
|
|
285
|
-
}
|
|
286
|
-
if (keySet.has(key)) {
|
|
287
|
-
issues.push(makeIssue("error", "metadata", `Duplicate spine category key '${key}'.`, projectFile));
|
|
288
|
-
continue;
|
|
289
|
-
}
|
|
290
|
-
if (prefixSet.has(prefix)) {
|
|
291
|
-
issues.push(makeIssue("error", "metadata", `Duplicate spine category prefix '${prefix}'.`, projectFile));
|
|
292
|
-
continue;
|
|
293
|
-
}
|
|
294
|
-
if (notesSet.has(notesFile)) {
|
|
295
|
-
issues.push(makeIssue("error", "metadata", `Duplicate spine category notesFile '${notesFile}'.`, projectFile));
|
|
296
|
-
continue;
|
|
297
|
-
}
|
|
298
|
-
keySet.add(key);
|
|
299
|
-
prefixSet.add(prefix);
|
|
300
|
-
notesSet.add(notesFile);
|
|
301
|
-
categories.push({
|
|
302
|
-
key,
|
|
303
|
-
prefix,
|
|
304
|
-
notesFile,
|
|
305
|
-
idPattern: new RegExp(`^${escapeRegex(prefix)}-[A-Z0-9-]+$`)
|
|
306
|
-
});
|
|
307
|
-
}
|
|
308
|
-
return {
|
|
309
|
-
schema: {
|
|
310
|
-
categories,
|
|
311
|
-
inlineIdRegex: buildInlineIdRegex(categories)
|
|
312
|
-
},
|
|
313
|
-
issues
|
|
314
|
-
};
|
|
315
|
-
}
|
|
316
409
|
function resolveRequiredMetadata(project, runtimeConfig) {
|
|
317
410
|
const issues = [];
|
|
318
411
|
const projectFile = path.relative(repoRoot, path.join(project.root, "stego-project.json"));
|
|
@@ -406,16 +499,6 @@ function resolveCompileStructure(project) {
|
|
|
406
499
|
}
|
|
407
500
|
return { levels, issues };
|
|
408
501
|
}
|
|
409
|
-
function buildInlineIdRegex(categories) {
|
|
410
|
-
if (categories.length === 0) {
|
|
411
|
-
return null;
|
|
412
|
-
}
|
|
413
|
-
const prefixes = categories.map((category) => escapeRegex(category.prefix)).join("|");
|
|
414
|
-
return new RegExp(`\\b(?:${prefixes})-[A-Z0-9-]+\\b`, "g");
|
|
415
|
-
}
|
|
416
|
-
function escapeRegex(value) {
|
|
417
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
418
|
-
}
|
|
419
502
|
function isPlainObject(value) {
|
|
420
503
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
421
504
|
}
|
|
@@ -529,6 +612,7 @@ async function initWorkspace(options) {
|
|
|
529
612
|
const copiedPaths = [];
|
|
530
613
|
writeScaffoldGitignore(targetRoot, copiedPaths);
|
|
531
614
|
writeScaffoldReadme(targetRoot, copiedPaths);
|
|
615
|
+
writeScaffoldAgents(targetRoot, copiedPaths);
|
|
532
616
|
copyTemplateAsset(".markdownlint.json", targetRoot, copiedPaths);
|
|
533
617
|
copyTemplateAsset(".markdownlint.manuscript.json", targetRoot, copiedPaths);
|
|
534
618
|
copyTemplateAsset(".cspell.json", targetRoot, copiedPaths);
|
|
@@ -538,8 +622,13 @@ async function initWorkspace(options) {
|
|
|
538
622
|
copyTemplateAsset(path.join(".vscode", "extensions.json"), targetRoot, copiedPaths, { optional: true });
|
|
539
623
|
rewriteTemplateProjectPackageScripts(targetRoot);
|
|
540
624
|
const enableProseFont = await promptYesNo(PROSE_FONT_PROMPT, true);
|
|
541
|
-
|
|
542
|
-
|
|
625
|
+
const suggestedCommentAuthor = resolveSuggestedCommentAuthor(targetRoot);
|
|
626
|
+
const commentAuthor = (await promptText(COMMENT_AUTHOR_PROMPT, suggestedCommentAuthor)).trim();
|
|
627
|
+
if (enableProseFont || commentAuthor) {
|
|
628
|
+
writeProjectProseEditorSettings(targetRoot, copiedPaths, {
|
|
629
|
+
enableProseFont,
|
|
630
|
+
commentAuthor
|
|
631
|
+
});
|
|
543
632
|
}
|
|
544
633
|
writeInitRootPackageJson(targetRoot);
|
|
545
634
|
logLine(`Initialized Stego workspace in ${targetRoot}`);
|
|
@@ -582,6 +671,46 @@ async function promptYesNo(question, defaultYes) {
|
|
|
582
671
|
rl.close();
|
|
583
672
|
}
|
|
584
673
|
}
|
|
674
|
+
async function promptText(question, defaultValue = "") {
|
|
675
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
676
|
+
return defaultValue;
|
|
677
|
+
}
|
|
678
|
+
const rl = createInterface({
|
|
679
|
+
input: process.stdin,
|
|
680
|
+
output: process.stdout
|
|
681
|
+
});
|
|
682
|
+
const suffix = defaultValue ? ` [${defaultValue}] ` : " ";
|
|
683
|
+
try {
|
|
684
|
+
const answer = (await rl.question(`${question}${suffix}`)).trim();
|
|
685
|
+
if (!answer) {
|
|
686
|
+
return defaultValue;
|
|
687
|
+
}
|
|
688
|
+
return answer;
|
|
689
|
+
}
|
|
690
|
+
finally {
|
|
691
|
+
rl.close();
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
function resolveSuggestedCommentAuthor(cwd) {
|
|
695
|
+
const gitAuthor = spawnSync("git", ["config", "--get", "user.name"], {
|
|
696
|
+
cwd,
|
|
697
|
+
encoding: "utf8"
|
|
698
|
+
});
|
|
699
|
+
const fromGit = (gitAuthor.stdout || "").trim();
|
|
700
|
+
if (gitAuthor.status === 0 && fromGit) {
|
|
701
|
+
return fromGit;
|
|
702
|
+
}
|
|
703
|
+
try {
|
|
704
|
+
const username = os.userInfo().username.trim();
|
|
705
|
+
if (username) {
|
|
706
|
+
return username;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
catch {
|
|
710
|
+
// ignore lookup failure and fall back to empty
|
|
711
|
+
}
|
|
712
|
+
return "";
|
|
713
|
+
}
|
|
585
714
|
function copyTemplateAsset(sourceRelativePath, targetRoot, copiedPaths, options) {
|
|
586
715
|
const sourcePath = path.join(packageRoot, sourceRelativePath);
|
|
587
716
|
if (!fs.existsSync(sourcePath)) {
|
|
@@ -616,6 +745,11 @@ function writeScaffoldReadme(targetRoot, copiedPaths) {
|
|
|
616
745
|
fs.writeFileSync(destinationPath, SCAFFOLD_README_CONTENT, "utf8");
|
|
617
746
|
copiedPaths.push("README.md");
|
|
618
747
|
}
|
|
748
|
+
function writeScaffoldAgents(targetRoot, copiedPaths) {
|
|
749
|
+
const destinationPath = path.join(targetRoot, "AGENTS.md");
|
|
750
|
+
fs.writeFileSync(destinationPath, SCAFFOLD_AGENTS_CONTENT, "utf8");
|
|
751
|
+
copiedPaths.push("AGENTS.md");
|
|
752
|
+
}
|
|
619
753
|
function shouldCopyTemplatePath(currentSourcePath) {
|
|
620
754
|
const relativePath = path.relative(packageRoot, currentSourcePath);
|
|
621
755
|
if (!relativePath || relativePath.startsWith("..")) {
|
|
@@ -691,7 +825,7 @@ function ensureProjectExtensionsRecommendations(projectRoot) {
|
|
|
691
825
|
};
|
|
692
826
|
fs.writeFileSync(extensionsPath, `${JSON.stringify(extensionsConfig, null, 2)}\n`, "utf8");
|
|
693
827
|
}
|
|
694
|
-
function writeProjectProseEditorSettings(targetRoot, copiedPaths) {
|
|
828
|
+
function writeProjectProseEditorSettings(targetRoot, copiedPaths, options) {
|
|
695
829
|
const projectsRoot = path.join(targetRoot, "projects");
|
|
696
830
|
if (!fs.existsSync(projectsRoot)) {
|
|
697
831
|
return;
|
|
@@ -701,14 +835,16 @@ function writeProjectProseEditorSettings(targetRoot, copiedPaths) {
|
|
|
701
835
|
continue;
|
|
702
836
|
}
|
|
703
837
|
const projectRoot = path.join(projectsRoot, entry.name);
|
|
704
|
-
const settingsPath = writeProseEditorSettingsForProject(projectRoot);
|
|
838
|
+
const settingsPath = writeProseEditorSettingsForProject(projectRoot, options);
|
|
705
839
|
copiedPaths.push(path.relative(targetRoot, settingsPath));
|
|
706
840
|
}
|
|
707
841
|
}
|
|
708
|
-
function writeProseEditorSettingsForProject(projectRoot) {
|
|
842
|
+
function writeProseEditorSettingsForProject(projectRoot, options) {
|
|
709
843
|
const vscodeDir = path.join(projectRoot, ".vscode");
|
|
710
844
|
const settingsPath = path.join(vscodeDir, "settings.json");
|
|
711
845
|
fs.mkdirSync(vscodeDir, { recursive: true });
|
|
846
|
+
const enableProseFont = options?.enableProseFont ?? true;
|
|
847
|
+
const commentAuthor = (options?.commentAuthor ?? "").trim();
|
|
712
848
|
let existingSettings = {};
|
|
713
849
|
if (fs.existsSync(settingsPath)) {
|
|
714
850
|
try {
|
|
@@ -724,17 +860,22 @@ function writeProseEditorSettingsForProject(projectRoot) {
|
|
|
724
860
|
const proseMarkdownSettings = isPlainObject(PROSE_MARKDOWN_EDITOR_SETTINGS["[markdown]"])
|
|
725
861
|
? PROSE_MARKDOWN_EDITOR_SETTINGS["[markdown]"]
|
|
726
862
|
: {};
|
|
727
|
-
const existingMarkdownSettings = isPlainObject(existingSettings["[markdown]"])
|
|
728
|
-
? existingSettings["[markdown]"]
|
|
729
|
-
: {};
|
|
730
863
|
const nextSettings = {
|
|
731
|
-
...existingSettings
|
|
732
|
-
|
|
864
|
+
...existingSettings
|
|
865
|
+
};
|
|
866
|
+
if (enableProseFont) {
|
|
867
|
+
const existingMarkdownSettings = isPlainObject(existingSettings["[markdown]"])
|
|
868
|
+
? existingSettings["[markdown]"]
|
|
869
|
+
: {};
|
|
870
|
+
nextSettings["[markdown]"] = {
|
|
733
871
|
...existingMarkdownSettings,
|
|
734
872
|
...proseMarkdownSettings
|
|
735
|
-
}
|
|
736
|
-
"markdown.preview.fontFamily"
|
|
737
|
-
}
|
|
873
|
+
};
|
|
874
|
+
nextSettings["markdown.preview.fontFamily"] = PROSE_MARKDOWN_EDITOR_SETTINGS["markdown.preview.fontFamily"];
|
|
875
|
+
}
|
|
876
|
+
if (commentAuthor) {
|
|
877
|
+
nextSettings["stego.comments.author"] = commentAuthor;
|
|
878
|
+
}
|
|
738
879
|
fs.writeFileSync(settingsPath, `${JSON.stringify(nextSettings, null, 2)}\n`, "utf8");
|
|
739
880
|
return settingsPath;
|
|
740
881
|
}
|
|
@@ -753,6 +894,8 @@ function writeInitRootPackageJson(targetRoot) {
|
|
|
753
894
|
"list-projects": "stego list-projects",
|
|
754
895
|
"new-project": "stego new-project",
|
|
755
896
|
new: "stego new",
|
|
897
|
+
spine: "stego spine",
|
|
898
|
+
metadata: "stego metadata",
|
|
756
899
|
lint: "stego lint",
|
|
757
900
|
validate: "stego validate",
|
|
758
901
|
build: "stego build",
|
|
@@ -768,7 +911,7 @@ function writeInitRootPackageJson(targetRoot) {
|
|
|
768
911
|
fs.writeFileSync(path.join(targetRoot, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
769
912
|
}
|
|
770
913
|
function printUsage() {
|
|
771
|
-
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 new --project <project-id> [--i <prefix>|-i <prefix>] [--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 lint --project <project-id> [--manuscript|--spine] [--root <path>]\n export --project <project-id> --format <md|docx|pdf|epub> [--output <path>] [--root <path>]\n comments read <manuscript> [--format <text|json>]\n comments add <manuscript> [--message <text> | --input <path|->] [--author <name>] [--start-line <n> --start-col <n> --end-line <n> --end-col <n>] [--cursor-line <n>] [--format <text|json>]\n comments reply <manuscript> --comment-id <CMT-####> [--message <text> | --input <path|->] [--author <name>] [--format <text|json>]\n comments set-status <manuscript> --comment-id <CMT-####> --status <open|resolved> [--thread] [--format <text|json>]\n comments delete <manuscript> --comment-id <CMT-####> [--format <text|json>]\n comments clear-resolved <manuscript> [--format <text|json>]\n comments sync-anchors <manuscript> --input <path|-> [--format <text|json>]\n`);
|
|
914
|
+
console.log(`Stego CLI\n\nCommands:\n init [--force]\n list-projects [--root <path>]\n new-project --project <project-id> [--title <title>] [--prose-font <yes|no|prompt>] [--format <text|json>] [--root <path>]\n new --project <project-id> [--i <prefix>|-i <prefix>] [--filename <name>] [--format <text|json>] [--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 lint --project <project-id> [--manuscript|--spine] [--root <path>]\n export --project <project-id> --format <md|docx|pdf|epub> [--output <path>] [--root <path>]\n spine read --project <project-id> [--format <text|json>] [--root <path>]\n spine new-category --project <project-id> --key <category> [--label <label>] [--require-metadata] [--format <text|json>] [--root <path>]\n spine new --project <project-id> --category <category> [--filename <relative-path>] [--format <text|json>] [--root <path>]\n metadata read <markdown-path> [--format <text|json>]\n metadata apply <markdown-path> --input <path|-> [--format <text|json>]\n comments read <manuscript> [--format <text|json>]\n comments add <manuscript> [--message <text> | --input <path|->] [--author <name>] [--start-line <n> --start-col <n> --end-line <n> --end-col <n>] [--cursor-line <n>] [--format <text|json>]\n comments reply <manuscript> --comment-id <CMT-####> [--message <text> | --input <path|->] [--author <name>] [--format <text|json>]\n comments set-status <manuscript> --comment-id <CMT-####> --status <open|resolved> [--thread] [--format <text|json>]\n comments delete <manuscript> --comment-id <CMT-####> [--format <text|json>]\n comments clear-resolved <manuscript> [--format <text|json>]\n comments sync-anchors <manuscript> --input <path|-> [--format <text|json>]\n`);
|
|
772
915
|
}
|
|
773
916
|
function listProjects() {
|
|
774
917
|
const ids = getProjectIds();
|
|
@@ -781,8 +924,8 @@ function listProjects() {
|
|
|
781
924
|
console.log(`- ${id}`);
|
|
782
925
|
}
|
|
783
926
|
}
|
|
784
|
-
async function createProject(
|
|
785
|
-
const projectId = (
|
|
927
|
+
async function createProject(options) {
|
|
928
|
+
const projectId = (options.projectId || "").trim();
|
|
786
929
|
if (!projectId) {
|
|
787
930
|
throw new Error("Project id is required. Use --project <project-id>.");
|
|
788
931
|
}
|
|
@@ -802,7 +945,7 @@ async function createProject(projectIdOption, titleOption) {
|
|
|
802
945
|
const manuscriptDir = path.join(projectRoot, config.chapterDir);
|
|
803
946
|
const projectJson = {
|
|
804
947
|
id: projectId,
|
|
805
|
-
title:
|
|
948
|
+
title: options.title?.trim() || toDisplayTitle(projectId),
|
|
806
949
|
requiredMetadata: ["status"],
|
|
807
950
|
compileStructure: {
|
|
808
951
|
levels: [
|
|
@@ -815,14 +958,7 @@ async function createProject(projectIdOption, titleOption) {
|
|
|
815
958
|
pageBreak: "between-groups"
|
|
816
959
|
}
|
|
817
960
|
]
|
|
818
|
-
}
|
|
819
|
-
spineCategories: [
|
|
820
|
-
{
|
|
821
|
-
key: "characters",
|
|
822
|
-
prefix: "CHAR",
|
|
823
|
-
notesFile: "characters.md"
|
|
824
|
-
}
|
|
825
|
-
]
|
|
961
|
+
}
|
|
826
962
|
};
|
|
827
963
|
const projectJsonPath = path.join(projectRoot, "stego-project.json");
|
|
828
964
|
fs.writeFileSync(projectJsonPath, `${JSON.stringify(projectJson, null, 2)}\n`, "utf8");
|
|
@@ -831,6 +967,8 @@ async function createProject(projectIdOption, titleOption) {
|
|
|
831
967
|
private: true,
|
|
832
968
|
scripts: {
|
|
833
969
|
new: "npx --no-install stego new",
|
|
970
|
+
"spine:new": "npx --no-install stego spine new",
|
|
971
|
+
"spine:new-category": "npx --no-install stego spine new-category",
|
|
834
972
|
lint: "npx --no-install stego lint",
|
|
835
973
|
validate: "npx --no-install stego validate",
|
|
836
974
|
build: "npx --no-install stego build",
|
|
@@ -851,20 +989,57 @@ chapter_title: Hello World
|
|
|
851
989
|
|
|
852
990
|
Start writing here.
|
|
853
991
|
`, "utf8");
|
|
854
|
-
const
|
|
855
|
-
fs.
|
|
992
|
+
const charactersDir = path.join(spineDir, "characters");
|
|
993
|
+
fs.mkdirSync(charactersDir, { recursive: true });
|
|
994
|
+
const charactersCategoryPath = path.join(charactersDir, "_category.md");
|
|
995
|
+
fs.writeFileSync(charactersCategoryPath, `---
|
|
996
|
+
label: Characters
|
|
997
|
+
---
|
|
998
|
+
|
|
999
|
+
# Characters
|
|
1000
|
+
|
|
1001
|
+
`, "utf8");
|
|
1002
|
+
const charactersEntryPath = path.join(charactersDir, "example-character.md");
|
|
1003
|
+
fs.writeFileSync(charactersEntryPath, `# Example Character
|
|
1004
|
+
|
|
1005
|
+
`, "utf8");
|
|
856
1006
|
const projectExtensionsPath = path.join(projectRoot, ".vscode", "extensions.json");
|
|
857
1007
|
ensureProjectExtensionsRecommendations(projectRoot);
|
|
858
1008
|
let projectSettingsPath = null;
|
|
859
|
-
const
|
|
1009
|
+
const proseFontMode = parseProseFontMode(options.proseFont);
|
|
1010
|
+
const enableProseFont = proseFontMode === "prompt"
|
|
1011
|
+
? await promptYesNo(PROSE_FONT_PROMPT, true)
|
|
1012
|
+
: proseFontMode === "yes";
|
|
860
1013
|
if (enableProseFont) {
|
|
861
1014
|
projectSettingsPath = writeProseEditorSettingsForProject(projectRoot);
|
|
862
1015
|
}
|
|
1016
|
+
const outputFormat = parseTextOrJsonFormat(options.outputFormat);
|
|
1017
|
+
if (outputFormat === "json") {
|
|
1018
|
+
process.stdout.write(`${JSON.stringify({
|
|
1019
|
+
ok: true,
|
|
1020
|
+
operation: "new-project",
|
|
1021
|
+
result: {
|
|
1022
|
+
projectId,
|
|
1023
|
+
projectPath: path.relative(repoRoot, projectRoot),
|
|
1024
|
+
files: [
|
|
1025
|
+
path.relative(repoRoot, projectJsonPath),
|
|
1026
|
+
path.relative(repoRoot, projectPackagePath),
|
|
1027
|
+
path.relative(repoRoot, starterManuscriptPath),
|
|
1028
|
+
path.relative(repoRoot, charactersCategoryPath),
|
|
1029
|
+
path.relative(repoRoot, charactersEntryPath),
|
|
1030
|
+
path.relative(repoRoot, projectExtensionsPath),
|
|
1031
|
+
...(projectSettingsPath ? [path.relative(repoRoot, projectSettingsPath)] : [])
|
|
1032
|
+
]
|
|
1033
|
+
}
|
|
1034
|
+
}, null, 2)}\n`);
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
863
1037
|
logLine(`Created project: ${path.relative(repoRoot, projectRoot)}`);
|
|
864
1038
|
logLine(`- ${path.relative(repoRoot, projectJsonPath)}`);
|
|
865
1039
|
logLine(`- ${path.relative(repoRoot, projectPackagePath)}`);
|
|
866
1040
|
logLine(`- ${path.relative(repoRoot, starterManuscriptPath)}`);
|
|
867
|
-
logLine(`- ${path.relative(repoRoot,
|
|
1041
|
+
logLine(`- ${path.relative(repoRoot, charactersCategoryPath)}`);
|
|
1042
|
+
logLine(`- ${path.relative(repoRoot, charactersEntryPath)}`);
|
|
868
1043
|
logLine(`- ${path.relative(repoRoot, projectExtensionsPath)}`);
|
|
869
1044
|
if (projectSettingsPath) {
|
|
870
1045
|
logLine(`- ${path.relative(repoRoot, projectSettingsPath)}`);
|
|
@@ -1061,13 +1236,16 @@ function inferProjectIdFromCwd(cwd) {
|
|
|
1061
1236
|
}
|
|
1062
1237
|
function inspectProject(project, runtimeConfig, options = {}) {
|
|
1063
1238
|
const issues = [];
|
|
1064
|
-
const emptySpineState = {
|
|
1065
|
-
const spineSchema = resolveSpineSchema(project);
|
|
1239
|
+
const emptySpineState = { categories: [], entriesByCategory: new Map(), issues: [] };
|
|
1066
1240
|
const requiredMetadataState = resolveRequiredMetadata(project, runtimeConfig);
|
|
1067
1241
|
const compileStructureState = resolveCompileStructure(project);
|
|
1068
|
-
issues.push(...spineSchema.issues);
|
|
1069
1242
|
issues.push(...requiredMetadataState.issues);
|
|
1070
1243
|
issues.push(...compileStructureState.issues);
|
|
1244
|
+
if (project.meta.spineCategories !== undefined) {
|
|
1245
|
+
issues.push(makeIssue("error", "metadata", "Legacy 'spineCategories' in stego-project.json is no longer supported. Use spine/ category directories and files.", path.relative(repoRoot, path.join(project.root, "stego-project.json"))));
|
|
1246
|
+
}
|
|
1247
|
+
const spineState = readSpine(project);
|
|
1248
|
+
issues.push(...spineState.issues);
|
|
1071
1249
|
let chapterFiles = [];
|
|
1072
1250
|
const onlyFile = options.onlyFile?.trim();
|
|
1073
1251
|
if (onlyFile) {
|
|
@@ -1107,7 +1285,7 @@ function inspectProject(project, runtimeConfig, options = {}) {
|
|
|
1107
1285
|
return { chapters: [], issues, spineState: emptySpineState, compileStructureLevels: compileStructureState.levels };
|
|
1108
1286
|
}
|
|
1109
1287
|
}
|
|
1110
|
-
const chapters = chapterFiles.map((chapterPath) => parseChapter(chapterPath, runtimeConfig, requiredMetadataState.requiredMetadata,
|
|
1288
|
+
const chapters = chapterFiles.map((chapterPath) => parseChapter(chapterPath, runtimeConfig, requiredMetadataState.requiredMetadata, spineState.categories, compileStructureState.levels));
|
|
1111
1289
|
for (const chapter of chapters) {
|
|
1112
1290
|
issues.push(...chapter.issues);
|
|
1113
1291
|
}
|
|
@@ -1134,10 +1312,8 @@ function inspectProject(project, runtimeConfig, options = {}) {
|
|
|
1134
1312
|
}
|
|
1135
1313
|
return a.order - b.order;
|
|
1136
1314
|
});
|
|
1137
|
-
const spineState = readSpine(project.spineDir, spineSchema.schema.categories, spineSchema.schema.inlineIdRegex);
|
|
1138
|
-
issues.push(...spineState.issues);
|
|
1139
1315
|
for (const chapter of chapters) {
|
|
1140
|
-
issues.push(...
|
|
1316
|
+
issues.push(...findUnknownSpineReferences(chapter.referenceKeysByCategory, spineState.entriesByCategory, chapter.relativePath));
|
|
1141
1317
|
}
|
|
1142
1318
|
return {
|
|
1143
1319
|
chapters,
|
|
@@ -1146,7 +1322,7 @@ function inspectProject(project, runtimeConfig, options = {}) {
|
|
|
1146
1322
|
compileStructureLevels: compileStructureState.levels
|
|
1147
1323
|
};
|
|
1148
1324
|
}
|
|
1149
|
-
function parseChapter(chapterPath, runtimeConfig, requiredMetadata, spineCategories,
|
|
1325
|
+
function parseChapter(chapterPath, runtimeConfig, requiredMetadata, spineCategories, compileStructureLevels) {
|
|
1150
1326
|
const relativePath = path.relative(repoRoot, chapterPath);
|
|
1151
1327
|
const raw = fs.readFileSync(chapterPath, "utf8");
|
|
1152
1328
|
const { metadata, body, comments, issues } = parseMetadata(raw, chapterPath, false);
|
|
@@ -1175,9 +1351,8 @@ function parseChapter(chapterPath, runtimeConfig, requiredMetadata, spineCategor
|
|
|
1175
1351
|
void normalizeGroupingValue(metadata[level.titleKey], relativePath, chapterIssues, level.titleKey);
|
|
1176
1352
|
}
|
|
1177
1353
|
}
|
|
1178
|
-
const referenceValidation =
|
|
1354
|
+
const referenceValidation = extractReferenceKeysByCategory(metadata, relativePath, spineCategories);
|
|
1179
1355
|
chapterIssues.push(...referenceValidation.issues);
|
|
1180
|
-
chapterIssues.push(...findInlineSpineIdMentions(body, relativePath, inlineIdRegex));
|
|
1181
1356
|
chapterIssues.push(...validateMarkdownBody(body, chapterPath));
|
|
1182
1357
|
return {
|
|
1183
1358
|
path: chapterPath,
|
|
@@ -1185,7 +1360,7 @@ function parseChapter(chapterPath, runtimeConfig, requiredMetadata, spineCategor
|
|
|
1185
1360
|
title,
|
|
1186
1361
|
order,
|
|
1187
1362
|
status,
|
|
1188
|
-
|
|
1363
|
+
referenceKeysByCategory: referenceValidation.referencesByCategory,
|
|
1189
1364
|
groupValues,
|
|
1190
1365
|
metadata,
|
|
1191
1366
|
body,
|
|
@@ -1228,49 +1403,39 @@ function parseOrderFromFilename(chapterPath, relativePath, issues) {
|
|
|
1228
1403
|
}
|
|
1229
1404
|
return Number(match[1]);
|
|
1230
1405
|
}
|
|
1231
|
-
function
|
|
1406
|
+
function extractReferenceKeysByCategory(metadata, relativePath, spineCategories) {
|
|
1232
1407
|
const issues = [];
|
|
1233
|
-
const
|
|
1408
|
+
const referencesByCategory = {};
|
|
1234
1409
|
for (const category of spineCategories) {
|
|
1235
1410
|
const rawValue = metadata[category.key];
|
|
1236
1411
|
if (rawValue == null || rawValue === "") {
|
|
1237
1412
|
continue;
|
|
1238
1413
|
}
|
|
1239
1414
|
if (!Array.isArray(rawValue)) {
|
|
1240
|
-
issues.push(makeIssue("error", "metadata", `Metadata '${category.key}' must be an array
|
|
1415
|
+
issues.push(makeIssue("error", "metadata", `Metadata '${category.key}' must be an array of spine entry keys (for example: [\"matthaeus\"]).`, relativePath));
|
|
1241
1416
|
continue;
|
|
1242
1417
|
}
|
|
1418
|
+
const seen = new Set();
|
|
1419
|
+
const values = [];
|
|
1243
1420
|
for (const entry of rawValue) {
|
|
1244
1421
|
if (typeof entry !== "string") {
|
|
1245
1422
|
issues.push(makeIssue("error", "metadata", `Metadata '${category.key}' entries must be strings.`, relativePath));
|
|
1246
1423
|
continue;
|
|
1247
1424
|
}
|
|
1248
|
-
const
|
|
1249
|
-
if (!
|
|
1250
|
-
issues.push(makeIssue("error", "metadata", `
|
|
1425
|
+
const normalized = entry.trim();
|
|
1426
|
+
if (!normalized) {
|
|
1427
|
+
issues.push(makeIssue("error", "metadata", `Metadata '${category.key}' contains an empty entry key.`, relativePath));
|
|
1251
1428
|
continue;
|
|
1252
1429
|
}
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
function findInlineSpineIdMentions(body, relativePath, inlineIdRegex) {
|
|
1259
|
-
const issues = [];
|
|
1260
|
-
if (!inlineIdRegex) {
|
|
1261
|
-
return issues;
|
|
1262
|
-
}
|
|
1263
|
-
const lines = body.split(/\r?\n/);
|
|
1264
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
1265
|
-
const matches = lines[index].match(inlineIdRegex);
|
|
1266
|
-
if (!matches) {
|
|
1267
|
-
continue;
|
|
1268
|
-
}
|
|
1269
|
-
for (const id of matches) {
|
|
1270
|
-
issues.push(makeIssue("error", "continuity", `Inline ID '${id}' found in prose. Move canon IDs to metadata fields only.`, relativePath, index + 1));
|
|
1430
|
+
if (seen.has(normalized)) {
|
|
1431
|
+
continue;
|
|
1432
|
+
}
|
|
1433
|
+
seen.add(normalized);
|
|
1434
|
+
values.push(normalized);
|
|
1271
1435
|
}
|
|
1436
|
+
referencesByCategory[category.key] = values;
|
|
1272
1437
|
}
|
|
1273
|
-
return issues;
|
|
1438
|
+
return { referencesByCategory, issues };
|
|
1274
1439
|
}
|
|
1275
1440
|
function parseMetadata(raw, chapterPath, required) {
|
|
1276
1441
|
const relativePath = path.relative(repoRoot, chapterPath);
|
|
@@ -1527,39 +1692,31 @@ function runStyleHeuristics(body, relativePath) {
|
|
|
1527
1692
|
function countWords(text) {
|
|
1528
1693
|
return text.trim().split(/\s+/).filter(Boolean).length;
|
|
1529
1694
|
}
|
|
1530
|
-
function readSpine(
|
|
1531
|
-
const
|
|
1532
|
-
const
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
return { ids, issues };
|
|
1539
|
-
}
|
|
1540
|
-
for (const category of spineCategories) {
|
|
1541
|
-
const fullPath = path.join(spineDir, category.notesFile);
|
|
1542
|
-
const relativePath = path.relative(repoRoot, fullPath);
|
|
1543
|
-
if (!fs.existsSync(fullPath)) {
|
|
1544
|
-
issues.push(makeIssue("warning", "continuity", `Missing spine file '${category.notesFile}' for category '${category.key}'.`, relativePath));
|
|
1545
|
-
continue;
|
|
1546
|
-
}
|
|
1547
|
-
const text = fs.readFileSync(fullPath, "utf8");
|
|
1548
|
-
if (!inlineIdRegex) {
|
|
1549
|
-
continue;
|
|
1550
|
-
}
|
|
1551
|
-
const matches = text.match(inlineIdRegex) || [];
|
|
1552
|
-
for (const id of matches) {
|
|
1553
|
-
ids.add(id);
|
|
1554
|
-
}
|
|
1695
|
+
function readSpine(project) {
|
|
1696
|
+
const catalog = readSpineCatalog(project.root, project.spineDir);
|
|
1697
|
+
const categories = [];
|
|
1698
|
+
const entriesByCategory = new Map();
|
|
1699
|
+
for (const category of catalog.categories) {
|
|
1700
|
+
const entries = new Set(category.entries.map((entry) => entry.key));
|
|
1701
|
+
categories.push({ key: category.key, entries });
|
|
1702
|
+
entriesByCategory.set(category.key, entries);
|
|
1555
1703
|
}
|
|
1556
|
-
|
|
1704
|
+
const issues = catalog.issues.map((message) => makeIssue("warning", "continuity", message));
|
|
1705
|
+
return { categories, entriesByCategory, issues };
|
|
1557
1706
|
}
|
|
1558
|
-
function
|
|
1707
|
+
function findUnknownSpineReferences(referencesByCategory, entriesByCategory, relativePath) {
|
|
1559
1708
|
const issues = [];
|
|
1560
|
-
for (const
|
|
1561
|
-
|
|
1562
|
-
|
|
1709
|
+
for (const [categoryKey, values] of Object.entries(referencesByCategory)) {
|
|
1710
|
+
const known = entriesByCategory.get(categoryKey);
|
|
1711
|
+
if (!known) {
|
|
1712
|
+
issues.push(makeIssue("warning", "continuity", `Metadata category '${categoryKey}' has references but no matching spine category directory was found in spine/.`, relativePath));
|
|
1713
|
+
continue;
|
|
1714
|
+
}
|
|
1715
|
+
for (const value of values) {
|
|
1716
|
+
if (known.has(value)) {
|
|
1717
|
+
continue;
|
|
1718
|
+
}
|
|
1719
|
+
issues.push(makeIssue("warning", "continuity", `Metadata reference '${categoryKey}: ${value}' does not exist in spine/${categoryKey}/.`, relativePath));
|
|
1563
1720
|
}
|
|
1564
1721
|
}
|
|
1565
1722
|
return issues;
|
|
@@ -1596,8 +1753,11 @@ function runStageCheck(project, runtimeConfig, stage, onlyFile) {
|
|
|
1596
1753
|
}
|
|
1597
1754
|
}
|
|
1598
1755
|
if (policy.requireSpine) {
|
|
1756
|
+
if (report.spineState.categories.length === 0) {
|
|
1757
|
+
issues.push(makeIssue("error", "continuity", "No spine categories found. Add at least one category under spine/<category>/ before this stage."));
|
|
1758
|
+
}
|
|
1599
1759
|
for (const spineIssue of report.issues.filter((issue) => issue.category === "continuity")) {
|
|
1600
|
-
if (spineIssue.message.startsWith("Missing spine
|
|
1760
|
+
if (spineIssue.message.startsWith("Missing spine directory")) {
|
|
1601
1761
|
issues.push({ ...spineIssue, level: "error" });
|
|
1602
1762
|
}
|
|
1603
1763
|
}
|
|
@@ -1609,7 +1769,7 @@ function runStageCheck(project, runtimeConfig, stage, onlyFile) {
|
|
|
1609
1769
|
}
|
|
1610
1770
|
}
|
|
1611
1771
|
const chapterPaths = report.chapters.map((chapter) => chapter.path);
|
|
1612
|
-
const spineWords = collectSpineWordsForSpellcheck(report.spineState.
|
|
1772
|
+
const spineWords = collectSpineWordsForSpellcheck(report.spineState.entriesByCategory);
|
|
1613
1773
|
if (policy.enforceMarkdownlint) {
|
|
1614
1774
|
issues.push(...runMarkdownlint(project, chapterPaths, true, "manuscript"));
|
|
1615
1775
|
}
|
|
@@ -1767,18 +1927,29 @@ function runMarkdownlint(project, files, required, profile = "default") {
|
|
|
1767
1927
|
prepared.cleanup();
|
|
1768
1928
|
}
|
|
1769
1929
|
}
|
|
1770
|
-
function collectSpineWordsForSpellcheck(
|
|
1930
|
+
function collectSpineWordsForSpellcheck(entriesByCategory) {
|
|
1771
1931
|
const words = new Set();
|
|
1772
|
-
for (const
|
|
1773
|
-
const
|
|
1774
|
-
.split(
|
|
1932
|
+
for (const [category, entries] of entriesByCategory) {
|
|
1933
|
+
const categoryParts = category
|
|
1934
|
+
.split(/[-_/]+/)
|
|
1775
1935
|
.map((part) => part.trim())
|
|
1776
1936
|
.filter(Boolean);
|
|
1777
|
-
for (const part of
|
|
1778
|
-
if (
|
|
1779
|
-
|
|
1937
|
+
for (const part of categoryParts) {
|
|
1938
|
+
if (/[A-Za-z]/.test(part)) {
|
|
1939
|
+
words.add(part.toLowerCase());
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
for (const entry of entries) {
|
|
1943
|
+
const entryParts = entry
|
|
1944
|
+
.split(/[-_/]+/)
|
|
1945
|
+
.map((part) => part.trim())
|
|
1946
|
+
.filter(Boolean);
|
|
1947
|
+
for (const part of entryParts) {
|
|
1948
|
+
if (!/[A-Za-z]/.test(part)) {
|
|
1949
|
+
continue;
|
|
1950
|
+
}
|
|
1951
|
+
words.add(part.toLowerCase());
|
|
1780
1952
|
}
|
|
1781
|
-
words.add(part.toLowerCase());
|
|
1782
1953
|
}
|
|
1783
1954
|
}
|
|
1784
1955
|
return Array.from(words).sort();
|