@rohal12/twee-ts 1.5.0 → 1.7.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.
@@ -151,6 +151,9 @@ function hasInfoTags(p) {
151
151
  function hasInfoName(p) {
152
152
  return INFO_PASSAGE_NAMES.has(p.name);
153
153
  }
154
+ function isInfoPassage(p) {
155
+ return hasInfoName(p) || hasInfoTags(p);
156
+ }
154
157
  function isStoryPassage(p) {
155
158
  return !hasInfoName(p) && !hasInfoTags(p);
156
159
  }
@@ -262,8 +265,21 @@ function quote(s) {
262
265
  }
263
266
 
264
267
  // src/story.ts
268
+ var passageIndex = /* @__PURE__ */ new WeakMap();
269
+ function getIndex(story) {
270
+ let index = passageIndex.get(story);
271
+ if (!index) {
272
+ index = /* @__PURE__ */ new Map();
273
+ for (let i = 0; i < story.passages.length; i++) {
274
+ const passage = story.passages[i];
275
+ if (passage) index.set(passage.name, i);
276
+ }
277
+ passageIndex.set(story, index);
278
+ }
279
+ return index;
280
+ }
265
281
  function createStory() {
266
- return {
282
+ const story = {
267
283
  name: "",
268
284
  ifid: "",
269
285
  passages: [],
@@ -279,16 +295,17 @@ function createStory() {
279
295
  zoom: 1
280
296
  }
281
297
  };
298
+ passageIndex.set(story, /* @__PURE__ */ new Map());
299
+ return story;
282
300
  }
283
301
  function storyHas(story, name) {
284
- return story.passages.some((p) => p.name === name);
285
- }
286
- function storyIndex(story, name) {
287
- return story.passages.findIndex((p) => p.name === name);
302
+ return getIndex(story).has(name);
288
303
  }
289
304
  function storyAppend(story, p, diagnostics) {
290
- const i = storyIndex(story, p.name);
305
+ const index = getIndex(story);
306
+ const i = index.get(p.name) ?? -1;
291
307
  if (i === -1) {
308
+ index.set(p.name, story.passages.length);
292
309
  story.passages.push(p);
293
310
  } else {
294
311
  diagnostics.push({
@@ -299,9 +316,15 @@ function storyAppend(story, p, diagnostics) {
299
316
  }
300
317
  }
301
318
  function storyPrepend(story, p, diagnostics) {
302
- const i = storyIndex(story, p.name);
319
+ const index = getIndex(story);
320
+ const i = index.get(p.name) ?? -1;
303
321
  if (i === -1) {
304
322
  story.passages.unshift(p);
323
+ index.clear();
324
+ for (let j = 0; j < story.passages.length; j++) {
325
+ const passage = story.passages[j];
326
+ if (passage) index.set(passage.name, j);
327
+ }
305
328
  } else {
306
329
  diagnostics.push({
307
330
  level: "warning",
@@ -591,11 +614,15 @@ function watchFilesystem(pathnames, outFilename, callback) {
591
614
  const watchers = [];
592
615
  let buildTimer = null;
593
616
  const BUILD_DEBOUNCE = 500;
594
- function scheduleBuild() {
617
+ const pendingFiles = /* @__PURE__ */ new Set();
618
+ function scheduleBuild(changedFile) {
619
+ if (changedFile) pendingFiles.add(changedFile);
595
620
  if (buildTimer) clearTimeout(buildTimer);
596
621
  buildTimer = setTimeout(() => {
597
622
  buildTimer = null;
598
- callback();
623
+ const files = pendingFiles.size > 0 ? new Set(pendingFiles) : void 0;
624
+ pendingFiles.clear();
625
+ callback(files);
599
626
  }, BUILD_DEBOUNCE);
600
627
  }
601
628
  for (const pathname of pathnames) {
@@ -605,7 +632,8 @@ function watchFilesystem(pathnames, outFilename, callback) {
605
632
  const abs = resolve(pathname, filename);
606
633
  if (abs === absOutFile) return;
607
634
  if (isKnownFileType(filename)) {
608
- scheduleBuild();
635
+ const rel = relative(process.cwd(), abs);
636
+ scheduleBuild(rel || abs);
609
637
  }
610
638
  });
611
639
  watchers.push(watcher);
@@ -864,6 +892,7 @@ function readFormatSource(format) {
864
892
 
865
893
  // src/loader.ts
866
894
  import { basename } from "path";
895
+ import { statSync as statSync3 } from "fs";
867
896
 
868
897
  // src/lexer.ts
869
898
  var EOF = -1;
@@ -1371,54 +1400,182 @@ function loadInlineSources(story, sources, opts, diagnostics) {
1371
1400
  }
1372
1401
  }
1373
1402
  }
1374
- function loadTwee(story, filename, opts, diagnostics) {
1403
+ function parseTweeFile(filename, opts) {
1375
1404
  const source = readUTF8(filename);
1376
1405
  const result = parseTwee(source, {
1377
1406
  filename,
1378
1407
  trim: opts.trim ?? true,
1379
1408
  twee2Compat: opts.twee2Compat ?? false
1380
1409
  });
1381
- diagnostics.push(...result.diagnostics);
1382
- for (const p of result.passages) {
1383
- storyAdd(story, p, diagnostics);
1384
- }
1410
+ return { passages: result.passages, diagnostics: [...result.diagnostics] };
1385
1411
  }
1386
- function loadTagged(story, tag, filename, diagnostics) {
1412
+ function parseTaggedFile(tag, filename) {
1387
1413
  const source = readUTF8(filename);
1388
- storyAdd(story, { name: basename(filename), tags: [tag], text: source }, diagnostics);
1414
+ return { passages: [{ name: basename(filename), tags: [tag], text: source }], diagnostics: [] };
1389
1415
  }
1390
- function loadMedia(story, tag, filename, diagnostics) {
1416
+ function parseMediaFile(tag, filename) {
1391
1417
  const source = readBase64(filename);
1392
1418
  const name = baseNameWithoutExt(filename);
1393
- storyAdd(
1394
- story,
1395
- {
1396
- name,
1397
- tags: [tag],
1398
- text: `data:${mediaTypeFromFilename(filename)};base64,${source}`
1399
- },
1400
- diagnostics
1401
- );
1419
+ return {
1420
+ passages: [{ name, tags: [tag], text: `data:${mediaTypeFromFilename(filename)};base64,${source}` }],
1421
+ diagnostics: []
1422
+ };
1402
1423
  }
1403
- function loadFont(story, filename, diagnostics) {
1424
+ function parseFontFile(filename) {
1404
1425
  const source = readBase64(filename);
1405
1426
  const name = basename(filename);
1406
1427
  const family = baseNameWithoutExt(filename);
1407
1428
  const ext = normalizedFileExt(filename);
1408
1429
  const mediaType = mediaTypeFromExt(ext);
1409
1430
  const hint = fontFormatHint(ext);
1410
- storyAdd(
1411
- story,
1412
- {
1413
- name,
1414
- tags: ["stylesheet"],
1415
- text: `@font-face {
1431
+ return {
1432
+ passages: [
1433
+ {
1434
+ name,
1435
+ tags: ["stylesheet"],
1436
+ text: `@font-face {
1416
1437
  font-family: "${family}";
1417
1438
  src: url("data:${mediaType};base64,${source}") format("${hint}");
1418
1439
  }`
1419
- },
1420
- diagnostics
1421
- );
1440
+ }
1441
+ ],
1442
+ diagnostics: []
1443
+ };
1444
+ }
1445
+ function parseFile(filename, opts) {
1446
+ const ext = normalizedFileExt(filename);
1447
+ switch (ext) {
1448
+ case "tw":
1449
+ case "twee":
1450
+ return parseTweeFile(filename, opts);
1451
+ case "tw2":
1452
+ case "twee2":
1453
+ return parseTweeFile(filename, { ...opts, twee2Compat: true });
1454
+ case "css":
1455
+ return parseTaggedFile("stylesheet", filename);
1456
+ case "js":
1457
+ return parseTaggedFile("script", filename);
1458
+ case "otf":
1459
+ case "ttf":
1460
+ case "woff":
1461
+ case "woff2":
1462
+ return parseFontFile(filename);
1463
+ case "gif":
1464
+ case "jpeg":
1465
+ case "jpg":
1466
+ case "png":
1467
+ case "svg":
1468
+ case "tif":
1469
+ case "tiff":
1470
+ case "webp":
1471
+ return parseMediaFile("Twine.image", filename);
1472
+ case "aac":
1473
+ case "flac":
1474
+ case "m4a":
1475
+ case "mp3":
1476
+ case "oga":
1477
+ case "ogg":
1478
+ case "opus":
1479
+ case "wav":
1480
+ case "wave":
1481
+ case "weba":
1482
+ return parseMediaFile("Twine.audio", filename);
1483
+ case "mp4":
1484
+ case "ogv":
1485
+ case "webm":
1486
+ return parseMediaFile("Twine.video", filename);
1487
+ case "vtt":
1488
+ return parseMediaFile("Twine.vtt", filename);
1489
+ default:
1490
+ return void 0;
1491
+ }
1492
+ }
1493
+ function loadTwee(story, filename, opts, diagnostics) {
1494
+ const result = parseTweeFile(filename, opts);
1495
+ diagnostics.push(...result.diagnostics);
1496
+ for (const p of result.passages) {
1497
+ storyAdd(story, p, diagnostics);
1498
+ }
1499
+ }
1500
+ function loadTagged(story, tag, filename, diagnostics) {
1501
+ const result = parseTaggedFile(tag, filename);
1502
+ for (const p of result.passages) {
1503
+ storyAdd(story, p, diagnostics);
1504
+ }
1505
+ }
1506
+ function loadMedia(story, tag, filename, diagnostics) {
1507
+ const result = parseMediaFile(tag, filename);
1508
+ for (const p of result.passages) {
1509
+ storyAdd(story, p, diagnostics);
1510
+ }
1511
+ }
1512
+ function loadFont(story, filename, diagnostics) {
1513
+ const result = parseFontFile(filename);
1514
+ for (const p of result.passages) {
1515
+ storyAdd(story, p, diagnostics);
1516
+ }
1517
+ }
1518
+ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
1519
+ const currentFiles = /* @__PURE__ */ new Set();
1520
+ for (const filename of filenames) {
1521
+ if (processedFiles.has(filename)) {
1522
+ diagnostics.push({ level: "warning", message: `load ${filename}: Skipping duplicate.` });
1523
+ continue;
1524
+ }
1525
+ currentFiles.add(filename);
1526
+ const cached = cache.get(filename);
1527
+ if (changedFiles && cached && !changedFiles.has(filename)) {
1528
+ diagnostics.push(...cached.diagnostics);
1529
+ for (const p of cached.passages) {
1530
+ storyAdd(story, p, diagnostics);
1531
+ }
1532
+ processedFiles.add(filename);
1533
+ continue;
1534
+ }
1535
+ let mtimeMs;
1536
+ try {
1537
+ mtimeMs = statSync3(filename).mtimeMs;
1538
+ } catch {
1539
+ continue;
1540
+ }
1541
+ if (cached && cached.mtimeMs === mtimeMs) {
1542
+ diagnostics.push(...cached.diagnostics);
1543
+ for (const p of cached.passages) {
1544
+ storyAdd(story, p, diagnostics);
1545
+ }
1546
+ processedFiles.add(filename);
1547
+ continue;
1548
+ }
1549
+ try {
1550
+ const result = parseFile(filename, opts);
1551
+ if (!result) continue;
1552
+ cache.set(filename, {
1553
+ mtimeMs,
1554
+ passages: result.passages,
1555
+ diagnostics: result.diagnostics
1556
+ });
1557
+ diagnostics.push(...result.diagnostics);
1558
+ for (const p of result.passages) {
1559
+ storyAdd(story, p, diagnostics);
1560
+ }
1561
+ } catch (e) {
1562
+ diagnostics.push({
1563
+ level: "error",
1564
+ message: `load ${filename}: ${e instanceof Error ? e.message : String(e)}`,
1565
+ file: filename
1566
+ });
1567
+ continue;
1568
+ }
1569
+ processedFiles.add(filename);
1570
+ }
1571
+ for (const key of cache.keys()) {
1572
+ if (!currentFiles.has(key)) {
1573
+ cache.delete(key);
1574
+ }
1575
+ }
1576
+ if (story.name !== "" && !story.passages.some((p) => p.name === "StoryTitle")) {
1577
+ storyPrepend(story, { name: "StoryTitle", tags: [], text: story.name }, diagnostics);
1578
+ }
1422
1579
  }
1423
1580
 
1424
1581
  // src/version.ts
@@ -1687,7 +1844,7 @@ function modifyHead(html, modulePaths, headFile, diagnostics) {
1687
1844
  }
1688
1845
 
1689
1846
  // src/remote-formats.ts
1690
- import { mkdirSync, readFileSync as readFileSync3, writeFileSync, existsSync, readdirSync as readdirSync3, statSync as statSync3 } from "fs";
1847
+ import { mkdirSync, readFileSync as readFileSync3, writeFileSync, existsSync, readdirSync as readdirSync3, statSync as statSync4 } from "fs";
1691
1848
  import { join as join4 } from "path";
1692
1849
  import { homedir as homedir2 } from "os";
1693
1850
  var DEFAULT_SFA_INDICES = [
@@ -1899,7 +2056,7 @@ function discoverCachedFormats() {
1899
2056
  for (const name of names) {
1900
2057
  const nameDir = join4(cacheDir, name);
1901
2058
  try {
1902
- if (!statSync3(nameDir).isDirectory()) continue;
2059
+ if (!statSync4(nameDir).isDirectory()) continue;
1903
2060
  } catch {
1904
2061
  continue;
1905
2062
  }
@@ -1940,11 +2097,12 @@ async function compileToFile(options) {
1940
2097
  }
1941
2098
  async function watch(options) {
1942
2099
  const controller = new AbortController();
2100
+ const cache = /* @__PURE__ */ new Map();
1943
2101
  const filePaths = options.sources.filter((s) => typeof s === "string");
1944
2102
  const modulePaths = options.modules ?? [];
1945
2103
  const allPaths = [...filePaths, ...modulePaths];
1946
- const handle = watchFilesystem(allPaths, options.outFile, () => {
1947
- buildOutput(options).then((result) => {
2104
+ const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
2105
+ buildOutput(options, cache, changedFiles).then((result) => {
1948
2106
  writeFileSync2(options.outFile, result.output, "utf-8");
1949
2107
  options.onBuild?.(result);
1950
2108
  }).catch((e) => {
@@ -1954,7 +2112,7 @@ async function watch(options) {
1954
2112
  controller.signal.addEventListener("abort", () => handle.close());
1955
2113
  return controller;
1956
2114
  }
1957
- async function buildOutput(options) {
2115
+ async function buildOutput(options, cache, changedFiles) {
1958
2116
  const diagnostics = [];
1959
2117
  const outputMode = options.outputMode ?? "html";
1960
2118
  const trim = options.trim ?? true;
@@ -1975,7 +2133,11 @@ async function buildOutput(options) {
1975
2133
  const sourceFilenames = getFilenames(filePaths);
1976
2134
  const story = createStory();
1977
2135
  const processedFiles = /* @__PURE__ */ new Set();
1978
- loadSources(story, sourceFilenames, { trim, twee2Compat }, diagnostics, processedFiles);
2136
+ if (cache) {
2137
+ loadSourcesCached(story, sourceFilenames, { trim, twee2Compat }, diagnostics, processedFiles, cache, changedFiles);
2138
+ } else {
2139
+ loadSources(story, sourceFilenames, { trim, twee2Compat }, diagnostics, processedFiles);
2140
+ }
1979
2141
  loadInlineSources(story, inlineSources, { trim, twee2Compat }, diagnostics);
1980
2142
  if (options.tagAliases) {
1981
2143
  story.passages = applyTagAliases(story.passages, options.tagAliases);
@@ -2113,6 +2275,171 @@ function storyToJSON(story) {
2113
2275
  return JSON.stringify(obj, null, 2);
2114
2276
  }
2115
2277
 
2278
+ // src/inspect.ts
2279
+ function extractLinksFromText(text) {
2280
+ const targets = /* @__PURE__ */ new Set();
2281
+ const wikiLinkRe = /\[\[([^\]]+)\]\]/g;
2282
+ let m;
2283
+ while ((m = wikiLinkRe.exec(text)) !== null) {
2284
+ const content = m[1];
2285
+ const arrowIdx = content.indexOf("->");
2286
+ if (arrowIdx !== -1) {
2287
+ targets.add(content.slice(arrowIdx + 2).trim());
2288
+ continue;
2289
+ }
2290
+ const pipeIdx = content.indexOf("|");
2291
+ if (pipeIdx !== -1) {
2292
+ targets.add(content.slice(pipeIdx + 1).trim());
2293
+ continue;
2294
+ }
2295
+ targets.add(content.trim());
2296
+ }
2297
+ const gotoRe = /<<goto\s+["']([^"']+)["']\s*>>/g;
2298
+ while ((m = gotoRe.exec(text)) !== null) {
2299
+ targets.add(m[1]);
2300
+ }
2301
+ const linkMacroRe = /<<link\s+["'][^"']*["']\s+["']([^"']+)["']\s*>>/g;
2302
+ while ((m = linkMacroRe.exec(text)) !== null) {
2303
+ targets.add(m[1]);
2304
+ }
2305
+ return [...targets];
2306
+ }
2307
+ function storyInspect(story) {
2308
+ const allNames = new Set(story.passages.map((p) => p.name));
2309
+ const passages = [];
2310
+ const storyPassages = [];
2311
+ const infoPassages = [];
2312
+ const tagSet = /* @__PURE__ */ new Set();
2313
+ const passagesByTag = /* @__PURE__ */ new Map();
2314
+ const tagsByPassage = /* @__PURE__ */ new Map();
2315
+ const links = /* @__PURE__ */ new Map();
2316
+ for (const p of story.passages) {
2317
+ passages.push(p.name);
2318
+ if (isStoryPassage(p)) {
2319
+ storyPassages.push(p.name);
2320
+ }
2321
+ if (isInfoPassage(p)) {
2322
+ infoPassages.push(p.name);
2323
+ }
2324
+ tagsByPassage.set(p.name, [...p.tags]);
2325
+ for (const tag of p.tags) {
2326
+ tagSet.add(tag);
2327
+ let list = passagesByTag.get(tag);
2328
+ if (!list) {
2329
+ list = [];
2330
+ passagesByTag.set(tag, list);
2331
+ }
2332
+ list.push(p.name);
2333
+ }
2334
+ const targets = extractLinksFromText(p.text);
2335
+ links.set(p.name, targets);
2336
+ }
2337
+ const brokenLinks = [];
2338
+ for (const [from, targets] of links) {
2339
+ for (const to of targets) {
2340
+ if (!allNames.has(to)) {
2341
+ brokenLinks.push({ from, to });
2342
+ }
2343
+ }
2344
+ }
2345
+ const deadEnds = [];
2346
+ for (const name of storyPassages) {
2347
+ const targets = links.get(name);
2348
+ if (!targets || targets.length === 0) {
2349
+ deadEnds.push(name);
2350
+ }
2351
+ }
2352
+ const start = story.twine2.start || "Start";
2353
+ const linkedTo = /* @__PURE__ */ new Set();
2354
+ for (const targets of links.values()) {
2355
+ for (const t of targets) {
2356
+ linkedTo.add(t);
2357
+ }
2358
+ }
2359
+ const orphans = [];
2360
+ for (const name of storyPassages) {
2361
+ if (name !== start && !linkedTo.has(name)) {
2362
+ orphans.push(name);
2363
+ }
2364
+ }
2365
+ return {
2366
+ passages,
2367
+ storyPassages,
2368
+ infoPassages,
2369
+ tags: [...tagSet].sort(),
2370
+ passagesByTag,
2371
+ tagsByPassage,
2372
+ links,
2373
+ brokenLinks,
2374
+ deadEnds,
2375
+ orphans,
2376
+ start
2377
+ };
2378
+ }
2379
+
2380
+ // src/lint.ts
2381
+ async function lint(options) {
2382
+ const result = await compile({ ...options, outputMode: "json" });
2383
+ const map = storyInspect(result.story);
2384
+ return {
2385
+ diagnostics: result.diagnostics,
2386
+ stats: result.stats,
2387
+ formatName: result.story.twine2.format,
2388
+ formatVersion: result.story.twine2.formatVersion,
2389
+ start: map.start,
2390
+ passages: map.passages.length,
2391
+ storyPassages: map.storyPassages.length,
2392
+ infoPassages: map.infoPassages.length,
2393
+ brokenLinks: map.brokenLinks,
2394
+ deadEnds: map.deadEnds,
2395
+ orphans: map.orphans
2396
+ };
2397
+ }
2398
+ function formatLintReport(result) {
2399
+ const lines = [];
2400
+ const formatStr = result.formatName ? `${result.formatName}${result.formatVersion ? " " + result.formatVersion : ""}` : "unknown";
2401
+ lines.push(`Format: ${formatStr}`);
2402
+ lines.push(
2403
+ `Passages: ${result.passages} total (${result.storyPassages} story, ${result.infoPassages} info), ${result.stats.words.toLocaleString()} words, ${result.stats.files.length} files`
2404
+ );
2405
+ lines.push(`Start: ${result.start}`);
2406
+ if (result.brokenLinks.length > 0) {
2407
+ lines.push("");
2408
+ lines.push(`Broken links (${result.brokenLinks.length}):`);
2409
+ for (const link of result.brokenLinks) {
2410
+ lines.push(` ${link.from} -> ${link.to} (passage "${link.to}" does not exist)`);
2411
+ }
2412
+ }
2413
+ if (result.deadEnds.length > 0) {
2414
+ lines.push("");
2415
+ lines.push(`Dead ends (${result.deadEnds.length}): ${result.deadEnds.join(", ")}`);
2416
+ }
2417
+ if (result.orphans.length > 0) {
2418
+ lines.push("");
2419
+ lines.push(`Orphans (${result.orphans.length}): ${result.orphans.join(", ")}`);
2420
+ }
2421
+ const errors = result.diagnostics.filter((d) => d.level === "error");
2422
+ const warnings = result.diagnostics.filter((d) => d.level === "warning");
2423
+ if (errors.length > 0 || warnings.length > 0) {
2424
+ lines.push("");
2425
+ lines.push(`Diagnostics: ${errors.length} error(s), ${warnings.length} warning(s)`);
2426
+ for (const d of errors) {
2427
+ lines.push(` error: ${d.message}`);
2428
+ }
2429
+ for (const d of warnings) {
2430
+ lines.push(` warning: ${d.message}`);
2431
+ }
2432
+ }
2433
+ lines.push("");
2434
+ const hasErrors = errors.length > 0 || result.brokenLinks.length > 0;
2435
+ if (hasErrors) {
2436
+ lines.push("Lint failed.");
2437
+ } else {
2438
+ lines.push("Lint passed.");
2439
+ }
2440
+ return lines.join("\n");
2441
+ }
2442
+
2116
2443
  // src/config.ts
2117
2444
  import { readFileSync as readFileSync4, existsSync as existsSync2 } from "fs";
2118
2445
  import { join as join5 } from "path";
@@ -2229,6 +2556,7 @@ var { values, positionals } = parseArgs({
2229
2556
  "archive-twine1": { type: "boolean" },
2230
2557
  "twee2-compat": { type: "boolean" },
2231
2558
  "no-trim": { type: "boolean" },
2559
+ lint: { type: "boolean" },
2232
2560
  test: { type: "boolean", short: "t" },
2233
2561
  watch: { type: "boolean", short: "w" },
2234
2562
  "log-stats": { type: "boolean", short: "l" },
@@ -2297,6 +2625,27 @@ async function main() {
2297
2625
  tagAliases[pair.slice(0, eq)] = pair.slice(eq + 1);
2298
2626
  }
2299
2627
  }
2628
+ if (values.lint) {
2629
+ const lintResult = await lint({
2630
+ sources,
2631
+ formatId: values.format ?? config?.formatId,
2632
+ startPassage: values.start ?? config?.startPassage,
2633
+ formatPaths: config?.formatPaths,
2634
+ modules: values.module ?? config?.modules,
2635
+ headFile: values.head ?? config?.headFile,
2636
+ trim: values["no-trim"] ? false : config?.trim ?? true,
2637
+ twee2Compat: values["twee2-compat"] ?? config?.twee2Compat ?? false,
2638
+ testMode: values.test ?? config?.testMode ?? false,
2639
+ formatIndices: values["format-index"] ?? config?.formatIndices,
2640
+ formatUrls: values["format-url"] ?? config?.formatUrls,
2641
+ noRemote: values["no-remote"] ?? config?.noRemote ?? false,
2642
+ tagAliases,
2643
+ sourceInfo: values["source-info"] ?? config?.sourceInfo ?? false
2644
+ });
2645
+ console.log(formatLintReport(lintResult));
2646
+ const hasErrors = lintResult.brokenLinks.length > 0 || lintResult.diagnostics.some((d) => d.level === "error");
2647
+ process.exit(hasErrors ? 1 : 0);
2648
+ }
2300
2649
  const compileOptions = {
2301
2650
  sources,
2302
2651
  outputMode,
@@ -2429,6 +2778,7 @@ Options:
2429
2778
  --archive-twine1 Output as Twine 1 archive
2430
2779
  --json Output as JSON
2431
2780
  --twee2-compat Enable Twee2 syntax compatibility
2781
+ --lint Lint story structure (broken links, dead ends, orphans)
2432
2782
  --no-trim Don't trim passage whitespace
2433
2783
  -t, --test Enable test/debug mode
2434
2784
  -w, --watch Watch for changes and rebuild