@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.
@@ -15,6 +15,19 @@ import { fileURLToPath } from "node:url";
15
15
  function generateIFID() {
16
16
  return randomUUID().toUpperCase();
17
17
  }
18
+ /** Length of the `UUID://...//` wrapper around a 36-character UUID. */
19
+ const WRAPPED_LENGTH = 45;
20
+ /**
21
+ * Bring an IFID into the form the story model stores and every output writes: the uppercase
22
+ * bare UUID. A valid `UUID://...//` wrapped IFID loses its wrapper; outputs that need the wrapper
23
+ * (the Treaty of Babel comment) add exactly one. Twine 2 IFIDs use only capital letters, so the
24
+ * value is uppercased, as Tweego does. An invalid value is only uppercased, so that validation
25
+ * reports it as written.
26
+ */
27
+ function normalizeIFID(value) {
28
+ const upper = value.toUpperCase();
29
+ return upper.length === WRAPPED_LENGTH && validateIFID(upper) === null ? upper.slice(7, 43) : upper;
30
+ }
18
31
  /**
19
32
  * Validate an IFID string.
20
33
  * Accepts both bare UUIDs and UUID://...// wrapped format.
@@ -443,7 +456,7 @@ function unmarshalStoryData(story, json) {
443
456
  }
444
457
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return "Cannot unmarshal \"StoryData\"; expected a JSON object";
445
458
  const data = raw;
446
- if (typeof data.ifid === "string" && data.ifid) story.ifid = data.ifid.toUpperCase();
459
+ if (typeof data.ifid === "string" && data.ifid) story.ifid = normalizeIFID(data.ifid);
447
460
  if (typeof data.format === "string" && data.format) story.twine2.format = data.format;
448
461
  if (typeof data["format-version"] === "string" && data["format-version"]) story.twine2.formatVersion = data["format-version"];
449
462
  if (Array.isArray(data.options)) {
@@ -474,7 +487,7 @@ function unmarshalStorySettings(story, text, diagnostics) {
474
487
  const val = line.slice(colonIdx + 1).trim().toLowerCase();
475
488
  switch (key) {
476
489
  case "ifid":
477
- if (validateIFID(val) === null) story.legacyIFID = val.toUpperCase();
490
+ if (validateIFID(val) === null) story.legacyIFID = normalizeIFID(val);
478
491
  obsolete.push("\"ifid\"");
479
492
  continue;
480
493
  case "zoom":
@@ -4627,14 +4640,16 @@ function parseDocument(data, options) {
4627
4640
  */
4628
4641
  /**
4629
4642
  * Parse a Twine 2 or Twine 1 compiled HTML file back into a Story model.
4643
+ * Tweego always trims passage text; here `trim: false` keeps it exactly, as for Twee sources.
4630
4644
  */
