stego-cli 0.4.0 → 0.4.2

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.
Files changed (127) hide show
  1. package/.vscode/extensions.json +7 -0
  2. package/README.md +41 -0
  3. package/dist/shared/src/contracts/cli/envelopes.js +19 -0
  4. package/dist/shared/src/contracts/cli/errors.js +14 -0
  5. package/dist/shared/src/contracts/cli/exit-codes.js +15 -0
  6. package/dist/shared/src/contracts/cli/index.js +6 -0
  7. package/dist/shared/src/contracts/cli/metadata.js +1 -0
  8. package/dist/shared/src/contracts/cli/operations.js +1 -0
  9. package/dist/shared/src/domain/comments/anchors.js +1 -0
  10. package/dist/shared/src/domain/comments/index.js +4 -0
  11. package/dist/shared/src/domain/comments/serializer.js +1 -0
  12. package/dist/shared/src/domain/comments/thread-key.js +21 -0
  13. package/dist/shared/src/domain/frontmatter/index.js +3 -0
  14. package/dist/shared/src/domain/frontmatter/parser.js +34 -0
  15. package/dist/shared/src/domain/frontmatter/serializer.js +32 -0
  16. package/dist/shared/src/domain/frontmatter/validators.js +47 -0
  17. package/dist/shared/src/domain/project/index.js +4 -0
  18. package/dist/shared/src/domain/stages/index.js +20 -0
  19. package/dist/shared/src/index.js +6 -0
  20. package/dist/shared/src/utils/guards.js +6 -0
  21. package/dist/shared/src/utils/index.js +3 -0
  22. package/dist/shared/src/utils/invariant.js +5 -0
  23. package/dist/shared/src/utils/result.js +6 -0
  24. package/dist/stego-cli/src/app/cli-version.js +32 -0
  25. package/dist/stego-cli/src/app/command-context.js +1 -0
  26. package/dist/stego-cli/src/app/command-registry.js +121 -0
  27. package/dist/stego-cli/src/app/create-cli-app.js +42 -0
  28. package/dist/stego-cli/src/app/error-boundary.js +42 -0
  29. package/dist/stego-cli/src/app/index.js +6 -0
  30. package/dist/stego-cli/src/app/output-renderer.js +6 -0
  31. package/dist/stego-cli/src/main.js +14 -0
  32. package/dist/stego-cli/src/modules/comments/application/comment-operations.js +457 -0
  33. package/dist/stego-cli/src/modules/comments/commands/comments-add.js +40 -0
  34. package/dist/stego-cli/src/modules/comments/commands/comments-clear-resolved.js +32 -0
  35. package/dist/stego-cli/src/modules/comments/commands/comments-delete.js +33 -0
  36. package/dist/stego-cli/src/modules/comments/commands/comments-read.js +32 -0
  37. package/dist/stego-cli/src/modules/comments/commands/comments-reply.js +36 -0
  38. package/dist/stego-cli/src/modules/comments/commands/comments-set-status.js +35 -0
  39. package/dist/stego-cli/src/modules/comments/commands/comments-sync-anchors.js +33 -0
  40. package/dist/stego-cli/src/modules/comments/domain/comment-policy.js +14 -0
  41. package/dist/stego-cli/src/modules/comments/index.js +20 -0
  42. package/dist/stego-cli/src/modules/comments/infra/comments-repo.js +68 -0
  43. package/dist/stego-cli/src/modules/comments/types.js +1 -0
  44. package/dist/stego-cli/src/modules/compile/application/compile-manuscript.js +16 -0
  45. package/dist/stego-cli/src/modules/compile/commands/build.js +51 -0
  46. package/dist/stego-cli/src/modules/compile/domain/compile-structure.js +105 -0
  47. package/dist/stego-cli/src/modules/compile/index.js +8 -0
  48. package/dist/stego-cli/src/modules/compile/infra/dist-writer.js +8 -0
  49. package/dist/stego-cli/src/modules/compile/types.js +1 -0
  50. package/dist/stego-cli/src/modules/export/application/run-export.js +29 -0
  51. package/dist/stego-cli/src/modules/export/commands/export.js +61 -0
  52. package/dist/stego-cli/src/modules/export/domain/exporter.js +6 -0
  53. package/dist/stego-cli/src/modules/export/index.js +8 -0
  54. package/dist/stego-cli/src/modules/export/types.js +1 -0
  55. package/dist/stego-cli/src/modules/index.js +22 -0
  56. package/dist/stego-cli/src/modules/manuscript/application/create-manuscript.js +107 -0
  57. package/dist/stego-cli/src/modules/manuscript/application/order-inference.js +56 -0
  58. package/dist/stego-cli/src/modules/manuscript/commands/new-manuscript.js +54 -0
  59. package/dist/stego-cli/src/modules/manuscript/domain/manuscript.js +1 -0
  60. package/dist/stego-cli/src/modules/manuscript/index.js +9 -0
  61. package/dist/stego-cli/src/modules/manuscript/infra/manuscript-repo.js +39 -0
  62. package/dist/stego-cli/src/modules/manuscript/types.js +1 -0
  63. package/dist/stego-cli/src/modules/metadata/application/apply-metadata.js +45 -0
  64. package/dist/stego-cli/src/modules/metadata/application/read-metadata.js +18 -0
  65. package/dist/stego-cli/src/modules/metadata/commands/metadata-apply.js +38 -0
  66. package/dist/stego-cli/src/modules/metadata/commands/metadata-read.js +33 -0
  67. package/dist/stego-cli/src/modules/metadata/domain/metadata.js +1 -0
  68. package/dist/stego-cli/src/modules/metadata/index.js +11 -0
  69. package/dist/stego-cli/src/modules/metadata/infra/metadata-repo.js +68 -0
  70. package/dist/stego-cli/src/modules/metadata/types.js +1 -0
  71. package/dist/stego-cli/src/modules/project/application/create-project.js +203 -0
  72. package/dist/stego-cli/src/modules/project/application/infer-project.js +72 -0
  73. package/dist/stego-cli/src/modules/project/commands/new-project.js +86 -0
  74. package/dist/stego-cli/src/modules/project/domain/project.js +1 -0
  75. package/dist/stego-cli/src/modules/project/index.js +9 -0
  76. package/dist/stego-cli/src/modules/project/infra/project-repo.js +27 -0
  77. package/dist/stego-cli/src/modules/project/types.js +1 -0
  78. package/dist/stego-cli/src/modules/quality/application/inspect-project.js +603 -0
  79. package/dist/stego-cli/src/modules/quality/application/lint-runner.js +313 -0
  80. package/dist/stego-cli/src/modules/quality/application/stage-check.js +87 -0
  81. package/dist/stego-cli/src/modules/quality/commands/check-stage.js +54 -0
  82. package/dist/stego-cli/src/modules/quality/commands/lint.js +51 -0
  83. package/dist/stego-cli/src/modules/quality/commands/validate.js +50 -0
  84. package/dist/stego-cli/src/modules/quality/domain/issues.js +1 -0
  85. package/dist/stego-cli/src/modules/quality/domain/policies.js +1 -0
  86. package/dist/stego-cli/src/modules/quality/index.js +14 -0
  87. package/dist/stego-cli/src/modules/quality/infra/cspell-adapter.js +1 -0
  88. package/dist/stego-cli/src/modules/quality/infra/markdownlint-adapter.js +1 -0
  89. package/dist/stego-cli/src/modules/quality/types.js +1 -0
  90. package/dist/stego-cli/src/modules/scaffold/application/scaffold-workspace.js +307 -0
  91. package/dist/stego-cli/src/modules/scaffold/commands/init.js +34 -0
  92. package/dist/stego-cli/src/modules/scaffold/domain/templates.js +182 -0
  93. package/dist/stego-cli/src/modules/scaffold/index.js +8 -0
  94. package/dist/stego-cli/src/modules/scaffold/infra/template-repo.js +33 -0
  95. package/dist/stego-cli/src/modules/scaffold/types.js +1 -0
  96. package/dist/stego-cli/src/modules/spine/application/create-category.js +14 -0
  97. package/dist/stego-cli/src/modules/spine/application/create-entry.js +9 -0
  98. package/dist/stego-cli/src/modules/spine/application/read-catalog.js +13 -0
  99. package/dist/stego-cli/src/modules/spine/commands/spine-deprecated-aliases.js +33 -0
  100. package/dist/stego-cli/src/modules/spine/commands/spine-new-category.js +64 -0
  101. package/dist/stego-cli/src/modules/spine/commands/spine-new-entry.js +65 -0
  102. package/dist/stego-cli/src/modules/spine/commands/spine-read.js +49 -0
  103. package/dist/{spine/spine-domain.js → stego-cli/src/modules/spine/domain/spine.js} +13 -7
  104. package/dist/stego-cli/src/modules/spine/index.js +16 -0
  105. package/dist/stego-cli/src/modules/spine/infra/spine-repo.js +46 -0
  106. package/dist/stego-cli/src/modules/spine/types.js +1 -0
  107. package/dist/stego-cli/src/modules/workspace/application/discover-projects.js +18 -0
  108. package/dist/stego-cli/src/modules/workspace/application/resolve-workspace.js +73 -0
  109. package/dist/stego-cli/src/modules/workspace/commands/list-projects.js +40 -0
  110. package/dist/stego-cli/src/modules/workspace/index.js +9 -0
  111. package/dist/stego-cli/src/modules/workspace/infra/workspace-repo.js +37 -0
  112. package/dist/stego-cli/src/modules/workspace/types.js +1 -0
  113. package/dist/stego-cli/src/platform/clock.js +3 -0
  114. package/dist/stego-cli/src/platform/fs.js +13 -0
  115. package/dist/stego-cli/src/platform/index.js +3 -0
  116. package/dist/stego-cli/src/platform/temp-files.js +6 -0
  117. package/package.json +20 -11
  118. package/dist/comments/comments-command.js +0 -499
  119. package/dist/comments/errors.js +0 -20
  120. package/dist/metadata/metadata-command.js +0 -127
  121. package/dist/metadata/metadata-domain.js +0 -209
  122. package/dist/spine/spine-command.js +0 -129
  123. package/dist/stego-cli.js +0 -2107
  124. /package/dist/{exporters/exporter-types.js → shared/src/contracts/cli/comments.js} +0 -0
  125. /package/dist/{comments/comment-domain.js → shared/src/domain/comments/parser.js} +0 -0
  126. /package/dist/{exporters → stego-cli/src/modules/export/infra}/markdown-exporter.js +0 -0
  127. /package/dist/{exporters → stego-cli/src/modules/export/infra}/pandoc-exporter.js +0 -0
