stego-cli 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -22
- package/dist/shared/src/domain/frontmatter/validators.js +20 -2
- package/dist/shared/src/domain/images/index.js +1 -0
- package/dist/shared/src/domain/images/style.js +185 -0
- package/dist/shared/src/index.js +1 -0
- package/dist/stego-cli/src/app/command-registry.js +93 -1
- package/dist/stego-cli/src/app/create-cli-app.js +3 -0
- package/dist/stego-cli/src/app/error-boundary.js +11 -1
- package/dist/stego-cli/src/modules/comments/commands/comments-add.js +0 -1
- package/dist/stego-cli/src/modules/comments/commands/comments-clear-resolved.js +0 -1
- package/dist/stego-cli/src/modules/comments/commands/comments-delete.js +0 -1
- package/dist/stego-cli/src/modules/comments/commands/comments-read.js +0 -1
- package/dist/stego-cli/src/modules/comments/commands/comments-reply.js +0 -1
- package/dist/stego-cli/src/modules/comments/commands/comments-set-status.js +0 -1
- package/dist/stego-cli/src/modules/comments/commands/comments-sync-anchors.js +0 -1
- package/dist/stego-cli/src/modules/compile/application/compile-manuscript.js +12 -1
- package/dist/stego-cli/src/modules/compile/commands/build.js +1 -2
- package/dist/stego-cli/src/modules/compile/domain/compile-structure.js +0 -3
- package/dist/stego-cli/src/modules/compile/domain/image-settings.js +394 -0
- package/dist/stego-cli/src/modules/export/application/run-export.js +22 -1
- package/dist/stego-cli/src/modules/export/commands/export.js +1 -2
- package/dist/stego-cli/src/modules/export/infra/pandoc-exporter.js +29 -2
- package/dist/stego-cli/src/modules/manuscript/commands/new-manuscript.js +1 -1
- package/dist/stego-cli/src/modules/project/application/create-project.js +41 -1
- package/dist/stego-cli/src/modules/project/application/infer-project.js +1 -1
- package/dist/stego-cli/src/modules/project/commands/new-project.js +1 -1
- package/dist/stego-cli/src/modules/quality/application/inspect-project.js +147 -112
- package/dist/stego-cli/src/modules/quality/commands/check-stage.js +1 -2
- package/dist/stego-cli/src/modules/quality/commands/lint.js +1 -2
- package/dist/stego-cli/src/modules/quality/commands/validate.js +1 -2
- package/dist/stego-cli/src/modules/scaffold/commands/init.js +2 -2
- package/dist/stego-cli/src/modules/scaffold/domain/templates.js +15 -14
- package/dist/stego-cli/src/modules/spine/commands/spine-deprecated-aliases.js +2 -2
- package/dist/stego-cli/src/modules/spine/commands/spine-new-category.js +1 -2
- package/dist/stego-cli/src/modules/spine/commands/spine-new-entry.js +1 -2
- package/dist/stego-cli/src/modules/spine/commands/spine-read.js +1 -2
- package/filters/image-layout.css +23 -0
- package/filters/image-layout.lua +170 -0
- package/package.json +4 -2
- package/projects/fiction-example/assets/README.md +31 -0
- package/projects/stego-docs/assets/README.md +31 -0
- package/projects/stego-docs/manuscript/500-project-configuration.md +37 -0
- package/projects/stego-docs/manuscript/800-build-export-and-release-outputs.md +4 -0
- package/dist/stego-cli/src/modules/manuscript/domain/manuscript.js +0 -1
- package/dist/stego-cli/src/modules/project/domain/project.js +0 -1
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { IMAGE_GLOBAL_KEYS, asPlainRecord, cloneImageStyle, extractImageDestinationTarget, inferEffectiveImageLayout, isExternalImageTarget, isImageStyleEmpty, mergeImageStyles, normalizeImageAttrs, normalizeImageClasses, normalizeImagePathKey, normalizeImageScalar, stripImageQueryAndAnchor } from "../../../../../shared/src/domain/images/index.js";
|
|
3
|
+
export function parseProjectImageDefaults(projectMeta) {
|
|
4
|
+
const warnings = [];
|
|
5
|
+
const global = {};
|
|
6
|
+
const overrides = new Map();
|
|
7
|
+
const rawImages = projectMeta.images;
|
|
8
|
+
const imagesRecord = asPlainRecord(rawImages);
|
|
9
|
+
if (rawImages == null) {
|
|
10
|
+
return { global, overrides, warnings };
|
|
11
|
+
}
|
|
12
|
+
if (!imagesRecord) {
|
|
13
|
+
warnings.push("Project 'images' must be an object.");
|
|
14
|
+
return { global, overrides, warnings };
|
|
15
|
+
}
|
|
16
|
+
for (const [key, value] of Object.entries(imagesRecord)) {
|
|
17
|
+
if (IMAGE_GLOBAL_KEYS.has(key)) {
|
|
18
|
+
applyImageStyleField(global, key, value, `project.images.${key}`, warnings);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
warnings.push(`Project image defaults do not support key 'images.${key}'. Use only width, height, classes, id, attrs, layout, align in stego-project.json.`);
|
|
22
|
+
}
|
|
23
|
+
return { global, overrides, warnings };
|
|
24
|
+
}
|
|
25
|
+
export function parseManuscriptImageOverrides(frontmatter) {
|
|
26
|
+
const warnings = [];
|
|
27
|
+
const global = {};
|
|
28
|
+
const overrides = new Map();
|
|
29
|
+
const rawImages = frontmatter.images;
|
|
30
|
+
const imagesRecord = asPlainRecord(rawImages);
|
|
31
|
+
if (rawImages == null) {
|
|
32
|
+
return { global, overrides, warnings };
|
|
33
|
+
}
|
|
34
|
+
if (!imagesRecord) {
|
|
35
|
+
warnings.push("Metadata 'images' must be an object.");
|
|
36
|
+
return { global, overrides, warnings };
|
|
37
|
+
}
|
|
38
|
+
for (const [key, value] of Object.entries(imagesRecord)) {
|
|
39
|
+
if (IMAGE_GLOBAL_KEYS.has(key)) {
|
|
40
|
+
warnings.push(`Manuscript frontmatter 'images.${key}' is reserved for project defaults. Put defaults in stego-project.json 'images.${key}'.`);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const styleRecord = asPlainRecord(value);
|
|
44
|
+
if (!styleRecord) {
|
|
45
|
+
warnings.push(`Metadata 'images.${key}' must be an object of style keys (width, height, classes, id, attrs, layout, align).`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const style = {};
|
|
49
|
+
for (const [styleKey, styleValue] of Object.entries(styleRecord)) {
|
|
50
|
+
applyImageStyleField(style, styleKey, styleValue, `images.${key}.${styleKey}`, warnings);
|
|
51
|
+
}
|
|
52
|
+
overrides.set(normalizeImagePathKey(key), style);
|
|
53
|
+
}
|
|
54
|
+
return { global, overrides, warnings };
|
|
55
|
+
}
|
|
56
|
+
export function rewriteMarkdownImagesForChapter(input) {
|
|
57
|
+
const projectDefaults = parseProjectImageDefaults(input.projectMeta);
|
|
58
|
+
const manuscriptOverrides = parseManuscriptImageOverrides(input.frontmatter);
|
|
59
|
+
const settings = {
|
|
60
|
+
global: projectDefaults.global,
|
|
61
|
+
overrides: manuscriptOverrides.overrides,
|
|
62
|
+
warnings: [...projectDefaults.warnings, ...manuscriptOverrides.warnings]
|
|
63
|
+
};
|
|
64
|
+
if (isImageStyleEmpty(settings.global) && settings.overrides.size === 0) {
|
|
65
|
+
return input.body;
|
|
66
|
+
}
|
|
67
|
+
const lineEnding = input.body.includes("\r\n") ? "\r\n" : "\n";
|
|
68
|
+
const lines = input.body.split(/\r?\n/);
|
|
69
|
+
const rewritten = [];
|
|
70
|
+
let openFence = null;
|
|
71
|
+
const chapterDir = path.dirname(input.chapterPath);
|
|
72
|
+
const assetsDir = path.resolve(input.projectRoot, "assets");
|
|
73
|
+
for (const line of lines) {
|
|
74
|
+
const trimmed = line.trimStart();
|
|
75
|
+
const fenceMatch = trimmed.match(/^(```+|~~~+)/);
|
|
76
|
+
if (fenceMatch) {
|
|
77
|
+
const marker = fenceMatch[1][0];
|
|
78
|
+
const length = fenceMatch[1].length;
|
|
79
|
+
if (!openFence) {
|
|
80
|
+
openFence = { marker, length };
|
|
81
|
+
}
|
|
82
|
+
else if (openFence.marker === marker && length >= openFence.length) {
|
|
83
|
+
openFence = null;
|
|
84
|
+
}
|
|
85
|
+
rewritten.push(line);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (openFence) {
|
|
89
|
+
rewritten.push(line);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
rewritten.push(rewriteImagesInLine(line, {
|
|
93
|
+
settings,
|
|
94
|
+
chapterDir,
|
|
95
|
+
projectRoot: input.projectRoot,
|
|
96
|
+
assetsDir
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
return rewritten.join(lineEnding);
|
|
100
|
+
}
|
|
101
|
+
function rewriteImagesInLine(line, context) {
|
|
102
|
+
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)(\s*\{[^{}]*\})?/g;
|
|
103
|
+
return line.replace(imageRegex, (_full, altText, destination, inlineAttrText) => {
|
|
104
|
+
const parsedDestination = extractImageDestinationTarget(destination);
|
|
105
|
+
if (!parsedDestination || isExternalImageTarget(parsedDestination) || parsedDestination.startsWith("#")) {
|
|
106
|
+
return _full;
|
|
107
|
+
}
|
|
108
|
+
const cleanTarget = stripImageQueryAndAnchor(parsedDestination);
|
|
109
|
+
if (!cleanTarget) {
|
|
110
|
+
return _full;
|
|
111
|
+
}
|
|
112
|
+
const resolvedPath = path.resolve(context.chapterDir, cleanTarget);
|
|
113
|
+
if (!isPathInside(resolvedPath, context.assetsDir)) {
|
|
114
|
+
return _full;
|
|
115
|
+
}
|
|
116
|
+
const relativeToProject = path.relative(context.projectRoot, resolvedPath);
|
|
117
|
+
if (!relativeToProject || relativeToProject.startsWith("..") || path.isAbsolute(relativeToProject)) {
|
|
118
|
+
return _full;
|
|
119
|
+
}
|
|
120
|
+
const projectKey = normalizeImagePathKey(relativeToProject);
|
|
121
|
+
const effectiveStyle = buildEffectiveStyle(context.settings, projectKey);
|
|
122
|
+
if (isImageStyleEmpty(effectiveStyle)) {
|
|
123
|
+
return _full;
|
|
124
|
+
}
|
|
125
|
+
const inlineStyle = parseInlineAttrList(inlineAttrText?.trim());
|
|
126
|
+
const merged = mergeStyle(effectiveStyle, inlineStyle);
|
|
127
|
+
const renderedAttrList = renderAttrList(merged);
|
|
128
|
+
return `${renderedAttrList}`;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
function buildEffectiveStyle(settings, projectRelativeKey) {
|
|
132
|
+
const merged = cloneImageStyle(settings.global);
|
|
133
|
+
const override = settings.overrides.get(projectRelativeKey);
|
|
134
|
+
if (override) {
|
|
135
|
+
return mergeStyle(merged, override);
|
|
136
|
+
}
|
|
137
|
+
return merged;
|
|
138
|
+
}
|
|
139
|
+
function mergeStyle(base, next) {
|
|
140
|
+
const merged = mergeImageStyles(base, next);
|
|
141
|
+
if (next.width) {
|
|
142
|
+
if (merged.attrs && Object.hasOwn(merged.attrs, "width")) {
|
|
143
|
+
merged.attrs.width = next.width;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (next.height) {
|
|
147
|
+
if (merged.attrs && Object.hasOwn(merged.attrs, "height")) {
|
|
148
|
+
merged.attrs.height = next.height;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (next.layout) {
|
|
152
|
+
if (merged.attrs && Object.hasOwn(merged.attrs, "data-layout")) {
|
|
153
|
+
merged.attrs["data-layout"] = next.layout;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (next.align) {
|
|
157
|
+
if (merged.attrs && Object.hasOwn(merged.attrs, "data-align")) {
|
|
158
|
+
merged.attrs["data-align"] = next.align;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return merged;
|
|
162
|
+
}
|
|
163
|
+
function parseInlineAttrList(rawAttrText) {
|
|
164
|
+
if (!rawAttrText) {
|
|
165
|
+
return {};
|
|
166
|
+
}
|
|
167
|
+
const text = rawAttrText.trim();
|
|
168
|
+
if (!text.startsWith("{") || !text.endsWith("}")) {
|
|
169
|
+
return {};
|
|
170
|
+
}
|
|
171
|
+
const body = text.slice(1, -1).trim();
|
|
172
|
+
if (!body) {
|
|
173
|
+
return {};
|
|
174
|
+
}
|
|
175
|
+
const style = {};
|
|
176
|
+
const attrs = {};
|
|
177
|
+
const classes = [];
|
|
178
|
+
for (const token of splitAttrTokens(body)) {
|
|
179
|
+
if (!token) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (token.startsWith("#")) {
|
|
183
|
+
const id = token.slice(1).trim();
|
|
184
|
+
if (id) {
|
|
185
|
+
style.id = id;
|
|
186
|
+
}
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (token.startsWith(".")) {
|
|
190
|
+
const className = token.slice(1).trim();
|
|
191
|
+
if (className) {
|
|
192
|
+
classes.push(className);
|
|
193
|
+
}
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const equalsIndex = token.indexOf("=");
|
|
197
|
+
if (equalsIndex <= 0) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const key = token.slice(0, equalsIndex).trim();
|
|
201
|
+
const value = stripWrappingQuotes(token.slice(equalsIndex + 1).trim());
|
|
202
|
+
if (!key || !value) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (key === "width") {
|
|
206
|
+
style.width = value;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (key === "height") {
|
|
210
|
+
style.height = value;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (key === "layout") {
|
|
214
|
+
if (value === "block" || value === "inline") {
|
|
215
|
+
style.layout = value;
|
|
216
|
+
}
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (key === "align") {
|
|
220
|
+
if (value === "left" || value === "center" || value === "right") {
|
|
221
|
+
style.align = value;
|
|
222
|
+
}
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
attrs[key] = value;
|
|
226
|
+
}
|
|
227
|
+
if (classes.length > 0) {
|
|
228
|
+
style.classes = classes;
|
|
229
|
+
}
|
|
230
|
+
if (Object.keys(attrs).length > 0) {
|
|
231
|
+
style.attrs = attrs;
|
|
232
|
+
}
|
|
233
|
+
return style;
|
|
234
|
+
}
|
|
235
|
+
function splitAttrTokens(value) {
|
|
236
|
+
const tokens = [];
|
|
237
|
+
let current = "";
|
|
238
|
+
let quote = null;
|
|
239
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
240
|
+
const char = value[index];
|
|
241
|
+
if (quote) {
|
|
242
|
+
current += char;
|
|
243
|
+
if (char === quote && value[index - 1] !== "\\") {
|
|
244
|
+
quote = null;
|
|
245
|
+
}
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (char === '"' || char === "'") {
|
|
249
|
+
quote = char;
|
|
250
|
+
current += char;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (/\s/.test(char)) {
|
|
254
|
+
if (current) {
|
|
255
|
+
tokens.push(current);
|
|
256
|
+
current = "";
|
|
257
|
+
}
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
current += char;
|
|
261
|
+
}
|
|
262
|
+
if (current) {
|
|
263
|
+
tokens.push(current);
|
|
264
|
+
}
|
|
265
|
+
return tokens;
|
|
266
|
+
}
|
|
267
|
+
function renderAttrList(style) {
|
|
268
|
+
const attrs = { ...(style.attrs ?? {}) };
|
|
269
|
+
const effectiveLayout = inferEffectiveImageLayout(style);
|
|
270
|
+
if (effectiveLayout && !Object.hasOwn(attrs, "data-layout")) {
|
|
271
|
+
attrs["data-layout"] = effectiveLayout;
|
|
272
|
+
}
|
|
273
|
+
if (style.align && !Object.hasOwn(attrs, "data-align")) {
|
|
274
|
+
attrs["data-align"] = style.align;
|
|
275
|
+
}
|
|
276
|
+
if (style.width && !Object.hasOwn(attrs, "width")) {
|
|
277
|
+
attrs.width = style.width;
|
|
278
|
+
}
|
|
279
|
+
if (style.height && !Object.hasOwn(attrs, "height")) {
|
|
280
|
+
attrs.height = style.height;
|
|
281
|
+
}
|
|
282
|
+
const tokens = [];
|
|
283
|
+
if (style.id) {
|
|
284
|
+
tokens.push(`#${style.id}`);
|
|
285
|
+
}
|
|
286
|
+
for (const className of style.classes ?? []) {
|
|
287
|
+
if (className.trim().length > 0) {
|
|
288
|
+
tokens.push(`.${className.trim()}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (Object.hasOwn(attrs, "width")) {
|
|
292
|
+
tokens.push(`width=${formatAttrValue(attrs.width)}`);
|
|
293
|
+
delete attrs.width;
|
|
294
|
+
}
|
|
295
|
+
if (Object.hasOwn(attrs, "height")) {
|
|
296
|
+
tokens.push(`height=${formatAttrValue(attrs.height)}`);
|
|
297
|
+
delete attrs.height;
|
|
298
|
+
}
|
|
299
|
+
const remainingKeys = Object.keys(attrs).sort((a, b) => a.localeCompare(b));
|
|
300
|
+
for (const key of remainingKeys) {
|
|
301
|
+
tokens.push(`${key}=${formatAttrValue(attrs[key])}`);
|
|
302
|
+
}
|
|
303
|
+
if (tokens.length === 0) {
|
|
304
|
+
return "";
|
|
305
|
+
}
|
|
306
|
+
return `{${tokens.join(" ")}}`;
|
|
307
|
+
}
|
|
308
|
+
function formatAttrValue(value) {
|
|
309
|
+
const normalized = value.trim();
|
|
310
|
+
if (/^[A-Za-z0-9._%:/+-]+$/.test(normalized)) {
|
|
311
|
+
return normalized;
|
|
312
|
+
}
|
|
313
|
+
return `"${normalized.replaceAll('"', '\\"')}"`;
|
|
314
|
+
}
|
|
315
|
+
function applyImageStyleField(style, key, value, metadataPath, warnings) {
|
|
316
|
+
if (key === "width" || key === "height" || key === "id") {
|
|
317
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
|
|
318
|
+
warnings.push(`Metadata '${metadataPath}' must be a scalar value.`);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const normalized = normalizeImageScalar(value);
|
|
322
|
+
if (!normalized) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (key === "width") {
|
|
326
|
+
style.width = normalized;
|
|
327
|
+
}
|
|
328
|
+
else if (key === "height") {
|
|
329
|
+
style.height = normalized;
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
style.id = normalized;
|
|
333
|
+
}
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (key === "classes") {
|
|
337
|
+
const classes = normalizeImageClasses(value);
|
|
338
|
+
if (!classes) {
|
|
339
|
+
warnings.push(`Metadata '${metadataPath}' must be a string or array of strings.`);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (classes && classes.length > 0) {
|
|
343
|
+
style.classes = classes;
|
|
344
|
+
}
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (key === "attrs") {
|
|
348
|
+
const attrsRecord = asPlainRecord(value);
|
|
349
|
+
if (!attrsRecord) {
|
|
350
|
+
warnings.push(`Metadata '${metadataPath}' must be an object of scalar values.`);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
for (const [attrKey, attrValue] of Object.entries(attrsRecord)) {
|
|
354
|
+
if (typeof attrValue !== "string" && typeof attrValue !== "number" && typeof attrValue !== "boolean") {
|
|
355
|
+
warnings.push(`Metadata '${metadataPath}.${attrKey}' must be a scalar value.`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const attrs = normalizeImageAttrs(attrsRecord);
|
|
359
|
+
if (attrs && Object.keys(attrs).length > 0) {
|
|
360
|
+
style.attrs = attrs;
|
|
361
|
+
}
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (key === "layout") {
|
|
365
|
+
if (value !== "block" && value !== "inline") {
|
|
366
|
+
warnings.push(`Metadata '${metadataPath}' must be either 'block' or 'inline'.`);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
style.layout = value;
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (key === "align") {
|
|
373
|
+
if (value !== "left" && value !== "center" && value !== "right") {
|
|
374
|
+
warnings.push(`Metadata '${metadataPath}' must be one of: left, center, right.`);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
style.align = value;
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
warnings.push(`Unsupported image style key '${key}' in '${metadataPath}'. Allowed keys: width, height, classes, id, attrs, layout, align.`);
|
|
381
|
+
}
|
|
382
|
+
function stripWrappingQuotes(value) {
|
|
383
|
+
if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
|
|
384
|
+
return value.slice(1, -1);
|
|
385
|
+
}
|
|
386
|
+
return value;
|
|
387
|
+
}
|
|
388
|
+
function isPathInside(candidatePath, parentPath) {
|
|
389
|
+
const relative = path.relative(path.resolve(parentPath), path.resolve(candidatePath));
|
|
390
|
+
if (!relative) {
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
return !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
394
|
+
}
|
|
@@ -18,12 +18,33 @@ export function runExport(input) {
|
|
|
18
18
|
if (!capability.ok) {
|
|
19
19
|
throw new Error(capability.reason || `Exporter '${exporter.id}' cannot run.`);
|
|
20
20
|
}
|
|
21
|
+
const resourcePaths = uniqueResolvedPaths([
|
|
22
|
+
input.project.root,
|
|
23
|
+
input.project.manuscriptDir,
|
|
24
|
+
path.join(input.project.root, "assets"),
|
|
25
|
+
path.dirname(input.inputPath)
|
|
26
|
+
]);
|
|
21
27
|
exporter.run({
|
|
22
28
|
inputPath: input.inputPath,
|
|
23
|
-
outputPath
|
|
29
|
+
outputPath,
|
|
30
|
+
cwd: input.project.root,
|
|
31
|
+
resourcePaths
|
|
24
32
|
});
|
|
25
33
|
return {
|
|
26
34
|
outputPath,
|
|
27
35
|
format
|
|
28
36
|
};
|
|
29
37
|
}
|
|
38
|
+
function uniqueResolvedPaths(paths) {
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
const unique = [];
|
|
41
|
+
for (const value of paths) {
|
|
42
|
+
const normalized = path.resolve(value);
|
|
43
|
+
if (seen.has(normalized)) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
seen.add(normalized);
|
|
47
|
+
unique.push(normalized);
|
|
48
|
+
}
|
|
49
|
+
return unique;
|
|
50
|
+
}
|
|
@@ -8,9 +8,8 @@ export function registerExportCommand(registry) {
|
|
|
8
8
|
registry.register({
|
|
9
9
|
name: "export",
|
|
10
10
|
description: "Export manuscript formats",
|
|
11
|
-
allowUnknownOptions: true,
|
|
12
11
|
options: [
|
|
13
|
-
{ flags: "--project <project-id>", description: "Project id" },
|
|
12
|
+
{ flags: "-p, --project <project-id>", description: "Project id" },
|
|
14
13
|
{ flags: "--format <format>", description: "md|docx|pdf|epub" },
|
|
15
14
|
{ flags: "--output <path>", description: "Explicit output path" },
|
|
16
15
|
{ flags: "--root <path>", description: "Workspace root path" }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
4
5
|
function hasPandoc() {
|
|
5
6
|
const result = spawnSync("pandoc", ["--version"], { stdio: "ignore" });
|
|
6
7
|
return result.status === 0;
|
|
@@ -21,6 +22,20 @@ function resolvePdfEngine() {
|
|
|
21
22
|
function getMissingPdfEngineReason() {
|
|
22
23
|
return "No PDF engine found. Install one of: tectonic, xelatex, lualatex, pdflatex, wkhtmltopdf, weasyprint, prince, or typst.";
|
|
23
24
|
}
|
|
25
|
+
function resolveBundledFile(relativePathFromPackageRoot) {
|
|
26
|
+
let current = path.dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
while (true) {
|
|
28
|
+
const candidate = path.join(current, relativePathFromPackageRoot);
|
|
29
|
+
if (fs.existsSync(candidate)) {
|
|
30
|
+
return candidate;
|
|
31
|
+
}
|
|
32
|
+
const parent = path.dirname(current);
|
|
33
|
+
if (parent === current) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
current = parent;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
24
39
|
export function createPandocExporter(format) {
|
|
25
40
|
return {
|
|
26
41
|
id: format,
|
|
@@ -40,9 +55,20 @@ export function createPandocExporter(format) {
|
|
|
40
55
|
}
|
|
41
56
|
return { ok: true };
|
|
42
57
|
},
|
|
43
|
-
run({ inputPath, outputPath }) {
|
|
58
|
+
run({ inputPath, outputPath, cwd, resourcePaths }) {
|
|
44
59
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
45
60
|
const args = [inputPath, "-o", outputPath];
|
|
61
|
+
const imageLayoutFilter = resolveBundledFile(path.join("filters", "image-layout.lua"));
|
|
62
|
+
if (imageLayoutFilter) {
|
|
63
|
+
args.push("--lua-filter", imageLayoutFilter);
|
|
64
|
+
}
|
|
65
|
+
const imageLayoutStylesheet = resolveBundledFile(path.join("filters", "image-layout.css"));
|
|
66
|
+
if (format === "epub" && imageLayoutStylesheet) {
|
|
67
|
+
args.push("--css", imageLayoutStylesheet);
|
|
68
|
+
}
|
|
69
|
+
if (resourcePaths && resourcePaths.length > 0) {
|
|
70
|
+
args.push(`--resource-path=${resourcePaths.join(path.delimiter)}`);
|
|
71
|
+
}
|
|
46
72
|
if (format === "pdf") {
|
|
47
73
|
const engine = resolvePdfEngine();
|
|
48
74
|
if (!engine) {
|
|
@@ -51,7 +77,8 @@ export function createPandocExporter(format) {
|
|
|
51
77
|
args.push(`--pdf-engine=${engine}`);
|
|
52
78
|
}
|
|
53
79
|
const result = spawnSync("pandoc", args, {
|
|
54
|
-
encoding: "utf8"
|
|
80
|
+
encoding: "utf8",
|
|
81
|
+
cwd: cwd || undefined
|
|
55
82
|
});
|
|
56
83
|
if (result.status !== 0) {
|
|
57
84
|
const stderr = (result.stderr || "").trim();
|
|
@@ -7,7 +7,7 @@ export function registerNewManuscriptCommand(registry) {
|
|
|
7
7
|
name: "new",
|
|
8
8
|
description: "Create a new manuscript file",
|
|
9
9
|
options: [
|
|
10
|
-
{ flags: "--project <project-id>", description: "Project id" },
|
|
10
|
+
{ flags: "-p, --project <project-id>", description: "Project id" },
|
|
11
11
|
{ flags: "-i, --i <prefix>", description: "Numeric filename prefix override" },
|
|
12
12
|
{ flags: "--filename <name>", description: "Explicit manuscript filename" },
|
|
13
13
|
{ flags: "--format <format>", description: "text|json" },
|
|
@@ -22,7 +22,7 @@ const PROSE_MARKDOWN_EDITOR_SETTINGS = {
|
|
|
22
22
|
export function createProject(input) {
|
|
23
23
|
const projectId = (input.projectId || "").trim();
|
|
24
24
|
if (!projectId) {
|
|
25
|
-
throw new CliError("INVALID_USAGE", "Project id is required. Use --project <project-id>.");
|
|
25
|
+
throw new CliError("INVALID_USAGE", "Project id is required. Use --project/-p <project-id>.");
|
|
26
26
|
}
|
|
27
27
|
if (!isValidProjectId(projectId)) {
|
|
28
28
|
throw new CliError("INVALID_USAGE", "Project id must match /^[a-z0-9][a-z0-9-]*$/.");
|
|
@@ -34,10 +34,12 @@ export function createProject(input) {
|
|
|
34
34
|
const manuscriptDir = path.join(projectRoot, input.workspace.config.chapterDir);
|
|
35
35
|
const spineDir = path.join(projectRoot, input.workspace.config.spineDir);
|
|
36
36
|
const notesDir = path.join(projectRoot, input.workspace.config.notesDir);
|
|
37
|
+
const assetsDir = path.join(projectRoot, "assets");
|
|
37
38
|
const distDir = path.join(projectRoot, input.workspace.config.distDir);
|
|
38
39
|
ensureDirectory(manuscriptDir);
|
|
39
40
|
ensureDirectory(spineDir);
|
|
40
41
|
ensureDirectory(notesDir);
|
|
42
|
+
ensureDirectory(assetsDir);
|
|
41
43
|
ensureDirectory(distDir);
|
|
42
44
|
const projectJsonPath = path.join(projectRoot, "stego-project.json");
|
|
43
45
|
writeTextFile(projectJsonPath, `${JSON.stringify({
|
|
@@ -95,6 +97,43 @@ label: Characters
|
|
|
95
97
|
`);
|
|
96
98
|
const charactersEntryPath = path.join(charactersDir, "example-character.md");
|
|
97
99
|
writeTextFile(charactersEntryPath, "# Example Character\n\n");
|
|
100
|
+
const assetsReadmePath = path.join(assetsDir, "README.md");
|
|
101
|
+
writeTextFile(assetsReadmePath, `# Assets
|
|
102
|
+
|
|
103
|
+
Store manuscript images in this directory (or subdirectories).
|
|
104
|
+
|
|
105
|
+
Use standard Markdown image syntax in manuscript files, typically with paths like:
|
|
106
|
+
|
|
107
|
+
\`\`\`md
|
|
108
|
+

|
|
109
|
+
\`\`\`
|
|
110
|
+
|
|
111
|
+
Optional manuscript frontmatter image settings:
|
|
112
|
+
|
|
113
|
+
\`\`\`json
|
|
114
|
+
// stego-project.json
|
|
115
|
+
{
|
|
116
|
+
"images": {
|
|
117
|
+
"layout": "block",
|
|
118
|
+
"align": "center",
|
|
119
|
+
"width": "50%"
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
\`\`\`
|
|
123
|
+
|
|
124
|
+
\`\`\`yaml
|
|
125
|
+
# manuscript frontmatter overrides
|
|
126
|
+
images:
|
|
127
|
+
assets/maps/city-plan.png:
|
|
128
|
+
layout: inline
|
|
129
|
+
align: left
|
|
130
|
+
width: 100%
|
|
131
|
+
\`\`\`
|
|
132
|
+
|
|
133
|
+
Manuscript frontmatter \`images\` is for per-path overrides.
|
|
134
|
+
Global defaults belong in \`stego-project.json\` under \`images\`.
|
|
135
|
+
|
|
136
|
+
`);
|
|
98
137
|
const projectExtensionsPath = ensureProjectExtensionsRecommendations(projectRoot);
|
|
99
138
|
let projectSettingsPath;
|
|
100
139
|
if (input.enableProseFont) {
|
|
@@ -109,6 +148,7 @@ label: Characters
|
|
|
109
148
|
path.relative(input.workspace.repoRoot, starterManuscriptPath),
|
|
110
149
|
path.relative(input.workspace.repoRoot, charactersCategoryPath),
|
|
111
150
|
path.relative(input.workspace.repoRoot, charactersEntryPath),
|
|
151
|
+
path.relative(input.workspace.repoRoot, assetsReadmePath),
|
|
112
152
|
path.relative(input.workspace.repoRoot, projectExtensionsPath),
|
|
113
153
|
...(projectSettingsPath ? [path.relative(input.workspace.repoRoot, projectSettingsPath)] : [])
|
|
114
154
|
]
|
|
@@ -6,7 +6,7 @@ export function resolveProjectContext(input) {
|
|
|
6
6
|
const projectIds = listProjectIds(input.workspace);
|
|
7
7
|
const projectId = resolveProjectIdCandidate(input, projectIds);
|
|
8
8
|
if (!projectId) {
|
|
9
|
-
throw new CliError("PROJECT_NOT_FOUND", "Project id is required. Use --project <project-id>.");
|
|
9
|
+
throw new CliError("PROJECT_NOT_FOUND", "Project id is required. Use --project/-p <project-id>.");
|
|
10
10
|
}
|
|
11
11
|
const projectRoot = path.join(input.workspace.repoRoot, input.workspace.config.projectsDir, projectId);
|
|
12
12
|
const projectJsonPath = path.join(projectRoot, "stego-project.json");
|
|
@@ -8,7 +8,7 @@ export function registerNewProjectCommand(registry) {
|
|
|
8
8
|
name: "new-project",
|
|
9
9
|
description: "Create a new Stego project",
|
|
10
10
|
options: [
|
|
11
|
-
{ flags: "--project <project-id>", description: "Project id" },
|
|
11
|
+
{ flags: "-p, --project <project-id>", description: "Project id" },
|
|
12
12
|
{ flags: "--title <title>", description: "Project title" },
|
|
13
13
|
{ flags: "--prose-font <mode>", description: "yes|no|prompt" },
|
|
14
14
|
{ flags: "--format <format>", description: "text|json" },
|