4631
- function decompileHTML(html) {
4645
+ function decompileHTML(html, options = {}) {
4632
4646
  const diagnostics = [];
4633
4647
  const story = createStory();
4648
+ const passageText = options.trim === false ? (text) => text : (text) => text.trim();
4634
4649
  const doc = parseDocument(html);
4635
4650
  const twine2Data = findElement(doc, "tw-storydata");
4636
4651
  if (twine2Data) {
4637
- decompileTwine2(twine2Data, story, diagnostics);
4652
+ decompileTwine2(twine2Data, story, passageText, diagnostics);
4638
4653
  return {
4639
4654
  story,
4640
4655
  diagnostics
@@ -4642,7 +4657,7 @@ function decompileHTML(html) {
4642
4657
  }
4643
4658
  const twine1Data = findElementByIdPattern(doc, /^store(?:-a|A)rea$/);
4644
4659
  if (twine1Data) {
4645
- decompileTwine1(twine1Data, story, diagnostics);
4660
+ decompileTwine1(twine1Data, story, passageText, diagnostics);
4646
4661
  return {
4647
4662
  story,
4648
4663
  diagnostics
@@ -4657,7 +4672,7 @@ function decompileHTML(html) {
4657
4672
  diagnostics
4658
4673
  };
4659
4674
  }
4660
- function decompileTwine2(storyData, story, diagnostics) {
4675
+ function decompileTwine2(storyData, story, passageText, diagnostics) {
4661
4676
  let startnode = 0;
4662
4677
  const attrs = storyData.attribs ?? {};
4663
4678
  if (attrs["name"]) story.name = attrs["name"];
@@ -4669,7 +4684,7 @@ function decompileTwine2(storyData, story, diagnostics) {
4669
4684
  });
4670
4685
  else startnode = parsed;
4671
4686
  }
4672
- if (attrs["ifid"]) story.ifid = attrs["ifid"].toUpperCase();
4687
+ if (attrs["ifid"]) story.ifid = normalizeIFID(attrs["ifid"]);
4673
4688
  if (attrs["zoom"]) {
4674
4689
  const parsed = parseFloat(attrs["zoom"]);
4675
4690
  if (Number.isNaN(parsed)) diagnostics.push({
@@ -4682,15 +4697,20 @@ function decompileTwine2(storyData, story, diagnostics) {
4682
4697
  if (attrs["format"]) story.twine2.format = attrs["format"];
4683
4698
  if (attrs["format-version"]) story.twine2.formatVersion = attrs["format-version"];
4684
4699
  if (attrs["options"]) for (const opt of attrs["options"].split(/\s+/).filter((s) => s.length > 0)) story.twine2.options.set(opt, true);
4685
- for (const node of storyData.children ?? []) {
4700
+ const children = storyData.children ?? [];
4701
+ const takenNames = new Set(children.filter((node) => node.type === "tag" && node.name === "tw-passagedata").map(passageDataName));
4702
+ for (const node of children) {
4686
4703
  if (node.type !== "tag" && node.type !== "style" && node.type !== "script") continue;
4687
4704
  switch (node.name) {
4688
4705
  case "style":
4689
4706
  case "script": {
4690
- const text = getTextContent(node).trim();
4691
- if (text.length === 0) continue;
4707
+ const content = getTextContent(node);
4708
+ if (content.trim().length === 0) continue;
4709
+ const text = passageText(content);
4710
+ const name = freeName(node.name === "style" ? "Story Stylesheet" : "Story JavaScript", takenNames);
4711
+ takenNames.add(name);
4692
4712
  storyAdd(story, {
4693
- name: node.name === "style" ? "Story Stylesheet" : "Story JavaScript",
4713
+ name,
4694
4714
  tags: node.name === "style" ? ["stylesheet"] : ["script"],
4695
4715
  text
4696
4716
  }, diagnostics);
@@ -4706,7 +4726,7 @@ function decompileTwine2(storyData, story, diagnostics) {
4706
4726
  case "tw-passagedata": {
4707
4727
  let pid = 0;
4708
4728
  const pAttrs = node.attribs ?? {};
4709
- const name = pAttrs["name"] ?? "";
4729
+ const name = passageDataName(node);
4710
4730
  const tags = pAttrs["tags"] ? pAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [];
4711
4731
  const metadata = {};
4712
4732
  if (pAttrs["pid"]) {
@@ -4723,7 +4743,7 @@ function decompileTwine2(storyData, story, diagnostics) {
4723
4743
  const passage = {
4724
4744
  name,
4725
4745
  tags,
4726
- text: getTextContent(node).trim()
4746
+ text: passageText(getTextContent(node))
4727
4747
  };
4728
4748
  if (metadata.position || metadata.size) passage.metadata = metadata;
4729
4749
  storyAdd(story, passage, diagnostics);
@@ -4737,8 +4757,19 @@ function decompileTwine2(storyData, story, diagnostics) {
4737
4757
  text: marshalStoryData(story)
4738
4758
  }, diagnostics);
4739
4759
  }
4740
- function decompileTwine1(storeArea, story, diagnostics) {
4741
- const passages = (storeArea.children ?? []).filter(isTiddler).map(tiddlerToPassage);
4760
+ function passageDataName(node) {
4761
+ return node.attribs?.["name"] ?? "";
4762
+ }
4763
+ /** The first of `base`, `base 2`, `base 3`, … that is not in `taken`. */
4764
+ function freeName(base, taken) {
4765
+ if (!taken.has(base)) return base;
4766
+ for (let n = 2;; n++) {
4767
+ const candidate = `${base} ${n}`;
4768
+ if (!taken.has(candidate)) return candidate;
4769
+ }
4770
+ }
4771
+ function decompileTwine1(storeArea, story, passageText, diagnostics) {
4772
+ const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
4742
4773
  const obfuscated = isRot13Obfuscated(passages);
4743
4774
  for (const p of passages) storyAdd(story, obfuscated && p.name !== "StorySettings" ? {
4744
4775
  ...p,
@@ -4748,12 +4779,12 @@ function decompileTwine1(storeArea, story, diagnostics) {
4748
4779
  function isTiddler(node) {
4749
4780
  return node.type === "tag" && node.name === "div" && node.attribs !== void 0 && "tiddler" in node.attribs;
4750
4781
  }
4751
- function tiddlerToPassage(node) {
4782
+ function tiddlerToPassage(node, passageText) {
4752
4783
  const nodeAttrs = node.attribs ?? {};
4753
4784
  const passage = {
4754
4785
  name: nodeAttrs["tiddler"] ?? "",
4755
4786
  tags: nodeAttrs["tags"] ? nodeAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [],
4756
- text: tiddlerUnescape(getTextContent(node)).trim()
4787
+ text: passageText(tiddlerUnescape(getTextContent(node)))
4757
4788
  };
4758
4789
  const position = nodeAttrs["twine-position"];
4759
4790
  if (position) passage.metadata = { position };
@@ -4818,7 +4849,7 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
4818
4849
  break;
4819
4850
  case "htm":
4820
4851
  case "html":
4821
- loadHTML(story, filename, diagnostics);
4852
+ loadHTML(story, filename, opts, diagnostics);
4822
4853
  break;
4823
4854
  case "css":
4824
4855
  loadTagged(story, "stylesheet", filename, diagnostics);
@@ -4972,8 +5003,8 @@ function parseFontFile(filename) {
4972
5003
  diagnostics: []
4973
5004
  };
4974
5005
  }
4975
- function parseHTMLFile(filename) {
4976
- const { story, diagnostics } = decompileHTML(readUTF8(filename));
5006
+ function parseHTMLFile(filename, opts) {
5007
+ const { story, diagnostics } = decompileHTML(readUTF8(filename), { trim: opts.trim ?? true });
4977
5008
  return {
4978
5009
  passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
4979
5010
  name: "StoryTitle",
@@ -4985,14 +5016,17 @@ function parseHTMLFile(filename) {
4985
5016
  }
4986
5017
  /**
4987
5018
  * Identity of the options that affect how a file parses, stored on its cache entry.
4988
- * Only Twee files depend on them; every other file type yields the same key for all options.
5019
+ * Twee files depend on `trim` and `twee2Compat`, HTML files on `trim`; every other file type
5020
+ * yields the same key for all options.
4989
5021
  */
4990
5022
  function parseOptionsKey(filename, opts) {
4991
5023
  const ext = normalizedFileExt(filename);
5024
+ const trim = opts.trim ?? true;
5025
+ if (ext === "htm" || ext === "html") return JSON.stringify({ trim });
4992
5026
  const twee2File = ext === "tw2" || ext === "twee2";
4993
5027
  if (!twee2File && ext !== "tw" && ext !== "twee") return "";
4994
5028
  return JSON.stringify({
4995
- trim: opts.trim ?? true,
5029
+ trim,
4996
5030
  twee2Compat: twee2File || (opts.twee2Compat ?? false)
4997
5031
  });
4998
5032
  }
@@ -5006,7 +5040,7 @@ function parseFile(filename, opts) {
5006
5040
  twee2Compat: true
5007
5041
  });
5008
5042
  case "htm":
5009
- case "html": return parseHTMLFile(filename);
5043
+ case "html": return parseHTMLFile(filename, opts);
5010
5044
  case "css": return parseTaggedFile("stylesheet", filename);
5011
5045
  case "js": return parseTaggedFile("script", filename);
5012
5046
  case "otf":
@@ -5038,8 +5072,8 @@ function parseFile(filename, opts) {
5038
5072
  default: return;
5039
5073
  }
5040
5074
  }
5041
- function loadHTML(story, filename, diagnostics) {
5042
- const result = parseHTMLFile(filename);
5075
+ function loadHTML(story, filename, opts, diagnostics) {
5076
+ const result = parseHTMLFile(filename, opts);
5043
5077
  diagnostics.push(...result.diagnostics);
5044
5078
  for (const p of result.passages) storyAdd(story, p, diagnostics);
5045
5079
  }
@@ -5078,7 +5112,8 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5078
5112
  const optionsKey = parseOptionsKey(filename, opts);
5079
5113
  const entry = cache.get(filename);
5080
5114
  const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
5081
- if (changedFiles && cached && !changedFiles.has(filename)) {
5115
+ const changed = changedFiles?.has(filename) ?? false;
5116
+ if (changedFiles && cached && !changed) {
5082
5117
  diagnostics.push(...cached.diagnostics);
5083
5118
  for (const p of cached.passages) storyAdd(story, p, diagnostics);
5084
5119
  processedFiles.add(filename);
@@ -5090,7 +5125,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5090
5125
  } catch {
5091
5126
  continue;
5092
5127
  }
5093
- if (cached && cached.mtimeMs === mtimeMs) {
5128
+ if (cached && !changed && cached.mtimeMs === mtimeMs) {
5094
5129
  diagnostics.push(...cached.diagnostics);
5095
5130
  for (const p of cached.passages) storyAdd(story, p, diagnostics);
5096
5131
  processedFiles.add(filename);
@@ -5125,6 +5160,121 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5125
5160
  }, diagnostics);
5126
5161
  }
5127
5162
  //#endregion
5163
+ //#region src/template.ts
5164
+ /**
5165
+ * An HTML closing head tag: `</head` in any letter case, followed by whitespace, `/` or `>`, up to the next `>`.
5166
+ * This is how the HTML tokenizer reads an end tag; it ignores whatever sits between the tag name and the `>`.
5167
+ */
5168
+ const CLOSING_HEAD_TAG = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>";
5169
+ /** The regular expression source matching exactly `text`. */
5170
+ function literal(text) {
5171
+ return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
5172
+ }
5173
+ /** Replace the `slots` placeholders found in `template`, without scanning inserted text. */
5174
+ function fillTemplate(template, slots) {
5175
+ return fillTemplateParts([{
5176
+ kind: "scan",
5177
+ text: template,
5178
+ slots
5179
+ }]);
5180
+ }
5181
+ /**
5182
+ * Fill the parts of a template in order, joined. A `'first'` slot replaced in one part is not replaced again in a
5183
+ * later part that lists the same slot object.
5184
+ */
5185
+ function fillTemplateParts(parts) {
5186
+ const filled = /* @__PURE__ */ new Set();
5187
+ return parts.map((part) => {
5188
+ if (part.kind === "verbatim" || part.slots.length === 0) return part.text;
5189
+ const { slots } = part;
5190
+ const pattern = new RegExp(slots.map((slot) => `(${slot.pattern})`).join("|"), "g");
5191
+ return part.text.replace(pattern, (match, ...groups) => {
5192
+ const slot = slots.find((_, i) => groups[i] !== void 0);
5193
+ if (slot === void 0 || slot.occurrences === "first" && filled.has(slot)) return match;
5194
+ filled.add(slot);
5195
+ return slot.replacement(match);
5196
+ });
5197
+ }).join("");
5198
+ }
5199
+ //#endregion
5200
+ //#region src/modules.ts
5201
+ /**
5202
+ * Load modules and return HTML tags to inject before the closing head tag.
5203
+ */
5204
+ function loadModules(filenames) {
5205
+ const processed = /* @__PURE__ */ new Set();
5206
+ const headTags = [];
5207
+ for (const filename of filenames) {
5208
+ if (processed.has(filename)) continue;
5209
+ const ext = normalizedFileExt(filename);
5210
+ let tag = null;
5211
+ switch (ext) {
5212
+ case "css":
5213
+ tag = loadModuleTagged("style", filename);
5214
+ break;
5215
+ case "js":
5216
+ tag = loadModuleTagged("script", filename);
5217
+ break;
5218
+ case "otf":
5219
+ case "ttf":
5220
+ case "woff":
5221
+ case "woff2":
5222
+ tag = loadModuleFont(filename);
5223
+ break;
5224
+ default: continue;
5225
+ }
5226
+ if (tag) headTags.push(tag);
5227
+ processed.add(filename);
5228
+ }
5229
+ return headTags.join("\n");
5230
+ }
5231
+ function loadModuleTagged(tag, filename) {
5232
+ const source = readUTF8(filename).trim();
5233
+ if (source.length === 0) return null;
5234
+ return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
5235
+ }
5236
+ function loadModuleFont(filename) {
5237
+ const source = readBase64(filename);
5238
+ const family = baseNameWithoutExt(filename);
5239
+ const idSlug = `style-module-${slugify(family)}`;
5240
+ const ext = normalizedFileExt(filename);
5241
+ 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>`;
5242
+ }
5243
+ /**
5244
+ * Load the module tags and head file content to inject before the closing head tag, joined by newlines,
5245
+ * or `''` when there is nothing to inject.
5246
+ */
5247
+ function loadHeadContent(modulePaths, headFile, diagnostics) {
5248
+ const parts = [];
5249
+ if (modulePaths.length > 0) {
5250
+ const modules = loadModules(modulePaths).trim();
5251
+ if (modules.length > 0) parts.push(modules);
5252
+ }
5253
+ if (headFile) try {
5254
+ const source = readUTF8(headFile).trim();
5255
+ if (source.length > 0) parts.push(source);
5256
+ } catch (e) {
5257
+ diagnostics?.push({
5258
+ level: "warning",
5259
+ message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
5260
+ });
5261
+ }
5262
+ return parts.join("\n");
5263
+ }
5264
+ /**
5265
+ * The template slot that inserts `content` on its own line before the first closing head tag (as Tweego does),
5266
+ * or `undefined` when `content` is empty. The tag may use any letter case and whitespace before its `>`, and is
5267
+ * kept as written.
5268
+ */
5269
+ function headSlot(content) {
5270
+ if (content.length === 0) return void 0;
5271
+ return {
5272
+ pattern: CLOSING_HEAD_TAG,
5273
+ occurrences: "first",
5274
+ replacement: (tag) => `${content}\n${tag}`
5275
+ };
5276
+ }
5277
+ //#endregion
5128
5278
  //#region src/version.ts
5129
5279
  /**
5130
5280
  * The twee-ts version, read at runtime from the package's own package.json.
@@ -5158,24 +5308,38 @@ const CREATOR_NAME$2 = "Twee-ts";
5158
5308
  function toTwine2Archive(story, startName, options) {
5159
5309
  return getTwine2DataChunk(story, startName, options) + "\n";
5160
5310
  }
5311
+ /**
5312
+ * Fill the Twine 2 format template: every `{{STORY_NAME}}` gets the escaped story name and the first
5313
+ * `{{STORY_DATA}}` the story data (as Tweego does), and `head` goes before the first closing head tag. All three are
5314
+ * found in the template in one pass, so a story name, passage text or head content holding a placeholder or a
5315
+ * closing head tag stays literal.
5316
+ */
5161
5317
  function toTwine2HTML(story, format, startName, options) {
5162
- let template = readFormatSource(format);
5163
- if (template.includes("{{STORY_NAME}}")) {
5164
- const name = htmlEscape(story.name);
5165
- template = template.replaceAll("{{STORY_NAME}}", () => name);
5166
- }
5167
- if (template.includes("{{STORY_DATA}}")) {
5168
- const data = getTwine2DataChunk({
5169
- ...story,
5170
- twine2: {
5171
- ...story.twine2,
5172
- format: format.name,
5173
- formatVersion: format.version
5318
+ const name = htmlEscape(story.name);
5319
+ const head = headSlot(options?.head ?? "");
5320
+ const slots = [
5321
+ {
5322
+ pattern: literal("{{STORY_NAME}}"),
5323
+ occurrences: "all",
5324
+ replacement: () => name
5325
+ },
5326
+ {
5327
+ pattern: literal("{{STORY_DATA}}"),
5328
+ occurrences: "first",
5329
+ replacement: () => {
5330
+ return getTwine2DataChunk({
5331
+ ...story,
5332
+ twine2: {
5333
+ ...story.twine2,
5334
+ format: format.name,
5335
+ formatVersion: format.version
5336
+ }
5337
+ }, startName, options);
5174
5338
  }
5175
- }, startName, options);
5176
- template = template.replace("{{STORY_DATA}}", () => data);
5177
- }
5178
- return template;
5339
+ },
5340
+ ...head === void 0 ? [] : [head]
5341
+ ];
5342
+ return fillTemplate(readFormatSource(format), slots);
5179
5343
  }
5180
5344
  const OMITTING_TAGS = [
5181
5345
  "Twine.private",
@@ -5268,7 +5432,13 @@ function toTwine1Archive(story, _startName) {
5268
5432
  const { data, count } = getTwine1PassageChunk(story);
5269
5433
  return `<div id="storeArea" data-size="${count}">${data}</div>\n`;
5270
5434
  }
5271
- function toTwine1HTML(story, format, startName) {
5435
+ /**
5436
+ * Fill the Twine 1 format template. The format's components are inserted first, one after another, as Tweego and
5437
+ * Twine 1 do. Then the first of each story placeholder (`"VERSION"`, `"TIME"`, `"START_AT"`, `"STORY_SIZE"`,
5438
+ * `"STORY"`), the IFID comment and `head` (before the first closing head tag) are found in one pass, so a start
5439
+ * passage name or story data holding a placeholder or a closing head tag stays literal.
5440
+ */
5441
+ function toTwine1HTML(story, format, startName, options) {
5272
5442
  const formatDir = dirname(format.filename);
5273
5443
  const parentDir = dirname(formatDir);
5274
5444
  let template = readFormatSource(format);
@@ -5279,28 +5449,60 @@ function toTwine1HTML(story, format, startName) {
5279
5449
  template = tryReplaceComponent(template, "\"JONAH\"", join(formatDir, "code.js"), true);
5280
5450
  if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", join(parentDir, "jquery.js"), true);
5281
5451
  if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", join(parentDir, "modernizr.js"), true);
5452
+ const isPre14 = !template.includes("\"STORY\"");
5453
+ const footer = isPre14 ? readFooter(formatDir) : "";
5454
+ const storeArea = (template + footer).includes("<div id=\"store-area\"") ? "<div id=\"store-area\"" : "<div id=\"storeArea\"";
5455
+ const safeIfid = htmlCommentSanitize(story.ifid);
5456
+ const late = [story.ifid ? {
5457
+ pattern: literal(storeArea),
5458
+ occurrences: "first",
5459
+ replacement: (div) => `<!-- UUID://${safeIfid}// -->${div}`
5460
+ } : void 0, headSlot(options?.head ?? "")].filter((slot) => slot !== void 0);
5282
5461
  const displayStart = startName === "Start" ? "" : startName;
5283
- template = template.replace("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`);
5284
- template = template.replace("\"TIME\"", `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`);
5285
- const startAtValue = `"${jsStringEscape(displayStart)}"`;
5286
- template = template.replace("\"START_AT\"", () => startAtValue);
5287
- template = template.replace("\"STORY_SIZE\"", `"${count}"`);
5288
- if (template.includes("\"STORY\"")) template = template.replace("\"STORY\"", () => data);
5289
- else {
5290
- let footer;
5291
- try {
5292
- footer = readFileSync(join(formatDir, "footer.html"), "utf-8");
5293
- } catch {
5294
- footer = "</div>\n</body>\n</html>\n";
5295
- }
5296
- template += data + footer;
5297
- }
5298
- if (story.ifid) {
5299
- const safeIfid = htmlCommentSanitize(story.ifid);
5300
- if (template.includes("<div id=\"store-area\"")) template = template.replace("<div id=\"store-area\"", `<!-- UUID://${safeIfid}// --><div id="store-area"`);
5301
- else template = template.replace("<div id=\"storeArea\"", `<!-- UUID://${safeIfid}// --><div id="storeArea"`);
5462
+ const slots = [
5463
+ firstSlot("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`),
5464
+ firstSlot("\"TIME\"", `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`),
5465
+ firstSlot("\"START_AT\"", `"${jsStringEscape(displayStart)}"`),
5466
+ firstSlot("\"STORY_SIZE\"", `"${count}"`),
5467
+ firstSlot("\"STORY\"", data),
5468
+ ...late
5469
+ ];
5470
+ return fillTemplateParts(isPre14 ? [
5471
+ {
5472
+ kind: "scan",
5473
+ text: template,
5474
+ slots
5475
+ },
5476
+ {
5477
+ kind: "verbatim",
5478
+ text: data
5479
+ },
5480
+ {
5481
+ kind: "scan",
5482
+ text: footer,
5483
+ slots: late
5484
+ }
5485
+ ] : [{
5486
+ kind: "scan",
5487
+ text: template,
5488
+ slots
5489
+ }]);
5490
+ }
5491
+ /** A slot replacing the first `token` with `value`. */
5492
+ function firstSlot(token, value) {
5493
+ return {
5494
+ pattern: literal(token),
5495
+ occurrences: "first",
5496
+ replacement: () => value
5497
+ };
5498
+ }
5499
+ /** The footer of a pre-1.4 format, or the default one when the format has none. */
5500
+ function readFooter(formatDir) {
5501
+ try {
5502
+ return readFileSync(join(formatDir, "footer.html"), "utf-8");
5503
+ } catch {
5504
+ return "</div>\n</body>\n</html>\n";
5302
5505
  }
5303
- return template;
5304
5506
  }
5305
5507
  /**
5306
5508
  * Why Twine 1 output leaves a passage out of its tiddlers, or `undefined` when the passage is emitted.
@@ -5338,82 +5540,54 @@ function tryReplaceComponent(template, placeholder, componentPath, required) {
5338
5540
  }
5339
5541
  //#endregion
5340
5542
  //#region src/output-twee.ts
5341
- function toTwee(story, outMode) {
5342
- let data = "";
5343
- for (const p of story.passages) data += passageToTwee(p, outMode);
5344
- return data;
5345
- }
5346
- //#endregion
5347
- //#region src/modules.ts
5348
5543
  /**
5349
- * Load modules and return HTML tags to inject before </head>.
5544
+ * Serialize the story as Twee. The StoryData passage is written from the story model rather than
5545
+ * from its loaded text, so changes made after loading (a start passage override, test mode) are kept.
5546
+ * Tweego writes the loaded text; its own normalization makes the two the same for an unchanged story.
5547
+ *
5548
+ * `addStoryData`: add a StoryData passage when the story has none, so that StoryData fields the
5549
+ * compile options set are recorded. Default: false.
5350
5550
  */
5351
- function loadModules(filenames) {
5352
- const processed = /* @__PURE__ */ new Set();
5353
- const headTags = [];
5354
- for (const filename of filenames) {
5355
- if (processed.has(filename)) continue;
5356
- const ext = normalizedFileExt(filename);
5357
- let tag = null;
5358
- switch (ext) {
5359
- case "css":
5360
- tag = loadModuleTagged("style", filename);
5361
- break;
5362
- case "js":
5363
- tag = loadModuleTagged("script", filename);
5364
- break;
5365
- case "otf":
5366
- case "ttf":
5367
- case "woff":
5368
- case "woff2":
5369
- tag = loadModuleFont(filename);
5370
- break;
5371
- default: continue;
5372
- }
5373
- if (tag) headTags.push(tag);
5374
- processed.add(filename);
5375
- }
5376
- return headTags.join("\n");
5551
+ function toTwee(story, outMode, options = {}) {
5552
+ return withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => passageToTwee(p, outMode)).join("");
5377
5553
  }
5378
- function loadModuleTagged(tag, filename) {
5379
- const source = readUTF8(filename).trim();
5380
- if (source.length === 0) return null;
5381
- return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
5554
+ function withEffectiveStoryData(story, addStoryData) {
5555
+ const storyData = {
5556
+ name: "StoryData",
5557
+ tags: [],
5558
+ text: marshalStoryData(story)
5559
+ };
5560
+ const index = story.passages.findIndex((p) => p.name === "StoryData");
5561
+ if (index !== -1) return story.passages.map((p, i) => i === index && isStoryDataJSON(p.text) ? {
5562
+ ...p,
5563
+ text: storyData.text
5564
+ } : p);
5565
+ if (!addStoryData) return story.passages;
5566
+ const at = story.passages[0]?.name === "StoryTitle" ? 1 : 0;
5567
+ return [
5568
+ ...story.passages.slice(0, at),
5569
+ storyData,
5570
+ ...story.passages.slice(at)
5571
+ ];
5382
5572
  }
5383
- function loadModuleFont(filename) {
5384
- const source = readBase64(filename);
5385
- const family = baseNameWithoutExt(filename);
5386
- const idSlug = `style-module-${slugify(family)}`;
5387
- const ext = normalizedFileExt(filename);
5388
- 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>`;
5573
+ /** Whether the text loaded as StoryData. Text that did not is kept as written, for the author to fix. */
5574
+ function isStoryDataJSON(text) {
5575
+ return unmarshalStoryData(createStory(), text) === null;
5389
5576
  }
5577
+ //#endregion
5578
+ //#region src/passage-omission.ts
5390
5579
  /**
5391
- * Inject modules and head file content before </head>.
5580
+ * Why the `target` output leaves `passage` out of its passage elements, or `undefined` when it
5581
+ * emits the passage (and so a link or the start can name it).
5392
5582
  */
5393
- function modifyHead(html, modulePaths, headFile, diagnostics) {
5394
- const parts = [];
5395
- if (modulePaths.length > 0) {
5396
- const modules = loadModules(modulePaths).trim();
5397
- if (modules.length > 0) parts.push(modules);
5398
- }
5399
- if (headFile) try {
5400
- const source = readUTF8(headFile).trim();
5401
- if (source.length > 0) parts.push(source);
5402
- } catch (e) {
5403
- diagnostics?.push({
5404
- level: "warning",
5405
- message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
5406
- });
5407
- }
5408
- if (parts.length > 0) {
5409
- parts.push("</head>");
5410
- const replacement = parts.join("\n");
5411
- return html.replace("</head>", () => replacement);
5583
+ function passageOmission(story, passage, target) {
5584
+ switch (target) {
5585
+ case "twine2": return twine2PassageOmission(story, passage);
5586
+ case "twine1": return twine1PassageOmission(passage);
5587
+ default: throw new Error(`unhandled passage output target: ${String(target)}`);
5412
5588
  }
5413
- return html;
5414
5589
  }
5415
- //#endregion
5416
- //#region src/start-passage.ts
5590
+ /** Why a passage is left out, as a predicate: `Passage "X" ${describeOmission(o)}`. */
5417
5591
  function describeOmission(omission) {
5418
5592
  switch (omission.kind) {
5419
5593
  case "special-name": return `is the special "${omission.name}" passage`;
@@ -5422,13 +5596,8 @@ function describeOmission(omission) {
5422
5596
  default: throw new Error(`unhandled passage omission: ${JSON.stringify(omission)}`);
5423
5597
  }
5424
5598
  }
5425
- function omissionFor(story, passage, target) {
5426
- switch (target) {
5427
- case "twine2": return twine2PassageOmission(story, passage);
5428
- case "twine1": return twine1PassageOmission(passage);
5429
- default: throw new Error(`unhandled start passage target: ${String(target)}`);
5430
- }
5431
- }
5599
+ //#endregion
5600
+ //#region src/start-passage.ts
5432
5601
  /**
5433
5602
  * Check that `startName` names a passage the `target` output emits.
5434
5603
  * Returns the error diagnostics to report (empty when the start is usable).
@@ -5439,7 +5608,7 @@ function startPassageDiagnostics(story, startName, target) {
5439
5608
  level: "error",
5440
5609
  message: `Starting passage "${startName}" not found.`
5441
5610
  }];
5442
- const omission = omissionFor(story, passage, target);
5611
+ const omission = passageOmission(story, passage, target);
5443
5612
  if (omission === void 0) return [];
5444
5613
  return [{
5445
5614
  level: "error",
@@ -5465,15 +5634,25 @@ var TweeTsError = class extends Error {
5465
5634
  }
5466
5635
  };
5467
5636
  /**
5468
- * Compile Twee sources to HTML, Twee, or JSON.
5637
+ * Builds as compileToFile() does for `outFile`, without writing it: the output may sit
5638
+ * inside a source folder, so its last build is left out of the sources and modules and
5639
+ * never read back. The caller decides whether the result is written. With no `outFile`
5640
+ * (output to stdout, or none), this is compile(). With `cache`, files are cached as
5641
+ * compileIncremental() caches them.
5642
+ *
5643
+ * Internal, for the CLI and the bundler plugins; not part of the public API.
5469
5644
  */
5470
- async function compile(options) {
5471
- return buildOutput(options);
5645
+ async function compileForOutputFile(options, outFile, cache) {
5646
+ return buildOutput(options, cache, void 0, outFile);
5472
5647
  }
5473
5648
  /**
5474
- * Watch for file changes and recompile.
5649
+ * watch(), writing to `outFile` only the builds `shouldWrite` accepts. A rejected build
5650
+ * leaves the output file as it was; `onBuild` receives every build either way.
5651
+ *
5652
+ * Internal, for the CLI, which keeps the last good output when a rebuild reports errors;
5653
+ * not part of the public API.
5475
5654
  */
5476
- async function watch$1(options) {
5655
+ async function watchWithWriteFilter(options, shouldWrite) {
5477
5656
  const controller = new AbortController();
5478
5657
  const cache = /* @__PURE__ */ new Map();
5479
5658
  const filePaths = options.sources.filter((s) => typeof s === "string");
@@ -5491,17 +5670,62 @@ async function watch$1(options) {
5491
5670
  const abs = resolve(filename);
5492
5671
  return !notExcludable.some((root) => abs === root || abs.startsWith(root + sep));
5493
5672
  };
5673
+ let building = false;
5674
+ let queued;
5675
+ const deliver = (outcome) => {
5676
+ try {
5677
+ if (!outcome.ok) throw outcome.error;
5678
+ if (shouldWrite(outcome.result)) writeFileSync(options.outFile, outcome.result.output, "utf-8");
5679
+ options.onBuild?.(outcome.result);
5680
+ } catch (e) {
5681
+ options.onError?.(toError(e));
5682
+ }
5683
+ };
5684
+ const drain = async (first) => {
5685
+ building = true;
5686
+ try {
5687
+ let request = first;
5688
+ while (request !== void 0) {
5689
+ const current = request;
5690
+ const outcome = await buildOutput(options, cache, current.changedFiles, options.outFile).then((result) => ({
5691
+ ok: true,
5692
+ result
5693
+ }), (e) => ({
5694
+ ok: false,
5695
+ error: toError(e)
5696
+ }));
5697
+ if (controller.signal.aborted) return;
5698
+ request = queued === void 0 ? void 0 : mergeBuildRequests(current, queued);
5699
+ queued = void 0;
5700
+ if (request === void 0) deliver(outcome);
5701
+ }
5702
+ } finally {
5703
+ building = false;
5704
+ }
5705
+ };
5494
5706
  const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
5495
- buildOutput(options, cache, changedFiles, options.outFile).then((result) => {
5496
- writeFileSync(options.outFile, result.output, "utf-8");
5497
- options.onBuild?.(result);
5498
- }).catch((e) => {
5499
- options.onError?.(e instanceof Error ? e : new Error(String(e)));
5500
- });
5707
+ if (controller.signal.aborted) return;
5708
+ const request = { changedFiles };
5709
+ if (building) {
5710
+ queued = queued === void 0 ? request : mergeBuildRequests(queued, request);
5711
+ return;
5712
+ }
5713
+ drain(request);
5501
5714
  }, ignore);
5502
- controller.signal.addEventListener("abort", () => handle.close());
5715
+ controller.signal.addEventListener("abort", () => {
5716
+ queued = void 0;
5717
+ handle.close();
5718
+ });
5503
5719
  return controller;
5504
5720
  }
5721
+ /** One build covering both requests: a full build if either is one, else every changed file. */
5722
+ function mergeBuildRequests(a, b) {
5723
+ if (a.changedFiles === void 0 || b.changedFiles === void 0) return { changedFiles: void 0 };
5724
+ return { changedFiles: /* @__PURE__ */ new Set([...a.changedFiles, ...b.changedFiles]) };
5725
+ }
5726
+ function toError(e) {
5727
+ return e instanceof Error ? e : new Error(String(e));
5728
+ }
5505
5729
  /**
5506
5730
  * `outFile`: the file the build is written to, which source and module discovery
5507
5731
  * skip (as Tweego does), so an output inside a source folder is never loaded back.
@@ -5549,7 +5773,7 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5549
5773
  switch (outputMode) {
5550
5774
  case "twee3":
5551
5775
  case "twee1":
5552
- output = toTwee(story, outputMode);
5776
+ output = toTwee(story, outputMode, { addStoryData: Boolean(options.startPassage) || testMode });
5553
5777
  break;
5554
5778
  case "twine2-archive":
5555
5779
  output = toTwine2Archive(story, startName, { sourceInfo });
@@ -5563,17 +5787,17 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5563
5787
  case "html": {
5564
5788
  diagnostics.push(...startPassageDiagnostics(story, startName, format?.isTwine2 === false ? "twine1" : "twine2"));
5565
5789
  if (!format) throw new TweeTsError("No story format available for HTML output.", diagnostics);
5566
- if (format.isTwine2) output = toTwine2HTML(story, format, startName, { sourceInfo });
5567
- else {
5568
- if (story.name === "" && !storyHas(story, "StoryTitle")) diagnostics.push({
5569
- level: "error",
5570
- message: "Special passage \"StoryTitle\" not found."
5571
- });
5572
- output = toTwine1HTML(story, format, startName);
5573
- }
5790
+ if (!format.isTwine2 && story.name === "" && !storyHas(story, "StoryTitle")) diagnostics.push({
5791
+ level: "error",
5792
+ message: "Special passage \"StoryTitle\" not found."
5793
+ });
5574
5794
  const modules = getFilenames(options.modules ?? [], outFile);
5575
5795
  diagnostics.push(...modules.diagnostics);
5576
- output = modifyHead(output, modules.filenames, options.headFile, diagnostics);
5796
+ const head = loadHeadContent(modules.filenames, options.headFile, diagnostics);
5797
+ output = format.isTwine2 ? toTwine2HTML(story, format, startName, {
5798
+ sourceInfo,
5799
+ head
5800
+ }) : toTwine1HTML(story, format, startName, { head });
5577
5801
  break;
5578
5802
  }
5579
5803
  default: throw new TweeTsError(`Unhandled output mode: ${outputMode}`, diagnostics);
@@ -5992,6 +6216,11 @@ function endOfRegularExpression(source, pos) {
5992
6216
  }
5993
6217
  //#endregion
5994
6218
  //#region src/link-markup.ts
6219
+ /** Records that the reading has looked at the characters before `pos`, and returns `result`. */
6220
+ function readTo(reader, pos, result) {
6221
+ reader.scanned = Math.max(reader.scanned, pos);
6222
+ return result;
6223
+ }
5995
6224
  /**
5996
6225
  * Reads link or image markup that starts at `start` in `text`, or returns `undefined` where
5997
6226
  * SugarCube rejects it (and so prints the text instead).
@@ -5999,15 +6228,17 @@ function endOfRegularExpression(source, pos) {
5999
6228
  * `budget.left`, if given, is how many characters the reading may look at; it is reduced by as
6000
6229
  * many as were read, and once it runs out, every reading fails. Unclosed markup is read to the end
6001
6230
  * of its line, so reading every `[[` of a long line that never closes one would otherwise take
6002
- * quadratic time.
6231
+ * quadratic time. A reading is charged for the characters it looked at, up to where it ended or
6232
+ * failed (a line feed, say), and no more, so markup left unclosed on short lines costs little.
6003
6233
  */
6004
6234
  function readSquareBracketedMarkup(text, start, budget = { left: Infinity }) {
6005
6235
  const reader = {
6006
6236
  text,
6007
- limit: Math.min(text.length, start + Math.max(budget.left, 0))
6237
+ limit: Math.min(text.length, start + Math.max(budget.left, 0)),
6238
+ scanned: start
6008
6239
  };
6009
6240
  const markup = readMarkup(reader, start);
6010
- budget.left -= (markup?.end ?? reader.limit) - start;
6241
+ budget.left -= Math.max(reader.scanned, markup?.end ?? start) - start;
6011
6242
  return markup;
6012
6243
  }
6013
6244
  function readMarkup(reader, start) {
@@ -6083,7 +6314,7 @@ function readCoreComponents(reader, from) {
6083
6314
  const ch = text[pos];
6084
6315
  pos += 1;
6085
6316
  switch (ch) {
6086
- case "\n": return;
6317
+ case "\n": return readTo(reader, pos, void 0);
6087
6318
  case "\"": {
6088
6319
  const end = readQuoted(reader, pos, "\"");
6089
6320
  if (end === void 0) return;
@@ -6117,7 +6348,7 @@ function readCoreComponents(reader, from) {
6117
6348
  case "]":
6118
6349
  depth -= 1;
6119
6350
  if (depth === 1) {
6120
- const end = componentEnd(text, pos);
6351
+ const end = componentEnd(reader, pos);
6121
6352
  if (end === void 0) return;
6122
6353
  return {
6123
6354
  link: link ?? text.slice(componentStart, end.textEnd),
@@ -6126,6 +6357,7 @@ function readCoreComponents(reader, from) {
6126
6357
  }
6127
6358
  }
6128
6359
  }
6360
+ return readTo(reader, pos, void 0);
6129
6361
  }
6130
6362
  /**
6131
6363
  * Reads a component after `][` that holds no delimiter: an image's link (`setter` false) or a
@@ -6139,7 +6371,7 @@ function readComponent(reader, from, setter) {
6139
6371
  const ch = text[pos];
6140
6372
  pos += 1;
6141
6373
  switch (ch) {
6142
- case "\n": return;
6374
+ case "\n": return readTo(reader, pos, void 0);
6143
6375
  case "\"":
6144
6376
  case "'": {
6145
6377
  if (ch === "'" && !setter) break;
@@ -6153,9 +6385,10 @@ function readComponent(reader, from, setter) {
6153
6385
  break;
6154
6386
  case "]":
6155
6387
  depth -= 1;
6156
- if (depth === 1) return componentEnd(text, pos);
6388
+ if (depth === 1) return componentEnd(reader, pos);
6157
6389
  }
6158
6390
  }
6391
+ return readTo(reader, pos, void 0);
6159
6392
  }
6160
6393
  /** Reads a setter, the last component; returns the index after its closing `]]`. */
6161
6394
  function readSetter(reader, from) {
@@ -6163,19 +6396,19 @@ function readSetter(reader, from) {
6163
6396
  return end?.last === true ? end.next : void 0;
6164
6397
  }
6165
6398
  /** What the `]` just before `pos`, which brings the depth back to one, ends; `undefined` if malformed. */
6166
- function componentEnd(text, pos) {
6167
- switch (text[pos]) {
6168
- case "[": return {
6399
+ function componentEnd(reader, pos) {
6400
+ switch (reader.text[pos]) {
6401
+ case "[": return readTo(reader, pos + 1, {
6169
6402
  textEnd: pos - 1,
6170
6403
  next: pos + 1,
6171
6404
  last: false
6172
- };
6173
- case "]": return {
6405
+ });
6406
+ case "]": return readTo(reader, pos + 1, {
6174
6407
  textEnd: pos - 1,
6175
6408
  next: pos + 1,
6176
6409
  last: true
6177
- };
6178
- default: return;
6410
+ });
6411
+ default: return readTo(reader, pos + 1, void 0);
6179
6412
  }
6180
6413
  }
6181
6414
  /**
@@ -6190,12 +6423,13 @@ function readQuoted(reader, from, quote) {
6190
6423
  const ch = text[pos];
6191
6424
  pos += 1;
6192
6425
  if (ch === quote) return pos;
6193
- if (ch === "\n") return;
6426
+ if (ch === "\n") return readTo(reader, pos, void 0);
6194
6427
  if (ch === "\\") {
6195
- if (pos >= reader.limit || text[pos] === "\n") return;
6428
+ if (pos >= reader.limit || text[pos] === "\n") return readTo(reader, Math.min(pos + 1, reader.limit), void 0);
6196
6429
  pos += 1;
6197
6430
  }
6198
6431
  }
6432
+ return readTo(reader, pos, void 0);
6199
6433
  }
6200
6434
  //#endregion
6201
6435
  //#region src/sugarcube-macros.ts
@@ -7053,6 +7287,39 @@ function extractLinksFromText(text, isScript) {
7053
7287
  ].flatMap((via) => found.filter((link) => link.via === via).map((link) => link.passage));
7054
7288
  return [...new Set(ordered)];
7055
7289
  }
7290
+ /**
7291
+ * The passages that `p` links to. A script passage is JavaScript, read for links only in its
7292
+ * strings. A stylesheet is CSS, which the story puts in a style element and never wikifies, so it
7293
+ * links nowhere; a passage tagged both is a script, as Twine 2 output takes it.
7294
+ */
7295
+ function passageLinks(p) {
7296
+ if (hasTag(p, "script")) return extractLinksFromText(textAsRead(p), true);
7297
+ if (hasTag(p, "stylesheet")) return [];
7298
+ return extractLinksFromText(textAsRead(p), false);
7299
+ }
7300
+ /**
7301
+ * Whether the `target` output keeps the links in `p`: always without a target, which describes
7302
+ * every source passage. With one, a passage the output leaves out never reaches the player, so it
7303
+ * links nowhere, except a script passage, which Twine 2 output leaves out as a passage but runs.
7304
+ */
7305
+ function readsLinks(story, p, { target }) {
7306
+ if (target === void 0) return true;
7307
+ const omission = passageOmission(story, p, target);
7308
+ return omission === void 0 || omission.kind === "tag" && omission.tag === "script";
7309
+ }
7310
+ /**
7311
+ * Each passage name, mapped to why the `target` output leaves that passage out, or to `undefined`
7312
+ * when a link can reach it: always without a target, and otherwise when the output emits a
7313
+ * passage of that name.
7314
+ */
7315
+ function destinationOmissions(story, { target }) {
7316
+ const omissions = /* @__PURE__ */ new Map();
7317
+ for (const p of story.passages) {
7318
+ const omission = target === void 0 ? void 0 : passageOmission(story, p, target);
7319
+ if (!omissions.has(p.name) || omission === void 0) omissions.set(p.name, omission);
7320
+ }
7321
+ return omissions;
7322
+ }
7056
7323
  /** Passages SugarCube runs from their raw text, never joining their lines. */
7057
7324
  const RAW_TEXT_PASSAGES = /* @__PURE__ */ new Set([
7058
7325
  "StoryInit",
@@ -7094,9 +7361,16 @@ function joinLines(text) {
7094
7361
  * expect(map.brokenLinks).toEqual([]);
7095
7362
  * expect(map.deadEnds).not.toContain('Start');
7096
7363
  * ```
7097
- */
7098
- function storyInspect(story) {
7099
- const allNames = new Set(story.passages.map((p) => p.name));
7364
+ *
7365
+ * By default, a link is broken only when no passage has its name. Pass a `target` to check links
7366
+ * against what that output emits: `storyInspect(result.story, { target: 'twine2' })` also reports
7367
+ * links to passages Twine 2 output leaves out, such as script, stylesheet and `Twine.private`
7368
+ * passages. With a target, passages that output leaves out also give no links (script passages
7369
+ * excepted, as Twine 2 output runs them), so they report no broken links and keep no passage from
7370
+ * being an orphan.
7371
+ */
7372
+ function storyInspect(story, options = {}) {
7373
+ const destinations = destinationOmissions(story, options);
7100
7374
  const passages = [];
7101
7375
  const storyPassages = [];
7102
7376
  const infoPassages = [];
@@ -7118,14 +7392,24 @@ function storyInspect(story) {
7118
7392
  }
7119
7393
  list.push(p.name);
7120
7394
  }
7121
- const targets = extractLinksFromText(textAsRead(p), hasTag(p, "script"));
7122
- links.set(p.name, targets);
7395
+ links.set(p.name, readsLinks(story, p, options) ? passageLinks(p) : []);
7123
7396
  }
7124
7397
  const brokenLinks = [];
7125
- for (const [from, targets] of links) for (const to of targets) if (!allNames.has(to)) brokenLinks.push({
7126
- from,
7127
- to
7128
- });
7398
+ for (const [from, targets] of links) for (const to of targets) {
7399
+ if (!destinations.has(to)) {
7400
+ brokenLinks.push({
7401
+ from,
7402
+ to
7403
+ });
7404
+ continue;
7405
+ }
7406
+ const omission = destinations.get(to);
7407
+ if (omission !== void 0) brokenLinks.push({
7408
+ from,
7409
+ to,
7410
+ omission
7411
+ });
7412
+ }
7129
7413
  const deadEnds = [];
7130
7414
  for (const name of storyPassages) {
7131
7415
  const targets = links.get(name);
@@ -7153,16 +7437,18 @@ function storyInspect(story) {
7153
7437
  //#endregion
7154
7438
  //#region src/lint.ts
7155
7439
  /**
7156
- * Lint a story: compile without output rendering, then inspect structure.
7157
- * Uses JSON output mode internally to avoid format resolution, so it checks
7158
- * the starting passage itself, against the Twine 2 passage rules.
7440
+ * lint() for a project whose builds are written to `outFile`: like a build for that file,
7441
+ * it leaves `outFile` out of the sources and modules, so an earlier build inside a source
7442
+ * folder is not linted as a source.
7443
+ *
7444
+ * Internal, for the CLI; not part of the public API.
7159
7445
  */
7160
- async function lint(options) {
7161
- const result = await compile({
7446
+ async function lintForOutputFile(options, outFile) {
7447
+ const result = await compileForOutputFile({
7162
7448
  ...options,
7163
7449
  outputMode: "json"
7164
- });
7165
- const map = storyInspect(result.story);
7450
+ }, outFile);
7451
+ const map = storyInspect(result.story, { target: "twine2" });
7166
7452
  return {
7167
7453
  diagnostics: [...result.diagnostics, ...startPassageDiagnostics(result.story, map.start, "twine2")],
7168
7454
  stats: result.stats,
@@ -7189,7 +7475,10 @@ function formatLintReport(result) {
7189
7475
  if (result.brokenLinks.length > 0) {
7190
7476
  lines.push("");
7191
7477
  lines.push(`Broken links (${result.brokenLinks.length}):`);
7192
- for (const link of result.brokenLinks) lines.push(` ${link.from} -> ${link.to} (passage "${link.to}" does not exist)`);
7478
+ for (const link of result.brokenLinks) {
7479
+ const why = link.omission === void 0 ? "does not exist" : `${describeOmission(link.omission)}, so it is left out of the story data`;
7480
+ lines.push(` ${link.from} -> ${link.to} (passage "${link.to}" ${why})`);
7481
+ }
7193
7482
  }
7194
7483
  if (result.deadEnds.length > 0) {
7195
7484
  lines.push("");
@@ -7472,8 +7761,10 @@ async function main() {
7472
7761
  }
7473
7762
  return raw;
7474
7763
  })();
7764
+ const outFile = values.output ?? config?.output ?? "-";
7765
+ const outPath = outFile === "-" ? void 0 : outFile;
7475
7766
  if (values.lint) {
7476
- const lintResult = await lint({
7767
+ const lintResult = await lintForOutputFile({
7477
7768
  sources,
7478
7769
  exclude: values.exclude ?? config?.exclude,
7479
7770
  formatId: values.format ?? config?.formatId,
@@ -7490,7 +7781,7 @@ async function main() {
7490
7781
  tagAliases,
7491
7782
  sourceInfo: values["source-info"] ?? config?.sourceInfo ?? false,
7492
7783
  wordCountMethod
7493
- });
7784
+ }, outPath);
7494
7785
  console.log(formatLintReport(lintResult));
7495
7786
  const hasErrors = lintResult.brokenLinks.length > 0 || lintResult.diagnostics.some((d) => d.level === "error");
7496
7787
  process.exit(hasErrors ? 1 : 0);
@@ -7515,39 +7806,46 @@ async function main() {
7515
7806
  sourceInfo: values["source-info"] ?? config?.sourceInfo ?? false,
7516
7807
  wordCountMethod
7517
7808
  };
7518
- const outFile = values.output ?? config?.output ?? "-";
7809
+ const log = {
7810
+ files: outPath !== void 0 && (values["log-files"] ?? false),
7811
+ stats: values["log-stats"] ?? false
7812
+ };
7519
7813
  if (values.watch) {
7520
- if (outFile === "-") {
7814
+ if (outPath === void 0) {
7521
7815
  console.error("Error: Watch mode requires an output file (-o).");
7522
7816
  process.exit(1);
7523
7817
  }
7524
7818
  console.log("Watch mode started. Press CTRL+C to stop.");
7525
- await watch$1({
7819
+ await watchWithWriteFilter({
7526
7820
  ...compileOptions,
7527
- outFile,
7821
+ outFile: outPath,
7528
7822
  onBuild(result) {
7529
7823
  console.log(`Built: ${result.stats.passages} passages, ${result.stats.words} words`);
7530
7824
  logDiagnostics(result.diagnostics);
7531
7825
  const errors = countErrors(result.diagnostics);
7532
- if (errors > 0) console.error(`Build has ${pluralize(errors, "error")}; still watching for changes.`);
7826
+ if (errors > 0) console.error(`Build has ${pluralize(errors, "error")}; output not written. Still watching for changes.`);
7827
+ logBuild(result, log);
7533
7828
  },
7534
7829
  onError(error) {
7535
7830
  console.error(`Build error: ${error.message}`);
7536
7831
  }
7537
- });
7832
+ }, (result) => countErrors(result.diagnostics) === 0);
7538
7833
  } else {
7539
- const result = await compile(compileOptions);
7834
+ const result = await compileForOutputFile(compileOptions, outPath);
7540
7835
  logDiagnostics(result.diagnostics);
7541
7836
  const errors = countErrors(result.diagnostics);
7542
7837
  if (errors > 0) {
7543
7838
  console.error(`Compilation failed with ${pluralize(errors, "error")}; output not written.`);
7544
7839
  process.exitCode = 1;
7545
- } else if (outFile === "-") process.stdout.write(result.output);
7546
- else writeFileSync(outFile, result.output, "utf-8");
7547
- if (outFile !== "-" && values["log-files"]) console.log(`\nFiles: ${result.stats.files.join(", ")}`);
7548
- if (values["log-stats"]) logStats(result);
7840
+ } else if (outPath === void 0) process.stdout.write(result.output);
7841
+ else writeFileSync(outPath, result.output, "utf-8");
7842
+ logBuild(result, log);
7549
7843
  }
7550
7844
  }
7845
+ function logBuild(result, log) {
7846
+ if (log.files) console.log(`\nFiles: ${result.stats.files.join(", ")}`);
7847
+ if (log.stats) logStats(result);
7848
+ }
7551
7849
  function countErrors(diagnostics) {
7552
7850
  return diagnostics.filter((d) => d.level === "error").length;
7553
7851
  }