@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/README.md CHANGED
@@ -64,7 +64,7 @@ export default {
64
64
  };
65
65
  ```
66
66
 
67
- Also available: `@rohal12/twee-ts/rollup`.
67
+ Also available: `@rohal12/twee-ts/rollup`. In both plugins, compile errors fail the build and emit no HTML; warnings go through the bundler's warnings.
68
68
 
69
69
  ## Documentation
70
70
 
@@ -4627,14 +4627,16 @@ function parseDocument(data, options) {
4627
4627
  */
4628
4628
  /**
4629
4629
  * Parse a Twine 2 or Twine 1 compiled HTML file back into a Story model.
4630
+ * Tweego always trims passage text; here `trim: false` keeps it exactly, as for Twee sources.
4630
4631
  */
4631
- function decompileHTML(html) {
4632
+ function decompileHTML(html, options = {}) {
4632
4633
  const diagnostics = [];
4633
4634
  const story = createStory();
4635
+ const passageText = options.trim === false ? (text) => text : (text) => text.trim();
4634
4636
  const doc = parseDocument(html);
4635
4637
  const twine2Data = findElement(doc, "tw-storydata");
4636
4638
  if (twine2Data) {
4637
- decompileTwine2(twine2Data, story, diagnostics);
4639
+ decompileTwine2(twine2Data, story, passageText, diagnostics);
4638
4640
  return {
4639
4641
  story,
4640
4642
  diagnostics
@@ -4642,7 +4644,7 @@ function decompileHTML(html) {
4642
4644
  }
4643
4645
  const twine1Data = findElementByIdPattern(doc, /^store(?:-a|A)rea$/);
4644
4646
  if (twine1Data) {
4645
- decompileTwine1(twine1Data, story, diagnostics);
4647
+ decompileTwine1(twine1Data, story, passageText, diagnostics);
4646
4648
  return {
4647
4649
  story,
4648
4650
  diagnostics
@@ -4657,7 +4659,7 @@ function decompileHTML(html) {
4657
4659
  diagnostics
4658
4660
  };
4659
4661
  }
4660
- function decompileTwine2(storyData, story, diagnostics) {
4662
+ function decompileTwine2(storyData, story, passageText, diagnostics) {
4661
4663
  let startnode = 0;
4662
4664
  const attrs = storyData.attribs ?? {};
4663
4665
  if (attrs["name"]) story.name = attrs["name"];
@@ -4687,8 +4689,9 @@ function decompileTwine2(storyData, story, diagnostics) {
4687
4689
  switch (node.name) {
4688
4690
  case "style":
4689
4691
  case "script": {
4690
- const text = getTextContent(node).trim();
4691
- if (text.length === 0) continue;
4692
+ const content = getTextContent(node);
4693
+ if (content.trim().length === 0) continue;
4694
+ const text = passageText(content);
4692
4695
  storyAdd(story, {
4693
4696
  name: node.name === "style" ? "Story Stylesheet" : "Story JavaScript",
4694
4697
  tags: node.name === "style" ? ["stylesheet"] : ["script"],
@@ -4723,7 +4726,7 @@ function decompileTwine2(storyData, story, diagnostics) {
4723
4726
  const passage = {
4724
4727
  name,
4725
4728
  tags,
4726
- text: getTextContent(node).trim()
4729
+ text: passageText(getTextContent(node))
4727
4730
  };
4728
4731
  if (metadata.position || metadata.size) passage.metadata = metadata;
4729
4732
  storyAdd(story, passage, diagnostics);
@@ -4737,8 +4740,8 @@ function decompileTwine2(storyData, story, diagnostics) {
4737
4740
  text: marshalStoryData(story)
4738
4741
  }, diagnostics);
4739
4742
  }
4740
- function decompileTwine1(storeArea, story, diagnostics) {
4741
- const passages = (storeArea.children ?? []).filter(isTiddler).map(tiddlerToPassage);
4743
+ function decompileTwine1(storeArea, story, passageText, diagnostics) {
4744
+ const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
4742
4745
  const obfuscated = isRot13Obfuscated(passages);
4743
4746
  for (const p of passages) storyAdd(story, obfuscated && p.name !== "StorySettings" ? {
4744
4747
  ...p,
@@ -4748,12 +4751,12 @@ function decompileTwine1(storeArea, story, diagnostics) {
4748
4751
  function isTiddler(node) {
4749
4752
  return node.type === "tag" && node.name === "div" && node.attribs !== void 0 && "tiddler" in node.attribs;
4750
4753
  }
4751
- function tiddlerToPassage(node) {
4754
+ function tiddlerToPassage(node, passageText) {
4752
4755
  const nodeAttrs = node.attribs ?? {};
4753
4756
  const passage = {
4754
4757
  name: nodeAttrs["tiddler"] ?? "",
4755
4758
  tags: nodeAttrs["tags"] ? nodeAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [],
4756
- text: tiddlerUnescape(getTextContent(node)).trim()
4759
+ text: passageText(tiddlerUnescape(getTextContent(node)))
4757
4760
  };
4758
4761
  const position = nodeAttrs["twine-position"];
4759
4762
  if (position) passage.metadata = { position };
@@ -4818,7 +4821,7 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
4818
4821
  break;
4819
4822
  case "htm":
4820
4823
  case "html":
4821
- loadHTML(story, filename, diagnostics);
4824
+ loadHTML(story, filename, opts, diagnostics);
4822
4825
  break;
4823
4826
  case "css":
4824
4827
  loadTagged(story, "stylesheet", filename, diagnostics);
@@ -4972,8 +4975,8 @@ function parseFontFile(filename) {
4972
4975
  diagnostics: []
4973
4976
  };
4974
4977
  }
4975
- function parseHTMLFile(filename) {
4976
- const { story, diagnostics } = decompileHTML(readUTF8(filename));
4978
+ function parseHTMLFile(filename, opts) {
4979
+ const { story, diagnostics } = decompileHTML(readUTF8(filename), { trim: opts.trim ?? true });
4977
4980
  return {
4978
4981
  passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
4979
4982
  name: "StoryTitle",
@@ -4985,14 +4988,17 @@ function parseHTMLFile(filename) {
4985
4988
  }
4986
4989
  /**
4987
4990
  * 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.
4991
+ * Twee files depend on `trim` and `twee2Compat`, HTML files on `trim`; every other file type
4992
+ * yields the same key for all options.
4989
4993
  */
4990
4994
  function parseOptionsKey(filename, opts) {
4991
4995
  const ext = normalizedFileExt(filename);
4996
+ const trim = opts.trim ?? true;
4997
+ if (ext === "htm" || ext === "html") return JSON.stringify({ trim });
4992
4998
  const twee2File = ext === "tw2" || ext === "twee2";
4993
4999
  if (!twee2File && ext !== "tw" && ext !== "twee") return "";
4994
5000
  return JSON.stringify({
4995
- trim: opts.trim ?? true,
5001
+ trim,
4996
5002
  twee2Compat: twee2File || (opts.twee2Compat ?? false)
4997
5003
  });
4998
5004
  }
@@ -5006,7 +5012,7 @@ function parseFile(filename, opts) {
5006
5012
  twee2Compat: true
5007
5013
  });
5008
5014
  case "htm":
5009
- case "html": return parseHTMLFile(filename);
5015
+ case "html": return parseHTMLFile(filename, opts);
5010
5016
  case "css": return parseTaggedFile("stylesheet", filename);
5011
5017
  case "js": return parseTaggedFile("script", filename);
5012
5018
  case "otf":
@@ -5038,8 +5044,8 @@ function parseFile(filename, opts) {
5038
5044
  default: return;
5039
5045
  }
5040
5046
  }
5041
- function loadHTML(story, filename, diagnostics) {
5042
- const result = parseHTMLFile(filename);
5047
+ function loadHTML(story, filename, opts, diagnostics) {
5048
+ const result = parseHTMLFile(filename, opts);
5043
5049
  diagnostics.push(...result.diagnostics);
5044
5050
  for (const p of result.passages) storyAdd(story, p, diagnostics);
5045
5051
  }
@@ -5078,7 +5084,8 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5078
5084
  const optionsKey = parseOptionsKey(filename, opts);
5079
5085
  const entry = cache.get(filename);
5080
5086
  const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
5081
- if (changedFiles && cached && !changedFiles.has(filename)) {
5087
+ const changed = changedFiles?.has(filename) ?? false;
5088
+ if (changedFiles && cached && !changed) {
5082
5089
  diagnostics.push(...cached.diagnostics);
5083
5090
  for (const p of cached.passages) storyAdd(story, p, diagnostics);
5084
5091
  processedFiles.add(filename);
@@ -5090,7 +5097,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5090
5097
  } catch {
5091
5098
  continue;
5092
5099
  }
5093
- if (cached && cached.mtimeMs === mtimeMs) {
5100
+ if (cached && !changed && cached.mtimeMs === mtimeMs) {
5094
5101
  diagnostics.push(...cached.diagnostics);
5095
5102
  for (const p of cached.passages) storyAdd(story, p, diagnostics);
5096
5103
  processedFiles.add(filename);
@@ -5338,10 +5345,39 @@ function tryReplaceComponent(template, placeholder, componentPath, required) {
5338
5345
  }
5339
5346
  //#endregion
5340
5347
  //#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;
5348
+ /**
5349
+ * Serialize the story as Twee. The StoryData passage is written from the story model rather than
5350
+ * from its loaded text, so changes made after loading (a start passage override, test mode) are kept.
5351
+ * Tweego writes the loaded text; its own normalization makes the two the same for an unchanged story.
5352
+ *
5353
+ * `addStoryData`: add a StoryData passage when the story has none, so that StoryData fields the
5354
+ * compile options set are recorded. Default: false.
5355
+ */
5356
+ function toTwee(story, outMode, options = {}) {
5357
+ return withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => passageToTwee(p, outMode)).join("");
5358
+ }
5359
+ function withEffectiveStoryData(story, addStoryData) {
5360
+ const storyData = {
5361
+ name: "StoryData",
5362
+ tags: [],
5363
+ text: marshalStoryData(story)
5364
+ };
5365
+ const index = story.passages.findIndex((p) => p.name === "StoryData");
5366
+ if (index !== -1) return story.passages.map((p, i) => i === index && isStoryDataJSON(p.text) ? {
5367
+ ...p,
5368
+ text: storyData.text
5369
+ } : p);
5370
+ if (!addStoryData) return story.passages;
5371
+ const at = story.passages[0]?.name === "StoryTitle" ? 1 : 0;
5372
+ return [
5373
+ ...story.passages.slice(0, at),
5374
+ storyData,
5375
+ ...story.passages.slice(at)
5376
+ ];
5377
+ }
5378
+ /** Whether the text loaded as StoryData. Text that did not is kept as written, for the author to fix. */
5379
+ function isStoryDataJSON(text) {
5380
+ return unmarshalStoryData(createStory(), text) === null;
5345
5381
  }
5346
5382
  //#endregion
5347
5383
  //#region src/modules.ts
@@ -5413,7 +5449,19 @@ function modifyHead(html, modulePaths, headFile, diagnostics) {
5413
5449
  return html;
5414
5450
  }
5415
5451
  //#endregion
5416
- //#region src/start-passage.ts
5452
+ //#region src/passage-omission.ts
5453
+ /**
5454
+ * Why the `target` output leaves `passage` out of its passage elements, or `undefined` when it
5455
+ * emits the passage (and so a link or the start can name it).
5456
+ */
5457
+ function passageOmission(story, passage, target) {
5458
+ switch (target) {
5459
+ case "twine2": return twine2PassageOmission(story, passage);
5460
+ case "twine1": return twine1PassageOmission(passage);
5461
+ default: throw new Error(`unhandled passage output target: ${String(target)}`);
5462
+ }
5463
+ }
5464
+ /** Why a passage is left out, as a predicate: `Passage "X" ${describeOmission(o)}`. */
5417
5465
  function describeOmission(omission) {
5418
5466
  switch (omission.kind) {
5419
5467
  case "special-name": return `is the special "${omission.name}" passage`;
@@ -5422,13 +5470,8 @@ function describeOmission(omission) {
5422
5470
  default: throw new Error(`unhandled passage omission: ${JSON.stringify(omission)}`);
5423
5471
  }
5424
5472
  }
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
- }
5473
+ //#endregion
5474
+ //#region src/start-passage.ts
5432
5475
  /**
5433
5476
  * Check that `startName` names a passage the `target` output emits.
5434
5477
  * Returns the error diagnostics to report (empty when the start is usable).
@@ -5439,7 +5482,7 @@ function startPassageDiagnostics(story, startName, target) {
5439
5482
  level: "error",
5440
5483
  message: `Starting passage "${startName}" not found.`
5441
5484
  }];
5442
- const omission = omissionFor(story, passage, target);
5485
+ const omission = passageOmission(story, passage, target);
5443
5486
  if (omission === void 0) return [];
5444
5487
  return [{
5445
5488
  level: "error",
@@ -5465,15 +5508,24 @@ var TweeTsError = class extends Error {
5465
5508
  }
5466
5509
  };
5467
5510
  /**
5468
- * Compile Twee sources to HTML, Twee, or JSON.
5511
+ * Builds as compileToFile() does for `outFile`, without writing it: the output may sit
5512
+ * inside a source folder, so its last build is left out of the sources and modules and
5513
+ * never read back. The caller decides whether the result is written. With no `outFile`
5514
+ * (output to stdout, or none), this is compile().
5515
+ *
5516
+ * Internal, for the CLI; not part of the public API.
5469
5517
  */
5470
- async function compile(options) {
5471
- return buildOutput(options);
5518
+ async function compileForOutputFile(options, outFile) {
5519
+ return buildOutput(options, void 0, void 0, outFile);
5472
5520
  }
5473
5521
  /**
5474
- * Watch for file changes and recompile.
5522
+ * watch(), writing to `outFile` only the builds `shouldWrite` accepts. A rejected build
5523
+ * leaves the output file as it was; `onBuild` receives every build either way.
5524
+ *
5525
+ * Internal, for the CLI, which keeps the last good output when a rebuild reports errors;
5526
+ * not part of the public API.
5475
5527
  */
5476
- async function watch$1(options) {
5528
+ async function watchWithWriteFilter(options, shouldWrite) {
5477
5529
  const controller = new AbortController();
5478
5530
  const cache = /* @__PURE__ */ new Map();
5479
5531
  const filePaths = options.sources.filter((s) => typeof s === "string");
@@ -5493,7 +5545,7 @@ async function watch$1(options) {
5493
5545
  };
5494
5546
  const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
5495
5547
  buildOutput(options, cache, changedFiles, options.outFile).then((result) => {
5496
- writeFileSync(options.outFile, result.output, "utf-8");
5548
+ if (shouldWrite(result)) writeFileSync(options.outFile, result.output, "utf-8");
5497
5549
  options.onBuild?.(result);
5498
5550
  }).catch((e) => {
5499
5551
  options.onError?.(e instanceof Error ? e : new Error(String(e)));
@@ -5549,7 +5601,7 @@ async function buildOutput(options, cache, changedFiles, outFile) {
5549
5601
  switch (outputMode) {
5550
5602
  case "twee3":
5551
5603
  case "twee1":
5552
- output = toTwee(story, outputMode);
5604
+ output = toTwee(story, outputMode, { addStoryData: Boolean(options.startPassage) || testMode });
5553
5605
  break;
5554
5606
  case "twine2-archive":
5555
5607
  output = toTwine2Archive(story, startName, { sourceInfo });
@@ -5992,6 +6044,11 @@ function endOfRegularExpression(source, pos) {
5992
6044
  }
5993
6045
  //#endregion
5994
6046
  //#region src/link-markup.ts
6047
+ /** Records that the reading has looked at the characters before `pos`, and returns `result`. */
6048
+ function readTo(reader, pos, result) {
6049
+ reader.scanned = Math.max(reader.scanned, pos);
6050
+ return result;
6051
+ }
5995
6052
  /**
5996
6053
  * Reads link or image markup that starts at `start` in `text`, or returns `undefined` where
5997
6054
  * SugarCube rejects it (and so prints the text instead).
@@ -5999,15 +6056,17 @@ function endOfRegularExpression(source, pos) {
5999
6056
  * `budget.left`, if given, is how many characters the reading may look at; it is reduced by as
6000
6057
  * many as were read, and once it runs out, every reading fails. Unclosed markup is read to the end
6001
6058
  * of its line, so reading every `[[` of a long line that never closes one would otherwise take
6002
- * quadratic time.
6059
+ * quadratic time. A reading is charged for the characters it looked at, up to where it ended or
6060
+ * failed (a line feed, say), and no more, so markup left unclosed on short lines costs little.
6003
6061
  */
6004
6062
  function readSquareBracketedMarkup(text, start, budget = { left: Infinity }) {
6005
6063
  const reader = {
6006
6064
  text,
6007
- limit: Math.min(text.length, start + Math.max(budget.left, 0))
6065
+ limit: Math.min(text.length, start + Math.max(budget.left, 0)),
6066
+ scanned: start
6008
6067
  };
6009
6068
  const markup = readMarkup(reader, start);
6010
- budget.left -= (markup?.end ?? reader.limit) - start;
6069
+ budget.left -= Math.max(reader.scanned, markup?.end ?? start) - start;
6011
6070
  return markup;
6012
6071
  }
6013
6072
  function readMarkup(reader, start) {
@@ -6083,7 +6142,7 @@ function readCoreComponents(reader, from) {
6083
6142
  const ch = text[pos];
6084
6143
  pos += 1;
6085
6144
  switch (ch) {
6086
- case "\n": return;
6145
+ case "\n": return readTo(reader, pos, void 0);
6087
6146
  case "\"": {
6088
6147
  const end = readQuoted(reader, pos, "\"");
6089
6148
  if (end === void 0) return;
@@ -6117,7 +6176,7 @@ function readCoreComponents(reader, from) {
6117
6176
  case "]":
6118
6177
  depth -= 1;
6119
6178
  if (depth === 1) {
6120
- const end = componentEnd(text, pos);
6179
+ const end = componentEnd(reader, pos);
6121
6180
  if (end === void 0) return;
6122
6181
  return {
6123
6182
  link: link ?? text.slice(componentStart, end.textEnd),
@@ -6126,6 +6185,7 @@ function readCoreComponents(reader, from) {
6126
6185
  }
6127
6186
  }
6128
6187
  }
6188
+ return readTo(reader, pos, void 0);
6129
6189
  }
6130
6190
  /**
6131
6191
  * Reads a component after `][` that holds no delimiter: an image's link (`setter` false) or a
@@ -6139,7 +6199,7 @@ function readComponent(reader, from, setter) {
6139
6199
  const ch = text[pos];
6140
6200
  pos += 1;
6141
6201
  switch (ch) {
6142
- case "\n": return;
6202
+ case "\n": return readTo(reader, pos, void 0);
6143
6203
  case "\"":
6144
6204
  case "'": {
6145
6205
  if (ch === "'" && !setter) break;
@@ -6153,9 +6213,10 @@ function readComponent(reader, from, setter) {
6153
6213
  break;
6154
6214
  case "]":
6155
6215
  depth -= 1;
6156
- if (depth === 1) return componentEnd(text, pos);
6216
+ if (depth === 1) return componentEnd(reader, pos);
6157
6217
  }
6158
6218
  }
6219
+ return readTo(reader, pos, void 0);
6159
6220
  }
6160
6221
  /** Reads a setter, the last component; returns the index after its closing `]]`. */
6161
6222
  function readSetter(reader, from) {
@@ -6163,19 +6224,19 @@ function readSetter(reader, from) {
6163
6224
  return end?.last === true ? end.next : void 0;
6164
6225
  }
6165
6226
  /** 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 {
6227
+ function componentEnd(reader, pos) {
6228
+ switch (reader.text[pos]) {
6229
+ case "[": return readTo(reader, pos + 1, {
6169
6230
  textEnd: pos - 1,
6170
6231
  next: pos + 1,
6171
6232
  last: false
6172
- };
6173
- case "]": return {
6233
+ });
6234
+ case "]": return readTo(reader, pos + 1, {
6174
6235
  textEnd: pos - 1,
6175
6236
  next: pos + 1,
6176
6237
  last: true
6177
- };
6178
- default: return;
6238
+ });
6239
+ default: return readTo(reader, pos + 1, void 0);
6179
6240
  }
6180
6241
  }
6181
6242
  /**
@@ -6190,12 +6251,13 @@ function readQuoted(reader, from, quote) {
6190
6251
  const ch = text[pos];
6191
6252
  pos += 1;
6192
6253
  if (ch === quote) return pos;
6193
- if (ch === "\n") return;
6254
+ if (ch === "\n") return readTo(reader, pos, void 0);
6194
6255
  if (ch === "\\") {
6195
- if (pos >= reader.limit || text[pos] === "\n") return;
6256
+ if (pos >= reader.limit || text[pos] === "\n") return readTo(reader, Math.min(pos + 1, reader.limit), void 0);
6196
6257
  pos += 1;
6197
6258
  }
6198
6259
  }
6260
+ return readTo(reader, pos, void 0);
6199
6261
  }
6200
6262
  //#endregion
6201
6263
  //#region src/sugarcube-macros.ts
@@ -7053,6 +7115,29 @@ function extractLinksFromText(text, isScript) {
7053
7115
  ].flatMap((via) => found.filter((link) => link.via === via).map((link) => link.passage));
7054
7116
  return [...new Set(ordered)];
7055
7117
  }
7118
+ /**
7119
+ * The passages that `p` links to. A script passage is JavaScript, read for links only in its
7120
+ * strings. A stylesheet is CSS, which the story puts in a style element and never wikifies, so it
7121
+ * links nowhere; a passage tagged both is a script, as Twine 2 output takes it.
7122
+ */
7123
+ function passageLinks(p) {
7124
+ if (hasTag(p, "script")) return extractLinksFromText(textAsRead(p), true);
7125
+ if (hasTag(p, "stylesheet")) return [];
7126
+ return extractLinksFromText(textAsRead(p), false);
7127
+ }
7128
+ /**
7129
+ * Each passage name, mapped to why the `target` output leaves that passage out, or to `undefined`
7130
+ * when a link can reach it: always without a target, and otherwise when the output emits a
7131
+ * passage of that name.
7132
+ */
7133
+ function destinationOmissions(story, { target }) {
7134
+ const omissions = /* @__PURE__ */ new Map();
7135
+ for (const p of story.passages) {
7136
+ const omission = target === void 0 ? void 0 : passageOmission(story, p, target);
7137
+ if (!omissions.has(p.name) || omission === void 0) omissions.set(p.name, omission);
7138
+ }
7139
+ return omissions;
7140
+ }
7056
7141
  /** Passages SugarCube runs from their raw text, never joining their lines. */
7057
7142
  const RAW_TEXT_PASSAGES = /* @__PURE__ */ new Set([
7058
7143
  "StoryInit",
@@ -7094,9 +7179,14 @@ function joinLines(text) {
7094
7179
  * expect(map.brokenLinks).toEqual([]);
7095
7180
  * expect(map.deadEnds).not.toContain('Start');
7096
7181
  * ```
