@rohal12/twee-ts 1.17.1 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
- import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import * as nodePath from "node:path";
3
3
  import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
4
- import { randomUUID } from "node:crypto";
4
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
5
5
  import { homedir } from "node:os";
6
6
  import { fileURLToPath } from "node:url";
7
7
  //#region src/ifid.ts
@@ -104,18 +104,44 @@ function tweeEscape(s) {
104
104
  if (s.length === 0) return s;
105
105
  return s.replace(/[\\[\]{}]/g, (ch) => "\\" + ch);
106
106
  }
107
- /** Escape a string for safe inclusion in a JavaScript string literal. */
107
+ const JS_STRING_ESCAPES = {
108
+ "\\": "\\\\",
109
+ "\"": "\\\"",
110
+ "'": "\\'",
111
+ "\n": "\\n",
112
+ "\r": "\\r",
113
+ " ": "\\t",
114
+ "<": "\\x3C",
115
+ "\u2028": "\\u2028",
116
+ "\u2029": "\\u2029"
117
+ };
118
+ /**
119
+ * Escape a string for the inside of a single- or double-quoted JavaScript string literal, including one written
120
+ * into an HTML `<script>` element. Besides backslashes, quotes and line breaks (with U+2028 and U+2029, which end
121
+ * a string literal in JavaScript before ES2019), every `<` becomes `\x3C`, so the literal holds no `<\/script`
122
+ * that would end the element and no `<!--` or `<script` that would change where it ends. The string's value is
123
+ * unchanged.
124
+ */
108
125
  function jsStringEscape(s) {
109
126
  if (s.length === 0) return s;
110
- return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
127
+ return s.replace(/[\\"'\n\r\t<\u2028\u2029]/g, (ch) => JS_STRING_ESCAPES[ch] ?? ch);
128
+ }
129
+ /**
130
+ * Escape a string for the inside of a double-quoted CSS string (such as a `font-family` name): `\` and `"` get
131
+ * a backslash, and line breaks and other control characters become hex escapes followed by a space (`\a ` for a
132
+ * line feed), since a raw line break ends a CSS string and a backslash before one is a line continuation.
133
+ */
134
+ function cssStringEscape(s) {
135
+ if (s.length === 0) return s;
136
+ return s.replace(/[\\"\u0000-\u001f\u007f]/g, (ch) => ch === "\\" || ch === "\"" ? `\\${ch}` : `\\${ch.charCodeAt(0).toString(16)} `);
111
137
  }
112
138
  /** Sanitize a string for safe inclusion in a CSS or JS block comment. */
113
139
  function commentSanitize(s) {
114
140
  return s.replace(/\*\//g, "* /");
115
141
  }
116
- /** Sanitize a string for safe inclusion in an HTML comment. */
142
+ /** Sanitize a string for safe inclusion in an HTML comment: break up `-->` and `--!>`, which both end one. */
117
143
  function htmlCommentSanitize(s) {
118
- return s.replace(/-->/g, "-- >");
144
+ return s.replace(/--(!?)>/g, "--$1 >");
119
145
  }
120
146
  /**
121
147
  * Escape JavaScript for the content of an HTML `<script>` element, so that the HTML parser reads all of it as
@@ -292,7 +318,19 @@ function passageToPassagedata(p, pid, options) {
292
318
  if (options?.sourceInfo && p.source) attrs += ` data-source-file=${quote(attrEscape(p.source.file))} data-source-line="${p.source.line}"`;
293
319
  return `${attrs}>${htmlEscape(p.text)}</tw-passagedata>`;
294
320
  }
295
- /** Generate `<div tiddler>` HTML for Twine 1. */
321
+ /**
322
+ * Whether Twine 1 `obfuscate:rot13` encodes a tiddler: every one but `StorySettings` and those tagged `Twine.image`
323
+ * (Twine 1.4 `Tiddler.isObfuscateable()`). Its engine tests the stored name and tags, which these tiddlers keep
324
+ * unencoded, so the decompiler tests them the same way.
325
+ */
326
+ function isObfuscatable(p) {
327
+ return p.name !== "StorySettings" && !p.tags.includes("Twine.image");
328
+ }
329
+ /**
330
+ * Generate `<div tiddler>` HTML for Twine 1. With `obfuscateRot13`, an obfuscatable tiddler (see
331
+ * `isObfuscatable()`) has its name, each tag and its text ROT13-encoded, as Twine 1.4 writes it
332
+ * (`Tiddler.toHtml()`), and as its engine.js decodes it.
333
+ */
296
334
  function passageToTiddler(p, pid, obfuscateRot13 = false) {
297
335
  let position;
298
336
  if (hasMetadataPosition(p)) position = p.metadata?.position ?? "";
@@ -304,9 +342,10 @@ function passageToTiddler(p, pid, obfuscateRot13 = false) {
304
342
  position = `${xp * 140 - 130},${yp * 140 - 130}`;
305
343
  }
306
344
  const created = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 12);
307
- const content = obfuscateRot13 && p.name !== "StorySettings" ? tiddlerEscape(rot13(p.text)) : tiddlerEscape(p.text);
308
- const nameComment = obfuscateRot13 ? `<!-- ${commentSanitize(p.name)} -->` : "";
309
- 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>`;
345
+ const encode = obfuscateRot13 && isObfuscatable(p) ? rot13 : (s) => s;
346
+ const name = attrEscape(encode(p.name));
347
+ const tags = attrEscape(p.tags.map(encode).join(" "));
348
+ return `<div tiddler=${quote(name)} tags=${quote(tags)} created=${quote(created)} modifier=${quote("twee")} twine-position=${quote(attrEscape(position))}>${tiddlerEscape(encode(p.text))}</div>`;
310
349
  }
311
350
  /**
312
351
  * Count words in a passage.
@@ -359,19 +398,128 @@ function quote(s) {
359
398
  }
360
399
  //#endregion
361
400
  //#region src/story.ts
362
- /** Module-level index: maps passage names to their array indices for O(1) lookups. */
363
401
  const passageIndex = /* @__PURE__ */ new WeakMap();
364
- function getIndex(story) {
365
- let index = passageIndex.get(story);
366
- if (!index) {
367
- index = /* @__PURE__ */ new Map();
368
- for (let i = 0; i < story.passages.length; i++) {
369
- const passage = story.passages[i];
370
- if (passage) index.set(passage.name, i);
402
+ function buildIndex(story) {
403
+ const names = story.passages.map((p) => p.name);
404
+ const index = {
405
+ passages: story.passages,
406
+ names,
407
+ positions: new Map(names.map((name, i) => [name, i]))
408
+ };
409
+ passageIndex.set(story, index);
410
+ return index;
411
+ }
412
+ /**
413
+ * The index, rebuilt if `story.passages` was reassigned or changed length. O(1); `position()`
414
+ * also checks every hit. A change that keeps the length, such as replacing an element, can still
415
+ * hide a passage from a lookup, so `storyVerifyIndex()` checks every position.
416
+ */
417
+ function currentIndex(story) {
418
+ const index = passageIndex.get(story);
419
+ return index !== void 0 && index.passages === story.passages && index.names.length === story.passages.length ? index : buildIndex(story);
420
+ }
421
+ /** The position of the passage named `name`, or -1. */
422
+ function position(story, name) {
423
+ const i = currentIndex(story).positions.get(name);
424
+ if (i === void 0) return -1;
425
+ if (story.passages[i]?.name === name) return i;
426
+ return buildIndex(story).positions.get(name) ?? -1;
427
+ }
428
+ /** Add a passage whose name no passage has yet at the end. */
429
+ function push(story, p) {
430
+ const index = currentIndex(story);
431
+ index.positions.set(p.name, story.passages.length);
432
+ index.names.push(p.name);
433
+ story.passages.push(p);
434
+ }
435
+ /** Put a passage at position `i`, in place of the passage there. */
436
+ function replaceAt(story, i, p) {
437
+ const renamed = story.passages[i]?.name !== p.name;
438
+ story.passages[i] = p;
439
+ if (renamed) buildIndex(story);
440
+ }
441
+ /** Names of compiler special passages, which storyAdd() reads into the story model. */
442
+ const SPECIAL_PASSAGE_NAMES = /* @__PURE__ */ new Set([
443
+ "StoryData",
444
+ "StoryIncludes",
445
+ "StorySettings",
446
+ "StoryTitle"
447
+ ]);
448
+ /** Passages whose names were generated, kept by identity so that cached passages keep the mark. */
449
+ const generatedNames = /* @__PURE__ */ new WeakMap();
450
+ /**
451
+ * Mark a passage's name as generated (from a file name, or for imported story code) and return
452
+ * the passage. `origin.base` is the name that free names are numbered from: `base`, `base 2`, ….
453
+ */
454
+ function withGeneratedName(p, origin) {
455
+ generatedNames.set(p, origin);
456
+ return p;
457
+ }
458
+ /** The first of `base`, `base 2`, `base 3`, … that `isFree` accepts. */
459
+ function freeName(base, isFree) {
460
+ if (isFree(base)) return base;
461
+ for (let n = 2;; n++) {
462
+ const candidate = `${base} ${n}`;
463
+ if (isFree(candidate)) return candidate;
464
+ }
465
+ }
466
+ /** Whether a generated name may take `name`: no passage has it and it is no special name. */
467
+ function isFreeForGenerated(story, name) {
468
+ return !SPECIAL_PASSAGE_NAMES.has(name) && position(story, name) === -1;
469
+ }
470
+ function reportRename(origin, from, to, diagnostics) {
471
+ switch (origin.kind) {
472
+ case "code": return;
473
+ case "media": {
474
+ const reason = SPECIAL_PASSAGE_NAMES.has(from) ? `"${from}" is a compiler special passage name` : `another passage has the name "${from}"`;
475
+ diagnostics.push({
476
+ level: "warning",
477
+ message: `Passage "${from}" from "${origin.file}" renamed to "${to}"; ${reason}.`
478
+ });
479
+ return;
371
480
  }
372
- passageIndex.set(story, index);
481
+ default: throw new Error(`unhandled generated name: ${JSON.stringify(origin)}`);
373
482
  }
374
- return index;
483
+ }
484
+ /** Add a passage with a generated name, under the first free name if its own is taken. */
485
+ function addGenerated(story, p, origin, diagnostics) {
486
+ if (isFreeForGenerated(story, p.name)) {
487
+ push(story, p);
488
+ return;
489
+ }
490
+ const name = freeName(origin.base, (n) => isFreeForGenerated(story, n));
491
+ reportRename(origin, p.name, name, diagnostics);
492
+ push(story, withGeneratedName({
493
+ ...p,
494
+ name
495
+ }, origin));
496
+ }
497
+ /**
498
+ * If a passage with a generated name has `name`, give it the first free name, in its place in the
499
+ * passage list, so that a passage from the sources can take the name.
500
+ */
501
+ function moveGeneratedAside(story, name, diagnostics) {
502
+ const i = position(story, name);
503
+ const existing = i === -1 ? void 0 : story.passages[i];
504
+ const origin = existing === void 0 ? void 0 : generatedNames.get(existing);
505
+ if (existing === void 0 || origin === void 0) return;
506
+ const free = freeName(origin.base, (n) => isFreeForGenerated(story, n));
507
+ reportRename(origin, name, free, diagnostics);
508
+ replaceAt(story, i, withGeneratedName({
509
+ ...existing,
510
+ name: free
511
+ }, origin));
512
+ }
513
+ function defaultTwine2Metadata() {
514
+ return {
515
+ format: "",
516
+ formatVersion: "",
517
+ options: /* @__PURE__ */ new Map(),
518
+ start: "",
519
+ tags: "",
520
+ tagColors: /* @__PURE__ */ new Map(),
521
+ zoom: 1
522
+ };
375
523
  }
376
524
  function createStory() {
377
525
  const story = {
@@ -380,52 +528,37 @@ function createStory() {
380
528
  passages: [],
381
529
  legacyIFID: "",
382
530
  twine1: { settings: /* @__PURE__ */ new Map() },
383
- twine2: {
384
- format: "",
385
- formatVersion: "",
386
- options: /* @__PURE__ */ new Map(),
387
- start: "",
388
- tags: "",
389
- tagColors: /* @__PURE__ */ new Map(),
390
- zoom: 1
391
- }
531
+ twine2: defaultTwine2Metadata()
392
532
  };
393
- passageIndex.set(story, /* @__PURE__ */ new Map());
533
+ buildIndex(story);
394
534
  return story;
395
535
  }
396
536
  function storyHas(story, name) {
397
- return getIndex(story).has(name);
537
+ return position(story, name) !== -1;
398
538
  }
399
539
  function storyAppend(story, p, diagnostics) {
400
- const index = getIndex(story);
401
- const i = index.get(p.name) ?? -1;
402
- if (i === -1) {
403
- index.set(p.name, story.passages.length);
404
- story.passages.push(p);
405
- } else {
540
+ const i = position(story, p.name);
541
+ if (i === -1) push(story, p);
542
+ else {
406
543
  diagnostics.push({
407
544
  level: "warning",
408
545
  message: `Replacing existing passage "${p.name}" with duplicate.`
409
546
  });
410
- story.passages[i] = p;
547
+ replaceAt(story, i, p);
411
548
  }
412
549
  }
413
550
  function storyPrepend(story, p, diagnostics) {
414
- const index = getIndex(story);
415
- const i = index.get(p.name) ?? -1;
551
+ moveGeneratedAside(story, p.name, diagnostics);
552
+ const i = position(story, p.name);
416
553
  if (i === -1) {
417
554
  story.passages.unshift(p);
418
- index.clear();
419
- for (let j = 0; j < story.passages.length; j++) {
420
- const passage = story.passages[j];
421
- if (passage) index.set(passage.name, j);
422
- }
555
+ buildIndex(story);
423
556
  } else {
424
557
  diagnostics.push({
425
558
  level: "warning",
426
559
  message: `Replacing existing passage "${p.name}" with duplicate.`
427
560
  });
428
- story.passages[i] = p;
561
+ replaceAt(story, i, p);
429
562
  }
430
563
  }
431
564
  function marshalStoryData(story) {
@@ -451,18 +584,20 @@ function unmarshalStoryData(story, json) {
451
584
  }
452
585
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return "Cannot unmarshal \"StoryData\"; expected a JSON object";
453
586
  const data = raw;
454
- if (typeof data.ifid === "string" && data.ifid) story.ifid = normalizeIFID(data.ifid);
455
- if (typeof data.format === "string" && data.format) story.twine2.format = data.format;
456
- if (typeof data["format-version"] === "string" && data["format-version"]) story.twine2.formatVersion = data["format-version"];
587
+ const twine2 = defaultTwine2Metadata();
588
+ story.ifid = typeof data.ifid === "string" ? normalizeIFID(data.ifid) : "";
589
+ if (typeof data.format === "string") twine2.format = data.format;
590
+ if (typeof data["format-version"] === "string") twine2.formatVersion = data["format-version"];
457
591
  if (Array.isArray(data.options)) {
458
- for (const opt of data.options) if (typeof opt === "string") story.twine2.options.set(opt, true);
592
+ for (const opt of data.options) if (typeof opt === "string") twine2.options.set(opt, true);
459
593
  }
460
- if (typeof data.start === "string" && data.start) story.twine2.start = data.start;
461
- if (typeof data.tags === "string") story.twine2.tags = data.tags;
594
+ if (typeof data.start === "string") twine2.start = data.start;
595
+ if (typeof data.tags === "string") twine2.tags = data.tags;
462
596
  if (typeof data["tag-colors"] === "object" && data["tag-colors"] !== null && !Array.isArray(data["tag-colors"])) {
463
- for (const [tag, color] of Object.entries(data["tag-colors"])) if (typeof color === "string") story.twine2.tagColors.set(tag, color);
597
+ for (const [tag, color] of Object.entries(data["tag-colors"])) if (typeof color === "string") twine2.tagColors.set(tag, color);
464
598
  }
465
- if (typeof data.zoom === "number" && data.zoom !== 0) story.twine2.zoom = data.zoom;
599
+ if (typeof data.zoom === "number" && data.zoom !== 0) twine2.zoom = data.zoom;
600
+ story.twine2 = twine2;
466
601
  return null;
467
602
  }
468
603
  function unmarshalStorySettings(story, text, diagnostics) {
@@ -503,8 +638,20 @@ function unmarshalStorySettings(story, text, diagnostics) {
503
638
  /**
504
639
  * Process a passage and add it to the story, handling special passages.
505
640
  * Creates new passage objects where text is modified rather than mutating the input.
641
+ *
642
+ * A passage replaces an earlier one with the same name, except where either name was generated
643
+ * (see `withGeneratedName()`): a generated name never replaces a passage and is never replaced.
644
+ * A passage with a generated name takes the first free name of `base`, `base 2`, … when its own
645
+ * is taken or is a compiler special name. A passage with a name from the sources that a generated
646
+ * name holds takes it over, and the generated one moves to the first free name in its place.
506
647
  */
507
648
  function storyAdd(story, p, diagnostics) {
649
+ const origin = generatedNames.get(p);
650
+ if (origin !== void 0) {
651
+ addGenerated(story, p, origin, diagnostics);
652
+ return;
653
+ }
654
+ moveGeneratedAside(story, p.name, diagnostics);
508
655
  let processed = p;
509
656
  switch (p.name) {
510
657
  case "StoryIncludes":
@@ -638,53 +785,413 @@ function isExcluded(filename, exclude) {
638
785
  const rel = relative(process.cwd(), resolve(filename)) || filename;
639
786
  return exclude.some((pattern) => matchesGlob(rel, pattern.replace(/^\.\//, "")));
640
787
  }
788
+ /** A build that writes nothing: `compile()`, or output to stdout. */
789
+ const NO_BUILD_OUTPUTS = {
790
+ files: [],
791
+ dirs: []
792
+ };
793
+ /** The outputs of a build that writes the one file `outFile`, or none without it. */
794
+ function toBuildOutputs(outputs) {
795
+ if (outputs === void 0) return NO_BUILD_OUTPUTS;
796
+ return typeof outputs === "string" ? {
797
+ files: [outputs],
798
+ dirs: []
799
+ } : outputs;
800
+ }
801
+ /**
802
+ * The path outputs are compared by: absolute, with every symbolic link in it
803
+ * resolved, so two paths that reach the same file through different links (a
804
+ * symlinked project folder, an alias of the output folder) compare equal. A
805
+ * path that doesn't exist yet, such as the output before the first build, is
806
+ * its nearest existing folder's real path joined with the rest.
807
+ */
808
+ function realPathOf(path) {
809
+ const abs = resolve(path);
810
+ try {
811
+ return realpathSync.native(abs);
812
+ } catch {}
813
+ const parent = dirname(abs);
814
+ return parent === abs ? abs : join(realPathOf(parent), basename(abs));
815
+ }
816
+ /** Whether the path `inner` is `outer` or inside it. */
817
+ function isSameOrInside(inner, outer) {
818
+ return inner === outer || inner.startsWith(outer.endsWith(sep) ? outer : outer + sep);
819
+ }
820
+ function outputPaths(outputs) {
821
+ const files = new Set(outputs.files.map(realPathOf));
822
+ const dirs = new Set(outputs.dirs.map(realPathOf));
823
+ const all = [...files, ...dirs];
824
+ return {
825
+ isFile: (real) => files.has(real),
826
+ isDir: (real) => dirs.has(real),
827
+ holds: (real) => dirs.has(real) || all.some((output) => output !== real && isSameOrInside(output, real)),
828
+ isOutput(path, roots) {
829
+ const real = realPathOf(path);
830
+ if (files.has(real)) return true;
831
+ const owners = [...dirs].filter((dir) => isSameOrInside(real, dir));
832
+ if (owners.length === 0) return false;
833
+ return roots.map(realPathOf).filter((root) => isSameOrInside(real, root)).every((root) => owners.some((dir) => dir !== root && isSameOrInside(dir, root)));
834
+ }
835
+ };
836
+ }
837
+ /**
838
+ * A folder entry as source discovery walks it, found at `pathname` with the real
839
+ * path `real` when it is no link: a link to a file counts as that file, and a
840
+ * link to anything else (a folder above all) as nothing, undefined. Throws when
841
+ * the entry or a link's target can't be read.
842
+ */
843
+ function walkedEntry(pathname, real) {
844
+ const stat = lstatSync(pathname);
845
+ if (!stat.isSymbolicLink()) return {
846
+ stat,
847
+ real
848
+ };
849
+ const target = statSync(pathname);
850
+ return target.isFile() ? {
851
+ stat: target,
852
+ real: realPathOf(pathname)
853
+ } : void 0;
854
+ }
641
855
  /**
642
856
  * Recursively walk directories, collecting regular file paths.
643
- * Filters out the output file to prevent circular compilation, and the files
644
- * that match an `exclude` glob (see isExcluded).
857
+ *
858
+ * Leaves out what the build writes (`outputs`, compared by real path, see
859
+ * realPathOf) so its last output is never read back, and the files that match an
860
+ * `exclude` glob (see isExcluded). A path given that is itself an output file is
861
+ * not walked but listed in `outputSources`.
862
+ *
863
+ * Symbolic links: a path given is followed wherever it leads. Inside a folder, a
864
+ * link to a file is read, but a link to a folder is not followed, as Tweego's walk
865
+ * doesn't follow one. A link back to its own folder or a parent would otherwise
866
+ * walk the same files again at every depth.
867
+ *
645
868
  * Like Tweego, a path that cannot be read is reported as a warning and skipped.
646
869
  */
647
- function getFilenames(pathnames, outFilename, exclude = []) {
870
+ function getFilenames(pathnames, outputs, exclude = []) {
648
871
  const filenames = [];
649
872
  const diagnostics = [];
650
- const absOutFile = outFilename ? resolve(outFilename) : "";
873
+ const outputSources = [];
874
+ const output = outputPaths(toBuildOutputs(outputs));
651
875
  function warn(pathname, e) {
652
876
  diagnostics.push({
653
877
  level: "warning",
654
878
  message: `path ${pathname}: ${e instanceof Error ? e.message : String(e)}`
655
879
  });
656
880
  }
657
- function walk(pathname) {
881
+ function addFile(pathname) {
882
+ const abs = resolve(pathname);
883
+ const rel = relative(process.cwd(), abs);
884
+ if (isExcluded(rel || abs, exclude)) return;
885
+ filenames.push(rel || abs);
886
+ }
887
+ function walkDir(pathname, real) {
888
+ let entries;
889
+ try {
890
+ entries = readdirSync(pathname);
891
+ } catch (e) {
892
+ warn(pathname, e);
893
+ return;
894
+ }
895
+ for (const entry of entries) walkEntry(join(pathname, entry), join(real, entry));
896
+ }
897
+ function walkEntry(pathname, real) {
898
+ let entry;
899
+ try {
900
+ entry = walkedEntry(pathname, real);
901
+ } catch (e) {
902
+ warn(pathname, e);
903
+ return;
904
+ }
905
+ if (entry === void 0) return;
906
+ if (entry.stat.isFile()) {
907
+ if (!output.isFile(entry.real)) addFile(pathname);
908
+ } else if (entry.stat.isDirectory() && !output.isDir(entry.real)) walkDir(pathname, entry.real);
909
+ }
910
+ for (const pathname of pathnames) {
658
911
  let stat;
659
912
  try {
660
913
  stat = statSync(pathname);
661
914
  } catch (e) {
662
915
  warn(pathname, e);
663
- return;
916
+ continue;
664
917
  }
918
+ const real = realPathOf(pathname);
665
919
  if (stat.isFile()) {
666
- const abs = resolve(pathname);
667
- if (abs === absOutFile) return;
668
- const rel = relative(process.cwd(), abs);
669
- if (isExcluded(rel || abs, exclude)) return;
670
- filenames.push(rel || abs);
671
- } else if (stat.isDirectory()) {
672
- let entries;
673
- try {
674
- entries = readdirSync(pathname);
675
- } catch (e) {
676
- warn(pathname, e);
677
- return;
920
+ if (output.isFile(real)) outputSources.push(pathname);
921
+ else addFile(pathname);
922
+ } else if (stat.isDirectory()) walkDir(pathname, real);
923
+ }
924
+ return {
925
+ filenames,
926
+ diagnostics,
927
+ outputSources
928
+ };
929
+ }
930
+ //#endregion
931
+ //#region src/relaxed-json.ts
932
+ /**
933
+ * A parser for the object literal a Twine 2 `format.js` passes to `window.storyFormat()`.
934
+ *
935
+ * The story formats spec does not require that object to be strict JSON, and Twine 2 runs
936
+ * `format.js` as JavaScript. This parser accepts JSON plus the JavaScript literal syntax formats
937
+ * use in practice: single-quoted strings, unquoted (identifier) and numeric keys, trailing commas,
938
+ * comments, JavaScript string escapes, and hexadecimal, octal and binary numbers.
939
+ *
940
+ * It reads string literals token by token, so their contents come back exactly as JavaScript
941
+ * evaluation gives them: text that only looks like structure (`a, }`, `word:`, an apostrophe) is
942
+ * never touched. Anything outside that subset, such as a function or a variable, is a syntax error.
943
+ */
944
+ const IDENTIFIER = /[A-Za-z_$][\w$]*/y;
945
+ const NUMBER = /[+-]?(?:0[xX][0-9a-fA-F]+|0[oO][0-7]+|0[bB][01]+|(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)/y;
946
+ const IDENTIFIER_PART = /[\w$]/;
947
+ const WHITESPACE = /[\s]/;
948
+ const HEX = /^[0-9a-fA-F]+$/;
949
+ /** Simple single-character escapes and the characters they stand for. */
950
+ const SIMPLE_ESCAPES = {
951
+ b: "\b",
952
+ f: "\f",
953
+ n: "\n",
954
+ r: "\r",
955
+ t: " ",
956
+ v: "\v"
957
+ };
958
+ /**
959
+ * Parse a JavaScript object, array or primitive literal: all of `text`, or the part from `start` to
960
+ * `end`. Error positions count from the start of `text`, so they point into the file it came from.
961
+ *
962
+ * @throws SyntaxError naming the line and column of the first thing it cannot parse.
963
+ */
964
+ function parseRelaxedJSON(text, start = 0, end = text.length) {
965
+ let pos = start;
966
+ function fail(message, at = pos) {
967
+ const before = text.slice(0, at);
968
+ const line = before.split("\n").length;
969
+ const column = at - before.lastIndexOf("\n");
970
+ throw new SyntaxError(`${message} at line ${line}, column ${column}`);
971
+ }
972
+ /** The character at `pos`, or '' at the end of the input. */
973
+ const peek = () => pos < end ? text[pos] ?? "" : "";
974
+ const describeChar = (at) => at >= end ? "end of input" : JSON.stringify(text[at]);
975
+ /** Skip whitespace and comments. */
976
+ const skipTrivia = () => {
977
+ while (pos < end) {
978
+ const ch = peek();
979
+ if (WHITESPACE.test(ch)) pos++;
980
+ else if (text.startsWith("//", pos)) {
981
+ const lineEnd = text.slice(pos, end).search(/[\n\r\u2028\u2029]/);
982
+ pos = lineEnd === -1 ? end : pos + lineEnd;
983
+ } else if (text.startsWith("/*", pos)) {
984
+ const commentEnd = text.indexOf("*/", pos + 2);
985
+ if (commentEnd === -1 || commentEnd + 2 > end) fail("Unterminated comment");
986
+ pos = commentEnd + 2;
987
+ } else return;
988
+ }
989
+ };
990
+ const consume = (ch) => {
991
+ skipTrivia();
992
+ if (peek() !== ch) fail(`Expected ${JSON.stringify(ch)} but found ${describeChar(pos)}`);
993
+ pos++;
994
+ };
995
+ /** Read exactly `count` hex digits and return their value. */
996
+ const readHex = (count) => {
997
+ const digits = text.slice(pos, Math.min(pos + count, end));
998
+ if (digits.length !== count || !HEX.test(digits)) fail("Invalid hexadecimal escape sequence");
999
+ pos += count;
1000
+ return parseInt(digits, 16);
1001
+ };
1002
+ /** Decode the escape sequence after a backslash; `pos` is just past the backslash. */
1003
+ const readEscape = () => {
1004
+ const ch = peek();
1005
+ const simple = SIMPLE_ESCAPES[ch];
1006
+ if (simple !== void 0) {
1007
+ pos++;
1008
+ return simple;
1009
+ }
1010
+ switch (ch) {
1011
+ case "": return fail("Unterminated string");
1012
+ case "x":
1013
+ pos++;
1014
+ return String.fromCharCode(readHex(2));
1015
+ case "u": {
1016
+ pos++;
1017
+ if (peek() !== "{") return String.fromCharCode(readHex(4));
1018
+ const close = text.indexOf("}", pos);
1019
+ if (close === -1 || close >= end) fail("Invalid Unicode escape sequence");
1020
+ pos++;
1021
+ const codePoint = readHex(close - pos);
1022
+ if (codePoint > 1114111) fail("Undefined Unicode code-point");
1023
+ pos = close + 1;
1024
+ return String.fromCodePoint(codePoint);
1025
+ }
1026
+ case "\r":
1027
+ pos += text[pos + 1] === "\n" ? 2 : 1;
1028
+ return "";
1029
+ case "\n":
1030
+ case "\u2028":
1031
+ case "\u2029":
1032
+ pos++;
1033
+ return "";
1034
+ case "0":
1035
+ if (/[0-9]/.test(text[pos + 1] ?? "")) fail("Octal escape sequences are not supported");
1036
+ pos++;
1037
+ return "\0";
1038
+ default:
1039
+ if (/[1-9]/.test(ch)) fail("Octal escape sequences are not supported");
1040
+ pos++;
1041
+ return ch;
1042
+ }
1043
+ };
1044
+ const readString = () => {
1045
+ const quote = peek();
1046
+ pos++;
1047
+ let out = "";
1048
+ let chunkStart = pos;
1049
+ for (;;) {
1050
+ if (pos >= end) fail("Unterminated string");
1051
+ const ch = peek();
1052
+ if (ch === quote) {
1053
+ out += text.slice(chunkStart, pos);
1054
+ pos++;
1055
+ return out;
1056
+ }
1057
+ if (ch === "\n" || ch === "\r") fail("Unterminated string");
1058
+ if (ch === "\\") {
1059
+ out += text.slice(chunkStart, pos);
1060
+ pos++;
1061
+ out += readEscape();
1062
+ chunkStart = pos;
1063
+ } else pos++;
1064
+ }
1065
+ };
1066
+ /** Read a match of a sticky regular expression at `pos`, or return undefined. */
1067
+ const readToken = (pattern) => {
1068
+ pattern.lastIndex = pos;
1069
+ const m = pattern.exec(text);
1070
+ if (!m || pattern.lastIndex > end) return void 0;
1071
+ pos = pattern.lastIndex;
1072
+ return m[0];
1073
+ };
1074
+ const readNumber = () => {
1075
+ const tokenStart = pos;
1076
+ const token = readToken(NUMBER);
1077
+ if (token === void 0) return void 0;
1078
+ if (IDENTIFIER_PART.test(peek())) fail("Invalid number", tokenStart);
1079
+ return (token.startsWith("-") ? -1 : 1) * Number(/^[+-]/.test(token) ? token.slice(1) : token);
1080
+ };
1081
+ const readKey = () => {
1082
+ skipTrivia();
1083
+ const ch = peek();
1084
+ if (ch === "\"" || ch === "'") return readString();
1085
+ const identifier = readToken(IDENTIFIER);
1086
+ if (identifier !== void 0) return identifier;
1087
+ const number = readNumber();
1088
+ if (number !== void 0) return String(number);
1089
+ return fail(`Expected a property name but found ${describeChar(pos)}`);
1090
+ };
1091
+ const readObject = () => {
1092
+ pos++;
1093
+ const out = {};
1094
+ for (;;) {
1095
+ skipTrivia();
1096
+ if (peek() === "}") {
1097
+ pos++;
1098
+ return out;
1099
+ }
1100
+ const key = readKey();
1101
+ consume(":");
1102
+ Object.defineProperty(out, key, {
1103
+ value: readValue(),
1104
+ enumerable: true,
1105
+ writable: true,
1106
+ configurable: true
1107
+ });
1108
+ skipTrivia();
1109
+ if (peek() === ",") pos++;
1110
+ else if (peek() !== "}") fail(`Expected "," or "}" but found ${describeChar(pos)}`);
1111
+ }
1112
+ };
1113
+ const readArray = () => {
1114
+ pos++;
1115
+ const out = [];
1116
+ for (;;) {
1117
+ skipTrivia();
1118
+ if (peek() === "]") {
1119
+ pos++;
1120
+ return out;
678
1121
  }
679
- for (const entry of entries) walk(join(pathname, entry));
1122
+ out.push(readValue());
1123
+ skipTrivia();
1124
+ if (peek() === ",") pos++;
1125
+ else if (peek() !== "]") fail(`Expected "," or "]" but found ${describeChar(pos)}`);
1126
+ }
1127
+ };
1128
+ function readValue() {
1129
+ skipTrivia();
1130
+ const ch = peek();
1131
+ if (ch === "{") return readObject();
1132
+ if (ch === "[") return readArray();
1133
+ if (ch === "\"" || ch === "'") return readString();
1134
+ const tokenStart = pos;
1135
+ const identifier = readToken(IDENTIFIER);
1136
+ if (identifier !== void 0) {
1137
+ if (identifier === "true") return true;
1138
+ if (identifier === "false") return false;
1139
+ if (identifier === "null") return null;
1140
+ return fail(`Unexpected identifier ${JSON.stringify(identifier)}`, tokenStart);
680
1141
  }
1142
+ const number = readNumber();
1143
+ if (number !== void 0) return number;
1144
+ return fail(`Unexpected ${describeChar(pos)}`);
681
1145
  }
682
- for (const pathname of pathnames) walk(pathname);
1146
+ const value = readValue();
1147
+ skipTrivia();
1148
+ if (pos < end) fail(`Unexpected ${describeChar(pos)} after the value`);
1149
+ return value;
1150
+ }
1151
+ //#endregion
1152
+ //#region src/semver.ts
1153
+ const IDENTIFIERS = String.raw`[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*`;
1154
+ const VERSION$1 = new RegExp(String.raw`^[vV]?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-(${IDENTIFIERS}))?(?:\+${IDENTIFIERS})?$`);
1155
+ const NUMERIC = /^\d+$/;
1156
+ /** Parse a version string, or return null when it is not a version. */
1157
+ function parseVersion(text) {
1158
+ const m = VERSION$1.exec(text);
1159
+ if (!m) return null;
683
1160
  return {
684
- filenames,
685
- diagnostics
1161
+ major: Number(m[1]),
1162
+ minor: Number(m[2] ?? 0),
1163
+ patch: Number(m[3] ?? 0),
1164
+ prerelease: m[4] === void 0 ? [] : m[4].split(".")
686
1165
  };
687
1166
  }
1167
+ /** Compare two prerelease identifiers: numeric ones numerically and below alphanumeric ones, which compare as ASCII. */
1168
+ function compareIdentifiers(a, b) {
1169
+ const aNumeric = NUMERIC.test(a);
1170
+ const bNumeric = NUMERIC.test(b);
1171
+ if (aNumeric && bNumeric) return Math.sign(Number(a) - Number(b));
1172
+ if (aNumeric !== bNumeric) return aNumeric ? -1 : 1;
1173
+ return a < b ? -1 : a > b ? 1 : 0;
1174
+ }
1175
+ /** Compare prerelease identifier lists. An empty list (a release) ranks above any prerelease. */
1176
+ function comparePrerelease(a, b) {
1177
+ if (a.length === 0 || b.length === 0) return Math.sign(b.length - a.length);
1178
+ const shared = Math.min(a.length, b.length);
1179
+ for (let i = 0; i < shared; i++) {
1180
+ const order = compareIdentifiers(a[i] ?? "", b[i] ?? "");
1181
+ if (order !== 0) return order;
1182
+ }
1183
+ return Math.sign(a.length - b.length);
1184
+ }
1185
+ /** Compare two versions by SemVer precedence: negative when `a` ranks lower, 0 when equal, positive when higher. */
1186
+ function compareVersions(a, b) {
1187
+ return Math.sign(a.major - b.major) || Math.sign(a.minor - b.minor) || Math.sign(a.patch - b.patch) || comparePrerelease(a.prerelease, b.prerelease);
1188
+ }
1189
+ /** Whether two version strings name the same version (prerelease included, build metadata ignored). */
1190
+ function sameVersion(a, b) {
1191
+ const left = parseVersion(a);
1192
+ const right = parseVersion(b);
1193
+ return left !== null && right !== null && compareVersions(left, right) === 0;
1194
+ }
688
1195
  //#endregion
689
1196
  //#region src/source-text.ts
690
1197
  /**
@@ -697,14 +1204,119 @@ function getFilenames(pathnames, outFilename, exclude = []) {
697
1204
  function normalizeSourceText(text) {
698
1205
  return (text.charCodeAt(0) === 65279 ? text.slice(1) : text).replace(/\r\n?/g, "\n");
699
1206
  }
1207
+ /** Byte order marks at the start of a line, directly before a passage header's `::`. */
1208
+ const BOMS_BEFORE_HEADER = /(?<=^|\n)\uFEFF+(?=::)/g;
1209
+ /**
1210
+ * Normalize Twee source text: {@link normalizeSourceText}, and also remove byte order marks at the start of
1211
+ * a line that are directly followed by `::`. Concatenating files (`cat a.tw b.tw`) leaves the BOM of every
1212
+ * later file in front of its first passage header, which would otherwise not be recognised as a header.
1213
+ * A U+FEFF anywhere else is passage text and kept. Idempotent.
1214
+ */
1215
+ function normalizeTweeSourceText(text) {
1216
+ return normalizeSourceText(text).replace(BOMS_BEFORE_HEADER, "");
1217
+ }
700
1218
  //#endregion
701
1219
  //#region src/util.ts
702
1220
  /**
703
1221
  * Shared file I/O utilities.
704
1222
  */
705
- /** Read a file as UTF-8 with BOM stripping and line ending normalization. */
706
- function readUTF8(filename) {
707
- return normalizeSourceText(readFileSync(filename, "utf-8"));
1223
+ /** The charset assumed for text that is not valid UTF-8, as Tweego assumes it. */
1224
+ const FALLBACK_CHARSET = "windows-1252";
1225
+ /**
1226
+ * Throws on invalid UTF-8 instead of replacing bytes with U+FFFD. `ignoreBOM` keeps a leading BOM in the
1227
+ * text, as `readFileSync(file, 'utf-8')` did, so {@link normalizeSourceText} strips exactly one.
1228
+ */
1229
+ const utf8Decoder = new TextDecoder("utf-8", {
1230
+ fatal: true,
1231
+ ignoreBOM: true
1232
+ });
1233
+ /**
1234
+ * The code points of the Windows-1252 bytes 0x80 to 0x9F, from the WHATWG Encoding Standard. The five bytes
1235
+ * Windows-1252 leaves undefined map to the C1 control with the same number, as the standard and Go's charmap
1236
+ * do. Every other byte is the code point with the same number.
1237
+ *
1238
+ * Decoded by hand, because Node's `TextDecoder('windows-1252')` decodes as ISO-8859-1 in some releases
1239
+ * (Node 22.18 turns 0x80 into U+0080 instead of €), and builds without ICU lack it.
1240
+ */
1241
+ const WINDOWS_1252_C1 = [
1242
+ 8364,
1243
+ 129,
1244
+ 8218,
1245
+ 402,
1246
+ 8222,
1247
+ 8230,
1248
+ 8224,
1249
+ 8225,
1250
+ 710,
1251
+ 8240,
1252
+ 352,
1253
+ 8249,
1254
+ 338,
1255
+ 141,
1256
+ 381,
1257
+ 143,
1258
+ 144,
1259
+ 8216,
1260
+ 8217,
1261
+ 8220,
1262
+ 8221,
1263
+ 8226,
1264
+ 8211,
1265
+ 8212,
1266
+ 732,
1267
+ 8482,
1268
+ 353,
1269
+ 8250,
1270
+ 339,
1271
+ 157,
1272
+ 382,
1273
+ 376
1274
+ ];
1275
+ function decodeWindows1252(bytes) {
1276
+ const chunks = [];
1277
+ for (let start = 0; start < bytes.length; start += 8192) {
1278
+ const codes = Array.from(bytes.subarray(start, start + 8192), (b) => b >= 128 && b <= 159 ? WINDOWS_1252_C1[b - 128] ?? b : b);
1279
+ chunks.push(String.fromCharCode(...codes));
1280
+ }
1281
+ return chunks.join("");
1282
+ }
1283
+ /**
1284
+ * Decode bytes read from `filename` as UTF-8. Bytes that are not valid UTF-8 are decoded as Windows-1252
1285
+ * instead, with a warning naming the file, as Tweego does (`fileReadAllWithEncoding` in io.go), so a legacy
1286
+ * file keeps its non-ASCII characters instead of losing them to U+FFFD.
1287
+ *
1288
+ * The text is returned as decoded: a leading BOM and CR line endings are kept (see {@link normalizeSourceText}).
1289
+ */
1290
+ function decodeText(bytes, filename) {
1291
+ try {
1292
+ return {
1293
+ text: utf8Decoder.decode(bytes),
1294
+ diagnostics: []
1295
+ };
1296
+ } catch {
1297
+ return {
1298
+ text: decodeWindows1252(bytes),
1299
+ diagnostics: [{
1300
+ level: "warning",
1301
+ message: `read ${filename}: Invalid UTF-8; assuming charset is ${FALLBACK_CHARSET}.`,
1302
+ file: filename
1303
+ }]
1304
+ };
1305
+ }
1306
+ }
1307
+ /**
1308
+ * Read a text file: decode it with {@link decodeText} (UTF-8, or Windows-1252 when it is not valid UTF-8),
1309
+ * strip a leading BOM and convert CRLF and bare CR line endings to LF. This is the one path every file read
1310
+ * as text goes through: sources, modules, the head file, story formats and the config file.
1311
+ *
1312
+ * @param diagnostics Receives the warning when the file is not valid UTF-8. Without it, the file is decoded
1313
+ * the same way and the warning is dropped.
1314
+ * @throws When the file cannot be read.
1315
+ */
1316
+ function readUTF8(filename, diagnostics) {
1317
+ const decoded = decodeText(readFileSync(filename), filename);
1318
+ diagnostics?.push(...decoded.diagnostics);
1319
+ return normalizeSourceText(decoded.text);
708
1320
  }
709
1321
  /** Read a file as base64. */
710
1322
  function readBase64(filename) {
@@ -720,20 +1332,11 @@ function baseNameWithoutExt(filename) {
720
1332
  /**
721
1333
  * Story format discovery, loading, and SemVer matching.
722
1334
  * Ported from formats.go + config.go.
1335
+ *
1336
+ * Every lookup (local folders here, the download cache and remote indices in remote-formats.ts)
1337
+ * goes through the same version parsing ({@link parseVersion}) and the same matching rules
1338
+ * ({@link selectFormatCandidate}).
723
1339
  */
724
- /**
725
- * Attempt to fix non-strict JSON (trailing commas, single quotes, unquoted keys).
726
- */
727
- function relaxedJSONParse(json) {
728
- try {
729
- return JSON.parse(json);
730
- } catch {}
731
- let fixed = json;
732
- fixed = fixed.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, "\"$1\"");
733
- fixed = fixed.replace(/(?<=^|[{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:/gm, "\"$1\":");
734
- fixed = fixed.replace(/,\s*([}\]])/g, "$1");
735
- return JSON.parse(fixed);
736
- }
737
1340
  /** A `"setup": function` property, which Harlowe appends to its otherwise-JSON format object. */
738
1341
  const SETUP_FUNCTION_PROPERTY = /,\s*"setup"\s*:\s*function\b/g;
739
1342
  /** Drop a trailing `"setup": function(){…}` property from a format object, if it has one. */
@@ -743,151 +1346,239 @@ function stripSetupFunction(chunk) {
743
1346
  return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
744
1347
  }
745
1348
  /**
746
- * Parse the Twine 2 format.js JSON chunk.
747
- * Handles Harlowe's malformed JSON by stripping the "setup" function property. The workaround
748
- * keys on the property itself, so the same bytes parse whether they come from a `harlowe-3`
749
- * directory or a direct download.
750
- * Per spec, the name key is Optional.
1349
+ * Parse the format object at `start`–`end` of `text`: strict JSON first (fast), then the
1350
+ * JavaScript literal subset formats use. Error positions point into `text`.
1351
+ */
1352
+ function parseFormatObject(text, start, end) {
1353
+ try {
1354
+ return {
1355
+ ok: true,
1356
+ value: JSON.parse(text.slice(start, end))
1357
+ };
1358
+ } catch {}
1359
+ try {
1360
+ return {
1361
+ ok: true,
1362
+ value: parseRelaxedJSON(text, start, end)
1363
+ };
1364
+ } catch (e) {
1365
+ return {
1366
+ ok: false,
1367
+ error: e instanceof Error ? e.message : String(e)
1368
+ };
1369
+ }
1370
+ }
1371
+ /** Check a parsed format object's fields. Per spec, `name` is optional; `version` and `source` are required. */
1372
+ function toFormatJSON(raw) {
1373
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
1374
+ ok: false,
1375
+ reason: "Story format JSON chunk is not an object."
1376
+ };
1377
+ const obj = raw;
1378
+ if (typeof obj.version !== "string") return {
1379
+ ok: false,
1380
+ reason: "Story format has no \"version\" string."
1381
+ };
1382
+ if (typeof obj.source !== "string") return {
1383
+ ok: false,
1384
+ reason: "Story format has no \"source\" string."
1385
+ };
1386
+ if (!parseVersion(obj.version)) return {
1387
+ ok: false,
1388
+ reason: `Story format version ${JSON.stringify(obj.version)} is not a SemVer version.`
1389
+ };
1390
+ const data = {
1391
+ name: typeof obj.name === "string" ? obj.name : "Untitled Story Format",
1392
+ version: obj.version,
1393
+ source: obj.source,
1394
+ proofing: obj.proofing === true
1395
+ };
1396
+ if (typeof obj.author === "string") data.author = obj.author;
1397
+ if (typeof obj.description === "string") data.description = obj.description;
1398
+ if (typeof obj.image === "string") data.image = obj.image;
1399
+ if (typeof obj.url === "string") data.url = obj.url;
1400
+ if (typeof obj.license === "string") data.license = obj.license;
1401
+ return {
1402
+ ok: true,
1403
+ data
1404
+ };
1405
+ }
1406
+ /**
1407
+ * Read the metadata of a Twine 2 format.js, or say why it cannot be used.
751
1408
  *
752
- * @param _formatId No longer used; kept so existing callers keep compiling.
1409
+ * The object passed to `window.storyFormat()` may be strict JSON or a JavaScript object literal
1410
+ * (single quotes, unquoted keys, trailing commas, comments). String values come back exactly as
1411
+ * JavaScript evaluation gives them. Harlowe's function-valued `setup` property is dropped; the
1412
+ * workaround keys on the property itself, so the same bytes parse whether they come from a
1413
+ * `harlowe-3` directory or a direct download.
753
1414
  */
754
- function parseFormatJSON(source, _formatId) {
1415
+ function decodeFormatJSON(source) {
755
1416
  const first = source.indexOf("{");
756
1417
  const last = source.lastIndexOf("}");
757
- if (first === -1 || last === -1) return null;
758
- const chunk = source.slice(first, last + 1);
759
- const parse = (json) => {
760
- const raw = relaxedJSONParse(json);
761
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
762
- const obj = raw;
763
- if (typeof obj.version !== "string" || typeof obj.source !== "string") return null;
764
- if (!parseSemver(obj.version)) return null;
765
- const result = {
766
- name: typeof obj.name === "string" ? obj.name : "Untitled Story Format",
767
- version: obj.version,
768
- source: obj.source,
769
- proofing: obj.proofing === true
770
- };
771
- if (typeof obj.author === "string") result.author = obj.author;
772
- if (typeof obj.description === "string") result.description = obj.description;
773
- if (typeof obj.image === "string") result.image = obj.image;
774
- if (typeof obj.url === "string") result.url = obj.url;
775
- if (typeof obj.license === "string") result.license = obj.license;
776
- return result;
1418
+ if (first === -1 || last < first) return {
1419
+ ok: false,
1420
+ reason: "Could not find Twine 2 style story format JSON chunk."
1421
+ };
1422
+ const parsed = parseFormatObject(source, first, last + 1);
1423
+ if (parsed.ok) return toFormatJSON(parsed.value);
1424
+ const stripped = stripSetupFunction(source.slice(first, last + 1));
1425
+ const retried = stripped === void 0 ? void 0 : parseFormatObject(stripped, 0, stripped.length);
1426
+ if (retried?.ok) return toFormatJSON(retried.value);
1427
+ return {
1428
+ ok: false,
1429
+ reason: `Could not decode story format JSON chunk: ${parsed.error}`
777
1430
  };
1431
+ }
1432
+ /**
1433
+ * Parse the Twine 2 format.js JSON chunk; null when it cannot be used ({@link decodeFormatJSON}
1434
+ * gives the reason).
1435
+ *
1436
+ * @param _formatId No longer used; kept so existing callers keep compiling.
1437
+ */
1438
+ function parseFormatJSON(source, _formatId) {
1439
+ const result = decodeFormatJSON(source);
1440
+ return result.ok ? result.data : null;
1441
+ }
1442
+ /** A directory's entries in a stable order, or none when it cannot be read. */
1443
+ function listDirectory(dir) {
778
1444
  try {
779
- return parse(chunk);
1445
+ return readdirSync(dir).sort();
780
1446
  } catch {
781
- const stripped = stripSetupFunction(chunk);
782
- if (stripped === void 0) return null;
783
- try {
784
- return parse(stripped);
785
- } catch {
786
- return null;
787
- }
1447
+ return [];
1448
+ }
1449
+ }
1450
+ function isDirectory(path) {
1451
+ try {
1452
+ return statSync(path).isDirectory();
1453
+ } catch {
1454
+ return false;
1455
+ }
1456
+ }
1457
+ function isFile(path) {
1458
+ try {
1459
+ return statSync(path).isFile();
1460
+ } catch {
1461
+ return false;
788
1462
  }
789
1463
  }
790
1464
  /**
791
- * Discover all story formats in the given search directories.
792
- * Implements SemVer-based version pruning: within each (name, major) group,
793
- * only the highest minor.patch survives.
1465
+ * Load the format in one folder: its format.js (Twine 2), else its header.html (Twine 1).
1466
+ * A format.js that cannot be used is reported as a warning, as Tweego does.
794
1467
  */
795
- function discoverFormats(searchDirs) {
796
- const formats = /* @__PURE__ */ new Map();
797
- const baseFilenames = ["format.js", "header.html"];
798
- for (const searchDir of searchDirs) {
799
- try {
800
- if (!statSync(searchDir).isDirectory()) continue;
801
- } catch {
802
- continue;
803
- }
804
- let entries;
1468
+ function loadFormatDir(searchDir, id, diagnostics) {
1469
+ const formatDir = join(searchDir, id);
1470
+ if (!isDirectory(formatDir)) return void 0;
1471
+ for (const baseFilename of ["format.js", "header.html"]) {
1472
+ const filename = join(formatDir, baseFilename);
1473
+ if (!isFile(filename)) continue;
1474
+ if (baseFilename === "header.html") return {
1475
+ id,
1476
+ filename,
1477
+ isTwine2: false,
1478
+ name: id,
1479
+ version: "",
1480
+ proofing: false
1481
+ };
1482
+ const encoding = [];
1483
+ let decoded;
805
1484
  try {
806
- entries = readdirSync(searchDir);
807
- } catch {
808
- continue;
1485
+ decoded = decodeFormatJSON(readUTF8(filename, encoding));
1486
+ } catch (e) {
1487
+ decoded = {
1488
+ ok: false,
1489
+ reason: `Could not read ${filename}: ${e instanceof Error ? e.message : String(e)}`
1490
+ };
809
1491
  }
810
- for (const entry of entries) {
811
- const formatDir = join(searchDir, entry);
812
- try {
813
- if (!statSync(formatDir).isDirectory()) continue;
814
- } catch {
815
- continue;
816
- }
817
- for (const baseFilename of baseFilenames) {
818
- const formatFilename = join(formatDir, baseFilename);
819
- try {
820
- if (!statSync(formatFilename).isFile()) continue;
821
- } catch {
822
- continue;
823
- }
824
- const isTwine2 = baseFilename === "format.js";
825
- const format = {
826
- id: entry,
827
- filename: formatFilename,
828
- isTwine2,
829
- name: "",
830
- version: "",
831
- proofing: false
832
- };
833
- if (isTwine2) try {
834
- const data = parseFormatJSON(readUTF8(formatFilename), entry);
835
- if (!data) continue;
836
- format.name = data.name;
837
- format.version = data.version;
838
- format.proofing = data.proofing ?? false;
839
- if (data.author) format.author = data.author;
840
- if (data.description) format.description = data.description;
841
- if (data.image) format.image = data.image;
842
- if (data.url) format.url = data.url;
843
- if (data.license) format.license = data.license;
844
- } catch {
845
- continue;
846
- }
847
- else format.name = entry;
848
- formats.set(entry, format);
849
- break;
850
- }
1492
+ if (!decoded.ok) {
1493
+ diagnostics?.push(...encoding);
1494
+ diagnostics?.push({
1495
+ level: "warning",
1496
+ message: `format ${id}: Skipping format; ${decoded.reason} (${filename})`
1497
+ });
1498
+ continue;
851
1499
  }
1500
+ const { data } = decoded;
1501
+ const format = {
1502
+ id,
1503
+ filename,
1504
+ isTwine2: true,
1505
+ name: data.name,
1506
+ version: data.version,
1507
+ proofing: data.proofing ?? false
1508
+ };
1509
+ if (data.author) format.author = data.author;
1510
+ if (data.description) format.description = data.description;
1511
+ if (data.image) format.image = data.image;
1512
+ if (data.url) format.url = data.url;
1513
+ if (data.license) format.license = data.license;
1514
+ return format;
1515
+ }
1516
+ }
1517
+ /**
1518
+ * Discover every story format in the search directories, keyed by folder name (the format ID),
1519
+ * without version pruning.
1520
+ *
1521
+ * Directories are searched in order, and a later directory outranks an earlier one: when two hold
1522
+ * the same folder name, the later one's format is kept. The map iterates in that order too, lowest
1523
+ * rank first. Formats that cannot be used are left out, with a warning in `diagnostics`.
1524
+ */
1525
+ function discoverAllFormats(searchDirs, diagnostics) {
1526
+ const formats = /* @__PURE__ */ new Map();
1527
+ for (const searchDir of searchDirs) for (const entry of listDirectory(searchDir)) {
1528
+ const format = loadFormatDir(searchDir, entry, diagnostics);
1529
+ if (!format) continue;
1530
+ formats.delete(entry);
1531
+ formats.set(entry, format);
852
1532
  }
853
- pruneFormats(formats);
854
1533
  return formats;
855
1534
  }
1535
+ /** The version a format is pruned and selected by, or null for one outside SemVer pruning. */
1536
+ function prunableVersion(format) {
1537
+ return format.isTwine2 && format.name ? parseVersion(format.version) : null;
1538
+ }
1539
+ /** The search directory a discovered format was found in. */
1540
+ function searchDirOf(format) {
1541
+ return dirname(dirname(format.filename));
1542
+ }
856
1543
  /**
857
- * Prune formats by SemVer: within each (name, major) group,
858
- * keep only the highest version. First-found wins for same name+version.
1544
+ * Prune formats by SemVer: within each (name, major) group, keep only the version with the highest
1545
+ * precedence (a release outranks its prereleases). Between equal versions, the folder in the
1546
+ * higher-ranked search directory wins, and within one directory the first folder name in sort
1547
+ * order. Returns a new map in the same order.
1548
+ *
1549
+ * Pruning serves name-based selection and listings. An explicit format ID is looked up in the
1550
+ * unpruned map, so a pinned folder is used even when another folder holds a newer version.
859
1551
  */
860
1552
  function pruneFormats(formats) {
861
- const bestByNameMajor = /* @__PURE__ */ new Map();
862
- const seenNameVersion = /* @__PURE__ */ new Set();
863
- for (const [id, f] of formats) {
864
- if (!f.isTwine2 || !f.name || !f.version) continue;
865
- const v = parseSemver(f.version);
866
- if (!v) continue;
867
- const nameVersionKey = `${f.name}@${f.version}`;
868
- if (seenNameVersion.has(nameVersionKey)) {
869
- formats.delete(id);
870
- continue;
871
- }
872
- seenNameVersion.add(nameVersionKey);
873
- const groupKey = `${f.name}@${v[0]}`;
874
- const existing = bestByNameMajor.get(groupKey);
875
- if (!existing || semverCompare(v, existing.version) > 0) bestByNameMajor.set(groupKey, {
876
- id,
877
- version: v
1553
+ const dirRank = /* @__PURE__ */ new Map();
1554
+ for (const format of formats.values()) {
1555
+ const dir = searchDirOf(format);
1556
+ if (!dirRank.has(dir)) dirRank.set(dir, dirRank.size);
1557
+ }
1558
+ const rankOf = (format) => dirRank.get(searchDirOf(format)) ?? 0;
1559
+ const best = /* @__PURE__ */ new Map();
1560
+ for (const format of formats.values()) {
1561
+ const version = prunableVersion(format);
1562
+ if (!version) continue;
1563
+ const group = `${format.name}@${version.major}`;
1564
+ const current = best.get(group);
1565
+ const order = current ? compareVersions(version, current.version) : 1;
1566
+ if (order > 0 || order === 0 && current && rankOf(format) > rankOf(current.format)) best.set(group, {
1567
+ format,
1568
+ version
878
1569
  });
879
1570
  }
880
- for (const [id, f] of [...formats]) {
881
- if (!f.isTwine2 || !f.name || !f.version) continue;
882
- const v = parseSemver(f.version);
883
- if (!v) continue;
884
- const groupKey = `${f.name}@${v[0]}`;
885
- const best = bestByNameMajor.get(groupKey);
886
- if (best && best.id !== id) formats.delete(id);
887
- }
1571
+ return new Map([...formats].filter(([id, format]) => {
1572
+ const version = prunableVersion(format);
1573
+ return version === null || best.get(`${format.name}@${version.major}`)?.format.id === id;
1574
+ }));
888
1575
  }
889
1576
  /**
890
- * Get the default format search directories.
1577
+ * Get the format search directories, lowest rank first: the story format subdirectories of the
1578
+ * home directory, then of the working directory, then each `TWEEGO_PATH` entry, then `extraPaths`
1579
+ * (`formatPaths`). A later directory outranks an earlier one for the same format folder name, so
1580
+ * `formatPaths` override `TWEEGO_PATH`, which overrides the home and working directories, as in
1581
+ * Tweego.
891
1582
  */
892
1583
  function getFormatSearchDirs(extraPaths = [], useTweegoPath = true) {
893
1584
  const subdirNames = [
@@ -902,41 +1593,27 @@ function getFormatSearchDirs(extraPaths = [], useTweegoPath = true) {
902
1593
  basePaths.add(homedir());
903
1594
  } catch {}
904
1595
  basePaths.add(process.cwd());
905
- const dirs = [];
906
- for (const base of basePaths) for (const sub of subdirNames) {
907
- const dir = join(base, sub);
908
- try {
909
- if (statSync(dir).isDirectory()) dirs.push(dir);
910
- } catch {}
911
- }
1596
+ const dirs = [...basePaths].flatMap((base) => subdirNames.map((sub) => join(base, sub))).filter(isDirectory);
1597
+ const tweegoPath = useTweegoPath ? process.env.TWEEGO_PATH : void 0;
1598
+ if (tweegoPath) dirs.push(...tweegoPath.split(process.platform === "win32" ? ";" : ":"));
912
1599
  dirs.push(...extraPaths);
913
- if (useTweegoPath) {
914
- const tweegoPath = process.env.TWEEGO_PATH;
915
- if (tweegoPath) dirs.push(...tweegoPath.split(process.platform === "win32" ? ";" : ":"));
916
- }
917
1600
  return dirs;
918
1601
  }
919
- /** Parse a simple semver string into [major, minor, patch]. */
1602
+ /**
1603
+ * Parse a version string into [major, minor, patch]. Accepts what {@link parseVersion} accepts
1604
+ * (`v1.2.3`, `1.2`, `2.0.0-beta.1`); the prerelease and build metadata are dropped.
1605
+ */
920
1606
  function parseSemver(v) {
921
- const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
922
- if (!m) return null;
923
- return [
924
- Number(m[1]),
925
- Number(m[2]),
926
- Number(m[3])
927
- ];
928
- }
929
- function semverCompare(a, b) {
930
- const [aMajor, aMinor, aPatch] = a;
931
- const [bMajor, bMinor, bPatch] = b;
932
- if (aMajor !== bMajor) return aMajor - bMajor;
933
- if (aMinor !== bMinor) return aMinor - bMinor;
934
- if (aPatch !== bPatch) return aPatch - bPatch;
935
- return 0;
1607
+ const parsed = parseVersion(v);
1608
+ return parsed ? [
1609
+ parsed.major,
1610
+ parsed.minor,
1611
+ parsed.patch
1612
+ ] : null;
936
1613
  }
937
1614
  /** Build a directory-style format ID from a name and version, e.g. ('SugarCube', '2.37.3') → 'sugarcube-2'. */
938
1615
  function makeFormatId(name, version) {
939
- const major = parseSemver(version)?.[0] ?? 0;
1616
+ const major = parseVersion(version)?.major ?? 0;
940
1617
  return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
941
1618
  }
942
1619
  /** Describe a format request for diagnostics. */
@@ -947,81 +1624,164 @@ function describeFormatRequest(request) {
947
1624
  default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
948
1625
  }
949
1626
  }
950
- /**
951
- * Pick the candidate that best answers a format request.
952
- *
953
- * - An ID request ('sugarcube-2') matches a candidate whose name and major version build that ID,
954
- * and takes the greatest such version.
955
- * - A name request matches the name case-insensitively. An exact version wins outright; otherwise
956
- * the greatest version with the same major that is not older than the one asked for (or, with
957
- * `allowOlder`, the greatest same-major version). An unparseable requested version takes the
958
- * greatest version of any major.
959
- */
960
- function selectFormatCandidate(request, candidates, describe, options = {}) {
961
- const allowOlder = options.allowOlder ?? false;
962
- const wanted = request.kind === "name" ? parseSemver(request.version) : null;
1627
+ /** {@link selectFormatCandidate} over one set of candidates, matching names without regard to case. */
1628
+ function pickFormatCandidate(request, candidates, describe, allowOlder) {
1629
+ const wanted = request.kind === "name" ? parseVersion(request.version) : null;
963
1630
  let best;
964
1631
  for (const candidate of candidates) {
965
1632
  const { name, version } = describe(candidate);
966
- const have = parseSemver(version);
1633
+ const have = parseVersion(version);
967
1634
  if (!have) continue;
968
1635
  if (request.kind === "id") {
969
1636
  if (makeFormatId(name, version) !== request.id.toLowerCase()) continue;
970
1637
  } else {
971
1638
  if (name.toLowerCase() !== request.name.toLowerCase()) continue;
972
1639
  if (wanted) {
973
- if (semverCompare(have, wanted) === 0) return candidate;
974
- if (have[0] !== wanted[0]) continue;
975
- if (!allowOlder && semverCompare(have, wanted) < 0) continue;
1640
+ const order = compareVersions(have, wanted);
1641
+ if (order === 0) return candidate;
1642
+ if (have.major !== wanted.major) continue;
1643
+ if (!allowOlder && order < 0) continue;
976
1644
  }
977
1645
  }
978
- if (!best || semverCompare(have, best.version) > 0) best = {
1646
+ if (!best || compareVersions(have, best.version) > 0) best = {
979
1647
  candidate,
980
1648
  version: have
981
1649
  };
982
1650
  }
983
1651
  return best?.candidate;
984
1652
  }
985
- /** Get format ID from Twine 2 name and version, using SemVer major matching. */
986
- function getFormatIdByNameAndVersion(formats, name, version) {
987
- const wanted = parseSemver(version);
988
- let bestVersion = null;
989
- let bestId;
990
- for (const [id, f] of formats) {
991
- if (!f.isTwine2 || f.name !== name) continue;
992
- const have = parseSemver(f.version);
993
- if (!have) continue;
994
- if (wanted === null || have[0] === wanted[0] && semverCompare(have, wanted) >= 0) {
995
- if (!bestVersion || semverCompare(have, bestVersion) > 0) {
996
- bestVersion = have;
997
- bestId = id;
998
- }
999
- }
1653
+ /**
1654
+ * Pick the candidate that best answers a format request. Local folders, the download cache and
1655
+ * remote indices all select through this one function.
1656
+ *
1657
+ * - An ID request ('sugarcube-2') matches, without regard to case, a candidate whose name and
1658
+ * major version build that ID, and takes the greatest such version.
1659
+ * - A name request matches the name without regard to case, but a candidate whose name matches in
1660
+ * case too is preferred when one answers the request. An exact version (prerelease included)
1661
+ * wins outright; otherwise the greatest version with the same major that is not older than the
1662
+ * one asked for (or, with `allowOlder`, the greatest same-major version). An unparseable
1663
+ * requested version takes the greatest version of any major.
1664
+ *
1665
+ * Versions compare by SemVer precedence ({@link compareVersions}), so a release outranks its
1666
+ * prereleases. Between equal versions, the earlier candidate wins.
1667
+ */
1668
+ function selectFormatCandidate(request, candidates, describe, options = {}) {
1669
+ const allowOlder = options.allowOlder ?? false;
1670
+ if (request.kind === "name") {
1671
+ const preferred = pickFormatCandidate(request, candidates.filter((c) => describe(c).name === request.name), describe, allowOlder);
1672
+ if (preferred !== void 0) return preferred;
1673
+ }
1674
+ return pickFormatCandidate(request, candidates, describe, allowOlder);
1675
+ }
1676
+ /** The Twine 2 formats of a discovered map, those from higher-ranked search directories first. */
1677
+ function rankedTwine2Formats(formats) {
1678
+ return [...formats.values()].filter((f) => f.isTwine2).reverse();
1679
+ }
1680
+ /**
1681
+ * Look a format ID up among discovered folders. The ID matches a folder name without regard to
1682
+ * case; an exact-case folder is preferred, then the highest-ranked one.
1683
+ */
1684
+ function findFormatById(formats, id) {
1685
+ const exact = formats.get(id);
1686
+ if (exact) return exact;
1687
+ const wanted = id.toLowerCase();
1688
+ return [...formats.values()].reverse().find((f) => f.id.toLowerCase() === wanted);
1689
+ }
1690
+ /**
1691
+ * Read the story format source (for Twine 2, extract the `source` property from JSON).
1692
+ * `diagnostics` receives a warning when the format file is not valid UTF-8 (it is read as Windows-1252).
1693
+ */
1694
+ function readFormatSource(format, diagnostics) {
1695
+ const source = readUTF8(format.filename, diagnostics);
1696
+ if (!format.isTwine2) return source;
1697
+ const decoded = decodeFormatJSON(source);
1698
+ if (!decoded.ok) throw new Error(`Cannot parse format ${format.id} JSON: ${decoded.reason}`);
1699
+ return decoded.data.source;
1700
+ }
1701
+ //#endregion
1702
+ //#region src/atomic-write.ts
1703
+ /**
1704
+ * Atomic file replacement, for every file twee-ts writes that something else may read
1705
+ * meanwhile: build output (which live-reload servers read on each change) and downloaded
1706
+ * story formats (which other twee-ts processes share).
1707
+ */
1708
+ /**
1709
+ * Writes `data` to `path` so that a reader sees either the previous file or the complete new
1710
+ * one, never a partly written file. The data goes to a temporary file in the same folder,
1711
+ * which is then renamed over `path`. When `path` is a symlink, the file it points to is
1712
+ * replaced, and an existing file keeps its permissions.
1713
+ *
1714
+ * On failure (a full disk, a missing folder) the temporary file is removed, `path` is left
1715
+ * as it was, and the error is thrown with `path` in its message and its original `code`.
1716
+ */
1717
+ function writeFileAtomic(path, data) {
1718
+ const target = followSymlink(path);
1719
+ const temp = join(dirname(target), `.${basename(target)}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
1720
+ try {
1721
+ writeFileSync(temp, data, {
1722
+ encoding: "utf-8",
1723
+ flag: "wx"
1724
+ });
1725
+ keepPermissions(target, temp);
1726
+ renameSync(temp, target);
1727
+ } catch (e) {
1728
+ rmSync(temp, { force: true });
1729
+ throw writeError(path, e);
1000
1730
  }
1001
- return bestId;
1002
1731
  }
1003
- /** Read the story format source (for Twine 2, extract the `source` property from JSON). */
1004
- function readFormatSource(format) {
1005
- const source = readUTF8(format.filename);
1006
- if (format.isTwine2) {
1007
- const data = parseFormatJSON(source, format.id);
1008
- if (!data) throw new Error(`Cannot parse format ${format.id} JSON`);
1009
- return data.source;
1732
+ /** The file a symlink at `path` points to, or `path` itself (also when it doesn't exist yet). */
1733
+ function followSymlink(path) {
1734
+ try {
1735
+ return realpathSync(path);
1736
+ } catch {
1737
+ return path;
1010
1738
  }
1011
- return source;
1739
+ }
1740
+ /**
1741
+ * Gives `temp` the permission bits of `target`, when `target` exists. Best effort: on a file
1742
+ * system without Unix permissions chmod can fail, and writing in place never kept them there either.
1743
+ */
1744
+ function keepPermissions(target, temp) {
1745
+ const existing = statSync(target, { throwIfNoEntry: false });
1746
+ if (existing === void 0) return;
1747
+ try {
1748
+ chmodSync(temp, existing.mode & 4095);
1749
+ } catch {}
1750
+ }
1751
+ /** `cause` as an error that names `path` and keeps the original error code. */
1752
+ function writeError(path, cause) {
1753
+ const message = cause instanceof Error ? cause.message : String(cause);
1754
+ const error = new Error(`Cannot write ${path}: ${message}`, { cause });
1755
+ if (cause instanceof Error && "code" in cause) error.code = cause.code;
1756
+ return error;
1012
1757
  }
1013
1758
  //#endregion
1014
1759
  //#region src/remote-formats.ts
1015
1760
  /**
1016
1761
  * Remote story format fetching, caching, and checksum verification.
1017
1762
  * Uses the Story Formats Archive (SFA) as the default source.
1763
+ *
1764
+ * Two caches hold downloads. Formats found through an index are shared by name and version
1765
+ * (`<cache>/<name>/<version>/format.js`). A format downloaded from a direct URL is kept under
1766
+ * that URL instead, so one project's copy never answers another project's request.
1767
+ * Cache files are replaced atomically, so another process never reads a partly written one.
1018
1768
  */
1019
1769
  const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
1770
+ /** The longest delay a timer accepts; a longer timeout means no limit. */
1771
+ const MAX_TIMER_DELAY = 2147483647;
1020
1772
  /** Get the cache directory for downloaded story formats. */
1021
1773
  function getCacheDir() {
1022
1774
  const base = process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache");
1023
1775
  return join(base, "twee-ts", "storyformats");
1024
1776
  }
1777
+ /** The cache directory for formats downloaded from direct URLs, beside {@link getCacheDir}. */
1778
+ function getUrlCacheDir() {
1779
+ return join(dirname(getCacheDir()), "storyformat-urls");
1780
+ }
1781
+ /** The name of one direct URL's cache entry: a hash of the URL, so each URL has its own. */
1782
+ function urlCacheKey(url) {
1783
+ return createHash("sha256").update(url).digest("hex");
1784
+ }
1025
1785
  /**
1026
1786
  * Whether a string can serve as one directory name inside the cache: not empty, not `.` or `..`,
1027
1787
  * and free of path separators. Format names and versions come from downloaded metadata, so they
@@ -1044,25 +1804,38 @@ function cachedFormatDir(name, version) {
1044
1804
  if (!isInside$1(root, dir)) throw new Error(`Refusing to cache a story format outside the cache directory ${root}: ${dir}`);
1045
1805
  return dir;
1046
1806
  }
1047
- /** Create `dir` as a plain directory, or check that it already is one (a symlink could lead out of the cache). */
1807
+ /** Whether `e` is a Node.js system error with the given code. */
1808
+ function hasErrorCode(e, code) {
1809
+ return e instanceof Error && "code" in e && e.code === code;
1810
+ }
1811
+ /**
1812
+ * Create `dir` as a plain directory, or check that it already is one (a symlink could lead out of the cache).
1813
+ * A directory another process creates between the check and the mkdir is fine.
1814
+ */
1048
1815
  function ensurePlainDirectory(dir) {
1049
- const stat = lstatSync(dir, { throwIfNoEntry: false });
1050
- if (stat === void 0) {
1816
+ if (lstatSync(dir, { throwIfNoEntry: false }) === void 0) try {
1051
1817
  mkdirSync(dir);
1052
1818
  return;
1819
+ } catch (e) {
1820
+ if (!hasErrorCode(e, "EEXIST")) throw e;
1053
1821
  }
1054
- if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`Refusing to write a story format outside the cache directory: ${dir} is not a plain directory`);
1822
+ const stat = lstatSync(dir, { throwIfNoEntry: false });
1823
+ 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`);
1055
1824
  }
1056
- /** Write a downloaded format.js to `<cache>/<name>/<version>/format.js` and return its path. */
1057
- function writeCachedFormat(name, version, text) {
1058
- const dir = cachedFormatDir(name, version);
1059
- const root = resolve(getCacheDir());
1825
+ /**
1826
+ * Write a downloaded format.js to `<root>/<...segments>/format.js`, replacing any earlier copy
1827
+ * atomically, and return its path. Every directory below `root` must be a plain one.
1828
+ */
1829
+ function writeCacheEntry(root, segments, text) {
1060
1830
  mkdirSync(root, { recursive: true });
1061
- ensurePlainDirectory(join(root, name));
1062
- ensurePlainDirectory(dir);
1831
+ const dir = segments.reduce((parent, segment) => {
1832
+ const child = join(parent, segment);
1833
+ ensurePlainDirectory(child);
1834
+ return child;
1835
+ }, root);
1063
1836
  const formatPath = join(dir, "format.js");
1064
1837
  if (lstatSync(formatPath, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error(`Refusing to write a story format outside the cache directory: ${formatPath} is a symlink`);
1065
- writeFileSync(formatPath, text, "utf-8");
1838
+ writeFileAtomic(formatPath, text);
1066
1839
  return formatPath;
1067
1840
  }
1068
1841
  /** In-memory index cache, keyed by URL. Cleared each compile. */
@@ -1071,6 +1844,78 @@ const indexCache = /* @__PURE__ */ new Map();
1071
1844
  function clearIndexCache() {
1072
1845
  indexCache.clear();
1073
1846
  }
1847
+ /** Requests in progress, by what they fetch, so concurrent compiles make each request once. */
1848
+ const sharedRequests = /* @__PURE__ */ new Map();
1849
+ /**
1850
+ * Runs `start` once for all callers that ask for `key` while it is in progress. A caller whose
1851
+ * `signal` aborts stops waiting at once and rejects with the signal's reason; the request
1852
+ * itself is aborted (through the signal `start` receives) once no caller waits on it.
1853
+ */
1854
+ function shareRequest(key, signal, start) {
1855
+ if (signal?.aborted) return Promise.reject(signal.reason);
1856
+ const request = sharedRequests.get(key) ?? startSharedRequest(key, start);
1857
+ request.waiters++;
1858
+ return new Promise((resolve, reject) => {
1859
+ let waiting = true;
1860
+ const stopWaiting = () => {
1861
+ if (!waiting) return;
1862
+ waiting = false;
1863
+ signal?.removeEventListener("abort", onAbort);
1864
+ request.waiters--;
1865
+ };
1866
+ const onAbort = () => {
1867
+ stopWaiting();
1868
+ if (request.waiters === 0) {
1869
+ if (sharedRequests.get(key) === request) sharedRequests.delete(key);
1870
+ request.controller.abort(signal?.reason);
1871
+ }
1872
+ reject(signal?.reason);
1873
+ };
1874
+ signal?.addEventListener("abort", onAbort, { once: true });
1875
+ request.promise.then((value) => {
1876
+ stopWaiting();
1877
+ resolve(value);
1878
+ }, (e) => {
1879
+ stopWaiting();
1880
+ reject(e);
1881
+ });
1882
+ });
1883
+ }
1884
+ function startSharedRequest(key, start) {
1885
+ const controller = new AbortController();
1886
+ const request = {
1887
+ promise: start(controller.signal),
1888
+ controller,
1889
+ waiters: 0
1890
+ };
1891
+ sharedRequests.set(key, request);
1892
+ const forget = () => {
1893
+ if (sharedRequests.get(key) === request) sharedRequests.delete(key);
1894
+ };
1895
+ request.promise.then(forget, forget);
1896
+ return request;
1897
+ }
1898
+ /** The time limit for each request, from the caller's options. */
1899
+ function requestTimeout(options) {
1900
+ const timeout = options.timeout ?? 3e4;
1901
+ if (!(timeout >= 0)) throw new RangeError(`A story format request timeout must be 0 or more milliseconds, not ${timeout}`);
1902
+ return timeout;
1903
+ }
1904
+ /**
1905
+ * Fetch `url` as text, aborting when `signal` does or after `timeout` milliseconds (0: no limit).
1906
+ * `what` names the request in errors, as in "Failed to <what> from <url>".
1907
+ */
1908
+ async function fetchText(url, what, signal, timeout) {
1909
+ const limit = timeout > 0 && timeout <= MAX_TIMER_DELAY ? AbortSignal.timeout(timeout) : void 0;
1910
+ try {
1911
+ const res = await fetch(url, { signal: limit ? AbortSignal.any([signal, limit]) : signal });
1912
+ if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
1913
+ return await res.text();
1914
+ } catch (e) {
1915
+ if (limit?.aborted && !signal.aborted) throw new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`, { cause: e });
1916
+ throw e;
1917
+ }
1918
+ }
1074
1919
  /** Validate that a JSON value is a valid SFAIndexEntry. */
1075
1920
  function isValidEntry(val) {
1076
1921
  if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
@@ -1086,15 +1931,18 @@ function validateSFAIndex(json) {
1086
1931
  twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
1087
1932
  };
1088
1933
  }
1089
- /** Fetch and parse an SFA index.json, with in-memory caching. */
1090
- async function fetchIndex(url) {
1934
+ /** Fetch and parse an SFA index.json, with in-memory caching. Concurrent calls for one URL share a request. */
1935
+ async function fetchIndex(url, options = {}) {
1936
+ options.signal?.throwIfAborted();
1091
1937
  const cached = indexCache.get(url);
1092
1938
  if (cached) return cached;
1093
- const res = await fetch(url);
1094
- if (!res.ok) throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
1095
- const data = validateSFAIndex(await res.json());
1096
- indexCache.set(url, data);
1097
- return data;
1939
+ const timeout = requestTimeout(options);
1940
+ return shareRequest(`index\0${url}`, options.signal, async (signal) => {
1941
+ const text = await fetchText(url, "fetch format index", signal, timeout);
1942
+ const data = validateSFAIndex(JSON.parse(text));
1943
+ indexCache.set(url, data);
1944
+ return data;
1945
+ });
1098
1946
  }
1099
1947
  /** Find the best matching entry in an SFA index for a name or ID request (twine2 entries first). */
1100
1948
  function findEntryForRequest(index, request) {
@@ -1115,84 +1963,138 @@ async function verifySHA256(content, expectedHex) {
1115
1963
  function getDownloadUrl(indexUrl, entry, formatType) {
1116
1964
  return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
1117
1965
  }
1118
- /** Download a format, verify its checksum, write to cache, and return StoryFormatInfo. */
1119
- async function fetchAndCacheFormat(entry, downloadUrl) {
1120
- cachedFormatDir(entry.name, entry.version);
1121
- const res = await fetch(downloadUrl);
1122
- if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
1123
- const text = await res.text();
1124
- const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
1125
- if (checksumKey) {
1126
- const expected = entry.checksums[checksumKey];
1127
- if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
1128
- if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
1129
- }
1130
- const id = makeFormatId(entry.name, entry.version);
1131
- const data = parseFormatJSON(text, id);
1132
- if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
1966
+ /** The StoryFormatInfo of a Twine 2 format.js cached at `filename`. */
1967
+ function cachedFormatInfo(id, filename, data) {
1133
1968
  return {
1134
1969
  id,
1135
- filename: writeCachedFormat(entry.name, entry.version, text),
1970
+ filename,
1136
1971
  isTwine2: true,
1137
1972
  name: data.name,
1138
1973
  version: data.version,
1139
1974
  proofing: data.proofing ?? false
1140
1975
  };
1141
1976
  }
1142
- /** Download a direct format.js URL, parse its JSON, cache it, and return StoryFormatInfo. */
1143
- async function fetchDirectFormat(url) {
1144
- const res = await fetch(url);
1145
- if (!res.ok) throw new Error(`Failed to download format from ${url}: ${res.status} ${res.statusText}`);
1146
- const text = await res.text();
1147
- const data = parseFormatJSON(text);
1148
- if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
1149
- return {
1150
- id: makeFormatId(data.name, data.version),
1151
- filename: writeCachedFormat(data.name, data.version, text),
1152
- isTwine2: true,
1153
- name: data.name,
1154
- version: data.version,
1155
- proofing: data.proofing ?? false
1156
- };
1977
+ /**
1978
+ * Download a format, verify its checksum, write it to the cache shared by name and version, and
1979
+ * return its StoryFormatInfo. Concurrent calls for one download URL share one request.
1980
+ */
1981
+ async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
1982
+ cachedFormatDir(entry.name, entry.version);
1983
+ const timeout = requestTimeout(options);
1984
+ return shareRequest(`format\0${getCacheDir()}\0${downloadUrl}`, options.signal, async (signal) => {
1985
+ const text = await fetchText(downloadUrl, "download format", signal, timeout);
1986
+ const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
1987
+ if (checksumKey) {
1988
+ const expected = entry.checksums[checksumKey];
1989
+ if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
1990
+ if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
1991
+ }
1992
+ const id = makeFormatId(entry.name, entry.version);
1993
+ const data = parseFormatJSON(text, id);
1994
+ if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
1995
+ signal.throwIfAborted();
1996
+ return cachedFormatInfo(id, writeCacheEntry(resolve(getCacheDir()), [entry.name, entry.version], text), data);
1997
+ });
1998
+ }
1999
+ /**
2000
+ * Download a direct format.js URL, parse its JSON, cache it under that URL (apart from the
2001
+ * downloads shared by name and version), and return its StoryFormatInfo. Concurrent calls for
2002
+ * one URL share one request.
2003
+ */
2004
+ async function fetchDirectFormat(url, options = {}) {
2005
+ const timeout = requestTimeout(options);
2006
+ return shareRequest(`url\0${getUrlCacheDir()}\0${url}`, options.signal, async (signal) => {
2007
+ const text = await fetchText(url, "download format", signal, timeout);
2008
+ const data = parseFormatJSON(text);
2009
+ if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
2010
+ cachedFormatDir(data.name, data.version);
2011
+ signal.throwIfAborted();
2012
+ const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
2013
+ return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
2014
+ });
2015
+ }
2016
+ /** The copy of a direct format URL in the download cache, if it has been downloaded before. */
2017
+ function getCachedDirectFormat(url) {
2018
+ const formatPath = join(getUrlCacheDir(), urlCacheKey(url), "format.js");
2019
+ try {
2020
+ if (!existsSync(formatPath)) return void 0;
2021
+ const data = parseFormatJSON(readUTF8(formatPath));
2022
+ if (!data) return void 0;
2023
+ return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
2024
+ } catch {
2025
+ return;
2026
+ }
2027
+ }
2028
+ /**
2029
+ * Look a request up among direct format URLs, in order, and return the first format that
2030
+ * answers it. A URL downloaded before is answered by its cached copy without the network;
2031
+ * any other is downloaded (unless `offline`). When none answers and a download failed, the
2032
+ * last failure is thrown. An aborted signal rejects with its reason and tries no further URL.
2033
+ *
2034
+ * Internal, for format resolution; not part of the public API.
2035
+ */
2036
+ async function resolveFormatUrls(request, urls, options = {}) {
2037
+ let lastError;
2038
+ for (const url of urls) {
2039
+ const cached = getCachedDirectFormat(url);
2040
+ if (cached) {
2041
+ if (answers(request, cached)) return cached;
2042
+ continue;
2043
+ }
2044
+ if (options.offline) continue;
2045
+ try {
2046
+ const info = await fetchDirectFormat(url, options);
2047
+ if (answers(request, info)) return info;
2048
+ } catch (e) {
2049
+ if (options.signal?.aborted) throw options.signal.reason;
2050
+ lastError = toError(e);
2051
+ }
2052
+ }
2053
+ if (lastError) throw lastError;
2054
+ }
2055
+ /** Whether `info` is a format the request accepts. */
2056
+ function answers(request, info) {
2057
+ return selectFormatCandidate(request, [info], (f) => f) !== void 0;
2058
+ }
2059
+ function toError(e) {
2060
+ return e instanceof Error ? e : new Error(String(e));
1157
2061
  }
1158
2062
  /**
1159
2063
  * Resolve a story format request remotely, in the same order as {@link resolveRemoteFormat}.
1160
2064
  * An ID request such as 'sugarcube-2' matches the format whose name and major version build that ID
1161
2065
  * (SugarCube 2.x), taking the greatest version available.
1162
2066
  */
1163
- async function resolveRemoteFormatRequest(request, indices, urls) {
2067
+ async function resolveRemoteFormatRequest(request, indices, urls, options = {}) {
2068
+ options.signal?.throwIfAborted();
1164
2069
  let lastError;
1165
- const cached = findCachedFormat(request);
1166
- if (cached && request.kind === "name" && isExactVersion(cached.version, request.version)) return cached;
1167
- for (const url of urls ?? []) try {
1168
- const info = await fetchDirectFormat(url);
1169
- if (selectFormatCandidate(request, [info], (f) => f)) return info;
2070
+ try {
2071
+ const direct = await resolveFormatUrls(request, urls ?? [], options);
2072
+ if (direct) return direct;
1170
2073
  } catch (e) {
1171
- lastError = e instanceof Error ? e : new Error(String(e));
2074
+ if (options.signal?.aborted) throw options.signal.reason;
2075
+ lastError = toError(e);
1172
2076
  }
2077
+ const cached = findCachedFormat(request);
2078
+ if (cached && request.kind === "name" && sameVersion(cached.version, request.version)) return cached;
1173
2079
  const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
1174
2080
  for (const indexUrl of allIndices) try {
1175
- const result = findEntryForRequest(await fetchIndex(indexUrl), request);
2081
+ const result = findEntryForRequest(await fetchIndex(indexUrl, options), request);
1176
2082
  if (result) {
1177
2083
  const hit = getCachedFormat(result.entry.name, result.entry.version);
1178
2084
  if (hit) return hit;
1179
2085
  const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
1180
- return await fetchAndCacheFormat(result.entry, downloadUrl);
2086
+ return await fetchAndCacheFormat(result.entry, downloadUrl, options);
1181
2087
  }
1182
2088
  } catch (e) {
1183
- lastError = e instanceof Error ? e : new Error(String(e));
2089
+ if (options.signal?.aborted) throw options.signal.reason;
2090
+ lastError = toError(e);
1184
2091
  }
1185
2092
  if (cached) return cached;
1186
2093
  if (lastError) throw lastError;
1187
2094
  }
1188
- /** Whether two version strings name the same SemVer version. */
1189
- function isExactVersion(have, wanted) {
1190
- const a = parseSemver(have);
1191
- const b = parseSemver(wanted);
1192
- return a !== null && b !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
1193
- }
1194
2095
  /**
1195
2096
  * Find the cached download that best answers a format request, without network access.
2097
+ * Only the downloads shared by name and version are searched, not those from direct URLs.
1196
2098
  * Matching follows {@link selectFormatCandidate}.
1197
2099
  */
1198
2100
  function findCachedFormat(request, options = {}) {
@@ -1204,18 +2106,11 @@ function getCachedFormat(name, version) {
1204
2106
  const formatPath = join(getCacheDir(), name, version, "format.js");
1205
2107
  try {
1206
2108
  if (!existsSync(formatPath)) return void 0;
1207
- const source = readFileSync(formatPath, "utf-8");
2109
+ const source = readUTF8(formatPath);
1208
2110
  const id = makeFormatId(name, version);
1209
2111
  const data = parseFormatJSON(source, id);
1210
2112
  if (!data) return void 0;
1211
- return {
1212
- id,
1213
- filename: formatPath,
1214
- isTwine2: true,
1215
- name: data.name,
1216
- version: data.version,
1217
- proofing: data.proofing ?? false
1218
- };
2113
+ return cachedFormatInfo(id, formatPath, data);
1219
2114
  } catch {
1220
2115
  return;
1221
2116
  }
@@ -1273,44 +2168,65 @@ function formatRequestFor(formatId, storyFormat, storyFormatVersion, defaultId)
1273
2168
  id: defaultId
1274
2169
  };
1275
2170
  }
1276
- /** Look a request up among the formats found in local format directories. */
1277
- function findLocalFormat(formats, request) {
2171
+ /**
2172
+ * Look a request up among the formats found in local format directories. An ID names a folder, so
2173
+ * it is looked up among all of them; a name request selects by SemVer among the pruned formats.
2174
+ */
2175
+ function findLocalFormat(all, pruned, request) {
1278
2176
  switch (request.kind) {
1279
- case "id": return formats.get(request.id);
1280
- case "name": {
1281
- const id = getFormatIdByNameAndVersion(formats, request.name, request.version);
1282
- return id === void 0 ? void 0 : formats.get(id);
1283
- }
2177
+ case "id": return findFormatById(all, request.id);
2178
+ case "name": return selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f);
1284
2179
  default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
1285
2180
  }
1286
2181
  }
1287
2182
  /**
1288
2183
  * Resolve a format request to a story format.
1289
2184
  *
1290
- * Order: local format directories, then the download cache, then remote sources (unless
1291
- * `noRemote`). When a StoryData request names a version that none of them has, an older version of
2185
+ * Order: local format directories, then the project's direct format URLs (each one's cached
2186
+ * copy, else a download unless `noRemote`), then the download cache shared by name and version,
2187
+ * then format indices (unless `noRemote`). Names and IDs match the same way in each (see
2188
+ * `selectFormatCandidate`), so a request that a local format answers never reaches the cache or the
2189
+ * network. Local formats that cannot be used are reported as warnings. When a StoryData request
2190
+ * names a version that none of them has, an older version of
1292
2191
  * the same format and major version is used with a warning. Anything else is an error diagnostic,
1293
2192
  * and the result is `undefined`.
1294
2193
  */
1295
2194
  async function resolveStoryFormat(request, options, diagnostics) {
1296
2195
  const noRemote = options.noRemote ?? false;
1297
- const formats = discoverFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true));
2196
+ const all = discoverAllFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true), diagnostics);
2197
+ const pruned = pruneFormats(all);
1298
2198
  const wanted = describeFormatRequest(request);
1299
- const local = findLocalFormat(formats, request);
2199
+ const local = findLocalFormat(all, pruned, request);
1300
2200
  if (local) return local;
2201
+ const fetchOptions = {
2202
+ signal: options.signal,
2203
+ timeout: options.formatFetchTimeout
2204
+ };
2205
+ const warnOnFailure = async (lookup) => {
2206
+ try {
2207
+ return await lookup();
2208
+ } catch (e) {
2209
+ if (options.signal?.aborted) throw options.signal.reason;
2210
+ diagnostics.push({
2211
+ level: "warning",
2212
+ message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
2213
+ });
2214
+ return;
2215
+ }
2216
+ };
2217
+ const direct = await warnOnFailure(() => resolveFormatUrls(request, options.formatUrls ?? [], {
2218
+ ...fetchOptions,
2219
+ offline: noRemote
2220
+ }));
2221
+ if (direct) return direct;
1301
2222
  const cached = findCachedFormat(request);
1302
2223
  if (cached) return cached;
1303
- if (!noRemote) try {
1304
- const remote = await resolveRemoteFormatRequest(request, options.formatIndices, options.formatUrls);
2224
+ if (!noRemote) {
2225
+ const remote = await warnOnFailure(() => resolveRemoteFormatRequest(request, options.formatIndices, [], fetchOptions));
1305
2226
  if (remote) return remote;
1306
- } catch (e) {
1307
- diagnostics.push({
1308
- level: "warning",
1309
- message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
1310
- });
1311
2227
  }
1312
2228
  if (request.kind === "name") {
1313
- const older = selectFormatCandidate(request, [...formats.values()].filter((f) => f.isTwine2), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
2229
+ const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
1314
2230
  if (older) {
1315
2231
  diagnostics.push({
1316
2232
  level: "warning",
@@ -1322,7 +2238,7 @@ async function resolveStoryFormat(request, options, diagnostics) {
1322
2238
  const reason = noRemote ? " (remote fetching disabled)" : "";
1323
2239
  diagnostics.push({
1324
2240
  level: "error",
1325
- message: `Story format ${wanted} is not available${reason}. Found: ${[...formats.keys()].join(", ") || "none"}`
2241
+ message: `Story format ${wanted} is not available${reason}. Found: ${[...all.keys()].join(", ") || "none"}`
1326
2242
  });
1327
2243
  }
1328
2244
  //#endregion
@@ -1591,12 +2507,14 @@ function twee2ToV3(s) {
1591
2507
  * Parse Twee source text into passages.
1592
2508
  *
1593
2509
  * The source is normalized first, as files are when they are read: a leading UTF-8 BOM is
1594
- * removed and CRLF and bare CR line endings become LF.
2510
+ * removed and CRLF and bare CR line endings become LF. A BOM at the start of a later line,
2511
+ * directly before `::` (left there by concatenating files), is removed too, so that line
2512
+ * stays a passage header.
1595
2513
  */
1596
2514
  function parseTwee(source, options = {}) {
1597
2515
  const { filename = "<inline>", trim = true, twee2Compat = false } = options;
1598
2516
  const diagnostics = [];
1599
- const normalized = normalizeSourceText(source);
2517
+ const normalized = normalizeTweeSourceText(source);
1600
2518
  const tweeSource = twee2Compat ? twee2ToV3(normalized) : normalized;
1601
2519
  const passages = [];
1602
2520
  let current = null;
@@ -4431,17 +5349,21 @@ function parseDocument(data, options) {
4431
5349
  * Ported from storyload.go:loadHTML().
4432
5350
  */
4433
5351
  /**
4434
- * Parse a Twine 2 or Twine 1 compiled HTML file back into a Story model.
4435
- * Tweego always trims passage text; here `trim: false` keeps it exactly, as for Twee sources.
5352
+ * `decompileHTML()` for loading an HTML file into a compile. The importer's StoryData passage is
5353
+ * checked when it is added to the story being compiled, which reports a missing or invalid IFID,
5354
+ * so the IFID is not reported here as well.
4436
5355
  */
4437
- function decompileHTML(html, options = {}) {
5356
+ function decompileHTMLForImport(html, options = {}) {
5357
+ return decompile(html, options, { ifid: false });
5358
+ }
5359
+ function decompile(html, options, checks) {
4438
5360
  const diagnostics = [];
4439
5361
  const story = createStory();
4440
5362
  const passageText = options.trim === false ? (text) => text : (text) => text.trim();
4441
5363
  const doc = parseDocument(html);
4442
5364
  const twine2Data = findElement(doc, "tw-storydata");
4443
5365
  if (twine2Data) {
4444
- decompileTwine2(twine2Data, story, passageText, diagnostics);
5366
+ decompileTwine2(twine2Data, story, passageText, checks, diagnostics);
4445
5367
  return {
4446
5368
  story,
4447
5369
  diagnostics
@@ -4464,7 +5386,7 @@ function decompileHTML(html, options = {}) {
4464
5386
  diagnostics
4465
5387
  };
4466
5388
  }
4467
- function decompileTwine2(storyData, story, passageText, diagnostics) {
5389
+ function decompileTwine2(storyData, story, passageText, checks, diagnostics) {
4468
5390
  let startnode = 0;
4469
5391
  const attrs = storyData.attribs ?? {};
4470
5392
  if (attrs["name"]) story.name = attrs["name"];
@@ -4477,6 +5399,7 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
4477
5399
  else startnode = parsed;
4478
5400
  }
4479
5401
  if (attrs["ifid"]) story.ifid = normalizeIFID(attrs["ifid"]);
5402
+ if (checks.ifid) diagnostics.push(...storyDataIFIDDiagnostics(attrs["ifid"] ?? ""));
4480
5403
  if (attrs["zoom"]) {
4481
5404
  const parsed = parseFloat(attrs["zoom"]);
4482
5405
  if (Number.isNaN(parsed)) diagnostics.push({
@@ -4499,13 +5422,17 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
4499
5422
  const content = getTextContent(node);
4500
5423
  if (content.trim().length === 0) continue;
4501
5424
  const text = passageText(content);
4502
- const name = freeName(node.name === "style" ? "Story Stylesheet" : "Story JavaScript", takenNames);
5425
+ const base = node.name === "style" ? "Story Stylesheet" : "Story JavaScript";
5426
+ const name = freeName(base, (n) => !takenNames.has(n));
4503
5427
  takenNames.add(name);
4504
- storyAdd(story, {
5428
+ storyAdd(story, withGeneratedName({
4505
5429
  name,
4506
5430
  tags: node.name === "style" ? ["stylesheet"] : ["script"],
4507
5431
  text
4508
- }, diagnostics);
5432
+ }, {
5433
+ kind: "code",
5434
+ base
5435
+ }), diagnostics);
4509
5436
  break;
4510
5437
  }
4511
5438
  case "tw-tag": {
@@ -4552,19 +5479,26 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
4552
5479
  function passageDataName(node) {
4553
5480
  return node.attribs?.["name"] ?? "";
4554
5481
  }
4555
- /** The first of `base`, `base 2`, `base 3`, … that is not in `taken`. */
4556
- function freeName(base, taken) {
4557
- if (!taken.has(base)) return base;
4558
- for (let n = 2;; n++) {
4559
- const candidate = `${base} ${n}`;
4560
- if (!taken.has(candidate)) return candidate;
4561
- }
5482
+ /** Diagnostics for the `ifid` attribute of `tw-storydata`; `value` is empty when it is missing. */
5483
+ function storyDataIFIDDiagnostics(value) {
5484
+ if (value === "") return [{
5485
+ level: "warning",
5486
+ message: "Story IFID not found; the \"tw-storydata\" content attribute \"ifid\" is missing or empty."
5487
+ }];
5488
+ const err = validateIFID(value);
5489
+ if (err === null) return [];
5490
+ return [{
5491
+ level: "warning",
5492
+ message: `Cannot parse "tw-storydata" content attribute "ifid" as an IFID; value "${value}" (${err}).`
5493
+ }];
4562
5494
  }
4563
5495
  function decompileTwine1(storeArea, story, passageText, diagnostics) {
4564
5496
  const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
4565
5497
  const obfuscated = isRot13Obfuscated(passages);
4566
- for (const p of passages) storyAdd(story, obfuscated && p.name !== "StorySettings" ? {
5498
+ for (const p of passages) storyAdd(story, obfuscated && isObfuscatable(p) ? {
4567
5499
  ...p,
5500
+ name: rot13(p.name),
5501
+ tags: p.tags.map(rot13),
4568
5502
  text: rot13(p.text)
4569
5503
  } : p, diagnostics);
4570
5504
  }
@@ -4709,7 +5643,12 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
4709
5643
  function loadInlineSources(story, sources, opts, diagnostics) {
4710
5644
  for (const source of sources) {
4711
5645
  if (typeof source === "string") continue;
4712
- const content = normalizeSourceText(typeof source.content === "string" ? source.content : source.content.toString("utf-8"));
5646
+ const decoded = typeof source.content === "string" ? {
5647
+ text: source.content,
5648
+ diagnostics: []
5649
+ } : decodeText(source.content, source.filename);
5650
+ diagnostics.push(...decoded.diagnostics);
5651
+ const content = normalizeSourceText(decoded.text);
4713
5652
  const ext = normalizedFileExt(source.filename);
4714
5653
  switch (ext) {
4715
5654
  case "":
@@ -4728,18 +5667,10 @@ function loadInlineSources(story, sources, opts, diagnostics) {
4728
5667
  break;
4729
5668
  }
4730
5669
  case "css":
4731
- storyAdd(story, {
4732
- name: basename(source.filename),
4733
- tags: ["stylesheet"],
4734
- text: content
4735
- }, diagnostics);
5670
+ storyAdd(story, codePassage(basename(source.filename), "stylesheet", content), diagnostics);
4736
5671
  break;
4737
5672
  case "js":
4738
- storyAdd(story, {
4739
- name: basename(source.filename),
4740
- tags: ["script"],
4741
- text: content
4742
- }, diagnostics);
5673
+ storyAdd(story, codePassage(basename(source.filename), "script", content), diagnostics);
4743
5674
  break;
4744
5675
  default: diagnostics.push({
4745
5676
  level: "warning",
@@ -4749,35 +5680,60 @@ function loadInlineSources(story, sources, opts, diagnostics) {
4749
5680
  }
4750
5681
  }
4751
5682
  function parseTweeFile(filename, opts) {
4752
- const result = parseTwee(readUTF8(filename), {
5683
+ const readDiagnostics = [];
5684
+ const result = parseTwee(readUTF8(filename, readDiagnostics), {
4753
5685
  filename,
4754
5686
  trim: opts.trim ?? true,
4755
5687
  twee2Compat: opts.twee2Compat ?? false
4756
5688
  });
4757
5689
  return {
4758
5690
  passages: result.passages,
4759
- diagnostics: [...result.diagnostics]
5691
+ diagnostics: [...readDiagnostics, ...result.diagnostics]
4760
5692
  };
4761
5693
  }
5694
+ /**
5695
+ * A stylesheet or script passage named after its file, as Tweego names it. The name only labels
5696
+ * the code, so when another passage has it, the passage takes a free one (see `storyAdd()`).
5697
+ */
5698
+ function codePassage(name, tag, text) {
5699
+ return withGeneratedName({
5700
+ name,
5701
+ tags: [tag],
5702
+ text
5703
+ }, {
5704
+ kind: "code",
5705
+ base: name
5706
+ });
5707
+ }
5708
+ /** Mark every passage of a parse result as a code passage named after its file. */
5709
+ function codeNames(result) {
5710
+ for (const p of result.passages) withGeneratedName(p, {
5711
+ kind: "code",
5712
+ base: p.name
5713
+ });
5714
+ return result;
5715
+ }
4762
5716
  function parseTaggedFile(tag, filename) {
4763
- const source = readUTF8(filename);
5717
+ const diagnostics = [];
5718
+ const source = readUTF8(filename, diagnostics);
4764
5719
  return {
4765
- passages: [{
4766
- name: basename(filename),
4767
- tags: [tag],
4768
- text: source
4769
- }],
4770
- diagnostics: []
5720
+ passages: [codePassage(basename(filename), tag, source)],
5721
+ diagnostics
4771
5722
  };
4772
5723
  }
4773
5724
  function parseMediaFile(tag, filename) {
4774
5725
  const source = readBase64(filename);
5726
+ const name = baseNameWithoutExt(filename);
4775
5727
  return {
4776
- passages: [{
4777
- name: baseNameWithoutExt(filename),
5728
+ passages: [withGeneratedName({
5729
+ name,
4778
5730
  tags: [tag],
4779
5731
  text: `data:${mediaTypeFromFilename(filename)};base64,${source}`
4780
- }],
5732
+ }, {
5733
+ kind: "media",
5734
+ base: name,
5735
+ file: filename
5736
+ })],
4781
5737
  diagnostics: []
4782
5738
  };
4783
5739
  }
@@ -4786,24 +5742,27 @@ function parseFontFile(filename) {
4786
5742
  const name = basename(filename);
4787
5743
  const family = baseNameWithoutExt(filename);
4788
5744
  const ext = normalizedFileExt(filename);
4789
- return {
5745
+ const mediaType = mediaTypeFromExt(ext);
5746
+ const hint = fontFormatHint(ext);
5747
+ return codeNames({
4790
5748
  passages: [{
4791
5749
  name,
4792
5750
  tags: ["stylesheet"],
4793
- text: `@font-face {\n\tfont-family: "${family}";\n\tsrc: url("data:${mediaTypeFromExt(ext)};base64,${source}") format("${fontFormatHint(ext)}");\n}`
5751
+ text: `@font-face {\n\tfont-family: "${cssStringEscape(family)}";\n\tsrc: url("data:${mediaType};base64,${source}") format("${hint}");\n}`
4794
5752
  }],
4795
5753
  diagnostics: []
4796
- };
5754
+ });
4797
5755
  }
4798
5756
  function parseHTMLFile(filename, opts) {
4799
- const { story, diagnostics } = decompileHTML(readUTF8(filename), { trim: opts.trim ?? true });
5757
+ const readDiagnostics = [];
5758
+ const { story, diagnostics } = decompileHTMLForImport(readUTF8(filename, readDiagnostics), { trim: opts.trim ?? true });
4800
5759
  return {
4801
5760
  passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
4802
5761
  name: "StoryTitle",
4803
5762
  tags: [],
4804
5763
  text: story.name
4805
5764
  }, ...story.passages] : story.passages,
4806
- diagnostics: [...diagnostics]
5765
+ diagnostics: [...readDiagnostics, ...diagnostics]
4807
5766
  };
4808
5767
  }
4809
5768
  /**
@@ -4876,6 +5835,7 @@ function loadTwee(story, filename, opts, diagnostics) {
4876
5835
  }
4877
5836
  function loadTagged(story, tag, filename, diagnostics) {
4878
5837
  const result = parseTaggedFile(tag, filename);
5838
+ diagnostics.push(...result.diagnostics);
4879
5839
  for (const p of result.passages) storyAdd(story, p, diagnostics);
4880
5840
  }
4881
5841
  function loadMedia(story, tag, filename, diagnostics) {
@@ -4892,6 +5852,7 @@ function loadFont(story, filename, diagnostics) {
4892
5852
  */
4893
5853
  function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
4894
5854
  const currentFiles = /* @__PURE__ */ new Set();
5855
+ const changedPaths = changedFiles === void 0 ? void 0 : new Set([...changedFiles].map((f) => resolve(f)));
4895
5856
  for (const filename of filenames) {
4896
5857
  if (processedFiles.has(filename)) {
4897
5858
  diagnostics.push({
@@ -4904,8 +5865,8 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
4904
5865
  const optionsKey = parseOptionsKey(filename, opts);
4905
5866
  const entry = cache.get(filename);
4906
5867
  const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
4907
- const changed = changedFiles?.has(filename) ?? false;
4908
- if (changedFiles && cached && !changed) {
5868
+ const changed = changedPaths?.has(resolve(filename)) ?? false;
5869
+ if (changedPaths && cached && !changed) {
4909
5870
  diagnostics.push(...cached.diagnostics);
4910
5871
  for (const p of cached.passages) storyAdd(story, p, diagnostics);
4911
5872
  processedFiles.add(filename);
@@ -4915,6 +5876,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
4915
5876
  try {
4916
5877
  mtimeMs = statSync(filename).mtimeMs;
4917
5878
  } catch {
5879
+ cache.delete(filename);
4918
5880
  continue;
4919
5881
  }
4920
5882
  if (cached && !changed && cached.mtimeMs === mtimeMs) {
@@ -4935,6 +5897,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
4935
5897
  diagnostics.push(...result.diagnostics);
4936
5898
  for (const p of result.passages) storyAdd(story, p, diagnostics);
4937
5899
  } catch (e) {
5900
+ cache.delete(filename);
4938
5901
  diagnostics.push({
4939
5902
  level: "error",
4940
5903
  message: `load ${filename}: ${e instanceof Error ? e.message : String(e)}`,
@@ -4958,6 +5921,11 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
4958
5921
  * This is how the HTML tokenizer reads an end tag; it ignores whatever sits between the tag name and the `>`.
4959
5922
  */
4960
5923
  const CLOSING_HEAD_TAG = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>";
5924
+ /**
5925
+ * The start of an HTML body start tag: `<body` in any letter case, followed by whitespace, `/` or `>` (so not
5926
+ * `<bodyx>`). Only the `<body` is matched; the attributes and `>` after it are left alone.
5927
+ */
5928
+ const BODY_START_TAG = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
4961
5929
  /** The regular expression source matching exactly `text`. */
4962
5930
  function literal(text) {
4963
5931
  return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
@@ -4991,9 +5959,10 @@ function fillTemplateParts(parts) {
4991
5959
  //#endregion
4992
5960
  //#region src/modules.ts
4993
5961
  /**
4994
- * Load modules and return HTML tags to inject before the closing head tag.
5962
+ * Load modules and return HTML tags to inject before the closing head tag. `diagnostics` receives a warning for
5963
+ * each module that is not valid UTF-8 (it is read as Windows-1252).
4995
5964
  */
4996
- function loadModules(filenames) {
5965
+ function loadModules(filenames, diagnostics) {
4997
5966
  const processed = /* @__PURE__ */ new Set();
4998
5967
  const headTags = [];
4999
5968
  for (const filename of filenames) {
@@ -5002,10 +5971,10 @@ function loadModules(filenames) {
5002
5971
  let tag = null;
5003
5972
  switch (ext) {
5004
5973
  case "css":
5005
- tag = loadModuleTagged("style", filename);
5974
+ tag = loadModuleTagged("style", filename, diagnostics);
5006
5975
  break;
5007
5976
  case "js":
5008
- tag = loadModuleTagged("script", filename);
5977
+ tag = loadModuleTagged("script", filename, diagnostics);
5009
5978
  break;
5010
5979
  case "otf":
5011
5980
  case "ttf":
@@ -5020,8 +5989,8 @@ function loadModules(filenames) {
5020
5989
  }
5021
5990
  return headTags.join("\n");
5022
5991
  }
5023
- function loadModuleTagged(tag, filename) {
5024
- const source = readUTF8(filename).trim();
5992
+ function loadModuleTagged(tag, filename, diagnostics) {
5993
+ const source = readUTF8(filename, diagnostics).trim();
5025
5994
  if (source.length === 0) return null;
5026
5995
  return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
5027
5996
  }
@@ -5030,20 +5999,22 @@ function loadModuleFont(filename) {
5030
5999
  const family = baseNameWithoutExt(filename);
5031
6000
  const idSlug = `style-module-${slugify(family)}`;
5032
6001
  const ext = normalizedFileExt(filename);
5033
- 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>`;
6002
+ const mediaType = mediaTypeFromExt(ext);
6003
+ const hint = fontFormatHint(ext);
6004
+ 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>`;
5034
6005
  }
5035
6006
  /**
5036
- * Load the module tags and head file content to inject before the closing head tag, joined by newlines,
6007
+ * Load the module tags and head file content to inject into the head (see `placeHead()`), joined by newlines,
5037
6008
  * or `''` when there is nothing to inject.
5038
6009
  */
5039
6010
  function loadHeadContent(modulePaths, headFile, diagnostics) {
5040
6011
  const parts = [];
5041
6012
  if (modulePaths.length > 0) {
5042
- const modules = loadModules(modulePaths).trim();
6013
+ const modules = loadModules(modulePaths, diagnostics).trim();
5043
6014
  if (modules.length > 0) parts.push(modules);
5044
6015
  }
5045
6016
  if (headFile) try {
5046
- const source = readUTF8(headFile).trim();
6017
+ const source = readUTF8(headFile, diagnostics).trim();
5047
6018
  if (source.length > 0) parts.push(source);
5048
6019
  } catch (e) {
5049
6020
  diagnostics?.push({
@@ -5054,16 +6025,51 @@ function loadHeadContent(modulePaths, headFile, diagnostics) {
5054
6025
  return parts.join("\n");
5055
6026
  }
5056
6027
  /**
5057
- * The template slot that inserts `content` on its own line before the first closing head tag (as Tweego does),
5058
- * or `undefined` when `content` is empty. The tag may use any letter case and whitespace before its `>`, and is
5059
- * kept as written.
6028
+ * Place `content` in a template, given the texts that `fillTemplateParts()` scans for the slot (the template, and
6029
+ * the footer of a pre-1.4 Twine 1 format):
6030
+ *
6031
+ * - on its own line before the first closing head tag, as Tweego does; the tag may use any letter case and
6032
+ * whitespace before its `>`, and is kept as written;
6033
+ * - in a template with no closing head tag (HTML lets a document leave it out), on its own line before the first
6034
+ * body start tag, where the HTML parser still puts it in the head, with a warning (Tweego drops it);
6035
+ * - in a template with neither, nowhere, with a warning.
6036
+ *
6037
+ * `owner` names the template in the warnings. Empty `content` needs no slot and gets no warning.
5060
6038
  */
5061
- function headSlot(content) {
5062
- if (content.length === 0) return void 0;
6039
+ function placeHead(content, texts, owner) {
6040
+ if (content.length === 0) return {
6041
+ slot: void 0,
6042
+ diagnostics: []
6043
+ };
6044
+ const found = (tag) => texts.some((text) => new RegExp(tag).test(text));
6045
+ if (found("<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>")) return {
6046
+ slot: beforeTag(CLOSING_HEAD_TAG, content),
6047
+ diagnostics: []
6048
+ };
6049
+ if (found("<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])")) {
6050
+ const message = `${owner} has no closing head tag; the modules and head file were injected before its body start tag.`;
6051
+ return {
6052
+ slot: beforeTag(BODY_START_TAG, content),
6053
+ diagnostics: [{
6054
+ level: "warning",
6055
+ message
6056
+ }]
6057
+ };
6058
+ }
5063
6059
  return {
5064
- pattern: CLOSING_HEAD_TAG,
6060
+ slot: void 0,
6061
+ diagnostics: [{
6062
+ level: "warning",
6063
+ message: `${owner} has no closing head tag and no body start tag; the modules and head file were not injected.`
6064
+ }]
6065
+ };
6066
+ }
6067
+ /** The slot that inserts `content` on its own line before the first match of `tag`, which is kept as written. */
6068
+ function beforeTag(tag, content) {
6069
+ return {
6070
+ pattern: tag,
5065
6071
  occurrences: "first",
5066
- replacement: (tag) => `${content}\n${tag}`
6072
+ replacement: (match) => `${content}\n${match}`
5067
6073
  };
5068
6074
  }
5069
6075
  //#endregion
@@ -5102,14 +6108,19 @@ function toTwine2Archive(story, startName, options) {
5102
6108
  }
5103
6109
  /**
5104
6110
  * Fill the Twine 2 format template: every `{{STORY_NAME}}` gets the escaped story name and the first
5105
- * `{{STORY_DATA}}` the story data (as Tweego does), and `head` goes before the first closing head tag. All three are
5106
- * found in the template in one pass, so a story name, passage text or head content holding a placeholder or a
5107
- * closing head tag stays literal.
6111
+ * `{{STORY_DATA}}` the story data (as Tweego does), and `head` goes before the first closing head tag (or, with a
6112
+ * warning, the body start tag; see `placeHead()`). All three are found in the template in one pass, so a story
6113
+ * name, passage text or head content holding a placeholder or a closing head tag stays literal.
6114
+ * `options.diagnostics` receives a warning when the format file is not valid UTF-8, and any warning about where
6115
+ * `head` went.
5108
6116
  */
5109
6117
  function toTwine2HTML(story, format, startName, options) {
5110
6118
  const name = htmlEscape(story.name);
5111
- const head = headSlot(options?.head ?? "");
5112
- const slots = [
6119
+ const template = readFormatSource(format, options?.diagnostics);
6120
+ const placement = placeHead(options?.head ?? "", [template], `Story format "${format.name}" ${format.version}`);
6121
+ options?.diagnostics?.push(...placement.diagnostics);
6122
+ const head = placement.slot;
6123
+ return fillTemplate(template, [
5113
6124
  {
5114
6125
  pattern: literal("{{STORY_NAME}}"),
5115
6126
  occurrences: "all",
@@ -5130,8 +6141,7 @@ function toTwine2HTML(story, format, startName, options) {
5130
6141
  }
5131
6142
  },
5132
6143
  ...head === void 0 ? [] : [head]
5133
- ];
5134
- return fillTemplate(readFormatSource(format), slots);
6144
+ ]);
5135
6145
  }
5136
6146
  const OMITTING_TAGS = [
5137
6147
  "Twine.private",
@@ -5227,29 +6237,35 @@ function toTwine1Archive(story, _startName) {
5227
6237
  /**
5228
6238
  * Fill the Twine 1 format template. The format's components are inserted first, one after another, as Tweego and
5229
6239
  * Twine 1 do. Then the first of each story placeholder (`"VERSION"`, `"TIME"`, `"START_AT"`, `"STORY_SIZE"`,
5230
- * `"STORY"`), the IFID comment and `head` (before the first closing head tag) are found in one pass, so a start
5231
- * passage name or story data holding a placeholder or a closing head tag stays literal.
6240
+ * `"STORY"`), the IFID comment and `head` (before the first closing head tag or, with a warning, the body start
6241
+ * tag; see `placeHead()`) are found in one pass, so a start passage name or story data holding a placeholder or a
6242
+ * closing head tag stays literal. `options.diagnostics` receives a warning for each format file that is not valid
6243
+ * UTF-8, and any warning about where `head` went.
5232
6244
  */
5233
6245
  function toTwine1HTML(story, format, startName, options) {
5234
6246
  const formatDir = dirname(format.filename);
5235
6247
  const parentDir = dirname(formatDir);
5236
- let template = readFormatSource(format);
6248
+ const diagnostics = options?.diagnostics;
6249
+ let template = readFormatSource(format, diagnostics);
5237
6250
  const { data, count } = getTwine1PassageChunk(story);
5238
- template = tryReplaceComponent(template, "\"USER_LIB\"", join(formatDir, "userlib.js"), false);
5239
- template = tryReplaceComponent(template, "\"ENGINE\"", join(parentDir, "engine.js"), true);
5240
- template = tryReplaceComponent(template, "\"SUGARCANE\"", join(formatDir, "code.js"), true);
5241
- template = tryReplaceComponent(template, "\"JONAH\"", join(formatDir, "code.js"), true);
5242
- if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", join(parentDir, "jquery.js"), true);
5243
- if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", join(parentDir, "modernizr.js"), true);
6251
+ template = tryReplaceComponent(template, "\"USER_LIB\"", join(formatDir, "userlib.js"), false, diagnostics);
6252
+ template = tryReplaceComponent(template, "\"ENGINE\"", join(parentDir, "engine.js"), true, diagnostics);
6253
+ template = tryReplaceComponent(template, "\"SUGARCANE\"", join(formatDir, "code.js"), true, diagnostics);
6254
+ template = tryReplaceComponent(template, "\"JONAH\"", join(formatDir, "code.js"), true, diagnostics);
6255
+ if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", join(parentDir, "jquery.js"), true, diagnostics);
6256
+ if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", join(parentDir, "modernizr.js"), true, diagnostics);
5244
6257
  const isPre14 = !template.includes("\"STORY\"");
5245
- const footer = isPre14 ? readFooter(formatDir) : "";
6258
+ const footer = isPre14 ? readFooter(formatDir, diagnostics) : "";
5246
6259
  const storeArea = (template + footer).includes("<div id=\"store-area\"") ? "<div id=\"store-area\"" : "<div id=\"storeArea\"";
5247
6260
  const safeIfid = htmlCommentSanitize(story.ifid);
5248
- const late = [story.ifid ? {
6261
+ const ifid = story.ifid ? {
5249
6262
  pattern: literal(storeArea),
5250
6263
  occurrences: "first",
5251
6264
  replacement: (div) => `<!-- UUID://${safeIfid}// -->${div}`
5252
- } : void 0, headSlot(options?.head ?? "")].filter((slot) => slot !== void 0);
6265
+ } : void 0;
6266
+ const placement = placeHead(options?.head ?? "", isPre14 ? [template, footer] : [template], `Story format "${format.name}"`);
6267
+ diagnostics?.push(...placement.diagnostics);
6268
+ const late = [ifid, placement.slot].filter((slot) => slot !== void 0);
5253
6269
  const displayStart = startName === "Start" ? "" : startName;
5254
6270
  const slots = [
5255
6271
  firstSlot("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`),
@@ -5289,9 +6305,9 @@ function firstSlot(token, value) {
5289
6305
  };
5290
6306
  }
5291
6307
  /** The footer of a pre-1.4 format, or the default one when the format has none. */
5292
- function readFooter(formatDir) {
6308
+ function readFooter(formatDir, diagnostics) {
5293
6309
  try {
5294
- return readFileSync(join(formatDir, "footer.html"), "utf-8");
6310
+ return readUTF8(join(formatDir, "footer.html"), diagnostics);
5295
6311
  } catch {
5296
6312
  return "</div>\n</body>\n</html>\n";
5297
6313
  }
@@ -5319,11 +6335,10 @@ function getTwine1PassageChunk(story) {
5319
6335
  count
5320
6336
  };
5321
6337
  }
5322
- function tryReplaceComponent(template, placeholder, componentPath, required) {
6338
+ function tryReplaceComponent(template, placeholder, componentPath, required, diagnostics) {
5323
6339
  if (!template.includes(placeholder)) return template;
5324
6340
  try {
5325
- let content = readFileSync(componentPath, "utf-8");
5326
- if (content.charCodeAt(0) === 65279) content = content.slice(1);
6341
+ const content = readUTF8(componentPath, diagnostics);
5327
6342
  return template.replace(placeholder, () => content);
5328
6343
  } catch (e) {
5329
6344
  if (required) throw new Error(`Required format component not found: ${componentPath}: ${e instanceof Error ? e.message : String(e)}`);
@@ -5337,11 +6352,16 @@ function tryReplaceComponent(template, placeholder, componentPath, required) {
5337
6352
  * from its loaded text, so changes made after loading (a start passage override, test mode) are kept.
5338
6353
  * Tweego writes the loaded text; its own normalization makes the two the same for an unchanged story.
5339
6354
  *
6355
+ * Twee cannot write every passage so that it reads back the same (see `tweeRoundTrip()`); `diagnostics`
6356
+ * receives a warning for each passage that will not. Tweego writes the same Twee without one.
6357
+ *
5340
6358
  * `addStoryData`: add a StoryData passage when the story has none, so that StoryData fields the
5341
6359
  * compile options set are recorded. Default: false.
5342
6360
  */
5343
6361
  function toTwee(story, outMode, options = {}) {
5344
- return withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => passageToTwee(p, outMode)).join("");
6362
+ const written = withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => tweeRoundTrip(p, outMode));
6363
+ options.diagnostics?.push(...written.flatMap(({ diagnostics }) => diagnostics));
6364
+ return written.map(({ passage }) => passageToTwee(passage, outMode)).join("");
5345
6365
  }
5346
6366
  function withEffectiveStoryData(story, addStoryData) {
5347
6367
  const storyData = {
@@ -5366,6 +6386,64 @@ function withEffectiveStoryData(story, addStoryData) {
5366
6386
  function isStoryDataJSON(text) {
5367
6387
  return unmarshalStoryData(createStory(), text) === null;
5368
6388
  }
6389
+ /** Characters Twee 3 escapes in names and tags, and Twee 1 cannot write there at all. */
6390
+ const TWEE1_UNWRITABLE = /[[\]{}\\]/;
6391
+ /**
6392
+ * Check that Twee reads `p` back as written, and say what it will not. Twee has no way to escape passage text,
6393
+ * and its parser trims names and splits tags at whitespace, so these do not survive:
6394
+ *
6395
+ * - a text line that starts with `::`, which reads as a new passage header. In stylesheet and script passages such
6396
+ * a line is indented by one space, which keeps the CSS or JavaScript working (only a template literal or a
6397
+ * string continued across lines, in either, would see the space); the warning says so. Other passages' text is
6398
+ * written as it is.
6399
+ * - a name that is empty, has leading or trailing whitespace, or holds a line break;
6400
+ * - a tag that is empty or holds whitespace;
6401
+ * - in Twee 1, which escapes nothing, a name or tag holding `[`, `]`, `{`, `}` or `\`.
6402
+ */
6403
+ function tweeRoundTrip(p, outMode) {
6404
+ const label = `Passage ${JSON.stringify(p.name)}`;
6405
+ const diagnostics = [];
6406
+ const warn = (problem) => {
6407
+ diagnostics.push({
6408
+ level: "warning",
6409
+ message: `${label} cannot be written as Twee that reads back the same: ${problem}.`
6410
+ });
6411
+ };
6412
+ if (p.name.trim() === "") warn("its name is empty");
6413
+ else if (p.name.trim() !== p.name) warn("its name has leading or trailing whitespace, which Twee drops");
6414
+ if (/[\r\n]/.test(p.name)) warn("its name has a line break, which ends a Twee passage header");
6415
+ if (outMode === "twee1" && TWEE1_UNWRITABLE.test(p.name)) warn("its name has \"[\", \"]\", \"{\", \"}\" or \"\\\", which Twee 1 cannot escape");
6416
+ for (const tag of p.tags) {
6417
+ if (tag === "" || /\s/.test(tag)) warn(`its tag ${JSON.stringify(tag)} is empty or has whitespace, which splits tags in Twee`);
6418
+ if (outMode === "twee1" && TWEE1_UNWRITABLE.test(tag)) warn(`its tag ${JSON.stringify(tag)} has "[", "]", "{", "}" or "\\", which Twee 1 cannot escape`);
6419
+ }
6420
+ const lines = p.text.split(/(\r\n?|\n)/);
6421
+ const headerLines = lines.flatMap((line, i) => i % 2 === 0 && line.startsWith("::") ? [i / 2 + 1] : []);
6422
+ if (headerLines.length === 0) return {
6423
+ passage: p,
6424
+ diagnostics
6425
+ };
6426
+ 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`;
6427
+ if (!p.tags.some((tag) => tag === "stylesheet" || tag === "script")) {
6428
+ warn(where);
6429
+ return {
6430
+ passage: p,
6431
+ diagnostics
6432
+ };
6433
+ }
6434
+ diagnostics.push({
6435
+ level: "warning",
6436
+ message: `${label}: ${where}; ${headerLines.length === 1 ? "it was" : "they were"} written indented by one space.`
6437
+ });
6438
+ const text = lines.map((line, i) => i % 2 === 0 && line.startsWith("::") ? ` ${line}` : line).join("");
6439
+ return {
6440
+ passage: {
6441
+ ...p,
6442
+ text
6443
+ },
6444
+ diagnostics
6445
+ };
6446
+ }
5369
6447
  //#endregion
5370
6448
  //#region src/passage-omission.ts
5371
6449
  /**
@@ -5421,22 +6499,28 @@ var TweeTsError = class extends Error {
5421
6499
  }
5422
6500
  };
5423
6501
  /**
5424
- * Builds as compileToFile() does for `outFile`, without writing it: the output may sit
6502
+ * Builds as compileToFile() does for `outputs`, without writing them: an output may sit
5425
6503
  * inside a source folder, so its last build is left out of the sources and modules and
5426
- * never read back. The caller decides whether the result is written. With no `outFile`
5427
- * (output to stdout, or none), this is compile(). With `cache`, files are cached as
5428
- * compileIncremental() caches them.
6504
+ * never read back. `outputs` is the one file the CLI writes, or every path a bundler's
6505
+ * build writes (see BuildOutputs). The caller decides whether the result is written.
6506
+ * With no outputs (output to stdout, or none), this is compile(). With `cache`, files
6507
+ * are cached as compileIncremental() caches them.
6508
+ *
6509
+ * Throws a TweeTsError when a source, module or head file named directly is an output.
5429
6510
  *
5430
6511
  * Internal, for the CLI and the bundler plugins; not part of the public API.
5431
6512
  */
5432
- async function compileForOutputFile(options, outFile, cache) {
5433
- return buildOutput(options, cache, void 0, outFile);
6513
+ async function compileForOutputFile(options, outputs, cache) {
6514
+ return buildOutput(options, cache, void 0, outputs);
5434
6515
  }
5435
6516
  /**
5436
- * `outFile`: the file the build is written to, which source and module discovery
5437
- * skip (as Tweego does), so an output inside a source folder is never loaded back.
6517
+ * `outputs`: what the build writes (an output file's path, or a bundler's outputs),
6518
+ * which source and module discovery skip, so an output inside a source folder is
6519
+ * never loaded back. A source, module or head file named directly that is an output
6520
+ * is a TweeTsError, as in Tweego: writing the build would overwrite it.
5438
6521
  */
5439
- async function buildOutput(options, cache, changedFiles, outFile) {
6522
+ async function buildOutput(options, cache, changedFiles, outputs) {
6523
+ const written = toBuildOutputs(outputs);
5440
6524
  const diagnostics = [];
5441
6525
  const outputMode = options.outputMode ?? "html";
5442
6526
  const trim = options.trim ?? true;
@@ -5444,13 +6528,18 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5444
6528
  const testMode = options.testMode ?? false;
5445
6529
  const noRemote = options.noRemote ?? false;
5446
6530
  const sourceInfo = options.sourceInfo ?? false;
6531
+ options.signal?.throwIfAborted();
6532
+ const timeout = options.formatFetchTimeout;
6533
+ if (timeout !== void 0 && !(timeout >= 0)) throw new TweeTsError(`formatFetchTimeout must be 0 or more milliseconds, not ${timeout}.`);
5447
6534
  clearIndexCache();
5448
6535
  const filePaths = [];
5449
6536
  const inlineSources = [];
5450
6537
  for (const source of options.sources) if (typeof source === "string") filePaths.push(source);
5451
6538
  else inlineSources.push(source);
5452
- const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths, outFile, options.exclude);
5453
- diagnostics.push(...sourcePathDiagnostics);
6539
+ const sourcePaths = getFilenames(filePaths, written, options.exclude);
6540
+ diagnostics.push(...sourcePaths.diagnostics);
6541
+ rejectOutputSources(sourcePaths.outputSources, diagnostics);
6542
+ const sourceFilenames = sourcePaths.filenames;
5454
6543
  const story = createStory();
5455
6544
  const processedFiles = /* @__PURE__ */ new Set();
5456
6545
  if (cache) loadSourcesCached(story, sourceFilenames, {
@@ -5479,7 +6568,10 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5479
6568
  switch (outputMode) {
5480
6569
  case "twee3":
5481
6570
  case "twee1":
5482
- output = toTwee(story, outputMode, { addStoryData: Boolean(options.startPassage) || testMode });
6571
+ output = toTwee(story, outputMode, {
6572
+ addStoryData: Boolean(options.startPassage) || testMode,
6573
+ diagnostics
6574
+ });
5483
6575
  break;
5484
6576
  case "twine2-archive":
5485
6577
  output = toTwine2Archive(story, startName, { sourceInfo });
@@ -5497,13 +6589,20 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5497
6589
  level: "error",
5498
6590
  message: "Special passage \"StoryTitle\" not found."
5499
6591
  });
5500
- const modules = getFilenames(options.modules ?? [], outFile);
6592
+ const modules = getFilenames(options.modules ?? [], written);
5501
6593
  diagnostics.push(...modules.diagnostics);
6594
+ const { headFile } = options;
6595
+ const headIsOutput = headFile !== void 0 && outputPaths(written).isFile(realPathOf(headFile));
6596
+ rejectOutputSources([...modules.outputSources, ...headIsOutput ? [headFile] : []], diagnostics);
5502
6597
  const head = loadHeadContent(modules.filenames, options.headFile, diagnostics);
5503
6598
  output = format.isTwine2 ? toTwine2HTML(story, format, startName, {
5504
6599
  sourceInfo,
5505
- head
5506
- }) : toTwine1HTML(story, format, startName, { head });
6600
+ head,
6601
+ diagnostics
6602
+ }) : toTwine1HTML(story, format, startName, {
6603
+ head,
6604
+ diagnostics
6605
+ });
5507
6606
  break;
5508
6607
  }
5509
6608
  default: throw new TweeTsError(`Unhandled output mode: ${outputMode}`, diagnostics);
@@ -5520,6 +6619,16 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5520
6619
  stats
5521
6620
  };
5522
6621
  }
6622
+ /**
6623
+ * Throws for the first of `paths`, inputs named directly that are also an output:
6624
+ * writing the build would overwrite them. An output found while walking a source
6625
+ * folder is skipped instead (see getFilenames).
6626
+ */
6627
+ function rejectOutputSources(paths, diagnostics) {
6628
+ const [first] = paths;
6629
+ if (first === void 0) return;
6630
+ throw new TweeTsError(`path ${first}: Output file cannot be an input source.`, [...diagnostics]);
6631
+ }
5523
6632
  function ensureIFID(story, diagnostics) {
5524
6633
  if (story.ifid !== "") return;
5525
6634
  if (story.legacyIFID !== "") {
@@ -5612,7 +6721,8 @@ function fatalError(e) {
5612
6721
  /**
5613
6722
  * Path helpers for the bundler plugins. Vite reports module ids with forward
5614
6723
  * slashes on every platform, while node:path gives backslashes on Windows, so
5615
- * the Vite plugin compares paths in forward-slash form only.
6724
+ * the Vite plugin compares paths in forward-slash form only. Paths a build
6725
+ * writes are compared by real path instead (see filesystem.ts).
5616
6726
  */
5617
6727
  /**
5618
6728
  * The absolute path a bundle writes the file it emits as `fileName` to: inside
@@ -5624,6 +6734,45 @@ function emittedFilePath(output, fileName) {
5624
6734
  const dir = output.dir ?? (output.file === void 0 ? void 0 : dirname(output.file));
5625
6735
  return dir === void 0 ? void 0 : resolve(dir, fileName);
5626
6736
  }
6737
+ /**
6738
+ * The locations a bundler's `output` option names: one output's settings or an
6739
+ * array of them, as a config gives them. Outputs that name neither `dir` nor
6740
+ * `file`, and anything that isn't an output's settings, are left out.
6741
+ */
6742
+ function outputLocations(option) {
6743
+ return (Array.isArray(option) ? option : [option]).flatMap((output) => {
6744
+ if (typeof output !== "object" || output === null) return [];
6745
+ const { dir, file } = output;
6746
+ const location = {
6747
+ ...typeof dir === "string" ? { dir } : {},
6748
+ ...typeof file === "string" ? { file } : {}
6749
+ };
6750
+ return location.dir === void 0 && location.file === void 0 ? [] : [location];
6751
+ });
6752
+ }
6753
+ /** An empty OutputRecord for a plugin that writes its story as `storyFileName`. */
6754
+ function createOutputRecord(storyFileName) {
6755
+ const files = /* @__PURE__ */ new Set();
6756
+ const dirs = /* @__PURE__ */ new Set();
6757
+ const addFiles = (output, fileNames) => {
6758
+ for (const fileName of fileNames) {
6759
+ const path = emittedFilePath(output, fileName);
6760
+ if (path !== void 0) files.add(path);
6761
+ }
6762
+ };
6763
+ return {
6764
+ addLocation(output) {
6765
+ if (output.dir !== void 0) dirs.add(resolve(output.dir));
6766
+ if (output.file !== void 0) files.add(resolve(output.file));
6767
+ addFiles(output, [storyFileName]);
6768
+ },
6769
+ addFiles,
6770
+ outputs: () => ({
6771
+ files: [...files],
6772
+ dirs: [...dirs]
6773
+ })
6774
+ };
6775
+ }
5627
6776
  /** The path with Windows separators turned into forward slashes. */
5628
6777
  function toPosix(path) {
5629
6778
  return path.replace(/\\/g, "/");
@@ -5641,6 +6790,6 @@ function isViteConfigTemp(file) {
5641
6790
  return /\.timestamp-\d+-[0-9a-f]*\.mjs$/.test(file);
5642
6791
  }
5643
6792
  //#endregion
5644
- export { fatalError as a, compileForOutputFile as c, mediaTypeFromFilename as d, toPosix as i, getFilenames as l, isInside as n, formatDiagnostic as o, isViteConfigTemp as r, splitDiagnostics as s, emittedFilePath as t, isExcluded as u };
6793
+ export { toPosix as a, splitDiagnostics as c, isExcluded as d, outputPaths as f, mediaTypeFromFilename as h, outputLocations as i, compileForOutputFile as l, walkedEntry as m, isInside as n, fatalError as o, realPathOf as p, isViteConfigTemp as r, formatDiagnostic as s, createOutputRecord as t, getFilenames as u };
5645
6794
 
5646
- //# sourceMappingURL=paths-w4ulhCFb.js.map
6795
+ //# sourceMappingURL=paths-DgO9oo6G.js.map