@rohal12/twee-ts 1.17.0 → 1.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -36,11 +36,27 @@ let node_url = require("node:url");
36
36
  function generateIFID() {
37
37
  return (0, node_crypto.randomUUID)().toUpperCase();
38
38
  }
39
- /** Validate and brand an IFID string. Throws on invalid input. */
39
+ /**
40
+ * Validate and brand an IFID string. Throws on invalid input.
41
+ * Returns the IFID in its stored form (see `normalizeIFID`).
42
+ */
40
43
  function createIFID(value) {
41
44
  const err = validateIFID(value);
42
45
  if (err) throw new Error(`Invalid IFID: ${err}`);
43
- return value.toUpperCase();
46
+ return normalizeIFID(value);
47
+ }
48
+ /** Length of the `UUID://...//` wrapper around a 36-character UUID. */
49
+ const WRAPPED_LENGTH = 45;
50
+ /**
51
+ * Bring an IFID into the form the story model stores and every output writes: the uppercase
52
+ * bare UUID. A valid `UUID://...//` wrapped IFID loses its wrapper; outputs that need the wrapper
53
+ * (the Treaty of Babel comment) add exactly one. Twine 2 IFIDs use only capital letters, so the
54
+ * value is uppercased, as Tweego does. An invalid value is only uppercased, so that validation
55
+ * reports it as written.
56
+ */
57
+ function normalizeIFID(value) {
58
+ const upper = value.toUpperCase();
59
+ return upper.length === WRAPPED_LENGTH && validateIFID(upper) === null ? upper.slice(7, 43) : upper;
44
60
  }
45
61
  /**
46
62
  * Validate an IFID string.
@@ -470,7 +486,7 @@ function unmarshalStoryData(story, json) {
470
486
  }
471
487
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return "Cannot unmarshal \"StoryData\"; expected a JSON object";
472
488
  const data = raw;
473
- if (typeof data.ifid === "string" && data.ifid) story.ifid = data.ifid.toUpperCase();
489
+ if (typeof data.ifid === "string" && data.ifid) story.ifid = normalizeIFID(data.ifid);
474
490
  if (typeof data.format === "string" && data.format) story.twine2.format = data.format;
475
491
  if (typeof data["format-version"] === "string" && data["format-version"]) story.twine2.formatVersion = data["format-version"];
476
492
  if (Array.isArray(data.options)) {
@@ -501,7 +517,7 @@ function unmarshalStorySettings(story, text, diagnostics) {
501
517
  const val = line.slice(colonIdx + 1).trim().toLowerCase();
502
518
  switch (key) {
503
519
  case "ifid":
504
- if (validateIFID(val) === null) story.legacyIFID = val.toUpperCase();
520
+ if (validateIFID(val) === null) story.legacyIFID = normalizeIFID(val);
505
521
  obsolete.push("\"ifid\"");
506
522
  continue;
507
523
  case "zoom":
@@ -4763,7 +4779,7 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
4763
4779
  });
4764
4780
  else startnode = parsed;
4765
4781
  }
4766
- if (attrs["ifid"]) story.ifid = attrs["ifid"].toUpperCase();
4782
+ if (attrs["ifid"]) story.ifid = normalizeIFID(attrs["ifid"]);
4767
4783
  if (attrs["zoom"]) {
4768
4784
  const parsed = parseFloat(attrs["zoom"]);
4769
4785
  if (Number.isNaN(parsed)) diagnostics.push({
@@ -4776,7 +4792,9 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
4776
4792
  if (attrs["format"]) story.twine2.format = attrs["format"];
4777
4793
  if (attrs["format-version"]) story.twine2.formatVersion = attrs["format-version"];
4778
4794
  if (attrs["options"]) for (const opt of attrs["options"].split(/\s+/).filter((s) => s.length > 0)) story.twine2.options.set(opt, true);
4779
- for (const node of storyData.children ?? []) {
4795
+ const children = storyData.children ?? [];
4796
+ const takenNames = new Set(children.filter((node) => node.type === "tag" && node.name === "tw-passagedata").map(passageDataName));
4797
+ for (const node of children) {
4780
4798
  if (node.type !== "tag" && node.type !== "style" && node.type !== "script") continue;
4781
4799
  switch (node.name) {
4782
4800
  case "style":
@@ -4784,8 +4802,10 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
4784
4802
  const content = getTextContent(node);
4785
4803
  if (content.trim().length === 0) continue;
4786
4804
  const text = passageText(content);
4805
+ const name = freeName(node.name === "style" ? "Story Stylesheet" : "Story JavaScript", takenNames);
4806
+ takenNames.add(name);
4787
4807
  storyAdd(story, {
4788
- name: node.name === "style" ? "Story Stylesheet" : "Story JavaScript",
4808
+ name,
4789
4809
  tags: node.name === "style" ? ["stylesheet"] : ["script"],
4790
4810
  text
4791
4811
  }, diagnostics);
@@ -4801,7 +4821,7 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
4801
4821
  case "tw-passagedata": {
4802
4822
  let pid = 0;
4803
4823
  const pAttrs = node.attribs ?? {};
4804
- const name = pAttrs["name"] ?? "";
4824
+ const name = passageDataName(node);
4805
4825
  const tags = pAttrs["tags"] ? pAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [];
4806
4826
  const metadata = {};
4807
4827
  if (pAttrs["pid"]) {
@@ -4832,6 +4852,17 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
4832
4852
  text: marshalStoryData(story)
4833
4853
  }, diagnostics);
4834
4854
  }
4855
+ function passageDataName(node) {
4856
+ return node.attribs?.["name"] ?? "";
4857
+ }
4858
+ /** The first of `base`, `base 2`, `base 3`, … that is not in `taken`. */
4859
+ function freeName(base, taken) {
4860
+ if (!taken.has(base)) return base;
4861
+ for (let n = 2;; n++) {
4862
+ const candidate = `${base} ${n}`;
4863
+ if (!taken.has(candidate)) return candidate;
4864
+ }
4865
+ }
4835
4866
  function decompileTwine1(storeArea, story, passageText, diagnostics) {
4836
4867
  const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
4837
4868
  const obfuscated = isRot13Obfuscated(passages);
@@ -5224,6 +5255,121 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5224
5255
  }, diagnostics);