7182
+ *
7183
+ * By default, a link is broken only when no passage has its name. Pass a `target` to check links
7184
+ * against what that output emits: `storyInspect(result.story, { target: 'twine2' })` also reports
7185
+ * links to passages Twine 2 output leaves out, such as script, stylesheet and `Twine.private`
7186
+ * passages.
7097
7187
  */
7098
- function storyInspect(story) {
7099
- const allNames = new Set(story.passages.map((p) => p.name));
7188
+ function storyInspect(story, options = {}) {
7189
+ const destinations = destinationOmissions(story, options);
7100
7190
  const passages = [];
7101
7191
  const storyPassages = [];
7102
7192
  const infoPassages = [];
@@ -7118,14 +7208,24 @@ function storyInspect(story) {
7118
7208
  }
7119
7209
  list.push(p.name);
7120
7210
  }
7121
- const targets = extractLinksFromText(textAsRead(p), hasTag(p, "script"));
7122
- links.set(p.name, targets);
7211
+ links.set(p.name, passageLinks(p));
7123
7212
  }
7124
7213
  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
- });
7214
+ for (const [from, targets] of links) for (const to of targets) {
7215
+ if (!destinations.has(to)) {
7216
+ brokenLinks.push({
7217
+ from,
7218
+ to
7219
+ });
7220
+ continue;
7221
+ }
7222
+ const omission = destinations.get(to);
7223
+ if (omission !== void 0) brokenLinks.push({
7224
+ from,
7225
+ to,
7226
+ omission
7227
+ });
7228
+ }
7129
7229
  const deadEnds = [];
