@bendyline/docblocks-cli 2.0.0 → 2.1.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.
Files changed (116) hide show
  1. package/README.md +11 -17
  2. package/dist/bin.d.ts +1 -0
  3. package/dist/bin.js +7463 -0
  4. package/dist/index.d.ts +133 -2
  5. package/dist/index.js +1328 -27
  6. package/package.json +23 -10
  7. package/dist/commands/build.d.ts +0 -14
  8. package/dist/commands/build.d.ts.map +0 -1
  9. package/dist/commands/build.js +0 -74
  10. package/dist/commands/build.js.map +0 -1
  11. package/dist/commands/convert.d.ts +0 -42
  12. package/dist/commands/convert.d.ts.map +0 -1
  13. package/dist/commands/convert.js +0 -339
  14. package/dist/commands/convert.js.map +0 -1
  15. package/dist/commands/init.d.ts +0 -3
  16. package/dist/commands/init.d.ts.map +0 -1
  17. package/dist/commands/init.js +0 -23
  18. package/dist/commands/init.js.map +0 -1
  19. package/dist/commands/mcp.d.ts +0 -12
  20. package/dist/commands/mcp.d.ts.map +0 -1
  21. package/dist/commands/mcp.js +0 -106
  22. package/dist/commands/mcp.js.map +0 -1
  23. package/dist/commands/parse.d.ts +0 -6
  24. package/dist/commands/parse.d.ts.map +0 -1
  25. package/dist/commands/parse.js +0 -38
  26. package/dist/commands/parse.js.map +0 -1
  27. package/dist/commands/serve.d.ts +0 -36
  28. package/dist/commands/serve.d.ts.map +0 -1
  29. package/dist/commands/serve.js +0 -347
  30. package/dist/commands/serve.js.map +0 -1
  31. package/dist/commands/themes.d.ts +0 -6
  32. package/dist/commands/themes.d.ts.map +0 -1
  33. package/dist/commands/themes.js +0 -15
  34. package/dist/commands/themes.js.map +0 -1
  35. package/dist/commands/transforms.d.ts +0 -6
  36. package/dist/commands/transforms.d.ts.map +0 -1
  37. package/dist/commands/transforms.js +0 -15
  38. package/dist/commands/transforms.js.map +0 -1
  39. package/dist/commands/video.d.ts +0 -38
  40. package/dist/commands/video.d.ts.map +0 -1
  41. package/dist/commands/video.js +0 -136
  42. package/dist/commands/video.js.map +0 -1
  43. package/dist/contained-file.d.ts +0 -8
  44. package/dist/contained-file.d.ts.map +0 -1
  45. package/dist/contained-file.js +0 -77
  46. package/dist/contained-file.js.map +0 -1
  47. package/dist/index.d.ts.map +0 -1
  48. package/dist/index.js.map +0 -1
  49. package/dist/mcp/agentic-tools.d.ts +0 -16
  50. package/dist/mcp/agentic-tools.d.ts.map +0 -1
  51. package/dist/mcp/agentic-tools.js +0 -967
  52. package/dist/mcp/agentic-tools.js.map +0 -1
  53. package/dist/mcp/artifact-store.d.ts +0 -98
  54. package/dist/mcp/artifact-store.d.ts.map +0 -1
  55. package/dist/mcp/artifact-store.js +0 -542
  56. package/dist/mcp/artifact-store.js.map +0 -1
  57. package/dist/mcp/authority.d.ts +0 -53
  58. package/dist/mcp/authority.d.ts.map +0 -1
  59. package/dist/mcp/authority.js +0 -433
  60. package/dist/mcp/authority.js.map +0 -1
  61. package/dist/mcp/conversion-service.d.ts +0 -111
  62. package/dist/mcp/conversion-service.d.ts.map +0 -1
  63. package/dist/mcp/conversion-service.js +0 -512
  64. package/dist/mcp/conversion-service.js.map +0 -1
  65. package/dist/mcp/discovery-tools.d.ts +0 -4
  66. package/dist/mcp/discovery-tools.d.ts.map +0 -1
  67. package/dist/mcp/discovery-tools.js +0 -114
  68. package/dist/mcp/discovery-tools.js.map +0 -1
  69. package/dist/mcp/document-service.d.ts +0 -47
  70. package/dist/mcp/document-service.d.ts.map +0 -1
  71. package/dist/mcp/document-service.js +0 -424
  72. package/dist/mcp/document-service.js.map +0 -1
  73. package/dist/mcp/error-result.d.ts +0 -17
  74. package/dist/mcp/error-result.d.ts.map +0 -1
  75. package/dist/mcp/error-result.js +0 -82
  76. package/dist/mcp/error-result.js.map +0 -1
  77. package/dist/mcp/intelligence.d.ts +0 -15
  78. package/dist/mcp/intelligence.d.ts.map +0 -1
  79. package/dist/mcp/intelligence.js +0 -855
  80. package/dist/mcp/intelligence.js.map +0 -1
  81. package/dist/mcp/output-bounds.d.ts +0 -13
  82. package/dist/mcp/output-bounds.d.ts.map +0 -1
  83. package/dist/mcp/output-bounds.js +0 -61
  84. package/dist/mcp/output-bounds.js.map +0 -1
  85. package/dist/mcp/preview-service.d.ts +0 -54
  86. package/dist/mcp/preview-service.d.ts.map +0 -1
  87. package/dist/mcp/preview-service.js +0 -454
  88. package/dist/mcp/preview-service.js.map +0 -1
  89. package/dist/mcp/progress.d.ts +0 -18
  90. package/dist/mcp/progress.d.ts.map +0 -1
  91. package/dist/mcp/progress.js +0 -28
  92. package/dist/mcp/progress.js.map +0 -1
  93. package/dist/mcp/prompts.d.ts +0 -4
  94. package/dist/mcp/prompts.d.ts.map +0 -1
  95. package/dist/mcp/prompts.js +0 -124
  96. package/dist/mcp/prompts.js.map +0 -1
  97. package/dist/mcp/rendered-conversion.d.ts +0 -34
  98. package/dist/mcp/rendered-conversion.d.ts.map +0 -1
  99. package/dist/mcp/rendered-conversion.js +0 -364
  100. package/dist/mcp/rendered-conversion.js.map +0 -1
  101. package/dist/mcp/server.d.ts +0 -69
  102. package/dist/mcp/server.d.ts.map +0 -1
  103. package/dist/mcp/server.js +0 -249
  104. package/dist/mcp/server.js.map +0 -1
  105. package/dist/preview-policy.d.ts +0 -8
  106. package/dist/preview-policy.d.ts.map +0 -1
  107. package/dist/preview-policy.js +0 -85
  108. package/dist/preview-policy.js.map +0 -1
  109. package/dist/render-html.d.ts +0 -17
  110. package/dist/render-html.d.ts.map +0 -1
  111. package/dist/render-html.js +0 -85
  112. package/dist/render-html.js.map +0 -1
  113. package/dist/version.d.ts +0 -6
  114. package/dist/version.d.ts.map +0 -1
  115. package/dist/version.js +0 -104
  116. package/dist/version.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,27 +1,1328 @@
