@rohal12/twee-ts 1.17.1 → 1.18.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.
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from "node:util";
3
- import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
3
+ import { accessSync, chmodSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
4
4
  import * as nodePath from "node:path";
5
5
  import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
6
- import { randomUUID } from "node:crypto";
6
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
7
7
  import { homedir } from "node:os";
8
8
  import { fileURLToPath } from "node:url";
9
9
  //#region src/ifid.ts
@@ -106,18 +106,44 @@ function tweeEscape(s) {
106
106
  if (s.length === 0) return s;
107
107
  return s.replace(/[\\[\]{}]/g, (ch) => "\\" + ch);
108
108
  }
109
- /** Escape a string for safe inclusion in a JavaScript string literal. */
109
+ const JS_STRING_ESCAPES = {
110
+ "\\": "\\\\",
111
+ "\"": "\\\"",
112
+ "'": "\\'",
113
+ "\n": "\\n",
114
+ "\r": "\\r",
115
+ " ": "\\t",
116
+ "<": "\\x3C",
117
+ "\u2028": "\\u2028",
118
+ "\u2029": "\\u2029"
119
+ };
120
+ /**
121
+ * Escape a string for the inside of a single- or double-quoted JavaScript string literal, including one written
122
+ * into an HTML `<script>` element. Besides backslashes, quotes and line breaks (with U+2028 and U+2029, which end
123
+ * a string literal in JavaScript before ES2019), every `<` becomes `\x3C`, so the literal holds no `<\/script`
124
+ * that would end the element and no `<!--` or `<script` that would change where it ends. The string's value is
125
+ * unchanged.
126
+ */
110
127
  function jsStringEscape(s) {
111
128
  if (s.length === 0) return s;
112
- return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
129
+ return s.replace(/[\\"'\n\r\t<\u2028\u2029]/g, (ch) => JS_STRING_ESCAPES[ch] ?? ch);
130
+ }
131
+ /**
132
+ * Escape a string for the inside of a double-quoted CSS string (such as a `font-family` name): `\` and `"` get
133
+ * a backslash, and line breaks and other control characters become hex escapes followed by a space (`\a ` for a
134
+ * line feed), since a raw line break ends a CSS string and a backslash before one is a line continuation.
135
+ */
136
+ function cssStringEscape(s) {
137
+ if (s.length === 0) return s;
138
+ return s.replace(/[\\"\u0000-\u001f\u007f]/g, (ch) => ch === "\\" || ch === "\"" ? `\\${ch}` : `\\${ch.charCodeAt(0).toString(16)} `);
113
139
  }
114
140
  /** Sanitize a string for safe inclusion in a CSS or JS block comment. */
115
141
  function commentSanitize(s) {
116
142
  return s.replace(/\*\//g, "* /");
117
143
  }
118
- /** Sanitize a string for safe inclusion in an HTML comment. */
144
+ /** Sanitize a string for safe inclusion in an HTML comment: break up `-->` and `--!>`, which both end one. */
119
145
  function htmlCommentSanitize(s) {
120
- return s.replace(/-->/g, "-- >");
146
+ return s.replace(/--(!?)>/g, "--$1 >");
121
147
  }
122
148
  /**
123
149
  * Escape JavaScript for the content of an HTML `<script>` element, so that the HTML parser reads all of it as
@@ -297,7 +323,19 @@ function passageToPassagedata(p, pid, options) {
297
323
  if (options?.sourceInfo && p.source) attrs += ` data-source-file=${quote(attrEscape(p.source.file))} data-source-line="${p.source.line}"`;
298
324
  return `${attrs}>${htmlEscape(p.text)}</tw-passagedata>`;
299
325
  }
300
- /** Generate `<div tiddler>` HTML for Twine 1. */
326
+ /**
327
+ * Whether Twine 1 `obfuscate:rot13` encodes a tiddler: every one but `StorySettings` and those tagged `Twine.image`
328
+ * (Twine 1.4 `Tiddler.isObfuscateable()`). Its engine tests the stored name and tags, which these tiddlers keep
329
+ * unencoded, so the decompiler tests them the same way.
330
+ */
331
+ function isObfuscatable(p) {
332
+ return p.name !== "StorySettings" && !p.tags.includes("Twine.image");
333
+ }
334
+ /**
335
+ * Generate `<div tiddler>` HTML for Twine 1. With `obfuscateRot13`, an obfuscatable tiddler (see
336
+ * `isObfuscatable()`) has its name, each tag and its text ROT13-encoded, as Twine 1.4 writes it
337
+ * (`Tiddler.toHtml()`), and as its engine.js decodes it.
338
+ */
301
339
  function passageToTiddler(p, pid, obfuscateRot13 = false) {
302
340
  let position;
303
341
  if (hasMetadataPosition(p)) position = p.metadata?.position ?? "";
@@ -309,9 +347,10 @@ function passageToTiddler(p, pid, obfuscateRot13 = false) {
309
347
  position = `${xp * 140 - 130},${yp * 140 - 130}`;
310
348
  }
311
349
  const created = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 12);
312
- const content = obfuscateRot13 && p.name !== "StorySettings" ? tiddlerEscape(rot13(p.text)) : tiddlerEscape(p.text);
313
- const nameComment = obfuscateRot13 ? `<!-- ${commentSanitize(p.name)} -->` : "";
314
- return `<div tiddler=${quote(attrEscape(p.name))} tags=${quote(attrEscape(p.tags.join(" ")))} created=${quote(created)} modifier=${quote("twee")} twine-position=${quote(attrEscape(position))}>${nameComment}${content}</div>`;
350
+ const encode = obfuscateRot13 && isObfuscatable(p) ? rot13 : (s) => s;
351
+ const name = attrEscape(encode(p.name));
352
+ const tags = attrEscape(p.tags.map(encode).join(" "));
353
+ return `<div tiddler=${quote(name)} tags=${quote(tags)} created=${quote(created)} modifier=${quote("twee")} twine-position=${quote(attrEscape(position))}>${tiddlerEscape(encode(p.text))}</div>`;
315
354
  }
316
355
  /**
317
356
  * Count words in a passage.
@@ -364,19 +403,128 @@ function quote(s) {
364
403
  }
365
404
  //#endregion
366
405
  //#region src/story.ts
367
- /** Module-level index: maps passage names to their array indices for O(1) lookups. */
368
406
  const passageIndex = /* @__PURE__ */ new WeakMap();
369
- function getIndex(story) {
370
- let index = passageIndex.get(story);
371
- if (!index) {
372
- index = /* @__PURE__ */ new Map();
373
- for (let i = 0; i < story.passages.length; i++) {
374
- const passage = story.passages[i];
375
- if (passage) index.set(passage.name, i);
407
+ function buildIndex(story) {
408
+ const names = story.passages.map((p) => p.name);
409
+ const index = {
410
+ passages: story.passages,
411
+ names,
412
+ positions: new Map(names.map((name, i) => [name, i]))
413
+ };
414
+ passageIndex.set(story, index);
415
+ return index;
416
+ }
417
+ /**
418
+ * The index, rebuilt if `story.passages` was reassigned or changed length. O(1); `position()`
419
+ * also checks every hit. A change that keeps the length, such as replacing an element, can still
420
+ * hide a passage from a lookup, so `storyVerifyIndex()` checks every position.
421
+ */
422
+ function currentIndex(story) {
423
+ const index = passageIndex.get(story);
424
+ return index !== void 0 && index.passages === story.passages && index.names.length === story.passages.length ? index : buildIndex(story);
425
+ }
426
+ /** The position of the passage named `name`, or -1. */
427
+ function position(story, name) {
428
+ const i = currentIndex(story).positions.get(name);
429
+ if (i === void 0) return -1;
430
+ if (story.passages[i]?.name === name) return i;
431
+ return buildIndex(story).positions.get(name) ?? -1;
432
+ }
433
+ /** Add a passage whose name no passage has yet at the end. */
434
+ function push(story, p) {
435
+ const index = currentIndex(story);
436
+ index.positions.set(p.name, story.passages.length);
437
+ index.names.push(p.name);
438
+ story.passages.push(p);
439
+ }
440
+ /** Put a passage at position `i`, in place of the passage there. */
441
+ function replaceAt(story, i, p) {
442
+ const renamed = story.passages[i]?.name !== p.name;
443
+ story.passages[i] = p;
444
+ if (renamed) buildIndex(story);
445
+ }
446
+ /** Names of compiler special passages, which storyAdd() reads into the story model. */
447
+ const SPECIAL_PASSAGE_NAMES = /* @__PURE__ */ new Set([
448
+ "StoryData",
449
+ "StoryIncludes",
450
+ "StorySettings",
451
+ "StoryTitle"
452
+ ]);
453
+ /** Passages whose names were generated, kept by identity so that cached passages keep the mark. */
454
+ const generatedNames = /* @__PURE__ */ new WeakMap();
455
+ /**
456
+ * Mark a passage's name as generated (from a file name, or for imported story code) and return
457
+ * the passage. `origin.base` is the name that free names are numbered from: `base`, `base 2`, ….
458
+ */
459
+ function withGeneratedName(p, origin) {
460
+ generatedNames.set(p, origin);
461
+ return p;
462
+ }
463
+ /** The first of `base`, `base 2`, `base 3`, … that `isFree` accepts. */
464
+ function freeName(base, isFree) {
465
+ if (isFree(base)) return base;
466
+ for (let n = 2;; n++) {
467
+ const candidate = `${base} ${n}`;
468
+ if (isFree(candidate)) return candidate;
469
+ }
470
+ }
471
+ /** Whether a generated name may take `name`: no passage has it and it is no special name. */
472
+ function isFreeForGenerated(story, name) {
473
+ return !SPECIAL_PASSAGE_NAMES.has(name) && position(story, name) === -1;
474
+ }
475
+ function reportRename(origin, from, to, diagnostics) {
476
+ switch (origin.kind) {
477
+ case "code": return;
478
+ case "media": {
479
+ const reason = SPECIAL_PASSAGE_NAMES.has(from) ? `"${from}" is a compiler special passage name` : `another passage has the name "${from}"`;
480
+ diagnostics.push({
481
+ level: "warning",
482
+ message: `Passage "${from}" from "${origin.file}" renamed to "${to}"; ${reason}.`
483
+ });
484
+ return;
376
485
  }
377
- passageIndex.set(story, index);
486
+ default: throw new Error(`unhandled generated name: ${JSON.stringify(origin)}`);
378
487
  }
379
- return index;
488
+ }
489
+ /** Add a passage with a generated name, under the first free name if its own is taken. */
490
+ function addGenerated(story, p, origin, diagnostics) {
491
+ if (isFreeForGenerated(story, p.name)) {
492
+ push(story, p);
493
+ return;
494
+ }
495
+ const name = freeName(origin.base, (n) => isFreeForGenerated(story, n));
496
+ reportRename(origin, p.name, name, diagnostics);
497
+ push(story, withGeneratedName({
498
+ ...p,
499
+ name
500
+ }, origin));
501
+ }
502
+ /**
503
+ * If a passage with a generated name has `name`, give it the first free name, in its place in the
504
+ * passage list, so that a passage from the sources can take the name.
505
+ */
506
+ function moveGeneratedAside(story, name, diagnostics) {
507
+ const i = position(story, name);
508
+ const existing = i === -1 ? void 0 : story.passages[i];
509
+ const origin = existing === void 0 ? void 0 : generatedNames.get(existing);
510
+ if (existing === void 0 || origin === void 0) return;
511
+ const free = freeName(origin.base, (n) => isFreeForGenerated(story, n));
512
+ reportRename(origin, name, free, diagnostics);
513
+ replaceAt(story, i, withGeneratedName({
514
+ ...existing,
515
+ name: free
516
+ }, origin));
517
+ }
518
+ function defaultTwine2Metadata() {
519
+ return {
520
+ format: "",
521
+ formatVersion: "",
522
+ options: /* @__PURE__ */ new Map(),
523
+ start: "",
524
+ tags: "",
525
+ tagColors: /* @__PURE__ */ new Map(),
526
+ zoom: 1
527
+ };
380
528
  }
381
529
  function createStory() {
382
530
  const story = {
@@ -385,52 +533,37 @@ function createStory() {
385
533
  passages: [],
386
534
  legacyIFID: "",
387
535
  twine1: { settings: /* @__PURE__ */ new Map() },
388
- twine2: {
389
- format: "",
390
- formatVersion: "",
391
- options: /* @__PURE__ */ new Map(),
392
- start: "",
393
- tags: "",
394
- tagColors: /* @__PURE__ */ new Map(),
395
- zoom: 1
396
- }
536
+ twine2: defaultTwine2Metadata()
397
537
  };
398
- passageIndex.set(story, /* @__PURE__ */ new Map());
538
+ buildIndex(story);
399
539
  return story;
400
540
  }
401
541
  function storyHas(story, name) {
402
- return getIndex(story).has(name);
542
+ return position(story, name) !== -1;
403
543
  }
404
544
  function storyAppend(story, p, diagnostics) {
405
- const index = getIndex(story);
406
- const i = index.get(p.name) ?? -1;
407
- if (i === -1) {
408
- index.set(p.name, story.passages.length);
409
- story.passages.push(p);
410
- } else {
545
+ const i = position(story, p.name);
546
+ if (i === -1) push(story, p);
547
+ else {
411
548
  diagnostics.push({
412
549
  level: "warning",
413
550
  message: `Replacing existing passage "${p.name}" with duplicate.`
414
551
  });
415
- story.passages[i] = p;
552
+ replaceAt(story, i, p);
416
553
  }
417
554
  }
418
555
  function storyPrepend(story, p, diagnostics) {
419
- const index = getIndex(story);
420
- const i = index.get(p.name) ?? -1;
556
+ moveGeneratedAside(story, p.name, diagnostics);
557
+ const i = position(story, p.name);
421
558
  if (i === -1) {
422
559
  story.passages.unshift(p);
423
- index.clear();
424
- for (let j = 0; j < story.passages.length; j++) {
425
- const passage = story.passages[j];
426
- if (passage) index.set(passage.name, j);
427
- }
560
+ buildIndex(story);
428
561
  } else {
429
562
  diagnostics.push({
430
563
  level: "warning",
431
564
  message: `Replacing existing passage "${p.name}" with duplicate.`
432
565
  });
433
- story.passages[i] = p;
566
+ replaceAt(story, i, p);
434
567
  }
435
568
  }
436
569
  function marshalStoryData(story) {
@@ -456,18 +589,20 @@ function unmarshalStoryData(story, json) {
456
589
  }
457
590
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return "Cannot unmarshal \"StoryData\"; expected a JSON object";
458
591
  const data = raw;
459
- if (typeof data.ifid === "string" && data.ifid) story.ifid = normalizeIFID(data.ifid);
460
- if (typeof data.format === "string" && data.format) story.twine2.format = data.format;
461
- if (typeof data["format-version"] === "string" && data["format-version"]) story.twine2.formatVersion = data["format-version"];
592
+ const twine2 = defaultTwine2Metadata();
593
+ story.ifid = typeof data.ifid === "string" ? normalizeIFID(data.ifid) : "";
594
+ if (typeof data.format === "string") twine2.format = data.format;
595
+ if (typeof data["format-version"] === "string") twine2.formatVersion = data["format-version"];
462
596
  if (Array.isArray(data.options)) {
463
- for (const opt of data.options) if (typeof opt === "string") story.twine2.options.set(opt, true);
597
+ for (const opt of data.options) if (typeof opt === "string") twine2.options.set(opt, true);
464
598
  }
465
- if (typeof data.start === "string" && data.start) story.twine2.start = data.start;
466
- if (typeof data.tags === "string") story.twine2.tags = data.tags;
599
+ if (typeof data.start === "string") twine2.start = data.start;
600
+ if (typeof data.tags === "string") twine2.tags = data.tags;
467
601
  if (typeof data["tag-colors"] === "object" && data["tag-colors"] !== null && !Array.isArray(data["tag-colors"])) {
468
- for (const [tag, color] of Object.entries(data["tag-colors"])) if (typeof color === "string") story.twine2.tagColors.set(tag, color);
602
+ for (const [tag, color] of Object.entries(data["tag-colors"])) if (typeof color === "string") twine2.tagColors.set(tag, color);
469
603
  }
470
- if (typeof data.zoom === "number" && data.zoom !== 0) story.twine2.zoom = data.zoom;
604
+ if (typeof data.zoom === "number" && data.zoom !== 0) twine2.zoom = data.zoom;
605
+ story.twine2 = twine2;
471
606
  return null;
472
607
  }
473
608
  function unmarshalStorySettings(story, text, diagnostics) {
@@ -508,8 +643,20 @@ function unmarshalStorySettings(story, text, diagnostics) {
508
643
  /**
509
644
  * Process a passage and add it to the story, handling special passages.
510
645
  * Creates new passage objects where text is modified rather than mutating the input.
646
+ *
647
+ * A passage replaces an earlier one with the same name, except where either name was generated
648
+ * (see `withGeneratedName()`): a generated name never replaces a passage and is never replaced.
649
+ * A passage with a generated name takes the first free name of `base`, `base 2`, … when its own
650
+ * is taken or is a compiler special name. A passage with a name from the sources that a generated
651
+ * name holds takes it over, and the generated one moves to the first free name in its place.
511
652
  */
512
653
  function storyAdd(story, p, diagnostics) {
654
+ const origin = generatedNames.get(p);
655
+ if (origin !== void 0) {
656
+ addGenerated(story, p, origin, diagnostics);
657
+ return;
658
+ }
659
+ moveGeneratedAside(story, p.name, diagnostics);
513
660
  let processed = p;
514
661
  switch (p.name) {
515
662
  case "StoryIncludes":
@@ -684,131 +831,579 @@ function isExcluded(filename, exclude) {
684
831
  const rel = relative(process.cwd(), resolve(filename)) || filename;
685
832
  return exclude.some((pattern) => matchesGlob(rel, pattern.replace(/^\.\//, "")));
686
833
  }
834
+ /** A build that writes nothing: `compile()`, or output to stdout. */
835
+ const NO_BUILD_OUTPUTS = {
836
+ files: [],
837
+ dirs: []
838
+ };
839
+ /** The outputs of a build that writes the one file `outFile`, or none without it. */
840
+ function toBuildOutputs(outputs) {
841
+ if (outputs === void 0) return NO_BUILD_OUTPUTS;
842
+ return typeof outputs === "string" ? {
843
+ files: [outputs],
844
+ dirs: []
845
+ } : outputs;
846
+ }
847
+ /**
848
+ * The path outputs are compared by: absolute, with every symbolic link in it
849
+ * resolved, so two paths that reach the same file through different links (a
850
+ * symlinked project folder, an alias of the output folder) compare equal. A
851
+ * path that doesn't exist yet, such as the output before the first build, is
852
+ * its nearest existing folder's real path joined with the rest.
853
+ */
854
+ function realPathOf(path) {
855
+ const abs = resolve(path);
856
+ try {
857
+ return realpathSync.native(abs);
858
+ } catch {}
859
+ const parent = dirname(abs);
860
+ return parent === abs ? abs : join(realPathOf(parent), basename(abs));
861
+ }
862
+ /** Whether the path `inner` is `outer` or inside it. */
863
+ function isSameOrInside(inner, outer) {
864
+ return inner === outer || inner.startsWith(outer.endsWith(sep) ? outer : outer + sep);
865
+ }
866
+ function outputPaths(outputs) {
867
+ const files = new Set(outputs.files.map(realPathOf));
868
+ const dirs = new Set(outputs.dirs.map(realPathOf));
869
+ const all = [...files, ...dirs];
870
+ return {
871
+ isFile: (real) => files.has(real),
872
+ isDir: (real) => dirs.has(real),
873
+ holds: (real) => dirs.has(real) || all.some((output) => output !== real && isSameOrInside(output, real)),
874
+ isOutput(path, roots) {
875
+ const real = realPathOf(path);
876
+ if (files.has(real)) return true;
877
+ const owners = [...dirs].filter((dir) => isSameOrInside(real, dir));
878
+ if (owners.length === 0) return false;
879
+ return roots.map(realPathOf).filter((root) => isSameOrInside(real, root)).every((root) => owners.some((dir) => dir !== root && isSameOrInside(dir, root)));
880
+ }
881
+ };
882
+ }
883
+ /**
884
+ * A folder entry as source discovery walks it, found at `pathname` with the real
885
+ * path `real` when it is no link: a link to a file counts as that file, and a
886
+ * link to anything else (a folder above all) as nothing, undefined. Throws when
887
+ * the entry or a link's target can't be read.
888
+ */
889
+ function walkedEntry(pathname, real) {
890
+ const stat = lstatSync(pathname);
891
+ if (!stat.isSymbolicLink()) return {
892
+ stat,
893
+ real
894
+ };
895
+ const target = statSync(pathname);
896
+ return target.isFile() ? {
897
+ stat: target,
898
+ real: realPathOf(pathname)
899
+ } : void 0;
900
+ }
687
901
  /**
688
902
  * Recursively walk directories, collecting regular file paths.
689
- * Filters out the output file to prevent circular compilation, and the files
690
- * that match an `exclude` glob (see isExcluded).
903
+ *
904
+ * Leaves out what the build writes (`outputs`, compared by real path, see
905
+ * realPathOf) so its last output is never read back, and the files that match an
906
+ * `exclude` glob (see isExcluded). A path given that is itself an output file is
907
+ * not walked but listed in `outputSources`.
908
+ *
909
+ * Symbolic links: a path given is followed wherever it leads. Inside a folder, a
910
+ * link to a file is read, but a link to a folder is not followed, as Tweego's walk
911
+ * doesn't follow one. A link back to its own folder or a parent would otherwise
912
+ * walk the same files again at every depth.
913
+ *
691
914
  * Like Tweego, a path that cannot be read is reported as a warning and skipped.
692
915
  */
693
- function getFilenames(pathnames, outFilename, exclude = []) {
916
+ function getFilenames(pathnames, outputs, exclude = []) {
694
917
  const filenames = [];
695
918
  const diagnostics = [];
696
- const absOutFile = outFilename ? resolve(outFilename) : "";
919
+ const outputSources = [];
920
+ const output = outputPaths(toBuildOutputs(outputs));
697
921
  function warn(pathname, e) {
698
922
  diagnostics.push({
699
923
  level: "warning",
700
924
  message: `path ${pathname}: ${e instanceof Error ? e.message : String(e)}`
701
925
  });
702
926
  }
703
- function walk(pathname) {
927
+ function addFile(pathname) {
928
+ const abs = resolve(pathname);
929
+ const rel = relative(process.cwd(), abs);
930
+ if (isExcluded(rel || abs, exclude)) return;
931
+ filenames.push(rel || abs);
932
+ }
933
+ function walkDir(pathname, real) {
934
+ let entries;
935
+ try {
936
+ entries = readdirSync(pathname);
937
+ } catch (e) {
938
+ warn(pathname, e);
939
+ return;
940
+ }
941
+ for (const entry of entries) walkEntry(join(pathname, entry), join(real, entry));
942
+ }
943
+ function walkEntry(pathname, real) {
944
+ let entry;
945
+ try {
946
+ entry = walkedEntry(pathname, real);
947
+ } catch (e) {
948
+ warn(pathname, e);
949
+ return;
950
+ }
951
+ if (entry === void 0) return;
952
+ if (entry.stat.isFile()) {
953
+ if (!output.isFile(entry.real)) addFile(pathname);
954
+ } else if (entry.stat.isDirectory() && !output.isDir(entry.real)) walkDir(pathname, entry.real);
955
+ }
956
+ for (const pathname of pathnames) {
704
957
  let stat;
705
958
  try {
706
959
  stat = statSync(pathname);
707
960
  } catch (e) {
708
961
  warn(pathname, e);
709
- return;
962
+ continue;
710
963
  }
964
+ const real = realPathOf(pathname);
711
965
  if (stat.isFile()) {
712
- const abs = resolve(pathname);
713
- if (abs === absOutFile) return;
714
- const rel = relative(process.cwd(), abs);
715
- if (isExcluded(rel || abs, exclude)) return;
716
- filenames.push(rel || abs);
717
- } else if (stat.isDirectory()) {
718
- let entries;
719
- try {
720
- entries = readdirSync(pathname);
721
- } catch (e) {
722
- warn(pathname, e);
723
- return;
724
- }
725
- for (const entry of entries) walk(join(pathname, entry));
726
- }
966
+ if (output.isFile(real)) outputSources.push(pathname);
967
+ else addFile(pathname);
968
+ } else if (stat.isDirectory()) walkDir(pathname, real);
727
969
  }
728
- for (const pathname of pathnames) walk(pathname);
729
970
  return {
730
971
  filenames,
731
- diagnostics
972
+ diagnostics,
973
+ outputSources
732
974
  };
733
975
  }
734
- /** Whether the absolute path `file` is the absolute path `dir` or inside it. */
735
- function isInsideDir(file, dir) {
736
- return file === dir || file.startsWith(dir.endsWith(sep) ? dir : dir + sep);
737
- }
738
976
  /**
739
- * The watched paths split into folders (as given) and files (absolute). A path
740
- * that cannot be read counts as a folder, which fs.watch then fails to watch.
977
+ * A watched path that can't be watched: fs.watch failed on it, or on the folder it is
978
+ * watched through. watchFilesystem passes it to its `onError` and keeps watching the rest.
741
979
  */
742
- function splitWatchRoots(pathnames) {
743
- const dirs = [];
744
- const files = /* @__PURE__ */ new Set();
745
- for (const pathname of pathnames) {
746
- let isFile = false;
747
- try {
748
- isFile = statSync(pathname).isFile();
749
- } catch {}
750
- if (isFile) files.add(resolve(pathname));
751
- else dirs.push(pathname);
980
+ var WatchPathError = class extends Error {
981
+ path;
982
+ constructor(path, cause) {
983
+ super(`Cannot watch ${path}: ${cause instanceof Error ? cause.message : String(cause)}`);
984
+ this.path = path;
985
+ this.name = "WatchPathError";
752
986
  }
987
+ };
988
+ function pathKind(path) {
989
+ try {
990
+ const stats = statSync(path);
991
+ return stats.isDirectory() ? {
992
+ kind: "dir",
993
+ id: `${stats.dev}:${stats.ino}`
994
+ } : { kind: "file" };
995
+ } catch {
996
+ return { kind: "missing" };
997
+ }
998
+ }
999
+ function rootState(abs) {
753
1000
  return {
754
- dirs,
755
- files
1001
+ anchor: nearestFolderAbove(abs),
1002
+ self: pathKind(abs)
756
1003
  };
757
1004
  }
1005
+ function nearestFolderAbove(abs) {
1006
+ for (let dir = dirname(abs), below = abs; dir !== below; below = dir, dir = dirname(dir)) {
1007
+ const kind = pathKind(dir);
1008
+ if (kind.kind === "dir") return {
1009
+ path: dir,
1010
+ id: kind.id
1011
+ };
1012
+ }
1013
+ }
1014
+ /** Whether two states need the same watches: the same anchor folder, and the same watched folder if any. */
1015
+ function sameWatches(a, b) {
1016
+ const selfId = (s) => s.self.kind === "dir" ? s.self.id : void 0;
1017
+ return a.anchor?.path === b.anchor?.path && a.anchor?.id === b.anchor?.id && selfId(a) === selfId(b);
1018
+ }
758
1019
  /**
759
1020
  * Watch paths for changes, calling the build callback on known file type changes.
760
1021
  * A folder is watched recursively; a file is watched on its own and counts
761
1022
  * whatever its type (a head file, say). A change to a file `ignore` returns true
762
1023
  * for (given the path relative to the working directory) schedules no build.
763
1024
  * Uses debouncing to avoid rapid rebuilds.
764
- */
765
- function watchFilesystem(pathnames, outFilename, callback, ignore = () => false) {
766
- const absOutFile = resolve(outFilename);
767
- const watchers = [];
1025
+ *
1026
+ * A watched path that doesn't exist yet is waited for, and one that is deleted, or renamed
1027
+ * away and replaced, is followed to the new folder or file at its path: each path is also
1028
+ * watched through the folder above it. When that sets up a path's watches again, the next
1029
+ * build is a full one (`changedFiles` undefined), since changes made in between went unseen.
1030
+ * A path that can't be watched is passed to `onError` as a WatchPathError, once while the
1031
+ * failure persists; the other paths are still watched.
1032
+ */
1033
+ function watchFilesystem(pathnames, outFilename, callback, ignore = () => false, onError = () => {}) {
1034
+ const output = outputPaths(toBuildOutputs(outFilename));
768
1035
  let buildTimer = null;
769
1036
  const BUILD_DEBOUNCE = 500;
770
1037
  const pendingFiles = /* @__PURE__ */ new Set();
1038
+ let pendingFullBuild = false;
1039
+ let closed = false;
771
1040
  function scheduleBuild(changedFile) {
772
- if (changedFile) pendingFiles.add(changedFile);
1041
+ if (changedFile === void 0) pendingFullBuild = true;
1042
+ else pendingFiles.add(changedFile);
773
1043
  if (buildTimer) clearTimeout(buildTimer);
774
1044
  buildTimer = setTimeout(() => {
775
1045
  buildTimer = null;
776
- const files = pendingFiles.size > 0 ? new Set(pendingFiles) : void 0;
1046
+ const files = pendingFullBuild || pendingFiles.size === 0 ? void 0 : new Set(pendingFiles);
777
1047
  pendingFiles.clear();
1048
+ pendingFullBuild = false;
778
1049
  callback(files);
779
1050
  }, BUILD_DEBOUNCE);
780
1051
  }
1052
+ const roots = pathnames.map((path) => {
1053
+ const abs = resolve(path);
1054
+ return {
1055
+ path,
1056
+ abs,
1057
+ state: rootState(abs),
1058
+ watchers: [],
1059
+ failed: false,
1060
+ lastReport: void 0
1061
+ };
1062
+ });
1063
+ const isNamedFile = (abs) => roots.some((r) => r.abs === abs && r.state.self.kind === "file");
781
1064
  function fileChanged(abs, named) {
782
- if (abs === absOutFile) return;
783
1065
  if (!named && !isKnownFileType(abs)) return;
1066
+ if (output.isFile(realPathOf(abs))) return;
784
1067
  const rel = relative(process.cwd(), abs);
785
1068
  if (!ignore(rel || abs)) scheduleBuild(rel || abs);
786
1069
  }
787
- function watchPath(pathname, recursive, listener) {
1070
+ function report(root, cause) {
1071
+ const error = new WatchPathError(root.path, cause);
1072
+ if (error.message === root.lastReport) return;
1073
+ root.lastReport = error.message;
1074
+ onError(error);
1075
+ }
1076
+ function startWatch(root, path, recursive, listener) {
788
1077
  try {
789
- watchers.push(watch(pathname, { recursive }, (_event, filename) => {
790
- if (filename) listener(filename);
791
- }));
792
- } catch {}
1078
+ if (recursive) accessSync(path, constants.R_OK | constants.X_OK);
1079
+ const watcher = watch(path, { recursive }, (_event, filename) => {
1080
+ if (!closed) listener(filename ?? "");
1081
+ });
1082
+ watcher.on("error", (e) => {
1083
+ watcher.close();
1084
+ root.watchers = root.watchers.filter((w) => w !== watcher);
1085
+ root.failed = true;
1086
+ if (!closed && !recheck(root, false)) report(root, e);
1087
+ });
1088
+ root.watchers.push(watcher);
1089
+ } catch (e) {
1090
+ root.failed = true;
1091
+ report(root, e);
1092
+ }
1093
+ }
1094
+ function arm(root) {
1095
+ for (const w of root.watchers) w.close();
1096
+ root.watchers = [];
1097
+ root.failed = false;
1098
+ const { anchor, self } = root.state;
1099
+ if (anchor) startWatch(root, anchor.path, false, (filename) => {
1100
+ const before = root.state.self.kind;
1101
+ recheck(root, true);
1102
+ if ((before === "file" || isNamedFile(root.abs)) && filename !== "" && resolve(anchor.path, filename) === root.abs) fileChanged(root.abs, true);
1103
+ });
1104
+ if (self.kind === "dir") startWatch(root, root.path, true, (filename) => {
1105
+ if (filename === "") recheck(root, true);
1106
+ else {
1107
+ const abs = resolve(root.abs, filename);
1108
+ fileChanged(abs, isNamedFile(abs));
1109
+ }
1110
+ });
1111
+ if (!root.failed) root.lastReport = void 0;
793
1112
  }
794
- const { dirs, files } = splitWatchRoots(pathnames);
795
- const absDirs = dirs.map((dir) => resolve(dir));
796
- for (const dir of dirs) watchPath(dir, true, (filename) => {
797
- const abs = resolve(dir, filename);
798
- fileChanged(abs, files.has(abs));
799
- });
800
- const fileDirs = new Set([...files].filter((file) => !absDirs.some((dir) => isInsideDir(file, dir))).map((file) => dirname(file)));
801
- for (const dir of fileDirs) watchPath(dir, false, (filename) => {
802
- const abs = resolve(dir, filename);
803
- if (files.has(abs)) fileChanged(abs, true);
804
- });
1113
+ /**
1114
+ * Sets up `root`'s watches again when its path, or the folder above it, is no longer the
1115
+ * one they were started on (or, with `retry`, when one of them failed), and then schedules a
1116
+ * full build. Returns whether its state changed.
1117
+ */
1118
+ function recheck(root, retry) {
1119
+ const before = root.state;
1120
+ const after = rootState(root.abs);
1121
+ const changed = !sameWatches(before, after);
1122
+ const wasFailed = root.failed;
1123
+ if (!changed && !(retry && wasFailed)) {
1124
+ root.state = after;
1125
+ return false;
1126
+ }
1127
+ root.state = after;
1128
+ arm(root);
1129
+ const recovered = wasFailed && !root.failed;
1130
+ if ((changed || recovered) && (before.self.kind !== "missing" || after.self.kind !== "missing")) scheduleBuild();
1131
+ return changed;
1132
+ }
1133
+ for (const root of roots) arm(root);
805
1134
  callback();
806
1135
  return { close() {
1136
+ closed = true;
807
1137
  if (buildTimer) clearTimeout(buildTimer);
808
- for (const w of watchers) w.close();
1138
+ for (const root of roots) for (const w of root.watchers) w.close();
809
1139
  } };
810
1140
  }
811
1141
  //#endregion
1142
+ //#region src/relaxed-json.ts
1143
+ /**
1144
+ * A parser for the object literal a Twine 2 `format.js` passes to `window.storyFormat()`.
1145
+ *
1146
+ * The story formats spec does not require that object to be strict JSON, and Twine 2 runs
1147
+ * `format.js` as JavaScript. This parser accepts JSON plus the JavaScript literal syntax formats
1148
+ * use in practice: single-quoted strings, unquoted (identifier) and numeric keys, trailing commas,
1149
+ * comments, JavaScript string escapes, and hexadecimal, octal and binary numbers.
1150
+ *
1151
+ * It reads string literals token by token, so their contents come back exactly as JavaScript
1152
+ * evaluation gives them: text that only looks like structure (`a, }`, `word:`, an apostrophe) is
1153
+ * never touched. Anything outside that subset, such as a function or a variable, is a syntax error.
1154
+ */
1155
+ const IDENTIFIER = /[A-Za-z_$][\w$]*/y;
1156
+ const NUMBER = /[+-]?(?:0[xX][0-9a-fA-F]+|0[oO][0-7]+|0[bB][01]+|(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)/y;
1157
+ const IDENTIFIER_PART = /[\w$]/;
1158
+ const WHITESPACE = /[\s]/;
1159
+ const HEX = /^[0-9a-fA-F]+$/;
1160
+ /** Simple single-character escapes and the characters they stand for. */
1161
+ const SIMPLE_ESCAPES = {
1162
+ b: "\b",
1163
+ f: "\f",
1164
+ n: "\n",
1165
+ r: "\r",
1166
+ t: " ",
1167
+ v: "\v"
1168
+ };
1169
+ /**
1170
+ * Parse a JavaScript object, array or primitive literal: all of `text`, or the part from `start` to
1171
+ * `end`. Error positions count from the start of `text`, so they point into the file it came from.
1172
+ *
1173
+ * @throws SyntaxError naming the line and column of the first thing it cannot parse.
1174
+ */
1175
+ function parseRelaxedJSON(text, start = 0, end = text.length) {
1176
+ let pos = start;
1177
+ function fail(message, at = pos) {
1178
+ const before = text.slice(0, at);
1179
+ const line = before.split("\n").length;
1180
+ const column = at - before.lastIndexOf("\n");
1181
+ throw new SyntaxError(`${message} at line ${line}, column ${column}`);
1182
+ }
1183
+ /** The character at `pos`, or '' at the end of the input. */
1184
+ const peek = () => pos < end ? text[pos] ?? "" : "";
1185
+ const describeChar = (at) => at >= end ? "end of input" : JSON.stringify(text[at]);
1186
+ /** Skip whitespace and comments. */
1187
+ const skipTrivia = () => {
1188
+ while (pos < end) {
1189
+ const ch = peek();
1190
+ if (WHITESPACE.test(ch)) pos++;
1191
+ else if (text.startsWith("//", pos)) {
1192
+ const lineEnd = text.slice(pos, end).search(/[\n\r\u2028\u2029]/);
1193
+ pos = lineEnd === -1 ? end : pos + lineEnd;
1194
+ } else if (text.startsWith("/*", pos)) {
1195
+ const commentEnd = text.indexOf("*/", pos + 2);
1196
+ if (commentEnd === -1 || commentEnd + 2 > end) fail("Unterminated comment");
1197
+ pos = commentEnd + 2;
1198
+ } else return;
1199
+ }
1200
+ };
1201
+ const consume = (ch) => {
1202
+ skipTrivia();
1203
+ if (peek() !== ch) fail(`Expected ${JSON.stringify(ch)} but found ${describeChar(pos)}`);
1204
+ pos++;
1205
+ };
1206
+ /** Read exactly `count` hex digits and return their value. */
1207
+ const readHex = (count) => {
1208
+ const digits = text.slice(pos, Math.min(pos + count, end));
1209
+ if (digits.length !== count || !HEX.test(digits)) fail("Invalid hexadecimal escape sequence");
1210
+ pos += count;
1211
+ return parseInt(digits, 16);
1212
+ };
1213
+ /** Decode the escape sequence after a backslash; `pos` is just past the backslash. */
1214
+ const readEscape = () => {
1215
+ const ch = peek();
1216
+ const simple = SIMPLE_ESCAPES[ch];
1217
+ if (simple !== void 0) {
1218
+ pos++;
1219
+ return simple;
1220
+ }
1221
+ switch (ch) {
1222
+ case "": return fail("Unterminated string");
1223
+ case "x":
1224
+ pos++;
1225
+ return String.fromCharCode(readHex(2));
1226
+ case "u": {
1227
+ pos++;
1228
+ if (peek() !== "{") return String.fromCharCode(readHex(4));
1229
+ const close = text.indexOf("}", pos);
1230
+ if (close === -1 || close >= end) fail("Invalid Unicode escape sequence");
1231
+ pos++;
1232
+ const codePoint = readHex(close - pos);
1233
+ if (codePoint > 1114111) fail("Undefined Unicode code-point");
1234
+ pos = close + 1;
1235
+ return String.fromCodePoint(codePoint);
1236
+ }
1237
+ case "\r":
1238
+ pos += text[pos + 1] === "\n" ? 2 : 1;
1239
+ return "";
1240
+ case "\n":
1241
+ case "\u2028":
1242
+ case "\u2029":
1243
+ pos++;
1244
+ return "";
1245
+ case "0":
1246
+ if (/[0-9]/.test(text[pos + 1] ?? "")) fail("Octal escape sequences are not supported");
1247
+ pos++;
1248
+ return "\0";
1249
+ default:
1250
+ if (/[1-9]/.test(ch)) fail("Octal escape sequences are not supported");
1251
+ pos++;
1252
+ return ch;
1253
+ }
1254
+ };
1255
+ const readString = () => {
1256
+ const quote = peek();
1257
+ pos++;
1258
+ let out = "";
1259
+ let chunkStart = pos;
1260
+ for (;;) {
1261
+ if (pos >= end) fail("Unterminated string");
1262
+ const ch = peek();
1263
+ if (ch === quote) {
1264
+ out += text.slice(chunkStart, pos);
1265
+ pos++;
1266
+ return out;
1267
+ }
1268
+ if (ch === "\n" || ch === "\r") fail("Unterminated string");
1269
+ if (ch === "\\") {
1270
+ out += text.slice(chunkStart, pos);
1271
+ pos++;
1272
+ out += readEscape();
1273
+ chunkStart = pos;
1274
+ } else pos++;
1275
+ }
1276
+ };
1277
+ /** Read a match of a sticky regular expression at `pos`, or return undefined. */
1278
+ const readToken = (pattern) => {
1279
+ pattern.lastIndex = pos;
1280
+ const m = pattern.exec(text);
1281
+ if (!m || pattern.lastIndex > end) return void 0;
1282
+ pos = pattern.lastIndex;
1283
+ return m[0];
1284
+ };
1285
+ const readNumber = () => {
1286
+ const tokenStart = pos;
1287
+ const token = readToken(NUMBER);
1288
+ if (token === void 0) return void 0;
1289
+ if (IDENTIFIER_PART.test(peek())) fail("Invalid number", tokenStart);
1290
+ return (token.startsWith("-") ? -1 : 1) * Number(/^[+-]/.test(token) ? token.slice(1) : token);
1291
+ };
1292
+ const readKey = () => {
1293
+ skipTrivia();
1294
+ const ch = peek();
1295
+ if (ch === "\"" || ch === "'") return readString();
1296
+ const identifier = readToken(IDENTIFIER);
1297
+ if (identifier !== void 0) return identifier;
1298
+ const number = readNumber();
1299
+ if (number !== void 0) return String(number);
1300
+ return fail(`Expected a property name but found ${describeChar(pos)}`);
1301
+ };
1302
+ const readObject = () => {
1303
+ pos++;
1304
+ const out = {};
1305
+ for (;;) {
1306
+ skipTrivia();
1307
+ if (peek() === "}") {
1308
+ pos++;
1309
+ return out;
1310
+ }
1311
+ const key = readKey();
1312
+ consume(":");
1313
+ Object.defineProperty(out, key, {
1314
+ value: readValue(),
1315
+ enumerable: true,
1316
+ writable: true,
1317
+ configurable: true
1318
+ });
1319
+ skipTrivia();
1320
+ if (peek() === ",") pos++;
1321
+ else if (peek() !== "}") fail(`Expected "," or "}" but found ${describeChar(pos)}`);
1322
+ }
1323
+ };
1324
+ const readArray = () => {
1325
+ pos++;
1326
+ const out = [];
1327
+ for (;;) {
1328
+ skipTrivia();
1329
+ if (peek() === "]") {
1330
+ pos++;
1331
+ return out;
1332
+ }
1333
+ out.push(readValue());
1334
+ skipTrivia();
1335
+ if (peek() === ",") pos++;
1336
+ else if (peek() !== "]") fail(`Expected "," or "]" but found ${describeChar(pos)}`);
1337
+ }
1338
+ };
1339
+ function readValue() {
1340
+ skipTrivia();
1341
+ const ch = peek();
1342
+ if (ch === "{") return readObject();
1343
+ if (ch === "[") return readArray();
1344
+ if (ch === "\"" || ch === "'") return readString();
1345
+ const tokenStart = pos;
1346
+ const identifier = readToken(IDENTIFIER);
1347
+ if (identifier !== void 0) {
1348
+ if (identifier === "true") return true;
1349
+ if (identifier === "false") return false;
1350
+ if (identifier === "null") return null;
1351
+ return fail(`Unexpected identifier ${JSON.stringify(identifier)}`, tokenStart);
1352
+ }
1353
+ const number = readNumber();
1354
+ if (number !== void 0) return number;
1355
+ return fail(`Unexpected ${describeChar(pos)}`);
1356
+ }
1357
+ const value = readValue();
1358
+ skipTrivia();
1359
+ if (pos < end) fail(`Unexpected ${describeChar(pos)} after the value`);
1360
+ return value;
1361
+ }
1362
+ //#endregion
1363
+ //#region src/semver.ts
1364
+ const IDENTIFIERS = String.raw`[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*`;
1365
+ const VERSION$1 = new RegExp(String.raw`^[vV]?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-(${IDENTIFIERS}))?(?:\+${IDENTIFIERS})?$`);
1366
+ const NUMERIC = /^\d+$/;
1367
+ /** Parse a version string, or return null when it is not a version. */
1368
+ function parseVersion(text) {
1369
+ const m = VERSION$1.exec(text);
1370
+ if (!m) return null;
1371
+ return {
1372
+ major: Number(m[1]),
1373
+ minor: Number(m[2] ?? 0),
1374
+ patch: Number(m[3] ?? 0),
1375
+ prerelease: m[4] === void 0 ? [] : m[4].split(".")
1376
+ };
1377
+ }
1378
+ /** Compare two prerelease identifiers: numeric ones numerically and below alphanumeric ones, which compare as ASCII. */
1379
+ function compareIdentifiers(a, b) {
1380
+ const aNumeric = NUMERIC.test(a);
1381
+ const bNumeric = NUMERIC.test(b);
1382
+ if (aNumeric && bNumeric) return Math.sign(Number(a) - Number(b));
1383
+ if (aNumeric !== bNumeric) return aNumeric ? -1 : 1;
1384
+ return a < b ? -1 : a > b ? 1 : 0;
1385
+ }
1386
+ /** Compare prerelease identifier lists. An empty list (a release) ranks above any prerelease. */
1387
+ function comparePrerelease(a, b) {
1388
+ if (a.length === 0 || b.length === 0) return Math.sign(b.length - a.length);
1389
+ const shared = Math.min(a.length, b.length);
1390
+ for (let i = 0; i < shared; i++) {
1391
+ const order = compareIdentifiers(a[i] ?? "", b[i] ?? "");
1392
+ if (order !== 0) return order;
1393
+ }
1394
+ return Math.sign(a.length - b.length);
1395
+ }
1396
+ /** Compare two versions by SemVer precedence: negative when `a` ranks lower, 0 when equal, positive when higher. */
1397
+ function compareVersions(a, b) {
1398
+ return Math.sign(a.major - b.major) || Math.sign(a.minor - b.minor) || Math.sign(a.patch - b.patch) || comparePrerelease(a.prerelease, b.prerelease);
1399
+ }
1400
+ /** Whether two version strings name the same version (prerelease included, build metadata ignored). */
1401
+ function sameVersion(a, b) {
1402
+ const left = parseVersion(a);
1403
+ const right = parseVersion(b);
1404
+ return left !== null && right !== null && compareVersions(left, right) === 0;
1405
+ }
1406
+ //#endregion
812
1407
  //#region src/source-text.ts
813
1408
  /**
814
1409
  * Source text normalization shared by every input path (files, in-memory sources, `parseTwee`).
@@ -820,14 +1415,119 @@ function watchFilesystem(pathnames, outFilename, callback, ignore = () => false)
820
1415
  function normalizeSourceText(text) {
821
1416
  return (text.charCodeAt(0) === 65279 ? text.slice(1) : text).replace(/\r\n?/g, "\n");
822
1417
  }
1418
+ /** Byte order marks at the start of a line, directly before a passage header's `::`. */
1419
+ const BOMS_BEFORE_HEADER = /(?<=^|\n)\uFEFF+(?=::)/g;
1420
+ /**
1421
+ * Normalize Twee source text: {@link normalizeSourceText}, and also remove byte order marks at the start of
1422
+ * a line that are directly followed by `::`. Concatenating files (`cat a.tw b.tw`) leaves the BOM of every
1423
+ * later file in front of its first passage header, which would otherwise not be recognised as a header.
1424
+ * A U+FEFF anywhere else is passage text and kept. Idempotent.
1425
+ */
1426
+ function normalizeTweeSourceText(text) {
1427
+ return normalizeSourceText(text).replace(BOMS_BEFORE_HEADER, "");
1428
+ }
823
1429
  //#endregion
824
1430
  //#region src/util.ts
825
1431
  /**
826
1432
  * Shared file I/O utilities.
827
1433
  */
828
- /** Read a file as UTF-8 with BOM stripping and line ending normalization. */
829
- function readUTF8(filename) {
830
- return normalizeSourceText(readFileSync(filename, "utf-8"));
1434
+ /** The charset assumed for text that is not valid UTF-8, as Tweego assumes it. */
1435
+ const FALLBACK_CHARSET = "windows-1252";
1436
+ /**
1437
+ * Throws on invalid UTF-8 instead of replacing bytes with U+FFFD. `ignoreBOM` keeps a leading BOM in the
1438
+ * text, as `readFileSync(file, 'utf-8')` did, so {@link normalizeSourceText} strips exactly one.
1439
+ */
1440
+ const utf8Decoder = new TextDecoder("utf-8", {
1441
+ fatal: true,
1442
+ ignoreBOM: true
1443
+ });
1444
+ /**
1445
+ * The code points of the Windows-1252 bytes 0x80 to 0x9F, from the WHATWG Encoding Standard. The five bytes
1446
+ * Windows-1252 leaves undefined map to the C1 control with the same number, as the standard and Go's charmap
1447
+ * do. Every other byte is the code point with the same number.
1448
+ *
1449
+ * Decoded by hand, because Node's `TextDecoder('windows-1252')` decodes as ISO-8859-1 in some releases
1450
+ * (Node 22.18 turns 0x80 into U+0080 instead of €), and builds without ICU lack it.
1451
+ */
1452
+ const WINDOWS_1252_C1 = [
1453
+ 8364,
1454
+ 129,
1455
+ 8218,
1456
+ 402,
1457
+ 8222,
1458
+ 8230,
1459
+ 8224,
1460
+ 8225,
1461
+ 710,
1462
+ 8240,
1463
+ 352,
1464
+ 8249,
1465
+ 338,
1466
+ 141,
1467
+ 381,
1468
+ 143,
1469
+ 144,
1470
+ 8216,
1471
+ 8217,
1472
+ 8220,
1473
+ 8221,
1474
+ 8226,
1475
+ 8211,
1476
+ 8212,
1477
+ 732,
1478
+ 8482,
1479
+ 353,
1480
+ 8250,
1481
+ 339,
1482
+ 157,
1483
+ 382,
1484
+ 376
1485
+ ];
1486
+ function decodeWindows1252(bytes) {
1487
+ const chunks = [];
1488
+ for (let start = 0; start < bytes.length; start += 8192) {
1489
+ const codes = Array.from(bytes.subarray(start, start + 8192), (b) => b >= 128 && b <= 159 ? WINDOWS_1252_C1[b - 128] ?? b : b);
1490
+ chunks.push(String.fromCharCode(...codes));
1491
+ }
1492
+ return chunks.join("");
1493
+ }
1494
+ /**
1495
+ * Decode bytes read from `filename` as UTF-8. Bytes that are not valid UTF-8 are decoded as Windows-1252
1496
+ * instead, with a warning naming the file, as Tweego does (`fileReadAllWithEncoding` in io.go), so a legacy
1497
+ * file keeps its non-ASCII characters instead of losing them to U+FFFD.
1498
+ *
1499
+ * The text is returned as decoded: a leading BOM and CR line endings are kept (see {@link normalizeSourceText}).
1500
+ */
1501
+ function decodeText(bytes, filename) {
1502
+ try {
1503
+ return {
1504
+ text: utf8Decoder.decode(bytes),
1505
+ diagnostics: []
1506
+ };
1507
+ } catch {
1508
+ return {
1509
+ text: decodeWindows1252(bytes),
1510
+ diagnostics: [{
1511
+ level: "warning",
1512
+ message: `read ${filename}: Invalid UTF-8; assuming charset is ${FALLBACK_CHARSET}.`,
1513
+ file: filename
1514
+ }]
1515
+ };
1516
+ }
1517
+ }
1518
+ /**
1519
+ * Read a text file: decode it with {@link decodeText} (UTF-8, or Windows-1252 when it is not valid UTF-8),
1520
+ * strip a leading BOM and convert CRLF and bare CR line endings to LF. This is the one path every file read
1521
+ * as text goes through: sources, modules, the head file, story formats and the config file.
1522
+ *
1523
+ * @param diagnostics Receives the warning when the file is not valid UTF-8. Without it, the file is decoded
1524
+ * the same way and the warning is dropped.
1525
+ * @throws When the file cannot be read.
1526
+ */
1527
+ function readUTF8(filename, diagnostics) {
1528
+ const decoded = decodeText(readFileSync(filename), filename);
1529
+ diagnostics?.push(...decoded.diagnostics);
1530
+ return normalizeSourceText(decoded.text);
831
1531
  }
832
1532
  /** Read a file as base64. */
833
1533
  function readBase64(filename) {
@@ -843,20 +1543,11 @@ function baseNameWithoutExt(filename) {
843
1543
  /**
844
1544
  * Story format discovery, loading, and SemVer matching.
845
1545
  * Ported from formats.go + config.go.
1546
+ *
1547
+ * Every lookup (local folders here, the download cache and remote indices in remote-formats.ts)
1548
+ * goes through the same version parsing ({@link parseVersion}) and the same matching rules
1549
+ * ({@link selectFormatCandidate}).
846
1550
  */
847
- /**
848
- * Attempt to fix non-strict JSON (trailing commas, single quotes, unquoted keys).
849
- */
850
- function relaxedJSONParse(json) {
851
- try {
852
- return JSON.parse(json);
853
- } catch {}
854
- let fixed = json;
855
- fixed = fixed.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, "\"$1\"");
856
- fixed = fixed.replace(/(?<=^|[{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:/gm, "\"$1\":");
857
- fixed = fixed.replace(/,\s*([}\]])/g, "$1");
858
- return JSON.parse(fixed);
859
- }
860
1551
  /** A `"setup": function` property, which Harlowe appends to its otherwise-JSON format object. */
861
1552
  const SETUP_FUNCTION_PROPERTY = /,\s*"setup"\s*:\s*function\b/g;
862
1553
  /** Drop a trailing `"setup": function(){…}` property from a format object, if it has one. */
@@ -866,151 +1557,239 @@ function stripSetupFunction(chunk) {
866
1557
  return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
867
1558
  }
868
1559
  /**
869
- * Parse the Twine 2 format.js JSON chunk.
870
- * Handles Harlowe's malformed JSON by stripping the "setup" function property. The workaround
871
- * keys on the property itself, so the same bytes parse whether they come from a `harlowe-3`
872
- * directory or a direct download.
873
- * Per spec, the name key is Optional.
1560
+ * Parse the format object at `start`–`end` of `text`: strict JSON first (fast), then the
1561
+ * JavaScript literal subset formats use. Error positions point into `text`.
1562
+ */
1563
+ function parseFormatObject(text, start, end) {
1564
+ try {
1565
+ return {
1566
+ ok: true,
1567
+ value: JSON.parse(text.slice(start, end))
1568
+ };
1569
+ } catch {}
1570
+ try {
1571
+ return {
1572
+ ok: true,
1573
+ value: parseRelaxedJSON(text, start, end)
1574
+ };
1575
+ } catch (e) {
1576
+ return {
1577
+ ok: false,
1578
+ error: e instanceof Error ? e.message : String(e)
1579
+ };
1580
+ }
1581
+ }
1582
+ /** Check a parsed format object's fields. Per spec, `name` is optional; `version` and `source` are required. */
1583
+ function toFormatJSON(raw) {
1584
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
1585
+ ok: false,
1586
+ reason: "Story format JSON chunk is not an object."
1587
+ };
1588
+ const obj = raw;
1589
+ if (typeof obj.version !== "string") return {
1590
+ ok: false,
1591
+ reason: "Story format has no \"version\" string."
1592
+ };
1593
+ if (typeof obj.source !== "string") return {
1594
+ ok: false,
1595
+ reason: "Story format has no \"source\" string."
1596
+ };
1597
+ if (!parseVersion(obj.version)) return {
1598
+ ok: false,
1599
+ reason: `Story format version ${JSON.stringify(obj.version)} is not a SemVer version.`
1600
+ };
1601
+ const data = {
1602
+ name: typeof obj.name === "string" ? obj.name : "Untitled Story Format",
1603
+ version: obj.version,
1604
+ source: obj.source,
1605
+ proofing: obj.proofing === true
1606
+ };
1607
+ if (typeof obj.author === "string") data.author = obj.author;
1608
+ if (typeof obj.description === "string") data.description = obj.description;
1609
+ if (typeof obj.image === "string") data.image = obj.image;
1610
+ if (typeof obj.url === "string") data.url = obj.url;
1611
+ if (typeof obj.license === "string") data.license = obj.license;
1612
+ return {
1613
+ ok: true,
1614
+ data
1615
+ };
1616
+ }
1617
+ /**
1618
+ * Read the metadata of a Twine 2 format.js, or say why it cannot be used.
874
1619
  *
875
- * @param _formatId No longer used; kept so existing callers keep compiling.
1620
+ * The object passed to `window.storyFormat()` may be strict JSON or a JavaScript object literal
1621
+ * (single quotes, unquoted keys, trailing commas, comments). String values come back exactly as
1622
+ * JavaScript evaluation gives them. Harlowe's function-valued `setup` property is dropped; the
1623
+ * workaround keys on the property itself, so the same bytes parse whether they come from a
1624
+ * `harlowe-3` directory or a direct download.
876
1625
  */
877
- function parseFormatJSON(source, _formatId) {
1626
+ function decodeFormatJSON(source) {
878
1627
  const first = source.indexOf("{");
879
1628
  const last = source.lastIndexOf("}");
880
- if (first === -1 || last === -1) return null;
881
- const chunk = source.slice(first, last + 1);
882
- const parse = (json) => {
883
- const raw = relaxedJSONParse(json);
884
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
885
- const obj = raw;
886
- if (typeof obj.version !== "string" || typeof obj.source !== "string") return null;
887
- if (!parseSemver(obj.version)) return null;
888
- const result = {
889
- name: typeof obj.name === "string" ? obj.name : "Untitled Story Format",
890
- version: obj.version,
891
- source: obj.source,
892
- proofing: obj.proofing === true
893
- };
894
- if (typeof obj.author === "string") result.author = obj.author;
895
- if (typeof obj.description === "string") result.description = obj.description;
896
- if (typeof obj.image === "string") result.image = obj.image;
897
- if (typeof obj.url === "string") result.url = obj.url;
898
- if (typeof obj.license === "string") result.license = obj.license;
899
- return result;
1629
+ if (first === -1 || last < first) return {
1630
+ ok: false,
1631
+ reason: "Could not find Twine 2 style story format JSON chunk."
1632
+ };
1633
+ const parsed = parseFormatObject(source, first, last + 1);
1634
+ if (parsed.ok) return toFormatJSON(parsed.value);
1635
+ const stripped = stripSetupFunction(source.slice(first, last + 1));
1636
+ const retried = stripped === void 0 ? void 0 : parseFormatObject(stripped, 0, stripped.length);
1637
+ if (retried?.ok) return toFormatJSON(retried.value);
1638
+ return {
1639
+ ok: false,
1640
+ reason: `Could not decode story format JSON chunk: ${parsed.error}`
900
1641
  };
1642
+ }
1643
+ /**
1644
+ * Parse the Twine 2 format.js JSON chunk; null when it cannot be used ({@link decodeFormatJSON}
1645
+ * gives the reason).
1646
+ *
1647
+ * @param _formatId No longer used; kept so existing callers keep compiling.
1648
+ */
1649
+ function parseFormatJSON(source, _formatId) {
1650
+ const result = decodeFormatJSON(source);
1651
+ return result.ok ? result.data : null;
1652
+ }
1653
+ /** A directory's entries in a stable order, or none when it cannot be read. */
1654
+ function listDirectory(dir) {
901
1655
  try {
902
- return parse(chunk);
1656
+ return readdirSync(dir).sort();
903
1657
  } catch {
904
- const stripped = stripSetupFunction(chunk);
905
- if (stripped === void 0) return null;
906
- try {
907
- return parse(stripped);
908
- } catch {
909
- return null;
910
- }
1658
+ return [];
1659
+ }
1660
+ }
1661
+ function isDirectory(path) {
1662
+ try {
1663
+ return statSync(path).isDirectory();
1664
+ } catch {
1665
+ return false;
1666
+ }
1667
+ }
1668
+ function isFile(path) {
1669
+ try {
1670
+ return statSync(path).isFile();
1671
+ } catch {
1672
+ return false;
911
1673
  }
912
1674
  }
913
1675
  /**
914
- * Discover all story formats in the given search directories.
915
- * Implements SemVer-based version pruning: within each (name, major) group,
916
- * only the highest minor.patch survives.
1676
+ * Load the format in one folder: its format.js (Twine 2), else its header.html (Twine 1).
1677
+ * A format.js that cannot be used is reported as a warning, as Tweego does.
917
1678
  */
918
- function discoverFormats(searchDirs) {
919
- const formats = /* @__PURE__ */ new Map();
920
- const baseFilenames = ["format.js", "header.html"];
921
- for (const searchDir of searchDirs) {
1679
+ function loadFormatDir(searchDir, id, diagnostics) {
1680
+ const formatDir = join(searchDir, id);
1681
+ if (!isDirectory(formatDir)) return void 0;
1682
+ for (const baseFilename of ["format.js", "header.html"]) {
1683
+ const filename = join(formatDir, baseFilename);
1684
+ if (!isFile(filename)) continue;
1685
+ if (baseFilename === "header.html") return {
1686
+ id,
1687
+ filename,
1688
+ isTwine2: false,
1689
+ name: id,
1690
+ version: "",
1691
+ proofing: false
1692
+ };
1693
+ const encoding = [];
1694
+ let decoded;
922
1695
  try {
923
- if (!statSync(searchDir).isDirectory()) continue;
924
- } catch {
925
- continue;
1696
+ decoded = decodeFormatJSON(readUTF8(filename, encoding));
1697
+ } catch (e) {
1698
+ decoded = {
1699
+ ok: false,
1700
+ reason: `Could not read ${filename}: ${e instanceof Error ? e.message : String(e)}`
1701
+ };
926
1702
  }
927
- let entries;
928
- try {
929
- entries = readdirSync(searchDir);
930
- } catch {
1703
+ if (!decoded.ok) {
1704
+ diagnostics?.push(...encoding);
1705
+ diagnostics?.push({
1706
+ level: "warning",
1707
+ message: `format ${id}: Skipping format; ${decoded.reason} (${filename})`
1708
+ });
931
1709
  continue;
932
1710
  }
933
- for (const entry of entries) {
934
- const formatDir = join(searchDir, entry);
935
- try {
936
- if (!statSync(formatDir).isDirectory()) continue;
937
- } catch {
938
- continue;
939
- }
940
- for (const baseFilename of baseFilenames) {
941
- const formatFilename = join(formatDir, baseFilename);
942
- try {
943
- if (!statSync(formatFilename).isFile()) continue;
944
- } catch {
945
- continue;
946
- }
947
- const isTwine2 = baseFilename === "format.js";
948
- const format = {
949
- id: entry,
950
- filename: formatFilename,
951
- isTwine2,
952
- name: "",
953
- version: "",
954
- proofing: false
955
- };
956
- if (isTwine2) try {
957
- const data = parseFormatJSON(readUTF8(formatFilename), entry);
958
- if (!data) continue;
959
- format.name = data.name;
960
- format.version = data.version;
961
- format.proofing = data.proofing ?? false;
962
- if (data.author) format.author = data.author;
963
- if (data.description) format.description = data.description;
964
- if (data.image) format.image = data.image;
965
- if (data.url) format.url = data.url;
966
- if (data.license) format.license = data.license;
967
- } catch {
968
- continue;
969
- }
970
- else format.name = entry;
971
- formats.set(entry, format);
972
- break;
973
- }
974
- }
1711
+ const { data } = decoded;
1712
+ const format = {
1713
+ id,
1714
+ filename,
1715
+ isTwine2: true,
1716
+ name: data.name,
1717
+ version: data.version,
1718
+ proofing: data.proofing ?? false
1719
+ };
1720
+ if (data.author) format.author = data.author;
1721
+ if (data.description) format.description = data.description;
1722
+ if (data.image) format.image = data.image;
1723
+ if (data.url) format.url = data.url;
1724
+ if (data.license) format.license = data.license;
1725
+ return format;
1726
+ }
1727
+ }
1728
+ /**
1729
+ * Discover every story format in the search directories, keyed by folder name (the format ID),
1730
+ * without version pruning.
1731
+ *
1732
+ * Directories are searched in order, and a later directory outranks an earlier one: when two hold
1733
+ * the same folder name, the later one's format is kept. The map iterates in that order too, lowest
1734
+ * rank first. Formats that cannot be used are left out, with a warning in `diagnostics`.
1735
+ */
1736
+ function discoverAllFormats(searchDirs, diagnostics) {
1737
+ const formats = /* @__PURE__ */ new Map();
1738
+ for (const searchDir of searchDirs) for (const entry of listDirectory(searchDir)) {
1739
+ const format = loadFormatDir(searchDir, entry, diagnostics);
1740
+ if (!format) continue;
1741
+ formats.delete(entry);
1742
+ formats.set(entry, format);
975
1743
  }
976
- pruneFormats(formats);
977
1744
  return formats;
978
1745
  }
1746
+ /** The version a format is pruned and selected by, or null for one outside SemVer pruning. */
1747
+ function prunableVersion(format) {
1748
+ return format.isTwine2 && format.name ? parseVersion(format.version) : null;
1749
+ }
1750
+ /** The search directory a discovered format was found in. */
1751
+ function searchDirOf(format) {
1752
+ return dirname(dirname(format.filename));
1753
+ }
979
1754
  /**
980
- * Prune formats by SemVer: within each (name, major) group,
981
- * keep only the highest version. First-found wins for same name+version.
1755
+ * Prune formats by SemVer: within each (name, major) group, keep only the version with the highest
1756
+ * precedence (a release outranks its prereleases). Between equal versions, the folder in the
1757
+ * higher-ranked search directory wins, and within one directory the first folder name in sort
1758
+ * order. Returns a new map in the same order.
1759
+ *
1760
+ * Pruning serves name-based selection and listings. An explicit format ID is looked up in the
1761
+ * unpruned map, so a pinned folder is used even when another folder holds a newer version.
982
1762
  */
983
1763
  function pruneFormats(formats) {
984
- const bestByNameMajor = /* @__PURE__ */ new Map();
985
- const seenNameVersion = /* @__PURE__ */ new Set();
986
- for (const [id, f] of formats) {
987
- if (!f.isTwine2 || !f.name || !f.version) continue;
988
- const v = parseSemver(f.version);
989
- if (!v) continue;
990
- const nameVersionKey = `${f.name}@${f.version}`;
991
- if (seenNameVersion.has(nameVersionKey)) {
992
- formats.delete(id);
993
- continue;
994
- }
995
- seenNameVersion.add(nameVersionKey);
996
- const groupKey = `${f.name}@${v[0]}`;
997
- const existing = bestByNameMajor.get(groupKey);
998
- if (!existing || semverCompare(v, existing.version) > 0) bestByNameMajor.set(groupKey, {
999
- id,
1000
- version: v
1764
+ const dirRank = /* @__PURE__ */ new Map();
1765
+ for (const format of formats.values()) {
1766
+ const dir = searchDirOf(format);
1767
+ if (!dirRank.has(dir)) dirRank.set(dir, dirRank.size);
1768
+ }
1769
+ const rankOf = (format) => dirRank.get(searchDirOf(format)) ?? 0;
1770
+ const best = /* @__PURE__ */ new Map();
1771
+ for (const format of formats.values()) {
1772
+ const version = prunableVersion(format);
1773
+ if (!version) continue;
1774
+ const group = `${format.name}@${version.major}`;
1775
+ const current = best.get(group);
1776
+ const order = current ? compareVersions(version, current.version) : 1;
1777
+ if (order > 0 || order === 0 && current && rankOf(format) > rankOf(current.format)) best.set(group, {
1778
+ format,
1779
+ version
1001
1780
  });
1002
1781
  }
1003
- for (const [id, f] of [...formats]) {
1004
- if (!f.isTwine2 || !f.name || !f.version) continue;
1005
- const v = parseSemver(f.version);
1006
- if (!v) continue;
1007
- const groupKey = `${f.name}@${v[0]}`;
1008
- const best = bestByNameMajor.get(groupKey);
1009
- if (best && best.id !== id) formats.delete(id);
1010
- }
1782
+ return new Map([...formats].filter(([id, format]) => {
1783
+ const version = prunableVersion(format);
1784
+ return version === null || best.get(`${format.name}@${version.major}`)?.format.id === id;
1785
+ }));
1011
1786
  }
1012
1787
  /**
1013
- * Get the default format search directories.
1788
+ * Get the format search directories, lowest rank first: the story format subdirectories of the
1789
+ * home directory, then of the working directory, then each `TWEEGO_PATH` entry, then `extraPaths`
1790
+ * (`formatPaths`). A later directory outranks an earlier one for the same format folder name, so
1791
+ * `formatPaths` override `TWEEGO_PATH`, which overrides the home and working directories, as in
1792
+ * Tweego.
1014
1793
  */
1015
1794
  function getFormatSearchDirs(extraPaths = [], useTweegoPath = true) {
1016
1795
  const subdirNames = [
@@ -1025,41 +1804,27 @@ function getFormatSearchDirs(extraPaths = [], useTweegoPath = true) {
1025
1804
  basePaths.add(homedir());
1026
1805
  } catch {}
1027
1806
  basePaths.add(process.cwd());
1028
- const dirs = [];
1029
- for (const base of basePaths) for (const sub of subdirNames) {
1030
- const dir = join(base, sub);
1031
- try {
1032
- if (statSync(dir).isDirectory()) dirs.push(dir);
1033
- } catch {}
1034
- }
1807
+ const dirs = [...basePaths].flatMap((base) => subdirNames.map((sub) => join(base, sub))).filter(isDirectory);
1808
+ const tweegoPath = useTweegoPath ? process.env.TWEEGO_PATH : void 0;
1809
+ if (tweegoPath) dirs.push(...tweegoPath.split(process.platform === "win32" ? ";" : ":"));
1035
1810
  dirs.push(...extraPaths);
1036
- if (useTweegoPath) {
1037
- const tweegoPath = process.env.TWEEGO_PATH;
1038
- if (tweegoPath) dirs.push(...tweegoPath.split(process.platform === "win32" ? ";" : ":"));
1039
- }
1040
1811
  return dirs;
1041
1812
  }
1042
- /** Parse a simple semver string into [major, minor, patch]. */
1813
+ /**
1814
+ * Parse a version string into [major, minor, patch]. Accepts what {@link parseVersion} accepts
1815
+ * (`v1.2.3`, `1.2`, `2.0.0-beta.1`); the prerelease and build metadata are dropped.
1816
+ */
1043
1817
  function parseSemver(v) {
1044
- const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
1045
- if (!m) return null;
1046
- return [
1047
- Number(m[1]),
1048
- Number(m[2]),
1049
- Number(m[3])
1050
- ];
1051
- }
1052
- function semverCompare(a, b) {
1053
- const [aMajor, aMinor, aPatch] = a;
1054
- const [bMajor, bMinor, bPatch] = b;
1055
- if (aMajor !== bMajor) return aMajor - bMajor;
1056
- if (aMinor !== bMinor) return aMinor - bMinor;
1057
- if (aPatch !== bPatch) return aPatch - bPatch;
1058
- return 0;
1818
+ const parsed = parseVersion(v);
1819
+ return parsed ? [
1820
+ parsed.major,
1821
+ parsed.minor,
1822
+ parsed.patch
1823
+ ] : null;
1059
1824
  }
1060
1825
  /** Build a directory-style format ID from a name and version, e.g. ('SugarCube', '2.37.3') → 'sugarcube-2'. */
1061
1826
  function makeFormatId(name, version) {
1062
- const major = parseSemver(version)?.[0] ?? 0;
1827
+ const major = parseVersion(version)?.major ?? 0;
1063
1828
  return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
1064
1829
  }
1065
1830
  /** Describe a format request for diagnostics. */
@@ -1070,81 +1835,164 @@ function describeFormatRequest(request) {
1070
1835
  default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
1071
1836
  }
1072
1837
  }
1838
+ /** {@link selectFormatCandidate} over one set of candidates, matching names without regard to case. */
1839
+ function pickFormatCandidate(request, candidates, describe, allowOlder) {
1840
+ const wanted = request.kind === "name" ? parseVersion(request.version) : null;
1841
+ let best;
1842
+ for (const candidate of candidates) {
1843
+ const { name, version } = describe(candidate);
1844
+ const have = parseVersion(version);
1845
+ if (!have) continue;
1846
+ if (request.kind === "id") {
1847
+ if (makeFormatId(name, version) !== request.id.toLowerCase()) continue;
1848
+ } else {
1849
+ if (name.toLowerCase() !== request.name.toLowerCase()) continue;
1850
+ if (wanted) {
1851
+ const order = compareVersions(have, wanted);
1852
+ if (order === 0) return candidate;
1853
+ if (have.major !== wanted.major) continue;
1854
+ if (!allowOlder && order < 0) continue;
1855
+ }
1856
+ }
1857
+ if (!best || compareVersions(have, best.version) > 0) best = {
1858
+ candidate,
1859
+ version: have
1860
+ };
1861
+ }
1862
+ return best?.candidate;
1863
+ }
1864
+ /**
1865
+ * Pick the candidate that best answers a format request. Local folders, the download cache and
1866
+ * remote indices all select through this one function.
1867
+ *
1868
+ * - An ID request ('sugarcube-2') matches, without regard to case, a candidate whose name and
1869
+ * major version build that ID, and takes the greatest such version.
1870
+ * - A name request matches the name without regard to case, but a candidate whose name matches in
1871
+ * case too is preferred when one answers the request. An exact version (prerelease included)
1872
+ * wins outright; otherwise the greatest version with the same major that is not older than the
1873
+ * one asked for (or, with `allowOlder`, the greatest same-major version). An unparseable
1874
+ * requested version takes the greatest version of any major.
1875
+ *
1876
+ * Versions compare by SemVer precedence ({@link compareVersions}), so a release outranks its
1877
+ * prereleases. Between equal versions, the earlier candidate wins.
1878
+ */
1879
+ function selectFormatCandidate(request, candidates, describe, options = {}) {
1880
+ const allowOlder = options.allowOlder ?? false;
1881
+ if (request.kind === "name") {
1882
+ const preferred = pickFormatCandidate(request, candidates.filter((c) => describe(c).name === request.name), describe, allowOlder);
1883
+ if (preferred !== void 0) return preferred;
1884
+ }
1885
+ return pickFormatCandidate(request, candidates, describe, allowOlder);
1886
+ }
1887
+ /** The Twine 2 formats of a discovered map, those from higher-ranked search directories first. */
1888
+ function rankedTwine2Formats(formats) {
1889
+ return [...formats.values()].filter((f) => f.isTwine2).reverse();
1890
+ }
1891
+ /**
1892
+ * Look a format ID up among discovered folders. The ID matches a folder name without regard to
1893
+ * case; an exact-case folder is preferred, then the highest-ranked one.
1894
+ */
1895
+ function findFormatById(formats, id) {
1896
+ const exact = formats.get(id);
1897
+ if (exact) return exact;
1898
+ const wanted = id.toLowerCase();
1899
+ return [...formats.values()].reverse().find((f) => f.id.toLowerCase() === wanted);
1900
+ }
1901
+ /**
1902
+ * Read the story format source (for Twine 2, extract the `source` property from JSON).
1903
+ * `diagnostics` receives a warning when the format file is not valid UTF-8 (it is read as Windows-1252).
1904
+ */
1905
+ function readFormatSource(format, diagnostics) {
1906
+ const source = readUTF8(format.filename, diagnostics);
1907
+ if (!format.isTwine2) return source;
1908
+ const decoded = decodeFormatJSON(source);
1909
+ if (!decoded.ok) throw new Error(`Cannot parse format ${format.id} JSON: ${decoded.reason}`);
1910
+ return decoded.data.source;
1911
+ }
1912
+ //#endregion
1913
+ //#region src/atomic-write.ts
1914
+ /**
1915
+ * Atomic file replacement, for every file twee-ts writes that something else may read
1916
+ * meanwhile: build output (which live-reload servers read on each change) and downloaded
1917
+ * story formats (which other twee-ts processes share).
1918
+ */
1073
1919
  /**
1074
- * Pick the candidate that best answers a format request.
1920
+ * Writes `data` to `path` so that a reader sees either the previous file or the complete new
1921
+ * one, never a partly written file. The data goes to a temporary file in the same folder,
1922
+ * which is then renamed over `path`. When `path` is a symlink, the file it points to is
1923
+ * replaced, and an existing file keeps its permissions.
1075
1924
  *
1076
- * - An ID request ('sugarcube-2') matches a candidate whose name and major version build that ID,
1077
- * and takes the greatest such version.
1078
- * - A name request matches the name case-insensitively. An exact version wins outright; otherwise
1079
- * the greatest version with the same major that is not older than the one asked for (or, with
1080
- * `allowOlder`, the greatest same-major version). An unparseable requested version takes the
1081
- * greatest version of any major.
1925
+ * On failure (a full disk, a missing folder) the temporary file is removed, `path` is left
1926
+ * as it was, and the error is thrown with `path` in its message and its original `code`.
1082
1927
  */
1083
- function selectFormatCandidate(request, candidates, describe, options = {}) {
1084
- const allowOlder = options.allowOlder ?? false;
1085
- const wanted = request.kind === "name" ? parseSemver(request.version) : null;
1086
- let best;
1087
- for (const candidate of candidates) {
1088
- const { name, version } = describe(candidate);
1089
- const have = parseSemver(version);
1090
- if (!have) continue;
1091
- if (request.kind === "id") {
1092
- if (makeFormatId(name, version) !== request.id.toLowerCase()) continue;
1093
- } else {
1094
- if (name.toLowerCase() !== request.name.toLowerCase()) continue;
1095
- if (wanted) {
1096
- if (semverCompare(have, wanted) === 0) return candidate;
1097
- if (have[0] !== wanted[0]) continue;
1098
- if (!allowOlder && semverCompare(have, wanted) < 0) continue;
1099
- }
1100
- }
1101
- if (!best || semverCompare(have, best.version) > 0) best = {
1102
- candidate,
1103
- version: have
1104
- };
1928
+ function writeFileAtomic(path, data) {
1929
+ const target = followSymlink(path);
1930
+ const temp = join(dirname(target), `.${basename(target)}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
1931
+ try {
1932
+ writeFileSync(temp, data, {
1933
+ encoding: "utf-8",
1934
+ flag: "wx"
1935
+ });
1936
+ keepPermissions(target, temp);
1937
+ renameSync(temp, target);
1938
+ } catch (e) {
1939
+ rmSync(temp, { force: true });
1940
+ throw writeError(path, e);
1105
1941
  }
1106
- return best?.candidate;
1107
1942
  }
1108
- /** Get format ID from Twine 2 name and version, using SemVer major matching. */
1109
- function getFormatIdByNameAndVersion(formats, name, version) {
1110
- const wanted = parseSemver(version);
1111
- let bestVersion = null;
1112
- let bestId;
1113
- for (const [id, f] of formats) {
1114
- if (!f.isTwine2 || f.name !== name) continue;
1115
- const have = parseSemver(f.version);
1116
- if (!have) continue;
1117
- if (wanted === null || have[0] === wanted[0] && semverCompare(have, wanted) >= 0) {
1118
- if (!bestVersion || semverCompare(have, bestVersion) > 0) {
1119
- bestVersion = have;
1120
- bestId = id;
1121
- }
1122
- }
1943
+ /** The file a symlink at `path` points to, or `path` itself (also when it doesn't exist yet). */
1944
+ function followSymlink(path) {
1945
+ try {
1946
+ return realpathSync(path);
1947
+ } catch {
1948
+ return path;
1123
1949
  }
1124
- return bestId;
1125
1950
  }
1126
- /** Read the story format source (for Twine 2, extract the `source` property from JSON). */
1127
- function readFormatSource(format) {
1128
- const source = readUTF8(format.filename);
1129
- if (format.isTwine2) {
1130
- const data = parseFormatJSON(source, format.id);
1131
- if (!data) throw new Error(`Cannot parse format ${format.id} JSON`);
1132
- return data.source;
1133
- }
1134
- return source;
1951
+ /**
1952
+ * Gives `temp` the permission bits of `target`, when `target` exists. Best effort: on a file
1953
+ * system without Unix permissions chmod can fail, and writing in place never kept them there either.
1954
+ */
1955
+ function keepPermissions(target, temp) {
1956
+ const existing = statSync(target, { throwIfNoEntry: false });
1957
+ if (existing === void 0) return;
1958
+ try {
1959
+ chmodSync(temp, existing.mode & 4095);
1960
+ } catch {}
1961
+ }
1962
+ /** `cause` as an error that names `path` and keeps the original error code. */
1963
+ function writeError(path, cause) {
1964
+ const message = cause instanceof Error ? cause.message : String(cause);
1965
+ const error = new Error(`Cannot write ${path}: ${message}`, { cause });
1966
+ if (cause instanceof Error && "code" in cause) error.code = cause.code;
1967
+ return error;
1135
1968
  }
1136
1969
  //#endregion
1137
1970
  //#region src/remote-formats.ts
1138
1971
  /**
1139
1972
  * Remote story format fetching, caching, and checksum verification.
1140
1973
  * Uses the Story Formats Archive (SFA) as the default source.
1974
+ *
1975
+ * Two caches hold downloads. Formats found through an index are shared by name and version
1976
+ * (`<cache>/<name>/<version>/format.js`). A format downloaded from a direct URL is kept under
1977
+ * that URL instead, so one project's copy never answers another project's request.
1978
+ * Cache files are replaced atomically, so another process never reads a partly written one.
1141
1979
  */
1142
1980
  const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
1981
+ /** The longest delay a timer accepts; a longer timeout means no limit. */
1982
+ const MAX_TIMER_DELAY = 2147483647;
1143
1983
  /** Get the cache directory for downloaded story formats. */
1144
1984
  function getCacheDir() {
1145
1985
  const base = process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache");
1146
1986
  return join(base, "twee-ts", "storyformats");
1147
1987
  }
1988
+ /** The cache directory for formats downloaded from direct URLs, beside {@link getCacheDir}. */
1989
+ function getUrlCacheDir() {
1990
+ return join(dirname(getCacheDir()), "storyformat-urls");
1991
+ }
1992
+ /** The name of one direct URL's cache entry: a hash of the URL, so each URL has its own. */
1993
+ function urlCacheKey(url) {
1994
+ return createHash("sha256").update(url).digest("hex");
1995
+ }
1148
1996
  /**
1149
1997
  * Whether a string can serve as one directory name inside the cache: not empty, not `.` or `..`,
1150
1998
  * and free of path separators. Format names and versions come from downloaded metadata, so they
@@ -1167,25 +2015,38 @@ function cachedFormatDir(name, version) {
1167
2015
  if (!isInside(root, dir)) throw new Error(`Refusing to cache a story format outside the cache directory ${root}: ${dir}`);
1168
2016
  return dir;
1169
2017
  }
1170
- /** Create `dir` as a plain directory, or check that it already is one (a symlink could lead out of the cache). */
2018
+ /** Whether `e` is a Node.js system error with the given code. */
2019
+ function hasErrorCode(e, code) {
2020
+ return e instanceof Error && "code" in e && e.code === code;
2021
+ }
2022
+ /**
2023
+ * Create `dir` as a plain directory, or check that it already is one (a symlink could lead out of the cache).
2024
+ * A directory another process creates between the check and the mkdir is fine.
2025
+ */
1171
2026
  function ensurePlainDirectory(dir) {
1172
- const stat = lstatSync(dir, { throwIfNoEntry: false });
1173
- if (stat === void 0) {
2027
+ if (lstatSync(dir, { throwIfNoEntry: false }) === void 0) try {
1174
2028
  mkdirSync(dir);
1175
2029
  return;
2030
+ } catch (e) {
2031
+ if (!hasErrorCode(e, "EEXIST")) throw e;
1176
2032
  }
1177
- if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`Refusing to write a story format outside the cache directory: ${dir} is not a plain directory`);
2033
+ const stat = lstatSync(dir, { throwIfNoEntry: false });
2034
+ if (stat === void 0 || stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`Refusing to write a story format outside the cache directory: ${dir} is not a plain directory`);
1178
2035
  }
1179
- /** Write a downloaded format.js to `<cache>/<name>/<version>/format.js` and return its path. */
1180
- function writeCachedFormat(name, version, text) {
1181
- const dir = cachedFormatDir(name, version);
1182
- const root = resolve(getCacheDir());
2036
+ /**
2037
+ * Write a downloaded format.js to `<root>/<...segments>/format.js`, replacing any earlier copy
2038
+ * atomically, and return its path. Every directory below `root` must be a plain one.
2039
+ */
2040
+ function writeCacheEntry(root, segments, text) {
1183
2041
  mkdirSync(root, { recursive: true });
1184
- ensurePlainDirectory(join(root, name));
1185
- ensurePlainDirectory(dir);
2042
+ const dir = segments.reduce((parent, segment) => {
2043
+ const child = join(parent, segment);
2044
+ ensurePlainDirectory(child);
2045
+ return child;
2046
+ }, root);
1186
2047
  const formatPath = join(dir, "format.js");
1187
2048
  if (lstatSync(formatPath, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error(`Refusing to write a story format outside the cache directory: ${formatPath} is a symlink`);
1188
- writeFileSync(formatPath, text, "utf-8");
2049
+ writeFileAtomic(formatPath, text);
1189
2050
  return formatPath;
1190
2051
  }
1191
2052
  /** In-memory index cache, keyed by URL. Cleared each compile. */
@@ -1194,6 +2055,78 @@ const indexCache = /* @__PURE__ */ new Map();
1194
2055
  function clearIndexCache() {
1195
2056
  indexCache.clear();
1196
2057
  }
2058
+ /** Requests in progress, by what they fetch, so concurrent compiles make each request once. */
2059
+ const sharedRequests = /* @__PURE__ */ new Map();
2060
+ /**
2061
+ * Runs `start` once for all callers that ask for `key` while it is in progress. A caller whose
2062
+ * `signal` aborts stops waiting at once and rejects with the signal's reason; the request
2063
+ * itself is aborted (through the signal `start` receives) once no caller waits on it.
2064
+ */
2065
+ function shareRequest(key, signal, start) {
2066
+ if (signal?.aborted) return Promise.reject(signal.reason);
2067
+ const request = sharedRequests.get(key) ?? startSharedRequest(key, start);
2068
+ request.waiters++;
2069
+ return new Promise((resolve, reject) => {
2070
+ let waiting = true;
2071
+ const stopWaiting = () => {
2072
+ if (!waiting) return;
2073
+ waiting = false;
2074
+ signal?.removeEventListener("abort", onAbort);
2075
+ request.waiters--;
2076
+ };
2077
+ const onAbort = () => {
2078
+ stopWaiting();
2079
+ if (request.waiters === 0) {
2080
+ if (sharedRequests.get(key) === request) sharedRequests.delete(key);
2081
+ request.controller.abort(signal?.reason);
2082
+ }
2083
+ reject(signal?.reason);
2084
+ };
2085
+ signal?.addEventListener("abort", onAbort, { once: true });
2086
+ request.promise.then((value) => {
2087
+ stopWaiting();
2088
+ resolve(value);
2089
+ }, (e) => {
2090
+ stopWaiting();
2091
+ reject(e);
2092
+ });
2093
+ });
2094
+ }
2095
+ function startSharedRequest(key, start) {
2096
+ const controller = new AbortController();
2097
+ const request = {
2098
+ promise: start(controller.signal),
2099
+ controller,
2100
+ waiters: 0
2101
+ };
2102
+ sharedRequests.set(key, request);
2103
+ const forget = () => {
2104
+ if (sharedRequests.get(key) === request) sharedRequests.delete(key);
2105
+ };
2106
+ request.promise.then(forget, forget);
2107
+ return request;
2108
+ }
2109
+ /** The time limit for each request, from the caller's options. */
2110
+ function requestTimeout(options) {
2111
+ const timeout = options.timeout ?? 3e4;
2112
+ if (!(timeout >= 0)) throw new RangeError(`A story format request timeout must be 0 or more milliseconds, not ${timeout}`);
2113
+ return timeout;
2114
+ }
2115
+ /**
2116
+ * Fetch `url` as text, aborting when `signal` does or after `timeout` milliseconds (0: no limit).
2117
+ * `what` names the request in errors, as in "Failed to <what> from <url>".
2118
+ */
2119
+ async function fetchText(url, what, signal, timeout) {
2120
+ const limit = timeout > 0 && timeout <= MAX_TIMER_DELAY ? AbortSignal.timeout(timeout) : void 0;
2121
+ try {
2122
+ const res = await fetch(url, { signal: limit ? AbortSignal.any([signal, limit]) : signal });
2123
+ if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
2124
+ return await res.text();
2125
+ } catch (e) {
2126
+ if (limit?.aborted && !signal.aborted) throw new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`, { cause: e });
2127
+ throw e;
2128
+ }
2129
+ }
1197
2130
  /** Validate that a JSON value is a valid SFAIndexEntry. */
1198
2131
  function isValidEntry(val) {
1199
2132
  if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
@@ -1209,15 +2142,18 @@ function validateSFAIndex(json) {
1209
2142
  twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
1210
2143
  };
1211
2144
  }
1212
- /** Fetch and parse an SFA index.json, with in-memory caching. */
1213
- async function fetchIndex(url) {
2145
+ /** Fetch and parse an SFA index.json, with in-memory caching. Concurrent calls for one URL share a request. */
2146
+ async function fetchIndex(url, options = {}) {
2147
+ options.signal?.throwIfAborted();
1214
2148
  const cached = indexCache.get(url);
1215
2149
  if (cached) return cached;
1216
- const res = await fetch(url);
1217
- if (!res.ok) throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
1218
- const data = validateSFAIndex(await res.json());
1219
- indexCache.set(url, data);
1220
- return data;
2150
+ const timeout = requestTimeout(options);
2151
+ return shareRequest(`index\0${url}`, options.signal, async (signal) => {
2152
+ const text = await fetchText(url, "fetch format index", signal, timeout);
2153
+ const data = validateSFAIndex(JSON.parse(text));
2154
+ indexCache.set(url, data);
2155
+ return data;
2156
+ });
1221
2157
  }
1222
2158
  /** Find the best matching entry in an SFA index for a name or ID request (twine2 entries first). */
1223
2159
  function findEntryForRequest(index, request) {
@@ -1238,84 +2174,138 @@ async function verifySHA256(content, expectedHex) {
1238
2174
  function getDownloadUrl(indexUrl, entry, formatType) {
1239
2175
  return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
1240
2176
  }
1241
- /** Download a format, verify its checksum, write to cache, and return StoryFormatInfo. */
1242
- async function fetchAndCacheFormat(entry, downloadUrl) {
1243
- cachedFormatDir(entry.name, entry.version);
1244
- const res = await fetch(downloadUrl);
1245
- if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
1246
- const text = await res.text();
1247
- const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
1248
- if (checksumKey) {
1249
- const expected = entry.checksums[checksumKey];
1250
- if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
1251
- if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
1252
- }
1253
- const id = makeFormatId(entry.name, entry.version);
1254
- const data = parseFormatJSON(text, id);
1255
- if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
2177
+ /** The StoryFormatInfo of a Twine 2 format.js cached at `filename`. */
2178
+ function cachedFormatInfo(id, filename, data) {
1256
2179
  return {
1257
2180
  id,
1258
- filename: writeCachedFormat(entry.name, entry.version, text),
2181
+ filename,
1259
2182
  isTwine2: true,
1260
2183
  name: data.name,
1261
2184
  version: data.version,
1262
2185
  proofing: data.proofing ?? false
1263
2186
  };
1264
2187
  }
1265
- /** Download a direct format.js URL, parse its JSON, cache it, and return StoryFormatInfo. */
1266
- async function fetchDirectFormat(url) {
1267
- const res = await fetch(url);
1268
- if (!res.ok) throw new Error(`Failed to download format from ${url}: ${res.status} ${res.statusText}`);
1269
- const text = await res.text();
1270
- const data = parseFormatJSON(text);
1271
- if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
1272
- return {
1273
- id: makeFormatId(data.name, data.version),
1274
- filename: writeCachedFormat(data.name, data.version, text),
1275
- isTwine2: true,
1276
- name: data.name,
1277
- version: data.version,
1278
- proofing: data.proofing ?? false
1279
- };
2188
+ /**
2189
+ * Download a format, verify its checksum, write it to the cache shared by name and version, and
2190
+ * return its StoryFormatInfo. Concurrent calls for one download URL share one request.
2191
+ */
2192
+ async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
2193
+ cachedFormatDir(entry.name, entry.version);
2194
+ const timeout = requestTimeout(options);
2195
+ return shareRequest(`format\0${getCacheDir()}\0${downloadUrl}`, options.signal, async (signal) => {
2196
+ const text = await fetchText(downloadUrl, "download format", signal, timeout);
2197
+ const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
2198
+ if (checksumKey) {
2199
+ const expected = entry.checksums[checksumKey];
2200
+ if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
2201
+ if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
2202
+ }
2203
+ const id = makeFormatId(entry.name, entry.version);
2204
+ const data = parseFormatJSON(text, id);
2205
+ if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
2206
+ signal.throwIfAborted();
2207
+ return cachedFormatInfo(id, writeCacheEntry(resolve(getCacheDir()), [entry.name, entry.version], text), data);
2208
+ });
2209
+ }
2210
+ /**
2211
+ * Download a direct format.js URL, parse its JSON, cache it under that URL (apart from the
2212
+ * downloads shared by name and version), and return its StoryFormatInfo. Concurrent calls for
2213
+ * one URL share one request.
2214
+ */
2215
+ async function fetchDirectFormat(url, options = {}) {
2216
+ const timeout = requestTimeout(options);
2217
+ return shareRequest(`url\0${getUrlCacheDir()}\0${url}`, options.signal, async (signal) => {
2218
+ const text = await fetchText(url, "download format", signal, timeout);
2219
+ const data = parseFormatJSON(text);
2220
+ if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
2221
+ cachedFormatDir(data.name, data.version);
2222
+ signal.throwIfAborted();
2223
+ const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
2224
+ return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
2225
+ });
2226
+ }
2227
+ /** The copy of a direct format URL in the download cache, if it has been downloaded before. */
2228
+ function getCachedDirectFormat(url) {
2229
+ const formatPath = join(getUrlCacheDir(), urlCacheKey(url), "format.js");
2230
+ try {
2231
+ if (!existsSync(formatPath)) return void 0;
2232
+ const data = parseFormatJSON(readUTF8(formatPath));
2233
+ if (!data) return void 0;
2234
+ return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
2235
+ } catch {
2236
+ return;
2237
+ }
2238
+ }
2239
+ /**
2240
+ * Look a request up among direct format URLs, in order, and return the first format that
2241
+ * answers it. A URL downloaded before is answered by its cached copy without the network;
2242
+ * any other is downloaded (unless `offline`). When none answers and a download failed, the
2243
+ * last failure is thrown. An aborted signal rejects with its reason and tries no further URL.
2244
+ *
2245
+ * Internal, for format resolution; not part of the public API.
2246
+ */
2247
+ async function resolveFormatUrls(request, urls, options = {}) {
2248
+ let lastError;
2249
+ for (const url of urls) {
2250
+ const cached = getCachedDirectFormat(url);
2251
+ if (cached) {
2252
+ if (answers(request, cached)) return cached;
2253
+ continue;
2254
+ }
2255
+ if (options.offline) continue;
2256
+ try {
2257
+ const info = await fetchDirectFormat(url, options);
2258
+ if (answers(request, info)) return info;
2259
+ } catch (e) {
2260
+ if (options.signal?.aborted) throw options.signal.reason;
2261
+ lastError = toError$1(e);
2262
+ }
2263
+ }
2264
+ if (lastError) throw lastError;
2265
+ }
2266
+ /** Whether `info` is a format the request accepts. */
2267
+ function answers(request, info) {
2268
+ return selectFormatCandidate(request, [info], (f) => f) !== void 0;
2269
+ }
2270
+ function toError$1(e) {
2271
+ return e instanceof Error ? e : new Error(String(e));
1280
2272
  }
1281
2273
  /**
1282
2274
  * Resolve a story format request remotely, in the same order as {@link resolveRemoteFormat}.
1283
2275
  * An ID request such as 'sugarcube-2' matches the format whose name and major version build that ID
1284
2276
  * (SugarCube 2.x), taking the greatest version available.
1285
2277
  */
1286
- async function resolveRemoteFormatRequest(request, indices, urls) {
2278
+ async function resolveRemoteFormatRequest(request, indices, urls, options = {}) {
2279
+ options.signal?.throwIfAborted();
1287
2280
  let lastError;
1288
- const cached = findCachedFormat(request);
1289
- if (cached && request.kind === "name" && isExactVersion(cached.version, request.version)) return cached;
1290
- for (const url of urls ?? []) try {
1291
- const info = await fetchDirectFormat(url);
1292
- if (selectFormatCandidate(request, [info], (f) => f)) return info;
2281
+ try {
2282
+ const direct = await resolveFormatUrls(request, urls ?? [], options);
2283
+ if (direct) return direct;
1293
2284
  } catch (e) {
1294
- lastError = e instanceof Error ? e : new Error(String(e));
2285
+ if (options.signal?.aborted) throw options.signal.reason;
2286
+ lastError = toError$1(e);
1295
2287
  }
2288
+ const cached = findCachedFormat(request);
2289
+ if (cached && request.kind === "name" && sameVersion(cached.version, request.version)) return cached;
1296
2290
  const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
1297
2291
  for (const indexUrl of allIndices) try {
1298
- const result = findEntryForRequest(await fetchIndex(indexUrl), request);
2292
+ const result = findEntryForRequest(await fetchIndex(indexUrl, options), request);
1299
2293
  if (result) {
1300
2294
  const hit = getCachedFormat(result.entry.name, result.entry.version);
1301
2295
  if (hit) return hit;
1302
2296
  const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
1303
- return await fetchAndCacheFormat(result.entry, downloadUrl);
2297
+ return await fetchAndCacheFormat(result.entry, downloadUrl, options);
1304
2298
  }
1305
2299
  } catch (e) {
1306
- lastError = e instanceof Error ? e : new Error(String(e));
2300
+ if (options.signal?.aborted) throw options.signal.reason;
2301
+ lastError = toError$1(e);
1307
2302
  }
1308
2303
  if (cached) return cached;
1309
2304
  if (lastError) throw lastError;
1310
2305
  }
1311
- /** Whether two version strings name the same SemVer version. */
1312
- function isExactVersion(have, wanted) {
1313
- const a = parseSemver(have);
1314
- const b = parseSemver(wanted);
1315
- return a !== null && b !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
1316
- }
1317
2306
  /**
1318
2307
  * Find the cached download that best answers a format request, without network access.
2308
+ * Only the downloads shared by name and version are searched, not those from direct URLs.
1319
2309
  * Matching follows {@link selectFormatCandidate}.
1320
2310
  */
1321
2311
  function findCachedFormat(request, options = {}) {
@@ -1327,18 +2317,11 @@ function getCachedFormat(name, version) {
1327
2317
  const formatPath = join(getCacheDir(), name, version, "format.js");
1328
2318
  try {
1329
2319
  if (!existsSync(formatPath)) return void 0;
1330
- const source = readFileSync(formatPath, "utf-8");
2320
+ const source = readUTF8(formatPath);
1331
2321
  const id = makeFormatId(name, version);
1332
2322
  const data = parseFormatJSON(source, id);
1333
2323
  if (!data) return void 0;
1334
- return {
1335
- id,
1336
- filename: formatPath,
1337
- isTwine2: true,
1338
- name: data.name,
1339
- version: data.version,
1340
- proofing: data.proofing ?? false
1341
- };
2324
+ return cachedFormatInfo(id, formatPath, data);
1342
2325
  } catch {
1343
2326
  return;
1344
2327
  }
@@ -1423,21 +2406,27 @@ function listCachedFormats() {
1423
2406
  }
1424
2407
  /**
1425
2408
  * Clear all cached formats, or only those matching a given name. Returns the number of entries removed.
2409
+ * Clearing all also removes the downloads from direct format URLs; a name clears only the
2410
+ * downloads shared by name and version.
1426
2411
  * A name must be a single cache entry name (as `listCachedFormats()` reports it); anything with a
1427
2412
  * path separator or `.`/`..` throws instead of deleting outside the format's own directory.
1428
2413
  */
1429
2414
  function clearCachedFormats(name) {
1430
2415
  if (name && !isSafeSegment(name)) throw new Error(`Refusing to clear ${JSON.stringify(name)}: it is not a cached format name. Use a name as "cache list" shows it.`);
1431
2416
  const cacheDir = getCacheDir();
1432
- if (!existsSync(cacheDir)) return 0;
1433
2417
  if (!name) {
1434
- const count = listCachedFormats().length;
2418
+ const count = listCachedFormats().length + countUrlCacheEntries();
1435
2419
  rmSync(cacheDir, {
1436
2420
  recursive: true,
1437
2421
  force: true
1438
2422
  });
2423
+ rmSync(getUrlCacheDir(), {
2424
+ recursive: true,
2425
+ force: true
2426
+ });
1439
2427
  return count;
1440
2428
  }
2429
+ if (!existsSync(cacheDir)) return 0;
1441
2430
  const root = resolve(cacheDir);
1442
2431
  const nameDir = resolve(root, name);
1443
2432
  if (!isInside(root, nameDir)) throw new Error(`Refusing to clear ${JSON.stringify(name)}: it is not a cached format name.`);
@@ -1455,6 +2444,15 @@ function clearCachedFormats(name) {
1455
2444
  });
1456
2445
  return count;
1457
2446
  }
2447
+ /** The number of direct format URLs with a download in the cache. */
2448
+ function countUrlCacheEntries() {
2449
+ const dir = getUrlCacheDir();
2450
+ try {
2451
+ return readdirSync(dir).filter((key) => existsSync(join(dir, key, "format.js"))).length;
2452
+ } catch {
2453
+ return 0;
2454
+ }
2455
+ }
1458
2456
  /** Get total cache size in bytes and format count. */
1459
2457
  function getCacheSize() {
1460
2458
  const entries = listCachedFormats();
@@ -1481,44 +2479,65 @@ function formatRequestFor(formatId, storyFormat, storyFormatVersion, defaultId)
1481
2479
  id: defaultId
1482
2480
  };
1483
2481
  }
1484
- /** Look a request up among the formats found in local format directories. */
1485
- function findLocalFormat(formats, request) {
2482
+ /**
2483
+ * Look a request up among the formats found in local format directories. An ID names a folder, so
2484
+ * it is looked up among all of them; a name request selects by SemVer among the pruned formats.
2485
+ */
2486
+ function findLocalFormat(all, pruned, request) {
1486
2487
  switch (request.kind) {
1487
- case "id": return formats.get(request.id);
1488
- case "name": {
1489
- const id = getFormatIdByNameAndVersion(formats, request.name, request.version);
1490
- return id === void 0 ? void 0 : formats.get(id);
1491
- }
2488
+ case "id": return findFormatById(all, request.id);
2489
+ case "name": return selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f);
1492
2490
  default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
1493
2491
  }
1494
2492
  }
1495
2493
  /**
1496
2494
  * Resolve a format request to a story format.
1497
2495
  *
1498
- * Order: local format directories, then the download cache, then remote sources (unless
1499
- * `noRemote`). When a StoryData request names a version that none of them has, an older version of
2496
+ * Order: local format directories, then the project's direct format URLs (each one's cached
2497
+ * copy, else a download unless `noRemote`), then the download cache shared by name and version,
2498
+ * then format indices (unless `noRemote`). Names and IDs match the same way in each (see
2499
+ * `selectFormatCandidate`), so a request that a local format answers never reaches the cache or the
2500
+ * network. Local formats that cannot be used are reported as warnings. When a StoryData request
2501
+ * names a version that none of them has, an older version of
1500
2502
  * the same format and major version is used with a warning. Anything else is an error diagnostic,
1501
2503
  * and the result is `undefined`.
1502
2504
  */
1503
2505
  async function resolveStoryFormat(request, options, diagnostics) {
1504
2506
  const noRemote = options.noRemote ?? false;
1505
- const formats = discoverFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true));
2507
+ const all = discoverAllFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true), diagnostics);
2508
+ const pruned = pruneFormats(all);
1506
2509
  const wanted = describeFormatRequest(request);
1507
- const local = findLocalFormat(formats, request);
2510
+ const local = findLocalFormat(all, pruned, request);
1508
2511
  if (local) return local;
2512
+ const fetchOptions = {
2513
+ signal: options.signal,
2514
+ timeout: options.formatFetchTimeout
2515
+ };
2516
+ const warnOnFailure = async (lookup) => {
2517
+ try {
2518
+ return await lookup();
2519
+ } catch (e) {
2520
+ if (options.signal?.aborted) throw options.signal.reason;
2521
+ diagnostics.push({
2522
+ level: "warning",
2523
+ message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
2524
+ });
2525
+ return;
2526
+ }
2527
+ };
2528
+ const direct = await warnOnFailure(() => resolveFormatUrls(request, options.formatUrls ?? [], {
2529
+ ...fetchOptions,
2530
+ offline: noRemote
2531
+ }));
2532
+ if (direct) return direct;
1509
2533
  const cached = findCachedFormat(request);
1510
2534
  if (cached) return cached;
1511
- if (!noRemote) try {
1512
- const remote = await resolveRemoteFormatRequest(request, options.formatIndices, options.formatUrls);
2535
+ if (!noRemote) {
2536
+ const remote = await warnOnFailure(() => resolveRemoteFormatRequest(request, options.formatIndices, [], fetchOptions));
1513
2537
  if (remote) return remote;
1514
- } catch (e) {
1515
- diagnostics.push({
1516
- level: "warning",
1517
- message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
1518
- });
1519
2538
  }
1520
2539
  if (request.kind === "name") {
1521
- const older = selectFormatCandidate(request, [...formats.values()].filter((f) => f.isTwine2), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
2540
+ const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
1522
2541
  if (older) {
1523
2542
  diagnostics.push({
1524
2543
  level: "warning",
@@ -1530,7 +2549,7 @@ async function resolveStoryFormat(request, options, diagnostics) {
1530
2549
  const reason = noRemote ? " (remote fetching disabled)" : "";
1531
2550
  diagnostics.push({
1532
2551
  level: "error",
1533
- message: `Story format ${wanted} is not available${reason}. Found: ${[...formats.keys()].join(", ") || "none"}`
2552
+ message: `Story format ${wanted} is not available${reason}. Found: ${[...all.keys()].join(", ") || "none"}`
1534
2553
  });
1535
2554
  }
1536
2555
  //#endregion
@@ -1799,12 +2818,14 @@ function twee2ToV3(s) {
1799
2818
  * Parse Twee source text into passages.
1800
2819
  *
1801
2820
  * The source is normalized first, as files are when they are read: a leading UTF-8 BOM is
1802
- * removed and CRLF and bare CR line endings become LF.
2821
+ * removed and CRLF and bare CR line endings become LF. A BOM at the start of a later line,
2822
+ * directly before `::` (left there by concatenating files), is removed too, so that line
2823
+ * stays a passage header.
1803
2824
  */
1804
2825
  function parseTwee(source, options = {}) {
1805
2826
  const { filename = "<inline>", trim = true, twee2Compat = false } = options;
1806
2827
  const diagnostics = [];
1807
- const normalized = normalizeSourceText(source);
2828
+ const normalized = normalizeTweeSourceText(source);
1808
2829
  const tweeSource = twee2Compat ? twee2ToV3(normalized) : normalized;
1809
2830
  const passages = [];
1810
2831
  let current = null;
@@ -4639,17 +5660,21 @@ function parseDocument(data, options) {
4639
5660
  * Ported from storyload.go:loadHTML().
4640
5661
  */
4641
5662
  /**
4642
- * Parse a Twine 2 or Twine 1 compiled HTML file back into a Story model.
4643
- * Tweego always trims passage text; here `trim: false` keeps it exactly, as for Twee sources.
5663
+ * `decompileHTML()` for loading an HTML file into a compile. The importer's StoryData passage is
5664
+ * checked when it is added to the story being compiled, which reports a missing or invalid IFID,
5665
+ * so the IFID is not reported here as well.
4644
5666
  */
4645
- function decompileHTML(html, options = {}) {
5667
+ function decompileHTMLForImport(html, options = {}) {
5668
+ return decompile(html, options, { ifid: false });
5669
+ }
5670
+ function decompile(html, options, checks) {
4646
5671
  const diagnostics = [];
4647
5672
  const story = createStory();
4648
5673
  const passageText = options.trim === false ? (text) => text : (text) => text.trim();
4649
5674
  const doc = parseDocument(html);
4650
5675
  const twine2Data = findElement(doc, "tw-storydata");
4651
5676
  if (twine2Data) {
4652
- decompileTwine2(twine2Data, story, passageText, diagnostics);
5677
+ decompileTwine2(twine2Data, story, passageText, checks, diagnostics);
4653
5678
  return {
4654
5679
  story,
4655
5680
  diagnostics
@@ -4672,7 +5697,7 @@ function decompileHTML(html, options = {}) {
4672
5697
  diagnostics
4673
5698
  };
4674
5699
  }
4675
- function decompileTwine2(storyData, story, passageText, diagnostics) {
5700
+ function decompileTwine2(storyData, story, passageText, checks, diagnostics) {
4676
5701
  let startnode = 0;
4677
5702
  const attrs = storyData.attribs ?? {};
4678
5703
  if (attrs["name"]) story.name = attrs["name"];
@@ -4685,6 +5710,7 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
4685
5710
  else startnode = parsed;
4686
5711
  }
4687
5712
  if (attrs["ifid"]) story.ifid = normalizeIFID(attrs["ifid"]);
5713
+ if (checks.ifid) diagnostics.push(...storyDataIFIDDiagnostics(attrs["ifid"] ?? ""));
4688
5714
  if (attrs["zoom"]) {
4689
5715
  const parsed = parseFloat(attrs["zoom"]);
4690
5716
  if (Number.isNaN(parsed)) diagnostics.push({
@@ -4707,13 +5733,17 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
4707
5733
  const content = getTextContent(node);
4708
5734
  if (content.trim().length === 0) continue;
4709
5735
  const text = passageText(content);
4710
- const name = freeName(node.name === "style" ? "Story Stylesheet" : "Story JavaScript", takenNames);
5736
+ const base = node.name === "style" ? "Story Stylesheet" : "Story JavaScript";
5737
+ const name = freeName(base, (n) => !takenNames.has(n));
4711
5738
  takenNames.add(name);
4712
- storyAdd(story, {
5739
+ storyAdd(story, withGeneratedName({
4713
5740
  name,
4714
5741
  tags: node.name === "style" ? ["stylesheet"] : ["script"],
4715
5742
  text
4716
- }, diagnostics);
5743
+ }, {
5744
+ kind: "code",
5745
+ base
5746
+ }), diagnostics);
4717
5747
  break;
4718
5748
  }
4719
5749
  case "tw-tag": {
@@ -4760,19 +5790,26 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
4760
5790
  function passageDataName(node) {
4761
5791
  return node.attribs?.["name"] ?? "";
4762
5792
  }
4763
- /** The first of `base`, `base 2`, `base 3`, … that is not in `taken`. */
4764
- function freeName(base, taken) {
4765
- if (!taken.has(base)) return base;
4766
- for (let n = 2;; n++) {
4767
- const candidate = `${base} ${n}`;
4768
- if (!taken.has(candidate)) return candidate;
4769
- }
5793
+ /** Diagnostics for the `ifid` attribute of `tw-storydata`; `value` is empty when it is missing. */
5794
+ function storyDataIFIDDiagnostics(value) {
5795
+ if (value === "") return [{
5796
+ level: "warning",
5797
+ message: "Story IFID not found; the \"tw-storydata\" content attribute \"ifid\" is missing or empty."
5798
+ }];
5799
+ const err = validateIFID(value);
5800
+ if (err === null) return [];
5801
+ return [{
5802
+ level: "warning",
5803
+ message: `Cannot parse "tw-storydata" content attribute "ifid" as an IFID; value "${value}" (${err}).`
5804
+ }];
4770
5805
  }
4771
5806
  function decompileTwine1(storeArea, story, passageText, diagnostics) {
4772
5807
  const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
4773
5808
  const obfuscated = isRot13Obfuscated(passages);
4774
- for (const p of passages) storyAdd(story, obfuscated && p.name !== "StorySettings" ? {
5809
+ for (const p of passages) storyAdd(story, obfuscated && isObfuscatable(p) ? {
4775
5810
  ...p,
5811
+ name: rot13(p.name),
5812
+ tags: p.tags.map(rot13),
4776
5813
  text: rot13(p.text)
4777
5814
  } : p, diagnostics);
4778
5815
  }
@@ -4917,7 +5954,12 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
4917
5954
  function loadInlineSources(story, sources, opts, diagnostics) {
4918
5955
  for (const source of sources) {
4919
5956
  if (typeof source === "string") continue;
4920
- const content = normalizeSourceText(typeof source.content === "string" ? source.content : source.content.toString("utf-8"));
5957
+ const decoded = typeof source.content === "string" ? {
5958
+ text: source.content,
5959
+ diagnostics: []
5960
+ } : decodeText(source.content, source.filename);
5961
+ diagnostics.push(...decoded.diagnostics);
5962
+ const content = normalizeSourceText(decoded.text);
4921
5963
  const ext = normalizedFileExt(source.filename);
4922
5964
  switch (ext) {
4923
5965
  case "":
@@ -4936,18 +5978,10 @@ function loadInlineSources(story, sources, opts, diagnostics) {
4936
5978
  break;
4937
5979
  }
4938
5980
  case "css":
4939
- storyAdd(story, {
4940
- name: basename(source.filename),
4941
- tags: ["stylesheet"],
4942
- text: content
4943
- }, diagnostics);
5981
+ storyAdd(story, codePassage(basename(source.filename), "stylesheet", content), diagnostics);
4944
5982
  break;
4945
5983
  case "js":
4946
- storyAdd(story, {
4947
- name: basename(source.filename),
4948
- tags: ["script"],
4949
- text: content
4950
- }, diagnostics);
5984
+ storyAdd(story, codePassage(basename(source.filename), "script", content), diagnostics);
4951
5985
  break;
4952
5986
  default: diagnostics.push({
4953
5987
  level: "warning",
@@ -4957,35 +5991,60 @@ function loadInlineSources(story, sources, opts, diagnostics) {
4957
5991
  }
4958
5992
  }
4959
5993
  function parseTweeFile(filename, opts) {
4960
- const result = parseTwee(readUTF8(filename), {
5994
+ const readDiagnostics = [];
5995
+ const result = parseTwee(readUTF8(filename, readDiagnostics), {
4961
5996
  filename,
4962
5997
  trim: opts.trim ?? true,
4963
5998
  twee2Compat: opts.twee2Compat ?? false
4964
5999
  });
4965
6000
  return {
4966
6001
  passages: result.passages,
4967
- diagnostics: [...result.diagnostics]
6002
+ diagnostics: [...readDiagnostics, ...result.diagnostics]
4968
6003
  };
4969
6004
  }
6005
+ /**
6006
+ * A stylesheet or script passage named after its file, as Tweego names it. The name only labels
6007
+ * the code, so when another passage has it, the passage takes a free one (see `storyAdd()`).
6008
+ */
6009
+ function codePassage(name, tag, text) {
6010
+ return withGeneratedName({
6011
+ name,
6012
+ tags: [tag],
6013
+ text
6014
+ }, {
6015
+ kind: "code",
6016
+ base: name
6017
+ });
6018
+ }
6019
+ /** Mark every passage of a parse result as a code passage named after its file. */
6020
+ function codeNames(result) {
6021
+ for (const p of result.passages) withGeneratedName(p, {
6022
+ kind: "code",
6023
+ base: p.name
6024
+ });
6025
+ return result;
6026
+ }
4970
6027
  function parseTaggedFile(tag, filename) {
4971
- const source = readUTF8(filename);
6028
+ const diagnostics = [];
6029
+ const source = readUTF8(filename, diagnostics);
4972
6030
  return {
4973
- passages: [{
4974
- name: basename(filename),
4975
- tags: [tag],
4976
- text: source
4977
- }],
4978
- diagnostics: []
6031
+ passages: [codePassage(basename(filename), tag, source)],
6032
+ diagnostics
4979
6033
  };
4980
6034
  }
4981
6035
  function parseMediaFile(tag, filename) {
4982
6036
  const source = readBase64(filename);
6037
+ const name = baseNameWithoutExt(filename);
4983
6038
  return {
4984
- passages: [{
4985
- name: baseNameWithoutExt(filename),
6039
+ passages: [withGeneratedName({
6040
+ name,
4986
6041
  tags: [tag],
4987
6042
  text: `data:${mediaTypeFromFilename(filename)};base64,${source}`
4988
- }],
6043
+ }, {
6044
+ kind: "media",
6045
+ base: name,
6046
+ file: filename
6047
+ })],
4989
6048
  diagnostics: []
4990
6049
  };
4991
6050
  }
@@ -4994,24 +6053,27 @@ function parseFontFile(filename) {
4994
6053
  const name = basename(filename);
4995
6054
  const family = baseNameWithoutExt(filename);
4996
6055
  const ext = normalizedFileExt(filename);
4997
- return {
6056
+ const mediaType = mediaTypeFromExt(ext);
6057
+ const hint = fontFormatHint(ext);
6058
+ return codeNames({
4998
6059
  passages: [{
4999
6060
  name,
5000
6061
  tags: ["stylesheet"],
5001
- text: `@font-face {\n\tfont-family: "${family}";\n\tsrc: url("data:${mediaTypeFromExt(ext)};base64,${source}") format("${fontFormatHint(ext)}");\n}`
6062
+ text: `@font-face {\n\tfont-family: "${cssStringEscape(family)}";\n\tsrc: url("data:${mediaType};base64,${source}") format("${hint}");\n}`
5002
6063
  }],
5003
6064
  diagnostics: []
5004
- };
6065
+ });
5005
6066
  }
5006
6067
  function parseHTMLFile(filename, opts) {
5007
- const { story, diagnostics } = decompileHTML(readUTF8(filename), { trim: opts.trim ?? true });
6068
+ const readDiagnostics = [];
6069
+ const { story, diagnostics } = decompileHTMLForImport(readUTF8(filename, readDiagnostics), { trim: opts.trim ?? true });
5008
6070
  return {
5009
6071
  passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
5010
6072
  name: "StoryTitle",
5011
6073
  tags: [],
5012
6074
  text: story.name
5013
6075
  }, ...story.passages] : story.passages,
5014
- diagnostics: [...diagnostics]
6076
+ diagnostics: [...readDiagnostics, ...diagnostics]
5015
6077
  };
5016
6078
  }
5017
6079
  /**
@@ -5084,6 +6146,7 @@ function loadTwee(story, filename, opts, diagnostics) {
5084
6146
  }
5085
6147
  function loadTagged(story, tag, filename, diagnostics) {
5086
6148
  const result = parseTaggedFile(tag, filename);
6149
+ diagnostics.push(...result.diagnostics);
5087
6150
  for (const p of result.passages) storyAdd(story, p, diagnostics);
5088
6151
  }
5089
6152
  function loadMedia(story, tag, filename, diagnostics) {
@@ -5100,6 +6163,7 @@ function loadFont(story, filename, diagnostics) {
5100
6163
  */
5101
6164
  function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
5102
6165
  const currentFiles = /* @__PURE__ */ new Set();
6166
+ const changedPaths = changedFiles === void 0 ? void 0 : new Set([...changedFiles].map((f) => resolve(f)));
5103
6167
  for (const filename of filenames) {
5104
6168
  if (processedFiles.has(filename)) {
5105
6169
  diagnostics.push({
@@ -5112,8 +6176,8 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5112
6176
  const optionsKey = parseOptionsKey(filename, opts);
5113
6177
  const entry = cache.get(filename);
5114
6178
  const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
5115
- const changed = changedFiles?.has(filename) ?? false;
5116
- if (changedFiles && cached && !changed) {
6179
+ const changed = changedPaths?.has(resolve(filename)) ?? false;
6180
+ if (changedPaths && cached && !changed) {
5117
6181
  diagnostics.push(...cached.diagnostics);
5118
6182
  for (const p of cached.passages) storyAdd(story, p, diagnostics);
5119
6183
  processedFiles.add(filename);
@@ -5123,6 +6187,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5123
6187
  try {
5124
6188
  mtimeMs = statSync(filename).mtimeMs;
5125
6189
  } catch {
6190
+ cache.delete(filename);
5126
6191
  continue;
5127
6192
  }
5128
6193
  if (cached && !changed && cached.mtimeMs === mtimeMs) {
@@ -5143,6 +6208,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5143
6208
  diagnostics.push(...result.diagnostics);
5144
6209
  for (const p of result.passages) storyAdd(story, p, diagnostics);
5145
6210
  } catch (e) {
6211
+ cache.delete(filename);
5146
6212
  diagnostics.push({
5147
6213
  level: "error",
5148
6214
  message: `load ${filename}: ${e instanceof Error ? e.message : String(e)}`,
@@ -5166,6 +6232,11 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5166
6232
  * This is how the HTML tokenizer reads an end tag; it ignores whatever sits between the tag name and the `>`.
5167
6233
  */
5168
6234
  const CLOSING_HEAD_TAG = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>";
6235
+ /**
6236
+ * The start of an HTML body start tag: `<body` in any letter case, followed by whitespace, `/` or `>` (so not
6237
+ * `<bodyx>`). Only the `<body` is matched; the attributes and `>` after it are left alone.
6238
+ */
6239
+ const BODY_START_TAG = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
5169
6240
  /** The regular expression source matching exactly `text`. */
5170
6241
  function literal(text) {
5171
6242
  return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
@@ -5199,9 +6270,10 @@ function fillTemplateParts(parts) {
5199
6270
  //#endregion
5200
6271
  //#region src/modules.ts
5201
6272
  /**
5202
- * Load modules and return HTML tags to inject before the closing head tag.
6273
+ * Load modules and return HTML tags to inject before the closing head tag. `diagnostics` receives a warning for
6274
+ * each module that is not valid UTF-8 (it is read as Windows-1252).
5203
6275
  */
5204
- function loadModules(filenames) {
6276
+ function loadModules(filenames, diagnostics) {
5205
6277
  const processed = /* @__PURE__ */ new Set();
5206
6278
  const headTags = [];
5207
6279
  for (const filename of filenames) {
@@ -5210,10 +6282,10 @@ function loadModules(filenames) {
5210
6282
  let tag = null;
5211
6283
  switch (ext) {
5212
6284
  case "css":
5213
- tag = loadModuleTagged("style", filename);
6285
+ tag = loadModuleTagged("style", filename, diagnostics);
5214
6286
  break;
5215
6287
  case "js":
5216
- tag = loadModuleTagged("script", filename);
6288
+ tag = loadModuleTagged("script", filename, diagnostics);
5217
6289
  break;
5218
6290
  case "otf":
5219
6291
  case "ttf":
@@ -5228,8 +6300,8 @@ function loadModules(filenames) {
5228
6300
  }
5229
6301
  return headTags.join("\n");
5230
6302
  }
5231
- function loadModuleTagged(tag, filename) {
5232
- const source = readUTF8(filename).trim();
6303
+ function loadModuleTagged(tag, filename, diagnostics) {
6304
+ const source = readUTF8(filename, diagnostics).trim();
5233
6305
  if (source.length === 0) return null;
5234
6306
  return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
5235
6307
  }
@@ -5238,20 +6310,22 @@ function loadModuleFont(filename) {
5238
6310
  const family = baseNameWithoutExt(filename);
5239
6311
  const idSlug = `style-module-${slugify(family)}`;
5240
6312
  const ext = normalizedFileExt(filename);
5241
- return `<style id="${idSlug}" type="text/css">@font-face {\n\tfont-family: "${family}";\n\tsrc: url("data:${mediaTypeFromExt(ext)};base64,${source}") format("${fontFormatHint(ext)}");\n}</style>`;
6313
+ const mediaType = mediaTypeFromExt(ext);
6314
+ const hint = fontFormatHint(ext);
6315
+ return `<style id="${idSlug}" type="text/css">@font-face {\n\tfont-family: "${cssStringEscape(family)}";\n\tsrc: url("data:${mediaType};base64,${source}") format("${hint}");\n}</style>`;
5242
6316
  }
5243
6317
  /**
5244
- * Load the module tags and head file content to inject before the closing head tag, joined by newlines,
6318
+ * Load the module tags and head file content to inject into the head (see `placeHead()`), joined by newlines,
5245
6319
  * or `''` when there is nothing to inject.
5246
6320
  */
5247
6321
  function loadHeadContent(modulePaths, headFile, diagnostics) {
5248
6322
  const parts = [];
5249
6323
  if (modulePaths.length > 0) {
5250
- const modules = loadModules(modulePaths).trim();
6324
+ const modules = loadModules(modulePaths, diagnostics).trim();
5251
6325
  if (modules.length > 0) parts.push(modules);
5252
6326
  }
5253
6327
  if (headFile) try {
5254
- const source = readUTF8(headFile).trim();
6328
+ const source = readUTF8(headFile, diagnostics).trim();
5255
6329
  if (source.length > 0) parts.push(source);
5256
6330
  } catch (e) {
5257
6331
  diagnostics?.push({
@@ -5262,16 +6336,51 @@ function loadHeadContent(modulePaths, headFile, diagnostics) {
5262
6336
  return parts.join("\n");
5263
6337
  }
5264
6338
  /**
5265
- * The template slot that inserts `content` on its own line before the first closing head tag (as Tweego does),
5266
- * or `undefined` when `content` is empty. The tag may use any letter case and whitespace before its `>`, and is
5267
- * kept as written.
6339
+ * Place `content` in a template, given the texts that `fillTemplateParts()` scans for the slot (the template, and
6340
+ * the footer of a pre-1.4 Twine 1 format):
6341
+ *
6342
+ * - on its own line before the first closing head tag, as Tweego does; the tag may use any letter case and
6343
+ * whitespace before its `>`, and is kept as written;
6344
+ * - in a template with no closing head tag (HTML lets a document leave it out), on its own line before the first
6345
+ * body start tag, where the HTML parser still puts it in the head, with a warning (Tweego drops it);
6346
+ * - in a template with neither, nowhere, with a warning.
6347
+ *
6348
+ * `owner` names the template in the warnings. Empty `content` needs no slot and gets no warning.
5268
6349
  */
5269
- function headSlot(content) {
5270
- if (content.length === 0) return void 0;
6350
+ function placeHead(content, texts, owner) {
6351
+ if (content.length === 0) return {
6352
+ slot: void 0,
6353
+ diagnostics: []
6354
+ };
6355
+ const found = (tag) => texts.some((text) => new RegExp(tag).test(text));
6356
+ if (found("<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>")) return {
6357
+ slot: beforeTag(CLOSING_HEAD_TAG, content),
6358
+ diagnostics: []
6359
+ };
6360
+ if (found("<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])")) {
6361
+ const message = `${owner} has no closing head tag; the modules and head file were injected before its body start tag.`;
6362
+ return {
6363
+ slot: beforeTag(BODY_START_TAG, content),
6364
+ diagnostics: [{
6365
+ level: "warning",
6366
+ message
6367
+ }]
6368
+ };
6369
+ }
6370
+ return {
6371
+ slot: void 0,
6372
+ diagnostics: [{
6373
+ level: "warning",
6374
+ message: `${owner} has no closing head tag and no body start tag; the modules and head file were not injected.`
6375
+ }]
6376
+ };
6377
+ }
6378
+ /** The slot that inserts `content` on its own line before the first match of `tag`, which is kept as written. */
6379
+ function beforeTag(tag, content) {
5271
6380
  return {
5272
- pattern: CLOSING_HEAD_TAG,
6381
+ pattern: tag,
5273
6382
  occurrences: "first",
5274
- replacement: (tag) => `${content}\n${tag}`
6383
+ replacement: (match) => `${content}\n${match}`
5275
6384
  };
5276
6385
  }
5277
6386
  //#endregion
@@ -5310,14 +6419,19 @@ function toTwine2Archive(story, startName, options) {
5310
6419
  }
5311
6420
  /**
5312
6421
  * Fill the Twine 2 format template: every `{{STORY_NAME}}` gets the escaped story name and the first
5313
- * `{{STORY_DATA}}` the story data (as Tweego does), and `head` goes before the first closing head tag. All three are
5314
- * found in the template in one pass, so a story name, passage text or head content holding a placeholder or a
5315
- * closing head tag stays literal.
6422
+ * `{{STORY_DATA}}` the story data (as Tweego does), and `head` goes before the first closing head tag (or, with a
6423
+ * warning, the body start tag; see `placeHead()`). All three are found in the template in one pass, so a story
6424
+ * name, passage text or head content holding a placeholder or a closing head tag stays literal.
6425
+ * `options.diagnostics` receives a warning when the format file is not valid UTF-8, and any warning about where
6426
+ * `head` went.
5316
6427
  */
5317
6428
  function toTwine2HTML(story, format, startName, options) {
5318
6429
  const name = htmlEscape(story.name);
5319
- const head = headSlot(options?.head ?? "");
5320
- const slots = [
6430
+ const template = readFormatSource(format, options?.diagnostics);
6431
+ const placement = placeHead(options?.head ?? "", [template], `Story format "${format.name}" ${format.version}`);
6432
+ options?.diagnostics?.push(...placement.diagnostics);
6433
+ const head = placement.slot;
6434
+ return fillTemplate(template, [
5321
6435
  {
5322
6436
  pattern: literal("{{STORY_NAME}}"),
5323
6437
  occurrences: "all",
@@ -5338,8 +6452,7 @@ function toTwine2HTML(story, format, startName, options) {
5338
6452
  }
5339
6453
  },
5340
6454
  ...head === void 0 ? [] : [head]
5341
- ];
5342
- return fillTemplate(readFormatSource(format), slots);
6455
+ ]);
5343
6456
  }
5344
6457
  const OMITTING_TAGS = [
5345
6458
  "Twine.private",
@@ -5435,29 +6548,35 @@ function toTwine1Archive(story, _startName) {
5435
6548
  /**
5436
6549
  * Fill the Twine 1 format template. The format's components are inserted first, one after another, as Tweego and
5437
6550
  * Twine 1 do. Then the first of each story placeholder (`"VERSION"`, `"TIME"`, `"START_AT"`, `"STORY_SIZE"`,
5438
- * `"STORY"`), the IFID comment and `head` (before the first closing head tag) are found in one pass, so a start
5439
- * passage name or story data holding a placeholder or a closing head tag stays literal.
6551
+ * `"STORY"`), the IFID comment and `head` (before the first closing head tag or, with a warning, the body start
6552
+ * tag; see `placeHead()`) are found in one pass, so a start passage name or story data holding a placeholder or a
6553
+ * closing head tag stays literal. `options.diagnostics` receives a warning for each format file that is not valid
6554
+ * UTF-8, and any warning about where `head` went.
5440
6555
  */
5441
6556
  function toTwine1HTML(story, format, startName, options) {
5442
6557
  const formatDir = dirname(format.filename);
5443
6558
  const parentDir = dirname(formatDir);
5444
- let template = readFormatSource(format);
6559
+ const diagnostics = options?.diagnostics;
6560
+ let template = readFormatSource(format, diagnostics);
5445
6561
  const { data, count } = getTwine1PassageChunk(story);
5446
- template = tryReplaceComponent(template, "\"USER_LIB\"", join(formatDir, "userlib.js"), false);
5447
- template = tryReplaceComponent(template, "\"ENGINE\"", join(parentDir, "engine.js"), true);
5448
- template = tryReplaceComponent(template, "\"SUGARCANE\"", join(formatDir, "code.js"), true);
5449
- template = tryReplaceComponent(template, "\"JONAH\"", join(formatDir, "code.js"), true);
5450
- if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", join(parentDir, "jquery.js"), true);
5451
- if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", join(parentDir, "modernizr.js"), true);
6562
+ template = tryReplaceComponent(template, "\"USER_LIB\"", join(formatDir, "userlib.js"), false, diagnostics);
6563
+ template = tryReplaceComponent(template, "\"ENGINE\"", join(parentDir, "engine.js"), true, diagnostics);
6564
+ template = tryReplaceComponent(template, "\"SUGARCANE\"", join(formatDir, "code.js"), true, diagnostics);
6565
+ template = tryReplaceComponent(template, "\"JONAH\"", join(formatDir, "code.js"), true, diagnostics);
6566
+ if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", join(parentDir, "jquery.js"), true, diagnostics);
6567
+ if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", join(parentDir, "modernizr.js"), true, diagnostics);
5452
6568
  const isPre14 = !template.includes("\"STORY\"");
5453
- const footer = isPre14 ? readFooter(formatDir) : "";
6569
+ const footer = isPre14 ? readFooter(formatDir, diagnostics) : "";
5454
6570
  const storeArea = (template + footer).includes("<div id=\"store-area\"") ? "<div id=\"store-area\"" : "<div id=\"storeArea\"";
5455
6571
  const safeIfid = htmlCommentSanitize(story.ifid);
5456
- const late = [story.ifid ? {
6572
+ const ifid = story.ifid ? {
5457
6573
  pattern: literal(storeArea),
5458
6574
  occurrences: "first",
5459
6575
  replacement: (div) => `<!-- UUID://${safeIfid}// -->${div}`
5460
- } : void 0, headSlot(options?.head ?? "")].filter((slot) => slot !== void 0);
6576
+ } : void 0;
6577
+ const placement = placeHead(options?.head ?? "", isPre14 ? [template, footer] : [template], `Story format "${format.name}"`);
6578
+ diagnostics?.push(...placement.diagnostics);
6579
+ const late = [ifid, placement.slot].filter((slot) => slot !== void 0);
5461
6580
  const displayStart = startName === "Start" ? "" : startName;
5462
6581
  const slots = [
5463
6582
  firstSlot("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`),
@@ -5497,9 +6616,9 @@ function firstSlot(token, value) {
5497
6616
  };
5498
6617
  }
5499
6618
  /** The footer of a pre-1.4 format, or the default one when the format has none. */
5500
- function readFooter(formatDir) {
6619
+ function readFooter(formatDir, diagnostics) {
5501
6620
  try {
5502
- return readFileSync(join(formatDir, "footer.html"), "utf-8");
6621
+ return readUTF8(join(formatDir, "footer.html"), diagnostics);
5503
6622
  } catch {
5504
6623
  return "</div>\n</body>\n</html>\n";
5505
6624
  }
@@ -5527,11 +6646,10 @@ function getTwine1PassageChunk(story) {
5527
6646
  count
5528
6647
  };
5529
6648
  }
5530
- function tryReplaceComponent(template, placeholder, componentPath, required) {
6649
+ function tryReplaceComponent(template, placeholder, componentPath, required, diagnostics) {
5531
6650
  if (!template.includes(placeholder)) return template;
5532
6651
  try {
5533
- let content = readFileSync(componentPath, "utf-8");
5534
- if (content.charCodeAt(0) === 65279) content = content.slice(1);
6652
+ const content = readUTF8(componentPath, diagnostics);
5535
6653
  return template.replace(placeholder, () => content);
5536
6654
  } catch (e) {
5537
6655
  if (required) throw new Error(`Required format component not found: ${componentPath}: ${e instanceof Error ? e.message : String(e)}`);
@@ -5545,11 +6663,16 @@ function tryReplaceComponent(template, placeholder, componentPath, required) {
5545
6663
  * from its loaded text, so changes made after loading (a start passage override, test mode) are kept.
5546
6664
  * Tweego writes the loaded text; its own normalization makes the two the same for an unchanged story.
5547
6665
  *
6666
+ * Twee cannot write every passage so that it reads back the same (see `tweeRoundTrip()`); `diagnostics`
6667
+ * receives a warning for each passage that will not. Tweego writes the same Twee without one.
6668
+ *
5548
6669
  * `addStoryData`: add a StoryData passage when the story has none, so that StoryData fields the
5549
6670
  * compile options set are recorded. Default: false.
5550
6671
  */
5551
6672
  function toTwee(story, outMode, options = {}) {
5552
- return withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => passageToTwee(p, outMode)).join("");
6673
+ const written = withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => tweeRoundTrip(p, outMode));
6674
+ options.diagnostics?.push(...written.flatMap(({ diagnostics }) => diagnostics));
6675
+ return written.map(({ passage }) => passageToTwee(passage, outMode)).join("");
5553
6676
  }
5554
6677
  function withEffectiveStoryData(story, addStoryData) {
5555
6678
  const storyData = {
@@ -5574,6 +6697,64 @@ function withEffectiveStoryData(story, addStoryData) {
5574
6697
  function isStoryDataJSON(text) {
5575
6698
  return unmarshalStoryData(createStory(), text) === null;
5576
6699
  }
6700
+ /** Characters Twee 3 escapes in names and tags, and Twee 1 cannot write there at all. */
6701
+ const TWEE1_UNWRITABLE = /[[\]{}\\]/;
6702
+ /**
6703
+ * Check that Twee reads `p` back as written, and say what it will not. Twee has no way to escape passage text,
6704
+ * and its parser trims names and splits tags at whitespace, so these do not survive:
6705
+ *
6706
+ * - a text line that starts with `::`, which reads as a new passage header. In stylesheet and script passages such
6707
+ * a line is indented by one space, which keeps the CSS or JavaScript working (only a template literal or a
6708
+ * string continued across lines, in either, would see the space); the warning says so. Other passages' text is
6709
+ * written as it is.
6710
+ * - a name that is empty, has leading or trailing whitespace, or holds a line break;
6711
+ * - a tag that is empty or holds whitespace;
6712
+ * - in Twee 1, which escapes nothing, a name or tag holding `[`, `]`, `{`, `}` or `\`.
6713
+ */
6714
+ function tweeRoundTrip(p, outMode) {
6715
+ const label = `Passage ${JSON.stringify(p.name)}`;
6716
+ const diagnostics = [];
6717
+ const warn = (problem) => {
6718
+ diagnostics.push({
6719
+ level: "warning",
6720
+ message: `${label} cannot be written as Twee that reads back the same: ${problem}.`
6721
+ });
6722
+ };
6723
+ if (p.name.trim() === "") warn("its name is empty");
6724
+ else if (p.name.trim() !== p.name) warn("its name has leading or trailing whitespace, which Twee drops");
6725
+ if (/[\r\n]/.test(p.name)) warn("its name has a line break, which ends a Twee passage header");
6726
+ if (outMode === "twee1" && TWEE1_UNWRITABLE.test(p.name)) warn("its name has \"[\", \"]\", \"{\", \"}\" or \"\\\", which Twee 1 cannot escape");
6727
+ for (const tag of p.tags) {
6728
+ if (tag === "" || /\s/.test(tag)) warn(`its tag ${JSON.stringify(tag)} is empty or has whitespace, which splits tags in Twee`);
6729
+ if (outMode === "twee1" && TWEE1_UNWRITABLE.test(tag)) warn(`its tag ${JSON.stringify(tag)} has "[", "]", "{", "}" or "\\", which Twee 1 cannot escape`);
6730
+ }
6731
+ const lines = p.text.split(/(\r\n?|\n)/);
6732
+ const headerLines = lines.flatMap((line, i) => i % 2 === 0 && line.startsWith("::") ? [i / 2 + 1] : []);
6733
+ if (headerLines.length === 0) return {
6734
+ passage: p,
6735
+ diagnostics
6736
+ };
6737
+ const where = `${headerLines.length === 1 ? "line" : "lines"} ${headerLines.join(", ")} of its text start${headerLines.length === 1 ? "s" : ""} with "::", which Twee reads as a passage header`;
6738
+ if (!p.tags.some((tag) => tag === "stylesheet" || tag === "script")) {
6739
+ warn(where);
6740
+ return {
6741
+ passage: p,
6742
+ diagnostics
6743
+ };
6744
+ }
6745
+ diagnostics.push({
6746
+ level: "warning",
6747
+ message: `${label}: ${where}; ${headerLines.length === 1 ? "it was" : "they were"} written indented by one space.`
6748
+ });
6749
+ const text = lines.map((line, i) => i % 2 === 0 && line.startsWith("::") ? ` ${line}` : line).join("");
6750
+ return {
6751
+ passage: {
6752
+ ...p,
6753
+ text
6754
+ },
6755
+ diagnostics
6756
+ };
6757
+ }
5577
6758
  //#endregion
5578
6759
  //#region src/passage-omission.ts
5579
6760
  /**
@@ -5634,16 +6815,19 @@ var TweeTsError = class extends Error {
5634
6815
  }
5635
6816
  };
5636
6817
  /**
5637
- * Builds as compileToFile() does for `outFile`, without writing it: the output may sit
6818
+ * Builds as compileToFile() does for `outputs`, without writing them: an output may sit
5638
6819
  * inside a source folder, so its last build is left out of the sources and modules and
5639
- * never read back. The caller decides whether the result is written. With no `outFile`
5640
- * (output to stdout, or none), this is compile(). With `cache`, files are cached as
5641
- * compileIncremental() caches them.
6820
+ * never read back. `outputs` is the one file the CLI writes, or every path a bundler's
6821
+ * build writes (see BuildOutputs). The caller decides whether the result is written.
6822
+ * With no outputs (output to stdout, or none), this is compile(). With `cache`, files
6823
+ * are cached as compileIncremental() caches them.
6824
+ *
6825
+ * Throws a TweeTsError when a source, module or head file named directly is an output.
5642
6826
  *
5643
6827
  * Internal, for the CLI and the bundler plugins; not part of the public API.
5644
6828
  */
5645
- async function compileForOutputFile(options, outFile, cache) {
5646
- return buildOutput(options, cache, void 0, outFile);
6829
+ async function compileForOutputFile(options, outputs, cache) {
6830
+ return buildOutput(options, cache, void 0, outputs);
5647
6831
  }
5648
6832
  /**
5649
6833
  * watch(), writing to `outFile` only the builds `shouldWrite` accepts. A rejected build
@@ -5652,9 +6836,13 @@ async function compileForOutputFile(options, outFile, cache) {
5652
6836
  * Internal, for the CLI, which keeps the last good output when a rebuild reports errors;
5653
6837
  * not part of the public API.
5654
6838
  */
5655
- async function watchWithWriteFilter(options, shouldWrite) {
6839
+ async function watchWithWriteFilter(options, shouldWrite, hooks = {}) {
5656
6840
  const controller = new AbortController();
5657
6841
  const cache = /* @__PURE__ */ new Map();
6842
+ const buildOptions = {
6843
+ ...options,
6844
+ signal: controller.signal
6845
+ };
5658
6846
  const filePaths = options.sources.filter((s) => typeof s === "string");
5659
6847
  const modulePaths = options.modules ?? [];
5660
6848
  const headPaths = options.headFile ? [options.headFile] : [];
@@ -5672,13 +6860,20 @@ async function watchWithWriteFilter(options, shouldWrite) {
5672
6860
  };
5673
6861
  let building = false;
5674
6862
  let queued;
6863
+ const reportError = (error) => {
6864
+ try {
6865
+ options.onError?.(error);
6866
+ } catch (e) {
6867
+ console.error(`twee-ts watch: onError threw while handling "${error.message}": ${toError(e).message}`);
6868
+ }
6869
+ };
5675
6870
  const deliver = (outcome) => {
5676
6871
  try {
5677
6872
  if (!outcome.ok) throw outcome.error;
5678
- if (shouldWrite(outcome.result)) writeFileSync(options.outFile, outcome.result.output, "utf-8");
6873
+ if (shouldWrite(outcome.result)) writeFileAtomic(options.outFile, outcome.result.output);
5679
6874
  options.onBuild?.(outcome.result);
5680
6875
  } catch (e) {
5681
- options.onError?.(toError(e));
6876
+ reportError(toError(e));
5682
6877
  }
5683
6878
  };
5684
6879
  const drain = async (first) => {
@@ -5686,8 +6881,7 @@ async function watchWithWriteFilter(options, shouldWrite) {
5686
6881
  try {
5687
6882
  let request = first;
5688
6883
  while (request !== void 0) {
5689
- const current = request;
5690
- const outcome = await buildOutput(options, cache, current.changedFiles, options.outFile).then((result) => ({
6884
+ const outcome = await buildOutput(buildOptions, cache, request.changedFiles, options.outFile).then((result) => ({
5691
6885
  ok: true,
5692
6886
  result
5693
6887
  }), (e) => ({
@@ -5695,12 +6889,13 @@ async function watchWithWriteFilter(options, shouldWrite) {
5695
6889
  error: toError(e)
5696
6890
  }));
5697
6891
  if (controller.signal.aborted) return;
5698
- request = queued === void 0 ? void 0 : mergeBuildRequests(current, queued);
6892
+ deliver(outcome);
6893
+ request = queued;
5699
6894
  queued = void 0;
5700
- if (request === void 0) deliver(outcome);
5701
6895
  }
5702
6896
  } finally {
5703
6897
  building = false;
6898
+ hooks.onIdle?.();
5704
6899
  }
5705
6900
  };
5706
6901
  const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
@@ -5710,12 +6905,17 @@ async function watchWithWriteFilter(options, shouldWrite) {
5710
6905
  queued = queued === void 0 ? request : mergeBuildRequests(queued, request);
5711
6906
  return;
5712
6907
  }
5713
- drain(request);
5714
- }, ignore);
6908
+ drain(request).catch((e) => reportError(toError(e)));
6909
+ }, ignore, reportError);
6910
+ const outer = options.signal;
6911
+ const onOuterAbort = () => controller.abort(outer?.reason);
5715
6912
  controller.signal.addEventListener("abort", () => {
5716
6913
  queued = void 0;
5717
6914
  handle.close();
6915
+ outer?.removeEventListener("abort", onOuterAbort);
5718
6916
  });
6917
+ if (outer?.aborted) onOuterAbort();
6918
+ else outer?.addEventListener("abort", onOuterAbort, { once: true });
5719
6919
  return controller;
5720
6920
  }
5721
6921
  /** One build covering both requests: a full build if either is one, else every changed file. */
@@ -5727,10 +6927,13 @@ function toError(e) {
5727
6927
  return e instanceof Error ? e : new Error(String(e));
5728
6928
  }
5729
6929
  /**
5730
- * `outFile`: the file the build is written to, which source and module discovery
5731
- * skip (as Tweego does), so an output inside a source folder is never loaded back.
6930
+ * `outputs`: what the build writes (an output file's path, or a bundler's outputs),
6931
+ * which source and module discovery skip, so an output inside a source folder is
6932
+ * never loaded back. A source, module or head file named directly that is an output
6933
+ * is a TweeTsError, as in Tweego: writing the build would overwrite it.
5732
6934
  */
5733
- async function buildOutput(options, cache, changedFiles, outFile) {
6935
+ async function buildOutput(options, cache, changedFiles, outputs) {
6936
+ const written = toBuildOutputs(outputs);
5734
6937
  const diagnostics = [];
5735
6938
  const outputMode = options.outputMode ?? "html";
5736
6939
  const trim = options.trim ?? true;
@@ -5738,13 +6941,18 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5738
6941
  const testMode = options.testMode ?? false;
5739
6942
  const noRemote = options.noRemote ?? false;
5740
6943
  const sourceInfo = options.sourceInfo ?? false;
6944
+ options.signal?.throwIfAborted();
6945
+ const timeout = options.formatFetchTimeout;
6946
+ if (timeout !== void 0 && !(timeout >= 0)) throw new TweeTsError(`formatFetchTimeout must be 0 or more milliseconds, not ${timeout}.`);
5741
6947
  clearIndexCache();
5742
6948
  const filePaths = [];
5743
6949
  const inlineSources = [];
5744
6950
  for (const source of options.sources) if (typeof source === "string") filePaths.push(source);
5745
6951
  else inlineSources.push(source);
5746
- const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths, outFile, options.exclude);
5747
- diagnostics.push(...sourcePathDiagnostics);
6952
+ const sourcePaths = getFilenames(filePaths, written, options.exclude);
6953
+ diagnostics.push(...sourcePaths.diagnostics);
6954
+ rejectOutputSources(sourcePaths.outputSources, diagnostics);
6955
+ const sourceFilenames = sourcePaths.filenames;
5748
6956
  const story = createStory();
5749
6957
  const processedFiles = /* @__PURE__ */ new Set();
5750
6958
  if (cache) loadSourcesCached(story, sourceFilenames, {
@@ -5773,7 +6981,10 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5773
6981
  switch (outputMode) {
5774
6982
  case "twee3":
5775
6983
  case "twee1":
5776
- output = toTwee(story, outputMode, { addStoryData: Boolean(options.startPassage) || testMode });
6984
+ output = toTwee(story, outputMode, {
6985
+ addStoryData: Boolean(options.startPassage) || testMode,
6986
+ diagnostics
6987
+ });
5777
6988
  break;
5778
6989
  case "twine2-archive":
5779
6990
  output = toTwine2Archive(story, startName, { sourceInfo });
@@ -5791,13 +7002,20 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5791
7002
  level: "error",
5792
7003
  message: "Special passage \"StoryTitle\" not found."
5793
7004
  });
5794
- const modules = getFilenames(options.modules ?? [], outFile);
7005
+ const modules = getFilenames(options.modules ?? [], written);
5795
7006
  diagnostics.push(...modules.diagnostics);
7007
+ const { headFile } = options;
7008
+ const headIsOutput = headFile !== void 0 && outputPaths(written).isFile(realPathOf(headFile));
7009
+ rejectOutputSources([...modules.outputSources, ...headIsOutput ? [headFile] : []], diagnostics);
5796
7010
  const head = loadHeadContent(modules.filenames, options.headFile, diagnostics);
5797
7011
  output = format.isTwine2 ? toTwine2HTML(story, format, startName, {
5798
7012
  sourceInfo,
5799
- head
5800
- }) : toTwine1HTML(story, format, startName, { head });
7013
+ head,
7014
+ diagnostics
7015
+ }) : toTwine1HTML(story, format, startName, {
7016
+ head,
7017
+ diagnostics
7018
+ });
5801
7019
  break;
5802
7020
  }
5803
7021
  default: throw new TweeTsError(`Unhandled output mode: ${outputMode}`, diagnostics);
@@ -5814,6 +7032,16 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5814
7032
  stats
5815
7033
  };
5816
7034
  }
7035
+ /**
7036
+ * Throws for the first of `paths`, inputs named directly that are also an output:
7037
+ * writing the build would overwrite them. An output found while walking a source
7038
+ * folder is skipped instead (see getFilenames).
7039
+ */
7040
+ function rejectOutputSources(paths, diagnostics) {
7041
+ const [first] = paths;
7042
+ if (first === void 0) return;
7043
+ throw new TweeTsError(`path ${first}: Output file cannot be an input source.`, [...diagnostics]);
7044
+ }
5817
7045
  function ensureIFID(story, diagnostics) {
5818
7046
  if (story.ifid !== "") return;
5819
7047
  if (story.legacyIFID !== "") {
@@ -7383,7 +8611,7 @@ function storyInspect(story, options = {}) {
7383
8611
  if (isStoryPassage(p)) storyPassages.push(p.name);
7384
8612
  if (isInfoPassage(p)) infoPassages.push(p.name);
7385
8613
  tagsByPassage.set(p.name, [...p.tags]);
7386
- for (const tag of p.tags) {
8614
+ for (const tag of new Set(p.tags)) {
7387
8615
  tagSet.add(tag);
7388
8616
  let list = passagesByTag.get(tag);
7389
8617
  if (!list) {
@@ -7516,46 +8744,100 @@ const VALID_OUTPUT_MODES = [
7516
8744
  "json"
7517
8745
  ];
7518
8746
  const VALID_WORD_COUNT_METHODS = ["tweego", "whitespace"];
7519
- /** Load a config file from the given directory (default: cwd). Returns null if not found. */
7520
- function loadConfig(dir) {
8747
+ /** One entry per {@link TweeTsConfig} key: the compiler rejects a missing or an extra key here. */
8748
+ const CONFIG_KEY_SET = {
8749
+ sources: true,
8750
+ exclude: true,
8751
+ output: true,
8752
+ outputMode: true,
8753
+ formatId: true,
8754
+ startPassage: true,
8755
+ formatPaths: true,
8756
+ formatIndices: true,
8757
+ formatUrls: true,
8758
+ useTweegoPath: true,
8759
+ modules: true,
8760
+ headFile: true,
8761
+ trim: true,
8762
+ twee2Compat: true,
8763
+ testMode: true,
8764
+ noRemote: true,
8765
+ formatFetchTimeout: true,
8766
+ tagAliases: true,
8767
+ sourceInfo: true,
8768
+ wordCountMethod: true
8769
+ };
8770
+ /** The keys a config file may hold besides `$schema`; the JSON schema's `properties` list the same keys. */
8771
+ const CONFIG_KEYS = Object.keys(CONFIG_KEY_SET);
8772
+ /** The key that only references the JSON schema, for editors. */
8773
+ const SCHEMA_KEY = "$schema";
8774
+ /** A key folded so that spellings differing only in letter case, `-` or `_` compare equal. */
8775
+ const foldKey = (key) => key.toLowerCase().replace(/[-_]/g, "");
8776
+ /**
8777
+ * Load a config file from the given directory (default: cwd). Returns null if not found.
8778
+ *
8779
+ * @param diagnostics Receives warnings that do not stop the config from loading: keys the config does not
8780
+ * define, and a file that is not valid UTF-8 (it is read as Windows-1252).
8781
+ * @throws When the file is not valid JSON or fails {@link validateConfig}.
8782
+ */
8783
+ function loadConfig(dir, diagnostics) {
7521
8784
  const base = dir ?? process.cwd();
7522
8785
  const configPath = join(base, CONFIG_FILENAME);
7523
8786
  if (!existsSync(configPath)) return null;
8787
+ const readDiagnostics = [];
7524
8788
  let raw;
7525
8789
  try {
7526
- raw = readFileSync(configPath, "utf-8");
8790
+ raw = readUTF8(configPath, readDiagnostics);
7527
8791
  } catch {
7528
8792
  return null;
7529
8793
  }
7530
- let data;
7531
- try {
7532
- data = JSON.parse(raw);
7533
- } catch (e) {
7534
- throw new Error(`Invalid JSON in ${configPath}: ${e instanceof Error ? e.message : String(e)}`);
7535
- }
7536
- const errors = validateConfig(data);
7537
- if (errors.length > 0) throw new Error(`Invalid config in ${configPath}:\n ${errors.join("\n ")}`);
7538
- return data;
8794
+ const config = parseConfig(raw, configPath, readDiagnostics);
8795
+ diagnostics?.push(...readDiagnostics);
8796
+ return config;
7539
8797
  }
7540
- /** Load a config from a specific file path. */
7541
- function loadConfigFile(filePath) {
8798
+ /**
8799
+ * Load a config from a specific file path.
8800
+ *
8801
+ * @param diagnostics Receives warnings that do not stop the config from loading: keys the config does not
8802
+ * define, and a file that is not valid UTF-8 (it is read as Windows-1252).
8803
+ * @throws When the file cannot be read, is not valid JSON or fails {@link validateConfig}.
8804
+ */
8805
+ function loadConfigFile(filePath, diagnostics) {
8806
+ const readDiagnostics = [];
7542
8807
  let raw;
7543
8808
  try {
7544
- raw = readFileSync(filePath, "utf-8");
8809
+ raw = readUTF8(filePath, readDiagnostics);
7545
8810
  } catch (e) {
7546
8811
  throw new Error(`Cannot read config file ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
7547
8812
  }
8813
+ const config = parseConfig(raw, filePath, readDiagnostics);
8814
+ diagnostics?.push(...readDiagnostics);
8815
+ return config;
8816
+ }
8817
+ /**
8818
+ * Parse and validate the text of the config file at `path`. Unknown keys are added to `diagnostics` as
8819
+ * warnings, so a config with a stray key keeps loading.
8820
+ */
8821
+ function parseConfig(raw, path, diagnostics) {
7548
8822
  let data;
7549
8823
  try {
7550
8824
  data = JSON.parse(raw);
7551
8825
  } catch (e) {
7552
- throw new Error(`Invalid JSON in ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
8826
+ throw new Error(`Invalid JSON in ${path}: ${e instanceof Error ? e.message : String(e)}`);
7553
8827
  }
7554
8828
  const errors = validateConfig(data);
7555
- if (errors.length > 0) throw new Error(`Invalid config in ${filePath}:\n ${errors.join("\n ")}`);
8829
+ if (errors.length > 0) throw new Error(`Invalid config in ${path}:\n ${errors.join("\n ")}`);
8830
+ diagnostics.push(...unknownConfigKeyWarnings(data).map((message) => ({
8831
+ level: "warning",
8832
+ message: `${path}: ${message}`,
8833
+ file: path
8834
+ })));
7556
8835
  return data;
7557
8836
  }
7558
- /** Validate a config object. Returns an array of error strings (empty = valid). */
8837
+ /**
8838
+ * Validate a config object. Returns an array of error strings (empty = valid).
8839
+ * Keys the config does not define are not errors; {@link unknownConfigKeyWarnings} reports them.
8840
+ */
7559
8841
  function validateConfig(data) {
7560
8842
  const errors = [];
7561
8843
  if (typeof data !== "object" || data === null || Array.isArray(data)) {
@@ -7588,6 +8870,10 @@ function validateConfig(data) {
7588
8870
  if (!Array.isArray(obj[key])) errors.push(`"${key}" must be an array.`);
7589
8871
  else if (!obj[key].every((v) => typeof v === "string")) errors.push(`"${key}" must be an array of strings.`);
7590
8872
  }
8873
+ if ("formatFetchTimeout" in obj) {
8874
+ const timeout = obj["formatFetchTimeout"];
8875
+ if (typeof timeout !== "number" || !(timeout >= 0)) errors.push("\"formatFetchTimeout\" must be a number of milliseconds, 0 or more.");
8876
+ }
7591
8877
  if ("tagAliases" in obj) {
7592
8878
  if (typeof obj["tagAliases"] !== "object" || obj["tagAliases"] === null || Array.isArray(obj["tagAliases"])) errors.push("\"tagAliases\" must be an object.");
7593
8879
  else {
@@ -7603,6 +8889,20 @@ function validateConfig(data) {
7603
8889
  }
7604
8890
  return errors;
7605
8891
  }
8892
+ /**
8893
+ * Warnings for the keys of a config object that the config does not define (the JSON schema's
8894
+ * `additionalProperties: false`), other than `$schema`. twee-ts ignores such a key, so a misspelt one would
8895
+ * otherwise leave its option at the default without a word. A key that differs from a defined one only in
8896
+ * letter case, `-` or `_` (`formatID`, `output-mode`) gets a suggestion. Returns `[]` for anything that is not
8897
+ * an object, which {@link validateConfig} reports.
8898
+ */
8899
+ function unknownConfigKeyWarnings(data) {
8900
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return [];
8901
+ return Object.keys(data).filter((key) => key !== SCHEMA_KEY && !Object.hasOwn(CONFIG_KEY_SET, key)).map((key) => {
8902
+ const suggestion = CONFIG_KEYS.find((known) => foldKey(known) === foldKey(key));
8903
+ return `Unknown config key "${key}"${suggestion === void 0 ? "" : ` (did you mean "${suggestion}"?)`}; it is ignored.`;
8904
+ });
8905
+ }
7606
8906
  /** Return a default config JSON string for --init scaffolding. */
7607
8907
  function scaffoldConfig() {
7608
8908
  return JSON.stringify({
@@ -7710,10 +9010,6 @@ async function main() {
7710
9010
  printUsage();
7711
9011
  return;
7712
9012
  }
7713
- if (values["list-formats"]) {
7714
- listFormats();
7715
- return;
7716
- }
7717
9013
  if (values.init) {
7718
9014
  runInit();
7719
9015
  return;
@@ -7724,8 +9020,14 @@ async function main() {
7724
9020
  }
7725
9021
  let config = null;
7726
9022
  if (!values["no-config"]) {
7727
- if (values.config) config = loadConfigFile(values.config);
7728
- else config = loadConfig();
9023
+ const configDiagnostics = [];
9024
+ if (values.config) config = loadConfigFile(values.config, configDiagnostics);
9025
+ else config = loadConfig(void 0, configDiagnostics);
9026
+ logDiagnostics(configDiagnostics);
9027
+ }
9028
+ if (values["list-formats"]) {
9029
+ listFormats(config?.formatPaths ?? [], config?.useTweegoPath ?? true);
9030
+ return;
7729
9031
  }
7730
9032
  const sources = positionals.length > 0 ? positionals : config?.sources;
7731
9033
  if (!sources || sources.length === 0) {
@@ -7778,6 +9080,7 @@ async function main() {
7778
9080
  formatIndices: values["format-index"] ?? config?.formatIndices,
7779
9081
  formatUrls: values["format-url"] ?? config?.formatUrls,
7780
9082
  noRemote: values["no-remote"] ?? config?.noRemote ?? false,
9083
+ formatFetchTimeout: config?.formatFetchTimeout,
7781
9084
  tagAliases,
7782
9085
  sourceInfo: values["source-info"] ?? config?.sourceInfo ?? false,
7783
9086
  wordCountMethod
@@ -7802,6 +9105,7 @@ async function main() {
7802
9105
  formatIndices: values["format-index"] ?? config?.formatIndices,
7803
9106
  formatUrls: values["format-url"] ?? config?.formatUrls,
7804
9107
  noRemote: values["no-remote"] ?? config?.noRemote ?? false,
9108
+ formatFetchTimeout: config?.formatFetchTimeout,
7805
9109
  tagAliases,
7806
9110
  sourceInfo: values["source-info"] ?? config?.sourceInfo ?? false,
7807
9111
  wordCountMethod
@@ -7827,7 +9131,13 @@ async function main() {
7827
9131
  logBuild(result, log);
7828
9132
  },
7829
9133
  onError(error) {
7830
- console.error(`Build error: ${error.message}`);
9134
+ if (error instanceof WatchPathError) {
9135
+ console.error(`error: ${error.message}`);
9136
+ process.exitCode = 1;
9137
+ } else {
9138
+ logErrorDiagnostics(error);
9139
+ console.error(`Build error: ${error.message}`);
9140
+ }
7831
9141
  }
7832
9142
  }, (result) => countErrors(result.diagnostics) === 0);
7833
9143
  } else {
@@ -7838,7 +9148,7 @@ async function main() {
7838
9148
  console.error(`Compilation failed with ${pluralize(errors, "error")}; output not written.`);
7839
9149
  process.exitCode = 1;
7840
9150
  } else if (outPath === void 0) process.stdout.write(result.output);
7841
- else writeFileSync(outPath, result.output, "utf-8");
9151
+ else writeFileAtomic(outPath, result.output);
7842
9152
  logBuild(result, log);
7843
9153
  }
7844
9154
  }
@@ -7852,24 +9162,47 @@ function countErrors(diagnostics) {
7852
9162
  function pluralize(count, noun) {
7853
9163
  return `${count} ${noun}${count === 1 ? "" : "s"}`;
7854
9164
  }
7855
- function listFormats() {
7856
- const formats = discoverFormats(getFormatSearchDirs());
9165
+ /** Order version strings from highest to lowest SemVer precedence (unparseable ones last). */
9166
+ function byVersionDescending(a, b) {
9167
+ const left = parseVersion(a);
9168
+ const right = parseVersion(b);
9169
+ if (!left || !right) return Number(!left) - Number(!right);
9170
+ return compareVersions(right, left);
9171
+ }
9172
+ /** Print the formats --format can select: local folders (pruned by SemVer) and cached downloads, by ID. */
9173
+ function listFormats(formatPaths, useTweegoPath) {
9174
+ const diagnostics = [];
9175
+ const formats = pruneFormats(discoverAllFormats(getFormatSearchDirs(formatPaths, useTweegoPath), diagnostics));
9176
+ logDiagnostics(diagnostics);
7857
9177
  console.log("Local story formats:");
7858
9178
  if (formats.size === 0) console.log(" (none)");
7859
9179
  else for (const [id, f] of formats) {
7860
9180
  const type = f.isTwine2 ? "Twine 2" : "Twine 1";
7861
9181
  console.log(` ${id}: ${f.name || id} ${f.version} (${type})`);
7862
9182
  }
7863
- const cached = discoverCachedFormats();
7864
- if (cached.size > 0) {
9183
+ const cachedById = /* @__PURE__ */ new Map();
9184
+ for (const f of discoverCachedFormats().values()) {
9185
+ const id = makeFormatId(f.name, f.version);
9186
+ cachedById.set(id, [...cachedById.get(id) ?? [], f]);
9187
+ }
9188
+ if (cachedById.size > 0) {
7865
9189
  console.log("\nCached remote formats:");
7866
- for (const [id, f] of cached) console.log(` ${id}: ${f.name} ${f.version}`);
9190
+ for (const [id, versions] of [...cachedById].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
9191
+ const [newest, ...older] = [...versions].sort((a, b) => byVersionDescending(a.version, b.version));
9192
+ if (!newest) continue;
9193
+ const also = older.length > 0 ? ` (also cached: ${older.map((f) => f.version).join(", ")})` : "";
9194
+ console.log(` ${id}: ${newest.name} ${newest.version}${also}`);
9195
+ }
7867
9196
  }
7868
9197
  }
7869
9198
  function logDiagnostics(diagnostics) {
7870
9199
  for (const d of diagnostics) if (d.level === "error") console.error(`error: ${d.message}`);
7871
9200
  else console.warn(`warning: ${d.message}`);
7872
9201
  }
9202
+ /** Prints what a fatal TweeTsError collected (such as why no story format was found) before its message. */
9203
+ function logErrorDiagnostics(error) {
9204
+ if (error instanceof TweeTsError) logDiagnostics(error.diagnostics);
9205
+ }
7873
9206
  function logStats(result) {
7874
9207
  const s = result.stats;
7875
9208
  console.log(`\nStatistics:`);
@@ -8021,6 +9354,7 @@ Subcommands:
8021
9354
  cache path Print cache directory path`);
8022
9355
  }
8023
9356
  main().catch((err) => {
9357
+ logErrorDiagnostics(err);
8024
9358
  console.error(err instanceof Error ? err.message : String(err));
8025
9359
  process.exit(1);
8026
9360
  });