@bendyline/docblocks-cli 2.6.2 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,13 +1,13 @@
1
1
  // src/commands/build.ts
2
2
  import { Command } from "commander";
3
3
  import { mkdir, opendir, readFile, stat as stat2, writeFile } from "fs/promises";
4
- import path4 from "path";
4
+ import path5 from "path";
5
5
 
6
6
  // src/render-html.ts
7
7
  import { realpath, stat } from "fs/promises";
8
8
  import path3 from "path";
9
9
  import { markdownToDoc } from "@bendyline/squisq/doc";
10
- import { parseMarkdown } from "@bendyline/squisq/markdown";
10
+ import { parseMarkdown, readFrontmatterThemeId } from "@bendyline/squisq/markdown";
11
11
  import { collectImagePaths, docToHtml } from "@bendyline/squisq-formats/html";
12
12
  import { PLAYER_BUNDLE } from "@bendyline/squisq-react/standalone-source";
13
13
 
@@ -123,25 +123,35 @@ var DEFAULT_MAX_ASSET_BYTES = 50 * 1024 * 1024;
123
123
  var MAX_SINGLE_ASSET_BYTES = 20 * 1024 * 1024;
124
124
  async function renderMarkdownHtml(markdown, options) {
125
125
  await assertKnownThemeId(options.themeId);
126
+ const warn = options.onWarning ?? ((message) => console.warn(message));
126
127
  const markdownDoc = parseMarkdown(markdown);
127
128
  const doc = markdownToDoc(markdownDoc);
129
+ const themeId = options.themeId ?? (readFrontmatterThemeId(markdownDoc.frontmatter) ? void 0 : await usableFallbackThemeId(options.fallbackThemeId, warn));
128
130
  const images = options.sourcePath && options.assetRoot ? await readReferencedImages(
129
131
  doc,
130
132
  options.sourcePath,
131
133
  options.assetRoot,
132
134
  options.maxAssetBytes ?? DEFAULT_MAX_ASSET_BYTES,
133
135
  options.allowReferencedAsset,
134
- options.onWarning ?? ((message) => console.warn(message))
136
+ warn
135
137
  ) : void 0;
136
138
  return docToHtml(doc, {
137
139
  playerScript: PLAYER_BUNDLE,
138
140
  images,
139
141
  title: options.title,
140
142
  mode: options.mode ?? "static",
141
- themeId: options.themeId,
143
+ themeId,
142
144
  showCodeCopyButton: true
143
145
  });
144
146
  }
