@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.js CHANGED
@@ -13,11 +13,27 @@ import { fileURLToPath } from "node:url";
13
13
  function generateIFID() {
14
14
  return randomUUID().toUpperCase();
15
15
  }
16
- /** Validate and brand an IFID string. Throws on invalid input. */
16
+ /**
17
+ * Validate and brand an IFID string. Throws on invalid input.
18
+ * Returns the IFID in its stored form (see `normalizeIFID`).
19
+ */
17
20
  function createIFID(value) {
18
21
  const err = validateIFID(value);
19
22
  if (err) throw new Error(`Invalid IFID: ${err}`);
20
- return value.toUpperCase();
23
+ return normalizeIFID(value);
24
+ }
25
+ /** Length of the `UUID://...//` wrapper around a 36-character UUID. */
26
+ const WRAPPED_LENGTH = 45;
27
+ /**
28
+ * Bring an IFID into the form the story model stores and every output writes: the uppercase
29
+ * bare UUID. A valid `UUID://...//` wrapped IFID loses its wrapper; outputs that need the wrapper
30
+ * (the Treaty of Babel comment) add exactly one. Twine 2 IFIDs use only capital letters, so the
31
+ * value is uppercased, as Tweego does. An invalid value is only uppercased, so that validation
32
+ * reports it as written.
33
+ */
34
+ function normalizeIFID(value) {
35
+ const upper = value.toUpperCase();
36
+ return upper.length === WRAPPED_LENGTH && validateIFID(upper) === null ? upper.slice(7, 43) : upper;
21
37
  }
