stego-cli 0.3.4 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.vscode/extensions.json +7 -0
- package/README.md +45 -0
- package/dist/metadata/metadata-command.js +127 -0
- package/dist/metadata/metadata-domain.js +209 -0
- package/dist/spine/spine-command.js +129 -0
- package/dist/spine/spine-domain.js +274 -0
- package/dist/stego-cli.js +358 -187
- package/package.json +3 -2
- package/projects/fiction-example/spine/characters/CHAR-AGNES.md +17 -0
- package/projects/fiction-example/spine/characters/CHAR-ETIENNE.md +17 -0
- package/projects/fiction-example/spine/characters/CHAR-MATTHAEUS.md +17 -0
- package/projects/fiction-example/spine/characters/CHAR-RAOUL.md +17 -0
- package/projects/fiction-example/spine/characters/_category.md +6 -0
- package/projects/fiction-example/spine/locations/LOC-CHARNEL.md +17 -0
- package/projects/fiction-example/spine/locations/LOC-COLLEGE.md +17 -0
- package/projects/fiction-example/spine/locations/LOC-HOTELDIEU.md +17 -0
- package/projects/fiction-example/spine/locations/LOC-QUAY.md +17 -0
- package/projects/fiction-example/spine/locations/_category.md +6 -0
- package/projects/fiction-example/spine/sources/SRC-CONJUNCTION.md +20 -0
- package/projects/fiction-example/spine/sources/SRC-GALEN.md +20 -0
- package/projects/fiction-example/spine/sources/SRC-WARD-DATA.md +20 -0
- package/projects/fiction-example/spine/sources/_category.md +6 -0
- package/projects/fiction-example/stego-project.json +1 -18
- package/projects/stego-docs/manuscript/500-project-configuration.md +3 -3
- package/projects/stego-docs/spine/commands/CMD-BUILD.md +11 -0
- package/projects/stego-docs/spine/commands/CMD-CHECK-STAGE.md +11 -0
- package/projects/stego-docs/spine/commands/CMD-EXPORT.md +11 -0
- package/projects/stego-docs/spine/commands/CMD-INIT.md +11 -0
- package/projects/stego-docs/spine/commands/CMD-LIST-PROJECTS.md +10 -0
- package/projects/stego-docs/spine/commands/CMD-NEW-PROJECT.md +10 -0
- package/projects/stego-docs/spine/commands/CMD-NEW.md +11 -0
- package/projects/stego-docs/spine/commands/CMD-VALIDATE.md +11 -0
- package/projects/stego-docs/spine/commands/_category.md +6 -0
- package/projects/stego-docs/spine/concepts/CON-COMPILE-STRUCTURE.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-DIST.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-MANUSCRIPT.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-METADATA.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-NOTES.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-PROJECT.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-SPINE-CATEGORY.md +11 -0
- package/projects/stego-docs/spine/concepts/CON-SPINE.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-STAGE-GATE.md +10 -0
- package/projects/stego-docs/spine/concepts/CON-WORKSPACE.md +9 -0
- package/projects/stego-docs/spine/concepts/_category.md +6 -0
- package/projects/stego-docs/spine/configuration/CFG-ALLOWED-STATUSES.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-COMPILE-LEVELS.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-COMPILE-STRUCTURE.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-REQUIRED-METADATA.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-SPINE-CATEGORIES.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-STAGE-POLICIES.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-STEGO-CONFIG.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-STEGO-PROJECT.md +9 -0
- package/projects/stego-docs/spine/configuration/_category.md +6 -0
- package/projects/stego-docs/spine/integrations/INT-CSPELL.md +9 -0
- package/projects/stego-docs/spine/integrations/INT-MARKDOWNLINT.md +9 -0
- package/projects/stego-docs/spine/integrations/INT-PANDOC.md +9 -0
- package/projects/stego-docs/spine/integrations/INT-SAURUS-EXTENSION.md +9 -0
- package/projects/stego-docs/spine/integrations/INT-STEGO-EXTENSION.md +9 -0
- package/projects/stego-docs/spine/integrations/INT-VSCODE.md +9 -0
- package/projects/stego-docs/spine/integrations/_category.md +6 -0
- package/projects/stego-docs/spine/workflows/FLOW-BUILD-EXPORT.md +10 -0
- package/projects/stego-docs/spine/workflows/FLOW-DAILY-WRITING.md +10 -0
- package/projects/stego-docs/spine/workflows/FLOW-INIT-WORKSPACE.md +9 -0
- package/projects/stego-docs/spine/workflows/FLOW-NEW-PROJECT.md +10 -0
- package/projects/stego-docs/spine/workflows/FLOW-PROOF-RELEASE.md +10 -0
- package/projects/stego-docs/spine/workflows/FLOW-STAGE-PROMOTION.md +10 -0
- package/projects/stego-docs/spine/workflows/_category.md +6 -0
- package/projects/stego-docs/stego-project.json +1 -28
- package/projects/fiction-example/spine/characters.md +0 -35
- package/projects/fiction-example/spine/locations.md +0 -37
- package/projects/fiction-example/spine/sources.md +0 -31
- package/projects/stego-docs/spine/commands.md +0 -71
- package/projects/stego-docs/spine/concepts.md +0 -72
- package/projects/stego-docs/spine/configuration.md +0 -57
- package/projects/stego-docs/spine/integrations.md +0 -43
- package/projects/stego-docs/spine/workflows.md +0 -48
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { deriveDefaultLabelFromFilename, parseMarkdownDocument, serializeMarkdownDocument } from "../metadata/metadata-domain.js";
|
|
4
|
+
const CATEGORY_KEY_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
5
|
+
export function readSpineCatalog(projectRoot, spineDir) {
|
|
6
|
+
const absoluteSpineDir = path.resolve(spineDir);
|
|
7
|
+
const issues = [];
|
|
8
|
+
const categories = [];
|
|
9
|
+
const entriesByCategory = new Map();
|
|
10
|
+
if (!fs.existsSync(absoluteSpineDir)) {
|
|
11
|
+
return { categories, entriesByCategory, issues: [`Missing spine directory: ${absoluteSpineDir}`] };
|
|
12
|
+
}
|
|
13
|
+
const categoryDirs = fs
|
|
14
|
+
.readdirSync(absoluteSpineDir, { withFileTypes: true })
|
|
15
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
|
|
16
|
+
.map((entry) => entry.name)
|
|
17
|
+
.sort((a, b) => a.localeCompare(b));
|
|
18
|
+
for (const categoryKey of categoryDirs) {
|
|
19
|
+
if (!CATEGORY_KEY_PATTERN.test(categoryKey)) {
|
|
20
|
+
issues.push(`Ignored invalid spine category directory '${categoryKey}'.`);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const categoryDir = path.join(absoluteSpineDir, categoryKey);
|
|
24
|
+
const metadataPath = path.join(categoryDir, "_category.md");
|
|
25
|
+
const categoryLabel = readCategoryLabel(metadataPath, categoryKey);
|
|
26
|
+
const entryFiles = collectEntryFiles(categoryDir);
|
|
27
|
+
const entries = [];
|
|
28
|
+
const knownKeys = new Set();
|
|
29
|
+
for (const entryFile of entryFiles) {
|
|
30
|
+
const key = toEntryKey(categoryDir, entryFile);
|
|
31
|
+
if (!key) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (knownKeys.has(key)) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
knownKeys.add(key);
|
|
38
|
+
const entryMeta = readEntryPreview(entryFile);
|
|
39
|
+
entries.push({
|
|
40
|
+
key,
|
|
41
|
+
path: path.relative(projectRoot, entryFile).split(path.sep).join("/"),
|
|
42
|
+
label: entryMeta.label,
|
|
43
|
+
title: entryMeta.title,
|
|
44
|
+
description: entryMeta.description
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
entries.sort((a, b) => a.key.localeCompare(b.key));
|
|
48
|
+
entriesByCategory.set(categoryKey, knownKeys);
|
|
49
|
+
categories.push({
|
|
50
|
+
key: categoryKey,
|
|
51
|
+
label: categoryLabel,
|
|
52
|
+
path: path.relative(projectRoot, categoryDir).split(path.sep).join("/"),
|
|
53
|
+
metadataPath: path.relative(projectRoot, metadataPath).split(path.sep).join("/"),
|
|
54
|
+
entries
|
|
55
|
+
});
|
|
56
|
+
if (!fs.existsSync(metadataPath)) {
|
|
57
|
+
issues.push(`Missing category metadata file: ${metadataPath}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { categories, entriesByCategory, issues };
|
|
61
|
+
}
|
|
62
|
+
export function createSpineCategory(projectRoot, spineDir, keyRaw, labelRaw, requiredMetadata, updateRequiredMetadata) {
|
|
63
|
+
const key = normalizeCategoryKey(keyRaw);
|
|
64
|
+
if (!key) {
|
|
65
|
+
throw new Error("Category key is required.");
|
|
66
|
+
}
|
|
67
|
+
if (!CATEGORY_KEY_PATTERN.test(key)) {
|
|
68
|
+
throw new Error(`Invalid category key '${key}'. Use letters, numbers, '_' or '-'.`);
|
|
69
|
+
}
|
|
70
|
+
const categoryDir = path.join(path.resolve(spineDir), key);
|
|
71
|
+
fs.mkdirSync(categoryDir, { recursive: true });
|
|
72
|
+
const label = (labelRaw || "").trim() || toDisplayLabel(key);
|
|
73
|
+
const metadataPath = path.join(categoryDir, "_category.md");
|
|
74
|
+
if (!fs.existsSync(metadataPath)) {
|
|
75
|
+
const frontmatter = { label };
|
|
76
|
+
const rendered = serializeMarkdownDocument({
|
|
77
|
+
lineEnding: "\n",
|
|
78
|
+
hasFrontmatter: true,
|
|
79
|
+
frontmatter,
|
|
80
|
+
body: `# ${label}\n\n`
|
|
81
|
+
});
|
|
82
|
+
fs.writeFileSync(metadataPath, rendered, "utf8");
|
|
83
|
+
}
|
|
84
|
+
let requiredMetadataUpdated = false;
|
|
85
|
+
if (updateRequiredMetadata) {
|
|
86
|
+
const normalizedRequired = new Set(requiredMetadata.map((value) => value.trim()).filter(Boolean));
|
|
87
|
+
if (!normalizedRequired.has(key)) {
|
|
88
|
+
normalizedRequired.add(key);
|
|
89
|
+
requiredMetadata.splice(0, requiredMetadata.length, ...Array.from(normalizedRequired));
|
|
90
|
+
requiredMetadataUpdated = true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
key,
|
|
95
|
+
label,
|
|
96
|
+
categoryDir: path.relative(projectRoot, categoryDir).split(path.sep).join("/"),
|
|
97
|
+
metadataPath: path.relative(projectRoot, metadataPath).split(path.sep).join("/"),
|
|
98
|
+
requiredMetadataUpdated
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export function createSpineEntry(projectRoot, spineDir, categoryRaw, filenameRaw) {
|
|
102
|
+
const category = normalizeCategoryKey(categoryRaw);
|
|
103
|
+
if (!category) {
|
|
104
|
+
throw new Error("--category is required.");
|
|
105
|
+
}
|
|
106
|
+
if (!CATEGORY_KEY_PATTERN.test(category)) {
|
|
107
|
+
throw new Error(`Invalid category key '${category}'. Use letters, numbers, '_' or '-'.`);
|
|
108
|
+
}
|
|
109
|
+
const categoryDir = path.join(path.resolve(spineDir), category);
|
|
110
|
+
if (!fs.existsSync(categoryDir)) {
|
|
111
|
+
throw new Error(`Spine category '${category}' does not exist. Run 'stego spine new-category --key ${category}' first.`);
|
|
112
|
+
}
|
|
113
|
+
const filename = normalizeEntryFilename(filenameRaw);
|
|
114
|
+
const fullPath = path.resolve(path.join(categoryDir, filename));
|
|
115
|
+
const relativeToCategory = path.relative(categoryDir, fullPath).split(path.sep).join("/");
|
|
116
|
+
if (!relativeToCategory || relativeToCategory.startsWith("..") || path.isAbsolute(relativeToCategory)) {
|
|
117
|
+
throw new Error(`Invalid filename '${filename}'. Use a path inside category '${category}'.`);
|
|
118
|
+
}
|
|
119
|
+
if (!fullPath.toLowerCase().endsWith(".md")) {
|
|
120
|
+
throw new Error(`Invalid filename '${filename}'. Use markdown file paths.`);
|
|
121
|
+
}
|
|
122
|
+
if (fs.existsSync(fullPath)) {
|
|
123
|
+
throw new Error(`Spine entry already exists: ${fullPath}`);
|
|
124
|
+
}
|
|
125
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
126
|
+
const inferredLabel = deriveDefaultLabelFromFilename(fullPath);
|
|
127
|
+
const rendered = serializeMarkdownDocument({
|
|
128
|
+
lineEnding: "\n",
|
|
129
|
+
hasFrontmatter: false,
|
|
130
|
+
frontmatter: {},
|
|
131
|
+
body: `# ${inferredLabel}\n\n`
|
|
132
|
+
});
|
|
133
|
+
fs.writeFileSync(fullPath, rendered, "utf8");
|
|
134
|
+
return {
|
|
135
|
+
category,
|
|
136
|
+
entryKey: toEntryKey(categoryDir, fullPath) || path.basename(fullPath, ".md"),
|
|
137
|
+
filePath: path.relative(projectRoot, fullPath).split(path.sep).join("/")
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
export function toDisplayLabel(value) {
|
|
141
|
+
const normalized = value
|
|
142
|
+
.replace(/[_-]+/g, " ")
|
|
143
|
+
.trim();
|
|
144
|
+
if (!normalized) {
|
|
145
|
+
return value;
|
|
146
|
+
}
|
|
147
|
+
return normalized.replace(/\b\w/g, (part) => part.toUpperCase());
|
|
148
|
+
}
|
|
149
|
+
function readCategoryLabel(metadataPath, categoryKey) {
|
|
150
|
+
if (!fs.existsSync(metadataPath)) {
|
|
151
|
+
return toDisplayLabel(categoryKey);
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const raw = fs.readFileSync(metadataPath, "utf8");
|
|
155
|
+
const parsed = parseMarkdownDocument(raw);
|
|
156
|
+
const fromFrontmatter = typeof parsed.frontmatter.label === "string" ? parsed.frontmatter.label.trim() : "";
|
|
157
|
+
if (fromFrontmatter) {
|
|
158
|
+
return fromFrontmatter;
|
|
159
|
+
}
|
|
160
|
+
const heading = findFirstHeading(parsed.body);
|
|
161
|
+
if (heading) {
|
|
162
|
+
return heading;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// fallback below
|
|
167
|
+
}
|
|
168
|
+
return toDisplayLabel(categoryKey);
|
|
169
|
+
}
|
|
170
|
+
function readEntryPreview(filePath) {
|
|
171
|
+
const fallback = deriveDefaultLabelFromFilename(filePath);
|
|
172
|
+
try {
|
|
173
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
174
|
+
const parsed = parseMarkdownDocument(raw);
|
|
175
|
+
const heading = findFirstHeading(parsed.body);
|
|
176
|
+
const labelFromFrontmatter = typeof parsed.frontmatter.label === "string" ? parsed.frontmatter.label.trim() : "";
|
|
177
|
+
const label = labelFromFrontmatter || heading || fallback;
|
|
178
|
+
const title = heading || label || fallback;
|
|
179
|
+
const description = firstContentLine(parsed.body, heading).slice(0, 220);
|
|
180
|
+
return { label, title, description };
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return { label: fallback, title: fallback, description: "" };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function collectEntryFiles(categoryDir) {
|
|
187
|
+
const files = [];
|
|
188
|
+
const stack = [path.resolve(categoryDir)];
|
|
189
|
+
while (stack.length > 0) {
|
|
190
|
+
const current = stack.pop();
|
|
191
|
+
if (!current) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
let entries;
|
|
195
|
+
try {
|
|
196
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
202
|
+
for (const entry of entries) {
|
|
203
|
+
if (entry.name.startsWith(".")) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const full = path.join(current, entry.name);
|
|
207
|
+
if (entry.isDirectory()) {
|
|
208
|
+
stack.push(full);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".md")) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (entry.name.toLowerCase() === "_category.md") {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
files.push(full);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return files.sort((a, b) => a.localeCompare(b));
|
|
221
|
+
}
|
|
222
|
+
function normalizeCategoryKey(rawValue) {
|
|
223
|
+
return rawValue.trim().toLowerCase();
|
|
224
|
+
}
|
|
225
|
+
function normalizeEntryFilename(rawValue) {
|
|
226
|
+
const trimmed = (rawValue || "").trim();
|
|
227
|
+
const fallback = "new-entry.md";
|
|
228
|
+
const withFallback = trimmed || fallback;
|
|
229
|
+
const withExt = withFallback.toLowerCase().endsWith(".md") ? withFallback : `${withFallback}.md`;
|
|
230
|
+
const normalized = withExt.replace(/\\/g, "/");
|
|
231
|
+
if (normalized.startsWith("/") || normalized.includes("../") || normalized.startsWith("../")) {
|
|
232
|
+
throw new Error(`Invalid filename '${rawValue || ""}'.`);
|
|
233
|
+
}
|
|
234
|
+
return normalized;
|
|
235
|
+
}
|
|
236
|
+
function toEntryKey(categoryDir, filePath) {
|
|
237
|
+
const relative = path.relative(categoryDir, filePath).split(path.sep).join("/");
|
|
238
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
if (!relative.toLowerCase().endsWith(".md")) {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
return relative.slice(0, -3);
|
|
245
|
+
}
|
|
246
|
+
function findFirstHeading(body) {
|
|
247
|
+
for (const line of body.split(/\r?\n/)) {
|
|
248
|
+
const trimmed = line.trim();
|
|
249
|
+
const match = trimmed.match(/^#{1,6}\s+(.+?)\s*$/);
|
|
250
|
+
if (match) {
|
|
251
|
+
return match[1].trim();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
function firstContentLine(body, heading) {
|
|
257
|
+
const lines = body.split(/\r?\n/);
|
|
258
|
+
let skippedHeading = !heading;
|
|
259
|
+
for (const rawLine of lines) {
|
|
260
|
+
const trimmed = rawLine.trim();
|
|
261
|
+
if (!trimmed) {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (!skippedHeading && /^#{1,6}\s+/.test(trimmed)) {
|
|
265
|
+
skippedHeading = true;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (trimmed.startsWith("<!--")) {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
return trimmed;
|
|
272
|
+
}
|
|
273
|
+
return "";
|
|
274
|
+
}
|