5225
5256
  }
5226
5257
  //#endregion
5258
+ //#region src/template.ts
5259
+ /**
5260
+ * An HTML closing head tag: `</head` in any letter case, followed by whitespace, `/` or `>`, up to the next `>`.
5261
+ * This is how the HTML tokenizer reads an end tag; it ignores whatever sits between the tag name and the `>`.
5262
+ */
5263
+ const CLOSING_HEAD_TAG = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>";
5264
+ /** The regular expression source matching exactly `text`. */
5265
+ function literal(text) {
5266
+ return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
5267
+ }
5268
+ /** Replace the `slots` placeholders found in `template`, without scanning inserted text. */
5269
+ function fillTemplate(template, slots) {
5270
+ return fillTemplateParts([{
5271
+ kind: "scan",
5272
+ text: template,
5273
+ slots
5274
+ }]);
5275
+ }
5276
+ /**
5277
+ * Fill the parts of a template in order, joined. A `'first'` slot replaced in one part is not replaced again in a
5278
+ * later part that lists the same slot object.
5279
+ */
5280
+ function fillTemplateParts(parts) {
5281
+ const filled = /* @__PURE__ */ new Set();
5282
+ return parts.map((part) => {
5283
+ if (part.kind === "verbatim" || part.slots.length === 0) return part.text;
5284
+ const { slots } = part;
5285
+ const pattern = new RegExp(slots.map((slot) => `(${slot.pattern})`).join("|"), "g");
5286
+ return part.text.replace(pattern, (match, ...groups) => {
5287
+ const slot = slots.find((_, i) => groups[i] !== void 0);
5288
+ if (slot === void 0 || slot.occurrences === "first" && filled.has(slot)) return match;
5289
+ filled.add(slot);
5290
+ return slot.replacement(match);
5291
+ });
5292
+ }).join("");
5293
+ }
5294
+ //#endregion
5295
+ //#region src/modules.ts
5296
+ /**
5297
+ * Load modules and return HTML tags to inject before the closing head tag.
5298
+ */
5299
+ function loadModules(filenames) {
5300
+ const processed = /* @__PURE__ */ new Set();
5301
+ const headTags = [];
5302
+ for (const filename of filenames) {
5303
+ if (processed.has(filename)) continue;
5304
+ const ext = normalizedFileExt(filename);
5305
+ let tag = null;
5306
+ switch (ext) {
5307
+ case "css":
5308
+ tag = loadModuleTagged("style", filename);
5309
+ break;
5310
+ case "js":
5311
+ tag = loadModuleTagged("script", filename);
5312
+ break;
5313
+ case "otf":
5314
+ case "ttf":
5315
+ case "woff":
5316
+ case "woff2":
5317
+ tag = loadModuleFont(filename);
5318
+ break;
5319
+ default: continue;
5320
+ }
5321
+ if (tag) headTags.push(tag);
5322
+ processed.add(filename);
5323
+ }
5324
+ return headTags.join("\n");
5325
+ }
5326
+ function loadModuleTagged(tag, filename) {
5327
+ const source = readUTF8(filename).trim();
5328
+ if (source.length === 0) return null;
5329
+ return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
5330
+ }
5331
+ function loadModuleFont(filename) {
5332
+ const source = readBase64(filename);
5333
+ const family = baseNameWithoutExt(filename);
5334
+ const idSlug = `style-module-${slugify(family)}`;
5335
+ const ext = normalizedFileExt(filename);
5336
+ return `<style id="${idSlug}" type="text/css">@font-face {\n\tfont-family: "${family}";\n\tsrc: url("data:${mediaTypeFromExt(ext)};base64,${source}") format("${fontFormatHint(ext)}");\n}</style>`;
5337
+ }
5338
+ /**
5339
+ * Load the module tags and head file content to inject before the closing head tag, joined by newlines,
5340
+ * or `''` when there is nothing to inject.
5341
+ */
5342
+ function loadHeadContent(modulePaths, headFile, diagnostics) {
5343
+ const parts = [];
5344
+ if (modulePaths.length > 0) {
5345
+ const modules = loadModules(modulePaths).trim();
5346
+ if (modules.length > 0) parts.push(modules);
5347
+ }
5348
+ if (headFile) try {
5349
+ const source = readUTF8(headFile).trim();
5350
+ if (source.length > 0) parts.push(source);
5351
+ } catch (e) {
5352
+ diagnostics?.push({
5353
+ level: "warning",
5354
+ message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
5355
+ });
5356
+ }
5357
+ return parts.join("\n");
5358
+ }
5359
+ /**
5360
+ * The template slot that inserts `content` on its own line before the first closing head tag (as Tweego does),
5361
+ * or `undefined` when `content` is empty. The tag may use any letter case and whitespace before its `>`, and is
5362
+ * kept as written.
5363
+ */
5364
+ function headSlot(content) {
5365
+ if (content.length === 0) return void 0;
5366
+ return {
5367
+ pattern: CLOSING_HEAD_TAG,
5368
+ occurrences: "first",
5369
+ replacement: (tag) => `${content}\n${tag}`
5370
+ };
5371
+ }
5372
+ //#endregion
5227
5373
  //#region src/version.ts