22
38
  /**
23
39
  * Validate an IFID string.
@@ -447,7 +463,7 @@ function unmarshalStoryData(story, json) {
447
463
  }
448
464
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return "Cannot unmarshal \"StoryData\"; expected a JSON object";
449
465
  const data = raw;
450
- if (typeof data.ifid === "string" && data.ifid) story.ifid = data.ifid.toUpperCase();
466
+ if (typeof data.ifid === "string" && data.ifid) story.ifid = normalizeIFID(data.ifid);
451
467
  if (typeof data.format === "string" && data.format) story.twine2.format = data.format;
452
468
  if (typeof data["format-version"] === "string" && data["format-version"]) story.twine2.formatVersion = data["format-version"];
453
469
  if (Array.isArray(data.options)) {
@@ -478,7 +494,7 @@ function unmarshalStorySettings(story, text, diagnostics) {
478
494
  const val = line.slice(colonIdx + 1).trim().toLowerCase();
479
495
  switch (key) {
480
496
  case "ifid":
481
- if (validateIFID(val) === null) story.legacyIFID = val.toUpperCase();
497
+ if (validateIFID(val) === null) story.legacyIFID = normalizeIFID(val);
482
498
  obsolete.push("\"ifid\"");
483
499
  continue;
484
500
  case "zoom":
@@ -4740,7 +4756,7 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
4740
4756
  });
4741
4757
  else startnode = parsed;
4742
4758
  }
4743
- if (attrs["ifid"]) story.ifid = attrs["ifid"].toUpperCase();
4759
+ if (attrs["ifid"]) story.ifid = normalizeIFID(attrs["ifid"]);
4744
4760
  if (attrs["zoom"]) {
4745
4761
  const parsed = parseFloat(attrs["zoom"]);
4746
4762
  if (Number.isNaN(parsed)) diagnostics.push({
@@ -4753,7 +4769,9 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
4753
4769
  if (attrs["format"]) story.twine2.format = attrs["format"];
4754
4770
  if (attrs["format-version"]) story.twine2.formatVersion = attrs["format-version"];
4755
4771
  if (attrs["options"]) for (const opt of attrs["options"].split(/\s+/).filter((s) => s.length > 0)) story.twine2.options.set(opt, true);
4756
- for (const node of storyData.children ?? []) {
4772
+ const children = storyData.children ?? [];
4773
+ const takenNames = new Set(children.filter((node) => node.type === "tag" && node.name === "tw-passagedata").map(passageDataName));
4774
+ for (const node of children) {
4757
4775
  if (node.type !== "tag" && node.type !== "style" && node.type !== "script") continue;
4758
4776
  switch (node.name) {
4759
4777
  case "style":
@@ -4761,8 +4779,10 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
4761
4779
  const content = getTextContent(node);
4762
4780
  if (content.trim().length === 0) continue;
4763
4781
  const text = passageText(content);
4782
+ const name = freeName(node.name === "style" ? "Story Stylesheet" : "Story JavaScript", takenNames);
4783
+ takenNames.add(name);
4764
4784
  storyAdd(story, {
4765
- name: node.name === "style" ? "Story Stylesheet" : "Story JavaScript",
4785
+ name,
4766
4786
  tags: node.name === "style" ? ["stylesheet"] : ["script"],
4767
4787
  text
4768
4788
  }, diagnostics);
@@ -4778,7 +4798,7 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
4778
4798
  case "tw-passagedata": {
4779
4799
  let pid = 0;
4780
4800
  const pAttrs = node.attribs ?? {};
4781
- const name = pAttrs["name"] ?? "";
4801
+ const name = passageDataName(node);
4782
4802
  const tags = pAttrs["tags"] ? pAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [];
4783
4803
  const metadata = {};
4784
4804
  if (pAttrs["pid"]) {
@@ -4809,6 +4829,17 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
4809
4829
  text: marshalStoryData(story)
4810
4830
  }, diagnostics);
4811
4831
  }
4832
+ function passageDataName(node) {
4833
+ return node.attribs?.["name"] ?? "";
4834
+ }
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
+ }
4842
+ }
4812
4843
  function decompileTwine1(storeArea, story, passageText, diagnostics) {
4813
4844
  const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
4814
4845
  const obfuscated = isRot13Obfuscated(passages);
@@ -5201,6 +5232,121 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5201
5232
  }, diagnostics);
5202
5233
  }
5203
5234
  //#endregion
5235
+ //#region src/template.ts
5236
+ /**
5237
+ * An HTML closing head tag: `</head` in any letter case, followed by whitespace, `/` or `>`, up to the next `>`.
5238
+ * This is how the HTML tokenizer reads an end tag; it ignores whatever sits between the tag name and the `>`.
5239
+ */
5240
+ const CLOSING_HEAD_TAG = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>";
5241
+ /** The regular expression source matching exactly `text`. */
5242
+ function literal(text) {
5243
+ return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
5244
+ }
5245
+ /** Replace the `slots` placeholders found in `template`, without scanning inserted text. */
5246
+ function fillTemplate(template, slots) {
5247
+ return fillTemplateParts([{
5248
+ kind: "scan",
5249
+ text: template,
5250
+ slots
5251
+ }]);
5252
+ }
5253
+ /**
5254
+ * Fill the parts of a template in order, joined. A `'first'` slot replaced in one part is not replaced again in a
5255
+ * later part that lists the same slot object.
5256
+ */
5257
+ function fillTemplateParts(parts) {
5258
+ const filled = /* @__PURE__ */ new Set();
5259
+ return parts.map((part) => {
5260
+ if (part.kind === "verbatim" || part.slots.length === 0) return part.text;
5261
+ const { slots } = part;
5262
+ const pattern = new RegExp(slots.map((slot) => `(${slot.pattern})`).join("|"), "g");
5263
+ return part.text.replace(pattern, (match, ...groups) => {
5264
+ const slot = slots.find((_, i) => groups[i] !== void 0);
5265
+ if (slot === void 0 || slot.occurrences === "first" && filled.has(slot)) return match;
5266
+ filled.add(slot);
5267
+ return slot.replacement(match);
5268
+ });
5269
+ }).join("");
5270
+ }
5271
+ //#endregion
5272
+ //#region src/modules.ts
5273
+ /**
5274
+ * Load modules and return HTML tags to inject before the closing head tag.
5275
+ */
5276
+ function loadModules(filenames) {
5277
+ const processed = /* @__PURE__ */ new Set();
5278
+ const headTags = [];
5279
+ for (const filename of filenames) {
5280
+ if (processed.has(filename)) continue;
5281
+ const ext = normalizedFileExt(filename);
5282
+ let tag = null;
5283
+ switch (ext) {
5284
+ case "css":
5285
+ tag = loadModuleTagged("style", filename);
5286
+ break;
5287
+ case "js":
5288
+ tag = loadModuleTagged("script", filename);
5289
+ break;
5290
+ case "otf":
5291
+ case "ttf":
5292
+ case "woff":
5293
+ case "woff2":
5294
+ tag = loadModuleFont(filename);
5295
+ break;
5296
+ default: continue;
5297
+ }
5298
+ if (tag) headTags.push(tag);
5299
+ processed.add(filename);
5300
+ }
5301
+ return headTags.join("\n");
5302
+ }
5303
+ function loadModuleTagged(tag, filename) {
5304
+ const source = readUTF8(filename).trim();
5305
+ if (source.length === 0) return null;
5306
+ return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
5307
+ }
5308
+ function loadModuleFont(filename) {
5309
+ const source = readBase64(filename);
5310
+ const family = baseNameWithoutExt(filename);
5311
+ const idSlug = `style-module-${slugify(family)}`;
5312
+ 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>`;
5314
+ }
5315
+ /**
5316
+ * Load the module tags and head file content to inject before the closing head tag, joined by newlines,
5317
+ * or `''` when there is nothing to inject.
5318
+ */
5319
+ function loadHeadContent(modulePaths, headFile, diagnostics) {
5320
+ const parts = [];
5321
+ if (modulePaths.length > 0) {
5322
+ const modules = loadModules(modulePaths).trim();
5323
+ if (modules.length > 0) parts.push(modules);
5324
+ }
5325
+ if (headFile) try {
5326
+ const source = readUTF8(headFile).trim();
5327
+ if (source.length > 0) parts.push(source);
5328
+ } catch (e) {
5329
+ diagnostics?.push({
5330
+ level: "warning",
5331
+ message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
5332
+ });
5333
+ }
5334
+ return parts.join("\n");
5335
+ }
5336
+ /**
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.
5340
+ */
5341
+ function headSlot(content) {
5342
+ if (content.length === 0) return void 0;
5343
+ return {
5344
+ pattern: CLOSING_HEAD_TAG,
5345
+ occurrences: "first",
5346
+ replacement: (tag) => `${content}\n${tag}`
5347
+ };
5348
+ }
5349
+ //#endregion
5204
5350
  //#region src/version.ts
5205
5351
  /**
5206
5352
  * The twee-ts version, read at runtime from the package's own package.json.
@@ -5234,24 +5380,38 @@ const CREATOR_NAME$2 = "Twee-ts";
5234
5380
  function toTwine2Archive(story, startName, options) {
5235
5381
  return getTwine2DataChunk(story, startName, options) + "\n";
5236
5382
  }
5383
+ /**
5384
+ * 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.
5388
+ */
5237
5389
  function toTwine2HTML(story, format, startName, options) {
5238
- let template = readFormatSource(format);
5239
- if (template.includes("{{STORY_NAME}}")) {
5240
- const name = htmlEscape(story.name);
5241
- template = template.replaceAll("{{STORY_NAME}}", () => name);
5242
- }
5243
- if (template.includes("{{STORY_DATA}}")) {
5244
- const data = getTwine2DataChunk({
5245
- ...story,
5246
- twine2: {
5247
- ...story.twine2,
5248
- format: format.name,
5249
- formatVersion: format.version
5390
+ const name = htmlEscape(story.name);
5391
+ const head = headSlot(options?.head ?? "");
5392
+ const slots = [
5393
+ {
5394
+ pattern: literal("{{STORY_NAME}}"),
5395
+ occurrences: "all",
5396
+ replacement: () => name
5397
+ },
5398
+ {
5399
+ pattern: literal("{{STORY_DATA}}"),
5400
+ occurrences: "first",
5401
+ replacement: () => {
5402
+ return getTwine2DataChunk({
5403
+ ...story,
5404
+ twine2: {
5405
+ ...story.twine2,
5406
+ format: format.name,
5407
+ formatVersion: format.version
5408
+ }
5409
+ }, startName, options);
5250
5410
  }
5251
- }, startName, options);
5252
- template = template.replace("{{STORY_DATA}}", () => data);
5253
- }
5254
- return template;
5411
+ },
5412
+ ...head === void 0 ? [] : [head]
5413
+ ];
5414
+ return fillTemplate(readFormatSource(format), slots);
5255
5415
  }
5256
5416
  const OMITTING_TAGS = [
5257
5417
  "Twine.private",
@@ -5344,7 +5504,13 @@ function toTwine1Archive(story, _startName) {
5344
5504
  const { data, count } = getTwine1PassageChunk(story);
5345
5505
  return `<div id="storeArea" data-size="${count}">${data}</div>\n`;
5346
5506
  }
5347
- function toTwine1HTML(story, format, startName) {
5507
+ /**
5508
+ * Fill the Twine 1 format template. The format's components are inserted first, one after another, as Tweego and
5509
+ * 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.
5512
+ */
5513
+ function toTwine1HTML(story, format, startName, options) {
5348
5514
  const formatDir = dirname(format.filename);
5349
5515
  const parentDir = dirname(formatDir);
5350
5516
  let template = readFormatSource(format);
@@ -5355,28 +5521,60 @@ function toTwine1HTML(story, format, startName) {
5355
5521
  template = tryReplaceComponent(template, "\"JONAH\"", join(formatDir, "code.js"), true);
5356
5522
  if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", join(parentDir, "jquery.js"), true);
5357
5523
  if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", join(parentDir, "modernizr.js"), true);
5524
+ const isPre14 = !template.includes("\"STORY\"");
5525
+ const footer = isPre14 ? readFooter(formatDir) : "";
5526
+ const storeArea = (template + footer).includes("<div id=\"store-area\"") ? "<div id=\"store-area\"" : "<div id=\"storeArea\"";
5527
+ const safeIfid = htmlCommentSanitize(story.ifid);
5528
+ const late = [story.ifid ? {
5529
+ pattern: literal(storeArea),
5530
+ occurrences: "first",
5531
+ replacement: (div) => `<!-- UUID://${safeIfid}// -->${div}`
5532
+ } : void 0, headSlot(options?.head ?? "")].filter((slot) => slot !== void 0);
5358
5533
  const displayStart = startName === "Start" ? "" : startName;
