@rohal12/twee-ts 1.17.1 → 1.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -21,10 +21,10 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
21
21
  enumerable: true
22
22
  }) : target, mod));
23
23
  //#endregion
24
- let node_fs = require("node:fs");
25
24
  let node_path = require("node:path");
26
25
  node_path = __toESM(node_path, 1);
27
26
  let node_crypto = require("node:crypto");
27
+ let node_fs = require("node:fs");
28
28
  let node_os = require("node:os");
29
29
  let node_url = require("node:url");
30
30
  //#region src/ifid.ts
@@ -136,18 +136,44 @@ function tweeEscape(s) {
136
136
  if (s.length === 0) return s;
137
137
  return s.replace(/[\\[\]{}]/g, (ch) => "\\" + ch);
138
138
  }
139
- /** Escape a string for safe inclusion in a JavaScript string literal. */
139
+ const JS_STRING_ESCAPES = {
140
+ "\\": "\\\\",
141
+ "\"": "\\\"",
142
+ "'": "\\'",
143
+ "\n": "\\n",
144
+ "\r": "\\r",
145
+ " ": "\\t",
146
+ "<": "\\x3C",
147
+ "\u2028": "\\u2028",
148
+ "\u2029": "\\u2029"
149
+ };
150
+ /**
151
+ * Escape a string for the inside of a single- or double-quoted JavaScript string literal, including one written
152
+ * into an HTML `<script>` element. Besides backslashes, quotes and line breaks (with U+2028 and U+2029, which end
153
+ * a string literal in JavaScript before ES2019), every `<` becomes `\x3C`, so the literal holds no `<\/script`
154
+ * that would end the element and no `<!--` or `<script` that would change where it ends. The string's value is
155
+ * unchanged.
156
+ */
140
157
  function jsStringEscape(s) {
141
158
  if (s.length === 0) return s;
142
- return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
159
+ return s.replace(/[\\"'\n\r\t<\u2028\u2029]/g, (ch) => JS_STRING_ESCAPES[ch] ?? ch);
160
+ }
161
+ /**
162
+ * Escape a string for the inside of a double-quoted CSS string (such as a `font-family` name): `\` and `"` get
163
+ * a backslash, and line breaks and other control characters become hex escapes followed by a space (`\a ` for a
164
+ * line feed), since a raw line break ends a CSS string and a backslash before one is a line continuation.
165
+ */
166
+ function cssStringEscape(s) {
167
+ if (s.length === 0) return s;
168
+ return s.replace(/[\\"\u0000-\u001f\u007f]/g, (ch) => ch === "\\" || ch === "\"" ? `\\${ch}` : `\\${ch.charCodeAt(0).toString(16)} `);
143
169
  }
144
170
  /** Sanitize a string for safe inclusion in a CSS or JS block comment. */
145
171
  function commentSanitize(s) {
146
172
  return s.replace(/\*\//g, "* /");
147
173
  }
148
- /** Sanitize a string for safe inclusion in an HTML comment. */
174
+ /** Sanitize a string for safe inclusion in an HTML comment: break up `-->` and `--!>`, which both end one. */
149
175
  function htmlCommentSanitize(s) {
150
- return s.replace(/-->/g, "-- >");
176
+ return s.replace(/--(!?)>/g, "--$1 >");
151
177
  }
152
178
  /**
153
179
  * Escape JavaScript for the content of an HTML `<script>` element, so that the HTML parser reads all of it as
@@ -327,7 +353,19 @@ function passageToPassagedata(p, pid, options) {
327
353
  if (options?.sourceInfo && p.source) attrs += ` data-source-file=${quote(attrEscape(p.source.file))} data-source-line="${p.source.line}"`;
328
354
  return `${attrs}>${htmlEscape(p.text)}</tw-passagedata>`;
329
355
  }
330
- /** Generate `<div tiddler>` HTML for Twine 1. */
356
+ /**
357
+ * Whether Twine 1 `obfuscate:rot13` encodes a tiddler: every one but `StorySettings` and those tagged `Twine.image`
358
+ * (Twine 1.4 `Tiddler.isObfuscateable()`). Its engine tests the stored name and tags, which these tiddlers keep
359
+ * unencoded, so the decompiler tests them the same way.
360
+ */
361
+ function isObfuscatable(p) {
362
+ return p.name !== "StorySettings" && !p.tags.includes("Twine.image");
363
+ }
364
+ /**
365
+ * Generate `<div tiddler>` HTML for Twine 1. With `obfuscateRot13`, an obfuscatable tiddler (see
366
+ * `isObfuscatable()`) has its name, each tag and its text ROT13-encoded, as Twine 1.4 writes it
367
+ * (`Tiddler.toHtml()`), and as its engine.js decodes it.
368
+ */
331
369
  function passageToTiddler(p, pid, obfuscateRot13 = false) {
332
370
  let position;
333
371
  if (hasMetadataPosition(p)) position = p.metadata?.position ?? "";
@@ -339,9 +377,10 @@ function passageToTiddler(p, pid, obfuscateRot13 = false) {
339
377
  position = `${xp * 140 - 130},${yp * 140 - 130}`;
340
378
  }
341
379
  const created = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 12);
342
- const content = obfuscateRot13 && p.name !== "StorySettings" ? tiddlerEscape(rot13(p.text)) : tiddlerEscape(p.text);
343
- const nameComment = obfuscateRot13 ? `<!-- ${commentSanitize(p.name)} -->` : "";
344
- 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>`;
380
+ const encode = obfuscateRot13 && isObfuscatable(p) ? rot13 : (s) => s;
381
+ const name = attrEscape(encode(p.name));
382
+ const tags = attrEscape(p.tags.map(encode).join(" "));
383
+ return `<div tiddler=${quote(name)} tags=${quote(tags)} created=${quote(created)} modifier=${quote("twee")} twine-position=${quote(attrEscape(position))}>${tiddlerEscape(encode(p.text))}</div>`;
345
384
  }
346
385
  /**
347
386
  * Count words in a passage.
@@ -394,19 +433,136 @@ function quote(s) {
394
433
  }
395
434
  //#endregion
396
435
  //#region src/story.ts
397
- /** Module-level index: maps passage names to their array indices for O(1) lookups. */
398
436
  const passageIndex = /* @__PURE__ */ new WeakMap();
399
- function getIndex(story) {
400
- let index = passageIndex.get(story);
401
- if (!index) {
402
- index = /* @__PURE__ */ new Map();
403
- for (let i = 0; i < story.passages.length; i++) {
404
- const passage = story.passages[i];
405
- if (passage) index.set(passage.name, i);
437
+ function buildIndex(story) {
438
+ const names = story.passages.map((p) => p.name);
439
+ const index = {
440
+ passages: story.passages,
441
+ names,
442
+ positions: new Map(names.map((name, i) => [name, i]))
443
+ };
444
+ passageIndex.set(story, index);
445
+ return index;
446
+ }
447
+ /**
448
+ * The index, rebuilt if `story.passages` was reassigned or changed length. O(1); `position()`
449
+ * also checks every hit. A change that keeps the length, such as replacing an element, can still
450
+ * hide a passage from a lookup, so `storyVerifyIndex()` checks every position.
451
+ */
452
+ function currentIndex(story) {
453
+ const index = passageIndex.get(story);
454
+ return index !== void 0 && index.passages === story.passages && index.names.length === story.passages.length ? index : buildIndex(story);
455
+ }
456
+ /**
457
+ * Rebuild the index unless it matches `story.passages` at every position. O(n): for entry points
458
+ * that run after code outside the compiler may have changed the passages, such as StoryBuilder.
459
+ */
460
+ function storyVerifyIndex(story) {
461
+ const index = currentIndex(story);
462
+ if (!story.passages.every((p, i) => p.name === index.names[i])) buildIndex(story);
463
+ }
464
+ /** The position of the passage named `name`, or -1. */
465
+ function position(story, name) {
466
+ const i = currentIndex(story).positions.get(name);
467
+ if (i === void 0) return -1;
468
+ if (story.passages[i]?.name === name) return i;
469
+ return buildIndex(story).positions.get(name) ?? -1;
470
+ }
471
+ /** Add a passage whose name no passage has yet at the end. */
472
+ function push(story, p) {
473
+ const index = currentIndex(story);
474
+ index.positions.set(p.name, story.passages.length);
475
+ index.names.push(p.name);
476
+ story.passages.push(p);
477
+ }
478
+ /** Put a passage at position `i`, in place of the passage there. */
479
+ function replaceAt(story, i, p) {
480
+ const renamed = story.passages[i]?.name !== p.name;
481
+ story.passages[i] = p;
482
+ if (renamed) buildIndex(story);
483
+ }
484
+ /** Names of compiler special passages, which storyAdd() reads into the story model. */
485
+ const SPECIAL_PASSAGE_NAMES = /* @__PURE__ */ new Set([
486
+ "StoryData",
487
+ "StoryIncludes",
488
+ "StorySettings",
489
+ "StoryTitle"
490
+ ]);
491
+ /** Passages whose names were generated, kept by identity so that cached passages keep the mark. */
492
+ const generatedNames = /* @__PURE__ */ new WeakMap();
493
+ /**
494
+ * Mark a passage's name as generated (from a file name, or for imported story code) and return
495
+ * the passage. `origin.base` is the name that free names are numbered from: `base`, `base 2`, ….
496
+ */
497
+ function withGeneratedName(p, origin) {
498
+ generatedNames.set(p, origin);
499
+ return p;
500
+ }
501
+ /** The first of `base`, `base 2`, `base 3`, … that `isFree` accepts. */
502
+ function freeName(base, isFree) {
503
+ if (isFree(base)) return base;
504
+ for (let n = 2;; n++) {
505
+ const candidate = `${base} ${n}`;
506
+ if (isFree(candidate)) return candidate;
507
+ }
508
+ }
509
+ /** Whether a generated name may take `name`: no passage has it and it is no special name. */
510
+ function isFreeForGenerated(story, name) {
511
+ return !SPECIAL_PASSAGE_NAMES.has(name) && position(story, name) === -1;
512
+ }
513
+ function reportRename(origin, from, to, diagnostics) {
514
+ switch (origin.kind) {
515
+ case "code": return;
516
+ case "media": {
517
+ const reason = SPECIAL_PASSAGE_NAMES.has(from) ? `"${from}" is a compiler special passage name` : `another passage has the name "${from}"`;
518
+ diagnostics.push({
519
+ level: "warning",
520
+ message: `Passage "${from}" from "${origin.file}" renamed to "${to}"; ${reason}.`
521
+ });
522
+ return;
406
523
  }
407
- passageIndex.set(story, index);
524
+ default: throw new Error(`unhandled generated name: ${JSON.stringify(origin)}`);
408
525
  }
409
- return index;
526
+ }
527
+ /** Add a passage with a generated name, under the first free name if its own is taken. */
528
+ function addGenerated(story, p, origin, diagnostics) {
529
+ if (isFreeForGenerated(story, p.name)) {
530
+ push(story, p);
531
+ return;
532
+ }
533
+ const name = freeName(origin.base, (n) => isFreeForGenerated(story, n));
534
+ reportRename(origin, p.name, name, diagnostics);
535
+ push(story, withGeneratedName({
536
+ ...p,
537
+ name
538
+ }, origin));
539
+ }
540
+ /**
541
+ * If a passage with a generated name has `name`, give it the first free name, in its place in the
542
+ * passage list, so that a passage from the sources can take the name.
543
+ */
544
+ function moveGeneratedAside(story, name, diagnostics) {
545
+ const i = position(story, name);
546
+ const existing = i === -1 ? void 0 : story.passages[i];
547
+ const origin = existing === void 0 ? void 0 : generatedNames.get(existing);
548
+ if (existing === void 0 || origin === void 0) return;
549
+ const free = freeName(origin.base, (n) => isFreeForGenerated(story, n));
550
+ reportRename(origin, name, free, diagnostics);
551
+ replaceAt(story, i, withGeneratedName({
552
+ ...existing,
553
+ name: free
554
+ }, origin));
555
+ }
556
+ function defaultTwine2Metadata() {
557
+ return {
558
+ format: "",
559
+ formatVersion: "",
560
+ options: /* @__PURE__ */ new Map(),
561
+ start: "",
562
+ tags: "",
563
+ tagColors: /* @__PURE__ */ new Map(),
564
+ zoom: 1
565
+ };
410
566
  }
411
567
  function createStory() {
412
568
  const story = {
@@ -415,52 +571,37 @@ function createStory() {
415
571
  passages: [],
416
572
  legacyIFID: "",
417
573
  twine1: { settings: /* @__PURE__ */ new Map() },
418
- twine2: {
419
- format: "",
420
- formatVersion: "",
421
- options: /* @__PURE__ */ new Map(),
422
- start: "",
423
- tags: "",
424
- tagColors: /* @__PURE__ */ new Map(),
425
- zoom: 1
426
- }
574
+ twine2: defaultTwine2Metadata()
427
575
  };
428
- passageIndex.set(story, /* @__PURE__ */ new Map());
576
+ buildIndex(story);
429
577
  return story;
430
578
  }
431
579
  function storyHas(story, name) {
432
- return getIndex(story).has(name);
580
+ return position(story, name) !== -1;
433
581
  }
434
582
  function storyAppend(story, p, diagnostics) {
435
- const index = getIndex(story);
436
- const i = index.get(p.name) ?? -1;
437
- if (i === -1) {
438
- index.set(p.name, story.passages.length);
439
- story.passages.push(p);
440
- } else {
583
+ const i = position(story, p.name);
584
+ if (i === -1) push(story, p);
585
+ else {
441
586
  diagnostics.push({
442
587
  level: "warning",
443
588
  message: `Replacing existing passage "${p.name}" with duplicate.`
444
589
  });
445
- story.passages[i] = p;
590
+ replaceAt(story, i, p);
446
591
  }
447
592
  }
448
593
  function storyPrepend(story, p, diagnostics) {
449
- const index = getIndex(story);
450
- const i = index.get(p.name) ?? -1;
594
+ moveGeneratedAside(story, p.name, diagnostics);
595
+ const i = position(story, p.name);
451
596
  if (i === -1) {
452
597
  story.passages.unshift(p);
453
- index.clear();
454
- for (let j = 0; j < story.passages.length; j++) {
455
- const passage = story.passages[j];
456
- if (passage) index.set(passage.name, j);
457
- }
598
+ buildIndex(story);
458
599
  } else {
459
600
  diagnostics.push({
460
601
  level: "warning",
461
602
  message: `Replacing existing passage "${p.name}" with duplicate.`
462
603
  });
463
- story.passages[i] = p;
604
+ replaceAt(story, i, p);
464
605
  }
465
606
  }
466
607
  function marshalStoryData(story) {
@@ -486,18 +627,20 @@ function unmarshalStoryData(story, json) {
486
627
  }
487
628
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return "Cannot unmarshal \"StoryData\"; expected a JSON object";
488
629
  const data = raw;
489
- if (typeof data.ifid === "string" && data.ifid) story.ifid = normalizeIFID(data.ifid);
490
- if (typeof data.format === "string" && data.format) story.twine2.format = data.format;
491
- if (typeof data["format-version"] === "string" && data["format-version"]) story.twine2.formatVersion = data["format-version"];
630
+ const twine2 = defaultTwine2Metadata();
631
+ story.ifid = typeof data.ifid === "string" ? normalizeIFID(data.ifid) : "";
632
+ if (typeof data.format === "string") twine2.format = data.format;
633
+ if (typeof data["format-version"] === "string") twine2.formatVersion = data["format-version"];
492
634
  if (Array.isArray(data.options)) {
493
- for (const opt of data.options) if (typeof opt === "string") story.twine2.options.set(opt, true);
635
+ for (const opt of data.options) if (typeof opt === "string") twine2.options.set(opt, true);
494
636
  }
495
- if (typeof data.start === "string" && data.start) story.twine2.start = data.start;
496
- if (typeof data.tags === "string") story.twine2.tags = data.tags;
637
+ if (typeof data.start === "string") twine2.start = data.start;
638
+ if (typeof data.tags === "string") twine2.tags = data.tags;
497
639
  if (typeof data["tag-colors"] === "object" && data["tag-colors"] !== null && !Array.isArray(data["tag-colors"])) {
498
- for (const [tag, color] of Object.entries(data["tag-colors"])) if (typeof color === "string") story.twine2.tagColors.set(tag, color);
640
+ for (const [tag, color] of Object.entries(data["tag-colors"])) if (typeof color === "string") twine2.tagColors.set(tag, color);
499
641
  }
500
- if (typeof data.zoom === "number" && data.zoom !== 0) story.twine2.zoom = data.zoom;
642
+ if (typeof data.zoom === "number" && data.zoom !== 0) twine2.zoom = data.zoom;
643
+ story.twine2 = twine2;
501
644
  return null;
502
645
  }
503
646
  function unmarshalStorySettings(story, text, diagnostics) {
@@ -538,8 +681,20 @@ function unmarshalStorySettings(story, text, diagnostics) {
538
681
  /**
539
682
  * Process a passage and add it to the story, handling special passages.
540
683
  * Creates new passage objects where text is modified rather than mutating the input.
684
+ *
685
+ * A passage replaces an earlier one with the same name, except where either name was generated
686
+ * (see `withGeneratedName()`): a generated name never replaces a passage and is never replaced.
687
+ * A passage with a generated name takes the first free name of `base`, `base 2`, … when its own
688
+ * is taken or is a compiler special name. A passage with a name from the sources that a generated
689
+ * name holds takes it over, and the generated one moves to the first free name in its place.
541
690
  */
542
691
  function storyAdd(story, p, diagnostics) {
692
+ const origin = generatedNames.get(p);
693
+ if (origin !== void 0) {
694
+ addGenerated(story, p, origin, diagnostics);
695
+ return;
696
+ }
697
+ moveGeneratedAside(story, p.name, diagnostics);
543
698
  let processed = p;
544
699
  switch (p.name) {
545
700
  case "StoryIncludes":
@@ -599,7 +754,9 @@ function getStoryStats(story, wordCountMethod = "tweego") {
599
754
  }
600
755
  /**
601
756
  * Builder that separates the mutable construction phase from the immutable consumption phase.
602
- * During construction, the internal `Story` is mutable via `add()` and direct access.
757
+ * During construction, the internal `Story` is mutable via `add()` and direct access: you may
758
+ * push, splice, sort, rename or reassign `story.passages` between calls, and `add()` and `has()`
759
+ * see the passages as they are then.
603
760
  * After `build()`, the story is returned as `ReadonlyStory`.
604
761
  */
605
762
  var StoryBuilder = class {
@@ -610,10 +767,13 @@ var StoryBuilder = class {
610
767
  }
611
768
  /** Add a passage, handling special passages (StoryData, StoryTitle, etc.). */
612
769
  add(passage, diagnostics) {
770
+ if (generatedNames.has(passage) || !storyHas(this.story, passage.name)) storyVerifyIndex(this.story);
613
771
  storyAdd(this.story, passage, diagnostics);
614
772
  }
615
773
  /** Check if a passage name exists. */
616
774
  has(name) {
775
+ if (storyHas(this.story, name)) return true;
776
+ storyVerifyIndex(this.story);
617
777
  return storyHas(this.story, name);
618
778
  }
619
779
  /** Finalize and return the story as read-only. */
@@ -738,131 +898,579 @@ function isExcluded(filename, exclude) {
738
898
  const rel = (0, node_path.relative)(process.cwd(), (0, node_path.resolve)(filename)) || filename;
739
899
  return exclude.some((pattern) => matchesGlob(rel, pattern.replace(/^\.\//, "")));
740
900
  }
901
+ /** A build that writes nothing: `compile()`, or output to stdout. */
902
+ const NO_BUILD_OUTPUTS = {
903
+ files: [],
904
+ dirs: []
905
+ };
906
+ /** The outputs of a build that writes the one file `outFile`, or none without it. */
907
+ function toBuildOutputs(outputs) {
908
+ if (outputs === void 0) return NO_BUILD_OUTPUTS;
909
+ return typeof outputs === "string" ? {
910
+ files: [outputs],
911
+ dirs: []
912
+ } : outputs;
913
+ }
914
+ /**
915
+ * The path outputs are compared by: absolute, with every symbolic link in it
916
+ * resolved, so two paths that reach the same file through different links (a
917
+ * symlinked project folder, an alias of the output folder) compare equal. A
918
+ * path that doesn't exist yet, such as the output before the first build, is
919
+ * its nearest existing folder's real path joined with the rest.
920
+ */
921
+ function realPathOf(path) {
922
+ const abs = (0, node_path.resolve)(path);
923
+ try {
924
+ return node_fs.realpathSync.native(abs);
925
+ } catch {}
926
+ const parent = (0, node_path.dirname)(abs);
927
+ return parent === abs ? abs : (0, node_path.join)(realPathOf(parent), (0, node_path.basename)(abs));
928
+ }
929
+ /** Whether the path `inner` is `outer` or inside it. */
930
+ function isSameOrInside(inner, outer) {
931
+ return inner === outer || inner.startsWith(outer.endsWith(node_path.sep) ? outer : outer + node_path.sep);
932
+ }
933
+ function outputPaths(outputs) {
934
+ const files = new Set(outputs.files.map(realPathOf));
935
+ const dirs = new Set(outputs.dirs.map(realPathOf));
936
+ const all = [...files, ...dirs];
937
+ return {
938
+ isFile: (real) => files.has(real),
939
+ isDir: (real) => dirs.has(real),
940
+ holds: (real) => dirs.has(real) || all.some((output) => output !== real && isSameOrInside(output, real)),
941
+ isOutput(path, roots) {
942
+ const real = realPathOf(path);
943
+ if (files.has(real)) return true;
944
+ const owners = [...dirs].filter((dir) => isSameOrInside(real, dir));
945
+ if (owners.length === 0) return false;
946
+ return roots.map(realPathOf).filter((root) => isSameOrInside(real, root)).every((root) => owners.some((dir) => dir !== root && isSameOrInside(dir, root)));
947
+ }
948
+ };
949
+ }
950
+ /**
951
+ * A folder entry as source discovery walks it, found at `pathname` with the real
952
+ * path `real` when it is no link: a link to a file counts as that file, and a
953
+ * link to anything else (a folder above all) as nothing, undefined. Throws when
954
+ * the entry or a link's target can't be read.
955
+ */
956
+ function walkedEntry(pathname, real) {
957
+ const stat = (0, node_fs.lstatSync)(pathname);
958
+ if (!stat.isSymbolicLink()) return {
959
+ stat,
960
+ real
961
+ };
962
+ const target = (0, node_fs.statSync)(pathname);
963
+ return target.isFile() ? {
964
+ stat: target,
965
+ real: realPathOf(pathname)
966
+ } : void 0;
967
+ }
741
968
  /**
742
969
  * Recursively walk directories, collecting regular file paths.
743
- * Filters out the output file to prevent circular compilation, and the files
744
- * that match an `exclude` glob (see isExcluded).
970
+ *
971
+ * Leaves out what the build writes (`outputs`, compared by real path, see
972
+ * realPathOf) so its last output is never read back, and the files that match an
973
+ * `exclude` glob (see isExcluded). A path given that is itself an output file is
974
+ * not walked but listed in `outputSources`.
975
+ *
976
+ * Symbolic links: a path given is followed wherever it leads. Inside a folder, a
977
+ * link to a file is read, but a link to a folder is not followed, as Tweego's walk
978
+ * doesn't follow one. A link back to its own folder or a parent would otherwise
979
+ * walk the same files again at every depth.
980
+ *
745
981
  * Like Tweego, a path that cannot be read is reported as a warning and skipped.
746
982
  */
747
- function getFilenames(pathnames, outFilename, exclude = []) {
983
+ function getFilenames(pathnames, outputs, exclude = []) {
748
984
  const filenames = [];
749
985
  const diagnostics = [];
750
- const absOutFile = outFilename ? (0, node_path.resolve)(outFilename) : "";
986
+ const outputSources = [];
987
+ const output = outputPaths(toBuildOutputs(outputs));
751
988
  function warn(pathname, e) {
752
989
  diagnostics.push({
753
990
  level: "warning",
754
991
  message: `path ${pathname}: ${e instanceof Error ? e.message : String(e)}`
755
992
  });
756
993
  }
757
- function walk(pathname) {
994
+ function addFile(pathname) {
995
+ const abs = (0, node_path.resolve)(pathname);
996
+ const rel = (0, node_path.relative)(process.cwd(), abs);
997
+ if (isExcluded(rel || abs, exclude)) return;
998
+ filenames.push(rel || abs);
999
+ }
1000
+ function walkDir(pathname, real) {
1001
+ let entries;
1002
+ try {
1003
+ entries = (0, node_fs.readdirSync)(pathname);
1004
+ } catch (e) {
1005
+ warn(pathname, e);
1006
+ return;
1007
+ }
1008
+ for (const entry of entries) walkEntry((0, node_path.join)(pathname, entry), (0, node_path.join)(real, entry));
1009
+ }
1010
+ function walkEntry(pathname, real) {
1011
+ let entry;
1012
+ try {
1013
+ entry = walkedEntry(pathname, real);
1014
+ } catch (e) {
1015
+ warn(pathname, e);
1016
+ return;
1017
+ }
1018
+ if (entry === void 0) return;
1019
+ if (entry.stat.isFile()) {
1020
+ if (!output.isFile(entry.real)) addFile(pathname);
1021
+ } else if (entry.stat.isDirectory() && !output.isDir(entry.real)) walkDir(pathname, entry.real);
1022
+ }
1023
+ for (const pathname of pathnames) {
758
1024
  let stat;
759
1025
  try {
760
1026
  stat = (0, node_fs.statSync)(pathname);
761
1027
  } catch (e) {
762
1028
  warn(pathname, e);
763
- return;
1029
+ continue;
764
1030
  }
1031
+ const real = realPathOf(pathname);
765
1032
  if (stat.isFile()) {
766
- const abs = (0, node_path.resolve)(pathname);
767
- if (abs === absOutFile) return;
768
- const rel = (0, node_path.relative)(process.cwd(), abs);
769
- if (isExcluded(rel || abs, exclude)) return;
770
- filenames.push(rel || abs);
771
- } else if (stat.isDirectory()) {
772
- let entries;
773
- try {
774
- entries = (0, node_fs.readdirSync)(pathname);
775
- } catch (e) {
776
- warn(pathname, e);
777
- return;
778
- }
779
- for (const entry of entries) walk((0, node_path.join)(pathname, entry));
780
- }
1033
+ if (output.isFile(real)) outputSources.push(pathname);
1034
+ else addFile(pathname);
1035
+ } else if (stat.isDirectory()) walkDir(pathname, real);
781
1036
  }
782
- for (const pathname of pathnames) walk(pathname);
783
1037
  return {
784
1038
  filenames,
785
- diagnostics
1039
+ diagnostics,
1040
+ outputSources
786
1041
  };
787
1042
  }
788
- /** Whether the absolute path `file` is the absolute path `dir` or inside it. */
789
- function isInsideDir(file, dir) {
790
- return file === dir || file.startsWith(dir.endsWith(node_path.sep) ? dir : dir + node_path.sep);
791
- }
792
1043
  /**
793
- * The watched paths split into folders (as given) and files (absolute). A path
794
- * that cannot be read counts as a folder, which fs.watch then fails to watch.
1044
+ * A watched path that can't be watched: fs.watch failed on it, or on the folder it is
1045
+ * watched through. watchFilesystem passes it to its `onError` and keeps watching the rest.
795
1046
  */
796
- function splitWatchRoots(pathnames) {
797
- const dirs = [];
798
- const files = /* @__PURE__ */ new Set();
799
- for (const pathname of pathnames) {
800
- let isFile = false;
801
- try {
802
- isFile = (0, node_fs.statSync)(pathname).isFile();
803
- } catch {}
804
- if (isFile) files.add((0, node_path.resolve)(pathname));
805
- else dirs.push(pathname);
1047
+ var WatchPathError = class extends Error {
1048
+ path;
1049
+ constructor(path, cause) {
1050
+ super(`Cannot watch ${path}: ${cause instanceof Error ? cause.message : String(cause)}`);
1051
+ this.path = path;
1052
+ this.name = "WatchPathError";
806
1053
  }
1054
+ };
1055
+ function pathKind(path) {
1056
+ try {
1057
+ const stats = (0, node_fs.statSync)(path);
1058
+ return stats.isDirectory() ? {
1059
+ kind: "dir",
1060
+ id: `${stats.dev}:${stats.ino}`
1061
+ } : { kind: "file" };
1062
+ } catch {
1063
+ return { kind: "missing" };
1064
+ }
1065
+ }
1066
+ function rootState(abs) {
807
1067
  return {
808
- dirs,
809
- files
1068
+ anchor: nearestFolderAbove(abs),
1069
+ self: pathKind(abs)
810
1070
  };
811
1071
  }
1072
+ function nearestFolderAbove(abs) {
1073
+ for (let dir = (0, node_path.dirname)(abs), below = abs; dir !== below; below = dir, dir = (0, node_path.dirname)(dir)) {
1074
+ const kind = pathKind(dir);
1075
+ if (kind.kind === "dir") return {
1076
+ path: dir,
1077
+ id: kind.id
1078
+ };
1079
+ }
1080
+ }
1081
+ /** Whether two states need the same watches: the same anchor folder, and the same watched folder if any. */
1082
+ function sameWatches(a, b) {
1083
+ const selfId = (s) => s.self.kind === "dir" ? s.self.id : void 0;
1084
+ return a.anchor?.path === b.anchor?.path && a.anchor?.id === b.anchor?.id && selfId(a) === selfId(b);
1085
+ }
812
1086
  /**
813
1087
  * Watch paths for changes, calling the build callback on known file type changes.
814
1088
  * A folder is watched recursively; a file is watched on its own and counts
815
1089
  * whatever its type (a head file, say). A change to a file `ignore` returns true
816
1090
  * for (given the path relative to the working directory) schedules no build.
817
1091
  * Uses debouncing to avoid rapid rebuilds.
818
- */
819
- function watchFilesystem(pathnames, outFilename, callback, ignore = () => false) {
820
- const absOutFile = (0, node_path.resolve)(outFilename);
821
- const watchers = [];
1092
+ *
1093
+ * A watched path that doesn't exist yet is waited for, and one that is deleted, or renamed
1094
+ * away and replaced, is followed to the new folder or file at its path: each path is also
1095
+ * watched through the folder above it. When that sets up a path's watches again, the next
1096
+ * build is a full one (`changedFiles` undefined), since changes made in between went unseen.
1097
+ * A path that can't be watched is passed to `onError` as a WatchPathError, once while the
1098
+ * failure persists; the other paths are still watched.
1099
+ */
1100
+ function watchFilesystem(pathnames, outFilename, callback, ignore = () => false, onError = () => {}) {
1101
+ const output = outputPaths(toBuildOutputs(outFilename));
822
1102
  let buildTimer = null;
823
1103
  const BUILD_DEBOUNCE = 500;
824
1104
  const pendingFiles = /* @__PURE__ */ new Set();
1105
+ let pendingFullBuild = false;
1106
+ let closed = false;
825
1107
  function scheduleBuild(changedFile) {
826
- if (changedFile) pendingFiles.add(changedFile);
1108
+ if (changedFile === void 0) pendingFullBuild = true;
1109
+ else pendingFiles.add(changedFile);
827
1110
  if (buildTimer) clearTimeout(buildTimer);
828
1111
  buildTimer = setTimeout(() => {
829
1112
  buildTimer = null;
830
- const files = pendingFiles.size > 0 ? new Set(pendingFiles) : void 0;
1113
+ const files = pendingFullBuild || pendingFiles.size === 0 ? void 0 : new Set(pendingFiles);
831
1114
  pendingFiles.clear();
1115
+ pendingFullBuild = false;
832
1116
  callback(files);
833
1117
  }, BUILD_DEBOUNCE);
834
1118
  }
1119
+ const roots = pathnames.map((path) => {
1120
+ const abs = (0, node_path.resolve)(path);
1121
+ return {
1122
+ path,
1123
+ abs,
1124
+ state: rootState(abs),
1125
+ watchers: [],
1126
+ failed: false,
1127
+ lastReport: void 0
1128
+ };
1129
+ });
1130
+ const isNamedFile = (abs) => roots.some((r) => r.abs === abs && r.state.self.kind === "file");
835
1131
  function fileChanged(abs, named) {
836
- if (abs === absOutFile) return;
837
1132
  if (!named && !isKnownFileType(abs)) return;
1133
+ if (output.isFile(realPathOf(abs))) return;
838
1134
  const rel = (0, node_path.relative)(process.cwd(), abs);
839
1135
  if (!ignore(rel || abs)) scheduleBuild(rel || abs);
840
1136
  }
841
- function watchPath(pathname, recursive, listener) {
1137
+ function report(root, cause) {
1138
+ const error = new WatchPathError(root.path, cause);
1139
+ if (error.message === root.lastReport) return;
1140
+ root.lastReport = error.message;
1141
+ onError(error);
1142
+ }
1143
+ function startWatch(root, path, recursive, listener) {
842
1144
  try {
843
- watchers.push((0, node_fs.watch)(pathname, { recursive }, (_event, filename) => {
844
- if (filename) listener(filename);
845
- }));
846
- } catch {}
1145
+ if (recursive) (0, node_fs.accessSync)(path, node_fs.constants.R_OK | node_fs.constants.X_OK);
1146
+ const watcher = (0, node_fs.watch)(path, { recursive }, (_event, filename) => {
1147
+ if (!closed) listener(filename ?? "");
1148
+ });
1149
+ watcher.on("error", (e) => {
1150
+ watcher.close();
1151
+ root.watchers = root.watchers.filter((w) => w !== watcher);
1152
+ root.failed = true;
1153
+ if (!closed && !recheck(root, false)) report(root, e);
1154
+ });
1155
+ root.watchers.push(watcher);
1156
+ } catch (e) {
1157
+ root.failed = true;
1158
+ report(root, e);
1159
+ }
1160
+ }
1161
+ function arm(root) {
1162
+ for (const w of root.watchers) w.close();
1163
+ root.watchers = [];
1164
+ root.failed = false;
1165
+ const { anchor, self } = root.state;
1166
+ if (anchor) startWatch(root, anchor.path, false, (filename) => {
1167
+ const before = root.state.self.kind;
1168
+ recheck(root, true);
1169
+ if ((before === "file" || isNamedFile(root.abs)) && filename !== "" && (0, node_path.resolve)(anchor.path, filename) === root.abs) fileChanged(root.abs, true);
1170
+ });
1171
+ if (self.kind === "dir") startWatch(root, root.path, true, (filename) => {
1172
+ if (filename === "") recheck(root, true);
1173
+ else {
1174
+ const abs = (0, node_path.resolve)(root.abs, filename);
1175
+ fileChanged(abs, isNamedFile(abs));
1176
+ }
1177
+ });
1178
+ if (!root.failed) root.lastReport = void 0;
847
1179
  }
848
- const { dirs, files } = splitWatchRoots(pathnames);
849
- const absDirs = dirs.map((dir) => (0, node_path.resolve)(dir));
850
- for (const dir of dirs) watchPath(dir, true, (filename) => {
851
- const abs = (0, node_path.resolve)(dir, filename);
852
- fileChanged(abs, files.has(abs));
853
- });
854
- const fileDirs = new Set([...files].filter((file) => !absDirs.some((dir) => isInsideDir(file, dir))).map((file) => (0, node_path.dirname)(file)));
855
- for (const dir of fileDirs) watchPath(dir, false, (filename) => {
856
- const abs = (0, node_path.resolve)(dir, filename);
857
- if (files.has(abs)) fileChanged(abs, true);
858
- });
1180
+ /**
1181
+ * Sets up `root`'s watches again when its path, or the folder above it, is no longer the
1182
+ * one they were started on (or, with `retry`, when one of them failed), and then schedules a
1183
+ * full build. Returns whether its state changed.
1184
+ */
1185
+ function recheck(root, retry) {
1186
+ const before = root.state;
1187
+ const after = rootState(root.abs);
1188
+ const changed = !sameWatches(before, after);
1189
+ const wasFailed = root.failed;
1190
+ if (!changed && !(retry && wasFailed)) {
1191
+ root.state = after;
1192
+ return false;
1193
+ }
1194
+ root.state = after;
1195
+ arm(root);
1196
+ const recovered = wasFailed && !root.failed;
1197
+ if ((changed || recovered) && (before.self.kind !== "missing" || after.self.kind !== "missing")) scheduleBuild();
1198
+ return changed;
1199
+ }
1200
+ for (const root of roots) arm(root);
859
1201
  callback();
860
1202
  return { close() {
1203
+ closed = true;
861
1204
  if (buildTimer) clearTimeout(buildTimer);
862
- for (const w of watchers) w.close();
1205
+ for (const root of roots) for (const w of root.watchers) w.close();
863
1206
  } };
864
1207
  }
865
1208
  //#endregion
1209
+ //#region src/relaxed-json.ts
1210
+ /**
1211
+ * A parser for the object literal a Twine 2 `format.js` passes to `window.storyFormat()`.
1212
+ *
1213
+ * The story formats spec does not require that object to be strict JSON, and Twine 2 runs
1214
+ * `format.js` as JavaScript. This parser accepts JSON plus the JavaScript literal syntax formats
1215
+ * use in practice: single-quoted strings, unquoted (identifier) and numeric keys, trailing commas,
1216
+ * comments, JavaScript string escapes, and hexadecimal, octal and binary numbers.
1217
+ *
1218
+ * It reads string literals token by token, so their contents come back exactly as JavaScript
1219
+ * evaluation gives them: text that only looks like structure (`a, }`, `word:`, an apostrophe) is
1220
+ * never touched. Anything outside that subset, such as a function or a variable, is a syntax error.
1221
+ */
1222
+ const IDENTIFIER = /[A-Za-z_$][\w$]*/y;
1223
+ const NUMBER = /[+-]?(?:0[xX][0-9a-fA-F]+|0[oO][0-7]+|0[bB][01]+|(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)/y;
1224
+ const IDENTIFIER_PART = /[\w$]/;
1225
+ const WHITESPACE = /[\s]/;
1226
+ const HEX = /^[0-9a-fA-F]+$/;
1227
+ /** Simple single-character escapes and the characters they stand for. */
1228
+ const SIMPLE_ESCAPES = {
1229
+ b: "\b",
1230
+ f: "\f",
1231
+ n: "\n",
1232
+ r: "\r",
1233
+ t: " ",
1234
+ v: "\v"
1235
+ };
1236
+ /**
1237
+ * Parse a JavaScript object, array or primitive literal: all of `text`, or the part from `start` to
1238
+ * `end`. Error positions count from the start of `text`, so they point into the file it came from.
1239
+ *
1240
+ * @throws SyntaxError naming the line and column of the first thing it cannot parse.
1241
+ */
1242
+ function parseRelaxedJSON(text, start = 0, end = text.length) {
1243
+ let pos = start;
1244
+ function fail(message, at = pos) {
1245
+ const before = text.slice(0, at);
1246
+ const line = before.split("\n").length;
1247
+ const column = at - before.lastIndexOf("\n");
1248
+ throw new SyntaxError(`${message} at line ${line}, column ${column}`);
1249
+ }
1250
+ /** The character at `pos`, or '' at the end of the input. */
1251
+ const peek = () => pos < end ? text[pos] ?? "" : "";
1252
+ const describeChar = (at) => at >= end ? "end of input" : JSON.stringify(text[at]);
1253
+ /** Skip whitespace and comments. */
1254
+ const skipTrivia = () => {
1255
+ while (pos < end) {
1256
+ const ch = peek();
1257
+ if (WHITESPACE.test(ch)) pos++;
1258
+ else if (text.startsWith("//", pos)) {
1259
+ const lineEnd = text.slice(pos, end).search(/[\n\r\u2028\u2029]/);
1260
+ pos = lineEnd === -1 ? end : pos + lineEnd;
1261
+ } else if (text.startsWith("/*", pos)) {
1262
+ const commentEnd = text.indexOf("*/", pos + 2);
1263
+ if (commentEnd === -1 || commentEnd + 2 > end) fail("Unterminated comment");
1264
+ pos = commentEnd + 2;
1265
+ } else return;
1266
+ }
1267
+ };
1268
+ const consume = (ch) => {
1269
+ skipTrivia();
1270
+ if (peek() !== ch) fail(`Expected ${JSON.stringify(ch)} but found ${describeChar(pos)}`);
1271
+ pos++;
1272
+ };
1273
+ /** Read exactly `count` hex digits and return their value. */
1274
+ const readHex = (count) => {
1275
+ const digits = text.slice(pos, Math.min(pos + count, end));
1276
+ if (digits.length !== count || !HEX.test(digits)) fail("Invalid hexadecimal escape sequence");
1277
+ pos += count;
1278
+ return parseInt(digits, 16);
1279
+ };
1280
+ /** Decode the escape sequence after a backslash; `pos` is just past the backslash. */
1281
+ const readEscape = () => {
1282
+ const ch = peek();
1283
+ const simple = SIMPLE_ESCAPES[ch];
1284
+ if (simple !== void 0) {
1285
+ pos++;
1286
+ return simple;
1287
+ }
1288
+ switch (ch) {
1289
+ case "": return fail("Unterminated string");
1290
+ case "x":
1291
+ pos++;
1292
+ return String.fromCharCode(readHex(2));
1293
+ case "u": {
1294
+ pos++;
1295
+ if (peek() !== "{") return String.fromCharCode(readHex(4));
1296
+ const close = text.indexOf("}", pos);
1297
+ if (close === -1 || close >= end) fail("Invalid Unicode escape sequence");
1298
+ pos++;
1299
+ const codePoint = readHex(close - pos);
1300
+ if (codePoint > 1114111) fail("Undefined Unicode code-point");
1301
+ pos = close + 1;
1302
+ return String.fromCodePoint(codePoint);
1303
+ }
1304
+ case "\r":
1305
+ pos += text[pos + 1] === "\n" ? 2 : 1;
1306
+ return "";
1307
+ case "\n":
1308
+ case "\u2028":
1309
+ case "\u2029":
1310
+ pos++;
1311
+ return "";
1312
+ case "0":
1313
+ if (/[0-9]/.test(text[pos + 1] ?? "")) fail("Octal escape sequences are not supported");
1314
+ pos++;
1315
+ return "\0";
1316
+ default:
1317
+ if (/[1-9]/.test(ch)) fail("Octal escape sequences are not supported");
1318
+ pos++;
1319
+ return ch;
1320
+ }
1321
+ };
1322
+ const readString = () => {
1323
+ const quote = peek();
1324
+ pos++;
1325
+ let out = "";
1326
+ let chunkStart = pos;
1327
+ for (;;) {
1328
+ if (pos >= end) fail("Unterminated string");
1329
+ const ch = peek();
1330
+ if (ch === quote) {
1331
+ out += text.slice(chunkStart, pos);
1332
+ pos++;
1333
+ return out;
1334
+ }
1335
+ if (ch === "\n" || ch === "\r") fail("Unterminated string");
1336
+ if (ch === "\\") {
1337
+ out += text.slice(chunkStart, pos);
1338
+ pos++;
1339
+ out += readEscape();
1340
+ chunkStart = pos;
1341
+ } else pos++;
1342
+ }
1343
+ };
1344
+ /** Read a match of a sticky regular expression at `pos`, or return undefined. */
1345
+ const readToken = (pattern) => {
1346
+ pattern.lastIndex = pos;
1347
+ const m = pattern.exec(text);
1348
+ if (!m || pattern.lastIndex > end) return void 0;
1349
+ pos = pattern.lastIndex;
1350
+ return m[0];
1351
+ };
1352
+ const readNumber = () => {
1353
+ const tokenStart = pos;
1354
+ const token = readToken(NUMBER);
1355
+ if (token === void 0) return void 0;
1356
+ if (IDENTIFIER_PART.test(peek())) fail("Invalid number", tokenStart);
1357
+ return (token.startsWith("-") ? -1 : 1) * Number(/^[+-]/.test(token) ? token.slice(1) : token);
1358
+ };
1359
+ const readKey = () => {
1360
+ skipTrivia();
1361
+ const ch = peek();
1362
+ if (ch === "\"" || ch === "'") return readString();
1363
+ const identifier = readToken(IDENTIFIER);
1364
+ if (identifier !== void 0) return identifier;
1365
+ const number = readNumber();
1366
+ if (number !== void 0) return String(number);
1367
+ return fail(`Expected a property name but found ${describeChar(pos)}`);
1368
+ };
1369
+ const readObject = () => {
1370
+ pos++;
1371
+ const out = {};
1372
+ for (;;) {
1373
+ skipTrivia();
1374
+ if (peek() === "}") {
1375
+ pos++;
1376
+ return out;
1377
+ }
1378
+ const key = readKey();
1379
+ consume(":");
1380
+ Object.defineProperty(out, key, {
1381
+ value: readValue(),
1382
+ enumerable: true,
1383
+ writable: true,
1384
+ configurable: true
1385
+ });
1386
+ skipTrivia();
1387
+ if (peek() === ",") pos++;
1388
+ else if (peek() !== "}") fail(`Expected "," or "}" but found ${describeChar(pos)}`);
1389
+ }
1390
+ };
1391
+ const readArray = () => {
1392
+ pos++;
1393
+ const out = [];
1394
+ for (;;) {
1395
+ skipTrivia();
1396
+ if (peek() === "]") {
1397
+ pos++;
1398
+ return out;
1399
+ }
1400
+ out.push(readValue());
1401
+ skipTrivia();
1402
+ if (peek() === ",") pos++;
1403
+ else if (peek() !== "]") fail(`Expected "," or "]" but found ${describeChar(pos)}`);
1404
+ }
1405
+ };
1406
+ function readValue() {
1407
+ skipTrivia();
1408
+ const ch = peek();
1409
+ if (ch === "{") return readObject();
1410
+ if (ch === "[") return readArray();
1411
+ if (ch === "\"" || ch === "'") return readString();
1412
+ const tokenStart = pos;
1413
+ const identifier = readToken(IDENTIFIER);
1414
+ if (identifier !== void 0) {
1415
+ if (identifier === "true") return true;
1416
+ if (identifier === "false") return false;
1417
+ if (identifier === "null") return null;
1418
+ return fail(`Unexpected identifier ${JSON.stringify(identifier)}`, tokenStart);
1419
+ }
1420
+ const number = readNumber();
1421
+ if (number !== void 0) return number;
1422
+ return fail(`Unexpected ${describeChar(pos)}`);
1423
+ }
1424
+ const value = readValue();
1425
+ skipTrivia();
1426
+ if (pos < end) fail(`Unexpected ${describeChar(pos)} after the value`);
1427
+ return value;
1428
+ }
1429
+ //#endregion
1430
+ //#region src/semver.ts
1431
+ const IDENTIFIERS = String.raw`[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*`;
1432
+ const VERSION$1 = new RegExp(String.raw`^[vV]?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-(${IDENTIFIERS}))?(?:\+${IDENTIFIERS})?$`);
1433
+ const NUMERIC = /^\d+$/;
1434
+ /** Parse a version string, or return null when it is not a version. */
1435
+ function parseVersion(text) {
1436
+ const m = VERSION$1.exec(text);
1437
+ if (!m) return null;
1438
+ return {
1439
+ major: Number(m[1]),
1440
+ minor: Number(m[2] ?? 0),
1441
+ patch: Number(m[3] ?? 0),
1442
+ prerelease: m[4] === void 0 ? [] : m[4].split(".")
1443
+ };
1444
+ }
1445
+ /** Compare two prerelease identifiers: numeric ones numerically and below alphanumeric ones, which compare as ASCII. */
1446
+ function compareIdentifiers(a, b) {
1447
+ const aNumeric = NUMERIC.test(a);
1448
+ const bNumeric = NUMERIC.test(b);
1449
+ if (aNumeric && bNumeric) return Math.sign(Number(a) - Number(b));
1450
+ if (aNumeric !== bNumeric) return aNumeric ? -1 : 1;
1451
+ return a < b ? -1 : a > b ? 1 : 0;
1452
+ }
1453
+ /** Compare prerelease identifier lists. An empty list (a release) ranks above any prerelease. */
1454
+ function comparePrerelease(a, b) {
1455
+ if (a.length === 0 || b.length === 0) return Math.sign(b.length - a.length);
1456
+ const shared = Math.min(a.length, b.length);
1457
+ for (let i = 0; i < shared; i++) {
1458
+ const order = compareIdentifiers(a[i] ?? "", b[i] ?? "");
1459
+ if (order !== 0) return order;
1460
+ }
1461
+ return Math.sign(a.length - b.length);
1462
+ }
1463
+ /** Compare two versions by SemVer precedence: negative when `a` ranks lower, 0 when equal, positive when higher. */
1464
+ function compareVersions(a, b) {
1465
+ return Math.sign(a.major - b.major) || Math.sign(a.minor - b.minor) || Math.sign(a.patch - b.patch) || comparePrerelease(a.prerelease, b.prerelease);
1466
+ }
1467
+ /** Whether two version strings name the same version (prerelease included, build metadata ignored). */
1468
+ function sameVersion(a, b) {
1469
+ const left = parseVersion(a);
1470
+ const right = parseVersion(b);
1471
+ return left !== null && right !== null && compareVersions(left, right) === 0;
1472
+ }
1473
+ //#endregion
866
1474
  //#region src/source-text.ts
867
1475
  /**
868
1476
  * Source text normalization shared by every input path (files, in-memory sources, `parseTwee`).
@@ -874,43 +1482,138 @@ function watchFilesystem(pathnames, outFilename, callback, ignore = () => false)
874
1482
  function normalizeSourceText(text) {
875
1483
  return (text.charCodeAt(0) === 65279 ? text.slice(1) : text).replace(/\r\n?/g, "\n");
876
1484
  }
1485
+ /** Byte order marks at the start of a line, directly before a passage header's `::`. */
1486
+ const BOMS_BEFORE_HEADER = /(?<=^|\n)\uFEFF+(?=::)/g;
1487
+ /**
1488
+ * Normalize Twee source text: {@link normalizeSourceText}, and also remove byte order marks at the start of
1489
+ * a line that are directly followed by `::`. Concatenating files (`cat a.tw b.tw`) leaves the BOM of every
1490
+ * later file in front of its first passage header, which would otherwise not be recognised as a header.
1491
+ * A U+FEFF anywhere else is passage text and kept. Idempotent.
1492
+ */
1493
+ function normalizeTweeSourceText(text) {
1494
+ return normalizeSourceText(text).replace(BOMS_BEFORE_HEADER, "");
1495
+ }
877
1496
  //#endregion
878
1497
  //#region src/util.ts
879
1498
  /**
880
1499
  * Shared file I/O utilities.
881
1500
  */
882
- /** Read a file as UTF-8 with BOM stripping and line ending normalization. */
883
- function readUTF8(filename) {
884
- return normalizeSourceText((0, node_fs.readFileSync)(filename, "utf-8"));
1501
+ /** The charset assumed for text that is not valid UTF-8, as Tweego assumes it. */
1502
+ const FALLBACK_CHARSET = "windows-1252";
1503
+ /**
1504
+ * Throws on invalid UTF-8 instead of replacing bytes with U+FFFD. `ignoreBOM` keeps a leading BOM in the
1505
+ * text, as `readFileSync(file, 'utf-8')` did, so {@link normalizeSourceText} strips exactly one.
1506
+ */
1507
+ const utf8Decoder = new TextDecoder("utf-8", {
1508
+ fatal: true,
1509
+ ignoreBOM: true
1510
+ });
1511
+ /**
1512
+ * The code points of the Windows-1252 bytes 0x80 to 0x9F, from the WHATWG Encoding Standard. The five bytes
1513
+ * Windows-1252 leaves undefined map to the C1 control with the same number, as the standard and Go's charmap
1514
+ * do. Every other byte is the code point with the same number.
1515
+ *
1516
+ * Decoded by hand, because Node's `TextDecoder('windows-1252')` decodes as ISO-8859-1 in some releases
1517
+ * (Node 22.18 turns 0x80 into U+0080 instead of €), and builds without ICU lack it.
1518
+ */
1519
+ const WINDOWS_1252_C1 = [
1520
+ 8364,
1521
+ 129,
1522
+ 8218,
1523
+ 402,
1524
+ 8222,
1525
+ 8230,
1526
+ 8224,
1527
+ 8225,
1528
+ 710,
1529
+ 8240,
1530
+ 352,
1531
+ 8249,
1532
+ 338,
1533
+ 141,
1534
+ 381,
1535
+ 143,
1536
+ 144,
1537
+ 8216,
1538
+ 8217,
1539
+ 8220,
1540
+ 8221,
1541
+ 8226,
1542
+ 8211,
1543
+ 8212,
1544
+ 732,
1545
+ 8482,
1546
+ 353,
1547
+ 8250,
1548
+ 339,
1549
+ 157,
1550
+ 382,
1551
+ 376
1552
+ ];
1553
+ function decodeWindows1252(bytes) {
1554
+ const chunks = [];
1555
+ for (let start = 0; start < bytes.length; start += 8192) {
1556
+ const codes = Array.from(bytes.subarray(start, start + 8192), (b) => b >= 128 && b <= 159 ? WINDOWS_1252_C1[b - 128] ?? b : b);
1557
+ chunks.push(String.fromCharCode(...codes));
1558
+ }
1559
+ return chunks.join("");
1560
+ }
1561
+ /**
1562
+ * Decode bytes read from `filename` as UTF-8. Bytes that are not valid UTF-8 are decoded as Windows-1252
1563
+ * instead, with a warning naming the file, as Tweego does (`fileReadAllWithEncoding` in io.go), so a legacy
1564
+ * file keeps its non-ASCII characters instead of losing them to U+FFFD.
1565
+ *
1566
+ * The text is returned as decoded: a leading BOM and CR line endings are kept (see {@link normalizeSourceText}).
1567
+ */
1568
+ function decodeText(bytes, filename) {
1569
+ try {
1570
+ return {
1571
+ text: utf8Decoder.decode(bytes),
1572
+ diagnostics: []
1573
+ };
1574
+ } catch {
1575
+ return {
1576
+ text: decodeWindows1252(bytes),
1577
+ diagnostics: [{
1578
+ level: "warning",
1579
+ message: `read ${filename}: Invalid UTF-8; assuming charset is ${FALLBACK_CHARSET}.`,
1580
+ file: filename
1581
+ }]
1582
+ };
1583
+ }
1584
+ }
1585
+ /**
1586
+ * Read a text file: decode it with {@link decodeText} (UTF-8, or Windows-1252 when it is not valid UTF-8),
1587
+ * strip a leading BOM and convert CRLF and bare CR line endings to LF. This is the one path every file read
1588
+ * as text goes through: sources, modules, the head file, story formats and the config file.
1589
+ *
1590
+ * @param diagnostics Receives the warning when the file is not valid UTF-8. Without it, the file is decoded
1591
+ * the same way and the warning is dropped.
1592
+ * @throws When the file cannot be read.
1593
+ */
1594
+ function readUTF8(filename, diagnostics) {
1595
+ const decoded = decodeText((0, node_fs.readFileSync)(filename), filename);
1596
+ diagnostics?.push(...decoded.diagnostics);
1597
+ return normalizeSourceText(decoded.text);
885
1598
  }
886
1599
  /** Read a file as base64. */
887
1600
  function readBase64(filename) {
888
1601
  return (0, node_fs.readFileSync)(filename).toString("base64");
889
1602
  }
890
- /** Get the filename without extension, falling back to the full basename for dotfiles. */
1603
+ /** Get the filename without extension, a dotfile keeps its whole name. */
891
1604
  function baseNameWithoutExt(filename) {
892
- const { name, ext } = (0, node_path.parse)(filename);
893
- return name || ext;
1605
+ return (0, node_path.parse)(filename).name;
894
1606
  }
895
1607
  //#endregion
896
1608
  //#region src/formats.ts
897
1609
  /**
898
1610
  * Story format discovery, loading, and SemVer matching.
899
1611
  * Ported from formats.go + config.go.
1612
+ *
1613
+ * Every lookup (local folders here, the download cache and remote indices in remote-formats.ts)
1614
+ * goes through the same version parsing ({@link parseVersion}) and the same matching rules
1615
+ * ({@link selectFormatCandidate}).
900
1616
  */
901
- /**
902
- * Attempt to fix non-strict JSON (trailing commas, single quotes, unquoted keys).
903
- */
904
- function relaxedJSONParse(json) {
905
- try {
906
- return JSON.parse(json);
907
- } catch {}
908
- let fixed = json;
909
- fixed = fixed.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, "\"$1\"");
910
- fixed = fixed.replace(/(?<=^|[{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:/gm, "\"$1\":");
911
- fixed = fixed.replace(/,\s*([}\]])/g, "$1");
912
- return JSON.parse(fixed);
913
- }
914
1617
  /** A `"setup": function` property, which Harlowe appends to its otherwise-JSON format object. */
915
1618
  const SETUP_FUNCTION_PROPERTY = /,\s*"setup"\s*:\s*function\b/g;
916
1619
  /** Drop a trailing `"setup": function(){…}` property from a format object, if it has one. */
@@ -920,151 +1623,249 @@ function stripSetupFunction(chunk) {
920
1623
  return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
921
1624
  }
922
1625
  /**
923
- * Parse the Twine 2 format.js JSON chunk.
924
- * Handles Harlowe's malformed JSON by stripping the "setup" function property. The workaround
925
- * keys on the property itself, so the same bytes parse whether they come from a `harlowe-3`
926
- * directory or a direct download.
927
- * Per spec, the name key is Optional.
1626
+ * Parse the format object at `start`–`end` of `text`: strict JSON first (fast), then the
1627
+ * JavaScript literal subset formats use. Error positions point into `text`.
1628
+ */
1629
+ function parseFormatObject(text, start, end) {
1630
+ try {
1631
+ return {
1632
+ ok: true,
1633
+ value: JSON.parse(text.slice(start, end))
1634
+ };
1635
+ } catch {}
1636
+ try {
1637
+ return {
1638
+ ok: true,
1639
+ value: parseRelaxedJSON(text, start, end)
1640
+ };
1641
+ } catch (e) {
1642
+ return {
1643
+ ok: false,
1644
+ error: e instanceof Error ? e.message : String(e)
1645
+ };
1646
+ }
1647
+ }
1648
+ /** Check a parsed format object's fields. Per spec, `name` is optional; `version` and `source` are required. */
1649
+ /** The name given to a format.js that names no format. */
1650
+ const UNNAMED_FORMAT_NAME = "Untitled Story Format";
1651
+ function toFormatJSON(raw) {
1652
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
1653
+ ok: false,
1654
+ reason: "Story format JSON chunk is not an object."
1655
+ };
1656
+ const obj = raw;
1657
+ if (typeof obj.version !== "string") return {
1658
+ ok: false,
1659
+ reason: "Story format has no \"version\" string."
1660
+ };
1661
+ if (typeof obj.source !== "string") return {
1662
+ ok: false,
1663
+ reason: "Story format has no \"source\" string."
1664
+ };
1665
+ if (!parseVersion(obj.version)) return {
1666
+ ok: false,
1667
+ reason: `Story format version ${JSON.stringify(obj.version)} is not a SemVer version.`
1668
+ };
1669
+ const data = {
1670
+ name: typeof obj.name === "string" ? obj.name : UNNAMED_FORMAT_NAME,
1671
+ version: obj.version,
1672
+ source: obj.source,
1673
+ proofing: obj.proofing === true
1674
+ };
1675
+ if (typeof obj.author === "string") data.author = obj.author;
1676
+ if (typeof obj.description === "string") data.description = obj.description;
1677
+ if (typeof obj.image === "string") data.image = obj.image;
1678
+ if (typeof obj.url === "string") data.url = obj.url;
1679
+ if (typeof obj.license === "string") data.license = obj.license;
1680
+ return {
1681
+ ok: true,
1682
+ data
1683
+ };
1684
+ }
1685
+ /**
1686
+ * Read the metadata of a Twine 2 format.js, or say why it cannot be used.
928
1687
  *
929
- * @param _formatId No longer used; kept so existing callers keep compiling.
1688
+ * The object passed to `window.storyFormat()` may be strict JSON or a JavaScript object literal
1689
+ * (single quotes, unquoted keys, trailing commas, comments). String values come back exactly as
1690
+ * JavaScript evaluation gives them. Harlowe's function-valued `setup` property is dropped; the
1691
+ * workaround keys on the property itself, so the same bytes parse whether they come from a
1692
+ * `harlowe-3` directory or a direct download.
930
1693
  */
931
- function parseFormatJSON(source, _formatId) {
1694
+ function decodeFormatJSON(source) {
932
1695
  const first = source.indexOf("{");
933
1696
  const last = source.lastIndexOf("}");
934
- if (first === -1 || last === -1) return null;
935
- const chunk = source.slice(first, last + 1);
936
- const parse = (json) => {
937
- const raw = relaxedJSONParse(json);
938
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
939
- const obj = raw;
940
- if (typeof obj.version !== "string" || typeof obj.source !== "string") return null;
941
- if (!parseSemver(obj.version)) return null;
942
- const result = {
943
- name: typeof obj.name === "string" ? obj.name : "Untitled Story Format",
944
- version: obj.version,
945
- source: obj.source,
946
- proofing: obj.proofing === true
947
- };
948
- if (typeof obj.author === "string") result.author = obj.author;
949
- if (typeof obj.description === "string") result.description = obj.description;
950
- if (typeof obj.image === "string") result.image = obj.image;
951
- if (typeof obj.url === "string") result.url = obj.url;
952
- if (typeof obj.license === "string") result.license = obj.license;
953
- return result;
1697
+ if (first === -1 || last < first) return {
1698
+ ok: false,
1699
+ reason: "Could not find Twine 2 style story format JSON chunk."
954
1700
  };
1701
+ const parsed = parseFormatObject(source, first, last + 1);
1702
+ if (parsed.ok) return toFormatJSON(parsed.value);
1703
+ const stripped = stripSetupFunction(source.slice(first, last + 1));
1704
+ const retried = stripped === void 0 ? void 0 : parseFormatObject(stripped, 0, stripped.length);
1705
+ if (retried?.ok) return toFormatJSON(retried.value);
1706
+ return {
1707
+ ok: false,
1708
+ reason: `Could not decode story format JSON chunk: ${parsed.error}`
1709
+ };
1710
+ }
1711
+ /**
1712
+ * Parse the Twine 2 format.js JSON chunk; null when it cannot be used ({@link decodeFormatJSON}
1713
+ * gives the reason).
1714
+ *
1715
+ * @param _formatId No longer used; kept so existing callers keep compiling.
1716
+ */
1717
+ function parseFormatJSON(source, _formatId) {
1718
+ const result = decodeFormatJSON(source);
1719
+ return result.ok ? result.data : null;
1720
+ }
1721
+ /** A directory's entries in a stable order, or none when it cannot be read. */
1722
+ function listDirectory(dir) {
955
1723
  try {
956
- return parse(chunk);
1724
+ return (0, node_fs.readdirSync)(dir).sort();
957
1725
  } catch {
958
- const stripped = stripSetupFunction(chunk);
959
- if (stripped === void 0) return null;
960
- try {
961
- return parse(stripped);
962
- } catch {
963
- return null;
964
- }
1726
+ return [];
1727
+ }
1728
+ }
1729
+ function isDirectory(path) {
1730
+ try {
1731
+ return (0, node_fs.statSync)(path).isDirectory();
1732
+ } catch {
1733
+ return false;
1734
+ }
1735
+ }
1736
+ function isFile(path) {
1737
+ try {
1738
+ return (0, node_fs.statSync)(path).isFile();
1739
+ } catch {
1740
+ return false;
965
1741
  }
966
1742
  }
967
1743
  /**
968
- * Discover all story formats in the given search directories.
969
- * Implements SemVer-based version pruning: within each (name, major) group,
970
- * only the highest minor.patch survives.
1744
+ * Load the format in one folder: its format.js (Twine 2), else its header.html (Twine 1).
1745
+ * A format.js that cannot be used is reported as a warning, as Tweego does.
971
1746
  */
972
- function discoverFormats(searchDirs) {
973
- const formats = /* @__PURE__ */ new Map();
974
- const baseFilenames = ["format.js", "header.html"];
975
- for (const searchDir of searchDirs) {
1747
+ function loadFormatDir(searchDir, id, diagnostics) {
1748
+ const formatDir = (0, node_path.join)(searchDir, id);
1749
+ if (!isDirectory(formatDir)) return void 0;
1750
+ for (const baseFilename of ["format.js", "header.html"]) {
1751
+ const filename = (0, node_path.join)(formatDir, baseFilename);
1752
+ if (!isFile(filename)) continue;
1753
+ if (baseFilename === "header.html") return {
1754
+ id,
1755
+ filename,
1756
+ isTwine2: false,
1757
+ name: id,
1758
+ version: "",
1759
+ proofing: false
1760
+ };
1761
+ const encoding = [];
1762
+ let decoded;
976
1763
  try {
977
- if (!(0, node_fs.statSync)(searchDir).isDirectory()) continue;
978
- } catch {
979
- continue;
1764
+ decoded = decodeFormatJSON(readUTF8(filename, encoding));
1765
+ } catch (e) {
1766
+ decoded = {
1767
+ ok: false,
1768
+ reason: `Could not read ${filename}: ${e instanceof Error ? e.message : String(e)}`
1769
+ };
980
1770
  }
981
- let entries;
982
- try {
983
- entries = (0, node_fs.readdirSync)(searchDir);
984
- } catch {
1771
+ if (!decoded.ok) {
1772
+ diagnostics?.push(...encoding);
1773
+ diagnostics?.push({
1774
+ level: "warning",
1775
+ message: `format ${id}: Skipping format; ${decoded.reason} (${filename})`
1776
+ });
985
1777
  continue;
986
1778
  }
987
- for (const entry of entries) {
988
- const formatDir = (0, node_path.join)(searchDir, entry);
989
- try {
990
- if (!(0, node_fs.statSync)(formatDir).isDirectory()) continue;
991
- } catch {
992
- continue;
993
- }
994
- for (const baseFilename of baseFilenames) {
995
- const formatFilename = (0, node_path.join)(formatDir, baseFilename);
996
- try {
997
- if (!(0, node_fs.statSync)(formatFilename).isFile()) continue;
998
- } catch {
999
- continue;
1000
- }
1001
- const isTwine2 = baseFilename === "format.js";
1002
- const format = {
1003
- id: entry,
1004
- filename: formatFilename,
1005
- isTwine2,
1006
- name: "",
1007
- version: "",
1008
- proofing: false
1009
- };
1010
- if (isTwine2) try {
1011
- const data = parseFormatJSON(readUTF8(formatFilename), entry);
1012
- if (!data) continue;
1013
- format.name = data.name;
1014
- format.version = data.version;
1015
- format.proofing = data.proofing ?? false;
1016
- if (data.author) format.author = data.author;
1017
- if (data.description) format.description = data.description;
1018
- if (data.image) format.image = data.image;
1019
- if (data.url) format.url = data.url;
1020
- if (data.license) format.license = data.license;
1021
- } catch {
1022
- continue;
1023
- }
1024
- else format.name = entry;
1025
- formats.set(entry, format);
1026
- break;
1027
- }
1028
- }
1779
+ const { data } = decoded;
1780
+ const format = {
1781
+ id,
1782
+ filename,
1783
+ isTwine2: true,
1784
+ name: data.name,
1785
+ version: data.version,
1786
+ proofing: data.proofing ?? false
1787
+ };
1788
+ if (data.author) format.author = data.author;
1789
+ if (data.description) format.description = data.description;
1790
+ if (data.image) format.image = data.image;
1791
+ if (data.url) format.url = data.url;
1792
+ if (data.license) format.license = data.license;
1793
+ return format;
1794
+ }
1795
+ }
1796
+ /**
1797
+ * Discover every story format in the search directories, keyed by folder name (the format ID),
1798
+ * without version pruning.
1799
+ *
1800
+ * Directories are searched in order, and a later directory outranks an earlier one: when two hold
1801
+ * the same folder name, the later one's format is kept. The map iterates in that order too, lowest
1802
+ * rank first. Formats that cannot be used are left out, with a warning in `diagnostics`.
1803
+ */
1804
+ function discoverAllFormats(searchDirs, diagnostics) {
1805
+ const formats = /* @__PURE__ */ new Map();
1806
+ for (const searchDir of searchDirs) for (const entry of listDirectory(searchDir)) {
1807
+ const format = loadFormatDir(searchDir, entry, diagnostics);
1808
+ if (!format) continue;
1809
+ formats.delete(entry);
1810
+ formats.set(entry, format);
1029
1811
  }
1030
- pruneFormats(formats);
1031
1812
  return formats;
1032
1813
  }
1814
+ /** The version a format is pruned and selected by, or null for one outside SemVer pruning. */
1815
+ function prunableVersion(format) {
1816
+ return format.isTwine2 && format.name ? parseVersion(format.version) : null;
1817
+ }
1818
+ /** The search directory a discovered format was found in. */
1819
+ function searchDirOf(format) {
1820
+ return (0, node_path.dirname)((0, node_path.dirname)(format.filename));
1821
+ }
1033
1822
  /**
1034
- * Prune formats by SemVer: within each (name, major) group,
1035
- * keep only the highest version. First-found wins for same name+version.
1823
+ * Prune formats by SemVer: within each (name, major) group, keep only the version with the highest
1824
+ * precedence (a release outranks its prereleases). Between equal versions, the folder in the
1825
+ * higher-ranked search directory wins, and within one directory the first folder name in sort
1826
+ * order. Returns a new map in the same order.
1827
+ *
1828
+ * Pruning serves name-based selection and listings. An explicit format ID is looked up in the
1829
+ * unpruned map, so a pinned folder is used even when another folder holds a newer version.
1036
1830
  */
1037
1831
  function pruneFormats(formats) {
1038
- const bestByNameMajor = /* @__PURE__ */ new Map();
1039
- const seenNameVersion = /* @__PURE__ */ new Set();
1040
- for (const [id, f] of formats) {
1041
- if (!f.isTwine2 || !f.name || !f.version) continue;
1042
- const v = parseSemver(f.version);
1043
- if (!v) continue;
1044
- const nameVersionKey = `${f.name}@${f.version}`;
1045
- if (seenNameVersion.has(nameVersionKey)) {
1046
- formats.delete(id);
1047
- continue;
1048
- }
1049
- seenNameVersion.add(nameVersionKey);
1050
- const groupKey = `${f.name}@${v[0]}`;
1051
- const existing = bestByNameMajor.get(groupKey);
1052
- if (!existing || semverCompare(v, existing.version) > 0) bestByNameMajor.set(groupKey, {
1053
- id,
1054
- version: v
1832
+ const dirRank = /* @__PURE__ */ new Map();
1833
+ for (const format of formats.values()) {
1834
+ const dir = searchDirOf(format);
1835
+ if (!dirRank.has(dir)) dirRank.set(dir, dirRank.size);
1836
+ }
1837
+ const rankOf = (format) => dirRank.get(searchDirOf(format)) ?? 0;
1838
+ const best = /* @__PURE__ */ new Map();
1839
+ for (const format of formats.values()) {
1840
+ const version = prunableVersion(format);
1841
+ if (!version) continue;
1842
+ const group = `${format.name}@${version.major}`;
1843
+ const current = best.get(group);
1844
+ const order = current ? compareVersions(version, current.version) : 1;
1845
+ if (order > 0 || order === 0 && current && rankOf(format) > rankOf(current.format)) best.set(group, {
1846
+ format,
1847
+ version
1055
1848
  });
1056
1849
  }
1057
- for (const [id, f] of [...formats]) {
1058
- if (!f.isTwine2 || !f.name || !f.version) continue;
1059
- const v = parseSemver(f.version);
1060
- if (!v) continue;
1061
- const groupKey = `${f.name}@${v[0]}`;
1062
- const best = bestByNameMajor.get(groupKey);
1063
- if (best && best.id !== id) formats.delete(id);
1064
- }
1850
+ return new Map([...formats].filter(([id, format]) => {
1851
+ const version = prunableVersion(format);
1852
+ return version === null || best.get(`${format.name}@${version.major}`)?.format.id === id;
1853
+ }));
1854
+ }
1855
+ /**
1856
+ * Discover all story formats in the given search directories, pruned by SemVer: within each
1857
+ * (name, major) group only the highest version survives. See {@link discoverAllFormats} for the
1858
+ * directory ranking and {@link pruneFormats} for pruning.
1859
+ */
1860
+ function discoverFormats(searchDirs) {
1861
+ return pruneFormats(discoverAllFormats(searchDirs));
1065
1862
  }
1066
1863
  /**
1067
- * Get the default format search directories.
1864
+ * Get the format search directories, lowest rank first: the story format subdirectories of the
1865
+ * home directory, then of the working directory, then each `TWEEGO_PATH` entry, then `extraPaths`
1866
+ * (`formatPaths`). A later directory outranks an earlier one for the same format folder name, so
1867
+ * `formatPaths` override `TWEEGO_PATH`, which overrides the home and working directories, as in
1868
+ * Tweego.
1068
1869
  */
1069
1870
  function getFormatSearchDirs(extraPaths = [], useTweegoPath = true) {
1070
1871
  const subdirNames = [
@@ -1079,30 +1880,25 @@ function getFormatSearchDirs(extraPaths = [], useTweegoPath = true) {
1079
1880
  basePaths.add((0, node_os.homedir)());
1080
1881
  } catch {}
1081
1882
  basePaths.add(process.cwd());
1082
- const dirs = [];
1083
- for (const base of basePaths) for (const sub of subdirNames) {
1084
- const dir = (0, node_path.join)(base, sub);
1085
- try {
1086
- if ((0, node_fs.statSync)(dir).isDirectory()) dirs.push(dir);
1087
- } catch {}
1088
- }
1883
+ const dirs = [...basePaths].flatMap((base) => subdirNames.map((sub) => (0, node_path.join)(base, sub))).filter(isDirectory);
1884
+ const tweegoPath = useTweegoPath ? process.env.TWEEGO_PATH : void 0;
1885
+ if (tweegoPath) dirs.push(...tweegoPath.split(process.platform === "win32" ? ";" : ":"));
1089
1886
  dirs.push(...extraPaths);
1090
- if (useTweegoPath) {
1091
- const tweegoPath = process.env.TWEEGO_PATH;
1092
- if (tweegoPath) dirs.push(...tweegoPath.split(process.platform === "win32" ? ";" : ":"));
1093
- }
1094
1887
  return dirs;
1095
1888
  }
1096
- /** Parse a simple semver string into [major, minor, patch]. */
1889
+ /**
1890
+ * Parse a version string into [major, minor, patch]. Accepts what {@link parseVersion} accepts
1891
+ * (`v1.2.3`, `1.2`, `2.0.0-beta.1`); the prerelease and build metadata are dropped.
1892
+ */
1097
1893
  function parseSemver(v) {
1098
- const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
1099
- if (!m) return null;
1100
- return [
1101
- Number(m[1]),
1102
- Number(m[2]),
1103
- Number(m[3])
1104
- ];
1105
- }
1894
+ const parsed = parseVersion(v);
1895
+ return parsed ? [
1896
+ parsed.major,
1897
+ parsed.minor,
1898
+ parsed.patch
1899
+ ] : null;
1900
+ }
1901
+ /** Compare [major, minor, patch] tuples. Use {@link compareVersions} to take prereleases into account. */
1106
1902
  function semverCompare(a, b) {
1107
1903
  const [aMajor, aMinor, aPatch] = a;
1108
1904
  const [bMajor, bMinor, bPatch] = b;
@@ -1113,7 +1909,7 @@ function semverCompare(a, b) {
1113
1909
  }
1114
1910
  /** Build a directory-style format ID from a name and version, e.g. ('SugarCube', '2.37.3') → 'sugarcube-2'. */
1115
1911
  function makeFormatId(name, version) {
1116
- const major = parseSemver(version)?.[0] ?? 0;
1912
+ const major = parseVersion(version)?.major ?? 0;
1117
1913
  return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
1118
1914
  }
1119
1915
  /** Describe a format request for diagnostics. */
@@ -1124,81 +1920,164 @@ function describeFormatRequest(request) {
1124
1920
  default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
1125
1921
  }
1126
1922
  }
1127
- /**
1128
- * Pick the candidate that best answers a format request.
1129
- *
1130
- * - An ID request ('sugarcube-2') matches a candidate whose name and major version build that ID,
1131
- * and takes the greatest such version.
1132
- * - A name request matches the name case-insensitively. An exact version wins outright; otherwise
1133
- * the greatest version with the same major that is not older than the one asked for (or, with
1134
- * `allowOlder`, the greatest same-major version). An unparseable requested version takes the
1135
- * greatest version of any major.
1136
- */
1137
- function selectFormatCandidate(request, candidates, describe, options = {}) {
1138
- const allowOlder = options.allowOlder ?? false;
1139
- const wanted = request.kind === "name" ? parseSemver(request.version) : null;
1923
+ /** {@link selectFormatCandidate} over one set of candidates, matching names without regard to case. */
1924
+ function pickFormatCandidate(request, candidates, describe, allowOlder) {
1925
+ const wanted = request.kind === "name" ? parseVersion(request.version) : null;
1140
1926
  let best;
1141
1927
  for (const candidate of candidates) {
1142
1928
  const { name, version } = describe(candidate);
1143
- const have = parseSemver(version);
1929
+ const have = parseVersion(version);
1144
1930
  if (!have) continue;
1145
1931
  if (request.kind === "id") {
1146
1932
  if (makeFormatId(name, version) !== request.id.toLowerCase()) continue;
1147
1933
  } else {
1148
1934
  if (name.toLowerCase() !== request.name.toLowerCase()) continue;
1149
1935
  if (wanted) {
1150
- if (semverCompare(have, wanted) === 0) return candidate;
1151
- if (have[0] !== wanted[0]) continue;
1152
- if (!allowOlder && semverCompare(have, wanted) < 0) continue;
1936
+ const order = compareVersions(have, wanted);
1937
+ if (order === 0) return candidate;
1938
+ if (have.major !== wanted.major) continue;
1939
+ if (!allowOlder && order < 0) continue;
1153
1940
  }
1154
1941
  }
1155
- if (!best || semverCompare(have, best.version) > 0) best = {
1942
+ if (!best || compareVersions(have, best.version) > 0) best = {
1156
1943
  candidate,
1157
1944
  version: have
1158
1945
  };
1159
1946
  }
1160
1947
  return best?.candidate;
1161
1948
  }
1162
- /** Get format ID from Twine 2 name and version, using SemVer major matching. */
1163
- function getFormatIdByNameAndVersion(formats, name, version) {
1164
- const wanted = parseSemver(version);
1165
- let bestVersion = null;
1166
- let bestId;
1167
- for (const [id, f] of formats) {
1168
- if (!f.isTwine2 || f.name !== name) continue;
1169
- const have = parseSemver(f.version);
1170
- if (!have) continue;
1171
- if (wanted === null || have[0] === wanted[0] && semverCompare(have, wanted) >= 0) {
1172
- if (!bestVersion || semverCompare(have, bestVersion) > 0) {
1173
- bestVersion = have;
1174
- bestId = id;
1175
- }
1176
- }
1949
+ /**
1950
+ * Pick the candidate that best answers a format request. Local folders, the download cache and
1951
+ * remote indices all select through this one function.
1952
+ *
1953
+ * - An ID request ('sugarcube-2') matches, without regard to case, a candidate whose name and
1954
+ * major version build that ID, and takes the greatest such version.
1955
+ * - A name request matches the name without regard to case, but a candidate whose name matches in
1956
+ * case too is preferred when one answers the request. An exact version (prerelease included)
1957
+ * wins outright; otherwise the greatest version with the same major that is not older than the
1958
+ * one asked for (or, with `allowOlder`, the greatest same-major version). An unparseable
1959
+ * requested version takes the greatest version of any major.
1960
+ *
1961
+ * Versions compare by SemVer precedence ({@link compareVersions}), so a release outranks its
1962
+ * prereleases. Between equal versions, the earlier candidate wins.
1963
+ */
1964
+ function selectFormatCandidate(request, candidates, describe, options = {}) {
1965
+ const allowOlder = options.allowOlder ?? false;
1966
+ if (request.kind === "name") {
1967
+ const preferred = pickFormatCandidate(request, candidates.filter((c) => describe(c).name === request.name), describe, allowOlder);
1968
+ if (preferred !== void 0) return preferred;
1969
+ }
1970
+ return pickFormatCandidate(request, candidates, describe, allowOlder);
1971
+ }
1972
+ /** The Twine 2 formats of a discovered map, those from higher-ranked search directories first. */
1973
+ function rankedTwine2Formats(formats) {
1974
+ return [...formats.values()].filter((f) => f.isTwine2).reverse();
1975
+ }
1976
+ /**
1977
+ * Look a format ID up among discovered folders. The ID matches a folder name without regard to
1978
+ * case; an exact-case folder is preferred, then the highest-ranked one.
1979
+ */
1980
+ function findFormatById(formats, id) {
1981
+ const exact = formats.get(id);
1982
+ if (exact) return exact;
1983
+ const wanted = id.toLowerCase();
1984
+ return [...formats.values()].reverse().find((f) => f.id.toLowerCase() === wanted);
1985
+ }
1986
+ /**
1987
+ * Read the story format source (for Twine 2, extract the `source` property from JSON).
1988
+ * `diagnostics` receives a warning when the format file is not valid UTF-8 (it is read as Windows-1252).
1989
+ */
1990
+ function readFormatSource(format, diagnostics) {
1991
+ const source = readUTF8(format.filename, diagnostics);
1992
+ if (!format.isTwine2) return source;
1993
+ const decoded = decodeFormatJSON(source);
1994
+ if (!decoded.ok) throw new Error(`Cannot parse format ${format.id} JSON: ${decoded.reason}`);
1995
+ return decoded.data.source;
1996
+ }
1997
+ //#endregion
1998
+ //#region src/atomic-write.ts
1999
+ /**
2000
+ * Atomic file replacement, for every file twee-ts writes that something else may read
2001
+ * meanwhile: build output (which live-reload servers read on each change) and downloaded
2002
+ * story formats (which other twee-ts processes share).
2003
+ */
2004
+ /**
2005
+ * Writes `data` to `path` so that a reader sees either the previous file or the complete new
2006
+ * one, never a partly written file. The data goes to a temporary file in the same folder,
2007
+ * which is then renamed over `path`. When `path` is a symlink, the file it points to is
2008
+ * replaced, and an existing file keeps its permissions.
2009
+ *
2010
+ * On failure (a full disk, a missing folder) the temporary file is removed, `path` is left
2011
+ * as it was, and the error is thrown with `path` in its message and its original `code`.
2012
+ */
2013
+ function writeFileAtomic(path, data) {
2014
+ const target = followSymlink(path);
2015
+ const temp = (0, node_path.join)((0, node_path.dirname)(target), `.${(0, node_path.basename)(target)}.${process.pid}-${(0, node_crypto.randomBytes)(6).toString("hex")}.tmp`);
2016
+ try {
2017
+ (0, node_fs.writeFileSync)(temp, data, {
2018
+ encoding: "utf-8",
2019
+ flag: "wx"
2020
+ });
2021
+ keepPermissions(target, temp);
2022
+ (0, node_fs.renameSync)(temp, target);
2023
+ } catch (e) {
2024
+ (0, node_fs.rmSync)(temp, { force: true });
2025
+ throw writeError(path, e);
1177
2026
  }
1178
- return bestId;
1179
2027
  }
1180
- /** Read the story format source (for Twine 2, extract the `source` property from JSON). */
1181
- function readFormatSource(format) {
1182
- const source = readUTF8(format.filename);
1183
- if (format.isTwine2) {
1184
- const data = parseFormatJSON(source, format.id);
1185
- if (!data) throw new Error(`Cannot parse format ${format.id} JSON`);
1186
- return data.source;
2028
+ /** The file a symlink at `path` points to, or `path` itself (also when it doesn't exist yet). */
2029
+ function followSymlink(path) {
2030
+ try {
2031
+ return (0, node_fs.realpathSync)(path);
2032
+ } catch {
2033
+ return path;
1187
2034
  }
1188
- return source;
2035
+ }
2036
+ /**
2037
+ * Gives `temp` the permission bits of `target`, when `target` exists. Best effort: on a file
2038
+ * system without Unix permissions chmod can fail, and writing in place never kept them there either.
2039
+ */
2040
+ function keepPermissions(target, temp) {
2041
+ const existing = (0, node_fs.statSync)(target, { throwIfNoEntry: false });
2042
+ if (existing === void 0) return;
2043
+ try {
2044
+ (0, node_fs.chmodSync)(temp, existing.mode & 4095);
2045
+ } catch {}
2046
+ }
2047
+ /** `cause` as an error that names `path` and keeps the original error code. */
2048
+ function writeError(path, cause) {
2049
+ const message = cause instanceof Error ? cause.message : String(cause);
2050
+ const error = new Error(`Cannot write ${path}: ${message}`, { cause });
2051
+ if (cause instanceof Error && "code" in cause) error.code = cause.code;
2052
+ return error;
1189
2053
  }
1190
2054
  //#endregion
1191
2055
  //#region src/remote-formats.ts
1192
2056
  /**
1193
2057
  * Remote story format fetching, caching, and checksum verification.
1194
2058
  * Uses the Story Formats Archive (SFA) as the default source.
2059
+ *
2060
+ * Two caches hold downloads. Formats found through an index are shared by name and version
2061
+ * (`<cache>/<name>/<version>/format.js`). A format downloaded from a direct URL is kept under
2062
+ * that URL instead, so one project's copy never answers another project's request.
2063
+ * Cache files are replaced atomically, so another process never reads a partly written one.
1195
2064
  */
1196
2065
  const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
2066
+ /** The longest delay a timer accepts; a longer timeout means no limit. */
2067
+ const MAX_TIMER_DELAY = 2147483647;
1197
2068
  /** Get the cache directory for downloaded story formats. */
1198
2069
  function getCacheDir() {
1199
2070
  const base = process.env["XDG_CACHE_HOME"] || (0, node_path.join)((0, node_os.homedir)(), ".cache");
1200
2071
  return (0, node_path.join)(base, "twee-ts", "storyformats");
1201
2072
  }
2073
+ /** The cache directory for formats downloaded from direct URLs, beside {@link getCacheDir}. */
2074
+ function getUrlCacheDir() {
2075
+ return (0, node_path.join)((0, node_path.dirname)(getCacheDir()), "storyformat-urls");
2076
+ }
2077
+ /** The name of one direct URL's cache entry: a hash of the URL, so each URL has its own. */
2078
+ function urlCacheKey(url) {
2079
+ return (0, node_crypto.createHash)("sha256").update(url).digest("hex");
2080
+ }
1202
2081
  /**
1203
2082
  * Whether a string can serve as one directory name inside the cache: not empty, not `.` or `..`,
1204
2083
  * and free of path separators. Format names and versions come from downloaded metadata, so they
@@ -1221,32 +2100,135 @@ function cachedFormatDir(name, version) {
1221
2100
  if (!isInside(root, dir)) throw new Error(`Refusing to cache a story format outside the cache directory ${root}: ${dir}`);
1222
2101
  return dir;
1223
2102
  }
1224
- /** Create `dir` as a plain directory, or check that it already is one (a symlink could lead out of the cache). */
2103
+ /** Whether `e` is a Node.js system error with the given code. */
2104
+ function hasErrorCode(e, code) {
2105
+ return e instanceof Error && "code" in e && e.code === code;
2106
+ }
2107
+ /**
2108
+ * Create `dir` as a plain directory, or check that it already is one (a symlink could lead out of the cache).
2109
+ * A directory another process creates between the check and the mkdir is fine.
2110
+ */
1225
2111
  function ensurePlainDirectory(dir) {
1226
- const stat = (0, node_fs.lstatSync)(dir, { throwIfNoEntry: false });
1227
- if (stat === void 0) {
2112
+ if ((0, node_fs.lstatSync)(dir, { throwIfNoEntry: false }) === void 0) try {
1228
2113
  (0, node_fs.mkdirSync)(dir);
1229
2114
  return;
2115
+ } catch (e) {
2116
+ if (!hasErrorCode(e, "EEXIST")) throw e;
1230
2117
  }
1231
- if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`Refusing to write a story format outside the cache directory: ${dir} is not a plain directory`);
2118
+ const stat = (0, node_fs.lstatSync)(dir, { throwIfNoEntry: false });
2119
+ 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`);
1232
2120
  }
1233
- /** Write a downloaded format.js to `<cache>/<name>/<version>/format.js` and return its path. */
1234
- function writeCachedFormat(name, version, text) {
1235
- const dir = cachedFormatDir(name, version);
1236
- const root = (0, node_path.resolve)(getCacheDir());
2121
+ /**
2122
+ * Write a downloaded format.js to `<root>/<...segments>/format.js`, replacing any earlier copy
2123
+ * atomically, and return its path. Every directory below `root` must be a plain one.
2124
+ */
2125
+ function writeCacheEntry(root, segments, text) {
1237
2126
  (0, node_fs.mkdirSync)(root, { recursive: true });
1238
- ensurePlainDirectory((0, node_path.join)(root, name));
1239
- ensurePlainDirectory(dir);
2127
+ const dir = segments.reduce((parent, segment) => {
2128
+ const child = (0, node_path.join)(parent, segment);
2129
+ ensurePlainDirectory(child);
2130
+ return child;
2131
+ }, root);
1240
2132
  const formatPath = (0, node_path.join)(dir, "format.js");
1241
2133
  if ((0, node_fs.lstatSync)(formatPath, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error(`Refusing to write a story format outside the cache directory: ${formatPath} is a symlink`);
1242
- (0, node_fs.writeFileSync)(formatPath, text, "utf-8");
2134
+ writeFileAtomic(formatPath, text);
1243
2135
  return formatPath;
1244
2136
  }
1245
- /** In-memory index cache, keyed by URL. Cleared each compile. */
1246
- const indexCache = /* @__PURE__ */ new Map();
1247
- /** Clear the in-memory index cache. */
1248
- function clearIndexCache() {
1249
- indexCache.clear();
2137
+ /** In-memory index cache, keyed by URL. Cleared each compile. */
2138
+ const indexCache = /* @__PURE__ */ new Map();
2139
+ /** Clear the in-memory index cache. */
2140
+ function clearIndexCache() {
2141
+ indexCache.clear();
2142
+ }
2143
+ /** Requests in progress, by what they fetch, so concurrent compiles make each request once. */
2144
+ const sharedRequests = /* @__PURE__ */ new Map();
2145
+ /**
2146
+ * Runs `start` once for all callers that ask for `key` while it is in progress. Each caller waits
2147
+ * under its own limits: a caller whose `signal` aborts, or whose `timeout` passes, stops waiting
2148
+ * at once and rejects, while the others keep waiting. The request itself is aborted (through
2149
+ * the signal `start` receives) once no caller waits on it. `start` must not depend on any one
2150
+ * caller: what differs between callers belongs after the shared result.
2151
+ */
2152
+ function shareRequest(key, { signal, timeout, timedOut }, start) {
2153
+ if (signal?.aborted) return Promise.reject(signal.reason);
2154
+ const request = sharedRequests.get(key) ?? startSharedRequest(key, start);
2155
+ request.waiters++;
2156
+ return new Promise((resolve, reject) => {
2157
+ let waiting = true;
2158
+ let timer;
2159
+ const stopWaiting = () => {
2160
+ if (!waiting) return;
2161
+ waiting = false;
2162
+ signal?.removeEventListener("abort", onAbort);
2163
+ clearTimeout(timer);
2164
+ request.waiters--;
2165
+ };
2166
+ const giveUp = (reason) => {
2167
+ stopWaiting();
2168
+ if (request.waiters === 0) {
2169
+ if (sharedRequests.get(key) === request) sharedRequests.delete(key);
2170
+ request.controller.abort(reason);
2171
+ }
2172
+ reject(reason);
2173
+ };
2174
+ const onAbort = () => giveUp(signal?.reason);
2175
+ signal?.addEventListener("abort", onAbort, { once: true });
2176
+ if (timeout > 0 && timeout <= MAX_TIMER_DELAY) timer = setTimeout(() => giveUp(timedOut()), timeout);
2177
+ request.promise.then((value) => {
2178
+ stopWaiting();
2179
+ resolve(value);
2180
+ }, (e) => {
2181
+ stopWaiting();
2182
+ reject(e);
2183
+ });
2184
+ });
2185
+ }
2186
+ function startSharedRequest(key, start) {
2187
+ const controller = new AbortController();
2188
+ const request = {
2189
+ promise: start(controller.signal),
2190
+ controller,
2191
+ waiters: 0
2192
+ };
2193
+ sharedRequests.set(key, request);
2194
+ const forget = () => {
2195
+ if (sharedRequests.get(key) === request) sharedRequests.delete(key);
2196
+ };
2197
+ request.promise.then(forget, forget);
2198
+ return request;
2199
+ }
2200
+ /** The time limit for each request, from the caller's options. */
2201
+ function requestTimeout(options) {
2202
+ const timeout = options.timeout ?? 3e4;
2203
+ if (!(timeout >= 0)) throw new RangeError(`A story format request timeout must be 0 or more milliseconds, not ${timeout}`);
2204
+ return timeout;
2205
+ }
2206
+ /** How one caller waits for `url`: under its own signal and timeout. `what` names the request in errors. */
2207
+ function waitOptions(options, what, url) {
2208
+ const timeout = requestTimeout(options);
2209
+ return {
2210
+ signal: options.signal,
2211
+ timeout,
2212
+ timedOut: () => /* @__PURE__ */ new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`)
2213
+ };
2214
+ }
2215
+ /**
2216
+ * Fetch `url` as bytes, aborting when `signal` does. `what` names the request in errors, as in
2217
+ * "Failed to <what> from <url>". The bytes are returned undecoded, so checksums cover what was served.
2218
+ */
2219
+ async function fetchBytes(url, what, signal) {
2220
+ const res = await fetch(url, { signal });
2221
+ if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
2222
+ return new Uint8Array(await res.arrayBuffer());
2223
+ }
2224
+ /** Download a format.js once for every caller in this process that asks for `url` meanwhile. */
2225
+ function shareDownload(url, options) {
2226
+ return shareRequest(`download\0${url}`, waitOptions(options, "download format", url), (signal) => fetchBytes(url, "download format", signal));
2227
+ }
2228
+ /** Decode a downloaded format.js as local ones are (UTF-8, else Windows-1252), without a leading BOM. */
2229
+ function decodeDownload(bytes, url) {
2230
+ const { text } = decodeText(bytes, url);
2231
+ return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
1250
2232
  }
1251
2233
  /** Validate that a JSON value is a valid SFAIndexEntry. */
1252
2234
  function isValidEntry(val) {
@@ -1263,15 +2245,17 @@ function validateSFAIndex(json) {
1263
2245
  twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
1264
2246
  };
1265
2247
  }
1266
- /** Fetch and parse an SFA index.json, with in-memory caching. */
1267
- async function fetchIndex(url) {
2248
+ /** Fetch and parse an SFA index.json, with in-memory caching. Concurrent calls for one URL share a request. */
2249
+ async function fetchIndex(url, options = {}) {
2250
+ options.signal?.throwIfAborted();
1268
2251
  const cached = indexCache.get(url);
1269
2252
  if (cached) return cached;
1270
- const res = await fetch(url);
1271
- if (!res.ok) throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
1272
- const data = validateSFAIndex(await res.json());
1273
- indexCache.set(url, data);
1274
- return data;
2253
+ return shareRequest(`index\0${url}`, waitOptions(options, "fetch format index", url), async (signal) => {
2254
+ const text = decodeDownload(await fetchBytes(url, "fetch format index", signal), url);
2255
+ const data = validateSFAIndex(JSON.parse(text));
2256
+ indexCache.set(url, data);
2257
+ return data;
2258
+ });
1275
2259
  }
1276
2260
  /** Find the best matching entry in an SFA index for a name or ID request (twine2 entries first). */
1277
2261
  function findEntryForRequest(index, request) {
@@ -1292,99 +2276,163 @@ async function verifySHA256(content, expectedHex) {
1292
2276
  function getDownloadUrl(indexUrl, entry, formatType) {
1293
2277
  return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
1294
2278
  }
1295
- /** Download a format, verify its checksum, write to cache, and return StoryFormatInfo. */
1296
- async function fetchAndCacheFormat(entry, downloadUrl) {
2279
+ /** The StoryFormatInfo of a Twine 2 format.js cached at `filename`. */
2280
+ function cachedFormatInfo(id, filename, data) {
2281
+ return {
2282
+ id,
2283
+ filename,
2284
+ isTwine2: true,
2285
+ name: data.name,
2286
+ version: data.version,
2287
+ proofing: data.proofing ?? false
2288
+ };
2289
+ }
2290
+ /**
2291
+ * Check that a downloaded format is the one the index entry promised. The name matches without
2292
+ * regard to case, as format requests do, and the version by SemVer precedence, so "1.0" and "v1.0.0"
2293
+ * agree. A format.js that names no format ({@link UNNAMED_FORMAT_NAME}) is allowed: such formats
2294
+ * exist, and the index entry then supplies the name it is cached under.
2295
+ */
2296
+ function checkFormatIdentity(entry, data, downloadUrl) {
2297
+ if ((data.name === "Untitled Story Format" || data.name.toLowerCase() === entry.name.toLowerCase()) && sameVersion(data.version, entry.version)) return;
2298
+ throw new Error(`Story format mismatch for ${downloadUrl}: the index lists ${entry.name} ${entry.version}, but the download is ${data.name} ${data.version}`);
2299
+ }
2300
+ /**
2301
+ * Download a format, verify its checksum, check that it is the format the entry names, write it to
2302
+ * the cache shared by name and version, and return its StoryFormatInfo. Concurrent calls for one
2303
+ * download URL share one request for the bytes; each call then verifies them against its own
2304
+ * entry, within its own timeout.
2305
+ */
2306
+ async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
1297
2307
  cachedFormatDir(entry.name, entry.version);
1298
- const res = await fetch(downloadUrl);
1299
- if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
1300
- const text = await res.text();
2308
+ const bytes = await shareDownload(downloadUrl, options);
1301
2309
  const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
1302
2310
  if (checksumKey) {
1303
2311
  const expected = entry.checksums[checksumKey];
1304
2312
  if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
1305
- if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
2313
+ if (!await verifySHA256(bytes, expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
1306
2314
  }
2315
+ const text = decodeDownload(bytes, downloadUrl);
1307
2316
  const id = makeFormatId(entry.name, entry.version);
1308
2317
  const data = parseFormatJSON(text, id);
1309
2318
  if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
1310
- return {
1311
- id,
1312
- filename: writeCachedFormat(entry.name, entry.version, text),
1313
- isTwine2: true,
1314
- name: data.name,
1315
- version: data.version,
1316
- proofing: data.proofing ?? false
1317
- };
2319
+ checkFormatIdentity(entry, data, downloadUrl);
2320
+ options.signal?.throwIfAborted();
2321
+ return cachedFormatInfo(id, writeCacheEntry((0, node_path.resolve)(getCacheDir()), [entry.name, entry.version], text), data);
1318
2322
  }
1319
- /** Download a direct format.js URL, parse its JSON, cache it, and return StoryFormatInfo. */
1320
- async function fetchDirectFormat(url) {
1321
- const res = await fetch(url);
1322
- if (!res.ok) throw new Error(`Failed to download format from ${url}: ${res.status} ${res.statusText}`);
1323
- const text = await res.text();
2323
+ /**
2324
+ * Download a direct format.js URL, parse its JSON, cache it under that URL (apart from the
2325
+ * downloads shared by name and version), and return its StoryFormatInfo. Concurrent calls for
2326
+ * one URL share one request.
2327
+ */
2328
+ async function fetchDirectFormat(url, options = {}) {
2329
+ const text = decodeDownload(await shareDownload(url, options), url);
1324
2330
  const data = parseFormatJSON(text);
1325
2331
  if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
1326
- return {
1327
- id: makeFormatId(data.name, data.version),
1328
- filename: writeCachedFormat(data.name, data.version, text),
1329
- isTwine2: true,
1330
- name: data.name,
1331
- version: data.version,
1332
- proofing: data.proofing ?? false
1333
- };
2332
+ cachedFormatDir(data.name, data.version);
2333
+ options.signal?.throwIfAborted();
2334
+ const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
2335
+ return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
2336
+ }
2337
+ /** The copy of a direct format URL in the download cache, if it has been downloaded before. */
2338
+ function getCachedDirectFormat(url) {
2339
+ const formatPath = (0, node_path.join)(getUrlCacheDir(), urlCacheKey(url), "format.js");
2340
+ try {
2341
+ if (!(0, node_fs.existsSync)(formatPath)) return void 0;
2342
+ const data = parseFormatJSON(readUTF8(formatPath));
2343
+ if (!data) return void 0;
2344
+ return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
2345
+ } catch {
2346
+ return;
2347
+ }
2348
+ }
2349
+ /**
2350
+ * Look a request up among direct format URLs, in order, and return the first format that
2351
+ * answers it. A URL downloaded before is answered by its cached copy without the network;
2352
+ * any other is downloaded (unless `offline`). When none answers and a download failed, the
2353
+ * last failure is thrown. An aborted signal rejects with its reason and tries no further URL.
2354
+ *
2355
+ * Internal, for format resolution; not part of the public API.
2356
+ */
2357
+ async function resolveFormatUrls(request, urls, options = {}) {
2358
+ let lastError;
2359
+ for (const url of urls) {
2360
+ const cached = getCachedDirectFormat(url);
2361
+ if (cached) {
2362
+ if (answers(request, cached)) return cached;
2363
+ continue;
2364
+ }
2365
+ if (options.offline) continue;
2366
+ try {
2367
+ const info = await fetchDirectFormat(url, options);
2368
+ if (answers(request, info)) return info;
2369
+ } catch (e) {
2370
+ if (options.signal?.aborted) throw options.signal.reason;
2371
+ lastError = toError$1(e);
2372
+ }
2373
+ }
2374
+ if (lastError) throw lastError;
2375
+ }
2376
+ /** Whether `info` is a format the request accepts. */
2377
+ function answers(request, info) {
2378
+ return selectFormatCandidate(request, [info], (f) => f) !== void 0;
2379
+ }
2380
+ function toError$1(e) {
2381
+ return e instanceof Error ? e : new Error(String(e));
1334
2382
  }
1335
2383
  /**
1336
2384
  * Try to resolve a remote story format by name and version.
1337
- * 0. Use an exactly matching cached download, without touching the network
1338
- * 1. Try direct format URLs
1339
- * 2. Try custom index URLs
1340
- * 3. Try default SFA indices
1341
- * 4. Fall back to a compatible cached download (e.g. when offline)
2385
+ * 1. Try direct format URLs (each one's cached copy, else a download)
2386
+ * 2. Use an exactly matching download from the shared cache, without touching the network
2387
+ * 3. Try custom index URLs, then the default SFA indices
2388
+ * 4. Fall back to a compatible download from the shared cache (e.g. when offline)
2389
+ *
2390
+ * `options.signal` aborts the lookup, which then rejects with the signal's reason;
2391
+ * `options.timeout` limits each request (default {@link DEFAULT_FORMAT_FETCH_TIMEOUT} ms).
1342
2392
  */
1343
- async function resolveRemoteFormat(name, version, indices, urls) {
2393
+ async function resolveRemoteFormat(name, version, indices, urls, options = {}) {
1344
2394
  return resolveRemoteFormatRequest({
1345
2395
  kind: "name",
1346
2396
  name,
1347
2397
  version
1348
- }, indices, urls);
2398
+ }, indices, urls, options);
1349
2399
  }
1350
2400
  /**
1351
2401
  * Resolve a story format request remotely, in the same order as {@link resolveRemoteFormat}.
1352
2402
  * An ID request such as 'sugarcube-2' matches the format whose name and major version build that ID
1353
2403
  * (SugarCube 2.x), taking the greatest version available.
1354
2404
  */
1355
- async function resolveRemoteFormatRequest(request, indices, urls) {
2405
+ async function resolveRemoteFormatRequest(request, indices, urls, options = {}) {
2406
+ options.signal?.throwIfAborted();
1356
2407
  let lastError;
1357
- const cached = findCachedFormat(request);
1358
- if (cached && request.kind === "name" && isExactVersion(cached.version, request.version)) return cached;
1359
- for (const url of urls ?? []) try {
1360
- const info = await fetchDirectFormat(url);
1361
- if (selectFormatCandidate(request, [info], (f) => f)) return info;
2408
+ try {
2409
+ const direct = await resolveFormatUrls(request, urls ?? [], options);
2410
+ if (direct) return direct;
1362
2411
  } catch (e) {
1363
- lastError = e instanceof Error ? e : new Error(String(e));
2412
+ if (options.signal?.aborted) throw options.signal.reason;
2413
+ lastError = toError$1(e);
1364
2414
  }
2415
+ const cached = findCachedFormat(request);
2416
+ if (cached && request.kind === "name" && sameVersion(cached.version, request.version)) return cached;
1365
2417
  const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
1366
2418
  for (const indexUrl of allIndices) try {
1367
- const result = findEntryForRequest(await fetchIndex(indexUrl), request);
2419
+ const result = findEntryForRequest(await fetchIndex(indexUrl, options), request);
1368
2420
  if (result) {
1369
2421
  const hit = getCachedFormat(result.entry.name, result.entry.version);
1370
2422
  if (hit) return hit;
1371
2423
  const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
1372
- return await fetchAndCacheFormat(result.entry, downloadUrl);
2424
+ return await fetchAndCacheFormat(result.entry, downloadUrl, options);
1373
2425
  }
1374
2426
  } catch (e) {
1375
- lastError = e instanceof Error ? e : new Error(String(e));
2427
+ if (options.signal?.aborted) throw options.signal.reason;
2428
+ lastError = toError$1(e);
1376
2429
  }
1377
2430
  if (cached) return cached;
1378
2431
  if (lastError) throw lastError;
1379
2432
  }
1380
- /** Whether two version strings name the same SemVer version. */
1381
- function isExactVersion(have, wanted) {
1382
- const a = parseSemver(have);
1383
- const b = parseSemver(wanted);
1384
- return a !== null && b !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
1385
- }
1386
2433
  /**
1387
2434
  * Find the cached download that best answers a format request, without network access.
2435
+ * Only the downloads shared by name and version are searched, not those from direct URLs.
1388
2436
  * Matching follows {@link selectFormatCandidate}.
1389
2437
  */
1390
2438
  function findCachedFormat(request, options = {}) {
@@ -1396,18 +2444,11 @@ function getCachedFormat(name, version) {
1396
2444
  const formatPath = (0, node_path.join)(getCacheDir(), name, version, "format.js");
1397
2445
  try {
1398
2446
  if (!(0, node_fs.existsSync)(formatPath)) return void 0;
1399
- const source = (0, node_fs.readFileSync)(formatPath, "utf-8");
2447
+ const source = readUTF8(formatPath);
1400
2448
  const id = makeFormatId(name, version);
1401
2449
  const data = parseFormatJSON(source, id);
1402
2450
  if (!data) return void 0;
1403
- return {
1404
- id,
1405
- filename: formatPath,
1406
- isTwine2: true,
1407
- name: data.name,
1408
- version: data.version,
1409
- proofing: data.proofing ?? false
1410
- };
2451
+ return cachedFormatInfo(id, formatPath, data);
1411
2452
  } catch {
1412
2453
  return;
1413
2454
  }
@@ -1492,21 +2533,27 @@ function listCachedFormats() {
1492
2533
  }
1493
2534
  /**
1494
2535
  * Clear all cached formats, or only those matching a given name. Returns the number of entries removed.
2536
+ * Clearing all also removes the downloads from direct format URLs; a name clears only the
2537
+ * downloads shared by name and version.
1495
2538
  * A name must be a single cache entry name (as `listCachedFormats()` reports it); anything with a
1496
2539
  * path separator or `.`/`..` throws instead of deleting outside the format's own directory.
1497
2540
  */
1498
2541
  function clearCachedFormats(name) {
1499
2542
  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.`);
1500
2543
  const cacheDir = getCacheDir();
1501
- if (!(0, node_fs.existsSync)(cacheDir)) return 0;
1502
2544
  if (!name) {
1503
- const count = listCachedFormats().length;
2545
+ const count = listCachedFormats().length + countUrlCacheEntries();
1504
2546
  (0, node_fs.rmSync)(cacheDir, {
1505
2547
  recursive: true,
1506
2548
  force: true
1507
2549
  });
2550
+ (0, node_fs.rmSync)(getUrlCacheDir(), {
2551
+ recursive: true,
2552
+ force: true
2553
+ });
1508
2554
  return count;
1509
2555
  }
2556
+ if (!(0, node_fs.existsSync)(cacheDir)) return 0;
1510
2557
  const root = (0, node_path.resolve)(cacheDir);
1511
2558
  const nameDir = (0, node_path.resolve)(root, name);
1512
2559
  if (!isInside(root, nameDir)) throw new Error(`Refusing to clear ${JSON.stringify(name)}: it is not a cached format name.`);
@@ -1524,6 +2571,15 @@ function clearCachedFormats(name) {
1524
2571
  });
1525
2572
  return count;
1526
2573
  }
2574
+ /** The number of direct format URLs with a download in the cache. */
2575
+ function countUrlCacheEntries() {
2576
+ const dir = getUrlCacheDir();
2577
+ try {
2578
+ return (0, node_fs.readdirSync)(dir).filter((key) => (0, node_fs.existsSync)((0, node_path.join)(dir, key, "format.js"))).length;
2579
+ } catch {
2580
+ return 0;
2581
+ }
2582
+ }
1527
2583
  /** Get total cache size in bytes and format count. */
1528
2584
  function getCacheSize() {
1529
2585
  const entries = listCachedFormats();
@@ -1550,44 +2606,65 @@ function formatRequestFor(formatId, storyFormat, storyFormatVersion, defaultId)
1550
2606
  id: defaultId
1551
2607
  };
1552
2608
  }
1553
- /** Look a request up among the formats found in local format directories. */
1554
- function findLocalFormat(formats, request) {
2609
+ /**
2610
+ * Look a request up among the formats found in local format directories. An ID names a folder, so
2611
+ * it is looked up among all of them; a name request selects by SemVer among the pruned formats.
2612
+ */
2613
+ function findLocalFormat(all, pruned, request) {
1555
2614
  switch (request.kind) {
1556
- case "id": return formats.get(request.id);
1557
- case "name": {
1558
- const id = getFormatIdByNameAndVersion(formats, request.name, request.version);
1559
- return id === void 0 ? void 0 : formats.get(id);
1560
- }
2615
+ case "id": return findFormatById(all, request.id);
2616
+ case "name": return selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f);
1561
2617
  default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
1562
2618
  }
1563
2619
  }
1564
2620
  /**
1565
2621
  * Resolve a format request to a story format.
1566
2622
  *
1567
- * Order: local format directories, then the download cache, then remote sources (unless
1568
- * `noRemote`). When a StoryData request names a version that none of them has, an older version of
2623
+ * Order: local format directories, then the project's direct format URLs (each one's cached
2624
+ * copy, else a download unless `noRemote`), then the download cache shared by name and version,
2625
+ * then format indices (unless `noRemote`). Names and IDs match the same way in each (see
2626
+ * `selectFormatCandidate`), so a request that a local format answers never reaches the cache or the
2627
+ * network. Local formats that cannot be used are reported as warnings. When a StoryData request
2628
+ * names a version that none of them has, an older version of
1569
2629
  * the same format and major version is used with a warning. Anything else is an error diagnostic,
1570
2630
  * and the result is `undefined`.
1571
2631
  */
1572
2632
  async function resolveStoryFormat(request, options, diagnostics) {
1573
2633
  const noRemote = options.noRemote ?? false;
1574
- const formats = discoverFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true));
2634
+ const all = discoverAllFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true), diagnostics);
2635
+ const pruned = pruneFormats(all);
1575
2636
  const wanted = describeFormatRequest(request);
