@rohal12/twee-ts 1.16.1 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -4719,14 +4719,16 @@ function parseDocument(data, options) {
4719
4719
  */
4720
4720
  /**
4721
4721
  * Parse a Twine 2 or Twine 1 compiled HTML file back into a Story model.
4722
+ * Tweego always trims passage text; here `trim: false` keeps it exactly, as for Twee sources.
4722
4723
  */
4723
- function decompileHTML(html) {
4724
+ function decompileHTML(html, options = {}) {
4724
4725
  const diagnostics = [];
4725
4726
  const story = createStory();
4727
+ const passageText = options.trim === false ? (text) => text : (text) => text.trim();
4726
4728
  const doc = parseDocument(html);
4727
4729
  const twine2Data = findElement(doc, "tw-storydata");
4728
4730
  if (twine2Data) {
4729
- decompileTwine2(twine2Data, story, diagnostics);
4731
+ decompileTwine2(twine2Data, story, passageText, diagnostics);
4730
4732
  return {
4731
4733
  story,
4732
4734
  diagnostics
@@ -4734,7 +4736,7 @@ function decompileHTML(html) {
4734
4736
  }
4735
4737
  const twine1Data = findElementByIdPattern(doc, /^store(?:-a|A)rea$/);
4736
4738
  if (twine1Data) {
4737
- decompileTwine1(twine1Data, story, diagnostics);
4739
+ decompileTwine1(twine1Data, story, passageText, diagnostics);
4738
4740
  return {
4739
4741
  story,
4740
4742
  diagnostics
@@ -4749,7 +4751,7 @@ function decompileHTML(html) {
4749
4751
  diagnostics
4750
4752
  };
4751
4753
  }
4752
- function decompileTwine2(storyData, story, diagnostics) {
4754
+ function decompileTwine2(storyData, story, passageText, diagnostics) {
4753
4755
  let startnode = 0;
4754
4756
  const attrs = storyData.attribs ?? {};
4755
4757
  if (attrs["name"]) story.name = attrs["name"];
@@ -4779,8 +4781,9 @@ function decompileTwine2(storyData, story, diagnostics) {
4779
4781
  switch (node.name) {
4780
4782
  case "style":
4781
4783
  case "script": {
4782
- const text = getTextContent(node).trim();
4783
- if (text.length === 0) continue;
4784
+ const content = getTextContent(node);
4785
+ if (content.trim().length === 0) continue;
4786
+ const text = passageText(content);
4784
4787
  storyAdd(story, {
4785
4788
  name: node.name === "style" ? "Story Stylesheet" : "Story JavaScript",
4786
4789
  tags: node.name === "style" ? ["stylesheet"] : ["script"],
@@ -4815,7 +4818,7 @@ function decompileTwine2(storyData, story, diagnostics) {
4815
4818
  const passage = {
4816
4819
  name,
4817
4820
  tags,
4818
- text: getTextContent(node).trim()
4821
+ text: passageText(getTextContent(node))
4819
4822
  };
4820
4823
  if (metadata.position || metadata.size) passage.metadata = metadata;
4821
4824
  storyAdd(story, passage, diagnostics);
@@ -4829,8 +4832,8 @@ function decompileTwine2(storyData, story, diagnostics) {
4829
4832
  text: marshalStoryData(story)
4830
4833
  }, diagnostics);
4831
4834
  }
4832
- function decompileTwine1(storeArea, story, diagnostics) {
4833
- const passages = (storeArea.children ?? []).filter(isTiddler).map(tiddlerToPassage);
4835
+ function decompileTwine1(storeArea, story, passageText, diagnostics) {
4836
+ const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
4834
4837
  const obfuscated = isRot13Obfuscated(passages);
4835
4838
  for (const p of passages) storyAdd(story, obfuscated && p.name !== "StorySettings" ? {
4836
4839
  ...p,
@@ -4840,12 +4843,12 @@ function decompileTwine1(storeArea, story, diagnostics) {
4840
4843
  function isTiddler(node) {
4841
4844
  return node.type === "tag" && node.name === "div" && node.attribs !== void 0 && "tiddler" in node.attribs;
4842
4845
  }
4843
- function tiddlerToPassage(node) {
4846
+ function tiddlerToPassage(node, passageText) {
4844
4847
  const nodeAttrs = node.attribs ?? {};
4845
4848
  const passage = {
4846
4849
  name: nodeAttrs["tiddler"] ?? "",
4847
4850
  tags: nodeAttrs["tags"] ? nodeAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [],
4848
- text: tiddlerUnescape(getTextContent(node)).trim()
4851
+ text: passageText(tiddlerUnescape(getTextContent(node)))
4849
4852
  };
4850
4853
  const position = nodeAttrs["twine-position"];
4851
4854
  if (position) passage.metadata = { position };
@@ -4910,7 +4913,7 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
4910
4913
  break;
4911
4914
  case "htm":
4912
4915
  case "html":
4913
- loadHTML(story, filename, diagnostics);
4916
+ loadHTML(story, filename, opts, diagnostics);
4914
4917
  break;
4915
4918
  case "css":
4916
4919
  loadTagged(story, "stylesheet", filename, diagnostics);
@@ -5064,8 +5067,8 @@ function parseFontFile(filename) {
5064
5067
  diagnostics: []
5065
5068
  };
5066
5069
  }
5067
- function parseHTMLFile(filename) {
5068
- const { story, diagnostics } = decompileHTML(readUTF8(filename));
5070
+ function parseHTMLFile(filename, opts) {
5071
+ const { story, diagnostics } = decompileHTML(readUTF8(filename), { trim: opts.trim ?? true });
5069
5072
  return {
5070
5073
  passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
5071
5074
  name: "StoryTitle",
@@ -5077,14 +5080,17 @@ function parseHTMLFile(filename) {
5077
5080
  }
5078
5081
  /**
5079
5082
  * Identity of the options that affect how a file parses, stored on its cache entry.
5080
- * Only Twee files depend on them; every other file type yields the same key for all options.
5083
+ * Twee files depend on `trim` and `twee2Compat`, HTML files on `trim`; every other file type
5084
+ * yields the same key for all options.
5081
5085
  */
5082
5086
  function parseOptionsKey(filename, opts) {
5083
5087
  const ext = normalizedFileExt(filename);
5088
+ const trim = opts.trim ?? true;
5089
+ if (ext === "htm" || ext === "html") return JSON.stringify({ trim });
5084
5090
  const twee2File = ext === "tw2" || ext === "twee2";
5085
5091
  if (!twee2File && ext !== "tw" && ext !== "twee") return "";
5086
5092
  return JSON.stringify({
5087
- trim: opts.trim ?? true,
5093
+ trim,
5088
5094
  twee2Compat: twee2File || (opts.twee2Compat ?? false)
5089
5095
  });
5090
5096
  }
@@ -5098,7 +5104,7 @@ function parseFile(filename, opts) {
5098
5104
  twee2Compat: true
5099
5105
  });
5100
5106
  case "htm":
5101
- case "html": return parseHTMLFile(filename);
5107
+ case "html": return parseHTMLFile(filename, opts);
5102
5108
  case "css": return parseTaggedFile("stylesheet", filename);
5103
5109
  case "js": return parseTaggedFile("script", filename);
5104
5110
  case "otf":
@@ -5130,8 +5136,8 @@ function parseFile(filename, opts) {
5130
5136
  default: return;
5131
5137
  }
5132
5138
  }
5133
- function loadHTML(story, filename, diagnostics) {
5134
- const result = parseHTMLFile(filename);
5139
+ function loadHTML(story, filename, opts, diagnostics) {
5140
+ const result = parseHTMLFile(filename, opts);
5135
5141
  diagnostics.push(...result.diagnostics);
5136
5142
  for (const p of result.passages) storyAdd(story, p, diagnostics);
5137
5143
  }
@@ -5170,7 +5176,8 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5170
5176
  const optionsKey = parseOptionsKey(filename, opts);
5171
5177
  const entry = cache.get(filename);
5172
5178
  const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
5173
- if (changedFiles && cached && !changedFiles.has(filename)) {
5179
+ const changed = changedFiles?.has(filename) ?? false;
5180
+ if (changedFiles && cached && !changed) {
5174
5181
  diagnostics.push(...cached.diagnostics);
5175
5182
  for (const p of cached.passages) storyAdd(story, p, diagnostics);
5176
5183
  processedFiles.add(filename);
@@ -5182,7 +5189,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5182
5189
  } catch {
5183
5190
  continue;
5184
5191
  }
5185
- if (cached && cached.mtimeMs === mtimeMs) {
5192
+ if (cached && !changed && cached.mtimeMs === mtimeMs) {
5186
5193
  diagnostics.push(...cached.diagnostics);
5187
5194
  for (const p of cached.passages) storyAdd(story, p, diagnostics);
5188
5195
  processedFiles.add(filename);
@@ -5430,10 +5437,39 @@ function tryReplaceComponent(template, placeholder, componentPath, required) {
5430
5437
  }
5431
5438
  //#endregion
5432
5439
  //#region src/output-twee.ts
5433
- function toTwee(story, outMode) {
5434
- let data = "";
5435
- for (const p of story.passages) data += passageToTwee(p, outMode);
5436
- return data;
5440
+ /**
5441
+ * Serialize the story as Twee. The StoryData passage is written from the story model rather than
5442
+ * from its loaded text, so changes made after loading (a start passage override, test mode) are kept.
5443
+ * Tweego writes the loaded text; its own normalization makes the two the same for an unchanged story.
5444
+ *
5445
+ * `addStoryData`: add a StoryData passage when the story has none, so that StoryData fields the
5446
+ * compile options set are recorded. Default: false.
5447
+ */
5448
+ function toTwee(story, outMode, options = {}) {
5449
+ return withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => passageToTwee(p, outMode)).join("");
5450
+ }
5451
+ function withEffectiveStoryData(story, addStoryData) {
5452
+ const storyData = {
5453
+ name: "StoryData",
5454
+ tags: [],
5455
+ text: marshalStoryData(story)
5456
+ };
5457
+ const index = story.passages.findIndex((p) => p.name === "StoryData");
5458
+ if (index !== -1) return story.passages.map((p, i) => i === index && isStoryDataJSON(p.text) ? {
5459
+ ...p,
5460
+ text: storyData.text
5461
+ } : p);
5462
+ if (!addStoryData) return story.passages;
5463
+ const at = story.passages[0]?.name === "StoryTitle" ? 1 : 0;
5464
+ return [
5465
+ ...story.passages.slice(0, at),
5466
+ storyData,
5467
+ ...story.passages.slice(at)
5468
+ ];
5469
+ }
5470
+ /** Whether the text loaded as StoryData. Text that did not is kept as written, for the author to fix. */
5471
+ function isStoryDataJSON(text) {
5472
+ return unmarshalStoryData(createStory(), text) === null;
5437
5473
  }
5438
5474
  //#endregion
5439
5475
  //#region src/modules.ts
@@ -5505,7 +5541,19 @@ function modifyHead(html, modulePaths, headFile, diagnostics) {
5505
5541
  return html;
5506
5542
  }
5507
5543
  //#endregion
5508
- //#region src/start-passage.ts
5544
+ //#region src/passage-omission.ts
5545
+ /**
5546
+ * Why the `target` output leaves `passage` out of its passage elements, or `undefined` when it
5547
+ * emits the passage (and so a link or the start can name it).
5548
+ */
5549
+ function passageOmission(story, passage, target) {
5550
+ switch (target) {
5551
+ case "twine2": return twine2PassageOmission(story, passage);
5552
+ case "twine1": return twine1PassageOmission(passage);
5553
+ default: throw new Error(`unhandled passage output target: ${String(target)}`);
5554
+ }
5555
+ }
5556
+ /** Why a passage is left out, as a predicate: `Passage "X" ${describeOmission(o)}`. */
5509
5557
  function describeOmission(omission) {
5510
5558
  switch (omission.kind) {
5511
5559
  case "special-name": return `is the special "${omission.name}" passage`;
@@ -5514,13 +5562,8 @@ function describeOmission(omission) {
5514
5562
  default: throw new Error(`unhandled passage omission: ${JSON.stringify(omission)}`);
5515
5563
  }
5516
5564
  }
5517
- function omissionFor(story, passage, target) {
5518
- switch (target) {
5519
- case "twine2": return twine2PassageOmission(story, passage);
5520
- case "twine1": return twine1PassageOmission(passage);
5521
- default: throw new Error(`unhandled start passage target: ${String(target)}`);
5522
- }
5523
- }
5565
+ //#endregion
5566
+ //#region src/start-passage.ts
5524
5567
  /**
5525
5568
  * Check that `startName` names a passage the `target` output emits.
5526
5569
  * Returns the error diagnostics to report (empty when the start is usable).
@@ -5531,7 +5574,7 @@ function startPassageDiagnostics(story, startName, target) {
5531
5574
  level: "error",
5532
5575
  message: `Starting passage "${startName}" not found.`
5533
5576
  }];
5534
- const omission = omissionFor(story, passage, target);
5577
+ const omission = passageOmission(story, passage, target);
5535
5578
  if (omission === void 0) return [];
5536
5579
  return [{
5537
5580
  level: "error",
@@ -5566,21 +5609,48 @@ async function compile(options) {
5566
5609
  * Compile and write to a file.
5567
5610
  */
5568
5611
  async function compileToFile(options) {
5569
- const result = await buildOutput(options, void 0, void 0, options.outFile);
5612
+ const result = await compileForOutputFile(options, options.outFile);
5570
5613
  (0, node_fs.writeFileSync)(options.outFile, result.output, "utf-8");
5571
5614
  return result;
5572
5615
  }
5573
5616
  /**
5617
+ * Builds as compileToFile() does for `outFile`, without writing it: the output may sit
5618
+ * inside a source folder, so its last build is left out of the sources and modules and
5619
+ * never read back. The caller decides whether the result is written. With no `outFile`
5620
+ * (output to stdout, or none), this is compile().
5621
+ *
5622
+ * Internal, for the CLI; not part of the public API.
5623
+ */
5624
+ async function compileForOutputFile(options, outFile) {
5625
+ return buildOutput(options, void 0, void 0, outFile);
5626
+ }
5627
+ /**
5574
5628
  * Compile with incremental caching support.
5575
5629
  * Plugins and advanced users can manage their own cache and changed-file tracking.
5630
+ *
5631
+ * Without `changedFiles`, a cached file is reused while its modification time is unchanged.
5632
+ * With it, a file it names (by the path source discovery gives it, relative to the working
5633
+ * directory) is always reparsed, whatever its modification time, and every other cached
5634
+ * file is reused as it is.
5576
5635
  */
5577
5636
  async function compileIncremental(options, cache, changedFiles) {
5578
5637
  return buildOutput(options, cache, changedFiles);
5579
5638
  }
5580
5639
  /**
5581
- * Watch for file changes and recompile.
5640
+ * Watch for file changes and recompile. Every build is written to `outFile`, including
5641
+ * one whose diagnostics report errors.
5582
5642
  */
5583
5643
  async function watch(options) {
5644
+ return watchWithWriteFilter(options, () => true);
5645
+ }
5646
+ /**
5647
+ * watch(), writing to `outFile` only the builds `shouldWrite` accepts. A rejected build
5648
+ * leaves the output file as it was; `onBuild` receives every build either way.
5649
+ *
5650
+ * Internal, for the CLI, which keeps the last good output when a rebuild reports errors;
5651
+ * not part of the public API.
5652
+ */
5653
+ async function watchWithWriteFilter(options, shouldWrite) {
5584
5654
  const controller = new AbortController();
5585
5655
  const cache = /* @__PURE__ */ new Map();
5586
5656
  const filePaths = options.sources.filter((s) => typeof s === "string");
@@ -5600,7 +5670,7 @@ async function watch(options) {
5600
5670
  };
5601
5671
  const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
5602
5672
  buildOutput(options, cache, changedFiles, options.outFile).then((result) => {
5603
- (0, node_fs.writeFileSync)(options.outFile, result.output, "utf-8");
5673
+ if (shouldWrite(result)) (0, node_fs.writeFileSync)(options.outFile, result.output, "utf-8");
5604
5674
  options.onBuild?.(result);
5605
5675
  }).catch((e) => {
5606
5676
  options.onError?.(e instanceof Error ? e : new Error(String(e)));
@@ -5656,7 +5726,7 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5656
5726
  switch (outputMode) {
5657
5727
  case "twee3":
5658
5728
  case "twee1":
5659
- output = toTwee(story, outputMode);
5729
+ output = toTwee(story, outputMode, { addStoryData: Boolean(options.startPassage) || testMode });
5660
5730
  break;
5661
5731
  case "twine2-archive":
5662
5732
  output = toTwine2Archive(story, startName, { sourceInfo });
@@ -6099,6 +6169,11 @@ function endOfRegularExpression(source, pos) {
6099
6169
  }
6100
6170
  //#endregion
6101
6171
  //#region src/link-markup.ts
6172
+ /** Records that the reading has looked at the characters before `pos`, and returns `result`. */
6173
+ function readTo(reader, pos, result) {
6174
+ reader.scanned = Math.max(reader.scanned, pos);
6175
+ return result;
6176
+ }
6102
6177
  /**
6103
6178
  * Reads link or image markup that starts at `start` in `text`, or returns `undefined` where
6104
6179
  * SugarCube rejects it (and so prints the text instead).
@@ -6106,15 +6181,17 @@ function endOfRegularExpression(source, pos) {
6106
6181
  * `budget.left`, if given, is how many characters the reading may look at; it is reduced by as
6107
6182
  * many as were read, and once it runs out, every reading fails. Unclosed markup is read to the end
6108
6183
  * of its line, so reading every `[[` of a long line that never closes one would otherwise take
6109
- * quadratic time.
6184
+ * quadratic time. A reading is charged for the characters it looked at, up to where it ended or
6185
+ * failed (a line feed, say), and no more, so markup left unclosed on short lines costs little.
6110
6186
  */
6111
6187
  function readSquareBracketedMarkup(text, start, budget = { left: Infinity }) {
6112
6188
  const reader = {
6113
6189
  text,
6114
- limit: Math.min(text.length, start + Math.max(budget.left, 0))
6190
+ limit: Math.min(text.length, start + Math.max(budget.left, 0)),
6191
+ scanned: start
6115
6192
  };
6116
6193
  const markup = readMarkup(reader, start);
6117
- budget.left -= (markup?.end ?? reader.limit) - start;
6194
+ budget.left -= Math.max(reader.scanned, markup?.end ?? start) - start;
6118
6195
  return markup;
6119
6196
  }
6120
6197
  function readMarkup(reader, start) {
@@ -6190,7 +6267,7 @@ function readCoreComponents(reader, from) {
6190
6267
  const ch = text[pos];
6191
6268
  pos += 1;
6192
6269
  switch (ch) {
6193
- case "\n": return;
6270
+ case "\n": return readTo(reader, pos, void 0);
6194
6271
  case "\"": {
6195
6272
  const end = readQuoted(reader, pos, "\"");
6196
6273
  if (end === void 0) return;
@@ -6224,7 +6301,7 @@ function readCoreComponents(reader, from) {
6224
6301
  case "]":
6225
6302
  depth -= 1;
6226
6303
  if (depth === 1) {
6227
- const end = componentEnd(text, pos);
6304
+ const end = componentEnd(reader, pos);
6228
6305
  if (end === void 0) return;
6229
6306
  return {
6230
6307
  link: link ?? text.slice(componentStart, end.textEnd),
@@ -6233,6 +6310,7 @@ function readCoreComponents(reader, from) {
6233
6310
  }
6234
6311
  }
6235
6312
  }
6313
+ return readTo(reader, pos, void 0);
6236
6314
  }
6237
6315
  /**
6238
6316
  * Reads a component after `][` that holds no delimiter: an image's link (`setter` false) or a
@@ -6246,7 +6324,7 @@ function readComponent(reader, from, setter) {
6246
6324
  const ch = text[pos];
6247
6325
  pos += 1;
6248
6326
  switch (ch) {
6249
- case "\n": return;
6327
+ case "\n": return readTo(reader, pos, void 0);
6250
6328
  case "\"":
6251
6329
  case "'": {
6252
6330
  if (ch === "'" && !setter) break;
@@ -6260,9 +6338,10 @@ function readComponent(reader, from, setter) {
6260
6338
  break;
6261
6339
  case "]":
6262
6340
  depth -= 1;
6263
- if (depth === 1) return componentEnd(text, pos);
6341
+ if (depth === 1) return componentEnd(reader, pos);
6264
6342
  }
6265
6343
  }
6344
+ return readTo(reader, pos, void 0);
6266
6345
  }
6267
6346
  /** Reads a setter, the last component; returns the index after its closing `]]`. */
6268
6347
  function readSetter(reader, from) {
@@ -6270,19 +6349,19 @@ function readSetter(reader, from) {
6270
6349
  return end?.last === true ? end.next : void 0;
6271
6350
  }
6272
6351
  /** What the `]` just before `pos`, which brings the depth back to one, ends; `undefined` if malformed. */
6273
- function componentEnd(text, pos) {
6274
- switch (text[pos]) {
6275
- case "[": return {
6352
+ function componentEnd(reader, pos) {
6353
+ switch (reader.text[pos]) {
6354
+ case "[": return readTo(reader, pos + 1, {
6276
6355
  textEnd: pos - 1,
6277
6356
  next: pos + 1,
6278
6357
  last: false
6279
- };
6280
- case "]": return {
6358
+ });
6359
+ case "]": return readTo(reader, pos + 1, {
6281
6360
  textEnd: pos - 1,
6282
6361
  next: pos + 1,
6283
6362
  last: true
6284
- };
6285
- default: return;
6363
+ });
6364
+ default: return readTo(reader, pos + 1, void 0);
6286
6365
  }
6287
6366
  }
6288
6367
  /**
@@ -6297,12 +6376,13 @@ function readQuoted(reader, from, quote) {
6297
6376
  const ch = text[pos];
6298
6377
  pos += 1;
6299
6378
  if (ch === quote) return pos;
6300
- if (ch === "\n") return;
6379
+ if (ch === "\n") return readTo(reader, pos, void 0);
6301
6380
  if (ch === "\\") {
6302
- if (pos >= reader.limit || text[pos] === "\n") return;
6381
+ if (pos >= reader.limit || text[pos] === "\n") return readTo(reader, Math.min(pos + 1, reader.limit), void 0);
6303
6382
  pos += 1;
6304
6383
  }
6305
6384
  }
6385
+ return readTo(reader, pos, void 0);
6306
6386
  }
6307
6387
  //#endregion
6308
6388
  //#region src/sugarcube-macros.ts
@@ -7160,6 +7240,29 @@ function extractLinksFromText(text, isScript) {
7160
7240
  ].flatMap((via) => found.filter((link) => link.via === via).map((link) => link.passage));
7161
7241
  return [...new Set(ordered)];
7162
7242
  }
7243
+ /**
7244
+ * The passages that `p` links to. A script passage is JavaScript, read for links only in its
7245
+ * strings. A stylesheet is CSS, which the story puts in a style element and never wikifies, so it
7246
+ * links nowhere; a passage tagged both is a script, as Twine 2 output takes it.
7247
+ */
7248
+ function passageLinks(p) {
7249
+ if (hasTag(p, "script")) return extractLinksFromText(textAsRead(p), true);
7250
+ if (hasTag(p, "stylesheet")) return [];
7251
+ return extractLinksFromText(textAsRead(p), false);
7252
+ }
7253
+ /**
7254
+ * Each passage name, mapped to why the `target` output leaves that passage out, or to `undefined`
7255
+ * when a link can reach it: always without a target, and otherwise when the output emits a
7256
+ * passage of that name.
7257
+ */
7258
+ function destinationOmissions(story, { target }) {
7259
+ const omissions = /* @__PURE__ */ new Map();
7260
+ for (const p of story.passages) {
7261
+ const omission = target === void 0 ? void 0 : passageOmission(story, p, target);
7262
+ if (!omissions.has(p.name) || omission === void 0) omissions.set(p.name, omission);
7263
+ }
7264
+ return omissions;
7265
+ }
7163
7266
  /** Passages SugarCube runs from their raw text, never joining their lines. */
7164
7267
  const RAW_TEXT_PASSAGES = /* @__PURE__ */ new Set([
7165
7268
  "StoryInit",
@@ -7201,9 +7304,14 @@ function joinLines(text) {
7201
7304
  * expect(map.brokenLinks).toEqual([]);
7202
7305
  * expect(map.deadEnds).not.toContain('Start');
7203
7306
  * ```
7307
+ *
7308
+ * By default, a link is broken only when no passage has its name. Pass a `target` to check links
7309
+ * against what that output emits: `storyInspect(result.story, { target: 'twine2' })` also reports
7310
+ * links to passages Twine 2 output leaves out, such as script, stylesheet and `Twine.private`
7311
+ * passages.
7204
7312
  */
7205
- function storyInspect(story) {
7206
- const allNames = new Set(story.passages.map((p) => p.name));
7313
+ function storyInspect(story, options = {}) {
7314
+ const destinations = destinationOmissions(story, options);
7207
7315
  const passages = [];
7208
7316
  const storyPassages = [];
7209
7317
  const infoPassages = [];
@@ -7225,14 +7333,24 @@ function storyInspect(story) {
7225
7333
  }
7226
7334
  list.push(p.name);
7227
7335
  }
7228
- const targets = extractLinksFromText(textAsRead(p), hasTag(p, "script"));
7229
- links.set(p.name, targets);
7336
+ links.set(p.name, passageLinks(p));
7230
7337
  }
7231
7338
  const brokenLinks = [];
7232
- for (const [from, targets] of links) for (const to of targets) if (!allNames.has(to)) brokenLinks.push({
7233
- from,
7234
- to
7235
- });
7339
+ for (const [from, targets] of links) for (const to of targets) {
7340
+ if (!destinations.has(to)) {
7341
+ brokenLinks.push({
7342
+ from,
7343
+ to
7344
+ });
7345
+ continue;
7346
+ }
7347
+ const omission = destinations.get(to);
7348
+ if (omission !== void 0) brokenLinks.push({
7349
+ from,
7350
+ to,
7351
+ omission
7352
+ });
7353
+ }
7236
7354
  const deadEnds = [];
7237
7355
  for (const name of storyPassages) {
7238
7356
  const targets = links.get(name);
@@ -7262,14 +7380,26 @@ function storyInspect(story) {
7262
7380
  /**
7263
7381
  * Lint a story: compile without output rendering, then inspect structure.
7264
7382
  * Uses JSON output mode internally to avoid format resolution, so it checks
7265
- * the starting passage itself, against the Twine 2 passage rules.
7383
+ * the starting passage itself, against the Twine 2 passage rules. Link
7384
+ * destinations are checked against the same rules: a link to a passage that
7385
+ * Twine 2 output leaves out is broken.
7266
7386
  */
7267
7387
  async function lint(options) {
7268
- const result = await compile({
7388
+ return lintForOutputFile(options, void 0);
7389
+ }
7390
+ /**
7391
+ * lint() for a project whose builds are written to `outFile`: like a build for that file,
7392
+ * it leaves `outFile` out of the sources and modules, so an earlier build inside a source
7393
+ * folder is not linted as a source.
7394
+ *
7395
+ * Internal, for the CLI; not part of the public API.
7396
+ */
7397
+ async function lintForOutputFile(options, outFile) {
7398
+ const result = await compileForOutputFile({
7269
7399
  ...options,
7270
7400
  outputMode: "json"
7271
- });
7272
- const map = storyInspect(result.story);
7401
+ }, outFile);
7402
+ const map = storyInspect(result.story, { target: "twine2" });
7273
7403
  return {
7274
7404
  diagnostics: [...result.diagnostics, ...startPassageDiagnostics(result.story, map.start, "twine2")],
7275
7405
  stats: result.stats,
@@ -7296,7 +7426,10 @@ function formatLintReport(result) {
7296
7426
  if (result.brokenLinks.length > 0) {
7297
7427
  lines.push("");
7298
7428
  lines.push(`Broken links (${result.brokenLinks.length}):`);
7299
- for (const link of result.brokenLinks) lines.push(` ${link.from} -> ${link.to} (passage "${link.to}" does not exist)`);
7429
+ for (const link of result.brokenLinks) {
7430
+ const why = link.omission === void 0 ? "does not exist" : `${describeOmission(link.omission)}, so it is left out of the story data`;
7431
+ lines.push(` ${link.from} -> ${link.to} (passage "${link.to}" ${why})`);
7432
+ }
7300
7433
  }
7301
7434
  if (result.deadEnds.length > 0) {
7302
7435
  lines.push("");