5359
- template = template.replace("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`);
5360
- template = template.replace("\"TIME\"", `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`);
5361
- const startAtValue = `"${jsStringEscape(displayStart)}"`;
5362
- template = template.replace("\"START_AT\"", () => startAtValue);
5363
- template = template.replace("\"STORY_SIZE\"", `"${count}"`);
5364
- if (template.includes("\"STORY\"")) template = template.replace("\"STORY\"", () => data);
5365
- else {
5366
- let footer;
5367
- try {
5368
- footer = readFileSync(join(formatDir, "footer.html"), "utf-8");
5369
- } catch {
5370
- footer = "</div>\n</body>\n</html>\n";
5371
- }
5372
- template += data + footer;
5373
- }
5374
- if (story.ifid) {
5375
- const safeIfid = htmlCommentSanitize(story.ifid);
5376
- if (template.includes("<div id=\"store-area\"")) template = template.replace("<div id=\"store-area\"", `<!-- UUID://${safeIfid}// --><div id="store-area"`);
5377
- else template = template.replace("<div id=\"storeArea\"", `<!-- UUID://${safeIfid}// --><div id="storeArea"`);
5534
+ const slots = [
5535
+ firstSlot("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`),
5536
+ firstSlot("\"TIME\"", `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`),
5537
+ firstSlot("\"START_AT\"", `"${jsStringEscape(displayStart)}"`),
5538
+ firstSlot("\"STORY_SIZE\"", `"${count}"`),
5539
+ firstSlot("\"STORY\"", data),
5540
+ ...late
5541
+ ];
5542
+ return fillTemplateParts(isPre14 ? [
5543
+ {
5544
+ kind: "scan",
5545
+ text: template,
5546
+ slots
5547
+ },
5548
+ {
5549
+ kind: "verbatim",
5550
+ text: data
5551
+ },
5552
+ {
5553
+ kind: "scan",
5554
+ text: footer,
5555
+ slots: late
5556
+ }
5557
+ ] : [{
5558
+ kind: "scan",
5559
+ text: template,
5560
+ slots
5561
+ }]);
5562
+ }
5563
+ /** A slot replacing the first `token` with `value`. */
5564
+ function firstSlot(token, value) {
5565
+ return {
5566
+ pattern: literal(token),
5567
+ occurrences: "first",
5568
+ replacement: () => value
5569
+ };
5570
+ }
5571
+ /** The footer of a pre-1.4 format, or the default one when the format has none. */
5572
+ function readFooter(formatDir) {
5573
+ try {
5574
+ return readFileSync(join(formatDir, "footer.html"), "utf-8");
5575
+ } catch {
5576
+ return "</div>\n</body>\n</html>\n";
5378
5577
  }
5379
- return template;
5380
5578
  }
5381
5579
  /**
5382
5580
  * Why Twine 1 output leaves a passage out of its tiddlers, or `undefined` when the passage is emitted.
@@ -5449,75 +5647,6 @@ function isStoryDataJSON(text) {
5449
5647
  return unmarshalStoryData(createStory(), text) === null;
5450
5648
  }
5451
5649
  //#endregion
5452
- //#region src/modules.ts
5453
- /**
5454
- * Load modules and return HTML tags to inject before </head>.
5455
- */
5456
- function loadModules(filenames) {
5457
- const processed = /* @__PURE__ */ new Set();
5458
- const headTags = [];
5459
- for (const filename of filenames) {
5460
- if (processed.has(filename)) continue;
5461
- const ext = normalizedFileExt(filename);
5462
- let tag = null;
5463
- switch (ext) {
5464
- case "css":
5465
- tag = loadModuleTagged("style", filename);
5466
- break;
5467
- case "js":
5468
- tag = loadModuleTagged("script", filename);
5469
- break;
5470
- case "otf":
5471
- case "ttf":
5472
- case "woff":
5473
- case "woff2":
5474
- tag = loadModuleFont(filename);
5475
- break;
5476
- default: continue;
5477
- }
5478
- if (tag) headTags.push(tag);
5479
- processed.add(filename);
5480
- }
5481
- return headTags.join("\n");
5482
- }
5483
- function loadModuleTagged(tag, filename) {
5484
- const source = readUTF8(filename).trim();
5485
- if (source.length === 0) return null;
5486
- return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
5487
- }
5488
- function loadModuleFont(filename) {
5489
- const source = readBase64(filename);
5490
- const family = baseNameWithoutExt(filename);
5491
- const idSlug = `style-module-${slugify(family)}`;
5492
- const ext = normalizedFileExt(filename);
5493
- 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>`;
5494
- }
5495
- /**
5496
- * Inject modules and head file content before </head>.
5497
- */
5498
- function modifyHead(html, modulePaths, headFile, diagnostics) {
5499
- const parts = [];
5500
- if (modulePaths.length > 0) {
5501
- const modules = loadModules(modulePaths).trim();
5502
- if (modules.length > 0) parts.push(modules);
5503
- }
5504
- if (headFile) try {
5505
- const source = readUTF8(headFile).trim();
5506
- if (source.length > 0) parts.push(source);
5507
- } catch (e) {
5508
- diagnostics?.push({
5509
- level: "warning",
5510
- message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
5511
- });
5512
- }
5513
- if (parts.length > 0) {
5514
- parts.push("</head>");
5515
- const replacement = parts.join("\n");
5516
- return html.replace("</head>", () => replacement);
5517
- }
5518
- return html;
5519
- }
5520
- //#endregion
5521
5650
  //#region src/passage-omission.ts
5522
5651
  /**
5523
5652
  * Why the `target` output leaves `passage` out of its passage elements, or `undefined` when it
@@ -5594,12 +5723,13 @@ async function compileToFile(options) {
5594
5723
  * Builds as compileToFile() does for `outFile`, without writing it: the output may sit
5595
5724
  * inside a source folder, so its last build is left out of the sources and modules and
5596
5725
  * never read back. The caller decides whether the result is written. With no `outFile`
5597
- * (output to stdout, or none), this is compile().
5726
+ * (output to stdout, or none), this is compile(). With `cache`, files are cached as
5727
+ * compileIncremental() caches them.
5598
5728
  *
5599
- * Internal, for the CLI; not part of the public API.
5729
+ * Internal, for the CLI and the bundler plugins; not part of the public API.
5600
5730
  */
5601
- async function compileForOutputFile(options, outFile) {
5602
- return buildOutput(options, void 0, void 0, outFile);
5731
+ async function compileForOutputFile(options, outFile, cache) {
5732
+ return buildOutput(options, cache, void 0, outFile);
5603
5733
  }
5604
5734
  /**
5605
5735
  * Compile with incremental caching support.
@@ -5615,7 +5745,9 @@ async function compileIncremental(options, cache, changedFiles) {
5615
5745
  }
5616
5746
  /**
5617
5747
  * Watch for file changes and recompile. Every build is written to `outFile`, including
5618
- * one whose diagnostics report errors.
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.
5619
5751
  */
5620
5752
  async function watch(options) {
5621
5753
  return watchWithWriteFilter(options, () => true);
@@ -5645,17 +5777,62 @@ async function watchWithWriteFilter(options, shouldWrite) {
5645
5777
  const abs = resolve(filename);
5646
5778
  return !notExcludable.some((root) => abs === root || abs.startsWith(root + sep));
5647
5779
  };
5780
+ let building = false;
5781
+ let queued;
5782
+ const deliver = (outcome) => {
5783
+ try {
5784
+ if (!outcome.ok) throw outcome.error;
5785
+ if (shouldWrite(outcome.result)) writeFileSync(options.outFile, outcome.result.output, "utf-8");
5786
+ options.onBuild?.(outcome.result);
5787
+ } catch (e) {
5788
+ options.onError?.(toError(e));
5789
+ }
5790
+ };
5791
+ const drain = async (first) => {
5792
+ building = true;
5793
+ try {
5794
+ let request = first;
5795
+ while (request !== void 0) {
5796
+ const current = request;
5797
+ const outcome = await buildOutput(options, cache, current.changedFiles, options.outFile).then((result) => ({
5798
+ ok: true,
5799
+ result
5800
+ }), (e) => ({
5801
+ ok: false,
5802
+ error: toError(e)
5803
+ }));
5804
+ if (controller.signal.aborted) return;
5805
+ request = queued === void 0 ? void 0 : mergeBuildRequests(current, queued);
5806
+ queued = void 0;
5807
+ if (request === void 0) deliver(outcome);
5808
+ }
5809
+ } finally {
5810
+ building = false;
5811
+ }
5812
+ };
5648
5813
  const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
5649
- buildOutput(options, cache, changedFiles, options.outFile).then((result) => {
5650
- if (shouldWrite(result)) writeFileSync(options.outFile, result.output, "utf-8");
5651
- options.onBuild?.(result);
5652
- }).catch((e) => {
5653
- options.onError?.(e instanceof Error ? e : new Error(String(e)));
5654
- });
5814
+ if (controller.signal.aborted) return;
5815
+ const request = { changedFiles };
5816
+ if (building) {
5817
+ queued = queued === void 0 ? request : mergeBuildRequests(queued, request);
5818
+ return;
5819
+ }
5820
+ drain(request);
5655
5821
  }, ignore);
5656
- controller.signal.addEventListener("abort", () => handle.close());
5822
+ controller.signal.addEventListener("abort", () => {
5823
+ queued = void 0;
5824
+ handle.close();
5825
+ });
5657
5826
  return controller;
5658
5827
  }
5828
+ /** One build covering both requests: a full build if either is one, else every changed file. */
5829
+ function mergeBuildRequests(a, b) {
5830
+ if (a.changedFiles === void 0 || b.changedFiles === void 0) return { changedFiles: void 0 };
5831
+ return { changedFiles: /* @__PURE__ */ new Set([...a.changedFiles, ...b.changedFiles]) };
5832
+ }
5833
+ function toError(e) {
5834
+ return e instanceof Error ? e : new Error(String(e));
5835
+ }
5659
5836
  /**
5660
5837
  * `outFile`: the file the build is written to, which source and module discovery
5661
5838
  * skip (as Tweego does), so an output inside a source folder is never loaded back.
@@ -5717,17 +5894,17 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5717
5894
  case "html": {
5718
5895
  diagnostics.push(...startPassageDiagnostics(story, startName, format?.isTwine2 === false ? "twine1" : "twine2"));
5719
5896
  if (!format) throw new TweeTsError("No story format available for HTML output.", diagnostics);
5720
- if (format.isTwine2) output = toTwine2HTML(story, format, startName, { sourceInfo });
5721
- else {
5722
- if (story.name === "" && !storyHas(story, "StoryTitle")) diagnostics.push({
5723
- level: "error",
5724
- message: "Special passage \"StoryTitle\" not found."
5725
- });
5726
- output = toTwine1HTML(story, format, startName);
5727
- }
5897
+ if (!format.isTwine2 && story.name === "" && !storyHas(story, "StoryTitle")) diagnostics.push({
5898
+ level: "error",
5899
+ message: "Special passage \"StoryTitle\" not found."
5900
+ });
5728
5901
  const modules = getFilenames(options.modules ?? [], outFile);
5729
5902
  diagnostics.push(...modules.diagnostics);
5730
- output = modifyHead(output, modules.filenames, options.headFile, diagnostics);
5903
+ const head = loadHeadContent(modules.filenames, options.headFile, diagnostics);
5904
+ output = format.isTwine2 ? toTwine2HTML(story, format, startName, {
5905
+ sourceInfo,
5906
+ head
5907
+ }) : toTwine1HTML(story, format, startName, { head });
5731
5908
  break;
5732
5909
  }
5733
5910
  default: throw new TweeTsError(`Unhandled output mode: ${outputMode}`, diagnostics);
@@ -7228,6 +7405,16 @@ function passageLinks(p) {
7228
7405
  return extractLinksFromText(textAsRead(p), false);
7229
7406
  }
7230
7407
  /**
7408
+ * Whether the `target` output keeps the links in `p`: always without a target, which describes
7409
+ * every source passage. With one, a passage the output leaves out never reaches the player, so it
7410
+ * links nowhere, except a script passage, which Twine 2 output leaves out as a passage but runs.
7411
+ */
7412
+ function readsLinks(story, p, { target }) {
7413
+ if (target === void 0) return true;
7414
+ const omission = passageOmission(story, p, target);
7415
+ return omission === void 0 || omission.kind === "tag" && omission.tag === "script";
7416
+ }
7417
+ /**
7231
7418
  * Each passage name, mapped to why the `target` output leaves that passage out, or to `undefined`
7232
7419
  * when a link can reach it: always without a target, and otherwise when the output emits a
7233
7420
  * passage of that name.
@@ -7285,7 +7472,9 @@ function joinLines(text) {
7285
7472
  * By default, a link is broken only when no passage has its name. Pass a `target` to check links
7286
7473
  * against what that output emits: `storyInspect(result.story, { target: 'twine2' })` also reports
7287
7474
  * links to passages Twine 2 output leaves out, such as script, stylesheet and `Twine.private`
7288
- * passages.
7475
+ * passages. With a target, passages that output leaves out also give no links (script passages
7476
+ * excepted, as Twine 2 output runs them), so they report no broken links and keep no passage from
7477
+ * being an orphan.
7289
7478
  */
7290
7479
  function storyInspect(story, options = {}) {
7291
7480
  const destinations = destinationOmissions(story, options);
@@ -7310,7 +7499,7 @@ function storyInspect(story, options = {}) {
7310
7499
  }
7311
7500
  list.push(p.name);
7312
7501
  }
7313
- links.set(p.name, passageLinks(p));
7502
+ links.set(p.name, readsLinks(story, p, options) ? passageLinks(p) : []);
7314
7503
  }
7315
7504
  const brokenLinks = [];
7316
7505
  for (const [from, targets] of links) for (const to of targets) {
@@ -7359,7 +7548,8 @@ function storyInspect(story, options = {}) {
7359
7548
  * Uses JSON output mode internally to avoid format resolution, so it checks
7360
7549
  * the starting passage itself, against the Twine 2 passage rules. Link
7361
7550
  * destinations are checked against the same rules: a link to a passage that
7362
- * Twine 2 output leaves out is broken.
7551
+ * Twine 2 output leaves out is broken, and a passage it leaves out (other than
7552
+ * a script passage) gives no links.
7363
7553
  */
7364
7554
  async function lint(options) {
7365
7555
  return lintForOutputFile(options, void 0);