1576
- const local = findLocalFormat(formats, request);
2637
+ const local = findLocalFormat(all, pruned, request);
1577
2638
  if (local) return local;
2639
+ const fetchOptions = {
2640
+ signal: options.signal,
2641
+ timeout: options.formatFetchTimeout
2642
+ };
2643
+ const warnOnFailure = async (lookup) => {
2644
+ try {
2645
+ return await lookup();
2646
+ } catch (e) {
2647
+ if (options.signal?.aborted) throw options.signal.reason;
2648
+ diagnostics.push({
2649
+ level: "warning",
2650
+ message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
2651
+ });
2652
+ return;
2653
+ }
2654
+ };
2655
+ const direct = await warnOnFailure(() => resolveFormatUrls(request, options.formatUrls ?? [], {
2656
+ ...fetchOptions,
2657
+ offline: noRemote
2658
+ }));
2659
+ if (direct) return direct;
1578
2660
  const cached = findCachedFormat(request);
1579
2661
  if (cached) return cached;
1580
- if (!noRemote) try {
1581
- const remote = await resolveRemoteFormatRequest(request, options.formatIndices, options.formatUrls);
2662
+ if (!noRemote) {
2663
+ const remote = await warnOnFailure(() => resolveRemoteFormatRequest(request, options.formatIndices, [], fetchOptions));
1582
2664
  if (remote) return remote;
1583
- } catch (e) {
1584
- diagnostics.push({
1585
- level: "warning",
1586
- message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
1587
- });
1588
2665
  }
1589
2666
  if (request.kind === "name") {
1590
- const older = selectFormatCandidate(request, [...formats.values()].filter((f) => f.isTwine2), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
2667
+ const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
1591
2668
  if (older) {
1592
2669
  diagnostics.push({
1593
2670
  level: "warning",
@@ -1599,7 +2676,7 @@ async function resolveStoryFormat(request, options, diagnostics) {
1599
2676
  const reason = noRemote ? " (remote fetching disabled)" : "";
1600
2677
  diagnostics.push({
1601
2678
  level: "error",
1602
- message: `Story format ${wanted} is not available${reason}. Found: ${[...formats.keys()].join(", ") || "none"}`
2679
+ message: `Story format ${wanted} is not available${reason}. Found: ${[...all.keys()].join(", ") || "none"}`
1603
2680
  });
1604
2681
  }
1605
2682
  //#endregion
@@ -1836,8 +2913,6 @@ function* tweeLexer(input) {
1836
2913
  const items = ctx.drainItems();
1837
2914
  for (const item of items) yield item;
1838
2915
  }
1839
- const remaining = ctx.drainItems();
1840
- for (const item of remaining) yield item;
1841
2916
  }
1842
2917
  /** Convenience class wrapper for the lexer generator. */
1843
2918
  var TweeLexer = class {
@@ -1894,12 +2969,14 @@ function twee2ToV3(s) {
1894
2969
  * Parse Twee source text into passages.
1895
2970
  *
1896
2971
  * The source is normalized first, as files are when they are read: a leading UTF-8 BOM is
1897
- * removed and CRLF and bare CR line endings become LF.
2972
+ * removed and CRLF and bare CR line endings become LF. A BOM at the start of a later line,
2973
+ * directly before `::` (left there by concatenating files), is removed too, so that line
2974
+ * stays a passage header.
1898
2975
  */
1899
2976
  function parseTwee(source, options = {}) {
1900
2977
  const { filename = "<inline>", trim = true, twee2Compat = false } = options;
1901
2978
  const diagnostics = [];
1902
- const normalized = normalizeSourceText(source);
2979
+ const normalized = normalizeTweeSourceText(source);
1903
2980
  const tweeSource = twee2Compat ? twee2ToV3(normalized) : normalized;
1904
2981
  const passages = [];
1905
2982
  let current = null;
@@ -1988,9 +3065,7 @@ function parseTwee(source, options = {}) {
1988
3065
  };
1989
3066
  }
1990
3067
  try {
1991
- const raw = JSON.parse(item.val);
1992
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error("expected a JSON object");
1993
- const parsed = raw;
3068
+ const parsed = JSON.parse(item.val);
1994
3069
  const meta = {};
1995
3070
  for (const [key, value] of Object.entries(parsed)) if (typeof value === "string") meta[key] = value;
1996
3071
  current.metadata = meta;
@@ -4736,15 +5811,28 @@ function parseDocument(data, options) {
4736
5811
  /**
4737
5812
  * Parse a Twine 2 or Twine 1 compiled HTML file back into a Story model.
4738
5813
  * Tweego always trims passage text; here `trim: false` keeps it exactly, as for Twee sources.
5814
+ * A missing or invalid `tw-storydata` IFID is reported as a warning; an invalid one is kept as
5815
+ * written (uppercased), a missing one stays empty.
4739
5816
  */
4740
5817
  function decompileHTML(html, options = {}) {
5818
+ return decompile(html, options, { ifid: true });
5819
+ }
5820
+ /**
5821
+ * `decompileHTML()` for loading an HTML file into a compile. The importer's StoryData passage is
5822
+ * checked when it is added to the story being compiled, which reports a missing or invalid IFID,
5823
+ * so the IFID is not reported here as well.
5824
+ */
5825
+ function decompileHTMLForImport(html, options = {}) {
5826
+ return decompile(html, options, { ifid: false });
5827
+ }
5828
+ function decompile(html, options, checks) {
4741
5829
  const diagnostics = [];
4742
5830
  const story = createStory();
4743
5831
  const passageText = options.trim === false ? (text) => text : (text) => text.trim();
4744
5832
  const doc = parseDocument(html);
4745
5833
  const twine2Data = findElement(doc, "tw-storydata");
4746
5834
  if (twine2Data) {
4747
- decompileTwine2(twine2Data, story, passageText, diagnostics);
5835
+ decompileTwine2(twine2Data, story, passageText, checks, diagnostics);
4748
5836
  return {
4749
5837
  story,
4750
5838
  diagnostics
@@ -4767,7 +5855,7 @@ function decompileHTML(html, options = {}) {
4767
5855
  diagnostics
4768
5856
  };
4769
5857
  }
4770
- function decompileTwine2(storyData, story, passageText, diagnostics) {
5858
+ function decompileTwine2(storyData, story, passageText, checks, diagnostics) {
4771
5859
  let startnode = 0;
4772
5860
  const attrs = storyData.attribs ?? {};
4773
5861
  if (attrs["name"]) story.name = attrs["name"];
@@ -4780,6 +5868,7 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
4780
5868
  else startnode = parsed;
4781
5869
  }
4782
5870
  if (attrs["ifid"]) story.ifid = normalizeIFID(attrs["ifid"]);
5871
+ if (checks.ifid) diagnostics.push(...storyDataIFIDDiagnostics(attrs["ifid"] ?? ""));
4783
5872
  if (attrs["zoom"]) {
4784
5873
  const parsed = parseFloat(attrs["zoom"]);
4785
5874
  if (Number.isNaN(parsed)) diagnostics.push({
@@ -4802,13 +5891,17 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
4802
5891
  const content = getTextContent(node);
4803
5892
  if (content.trim().length === 0) continue;
4804
5893
  const text = passageText(content);
4805
- const name = freeName(node.name === "style" ? "Story Stylesheet" : "Story JavaScript", takenNames);
5894
+ const base = node.name === "style" ? "Story Stylesheet" : "Story JavaScript";
5895
+ const name = freeName(base, (n) => !takenNames.has(n));
4806
5896
  takenNames.add(name);
4807
- storyAdd(story, {
5897
+ storyAdd(story, withGeneratedName({
4808
5898
  name,
4809
5899
  tags: node.name === "style" ? ["stylesheet"] : ["script"],
4810
5900
  text
4811
- }, diagnostics);
5901
+ }, {
5902
+ kind: "code",
5903
+ base
5904
+ }), diagnostics);
4812
5905
  break;
4813
5906
  }
4814
5907
  case "tw-tag": {
@@ -4855,19 +5948,26 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
4855
5948
  function passageDataName(node) {
4856
5949
  return node.attribs?.["name"] ?? "";
4857
5950
  }
4858
- /** The first of `base`, `base 2`, `base 3`, … that is not in `taken`. */
4859
- function freeName(base, taken) {
4860
- if (!taken.has(base)) return base;
4861
- for (let n = 2;; n++) {
4862
- const candidate = `${base} ${n}`;
4863
- if (!taken.has(candidate)) return candidate;
4864
- }
5951
+ /** Diagnostics for the `ifid` attribute of `tw-storydata`; `value` is empty when it is missing. */
5952
+ function storyDataIFIDDiagnostics(value) {
5953
+ if (value === "") return [{
5954
+ level: "warning",
5955
+ message: "Story IFID not found; the \"tw-storydata\" content attribute \"ifid\" is missing or empty."
5956
+ }];
5957
+ const err = validateIFID(value);
5958
+ if (err === null) return [];
5959
+ return [{
5960
+ level: "warning",
5961
+ message: `Cannot parse "tw-storydata" content attribute "ifid" as an IFID; value "${value}" (${err}).`
5962
+ }];
4865
5963
  }
4866
5964
  function decompileTwine1(storeArea, story, passageText, diagnostics) {
4867
5965
  const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
4868
5966
  const obfuscated = isRot13Obfuscated(passages);
4869
- for (const p of passages) storyAdd(story, obfuscated && p.name !== "StorySettings" ? {
5967
+ for (const p of passages) storyAdd(story, obfuscated && isObfuscatable(p) ? {
4870
5968
  ...p,
5969
+ name: rot13(p.name),
5970
+ tags: p.tags.map(rot13),
4871
5971
  text: rot13(p.text)
4872
5972
  } : p, diagnostics);
4873
5973
  }
@@ -5012,7 +6112,12 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
5012
6112
  function loadInlineSources(story, sources, opts, diagnostics) {
5013
6113
  for (const source of sources) {
5014
6114
  if (typeof source === "string") continue;
5015
- const content = normalizeSourceText(typeof source.content === "string" ? source.content : source.content.toString("utf-8"));
6115
+ const decoded = typeof source.content === "string" ? {
6116
+ text: source.content,
6117
+ diagnostics: []
6118
+ } : decodeText(source.content, source.filename);
6119
+ diagnostics.push(...decoded.diagnostics);
6120
+ const content = normalizeSourceText(decoded.text);
5016
6121
  const ext = normalizedFileExt(source.filename);
5017
6122
  switch (ext) {
5018
6123
  case "":
@@ -5031,18 +6136,10 @@ function loadInlineSources(story, sources, opts, diagnostics) {
5031
6136
  break;
5032
6137
  }
5033
6138
  case "css":
5034
- storyAdd(story, {
5035
- name: (0, node_path.basename)(source.filename),
5036
- tags: ["stylesheet"],
5037
- text: content
5038
- }, diagnostics);
6139
+ storyAdd(story, codePassage((0, node_path.basename)(source.filename), "stylesheet", content), diagnostics);
5039
6140
  break;
5040
6141
  case "js":
5041
- storyAdd(story, {
5042
- name: (0, node_path.basename)(source.filename),
5043
- tags: ["script"],
5044
- text: content
5045
- }, diagnostics);
6142
+ storyAdd(story, codePassage((0, node_path.basename)(source.filename), "script", content), diagnostics);
5046
6143
  break;
5047
6144
  default: diagnostics.push({
5048
6145
  level: "warning",
@@ -5052,35 +6149,60 @@ function loadInlineSources(story, sources, opts, diagnostics) {
5052
6149
  }
5053
6150
  }
5054
6151
  function parseTweeFile(filename, opts) {
5055
- const result = parseTwee(readUTF8(filename), {
6152
+ const readDiagnostics = [];
6153
+ const result = parseTwee(readUTF8(filename, readDiagnostics), {
5056
6154
  filename,
5057
6155
  trim: opts.trim ?? true,
5058
6156
  twee2Compat: opts.twee2Compat ?? false
5059
6157
  });
5060
6158
  return {
5061
6159
  passages: result.passages,
5062
- diagnostics: [...result.diagnostics]
6160
+ diagnostics: [...readDiagnostics, ...result.diagnostics]
5063
6161
  };
5064
6162
  }
6163
+ /**
6164
+ * A stylesheet or script passage named after its file, as Tweego names it. The name only labels
6165
+ * the code, so when another passage has it, the passage takes a free one (see `storyAdd()`).
6166
+ */
6167
+ function codePassage(name, tag, text) {
6168
+ return withGeneratedName({
6169
+ name,
6170
+ tags: [tag],
6171
+ text
6172
+ }, {
6173
+ kind: "code",
6174
+ base: name
6175
+ });
6176
+ }
6177
+ /** Mark every passage of a parse result as a code passage named after its file. */
6178
+ function codeNames(result) {
6179
+ for (const p of result.passages) withGeneratedName(p, {
6180
+ kind: "code",
6181
+ base: p.name
6182
+ });
6183
+ return result;
6184
+ }
5065
6185
  function parseTaggedFile(tag, filename) {
5066
- const source = readUTF8(filename);
6186
+ const diagnostics = [];
6187
+ const source = readUTF8(filename, diagnostics);
5067
6188
  return {
5068
- passages: [{
5069
- name: (0, node_path.basename)(filename),
5070
- tags: [tag],
5071
- text: source
5072
- }],
5073
- diagnostics: []
6189
+ passages: [codePassage((0, node_path.basename)(filename), tag, source)],
6190
+ diagnostics
5074
6191
  };
5075
6192
  }
5076
6193
  function parseMediaFile(tag, filename) {
5077
6194
  const source = readBase64(filename);
6195
+ const name = baseNameWithoutExt(filename);
5078
6196
  return {
5079
- passages: [{
5080
- name: baseNameWithoutExt(filename),
6197
+ passages: [withGeneratedName({
6198
+ name,
5081
6199
  tags: [tag],
5082
6200
  text: `data:${mediaTypeFromFilename(filename)};base64,${source}`
5083
- }],
6201
+ }, {
6202
+ kind: "media",
6203
+ base: name,
6204
+ file: filename
6205
+ })],
5084
6206
  diagnostics: []
5085
6207
  };
5086
6208
  }
@@ -5089,24 +6211,27 @@ function parseFontFile(filename) {
5089
6211
  const name = (0, node_path.basename)(filename);
5090
6212
  const family = baseNameWithoutExt(filename);
5091
6213
  const ext = normalizedFileExt(filename);
5092
- return {
6214
+ const mediaType = mediaTypeFromExt(ext);
6215
+ const hint = fontFormatHint(ext);
6216
+ return codeNames({
5093
6217
  passages: [{
5094
6218
  name,
5095
6219
  tags: ["stylesheet"],
5096
- text: `@font-face {\n\tfont-family: "${family}";\n\tsrc: url("data:${mediaTypeFromExt(ext)};base64,${source}") format("${fontFormatHint(ext)}");\n}`
6220
+ text: `@font-face {\n\tfont-family: "${cssStringEscape(family)}";\n\tsrc: url("data:${mediaType};base64,${source}") format("${hint}");\n}`
5097
6221
  }],
5098
6222
  diagnostics: []
5099
- };
6223
+ });
5100
6224
  }
5101
6225
  function parseHTMLFile(filename, opts) {
5102
- const { story, diagnostics } = decompileHTML(readUTF8(filename), { trim: opts.trim ?? true });
6226
+ const readDiagnostics = [];
6227
+ const { story, diagnostics } = decompileHTMLForImport(readUTF8(filename, readDiagnostics), { trim: opts.trim ?? true });
5103
6228
  return {
5104
6229
  passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
5105
6230
  name: "StoryTitle",
5106
6231
  tags: [],
5107
6232
  text: story.name
5108
6233
  }, ...story.passages] : story.passages,
5109
- diagnostics: [...diagnostics]
6234
+ diagnostics: [...readDiagnostics, ...diagnostics]
5110
6235
  };
5111
6236
  }
5112
6237
  /**
@@ -5179,6 +6304,7 @@ function loadTwee(story, filename, opts, diagnostics) {
5179
6304
  }
5180
6305
  function loadTagged(story, tag, filename, diagnostics) {
5181
6306
  const result = parseTaggedFile(tag, filename);
6307
+ diagnostics.push(...result.diagnostics);
5182
6308
  for (const p of result.passages) storyAdd(story, p, diagnostics);
5183
6309
  }
5184
6310
  function loadMedia(story, tag, filename, diagnostics) {
@@ -5193,8 +6319,9 @@ function loadFont(story, filename, diagnostics) {
5193
6319
  * Load sources with mtime-based caching for incremental rebuilds.
5194
6320
  * Always creates a fresh Story; cached passages are replayed via storyAdd().
5195
6321
  */
5196
- function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
5197
- const currentFiles = /* @__PURE__ */ new Set();
6322
+ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles, buildFiles) {
6323
+ const currentFiles = new Set(buildFiles);
6324
+ const changedPaths = changedFiles === void 0 ? void 0 : new Set([...changedFiles].map((f) => (0, node_path.resolve)(f)));
5198
6325
  for (const filename of filenames) {
5199
6326
  if (processedFiles.has(filename)) {
5200
6327
  diagnostics.push({
@@ -5207,8 +6334,8 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5207
6334
  const optionsKey = parseOptionsKey(filename, opts);
5208
6335
  const entry = cache.get(filename);
5209
6336
  const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
5210
- const changed = changedFiles?.has(filename) ?? false;
5211
- if (changedFiles && cached && !changed) {
6337
+ const changed = changedPaths?.has((0, node_path.resolve)(filename)) ?? false;
6338
+ if (changedPaths && cached && !changed) {
5212
6339
  diagnostics.push(...cached.diagnostics);
5213
6340
  for (const p of cached.passages) storyAdd(story, p, diagnostics);
5214
6341
  processedFiles.add(filename);
@@ -5218,6 +6345,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5218
6345
  try {
5219
6346
  mtimeMs = (0, node_fs.statSync)(filename).mtimeMs;
5220
6347
  } catch {
6348
+ cache.delete(filename);
5221
6349
  continue;
5222
6350
  }
5223
6351
  if (cached && !changed && cached.mtimeMs === mtimeMs) {
@@ -5238,6 +6366,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5238
6366
  diagnostics.push(...result.diagnostics);
5239
6367
  for (const p of result.passages) storyAdd(story, p, diagnostics);
5240
6368
  } catch (e) {
6369
+ cache.delete(filename);
5241
6370
  diagnostics.push({
5242
6371
  level: "error",
5243
6372
  message: `load ${filename}: ${e instanceof Error ? e.message : String(e)}`,
@@ -5256,11 +6385,6 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5256
6385
  }
5257
6386
  //#endregion
5258
6387
  //#region src/template.ts
5259
- /**
5260
- * An HTML closing head tag: `</head` in any letter case, followed by whitespace, `/` or `>`, up to the next `>`.
5261
- * This is how the HTML tokenizer reads an end tag; it ignores whatever sits between the tag name and the `>`.
5262
- */
5263
- const CLOSING_HEAD_TAG = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>";
5264
6388
  /** The regular expression source matching exactly `text`. */
5265
6389
  function literal(text) {
5266
6390
  return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
@@ -5286,6 +6410,8 @@ function fillTemplateParts(parts) {
5286
6410
  return part.text.replace(pattern, (match, ...groups) => {
5287
6411
  const slot = slots.find((_, i) => groups[i] !== void 0);
5288
6412
  if (slot === void 0 || slot.occurrences === "first" && filled.has(slot)) return match;
6413
+ const offset = groups[slots.length];
6414
+ if (slot.accepts !== void 0 && typeof offset === "number" && !slot.accepts(part.text, offset)) return match;
5289
6415
  filled.add(slot);
5290
6416
  return slot.replacement(match);
5291
6417
  });
@@ -5294,9 +6420,10 @@ function fillTemplateParts(parts) {
5294
6420
  //#endregion
5295
6421
  //#region src/modules.ts
5296
6422
  /**
5297
- * Load modules and return HTML tags to inject before the closing head tag.
6423
+ * Load modules and return HTML tags to inject before the closing head tag. `diagnostics` receives a warning for
6424
+ * each module that is not valid UTF-8 (it is read as Windows-1252).
5298
6425
  */
5299
- function loadModules(filenames) {
6426
+ function loadModules(filenames, diagnostics) {
5300
6427
  const processed = /* @__PURE__ */ new Set();
5301
6428
  const headTags = [];
5302
6429
  for (const filename of filenames) {
@@ -5305,10 +6432,10 @@ function loadModules(filenames) {
5305
6432
  let tag = null;
5306
6433
  switch (ext) {
5307
6434
  case "css":
5308
- tag = loadModuleTagged("style", filename);
6435
+ tag = loadModuleTagged("style", filename, diagnostics);
5309
6436
  break;
5310
6437
  case "js":
5311
- tag = loadModuleTagged("script", filename);
6438
+ tag = loadModuleTagged("script", filename, diagnostics);
5312
6439
  break;
5313
6440
  case "otf":
5314
6441
  case "ttf":
@@ -5323,8 +6450,8 @@ function loadModules(filenames) {
5323
6450
  }
5324
6451
  return headTags.join("\n");
5325
6452
  }
5326
- function loadModuleTagged(tag, filename) {
5327
- const source = readUTF8(filename).trim();
6453
+ function loadModuleTagged(tag, filename, diagnostics) {
6454
+ const source = readUTF8(filename, diagnostics).trim();
5328
6455
  if (source.length === 0) return null;
5329
6456
  return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
5330
6457
  }
@@ -5333,20 +6460,22 @@ function loadModuleFont(filename) {
5333
6460
  const family = baseNameWithoutExt(filename);
5334
6461
  const idSlug = `style-module-${slugify(family)}`;
5335
6462
  const ext = normalizedFileExt(filename);
5336
- 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>`;
6463
+ const mediaType = mediaTypeFromExt(ext);
6464
+ const hint = fontFormatHint(ext);
6465
+ 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>`;
5337
6466
  }
5338
6467
  /**
5339
- * Load the module tags and head file content to inject before the closing head tag, joined by newlines,
6468
+ * Load the module tags and head file content to inject into the head (see `placeHead()`), joined by newlines,
5340
6469
  * or `''` when there is nothing to inject.
5341
6470
  */
5342
6471
  function loadHeadContent(modulePaths, headFile, diagnostics) {
5343
6472
  const parts = [];
5344
6473
  if (modulePaths.length > 0) {
5345
- const modules = loadModules(modulePaths).trim();
6474
+ const modules = loadModules(modulePaths, diagnostics).trim();
5346
6475
  if (modules.length > 0) parts.push(modules);
5347
6476
  }
5348
6477
  if (headFile) try {
5349
- const source = readUTF8(headFile).trim();
6478
+ const source = readUTF8(headFile, diagnostics).trim();
5350
6479
  if (source.length > 0) parts.push(source);
5351
6480
  } catch (e) {
5352
6481
  diagnostics?.push({
@@ -5357,16 +6486,140 @@ function loadHeadContent(modulePaths, headFile, diagnostics) {
5357
6486
  return parts.join("\n");
5358
6487
  }
5359
6488
  /**
5360
- * The template slot that inserts `content` on its own line before the first closing head tag (as Tweego does),
5361
- * or `undefined` when `content` is empty. The tag may use any letter case and whitespace before its `>`, and is
5362
- * kept as written.
6489
+ * Place `content` in a template, given the texts that `fillTemplateParts()` scans for the slot (the template, and
6490
+ * the footer of a pre-1.4 Twine 1 format):
6491
+ *
6492
+ * - on its own line before the first closing head tag, as Tweego does; the tag may use any letter case and
6493
+ * whitespace before its `>`, and is kept as written;
6494
+ * - in a template with no closing head tag (HTML lets a document leave it out), on its own line before the first
6495
+ * body start tag, where the HTML parser still puts it in the head, with a warning (Tweego drops it);
6496
+ * - in a template with neither, nowhere, with a warning.
6497
+ *
6498
+ * `owner` names the template in the warnings. Empty `content` needs no slot and gets no warning.
6499
+ */
6500
+ function placeHead(content, texts, owner) {
6501
+ if (content.length === 0) return {
6502
+ slot: void 0,
6503
+ diagnostics: []
6504
+ };
6505
+ const scans = texts.map((text) => ({
6506
+ text,
6507
+ tags: scanHeadTags(text)
6508
+ }));
6509
+ if (scans.some(({ tags }) => tags.closingHead !== void 0)) return {
6510
+ slot: beforeTag(CLOSING_HEAD_PREFIX, "closingHead", content),
6511
+ diagnostics: []
6512
+ };
6513
+ if (scans.some(({ tags }) => tags.bodyStart !== void 0)) {
6514
+ const message = `${owner} has no closing head tag; the modules and head file were injected before its body start tag.`;
6515
+ return {
6516
+ slot: beforeTag(BODY_START_PREFIX, "bodyStart", content),
6517
+ diagnostics: [{
6518
+ level: "warning",
6519
+ message
6520
+ }]
6521
+ };
6522
+ }
6523
+ return {
6524
+ slot: void 0,
6525
+ diagnostics: [{
6526
+ level: "warning",
6527
+ message: `${owner} has no closing head tag and no body start tag; the modules and head file were not injected.`
6528
+ }]
6529
+ };
6530
+ }
6531
+ /** `</head` in any letter case, followed by whitespace, `/` or `>`; the rest of the tag is left alone. */
6532
+ const CLOSING_HEAD_PREFIX = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])";
6533
+ /** `<body` in any letter case, followed by whitespace, `/` or `>` (so not `<bodyx>`). */
6534
+ const BODY_START_PREFIX = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
6535
+ /** Elements whose content is text, so a tag written there is not a tag. */
6536
+ const RAW_TEXT_ELEMENTS = /* @__PURE__ */ new Set([
6537
+ "script",
6538
+ "style",
6539
+ "textarea",
6540
+ "title"
6541
+ ]);
6542
+ const isSpace = (c) => c === " " || c === " " || c === "\n" || c === "\f" || c === "\r";
6543
+ const isLetter = (c) => c !== void 0 && /^[A-Za-z]$/.test(c);
6544
+ /**
6545
+ * Find the first closing head tag and body start tag that HTML would read as tags: not those in a comment, in
6546
+ * the text of a script, style, textarea or title element, or in a quoted attribute value. A small scanner, not
6547
+ * a full HTML tokenizer; it needs no more than the tags and where they end.
6548
+ */
6549
+ function scanHeadTags(html) {
6550
+ let closingHead;
6551
+ let bodyStart;
6552
+ let i = 0;
6553
+ while (i < html.length) {
6554
+ const lt = html.indexOf("<", i);
6555
+ if (lt === -1) break;
6556
+ const next = html[lt + 1];
6557
+ if (html.startsWith("<!--", lt)) {
6558
+ const end = html.indexOf("-->", lt + 4);
6559
+ i = end === -1 ? html.length : end + 3;
6560
+ } else if (next === "!" || next === "?") {
6561
+ const end = html.indexOf(">", lt);
6562
+ i = end === -1 ? html.length : end + 1;
6563
+ } else if (next === "/" && isLetter(html[lt + 2])) {
6564
+ const { name, end } = readTag(html, lt + 2);
6565
+ if (name === "head" && closingHead === void 0) closingHead = lt;
6566
+ i = end;
6567
+ } else if (isLetter(next)) {
6568
+ const { name, end } = readTag(html, lt + 1);
6569
+ if (name === "body" && bodyStart === void 0) bodyStart = lt;
6570
+ i = RAW_TEXT_ELEMENTS.has(name) ? rawTextEnd(html, name, end) : end;
6571
+ } else i = lt + 1;
6572
+ if (closingHead !== void 0 && bodyStart !== void 0) break;
6573
+ }
6574
+ return {
6575
+ closingHead,
6576
+ bodyStart
6577
+ };
6578
+ }
6579
+ /** Read a tag whose name starts at `from`: its lowercase name, and the offset after its `>` (or the end). */
6580
+ function readTag(html, from) {
6581
+ let i = from;
6582
+ while (i < html.length && !isSpace(html[i]) && html[i] !== "/" && html[i] !== ">") i++;
6583
+ const name = html.slice(from, i).toLowerCase();
6584
+ while (i < html.length && html[i] !== ">") {
6585
+ if (html[i] !== "=") {
6586
+ i++;
6587
+ continue;
6588
+ }
6589
+ i++;
6590
+ while (isSpace(html[i])) i++;
6591
+ const quote = html[i];
6592
+ if (quote === "\"" || quote === "'") {
6593
+ const close = html.indexOf(quote, i + 1);
6594
+ i = close === -1 ? html.length : close + 1;
6595
+ }
6596
+ }
6597
+ return {
6598
+ name,
6599
+ end: Math.min(i + 1, html.length)
6600
+ };
6601
+ }
6602
+ /** The offset of the end tag that closes a raw text element, given where its text starts (or the end). */
6603
+ function rawTextEnd(html, name, from) {
6604
+ const close = new RegExp(`</${name}(?=[\\t\\n\\f\\r />])`, "gi");
6605
+ close.lastIndex = from;
6606
+ return close.exec(html)?.index ?? html.length;
6607
+ }
6608
+ /**
6609
+ * The slot that inserts `content` on its own line before the first real tag of the kind `which` names (see
6610
+ * `scanHeadTags()`), which is kept as written. A look-alike inside a script or comment is left alone.
5363
6611
  */
5364
- function headSlot(content) {
5365
- if (content.length === 0) return void 0;
6612
+ function beforeTag(pattern, which, content) {
6613
+ const cache = /* @__PURE__ */ new Map();
6614
+ const offsetIn = (text) => {
6615
+ if (!cache.has(text)) cache.set(text, scanHeadTags(text)[which]);
6616
+ return cache.get(text);
6617
+ };
5366
6618
  return {
5367
- pattern: CLOSING_HEAD_TAG,
6619
+ pattern,
5368
6620
  occurrences: "first",
5369
- replacement: (tag) => `${content}\n${tag}`
6621
+ replacement: (match) => `${content}\n${match}`,
6622
+ accepts: (text, offset) => offsetIn(text) === offset
5370
6623
  };
5371
6624
  }
5372
6625
  //#endregion
@@ -5405,14 +6658,19 @@ function toTwine2Archive(story, startName, options) {
5405
6658
  }
5406
6659
  /**
5407
6660
  * Fill the Twine 2 format template: every `{{STORY_NAME}}` gets the escaped story name and the first
5408
- * `{{STORY_DATA}}` the story data (as Tweego does), and `head` goes before the first closing head tag. All three are
5409
- * found in the template in one pass, so a story name, passage text or head content holding a placeholder or a
5410
- * closing head tag stays literal.
6661
+ * `{{STORY_DATA}}` the story data (as Tweego does), and `head` goes before the first closing head tag (or, with a
6662
+ * warning, the body start tag; see `placeHead()`). All three are found in the template in one pass, so a story
6663
+ * name, passage text or head content holding a placeholder or a closing head tag stays literal.
6664
+ * `options.diagnostics` receives a warning when the format file is not valid UTF-8, and any warning about where
6665
+ * `head` went.
5411
6666
  */
5412
6667
  function toTwine2HTML(story, format, startName, options) {
5413
6668
  const name = htmlEscape(story.name);
5414
- const head = headSlot(options?.head ?? "");
5415
- const slots = [
6669
+ const template = readFormatSource(format, options?.diagnostics);
6670
+ const placement = placeHead(options?.head ?? "", [template], `Story format "${format.name}" ${format.version}`);
6671
+ options?.diagnostics?.push(...placement.diagnostics);
6672
+ const head = placement.slot;
6673
+ return fillTemplate(template, [
5416
6674
  {
5417
6675
  pattern: literal("{{STORY_NAME}}"),
5418
6676
  occurrences: "all",
@@ -5433,8 +6691,7 @@ function toTwine2HTML(story, format, startName, options) {
5433
6691
  }
5434
6692
  },
5435
6693
  ...head === void 0 ? [] : [head]
5436
- ];
5437
- return fillTemplate(readFormatSource(format), slots);
6694
+ ]);
5438
6695
  }
5439
6696
  const OMITTING_TAGS = [
5440
6697
  "Twine.private",
@@ -5530,29 +6787,35 @@ function toTwine1Archive(story, _startName) {
5530
6787
  /**
5531
6788
  * Fill the Twine 1 format template. The format's components are inserted first, one after another, as Tweego and
5532
6789
  * Twine 1 do. Then the first of each story placeholder (`"VERSION"`, `"TIME"`, `"START_AT"`, `"STORY_SIZE"`,
5533
- * `"STORY"`), the IFID comment and `head` (before the first closing head tag) are found in one pass, so a start
5534
- * passage name or story data holding a placeholder or a closing head tag stays literal.
6790
+ * `"STORY"`), the IFID comment and `head` (before the first closing head tag or, with a warning, the body start
6791
+ * tag; see `placeHead()`) are found in one pass, so a start passage name or story data holding a placeholder or a
6792
+ * closing head tag stays literal. `options.diagnostics` receives a warning for each format file that is not valid
6793
+ * UTF-8, and any warning about where `head` went.
5535
6794
  */
5536
6795
  function toTwine1HTML(story, format, startName, options) {
5537
6796
  const formatDir = (0, node_path.dirname)(format.filename);
5538
6797
  const parentDir = (0, node_path.dirname)(formatDir);
5539
- let template = readFormatSource(format);
6798
+ const diagnostics = options?.diagnostics;
6799
+ let template = readFormatSource(format, diagnostics);
5540
6800
  const { data, count } = getTwine1PassageChunk(story);
5541
- template = tryReplaceComponent(template, "\"USER_LIB\"", (0, node_path.join)(formatDir, "userlib.js"), false);
5542
- template = tryReplaceComponent(template, "\"ENGINE\"", (0, node_path.join)(parentDir, "engine.js"), true);
5543
- template = tryReplaceComponent(template, "\"SUGARCANE\"", (0, node_path.join)(formatDir, "code.js"), true);
5544
- template = tryReplaceComponent(template, "\"JONAH\"", (0, node_path.join)(formatDir, "code.js"), true);
5545
- if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", (0, node_path.join)(parentDir, "jquery.js"), true);
5546
- if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", (0, node_path.join)(parentDir, "modernizr.js"), true);
6801
+ template = tryReplaceComponent(template, "\"USER_LIB\"", (0, node_path.join)(formatDir, "userlib.js"), false, diagnostics);
6802
+ template = tryReplaceComponent(template, "\"ENGINE\"", (0, node_path.join)(parentDir, "engine.js"), true, diagnostics);
6803
+ template = tryReplaceComponent(template, "\"SUGARCANE\"", (0, node_path.join)(formatDir, "code.js"), true, diagnostics);
6804
+ template = tryReplaceComponent(template, "\"JONAH\"", (0, node_path.join)(formatDir, "code.js"), true, diagnostics);
6805
+ if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", (0, node_path.join)(parentDir, "jquery.js"), true, diagnostics);
6806
+ if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", (0, node_path.join)(parentDir, "modernizr.js"), true, diagnostics);
5547
6807
  const isPre14 = !template.includes("\"STORY\"");
5548
- const footer = isPre14 ? readFooter(formatDir) : "";
6808
+ const footer = isPre14 ? readFooter(formatDir, diagnostics) : "";
5549
6809
  const storeArea = (template + footer).includes("<div id=\"store-area\"") ? "<div id=\"store-area\"" : "<div id=\"storeArea\"";
5550
6810
  const safeIfid = htmlCommentSanitize(story.ifid);
5551
- const late = [story.ifid ? {
6811
+ const ifid = story.ifid ? {
5552
6812
  pattern: literal(storeArea),
5553
6813
  occurrences: "first",
5554
6814
  replacement: (div) => `<!-- UUID://${safeIfid}// -->${div}`
5555
- } : void 0, headSlot(options?.head ?? "")].filter((slot) => slot !== void 0);
6815
+ } : void 0;
6816
+ const placement = placeHead(options?.head ?? "", isPre14 ? [template, footer] : [template], `Story format "${format.name}"`);
6817
+ diagnostics?.push(...placement.diagnostics);
6818
+ const late = [ifid, placement.slot].filter((slot) => slot !== void 0);
5556
6819
  const displayStart = startName === "Start" ? "" : startName;
5557
6820
  const slots = [
5558
6821
  firstSlot("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`),
@@ -5592,9 +6855,9 @@ function firstSlot(token, value) {
5592
6855
  };
5593
6856
  }
5594
6857
  /** The footer of a pre-1.4 format, or the default one when the format has none. */
5595
- function readFooter(formatDir) {
6858
+ function readFooter(formatDir, diagnostics) {
5596
6859
  try {
5597
- return (0, node_fs.readFileSync)((0, node_path.join)(formatDir, "footer.html"), "utf-8");
6860
+ return readUTF8((0, node_path.join)(formatDir, "footer.html"), diagnostics);
5598
6861
  } catch {
5599
6862
  return "</div>\n</body>\n</html>\n";
5600
6863
  }
@@ -5622,11 +6885,10 @@ function getTwine1PassageChunk(story) {
5622
6885
  count
5623
6886
  };
5624
6887
  }
5625
- function tryReplaceComponent(template, placeholder, componentPath, required) {
6888
+ function tryReplaceComponent(template, placeholder, componentPath, required, diagnostics) {
5626
6889
  if (!template.includes(placeholder)) return template;
5627
6890
  try {
5628
- let content = (0, node_fs.readFileSync)(componentPath, "utf-8");
5629
- if (content.charCodeAt(0) === 65279) content = content.slice(1);
6891
+ const content = readUTF8(componentPath, diagnostics);
5630
6892
  return template.replace(placeholder, () => content);
5631
6893
  } catch (e) {
5632
6894
  if (required) throw new Error(`Required format component not found: ${componentPath}: ${e instanceof Error ? e.message : String(e)}`);
@@ -5640,11 +6902,16 @@ function tryReplaceComponent(template, placeholder, componentPath, required) {
5640
6902
  * from its loaded text, so changes made after loading (a start passage override, test mode) are kept.
5641
6903
  * Tweego writes the loaded text; its own normalization makes the two the same for an unchanged story.
5642
6904
  *
6905
+ * Twee cannot write every passage so that it reads back the same (see `tweeRoundTrip()`); `diagnostics`
6906
+ * receives a warning for each passage that will not. Tweego writes the same Twee without one.
6907
+ *
5643
6908
  * `addStoryData`: add a StoryData passage when the story has none, so that StoryData fields the
5644
6909
  * compile options set are recorded. Default: false.
5645
6910
  */
5646
6911
  function toTwee(story, outMode, options = {}) {
5647
- return withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => passageToTwee(p, outMode)).join("");
6912
+ const written = withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => tweeRoundTrip(p, outMode));
6913
+ options.diagnostics?.push(...written.flatMap(({ diagnostics }) => diagnostics));
6914
+ return written.map(({ passage }) => passageToTwee(passage, outMode)).join("");
5648
6915
  }
5649
6916
  function withEffectiveStoryData(story, addStoryData) {
5650
6917
  const storyData = {
@@ -5669,6 +6936,64 @@ function withEffectiveStoryData(story, addStoryData) {
5669
6936
  function isStoryDataJSON(text) {
5670
6937
  return unmarshalStoryData(createStory(), text) === null;
5671
6938
  }
6939
+ /** Characters Twee 3 escapes in names and tags, and Twee 1 cannot write there at all. */
6940
+ const TWEE1_UNWRITABLE = /[[\]{}\\]/;
6941
+ /**
6942
+ * Check that Twee reads `p` back as written, and say what it will not. Twee has no way to escape passage text,
6943
+ * and its parser trims names and splits tags at whitespace, so these do not survive:
6944
+ *
6945
+ * - a text line that starts with `::`, which reads as a new passage header. In stylesheet and script passages such
6946
+ * a line is indented by one space, which keeps the CSS or JavaScript working (only a template literal or a
6947
+ * string continued across lines, in either, would see the space); the warning says so. Other passages' text is
6948
+ * written as it is.
6949
+ * - a name that is empty, has leading or trailing whitespace, or holds a line break;
6950
+ * - a tag that is empty or holds whitespace;
6951
+ * - in Twee 1, which escapes nothing, a name or tag holding `[`, `]`, `{`, `}` or `\`.
6952
+ */
6953
+ function tweeRoundTrip(p, outMode) {
6954
+ const label = `Passage ${JSON.stringify(p.name)}`;
6955
+ const diagnostics = [];
6956
+ const warn = (problem) => {
6957
+ diagnostics.push({
6958
+ level: "warning",
6959
+ message: `${label} cannot be written as Twee that reads back the same: ${problem}.`
6960
+ });
6961
+ };
6962
+ if (p.name.trim() === "") warn("its name is empty");
6963
+ else if (p.name.trim() !== p.name) warn("its name has leading or trailing whitespace, which Twee drops");
6964
+ if (/[\r\n]/.test(p.name)) warn("its name has a line break, which ends a Twee passage header");
6965
+ if (outMode === "twee1" && TWEE1_UNWRITABLE.test(p.name)) warn("its name has \"[\", \"]\", \"{\", \"}\" or \"\\\", which Twee 1 cannot escape");
6966
+ for (const tag of p.tags) {
6967
+ if (tag === "" || /\s/.test(tag)) warn(`its tag ${JSON.stringify(tag)} is empty or has whitespace, which splits tags in Twee`);
6968
+ if (outMode === "twee1" && TWEE1_UNWRITABLE.test(tag)) warn(`its tag ${JSON.stringify(tag)} has "[", "]", "{", "}" or "\\", which Twee 1 cannot escape`);
6969
+ }
6970
+ const lines = p.text.split(/(\r\n?|\n)/);
6971
+ const headerLines = lines.flatMap((line, i) => i % 2 === 0 && line.startsWith("::") ? [i / 2 + 1] : []);
6972
+ if (headerLines.length === 0) return {
6973
+ passage: p,
6974
+ diagnostics
6975
+ };
6976
+ 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`;
6977
+ if (!p.tags.some((tag) => tag === "stylesheet" || tag === "script")) {
6978
+ warn(where);
6979
+ return {
6980
+ passage: p,
6981
+ diagnostics
6982
+ };
6983
+ }
6984
+ diagnostics.push({
6985
+ level: "warning",
6986
+ message: `${label}: ${where}; ${headerLines.length === 1 ? "it was" : "they were"} written indented by one space.`
6987
+ });
6988
+ const text = lines.map((line, i) => i % 2 === 0 && line.startsWith("::") ? ` ${line}` : line).join("");
6989
+ return {
6990
+ passage: {
6991
+ ...p,
6992
+ text
6993
+ },
6994
+ diagnostics
6995
+ };
6996
+ }
5672
6997
  //#endregion
5673
6998
  //#region src/passage-omission.ts
5674
6999
  /**
@@ -5735,42 +7060,53 @@ async function compile(options) {
5735
7060
  return buildOutput(options);
5736
7061
  }
5737
7062
  /**
5738
- * Compile and write to a file.
7063
+ * Compile and write to a file. The file is replaced atomically: a reader sees the previous
7064
+ * build or the new one, never part of it, and a failed write leaves the previous build.
5739
7065
  */
5740
7066
  async function compileToFile(options) {
5741
7067
  const result = await compileForOutputFile(options, options.outFile);
5742
- (0, node_fs.writeFileSync)(options.outFile, result.output, "utf-8");
7068
+ options.signal?.throwIfAborted();
7069
+ writeFileAtomic(options.outFile, result.output);
5743
7070
  return result;
5744
7071
  }
5745
7072
  /**
5746
- * Builds as compileToFile() does for `outFile`, without writing it: the output may sit
7073
+ * Builds as compileToFile() does for `outputs`, without writing them: an output may sit
5747
7074
  * inside a source folder, so its last build is left out of the sources and modules and
5748
- * never read back. The caller decides whether the result is written. With no `outFile`
5749
- * (output to stdout, or none), this is compile(). With `cache`, files are cached as
5750
- * compileIncremental() caches them.
7075
+ * never read back. `outputs` is the one file the CLI writes, or every path a bundler's
7076
+ * build writes (see BuildOutputs). The caller decides whether the result is written.
7077
+ * With no outputs (output to stdout, or none), this is compile(). With `cache`, files
7078
+ * are cached as compileIncremental() caches them.
7079
+ *
7080
+ * Throws a TweeTsError when a source, module or head file named directly is an output.
5751
7081
  *
5752
7082
  * Internal, for the CLI and the bundler plugins; not part of the public API.
5753
7083
  */
5754
- async function compileForOutputFile(options, outFile, cache) {
5755
- return buildOutput(options, cache, void 0, outFile);
7084
+ async function compileForOutputFile(options, outputs, cache) {
7085
+ return buildOutput(options, cache, void 0, outputs);
5756
7086
  }
5757
7087
  /**
5758
7088
  * Compile with incremental caching support.
5759
7089
  * Plugins and advanced users can manage their own cache and changed-file tracking.
5760
7090
  *
5761
7091
  * Without `changedFiles`, a cached file is reused while its modification time is unchanged.
5762
- * With it, a file it names (by the path source discovery gives it, relative to the working
5763
- * directory) is always reparsed, whatever its modification time, and every other cached
5764
- * file is reused as it is.
7092
+ * With it, a file it names is always reparsed, whatever its modification time, and every
7093
+ * other cached file is reused as it is. A file may be named by an absolute path or by a path
7094
+ * relative to the working directory, with or without a leading `./`: entries are matched to
7095
+ * the source files by resolved path. A file that fails to load is dropped from the cache, so
7096
+ * the next build tries it again.
5765
7097
  */
5766
7098
  async function compileIncremental(options, cache, changedFiles) {
5767
7099
  return buildOutput(options, cache, changedFiles);
5768
7100
  }
5769
7101
  /**
5770
- * Watch for file changes and recompile. Every build is written to `outFile`, including
5771
- * one whose diagnostics report errors. Builds run one at a time: changes made during a
5772
- * build go into one follow-up build after it, and the superseded build is neither
5773
- * written nor reported.
7102
+ * Watch for file changes and recompile. Every build is written to `outFile` (replacing it
7103
+ * atomically, as compileToFile() does), including one whose diagnostics report errors.
7104
+ * Builds run one at a time, and each one is written and reported as it finishes, in order:
7105
+ * changes made during a build go into one follow-up build after it. A watched path that
7106
+ * can't be watched is passed to `onError`.
7107
+ *
7108
+ * Aborting the returned controller, or the `signal` in the options, stops watching and
7109
+ * aborts the story format requests of the build in progress.
5774
7110
  */
5775
7111
  async function watch(options) {
5776
7112
  return watchWithWriteFilter(options, () => true);
@@ -5782,9 +7118,13 @@ async function watch(options) {
5782
7118
  * Internal, for the CLI, which keeps the last good output when a rebuild reports errors;
5783
7119
  * not part of the public API.
5784
7120
  */
5785
- async function watchWithWriteFilter(options, shouldWrite) {
7121
+ async function watchWithWriteFilter(options, shouldWrite, hooks = {}) {
5786
7122
  const controller = new AbortController();
5787
7123
  const cache = /* @__PURE__ */ new Map();
7124
+ const buildOptions = {
7125
+ ...options,
7126
+ signal: controller.signal
7127
+ };
5788
7128
  const filePaths = options.sources.filter((s) => typeof s === "string");
5789
7129
  const modulePaths = options.modules ?? [];
5790
7130
  const headPaths = options.headFile ? [options.headFile] : [];
@@ -5802,13 +7142,20 @@ async function watchWithWriteFilter(options, shouldWrite) {
5802
7142
  };
5803
7143
  let building = false;
5804
7144
  let queued;
7145
+ const reportError = (error) => {
7146
+ try {
7147
+ options.onError?.(error);
7148
+ } catch (e) {
7149
+ console.error(`twee-ts watch: onError threw while handling "${error.message}": ${toError(e).message}`);
7150
+ }
7151
+ };
5805
7152
  const deliver = (outcome) => {
5806
7153
  try {
5807
7154
  if (!outcome.ok) throw outcome.error;
5808
- if (shouldWrite(outcome.result)) (0, node_fs.writeFileSync)(options.outFile, outcome.result.output, "utf-8");
7155
+ if (shouldWrite(outcome.result)) writeFileAtomic(options.outFile, outcome.result.output);
5809
7156
  options.onBuild?.(outcome.result);
5810
7157
  } catch (e) {
5811
- options.onError?.(toError(e));
7158
+ reportError(toError(e));
5812
7159
  }
5813
7160
  };
5814
7161
  const drain = async (first) => {
@@ -5816,8 +7163,7 @@ async function watchWithWriteFilter(options, shouldWrite) {
5816
7163
  try {
5817
7164
  let request = first;
5818
7165
  while (request !== void 0) {
5819
- const current = request;
5820
- const outcome = await buildOutput(options, cache, current.changedFiles, options.outFile).then((result) => ({
7166
+ const outcome = await buildOutput(buildOptions, cache, request.changedFiles, options.outFile).then((result) => ({
5821
7167
  ok: true,
5822
7168
  result
5823
7169
  }), (e) => ({
@@ -5825,12 +7171,13 @@ async function watchWithWriteFilter(options, shouldWrite) {
5825
7171
  error: toError(e)
5826
7172
  }));
5827
7173
  if (controller.signal.aborted) return;
5828
- request = queued === void 0 ? void 0 : mergeBuildRequests(current, queued);
7174
+ deliver(outcome);
7175
+ request = queued;
5829
7176
  queued = void 0;
5830
- if (request === void 0) deliver(outcome);
5831
7177
  }
5832
7178
  } finally {
5833
7179
  building = false;
7180
+ hooks.onIdle?.();
5834
7181
  }
5835
7182
  };
5836
7183
  const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
@@ -5840,12 +7187,17 @@ async function watchWithWriteFilter(options, shouldWrite) {
5840
7187
  queued = queued === void 0 ? request : mergeBuildRequests(queued, request);
5841
7188
  return;
5842
7189
  }
5843
- drain(request);
5844
- }, ignore);
7190
+ drain(request).catch((e) => reportError(toError(e)));
7191
+ }, ignore, reportError);
7192
+ const outer = options.signal;
7193
+ const onOuterAbort = () => controller.abort(outer?.reason);
5845
7194
  controller.signal.addEventListener("abort", () => {
5846
7195
  queued = void 0;
5847
7196
  handle.close();
7197
+ outer?.removeEventListener("abort", onOuterAbort);
5848
7198
  });
7199
+ if (outer?.aborted) onOuterAbort();
7200
+ else outer?.addEventListener("abort", onOuterAbort, { once: true });
5849
7201
  return controller;
5850
7202
  }
5851
7203
  /** One build covering both requests: a full build if either is one, else every changed file. */
@@ -5857,10 +7209,13 @@ function toError(e) {
5857
7209
  return e instanceof Error ? e : new Error(String(e));
5858
7210
  }
5859
7211
  /**
5860
- * `outFile`: the file the build is written to, which source and module discovery
5861
- * skip (as Tweego does), so an output inside a source folder is never loaded back.
7212
+ * `outputs`: what the build writes (an output file's path, or a bundler's outputs),
7213
+ * which source and module discovery skip, so an output inside a source folder is
7214
+ * never loaded back. A source, module or head file named directly that is an output
7215
+ * is a TweeTsError, as in Tweego: writing the build would overwrite it.
5862
7216
  */
5863
- async function buildOutput(options, cache, changedFiles, outFile) {
7217
+ async function buildOutput(options, cache, changedFiles, outputs) {
7218
+ const written = toBuildOutputs(outputs);
5864
7219
  const diagnostics = [];
5865
7220
  const outputMode = options.outputMode ?? "html";
5866
7221
  const trim = options.trim ?? true;
@@ -5868,33 +7223,65 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5868
7223
  const testMode = options.testMode ?? false;
5869
7224
  const noRemote = options.noRemote ?? false;
5870
7225
  const sourceInfo = options.sourceInfo ?? false;
7226
+ options.signal?.throwIfAborted();
7227
+ const timeout = options.formatFetchTimeout;
7228
+ if (timeout !== void 0 && !(timeout >= 0)) throw new TweeTsError(`formatFetchTimeout must be 0 or more milliseconds, not ${timeout}.`);
5871
7229
  clearIndexCache();
5872
- const filePaths = [];
5873
- const inlineSources = [];
5874
- for (const source of options.sources) if (typeof source === "string") filePaths.push(source);
5875
- else inlineSources.push(source);
5876
- const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths, outFile, options.exclude);
5877
- diagnostics.push(...sourcePathDiagnostics);
7230
+ const groups = [];
7231
+ const outputSources = [];
7232
+ for (const source of options.sources) {
7233
+ const last = groups[groups.length - 1];
7234
+ if (typeof source === "string") {
7235
+ if (last?.kind === "paths") last.paths.push(source);
7236
+ else groups.push({
7237
+ kind: "paths",
7238
+ paths: [source]
7239
+ });
7240
+ } else if (last?.kind === "inline") last.sources.push(source);
7241
+ else groups.push({
7242
+ kind: "inline",
7243
+ sources: [source]
7244
+ });
7245
+ }
7246
+ const walked = groups.map((group) => {
7247
+ if (group.kind === "inline") return group;
7248
+ const found = getFilenames(group.paths, written, options.exclude);
7249
+ diagnostics.push(...found.diagnostics);
7250
+ outputSources.push(...found.outputSources);
7251
+ return {
7252
+ kind: "files",
7253
+ filenames: found.filenames
7254
+ };
7255
+ });
7256
+ rejectOutputSources(outputSources, diagnostics);
7257
+ const buildFiles = new Set(walked.flatMap((group) => group.kind === "files" ? group.filenames : []));
5878
7258
  const story = createStory();
5879
7259
  const processedFiles = /* @__PURE__ */ new Set();
5880
- if (cache) loadSourcesCached(story, sourceFilenames, {
7260
+ for (const group of walked) if (group.kind === "inline") loadInlineSources(story, group.sources, {
5881
7261
  trim,
5882
7262
  twee2Compat
5883
- }, diagnostics, processedFiles, cache, changedFiles);
5884
- else loadSources(story, sourceFilenames, {
7263
+ }, diagnostics);
7264
+ else if (cache) loadSourcesCached(story, group.filenames, {
7265
+ trim,
7266
+ twee2Compat
7267
+ }, diagnostics, processedFiles, cache, changedFiles, buildFiles);
7268
+ else loadSources(story, group.filenames, {
5885
7269
  trim,
5886
7270
  twee2Compat
5887
7271
  }, diagnostics, processedFiles);
5888
- loadInlineSources(story, inlineSources, {
7272
+ if (cache && buildFiles.size === 0) loadSourcesCached(story, [], {
5889
7273
  trim,
5890
7274
  twee2Compat
5891
- }, diagnostics);
7275
+ }, diagnostics, processedFiles, cache, changedFiles);
5892
7276
  if (options.tagAliases) story.passages = applyTagAliases(story.passages, options.tagAliases);
5893
7277
  let format;
5894
- if (outputMode === "html") format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
5895
- ...options,
5896
- noRemote
5897
- }, diagnostics);
7278
+ if (outputMode === "html") {
7279
+ format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
7280
+ ...options,
7281
+ noRemote
7282
+ }, diagnostics);
7283
+ options.signal?.throwIfAborted();
7284
+ }
5898
7285
  if (options.startPassage) story.twine2.start = options.startPassage;
5899
7286
  const startName = story.twine2.start || DEFAULT_START_NAME;
5900
7287
  if (testMode) story.twine2.options.set("debug", true);
@@ -5903,7 +7290,10 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5903
7290
  switch (outputMode) {
5904
7291
  case "twee3":
5905
7292
  case "twee1":
5906
- output = toTwee(story, outputMode, { addStoryData: Boolean(options.startPassage) || testMode });
7293
+ output = toTwee(story, outputMode, {
7294
+ addStoryData: Boolean(options.startPassage) || testMode,
7295
+ diagnostics
7296
+ });
5907
7297
  break;
5908
7298
  case "twine2-archive":
5909
7299
  output = toTwine2Archive(story, startName, { sourceInfo });
@@ -5921,13 +7311,20 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5921
7311
  level: "error",
5922
7312
  message: "Special passage \"StoryTitle\" not found."
5923
7313
  });
5924
- const modules = getFilenames(options.modules ?? [], outFile);
7314
+ const modules = getFilenames(options.modules ?? [], written);
5925
7315
  diagnostics.push(...modules.diagnostics);
7316
+ const { headFile } = options;
7317
+ const headIsOutput = headFile !== void 0 && outputPaths(written).isFile(realPathOf(headFile));
7318
+ rejectOutputSources([...modules.outputSources, ...headIsOutput ? [headFile] : []], diagnostics);
5926
7319
  const head = loadHeadContent(modules.filenames, options.headFile, diagnostics);
5927
7320
  output = format.isTwine2 ? toTwine2HTML(story, format, startName, {
5928
7321
  sourceInfo,
5929
- head
5930
- }) : toTwine1HTML(story, format, startName, { head });
7322
+ head,
7323
+ diagnostics
7324
+ }) : toTwine1HTML(story, format, startName, {
7325
+ head,
7326
+ diagnostics
7327
+ });
5931
7328
  break;
5932
7329
  }
5933
7330
  default: throw new TweeTsError(`Unhandled output mode: ${outputMode}`, diagnostics);
@@ -5936,6 +7333,7 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5936
7333
  ...getStoryStats(story, options.wordCountMethod),
5937
7334
  files: [...processedFiles]
5938
7335
  };
7336
+ options.signal?.throwIfAborted();
5939
7337
  return {
5940
7338
  output,
5941
7339
  story,
@@ -5944,6 +7342,16 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5944
7342
  stats
5945
7343
  };
5946
7344
  }
7345
+ /**
7346
+ * Throws for the first of `paths`, inputs named directly that are also an output:
7347
+ * writing the build would overwrite them. An output found while walking a source
7348
+ * folder is skipped instead (see getFilenames).
7349
+ */
7350
+ function rejectOutputSources(paths, diagnostics) {
7351
+ const [first] = paths;
7352
+ if (first === void 0) return;
7353
+ throw new TweeTsError(`path ${first}: Output file cannot be an input source.`, [...diagnostics]);
7354
+ }
5947
7355
  function ensureIFID(story, diagnostics) {
5948
7356
  if (story.ifid !== "") return;
5949
7357
  if (story.legacyIFID !== "") {
@@ -7513,7 +8921,7 @@ function storyInspect(story, options = {}) {
7513
8921
  if (isStoryPassage(p)) storyPassages.push(p.name);
7514
8922
  if (isInfoPassage(p)) infoPassages.push(p.name);
7515
8923
  tagsByPassage.set(p.name, [...p.tags]);
7516
- for (const tag of p.tags) {
8924
+ for (const tag of new Set(p.tags)) {
7517
8925
  tagSet.add(tag);
7518
8926
  let list = passagesByTag.get(tag);
7519
8927
  if (!list) {
@@ -7657,46 +9065,91 @@ const VALID_OUTPUT_MODES = [
7657
9065
  "json"
7658
9066
  ];
7659
9067
  const VALID_WORD_COUNT_METHODS = ["tweego", "whitespace"];
7660
- /** Load a config file from the given directory (default: cwd). Returns null if not found. */
7661
- function loadConfig(dir) {
9068
+ /** One entry per {@link TweeTsConfig} key: the compiler rejects a missing or an extra key here. */
9069
+ const CONFIG_KEY_SET = {
9070
+ sources: true,
9071
+ exclude: true,
9072
+ output: true,
9073
+ outputMode: true,
9074
+ formatId: true,
9075
+ startPassage: true,
9076
+ formatPaths: true,
9077
+ formatIndices: true,
9078
+ formatUrls: true,
9079
+ useTweegoPath: true,
9080
+ modules: true,
9081
+ headFile: true,
9082
+ trim: true,
9083
+ twee2Compat: true,
9084
+ testMode: true,
9085
+ noRemote: true,
9086
+ formatFetchTimeout: true,
9087
+ tagAliases: true,
9088
+ sourceInfo: true,
9089
+ wordCountMethod: true
9090
+ };
9091
+ /** The keys a config file may hold besides `$schema`; the JSON schema's `properties` list the same keys. */
9092
+ const CONFIG_KEYS = Object.keys(CONFIG_KEY_SET);
9093
+ /** The key that only references the JSON schema, for editors. */
9094
+ const SCHEMA_KEY = "$schema";
9095
+ /** A key folded so that spellings differing only in letter case, `-` or `_` compare equal. */
9096
+ const foldKey = (key) => key.toLowerCase().replace(/[-_]/g, "");
9097
+ /**
9098
+ * Load a config file from the given directory (default: cwd). Returns null if not found.
9099
+ *
9100
+ * @param diagnostics Receives warnings that do not stop the config from loading: keys the config does not
9101
+ * define, and a file that is not valid UTF-8 (it is read as Windows-1252).
9102
+ * @throws When the file exists but cannot be read, is not valid JSON or fails {@link validateConfig}.
9103
+ */
9104
+ function loadConfig(dir, diagnostics) {
7662
9105
  const base = dir ?? process.cwd();
7663
9106
  const configPath = (0, node_path.join)(base, CONFIG_FILENAME);
7664
9107
  if (!(0, node_fs.existsSync)(configPath)) return null;
7665
- let raw;
7666
- try {
7667
- raw = (0, node_fs.readFileSync)(configPath, "utf-8");
7668
- } catch {
7669
- return null;
7670
- }
7671
- let data;
7672
- try {
7673
- data = JSON.parse(raw);
7674
- } catch (e) {
7675
- throw new Error(`Invalid JSON in ${configPath}: ${e instanceof Error ? e.message : String(e)}`);
7676
- }
7677
- const errors = validateConfig(data);
7678
- if (errors.length > 0) throw new Error(`Invalid config in ${configPath}:\n ${errors.join("\n ")}`);
7679
- return data;
9108
+ return loadConfigFile(configPath, diagnostics);
7680
9109
  }
7681
- /** Load a config from a specific file path. */
7682
- function loadConfigFile(filePath) {
9110
+ /**
9111
+ * Load a config from a specific file path.
9112
+ *
9113
+ * @param diagnostics Receives warnings that do not stop the config from loading: keys the config does not
9114
+ * define, and a file that is not valid UTF-8 (it is read as Windows-1252).
9115
+ * @throws When the file cannot be read, is not valid JSON or fails {@link validateConfig}.
9116
+ */
9117
+ function loadConfigFile(filePath, diagnostics) {
9118
+ const readDiagnostics = [];
7683
9119
  let raw;
7684
9120
  try {
7685
- raw = (0, node_fs.readFileSync)(filePath, "utf-8");
9121
+ raw = readUTF8(filePath, readDiagnostics);
7686
9122
  } catch (e) {
7687
9123
  throw new Error(`Cannot read config file ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
7688
9124
  }
9125
+ const config = parseConfig(raw, filePath, readDiagnostics);
9126
+ diagnostics?.push(...readDiagnostics);
9127
+ return config;
9128
+ }
9129
+ /**
9130
+ * Parse and validate the text of the config file at `path`. Unknown keys are added to `diagnostics` as
9131
+ * warnings, so a config with a stray key keeps loading.
9132
+ */
9133
+ function parseConfig(raw, path, diagnostics) {
7689
9134
  let data;
7690
9135
  try {
7691
9136
  data = JSON.parse(raw);
7692
9137
  } catch (e) {
7693
- throw new Error(`Invalid JSON in ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
9138
+ throw new Error(`Invalid JSON in ${path}: ${e instanceof Error ? e.message : String(e)}`);
7694
9139
  }
7695
9140
  const errors = validateConfig(data);
7696
- if (errors.length > 0) throw new Error(`Invalid config in ${filePath}:\n ${errors.join("\n ")}`);
9141
+ if (errors.length > 0) throw new Error(`Invalid config in ${path}:\n ${errors.join("\n ")}`);
9142
+ diagnostics.push(...unknownConfigKeyWarnings(data).map((message) => ({
9143
+ level: "warning",
9144
+ message: `${path}: ${message}`,
9145
+ file: path
9146
+ })));
7697
9147
  return data;
7698
9148
  }
7699
- /** Validate a config object. Returns an array of error strings (empty = valid). */
9149
+ /**
9150
+ * Validate a config object. Returns an array of error strings (empty = valid).
9151
+ * Keys the config does not define are not errors; {@link unknownConfigKeyWarnings} reports them.
9152
+ */
7700
9153
  function validateConfig(data) {
7701
9154
  const errors = [];
7702
9155
  if (typeof data !== "object" || data === null || Array.isArray(data)) {
@@ -7729,6 +9182,10 @@ function validateConfig(data) {
7729
9182
  if (!Array.isArray(obj[key])) errors.push(`"${key}" must be an array.`);
7730
9183
  else if (!obj[key].every((v) => typeof v === "string")) errors.push(`"${key}" must be an array of strings.`);
7731
9184
  }
9185
+ if ("formatFetchTimeout" in obj) {
9186
+ const timeout = obj["formatFetchTimeout"];
9187
+ if (typeof timeout !== "number" || !(timeout >= 0)) errors.push("\"formatFetchTimeout\" must be a number of milliseconds, 0 or more.");
9188
+ }
7732
9189
  if ("tagAliases" in obj) {
7733
9190
  if (typeof obj["tagAliases"] !== "object" || obj["tagAliases"] === null || Array.isArray(obj["tagAliases"])) errors.push("\"tagAliases\" must be an object.");
7734
9191
  else {
@@ -7744,6 +9201,20 @@ function validateConfig(data) {
7744
9201
  }
7745
9202
  return errors;
7746
9203
  }
9204
+ /**
9205
+ * Warnings for the keys of a config object that the config does not define (the JSON schema's
9206
+ * `additionalProperties: false`), other than `$schema`. twee-ts ignores such a key, so a misspelt one would
9207
+ * otherwise leave its option at the default without a word. A key that differs from a defined one only in
9208
+ * letter case, `-` or `_` (`formatID`, `output-mode`) gets a suggestion. Returns `[]` for anything that is not
9209
+ * an object, which {@link validateConfig} reports.
9210
+ */
9211
+ function unknownConfigKeyWarnings(data) {
9212
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return [];
9213
+ return Object.keys(data).filter((key) => key !== SCHEMA_KEY && !Object.hasOwn(CONFIG_KEY_SET, key)).map((key) => {
9214
+ const suggestion = CONFIG_KEYS.find((known) => foldKey(known) === foldKey(key));
9215
+ return `Unknown config key "${key}"${suggestion === void 0 ? "" : ` (did you mean "${suggestion}"?)`}; it is ignored.`;
9216
+ });
9217
+ }
7747
9218
  /** Return a default config JSON string for --init scaffolding. */
7748
9219
  function scaffoldConfig() {
7749
9220
  return JSON.stringify({
@@ -7786,6 +9257,7 @@ exports.scaffoldConfig = scaffoldConfig;
7786
9257
  exports.semverCompare = semverCompare;
7787
9258
  exports.storyInspect = storyInspect;
7788
9259
  exports.tweeLexer = tweeLexer;
9260
+ exports.unknownConfigKeyWarnings = unknownConfigKeyWarnings;
7789
9261
  exports.validateConfig = validateConfig;
7790
9262
  exports.validateIFID = validateIFID;
7791
9263
  exports.watch = watch;