147
+ async function usableFallbackThemeId(fallbackThemeId, warn) {
148
+ if (!fallbackThemeId) return void 0;
149
+ if ((await getAvailableThemeIds()).includes(fallbackThemeId)) return fallbackThemeId;
150
+ warn(
151
+ `Ignoring unknown fallback theme "${fallbackThemeId}"; the document's default theme applies.`
152
+ );
153
+ return void 0;
154
+ }
145
155
  async function readReferencedImages(doc, sourcePath, assetRoot, maxAssetBytes, allowReferencedAsset, warn) {
146
156
  const images = /* @__PURE__ */ new Map();
147
157
  const root = await realpathIfPresent(path3.resolve(assetRoot));
@@ -237,8 +247,575 @@ function positiveLimit(value, fallback, label) {
237
247
  return selected;
238
248
  }
239
249
 
250
+ // src/internal/workspace-settings.ts
251
+ import { decodeUtf8Text, parseWorkspacePath as parseWorkspacePath2 } from "@bendyline/docblocks/filesystem";
252
+ import {
253
+ WORKSPACE_SETTINGS_LIMITS,
254
+ WORKSPACE_SETTINGS_PATH,
255
+ parseWorkspaceSettingsText,
256
+ resolveWorkspaceDefaultThemeId
257
+ } from "@bendyline/docblocks/workspace-settings";
258
+
259
+ // src/internal/workspace-io.ts
260
+ import { constants as fsConstants } from "fs";
261
+ import fs2 from "fs/promises";
262
+ import { randomUUID } from "crypto";
263
+ import path4 from "path";
264
+ import {
265
+ FsError,
266
+ mapNodeErrorCodeToFsErrorCode,
267
+ parseFileSystemVersion,
268
+ parseWorkspacePath,
269
+ tryParseWorkspacePath
270
+ } from "@bendyline/docblocks/filesystem";
271
+
272
+ // src/internal/link-support.ts
273
+ var LINK_UNSUPPORTED_CODES = [
274
+ "EPERM",
275
+ "ENOSYS",
276
+ "ENOTSUP",
277
+ "EOPNOTSUPP",
278
+ "EXDEV",
279
+ "EMLINK"
280
+ ];
281
+ function isLinkUnsupportedError(error) {
282
+ return isNodeErrorCode(error, LINK_UNSUPPORTED_CODES);
283
+ }
284
+
285
+ // src/internal/workspace-io.ts
286
+ var NODE_WORKSPACE_IO_LIMITS = {
287
+ maxFileBytes: 256 * 1024 * 1024,
288
+ maxDirectoryEntries: 1e5
289
+ };
290
+ var LISTING_STAT_CONCURRENCY = 16;
291
+ var TEMPORARY_PREFIX = ".docblocks-tmp-";
292
+ async function createNodeWorkspaceIO(rootDir, options = {}) {
293
+ const requested = path4.resolve(rootDir);
294
+ let root;
295
+ try {
296
+ root = await fs2.realpath(requested);
297
+ } catch (error) {
298
+ if (isNodeErrorCode(error, ["ENOENT", "ENOTDIR"])) {
299
+ throw new FsError("not-found", `Workspace folder not found: ${requested}`, {
300
+ operation: "stat"
301
+ });
302
+ }
303
+ throw toFsError(error, "stat", "");
304
+ }
305
+ let info;
306
+ try {
307
+ info = await fs2.stat(root);
308
+ } catch (error) {
309
+ throw toFsError(error, "stat", "");
310
+ }
311
+ if (!info.isDirectory()) {
312
+ throw new FsError("type-mismatch", `Workspace root is not a folder: ${requested}`, {
313
+ operation: "stat"
314
+ });
315
+ }
316
+ return new NodeWorkspaceIO(root, {
317
+ maxFileBytes: positiveLimit(
318
+ options.maxFileBytes,
319
+ NODE_WORKSPACE_IO_LIMITS.maxFileBytes,
320
+ "workspace file byte"
321
+ ),
322
+ maxDirectoryEntries: positiveLimit(
323
+ options.maxDirectoryEntries,
324
+ NODE_WORKSPACE_IO_LIMITS.maxDirectoryEntries,
325
+ "workspace folder entry"
326
+ )
327
+ });
328
+ }
329
+ var NodeWorkspaceIO = class {
330
+ constructor(root, limits) {
331
+ this.root = root;
332
+ this.limits = limits;
333
+ }
334
+ async stat(input) {
335
+ const workspacePath = canonical(input, "stat");
336
+ try {
337
+ const located = await this.locate(workspacePath, "stat");
338
+ if (!located) return null;
339
+ const info = await lstatOrNull(located);
340
+ if (!info) return null;
341
+ return snapshotFor(workspacePath, assertSupportedKind(info, workspacePath, "stat"), info);
342
+ } catch (error) {
343
+ throw toFsError(error, "stat", workspacePath);
344
+ }
345
+ }
346
+ async readFile(input) {
347
+ const workspacePath = canonical(input, "read");
348
+ try {
349
+ const located = await this.locate(workspacePath, "read");
350
+ if (!located) return null;
351
+ const before = await lstatOrNull(located);
352
+ if (!before) return null;
353
+ if (assertSupportedKind(before, workspacePath, "read") !== "file") {
354
+ throw new FsError("type-mismatch", `"${workspacePath}" is a folder, not a file.`, {
355
+ operation: "read",
356
+ path: workspacePath
357
+ });
358
+ }
359
+ this.assertFileSize(before.size, workspacePath, "read");
360
+ return await this.readLocatedFile(located, before, workspacePath);
361
+ } catch (error) {
362
+ if (isNodeErrorCode(error, ["ENOENT", "ENOTDIR"])) return null;
363
+ throw toFsError(error, "read", workspacePath);
364
+ }
365
+ }
366
+ async readDirectory(input) {
367
+ const workspacePath = canonical(input, "list");
368
+ try {
369
+ let directory = this.root;
370
+ if (workspacePath) {
371
+ const located = await this.locate(workspacePath, "list");
372
+ const info = located ? await lstatOrNull(located) : null;
373
+ if (!located || !info) {
374
+ throw new FsError("not-found", `Folder "${workspacePath}" does not exist.`, {
375
+ operation: "list",
376
+ path: workspacePath
377
+ });
378
+ }
379
+ if (assertSupportedKind(info, workspacePath, "list") !== "directory") {
380
+ throw new FsError("type-mismatch", `"${workspacePath}" is a file, not a folder.`, {
381
+ operation: "list",
382
+ path: workspacePath
383
+ });
384
+ }
385
+ directory = located;
386
+ }
387
+ const names = await this.listNames(directory, workspacePath);
388
+ const entries = [];
389
+ for (let index = 0; index < names.length; index += LISTING_STAT_CONCURRENCY) {
390
+ const batch = names.slice(index, index + LISTING_STAT_CONCURRENCY);
391
+ const snapshots = await Promise.all(
392
+ batch.map(async (name) => {
393
+ const info = await lstatOrNull(path4.join(directory, name));
394
+ if (!info || info.isSymbolicLink()) return null;
395
+ const kind = info.isFile() ? "file" : info.isDirectory() ? "directory" : null;
396
+ if (!kind) return null;
397
+ const childPath = parseWorkspacePath(workspacePath ? `${workspacePath}/${name}` : name);
398
+ return snapshotFor(childPath, kind, info);
399
+ })
400
+ );
401
+ for (const snapshot of snapshots) if (snapshot) entries.push(snapshot);
402
+ }
403
+ return Object.freeze(entries.sort(compareSnapshots));
404
+ } catch (error) {
405
+ throw toFsError(error, "list", workspacePath);
406
+ }
407
+ }
408
+ async writeFile(input, data, options = {}) {
409
+ const workspacePath = canonical(input, "write");
410
+ if (!workspacePath) {
411
+ throw new FsError("invalid-path", "The workspace root cannot be written.", {
412
+ operation: "write",
413
+ path: workspacePath
414
+ });
415
+ }
416
+ const bytes = ArrayBuffer.isView(data) ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) : new Uint8Array(data);
417
+ const mode = options.mode ?? "upsert";
418
+ const expectedVersion = options.expectedVersion;
419
+ let temporary = null;
420
+ try {
421
+ this.assertFileSize(BigInt(bytes.byteLength), workspacePath, "write");
422
+ const parent = await this.ensureParent(workspacePath, options.createParents === true);
423
+ const target = path4.join(parent, basename(workspacePath));
424
+ let current = await lstatOrNull(target);
425
+ assertWritable(current, workspacePath, mode, expectedVersion);
426
+ temporary = path4.join(parent, `${TEMPORARY_PREFIX}${randomUUID()}`);
427
+ const handle = await fs2.open(temporary, "wx", 438);
428
+ try {
429
+ await handle.writeFile(bytes);
430
+ if (current?.isFile()) await handle.chmod(Number(current.mode & 0o7777n));
431
+ } finally {
432
+ await handle.close();
433
+ }
434
+ current = await lstatOrNull(target);
435
+ assertWritable(current, workspacePath, mode, expectedVersion);
436
+ if (!current && (mode === "create" || expectedVersion === null)) {
437
+ await publishExclusive(temporary, target, bytes, workspacePath, mode);
438
+ } else {
439
+ await fs2.rename(temporary, target);
440
+ temporary = null;
441
+ }
442
+ const written = await fs2.lstat(target, { bigint: true });
443
+ const snapshot = snapshotFor(workspacePath, "file", written);
444
+ if (snapshot.kind !== "file") throw new Error("Unreachable: wrote a non-file entry.");
445
+ return snapshot;
446
+ } catch (error) {
447
+ throw toFsError(error, "write", workspacePath);
448
+ } finally {
449
+ if (temporary) await fs2.rm(temporary, { force: true }).catch(() => void 0);
450
+ }
451
+ }
452
+ /**
453
+ * Physical absolute path for `workspacePath`, with its parent proven to be
454
+ * inside the root, or null when a parent is missing or is a file.
455
+ */
456
+ async locate(workspacePath, operation) {
457
+ if (!workspacePath) return this.root;
458
+ const segments = workspacePath.split("/");
459
+ const name = segments.pop();
460
+ let parent;
461
+ try {
462
+ parent = await fs2.realpath(path4.join(this.root, ...segments));
463
+ } catch (error) {
464
+ if (isNodeErrorCode(error, ["ENOENT", "ENOTDIR"])) return null;
465
+ throw error;
466
+ }
467
+ if (!isPathInside(this.root, parent)) throw escapeError(workspacePath, operation);
468
+ return path4.join(parent, name);
469
+ }
470
+ /** Resolve (and optionally create) the physical parent folder of a write. */
471
+ async ensureParent(workspacePath, createParents) {
472
+ const segments = workspacePath.split("/").slice(0, -1);
473
+ let current = this.root;
474
+ let walked = "";
475
+ for (const segment of segments) {
476
+ walked = walked ? `${walked}/${segment}` : segment;
477
+ const next = path4.join(current, segment);
478
+ let info = await lstatOrNull(next);
479
+ if (!info) {
480
+ if (!createParents) {
481
+ throw new FsError("not-found", `Parent folder "${walked}" does not exist.`, {
482
+ operation: "write",
483
+ path: workspacePath
484
+ });
485
+ }
486
+ try {
487
+ await fs2.mkdir(next);
488
+ } catch (error) {
489
+ if (!isNodeErrorCode(error, "EEXIST")) throw error;
490
+ }
491
+ info = await fs2.lstat(next, { bigint: true });
492
+ }
493
+ if (info.isSymbolicLink()) {
494
+ const physical = await fs2.realpath(next);
495
+ if (!isPathInside(this.root, physical)) throw escapeError(workspacePath, "write");
496
+ if (!(await fs2.stat(physical)).isDirectory()) throw parentFileError(walked, workspacePath);
497
+ current = physical;
498
+ } else if (info.isDirectory()) {
499
+ current = next;
500
+ } else {
501
+ throw parentFileError(walked, workspacePath);
502
+ }
503
+ }
504
+ const physicalParent = await fs2.realpath(current);
505
+ if (!isPathInside(this.root, physicalParent)) throw escapeError(workspacePath, "write");
506
+ return physicalParent;
507
+ }
508
+ async listNames(directory, workspacePath) {
509
+ const names = [];
510
+ let seen = 0;
511
+ const handle = await fs2.opendir(directory);
512
+ for await (const dirent of handle) {
513
+ seen += 1;
514
+ if (seen > this.limits.maxDirectoryEntries) {
515
+ throw new FsError(
516
+ "not-supported",
517
+ `Folder "${workspacePath || "."}" has more than ${this.limits.maxDirectoryEntries} entries.`,
518
+ { operation: "list", path: workspacePath }
519
+ );
520
+ }
521
+ if (dirent.isSymbolicLink()) continue;
522
+ if (tryParseWorkspacePath(dirent.name) !== dirent.name) continue;
523
+ names.push(dirent.name);
524
+ }
525
+ return names;
526
+ }
527
+ async readLocatedFile(located, before, workspacePath) {
528
+ const noFollow = typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0;
529
+ const handle = await fs2.open(located, fsConstants.O_RDONLY | noFollow);
530
+ try {
531
+ const opened = await handle.stat({ bigint: true });
532
+ if (!opened.isFile() || opened.ino !== before.ino) throw changedError(workspacePath);
533
+ const physical = await fs2.realpath(located);
534
+ if (!isPathInside(this.root, physical)) throw escapeError(workspacePath, "read");
535
+ this.assertFileSize(opened.size, workspacePath, "read");
536
+ const size = Number(opened.size);
537
+ const buffer = new Uint8Array(size);
538
+ let offset = 0;
539
+ while (offset < size) {
540
+ const { bytesRead } = await handle.read(buffer, offset, size - offset, offset);
541
+ if (bytesRead === 0) break;
542
+ offset += bytesRead;
543
+ }
544
+ const probe = new Uint8Array(1);
545
+ const grew = (await handle.read(probe, 0, 1, offset)).bytesRead > 0;
546
+ const after = await handle.stat({ bigint: true });
547
+ if (grew || offset !== size || !sameFileState(opened, after)) {
548
+ throw changedError(workspacePath);
549
+ }
550
+ const entry = snapshotFor(workspacePath, "file", opened);
551
+ if (entry.kind !== "file") throw new Error("Unreachable: read a non-file entry.");
552
+ return Object.freeze({ entry, data: buffer.buffer });
553
+ } finally {
554
+ await handle.close();
555
+ }
556
+ }
557
+ assertFileSize(size, workspacePath, operation) {
558
+ if (size > BigInt(this.limits.maxFileBytes)) {
559
+ throw new FsError(
560
+ "not-supported",
561
+ `"${workspacePath}" is larger than the ${this.limits.maxFileBytes}-byte workspace file limit.`,
562
+ { operation, path: workspacePath }
563
+ );
564
+ }
565
+ }
566
+ };
567
+ function canonical(input, operation) {
568
+ try {
569
+ return parseWorkspacePath(input);
570
+ } catch (error) {
571
+ throw toFsError(error, operation, input);
572
+ }
573
+ }
574
+ function basename(workspacePath) {
575
+ const slash = workspacePath.lastIndexOf("/");
576
+ return slash < 0 ? workspacePath : workspacePath.slice(slash + 1);
577
+ }
578
+ async function lstatOrNull(absolutePath) {
579
+ try {
580
+ return await fs2.lstat(absolutePath, { bigint: true });
581
+ } catch (error) {
582
+ if (isNodeErrorCode(error, ["ENOENT", "ENOTDIR"])) return null;
583
+ throw error;
584
+ }
585
+ }
586
+ function assertSupportedKind(info, workspacePath, operation) {
587
+ if (info.isSymbolicLink()) {
588
+ throw new FsError(
589
+ "not-supported",
590
+ `"${workspacePath}" is a symbolic link; workspace outputs never follow links.`,
591
+ { operation, path: workspacePath }
592
+ );
593
+ }
594
+ if (info.isFile()) return "file";
595
+ if (info.isDirectory()) return "directory";
596
+ throw new FsError("not-supported", `"${workspacePath}" is not a regular file or folder.`, {
597
+ operation,
598
+ path: workspacePath
599
+ });
600
+ }
601
+ function versionFor(kind, info) {
602
+ return parseFileSystemVersion(
603
+ `node-v1:${kind}:ino=${info.ino}:size=${kind === "file" ? info.size : 0n}:mtime-ns=${info.mtimeNs}`
604
+ );
605
+ }
606
+ function snapshotFor(workspacePath, kind, info) {
607
+ const base = {
608
+ path: workspacePath,
609
+ name: basename(workspacePath),
610
+ version: versionFor(kind, info),
611
+ lastModified: info.mtime.toISOString()
612
+ };
613
+ if (kind === "directory") return Object.freeze({ ...base, kind, size: null });
614
+ const size = Number(info.size);
615
+ if (!Number.isSafeInteger(size)) {
616
+ throw new FsError("not-supported", `"${workspacePath}" is too large to describe.`, {
617
+ path: workspacePath
618
+ });
619
+ }
620
+ return Object.freeze({ ...base, kind, size });
621
+ }
622
+ function sameFileState(left, right) {
623
+ return left.ino === right.ino && left.size === right.size && left.mtimeNs === right.mtimeNs;
624
+ }
625
+ function assertWritable(current, workspacePath, mode, expectedVersion) {
626
+ if (current) {
627
+ const kind = assertSupportedKind(current, workspacePath, "write");
628
+ if (kind === "directory") {
629
+ throw new FsError("type-mismatch", `"${workspacePath}" is a folder; it cannot be replaced.`, {
630
+ operation: "write",
631
+ path: workspacePath
632
+ });
633
+ }
634
+ if (mode === "create") {
635
+ throw new FsError("already-exists", `"${workspacePath}" already exists.`, {
636
+ operation: "write",
637
+ path: workspacePath
638
+ });
639
+ }
640
+ } else if (mode === "replace") {
641
+ throw new FsError("not-found", `"${workspacePath}" does not exist.`, {
642
+ operation: "write",
643
+ path: workspacePath
644
+ });
645
+ }
646
+ if (expectedVersion === void 0) return;
647
+ const matches = expectedVersion === null ? !current : !!current && versionFor("file", current) === expectedVersion;
648
+ if (!matches) {
649
+ throw new FsError("conflict", `"${workspacePath}" changed since it was read.`, {
650
+ operation: "write",
651
+ path: workspacePath,
652
+ retryable: true
653
+ });
654
+ }
655
+ }
656
+ async function publishExclusive(temporary, target, bytes, workspacePath, mode) {
657
+ try {
658
+ await fs2.link(temporary, target);
659
+ return;
660
+ } catch (error) {
661
+ if (isNodeErrorCode(error, "EEXIST")) throw existsRaceError(workspacePath, mode);
662
+ if (!isLinkUnsupportedError(error)) throw error;
663
+ }
664
+ let handle;
665
+ try {
666
+ handle = await fs2.open(target, "wx", 438);
667
+ } catch (error) {
668
+ if (isNodeErrorCode(error, "EEXIST")) throw existsRaceError(workspacePath, mode);
669
+ throw error;
670
+ }
671
+ try {
672
+ await handle.writeFile(bytes);
673
+ } finally {
674
+ await handle.close();
675
+ }
676
+ }
677
+ function existsRaceError(workspacePath, mode) {
678
+ return mode === "create" ? new FsError("already-exists", `"${workspacePath}" already exists.`, {
679
+ operation: "write",
680
+ path: workspacePath
681
+ }) : new FsError("conflict", `"${workspacePath}" was created by another writer.`, {
682
+ operation: "write",
683
+ path: workspacePath,
684
+ retryable: true
685
+ });
686
+ }
687
+ function escapeError(workspacePath, operation) {
688
+ return new FsError(
689
+ "path-escape",
690
+ `"${workspacePath}" resolves outside the workspace folder through a symbolic link.`,
691
+ { operation, path: workspacePath }
692
+ );
693
+ }
694
+ function parentFileError(parent, workspacePath) {
695
+ return new FsError("type-mismatch", `"${parent}" is a file, not a folder.`, {
696
+ operation: "write",
697
+ path: workspacePath
698
+ });
699
+ }
700
+ function changedError(workspacePath) {
701
+ return new FsError("conflict", `"${workspacePath}" changed while it was being read.`, {
702
+ operation: "read",
703
+ path: workspacePath,
704
+ retryable: true
705
+ });
706
+ }
707
+ function compareSnapshots(left, right) {
708
+ if (left.kind !== right.kind) return left.kind === "directory" ? -1 : 1;
709
+ return left.name < right.name ? -1 : left.name > right.name ? 1 : 0;
710
+ }
711
+ function toFsError(error, operation, workspacePath) {
712
+ if (error instanceof FsError) return error;
713
+ const message = error instanceof Error ? error.message : String(error);
714
+ const code = nodeErrorCode(error);
715
+ if (code === "ELOOP") {
716
+ return new FsError(
717
+ "not-supported",
718
+ `"${workspacePath}" is a symbolic link; workspace outputs never follow links.`,
719
+ { operation, path: workspacePath }
720
+ );
721
+ }
722
+ if (code) {
723
+ return new FsError(
724
+ mapNodeErrorCodeToFsErrorCode(code),
725
+ `Could not ${OPERATION_VERBS[operation]} "${workspacePath || "."}": ${message}`,
726
+ { operation, path: workspacePath }
727
+ );
728
+ }
729
+ return new FsError("io", message, { operation, path: workspacePath });
730
+ }
731
+ function nodeErrorCode(error) {
732
+ if (!(error instanceof Error) || !("code" in error)) return null;
733
+ const { code } = error;
734
+ return typeof code === "string" ? code : null;
735
+ }
736
+ var OPERATION_VERBS = {
737
+ "parse-path": "parse",
738
+ read: "read",
739
+ stat: "inspect",
740
+ list: "list",
741
+ write: "write",
742
+ "create-directory": "create",
743
+ remove: "remove",
744
+ move: "move",
745
+ snapshot: "snapshot",
746
+ watch: "watch",
747
+ dispose: "close"
748
+ };
749
+
750
+ // src/internal/workspace-settings.ts
751
+ var MAX_SETTINGS_FILE_BYTES = WORKSPACE_SETTINGS_LIMITS.maxFileBytes + 3;
752
+ async function readWorkspaceSettings(io) {
753
+ const settingsPath = parseWorkspacePath2(WORKSPACE_SETTINGS_PATH);
754
+ const info = await io.stat(settingsPath);
755
+ if (!info) return { status: "missing" };
756
+ if (info.kind !== "file") {
757
+ return { status: "invalid", message: `${WORKSPACE_SETTINGS_PATH} is a folder, not a file.` };
758
+ }
759
+ if (info.size > MAX_SETTINGS_FILE_BYTES) return tooLarge();
760
+ const read = await io.readFile(settingsPath);
761
+ if (!read) return { status: "missing" };
762
+ if (read.data.byteLength > MAX_SETTINGS_FILE_BYTES) return tooLarge();
763
+ let text;
764
+ try {
765
+ text = decodeUtf8Text(read.data, {
766
+ label: "Workspace settings",
767
+ path: WORKSPACE_SETTINGS_PATH
768
+ });
769
+ } catch (error) {
770
+ return {
771
+ status: "invalid",
772
+ message: error instanceof Error ? error.message : `${WORKSPACE_SETTINGS_PATH} is not UTF-8.`
773
+ };
774
+ }
775
+ return parseWorkspaceSettingsText(text);
776
+ }
777
+ function describeWorkspaceSettingsProblem(message) {
778
+ return message.includes(WORKSPACE_SETTINGS_PATH) ? message : `${WORKSPACE_SETTINGS_PATH}: ${message}`;
779
+ }
780
+ function tooLarge() {
781
+ return {
782
+ status: "invalid",
783
+ message: `${WORKSPACE_SETTINGS_PATH} is larger than ${WORKSPACE_SETTINGS_LIMITS.maxFileBytes / 1024} KiB.`
784
+ };
785
+ }
786
+ async function createWorkspaceThemeFallback(rootDir, warn) {
787
+ const io = await createNodeWorkspaceIO(rootDir);
788
+ let lastWarning = null;
789
+ const report = (message) => {
790
+ if (message !== null && message !== lastWarning) warn(message);
791
+ lastWarning = message;
792
+ };
793
+ return {
794
+ async resolve() {
795
+ const result = await readWorkspaceSettings(io);
796
+ if (result.status === "missing") {
797
+ report(null);
798
+ return void 0;
799
+ }
800
+ if (result.status !== "ok") {
801
+ report(`Ignoring workspace settings. ${describeWorkspaceSettingsProblem(result.message)}`);
802
+ return void 0;
803
+ }
804
+ const themeId = resolveWorkspaceDefaultThemeId(result.settings);
805
+ if (themeId && !(await getAvailableThemeIds()).includes(themeId)) {
806
+ report(
807
+ `Ignoring workspace default theme "${themeId}" from ${WORKSPACE_SETTINGS_PATH}: it is not a known theme.`
808
+ );
809
+ return void 0;
810
+ }
811
+ report(null);
812
+ return themeId;
813
+ }
814
+ };
815
+ }
816
+
240
817
  // src/commands/build.ts
