@rohal12/twee-ts 1.17.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -36,11 +36,27 @@ let node_url = require("node:url");
36
36
  function generateIFID() {
37
37
  return (0, node_crypto.randomUUID)().toUpperCase();
38
38
  }
39
- /** Validate and brand an IFID string. Throws on invalid input. */
39
+ /**
40
+ * Validate and brand an IFID string. Throws on invalid input.
41
+ * Returns the IFID in its stored form (see `normalizeIFID`).
42
+ */
40
43
  function createIFID(value) {
41
44
  const err = validateIFID(value);
42
45
  if (err) throw new Error(`Invalid IFID: ${err}`);
43
- return value.toUpperCase();
46
+ return normalizeIFID(value);
47
+ }
48
+ /** Length of the `UUID://...//` wrapper around a 36-character UUID. */
49
+ const WRAPPED_LENGTH = 45;
50
+ /**
51
+ * Bring an IFID into the form the story model stores and every output writes: the uppercase
52
+ * bare UUID. A valid `UUID://...//` wrapped IFID loses its wrapper; outputs that need the wrapper
53
+ * (the Treaty of Babel comment) add exactly one. Twine 2 IFIDs use only capital letters, so the
54
+ * value is uppercased, as Tweego does. An invalid value is only uppercased, so that validation
55
+ * reports it as written.
56
+ */
57
+ function normalizeIFID(value) {
58
+ const upper = value.toUpperCase();
59
+ return upper.length === WRAPPED_LENGTH && validateIFID(upper) === null ? upper.slice(7, 43) : upper;
44
60
  }
45
61
  /**
46
62
  * Validate an IFID string.
@@ -120,18 +136,44 @@ function tweeEscape(s) {
120
136
  if (s.length === 0) return s;
121
137
  return s.replace(/[\\[\]{}]/g, (ch) => "\\" + ch);
122
138
  }
123
- /** 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
+ */
124
157
  function jsStringEscape(s) {
125
158
  if (s.length === 0) return s;
126
- 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)} `);
127
169
  }
128
170
  /** Sanitize a string for safe inclusion in a CSS or JS block comment. */
129
171
  function commentSanitize(s) {
130
172
  return s.replace(/\*\//g, "* /");
131
173
  }
132
- /** 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. */
133
175
  function htmlCommentSanitize(s) {
134
- return s.replace(/-->/g, "-- >");
176
+ return s.replace(/--(!?)>/g, "--$1 >");
135
177
  }
136
178
  /**
137
179
  * Escape JavaScript for the content of an HTML `<script>` element, so that the HTML parser reads all of it as
@@ -311,7 +353,19 @@ function passageToPassagedata(p, pid, options) {
311
353
  if (options?.sourceInfo && p.source) attrs += ` data-source-file=${quote(attrEscape(p.source.file))} data-source-line="${p.source.line}"`;
312
354
  return `${attrs}>${htmlEscape(p.text)}</tw-passagedata>`;
313
355
  }
314
- /** 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
+ */
315
369
  function passageToTiddler(p, pid, obfuscateRot13 = false) {
316
370
  let position;
317
371
  if (hasMetadataPosition(p)) position = p.metadata?.position ?? "";
@@ -323,9 +377,10 @@ function passageToTiddler(p, pid, obfuscateRot13 = false) {
323
377
  position = `${xp * 140 - 130},${yp * 140 - 130}`;
324
378
  }
325
379
  const created = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 12);
326
- const content = obfuscateRot13 && p.name !== "StorySettings" ? tiddlerEscape(rot13(p.text)) : tiddlerEscape(p.text);
327
- const nameComment = obfuscateRot13 ? `<!-- ${commentSanitize(p.name)} -->` : "";
328
- 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>`;
329
384
  }
330
385
  /**
331
386
  * Count words in a passage.
@@ -378,19 +433,136 @@ function quote(s) {
378
433
  }
379
434
  //#endregion
380
435
  //#region src/story.ts
381
- /** Module-level index: maps passage names to their array indices for O(1) lookups. */
382
436
  const passageIndex = /* @__PURE__ */ new WeakMap();
383
- function getIndex(story) {
384
- let index = passageIndex.get(story);
385
- if (!index) {
386
- index = /* @__PURE__ */ new Map();
387
- for (let i = 0; i < story.passages.length; i++) {
388
- const passage = story.passages[i];
389
- 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;
390
523
  }
391
- passageIndex.set(story, index);
524
+ default: throw new Error(`unhandled generated name: ${JSON.stringify(origin)}`);
392
525
  }
393
- 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
+ };
394
566
  }
395
567
  function createStory() {
396
568
  const story = {
@@ -399,52 +571,37 @@ function createStory() {
399
571
  passages: [],
400
572
  legacyIFID: "",
401
573
  twine1: { settings: /* @__PURE__ */ new Map() },
402
- twine2: {
403
- format: "",
404
- formatVersion: "",
405
- options: /* @__PURE__ */ new Map(),
406
- start: "",
407
- tags: "",
408
- tagColors: /* @__PURE__ */ new Map(),
409
- zoom: 1
410
- }
574
+ twine2: defaultTwine2Metadata()
411
575
  };
412
- passageIndex.set(story, /* @__PURE__ */ new Map());
576
+ buildIndex(story);
413
577
  return story;
414
578
  }
415
579
  function storyHas(story, name) {
416
- return getIndex(story).has(name);
580
+ return position(story, name) !== -1;
417
581
  }
418
582
  function storyAppend(story, p, diagnostics) {
419
- const index = getIndex(story);
420
- const i = index.get(p.name) ?? -1;
421
- if (i === -1) {
422
- index.set(p.name, story.passages.length);
423
- story.passages.push(p);
424
- } else {
583
+ const i = position(story, p.name);
584
+ if (i === -1) push(story, p);
585
+ else {
425
586
  diagnostics.push({
426
587
  level: "warning",
427
588
  message: `Replacing existing passage "${p.name}" with duplicate.`
428
589
  });
429
- story.passages[i] = p;
590
+ replaceAt(story, i, p);
430
591
  }
431
592
  }
432
593
  function storyPrepend(story, p, diagnostics) {
433
- const index = getIndex(story);
434
- const i = index.get(p.name) ?? -1;
594
+ moveGeneratedAside(story, p.name, diagnostics);
595
+ const i = position(story, p.name);
435
596
  if (i === -1) {
436
597
  story.passages.unshift(p);
437
- index.clear();
438
- for (let j = 0; j < story.passages.length; j++) {
439
- const passage = story.passages[j];
440
- if (passage) index.set(passage.name, j);
441
- }
598
+ buildIndex(story);
442
599
  } else {
443
600
  diagnostics.push({
444
601
  level: "warning",
445
602
  message: `Replacing existing passage "${p.name}" with duplicate.`
446
603
  });
447
- story.passages[i] = p;
604
+ replaceAt(story, i, p);
448
605
  }
449
606
  }
450
607
  function marshalStoryData(story) {
@@ -470,18 +627,20 @@ function unmarshalStoryData(story, json) {
470
627
  }
471
628
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return "Cannot unmarshal \"StoryData\"; expected a JSON object";
472
629
  const data = raw;
473
- if (typeof data.ifid === "string" && data.ifid) story.ifid = data.ifid.toUpperCase();
474
- if (typeof data.format === "string" && data.format) story.twine2.format = data.format;
475
- 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"];
476
634
  if (Array.isArray(data.options)) {
477
- 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);
478
636
  }
479
- if (typeof data.start === "string" && data.start) story.twine2.start = data.start;
480
- 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;
481
639
  if (typeof data["tag-colors"] === "object" && data["tag-colors"] !== null && !Array.isArray(data["tag-colors"])) {
482
- 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);
483
641
  }
484
- 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;
485
644
  return null;
486
645
  }
