@rohal12/twee-ts 1.16.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -133,6 +133,61 @@ function commentSanitize(s) {
133
133
  function htmlCommentSanitize(s) {
134
134
  return s.replace(/-->/g, "-- >");
135
135
  }
136
+ /**
137
+ * Escape JavaScript for the content of an HTML `<script>` element, so that the HTML parser reads all of it as
138
+ * the element's text and ends the element at the end tag written after it.
139
+ *
140
+ * - Every `<\/script` (in any letter case) becomes `<\/script`. HTML ends a script element at the first
141
+ * `<\/script` followed by whitespace, `/` or `>`, wherever it is in the JavaScript, even inside a string.
142
+ * - Every `<!--` becomes `<\!--`, but only if the text would otherwise end in the HTML tokenizer's "script
143
+ * data double escaped" state: after a `<!--` and then a `<script` start tag with no `-->` between them, the
144
+ * end tag after the text would not end the element.
145
+ *
146
+ * Inserting a backslash after `<` keeps the value of every string, template, and regular expression literal
147
+ * and every comment where these sequences can be written (`\/` and `\!` are identity escapes; a backslash
148
+ * already before the `<` escapes the `<`, as it did before); `<\/script` is also how minifiers such as Terser
149
+ * write inline scripts. It only changes code that uses them outside literals (`a <\/script/ b`, an HTML-like `<!--`
150
+ * comment that is escaped because of a later `<script`), the raw text of a tagged template, and a `<!--` in a
151
+ * regular expression with the `u` or `v` flag. The result is unchanged by escaping it again.
152
+ */
153
+ function scriptContentEscape(s) {
154
+ const escaped = s.replace(/<(?=\/script)/gi, "<\\");
155
+ return endsDoubleEscaped(escaped) ? escaped.replace(/<(?=!--)/g, "<\\") : escaped;
156
+ }
157
+ /**
158
+ * Whether the HTML tokenizer, reading `s` as script element content with no `<\/script` in it, ends in the
159
+ * "script data double escaped" state, where an end tag after `s` would not end the element.
160
+ * See https://html.spec.whatwg.org/multipage/parsing.html#script-data-escaped-state.
161
+ */
162
+ function endsDoubleEscaped(s) {
163
+ const scriptStartTag = /<script[\t\n\f\r />]/gi;
164
+ let pos = 0;
165
+ for (;;) {
166
+ const open = s.indexOf("<!--", pos);
167
+ if (open === -1) return false;
168
+ const escapedFrom = open + 2;
169
+ const close = s.indexOf("-->", escapedFrom);
170
+ scriptStartTag.lastIndex = escapedFrom;
171
+ const start = scriptStartTag.exec(s);
172
+ if (start === null || close !== -1 && close < start.index) {
173
+ if (close === -1) return false;
174
+ pos = close + 3;
175
+ continue;
176
+ }
177
+ const end = s.indexOf("-->", start.index + start[0].length);
178
+ if (end === -1) return true;
179
+ pos = end + 3;
180
+ }
181
+ }
182
+ /**
183
+ * Escape CSS for the content of an HTML `<style>` element: every `</style` (in any letter case) becomes
184
+ * `<\/style`, since HTML ends a style element at the first `</style` followed by whitespace, `/` or `>`.
185
+ * `\/` stands for `/` in CSS strings and URLs, and comments don't matter; `</style` anywhere else isn't valid
186
+ * CSS anyway.
187
+ */
188
+ function styleContentEscape(s) {
189
+ return s.replace(/<(?=\/style)/gi, "<\\");
190
+ }
136
191
  /** Apply ROT13 encoding to a string (only affects [A-Za-z]). */
137
192
  function rot13(s) {
138
193
  if (s.length === 0) return s;
@@ -714,10 +769,35 @@ function getFilenames(pathnames, outFilename, exclude = []) {
714
769
  diagnostics
715
770
  };
716
771
  }
772
+ /** Whether the absolute path `file` is the absolute path `dir` or inside it. */
773
+ function isInsideDir(file, dir) {
774
+ return file === dir || file.startsWith(dir.endsWith(node_path.sep) ? dir : dir + node_path.sep);
775
+ }
776
+ /**
777
+ * The watched paths split into folders (as given) and files (absolute). A path
778
+ * that cannot be read counts as a folder, which fs.watch then fails to watch.
779
+ */
780
+ function splitWatchRoots(pathnames) {
781
+ const dirs = [];
782
+ const files = /* @__PURE__ */ new Set();
783
+ for (const pathname of pathnames) {
784
+ let isFile = false;
785
+ try {
786
+ isFile = (0, node_fs.statSync)(pathname).isFile();
787
+ } catch {}
788
+ if (isFile) files.add((0, node_path.resolve)(pathname));
789
+ else dirs.push(pathname);
790
+ }
791
+ return {
792
+ dirs,
793
+ files
794
+ };
795
+ }
717
796
  /**
718
797
  * Watch paths for changes, calling the build callback on known file type changes.
719
- * A change to a file `ignore` returns true for (given the path relative to the
720
- * working directory) schedules no build.
798
+ * A folder is watched recursively; a file is watched on its own and counts
799
+ * whatever its type (a head file, say). A change to a file `ignore` returns true
800
+ * for (given the path relative to the working directory) schedules no build.
721
801
  * Uses debouncing to avoid rapid rebuilds.
722
802
  */
723
803
  function watchFilesystem(pathnames, outFilename, callback, ignore = () => false) {
@@ -736,18 +816,30 @@ function watchFilesystem(pathnames, outFilename, callback, ignore = () => false)
736
816
  callback(files);
737
817
  }, BUILD_DEBOUNCE);
738
818
  }
739
- for (const pathname of pathnames) try {
740
- const watcher = (0, node_fs.watch)(pathname, { recursive: true }, (_event, filename) => {
741
- if (!filename) return;
742
- const abs = (0, node_path.resolve)(pathname, filename);
743
- if (abs === absOutFile) return;
744
- if (isKnownFileType(filename)) {
745
- const rel = (0, node_path.relative)(process.cwd(), abs);
746
- if (!ignore(rel || abs)) scheduleBuild(rel || abs);
747
- }
748
- });
749
- watchers.push(watcher);
750
- } catch {}
819
+ function fileChanged(abs, named) {
820
+ if (abs === absOutFile) return;
821
+ if (!named && !isKnownFileType(abs)) return;
822
+ const rel = (0, node_path.relative)(process.cwd(), abs);
823
+ if (!ignore(rel || abs)) scheduleBuild(rel || abs);
824
+ }
825
+ function watchPath(pathname, recursive, listener) {
826
+ try {
827
+ watchers.push((0, node_fs.watch)(pathname, { recursive }, (_event, filename) => {
828
+ if (filename) listener(filename);
829
+ }));
830
+ } catch {}
831
+ }
832
+ const { dirs, files } = splitWatchRoots(pathnames);
833
+ const absDirs = dirs.map((dir) => (0, node_path.resolve)(dir));
834
+ for (const dir of dirs) watchPath(dir, true, (filename) => {
835
+ const abs = (0, node_path.resolve)(dir, filename);
836
+ fileChanged(abs, files.has(abs));
837
+ });
838
+ const fileDirs = new Set([...files].filter((file) => !absDirs.some((dir) => isInsideDir(file, dir))).map((file) => (0, node_path.dirname)(file)));
839
+ for (const dir of fileDirs) watchPath(dir, false, (filename) => {
840
+ const abs = (0, node_path.resolve)(dir, filename);
841
+ if (files.has(abs)) fileChanged(abs, true);
842
+ });
751
843
  callback();
752
844
  return { close() {
753
845
  if (buildTimer) clearTimeout(buildTimer);
@@ -755,16 +847,25 @@ function watchFilesystem(pathnames, outFilename, callback, ignore = () => false)
755
847
  } };
756
848
  }
757
849
  //#endregion
850
+ //#region src/source-text.ts
851
+ /**
852
+ * Source text normalization shared by every input path (files, in-memory sources, `parseTwee`).
853
+ */
854
+ /**
855
+ * Strip a leading UTF-8 byte order mark and convert CRLF and bare CR line endings to LF.
856
+ * Idempotent, so text that is already normalized passes through unchanged.
857
+ */
858
+ function normalizeSourceText(text) {
859
+ return (text.charCodeAt(0) === 65279 ? text.slice(1) : text).replace(/\r\n?/g, "\n");
860
+ }
861
+ //#endregion
758
862
  //#region src/util.ts
759
863
  /**
760
864
  * Shared file I/O utilities.
761
865
  */
762
866
  /** Read a file as UTF-8 with BOM stripping and line ending normalization. */
763
867
  function readUTF8(filename) {
764
- let content = (0, node_fs.readFileSync)(filename, "utf-8");
765
- if (content.charCodeAt(0) === 65279) content = content.slice(1);
766
- content = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
767
- return content;
868
+ return normalizeSourceText((0, node_fs.readFileSync)(filename, "utf-8"));
768
869
  }
769
870
  /** Read a file as base64. */
770
871
  function readBase64(filename) {
@@ -794,16 +895,28 @@ function relaxedJSONParse(json) {
794
895
  fixed = fixed.replace(/,\s*([}\]])/g, "$1");
795
896
  return JSON.parse(fixed);
796
897
  }
898
+ /** A `"setup": function` property, which Harlowe appends to its otherwise-JSON format object. */
899
+ const SETUP_FUNCTION_PROPERTY = /,\s*"setup"\s*:\s*function\b/g;
900
+ /** Drop a trailing `"setup": function(){…}` property from a format object, if it has one. */
901
+ function stripSetupFunction(chunk) {
902
+ let lastIndex = -1;
903
+ for (const match of chunk.matchAll(SETUP_FUNCTION_PROPERTY)) lastIndex = match.index;
904
+ return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
905
+ }
797
906
  /**
798
907
  * Parse the Twine 2 format.js JSON chunk.
799
- * Handles Harlowe's malformed JSON by stripping the "setup" function property.
908
+ * Handles Harlowe's malformed JSON by stripping the "setup" function property. The workaround
909
+ * keys on the property itself, so the same bytes parse whether they come from a `harlowe-3`
910
+ * directory or a direct download.
800
911
  * Per spec, the name key is Optional.
912
+ *
913
+ * @param _formatId No longer used; kept so existing callers keep compiling.
801
914
  */