1
- import { Command } from 'commander';
2
- import { initCommand } from './commands/init.js';
3
- import { buildCommand } from './commands/build.js';
4
- import { serveCommand } from './commands/serve.js';
5
- import { convertCommand } from './commands/convert.js';
6
- import { videoCommand } from './commands/video.js';
7
- import { mcpCommand } from './commands/mcp.js';
8
- import { themesCommand } from './commands/themes.js';
9
- import { transformsCommand } from './commands/transforms.js';
10
- import { parseCommand } from './commands/parse.js';
11
- import { getPackageVersion } from './version.js';
12
- const program = new Command();
13
- program
14
- .name('docblocks')
15
- .description('Build, preview, convert, render, inspect, and automate documents')
16
- .version(getPackageVersion());
17
- program.addCommand(initCommand);
18
- program.addCommand(buildCommand);
19
- program.addCommand(serveCommand);
20
- program.addCommand(convertCommand);
21
- program.addCommand(videoCommand);
22
- program.addCommand(mcpCommand);
23
- program.addCommand(themesCommand);
24
- program.addCommand(transformsCommand);
25
- program.addCommand(parseCommand);
26
- program.parse();
27
- //# sourceMappingURL=index.js.map
1
+ // src/commands/build.ts
2
+ import { Command } from "commander";
3
+ import { mkdir, opendir, readFile, stat as stat2, writeFile } from "fs/promises";
4
+ import path4 from "path";
5
+
6
+ // src/render-html.ts
7
+ import { realpath, stat } from "fs/promises";
8
+ import path3 from "path";
9
+ import { markdownToDoc } from "@bendyline/squisq/doc";
10
+ import { parseMarkdown } from "@bendyline/squisq/markdown";
11
+ import { collectImagePaths, docToHtml } from "@bendyline/squisq-formats/html";
12
+ import { PLAYER_BUNDLE } from "@bendyline/squisq-react/standalone-source";
13
+
14
+ // src/contained-file.ts
15
+ import fs from "fs/promises";
16
+ import path2 from "path";
17
+
18
+ // src/internal/file-stat.ts
19
+ import { open } from "fs/promises";
20
+ async function statFileThroughDescriptor(filePath) {
21
+ const handle = await open(filePath, "r");
22
+ try {
23
+ return await handle.stat();
24
+ } finally {
25
+ await handle.close();
26
+ }
27
+ }
28
+
29
+ // src/internal/paths.ts
30
+ import path from "path";
31
+ function isPathInside(root, candidate) {
32
+ const relative = path.relative(path.resolve(root), path.resolve(candidate));
33
+ return relative === "" || !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative);
34
+ }
35
+
36
+ // src/contained-file.ts
37
+ var CONTAINED_READ_CHUNK_BYTES = 64 * 1024;
38
+ async function readContainedFile(rootPath, candidatePath, maximumBytes, options = {}) {
39
+ const { signal, afterReadChunk } = options;
40
+ signal?.throwIfAborted();
41
+ const physicalRoot = await fs.realpath(path2.resolve(rootPath));
42
+ signal?.throwIfAborted();
43
+ const physicalCandidate = await fs.realpath(path2.resolve(candidatePath));
44
+ signal?.throwIfAborted();
45
+ if (!isPathInside(physicalRoot, physicalCandidate)) {
46
+ throw new Error("File is outside the authorized root");
47
+ }
48
+ const handle = await fs.open(physicalCandidate, "r");
49
+ try {
50
+ signal?.throwIfAborted();
51
+ const before = await handle.stat();
52
+ signal?.throwIfAborted();
53
+ if (!before.isFile() || before.size > maximumBytes) {
54
+ throw new Error("File exceeds the configured size limit");
55
+ }
56
+ await assertIdentity(physicalRoot, physicalCandidate, before, signal);
57
+ signal?.throwIfAborted();
58
+ const result = Buffer.alloc(before.size);
59
+ let offset = 0;
60
+ while (offset < result.length) {
61
+ signal?.throwIfAborted();
62
+ const length = Math.min(CONTAINED_READ_CHUNK_BYTES, result.length - offset);
63
+ const { bytesRead } = await handle.read(result, offset, length, offset);
64
+ if (bytesRead === 0) break;
65
+ offset += bytesRead;
66
+ await afterReadChunk?.(offset, result.length);
67
+ signal?.throwIfAborted();
68
+ }
69
+ signal?.throwIfAborted();
70
+ const extra = Buffer.alloc(1);
71
+ if ((await handle.read(extra, 0, 1, offset)).bytesRead > 0) {
72
+ throw new Error("File grew beyond the configured size limit");
73
+ }
74
+ signal?.throwIfAborted();
75
+ const after = await handle.stat();
76
+ signal?.throwIfAborted();
77
+ if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
78
+ throw new Error("File changed while it was being read");
79
+ }
80
+ await assertIdentity(physicalRoot, physicalCandidate, after, signal);
81
+ signal?.throwIfAborted();
82
+ return result.subarray(0, offset);
83
+ } finally {
84
+ await handle.close();
85
+ }
86
+ }
87
+ async function assertIdentity(physicalRoot, candidatePath, descriptorStat, signal) {
88
+ signal?.throwIfAborted();
89
+ const physicalCandidate = await fs.realpath(candidatePath);
90
+ signal?.throwIfAborted();
91
+ if (!isPathInside(physicalRoot, physicalCandidate)) {
92
+ throw new Error("File escaped the authorized root while it was being read");
93
+ }
94
+ const pathStat = await statFileThroughDescriptor(physicalCandidate);
95
+ signal?.throwIfAborted();
96
+ if (descriptorStat.dev !== pathStat.dev || descriptorStat.ino !== pathStat.ino) {
97
+ throw new Error("File changed physical identity while it was being read");
98
+ }
99
+ }
100
+
101
+ // src/internal/node-error.ts
102
+ function isNodeErrorCode(error, codes) {
103
+ if (!(error instanceof Error) || !("code" in error)) return false;
104
+ const code = String(error.code);
105
+ return typeof codes === "string" ? code === codes : codes.includes(code);
106
+ }
107
+
108
+ // src/internal/theme.ts
109
+ async function getAvailableThemeIds() {
110
+ const { getAvailableThemes } = await import("@bendyline/squisq/schemas");
111
+ return getAvailableThemes();
112
+ }
113
+ async function assertKnownThemeId(themeId, customThemeIds = []) {
114
+ if (!themeId) return;
115
+ const builtInThemeIds = await getAvailableThemeIds();
116
+ if (builtInThemeIds.includes(themeId) || customThemeIds.includes(themeId)) return;
117
+ throw new Error(`Unknown theme "${themeId}". Available: ${builtInThemeIds.join(", ")}`);
118
+ }
119
+
120
+ // src/render-html.ts
121
+ var MAX_REFERENCED_IMAGES = 100;
122
+ var DEFAULT_MAX_ASSET_BYTES = 50 * 1024 * 1024;
123
+ var MAX_SINGLE_ASSET_BYTES = 20 * 1024 * 1024;
124
+ async function renderMarkdownHtml(markdown, options) {
125
+ await assertKnownThemeId(options.themeId);
126
+ const markdownDoc = parseMarkdown(markdown);
127
+ const doc = markdownToDoc(markdownDoc);
128
+ const images = options.sourcePath && options.assetRoot ? await readReferencedImages(
129
+ doc,
130
+ options.sourcePath,
131
+ options.assetRoot,
132
+ options.maxAssetBytes ?? DEFAULT_MAX_ASSET_BYTES,
133
+ options.allowReferencedAsset,
134
+ options.onWarning ?? ((message) => console.warn(message))
135
+ ) : void 0;
136
+ return docToHtml(doc, {
137
+ playerScript: PLAYER_BUNDLE,
138
+ images,
139
+ title: options.title,
140
+ mode: options.mode ?? "static",
141
+ themeId: options.themeId
142
+ });
143
+ }
144
+ async function readReferencedImages(doc, sourcePath, assetRoot, maxAssetBytes, allowReferencedAsset, warn) {
145
+ const images = /* @__PURE__ */ new Map();
146
+ const root = await realpathIfPresent(path3.resolve(assetRoot));
147
+ const baseDir = await realpathIfPresent(path3.dirname(path3.resolve(sourcePath)));
148
+ if (!root || !baseDir || !isPathInside(root, baseDir)) return images;
149
+ let totalBytes = 0;
150
+ const referenced = [...collectImagePaths(doc)];
151
+ const embeddable = referenced.slice(0, MAX_REFERENCED_IMAGES);
152
+ const dropped = referenced.slice(MAX_REFERENCED_IMAGES);
153
+ if (dropped.length > 0) {
154
+ warn(
155
+ `Skipped ${dropped.length} of ${referenced.length} referenced images: the render embeds at most ${MAX_REFERENCED_IMAGES}. Not embedded: ${describeList(dropped)}`
156
+ );
157
+ }
158
+ for (const imagePath of embeddable) {
159
+ if (!isLocalRelativePath(imagePath)) continue;
160
+ const normalizedPath = stripUrlSuffix(imagePath);
161
+ const absolutePath = path3.resolve(baseDir, normalizedPath);
162
+ if (!isPathInside(root, absolutePath)) continue;
163
+ try {
164
+ const physicalPath = await realpath(absolutePath);
165
+ if (!isPathInside(root, physicalPath)) continue;
166
+ if (allowReferencedAsset && !allowReferencedAsset({
167
+ assetRoot: root,
168
+ requestedPath: absolutePath,
169
+ physicalPath
170
+ })) {
171
+ continue;
172
+ }
173
+ const info = await stat(physicalPath);
174
+ if (!info.isFile()) {
175
+ warn(`Skipped referenced image "${imagePath}": it is not a regular file.`);
176
+ continue;
177
+ }
178
+ if (info.size > MAX_SINGLE_ASSET_BYTES) {
179
+ warn(
180
+ `Skipped referenced image "${imagePath}": ${info.size} bytes exceeds the ${MAX_SINGLE_ASSET_BYTES}-byte per-image embed limit.`
181
+ );
182
+ continue;
183
+ }
184
+ if (totalBytes + info.size > maxAssetBytes) {
185
+ warn(
186
+ `Skipped referenced image "${imagePath}": embedding ${info.size} more bytes would exceed the ${maxAssetBytes}-byte total image budget (${totalBytes} bytes already embedded).`
187
+ );
188
+ continue;
189
+ }
190
+ const data = await readContainedFile(root, physicalPath, MAX_SINGLE_ASSET_BYTES);
191
+ totalBytes += data.byteLength;
192
+ images.set(imagePath, toExactArrayBuffer(data));
193
+ } catch (error) {
194
+ if (isMissingPathError(error)) continue;
195
+ const contextualError = new Error(`Failed to embed referenced image "${imagePath}".`);
196
+ contextualError.cause = error;
197
+ throw contextualError;
198
+ }
199
+ }
200
+ return images;
201
+ }
202
+ async function realpathIfPresent(candidate) {
203
+ try {
204
+ return await realpath(candidate);
205
+ } catch (error) {
206
+ if (isMissingPathError(error)) return null;
207
+ throw error;
208
+ }
209
+ }
210
+ function isMissingPathError(error) {
211
+ return isNodeErrorCode(error, ["ENOENT", "ENOTDIR"]);
212
+ }
213
+ function describeList(values, shown = 5) {
214
+ const head = values.slice(0, shown).map((value) => `"${value}"`);
215
+ const remaining = values.length - head.length;
216
+ return remaining > 0 ? `${head.join(", ")} (and ${remaining} more)` : head.join(", ");
217
+ }
218
+ function isLocalRelativePath(candidate) {
219
+ return !path3.isAbsolute(candidate) && !candidate.startsWith("/") && !/^(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(candidate) && !candidate.startsWith("data:");
220
+ }
221
+ function stripUrlSuffix(candidate) {
222
+ return candidate.split(/[?#]/, 1)[0] ?? candidate;
223
+ }
224
+ function toExactArrayBuffer(data) {
225
+ const copy = new Uint8Array(data.byteLength);
226
+ copy.set(data);
227
+ return copy.buffer;
228
+ }
229
+
230
+ // src/internal/limits.ts
231
+ function positiveLimit(value, fallback, label) {
232
+ const selected = value ?? fallback;
233
+ if (!Number.isSafeInteger(selected) || selected < 1) {
234
+ throw new Error(`Invalid ${label} limit.`);
235
+ }
236
+ return selected;
237
+ }
238
+
239
+ // src/commands/build.ts
240
+ import { decodeUtf8Text } from "@bendyline/docblocks/filesystem";
241
+ var DEFAULT_MAX_BUILD_ENTRIES = 1e5;
242
+ var DEFAULT_MAX_BUILD_DEPTH = 64;
243
+ var DEFAULT_MAX_BUILD_INPUT_BYTES = 20 * 1024 * 1024;
244
+ var DEFAULT_MAX_BUILD_TOTAL_INPUT_BYTES = 512 * 1024 * 1024;
245
+ var DEFAULT_MAX_BUILD_OUTPUT_BYTES = 128 * 1024 * 1024;
246
+ var DEFAULT_MAX_BUILD_TOTAL_OUTPUT_BYTES = 1024 * 1024 * 1024;
247
+ async function runBuild(opts) {
248
+ await assertKnownThemeId(opts.theme);
249
+ const inputDir = path4.resolve(opts.input);
250
+ const outputDir = path4.resolve(opts.output);
251
+ let inputStat;
252
+ try {
253
+ inputStat = await stat2(inputDir);
254
+ } catch (error) {
255
+ if (!isNodeErrorCode(error, "ENOENT")) throw error;
256
+ throw new Error(`Input directory not found: ${inputDir}`);
257
+ }
258
+ if (!inputStat.isDirectory()) throw new Error(`Input is not a directory: ${inputDir}`);
259
+ const markdownFiles = await listMarkdownFiles(inputDir, {
260
+ maxEntries: positiveLimit(opts.maxEntries, DEFAULT_MAX_BUILD_ENTRIES, "build entry"),
261
+ maxDepth: positiveLimit(opts.maxDepth, DEFAULT_MAX_BUILD_DEPTH, "build depth")
262
+ });
263
+ if (markdownFiles.length === 0) {
264
+ throw new Error(`No markdown files found in ${inputDir}.`);
265
+ }
266
+ const byteLimits = resolveBuildByteLimits(opts);
267
+ await assertBuildInputSizes(markdownFiles, byteLimits);
268
+ await mkdir(outputDir, { recursive: true });
269
+ const builtFiles = [];
270
+ let totalInputBytes = 0;
271
+ let totalOutputBytes = 0;
272
+ for (const sourcePath of markdownFiles) {
273
+ const relativePath = path4.relative(inputDir, sourcePath);
274
+ const outputPath = path4.join(outputDir, replaceMarkdownExtension(relativePath));
275
+ const sourceBytes = await readFile(sourcePath);
276
+ if (sourceBytes.byteLength > byteLimits.maxInputBytes) {
277
+ throw new Error(
278
+ `Build input ${sourcePath} exceeds the ${byteLimits.maxInputBytes}-byte per-file limit.`
279
+ );
280
+ }
281
+ totalInputBytes += sourceBytes.byteLength;
282
+ if (totalInputBytes > byteLimits.maxTotalInputBytes) {
283
+ throw new Error(
284
+ `Build inputs exceed the ${byteLimits.maxTotalInputBytes}-byte aggregate limit.`
285
+ );
286
+ }
287
+ const source = decodeUtf8Text(sourceBytes, { label: "Build input", path: sourcePath });
288
+ const html = await renderMarkdownHtml(source, {
289
+ title: titleFromPath(relativePath),
290
+ sourcePath,
291
+ assetRoot: inputDir,
292
+ themeId: opts.theme,
293
+ mode: "static"
294
+ });
295
+ const outputBytes = Buffer.byteLength(html, "utf8");
296
+ if (outputBytes > byteLimits.maxOutputBytes) {
297
+ throw new Error(
298
+ `Build output ${outputPath} exceeds the ${byteLimits.maxOutputBytes}-byte per-file limit.`
299
+ );
300
+ }
301
+ totalOutputBytes += outputBytes;
302
+ if (totalOutputBytes > byteLimits.maxTotalOutputBytes) {
303
+ throw new Error(
304
+ `Build outputs exceed the ${byteLimits.maxTotalOutputBytes}-byte aggregate limit.`
305
+ );
306
+ }
307
+ await mkdir(path4.dirname(outputPath), { recursive: true });
308
+ await writeFile(outputPath, html, "utf-8");
309
+ builtFiles.push(outputPath);
310
+ }
311
+ return { inputDir, outputDir, builtFiles };
312
+ }
313
+ var buildCommand = new Command("build").description("Build markdown files into HTML output").option("-i, --input <dir>", "input directory", ".").option("-o, --output <dir>", "output directory", "dist").option("-t, --theme <id>", "Squisq theme ID to apply").option("--max-input-bytes <bytes>", "maximum bytes in one Markdown input").option("--max-total-input-bytes <bytes>", "maximum aggregate Markdown input bytes").option("--max-output-bytes <bytes>", "maximum bytes in one generated HTML file").option("--max-total-output-bytes <bytes>", "maximum aggregate generated HTML bytes").option("--allow-large-build", "disable default byte budgets for a trusted bulk build").action(async (opts) => {
314
+ try {
315
+ const result = await runBuild({
316
+ input: opts.input,
317
+ output: opts.output,
318
+ theme: opts.theme,
319
+ maxInputBytes: parseOptionalByteLimit("--max-input-bytes", opts.maxInputBytes),
320
+ maxTotalInputBytes: parseOptionalByteLimit(
321
+ "--max-total-input-bytes",
322
+ opts.maxTotalInputBytes
323
+ ),
324
+ maxOutputBytes: parseOptionalByteLimit("--max-output-bytes", opts.maxOutputBytes),
325
+ maxTotalOutputBytes: parseOptionalByteLimit(
326
+ "--max-total-output-bytes",
327
+ opts.maxTotalOutputBytes
328
+ ),
329
+ allowLargeBuild: opts.allowLargeBuild
330
+ });
331
+ for (const outFile of result.builtFiles) {
332
+ console.error(`Built: ${outFile}`);
333
+ }
334
+ console.error(`Done. ${result.builtFiles.length} file(s) built to ${result.outputDir}`);
335
+ } catch (err) {
336
+ const message = err instanceof Error ? err.message : String(err);
337
+ console.error(`Error: ${message}`);
338
+ process.exitCode = 1;
339
+ }
340
+ });
341
+ function resolveBuildByteLimits(options) {
342
+ const unlimitedDefault = options.allowLargeBuild === true ? Number.MAX_SAFE_INTEGER : void 0;
343
+ return {
344
+ maxInputBytes: positiveLimit(
345
+ options.maxInputBytes,
346
+ unlimitedDefault ?? DEFAULT_MAX_BUILD_INPUT_BYTES,
347
+ "build input byte"
348
+ ),
349
+ maxTotalInputBytes: positiveLimit(
350
+ options.maxTotalInputBytes,
351
+ unlimitedDefault ?? DEFAULT_MAX_BUILD_TOTAL_INPUT_BYTES,
352
+ "build aggregate input byte"
353
+ ),
354
+ maxOutputBytes: positiveLimit(
355
+ options.maxOutputBytes,
356
+ unlimitedDefault ?? DEFAULT_MAX_BUILD_OUTPUT_BYTES,
357
+ "build output byte"
358
+ ),
359
+ maxTotalOutputBytes: positiveLimit(
360
+ options.maxTotalOutputBytes,
361
+ unlimitedDefault ?? DEFAULT_MAX_BUILD_TOTAL_OUTPUT_BYTES,
362
+ "build aggregate output byte"
363
+ )
364
+ };
365
+ }
366
+ async function assertBuildInputSizes(markdownFiles, limits) {
367
+ let totalBytes = 0;
368
+ for (const sourcePath of markdownFiles) {
369
+ const info = await stat2(sourcePath);
370
+ if (!info.isFile()) throw new Error(`Build input is not a file: ${sourcePath}`);
371
+ if (info.size > limits.maxInputBytes) {
372
+ throw new Error(
373
+ `Build input ${sourcePath} exceeds the ${limits.maxInputBytes}-byte per-file limit.`
374
+ );
375
+ }
376
+ totalBytes += info.size;
377
+ if (totalBytes > limits.maxTotalInputBytes) {
378
+ throw new Error(`Build inputs exceed the ${limits.maxTotalInputBytes}-byte aggregate limit.`);
379
+ }
380
+ }
381
+ }
382
+ function parseOptionalByteLimit(name, value) {
383
+ if (value === void 0) return void 0;
384
+ const parsed = Number(value);
385
+ if (!Number.isSafeInteger(parsed) || parsed < 1) {
386
+ throw new Error(`${name} must be a positive safe integer`);
387
+ }
388
+ return parsed;
389
+ }
390
+ async function listMarkdownFiles(root, limits) {
391
+ const files = [];
392
+ const pending = [{ directory: root, depth: 0 }];
393
+ let entryCount = 0;
394
+ while (pending.length > 0) {
395
+ const current = pending.pop();
396
+ const entries = await opendir(current.directory);
397
+ for await (const entry of entries) {
398
+ entryCount += 1;
399
+ if (entryCount > limits.maxEntries) {
400
+ throw new Error(`Build traversal exceeded ${limits.maxEntries} filesystem entries.`);
401
+ }
402
+ const absolutePath = path4.join(current.directory, entry.name);
403
+ if (entry.isDirectory()) {
404
+ if (current.depth >= limits.maxDepth) {
405
+ throw new Error(`Build traversal exceeded a depth of ${limits.maxDepth}.`);
406
+ }
407
+ pending.push({ directory: absolutePath, depth: current.depth + 1 });
408
+ } else if (entry.isFile() && isMarkdownFile(entry.name)) {
409
+ files.push(absolutePath);
410
+ }
411
+ }
412
+ }
413
+ return files.sort((a, b) => a.localeCompare(b));
414
+ }
415
+ function isMarkdownFile(fileName) {
416
+ return /\.(md|markdown)$/i.test(fileName);
417
+ }
418
+ function replaceMarkdownExtension(filePath) {
419
+ return filePath.replace(/\.(md|markdown)$/i, ".html");
420
+ }
421
+ function titleFromPath(filePath) {
422
+ const baseName = path4.basename(filePath).replace(/\.(md|markdown)$/i, "");
423
+ return baseName || "DocBlocks Document";
424
+ }
425
+
426
+ // src/commands/convert.ts
427
+ import {
428
+ link,
429
+ lstat,
430
+ mkdir as mkdir2,
431
+ mkdtemp,
432
+ open as open2,
433
+ opendir as opendir2,
434
+ realpath as realpath2,
435
+ rename,
436
+ rm,
437
+ stat as stat3
438
+ } from "fs/promises";
439
+ import { randomUUID } from "crypto";
440
+ import { dirname, basename, extname, join, resolve } from "path";
441
+ import { Command as Command2 } from "commander";
442
+
443
+ // src/internal/link-support.ts
444
+ var LINK_UNSUPPORTED_CODES = [
445
+ "EPERM",
446
+ "ENOSYS",
447
+ "ENOTSUP",
448
+ "EOPNOTSUPP",
449
+ "EXDEV",
450
+ "EMLINK"
451
+ ];
452
+ function isLinkUnsupportedError(error) {
453
+ return isNodeErrorCode(error, LINK_UNSUPPORTED_CODES);
454
+ }
455
+
456
+ // src/internal/cancellation.ts
457
+ function cancellationError(message) {
458
+ const error = new Error(message);
459
+ error.name = "AbortError";
460
+ return error;
461
+ }
462
+ function throwIfAborted(signal, message) {
463
+ if (!signal?.aborted) return;
464
+ throw signal.reason ?? cancellationError(message);
465
+ }
466
+
467
+ // src/commands/convert.ts
468
+ var DEFAULT_FORMATS = ["docx", "pptx", "pdf", "html", "dbk"];
469
+ var DEFAULT_MAX_CONVERT_INPUT_BYTES = 1024 * 1024 * 1024;
470
+ var DEFAULT_MAX_CONVERT_INPUT_ENTRIES = 2e4;
471
+ var DEFAULT_MAX_CONVERT_OUTPUT_BYTES = 512 * 1024 * 1024;
472
+ var DEFAULT_MAX_CONVERT_TOTAL_OUTPUT_BYTES = 1024 * 1024 * 1024;
473
+ async function runConvert(inputPath, opts) {
474
+ throwIfAborted2(opts.signal);
475
+ const resolvedInput = resolve(inputPath);
476
+ const outputDir = opts.outputDir ? resolve(opts.outputDir) : dirname(resolvedInput);
477
+ const inputBasename = basename(resolvedInput);
478
+ const inputExt = extname(inputBasename);
479
+ const baseName = inputExt ? inputBasename.slice(0, -inputExt.length) : inputBasename;
480
+ const maxInputBytes = positiveLimit(
481
+ opts.maxInputBytes,
482
+ DEFAULT_MAX_CONVERT_INPUT_BYTES,
483
+ "conversion input byte"
484
+ );
485
+ const maxInputEntries = positiveLimit(
486
+ opts.maxInputEntries,
487
+ DEFAULT_MAX_CONVERT_INPUT_ENTRIES,
488
+ "conversion input entry"
489
+ );
490
+ const maxOutputBytes = positiveLimit(
491
+ opts.maxOutputBytes,
492
+ DEFAULT_MAX_CONVERT_OUTPUT_BYTES,
493
+ "conversion output byte"
494
+ );
495
+ const maxTotalOutputBytes = positiveLimit(
496
+ opts.maxTotalOutputBytes,
497
+ DEFAULT_MAX_CONVERT_TOTAL_OUTPUT_BYTES,
498
+ "conversion aggregate output byte"
499
+ );
500
+ await assertInputWithinBudget(resolvedInput, maxInputBytes, maxInputEntries, opts.signal);
501
+ const squisq = await import("@bendyline/squisq-cli/api");
502
+ throwIfAborted2(opts.signal);
503
+ const registry = opts.registry ?? squisq.createCliRegistry();
504
+ const exportableIds = registry.list().filter((definition) => definition.exportDoc !== void 0).map((definition) => definition.id);
505
+ const exportable = new Set(exportableIds);
506
+ const registryHint = "Choose a format reported by the linked Squisq CLI registry.";
507
+ const requestedList = opts.formats;
508
+ const explicitFormats = requestedList !== void 0;
509
+ const requested = explicitFormats ? requestedList.split(",").map((format) => format.trim().toLowerCase()).filter(Boolean) : [...DEFAULT_FORMATS];
510
+ if (explicitFormats) {
511
+ const duplicate = firstDuplicate(requested);
512
+ if (duplicate) {
513
+ throw new squisq.ConversionError(
514
+ "unknown-format",
515
+ `Duplicate conversion target: ${duplicate}. List each requested format once.`,
516
+ { format: duplicate, hint: registryHint }
517
+ );
518
+ }
519
+ }
520
+ const unknown = requested.filter((format) => !exportable.has(format));
521
+ if (unknown.length > 0 && explicitFormats) {
522
+ throw new squisq.ConversionError(
523
+ "unknown-format",
524
+ `Unknown or non-exportable format${unknown.length === 1 ? "" : "s"} "${unknown.join(", ")}". Valid: ${exportableIds.join(", ")}`,
525
+ { format: unknown[0], hint: registryHint }
526
+ );
527
+ }
528
+ if (unknown.length > 0) {
529
+ console.warn(
530
+ `Unknown or non-exportable default format${unknown.length === 1 ? "" : "s"} "${unknown.join(", ")}" \u2014 skipping. Valid: ${exportableIds.join(", ")}`
531
+ );
532
+ }
533
+ const formats = requested.filter((format) => exportable.has(format));
534
+ if (formats.length === 0) {
535
+ throw new squisq.ConversionError(
536
+ "unknown-format",
537
+ `No valid formats specified. Valid: ${exportableIds.join(", ")}`,
538
+ { format: requested[0], hint: registryHint }
539
+ );
540
+ }
541
+ if (opts.transform) {
542
+ const { getTransformStyleIds } = await import("@bendyline/squisq/transform");
543
+ throwIfAborted2(opts.signal);
544
+ const styles = getTransformStyleIds();
545
+ if (!styles.includes(opts.transform)) {
546
+ throw new Error(
547
+ `Unknown transform style "${opts.transform}". Available: ${styles.join(", ")}`
548
+ );
549
+ }
550
+ }
551
+ const allowOverwrite = opts.allowOverwrite === true;
552
+ if (!allowOverwrite) {
553
+ await assertOutputsAvailable(
554
+ formats.map((format) => plannedOutputPath(outputDir, baseName, format, registry)),
555
+ opts.signal
556
+ );
557
+ }
558
+ console.error(`Reading: ${resolvedInput}`);
559
+ const result = await squisq.readInput(resolvedInput, { signal: opts.signal });
560
+ throwIfAborted2(opts.signal);
561
+ const { container } = result;
562
+ await assertKnownThemeId(
563
+ opts.theme,
564
+ result.doc.customThemes?.map((theme) => theme.id)
565
+ );
566
+ throwIfAborted2(opts.signal);
567
+ let exportMarkdownDoc = result.markdownDoc;
568
+ if (opts.transform && exportMarkdownDoc) {
569
+ exportMarkdownDoc = await applyTransformToMarkdown(
570
+ exportMarkdownDoc,
571
+ container,
572
+ opts.transform,
573
+ opts.theme
574
+ );
575
+ throwIfAborted2(opts.signal);
576
+ console.error(` Applied transform: ${opts.transform}`);
577
+ }
578
+ const source = exportMarkdownDoc ? { kind: "markdown", markdown: exportMarkdownDoc, container, baseName } : { kind: "doc", doc: result.doc, container, baseName };
579
+ const preparedOutputs = [];
580
+ const outputPathKeys = /* @__PURE__ */ new Set();
581
+ let totalOutputBytes = 0;
582
+ for (const format of formats) {
583
+ throwIfAborted2(opts.signal);
584
+ const conversion = await squisq.convert(source, format, {
585
+ signal: opts.signal,
586
+ registry,
587
+ title: baseName,
588
+ themeId: opts.theme,
589
+ ...!exportMarkdownDoc && opts.transform ? { transformStyle: opts.transform } : {}
590
+ });
591
+ throwIfAborted2(opts.signal);
592
+ if (conversion.bytes.byteLength > maxOutputBytes) {
593
+ throw new Error(`${format} output exceeds the ${maxOutputBytes}-byte conversion limit.`);
594
+ }
595
+ totalOutputBytes += conversion.bytes.byteLength;
596
+ if (totalOutputBytes > maxTotalOutputBytes) {
597
+ throw new Error(`Conversion outputs exceed the ${maxTotalOutputBytes}-byte aggregate limit.`);
598
+ }
599
+ const suggestedFilename = basename(conversion.suggestedFilename);
600
+ const outPath = join(outputDir, suggestedFilename);
601
+ const outputPathKey = process.platform === "win32" ? outPath.toLowerCase() : outPath;
602
+ if (outputPathKeys.has(outputPathKey)) {
603
+ throw new Error(`Multiple conversion targets resolve to the same output: ${outPath}`);
604
+ }
605
+ outputPathKeys.add(outputPathKey);
606
+ preparedOutputs.push({
607
+ path: outPath,
608
+ format,
609
+ bytes: conversion.bytes,
610
+ size: conversion.bytes.byteLength,
611
+ mimeType: conversion.mimeType,
612
+ suggestedFilename: conversion.suggestedFilename,
613
+ warnings: [...conversion.warnings]
614
+ });
615
+ }
616
+ await publishConversionBatch(preparedOutputs, outputDir, allowOverwrite, opts.signal);
617
+ const outputFiles = preparedOutputs.map(
618
+ ({ bytes: _bytes, ...output }) => output
619
+ );
620
+ for (const output of outputFiles) {
621
+ for (const warning of output.warnings) {
622
+ console.error(` Warning [${output.format}]: ${warning}`);
623
+ }
624
+ console.error(` \u2713 ${output.path}`);
625
+ }
626
+ console.error("Done.");
627
+ return { outputFiles };
628
+ }
629
+ async function assertInputWithinBudget(inputPath, maxBytes, maxEntries, signal) {
630
+ let rootInfo;
631
+ try {
632
+ rootInfo = await stat3(inputPath);
633
+ } catch (error) {
634
+ if (isNodeErrorCode(error, ["ENOENT", "ENOTDIR"])) {
635
+ throw new Error(`Conversion input not found: ${inputPath}`);
636
+ }
637
+ throw error;
638
+ }
639
+ if (rootInfo.isFile()) {
640
+ if (rootInfo.size > maxBytes) {
641
+ throw new Error(`Conversion input exceeds the ${maxBytes}-byte limit.`);
642
+ }
643
+ return;
644
+ }
645
+ if (!rootInfo.isDirectory()) throw new Error(`Unsupported conversion input: ${inputPath}`);
646
+ const pending = [inputPath];
647
+ const visitedDirectories = /* @__PURE__ */ new Set();
648
+ let entryCount = 0;
649
+ let totalBytes = 0;
650
+ while (pending.length > 0) {
651
+ throwIfAborted2(signal);
652
+ const directory = pending.pop();
653
+ const physicalDirectory = await realpath2(directory);
654
+ if (visitedDirectories.has(physicalDirectory)) continue;
655
+ visitedDirectories.add(physicalDirectory);
656
+ const entries = await opendir2(directory);
657
+ for await (const entry of entries) {
658
+ throwIfAborted2(signal);
659
+ entryCount += 1;
660
+ if (entryCount > maxEntries) {
661
+ throw new Error(`Conversion input exceeds the ${maxEntries}-entry limit.`);
662
+ }
663
+ const entryPath = join(directory, entry.name);
664
+ const info = await stat3(entryPath);
665
+ if (info.isDirectory()) pending.push(entryPath);
666
+ else if (info.isFile()) {
667
+ totalBytes += info.size;
668
+ if (totalBytes > maxBytes) {
669
+ throw new Error(`Conversion input exceeds the ${maxBytes}-byte limit.`);
670
+ }
671
+ }
672
+ }
673
+ }
674
+ }
675
+ function plannedOutputPath(outputDir, baseName, format, registry) {
676
+ const extension = registry.get(format)?.extensions[0]?.replace(/^\.+/u, "") ?? format;
677
+ return join(outputDir, `${baseName}.${extension}`);
678
+ }
679
+ async function assertOutputsAvailable(outputPaths, signal) {
680
+ const conflicts = [];
681
+ for (const outputPath of outputPaths) {
682
+ throwIfAborted2(signal);
683
+ if (await lstatIfPresent(outputPath)) conflicts.push(outputPath);
684
+ }
685
+ throwIfAborted2(signal);
686
+ if (conflicts.length > 0) throw new Error(overwriteConflictMessage(conflicts));
687
+ }
688
+ function overwriteConflictMessage(conflicts) {
689
+ const noun = conflicts.length === 1 ? "file" : "files";
690
+ const pronoun = conflicts.length === 1 ? "it" : "them";
691
+ return [
692
+ `Refusing to overwrite ${conflicts.length} existing ${noun}:`,
693
+ ...conflicts.map((conflict) => ` ${conflict}`),
694
+ `Pass --allow-overwrite to replace ${pronoun}, or use --output-dir to write elsewhere.`
695
+ ].join("\n");
696
+ }
697
+ var ConversionRollbackError = class extends Error {
698
+ constructor(message, publicationError, rollbackErrors) {
699
+ super(message);
700
+ this.name = "ConversionRollbackError";
701
+ this.publicationError = publicationError;
702
+ this.rollbackErrors = rollbackErrors;
703
+ }
704
+ };
705
+ async function publishConversionBatch(outputs, outputDir, allowOverwrite, signal, afterPublishForTest) {
706
+ await mkdir2(outputDir, { recursive: true });
707
+ const stagingDirectory = await mkdtemp(join(outputDir, ".docblocks-convert-"));
708
+ let preserveRecoveryDirectory = false;
709
+ try {
710
+ const stagedOutputs = [];
711
+ for (let index = 0; index < outputs.length; index += 1) {
712
+ throwIfAborted2(signal);
713
+ const output = outputs[index];
714
+ if (!output) continue;
715
+ const stagedPath = join(stagingDirectory, `${index}-${randomUUID()}.stage`);
716
+ await writePrivateFile(stagedPath, output.bytes);
717
+ stagedOutputs.push({ outputPath: output.path, stagedPath, bytes: output.bytes });
718
+ }
719
+ if (!allowOverwrite) {
720
+ await assertOutputsAvailable(
721
+ stagedOutputs.map((output) => output.outputPath),
722
+ signal
723
+ );
724
+ } else {
725
+ await assertReplaceableOutputs(stagedOutputs.map((output) => output.outputPath));
726
+ }
727
+ const committed = [];
728
+ try {
729
+ for (let index = 0; index < stagedOutputs.length; index += 1) {
730
+ throwIfAborted2(signal);
731
+ const output = stagedOutputs[index];
732
+ if (!output) continue;
733
+ const state = {
734
+ outputPath: output.outputPath,
735
+ backupPath: null,
736
+ published: false
737
+ };
738
+ committed.push(state);
739
+ if (allowOverwrite) {
740
+ const existing = await lstatIfPresent(output.outputPath);
741
+ if (existing && !existing.isFile() && !existing.isSymbolicLink()) {
742
+ throw new Error(`Refusing to replace a non-file destination: ${output.outputPath}`);
743
+ }
744
+ if (existing) {
745
+ state.backupPath = join(stagingDirectory, `${index}-${randomUUID()}.backup`);
746
+ await rename(output.outputPath, state.backupPath);
747
+ }
748
+ await rename(output.stagedPath, output.outputPath);
749
+ } else {
750
+ await publishStagedFileNoReplace(output);
751
+ }
752
+ state.published = true;
753
+ await afterPublishForTest?.(committed.length);
754
+ }
755
+ } catch (error) {
756
+ const rollbackErrors = await rollbackConversionBatch(committed);
757
+ if (rollbackErrors.length > 0) {
758
+ preserveRecoveryDirectory = true;
759
+ throw new ConversionRollbackError(
760
+ `Conversion publication failed and automatic rollback was incomplete. Recovery files remain in ${stagingDirectory}.`,
761
+ error,
762
+ rollbackErrors
763
+ );
764
+ }
765
+ throw error;
766
+ }
767
+ } finally {
768
+ if (!preserveRecoveryDirectory) {
769
+ await rm(stagingDirectory, { recursive: true, force: true }).catch(() => void 0);
770
+ }
771
+ }
772
+ }
773
+ async function writePrivateFile(path6, bytes) {
774
+ const handle = await open2(path6, "wx", 384);
775
+ try {
776
+ await handle.writeFile(bytes);
777
+ await handle.sync();
778
+ } finally {
779
+ await handle.close();
780
+ }
781
+ }
782
+ async function publishStagedFileNoReplace(output) {
783
+ try {
784
+ await link(output.stagedPath, output.outputPath);
785
+ return;
786
+ } catch (error) {
787
+ if (isNodeErrorCode(error, "EEXIST")) {
788
+ throw new Error(overwriteConflictMessage([output.outputPath]));
789
+ }
790
+ if (!isLinkUnsupportedError(error)) throw error;
791
+ }
792
+ let published;
793
+ try {
794
+ published = await open2(output.outputPath, "wx", 384);
795
+ } catch (error) {
796
+ if (isNodeErrorCode(error, "EEXIST")) {
797
+ throw new Error(overwriteConflictMessage([output.outputPath]));
798
+ }
799
+ throw error;
800
+ }
801
+ try {
802
+ await published.writeFile(output.bytes);
803
+ await published.sync();
804
+ await published.close();
805
+ } catch (error) {
806
+ await published.close().catch(() => void 0);
807
+ await rm(output.outputPath, { force: true }).catch(() => void 0);
808
+ throw error;
809
+ }
810
+ }
811
+ async function rollbackConversionBatch(committed) {
812
+ const rollbackErrors = [];
813
+ for (let index = committed.length - 1; index >= 0; index -= 1) {
814
+ const state = committed[index];
815
+ if (!state) continue;
816
+ try {
817
+ if (state.published) await rm(state.outputPath, { force: true });
818
+ if (state.backupPath) await rename(state.backupPath, state.outputPath);
819
+ } catch (error) {
820
+ rollbackErrors.push(error);
821
+ }
822
+ }
823
+ return rollbackErrors;
824
+ }
825
+ async function assertReplaceableOutputs(outputPaths) {
826
+ for (const outputPath of outputPaths) {
827
+ const existing = await lstatIfPresent(outputPath);
828
+ if (existing && !existing.isFile() && !existing.isSymbolicLink()) {
829
+ throw new Error(`Refusing to replace a non-file destination: ${outputPath}`);
830
+ }
831
+ }
832
+ }
833
+ async function lstatIfPresent(path6) {
834
+ try {
835
+ return await lstat(path6);
836
+ } catch (error) {
837
+ if (isNodeErrorCode(error, ["ENOENT", "ENOTDIR"])) return null;
838
+ throw error;
839
+ }
840
+ }
841
+ function firstDuplicate(values) {
842
+ const seen = /* @__PURE__ */ new Set();
843
+ for (const value of values) {
844
+ if (seen.has(value)) return value;
845
+ seen.add(value);
846
+ }
847
+ return void 0;
848
+ }
849
+ function throwIfAborted2(signal) {
850
+ throwIfAborted(signal, "Document conversion was cancelled");
851
+ }
852
+ async function applyTransformToMarkdown(markdownDoc, container, transformStyle, themeId) {
853
+ const { markdownToDoc: markdownToDoc2, docToMarkdown } = await import("@bendyline/squisq/doc");
854
+ const { readFrontmatterThemeId } = await import("@bendyline/squisq/markdown");
855
+ const { applyTransform, extractDocImages } = await import("@bendyline/squisq/transform");
856
+ const doc = markdownToDoc2(markdownDoc);
857
+ const images = extractDocImages(doc.blocks);
858
+ const effectiveTheme = themeId ?? readFrontmatterThemeId(markdownDoc.frontmatter);
859
+ const result = applyTransform(doc, transformStyle, { themeId: effectiveTheme, images });
860
+ const projected = projectTransformOntoMarkdownSource(doc, result.doc);
861
+ const transformedMarkdown = docToMarkdown(projected);
862
+ const resolvedTheme = effectiveTheme ?? result.doc.themeId ?? doc.themeId;
863
+ if (!resolvedTheme) return transformedMarkdown;
864
+ return {
865
+ ...transformedMarkdown,
866
+ frontmatter: {
867
+ ...transformedMarkdown.frontmatter ?? {},
868
+ "squisq-theme": resolvedTheme
869
+ }
870
+ };
871
+ }
872
+ function projectTransformOntoMarkdownSource(source, transformed) {
873
+ const candidatesBySource = /* @__PURE__ */ new Map();
874
+ const timedSourceBlocks = collectTransformSourceBlocks(source.blocks).sort(
875
+ (left, right) => left.startTime - right.startTime
876
+ );
877
+ const collectCandidates = (blocks) => {
878
+ for (const block of blocks) {
879
+ const provenance = block;
880
+ const sourceBlockId = provenance.sourceBlockId ?? findSourceBlockAtTime(timedSourceBlocks, provenance.sourceStartTime)?.id;
881
+ if (sourceBlockId && block.template) {
882
+ const candidates = candidatesBySource.get(sourceBlockId) ?? [];
883
+ candidates.push(block);
884
+ candidatesBySource.set(sourceBlockId, candidates);
885
+ }
886
+ if (block.children) collectCandidates(block.children);
887
+ }
888
+ };
889
+ collectCandidates(transformed.blocks);
890
+ const projectBlock = (block) => {
891
+ const children = block.children?.map(projectBlock);
892
+ const visual = selectVisualCandidate(candidatesBySource.get(block.id));
893
+ if (!visual || !block.sourceHeading) {
894
+ return { ...block, ...children ? { children } : {} };
895
+ }
896
+ const { autoTemplate: _autoTemplate, ...sourceBlock } = block;
897
+ void _autoTemplate;
898
+ const templateOverrides = {
899
+ ...visual.overrides,
900
+ ...block.sourceHeading.templateAnnotation?.params ?? {},
901
+ ...block.templateOverrides ?? {}
902
+ };
903
+ const sourceHeading = {
904
+ ...block.sourceHeading,
905
+ templateAnnotation: {
906
+ ...block.sourceHeading.templateAnnotation ?? {},
907
+ template: visual.block.template,
908
+ ...Object.keys(templateOverrides).length > 0 ? { params: templateOverrides } : {}
909
+ }
910
+ };
911
+ return {
912
+ ...sourceBlock,
913
+ ...children ? { children } : {},
914
+ template: visual.block.template,
915
+ templateOverrides,
916
+ sourceHeading,
917
+ ...block.transition || visual.block.transition ? { transition: block.transition ?? visual.block.transition } : {}
918
+ };
919
+ };
920
+ return {
921
+ ...source,
922
+ blocks: source.blocks.map(projectBlock),
923
+ themeId: transformed.themeId ?? source.themeId
924
+ };
925
+ }
926
+ function collectTransformSourceBlocks(blocks) {
927
+ const sourceBlocks = [];
928
+ for (const block of blocks) {
929
+ if (block.children && block.children.length > 0) {
930
+ if (!block.contents || block.contents.length === 0) sourceBlocks.push(block);
931
+ sourceBlocks.push(...collectTransformSourceBlocks(block.children));
932
+ } else {
933
+ sourceBlocks.push(block);
934
+ }
935
+ }
936
+ return sourceBlocks;
937
+ }
938
+ function findSourceBlockAtTime(sourceBlocks, sourceStartTime) {
939
+ if (sourceStartTime === void 0) return void 0;
940
+ let match;
941
+ for (const block of sourceBlocks) {
942
+ if (block.startTime > sourceStartTime) break;
943
+ match = block;
944
+ }
945
+ return match;
946
+ }
947
+ function selectVisualCandidate(candidates) {
948
+ if (!candidates || candidates.length === 0) return void 0;
949
+ for (const candidate of candidates) {
950
+ if (candidate.id.startsWith("transform-header-") || candidate.id.startsWith("transform-img-") || candidate.template === "sectionHeader") {
951
+ continue;
952
+ }
953
+ const overrides = serializeVisualInputs(candidate);
954
+ if (overrides) return { block: candidate, overrides };
955
+ }
956
+ return void 0;
957
+ }
958
+ function serializeVisualInputs(block) {
959
+ const input = block;
960
+ switch (block.template) {
961
+ case "title":
962
+ return serializeStringInputs(input, ["title"], ["subtitle", "backgroundColor"]);
963
+ case "statHighlight":
964
+ return serializeStringInputs(input, ["stat", "description"], ["detail", "colorScheme"]);
965
+ case "quote":
966
+ return serializeStringInputs(input, ["quote"], ["attribution"]);
967
+ case "fullBleedQuote":
968
+ return serializeStringInputs(input, ["text"], ["colorScheme"]);
969
+ case "factCard":
970
+ return serializeStringInputs(input, ["fact", "explanation"], ["source"]);
971
+ case "definitionCard":
972
+ return serializeStringInputs(input, ["term", "definition"], ["origin", "colorScheme"]);
973
+ case "dateEvent":
974
+ return serializeStringInputs(input, ["date", "description"], ["footer", "mood"]);
975
+ case "twoColumn": {
976
+ const left = serializeLabeledPair(input.left);
977
+ const right = serializeLabeledPair(input.right);
978
+ if (!left || !right) return void 0;
979
+ return {
980
+ left,
981
+ right,
982
+ ...serializeStringInputs(input, [], ["header", "leftColor", "rightColor"]) ?? {}
983
+ };
984
+ }
985
+ default:
986
+ return void 0;
987
+ }
988
+ }
989
+ function serializeStringInputs(input, required, optional) {
990
+ const serialized = {};
991
+ for (const key of required) {
992
+ const value = input[key];
993
+ if (typeof value !== "string") return void 0;
994
+ serialized[key] = value;
995
+ }
996
+ for (const key of optional) {
997
+ const value = input[key];
998
+ if (typeof value === "string") serialized[key] = value;
999
+ }
1000
+ return serialized;
1001
+ }
1002
+ function serializeLabeledPair(value) {
1003
+ if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
1004
+ const pair = value;
1005
+ if (typeof pair.label !== "string") return void 0;
1006
+ return typeof pair.sublabel === "string" && pair.sublabel ? `${pair.label}|${pair.sublabel}` : pair.label;
1007
+ }
1008
+ var convertCommand = new Command2("convert").description("Convert a document through the linked Squisq format registry").argument("<input>", "Path to a supported document, .zip/.dbk container, or folder").option("-o, --output-dir <dir>", "Output directory (default: same as input)").option(
1009
+ "-f, --formats <list>",
1010
+ `Comma-separated linked Squisq export formats (default: ${DEFAULT_FORMATS.join(", ")})`
1011
+ ).option("-t, --theme <id>", "Squisq theme ID to apply (e.g., documentary, cinematic, bold)").option(
1012
+ "--transform <style>",
1013
+ "Transform style to apply before export (e.g., documentary, magazine, minimal)"
1014
+ ).option("--allow-overwrite", "replace existing output files instead of refusing the run").action(async (inputPath, opts) => {
1015
+ try {
1016
+ await runConvert(inputPath, opts);
1017
+ } catch (err) {
1018
+ const message = err instanceof Error ? err.message : String(err);
1019
+ console.error(`Error: ${message}`);
1020
+ if (isConversionErrorMetadata(err)) {
1021
+ console.error(` Code: ${err.code}${err.format ? ` (${err.format})` : ""}`);
1022
+ if (err.hint) console.error(` Hint: ${err.hint}`);
1023
+ }
1024
+ process.exitCode = 1;
1025
+ }
1026
+ });
1027
+ function isConversionErrorMetadata(error) {
1028
+ if (!(error instanceof Error)) return false;
1029
+ const candidate = error;
1030
+ if (typeof candidate.code !== "string") return false;
1031
+ if (candidate.format !== void 0 && typeof candidate.format !== "string") {
1032
+ return false;
1033
+ }
1034
+ return candidate.hint === void 0 || typeof candidate.hint === "string";
1035
+ }
1036
+
1037
+ // src/commands/video.ts
1038
+ import { lstat as lstat2, mkdir as mkdir3 } from "fs/promises";
1039
+ import { dirname as dirname2, basename as basename2, extname as extname2, resolve as resolve2 } from "path";
1040
+ import { Command as Command3 } from "commander";
1041
+ import {
1042
+ resolveDimensions,
1043
+ validateVideoExportOptions
1044
+ } from "@bendyline/squisq-video";
1045
+ var VALID_QUALITIES = ["draft", "normal", "high"];
1046
+ var VALID_ORIENTATIONS = ["landscape", "portrait"];
1047
+ var VALID_CAPTIONS = ["off", "standard", "social"];
1048
+ var CLI_VIDEO_RENDER_LIMITS = Object.freeze({
1049
+ maximumDimension: 3840,
1050
+ maximumPixelsPerFrame: 8294400
1051
+ });
1052
+ async function runVideo(inputPath, opts, onProgress, dependencies = {}) {
1053
+ throwIfAborted3(opts.signal);
1054
+ const resolvedInput = resolve2(inputPath);
1055
+ const fps = opts.fps ?? 30;
1056
+ if (!Number.isFinite(fps) || fps < 1 || fps > 120) {
1057
+ throw new Error("FPS must be a number between 1 and 120");
1058
+ }
1059
+ const quality = opts.quality ?? "normal";
1060
+ if (!VALID_QUALITIES.includes(quality)) {
1061
+ throw new Error(`Invalid quality "${quality}". Valid: ${VALID_QUALITIES.join(", ")}`);
1062
+ }
1063
+ const orientation = opts.orientation ?? "landscape";
1064
+ if (!VALID_ORIENTATIONS.includes(orientation)) {
1065
+ throw new Error(
1066
+ `Invalid orientation "${orientation}". Valid: ${VALID_ORIENTATIONS.join(", ")}`
1067
+ );
1068
+ }
1069
+ const captions = opts.captions ?? "off";
1070
+ if (!VALID_CAPTIONS.includes(captions)) {
1071
+ throw new Error(`Invalid captions "${captions}". Valid: ${VALID_CAPTIONS.join(", ")}`);
1072
+ }
1073
+ const captionStyle = captions === "off" ? void 0 : captions;
1074
+ assertCliVideoRenderBudget({
1075
+ fps,
1076
+ quality,
1077
+ orientation,
1078
+ width: opts.width,
1079
+ height: opts.height
1080
+ });
1081
+ const inputBasename = basename2(resolvedInput);
1082
+ const inputExt = extname2(inputBasename);
1083
+ const baseName = inputExt ? inputBasename.slice(0, -inputExt.length) : inputBasename;
1084
+ const outputPath = opts.output ? resolve2(opts.output) : resolve2(dirname2(resolvedInput), `${baseName}.mp4`);
1085
+ if (opts.allowOverwrite !== true && await lstat2(outputPath).catch(() => null)) {
1086
+ throw new Error(
1087
+ [
1088
+ "Refusing to overwrite an existing file:",
1089
+ ` ${outputPath}`,
1090
+ "Pass --allow-overwrite to replace it, or use --output to write elsewhere."
1091
+ ].join("\n")
1092
+ );
1093
+ }
1094
+ throwIfAborted3(opts.signal);
1095
+ await mkdir3(dirname2(outputPath), { recursive: true });
1096
+ throwIfAborted3(opts.signal);
1097
+ console.error(`Reading: ${resolvedInput}`);
1098
+ const squisq = await import("@bendyline/squisq-cli/api");
1099
+ throwIfAborted3(opts.signal);
1100
+ const renderDocToMp4 = dependencies.renderDocToMp4 ?? squisq.renderDocToMp4;
1101
+ const { readInput } = squisq;
1102
+ const result = await readInput(resolvedInput, { signal: opts.signal });
1103
+ throwIfAborted3(opts.signal);
1104
+ const { container, doc } = result;
1105
+ console.error(`Using normalized ${result.sourceFormat} document`);
1106
+ console.error(
1107
+ `Rendering: ${fps} fps, quality: ${quality}, orientation: ${orientation}, captions: ${captions}`
1108
+ );
1109
+ let latestProgress = 0;
1110
+ const renderResult = await renderDocToMp4(doc, container, {
1111
+ signal: opts.signal,
1112
+ outputPath,
1113
+ fps,
1114
+ quality,
1115
+ orientation,
1116
+ width: opts.width,
1117
+ height: opts.height,
1118
+ captionStyle,
1119
+ onProgress: (phase, percent) => {
1120
+ throwIfAborted3(opts.signal);
1121
+ const boundedPercent = Math.max(0, Math.min(100, percent));
1122
+ if (boundedPercent < latestProgress) return;
1123
+ latestProgress = boundedPercent;
1124
+ if (onProgress) onProgress(phase, boundedPercent);
1125
+ else process.stderr.write(`\r ${phase}: ${boundedPercent}% `);
1126
+ throwIfAborted3(opts.signal);
1127
+ }
1128
+ });
1129
+ throwIfAborted3(opts.signal);
1130
+ process.stderr.write("\r" + " ".repeat(60) + "\r");
1131
+ console.error(` \u2713 ${outputPath}`);
1132
+ console.error(
1133
+ ` Duration: ${renderResult.duration.toFixed(1)}s, ${renderResult.frameCount} frames`
1134
+ );
1135
+ console.error("Done.");
1136
+ return {
1137
+ outputPath: renderResult.outputPath,
1138
+ duration: renderResult.duration,
1139
+ frameCount: renderResult.frameCount
1140
+ };
1141
+ }
1142
+ function assertCliVideoRenderBudget(options) {
1143
+ validateVideoExportOptions(options);
1144
+ const { width, height } = resolveDimensions(options);
1145
+ for (const [label, value] of [
1146
+ ["width", width],
1147
+ ["height", height]
1148
+ ]) {
1149
+ if (value % 2 !== 0) {
1150
+ throw new RangeError(
1151
+ `Video ${label} must be an even number of pixels (got ${value}); H.264 yuv420p cannot encode odd dimensions.`
1152
+ );
1153
+ }
1154
+ }
1155
+ const pixelsPerFrame = width * height;
1156
+ const violations = [];
1157
+ if (width > CLI_VIDEO_RENDER_LIMITS.maximumDimension || height > CLI_VIDEO_RENDER_LIMITS.maximumDimension) {
1158
+ violations.push(
1159
+ `${width}x${height} exceeds the ${CLI_VIDEO_RENDER_LIMITS.maximumDimension}-pixel dimension limit`
1160
+ );
1161
+ }
1162
+ if (pixelsPerFrame > CLI_VIDEO_RENDER_LIMITS.maximumPixelsPerFrame) {
1163
+ violations.push(
1164
+ `${pixelsPerFrame} pixels per frame exceeds the ${CLI_VIDEO_RENDER_LIMITS.maximumPixelsPerFrame}-pixel limit`
1165
+ );
1166
+ }
1167
+ if (violations.length > 0) {
1168
+ throw new RangeError(`Video render exceeds the CLI resource budget: ${violations.join("; ")}.`);
1169
+ }
1170
+ }
1171
+ function throwIfAborted3(signal) {
1172
+ throwIfAborted(signal, "Video rendering was cancelled");
1173
+ }
1174
+ var videoCommand = new Command3("video").description("Render a squisq document to MP4 video").argument("<input>", "Path to a supported document, .zip/.dbk container, or folder").argument("[output]", "Output MP4 path (default: <input>.mp4)").option("-o, --output <path>", "Output MP4 path (default: <input>.mp4)").option("--fps <number>", "Frames per second", "30").option("--quality <level>", `Encoding quality: ${VALID_QUALITIES.join(", ")}`, "normal").option(
1175
+ "--orientation <orient>",
1176
+ `Video orientation: ${VALID_ORIENTATIONS.join(", ")}`,
1177
+ "landscape"
1178
+ ).option("--captions <style>", `Caption style: ${VALID_CAPTIONS.join(", ")}`, "off").option("--width <pixels>", "Override video width").option("--height <pixels>", "Override video height").option("--allow-overwrite", "replace an existing output MP4 instead of refusing the run").action(async (inputPath, outputArg, opts) => {
1179
+ try {
1180
+ await runVideo(inputPath, {
1181
+ output: selectVideoOutput(outputArg, opts.output),
1182
+ fps: parseNumberOption("--fps", opts.fps ?? "30"),
1183
+ quality: opts.quality,
1184
+ orientation: opts.orientation,
1185
+ captions: opts.captions,
1186
+ width: opts.width ? parseNumberOption("--width", opts.width) : void 0,
1187
+ height: opts.height ? parseNumberOption("--height", opts.height) : void 0,
1188
+ allowOverwrite: opts.allowOverwrite
1189
+ });
1190
+ } catch (err) {
1191
+ const message = err instanceof Error ? err.message : String(err);
1192
+ console.error(`Error: ${message}`);
1193
+ process.exitCode = 1;
1194
+ }
1195
+ });
1196
+ function parseNumberOption(name, value) {
1197
+ const parsed = Number(value);
1198
+ if (!Number.isFinite(parsed)) throw new Error(`${name} must be a finite number`);
1199
+ return parsed;
1200
+ }
1201
+ function selectVideoOutput(positional, option) {
1202
+ if (positional !== void 0 && option !== void 0) {
1203
+ throw new Error(
1204
+ [
1205
+ "Two output paths were requested:",
1206
+ ` positional ${positional}`,
1207
+ ` --output ${option}`,
1208
+ "Pass the output path exactly once."
1209
+ ].join("\n")
1210
+ );
1211
+ }
1212
+ return option ?? positional;
1213
+ }
1214
+
1215
+ // src/commands/parse.ts
1216
+ import { open as open3 } from "fs/promises";
1217
+ import { resolve as resolve3 } from "path";
1218
+ import { Command as Command4 } from "commander";
1219
+ import { decodeUtf8Text as decodeUtf8Text2 } from "@bendyline/docblocks/filesystem";
1220
+ var DEFAULT_MAX_PARSE_INPUT_BYTES = 20 * 1024 * 1024;
1221
+ var DEFAULT_MAX_PARSE_OUTPUT_BYTES = 128 * 1024 * 1024;
1222
+ async function runParse(inputPath, options = {}) {
1223
+ const resolvedInput = resolve3(inputPath);
1224
+ const maxInputBytes = positiveLimit(
1225
+ options.maxInputBytes,
1226
+ DEFAULT_MAX_PARSE_INPUT_BYTES,
1227
+ "parse input byte"
1228
+ );
1229
+ const maxOutputBytes = positiveLimit(
1230
+ options.maxOutputBytes,
1231
+ DEFAULT_MAX_PARSE_OUTPUT_BYTES,
1232
+ "parse output byte"
1233
+ );
1234
+ const bytes = await readBoundedFile(resolvedInput, maxInputBytes);
1235
+ const content = decodeUtf8Text2(bytes, { label: "Parse input", path: resolvedInput });
1236
+ const { parseMarkdown: parseMarkdown2 } = await import("@bendyline/squisq/markdown");
1237
+ const markdownDoc = parseMarkdown2(content);
1238
+ const stats = {
1239
+ headingCount: 0,
1240
+ paragraphCount: 0,
1241
+ blockCount: markdownDoc.children?.length ?? 0
1242
+ };
1243
+ if (markdownDoc.children) {
1244
+ for (const node of markdownDoc.children) {
1245
+ if (node.type === "heading") stats.headingCount += 1;
1246
+ if (node.type === "paragraph") stats.paragraphCount += 1;
1247
+ }
1248
+ }
1249
+ const serialized = `${JSON.stringify({ stats, document: markdownDoc }, null, 2)}
1250
+ `;
1251
+ const outputBytes = Buffer.byteLength(serialized, "utf8");
1252
+ if (outputBytes > maxOutputBytes) {
1253
+ throw new Error(`Parse output exceeds the ${maxOutputBytes}-byte limit.`);
1254
+ }
1255
+ return serialized;
1256
+ }
1257
+ async function readBoundedFile(inputPath, maxBytes) {
1258
+ let handle;
1259
+ try {
1260
+ handle = await open3(inputPath, "r");
1261
+ } catch (error) {
1262
+ if (isNodeErrorCode(error, ["ENOENT", "ENOTDIR"])) {
1263
+ throw new Error(`Parse input not found: ${inputPath}`);
1264
+ }
1265
+ throw error;
1266
+ }
1267
+ try {
1268
+ const info = await handle.stat();
1269
+ if (!info.isFile()) throw new Error(`Parse input is not a file: ${inputPath}`);
1270
+ if (info.size > maxBytes) throw new Error(`Parse input exceeds the ${maxBytes}-byte limit.`);
1271
+ const chunks = [];
1272
+ let totalBytes = 0;
1273
+ while (true) {
1274
+ const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes + 1 - totalBytes));
1275
+ const { bytesRead } = await handle.read(chunk, 0, chunk.byteLength, null);
1276
+ if (bytesRead === 0) break;
1277
+ totalBytes += bytesRead;
1278
+ if (totalBytes > maxBytes) {
1279
+ throw new Error(`Parse input exceeds the ${maxBytes}-byte limit.`);
1280
+ }
1281
+ chunks.push(chunk.subarray(0, bytesRead));
1282
+ }
1283
+ return Buffer.concat(chunks, totalBytes);
1284
+ } finally {
1285
+ await handle.close();
1286
+ }
1287
+ }
1288
+ var parseCommand = new Command4("parse").description("Parse a markdown file and print its structure as JSON").argument("<input>", "Path to UTF-8 Markdown content").action(async (inputPath) => {
1289
+ try {
1290
+ process.stdout.write(await runParse(inputPath));
1291
+ } catch (err) {
1292
+ const message = err instanceof Error ? err.message : String(err);
1293
+ console.error(`Error: ${message}`);
1294
+ process.exitCode = 1;
1295
+ }
1296
+ });
1297
+
1298
+ // src/version.ts
1299
+ import { createHash } from "crypto";
1300
+ import { createRequire } from "module";
1301
+ import { readFileSync } from "fs";
1302
+ import { readdir, readFile as readFile2, stat as stat4 } from "fs/promises";
1303
+ import path5 from "path";
1304
+ var require2 = createRequire(import.meta.url);
1305
+ function getPackageVersion() {
1306
+ const pkg = readPackageJson();
1307
+ if (typeof pkg?.version === "string" && pkg.version.length > 0) {
1308
+ return pkg.version;
1309
+ }
1310
+ return "0.0.0";
1311
+ }
1312
+ function readPackageJson() {
1313
+ try {
1314
+ const raw = readFileSync(new URL("../package.json", import.meta.url), "utf-8");
1315
+ return JSON.parse(raw);
1316
+ } catch {
1317
+ return null;
1318
+ }
1319
+ }
1320
+ export {
1321
+ applyTransformToMarkdown,
1322
+ assertCliVideoRenderBudget,
1323
+ getPackageVersion,
1324
+ runBuild,
1325
+ runConvert,
1326
+ runParse,
1327
+ runVideo
1328
+ };