@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.
@@ -6,7 +6,7 @@ import {
6
6
 
7
7
  // src/api.ts
8
8
  import { readFile as readFile3 } from "fs/promises";
9
- import { resolveMediaSchedule } from "@bendyline/squisq/schemas";
9
+ import { resolveMediaSchedule as resolveMediaSchedule2 } from "@bendyline/squisq/schemas";
10
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";
@@ -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) => {
@@ -330,7 +330,7 @@ function mp4Format() {
330
330
  const animationsEnabled = typeof mp4Opts.animationsEnabled === "boolean" ? mp4Opts.animationsEnabled : MP4_DEFAULTS.animationsEnabled;
331
331
  const outputPath = join(tmpdir(), `squisq-mp4-${randomBytes(8).toString("hex")}.mp4`);
332
332
  try {
333
- const { renderDocToMp4: renderDocToMp42 } = await import("./api-KYHSJM5T.js");
333
+ const { renderDocToMp4: renderDocToMp42 } = await import("./api-PBBQQMRP.js");
334
334
  await renderDocToMp42(input.doc, input.container, {
335
335
  outputPath,
336
336
  fps,
@@ -371,7 +371,7 @@ function gifFormat() {
371
371
  const defaultHeight = portrait ? GIF_DEFAULTS.width : GIF_DEFAULTS.height;
372
372
  const outputPath = join(tmpdir(), `squisq-gif-${randomBytes(8).toString("hex")}.gif`);
373
373
  try {
374
- const { renderDocToGif: renderDocToGif2 } = await import("./api-KYHSJM5T.js");
374
+ const { renderDocToGif: renderDocToGif2 } = await import("./api-PBBQQMRP.js");
375
375
  const result = await renderDocToGif2(input.doc, input.container, {
376
376
  outputPath,
377
377
  fps: typeof gifOpts.fps === "number" ? gifOpts.fps : GIF_DEFAULTS.fps,
@@ -415,13 +415,45 @@ function createCliRegistry() {
415
415
  import { MemoryContentContainer as MemoryContentContainer2 } from "@bendyline/squisq/storage";
416
416
 
417
417
  // src/util/readInput.ts
418
- import { readFile as readFile2, readdir, stat } from "fs/promises";
419
- 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";
420
431
  import { parseMarkdown, stringifyMarkdown } from "@bendyline/squisq/markdown";
421
432
  import { markdownToDoc, resolveAudioMapping } from "@bendyline/squisq/doc";
433
+ import { resolveMediaSchedule, validateDocSchema } from "@bendyline/squisq/schemas";
422
434
  import { MemoryContentContainer } from "@bendyline/squisq/storage";
423
435
  import { zipToContainer } from "@bendyline/squisq-formats/container";
424
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
+ }
425
457
  var MIME_TYPES = {
426
458
  ".md": "text/markdown",
427
459
  ".txt": "text/plain",
@@ -436,7 +468,11 @@ var MIME_TYPES = {
436
468
  ".wav": "audio/wav",
437
469
  ".ogg": "audio/ogg",
438
470
  ".mp4": "video/mp4",
439
- ".webm": "video/webm"
471
+ ".webm": "video/webm",
472
+ ".woff": "font/woff",
473
+ ".woff2": "font/woff2",
474
+ ".ttf": "font/ttf",
475
+ ".otf": "font/otf"
440
476
  };
441
477
  var IMPORTER_EXTS = [".docx", ".pptx", ".pdf", ".xlsx", ".csv", ".html", ".htm"];
442
478
  function mimeFromExt(filePath) {
@@ -462,8 +498,10 @@ async function readInput(inputPath, options) {
462
498
  throwIfAborted(options?.signal);
463
499
  const result = await readInputRaw(inputPath, options);
464
500
  throwIfAborted(options?.signal);
501
+ assertValidDoc(result.doc, inputPath);
465
502
  const doc = await resolveAudioMapping(result.doc, result.container);
466
503
  throwIfAborted(options?.signal);
504
+ assertValidDoc(doc, inputPath);
467
505
  return doc === result.doc ? result : { ...result, doc };
468
506
  }
469
507
  async function readInputRaw(inputPath, options) {
@@ -516,69 +554,160 @@ async function readUtf8File(filePath, signal) {
516
554
  }
517
555
  async function readMarkdownFile(filePath, signal) {
518
556
  const content = await readUtf8File(filePath, signal);
519
- const container = new MemoryContentContainer();
520
- await container.writeDocument(content);
521
- throwIfAborted(signal);
522
557
  const markdownDoc = parseMarkdown(content);
523
- 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" };
524
561
  }
525
- function parseDocJson(content, source) {
526
- let parsed;
527
- try {
528
- parsed = JSON.parse(content);
529
- } catch (error) {
530
- const detail = error instanceof Error ? error.message : String(error);
531
- throw new Error(`${source} is not valid JSON: ${detail}`);
532
- }
533
- const fail = (detail) => {
534
- throw new Error(`${source} is not a valid squisq Doc: ${detail}`);
535
- };
536
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
537
- fail(`expected a JSON object, got ${Array.isArray(parsed) ? "an array" : typeof parsed}`);
538
- }
539
- const doc = parsed;
540
- if (!Array.isArray(doc.blocks)) {
541
- 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
+ }
542
574
  }
543
- for (const [index, block] of doc.blocks.entries()) {
544
- if (typeof block !== "object" || block === null || Array.isArray(block)) {
545
- 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`);
546
579
  }
547
580
  }
548
- if (doc.duration !== void 0 && !isFiniteNumber(doc.duration)) {
549
- 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;
550
630
  }
551
- if (doc.audio !== void 0) {
552
- if (typeof doc.audio !== "object" || doc.audio === null || Array.isArray(doc.audio)) {
553
- 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;
554
645
  }
555
- const segments = doc.audio.segments;
556
- if (segments !== void 0) {
557
- if (!Array.isArray(segments)) fail('"audio.segments" must be an array');
558
- for (const [index, segment] of segments.entries()) {
559
- if (typeof segment !== "object" || segment === null) {
560
- fail(`"audio.segments[${index}]" must be an object`);
561
- }
562
- if (!isFiniteNumber(segment.duration)) {
563
- fail(
564
- `"audio.segments[${index}].duration" must be a finite number, got ${describe(segment.duration)}`
565
- );
566
- }
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);
567
651
  }
568
652
  }
569
- }
570
- return {
571
- ...doc,
572
- audio: doc.audio ?? { segments: [] }
573
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");
574
693
  }
575
- function isFiniteNumber(value) {
576
- return typeof value === "number" && Number.isFinite(value);
694
+ function formatMiB(bytes) {
695
+ return `${Math.round(bytes / (1024 * 1024))} MB`;
696
+ }
697
+ function assertValidDoc(value, source) {
698
+ const issues = validateDocSchema(value);
699
+ if (issues.length > 0) throw new DocInputValidationError(source, issues);
577
700
  }
578
- function describe(value) {
579
- if (typeof value === "number") return Number.isNaN(value) ? "NaN" : String(value);
580
- if (typeof value === "string") return JSON.stringify(value);
581
- 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;
582
711
  }
583
712
  async function readDocJsonFile(filePath, signal) {
584
713
  const content = await readUtf8File(filePath, signal);
@@ -751,7 +880,7 @@ async function captureDocFrames(doc, container, options) {
751
880
  audio.set(seg.name, data);
752
881
  }
753
882
  }
754
- const mediaSrcs = new Set(resolveMediaSchedule(doc).map((clip) => clip.src));
883
+ const mediaSrcs = new Set(resolveMediaSchedule2(doc).map((clip) => clip.src));
755
884
  for (const block of flattenBlocks2(doc.blocks)) {
756
885
  for (const layer of block.layers ?? []) {
757
886
  if (layer.type === "video") mediaSrcs.add(layer.content.src);
@@ -1002,7 +1131,7 @@ async function renderDocToGif(doc, container, options) {
1002
1131
  options.signal?.throwIfAborted();
1003
1132
  options.onProgress?.("done", 100);
1004
1133
  options.signal?.throwIfAborted();
1005
- 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");
1006
1135
  return {
1007
1136
  duration: capture.totalDuration,
1008
1137
  frameCount,
@@ -1017,8 +1146,30 @@ async function extractThumbnails(options) {
1017
1146
  const { videoPath, outputDir, slug, sizes, force, signal } = options;
1018
1147
  const { existsSync } = await import("fs");
1019
1148
  const { rm: rm2 } = await import("fs/promises");
1020
- const { join: join3 } = await import("path");
1149
+ const { isAbsolute: isAbsolute2, relative: relative2, resolve: resolve2, sep: sep2 } = await import("path");
1021
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
+ });
1022
1173
  const ffmpegPath = (await detectFfmpegDetailed(signal))?.path ?? null;
1023
1174
  if (!ffmpegPath) {
1024
1175
  throw new Error(
@@ -1027,9 +1178,9 @@ async function extractThumbnails(options) {
1027
1178
  }
1028
1179
  const generatedPaths = [];
1029
1180
  try {
1030
- for (const thumb of sizes) {
1181
+ for (const [index, thumb] of sizes.entries()) {
1031
1182
  signal?.throwIfAborted();
1032
- const outputPath = join3(outputDir, `${slug}-${thumb.width}x${thumb.height}.jpg`);
1183
+ const outputPath = outputPaths[index];
1033
1184
  if (!force && existsSync(outputPath)) continue;
1034
1185
  try {
1035
1186
  await runFfmpeg(
@@ -1056,6 +1207,7 @@ async function extractThumbnails(options) {
1056
1207
  }
1057
1208
 
1058
1209
  export {
1210
+ DocInputValidationError,
1059
1211
  readInput,
1060
1212
  getFfmpegVersion,
1061
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-VK6WPOSG.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 {