@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.js CHANGED
@@ -4696,14 +4696,16 @@ function parseDocument(data, options) {
4696
4696
  */
4697
4697
  /**
4698
4698
  * Parse a Twine 2 or Twine 1 compiled HTML file back into a Story model.
4699
+ * Tweego always trims passage text; here `trim: false` keeps it exactly, as for Twee sources.
4699
4700
  */
4700
- function decompileHTML(html) {
4701
+ function decompileHTML(html, options = {}) {
4701
4702
  const diagnostics = [];
4702
4703
  const story = createStory();
4704
+ const passageText = options.trim === false ? (text) => text : (text) => text.trim();
4703
4705
  const doc = parseDocument(html);
4704
4706
  const twine2Data = findElement(doc, "tw-storydata");
4705
4707
  if (twine2Data) {
4706
- decompileTwine2(twine2Data, story, diagnostics);
4708
+ decompileTwine2(twine2Data, story, passageText, diagnostics);
4707
4709
  return {
4708
4710
  story,
4709
4711
  diagnostics
@@ -4711,7 +4713,7 @@ function decompileHTML(html) {
4711
4713
  }
4712
4714
  const twine1Data = findElementByIdPattern(doc, /^store(?:-a|A)rea$/);
4713
4715
  if (twine1Data) {
4714
- decompileTwine1(twine1Data, story, diagnostics);
4716
+ decompileTwine1(twine1Data, story, passageText, diagnostics);
4715
4717
  return {
4716
4718
  story,
4717
4719
  diagnostics
@@ -4726,7 +4728,7 @@ function decompileHTML(html) {
4726
4728
  diagnostics
4727
4729
  };
4728
4730
  }
4729
- function decompileTwine2(storyData, story, diagnostics) {
4731
+ function decompileTwine2(storyData, story, passageText, diagnostics) {
4730
4732
  let startnode = 0;
4731
4733
  const attrs = storyData.attribs ?? {};
4732
4734
  if (attrs["name"]) story.name = attrs["name"];
@@ -4756,8 +4758,9 @@ function decompileTwine2(storyData, story, diagnostics) {
4756
4758
  switch (node.name) {
4757
4759
  case "style":
4758
4760
  case "script": {
4759
- const text = getTextContent(node).trim();
4760
- if (text.length === 0) continue;
4761
+ const content = getTextContent(node);
4762
+ if (content.trim().length === 0) continue;
4763
+ const text = passageText(content);
4761
4764
  storyAdd(story, {
4762
4765
  name: node.name === "style" ? "Story Stylesheet" : "Story JavaScript",
4763
4766
  tags: node.name === "style" ? ["stylesheet"] : ["script"],
@@ -4792,7 +4795,7 @@ function decompileTwine2(storyData, story, diagnostics) {
4792
4795
  const passage = {
4793
4796
  name,
4794
4797
  tags,
4795
- text: getTextContent(node).trim()
4798
+ text: passageText(getTextContent(node))
4796
4799
  };
4797
4800
  if (metadata.position || metadata.size) passage.metadata = metadata;
4798
4801
  storyAdd(story, passage, diagnostics);
@@ -4806,8 +4809,8 @@ function decompileTwine2(storyData, story, diagnostics) {
4806
4809
  text: marshalStoryData(story)
4807
4810
  }, diagnostics);
4808
4811
  }
4809
- function decompileTwine1(storeArea, story, diagnostics) {
4810
- const passages = (storeArea.children ?? []).filter(isTiddler).map(tiddlerToPassage);
4812
+ function decompileTwine1(storeArea, story, passageText, diagnostics) {
4813
+ const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
4811
4814
  const obfuscated = isRot13Obfuscated(passages);
4812
4815
  for (const p of passages) storyAdd(story, obfuscated && p.name !== "StorySettings" ? {
4813
4816
  ...p,
@@ -4817,12 +4820,12 @@ function decompileTwine1(storeArea, story, diagnostics) {
4817
4820
  function isTiddler(node) {
4818
4821
  return node.type === "tag" && node.name === "div" && node.attribs !== void 0 && "tiddler" in node.attribs;
4819
4822
  }
4820
- function tiddlerToPassage(node) {
4823
+ function tiddlerToPassage(node, passageText) {
4821
4824
  const nodeAttrs = node.attribs ?? {};
4822
4825
  const passage = {
4823
4826
  name: nodeAttrs["tiddler"] ?? "",
4824
4827
  tags: nodeAttrs["tags"] ? nodeAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [],
4825
- text: tiddlerUnescape(getTextContent(node)).trim()
4828
+ text: passageText(tiddlerUnescape(getTextContent(node)))
4826
4829
  };
4827
4830
  const position = nodeAttrs["twine-position"];
4828
4831
  if (position) passage.metadata = { position };
@@ -4887,7 +4890,7 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
4887
4890
  break;
4888
4891
  case "htm":
4889
4892
  case "html":
4890
- loadHTML(story, filename, diagnostics);
4893
+ loadHTML(story, filename, opts, diagnostics);
4891
4894
  break;
4892
4895
  case "css":
4893
4896
  loadTagged(story, "stylesheet", filename, diagnostics);
@@ -5041,8 +5044,8 @@ function parseFontFile(filename) {
5041
5044
  diagnostics: []
5042
5045
  };
5043
5046
  }
5044
- function parseHTMLFile(filename) {
5045
- const { story, diagnostics } = decompileHTML(readUTF8(filename));
5047
+ function parseHTMLFile(filename, opts) {
5048
+ const { story, diagnostics } = decompileHTML(readUTF8(filename), { trim: opts.trim ?? true });
5046
5049
  return {
5047
5050
  passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
5048
5051
  name: "StoryTitle",
@@ -5054,14 +5057,17 @@ function parseHTMLFile(filename) {
5054
5057
  }
5055
5058
  /**
5056
5059
  * 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.
5060
+ * Twee files depend on `trim` and `twee2Compat`, HTML files on `trim`; every other file type
5061
+ * yields the same key for all options.
5058
5062
  */
5059
5063
  function parseOptionsKey(filename, opts) {
5060
5064
  const ext = normalizedFileExt(filename);
5065
+ const trim = opts.trim ?? true;
5066
+ if (ext === "htm" || ext === "html") return JSON.stringify({ trim });
5061
5067
  const twee2File = ext === "tw2" || ext === "twee2";
5062
5068
  if (!twee2File && ext !== "tw" && ext !== "twee") return "";
5063
5069
  return JSON.stringify({
5064
- trim: opts.trim ?? true,
5070
+ trim,
5065
5071
  twee2Compat: twee2File || (opts.twee2Compat ?? false)
5066
5072
  });
5067
5073
  }
@@ -5075,7 +5081,7 @@ function parseFile(filename, opts) {
5075
5081
  twee2Compat: true
5076
5082
  });
5077
5083
  case "htm":
5078
- case "html": return parseHTMLFile(filename);
5084
+ case "html": return parseHTMLFile(filename, opts);
5079
5085
  case "css": return parseTaggedFile("stylesheet", filename);
5080
5086
  case "js": return parseTaggedFile("script", filename);
5081
5087
  case "otf":
@@ -5107,8 +5113,8 @@ function parseFile(filename, opts) {
5107
5113
  default: return;
5108
5114
  }
5109
5115
  }
5110
- function loadHTML(story, filename, diagnostics) {
5111
- const result = parseHTMLFile(filename);
5116
+ function loadHTML(story, filename, opts, diagnostics) {
5117
+ const result = parseHTMLFile(filename, opts);
5112
5118
  diagnostics.push(...result.diagnostics);
5113
5119
  for (const p of result.passages) storyAdd(story, p, diagnostics);
5114
5120
  }
@@ -5147,7 +5153,8 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5147
5153
  const optionsKey = parseOptionsKey(filename, opts);
5148
5154
  const entry = cache.get(filename);
5149
5155
  const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
5150
- if (changedFiles && cached && !changedFiles.has(filename)) {
5156
+ const changed = changedFiles?.has(filename) ?? false;
5157
+ if (changedFiles && cached && !changed) {
5151
5158
  diagnostics.push(...cached.diagnostics);
5152
5159
  for (const p of cached.passages) storyAdd(story, p, diagnostics);
5153
5160
  processedFiles.add(filename);
@@ -5159,7 +5166,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5159
5166
  } catch {
5160
5167
  continue;
5161
5168
  }
5162
- if (cached && cached.mtimeMs === mtimeMs) {
5169
+ if (cached && !changed && cached.mtimeMs === mtimeMs) {
5163
5170
  diagnostics.push(...cached.diagnostics);
5164
5171
  for (const p of cached.passages) storyAdd(story, p, diagnostics);
5165
5172
  processedFiles.add(filename);
@@ -5407,10 +5414,39 @@ function tryReplaceComponent(template, placeholder, componentPath, required) {
5407
5414
  }
5408
5415
  //#endregion
5409
5416
  //#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;
5417
+ /**
5418
+ * Serialize the story as Twee. The StoryData passage is written from the story model rather than
5419
+ * from its loaded text, so changes made after loading (a start passage override, test mode) are kept.
5420
+ * Tweego writes the loaded text; its own normalization makes the two the same for an unchanged story.
5421
+ *
5422
+ * `addStoryData`: add a StoryData passage when the story has none, so that StoryData fields the
5423
+ * compile options set are recorded. Default: false.
5424
+ */
5425
+ function toTwee(story, outMode, options = {}) {
5426
+ return withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => passageToTwee(p, outMode)).join("");
5427
+ }
5428
+ function withEffectiveStoryData(story, addStoryData) {
5429
+ const storyData = {
5430
+ name: "StoryData",
5431
+ tags: [],
5432
+ text: marshalStoryData(story)
5433
+ };
5434
+ const index = story.passages.findIndex((p) => p.name === "StoryData");
5435
+ if (index !== -1) return story.passages.map((p, i) => i === index && isStoryDataJSON(p.text) ? {
5436
+ ...p,
5437
+ text: storyData.text
5438
+ } : p);
5439
+ if (!addStoryData) return story.passages;
5440
+ const at = story.passages[0]?.name === "StoryTitle" ? 1 : 0;
5441
+ return [
5442
+ ...story.passages.slice(0, at),
5443
+ storyData,
5444
+ ...story.passages.slice(at)
5445
+ ];
5446
+ }
5447
+ /** Whether the text loaded as StoryData. Text that did not is kept as written, for the author to fix. */
5448
+ function isStoryDataJSON(text) {
5449
+ return unmarshalStoryData(createStory(), text) === null;
5414
5450
  }
5415
5451
  //#endregion
5416
5452
  //#region src/modules.ts
@@ -5482,7 +5518,19 @@ function modifyHead(html, modulePaths, headFile, diagnostics) {
5482
5518
  return html;
5483
5519
  }
5484
5520
  //#endregion
5485
- //#region src/start-passage.ts
5521
+ //#region src/passage-omission.ts
5522
+ /**
5523
+ * Why the `target` output leaves `passage` out of its passage elements, or `undefined` when it
5524
+ * emits the passage (and so a link or the start can name it).
5525
+ */
5526
+ function passageOmission(story, passage, target) {
5527
+ switch (target) {
5528
+ case "twine2": return twine2PassageOmission(story, passage);
5529
+ case "twine1": return twine1PassageOmission(passage);
5530
+ default: throw new Error(`unhandled passage output target: ${String(target)}`);
5531
+ }
5532
+ }
5533
+ /** Why a passage is left out, as a predicate: `Passage "X" ${describeOmission(o)}`. */
5486
5534
  function describeOmission(omission) {
5487
5535
  switch (omission.kind) {
5488
5536
  case "special-name": return `is the special "${omission.name}" passage`;
@@ -5491,13 +5539,8 @@ function describeOmission(omission) {
5491
5539
  default: throw new Error(`unhandled passage omission: ${JSON.stringify(omission)}`);
5492
5540
  }
5493
5541
  }
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
- }
5542
+ //#endregion
5543
+ //#region src/start-passage.ts
5501
5544
  /**
5502
5545
  * Check that `startName` names a passage the `target` output emits.
5503
5546
  * Returns the error diagnostics to report (empty when the start is usable).
@@ -5508,7 +5551,7 @@ function startPassageDiagnostics(story, startName, target) {
5508
5551
  level: "error",
5509
5552
  message: `Starting passage "${startName}" not found.`
5510
5553
  }];
5511
- const omission = omissionFor(story, passage, target);
5554
+ const omission = passageOmission(story, passage, target);
5512
5555
  if (omission === void 0) return [];
5513
5556
  return [{
5514
5557
  level: "error",
@@ -5543,21 +5586,48 @@ async function compile(options) {
5543
5586
  * Compile and write to a file.
5544
5587
  */
5545
5588
  async function compileToFile(options) {
5546
- const result = await buildOutput(options, void 0, void 0, options.outFile);
5589
+ const result = await compileForOutputFile(options, options.outFile);
5547
5590
  writeFileSync(options.outFile, result.output, "utf-8");
5548
5591
  return result;
5549
5592
  }
5550
5593
  /**
5594
+ * Builds as compileToFile() does for `outFile`, without writing it: the output may sit
5595
+ * inside a source folder, so its last build is left out of the sources and modules and
5596
+ * never read back. The caller decides whether the result is written. With no `outFile`
5597
+ * (output to stdout, or none), this is compile().
5598
+ *
5599
+ * Internal, for the CLI; not part of the public API.
5600
+ */
5601
+ async function compileForOutputFile(options, outFile) {
5602
+ return buildOutput(options, void 0, void 0, outFile);
5603
+ }
5604
+ /**
5551
5605
  * Compile with incremental caching support.
5552
5606
  * Plugins and advanced users can manage their own cache and changed-file tracking.
5607
+ *
5608
+ * Without `changedFiles`, a cached file is reused while its modification time is unchanged.
5609
+ * With it, a file it names (by the path source discovery gives it, relative to the working
5610
+ * directory) is always reparsed, whatever its modification time, and every other cached
5611
+ * file is reused as it is.
5553
5612
  */
5554
5613
  async function compileIncremental(options, cache, changedFiles) {
5555
5614
  return buildOutput(options, cache, changedFiles);
5556
5615
  }
5557
5616
  /**
5558
- * Watch for file changes and recompile.
5617
+ * Watch for file changes and recompile. Every build is written to `outFile`, including
5618
+ * one whose diagnostics report errors.
5559
5619
  */
5560
5620
  async function watch(options) {
5621
+ return watchWithWriteFilter(options, () => true);
5622
+ }
5623
+ /**
5624
+ * watch(), writing to `outFile` only the builds `shouldWrite` accepts. A rejected build
5625
+ * leaves the output file as it was; `onBuild` receives every build either way.
5626
+ *
5627
+ * Internal, for the CLI, which keeps the last good output when a rebuild reports errors;
5628
+ * not part of the public API.
5629
+ */
5630
+ async function watchWithWriteFilter(options, shouldWrite) {
5561
5631
  const controller = new AbortController();
5562
5632
  const cache = /* @__PURE__ */ new Map();
5563
5633
  const filePaths = options.sources.filter((s) => typeof s === "string");
@@ -5577,7 +5647,7 @@ async function watch(options) {
5577
5647
  };
5578
5648
  const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
5579
5649
  buildOutput(options, cache, changedFiles, options.outFile).then((result) => {
5580
- writeFileSync(options.outFile, result.output, "utf-8");
5650
+ if (shouldWrite(result)) writeFileSync(options.outFile, result.output, "utf-8");
5581
5651
  options.onBuild?.(result);
5582
5652
  }).catch((e) => {
5583
5653
  options.onError?.(e instanceof Error ? e : new Error(String(e)));
@@ -5633,7 +5703,7 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5633
5703
  switch (outputMode) {
5634
5704
  case "twee3":
5635
5705
  case "twee1":
5636
- output = toTwee(story, outputMode);
5706
+ output = toTwee(story, outputMode, { addStoryData: Boolean(options.startPassage) || testMode });
5637
5707
  break;
5638
5708
  case "twine2-archive":
5639
5709
  output = toTwine2Archive(story, startName, { sourceInfo });
@@ -6076,6 +6146,11 @@ function endOfRegularExpression(source, pos) {
6076
6146
  }
6077
6147
  //#endregion
6078
6148
  //#region src/link-markup.ts
6149
+ /** Records that the reading has looked at the characters before `pos`, and returns `result`. */
6150
+ function readTo(reader, pos, result) {
6151
+ reader.scanned = Math.max(reader.scanned, pos);
6152
+ return result;
6153
+ }
6079
6154
  /**
6080
6155
  * Reads link or image markup that starts at `start` in `text`, or returns `undefined` where
6081
6156
  * SugarCube rejects it (and so prints the text instead).
@@ -6083,15 +6158,17 @@ function endOfRegularExpression(source, pos) {
6083
6158
  * `budget.left`, if given, is how many characters the reading may look at; it is reduced by as
6084
6159
  * many as were read, and once it runs out, every reading fails. Unclosed markup is read to the end
6085
6160
  * of its line, so reading every `[[` of a long line that never closes one would otherwise take
6086
- * quadratic time.
6161
+ * quadratic time. A reading is charged for the characters it looked at, up to where it ended or
6162
+ * failed (a line feed, say), and no more, so markup left unclosed on short lines costs little.
6087
6163
  */
6088
6164
  function readSquareBracketedMarkup(text, start, budget = { left: Infinity }) {
6089
6165
  const reader = {
6090
6166
  text,
6091
- limit: Math.min(text.length, start + Math.max(budget.left, 0))
6167
+ limit: Math.min(text.length, start + Math.max(budget.left, 0)),
6168
+ scanned: start
6092
6169
  };
6093
6170
  const markup = readMarkup(reader, start);
6094
- budget.left -= (markup?.end ?? reader.limit) - start;
6171
+ budget.left -= Math.max(reader.scanned, markup?.end ?? start) - start;
6095
6172
  return markup;
6096
6173
  }
6097
6174
  function readMarkup(reader, start) {
@@ -6167,7 +6244,7 @@ function readCoreComponents(reader, from) {
6167
6244
  const ch = text[pos];
6168
6245
  pos += 1;
6169
6246
  switch (ch) {
6170
- case "\n": return;
6247
+ case "\n": return readTo(reader, pos, void 0);
6171
6248
  case "\"": {
6172
6249
  const end = readQuoted(reader, pos, "\"");
6173
6250
  if (end === void 0) return;
@@ -6201,7 +6278,7 @@ function readCoreComponents(reader, from) {
6201
6278
  case "]":
6202
6279
  depth -= 1;
6203
6280
  if (depth === 1) {
6204
- const end = componentEnd(text, pos);
6281
+ const end = componentEnd(reader, pos);
6205
6282
  if (end === void 0) return;
6206
6283
  return {
6207
6284
  link: link ?? text.slice(componentStart, end.textEnd),
@@ -6210,6 +6287,7 @@ function readCoreComponents(reader, from) {
6210
6287
  }
6211
6288
  }
6212
6289
  }
6290
+ return readTo(reader, pos, void 0);
6213
6291
  }
6214
6292
  /**
6215
6293
  * Reads a component after `][` that holds no delimiter: an image's link (`setter` false) or a
@@ -6223,7 +6301,7 @@ function readComponent(reader, from, setter) {
6223
6301
  const ch = text[pos];
6224
6302
  pos += 1;
6225
6303
  switch (ch) {
6226
- case "\n": return;
6304
+ case "\n": return readTo(reader, pos, void 0);
6227
6305
  case "\"":
6228
6306
  case "'": {
6229
6307
  if (ch === "'" && !setter) break;
@@ -6237,9 +6315,10 @@ function readComponent(reader, from, setter) {
6237
6315
  break;
6238
6316
  case "]":
6239
6317
  depth -= 1;
6240
- if (depth === 1) return componentEnd(text, pos);
6318
+ if (depth === 1) return componentEnd(reader, pos);
6241
6319
  }
6242
6320
  }
6321
+ return readTo(reader, pos, void 0);
6243
6322
  }
6244
6323
  /** Reads a setter, the last component; returns the index after its closing `]]`. */
6245
6324
  function readSetter(reader, from) {
@@ -6247,19 +6326,19 @@ function readSetter(reader, from) {
6247
6326
  return end?.last === true ? end.next : void 0;
6248
6327
  }
6249
6328
  /** 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 {
6329
+ function componentEnd(reader, pos) {
6330
+ switch (reader.text[pos]) {
6331
+ case "[": return readTo(reader, pos + 1, {
6253
6332
  textEnd: pos - 1,
6254
6333
  next: pos + 1,
6255
6334
  last: false
6256
- };
6257
- case "]": return {
6335
+ });
6336
+ case "]": return readTo(reader, pos + 1, {
6258
6337
  textEnd: pos - 1,
6259
6338
  next: pos + 1,
6260
6339
  last: true
6261
- };
6262
- default: return;
6340
+ });
6341
+ default: return readTo(reader, pos + 1, void 0);
6263
6342
  }
6264
6343
  }
6265
6344
  /**
@@ -6274,12 +6353,13 @@ function readQuoted(reader, from, quote) {
6274
6353
  const ch = text[pos];
6275
6354
  pos += 1;
6276
6355
  if (ch === quote) return pos;
6277
- if (ch === "\n") return;
6356
+ if (ch === "\n") return readTo(reader, pos, void 0);
6278
6357
  if (ch === "\\") {
6279
- if (pos >= reader.limit || text[pos] === "\n") return;
6358
+ if (pos >= reader.limit || text[pos] === "\n") return readTo(reader, Math.min(pos + 1, reader.limit), void 0);
6280
6359
  pos += 1;
6281
6360
  }
6282
6361
  }
6362
+ return readTo(reader, pos, void 0);
6283
6363
  }
6284
6364
  //#endregion
6285
6365
  //#region src/sugarcube-macros.ts
@@ -7137,6 +7217,29 @@ function extractLinksFromText(text, isScript) {
7137
7217
  ].flatMap((via) => found.filter((link) => link.via === via).map((link) => link.passage));
7138
7218
  return [...new Set(ordered)];
7139
7219
  }
7220
+ /**
7221
+ * The passages that `p` links to. A script passage is JavaScript, read for links only in its
7222
+ * strings. A stylesheet is CSS, which the story puts in a style element and never wikifies, so it
7223
+ * links nowhere; a passage tagged both is a script, as Twine 2 output takes it.
7224
+ */
7225
+ function passageLinks(p) {
7226
+ if (hasTag(p, "script")) return extractLinksFromText(textAsRead(p), true);
7227
+ if (hasTag(p, "stylesheet")) return [];
7228
+ return extractLinksFromText(textAsRead(p), false);
7229
+ }
7230
+ /**
7231
+ * Each passage name, mapped to why the `target` output leaves that passage out, or to `undefined`
7232
+ * when a link can reach it: always without a target, and otherwise when the output emits a
7233
+ * passage of that name.
7234
+ */
7235
+ function destinationOmissions(story, { target }) {
7236
+ const omissions = /* @__PURE__ */ new Map();
7237
+ for (const p of story.passages) {
7238
+ const omission = target === void 0 ? void 0 : passageOmission(story, p, target);
7239
+ if (!omissions.has(p.name) || omission === void 0) omissions.set(p.name, omission);
7240
+ }
7241
+ return omissions;
7242
+ }
7140
7243
  /** Passages SugarCube runs from their raw text, never joining their lines. */
7141
7244
  const RAW_TEXT_PASSAGES = /* @__PURE__ */ new Set([
7142
7245
  "StoryInit",
@@ -7178,9 +7281,14 @@ function joinLines(text) {
7178
7281
  * expect(map.brokenLinks).toEqual([]);
7179
7282
  * expect(map.deadEnds).not.toContain('Start');
7180
7283
  * ```
7284
+ *
7285
+ * By default, a link is broken only when no passage has its name. Pass a `target` to check links
7286
+ * against what that output emits: `storyInspect(result.story, { target: 'twine2' })` also reports
7287
+ * links to passages Twine 2 output leaves out, such as script, stylesheet and `Twine.private`
7288
+ * passages.
7181
7289
  */
7182
- function storyInspect(story) {
7183
- const allNames = new Set(story.passages.map((p) => p.name));
7290
+ function storyInspect(story, options = {}) {
7291
+ const destinations = destinationOmissions(story, options);
7184
7292
  const passages = [];
7185
7293
  const storyPassages = [];
7186
7294
  const infoPassages = [];
@@ -7202,14 +7310,24 @@ function storyInspect(story) {
7202
7310
  }
7203
7311
  list.push(p.name);
7204
7312
  }
7205
- const targets = extractLinksFromText(textAsRead(p), hasTag(p, "script"));
7206
- links.set(p.name, targets);
7313
+ links.set(p.name, passageLinks(p));
7207
7314
  }
7208
7315
  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
- });
7316
+ for (const [from, targets] of links) for (const to of targets) {
7317
+ if (!destinations.has(to)) {
7318
+ brokenLinks.push({
7319
+ from,
7320
+ to
7321
+ });
7322
+ continue;
7323
+ }
7324
+ const omission = destinations.get(to);
7325
+ if (omission !== void 0) brokenLinks.push({
7326
+ from,
7327
+ to,
7328
+ omission
7329
+ });
7330
+ }
7213
7331
  const deadEnds = [];
7214
7332
  for (const name of storyPassages) {
7215
7333
  const targets = links.get(name);
@@ -7239,14 +7357,26 @@ function storyInspect(story) {
7239
7357
  /**
7240
7358
  * Lint a story: compile without output rendering, then inspect structure.
7241
7359
  * Uses JSON output mode internally to avoid format resolution, so it checks
7242
- * the starting passage itself, against the Twine 2 passage rules.
7360
+ * the starting passage itself, against the Twine 2 passage rules. Link
7361
+ * destinations are checked against the same rules: a link to a passage that
7362
+ * Twine 2 output leaves out is broken.
7243
7363
  */
7244
7364
  async function lint(options) {
7245
- const result = await compile({
7365
+ return lintForOutputFile(options, void 0);
7366
+ }
7367
+ /**
7368
+ * lint() for a project whose builds are written to `outFile`: like a build for that file,
7369
+ * it leaves `outFile` out of the sources and modules, so an earlier build inside a source
7370
+ * folder is not linted as a source.
7371
+ *
7372
+ * Internal, for the CLI; not part of the public API.
7373
+ */
7374
+ async function lintForOutputFile(options, outFile) {
7375
+ const result = await compileForOutputFile({
7246
7376
  ...options,
7247
7377
  outputMode: "json"
7248
- });
7249
- const map = storyInspect(result.story);
7378
+ }, outFile);
7379
+ const map = storyInspect(result.story, { target: "twine2" });
7250
7380
  return {
7251
7381
  diagnostics: [...result.diagnostics, ...startPassageDiagnostics(result.story, map.start, "twine2")],
7252
7382
  stats: result.stats,
@@ -7273,7 +7403,10 @@ function formatLintReport(result) {
7273
7403
  if (result.brokenLinks.length > 0) {
7274
7404
  lines.push("");
7275
7405
  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)`);
7406
+ for (const link of result.brokenLinks) {
7407
+ const why = link.omission === void 0 ? "does not exist" : `${describeOmission(link.omission)}, so it is left out of the story data`;
7408
+ lines.push(` ${link.from} -> ${link.to} (passage "${link.to}" ${why})`);
7409
+ }
7277
7410
  }
7278
7411
  if (result.deadEnds.length > 0) {
7279
7412
  lines.push("");