@rohal12/twee-ts 1.15.4 → 1.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from "node:util";
3
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
4
- import { basename, dirname, extname, join, parse, relative, resolve } from "node:path";
3
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
4
+ import * as nodePath from "node:path";
5
+ import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
5
6
  import { randomUUID } from "node:crypto";
6
7
  import { homedir } from "node:os";
7
8
  import { fileURLToPath } from "node:url";
@@ -105,6 +106,61 @@ function commentSanitize(s) {
105
106
  function htmlCommentSanitize(s) {
106
107
  return s.replace(/-->/g, "-- >");
107
108
  }
109
+ /**
110
+ * Escape JavaScript for the content of an HTML `<script>` element, so that the HTML parser reads all of it as
111
+ * the element's text and ends the element at the end tag written after it.
112
+ *
113
+ * - Every `<\/script` (in any letter case) becomes `<\/script`. HTML ends a script element at the first
114
+ * `<\/script` followed by whitespace, `/` or `>`, wherever it is in the JavaScript, even inside a string.
115
+ * - Every `<!--` becomes `<\!--`, but only if the text would otherwise end in the HTML tokenizer's "script
116
+ * data double escaped" state: after a `<!--` and then a `<script` start tag with no `-->` between them, the
117
+ * end tag after the text would not end the element.
118
+ *
119
+ * Inserting a backslash after `<` keeps the value of every string, template, and regular expression literal
120
+ * and every comment where these sequences can be written (`\/` and `\!` are identity escapes; a backslash
121
+ * already before the `<` escapes the `<`, as it did before); `<\/script` is also how minifiers such as Terser
122
+ * write inline scripts. It only changes code that uses them outside literals (`a <\/script/ b`, an HTML-like `<!--`
123
+ * comment that is escaped because of a later `<script`), the raw text of a tagged template, and a `<!--` in a
124
+ * regular expression with the `u` or `v` flag. The result is unchanged by escaping it again.
125
+ */
126
+ function scriptContentEscape(s) {
127
+ const escaped = s.replace(/<(?=\/script)/gi, "<\\");
128
+ return endsDoubleEscaped(escaped) ? escaped.replace(/<(?=!--)/g, "<\\") : escaped;
129
+ }
130
+ /**
131
+ * Whether the HTML tokenizer, reading `s` as script element content with no `<\/script` in it, ends in the
132
+ * "script data double escaped" state, where an end tag after `s` would not end the element.
133
+ * See https://html.spec.whatwg.org/multipage/parsing.html#script-data-escaped-state.
134
+ */
135
+ function endsDoubleEscaped(s) {
136
+ const scriptStartTag = /<script[\t\n\f\r />]/gi;
137
+ let pos = 0;
138
+ for (;;) {
139
+ const open = s.indexOf("<!--", pos);
140
+ if (open === -1) return false;
141
+ const escapedFrom = open + 2;
142
+ const close = s.indexOf("-->", escapedFrom);
143
+ scriptStartTag.lastIndex = escapedFrom;
144
+ const start = scriptStartTag.exec(s);
145
+ if (start === null || close !== -1 && close < start.index) {
146
+ if (close === -1) return false;
147
+ pos = close + 3;
148
+ continue;
149
+ }
150
+ const end = s.indexOf("-->", start.index + start[0].length);
151
+ if (end === -1) return true;
152
+ pos = end + 3;
153
+ }
154
+ }
155
+ /**
156
+ * Escape CSS for the content of an HTML `<style>` element: every `</style` (in any letter case) becomes
157
+ * `<\/style`, since HTML ends a style element at the first `</style` followed by whitespace, `/` or `>`.
158
+ * `\/` stands for `/` in CSS strings and URLs, and comments don't matter; `</style` anywhere else isn't valid
159
+ * CSS anyway.
160
+ */
161
+ function styleContentEscape(s) {
162
+ return s.replace(/<(?=\/style)/gi, "<\\");
163
+ }
108
164
  /** Apply ROT13 encoding to a string (only affects [A-Za-z]). */
109
165
  function rot13(s) {
110
166
  if (s.length === 0) return s;
@@ -603,11 +659,25 @@ function fontFormatHint(ext) {
603
659
  * Ported from filesystem.go.
604
660
  */
605
661
  /**
662
+ * Whether `filename` matches one of the `exclude` globs. The globs are matched
663
+ * against the file's path relative to the working directory, the path
664
+ * getFilenames reports; an absolute `filename` is made relative first. A leading
665
+ * `./` in a glob is dropped. Matching is Node's `path.matchesGlob`.
666
+ */
667
+ function isExcluded(filename, exclude) {
668
+ if (exclude.length === 0) return false;
669
+ const { matchesGlob } = nodePath;
670
+ if (typeof matchesGlob !== "function") throw new Error(`The exclude option needs Node.js 22.5 or newer (found ${process.version}).`);
671
+ const rel = relative(process.cwd(), resolve(filename)) || filename;
672
+ return exclude.some((pattern) => matchesGlob(rel, pattern.replace(/^\.\//, "")));
673
+ }
674
+ /**
606
675
  * Recursively walk directories, collecting regular file paths.
607
- * Filters out the output file to prevent circular compilation.
676
+ * Filters out the output file to prevent circular compilation, and the files
677
+ * that match an `exclude` glob (see isExcluded).
608
678
  * Like Tweego, a path that cannot be read is reported as a warning and skipped.
609
679
  */
610
- function getFilenames(pathnames, outFilename) {
680
+ function getFilenames(pathnames, outFilename, exclude = []) {
611
681
  const filenames = [];
612
682
  const diagnostics = [];
613
683
  const absOutFile = outFilename ? resolve(outFilename) : "";
@@ -629,6 +699,7 @@ function getFilenames(pathnames, outFilename) {
629
699
  const abs = resolve(pathname);
630
700
  if (abs === absOutFile) return;
631
701
  const rel = relative(process.cwd(), abs);
702
+ if (isExcluded(rel || abs, exclude)) return;
632
703
  filenames.push(rel || abs);
633
704
  } else if (stat.isDirectory()) {
634
705
  let entries;
@@ -647,11 +718,38 @@ function getFilenames(pathnames, outFilename) {
647
718
  diagnostics
648
719
  };
649
720
  }
721
+ /** Whether the absolute path `file` is the absolute path `dir` or inside it. */
722
+ function isInsideDir(file, dir) {
723
+ return file === dir || file.startsWith(dir.endsWith(sep) ? dir : dir + sep);
724
+ }
725
+ /**
726
+ * The watched paths split into folders (as given) and files (absolute). A path
727
+ * that cannot be read counts as a folder, which fs.watch then fails to watch.
728
+ */
729
+ function splitWatchRoots(pathnames) {
730
+ const dirs = [];
731
+ const files = /* @__PURE__ */ new Set();
732
+ for (const pathname of pathnames) {
733
+ let isFile = false;
734
+ try {
735
+ isFile = statSync(pathname).isFile();
736
+ } catch {}
737
+ if (isFile) files.add(resolve(pathname));
738
+ else dirs.push(pathname);
739
+ }
740
+ return {
741
+ dirs,
742
+ files
743
+ };
744
+ }
650
745
  /**
651
746
  * Watch paths for changes, calling the build callback on known file type changes.
747
+ * A folder is watched recursively; a file is watched on its own and counts
748
+ * whatever its type (a head file, say). A change to a file `ignore` returns true
749
+ * for (given the path relative to the working directory) schedules no build.
652
750
  * Uses debouncing to avoid rapid rebuilds.
653
751
  */
654
- function watchFilesystem(pathnames, outFilename, callback) {
752
+ function watchFilesystem(pathnames, outFilename, callback, ignore = () => false) {
655
753
  const absOutFile = resolve(outFilename);
656
754
  const watchers = [];
657
755
  let buildTimer = null;
@@ -667,15 +765,30 @@ function watchFilesystem(pathnames, outFilename, callback) {
667
765
  callback(files);
668
766
  }, BUILD_DEBOUNCE);
669
767
  }
670
- for (const pathname of pathnames) try {
671
- const watcher = watch(pathname, { recursive: true }, (_event, filename) => {
672
- if (!filename) return;
673
- const abs = resolve(pathname, filename);
674
- if (abs === absOutFile) return;
675
- if (isKnownFileType(filename)) scheduleBuild(relative(process.cwd(), abs) || abs);
676
- });
677
- watchers.push(watcher);
678
- } catch {}
768
+ function fileChanged(abs, named) {
769
+ if (abs === absOutFile) return;
770
+ if (!named && !isKnownFileType(abs)) return;
771
+ const rel = relative(process.cwd(), abs);
772
+ if (!ignore(rel || abs)) scheduleBuild(rel || abs);
773
+ }
774
+ function watchPath(pathname, recursive, listener) {
775
+ try {
776
+ watchers.push(watch(pathname, { recursive }, (_event, filename) => {
777
+ if (filename) listener(filename);
778
+ }));
779
+ } catch {}
780
+ }
781
+ const { dirs, files } = splitWatchRoots(pathnames);
782
+ const absDirs = dirs.map((dir) => resolve(dir));
783
+ for (const dir of dirs) watchPath(dir, true, (filename) => {
784
+ const abs = resolve(dir, filename);
785
+ fileChanged(abs, files.has(abs));
786
+ });
787
+ const fileDirs = new Set([...files].filter((file) => !absDirs.some((dir) => isInsideDir(file, dir))).map((file) => dirname(file)));
788
+ for (const dir of fileDirs) watchPath(dir, false, (filename) => {
789
+ const abs = resolve(dir, filename);
790
+ if (files.has(abs)) fileChanged(abs, true);
791
+ });
679
792
  callback();
680
793
  return { close() {
681
794
  if (buildTimer) clearTimeout(buildTimer);
@@ -683,16 +796,25 @@ function watchFilesystem(pathnames, outFilename, callback) {
683
796
  } };
684
797
  }
685
798
  //#endregion
799
+ //#region src/source-text.ts
800
+ /**
801
+ * Source text normalization shared by every input path (files, in-memory sources, `parseTwee`).
802
+ */
803
+ /**
804
+ * Strip a leading UTF-8 byte order mark and convert CRLF and bare CR line endings to LF.
805
+ * Idempotent, so text that is already normalized passes through unchanged.
806
+ */
807
+ function normalizeSourceText(text) {
808
+ return (text.charCodeAt(0) === 65279 ? text.slice(1) : text).replace(/\r\n?/g, "\n");
809
+ }
810
+ //#endregion
686
811
  //#region src/util.ts
687
812
  /**
688
813
  * Shared file I/O utilities.
689
814
  */
690
815
  /** Read a file as UTF-8 with BOM stripping and line ending normalization. */
691
816
  function readUTF8(filename) {
692
- let content = readFileSync(filename, "utf-8");
693
- if (content.charCodeAt(0) === 65279) content = content.slice(1);
694
- content = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
695
- return content;
817
+ return normalizeSourceText(readFileSync(filename, "utf-8"));
696
818
  }
697
819
  /** Read a file as base64. */
698
820
  function readBase64(filename) {
@@ -722,16 +844,28 @@ function relaxedJSONParse(json) {
722
844
  fixed = fixed.replace(/,\s*([}\]])/g, "$1");
723
845
  return JSON.parse(fixed);
724
846
  }
847
+ /** A `"setup": function` property, which Harlowe appends to its otherwise-JSON format object. */
848
+ const SETUP_FUNCTION_PROPERTY = /,\s*"setup"\s*:\s*function\b/g;
849
+ /** Drop a trailing `"setup": function(){…}` property from a format object, if it has one. */
850
+ function stripSetupFunction(chunk) {
851
+ let lastIndex = -1;
852
+ for (const match of chunk.matchAll(SETUP_FUNCTION_PROPERTY)) lastIndex = match.index;
853
+ return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
854
+ }
725
855
  /**
726
856
  * Parse the Twine 2 format.js JSON chunk.
727
- * Handles Harlowe's malformed JSON by stripping the "setup" function property.
857
+ * Handles Harlowe's malformed JSON by stripping the "setup" function property. The workaround
858
+ * keys on the property itself, so the same bytes parse whether they come from a `harlowe-3`
859
+ * directory or a direct download.
728
860
  * Per spec, the name key is Optional.
861
+ *
862
+ * @param _formatId No longer used; kept so existing callers keep compiling.
729
863
  */
730
- function parseFormatJSON(source, formatId) {
864
+ function parseFormatJSON(source, _formatId) {
731
865
  const first = source.indexOf("{");
732
866
  const last = source.lastIndexOf("}");
733
867
  if (first === -1 || last === -1) return null;
734
- let chunk = source.slice(first, last + 1);
868
+ const chunk = source.slice(first, last + 1);
735
869
  const parse = (json) => {
736
870
  const raw = relaxedJSONParse(json);
737
871
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
@@ -754,16 +888,13 @@ function parseFormatJSON(source, formatId) {
754
888
  try {
755
889
  return parse(chunk);
756
890
  } catch {
757
- if (formatId.toLowerCase().startsWith("harlowe")) {
758
- const setupIdx = chunk.lastIndexOf(",\"setup\": function");
759
- if (setupIdx !== -1) {
760
- chunk = chunk.slice(0, setupIdx) + "}";
761
- try {
762
- return parse(chunk);
763
- } catch {}
764
- }
891
+ const stripped = stripSetupFunction(chunk);
892
+ if (stripped === void 0) return null;
893
+ try {
894
+ return parse(stripped);
895
+ } catch {
896
+ return null;
765
897
  }
766
- return null;
767
898
  }
768
899
  }
769
900
  /**
@@ -913,6 +1044,54 @@ function semverCompare(a, b) {
913
1044
  if (aPatch !== bPatch) return aPatch - bPatch;
914
1045
  return 0;
915
1046
  }
1047
+ /** Build a directory-style format ID from a name and version, e.g. ('SugarCube', '2.37.3') → 'sugarcube-2'. */
1048
+ function makeFormatId(name, version) {
1049
+ const major = parseSemver(version)?.[0] ?? 0;
1050
+ return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
1051
+ }
1052
+ /** Describe a format request for diagnostics. */
1053
+ function describeFormatRequest(request) {
1054
+ switch (request.kind) {
1055
+ case "id": return `"${request.id}"`;
1056
+ case "name": return request.version ? `"${request.name}" at version "${request.version}"` : `"${request.name}"`;
1057
+ default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
1058
+ }
1059
+ }
1060
+ /**
1061
+ * Pick the candidate that best answers a format request.
1062
+ *
1063
+ * - An ID request ('sugarcube-2') matches a candidate whose name and major version build that ID,
1064
+ * and takes the greatest such version.
1065
+ * - A name request matches the name case-insensitively. An exact version wins outright; otherwise
1066
+ * the greatest version with the same major that is not older than the one asked for (or, with
1067
+ * `allowOlder`, the greatest same-major version). An unparseable requested version takes the
1068
+ * greatest version of any major.
1069
+ */
1070
+ function selectFormatCandidate(request, candidates, describe, options = {}) {
1071
+ const allowOlder = options.allowOlder ?? false;
1072
+ const wanted = request.kind === "name" ? parseSemver(request.version) : null;
1073
+ let best;
1074
+ for (const candidate of candidates) {
1075
+ const { name, version } = describe(candidate);
1076
+ const have = parseSemver(version);
1077
+ if (!have) continue;
1078
+ if (request.kind === "id") {
1079
+ if (makeFormatId(name, version) !== request.id.toLowerCase()) continue;
1080
+ } else {
1081
+ if (name.toLowerCase() !== request.name.toLowerCase()) continue;
1082
+ if (wanted) {
1083
+ if (semverCompare(have, wanted) === 0) return candidate;
1084
+ if (have[0] !== wanted[0]) continue;
1085
+ if (!allowOlder && semverCompare(have, wanted) < 0) continue;
1086
+ }
1087
+ }
1088
+ if (!best || semverCompare(have, best.version) > 0) best = {
1089
+ candidate,
1090
+ version: have
1091
+ };
1092
+ }
1093
+ return best?.candidate;
1094
+ }
916
1095
  /** Get format ID from Twine 2 name and version, using SemVer major matching. */
917
1096
  function getFormatIdByNameAndVersion(formats, name, version) {
918
1097
  const wanted = parseSemver(version);
@@ -939,7 +1118,407 @@ function readFormatSource(format) {
939
1118
  if (!data) throw new Error(`Cannot parse format ${format.id} JSON`);
940
1119
  return data.source;
941
1120
  }
942
- return source;
1121
+ return source;
1122
+ }
1123
+ //#endregion
1124
+ //#region src/remote-formats.ts
1125
+ /**
1126
+ * Remote story format fetching, caching, and checksum verification.
1127
+ * Uses the Story Formats Archive (SFA) as the default source.
1128
+ */
1129
+ const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
1130
+ /** Get the cache directory for downloaded story formats. */
1131
+ function getCacheDir() {
1132
+ const base = process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache");
1133
+ return join(base, "twee-ts", "storyformats");
1134
+ }
1135
+ /**
1136
+ * Whether a string can serve as one directory name inside the cache: not empty, not `.` or `..`,
1137
+ * and free of path separators. Format names and versions come from downloaded metadata, so they
1138
+ * are untrusted path input.
1139
+ */
1140
+ function isSafeSegment(segment) {
1141
+ return segment !== "" && segment !== "." && segment !== ".." && !/[/\\\0]/.test(segment);
1142
+ }
1143
+ /** Whether absolute path `target` lies strictly inside absolute path `root`. */
1144
+ function isInside(root, target) {
1145
+ const rel = relative(root, target);
1146
+ return rel !== "" && !isAbsolute(rel) && rel.split(sep)[0] !== "..";
1147
+ }
1148
+ /** The cache directory for one format version. Throws when the name or version could leave the cache. */
1149
+ function cachedFormatDir(name, version) {
1150
+ if (!isSafeSegment(name)) throw new Error(`Refusing to cache a story format with an unsafe name: ${JSON.stringify(name)}`);
1151
+ if (!isSafeSegment(version) || !parseSemver(version)) throw new Error(`Refusing to cache story format "${name}" with an unsafe version: ${JSON.stringify(version)}`);
1152
+ const root = resolve(getCacheDir());
1153
+ const dir = resolve(root, name, version);
1154
+ if (!isInside(root, dir)) throw new Error(`Refusing to cache a story format outside the cache directory ${root}: ${dir}`);
1155
+ return dir;
1156
+ }
1157
+ /** Create `dir` as a plain directory, or check that it already is one (a symlink could lead out of the cache). */
1158
+ function ensurePlainDirectory(dir) {
1159
+ const stat = lstatSync(dir, { throwIfNoEntry: false });
1160
+ if (stat === void 0) {
1161
+ mkdirSync(dir);
1162
+ return;
1163
+ }
1164
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`Refusing to write a story format outside the cache directory: ${dir} is not a plain directory`);
1165
+ }
1166
+ /** Write a downloaded format.js to `<cache>/<name>/<version>/format.js` and return its path. */
1167
+ function writeCachedFormat(name, version, text) {
1168
+ const dir = cachedFormatDir(name, version);
1169
+ const root = resolve(getCacheDir());
1170
+ mkdirSync(root, { recursive: true });
1171
+ ensurePlainDirectory(join(root, name));
1172
+ ensurePlainDirectory(dir);
1173
+ const formatPath = join(dir, "format.js");
1174
+ if (lstatSync(formatPath, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error(`Refusing to write a story format outside the cache directory: ${formatPath} is a symlink`);
1175
+ writeFileSync(formatPath, text, "utf-8");
1176
+ return formatPath;
1177
+ }
1178
+ /** In-memory index cache, keyed by URL. Cleared each compile. */
1179
+ const indexCache = /* @__PURE__ */ new Map();
1180
+ /** Clear the in-memory index cache. */
1181
+ function clearIndexCache() {
1182
+ indexCache.clear();
1183
+ }
1184
+ /** Validate that a JSON value is a valid SFAIndexEntry. */
1185
+ function isValidEntry(val) {
1186
+ if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
1187
+ const obj = val;
1188
+ return typeof obj.name === "string" && typeof obj.version === "string" && typeof obj.checksums === "object" && obj.checksums !== null && !Array.isArray(obj.checksums);
1189
+ }
1190
+ /** Validate that a JSON value conforms to the SFAIndex shape. */
1191
+ function validateSFAIndex(json) {
1192
+ if (typeof json !== "object" || json === null) throw new Error("SFA index is not an object");
1193
+ const obj = json;
1194
+ return {
1195
+ twine1: Array.isArray(obj["twine1"]) ? obj["twine1"].filter(isValidEntry) : [],
1196
+ twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
1197
+ };
1198
+ }
1199
+ /** Fetch and parse an SFA index.json, with in-memory caching. */
1200
+ async function fetchIndex(url) {
1201
+ const cached = indexCache.get(url);
1202
+ if (cached) return cached;
1203
+ const res = await fetch(url);
1204
+ if (!res.ok) throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
1205
+ const data = validateSFAIndex(await res.json());
1206
+ indexCache.set(url, data);
1207
+ return data;
1208
+ }
1209
+ /** Find the best matching entry in an SFA index for a name or ID request (twine2 entries first). */
1210
+ function findEntryForRequest(index, request) {
1211
+ return selectFormatCandidate(request, [...(index.twine2 ?? []).map((entry) => ({
1212
+ entry,
1213
+ formatType: "twine2"
1214
+ })), ...(index.twine1 ?? []).map((entry) => ({
1215
+ entry,
1216
+ formatType: "twine1"
1217
+ }))], (c) => c.entry);
1218
+ }
1219
+ /** Verify SHA-256 checksum using Web Crypto API (Node 22 built-in). */
1220
+ async function verifySHA256(content, expectedHex) {
1221
+ const digest = await crypto.subtle.digest("SHA-256", content);
1222
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("") === expectedHex.toLowerCase();
1223
+ }
1224
+ /** Derive the download URL for a format.js from the index URL and entry. */
1225
+ function getDownloadUrl(indexUrl, entry, formatType) {
1226
+ return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
1227
+ }
1228
+ /** Download a format, verify its checksum, write to cache, and return StoryFormatInfo. */
1229
+ async function fetchAndCacheFormat(entry, downloadUrl) {
1230
+ cachedFormatDir(entry.name, entry.version);
1231
+ const res = await fetch(downloadUrl);
1232
+ if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
1233
+ const text = await res.text();
1234
+ const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
1235
+ if (checksumKey) {
1236
+ const expected = entry.checksums[checksumKey];
1237
+ if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
1238
+ if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
1239
+ }
1240
+ const id = makeFormatId(entry.name, entry.version);
1241
+ const data = parseFormatJSON(text, id);
1242
+ if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
1243
+ return {
1244
+ id,
1245
+ filename: writeCachedFormat(entry.name, entry.version, text),
1246
+ isTwine2: true,
1247
+ name: data.name,
1248
+ version: data.version,
1249
+ proofing: data.proofing ?? false
1250
+ };
1251
+ }
1252
+ /** Download a direct format.js URL, parse its JSON, cache it, and return StoryFormatInfo. */
1253
+ async function fetchDirectFormat(url) {
1254
+ const res = await fetch(url);
1255
+ if (!res.ok) throw new Error(`Failed to download format from ${url}: ${res.status} ${res.statusText}`);
1256
+ const text = await res.text();
1257
+ const data = parseFormatJSON(text);
1258
+ if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
1259
+ return {
1260
+ id: makeFormatId(data.name, data.version),
1261
+ filename: writeCachedFormat(data.name, data.version, text),
1262
+ isTwine2: true,
1263
+ name: data.name,
1264
+ version: data.version,
1265
+ proofing: data.proofing ?? false
1266
+ };
1267
+ }
1268
+ /**
1269
+ * Resolve a story format request remotely, in the same order as {@link resolveRemoteFormat}.
1270
+ * An ID request such as 'sugarcube-2' matches the format whose name and major version build that ID
1271
+ * (SugarCube 2.x), taking the greatest version available.
1272
+ */
1273
+ async function resolveRemoteFormatRequest(request, indices, urls) {
1274
+ let lastError;
1275
+ const cached = findCachedFormat(request);
1276
+ if (cached && request.kind === "name" && isExactVersion(cached.version, request.version)) return cached;
1277
+ for (const url of urls ?? []) try {
1278
+ const info = await fetchDirectFormat(url);
1279
+ if (selectFormatCandidate(request, [info], (f) => f)) return info;
1280
+ } catch (e) {
1281
+ lastError = e instanceof Error ? e : new Error(String(e));
1282
+ }
1283
+ const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
1284
+ for (const indexUrl of allIndices) try {
1285
+ const result = findEntryForRequest(await fetchIndex(indexUrl), request);
1286
+ if (result) {
1287
+ const hit = getCachedFormat(result.entry.name, result.entry.version);
1288
+ if (hit) return hit;
1289
+ const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
1290
+ return await fetchAndCacheFormat(result.entry, downloadUrl);
1291
+ }
1292
+ } catch (e) {
1293
+ lastError = e instanceof Error ? e : new Error(String(e));
1294
+ }
1295
+ if (cached) return cached;
1296
+ if (lastError) throw lastError;
1297
+ }
1298
+ /** Whether two version strings name the same SemVer version. */
1299
+ function isExactVersion(have, wanted) {
1300
+ const a = parseSemver(have);
1301
+ const b = parseSemver(wanted);
1302
+ return a !== null && b !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
1303
+ }
1304
+ /**
1305
+ * Find the cached download that best answers a format request, without network access.
1306
+ * Matching follows {@link selectFormatCandidate}.
1307
+ */
1308
+ function findCachedFormat(request, options = {}) {
1309
+ return selectFormatCandidate(request, [...discoverCachedFormats().values()], (f) => f, options);
1310
+ }
1311
+ /** Check if a format is already in the local cache. */
1312
+ function getCachedFormat(name, version) {
1313
+ if (!isSafeSegment(name) || !isSafeSegment(version)) return void 0;
1314
+ const formatPath = join(getCacheDir(), name, version, "format.js");
1315
+ try {
1316
+ if (!existsSync(formatPath)) return void 0;
1317
+ const source = readFileSync(formatPath, "utf-8");
1318
+ const id = makeFormatId(name, version);
1319
+ const data = parseFormatJSON(source, id);
1320
+ if (!data) return void 0;
1321
+ return {
1322
+ id,
1323
+ filename: formatPath,
1324
+ isTwine2: true,
1325
+ name: data.name,
1326
+ version: data.version,
1327
+ proofing: data.proofing ?? false
1328
+ };
1329
+ } catch {
1330
+ return;
1331
+ }
1332
+ }
1333
+ /** Discover all cached remote formats. Returns a Map like discoverFormats(). */
1334
+ function discoverCachedFormats() {
1335
+ const formats = /* @__PURE__ */ new Map();
1336
+ const cacheDir = getCacheDir();
1337
+ try {
1338
+ if (!existsSync(cacheDir)) return formats;
1339
+ } catch {
1340
+ return formats;
1341
+ }
1342
+ let names;
1343
+ try {
1344
+ names = readdirSync(cacheDir);
1345
+ } catch {
1346
+ return formats;
1347
+ }
1348
+ for (const name of names) {
1349
+ const nameDir = join(cacheDir, name);
1350
+ try {
1351
+ if (!statSync(nameDir).isDirectory()) continue;
1352
+ } catch {
1353
+ continue;
1354
+ }
1355
+ let versions;
1356
+ try {
1357
+ versions = readdirSync(nameDir);
1358
+ } catch {
1359
+ continue;
1360
+ }
1361
+ for (const version of versions) {
1362
+ const info = getCachedFormat(name, version);
1363
+ if (info) formats.set(`${info.id}-${version}`, info);
1364
+ }
1365
+ }
1366
+ return formats;
1367
+ }
1368
+ /** List all cached formats with size and modification date. */
1369
+ function listCachedFormats() {
1370
+ const cacheDir = getCacheDir();
1371
+ const entries = [];
1372
+ try {
1373
+ if (!existsSync(cacheDir)) return entries;
1374
+ } catch {
1375
+ return entries;
1376
+ }
1377
+ let names;
1378
+ try {
1379
+ names = readdirSync(cacheDir);
1380
+ } catch {
1381
+ return entries;
1382
+ }
1383
+ for (const name of names) {
1384
+ const nameDir = join(cacheDir, name);
1385
+ try {
1386
+ if (!statSync(nameDir).isDirectory()) continue;
1387
+ } catch {
1388
+ continue;
1389
+ }
1390
+ let versions;
1391
+ try {
1392
+ versions = readdirSync(nameDir);
1393
+ } catch {
1394
+ continue;
1395
+ }
1396
+ for (const version of versions) {
1397
+ const formatPath = join(nameDir, version, "format.js");
1398
+ try {
1399
+ const stat = statSync(formatPath);
1400
+ entries.push({
1401
+ name,
1402
+ version,
1403
+ sizeBytes: stat.size,
1404
+ modifiedAt: stat.mtime
1405
+ });
1406
+ } catch {}
1407
+ }
1408
+ }
1409
+ return entries;
1410
+ }
1411
+ /**
1412
+ * Clear all cached formats, or only those matching a given name. Returns the number of entries removed.
1413
+ * A name must be a single cache entry name (as `listCachedFormats()` reports it); anything with a
1414
+ * path separator or `.`/`..` throws instead of deleting outside the format's own directory.
1415
+ */
1416
+ function clearCachedFormats(name) {
1417
+ if (name && !isSafeSegment(name)) throw new Error(`Refusing to clear ${JSON.stringify(name)}: it is not a cached format name. Use a name as "cache list" shows it.`);
1418
+ const cacheDir = getCacheDir();
1419
+ if (!existsSync(cacheDir)) return 0;
1420
+ if (!name) {
1421
+ const count = listCachedFormats().length;
1422
+ rmSync(cacheDir, {
1423
+ recursive: true,
1424
+ force: true
1425
+ });
1426
+ return count;
1427
+ }
1428
+ const root = resolve(cacheDir);
1429
+ const nameDir = resolve(root, name);
1430
+ if (!isInside(root, nameDir)) throw new Error(`Refusing to clear ${JSON.stringify(name)}: it is not a cached format name.`);
1431
+ if (!lstatSync(nameDir, { throwIfNoEntry: false })?.isDirectory()) return 0;
1432
+ let versions;
1433
+ try {
1434
+ versions = readdirSync(nameDir);
1435
+ } catch {
1436
+ return 0;
1437
+ }
1438
+ const count = versions.length;
1439
+ rmSync(nameDir, {
1440
+ recursive: true,
1441
+ force: true
1442
+ });
1443
+ return count;
1444
+ }
1445
+ /** Get total cache size in bytes and format count. */
1446
+ function getCacheSize() {
1447
+ const entries = listCachedFormats();
1448
+ return {
1449
+ totalBytes: entries.reduce((sum, e) => sum + e.sizeBytes, 0),
1450
+ count: entries.length
1451
+ };
1452
+ }
1453
+ //#endregion
1454
+ //#region src/format-resolution.ts
1455
+ /** Choose the format request: explicit format ID > StoryData format > default ID. */
1456
+ function formatRequestFor(formatId, storyFormat, storyFormatVersion, defaultId) {
1457
+ if (formatId) return {
1458
+ kind: "id",
1459
+ id: formatId
1460
+ };
1461
+ if (storyFormat) return {
1462
+ kind: "name",
1463
+ name: storyFormat,
1464
+ version: storyFormatVersion
1465
+ };
1466
+ return {
1467
+ kind: "id",
1468
+ id: defaultId
1469
+ };
1470
+ }
1471
+ /** Look a request up among the formats found in local format directories. */
1472
+ function findLocalFormat(formats, request) {
1473
+ switch (request.kind) {
1474
+ case "id": return formats.get(request.id);
1475
+ case "name": {
1476
+ const id = getFormatIdByNameAndVersion(formats, request.name, request.version);
1477
+ return id === void 0 ? void 0 : formats.get(id);
1478
+ }
1479
+ default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
1480
+ }
1481
+ }
1482
+ /**
1483
+ * Resolve a format request to a story format.
1484
+ *
1485
+ * Order: local format directories, then the download cache, then remote sources (unless
1486
+ * `noRemote`). When a StoryData request names a version that none of them has, an older version of
1487
+ * the same format and major version is used with a warning. Anything else is an error diagnostic,
1488
+ * and the result is `undefined`.
1489
+ */
1490
+ async function resolveStoryFormat(request, options, diagnostics) {
1491
+ const noRemote = options.noRemote ?? false;
1492
+ const formats = discoverFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true));
1493
+ const wanted = describeFormatRequest(request);
1494
+ const local = findLocalFormat(formats, request);
1495
+ if (local) return local;
1496
+ const cached = findCachedFormat(request);
1497
+ if (cached) return cached;
1498
+ if (!noRemote) try {
1499
+ const remote = await resolveRemoteFormatRequest(request, options.formatIndices, options.formatUrls);
1500
+ if (remote) return remote;
1501
+ } catch (e) {
1502
+ diagnostics.push({
1503
+ level: "warning",
1504
+ message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
1505
+ });
1506
+ }
1507
+ if (request.kind === "name") {
1508
+ const older = selectFormatCandidate(request, [...formats.values()].filter((f) => f.isTwine2), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
1509
+ if (older) {
1510
+ diagnostics.push({
1511
+ level: "warning",
1512
+ message: `Story format ${wanted} is not available; using ${older.name} ${older.version} instead.`
1513
+ });
1514
+ return older;
1515
+ }
1516
+ }
1517
+ const reason = noRemote ? " (remote fetching disabled)" : "";
1518
+ diagnostics.push({
1519
+ level: "error",
1520
+ message: `Story format ${wanted} is not available${reason}. Found: ${[...formats.keys()].join(", ") || "none"}`
1521
+ });
943
1522
  }
944
1523
  //#endregion
945
1524
  //#region src/types.ts
@@ -1205,16 +1784,20 @@ function twee2ToV3(s) {
1205
1784
  //#region src/parser.ts
1206
1785
  /**
1207
1786
  * Parse Twee source text into passages.
1787
+ *
1788
+ * The source is normalized first, as files are when they are read: a leading UTF-8 BOM is
1789
+ * removed and CRLF and bare CR line endings become LF.
1208
1790
  */
1209
1791
  function parseTwee(source, options = {}) {
1210
1792
  const { filename = "<inline>", trim = true, twee2Compat = false } = options;
1211
1793
  const diagnostics = [];
1212
- if (twee2Compat) source = twee2ToV3(source);
1794
+ const normalized = normalizeSourceText(source);
1795
+ const tweeSource = twee2Compat ? twee2ToV3(normalized) : normalized;
1213
1796
  const passages = [];
1214
1797
  let current = null;
1215
1798
  let pCount = 0;
1216
1799
  let lastType = ItemType.EOF;
1217
- for (const item of tweeLexer(source)) {
1800
+ for (const item of tweeLexer(tweeSource)) {
1218
1801
  switch (item.type) {
1219
1802
  case ItemType.Error:
1220
1803
  diagnostics.push({
@@ -4095,6 +4678,7 @@ function decompileTwine2(storyData, story, diagnostics) {
4095
4678
  });
4096
4679
  else story.twine2.zoom = parsed;
4097
4680
  }
4681
+ if (attrs["tags"]) story.twine2.tags = attrs["tags"];
4098
4682
  if (attrs["format"]) story.twine2.format = attrs["format"];
4099
4683
  if (attrs["format-version"]) story.twine2.formatVersion = attrs["format-version"];
4100
4684
  if (attrs["options"]) for (const opt of attrs["options"].split(/\s+/).filter((s) => s.length > 0)) story.twine2.options.set(opt, true);
@@ -4154,22 +4738,32 @@ function decompileTwine2(storyData, story, diagnostics) {
4154
4738
  }, diagnostics);
4155
4739
  }
4156
4740
  function decompileTwine1(storeArea, story, diagnostics) {
4157
- for (const node of storeArea.children ?? []) {
4158
- if (node.type !== "tag" || node.name !== "div") continue;
4159
- const nodeAttrs = node.attribs ?? {};
4160
- if (!("tiddler" in nodeAttrs)) continue;
4161
- const name = nodeAttrs["tiddler"] ?? "";
4162
- const tags = nodeAttrs["tags"] ? nodeAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [];
4163
- const metadata = {};
4164
- if (nodeAttrs["twine-position"]) metadata.position = nodeAttrs["twine-position"];
4165
- const passage = {
4166
- name,
4167
- tags,
4168
- text: tiddlerUnescape(getTextContent(node)).trim()
4169
- };
4170
- if (metadata.position) passage.metadata = metadata;
4171
- storyAdd(story, passage, diagnostics);
4172
- }
4741
+ const passages = (storeArea.children ?? []).filter(isTiddler).map(tiddlerToPassage);
4742
+ const obfuscated = isRot13Obfuscated(passages);
4743
+ for (const p of passages) storyAdd(story, obfuscated && p.name !== "StorySettings" ? {
4744
+ ...p,
4745
+ text: rot13(p.text)
4746
+ } : p, diagnostics);
4747
+ }
4748
+ function isTiddler(node) {
4749
+ return node.type === "tag" && node.name === "div" && node.attribs !== void 0 && "tiddler" in node.attribs;
4750
+ }
4751
+ function tiddlerToPassage(node) {
4752
+ const nodeAttrs = node.attribs ?? {};
4753
+ const passage = {
4754
+ name: nodeAttrs["tiddler"] ?? "",
4755
+ tags: nodeAttrs["tags"] ? nodeAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [],
4756
+ text: tiddlerUnescape(getTextContent(node)).trim()
4757
+ };
4758
+ const position = nodeAttrs["twine-position"];
4759
+ if (position) passage.metadata = { position };
4760
+ return passage;
4761
+ }
4762
+ /** Whether the StorySettings tiddlers turn on ROT13 obfuscation, read the way the writer reads them. */
4763
+ function isRot13Obfuscated(passages) {
4764
+ const settings = createStory();
4765
+ for (const p of passages) if (p.name === "StorySettings") unmarshalStorySettings(settings, p.text, []);
4766
+ return settings.twine1.settings.get("obfuscate") === "rot13";
4173
4767
  }
4174
4768
  function findElement(node, tagName) {
4175
4769
  if (node.type === "tag" && node.name === tagName) return node;
@@ -4292,7 +4886,7 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
4292
4886
  function loadInlineSources(story, sources, opts, diagnostics) {
4293
4887
  for (const source of sources) {
4294
4888
  if (typeof source === "string") continue;
4295
- const content = typeof source.content === "string" ? source.content : source.content.toString("utf-8");
4889
+ const content = normalizeSourceText(typeof source.content === "string" ? source.content : source.content.toString("utf-8"));
4296
4890
  const ext = normalizedFileExt(source.filename);
4297
4891
  switch (ext) {
4298
4892
  case "":
@@ -4379,12 +4973,29 @@ function parseFontFile(filename) {
4379
4973
  };
4380
4974
  }
4381
4975
  function parseHTMLFile(filename) {
4382
- const result = decompileHTML(readUTF8(filename));
4976
+ const { story, diagnostics } = decompileHTML(readUTF8(filename));
4383
4977
  return {
4384
- passages: result.story.passages,
4385
- diagnostics: [...result.diagnostics]
4978
+ passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
4979
+ name: "StoryTitle",
4980
+ tags: [],
4981
+ text: story.name
4982
+ }, ...story.passages] : story.passages,
4983
+ diagnostics: [...diagnostics]
4386
4984
  };
4387
4985
  }
4986
+ /**
4987
+ * 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.
4989
+ */
4990
+ function parseOptionsKey(filename, opts) {
4991
+ const ext = normalizedFileExt(filename);
4992
+ const twee2File = ext === "tw2" || ext === "twee2";
4993
+ if (!twee2File && ext !== "tw" && ext !== "twee") return "";
4994
+ return JSON.stringify({
4995
+ trim: opts.trim ?? true,
4996
+ twee2Compat: twee2File || (opts.twee2Compat ?? false)
4997
+ });
4998
+ }
4388
4999
  function parseFile(filename, opts) {
4389
5000
  switch (normalizedFileExt(filename)) {
4390
5001
  case "tw":
@@ -4464,7 +5075,9 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
4464
5075
  continue;
4465
5076
  }
4466
5077
  currentFiles.add(filename);
4467
- const cached = cache.get(filename);
5078
+ const optionsKey = parseOptionsKey(filename, opts);
5079
+ const entry = cache.get(filename);
5080
+ const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
4468
5081
  if (changedFiles && cached && !changedFiles.has(filename)) {
4469
5082
  diagnostics.push(...cached.diagnostics);
4470
5083
  for (const p of cached.passages) storyAdd(story, p, diagnostics);
@@ -4488,6 +5101,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
4488
5101
  if (!result) continue;
4489
5102
  cache.set(filename, {
4490
5103
  mtimeMs,
5104
+ parseOptionsKey: optionsKey,
4491
5105
  passages: result.passages,
4492
5106
  diagnostics: result.diagnostics
4493
5107
  });
@@ -4551,11 +5165,39 @@ function toTwine2HTML(story, format, startName, options) {
4551
5165
  template = template.replaceAll("{{STORY_NAME}}", () => name);
4552
5166
  }
4553
5167
  if (template.includes("{{STORY_DATA}}")) {
4554
- const data = getTwine2DataChunk(story, startName, options);
5168
+ const data = getTwine2DataChunk({
5169
+ ...story,
5170
+ twine2: {
5171
+ ...story.twine2,
5172
+ format: format.name,
5173
+ formatVersion: format.version
5174
+ }
5175
+ }, startName, options);
4555
5176
  template = template.replace("{{STORY_DATA}}", () => data);
4556
5177
  }
4557
5178
  return template;
4558
5179
  }
5180
+ const OMITTING_TAGS = [
5181
+ "Twine.private",
5182
+ "script",
5183
+ "stylesheet"
5184
+ ];
5185
+ /**
5186
+ * Why Twine 2 output leaves a passage out of its `<tw-passagedata>` elements,
5187
+ * or `undefined` when the passage is emitted (and so can be the starting passage).
5188
+ */
5189
+ function twine2PassageOmission(story, p) {
5190
+ if (p.name === "StoryTitle" || p.name === "StoryData") return {
5191
+ kind: "special-name",
5192
+ name: p.name
5193
+ };
5194
+ const tag = OMITTING_TAGS.find((t) => hasTag(p, t));
5195
+ if (tag !== void 0) return {
5196
+ kind: "tag",
5197
+ tag
5198
+ };
5199
+ if (p.name === "StorySettings" && story.twine1.settings.size === 0) return { kind: "empty-story-settings" };
5200
+ }
4559
5201
  function getTwine2DataChunk(story, startName, options) {
4560
5202
  const parts = [];
4561
5203
  let startID = "";
@@ -4578,7 +5220,7 @@ function getTwine2DataChunk(story, startName, options) {
4578
5220
  pidS++;
4579
5221
  }
4580
5222
  }
4581
- parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContent}</style>`);
5223
+ parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContentEscape(styleContent)}</style>`);
4582
5224
  let scriptContent = "";
4583
5225
  if (scripts.length === 1) scriptContent = scripts[0]?.text ?? "";
4584
5226
  else if (scripts.length > 1) {
@@ -4590,7 +5232,7 @@ function getTwine2DataChunk(story, startName, options) {
4590
5232
  pidS++;
4591
5233
  }
4592
5234
  }
4593
- parts.push(`<script role="script" id="twine-user-script" type="text/twine-javascript">${scriptContent}<\/script>`);
5235
+ parts.push(`<script role="script" id="twine-user-script" type="text/twine-javascript">${scriptContentEscape(scriptContent)}<\/script>`);
4594
5236
  const validTagColors = /* @__PURE__ */ new Set([
4595
5237
  "gray",
4596
5238
  "red",
@@ -4604,8 +5246,7 @@ function getTwine2DataChunk(story, startName, options) {
4604
5246
  for (const [tag, color] of story.twine2.tagColors) if (validTagColors.has(color) || hexColorPattern.test(color)) parts.push(`<tw-tag name="${attrEscape(tag)}" color="${attrEscape(color)}"></tw-tag>`);
4605
5247
  pid = 1;
4606
5248
  for (const p of story.passages) {
4607
- if (p.name === "StoryTitle" || p.name === "StoryData" || hasAnyTag(p, "script", "stylesheet", "Twine.private")) continue;
4608
- if (p.name === "StorySettings" && story.twine1.settings.size === 0) continue;
5249
+ if (twine2PassageOmission(story, p) !== void 0) continue;
4609
5250
  parts.push(passageToPassagedata(p, pid, options));
4610
5251
  if (startName === p.name) startID = String(pid);
4611
5252
  pid++;
@@ -4661,12 +5302,21 @@ function toTwine1HTML(story, format, startName) {
4661
5302
  }
4662
5303
  return template;
4663
5304
  }
5305
+ /**
5306
+ * Why Twine 1 output leaves a passage out of its tiddlers, or `undefined` when the passage is emitted.
5307
+ */
5308
+ function twine1PassageOmission(p) {
5309
+ return hasTag(p, "Twine.private") ? {
5310
+ kind: "tag",
5311
+ tag: "Twine.private"
5312
+ } : void 0;
5313
+ }
4664
5314
  function getTwine1PassageChunk(story) {
4665
5315
  const obfuscateRot13 = story.twine1.settings.get("obfuscate") === "rot13";
4666
5316
  let data = "";
4667
5317
  let count = 0;
4668
5318
  for (const p of story.passages) {
4669
- if (hasTag(p, "Twine.private")) continue;
5319
+ if (twine1PassageOmission(p) !== void 0) continue;
4670
5320
  count++;
4671
5321
  data += passageToTiddler(p, count, obfuscateRot13);
4672
5322
  }
@@ -4723,352 +5373,78 @@ function loadModules(filenames) {
4723
5373
  if (tag) headTags.push(tag);
4724
5374
  processed.add(filename);
4725
5375
  }
4726
- return headTags.join("\n");
4727
- }
4728
- function loadModuleTagged(tag, filename) {
4729
- const source = readUTF8(filename).trim();
4730
- if (source.length === 0) return null;
4731
- return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${source}</${tag}>`;
4732
- }
4733
- function loadModuleFont(filename) {
4734
- const source = readBase64(filename);
4735
- const family = baseNameWithoutExt(filename);
4736
- const idSlug = `style-module-${slugify(family)}`;
4737
- const ext = normalizedFileExt(filename);
4738
- return `<style id="${idSlug}" type="text/css">@font-face {\n\tfont-family: "${family}";\n\tsrc: url("data:${mediaTypeFromExt(ext)};base64,${source}") format("${fontFormatHint(ext)}");\n}</style>`;
4739
- }
4740
- /**
4741
- * Inject modules and head file content before </head>.
4742
- */
4743
- function modifyHead(html, modulePaths, headFile, diagnostics) {
4744
- const parts = [];
4745
- if (modulePaths.length > 0) {
4746
- const modules = loadModules(modulePaths).trim();
4747
- if (modules.length > 0) parts.push(modules);
4748
- }
4749
- if (headFile) try {
4750
- const source = readUTF8(headFile).trim();
4751
- if (source.length > 0) parts.push(source);
4752
- } catch (e) {
4753
- diagnostics?.push({
4754
- level: "warning",
4755
- message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
4756
- });
4757
- }
4758
- if (parts.length > 0) {
4759
- parts.push("</head>");
4760
- const replacement = parts.join("\n");
4761
- return html.replace("</head>", () => replacement);
4762
- }
4763
- return html;
4764
- }
4765
- //#endregion
4766
- //#region src/remote-formats.ts
4767
- /**
4768
- * Remote story format fetching, caching, and checksum verification.
4769
- * Uses the Story Formats Archive (SFA) as the default source.
4770
- */
4771
- const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
4772
- /** Get the cache directory for downloaded story formats. */
4773
- function getCacheDir() {
4774
- const base = process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache");
4775
- return join(base, "twee-ts", "storyformats");
4776
- }
4777
- /** In-memory index cache, keyed by URL. Cleared each compile. */
4778
- const indexCache = /* @__PURE__ */ new Map();
4779
- /** Clear the in-memory index cache. */
4780
- function clearIndexCache() {
4781
- indexCache.clear();
4782
- }
4783
- /** Validate that a JSON value is a valid SFAIndexEntry. */
4784
- function isValidEntry(val) {
4785
- if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
4786
- const obj = val;
4787
- return typeof obj.name === "string" && typeof obj.version === "string" && typeof obj.checksums === "object" && obj.checksums !== null && !Array.isArray(obj.checksums);
4788
- }
4789
- /** Validate that a JSON value conforms to the SFAIndex shape. */
4790
- function validateSFAIndex(json) {
4791
- if (typeof json !== "object" || json === null) throw new Error("SFA index is not an object");
4792
- const obj = json;
4793
- return {
4794
- twine1: Array.isArray(obj["twine1"]) ? obj["twine1"].filter(isValidEntry) : [],
4795
- twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
4796
- };
4797
- }
4798
- /** Generate a format ID from name and version. */
4799
- function makeFormatId(name, version) {
4800
- const major = parseSemver(version)?.[0] ?? "0";
4801
- return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
4802
- }
4803
- /** Fetch and parse an SFA index.json, with in-memory caching. */
4804
- async function fetchIndex(url) {
4805
- const cached = indexCache.get(url);
4806
- if (cached) return cached;
4807
- const res = await fetch(url);
4808
- if (!res.ok) throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
4809
- const data = validateSFAIndex(await res.json());
4810
- indexCache.set(url, data);
4811
- return data;
4812
- }
4813
- /**
4814
- * Find the best matching entry in an SFA index.
4815
- * Exact version preferred, then highest version with same major.
4816
- */
4817
- function findEntry(index, name, version) {
4818
- const wanted = parseSemver(version);
4819
- const searchArrays = [{
4820
- entries: index.twine2 ?? [],
4821
- formatType: "twine2"
4822
- }, {
4823
- entries: index.twine1 ?? [],
4824
- formatType: "twine1"
4825
- }];
4826
- let bestResult;
4827
- let bestVersion = null;
4828
- for (const { entries, formatType } of searchArrays) for (const entry of entries) {
4829
- if (entry.name.toLowerCase() !== name.toLowerCase()) continue;
4830
- const have = parseSemver(entry.version);
4831
- if (!have) continue;
4832
- if (wanted && semverCompare(have, wanted) === 0) return {
4833
- entry,
4834
- formatType
4835
- };
4836
- if (wanted === null || have[0] === wanted[0] && semverCompare(have, wanted) >= 0) {
4837
- if (!bestVersion || semverCompare(have, bestVersion) > 0) {
4838
- bestVersion = have;
4839
- bestResult = {
4840
- entry,
4841
- formatType
4842
- };
4843
- }
4844
- }
4845
- }
4846
- return bestResult;
4847
- }
4848
- /** Verify SHA-256 checksum using Web Crypto API (Node 22 built-in). */
4849
- async function verifySHA256(content, expectedHex) {
4850
- const digest = await crypto.subtle.digest("SHA-256", content);
4851
- return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("") === expectedHex.toLowerCase();
4852
- }
4853
- /** Derive the download URL for a format.js from the index URL and entry. */
4854
- function getDownloadUrl(indexUrl, entry, formatType) {
4855
- return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
4856
- }
4857
- /** Download a format, verify its checksum, write to cache, and return StoryFormatInfo. */
4858
- async function fetchAndCacheFormat(entry, downloadUrl) {
4859
- const res = await fetch(downloadUrl);
4860
- if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
4861
- const text = await res.text();
4862
- const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
4863
- if (checksumKey) {
4864
- const expected = entry.checksums[checksumKey];
4865
- if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
4866
- if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
4867
- }
4868
- const id = makeFormatId(entry.name, entry.version);
4869
- const data = parseFormatJSON(text, id);
4870
- if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
4871
- const cacheDir = getCacheDir();
4872
- const formatDir = join(cacheDir, entry.name, entry.version);
4873
- mkdirSync(formatDir, { recursive: true });
4874
- const formatPath = join(formatDir, "format.js");
4875
- writeFileSync(formatPath, text, "utf-8");
4876
- return {
4877
- id,
4878
- filename: formatPath,
4879
- isTwine2: true,
4880
- name: data.name,
4881
- version: data.version,
4882
- proofing: data.proofing ?? false
4883
- };
4884
- }
4885
- /** Download a direct format.js URL, parse its JSON, cache it, and return StoryFormatInfo. */
4886
- async function fetchDirectFormat(url) {
4887
- const res = await fetch(url);
4888
- if (!res.ok) throw new Error(`Failed to download format from ${url}: ${res.status} ${res.statusText}`);
4889
- const text = await res.text();
4890
- const data = parseFormatJSON(text, "direct-url");
4891
- if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
4892
- const id = makeFormatId(data.name, data.version);
4893
- const cacheDir = getCacheDir();
4894
- const formatDir = join(cacheDir, data.name, data.version);
4895
- mkdirSync(formatDir, { recursive: true });
4896
- const formatPath = join(formatDir, "format.js");
4897
- writeFileSync(formatPath, text, "utf-8");
4898
- return {
4899
- id,
4900
- filename: formatPath,
4901
- isTwine2: true,
4902
- name: data.name,
4903
- version: data.version,
4904
- proofing: data.proofing ?? false
4905
- };
4906
- }
4907
- /**
4908
- * Try to resolve a remote story format by name and version.
4909
- * 1. Try direct format URLs
4910
- * 2. Try custom index URLs
4911
- * 3. Try default SFA indices
4912
- */
4913
- async function resolveRemoteFormat(name, version, indices, urls) {
4914
- let lastError;
4915
- if (urls) for (const url of urls) try {
4916
- const info = await fetchDirectFormat(url);
4917
- if (info.name.toLowerCase() === name.toLowerCase()) {
4918
- const wanted = parseSemver(version);
4919
- const have = parseSemver(info.version);
4920
- if (!wanted || have && have[0] === wanted[0] && semverCompare(have, wanted) >= 0) return info;
4921
- }
4922
- } catch (e) {
4923
- lastError = e instanceof Error ? e : new Error(String(e));
4924
- }
4925
- const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
4926
- for (const indexUrl of allIndices) try {
4927
- const result = findEntry(await fetchIndex(indexUrl), name, version);
4928
- if (result) {
4929
- const cached = getCachedFormat(result.entry.name, result.entry.version);
4930
- if (cached) return cached;
4931
- const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
4932
- return await fetchAndCacheFormat(result.entry, downloadUrl);
4933
- }
4934
- } catch (e) {
4935
- lastError = e instanceof Error ? e : new Error(String(e));
4936
- }
4937
- if (lastError) throw lastError;
4938
- }
4939
- /** Check if a format is already in the local cache. */
4940
- function getCachedFormat(name, version) {
4941
- const formatPath = join(getCacheDir(), name, version, "format.js");
4942
- try {
4943
- if (!existsSync(formatPath)) return void 0;
4944
- const source = readFileSync(formatPath, "utf-8");
4945
- const id = makeFormatId(name, version);
4946
- const data = parseFormatJSON(source, id);
4947
- if (!data) return void 0;
4948
- return {
4949
- id,
4950
- filename: formatPath,
4951
- isTwine2: true,
4952
- name: data.name,
4953
- version: data.version,
4954
- proofing: data.proofing ?? false
4955
- };
4956
- } catch {
4957
- return;
4958
- }
4959
- }
4960
- /** Discover all cached remote formats. Returns a Map like discoverFormats(). */
4961
- function discoverCachedFormats() {
4962
- const formats = /* @__PURE__ */ new Map();
4963
- const cacheDir = getCacheDir();
4964
- try {
4965
- if (!existsSync(cacheDir)) return formats;
4966
- } catch {
4967
- return formats;
4968
- }
4969
- let names;
4970
- try {
4971
- names = readdirSync(cacheDir);
4972
- } catch {
4973
- return formats;
4974
- }
4975
- for (const name of names) {
4976
- const nameDir = join(cacheDir, name);
4977
- try {
4978
- if (!statSync(nameDir).isDirectory()) continue;
4979
- } catch {
4980
- continue;
4981
- }
4982
- let versions;
4983
- try {
4984
- versions = readdirSync(nameDir);
4985
- } catch {
4986
- continue;
4987
- }
4988
- for (const version of versions) {
4989
- const info = getCachedFormat(name, version);
4990
- if (info) formats.set(`${info.id}-${version}`, info);
4991
- }
4992
- }
4993
- return formats;
5376
+ return headTags.join("\n");
4994
5377
  }
4995
- /** List all cached formats with size and modification date. */
4996
- function listCachedFormats() {
4997
- const cacheDir = getCacheDir();
4998
- const entries = [];
4999
- try {
5000
- if (!existsSync(cacheDir)) return entries;
5001
- } catch {
5002
- return entries;
5378
+ function loadModuleTagged(tag, filename) {
5379
+ const source = readUTF8(filename).trim();
5380
+ if (source.length === 0) return null;
5381
+ return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
5382
+ }
5383
+ function loadModuleFont(filename) {
5384
+ const source = readBase64(filename);
5385
+ const family = baseNameWithoutExt(filename);
5386
+ const idSlug = `style-module-${slugify(family)}`;
5387
+ const ext = normalizedFileExt(filename);
5388
+ return `<style id="${idSlug}" type="text/css">@font-face {\n\tfont-family: "${family}";\n\tsrc: url("data:${mediaTypeFromExt(ext)};base64,${source}") format("${fontFormatHint(ext)}");\n}</style>`;
5389
+ }
5390
+ /**
5391
+ * Inject modules and head file content before </head>.
5392
+ */
5393
+ function modifyHead(html, modulePaths, headFile, diagnostics) {
5394
+ const parts = [];
5395
+ if (modulePaths.length > 0) {
5396
+ const modules = loadModules(modulePaths).trim();
5397
+ if (modules.length > 0) parts.push(modules);
5003
5398
  }
5004
- let names;
5005
- try {
5006
- names = readdirSync(cacheDir);
5007
- } catch {
5008
- return entries;
5399
+ if (headFile) try {
5400
+ const source = readUTF8(headFile).trim();
5401
+ if (source.length > 0) parts.push(source);
5402
+ } catch (e) {
5403
+ diagnostics?.push({
5404
+ level: "warning",
5405
+ message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
5406
+ });
5009
5407
  }
5010
- for (const name of names) {
5011
- const nameDir = join(cacheDir, name);
5012
- try {
5013
- if (!statSync(nameDir).isDirectory()) continue;
5014
- } catch {
5015
- continue;
5016
- }
5017
- let versions;
5018
- try {
5019
- versions = readdirSync(nameDir);
5020
- } catch {
5021
- continue;
5022
- }
5023
- for (const version of versions) {
5024
- const formatPath = join(nameDir, version, "format.js");
5025
- try {
5026
- const stat = statSync(formatPath);
5027
- entries.push({
5028
- name,
5029
- version,
5030
- sizeBytes: stat.size,
5031
- modifiedAt: stat.mtime
5032
- });
5033
- } catch {}
5034
- }
5408
+ if (parts.length > 0) {
5409
+ parts.push("</head>");
5410
+ const replacement = parts.join("\n");
5411
+ return html.replace("</head>", () => replacement);
5035
5412
  }
5036
- return entries;
5413
+ return html;
5037
5414
  }
5038
- /** Clear all cached formats, or only those matching a given name. Returns the number of entries removed. */
5039
- function clearCachedFormats(name) {
5040
- const cacheDir = getCacheDir();
5041
- if (!existsSync(cacheDir)) return 0;
5042
- if (!name) {
5043
- const count = listCachedFormats().length;
5044
- rmSync(cacheDir, {
5045
- recursive: true,
5046
- force: true
5047
- });
5048
- return count;
5415
+ //#endregion
5416
+ //#region src/start-passage.ts
5417
+ function describeOmission(omission) {
5418
+ switch (omission.kind) {
5419
+ case "special-name": return `is the special "${omission.name}" passage`;
5420
+ case "tag": return `is tagged "${omission.tag}"`;
5421
+ case "empty-story-settings": return "is an empty \"StorySettings\" passage";
5422
+ default: throw new Error(`unhandled passage omission: ${JSON.stringify(omission)}`);
5049
5423
  }
5050
- const nameDir = join(cacheDir, name);
5051
- if (!existsSync(nameDir)) return 0;
5052
- let versions;
5053
- try {
5054
- versions = readdirSync(nameDir);
5055
- } catch {
5056
- return 0;
5424
+ }
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)}`);
5057
5430
  }
5058
- const count = versions.length;
5059
- rmSync(nameDir, {
5060
- recursive: true,
5061
- force: true
5062
- });
5063
- return count;
5064
5431
  }
5065
- /** Get total cache size in bytes and format count. */
5066
- function getCacheSize() {
5067
- const entries = listCachedFormats();
5068
- return {
5069
- totalBytes: entries.reduce((sum, e) => sum + e.sizeBytes, 0),
5070
- count: entries.length
5071
- };
5432
+ /**
5433
+ * Check that `startName` names a passage the `target` output emits.
5434
+ * Returns the error diagnostics to report (empty when the start is usable).
5435
+ */
5436
+ function startPassageDiagnostics(story, startName, target) {
5437
+ const passage = story.passages.find((p) => p.name === startName);
5438
+ if (passage === void 0) return [{
5439
+ level: "error",
5440
+ message: `Starting passage "${startName}" not found.`
5441
+ }];
5442
+ const omission = omissionFor(story, passage, target);
5443
+ if (omission === void 0) return [];
5444
+ return [{
5445
+ level: "error",
5446
+ message: `Starting passage "${startName}" ${describeOmission(omission)}, so it is left out of the story data. Choose a story passage.`
5447
+ }];
5072
5448
  }
5073
5449
  //#endregion
5074
5450
  //#region src/compiler.ts
@@ -5095,14 +5471,6 @@ async function compile(options) {
5095
5471
  return buildOutput(options);
5096
5472
  }
5097
5473
  /**
5098
- * Compile and write to a file.
5099
- */
5100
- async function compileToFile(options) {
5101
- const result = await compile(options);
5102
- writeFileSync(options.outFile, result.output, "utf-8");
5103
- return result;
5104
- }
5105
- /**
5106
5474
  * Watch for file changes and recompile.
5107
5475
  */
5108
5476
  async function watch$1(options) {
@@ -5110,18 +5478,35 @@ async function watch$1(options) {
5110
5478
  const cache = /* @__PURE__ */ new Map();
5111
5479
  const filePaths = options.sources.filter((s) => typeof s === "string");
5112
5480
  const modulePaths = options.modules ?? [];
5113
- const handle = watchFilesystem([...filePaths, ...modulePaths], options.outFile, (changedFiles) => {
5114
- buildOutput(options, cache, changedFiles).then((result) => {
5481
+ const headPaths = options.headFile ? [options.headFile] : [];
5482
+ const allPaths = [
5483
+ ...filePaths,
5484
+ ...modulePaths,
5485
+ ...headPaths
5486
+ ];
5487
+ const exclude = options.exclude ?? [];
5488
+ const notExcludable = [...modulePaths, ...headPaths].map((p) => resolve(p));
5489
+ const ignore = (filename) => {
5490
+ if (!isExcluded(filename, exclude)) return false;
5491
+ const abs = resolve(filename);
5492
+ return !notExcludable.some((root) => abs === root || abs.startsWith(root + sep));
5493
+ };
5494
+ const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
5495
+ buildOutput(options, cache, changedFiles, options.outFile).then((result) => {
5115
5496
  writeFileSync(options.outFile, result.output, "utf-8");
5116
5497
  options.onBuild?.(result);
5117
5498
  }).catch((e) => {
5118
5499
  options.onError?.(e instanceof Error ? e : new Error(String(e)));
5119
5500
  });
5120
- });
5501
+ }, ignore);
5121
5502
  controller.signal.addEventListener("abort", () => handle.close());
5122
5503
  return controller;
5123
5504
  }
5124
- async function buildOutput(options, cache, changedFiles) {
5505
+ /**
5506
+ * `outFile`: the file the build is written to, which source and module discovery
5507
+ * skip (as Tweego does), so an output inside a source folder is never loaded back.
5508
+ */
5509
+ async function buildOutput(options, cache, changedFiles, outFile) {
5125
5510
  const diagnostics = [];
5126
5511
  const outputMode = options.outputMode ?? "html";
5127
5512
  const trim = options.trim ?? true;
@@ -5134,7 +5519,7 @@ async function buildOutput(options, cache, changedFiles) {
5134
5519
  const inlineSources = [];
5135
5520
  for (const source of options.sources) if (typeof source === "string") filePaths.push(source);
5136
5521
  else inlineSources.push(source);
5137
- const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths);
5522
+ const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths, outFile, options.exclude);
5138
5523
  diagnostics.push(...sourcePathDiagnostics);
5139
5524
  const story = createStory();
5140
5525
  const processedFiles = /* @__PURE__ */ new Set();
@@ -5152,33 +5537,12 @@ async function buildOutput(options, cache, changedFiles) {
5152
5537
  }, diagnostics);
5153
5538
  if (options.tagAliases) story.passages = applyTagAliases(story.passages, options.tagAliases);
5154
5539
  let format;
5155
- let formatId = options.formatId ?? "";
5156
- if (outputMode === "html") {
5157
- const formats = discoverFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true));
5158
- if (!formatId && story.twine2.format) formatId = getFormatIdByNameAndVersion(formats, story.twine2.format, story.twine2.formatVersion) ?? "";
5159
- if (!formatId) formatId = DEFAULT_FORMAT_ID;
5160
- format = formats.get(formatId);
5161
- if (!format && !noRemote) {
5162
- const remoteName = story.twine2.format || formatId;
5163
- const remoteVersion = story.twine2.formatVersion || "";
5164
- try {
5165
- format = await resolveRemoteFormat(remoteName, remoteVersion, options.formatIndices, options.formatUrls);
5166
- } catch (e) {
5167
- diagnostics.push({
5168
- level: "warning",
5169
- message: `Remote format fetch failed for "${remoteName}": ${e instanceof Error ? e.message : String(e)}`
5170
- });
5171
- }
5172
- }
5173
- if (!format) {
5174
- const reason = noRemote ? " (remote fetching disabled)" : "";
5175
- diagnostics.push({
5176
- level: "error",
5177
- message: `Story format "${formatId}" is not available${reason}. Found: ${[...formats.keys()].join(", ") || "none"}`
5178
- });
5179
- }
5180
- }
5181
- const startName = options.startPassage || story.twine2.start || DEFAULT_START_NAME;
5540
+ if (outputMode === "html") format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
5541
+ ...options,
5542
+ noRemote
5543
+ }, diagnostics);
5544
+ if (options.startPassage) story.twine2.start = options.startPassage;
5545
+ const startName = story.twine2.start || DEFAULT_START_NAME;
5182
5546
  if (testMode) story.twine2.options.set("debug", true);
5183
5547
  ensureIFID(story, diagnostics);
5184
5548
  let output;
@@ -5197,10 +5561,7 @@ async function buildOutput(options, cache, changedFiles) {
5197
5561
  output = storyToJSON(story);
5198
5562
  break;
5199
5563
  case "html": {
5200
- if (!storyHas(story, startName)) diagnostics.push({
5201
- level: "error",
5202
- message: `Starting passage "${startName}" not found.`
5203
- });
5564
+ diagnostics.push(...startPassageDiagnostics(story, startName, format?.isTwine2 === false ? "twine1" : "twine2"));
5204
5565
  if (!format) throw new TweeTsError("No story format available for HTML output.", diagnostics);
5205
5566
  if (format.isTwine2) output = toTwine2HTML(story, format, startName, { sourceInfo });
5206
5567
  else {
@@ -5210,7 +5571,7 @@ async function buildOutput(options, cache, changedFiles) {
5210
5571
  });
5211
5572
  output = toTwine1HTML(story, format, startName);
5212
5573
  }
5213
- const modules = getFilenames(options.modules ?? []);
5574
+ const modules = getFilenames(options.modules ?? [], outFile);
5214
5575
  diagnostics.push(...modules.diagnostics);
5215
5576
  output = modifyHead(output, modules.filenames, options.headFile, diagnostics);
5216
5577
  break;
@@ -5630,19 +5991,227 @@ function endOfRegularExpression(source, pos) {
5630
5991
  };
5631
5992
  }
5632
5993
  //#endregion
5994
+ //#region src/link-markup.ts
5995
+ /**
5996
+ * Reads link or image markup that starts at `start` in `text`, or returns `undefined` where
5997
+ * SugarCube rejects it (and so prints the text instead).
5998
+ *
5999
+ * `budget.left`, if given, is how many characters the reading may look at; it is reduced by as
6000
+ * many as were read, and once it runs out, every reading fails. Unclosed markup is read to the end
6001
+ * of its line, so reading every `[[` of a long line that never closes one would otherwise take
6002
+ * quadratic time.
6003
+ */
6004
+ function readSquareBracketedMarkup(text, start, budget = { left: Infinity }) {
6005
+ const reader = {
6006
+ text,
6007
+ limit: Math.min(text.length, start + Math.max(budget.left, 0))
6008
+ };
6009
+ const markup = readMarkup(reader, start);
6010
+ budget.left -= (markup?.end ?? reader.limit) - start;
6011
+ return markup;
6012
+ }
6013
+ function readMarkup(reader, start) {
6014
+ const { text } = reader;
6015
+ if (text[start] !== "[") return;
6016
+ let pos = start + 1;
6017
+ let type;
6018
+ if (text[pos] === "[") {
6019
+ type = "link";
6020
+ pos += 1;
6021
+ } else {
6022
+ if (text[pos] === "<" || text[pos] === ">") pos += 1;
6023
+ if (!/^[Ii][Mm][Gg]\[/.test(text.slice(pos, pos + 4))) return;
6024
+ type = "image";
6025
+ pos += 4;
6026
+ }
6027
+ const core = readCoreComponents(reader, pos);
6028
+ if (core === void 0) return;
6029
+ if (type === "link") {
6030
+ if (!core.end.last) {
6031
+ const setterEnd = readSetter(reader, core.end.next);
6032
+ if (setterEnd === void 0) return;
6033
+ return {
6034
+ type,
6035
+ link: linkName(core.link),
6036
+ end: setterEnd
6037
+ };
6038
+ }
6039
+ return {
6040
+ type,
6041
+ link: linkName(core.link),
6042
+ end: core.end.next
6043
+ };
6044
+ }
6045
+ if (core.end.last) return {
6046
+ type,
6047
+ link: void 0,
6048
+ end: core.end.next
6049
+ };
6050
+ const linkEnd = readComponent(reader, core.end.next, false);
6051
+ if (linkEnd === void 0) return;
6052
+ const link = linkName(text.slice(core.end.next, linkEnd.textEnd));
6053
+ if (linkEnd.last) return {
6054
+ type,
6055
+ link,
6056
+ end: linkEnd.next
6057
+ };
6058
+ const setterEnd = readSetter(reader, linkEnd.next);
6059
+ return setterEnd === void 0 ? void 0 : {
6060
+ type,
6061
+ link,
6062
+ end: setterEnd
6063
+ };
6064
+ }
6065
+ /** The link component as SugarCube keeps it: trimmed, and without a leading `~`. */
6066
+ function linkName(component) {
6067
+ const trimmed = component.trim();
6068
+ return trimmed.startsWith("~") ? trimmed.slice(1) : trimmed;
6069
+ }
6070
+ /**
6071
+ * Reads the first components: the text and the link of link markup, or the title and the source
6072
+ * of image markup, divided by the first delimiter. `link` is the text of the link (or source)
6073
+ * component, untrimmed.
6074
+ */
6075
+ function readCoreComponents(reader, from) {
6076
+ const { text } = reader;
6077
+ let delimiter = "none";
6078
+ let componentStart = from;
6079
+ let link;
6080
+ let depth = 2;
6081
+ let pos = from;
6082
+ while (pos < reader.limit) {
6083
+ const ch = text[pos];
6084
+ pos += 1;
6085
+ switch (ch) {
6086
+ case "\n": return;
6087
+ case "\"": {
6088
+ const end = readQuoted(reader, pos, "\"");
6089
+ if (end === void 0) return;
6090
+ pos = end;
6091
+ break;
6092
+ }
6093
+ case "|":
6094
+ if (delimiter === "none") {
6095
+ delimiter = "ltr";
6096
+ componentStart = pos;
6097
+ }
6098
+ break;
6099
+ case "-":
6100
+ if (delimiter === "none" && text[pos] === ">") {
6101
+ delimiter = "ltr";
6102
+ pos += 1;
6103
+ componentStart = pos;
6104
+ }
6105
+ break;
6106
+ case "<":
6107
+ if (delimiter === "none" && text[pos] === "-") {
6108
+ delimiter = "rtl";
6109
+ link = text.slice(componentStart, pos - 1);
6110
+ pos += 1;
6111
+ componentStart = pos;
6112
+ }
6113
+ break;
6114
+ case "[":
6115
+ depth += 1;
6116
+ break;
6117
+ case "]":
6118
+ depth -= 1;
6119
+ if (depth === 1) {
6120
+ const end = componentEnd(text, pos);
6121
+ if (end === void 0) return;
6122
+ return {
6123
+ link: link ?? text.slice(componentStart, end.textEnd),
6124
+ end
6125
+ };
6126
+ }
6127
+ }
6128
+ }
6129
+ }
6130
+ /**
6131
+ * Reads a component after `][` that holds no delimiter: an image's link (`setter` false) or a
6132
+ * setter, in which single quotes also quote.
6133
+ */
6134
+ function readComponent(reader, from, setter) {
6135
+ const { text } = reader;
6136
+ let depth = 2;
6137
+ let pos = from;
6138
+ while (pos < reader.limit) {
6139
+ const ch = text[pos];
6140
+ pos += 1;
6141
+ switch (ch) {
6142
+ case "\n": return;
6143
+ case "\"":
6144
+ case "'": {
6145
+ if (ch === "'" && !setter) break;
6146
+ const end = readQuoted(reader, pos, ch);
6147
+ if (end === void 0) return;
6148
+ pos = end;
6149
+ break;
6150
+ }
6151
+ case "[":
6152
+ depth += 1;
6153
+ break;
6154
+ case "]":
6155
+ depth -= 1;
6156
+ if (depth === 1) return componentEnd(text, pos);
6157
+ }
6158
+ }
6159
+ }
6160
+ /** Reads a setter, the last component; returns the index after its closing `]]`. */
6161
+ function readSetter(reader, from) {
6162
+ const end = readComponent(reader, from, true);
6163
+ return end?.last === true ? end.next : void 0;
6164
+ }
6165
+ /** 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 {
6169
+ textEnd: pos - 1,
6170
+ next: pos + 1,
6171
+ last: false
6172
+ };
6173
+ case "]": return {
6174
+ textEnd: pos - 1,
6175
+ next: pos + 1,
6176
+ last: true
6177
+ };
6178
+ default: return;
6179
+ }
6180
+ }
6181
+ /**
6182
+ * Reads a quoted string from just after its opening quote; a backslash escapes the character
6183
+ * after it. A line feed, or the end of the text, ends it unterminated. Returns the index after the
6184
+ * closing quote.
6185
+ */
6186
+ function readQuoted(reader, from, quote) {
6187
+ const { text } = reader;
6188
+ let pos = from;
6189
+ while (pos < reader.limit) {
6190
+ const ch = text[pos];
6191
+ pos += 1;
6192
+ if (ch === quote) return pos;
6193
+ if (ch === "\n") return;
6194
+ if (ch === "\\") {
6195
+ if (pos >= reader.limit || text[pos] === "\n") return;
6196
+ pos += 1;
6197
+ }
6198
+ }
6199
+ }
6200
+ //#endregion
5633
6201
  //#region src/sugarcube-macros.ts
5634
6202
  /**
5635
- * Reads SugarCube 2 macro calls the way SugarCube 2.37.3 does, so the link check sees the same
5636
- * passage names that `<<link>>` and `<<goto>>` use when the story plays.
6203
+ * Reads SugarCube 2 macro calls and link markup the way SugarCube 2.37.3 does, so the link check
6204
+ * sees the same passage names that `[[…]]` links, `<<link>>` and `<<goto>>` use when the story
6205
+ * plays.
5637
6206
  *
5638
6207
  * Each step follows a part of SugarCube's macro parser:
5639
6208
  * - `findMacroTags` finds where a tag starts and ends (the parser's `lookahead` pattern);
5640
6209
  * - `parseMacroArgs` splits the text after the macro name into arguments (`parseArgs`);
5641
6210
  * - quoted arguments are evaluated as strict-mode JavaScript strings (`evalStringLiteral`).
5642
6211
  *
6212
+ * Link and image markup is read as `parseSquareBracketedMarkup` reads it (see `link-markup.ts`).
5643
6213
  * Of the rest of the wikifier, only comments, `<script>` elements and `<<script>>` bodies are
5644
- * told apart; other markup, such as verbatim text, is read as markup (see
5645
- * `findMacroPassageLinks`).
6214
+ * told apart; other markup, such as verbatim text, is read as markup (see `findPassageLinks`).
5646
6215
  */
5647
6216
  const NAME_RE = /\/?[A-Za-z][\w-]*|[=-]/y;
5648
6217
  const SPACES_RE = /\s*/y;
@@ -5687,17 +6256,22 @@ const RAW_ARGUMENT_MACROS = /* @__PURE__ */ new Set([
5687
6256
  * levels; the limit keeps the work linear in the size of the text on any input.
5688
6257
  */
5689
6258
  const MAX_STRING_DEPTH = 10;
5690
- /** A context for reading `text`: the closer search may read four times it, plus an allowance. */
6259
+ /**
6260
+ * A context for reading `text`: the closer search, and the markup reading, may each read four
6261
+ * times it, plus an allowance.
6262
+ */
5691
6263
  function readContext(text) {
6264
+ const allowance = () => ({ left: 4 * text.length + 1e5 });
5692
6265
  return {
5693
6266
  depth: 0,
5694
- budget: { left: 4 * text.length + 1e5 }
6267
+ budget: allowance(),
6268
+ markupBudget: allowance()
5695
6269
  };
5696
6270
  }
5697
6271
  function deeper(context) {
5698
6272
  return {
5699
- depth: context.depth + 1,
5700
- budget: context.budget
6273
+ ...context,
6274
+ depth: context.depth + 1
5701
6275
  };
5702
6276
  }
5703
6277
  /** Index of the first match of a global `re` at or after `from`, or -1. */
@@ -5927,7 +6501,10 @@ function lexArgument(raw, pos) {
5927
6501
  case "[": {
5928
6502
  const end = lexSquareBracketed(raw, pos + 1);
5929
6503
  return end === void 0 ? void 0 : {
5930
- arg: { kind: "markup" },
6504
+ arg: {
6505
+ kind: "markup",
6506
+ text: raw.slice(pos, end)
6507
+ },
5931
6508
  end
5932
6509
  };
5933
6510
  }
@@ -6071,13 +6648,44 @@ function ownPassageLink(tag, lexed) {
6071
6648
  const arg = passageArgument(tag.name, lexed);
6072
6649
  const passage = arg === void 0 ? void 0 : passageName(arg);
6073
6650
  return passage === void 0 ? void 0 : {
6074
- macro: tag.name,
6651
+ via: tag.name,
6075
6652
  passage
6076
6653
  };
6077
6654
  }
6078
6655
  /**
6079
- * The passages a tag names: its own, if it is a `<<link>>` or `<<goto>>` call, then those of the
6080
- * calls inside the strings of its arguments, which the macro may print or pass on
6656
+ * The passage a link's destination names. SugarCube evaluates a destination that no passage is
6657
+ * named by, and a quoted string gives its value (`[[Go|"Room"]]`), unless that is blank.
6658
+ */
6659
+ function linkDestination(link) {
6660
+ const value = evalStringLiteral(link);
6661
+ return value === void 0 || value.trim() === "" ? link : value;
6662
+ }
6663
+ /**
6664
+ * The passage that link markup from `start` in `text` names, and where the markup ends, or
6665
+ * `undefined` where SugarCube rejects the markup. Image markup names none here, nor does markup
6666
+ * with a template literal's `${…}` in it, whose passage is known only in play.
6667
+ */
6668
+ function linkMarkupAt(text, start, budget) {
6669
+ const markup = readSquareBracketedMarkup(text, start, budget);
6670
+ if (markup === void 0) return;
6671
+ const passage = markup.type === "link" && !text.slice(start, markup.end).includes("﷐") ? markup.link : void 0;
6672
+ return {
6673
+ link: passage === void 0 ? void 0 : {
6674
+ via: "markup",
6675
+ passage: linkDestination(passage)
6676
+ },
6677
+ end: markup.end
6678
+ };
6679
+ }
6680
+ /** The passage that a link markup argument names, if SugarCube reads the whole argument as markup. */
6681
+ function markupArgumentLinks(raw) {
6682
+ const markup = linkMarkupAt(raw, 0);
6683
+ return markup?.link === void 0 || markup.end !== raw.length ? [] : [markup.link];
6684
+ }
6685
+ /**
6686
+ * The passages a tag names: its own, if it is a `<<link>>` or `<<goto>>` call, then those of its
6687
+ * link markup arguments (`<<button [[Go|Room]]>>`), and those of the links and calls inside the
6688
+ * strings of its arguments, which the macro may print or pass on
6081
6689
  * (`<<set _out to '<<link "Go" "Room">><</link>>'>>`). The arguments of the macros SugarCube
6082
6690
  * doesn't split are read as JavaScript; in others, a backquoted expression is.
6083
6691
  */
@@ -6090,7 +6698,7 @@ function tagPassageLinks(tag, context) {
6090
6698
  switch (arg.kind) {
6091
6699
  case "string": return markupPassageLinks(arg.value, deeper(context));
6092
6700
  case "expression": return javaScriptPassageLinks(arg.code, context);
6093
- case "markup":
6701
+ case "markup": return markupArgumentLinks(arg.text);
6094
6702
  case "word": return [];
6095
6703
  default: throw new Error(`unhandled macro argument: ${JSON.stringify(arg)}`);
6096
6704
  }
@@ -6100,16 +6708,26 @@ function tagPassageLinks(tag, context) {
6100
6708
  /**
6101
6709
  * The passage named by a `<<link>>` or `<<goto>>` that appears unquoted inside another tag's
6102
6710
  * arguments, at `start`. SugarCube never sees such an outer "tag" when its `<<` lies in text it
6103
- * reads as something else, such as link markup (`[[<<-- Back|Prev]]`); the call inside it then
6104
- * runs. The call ends where the outer tag ends.
6711
+ * reads as something else, such as verbatim text (`{{{<<if}}}`); the call inside it then runs.
6712
+ * The call ends where the outer tag ends.
6105
6713
  */
6106
6714
  function innerPassageLink(matchTagAt, start) {
6107
6715
  const inner = matchTagAt(start)?.tag;
6108
6716
  const lexed = inner === void 0 ? void 0 : lexMacroArgs(inner.args);
6109
6717
  return inner === void 0 || lexed === void 0 ? void 0 : ownPassageLink(inner, lexed);
6110
6718
  }
6111
- const REGION_OPEN_RE = /\/\/|\/\*|\/%|<!--|<[Ss][Cc][Rr][Ii][Pp][Tt]/g;
6719
+ const REGION_OPEN_RE = /\[\[[^[]|\[[<>]?[Ii][Mm][Gg]\[|\/\/|\/\*|\/%|<!--|<[Ss][Cc][Rr][Ii][Pp][Tt]/g;
6112
6720
  const HAS_SCRIPT_OPEN_RE = /<[Ss][Cc][Rr][Ii][Pp][Tt]/;
6721
+ function regionKind(opener) {
6722
+ if (opener.startsWith("[")) return "markup";
6723
+ switch (opener) {
6724
+ case "//":
6725
+ case "/*":
6726
+ case "/%":
6727
+ case "<!--": return opener;
6728
+ default: return "script";
6729
+ }
6730
+ }
6113
6731
  const SCRIPT_CLOSE_RE = /<\/[Ss][Cc][Rr][Ii][Pp][Tt]>|[\r\u2028\u2029]/g;
6114
6732
  const COMMENT_CLOSE_RES = {
6115
6733
  "/*": /\*\/|[\r\u2028\u2029]/g,
@@ -6263,22 +6881,31 @@ function append(links, more) {
6263
6881
  for (const link of more) links.push(link);
6264
6882
  }
6265
6883
  /**
6266
- * The passages that `<<link>>` and `<<goto>>` calls in passage markup name. Calls whose passage
6267
- * is known only in play (a variable or an expression) are left out. A tag's own passage comes
6268
- * before those of the calls inside its arguments' strings.
6884
+ * The passages that link markup (`[[…]]`) and `<<link>>` and `<<goto>>` calls in passage markup
6885
+ * name, in the order they come. Calls whose passage is known only in play (a variable or an
6886
+ * expression) are left out. A tag's own passage comes before those that its arguments name, as
6887
+ * link markup or inside strings.
6269
6888
  *
6270
6889
  * The markup is walked in order, as SugarCube's wikifier walks it, with a tag tried at each
6271
- * `<<` the walk reaches. Of the wikifier's other parsers, only those whose text holds no call
6272
- * are told apart: comments (`/* … *` + `/`, `/% … %/`, `<!-- … -->`), whose calls never run,
6273
- * and `<script>` elements and `<<script>>` bodies, which are JavaScript: only the calls in their
6274
- * strings are read (see `findJavaScriptPassageLinks`). Everything else, verbatim text included,
6275
- * is read as markup.
6890
+ * `<<` the walk reaches, and link or image markup read at each `[[` or `[img[`; markup SugarCube
6891
+ * rejects is read on from just after its opener, and so is markup after a limit on how much
6892
+ * markup a reading may scan, which only input built to take quadratic time reaches (see
6893
+ * `readSquareBracketedMarkup`). Image markup names no passage here. Of the wikifier's other
6894
+ * parsers, only those whose text holds no link or call are told apart: comments (`/* … *` + `/`,
6895
+ * `/% … %/`, `<!-- … -->`), whose links and calls never run, and `<script>` elements and
6896
+ * `<<script>>` bodies, which are JavaScript: only the links and calls in their strings are read
6897
+ * (see `findJavaScriptPassageLinks`). Everything else, verbatim text included, is read as markup.
6276
6898
  *
6277
6899
  * Where this differs from SugarCube:
6900
+ * - a link whose passage SugarCube evaluates, because no passage has its name, is taken by its
6901
+ * name, `[[Go|$next]]` naming the passage `$next`, unless it is a quoted string, which is taken
6902
+ * by its value even when a passage has the name with the quotes;
6903
+ * - a link SugarCube takes for a URL, because no passage has its name and it looks like one or
6904
+ * holds a `/`, `.`, `?` or `#`, is still read as naming a passage;
6278
6905
  * - a comment or a `<<script>>` body is skipped whole, even where `parseBody` ends a container
6279
6906
  * such as `<<if>>` at a closing tag inside it and the rest then runs;
6280
- * - a `/*`, `<!--` or `<script` inside other markup that holds no call, such as link markup or a
6281
- * `<style>` element, still starts a comment or an element;
6907
+ * - a `[[`, `/*`, `<!--` or `<script` inside other markup that holds no call, such as verbatim
6908
+ * text or a `<style>` element, still starts link markup, a comment or an element;
6282
6909
  * - a `<<` in such other markup still starts a tag. When that tag runs over a `<<link` or
6283
6910
  * `<<goto` outside the parts of its arguments that the tag pattern reads as units, that call
6284
6911
  * is still read (see `innerPassageLink`), even where the tag's arguments would be JavaScript,
@@ -6293,33 +6920,43 @@ function append(links, more) {
6293
6920
  * `Config.passages.nobr`, are not joined before they are read (see `storyInspect` for passages
6294
6921
  * tagged `nobr`).
6295
6922
  */
6296
- function findMacroPassageLinks(text) {
6923
+ function findPassageLinks(text) {
6297
6924
  return markupPassageLinks(text, readContext(text));
6298
6925
  }
6299
6926
  function markupPassageLinks(text, context) {
6300
- if (context.depth > MAX_STRING_DEPTH || !text.includes("<<") && !HAS_SCRIPT_OPEN_RE.test(text)) return [];
6927
+ if (context.depth > MAX_STRING_DEPTH || !text.includes("<<") && !text.includes("[[") && !HAS_SCRIPT_OPEN_RE.test(text)) return [];
6301
6928
  const matchTagAt = tagMatcher(text);
6302
6929
  const matchInnerTagAt = tagMatcher(text);
6303
6930
  const readComment = commentReader(text);
6304
6931
  const readScriptElement = scriptElementReader(text);
6305
6932
  let findScriptCloser;
6306
6933
  const links = [];
6307
- /** Reads the region `opener` opens, adding the calls in a `<script>` element; returns where the walk goes on. */
6934
+ /**
6935
+ * Reads the region `opener` opens, adding the passage that link markup names and the links and
6936
+ * calls in a `<script>` element; returns where the walk goes on.
6937
+ */
6308
6938
  const regionEnd = (opener) => {
6309
6939
  const start = opener.index;
6310
- const kind = opener[0];
6940
+ const kind = regionKind(opener[0]);
6311
6941
  switch (kind) {
6942
+ case "markup": {
6943
+ const markup = linkMarkupAt(text, start, context.markupBudget);
6944
+ if (markup === void 0) return start + opener[0].length;
6945
+ if (markup.link !== void 0) links.push(markup.link);
6946
+ return markup.end;
6947
+ }
6312
6948
  case "//": return start + 2;
6313
6949
  case "/*":
6314
6950
  case "/%":
6315
6951
  case "<!--": return readComment(kind, start) ?? start + kind.length;
6316
- default: {
6952
+ case "script": {
6317
6953
  const element = readScriptElement(start);
6318
6954
  if (element.openerEnd === void 0) return start + 1;
6319
6955
  if (element.close === void 0) return element.openerEnd;
6320
6956
  append(links, javaScriptPassageLinks(text.slice(element.openerEnd, element.close), context));
6321
6957
  return element.close + 9;
6322
6958
  }
6959
+ default: throw new Error(`unhandled region: ${String(kind)}`);
6323
6960
  }
6324
6961
  };
6325
6962
  let macro = -2;
@@ -6359,24 +6996,26 @@ function markupPassageLinks(text, context) {
6359
6996
  }
6360
6997
  }
6361
6998
  /**
6362
- * The passages that `<<link>>` and `<<goto>>` calls in the strings of JavaScript source name,
6363
- * such as a script passage or a `<<script>>` body: `$.wiki('<<goto "Room">>')`. Code outside
6364
- * the strings isn't markup, so a call put together while the story plays, such as
6365
- * `'<<goto "' + target + '">>'`, names no passage here.
6999
+ * The passages that link markup and `<<link>>` and `<<goto>>` calls in the strings of JavaScript
7000
+ * source name, such as a script passage or a `<<script>>` body: `$.wiki('<<goto "Room">>')`.
7001
+ * Code outside the strings isn't markup, so a call put together while the story plays, such as
7002
+ * `'<<goto "' + target + '">>'`, names no passage here, nor does an array of arrays (`[[0, 1]]`).
6366
7003
  */
6367
7004
  function findJavaScriptPassageLinks(source) {
6368
7005
  return javaScriptPassageLinks(source, readContext(source));
6369
7006
  }
6370
7007
  /**
6371
7008
  * What JavaScript source must contain for one of its string or template literals to hold a call
6372
- * that is read. A value holding `<<`: each `<` in it is written as `<`, `\<`, `\x3c`, `\u003c` or
6373
- * `\u{…}`, two can be next to each other with only line continuations between, and the first
6374
- * is `<` or `\<` (then the source holds `<<` or `<\`) or one of the others. Or a value holding a
6375
- * `<script>` element, whose own strings can make `<<` from escapes the outer string encodes.
7009
+ * or link markup that is read. A value holding `<<`: each `<` in it is written as `<`, `\<`,
7010
+ * `\x3c`, `\u003c` or `\u{…}`, two can be next to each other with only line continuations
7011
+ * between, and the first is `<` or `\<` (then the source holds `<<` or `<\`) or one of the others.
7012
+ * A value holding `[[`, likewise: the source holds `[[`, a `[` before `\[` or a line continuation,
7013
+ * `\x5b`, `\u005b` or `\u{`. Or a value holding a `<script>` element, whose own strings can make
7014
+ * `<<` or `[[` from escapes the outer string encodes.
6376
7015
  */
6377
- const MAY_HOLD_MACRO_RE = /<<|<\\|\\x3c|\\u003c|\\u\{|<script/i;
7016
+ const MAY_HOLD_LINK_RE = /<<|<\\|\\x3c|\\u003c|\\u\{|<script|\[\[|\[\\[[\n\r\u2028\u2029]|\\x5b|\\u005b/i;
6378
7017
  function javaScriptPassageLinks(source, context) {
6379
- if (context.depth >= MAX_STRING_DEPTH || !MAY_HOLD_MACRO_RE.test(source)) return [];
7018
+ if (context.depth >= MAX_STRING_DEPTH || !MAY_HOLD_LINK_RE.test(source)) return [];
6380
7019
  const inner = deeper(context);
6381
7020
  const links = [];
6382
7021
  for (const value of javaScriptStrings(source)) append(links, markupPassageLinks(value, inner));
@@ -6385,43 +7024,34 @@ function javaScriptPassageLinks(source, context) {
6385
7024
  //#endregion
6386
7025
  //#region src/inspect.ts
6387
7026
  /**
6388
- * Extract all [[wiki-style links]] from passage text.
7027
+ * Extract the passages that passage text links to, read as SugarCube 2 reads them.
6389
7028
  *
6390
7029
  * Supported syntaxes:
6391
7030
  * - `[[PassageName]]`
6392
- * - `[[Display Text->PassageName]]` (Twine 2 / SugarCube arrow)
6393
- * - `[[Display Text|PassageName]]` (Twine 1 / Harlowe pipe)
6394
- * - `<<goto "PassageName">>` (SugarCube macro)
7031
+ * - `[[Display Text|PassageName]]`
7032
+ * - `[[Display Text->PassageName]]`
7033
+ * - `[[PassageName<-Display Text]]`
7034
+ * - any of those with a setter: `[[Display Text|PassageName][$x to 1]]`
7035
+ * - `<<goto "PassageName">>` (SugarCube macro)
6395
7036
  * - `<<link "Display" "PassageName">>` (SugarCube macro)
6396
7037
  *
6397
- * SugarCube macro arguments may be double- or single-quoted, with backslash escapes, or bare
6398
- * words; a passage named by a variable or an expression is known only in play and is skipped.
6399
- * Macro calls are also read inside the quoted strings of other macros' arguments, and inside the
6400
- * strings of `<<script>>` bodies and `<script>` elements; calls in comments are not read. A
6401
- * script passage (`isScript`) is JavaScript, so its macro calls are read only inside its strings.
6402
- * See `sugarcube-macros.ts` for the details and the known differences from SugarCube.
7038
+ * In link markup, the first `|`, `->` or `<-` divides the text from the passage name; image
7039
+ * markup (`[img[…][PassageName]]`) is not read. SugarCube macro arguments may be double- or
7040
+ * single-quoted, with backslash escapes, or bare words; a passage named by a variable or an
7041
+ * expression is known only in play and is skipped. Links and macro calls are also read inside the
7042
+ * quoted strings of macros' arguments, and inside the strings of `<<script>>` bodies and
7043
+ * `<script>` elements; links and calls in comments are not read. A script passage (`isScript`) is
7044
+ * JavaScript, so its links and calls are read only inside its strings. See `sugarcube-macros.ts`
7045
+ * for the details and the known differences from SugarCube.
6403
7046
  */
6404
7047
  function extractLinksFromText(text, isScript) {
6405
- const targets = /* @__PURE__ */ new Set();
6406
- const wikiLinkRe = /\[\[([^\]]+)\]\]/g;
6407
- let m;
6408
- while ((m = wikiLinkRe.exec(text)) !== null) {
6409
- const content = m[1];
6410
- const arrowIdx = content.indexOf("->");
6411
- if (arrowIdx !== -1) {
6412
- targets.add(content.slice(arrowIdx + 2).trim());
6413
- continue;
6414
- }
6415
- const pipeIdx = content.indexOf("|");
6416
- if (pipeIdx !== -1) {
6417
- targets.add(content.slice(pipeIdx + 1).trim());
6418
- continue;
6419
- }
6420
- targets.add(content.trim());
6421
- }
6422
- const macroLinks = isScript ? findJavaScriptPassageLinks(text) : findMacroPassageLinks(text);
6423
- for (const macro of ["goto", "link"]) for (const link of macroLinks) if (link.macro === macro) targets.add(link.passage);
6424
- return [...targets];
7048
+ const found = isScript ? findJavaScriptPassageLinks(text) : findPassageLinks(text);
7049
+ const ordered = [
7050
+ "markup",
7051
+ "goto",
7052
+ "link"
7053
+ ].flatMap((via) => found.filter((link) => link.via === via).map((link) => link.passage));
7054
+ return [...new Set(ordered)];
6425
7055
  }
6426
7056
  /** Passages SugarCube runs from their raw text, never joining their lines. */
6427
7057
  const RAW_TEXT_PASSAGES = /* @__PURE__ */ new Set([
@@ -6524,7 +7154,8 @@ function storyInspect(story) {
6524
7154
  //#region src/lint.ts
6525
7155
  /**
6526
7156
  * Lint a story: compile without output rendering, then inspect structure.
6527
- * Uses JSON output mode internally to avoid format resolution.
7157
+ * Uses JSON output mode internally to avoid format resolution, so it checks
7158
+ * the starting passage itself, against the Twine 2 passage rules.
6528
7159
  */
6529
7160
  async function lint(options) {
6530
7161
  const result = await compile({
@@ -6533,7 +7164,7 @@ async function lint(options) {
6533
7164
  });
6534
7165
  const map = storyInspect(result.story);
6535
7166
  return {
6536
- diagnostics: result.diagnostics,
7167
+ diagnostics: [...result.diagnostics, ...startPassageDiagnostics(result.story, map.start, "twine2")],
6537
7168
  stats: result.stats,
6538
7169
  formatName: result.story.twine2.format,
6539
7170
  formatVersion: result.story.twine2.formatVersion,
@@ -6659,6 +7290,7 @@ function validateConfig(data) {
6659
7290
  ]) if (key in obj && typeof obj[key] !== "boolean") errors.push(`"${key}" must be a boolean.`);
6660
7291
  for (const key of [
6661
7292
  "sources",
7293
+ "exclude",
6662
7294
  "formatPaths",
6663
7295
  "formatIndices",
6664
7296
  "formatUrls",
@@ -6767,6 +7399,10 @@ const { values, positionals } = parseArgs({
6767
7399
  type: "string",
6768
7400
  multiple: true
6769
7401
  },
7402
+ exclude: {
7403
+ type: "string",
7404
+ multiple: true
7405
+ },
6770
7406
  "source-info": { type: "boolean" },
6771
7407
  "word-count-method": { type: "string" },
6772
7408
  config: {
@@ -6839,6 +7475,7 @@ async function main() {
6839
7475
  if (values.lint) {
6840
7476
  const lintResult = await lint({
6841
7477
  sources,
7478
+ exclude: values.exclude ?? config?.exclude,
6842
7479
  formatId: values.format ?? config?.formatId,
6843
7480
  startPassage: values.start ?? config?.startPassage,
6844
7481
  formatPaths: config?.formatPaths,
@@ -6860,6 +7497,7 @@ async function main() {
6860
7497
  }
6861
7498
  const compileOptions = {
6862
7499
  sources,
7500
+ exclude: values.exclude ?? config?.exclude,
6863
7501
  outputMode,
6864
7502
  formatId: values.format ?? config?.formatId,
6865
7503
  startPassage: values.start ?? config?.startPassage,
@@ -6890,26 +7528,32 @@ async function main() {
6890
7528
  onBuild(result) {
6891
7529
  console.log(`Built: ${result.stats.passages} passages, ${result.stats.words} words`);
6892
7530
  logDiagnostics(result.diagnostics);
7531
+ const errors = countErrors(result.diagnostics);
7532
+ if (errors > 0) console.error(`Build has ${pluralize(errors, "error")}; still watching for changes.`);
6893
7533
  },
6894
7534
  onError(error) {
6895
7535
  console.error(`Build error: ${error.message}`);
6896
7536
  }
6897
7537
  });
6898
- } else if (outFile === "-") {
6899
- const result = await compile(compileOptions);
6900
- process.stdout.write(result.output);
6901
- logDiagnostics(result.diagnostics);
6902
- if (values["log-stats"]) logStats(result);
6903
7538
  } else {
6904
- const result = await compileToFile({
6905
- ...compileOptions,
6906
- outFile
6907
- });
7539
+ const result = await compile(compileOptions);
6908
7540
  logDiagnostics(result.diagnostics);
6909
- if (values["log-files"]) console.log(`\nFiles: ${result.stats.files.join(", ")}`);
7541
+ const errors = countErrors(result.diagnostics);
7542
+ if (errors > 0) {
7543
+ console.error(`Compilation failed with ${pluralize(errors, "error")}; output not written.`);
7544
+ 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(", ")}`);
6910
7548
  if (values["log-stats"]) logStats(result);
6911
7549
  }
6912
7550
  }
7551
+ function countErrors(diagnostics) {
7552
+ return diagnostics.filter((d) => d.level === "error").length;
7553
+ }
7554
+ function pluralize(count, noun) {
7555
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
7556
+ }
6913
7557
  function listFormats() {
6914
7558
  const formats = discoverFormats(getFormatSearchDirs());
6915
7559
  console.log("Local story formats:");
@@ -6935,26 +7579,56 @@ function logStats(result) {
6935
7579
  console.log(` Words: ${s.words}`);
6936
7580
  console.log(` Files: ${s.files.length}`);
6937
7581
  }
6938
- function runInit() {
6939
- console.log("Initializing new twee-ts project...");
6940
- mkdirSync("src", { recursive: true });
6941
- writeFileSync(join("src", "StoryData.tw"), `:: StoryData
7582
+ const SCAFFOLD_FILES = [
7583
+ {
7584
+ path: CONFIG_FILENAME,
7585
+ content: scaffoldConfig
7586
+ },
7587
+ {
7588
+ path: "src/StoryData.tw",
7589
+ content: () => `:: StoryData
6942
7590
  {
6943
7591
  \t"ifid": "${crypto.randomUUID().toUpperCase()}"
6944
7592
  }
6945
- `);
6946
- writeFileSync(join("src", "Start.tw"), `:: Start
7593
+ `
7594
+ },
7595
+ {
7596
+ path: "src/Start.tw",
7597
+ content: () => `:: Start
6947
7598
  Welcome to your new Twine story!
6948
7599
 
6949
7600
  This is the starting passage. Edit this file to begin writing your story.
6950
- `);
6951
- if (!existsSync("twee-ts.config.json")) {
6952
- writeFileSync(CONFIG_FILENAME, scaffoldConfig());
6953
- console.log(`Created: ${CONFIG_FILENAME}`);
6954
- }
6955
- console.log("Created:");
6956
- console.log(" src/StoryData.tw");
6957
- console.log(" src/Start.tw");
7601
+ `
7602
+ }
7603
+ ];
7604
+ /**
7605
+ * Writes `content` to `path` unless the file already exists.
7606
+ * The exclusive flag makes the check and the write one step, so an existing file is never truncated.
7607
+ * Returns false when the file was already there.
7608
+ */
7609
+ function writeNewFile(path, content) {
7610
+ try {
7611
+ writeFileSync(path, content, { flag: "wx" });
7612
+ return true;
7613
+ } catch (e) {
7614
+ if (e instanceof Error && "code" in e && e.code === "EEXIST") return false;
7615
+ throw new Error(`Cannot create ${path}: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
7616
+ }
7617
+ }
7618
+ function runInit() {
7619
+ console.log("Initializing new twee-ts project...");
7620
+ mkdirSync("src", { recursive: true });
7621
+ const results = SCAFFOLD_FILES.map((file) => ({
7622
+ path: file.path,
7623
+ created: writeNewFile(file.path, file.content())
7624
+ }));
7625
+ const created = results.filter((r) => r.created).map((r) => r.path);
7626
+ const skipped = results.filter((r) => !r.created).map((r) => r.path);
7627
+ if (created.length > 0) {
7628
+ console.log("Created:");
7629
+ for (const path of created) console.log(` ${path}`);
7630
+ }
7631
+ for (const path of skipped) console.log(`Skipped (already exists): ${path}`);
6958
7632
  console.log("\nRun: npx @rohal12/twee-ts");
6959
7633
  }
6960
7634
  function formatBytes(bytes) {
@@ -7033,6 +7707,7 @@ Options:
7033
7707
  --format-index <url> SFA-compatible format index URL (repeatable)
7034
7708
  --format-url <url> Direct format.js URL (repeatable)
7035
7709
  --tag-alias <alias=target> Map a tag to a special tag (repeatable)
7710
+ --exclude <glob> Leave out source files matching a glob (repeatable)
7036
7711
  --source-info Emit source file/line as data- attributes on passages
7037
7712
  --word-count-method <m> Word counting method: tweego (default), whitespace
7038
7713
  --no-remote Disable remote format fetching