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.
- package/.vscode/extensions.json +7 -0
- package/README.md +41 -0
- package/dist/shared/src/contracts/cli/envelopes.js +19 -0
- package/dist/shared/src/contracts/cli/errors.js +14 -0
- package/dist/shared/src/contracts/cli/exit-codes.js +15 -0
- package/dist/shared/src/contracts/cli/index.js +6 -0
- package/dist/shared/src/contracts/cli/metadata.js +1 -0
- package/dist/shared/src/contracts/cli/operations.js +1 -0
- package/dist/shared/src/domain/comments/anchors.js +1 -0
- package/dist/shared/src/domain/comments/index.js +4 -0
- package/dist/shared/src/domain/comments/serializer.js +1 -0
- package/dist/shared/src/domain/comments/thread-key.js +21 -0
- package/dist/shared/src/domain/frontmatter/index.js +3 -0
- package/dist/shared/src/domain/frontmatter/parser.js +34 -0
- package/dist/shared/src/domain/frontmatter/serializer.js +32 -0
- package/dist/shared/src/domain/frontmatter/validators.js +47 -0
- package/dist/shared/src/domain/project/index.js +4 -0
- package/dist/shared/src/domain/stages/index.js +20 -0
- package/dist/shared/src/index.js +6 -0
- package/dist/shared/src/utils/guards.js +6 -0
- package/dist/shared/src/utils/index.js +3 -0
- package/dist/shared/src/utils/invariant.js +5 -0
- package/dist/shared/src/utils/result.js +6 -0
- package/dist/stego-cli/src/app/cli-version.js +32 -0
- package/dist/stego-cli/src/app/command-context.js +1 -0
- package/dist/stego-cli/src/app/command-registry.js +121 -0
- package/dist/stego-cli/src/app/create-cli-app.js +42 -0
- package/dist/stego-cli/src/app/error-boundary.js +42 -0
- package/dist/stego-cli/src/app/index.js +6 -0
- package/dist/stego-cli/src/app/output-renderer.js +6 -0
- package/dist/stego-cli/src/main.js +14 -0
- package/dist/stego-cli/src/modules/comments/application/comment-operations.js +457 -0
- package/dist/stego-cli/src/modules/comments/commands/comments-add.js +40 -0
- package/dist/stego-cli/src/modules/comments/commands/comments-clear-resolved.js +32 -0
- package/dist/stego-cli/src/modules/comments/commands/comments-delete.js +33 -0
- package/dist/stego-cli/src/modules/comments/commands/comments-read.js +32 -0
- package/dist/stego-cli/src/modules/comments/commands/comments-reply.js +36 -0
- package/dist/stego-cli/src/modules/comments/commands/comments-set-status.js +35 -0
- package/dist/stego-cli/src/modules/comments/commands/comments-sync-anchors.js +33 -0
- package/dist/stego-cli/src/modules/comments/domain/comment-policy.js +14 -0
- package/dist/stego-cli/src/modules/comments/index.js +20 -0
- package/dist/stego-cli/src/modules/comments/infra/comments-repo.js +68 -0
- package/dist/stego-cli/src/modules/comments/types.js +1 -0
- package/dist/stego-cli/src/modules/compile/application/compile-manuscript.js +16 -0
- package/dist/stego-cli/src/modules/compile/commands/build.js +51 -0
- package/dist/stego-cli/src/modules/compile/domain/compile-structure.js +105 -0
- package/dist/stego-cli/src/modules/compile/index.js +8 -0
- package/dist/stego-cli/src/modules/compile/infra/dist-writer.js +8 -0
- package/dist/stego-cli/src/modules/compile/types.js +1 -0
- package/dist/stego-cli/src/modules/export/application/run-export.js +29 -0
- package/dist/stego-cli/src/modules/export/commands/export.js +61 -0
- package/dist/stego-cli/src/modules/export/domain/exporter.js +6 -0
- package/dist/stego-cli/src/modules/export/index.js +8 -0
- package/dist/stego-cli/src/modules/export/types.js +1 -0
- package/dist/stego-cli/src/modules/index.js +22 -0
- package/dist/stego-cli/src/modules/manuscript/application/create-manuscript.js +107 -0
- package/dist/stego-cli/src/modules/manuscript/application/order-inference.js +56 -0
- package/dist/stego-cli/src/modules/manuscript/commands/new-manuscript.js +54 -0
- package/dist/stego-cli/src/modules/manuscript/domain/manuscript.js +1 -0
- package/dist/stego-cli/src/modules/manuscript/index.js +9 -0
- package/dist/stego-cli/src/modules/manuscript/infra/manuscript-repo.js +39 -0
- package/dist/stego-cli/src/modules/manuscript/types.js +1 -0
- package/dist/stego-cli/src/modules/metadata/application/apply-metadata.js +45 -0
- package/dist/stego-cli/src/modules/metadata/application/read-metadata.js +18 -0
- package/dist/stego-cli/src/modules/metadata/commands/metadata-apply.js +38 -0
- package/dist/stego-cli/src/modules/metadata/commands/metadata-read.js +33 -0
- package/dist/stego-cli/src/modules/metadata/domain/metadata.js +1 -0
- package/dist/stego-cli/src/modules/metadata/index.js +11 -0
- package/dist/stego-cli/src/modules/metadata/infra/metadata-repo.js +68 -0
- package/dist/stego-cli/src/modules/metadata/types.js +1 -0
- package/dist/stego-cli/src/modules/project/application/create-project.js +203 -0
- package/dist/stego-cli/src/modules/project/application/infer-project.js +72 -0
- package/dist/stego-cli/src/modules/project/commands/new-project.js +86 -0
- package/dist/stego-cli/src/modules/project/domain/project.js +1 -0
- package/dist/stego-cli/src/modules/project/index.js +9 -0
- package/dist/stego-cli/src/modules/project/infra/project-repo.js +27 -0
- package/dist/stego-cli/src/modules/project/types.js +1 -0
- package/dist/stego-cli/src/modules/quality/application/inspect-project.js +603 -0
- package/dist/stego-cli/src/modules/quality/application/lint-runner.js +313 -0
- package/dist/stego-cli/src/modules/quality/application/stage-check.js +87 -0
- package/dist/stego-cli/src/modules/quality/commands/check-stage.js +54 -0
- package/dist/stego-cli/src/modules/quality/commands/lint.js +51 -0
- package/dist/stego-cli/src/modules/quality/commands/validate.js +50 -0
- package/dist/stego-cli/src/modules/quality/domain/issues.js +1 -0
- package/dist/stego-cli/src/modules/quality/domain/policies.js +1 -0
- package/dist/stego-cli/src/modules/quality/index.js +14 -0
- package/dist/stego-cli/src/modules/quality/infra/cspell-adapter.js +1 -0
- package/dist/stego-cli/src/modules/quality/infra/markdownlint-adapter.js +1 -0
- package/dist/stego-cli/src/modules/quality/types.js +1 -0
- package/dist/stego-cli/src/modules/scaffold/application/scaffold-workspace.js +307 -0
- package/dist/stego-cli/src/modules/scaffold/commands/init.js +34 -0
- package/dist/stego-cli/src/modules/scaffold/domain/templates.js +182 -0
- package/dist/stego-cli/src/modules/scaffold/index.js +8 -0
- package/dist/stego-cli/src/modules/scaffold/infra/template-repo.js +33 -0
- package/dist/stego-cli/src/modules/scaffold/types.js +1 -0
- package/dist/stego-cli/src/modules/spine/application/create-category.js +14 -0
- package/dist/stego-cli/src/modules/spine/application/create-entry.js +9 -0
- package/dist/stego-cli/src/modules/spine/application/read-catalog.js +13 -0
- package/dist/stego-cli/src/modules/spine/commands/spine-deprecated-aliases.js +33 -0
- package/dist/stego-cli/src/modules/spine/commands/spine-new-category.js +64 -0
- package/dist/stego-cli/src/modules/spine/commands/spine-new-entry.js +65 -0
- package/dist/stego-cli/src/modules/spine/commands/spine-read.js +49 -0
- package/dist/{spine/spine-domain.js → stego-cli/src/modules/spine/domain/spine.js} +13 -7
- package/dist/stego-cli/src/modules/spine/index.js +16 -0
- package/dist/stego-cli/src/modules/spine/infra/spine-repo.js +46 -0
- package/dist/stego-cli/src/modules/spine/types.js +1 -0
- package/dist/stego-cli/src/modules/workspace/application/discover-projects.js +18 -0
- package/dist/stego-cli/src/modules/workspace/application/resolve-workspace.js +73 -0
- package/dist/stego-cli/src/modules/workspace/commands/list-projects.js +40 -0
- package/dist/stego-cli/src/modules/workspace/index.js +9 -0
- package/dist/stego-cli/src/modules/workspace/infra/workspace-repo.js +37 -0
- package/dist/stego-cli/src/modules/workspace/types.js +1 -0
- package/dist/stego-cli/src/platform/clock.js +3 -0
- package/dist/stego-cli/src/platform/fs.js +13 -0
- package/dist/stego-cli/src/platform/index.js +3 -0
- package/dist/stego-cli/src/platform/temp-files.js +6 -0
- package/package.json +20 -11
- package/dist/comments/comments-command.js +0 -499
- package/dist/comments/errors.js +0 -20
- package/dist/metadata/metadata-command.js +0 -127
- package/dist/metadata/metadata-domain.js +0 -209
- package/dist/spine/spine-command.js +0 -129
- package/dist/stego-cli.js +0 -2107
- /package/dist/{exporters/exporter-types.js → shared/src/contracts/cli/comments.js} +0 -0
- /package/dist/{comments/comment-domain.js → shared/src/domain/comments/parser.js} +0 -0
- /package/dist/{exporters → stego-cli/src/modules/export/infra}/markdown-exporter.js +0 -0
- /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
|
-
}
|