5228
5374
  /**
5229
5375
  * The twee-ts version, read at runtime from the package's own package.json.
@@ -5257,24 +5403,38 @@ const CREATOR_NAME$2 = "Twee-ts";
5257
5403
  function toTwine2Archive(story, startName, options) {
5258
5404
  return getTwine2DataChunk(story, startName, options) + "\n";
5259
5405
  }
5406
+ /**
5407
+ * Fill the Twine 2 format template: every `{{STORY_NAME}}` gets the escaped story name and the first
5408
+ * `{{STORY_DATA}}` the story data (as Tweego does), and `head` goes before the first closing head tag. All three are
5409
+ * found in the template in one pass, so a story name, passage text or head content holding a placeholder or a
5410
+ * closing head tag stays literal.
5411
+ */
5260
5412
  function toTwine2HTML(story, format, startName, options) {
5261
- let template = readFormatSource(format);
5262
- if (template.includes("{{STORY_NAME}}")) {
5263
- const name = htmlEscape(story.name);
5264
- template = template.replaceAll("{{STORY_NAME}}", () => name);
5265
- }
5266
- if (template.includes("{{STORY_DATA}}")) {
5267
- const data = getTwine2DataChunk({
5268
- ...story,
5269
- twine2: {
5270
- ...story.twine2,
5271
- format: format.name,
5272
- formatVersion: format.version
5413
+ const name = htmlEscape(story.name);
5414
+ const head = headSlot(options?.head ?? "");
5415
+ const slots = [
5416
+ {
5417
+ pattern: literal("{{STORY_NAME}}"),
5418
+ occurrences: "all",
5419
+ replacement: () => name
5420
+ },
5421
+ {
5422
+ pattern: literal("{{STORY_DATA}}"),
5423
+ occurrences: "first",
5424
+ replacement: () => {
5425
+ return getTwine2DataChunk({
5426
+ ...story,
5427
+ twine2: {
5428
+ ...story.twine2,
5429
+ format: format.name,
5430
+ formatVersion: format.version
5431
+ }
5432
+ }, startName, options);
5273
5433
  }
5274
- }, startName, options);
5275
- template = template.replace("{{STORY_DATA}}", () => data);
5276
- }
5277
- return template;
5434
+ },
5435
+ ...head === void 0 ? [] : [head]
5436
+ ];
5437
+ return fillTemplate(readFormatSource(format), slots);
5278
5438
  }
5279
5439
  const OMITTING_TAGS = [
5280
5440
  "Twine.private",
@@ -5367,7 +5527,13 @@ function toTwine1Archive(story, _startName) {
5367
5527
  const { data, count } = getTwine1PassageChunk(story);
5368
5528
  return `<div id="storeArea" data-size="${count}">${data}</div>\n`;
5369
5529
  }
5370
- function toTwine1HTML(story, format, startName) {
5530
+ /**
5531
+ * Fill the Twine 1 format template. The format's components are inserted first, one after another, as Tweego and
5532
+ * Twine 1 do. Then the first of each story placeholder (`"VERSION"`, `"TIME"`, `"START_AT"`, `"STORY_SIZE"`,
5533
+ * `"STORY"`), the IFID comment and `head` (before the first closing head tag) are found in one pass, so a start
5534
+ * passage name or story data holding a placeholder or a closing head tag stays literal.
5535
+ */
5536
+ function toTwine1HTML(story, format, startName, options) {
5371
5537
  const formatDir = (0, node_path.dirname)(format.filename);
5372
5538
  const parentDir = (0, node_path.dirname)(formatDir);
5373
5539
  let template = readFormatSource(format);
@@ -5378,28 +5544,60 @@ function toTwine1HTML(story, format, startName) {
5378
5544
  template = tryReplaceComponent(template, "\"JONAH\"", (0, node_path.join)(formatDir, "code.js"), true);
5379
5545
  if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", (0, node_path.join)(parentDir, "jquery.js"), true);
5380
5546
  if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", (0, node_path.join)(parentDir, "modernizr.js"), true);
5547
+ const isPre14 = !template.includes("\"STORY\"");
5548
+ const footer = isPre14 ? readFooter(formatDir) : "";
5549
+ const storeArea = (template + footer).includes("<div id=\"store-area\"") ? "<div id=\"store-area\"" : "<div id=\"storeArea\"";
5550
+ const safeIfid = htmlCommentSanitize(story.ifid);
5551
+ const late = [story.ifid ? {
5552
+ pattern: literal(storeArea),
5553
+ occurrences: "first",
5554
+ replacement: (div) => `<!-- UUID://${safeIfid}// -->${div}`
5555
+ } : void 0, headSlot(options?.head ?? "")].filter((slot) => slot !== void 0);
5381
5556
  const displayStart = startName === "Start" ? "" : startName;
5382
- template = template.replace("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`);
5383
- template = template.replace("\"TIME\"", `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`);
5384
- const startAtValue = `"${jsStringEscape(displayStart)}"`;
5385
- template = template.replace("\"START_AT\"", () => startAtValue);
5386
- template = template.replace("\"STORY_SIZE\"", `"${count}"`);
5387
- if (template.includes("\"STORY\"")) template = template.replace("\"STORY\"", () => data);
5388
- else {
5389
- let footer;
5390
- try {
5391
- footer = (0, node_fs.readFileSync)((0, node_path.join)(formatDir, "footer.html"), "utf-8");
5392
- } catch {
5393
- footer = "</div>\n</body>\n</html>\n";
5394
- }
5395
- template += data + footer;
5396
- }
5397
- if (story.ifid) {
5398
- const safeIfid = htmlCommentSanitize(story.ifid);
5399
- if (template.includes("<div id=\"store-area\"")) template = template.replace("<div id=\"store-area\"", `<!-- UUID://${safeIfid}// --><div id="store-area"`);
5400
- else template = template.replace("<div id=\"storeArea\"", `<!-- UUID://${safeIfid}// --><div id="storeArea"`);
5557
+ const slots = [
5558
+ firstSlot("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`),
5559
+ firstSlot("\"TIME\"", `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`),
5560
+ firstSlot("\"START_AT\"", `"${jsStringEscape(displayStart)}"`),
5561
+ firstSlot("\"STORY_SIZE\"", `"${count}"`),
5562
+ firstSlot("\"STORY\"", data),
5563
+ ...late
5564
+ ];
5565
+ return fillTemplateParts(isPre14 ? [
5566
+ {
5567
+ kind: "scan",
5568
+ text: template,
5569
+ slots
5570
+ },
5571
+ {
5572
+ kind: "verbatim",
5573
+ text: data
5574
+ },
5575
+ {
5576
+ kind: "scan",
5577
+ text: footer,
5578
+ slots: late
5579
+ }
5580
+ ] : [{
5581
+ kind: "scan",
5582
+ text: template,
5583
+ slots
5584
+ }]);
5585
+ }
5586
+ /** A slot replacing the first `token` with `value`. */
5587
+ function firstSlot(token, value) {
5588
+ return {
5589
+ pattern: literal(token),
5590
+ occurrences: "first",
5591
+ replacement: () => value
5592
+ };
5593
+ }
5594
+ /** The footer of a pre-1.4 format, or the default one when the format has none. */
5595
+ function readFooter(formatDir) {
5596
+ try {
5597
+ return (0, node_fs.readFileSync)((0, node_path.join)(formatDir, "footer.html"), "utf-8");
5598
+ } catch {
5599
+ return "</div>\n</body>\n</html>\n";
5401
5600
  }
5402
- return template;
5403
5601
  }
5404
5602
  /**
5405
5603
  * Why Twine 1 output leaves a passage out of its tiddlers, or `undefined` when the passage is emitted.
@@ -5472,75 +5670,6 @@ function isStoryDataJSON(text) {
5472
5670
  return unmarshalStoryData(createStory(), text) === null;
5473
5671
  }
5474
5672
  //#endregion
5475
- //#region src/modules.ts
5476
- /**
5477
- * Load modules and return HTML tags to inject before </head>.
5478
- */
5479
- function loadModules(filenames) {
5480
- const processed = /* @__PURE__ */ new Set();
5481
- const headTags = [];
5482
- for (const filename of filenames) {
5483
- if (processed.has(filename)) continue;
5484
- const ext = normalizedFileExt(filename);
5485
- let tag = null;
5486
- switch (ext) {
5487
- case "css":
5488
- tag = loadModuleTagged("style", filename);
5489
- break;
5490
- case "js":
5491
- tag = loadModuleTagged("script", filename);
5492
- break;
5493
- case "otf":
5494
- case "ttf":
5495
- case "woff":
5496
- case "woff2":
5497
- tag = loadModuleFont(filename);
5498
- break;
5499
- default: continue;
5500
- }
5501
- if (tag) headTags.push(tag);
5502
- processed.add(filename);
5503
- }
5504
- return headTags.join("\n");
5505
- }
5506
- function loadModuleTagged(tag, filename) {
5507
- const source = readUTF8(filename).trim();
5508
- if (source.length === 0) return null;
5509
- return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
5510
- }
5511
- function loadModuleFont(filename) {
5512
- const source = readBase64(filename);
5513
- const family = baseNameWithoutExt(filename);
5514
- const idSlug = `style-module-${slugify(family)}`;
5515
- const ext = normalizedFileExt(filename);
5516
- return `<style id="${idSlug}" type="text/css">@font-face {\n\tfont-family: "${family}";\n\tsrc: url("data:${mediaTypeFromExt(ext)};base64,${source}") format("${fontFormatHint(ext)}");\n}</style>`;
5517
- }
5518
- /**
5519
- * Inject modules and head file content before </head>.
5520
- */
5521
- function modifyHead(html, modulePaths, headFile, diagnostics) {
5522
- const parts = [];
5523
- if (modulePaths.length > 0) {
5524
- const modules = loadModules(modulePaths).trim();
5525
- if (modules.length > 0) parts.push(modules);
5526
- }
5527
- if (headFile) try {
5528
- const source = readUTF8(headFile).trim();
5529
- if (source.length > 0) parts.push(source);
5530
- } catch (e) {
5531
- diagnostics?.push({
5532
- level: "warning",
5533
- message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
5534
- });
5535
- }
5536
- if (parts.length > 0) {
5537
- parts.push("</head>");
5538
- const replacement = parts.join("\n");
5539
- return html.replace("</head>", () => replacement);
5540
- }
5541
- return html;
5542
- }
5543
- //#endregion
5544
5673
  //#region src/passage-omission.ts
5545
5674
  /**
5546
5675
  * Why the `target` output leaves `passage` out of its passage elements, or `undefined` when it
@@ -5617,12 +5746,13 @@ async function compileToFile(options) {
5617
5746
  * Builds as compileToFile() does for `outFile`, without writing it: the output may sit
5618
5747
  * inside a source folder, so its last build is left out of the sources and modules and
5619
5748
  * never read back. The caller decides whether the result is written. With no `outFile`
5620
- * (output to stdout, or none), this is compile().
5749
+ * (output to stdout, or none), this is compile(). With `cache`, files are cached as
5750
+ * compileIncremental() caches them.
5621
5751
  *
5622
- * Internal, for the CLI; not part of the public API.
5752
+ * Internal, for the CLI and the bundler plugins; not part of the public API.
5623
5753
  */
5624
- async function compileForOutputFile(options, outFile) {
5625
- return buildOutput(options, void 0, void 0, outFile);
5754
+ async function compileForOutputFile(options, outFile, cache) {
5755
+ return buildOutput(options, cache, void 0, outFile);
5626
5756
  }
5627
5757
  /**
5628
5758
  * Compile with incremental caching support.
@@ -5638,7 +5768,9 @@ async function compileIncremental(options, cache, changedFiles) {
5638
5768
  }
5639
5769
  /**
5640
5770
  * Watch for file changes and recompile. Every build is written to `outFile`, including
5641
- * one whose diagnostics report errors.
5771
+ * one whose diagnostics report errors. Builds run one at a time: changes made during a
5772
+ * build go into one follow-up build after it, and the superseded build is neither
5773
+ * written nor reported.
5642
5774
  */
5643
5775
  async function watch(options) {
5644
5776
  return watchWithWriteFilter(options, () => true);
@@ -5668,17 +5800,62 @@ async function watchWithWriteFilter(options, shouldWrite) {
5668
5800
  const abs = (0, node_path.resolve)(filename);
5669
5801
  return !notExcludable.some((root) => abs === root || abs.startsWith(root + node_path.sep));
5670
5802
  };
5803
+ let building = false;
5804
+ let queued;
5805
+ const deliver = (outcome) => {
5806
+ try {
5807
+ if (!outcome.ok) throw outcome.error;
5808
+ if (shouldWrite(outcome.result)) (0, node_fs.writeFileSync)(options.outFile, outcome.result.output, "utf-8");
5809
+ options.onBuild?.(outcome.result);
5810
+ } catch (e) {
5811
+ options.onError?.(toError(e));
5812
+ }
5813
+ };
5814
+ const drain = async (first) => {
5815
+ building = true;
5816
+ try {
5817
+ let request = first;
5818
+ while (request !== void 0) {
5819
+ const current = request;
5820
+ const outcome = await buildOutput(options, cache, current.changedFiles, options.outFile).then((result) => ({
5821
+ ok: true,
5822
+ result
5823
+ }), (e) => ({
5824
+ ok: false,
5825
+ error: toError(e)
5826
+ }));
5827
+ if (controller.signal.aborted) return;
5828
+ request = queued === void 0 ? void 0 : mergeBuildRequests(current, queued);
5829
+ queued = void 0;
5830
+ if (request === void 0) deliver(outcome);
5831
+ }
5832
+ } finally {
5833
+ building = false;
5834
+ }
5835
+ };
5671
5836
  const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
5672
- buildOutput(options, cache, changedFiles, options.outFile).then((result) => {
5673
- if (shouldWrite(result)) (0, node_fs.writeFileSync)(options.outFile, result.output, "utf-8");
5674
- options.onBuild?.(result);
5675
- }).catch((e) => {
5676
- options.onError?.(e instanceof Error ? e : new Error(String(e)));
5677
- });
5837
+ if (controller.signal.aborted) return;
5838
+ const request = { changedFiles };
5839
+ if (building) {
5840
+ queued = queued === void 0 ? request : mergeBuildRequests(queued, request);
5841
+ return;
5842
+ }
5843
+ drain(request);
5678
5844
  }, ignore);
5679
- controller.signal.addEventListener("abort", () => handle.close());
5845
+ controller.signal.addEventListener("abort", () => {
5846
+ queued = void 0;
5847
+ handle.close();
5848
+ });
5680
5849
  return controller;
5681
5850
  }
5851
+ /** One build covering both requests: a full build if either is one, else every changed file. */
5852
+ function mergeBuildRequests(a, b) {
5853
+ if (a.changedFiles === void 0 || b.changedFiles === void 0) return { changedFiles: void 0 };
5854
+ return { changedFiles: /* @__PURE__ */ new Set([...a.changedFiles, ...b.changedFiles]) };
5855
+ }
5856
+ function toError(e) {
5857
+ return e instanceof Error ? e : new Error(String(e));
5858
+ }
5682
5859
  /**
5683
5860
  * `outFile`: the file the build is written to, which source and module discovery
5684
5861
  * skip (as Tweego does), so an output inside a source folder is never loaded back.
@@ -5740,17 +5917,17 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5740
5917
  case "html": {
5741
5918
  diagnostics.push(...startPassageDiagnostics(story, startName, format?.isTwine2 === false ? "twine1" : "twine2"));
5742
5919
  if (!format) throw new TweeTsError("No story format available for HTML output.", diagnostics);
5743
- if (format.isTwine2) output = toTwine2HTML(story, format, startName, { sourceInfo });
5744
- else {
5745
- if (story.name === "" && !storyHas(story, "StoryTitle")) diagnostics.push({
5746
- level: "error",
5747
- message: "Special passage \"StoryTitle\" not found."
5748
- });
5749
- output = toTwine1HTML(story, format, startName);
5750
- }
5920
+ if (!format.isTwine2 && story.name === "" && !storyHas(story, "StoryTitle")) diagnostics.push({
5921
+ level: "error",
5922
+ message: "Special passage \"StoryTitle\" not found."
5923
+ });
5751
5924
  const modules = getFilenames(options.modules ?? [], outFile);
5752
5925
  diagnostics.push(...modules.diagnostics);
5753
- output = modifyHead(output, modules.filenames, options.headFile, diagnostics);
5926
+ const head = loadHeadContent(modules.filenames, options.headFile, diagnostics);
5927
+ output = format.isTwine2 ? toTwine2HTML(story, format, startName, {
5928
+ sourceInfo,
5929
+ head
5930
+ }) : toTwine1HTML(story, format, startName, { head });
5754
5931
  break;
5755
5932
  }
5756
5933
  default: throw new TweeTsError(`Unhandled output mode: ${outputMode}`, diagnostics);
@@ -7251,6 +7428,16 @@ function passageLinks(p) {
7251
7428
  return extractLinksFromText(textAsRead(p), false);
7252
7429
  }
7253
7430
  /**
7431
+ * Whether the `target` output keeps the links in `p`: always without a target, which describes
7432
+ * every source passage. With one, a passage the output leaves out never reaches the player, so it
7433
+ * links nowhere, except a script passage, which Twine 2 output leaves out as a passage but runs.
7434
+ */
7435
+ function readsLinks(story, p, { target }) {
7436
+ if (target === void 0) return true;
7437
+ const omission = passageOmission(story, p, target);
7438
+ return omission === void 0 || omission.kind === "tag" && omission.tag === "script";
7439
+ }
7440
+ /**
7254
7441
  * Each passage name, mapped to why the `target` output leaves that passage out, or to `undefined`
7255
7442
  * when a link can reach it: always without a target, and otherwise when the output emits a
7256
7443
  * passage of that name.
@@ -7308,7 +7495,9 @@ function joinLines(text) {
7308
7495
  * By default, a link is broken only when no passage has its name. Pass a `target` to check links
7309
7496
  * against what that output emits: `storyInspect(result.story, { target: 'twine2' })` also reports
7310
7497
  * links to passages Twine 2 output leaves out, such as script, stylesheet and `Twine.private`
7311
- * passages.
7498
+ * passages. With a target, passages that output leaves out also give no links (script passages
7499
+ * excepted, as Twine 2 output runs them), so they report no broken links and keep no passage from
7500
+ * being an orphan.
7312
7501
  */
7313
7502
  function storyInspect(story, options = {}) {
7314
7503
  const destinations = destinationOmissions(story, options);
@@ -7333,7 +7522,7 @@ function storyInspect(story, options = {}) {
7333
7522
  }
7334
7523
  list.push(p.name);
7335
7524
  }
7336
- links.set(p.name, passageLinks(p));
7525
+ links.set(p.name, readsLinks(story, p, options) ? passageLinks(p) : []);
7337
7526
  }
7338
7527
  const brokenLinks = [];
7339
7528
  for (const [from, targets] of links) for (const to of targets) {
@@ -7382,7 +7571,8 @@ function storyInspect(story, options = {}) {
7382
7571
  * Uses JSON output mode internally to avoid format resolution, so it checks
7383
7572
  * the starting passage itself, against the Twine 2 passage rules. Link
7384
7573
  * destinations are checked against the same rules: a link to a passage that
7385
- * Twine 2 output leaves out is broken.
7574
+ * Twine 2 output leaves out is broken, and a passage it leaves out (other than
7575
+ * a script passage) gives no links.
7386
7576
  */
7387
7577
  async function lint(options) {
7388
7578
  return lintForOutputFile(options, void 0);