487
646
  function unmarshalStorySettings(story, text, diagnostics) {
@@ -501,7 +660,7 @@ function unmarshalStorySettings(story, text, diagnostics) {
501
660
  const val = line.slice(colonIdx + 1).trim().toLowerCase();
502
661
  switch (key) {
503
662
  case "ifid":
504
- if (validateIFID(val) === null) story.legacyIFID = val.toUpperCase();
663
+ if (validateIFID(val) === null) story.legacyIFID = normalizeIFID(val);
505
664
  obsolete.push("\"ifid\"");
506
665
  continue;
507
666
  case "zoom":
@@ -522,8 +681,20 @@ function unmarshalStorySettings(story, text, diagnostics) {
522
681
  /**
523
682
  * Process a passage and add it to the story, handling special passages.
524
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.
525
690
  */
526
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);
527
698
  let processed = p;
528
699
  switch (p.name) {
529
700
  case "StoryIncludes":
@@ -583,7 +754,9 @@ function getStoryStats(story, wordCountMethod = "tweego") {
583
754
  }
584
755
  /**
585
756
  * Builder that separates the mutable construction phase from the immutable consumption phase.
586
- * 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.
587
760
  * After `build()`, the story is returned as `ReadonlyStory`.
588
761
  */
589
762
  var StoryBuilder = class {
@@ -594,10 +767,13 @@ var StoryBuilder = class {
594
767
  }
595
768
  /** Add a passage, handling special passages (StoryData, StoryTitle, etc.). */
596
769
  add(passage, diagnostics) {
770
+ if (generatedNames.has(passage) || !storyHas(this.story, passage.name)) storyVerifyIndex(this.story);
597
771
  storyAdd(this.story, passage, diagnostics);
598
772
  }
599
773
  /** Check if a passage name exists. */
600
774
  has(name) {
775
+ if (storyHas(this.story, name)) return true;
776
+ storyVerifyIndex(this.story);
601
777
  return storyHas(this.story, name);
602
778
  }
603
779
  /** Finalize and return the story as read-only. */
@@ -722,131 +898,579 @@ function isExcluded(filename, exclude) {
722
898
  const rel = (0, node_path.relative)(process.cwd(), (0, node_path.resolve)(filename)) || filename;
723
899
  return exclude.some((pattern) => matchesGlob(rel, pattern.replace(/^\.\//, "")));
724
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
+ }
725
968
  /**
726
969
  * Recursively walk directories, collecting regular file paths.
727
- * Filters out the output file to prevent circular compilation, and the files
728
- * 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
+ *
729
981
  * Like Tweego, a path that cannot be read is reported as a warning and skipped.
730
982
  */
731
- function getFilenames(pathnames, outFilename, exclude = []) {
983
+ function getFilenames(pathnames, outputs, exclude = []) {
732
984
  const filenames = [];
733
985
  const diagnostics = [];
734
- const absOutFile = outFilename ? (0, node_path.resolve)(outFilename) : "";
986
+ const outputSources = [];
987
+ const output = outputPaths(toBuildOutputs(outputs));
735
988
  function warn(pathname, e) {
736
989
  diagnostics.push({
737
990
  level: "warning",
738
991
  message: `path ${pathname}: ${e instanceof Error ? e.message : String(e)}`
739
992
  });
740
993
  }
741
- 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) {
742
1024
  let stat;
743
1025
  try {
744
1026
  stat = (0, node_fs.statSync)(pathname);
745
1027
  } catch (e) {
746
1028
  warn(pathname, e);
747
- return;
1029
+ continue;
748
1030
  }
1031
+ const real = realPathOf(pathname);
749
1032
  if (stat.isFile()) {
750
- const abs = (0, node_path.resolve)(pathname);
751
- if (abs === absOutFile) return;
752
- const rel = (0, node_path.relative)(process.cwd(), abs);
753
- if (isExcluded(rel || abs, exclude)) return;
754
- filenames.push(rel || abs);
755
- } else if (stat.isDirectory()) {
756
- let entries;
757
- try {
758
- entries = (0, node_fs.readdirSync)(pathname);
759
- } catch (e) {
760
- warn(pathname, e);
761
- return;
762
- }
763
- for (const entry of entries) walk((0, node_path.join)(pathname, entry));
764
- }
1033
+ if (output.isFile(real)) outputSources.push(pathname);
1034
+ else addFile(pathname);
1035
+ } else if (stat.isDirectory()) walkDir(pathname, real);
765
1036
  }
766
- for (const pathname of pathnames) walk(pathname);
767
1037
  return {
768
1038
  filenames,
769
- diagnostics
1039
+ diagnostics,
1040
+ outputSources
770
1041
  };
771
1042
  }
772
- /** Whether the absolute path `file` is the absolute path `dir` or inside it. */
773
- function isInsideDir(file, dir) {
774
- return file === dir || file.startsWith(dir.endsWith(node_path.sep) ? dir : dir + node_path.sep);
775
- }
776
1043
  /**
777
- * The watched paths split into folders (as given) and files (absolute). A path
778
- * 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.
779
1046
  */
780
- function splitWatchRoots(pathnames) {
781
- const dirs = [];
782
- const files = /* @__PURE__ */ new Set();
783
- for (const pathname of pathnames) {
784
- let isFile = false;
785
- try {
786
- isFile = (0, node_fs.statSync)(pathname).isFile();
787
- } catch {}
788
- if (isFile) files.add((0, node_path.resolve)(pathname));
789
- 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";
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" };
790
1064
  }
1065
+ }
1066
+ function rootState(abs) {
791
1067
  return {
792
- dirs,
793
- files
1068
+ anchor: nearestFolderAbove(abs),
1069
+ self: pathKind(abs)
794
1070
  };
795
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
+ }
796
1086
  /**
797
1087
  * Watch paths for changes, calling the build callback on known file type changes.
798
1088
  * A folder is watched recursively; a file is watched on its own and counts
799
1089
  * whatever its type (a head file, say). A change to a file `ignore` returns true
800
1090
  * for (given the path relative to the working directory) schedules no build.
801
1091
  * Uses debouncing to avoid rapid rebuilds.
802
- */
803
- function watchFilesystem(pathnames, outFilename, callback, ignore = () => false) {
804
- const absOutFile = (0, node_path.resolve)(outFilename);
805
- 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));
806
1102
  let buildTimer = null;
807
1103
  const BUILD_DEBOUNCE = 500;
808
1104
  const pendingFiles = /* @__PURE__ */ new Set();
1105
+ let pendingFullBuild = false;
1106
+ let closed = false;
809
1107
  function scheduleBuild(changedFile) {
810
- if (changedFile) pendingFiles.add(changedFile);
1108
+ if (changedFile === void 0) pendingFullBuild = true;
1109
+ else pendingFiles.add(changedFile);
811
1110
  if (buildTimer) clearTimeout(buildTimer);
812
1111
  buildTimer = setTimeout(() => {
813
1112
  buildTimer = null;
814
- const files = pendingFiles.size > 0 ? new Set(pendingFiles) : void 0;
1113
+ const files = pendingFullBuild || pendingFiles.size === 0 ? void 0 : new Set(pendingFiles);
815
1114
  pendingFiles.clear();
1115
+ pendingFullBuild = false;
816
1116
  callback(files);
817
1117
  }, BUILD_DEBOUNCE);
818
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");
819
1131
  function fileChanged(abs, named) {
820
- if (abs === absOutFile) return;
821
1132
  if (!named && !isKnownFileType(abs)) return;
1133
+ if (output.isFile(realPathOf(abs))) return;
822
1134
  const rel = (0, node_path.relative)(process.cwd(), abs);
823
1135
  if (!ignore(rel || abs)) scheduleBuild(rel || abs);
824
1136
  }
825
- 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) {
826
1144
  try {
827
- watchers.push((0, node_fs.watch)(pathname, { recursive }, (_event, filename) => {
828
- if (filename) listener(filename);
829
- }));
830
- } 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;
831
1179
  }
832
- const { dirs, files } = splitWatchRoots(pathnames);
833
- const absDirs = dirs.map((dir) => (0, node_path.resolve)(dir));
834
- for (const dir of dirs) watchPath(dir, true, (filename) => {
835
- const abs = (0, node_path.resolve)(dir, filename);
836
- fileChanged(abs, files.has(abs));
837
- });
838
- const fileDirs = new Set([...files].filter((file) => !absDirs.some((dir) => isInsideDir(file, dir))).map((file) => (0, node_path.dirname)(file)));
839
- for (const dir of fileDirs) watchPath(dir, false, (filename) => {
840
- const abs = (0, node_path.resolve)(dir, filename);
841
- if (files.has(abs)) fileChanged(abs, true);
842
- });
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);
843
1201
  callback();
844
1202
  return { close() {
1203
+ closed = true;
845
1204
  if (buildTimer) clearTimeout(buildTimer);
846
- for (const w of watchers) w.close();
1205
+ for (const root of roots) for (const w of root.watchers) w.close();
847
1206
  } };
848
1207
  }
849
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
850
1474
  //#region src/source-text.ts
851
1475
  /**
852
1476
  * Source text normalization shared by every input path (files, in-memory sources, `parseTwee`).
@@ -858,14 +1482,119 @@ function watchFilesystem(pathnames, outFilename, callback, ignore = () => false)
858
1482
  function normalizeSourceText(text) {
859
1483
  return (text.charCodeAt(0) === 65279 ? text.slice(1) : text).replace(/\r\n?/g, "\n");
860
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
+ }
861
1496
  //#endregion
862
1497
  //#region src/util.ts
863
1498
  /**
864
1499
  * Shared file I/O utilities.
865
1500
  */
866
- /** Read a file as UTF-8 with BOM stripping and line ending normalization. */
867
- function readUTF8(filename) {
868
- 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);
869
1598
  }
870
1599
  /** Read a file as base64. */
871
1600
  function readBase64(filename) {
@@ -881,20 +1610,11 @@ function baseNameWithoutExt(filename) {
881
1610
  /**
882
1611
  * Story format discovery, loading, and SemVer matching.
883
1612
  * Ported from formats.go + config.go.
1613
+ *
1614
+ * Every lookup (local folders here, the download cache and remote indices in remote-formats.ts)
1615
+ * goes through the same version parsing ({@link parseVersion}) and the same matching rules
1616
+ * ({@link selectFormatCandidate}).
884
1617
  */
885
- /**
886
- * Attempt to fix non-strict JSON (trailing commas, single quotes, unquoted keys).
887
- */
888
- function relaxedJSONParse(json) {
889
- try {
890
- return JSON.parse(json);
891
- } catch {}
892
- let fixed = json;
893
- fixed = fixed.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, "\"$1\"");
894
- fixed = fixed.replace(/(?<=^|[{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:/gm, "\"$1\":");
895
- fixed = fixed.replace(/,\s*([}\]])/g, "$1");
896
- return JSON.parse(fixed);
897
- }
898
1618
  /** A `"setup": function` property, which Harlowe appends to its otherwise-JSON format object. */
899
1619
  const SETUP_FUNCTION_PROPERTY = /,\s*"setup"\s*:\s*function\b/g;
900
1620
  /** Drop a trailing `"setup": function(){…}` property from a format object, if it has one. */
@@ -904,151 +1624,247 @@ function stripSetupFunction(chunk) {
904
1624
  return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
905
1625
  }
906
1626
  /**
907
- * Parse the Twine 2 format.js JSON chunk.
908
- * Handles Harlowe's malformed JSON by stripping the "setup" function property. The workaround
909
- * keys on the property itself, so the same bytes parse whether they come from a `harlowe-3`
910
- * directory or a direct download.
911
- * Per spec, the name key is Optional.
1627
+ * Parse the format object at `start`–`end` of `text`: strict JSON first (fast), then the
1628
+ * JavaScript literal subset formats use. Error positions point into `text`.
1629
+ */
1630
+ function parseFormatObject(text, start, end) {
1631
+ try {
1632
+ return {
1633
+ ok: true,
1634
+ value: JSON.parse(text.slice(start, end))
1635
+ };
1636
+ } catch {}
1637
+ try {
1638
+ return {
1639
+ ok: true,
1640
+ value: parseRelaxedJSON(text, start, end)
1641
+ };
1642
+ } catch (e) {
1643
+ return {
1644
+ ok: false,
1645
+ error: e instanceof Error ? e.message : String(e)
1646
+ };
1647
+ }
1648
+ }
1649
+ /** Check a parsed format object's fields. Per spec, `name` is optional; `version` and `source` are required. */
1650
+ function toFormatJSON(raw) {
1651
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
1652
+ ok: false,
1653
+ reason: "Story format JSON chunk is not an object."
1654
+ };
1655
+ const obj = raw;
1656
+ if (typeof obj.version !== "string") return {
1657
+ ok: false,
1658
+ reason: "Story format has no \"version\" string."
1659
+ };
1660
+ if (typeof obj.source !== "string") return {
1661
+ ok: false,
1662
+ reason: "Story format has no \"source\" string."
1663
+ };
1664
+ if (!parseVersion(obj.version)) return {
1665
+ ok: false,
1666
+ reason: `Story format version ${JSON.stringify(obj.version)} is not a SemVer version.`
1667
+ };
1668
+ const data = {
1669
+ name: typeof obj.name === "string" ? obj.name : "Untitled Story Format",
1670
+ version: obj.version,
1671
+ source: obj.source,
1672
+ proofing: obj.proofing === true
1673
+ };
1674
+ if (typeof obj.author === "string") data.author = obj.author;
1675
+ if (typeof obj.description === "string") data.description = obj.description;
1676
+ if (typeof obj.image === "string") data.image = obj.image;
1677
+ if (typeof obj.url === "string") data.url = obj.url;
1678
+ if (typeof obj.license === "string") data.license = obj.license;
1679
+ return {
1680
+ ok: true,
1681
+ data
1682
+ };
1683
+ }
1684
+ /**
1685
+ * Read the metadata of a Twine 2 format.js, or say why it cannot be used.
912
1686
  *
913
- * @param _formatId No longer used; kept so existing callers keep compiling.
1687
+ * The object passed to `window.storyFormat()` may be strict JSON or a JavaScript object literal
1688
+ * (single quotes, unquoted keys, trailing commas, comments). String values come back exactly as
1689
+ * JavaScript evaluation gives them. Harlowe's function-valued `setup` property is dropped; the
1690
+ * workaround keys on the property itself, so the same bytes parse whether they come from a
1691
+ * `harlowe-3` directory or a direct download.
914
1692
  */
915
- function parseFormatJSON(source, _formatId) {
1693
+ function decodeFormatJSON(source) {
916
1694
  const first = source.indexOf("{");
917
1695
  const last = source.lastIndexOf("}");
918
- if (first === -1 || last === -1) return null;
919
- const chunk = source.slice(first, last + 1);
920
- const parse = (json) => {
921
- const raw = relaxedJSONParse(json);
922
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
923
- const obj = raw;
924
- if (typeof obj.version !== "string" || typeof obj.source !== "string") return null;
925
- if (!parseSemver(obj.version)) return null;
926
- const result = {
927
- name: typeof obj.name === "string" ? obj.name : "Untitled Story Format",
928
- version: obj.version,
929
- source: obj.source,
930
- proofing: obj.proofing === true
931
- };
932
- if (typeof obj.author === "string") result.author = obj.author;
933
- if (typeof obj.description === "string") result.description = obj.description;
934
- if (typeof obj.image === "string") result.image = obj.image;
935
- if (typeof obj.url === "string") result.url = obj.url;
936
- if (typeof obj.license === "string") result.license = obj.license;
937
- return result;
1696
+ if (first === -1 || last < first) return {
1697
+ ok: false,
1698
+ reason: "Could not find Twine 2 style story format JSON chunk."
938
1699
  };
1700
+ const parsed = parseFormatObject(source, first, last + 1);
1701
+ if (parsed.ok) return toFormatJSON(parsed.value);
1702
+ const stripped = stripSetupFunction(source.slice(first, last + 1));
1703
+ const retried = stripped === void 0 ? void 0 : parseFormatObject(stripped, 0, stripped.length);
1704
+ if (retried?.ok) return toFormatJSON(retried.value);
1705
+ return {
1706
+ ok: false,
1707
+ reason: `Could not decode story format JSON chunk: ${parsed.error}`
1708
+ };
1709
+ }
1710
+ /**
1711
+ * Parse the Twine 2 format.js JSON chunk; null when it cannot be used ({@link decodeFormatJSON}
1712
+ * gives the reason).
1713
+ *
1714
+ * @param _formatId No longer used; kept so existing callers keep compiling.
1715
+ */
1716
+ function parseFormatJSON(source, _formatId) {
1717
+ const result = decodeFormatJSON(source);
1718
+ return result.ok ? result.data : null;
1719
+ }
1720
+ /** A directory's entries in a stable order, or none when it cannot be read. */
1721
+ function listDirectory(dir) {
939
1722
  try {
940
- return parse(chunk);
1723
+ return (0, node_fs.readdirSync)(dir).sort();
941
1724
  } catch {
942
- const stripped = stripSetupFunction(chunk);
943
- if (stripped === void 0) return null;
944
- try {
945
- return parse(stripped);
946
- } catch {
947
- return null;
948
- }
1725
+ return [];
1726
+ }
1727
+ }
1728
+ function isDirectory(path) {
1729
+ try {
1730
+ return (0, node_fs.statSync)(path).isDirectory();
1731
+ } catch {
1732
+ return false;
1733
+ }
1734
+ }
1735
+ function isFile(path) {
1736
+ try {
1737
+ return (0, node_fs.statSync)(path).isFile();
1738
+ } catch {
1739
+ return false;
949
1740
  }
950
1741
  }
951
1742
  /**
952
- * Discover all story formats in the given search directories.
953
- * Implements SemVer-based version pruning: within each (name, major) group,
954
- * only the highest minor.patch survives.
1743
+ * Load the format in one folder: its format.js (Twine 2), else its header.html (Twine 1).
1744
+ * A format.js that cannot be used is reported as a warning, as Tweego does.
955
1745
  */
956
- function discoverFormats(searchDirs) {
957
- const formats = /* @__PURE__ */ new Map();
958
- const baseFilenames = ["format.js", "header.html"];
959
- for (const searchDir of searchDirs) {
1746
+ function loadFormatDir(searchDir, id, diagnostics) {
1747
+ const formatDir = (0, node_path.join)(searchDir, id);
1748
+ if (!isDirectory(formatDir)) return void 0;
1749
+ for (const baseFilename of ["format.js", "header.html"]) {
1750
+ const filename = (0, node_path.join)(formatDir, baseFilename);
1751
+ if (!isFile(filename)) continue;
1752
+ if (baseFilename === "header.html") return {
1753
+ id,
1754
+ filename,
1755
+ isTwine2: false,
1756
+ name: id,
1757
+ version: "",
1758
+ proofing: false
1759
+ };
1760
+ const encoding = [];
1761
+ let decoded;
960
1762
  try {
961
- if (!(0, node_fs.statSync)(searchDir).isDirectory()) continue;
962
- } catch {
963
- continue;
1763
+ decoded = decodeFormatJSON(readUTF8(filename, encoding));
1764
+ } catch (e) {
1765
+ decoded = {
1766
+ ok: false,
1767
+ reason: `Could not read ${filename}: ${e instanceof Error ? e.message : String(e)}`
1768
+ };
964
1769
  }
965
- let entries;
966
- try {
967
- entries = (0, node_fs.readdirSync)(searchDir);
968
- } catch {
1770
+ if (!decoded.ok) {
1771
+ diagnostics?.push(...encoding);
1772
+ diagnostics?.push({
1773
+ level: "warning",
1774
+ message: `format ${id}: Skipping format; ${decoded.reason} (${filename})`
1775
+ });
969
1776
  continue;
970
1777
  }
971
- for (const entry of entries) {
972
- const formatDir = (0, node_path.join)(searchDir, entry);
973
- try {
974
- if (!(0, node_fs.statSync)(formatDir).isDirectory()) continue;
975
- } catch {
976
- continue;
977
- }
978
- for (const baseFilename of baseFilenames) {
979
- const formatFilename = (0, node_path.join)(formatDir, baseFilename);
980
- try {
981
- if (!(0, node_fs.statSync)(formatFilename).isFile()) continue;
982
- } catch {
983
- continue;
984
- }
985
- const isTwine2 = baseFilename === "format.js";
986
- const format = {
987
- id: entry,
988
- filename: formatFilename,
989
- isTwine2,
990
- name: "",
991
- version: "",
992
- proofing: false
993
- };
994
- if (isTwine2) try {
995
- const data = parseFormatJSON(readUTF8(formatFilename), entry);
996
- if (!data) continue;
997
- format.name = data.name;
998
- format.version = data.version;
999
- format.proofing = data.proofing ?? false;
1000
- if (data.author) format.author = data.author;
1001
- if (data.description) format.description = data.description;
1002
- if (data.image) format.image = data.image;
1003
- if (data.url) format.url = data.url;
1004
- if (data.license) format.license = data.license;
1005
- } catch {
1006
- continue;
1007
- }
1008
- else format.name = entry;
1009
- formats.set(entry, format);
1010
- break;
1011
- }
1012
- }
1778
+ const { data } = decoded;
1779
+ const format = {
1780
+ id,
1781
+ filename,
1782
+ isTwine2: true,
1783
+ name: data.name,
1784
+ version: data.version,
1785
+ proofing: data.proofing ?? false
1786
+ };
1787
+ if (data.author) format.author = data.author;
1788
+ if (data.description) format.description = data.description;
1789
+ if (data.image) format.image = data.image;
1790
+ if (data.url) format.url = data.url;
1791
+ if (data.license) format.license = data.license;
1792
+ return format;
1793
+ }
1794
+ }
1795
+ /**
1796
+ * Discover every story format in the search directories, keyed by folder name (the format ID),
1797
+ * without version pruning.
1798
+ *
1799
+ * Directories are searched in order, and a later directory outranks an earlier one: when two hold
1800
+ * the same folder name, the later one's format is kept. The map iterates in that order too, lowest
1801
+ * rank first. Formats that cannot be used are left out, with a warning in `diagnostics`.
1802
+ */
1803
+ function discoverAllFormats(searchDirs, diagnostics) {
1804
+ const formats = /* @__PURE__ */ new Map();
1805
+ for (const searchDir of searchDirs) for (const entry of listDirectory(searchDir)) {
1806
+ const format = loadFormatDir(searchDir, entry, diagnostics);
1807
+ if (!format) continue;
1808
+ formats.delete(entry);
1809
+ formats.set(entry, format);
1013
1810
  }
1014
- pruneFormats(formats);
1015
1811
  return formats;
1016
1812
  }
1813
+ /** The version a format is pruned and selected by, or null for one outside SemVer pruning. */
1814
+ function prunableVersion(format) {
1815
+ return format.isTwine2 && format.name ? parseVersion(format.version) : null;
1816
+ }
1817
+ /** The search directory a discovered format was found in. */
1818
+ function searchDirOf(format) {
1819
+ return (0, node_path.dirname)((0, node_path.dirname)(format.filename));
1820
+ }
1017
1821
  /**
1018
- * Prune formats by SemVer: within each (name, major) group,
1019
- * keep only the highest version. First-found wins for same name+version.
1822
+ * Prune formats by SemVer: within each (name, major) group, keep only the version with the highest
1823
+ * precedence (a release outranks its prereleases). Between equal versions, the folder in the
1824
+ * higher-ranked search directory wins, and within one directory the first folder name in sort
1825
+ * order. Returns a new map in the same order.
1826
+ *
1827
+ * Pruning serves name-based selection and listings. An explicit format ID is looked up in the
1828
+ * unpruned map, so a pinned folder is used even when another folder holds a newer version.
1020
1829
  */
1021
1830
  function pruneFormats(formats) {
1022
- const bestByNameMajor = /* @__PURE__ */ new Map();
1023
- const seenNameVersion = /* @__PURE__ */ new Set();
1024
- for (const [id, f] of formats) {
1025
- if (!f.isTwine2 || !f.name || !f.version) continue;
1026
- const v = parseSemver(f.version);
1027
- if (!v) continue;
1028
- const nameVersionKey = `${f.name}@${f.version}`;
1029
- if (seenNameVersion.has(nameVersionKey)) {
1030
- formats.delete(id);
1031
- continue;
1032
- }
1033
- seenNameVersion.add(nameVersionKey);
1034
- const groupKey = `${f.name}@${v[0]}`;
1035
- const existing = bestByNameMajor.get(groupKey);
1036
- if (!existing || semverCompare(v, existing.version) > 0) bestByNameMajor.set(groupKey, {
1037
- id,
1038
- version: v
1831
+ const dirRank = /* @__PURE__ */ new Map();
1832
+ for (const format of formats.values()) {
1833
+ const dir = searchDirOf(format);
1834
+ if (!dirRank.has(dir)) dirRank.set(dir, dirRank.size);
1835
+ }
1836
+ const rankOf = (format) => dirRank.get(searchDirOf(format)) ?? 0;
1837
+ const best = /* @__PURE__ */ new Map();
1838
+ for (const format of formats.values()) {
1839
+ const version = prunableVersion(format);
1840
+ if (!version) continue;
1841
+ const group = `${format.name}@${version.major}`;
1842
+ const current = best.get(group);
1843
+ const order = current ? compareVersions(version, current.version) : 1;
1844
+ if (order > 0 || order === 0 && current && rankOf(format) > rankOf(current.format)) best.set(group, {
1845
+ format,
1846
+ version
1039
1847
  });
1040
1848
  }
1041
- for (const [id, f] of [...formats]) {
1042
- if (!f.isTwine2 || !f.name || !f.version) continue;
1043
- const v = parseSemver(f.version);
1044
- if (!v) continue;
1045
- const groupKey = `${f.name}@${v[0]}`;
1046
- const best = bestByNameMajor.get(groupKey);
1047
- if (best && best.id !== id) formats.delete(id);
1048
- }
1849
+ return new Map([...formats].filter(([id, format]) => {
1850
+ const version = prunableVersion(format);
1851
+ return version === null || best.get(`${format.name}@${version.major}`)?.format.id === id;
1852
+ }));
1049
1853
  }
1050
1854
  /**
1051
- * Get the default format search directories.
1855
+ * Discover all story formats in the given search directories, pruned by SemVer: within each
1856
+ * (name, major) group only the highest version survives. See {@link discoverAllFormats} for the
1857
+ * directory ranking and {@link pruneFormats} for pruning.
1858
+ */
1859
+ function discoverFormats(searchDirs) {
1860
+ return pruneFormats(discoverAllFormats(searchDirs));
1861
+ }
1862
+ /**
1863
+ * Get the format search directories, lowest rank first: the story format subdirectories of the
1864
+ * home directory, then of the working directory, then each `TWEEGO_PATH` entry, then `extraPaths`
1865
+ * (`formatPaths`). A later directory outranks an earlier one for the same format folder name, so
1866
+ * `formatPaths` override `TWEEGO_PATH`, which overrides the home and working directories, as in
1867
+ * Tweego.
1052
1868
  */
1053
1869
  function getFormatSearchDirs(extraPaths = [], useTweegoPath = true) {
1054
1870
  const subdirNames = [
@@ -1063,30 +1879,25 @@ function getFormatSearchDirs(extraPaths = [], useTweegoPath = true) {
1063
1879
  basePaths.add((0, node_os.homedir)());
1064
1880
  } catch {}
1065
1881
  basePaths.add(process.cwd());
1066
- const dirs = [];
1067
- for (const base of basePaths) for (const sub of subdirNames) {
1068
- const dir = (0, node_path.join)(base, sub);
1069
- try {
1070
- if ((0, node_fs.statSync)(dir).isDirectory()) dirs.push(dir);
1071
- } catch {}
1072
- }
1882
+ const dirs = [...basePaths].flatMap((base) => subdirNames.map((sub) => (0, node_path.join)(base, sub))).filter(isDirectory);
1883
+ const tweegoPath = useTweegoPath ? process.env.TWEEGO_PATH : void 0;
1884
+ if (tweegoPath) dirs.push(...tweegoPath.split(process.platform === "win32" ? ";" : ":"));
1073
1885
  dirs.push(...extraPaths);
1074
- if (useTweegoPath) {
1075
- const tweegoPath = process.env.TWEEGO_PATH;
1076
- if (tweegoPath) dirs.push(...tweegoPath.split(process.platform === "win32" ? ";" : ":"));
1077
- }
1078
1886
  return dirs;
1079
1887
  }
1080
- /** Parse a simple semver string into [major, minor, patch]. */
1888
+ /**
1889
+ * Parse a version string into [major, minor, patch]. Accepts what {@link parseVersion} accepts
1890
+ * (`v1.2.3`, `1.2`, `2.0.0-beta.1`); the prerelease and build metadata are dropped.
1891
+ */
1081
1892
  function parseSemver(v) {
1082
- const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
1083
- if (!m) return null;
1084
- return [
1085
- Number(m[1]),
1086
- Number(m[2]),
1087
- Number(m[3])
1088
- ];
1089
- }
1893
+ const parsed = parseVersion(v);
1894
+ return parsed ? [
1895
+ parsed.major,
1896
+ parsed.minor,
1897
+ parsed.patch
1898
+ ] : null;
1899
+ }
1900
+ /** Compare [major, minor, patch] tuples. Use {@link compareVersions} to take prereleases into account. */
1090
1901
  function semverCompare(a, b) {
1091
1902
  const [aMajor, aMinor, aPatch] = a;
1092
1903
  const [bMajor, bMinor, bPatch] = b;
@@ -1097,7 +1908,7 @@ function semverCompare(a, b) {
1097
1908
  }
1098
1909
  /** Build a directory-style format ID from a name and version, e.g. ('SugarCube', '2.37.3') → 'sugarcube-2'. */
1099
1910
  function makeFormatId(name, version) {
1100
- const major = parseSemver(version)?.[0] ?? 0;
1911
+ const major = parseVersion(version)?.major ?? 0;
1101
1912
  return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
1102
1913
  }
1103
1914
  /** Describe a format request for diagnostics. */
@@ -1108,81 +1919,164 @@ function describeFormatRequest(request) {
1108
1919
  default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
1109
1920
  }
1110
1921
  }
1111
- /**
1112
- * Pick the candidate that best answers a format request.
1113
- *
1114
- * - An ID request ('sugarcube-2') matches a candidate whose name and major version build that ID,
1115
- * and takes the greatest such version.
1116
- * - A name request matches the name case-insensitively. An exact version wins outright; otherwise
1117
- * the greatest version with the same major that is not older than the one asked for (or, with
1118
- * `allowOlder`, the greatest same-major version). An unparseable requested version takes the
1119
- * greatest version of any major.
1120
- */
1121
- function selectFormatCandidate(request, candidates, describe, options = {}) {
1122
- const allowOlder = options.allowOlder ?? false;
1123
- const wanted = request.kind === "name" ? parseSemver(request.version) : null;
1922
+ /** {@link selectFormatCandidate} over one set of candidates, matching names without regard to case. */
1923
+ function pickFormatCandidate(request, candidates, describe, allowOlder) {
1924
+ const wanted = request.kind === "name" ? parseVersion(request.version) : null;
1124
1925
  let best;
1125
1926
  for (const candidate of candidates) {
1126
1927
  const { name, version } = describe(candidate);
1127
- const have = parseSemver(version);
1928
+ const have = parseVersion(version);
1128
1929
  if (!have) continue;
1129
1930
  if (request.kind === "id") {
1130
1931
  if (makeFormatId(name, version) !== request.id.toLowerCase()) continue;
1131
1932
  } else {
1132
1933
  if (name.toLowerCase() !== request.name.toLowerCase()) continue;
1133
1934
  if (wanted) {
1134
- if (semverCompare(have, wanted) === 0) return candidate;
1135
- if (have[0] !== wanted[0]) continue;
1136
- if (!allowOlder && semverCompare(have, wanted) < 0) continue;
1935
+ const order = compareVersions(have, wanted);
1936
+ if (order === 0) return candidate;
1937
+ if (have.major !== wanted.major) continue;
1938
+ if (!allowOlder && order < 0) continue;
1137
1939
  }
1138
1940
  }
1139
- if (!best || semverCompare(have, best.version) > 0) best = {
1941
+ if (!best || compareVersions(have, best.version) > 0) best = {
1140
1942
  candidate,
1141
1943
  version: have
1142
1944
  };
1143
1945
  }
1144
1946
  return best?.candidate;
1145
1947
  }
1146
- /** Get format ID from Twine 2 name and version, using SemVer major matching. */
1147
- function getFormatIdByNameAndVersion(formats, name, version) {
1148
- const wanted = parseSemver(version);
1149
- let bestVersion = null;
1150
- let bestId;
1151
- for (const [id, f] of formats) {
1152
- if (!f.isTwine2 || f.name !== name) continue;
1153
- const have = parseSemver(f.version);
1154
- if (!have) continue;
1155
- if (wanted === null || have[0] === wanted[0] && semverCompare(have, wanted) >= 0) {
1156
- if (!bestVersion || semverCompare(have, bestVersion) > 0) {
1157
- bestVersion = have;
1158
- bestId = id;
1159
- }
1160
- }
1948
+ /**
1949
+ * Pick the candidate that best answers a format request. Local folders, the download cache and
1950
+ * remote indices all select through this one function.
1951
+ *
1952
+ * - An ID request ('sugarcube-2') matches, without regard to case, a candidate whose name and
1953
+ * major version build that ID, and takes the greatest such version.
1954
+ * - A name request matches the name without regard to case, but a candidate whose name matches in
1955
+ * case too is preferred when one answers the request. An exact version (prerelease included)
1956
+ * wins outright; otherwise the greatest version with the same major that is not older than the
1957
+ * one asked for (or, with `allowOlder`, the greatest same-major version). An unparseable
1958
+ * requested version takes the greatest version of any major.
1959
+ *
1960
+ * Versions compare by SemVer precedence ({@link compareVersions}), so a release outranks its
1961
+ * prereleases. Between equal versions, the earlier candidate wins.
1962
+ */
1963
+ function selectFormatCandidate(request, candidates, describe, options = {}) {
1964
+ const allowOlder = options.allowOlder ?? false;
1965
+ if (request.kind === "name") {
1966
+ const preferred = pickFormatCandidate(request, candidates.filter((c) => describe(c).name === request.name), describe, allowOlder);
1967
+ if (preferred !== void 0) return preferred;
1161
1968
  }
1162
- return bestId;
1969
+ return pickFormatCandidate(request, candidates, describe, allowOlder);
1970
+ }
1971
+ /** The Twine 2 formats of a discovered map, those from higher-ranked search directories first. */
1972
+ function rankedTwine2Formats(formats) {
1973
+ return [...formats.values()].filter((f) => f.isTwine2).reverse();
1163
1974
  }
1164
- /** Read the story format source (for Twine 2, extract the `source` property from JSON). */
1165
- function readFormatSource(format) {
1166
- const source = readUTF8(format.filename);
1167
- if (format.isTwine2) {
1168
- const data = parseFormatJSON(source, format.id);
1169
- if (!data) throw new Error(`Cannot parse format ${format.id} JSON`);
1170
- return data.source;
1975
+ /**
1976
+ * Look a format ID up among discovered folders. The ID matches a folder name without regard to
1977
+ * case; an exact-case folder is preferred, then the highest-ranked one.
1978
+ */
1979
+ function findFormatById(formats, id) {
1980
+ const exact = formats.get(id);
1981
+ if (exact) return exact;
1982
+ const wanted = id.toLowerCase();
1983
+ return [...formats.values()].reverse().find((f) => f.id.toLowerCase() === wanted);
1984
+ }
1985
+ /**
1986
+ * Read the story format source (for Twine 2, extract the `source` property from JSON).
1987
+ * `diagnostics` receives a warning when the format file is not valid UTF-8 (it is read as Windows-1252).
1988
+ */
1989
+ function readFormatSource(format, diagnostics) {
1990
+ const source = readUTF8(format.filename, diagnostics);
1991
+ if (!format.isTwine2) return source;
1992
+ const decoded = decodeFormatJSON(source);
1993
+ if (!decoded.ok) throw new Error(`Cannot parse format ${format.id} JSON: ${decoded.reason}`);
1994
+ return decoded.data.source;
1995
+ }
1996
+ //#endregion
1997
+ //#region src/atomic-write.ts
1998
+ /**
1999
+ * Atomic file replacement, for every file twee-ts writes that something else may read
2000
+ * meanwhile: build output (which live-reload servers read on each change) and downloaded
2001
+ * story formats (which other twee-ts processes share).
2002
+ */
2003
+ /**
2004
+ * Writes `data` to `path` so that a reader sees either the previous file or the complete new
2005
+ * one, never a partly written file. The data goes to a temporary file in the same folder,
2006
+ * which is then renamed over `path`. When `path` is a symlink, the file it points to is
2007
+ * replaced, and an existing file keeps its permissions.
2008
+ *
2009
+ * On failure (a full disk, a missing folder) the temporary file is removed, `path` is left
2010
+ * as it was, and the error is thrown with `path` in its message and its original `code`.
2011
+ */
2012
+ function writeFileAtomic(path, data) {
2013
+ const target = followSymlink(path);
2014
+ 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`);
2015
+ try {
2016
+ (0, node_fs.writeFileSync)(temp, data, {
2017
+ encoding: "utf-8",
2018
+ flag: "wx"
2019
+ });
2020
+ keepPermissions(target, temp);
2021
+ (0, node_fs.renameSync)(temp, target);
2022
+ } catch (e) {
2023
+ (0, node_fs.rmSync)(temp, { force: true });
2024
+ throw writeError(path, e);
2025
+ }
2026
+ }
2027
+ /** The file a symlink at `path` points to, or `path` itself (also when it doesn't exist yet). */
2028
+ function followSymlink(path) {
2029
+ try {
2030
+ return (0, node_fs.realpathSync)(path);
2031
+ } catch {
2032
+ return path;
1171
2033
  }
1172
- return source;
2034
+ }
2035
+ /**
2036
+ * Gives `temp` the permission bits of `target`, when `target` exists. Best effort: on a file
2037
+ * system without Unix permissions chmod can fail, and writing in place never kept them there either.
2038
+ */
2039
+ function keepPermissions(target, temp) {
2040
+ const existing = (0, node_fs.statSync)(target, { throwIfNoEntry: false });
2041
+ if (existing === void 0) return;
2042
+ try {
2043
+ (0, node_fs.chmodSync)(temp, existing.mode & 4095);
2044
+ } catch {}
2045
+ }
2046
+ /** `cause` as an error that names `path` and keeps the original error code. */
2047
+ function writeError(path, cause) {
2048
+ const message = cause instanceof Error ? cause.message : String(cause);
2049
+ const error = new Error(`Cannot write ${path}: ${message}`, { cause });
2050
+ if (cause instanceof Error && "code" in cause) error.code = cause.code;
2051
+ return error;
1173
2052
  }
1174
2053
  //#endregion
1175
2054
  //#region src/remote-formats.ts
1176
2055
  /**
1177
2056
  * Remote story format fetching, caching, and checksum verification.
1178
2057
  * Uses the Story Formats Archive (SFA) as the default source.
2058
+ *
2059
+ * Two caches hold downloads. Formats found through an index are shared by name and version
2060
+ * (`<cache>/<name>/<version>/format.js`). A format downloaded from a direct URL is kept under
2061
+ * that URL instead, so one project's copy never answers another project's request.
2062
+ * Cache files are replaced atomically, so another process never reads a partly written one.
1179
2063
  */
1180
2064
  const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
2065
+ /** The longest delay a timer accepts; a longer timeout means no limit. */
2066
+ const MAX_TIMER_DELAY = 2147483647;
1181
2067
  /** Get the cache directory for downloaded story formats. */
1182
2068
  function getCacheDir() {
1183
2069
  const base = process.env["XDG_CACHE_HOME"] || (0, node_path.join)((0, node_os.homedir)(), ".cache");
1184
2070
  return (0, node_path.join)(base, "twee-ts", "storyformats");
1185
2071
  }
2072
+ /** The cache directory for formats downloaded from direct URLs, beside {@link getCacheDir}. */
2073
+ function getUrlCacheDir() {
2074
+ return (0, node_path.join)((0, node_path.dirname)(getCacheDir()), "storyformat-urls");
2075
+ }
2076
+ /** The name of one direct URL's cache entry: a hash of the URL, so each URL has its own. */
2077
+ function urlCacheKey(url) {
2078
+ return (0, node_crypto.createHash)("sha256").update(url).digest("hex");
2079
+ }
1186
2080
  /**
1187
2081
  * Whether a string can serve as one directory name inside the cache: not empty, not `.` or `..`,
1188
2082
  * and free of path separators. Format names and versions come from downloaded metadata, so they
@@ -1205,25 +2099,38 @@ function cachedFormatDir(name, version) {
1205
2099
  if (!isInside(root, dir)) throw new Error(`Refusing to cache a story format outside the cache directory ${root}: ${dir}`);
1206
2100
  return dir;
1207
2101
  }
1208
- /** Create `dir` as a plain directory, or check that it already is one (a symlink could lead out of the cache). */
2102
+ /** Whether `e` is a Node.js system error with the given code. */
2103
+ function hasErrorCode(e, code) {
2104
+ return e instanceof Error && "code" in e && e.code === code;
2105
+ }
2106
+ /**
2107
+ * Create `dir` as a plain directory, or check that it already is one (a symlink could lead out of the cache).
2108
+ * A directory another process creates between the check and the mkdir is fine.
2109
+ */
1209
2110
  function ensurePlainDirectory(dir) {
1210
- const stat = (0, node_fs.lstatSync)(dir, { throwIfNoEntry: false });
1211
- if (stat === void 0) {
2111
+ if ((0, node_fs.lstatSync)(dir, { throwIfNoEntry: false }) === void 0) try {
1212
2112
  (0, node_fs.mkdirSync)(dir);
1213
2113
  return;
2114
+ } catch (e) {
2115
+ if (!hasErrorCode(e, "EEXIST")) throw e;
1214
2116
  }
1215
- if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`Refusing to write a story format outside the cache directory: ${dir} is not a plain directory`);
2117
+ const stat = (0, node_fs.lstatSync)(dir, { throwIfNoEntry: false });
2118
+ 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`);
1216
2119
  }
1217
- /** Write a downloaded format.js to `<cache>/<name>/<version>/format.js` and return its path. */
1218
- function writeCachedFormat(name, version, text) {
1219
- const dir = cachedFormatDir(name, version);
1220
- const root = (0, node_path.resolve)(getCacheDir());
2120
+ /**
2121
+ * Write a downloaded format.js to `<root>/<...segments>/format.js`, replacing any earlier copy
2122
+ * atomically, and return its path. Every directory below `root` must be a plain one.
2123
+ */
2124
+ function writeCacheEntry(root, segments, text) {
1221
2125
  (0, node_fs.mkdirSync)(root, { recursive: true });
1222
- ensurePlainDirectory((0, node_path.join)(root, name));
1223
- ensurePlainDirectory(dir);
2126
+ const dir = segments.reduce((parent, segment) => {
2127
+ const child = (0, node_path.join)(parent, segment);
2128
+ ensurePlainDirectory(child);
2129
+ return child;
2130
+ }, root);
1224
2131
  const formatPath = (0, node_path.join)(dir, "format.js");
1225
2132
  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`);
1226
- (0, node_fs.writeFileSync)(formatPath, text, "utf-8");
2133
+ writeFileAtomic(formatPath, text);
1227
2134
  return formatPath;
1228
2135
  }
1229
2136
  /** In-memory index cache, keyed by URL. Cleared each compile. */
@@ -1232,6 +2139,78 @@ const indexCache = /* @__PURE__ */ new Map();
1232
2139
  function clearIndexCache() {
1233
2140
  indexCache.clear();
1234
2141
  }
2142
+ /** Requests in progress, by what they fetch, so concurrent compiles make each request once. */
2143
+ const sharedRequests = /* @__PURE__ */ new Map();
2144
+ /**
2145
+ * Runs `start` once for all callers that ask for `key` while it is in progress. A caller whose
2146
+ * `signal` aborts stops waiting at once and rejects with the signal's reason; the request
2147
+ * itself is aborted (through the signal `start` receives) once no caller waits on it.
2148
+ */
2149
+ function shareRequest(key, signal, start) {
2150
+ if (signal?.aborted) return Promise.reject(signal.reason);
2151
+ const request = sharedRequests.get(key) ?? startSharedRequest(key, start);
2152
+ request.waiters++;
2153
+ return new Promise((resolve, reject) => {
2154
+ let waiting = true;
2155
+ const stopWaiting = () => {
2156
+ if (!waiting) return;
2157
+ waiting = false;
2158
+ signal?.removeEventListener("abort", onAbort);
2159
+ request.waiters--;
2160
+ };
2161
+ const onAbort = () => {
2162
+ stopWaiting();
2163
+ if (request.waiters === 0) {
2164
+ if (sharedRequests.get(key) === request) sharedRequests.delete(key);
2165
+ request.controller.abort(signal?.reason);
2166
+ }
2167
+ reject(signal?.reason);
2168
+ };
2169
+ signal?.addEventListener("abort", onAbort, { once: true });
2170
+ request.promise.then((value) => {
2171
+ stopWaiting();
2172
+ resolve(value);
2173
+ }, (e) => {
2174
+ stopWaiting();
2175
+ reject(e);
2176
+ });
2177
+ });
2178
+ }
2179
+ function startSharedRequest(key, start) {
2180
+ const controller = new AbortController();
2181
+ const request = {
2182
+ promise: start(controller.signal),
2183
+ controller,
2184
+ waiters: 0
2185
+ };
2186
+ sharedRequests.set(key, request);
2187
+ const forget = () => {
2188
+ if (sharedRequests.get(key) === request) sharedRequests.delete(key);
2189
+ };
2190
+ request.promise.then(forget, forget);
2191
+ return request;
2192
+ }
2193
+ /** The time limit for each request, from the caller's options. */
2194
+ function requestTimeout(options) {
2195
+ const timeout = options.timeout ?? 3e4;
2196
+ if (!(timeout >= 0)) throw new RangeError(`A story format request timeout must be 0 or more milliseconds, not ${timeout}`);
2197
+ return timeout;
2198
+ }
2199
+ /**
2200
+ * Fetch `url` as text, aborting when `signal` does or after `timeout` milliseconds (0: no limit).
2201
+ * `what` names the request in errors, as in "Failed to <what> from <url>".
2202
+ */
2203
+ async function fetchText(url, what, signal, timeout) {
2204
+ const limit = timeout > 0 && timeout <= MAX_TIMER_DELAY ? AbortSignal.timeout(timeout) : void 0;
2205
+ try {
2206
+ const res = await fetch(url, { signal: limit ? AbortSignal.any([signal, limit]) : signal });
2207
+ if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
2208
+ return await res.text();
2209
+ } catch (e) {
2210
+ if (limit?.aborted && !signal.aborted) throw new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`, { cause: e });
2211
+ throw e;
2212
+ }
2213
+ }
1235
2214
  /** Validate that a JSON value is a valid SFAIndexEntry. */
1236
2215
  function isValidEntry(val) {
1237
2216
  if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
@@ -1247,15 +2226,18 @@ function validateSFAIndex(json) {
1247
2226
  twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
1248
2227
  };
1249
2228
  }
1250
- /** Fetch and parse an SFA index.json, with in-memory caching. */
1251
- async function fetchIndex(url) {
2229
+ /** Fetch and parse an SFA index.json, with in-memory caching. Concurrent calls for one URL share a request. */
2230
+ async function fetchIndex(url, options = {}) {
2231
+ options.signal?.throwIfAborted();
1252
2232
  const cached = indexCache.get(url);
1253
2233
  if (cached) return cached;
1254
- const res = await fetch(url);
1255
- if (!res.ok) throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
1256
- const data = validateSFAIndex(await res.json());
1257
- indexCache.set(url, data);
1258
- return data;
2234
+ const timeout = requestTimeout(options);
2235
+ return shareRequest(`index\0${url}`, options.signal, async (signal) => {
2236
+ const text = await fetchText(url, "fetch format index", signal, timeout);
2237
+ const data = validateSFAIndex(JSON.parse(text));
2238
+ indexCache.set(url, data);
2239
+ return data;
2240
+ });
1259
2241
  }
1260
2242
  /** Find the best matching entry in an SFA index for a name or ID request (twine2 entries first). */
1261
2243
  function findEntryForRequest(index, request) {
@@ -1276,99 +2258,155 @@ async function verifySHA256(content, expectedHex) {
1276
2258
  function getDownloadUrl(indexUrl, entry, formatType) {
1277
2259
  return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
1278
2260
  }
1279
- /** Download a format, verify its checksum, write to cache, and return StoryFormatInfo. */
1280
- async function fetchAndCacheFormat(entry, downloadUrl) {
1281
- cachedFormatDir(entry.name, entry.version);
1282
- const res = await fetch(downloadUrl);
1283
- if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
1284
- const text = await res.text();
1285
- const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
1286
- if (checksumKey) {
1287
- const expected = entry.checksums[checksumKey];
1288
- if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
1289
- if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
1290
- }
1291
- const id = makeFormatId(entry.name, entry.version);
1292
- const data = parseFormatJSON(text, id);
1293
- if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
2261
+ /** The StoryFormatInfo of a Twine 2 format.js cached at `filename`. */
2262
+ function cachedFormatInfo(id, filename, data) {
1294
2263
  return {
1295
2264
  id,
1296
- filename: writeCachedFormat(entry.name, entry.version, text),
2265
+ filename,
1297
2266
  isTwine2: true,
1298
2267
  name: data.name,
1299
2268
  version: data.version,
1300
2269
  proofing: data.proofing ?? false
1301
2270
  };
1302
2271
  }
1303
- /** Download a direct format.js URL, parse its JSON, cache it, and return StoryFormatInfo. */
1304
- async function fetchDirectFormat(url) {
1305
- const res = await fetch(url);
1306
- if (!res.ok) throw new Error(`Failed to download format from ${url}: ${res.status} ${res.statusText}`);
1307
- const text = await res.text();
1308
- const data = parseFormatJSON(text);
1309
- if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
1310
- return {
1311
- id: makeFormatId(data.name, data.version),
1312
- filename: writeCachedFormat(data.name, data.version, text),
1313
- isTwine2: true,
1314
- name: data.name,
1315
- version: data.version,
1316
- proofing: data.proofing ?? false
1317
- };
2272
+ /**
2273
+ * Download a format, verify its checksum, write it to the cache shared by name and version, and
2274
+ * return its StoryFormatInfo. Concurrent calls for one download URL share one request.
2275
+ */
2276
+ async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
2277
+ cachedFormatDir(entry.name, entry.version);
2278
+ const timeout = requestTimeout(options);
2279
+ return shareRequest(`format\0${getCacheDir()}\0${downloadUrl}`, options.signal, async (signal) => {
2280
+ const text = await fetchText(downloadUrl, "download format", signal, timeout);
2281
+ const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
2282
+ if (checksumKey) {
2283
+ const expected = entry.checksums[checksumKey];
2284
+ if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
2285
+ if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
2286
+ }
2287
+ const id = makeFormatId(entry.name, entry.version);
2288
+ const data = parseFormatJSON(text, id);
2289
+ if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
2290
+ signal.throwIfAborted();
2291
+ return cachedFormatInfo(id, writeCacheEntry((0, node_path.resolve)(getCacheDir()), [entry.name, entry.version], text), data);
2292
+ });
2293
+ }
2294
+ /**
2295
+ * Download a direct format.js URL, parse its JSON, cache it under that URL (apart from the
2296
+ * downloads shared by name and version), and return its StoryFormatInfo. Concurrent calls for
2297
+ * one URL share one request.
2298
+ */
2299
+ async function fetchDirectFormat(url, options = {}) {
2300
+ const timeout = requestTimeout(options);
2301
+ return shareRequest(`url\0${getUrlCacheDir()}\0${url}`, options.signal, async (signal) => {
2302
+ const text = await fetchText(url, "download format", signal, timeout);
2303
+ const data = parseFormatJSON(text);
2304
+ if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
2305
+ cachedFormatDir(data.name, data.version);
2306
+ signal.throwIfAborted();
2307
+ const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
2308
+ return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
2309
+ });
2310
+ }
2311
+ /** The copy of a direct format URL in the download cache, if it has been downloaded before. */
2312
+ function getCachedDirectFormat(url) {
2313
+ const formatPath = (0, node_path.join)(getUrlCacheDir(), urlCacheKey(url), "format.js");
2314
+ try {
2315
+ if (!(0, node_fs.existsSync)(formatPath)) return void 0;
2316
+ const data = parseFormatJSON(readUTF8(formatPath));
2317
+ if (!data) return void 0;
2318
+ return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
2319
+ } catch {
2320
+ return;
2321
+ }
2322
+ }
2323
+ /**
2324
+ * Look a request up among direct format URLs, in order, and return the first format that
2325
+ * answers it. A URL downloaded before is answered by its cached copy without the network;
2326
+ * any other is downloaded (unless `offline`). When none answers and a download failed, the
2327
+ * last failure is thrown. An aborted signal rejects with its reason and tries no further URL.
2328
+ *
2329
+ * Internal, for format resolution; not part of the public API.
2330
+ */
2331
+ async function resolveFormatUrls(request, urls, options = {}) {
2332
+ let lastError;
2333
+ for (const url of urls) {
2334
+ const cached = getCachedDirectFormat(url);
2335
+ if (cached) {
2336
+ if (answers(request, cached)) return cached;
2337
+ continue;
2338
+ }
2339
+ if (options.offline) continue;
2340
+ try {
2341
+ const info = await fetchDirectFormat(url, options);
2342
+ if (answers(request, info)) return info;
2343
+ } catch (e) {
2344
+ if (options.signal?.aborted) throw options.signal.reason;
2345
+ lastError = toError$1(e);
2346
+ }
2347
+ }
2348
+ if (lastError) throw lastError;
2349
+ }
2350
+ /** Whether `info` is a format the request accepts. */
2351
+ function answers(request, info) {
2352
+ return selectFormatCandidate(request, [info], (f) => f) !== void 0;
2353
+ }
2354
+ function toError$1(e) {
2355
+ return e instanceof Error ? e : new Error(String(e));
1318
2356
  }
1319
2357
  /**
1320
2358
  * Try to resolve a remote story format by name and version.
1321
- * 0. Use an exactly matching cached download, without touching the network
1322
- * 1. Try direct format URLs
1323
- * 2. Try custom index URLs
1324
- * 3. Try default SFA indices
1325
- * 4. Fall back to a compatible cached download (e.g. when offline)
2359
+ * 1. Try direct format URLs (each one's cached copy, else a download)
2360
+ * 2. Use an exactly matching download from the shared cache, without touching the network
2361
+ * 3. Try custom index URLs, then the default SFA indices
2362
+ * 4. Fall back to a compatible download from the shared cache (e.g. when offline)
2363
+ *
2364
+ * `options.signal` aborts the lookup, which then rejects with the signal's reason;
2365
+ * `options.timeout` limits each request (default {@link DEFAULT_FORMAT_FETCH_TIMEOUT} ms).
1326
2366
  */
1327
- async function resolveRemoteFormat(name, version, indices, urls) {
2367
+ async function resolveRemoteFormat(name, version, indices, urls, options = {}) {
1328
2368
  return resolveRemoteFormatRequest({
1329
2369
  kind: "name",
1330
2370
  name,
1331
2371
  version
1332
- }, indices, urls);
2372
+ }, indices, urls, options);
1333
2373
  }
1334
2374
  /**
1335
2375
  * Resolve a story format request remotely, in the same order as {@link resolveRemoteFormat}.
1336
2376
  * An ID request such as 'sugarcube-2' matches the format whose name and major version build that ID
1337
2377
  * (SugarCube 2.x), taking the greatest version available.
1338
2378
  */
1339
- async function resolveRemoteFormatRequest(request, indices, urls) {
2379
+ async function resolveRemoteFormatRequest(request, indices, urls, options = {}) {
2380
+ options.signal?.throwIfAborted();
1340
2381
  let lastError;
1341
- const cached = findCachedFormat(request);
1342
- if (cached && request.kind === "name" && isExactVersion(cached.version, request.version)) return cached;
1343
- for (const url of urls ?? []) try {
1344
- const info = await fetchDirectFormat(url);
1345
- if (selectFormatCandidate(request, [info], (f) => f)) return info;
2382
+ try {
2383
+ const direct = await resolveFormatUrls(request, urls ?? [], options);
2384
+ if (direct) return direct;
1346
2385
  } catch (e) {
1347
- lastError = e instanceof Error ? e : new Error(String(e));
2386
+ if (options.signal?.aborted) throw options.signal.reason;
2387
+ lastError = toError$1(e);
1348
2388
  }
2389
+ const cached = findCachedFormat(request);
2390
+ if (cached && request.kind === "name" && sameVersion(cached.version, request.version)) return cached;
1349
2391
  const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
1350
2392
  for (const indexUrl of allIndices) try {
1351
- const result = findEntryForRequest(await fetchIndex(indexUrl), request);
2393
+ const result = findEntryForRequest(await fetchIndex(indexUrl, options), request);
1352
2394
  if (result) {
1353
2395
  const hit = getCachedFormat(result.entry.name, result.entry.version);
1354
2396
  if (hit) return hit;
1355
2397
  const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
1356
- return await fetchAndCacheFormat(result.entry, downloadUrl);
2398
+ return await fetchAndCacheFormat(result.entry, downloadUrl, options);
1357
2399
  }
1358
2400
  } catch (e) {
1359
- lastError = e instanceof Error ? e : new Error(String(e));
2401
+ if (options.signal?.aborted) throw options.signal.reason;
2402
+ lastError = toError$1(e);
1360
2403
  }
1361
2404
  if (cached) return cached;
1362
2405
  if (lastError) throw lastError;
1363
2406
  }
1364
- /** Whether two version strings name the same SemVer version. */
1365
- function isExactVersion(have, wanted) {
1366
- const a = parseSemver(have);
1367
- const b = parseSemver(wanted);
1368
- return a !== null && b !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
1369
- }
1370
2407
  /**
1371
2408
  * Find the cached download that best answers a format request, without network access.
2409
+ * Only the downloads shared by name and version are searched, not those from direct URLs.
1372
2410
  * Matching follows {@link selectFormatCandidate}.
1373
2411
  */
1374
2412
  function findCachedFormat(request, options = {}) {
@@ -1380,18 +2418,11 @@ function getCachedFormat(name, version) {
1380
2418
  const formatPath = (0, node_path.join)(getCacheDir(), name, version, "format.js");
1381
2419
  try {
1382
2420
  if (!(0, node_fs.existsSync)(formatPath)) return void 0;
1383
- const source = (0, node_fs.readFileSync)(formatPath, "utf-8");
2421
+ const source = readUTF8(formatPath);
1384
2422
  const id = makeFormatId(name, version);
1385
2423
  const data = parseFormatJSON(source, id);
1386
2424
  if (!data) return void 0;
1387
- return {
1388
- id,
1389
- filename: formatPath,
1390
- isTwine2: true,
1391
- name: data.name,
1392
- version: data.version,
1393
- proofing: data.proofing ?? false
1394
- };
2425
+ return cachedFormatInfo(id, formatPath, data);
1395
2426
  } catch {
1396
2427
  return;
1397
2428
  }
@@ -1476,21 +2507,27 @@ function listCachedFormats() {
1476
2507
  }
1477
2508
  /**
1478
2509
  * Clear all cached formats, or only those matching a given name. Returns the number of entries removed.
2510
+ * Clearing all also removes the downloads from direct format URLs; a name clears only the
2511
+ * downloads shared by name and version.
1479
2512
  * A name must be a single cache entry name (as `listCachedFormats()` reports it); anything with a
1480
2513
  * path separator or `.`/`..` throws instead of deleting outside the format's own directory.
1481
2514
  */
1482
2515
  function clearCachedFormats(name) {
1483
2516
  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.`);
1484
2517
  const cacheDir = getCacheDir();
1485
- if (!(0, node_fs.existsSync)(cacheDir)) return 0;
1486
2518
  if (!name) {
1487
- const count = listCachedFormats().length;
2519
+ const count = listCachedFormats().length + countUrlCacheEntries();
1488
2520
  (0, node_fs.rmSync)(cacheDir, {
1489
2521
  recursive: true,
1490
2522
  force: true
1491
2523
  });
2524
+ (0, node_fs.rmSync)(getUrlCacheDir(), {
2525
+ recursive: true,
2526
+ force: true
2527
+ });
1492
2528
  return count;
1493
2529
  }
2530
+ if (!(0, node_fs.existsSync)(cacheDir)) return 0;
1494
2531
  const root = (0, node_path.resolve)(cacheDir);
1495
2532
  const nameDir = (0, node_path.resolve)(root, name);
1496
2533
  if (!isInside(root, nameDir)) throw new Error(`Refusing to clear ${JSON.stringify(name)}: it is not a cached format name.`);
@@ -1508,6 +2545,15 @@ function clearCachedFormats(name) {
1508
2545
  });
1509
2546
  return count;
1510
2547
  }
2548
+ /** The number of direct format URLs with a download in the cache. */
2549
+ function countUrlCacheEntries() {
2550
+ const dir = getUrlCacheDir();
2551
+ try {
2552
+ return (0, node_fs.readdirSync)(dir).filter((key) => (0, node_fs.existsSync)((0, node_path.join)(dir, key, "format.js"))).length;
2553
+ } catch {
2554
+ return 0;
2555
+ }
2556
+ }
1511
2557
  /** Get total cache size in bytes and format count. */
1512
2558
  function getCacheSize() {
1513
2559
  const entries = listCachedFormats();
@@ -1534,44 +2580,65 @@ function formatRequestFor(formatId, storyFormat, storyFormatVersion, defaultId)
1534
2580
  id: defaultId
1535
2581
  };
1536
2582
  }
1537
- /** Look a request up among the formats found in local format directories. */
1538
- function findLocalFormat(formats, request) {
2583
+ /**
2584
+ * Look a request up among the formats found in local format directories. An ID names a folder, so
2585
+ * it is looked up among all of them; a name request selects by SemVer among the pruned formats.
2586
+ */
2587
+ function findLocalFormat(all, pruned, request) {
1539
2588
  switch (request.kind) {
1540
- case "id": return formats.get(request.id);
1541
- case "name": {
1542
- const id = getFormatIdByNameAndVersion(formats, request.name, request.version);
1543
- return id === void 0 ? void 0 : formats.get(id);
1544
- }
2589
+ case "id": return findFormatById(all, request.id);
2590
+ case "name": return selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f);
1545
2591
  default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
1546
2592
  }
1547
2593
  }
1548
2594
  /**
1549
2595
  * Resolve a format request to a story format.
1550
2596
  *
1551
- * Order: local format directories, then the download cache, then remote sources (unless
1552
- * `noRemote`). When a StoryData request names a version that none of them has, an older version of
2597
+ * Order: local format directories, then the project's direct format URLs (each one's cached
2598
+ * copy, else a download unless `noRemote`), then the download cache shared by name and version,
2599
+ * then format indices (unless `noRemote`). Names and IDs match the same way in each (see
2600
+ * `selectFormatCandidate`), so a request that a local format answers never reaches the cache or the
2601
+ * network. Local formats that cannot be used are reported as warnings. When a StoryData request
2602
+ * names a version that none of them has, an older version of
1553
2603
  * the same format and major version is used with a warning. Anything else is an error diagnostic,
1554
2604
  * and the result is `undefined`.
1555
2605
  */
1556
2606
  async function resolveStoryFormat(request, options, diagnostics) {
1557
2607
  const noRemote = options.noRemote ?? false;
1558
- const formats = discoverFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true));
2608
+ const all = discoverAllFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true), diagnostics);
2609
+ const pruned = pruneFormats(all);
1559
2610
  const wanted = describeFormatRequest(request);
1560
- const local = findLocalFormat(formats, request);
2611
+ const local = findLocalFormat(all, pruned, request);
1561
2612
  if (local) return local;
2613
+ const fetchOptions = {
2614
+ signal: options.signal,
2615
+ timeout: options.formatFetchTimeout
2616
+ };
2617
+ const warnOnFailure = async (lookup) => {
2618
+ try {
2619
+ return await lookup();
2620
+ } catch (e) {
2621
+ if (options.signal?.aborted) throw options.signal.reason;
2622
+ diagnostics.push({
2623
+ level: "warning",
2624
+ message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
2625
+ });
2626
+ return;
2627
+ }
2628
+ };
2629
+ const direct = await warnOnFailure(() => resolveFormatUrls(request, options.formatUrls ?? [], {
2630
+ ...fetchOptions,
2631
+ offline: noRemote
2632
+ }));
2633
+ if (direct) return direct;
1562
2634
  const cached = findCachedFormat(request);
1563
2635
  if (cached) return cached;
1564
- if (!noRemote) try {
1565
- const remote = await resolveRemoteFormatRequest(request, options.formatIndices, options.formatUrls);
2636
+ if (!noRemote) {
2637
+ const remote = await warnOnFailure(() => resolveRemoteFormatRequest(request, options.formatIndices, [], fetchOptions));
1566
2638
  if (remote) return remote;
1567
- } catch (e) {
1568
- diagnostics.push({
1569
- level: "warning",
1570
- message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
1571
- });
1572
2639
  }
1573
2640
  if (request.kind === "name") {
1574
- const older = selectFormatCandidate(request, [...formats.values()].filter((f) => f.isTwine2), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
2641
+ const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
1575
2642
  if (older) {
1576
2643
  diagnostics.push({
1577
2644
  level: "warning",
@@ -1583,7 +2650,7 @@ async function resolveStoryFormat(request, options, diagnostics) {
1583
2650
  const reason = noRemote ? " (remote fetching disabled)" : "";
1584
2651
  diagnostics.push({
1585
2652
  level: "error",
1586
- message: `Story format ${wanted} is not available${reason}. Found: ${[...formats.keys()].join(", ") || "none"}`
2653
+ message: `Story format ${wanted} is not available${reason}. Found: ${[...all.keys()].join(", ") || "none"}`
1587
2654
  });
1588
2655
  }
1589
2656
  //#endregion
@@ -1878,12 +2945,14 @@ function twee2ToV3(s) {
1878
2945
  * Parse Twee source text into passages.
1879
2946
  *
1880
2947
  * The source is normalized first, as files are when they are read: a leading UTF-8 BOM is
1881
- * removed and CRLF and bare CR line endings become LF.
2948
+ * removed and CRLF and bare CR line endings become LF. A BOM at the start of a later line,
2949
+ * directly before `::` (left there by concatenating files), is removed too, so that line
2950
+ * stays a passage header.
1882
2951
  */
1883
2952
  function parseTwee(source, options = {}) {
1884
2953
  const { filename = "<inline>", trim = true, twee2Compat = false } = options;
1885
2954
  const diagnostics = [];
1886
- const normalized = normalizeSourceText(source);
2955
+ const normalized = normalizeTweeSourceText(source);
1887
2956
  const tweeSource = twee2Compat ? twee2ToV3(normalized) : normalized;
1888
2957
  const passages = [];
1889
2958
  let current = null;
@@ -4720,15 +5789,28 @@ function parseDocument(data, options) {
4720
5789
  /**
4721
5790
  * Parse a Twine 2 or Twine 1 compiled HTML file back into a Story model.
4722
5791
  * Tweego always trims passage text; here `trim: false` keeps it exactly, as for Twee sources.
5792
+ * A missing or invalid `tw-storydata` IFID is reported as a warning; an invalid one is kept as
5793
+ * written (uppercased), a missing one stays empty.
4723
5794
  */
4724
5795
  function decompileHTML(html, options = {}) {
5796
+ return decompile(html, options, { ifid: true });
5797
+ }
5798
+ /**
5799
+ * `decompileHTML()` for loading an HTML file into a compile. The importer's StoryData passage is
5800
+ * checked when it is added to the story being compiled, which reports a missing or invalid IFID,
5801
+ * so the IFID is not reported here as well.
5802
+ */
5803
+ function decompileHTMLForImport(html, options = {}) {
5804
+ return decompile(html, options, { ifid: false });
5805
+ }
5806
+ function decompile(html, options, checks) {
4725
5807
  const diagnostics = [];
4726
5808
  const story = createStory();
4727
5809
  const passageText = options.trim === false ? (text) => text : (text) => text.trim();
4728
5810
  const doc = parseDocument(html);
4729
5811
  const twine2Data = findElement(doc, "tw-storydata");
4730
5812
  if (twine2Data) {
4731
- decompileTwine2(twine2Data, story, passageText, diagnostics);
5813
+ decompileTwine2(twine2Data, story, passageText, checks, diagnostics);
4732
5814
  return {
4733
5815
  story,
4734
5816
  diagnostics
@@ -4751,7 +5833,7 @@ function decompileHTML(html, options = {}) {
4751
5833
  diagnostics
4752
5834
  };
4753
5835
  }
4754
- function decompileTwine2(storyData, story, passageText, diagnostics) {
5836
+ function decompileTwine2(storyData, story, passageText, checks, diagnostics) {
4755
5837
  let startnode = 0;
4756
5838
  const attrs = storyData.attribs ?? {};
4757
5839
  if (attrs["name"]) story.name = attrs["name"];
@@ -4763,7 +5845,8 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
4763
5845
  });
4764
5846
  else startnode = parsed;
4765
5847
  }
4766
- if (attrs["ifid"]) story.ifid = attrs["ifid"].toUpperCase();
5848
+ if (attrs["ifid"]) story.ifid = normalizeIFID(attrs["ifid"]);
5849
+ if (checks.ifid) diagnostics.push(...storyDataIFIDDiagnostics(attrs["ifid"] ?? ""));
4767
5850
  if (attrs["zoom"]) {
4768
5851
  const parsed = parseFloat(attrs["zoom"]);
4769
5852
  if (Number.isNaN(parsed)) diagnostics.push({
@@ -4776,7 +5859,9 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
4776
5859
  if (attrs["format"]) story.twine2.format = attrs["format"];
4777
5860
  if (attrs["format-version"]) story.twine2.formatVersion = attrs["format-version"];
4778
5861
  if (attrs["options"]) for (const opt of attrs["options"].split(/\s+/).filter((s) => s.length > 0)) story.twine2.options.set(opt, true);
4779
- for (const node of storyData.children ?? []) {
5862
+ const children = storyData.children ?? [];
5863
+ const takenNames = new Set(children.filter((node) => node.type === "tag" && node.name === "tw-passagedata").map(passageDataName));
5864
+ for (const node of children) {
4780
5865
  if (node.type !== "tag" && node.type !== "style" && node.type !== "script") continue;
4781
5866
  switch (node.name) {
4782
5867
  case "style":
@@ -4784,11 +5869,17 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
4784
5869
  const content = getTextContent(node);
4785
5870
  if (content.trim().length === 0) continue;
4786
5871
  const text = passageText(content);
4787
- storyAdd(story, {
4788
- name: node.name === "style" ? "Story Stylesheet" : "Story JavaScript",
5872
+ const base = node.name === "style" ? "Story Stylesheet" : "Story JavaScript";
5873
+ const name = freeName(base, (n) => !takenNames.has(n));
5874
+ takenNames.add(name);
5875
+ storyAdd(story, withGeneratedName({
5876
+ name,
4789
5877
  tags: node.name === "style" ? ["stylesheet"] : ["script"],
4790
5878
  text
4791
- }, diagnostics);
5879
+ }, {
5880
+ kind: "code",
5881
+ base
5882
+ }), diagnostics);
4792
5883
  break;
4793
5884
  }
4794
5885
  case "tw-tag": {
@@ -4801,7 +5892,7 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
4801
5892
  case "tw-passagedata": {
4802
5893
  let pid = 0;
4803
5894
  const pAttrs = node.attribs ?? {};
4804
- const name = pAttrs["name"] ?? "";
5895
+ const name = passageDataName(node);
4805
5896
  const tags = pAttrs["tags"] ? pAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [];
4806
5897
  const metadata = {};
4807
5898
  if (pAttrs["pid"]) {
@@ -4832,11 +5923,29 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
4832
5923
  text: marshalStoryData(story)
4833
5924
  }, diagnostics);
4834
5925
  }
5926
+ function passageDataName(node) {
5927
+ return node.attribs?.["name"] ?? "";
5928
+ }
5929
+ /** Diagnostics for the `ifid` attribute of `tw-storydata`; `value` is empty when it is missing. */
5930
+ function storyDataIFIDDiagnostics(value) {
5931
+ if (value === "") return [{
5932
+ level: "warning",
5933
+ message: "Story IFID not found; the \"tw-storydata\" content attribute \"ifid\" is missing or empty."
5934
+ }];
5935
+ const err = validateIFID(value);
5936
+ if (err === null) return [];
5937
+ return [{
5938
+ level: "warning",
5939
+ message: `Cannot parse "tw-storydata" content attribute "ifid" as an IFID; value "${value}" (${err}).`
5940
+ }];
5941
+ }
4835
5942
  function decompileTwine1(storeArea, story, passageText, diagnostics) {
4836
5943
  const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
4837
5944
  const obfuscated = isRot13Obfuscated(passages);
4838
- for (const p of passages) storyAdd(story, obfuscated && p.name !== "StorySettings" ? {
5945
+ for (const p of passages) storyAdd(story, obfuscated && isObfuscatable(p) ? {
4839
5946
  ...p,
5947
+ name: rot13(p.name),
5948
+ tags: p.tags.map(rot13),
4840
5949
  text: rot13(p.text)
4841
5950
  } : p, diagnostics);
4842
5951
  }
@@ -4981,7 +6090,12 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
4981
6090
  function loadInlineSources(story, sources, opts, diagnostics) {
4982
6091
  for (const source of sources) {
4983
6092
  if (typeof source === "string") continue;
4984
- const content = normalizeSourceText(typeof source.content === "string" ? source.content : source.content.toString("utf-8"));
6093
+ const decoded = typeof source.content === "string" ? {
6094
+ text: source.content,
6095
+ diagnostics: []
6096
+ } : decodeText(source.content, source.filename);
6097
+ diagnostics.push(...decoded.diagnostics);
6098
+ const content = normalizeSourceText(decoded.text);
4985
6099
  const ext = normalizedFileExt(source.filename);
4986
6100
  switch (ext) {
4987
6101
  case "":
@@ -5000,18 +6114,10 @@ function loadInlineSources(story, sources, opts, diagnostics) {
5000
6114
  break;
5001
6115
  }
5002
6116
  case "css":
5003
- storyAdd(story, {
5004
- name: (0, node_path.basename)(source.filename),
5005
- tags: ["stylesheet"],
5006
- text: content
5007
- }, diagnostics);
6117
+ storyAdd(story, codePassage((0, node_path.basename)(source.filename), "stylesheet", content), diagnostics);
5008
6118
  break;
5009
6119
  case "js":
5010
- storyAdd(story, {
5011
- name: (0, node_path.basename)(source.filename),
5012
- tags: ["script"],
5013
- text: content
5014
- }, diagnostics);
6120
+ storyAdd(story, codePassage((0, node_path.basename)(source.filename), "script", content), diagnostics);
5015
6121
  break;
5016
6122
  default: diagnostics.push({
5017
6123
  level: "warning",
@@ -5021,35 +6127,60 @@ function loadInlineSources(story, sources, opts, diagnostics) {
5021
6127
  }
5022
6128
  }
5023
6129
  function parseTweeFile(filename, opts) {
5024
- const result = parseTwee(readUTF8(filename), {
6130
+ const readDiagnostics = [];
6131
+ const result = parseTwee(readUTF8(filename, readDiagnostics), {
5025
6132
  filename,
5026
6133
  trim: opts.trim ?? true,
5027
6134
  twee2Compat: opts.twee2Compat ?? false
5028
6135
  });
5029
6136
  return {
5030
6137
  passages: result.passages,
5031
- diagnostics: [...result.diagnostics]
6138
+ diagnostics: [...readDiagnostics, ...result.diagnostics]
5032
6139
  };
5033
6140
  }
6141
+ /**
6142
+ * A stylesheet or script passage named after its file, as Tweego names it. The name only labels
6143
+ * the code, so when another passage has it, the passage takes a free one (see `storyAdd()`).
6144
+ */
6145
+ function codePassage(name, tag, text) {
6146
+ return withGeneratedName({
6147
+ name,
6148
+ tags: [tag],
6149
+ text
6150
+ }, {
6151
+ kind: "code",
6152
+ base: name
6153
+ });
6154
+ }
6155
+ /** Mark every passage of a parse result as a code passage named after its file. */
6156
+ function codeNames(result) {
6157
+ for (const p of result.passages) withGeneratedName(p, {
6158
+ kind: "code",
6159
+ base: p.name
6160
+ });
6161
+ return result;
6162
+ }
5034
6163
  function parseTaggedFile(tag, filename) {
5035
- const source = readUTF8(filename);
6164
+ const diagnostics = [];
6165
+ const source = readUTF8(filename, diagnostics);
5036
6166
  return {
5037
- passages: [{
5038
- name: (0, node_path.basename)(filename),
5039
- tags: [tag],
5040
- text: source
5041
- }],
5042
- diagnostics: []
6167
+ passages: [codePassage((0, node_path.basename)(filename), tag, source)],
6168
+ diagnostics
5043
6169
  };
5044
6170
  }
5045
6171
  function parseMediaFile(tag, filename) {
5046
6172
  const source = readBase64(filename);
6173
+ const name = baseNameWithoutExt(filename);
5047
6174
  return {
5048
- passages: [{
5049
- name: baseNameWithoutExt(filename),
6175
+ passages: [withGeneratedName({
6176
+ name,
5050
6177
  tags: [tag],
5051
6178
  text: `data:${mediaTypeFromFilename(filename)};base64,${source}`
5052
- }],
6179
+ }, {
6180
+ kind: "media",
6181
+ base: name,
6182
+ file: filename
6183
+ })],
5053
6184
  diagnostics: []
5054
6185
  };
5055
6186
  }
@@ -5058,24 +6189,27 @@ function parseFontFile(filename) {
5058
6189
  const name = (0, node_path.basename)(filename);
5059
6190
  const family = baseNameWithoutExt(filename);
5060
6191
  const ext = normalizedFileExt(filename);
5061
- return {
6192
+ const mediaType = mediaTypeFromExt(ext);
6193
+ const hint = fontFormatHint(ext);
6194
+ return codeNames({
5062
6195
  passages: [{
5063
6196
  name,
5064
6197
  tags: ["stylesheet"],
5065
- text: `@font-face {\n\tfont-family: "${family}";\n\tsrc: url("data:${mediaTypeFromExt(ext)};base64,${source}") format("${fontFormatHint(ext)}");\n}`
6198
+ text: `@font-face {\n\tfont-family: "${cssStringEscape(family)}";\n\tsrc: url("data:${mediaType};base64,${source}") format("${hint}");\n}`
5066
6199
  }],
5067
6200
  diagnostics: []
5068
- };
6201
+ });
5069
6202
  }
5070
6203
  function parseHTMLFile(filename, opts) {
5071
- const { story, diagnostics } = decompileHTML(readUTF8(filename), { trim: opts.trim ?? true });
6204
+ const readDiagnostics = [];
6205
+ const { story, diagnostics } = decompileHTMLForImport(readUTF8(filename, readDiagnostics), { trim: opts.trim ?? true });
5072
6206
  return {
5073
6207
  passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
5074
6208
  name: "StoryTitle",
5075
6209
  tags: [],
5076
6210
  text: story.name
5077
6211
  }, ...story.passages] : story.passages,
5078
- diagnostics: [...diagnostics]
6212
+ diagnostics: [...readDiagnostics, ...diagnostics]
5079
6213
  };
5080
6214
  }
5081
6215
  /**
@@ -5148,6 +6282,7 @@ function loadTwee(story, filename, opts, diagnostics) {
5148
6282
  }
5149
6283
  function loadTagged(story, tag, filename, diagnostics) {
5150
6284
  const result = parseTaggedFile(tag, filename);
6285
+ diagnostics.push(...result.diagnostics);
5151
6286
  for (const p of result.passages) storyAdd(story, p, diagnostics);
5152
6287
  }
5153
6288
  function loadMedia(story, tag, filename, diagnostics) {
@@ -5164,6 +6299,7 @@ function loadFont(story, filename, diagnostics) {
5164
6299
  */
5165
6300
  function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
5166
6301
  const currentFiles = /* @__PURE__ */ new Set();
6302
+ const changedPaths = changedFiles === void 0 ? void 0 : new Set([...changedFiles].map((f) => (0, node_path.resolve)(f)));
5167
6303
  for (const filename of filenames) {
5168
6304
  if (processedFiles.has(filename)) {
5169
6305
  diagnostics.push({
@@ -5176,8 +6312,8 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5176
6312
  const optionsKey = parseOptionsKey(filename, opts);
5177
6313
  const entry = cache.get(filename);
5178
6314
  const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
5179
- const changed = changedFiles?.has(filename) ?? false;
5180
- if (changedFiles && cached && !changed) {
6315
+ const changed = changedPaths?.has((0, node_path.resolve)(filename)) ?? false;
6316
+ if (changedPaths && cached && !changed) {
5181
6317
  diagnostics.push(...cached.diagnostics);
5182
6318
  for (const p of cached.passages) storyAdd(story, p, diagnostics);
5183
6319
  processedFiles.add(filename);
@@ -5187,6 +6323,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5187
6323
  try {
5188
6324
  mtimeMs = (0, node_fs.statSync)(filename).mtimeMs;
5189
6325
  } catch {
6326
+ cache.delete(filename);
5190
6327
  continue;
5191
6328
  }
5192
6329
  if (cached && !changed && cached.mtimeMs === mtimeMs) {
@@ -5207,6 +6344,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5207
6344
  diagnostics.push(...result.diagnostics);
5208
6345
  for (const p of result.passages) storyAdd(story, p, diagnostics);
5209
6346
  } catch (e) {
6347
+ cache.delete(filename);
5210
6348
  diagnostics.push({
5211
6349
  level: "error",
5212
6350
  message: `load ${filename}: ${e instanceof Error ? e.message : String(e)}`,
@@ -5224,6 +6362,164 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5224
6362
  }, diagnostics);
5225
6363
  }
5226
6364
  //#endregion
6365
+ //#region src/template.ts
6366
+ /**
6367
+ * An HTML closing head tag: `</head` in any letter case, followed by whitespace, `/` or `>`, up to the next `>`.
6368
+ * This is how the HTML tokenizer reads an end tag; it ignores whatever sits between the tag name and the `>`.
6369
+ */
6370
+ const CLOSING_HEAD_TAG = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>";
6371
+ /**
6372
+ * The start of an HTML body start tag: `<body` in any letter case, followed by whitespace, `/` or `>` (so not
6373
+ * `<bodyx>`). Only the `<body` is matched; the attributes and `>` after it are left alone.
6374
+ */
6375
+ const BODY_START_TAG = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
6376
+ /** The regular expression source matching exactly `text`. */
6377
+ function literal(text) {
6378
+ return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
6379
+ }
6380
+ /** Replace the `slots` placeholders found in `template`, without scanning inserted text. */
6381
+ function fillTemplate(template, slots) {
6382
+ return fillTemplateParts([{
6383
+ kind: "scan",
6384
+ text: template,
6385
+ slots
6386
+ }]);
6387
+ }
6388
+ /**
6389
+ * Fill the parts of a template in order, joined. A `'first'` slot replaced in one part is not replaced again in a
6390
+ * later part that lists the same slot object.
6391
+ */
6392
+ function fillTemplateParts(parts) {
6393
+ const filled = /* @__PURE__ */ new Set();
6394
+ return parts.map((part) => {
6395
+ if (part.kind === "verbatim" || part.slots.length === 0) return part.text;
6396
+ const { slots } = part;
6397
+ const pattern = new RegExp(slots.map((slot) => `(${slot.pattern})`).join("|"), "g");
6398
+ return part.text.replace(pattern, (match, ...groups) => {
6399
+ const slot = slots.find((_, i) => groups[i] !== void 0);
6400
+ if (slot === void 0 || slot.occurrences === "first" && filled.has(slot)) return match;
6401
+ filled.add(slot);
6402
+ return slot.replacement(match);
6403
+ });
6404
+ }).join("");
6405
+ }
6406
+ //#endregion
6407
+ //#region src/modules.ts
6408
+ /**
6409
+ * Load modules and return HTML tags to inject before the closing head tag. `diagnostics` receives a warning for
6410
+ * each module that is not valid UTF-8 (it is read as Windows-1252).
6411
+ */
6412
+ function loadModules(filenames, diagnostics) {
6413
+ const processed = /* @__PURE__ */ new Set();
6414
+ const headTags = [];
6415
+ for (const filename of filenames) {
6416
+ if (processed.has(filename)) continue;
6417
+ const ext = normalizedFileExt(filename);
6418
+ let tag = null;
6419
+ switch (ext) {
6420
+ case "css":
6421
+ tag = loadModuleTagged("style", filename, diagnostics);
6422
+ break;
6423
+ case "js":
6424
+ tag = loadModuleTagged("script", filename, diagnostics);
6425
+ break;
6426
+ case "otf":
6427
+ case "ttf":
6428
+ case "woff":
6429
+ case "woff2":
6430
+ tag = loadModuleFont(filename);
6431
+ break;
6432
+ default: continue;
6433
+ }
6434
+ if (tag) headTags.push(tag);
6435
+ processed.add(filename);
6436
+ }
6437
+ return headTags.join("\n");
6438
+ }
6439
+ function loadModuleTagged(tag, filename, diagnostics) {
6440
+ const source = readUTF8(filename, diagnostics).trim();
6441
+ if (source.length === 0) return null;
6442
+ return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
6443
+ }
6444
+ function loadModuleFont(filename) {
6445
+ const source = readBase64(filename);
6446
+ const family = baseNameWithoutExt(filename);
6447
+ const idSlug = `style-module-${slugify(family)}`;
6448
+ const ext = normalizedFileExt(filename);
6449
+ const mediaType = mediaTypeFromExt(ext);
6450
+ const hint = fontFormatHint(ext);
6451
+ 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>`;
6452
+ }
6453
+ /**
6454
+ * Load the module tags and head file content to inject into the head (see `placeHead()`), joined by newlines,
6455
+ * or `''` when there is nothing to inject.
6456
+ */
6457
+ function loadHeadContent(modulePaths, headFile, diagnostics) {
6458
+ const parts = [];
6459
+ if (modulePaths.length > 0) {
6460
+ const modules = loadModules(modulePaths, diagnostics).trim();
6461
+ if (modules.length > 0) parts.push(modules);
6462
+ }
6463
+ if (headFile) try {
6464
+ const source = readUTF8(headFile, diagnostics).trim();
6465
+ if (source.length > 0) parts.push(source);
6466
+ } catch (e) {
6467
+ diagnostics?.push({
6468
+ level: "warning",
6469
+ message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
6470
+ });
6471
+ }
6472
+ return parts.join("\n");
6473
+ }
6474
+ /**
6475
+ * Place `content` in a template, given the texts that `fillTemplateParts()` scans for the slot (the template, and
6476
+ * the footer of a pre-1.4 Twine 1 format):
6477
+ *
6478
+ * - on its own line before the first closing head tag, as Tweego does; the tag may use any letter case and
6479
+ * whitespace before its `>`, and is kept as written;
6480
+ * - in a template with no closing head tag (HTML lets a document leave it out), on its own line before the first
6481
+ * body start tag, where the HTML parser still puts it in the head, with a warning (Tweego drops it);
6482
+ * - in a template with neither, nowhere, with a warning.
6483
+ *
6484
+ * `owner` names the template in the warnings. Empty `content` needs no slot and gets no warning.
6485
+ */
6486
+ function placeHead(content, texts, owner) {
6487
+ if (content.length === 0) return {
6488
+ slot: void 0,
6489
+ diagnostics: []
6490
+ };
6491
+ const found = (tag) => texts.some((text) => new RegExp(tag).test(text));
6492
+ if (found("<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>")) return {
6493
+ slot: beforeTag(CLOSING_HEAD_TAG, content),
6494
+ diagnostics: []
6495
+ };
6496
+ if (found("<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])")) {
6497
+ const message = `${owner} has no closing head tag; the modules and head file were injected before its body start tag.`;
6498
+ return {
6499
+ slot: beforeTag(BODY_START_TAG, content),
6500
+ diagnostics: [{
6501
+ level: "warning",
6502
+ message
6503
+ }]
6504
+ };
6505
+ }
6506
+ return {
6507
+ slot: void 0,
6508
+ diagnostics: [{
6509
+ level: "warning",
6510
+ message: `${owner} has no closing head tag and no body start tag; the modules and head file were not injected.`
6511
+ }]
6512
+ };
6513
+ }
6514
+ /** The slot that inserts `content` on its own line before the first match of `tag`, which is kept as written. */
6515
+ function beforeTag(tag, content) {
6516
+ return {
6517
+ pattern: tag,
6518
+ occurrences: "first",
6519
+ replacement: (match) => `${content}\n${match}`
6520
+ };
6521
+ }
6522
+ //#endregion
5227
6523
  //#region src/version.ts
5228
6524
  /**
5229
6525
  * The twee-ts version, read at runtime from the package's own package.json.
@@ -5257,24 +6553,42 @@ const CREATOR_NAME$2 = "Twee-ts";
5257
6553
  function toTwine2Archive(story, startName, options) {
5258
6554
  return getTwine2DataChunk(story, startName, options) + "\n";
5259
6555
  }
6556
+ /**
6557
+ * Fill the Twine 2 format template: every `{{STORY_NAME}}` gets the escaped story name and the first
6558
+ * `{{STORY_DATA}}` the story data (as Tweego does), and `head` goes before the first closing head tag (or, with a
6559
+ * warning, the body start tag; see `placeHead()`). All three are found in the template in one pass, so a story
6560
+ * name, passage text or head content holding a placeholder or a closing head tag stays literal.
6561
+ * `options.diagnostics` receives a warning when the format file is not valid UTF-8, and any warning about where
6562
+ * `head` went.
6563
+ */
5260
6564
  function toTwine2HTML(story, format, startName, options) {
5261
- let template = readFormatSource(format);
5262
- if (template.includes("{{STORY_NAME}}")) {
5263
- const name = htmlEscape(story.name);
5264
- template = template.replaceAll("{{STORY_NAME}}", () => name);
5265
- }
5266
- if (template.includes("{{STORY_DATA}}")) {
5267
- const data = getTwine2DataChunk({
5268
- ...story,
5269
- twine2: {
5270
- ...story.twine2,
5271
- format: format.name,
5272
- formatVersion: format.version
6565
+ const name = htmlEscape(story.name);
6566
+ const template = readFormatSource(format, options?.diagnostics);
6567
+ const placement = placeHead(options?.head ?? "", [template], `Story format "${format.name}" ${format.version}`);
6568
+ options?.diagnostics?.push(...placement.diagnostics);
6569
+ const head = placement.slot;
6570
+ return fillTemplate(template, [
6571
+ {
6572
+ pattern: literal("{{STORY_NAME}}"),
6573
+ occurrences: "all",
6574
+ replacement: () => name
6575
+ },
6576
+ {
6577
+ pattern: literal("{{STORY_DATA}}"),
6578
+ occurrences: "first",
6579
+ replacement: () => {
6580
+ return getTwine2DataChunk({
6581
+ ...story,
6582
+ twine2: {
6583
+ ...story.twine2,
6584
+ format: format.name,
6585
+ formatVersion: format.version
6586
+ }
6587
+ }, startName, options);
5273
6588
  }
5274
- }, startName, options);
5275
- template = template.replace("{{STORY_DATA}}", () => data);
5276
- }
5277
- return template;
6589
+ },
6590
+ ...head === void 0 ? [] : [head]
6591
+ ]);
5278
6592
  }
5279
6593
  const OMITTING_TAGS = [
5280
6594
  "Twine.private",
@@ -5367,39 +6681,83 @@ function toTwine1Archive(story, _startName) {
5367
6681
  const { data, count } = getTwine1PassageChunk(story);
5368
6682
  return `<div id="storeArea" data-size="${count}">${data}</div>\n`;
5369
6683
  }
5370
- function toTwine1HTML(story, format, startName) {
6684
+ /**
6685
+ * Fill the Twine 1 format template. The format's components are inserted first, one after another, as Tweego and
6686
+ * Twine 1 do. Then the first of each story placeholder (`"VERSION"`, `"TIME"`, `"START_AT"`, `"STORY_SIZE"`,
6687
+ * `"STORY"`), the IFID comment and `head` (before the first closing head tag or, with a warning, the body start
6688
+ * tag; see `placeHead()`) are found in one pass, so a start passage name or story data holding a placeholder or a
6689
+ * closing head tag stays literal. `options.diagnostics` receives a warning for each format file that is not valid
6690
+ * UTF-8, and any warning about where `head` went.
6691
+ */
6692
+ function toTwine1HTML(story, format, startName, options) {
5371
6693
  const formatDir = (0, node_path.dirname)(format.filename);
5372
6694
  const parentDir = (0, node_path.dirname)(formatDir);
5373
- let template = readFormatSource(format);
6695
+ const diagnostics = options?.diagnostics;
6696
+ let template = readFormatSource(format, diagnostics);
5374
6697
  const { data, count } = getTwine1PassageChunk(story);
5375
- template = tryReplaceComponent(template, "\"USER_LIB\"", (0, node_path.join)(formatDir, "userlib.js"), false);
5376
- template = tryReplaceComponent(template, "\"ENGINE\"", (0, node_path.join)(parentDir, "engine.js"), true);
5377
- template = tryReplaceComponent(template, "\"SUGARCANE\"", (0, node_path.join)(formatDir, "code.js"), true);
5378
- template = tryReplaceComponent(template, "\"JONAH\"", (0, node_path.join)(formatDir, "code.js"), true);
5379
- if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", (0, node_path.join)(parentDir, "jquery.js"), true);
5380
- if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", (0, node_path.join)(parentDir, "modernizr.js"), true);
6698
+ template = tryReplaceComponent(template, "\"USER_LIB\"", (0, node_path.join)(formatDir, "userlib.js"), false, diagnostics);
6699
+ template = tryReplaceComponent(template, "\"ENGINE\"", (0, node_path.join)(parentDir, "engine.js"), true, diagnostics);
6700
+ template = tryReplaceComponent(template, "\"SUGARCANE\"", (0, node_path.join)(formatDir, "code.js"), true, diagnostics);
6701
+ template = tryReplaceComponent(template, "\"JONAH\"", (0, node_path.join)(formatDir, "code.js"), true, diagnostics);
6702
+ if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", (0, node_path.join)(parentDir, "jquery.js"), true, diagnostics);
6703
+ if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", (0, node_path.join)(parentDir, "modernizr.js"), true, diagnostics);
6704
+ const isPre14 = !template.includes("\"STORY\"");
6705
+ const footer = isPre14 ? readFooter(formatDir, diagnostics) : "";
6706
+ const storeArea = (template + footer).includes("<div id=\"store-area\"") ? "<div id=\"store-area\"" : "<div id=\"storeArea\"";
6707
+ const safeIfid = htmlCommentSanitize(story.ifid);
6708
+ const ifid = story.ifid ? {
6709
+ pattern: literal(storeArea),
6710
+ occurrences: "first",
6711
+ replacement: (div) => `<!-- UUID://${safeIfid}// -->${div}`
6712
+ } : void 0;
6713
+ const placement = placeHead(options?.head ?? "", isPre14 ? [template, footer] : [template], `Story format "${format.name}"`);
6714
+ diagnostics?.push(...placement.diagnostics);
6715
+ const late = [ifid, placement.slot].filter((slot) => slot !== void 0);
5381
6716
  const displayStart = startName === "Start" ? "" : startName;
5382
- template = template.replace("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`);
5383
- template = template.replace("\"TIME\"", `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`);
5384
- const startAtValue = `"${jsStringEscape(displayStart)}"`;
5385
- template = template.replace("\"START_AT\"", () => startAtValue);
5386
- template = template.replace("\"STORY_SIZE\"", `"${count}"`);
5387
- if (template.includes("\"STORY\"")) template = template.replace("\"STORY\"", () => data);
5388
- else {
5389
- let footer;
5390
- try {
5391
- footer = (0, node_fs.readFileSync)((0, node_path.join)(formatDir, "footer.html"), "utf-8");
5392
- } catch {
5393
- footer = "</div>\n</body>\n</html>\n";
5394
- }
5395
- template += data + footer;
5396
- }
5397
- if (story.ifid) {
5398
- const safeIfid = htmlCommentSanitize(story.ifid);
5399
- if (template.includes("<div id=\"store-area\"")) template = template.replace("<div id=\"store-area\"", `<!-- UUID://${safeIfid}// --><div id="store-area"`);
5400
- else template = template.replace("<div id=\"storeArea\"", `<!-- UUID://${safeIfid}// --><div id="storeArea"`);
6717
+ const slots = [
6718
+ firstSlot("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`),
6719
+ firstSlot("\"TIME\"", `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`),
6720
+ firstSlot("\"START_AT\"", `"${jsStringEscape(displayStart)}"`),
6721
+ firstSlot("\"STORY_SIZE\"", `"${count}"`),
6722
+ firstSlot("\"STORY\"", data),
6723
+ ...late
6724
+ ];
6725
+ return fillTemplateParts(isPre14 ? [
6726
+ {
6727
+ kind: "scan",
6728
+ text: template,
6729
+ slots
6730
+ },
6731
+ {
6732
+ kind: "verbatim",
6733
+ text: data
6734
+ },
6735
+ {
6736
+ kind: "scan",
6737
+ text: footer,
6738
+ slots: late
6739
+ }
6740
+ ] : [{
6741
+ kind: "scan",
6742
+ text: template,
6743
+ slots
6744
+ }]);
6745
+ }
6746
+ /** A slot replacing the first `token` with `value`. */
6747
+ function firstSlot(token, value) {
6748
+ return {
6749
+ pattern: literal(token),
6750
+ occurrences: "first",
6751
+ replacement: () => value
6752
+ };
6753
+ }
6754
+ /** The footer of a pre-1.4 format, or the default one when the format has none. */
6755
+ function readFooter(formatDir, diagnostics) {
6756
+ try {
6757
+ return readUTF8((0, node_path.join)(formatDir, "footer.html"), diagnostics);
6758
+ } catch {
6759
+ return "</div>\n</body>\n</html>\n";
5401
6760
  }
5402
- return template;
5403
6761
  }
5404
6762
  /**
5405
6763
  * Why Twine 1 output leaves a passage out of its tiddlers, or `undefined` when the passage is emitted.
@@ -5424,11 +6782,10 @@ function getTwine1PassageChunk(story) {
5424
6782
  count
5425
6783
  };
5426
6784
  }
5427
- function tryReplaceComponent(template, placeholder, componentPath, required) {
6785
+ function tryReplaceComponent(template, placeholder, componentPath, required, diagnostics) {
5428
6786
  if (!template.includes(placeholder)) return template;
5429
6787
  try {
5430
- let content = (0, node_fs.readFileSync)(componentPath, "utf-8");
5431
- if (content.charCodeAt(0) === 65279) content = content.slice(1);
6788
+ const content = readUTF8(componentPath, diagnostics);
5432
6789
  return template.replace(placeholder, () => content);
5433
6790
  } catch (e) {
5434
6791
  if (required) throw new Error(`Required format component not found: ${componentPath}: ${e instanceof Error ? e.message : String(e)}`);
@@ -5442,11 +6799,16 @@ function tryReplaceComponent(template, placeholder, componentPath, required) {
5442
6799
  * from its loaded text, so changes made after loading (a start passage override, test mode) are kept.
5443
6800
  * Tweego writes the loaded text; its own normalization makes the two the same for an unchanged story.
5444
6801
  *
6802
+ * Twee cannot write every passage so that it reads back the same (see `tweeRoundTrip()`); `diagnostics`
6803
+ * receives a warning for each passage that will not. Tweego writes the same Twee without one.
6804
+ *
5445
6805
  * `addStoryData`: add a StoryData passage when the story has none, so that StoryData fields the
5446
6806
  * compile options set are recorded. Default: false.
5447
6807
  */
5448
6808
  function toTwee(story, outMode, options = {}) {
5449
- return withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => passageToTwee(p, outMode)).join("");
6809
+ const written = withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => tweeRoundTrip(p, outMode));
6810
+ options.diagnostics?.push(...written.flatMap(({ diagnostics }) => diagnostics));
6811
+ return written.map(({ passage }) => passageToTwee(passage, outMode)).join("");
5450
6812
  }
5451
6813
  function withEffectiveStoryData(story, addStoryData) {
5452
6814
  const storyData = {
@@ -5471,74 +6833,63 @@ function withEffectiveStoryData(story, addStoryData) {
5471
6833
  function isStoryDataJSON(text) {
5472
6834
  return unmarshalStoryData(createStory(), text) === null;
5473
6835
  }
5474
- //#endregion
5475
- //#region src/modules.ts
5476
- /**
5477
- * Load modules and return HTML tags to inject before </head>.
5478
- */
5479
- function loadModules(filenames) {
5480
- const processed = /* @__PURE__ */ new Set();
5481
- const headTags = [];
5482
- for (const filename of filenames) {
5483
- if (processed.has(filename)) continue;
5484
- const ext = normalizedFileExt(filename);
5485
- let tag = null;
5486
- switch (ext) {
5487
- case "css":
5488
- tag = loadModuleTagged("style", filename);
5489
- break;
5490
- case "js":
5491
- tag = loadModuleTagged("script", filename);
5492
- break;
5493
- case "otf":
5494
- case "ttf":
5495
- case "woff":
5496
- case "woff2":
5497
- tag = loadModuleFont(filename);
5498
- break;
5499
- default: continue;
5500
- }
5501
- if (tag) headTags.push(tag);
5502
- processed.add(filename);
5503
- }
5504
- return headTags.join("\n");
5505
- }
5506
- function loadModuleTagged(tag, filename) {
5507
- const source = readUTF8(filename).trim();
5508
- if (source.length === 0) return null;
5509
- return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
5510
- }
5511
- function loadModuleFont(filename) {
5512
- const source = readBase64(filename);
5513
- const family = baseNameWithoutExt(filename);
5514
- const idSlug = `style-module-${slugify(family)}`;
5515
- const ext = normalizedFileExt(filename);
5516
- 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>`;
5517
- }
6836
+ /** Characters Twee 3 escapes in names and tags, and Twee 1 cannot write there at all. */
6837
+ const TWEE1_UNWRITABLE = /[[\]{}\\]/;
5518
6838
  /**
5519
- * Inject modules and head file content before </head>.
5520
- */
5521
- function modifyHead(html, modulePaths, headFile, diagnostics) {
5522
- const parts = [];
5523
- if (modulePaths.length > 0) {
5524
- const modules = loadModules(modulePaths).trim();
5525
- if (modules.length > 0) parts.push(modules);
5526
- }
5527
- if (headFile) try {
5528
- const source = readUTF8(headFile).trim();
5529
- if (source.length > 0) parts.push(source);
5530
- } catch (e) {
5531
- diagnostics?.push({
6839
+ * Check that Twee reads `p` back as written, and say what it will not. Twee has no way to escape passage text,
6840
+ * and its parser trims names and splits tags at whitespace, so these do not survive:
6841
+ *
6842
+ * - a text line that starts with `::`, which reads as a new passage header. In stylesheet and script passages such
6843
+ * a line is indented by one space, which keeps the CSS or JavaScript working (only a template literal or a
6844
+ * string continued across lines, in either, would see the space); the warning says so. Other passages' text is
6845
+ * written as it is.
6846
+ * - a name that is empty, has leading or trailing whitespace, or holds a line break;
6847
+ * - a tag that is empty or holds whitespace;
6848
+ * - in Twee 1, which escapes nothing, a name or tag holding `[`, `]`, `{`, `}` or `\`.
6849
+ */
6850
+ function tweeRoundTrip(p, outMode) {
6851
+ const label = `Passage ${JSON.stringify(p.name)}`;
6852
+ const diagnostics = [];
6853
+ const warn = (problem) => {
6854
+ diagnostics.push({
5532
6855
  level: "warning",
5533
- message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
6856
+ message: `${label} cannot be written as Twee that reads back the same: ${problem}.`
5534
6857
  });
6858
+ };
6859
+ if (p.name.trim() === "") warn("its name is empty");
6860
+ else if (p.name.trim() !== p.name) warn("its name has leading or trailing whitespace, which Twee drops");
6861
+ if (/[\r\n]/.test(p.name)) warn("its name has a line break, which ends a Twee passage header");
6862
+ if (outMode === "twee1" && TWEE1_UNWRITABLE.test(p.name)) warn("its name has \"[\", \"]\", \"{\", \"}\" or \"\\\", which Twee 1 cannot escape");
6863
+ for (const tag of p.tags) {
6864
+ if (tag === "" || /\s/.test(tag)) warn(`its tag ${JSON.stringify(tag)} is empty or has whitespace, which splits tags in Twee`);
6865
+ if (outMode === "twee1" && TWEE1_UNWRITABLE.test(tag)) warn(`its tag ${JSON.stringify(tag)} has "[", "]", "{", "}" or "\\", which Twee 1 cannot escape`);
6866
+ }
6867
+ const lines = p.text.split(/(\r\n?|\n)/);
6868
+ const headerLines = lines.flatMap((line, i) => i % 2 === 0 && line.startsWith("::") ? [i / 2 + 1] : []);
6869
+ if (headerLines.length === 0) return {
6870
+ passage: p,
6871
+ diagnostics
6872
+ };
6873
+ 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`;
6874
+ if (!p.tags.some((tag) => tag === "stylesheet" || tag === "script")) {
6875
+ warn(where);
6876
+ return {
6877
+ passage: p,
6878
+ diagnostics
6879
+ };
5535
6880
  }
5536
- if (parts.length > 0) {
5537
- parts.push("</head>");
5538
- const replacement = parts.join("\n");
5539
- return html.replace("</head>", () => replacement);
5540
- }
5541
- return html;
6881
+ diagnostics.push({
6882
+ level: "warning",
6883
+ message: `${label}: ${where}; ${headerLines.length === 1 ? "it was" : "they were"} written indented by one space.`
6884
+ });
6885
+ const text = lines.map((line, i) => i % 2 === 0 && line.startsWith("::") ? ` ${line}` : line).join("");
6886
+ return {
6887
+ passage: {
6888
+ ...p,
6889
+ text
6890
+ },
6891
+ diagnostics
6892
+ };
5542
6893
  }
5543
6894
  //#endregion
5544
6895
  //#region src/passage-omission.ts
@@ -5606,39 +6957,52 @@ async function compile(options) {
5606
6957
  return buildOutput(options);
5607
6958
  }
5608
6959
  /**
5609
- * Compile and write to a file.
6960
+ * Compile and write to a file. The file is replaced atomically: a reader sees the previous
6961
+ * build or the new one, never part of it, and a failed write leaves the previous build.
5610
6962
  */
5611
6963
  async function compileToFile(options) {
5612
6964
  const result = await compileForOutputFile(options, options.outFile);
5613
- (0, node_fs.writeFileSync)(options.outFile, result.output, "utf-8");
6965
+ writeFileAtomic(options.outFile, result.output);
5614
6966
  return result;
5615
6967
  }
5616
6968
  /**
5617
- * Builds as compileToFile() does for `outFile`, without writing it: the output may sit
6969
+ * Builds as compileToFile() does for `outputs`, without writing them: an output may sit
5618
6970
  * inside a source folder, so its last build is left out of the sources and modules and
5619
- * never read back. The caller decides whether the result is written. With no `outFile`
5620
- * (output to stdout, or none), this is compile().
6971
+ * never read back. `outputs` is the one file the CLI writes, or every path a bundler's
6972
+ * build writes (see BuildOutputs). The caller decides whether the result is written.
6973
+ * With no outputs (output to stdout, or none), this is compile(). With `cache`, files
6974
+ * are cached as compileIncremental() caches them.
5621
6975
  *
5622
- * Internal, for the CLI; not part of the public API.
6976
+ * Throws a TweeTsError when a source, module or head file named directly is an output.
6977
+ *
6978
+ * Internal, for the CLI and the bundler plugins; not part of the public API.
5623
6979
  */
5624
- async function compileForOutputFile(options, outFile) {
5625
- return buildOutput(options, void 0, void 0, outFile);
6980
+ async function compileForOutputFile(options, outputs, cache) {
6981
+ return buildOutput(options, cache, void 0, outputs);
5626
6982
  }
5627
6983
  /**
5628
6984
  * Compile with incremental caching support.
5629
6985
  * Plugins and advanced users can manage their own cache and changed-file tracking.
5630
6986
  *
5631
6987
  * Without `changedFiles`, a cached file is reused while its modification time is unchanged.
5632
- * With it, a file it names (by the path source discovery gives it, relative to the working
5633
- * directory) is always reparsed, whatever its modification time, and every other cached
5634
- * file is reused as it is.
6988
+ * With it, a file it names is always reparsed, whatever its modification time, and every
6989
+ * other cached file is reused as it is. A file may be named by an absolute path or by a path
6990
+ * relative to the working directory, with or without a leading `./`: entries are matched to
6991
+ * the source files by resolved path. A file that fails to load is dropped from the cache, so
6992
+ * the next build tries it again.
5635
6993
  */
5636
6994
  async function compileIncremental(options, cache, changedFiles) {
5637
6995
  return buildOutput(options, cache, changedFiles);
5638
6996
  }
5639
6997
  /**
5640
- * Watch for file changes and recompile. Every build is written to `outFile`, including
5641
- * one whose diagnostics report errors.
6998
+ * Watch for file changes and recompile. Every build is written to `outFile` (replacing it
6999
+ * atomically, as compileToFile() does), including one whose diagnostics report errors.
7000
+ * Builds run one at a time, and each one is written and reported as it finishes, in order:
7001
+ * changes made during a build go into one follow-up build after it. A watched path that
7002
+ * can't be watched is passed to `onError`.
7003
+ *
7004
+ * Aborting the returned controller, or the `signal` in the options, stops watching and
7005
+ * aborts the story format requests of the build in progress.
5642
7006
  */
5643
7007
  async function watch(options) {
5644
7008
  return watchWithWriteFilter(options, () => true);
@@ -5650,9 +7014,13 @@ async function watch(options) {
5650
7014
  * Internal, for the CLI, which keeps the last good output when a rebuild reports errors;
5651
7015
  * not part of the public API.
5652
7016
  */
5653
- async function watchWithWriteFilter(options, shouldWrite) {
7017
+ async function watchWithWriteFilter(options, shouldWrite, hooks = {}) {
5654
7018
  const controller = new AbortController();
5655
7019
  const cache = /* @__PURE__ */ new Map();
7020
+ const buildOptions = {
7021
+ ...options,
7022
+ signal: controller.signal
7023
+ };
5656
7024
  const filePaths = options.sources.filter((s) => typeof s === "string");
5657
7025
  const modulePaths = options.modules ?? [];
5658
7026
  const headPaths = options.headFile ? [options.headFile] : [];
@@ -5668,22 +7036,82 @@ async function watchWithWriteFilter(options, shouldWrite) {
5668
7036
  const abs = (0, node_path.resolve)(filename);
5669
7037
  return !notExcludable.some((root) => abs === root || abs.startsWith(root + node_path.sep));
5670
7038
  };
7039
+ let building = false;
7040
+ let queued;
7041
+ const reportError = (error) => {
7042
+ try {
7043
+ options.onError?.(error);
7044
+ } catch (e) {
7045
+ console.error(`twee-ts watch: onError threw while handling "${error.message}": ${toError(e).message}`);
7046
+ }
7047
+ };
7048
+ const deliver = (outcome) => {
7049
+ try {
7050
+ if (!outcome.ok) throw outcome.error;
7051
+ if (shouldWrite(outcome.result)) writeFileAtomic(options.outFile, outcome.result.output);
7052
+ options.onBuild?.(outcome.result);
7053
+ } catch (e) {
7054
+ reportError(toError(e));
7055
+ }
7056
+ };
7057
+ const drain = async (first) => {
7058
+ building = true;
7059
+ try {
7060
+ let request = first;
7061
+ while (request !== void 0) {
7062
+ const outcome = await buildOutput(buildOptions, cache, request.changedFiles, options.outFile).then((result) => ({
7063
+ ok: true,
7064
+ result
7065
+ }), (e) => ({
7066
+ ok: false,
7067
+ error: toError(e)
7068
+ }));
7069
+ if (controller.signal.aborted) return;
7070
+ deliver(outcome);
7071
+ request = queued;
7072
+ queued = void 0;
7073
+ }
7074
+ } finally {
7075
+ building = false;
7076
+ hooks.onIdle?.();
7077
+ }
7078
+ };
5671
7079
  const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
5672
- buildOutput(options, cache, changedFiles, options.outFile).then((result) => {
5673
- if (shouldWrite(result)) (0, node_fs.writeFileSync)(options.outFile, result.output, "utf-8");
5674
- options.onBuild?.(result);
5675
- }).catch((e) => {
5676
- options.onError?.(e instanceof Error ? e : new Error(String(e)));
5677
- });
5678
- }, ignore);
5679
- controller.signal.addEventListener("abort", () => handle.close());
7080
+ if (controller.signal.aborted) return;
7081
+ const request = { changedFiles };
7082
+ if (building) {
7083
+ queued = queued === void 0 ? request : mergeBuildRequests(queued, request);
7084
+ return;
7085
+ }
7086
+ drain(request).catch((e) => reportError(toError(e)));
7087
+ }, ignore, reportError);
7088
+ const outer = options.signal;
7089
+ const onOuterAbort = () => controller.abort(outer?.reason);
7090
+ controller.signal.addEventListener("abort", () => {
7091
+ queued = void 0;
7092
+ handle.close();
7093
+ outer?.removeEventListener("abort", onOuterAbort);
7094
+ });
7095
+ if (outer?.aborted) onOuterAbort();
7096
+ else outer?.addEventListener("abort", onOuterAbort, { once: true });
5680
7097
  return controller;
5681
7098
  }
7099
+ /** One build covering both requests: a full build if either is one, else every changed file. */
7100
+ function mergeBuildRequests(a, b) {
7101
+ if (a.changedFiles === void 0 || b.changedFiles === void 0) return { changedFiles: void 0 };
7102
+ return { changedFiles: /* @__PURE__ */ new Set([...a.changedFiles, ...b.changedFiles]) };
7103
+ }
7104
+ function toError(e) {
7105
+ return e instanceof Error ? e : new Error(String(e));
7106
+ }
5682
7107
  /**
5683
- * `outFile`: the file the build is written to, which source and module discovery
5684
- * skip (as Tweego does), so an output inside a source folder is never loaded back.
7108
+ * `outputs`: what the build writes (an output file's path, or a bundler's outputs),
7109
+ * which source and module discovery skip, so an output inside a source folder is
7110
+ * never loaded back. A source, module or head file named directly that is an output
7111
+ * is a TweeTsError, as in Tweego: writing the build would overwrite it.
5685
7112
  */
5686
- async function buildOutput(options, cache, changedFiles, outFile) {
7113
+ async function buildOutput(options, cache, changedFiles, outputs) {
7114
+ const written = toBuildOutputs(outputs);
5687
7115
  const diagnostics = [];
5688
7116
  const outputMode = options.outputMode ?? "html";
5689
7117
  const trim = options.trim ?? true;
@@ -5691,13 +7119,18 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5691
7119
  const testMode = options.testMode ?? false;
5692
7120
  const noRemote = options.noRemote ?? false;
5693
7121
  const sourceInfo = options.sourceInfo ?? false;
7122
+ options.signal?.throwIfAborted();
7123
+ const timeout = options.formatFetchTimeout;
7124
+ if (timeout !== void 0 && !(timeout >= 0)) throw new TweeTsError(`formatFetchTimeout must be 0 or more milliseconds, not ${timeout}.`);
5694
7125
  clearIndexCache();
5695
7126
  const filePaths = [];
5696
7127
  const inlineSources = [];
5697
7128
  for (const source of options.sources) if (typeof source === "string") filePaths.push(source);
5698
7129
  else inlineSources.push(source);
5699
- const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths, outFile, options.exclude);
5700
- diagnostics.push(...sourcePathDiagnostics);
7130
+ const sourcePaths = getFilenames(filePaths, written, options.exclude);
7131
+ diagnostics.push(...sourcePaths.diagnostics);
7132
+ rejectOutputSources(sourcePaths.outputSources, diagnostics);
7133
+ const sourceFilenames = sourcePaths.filenames;
5701
7134
  const story = createStory();
5702
7135
  const processedFiles = /* @__PURE__ */ new Set();
5703
7136
  if (cache) loadSourcesCached(story, sourceFilenames, {
@@ -5726,7 +7159,10 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5726
7159
  switch (outputMode) {
5727
7160
  case "twee3":
5728
7161
  case "twee1":
5729
- output = toTwee(story, outputMode, { addStoryData: Boolean(options.startPassage) || testMode });
7162
+ output = toTwee(story, outputMode, {
7163
+ addStoryData: Boolean(options.startPassage) || testMode,
7164
+ diagnostics
7165
+ });
5730
7166
  break;
5731
7167
  case "twine2-archive":
5732
7168
  output = toTwine2Archive(story, startName, { sourceInfo });
@@ -5740,17 +7176,24 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5740
7176
  case "html": {
5741
7177
  diagnostics.push(...startPassageDiagnostics(story, startName, format?.isTwine2 === false ? "twine1" : "twine2"));
5742
7178
  if (!format) throw new TweeTsError("No story format available for HTML output.", diagnostics);
5743
- if (format.isTwine2) output = toTwine2HTML(story, format, startName, { sourceInfo });
5744
- else {
5745
- if (story.name === "" && !storyHas(story, "StoryTitle")) diagnostics.push({
5746
- level: "error",
5747
- message: "Special passage \"StoryTitle\" not found."
5748
- });
5749
- output = toTwine1HTML(story, format, startName);
5750
- }
5751
- const modules = getFilenames(options.modules ?? [], outFile);
7179
+ if (!format.isTwine2 && story.name === "" && !storyHas(story, "StoryTitle")) diagnostics.push({
7180
+ level: "error",
7181
+ message: "Special passage \"StoryTitle\" not found."
7182
+ });
7183
+ const modules = getFilenames(options.modules ?? [], written);
5752
7184
  diagnostics.push(...modules.diagnostics);
5753
- output = modifyHead(output, modules.filenames, options.headFile, diagnostics);
7185
+ const { headFile } = options;
7186
+ const headIsOutput = headFile !== void 0 && outputPaths(written).isFile(realPathOf(headFile));
7187
+ rejectOutputSources([...modules.outputSources, ...headIsOutput ? [headFile] : []], diagnostics);
7188
+ const head = loadHeadContent(modules.filenames, options.headFile, diagnostics);
7189
+ output = format.isTwine2 ? toTwine2HTML(story, format, startName, {
7190
+ sourceInfo,
7191
+ head,
7192
+ diagnostics
7193
+ }) : toTwine1HTML(story, format, startName, {
7194
+ head,
7195
+ diagnostics
7196
+ });
5754
7197
  break;
5755
7198
  }
5756
7199
  default: throw new TweeTsError(`Unhandled output mode: ${outputMode}`, diagnostics);
@@ -5767,6 +7210,16 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5767
7210
  stats
5768
7211
  };
5769
7212
  }
7213
+ /**
7214
+ * Throws for the first of `paths`, inputs named directly that are also an output:
7215
+ * writing the build would overwrite them. An output found while walking a source
7216
+ * folder is skipped instead (see getFilenames).
7217
+ */
7218
+ function rejectOutputSources(paths, diagnostics) {
7219
+ const [first] = paths;
7220
+ if (first === void 0) return;
7221
+ throw new TweeTsError(`path ${first}: Output file cannot be an input source.`, [...diagnostics]);
7222
+ }
5770
7223
  function ensureIFID(story, diagnostics) {
5771
7224
  if (story.ifid !== "") return;
5772
7225
  if (story.legacyIFID !== "") {
@@ -7251,6 +8704,16 @@ function passageLinks(p) {
7251
8704
  return extractLinksFromText(textAsRead(p), false);
7252
8705
  }
7253
8706
  /**
8707
+ * Whether the `target` output keeps the links in `p`: always without a target, which describes
8708
+ * every source passage. With one, a passage the output leaves out never reaches the player, so it
8709
+ * links nowhere, except a script passage, which Twine 2 output leaves out as a passage but runs.
8710
+ */
8711
+ function readsLinks(story, p, { target }) {
8712
+ if (target === void 0) return true;
8713
+ const omission = passageOmission(story, p, target);
8714
+ return omission === void 0 || omission.kind === "tag" && omission.tag === "script";
8715
+ }
8716
+ /**
7254
8717
  * Each passage name, mapped to why the `target` output leaves that passage out, or to `undefined`
7255
8718
  * when a link can reach it: always without a target, and otherwise when the output emits a
7256
8719
  * passage of that name.
@@ -7308,7 +8771,9 @@ function joinLines(text) {
7308
8771
  * By default, a link is broken only when no passage has its name. Pass a `target` to check links
7309
8772
  * against what that output emits: `storyInspect(result.story, { target: 'twine2' })` also reports
7310
8773
  * links to passages Twine 2 output leaves out, such as script, stylesheet and `Twine.private`
7311
- * passages.
8774
+ * passages. With a target, passages that output leaves out also give no links (script passages
8775
+ * excepted, as Twine 2 output runs them), so they report no broken links and keep no passage from
8776
+ * being an orphan.
7312
8777
  */
7313
8778
  function storyInspect(story, options = {}) {
7314
8779
  const destinations = destinationOmissions(story, options);
@@ -7324,7 +8789,7 @@ function storyInspect(story, options = {}) {
7324
8789
  if (isStoryPassage(p)) storyPassages.push(p.name);
7325
8790
  if (isInfoPassage(p)) infoPassages.push(p.name);
7326
8791
  tagsByPassage.set(p.name, [...p.tags]);
7327
- for (const tag of p.tags) {
8792
+ for (const tag of new Set(p.tags)) {
7328
8793
  tagSet.add(tag);
7329
8794
  let list = passagesByTag.get(tag);
7330
8795
  if (!list) {
@@ -7333,7 +8798,7 @@ function storyInspect(story, options = {}) {
7333
8798
  }
7334
8799
  list.push(p.name);
7335
8800
  }
7336
- links.set(p.name, passageLinks(p));
8801
+ links.set(p.name, readsLinks(story, p, options) ? passageLinks(p) : []);
7337
8802
  }
7338
8803
  const brokenLinks = [];
7339
8804
  for (const [from, targets] of links) for (const to of targets) {
@@ -7382,7 +8847,8 @@ function storyInspect(story, options = {}) {
7382
8847
  * Uses JSON output mode internally to avoid format resolution, so it checks
7383
8848
  * the starting passage itself, against the Twine 2 passage rules. Link
7384
8849
  * destinations are checked against the same rules: a link to a passage that
7385
- * Twine 2 output leaves out is broken.
8850
+ * Twine 2 output leaves out is broken, and a passage it leaves out (other than
8851
+ * a script passage) gives no links.
7386
8852
  */
7387
8853
  async function lint(options) {
7388
8854
  return lintForOutputFile(options, void 0);
@@ -7467,46 +8933,100 @@ const VALID_OUTPUT_MODES = [
7467
8933
  "json"
7468
8934
  ];
7469
8935
  const VALID_WORD_COUNT_METHODS = ["tweego", "whitespace"];
7470
- /** Load a config file from the given directory (default: cwd). Returns null if not found. */
7471
- function loadConfig(dir) {
8936
+ /** One entry per {@link TweeTsConfig} key: the compiler rejects a missing or an extra key here. */
8937
+ const CONFIG_KEY_SET = {
8938
+ sources: true,
8939
+ exclude: true,
8940
+ output: true,
8941
+ outputMode: true,
8942
+ formatId: true,
8943
+ startPassage: true,
8944
+ formatPaths: true,
8945
+ formatIndices: true,
8946
+ formatUrls: true,
8947
+ useTweegoPath: true,
8948
+ modules: true,
8949
+ headFile: true,
8950
+ trim: true,
8951
+ twee2Compat: true,
8952
+ testMode: true,
8953
+ noRemote: true,
8954
+ formatFetchTimeout: true,
8955
+ tagAliases: true,
8956
+ sourceInfo: true,
8957
+ wordCountMethod: true
8958
+ };
8959
+ /** The keys a config file may hold besides `$schema`; the JSON schema's `properties` list the same keys. */
8960
+ const CONFIG_KEYS = Object.keys(CONFIG_KEY_SET);
8961
+ /** The key that only references the JSON schema, for editors. */
8962
+ const SCHEMA_KEY = "$schema";
8963
+ /** A key folded so that spellings differing only in letter case, `-` or `_` compare equal. */
8964
+ const foldKey = (key) => key.toLowerCase().replace(/[-_]/g, "");
8965
+ /**
8966
+ * Load a config file from the given directory (default: cwd). Returns null if not found.
8967
+ *
8968
+ * @param diagnostics Receives warnings that do not stop the config from loading: keys the config does not
8969
+ * define, and a file that is not valid UTF-8 (it is read as Windows-1252).
8970
+ * @throws When the file is not valid JSON or fails {@link validateConfig}.
8971
+ */
8972
+ function loadConfig(dir, diagnostics) {
7472
8973
  const base = dir ?? process.cwd();
7473
8974
  const configPath = (0, node_path.join)(base, CONFIG_FILENAME);
7474
8975
  if (!(0, node_fs.existsSync)(configPath)) return null;
8976
+ const readDiagnostics = [];
7475
8977
  let raw;
7476
8978
  try {
7477
- raw = (0, node_fs.readFileSync)(configPath, "utf-8");
8979
+ raw = readUTF8(configPath, readDiagnostics);
7478
8980
  } catch {
7479
8981
  return null;
7480
8982
  }
7481
- let data;
7482
- try {
7483
- data = JSON.parse(raw);
7484
- } catch (e) {
7485
- throw new Error(`Invalid JSON in ${configPath}: ${e instanceof Error ? e.message : String(e)}`);
7486
- }
7487
- const errors = validateConfig(data);
7488
- if (errors.length > 0) throw new Error(`Invalid config in ${configPath}:\n ${errors.join("\n ")}`);
7489
- return data;
8983
+ const config = parseConfig(raw, configPath, readDiagnostics);
8984
+ diagnostics?.push(...readDiagnostics);
8985
+ return config;
7490
8986
  }
7491
- /** Load a config from a specific file path. */
7492
- function loadConfigFile(filePath) {
8987
+ /**
8988
+ * Load a config from a specific file path.
8989
+ *
8990
+ * @param diagnostics Receives warnings that do not stop the config from loading: keys the config does not
8991
+ * define, and a file that is not valid UTF-8 (it is read as Windows-1252).
8992
+ * @throws When the file cannot be read, is not valid JSON or fails {@link validateConfig}.
8993
+ */
8994
+ function loadConfigFile(filePath, diagnostics) {
8995
+ const readDiagnostics = [];
7493
8996
  let raw;
7494
8997
  try {
7495
- raw = (0, node_fs.readFileSync)(filePath, "utf-8");
8998
+ raw = readUTF8(filePath, readDiagnostics);
7496
8999
  } catch (e) {
7497
9000
  throw new Error(`Cannot read config file ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
7498
9001
  }
9002
+ const config = parseConfig(raw, filePath, readDiagnostics);
9003
+ diagnostics?.push(...readDiagnostics);
9004
+ return config;
9005
+ }
9006
+ /**
9007
+ * Parse and validate the text of the config file at `path`. Unknown keys are added to `diagnostics` as
9008
+ * warnings, so a config with a stray key keeps loading.
9009
+ */
9010
+ function parseConfig(raw, path, diagnostics) {
7499
9011
  let data;
7500
9012
  try {
7501
9013
  data = JSON.parse(raw);
7502
9014
  } catch (e) {
7503
- throw new Error(`Invalid JSON in ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
9015
+ throw new Error(`Invalid JSON in ${path}: ${e instanceof Error ? e.message : String(e)}`);
7504
9016
  }
7505
9017
  const errors = validateConfig(data);
7506
- if (errors.length > 0) throw new Error(`Invalid config in ${filePath}:\n ${errors.join("\n ")}`);
9018
+ if (errors.length > 0) throw new Error(`Invalid config in ${path}:\n ${errors.join("\n ")}`);
9019
+ diagnostics.push(...unknownConfigKeyWarnings(data).map((message) => ({
9020
+ level: "warning",
9021
+ message: `${path}: ${message}`,
9022
+ file: path
9023
+ })));
7507
9024
  return data;
7508
9025
  }
7509
- /** Validate a config object. Returns an array of error strings (empty = valid). */
9026
+ /**
9027
+ * Validate a config object. Returns an array of error strings (empty = valid).
9028
+ * Keys the config does not define are not errors; {@link unknownConfigKeyWarnings} reports them.
9029
+ */
7510
9030
  function validateConfig(data) {
7511
9031
  const errors = [];
7512
9032
  if (typeof data !== "object" || data === null || Array.isArray(data)) {
@@ -7539,6 +9059,10 @@ function validateConfig(data) {
7539
9059
  if (!Array.isArray(obj[key])) errors.push(`"${key}" must be an array.`);
7540
9060
  else if (!obj[key].every((v) => typeof v === "string")) errors.push(`"${key}" must be an array of strings.`);
7541
9061
  }
9062
+ if ("formatFetchTimeout" in obj) {
9063
+ const timeout = obj["formatFetchTimeout"];
9064
+ if (typeof timeout !== "number" || !(timeout >= 0)) errors.push("\"formatFetchTimeout\" must be a number of milliseconds, 0 or more.");
9065
+ }
7542
9066
  if ("tagAliases" in obj) {
7543
9067
  if (typeof obj["tagAliases"] !== "object" || obj["tagAliases"] === null || Array.isArray(obj["tagAliases"])) errors.push("\"tagAliases\" must be an object.");
7544
9068
  else {
@@ -7554,6 +9078,20 @@ function validateConfig(data) {
7554
9078
  }
7555
9079
  return errors;
7556
9080
  }
9081
+ /**
9082
+ * Warnings for the keys of a config object that the config does not define (the JSON schema's
9083
+ * `additionalProperties: false`), other than `$schema`. twee-ts ignores such a key, so a misspelt one would
9084
+ * otherwise leave its option at the default without a word. A key that differs from a defined one only in
9085
+ * letter case, `-` or `_` (`formatID`, `output-mode`) gets a suggestion. Returns `[]` for anything that is not
9086
+ * an object, which {@link validateConfig} reports.
9087
+ */
9088
+ function unknownConfigKeyWarnings(data) {
9089
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return [];
9090
+ return Object.keys(data).filter((key) => key !== SCHEMA_KEY && !Object.hasOwn(CONFIG_KEY_SET, key)).map((key) => {
9091
+ const suggestion = CONFIG_KEYS.find((known) => foldKey(known) === foldKey(key));
9092
+ return `Unknown config key "${key}"${suggestion === void 0 ? "" : ` (did you mean "${suggestion}"?)`}; it is ignored.`;
9093
+ });
9094
+ }
7557
9095
  /** Return a default config JSON string for --init scaffolding. */
7558
9096
  function scaffoldConfig() {
7559
9097
  return JSON.stringify({
@@ -7596,6 +9134,7 @@ exports.scaffoldConfig = scaffoldConfig;
7596
9134
  exports.semverCompare = semverCompare;
7597
9135
  exports.storyInspect = storyInspect;
7598
9136
  exports.tweeLexer = tweeLexer;
9137
+ exports.unknownConfigKeyWarnings = unknownConfigKeyWarnings;
7599
9138
  exports.validateConfig = validateConfig;
7600
9139
  exports.validateIFID = validateIFID;
7601
9140
  exports.watch = watch;