@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.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":
@@ -4696,14 +4712,16 @@ function parseDocument(data, options) {
4696
4712
  */
4697
4713
  /**
4698
4714
  * Parse a Twine 2 or Twine 1 compiled HTML file back into a Story model.
4715
+ * Tweego always trims passage text; here `trim: false` keeps it exactly, as for Twee sources.
4699
4716
  */
4700
- function decompileHTML(html) {
4717
+ function decompileHTML(html, options = {}) {
4701
4718
  const diagnostics = [];
4702
4719
  const story = createStory();
4720
+ const passageText = options.trim === false ? (text) => text : (text) => text.trim();
4703
4721
  const doc = parseDocument(html);
4704
4722
  const twine2Data = findElement(doc, "tw-storydata");
4705
4723
  if (twine2Data) {
4706
- decompileTwine2(twine2Data, story, diagnostics);
4724
+ decompileTwine2(twine2Data, story, passageText, diagnostics);
4707
4725
  return {
4708
4726
  story,
4709
4727
  diagnostics
@@ -4711,7 +4729,7 @@ function decompileHTML(html) {
4711
4729
  }
4712
4730
  const twine1Data = findElementByIdPattern(doc, /^store(?:-a|A)rea$/);
4713
4731
  if (twine1Data) {
4714
- decompileTwine1(twine1Data, story, diagnostics);
4732
+ decompileTwine1(twine1Data, story, passageText, diagnostics);
4715
4733
  return {
4716
4734
  story,
4717
4735
  diagnostics
@@ -4726,7 +4744,7 @@ function decompileHTML(html) {
4726
4744
  diagnostics
4727
4745
  };
4728
4746
  }
4729
- function decompileTwine2(storyData, story, diagnostics) {
4747
+ function decompileTwine2(storyData, story, passageText, diagnostics) {
4730
4748
  let startnode = 0;
4731
4749
  const attrs = storyData.attribs ?? {};
4732
4750
  if (attrs["name"]) story.name = attrs["name"];
@@ -4738,7 +4756,7 @@ function decompileTwine2(storyData, story, diagnostics) {
4738
4756
  });
4739
4757
  else startnode = parsed;
4740
4758
  }
4741
- if (attrs["ifid"]) story.ifid = attrs["ifid"].toUpperCase();
4759
+ if (attrs["ifid"]) story.ifid = normalizeIFID(attrs["ifid"]);
4742
4760
  if (attrs["zoom"]) {
4743
4761
  const parsed = parseFloat(attrs["zoom"]);
4744
4762
  if (Number.isNaN(parsed)) diagnostics.push({
@@ -4751,15 +4769,20 @@ function decompileTwine2(storyData, story, diagnostics) {
4751
4769
  if (attrs["format"]) story.twine2.format = attrs["format"];
4752
4770
  if (attrs["format-version"]) story.twine2.formatVersion = attrs["format-version"];
4753
4771
  if (attrs["options"]) for (const opt of attrs["options"].split(/\s+/).filter((s) => s.length > 0)) story.twine2.options.set(opt, true);
4754
- 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) {
4755
4775
  if (node.type !== "tag" && node.type !== "style" && node.type !== "script") continue;
4756
4776
  switch (node.name) {
4757
4777
  case "style":
4758
4778
  case "script": {
4759
- const text = getTextContent(node).trim();
4760
- if (text.length === 0) continue;
4779
+ const content = getTextContent(node);
4780
+ if (content.trim().length === 0) continue;
4781
+ const text = passageText(content);
4782
+ const name = freeName(node.name === "style" ? "Story Stylesheet" : "Story JavaScript", takenNames);
4783
+ takenNames.add(name);
4761
4784
  storyAdd(story, {
4762
- name: node.name === "style" ? "Story Stylesheet" : "Story JavaScript",
4785
+ name,
4763
4786
  tags: node.name === "style" ? ["stylesheet"] : ["script"],
4764
4787
  text
4765
4788
  }, diagnostics);
@@ -4775,7 +4798,7 @@ function decompileTwine2(storyData, story, diagnostics) {
4775
4798
  case "tw-passagedata": {
4776
4799
  let pid = 0;
4777
4800
  const pAttrs = node.attribs ?? {};
4778
- const name = pAttrs["name"] ?? "";
4801
+ const name = passageDataName(node);
4779
4802
  const tags = pAttrs["tags"] ? pAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [];
4780
4803
  const metadata = {};
4781
4804
  if (pAttrs["pid"]) {
@@ -4792,7 +4815,7 @@ function decompileTwine2(storyData, story, diagnostics) {
4792
4815
  const passage = {
4793
4816
  name,
4794
4817
  tags,
4795
- text: getTextContent(node).trim()
4818
+ text: passageText(getTextContent(node))
4796
4819
  };
4797
4820
  if (metadata.position || metadata.size) passage.metadata = metadata;
4798
4821
  storyAdd(story, passage, diagnostics);
@@ -4806,8 +4829,19 @@ function decompileTwine2(storyData, story, diagnostics) {
4806
4829
  text: marshalStoryData(story)
4807
4830
  }, diagnostics);
4808
4831
  }
4809
- function decompileTwine1(storeArea, story, diagnostics) {
4810
- const passages = (storeArea.children ?? []).filter(isTiddler).map(tiddlerToPassage);
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
+ }
4843
+ function decompileTwine1(storeArea, story, passageText, diagnostics) {
4844
+ const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
4811
4845
  const obfuscated = isRot13Obfuscated(passages);
4812
4846
  for (const p of passages) storyAdd(story, obfuscated && p.name !== "StorySettings" ? {
4813
4847
  ...p,
@@ -4817,12 +4851,12 @@ function decompileTwine1(storeArea, story, diagnostics) {
4817
4851
  function isTiddler(node) {
4818
4852
  return node.type === "tag" && node.name === "div" && node.attribs !== void 0 && "tiddler" in node.attribs;
4819
4853
  }
4820
- function tiddlerToPassage(node) {
4854
+ function tiddlerToPassage(node, passageText) {
4821
4855
  const nodeAttrs = node.attribs ?? {};
4822
4856
  const passage = {
4823
4857
  name: nodeAttrs["tiddler"] ?? "",
4824
4858
  tags: nodeAttrs["tags"] ? nodeAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [],
4825
- text: tiddlerUnescape(getTextContent(node)).trim()
4859
+ text: passageText(tiddlerUnescape(getTextContent(node)))
4826
4860
  };
4827
4861
  const position = nodeAttrs["twine-position"];
4828
4862
  if (position) passage.metadata = { position };
@@ -4887,7 +4921,7 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
4887
4921
  break;
4888
4922
  case "htm":
4889
4923
  case "html":
4890
- loadHTML(story, filename, diagnostics);
4924
+ loadHTML(story, filename, opts, diagnostics);
4891
4925
  break;
4892
4926
  case "css":
4893
4927
  loadTagged(story, "stylesheet", filename, diagnostics);
@@ -5041,8 +5075,8 @@ function parseFontFile(filename) {
5041
5075
  diagnostics: []
5042
5076
  };
5043
5077
  }
5044
- function parseHTMLFile(filename) {
5045
- const { story, diagnostics } = decompileHTML(readUTF8(filename));
5078
+ function parseHTMLFile(filename, opts) {
5079
+ const { story, diagnostics } = decompileHTML(readUTF8(filename), { trim: opts.trim ?? true });
5046
5080
  return {
5047
5081
  passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
5048
5082
  name: "StoryTitle",
@@ -5054,14 +5088,17 @@ function parseHTMLFile(filename) {
5054
5088
  }
5055
5089
  /**
5056
5090
  * Identity of the options that affect how a file parses, stored on its cache entry.
5057
- * Only Twee files depend on them; every other file type yields the same key for all options.
5091
+ * Twee files depend on `trim` and `twee2Compat`, HTML files on `trim`; every other file type
5092
+ * yields the same key for all options.
5058
5093
  */
5059
5094
  function parseOptionsKey(filename, opts) {
5060
5095
  const ext = normalizedFileExt(filename);
5096
+ const trim = opts.trim ?? true;
5097
+ if (ext === "htm" || ext === "html") return JSON.stringify({ trim });
5061
5098
  const twee2File = ext === "tw2" || ext === "twee2";
5062
5099
  if (!twee2File && ext !== "tw" && ext !== "twee") return "";
5063
5100
  return JSON.stringify({
5064
- trim: opts.trim ?? true,
5101
+ trim,
5065
5102
  twee2Compat: twee2File || (opts.twee2Compat ?? false)
5066
5103
  });
5067
5104
  }
@@ -5075,7 +5112,7 @@ function parseFile(filename, opts) {
5075
5112
  twee2Compat: true
5076
5113
  });
5077
5114
  case "htm":
5078
- case "html": return parseHTMLFile(filename);
5115
+ case "html": return parseHTMLFile(filename, opts);
5079
5116
  case "css": return parseTaggedFile("stylesheet", filename);
5080
5117
  case "js": return parseTaggedFile("script", filename);
5081
5118
  case "otf":
@@ -5107,8 +5144,8 @@ function parseFile(filename, opts) {
5107
5144
  default: return;
5108
5145
  }
5109
5146
  }
5110
- function loadHTML(story, filename, diagnostics) {
5111
- const result = parseHTMLFile(filename);
5147
+ function loadHTML(story, filename, opts, diagnostics) {
5148
+ const result = parseHTMLFile(filename, opts);
5112
5149
  diagnostics.push(...result.diagnostics);
5113
5150
  for (const p of result.passages) storyAdd(story, p, diagnostics);
5114
5151
  }
@@ -5147,7 +5184,8 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5147
5184
  const optionsKey = parseOptionsKey(filename, opts);
5148
5185
  const entry = cache.get(filename);
5149
5186
  const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
5150
- if (changedFiles && cached && !changedFiles.has(filename)) {
5187
+ const changed = changedFiles?.has(filename) ?? false;
5188
+ if (changedFiles && cached && !changed) {
5151
5189
  diagnostics.push(...cached.diagnostics);
5152
5190
  for (const p of cached.passages) storyAdd(story, p, diagnostics);
5153
5191
  processedFiles.add(filename);
@@ -5159,7 +5197,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5159
5197
  } catch {
5160
5198
  continue;
5161
5199
  }
5162
- if (cached && cached.mtimeMs === mtimeMs) {
5200
+ if (cached && !changed && cached.mtimeMs === mtimeMs) {
5163
5201
  diagnostics.push(...cached.diagnostics);
5164
5202
  for (const p of cached.passages) storyAdd(story, p, diagnostics);
5165
5203
  processedFiles.add(filename);
@@ -5194,6 +5232,121 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5194
5232
  }, diagnostics);
5195
5233
  }
5196
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
5197
5350
  //#region src/version.ts
5198
5351
  /**
5199
5352
  * The twee-ts version, read at runtime from the package's own package.json.
@@ -5227,24 +5380,38 @@ const CREATOR_NAME$2 = "Twee-ts";
5227
5380
  function toTwine2Archive(story, startName, options) {
5228
5381
  return getTwine2DataChunk(story, startName, options) + "\n";
5229
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
+ */
5230
5389
  function toTwine2HTML(story, format, startName, options) {
5231
- let template = readFormatSource(format);
5232
- if (template.includes("{{STORY_NAME}}")) {
5233
- const name = htmlEscape(story.name);
5234
- template = template.replaceAll("{{STORY_NAME}}", () => name);
5235
- }
5236
- if (template.includes("{{STORY_DATA}}")) {
5237
- const data = getTwine2DataChunk({
5238
- ...story,
5239
- twine2: {
5240
- ...story.twine2,
5241
- format: format.name,
5242
- 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);
5243
5410
  }
5244
- }, startName, options);
5245
- template = template.replace("{{STORY_DATA}}", () => data);
5246
- }
5247
- return template;
5411
+ },
5412
+ ...head === void 0 ? [] : [head]
5413
+ ];
5414
+ return fillTemplate(readFormatSource(format), slots);
5248
5415
  }
5249
5416
  const OMITTING_TAGS = [
5250
5417
  "Twine.private",
@@ -5337,7 +5504,13 @@ function toTwine1Archive(story, _startName) {
5337
5504
  const { data, count } = getTwine1PassageChunk(story);
5338
5505
  return `<div id="storeArea" data-size="${count}">${data}</div>\n`;
5339
5506
  }
5340
- 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) {
5341
5514
  const formatDir = dirname(format.filename);
5342
5515
  const parentDir = dirname(formatDir);
5343
5516
  let template = readFormatSource(format);
@@ -5348,28 +5521,60 @@ function toTwine1HTML(story, format, startName) {
5348
5521
  template = tryReplaceComponent(template, "\"JONAH\"", join(formatDir, "code.js"), true);
5349
5522
  if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", join(parentDir, "jquery.js"), true);
5350
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);
5351
5533
  const displayStart = startName === "Start" ? "" : startName;
5352
- template = template.replace("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`);
5353
- template = template.replace("\"TIME\"", `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`);
5354
- const startAtValue = `"${jsStringEscape(displayStart)}"`;
5355
- template = template.replace("\"START_AT\"", () => startAtValue);
5356
- template = template.replace("\"STORY_SIZE\"", `"${count}"`);
5357
- if (template.includes("\"STORY\"")) template = template.replace("\"STORY\"", () => data);
5358
- else {
5359
- let footer;
5360
- try {
5361
- footer = readFileSync(join(formatDir, "footer.html"), "utf-8");
5362
- } catch {
5363
- footer = "</div>\n</body>\n</html>\n";
5364
- }
5365
- template += data + footer;
5366
- }
5367
- if (story.ifid) {
5368
- const safeIfid = htmlCommentSanitize(story.ifid);
5369
- if (template.includes("<div id=\"store-area\"")) template = template.replace("<div id=\"store-area\"", `<!-- UUID://${safeIfid}// --><div id="store-area"`);
5370
- 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";
5371
5577
  }
5372
- return template;
5373
5578
  }
5374
5579
  /**
5375
5580
  * Why Twine 1 output leaves a passage out of its tiddlers, or `undefined` when the passage is emitted.
@@ -5407,82 +5612,54 @@ function tryReplaceComponent(template, placeholder, componentPath, required) {
5407
5612
  }
5408
5613
  //#endregion
5409
5614
  //#region src/output-twee.ts
5410
- function toTwee(story, outMode) {
5411
- let data = "";
5412
- for (const p of story.passages) data += passageToTwee(p, outMode);
5413
- return data;
5414
- }
5415
- //#endregion
5416
- //#region src/modules.ts
5417
5615
  /**
5418
- * Load modules and return HTML tags to inject before </head>.
5616
+ * Serialize the story as Twee. The StoryData passage is written from the story model rather than
5617
+ * from its loaded text, so changes made after loading (a start passage override, test mode) are kept.
5618
+ * Tweego writes the loaded text; its own normalization makes the two the same for an unchanged story.
5619
+ *
5620
+ * `addStoryData`: add a StoryData passage when the story has none, so that StoryData fields the
5621
+ * compile options set are recorded. Default: false.
5419
5622
  */
5420
- function loadModules(filenames) {
5421
- const processed = /* @__PURE__ */ new Set();
5422
- const headTags = [];
5423
- for (const filename of filenames) {
5424
- if (processed.has(filename)) continue;
5425
- const ext = normalizedFileExt(filename);
5426
- let tag = null;
5427
- switch (ext) {
5428
- case "css":
5429
- tag = loadModuleTagged("style", filename);
5430
- break;
5431
- case "js":
5432
- tag = loadModuleTagged("script", filename);
5433
- break;
5434
- case "otf":
5435
- case "ttf":
5436
- case "woff":
5437
- case "woff2":
5438
- tag = loadModuleFont(filename);
5439
- break;
5440
- default: continue;
5441
- }
5442
- if (tag) headTags.push(tag);
5443
- processed.add(filename);
5444
- }
5445
- return headTags.join("\n");
5623
+ function toTwee(story, outMode, options = {}) {
5624
+ return withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => passageToTwee(p, outMode)).join("");
5446
5625
  }
5447
- function loadModuleTagged(tag, filename) {
5448
- const source = readUTF8(filename).trim();
5449
- if (source.length === 0) return null;
5450
- return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
5626
+ function withEffectiveStoryData(story, addStoryData) {
5627
+ const storyData = {
5628
+ name: "StoryData",
5629
+ tags: [],
5630
+ text: marshalStoryData(story)
5631
+ };
5632
+ const index = story.passages.findIndex((p) => p.name === "StoryData");
5633
+ if (index !== -1) return story.passages.map((p, i) => i === index && isStoryDataJSON(p.text) ? {
5634
+ ...p,
5635
+ text: storyData.text
5636
+ } : p);
5637
+ if (!addStoryData) return story.passages;
5638
+ const at = story.passages[0]?.name === "StoryTitle" ? 1 : 0;
5639
+ return [
5640
+ ...story.passages.slice(0, at),
5641
+ storyData,
5642
+ ...story.passages.slice(at)
5643
+ ];
5451
5644
  }
5452
- function loadModuleFont(filename) {
5453
- const source = readBase64(filename);
5454
- const family = baseNameWithoutExt(filename);
5455
- const idSlug = `style-module-${slugify(family)}`;
5456
- const ext = normalizedFileExt(filename);
5457
- 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>`;
5645
+ /** Whether the text loaded as StoryData. Text that did not is kept as written, for the author to fix. */
5646
+ function isStoryDataJSON(text) {
5647
+ return unmarshalStoryData(createStory(), text) === null;
5458
5648
  }
5649
+ //#endregion
5650
+ //#region src/passage-omission.ts
5459
5651
  /**
5460
- * Inject modules and head file content before </head>.
5652
+ * Why the `target` output leaves `passage` out of its passage elements, or `undefined` when it
5653
+ * emits the passage (and so a link or the start can name it).
5461
5654
  */
5462
- function modifyHead(html, modulePaths, headFile, diagnostics) {
5463
- const parts = [];
5464
- if (modulePaths.length > 0) {
5465
- const modules = loadModules(modulePaths).trim();
5466
- if (modules.length > 0) parts.push(modules);
5467
- }
5468
- if (headFile) try {
5469
- const source = readUTF8(headFile).trim();
5470
- if (source.length > 0) parts.push(source);
5471
- } catch (e) {
5472
- diagnostics?.push({
5473
- level: "warning",
5474
- message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
5475
- });
5476
- }
5477
- if (parts.length > 0) {
5478
- parts.push("</head>");
5479
- const replacement = parts.join("\n");
5480
- return html.replace("</head>", () => replacement);
5655
+ function passageOmission(story, passage, target) {
5656
+ switch (target) {
5657
+ case "twine2": return twine2PassageOmission(story, passage);
5658
+ case "twine1": return twine1PassageOmission(passage);
5659
+ default: throw new Error(`unhandled passage output target: ${String(target)}`);
5481
5660
  }
5482
- return html;
5483
5661
  }
5484
- //#endregion
5485
- //#region src/start-passage.ts
5662
+ /** Why a passage is left out, as a predicate: `Passage "X" ${describeOmission(o)}`. */
5486
5663
  function describeOmission(omission) {
5487
5664
  switch (omission.kind) {
5488
5665
  case "special-name": return `is the special "${omission.name}" passage`;
@@ -5491,13 +5668,8 @@ function describeOmission(omission) {
5491
5668
  default: throw new Error(`unhandled passage omission: ${JSON.stringify(omission)}`);
5492
5669
  }
5493
5670
  }
5494
- function omissionFor(story, passage, target) {
5495
- switch (target) {
5496
- case "twine2": return twine2PassageOmission(story, passage);
5497
- case "twine1": return twine1PassageOmission(passage);
5498
- default: throw new Error(`unhandled start passage target: ${String(target)}`);
5499
- }
5500
- }
5671
+ //#endregion
5672
+ //#region src/start-passage.ts
5501
5673
  /**
5502
5674
  * Check that `startName` names a passage the `target` output emits.
5503
5675
  * Returns the error diagnostics to report (empty when the start is usable).
@@ -5508,7 +5680,7 @@ function startPassageDiagnostics(story, startName, target) {
5508
5680
  level: "error",
5509
5681
  message: `Starting passage "${startName}" not found.`
5510
5682
  }];
5511
- const omission = omissionFor(story, passage, target);
5683
+ const omission = passageOmission(story, passage, target);
5512
5684
  if (omission === void 0) return [];
5513
5685
  return [{
5514
5686
  level: "error",
@@ -5543,21 +5715,51 @@ async function compile(options) {
5543
5715
  * Compile and write to a file.
5544
5716
  */
5545
5717
  async function compileToFile(options) {
5546
- const result = await buildOutput(options, void 0, void 0, options.outFile);
5718
+ const result = await compileForOutputFile(options, options.outFile);
5547
5719
  writeFileSync(options.outFile, result.output, "utf-8");
5548
5720
  return result;
5549
5721
  }
5550
5722
  /**
5723
+ * Builds as compileToFile() does for `outFile`, without writing it: the output may sit
5724
+ * inside a source folder, so its last build is left out of the sources and modules and
5725
+ * never read back. The caller decides whether the result is written. With no `outFile`
5726
+ * (output to stdout, or none), this is compile(). With `cache`, files are cached as
5727
+ * compileIncremental() caches them.
5728
+ *
5729
+ * Internal, for the CLI and the bundler plugins; not part of the public API.
5730
+ */
5731
+ async function compileForOutputFile(options, outFile, cache) {
5732
+ return buildOutput(options, cache, void 0, outFile);
5733
+ }
5734
+ /**
5551
5735
  * Compile with incremental caching support.
5552
5736
  * Plugins and advanced users can manage their own cache and changed-file tracking.
5737
+ *
5738
+ * Without `changedFiles`, a cached file is reused while its modification time is unchanged.
5739
+ * With it, a file it names (by the path source discovery gives it, relative to the working
5740
+ * directory) is always reparsed, whatever its modification time, and every other cached
5741
+ * file is reused as it is.
5553
5742
  */
5554
5743
  async function compileIncremental(options, cache, changedFiles) {
5555
5744
  return buildOutput(options, cache, changedFiles);
5556
5745
  }
5557
5746
  /**
5558
- * Watch for file changes and recompile.
5747
+ * Watch for file changes and recompile. Every build is written to `outFile`, including
5748
+ * one whose diagnostics report errors. Builds run one at a time: changes made during a
5749
+ * build go into one follow-up build after it, and the superseded build is neither
5750
+ * written nor reported.
5559
5751
  */
5560
5752
  async function watch(options) {
5753
+ return watchWithWriteFilter(options, () => true);
5754
+ }
5755
+ /**
5756
+ * watch(), writing to `outFile` only the builds `shouldWrite` accepts. A rejected build
5757
+ * leaves the output file as it was; `onBuild` receives every build either way.
5758
+ *
5759
+ * Internal, for the CLI, which keeps the last good output when a rebuild reports errors;
5760
+ * not part of the public API.
5761
+ */
5762
+ async function watchWithWriteFilter(options, shouldWrite) {
5561
5763
  const controller = new AbortController();
5562
5764
  const cache = /* @__PURE__ */ new Map();
5563
5765
  const filePaths = options.sources.filter((s) => typeof s === "string");
@@ -5575,17 +5777,62 @@ async function watch(options) {
5575
5777
  const abs = resolve(filename);
5576
5778
  return !notExcludable.some((root) => abs === root || abs.startsWith(root + sep));
5577
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
+ };
5578
5813
  const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
5579
- buildOutput(options, cache, changedFiles, options.outFile).then((result) => {
5580
- writeFileSync(options.outFile, result.output, "utf-8");
5581
- options.onBuild?.(result);
5582
- }).catch((e) => {
5583
- options.onError?.(e instanceof Error ? e : new Error(String(e)));
5584
- });
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);
5585
5821
  }, ignore);
5586
- controller.signal.addEventListener("abort", () => handle.close());
5822
+ controller.signal.addEventListener("abort", () => {
5823
+ queued = void 0;
5824
+ handle.close();
5825
+ });
5587
5826
  return controller;
5588
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
+ }
5589
5836
  /**
5590
5837
  * `outFile`: the file the build is written to, which source and module discovery
5591
5838
  * skip (as Tweego does), so an output inside a source folder is never loaded back.
@@ -5633,7 +5880,7 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5633
5880
  switch (outputMode) {
5634
5881
  case "twee3":
5635
5882
  case "twee1":
5636
- output = toTwee(story, outputMode);
5883
+ output = toTwee(story, outputMode, { addStoryData: Boolean(options.startPassage) || testMode });
5637
5884
  break;
5638
5885
  case "twine2-archive":
5639
5886
  output = toTwine2Archive(story, startName, { sourceInfo });
@@ -5647,17 +5894,17 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5647
5894
  case "html": {
5648
5895
  diagnostics.push(...startPassageDiagnostics(story, startName, format?.isTwine2 === false ? "twine1" : "twine2"));
5649
5896
  if (!format) throw new TweeTsError("No story format available for HTML output.", diagnostics);
5650
- if (format.isTwine2) output = toTwine2HTML(story, format, startName, { sourceInfo });
5651
- else {
5652
- if (story.name === "" && !storyHas(story, "StoryTitle")) diagnostics.push({
5653
- level: "error",
5654
- message: "Special passage \"StoryTitle\" not found."
5655
- });
5656
- output = toTwine1HTML(story, format, startName);
5657
- }
5897
+ if (!format.isTwine2 && story.name === "" && !storyHas(story, "StoryTitle")) diagnostics.push({
5898
+ level: "error",
5899
+ message: "Special passage \"StoryTitle\" not found."
5900
+ });
5658
5901
  const modules = getFilenames(options.modules ?? [], outFile);
5659
5902
  diagnostics.push(...modules.diagnostics);
5660
- 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 });
5661
5908
  break;
5662
5909
  }
5663
5910
  default: throw new TweeTsError(`Unhandled output mode: ${outputMode}`, diagnostics);
@@ -6076,6 +6323,11 @@ function endOfRegularExpression(source, pos) {
6076
6323
  }
6077
6324
  //#endregion
6078
6325
  //#region src/link-markup.ts
6326
+ /** Records that the reading has looked at the characters before `pos`, and returns `result`. */
6327
+ function readTo(reader, pos, result) {
6328
+ reader.scanned = Math.max(reader.scanned, pos);
6329
+ return result;
6330
+ }
6079
6331
  /**
6080
6332
  * Reads link or image markup that starts at `start` in `text`, or returns `undefined` where
6081
6333
  * SugarCube rejects it (and so prints the text instead).
@@ -6083,15 +6335,17 @@ function endOfRegularExpression(source, pos) {
6083
6335
  * `budget.left`, if given, is how many characters the reading may look at; it is reduced by as
6084
6336
  * many as were read, and once it runs out, every reading fails. Unclosed markup is read to the end
6085
6337
  * of its line, so reading every `[[` of a long line that never closes one would otherwise take
6086
- * quadratic time.
6338
+ * quadratic time. A reading is charged for the characters it looked at, up to where it ended or
6339
+ * failed (a line feed, say), and no more, so markup left unclosed on short lines costs little.
6087
6340
  */
6088
6341
  function readSquareBracketedMarkup(text, start, budget = { left: Infinity }) {
6089
6342
  const reader = {
6090
6343
  text,
6091
- limit: Math.min(text.length, start + Math.max(budget.left, 0))
6344
+ limit: Math.min(text.length, start + Math.max(budget.left, 0)),
6345
+ scanned: start
6092
6346
  };
6093
6347
  const markup = readMarkup(reader, start);
6094
- budget.left -= (markup?.end ?? reader.limit) - start;
6348
+ budget.left -= Math.max(reader.scanned, markup?.end ?? start) - start;
6095
6349
  return markup;
6096
6350
  }
6097
6351
  function readMarkup(reader, start) {
@@ -6167,7 +6421,7 @@ function readCoreComponents(reader, from) {
6167
6421
  const ch = text[pos];
6168
6422
  pos += 1;
6169
6423
  switch (ch) {
6170
- case "\n": return;
6424
+ case "\n": return readTo(reader, pos, void 0);
6171
6425
  case "\"": {
6172
6426
  const end = readQuoted(reader, pos, "\"");
6173
6427
  if (end === void 0) return;
@@ -6201,7 +6455,7 @@ function readCoreComponents(reader, from) {
6201
6455
  case "]":
6202
6456
  depth -= 1;
6203
6457
  if (depth === 1) {
6204
- const end = componentEnd(text, pos);
6458
+ const end = componentEnd(reader, pos);
6205
6459
  if (end === void 0) return;
6206
6460
  return {
6207
6461
  link: link ?? text.slice(componentStart, end.textEnd),
@@ -6210,6 +6464,7 @@ function readCoreComponents(reader, from) {
6210
6464
  }
6211
6465
  }
6212
6466
  }
6467
+ return readTo(reader, pos, void 0);
6213
6468
  }
6214
6469
  /**
6215
6470
  * Reads a component after `][` that holds no delimiter: an image's link (`setter` false) or a
@@ -6223,7 +6478,7 @@ function readComponent(reader, from, setter) {
6223
6478
  const ch = text[pos];
6224
6479
  pos += 1;
6225
6480
  switch (ch) {
6226
- case "\n": return;
6481
+ case "\n": return readTo(reader, pos, void 0);
6227
6482
  case "\"":
6228
6483
  case "'": {
6229
6484
  if (ch === "'" && !setter) break;
@@ -6237,9 +6492,10 @@ function readComponent(reader, from, setter) {
6237
6492
  break;
6238
6493
  case "]":
6239
6494
  depth -= 1;
6240
- if (depth === 1) return componentEnd(text, pos);
6495
+ if (depth === 1) return componentEnd(reader, pos);
6241
6496
  }
6242
6497
  }
6498
+ return readTo(reader, pos, void 0);
6243
6499
  }
6244
6500
  /** Reads a setter, the last component; returns the index after its closing `]]`. */
6245
6501
  function readSetter(reader, from) {
@@ -6247,19 +6503,19 @@ function readSetter(reader, from) {
6247
6503
  return end?.last === true ? end.next : void 0;
6248
6504
  }
6249
6505
  /** What the `]` just before `pos`, which brings the depth back to one, ends; `undefined` if malformed. */
6250
- function componentEnd(text, pos) {
6251
- switch (text[pos]) {
6252
- case "[": return {
6506
+ function componentEnd(reader, pos) {
6507
+ switch (reader.text[pos]) {
6508
+ case "[": return readTo(reader, pos + 1, {
6253
6509
  textEnd: pos - 1,
6254
6510
  next: pos + 1,
6255
6511
  last: false
6256
- };
6257
- case "]": return {
6512
+ });
6513
+ case "]": return readTo(reader, pos + 1, {
6258
6514
  textEnd: pos - 1,
6259
6515
  next: pos + 1,
6260
6516
  last: true
6261
- };
6262
- default: return;
6517
+ });
6518
+ default: return readTo(reader, pos + 1, void 0);
6263
6519
  }
6264
6520
  }
6265
6521
  /**
@@ -6274,12 +6530,13 @@ function readQuoted(reader, from, quote) {
6274
6530
  const ch = text[pos];
6275
6531
  pos += 1;
6276
6532
  if (ch === quote) return pos;
6277
- if (ch === "\n") return;
6533
+ if (ch === "\n") return readTo(reader, pos, void 0);
6278
6534
  if (ch === "\\") {
6279
- if (pos >= reader.limit || text[pos] === "\n") return;
6535
+ if (pos >= reader.limit || text[pos] === "\n") return readTo(reader, Math.min(pos + 1, reader.limit), void 0);
6280
6536
  pos += 1;
6281
6537
  }
6282
6538
  }
6539
+ return readTo(reader, pos, void 0);
6283
6540
  }
6284
6541
  //#endregion
6285
6542
  //#region src/sugarcube-macros.ts
@@ -7137,6 +7394,39 @@ function extractLinksFromText(text, isScript) {
7137
7394
  ].flatMap((via) => found.filter((link) => link.via === via).map((link) => link.passage));
7138
7395
  return [...new Set(ordered)];
7139
7396
  }
7397
+ /**
7398
+ * The passages that `p` links to. A script passage is JavaScript, read for links only in its
7399
+ * strings. A stylesheet is CSS, which the story puts in a style element and never wikifies, so it
7400
+ * links nowhere; a passage tagged both is a script, as Twine 2 output takes it.
7401
+ */
7402
+ function passageLinks(p) {
7403
+ if (hasTag(p, "script")) return extractLinksFromText(textAsRead(p), true);
7404
+ if (hasTag(p, "stylesheet")) return [];
7405
+ return extractLinksFromText(textAsRead(p), false);
7406
+ }
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
+ /**
7418
+ * Each passage name, mapped to why the `target` output leaves that passage out, or to `undefined`
7419
+ * when a link can reach it: always without a target, and otherwise when the output emits a
7420
+ * passage of that name.
7421
+ */
7422
+ function destinationOmissions(story, { target }) {
7423
+ const omissions = /* @__PURE__ */ new Map();
7424
+ for (const p of story.passages) {
7425
+ const omission = target === void 0 ? void 0 : passageOmission(story, p, target);
7426
+ if (!omissions.has(p.name) || omission === void 0) omissions.set(p.name, omission);
7427
+ }
7428
+ return omissions;
7429
+ }
7140
7430
  /** Passages SugarCube runs from their raw text, never joining their lines. */
7141
7431
  const RAW_TEXT_PASSAGES = /* @__PURE__ */ new Set([
7142
7432
  "StoryInit",
@@ -7178,9 +7468,16 @@ function joinLines(text) {
7178
7468
  * expect(map.brokenLinks).toEqual([]);
7179
7469
  * expect(map.deadEnds).not.toContain('Start');
7180
7470
  * ```
7181
- */
7182
- function storyInspect(story) {
7183
- const allNames = new Set(story.passages.map((p) => p.name));
7471
+ *
7472
+ * By default, a link is broken only when no passage has its name. Pass a `target` to check links
7473
+ * against what that output emits: `storyInspect(result.story, { target: 'twine2' })` also reports
7474
+ * links to passages Twine 2 output leaves out, such as script, stylesheet and `Twine.private`
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.
7478
+ */
7479
+ function storyInspect(story, options = {}) {
7480
+ const destinations = destinationOmissions(story, options);
7184
7481
  const passages = [];
7185
7482
  const storyPassages = [];
7186
7483
  const infoPassages = [];
@@ -7202,14 +7499,24 @@ function storyInspect(story) {
7202
7499
  }
7203
7500
  list.push(p.name);
7204
7501
  }
7205
- const targets = extractLinksFromText(textAsRead(p), hasTag(p, "script"));
7206
- links.set(p.name, targets);
7502
+ links.set(p.name, readsLinks(story, p, options) ? passageLinks(p) : []);
7207
7503
  }
7208
7504
  const brokenLinks = [];
7209
- for (const [from, targets] of links) for (const to of targets) if (!allNames.has(to)) brokenLinks.push({
7210
- from,
7211
- to
7212
- });
7505
+ for (const [from, targets] of links) for (const to of targets) {
7506
+ if (!destinations.has(to)) {
7507
+ brokenLinks.push({
7508
+ from,
7509
+ to
7510
+ });
7511
+ continue;
7512
+ }
7513
+ const omission = destinations.get(to);
7514
+ if (omission !== void 0) brokenLinks.push({
7515
+ from,
7516
+ to,
7517
+ omission
7518
+ });
7519
+ }
7213
7520
  const deadEnds = [];
7214
7521
  for (const name of storyPassages) {
7215
7522
  const targets = links.get(name);
@@ -7239,14 +7546,27 @@ function storyInspect(story) {
7239
7546
  /**
7240
7547
  * Lint a story: compile without output rendering, then inspect structure.
7241
7548
  * Uses JSON output mode internally to avoid format resolution, so it checks
7242
- * the starting passage itself, against the Twine 2 passage rules.
7549
+ * the starting passage itself, against the Twine 2 passage rules. Link
7550
+ * destinations are checked against the same rules: a link to a passage that
7551
+ * Twine 2 output leaves out is broken, and a passage it leaves out (other than
7552
+ * a script passage) gives no links.
7243
7553
  */
7244
7554
  async function lint(options) {
7245
- const result = await compile({
7555
+ return lintForOutputFile(options, void 0);
7556
+ }
7557
+ /**
7558
+ * lint() for a project whose builds are written to `outFile`: like a build for that file,
7559
+ * it leaves `outFile` out of the sources and modules, so an earlier build inside a source
7560
+ * folder is not linted as a source.
7561
+ *
7562
+ * Internal, for the CLI; not part of the public API.
7563
+ */
7564
+ async function lintForOutputFile(options, outFile) {
7565
+ const result = await compileForOutputFile({
7246
7566
  ...options,
7247
7567
  outputMode: "json"
7248
- });
7249
- const map = storyInspect(result.story);
7568
+ }, outFile);
7569
+ const map = storyInspect(result.story, { target: "twine2" });
7250
7570
  return {
7251
7571
  diagnostics: [...result.diagnostics, ...startPassageDiagnostics(result.story, map.start, "twine2")],
7252
7572
  stats: result.stats,
@@ -7273,7 +7593,10 @@ function formatLintReport(result) {
7273
7593
  if (result.brokenLinks.length > 0) {
7274
7594
  lines.push("");
7275
7595
  lines.push(`Broken links (${result.brokenLinks.length}):`);
7276
- for (const link of result.brokenLinks) lines.push(` ${link.from} -> ${link.to} (passage "${link.to}" does not exist)`);
7596
+ for (const link of result.brokenLinks) {
7597
+ const why = link.omission === void 0 ? "does not exist" : `${describeOmission(link.omission)}, so it is left out of the story data`;
7598
+ lines.push(` ${link.from} -> ${link.to} (passage "${link.to}" ${why})`);
7599
+ }
7277
7600
  }
7278
7601
  if (result.deadEnds.length > 0) {
7279
7602
  lines.push("");