stego-cli 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/dist/comments/comment-domain.js +919 -0
- package/dist/comments/comments-command.js +356 -64
- 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 +205 -426
- 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/dist/comments/add-comment.js +0 -382
- 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
|
@@ -8,8 +8,12 @@ import { createInterface } from "node:readline/promises";
|
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { markdownExporter } from "./exporters/markdown-exporter.js";
|
|
10
10
|
import { createPandocExporter } from "./exporters/pandoc-exporter.js";
|
|
11
|
+
import { parseCommentAppendix } from "./comments/comment-domain.js";
|
|
11
12
|
import { runCommentsCommand } from "./comments/comments-command.js";
|
|
12
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";
|
|
13
17
|
const STATUS_RANK = {
|
|
14
18
|
draft: 0,
|
|
15
19
|
revise: 1,
|
|
@@ -110,6 +114,12 @@ let config;
|
|
|
110
114
|
void main();
|
|
111
115
|
async function main() {
|
|
112
116
|
const { command, options } = parseArgs(process.argv.slice(2));
|
|
117
|
+
if (command === "version" || command === "--version" || command === "-v") {
|
|
118
|
+
const cliPackage = readJson(path.join(packageRoot, "package.json"));
|
|
119
|
+
const version = typeof cliPackage.version === "string" ? cliPackage.version : "0.0.0";
|
|
120
|
+
console.log(version);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
113
123
|
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
114
124
|
printUsage();
|
|
115
125
|
return;
|
|
@@ -125,13 +135,31 @@ async function main() {
|
|
|
125
135
|
return;
|
|
126
136
|
case "new-project":
|
|
127
137
|
activateWorkspace(options);
|
|
128
|
-
await createProject(
|
|
138
|
+
await createProject({
|
|
139
|
+
projectId: readStringOption(options, "project"),
|
|
140
|
+
title: readStringOption(options, "title"),
|
|
141
|
+
proseFont: readStringOption(options, "prose-font"),
|
|
142
|
+
outputFormat: readStringOption(options, "format")
|
|
143
|
+
});
|
|
129
144
|
return;
|
|
130
145
|
case "new": {
|
|
131
146
|
activateWorkspace(options);
|
|
132
147
|
const project = resolveProject(readStringOption(options, "project"));
|
|
133
148
|
const createdPath = createNewManuscript(project, readStringOption(options, "i"), readStringOption(options, "filename"));
|
|
134
|
-
|
|
149
|
+
const outputFormat = parseTextOrJsonFormat(readStringOption(options, "format"));
|
|
150
|
+
if (outputFormat === "json") {
|
|
151
|
+
process.stdout.write(`${JSON.stringify({
|
|
152
|
+
ok: true,
|
|
153
|
+
operation: "new",
|
|
154
|
+
result: {
|
|
155
|
+
projectId: project.id,
|
|
156
|
+
filePath: createdPath
|
|
157
|
+
}
|
|
158
|
+
}, null, 2)}\n`);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
logLine(`Created manuscript: ${createdPath}`);
|
|
162
|
+
}
|
|
135
163
|
return;
|
|
136
164
|
}
|
|
137
165
|
case "validate": {
|
|
@@ -201,6 +229,20 @@ async function main() {
|
|
|
201
229
|
case "comments":
|
|
202
230
|
await runCommentsCommand(options, process.cwd());
|
|
203
231
|
return;
|
|
232
|
+
case "metadata":
|
|
233
|
+
await runMetadataCommand(options, process.cwd());
|
|
234
|
+
return;
|
|
235
|
+
case "spine": {
|
|
236
|
+
activateWorkspace(options);
|
|
237
|
+
const project = resolveProject(readStringOption(options, "project"));
|
|
238
|
+
runSpineCommand(options, {
|
|
239
|
+
id: project.id,
|
|
240
|
+
root: project.root,
|
|
241
|
+
spineDir: project.spineDir,
|
|
242
|
+
meta: project.meta
|
|
243
|
+
});
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
204
246
|
default:
|
|
205
247
|
throw new Error(`Unknown command '${command}'. Run with 'help' for usage.`);
|
|
206
248
|
}
|
|
@@ -231,6 +273,28 @@ function readStringOption(options, key) {
|
|
|
231
273
|
function readBooleanOption(options, key) {
|
|
232
274
|
return options[key] === true;
|
|
233
275
|
}
|
|
276
|
+
function parseTextOrJsonFormat(raw) {
|
|
277
|
+
if (!raw || raw === "text") {
|
|
278
|
+
return "text";
|
|
279
|
+
}
|
|
280
|
+
if (raw === "json") {
|
|
281
|
+
return "json";
|
|
282
|
+
}
|
|
283
|
+
throw new Error("Invalid --format value. Use 'text' or 'json'.");
|
|
284
|
+
}
|
|
285
|
+
function parseProseFontMode(raw) {
|
|
286
|
+
const normalized = (raw || "prompt").trim().toLowerCase();
|
|
287
|
+
if (normalized === "yes" || normalized === "true" || normalized === "y") {
|
|
288
|
+
return "yes";
|
|
289
|
+
}
|
|
290
|
+
if (normalized === "no" || normalized === "false" || normalized === "n") {
|
|
291
|
+
return "no";
|
|
292
|
+
}
|
|
293
|
+
if (normalized === "prompt" || normalized === "ask") {
|
|
294
|
+
return "prompt";
|
|
295
|
+
}
|
|
296
|
+
throw new Error("Invalid --prose-font value. Use 'yes', 'no', or 'prompt'.");
|
|
297
|
+
}
|
|
234
298
|
function activateWorkspace(options) {
|
|
235
299
|
const workspace = resolveWorkspaceContext(readStringOption(options, "root"));
|
|
236
300
|
repoRoot = workspace.repoRoot;
|
|
@@ -243,75 +307,6 @@ function isStageName(value) {
|
|
|
243
307
|
function isExportFormat(value) {
|
|
244
308
|
return value === "md" || value === "docx" || value === "pdf" || value === "epub";
|
|
245
309
|
}
|
|
246
|
-
function resolveSpineSchema(project) {
|
|
247
|
-
const issues = [];
|
|
248
|
-
const projectFile = path.relative(repoRoot, path.join(project.root, "stego-project.json"));
|
|
249
|
-
const rawCategories = project.meta.spineCategories;
|
|
250
|
-
if (rawCategories == null) {
|
|
251
|
-
return { schema: { categories: [], inlineIdRegex: null }, issues };
|
|
252
|
-
}
|
|
253
|
-
if (!Array.isArray(rawCategories)) {
|
|
254
|
-
issues.push(makeIssue("error", "metadata", "Project 'spineCategories' must be an array when defined.", projectFile));
|
|
255
|
-
return { schema: { categories: [], inlineIdRegex: null }, issues };
|
|
256
|
-
}
|
|
257
|
-
const categories = [];
|
|
258
|
-
const keySet = new Set();
|
|
259
|
-
const prefixSet = new Set();
|
|
260
|
-
const notesSet = new Set();
|
|
261
|
-
for (const [index, categoryEntry] of rawCategories.entries()) {
|
|
262
|
-
if (!isPlainObject(categoryEntry)) {
|
|
263
|
-
issues.push(makeIssue("error", "metadata", `Invalid spineCategories entry at index ${index}. Expected object with key, prefix, notesFile.`, projectFile));
|
|
264
|
-
continue;
|
|
265
|
-
}
|
|
266
|
-
const key = typeof categoryEntry.key === "string" ? categoryEntry.key.trim() : "";
|
|
267
|
-
const prefix = typeof categoryEntry.prefix === "string" ? categoryEntry.prefix.trim() : "";
|
|
268
|
-
const notesFile = typeof categoryEntry.notesFile === "string" ? categoryEntry.notesFile.trim() : "";
|
|
269
|
-
if (!/^[a-z][a-z0-9_-]*$/.test(key)) {
|
|
270
|
-
issues.push(makeIssue("error", "metadata", `Invalid spine category key '${key || "<empty>"}'. Use lowercase key names like 'cast' or 'incidents'.`, projectFile));
|
|
271
|
-
continue;
|
|
272
|
-
}
|
|
273
|
-
if (!/^[A-Z][A-Z0-9-]*$/.test(prefix)) {
|
|
274
|
-
issues.push(makeIssue("error", "metadata", `Invalid spine category prefix '${prefix || "<empty>"}'. Use uppercase prefixes like 'CHAR' or 'STATUTE'.`, projectFile));
|
|
275
|
-
continue;
|
|
276
|
-
}
|
|
277
|
-
if (prefix.toUpperCase() === RESERVED_COMMENT_PREFIX) {
|
|
278
|
-
issues.push(makeIssue("error", "metadata", `Invalid spine category prefix '${prefix}'. '${RESERVED_COMMENT_PREFIX}' is reserved for Stego comment IDs (e.g. CMT-0001).`, projectFile));
|
|
279
|
-
continue;
|
|
280
|
-
}
|
|
281
|
-
if (!/^[A-Za-z0-9._-]+\.md$/.test(notesFile)) {
|
|
282
|
-
issues.push(makeIssue("error", "metadata", `Invalid notesFile '${notesFile || "<empty>"}'. Use markdown filenames like 'characters.md' (resolved in spine/).`, projectFile));
|
|
283
|
-
continue;
|
|
284
|
-
}
|
|
285
|
-
if (keySet.has(key)) {
|
|
286
|
-
issues.push(makeIssue("error", "metadata", `Duplicate spine category key '${key}'.`, projectFile));
|
|
287
|
-
continue;
|
|
288
|
-
}
|
|
289
|
-
if (prefixSet.has(prefix)) {
|
|
290
|
-
issues.push(makeIssue("error", "metadata", `Duplicate spine category prefix '${prefix}'.`, projectFile));
|
|
291
|
-
continue;
|
|
292
|
-
}
|
|
293
|
-
if (notesSet.has(notesFile)) {
|
|
294
|
-
issues.push(makeIssue("error", "metadata", `Duplicate spine category notesFile '${notesFile}'.`, projectFile));
|
|
295
|
-
continue;
|
|
296
|
-
}
|
|
297
|
-
keySet.add(key);
|
|
298
|
-
prefixSet.add(prefix);
|
|
299
|
-
notesSet.add(notesFile);
|
|
300
|
-
categories.push({
|
|
301
|
-
key,
|
|
302
|
-
prefix,
|
|
303
|
-
notesFile,
|
|
304
|
-
idPattern: new RegExp(`^${escapeRegex(prefix)}-[A-Z0-9-]+$`)
|
|
305
|
-
});
|
|
306
|
-
}
|
|
307
|
-
return {
|
|
308
|
-
schema: {
|
|
309
|
-
categories,
|
|
310
|
-
inlineIdRegex: buildInlineIdRegex(categories)
|
|
311
|
-
},
|
|
312
|
-
issues
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
310
|
function resolveRequiredMetadata(project, runtimeConfig) {
|
|
316
311
|
const issues = [];
|
|
317
312
|
const projectFile = path.relative(repoRoot, path.join(project.root, "stego-project.json"));
|
|
@@ -405,16 +400,6 @@ function resolveCompileStructure(project) {
|
|
|
405
400
|
}
|
|
406
401
|
return { levels, issues };
|
|
407
402
|
}
|
|
408
|
-
function buildInlineIdRegex(categories) {
|
|
409
|
-
if (categories.length === 0) {
|
|
410
|
-
return null;
|
|
411
|
-
}
|
|
412
|
-
const prefixes = categories.map((category) => escapeRegex(category.prefix)).join("|");
|
|
413
|
-
return new RegExp(`\\b(?:${prefixes})-[A-Z0-9-]+\\b`, "g");
|
|
414
|
-
}
|
|
415
|
-
function escapeRegex(value) {
|
|
416
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
417
|
-
}
|
|
418
403
|
function isPlainObject(value) {
|
|
419
404
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
420
405
|
}
|
|
@@ -752,6 +737,8 @@ function writeInitRootPackageJson(targetRoot) {
|
|
|
752
737
|
"list-projects": "stego list-projects",
|
|
753
738
|
"new-project": "stego new-project",
|
|
754
739
|
new: "stego new",
|
|
740
|
+
spine: "stego spine",
|
|
741
|
+
metadata: "stego metadata",
|
|
755
742
|
lint: "stego lint",
|
|
756
743
|
validate: "stego validate",
|
|
757
744
|
build: "stego build",
|
|
@@ -767,7 +754,7 @@ function writeInitRootPackageJson(targetRoot) {
|
|
|
767
754
|
fs.writeFileSync(path.join(targetRoot, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
768
755
|
}
|
|
769
756
|
function printUsage() {
|
|
770
|
-
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 add <manuscript> [--message <text> | --input <path|->] [--author <name>] [--start-line <n> --start-col <n> --end-line <n> --end-col <n>] [--format <text|json>]\n`);
|
|
757
|
+
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`);
|
|
771
758
|
}
|
|
772
759
|
function listProjects() {
|
|
773
760
|
const ids = getProjectIds();
|
|
@@ -780,8 +767,8 @@ function listProjects() {
|
|
|
780
767
|
console.log(`- ${id}`);
|
|
781
768
|
}
|
|
782
769
|
}
|
|
783
|
-
async function createProject(
|
|
784
|
-
const projectId = (
|
|
770
|
+
async function createProject(options) {
|
|
771
|
+
const projectId = (options.projectId || "").trim();
|
|
785
772
|
if (!projectId) {
|
|
786
773
|
throw new Error("Project id is required. Use --project <project-id>.");
|
|
787
774
|
}
|
|
@@ -801,7 +788,7 @@ async function createProject(projectIdOption, titleOption) {
|
|
|
801
788
|
const manuscriptDir = path.join(projectRoot, config.chapterDir);
|
|
802
789
|
const projectJson = {
|
|
803
790
|
id: projectId,
|
|
804
|
-
title:
|
|
791
|
+
title: options.title?.trim() || toDisplayTitle(projectId),
|
|
805
792
|
requiredMetadata: ["status"],
|
|
806
793
|
compileStructure: {
|
|
807
794
|
levels: [
|
|
@@ -814,14 +801,7 @@ async function createProject(projectIdOption, titleOption) {
|
|
|
814
801
|
pageBreak: "between-groups"
|
|
815
802
|
}
|
|
816
803
|
]
|
|
817
|
-
}
|
|
818
|
-
spineCategories: [
|
|
819
|
-
{
|
|
820
|
-
key: "characters",
|
|
821
|
-
prefix: "CHAR",
|
|
822
|
-
notesFile: "characters.md"
|
|
823
|
-
}
|
|
824
|
-
]
|
|
804
|
+
}
|
|
825
805
|
};
|
|
826
806
|
const projectJsonPath = path.join(projectRoot, "stego-project.json");
|
|
827
807
|
fs.writeFileSync(projectJsonPath, `${JSON.stringify(projectJson, null, 2)}\n`, "utf8");
|
|
@@ -830,6 +810,8 @@ async function createProject(projectIdOption, titleOption) {
|
|
|
830
810
|
private: true,
|
|
831
811
|
scripts: {
|
|
832
812
|
new: "npx --no-install stego new",
|
|
813
|
+
"spine:new": "npx --no-install stego spine new",
|
|
814
|
+
"spine:new-category": "npx --no-install stego spine new-category",
|
|
833
815
|
lint: "npx --no-install stego lint",
|
|
834
816
|
validate: "npx --no-install stego validate",
|
|
835
817
|
build: "npx --no-install stego build",
|
|
@@ -850,20 +832,57 @@ chapter_title: Hello World
|
|
|
850
832
|
|
|
851
833
|
Start writing here.
|
|
852
834
|
`, "utf8");
|
|
853
|
-
const
|
|
854
|
-
fs.
|
|
835
|
+
const charactersDir = path.join(spineDir, "characters");
|
|
836
|
+
fs.mkdirSync(charactersDir, { recursive: true });
|
|
837
|
+
const charactersCategoryPath = path.join(charactersDir, "_category.md");
|
|
838
|
+
fs.writeFileSync(charactersCategoryPath, `---
|
|
839
|
+
label: Characters
|
|
840
|
+
---
|
|
841
|
+
|
|
842
|
+
# Characters
|
|
843
|
+
|
|
844
|
+
`, "utf8");
|
|
845
|
+
const charactersEntryPath = path.join(charactersDir, "example-character.md");
|
|
846
|
+
fs.writeFileSync(charactersEntryPath, `# Example Character
|
|
847
|
+
|
|
848
|
+
`, "utf8");
|
|
855
849
|
const projectExtensionsPath = path.join(projectRoot, ".vscode", "extensions.json");
|
|
856
850
|
ensureProjectExtensionsRecommendations(projectRoot);
|
|
857
851
|
let projectSettingsPath = null;
|
|
858
|
-
const
|
|
852
|
+
const proseFontMode = parseProseFontMode(options.proseFont);
|
|
853
|
+
const enableProseFont = proseFontMode === "prompt"
|
|
854
|
+
? await promptYesNo(PROSE_FONT_PROMPT, true)
|
|
855
|
+
: proseFontMode === "yes";
|
|
859
856
|
if (enableProseFont) {
|
|
860
857
|
projectSettingsPath = writeProseEditorSettingsForProject(projectRoot);
|
|
861
858
|
}
|
|
859
|
+
const outputFormat = parseTextOrJsonFormat(options.outputFormat);
|
|
860
|
+
if (outputFormat === "json") {
|
|
861
|
+
process.stdout.write(`${JSON.stringify({
|
|
862
|
+
ok: true,
|
|
863
|
+
operation: "new-project",
|
|
864
|
+
result: {
|
|
865
|
+
projectId,
|
|
866
|
+
projectPath: path.relative(repoRoot, projectRoot),
|
|
867
|
+
files: [
|
|
868
|
+
path.relative(repoRoot, projectJsonPath),
|
|
869
|
+
path.relative(repoRoot, projectPackagePath),
|
|
870
|
+
path.relative(repoRoot, starterManuscriptPath),
|
|
871
|
+
path.relative(repoRoot, charactersCategoryPath),
|
|
872
|
+
path.relative(repoRoot, charactersEntryPath),
|
|
873
|
+
path.relative(repoRoot, projectExtensionsPath),
|
|
874
|
+
...(projectSettingsPath ? [path.relative(repoRoot, projectSettingsPath)] : [])
|
|
875
|
+
]
|
|
876
|
+
}
|
|
877
|
+
}, null, 2)}\n`);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
862
880
|
logLine(`Created project: ${path.relative(repoRoot, projectRoot)}`);
|
|
863
881
|
logLine(`- ${path.relative(repoRoot, projectJsonPath)}`);
|
|
864
882
|
logLine(`- ${path.relative(repoRoot, projectPackagePath)}`);
|
|
865
883
|
logLine(`- ${path.relative(repoRoot, starterManuscriptPath)}`);
|
|
866
|
-
logLine(`- ${path.relative(repoRoot,
|
|
884
|
+
logLine(`- ${path.relative(repoRoot, charactersCategoryPath)}`);
|
|
885
|
+
logLine(`- ${path.relative(repoRoot, charactersEntryPath)}`);
|
|
867
886
|
logLine(`- ${path.relative(repoRoot, projectExtensionsPath)}`);
|
|
868
887
|
if (projectSettingsPath) {
|
|
869
888
|
logLine(`- ${path.relative(repoRoot, projectSettingsPath)}`);
|
|
@@ -1060,13 +1079,16 @@ function inferProjectIdFromCwd(cwd) {
|
|
|
1060
1079
|
}
|
|
1061
1080
|
function inspectProject(project, runtimeConfig, options = {}) {
|
|
1062
1081
|
const issues = [];
|
|
1063
|
-
const emptySpineState = {
|
|
1064
|
-
const spineSchema = resolveSpineSchema(project);
|
|
1082
|
+
const emptySpineState = { categories: [], entriesByCategory: new Map(), issues: [] };
|
|
1065
1083
|
const requiredMetadataState = resolveRequiredMetadata(project, runtimeConfig);
|
|
1066
1084
|
const compileStructureState = resolveCompileStructure(project);
|
|
1067
|
-
issues.push(...spineSchema.issues);
|
|
1068
1085
|
issues.push(...requiredMetadataState.issues);
|
|
1069
1086
|
issues.push(...compileStructureState.issues);
|
|
1087
|
+
if (project.meta.spineCategories !== undefined) {
|
|
1088
|
+
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"))));
|
|
1089
|
+
}
|
|
1090
|
+
const spineState = readSpine(project);
|
|
1091
|
+
issues.push(...spineState.issues);
|
|
1070
1092
|
let chapterFiles = [];
|
|
1071
1093
|
const onlyFile = options.onlyFile?.trim();
|
|
1072
1094
|
if (onlyFile) {
|
|
@@ -1106,7 +1128,7 @@ function inspectProject(project, runtimeConfig, options = {}) {
|
|
|
1106
1128
|
return { chapters: [], issues, spineState: emptySpineState, compileStructureLevels: compileStructureState.levels };
|
|
1107
1129
|
}
|
|
1108
1130
|
}
|
|
1109
|
-
const chapters = chapterFiles.map((chapterPath) => parseChapter(chapterPath, runtimeConfig, requiredMetadataState.requiredMetadata,
|
|
1131
|
+
const chapters = chapterFiles.map((chapterPath) => parseChapter(chapterPath, runtimeConfig, requiredMetadataState.requiredMetadata, spineState.categories, compileStructureState.levels));
|
|
1110
1132
|
for (const chapter of chapters) {
|
|
1111
1133
|
issues.push(...chapter.issues);
|
|
1112
1134
|
}
|
|
@@ -1133,10 +1155,8 @@ function inspectProject(project, runtimeConfig, options = {}) {
|
|
|
1133
1155
|
}
|
|
1134
1156
|
return a.order - b.order;
|
|
1135
1157
|
});
|
|
1136
|
-
const spineState = readSpine(project.spineDir, spineSchema.schema.categories, spineSchema.schema.inlineIdRegex);
|
|
1137
|
-
issues.push(...spineState.issues);
|
|
1138
1158
|
for (const chapter of chapters) {
|
|
1139
|
-
issues.push(...
|
|
1159
|
+
issues.push(...findUnknownSpineReferences(chapter.referenceKeysByCategory, spineState.entriesByCategory, chapter.relativePath));
|
|
1140
1160
|
}
|
|
1141
1161
|
return {
|
|
1142
1162
|
chapters,
|
|
@@ -1145,7 +1165,7 @@ function inspectProject(project, runtimeConfig, options = {}) {
|
|
|
1145
1165
|
compileStructureLevels: compileStructureState.levels
|
|
1146
1166
|
};
|
|
1147
1167
|
}
|
|
1148
|
-
function parseChapter(chapterPath, runtimeConfig, requiredMetadata, spineCategories,
|
|
1168
|
+
function parseChapter(chapterPath, runtimeConfig, requiredMetadata, spineCategories, compileStructureLevels) {
|
|
1149
1169
|
const relativePath = path.relative(repoRoot, chapterPath);
|
|
1150
1170
|
const raw = fs.readFileSync(chapterPath, "utf8");
|
|
1151
1171
|
const { metadata, body, comments, issues } = parseMetadata(raw, chapterPath, false);
|
|
@@ -1174,9 +1194,8 @@ function parseChapter(chapterPath, runtimeConfig, requiredMetadata, spineCategor
|
|
|
1174
1194
|
void normalizeGroupingValue(metadata[level.titleKey], relativePath, chapterIssues, level.titleKey);
|
|
1175
1195
|
}
|
|
1176
1196
|
}
|
|
1177
|
-
const referenceValidation =
|
|
1197
|
+
const referenceValidation = extractReferenceKeysByCategory(metadata, relativePath, spineCategories);
|
|
1178
1198
|
chapterIssues.push(...referenceValidation.issues);
|
|
1179
|
-
chapterIssues.push(...findInlineSpineIdMentions(body, relativePath, inlineIdRegex));
|
|
1180
1199
|
chapterIssues.push(...validateMarkdownBody(body, chapterPath));
|
|
1181
1200
|
return {
|
|
1182
1201
|
path: chapterPath,
|
|
@@ -1184,7 +1203,7 @@ function parseChapter(chapterPath, runtimeConfig, requiredMetadata, spineCategor
|
|
|
1184
1203
|
title,
|
|
1185
1204
|
order,
|
|
1186
1205
|
status,
|
|
1187
|
-
|
|
1206
|
+
referenceKeysByCategory: referenceValidation.referencesByCategory,
|
|
1188
1207
|
groupValues,
|
|
1189
1208
|
metadata,
|
|
1190
1209
|
body,
|
|
@@ -1227,49 +1246,39 @@ function parseOrderFromFilename(chapterPath, relativePath, issues) {
|
|
|
1227
1246
|
}
|
|
1228
1247
|
return Number(match[1]);
|
|
1229
1248
|
}
|
|
1230
|
-
function
|
|
1249
|
+
function extractReferenceKeysByCategory(metadata, relativePath, spineCategories) {
|
|
1231
1250
|
const issues = [];
|
|
1232
|
-
const
|
|
1251
|
+
const referencesByCategory = {};
|
|
1233
1252
|
for (const category of spineCategories) {
|
|
1234
1253
|
const rawValue = metadata[category.key];
|
|
1235
1254
|
if (rawValue == null || rawValue === "") {
|
|
1236
1255
|
continue;
|
|
1237
1256
|
}
|
|
1238
1257
|
if (!Array.isArray(rawValue)) {
|
|
1239
|
-
issues.push(makeIssue("error", "metadata", `Metadata '${category.key}' must be an array
|
|
1258
|
+
issues.push(makeIssue("error", "metadata", `Metadata '${category.key}' must be an array of spine entry keys (for example: [\"matthaeus\"]).`, relativePath));
|
|
1240
1259
|
continue;
|
|
1241
1260
|
}
|
|
1261
|
+
const seen = new Set();
|
|
1262
|
+
const values = [];
|
|
1242
1263
|
for (const entry of rawValue) {
|
|
1243
1264
|
if (typeof entry !== "string") {
|
|
1244
1265
|
issues.push(makeIssue("error", "metadata", `Metadata '${category.key}' entries must be strings.`, relativePath));
|
|
1245
1266
|
continue;
|
|
1246
1267
|
}
|
|
1247
|
-
const
|
|
1248
|
-
if (!
|
|
1249
|
-
issues.push(makeIssue("error", "metadata", `
|
|
1268
|
+
const normalized = entry.trim();
|
|
1269
|
+
if (!normalized) {
|
|
1270
|
+
issues.push(makeIssue("error", "metadata", `Metadata '${category.key}' contains an empty entry key.`, relativePath));
|
|
1250
1271
|
continue;
|
|
1251
1272
|
}
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
function findInlineSpineIdMentions(body, relativePath, inlineIdRegex) {
|
|
1258
|
-
const issues = [];
|
|
1259
|
-
if (!inlineIdRegex) {
|
|
1260
|
-
return issues;
|
|
1261
|
-
}
|
|
1262
|
-
const lines = body.split(/\r?\n/);
|
|
1263
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
1264
|
-
const matches = lines[index].match(inlineIdRegex);
|
|
1265
|
-
if (!matches) {
|
|
1266
|
-
continue;
|
|
1267
|
-
}
|
|
1268
|
-
for (const id of matches) {
|
|
1269
|
-
issues.push(makeIssue("error", "continuity", `Inline ID '${id}' found in prose. Move canon IDs to metadata fields only.`, relativePath, index + 1));
|
|
1273
|
+
if (seen.has(normalized)) {
|
|
1274
|
+
continue;
|
|
1275
|
+
}
|
|
1276
|
+
seen.add(normalized);
|
|
1277
|
+
values.push(normalized);
|
|
1270
1278
|
}
|
|
1279
|
+
referencesByCategory[category.key] = values;
|
|
1271
1280
|
}
|
|
1272
|
-
return issues;
|
|
1281
|
+
return { referencesByCategory, issues };
|
|
1273
1282
|
}
|
|
1274
1283
|
function parseMetadata(raw, chapterPath, required) {
|
|
1275
1284
|
const relativePath = path.relative(repoRoot, chapterPath);
|
|
@@ -1375,265 +1384,29 @@ function parseMetadata(raw, chapterPath, required) {
|
|
|
1375
1384
|
};
|
|
1376
1385
|
}
|
|
1377
1386
|
function parseStegoCommentsAppendix(body, relativePath, bodyStartLine) {
|
|
1378
|
-
const
|
|
1379
|
-
const
|
|
1380
|
-
const
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
if (startIndexes.length === 0 && endIndexes.length === 0) {
|
|
1386
|
-
return { bodyWithoutComments: body, comments: [], issues };
|
|
1387
|
-
}
|
|
1388
|
-
if (startIndexes.length !== 1 || endIndexes.length !== 1) {
|
|
1389
|
-
if (startIndexes.length !== 1) {
|
|
1390
|
-
issues.push(makeIssue("error", "comments", `Expected exactly one '${startMarker}' marker.`, relativePath));
|
|
1391
|
-
}
|
|
1392
|
-
if (endIndexes.length !== 1) {
|
|
1393
|
-
issues.push(makeIssue("error", "comments", `Expected exactly one '${endMarker}' marker.`, relativePath));
|
|
1394
|
-
}
|
|
1395
|
-
return { bodyWithoutComments: body, comments: [], issues };
|
|
1396
|
-
}
|
|
1397
|
-
const start = startIndexes[0];
|
|
1398
|
-
const end = endIndexes[0];
|
|
1399
|
-
if (end <= start) {
|
|
1400
|
-
issues.push(makeIssue("error", "comments", `'${endMarker}' must appear after '${startMarker}'.`, relativePath, bodyStartLine + end));
|
|
1401
|
-
return { bodyWithoutComments: body, comments: [], issues };
|
|
1402
|
-
}
|
|
1403
|
-
const blockLines = lines.slice(start + 1, end);
|
|
1404
|
-
const comments = parseStegoCommentThreads(blockLines, relativePath, bodyStartLine + start + 1, issues);
|
|
1405
|
-
let removeStart = start;
|
|
1406
|
-
if (removeStart > 0 && lines[removeStart - 1].trim().length === 0) {
|
|
1407
|
-
removeStart -= 1;
|
|
1408
|
-
}
|
|
1409
|
-
const kept = [...lines.slice(0, removeStart), ...lines.slice(end + 1)];
|
|
1410
|
-
while (kept.length > 0 && kept[kept.length - 1].trim().length === 0) {
|
|
1411
|
-
kept.pop();
|
|
1412
|
-
}
|
|
1387
|
+
const parsed = parseCommentAppendix(body);
|
|
1388
|
+
const issues = parsed.errors.map((error) => parseCommentIssueFromParserError(error, relativePath, bodyStartLine));
|
|
1389
|
+
const comments = parsed.comments.map((comment) => ({
|
|
1390
|
+
id: comment.id,
|
|
1391
|
+
resolved: comment.status === "resolved",
|
|
1392
|
+
thread: comment.thread
|
|
1393
|
+
}));
|
|
1413
1394
|
return {
|
|
1414
|
-
bodyWithoutComments:
|
|
1395
|
+
bodyWithoutComments: parsed.contentWithoutComments,
|
|
1415
1396
|
comments,
|
|
1416
1397
|
issues
|
|
1417
1398
|
};
|
|
1418
1399
|
}
|
|
1419
|
-
function
|
|
1420
|
-
const
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
const trimmed = lines[index].trim();
|
|
1424
|
-
if (!trimmed) {
|
|
1425
|
-
index += 1;
|
|
1426
|
-
continue;
|
|
1427
|
-
}
|
|
1428
|
-
const id = parseCommentThreadDelimiter(trimmed);
|
|
1429
|
-
if (!id) {
|
|
1430
|
-
issues.push(makeIssue("error", "comments", "Invalid comments appendix line. Expected comment delimiter '<!-- comment: CMT-0001 -->'.", relativePath, baseLine + index));
|
|
1431
|
-
index += 1;
|
|
1432
|
-
continue;
|
|
1433
|
-
}
|
|
1434
|
-
index += 1;
|
|
1435
|
-
const rowLines = [];
|
|
1436
|
-
const rowLineNumbers = [];
|
|
1437
|
-
while (index < lines.length) {
|
|
1438
|
-
const nextTrimmed = lines[index].trim();
|
|
1439
|
-
if (parseCommentThreadDelimiter(nextTrimmed)) {
|
|
1440
|
-
break;
|
|
1441
|
-
}
|
|
1442
|
-
rowLines.push(lines[index]);
|
|
1443
|
-
rowLineNumbers.push(baseLine + index);
|
|
1444
|
-
index += 1;
|
|
1445
|
-
}
|
|
1446
|
-
let resolved;
|
|
1447
|
-
let sawMeta64 = false;
|
|
1448
|
-
const thread = [];
|
|
1449
|
-
let rowIndex = 0;
|
|
1450
|
-
while (rowIndex < rowLines.length) {
|
|
1451
|
-
const rawRow = rowLines[rowIndex];
|
|
1452
|
-
const lineNumber = rowLineNumbers[rowIndex];
|
|
1453
|
-
const trimmedRow = rawRow.trim();
|
|
1454
|
-
if (!trimmedRow) {
|
|
1455
|
-
rowIndex += 1;
|
|
1456
|
-
continue;
|
|
1457
|
-
}
|
|
1458
|
-
if (thread.length > 0) {
|
|
1459
|
-
issues.push(makeIssue("error", "comments", `Multiple message blocks found for ${id}. Create a new CMT id for each reply.`, relativePath, lineNumber));
|
|
1460
|
-
break;
|
|
1461
|
-
}
|
|
1462
|
-
if (!sawMeta64) {
|
|
1463
|
-
const metaMatch = trimmedRow.match(/^<!--\s*meta64:\s*(\S+)\s*-->\s*$/);
|
|
1464
|
-
if (!metaMatch) {
|
|
1465
|
-
issues.push(makeIssue("error", "comments", `Invalid comment metadata row '${trimmedRow}'. Expected '<!-- meta64: <base64url-json> -->'.`, relativePath, lineNumber));
|
|
1466
|
-
rowIndex += 1;
|
|
1467
|
-
continue;
|
|
1468
|
-
}
|
|
1469
|
-
sawMeta64 = true;
|
|
1470
|
-
const decoded = decodeCommentMeta64(metaMatch[1], id, relativePath, lineNumber, issues);
|
|
1471
|
-
if (decoded) {
|
|
1472
|
-
resolved = decoded.resolved;
|
|
1473
|
-
}
|
|
1474
|
-
rowIndex += 1;
|
|
1475
|
-
continue;
|
|
1476
|
-
}
|
|
1477
|
-
const headerQuote = extractQuotedLine(rawRow);
|
|
1478
|
-
if (headerQuote === undefined) {
|
|
1479
|
-
issues.push(makeIssue("error", "comments", `Invalid thread header '${trimmedRow}'. Expected blockquote header like '> _timestamp — author_'.`, relativePath, lineNumber));
|
|
1480
|
-
rowIndex += 1;
|
|
1481
|
-
continue;
|
|
1482
|
-
}
|
|
1483
|
-
const header = parseThreadHeader(headerQuote);
|
|
1484
|
-
if (!header) {
|
|
1485
|
-
issues.push(makeIssue("error", "comments", `Invalid thread header '${headerQuote.trim()}'. Expected '> _timestamp — author_'.`, relativePath, lineNumber));
|
|
1486
|
-
rowIndex += 1;
|
|
1487
|
-
continue;
|
|
1488
|
-
}
|
|
1489
|
-
rowIndex += 1;
|
|
1490
|
-
while (rowIndex < rowLines.length) {
|
|
1491
|
-
const separatorRaw = rowLines[rowIndex];
|
|
1492
|
-
const separatorTrimmed = separatorRaw.trim();
|
|
1493
|
-
if (!separatorTrimmed) {
|
|
1494
|
-
rowIndex += 1;
|
|
1495
|
-
continue;
|
|
1496
|
-
}
|
|
1497
|
-
const separatorQuote = extractQuotedLine(separatorRaw);
|
|
1498
|
-
if (separatorQuote !== undefined && separatorQuote.trim().length === 0) {
|
|
1499
|
-
rowIndex += 1;
|
|
1500
|
-
}
|
|
1501
|
-
break;
|
|
1502
|
-
}
|
|
1503
|
-
const messageLines = [];
|
|
1504
|
-
while (rowIndex < rowLines.length) {
|
|
1505
|
-
const messageRaw = rowLines[rowIndex];
|
|
1506
|
-
const messageLineNumber = rowLineNumbers[rowIndex];
|
|
1507
|
-
const messageTrimmed = messageRaw.trim();
|
|
1508
|
-
if (!messageTrimmed) {
|
|
1509
|
-
rowIndex += 1;
|
|
1510
|
-
if (messageLines.length > 0) {
|
|
1511
|
-
break;
|
|
1512
|
-
}
|
|
1513
|
-
continue;
|
|
1514
|
-
}
|
|
1515
|
-
const messageQuote = extractQuotedLine(messageRaw);
|
|
1516
|
-
if (messageQuote === undefined) {
|
|
1517
|
-
issues.push(makeIssue("error", "comments", `Invalid thread line '${messageTrimmed}'. Expected blockquote content starting with '>'.`, relativePath, messageLineNumber));
|
|
1518
|
-
rowIndex += 1;
|
|
1519
|
-
if (messageLines.length > 0) {
|
|
1520
|
-
break;
|
|
1521
|
-
}
|
|
1522
|
-
continue;
|
|
1523
|
-
}
|
|
1524
|
-
if (parseThreadHeader(messageQuote)) {
|
|
1525
|
-
break;
|
|
1526
|
-
}
|
|
1527
|
-
messageLines.push(messageQuote);
|
|
1528
|
-
rowIndex += 1;
|
|
1529
|
-
}
|
|
1530
|
-
while (messageLines.length > 0 && messageLines[messageLines.length - 1].trim().length === 0) {
|
|
1531
|
-
messageLines.pop();
|
|
1532
|
-
}
|
|
1533
|
-
if (messageLines.length === 0) {
|
|
1534
|
-
issues.push(makeIssue("error", "comments", `Thread entry for comment ${id} is missing message text.`, relativePath, lineNumber));
|
|
1535
|
-
continue;
|
|
1536
|
-
}
|
|
1537
|
-
const message = messageLines.join("\n").trim();
|
|
1538
|
-
thread.push(`${header.timestamp} | ${header.author} | ${message}`);
|
|
1539
|
-
}
|
|
1540
|
-
if (!sawMeta64) {
|
|
1541
|
-
issues.push(makeIssue("error", "comments", `Comment ${id} is missing metadata row ('<!-- meta64: <base64url-json> -->').`, relativePath));
|
|
1542
|
-
resolved = false;
|
|
1543
|
-
}
|
|
1544
|
-
if (thread.length === 0) {
|
|
1545
|
-
issues.push(makeIssue("error", "comments", `Comment ${id} is missing valid blockquote thread entries.`, relativePath));
|
|
1546
|
-
}
|
|
1547
|
-
comments.push({ id, resolved: Boolean(resolved), thread });
|
|
1400
|
+
function parseCommentIssueFromParserError(error, relativePath, bodyStartLine) {
|
|
1401
|
+
const lineMatch = error.match(/^Line\\s+(\\d+):\\s+([\\s\\S]+)$/);
|
|
1402
|
+
if (!lineMatch) {
|
|
1403
|
+
return makeIssue("error", "comments", error, relativePath);
|
|
1548
1404
|
}
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
return markerMatch[1].toUpperCase();
|
|
1555
|
-
}
|
|
1556
|
-
const legacyHeadingMatch = line.match(/^###\s+(CMT-\d{4,})\s*$/i);
|
|
1557
|
-
if (legacyHeadingMatch?.[1]) {
|
|
1558
|
-
return legacyHeadingMatch[1].toUpperCase();
|
|
1559
|
-
}
|
|
1560
|
-
return undefined;
|
|
1561
|
-
}
|
|
1562
|
-
function decodeCommentMeta64(encoded, commentId, relativePath, lineNumber, issues) {
|
|
1563
|
-
let rawJson = "";
|
|
1564
|
-
try {
|
|
1565
|
-
rawJson = Buffer.from(encoded, "base64url").toString("utf8");
|
|
1566
|
-
}
|
|
1567
|
-
catch {
|
|
1568
|
-
issues.push(makeIssue("error", "comments", `Invalid meta64 payload for comment ${commentId}; expected base64url-encoded JSON.`, relativePath, lineNumber));
|
|
1569
|
-
return undefined;
|
|
1570
|
-
}
|
|
1571
|
-
let parsed;
|
|
1572
|
-
try {
|
|
1573
|
-
parsed = JSON.parse(rawJson);
|
|
1574
|
-
}
|
|
1575
|
-
catch {
|
|
1576
|
-
issues.push(makeIssue("error", "comments", `Invalid meta64 JSON for comment ${commentId}.`, relativePath, lineNumber));
|
|
1577
|
-
return undefined;
|
|
1578
|
-
}
|
|
1579
|
-
if (!isPlainObject(parsed)) {
|
|
1580
|
-
issues.push(makeIssue("error", "comments", `Invalid meta64 object for comment ${commentId}.`, relativePath, lineNumber));
|
|
1581
|
-
return undefined;
|
|
1582
|
-
}
|
|
1583
|
-
const allowedKeys = new Set([
|
|
1584
|
-
"status",
|
|
1585
|
-
"created_at",
|
|
1586
|
-
"timezone",
|
|
1587
|
-
"timezone_offset_minutes",
|
|
1588
|
-
"paragraph_index",
|
|
1589
|
-
"excerpt_start_line",
|
|
1590
|
-
"excerpt_start_col",
|
|
1591
|
-
"excerpt_end_line",
|
|
1592
|
-
"excerpt_end_col",
|
|
1593
|
-
"anchor",
|
|
1594
|
-
"excerpt",
|
|
1595
|
-
"signature"
|
|
1596
|
-
]);
|
|
1597
|
-
for (const key of Object.keys(parsed)) {
|
|
1598
|
-
if (!allowedKeys.has(key)) {
|
|
1599
|
-
issues.push(makeIssue("error", "comments", `meta64 for comment ${commentId} contains unsupported key '${key}'.`, relativePath, lineNumber));
|
|
1600
|
-
return undefined;
|
|
1601
|
-
}
|
|
1602
|
-
}
|
|
1603
|
-
const status = typeof parsed.status === "string" ? parsed.status.trim().toLowerCase() : "";
|
|
1604
|
-
if (status !== "open" && status !== "resolved") {
|
|
1605
|
-
issues.push(makeIssue("error", "comments", `meta64 for comment ${commentId} must include status 'open' or 'resolved'.`, relativePath, lineNumber));
|
|
1606
|
-
return undefined;
|
|
1607
|
-
}
|
|
1608
|
-
return { resolved: status === "resolved" };
|
|
1609
|
-
}
|
|
1610
|
-
function extractQuotedLine(raw) {
|
|
1611
|
-
const quoteMatch = raw.match(/^\s*>\s?(.*)$/);
|
|
1612
|
-
if (!quoteMatch) {
|
|
1613
|
-
return undefined;
|
|
1614
|
-
}
|
|
1615
|
-
return quoteMatch[1];
|
|
1616
|
-
}
|
|
1617
|
-
function parseThreadHeader(value) {
|
|
1618
|
-
const match = value.trim().match(/^_(.+?)\s*(?:—|\|)\s*(.+?)_\s*$/);
|
|
1619
|
-
if (!match) {
|
|
1620
|
-
return undefined;
|
|
1621
|
-
}
|
|
1622
|
-
const timestamp = match[1].trim();
|
|
1623
|
-
const author = match[2].trim();
|
|
1624
|
-
if (!timestamp || !author) {
|
|
1625
|
-
return undefined;
|
|
1626
|
-
}
|
|
1627
|
-
return { timestamp, author };
|
|
1628
|
-
}
|
|
1629
|
-
function findTrimmedLineIndexes(lines, marker) {
|
|
1630
|
-
const indexes = [];
|
|
1631
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
1632
|
-
if (lines[index].trim() === marker) {
|
|
1633
|
-
indexes.push(index);
|
|
1634
|
-
}
|
|
1635
|
-
}
|
|
1636
|
-
return indexes;
|
|
1405
|
+
const relativeLine = Number.parseInt(lineMatch[1], 10);
|
|
1406
|
+
const absoluteLine = Number.isFinite(relativeLine)
|
|
1407
|
+
? bodyStartLine + relativeLine - 1
|
|
1408
|
+
: undefined;
|
|
1409
|
+
return makeIssue("error", "comments", lineMatch[2], relativePath, absoluteLine);
|
|
1637
1410
|
}
|
|
1638
1411
|
function coerceMetadataValue(value) {
|
|
1639
1412
|
if (!value) {
|
|
@@ -1762,39 +1535,31 @@ function runStyleHeuristics(body, relativePath) {
|
|
|
1762
1535
|
function countWords(text) {
|
|
1763
1536
|
return text.trim().split(/\s+/).filter(Boolean).length;
|
|
1764
1537
|
}
|
|
1765
|
-
function readSpine(
|
|
1766
|
-
const
|
|
1767
|
-
const
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
return { ids, issues };
|
|
1774
|
-
}
|
|
1775
|
-
for (const category of spineCategories) {
|
|
1776
|
-
const fullPath = path.join(spineDir, category.notesFile);
|
|
1777
|
-
const relativePath = path.relative(repoRoot, fullPath);
|
|
1778
|
-
if (!fs.existsSync(fullPath)) {
|
|
1779
|
-
issues.push(makeIssue("warning", "continuity", `Missing spine file '${category.notesFile}' for category '${category.key}'.`, relativePath));
|
|
1780
|
-
continue;
|
|
1781
|
-
}
|
|
1782
|
-
const text = fs.readFileSync(fullPath, "utf8");
|
|
1783
|
-
if (!inlineIdRegex) {
|
|
1784
|
-
continue;
|
|
1785
|
-
}
|
|
1786
|
-
const matches = text.match(inlineIdRegex) || [];
|
|
1787
|
-
for (const id of matches) {
|
|
1788
|
-
ids.add(id);
|
|
1789
|
-
}
|
|
1538
|
+
function readSpine(project) {
|
|
1539
|
+
const catalog = readSpineCatalog(project.root, project.spineDir);
|
|
1540
|
+
const categories = [];
|
|
1541
|
+
const entriesByCategory = new Map();
|
|
1542
|
+
for (const category of catalog.categories) {
|
|
1543
|
+
const entries = new Set(category.entries.map((entry) => entry.key));
|
|
1544
|
+
categories.push({ key: category.key, entries });
|
|
1545
|
+
entriesByCategory.set(category.key, entries);
|
|
1790
1546
|
}
|
|
1791
|
-
|
|
1547
|
+
const issues = catalog.issues.map((message) => makeIssue("warning", "continuity", message));
|
|
1548
|
+
return { categories, entriesByCategory, issues };
|
|
1792
1549
|
}
|
|
1793
|
-
function
|
|
1550
|
+
function findUnknownSpineReferences(referencesByCategory, entriesByCategory, relativePath) {
|
|
1794
1551
|
const issues = [];
|
|
1795
|
-
for (const
|
|
1796
|
-
|
|
1797
|
-
|
|
1552
|
+
for (const [categoryKey, values] of Object.entries(referencesByCategory)) {
|
|
1553
|
+
const known = entriesByCategory.get(categoryKey);
|
|
1554
|
+
if (!known) {
|
|
1555
|
+
issues.push(makeIssue("warning", "continuity", `Metadata category '${categoryKey}' has references but no matching spine category directory was found in spine/.`, relativePath));
|
|
1556
|
+
continue;
|
|
1557
|
+
}
|
|
1558
|
+
for (const value of values) {
|
|
1559
|
+
if (known.has(value)) {
|
|
1560
|
+
continue;
|
|
1561
|
+
}
|
|
1562
|
+
issues.push(makeIssue("warning", "continuity", `Metadata reference '${categoryKey}: ${value}' does not exist in spine/${categoryKey}/.`, relativePath));
|
|
1798
1563
|
}
|
|
1799
1564
|
}
|
|
1800
1565
|
return issues;
|
|
@@ -1831,8 +1596,11 @@ function runStageCheck(project, runtimeConfig, stage, onlyFile) {
|
|
|
1831
1596
|
}
|
|
1832
1597
|
}
|
|
1833
1598
|
if (policy.requireSpine) {
|
|
1599
|
+
if (report.spineState.categories.length === 0) {
|
|
1600
|
+
issues.push(makeIssue("error", "continuity", "No spine categories found. Add at least one category under spine/<category>/ before this stage."));
|
|
1601
|
+
}
|
|
1834
1602
|
for (const spineIssue of report.issues.filter((issue) => issue.category === "continuity")) {
|
|
1835
|
-
if (spineIssue.message.startsWith("Missing spine
|
|
1603
|
+
if (spineIssue.message.startsWith("Missing spine directory")) {
|
|
1836
1604
|
issues.push({ ...spineIssue, level: "error" });
|
|
1837
1605
|
}
|
|
1838
1606
|
}
|
|
@@ -1844,7 +1612,7 @@ function runStageCheck(project, runtimeConfig, stage, onlyFile) {
|
|
|
1844
1612
|
}
|
|
1845
1613
|
}
|
|
1846
1614
|
const chapterPaths = report.chapters.map((chapter) => chapter.path);
|
|
1847
|
-
const spineWords = collectSpineWordsForSpellcheck(report.spineState.
|
|
1615
|
+
const spineWords = collectSpineWordsForSpellcheck(report.spineState.entriesByCategory);
|
|
1848
1616
|
if (policy.enforceMarkdownlint) {
|
|
1849
1617
|
issues.push(...runMarkdownlint(project, chapterPaths, true, "manuscript"));
|
|
1850
1618
|
}
|
|
@@ -2002,18 +1770,29 @@ function runMarkdownlint(project, files, required, profile = "default") {
|
|
|
2002
1770
|
prepared.cleanup();
|
|
2003
1771
|
}
|
|
2004
1772
|
}
|
|
2005
|
-
function collectSpineWordsForSpellcheck(
|
|
1773
|
+
function collectSpineWordsForSpellcheck(entriesByCategory) {
|
|
2006
1774
|
const words = new Set();
|
|
2007
|
-
for (const
|
|
2008
|
-
const
|
|
2009
|
-
.split(
|
|
1775
|
+
for (const [category, entries] of entriesByCategory) {
|
|
1776
|
+
const categoryParts = category
|
|
1777
|
+
.split(/[-_/]+/)
|
|
2010
1778
|
.map((part) => part.trim())
|
|
2011
1779
|
.filter(Boolean);
|
|
2012
|
-
for (const part of
|
|
2013
|
-
if (
|
|
2014
|
-
|
|
1780
|
+
for (const part of categoryParts) {
|
|
1781
|
+
if (/[A-Za-z]/.test(part)) {
|
|
1782
|
+
words.add(part.toLowerCase());
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
for (const entry of entries) {
|
|
1786
|
+
const entryParts = entry
|
|
1787
|
+
.split(/[-_/]+/)
|
|
1788
|
+
.map((part) => part.trim())
|
|
1789
|
+
.filter(Boolean);
|
|
1790
|
+
for (const part of entryParts) {
|
|
1791
|
+
if (!/[A-Za-z]/.test(part)) {
|
|
1792
|
+
continue;
|
|
1793
|
+
}
|
|
1794
|
+
words.add(part.toLowerCase());
|
|
2015
1795
|
}
|
|
2016
|
-
words.add(part.toLowerCase());
|
|
2017
1796
|
}
|
|
2018
1797
|
}
|
|
2019
1798
|
return Array.from(words).sort();
|