package/dist/stego-cli.js DELETED
@@ -1,2107 +0,0 @@
1
- #!/usr/bin/env node
2
- import fs from "node:fs";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import process from "node:process";
6
- import { spawnSync } from "node:child_process";
7
- import { createInterface } from "node:readline/promises";
8
- import { fileURLToPath } from "node:url";
9
- import { markdownExporter } from "./exporters/markdown-exporter.js";
10
- import { createPandocExporter } from "./exporters/pandoc-exporter.js";
11
- import { parseCommentAppendix } from "./comments/comment-domain.js";
12
- import { runCommentsCommand } from "./comments/comments-command.js";
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";
17
- const STATUS_RANK = {
18
- draft: 0,
19
- revise: 1,
20
- "line-edit": 2,
21
- proof: 3,
22
- final: 4
23
- };
24
- const RESERVED_COMMENT_PREFIX = "CMT";
25
- const DEFAULT_NEW_MANUSCRIPT_SLUG = "new-document";
26
- const ROOT_CONFIG_FILENAME = "stego.config.json";
27
- const PROSE_FONT_PROMPT = "Switch workspace to proportional (prose-style) font? (recommended)";
28
- const SCAFFOLD_GITIGNORE_CONTENT = `node_modules/
29
- /dist/
30
- .DS_Store
31
- *.log
32
- projects/*/dist/*
33
- !projects/*/dist/.gitkeep
34
- projects/*/.vscode/settings.json
35
- .vscode/settings.json
36
- `;
37
- const SCAFFOLD_README_CONTENT = `# Stego Workspace
38
-
39
- This directory is a Stego writing workspace (a monorepo for one or more writing projects).
40
-
41
- ## What was scaffolded
42
-
43
- - \`stego.config.json\` workspace configuration
44
- - \`projects/\` demo projects (\`stego-docs\` and \`fiction-example\`)
45
- - root \`package.json\` scripts for Stego commands
46
- - root \`.vscode/tasks.json\` tasks for common workflows
47
-
48
- Full documentation lives in \`projects/stego-docs\`.
49
-
50
- ## First run
51
-
52
- \`\`\`bash
53
- npm install
54
- stego list-projects
55
- \`\`\`
56
-
57
- ## Run commands for a specific project (from workspace root)
58
-
59
- \`\`\`bash
60
- stego validate --project fiction-example
61
- stego build --project fiction-example
62
- stego check-stage --project fiction-example --stage revise
63
- stego export --project fiction-example --format md
64
- stego new --project fiction-example
65
- \`\`\`
66
-
67
- ## Work inside one project
68
-
69
- Each project also has local scripts, so you can run commands from inside a project directory:
70
-
71
- \`\`\`bash
72
- cd projects/fiction-example
73
- npm run validate
74
- npm run build
75
- \`\`\`
76
-
77
- ## VS Code recommendation
78
-
79
- When you are actively working on one project, open that project directory directly in VS Code (for example \`projects/fiction-example\`).
80
-
81
- This keeps your editor context focused and applies the project's recommended extensions (including Stego + Saurus) for that project.
82
-
83
- ## Create a new project
84
-
85
- \`\`\`bash
86
- stego new-project --project my-book --title "My Book"
87
- \`\`\`
88
-
89
- ## Add a new manuscript file
90
-
91
- \`\`\`bash
92
- stego new --project fiction-example
93
- \`\`\`
94
- `;
95
- const PROSE_MARKDOWN_EDITOR_SETTINGS = {
96
- "[markdown]": {
97
- "editor.fontFamily": "Inter, Helvetica Neue, Helvetica, Arial, sans-serif",
98
- "editor.fontSize": 17,
99
- "editor.lineHeight": 28,
100
- "editor.wordWrap": "wordWrapColumn",
101
- "editor.wordWrapColumn": 72,
102
- "editor.lineNumbers": "off"
103
- },
104
- "markdown.preview.fontFamily": "Inter, Helvetica Neue, Helvetica, Arial, sans-serif"
105
- };
106
- const PROJECT_EXTENSION_RECOMMENDATIONS = [
107
- "matt-gold.stego-extension",
108
- "matt-gold.saurus-extension"
109
- ];
110
- const scriptDir = path.dirname(fileURLToPath(import.meta.url));
111
- const packageRoot = path.resolve(scriptDir, "..");
112
- let repoRoot = "";
113
- let config;
114
- void main();
115
- async function main() {
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
- }
123
- if (!command || command === "help" || command === "--help" || command === "-h") {
124
- printUsage();
125
- return;
126
- }
127
- try {
128
- switch (command) {
129
- case "init":
130
- await initWorkspace({ force: readBooleanOption(options, "force") });
131
- return;
132
- case "list-projects":
133
- activateWorkspace(options);
134
- listProjects();
135
- return;
136
- case "new-project":
137
- activateWorkspace(options);
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
- });
144
- return;
145
- case "new": {
146
- activateWorkspace(options);
147
- const project = resolveProject(readStringOption(options, "project"));
148
- const createdPath = createNewManuscript(project, readStringOption(options, "i"), readStringOption(options, "filename"));
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
- }
163
- return;
164
- }
165
- case "validate": {
166
- activateWorkspace(options);
167
- const project = resolveProject(readStringOption(options, "project"));
168
- const report = inspectProject(project, config, { onlyFile: readStringOption(options, "file") });
169
- printReport(report.issues);
170
- exitIfErrors(report.issues);
171
- if (report.chapters.length === 1) {
172
- logLine(`Validation passed for '${report.chapters[0].relativePath}'.`);
173
- }
174
- else {
175
- logLine(`Validation passed for '${project.id}'.`);
176
- }
177
- return;
178
- }
179
- case "build": {
180
- activateWorkspace(options);
181
- const project = resolveProject(readStringOption(options, "project"));
182
- const report = inspectProject(project, config);
183
- printReport(report.issues);
184
- exitIfErrors(report.issues);
185
- const outputPath = buildManuscript(project, report.chapters, report.compileStructureLevels);
186
- logLine(`Build output: ${outputPath}`);
187
- return;
188
- }
189
- case "check-stage": {
190
- activateWorkspace(options);
191
- const project = resolveProject(readStringOption(options, "project"));
192
- const stage = readStringOption(options, "stage") || "draft";
193
- const requestedFile = readStringOption(options, "file");
194
- const report = runStageCheck(project, config, stage, requestedFile);
195
- printReport(report.issues);
196
- exitIfErrors(report.issues);
197
- if (requestedFile && report.chapters.length === 1) {
198
- logLine(`Stage check passed for '${report.chapters[0].relativePath}' at stage '${stage}'.`);
199
- }
200
- else {
201
- logLine(`Stage check passed for '${project.id}' at stage '${stage}'.`);
202
- }
203
- return;
204
- }
205
- case "lint": {
206
- activateWorkspace(options);
207
- const project = resolveProject(readStringOption(options, "project"));
208
- const selection = resolveLintSelection(options);
209
- const result = runProjectLint(project, selection);
210
- printReport(result.issues);
211
- exitIfErrors(result.issues);
212
- const scopeLabel = formatLintSelection(selection);
213
- const fileLabel = result.fileCount === 1 ? "file" : "files";
214
- logLine(`Lint passed for '${project.id}' (${scopeLabel}, ${result.fileCount} ${fileLabel}).`);
215
- return;
216
- }
217
- case "export": {
218
- activateWorkspace(options);
219
- const project = resolveProject(readStringOption(options, "project"));
220
- const format = (readStringOption(options, "format") || "md").toLowerCase();
221
- const report = inspectProject(project, config);
222
- printReport(report.issues);
223
- exitIfErrors(report.issues);
224
- const inputPath = buildManuscript(project, report.chapters, report.compileStructureLevels);
225
- const outputPath = runExport(project, format, inputPath, readStringOption(options, "output"));
226
- logLine(`Export output: ${outputPath}`);
227
- return;
228
- }
229
- case "comments":
230
- await runCommentsCommand(options, process.cwd());
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
- }
246
- default:
247
- throw new Error(`Unknown command '${command}'. Run with 'help' for usage.`);
248
- }
249
- }
250
- catch (error) {
251
- if (error instanceof CommentsCommandError) {
252
- if (error.outputFormat === "json") {
253
- console.error(JSON.stringify(error.toJson(), null, 2));
254
- }
255
- else {
256
- console.error(`ERROR: ${error.message}`);
257
- }
258
- process.exit(error.exitCode);
259
- }
260
- if (error instanceof Error) {
261
- console.error(`ERROR: ${error.message}`);
262
- }
263
- else {
264
- console.error(`ERROR: ${String(error)}`);
265
- }
266
- process.exit(1);
267
- }
268
- }
269
- function readStringOption(options, key) {
270
- const value = options[key];
271
- return typeof value === "string" ? value : undefined;
272
- }
273
- function readBooleanOption(options, key) {
274
- return options[key] === true;
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
- }
298
- function activateWorkspace(options) {
299
- const workspace = resolveWorkspaceContext(readStringOption(options, "root"));
300
- repoRoot = workspace.repoRoot;
301
- config = workspace.config;
302
- return workspace;
303
- }
304
- function isStageName(value) {
305
- return Object.hasOwn(STATUS_RANK, value);
306
- }
307
- function isExportFormat(value) {
308
- return value === "md" || value === "docx" || value === "pdf" || value === "epub";
309
- }
310
- function resolveRequiredMetadata(project, runtimeConfig) {
311
- const issues = [];
312
- const projectFile = path.relative(repoRoot, path.join(project.root, "stego-project.json"));
313
- const raw = project.meta.requiredMetadata;
314
- if (raw == null) {
315
- return { requiredMetadata: runtimeConfig.requiredMetadata, issues };
316
- }
317
- if (!Array.isArray(raw)) {
318
- issues.push(makeIssue("error", "metadata", "Project 'requiredMetadata' must be an array of metadata keys.", projectFile));
319
- return { requiredMetadata: runtimeConfig.requiredMetadata, issues };
320
- }
321
- const requiredMetadata = [];
322
- const seen = new Set();
323
- for (const [index, entry] of raw.entries()) {
324
- if (typeof entry !== "string") {
325
- issues.push(makeIssue("error", "metadata", `Project 'requiredMetadata' entry at index ${index} must be a string.`, projectFile));
326
- continue;
327
- }
328
- const key = entry.trim();
329
- if (!key) {
330
- issues.push(makeIssue("error", "metadata", `Project 'requiredMetadata' entry at index ${index} cannot be empty.`, projectFile));
331
- continue;
332
- }
333
- if (seen.has(key)) {
334
- continue;
335
- }
336
- seen.add(key);
337
- requiredMetadata.push(key);
338
- }
339
- return { requiredMetadata, issues };
340
- }
341
- function resolveCompileStructure(project) {
342
- const issues = [];
343
- const projectFile = path.relative(repoRoot, path.join(project.root, "stego-project.json"));
344
- const raw = project.meta.compileStructure;
345
- if (raw == null) {
346
- return { levels: [], issues };
347
- }
348
- if (!isPlainObject(raw)) {
349
- issues.push(makeIssue("error", "metadata", "Project 'compileStructure' must be an object.", projectFile));
350
- return { levels: [], issues };
351
- }
352
- const rawLevels = raw.levels;
353
- if (!Array.isArray(rawLevels)) {
354
- issues.push(makeIssue("error", "metadata", "Project 'compileStructure.levels' must be an array.", projectFile));
355
- return { levels: [], issues };
356
- }
357
- const levels = [];
358
- const seenKeys = new Set();
359
- for (const [index, entry] of rawLevels.entries()) {
360
- if (!isPlainObject(entry)) {
361
- issues.push(makeIssue("error", "metadata", `Invalid compileStructure level at index ${index}. Expected object.`, projectFile));
362
- continue;
363
- }
364
- const key = typeof entry.key === "string" ? entry.key.trim() : "";
365
- const label = typeof entry.label === "string" ? entry.label.trim() : "";
366
- const titleKeyRaw = typeof entry.titleKey === "string" ? entry.titleKey.trim() : "";
367
- const headingTemplateRaw = typeof entry.headingTemplate === "string" ? entry.headingTemplate.trim() : "";
368
- if (!key || !/^[a-z][a-z0-9_-]*$/.test(key)) {
369
- issues.push(makeIssue("error", "metadata", `compileStructure.levels[${index}].key must match /^[a-z][a-z0-9_-]*$/.`, projectFile));
370
- continue;
371
- }
372
- if (!label) {
373
- issues.push(makeIssue("error", "metadata", `compileStructure.levels[${index}].label is required.`, projectFile));
374
- continue;
375
- }
376
- if (seenKeys.has(key)) {
377
- issues.push(makeIssue("error", "metadata", `Duplicate compileStructure level key '${key}'.`, projectFile));
378
- continue;
379
- }
380
- if (titleKeyRaw && !/^[a-z][a-z0-9_-]*$/.test(titleKeyRaw)) {
381
- issues.push(makeIssue("error", "metadata", `compileStructure.levels[${index}].titleKey must match /^[a-z][a-z0-9_-]*$/.`, projectFile));
382
- continue;
383
- }
384
- const pageBreakRaw = typeof entry.pageBreak === "string" ? entry.pageBreak.trim() : "between-groups";
385
- if (pageBreakRaw !== "none" && pageBreakRaw !== "between-groups") {
386
- issues.push(makeIssue("error", "metadata", `compileStructure.levels[${index}].pageBreak must be 'none' or 'between-groups'.`, projectFile));
387
- continue;
388
- }
389
- const injectHeading = typeof entry.injectHeading === "boolean" ? entry.injectHeading : true;
390
- const headingTemplate = headingTemplateRaw || "{label} {value}: {title}";
391
- seenKeys.add(key);
392
- levels.push({
393
- key,
394
- label,
395
- titleKey: titleKeyRaw || undefined,
396
- injectHeading,
397
- headingTemplate,
398
- pageBreak: pageBreakRaw
399
- });
400
- }
401
- return { levels, issues };
402
- }
403
- function isPlainObject(value) {
404
- return typeof value === "object" && value !== null && !Array.isArray(value);
405
- }
406
- function parseArgs(argv) {
407
- const [command, ...rest] = argv;
408
- const options = { _: [] };
409
- for (let i = 0; i < rest.length; i += 1) {
410
- const token = rest[i];
411
- if (token === "--") {
412
- options._.push(...rest.slice(i + 1));
413
- break;
414
- }
415
- if (token.startsWith("--")) {
416
- const key = token.slice(2);
417
- const next = rest[i + 1];
418
- if (!next || !canBeOptionValue(next)) {
419
- options[key] = true;
420
- continue;
421
- }
422
- options[key] = next;
423
- i += 1;
424
- continue;
425
- }
426
- if (token.startsWith("-") && token.length > 1) {
427
- const key = token.slice(1);
428
- const next = rest[i + 1];
429
- if (!next || !canBeOptionValue(next)) {
430
- options[key] = true;
431
- continue;
432
- }
433
- options[key] = next;
434
- i += 1;
435
- continue;
436
- }
437
- if (!token.startsWith("--")) {
438
- options._.push(token);
439
- continue;
440
- }
441
- }
442
- return { command, options };
443
- }
444
- function canBeOptionValue(token) {
445
- if (token === "-") {
446
- return true;
447
- }
448
- return !token.startsWith("-");
449
- }
450
- function resolveWorkspaceContext(rootOption) {
451
- if (rootOption) {
452
- const explicitRoot = path.resolve(process.cwd(), rootOption);
453
- if (!fs.existsSync(explicitRoot) || !fs.statSync(explicitRoot).isDirectory()) {
454
- throw new Error(`Workspace root does not exist or is not a directory: ${explicitRoot}`);
455
- }
456
- const explicitConfigPath = path.join(explicitRoot, ROOT_CONFIG_FILENAME);
457
- if (!fs.existsSync(explicitConfigPath)) {
458
- const legacyConfigPath = path.join(explicitRoot, "writing.config.json");
459
- if (fs.existsSync(legacyConfigPath)) {
460
- throw new Error(`Found legacy 'writing.config.json' at '${explicitRoot}'. Rename it to '${ROOT_CONFIG_FILENAME}'.`);
461
- }
462
- throw new Error(`No Stego workspace found at '${explicitRoot}'. Expected '${ROOT_CONFIG_FILENAME}'.`);
463
- }
464
- return {
465
- repoRoot: explicitRoot,
466
- configPath: explicitConfigPath,
467
- config: readJson(explicitConfigPath)
468
- };
469
- }
470
- const discoveredConfigPath = findNearestFileUpward(process.cwd(), ROOT_CONFIG_FILENAME);
471
- if (!discoveredConfigPath) {
472
- const legacyConfigPath = findNearestFileUpward(process.cwd(), "writing.config.json");
473
- if (legacyConfigPath) {
474
- throw new Error(`Found legacy '${path.basename(legacyConfigPath)}' at '${path.dirname(legacyConfigPath)}'. Rename it to '${ROOT_CONFIG_FILENAME}'.`);
475
- }
476
- throw new Error(`No Stego workspace found from '${process.cwd()}'. Run 'stego init' or pass --root <path>.`);
477
- }
478
- const discoveredRoot = path.dirname(discoveredConfigPath);
479
- return {
480
- repoRoot: discoveredRoot,
481
- configPath: discoveredConfigPath,
482
- config: readJson(discoveredConfigPath)
483
- };
484
- }
485
- function findNearestFileUpward(startPath, filename) {
486
- let current = path.resolve(startPath);
487
- if (!fs.existsSync(current)) {
488
- return null;
489
- }
490
- if (!fs.statSync(current).isDirectory()) {
491
- current = path.dirname(current);
492
- }
493
- while (true) {
494
- const candidate = path.join(current, filename);
495
- if (fs.existsSync(candidate)) {
496
- return candidate;
497
- }
498
- const parent = path.dirname(current);
499
- if (parent === current) {
500
- return null;
501
- }
502
- current = parent;
503
- }
504
- }
505
- async function initWorkspace(options) {
506
- const targetRoot = process.cwd();
507
- const entries = fs
508
- .readdirSync(targetRoot, { withFileTypes: true })
509
- .filter((entry) => entry.name !== "." && entry.name !== "..");
510
- if (entries.length > 0 && !options.force) {
511
- throw new Error(`Target directory is not empty: ${targetRoot}. Re-run with --force to continue.`);
512
- }
513
- const copiedPaths = [];
514
- writeScaffoldGitignore(targetRoot, copiedPaths);
515
- writeScaffoldReadme(targetRoot, copiedPaths);
516
- copyTemplateAsset(".markdownlint.json", targetRoot, copiedPaths);
517
- copyTemplateAsset(".markdownlint.manuscript.json", targetRoot, copiedPaths);
518
- copyTemplateAsset(".cspell.json", targetRoot, copiedPaths);
519
- copyTemplateAsset(ROOT_CONFIG_FILENAME, targetRoot, copiedPaths);
520
- copyTemplateAsset("projects", targetRoot, copiedPaths);
521
- copyTemplateAsset(path.join(".vscode", "tasks.json"), targetRoot, copiedPaths);
522
- copyTemplateAsset(path.join(".vscode", "extensions.json"), targetRoot, copiedPaths, { optional: true });
523
- rewriteTemplateProjectPackageScripts(targetRoot);
524
- const enableProseFont = await promptYesNo(PROSE_FONT_PROMPT, true);
525
- if (enableProseFont) {
526
- writeProjectProseEditorSettings(targetRoot, copiedPaths);
527
- }
528
- writeInitRootPackageJson(targetRoot);
529
- logLine(`Initialized Stego workspace in ${targetRoot}`);
530
- for (const relativePath of copiedPaths) {
531
- logLine(`- ${relativePath}`);
532
- }
533
- logLine("- package.json");
534
- logLine("");
535
- logLine("Next steps:");
536
- logLine(" npm install");
537
- logLine(" stego list-projects");
538
- logLine(" stego validate --project fiction-example");
539
- logLine(" stego build --project fiction-example");
540
- }
541
- async function promptYesNo(question, defaultYes) {
542
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
543
- return defaultYes;
544
- }
545
- const rl = createInterface({
546
- input: process.stdin,
547
- output: process.stdout
548
- });
549
- const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
550
- try {
551
- while (true) {
552
- const answer = (await rl.question(`${question}${suffix}`)).trim().toLowerCase();
553
- if (!answer) {
554
- return defaultYes;
555
- }
556
- if (answer === "y" || answer === "yes") {
557
- return true;
558
- }
559
- if (answer === "n" || answer === "no") {
560
- return false;
561
- }
562
- console.log("Please answer y or n.");
563
- }
564
- }
565
- finally {
566
- rl.close();
567
- }
568
- }
569
- function copyTemplateAsset(sourceRelativePath, targetRoot, copiedPaths, options) {
570
- const sourcePath = path.join(packageRoot, sourceRelativePath);
571
- if (!fs.existsSync(sourcePath)) {
572
- if (options?.optional) {
573
- return;
574
- }
575
- throw new Error(`Template asset is missing from stego-cli package: ${sourceRelativePath}`);
576
- }
577
- const destinationPath = path.join(targetRoot, sourceRelativePath);
578
- const stats = fs.statSync(sourcePath);
579
- if (stats.isDirectory()) {
580
- fs.mkdirSync(destinationPath, { recursive: true });
581
- fs.cpSync(sourcePath, destinationPath, {
582
- recursive: true,
583
- force: true,
584
- filter: (currentSourcePath) => shouldCopyTemplatePath(currentSourcePath)
585
- });
586
- }
587
- else {
588
- fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
589
- fs.copyFileSync(sourcePath, destinationPath);
590
- }
591
- copiedPaths.push(sourceRelativePath);
592
- }
593
- function writeScaffoldGitignore(targetRoot, copiedPaths) {
594
- const destinationPath = path.join(targetRoot, ".gitignore");
595
- fs.writeFileSync(destinationPath, SCAFFOLD_GITIGNORE_CONTENT, "utf8");
596
- copiedPaths.push(".gitignore");
597
- }
598
- function writeScaffoldReadme(targetRoot, copiedPaths) {
599
- const destinationPath = path.join(targetRoot, "README.md");
600
- fs.writeFileSync(destinationPath, SCAFFOLD_README_CONTENT, "utf8");
601
- copiedPaths.push("README.md");
602
- }
603
- function shouldCopyTemplatePath(currentSourcePath) {
604
- const relativePath = path.relative(packageRoot, currentSourcePath);
605
- if (!relativePath || relativePath.startsWith("..")) {
606
- return true;
607
- }
608
- const parts = relativePath.split(path.sep);
609
- const name = parts[parts.length - 1] || "";
610
- if (name === ".DS_Store") {
611
- return false;
612
- }
613
- if (parts[0] === "projects") {
614
- if (parts[parts.length - 2] === ".vscode" && name === "settings.json") {
615
- return false;
616
- }
617
- const distIndex = parts.indexOf("dist");
618
- if (distIndex >= 0) {
619
- const isDistRoot = distIndex === parts.length - 1;
620
- const isGitkeep = name === ".gitkeep";
621
- return isDistRoot || isGitkeep;
622
- }
623
- }
624
- return true;
625
- }
626
- function rewriteTemplateProjectPackageScripts(targetRoot) {
627
- const projectsRoot = path.join(targetRoot, "projects");
628
- if (!fs.existsSync(projectsRoot)) {
629
- return;
630
- }
631
- for (const entry of fs.readdirSync(projectsRoot, { withFileTypes: true })) {
632
- if (!entry.isDirectory()) {
633
- continue;
634
- }
635
- const projectRoot = path.join(projectsRoot, entry.name);
636
- const packageJsonPath = path.join(projectsRoot, entry.name, "package.json");
637
- if (!fs.existsSync(packageJsonPath)) {
638
- continue;
639
- }
640
- const projectPackage = readJson(packageJsonPath);
641
- const scripts = isPlainObject(projectPackage.scripts)
642
- ? { ...projectPackage.scripts }
643
- : {};
644
- scripts.validate = "npx --no-install stego validate";
645
- scripts.build = "npx --no-install stego build";
646
- scripts["check-stage"] = "npx --no-install stego check-stage";
647
- scripts.export = "npx --no-install stego export";
648
- scripts.new = "npx --no-install stego new";
649
- projectPackage.scripts = scripts;
650
- fs.writeFileSync(packageJsonPath, `${JSON.stringify(projectPackage, null, 2)}\n`, "utf8");
651
- ensureProjectExtensionsRecommendations(projectRoot);
652
- }
653
- }
654
- function ensureProjectExtensionsRecommendations(projectRoot) {
655
- const vscodeDir = path.join(projectRoot, ".vscode");
656
- const extensionsPath = path.join(vscodeDir, "extensions.json");
657
- fs.mkdirSync(vscodeDir, { recursive: true });
658
- let existingRecommendations = [];
659
- if (fs.existsSync(extensionsPath)) {
660
- try {
661
- const parsed = readJson(extensionsPath);
662
- if (Array.isArray(parsed.recommendations)) {
663
- existingRecommendations = parsed.recommendations.filter((value) => typeof value === "string");
664
- }
665
- }
666
- catch {
667
- existingRecommendations = [];
668
- }
669
- }
670
- const mergedRecommendations = [
671
- ...new Set([...PROJECT_EXTENSION_RECOMMENDATIONS, ...existingRecommendations])
672
- ];
673
- const extensionsConfig = {
674
- recommendations: mergedRecommendations
675
- };
676
- fs.writeFileSync(extensionsPath, `${JSON.stringify(extensionsConfig, null, 2)}\n`, "utf8");
677
- }
678
- function writeProjectProseEditorSettings(targetRoot, copiedPaths) {
679
- const projectsRoot = path.join(targetRoot, "projects");
680
- if (!fs.existsSync(projectsRoot)) {
681
- return;
682
- }
683
- for (const entry of fs.readdirSync(projectsRoot, { withFileTypes: true })) {
684
- if (!entry.isDirectory()) {
685
- continue;
686
- }
687
- const projectRoot = path.join(projectsRoot, entry.name);
688
- const settingsPath = writeProseEditorSettingsForProject(projectRoot);
689
- copiedPaths.push(path.relative(targetRoot, settingsPath));
690
- }
691
- }
692
- function writeProseEditorSettingsForProject(projectRoot) {
693
- const vscodeDir = path.join(projectRoot, ".vscode");
694
- const settingsPath = path.join(vscodeDir, "settings.json");
695
- fs.mkdirSync(vscodeDir, { recursive: true });
696
- let existingSettings = {};
697
- if (fs.existsSync(settingsPath)) {
698
- try {
699
- const parsed = readJson(settingsPath);
700
- if (isPlainObject(parsed)) {
701
- existingSettings = parsed;
702
- }
703
- }
704
- catch {
705
- existingSettings = {};
706
- }
707
- }
708
- const proseMarkdownSettings = isPlainObject(PROSE_MARKDOWN_EDITOR_SETTINGS["[markdown]"])
709
- ? PROSE_MARKDOWN_EDITOR_SETTINGS["[markdown]"]
710
- : {};
711
- const existingMarkdownSettings = isPlainObject(existingSettings["[markdown]"])
712
- ? existingSettings["[markdown]"]
713
- : {};
714
- const nextSettings = {
715
- ...existingSettings,
716
- "[markdown]": {
717
- ...existingMarkdownSettings,
718
- ...proseMarkdownSettings
719
- },
720
- "markdown.preview.fontFamily": PROSE_MARKDOWN_EDITOR_SETTINGS["markdown.preview.fontFamily"]
721
- };
722
- fs.writeFileSync(settingsPath, `${JSON.stringify(nextSettings, null, 2)}\n`, "utf8");
723
- return settingsPath;
724
- }
725
- function writeInitRootPackageJson(targetRoot) {
726
- const cliPackage = readJson(path.join(packageRoot, "package.json"));
727
- const cliVersion = typeof cliPackage.version === "string" ? cliPackage.version : "0.1.0";
728
- const manifest = {
729
- name: path.basename(targetRoot) || "stego-workspace",
730
- private: true,
731
- type: "module",
732
- description: "Stego writing workspace",
733
- engines: {
734
- node: ">=20"
735
- },
736
- scripts: {
737
- "list-projects": "stego list-projects",
738
- "new-project": "stego new-project",
739
- new: "stego new",
740
- spine: "stego spine",
741
- metadata: "stego metadata",
742
- lint: "stego lint",
743
- validate: "stego validate",
744
- build: "stego build",
745
- "check-stage": "stego check-stage",
746
- export: "stego export"
747
- },
748
- devDependencies: {
749
- "stego-cli": `^${cliVersion}`,
750
- cspell: "^9.6.4",
751
- "markdownlint-cli": "^0.47.0"
752
- }
753
- };
754
- fs.writeFileSync(path.join(targetRoot, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
755
- }
756
- function printUsage() {
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`);
758
- }
759
- function listProjects() {
760
- const ids = getProjectIds();
761
- if (ids.length === 0) {
762
- console.log("No projects found.");
763
- return;
764
- }
765
- console.log("Projects:");
766
- for (const id of ids) {
767
- console.log(`- ${id}`);
768
- }
769
- }
770
- async function createProject(options) {
771
- const projectId = (options.projectId || "").trim();
772
- if (!projectId) {
773
- throw new Error("Project id is required. Use --project <project-id>.");
774
- }
775
- if (!/^[a-z0-9][a-z0-9-]*$/.test(projectId)) {
776
- throw new Error("Project id must match /^[a-z0-9][a-z0-9-]*$/.");
777
- }
778
- const projectRoot = path.join(repoRoot, config.projectsDir, projectId);
779
- if (fs.existsSync(projectRoot)) {
780
- throw new Error(`Project already exists: ${projectRoot}`);
781
- }
782
- fs.mkdirSync(path.join(projectRoot, config.chapterDir), { recursive: true });
783
- const spineDir = path.join(projectRoot, config.spineDir);
784
- fs.mkdirSync(spineDir, { recursive: true });
785
- const notesDir = path.join(projectRoot, config.notesDir);
786
- fs.mkdirSync(notesDir, { recursive: true });
787
- fs.mkdirSync(path.join(projectRoot, config.distDir), { recursive: true });
788
- const manuscriptDir = path.join(projectRoot, config.chapterDir);
789
- const projectJson = {
790
- id: projectId,
791
- title: options.title?.trim() || toDisplayTitle(projectId),
792
- requiredMetadata: ["status"],
793
- compileStructure: {
794
- levels: [
795
- {
796
- key: "chapter",
797
- label: "Chapter",
798
- titleKey: "chapter_title",
799
- injectHeading: true,
800
- headingTemplate: "{label} {value}: {title}",
801
- pageBreak: "between-groups"
802
- }
803
- ]
804
- }
805
- };
806
- const projectJsonPath = path.join(projectRoot, "stego-project.json");
807
- fs.writeFileSync(projectJsonPath, `${JSON.stringify(projectJson, null, 2)}\n`, "utf8");
808
- const projectPackage = {
809
- name: `stego-project-${projectId}`,
810
- private: true,
811
- scripts: {
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",
815
- lint: "npx --no-install stego lint",
816
- validate: "npx --no-install stego validate",
817
- build: "npx --no-install stego build",
818
- "check-stage": "npx --no-install stego check-stage",
819
- export: "npx --no-install stego export"
820
- }
821
- };
822
- const projectPackagePath = path.join(projectRoot, "package.json");
823
- fs.writeFileSync(projectPackagePath, `${JSON.stringify(projectPackage, null, 2)}\n`, "utf8");
824
- const starterManuscriptPath = path.join(manuscriptDir, "100-hello-world.md");
825
- fs.writeFileSync(starterManuscriptPath, `---
826
- status: draft
827
- chapter: 1
828
- chapter_title: Hello World
829
- ---
830
-
831
- # Hello World
832
-
833
- Start writing here.
834
- `, "utf8");
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");
849
- const projectExtensionsPath = path.join(projectRoot, ".vscode", "extensions.json");
850
- ensureProjectExtensionsRecommendations(projectRoot);
851
- let projectSettingsPath = null;
852
- const proseFontMode = parseProseFontMode(options.proseFont);
853
- const enableProseFont = proseFontMode === "prompt"
854
- ? await promptYesNo(PROSE_FONT_PROMPT, true)
855
- : proseFontMode === "yes";
856
- if (enableProseFont) {
857
- projectSettingsPath = writeProseEditorSettingsForProject(projectRoot);
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
- }
880
- logLine(`Created project: ${path.relative(repoRoot, projectRoot)}`);
881
- logLine(`- ${path.relative(repoRoot, projectJsonPath)}`);
882
- logLine(`- ${path.relative(repoRoot, projectPackagePath)}`);
883
- logLine(`- ${path.relative(repoRoot, starterManuscriptPath)}`);
884
- logLine(`- ${path.relative(repoRoot, charactersCategoryPath)}`);
885
- logLine(`- ${path.relative(repoRoot, charactersEntryPath)}`);
886
- logLine(`- ${path.relative(repoRoot, projectExtensionsPath)}`);
887
- if (projectSettingsPath) {
888
- logLine(`- ${path.relative(repoRoot, projectSettingsPath)}`);
889
- }
890
- }
891
- function createNewManuscript(project, requestedPrefixRaw, requestedFilenameRaw) {
892
- fs.mkdirSync(project.manuscriptDir, { recursive: true });
893
- const requiredMetadataState = resolveRequiredMetadata(project, config);
894
- const requiredMetadataErrors = requiredMetadataState.issues
895
- .filter((issue) => issue.level === "error")
896
- .map((issue) => issue.message);
897
- if (requiredMetadataErrors.length > 0) {
898
- throw new Error(`Unable to resolve required metadata for project '${project.id}': ${requiredMetadataErrors.join(" ")}`);
899
- }
900
- const existingEntries = listManuscriptOrderEntries(project.manuscriptDir);
901
- const explicitPrefix = parseManuscriptPrefix(requestedPrefixRaw);
902
- const requestedFilename = parseRequestedManuscriptFilename(requestedFilenameRaw);
903
- if (requestedFilename && explicitPrefix != null) {
904
- throw new Error("Options --filename and --i/-i cannot be used together.");
905
- }
906
- let filename;
907
- if (requestedFilename) {
908
- const requestedOrder = parseOrderFromManuscriptFilename(requestedFilename);
909
- if (requestedOrder != null) {
910
- const collision = existingEntries.find((entry) => entry.order === requestedOrder);
911
- if (collision) {
912
- throw new Error(`Manuscript prefix '${requestedOrder}' is already used by '${collision.filename}'. Choose a different filename prefix.`);
913
- }
914
- }
915
- filename = requestedFilename;
916
- }
917
- else {
918
- const nextPrefix = explicitPrefix ?? inferNextManuscriptPrefix(existingEntries);
919
- const collision = existingEntries.find((entry) => entry.order === nextPrefix);
920
- if (collision) {
921
- throw new Error(`Manuscript prefix '${nextPrefix}' is already used by '${collision.filename}'. Re-run with --i <number> to choose an unused prefix.`);
922
- }
923
- filename = `${nextPrefix}-${DEFAULT_NEW_MANUSCRIPT_SLUG}.md`;
924
- }
925
- const manuscriptPath = path.join(project.manuscriptDir, filename);
926
- if (fs.existsSync(manuscriptPath)) {
927
- throw new Error(`Manuscript already exists: ${filename}`);
928
- }
929
- const content = renderNewManuscriptTemplate(requiredMetadataState.requiredMetadata);
930
- fs.writeFileSync(manuscriptPath, content, "utf8");
931
- return path.relative(repoRoot, manuscriptPath);
932
- }
933
- function listManuscriptOrderEntries(manuscriptDir) {
934
- if (!fs.existsSync(manuscriptDir)) {
935
- return [];
936
- }
937
- return fs
938
- .readdirSync(manuscriptDir, { withFileTypes: true })
939
- .filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
940
- .map((entry) => {
941
- const match = entry.name.match(/^(\d+)[-_]/);
942
- if (!match) {
943
- return null;
944
- }
945
- return {
946
- order: Number(match[1]),
947
- filename: entry.name
948
- };
949
- })
950
- .filter((entry) => entry !== null)
951
- .sort((a, b) => {
952
- if (a.order === b.order) {
953
- return a.filename.localeCompare(b.filename);
954
- }
955
- return a.order - b.order;
956
- });
957
- }
958
- function parseManuscriptPrefix(raw) {
959
- if (raw == null) {
960
- return undefined;
961
- }
962
- const normalized = raw.trim();
963
- if (!normalized) {
964
- throw new Error("Option --i/-i requires a numeric value.");
965
- }
966
- if (!/^\d+$/.test(normalized)) {
967
- throw new Error(`Invalid manuscript prefix '${raw}'. Use a non-negative integer.`);
968
- }
969
- const parsed = Number(normalized);
970
- if (!Number.isSafeInteger(parsed)) {
971
- throw new Error(`Invalid manuscript prefix '${raw}'. Use a smaller integer value.`);
972
- }
973
- return parsed;
974
- }
975
- function parseRequestedManuscriptFilename(raw) {
976
- if (raw == null) {
977
- return undefined;
978
- }
979
- const normalized = raw.trim();
980
- if (!normalized) {
981
- throw new Error("Option --filename requires a value.");
982
- }
983
- if (/[\\/]/.test(normalized)) {
984
- throw new Error(`Invalid filename '${raw}'. Use a filename only (no directory separators).`);
985
- }
986
- const withExtension = normalized.toLowerCase().endsWith(".md")
987
- ? normalized
988
- : `${normalized}.md`;
989
- const stem = withExtension.slice(0, -3).trim();
990
- if (!stem) {
991
- throw new Error(`Invalid filename '${raw}'.`);
992
- }
993
- return withExtension;
994
- }
995
- function parseOrderFromManuscriptFilename(filename) {
996
- const match = filename.match(/^(\d+)[-_]/);
997
- if (!match) {
998
- return undefined;
999
- }
1000
- return Number(match[1]);
1001
- }
1002
- function inferNextManuscriptPrefix(entries) {
1003
- if (entries.length === 0) {
1004
- return 100;
1005
- }
1006
- if (entries.length === 1) {
1007
- return entries[0].order + 100;
1008
- }
1009
- const previous = entries[entries.length - 2].order;
1010
- const latest = entries[entries.length - 1].order;
1011
- const step = latest - previous;
1012
- return latest + (step > 0 ? step : 1);
1013
- }
1014
- function renderNewManuscriptTemplate(requiredMetadata) {
1015
- const lines = ["---", "status: draft"];
1016
- const seenKeys = new Set(["status"]);
1017
- for (const key of requiredMetadata) {
1018
- const normalized = key.trim();
1019
- if (!normalized || seenKeys.has(normalized)) {
1020
- continue;
1021
- }
1022
- seenKeys.add(normalized);
1023
- lines.push(`${normalized}:`);
1024
- }
1025
- lines.push("---", "");
1026
- return `${lines.join("\n")}\n`;
1027
- }
1028
- function getProjectIds() {
1029
- const projectsDir = path.join(repoRoot, config.projectsDir);
1030
- if (!fs.existsSync(projectsDir)) {
1031
- return [];
1032
- }
1033
- return fs
1034
- .readdirSync(projectsDir, { withFileTypes: true })
1035
- .filter((entry) => entry.isDirectory())
1036
- .map((entry) => entry.name)
1037
- .filter((id) => fs.existsSync(path.join(projectsDir, id, "stego-project.json")))
1038
- .sort();
1039
- }
1040
- function resolveProject(explicitProjectId) {
1041
- const ids = getProjectIds();
1042
- const projectId = explicitProjectId ||
1043
- process.env.STEGO_PROJECT ||
1044
- process.env.WRITING_PROJECT ||
1045
- inferProjectIdFromCwd(process.cwd()) ||
1046
- (ids.length === 1 ? ids[0] : null);
1047
- if (!projectId) {
1048
- throw new Error("Project id is required. Use --project <project-id>.");
1049
- }
1050
- const projectRoot = path.join(repoRoot, config.projectsDir, projectId);
1051
- if (!fs.existsSync(projectRoot)) {
1052
- throw new Error(`Project not found: ${projectRoot}`);
1053
- }
1054
- return {
1055
- id: projectId,
1056
- root: projectRoot,
1057
- manuscriptDir: path.join(projectRoot, config.chapterDir),
1058
- spineDir: path.join(projectRoot, config.spineDir),
1059
- notesDir: path.join(projectRoot, config.notesDir),
1060
- distDir: path.join(projectRoot, config.distDir),
1061
- meta: readJson(path.join(projectRoot, "stego-project.json"))
1062
- };
1063
- }
1064
- function inferProjectIdFromCwd(cwd) {
1065
- const projectsRoot = path.resolve(repoRoot, config.projectsDir);
1066
- const relative = path.relative(projectsRoot, path.resolve(cwd));
1067
- if (!relative || relative === "." || relative.startsWith("..") || path.isAbsolute(relative)) {
1068
- return null;
1069
- }
1070
- const projectId = relative.split(path.sep)[0];
1071
- if (!projectId) {
1072
- return null;
1073
- }
1074
- const projectJsonPath = path.join(projectsRoot, projectId, "stego-project.json");
1075
- if (!fs.existsSync(projectJsonPath)) {
1076
- return null;
1077
- }
1078
- return projectId;
1079
- }
1080
- function inspectProject(project, runtimeConfig, options = {}) {
1081
- const issues = [];
1082
- const emptySpineState = { categories: [], entriesByCategory: new Map(), issues: [] };
1083
- const requiredMetadataState = resolveRequiredMetadata(project, runtimeConfig);
1084
- const compileStructureState = resolveCompileStructure(project);
1085
- issues.push(...requiredMetadataState.issues);
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);
1092
- let chapterFiles = [];
1093
- const onlyFile = options.onlyFile?.trim();
1094
- if (onlyFile) {
1095
- const resolvedPath = path.resolve(project.root, onlyFile);
1096
- const relativeToProject = path.relative(project.root, resolvedPath);
1097
- if (!relativeToProject || relativeToProject.startsWith("..") || path.isAbsolute(relativeToProject)) {
1098
- issues.push(makeIssue("error", "structure", `Requested file is outside the project: ${onlyFile}`, null));
1099
- return { chapters: [], issues, spineState: emptySpineState, compileStructureLevels: compileStructureState.levels };
1100
- }
1101
- if (!fs.existsSync(resolvedPath)) {
1102
- issues.push(makeIssue("error", "structure", `Requested file does not exist: ${onlyFile}`, null));
1103
- return { chapters: [], issues, spineState: emptySpineState, compileStructureLevels: compileStructureState.levels };
1104
- }
1105
- if (!fs.statSync(resolvedPath).isFile() || !resolvedPath.endsWith(".md")) {
1106
- issues.push(makeIssue("error", "structure", `Requested file must be a markdown file: ${onlyFile}`, null));
1107
- return { chapters: [], issues, spineState: emptySpineState, compileStructureLevels: compileStructureState.levels };
1108
- }
1109
- const relativeToManuscript = path.relative(project.manuscriptDir, resolvedPath);
1110
- if (relativeToManuscript.startsWith("..") || path.isAbsolute(relativeToManuscript)) {
1111
- issues.push(makeIssue("error", "structure", `Requested file must be inside manuscript directory: ${project.manuscriptDir}`, null));
1112
- return { chapters: [], issues, spineState: emptySpineState, compileStructureLevels: compileStructureState.levels };
1113
- }
1114
- chapterFiles = [resolvedPath];
1115
- }
1116
- else {
1117
- if (!fs.existsSync(project.manuscriptDir)) {
1118
- issues.push(makeIssue("error", "structure", `Missing manuscript directory: ${project.manuscriptDir}`));
1119
- return { chapters: [], issues, spineState: emptySpineState, compileStructureLevels: compileStructureState.levels };
1120
- }
1121
- chapterFiles = fs
1122
- .readdirSync(project.manuscriptDir, { withFileTypes: true })
1123
- .filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
1124
- .map((entry) => path.join(project.manuscriptDir, entry.name))
1125
- .sort();
1126
- if (chapterFiles.length === 0) {
1127
- issues.push(makeIssue("error", "structure", `No manuscript files found in ${project.manuscriptDir}`));
1128
- return { chapters: [], issues, spineState: emptySpineState, compileStructureLevels: compileStructureState.levels };
1129
- }
1130
- }
1131
- const chapters = chapterFiles.map((chapterPath) => parseChapter(chapterPath, runtimeConfig, requiredMetadataState.requiredMetadata, spineState.categories, compileStructureState.levels));
1132
- for (const chapter of chapters) {
1133
- issues.push(...chapter.issues);
1134
- }
1135
- const orderMap = new Map();
1136
- for (const chapter of chapters) {
1137
- if (chapter.order == null) {
1138
- continue;
1139
- }
1140
- if (orderMap.has(chapter.order)) {
1141
- issues.push(makeIssue("error", "ordering", `Duplicate filename order prefix '${chapter.order}' in ${chapter.relativePath} and ${orderMap.get(chapter.order)}`, chapter.relativePath));
1142
- continue;
1143
- }
1144
- orderMap.set(chapter.order, chapter.relativePath);
1145
- }
1146
- chapters.sort((a, b) => {
1147
- if (a.order == null && b.order == null) {
1148
- return a.relativePath.localeCompare(b.relativePath);
1149
- }
1150
- if (a.order == null) {
1151
- return 1;
1152
- }
1153
- if (b.order == null) {
1154
- return -1;
1155
- }
1156
- return a.order - b.order;
1157
- });
1158
- for (const chapter of chapters) {
1159
- issues.push(...findUnknownSpineReferences(chapter.referenceKeysByCategory, spineState.entriesByCategory, chapter.relativePath));
1160
- }
1161
- return {
1162
- chapters,
1163
- issues,
1164
- spineState,
1165
- compileStructureLevels: compileStructureState.levels
1166
- };
1167
- }
1168
- function parseChapter(chapterPath, runtimeConfig, requiredMetadata, spineCategories, compileStructureLevels) {
1169
- const relativePath = path.relative(repoRoot, chapterPath);
1170
- const raw = fs.readFileSync(chapterPath, "utf8");
1171
- const { metadata, body, comments, issues } = parseMetadata(raw, chapterPath, false);
1172
- const chapterIssues = [...issues];
1173
- for (const requiredKey of requiredMetadata) {
1174
- if (metadata[requiredKey] == null || metadata[requiredKey] === "") {
1175
- chapterIssues.push(makeIssue("warning", "metadata", `Missing required metadata key '${requiredKey}'. Validation and stage checks that depend on '${requiredKey}' are skipped for this file.`, relativePath));
1176
- }
1177
- }
1178
- const title = deriveEntryTitle(metadata.title, chapterPath);
1179
- if (metadata.order != null && metadata.order !== "") {
1180
- chapterIssues.push(makeIssue("warning", "metadata", "Metadata 'order' is ignored. Ordering is derived from filename prefix.", relativePath));
1181
- }
1182
- const order = parseOrderFromFilename(chapterPath, relativePath, chapterIssues);
1183
- const status = String(metadata.status || "").trim();
1184
- if (status && !isStageName(status)) {
1185
- chapterIssues.push(makeIssue("error", "metadata", `Invalid file status '${status}'. Allowed: ${runtimeConfig.allowedStatuses.join(", ")}.`, relativePath));
1186
- }
1187
- const groupValues = {};
1188
- for (const level of compileStructureLevels) {
1189
- const groupValue = normalizeGroupingValue(metadata[level.key], relativePath, chapterIssues, level.key);
1190
- if (groupValue) {
1191
- groupValues[level.key] = groupValue;
1192
- }
1193
- if (level.titleKey) {
1194
- void normalizeGroupingValue(metadata[level.titleKey], relativePath, chapterIssues, level.titleKey);
1195
- }
1196
- }
1197
- const referenceValidation = extractReferenceKeysByCategory(metadata, relativePath, spineCategories);
1198
- chapterIssues.push(...referenceValidation.issues);
1199
- chapterIssues.push(...validateMarkdownBody(body, chapterPath));
1200
- return {
1201
- path: chapterPath,
1202
- relativePath,
1203
- title,
1204
- order,
1205
- status,
1206
- referenceKeysByCategory: referenceValidation.referencesByCategory,
1207
- groupValues,
1208
- metadata,
1209
- body,
1210
- comments,
1211
- issues: chapterIssues
1212
- };
1213
- }
1214
- function normalizeGroupingValue(rawValue, relativePath, issues, key) {
1215
- if (rawValue == null || rawValue === "") {
1216
- return undefined;
1217
- }
1218
- if (Array.isArray(rawValue)) {
1219
- issues.push(makeIssue("error", "metadata", `Metadata '${key}' must be a scalar value.`, relativePath));
1220
- return undefined;
1221
- }
1222
- const normalized = String(rawValue).trim();
1223
- return normalized.length > 0 ? normalized : undefined;
1224
- }
1225
- function deriveEntryTitle(rawTitle, chapterPath) {
1226
- if (typeof rawTitle === "string" && rawTitle.trim()) {
1227
- return rawTitle.trim();
1228
- }
1229
- const basename = path.basename(chapterPath, ".md");
1230
- const withoutPrefix = basename.replace(/^\d+[-_]?/, "");
1231
- const normalized = withoutPrefix.replace(/[-_]+/g, " ").trim();
1232
- if (!normalized) {
1233
- return basename;
1234
- }
1235
- return normalized.replace(/\b\w/g, (letter) => letter.toUpperCase());
1236
- }
1237
- function parseOrderFromFilename(chapterPath, relativePath, issues) {
1238
- const basename = path.basename(chapterPath, ".md");
1239
- const match = basename.match(/^(\d+)[-_]/);
1240
- if (!match) {
1241
- issues.push(makeIssue("error", "ordering", "Filename must start with a numeric prefix followed by '-' or '_' (for example '100-scene.md').", relativePath));
1242
- return null;
1243
- }
1244
- if (match[1].length !== 3) {
1245
- issues.push(makeIssue("warning", "ordering", `Filename prefix '${match[1]}' is valid but non-standard. Use three digits like 100, 200, 300.`, relativePath));
1246
- }
1247
- return Number(match[1]);
1248
- }
1249
- function extractReferenceKeysByCategory(metadata, relativePath, spineCategories) {
1250
- const issues = [];
1251
- const referencesByCategory = {};
1252
- for (const category of spineCategories) {
1253
- const rawValue = metadata[category.key];
1254
- if (rawValue == null || rawValue === "") {
1255
- continue;
1256
- }
1257
- if (!Array.isArray(rawValue)) {
1258
- issues.push(makeIssue("error", "metadata", `Metadata '${category.key}' must be an array of spine entry keys (for example: [\"matthaeus\"]).`, relativePath));
1259
- continue;
1260
- }
1261
- const seen = new Set();
1262
- const values = [];
1263
- for (const entry of rawValue) {
1264
- if (typeof entry !== "string") {
1265
- issues.push(makeIssue("error", "metadata", `Metadata '${category.key}' entries must be strings.`, relativePath));
1266
- continue;
1267
- }
1268
- const normalized = entry.trim();
1269
- if (!normalized) {
1270
- issues.push(makeIssue("error", "metadata", `Metadata '${category.key}' contains an empty entry key.`, relativePath));
1271
- continue;
1272
- }
1273
- if (seen.has(normalized)) {
1274
- continue;
1275
- }
1276
- seen.add(normalized);
1277
- values.push(normalized);
1278
- }
1279
- referencesByCategory[category.key] = values;
1280
- }
1281
- return { referencesByCategory, issues };
1282
- }
1283
- function parseMetadata(raw, chapterPath, required) {
1284
- const relativePath = path.relative(repoRoot, chapterPath);
1285
- const issues = [];
1286
- if (!raw.startsWith("---\n") && !raw.startsWith("---\r\n")) {
1287
- const commentsResult = parseStegoCommentsAppendix(raw, relativePath, 1);
1288
- if (!required) {
1289
- return {
1290
- metadata: {},
1291
- body: commentsResult.bodyWithoutComments,
1292
- comments: commentsResult.comments,
1293
- issues: commentsResult.issues
1294
- };
1295
- }
1296
- return {
1297
- metadata: {},
1298
- body: commentsResult.bodyWithoutComments,
1299
- comments: commentsResult.comments,
1300
- issues: [
1301
- makeIssue("error", "metadata", "Missing metadata block at top of file.", relativePath),
1302
- ...commentsResult.issues
1303
- ]
1304
- };
1305
- }
1306
- const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
1307
- if (!match) {
1308
- return {
1309
- metadata: {},
1310
- body: raw,
1311
- comments: [],
1312
- issues: [makeIssue("error", "metadata", "Metadata opening delimiter found, but closing delimiter is missing.", relativePath)]
1313
- };
1314
- }
1315
- const metadataText = match[1];
1316
- const body = raw.slice(match[0].length);
1317
- const metadata = {};
1318
- const lines = metadataText.split(/\r?\n/);
1319
- for (let i = 0; i < lines.length; i += 1) {
1320
- const line = lines[i].trim();
1321
- if (!line || line.startsWith("#")) {
1322
- continue;
1323
- }
1324
- const separatorIndex = line.indexOf(":");
1325
- if (separatorIndex === -1) {
1326
- issues.push(makeIssue("error", "metadata", `Invalid metadata line '${line}'. Expected 'key: value' format.`, relativePath, i + 1));
1327
- continue;
1328
- }
1329
- const key = line.slice(0, separatorIndex).trim();
1330
- const value = line.slice(separatorIndex + 1).trim();
1331
- if (!value) {
1332
- let lookahead = i + 1;
1333
- while (lookahead < lines.length) {
1334
- const nextTrimmed = lines[lookahead].trim();
1335
- if (!nextTrimmed || nextTrimmed.startsWith("#")) {
1336
- lookahead += 1;
1337
- continue;
1338
- }
1339
- break;
1340
- }
1341
- if (lookahead < lines.length) {
1342
- const firstValueLine = lines[lookahead];
1343
- const firstValueTrimmed = firstValueLine.trim();
1344
- const firstValueIndent = firstValueLine.length - firstValueLine.trimStart().length;
1345
- if (firstValueIndent > 0 && firstValueTrimmed.startsWith("- ")) {
1346
- const items = [];
1347
- let j = lookahead;
1348
- while (j < lines.length) {
1349
- const candidateRaw = lines[j];
1350
- const candidateTrimmed = candidateRaw.trim();
1351
- if (!candidateTrimmed || candidateTrimmed.startsWith("#")) {
1352
- j += 1;
1353
- continue;
1354
- }
1355
- const indent = candidateRaw.length - candidateRaw.trimStart().length;
1356
- if (indent === 0) {
1357
- break;
1358
- }
1359
- if (!candidateTrimmed.startsWith("- ")) {
1360
- issues.push(makeIssue("error", "metadata", `Unsupported metadata list line '${candidateTrimmed}'. Expected '- value'.`, relativePath, j + 1));
1361
- j += 1;
1362
- continue;
1363
- }
1364
- const itemValue = candidateTrimmed.slice(2).trim().replace(/^['"]|['"]$/g, "");
1365
- items.push(itemValue);
1366
- j += 1;
1367
- }
1368
- metadata[key] = items;
1369
- i = j - 1;
1370
- continue;
1371
- }
1372
- }
1373
- }
1374
- metadata[key] = coerceMetadataValue(value);
1375
- }
1376
- const bodyStartLine = match[0].split(/\r?\n/).length;
1377
- const commentsResult = parseStegoCommentsAppendix(body, relativePath, bodyStartLine);
1378
- issues.push(...commentsResult.issues);
1379
- return {
1380
- metadata,
1381
- body: commentsResult.bodyWithoutComments,
1382
- comments: commentsResult.comments,
1383
- issues
1384
- };
1385
- }
1386
- function parseStegoCommentsAppendix(body, relativePath, bodyStartLine) {
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
- }));
1394
- return {
1395
- bodyWithoutComments: parsed.contentWithoutComments,
1396
- comments,
1397
- issues
1398
- };
1399
- }
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);
1404
- }
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);
1410
- }
1411
- function coerceMetadataValue(value) {
1412
- if (!value) {
1413
- return "";
1414
- }
1415
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
1416
- return value.slice(1, -1);
1417
- }
1418
- if (value.startsWith("[") && value.endsWith("]")) {
1419
- const inner = value.slice(1, -1).trim();
1420
- if (!inner) {
1421
- return [];
1422
- }
1423
- return inner.split(",").map((entry) => entry.trim().replace(/^['\"]|['\"]$/g, ""));
1424
- }
1425
- if (/^-?\d+$/.test(value)) {
1426
- return Number(value);
1427
- }
1428
- if (value === "true") {
1429
- return true;
1430
- }
1431
- if (value === "false") {
1432
- return false;
1433
- }
1434
- return value;
1435
- }
1436
- function validateMarkdownBody(body, chapterPath) {
1437
- const relativePath = path.relative(repoRoot, chapterPath);
1438
- const issues = [];
1439
- const lines = body.split(/\r?\n/);
1440
- let openFence = null;
1441
- let previousHeadingLevel = 0;
1442
- for (let i = 0; i < lines.length; i += 1) {
1443
- const line = lines[i];
1444
- const fenceMatch = line.match(/^(```+|~~~+)/);
1445
- if (fenceMatch) {
1446
- const marker = fenceMatch[1][0];
1447
- const length = fenceMatch[1].length;
1448
- if (!openFence) {
1449
- openFence = { marker, length, line: i + 1 };
1450
- }
1451
- else if (openFence.marker === marker && length >= openFence.length) {
1452
- openFence = null;
1453
- }
1454
- continue;
1455
- }
1456
- const headingMatch = line.match(/^(#{1,6})\s+/);
1457
- if (headingMatch) {
1458
- const level = headingMatch[1].length;
1459
- if (previousHeadingLevel > 0 && level > previousHeadingLevel + 1) {
1460
- issues.push(makeIssue("warning", "style", `Heading level jumps from H${previousHeadingLevel} to H${level}.`, relativePath, i + 1));
1461
- }
1462
- previousHeadingLevel = level;
1463
- }
1464
- if (/\[[^\]]+\]\([^\)]*$/.test(line.trim())) {
1465
- issues.push(makeIssue("error", "structure", "Malformed markdown link, missing closing ')'.", relativePath, i + 1));
1466
- }
1467
- }
1468
- if (openFence) {
1469
- issues.push(makeIssue("error", "structure", `Unclosed code fence opened at line ${openFence.line}.`, relativePath, openFence.line));
1470
- }
1471
- issues.push(...checkLocalMarkdownLinks(body, chapterPath));
1472
- issues.push(...runStyleHeuristics(body, relativePath));
1473
- return issues;
1474
- }
1475
- function checkLocalMarkdownLinks(body, chapterPath) {
1476
- const relativePath = path.relative(repoRoot, chapterPath);
1477
- const issues = [];
1478
- const linkRegex = /!?\[[^\]]*\]\(([^)]+)\)/g;
1479
- let match;
1480
- while ((match = linkRegex.exec(body)) !== null) {
1481
- let target = match[1].trim();
1482
- if (!target) {
1483
- continue;
1484
- }
1485
- if (target.startsWith("<") && target.endsWith(">")) {
1486
- target = target.slice(1, -1).trim();
1487
- }
1488
- target = target.split(/\s+"/)[0].split(/\s+'/)[0].trim();
1489
- if (isExternalTarget(target) || target.startsWith("#")) {
1490
- continue;
1491
- }
1492
- const cleanTarget = target.split("#")[0];
1493
- if (!cleanTarget) {
1494
- continue;
1495
- }
1496
- const resolved = path.resolve(path.dirname(chapterPath), cleanTarget);
1497
- if (!fs.existsSync(resolved)) {
1498
- issues.push(makeIssue("warning", "links", `Broken local link/image target '${cleanTarget}'.`, relativePath));
1499
- }
1500
- }
1501
- return issues;
1502
- }
1503
- function isExternalTarget(target) {
1504
- return (target.startsWith("http://") ||
1505
- target.startsWith("https://") ||
1506
- target.startsWith("mailto:") ||
1507
- target.startsWith("tel:"));
1508
- }
1509
- function runStyleHeuristics(body, relativePath) {
1510
- const issues = [];
1511
- const prose = body
1512
- .replace(/```[\s\S]*?```/g, "")
1513
- .replace(/~~~[\s\S]*?~~~/g, "");
1514
- const paragraphs = prose
1515
- .split(/\n\s*\n/)
1516
- .map((paragraph) => paragraph.trim())
1517
- .filter((paragraph) => paragraph.length > 0)
1518
- .filter((paragraph) => !paragraph.startsWith("#"))
1519
- .filter((paragraph) => !paragraph.startsWith("- "));
1520
- for (const paragraph of paragraphs) {
1521
- const words = countWords(paragraph);
1522
- if (words > 180) {
1523
- issues.push(makeIssue("warning", "style", `Long paragraph detected (${words} words).`, relativePath));
1524
- }
1525
- const sentences = paragraph.split(/[.!?]+\s+/).map((sentence) => sentence.trim()).filter(Boolean);
1526
- for (const sentence of sentences) {
1527
- const sentenceWords = countWords(sentence);
1528
- if (sentenceWords > 45) {
1529
- issues.push(makeIssue("warning", "style", `Long sentence detected (${sentenceWords} words).`, relativePath));
1530
- }
1531
- }
1532
- }
1533
- return issues;
1534
- }
1535
- function countWords(text) {
1536
- return text.trim().split(/\s+/).filter(Boolean).length;
1537
- }
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);
1546
- }
1547
- const issues = catalog.issues.map((message) => makeIssue("warning", "continuity", message));
1548
- return { categories, entriesByCategory, issues };
1549
- }
1550
- function findUnknownSpineReferences(referencesByCategory, entriesByCategory, relativePath) {
1551
- const issues = [];
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));
1563
- }
1564
- }
1565
- return issues;
1566
- }
1567
- function runStageCheck(project, runtimeConfig, stage, onlyFile) {
1568
- if (!isStageName(stage)) {
1569
- throw new Error(`Unknown stage '${stage}'. Allowed: ${Object.keys(runtimeConfig.stagePolicies).join(", ")}.`);
1570
- }
1571
- const policy = runtimeConfig.stagePolicies[stage];
1572
- const report = inspectProject(project, runtimeConfig, { onlyFile });
1573
- const issues = [...report.issues];
1574
- const minimumRank = STATUS_RANK[policy.minimumChapterStatus];
1575
- for (const chapter of report.chapters) {
1576
- if (!isStageName(chapter.status)) {
1577
- continue;
1578
- }
1579
- const chapterRank = STATUS_RANK[chapter.status];
1580
- if (chapterRank == null) {
1581
- continue;
1582
- }
1583
- if (chapterRank < minimumRank) {
1584
- issues.push(makeIssue("error", "stage", `File status '${chapter.status}' is below required stage '${policy.minimumChapterStatus}'.`, chapter.relativePath));
1585
- }
1586
- if (stage === "final" && chapter.status !== "final") {
1587
- issues.push(makeIssue("error", "stage", "Final stage requires all chapters to be status 'final'.", chapter.relativePath));
1588
- }
1589
- if (policy.requireResolvedComments) {
1590
- const unresolvedComments = chapter.comments.filter((comment) => !comment.resolved);
1591
- if (unresolvedComments.length > 0) {
1592
- const unresolvedLabel = unresolvedComments.slice(0, 5).map((comment) => comment.id).join(", ");
1593
- const remainder = unresolvedComments.length > 5 ? ` (+${unresolvedComments.length - 5} more)` : "";
1594
- issues.push(makeIssue("error", "comments", `Unresolved comments (${unresolvedComments.length}): ${unresolvedLabel}${remainder}. Resolve or clear comments before stage '${stage}'.`, chapter.relativePath));
1595
- }
1596
- }
1597
- }
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
- }
1602
- for (const spineIssue of report.issues.filter((issue) => issue.category === "continuity")) {
1603
- if (spineIssue.message.startsWith("Missing spine directory")) {
1604
- issues.push({ ...spineIssue, level: "error" });
1605
- }
1606
- }
1607
- }
1608
- if (policy.enforceLocalLinks) {
1609
- for (const linkIssue of issues.filter((issue) => issue.category === "links" && issue.level !== "error")) {
1610
- linkIssue.level = "error";
1611
- linkIssue.message = `${linkIssue.message} (strict in stage '${stage}')`;
1612
- }
1613
- }
1614
- const chapterPaths = report.chapters.map((chapter) => chapter.path);
1615
- const spineWords = collectSpineWordsForSpellcheck(report.spineState.entriesByCategory);
1616
- if (policy.enforceMarkdownlint) {
1617
- issues.push(...runMarkdownlint(project, chapterPaths, true, "manuscript"));
1618
- }
1619
- else {
1620
- issues.push(...runMarkdownlint(project, chapterPaths, false, "manuscript"));
1621
- }
1622
- if (policy.enforceCSpell) {
1623
- issues.push(...runCSpell(chapterPaths, true, spineWords));
1624
- }
1625
- else {
1626
- issues.push(...runCSpell(chapterPaths, false, spineWords));
1627
- }
1628
- return { chapters: report.chapters, issues };
1629
- }
1630
- function resolveLintSelection(options) {
1631
- const manuscript = readBooleanOption(options, "manuscript");
1632
- const spine = readBooleanOption(options, "spine");
1633
- if (!manuscript && !spine) {
1634
- return { manuscript: true, spine: true };
1635
- }
1636
- return { manuscript, spine };
1637
- }
1638
- function formatLintSelection(selection) {
1639
- if (selection.manuscript && selection.spine) {
1640
- return "manuscript + spine";
1641
- }
1642
- if (selection.manuscript) {
1643
- return "manuscript";
1644
- }
1645
- if (selection.spine) {
1646
- return "spine";
1647
- }
1648
- return "none";
1649
- }
1650
- function runProjectLint(project, selection) {
1651
- const issues = [];
1652
- let fileCount = 0;
1653
- if (selection.manuscript) {
1654
- const manuscriptFiles = collectTopLevelMarkdownFiles(project.manuscriptDir);
1655
- if (manuscriptFiles.length === 0) {
1656
- issues.push(makeIssue("error", "lint", `No manuscript markdown files found in ${project.manuscriptDir}`));
1657
- }
1658
- else {
1659
- fileCount += manuscriptFiles.length;
1660
- issues.push(...runMarkdownlint(project, manuscriptFiles, true, "manuscript"));
1661
- }
1662
- }
1663
- if (selection.spine) {
1664
- const spineLintState = collectSpineLintMarkdownFiles(project);
1665
- issues.push(...spineLintState.issues);
1666
- if (spineLintState.files.length > 0) {
1667
- fileCount += spineLintState.files.length;
1668
- issues.push(...runMarkdownlint(project, spineLintState.files, true, "default"));
1669
- }
1670
- }
1671
- if (fileCount === 0 && issues.length === 0) {
1672
- issues.push(makeIssue("error", "lint", `No markdown files found for lint scope '${formatLintSelection(selection)}' in project '${project.id}'.`));
1673
- }
1674
- return { issues, fileCount };
1675
- }
1676
- function collectSpineLintMarkdownFiles(project) {
1677
- const issues = [];
1678
- const files = new Set();
1679
- addMarkdownFilesFromDirectory(files, project.spineDir, true);
1680
- if (!fs.existsSync(project.spineDir)) {
1681
- issues.push(makeIssue("warning", "lint", `Missing spine directory: ${project.spineDir}`));
1682
- }
1683
- addMarkdownFilesFromDirectory(files, project.notesDir, true);
1684
- if (!fs.existsSync(project.notesDir)) {
1685
- issues.push(makeIssue("warning", "lint", `Missing notes directory: ${project.notesDir}`));
1686
- }
1687
- for (const file of collectTopLevelMarkdownFiles(project.root)) {
1688
- files.add(file);
1689
- }
1690
- const sortedFiles = Array.from(files).sort();
1691
- if (sortedFiles.length === 0) {
1692
- issues.push(makeIssue("error", "lint", `No spine/notes markdown files found in ${project.spineDir}, ${project.notesDir}, or project root.`));
1693
- }
1694
- return { files: sortedFiles, issues };
1695
- }
1696
- function collectTopLevelMarkdownFiles(directory) {
1697
- if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) {
1698
- return [];
1699
- }
1700
- return fs
1701
- .readdirSync(directory, { withFileTypes: true })
1702
- .filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
1703
- .map((entry) => path.join(directory, entry.name))
1704
- .sort();
1705
- }
1706
- function addMarkdownFilesFromDirectory(target, directory, recursive) {
1707
- if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) {
1708
- return;
1709
- }
1710
- const stack = [directory];
1711
- while (stack.length > 0) {
1712
- const current = stack.pop();
1713
- if (!current) {
1714
- continue;
1715
- }
1716
- const entries = fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
1717
- for (const entry of entries) {
1718
- const fullPath = path.join(current, entry.name);
1719
- if (entry.isFile() && entry.name.endsWith(".md")) {
1720
- target.add(fullPath);
1721
- continue;
1722
- }
1723
- if (recursive && entry.isDirectory()) {
1724
- stack.push(fullPath);
1725
- }
1726
- }
1727
- }
1728
- }
1729
- function runMarkdownlint(project, files, required, profile = "default") {
1730
- if (files.length === 0) {
1731
- return [];
1732
- }
1733
- const markdownlintCommand = resolveCommand("markdownlint");
1734
- if (!markdownlintCommand) {
1735
- if (required) {
1736
- return [
1737
- makeIssue("error", "tooling", "markdownlint is required for this command but not installed. Run 'npm i' in the repo root.")
1738
- ];
1739
- }
1740
- return [];
1741
- }
1742
- const manuscriptProjectConfigPath = path.join(project.root, ".markdownlint.manuscript.json");
1743
- const manuscriptRepoConfigPath = path.join(repoRoot, ".markdownlint.manuscript.json");
1744
- const defaultProjectConfigPath = path.join(project.root, ".markdownlint.json");
1745
- const defaultRepoConfigPath = path.join(repoRoot, ".markdownlint.json");
1746
- const markdownlintConfigPath = profile === "manuscript"
1747
- ? (fs.existsSync(manuscriptProjectConfigPath)
1748
- ? manuscriptProjectConfigPath
1749
- : fs.existsSync(manuscriptRepoConfigPath)
1750
- ? manuscriptRepoConfigPath
1751
- : fs.existsSync(defaultProjectConfigPath)
1752
- ? defaultProjectConfigPath
1753
- : defaultRepoConfigPath)
1754
- : (fs.existsSync(defaultProjectConfigPath)
1755
- ? defaultProjectConfigPath
1756
- : defaultRepoConfigPath);
1757
- const prepared = prepareFilesWithoutComments(files);
1758
- try {
1759
- const result = spawnSync(markdownlintCommand, ["--config", markdownlintConfigPath, ...prepared.files], {
1760
- cwd: repoRoot,
1761
- encoding: "utf8"
1762
- });
1763
- if (result.status === 0) {
1764
- return [];
1765
- }
1766
- const details = remapToolOutputPaths(compactToolOutput(result.stdout, result.stderr), prepared.pathMap);
1767
- return [makeIssue(required ? "error" : "warning", "lint", `markdownlint reported issues. ${details}`)];
1768
- }
1769
- finally {
1770
- prepared.cleanup();
1771
- }
1772
- }
1773
- function collectSpineWordsForSpellcheck(entriesByCategory) {
1774
- const words = new Set();
1775
- for (const [category, entries] of entriesByCategory) {
1776
- const categoryParts = category
1777
- .split(/[-_/]+/)
1778
- .map((part) => part.trim())
1779
- .filter(Boolean);
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());
1795
- }
1796
- }
1797
- }
1798
- return Array.from(words).sort();
1799
- }
1800
- function runCSpell(files, required, extraWords = []) {
1801
- const cspellCommand = resolveCommand("cspell");
1802
- if (!cspellCommand) {
1803
- if (required) {
1804
- return [
1805
- makeIssue("error", "tooling", "cspell is required for this stage but not installed. Run 'npm i' in the repo root.")
1806
- ];
1807
- }
1808
- return [];
1809
- }
1810
- let tempConfigDir = null;
1811
- let cspellConfigPath = path.join(repoRoot, ".cspell.json");
1812
- if (extraWords.length > 0) {
1813
- const baseConfig = readJson(cspellConfigPath);
1814
- const existingWords = Array.isArray(baseConfig.words) ? baseConfig.words.filter((word) => typeof word === "string") : [];
1815
- const mergedWords = new Set([...existingWords, ...extraWords]);
1816
- tempConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "stego-cspell-"));
1817
- cspellConfigPath = path.join(tempConfigDir, "cspell.generated.json");
1818
- fs.writeFileSync(cspellConfigPath, `${JSON.stringify({ ...baseConfig, words: Array.from(mergedWords).sort() }, null, 2)}\n`, "utf8");
1819
- }
1820
- const prepared = prepareFilesWithoutComments(files);
1821
- try {
1822
- const result = spawnSync(cspellCommand, ["--no-progress", "--no-summary", "--config", cspellConfigPath, ...prepared.files], {
1823
- cwd: repoRoot,
1824
- encoding: "utf8"
1825
- });
1826
- if (result.status === 0) {
1827
- return [];
1828
- }
1829
- const details = remapToolOutputPaths(compactToolOutput(result.stdout, result.stderr), prepared.pathMap);
1830
- return [
1831
- makeIssue(required ? "error" : "warning", "spell", `cspell reported issues. ${details} Words from spine identifiers are auto-whitelisted. For additional terms, add them to '.cspell.json' under the 'words' array.`)
1832
- ];
1833
- }
1834
- finally {
1835
- prepared.cleanup();
1836
- if (tempConfigDir) {
1837
- fs.rmSync(tempConfigDir, { recursive: true, force: true });
1838
- }
1839
- }
1840
- }
1841
- function prepareFilesWithoutComments(files) {
1842
- if (files.length === 0) {
1843
- return {
1844
- files,
1845
- pathMap: new Map(),
1846
- cleanup: () => undefined
1847
- };
1848
- }
1849
- const tempDir = fs.mkdtempSync(path.join(repoRoot, ".stego-tooling-"));
1850
- const pathMap = new Map();
1851
- const preparedFiles = [];
1852
- for (let index = 0; index < files.length; index += 1) {
1853
- const filePath = files[index];
1854
- const raw = fs.readFileSync(filePath, "utf8");
1855
- const relativePath = path.relative(repoRoot, filePath);
1856
- const parsed = parseStegoCommentsAppendix(raw, relativePath, 1);
1857
- const sanitized = parsed.bodyWithoutComments.endsWith("\n")
1858
- ? parsed.bodyWithoutComments
1859
- : `${parsed.bodyWithoutComments}\n`;
1860
- const relativeTarget = relativePath.startsWith("..")
1861
- ? `external/file-${index + 1}-${path.basename(filePath)}`
1862
- : relativePath;
1863
- const targetPath = path.join(tempDir, relativeTarget);
1864
- fs.mkdirSync(path.dirname(targetPath), { recursive: true });
1865
- fs.writeFileSync(targetPath, sanitized, "utf8");
1866
- preparedFiles.push(targetPath);
1867
- pathMap.set(targetPath, filePath);
1868
- }
1869
- return {
1870
- files: preparedFiles,
1871
- pathMap,
1872
- cleanup: () => {
1873
- fs.rmSync(tempDir, { recursive: true, force: true });
1874
- }
1875
- };
1876
- }
1877
- function remapToolOutputPaths(output, pathMap) {
1878
- if (!output || pathMap.size === 0) {
1879
- return output;
1880
- }
1881
- let mapped = output;
1882
- for (const [preparedPath, originalPath] of pathMap.entries()) {
1883
- if (preparedPath === originalPath) {
1884
- continue;
1885
- }
1886
- mapped = mapped.split(preparedPath).join(originalPath);
1887
- const preparedRelative = path.relative(repoRoot, preparedPath);
1888
- const originalRelative = path.relative(repoRoot, originalPath);
1889
- const preparedRelativeNormalized = preparedRelative.split(path.sep).join("/");
1890
- const originalRelativeNormalized = originalRelative.split(path.sep).join("/");
1891
- mapped = mapped.split(preparedRelative).join(originalRelative);
1892
- mapped = mapped.split(preparedRelativeNormalized).join(originalRelativeNormalized);
1893
- }
1894
- return mapped;
1895
- }
1896
- function resolveCommand(command) {
1897
- const localCommandPath = path.join(repoRoot, "node_modules", ".bin", process.platform === "win32" ? `${command}.cmd` : command);
1898
- if (fs.existsSync(localCommandPath)) {
1899
- return localCommandPath;
1900
- }
1901
- return null;
1902
- }
1903
- function compactToolOutput(stdout, stderr) {
1904
- const text = `${stdout || ""}\n${stderr || ""}`.trim();
1905
- if (!text) {
1906
- return "No details provided by tool.";
1907
- }
1908
- return text
1909
- .split(/\r?\n/)
1910
- .filter(Boolean)
1911
- .slice(0, 4)
1912
- .join(" | ");
1913
- }
1914
- function buildManuscript(project, chapters, compileStructureLevels) {
1915
- fs.mkdirSync(project.distDir, { recursive: true });
1916
- const generatedAt = new Date().toISOString();
1917
- const title = project.meta.title || project.id;
1918
- const subtitle = project.meta.subtitle || "";
1919
- const author = project.meta.author || "";
1920
- const tocEntries = [];
1921
- const previousGroupValues = new Map();
1922
- const previousGroupTitles = new Map();
1923
- const entryHeadingLevel = Math.min(6, 2 + compileStructureLevels.length);
1924
- const lines = [];
1925
- lines.push(`<!-- generated: ${generatedAt} -->`);
1926
- lines.push("");
1927
- lines.push(`# ${title}`);
1928
- lines.push("");
1929
- if (subtitle) {
1930
- lines.push(`_${subtitle}_`);
1931
- lines.push("");
1932
- }
1933
- if (author) {
1934
- lines.push(`Author: ${author}`);
1935
- lines.push("");
1936
- }
1937
- lines.push(`Generated: ${generatedAt}`);
1938
- lines.push("");
1939
- lines.push("## Table of Contents");
1940
- lines.push("");
1941
- if (compileStructureLevels.length === 0) {
1942
- lines.push(`- [Manuscript](#${slugify("Manuscript")})`);
1943
- }
1944
- lines.push("");
1945
- for (let chapterIndex = 0; chapterIndex < chapters.length; chapterIndex += 1) {
1946
- const entry = chapters[chapterIndex];
1947
- let insertedBreakForEntry = false;
1948
- const levelChanged = [];
1949
- for (let levelIndex = 0; levelIndex < compileStructureLevels.length; levelIndex += 1) {
1950
- const level = compileStructureLevels[levelIndex];
1951
- const explicitValue = entry.groupValues[level.key];
1952
- const previousValue = previousGroupValues.get(level.key);
1953
- const currentValue = explicitValue ?? previousValue;
1954
- const explicitTitle = level.titleKey ? toScalarMetadataString(entry.metadata[level.titleKey]) : undefined;
1955
- const previousTitle = previousGroupTitles.get(level.key);
1956
- const currentTitle = explicitTitle ?? previousTitle;
1957
- const parentChanged = levelIndex > 0 && levelChanged[levelIndex - 1] === true;
1958
- const changed = parentChanged || currentValue !== previousValue;
1959
- levelChanged.push(changed);
1960
- if (!changed || !currentValue) {
1961
- previousGroupValues.set(level.key, currentValue);
1962
- previousGroupTitles.set(level.key, currentTitle);
1963
- continue;
1964
- }
1965
- if (level.pageBreak === "between-groups" && chapterIndex > 0 && !insertedBreakForEntry) {
1966
- lines.push("\\newpage");
1967
- lines.push("");
1968
- insertedBreakForEntry = true;
1969
- }
1970
- if (level.injectHeading) {
1971
- const heading = formatCompileStructureHeading(level, currentValue, currentTitle);
1972
- tocEntries.push({ level: levelIndex, heading });
1973
- const headingLevel = Math.min(6, 2 + levelIndex);
1974
- lines.push(`${"#".repeat(headingLevel)} ${heading}`);
1975
- lines.push("");
1976
- }
1977
- previousGroupValues.set(level.key, currentValue);
1978
- previousGroupTitles.set(level.key, currentTitle);
1979
- }
1980
- lines.push(`${"#".repeat(entryHeadingLevel)} ${entry.title}`);
1981
- lines.push("");
1982
- lines.push(`<!-- source: ${entry.relativePath} | order: ${entry.order} | status: ${entry.status} -->`);
1983
- lines.push("");
1984
- lines.push(entry.body.trim());
1985
- lines.push("");
1986
- }
1987
- if (tocEntries.length > 0) {
1988
- const tocStart = lines.indexOf("## Table of Contents");
1989
- if (tocStart >= 0) {
1990
- const insertAt = tocStart + 2;
1991
- const tocLines = tocEntries.map((entry) => `${" ".repeat(entry.level)}- [${entry.heading}](#${slugify(entry.heading)})`);
1992
- lines.splice(insertAt, 0, ...tocLines);
1993
- }
1994
- }
1995
- const outputPath = path.join(project.distDir, `${project.id}.md`);
1996
- fs.writeFileSync(outputPath, `${lines.join("\n")}\n`, "utf8");
1997
- return outputPath;
1998
- }
1999
- function formatCompileStructureHeading(level, value, title) {
2000
- const resolvedTitle = title || "";
2001
- if (!resolvedTitle && level.headingTemplate === "{label} {value}: {title}") {
2002
- return `${level.label} ${value}`;
2003
- }
2004
- return level.headingTemplate
2005
- .replaceAll("{label}", level.label)
2006
- .replaceAll("{value}", value)
2007
- .replaceAll("{title}", resolvedTitle)
2008
- .replace(/\s+/g, " ")
2009
- .replace(/:\s*$/, "")
2010
- .trim();
2011
- }
2012
- function toScalarMetadataString(rawValue) {
2013
- if (rawValue == null || rawValue === "" || Array.isArray(rawValue)) {
2014
- return undefined;
2015
- }
2016
- const normalized = String(rawValue).trim();
2017
- return normalized.length > 0 ? normalized : undefined;
2018
- }
2019
- function slugify(value) {
2020
- return value
2021
- .toLowerCase()
2022
- .replace(/[^a-z0-9\s-]/g, "")
2023
- .trim()
2024
- .replace(/\s+/g, "-");
2025
- }
2026
- function runExport(project, format, inputPath, explicitOutputPath) {
2027
- if (!isExportFormat(format)) {
2028
- throw new Error(`Unsupported export format '${format}'. Use md, docx, pdf, or epub.`);
2029
- }
2030
- const exporters = {
2031
- md: markdownExporter,
2032
- docx: createPandocExporter("docx"),
2033
- pdf: createPandocExporter("pdf"),
2034
- epub: createPandocExporter("epub")
2035
- };
2036
- const exporter = exporters[format];
2037
- const targetPath = explicitOutputPath || path.join(project.distDir, "exports", `${project.id}.${format === "md" ? "md" : format}`);
2038
- const capability = exporter.canRun();
2039
- if (!capability.ok) {
2040
- throw new Error(capability.reason || `Exporter '${exporter.id}' cannot run.`);
2041
- }
2042
- exporter.run({
2043
- inputPath,
2044
- outputPath: path.resolve(repoRoot, targetPath)
2045
- });
2046
- return path.resolve(repoRoot, targetPath);
2047
- }
2048
- function printReport(issues) {
2049
- if (issues.length === 0) {
2050
- return;
2051
- }
2052
- for (const issue of issues) {
2053
- const filePart = issue.file ? ` ${issue.file}` : "";
2054
- const linePart = issue.line ? `:${issue.line}` : "";
2055
- console.log(`[${issue.level.toUpperCase()}][${issue.category}]${filePart}${linePart} ${issue.message}`);
2056
- }
2057
- }
2058
- function exitIfErrors(issues) {
2059
- if (issues.some((issue) => issue.level === "error")) {
2060
- process.exit(1);
2061
- }
2062
- }
2063
- function makeIssue(level, category, message, file = null, line = null) {
2064
- return { level, category, message, file, line };
2065
- }
2066
- function readJson(filePath) {
2067
- if (!fs.existsSync(filePath)) {
2068
- throw new Error(`Missing JSON file: ${filePath}`);
2069
- }
2070
- const raw = fs.readFileSync(filePath, "utf8");
2071
- try {
2072
- return JSON.parse(raw);
2073
- }
2074
- catch (error) {
2075
- if (error instanceof Error) {
2076
- throw new Error(`Invalid JSON at ${filePath}: ${error.message}`);
2077
- }
2078
- throw new Error(`Invalid JSON at ${filePath}: ${String(error)}`);
2079
- }
2080
- }
2081
- function toDisplayTitle(value) {
2082
- return value
2083
- .split(/[-_]+/)
2084
- .filter(Boolean)
2085
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
2086
- .join(" ");
2087
- }
2088
- function toBoundedInt(value, fallback, min, max) {
2089
- let parsed = null;
2090
- if (typeof value === "number" && Number.isFinite(value)) {
2091
- parsed = value;
2092
- }
2093
- else if (typeof value === "string" && value.trim() !== "") {
2094
- const next = Number(value.trim());
2095
- if (Number.isFinite(next)) {
2096
- parsed = next;
2097
- }
2098
- }
2099
- if (parsed == null) {
2100
- return fallback;
2101
- }
2102
- const rounded = Math.round(parsed);
2103
- return Math.min(max, Math.max(min, rounded));
2104
- }
2105
- function logLine(message) {
2106
- console.log(message);
2107
- }