@bendyline/squisq-cli 2.4.2 → 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.
package/README.md CHANGED
@@ -177,17 +177,16 @@ const result = await convert({ kind: 'markdown', markdown: '# Hello' }, 'docx');
177
177
 
178
178
  ```ts
179
179
  import { renderDocToMp4, readInput } from '@bendyline/squisq-cli/api';
180
- import { markdownToDoc } from '@bendyline/squisq/doc';
181
180
 
182
181
  // Load a document from disk (.md, .zip/.dbk, folder, or Doc .json)
183
182
  const input = await readInput('./my-article.md');
184
183
 
185
- // readInput returns { container, markdownDoc, doc? } doc is only set for
186
- // JSON input, so build the Doc from markdown when needed:
187
- const doc = input.doc ?? markdownToDoc(input.markdownDoc!);
184
+ // `doc` is always normalized and ready to render. `markdownDoc` is present
185
+ // only when the source was markdown-shaped; `sourceFormat` identifies the input.
186
+ const { doc, container, markdownDoc, sourceFormat } = input;
188
187
 
189
188
  // Render to MP4
190
- const result = await renderDocToMp4(doc, input.container, {
189
+ const result = await renderDocToMp4(doc, container, {
191
190
  outputPath: './output.mp4',
192
191
  fps: 30, // default 30
193
192
  quality: 'high', // 'draft' | 'normal' | 'high' (default 'normal')
@@ -269,7 +268,7 @@ await extractThumbnails({
269
268
 
270
269
  ### Other exports
271
270
 
272
- - `readInput(inputPath)` → `{ container: MemoryContentContainer, markdownDoc: MarkdownDocument | null, doc?: Doc }`
271
+ - `readInput(inputPath)` → `{ doc: Doc, container: ContentContainer, markdownDoc?: MarkdownDocument, sourceFormat: FormatId }`
273
272
  - `MemoryContentContainer` (re-export from `@bendyline/squisq/storage`)
274
273
  - `VideoQuality`, `VideoOrientation` types (re-exports from `@bendyline/squisq-video`)
275
274
 
@@ -11,7 +11,7 @@ import {
11
11
  readInput,
12
12
  renderDocToGif,
13
13
  renderDocToMp4
14
- } from "./chunk-VK6WPOSG.js";
14
+ } from "./chunk-X3GFPKRJ.js";
15
15
  import {
16
16
  GIF_EXPORT_DEFAULTS,
17
17
  framesToGifNative,
package/dist/api.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
 
10
10
  // src/api.ts
11
11
  import { readFile as readFile3 } from "fs/promises";
12
- import { resolveMediaSchedule } from "@bendyline/squisq/schemas";
12
+ import { resolveMediaSchedule as resolveMediaSchedule2 } from "@bendyline/squisq/schemas";
13
13
  import { flattenBlocks as flattenBlocks2 } from "@bendyline/squisq/doc";
14
14
  import { ffmpegGifOutputArgs, generateRenderHtml } from "@bendyline/squisq-video";
15
15
  import { resolveDimensions } from "@bendyline/squisq-video";
@@ -22,17 +22,17 @@ import {
22
22
  import { execFile } from "child_process";
23
23
  function run(command, args, signal) {
24
24
  signal?.throwIfAborted();
25
- return new Promise((resolve, reject) => {
25
+ return new Promise((resolve2, reject) => {
26
26
  execFile(command, args, { timeout: 5e3, signal }, (err, stdout) => {
27
27
  if (signal?.aborted) {
28
28
  reject(signal.reason);
29
29
  return;
30
30
  }
31
31
  if (err || !stdout.trim()) {
32
- resolve(null);
32
+ resolve2(null);
33
33
  return;
34
34
  }
35
- resolve(stdout.trim());
35
+ resolve2(stdout.trim());
36
36
  });
37
37
  });
38
38
  }
@@ -76,7 +76,7 @@ async function detectFfmpegDetailed(signal) {
76
76
  import { computeAudioTimeline } from "@bendyline/squisq-video";
77
77
  async function buildMixedAudioTrack(doc, container, ffmpegPath, coverPreRoll, signal) {
78
78
  signal?.throwIfAborted();
79
- const timeline = computeAudioTimeline(doc, coverPreRoll);
79
+ const timeline = computeAudioTimeline(doc, coverPreRoll, { includeVideoAudio: false });
80
80
  if (timeline.length === 0) return null;
81
81
  const bytesBySrc = /* @__PURE__ */ new Map();
82
82
  const readSrc = async (src) => {
@@ -418,13 +418,45 @@ function createCliRegistry() {
418
418
  import { MemoryContentContainer as MemoryContentContainer2 } from "@bendyline/squisq/storage";
419
419
 
420
420
  // src/util/readInput.ts
421
- import { readFile as readFile2, readdir, stat } from "fs/promises";
422
- import { join as join2, extname } from "path";
421
+ import { readFile as readFile2, readdir, realpath, stat } from "fs/promises";
422
+ import {
423
+ basename,
424
+ dirname,
425
+ extname,
426
+ isAbsolute,
427
+ join as join2,
428
+ posix,
429
+ relative,
430
+ resolve,
431
+ sep,
432
+ win32
433
+ } from "path";
423
434
  import { parseMarkdown, stringifyMarkdown } from "@bendyline/squisq/markdown";
424
435
  import { markdownToDoc, resolveAudioMapping } from "@bendyline/squisq/doc";
436
+ import { resolveMediaSchedule, validateDocSchema } from "@bendyline/squisq/schemas";
425
437
  import { MemoryContentContainer } from "@bendyline/squisq/storage";
426
438
  import { zipToContainer } from "@bendyline/squisq-formats/container";
427
439
  import { defaultRegistry as defaultRegistry2 } from "@bendyline/squisq-formats";
440
+ var DocInputValidationError = class extends Error {
441
+ constructor(source, issues) {
442
+ const detail = issues.map(formatSchemaIssueForError).join("; ");
443
+ super(`${source} is not a valid squisq Doc: ${detail}`);
444
+ this.name = "DocInputValidationError";
445
+ this.issues = issues;
446
+ this.diagnostics = issues.map((issue) => ({
447
+ severity: "error",
448
+ code: "invalid-doc-schema",
449
+ message: `${issue.path} ${issue.message}`
450
+ }));
451
+ }
452
+ };
453
+ function formatSchemaIssueForError(issue) {
454
+ if (issue.path === "$") {
455
+ const got = /\(got ([^)]+)\)/.exec(issue.message)?.[1] ?? "an invalid value";
456
+ return `expected a JSON object, got ${got}`;
457
+ }
458
+ return `"${issue.path}" ${issue.message}`;
459
+ }
428
460
  var MIME_TYPES = {
429
461
  ".md": "text/markdown",
430
462
  ".txt": "text/plain",
@@ -439,7 +471,11 @@ var MIME_TYPES = {
439
471
  ".wav": "audio/wav",
440
472
  ".ogg": "audio/ogg",
441
473
  ".mp4": "video/mp4",
442
- ".webm": "video/webm"
474
+ ".webm": "video/webm",
475
+ ".woff": "font/woff",
476
+ ".woff2": "font/woff2",
477
+ ".ttf": "font/ttf",
478
+ ".otf": "font/otf"
443
479
  };
444
480
  var IMPORTER_EXTS = [".docx", ".pptx", ".pdf", ".xlsx", ".csv", ".html", ".htm"];
445
481
  function mimeFromExt(filePath) {
@@ -465,8 +501,10 @@ async function readInput(inputPath, options) {
465
501
  throwIfAborted(options?.signal);
466
502
  const result = await readInputRaw(inputPath, options);
467
503
  throwIfAborted(options?.signal);
504
+ assertValidDoc(result.doc, inputPath);
468
505
  const doc = await resolveAudioMapping(result.doc, result.container);
469
506
  throwIfAborted(options?.signal);
507
+ assertValidDoc(doc, inputPath);
470
508
  return doc === result.doc ? result : { ...result, doc };
471
509
  }
472
510
  async function readInputRaw(inputPath, options) {
@@ -519,69 +557,160 @@ async function readUtf8File(filePath, signal) {
519
557
  }
520
558
  async function readMarkdownFile(filePath, signal) {
521
559
  const content = await readUtf8File(filePath, signal);
522
- const container = new MemoryContentContainer();
523
- await container.writeDocument(content);
524
- throwIfAborted(signal);
525
560
  const markdownDoc = parseMarkdown(content);
526
- return { doc: markdownToDoc(markdownDoc), container, markdownDoc, sourceFormat: "md" };
561
+ const doc = markdownToDoc(markdownDoc);
562
+ const container = await buildBareMarkdownContainer(filePath, content, doc, signal);
563
+ return { doc, container, markdownDoc, sourceFormat: "md" };
527
564
  }
528
- function parseDocJson(content, source) {
529
- let parsed;
530
- try {
531
- parsed = JSON.parse(content);
532
- } catch (error) {
533
- const detail = error instanceof Error ? error.message : String(error);
534
- throw new Error(`${source} is not valid JSON: ${detail}`);
535
- }
536
- const fail = (detail) => {
537
- throw new Error(`${source} is not a valid squisq Doc: ${detail}`);
538
- };
539
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
540
- fail(`expected a JSON object, got ${Array.isArray(parsed) ? "an array" : typeof parsed}`);
541
- }
542
- const doc = parsed;
543
- if (!Array.isArray(doc.blocks)) {
544
- fail(`"blocks" must be an array${doc.blocks === void 0 ? " (field is missing)" : ""}`);
565
+ var NARRATION_EXTENSIONS = /* @__PURE__ */ new Set([".aac", ".flac", ".m4a", ".mp3", ".ogg", ".wav"]);
566
+ async function buildBareMarkdownContainer(filePath, content, doc, signal) {
567
+ const container = new MemoryContentContainer();
568
+ await container.writeDocument(content, basename(filePath));
569
+ throwIfAborted(signal);
570
+ const refs = collectAuthoredAssetRefs(content, doc);
571
+ const root = dirname(resolve(filePath));
572
+ for (const entry of await readdir(root, { withFileTypes: true })) {
573
+ throwIfAborted(signal);
574
+ if (entry.isFile() && NARRATION_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
575
+ refs.add(entry.name);
576
+ }
545
577
  }
546
- for (const [index, block] of doc.blocks.entries()) {
547
- if (typeof block !== "object" || block === null || Array.isArray(block)) {
548
- fail(`"blocks[${index}]" must be an object`);
578
+ refs.add("timing.json");
579
+ for (const ref of [...refs]) {
580
+ if (NARRATION_EXTENSIONS.has(extname(stripUrlSuffix(ref)).toLowerCase())) {
581
+ refs.add(`${stripUrlSuffix(ref)}.timing.json`);
549
582
  }
550
583
  }
551
- if (doc.duration !== void 0 && !isFiniteNumber(doc.duration)) {
552
- fail(`"duration" must be a finite number, got ${describe(doc.duration)}`);
584
+ const rootReal = await realpath(root);
585
+ let fileCount = 0;
586
+ let totalBytes = 0;
587
+ for (const authoredRef of refs) {
588
+ throwIfAborted(signal);
589
+ const safe = normalizeAssetReference(authoredRef);
590
+ if (!safe) continue;
591
+ const absolute = resolve(root, ...safe.split("/"));
592
+ if (!isContainedPath(root, absolute)) continue;
593
+ let assetReal;
594
+ let info;
595
+ try {
596
+ assetReal = await realpath(absolute);
597
+ if (!isContainedPath(rootReal, assetReal)) continue;
598
+ info = await stat(assetReal);
599
+ } catch (error) {
600
+ if (isMissingFileError(error)) continue;
601
+ throw error;
602
+ }
603
+ if (!info.isFile()) continue;
604
+ if (info.size > MAX_RENDER_MEDIA_FILE_BYTES) {
605
+ throw new Error(
606
+ `Sibling asset "${safe}" exceeds the ${formatMiB(MAX_RENDER_MEDIA_FILE_BYTES)} per-file input limit.`
607
+ );
608
+ }
609
+ if (fileCount + 1 > MAX_RENDER_MEDIA_FILES) {
610
+ throw new Error(
611
+ `Bare markdown input references more than ${MAX_RENDER_MEDIA_FILES} sibling assets.`
612
+ );
613
+ }
614
+ if (totalBytes + info.size > MAX_RENDER_MEDIA_TOTAL_BYTES) {
615
+ throw new Error(
616
+ `Sibling assets exceed the ${formatMiB(MAX_RENDER_MEDIA_TOTAL_BYTES)} total input limit.`
617
+ );
618
+ }
619
+ const data = await readBinaryFile(assetReal, signal);
620
+ if (data.byteLength > MAX_RENDER_MEDIA_FILE_BYTES) {
621
+ throw new Error(
622
+ `Sibling asset "${safe}" exceeds the ${formatMiB(MAX_RENDER_MEDIA_FILE_BYTES)} per-file input limit.`
623
+ );
624
+ }
625
+ if (totalBytes + data.byteLength > MAX_RENDER_MEDIA_TOTAL_BYTES) {
626
+ throw new Error(
627
+ `Sibling assets exceed the ${formatMiB(MAX_RENDER_MEDIA_TOTAL_BYTES)} total input limit.`
628
+ );
629
+ }
630
+ await container.writeFile(safe, data, mimeFromExt(safe));
631
+ fileCount += 1;
632
+ totalBytes += data.byteLength;
553
633
  }
554
- if (doc.audio !== void 0) {
555
- if (typeof doc.audio !== "object" || doc.audio === null || Array.isArray(doc.audio)) {
556
- fail('"audio" must be an object');
634
+ return container;
635
+ }
636
+ function collectAuthoredAssetRefs(content, doc) {
637
+ const refs = /* @__PURE__ */ new Set();
638
+ const add = (value) => {
639
+ if (typeof value === "string" && value.trim()) refs.add(value.trim());
640
+ };
641
+ const scanObject = (value, seen = /* @__PURE__ */ new Set()) => {
642
+ if (!value || typeof value !== "object") return;
643
+ if (seen.has(value)) return;
644
+ seen.add(value);
645
+ if (Array.isArray(value)) {
646
+ for (const item of value) scanObject(item, seen);
647
+ return;
557
648
  }
558
- const segments = doc.audio.segments;
559
- if (segments !== void 0) {
560
- if (!Array.isArray(segments)) fail('"audio.segments" must be an array');
561
- for (const [index, segment] of segments.entries()) {
562
- if (typeof segment !== "object" || segment === null) {
563
- fail(`"audio.segments[${index}]" must be an object`);
564
- }
565
- if (!isFiniteNumber(segment.duration)) {
566
- fail(
567
- `"audio.segments[${index}].duration" must be a finite number, got ${describe(segment.duration)}`
568
- );
569
- }
649
+ for (const [key, item] of Object.entries(value)) {
650
+ if (typeof item === "string" && ["src", "url", "heroSrc", "posterSrc", "staticSrc", "videoSrc", "imageSrc"].includes(key)) {
651
+ add(item);
652
+ } else {
653
+ scanObject(item, seen);
570
654
  }
571
655
  }
572
- }
573
- return {
574
- ...doc,
575
- audio: doc.audio ?? { segments: [] }
576
656
  };
657
+ scanObject(doc);
658
+ for (const segment of doc.audio.segments) add(segment.src);
659
+ for (const clip of resolveMediaSchedule(doc)) add(clip.src);
660
+ const patterns = [
661
+ /\b(?:src|href)\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/gi,
662
+ /\b(?:src|audio|video|image|font)\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s}\]]+))/gi,
663
+ /\burl\(\s*(?:"([^"]+)"|'([^']+)'|([^\s)]+))\s*\)/gi,
664
+ /!?\[[^\]]*\]\(\s*<?([^\s)>]+)>?/g
665
+ ];
666
+ for (const pattern of patterns) {
667
+ let match;
668
+ while (match = pattern.exec(content)) add(match.slice(1).find(Boolean));
669
+ }
670
+ return refs;
671
+ }
672
+ function normalizeAssetReference(authoredRef) {
673
+ let value = stripUrlSuffix(authoredRef.trim().replace(/^<|>$/g, ""));
674
+ try {
675
+ value = decodeURIComponent(value);
676
+ } catch {
677
+ return null;
678
+ }
679
+ if (!value || value.includes("\0") || /^[a-z][a-z\d+.-]*:/i.test(value) || posix.isAbsolute(value) || win32.isAbsolute(value) || isAbsolute(value)) {
680
+ return null;
681
+ }
682
+ const parts = value.replace(/\\/g, "/").split("/");
683
+ if (parts.some((part) => part === "..")) return null;
684
+ const normalized = parts.filter((part) => part && part !== ".").join("/");
685
+ return normalized || null;
686
+ }
687
+ function stripUrlSuffix(value) {
688
+ return value.split(/[?#]/, 1)[0] ?? value;
689
+ }
690
+ function isContainedPath(root, candidate) {
691
+ const rel = relative(root, candidate);
692
+ return rel === "" || !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
693
+ }
694
+ function isMissingFileError(error) {
695
+ return typeof error === "object" && error !== null && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
577
696
  }
578
- function isFiniteNumber(value) {
579
- return typeof value === "number" && Number.isFinite(value);
697
+ function formatMiB(bytes) {
698
+ return `${Math.round(bytes / (1024 * 1024))} MB`;
699
+ }
700
+ function assertValidDoc(value, source) {
701
+ const issues = validateDocSchema(value);
702
+ if (issues.length > 0) throw new DocInputValidationError(source, issues);
580
703
  }
581
- function describe(value) {
582
- if (typeof value === "number") return Number.isNaN(value) ? "NaN" : String(value);
583
- if (typeof value === "string") return JSON.stringify(value);
584
- return value === null ? "null" : typeof value;
704
+ function parseDocJson(content, source) {
705
+ let parsed;
706
+ try {
707
+ parsed = JSON.parse(content);
708
+ } catch (error) {
709
+ const detail = error instanceof Error ? error.message : String(error);
710
+ throw new Error(`${source} is not valid JSON: ${detail}`);
711
+ }
712
+ assertValidDoc(parsed, source);
713
+ return parsed;
585
714
  }
586
715
  async function readDocJsonFile(filePath, signal) {
587
716
  const content = await readUtf8File(filePath, signal);
@@ -754,7 +883,7 @@ async function captureDocFrames(doc, container, options) {
754
883
  audio.set(seg.name, data);
755
884
  }
756
885
  }
757
- const mediaSrcs = new Set(resolveMediaSchedule(doc).map((clip) => clip.src));
886
+ const mediaSrcs = new Set(resolveMediaSchedule2(doc).map((clip) => clip.src));
758
887
  for (const block of flattenBlocks2(doc.blocks)) {
759
888
  for (const layer of block.layers ?? []) {
760
889
  if (layer.type === "video") mediaSrcs.add(layer.content.src);
@@ -1005,7 +1134,7 @@ async function renderDocToGif(doc, container, options) {
1005
1134
  options.signal?.throwIfAborted();
1006
1135
  options.onProgress?.("done", 100);
1007
1136
  options.signal?.throwIfAborted();
1008
- const hasAudio = (doc.audio?.segments?.length ?? 0) > 0 || resolveMediaSchedule(doc).some((clip) => clip.kind === "audio");
1137
+ const hasAudio = (doc.audio?.segments?.length ?? 0) > 0 || resolveMediaSchedule2(doc).some((clip) => clip.kind === "audio");
1009
1138
  return {
1010
1139
  duration: capture.totalDuration,
1011
1140
  frameCount,
@@ -1020,8 +1149,30 @@ async function extractThumbnails(options) {
1020
1149
  const { videoPath, outputDir, slug, sizes, force, signal } = options;
1021
1150
  const { existsSync } = await import("fs");
1022
1151
  const { rm: rm2 } = await import("fs/promises");
1023
- const { join: join3 } = await import("path");
1152
+ const { isAbsolute: isAbsolute2, relative: relative2, resolve: resolve2, sep: sep2 } = await import("path");
1024
1153
  signal?.throwIfAborted();
1154
+ if (typeof slug !== "string" || !/^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,126}[A-Za-z0-9])?$/.test(slug)) {
1155
+ throw new TypeError(
1156
+ "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."
1157
+ );
1158
+ }
1159
+ for (const [index, thumb] of sizes.entries()) {
1160
+ if (!Number.isSafeInteger(thumb.width) || thumb.width <= 0) {
1161
+ throw new TypeError(`Thumbnail sizes[${index}].width must be a positive integer.`);
1162
+ }
1163
+ if (!Number.isSafeInteger(thumb.height) || thumb.height <= 0) {
1164
+ throw new TypeError(`Thumbnail sizes[${index}].height must be a positive integer.`);
1165
+ }
1166
+ }
1167
+ const resolvedOutputDir = resolve2(outputDir);
1168
+ const outputPaths = sizes.map((thumb) => {
1169
+ const outputPath = resolve2(resolvedOutputDir, `${slug}-${thumb.width}x${thumb.height}.jpg`);
1170
+ const rel = relative2(resolvedOutputDir, outputPath);
1171
+ if (rel === ".." || rel.startsWith(`..${sep2}`) || isAbsolute2(rel)) {
1172
+ throw new TypeError("Thumbnail output path must remain inside outputDir.");
1173
+ }
1174
+ return outputPath;
1175
+ });
1025
1176
  const ffmpegPath = (await detectFfmpegDetailed(signal))?.path ?? null;
1026
1177
  if (!ffmpegPath) {
1027
1178
  throw new Error(
@@ -1030,9 +1181,9 @@ async function extractThumbnails(options) {
1030
1181
  }
1031
1182
  const generatedPaths = [];
1032
1183
  try {
1033
- for (const thumb of sizes) {
1184
+ for (const [index, thumb] of sizes.entries()) {
1034
1185
  signal?.throwIfAborted();
1035
- const outputPath = join3(outputDir, `${slug}-${thumb.width}x${thumb.height}.jpg`);
1186
+ const outputPath = outputPaths[index];
1036
1187
  if (!force && existsSync(outputPath)) continue;
1037
1188
  try {
1038
1189
  await runFfmpeg(