241
- import { decodeUtf8Text } from "@bendyline/docblocks/filesystem";
818
+ import { decodeUtf8Text as decodeUtf8Text2 } from "@bendyline/docblocks/filesystem";
242
819
  var DEFAULT_MAX_BUILD_ENTRIES = 1e5;
243
820
  var DEFAULT_MAX_BUILD_DEPTH = 64;
244
821
  var DEFAULT_MAX_BUILD_INPUT_BYTES = 20 * 1024 * 1024;
@@ -247,8 +824,8 @@ var DEFAULT_MAX_BUILD_OUTPUT_BYTES = 128 * 1024 * 1024;
247
824
  var DEFAULT_MAX_BUILD_TOTAL_OUTPUT_BYTES = 1024 * 1024 * 1024;
248
825
  async function runBuild(opts) {
249
826
  await assertKnownThemeId(opts.theme);
250
- const inputDir = path4.resolve(opts.input);
251
- const outputDir = path4.resolve(opts.output);
827
+ const inputDir = path5.resolve(opts.input);
828
+ const outputDir = path5.resolve(opts.output);
252
829
  let inputStat;
253
830
  try {
254
831
  inputStat = await stat2(inputDir);
@@ -257,6 +834,8 @@ async function runBuild(opts) {
257
834
  throw new Error(`Input directory not found: ${inputDir}`);
258
835
  }
259
836
  if (!inputStat.isDirectory()) throw new Error(`Input is not a directory: ${inputDir}`);
837
+ const warn = opts.onWarning ?? ((message) => console.warn(message));
838
+ const fallbackThemeId = opts.theme || opts.ignoreWorkspaceSettings ? void 0 : await (await createWorkspaceThemeFallback(inputDir, warn)).resolve();
260
839
  const markdownFiles = await listMarkdownFiles(inputDir, {
261
840
  maxEntries: positiveLimit(opts.maxEntries, DEFAULT_MAX_BUILD_ENTRIES, "build entry"),
262
841
  maxDepth: positiveLimit(opts.maxDepth, DEFAULT_MAX_BUILD_DEPTH, "build depth")
@@ -271,8 +850,8 @@ async function runBuild(opts) {
271
850
  let totalInputBytes = 0;
272
851
  let totalOutputBytes = 0;
273
852
  for (const sourcePath of markdownFiles) {
274
- const relativePath = path4.relative(inputDir, sourcePath);
275
- const outputPath = path4.join(outputDir, replaceMarkdownExtension(relativePath));
853
+ const relativePath = path5.relative(inputDir, sourcePath);
854
+ const outputPath = path5.join(outputDir, replaceMarkdownExtension(relativePath));
276
855
  const sourceBytes = await readFile(sourcePath);
277
856
  if (sourceBytes.byteLength > byteLimits.maxInputBytes) {
278
857
  throw new Error(
@@ -285,13 +864,15 @@ async function runBuild(opts) {
285
864
  `Build inputs exceed the ${byteLimits.maxTotalInputBytes}-byte aggregate limit.`
286
865
  );
287
866
  }
288
- const source = decodeUtf8Text(sourceBytes, { label: "Build input", path: sourcePath });
867
+ const source = decodeUtf8Text2(sourceBytes, { label: "Build input", path: sourcePath });
289
868
  const html = await renderMarkdownHtml(source, {
290
869
  title: titleFromPath(relativePath),
291
870
  sourcePath,
292
871
  assetRoot: inputDir,
293
872
  themeId: opts.theme,
294
- mode: "static"
873
+ fallbackThemeId,
874
+ mode: "static",
875
+ onWarning: warn
295
876
  });
296
877
  const outputBytes = Buffer.byteLength(html, "utf8");
297
878
  if (outputBytes > byteLimits.maxOutputBytes) {
@@ -305,18 +886,22 @@ async function runBuild(opts) {
305
886
  `Build outputs exceed the ${byteLimits.maxTotalOutputBytes}-byte aggregate limit.`
306
887
  );
307
888
  }
308
- await mkdir(path4.dirname(outputPath), { recursive: true });
889
+ await mkdir(path5.dirname(outputPath), { recursive: true });
309
890
  await writeFile(outputPath, html, "utf-8");
310
891
  builtFiles.push(outputPath);
311
892
  }
312
893
  return { inputDir, outputDir, builtFiles };
313
894
  }
314
- 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) => {
895
+ 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(
896
+ "--ignore-workspace-settings",
897
+ "do not apply the default theme from <input>/.docblocks/workspace.json"
898
+ ).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) => {
315
899
  try {
316
900
  const result = await runBuild({
317
901
  input: opts.input,
318
902
  output: opts.output,
319
903
  theme: opts.theme,
904
+ ignoreWorkspaceSettings: opts.ignoreWorkspaceSettings,
320
905
  maxInputBytes: parseOptionalByteLimit("--max-input-bytes", opts.maxInputBytes),
321
906
  maxTotalInputBytes: parseOptionalByteLimit(
322
907
  "--max-total-input-bytes",
@@ -400,7 +985,7 @@ async function listMarkdownFiles(root, limits) {
400
985
  if (entryCount > limits.maxEntries) {
401
986
  throw new Error(`Build traversal exceeded ${limits.maxEntries} filesystem entries.`);
402
987
  }
403
- const absolutePath = path4.join(current.directory, entry.name);
988
+ const absolutePath = path5.join(current.directory, entry.name);
404
989
  if (entry.isDirectory()) {
405
990
  if (current.depth >= limits.maxDepth) {
406
991
  throw new Error(`Build traversal exceeded a depth of ${limits.maxDepth}.`);
@@ -420,7 +1005,7 @@ function replaceMarkdownExtension(filePath) {
420
1005
  return filePath.replace(/\.(md|markdown)$/i, ".html");
421
1006
  }
422
1007
  function titleFromPath(filePath) {
423
- const baseName = path4.basename(filePath).replace(/\.(md|markdown)$/i, "");
1008
+ const baseName = path5.basename(filePath).replace(/\.(md|markdown)$/i, "");
424
1009
  return baseName || "DocBlocks Document";
425
1010
  }
426
1011
 
@@ -437,23 +1022,10 @@ import {
437
1022
  rm,
438
1023
  stat as stat3
439
1024
  } from "fs/promises";
440
- import { randomUUID } from "crypto";
441
- import { dirname, basename, extname, join, resolve } from "path";
1025
+ import { randomUUID as randomUUID2 } from "crypto";
1026
+ import { dirname, basename as basename2, extname, join, resolve } from "path";
442
1027
  import { Command as Command2 } from "commander";
443
1028
 
444
- // src/internal/link-support.ts
445
- var LINK_UNSUPPORTED_CODES = [
446
- "EPERM",
447
- "ENOSYS",
448
- "ENOTSUP",
449
- "EOPNOTSUPP",
450
- "EXDEV",
451
- "EMLINK"
452
- ];
453
- function isLinkUnsupportedError(error) {
454
- return isNodeErrorCode(error, LINK_UNSUPPORTED_CODES);
455
- }
456
-
457
1029
  // src/internal/cancellation.ts
458
1030
  function cancellationError(message) {
459
1031
  const error = new Error(message);
@@ -536,7 +1108,7 @@ async function runConvert(inputPath, opts) {
536
1108
  throwIfAborted2(opts.signal);
537
1109
  const resolvedInput = resolve(inputPath);
538
1110
  const outputDir = opts.outputDir ? resolve(opts.outputDir) : dirname(resolvedInput);
539
- const inputBasename = basename(resolvedInput);
1111
+ const inputBasename = basename2(resolvedInput);
540
1112
  const inputExt = extname(inputBasename);
541
1113
  const baseName = inputExt ? inputBasename.slice(0, -inputExt.length) : inputBasename;
542
1114
  const maxInputBytes = positiveLimit(
@@ -678,7 +1250,7 @@ async function runConvert(inputPath, opts) {
678
1250
  if (totalOutputBytes > maxTotalOutputBytes) {
679
1251
  throw new Error(`Conversion outputs exceed the ${maxTotalOutputBytes}-byte aggregate limit.`);
680
1252
  }
681
- const suggestedFilename = basename(conversion.suggestedFilename);
1253
+ const suggestedFilename = basename2(conversion.suggestedFilename);
682
1254
  const outPath = join(outputDir, suggestedFilename);
683
1255
  const outputPathKey = process.platform === "win32" ? outPath.toLowerCase() : outPath;
684
1256
  if (outputPathKeys.has(outputPathKey)) {
@@ -794,7 +1366,7 @@ async function publishConversionBatch(outputs, outputDir, allowOverwrite, signal
794
1366
  throwIfAborted2(signal);
795
1367
  const output = outputs[index];
796
1368
  if (!output) continue;
797
- const stagedPath = join(stagingDirectory, `${index}-${randomUUID()}.stage`);
1369
+ const stagedPath = join(stagingDirectory, `${index}-${randomUUID2()}.stage`);
798
1370
  await writePrivateFile(stagedPath, output.bytes);
799
1371
  stagedOutputs.push({ outputPath: output.path, stagedPath, bytes: output.bytes });
800
1372
  }
@@ -824,7 +1396,7 @@ async function publishConversionBatch(outputs, outputDir, allowOverwrite, signal
824
1396
  throw new Error(`Refusing to replace a non-file destination: ${output.outputPath}`);
825
1397
  }
826
1398
  if (existing) {
827
- state.backupPath = join(stagingDirectory, `${index}-${randomUUID()}.backup`);
1399
+ state.backupPath = join(stagingDirectory, `${index}-${randomUUID2()}.backup`);
828
1400
  await rename(output.outputPath, state.backupPath);
829
1401
  }
830
1402
  await rename(output.stagedPath, output.outputPath);
@@ -852,8 +1424,8 @@ async function publishConversionBatch(outputs, outputDir, allowOverwrite, signal
852
1424
  }
853
1425
  }
854
1426
  }
855
- async function writePrivateFile(path6, bytes) {
856
- const handle = await open2(path6, "wx", 384);
1427
+ async function writePrivateFile(path7, bytes) {
1428
+ const handle = await open2(path7, "wx", 384);
857
1429
  try {
858
1430
  await handle.writeFile(bytes);
859
1431
  await handle.sync();
@@ -912,9 +1484,9 @@ async function assertReplaceableOutputs(outputPaths) {
912
1484
  }
913
1485
  }
914
1486
  }
915
- async function lstatIfPresent(path6) {
1487
+ async function lstatIfPresent(path7) {
916
1488
  try {
917
- return await lstat(path6);
1489
+ return await lstat(path7);
918
1490
  } catch (error) {
919
1491
  if (isNodeErrorCode(error, ["ENOENT", "ENOTDIR"])) return null;
920
1492
  throw error;
@@ -933,11 +1505,11 @@ function throwIfAborted2(signal) {
933
1505
  }
934
1506
  async function applyTransformToMarkdown(markdownDoc, container, transformStyle, themeId) {
935
1507
  const { markdownToDoc: markdownToDoc2, docToMarkdown } = await import("@bendyline/squisq/doc");
936
- const { readFrontmatterThemeId } = await import("@bendyline/squisq/markdown");
1508
+ const { readFrontmatterThemeId: readFrontmatterThemeId2 } = await import("@bendyline/squisq/markdown");
937
1509
  const { applyTransform, extractDocImages } = await import("@bendyline/squisq/transform");
938
1510
  const doc = markdownToDoc2(markdownDoc);
939
1511
  const images = extractDocImages(doc.blocks);
940
- const effectiveTheme = themeId ?? readFrontmatterThemeId(markdownDoc.frontmatter);
1512
+ const effectiveTheme = themeId ?? readFrontmatterThemeId2(markdownDoc.frontmatter);
941
1513
  const result = applyTransform(doc, transformStyle, { themeId: effectiveTheme, images });
942
1514
  const projected = projectTransformOntoMarkdownSource(doc, result.doc);
943
1515
  const transformedMarkdown = docToMarkdown(projected);
@@ -1127,7 +1699,7 @@ function isConversionErrorMetadata(error) {
1127
1699
 
1128
1700
  // src/commands/video.ts
1129
1701
  import { lstat as lstat2, mkdir as mkdir3 } from "fs/promises";
1130
- import { dirname as dirname2, basename as basename2, extname as extname2, resolve as resolve2 } from "path";
1702
+ import { dirname as dirname2, basename as basename3, extname as extname2, resolve as resolve2 } from "path";
1131
1703
  import { Command as Command3 } from "commander";
1132
1704
  import {
1133
1705
  resolveDimensions,
@@ -1169,7 +1741,7 @@ async function runVideo(inputPath, opts, onProgress, dependencies = {}) {
1169
1741
  width: opts.width,
1170
1742
  height: opts.height
1171
1743
  });
1172
- const inputBasename = basename2(resolvedInput);
1744
+ const inputBasename = basename3(resolvedInput);
1173
1745
  const inputExt = extname2(inputBasename);
1174
1746
  const baseName = inputExt ? inputBasename.slice(0, -inputExt.length) : inputBasename;
1175
1747
  const outputPath = opts.output ? resolve2(opts.output) : resolve2(dirname2(resolvedInput), `${baseName}.mp4`);
@@ -1307,7 +1879,7 @@ function selectVideoOutput(positional, option) {
1307
1879
  import { open as open3 } from "fs/promises";
1308
1880
  import { resolve as resolve3 } from "path";
1309
1881
  import { Command as Command4 } from "commander";
1310
- import { decodeUtf8Text as decodeUtf8Text2 } from "@bendyline/docblocks/filesystem";
1882
+ import { decodeUtf8Text as decodeUtf8Text3 } from "@bendyline/docblocks/filesystem";
1311
1883
  var DEFAULT_MAX_PARSE_INPUT_BYTES = 20 * 1024 * 1024;
1312
1884
  var DEFAULT_MAX_PARSE_OUTPUT_BYTES = 128 * 1024 * 1024;
1313
1885
  async function runParse(inputPath, options = {}) {
@@ -1323,7 +1895,7 @@ async function runParse(inputPath, options = {}) {
1323
1895
  "parse output byte"
1324
1896
  );
1325
1897
  const bytes = await readBoundedFile(resolvedInput, maxInputBytes);
1326
- const content = decodeUtf8Text2(bytes, { label: "Parse input", path: resolvedInput });
1898
+ const content = decodeUtf8Text3(bytes, { label: "Parse input", path: resolvedInput });
1327
1899
  const { parseMarkdown: parseMarkdown2 } = await import("@bendyline/squisq/markdown");
1328
1900
  const markdownDoc = parseMarkdown2(content);
1329
1901
  const stats = {
@@ -1386,12 +1958,98 @@ var parseCommand = new Command4("parse").description("Parse a markdown file and
1386
1958
  }
1387
1959
  });
1388
1960
 
1961
+ // src/commands/workspace.ts
1962
+ import { Command as Command5 } from "commander";
1963
+ import { FsError as FsError2 } from "@bendyline/docblocks/filesystem";
1964
+ import {
1965
+ WORKSPACE_SETTINGS_PATH as WORKSPACE_SETTINGS_PATH2,
1966
+ refreshWorkspaceOutputs,
1967
+ resolveCatalogOutputs
1968
+ } from "@bendyline/docblocks/workspace-settings";
1969
+ import { htmlToMarkdownDocSync, markdownDocToPlainHtml } from "@bendyline/squisq-formats/html";
1970
+ async function runWorkspaceRefresh(options = {}) {
1971
+ const io = await createNodeWorkspaceIO(options.dir ?? ".");
1972
+ const dryRun = options.dryRun === true;
1973
+ const settingsRead = await readWorkspaceSettings(io);
1974
+ if (settingsRead.status === "missing") {
1975
+ return { root: io.root, status: "missing", documentCount: 0, outputs: [], dryRun };
1976
+ }
1977
+ if (settingsRead.status !== "ok") {
1978
+ throw new Error(describeWorkspaceSettingsProblem(settingsRead.message));
1979
+ }
1980
+ const settings = settingsRead.settings;
1981
+ const resolved = resolveCatalogOutputs(settings);
1982
+ if (!resolved) {
1983
+ return { root: io.root, status: "disabled", documentCount: 0, outputs: [], dryRun };
1984
+ }
1985
+ const warn = options.onWarning ?? ((message) => console.warn(message));
1986
+ const catalogThemeId = resolved.html?.themeId;
1987
+ if (catalogThemeId && !(await getAvailableThemeIds()).includes(catalogThemeId)) {
1988
+ warn(
1989
+ `Catalog theme "${catalogThemeId}" from ${WORKSPACE_SETTINGS_PATH2} is not a known theme; the page renders with the standard theme.`
1990
+ );
1991
+ }
1992
+ const refresh = () => refreshWorkspaceOutputs(io, settings, renderer, {
1993
+ signal: options.signal,
1994
+ force: options.force === true,
1995
+ dryRun,
1996
+ limits: options.limits
1997
+ });
1998
+ let result;
1999
+ try {
2000
+ result = await refresh();
2001
+ } catch (error) {
2002
+ if (!(error instanceof FsError2) || error.code !== "conflict") throw error;
2003
+ result = await refresh();
2004
+ }
2005
+ return {
2006
+ root: io.root,
2007
+ status: "refreshed",
2008
+ documentCount: result.documentCount,
2009
+ outputs: result.outputs,
2010
+ dryRun
2011
+ };
2012
+ }
2013
+ var renderer = {
2014
+ renderCatalogHtml: (document, { title, themeId }) => markdownDocToPlainHtml(document, { title, themeId }),
2015
+ importHtml: (html) => htmlToMarkdownDocSync(html)
2016
+ };
2017
+ function describeWorkspaceOutput(output) {
2018
+ const status = output.status === "would-write" ? "would write" : output.status;
2019
+ return output.message ? `${status} ${output.path}: ${output.message}` : `${status} ${output.path}`;
2020
+ }
2021
+ var refreshCommand = new Command5("refresh").description(`Regenerate the catalog outputs enabled in <dir>/${WORKSPACE_SETTINGS_PATH2}`).argument("[dir]", "workspace root", ".").option("--force", "rewrite the catalog page even when its inputs are unchanged").option("--dry-run", "report what would change without writing anything").action(async (dir, opts) => {
2022
+ try {
2023
+ const result = await runWorkspaceRefresh({ dir, force: opts.force, dryRun: opts.dryRun });
2024
+ if (result.status === "missing") {
2025
+ console.error(
2026
+ `No workspace settings found at ${WORKSPACE_SETTINGS_PATH2} in ${result.root}; no outputs are enabled.`
2027
+ );
2028
+ return;
2029
+ }
2030
+ if (result.status === "disabled") {
2031
+ console.error(`${WORKSPACE_SETTINGS_PATH2} enables no catalog outputs; nothing to refresh.`);
2032
+ return;
2033
+ }
2034
+ for (const output of result.outputs) console.error(describeWorkspaceOutput(output));
2035
+ console.error(
2036
+ `${result.dryRun ? "Dry run: " : ""}${result.documentCount} document(s) cataloged in ${result.root}.`
2037
+ );
2038
+ if (result.outputs.some((output) => output.status === "blocked")) process.exitCode = 1;
2039
+ } catch (err) {
2040
+ const message = err instanceof Error ? err.message : String(err);
2041
+ console.error(`Error: ${message}`);
2042
+ process.exitCode = 1;
2043
+ }
2044
+ });
2045
+ var workspaceCommand = new Command5("workspace").description(`Maintain workspace outputs configured in ${WORKSPACE_SETTINGS_PATH2}`).addCommand(refreshCommand);
2046
+
1389
2047
  // src/version.ts
1390
2048
  import { createHash } from "crypto";
1391
2049
  import { createRequire } from "module";
1392
2050
  import { readFileSync } from "fs";
1393
2051
  import { readdir, readFile as readFile2, stat as stat4 } from "fs/promises";
1394
- import path5 from "path";
2052
+ import path6 from "path";
1395
2053
  var require2 = createRequire(import.meta.url);
1396
2054
  function getPackageVersion() {
1397
2055
  const pkg = readPackageJson();
@@ -1411,9 +2069,11 @@ function readPackageJson() {
1411
2069
  export {
1412
2070
  applyTransformToMarkdown,
1413
2071
  assertCliVideoRenderBudget,
2072
+ describeWorkspaceOutput,
1414
2073
  getPackageVersion,
1415
2074
  runBuild,
1416
2075
  runConvert,
1417
2076
  runParse,
1418
- runVideo
2077
+ runVideo,
2078
+ runWorkspaceRefresh
1419
2079
  };