@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.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
- import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, watch as watch$1, writeFileSync } from "node:fs";
2
1
  import * as nodePath from "node:path";
3
2
  import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
4
- import { randomUUID } from "node:crypto";
3
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
4
+ import { accessSync, chmodSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, watch as watch$1, writeFileSync } from "node:fs";
5
5
  import { homedir } from "node:os";
6
6
  import { fileURLToPath } from "node:url";
7
7
  //#region src/ifid.ts
@@ -113,18 +113,44 @@ function tweeEscape(s) {
113
113
  if (s.length === 0) return s;
114
114
  return s.replace(/[\\[\]{}]/g, (ch) => "\\" + ch);
115
115
  }
116
- /** Escape a string for safe inclusion in a JavaScript string literal. */
116
+ const JS_STRING_ESCAPES = {
117
+ "\\": "\\\\",
118
+ "\"": "\\\"",
119
+ "'": "\\'",
120
+ "\n": "\\n",
121
+ "\r": "\\r",
122
+ " ": "\\t",
123
+ "<": "\\x3C",
124
+ "\u2028": "\\u2028",
125
+ "\u2029": "\\u2029"
126
+ };
127
+ /**
128
+ * Escape a string for the inside of a single- or double-quoted JavaScript string literal, including one written
129
+ * into an HTML `<script>` element. Besides backslashes, quotes and line breaks (with U+2028 and U+2029, which end
130
+ * a string literal in JavaScript before ES2019), every `<` becomes `\x3C`, so the literal holds no `<\/script`
131
+ * that would end the element and no `<!--` or `<script` that would change where it ends. The string's value is
132
+ * unchanged.
133
+ */
117
134
  function jsStringEscape(s) {
118
135
  if (s.length === 0) return s;
119
- return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
136
+ return s.replace(/[\\"'\n\r\t<\u2028\u2029]/g, (ch) => JS_STRING_ESCAPES[ch] ?? ch);
137
+ }
138
+ /**
139
+ * Escape a string for the inside of a double-quoted CSS string (such as a `font-family` name): `\` and `"` get
140
+ * a backslash, and line breaks and other control characters become hex escapes followed by a space (`\a ` for a
141
+ * line feed), since a raw line break ends a CSS string and a backslash before one is a line continuation.
142
+ */
143
+ function cssStringEscape(s) {
144
+ if (s.length === 0) return s;
145
+ return s.replace(/[\\"\u0000-\u001f\u007f]/g, (ch) => ch === "\\" || ch === "\"" ? `\\${ch}` : `\\${ch.charCodeAt(0).toString(16)} `);
120
146
  }
121
147
  /** Sanitize a string for safe inclusion in a CSS or JS block comment. */
122
148
  function commentSanitize(s) {
123
149
  return s.replace(/\*\//g, "* /");
124
150
  }
125
- /** Sanitize a string for safe inclusion in an HTML comment. */
151
+ /** Sanitize a string for safe inclusion in an HTML comment: break up `-->` and `--!>`, which both end one. */
126
152
  function htmlCommentSanitize(s) {
127
- return s.replace(/-->/g, "-- >");
153
+ return s.replace(/--(!?)>/g, "--$1 >");
128
154
  }
129
155
  /**
130
156
  * Escape JavaScript for the content of an HTML `<script>` element, so that the HTML parser reads all of it as
@@ -304,7 +330,19 @@ function passageToPassagedata(p, pid, options) {
304
330
  if (options?.sourceInfo && p.source) attrs += ` data-source-file=${quote(attrEscape(p.source.file))} data-source-line="${p.source.line}"`;
305
331
  return `${attrs}>${htmlEscape(p.text)}</tw-passagedata>`;
306
332
  }
307
- /** Generate `<div tiddler>` HTML for Twine 1. */
333
+ /**
334
+ * Whether Twine 1 `obfuscate:rot13` encodes a tiddler: every one but `StorySettings` and those tagged `Twine.image`
335
+ * (Twine 1.4 `Tiddler.isObfuscateable()`). Its engine tests the stored name and tags, which these tiddlers keep
336
+ * unencoded, so the decompiler tests them the same way.
337
+ */
338
+ function isObfuscatable(p) {
339
+ return p.name !== "StorySettings" && !p.tags.includes("Twine.image");
340
+ }
341
+ /**
342
+ * Generate `<div tiddler>` HTML for Twine 1. With `obfuscateRot13`, an obfuscatable tiddler (see
343
+ * `isObfuscatable()`) has its name, each tag and its text ROT13-encoded, as Twine 1.4 writes it
344
+ * (`Tiddler.toHtml()`), and as its engine.js decodes it.
345
+ */
308
346
  function passageToTiddler(p, pid, obfuscateRot13 = false) {
309
347
  let position;
310
348
  if (hasMetadataPosition(p)) position = p.metadata?.position ?? "";
@@ -316,9 +354,10 @@ function passageToTiddler(p, pid, obfuscateRot13 = false) {
316
354
  position = `${xp * 140 - 130},${yp * 140 - 130}`;
317
355
  }
318
356
  const created = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 12);
319
- const content = obfuscateRot13 && p.name !== "StorySettings" ? tiddlerEscape(rot13(p.text)) : tiddlerEscape(p.text);
320
- const nameComment = obfuscateRot13 ? `<!-- ${commentSanitize(p.name)} -->` : "";
321
- 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>`;
357
+ const encode = obfuscateRot13 && isObfuscatable(p) ? rot13 : (s) => s;
358
+ const name = attrEscape(encode(p.name));
359
+ const tags = attrEscape(p.tags.map(encode).join(" "));
360
+ return `<div tiddler=${quote(name)} tags=${quote(tags)} created=${quote(created)} modifier=${quote("twee")} twine-position=${quote(attrEscape(position))}>${tiddlerEscape(encode(p.text))}</div>`;
322
361
  }
323
362
  /**
324
363
  * Count words in a passage.
@@ -371,19 +410,136 @@ function quote(s) {
371
410
  }
372
411
  //#endregion
373
412
  //#region src/story.ts
374
- /** Module-level index: maps passage names to their array indices for O(1) lookups. */
375
413
  const passageIndex = /* @__PURE__ */ new WeakMap();
376
- function getIndex(story) {
377
- let index = passageIndex.get(story);
378
- if (!index) {
379
- index = /* @__PURE__ */ new Map();
380
- for (let i = 0; i < story.passages.length; i++) {
381
- const passage = story.passages[i];
382
- if (passage) index.set(passage.name, i);
414
+ function buildIndex(story) {
415
+ const names = story.passages.map((p) => p.name);
416
+ const index = {
417
+ passages: story.passages,
418
+ names,
419
+ positions: new Map(names.map((name, i) => [name, i]))
420
+ };
421
+ passageIndex.set(story, index);
422
+ return index;
423
+ }
424
+ /**
425
+ * The index, rebuilt if `story.passages` was reassigned or changed length. O(1); `position()`
426
+ * also checks every hit. A change that keeps the length, such as replacing an element, can still
427
+ * hide a passage from a lookup, so `storyVerifyIndex()` checks every position.
428
+ */
429
+ function currentIndex(story) {
430
+ const index = passageIndex.get(story);
431
+ return index !== void 0 && index.passages === story.passages && index.names.length === story.passages.length ? index : buildIndex(story);
432
+ }
433
+ /**
434
+ * Rebuild the index unless it matches `story.passages` at every position. O(n): for entry points
435
+ * that run after code outside the compiler may have changed the passages, such as StoryBuilder.
436
+ */
437
+ function storyVerifyIndex(story) {
438
+ const index = currentIndex(story);
439
+ if (!story.passages.every((p, i) => p.name === index.names[i])) buildIndex(story);
440
+ }
441
+ /** The position of the passage named `name`, or -1. */
442
+ function position(story, name) {
443
+ const i = currentIndex(story).positions.get(name);
444
+ if (i === void 0) return -1;
445
+ if (story.passages[i]?.name === name) return i;
446
+ return buildIndex(story).positions.get(name) ?? -1;
447
+ }
448
+ /** Add a passage whose name no passage has yet at the end. */
449
+ function push(story, p) {
450
+ const index = currentIndex(story);
451
+ index.positions.set(p.name, story.passages.length);
452
+ index.names.push(p.name);
453
+ story.passages.push(p);
454
+ }
455
+ /** Put a passage at position `i`, in place of the passage there. */
456
+ function replaceAt(story, i, p) {
457
+ const renamed = story.passages[i]?.name !== p.name;
458
+ story.passages[i] = p;
459
+ if (renamed) buildIndex(story);
460
+ }
461
+ /** Names of compiler special passages, which storyAdd() reads into the story model. */
462
+ const SPECIAL_PASSAGE_NAMES = /* @__PURE__ */ new Set([
463
+ "StoryData",
464
+ "StoryIncludes",
465
+ "StorySettings",
466
+ "StoryTitle"
467
+ ]);
468
+ /** Passages whose names were generated, kept by identity so that cached passages keep the mark. */
469
+ const generatedNames = /* @__PURE__ */ new WeakMap();
470
+ /**
471
+ * Mark a passage's name as generated (from a file name, or for imported story code) and return
472
+ * the passage. `origin.base` is the name that free names are numbered from: `base`, `base 2`, ….
473
+ */
474
+ function withGeneratedName(p, origin) {
475
+ generatedNames.set(p, origin);
476
+ return p;
477
+ }
478
+ /** The first of `base`, `base 2`, `base 3`, … that `isFree` accepts. */
479
+ function freeName(base, isFree) {
480
+ if (isFree(base)) return base;
481
+ for (let n = 2;; n++) {
482
+ const candidate = `${base} ${n}`;
483
+ if (isFree(candidate)) return candidate;
484
+ }
485
+ }
486
+ /** Whether a generated name may take `name`: no passage has it and it is no special name. */
487
+ function isFreeForGenerated(story, name) {
488
+ return !SPECIAL_PASSAGE_NAMES.has(name) && position(story, name) === -1;
489
+ }
490
+ function reportRename(origin, from, to, diagnostics) {
491
+ switch (origin.kind) {
492
+ case "code": return;
493
+ case "media": {
494
+ const reason = SPECIAL_PASSAGE_NAMES.has(from) ? `"${from}" is a compiler special passage name` : `another passage has the name "${from}"`;
495
+ diagnostics.push({
496
+ level: "warning",
497
+ message: `Passage "${from}" from "${origin.file}" renamed to "${to}"; ${reason}.`
498
+ });
499
+ return;
383
500
  }
384
- passageIndex.set(story, index);
501
+ default: throw new Error(`unhandled generated name: ${JSON.stringify(origin)}`);
385
502
  }
386
- return index;
503
+ }
504
+ /** Add a passage with a generated name, under the first free name if its own is taken. */
505
+ function addGenerated(story, p, origin, diagnostics) {
506
+ if (isFreeForGenerated(story, p.name)) {
507
+ push(story, p);
508
+ return;
509
+ }
510
+ const name = freeName(origin.base, (n) => isFreeForGenerated(story, n));
511
+ reportRename(origin, p.name, name, diagnostics);
512
+ push(story, withGeneratedName({
513
+ ...p,
514
+ name
515
+ }, origin));
516
+ }
517
+ /**
518
+ * If a passage with a generated name has `name`, give it the first free name, in its place in the
519
+ * passage list, so that a passage from the sources can take the name.
520
+ */
521
+ function moveGeneratedAside(story, name, diagnostics) {
522
+ const i = position(story, name);
523
+ const existing = i === -1 ? void 0 : story.passages[i];
524
+ const origin = existing === void 0 ? void 0 : generatedNames.get(existing);
525
+ if (existing === void 0 || origin === void 0) return;
526
+ const free = freeName(origin.base, (n) => isFreeForGenerated(story, n));
527
+ reportRename(origin, name, free, diagnostics);
528
+ replaceAt(story, i, withGeneratedName({
529
+ ...existing,
530
+ name: free
531
+ }, origin));
532
+ }
533
+ function defaultTwine2Metadata() {
534
+ return {
535
+ format: "",
536
+ formatVersion: "",
537
+ options: /* @__PURE__ */ new Map(),
538
+ start: "",
539
+ tags: "",
540
+ tagColors: /* @__PURE__ */ new Map(),
541
+ zoom: 1
542
+ };
387
543
  }
388
544
  function createStory() {
389
545
  const story = {
@@ -392,52 +548,37 @@ function createStory() {
392
548
  passages: [],
393
549
  legacyIFID: "",
394
550
  twine1: { settings: /* @__PURE__ */ new Map() },
395
- twine2: {
396
- format: "",
397
- formatVersion: "",
398
- options: /* @__PURE__ */ new Map(),
399
- start: "",
400
- tags: "",
401
- tagColors: /* @__PURE__ */ new Map(),
402
- zoom: 1
403
- }
551
+ twine2: defaultTwine2Metadata()
404
552
  };
405
- passageIndex.set(story, /* @__PURE__ */ new Map());
553
+ buildIndex(story);
406
554
  return story;
407
555
  }
408
556
  function storyHas(story, name) {
409
- return getIndex(story).has(name);
557
+ return position(story, name) !== -1;
410
558
  }
411
559
  function storyAppend(story, p, diagnostics) {
412
- const index = getIndex(story);
413
- const i = index.get(p.name) ?? -1;
414
- if (i === -1) {
415
- index.set(p.name, story.passages.length);
416
- story.passages.push(p);
417
- } else {
560
+ const i = position(story, p.name);
561
+ if (i === -1) push(story, p);
562
+ else {
418
563
  diagnostics.push({
419
564
  level: "warning",
420
565
  message: `Replacing existing passage "${p.name}" with duplicate.`
421
566
  });
422
- story.passages[i] = p;
567
+ replaceAt(story, i, p);
423
568
  }
424
569
  }
425
570
  function storyPrepend(story, p, diagnostics) {
426
- const index = getIndex(story);
427
- const i = index.get(p.name) ?? -1;
571
+ moveGeneratedAside(story, p.name, diagnostics);
572
+ const i = position(story, p.name);
428
573
  if (i === -1) {
429
574
  story.passages.unshift(p);
430
- index.clear();
431
- for (let j = 0; j < story.passages.length; j++) {
432
- const passage = story.passages[j];
433
- if (passage) index.set(passage.name, j);
434
- }
575
+ buildIndex(story);
435
576
  } else {
436
577
  diagnostics.push({
437
578
  level: "warning",
438
579
  message: `Replacing existing passage "${p.name}" with duplicate.`
439
580
  });
440
- story.passages[i] = p;
581
+ replaceAt(story, i, p);
441
582
  }
442
583
  }
443
584
  function marshalStoryData(story) {
@@ -463,18 +604,20 @@ function unmarshalStoryData(story, json) {
463
604
  }
464
605
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return "Cannot unmarshal \"StoryData\"; expected a JSON object";
465
606
  const data = raw;
466
- if (typeof data.ifid === "string" && data.ifid) story.ifid = normalizeIFID(data.ifid);
467
- if (typeof data.format === "string" && data.format) story.twine2.format = data.format;
468
- if (typeof data["format-version"] === "string" && data["format-version"]) story.twine2.formatVersion = data["format-version"];
607
+ const twine2 = defaultTwine2Metadata();
608
+ story.ifid = typeof data.ifid === "string" ? normalizeIFID(data.ifid) : "";
609
+ if (typeof data.format === "string") twine2.format = data.format;
610
+ if (typeof data["format-version"] === "string") twine2.formatVersion = data["format-version"];
469
611
  if (Array.isArray(data.options)) {
470
- for (const opt of data.options) if (typeof opt === "string") story.twine2.options.set(opt, true);
612
+ for (const opt of data.options) if (typeof opt === "string") twine2.options.set(opt, true);
471
613
  }
472
- if (typeof data.start === "string" && data.start) story.twine2.start = data.start;
473
- if (typeof data.tags === "string") story.twine2.tags = data.tags;
614
+ if (typeof data.start === "string") twine2.start = data.start;
615
+ if (typeof data.tags === "string") twine2.tags = data.tags;
474
616
  if (typeof data["tag-colors"] === "object" && data["tag-colors"] !== null && !Array.isArray(data["tag-colors"])) {
475
- for (const [tag, color] of Object.entries(data["tag-colors"])) if (typeof color === "string") story.twine2.tagColors.set(tag, color);
617
+ for (const [tag, color] of Object.entries(data["tag-colors"])) if (typeof color === "string") twine2.tagColors.set(tag, color);
476
618
  }
477
- if (typeof data.zoom === "number" && data.zoom !== 0) story.twine2.zoom = data.zoom;
619
+ if (typeof data.zoom === "number" && data.zoom !== 0) twine2.zoom = data.zoom;
620
+ story.twine2 = twine2;
478
621
  return null;
479
622
  }
480
623
  function unmarshalStorySettings(story, text, diagnostics) {
@@ -515,8 +658,20 @@ function unmarshalStorySettings(story, text, diagnostics) {
515
658
  /**
516
659
  * Process a passage and add it to the story, handling special passages.
517
660
  * Creates new passage objects where text is modified rather than mutating the input.
661
+ *
662
+ * A passage replaces an earlier one with the same name, except where either name was generated
663
+ * (see `withGeneratedName()`): a generated name never replaces a passage and is never replaced.
664
+ * A passage with a generated name takes the first free name of `base`, `base 2`, … when its own
665
+ * is taken or is a compiler special name. A passage with a name from the sources that a generated
666
+ * name holds takes it over, and the generated one moves to the first free name in its place.
518
667
  */
519
668
  function storyAdd(story, p, diagnostics) {
669
+ const origin = generatedNames.get(p);
670
+ if (origin !== void 0) {
671
+ addGenerated(story, p, origin, diagnostics);
672
+ return;
673
+ }
674
+ moveGeneratedAside(story, p.name, diagnostics);
520
675
  let processed = p;
521
676
  switch (p.name) {
522
677
  case "StoryIncludes":
@@ -576,7 +731,9 @@ function getStoryStats(story, wordCountMethod = "tweego") {
576
731
  }
577
732
  /**
578
733
  * Builder that separates the mutable construction phase from the immutable consumption phase.
579
- * During construction, the internal `Story` is mutable via `add()` and direct access.
734
+ * During construction, the internal `Story` is mutable via `add()` and direct access: you may
735
+ * push, splice, sort, rename or reassign `story.passages` between calls, and `add()` and `has()`
736
+ * see the passages as they are then.
580
737
  * After `build()`, the story is returned as `ReadonlyStory`.
581
738
  */
582
739
  var StoryBuilder = class {
@@ -587,10 +744,13 @@ var StoryBuilder = class {
587
744
  }
588
745
  /** Add a passage, handling special passages (StoryData, StoryTitle, etc.). */
589
746
  add(passage, diagnostics) {
747
+ if (generatedNames.has(passage) || !storyHas(this.story, passage.name)) storyVerifyIndex(this.story);
590
748
  storyAdd(this.story, passage, diagnostics);
591
749
  }
592
750
  /** Check if a passage name exists. */
593
751
  has(name) {
752
+ if (storyHas(this.story, name)) return true;
753
+ storyVerifyIndex(this.story);
594
754
  return storyHas(this.story, name);
595
755
  }
596
756
  /** Finalize and return the story as read-only. */
@@ -715,131 +875,579 @@ function isExcluded(filename, exclude) {
715
875
  const rel = relative(process.cwd(), resolve(filename)) || filename;
716
876
  return exclude.some((pattern) => matchesGlob(rel, pattern.replace(/^\.\//, "")));
717
877
  }
878
+ /** A build that writes nothing: `compile()`, or output to stdout. */
879
+ const NO_BUILD_OUTPUTS = {
880
+ files: [],
881
+ dirs: []
882
+ };
883
+ /** The outputs of a build that writes the one file `outFile`, or none without it. */
884
+ function toBuildOutputs(outputs) {
885
+ if (outputs === void 0) return NO_BUILD_OUTPUTS;
886
+ return typeof outputs === "string" ? {
887
+ files: [outputs],
888
+ dirs: []
889
+ } : outputs;
890
+ }
891
+ /**
892
+ * The path outputs are compared by: absolute, with every symbolic link in it
893
+ * resolved, so two paths that reach the same file through different links (a
894
+ * symlinked project folder, an alias of the output folder) compare equal. A
895
+ * path that doesn't exist yet, such as the output before the first build, is
896
+ * its nearest existing folder's real path joined with the rest.
897
+ */
898
+ function realPathOf(path) {
899
+ const abs = resolve(path);
900
+ try {
901
+ return realpathSync.native(abs);
902
+ } catch {}
903
+ const parent = dirname(abs);
904
+ return parent === abs ? abs : join(realPathOf(parent), basename(abs));
905
+ }
906
+ /** Whether the path `inner` is `outer` or inside it. */
907
+ function isSameOrInside(inner, outer) {
908
+ return inner === outer || inner.startsWith(outer.endsWith(sep) ? outer : outer + sep);
909
+ }
910
+ function outputPaths(outputs) {
911
+ const files = new Set(outputs.files.map(realPathOf));
912
+ const dirs = new Set(outputs.dirs.map(realPathOf));
913
+ const all = [...files, ...dirs];
914
+ return {
915
+ isFile: (real) => files.has(real),
916
+ isDir: (real) => dirs.has(real),
917
+ holds: (real) => dirs.has(real) || all.some((output) => output !== real && isSameOrInside(output, real)),
918
+ isOutput(path, roots) {
919
+ const real = realPathOf(path);
920
+ if (files.has(real)) return true;
921
+ const owners = [...dirs].filter((dir) => isSameOrInside(real, dir));
922
+ if (owners.length === 0) return false;
923
+ return roots.map(realPathOf).filter((root) => isSameOrInside(real, root)).every((root) => owners.some((dir) => dir !== root && isSameOrInside(dir, root)));
924
+ }
925
+ };
926
+ }
927
+ /**
928
+ * A folder entry as source discovery walks it, found at `pathname` with the real
929
+ * path `real` when it is no link: a link to a file counts as that file, and a
930
+ * link to anything else (a folder above all) as nothing, undefined. Throws when
931
+ * the entry or a link's target can't be read.
932
+ */
933
+ function walkedEntry(pathname, real) {
934
+ const stat = lstatSync(pathname);
935
+ if (!stat.isSymbolicLink()) return {
936
+ stat,
937
+ real
938
+ };
939
+ const target = statSync(pathname);
940
+ return target.isFile() ? {
941
+ stat: target,
942
+ real: realPathOf(pathname)
943
+ } : void 0;
944
+ }
718
945
  /**
719
946
  * Recursively walk directories, collecting regular file paths.
720
- * Filters out the output file to prevent circular compilation, and the files
721
- * that match an `exclude` glob (see isExcluded).
947
+ *
948
+ * Leaves out what the build writes (`outputs`, compared by real path, see
949
+ * realPathOf) so its last output is never read back, and the files that match an
950
+ * `exclude` glob (see isExcluded). A path given that is itself an output file is
951
+ * not walked but listed in `outputSources`.
952
+ *
953
+ * Symbolic links: a path given is followed wherever it leads. Inside a folder, a
954
+ * link to a file is read, but a link to a folder is not followed, as Tweego's walk
955
+ * doesn't follow one. A link back to its own folder or a parent would otherwise
956
+ * walk the same files again at every depth.
957
+ *
722
958
  * Like Tweego, a path that cannot be read is reported as a warning and skipped.
723
959
  */
724
- function getFilenames(pathnames, outFilename, exclude = []) {
960
+ function getFilenames(pathnames, outputs, exclude = []) {
725
961
  const filenames = [];
726
962
  const diagnostics = [];
727
- const absOutFile = outFilename ? resolve(outFilename) : "";
963
+ const outputSources = [];
964
+ const output = outputPaths(toBuildOutputs(outputs));
728
965
  function warn(pathname, e) {
729
966
  diagnostics.push({
730
967
  level: "warning",
731
968
  message: `path ${pathname}: ${e instanceof Error ? e.message : String(e)}`
732
969
  });
733
970
  }
734
- function walk(pathname) {
971
+ function addFile(pathname) {
972
+ const abs = resolve(pathname);
973
+ const rel = relative(process.cwd(), abs);
974
+ if (isExcluded(rel || abs, exclude)) return;
975
+ filenames.push(rel || abs);
976
+ }
977
+ function walkDir(pathname, real) {
978
+ let entries;
979
+ try {
980
+ entries = readdirSync(pathname);
981
+ } catch (e) {
982
+ warn(pathname, e);
983
+ return;
984
+ }
985
+ for (const entry of entries) walkEntry(join(pathname, entry), join(real, entry));
986
+ }
987
+ function walkEntry(pathname, real) {
988
+ let entry;
989
+ try {
990
+ entry = walkedEntry(pathname, real);
991
+ } catch (e) {
992
+ warn(pathname, e);
993
+ return;
994
+ }
995
+ if (entry === void 0) return;
996
+ if (entry.stat.isFile()) {
997
+ if (!output.isFile(entry.real)) addFile(pathname);
998
+ } else if (entry.stat.isDirectory() && !output.isDir(entry.real)) walkDir(pathname, entry.real);
999
+ }
1000
+ for (const pathname of pathnames) {
735
1001
  let stat;
736
1002
  try {
737
1003
  stat = statSync(pathname);
738
1004
  } catch (e) {
739
1005
  warn(pathname, e);
740
- return;
1006
+ continue;
741
1007
  }
1008
+ const real = realPathOf(pathname);
742
1009
  if (stat.isFile()) {
743
- const abs = resolve(pathname);
744
- if (abs === absOutFile) return;
745
- const rel = relative(process.cwd(), abs);
746
- if (isExcluded(rel || abs, exclude)) return;
747
- filenames.push(rel || abs);
748
- } else if (stat.isDirectory()) {
749
- let entries;
750
- try {
751
- entries = readdirSync(pathname);
752
- } catch (e) {
753
- warn(pathname, e);
754
- return;
755
- }
756
- for (const entry of entries) walk(join(pathname, entry));
757
- }
1010
+ if (output.isFile(real)) outputSources.push(pathname);
1011
+ else addFile(pathname);
1012
+ } else if (stat.isDirectory()) walkDir(pathname, real);
758
1013
  }
759
- for (const pathname of pathnames) walk(pathname);
760
1014
  return {
761
1015
  filenames,
762
- diagnostics
1016
+ diagnostics,
1017
+ outputSources
763
1018
  };
764
1019
  }
765
- /** Whether the absolute path `file` is the absolute path `dir` or inside it. */
766
- function isInsideDir(file, dir) {
767
- return file === dir || file.startsWith(dir.endsWith(sep) ? dir : dir + sep);
768
- }
769
1020
  /**
770
- * The watched paths split into folders (as given) and files (absolute). A path
771
- * that cannot be read counts as a folder, which fs.watch then fails to watch.
1021
+ * A watched path that can't be watched: fs.watch failed on it, or on the folder it is
1022
+ * watched through. watchFilesystem passes it to its `onError` and keeps watching the rest.
772
1023
  */
773
- function splitWatchRoots(pathnames) {
774
- const dirs = [];
775
- const files = /* @__PURE__ */ new Set();
776
- for (const pathname of pathnames) {
777
- let isFile = false;
778
- try {
779
- isFile = statSync(pathname).isFile();
780
- } catch {}
781
- if (isFile) files.add(resolve(pathname));
782
- else dirs.push(pathname);
1024
+ var WatchPathError = class extends Error {
1025
+ path;
1026
+ constructor(path, cause) {
1027
+ super(`Cannot watch ${path}: ${cause instanceof Error ? cause.message : String(cause)}`);
1028
+ this.path = path;
1029
+ this.name = "WatchPathError";
1030
+ }
1031
+ };
1032
+ function pathKind(path) {
1033
+ try {
1034
+ const stats = statSync(path);
1035
+ return stats.isDirectory() ? {
1036
+ kind: "dir",
1037
+ id: `${stats.dev}:${stats.ino}`
1038
+ } : { kind: "file" };
1039
+ } catch {
1040
+ return { kind: "missing" };
783
1041
  }
1042
+ }
1043
+ function rootState(abs) {
784
1044
  return {
785
- dirs,
786
- files
1045
+ anchor: nearestFolderAbove(abs),
1046
+ self: pathKind(abs)
787
1047
  };
788
1048
  }
1049
+ function nearestFolderAbove(abs) {
1050
+ for (let dir = dirname(abs), below = abs; dir !== below; below = dir, dir = dirname(dir)) {
1051
+ const kind = pathKind(dir);
1052
+ if (kind.kind === "dir") return {
1053
+ path: dir,
1054
+ id: kind.id
1055
+ };
1056
+ }
1057
+ }
1058
+ /** Whether two states need the same watches: the same anchor folder, and the same watched folder if any. */
1059
+ function sameWatches(a, b) {
1060
+ const selfId = (s) => s.self.kind === "dir" ? s.self.id : void 0;
1061
+ return a.anchor?.path === b.anchor?.path && a.anchor?.id === b.anchor?.id && selfId(a) === selfId(b);
1062
+ }
789
1063
  /**
790
1064
  * Watch paths for changes, calling the build callback on known file type changes.
791
1065
  * A folder is watched recursively; a file is watched on its own and counts
792
1066
  * whatever its type (a head file, say). A change to a file `ignore` returns true
793
1067
  * for (given the path relative to the working directory) schedules no build.
794
1068
  * Uses debouncing to avoid rapid rebuilds.
795
- */
796
- function watchFilesystem(pathnames, outFilename, callback, ignore = () => false) {
797
- const absOutFile = resolve(outFilename);
798
- const watchers = [];
1069
+ *
1070
+ * A watched path that doesn't exist yet is waited for, and one that is deleted, or renamed
1071
+ * away and replaced, is followed to the new folder or file at its path: each path is also
1072
+ * watched through the folder above it. When that sets up a path's watches again, the next
1073
+ * build is a full one (`changedFiles` undefined), since changes made in between went unseen.
1074
+ * A path that can't be watched is passed to `onError` as a WatchPathError, once while the
1075
+ * failure persists; the other paths are still watched.
1076
+ */
1077
+ function watchFilesystem(pathnames, outFilename, callback, ignore = () => false, onError = () => {}) {
1078
+ const output = outputPaths(toBuildOutputs(outFilename));
799
1079
  let buildTimer = null;
800
1080
  const BUILD_DEBOUNCE = 500;
801
1081
  const pendingFiles = /* @__PURE__ */ new Set();
1082
+ let pendingFullBuild = false;
1083
+ let closed = false;
802
1084
  function scheduleBuild(changedFile) {
803
- if (changedFile) pendingFiles.add(changedFile);
1085
+ if (changedFile === void 0) pendingFullBuild = true;
1086
+ else pendingFiles.add(changedFile);
804
1087
  if (buildTimer) clearTimeout(buildTimer);
805
1088
  buildTimer = setTimeout(() => {
806
1089
  buildTimer = null;
807
- const files = pendingFiles.size > 0 ? new Set(pendingFiles) : void 0;
1090
+ const files = pendingFullBuild || pendingFiles.size === 0 ? void 0 : new Set(pendingFiles);
808
1091
  pendingFiles.clear();
1092
+ pendingFullBuild = false;
809
1093
  callback(files);
810
1094
  }, BUILD_DEBOUNCE);
811
1095
  }
1096
+ const roots = pathnames.map((path) => {
1097
+ const abs = resolve(path);
1098
+ return {
1099
+ path,
1100
+ abs,
1101
+ state: rootState(abs),
1102
+ watchers: [],
1103
+ failed: false,
1104
+ lastReport: void 0
1105
+ };
1106
+ });
1107
+ const isNamedFile = (abs) => roots.some((r) => r.abs === abs && r.state.self.kind === "file");
812
1108
  function fileChanged(abs, named) {
813
- if (abs === absOutFile) return;
814
1109
  if (!named && !isKnownFileType(abs)) return;
1110
+ if (output.isFile(realPathOf(abs))) return;
815
1111
  const rel = relative(process.cwd(), abs);
816
1112
  if (!ignore(rel || abs)) scheduleBuild(rel || abs);
817
1113
  }
818
- function watchPath(pathname, recursive, listener) {
1114
+ function report(root, cause) {
1115
+ const error = new WatchPathError(root.path, cause);
1116
+ if (error.message === root.lastReport) return;
1117
+ root.lastReport = error.message;
1118
+ onError(error);
1119
+ }
1120
+ function startWatch(root, path, recursive, listener) {
819
1121
  try {
820
- watchers.push(watch$1(pathname, { recursive }, (_event, filename) => {
821
- if (filename) listener(filename);
822
- }));
823
- } catch {}
1122
+ if (recursive) accessSync(path, constants.R_OK | constants.X_OK);
1123
+ const watcher = watch$1(path, { recursive }, (_event, filename) => {
1124
+ if (!closed) listener(filename ?? "");
1125
+ });
1126
+ watcher.on("error", (e) => {
1127
+ watcher.close();
1128
+ root.watchers = root.watchers.filter((w) => w !== watcher);
1129
+ root.failed = true;
1130
+ if (!closed && !recheck(root, false)) report(root, e);
1131
+ });
1132
+ root.watchers.push(watcher);
1133
+ } catch (e) {
1134
+ root.failed = true;
1135
+ report(root, e);
1136
+ }
1137
+ }
1138
+ function arm(root) {
1139
+ for (const w of root.watchers) w.close();
1140
+ root.watchers = [];
1141
+ root.failed = false;
1142
+ const { anchor, self } = root.state;
1143
+ if (anchor) startWatch(root, anchor.path, false, (filename) => {
1144
+ const before = root.state.self.kind;
1145
+ recheck(root, true);
1146
+ if ((before === "file" || isNamedFile(root.abs)) && filename !== "" && resolve(anchor.path, filename) === root.abs) fileChanged(root.abs, true);
1147
+ });
1148
+ if (self.kind === "dir") startWatch(root, root.path, true, (filename) => {
1149
+ if (filename === "") recheck(root, true);
1150
+ else {
1151
+ const abs = resolve(root.abs, filename);
1152
+ fileChanged(abs, isNamedFile(abs));
1153
+ }
1154
+ });
1155
+ if (!root.failed) root.lastReport = void 0;
824
1156
  }
825
- const { dirs, files } = splitWatchRoots(pathnames);
826
- const absDirs = dirs.map((dir) => resolve(dir));
827
- for (const dir of dirs) watchPath(dir, true, (filename) => {
828
- const abs = resolve(dir, filename);
829
- fileChanged(abs, files.has(abs));
830
- });
831
- const fileDirs = new Set([...files].filter((file) => !absDirs.some((dir) => isInsideDir(file, dir))).map((file) => dirname(file)));
832
- for (const dir of fileDirs) watchPath(dir, false, (filename) => {
833
- const abs = resolve(dir, filename);
834
- if (files.has(abs)) fileChanged(abs, true);
835
- });
1157
+ /**
1158
+ * Sets up `root`'s watches again when its path, or the folder above it, is no longer the
1159
+ * one they were started on (or, with `retry`, when one of them failed), and then schedules a
1160
+ * full build. Returns whether its state changed.
1161
+ */
1162
+ function recheck(root, retry) {
1163
+ const before = root.state;
1164
+ const after = rootState(root.abs);
1165
+ const changed = !sameWatches(before, after);
1166
+ const wasFailed = root.failed;
1167
+ if (!changed && !(retry && wasFailed)) {
1168
+ root.state = after;
1169
+ return false;
1170
+ }
1171
+ root.state = after;
1172
+ arm(root);
1173
+ const recovered = wasFailed && !root.failed;
1174
+ if ((changed || recovered) && (before.self.kind !== "missing" || after.self.kind !== "missing")) scheduleBuild();
1175
+ return changed;
1176
+ }
1177
+ for (const root of roots) arm(root);
836
1178
  callback();
837
1179
  return { close() {
1180
+ closed = true;
838
1181
  if (buildTimer) clearTimeout(buildTimer);
839
- for (const w of watchers) w.close();
1182
+ for (const root of roots) for (const w of root.watchers) w.close();
840
1183
  } };
841
1184
  }
842
1185
  //#endregion
1186
+ //#region src/relaxed-json.ts
1187
+ /**
1188
+ * A parser for the object literal a Twine 2 `format.js` passes to `window.storyFormat()`.
1189
+ *
1190
+ * The story formats spec does not require that object to be strict JSON, and Twine 2 runs
1191
+ * `format.js` as JavaScript. This parser accepts JSON plus the JavaScript literal syntax formats
1192
+ * use in practice: single-quoted strings, unquoted (identifier) and numeric keys, trailing commas,
1193
+ * comments, JavaScript string escapes, and hexadecimal, octal and binary numbers.
1194
+ *
1195
+ * It reads string literals token by token, so their contents come back exactly as JavaScript
1196
+ * evaluation gives them: text that only looks like structure (`a, }`, `word:`, an apostrophe) is
1197
+ * never touched. Anything outside that subset, such as a function or a variable, is a syntax error.
1198
+ */
1199
+ const IDENTIFIER = /[A-Za-z_$][\w$]*/y;
1200
+ const NUMBER = /[+-]?(?:0[xX][0-9a-fA-F]+|0[oO][0-7]+|0[bB][01]+|(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)/y;
1201
+ const IDENTIFIER_PART = /[\w$]/;
1202
+ const WHITESPACE = /[\s]/;
1203
+ const HEX = /^[0-9a-fA-F]+$/;
1204
+ /** Simple single-character escapes and the characters they stand for. */
1205
+ const SIMPLE_ESCAPES = {
1206
+ b: "\b",
1207
+ f: "\f",
1208
+ n: "\n",
1209
+ r: "\r",
1210
+ t: " ",
1211
+ v: "\v"
1212
+ };
1213
+ /**
1214
+ * Parse a JavaScript object, array or primitive literal: all of `text`, or the part from `start` to
1215
+ * `end`. Error positions count from the start of `text`, so they point into the file it came from.
1216
+ *
1217
+ * @throws SyntaxError naming the line and column of the first thing it cannot parse.
1218
+ */
1219
+ function parseRelaxedJSON(text, start = 0, end = text.length) {
1220
+ let pos = start;
1221
+ function fail(message, at = pos) {
1222
+ const before = text.slice(0, at);
1223
+ const line = before.split("\n").length;
1224
+ const column = at - before.lastIndexOf("\n");
1225
+ throw new SyntaxError(`${message} at line ${line}, column ${column}`);
1226
+ }
1227
+ /** The character at `pos`, or '' at the end of the input. */
1228
+ const peek = () => pos < end ? text[pos] ?? "" : "";
1229
+ const describeChar = (at) => at >= end ? "end of input" : JSON.stringify(text[at]);
1230
+ /** Skip whitespace and comments. */
1231
+ const skipTrivia = () => {
1232
+ while (pos < end) {
1233
+ const ch = peek();
1234
+ if (WHITESPACE.test(ch)) pos++;
1235
+ else if (text.startsWith("//", pos)) {
1236
+ const lineEnd = text.slice(pos, end).search(/[\n\r\u2028\u2029]/);
1237
+ pos = lineEnd === -1 ? end : pos + lineEnd;
1238
+ } else if (text.startsWith("/*", pos)) {
1239
+ const commentEnd = text.indexOf("*/", pos + 2);
1240
+ if (commentEnd === -1 || commentEnd + 2 > end) fail("Unterminated comment");
1241
+ pos = commentEnd + 2;
1242
+ } else return;
1243
+ }
1244
+ };
1245
+ const consume = (ch) => {
1246
+ skipTrivia();
1247
+ if (peek() !== ch) fail(`Expected ${JSON.stringify(ch)} but found ${describeChar(pos)}`);
1248
+ pos++;
1249
+ };
1250
+ /** Read exactly `count` hex digits and return their value. */
1251
+ const readHex = (count) => {
1252
+ const digits = text.slice(pos, Math.min(pos + count, end));
1253
+ if (digits.length !== count || !HEX.test(digits)) fail("Invalid hexadecimal escape sequence");
1254
+ pos += count;
1255
+ return parseInt(digits, 16);
1256
+ };
1257
+ /** Decode the escape sequence after a backslash; `pos` is just past the backslash. */
1258
+ const readEscape = () => {
1259
+ const ch = peek();
1260
+ const simple = SIMPLE_ESCAPES[ch];
1261
+ if (simple !== void 0) {
1262
+ pos++;
1263
+ return simple;
1264
+ }
1265
+ switch (ch) {
1266
+ case "": return fail("Unterminated string");
1267
+ case "x":
1268
+ pos++;
1269
+ return String.fromCharCode(readHex(2));
1270
+ case "u": {
1271
+ pos++;
1272
+ if (peek() !== "{") return String.fromCharCode(readHex(4));
1273
+ const close = text.indexOf("}", pos);
1274
+ if (close === -1 || close >= end) fail("Invalid Unicode escape sequence");
1275
+ pos++;
1276
+ const codePoint = readHex(close - pos);
1277
+ if (codePoint > 1114111) fail("Undefined Unicode code-point");
1278
+ pos = close + 1;
1279
+ return String.fromCodePoint(codePoint);
1280
+ }
1281
+ case "\r":
1282
+ pos += text[pos + 1] === "\n" ? 2 : 1;
1283
+ return "";
1284
+ case "\n":
1285
+ case "\u2028":
1286
+ case "\u2029":
1287
+ pos++;
1288
+ return "";
1289
+ case "0":
1290
+ if (/[0-9]/.test(text[pos + 1] ?? "")) fail("Octal escape sequences are not supported");
1291
+ pos++;
1292
+ return "\0";
1293
+ default:
1294
+ if (/[1-9]/.test(ch)) fail("Octal escape sequences are not supported");
1295
+ pos++;
1296
+ return ch;
1297
+ }
1298
+ };
1299
+ const readString = () => {
1300
+ const quote = peek();
1301
+ pos++;
1302
+ let out = "";
1303
+ let chunkStart = pos;
1304
+ for (;;) {
1305
+ if (pos >= end) fail("Unterminated string");
1306
+ const ch = peek();
1307
+ if (ch === quote) {
1308
+ out += text.slice(chunkStart, pos);
1309
+ pos++;
1310
+ return out;
1311
+ }
1312
+ if (ch === "\n" || ch === "\r") fail("Unterminated string");
1313
+ if (ch === "\\") {
1314
+ out += text.slice(chunkStart, pos);
1315
+ pos++;
1316
+ out += readEscape();
1317
+ chunkStart = pos;
1318
+ } else pos++;
1319
+ }
1320
+ };
1321
+ /** Read a match of a sticky regular expression at `pos`, or return undefined. */
1322
+ const readToken = (pattern) => {
1323
+ pattern.lastIndex = pos;
1324
+ const m = pattern.exec(text);
1325
+ if (!m || pattern.lastIndex > end) return void 0;
1326
+ pos = pattern.lastIndex;
1327
+ return m[0];
1328
+ };
1329
+ const readNumber = () => {
1330
+ const tokenStart = pos;
1331
+ const token = readToken(NUMBER);
1332
+ if (token === void 0) return void 0;
1333
+ if (IDENTIFIER_PART.test(peek())) fail("Invalid number", tokenStart);
1334
+ return (token.startsWith("-") ? -1 : 1) * Number(/^[+-]/.test(token) ? token.slice(1) : token);
1335
+ };
1336
+ const readKey = () => {
1337
+ skipTrivia();
1338
+ const ch = peek();
1339
+ if (ch === "\"" || ch === "'") return readString();
1340
+ const identifier = readToken(IDENTIFIER);
1341
+ if (identifier !== void 0) return identifier;
1342
+ const number = readNumber();
1343
+ if (number !== void 0) return String(number);
1344
+ return fail(`Expected a property name but found ${describeChar(pos)}`);
1345
+ };
1346
+ const readObject = () => {
1347
+ pos++;
1348
+ const out = {};
1349
+ for (;;) {
1350
+ skipTrivia();
1351
+ if (peek() === "}") {
1352
+ pos++;
1353
+ return out;
1354
+ }
1355
+ const key = readKey();
1356
+ consume(":");
1357
+ Object.defineProperty(out, key, {
1358
+ value: readValue(),
1359
+ enumerable: true,
1360
+ writable: true,
1361
+ configurable: true
1362
+ });
1363
+ skipTrivia();
1364
+ if (peek() === ",") pos++;
1365
+ else if (peek() !== "}") fail(`Expected "," or "}" but found ${describeChar(pos)}`);
1366
+ }
1367
+ };
1368
+ const readArray = () => {
1369
+ pos++;
1370
+ const out = [];
1371
+ for (;;) {
1372
+ skipTrivia();
1373
+ if (peek() === "]") {
1374
+ pos++;
1375
+ return out;
1376
+ }
1377
+ out.push(readValue());
1378
+ skipTrivia();
1379
+ if (peek() === ",") pos++;
1380
+ else if (peek() !== "]") fail(`Expected "," or "]" but found ${describeChar(pos)}`);
1381
+ }
1382
+ };
1383
+ function readValue() {
1384
+ skipTrivia();
1385
+ const ch = peek();
1386
+ if (ch === "{") return readObject();
1387
+ if (ch === "[") return readArray();
1388
+ if (ch === "\"" || ch === "'") return readString();
1389
+ const tokenStart = pos;
1390
+ const identifier = readToken(IDENTIFIER);
1391
+ if (identifier !== void 0) {
1392
+ if (identifier === "true") return true;
1393
+ if (identifier === "false") return false;
1394
+ if (identifier === "null") return null;
1395
+ return fail(`Unexpected identifier ${JSON.stringify(identifier)}`, tokenStart);
1396
+ }
1397
+ const number = readNumber();
1398
+ if (number !== void 0) return number;
1399
+ return fail(`Unexpected ${describeChar(pos)}`);
1400
+ }
1401
+ const value = readValue();
1402
+ skipTrivia();
1403
+ if (pos < end) fail(`Unexpected ${describeChar(pos)} after the value`);
1404
+ return value;
1405
+ }
1406
+ //#endregion
1407
+ //#region src/semver.ts
1408
+ const IDENTIFIERS = String.raw`[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*`;
1409
+ const VERSION$1 = new RegExp(String.raw`^[vV]?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-(${IDENTIFIERS}))?(?:\+${IDENTIFIERS})?$`);
1410
+ const NUMERIC = /^\d+$/;
1411
+ /** Parse a version string, or return null when it is not a version. */
1412
+ function parseVersion(text) {
1413
+ const m = VERSION$1.exec(text);
1414
+ if (!m) return null;
1415
+ return {
1416
+ major: Number(m[1]),
1417
+ minor: Number(m[2] ?? 0),
1418
+ patch: Number(m[3] ?? 0),
1419
+ prerelease: m[4] === void 0 ? [] : m[4].split(".")
1420
+ };
1421
+ }
1422
+ /** Compare two prerelease identifiers: numeric ones numerically and below alphanumeric ones, which compare as ASCII. */
1423
+ function compareIdentifiers(a, b) {
1424
+ const aNumeric = NUMERIC.test(a);
1425
+ const bNumeric = NUMERIC.test(b);
1426
+ if (aNumeric && bNumeric) return Math.sign(Number(a) - Number(b));
1427
+ if (aNumeric !== bNumeric) return aNumeric ? -1 : 1;
1428
+ return a < b ? -1 : a > b ? 1 : 0;
1429
+ }
1430
+ /** Compare prerelease identifier lists. An empty list (a release) ranks above any prerelease. */
1431
+ function comparePrerelease(a, b) {
1432
+ if (a.length === 0 || b.length === 0) return Math.sign(b.length - a.length);
1433
+ const shared = Math.min(a.length, b.length);
1434
+ for (let i = 0; i < shared; i++) {
1435
+ const order = compareIdentifiers(a[i] ?? "", b[i] ?? "");
1436
+ if (order !== 0) return order;
1437
+ }
1438
+ return Math.sign(a.length - b.length);
1439
+ }
1440
+ /** Compare two versions by SemVer precedence: negative when `a` ranks lower, 0 when equal, positive when higher. */
1441
+ function compareVersions(a, b) {
1442
+ return Math.sign(a.major - b.major) || Math.sign(a.minor - b.minor) || Math.sign(a.patch - b.patch) || comparePrerelease(a.prerelease, b.prerelease);
1443
+ }
1444
+ /** Whether two version strings name the same version (prerelease included, build metadata ignored). */
1445
+ function sameVersion(a, b) {
1446
+ const left = parseVersion(a);
1447
+ const right = parseVersion(b);
1448
+ return left !== null && right !== null && compareVersions(left, right) === 0;
1449
+ }
1450
+ //#endregion
843
1451
  //#region src/source-text.ts
844
1452
  /**
845
1453
  * Source text normalization shared by every input path (files, in-memory sources, `parseTwee`).
@@ -851,14 +1459,119 @@ function watchFilesystem(pathnames, outFilename, callback, ignore = () => false)
851
1459
  function normalizeSourceText(text) {
852
1460
  return (text.charCodeAt(0) === 65279 ? text.slice(1) : text).replace(/\r\n?/g, "\n");
853
1461
  }
1462
+ /** Byte order marks at the start of a line, directly before a passage header's `::`. */
1463
+ const BOMS_BEFORE_HEADER = /(?<=^|\n)\uFEFF+(?=::)/g;
1464
+ /**
1465
+ * Normalize Twee source text: {@link normalizeSourceText}, and also remove byte order marks at the start of
1466
+ * a line that are directly followed by `::`. Concatenating files (`cat a.tw b.tw`) leaves the BOM of every
1467
+ * later file in front of its first passage header, which would otherwise not be recognised as a header.
1468
+ * A U+FEFF anywhere else is passage text and kept. Idempotent.
1469
+ */
1470
+ function normalizeTweeSourceText(text) {
1471
+ return normalizeSourceText(text).replace(BOMS_BEFORE_HEADER, "");
1472
+ }
854
1473
  //#endregion
855
1474
  //#region src/util.ts
856
1475
  /**
857
1476
  * Shared file I/O utilities.
858
1477
  */
859
- /** Read a file as UTF-8 with BOM stripping and line ending normalization. */
860
- function readUTF8(filename) {
861
- return normalizeSourceText(readFileSync(filename, "utf-8"));
1478
+ /** The charset assumed for text that is not valid UTF-8, as Tweego assumes it. */
1479
+ const FALLBACK_CHARSET = "windows-1252";
1480
+ /**
1481
+ * Throws on invalid UTF-8 instead of replacing bytes with U+FFFD. `ignoreBOM` keeps a leading BOM in the
1482
+ * text, as `readFileSync(file, 'utf-8')` did, so {@link normalizeSourceText} strips exactly one.
1483
+ */
1484
+ const utf8Decoder = new TextDecoder("utf-8", {
1485
+ fatal: true,
1486
+ ignoreBOM: true
1487
+ });
1488
+ /**
1489
+ * The code points of the Windows-1252 bytes 0x80 to 0x9F, from the WHATWG Encoding Standard. The five bytes
1490
+ * Windows-1252 leaves undefined map to the C1 control with the same number, as the standard and Go's charmap
1491
+ * do. Every other byte is the code point with the same number.
1492
+ *
1493
+ * Decoded by hand, because Node's `TextDecoder('windows-1252')` decodes as ISO-8859-1 in some releases
1494
+ * (Node 22.18 turns 0x80 into U+0080 instead of €), and builds without ICU lack it.
1495
+ */
1496
+ const WINDOWS_1252_C1 = [
1497
+ 8364,
1498
+ 129,
1499
+ 8218,
1500
+ 402,
1501
+ 8222,
1502
+ 8230,
1503
+ 8224,
1504
+ 8225,
1505
+ 710,
1506
+ 8240,
1507
+ 352,
1508
+ 8249,
1509
+ 338,
1510
+ 141,
1511
+ 381,
1512
+ 143,
1513
+ 144,
1514
+ 8216,
1515
+ 8217,
1516
+ 8220,
1517
+ 8221,
1518
+ 8226,
1519
+ 8211,
1520
+ 8212,
1521
+ 732,
1522
+ 8482,
1523
+ 353,
1524
+ 8250,
1525
+ 339,
1526
+ 157,
1527
+ 382,
1528
+ 376
1529
+ ];
1530
+ function decodeWindows1252(bytes) {
1531
+ const chunks = [];
1532
+ for (let start = 0; start < bytes.length; start += 8192) {
1533
+ const codes = Array.from(bytes.subarray(start, start + 8192), (b) => b >= 128 && b <= 159 ? WINDOWS_1252_C1[b - 128] ?? b : b);
1534
+ chunks.push(String.fromCharCode(...codes));
1535
+ }
1536
+ return chunks.join("");
1537
+ }
1538
+ /**
1539
+ * Decode bytes read from `filename` as UTF-8. Bytes that are not valid UTF-8 are decoded as Windows-1252
1540
+ * instead, with a warning naming the file, as Tweego does (`fileReadAllWithEncoding` in io.go), so a legacy
1541
+ * file keeps its non-ASCII characters instead of losing them to U+FFFD.
1542
+ *
1543
+ * The text is returned as decoded: a leading BOM and CR line endings are kept (see {@link normalizeSourceText}).
1544
+ */
1545
+ function decodeText(bytes, filename) {
1546
+ try {
1547
+ return {
1548
+ text: utf8Decoder.decode(bytes),
1549
+ diagnostics: []
1550
+ };
1551
+ } catch {
1552
+ return {
1553
+ text: decodeWindows1252(bytes),
1554
+ diagnostics: [{
1555
+ level: "warning",
1556
+ message: `read ${filename}: Invalid UTF-8; assuming charset is ${FALLBACK_CHARSET}.`,
1557
+ file: filename
1558
+ }]
1559
+ };
1560
+ }
1561
+ }
1562
+ /**
1563
+ * Read a text file: decode it with {@link decodeText} (UTF-8, or Windows-1252 when it is not valid UTF-8),
1564
+ * strip a leading BOM and convert CRLF and bare CR line endings to LF. This is the one path every file read
1565
+ * as text goes through: sources, modules, the head file, story formats and the config file.
1566
+ *
1567
+ * @param diagnostics Receives the warning when the file is not valid UTF-8. Without it, the file is decoded
1568
+ * the same way and the warning is dropped.
1569
+ * @throws When the file cannot be read.
1570
+ */
1571
+ function readUTF8(filename, diagnostics) {
1572
+ const decoded = decodeText(readFileSync(filename), filename);
1573
+ diagnostics?.push(...decoded.diagnostics);
1574
+ return normalizeSourceText(decoded.text);
862
1575
  }
863
1576
  /** Read a file as base64. */
864
1577
  function readBase64(filename) {
@@ -874,20 +1587,11 @@ function baseNameWithoutExt(filename) {
874
1587
  /**
875
1588
  * Story format discovery, loading, and SemVer matching.
876
1589
  * Ported from formats.go + config.go.
1590
+ *
1591
+ * Every lookup (local folders here, the download cache and remote indices in remote-formats.ts)
1592
+ * goes through the same version parsing ({@link parseVersion}) and the same matching rules
1593
+ * ({@link selectFormatCandidate}).
877
1594
  */
878
- /**
879
- * Attempt to fix non-strict JSON (trailing commas, single quotes, unquoted keys).
880
- */
881
- function relaxedJSONParse(json) {
882
- try {
883
- return JSON.parse(json);
884
- } catch {}
885
- let fixed = json;
886
- fixed = fixed.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, "\"$1\"");
887
- fixed = fixed.replace(/(?<=^|[{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:/gm, "\"$1\":");
888
- fixed = fixed.replace(/,\s*([}\]])/g, "$1");
889
- return JSON.parse(fixed);
890
- }
891
1595
  /** A `"setup": function` property, which Harlowe appends to its otherwise-JSON format object. */
892
1596
  const SETUP_FUNCTION_PROPERTY = /,\s*"setup"\s*:\s*function\b/g;
893
1597
  /** Drop a trailing `"setup": function(){…}` property from a format object, if it has one. */
@@ -897,151 +1601,247 @@ function stripSetupFunction(chunk) {
897
1601
  return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
898
1602
  }
899
1603
  /**
900
- * Parse the Twine 2 format.js JSON chunk.
901
- * Handles Harlowe's malformed JSON by stripping the "setup" function property. The workaround
902
- * keys on the property itself, so the same bytes parse whether they come from a `harlowe-3`
903
- * directory or a direct download.
904
- * Per spec, the name key is Optional.
1604
+ * Parse the format object at `start`–`end` of `text`: strict JSON first (fast), then the
1605
+ * JavaScript literal subset formats use. Error positions point into `text`.
1606
+ */
1607
+ function parseFormatObject(text, start, end) {
1608
+ try {
1609
+ return {
1610
+ ok: true,
1611
+ value: JSON.parse(text.slice(start, end))
1612
+ };
1613
+ } catch {}
1614
+ try {
1615
+ return {
1616
+ ok: true,
1617
+ value: parseRelaxedJSON(text, start, end)
1618
+ };
1619
+ } catch (e) {
1620
+ return {
1621
+ ok: false,
1622
+ error: e instanceof Error ? e.message : String(e)
1623
+ };
1624
+ }
1625
+ }
1626
+ /** Check a parsed format object's fields. Per spec, `name` is optional; `version` and `source` are required. */
1627
+ function toFormatJSON(raw) {
1628
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
1629
+ ok: false,
1630
+ reason: "Story format JSON chunk is not an object."
1631
+ };
1632
+ const obj = raw;
1633
+ if (typeof obj.version !== "string") return {
1634
+ ok: false,
1635
+ reason: "Story format has no \"version\" string."
1636
+ };
1637
+ if (typeof obj.source !== "string") return {
1638
+ ok: false,
1639
+ reason: "Story format has no \"source\" string."
1640
+ };
1641
+ if (!parseVersion(obj.version)) return {
1642
+ ok: false,
1643
+ reason: `Story format version ${JSON.stringify(obj.version)} is not a SemVer version.`
1644
+ };
1645
+ const data = {
1646
+ name: typeof obj.name === "string" ? obj.name : "Untitled Story Format",
1647
+ version: obj.version,
1648
+ source: obj.source,
1649
+ proofing: obj.proofing === true
1650
+ };
1651
+ if (typeof obj.author === "string") data.author = obj.author;
1652
+ if (typeof obj.description === "string") data.description = obj.description;
1653
+ if (typeof obj.image === "string") data.image = obj.image;
1654
+ if (typeof obj.url === "string") data.url = obj.url;
1655
+ if (typeof obj.license === "string") data.license = obj.license;
1656
+ return {
1657
+ ok: true,
1658
+ data
1659
+ };
1660
+ }
1661
+ /**
1662
+ * Read the metadata of a Twine 2 format.js, or say why it cannot be used.
905
1663
  *
906
- * @param _formatId No longer used; kept so existing callers keep compiling.
1664
+ * The object passed to `window.storyFormat()` may be strict JSON or a JavaScript object literal
1665
+ * (single quotes, unquoted keys, trailing commas, comments). String values come back exactly as
1666
+ * JavaScript evaluation gives them. Harlowe's function-valued `setup` property is dropped; the
1667
+ * workaround keys on the property itself, so the same bytes parse whether they come from a
1668
+ * `harlowe-3` directory or a direct download.
907
1669
  */
908
- function parseFormatJSON(source, _formatId) {
1670
+ function decodeFormatJSON(source) {
909
1671
  const first = source.indexOf("{");
910
1672
  const last = source.lastIndexOf("}");
911
- if (first === -1 || last === -1) return null;
912
- const chunk = source.slice(first, last + 1);
913
- const parse = (json) => {
914
- const raw = relaxedJSONParse(json);
915
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
916
- const obj = raw;
917
- if (typeof obj.version !== "string" || typeof obj.source !== "string") return null;
918
- if (!parseSemver(obj.version)) return null;
919
- const result = {
920
- name: typeof obj.name === "string" ? obj.name : "Untitled Story Format",
921
- version: obj.version,
922
- source: obj.source,
923
- proofing: obj.proofing === true
924
- };
925
- if (typeof obj.author === "string") result.author = obj.author;
926
- if (typeof obj.description === "string") result.description = obj.description;
927
- if (typeof obj.image === "string") result.image = obj.image;
928
- if (typeof obj.url === "string") result.url = obj.url;
929
- if (typeof obj.license === "string") result.license = obj.license;
930
- return result;
1673
+ if (first === -1 || last < first) return {
1674
+ ok: false,
1675
+ reason: "Could not find Twine 2 style story format JSON chunk."
1676
+ };
1677
+ const parsed = parseFormatObject(source, first, last + 1);
1678
+ if (parsed.ok) return toFormatJSON(parsed.value);
1679
+ const stripped = stripSetupFunction(source.slice(first, last + 1));
1680
+ const retried = stripped === void 0 ? void 0 : parseFormatObject(stripped, 0, stripped.length);
1681
+ if (retried?.ok) return toFormatJSON(retried.value);
1682
+ return {
1683
+ ok: false,
1684
+ reason: `Could not decode story format JSON chunk: ${parsed.error}`
931
1685
  };
1686
+ }
1687
+ /**
1688
+ * Parse the Twine 2 format.js JSON chunk; null when it cannot be used ({@link decodeFormatJSON}
1689
+ * gives the reason).
1690
+ *
1691
+ * @param _formatId No longer used; kept so existing callers keep compiling.
1692
+ */
1693
+ function parseFormatJSON(source, _formatId) {
1694
+ const result = decodeFormatJSON(source);
1695
+ return result.ok ? result.data : null;
1696
+ }
1697
+ /** A directory's entries in a stable order, or none when it cannot be read. */
1698
+ function listDirectory(dir) {
932
1699
  try {
933
- return parse(chunk);
1700
+ return readdirSync(dir).sort();
934
1701
  } catch {
935
- const stripped = stripSetupFunction(chunk);
936
- if (stripped === void 0) return null;
937
- try {
938
- return parse(stripped);
939
- } catch {
940
- return null;
941
- }
1702
+ return [];
1703
+ }
1704
+ }
1705
+ function isDirectory(path) {
1706
+ try {
1707
+ return statSync(path).isDirectory();
1708
+ } catch {
1709
+ return false;
1710
+ }
1711
+ }
1712
+ function isFile(path) {
1713
+ try {
1714
+ return statSync(path).isFile();
1715
+ } catch {
1716
+ return false;
942
1717
  }
943
1718
  }
944
1719
  /**
945
- * Discover all story formats in the given search directories.
946
- * Implements SemVer-based version pruning: within each (name, major) group,
947
- * only the highest minor.patch survives.
1720
+ * Load the format in one folder: its format.js (Twine 2), else its header.html (Twine 1).
1721
+ * A format.js that cannot be used is reported as a warning, as Tweego does.
948
1722
  */
949
- function discoverFormats(searchDirs) {
950
- const formats = /* @__PURE__ */ new Map();
951
- const baseFilenames = ["format.js", "header.html"];
952
- for (const searchDir of searchDirs) {
1723
+ function loadFormatDir(searchDir, id, diagnostics) {
1724
+ const formatDir = join(searchDir, id);
1725
+ if (!isDirectory(formatDir)) return void 0;
1726
+ for (const baseFilename of ["format.js", "header.html"]) {
1727
+ const filename = join(formatDir, baseFilename);
1728
+ if (!isFile(filename)) continue;
1729
+ if (baseFilename === "header.html") return {
1730
+ id,
1731
+ filename,
1732
+ isTwine2: false,
1733
+ name: id,
1734
+ version: "",
1735
+ proofing: false
1736
+ };
1737
+ const encoding = [];
1738
+ let decoded;
953
1739
  try {
954
- if (!statSync(searchDir).isDirectory()) continue;
955
- } catch {
956
- continue;
1740
+ decoded = decodeFormatJSON(readUTF8(filename, encoding));
1741
+ } catch (e) {
1742
+ decoded = {
1743
+ ok: false,
1744
+ reason: `Could not read ${filename}: ${e instanceof Error ? e.message : String(e)}`
1745
+ };
957
1746
  }
958
- let entries;
959
- try {
960
- entries = readdirSync(searchDir);
961
- } catch {
1747
+ if (!decoded.ok) {
1748
+ diagnostics?.push(...encoding);
1749
+ diagnostics?.push({
1750
+ level: "warning",
1751
+ message: `format ${id}: Skipping format; ${decoded.reason} (${filename})`
1752
+ });
962
1753
  continue;
963
1754
  }
964
- for (const entry of entries) {
965
- const formatDir = join(searchDir, entry);
966
- try {
967
- if (!statSync(formatDir).isDirectory()) continue;
968
- } catch {
969
- continue;
970
- }
971
- for (const baseFilename of baseFilenames) {
972
- const formatFilename = join(formatDir, baseFilename);
973
- try {
974
- if (!statSync(formatFilename).isFile()) continue;
975
- } catch {
976
- continue;
977
- }
978
- const isTwine2 = baseFilename === "format.js";
979
- const format = {
980
- id: entry,
981
- filename: formatFilename,
982
- isTwine2,
983
- name: "",
984
- version: "",
985
- proofing: false
986
- };
987
- if (isTwine2) try {
988
- const data = parseFormatJSON(readUTF8(formatFilename), entry);
989
- if (!data) continue;
990
- format.name = data.name;
991
- format.version = data.version;
992
- format.proofing = data.proofing ?? false;
993
- if (data.author) format.author = data.author;
994
- if (data.description) format.description = data.description;
995
- if (data.image) format.image = data.image;
996
- if (data.url) format.url = data.url;
997
- if (data.license) format.license = data.license;
998
- } catch {
999
- continue;
1000
- }
1001
- else format.name = entry;
1002
- formats.set(entry, format);
1003
- break;
1004
- }
1005
- }
1755
+ const { data } = decoded;
1756
+ const format = {
1757
+ id,
1758
+ filename,
1759
+ isTwine2: true,
1760
+ name: data.name,
1761
+ version: data.version,
1762
+ proofing: data.proofing ?? false
1763
+ };
1764
+ if (data.author) format.author = data.author;
1765
+ if (data.description) format.description = data.description;
1766
+ if (data.image) format.image = data.image;
1767
+ if (data.url) format.url = data.url;
1768
+ if (data.license) format.license = data.license;
1769
+ return format;
1770
+ }
1771
+ }
1772
+ /**
1773
+ * Discover every story format in the search directories, keyed by folder name (the format ID),
1774
+ * without version pruning.
1775
+ *
1776
+ * Directories are searched in order, and a later directory outranks an earlier one: when two hold
1777
+ * the same folder name, the later one's format is kept. The map iterates in that order too, lowest
1778
+ * rank first. Formats that cannot be used are left out, with a warning in `diagnostics`.
1779
+ */
1780
+ function discoverAllFormats(searchDirs, diagnostics) {
1781
+ const formats = /* @__PURE__ */ new Map();
1782
+ for (const searchDir of searchDirs) for (const entry of listDirectory(searchDir)) {
1783
+ const format = loadFormatDir(searchDir, entry, diagnostics);
1784
+ if (!format) continue;
1785
+ formats.delete(entry);
1786
+ formats.set(entry, format);
1006
1787
  }
1007
- pruneFormats(formats);
1008
1788
  return formats;
1009
1789
  }
1790
+ /** The version a format is pruned and selected by, or null for one outside SemVer pruning. */
1791
+ function prunableVersion(format) {
1792
+ return format.isTwine2 && format.name ? parseVersion(format.version) : null;
1793
+ }
1794
+ /** The search directory a discovered format was found in. */
1795
+ function searchDirOf(format) {
1796
+ return dirname(dirname(format.filename));
1797
+ }
1010
1798
  /**
1011
- * Prune formats by SemVer: within each (name, major) group,
1012
- * keep only the highest version. First-found wins for same name+version.
1799
+ * Prune formats by SemVer: within each (name, major) group, keep only the version with the highest
1800
+ * precedence (a release outranks its prereleases). Between equal versions, the folder in the
1801
+ * higher-ranked search directory wins, and within one directory the first folder name in sort
1802
+ * order. Returns a new map in the same order.
1803
+ *
1804
+ * Pruning serves name-based selection and listings. An explicit format ID is looked up in the
1805
+ * unpruned map, so a pinned folder is used even when another folder holds a newer version.
1013
1806
  */
1014
1807
  function pruneFormats(formats) {
1015
- const bestByNameMajor = /* @__PURE__ */ new Map();
1016
- const seenNameVersion = /* @__PURE__ */ new Set();
1017
- for (const [id, f] of formats) {
1018
- if (!f.isTwine2 || !f.name || !f.version) continue;
1019
- const v = parseSemver(f.version);
1020
- if (!v) continue;
1021
- const nameVersionKey = `${f.name}@${f.version}`;
1022
- if (seenNameVersion.has(nameVersionKey)) {
1023
- formats.delete(id);
1024
- continue;
1025
- }
1026
- seenNameVersion.add(nameVersionKey);
1027
- const groupKey = `${f.name}@${v[0]}`;
1028
- const existing = bestByNameMajor.get(groupKey);
1029
- if (!existing || semverCompare(v, existing.version) > 0) bestByNameMajor.set(groupKey, {
1030
- id,
1031
- version: v
1808
+ const dirRank = /* @__PURE__ */ new Map();
1809
+ for (const format of formats.values()) {
1810
+ const dir = searchDirOf(format);
1811
+ if (!dirRank.has(dir)) dirRank.set(dir, dirRank.size);
1812
+ }
1813
+ const rankOf = (format) => dirRank.get(searchDirOf(format)) ?? 0;
1814
+ const best = /* @__PURE__ */ new Map();
1815
+ for (const format of formats.values()) {
1816
+ const version = prunableVersion(format);
1817
+ if (!version) continue;
1818
+ const group = `${format.name}@${version.major}`;
1819
+ const current = best.get(group);
1820
+ const order = current ? compareVersions(version, current.version) : 1;
1821
+ if (order > 0 || order === 0 && current && rankOf(format) > rankOf(current.format)) best.set(group, {
1822
+ format,
1823
+ version
1032
1824
  });
1033
1825
  }
1034
- for (const [id, f] of [...formats]) {
1035
- if (!f.isTwine2 || !f.name || !f.version) continue;
1036
- const v = parseSemver(f.version);
1037
- if (!v) continue;
1038
- const groupKey = `${f.name}@${v[0]}`;
1039
- const best = bestByNameMajor.get(groupKey);
1040
- if (best && best.id !== id) formats.delete(id);
1041
- }
1826
+ return new Map([...formats].filter(([id, format]) => {
1827
+ const version = prunableVersion(format);
1828
+ return version === null || best.get(`${format.name}@${version.major}`)?.format.id === id;
1829
+ }));
1042
1830
  }
1043
1831
  /**
1044
- * Get the default format search directories.
1832
+ * Discover all story formats in the given search directories, pruned by SemVer: within each
1833
+ * (name, major) group only the highest version survives. See {@link discoverAllFormats} for the
1834
+ * directory ranking and {@link pruneFormats} for pruning.
1835
+ */
1836
+ function discoverFormats(searchDirs) {
1837
+ return pruneFormats(discoverAllFormats(searchDirs));
1838
+ }
1839
+ /**
1840
+ * Get the format search directories, lowest rank first: the story format subdirectories of the
1841
+ * home directory, then of the working directory, then each `TWEEGO_PATH` entry, then `extraPaths`
1842
+ * (`formatPaths`). A later directory outranks an earlier one for the same format folder name, so
1843
+ * `formatPaths` override `TWEEGO_PATH`, which overrides the home and working directories, as in
1844
+ * Tweego.
1045
1845
  */
1046
1846
  function getFormatSearchDirs(extraPaths = [], useTweegoPath = true) {
1047
1847
  const subdirNames = [
@@ -1056,30 +1856,25 @@ function getFormatSearchDirs(extraPaths = [], useTweegoPath = true) {
1056
1856
  basePaths.add(homedir());
1057
1857
  } catch {}
1058
1858
  basePaths.add(process.cwd());
1059
- const dirs = [];
1060
- for (const base of basePaths) for (const sub of subdirNames) {
1061
- const dir = join(base, sub);
1062
- try {
1063
- if (statSync(dir).isDirectory()) dirs.push(dir);
1064
- } catch {}
1065
- }
1859
+ const dirs = [...basePaths].flatMap((base) => subdirNames.map((sub) => join(base, sub))).filter(isDirectory);
1860
+ const tweegoPath = useTweegoPath ? process.env.TWEEGO_PATH : void 0;
1861
+ if (tweegoPath) dirs.push(...tweegoPath.split(process.platform === "win32" ? ";" : ":"));
1066
1862
  dirs.push(...extraPaths);
1067
- if (useTweegoPath) {
1068
- const tweegoPath = process.env.TWEEGO_PATH;
1069
- if (tweegoPath) dirs.push(...tweegoPath.split(process.platform === "win32" ? ";" : ":"));
1070
- }
1071
1863
  return dirs;
1072
1864
  }
1073
- /** Parse a simple semver string into [major, minor, patch]. */
1865
+ /**
1866
+ * Parse a version string into [major, minor, patch]. Accepts what {@link parseVersion} accepts
1867
+ * (`v1.2.3`, `1.2`, `2.0.0-beta.1`); the prerelease and build metadata are dropped.
1868
+ */
1074
1869
  function parseSemver(v) {
1075
- const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
1076
- if (!m) return null;
1077
- return [
1078
- Number(m[1]),
1079
- Number(m[2]),
1080
- Number(m[3])
1081
- ];
1082
- }
1870
+ const parsed = parseVersion(v);
1871
+ return parsed ? [
1872
+ parsed.major,
1873
+ parsed.minor,
1874
+ parsed.patch
1875
+ ] : null;
1876
+ }
1877
+ /** Compare [major, minor, patch] tuples. Use {@link compareVersions} to take prereleases into account. */
1083
1878
  function semverCompare(a, b) {
1084
1879
  const [aMajor, aMinor, aPatch] = a;
1085
1880
  const [bMajor, bMinor, bPatch] = b;
@@ -1090,7 +1885,7 @@ function semverCompare(a, b) {
1090
1885
  }
1091
1886
  /** Build a directory-style format ID from a name and version, e.g. ('SugarCube', '2.37.3') → 'sugarcube-2'. */
1092
1887
  function makeFormatId(name, version) {
1093
- const major = parseSemver(version)?.[0] ?? 0;
1888
+ const major = parseVersion(version)?.major ?? 0;
1094
1889
  return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
1095
1890
  }
1096
1891
  /** Describe a format request for diagnostics. */
@@ -1099,83 +1894,166 @@ function describeFormatRequest(request) {
1099
1894
  case "id": return `"${request.id}"`;
1100
1895
  case "name": return request.version ? `"${request.name}" at version "${request.version}"` : `"${request.name}"`;
1101
1896
  default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
1102
- }
1103
- }
1104
- /**
1105
- * Pick the candidate that best answers a format request.
1106
- *
1107
- * - An ID request ('sugarcube-2') matches a candidate whose name and major version build that ID,
1108
- * and takes the greatest such version.
1109
- * - A name request matches the name case-insensitively. An exact version wins outright; otherwise
1110
- * the greatest version with the same major that is not older than the one asked for (or, with
1111
- * `allowOlder`, the greatest same-major version). An unparseable requested version takes the
1112
- * greatest version of any major.
1113
- */
1114
- function selectFormatCandidate(request, candidates, describe, options = {}) {
1115
- const allowOlder = options.allowOlder ?? false;
1116
- const wanted = request.kind === "name" ? parseSemver(request.version) : null;
1897
+ }
1898
+ }
1899
+ /** {@link selectFormatCandidate} over one set of candidates, matching names without regard to case. */
1900
+ function pickFormatCandidate(request, candidates, describe, allowOlder) {
1901
+ const wanted = request.kind === "name" ? parseVersion(request.version) : null;
1117
1902
  let best;
1118
1903
  for (const candidate of candidates) {
1119
1904
  const { name, version } = describe(candidate);
1120
- const have = parseSemver(version);
1905
+ const have = parseVersion(version);
1121
1906
  if (!have) continue;
1122
1907
  if (request.kind === "id") {
1123
1908
  if (makeFormatId(name, version) !== request.id.toLowerCase()) continue;
1124
1909
  } else {
1125
1910
  if (name.toLowerCase() !== request.name.toLowerCase()) continue;
1126
1911
  if (wanted) {
1127
- if (semverCompare(have, wanted) === 0) return candidate;
1128
- if (have[0] !== wanted[0]) continue;
1129
- if (!allowOlder && semverCompare(have, wanted) < 0) continue;
1912
+ const order = compareVersions(have, wanted);
1913
+ if (order === 0) return candidate;
1914
+ if (have.major !== wanted.major) continue;
1915
+ if (!allowOlder && order < 0) continue;
1130
1916
  }
1131
1917
  }
1132
- if (!best || semverCompare(have, best.version) > 0) best = {
1918
+ if (!best || compareVersions(have, best.version) > 0) best = {
1133
1919
  candidate,
1134
1920
  version: have
1135
1921
  };
1136
1922
  }
1137
1923
  return best?.candidate;
1138
1924
  }
1139
- /** Get format ID from Twine 2 name and version, using SemVer major matching. */
1140
- function getFormatIdByNameAndVersion(formats, name, version) {
1141
- const wanted = parseSemver(version);
1142
- let bestVersion = null;
1143
- let bestId;
1144
- for (const [id, f] of formats) {
1145
- if (!f.isTwine2 || f.name !== name) continue;
1146
- const have = parseSemver(f.version);
1147
- if (!have) continue;
1148
- if (wanted === null || have[0] === wanted[0] && semverCompare(have, wanted) >= 0) {
1149
- if (!bestVersion || semverCompare(have, bestVersion) > 0) {
1150
- bestVersion = have;
1151
- bestId = id;
1152
- }
1153
- }
1925
+ /**
1926
+ * Pick the candidate that best answers a format request. Local folders, the download cache and
1927
+ * remote indices all select through this one function.
1928
+ *
1929
+ * - An ID request ('sugarcube-2') matches, without regard to case, a candidate whose name and
1930
+ * major version build that ID, and takes the greatest such version.
1931
+ * - A name request matches the name without regard to case, but a candidate whose name matches in
1932
+ * case too is preferred when one answers the request. An exact version (prerelease included)
1933
+ * wins outright; otherwise the greatest version with the same major that is not older than the
1934
+ * one asked for (or, with `allowOlder`, the greatest same-major version). An unparseable
1935
+ * requested version takes the greatest version of any major.
1936
+ *
1937
+ * Versions compare by SemVer precedence ({@link compareVersions}), so a release outranks its
1938
+ * prereleases. Between equal versions, the earlier candidate wins.
1939
+ */
1940
+ function selectFormatCandidate(request, candidates, describe, options = {}) {
1941
+ const allowOlder = options.allowOlder ?? false;
1942
+ if (request.kind === "name") {
1943
+ const preferred = pickFormatCandidate(request, candidates.filter((c) => describe(c).name === request.name), describe, allowOlder);
1944
+ if (preferred !== void 0) return preferred;
1945
+ }
1946
+ return pickFormatCandidate(request, candidates, describe, allowOlder);
1947
+ }
1948
+ /** The Twine 2 formats of a discovered map, those from higher-ranked search directories first. */
1949
+ function rankedTwine2Formats(formats) {
1950
+ return [...formats.values()].filter((f) => f.isTwine2).reverse();
1951
+ }
1952
+ /**
1953
+ * Look a format ID up among discovered folders. The ID matches a folder name without regard to
1954
+ * case; an exact-case folder is preferred, then the highest-ranked one.
1955
+ */
1956
+ function findFormatById(formats, id) {
1957
+ const exact = formats.get(id);
1958
+ if (exact) return exact;
1959
+ const wanted = id.toLowerCase();
1960
+ return [...formats.values()].reverse().find((f) => f.id.toLowerCase() === wanted);
1961
+ }
1962
+ /**
1963
+ * Read the story format source (for Twine 2, extract the `source` property from JSON).
1964
+ * `diagnostics` receives a warning when the format file is not valid UTF-8 (it is read as Windows-1252).
1965
+ */
1966
+ function readFormatSource(format, diagnostics) {
1967
+ const source = readUTF8(format.filename, diagnostics);
1968
+ if (!format.isTwine2) return source;
1969
+ const decoded = decodeFormatJSON(source);
1970
+ if (!decoded.ok) throw new Error(`Cannot parse format ${format.id} JSON: ${decoded.reason}`);
1971
+ return decoded.data.source;
1972
+ }
1973
+ //#endregion
1974
+ //#region src/atomic-write.ts
1975
+ /**
1976
+ * Atomic file replacement, for every file twee-ts writes that something else may read
1977
+ * meanwhile: build output (which live-reload servers read on each change) and downloaded
1978
+ * story formats (which other twee-ts processes share).
1979
+ */
1980
+ /**
1981
+ * Writes `data` to `path` so that a reader sees either the previous file or the complete new
1982
+ * one, never a partly written file. The data goes to a temporary file in the same folder,
1983
+ * which is then renamed over `path`. When `path` is a symlink, the file it points to is
1984
+ * replaced, and an existing file keeps its permissions.
1985
+ *
1986
+ * On failure (a full disk, a missing folder) the temporary file is removed, `path` is left
1987
+ * as it was, and the error is thrown with `path` in its message and its original `code`.
1988
+ */
1989
+ function writeFileAtomic(path, data) {
1990
+ const target = followSymlink(path);
1991
+ const temp = join(dirname(target), `.${basename(target)}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
1992
+ try {
1993
+ writeFileSync(temp, data, {
1994
+ encoding: "utf-8",
1995
+ flag: "wx"
1996
+ });
1997
+ keepPermissions(target, temp);
1998
+ renameSync(temp, target);
1999
+ } catch (e) {
2000
+ rmSync(temp, { force: true });
2001
+ throw writeError(path, e);
1154
2002
  }
1155
- return bestId;
1156
2003
  }
1157
- /** Read the story format source (for Twine 2, extract the `source` property from JSON). */
1158
- function readFormatSource(format) {
1159
- const source = readUTF8(format.filename);
1160
- if (format.isTwine2) {
1161
- const data = parseFormatJSON(source, format.id);
1162
- if (!data) throw new Error(`Cannot parse format ${format.id} JSON`);
1163
- return data.source;
2004
+ /** The file a symlink at `path` points to, or `path` itself (also when it doesn't exist yet). */
2005
+ function followSymlink(path) {
2006
+ try {
2007
+ return realpathSync(path);
2008
+ } catch {
2009
+ return path;
1164
2010
  }
1165
- return source;
2011
+ }
2012
+ /**
2013
+ * Gives `temp` the permission bits of `target`, when `target` exists. Best effort: on a file
2014
+ * system without Unix permissions chmod can fail, and writing in place never kept them there either.
2015
+ */
2016
+ function keepPermissions(target, temp) {
2017
+ const existing = statSync(target, { throwIfNoEntry: false });
2018
+ if (existing === void 0) return;
2019
+ try {
2020
+ chmodSync(temp, existing.mode & 4095);
2021
+ } catch {}
2022
+ }
2023
+ /** `cause` as an error that names `path` and keeps the original error code. */
2024
+ function writeError(path, cause) {
2025
+ const message = cause instanceof Error ? cause.message : String(cause);
2026
+ const error = new Error(`Cannot write ${path}: ${message}`, { cause });
2027
+ if (cause instanceof Error && "code" in cause) error.code = cause.code;
2028
+ return error;
1166
2029
  }
1167
2030
  //#endregion
1168
2031
  //#region src/remote-formats.ts
1169
2032
  /**
1170
2033
  * Remote story format fetching, caching, and checksum verification.
1171
2034
  * Uses the Story Formats Archive (SFA) as the default source.
2035
+ *
2036
+ * Two caches hold downloads. Formats found through an index are shared by name and version
2037
+ * (`<cache>/<name>/<version>/format.js`). A format downloaded from a direct URL is kept under
2038
+ * that URL instead, so one project's copy never answers another project's request.
2039
+ * Cache files are replaced atomically, so another process never reads a partly written one.
1172
2040
  */
1173
2041
  const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
2042
+ /** The longest delay a timer accepts; a longer timeout means no limit. */
2043
+ const MAX_TIMER_DELAY = 2147483647;
1174
2044
  /** Get the cache directory for downloaded story formats. */
1175
2045
  function getCacheDir() {
1176
2046
  const base = process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache");
1177
2047
  return join(base, "twee-ts", "storyformats");
1178
2048
  }
2049
+ /** The cache directory for formats downloaded from direct URLs, beside {@link getCacheDir}. */
2050
+ function getUrlCacheDir() {
2051
+ return join(dirname(getCacheDir()), "storyformat-urls");
2052
+ }
2053
+ /** The name of one direct URL's cache entry: a hash of the URL, so each URL has its own. */
2054
+ function urlCacheKey(url) {
2055
+ return createHash("sha256").update(url).digest("hex");
2056
+ }
1179
2057
  /**
1180
2058
  * Whether a string can serve as one directory name inside the cache: not empty, not `.` or `..`,
1181
2059
  * and free of path separators. Format names and versions come from downloaded metadata, so they
@@ -1198,25 +2076,38 @@ function cachedFormatDir(name, version) {
1198
2076
  if (!isInside(root, dir)) throw new Error(`Refusing to cache a story format outside the cache directory ${root}: ${dir}`);
1199
2077
  return dir;
1200
2078
  }
1201
- /** Create `dir` as a plain directory, or check that it already is one (a symlink could lead out of the cache). */
2079
+ /** Whether `e` is a Node.js system error with the given code. */
2080
+ function hasErrorCode(e, code) {
2081
+ return e instanceof Error && "code" in e && e.code === code;
2082
+ }
2083
+ /**
2084
+ * Create `dir` as a plain directory, or check that it already is one (a symlink could lead out of the cache).
2085
+ * A directory another process creates between the check and the mkdir is fine.
2086
+ */
1202
2087
  function ensurePlainDirectory(dir) {
1203
- const stat = lstatSync(dir, { throwIfNoEntry: false });
1204
- if (stat === void 0) {
2088
+ if (lstatSync(dir, { throwIfNoEntry: false }) === void 0) try {
1205
2089
  mkdirSync(dir);
1206
2090
  return;
2091
+ } catch (e) {
2092
+ if (!hasErrorCode(e, "EEXIST")) throw e;
1207
2093
  }
1208
- if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`Refusing to write a story format outside the cache directory: ${dir} is not a plain directory`);
2094
+ const stat = lstatSync(dir, { throwIfNoEntry: false });
2095
+ 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`);
1209
2096
  }
1210
- /** Write a downloaded format.js to `<cache>/<name>/<version>/format.js` and return its path. */
1211
- function writeCachedFormat(name, version, text) {
1212
- const dir = cachedFormatDir(name, version);
1213
- const root = resolve(getCacheDir());
2097
+ /**
2098
+ * Write a downloaded format.js to `<root>/<...segments>/format.js`, replacing any earlier copy
2099
+ * atomically, and return its path. Every directory below `root` must be a plain one.
2100
+ */
2101
+ function writeCacheEntry(root, segments, text) {
1214
2102
  mkdirSync(root, { recursive: true });
1215
- ensurePlainDirectory(join(root, name));
1216
- ensurePlainDirectory(dir);
2103
+ const dir = segments.reduce((parent, segment) => {
2104
+ const child = join(parent, segment);
2105
+ ensurePlainDirectory(child);
2106
+ return child;
2107
+ }, root);
1217
2108
  const formatPath = join(dir, "format.js");
1218
2109
  if (lstatSync(formatPath, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error(`Refusing to write a story format outside the cache directory: ${formatPath} is a symlink`);
1219
- writeFileSync(formatPath, text, "utf-8");
2110
+ writeFileAtomic(formatPath, text);
1220
2111
  return formatPath;
1221
2112
  }
1222
2113
  /** In-memory index cache, keyed by URL. Cleared each compile. */
@@ -1225,6 +2116,78 @@ const indexCache = /* @__PURE__ */ new Map();
1225
2116
  function clearIndexCache() {
1226
2117
  indexCache.clear();
1227
2118
  }
2119
+ /** Requests in progress, by what they fetch, so concurrent compiles make each request once. */
2120
+ const sharedRequests = /* @__PURE__ */ new Map();
2121
+ /**
2122
+ * Runs `start` once for all callers that ask for `key` while it is in progress. A caller whose
2123
+ * `signal` aborts stops waiting at once and rejects with the signal's reason; the request
2124
+ * itself is aborted (through the signal `start` receives) once no caller waits on it.
2125
+ */
2126
+ function shareRequest(key, signal, start) {
2127
+ if (signal?.aborted) return Promise.reject(signal.reason);
2128
+ const request = sharedRequests.get(key) ?? startSharedRequest(key, start);
2129
+ request.waiters++;
2130
+ return new Promise((resolve, reject) => {
2131
+ let waiting = true;
2132
+ const stopWaiting = () => {
2133
+ if (!waiting) return;
2134
+ waiting = false;
2135
+ signal?.removeEventListener("abort", onAbort);
2136
+ request.waiters--;
2137
+ };
2138
+ const onAbort = () => {
2139
+ stopWaiting();
2140
+ if (request.waiters === 0) {
2141
+ if (sharedRequests.get(key) === request) sharedRequests.delete(key);
2142
+ request.controller.abort(signal?.reason);
2143
+ }
2144
+ reject(signal?.reason);
2145
+ };
2146
+ signal?.addEventListener("abort", onAbort, { once: true });
2147
+ request.promise.then((value) => {
2148
+ stopWaiting();
2149
+ resolve(value);
2150
+ }, (e) => {
2151
+ stopWaiting();
2152
+ reject(e);
2153
+ });
2154
+ });
2155
+ }
2156
+ function startSharedRequest(key, start) {
2157
+ const controller = new AbortController();
2158
+ const request = {
2159
+ promise: start(controller.signal),
2160
+ controller,
2161
+ waiters: 0
2162
+ };
2163
+ sharedRequests.set(key, request);
2164
+ const forget = () => {
2165
+ if (sharedRequests.get(key) === request) sharedRequests.delete(key);
2166
+ };
2167
+ request.promise.then(forget, forget);
2168
+ return request;
2169
+ }
2170
+ /** The time limit for each request, from the caller's options. */
2171
+ function requestTimeout(options) {
2172
+ const timeout = options.timeout ?? 3e4;
2173
+ if (!(timeout >= 0)) throw new RangeError(`A story format request timeout must be 0 or more milliseconds, not ${timeout}`);
2174
+ return timeout;
2175
+ }
2176
+ /**
2177
+ * Fetch `url` as text, aborting when `signal` does or after `timeout` milliseconds (0: no limit).
2178
+ * `what` names the request in errors, as in "Failed to <what> from <url>".
2179
+ */
2180
+ async function fetchText(url, what, signal, timeout) {
2181
+ const limit = timeout > 0 && timeout <= MAX_TIMER_DELAY ? AbortSignal.timeout(timeout) : void 0;
2182
+ try {
2183
+ const res = await fetch(url, { signal: limit ? AbortSignal.any([signal, limit]) : signal });
2184
+ if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
2185
+ return await res.text();
2186
+ } catch (e) {
2187
+ if (limit?.aborted && !signal.aborted) throw new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`, { cause: e });
2188
+ throw e;
2189
+ }
2190
+ }
1228
2191
  /** Validate that a JSON value is a valid SFAIndexEntry. */
1229
2192
  function isValidEntry(val) {
1230
2193
  if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
@@ -1240,15 +2203,18 @@ function validateSFAIndex(json) {
1240
2203
  twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
1241
2204
  };
1242
2205
  }
1243
- /** Fetch and parse an SFA index.json, with in-memory caching. */
1244
- async function fetchIndex(url) {
2206
+ /** Fetch and parse an SFA index.json, with in-memory caching. Concurrent calls for one URL share a request. */
2207
+ async function fetchIndex(url, options = {}) {
2208
+ options.signal?.throwIfAborted();
1245
2209
  const cached = indexCache.get(url);
1246
2210
  if (cached) return cached;
1247
- const res = await fetch(url);
1248
- if (!res.ok) throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
1249
- const data = validateSFAIndex(await res.json());
1250
- indexCache.set(url, data);
1251
- return data;
2211
+ const timeout = requestTimeout(options);
2212
+ return shareRequest(`index\0${url}`, options.signal, async (signal) => {
2213
+ const text = await fetchText(url, "fetch format index", signal, timeout);
2214
+ const data = validateSFAIndex(JSON.parse(text));
2215
+ indexCache.set(url, data);
2216
+ return data;
2217
+ });
1252
2218
  }
1253
2219
  /** Find the best matching entry in an SFA index for a name or ID request (twine2 entries first). */
1254
2220
  function findEntryForRequest(index, request) {
@@ -1269,99 +2235,155 @@ async function verifySHA256(content, expectedHex) {
1269
2235
  function getDownloadUrl(indexUrl, entry, formatType) {
1270
2236
  return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
1271
2237
  }
1272
- /** Download a format, verify its checksum, write to cache, and return StoryFormatInfo. */
1273
- async function fetchAndCacheFormat(entry, downloadUrl) {
1274
- cachedFormatDir(entry.name, entry.version);
1275
- const res = await fetch(downloadUrl);
1276
- if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
1277
- const text = await res.text();
1278
- const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
1279
- if (checksumKey) {
1280
- const expected = entry.checksums[checksumKey];
1281
- if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
1282
- if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
1283
- }
1284
- const id = makeFormatId(entry.name, entry.version);
1285
- const data = parseFormatJSON(text, id);
1286
- if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
2238
+ /** The StoryFormatInfo of a Twine 2 format.js cached at `filename`. */
2239
+ function cachedFormatInfo(id, filename, data) {
1287
2240
  return {
1288
2241
  id,
1289
- filename: writeCachedFormat(entry.name, entry.version, text),
2242
+ filename,
1290
2243
  isTwine2: true,
1291
2244
  name: data.name,
1292
2245
  version: data.version,
1293
2246
  proofing: data.proofing ?? false
1294
2247
  };
1295
2248
  }
1296
- /** Download a direct format.js URL, parse its JSON, cache it, and return StoryFormatInfo. */
1297
- async function fetchDirectFormat(url) {
1298
- const res = await fetch(url);
1299
- if (!res.ok) throw new Error(`Failed to download format from ${url}: ${res.status} ${res.statusText}`);
1300
- const text = await res.text();
1301
- const data = parseFormatJSON(text);
1302
- if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
1303
- return {
1304
- id: makeFormatId(data.name, data.version),
1305
- filename: writeCachedFormat(data.name, data.version, text),
1306
- isTwine2: true,
1307
- name: data.name,
1308
- version: data.version,
1309
- proofing: data.proofing ?? false
1310
- };
2249
+ /**
2250
+ * Download a format, verify its checksum, write it to the cache shared by name and version, and
2251
+ * return its StoryFormatInfo. Concurrent calls for one download URL share one request.
2252
+ */
2253
+ async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
2254
+ cachedFormatDir(entry.name, entry.version);
2255
+ const timeout = requestTimeout(options);
2256
+ return shareRequest(`format\0${getCacheDir()}\0${downloadUrl}`, options.signal, async (signal) => {
2257
+ const text = await fetchText(downloadUrl, "download format", signal, timeout);
2258
+ const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
2259
+ if (checksumKey) {
2260
+ const expected = entry.checksums[checksumKey];
2261
+ if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
2262
+ if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
2263
+ }
2264
+ const id = makeFormatId(entry.name, entry.version);
2265
+ const data = parseFormatJSON(text, id);
2266
+ if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
2267
+ signal.throwIfAborted();
2268
+ return cachedFormatInfo(id, writeCacheEntry(resolve(getCacheDir()), [entry.name, entry.version], text), data);
2269
+ });
2270
+ }
2271
+ /**
2272
+ * Download a direct format.js URL, parse its JSON, cache it under that URL (apart from the
2273
+ * downloads shared by name and version), and return its StoryFormatInfo. Concurrent calls for
2274
+ * one URL share one request.
2275
+ */
2276
+ async function fetchDirectFormat(url, options = {}) {
2277
+ const timeout = requestTimeout(options);
2278
+ return shareRequest(`url\0${getUrlCacheDir()}\0${url}`, options.signal, async (signal) => {
2279
+ const text = await fetchText(url, "download format", signal, timeout);
2280
+ const data = parseFormatJSON(text);
2281
+ if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
2282
+ cachedFormatDir(data.name, data.version);
2283
+ signal.throwIfAborted();
2284
+ const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
2285
+ return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
2286
+ });
2287
+ }
2288
+ /** The copy of a direct format URL in the download cache, if it has been downloaded before. */
2289
+ function getCachedDirectFormat(url) {
2290
+ const formatPath = join(getUrlCacheDir(), urlCacheKey(url), "format.js");
2291
+ try {
2292
+ if (!existsSync(formatPath)) return void 0;
2293
+ const data = parseFormatJSON(readUTF8(formatPath));
2294
+ if (!data) return void 0;
2295
+ return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
2296
+ } catch {
2297
+ return;
2298
+ }
2299
+ }
2300
+ /**
2301
+ * Look a request up among direct format URLs, in order, and return the first format that
2302
+ * answers it. A URL downloaded before is answered by its cached copy without the network;
2303
+ * any other is downloaded (unless `offline`). When none answers and a download failed, the
2304
+ * last failure is thrown. An aborted signal rejects with its reason and tries no further URL.
2305
+ *
2306
+ * Internal, for format resolution; not part of the public API.
2307
+ */
2308
+ async function resolveFormatUrls(request, urls, options = {}) {
2309
+ let lastError;
2310
+ for (const url of urls) {
2311
+ const cached = getCachedDirectFormat(url);
2312
+ if (cached) {
2313
+ if (answers(request, cached)) return cached;
2314
+ continue;
2315
+ }
2316
+ if (options.offline) continue;
2317
+ try {
2318
+ const info = await fetchDirectFormat(url, options);
2319
+ if (answers(request, info)) return info;
2320
+ } catch (e) {
2321
+ if (options.signal?.aborted) throw options.signal.reason;
2322
+ lastError = toError$1(e);
2323
+ }
2324
+ }
2325
+ if (lastError) throw lastError;
2326
+ }
2327
+ /** Whether `info` is a format the request accepts. */
2328
+ function answers(request, info) {
2329
+ return selectFormatCandidate(request, [info], (f) => f) !== void 0;
2330
+ }
2331
+ function toError$1(e) {
2332
+ return e instanceof Error ? e : new Error(String(e));
1311
2333
  }
1312
2334
  /**
1313
2335
  * Try to resolve a remote story format by name and version.
1314
- * 0. Use an exactly matching cached download, without touching the network
1315
- * 1. Try direct format URLs
1316
- * 2. Try custom index URLs
1317
- * 3. Try default SFA indices
1318
- * 4. Fall back to a compatible cached download (e.g. when offline)
2336
+ * 1. Try direct format URLs (each one's cached copy, else a download)
2337
+ * 2. Use an exactly matching download from the shared cache, without touching the network
2338
+ * 3. Try custom index URLs, then the default SFA indices
2339
+ * 4. Fall back to a compatible download from the shared cache (e.g. when offline)
2340
+ *
2341
+ * `options.signal` aborts the lookup, which then rejects with the signal's reason;
2342
+ * `options.timeout` limits each request (default {@link DEFAULT_FORMAT_FETCH_TIMEOUT} ms).
1319
2343
  */
1320
- async function resolveRemoteFormat(name, version, indices, urls) {
2344
+ async function resolveRemoteFormat(name, version, indices, urls, options = {}) {
1321
2345
  return resolveRemoteFormatRequest({
1322
2346
  kind: "name",
1323
2347
  name,
1324
2348
  version
1325
- }, indices, urls);
2349
+ }, indices, urls, options);
1326
2350
  }
1327
2351
  /**
1328
2352
  * Resolve a story format request remotely, in the same order as {@link resolveRemoteFormat}.
1329
2353
  * An ID request such as 'sugarcube-2' matches the format whose name and major version build that ID
1330
2354
  * (SugarCube 2.x), taking the greatest version available.
1331
2355
  */
1332
- async function resolveRemoteFormatRequest(request, indices, urls) {
2356
+ async function resolveRemoteFormatRequest(request, indices, urls, options = {}) {
2357
+ options.signal?.throwIfAborted();
1333
2358
  let lastError;
1334
- const cached = findCachedFormat(request);
1335
- if (cached && request.kind === "name" && isExactVersion(cached.version, request.version)) return cached;
1336
- for (const url of urls ?? []) try {
1337
- const info = await fetchDirectFormat(url);
1338
- if (selectFormatCandidate(request, [info], (f) => f)) return info;
2359
+ try {
2360
+ const direct = await resolveFormatUrls(request, urls ?? [], options);
2361
+ if (direct) return direct;
1339
2362
  } catch (e) {
1340
- lastError = e instanceof Error ? e : new Error(String(e));
2363
+ if (options.signal?.aborted) throw options.signal.reason;
2364
+ lastError = toError$1(e);
1341
2365
  }
2366
+ const cached = findCachedFormat(request);
2367
+ if (cached && request.kind === "name" && sameVersion(cached.version, request.version)) return cached;
1342
2368
  const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
1343
2369
  for (const indexUrl of allIndices) try {
1344
- const result = findEntryForRequest(await fetchIndex(indexUrl), request);
2370
+ const result = findEntryForRequest(await fetchIndex(indexUrl, options), request);
1345
2371
  if (result) {
1346
2372
  const hit = getCachedFormat(result.entry.name, result.entry.version);
1347
2373
  if (hit) return hit;
1348
2374
  const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
1349
- return await fetchAndCacheFormat(result.entry, downloadUrl);
2375
+ return await fetchAndCacheFormat(result.entry, downloadUrl, options);
1350
2376
  }
1351
2377
  } catch (e) {
1352
- lastError = e instanceof Error ? e : new Error(String(e));
2378
+ if (options.signal?.aborted) throw options.signal.reason;
2379
+ lastError = toError$1(e);
1353
2380
  }
1354
2381
  if (cached) return cached;
1355
2382
  if (lastError) throw lastError;
1356
2383
  }
1357
- /** Whether two version strings name the same SemVer version. */
1358
- function isExactVersion(have, wanted) {
1359
- const a = parseSemver(have);
1360
- const b = parseSemver(wanted);
1361
- return a !== null && b !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
1362
- }
1363
2384
  /**
1364
2385
  * Find the cached download that best answers a format request, without network access.
2386
+ * Only the downloads shared by name and version are searched, not those from direct URLs.
1365
2387
  * Matching follows {@link selectFormatCandidate}.
1366
2388
  */
1367
2389
  function findCachedFormat(request, options = {}) {
@@ -1373,18 +2395,11 @@ function getCachedFormat(name, version) {
1373
2395
  const formatPath = join(getCacheDir(), name, version, "format.js");
1374
2396
  try {
1375
2397
  if (!existsSync(formatPath)) return void 0;
1376
- const source = readFileSync(formatPath, "utf-8");
2398
+ const source = readUTF8(formatPath);
1377
2399
  const id = makeFormatId(name, version);
1378
2400
  const data = parseFormatJSON(source, id);
1379
2401
  if (!data) return void 0;
1380
- return {
1381
- id,
1382
- filename: formatPath,
1383
- isTwine2: true,
1384
- name: data.name,
1385
- version: data.version,
1386
- proofing: data.proofing ?? false
1387
- };
2402
+ return cachedFormatInfo(id, formatPath, data);
1388
2403
  } catch {
1389
2404
  return;
1390
2405
  }
@@ -1469,21 +2484,27 @@ function listCachedFormats() {
1469
2484
  }
1470
2485
  /**
1471
2486
  * Clear all cached formats, or only those matching a given name. Returns the number of entries removed.
2487
+ * Clearing all also removes the downloads from direct format URLs; a name clears only the
2488
+ * downloads shared by name and version.
1472
2489
  * A name must be a single cache entry name (as `listCachedFormats()` reports it); anything with a
1473
2490
  * path separator or `.`/`..` throws instead of deleting outside the format's own directory.
1474
2491
  */
1475
2492
  function clearCachedFormats(name) {
1476
2493
  if (name && !isSafeSegment(name)) throw new Error(`Refusing to clear ${JSON.stringify(name)}: it is not a cached format name. Use a name as "cache list" shows it.`);
1477
2494
  const cacheDir = getCacheDir();
1478
- if (!existsSync(cacheDir)) return 0;
1479
2495
  if (!name) {
1480
- const count = listCachedFormats().length;
2496
+ const count = listCachedFormats().length + countUrlCacheEntries();
1481
2497
  rmSync(cacheDir, {
1482
2498
  recursive: true,
1483
2499
  force: true
1484
2500
  });
2501
+ rmSync(getUrlCacheDir(), {
2502
+ recursive: true,
2503
+ force: true
2504
+ });
1485
2505
  return count;
1486
2506
  }
2507
+ if (!existsSync(cacheDir)) return 0;
1487
2508
  const root = resolve(cacheDir);
1488
2509
  const nameDir = resolve(root, name);
1489
2510
  if (!isInside(root, nameDir)) throw new Error(`Refusing to clear ${JSON.stringify(name)}: it is not a cached format name.`);
@@ -1501,6 +2522,15 @@ function clearCachedFormats(name) {
1501
2522
  });
1502
2523
  return count;
1503
2524
  }
2525
+ /** The number of direct format URLs with a download in the cache. */
2526
+ function countUrlCacheEntries() {
2527
+ const dir = getUrlCacheDir();
2528
+ try {
2529
+ return readdirSync(dir).filter((key) => existsSync(join(dir, key, "format.js"))).length;
2530
+ } catch {
2531
+ return 0;
2532
+ }
2533
+ }
1504
2534
  /** Get total cache size in bytes and format count. */
1505
2535
  function getCacheSize() {
1506
2536
  const entries = listCachedFormats();
@@ -1527,44 +2557,65 @@ function formatRequestFor(formatId, storyFormat, storyFormatVersion, defaultId)
1527
2557
  id: defaultId
1528
2558
  };
1529
2559
  }
1530
- /** Look a request up among the formats found in local format directories. */
1531
- function findLocalFormat(formats, request) {
2560
+ /**
2561
+ * Look a request up among the formats found in local format directories. An ID names a folder, so
2562
+ * it is looked up among all of them; a name request selects by SemVer among the pruned formats.
2563
+ */
2564
+ function findLocalFormat(all, pruned, request) {
1532
2565
  switch (request.kind) {
1533
- case "id": return formats.get(request.id);
1534
- case "name": {
1535
- const id = getFormatIdByNameAndVersion(formats, request.name, request.version);
1536
- return id === void 0 ? void 0 : formats.get(id);
1537
- }
2566
+ case "id": return findFormatById(all, request.id);
2567
+ case "name": return selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f);
1538
2568
  default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
1539
2569
  }
1540
2570
  }
1541
2571
  /**
1542
2572
  * Resolve a format request to a story format.
1543
2573
  *
1544
- * Order: local format directories, then the download cache, then remote sources (unless
1545
- * `noRemote`). When a StoryData request names a version that none of them has, an older version of
2574
+ * Order: local format directories, then the project's direct format URLs (each one's cached
2575
+ * copy, else a download unless `noRemote`), then the download cache shared by name and version,
2576
+ * then format indices (unless `noRemote`). Names and IDs match the same way in each (see
2577
+ * `selectFormatCandidate`), so a request that a local format answers never reaches the cache or the
2578
+ * network. Local formats that cannot be used are reported as warnings. When a StoryData request
2579
+ * names a version that none of them has, an older version of
1546
2580
  * the same format and major version is used with a warning. Anything else is an error diagnostic,
1547
2581
  * and the result is `undefined`.
1548
2582
  */
1549
2583
  async function resolveStoryFormat(request, options, diagnostics) {
1550
2584
  const noRemote = options.noRemote ?? false;
1551
- const formats = discoverFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true));
2585
+ const all = discoverAllFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true), diagnostics);
2586
+ const pruned = pruneFormats(all);
1552
2587
  const wanted = describeFormatRequest(request);
1553
- const local = findLocalFormat(formats, request);
2588
+ const local = findLocalFormat(all, pruned, request);
1554
2589
  if (local) return local;
2590
+ const fetchOptions = {
2591
+ signal: options.signal,
2592
+ timeout: options.formatFetchTimeout
2593
+ };
2594
+ const warnOnFailure = async (lookup) => {
2595
+ try {
2596
+ return await lookup();
2597
+ } catch (e) {
2598
+ if (options.signal?.aborted) throw options.signal.reason;
2599
+ diagnostics.push({
2600
+ level: "warning",
2601
+ message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
2602
+ });
2603
+ return;
2604
+ }
2605
+ };
2606
+ const direct = await warnOnFailure(() => resolveFormatUrls(request, options.formatUrls ?? [], {
2607
+ ...fetchOptions,
2608
+ offline: noRemote
2609
+ }));
2610
+ if (direct) return direct;
1555
2611
  const cached = findCachedFormat(request);
1556
2612
  if (cached) return cached;
1557
- if (!noRemote) try {
1558
- const remote = await resolveRemoteFormatRequest(request, options.formatIndices, options.formatUrls);
2613
+ if (!noRemote) {
2614
+ const remote = await warnOnFailure(() => resolveRemoteFormatRequest(request, options.formatIndices, [], fetchOptions));
1559
2615
  if (remote) return remote;
1560
- } catch (e) {
1561
- diagnostics.push({
1562
- level: "warning",
1563
- message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
1564
- });
1565
2616
  }
1566
2617
  if (request.kind === "name") {
1567
- const older = selectFormatCandidate(request, [...formats.values()].filter((f) => f.isTwine2), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
2618
+ const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
1568
2619
  if (older) {
1569
2620
  diagnostics.push({
1570
2621
  level: "warning",
@@ -1576,7 +2627,7 @@ async function resolveStoryFormat(request, options, diagnostics) {
1576
2627
  const reason = noRemote ? " (remote fetching disabled)" : "";
1577
2628
  diagnostics.push({
1578
2629
  level: "error",
1579
- message: `Story format ${wanted} is not available${reason}. Found: ${[...formats.keys()].join(", ") || "none"}`
2630
+ message: `Story format ${wanted} is not available${reason}. Found: ${[...all.keys()].join(", ") || "none"}`
1580
2631
  });
1581
2632
  }
1582
2633
  //#endregion
@@ -1871,12 +2922,14 @@ function twee2ToV3(s) {
1871
2922
  * Parse Twee source text into passages.
1872
2923
  *
1873
2924
  * The source is normalized first, as files are when they are read: a leading UTF-8 BOM is
1874
- * removed and CRLF and bare CR line endings become LF.
2925
+ * removed and CRLF and bare CR line endings become LF. A BOM at the start of a later line,
2926
+ * directly before `::` (left there by concatenating files), is removed too, so that line
2927
+ * stays a passage header.
1875
2928
  */
1876
2929
  function parseTwee(source, options = {}) {
1877
2930
  const { filename = "<inline>", trim = true, twee2Compat = false } = options;
1878
2931
  const diagnostics = [];
1879
- const normalized = normalizeSourceText(source);
2932
+ const normalized = normalizeTweeSourceText(source);
1880
2933
  const tweeSource = twee2Compat ? twee2ToV3(normalized) : normalized;
1881
2934
  const passages = [];
1882
2935
  let current = null;
@@ -4713,15 +5766,28 @@ function parseDocument(data, options) {
4713
5766
  /**
4714
5767
  * Parse a Twine 2 or Twine 1 compiled HTML file back into a Story model.
4715
5768
  * Tweego always trims passage text; here `trim: false` keeps it exactly, as for Twee sources.
5769
+ * A missing or invalid `tw-storydata` IFID is reported as a warning; an invalid one is kept as
5770
+ * written (uppercased), a missing one stays empty.
4716
5771
  */
4717
5772
  function decompileHTML(html, options = {}) {
5773
+ return decompile(html, options, { ifid: true });
5774
+ }
5775
+ /**
5776
+ * `decompileHTML()` for loading an HTML file into a compile. The importer's StoryData passage is
5777
+ * checked when it is added to the story being compiled, which reports a missing or invalid IFID,
5778
+ * so the IFID is not reported here as well.
5779
+ */
5780
+ function decompileHTMLForImport(html, options = {}) {
5781
+ return decompile(html, options, { ifid: false });
5782
+ }
5783
+ function decompile(html, options, checks) {
4718
5784
  const diagnostics = [];
4719
5785
  const story = createStory();
4720
5786
  const passageText = options.trim === false ? (text) => text : (text) => text.trim();
4721
5787
  const doc = parseDocument(html);
4722
5788
  const twine2Data = findElement(doc, "tw-storydata");
4723
5789
  if (twine2Data) {
4724
- decompileTwine2(twine2Data, story, passageText, diagnostics);
5790
+ decompileTwine2(twine2Data, story, passageText, checks, diagnostics);
4725
5791
  return {
4726
5792
  story,
4727
5793
  diagnostics
@@ -4744,7 +5810,7 @@ function decompileHTML(html, options = {}) {
4744
5810
  diagnostics
4745
5811
  };
4746
5812
  }
4747
- function decompileTwine2(storyData, story, passageText, diagnostics) {
5813
+ function decompileTwine2(storyData, story, passageText, checks, diagnostics) {
4748
5814
  let startnode = 0;
4749
5815
  const attrs = storyData.attribs ?? {};
4750
5816
  if (attrs["name"]) story.name = attrs["name"];
@@ -4757,6 +5823,7 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
4757
5823
  else startnode = parsed;
4758
5824
  }
4759
5825
  if (attrs["ifid"]) story.ifid = normalizeIFID(attrs["ifid"]);
5826
+ if (checks.ifid) diagnostics.push(...storyDataIFIDDiagnostics(attrs["ifid"] ?? ""));
4760
5827
  if (attrs["zoom"]) {
4761
5828
  const parsed = parseFloat(attrs["zoom"]);
4762
5829
  if (Number.isNaN(parsed)) diagnostics.push({
@@ -4779,13 +5846,17 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
4779
5846
  const content = getTextContent(node);
4780
5847
  if (content.trim().length === 0) continue;
4781
5848
  const text = passageText(content);
4782
- const name = freeName(node.name === "style" ? "Story Stylesheet" : "Story JavaScript", takenNames);
5849
+ const base = node.name === "style" ? "Story Stylesheet" : "Story JavaScript";
5850
+ const name = freeName(base, (n) => !takenNames.has(n));
4783
5851
  takenNames.add(name);
4784
- storyAdd(story, {
5852
+ storyAdd(story, withGeneratedName({
4785
5853
  name,
4786
5854
  tags: node.name === "style" ? ["stylesheet"] : ["script"],
4787
5855
  text
4788
- }, diagnostics);
5856
+ }, {
5857
+ kind: "code",
5858
+ base
5859
+ }), diagnostics);
4789
5860
  break;
4790
5861
  }
4791
5862
  case "tw-tag": {
@@ -4832,19 +5903,26 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
4832
5903
  function passageDataName(node) {
4833
5904
  return node.attribs?.["name"] ?? "";
4834
5905
  }
4835
- /** The first of `base`, `base 2`, `base 3`, … that is not in `taken`. */
4836
- function freeName(base, taken) {
4837
- if (!taken.has(base)) return base;
4838
- for (let n = 2;; n++) {
4839
- const candidate = `${base} ${n}`;
4840
- if (!taken.has(candidate)) return candidate;
4841
- }
5906
+ /** Diagnostics for the `ifid` attribute of `tw-storydata`; `value` is empty when it is missing. */
5907
+ function storyDataIFIDDiagnostics(value) {
5908
+ if (value === "") return [{
5909
+ level: "warning",
5910
+ message: "Story IFID not found; the \"tw-storydata\" content attribute \"ifid\" is missing or empty."
5911
+ }];
5912
+ const err = validateIFID(value);
5913
+ if (err === null) return [];
5914
+ return [{
5915
+ level: "warning",
5916
+ message: `Cannot parse "tw-storydata" content attribute "ifid" as an IFID; value "${value}" (${err}).`
5917
+ }];
4842
5918
  }
4843
5919
  function decompileTwine1(storeArea, story, passageText, diagnostics) {
4844
5920
  const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
4845
5921
  const obfuscated = isRot13Obfuscated(passages);
4846
- for (const p of passages) storyAdd(story, obfuscated && p.name !== "StorySettings" ? {
5922
+ for (const p of passages) storyAdd(story, obfuscated && isObfuscatable(p) ? {
4847
5923
  ...p,
5924
+ name: rot13(p.name),
5925
+ tags: p.tags.map(rot13),
4848
5926
  text: rot13(p.text)
4849
5927
  } : p, diagnostics);
4850
5928
  }
@@ -4989,7 +6067,12 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
4989
6067
  function loadInlineSources(story, sources, opts, diagnostics) {
4990
6068
  for (const source of sources) {
4991
6069
  if (typeof source === "string") continue;
4992
- const content = normalizeSourceText(typeof source.content === "string" ? source.content : source.content.toString("utf-8"));
6070
+ const decoded = typeof source.content === "string" ? {
6071
+ text: source.content,
6072
+ diagnostics: []
6073
+ } : decodeText(source.content, source.filename);
6074
+ diagnostics.push(...decoded.diagnostics);
6075
+ const content = normalizeSourceText(decoded.text);
4993
6076
  const ext = normalizedFileExt(source.filename);
4994
6077
  switch (ext) {
4995
6078
  case "":
@@ -5008,18 +6091,10 @@ function loadInlineSources(story, sources, opts, diagnostics) {
5008
6091
  break;
5009
6092
  }
5010
6093
  case "css":
5011
- storyAdd(story, {
5012
- name: basename(source.filename),
5013
- tags: ["stylesheet"],
5014
- text: content
5015
- }, diagnostics);
6094
+ storyAdd(story, codePassage(basename(source.filename), "stylesheet", content), diagnostics);
5016
6095
  break;
5017
6096
  case "js":
5018
- storyAdd(story, {
5019
- name: basename(source.filename),
5020
- tags: ["script"],
5021
- text: content
5022
- }, diagnostics);
6097
+ storyAdd(story, codePassage(basename(source.filename), "script", content), diagnostics);
5023
6098
  break;
5024
6099
  default: diagnostics.push({
5025
6100
  level: "warning",
@@ -5029,35 +6104,60 @@ function loadInlineSources(story, sources, opts, diagnostics) {
5029
6104
  }
5030
6105
  }
5031
6106
  function parseTweeFile(filename, opts) {
5032
- const result = parseTwee(readUTF8(filename), {
6107
+ const readDiagnostics = [];
6108
+ const result = parseTwee(readUTF8(filename, readDiagnostics), {
5033
6109
  filename,
5034
6110
  trim: opts.trim ?? true,
5035
6111
  twee2Compat: opts.twee2Compat ?? false
5036
6112
  });
5037
6113
  return {
5038
6114
  passages: result.passages,
5039
- diagnostics: [...result.diagnostics]
6115
+ diagnostics: [...readDiagnostics, ...result.diagnostics]
5040
6116
  };
5041
6117
  }
6118
+ /**
6119
+ * A stylesheet or script passage named after its file, as Tweego names it. The name only labels
6120
+ * the code, so when another passage has it, the passage takes a free one (see `storyAdd()`).
6121
+ */
6122
+ function codePassage(name, tag, text) {
6123
+ return withGeneratedName({
6124
+ name,
6125
+ tags: [tag],
6126
+ text
6127
+ }, {
6128
+ kind: "code",
6129
+ base: name
6130
+ });
6131
+ }
6132
+ /** Mark every passage of a parse result as a code passage named after its file. */
6133
+ function codeNames(result) {
6134
+ for (const p of result.passages) withGeneratedName(p, {
6135
+ kind: "code",
6136
+ base: p.name
6137
+ });
6138
+ return result;
6139
+ }
5042
6140
  function parseTaggedFile(tag, filename) {
5043
- const source = readUTF8(filename);
6141
+ const diagnostics = [];
6142
+ const source = readUTF8(filename, diagnostics);
5044
6143
  return {
5045
- passages: [{
5046
- name: basename(filename),
5047
- tags: [tag],
5048
- text: source
5049
- }],
5050
- diagnostics: []
6144
+ passages: [codePassage(basename(filename), tag, source)],
6145
+ diagnostics
5051
6146
  };
5052
6147
  }
5053
6148
  function parseMediaFile(tag, filename) {
5054
6149
  const source = readBase64(filename);
6150
+ const name = baseNameWithoutExt(filename);
5055
6151
  return {
5056
- passages: [{
5057
- name: baseNameWithoutExt(filename),
6152
+ passages: [withGeneratedName({
6153
+ name,
5058
6154
  tags: [tag],
5059
6155
  text: `data:${mediaTypeFromFilename(filename)};base64,${source}`
5060
- }],
6156
+ }, {
6157
+ kind: "media",
6158
+ base: name,
6159
+ file: filename
6160
+ })],
5061
6161
  diagnostics: []
5062
6162
  };
5063
6163
  }
@@ -5066,24 +6166,27 @@ function parseFontFile(filename) {
5066
6166
  const name = basename(filename);
5067
6167
  const family = baseNameWithoutExt(filename);
5068
6168
  const ext = normalizedFileExt(filename);
5069
- return {
6169
+ const mediaType = mediaTypeFromExt(ext);
6170
+ const hint = fontFormatHint(ext);
6171
+ return codeNames({
5070
6172
  passages: [{
5071
6173
  name,
5072
6174
  tags: ["stylesheet"],
5073
- text: `@font-face {\n\tfont-family: "${family}";\n\tsrc: url("data:${mediaTypeFromExt(ext)};base64,${source}") format("${fontFormatHint(ext)}");\n}`
6175
+ text: `@font-face {\n\tfont-family: "${cssStringEscape(family)}";\n\tsrc: url("data:${mediaType};base64,${source}") format("${hint}");\n}`
5074
6176
  }],
5075
6177
  diagnostics: []
5076
- };
6178
+ });
5077
6179
  }
5078
6180
  function parseHTMLFile(filename, opts) {
5079
- const { story, diagnostics } = decompileHTML(readUTF8(filename), { trim: opts.trim ?? true });
6181
+ const readDiagnostics = [];
6182
+ const { story, diagnostics } = decompileHTMLForImport(readUTF8(filename, readDiagnostics), { trim: opts.trim ?? true });
5080
6183
  return {
5081
6184
  passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
5082
6185
  name: "StoryTitle",
5083
6186
  tags: [],
5084
6187
  text: story.name
5085
6188
  }, ...story.passages] : story.passages,
5086
- diagnostics: [...diagnostics]
6189
+ diagnostics: [...readDiagnostics, ...diagnostics]
5087
6190
  };
5088
6191
  }
5089
6192
  /**
@@ -5156,6 +6259,7 @@ function loadTwee(story, filename, opts, diagnostics) {
5156
6259
  }
5157
6260
  function loadTagged(story, tag, filename, diagnostics) {
5158
6261
  const result = parseTaggedFile(tag, filename);
6262
+ diagnostics.push(...result.diagnostics);
5159
6263
  for (const p of result.passages) storyAdd(story, p, diagnostics);
5160
6264
  }
5161
6265
  function loadMedia(story, tag, filename, diagnostics) {
@@ -5172,6 +6276,7 @@ function loadFont(story, filename, diagnostics) {
5172
6276
  */
5173
6277
  function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
5174
6278
  const currentFiles = /* @__PURE__ */ new Set();
6279
+ const changedPaths = changedFiles === void 0 ? void 0 : new Set([...changedFiles].map((f) => resolve(f)));
5175
6280
  for (const filename of filenames) {
5176
6281
  if (processedFiles.has(filename)) {
5177
6282
  diagnostics.push({
@@ -5184,8 +6289,8 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5184
6289
  const optionsKey = parseOptionsKey(filename, opts);
5185
6290
  const entry = cache.get(filename);
5186
6291
  const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
5187
- const changed = changedFiles?.has(filename) ?? false;
5188
- if (changedFiles && cached && !changed) {
6292
+ const changed = changedPaths?.has(resolve(filename)) ?? false;
6293
+ if (changedPaths && cached && !changed) {
5189
6294
  diagnostics.push(...cached.diagnostics);
5190
6295
  for (const p of cached.passages) storyAdd(story, p, diagnostics);
5191
6296
  processedFiles.add(filename);
@@ -5195,6 +6300,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5195
6300
  try {
5196
6301
  mtimeMs = statSync(filename).mtimeMs;
5197
6302
  } catch {
6303
+ cache.delete(filename);
5198
6304
  continue;
5199
6305
  }
5200
6306
  if (cached && !changed && cached.mtimeMs === mtimeMs) {
@@ -5215,6 +6321,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5215
6321
  diagnostics.push(...result.diagnostics);
5216
6322
  for (const p of result.passages) storyAdd(story, p, diagnostics);
5217
6323
  } catch (e) {
6324
+ cache.delete(filename);
5218
6325
  diagnostics.push({
5219
6326
  level: "error",
5220
6327
  message: `load ${filename}: ${e instanceof Error ? e.message : String(e)}`,
@@ -5238,6 +6345,11 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5238
6345
  * This is how the HTML tokenizer reads an end tag; it ignores whatever sits between the tag name and the `>`.
5239
6346
  */
5240
6347
  const CLOSING_HEAD_TAG = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>";
6348
+ /**
6349
+ * The start of an HTML body start tag: `<body` in any letter case, followed by whitespace, `/` or `>` (so not
6350
+ * `<bodyx>`). Only the `<body` is matched; the attributes and `>` after it are left alone.
6351
+ */
6352
+ const BODY_START_TAG = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
5241
6353
  /** The regular expression source matching exactly `text`. */
5242
6354
  function literal(text) {
5243
6355
  return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
@@ -5271,9 +6383,10 @@ function fillTemplateParts(parts) {
5271
6383
  //#endregion
5272
6384
  //#region src/modules.ts
5273
6385
  /**
5274
- * Load modules and return HTML tags to inject before the closing head tag.
6386
+ * Load modules and return HTML tags to inject before the closing head tag. `diagnostics` receives a warning for
6387
+ * each module that is not valid UTF-8 (it is read as Windows-1252).
5275
6388
  */
5276
- function loadModules(filenames) {
6389
+ function loadModules(filenames, diagnostics) {
5277
6390
  const processed = /* @__PURE__ */ new Set();
5278
6391
  const headTags = [];
5279
6392
  for (const filename of filenames) {
@@ -5282,10 +6395,10 @@ function loadModules(filenames) {
5282
6395
  let tag = null;
5283
6396
  switch (ext) {
5284
6397
  case "css":
5285
- tag = loadModuleTagged("style", filename);
6398
+ tag = loadModuleTagged("style", filename, diagnostics);
5286
6399
  break;
5287
6400
  case "js":
5288
- tag = loadModuleTagged("script", filename);
6401
+ tag = loadModuleTagged("script", filename, diagnostics);
5289
6402
  break;
5290
6403
  case "otf":
5291
6404
  case "ttf":
@@ -5300,8 +6413,8 @@ function loadModules(filenames) {
5300
6413
  }
5301
6414
  return headTags.join("\n");
5302
6415
  }
5303
- function loadModuleTagged(tag, filename) {
5304
- const source = readUTF8(filename).trim();
6416
+ function loadModuleTagged(tag, filename, diagnostics) {
6417
+ const source = readUTF8(filename, diagnostics).trim();
5305
6418
  if (source.length === 0) return null;
5306
6419
  return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
5307
6420
  }
@@ -5310,20 +6423,22 @@ function loadModuleFont(filename) {
5310
6423
  const family = baseNameWithoutExt(filename);
5311
6424
  const idSlug = `style-module-${slugify(family)}`;
5312
6425
  const ext = normalizedFileExt(filename);
5313
- 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>`;
6426
+ const mediaType = mediaTypeFromExt(ext);
6427
+ const hint = fontFormatHint(ext);
6428
+ 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>`;
5314
6429
  }
5315
6430
  /**
5316
- * Load the module tags and head file content to inject before the closing head tag, joined by newlines,
6431
+ * Load the module tags and head file content to inject into the head (see `placeHead()`), joined by newlines,
5317
6432
  * or `''` when there is nothing to inject.
5318
6433
  */
5319
6434
  function loadHeadContent(modulePaths, headFile, diagnostics) {
5320
6435
  const parts = [];
5321
6436
  if (modulePaths.length > 0) {
5322
- const modules = loadModules(modulePaths).trim();
6437
+ const modules = loadModules(modulePaths, diagnostics).trim();
5323
6438
  if (modules.length > 0) parts.push(modules);
5324
6439
  }
5325
6440
  if (headFile) try {
5326
- const source = readUTF8(headFile).trim();
6441
+ const source = readUTF8(headFile, diagnostics).trim();
5327
6442
  if (source.length > 0) parts.push(source);
5328
6443
  } catch (e) {
5329
6444
  diagnostics?.push({
@@ -5334,16 +6449,51 @@ function loadHeadContent(modulePaths, headFile, diagnostics) {
5334
6449
  return parts.join("\n");
5335
6450
  }
5336
6451
  /**
5337
- * The template slot that inserts `content` on its own line before the first closing head tag (as Tweego does),
5338
- * or `undefined` when `content` is empty. The tag may use any letter case and whitespace before its `>`, and is
5339
- * kept as written.
6452
+ * Place `content` in a template, given the texts that `fillTemplateParts()` scans for the slot (the template, and
6453
+ * the footer of a pre-1.4 Twine 1 format):
6454
+ *
6455
+ * - on its own line before the first closing head tag, as Tweego does; the tag may use any letter case and
6456
+ * whitespace before its `>`, and is kept as written;
6457
+ * - in a template with no closing head tag (HTML lets a document leave it out), on its own line before the first
6458
+ * body start tag, where the HTML parser still puts it in the head, with a warning (Tweego drops it);
6459
+ * - in a template with neither, nowhere, with a warning.
6460
+ *
6461
+ * `owner` names the template in the warnings. Empty `content` needs no slot and gets no warning.
5340
6462
  */
5341
- function headSlot(content) {
5342
- if (content.length === 0) return void 0;
6463
+ function placeHead(content, texts, owner) {
6464
+ if (content.length === 0) return {
6465
+ slot: void 0,
6466
+ diagnostics: []
6467
+ };
6468
+ const found = (tag) => texts.some((text) => new RegExp(tag).test(text));
6469
+ if (found("<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>")) return {
6470
+ slot: beforeTag(CLOSING_HEAD_TAG, content),
6471
+ diagnostics: []
6472
+ };
6473
+ if (found("<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])")) {
6474
+ const message = `${owner} has no closing head tag; the modules and head file were injected before its body start tag.`;
6475
+ return {
6476
+ slot: beforeTag(BODY_START_TAG, content),
6477
+ diagnostics: [{
6478
+ level: "warning",
6479
+ message
6480
+ }]
6481
+ };
6482
+ }
6483
+ return {
6484
+ slot: void 0,
6485
+ diagnostics: [{
6486
+ level: "warning",
6487
+ message: `${owner} has no closing head tag and no body start tag; the modules and head file were not injected.`
6488
+ }]
6489
+ };
6490
+ }
6491
+ /** The slot that inserts `content` on its own line before the first match of `tag`, which is kept as written. */
6492
+ function beforeTag(tag, content) {
5343
6493
  return {
5344
- pattern: CLOSING_HEAD_TAG,
6494
+ pattern: tag,
5345
6495
  occurrences: "first",
5346
- replacement: (tag) => `${content}\n${tag}`
6496
+ replacement: (match) => `${content}\n${match}`
5347
6497
  };
5348
6498
  }
5349
6499
  //#endregion
@@ -5382,14 +6532,19 @@ function toTwine2Archive(story, startName, options) {
5382
6532
  }
5383
6533
  /**
5384
6534
  * Fill the Twine 2 format template: every `{{STORY_NAME}}` gets the escaped story name and the first
5385
- * `{{STORY_DATA}}` the story data (as Tweego does), and `head` goes before the first closing head tag. All three are
5386
- * found in the template in one pass, so a story name, passage text or head content holding a placeholder or a
5387
- * closing head tag stays literal.
6535
+ * `{{STORY_DATA}}` the story data (as Tweego does), and `head` goes before the first closing head tag (or, with a
6536
+ * warning, the body start tag; see `placeHead()`). All three are found in the template in one pass, so a story
6537
+ * name, passage text or head content holding a placeholder or a closing head tag stays literal.
6538
+ * `options.diagnostics` receives a warning when the format file is not valid UTF-8, and any warning about where
6539
+ * `head` went.
5388
6540
  */
5389
6541
  function toTwine2HTML(story, format, startName, options) {
5390
6542
  const name = htmlEscape(story.name);
5391
- const head = headSlot(options?.head ?? "");
5392
- const slots = [
6543
+ const template = readFormatSource(format, options?.diagnostics);
6544
+ const placement = placeHead(options?.head ?? "", [template], `Story format "${format.name}" ${format.version}`);
6545
+ options?.diagnostics?.push(...placement.diagnostics);
6546
+ const head = placement.slot;
6547
+ return fillTemplate(template, [
5393
6548
  {
5394
6549
  pattern: literal("{{STORY_NAME}}"),
5395
6550
  occurrences: "all",
@@ -5410,8 +6565,7 @@ function toTwine2HTML(story, format, startName, options) {
5410
6565
  }
5411
6566
  },
5412
6567
  ...head === void 0 ? [] : [head]
5413
- ];
5414
- return fillTemplate(readFormatSource(format), slots);
6568
+ ]);
5415
6569
  }
5416
6570
  const OMITTING_TAGS = [
5417
6571
  "Twine.private",
@@ -5507,29 +6661,35 @@ function toTwine1Archive(story, _startName) {
5507
6661
  /**
5508
6662
  * Fill the Twine 1 format template. The format's components are inserted first, one after another, as Tweego and
5509
6663
  * Twine 1 do. Then the first of each story placeholder (`"VERSION"`, `"TIME"`, `"START_AT"`, `"STORY_SIZE"`,
5510
- * `"STORY"`), the IFID comment and `head` (before the first closing head tag) are found in one pass, so a start
5511
- * passage name or story data holding a placeholder or a closing head tag stays literal.
6664
+ * `"STORY"`), the IFID comment and `head` (before the first closing head tag or, with a warning, the body start
6665
+ * tag; see `placeHead()`) are found in one pass, so a start passage name or story data holding a placeholder or a
6666
+ * closing head tag stays literal. `options.diagnostics` receives a warning for each format file that is not valid
6667
+ * UTF-8, and any warning about where `head` went.
5512
6668
  */
5513
6669
  function toTwine1HTML(story, format, startName, options) {
5514
6670
  const formatDir = dirname(format.filename);
5515
6671
  const parentDir = dirname(formatDir);
5516
- let template = readFormatSource(format);
6672
+ const diagnostics = options?.diagnostics;
6673
+ let template = readFormatSource(format, diagnostics);
5517
6674
  const { data, count } = getTwine1PassageChunk(story);
5518
- template = tryReplaceComponent(template, "\"USER_LIB\"", join(formatDir, "userlib.js"), false);
5519
- template = tryReplaceComponent(template, "\"ENGINE\"", join(parentDir, "engine.js"), true);
5520
- template = tryReplaceComponent(template, "\"SUGARCANE\"", join(formatDir, "code.js"), true);
5521
- template = tryReplaceComponent(template, "\"JONAH\"", join(formatDir, "code.js"), true);
5522
- if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", join(parentDir, "jquery.js"), true);
5523
- if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", join(parentDir, "modernizr.js"), true);
6675
+ template = tryReplaceComponent(template, "\"USER_LIB\"", join(formatDir, "userlib.js"), false, diagnostics);
6676
+ template = tryReplaceComponent(template, "\"ENGINE\"", join(parentDir, "engine.js"), true, diagnostics);
6677
+ template = tryReplaceComponent(template, "\"SUGARCANE\"", join(formatDir, "code.js"), true, diagnostics);
6678
+ template = tryReplaceComponent(template, "\"JONAH\"", join(formatDir, "code.js"), true, diagnostics);
6679
+ if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", join(parentDir, "jquery.js"), true, diagnostics);
6680
+ if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", join(parentDir, "modernizr.js"), true, diagnostics);
5524
6681
  const isPre14 = !template.includes("\"STORY\"");
5525
- const footer = isPre14 ? readFooter(formatDir) : "";
6682
+ const footer = isPre14 ? readFooter(formatDir, diagnostics) : "";
5526
6683
  const storeArea = (template + footer).includes("<div id=\"store-area\"") ? "<div id=\"store-area\"" : "<div id=\"storeArea\"";
5527
6684
  const safeIfid = htmlCommentSanitize(story.ifid);
5528
- const late = [story.ifid ? {
6685
+ const ifid = story.ifid ? {
5529
6686
  pattern: literal(storeArea),
5530
6687
  occurrences: "first",
5531
6688
  replacement: (div) => `<!-- UUID://${safeIfid}// -->${div}`
5532
- } : void 0, headSlot(options?.head ?? "")].filter((slot) => slot !== void 0);
6689
+ } : void 0;
6690
+ const placement = placeHead(options?.head ?? "", isPre14 ? [template, footer] : [template], `Story format "${format.name}"`);
6691
+ diagnostics?.push(...placement.diagnostics);
6692
+ const late = [ifid, placement.slot].filter((slot) => slot !== void 0);
5533
6693
  const displayStart = startName === "Start" ? "" : startName;
5534
6694
  const slots = [
5535
6695
  firstSlot("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`),
@@ -5569,9 +6729,9 @@ function firstSlot(token, value) {
5569
6729
  };
5570
6730
  }
5571
6731
  /** The footer of a pre-1.4 format, or the default one when the format has none. */
5572
- function readFooter(formatDir) {
6732
+ function readFooter(formatDir, diagnostics) {
5573
6733
  try {
5574
- return readFileSync(join(formatDir, "footer.html"), "utf-8");
6734
+ return readUTF8(join(formatDir, "footer.html"), diagnostics);
5575
6735
  } catch {
5576
6736
  return "</div>\n</body>\n</html>\n";
5577
6737
  }
@@ -5599,11 +6759,10 @@ function getTwine1PassageChunk(story) {
5599
6759
  count
5600
6760
  };
5601
6761
  }
5602
- function tryReplaceComponent(template, placeholder, componentPath, required) {
6762
+ function tryReplaceComponent(template, placeholder, componentPath, required, diagnostics) {
5603
6763
  if (!template.includes(placeholder)) return template;
5604
6764
  try {
5605
- let content = readFileSync(componentPath, "utf-8");
5606
- if (content.charCodeAt(0) === 65279) content = content.slice(1);
6765
+ const content = readUTF8(componentPath, diagnostics);
5607
6766
  return template.replace(placeholder, () => content);
5608
6767
  } catch (e) {
5609
6768
  if (required) throw new Error(`Required format component not found: ${componentPath}: ${e instanceof Error ? e.message : String(e)}`);
@@ -5617,11 +6776,16 @@ function tryReplaceComponent(template, placeholder, componentPath, required) {
5617
6776
  * from its loaded text, so changes made after loading (a start passage override, test mode) are kept.
5618
6777
  * Tweego writes the loaded text; its own normalization makes the two the same for an unchanged story.
5619
6778
  *
6779
+ * Twee cannot write every passage so that it reads back the same (see `tweeRoundTrip()`); `diagnostics`
6780
+ * receives a warning for each passage that will not. Tweego writes the same Twee without one.
6781
+ *
5620
6782
  * `addStoryData`: add a StoryData passage when the story has none, so that StoryData fields the
5621
6783
  * compile options set are recorded. Default: false.
5622
6784
  */
5623
6785
  function toTwee(story, outMode, options = {}) {
5624
- return withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => passageToTwee(p, outMode)).join("");
6786
+ const written = withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => tweeRoundTrip(p, outMode));
6787
+ options.diagnostics?.push(...written.flatMap(({ diagnostics }) => diagnostics));
6788
+ return written.map(({ passage }) => passageToTwee(passage, outMode)).join("");
5625
6789
  }
5626
6790
  function withEffectiveStoryData(story, addStoryData) {
5627
6791
  const storyData = {
@@ -5646,6 +6810,64 @@ function withEffectiveStoryData(story, addStoryData) {
5646
6810
  function isStoryDataJSON(text) {
5647
6811
  return unmarshalStoryData(createStory(), text) === null;
5648
6812
  }
6813
+ /** Characters Twee 3 escapes in names and tags, and Twee 1 cannot write there at all. */
6814
+ const TWEE1_UNWRITABLE = /[[\]{}\\]/;
6815
+ /**
6816
+ * Check that Twee reads `p` back as written, and say what it will not. Twee has no way to escape passage text,
6817
+ * and its parser trims names and splits tags at whitespace, so these do not survive:
6818
+ *
6819
+ * - a text line that starts with `::`, which reads as a new passage header. In stylesheet and script passages such
6820
+ * a line is indented by one space, which keeps the CSS or JavaScript working (only a template literal or a
6821
+ * string continued across lines, in either, would see the space); the warning says so. Other passages' text is
6822
+ * written as it is.
6823
+ * - a name that is empty, has leading or trailing whitespace, or holds a line break;
6824
+ * - a tag that is empty or holds whitespace;
6825
+ * - in Twee 1, which escapes nothing, a name or tag holding `[`, `]`, `{`, `}` or `\`.
6826
+ */
6827
+ function tweeRoundTrip(p, outMode) {
6828
+ const label = `Passage ${JSON.stringify(p.name)}`;
6829
+ const diagnostics = [];
6830
+ const warn = (problem) => {
6831
+ diagnostics.push({
6832
+ level: "warning",
6833
+ message: `${label} cannot be written as Twee that reads back the same: ${problem}.`
6834
+ });
6835
+ };
6836
+ if (p.name.trim() === "") warn("its name is empty");
6837
+ else if (p.name.trim() !== p.name) warn("its name has leading or trailing whitespace, which Twee drops");
6838
+ if (/[\r\n]/.test(p.name)) warn("its name has a line break, which ends a Twee passage header");
6839
+ if (outMode === "twee1" && TWEE1_UNWRITABLE.test(p.name)) warn("its name has \"[\", \"]\", \"{\", \"}\" or \"\\\", which Twee 1 cannot escape");
6840
+ for (const tag of p.tags) {
6841
+ if (tag === "" || /\s/.test(tag)) warn(`its tag ${JSON.stringify(tag)} is empty or has whitespace, which splits tags in Twee`);
6842
+ if (outMode === "twee1" && TWEE1_UNWRITABLE.test(tag)) warn(`its tag ${JSON.stringify(tag)} has "[", "]", "{", "}" or "\\", which Twee 1 cannot escape`);
6843
+ }
6844
+ const lines = p.text.split(/(\r\n?|\n)/);
6845
+ const headerLines = lines.flatMap((line, i) => i % 2 === 0 && line.startsWith("::") ? [i / 2 + 1] : []);
6846
+ if (headerLines.length === 0) return {
6847
+ passage: p,
6848
+ diagnostics
6849
+ };
6850
+ 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`;
6851
+ if (!p.tags.some((tag) => tag === "stylesheet" || tag === "script")) {
6852
+ warn(where);
6853
+ return {
6854
+ passage: p,
6855
+ diagnostics
6856
+ };
6857
+ }
6858
+ diagnostics.push({
6859
+ level: "warning",
6860
+ message: `${label}: ${where}; ${headerLines.length === 1 ? "it was" : "they were"} written indented by one space.`
6861
+ });
6862
+ const text = lines.map((line, i) => i % 2 === 0 && line.startsWith("::") ? ` ${line}` : line).join("");
6863
+ return {
6864
+ passage: {
6865
+ ...p,
6866
+ text
6867
+ },
6868
+ diagnostics
6869
+ };
6870
+ }
5649
6871
  //#endregion
5650
6872
  //#region src/passage-omission.ts
5651
6873
  /**
@@ -5712,42 +6934,52 @@ async function compile(options) {
5712
6934
  return buildOutput(options);
5713
6935
  }
5714
6936
  /**
5715
- * Compile and write to a file.
6937
+ * Compile and write to a file. The file is replaced atomically: a reader sees the previous
6938
+ * build or the new one, never part of it, and a failed write leaves the previous build.
5716
6939
  */
5717
6940
  async function compileToFile(options) {
5718
6941
  const result = await compileForOutputFile(options, options.outFile);
5719
- writeFileSync(options.outFile, result.output, "utf-8");
6942
+ writeFileAtomic(options.outFile, result.output);
5720
6943
  return result;
5721
6944
  }
5722
6945
  /**
5723
- * Builds as compileToFile() does for `outFile`, without writing it: the output may sit
6946
+ * Builds as compileToFile() does for `outputs`, without writing them: an output may sit
5724
6947
  * inside a source folder, so its last build is left out of the sources and modules and
5725
- * never read back. The caller decides whether the result is written. With no `outFile`
5726
- * (output to stdout, or none), this is compile(). With `cache`, files are cached as
5727
- * compileIncremental() caches them.
6948
+ * never read back. `outputs` is the one file the CLI writes, or every path a bundler's
6949
+ * build writes (see BuildOutputs). The caller decides whether the result is written.
6950
+ * With no outputs (output to stdout, or none), this is compile(). With `cache`, files
6951
+ * are cached as compileIncremental() caches them.
6952
+ *
6953
+ * Throws a TweeTsError when a source, module or head file named directly is an output.
5728
6954
  *
5729
6955
  * Internal, for the CLI and the bundler plugins; not part of the public API.
5730
6956
  */
5731
- async function compileForOutputFile(options, outFile, cache) {
5732
- return buildOutput(options, cache, void 0, outFile);
6957
+ async function compileForOutputFile(options, outputs, cache) {
6958
+ return buildOutput(options, cache, void 0, outputs);
5733
6959
  }
5734
6960
  /**
5735
6961
  * Compile with incremental caching support.
5736
6962
  * Plugins and advanced users can manage their own cache and changed-file tracking.
5737
6963
  *
5738
6964
  * Without `changedFiles`, a cached file is reused while its modification time is unchanged.
5739
- * With it, a file it names (by the path source discovery gives it, relative to the working
5740
- * directory) is always reparsed, whatever its modification time, and every other cached
5741
- * file is reused as it is.
6965
+ * With it, a file it names is always reparsed, whatever its modification time, and every
6966
+ * other cached file is reused as it is. A file may be named by an absolute path or by a path
6967
+ * relative to the working directory, with or without a leading `./`: entries are matched to
6968
+ * the source files by resolved path. A file that fails to load is dropped from the cache, so
6969
+ * the next build tries it again.
5742
6970
  */
5743
6971
  async function compileIncremental(options, cache, changedFiles) {
5744
6972
  return buildOutput(options, cache, changedFiles);
5745
6973
  }
5746
6974
  /**
5747
- * Watch for file changes and recompile. Every build is written to `outFile`, including
5748
- * one whose diagnostics report errors. Builds run one at a time: changes made during a
5749
- * build go into one follow-up build after it, and the superseded build is neither
5750
- * written nor reported.
6975
+ * Watch for file changes and recompile. Every build is written to `outFile` (replacing it
6976
+ * atomically, as compileToFile() does), including one whose diagnostics report errors.
6977
+ * Builds run one at a time, and each one is written and reported as it finishes, in order:
6978
+ * changes made during a build go into one follow-up build after it. A watched path that
6979
+ * can't be watched is passed to `onError`.
6980
+ *
6981
+ * Aborting the returned controller, or the `signal` in the options, stops watching and
6982
+ * aborts the story format requests of the build in progress.
5751
6983
  */
5752
6984
  async function watch(options) {
5753
6985
  return watchWithWriteFilter(options, () => true);
@@ -5759,9 +6991,13 @@ async function watch(options) {
5759
6991
  * Internal, for the CLI, which keeps the last good output when a rebuild reports errors;
5760
6992
  * not part of the public API.
5761
6993
  */
5762
- async function watchWithWriteFilter(options, shouldWrite) {
6994
+ async function watchWithWriteFilter(options, shouldWrite, hooks = {}) {
5763
6995
  const controller = new AbortController();
5764
6996
  const cache = /* @__PURE__ */ new Map();
6997
+ const buildOptions = {
6998
+ ...options,
6999
+ signal: controller.signal
7000
+ };
5765
7001
  const filePaths = options.sources.filter((s) => typeof s === "string");
5766
7002
  const modulePaths = options.modules ?? [];
5767
7003
  const headPaths = options.headFile ? [options.headFile] : [];
@@ -5779,13 +7015,20 @@ async function watchWithWriteFilter(options, shouldWrite) {
5779
7015
  };
5780
7016
  let building = false;
5781
7017
  let queued;
7018
+ const reportError = (error) => {
7019
+ try {
7020
+ options.onError?.(error);
7021
+ } catch (e) {
7022
+ console.error(`twee-ts watch: onError threw while handling "${error.message}": ${toError(e).message}`);
7023
+ }
7024
+ };
5782
7025
  const deliver = (outcome) => {
5783
7026
  try {
5784
7027
  if (!outcome.ok) throw outcome.error;
5785
- if (shouldWrite(outcome.result)) writeFileSync(options.outFile, outcome.result.output, "utf-8");
7028
+ if (shouldWrite(outcome.result)) writeFileAtomic(options.outFile, outcome.result.output);
5786
7029
  options.onBuild?.(outcome.result);
5787
7030
  } catch (e) {
5788
- options.onError?.(toError(e));
7031
+ reportError(toError(e));
5789
7032
  }
5790
7033
  };
5791
7034
  const drain = async (first) => {
@@ -5793,8 +7036,7 @@ async function watchWithWriteFilter(options, shouldWrite) {
5793
7036
  try {
5794
7037
  let request = first;
5795
7038
  while (request !== void 0) {
5796
- const current = request;
5797
- const outcome = await buildOutput(options, cache, current.changedFiles, options.outFile).then((result) => ({
7039
+ const outcome = await buildOutput(buildOptions, cache, request.changedFiles, options.outFile).then((result) => ({
5798
7040
  ok: true,
5799
7041
  result
5800
7042
  }), (e) => ({
@@ -5802,12 +7044,13 @@ async function watchWithWriteFilter(options, shouldWrite) {
5802
7044
  error: toError(e)
5803
7045
  }));
5804
7046
  if (controller.signal.aborted) return;
5805
- request = queued === void 0 ? void 0 : mergeBuildRequests(current, queued);
7047
+ deliver(outcome);
7048
+ request = queued;
5806
7049
  queued = void 0;
5807
- if (request === void 0) deliver(outcome);
5808
7050
  }
5809
7051
  } finally {
5810
7052
  building = false;
7053
+ hooks.onIdle?.();
5811
7054
  }
5812
7055
  };
5813
7056
  const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
@@ -5817,12 +7060,17 @@ async function watchWithWriteFilter(options, shouldWrite) {
5817
7060
  queued = queued === void 0 ? request : mergeBuildRequests(queued, request);
5818
7061
  return;
5819
7062
  }
5820
- drain(request);
5821
- }, ignore);
7063
+ drain(request).catch((e) => reportError(toError(e)));
7064
+ }, ignore, reportError);
7065
+ const outer = options.signal;
7066
+ const onOuterAbort = () => controller.abort(outer?.reason);
5822
7067
  controller.signal.addEventListener("abort", () => {
5823
7068
  queued = void 0;
5824
7069
  handle.close();
7070
+ outer?.removeEventListener("abort", onOuterAbort);
5825
7071
  });
7072
+ if (outer?.aborted) onOuterAbort();
7073
+ else outer?.addEventListener("abort", onOuterAbort, { once: true });
5826
7074
  return controller;
5827
7075
  }
5828
7076
  /** One build covering both requests: a full build if either is one, else every changed file. */
@@ -5834,10 +7082,13 @@ function toError(e) {
5834
7082
  return e instanceof Error ? e : new Error(String(e));
5835
7083
  }
5836
7084
  /**
5837
- * `outFile`: the file the build is written to, which source and module discovery
5838
- * skip (as Tweego does), so an output inside a source folder is never loaded back.
7085
+ * `outputs`: what the build writes (an output file's path, or a bundler's outputs),
7086
+ * which source and module discovery skip, so an output inside a source folder is
7087
+ * never loaded back. A source, module or head file named directly that is an output
7088
+ * is a TweeTsError, as in Tweego: writing the build would overwrite it.
5839
7089
  */
5840
- async function buildOutput(options, cache, changedFiles, outFile) {
7090
+ async function buildOutput(options, cache, changedFiles, outputs) {
7091
+ const written = toBuildOutputs(outputs);
5841
7092
  const diagnostics = [];
5842
7093
  const outputMode = options.outputMode ?? "html";
5843
7094
  const trim = options.trim ?? true;
@@ -5845,13 +7096,18 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5845
7096
  const testMode = options.testMode ?? false;
5846
7097
  const noRemote = options.noRemote ?? false;
5847
7098
  const sourceInfo = options.sourceInfo ?? false;
7099
+ options.signal?.throwIfAborted();
7100
+ const timeout = options.formatFetchTimeout;
7101
+ if (timeout !== void 0 && !(timeout >= 0)) throw new TweeTsError(`formatFetchTimeout must be 0 or more milliseconds, not ${timeout}.`);
5848
7102
  clearIndexCache();
5849
7103
  const filePaths = [];
5850
7104
  const inlineSources = [];
5851
7105
  for (const source of options.sources) if (typeof source === "string") filePaths.push(source);
5852
7106
  else inlineSources.push(source);
5853
- const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths, outFile, options.exclude);
5854
- diagnostics.push(...sourcePathDiagnostics);
7107
+ const sourcePaths = getFilenames(filePaths, written, options.exclude);
7108
+ diagnostics.push(...sourcePaths.diagnostics);
7109
+ rejectOutputSources(sourcePaths.outputSources, diagnostics);
7110
+ const sourceFilenames = sourcePaths.filenames;
5855
7111
  const story = createStory();
5856
7112
  const processedFiles = /* @__PURE__ */ new Set();
5857
7113
  if (cache) loadSourcesCached(story, sourceFilenames, {
@@ -5880,7 +7136,10 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5880
7136
  switch (outputMode) {
5881
7137
  case "twee3":
5882
7138
  case "twee1":
5883
- output = toTwee(story, outputMode, { addStoryData: Boolean(options.startPassage) || testMode });
7139
+ output = toTwee(story, outputMode, {
7140
+ addStoryData: Boolean(options.startPassage) || testMode,
7141
+ diagnostics
7142
+ });
5884
7143
  break;
5885
7144
  case "twine2-archive":
5886
7145
  output = toTwine2Archive(story, startName, { sourceInfo });
@@ -5898,13 +7157,20 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5898
7157
  level: "error",
5899
7158
  message: "Special passage \"StoryTitle\" not found."
5900
7159
  });
5901
- const modules = getFilenames(options.modules ?? [], outFile);
7160
+ const modules = getFilenames(options.modules ?? [], written);
5902
7161
  diagnostics.push(...modules.diagnostics);
7162
+ const { headFile } = options;
7163
+ const headIsOutput = headFile !== void 0 && outputPaths(written).isFile(realPathOf(headFile));
7164
+ rejectOutputSources([...modules.outputSources, ...headIsOutput ? [headFile] : []], diagnostics);
5903
7165
  const head = loadHeadContent(modules.filenames, options.headFile, diagnostics);
5904
7166
  output = format.isTwine2 ? toTwine2HTML(story, format, startName, {
5905
7167
  sourceInfo,
5906
- head
5907
- }) : toTwine1HTML(story, format, startName, { head });
7168
+ head,
7169
+ diagnostics
7170
+ }) : toTwine1HTML(story, format, startName, {
7171
+ head,
7172
+ diagnostics
7173
+ });
5908
7174
  break;
5909
7175
  }
5910
7176
  default: throw new TweeTsError(`Unhandled output mode: ${outputMode}`, diagnostics);
@@ -5921,6 +7187,16 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5921
7187
  stats
5922
7188
  };
5923
7189
  }
7190
+ /**
7191
+ * Throws for the first of `paths`, inputs named directly that are also an output:
7192
+ * writing the build would overwrite them. An output found while walking a source
7193
+ * folder is skipped instead (see getFilenames).
7194
+ */
7195
+ function rejectOutputSources(paths, diagnostics) {
7196
+ const [first] = paths;
7197
+ if (first === void 0) return;
7198
+ throw new TweeTsError(`path ${first}: Output file cannot be an input source.`, [...diagnostics]);
7199
+ }
5924
7200
  function ensureIFID(story, diagnostics) {
5925
7201
  if (story.ifid !== "") return;
5926
7202
  if (story.legacyIFID !== "") {
@@ -7490,7 +8766,7 @@ function storyInspect(story, options = {}) {
7490
8766
  if (isStoryPassage(p)) storyPassages.push(p.name);
7491
8767
  if (isInfoPassage(p)) infoPassages.push(p.name);
7492
8768
  tagsByPassage.set(p.name, [...p.tags]);
7493
- for (const tag of p.tags) {
8769
+ for (const tag of new Set(p.tags)) {
7494
8770
  tagSet.add(tag);
7495
8771
  let list = passagesByTag.get(tag);
7496
8772
  if (!list) {
@@ -7634,46 +8910,100 @@ const VALID_OUTPUT_MODES = [
7634
8910
  "json"
7635
8911
  ];
7636
8912
  const VALID_WORD_COUNT_METHODS = ["tweego", "whitespace"];
7637
- /** Load a config file from the given directory (default: cwd). Returns null if not found. */
7638
- function loadConfig(dir) {
8913
+ /** One entry per {@link TweeTsConfig} key: the compiler rejects a missing or an extra key here. */
8914
+ const CONFIG_KEY_SET = {
8915
+ sources: true,
8916
+ exclude: true,
8917
+ output: true,
8918
+ outputMode: true,
8919
+ formatId: true,
8920
+ startPassage: true,
8921
+ formatPaths: true,
8922
+ formatIndices: true,
8923
+ formatUrls: true,
8924
+ useTweegoPath: true,
8925
+ modules: true,
8926
+ headFile: true,
8927
+ trim: true,
8928
+ twee2Compat: true,
8929
+ testMode: true,
8930
+ noRemote: true,
8931
+ formatFetchTimeout: true,
8932
+ tagAliases: true,
8933
+ sourceInfo: true,
8934
+ wordCountMethod: true
8935
+ };
8936
+ /** The keys a config file may hold besides `$schema`; the JSON schema's `properties` list the same keys. */
8937
+ const CONFIG_KEYS = Object.keys(CONFIG_KEY_SET);
8938
+ /** The key that only references the JSON schema, for editors. */
8939
+ const SCHEMA_KEY = "$schema";
8940
+ /** A key folded so that spellings differing only in letter case, `-` or `_` compare equal. */
8941
+ const foldKey = (key) => key.toLowerCase().replace(/[-_]/g, "");
8942
+ /**
8943
+ * Load a config file from the given directory (default: cwd). Returns null if not found.
8944
+ *
8945
+ * @param diagnostics Receives warnings that do not stop the config from loading: keys the config does not
8946
+ * define, and a file that is not valid UTF-8 (it is read as Windows-1252).
8947
+ * @throws When the file is not valid JSON or fails {@link validateConfig}.
8948
+ */
8949
+ function loadConfig(dir, diagnostics) {
7639
8950
  const base = dir ?? process.cwd();
7640
8951
  const configPath = join(base, CONFIG_FILENAME);
7641
8952
  if (!existsSync(configPath)) return null;
8953
+ const readDiagnostics = [];
7642
8954
  let raw;
7643
8955
  try {
7644
- raw = readFileSync(configPath, "utf-8");
8956
+ raw = readUTF8(configPath, readDiagnostics);
7645
8957
  } catch {
7646
8958
  return null;
7647
8959
  }
7648
- let data;
7649
- try {
7650
- data = JSON.parse(raw);
7651
- } catch (e) {
7652
- throw new Error(`Invalid JSON in ${configPath}: ${e instanceof Error ? e.message : String(e)}`);
7653
- }
7654
- const errors = validateConfig(data);
7655
- if (errors.length > 0) throw new Error(`Invalid config in ${configPath}:\n ${errors.join("\n ")}`);
7656
- return data;
8960
+ const config = parseConfig(raw, configPath, readDiagnostics);
8961
+ diagnostics?.push(...readDiagnostics);
8962
+ return config;
7657
8963
  }
7658
- /** Load a config from a specific file path. */
7659
- function loadConfigFile(filePath) {
8964
+ /**
8965
+ * Load a config from a specific file path.
8966
+ *
8967
+ * @param diagnostics Receives warnings that do not stop the config from loading: keys the config does not
8968
+ * define, and a file that is not valid UTF-8 (it is read as Windows-1252).
8969
+ * @throws When the file cannot be read, is not valid JSON or fails {@link validateConfig}.
8970
+ */
8971
+ function loadConfigFile(filePath, diagnostics) {
8972
+ const readDiagnostics = [];
7660
8973
  let raw;
7661
8974
  try {
7662
- raw = readFileSync(filePath, "utf-8");
8975
+ raw = readUTF8(filePath, readDiagnostics);
7663
8976
  } catch (e) {
7664
8977
  throw new Error(`Cannot read config file ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
7665
8978
  }
8979
+ const config = parseConfig(raw, filePath, readDiagnostics);
8980
+ diagnostics?.push(...readDiagnostics);
8981
+ return config;
8982
+ }
8983
+ /**
8984
+ * Parse and validate the text of the config file at `path`. Unknown keys are added to `diagnostics` as
8985
+ * warnings, so a config with a stray key keeps loading.
8986
+ */
8987
+ function parseConfig(raw, path, diagnostics) {
7666
8988
  let data;
7667
8989
  try {
7668
8990
  data = JSON.parse(raw);
7669
8991
  } catch (e) {
7670
- throw new Error(`Invalid JSON in ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
8992
+ throw new Error(`Invalid JSON in ${path}: ${e instanceof Error ? e.message : String(e)}`);
7671
8993
  }
7672
8994
  const errors = validateConfig(data);
7673
- if (errors.length > 0) throw new Error(`Invalid config in ${filePath}:\n ${errors.join("\n ")}`);
8995
+ if (errors.length > 0) throw new Error(`Invalid config in ${path}:\n ${errors.join("\n ")}`);
8996
+ diagnostics.push(...unknownConfigKeyWarnings(data).map((message) => ({
8997
+ level: "warning",
8998
+ message: `${path}: ${message}`,
8999
+ file: path
9000
+ })));
7674
9001
  return data;
7675
9002
  }
7676
- /** Validate a config object. Returns an array of error strings (empty = valid). */
9003
+ /**
9004
+ * Validate a config object. Returns an array of error strings (empty = valid).
9005
+ * Keys the config does not define are not errors; {@link unknownConfigKeyWarnings} reports them.
9006
+ */
7677
9007
  function validateConfig(data) {
7678
9008
  const errors = [];
7679
9009
  if (typeof data !== "object" || data === null || Array.isArray(data)) {
@@ -7706,6 +9036,10 @@ function validateConfig(data) {
7706
9036
  if (!Array.isArray(obj[key])) errors.push(`"${key}" must be an array.`);
7707
9037
  else if (!obj[key].every((v) => typeof v === "string")) errors.push(`"${key}" must be an array of strings.`);
7708
9038
  }
9039
+ if ("formatFetchTimeout" in obj) {
9040
+ const timeout = obj["formatFetchTimeout"];
9041
+ if (typeof timeout !== "number" || !(timeout >= 0)) errors.push("\"formatFetchTimeout\" must be a number of milliseconds, 0 or more.");
9042
+ }
7709
9043
  if ("tagAliases" in obj) {
7710
9044
  if (typeof obj["tagAliases"] !== "object" || obj["tagAliases"] === null || Array.isArray(obj["tagAliases"])) errors.push("\"tagAliases\" must be an object.");
7711
9045
  else {
@@ -7721,6 +9055,20 @@ function validateConfig(data) {
7721
9055
  }
7722
9056
  return errors;
7723
9057
  }
9058
+ /**
9059
+ * Warnings for the keys of a config object that the config does not define (the JSON schema's
9060
+ * `additionalProperties: false`), other than `$schema`. twee-ts ignores such a key, so a misspelt one would
9061
+ * otherwise leave its option at the default without a word. A key that differs from a defined one only in
9062
+ * letter case, `-` or `_` (`formatID`, `output-mode`) gets a suggestion. Returns `[]` for anything that is not
9063
+ * an object, which {@link validateConfig} reports.
9064
+ */
9065
+ function unknownConfigKeyWarnings(data) {
9066
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return [];
9067
+ return Object.keys(data).filter((key) => key !== SCHEMA_KEY && !Object.hasOwn(CONFIG_KEY_SET, key)).map((key) => {
9068
+ const suggestion = CONFIG_KEYS.find((known) => foldKey(known) === foldKey(key));
9069
+ return `Unknown config key "${key}"${suggestion === void 0 ? "" : ` (did you mean "${suggestion}"?)`}; it is ignored.`;
9070
+ });
9071
+ }
7724
9072
  /** Return a default config JSON string for --init scaffolding. */
7725
9073
  function scaffoldConfig() {
7726
9074
  return JSON.stringify({
@@ -7730,6 +9078,6 @@ function scaffoldConfig() {
7730
9078
  }, null, 2) + "\n";
7731
9079
  }
7732
9080
  //#endregion
7733
- export { CONFIG_FILENAME, ItemType, StoryBuilder, TweeLexer, TweeTsError, applyTagAliases, clearCachedFormats, compile, compileIncremental, compileToFile, createIFID, decompileHTML, discoverCachedFormats, discoverFormats, fetchAndCacheFormat, fetchDirectFormat, formatLintReport, generateIFID, getCacheDir, getCacheSize, getFormatSearchDirs, lint, listCachedFormats, loadConfig, loadConfigFile, parseFormatJSON, parseSemver, parseTwee, resolveRemoteFormat, scaffoldConfig, semverCompare, storyInspect, tweeLexer, validateConfig, validateIFID, watch };
9081
+ export { CONFIG_FILENAME, ItemType, StoryBuilder, TweeLexer, TweeTsError, applyTagAliases, clearCachedFormats, compile, compileIncremental, compileToFile, createIFID, decompileHTML, discoverCachedFormats, discoverFormats, fetchAndCacheFormat, fetchDirectFormat, formatLintReport, generateIFID, getCacheDir, getCacheSize, getFormatSearchDirs, lint, listCachedFormats, loadConfig, loadConfigFile, parseFormatJSON, parseSemver, parseTwee, resolveRemoteFormat, scaffoldConfig, semverCompare, storyInspect, tweeLexer, unknownConfigKeyWarnings, validateConfig, validateIFID, watch };
7734
9082
 
7735
9083
  //# sourceMappingURL=index.js.map