7130
7230
  for (const name of storyPassages) {
7131
7231
  const targets = links.get(name);
@@ -7153,16 +7253,18 @@ function storyInspect(story) {
7153
7253
  //#endregion
7154
7254
  //#region src/lint.ts
7155
7255
  /**
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.
7256
+ * lint() for a project whose builds are written to `outFile`: like a build for that file,
7257
+ * it leaves `outFile` out of the sources and modules, so an earlier build inside a source
7258
+ * folder is not linted as a source.
7259
+ *
7260
+ * Internal, for the CLI; not part of the public API.
7159
7261
  */
7160
- async function lint(options) {
7161
- const result = await compile({
7262
+ async function lintForOutputFile(options, outFile) {
7263
+ const result = await compileForOutputFile({
7162
7264
  ...options,
7163
7265
  outputMode: "json"
7164
- });
7165
- const map = storyInspect(result.story);
7266
+ }, outFile);
7267
+ const map = storyInspect(result.story, { target: "twine2" });
7166
7268
  return {
7167
7269
  diagnostics: [...result.diagnostics, ...startPassageDiagnostics(result.story, map.start, "twine2")],
7168
7270
  stats: result.stats,
@@ -7189,7 +7291,10 @@ function formatLintReport(result) {
7189
7291
  if (result.brokenLinks.length > 0) {
7190
7292
  lines.push("");
7191
7293
  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)`);
7294
+ for (const link of result.brokenLinks) {
7295
+ const why = link.omission === void 0 ? "does not exist" : `${describeOmission(link.omission)}, so it is left out of the story data`;
7296
+ lines.push(` ${link.from} -> ${link.to} (passage "${link.to}" ${why})`);
7297
+ }
7193
7298
  }
7194
7299
  if (result.deadEnds.length > 0) {
7195
7300
  lines.push("");
@@ -7472,8 +7577,10 @@ async function main() {
7472
7577
  }
7473
7578
  return raw;
7474
7579
  })();
7580
+ const outFile = values.output ?? config?.output ?? "-";
7581
+ const outPath = outFile === "-" ? void 0 : outFile;
7475
7582
  if (values.lint) {
7476
- const lintResult = await lint({
7583
+ const lintResult = await lintForOutputFile({
7477
7584
  sources,
7478
7585
  exclude: values.exclude ?? config?.exclude,
7479
7586
  formatId: values.format ?? config?.formatId,
@@ -7490,7 +7597,7 @@ async function main() {
7490
7597
  tagAliases,
7491
7598
  sourceInfo: values["source-info"] ?? config?.sourceInfo ?? false,
7492
7599
  wordCountMethod
7493
- });
7600
+ }, outPath);
7494
7601
  console.log(formatLintReport(lintResult));
7495
7602
  const hasErrors = lintResult.brokenLinks.length > 0 || lintResult.diagnostics.some((d) => d.level === "error");
7496
7603
  process.exit(hasErrors ? 1 : 0);
@@ -7515,36 +7622,35 @@ async function main() {
7515
7622
  sourceInfo: values["source-info"] ?? config?.sourceInfo ?? false,
7516
7623
  wordCountMethod
7517
7624
  };
7518
- const outFile = values.output ?? config?.output ?? "-";
7519
7625
  if (values.watch) {
7520
- if (outFile === "-") {
7626
+ if (outPath === void 0) {
7521
7627
  console.error("Error: Watch mode requires an output file (-o).");
7522
7628
  process.exit(1);
7523
7629
  }
7524
7630
  console.log("Watch mode started. Press CTRL+C to stop.");
7525
- await watch$1({
7631
+ await watchWithWriteFilter({
7526
7632
  ...compileOptions,
7527
- outFile,
7633
+ outFile: outPath,
7528
7634
  onBuild(result) {
7529
7635
  console.log(`Built: ${result.stats.passages} passages, ${result.stats.words} words`);
7530
7636
  logDiagnostics(result.diagnostics);
7531
7637
  const errors = countErrors(result.diagnostics);
7532
- if (errors > 0) console.error(`Build has ${pluralize(errors, "error")}; still watching for changes.`);
7638
+ if (errors > 0) console.error(`Build has ${pluralize(errors, "error")}; output not written. Still watching for changes.`);
7533
7639
  },
7534
7640
  onError(error) {
7535
7641
  console.error(`Build error: ${error.message}`);
7536
7642
  }
7537
- });
7643
+ }, (result) => countErrors(result.diagnostics) === 0);
7538
7644
  } else {
7539
- const result = await compile(compileOptions);
7645
+ const result = await compileForOutputFile(compileOptions, outPath);
7540
7646
  logDiagnostics(result.diagnostics);
7541
7647
  const errors = countErrors(result.diagnostics);
7542
7648
  if (errors > 0) {
7543
7649
  console.error(`Compilation failed with ${pluralize(errors, "error")}; output not written.`);
7544
7650
  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(", ")}`);
7651
+ } else if (outPath === void 0) process.stdout.write(result.output);
7652
+ else writeFileSync(outPath, result.output, "utf-8");
7653
+ if (outPath !== void 0 && values["log-files"]) console.log(`\nFiles: ${result.stats.files.join(", ")}`);
7548
7654
  if (values["log-stats"]) logStats(result);
7549
7655
  }
7550
7656
  }