@rohal12/twee-ts 1.16.1 → 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":
@@ -4719,14 +4735,16 @@ function parseDocument(data, options) {
4719
4735
  */
4720
4736
  /**
4721
4737
  * Parse a Twine 2 or Twine 1 compiled HTML file back into a Story model.
4738
+ * Tweego always trims passage text; here `trim: false` keeps it exactly, as for Twee sources.
4722
4739
  */
4723
- function decompileHTML(html) {
4740
+ function decompileHTML(html, options = {}) {
4724
4741
  const diagnostics = [];
4725
4742
  const story = createStory();
4743
+ const passageText = options.trim === false ? (text) => text : (text) => text.trim();
4726
4744
  const doc = parseDocument(html);
4727
4745
  const twine2Data = findElement(doc, "tw-storydata");
4728
4746
  if (twine2Data) {
4729
- decompileTwine2(twine2Data, story, diagnostics);
4747
+ decompileTwine2(twine2Data, story, passageText, diagnostics);
4730
4748
  return {
4731
4749
  story,
4732
4750
  diagnostics
@@ -4734,7 +4752,7 @@ function decompileHTML(html) {
4734
4752
  }
4735
4753
  const twine1Data = findElementByIdPattern(doc, /^store(?:-a|A)rea$/);
4736
4754
  if (twine1Data) {
4737
- decompileTwine1(twine1Data, story, diagnostics);
4755
+ decompileTwine1(twine1Data, story, passageText, diagnostics);
4738
4756
  return {
4739
4757
  story,
4740
4758
  diagnostics
@@ -4749,7 +4767,7 @@ function decompileHTML(html) {
4749
4767
  diagnostics
4750
4768
  };
4751
4769
  }
4752
- function decompileTwine2(storyData, story, diagnostics) {
4770
+ function decompileTwine2(storyData, story, passageText, diagnostics) {
4753
4771
  let startnode = 0;
4754
4772
  const attrs = storyData.attribs ?? {};
4755
4773
  if (attrs["name"]) story.name = attrs["name"];
@@ -4761,7 +4779,7 @@ function decompileTwine2(storyData, story, diagnostics) {
4761
4779
  });
4762
4780
  else startnode = parsed;
4763
4781
  }
4764
- if (attrs["ifid"]) story.ifid = attrs["ifid"].toUpperCase();
4782
+ if (attrs["ifid"]) story.ifid = normalizeIFID(attrs["ifid"]);
4765
4783
  if (attrs["zoom"]) {
4766
4784
  const parsed = parseFloat(attrs["zoom"]);
4767
4785
  if (Number.isNaN(parsed)) diagnostics.push({
@@ -4774,15 +4792,20 @@ function decompileTwine2(storyData, story, diagnostics) {
4774
4792
  if (attrs["format"]) story.twine2.format = attrs["format"];
4775
4793
  if (attrs["format-version"]) story.twine2.formatVersion = attrs["format-version"];
4776
4794
  if (attrs["options"]) for (const opt of attrs["options"].split(/\s+/).filter((s) => s.length > 0)) story.twine2.options.set(opt, true);
4777
- 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) {
4778
4798
  if (node.type !== "tag" && node.type !== "style" && node.type !== "script") continue;
4779
4799
  switch (node.name) {
4780
4800
  case "style":
4781
4801
  case "script": {
4782
- const text = getTextContent(node).trim();
4783
- if (text.length === 0) continue;
4802
+ const content = getTextContent(node);
4803
+ if (content.trim().length === 0) continue;
4804
+ const text = passageText(content);
4805
+ const name = freeName(node.name === "style" ? "Story Stylesheet" : "Story JavaScript", takenNames);
4806
+ takenNames.add(name);
4784
4807
  storyAdd(story, {
4785
- name: node.name === "style" ? "Story Stylesheet" : "Story JavaScript",
4808
+ name,
4786
4809
  tags: node.name === "style" ? ["stylesheet"] : ["script"],
4787
4810
  text
4788
4811
  }, diagnostics);
@@ -4798,7 +4821,7 @@ function decompileTwine2(storyData, story, diagnostics) {
4798
4821
  case "tw-passagedata": {
4799
4822
  let pid = 0;
4800
4823
  const pAttrs = node.attribs ?? {};
4801
- const name = pAttrs["name"] ?? "";
4824
+ const name = passageDataName(node);
4802
4825
  const tags = pAttrs["tags"] ? pAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [];
4803
4826
  const metadata = {};
4804
4827
  if (pAttrs["pid"]) {
@@ -4815,7 +4838,7 @@ function decompileTwine2(storyData, story, diagnostics) {
4815
4838
  const passage = {
4816
4839
  name,
4817
4840
  tags,
4818
- text: getTextContent(node).trim()
4841
+ text: passageText(getTextContent(node))
4819
4842
  };
4820
4843
  if (metadata.position || metadata.size) passage.metadata = metadata;
4821
4844
  storyAdd(story, passage, diagnostics);
@@ -4829,8 +4852,19 @@ function decompileTwine2(storyData, story, diagnostics) {
4829
4852
  text: marshalStoryData(story)
4830
4853
  }, diagnostics);
4831
4854
  }
4832
- function decompileTwine1(storeArea, story, diagnostics) {
4833
- const passages = (storeArea.children ?? []).filter(isTiddler).map(tiddlerToPassage);
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
+ }
4866
+ function decompileTwine1(storeArea, story, passageText, diagnostics) {
4867
+ const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
4834
4868
  const obfuscated = isRot13Obfuscated(passages);
4835
4869
  for (const p of passages) storyAdd(story, obfuscated && p.name !== "StorySettings" ? {
4836
4870
  ...p,
@@ -4840,12 +4874,12 @@ function decompileTwine1(storeArea, story, diagnostics) {
4840
4874
  function isTiddler(node) {
4841
4875
  return node.type === "tag" && node.name === "div" && node.attribs !== void 0 && "tiddler" in node.attribs;
4842
4876
  }
4843
- function tiddlerToPassage(node) {
4877
+ function tiddlerToPassage(node, passageText) {
4844
4878
  const nodeAttrs = node.attribs ?? {};
4845
4879
  const passage = {
4846
4880
  name: nodeAttrs["tiddler"] ?? "",
4847
4881
  tags: nodeAttrs["tags"] ? nodeAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [],
4848
- text: tiddlerUnescape(getTextContent(node)).trim()
4882
+ text: passageText(tiddlerUnescape(getTextContent(node)))
4849
4883
  };
4850
4884
  const position = nodeAttrs["twine-position"];
4851
4885
  if (position) passage.metadata = { position };
@@ -4910,7 +4944,7 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
4910
4944
  break;
4911
4945
  case "htm":
4912
4946
  case "html":
4913
- loadHTML(story, filename, diagnostics);
4947
+ loadHTML(story, filename, opts, diagnostics);
4914
4948
  break;
4915
4949
  case "css":
4916
4950
  loadTagged(story, "stylesheet", filename, diagnostics);
@@ -5064,8 +5098,8 @@ function parseFontFile(filename) {
5064
5098
  diagnostics: []
5065
5099
  };
5066
5100
  }
5067
- function parseHTMLFile(filename) {
5068
- const { story, diagnostics } = decompileHTML(readUTF8(filename));
5101
+ function parseHTMLFile(filename, opts) {
5102
+ const { story, diagnostics } = decompileHTML(readUTF8(filename), { trim: opts.trim ?? true });
5069
5103
  return {
5070
5104
  passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
5071
5105
  name: "StoryTitle",
@@ -5077,14 +5111,17 @@ function parseHTMLFile(filename) {
5077
5111
  }
5078
5112
  /**
5079
5113
  * Identity of the options that affect how a file parses, stored on its cache entry.
5080
- * Only Twee files depend on them; every other file type yields the same key for all options.
5114
+ * Twee files depend on `trim` and `twee2Compat`, HTML files on `trim`; every other file type
5115
+ * yields the same key for all options.
5081
5116
  */
5082
5117
  function parseOptionsKey(filename, opts) {
5083
5118
  const ext = normalizedFileExt(filename);
5119
+ const trim = opts.trim ?? true;
5120
+ if (ext === "htm" || ext === "html") return JSON.stringify({ trim });
5084
5121
  const twee2File = ext === "tw2" || ext === "twee2";
5085
5122
  if (!twee2File && ext !== "tw" && ext !== "twee") return "";
5086
5123
  return JSON.stringify({
5087
- trim: opts.trim ?? true,
5124
+ trim,
5088
5125
  twee2Compat: twee2File || (opts.twee2Compat ?? false)
5089
5126
  });
5090
5127
  }
@@ -5098,7 +5135,7 @@ function parseFile(filename, opts) {
5098
5135
  twee2Compat: true
5099
5136
  });
5100
5137
  case "htm":
5101
- case "html": return parseHTMLFile(filename);
5138
+ case "html": return parseHTMLFile(filename, opts);
5102
5139
  case "css": return parseTaggedFile("stylesheet", filename);
5103
5140
  case "js": return parseTaggedFile("script", filename);
5104
5141
  case "otf":
@@ -5130,8 +5167,8 @@ function parseFile(filename, opts) {
5130
5167
  default: return;
5131
5168
  }
5132
5169
  }
5133
- function loadHTML(story, filename, diagnostics) {
5134
- const result = parseHTMLFile(filename);
5170
+ function loadHTML(story, filename, opts, diagnostics) {
5171
+ const result = parseHTMLFile(filename, opts);
5135
5172
  diagnostics.push(...result.diagnostics);
5136
5173
  for (const p of result.passages) storyAdd(story, p, diagnostics);
5137
5174
  }
@@ -5170,7 +5207,8 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5170
5207
  const optionsKey = parseOptionsKey(filename, opts);
5171
5208
  const entry = cache.get(filename);
5172
5209
  const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
5173
- if (changedFiles && cached && !changedFiles.has(filename)) {
5210
+ const changed = changedFiles?.has(filename) ?? false;
5211
+ if (changedFiles && cached && !changed) {
5174
5212
  diagnostics.push(...cached.diagnostics);
5175
5213
  for (const p of cached.passages) storyAdd(story, p, diagnostics);
5176
5214
  processedFiles.add(filename);
@@ -5182,7 +5220,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5182
5220
  } catch {
5183
5221
  continue;
5184
5222
  }
5185
- if (cached && cached.mtimeMs === mtimeMs) {
5223
+ if (cached && !changed && cached.mtimeMs === mtimeMs) {
5186
5224
  diagnostics.push(...cached.diagnostics);
5187
5225
  for (const p of cached.passages) storyAdd(story, p, diagnostics);
5188
5226
  processedFiles.add(filename);
@@ -5217,6 +5255,121 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5217
5255
  }, diagnostics);
5218
5256
  }
5219
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
5220
5373
  //#region src/version.ts
5221
5374
  /**
5222
5375
  * The twee-ts version, read at runtime from the package's own package.json.
@@ -5250,24 +5403,38 @@ const CREATOR_NAME$2 = "Twee-ts";
5250
5403
  function toTwine2Archive(story, startName, options) {
5251
5404
  return getTwine2DataChunk(story, startName, options) + "\n";
5252
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
+ */
5253
5412
  function toTwine2HTML(story, format, startName, options) {
5254
- let template = readFormatSource(format);
5255
- if (template.includes("{{STORY_NAME}}")) {
5256
- const name = htmlEscape(story.name);
5257
- template = template.replaceAll("{{STORY_NAME}}", () => name);
5258
- }
5259
- if (template.includes("{{STORY_DATA}}")) {
5260
- const data = getTwine2DataChunk({
5261
- ...story,
5262
- twine2: {
5263
- ...story.twine2,
5264
- format: format.name,
5265
- 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);
5266
5433
  }
5267
- }, startName, options);
5268
- template = template.replace("{{STORY_DATA}}", () => data);
5269
- }
5270
- return template;
5434
+ },
5435
+ ...head === void 0 ? [] : [head]
5436
+ ];
5437
+ return fillTemplate(readFormatSource(format), slots);
5271
5438
  }
5272
5439
  const OMITTING_TAGS = [
5273
5440
  "Twine.private",
@@ -5360,7 +5527,13 @@ function toTwine1Archive(story, _startName) {
5360
5527
  const { data, count } = getTwine1PassageChunk(story);
5361
5528
  return `<div id="storeArea" data-size="${count}">${data}</div>\n`;
5362
5529
  }
5363
- 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) {
5364
5537
  const formatDir = (0, node_path.dirname)(format.filename);
5365
5538
  const parentDir = (0, node_path.dirname)(formatDir);
5366
5539
  let template = readFormatSource(format);
@@ -5371,28 +5544,60 @@ function toTwine1HTML(story, format, startName) {
5371
5544
  template = tryReplaceComponent(template, "\"JONAH\"", (0, node_path.join)(formatDir, "code.js"), true);
5372
5545
  if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", (0, node_path.join)(parentDir, "jquery.js"), true);
5373
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);
5374
5556
  const displayStart = startName === "Start" ? "" : startName;
5375
- template = template.replace("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`);
5376
- template = template.replace("\"TIME\"", `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`);
5377
- const startAtValue = `"${jsStringEscape(displayStart)}"`;
5378
- template = template.replace("\"START_AT\"", () => startAtValue);
5379
- template = template.replace("\"STORY_SIZE\"", `"${count}"`);
5380
- if (template.includes("\"STORY\"")) template = template.replace("\"STORY\"", () => data);
5381
- else {
5382
- let footer;
5383
- try {
5384
- footer = (0, node_fs.readFileSync)((0, node_path.join)(formatDir, "footer.html"), "utf-8");
5385
- } catch {
5386
- footer = "</div>\n</body>\n</html>\n";
5387
- }
5388
- template += data + footer;
5389
- }
5390
- if (story.ifid) {
5391
- const safeIfid = htmlCommentSanitize(story.ifid);
5392
- if (template.includes("<div id=\"store-area\"")) template = template.replace("<div id=\"store-area\"", `<!-- UUID://${safeIfid}// --><div id="store-area"`);
5393
- 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";
5394
5600
  }
5395
- return template;
5396
5601
  }
5397
5602
  /**
5398
5603
  * Why Twine 1 output leaves a passage out of its tiddlers, or `undefined` when the passage is emitted.
@@ -5430,82 +5635,54 @@ function tryReplaceComponent(template, placeholder, componentPath, required) {
5430
5635
  }
5431
5636
  //#endregion
5432
5637
  //#region src/output-twee.ts
5433
- function toTwee(story, outMode) {
5434
- let data = "";
5435
- for (const p of story.passages) data += passageToTwee(p, outMode);
5436
- return data;
5437
- }
5438
- //#endregion
5439
- //#region src/modules.ts
5440
5638
  /**
5441
- * Load modules and return HTML tags to inject before </head>.
5639
+ * Serialize the story as Twee. The StoryData passage is written from the story model rather than
5640
+ * from its loaded text, so changes made after loading (a start passage override, test mode) are kept.
5641
+ * Tweego writes the loaded text; its own normalization makes the two the same for an unchanged story.
5642
+ *
5643
+ * `addStoryData`: add a StoryData passage when the story has none, so that StoryData fields the
5644
+ * compile options set are recorded. Default: false.
5442
5645
  */
5443
- function loadModules(filenames) {
5444
- const processed = /* @__PURE__ */ new Set();
5445
- const headTags = [];
5446
- for (const filename of filenames) {
5447
- if (processed.has(filename)) continue;
5448
- const ext = normalizedFileExt(filename);
5449
- let tag = null;
5450
- switch (ext) {
5451
- case "css":
5452
- tag = loadModuleTagged("style", filename);
5453
- break;
5454
- case "js":
5455
- tag = loadModuleTagged("script", filename);
5456
- break;
5457
- case "otf":
5458
- case "ttf":
5459
- case "woff":
5460
- case "woff2":
5461
- tag = loadModuleFont(filename);
5462
- break;
5463
- default: continue;
5464
- }
5465
- if (tag) headTags.push(tag);
5466
- processed.add(filename);
5467
- }
5468
- return headTags.join("\n");
5646
+ function toTwee(story, outMode, options = {}) {
5647
+ return withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => passageToTwee(p, outMode)).join("");
5469
5648
  }
5470
- function loadModuleTagged(tag, filename) {
5471
- const source = readUTF8(filename).trim();
5472
- if (source.length === 0) return null;
5473
- return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
5649
+ function withEffectiveStoryData(story, addStoryData) {
5650
+ const storyData = {
5651
+ name: "StoryData",
5652
+ tags: [],
5653
+ text: marshalStoryData(story)
5654
+ };
5655
+ const index = story.passages.findIndex((p) => p.name === "StoryData");
5656
+ if (index !== -1) return story.passages.map((p, i) => i === index && isStoryDataJSON(p.text) ? {
5657
+ ...p,
5658
+ text: storyData.text
5659
+ } : p);
5660
+ if (!addStoryData) return story.passages;
5661
+ const at = story.passages[0]?.name === "StoryTitle" ? 1 : 0;
5662
+ return [
5663
+ ...story.passages.slice(0, at),
5664
+ storyData,
5665
+ ...story.passages.slice(at)
5666
+ ];
5474
5667
  }
5475
- function loadModuleFont(filename) {
5476
- const source = readBase64(filename);
5477
- const family = baseNameWithoutExt(filename);
5478
- const idSlug = `style-module-${slugify(family)}`;
5479
- const ext = normalizedFileExt(filename);
5480
- 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>`;
5668
+ /** Whether the text loaded as StoryData. Text that did not is kept as written, for the author to fix. */
5669
+ function isStoryDataJSON(text) {
5670
+ return unmarshalStoryData(createStory(), text) === null;
5481
5671
  }
5672
+ //#endregion
5673
+ //#region src/passage-omission.ts
5482
5674
  /**
5483
- * Inject modules and head file content before </head>.
5675
+ * Why the `target` output leaves `passage` out of its passage elements, or `undefined` when it
5676
+ * emits the passage (and so a link or the start can name it).
5484
5677
  */
5485
- function modifyHead(html, modulePaths, headFile, diagnostics) {
5486
- const parts = [];
5487
- if (modulePaths.length > 0) {
5488
- const modules = loadModules(modulePaths).trim();
5489
- if (modules.length > 0) parts.push(modules);
5490
- }
5491
- if (headFile) try {
5492
- const source = readUTF8(headFile).trim();
5493
- if (source.length > 0) parts.push(source);
5494
- } catch (e) {
5495
- diagnostics?.push({
5496
- level: "warning",
5497
- message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
5498
- });
5499
- }
5500
- if (parts.length > 0) {
5501
- parts.push("</head>");
5502
- const replacement = parts.join("\n");
5503
- return html.replace("</head>", () => replacement);
5678
+ function passageOmission(story, passage, target) {
5679
+ switch (target) {
5680
+ case "twine2": return twine2PassageOmission(story, passage);
5681
+ case "twine1": return twine1PassageOmission(passage);
5682
+ default: throw new Error(`unhandled passage output target: ${String(target)}`);
5504
5683
  }
5505
- return html;
5506
5684
  }
5507
- //#endregion
5508
- //#region src/start-passage.ts
5685
+ /** Why a passage is left out, as a predicate: `Passage "X" ${describeOmission(o)}`. */
5509
5686
  function describeOmission(omission) {
5510
5687
  switch (omission.kind) {
5511
5688
  case "special-name": return `is the special "${omission.name}" passage`;
@@ -5514,13 +5691,8 @@ function describeOmission(omission) {
5514
5691
  default: throw new Error(`unhandled passage omission: ${JSON.stringify(omission)}`);
5515
5692
  }
5516
5693
  }
5517
- function omissionFor(story, passage, target) {
5518
- switch (target) {
5519
- case "twine2": return twine2PassageOmission(story, passage);
5520
- case "twine1": return twine1PassageOmission(passage);
5521
- default: throw new Error(`unhandled start passage target: ${String(target)}`);
5522
- }
5523
- }
5694
+ //#endregion
5695
+ //#region src/start-passage.ts
5524
5696
  /**
5525
5697
  * Check that `startName` names a passage the `target` output emits.
5526
5698
  * Returns the error diagnostics to report (empty when the start is usable).
@@ -5531,7 +5703,7 @@ function startPassageDiagnostics(story, startName, target) {
5531
5703
  level: "error",
5532
5704
  message: `Starting passage "${startName}" not found.`
5533
5705
  }];
5534
- const omission = omissionFor(story, passage, target);
5706
+ const omission = passageOmission(story, passage, target);
5535
5707
  if (omission === void 0) return [];
5536
5708
  return [{
5537
5709
  level: "error",
@@ -5566,21 +5738,51 @@ async function compile(options) {
5566
5738
  * Compile and write to a file.
5567
5739
  */
5568
5740
  async function compileToFile(options) {
5569
- const result = await buildOutput(options, void 0, void 0, options.outFile);
5741
+ const result = await compileForOutputFile(options, options.outFile);
5570
5742
  (0, node_fs.writeFileSync)(options.outFile, result.output, "utf-8");
5571
5743
  return result;
5572
5744
  }
5573
5745
  /**
5746
+ * Builds as compileToFile() does for `outFile`, without writing it: the output may sit
5747
+ * inside a source folder, so its last build is left out of the sources and modules and
5748
+ * never read back. The caller decides whether the result is written. With no `outFile`
5749
+ * (output to stdout, or none), this is compile(). With `cache`, files are cached as
5750
+ * compileIncremental() caches them.
5751
+ *
5752
+ * Internal, for the CLI and the bundler plugins; not part of the public API.
5753
+ */
5754
+ async function compileForOutputFile(options, outFile, cache) {
5755
+ return buildOutput(options, cache, void 0, outFile);
5756
+ }
5757
+ /**
5574
5758
  * Compile with incremental caching support.
5575
5759
  * Plugins and advanced users can manage their own cache and changed-file tracking.
5760
+ *
5761
+ * Without `changedFiles`, a cached file is reused while its modification time is unchanged.
5762
+ * With it, a file it names (by the path source discovery gives it, relative to the working
5763
+ * directory) is always reparsed, whatever its modification time, and every other cached
5764
+ * file is reused as it is.
5576
5765
  */
5577
5766
  async function compileIncremental(options, cache, changedFiles) {
5578
5767
  return buildOutput(options, cache, changedFiles);
5579
5768
  }
5580
5769
  /**
5581
- * Watch for file changes and recompile.
5770
+ * Watch for file changes and recompile. Every build is written to `outFile`, including
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.
5582
5774
  */
5583
5775
  async function watch(options) {
5776
+ return watchWithWriteFilter(options, () => true);
5777
+ }
5778
+ /**
5779
+ * watch(), writing to `outFile` only the builds `shouldWrite` accepts. A rejected build
5780
+ * leaves the output file as it was; `onBuild` receives every build either way.
5781
+ *
5782
+ * Internal, for the CLI, which keeps the last good output when a rebuild reports errors;
5783
+ * not part of the public API.
5784
+ */
5785
+ async function watchWithWriteFilter(options, shouldWrite) {
5584
5786
  const controller = new AbortController();
5585
5787
  const cache = /* @__PURE__ */ new Map();
5586
5788
  const filePaths = options.sources.filter((s) => typeof s === "string");
@@ -5598,17 +5800,62 @@ async function watch(options) {
5598
5800
  const abs = (0, node_path.resolve)(filename);
5599
5801
  return !notExcludable.some((root) => abs === root || abs.startsWith(root + node_path.sep));
5600
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
+ };
5601
5836
  const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
5602
- buildOutput(options, cache, changedFiles, options.outFile).then((result) => {
5603
- (0, node_fs.writeFileSync)(options.outFile, result.output, "utf-8");
5604
- options.onBuild?.(result);
5605
- }).catch((e) => {
5606
- options.onError?.(e instanceof Error ? e : new Error(String(e)));
5607
- });
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);
5608
5844
  }, ignore);
5609
- controller.signal.addEventListener("abort", () => handle.close());
5845
+ controller.signal.addEventListener("abort", () => {
5846
+ queued = void 0;
5847
+ handle.close();
5848
+ });
5610
5849
  return controller;
5611
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
+ }
5612
5859
  /**
5613
5860
  * `outFile`: the file the build is written to, which source and module discovery
5614
5861
  * skip (as Tweego does), so an output inside a source folder is never loaded back.
@@ -5656,7 +5903,7 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5656
5903
  switch (outputMode) {
5657
5904
  case "twee3":
5658
5905
  case "twee1":
5659
- output = toTwee(story, outputMode);
5906
+ output = toTwee(story, outputMode, { addStoryData: Boolean(options.startPassage) || testMode });
5660
5907
  break;
5661
5908
  case "twine2-archive":
5662
5909
  output = toTwine2Archive(story, startName, { sourceInfo });
@@ -5670,17 +5917,17 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5670
5917
  case "html": {
5671
5918
  diagnostics.push(...startPassageDiagnostics(story, startName, format?.isTwine2 === false ? "twine1" : "twine2"));
5672
5919
  if (!format) throw new TweeTsError("No story format available for HTML output.", diagnostics);
5673
- if (format.isTwine2) output = toTwine2HTML(story, format, startName, { sourceInfo });
5674
- else {
5675
- if (story.name === "" && !storyHas(story, "StoryTitle")) diagnostics.push({
5676
- level: "error",
5677
- message: "Special passage \"StoryTitle\" not found."
5678
- });
5679
- output = toTwine1HTML(story, format, startName);
5680
- }
5920
+ if (!format.isTwine2 && story.name === "" && !storyHas(story, "StoryTitle")) diagnostics.push({
5921
+ level: "error",
5922
+ message: "Special passage \"StoryTitle\" not found."
5923
+ });
5681
5924
  const modules = getFilenames(options.modules ?? [], outFile);
5682
5925
  diagnostics.push(...modules.diagnostics);
5683
- 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 });
5684
5931
  break;
5685
5932
  }
5686
5933
  default: throw new TweeTsError(`Unhandled output mode: ${outputMode}`, diagnostics);
@@ -6099,6 +6346,11 @@ function endOfRegularExpression(source, pos) {
6099
6346
  }
6100
6347
  //#endregion
6101
6348
  //#region src/link-markup.ts
6349
+ /** Records that the reading has looked at the characters before `pos`, and returns `result`. */
6350
+ function readTo(reader, pos, result) {
6351
+ reader.scanned = Math.max(reader.scanned, pos);
6352
+ return result;
6353
+ }
6102
6354
  /**
6103
6355
  * Reads link or image markup that starts at `start` in `text`, or returns `undefined` where
6104
6356
  * SugarCube rejects it (and so prints the text instead).
@@ -6106,15 +6358,17 @@ function endOfRegularExpression(source, pos) {
6106
6358
  * `budget.left`, if given, is how many characters the reading may look at; it is reduced by as
6107
6359
  * many as were read, and once it runs out, every reading fails. Unclosed markup is read to the end
6108
6360
  * of its line, so reading every `[[` of a long line that never closes one would otherwise take
6109
- * quadratic time.
6361
+ * quadratic time. A reading is charged for the characters it looked at, up to where it ended or
6362
+ * failed (a line feed, say), and no more, so markup left unclosed on short lines costs little.
6110
6363
  */
6111
6364
  function readSquareBracketedMarkup(text, start, budget = { left: Infinity }) {
6112
6365
  const reader = {
6113
6366
  text,
6114
- limit: Math.min(text.length, start + Math.max(budget.left, 0))
6367
+ limit: Math.min(text.length, start + Math.max(budget.left, 0)),
6368
+ scanned: start
6115
6369
  };
6116
6370
  const markup = readMarkup(reader, start);
6117
- budget.left -= (markup?.end ?? reader.limit) - start;
6371
+ budget.left -= Math.max(reader.scanned, markup?.end ?? start) - start;
6118
6372
  return markup;
6119
6373
  }
6120
6374
  function readMarkup(reader, start) {
@@ -6190,7 +6444,7 @@ function readCoreComponents(reader, from) {
6190
6444
  const ch = text[pos];
6191
6445
  pos += 1;
6192
6446
  switch (ch) {
6193
- case "\n": return;
6447
+ case "\n": return readTo(reader, pos, void 0);
6194
6448
  case "\"": {
6195
6449
  const end = readQuoted(reader, pos, "\"");
6196
6450
  if (end === void 0) return;
@@ -6224,7 +6478,7 @@ function readCoreComponents(reader, from) {
6224
6478
  case "]":
6225
6479
  depth -= 1;
6226
6480
  if (depth === 1) {
6227
- const end = componentEnd(text, pos);
6481
+ const end = componentEnd(reader, pos);
6228
6482
  if (end === void 0) return;
6229
6483
  return {
6230
6484
  link: link ?? text.slice(componentStart, end.textEnd),
@@ -6233,6 +6487,7 @@ function readCoreComponents(reader, from) {
6233
6487
  }
6234
6488
  }
6235
6489
  }
6490
+ return readTo(reader, pos, void 0);
6236
6491
  }
6237
6492
  /**
6238
6493
  * Reads a component after `][` that holds no delimiter: an image's link (`setter` false) or a
@@ -6246,7 +6501,7 @@ function readComponent(reader, from, setter) {
6246
6501
  const ch = text[pos];
6247
6502
  pos += 1;
6248
6503
  switch (ch) {
6249
- case "\n": return;
6504
+ case "\n": return readTo(reader, pos, void 0);
6250
6505
  case "\"":
6251
6506
  case "'": {
6252
6507
  if (ch === "'" && !setter) break;
@@ -6260,9 +6515,10 @@ function readComponent(reader, from, setter) {
6260
6515
  break;
6261
6516
  case "]":
6262
6517
  depth -= 1;
6263
- if (depth === 1) return componentEnd(text, pos);
6518
+ if (depth === 1) return componentEnd(reader, pos);
6264
6519
  }
6265
6520
  }
6521
+ return readTo(reader, pos, void 0);
6266
6522
  }
6267
6523
  /** Reads a setter, the last component; returns the index after its closing `]]`. */
6268
6524
  function readSetter(reader, from) {
@@ -6270,19 +6526,19 @@ function readSetter(reader, from) {
6270
6526
  return end?.last === true ? end.next : void 0;
6271
6527
  }
6272
6528
  /** What the `]` just before `pos`, which brings the depth back to one, ends; `undefined` if malformed. */
6273
- function componentEnd(text, pos) {
6274
- switch (text[pos]) {
6275
- case "[": return {
6529
+ function componentEnd(reader, pos) {
6530
+ switch (reader.text[pos]) {
6531
+ case "[": return readTo(reader, pos + 1, {
6276
6532
  textEnd: pos - 1,
6277
6533
  next: pos + 1,
6278
6534
  last: false
6279
- };
6280
- case "]": return {
6535
+ });
6536
+ case "]": return readTo(reader, pos + 1, {
6281
6537
  textEnd: pos - 1,
6282
6538
  next: pos + 1,
6283
6539
  last: true
6284
- };
6285
- default: return;
6540
+ });
6541
+ default: return readTo(reader, pos + 1, void 0);
6286
6542
  }
6287
6543
  }
6288
6544
  /**
@@ -6297,12 +6553,13 @@ function readQuoted(reader, from, quote) {
6297
6553
  const ch = text[pos];
6298
6554
  pos += 1;
6299
6555
  if (ch === quote) return pos;
6300
- if (ch === "\n") return;
6556
+ if (ch === "\n") return readTo(reader, pos, void 0);
6301
6557
  if (ch === "\\") {
6302
- if (pos >= reader.limit || text[pos] === "\n") return;
6558
+ if (pos >= reader.limit || text[pos] === "\n") return readTo(reader, Math.min(pos + 1, reader.limit), void 0);
6303
6559
  pos += 1;
6304
6560
  }
6305
6561
  }
6562
+ return readTo(reader, pos, void 0);
6306
6563
  }
6307
6564
  //#endregion
6308
6565
  //#region src/sugarcube-macros.ts
@@ -7160,6 +7417,39 @@ function extractLinksFromText(text, isScript) {
7160
7417
  ].flatMap((via) => found.filter((link) => link.via === via).map((link) => link.passage));
7161
7418
  return [...new Set(ordered)];
7162
7419
  }
7420
+ /**
7421
+ * The passages that `p` links to. A script passage is JavaScript, read for links only in its
7422
+ * strings. A stylesheet is CSS, which the story puts in a style element and never wikifies, so it
7423
+ * links nowhere; a passage tagged both is a script, as Twine 2 output takes it.
7424
+ */
7425
+ function passageLinks(p) {
7426
+ if (hasTag(p, "script")) return extractLinksFromText(textAsRead(p), true);
7427
+ if (hasTag(p, "stylesheet")) return [];
7428
+ return extractLinksFromText(textAsRead(p), false);
7429
+ }
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
+ /**
7441
+ * Each passage name, mapped to why the `target` output leaves that passage out, or to `undefined`
7442
+ * when a link can reach it: always without a target, and otherwise when the output emits a
7443
+ * passage of that name.
7444
+ */
7445
+ function destinationOmissions(story, { target }) {
7446
+ const omissions = /* @__PURE__ */ new Map();
7447
+ for (const p of story.passages) {
7448
+ const omission = target === void 0 ? void 0 : passageOmission(story, p, target);
7449
+ if (!omissions.has(p.name) || omission === void 0) omissions.set(p.name, omission);
7450
+ }
7451
+ return omissions;
7452
+ }
7163
7453
  /** Passages SugarCube runs from their raw text, never joining their lines. */
7164
7454
  const RAW_TEXT_PASSAGES = /* @__PURE__ */ new Set([
7165
7455
  "StoryInit",
@@ -7201,9 +7491,16 @@ function joinLines(text) {
7201
7491
  * expect(map.brokenLinks).toEqual([]);
7202
7492
  * expect(map.deadEnds).not.toContain('Start');
7203
7493
  * ```
7204
- */
7205
- function storyInspect(story) {
7206
- const allNames = new Set(story.passages.map((p) => p.name));
7494
+ *
7495
+ * By default, a link is broken only when no passage has its name. Pass a `target` to check links
7496
+ * against what that output emits: `storyInspect(result.story, { target: 'twine2' })` also reports
7497
+ * links to passages Twine 2 output leaves out, such as script, stylesheet and `Twine.private`
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.
7501
+ */
7502
+ function storyInspect(story, options = {}) {
7503
+ const destinations = destinationOmissions(story, options);
7207
7504
  const passages = [];
7208
7505
  const storyPassages = [];
7209
7506
  const infoPassages = [];
@@ -7225,14 +7522,24 @@ function storyInspect(story) {
7225
7522
  }
7226
7523
  list.push(p.name);
7227
7524
  }
7228
- const targets = extractLinksFromText(textAsRead(p), hasTag(p, "script"));
7229
- links.set(p.name, targets);
7525
+ links.set(p.name, readsLinks(story, p, options) ? passageLinks(p) : []);
7230
7526
  }
7231
7527
  const brokenLinks = [];
7232
- for (const [from, targets] of links) for (const to of targets) if (!allNames.has(to)) brokenLinks.push({
7233
- from,
7234
- to
7235
- });
7528
+ for (const [from, targets] of links) for (const to of targets) {
7529
+ if (!destinations.has(to)) {
7530
+ brokenLinks.push({
7531
+ from,
7532
+ to
7533
+ });
7534
+ continue;
7535
+ }
7536
+ const omission = destinations.get(to);
7537
+ if (omission !== void 0) brokenLinks.push({
7538
+ from,
7539
+ to,
7540
+ omission
7541
+ });
7542
+ }
7236
7543
  const deadEnds = [];
7237
7544
  for (const name of storyPassages) {
7238
7545
  const targets = links.get(name);
@@ -7262,14 +7569,27 @@ function storyInspect(story) {
7262
7569
  /**
7263
7570
  * Lint a story: compile without output rendering, then inspect structure.
7264
7571
  * Uses JSON output mode internally to avoid format resolution, so it checks
7265
- * the starting passage itself, against the Twine 2 passage rules.
7572
+ * the starting passage itself, against the Twine 2 passage rules. Link
7573
+ * destinations are checked against the same rules: a link to a passage that
7574
+ * Twine 2 output leaves out is broken, and a passage it leaves out (other than
7575
+ * a script passage) gives no links.
7266
7576
  */
7267
7577
  async function lint(options) {
7268
- const result = await compile({
7578
+ return lintForOutputFile(options, void 0);
7579
+ }
7580
+ /**
7581
+ * lint() for a project whose builds are written to `outFile`: like a build for that file,
7582
+ * it leaves `outFile` out of the sources and modules, so an earlier build inside a source
7583
+ * folder is not linted as a source.
7584
+ *
7585
+ * Internal, for the CLI; not part of the public API.
7586
+ */
7587
+ async function lintForOutputFile(options, outFile) {
7588
+ const result = await compileForOutputFile({
7269
7589
  ...options,
7270
7590
  outputMode: "json"
7271
- });
7272
- const map = storyInspect(result.story);
7591
+ }, outFile);
7592
+ const map = storyInspect(result.story, { target: "twine2" });
7273
7593
  return {
7274
7594
  diagnostics: [...result.diagnostics, ...startPassageDiagnostics(result.story, map.start, "twine2")],
7275
7595
  stats: result.stats,
@@ -7296,7 +7616,10 @@ function formatLintReport(result) {
7296
7616
  if (result.brokenLinks.length > 0) {
7297
7617
  lines.push("");
7298
7618
  lines.push(`Broken links (${result.brokenLinks.length}):`);
7299
- for (const link of result.brokenLinks) lines.push(` ${link.from} -> ${link.to} (passage "${link.to}" does not exist)`);
7619
+ for (const link of result.brokenLinks) {
7620
+ const why = link.omission === void 0 ? "does not exist" : `${describeOmission(link.omission)}, so it is left out of the story data`;
7621
+ lines.push(` ${link.from} -> ${link.to} (passage "${link.to}" ${why})`);
7622
+ }
7300
7623
  }
7301
7624
  if (result.deadEnds.length > 0) {
7302
7625
  lines.push("");