802
- function parseFormatJSON(source, formatId) {
915
+ function parseFormatJSON(source, _formatId) {
803
916
  const first = source.indexOf("{");
804
917
  const last = source.lastIndexOf("}");
805
918
  if (first === -1 || last === -1) return null;
806
- let chunk = source.slice(first, last + 1);
919
+ const chunk = source.slice(first, last + 1);
807
920
  const parse = (json) => {
808
921
  const raw = relaxedJSONParse(json);
809
922
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
@@ -826,16 +939,13 @@ function parseFormatJSON(source, formatId) {
826
939
  try {
827
940
  return parse(chunk);
828
941
  } catch {
829
- if (formatId.toLowerCase().startsWith("harlowe")) {
830
- const setupIdx = chunk.lastIndexOf(",\"setup\": function");
831
- if (setupIdx !== -1) {
832
- chunk = chunk.slice(0, setupIdx) + "}";
833
- try {
834
- return parse(chunk);
835
- } catch {}
836
- }
942
+ const stripped = stripSetupFunction(chunk);
943
+ if (stripped === void 0) return null;
944
+ try {
945
+ return parse(stripped);
946
+ } catch {
947
+ return null;
837
948
  }
838
- return null;
839
949
  }
840
950
  }
841
951
  /**
@@ -985,6 +1095,54 @@ function semverCompare(a, b) {
985
1095
  if (aPatch !== bPatch) return aPatch - bPatch;
986
1096
  return 0;
987
1097
  }
1098
+ /** Build a directory-style format ID from a name and version, e.g. ('SugarCube', '2.37.3') → 'sugarcube-2'. */
1099
+ function makeFormatId(name, version) {
1100
+ const major = parseSemver(version)?.[0] ?? 0;
1101
+ return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
1102
+ }
1103
+ /** Describe a format request for diagnostics. */
1104
+ function describeFormatRequest(request) {
1105
+ switch (request.kind) {
1106
+ case "id": return `"${request.id}"`;
1107
+ case "name": return request.version ? `"${request.name}" at version "${request.version}"` : `"${request.name}"`;
1108
+ default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
1109
+ }
1110
+ }
1111
+ /**
1112
+ * Pick the candidate that best answers a format request.
1113
+ *
1114
+ * - An ID request ('sugarcube-2') matches a candidate whose name and major version build that ID,
1115
+ * and takes the greatest such version.
1116
+ * - A name request matches the name case-insensitively. An exact version wins outright; otherwise
1117
+ * the greatest version with the same major that is not older than the one asked for (or, with
1118
+ * `allowOlder`, the greatest same-major version). An unparseable requested version takes the
1119
+ * greatest version of any major.
1120
+ */
1121
+ function selectFormatCandidate(request, candidates, describe, options = {}) {
1122
+ const allowOlder = options.allowOlder ?? false;
1123
+ const wanted = request.kind === "name" ? parseSemver(request.version) : null;
1124
+ let best;
1125
+ for (const candidate of candidates) {
1126
+ const { name, version } = describe(candidate);
1127
+ const have = parseSemver(version);
1128
+ if (!have) continue;
1129
+ if (request.kind === "id") {
1130
+ if (makeFormatId(name, version) !== request.id.toLowerCase()) continue;
1131
+ } else {
1132
+ if (name.toLowerCase() !== request.name.toLowerCase()) continue;
1133
+ if (wanted) {
1134
+ if (semverCompare(have, wanted) === 0) return candidate;
1135
+ if (have[0] !== wanted[0]) continue;
1136
+ if (!allowOlder && semverCompare(have, wanted) < 0) continue;
1137
+ }
1138
+ }
1139
+ if (!best || semverCompare(have, best.version) > 0) best = {
1140
+ candidate,
1141
+ version: have
1142
+ };
1143
+ }
1144
+ return best?.candidate;
1145
+ }
988
1146
  /** Get format ID from Twine 2 name and version, using SemVer major matching. */
989
1147
  function getFormatIdByNameAndVersion(formats, name, version) {
990
1148
  const wanted = parseSemver(version);
@@ -1014,171 +1172,586 @@ function readFormatSource(format) {
1014
1172
  return source;
1015
1173
  }
1016
1174
  //#endregion
1017
- //#region src/types.ts
1018
- const ItemType = {
1019
- Error: 0,
1020
- EOF: 1,
1021
- Header: 2,
1022
- Name: 3,
1023
- Tags: 4,
1024
- Metadata: 5,
1025
- Content: 6
1026
- };
1027
- //#endregion
1028
- //#region src/lexer.ts
1029
- const EOF = -1;
1030
- const HEADER_DELIM = "::";
1031
- const NEWLINE_HEADER_DELIM = "\n::";
1032
- var LexerContext = class {
1033
- input;
1034
- line = 1;
1035
- start = 0;
1036
- pos = 0;
1037
- items = [];
1038
- constructor(input) {
1039
- this.input = input;
1040
- }
1041
- next() {
1042
- if (this.pos >= this.input.length) return EOF;
1043
- const ch = this.input.charCodeAt(this.pos);
1044
- this.pos++;
1045
- if (ch === 10) this.line++;
1046
- return ch;
1047
- }
1048
- peek() {
1049
- if (this.pos >= this.input.length) return EOF;
1050
- return this.input.charCodeAt(this.pos);
1051
- }
1052
- backup() {
1053
- if (this.pos > this.start) {
1054
- this.pos--;
1055
- if (this.input.charCodeAt(this.pos) === 10) this.line--;
1056
- } else throw new Error("backup would leave pos < start");
1057
- }
1058
- emit(type) {
1059
- const val = this.input.slice(this.start, this.pos);
1060
- this.items.push({
1061
- type,
1062
- line: this.line,
1063
- pos: this.start,
1064
- val
1065
- });
1066
- if (type === ItemType.Content) {
1067
- for (let i = this.start; i < this.pos; i++) if (this.input.charCodeAt(i) === 10) this.line++;
1068
- }
1069
- this.start = this.pos;
1070
- }
1071
- ignore() {
1072
- for (let i = this.start; i < this.pos; i++) if (this.input.charCodeAt(i) === 10) this.line++;
1073
- this.start = this.pos;
1074
- }
1075
- acceptRun(valid) {
1076
- let ch;
1077
- for (ch = this.next(); ch !== EOF && valid.includes(String.fromCharCode(ch)); ch = this.next());
1078
- if (ch !== EOF) this.backup();
1079
- }
1080
- errorf(message) {
1081
- this.items.push({
1082
- type: ItemType.Error,
1083
- line: this.line,
1084
- pos: this.start,
1085
- val: message
1086
- });
1087
- return null;
1088
- }
1089
- /** Drain all pending items and return them. */
1090
- drainItems() {
1091
- const result = this.items;
1092
- this.items = [];
1093
- return result;
1094
- }
1095
- };
1096
- function acceptQuoted(ctx, quote) {
1097
- for (;;) {
1098
- const ch = ctx.next();
1099
- switch (ch) {
1100
- case 92: {
1101
- const r = ctx.next();
1102
- if (r !== 10 && r !== EOF) break;
1103
- }
1104
- case 10:
1105
- case EOF: return "unterminated quoted string";
1106
- default: if (ch === quote) return null;
1107
- }
1108
- }
1175
+ //#region src/remote-formats.ts
1176
+ /**
1177
+ * Remote story format fetching, caching, and checksum verification.
1178
+ * Uses the Story Formats Archive (SFA) as the default source.
1179
+ */
1180
+ const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
1181
+ /** Get the cache directory for downloaded story formats. */
1182
+ function getCacheDir() {
1183
+ const base = process.env["XDG_CACHE_HOME"] || (0, node_path.join)((0, node_os.homedir)(), ".cache");
1184
+ return (0, node_path.join)(base, "twee-ts", "storyformats");
1109
1185
  }
1110
- function lexProlog(ctx) {
1111
- if (ctx.input.startsWith(HEADER_DELIM, ctx.pos)) return lexHeaderDelim;
1112
- const i = ctx.input.indexOf(NEWLINE_HEADER_DELIM, ctx.pos);
1113
- if (i > -1) {
1114
- ctx.pos = i + 1;
1115
- ctx.ignore();
1116
- return lexHeaderDelim;
1186
+ /**
1187
+ * Whether a string can serve as one directory name inside the cache: not empty, not `.` or `..`,
1188
+ * and free of path separators. Format names and versions come from downloaded metadata, so they
1189
+ * are untrusted path input.
1190
+ */
1191
+ function isSafeSegment(segment) {
1192
+ return segment !== "" && segment !== "." && segment !== ".." && !/[/\\\0]/.test(segment);
1193
+ }
1194
+ /** Whether absolute path `target` lies strictly inside absolute path `root`. */
1195
+ function isInside(root, target) {
1196
+ const rel = (0, node_path.relative)(root, target);
1197
+ return rel !== "" && !(0, node_path.isAbsolute)(rel) && rel.split(node_path.sep)[0] !== "..";
1198
+ }
1199
+ /** The cache directory for one format version. Throws when the name or version could leave the cache. */
1200
+ function cachedFormatDir(name, version) {
1201
+ if (!isSafeSegment(name)) throw new Error(`Refusing to cache a story format with an unsafe name: ${JSON.stringify(name)}`);
1202
+ if (!isSafeSegment(version) || !parseSemver(version)) throw new Error(`Refusing to cache story format "${name}" with an unsafe version: ${JSON.stringify(version)}`);
1203
+ const root = (0, node_path.resolve)(getCacheDir());
1204
+ const dir = (0, node_path.resolve)(root, name, version);
1205
+ if (!isInside(root, dir)) throw new Error(`Refusing to cache a story format outside the cache directory ${root}: ${dir}`);
1206
+ return dir;
1207
+ }
1208
+ /** Create `dir` as a plain directory, or check that it already is one (a symlink could lead out of the cache). */
1209
+ function ensurePlainDirectory(dir) {
1210
+ const stat = (0, node_fs.lstatSync)(dir, { throwIfNoEntry: false });
1211
+ if (stat === void 0) {
1212
+ (0, node_fs.mkdirSync)(dir);
1213
+ return;
1117
1214
  }
1118
- ctx.emit(ItemType.EOF);
1119
- return null;
1215
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`Refusing to write a story format outside the cache directory: ${dir} is not a plain directory`);
1216
+ }
1217
+ /** Write a downloaded format.js to `<cache>/<name>/<version>/format.js` and return its path. */
1218
+ function writeCachedFormat(name, version, text) {
1219
+ const dir = cachedFormatDir(name, version);
1220
+ const root = (0, node_path.resolve)(getCacheDir());
1221
+ (0, node_fs.mkdirSync)(root, { recursive: true });
1222
+ ensurePlainDirectory((0, node_path.join)(root, name));
1223
+ ensurePlainDirectory(dir);
1224
+ const formatPath = (0, node_path.join)(dir, "format.js");
1225
+ if ((0, node_fs.lstatSync)(formatPath, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error(`Refusing to write a story format outside the cache directory: ${formatPath} is a symlink`);
1226
+ (0, node_fs.writeFileSync)(formatPath, text, "utf-8");
1227
+ return formatPath;
1120
1228
  }
1121
- function lexContent(ctx) {
1122
- if (ctx.input.startsWith(HEADER_DELIM, ctx.pos)) return lexHeaderDelim;
1123
- const i = ctx.input.indexOf(NEWLINE_HEADER_DELIM, ctx.pos);
1124
- if (i > -1) {
1125
- ctx.pos = i + 1;
1126
- ctx.emit(ItemType.Content);
1127
- return lexHeaderDelim;
1128
- }
1129
- ctx.pos = ctx.input.length;
1130
- if (ctx.pos > ctx.start) ctx.emit(ItemType.Content);
1131
- ctx.emit(ItemType.EOF);
1132
- return null;
1229
+ /** In-memory index cache, keyed by URL. Cleared each compile. */
1230
+ const indexCache = /* @__PURE__ */ new Map();
1231
+ /** Clear the in-memory index cache. */
1232
+ function clearIndexCache() {
1233
+ indexCache.clear();
1133
1234
  }
1134
- function lexHeaderDelim(ctx) {
1135
- ctx.pos += 2;
1136
- ctx.emit(ItemType.Header);
1137
- return lexName;
1235
+ /** Validate that a JSON value is a valid SFAIndexEntry. */
1236
+ function isValidEntry(val) {
1237
+ if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
1238
+ const obj = val;
1239
+ return typeof obj.name === "string" && typeof obj.version === "string" && typeof obj.checksums === "object" && obj.checksums !== null && !Array.isArray(obj.checksums);
1138
1240
  }
1139
- function lexName(ctx) {
1140
- let r;
1141
- outer: for (;;) {
1142
- r = ctx.next();
1143
- switch (r) {
1144
- case 92: {
1145
- const next = ctx.next();
1146
- if (next !== 10 && next !== EOF) break;
1147
- r = next;
1148
- }
1149
- case 91:
1150
- case 93:
1151
- case 123:
1152
- case 125:
1153
- case 10:
1154
- case EOF:
1155
- if (r !== EOF) ctx.backup();
1156
- break outer;
1157
- }
1158
- }
1159
- ctx.emit(ItemType.Name);
1160
- switch (r) {
1161
- case 91: return lexTags;
1162
- case 93: return ctx.errorf(`unexpected right square bracket ']'`);
1163
- case 123: return lexMetadata;
1164
- case 125: return ctx.errorf(`unexpected right curly brace '}'`);
1165
- case 10:
1166
- ctx.pos++;
1167
- ctx.ignore();
1168
- return lexContent;
1169
- }
1170
- ctx.emit(ItemType.EOF);
1171
- return null;
1241
+ /** Validate that a JSON value conforms to the SFAIndex shape. */
1242
+ function validateSFAIndex(json) {
1243
+ if (typeof json !== "object" || json === null) throw new Error("SFA index is not an object");
1244
+ const obj = json;
1245
+ return {
1246
+ twine1: Array.isArray(obj["twine1"]) ? obj["twine1"].filter(isValidEntry) : [],
1247
+ twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
1248
+ };
1172
1249
  }
1173
- function lexNextOptionalBlock(ctx) {
1174
- ctx.acceptRun(" ");
1175
- ctx.ignore();
1176
- const r = ctx.peek();
1177
- switch (r) {
1178
- case 91: return lexTags;
1179
- case 93: return ctx.errorf(`unexpected right square bracket ']'`);
1180
- case 123: return lexMetadata;
1181
- case 125: return ctx.errorf(`unexpected right curly brace '}'`);
1250
+ /** Fetch and parse an SFA index.json, with in-memory caching. */
1251
+ async function fetchIndex(url) {
1252
+ const cached = indexCache.get(url);
1253
+ if (cached) return cached;
1254
+ const res = await fetch(url);
1255
+ if (!res.ok) throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
1256
+ const data = validateSFAIndex(await res.json());
1257
+ indexCache.set(url, data);
1258
+ return data;
1259
+ }
1260
+ /** Find the best matching entry in an SFA index for a name or ID request (twine2 entries first). */
1261
+ function findEntryForRequest(index, request) {
1262
+ return selectFormatCandidate(request, [...(index.twine2 ?? []).map((entry) => ({
1263
+ entry,
1264
+ formatType: "twine2"
1265
+ })), ...(index.twine1 ?? []).map((entry) => ({
1266
+ entry,
1267
+ formatType: "twine1"
1268
+ }))], (c) => c.entry);
1269
+ }
1270
+ /** Verify SHA-256 checksum using Web Crypto API (Node 22 built-in). */
1271
+ async function verifySHA256(content, expectedHex) {
1272
+ const digest = await crypto.subtle.digest("SHA-256", content);
1273
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("") === expectedHex.toLowerCase();
1274
+ }
1275
+ /** Derive the download URL for a format.js from the index URL and entry. */
1276
+ function getDownloadUrl(indexUrl, entry, formatType) {
1277
+ return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
1278
+ }
1279
+ /** Download a format, verify its checksum, write to cache, and return StoryFormatInfo. */
1280
+ async function fetchAndCacheFormat(entry, downloadUrl) {
1281
+ cachedFormatDir(entry.name, entry.version);
1282
+ const res = await fetch(downloadUrl);
1283
+ if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
1284
+ const text = await res.text();
1285
+ const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
1286
+ if (checksumKey) {
1287
+ const expected = entry.checksums[checksumKey];
1288
+ if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
1289
+ if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
1290
+ }
1291
+ const id = makeFormatId(entry.name, entry.version);
1292
+ const data = parseFormatJSON(text, id);
1293
+ if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
1294
+ return {
1295
+ id,
1296
+ filename: writeCachedFormat(entry.name, entry.version, text),
1297
+ isTwine2: true,
1298
+ name: data.name,
1299
+ version: data.version,
1300
+ proofing: data.proofing ?? false
1301
+ };
1302
+ }
1303
+ /** Download a direct format.js URL, parse its JSON, cache it, and return StoryFormatInfo. */
1304
+ async function fetchDirectFormat(url) {
1305
+ const res = await fetch(url);
1306
+ if (!res.ok) throw new Error(`Failed to download format from ${url}: ${res.status} ${res.statusText}`);
1307
+ const text = await res.text();
1308
+ const data = parseFormatJSON(text);
1309
+ if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
1310
+ return {
1311
+ id: makeFormatId(data.name, data.version),
1312
+ filename: writeCachedFormat(data.name, data.version, text),
1313
+ isTwine2: true,
1314
+ name: data.name,
1315
+ version: data.version,
1316
+ proofing: data.proofing ?? false
1317
+ };
1318
+ }
1319
+ /**
1320
+ * Try to resolve a remote story format by name and version.
1321
+ * 0. Use an exactly matching cached download, without touching the network
1322
+ * 1. Try direct format URLs
1323
+ * 2. Try custom index URLs
1324
+ * 3. Try default SFA indices
1325
+ * 4. Fall back to a compatible cached download (e.g. when offline)
1326
+ */
1327
+ async function resolveRemoteFormat(name, version, indices, urls) {
1328
+ return resolveRemoteFormatRequest({
1329
+ kind: "name",
1330
+ name,
1331
+ version
1332
+ }, indices, urls);
1333
+ }
1334
+ /**
1335
+ * Resolve a story format request remotely, in the same order as {@link resolveRemoteFormat}.
1336
+ * An ID request such as 'sugarcube-2' matches the format whose name and major version build that ID
1337
+ * (SugarCube 2.x), taking the greatest version available.
1338
+ */
1339
+ async function resolveRemoteFormatRequest(request, indices, urls) {
1340
+ let lastError;
1341
+ const cached = findCachedFormat(request);
1342
+ if (cached && request.kind === "name" && isExactVersion(cached.version, request.version)) return cached;
1343
+ for (const url of urls ?? []) try {
1344
+ const info = await fetchDirectFormat(url);
1345
+ if (selectFormatCandidate(request, [info], (f) => f)) return info;
1346
+ } catch (e) {
1347
+ lastError = e instanceof Error ? e : new Error(String(e));
1348
+ }
1349
+ const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
1350
+ for (const indexUrl of allIndices) try {
1351
+ const result = findEntryForRequest(await fetchIndex(indexUrl), request);
1352
+ if (result) {
1353
+ const hit = getCachedFormat(result.entry.name, result.entry.version);
1354
+ if (hit) return hit;
1355
+ const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
1356
+ return await fetchAndCacheFormat(result.entry, downloadUrl);
1357
+ }
1358
+ } catch (e) {
1359
+ lastError = e instanceof Error ? e : new Error(String(e));
1360
+ }
1361
+ if (cached) return cached;
1362
+ if (lastError) throw lastError;
1363
+ }
1364
+ /** Whether two version strings name the same SemVer version. */
1365
+ function isExactVersion(have, wanted) {
1366
+ const a = parseSemver(have);
1367
+ const b = parseSemver(wanted);
1368
+ return a !== null && b !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
1369
+ }
1370
+ /**
1371
+ * Find the cached download that best answers a format request, without network access.
1372
+ * Matching follows {@link selectFormatCandidate}.
1373
+ */
1374
+ function findCachedFormat(request, options = {}) {
1375
+ return selectFormatCandidate(request, [...discoverCachedFormats().values()], (f) => f, options);
1376
+ }
1377
+ /** Check if a format is already in the local cache. */
1378
+ function getCachedFormat(name, version) {
1379
+ if (!isSafeSegment(name) || !isSafeSegment(version)) return void 0;
1380
+ const formatPath = (0, node_path.join)(getCacheDir(), name, version, "format.js");
1381
+ try {
1382
+ if (!(0, node_fs.existsSync)(formatPath)) return void 0;
1383
+ const source = (0, node_fs.readFileSync)(formatPath, "utf-8");
1384
+ const id = makeFormatId(name, version);
1385
+ const data = parseFormatJSON(source, id);
1386
+ if (!data) return void 0;
1387
+ return {
1388
+ id,
1389
+ filename: formatPath,
1390
+ isTwine2: true,
1391
+ name: data.name,
1392
+ version: data.version,
1393
+ proofing: data.proofing ?? false
1394
+ };
1395
+ } catch {
1396
+ return;
1397
+ }
1398
+ }
1399
+ /** Discover all cached remote formats. Returns a Map like discoverFormats(). */
1400
+ function discoverCachedFormats() {
1401
+ const formats = /* @__PURE__ */ new Map();
1402
+ const cacheDir = getCacheDir();
1403
+ try {
1404
+ if (!(0, node_fs.existsSync)(cacheDir)) return formats;
1405
+ } catch {
1406
+ return formats;
1407
+ }
1408
+ let names;
1409
+ try {
1410
+ names = (0, node_fs.readdirSync)(cacheDir);
1411
+ } catch {
1412
+ return formats;
1413
+ }
1414
+ for (const name of names) {
1415
+ const nameDir = (0, node_path.join)(cacheDir, name);
1416
+ try {
1417
+ if (!(0, node_fs.statSync)(nameDir).isDirectory()) continue;
1418
+ } catch {
1419
+ continue;
1420
+ }
1421
+ let versions;
1422
+ try {
1423
+ versions = (0, node_fs.readdirSync)(nameDir);
1424
+ } catch {
1425
+ continue;
1426
+ }
1427
+ for (const version of versions) {
1428
+ const info = getCachedFormat(name, version);
1429
+ if (info) formats.set(`${info.id}-${version}`, info);
1430
+ }
1431
+ }
1432
+ return formats;
1433
+ }
1434
+ /** List all cached formats with size and modification date. */
1435
+ function listCachedFormats() {
1436
+ const cacheDir = getCacheDir();
1437
+ const entries = [];
1438
+ try {
1439
+ if (!(0, node_fs.existsSync)(cacheDir)) return entries;
1440
+ } catch {
1441
+ return entries;
1442
+ }
1443
+ let names;
1444
+ try {
1445
+ names = (0, node_fs.readdirSync)(cacheDir);
1446
+ } catch {
1447
+ return entries;
1448
+ }
1449
+ for (const name of names) {
1450
+ const nameDir = (0, node_path.join)(cacheDir, name);
1451
+ try {
1452
+ if (!(0, node_fs.statSync)(nameDir).isDirectory()) continue;
1453
+ } catch {
1454
+ continue;
1455
+ }
1456
+ let versions;
1457
+ try {
1458
+ versions = (0, node_fs.readdirSync)(nameDir);
1459
+ } catch {
1460
+ continue;
1461
+ }
1462
+ for (const version of versions) {
1463
+ const formatPath = (0, node_path.join)(nameDir, version, "format.js");
1464
+ try {
1465
+ const stat = (0, node_fs.statSync)(formatPath);
1466
+ entries.push({
1467
+ name,
1468
+ version,
1469
+ sizeBytes: stat.size,
1470
+ modifiedAt: stat.mtime
1471
+ });
1472
+ } catch {}
1473
+ }
1474
+ }
1475
+ return entries;
1476
+ }
1477
+ /**
1478
+ * Clear all cached formats, or only those matching a given name. Returns the number of entries removed.
1479
+ * A name must be a single cache entry name (as `listCachedFormats()` reports it); anything with a
1480
+ * path separator or `.`/`..` throws instead of deleting outside the format's own directory.
1481
+ */
1482
+ function clearCachedFormats(name) {
1483
+ 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.`);
1484
+ const cacheDir = getCacheDir();
1485
+ if (!(0, node_fs.existsSync)(cacheDir)) return 0;
1486
+ if (!name) {
1487
+ const count = listCachedFormats().length;
1488
+ (0, node_fs.rmSync)(cacheDir, {
1489
+ recursive: true,
1490
+ force: true
1491
+ });
1492
+ return count;
1493
+ }
1494
+ const root = (0, node_path.resolve)(cacheDir);
1495
+ const nameDir = (0, node_path.resolve)(root, name);
1496
+ if (!isInside(root, nameDir)) throw new Error(`Refusing to clear ${JSON.stringify(name)}: it is not a cached format name.`);
1497
+ if (!(0, node_fs.lstatSync)(nameDir, { throwIfNoEntry: false })?.isDirectory()) return 0;
1498
+ let versions;
1499
+ try {
1500
+ versions = (0, node_fs.readdirSync)(nameDir);
1501
+ } catch {
1502
+ return 0;
1503
+ }
1504
+ const count = versions.length;
1505
+ (0, node_fs.rmSync)(nameDir, {
1506
+ recursive: true,
1507
+ force: true
1508
+ });
1509
+ return count;
1510
+ }
1511
+ /** Get total cache size in bytes and format count. */
1512
+ function getCacheSize() {
1513
+ const entries = listCachedFormats();
1514
+ return {
1515
+ totalBytes: entries.reduce((sum, e) => sum + e.sizeBytes, 0),
1516
+ count: entries.length
1517
+ };
1518
+ }
1519
+ //#endregion
1520
+ //#region src/format-resolution.ts
1521
+ /** Choose the format request: explicit format ID > StoryData format > default ID. */
1522
+ function formatRequestFor(formatId, storyFormat, storyFormatVersion, defaultId) {
1523
+ if (formatId) return {
1524
+ kind: "id",
1525
+ id: formatId
1526
+ };
1527
+ if (storyFormat) return {
1528
+ kind: "name",
1529
+ name: storyFormat,
1530
+ version: storyFormatVersion
1531
+ };
1532
+ return {
1533
+ kind: "id",
1534
+ id: defaultId
1535
+ };
1536
+ }
1537
+ /** Look a request up among the formats found in local format directories. */
1538
+ function findLocalFormat(formats, request) {
1539
+ switch (request.kind) {
1540
+ case "id": return formats.get(request.id);
1541
+ case "name": {
1542
+ const id = getFormatIdByNameAndVersion(formats, request.name, request.version);
1543
+ return id === void 0 ? void 0 : formats.get(id);
1544
+ }
1545
+ default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
1546
+ }
1547
+ }
1548
+ /**
1549
+ * Resolve a format request to a story format.
1550
+ *
1551
+ * Order: local format directories, then the download cache, then remote sources (unless
1552
+ * `noRemote`). When a StoryData request names a version that none of them has, an older version of
1553
+ * the same format and major version is used with a warning. Anything else is an error diagnostic,
1554
+ * and the result is `undefined`.
1555
+ */
1556
+ async function resolveStoryFormat(request, options, diagnostics) {
1557
+ const noRemote = options.noRemote ?? false;
1558
+ const formats = discoverFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true));
1559
+ const wanted = describeFormatRequest(request);
1560
+ const local = findLocalFormat(formats, request);
1561
+ if (local) return local;
1562
+ const cached = findCachedFormat(request);
1563
+ if (cached) return cached;
1564
+ if (!noRemote) try {
1565
+ const remote = await resolveRemoteFormatRequest(request, options.formatIndices, options.formatUrls);
1566
+ if (remote) return remote;
1567
+ } catch (e) {
1568
+ diagnostics.push({
1569
+ level: "warning",
1570
+ message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
1571
+ });
1572
+ }
1573
+ if (request.kind === "name") {
1574
+ const older = selectFormatCandidate(request, [...formats.values()].filter((f) => f.isTwine2), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
1575
+ if (older) {
1576
+ diagnostics.push({
1577
+ level: "warning",
1578
+ message: `Story format ${wanted} is not available; using ${older.name} ${older.version} instead.`
1579
+ });
1580
+ return older;
1581
+ }
1582
+ }
1583
+ const reason = noRemote ? " (remote fetching disabled)" : "";
1584
+ diagnostics.push({
1585
+ level: "error",
1586
+ message: `Story format ${wanted} is not available${reason}. Found: ${[...formats.keys()].join(", ") || "none"}`
1587
+ });
1588
+ }
1589
+ //#endregion
1590
+ //#region src/types.ts
1591
+ const ItemType = {
1592
+ Error: 0,
1593
+ EOF: 1,
1594
+ Header: 2,
1595
+ Name: 3,
1596
+ Tags: 4,
1597
+ Metadata: 5,
1598
+ Content: 6
1599
+ };
1600
+ //#endregion
1601
+ //#region src/lexer.ts
1602
+ const EOF = -1;
1603
+ const HEADER_DELIM = "::";
1604
+ const NEWLINE_HEADER_DELIM = "\n::";
1605
+ var LexerContext = class {
1606
+ input;
1607
+ line = 1;
1608
+ start = 0;
1609
+ pos = 0;
1610
+ items = [];
1611
+ constructor(input) {
1612
+ this.input = input;
1613
+ }
1614
+ next() {
1615
+ if (this.pos >= this.input.length) return EOF;
1616
+ const ch = this.input.charCodeAt(this.pos);
1617
+ this.pos++;
1618
+ if (ch === 10) this.line++;
1619
+ return ch;
1620
+ }
1621
+ peek() {
1622
+ if (this.pos >= this.input.length) return EOF;
1623
+ return this.input.charCodeAt(this.pos);
1624
+ }
1625
+ backup() {
1626
+ if (this.pos > this.start) {
1627
+ this.pos--;
1628
+ if (this.input.charCodeAt(this.pos) === 10) this.line--;
1629
+ } else throw new Error("backup would leave pos < start");
1630
+ }
1631
+ emit(type) {
1632
+ const val = this.input.slice(this.start, this.pos);
1633
+ this.items.push({
1634
+ type,
1635
+ line: this.line,
1636
+ pos: this.start,
1637
+ val
1638
+ });
1639
+ if (type === ItemType.Content) {
1640
+ for (let i = this.start; i < this.pos; i++) if (this.input.charCodeAt(i) === 10) this.line++;
1641
+ }
1642
+ this.start = this.pos;
1643
+ }
1644
+ ignore() {
1645
+ for (let i = this.start; i < this.pos; i++) if (this.input.charCodeAt(i) === 10) this.line++;
1646
+ this.start = this.pos;
1647
+ }
1648
+ acceptRun(valid) {
1649
+ let ch;
1650
+ for (ch = this.next(); ch !== EOF && valid.includes(String.fromCharCode(ch)); ch = this.next());
1651
+ if (ch !== EOF) this.backup();
1652
+ }
1653
+ errorf(message) {
1654
+ this.items.push({
1655
+ type: ItemType.Error,
1656
+ line: this.line,
1657
+ pos: this.start,
1658
+ val: message
1659
+ });
1660
+ return null;
1661
+ }
1662
+ /** Drain all pending items and return them. */
1663
+ drainItems() {
1664
+ const result = this.items;
1665
+ this.items = [];
1666
+ return result;
1667
+ }
1668
+ };
1669
+ function acceptQuoted(ctx, quote) {
1670
+ for (;;) {
1671
+ const ch = ctx.next();
1672
+ switch (ch) {
1673
+ case 92: {
1674
+ const r = ctx.next();
1675
+ if (r !== 10 && r !== EOF) break;
1676
+ }
1677
+ case 10:
1678
+ case EOF: return "unterminated quoted string";
1679
+ default: if (ch === quote) return null;
1680
+ }
1681
+ }
1682
+ }
1683
+ function lexProlog(ctx) {
1684
+ if (ctx.input.startsWith(HEADER_DELIM, ctx.pos)) return lexHeaderDelim;
1685
+ const i = ctx.input.indexOf(NEWLINE_HEADER_DELIM, ctx.pos);
1686
+ if (i > -1) {
1687
+ ctx.pos = i + 1;
1688
+ ctx.ignore();
1689
+ return lexHeaderDelim;
1690
+ }
1691
+ ctx.emit(ItemType.EOF);
1692
+ return null;
1693
+ }
1694
+ function lexContent(ctx) {
1695
+ if (ctx.input.startsWith(HEADER_DELIM, ctx.pos)) return lexHeaderDelim;
1696
+ const i = ctx.input.indexOf(NEWLINE_HEADER_DELIM, ctx.pos);
1697
+ if (i > -1) {
1698
+ ctx.pos = i + 1;
1699
+ ctx.emit(ItemType.Content);
1700
+ return lexHeaderDelim;
1701
+ }
1702
+ ctx.pos = ctx.input.length;
1703
+ if (ctx.pos > ctx.start) ctx.emit(ItemType.Content);
1704
+ ctx.emit(ItemType.EOF);
1705
+ return null;
1706
+ }
1707
+ function lexHeaderDelim(ctx) {
1708
+ ctx.pos += 2;
1709
+ ctx.emit(ItemType.Header);
1710
+ return lexName;
1711
+ }
1712
+ function lexName(ctx) {
1713
+ let r;
1714
+ outer: for (;;) {
1715
+ r = ctx.next();
1716
+ switch (r) {
1717
+ case 92: {
1718
+ const next = ctx.next();
1719
+ if (next !== 10 && next !== EOF) break;
1720
+ r = next;
1721
+ }
1722
+ case 91:
1723
+ case 93:
1724
+ case 123:
1725
+ case 125:
1726
+ case 10:
1727
+ case EOF:
1728
+ if (r !== EOF) ctx.backup();
1729
+ break outer;
1730
+ }
1731
+ }
1732
+ ctx.emit(ItemType.Name);
1733
+ switch (r) {
1734
+ case 91: return lexTags;
1735
+ case 93: return ctx.errorf(`unexpected right square bracket ']'`);
1736
+ case 123: return lexMetadata;
1737
+ case 125: return ctx.errorf(`unexpected right curly brace '}'`);
1738
+ case 10:
1739
+ ctx.pos++;
1740
+ ctx.ignore();
1741
+ return lexContent;
1742
+ }
1743
+ ctx.emit(ItemType.EOF);
1744
+ return null;
1745
+ }
1746
+ function lexNextOptionalBlock(ctx) {
1747
+ ctx.acceptRun(" ");
1748
+ ctx.ignore();
1749
+ const r = ctx.peek();
1750
+ switch (r) {
1751
+ case 91: return lexTags;
1752
+ case 93: return ctx.errorf(`unexpected right square bracket ']'`);
1753
+ case 123: return lexMetadata;
1754
+ case 125: return ctx.errorf(`unexpected right curly brace '}'`);
1182
1755
  case 10:
1183
1756
  ctx.pos++;
1184
1757
  ctx.ignore();
@@ -1303,16 +1876,20 @@ function twee2ToV3(s) {
1303
1876
  //#region src/parser.ts
1304
1877
  /**
1305
1878
  * Parse Twee source text into passages.
1879
+ *
1880
+ * The source is normalized first, as files are when they are read: a leading UTF-8 BOM is
1881
+ * removed and CRLF and bare CR line endings become LF.
1306
1882
  */
1307
1883
  function parseTwee(source, options = {}) {
1308
1884
  const { filename = "<inline>", trim = true, twee2Compat = false } = options;
1309
1885
  const diagnostics = [];
1310
- if (twee2Compat) source = twee2ToV3(source);
1886
+ const normalized = normalizeSourceText(source);
1887
+ const tweeSource = twee2Compat ? twee2ToV3(normalized) : normalized;
1311
1888
  const passages = [];
1312
1889
  let current = null;
1313
1890
  let pCount = 0;
1314
1891
  let lastType = ItemType.EOF;
1315
- for (const item of tweeLexer(source)) {
1892
+ for (const item of tweeLexer(tweeSource)) {
1316
1893
  switch (item.type) {
1317
1894
  case ItemType.Error:
1318
1895
  diagnostics.push({
@@ -4142,14 +4719,16 @@ function parseDocument(data, options) {
4142
4719
  */
4143
4720
  /**
4144
4721
  * Parse a Twine 2 or Twine 1 compiled HTML file back into a Story model.
4722
+ * Tweego always trims passage text; here `trim: false` keeps it exactly, as for Twee sources.
4145
4723
  */
4146
- function decompileHTML(html) {
4724
+ function decompileHTML(html, options = {}) {
4147
4725
  const diagnostics = [];
4148
4726
  const story = createStory();
4727
+ const passageText = options.trim === false ? (text) => text : (text) => text.trim();
4149
4728
  const doc = parseDocument(html);
4150
4729
  const twine2Data = findElement(doc, "tw-storydata");
4151
4730
  if (twine2Data) {
4152
- decompileTwine2(twine2Data, story, diagnostics);
4731
+ decompileTwine2(twine2Data, story, passageText, diagnostics);
4153
4732
  return {
4154
4733
  story,
4155
4734
  diagnostics
@@ -4157,7 +4736,7 @@ function decompileHTML(html) {
4157
4736
  }
4158
4737
  const twine1Data = findElementByIdPattern(doc, /^store(?:-a|A)rea$/);
4159
4738
  if (twine1Data) {
4160
- decompileTwine1(twine1Data, story, diagnostics);
4739
+ decompileTwine1(twine1Data, story, passageText, diagnostics);
4161
4740
  return {
4162
4741
  story,
4163
4742
  diagnostics
@@ -4172,7 +4751,7 @@ function decompileHTML(html) {
4172
4751
  diagnostics
4173
4752
  };
4174
4753
  }
4175
- function decompileTwine2(storyData, story, diagnostics) {
4754
+ function decompileTwine2(storyData, story, passageText, diagnostics) {
4176
4755
  let startnode = 0;
4177
4756
  const attrs = storyData.attribs ?? {};
4178
4757
  if (attrs["name"]) story.name = attrs["name"];
@@ -4193,6 +4772,7 @@ function decompileTwine2(storyData, story, diagnostics) {
4193
4772
  });
4194
4773
  else story.twine2.zoom = parsed;
4195
4774
  }
4775
+ if (attrs["tags"]) story.twine2.tags = attrs["tags"];
4196
4776
  if (attrs["format"]) story.twine2.format = attrs["format"];
4197
4777
  if (attrs["format-version"]) story.twine2.formatVersion = attrs["format-version"];
4198
4778
  if (attrs["options"]) for (const opt of attrs["options"].split(/\s+/).filter((s) => s.length > 0)) story.twine2.options.set(opt, true);
@@ -4201,8 +4781,9 @@ function decompileTwine2(storyData, story, diagnostics) {
4201
4781
  switch (node.name) {
4202
4782
  case "style":
4203
4783
  case "script": {
4204
- const text = getTextContent(node).trim();
4205
- if (text.length === 0) continue;
4784
+ const content = getTextContent(node);
4785
+ if (content.trim().length === 0) continue;
4786
+ const text = passageText(content);
4206
4787
  storyAdd(story, {
4207
4788
  name: node.name === "style" ? "Story Stylesheet" : "Story JavaScript",
4208
4789
  tags: node.name === "style" ? ["stylesheet"] : ["script"],
@@ -4237,7 +4818,7 @@ function decompileTwine2(storyData, story, diagnostics) {
4237
4818
  const passage = {
4238
4819
  name,
4239
4820
  tags,
4240
- text: getTextContent(node).trim()
4821
+ text: passageText(getTextContent(node))
4241
4822
  };
4242
4823
  if (metadata.position || metadata.size) passage.metadata = metadata;
4243
4824
  storyAdd(story, passage, diagnostics);
@@ -4251,23 +4832,33 @@ function decompileTwine2(storyData, story, diagnostics) {
4251
4832
  text: marshalStoryData(story)
4252
4833
  }, diagnostics);
4253
4834
  }
4254
- function decompileTwine1(storeArea, story, diagnostics) {
4255
- for (const node of storeArea.children ?? []) {
4256
- if (node.type !== "tag" || node.name !== "div") continue;
4257
- const nodeAttrs = node.attribs ?? {};
4258
- if (!("tiddler" in nodeAttrs)) continue;
4259
- const name = nodeAttrs["tiddler"] ?? "";
4260
- const tags = nodeAttrs["tags"] ? nodeAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [];
4261
- const metadata = {};
4262
- if (nodeAttrs["twine-position"]) metadata.position = nodeAttrs["twine-position"];
4263
- const passage = {
4264
- name,
4265
- tags,
4266
- text: tiddlerUnescape(getTextContent(node)).trim()
4267
- };
4268
- if (metadata.position) passage.metadata = metadata;
4269
- storyAdd(story, passage, diagnostics);
4270
- }
4835
+ function decompileTwine1(storeArea, story, passageText, diagnostics) {
4836
+ const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
4837
+ const obfuscated = isRot13Obfuscated(passages);
4838
+ for (const p of passages) storyAdd(story, obfuscated && p.name !== "StorySettings" ? {
4839
+ ...p,
4840
+ text: rot13(p.text)
4841
+ } : p, diagnostics);
4842
+ }
4843
+ function isTiddler(node) {
4844
+ return node.type === "tag" && node.name === "div" && node.attribs !== void 0 && "tiddler" in node.attribs;
4845
+ }
4846
+ function tiddlerToPassage(node, passageText) {
4847
+ const nodeAttrs = node.attribs ?? {};
4848
+ const passage = {
4849
+ name: nodeAttrs["tiddler"] ?? "",
4850
+ tags: nodeAttrs["tags"] ? nodeAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [],
4851
+ text: passageText(tiddlerUnescape(getTextContent(node)))
4852
+ };
4853
+ const position = nodeAttrs["twine-position"];
4854
+ if (position) passage.metadata = { position };
4855
+ return passage;
4856
+ }
4857
+ /** Whether the StorySettings tiddlers turn on ROT13 obfuscation, read the way the writer reads them. */
4858
+ function isRot13Obfuscated(passages) {
4859
+ const settings = createStory();
4860
+ for (const p of passages) if (p.name === "StorySettings") unmarshalStorySettings(settings, p.text, []);
4861
+ return settings.twine1.settings.get("obfuscate") === "rot13";
4271
4862
  }
4272
4863
  function findElement(node, tagName) {
4273
4864
  if (node.type === "tag" && node.name === tagName) return node;
@@ -4322,7 +4913,7 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
4322
4913
  break;
4323
4914
  case "htm":
4324
4915
  case "html":
4325
- loadHTML(story, filename, diagnostics);
4916
+ loadHTML(story, filename, opts, diagnostics);
4326
4917
  break;
4327
4918
  case "css":
4328
4919
  loadTagged(story, "stylesheet", filename, diagnostics);
@@ -4390,7 +4981,7 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
4390
4981
  function loadInlineSources(story, sources, opts, diagnostics) {
4391
4982
  for (const source of sources) {
4392
4983
  if (typeof source === "string") continue;
4393
- const content = typeof source.content === "string" ? source.content : source.content.toString("utf-8");
4984
+ const content = normalizeSourceText(typeof source.content === "string" ? source.content : source.content.toString("utf-8"));
4394
4985
  const ext = normalizedFileExt(source.filename);
4395
4986
  switch (ext) {
4396
4987
  case "":
@@ -4476,13 +5067,33 @@ function parseFontFile(filename) {
4476
5067
  diagnostics: []
4477
5068
  };
4478
5069
  }
4479
- function parseHTMLFile(filename) {
4480
- const result = decompileHTML(readUTF8(filename));
5070
+ function parseHTMLFile(filename, opts) {
5071
+ const { story, diagnostics } = decompileHTML(readUTF8(filename), { trim: opts.trim ?? true });
4481
5072
  return {
4482
- passages: result.story.passages,
4483
- diagnostics: [...result.diagnostics]
5073
+ passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
5074
+ name: "StoryTitle",
5075
+ tags: [],
5076
+ text: story.name
5077
+ }, ...story.passages] : story.passages,
5078
+ diagnostics: [...diagnostics]
4484
5079
  };
4485
5080
  }
5081
+ /**
5082
+ * Identity of the options that affect how a file parses, stored on its cache entry.
5083
+ * Twee files depend on `trim` and `twee2Compat`, HTML files on `trim`; every other file type
5084
+ * yields the same key for all options.
5085
+ */
5086
+ function parseOptionsKey(filename, opts) {
5087
+ const ext = normalizedFileExt(filename);
5088
+ const trim = opts.trim ?? true;
5089
+ if (ext === "htm" || ext === "html") return JSON.stringify({ trim });
5090
+ const twee2File = ext === "tw2" || ext === "twee2";
5091
+ if (!twee2File && ext !== "tw" && ext !== "twee") return "";
5092
+ return JSON.stringify({
5093
+ trim,
5094
+ twee2Compat: twee2File || (opts.twee2Compat ?? false)
5095
+ });
5096
+ }
4486
5097
  function parseFile(filename, opts) {
4487
5098
  switch (normalizedFileExt(filename)) {
4488
5099
  case "tw":
@@ -4493,7 +5104,7 @@ function parseFile(filename, opts) {
4493
5104
  twee2Compat: true
4494
5105
  });
4495
5106
  case "htm":
4496
- case "html": return parseHTMLFile(filename);
5107
+ case "html": return parseHTMLFile(filename, opts);
4497
5108
  case "css": return parseTaggedFile("stylesheet", filename);
4498
5109
  case "js": return parseTaggedFile("script", filename);
4499
5110
  case "otf":
@@ -4525,8 +5136,8 @@ function parseFile(filename, opts) {
4525
5136
  default: return;
4526
5137
  }
4527
5138
  }
4528
- function loadHTML(story, filename, diagnostics) {
4529
- const result = parseHTMLFile(filename);
5139
+ function loadHTML(story, filename, opts, diagnostics) {
5140
+ const result = parseHTMLFile(filename, opts);
4530
5141
  diagnostics.push(...result.diagnostics);
4531
5142
  for (const p of result.passages) storyAdd(story, p, diagnostics);
4532
5143
  }
@@ -4562,8 +5173,11 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
4562
5173
  continue;
4563
5174
  }
4564
5175
  currentFiles.add(filename);
4565
- const cached = cache.get(filename);
4566
- if (changedFiles && cached && !changedFiles.has(filename)) {
5176
+ const optionsKey = parseOptionsKey(filename, opts);
5177
+ const entry = cache.get(filename);
5178
+ const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
5179
+ const changed = changedFiles?.has(filename) ?? false;
5180
+ if (changedFiles && cached && !changed) {
4567
5181
  diagnostics.push(...cached.diagnostics);
4568
5182
  for (const p of cached.passages) storyAdd(story, p, diagnostics);
4569
5183
  processedFiles.add(filename);
@@ -4575,7 +5189,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
4575
5189
  } catch {
4576
5190
  continue;
4577
5191
  }
4578
- if (cached && cached.mtimeMs === mtimeMs) {
5192
+ if (cached && !changed && cached.mtimeMs === mtimeMs) {
4579
5193
  diagnostics.push(...cached.diagnostics);
4580
5194
  for (const p of cached.passages) storyAdd(story, p, diagnostics);
4581
5195
  processedFiles.add(filename);
@@ -4586,6 +5200,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
4586
5200
  if (!result) continue;
4587
5201
  cache.set(filename, {
4588
5202
  mtimeMs,
5203
+ parseOptionsKey: optionsKey,
4589
5204
  passages: result.passages,
4590
5205
  diagnostics: result.diagnostics
4591
5206
  });
@@ -4649,11 +5264,39 @@ function toTwine2HTML(story, format, startName, options) {
4649
5264
  template = template.replaceAll("{{STORY_NAME}}", () => name);
4650
5265
  }
4651
5266
  if (template.includes("{{STORY_DATA}}")) {
4652
- const data = getTwine2DataChunk(story, startName, options);
5267
+ const data = getTwine2DataChunk({
5268
+ ...story,
5269
+ twine2: {
5270
+ ...story.twine2,
5271
+ format: format.name,
5272
+ formatVersion: format.version
5273
+ }
5274
+ }, startName, options);
4653
5275
  template = template.replace("{{STORY_DATA}}", () => data);
4654
5276
  }
4655
5277
  return template;
4656
5278
  }
5279
+ const OMITTING_TAGS = [
5280
+ "Twine.private",
5281
+ "script",
5282
+ "stylesheet"
5283
+ ];
5284
+ /**
5285
+ * Why Twine 2 output leaves a passage out of its `<tw-passagedata>` elements,
5286
+ * or `undefined` when the passage is emitted (and so can be the starting passage).
5287
+ */
5288
+ function twine2PassageOmission(story, p) {
5289
+ if (p.name === "StoryTitle" || p.name === "StoryData") return {
5290
+ kind: "special-name",
5291
+ name: p.name
5292
+ };
5293
+ const tag = OMITTING_TAGS.find((t) => hasTag(p, t));
5294
+ if (tag !== void 0) return {
5295
+ kind: "tag",
5296
+ tag
5297
+ };
5298
+ if (p.name === "StorySettings" && story.twine1.settings.size === 0) return { kind: "empty-story-settings" };
5299
+ }
4657
5300
  function getTwine2DataChunk(story, startName, options) {
4658
5301
  const parts = [];
4659
5302
  let startID = "";
@@ -4676,7 +5319,7 @@ function getTwine2DataChunk(story, startName, options) {
4676
5319
  pidS++;
4677
5320
  }
4678
5321
  }
4679
- parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContent}</style>`);
5322
+ parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContentEscape(styleContent)}</style>`);
4680
5323
  let scriptContent = "";
4681
5324
  if (scripts.length === 1) scriptContent = scripts[0]?.text ?? "";
4682
5325
  else if (scripts.length > 1) {
@@ -4688,7 +5331,7 @@ function getTwine2DataChunk(story, startName, options) {
4688
5331
  pidS++;
4689
5332
  }
4690
5333
  }
4691
- parts.push(`<script role="script" id="twine-user-script" type="text/twine-javascript">${scriptContent}<\/script>`);
5334
+ parts.push(`<script role="script" id="twine-user-script" type="text/twine-javascript">${scriptContentEscape(scriptContent)}<\/script>`);
4692
5335
  const validTagColors = /* @__PURE__ */ new Set([
4693
5336
  "gray",
4694
5337
  "red",
@@ -4702,8 +5345,7 @@ function getTwine2DataChunk(story, startName, options) {
4702
5345
  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>`);
4703
5346
  pid = 1;
4704
5347
  for (const p of story.passages) {
4705
- if (p.name === "StoryTitle" || p.name === "StoryData" || hasAnyTag(p, "script", "stylesheet", "Twine.private")) continue;
4706
- if (p.name === "StorySettings" && story.twine1.settings.size === 0) continue;
5348
+ if (twine2PassageOmission(story, p) !== void 0) continue;
4707
5349
  parts.push(passageToPassagedata(p, pid, options));
4708
5350
  if (startName === p.name) startID = String(pid);
4709
5351
  pid++;
@@ -4759,12 +5401,21 @@ function toTwine1HTML(story, format, startName) {
4759
5401
  }
4760
5402
  return template;
4761
5403
  }
5404
+ /**
5405
+ * Why Twine 1 output leaves a passage out of its tiddlers, or `undefined` when the passage is emitted.
5406
+ */
5407
+ function twine1PassageOmission(p) {
5408
+ return hasTag(p, "Twine.private") ? {
5409
+ kind: "tag",
5410
+ tag: "Twine.private"
5411
+ } : void 0;
5412
+ }
4762
5413
  function getTwine1PassageChunk(story) {
4763
5414
  const obfuscateRot13 = story.twine1.settings.get("obfuscate") === "rot13";
4764
5415
  let data = "";
4765
5416
  let count = 0;
4766
5417
  for (const p of story.passages) {
4767
- if (hasTag(p, "Twine.private")) continue;
5418
+ if (twine1PassageOmission(p) !== void 0) continue;
4768
5419
  count++;
4769
5420
  data += passageToTiddler(p, count, obfuscateRot13);
4770
5421
  }
@@ -4786,10 +5437,39 @@ function tryReplaceComponent(template, placeholder, componentPath, required) {
4786
5437
  }
4787
5438
  //#endregion
4788
5439
  //#region src/output-twee.ts
4789
- function toTwee(story, outMode) {
4790
- let data = "";
4791
- for (const p of story.passages) data += passageToTwee(p, outMode);
4792
- return data;
5440
+ /**
5441
+ * Serialize the story as Twee. The StoryData passage is written from the story model rather than
5442
+ * from its loaded text, so changes made after loading (a start passage override, test mode) are kept.
5443
+ * Tweego writes the loaded text; its own normalization makes the two the same for an unchanged story.
5444
+ *
5445
+ * `addStoryData`: add a StoryData passage when the story has none, so that StoryData fields the
5446
+ * compile options set are recorded. Default: false.
5447
+ */
5448
+ function toTwee(story, outMode, options = {}) {
5449
+ return withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => passageToTwee(p, outMode)).join("");
5450
+ }
5451
+ function withEffectiveStoryData(story, addStoryData) {
5452
+ const storyData = {
5453
+ name: "StoryData",
5454
+ tags: [],
5455
+ text: marshalStoryData(story)
5456
+ };
5457
+ const index = story.passages.findIndex((p) => p.name === "StoryData");
5458
+ if (index !== -1) return story.passages.map((p, i) => i === index && isStoryDataJSON(p.text) ? {
5459
+ ...p,
5460
+ text: storyData.text
5461
+ } : p);
5462
+ if (!addStoryData) return story.passages;
5463
+ const at = story.passages[0]?.name === "StoryTitle" ? 1 : 0;
5464
+ return [
5465
+ ...story.passages.slice(0, at),
5466
+ storyData,
5467
+ ...story.passages.slice(at)
5468
+ ];
5469
+ }
5470
+ /** Whether the text loaded as StoryData. Text that did not is kept as written, for the author to fix. */
5471
+ function isStoryDataJSON(text) {
5472
+ return unmarshalStoryData(createStory(), text) === null;
4793
5473
  }
4794
5474
  //#endregion
4795
5475
  //#region src/modules.ts
@@ -4816,357 +5496,90 @@ function loadModules(filenames) {
4816
5496
  case "woff2":
4817
5497
  tag = loadModuleFont(filename);
4818
5498
  break;
4819
- default: continue;
4820
- }
4821
- if (tag) headTags.push(tag);
4822
- processed.add(filename);
4823
- }
4824
- return headTags.join("\n");
4825
- }
4826
- function loadModuleTagged(tag, filename) {
4827
- const source = readUTF8(filename).trim();
4828
- if (source.length === 0) return null;
4829
- return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${source}</${tag}>`;
4830
- }
4831
- function loadModuleFont(filename) {
4832
- const source = readBase64(filename);
4833
- const family = baseNameWithoutExt(filename);
4834
- const idSlug = `style-module-${slugify(family)}`;
4835
- const ext = normalizedFileExt(filename);
4836
- 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>`;
4837
- }
4838
- /**
4839
- * Inject modules and head file content before </head>.
4840
- */
4841
- function modifyHead(html, modulePaths, headFile, diagnostics) {
4842
- const parts = [];
4843
- if (modulePaths.length > 0) {
4844
- const modules = loadModules(modulePaths).trim();
4845
- if (modules.length > 0) parts.push(modules);
4846
- }
4847
- if (headFile) try {
4848
- const source = readUTF8(headFile).trim();
4849
- if (source.length > 0) parts.push(source);
4850
- } catch (e) {
4851
- diagnostics?.push({
4852
- level: "warning",
4853
- message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
4854
- });
4855
- }
4856
- if (parts.length > 0) {
4857
- parts.push("</head>");
4858
- const replacement = parts.join("\n");
4859
- return html.replace("</head>", () => replacement);
4860
- }
4861
- return html;
4862
- }
4863
- //#endregion
4864
- //#region src/remote-formats.ts
4865
- /**
4866
- * Remote story format fetching, caching, and checksum verification.
4867
- * Uses the Story Formats Archive (SFA) as the default source.
4868
- */
4869
- const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
4870
- /** Get the cache directory for downloaded story formats. */
4871
- function getCacheDir() {
4872
- const base = process.env["XDG_CACHE_HOME"] || (0, node_path.join)((0, node_os.homedir)(), ".cache");
4873
- return (0, node_path.join)(base, "twee-ts", "storyformats");
4874
- }
4875
- /** In-memory index cache, keyed by URL. Cleared each compile. */
4876
- const indexCache = /* @__PURE__ */ new Map();
4877
- /** Clear the in-memory index cache. */
4878
- function clearIndexCache() {
4879
- indexCache.clear();
4880
- }
4881
- /** Validate that a JSON value is a valid SFAIndexEntry. */
4882
- function isValidEntry(val) {
4883
- if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
4884
- const obj = val;
4885
- return typeof obj.name === "string" && typeof obj.version === "string" && typeof obj.checksums === "object" && obj.checksums !== null && !Array.isArray(obj.checksums);
4886
- }
4887
- /** Validate that a JSON value conforms to the SFAIndex shape. */
4888
- function validateSFAIndex(json) {
4889
- if (typeof json !== "object" || json === null) throw new Error("SFA index is not an object");
4890
- const obj = json;
4891
- return {
4892
- twine1: Array.isArray(obj["twine1"]) ? obj["twine1"].filter(isValidEntry) : [],
4893
- twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
4894
- };
4895
- }
4896
- /** Generate a format ID from name and version. */
4897
- function makeFormatId(name, version) {
4898
- const major = parseSemver(version)?.[0] ?? "0";
4899
- return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
4900
- }
4901
- /** Fetch and parse an SFA index.json, with in-memory caching. */
4902
- async function fetchIndex(url) {
4903
- const cached = indexCache.get(url);
4904
- if (cached) return cached;
4905
- const res = await fetch(url);
4906
- if (!res.ok) throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
4907
- const data = validateSFAIndex(await res.json());
4908
- indexCache.set(url, data);
4909
- return data;
4910
- }
4911
- /**
4912
- * Find the best matching entry in an SFA index.
4913
- * Exact version preferred, then highest version with same major.
4914
- */
4915
- function findEntry(index, name, version) {
4916
- const wanted = parseSemver(version);
4917
- const searchArrays = [{
4918
- entries: index.twine2 ?? [],
4919
- formatType: "twine2"
4920
- }, {
4921
- entries: index.twine1 ?? [],
4922
- formatType: "twine1"
4923
- }];
4924
- let bestResult;
4925
- let bestVersion = null;
4926
- for (const { entries, formatType } of searchArrays) for (const entry of entries) {
4927
- if (entry.name.toLowerCase() !== name.toLowerCase()) continue;
4928
- const have = parseSemver(entry.version);
4929
- if (!have) continue;
4930
- if (wanted && semverCompare(have, wanted) === 0) return {
4931
- entry,
4932
- formatType
4933
- };
4934
- if (wanted === null || have[0] === wanted[0] && semverCompare(have, wanted) >= 0) {
4935
- if (!bestVersion || semverCompare(have, bestVersion) > 0) {
4936
- bestVersion = have;
4937
- bestResult = {
4938
- entry,
4939
- formatType
4940
- };
4941
- }
4942
- }
4943
- }
4944
- return bestResult;
4945
- }
4946
- /** Verify SHA-256 checksum using Web Crypto API (Node 22 built-in). */
4947
- async function verifySHA256(content, expectedHex) {
4948
- const digest = await crypto.subtle.digest("SHA-256", content);
4949
- return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("") === expectedHex.toLowerCase();
4950
- }
4951
- /** Derive the download URL for a format.js from the index URL and entry. */
4952
- function getDownloadUrl(indexUrl, entry, formatType) {
4953
- return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
4954
- }
4955
- /** Download a format, verify its checksum, write to cache, and return StoryFormatInfo. */
4956
- async function fetchAndCacheFormat(entry, downloadUrl) {
4957
- const res = await fetch(downloadUrl);
4958
- if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
4959
- const text = await res.text();
4960
- const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
4961
- if (checksumKey) {
4962
- const expected = entry.checksums[checksumKey];
4963
- if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
4964
- if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
4965
- }
4966
- const id = makeFormatId(entry.name, entry.version);
4967
- const data = parseFormatJSON(text, id);
4968
- if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
4969
- const cacheDir = getCacheDir();
4970
- const formatDir = (0, node_path.join)(cacheDir, entry.name, entry.version);
4971
- (0, node_fs.mkdirSync)(formatDir, { recursive: true });
4972
- const formatPath = (0, node_path.join)(formatDir, "format.js");
4973
- (0, node_fs.writeFileSync)(formatPath, text, "utf-8");
4974
- return {
4975
- id,
4976
- filename: formatPath,
4977
- isTwine2: true,
4978
- name: data.name,
4979
- version: data.version,
4980
- proofing: data.proofing ?? false
4981
- };
4982
- }
4983
- /** Download a direct format.js URL, parse its JSON, cache it, and return StoryFormatInfo. */
4984
- async function fetchDirectFormat(url) {
4985
- const res = await fetch(url);
4986
- if (!res.ok) throw new Error(`Failed to download format from ${url}: ${res.status} ${res.statusText}`);
4987
- const text = await res.text();
4988
- const data = parseFormatJSON(text, "direct-url");
4989
- if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
4990
- const id = makeFormatId(data.name, data.version);
4991
- const cacheDir = getCacheDir();
4992
- const formatDir = (0, node_path.join)(cacheDir, data.name, data.version);
4993
- (0, node_fs.mkdirSync)(formatDir, { recursive: true });
4994
- const formatPath = (0, node_path.join)(formatDir, "format.js");
4995
- (0, node_fs.writeFileSync)(formatPath, text, "utf-8");
4996
- return {
4997
- id,
4998
- filename: formatPath,
4999
- isTwine2: true,
5000
- name: data.name,
5001
- version: data.version,
5002
- proofing: data.proofing ?? false
5003
- };
5004
- }
5005
- /**
5006
- * Try to resolve a remote story format by name and version.
5007
- * 1. Try direct format URLs
5008
- * 2. Try custom index URLs
5009
- * 3. Try default SFA indices
5010
- */
5011
- async function resolveRemoteFormat(name, version, indices, urls) {
5012
- let lastError;
5013
- if (urls) for (const url of urls) try {
5014
- const info = await fetchDirectFormat(url);
5015
- if (info.name.toLowerCase() === name.toLowerCase()) {
5016
- const wanted = parseSemver(version);
5017
- const have = parseSemver(info.version);
5018
- if (!wanted || have && have[0] === wanted[0] && semverCompare(have, wanted) >= 0) return info;
5019
- }
5020
- } catch (e) {
5021
- lastError = e instanceof Error ? e : new Error(String(e));
5022
- }
5023
- const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
5024
- for (const indexUrl of allIndices) try {
5025
- const result = findEntry(await fetchIndex(indexUrl), name, version);
5026
- if (result) {
5027
- const cached = getCachedFormat(result.entry.name, result.entry.version);
5028
- if (cached) return cached;
5029
- const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
5030
- return await fetchAndCacheFormat(result.entry, downloadUrl);
5031
- }
5032
- } catch (e) {
5033
- lastError = e instanceof Error ? e : new Error(String(e));
5034
- }
5035
- if (lastError) throw lastError;
5036
- }
5037
- /** Check if a format is already in the local cache. */
5038
- function getCachedFormat(name, version) {
5039
- const formatPath = (0, node_path.join)(getCacheDir(), name, version, "format.js");
5040
- try {
5041
- if (!(0, node_fs.existsSync)(formatPath)) return void 0;
5042
- const source = (0, node_fs.readFileSync)(formatPath, "utf-8");
5043
- const id = makeFormatId(name, version);
5044
- const data = parseFormatJSON(source, id);
5045
- if (!data) return void 0;
5046
- return {
5047
- id,
5048
- filename: formatPath,
5049
- isTwine2: true,
5050
- name: data.name,
5051
- version: data.version,
5052
- proofing: data.proofing ?? false
5053
- };
5054
- } catch {
5055
- return;
5056
- }
5057
- }
5058
- /** Discover all cached remote formats. Returns a Map like discoverFormats(). */
5059
- function discoverCachedFormats() {
5060
- const formats = /* @__PURE__ */ new Map();
5061
- const cacheDir = getCacheDir();
5062
- try {
5063
- if (!(0, node_fs.existsSync)(cacheDir)) return formats;
5064
- } catch {
5065
- return formats;
5066
- }
5067
- let names;
5068
- try {
5069
- names = (0, node_fs.readdirSync)(cacheDir);
5070
- } catch {
5071
- return formats;
5072
- }
5073
- for (const name of names) {
5074
- const nameDir = (0, node_path.join)(cacheDir, name);
5075
- try {
5076
- if (!(0, node_fs.statSync)(nameDir).isDirectory()) continue;
5077
- } catch {
5078
- continue;
5079
- }
5080
- let versions;
5081
- try {
5082
- versions = (0, node_fs.readdirSync)(nameDir);
5083
- } catch {
5084
- continue;
5085
- }
5086
- for (const version of versions) {
5087
- const info = getCachedFormat(name, version);
5088
- if (info) formats.set(`${info.id}-${version}`, info);
5499
+ default: continue;
5089
5500
  }
5501
+ if (tag) headTags.push(tag);
5502
+ processed.add(filename);
5090
5503
  }
5091
- return formats;
5504
+ return headTags.join("\n");
5092
5505
  }
5093
- /** List all cached formats with size and modification date. */
5094
- function listCachedFormats() {
5095
- const cacheDir = getCacheDir();
5096
- const entries = [];
5097
- try {
5098
- if (!(0, node_fs.existsSync)(cacheDir)) return entries;
5099
- } catch {
5100
- return entries;
5506
+ function loadModuleTagged(tag, filename) {
5507
+ const source = readUTF8(filename).trim();
5508
+ if (source.length === 0) return null;
5509
+ return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
5510
+ }
5511
+ function loadModuleFont(filename) {
5512
+ const source = readBase64(filename);
5513
+ const family = baseNameWithoutExt(filename);
5514
+ const idSlug = `style-module-${slugify(family)}`;
5515
+ const ext = normalizedFileExt(filename);
5516
+ 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>`;
5517
+ }
5518
+ /**
5519
+ * Inject modules and head file content before </head>.
5520
+ */
5521
+ function modifyHead(html, modulePaths, headFile, diagnostics) {
5522
+ const parts = [];
5523
+ if (modulePaths.length > 0) {
5524
+ const modules = loadModules(modulePaths).trim();
5525
+ if (modules.length > 0) parts.push(modules);
5101
5526
  }
5102
- let names;
5103
- try {
5104
- names = (0, node_fs.readdirSync)(cacheDir);
5105
- } catch {
5106
- return entries;
5527
+ if (headFile) try {
5528
+ const source = readUTF8(headFile).trim();
5529
+ if (source.length > 0) parts.push(source);
5530
+ } catch (e) {
5531
+ diagnostics?.push({
5532
+ level: "warning",
5533
+ message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
5534
+ });
5107
5535
  }
5108
- for (const name of names) {
5109
- const nameDir = (0, node_path.join)(cacheDir, name);
5110
- try {
5111
- if (!(0, node_fs.statSync)(nameDir).isDirectory()) continue;
5112
- } catch {
5113
- continue;
5114
- }
5115
- let versions;
5116
- try {
5117
- versions = (0, node_fs.readdirSync)(nameDir);
5118
- } catch {
5119
- continue;
5120
- }
5121
- for (const version of versions) {
5122
- const formatPath = (0, node_path.join)(nameDir, version, "format.js");
5123
- try {
5124
- const stat = (0, node_fs.statSync)(formatPath);
5125
- entries.push({
5126
- name,
5127
- version,
5128
- sizeBytes: stat.size,
5129
- modifiedAt: stat.mtime
5130
- });
5131
- } catch {}
5132
- }
5536
+ if (parts.length > 0) {
5537
+ parts.push("</head>");
5538
+ const replacement = parts.join("\n");
5539
+ return html.replace("</head>", () => replacement);
5133
5540
  }
5134
- return entries;
5541
+ return html;
5135
5542
  }
5136
- /** Clear all cached formats, or only those matching a given name. Returns the number of entries removed. */
5137
- function clearCachedFormats(name) {
5138
- const cacheDir = getCacheDir();
5139
- if (!(0, node_fs.existsSync)(cacheDir)) return 0;
5140
- if (!name) {
5141
- const count = listCachedFormats().length;
5142
- (0, node_fs.rmSync)(cacheDir, {
5143
- recursive: true,
5144
- force: true
5145
- });
5146
- return count;
5543
+ //#endregion
5544
+ //#region src/passage-omission.ts
5545
+ /**
5546
+ * Why the `target` output leaves `passage` out of its passage elements, or `undefined` when it
5547
+ * emits the passage (and so a link or the start can name it).
5548
+ */
5549
+ function passageOmission(story, passage, target) {
5550
+ switch (target) {
5551
+ case "twine2": return twine2PassageOmission(story, passage);
5552
+ case "twine1": return twine1PassageOmission(passage);
5553
+ default: throw new Error(`unhandled passage output target: ${String(target)}`);
5147
5554
  }
5148
- const nameDir = (0, node_path.join)(cacheDir, name);
5149
- if (!(0, node_fs.existsSync)(nameDir)) return 0;
5150
- let versions;
5151
- try {
5152
- versions = (0, node_fs.readdirSync)(nameDir);
5153
- } catch {
5154
- return 0;
5555
+ }
5556
+ /** Why a passage is left out, as a predicate: `Passage "X" ${describeOmission(o)}`. */
5557
+ function describeOmission(omission) {
5558
+ switch (omission.kind) {
5559
+ case "special-name": return `is the special "${omission.name}" passage`;
5560
+ case "tag": return `is tagged "${omission.tag}"`;
5561
+ case "empty-story-settings": return "is an empty \"StorySettings\" passage";
5562
+ default: throw new Error(`unhandled passage omission: ${JSON.stringify(omission)}`);
5155
5563
  }
5156
- const count = versions.length;
5157
- (0, node_fs.rmSync)(nameDir, {
5158
- recursive: true,
5159
- force: true
5160
- });
5161
- return count;
5162
5564
  }
5163
- /** Get total cache size in bytes and format count. */
5164
- function getCacheSize() {
5165
- const entries = listCachedFormats();
5166
- return {
5167
- totalBytes: entries.reduce((sum, e) => sum + e.sizeBytes, 0),
5168
- count: entries.length
5169
- };
5565
+ //#endregion
5566
+ //#region src/start-passage.ts
5567
+ /**
5568
+ * Check that `startName` names a passage the `target` output emits.
5569
+ * Returns the error diagnostics to report (empty when the start is usable).
5570
+ */
5571
+ function startPassageDiagnostics(story, startName, target) {
5572
+ const passage = story.passages.find((p) => p.name === startName);
5573
+ if (passage === void 0) return [{
5574
+ level: "error",
5575
+ message: `Starting passage "${startName}" not found.`
5576
+ }];
5577
+ const omission = passageOmission(story, passage, target);
5578
+ if (omission === void 0) return [];
5579
+ return [{
5580
+ level: "error",
5581
+ message: `Starting passage "${startName}" ${describeOmission(omission)}, so it is left out of the story data. Choose a story passage.`
5582
+ }];
5170
5583
  }
5171
5584
  //#endregion
5172
5585
  //#region src/compiler.ts
@@ -5196,36 +5609,68 @@ async function compile(options) {
5196
5609
  * Compile and write to a file.
5197
5610
  */
5198
5611
  async function compileToFile(options) {
5199
- const result = await compile(options);
5612
+ const result = await compileForOutputFile(options, options.outFile);
5200
5613
  (0, node_fs.writeFileSync)(options.outFile, result.output, "utf-8");
5201
5614
  return result;
5202
5615
  }
5203
5616
  /**
5617
+ * Builds as compileToFile() does for `outFile`, without writing it: the output may sit
5618
+ * inside a source folder, so its last build is left out of the sources and modules and
5619
+ * never read back. The caller decides whether the result is written. With no `outFile`
5620
+ * (output to stdout, or none), this is compile().
5621
+ *
5622
+ * Internal, for the CLI; not part of the public API.
5623
+ */
5624
+ async function compileForOutputFile(options, outFile) {
5625
+ return buildOutput(options, void 0, void 0, outFile);
5626
+ }
5627
+ /**
5204
5628
  * Compile with incremental caching support.
5205
5629
  * Plugins and advanced users can manage their own cache and changed-file tracking.
5630
+ *
5631
+ * Without `changedFiles`, a cached file is reused while its modification time is unchanged.
5632
+ * With it, a file it names (by the path source discovery gives it, relative to the working
5633
+ * directory) is always reparsed, whatever its modification time, and every other cached
5634
+ * file is reused as it is.
5206
5635
  */
5207
5636
  async function compileIncremental(options, cache, changedFiles) {
5208
5637
  return buildOutput(options, cache, changedFiles);
5209
5638
  }
5210
5639
  /**
5211
- * Watch for file changes and recompile.
5640
+ * Watch for file changes and recompile. Every build is written to `outFile`, including
5641
+ * one whose diagnostics report errors.
5212
5642
  */
5213
5643
  async function watch(options) {
5644
+ return watchWithWriteFilter(options, () => true);
5645
+ }
5646
+ /**
5647
+ * watch(), writing to `outFile` only the builds `shouldWrite` accepts. A rejected build
5648
+ * leaves the output file as it was; `onBuild` receives every build either way.
5649
+ *
5650
+ * Internal, for the CLI, which keeps the last good output when a rebuild reports errors;
5651
+ * not part of the public API.
5652
+ */
5653
+ async function watchWithWriteFilter(options, shouldWrite) {
5214
5654
  const controller = new AbortController();
5215
5655
  const cache = /* @__PURE__ */ new Map();
5216
5656
  const filePaths = options.sources.filter((s) => typeof s === "string");
5217
5657
  const modulePaths = options.modules ?? [];
5218
- const allPaths = [...filePaths, ...modulePaths];
5658
+ const headPaths = options.headFile ? [options.headFile] : [];
5659
+ const allPaths = [
5660
+ ...filePaths,
5661
+ ...modulePaths,
5662
+ ...headPaths
5663
+ ];
5219
5664
  const exclude = options.exclude ?? [];
5220
- const moduleRoots = modulePaths.map((p) => (0, node_path.resolve)(p));
5665
+ const notExcludable = [...modulePaths, ...headPaths].map((p) => (0, node_path.resolve)(p));
5221
5666
  const ignore = (filename) => {
5222
5667
  if (!isExcluded(filename, exclude)) return false;
5223
5668
  const abs = (0, node_path.resolve)(filename);
5224
- return !moduleRoots.some((root) => abs === root || abs.startsWith(root + node_path.sep));
5669
+ return !notExcludable.some((root) => abs === root || abs.startsWith(root + node_path.sep));
5225
5670
  };
5226
5671
  const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
5227
- buildOutput(options, cache, changedFiles).then((result) => {
5228
- (0, node_fs.writeFileSync)(options.outFile, result.output, "utf-8");
5672
+ buildOutput(options, cache, changedFiles, options.outFile).then((result) => {
5673
+ if (shouldWrite(result)) (0, node_fs.writeFileSync)(options.outFile, result.output, "utf-8");
5229
5674
  options.onBuild?.(result);
5230
5675
  }).catch((e) => {
5231
5676
  options.onError?.(e instanceof Error ? e : new Error(String(e)));
@@ -5234,7 +5679,11 @@ async function watch(options) {
5234
5679
  controller.signal.addEventListener("abort", () => handle.close());
5235
5680
  return controller;
5236
5681
  }
5237
- async function buildOutput(options, cache, changedFiles) {
5682
+ /**
5683
+ * `outFile`: the file the build is written to, which source and module discovery
5684
+ * skip (as Tweego does), so an output inside a source folder is never loaded back.
5685
+ */
5686
+ async function buildOutput(options, cache, changedFiles, outFile) {
5238
5687
  const diagnostics = [];
5239
5688
  const outputMode = options.outputMode ?? "html";
5240
5689
  const trim = options.trim ?? true;
@@ -5247,7 +5696,7 @@ async function buildOutput(options, cache, changedFiles) {
5247
5696
  const inlineSources = [];
5248
5697
  for (const source of options.sources) if (typeof source === "string") filePaths.push(source);
5249
5698
  else inlineSources.push(source);
5250
- const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths, void 0, options.exclude);
5699
+ const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths, outFile, options.exclude);
5251
5700
  diagnostics.push(...sourcePathDiagnostics);
5252
5701
  const story = createStory();
5253
5702
  const processedFiles = /* @__PURE__ */ new Set();
@@ -5265,40 +5714,19 @@ async function buildOutput(options, cache, changedFiles) {
5265
5714
  }, diagnostics);
5266
5715
  if (options.tagAliases) story.passages = applyTagAliases(story.passages, options.tagAliases);
5267
5716
  let format;
5268
- let formatId = options.formatId ?? "";
5269
- if (outputMode === "html") {
5270
- const formats = discoverFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true));
5271
- if (!formatId && story.twine2.format) formatId = getFormatIdByNameAndVersion(formats, story.twine2.format, story.twine2.formatVersion) ?? "";
5272
- if (!formatId) formatId = DEFAULT_FORMAT_ID;
5273
- format = formats.get(formatId);
5274
- if (!format && !noRemote) {
5275
- const remoteName = story.twine2.format || formatId;
5276
- const remoteVersion = story.twine2.formatVersion || "";
5277
- try {
5278
- format = await resolveRemoteFormat(remoteName, remoteVersion, options.formatIndices, options.formatUrls);
5279
- } catch (e) {
5280
- diagnostics.push({
5281
- level: "warning",
5282
- message: `Remote format fetch failed for "${remoteName}": ${e instanceof Error ? e.message : String(e)}`
5283
- });
5284
- }
5285
- }
5286
- if (!format) {
5287
- const reason = noRemote ? " (remote fetching disabled)" : "";
5288
- diagnostics.push({
5289
- level: "error",
5290
- message: `Story format "${formatId}" is not available${reason}. Found: ${[...formats.keys()].join(", ") || "none"}`
5291
- });
5292
- }
5293
- }
5294
- const startName = options.startPassage || story.twine2.start || DEFAULT_START_NAME;
5717
+ if (outputMode === "html") format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
5718
+ ...options,
5719
+ noRemote
5720
+ }, diagnostics);
5721
+ if (options.startPassage) story.twine2.start = options.startPassage;
5722
+ const startName = story.twine2.start || DEFAULT_START_NAME;
5295
5723
  if (testMode) story.twine2.options.set("debug", true);
5296
5724
  ensureIFID(story, diagnostics);
5297
5725
  let output;
5298
5726
  switch (outputMode) {
5299
5727
  case "twee3":
5300
5728
  case "twee1":
5301
- output = toTwee(story, outputMode);
5729
+ output = toTwee(story, outputMode, { addStoryData: Boolean(options.startPassage) || testMode });
5302
5730
  break;
5303
5731
  case "twine2-archive":
5304
5732
  output = toTwine2Archive(story, startName, { sourceInfo });
@@ -5310,10 +5738,7 @@ async function buildOutput(options, cache, changedFiles) {
5310
5738
  output = storyToJSON(story);
5311
5739
  break;
5312
5740
  case "html": {
5313
- if (!storyHas(story, startName)) diagnostics.push({
5314
- level: "error",
5315
- message: `Starting passage "${startName}" not found.`
5316
- });
5741
+ diagnostics.push(...startPassageDiagnostics(story, startName, format?.isTwine2 === false ? "twine1" : "twine2"));
5317
5742
  if (!format) throw new TweeTsError("No story format available for HTML output.", diagnostics);
5318
5743
  if (format.isTwine2) output = toTwine2HTML(story, format, startName, { sourceInfo });
5319
5744
  else {
@@ -5323,7 +5748,7 @@ async function buildOutput(options, cache, changedFiles) {
5323
5748
  });
5324
5749
  output = toTwine1HTML(story, format, startName);
5325
5750
  }
5326
- const modules = getFilenames(options.modules ?? []);
5751
+ const modules = getFilenames(options.modules ?? [], outFile);
5327
5752
  diagnostics.push(...modules.diagnostics);
5328
5753
  output = modifyHead(output, modules.filenames, options.headFile, diagnostics);
5329
5754
  break;
@@ -5743,19 +6168,237 @@ function endOfRegularExpression(source, pos) {
5743
6168
  };
5744
6169
  }
5745
6170
  //#endregion
6171
+ //#region src/link-markup.ts
6172
+ /** Records that the reading has looked at the characters before `pos`, and returns `result`. */
6173
+ function readTo(reader, pos, result) {
6174
+ reader.scanned = Math.max(reader.scanned, pos);
6175
+ return result;
6176
+ }
6177
+ /**
6178
+ * Reads link or image markup that starts at `start` in `text`, or returns `undefined` where
6179
+ * SugarCube rejects it (and so prints the text instead).
6180
+ *
6181
+ * `budget.left`, if given, is how many characters the reading may look at; it is reduced by as
6182
+ * many as were read, and once it runs out, every reading fails. Unclosed markup is read to the end
6183
+ * of its line, so reading every `[[` of a long line that never closes one would otherwise take
6184
+ * quadratic time. A reading is charged for the characters it looked at, up to where it ended or
6185
+ * failed (a line feed, say), and no more, so markup left unclosed on short lines costs little.
6186
+ */
6187
+ function readSquareBracketedMarkup(text, start, budget = { left: Infinity }) {
6188
+ const reader = {
6189
+ text,
6190
+ limit: Math.min(text.length, start + Math.max(budget.left, 0)),
6191
+ scanned: start
6192
+ };
6193
+ const markup = readMarkup(reader, start);
6194
+ budget.left -= Math.max(reader.scanned, markup?.end ?? start) - start;
6195
+ return markup;
6196
+ }
6197
+ function readMarkup(reader, start) {
6198
+ const { text } = reader;
6199
+ if (text[start] !== "[") return;
6200
+ let pos = start + 1;
6201
+ let type;
6202
+ if (text[pos] === "[") {
6203
+ type = "link";
6204
+ pos += 1;
6205
+ } else {
6206
+ if (text[pos] === "<" || text[pos] === ">") pos += 1;
6207
+ if (!/^[Ii][Mm][Gg]\[/.test(text.slice(pos, pos + 4))) return;
6208
+ type = "image";
6209
+ pos += 4;
6210
+ }
6211
+ const core = readCoreComponents(reader, pos);
6212
+ if (core === void 0) return;
6213
+ if (type === "link") {
6214
+ if (!core.end.last) {
6215
+ const setterEnd = readSetter(reader, core.end.next);
6216
+ if (setterEnd === void 0) return;
6217
+ return {
6218
+ type,
6219
+ link: linkName(core.link),
6220
+ end: setterEnd
6221
+ };
6222
+ }
6223
+ return {
6224
+ type,
6225
+ link: linkName(core.link),
6226
+ end: core.end.next
6227
+ };
6228
+ }
6229
+ if (core.end.last) return {
6230
+ type,
6231
+ link: void 0,
6232
+ end: core.end.next
6233
+ };
6234
+ const linkEnd = readComponent(reader, core.end.next, false);
6235
+ if (linkEnd === void 0) return;
6236
+ const link = linkName(text.slice(core.end.next, linkEnd.textEnd));
6237
+ if (linkEnd.last) return {
6238
+ type,
6239
+ link,
6240
+ end: linkEnd.next
6241
+ };
6242
+ const setterEnd = readSetter(reader, linkEnd.next);
6243
+ return setterEnd === void 0 ? void 0 : {
6244
+ type,
6245
+ link,
6246
+ end: setterEnd
6247
+ };
6248
+ }
6249
+ /** The link component as SugarCube keeps it: trimmed, and without a leading `~`. */
6250
+ function linkName(component) {
6251
+ const trimmed = component.trim();
6252
+ return trimmed.startsWith("~") ? trimmed.slice(1) : trimmed;
6253
+ }
6254
+ /**
6255
+ * Reads the first components: the text and the link of link markup, or the title and the source
6256
+ * of image markup, divided by the first delimiter. `link` is the text of the link (or source)
6257
+ * component, untrimmed.
6258
+ */
6259
+ function readCoreComponents(reader, from) {
6260
+ const { text } = reader;
6261
+ let delimiter = "none";
6262
+ let componentStart = from;
6263
+ let link;
6264
+ let depth = 2;
6265
+ let pos = from;
6266
+ while (pos < reader.limit) {
6267
+ const ch = text[pos];
6268
+ pos += 1;
6269
+ switch (ch) {
6270
+ case "\n": return readTo(reader, pos, void 0);
6271
+ case "\"": {
6272
+ const end = readQuoted(reader, pos, "\"");
6273
+ if (end === void 0) return;
6274
+ pos = end;
6275
+ break;
6276
+ }
6277
+ case "|":
6278
+ if (delimiter === "none") {
6279
+ delimiter = "ltr";
6280
+ componentStart = pos;
6281
+ }
6282
+ break;
6283
+ case "-":
6284
+ if (delimiter === "none" && text[pos] === ">") {
6285
+ delimiter = "ltr";
6286
+ pos += 1;
6287
+ componentStart = pos;
6288
+ }
6289
+ break;
6290
+ case "<":
6291
+ if (delimiter === "none" && text[pos] === "-") {
6292
+ delimiter = "rtl";
6293
+ link = text.slice(componentStart, pos - 1);
6294
+ pos += 1;
6295
+ componentStart = pos;
6296
+ }
6297
+ break;
6298
+ case "[":
6299
+ depth += 1;
6300
+ break;
6301
+ case "]":
6302
+ depth -= 1;
6303
+ if (depth === 1) {
6304
+ const end = componentEnd(reader, pos);
6305
+ if (end === void 0) return;
6306
+ return {
6307
+ link: link ?? text.slice(componentStart, end.textEnd),
6308
+ end
6309
+ };
6310
+ }
6311
+ }
6312
+ }
6313
+ return readTo(reader, pos, void 0);
6314
+ }
6315
+ /**
6316
+ * Reads a component after `][` that holds no delimiter: an image's link (`setter` false) or a
6317
+ * setter, in which single quotes also quote.
6318
+ */
6319
+ function readComponent(reader, from, setter) {
6320
+ const { text } = reader;
6321
+ let depth = 2;
6322
+ let pos = from;
6323
+ while (pos < reader.limit) {
6324
+ const ch = text[pos];
6325
+ pos += 1;
6326
+ switch (ch) {
6327
+ case "\n": return readTo(reader, pos, void 0);
6328
+ case "\"":
6329
+ case "'": {
6330
+ if (ch === "'" && !setter) break;
6331
+ const end = readQuoted(reader, pos, ch);
6332
+ if (end === void 0) return;
6333
+ pos = end;
6334
+ break;
6335
+ }
6336
+ case "[":
6337
+ depth += 1;
6338
+ break;
6339
+ case "]":
6340
+ depth -= 1;
6341
+ if (depth === 1) return componentEnd(reader, pos);
6342
+ }
6343
+ }
6344
+ return readTo(reader, pos, void 0);
6345
+ }
6346
+ /** Reads a setter, the last component; returns the index after its closing `]]`. */
6347
+ function readSetter(reader, from) {
6348
+ const end = readComponent(reader, from, true);
6349
+ return end?.last === true ? end.next : void 0;
6350
+ }
6351
+ /** What the `]` just before `pos`, which brings the depth back to one, ends; `undefined` if malformed. */
6352
+ function componentEnd(reader, pos) {
6353
+ switch (reader.text[pos]) {
6354
+ case "[": return readTo(reader, pos + 1, {
6355
+ textEnd: pos - 1,
6356
+ next: pos + 1,
6357
+ last: false
6358
+ });
6359
+ case "]": return readTo(reader, pos + 1, {
6360
+ textEnd: pos - 1,
6361
+ next: pos + 1,
6362
+ last: true
6363
+ });
6364
+ default: return readTo(reader, pos + 1, void 0);
6365
+ }
6366
+ }
6367
+ /**
6368
+ * Reads a quoted string from just after its opening quote; a backslash escapes the character
6369
+ * after it. A line feed, or the end of the text, ends it unterminated. Returns the index after the
6370
+ * closing quote.
6371
+ */
6372
+ function readQuoted(reader, from, quote) {
6373
+ const { text } = reader;
6374
+ let pos = from;
6375
+ while (pos < reader.limit) {
6376
+ const ch = text[pos];
6377
+ pos += 1;
6378
+ if (ch === quote) return pos;
6379
+ if (ch === "\n") return readTo(reader, pos, void 0);
6380
+ if (ch === "\\") {
6381
+ if (pos >= reader.limit || text[pos] === "\n") return readTo(reader, Math.min(pos + 1, reader.limit), void 0);
6382
+ pos += 1;
6383
+ }
6384
+ }
6385
+ return readTo(reader, pos, void 0);
6386
+ }
6387
+ //#endregion
5746
6388
  //#region src/sugarcube-macros.ts
5747
6389
  /**
5748
- * Reads SugarCube 2 macro calls the way SugarCube 2.37.3 does, so the link check sees the same
5749
- * passage names that `<<link>>` and `<<goto>>` use when the story plays.
6390
+ * Reads SugarCube 2 macro calls and link markup the way SugarCube 2.37.3 does, so the link check
6391
+ * sees the same passage names that `[[…]]` links, `<<link>>` and `<<goto>>` use when the story
6392
+ * plays.
5750
6393
  *
5751
6394
  * Each step follows a part of SugarCube's macro parser:
5752
6395
  * - `findMacroTags` finds where a tag starts and ends (the parser's `lookahead` pattern);
5753
6396
  * - `parseMacroArgs` splits the text after the macro name into arguments (`parseArgs`);
5754
6397
  * - quoted arguments are evaluated as strict-mode JavaScript strings (`evalStringLiteral`).
5755
6398
  *
6399
+ * Link and image markup is read as `parseSquareBracketedMarkup` reads it (see `link-markup.ts`).
5756
6400
  * Of the rest of the wikifier, only comments, `<script>` elements and `<<script>>` bodies are
5757
- * told apart; other markup, such as verbatim text, is read as markup (see
5758
- * `findMacroPassageLinks`).
6401
+ * told apart; other markup, such as verbatim text, is read as markup (see `findPassageLinks`).
5759
6402
  */
5760
6403
  const NAME_RE = /\/?[A-Za-z][\w-]*|[=-]/y;
5761
6404
  const SPACES_RE = /\s*/y;
@@ -5800,17 +6443,22 @@ const RAW_ARGUMENT_MACROS = /* @__PURE__ */ new Set([
5800
6443
  * levels; the limit keeps the work linear in the size of the text on any input.
5801
6444
  */
5802
6445
  const MAX_STRING_DEPTH = 10;
5803
- /** A context for reading `text`: the closer search may read four times it, plus an allowance. */
6446
+ /**
6447
+ * A context for reading `text`: the closer search, and the markup reading, may each read four
6448
+ * times it, plus an allowance.
6449
+ */
5804
6450
  function readContext(text) {
6451
+ const allowance = () => ({ left: 4 * text.length + 1e5 });
5805
6452
  return {
5806
6453
  depth: 0,
5807
- budget: { left: 4 * text.length + 1e5 }
6454
+ budget: allowance(),
6455
+ markupBudget: allowance()
5808
6456
  };
5809
6457
  }
5810
6458
  function deeper(context) {
5811
6459
  return {
5812
- depth: context.depth + 1,
5813
- budget: context.budget
6460
+ ...context,
6461
+ depth: context.depth + 1
5814
6462
  };
5815
6463
  }
5816
6464
  /** Index of the first match of a global `re` at or after `from`, or -1. */
@@ -6040,7 +6688,10 @@ function lexArgument(raw, pos) {
6040
6688
  case "[": {
6041
6689
  const end = lexSquareBracketed(raw, pos + 1);
6042
6690
  return end === void 0 ? void 0 : {
6043
- arg: { kind: "markup" },
6691
+ arg: {
6692
+ kind: "markup",
6693
+ text: raw.slice(pos, end)
6694
+ },
6044
6695
  end
6045
6696
  };
6046
6697
  }
@@ -6184,13 +6835,44 @@ function ownPassageLink(tag, lexed) {
6184
6835
  const arg = passageArgument(tag.name, lexed);
6185
6836
  const passage = arg === void 0 ? void 0 : passageName(arg);
6186
6837
  return passage === void 0 ? void 0 : {
6187
- macro: tag.name,
6838
+ via: tag.name,
6188
6839
  passage
6189
6840
  };
6190
6841
  }
6191
6842
  /**
6192
- * The passages a tag names: its own, if it is a `<<link>>` or `<<goto>>` call, then those of the
6193
- * calls inside the strings of its arguments, which the macro may print or pass on
6843
+ * The passage a link's destination names. SugarCube evaluates a destination that no passage is
6844
+ * named by, and a quoted string gives its value (`[[Go|"Room"]]`), unless that is blank.
6845
+ */
6846
+ function linkDestination(link) {
6847
+ const value = evalStringLiteral(link);
6848
+ return value === void 0 || value.trim() === "" ? link : value;
6849
+ }
6850
+ /**
6851
+ * The passage that link markup from `start` in `text` names, and where the markup ends, or
6852
+ * `undefined` where SugarCube rejects the markup. Image markup names none here, nor does markup
6853
+ * with a template literal's `${…}` in it, whose passage is known only in play.
6854
+ */
6855
+ function linkMarkupAt(text, start, budget) {
6856
+ const markup = readSquareBracketedMarkup(text, start, budget);
6857
+ if (markup === void 0) return;
6858
+ const passage = markup.type === "link" && !text.slice(start, markup.end).includes("﷐") ? markup.link : void 0;
6859
+ return {
6860
+ link: passage === void 0 ? void 0 : {
6861
+ via: "markup",
6862
+ passage: linkDestination(passage)
6863
+ },
6864
+ end: markup.end
6865
+ };
6866
+ }
6867
+ /** The passage that a link markup argument names, if SugarCube reads the whole argument as markup. */
6868
+ function markupArgumentLinks(raw) {
6869
+ const markup = linkMarkupAt(raw, 0);
6870
+ return markup?.link === void 0 || markup.end !== raw.length ? [] : [markup.link];
6871
+ }
6872
+ /**
6873
+ * The passages a tag names: its own, if it is a `<<link>>` or `<<goto>>` call, then those of its
6874
+ * link markup arguments (`<<button [[Go|Room]]>>`), and those of the links and calls inside the
6875
+ * strings of its arguments, which the macro may print or pass on
6194
6876
  * (`<<set _out to '<<link "Go" "Room">><</link>>'>>`). The arguments of the macros SugarCube
6195
6877
  * doesn't split are read as JavaScript; in others, a backquoted expression is.
6196
6878
  */
@@ -6203,7 +6885,7 @@ function tagPassageLinks(tag, context) {
6203
6885
  switch (arg.kind) {
6204
6886
  case "string": return markupPassageLinks(arg.value, deeper(context));
6205
6887
  case "expression": return javaScriptPassageLinks(arg.code, context);
6206
- case "markup":
6888
+ case "markup": return markupArgumentLinks(arg.text);
6207
6889
  case "word": return [];
6208
6890
  default: throw new Error(`unhandled macro argument: ${JSON.stringify(arg)}`);
6209
6891
  }
@@ -6213,16 +6895,26 @@ function tagPassageLinks(tag, context) {
6213
6895
  /**
6214
6896
  * The passage named by a `<<link>>` or `<<goto>>` that appears unquoted inside another tag's
6215
6897
  * arguments, at `start`. SugarCube never sees such an outer "tag" when its `<<` lies in text it
6216
- * reads as something else, such as link markup (`[[<<-- Back|Prev]]`); the call inside it then
6217
- * runs. The call ends where the outer tag ends.
6898
+ * reads as something else, such as verbatim text (`{{{<<if}}}`); the call inside it then runs.
6899
+ * The call ends where the outer tag ends.
6218
6900
  */
6219
6901
  function innerPassageLink(matchTagAt, start) {
6220
6902
  const inner = matchTagAt(start)?.tag;
6221
6903
  const lexed = inner === void 0 ? void 0 : lexMacroArgs(inner.args);
6222
6904
  return inner === void 0 || lexed === void 0 ? void 0 : ownPassageLink(inner, lexed);
6223
6905
  }
6224
- const REGION_OPEN_RE = /\/\/|\/\*|\/%|<!--|<[Ss][Cc][Rr][Ii][Pp][Tt]/g;
6906
+ const REGION_OPEN_RE = /\[\[[^[]|\[[<>]?[Ii][Mm][Gg]\[|\/\/|\/\*|\/%|<!--|<[Ss][Cc][Rr][Ii][Pp][Tt]/g;
6225
6907
  const HAS_SCRIPT_OPEN_RE = /<[Ss][Cc][Rr][Ii][Pp][Tt]/;
6908
+ function regionKind(opener) {
6909
+ if (opener.startsWith("[")) return "markup";
6910
+ switch (opener) {
6911
+ case "//":
6912
+ case "/*":
6913
+ case "/%":
6914
+ case "<!--": return opener;
6915
+ default: return "script";
6916
+ }
6917
+ }
6226
6918
  const SCRIPT_CLOSE_RE = /<\/[Ss][Cc][Rr][Ii][Pp][Tt]>|[\r\u2028\u2029]/g;
6227
6919
  const COMMENT_CLOSE_RES = {
6228
6920
  "/*": /\*\/|[\r\u2028\u2029]/g,
@@ -6376,22 +7068,31 @@ function append(links, more) {
6376
7068
  for (const link of more) links.push(link);
6377
7069
  }
6378
7070
  /**
6379
- * The passages that `<<link>>` and `<<goto>>` calls in passage markup name. Calls whose passage
6380
- * is known only in play (a variable or an expression) are left out. A tag's own passage comes
6381
- * before those of the calls inside its arguments' strings.
7071
+ * The passages that link markup (`[[…]]`) and `<<link>>` and `<<goto>>` calls in passage markup
7072
+ * name, in the order they come. Calls whose passage is known only in play (a variable or an
7073
+ * expression) are left out. A tag's own passage comes before those that its arguments name, as
7074
+ * link markup or inside strings.
6382
7075
  *
6383
7076
  * The markup is walked in order, as SugarCube's wikifier walks it, with a tag tried at each
6384
- * `<<` the walk reaches. Of the wikifier's other parsers, only those whose text holds no call
6385
- * are told apart: comments (`/* … *` + `/`, `/% … %/`, `<!-- … -->`), whose calls never run,
6386
- * and `<script>` elements and `<<script>>` bodies, which are JavaScript: only the calls in their
6387
- * strings are read (see `findJavaScriptPassageLinks`). Everything else, verbatim text included,
6388
- * is read as markup.
7077
+ * `<<` the walk reaches, and link or image markup read at each `[[` or `[img[`; markup SugarCube
7078
+ * rejects is read on from just after its opener, and so is markup after a limit on how much
7079
+ * markup a reading may scan, which only input built to take quadratic time reaches (see
7080
+ * `readSquareBracketedMarkup`). Image markup names no passage here. Of the wikifier's other
7081
+ * parsers, only those whose text holds no link or call are told apart: comments (`/* … *` + `/`,
7082
+ * `/% … %/`, `<!-- … -->`), whose links and calls never run, and `<script>` elements and
7083
+ * `<<script>>` bodies, which are JavaScript: only the links and calls in their strings are read
7084
+ * (see `findJavaScriptPassageLinks`). Everything else, verbatim text included, is read as markup.
6389
7085
  *
6390
7086
  * Where this differs from SugarCube:
7087
+ * - a link whose passage SugarCube evaluates, because no passage has its name, is taken by its
7088
+ * name, `[[Go|$next]]` naming the passage `$next`, unless it is a quoted string, which is taken
7089
+ * by its value even when a passage has the name with the quotes;
7090
+ * - a link SugarCube takes for a URL, because no passage has its name and it looks like one or
7091
+ * holds a `/`, `.`, `?` or `#`, is still read as naming a passage;
6391
7092
  * - a comment or a `<<script>>` body is skipped whole, even where `parseBody` ends a container
6392
7093
  * such as `<<if>>` at a closing tag inside it and the rest then runs;
6393
- * - a `/*`, `<!--` or `<script` inside other markup that holds no call, such as link markup or a
6394
- * `<style>` element, still starts a comment or an element;
7094
+ * - a `[[`, `/*`, `<!--` or `<script` inside other markup that holds no call, such as verbatim
7095
+ * text or a `<style>` element, still starts link markup, a comment or an element;
6395
7096
  * - a `<<` in such other markup still starts a tag. When that tag runs over a `<<link` or
6396
7097
  * `<<goto` outside the parts of its arguments that the tag pattern reads as units, that call
6397
7098
  * is still read (see `innerPassageLink`), even where the tag's arguments would be JavaScript,
@@ -6406,33 +7107,43 @@ function append(links, more) {
6406
7107
  * `Config.passages.nobr`, are not joined before they are read (see `storyInspect` for passages
6407
7108
  * tagged `nobr`).
6408
7109
  */
6409
- function findMacroPassageLinks(text) {
7110
+ function findPassageLinks(text) {
6410
7111
  return markupPassageLinks(text, readContext(text));
6411
7112
  }
6412
7113
  function markupPassageLinks(text, context) {
6413
- if (context.depth > MAX_STRING_DEPTH || !text.includes("<<") && !HAS_SCRIPT_OPEN_RE.test(text)) return [];
7114
+ if (context.depth > MAX_STRING_DEPTH || !text.includes("<<") && !text.includes("[[") && !HAS_SCRIPT_OPEN_RE.test(text)) return [];
6414
7115
  const matchTagAt = tagMatcher(text);
6415
7116
  const matchInnerTagAt = tagMatcher(text);
6416
7117
  const readComment = commentReader(text);
6417
7118
  const readScriptElement = scriptElementReader(text);
6418
7119
  let findScriptCloser;
6419
7120
  const links = [];
6420
- /** Reads the region `opener` opens, adding the calls in a `<script>` element; returns where the walk goes on. */
7121
+ /**
7122
+ * Reads the region `opener` opens, adding the passage that link markup names and the links and
7123
+ * calls in a `<script>` element; returns where the walk goes on.
7124
+ */
6421
7125
  const regionEnd = (opener) => {
6422
7126
  const start = opener.index;
6423
- const kind = opener[0];
7127
+ const kind = regionKind(opener[0]);
6424
7128
  switch (kind) {
7129
+ case "markup": {
7130
+ const markup = linkMarkupAt(text, start, context.markupBudget);
7131
+ if (markup === void 0) return start + opener[0].length;
7132
+ if (markup.link !== void 0) links.push(markup.link);
7133
+ return markup.end;
7134
+ }
6425
7135
  case "//": return start + 2;
6426
7136
  case "/*":
6427
7137
  case "/%":
6428
7138
  case "<!--": return readComment(kind, start) ?? start + kind.length;
6429
- default: {
7139
+ case "script": {
6430
7140
  const element = readScriptElement(start);
6431
7141
  if (element.openerEnd === void 0) return start + 1;
6432
7142
  if (element.close === void 0) return element.openerEnd;
6433
7143
  append(links, javaScriptPassageLinks(text.slice(element.openerEnd, element.close), context));
6434
7144
  return element.close + 9;
6435
7145
  }
7146
+ default: throw new Error(`unhandled region: ${String(kind)}`);
6436
7147
  }
6437
7148
  };
6438
7149
  let macro = -2;
@@ -6472,24 +7183,26 @@ function markupPassageLinks(text, context) {
6472
7183
  }
6473
7184
  }
6474
7185
  /**
6475
- * The passages that `<<link>>` and `<<goto>>` calls in the strings of JavaScript source name,
6476
- * such as a script passage or a `<<script>>` body: `$.wiki('<<goto "Room">>')`. Code outside
6477
- * the strings isn't markup, so a call put together while the story plays, such as
6478
- * `'<<goto "' + target + '">>'`, names no passage here.
7186
+ * The passages that link markup and `<<link>>` and `<<goto>>` calls in the strings of JavaScript
7187
+ * source name, such as a script passage or a `<<script>>` body: `$.wiki('<<goto "Room">>')`.
7188
+ * Code outside the strings isn't markup, so a call put together while the story plays, such as
7189
+ * `'<<goto "' + target + '">>'`, names no passage here, nor does an array of arrays (`[[0, 1]]`).
6479
7190
  */
6480
7191
  function findJavaScriptPassageLinks(source) {
6481
7192
  return javaScriptPassageLinks(source, readContext(source));
6482
7193
  }
6483
7194
  /**
6484
7195
  * What JavaScript source must contain for one of its string or template literals to hold a call
6485
- * that is read. A value holding `<<`: each `<` in it is written as `<`, `\<`, `\x3c`, `\u003c` or
6486
- * `\u{…}`, two can be next to each other with only line continuations between, and the first
6487
- * is `<` or `\<` (then the source holds `<<` or `<\`) or one of the others. Or a value holding a
6488
- * `<script>` element, whose own strings can make `<<` from escapes the outer string encodes.
7196
+ * or link markup that is read. A value holding `<<`: each `<` in it is written as `<`, `\<`,
7197
+ * `\x3c`, `\u003c` or `\u{…}`, two can be next to each other with only line continuations
7198
+ * between, and the first is `<` or `\<` (then the source holds `<<` or `<\`) or one of the others.
7199
+ * A value holding `[[`, likewise: the source holds `[[`, a `[` before `\[` or a line continuation,
7200
+ * `\x5b`, `\u005b` or `\u{`. Or a value holding a `<script>` element, whose own strings can make
7201
+ * `<<` or `[[` from escapes the outer string encodes.
6489
7202
  */
6490
- const MAY_HOLD_MACRO_RE = /<<|<\\|\\x3c|\\u003c|\\u\{|<script/i;
7203
+ const MAY_HOLD_LINK_RE = /<<|<\\|\\x3c|\\u003c|\\u\{|<script|\[\[|\[\\[[\n\r\u2028\u2029]|\\x5b|\\u005b/i;
6491
7204
  function javaScriptPassageLinks(source, context) {
6492
- if (context.depth >= MAX_STRING_DEPTH || !MAY_HOLD_MACRO_RE.test(source)) return [];
7205
+ if (context.depth >= MAX_STRING_DEPTH || !MAY_HOLD_LINK_RE.test(source)) return [];
6493
7206
  const inner = deeper(context);
6494
7207
  const links = [];
6495
7208
  for (const value of javaScriptStrings(source)) append(links, markupPassageLinks(value, inner));
@@ -6498,43 +7211,57 @@ function javaScriptPassageLinks(source, context) {
6498
7211
  //#endregion
6499
7212
  //#region src/inspect.ts
6500
7213
  /**
6501
- * Extract all [[wiki-style links]] from passage text.
7214
+ * Extract the passages that passage text links to, read as SugarCube 2 reads them.
6502
7215
  *
6503
7216
  * Supported syntaxes:
6504
7217
  * - `[[PassageName]]`
6505
- * - `[[Display Text->PassageName]]` (Twine 2 / SugarCube arrow)
6506
- * - `[[Display Text|PassageName]]` (Twine 1 / Harlowe pipe)
6507
- * - `<<goto "PassageName">>` (SugarCube macro)
7218
+ * - `[[Display Text|PassageName]]`
7219
+ * - `[[Display Text->PassageName]]`
7220
+ * - `[[PassageName<-Display Text]]`
7221
+ * - any of those with a setter: `[[Display Text|PassageName][$x to 1]]`
7222
+ * - `<<goto "PassageName">>` (SugarCube macro)
6508
7223
  * - `<<link "Display" "PassageName">>` (SugarCube macro)
6509
7224
  *
6510
- * SugarCube macro arguments may be double- or single-quoted, with backslash escapes, or bare
6511
- * words; a passage named by a variable or an expression is known only in play and is skipped.
6512
- * Macro calls are also read inside the quoted strings of other macros' arguments, and inside the
6513
- * strings of `<<script>>` bodies and `<script>` elements; calls in comments are not read. A
6514
- * script passage (`isScript`) is JavaScript, so its macro calls are read only inside its strings.
6515
- * See `sugarcube-macros.ts` for the details and the known differences from SugarCube.
7225
+ * In link markup, the first `|`, `->` or `<-` divides the text from the passage name; image
7226
+ * markup (`[img[…][PassageName]]`) is not read. SugarCube macro arguments may be double- or
7227
+ * single-quoted, with backslash escapes, or bare words; a passage named by a variable or an
7228
+ * expression is known only in play and is skipped. Links and macro calls are also read inside the
7229
+ * quoted strings of macros' arguments, and inside the strings of `<<script>>` bodies and
7230
+ * `<script>` elements; links and calls in comments are not read. A script passage (`isScript`) is
7231
+ * JavaScript, so its links and calls are read only inside its strings. See `sugarcube-macros.ts`
7232
+ * for the details and the known differences from SugarCube.
6516
7233
  */
6517
7234
  function extractLinksFromText(text, isScript) {
6518
- const targets = /* @__PURE__ */ new Set();
6519
- const wikiLinkRe = /\[\[([^\]]+)\]\]/g;
6520
- let m;
6521
- while ((m = wikiLinkRe.exec(text)) !== null) {
6522
- const content = m[1];
6523
- const arrowIdx = content.indexOf("->");
6524
- if (arrowIdx !== -1) {
6525
- targets.add(content.slice(arrowIdx + 2).trim());
6526
- continue;
6527
- }
6528
- const pipeIdx = content.indexOf("|");
6529
- if (pipeIdx !== -1) {
6530
- targets.add(content.slice(pipeIdx + 1).trim());
6531
- continue;
6532
- }
6533
- targets.add(content.trim());
7235
+ const found = isScript ? findJavaScriptPassageLinks(text) : findPassageLinks(text);
7236
+ const ordered = [
7237
+ "markup",
7238
+ "goto",
7239
+ "link"
7240
+ ].flatMap((via) => found.filter((link) => link.via === via).map((link) => link.passage));
7241
+ return [...new Set(ordered)];
7242
+ }
7243
+ /**
7244
+ * The passages that `p` links to. A script passage is JavaScript, read for links only in its
7245
+ * strings. A stylesheet is CSS, which the story puts in a style element and never wikifies, so it
7246
+ * links nowhere; a passage tagged both is a script, as Twine 2 output takes it.
7247
+ */
7248
+ function passageLinks(p) {
7249
+ if (hasTag(p, "script")) return extractLinksFromText(textAsRead(p), true);
7250
+ if (hasTag(p, "stylesheet")) return [];
7251
+ return extractLinksFromText(textAsRead(p), false);
7252
+ }
7253
+ /**
7254
+ * Each passage name, mapped to why the `target` output leaves that passage out, or to `undefined`
7255
+ * when a link can reach it: always without a target, and otherwise when the output emits a
7256
+ * passage of that name.
7257
+ */
7258
+ function destinationOmissions(story, { target }) {
7259
+ const omissions = /* @__PURE__ */ new Map();
7260
+ for (const p of story.passages) {
7261
+ const omission = target === void 0 ? void 0 : passageOmission(story, p, target);
7262
+ if (!omissions.has(p.name) || omission === void 0) omissions.set(p.name, omission);
6534
7263
  }
6535
- const macroLinks = isScript ? findJavaScriptPassageLinks(text) : findMacroPassageLinks(text);
6536
- for (const macro of ["goto", "link"]) for (const link of macroLinks) if (link.macro === macro) targets.add(link.passage);
6537
- return [...targets];
7264
+ return omissions;
6538
7265
  }
6539
7266
  /** Passages SugarCube runs from their raw text, never joining their lines. */
6540
7267
  const RAW_TEXT_PASSAGES = /* @__PURE__ */ new Set([
@@ -6577,9 +7304,14 @@ function joinLines(text) {
6577
7304
  * expect(map.brokenLinks).toEqual([]);
6578
7305
  * expect(map.deadEnds).not.toContain('Start');
6579
7306
  * ```
7307
+ *
7308
+ * By default, a link is broken only when no passage has its name. Pass a `target` to check links
7309
+ * against what that output emits: `storyInspect(result.story, { target: 'twine2' })` also reports
7310
+ * links to passages Twine 2 output leaves out, such as script, stylesheet and `Twine.private`
7311
+ * passages.
6580
7312
  */
6581
- function storyInspect(story) {
6582
- const allNames = new Set(story.passages.map((p) => p.name));
7313
+ function storyInspect(story, options = {}) {
7314
+ const destinations = destinationOmissions(story, options);
6583
7315
  const passages = [];
6584
7316
  const storyPassages = [];
6585
7317
  const infoPassages = [];
@@ -6601,14 +7333,24 @@ function storyInspect(story) {
6601
7333
  }
6602
7334
  list.push(p.name);
6603
7335
  }
6604
- const targets = extractLinksFromText(textAsRead(p), hasTag(p, "script"));
6605
- links.set(p.name, targets);
7336
+ links.set(p.name, passageLinks(p));
6606
7337
  }
6607
7338
  const brokenLinks = [];
6608
- for (const [from, targets] of links) for (const to of targets) if (!allNames.has(to)) brokenLinks.push({
6609
- from,
6610
- to
6611
- });
7339
+ for (const [from, targets] of links) for (const to of targets) {
7340
+ if (!destinations.has(to)) {
7341
+ brokenLinks.push({
7342
+ from,
7343
+ to
7344
+ });
7345
+ continue;
7346
+ }
7347
+ const omission = destinations.get(to);
7348
+ if (omission !== void 0) brokenLinks.push({
7349
+ from,
7350
+ to,
7351
+ omission
7352
+ });
7353
+ }
6612
7354
  const deadEnds = [];
6613
7355
  for (const name of storyPassages) {
6614
7356
  const targets = links.get(name);
@@ -6637,16 +7379,29 @@ function storyInspect(story) {
6637
7379
  //#region src/lint.ts
6638
7380
  /**
6639
7381
  * Lint a story: compile without output rendering, then inspect structure.
6640
- * Uses JSON output mode internally to avoid format resolution.
7382
+ * Uses JSON output mode internally to avoid format resolution, so it checks
7383
+ * the starting passage itself, against the Twine 2 passage rules. Link
7384
+ * destinations are checked against the same rules: a link to a passage that
7385
+ * Twine 2 output leaves out is broken.
6641
7386
  */
6642
7387
  async function lint(options) {
6643
- const result = await compile({
7388
+ return lintForOutputFile(options, void 0);
7389
+ }
7390
+ /**
7391
+ * lint() for a project whose builds are written to `outFile`: like a build for that file,
7392
+ * it leaves `outFile` out of the sources and modules, so an earlier build inside a source
7393
+ * folder is not linted as a source.
7394
+ *
7395
+ * Internal, for the CLI; not part of the public API.
7396
+ */
7397
+ async function lintForOutputFile(options, outFile) {
7398
+ const result = await compileForOutputFile({
6644
7399
  ...options,
6645
7400
  outputMode: "json"
6646
- });
6647
- const map = storyInspect(result.story);
7401
+ }, outFile);
7402
+ const map = storyInspect(result.story, { target: "twine2" });
6648
7403
  return {
6649
- diagnostics: result.diagnostics,
7404
+ diagnostics: [...result.diagnostics, ...startPassageDiagnostics(result.story, map.start, "twine2")],
6650
7405
  stats: result.stats,
6651
7406
  formatName: result.story.twine2.format,
6652
7407
  formatVersion: result.story.twine2.formatVersion,
@@ -6671,7 +7426,10 @@ function formatLintReport(result) {
6671
7426
  if (result.brokenLinks.length > 0) {
6672
7427
  lines.push("");
6673
7428
  lines.push(`Broken links (${result.brokenLinks.length}):`);
6674
- for (const link of result.brokenLinks) lines.push(` ${link.from} -> ${link.to} (passage "${link.to}" does not exist)`);
7429
+ for (const link of result.brokenLinks) {
7430
+ const why = link.omission === void 0 ? "does not exist" : `${describeOmission(link.omission)}, so it is left out of the story data`;
7431
+ lines.push(` ${link.from} -> ${link.to} (passage "${link.to}" ${why})`);
7432
+ }
6675
7433
  }
6676
7434
  if (result.deadEnds.length > 0) {
6677
7435
  lines.push("");