@bendyline/squisq-cli 2.4.1 → 2.4.3

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.
@@ -6,8 +6,8 @@ import {
6
6
 
7
7
  // src/api.ts
8
8
  import { readFile as readFile3 } from "fs/promises";
9
- import { resolveMediaSchedule } from "@bendyline/squisq/schemas";
10
- import { flattenBlocks } from "@bendyline/squisq/doc";
9
+ import { resolveMediaSchedule as resolveMediaSchedule2 } from "@bendyline/squisq/schemas";
10
+ import { flattenBlocks as flattenBlocks2 } from "@bendyline/squisq/doc";
11
11
  import { ffmpegGifOutputArgs, generateRenderHtml } from "@bendyline/squisq-video";
12
12
  import { resolveDimensions } from "@bendyline/squisq-video";
13
13
  import {
@@ -19,17 +19,17 @@ import {
19
19
  import { execFile } from "child_process";
20
20
  function run(command, args, signal) {
21
21
  signal?.throwIfAborted();
22
- return new Promise((resolve, reject) => {
22
+ return new Promise((resolve2, reject) => {
23
23
  execFile(command, args, { timeout: 5e3, signal }, (err, stdout) => {
24
24
  if (signal?.aborted) {
25
25
  reject(signal.reason);
26
26
  return;
27
27
  }
28
28
  if (err || !stdout.trim()) {
29
- resolve(null);
29
+ resolve2(null);
30
30
  return;
31
31
  }
32
- resolve(stdout.trim());
32
+ resolve2(stdout.trim());
33
33
  });
34
34
  });
35
35
  }
@@ -73,7 +73,7 @@ async function detectFfmpegDetailed(signal) {
73
73
  import { computeAudioTimeline } from "@bendyline/squisq-video";
74
74
  async function buildMixedAudioTrack(doc, container, ffmpegPath, coverPreRoll, signal) {
75
75
  signal?.throwIfAborted();
76
- const timeline = computeAudioTimeline(doc, coverPreRoll);
76
+ const timeline = computeAudioTimeline(doc, coverPreRoll, { includeVideoAudio: false });
77
77
  if (timeline.length === 0) return null;
78
78
  const bytesBySrc = /* @__PURE__ */ new Map();
79
79
  const readSrc = async (src) => {
@@ -268,6 +268,27 @@ function createMediaBudget() {
268
268
  };
269
269
  }
270
270
 
271
+ // src/util/playerBundle.ts
272
+ import { flattenBlocks } from "@bendyline/squisq/doc";
273
+ function selectStandalonePlayerVariant(doc) {
274
+ for (const block of flattenBlocks(doc.blocks)) {
275
+ if (block.layers?.some((layer) => layer.type === "mermaid")) return "full";
276
+ if (containsMermaidFence(block.contents)) return "full";
277
+ }
278
+ return "light";
279
+ }
280
+ function containsMermaidFence(value) {
281
+ if (Array.isArray(value)) return value.some(containsMermaidFence);
282
+ if (!isRecord(value)) return false;
283
+ if (value.type === "code" && typeof value.lang === "string" && value.lang.trim().toLowerCase() === "mermaid") {
284
+ return true;
285
+ }
286
+ return containsMermaidFence(value.children);
287
+ }
288
+ function isRecord(value) {
289
+ return typeof value === "object" && value !== null;
290
+ }
291
+
271
292
  // src/registry.ts
272
293
  import { randomBytes } from "crypto";
273
294
  import { readFile, rm } from "fs/promises";
@@ -309,7 +330,7 @@ function mp4Format() {
309
330
  const animationsEnabled = typeof mp4Opts.animationsEnabled === "boolean" ? mp4Opts.animationsEnabled : MP4_DEFAULTS.animationsEnabled;
310
331
  const outputPath = join(tmpdir(), `squisq-mp4-${randomBytes(8).toString("hex")}.mp4`);
311
332
  try {
312
- const { renderDocToMp4: renderDocToMp42 } = await import("./api-HBPJQ7MA.js");
333
+ const { renderDocToMp4: renderDocToMp42 } = await import("./api-PBBQQMRP.js");
313
334
  await renderDocToMp42(input.doc, input.container, {
314
335
  outputPath,
315
336
  fps,
@@ -350,7 +371,7 @@ function gifFormat() {
350
371
  const defaultHeight = portrait ? GIF_DEFAULTS.width : GIF_DEFAULTS.height;
351
372
  const outputPath = join(tmpdir(), `squisq-gif-${randomBytes(8).toString("hex")}.gif`);
352
373
  try {
353
- const { renderDocToGif: renderDocToGif2 } = await import("./api-HBPJQ7MA.js");
374
+ const { renderDocToGif: renderDocToGif2 } = await import("./api-PBBQQMRP.js");
354
375
  const result = await renderDocToGif2(input.doc, input.container, {
355
376
  outputPath,
356
377
  fps: typeof gifOpts.fps === "number" ? gifOpts.fps : GIF_DEFAULTS.fps,
@@ -394,13 +415,45 @@ function createCliRegistry() {
394
415
  import { MemoryContentContainer as MemoryContentContainer2 } from "@bendyline/squisq/storage";
395
416
 
396
417
  // src/util/readInput.ts
397
- import { readFile as readFile2, readdir, stat } from "fs/promises";
398
- import { join as join2, extname } from "path";
418
+ import { readFile as readFile2, readdir, realpath, stat } from "fs/promises";
419
+ import {
420
+ basename,
421
+ dirname,
422
+ extname,
423
+ isAbsolute,
424
+ join as join2,
425
+ posix,
426
+ relative,
427
+ resolve,
428
+ sep,
429
+ win32
430
+ } from "path";
399
431
  import { parseMarkdown, stringifyMarkdown } from "@bendyline/squisq/markdown";
400
432
  import { markdownToDoc, resolveAudioMapping } from "@bendyline/squisq/doc";
433
+ import { resolveMediaSchedule, validateDocSchema } from "@bendyline/squisq/schemas";
401
434
  import { MemoryContentContainer } from "@bendyline/squisq/storage";
402
435
  import { zipToContainer } from "@bendyline/squisq-formats/container";
403
436
  import { defaultRegistry as defaultRegistry2 } from "@bendyline/squisq-formats";
437
+ var DocInputValidationError = class extends Error {
438
+ constructor(source, issues) {
439
+ const detail = issues.map(formatSchemaIssueForError).join("; ");
440
+ super(`${source} is not a valid squisq Doc: ${detail}`);
441
+ this.name = "DocInputValidationError";
442
+ this.issues = issues;
443
+ this.diagnostics = issues.map((issue) => ({
444
+ severity: "error",
445
+ code: "invalid-doc-schema",
446
+ message: `${issue.path} ${issue.message}`
447
+ }));
448
+ }
449
+ };
450
+ function formatSchemaIssueForError(issue) {
451
+ if (issue.path === "$") {
452
+ const got = /\(got ([^)]+)\)/.exec(issue.message)?.[1] ?? "an invalid value";
453
+ return `expected a JSON object, got ${got}`;
454
+ }
455
+ return `"${issue.path}" ${issue.message}`;
456
+ }
404
457
  var MIME_TYPES = {
405
458
  ".md": "text/markdown",
406
459
  ".txt": "text/plain",
@@ -415,7 +468,11 @@ var MIME_TYPES = {
415
468
  ".wav": "audio/wav",
416
469
  ".ogg": "audio/ogg",
417
470
  ".mp4": "video/mp4",
418
- ".webm": "video/webm"
471
+ ".webm": "video/webm",
472
+ ".woff": "font/woff",
473
+ ".woff2": "font/woff2",
474
+ ".ttf": "font/ttf",
475
+ ".otf": "font/otf"
419
476
  };
420
477
  var IMPORTER_EXTS = [".docx", ".pptx", ".pdf", ".xlsx", ".csv", ".html", ".htm"];
421
478
  function mimeFromExt(filePath) {
@@ -441,8 +498,10 @@ async function readInput(inputPath, options) {
441
498
  throwIfAborted(options?.signal);
442
499
  const result = await readInputRaw(inputPath, options);
443
500
  throwIfAborted(options?.signal);
501
+ assertValidDoc(result.doc, inputPath);
444
502
  const doc = await resolveAudioMapping(result.doc, result.container);
445
503
  throwIfAborted(options?.signal);
504
+ assertValidDoc(doc, inputPath);
446
505
  return doc === result.doc ? result : { ...result, doc };
447
506
  }
448
507
  async function readInputRaw(inputPath, options) {
@@ -495,69 +554,160 @@ async function readUtf8File(filePath, signal) {
495
554
  }
496
555
  async function readMarkdownFile(filePath, signal) {
497
556
  const content = await readUtf8File(filePath, signal);
498
- const container = new MemoryContentContainer();
499
- await container.writeDocument(content);
500
- throwIfAborted(signal);
501
557
  const markdownDoc = parseMarkdown(content);
502
- return { doc: markdownToDoc(markdownDoc), container, markdownDoc, sourceFormat: "md" };
558
+ const doc = markdownToDoc(markdownDoc);
559
+ const container = await buildBareMarkdownContainer(filePath, content, doc, signal);
560
+ return { doc, container, markdownDoc, sourceFormat: "md" };
503
561
  }
504
- function parseDocJson(content, source) {
505
- let parsed;
506
- try {
507
- parsed = JSON.parse(content);
508
- } catch (error) {
509
- const detail = error instanceof Error ? error.message : String(error);
510
- throw new Error(`${source} is not valid JSON: ${detail}`);
511
- }
512
- const fail = (detail) => {
513
- throw new Error(`${source} is not a valid squisq Doc: ${detail}`);
514
- };
515
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
516
- fail(`expected a JSON object, got ${Array.isArray(parsed) ? "an array" : typeof parsed}`);
517
- }
518
- const doc = parsed;
519
- if (!Array.isArray(doc.blocks)) {
520
- fail(`"blocks" must be an array${doc.blocks === void 0 ? " (field is missing)" : ""}`);
562
+ var NARRATION_EXTENSIONS = /* @__PURE__ */ new Set([".aac", ".flac", ".m4a", ".mp3", ".ogg", ".wav"]);
563
+ async function buildBareMarkdownContainer(filePath, content, doc, signal) {
564
+ const container = new MemoryContentContainer();
565
+ await container.writeDocument(content, basename(filePath));
566
+ throwIfAborted(signal);
567
+ const refs = collectAuthoredAssetRefs(content, doc);
568
+ const root = dirname(resolve(filePath));
569
+ for (const entry of await readdir(root, { withFileTypes: true })) {
570
+ throwIfAborted(signal);
571
+ if (entry.isFile() && NARRATION_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
572
+ refs.add(entry.name);
573
+ }
521
574
  }
522
- for (const [index, block] of doc.blocks.entries()) {
523
- if (typeof block !== "object" || block === null || Array.isArray(block)) {
524
- fail(`"blocks[${index}]" must be an object`);
575
+ refs.add("timing.json");
576
+ for (const ref of [...refs]) {
577
+ if (NARRATION_EXTENSIONS.has(extname(stripUrlSuffix(ref)).toLowerCase())) {
578
+ refs.add(`${stripUrlSuffix(ref)}.timing.json`);
525
579
  }
526
580
  }
527
- if (doc.duration !== void 0 && !isFiniteNumber(doc.duration)) {
528
- fail(`"duration" must be a finite number, got ${describe(doc.duration)}`);
581
+ const rootReal = await realpath(root);
582
+ let fileCount = 0;
583
+ let totalBytes = 0;
584
+ for (const authoredRef of refs) {
585
+ throwIfAborted(signal);
586
+ const safe = normalizeAssetReference(authoredRef);
587
+ if (!safe) continue;
588
+ const absolute = resolve(root, ...safe.split("/"));
589
+ if (!isContainedPath(root, absolute)) continue;
590
+ let assetReal;
591
+ let info;
592
+ try {
593
+ assetReal = await realpath(absolute);
594
+ if (!isContainedPath(rootReal, assetReal)) continue;
595
+ info = await stat(assetReal);
596
+ } catch (error) {
597
+ if (isMissingFileError(error)) continue;
598
+ throw error;
599
+ }
600
+ if (!info.isFile()) continue;
601
+ if (info.size > MAX_RENDER_MEDIA_FILE_BYTES) {
602
+ throw new Error(
603
+ `Sibling asset "${safe}" exceeds the ${formatMiB(MAX_RENDER_MEDIA_FILE_BYTES)} per-file input limit.`
604
+ );
605
+ }
606
+ if (fileCount + 1 > MAX_RENDER_MEDIA_FILES) {
607
+ throw new Error(
608
+ `Bare markdown input references more than ${MAX_RENDER_MEDIA_FILES} sibling assets.`
609
+ );
610
+ }
611
+ if (totalBytes + info.size > MAX_RENDER_MEDIA_TOTAL_BYTES) {
612
+ throw new Error(
613
+ `Sibling assets exceed the ${formatMiB(MAX_RENDER_MEDIA_TOTAL_BYTES)} total input limit.`
614
+ );
615
+ }
616
+ const data = await readBinaryFile(assetReal, signal);
617
+ if (data.byteLength > MAX_RENDER_MEDIA_FILE_BYTES) {
618
+ throw new Error(
619
+ `Sibling asset "${safe}" exceeds the ${formatMiB(MAX_RENDER_MEDIA_FILE_BYTES)} per-file input limit.`
620
+ );
621
+ }
622
+ if (totalBytes + data.byteLength > MAX_RENDER_MEDIA_TOTAL_BYTES) {
623
+ throw new Error(
624
+ `Sibling assets exceed the ${formatMiB(MAX_RENDER_MEDIA_TOTAL_BYTES)} total input limit.`
625
+ );
626
+ }
627
+ await container.writeFile(safe, data, mimeFromExt(safe));
628
+ fileCount += 1;
629
+ totalBytes += data.byteLength;
529
630
  }
530
- if (doc.audio !== void 0) {
531
- if (typeof doc.audio !== "object" || doc.audio === null || Array.isArray(doc.audio)) {
532
- fail('"audio" must be an object');
631
+ return container;
632
+ }
633
+ function collectAuthoredAssetRefs(content, doc) {
634
+ const refs = /* @__PURE__ */ new Set();
635
+ const add = (value) => {
636
+ if (typeof value === "string" && value.trim()) refs.add(value.trim());
637
+ };
638
+ const scanObject = (value, seen = /* @__PURE__ */ new Set()) => {
639
+ if (!value || typeof value !== "object") return;
640
+ if (seen.has(value)) return;
641
+ seen.add(value);
642
+ if (Array.isArray(value)) {
643
+ for (const item of value) scanObject(item, seen);
644
+ return;
533
645
  }
534
- const segments = doc.audio.segments;
535
- if (segments !== void 0) {
536
- if (!Array.isArray(segments)) fail('"audio.segments" must be an array');
537
- for (const [index, segment] of segments.entries()) {
538
- if (typeof segment !== "object" || segment === null) {
539
- fail(`"audio.segments[${index}]" must be an object`);
540
- }
541
- if (!isFiniteNumber(segment.duration)) {
542
- fail(
543
- `"audio.segments[${index}].duration" must be a finite number, got ${describe(segment.duration)}`
544
- );
545
- }
646
+ for (const [key, item] of Object.entries(value)) {
647
+ if (typeof item === "string" && ["src", "url", "heroSrc", "posterSrc", "staticSrc", "videoSrc", "imageSrc"].includes(key)) {
648
+ add(item);
649
+ } else {
650
+ scanObject(item, seen);
546
651
  }
547
652
  }
548
- }
549
- return {
550
- ...doc,
551
- audio: doc.audio ?? { segments: [] }
552
653
  };
654
+ scanObject(doc);
655
+ for (const segment of doc.audio.segments) add(segment.src);
656
+ for (const clip of resolveMediaSchedule(doc)) add(clip.src);
657
+ const patterns = [
658
+ /\b(?:src|href)\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/gi,
659
+ /\b(?:src|audio|video|image|font)\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s}\]]+))/gi,
660
+ /\burl\(\s*(?:"([^"]+)"|'([^']+)'|([^\s)]+))\s*\)/gi,
661
+ /!?\[[^\]]*\]\(\s*<?([^\s)>]+)>?/g
662
+ ];
663
+ for (const pattern of patterns) {
664
+ let match;
665
+ while (match = pattern.exec(content)) add(match.slice(1).find(Boolean));
666
+ }
667
+ return refs;
668
+ }
669
+ function normalizeAssetReference(authoredRef) {
670
+ let value = stripUrlSuffix(authoredRef.trim().replace(/^<|>$/g, ""));
671
+ try {
672
+ value = decodeURIComponent(value);
673
+ } catch {
674
+ return null;
675
+ }
676
+ if (!value || value.includes("\0") || /^[a-z][a-z\d+.-]*:/i.test(value) || posix.isAbsolute(value) || win32.isAbsolute(value) || isAbsolute(value)) {
677
+ return null;
678
+ }
679
+ const parts = value.replace(/\\/g, "/").split("/");
680
+ if (parts.some((part) => part === "..")) return null;
681
+ const normalized = parts.filter((part) => part && part !== ".").join("/");
682
+ return normalized || null;
683
+ }
684
+ function stripUrlSuffix(value) {
685
+ return value.split(/[?#]/, 1)[0] ?? value;
686
+ }
687
+ function isContainedPath(root, candidate) {
688
+ const rel = relative(root, candidate);
689
+ return rel === "" || !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
690
+ }
691
+ function isMissingFileError(error) {
692
+ return typeof error === "object" && error !== null && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
693
+ }
694
+ function formatMiB(bytes) {
695
+ return `${Math.round(bytes / (1024 * 1024))} MB`;
553
696
  }
554
- function isFiniteNumber(value) {
555
- return typeof value === "number" && Number.isFinite(value);
697
+ function assertValidDoc(value, source) {
698
+ const issues = validateDocSchema(value);
699
+ if (issues.length > 0) throw new DocInputValidationError(source, issues);
556
700
  }
557
- function describe(value) {
558
- if (typeof value === "number") return Number.isNaN(value) ? "NaN" : String(value);
559
- if (typeof value === "string") return JSON.stringify(value);
560
- return value === null ? "null" : typeof value;
701
+ function parseDocJson(content, source) {
702
+ let parsed;
703
+ try {
704
+ parsed = JSON.parse(content);
705
+ } catch (error) {
706
+ const detail = error instanceof Error ? error.message : String(error);
707
+ throw new Error(`${source} is not valid JSON: ${detail}`);
708
+ }
709
+ assertValidDoc(parsed, source);
710
+ return parsed;
561
711
  }
562
712
  async function readDocJsonFile(filePath, signal) {
563
713
  const content = await readUtf8File(filePath, signal);
@@ -664,6 +814,7 @@ function throwIfAborted(signal) {
664
814
  // src/api.ts
665
815
  import { ConversionError } from "@bendyline/squisq-formats";
666
816
  var playerBundlePromise;
817
+ var fullPlayerBundlePromise;
667
818
  function loadPlayerBundle() {
668
819
  playerBundlePromise ??= readFile3(
669
820
  new URL("../dist/squisq-player.global.js", import.meta.url),
@@ -671,6 +822,13 @@ function loadPlayerBundle() {
671
822
  );
672
823
  return playerBundlePromise;
673
824
  }
825
+ function loadFullPlayerBundle() {
826
+ fullPlayerBundlePromise ??= readFile3(
827
+ new URL("../dist/squisq-player.full.global.js", import.meta.url),
828
+ "utf8"
829
+ );
830
+ return fullPlayerBundlePromise;
831
+ }
674
832
  async function convert(source, to, options = {}) {
675
833
  return formatsConvert(source, to, {
676
834
  registry: createCliRegistry(),
@@ -722,8 +880,8 @@ async function captureDocFrames(doc, container, options) {
722
880
  audio.set(seg.name, data);
723
881
  }
724
882
  }
725
- const mediaSrcs = new Set(resolveMediaSchedule(doc).map((clip) => clip.src));
726
- for (const block of flattenBlocks(doc.blocks)) {
883
+ const mediaSrcs = new Set(resolveMediaSchedule2(doc).map((clip) => clip.src));
884
+ for (const block of flattenBlocks2(doc.blocks)) {
727
885
  for (const layer of block.layers ?? []) {
728
886
  if (layer.type === "video") mediaSrcs.add(layer.content.src);
729
887
  }
@@ -740,7 +898,7 @@ async function captureDocFrames(doc, container, options) {
740
898
  }
741
899
  onProgress?.("generating render HTML", 10);
742
900
  signal?.throwIfAborted();
743
- const playerBundle = await loadPlayerBundle();
901
+ const playerBundle = await (selectStandalonePlayerVariant(doc) === "full" ? loadFullPlayerBundle() : loadPlayerBundle());
744
902
  signal?.throwIfAborted();
745
903
  const renderHtml = generateRenderHtml(doc, {
746
904
  playerScript: playerBundle,
@@ -973,7 +1131,7 @@ async function renderDocToGif(doc, container, options) {
973
1131
  options.signal?.throwIfAborted();
974
1132
  options.onProgress?.("done", 100);
975
1133
  options.signal?.throwIfAborted();
976
- const hasAudio = (doc.audio?.segments?.length ?? 0) > 0 || resolveMediaSchedule(doc).some((clip) => clip.kind === "audio");
1134
+ const hasAudio = (doc.audio?.segments?.length ?? 0) > 0 || resolveMediaSchedule2(doc).some((clip) => clip.kind === "audio");
977
1135
  return {
978
1136
  duration: capture.totalDuration,
979
1137
  frameCount,
@@ -988,8 +1146,30 @@ async function extractThumbnails(options) {
988
1146
  const { videoPath, outputDir, slug, sizes, force, signal } = options;
989
1147
  const { existsSync } = await import("fs");
990
1148
  const { rm: rm2 } = await import("fs/promises");
991
- const { join: join3 } = await import("path");
1149
+ const { isAbsolute: isAbsolute2, relative: relative2, resolve: resolve2, sep: sep2 } = await import("path");
992
1150
  signal?.throwIfAborted();
1151
+ if (typeof slug !== "string" || !/^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,126}[A-Za-z0-9])?$/.test(slug)) {
1152
+ throw new TypeError(
1153
+ "Thumbnail slug must be 1\u2013128 filename-safe characters (letters, numbers, dot, dash, or underscore) and must start and end with a letter or number."
1154
+ );
1155
+ }
1156
+ for (const [index, thumb] of sizes.entries()) {
1157
+ if (!Number.isSafeInteger(thumb.width) || thumb.width <= 0) {
1158
+ throw new TypeError(`Thumbnail sizes[${index}].width must be a positive integer.`);
1159
+ }
1160
+ if (!Number.isSafeInteger(thumb.height) || thumb.height <= 0) {
1161
+ throw new TypeError(`Thumbnail sizes[${index}].height must be a positive integer.`);
1162
+ }
1163
+ }
1164
+ const resolvedOutputDir = resolve2(outputDir);
1165
+ const outputPaths = sizes.map((thumb) => {
1166
+ const outputPath = resolve2(resolvedOutputDir, `${slug}-${thumb.width}x${thumb.height}.jpg`);
1167
+ const rel = relative2(resolvedOutputDir, outputPath);
1168
+ if (rel === ".." || rel.startsWith(`..${sep2}`) || isAbsolute2(rel)) {
1169
+ throw new TypeError("Thumbnail output path must remain inside outputDir.");
1170
+ }
1171
+ return outputPath;
1172
+ });
993
1173
  const ffmpegPath = (await detectFfmpegDetailed(signal))?.path ?? null;
994
1174
  if (!ffmpegPath) {
995
1175
  throw new Error(
@@ -998,9 +1178,9 @@ async function extractThumbnails(options) {
998
1178
  }
999
1179
  const generatedPaths = [];
1000
1180
  try {
1001
- for (const thumb of sizes) {
1181
+ for (const [index, thumb] of sizes.entries()) {
1002
1182
  signal?.throwIfAborted();
1003
- const outputPath = join3(outputDir, `${slug}-${thumb.width}x${thumb.height}.jpg`);
1183
+ const outputPath = outputPaths[index];
1004
1184
  if (!force && existsSync(outputPath)) continue;
1005
1185
  try {
1006
1186
  await runFfmpeg(
@@ -1027,6 +1207,7 @@ async function extractThumbnails(options) {
1027
1207
  }
1028
1208
 
1029
1209
  export {
1210
+ DocInputValidationError,
1030
1211
  readInput,
1031
1212
  getFfmpegVersion,
1032
1213
  detectFfmpegDetailed,
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ DocInputValidationError,
3
4
  convert,
4
5
  createCliRegistry,
5
6
  detectFfmpegDetailed,
@@ -7,7 +8,7 @@ import {
7
8
  readInput,
8
9
  renderDocToGif,
9
10
  renderDocToMp4
10
- } from "./chunk-WR2BZJ77.js";
11
+ } from "./chunk-X3GFPKRJ.js";
11
12
  import "./chunk-GGVASRPG.js";
12
13
 
13
14
  // src/index.ts
@@ -498,8 +499,7 @@ function clearProgress() {
498
499
  }
499
500
 
500
501
  // src/commands/validate.ts
501
- import { existsSync, statSync } from "fs";
502
- import { dirname as dirname3, extname as extname3, join as join2, resolve as resolve3 } from "path";
502
+ import { resolve as resolve3 } from "path";
503
503
  import { validateMarkdownDoc } from "@bendyline/squisq/doc";
504
504
  function registerValidateCommand(program2) {
505
505
  program2.command("validate").description("Validate a squisq markdown document and report structural problems").argument("<input>", "Path to .md file, .zip/.dbk container, or folder").option("--json", "Output diagnostics as JSON (for tooling and agents)").option("--strict", "Exit non-zero on warnings as well as errors").action(async (inputPath, opts) => {
@@ -507,6 +507,10 @@ function registerValidateCommand(program2) {
507
507
  const exitCode = await runValidate(inputPath, opts);
508
508
  process.exitCode = exitCode;
509
509
  } catch (err) {
510
+ if (err instanceof DocInputValidationError) {
511
+ process.exitCode = report(err.diagnostics, opts);
512
+ return;
513
+ }
510
514
  const message = err instanceof Error ? err.message : String(err);
511
515
  console.error(`Error: ${message}`);
512
516
  process.exitCode = 2;
@@ -518,16 +522,12 @@ async function runValidate(inputPath, opts) {
518
522
  const { container, markdownDoc, doc } = await readInput(resolvedInput);
519
523
  if (!markdownDoc) {
520
524
  const diagnostics = doc.diagnostics ?? [];
521
- return report(diagnostics, opts, "doc.json input \u2014 markdown-level checks skipped");
525
+ return report(diagnostics, opts, "doc.json input \u2014 canonical schema valid");
522
526
  }
523
527
  const entries = await container.listFiles();
524
528
  const containerPaths = new Set(entries.map((e) => e.path));
525
- const isBareMarkdown = statSync(resolvedInput).isFile() && ![".zip", ".dbk"].includes(extname3(resolvedInput).toLowerCase());
526
- const baseDir = dirname3(resolvedInput);
527
529
  const hasAsset = (path) => {
528
- if (containerPaths.has(path) || containerPaths.has(path.replace(/^\.\//, ""))) return true;
529
- if (isBareMarkdown) return existsSync(join2(baseDir, path));
530
- return false;
530
+ return containerPaths.has(path) || containerPaths.has(path.replace(/^\.\//, ""));
531
531
  };
532
532
  const result = validateMarkdownDoc(markdownDoc, { assets: hasAsset });
533
533
  return report(result.diagnostics, opts);
@@ -629,7 +629,7 @@ async function runDoctor(runtime = defaultDoctorRuntime) {
629
629
 
630
630
  // src/commands/transform.ts
631
631
  import { mkdir as mkdir3, readFile } from "fs/promises";
632
- import { dirname as dirname4, resolve as resolve4 } from "path";
632
+ import { dirname as dirname3, resolve as resolve4 } from "path";
633
633
  import { Option as Option2 } from "commander";
634
634
  import {
635
635
  DEFAULT_WRAP_WIDTH,
@@ -682,9 +682,13 @@ async function runTransform(inputPath, opts) {
682
682
  return 1;
683
683
  }
684
684
  }
685
- const width = Number.parseInt(opts.width, 10);
686
- if (Number.isNaN(width)) {
687
- console.error(`Error: --width must be a number (got "${opts.width}")`);
685
+ if (!/^\d+$/.test(opts.width)) {
686
+ console.error(`Error: --width must be an integer between 20 and 500 (got "${opts.width}")`);
687
+ return 1;
688
+ }
689
+ const width = Number(opts.width);
690
+ if (!Number.isSafeInteger(width) || width < 20 || width > 500) {
691
+ console.error(`Error: --width must be an integer between 20 and 500 (got "${opts.width}")`);
688
692
  return 1;
689
693
  }
690
694
  let result = source;
@@ -702,7 +706,7 @@ async function runTransform(inputPath, opts) {
702
706
  } else if (opts.output) {
703
707
  const resolvedOutput = resolve4(opts.output);
704
708
  await assertOutputsWritable([resolvedOutput], opts.overwrite);
705
- await mkdir3(dirname4(resolvedOutput), { recursive: true });
709
+ await mkdir3(dirname3(resolvedOutput), { recursive: true });
706
710
  await writeFileGuarded(resolvedOutput, bytes, opts.overwrite);
707
711
  console.error(`\u2713 ${summary} \u2192 ${resolvedOutput}`);
708
712
  } else {