@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.js CHANGED
@@ -1,6 +1,6 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, watch as watch$1, writeFileSync } from "node:fs";
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, watch as watch$1, writeFileSync } from "node:fs";
2
2
  import * as nodePath from "node:path";
3
- import { basename, dirname, extname, join, parse, relative, resolve, sep } from "node:path";
3
+ import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { homedir } from "node:os";
6
6
  import { fileURLToPath } from "node:url";
@@ -110,6 +110,61 @@ function commentSanitize(s) {
110
110
  function htmlCommentSanitize(s) {
111
111
  return s.replace(/-->/g, "-- >");
112
112
  }
113
+ /**
114
+ * Escape JavaScript for the content of an HTML `<script>` element, so that the HTML parser reads all of it as
115
+ * the element's text and ends the element at the end tag written after it.
116
+ *
117
+ * - Every `<\/script` (in any letter case) becomes `<\/script`. HTML ends a script element at the first
118
+ * `<\/script` followed by whitespace, `/` or `>`, wherever it is in the JavaScript, even inside a string.
119
+ * - Every `<!--` becomes `<\!--`, but only if the text would otherwise end in the HTML tokenizer's "script
120
+ * data double escaped" state: after a `<!--` and then a `<script` start tag with no `-->` between them, the
121
+ * end tag after the text would not end the element.
122
+ *
123
+ * Inserting a backslash after `<` keeps the value of every string, template, and regular expression literal
124
+ * and every comment where these sequences can be written (`\/` and `\!` are identity escapes; a backslash
125
+ * already before the `<` escapes the `<`, as it did before); `<\/script` is also how minifiers such as Terser
126
+ * write inline scripts. It only changes code that uses them outside literals (`a <\/script/ b`, an HTML-like `<!--`
127
+ * comment that is escaped because of a later `<script`), the raw text of a tagged template, and a `<!--` in a
128
+ * regular expression with the `u` or `v` flag. The result is unchanged by escaping it again.
129
+ */
130
+ function scriptContentEscape(s) {
131
+ const escaped = s.replace(/<(?=\/script)/gi, "<\\");
132
+ return endsDoubleEscaped(escaped) ? escaped.replace(/<(?=!--)/g, "<\\") : escaped;
133
+ }
134
+ /**
135
+ * Whether the HTML tokenizer, reading `s` as script element content with no `<\/script` in it, ends in the
136
+ * "script data double escaped" state, where an end tag after `s` would not end the element.
137
+ * See https://html.spec.whatwg.org/multipage/parsing.html#script-data-escaped-state.
138
+ */
139
+ function endsDoubleEscaped(s) {
140
+ const scriptStartTag = /<script[\t\n\f\r />]/gi;
141
+ let pos = 0;
142
+ for (;;) {
143
+ const open = s.indexOf("<!--", pos);
144
+ if (open === -1) return false;
145
+ const escapedFrom = open + 2;
146
+ const close = s.indexOf("-->", escapedFrom);
147
+ scriptStartTag.lastIndex = escapedFrom;
148
+ const start = scriptStartTag.exec(s);
149
+ if (start === null || close !== -1 && close < start.index) {
150
+ if (close === -1) return false;
151
+ pos = close + 3;
152
+ continue;
153
+ }
154
+ const end = s.indexOf("-->", start.index + start[0].length);
155
+ if (end === -1) return true;
156
+ pos = end + 3;
157
+ }
158
+ }
159
+ /**
160
+ * Escape CSS for the content of an HTML `<style>` element: every `</style` (in any letter case) becomes
161
+ * `<\/style`, since HTML ends a style element at the first `</style` followed by whitespace, `/` or `>`.
162
+ * `\/` stands for `/` in CSS strings and URLs, and comments don't matter; `</style` anywhere else isn't valid
163
+ * CSS anyway.
164
+ */
165
+ function styleContentEscape(s) {
166
+ return s.replace(/<(?=\/style)/gi, "<\\");
167
+ }
113
168
  /** Apply ROT13 encoding to a string (only affects [A-Za-z]). */
114
169
  function rot13(s) {
115
170
  if (s.length === 0) return s;
@@ -691,10 +746,35 @@ function getFilenames(pathnames, outFilename, exclude = []) {
691
746
  diagnostics
692
747
  };
693
748
  }
749
+ /** Whether the absolute path `file` is the absolute path `dir` or inside it. */
750
+ function isInsideDir(file, dir) {
751
+ return file === dir || file.startsWith(dir.endsWith(sep) ? dir : dir + sep);
752
+ }
753
+ /**
754
+ * The watched paths split into folders (as given) and files (absolute). A path
755
+ * that cannot be read counts as a folder, which fs.watch then fails to watch.
756
+ */
757
+ function splitWatchRoots(pathnames) {
758
+ const dirs = [];
759
+ const files = /* @__PURE__ */ new Set();
760
+ for (const pathname of pathnames) {
761
+ let isFile = false;
762
+ try {
763
+ isFile = statSync(pathname).isFile();
764
+ } catch {}
765
+ if (isFile) files.add(resolve(pathname));
766
+ else dirs.push(pathname);
767
+ }
768
+ return {
769
+ dirs,
770
+ files
771
+ };
772
+ }
694
773
  /**
695
774
  * Watch paths for changes, calling the build callback on known file type changes.
696
- * A change to a file `ignore` returns true for (given the path relative to the
697
- * working directory) schedules no build.
775
+ * A folder is watched recursively; a file is watched on its own and counts
776
+ * whatever its type (a head file, say). A change to a file `ignore` returns true
777
+ * for (given the path relative to the working directory) schedules no build.
698
778
  * Uses debouncing to avoid rapid rebuilds.
699
779
  */
700
780
  function watchFilesystem(pathnames, outFilename, callback, ignore = () => false) {
@@ -713,18 +793,30 @@ function watchFilesystem(pathnames, outFilename, callback, ignore = () => false)
713
793
  callback(files);
714
794
  }, BUILD_DEBOUNCE);
715
795
  }
716
- for (const pathname of pathnames) try {
717
- const watcher = watch$1(pathname, { recursive: true }, (_event, filename) => {
718
- if (!filename) return;
719
- const abs = resolve(pathname, filename);
720
- if (abs === absOutFile) return;
721
- if (isKnownFileType(filename)) {
722
- const rel = relative(process.cwd(), abs);
723
- if (!ignore(rel || abs)) scheduleBuild(rel || abs);
724
- }
725
- });
726
- watchers.push(watcher);
727
- } catch {}
796
+ function fileChanged(abs, named) {
797
+ if (abs === absOutFile) return;
798
+ if (!named && !isKnownFileType(abs)) return;
799
+ const rel = relative(process.cwd(), abs);
800
+ if (!ignore(rel || abs)) scheduleBuild(rel || abs);
801
+ }
802
+ function watchPath(pathname, recursive, listener) {
803
+ try {
804
+ watchers.push(watch$1(pathname, { recursive }, (_event, filename) => {
805
+ if (filename) listener(filename);
806
+ }));
807
+ } catch {}
808
+ }
809
+ const { dirs, files } = splitWatchRoots(pathnames);
810
+ const absDirs = dirs.map((dir) => resolve(dir));
811
+ for (const dir of dirs) watchPath(dir, true, (filename) => {
812
+ const abs = resolve(dir, filename);
813
+ fileChanged(abs, files.has(abs));
814
+ });
815
+ const fileDirs = new Set([...files].filter((file) => !absDirs.some((dir) => isInsideDir(file, dir))).map((file) => dirname(file)));
816
+ for (const dir of fileDirs) watchPath(dir, false, (filename) => {
817
+ const abs = resolve(dir, filename);
818
+ if (files.has(abs)) fileChanged(abs, true);
819
+ });
728
820
  callback();
729
821
  return { close() {
730
822
  if (buildTimer) clearTimeout(buildTimer);
@@ -732,16 +824,25 @@ function watchFilesystem(pathnames, outFilename, callback, ignore = () => false)
732
824
  } };
733
825
  }
734
826
  //#endregion
827
+ //#region src/source-text.ts
828
+ /**
829
+ * Source text normalization shared by every input path (files, in-memory sources, `parseTwee`).
830
+ */
831
+ /**
832
+ * Strip a leading UTF-8 byte order mark and convert CRLF and bare CR line endings to LF.
833
+ * Idempotent, so text that is already normalized passes through unchanged.
834
+ */
835
+ function normalizeSourceText(text) {
836
+ return (text.charCodeAt(0) === 65279 ? text.slice(1) : text).replace(/\r\n?/g, "\n");
837
+ }
838
+ //#endregion
735
839
  //#region src/util.ts
736
840
  /**
737
841
  * Shared file I/O utilities.
738
842
  */
739
843
  /** Read a file as UTF-8 with BOM stripping and line ending normalization. */
740
844
  function readUTF8(filename) {
741
- let content = readFileSync(filename, "utf-8");
742
- if (content.charCodeAt(0) === 65279) content = content.slice(1);
743
- content = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
744
- return content;
845
+ return normalizeSourceText(readFileSync(filename, "utf-8"));
745
846
  }
746
847
  /** Read a file as base64. */
747
848
  function readBase64(filename) {
@@ -771,16 +872,28 @@ function relaxedJSONParse(json) {
771
872
  fixed = fixed.replace(/,\s*([}\]])/g, "$1");
772
873
  return JSON.parse(fixed);
773
874
  }
875
+ /** A `"setup": function` property, which Harlowe appends to its otherwise-JSON format object. */
876
+ const SETUP_FUNCTION_PROPERTY = /,\s*"setup"\s*:\s*function\b/g;
877
+ /** Drop a trailing `"setup": function(){…}` property from a format object, if it has one. */
878
+ function stripSetupFunction(chunk) {
879
+ let lastIndex = -1;
880
+ for (const match of chunk.matchAll(SETUP_FUNCTION_PROPERTY)) lastIndex = match.index;
881
+ return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
882
+ }
774
883
  /**
775
884
  * Parse the Twine 2 format.js JSON chunk.
776
- * Handles Harlowe's malformed JSON by stripping the "setup" function property.
885
+ * Handles Harlowe's malformed JSON by stripping the "setup" function property. The workaround
886
+ * keys on the property itself, so the same bytes parse whether they come from a `harlowe-3`
887
+ * directory or a direct download.
777
888
  * Per spec, the name key is Optional.
889
+ *
890
+ * @param _formatId No longer used; kept so existing callers keep compiling.
778
891
  */
779
- function parseFormatJSON(source, formatId) {
892
+ function parseFormatJSON(source, _formatId) {
780
893
  const first = source.indexOf("{");
781
894
  const last = source.lastIndexOf("}");
782
895
  if (first === -1 || last === -1) return null;
783
- let chunk = source.slice(first, last + 1);
896
+ const chunk = source.slice(first, last + 1);
784
897
  const parse = (json) => {
785
898
  const raw = relaxedJSONParse(json);
786
899
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
@@ -803,16 +916,13 @@ function parseFormatJSON(source, formatId) {
803
916
  try {
804
917
  return parse(chunk);
805
918
  } catch {
806
- if (formatId.toLowerCase().startsWith("harlowe")) {
807
- const setupIdx = chunk.lastIndexOf(",\"setup\": function");
808
- if (setupIdx !== -1) {
809
- chunk = chunk.slice(0, setupIdx) + "}";
810
- try {
811
- return parse(chunk);
812
- } catch {}
813
- }
919
+ const stripped = stripSetupFunction(chunk);
920
+ if (stripped === void 0) return null;
921
+ try {
922
+ return parse(stripped);
923
+ } catch {
924
+ return null;
814
925
  }
815
- return null;
816
926
  }
817
927
  }
818
928
  /**
@@ -962,6 +1072,54 @@ function semverCompare(a, b) {
962
1072
  if (aPatch !== bPatch) return aPatch - bPatch;
963
1073
  return 0;
964
1074
  }
1075
+ /** Build a directory-style format ID from a name and version, e.g. ('SugarCube', '2.37.3') → 'sugarcube-2'. */
1076
+ function makeFormatId(name, version) {
1077
+ const major = parseSemver(version)?.[0] ?? 0;
1078
+ return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
1079
+ }
1080
+ /** Describe a format request for diagnostics. */
1081
+ function describeFormatRequest(request) {
1082
+ switch (request.kind) {
1083
+ case "id": return `"${request.id}"`;
1084
+ case "name": return request.version ? `"${request.name}" at version "${request.version}"` : `"${request.name}"`;
1085
+ default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
1086
+ }
1087
+ }
1088
+ /**
1089
+ * Pick the candidate that best answers a format request.
1090
+ *
1091
+ * - An ID request ('sugarcube-2') matches a candidate whose name and major version build that ID,
1092
+ * and takes the greatest such version.
1093
+ * - A name request matches the name case-insensitively. An exact version wins outright; otherwise
1094
+ * the greatest version with the same major that is not older than the one asked for (or, with
1095
+ * `allowOlder`, the greatest same-major version). An unparseable requested version takes the
1096
+ * greatest version of any major.
1097
+ */
1098
+ function selectFormatCandidate(request, candidates, describe, options = {}) {
1099
+ const allowOlder = options.allowOlder ?? false;
1100
+ const wanted = request.kind === "name" ? parseSemver(request.version) : null;
1101
+ let best;
1102
+ for (const candidate of candidates) {
1103
+ const { name, version } = describe(candidate);
1104
+ const have = parseSemver(version);
1105
+ if (!have) continue;
1106
+ if (request.kind === "id") {
1107
+ if (makeFormatId(name, version) !== request.id.toLowerCase()) continue;
1108
+ } else {
1109
+ if (name.toLowerCase() !== request.name.toLowerCase()) continue;
1110
+ if (wanted) {
1111
+ if (semverCompare(have, wanted) === 0) return candidate;
1112
+ if (have[0] !== wanted[0]) continue;
1113
+ if (!allowOlder && semverCompare(have, wanted) < 0) continue;
1114
+ }
1115
+ }
1116
+ if (!best || semverCompare(have, best.version) > 0) best = {
1117
+ candidate,
1118
+ version: have
1119
+ };
1120
+ }
1121
+ return best?.candidate;
1122
+ }
965
1123
  /** Get format ID from Twine 2 name and version, using SemVer major matching. */
966
1124
  function getFormatIdByNameAndVersion(formats, name, version) {
967
1125
  const wanted = parseSemver(version);
@@ -991,171 +1149,586 @@ function readFormatSource(format) {
991
1149
  return source;
992
1150
  }
993
1151
  //#endregion
994
- //#region src/types.ts
995
- const ItemType = {
996
- Error: 0,
997
- EOF: 1,
998
- Header: 2,
999
- Name: 3,
1000
- Tags: 4,
1001
- Metadata: 5,
1002
- Content: 6
1003
- };
1004
- //#endregion
1005
- //#region src/lexer.ts
1006
- const EOF = -1;
1007
- const HEADER_DELIM = "::";
1008
- const NEWLINE_HEADER_DELIM = "\n::";
1009
- var LexerContext = class {
1010
- input;
1011
- line = 1;
1012
- start = 0;
1013
- pos = 0;
1014
- items = [];
1015
- constructor(input) {
1016
- this.input = input;
1017
- }
1018
- next() {
1019
- if (this.pos >= this.input.length) return EOF;
1020
- const ch = this.input.charCodeAt(this.pos);
1021
- this.pos++;
1022
- if (ch === 10) this.line++;
1023
- return ch;
1024
- }
1025
- peek() {
1026
- if (this.pos >= this.input.length) return EOF;
1027
- return this.input.charCodeAt(this.pos);
1028
- }
1029
- backup() {
1030
- if (this.pos > this.start) {
1031
- this.pos--;
1032
- if (this.input.charCodeAt(this.pos) === 10) this.line--;
1033
- } else throw new Error("backup would leave pos < start");
1034
- }
1035
- emit(type) {
1036
- const val = this.input.slice(this.start, this.pos);
1037
- this.items.push({
1038
- type,
1039
- line: this.line,
1040
- pos: this.start,
1041
- val
1042
- });
1043
- if (type === ItemType.Content) {
1044
- for (let i = this.start; i < this.pos; i++) if (this.input.charCodeAt(i) === 10) this.line++;
1045
- }
1046
- this.start = this.pos;
1047
- }
1048
- ignore() {
1049
- for (let i = this.start; i < this.pos; i++) if (this.input.charCodeAt(i) === 10) this.line++;
1050
- this.start = this.pos;
1051
- }
1052
- acceptRun(valid) {
1053
- let ch;
1054
- for (ch = this.next(); ch !== EOF && valid.includes(String.fromCharCode(ch)); ch = this.next());
1055
- if (ch !== EOF) this.backup();
1056
- }
1057
- errorf(message) {
1058
- this.items.push({
1059
- type: ItemType.Error,
1060
- line: this.line,
1061
- pos: this.start,
1062
- val: message
1063
- });
1064
- return null;
1065
- }
1066
- /** Drain all pending items and return them. */
1067
- drainItems() {
1068
- const result = this.items;
1069
- this.items = [];
1070
- return result;
1071
- }
1072
- };
1073
- function acceptQuoted(ctx, quote) {
1074
- for (;;) {
1075
- const ch = ctx.next();
1076
- switch (ch) {
1077
- case 92: {
1078
- const r = ctx.next();
1079
- if (r !== 10 && r !== EOF) break;
1080
- }
1081
- case 10:
1082
- case EOF: return "unterminated quoted string";
1083
- default: if (ch === quote) return null;
1084
- }
1085
- }
1152
+ //#region src/remote-formats.ts
1153
+ /**
1154
+ * Remote story format fetching, caching, and checksum verification.
1155
+ * Uses the Story Formats Archive (SFA) as the default source.
1156
+ */
1157
+ const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
1158
+ /** Get the cache directory for downloaded story formats. */
1159
+ function getCacheDir() {
1160
+ const base = process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache");
1161
+ return join(base, "twee-ts", "storyformats");
1086
1162
  }
1087
- function lexProlog(ctx) {
1088
- if (ctx.input.startsWith(HEADER_DELIM, ctx.pos)) return lexHeaderDelim;
1089
- const i = ctx.input.indexOf(NEWLINE_HEADER_DELIM, ctx.pos);
1090
- if (i > -1) {
1091
- ctx.pos = i + 1;
1092
- ctx.ignore();
1093
- return lexHeaderDelim;
1163
+ /**
1164
+ * Whether a string can serve as one directory name inside the cache: not empty, not `.` or `..`,
1165
+ * and free of path separators. Format names and versions come from downloaded metadata, so they
1166
+ * are untrusted path input.
1167
+ */
1168
+ function isSafeSegment(segment) {
1169
+ return segment !== "" && segment !== "." && segment !== ".." && !/[/\\\0]/.test(segment);
1170
+ }
1171
+ /** Whether absolute path `target` lies strictly inside absolute path `root`. */
1172
+ function isInside(root, target) {
1173
+ const rel = relative(root, target);
1174
+ return rel !== "" && !isAbsolute(rel) && rel.split(sep)[0] !== "..";
1175
+ }
1176
+ /** The cache directory for one format version. Throws when the name or version could leave the cache. */
1177
+ function cachedFormatDir(name, version) {
1178
+ if (!isSafeSegment(name)) throw new Error(`Refusing to cache a story format with an unsafe name: ${JSON.stringify(name)}`);
1179
+ if (!isSafeSegment(version) || !parseSemver(version)) throw new Error(`Refusing to cache story format "${name}" with an unsafe version: ${JSON.stringify(version)}`);
1180
+ const root = resolve(getCacheDir());
1181
+ const dir = resolve(root, name, version);
1182
+ if (!isInside(root, dir)) throw new Error(`Refusing to cache a story format outside the cache directory ${root}: ${dir}`);
1183
+ return dir;
1184
+ }
1185
+ /** Create `dir` as a plain directory, or check that it already is one (a symlink could lead out of the cache). */
1186
+ function ensurePlainDirectory(dir) {
1187
+ const stat = lstatSync(dir, { throwIfNoEntry: false });
1188
+ if (stat === void 0) {
1189
+ mkdirSync(dir);
1190
+ return;
1094
1191
  }
1095
- ctx.emit(ItemType.EOF);
1096
- return null;
1192
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`Refusing to write a story format outside the cache directory: ${dir} is not a plain directory`);
1193
+ }
1194
+ /** Write a downloaded format.js to `<cache>/<name>/<version>/format.js` and return its path. */
1195
+ function writeCachedFormat(name, version, text) {
1196
+ const dir = cachedFormatDir(name, version);
1197
+ const root = resolve(getCacheDir());
1198
+ mkdirSync(root, { recursive: true });
1199
+ ensurePlainDirectory(join(root, name));
1200
+ ensurePlainDirectory(dir);
1201
+ const formatPath = join(dir, "format.js");
1202
+ if (lstatSync(formatPath, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error(`Refusing to write a story format outside the cache directory: ${formatPath} is a symlink`);
1203
+ writeFileSync(formatPath, text, "utf-8");
1204
+ return formatPath;
1097
1205
  }
1098
- function lexContent(ctx) {
1099
- if (ctx.input.startsWith(HEADER_DELIM, ctx.pos)) return lexHeaderDelim;
1100
- const i = ctx.input.indexOf(NEWLINE_HEADER_DELIM, ctx.pos);
1101
- if (i > -1) {
1102
- ctx.pos = i + 1;
1103
- ctx.emit(ItemType.Content);
1104
- return lexHeaderDelim;
1105
- }
1106
- ctx.pos = ctx.input.length;
1107
- if (ctx.pos > ctx.start) ctx.emit(ItemType.Content);
1108
- ctx.emit(ItemType.EOF);
1109
- return null;
1206
+ /** In-memory index cache, keyed by URL. Cleared each compile. */
1207
+ const indexCache = /* @__PURE__ */ new Map();
1208
+ /** Clear the in-memory index cache. */
1209
+ function clearIndexCache() {
1210
+ indexCache.clear();
1110
1211
  }
1111
- function lexHeaderDelim(ctx) {
1112
- ctx.pos += 2;
1113
- ctx.emit(ItemType.Header);
1114
- return lexName;
1212
+ /** Validate that a JSON value is a valid SFAIndexEntry. */
1213
+ function isValidEntry(val) {
1214
+ if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
1215
+ const obj = val;
1216
+ return typeof obj.name === "string" && typeof obj.version === "string" && typeof obj.checksums === "object" && obj.checksums !== null && !Array.isArray(obj.checksums);
1115
1217
  }
1116
- function lexName(ctx) {
1117
- let r;
1118
- outer: for (;;) {
1119
- r = ctx.next();
1120
- switch (r) {
1121
- case 92: {
1122
- const next = ctx.next();
1123
- if (next !== 10 && next !== EOF) break;
1124
- r = next;
1125
- }
1126
- case 91:
1127
- case 93:
1128
- case 123:
1129
- case 125:
1130
- case 10:
1131
- case EOF:
1132
- if (r !== EOF) ctx.backup();
1133
- break outer;
1134
- }
1135
- }
1136
- ctx.emit(ItemType.Name);
1137
- switch (r) {
1138
- case 91: return lexTags;
1139
- case 93: return ctx.errorf(`unexpected right square bracket ']'`);
1140
- case 123: return lexMetadata;
1141
- case 125: return ctx.errorf(`unexpected right curly brace '}'`);
1142
- case 10:
1143
- ctx.pos++;
1144
- ctx.ignore();
1145
- return lexContent;
1146
- }
1147
- ctx.emit(ItemType.EOF);
1148
- return null;
1218
+ /** Validate that a JSON value conforms to the SFAIndex shape. */
1219
+ function validateSFAIndex(json) {
1220
+ if (typeof json !== "object" || json === null) throw new Error("SFA index is not an object");
1221
+ const obj = json;
1222
+ return {
1223
+ twine1: Array.isArray(obj["twine1"]) ? obj["twine1"].filter(isValidEntry) : [],
1224
+ twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
1225
+ };
1149
1226
  }
1150
- function lexNextOptionalBlock(ctx) {
1151
- ctx.acceptRun(" ");
1152
- ctx.ignore();
1153
- const r = ctx.peek();
1154
- switch (r) {
1155
- case 91: return lexTags;
1156
- case 93: return ctx.errorf(`unexpected right square bracket ']'`);
1157
- case 123: return lexMetadata;
1158
- case 125: return ctx.errorf(`unexpected right curly brace '}'`);
1227
+ /** Fetch and parse an SFA index.json, with in-memory caching. */
1228
+ async function fetchIndex(url) {
1229
+ const cached = indexCache.get(url);
1230
+ if (cached) return cached;
1231
+ const res = await fetch(url);
1232
+ if (!res.ok) throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
1233
+ const data = validateSFAIndex(await res.json());
1234
+ indexCache.set(url, data);
1235
+ return data;
1236
+ }
1237
+ /** Find the best matching entry in an SFA index for a name or ID request (twine2 entries first). */
1238
+ function findEntryForRequest(index, request) {
1239
+ return selectFormatCandidate(request, [...(index.twine2 ?? []).map((entry) => ({
1240
+ entry,
1241
+ formatType: "twine2"
1242
+ })), ...(index.twine1 ?? []).map((entry) => ({
1243
+ entry,
1244
+ formatType: "twine1"
1245
+ }))], (c) => c.entry);
1246
+ }
1247
+ /** Verify SHA-256 checksum using Web Crypto API (Node 22 built-in). */
1248
+ async function verifySHA256(content, expectedHex) {
1249
+ const digest = await crypto.subtle.digest("SHA-256", content);
1250
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("") === expectedHex.toLowerCase();
1251
+ }
1252
+ /** Derive the download URL for a format.js from the index URL and entry. */
1253
+ function getDownloadUrl(indexUrl, entry, formatType) {
1254
+ return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
1255
+ }
1256
+ /** Download a format, verify its checksum, write to cache, and return StoryFormatInfo. */
1257
+ async function fetchAndCacheFormat(entry, downloadUrl) {
1258
+ cachedFormatDir(entry.name, entry.version);
1259
+ const res = await fetch(downloadUrl);
1260
+ if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
1261
+ const text = await res.text();
1262
+ const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
1263
+ if (checksumKey) {
1264
+ const expected = entry.checksums[checksumKey];
1265
+ if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
1266
+ if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
1267
+ }
1268
+ const id = makeFormatId(entry.name, entry.version);
1269
+ const data = parseFormatJSON(text, id);
1270
+ if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
1271
+ return {
1272
+ id,
1273
+ filename: writeCachedFormat(entry.name, entry.version, text),
1274
+ isTwine2: true,
1275
+ name: data.name,
1276
+ version: data.version,
1277
+ proofing: data.proofing ?? false
1278
+ };
1279
+ }
1280
+ /** Download a direct format.js URL, parse its JSON, cache it, and return StoryFormatInfo. */
1281
+ async function fetchDirectFormat(url) {
1282
+ const res = await fetch(url);
1283
+ if (!res.ok) throw new Error(`Failed to download format from ${url}: ${res.status} ${res.statusText}`);
1284
+ const text = await res.text();
1285
+ const data = parseFormatJSON(text);
1286
+ if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
1287
+ return {
1288
+ id: makeFormatId(data.name, data.version),
1289
+ filename: writeCachedFormat(data.name, data.version, text),
1290
+ isTwine2: true,
1291
+ name: data.name,
1292
+ version: data.version,
1293
+ proofing: data.proofing ?? false
1294
+ };
1295
+ }
1296
+ /**
1297
+ * Try to resolve a remote story format by name and version.
1298
+ * 0. Use an exactly matching cached download, without touching the network
1299
+ * 1. Try direct format URLs
1300
+ * 2. Try custom index URLs
1301
+ * 3. Try default SFA indices
1302
+ * 4. Fall back to a compatible cached download (e.g. when offline)
1303
+ */
1304
+ async function resolveRemoteFormat(name, version, indices, urls) {
1305
+ return resolveRemoteFormatRequest({
1306
+ kind: "name",
1307
+ name,
1308
+ version
1309
+ }, indices, urls);
1310
+ }
1311
+ /**
1312
+ * Resolve a story format request remotely, in the same order as {@link resolveRemoteFormat}.
1313
+ * An ID request such as 'sugarcube-2' matches the format whose name and major version build that ID
1314
+ * (SugarCube 2.x), taking the greatest version available.
1315
+ */
1316
+ async function resolveRemoteFormatRequest(request, indices, urls) {
1317
+ let lastError;
1318
+ const cached = findCachedFormat(request);
1319
+ if (cached && request.kind === "name" && isExactVersion(cached.version, request.version)) return cached;
1320
+ for (const url of urls ?? []) try {
1321
+ const info = await fetchDirectFormat(url);
1322
+ if (selectFormatCandidate(request, [info], (f) => f)) return info;
1323
+ } catch (e) {
1324
+ lastError = e instanceof Error ? e : new Error(String(e));
1325
+ }
1326
+ const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
1327
+ for (const indexUrl of allIndices) try {
1328
+ const result = findEntryForRequest(await fetchIndex(indexUrl), request);
1329
+ if (result) {
1330
+ const hit = getCachedFormat(result.entry.name, result.entry.version);
1331
+ if (hit) return hit;
1332
+ const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
1333
+ return await fetchAndCacheFormat(result.entry, downloadUrl);
1334
+ }
1335
+ } catch (e) {
1336
+ lastError = e instanceof Error ? e : new Error(String(e));
1337
+ }
1338
+ if (cached) return cached;
1339
+ if (lastError) throw lastError;
1340
+ }
1341
+ /** Whether two version strings name the same SemVer version. */
1342
+ function isExactVersion(have, wanted) {
1343
+ const a = parseSemver(have);
1344
+ const b = parseSemver(wanted);
1345
+ return a !== null && b !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
1346
+ }
1347
+ /**
1348
+ * Find the cached download that best answers a format request, without network access.
1349
+ * Matching follows {@link selectFormatCandidate}.
1350
+ */
1351
+ function findCachedFormat(request, options = {}) {
1352
+ return selectFormatCandidate(request, [...discoverCachedFormats().values()], (f) => f, options);
1353
+ }
1354
+ /** Check if a format is already in the local cache. */
1355
+ function getCachedFormat(name, version) {
1356
+ if (!isSafeSegment(name) || !isSafeSegment(version)) return void 0;
1357
+ const formatPath = join(getCacheDir(), name, version, "format.js");
1358
+ try {
1359
+ if (!existsSync(formatPath)) return void 0;
1360
+ const source = readFileSync(formatPath, "utf-8");
1361
+ const id = makeFormatId(name, version);
1362
+ const data = parseFormatJSON(source, id);
1363
+ if (!data) return void 0;
1364
+ return {
1365
+ id,
1366
+ filename: formatPath,
1367
+ isTwine2: true,
1368
+ name: data.name,
1369
+ version: data.version,
1370
+ proofing: data.proofing ?? false
1371
+ };
1372
+ } catch {
1373
+ return;
1374
+ }
1375
+ }
1376
+ /** Discover all cached remote formats. Returns a Map like discoverFormats(). */
1377
+ function discoverCachedFormats() {
1378
+ const formats = /* @__PURE__ */ new Map();
1379
+ const cacheDir = getCacheDir();
1380
+ try {
1381
+ if (!existsSync(cacheDir)) return formats;
1382
+ } catch {
1383
+ return formats;
1384
+ }
1385
+ let names;
1386
+ try {
1387
+ names = readdirSync(cacheDir);
1388
+ } catch {
1389
+ return formats;
1390
+ }
1391
+ for (const name of names) {
1392
+ const nameDir = join(cacheDir, name);
1393
+ try {
1394
+ if (!statSync(nameDir).isDirectory()) continue;
1395
+ } catch {
1396
+ continue;
1397
+ }
1398
+ let versions;
1399
+ try {
1400
+ versions = readdirSync(nameDir);
1401
+ } catch {
1402
+ continue;
1403
+ }
1404
+ for (const version of versions) {
1405
+ const info = getCachedFormat(name, version);
1406
+ if (info) formats.set(`${info.id}-${version}`, info);
1407
+ }
1408
+ }
1409
+ return formats;
1410
+ }
1411
+ /** List all cached formats with size and modification date. */
1412
+ function listCachedFormats() {
1413
+ const cacheDir = getCacheDir();
1414
+ const entries = [];
1415
+ try {
1416
+ if (!existsSync(cacheDir)) return entries;
1417
+ } catch {
1418
+ return entries;
1419
+ }
1420
+ let names;
1421
+ try {
1422
+ names = readdirSync(cacheDir);
1423
+ } catch {
1424
+ return entries;
1425
+ }
1426
+ for (const name of names) {
1427
+ const nameDir = join(cacheDir, name);
1428
+ try {
1429
+ if (!statSync(nameDir).isDirectory()) continue;
1430
+ } catch {
1431
+ continue;
1432
+ }
1433
+ let versions;
1434
+ try {
1435
+ versions = readdirSync(nameDir);
1436
+ } catch {
1437
+ continue;
1438
+ }
1439
+ for (const version of versions) {
1440
+ const formatPath = join(nameDir, version, "format.js");
1441
+ try {
1442
+ const stat = statSync(formatPath);
1443
+ entries.push({
1444
+ name,
1445
+ version,
1446
+ sizeBytes: stat.size,
1447
+ modifiedAt: stat.mtime
1448
+ });
1449
+ } catch {}
1450
+ }
1451
+ }
1452
+ return entries;
1453
+ }
1454
+ /**
1455
+ * Clear all cached formats, or only those matching a given name. Returns the number of entries removed.
1456
+ * A name must be a single cache entry name (as `listCachedFormats()` reports it); anything with a
1457
+ * path separator or `.`/`..` throws instead of deleting outside the format's own directory.
1458
+ */
1459
+ function clearCachedFormats(name) {
1460
+ 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.`);
1461
+ const cacheDir = getCacheDir();
1462
+ if (!existsSync(cacheDir)) return 0;
1463
+ if (!name) {
1464
+ const count = listCachedFormats().length;
1465
+ rmSync(cacheDir, {
1466
+ recursive: true,
1467
+ force: true
1468
+ });
1469
+ return count;
1470
+ }
1471
+ const root = resolve(cacheDir);
1472
+ const nameDir = resolve(root, name);
1473
+ if (!isInside(root, nameDir)) throw new Error(`Refusing to clear ${JSON.stringify(name)}: it is not a cached format name.`);
1474
+ if (!lstatSync(nameDir, { throwIfNoEntry: false })?.isDirectory()) return 0;
1475
+ let versions;
1476
+ try {
1477
+ versions = readdirSync(nameDir);
1478
+ } catch {
1479
+ return 0;
1480
+ }
1481
+ const count = versions.length;
1482
+ rmSync(nameDir, {
1483
+ recursive: true,
1484
+ force: true
1485
+ });
1486
+ return count;
1487
+ }
1488
+ /** Get total cache size in bytes and format count. */
1489
+ function getCacheSize() {
1490
+ const entries = listCachedFormats();
1491
+ return {
1492
+ totalBytes: entries.reduce((sum, e) => sum + e.sizeBytes, 0),
1493
+ count: entries.length
1494
+ };
1495
+ }
1496
+ //#endregion
1497
+ //#region src/format-resolution.ts
1498
+ /** Choose the format request: explicit format ID > StoryData format > default ID. */
1499
+ function formatRequestFor(formatId, storyFormat, storyFormatVersion, defaultId) {
1500
+ if (formatId) return {
1501
+ kind: "id",
1502
+ id: formatId
1503
+ };
1504
+ if (storyFormat) return {
1505
+ kind: "name",
1506
+ name: storyFormat,
1507
+ version: storyFormatVersion
1508
+ };
1509
+ return {
1510
+ kind: "id",
1511
+ id: defaultId
1512
+ };
1513
+ }
1514
+ /** Look a request up among the formats found in local format directories. */
1515
+ function findLocalFormat(formats, request) {
1516
+ switch (request.kind) {
1517
+ case "id": return formats.get(request.id);
1518
+ case "name": {
1519
+ const id = getFormatIdByNameAndVersion(formats, request.name, request.version);
1520
+ return id === void 0 ? void 0 : formats.get(id);
1521
+ }
1522
+ default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
1523
+ }
1524
+ }
1525
+ /**
1526
+ * Resolve a format request to a story format.
1527
+ *
1528
+ * Order: local format directories, then the download cache, then remote sources (unless
1529
+ * `noRemote`). When a StoryData request names a version that none of them has, an older version of
1530
+ * the same format and major version is used with a warning. Anything else is an error diagnostic,
1531
+ * and the result is `undefined`.
1532
+ */
1533
+ async function resolveStoryFormat(request, options, diagnostics) {
1534
+ const noRemote = options.noRemote ?? false;
1535
+ const formats = discoverFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true));
1536
+ const wanted = describeFormatRequest(request);
1537
+ const local = findLocalFormat(formats, request);
1538
+ if (local) return local;
1539
+ const cached = findCachedFormat(request);
1540
+ if (cached) return cached;
1541
+ if (!noRemote) try {
1542
+ const remote = await resolveRemoteFormatRequest(request, options.formatIndices, options.formatUrls);
1543
+ if (remote) return remote;
1544
+ } catch (e) {
1545
+ diagnostics.push({
1546
+ level: "warning",
1547
+ message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
1548
+ });
1549
+ }
1550
+ if (request.kind === "name") {
1551
+ const older = selectFormatCandidate(request, [...formats.values()].filter((f) => f.isTwine2), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
1552
+ if (older) {
1553
+ diagnostics.push({
1554
+ level: "warning",
1555
+ message: `Story format ${wanted} is not available; using ${older.name} ${older.version} instead.`
1556
+ });
1557
+ return older;
1558
+ }
1559
+ }
1560
+ const reason = noRemote ? " (remote fetching disabled)" : "";
1561
+ diagnostics.push({
1562
+ level: "error",
1563
+ message: `Story format ${wanted} is not available${reason}. Found: ${[...formats.keys()].join(", ") || "none"}`
1564
+ });
1565
+ }
1566
+ //#endregion
1567
+ //#region src/types.ts
1568
+ const ItemType = {
1569
+ Error: 0,
1570
+ EOF: 1,
1571
+ Header: 2,
1572
+ Name: 3,
1573
+ Tags: 4,
1574
+ Metadata: 5,
1575
+ Content: 6
1576
+ };
1577
+ //#endregion
1578
+ //#region src/lexer.ts
1579
+ const EOF = -1;
1580
+ const HEADER_DELIM = "::";
1581
+ const NEWLINE_HEADER_DELIM = "\n::";
1582
+ var LexerContext = class {
1583
+ input;
1584
+ line = 1;
1585
+ start = 0;
1586
+ pos = 0;
1587
+ items = [];
1588
+ constructor(input) {
1589
+ this.input = input;
1590
+ }
1591
+ next() {
1592
+ if (this.pos >= this.input.length) return EOF;
1593
+ const ch = this.input.charCodeAt(this.pos);
1594
+ this.pos++;
1595
+ if (ch === 10) this.line++;
1596
+ return ch;
1597
+ }
1598
+ peek() {
1599
+ if (this.pos >= this.input.length) return EOF;
1600
+ return this.input.charCodeAt(this.pos);
1601
+ }
1602
+ backup() {
1603
+ if (this.pos > this.start) {
1604
+ this.pos--;
1605
+ if (this.input.charCodeAt(this.pos) === 10) this.line--;
1606
+ } else throw new Error("backup would leave pos < start");
1607
+ }
1608
+ emit(type) {
1609
+ const val = this.input.slice(this.start, this.pos);
1610
+ this.items.push({
1611
+ type,
1612
+ line: this.line,
1613
+ pos: this.start,
1614
+ val
1615
+ });
1616
+ if (type === ItemType.Content) {
1617
+ for (let i = this.start; i < this.pos; i++) if (this.input.charCodeAt(i) === 10) this.line++;
1618
+ }
1619
+ this.start = this.pos;
1620
+ }
1621
+ ignore() {
1622
+ for (let i = this.start; i < this.pos; i++) if (this.input.charCodeAt(i) === 10) this.line++;
1623
+ this.start = this.pos;
1624
+ }
1625
+ acceptRun(valid) {
1626
+ let ch;
1627
+ for (ch = this.next(); ch !== EOF && valid.includes(String.fromCharCode(ch)); ch = this.next());
1628
+ if (ch !== EOF) this.backup();
1629
+ }
1630
+ errorf(message) {
1631
+ this.items.push({
1632
+ type: ItemType.Error,
1633
+ line: this.line,
1634
+ pos: this.start,
1635
+ val: message
1636
+ });
1637
+ return null;
1638
+ }
1639
+ /** Drain all pending items and return them. */
1640
+ drainItems() {
1641
+ const result = this.items;
1642
+ this.items = [];
1643
+ return result;
1644
+ }
1645
+ };
1646
+ function acceptQuoted(ctx, quote) {
1647
+ for (;;) {
1648
+ const ch = ctx.next();
1649
+ switch (ch) {
1650
+ case 92: {
1651
+ const r = ctx.next();
1652
+ if (r !== 10 && r !== EOF) break;
1653
+ }
1654
+ case 10:
1655
+ case EOF: return "unterminated quoted string";
1656
+ default: if (ch === quote) return null;
1657
+ }
1658
+ }
1659
+ }
1660
+ function lexProlog(ctx) {
1661
+ if (ctx.input.startsWith(HEADER_DELIM, ctx.pos)) return lexHeaderDelim;
1662
+ const i = ctx.input.indexOf(NEWLINE_HEADER_DELIM, ctx.pos);
1663
+ if (i > -1) {
1664
+ ctx.pos = i + 1;
1665
+ ctx.ignore();
1666
+ return lexHeaderDelim;
1667
+ }
1668
+ ctx.emit(ItemType.EOF);
1669
+ return null;
1670
+ }
1671
+ function lexContent(ctx) {
1672
+ if (ctx.input.startsWith(HEADER_DELIM, ctx.pos)) return lexHeaderDelim;
1673
+ const i = ctx.input.indexOf(NEWLINE_HEADER_DELIM, ctx.pos);
1674
+ if (i > -1) {
1675
+ ctx.pos = i + 1;
1676
+ ctx.emit(ItemType.Content);
1677
+ return lexHeaderDelim;
1678
+ }
1679
+ ctx.pos = ctx.input.length;
1680
+ if (ctx.pos > ctx.start) ctx.emit(ItemType.Content);
1681
+ ctx.emit(ItemType.EOF);
1682
+ return null;
1683
+ }
1684
+ function lexHeaderDelim(ctx) {
1685
+ ctx.pos += 2;
1686
+ ctx.emit(ItemType.Header);
1687
+ return lexName;
1688
+ }
1689
+ function lexName(ctx) {
1690
+ let r;
1691
+ outer: for (;;) {
1692
+ r = ctx.next();
1693
+ switch (r) {
1694
+ case 92: {
1695
+ const next = ctx.next();
1696
+ if (next !== 10 && next !== EOF) break;
1697
+ r = next;
1698
+ }
1699
+ case 91:
1700
+ case 93:
1701
+ case 123:
1702
+ case 125:
1703
+ case 10:
1704
+ case EOF:
1705
+ if (r !== EOF) ctx.backup();
1706
+ break outer;
1707
+ }
1708
+ }
1709
+ ctx.emit(ItemType.Name);
1710
+ switch (r) {
1711
+ case 91: return lexTags;
1712
+ case 93: return ctx.errorf(`unexpected right square bracket ']'`);
1713
+ case 123: return lexMetadata;
1714
+ case 125: return ctx.errorf(`unexpected right curly brace '}'`);
1715
+ case 10:
1716
+ ctx.pos++;
1717
+ ctx.ignore();
1718
+ return lexContent;
1719
+ }
1720
+ ctx.emit(ItemType.EOF);
1721
+ return null;
1722
+ }
1723
+ function lexNextOptionalBlock(ctx) {
1724
+ ctx.acceptRun(" ");
1725
+ ctx.ignore();
1726
+ const r = ctx.peek();
1727
+ switch (r) {
1728
+ case 91: return lexTags;
1729
+ case 93: return ctx.errorf(`unexpected right square bracket ']'`);
1730
+ case 123: return lexMetadata;
1731
+ case 125: return ctx.errorf(`unexpected right curly brace '}'`);
1159
1732
  case 10:
1160
1733
  ctx.pos++;
1161
1734
  ctx.ignore();
@@ -1280,16 +1853,20 @@ function twee2ToV3(s) {
1280
1853
  //#region src/parser.ts
1281
1854
  /**
1282
1855
  * Parse Twee source text into passages.
1856
+ *
1857
+ * The source is normalized first, as files are when they are read: a leading UTF-8 BOM is
1858
+ * removed and CRLF and bare CR line endings become LF.
1283
1859
  */
1284
1860
  function parseTwee(source, options = {}) {
1285
1861
  const { filename = "<inline>", trim = true, twee2Compat = false } = options;
1286
1862
  const diagnostics = [];
1287
- if (twee2Compat) source = twee2ToV3(source);
1863
+ const normalized = normalizeSourceText(source);
1864
+ const tweeSource = twee2Compat ? twee2ToV3(normalized) : normalized;
1288
1865
  const passages = [];
1289
1866
  let current = null;
1290
1867
  let pCount = 0;
1291
1868
  let lastType = ItemType.EOF;
1292
- for (const item of tweeLexer(source)) {
1869
+ for (const item of tweeLexer(tweeSource)) {
1293
1870
  switch (item.type) {
1294
1871
  case ItemType.Error:
1295
1872
  diagnostics.push({
@@ -4119,14 +4696,16 @@ function parseDocument(data, options) {
4119
4696
  */
4120
4697
  /**
4121
4698
  * Parse a Twine 2 or Twine 1 compiled HTML file back into a Story model.
4699
+ * Tweego always trims passage text; here `trim: false` keeps it exactly, as for Twee sources.
4122
4700
  */
4123
- function decompileHTML(html) {
4701
+ function decompileHTML(html, options = {}) {
4124
4702
  const diagnostics = [];
4125
4703
  const story = createStory();
4704
+ const passageText = options.trim === false ? (text) => text : (text) => text.trim();
4126
4705
  const doc = parseDocument(html);
4127
4706
  const twine2Data = findElement(doc, "tw-storydata");
4128
4707
  if (twine2Data) {
4129
- decompileTwine2(twine2Data, story, diagnostics);
4708
+ decompileTwine2(twine2Data, story, passageText, diagnostics);
4130
4709
  return {
4131
4710
  story,
4132
4711
  diagnostics
@@ -4134,7 +4713,7 @@ function decompileHTML(html) {
4134
4713
  }
4135
4714
  const twine1Data = findElementByIdPattern(doc, /^store(?:-a|A)rea$/);
4136
4715
  if (twine1Data) {
4137
- decompileTwine1(twine1Data, story, diagnostics);
4716
+ decompileTwine1(twine1Data, story, passageText, diagnostics);
4138
4717
  return {
4139
4718
  story,
4140
4719
  diagnostics
@@ -4149,7 +4728,7 @@ function decompileHTML(html) {
4149
4728
  diagnostics
4150
4729
  };
4151
4730
  }
4152
- function decompileTwine2(storyData, story, diagnostics) {
4731
+ function decompileTwine2(storyData, story, passageText, diagnostics) {
4153
4732
  let startnode = 0;
4154
4733
  const attrs = storyData.attribs ?? {};
4155
4734
  if (attrs["name"]) story.name = attrs["name"];
@@ -4170,6 +4749,7 @@ function decompileTwine2(storyData, story, diagnostics) {
4170
4749
  });
4171
4750
  else story.twine2.zoom = parsed;
4172
4751
  }
4752
+ if (attrs["tags"]) story.twine2.tags = attrs["tags"];
4173
4753
  if (attrs["format"]) story.twine2.format = attrs["format"];
4174
4754
  if (attrs["format-version"]) story.twine2.formatVersion = attrs["format-version"];
4175
4755
  if (attrs["options"]) for (const opt of attrs["options"].split(/\s+/).filter((s) => s.length > 0)) story.twine2.options.set(opt, true);
@@ -4178,8 +4758,9 @@ function decompileTwine2(storyData, story, diagnostics) {
4178
4758
  switch (node.name) {
4179
4759
  case "style":
4180
4760
  case "script": {
4181
- const text = getTextContent(node).trim();
4182
- if (text.length === 0) continue;
4761
+ const content = getTextContent(node);
4762
+ if (content.trim().length === 0) continue;
4763
+ const text = passageText(content);
4183
4764
  storyAdd(story, {
4184
4765
  name: node.name === "style" ? "Story Stylesheet" : "Story JavaScript",
4185
4766
  tags: node.name === "style" ? ["stylesheet"] : ["script"],
@@ -4214,7 +4795,7 @@ function decompileTwine2(storyData, story, diagnostics) {
4214
4795
  const passage = {
4215
4796
  name,
4216
4797
  tags,
4217
- text: getTextContent(node).trim()
4798
+ text: passageText(getTextContent(node))
4218
4799
  };
4219
4800
  if (metadata.position || metadata.size) passage.metadata = metadata;
4220
4801
  storyAdd(story, passage, diagnostics);
@@ -4228,23 +4809,33 @@ function decompileTwine2(storyData, story, diagnostics) {
4228
4809
  text: marshalStoryData(story)
4229
4810
  }, diagnostics);
4230
4811
  }
4231
- function decompileTwine1(storeArea, story, diagnostics) {
4232
- for (const node of storeArea.children ?? []) {
4233
- if (node.type !== "tag" || node.name !== "div") continue;
4234
- const nodeAttrs = node.attribs ?? {};
4235
- if (!("tiddler" in nodeAttrs)) continue;
4236
- const name = nodeAttrs["tiddler"] ?? "";
4237
- const tags = nodeAttrs["tags"] ? nodeAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [];
4238
- const metadata = {};
4239
- if (nodeAttrs["twine-position"]) metadata.position = nodeAttrs["twine-position"];
4240
- const passage = {
4241
- name,
4242
- tags,
4243
- text: tiddlerUnescape(getTextContent(node)).trim()
4244
- };
4245
- if (metadata.position) passage.metadata = metadata;
4246
- storyAdd(story, passage, diagnostics);
4247
- }
4812
+ function decompileTwine1(storeArea, story, passageText, diagnostics) {
4813
+ const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
4814
+ const obfuscated = isRot13Obfuscated(passages);
4815
+ for (const p of passages) storyAdd(story, obfuscated && p.name !== "StorySettings" ? {
4816
+ ...p,
4817
+ text: rot13(p.text)
4818
+ } : p, diagnostics);
4819
+ }
4820
+ function isTiddler(node) {
4821
+ return node.type === "tag" && node.name === "div" && node.attribs !== void 0 && "tiddler" in node.attribs;
4822
+ }
4823
+ function tiddlerToPassage(node, passageText) {
4824
+ const nodeAttrs = node.attribs ?? {};
4825
+ const passage = {
4826
+ name: nodeAttrs["tiddler"] ?? "",
4827
+ tags: nodeAttrs["tags"] ? nodeAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [],
4828
+ text: passageText(tiddlerUnescape(getTextContent(node)))
4829
+ };
4830
+ const position = nodeAttrs["twine-position"];
4831
+ if (position) passage.metadata = { position };
4832
+ return passage;
4833
+ }
4834
+ /** Whether the StorySettings tiddlers turn on ROT13 obfuscation, read the way the writer reads them. */
4835
+ function isRot13Obfuscated(passages) {
4836
+ const settings = createStory();
4837
+ for (const p of passages) if (p.name === "StorySettings") unmarshalStorySettings(settings, p.text, []);
4838
+ return settings.twine1.settings.get("obfuscate") === "rot13";
4248
4839
  }
4249
4840
  function findElement(node, tagName) {
4250
4841
  if (node.type === "tag" && node.name === tagName) return node;
@@ -4299,7 +4890,7 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
4299
4890
  break;
4300
4891
  case "htm":
4301
4892
  case "html":
4302
- loadHTML(story, filename, diagnostics);
4893
+ loadHTML(story, filename, opts, diagnostics);
4303
4894
  break;
4304
4895
  case "css":
4305
4896
  loadTagged(story, "stylesheet", filename, diagnostics);
@@ -4367,7 +4958,7 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
4367
4958
  function loadInlineSources(story, sources, opts, diagnostics) {
4368
4959
  for (const source of sources) {
4369
4960
  if (typeof source === "string") continue;
4370
- const content = typeof source.content === "string" ? source.content : source.content.toString("utf-8");
4961
+ const content = normalizeSourceText(typeof source.content === "string" ? source.content : source.content.toString("utf-8"));
4371
4962
  const ext = normalizedFileExt(source.filename);
4372
4963
  switch (ext) {
4373
4964
  case "":
@@ -4453,13 +5044,33 @@ function parseFontFile(filename) {
4453
5044
  diagnostics: []
4454
5045
  };
4455
5046
  }
4456
- function parseHTMLFile(filename) {
4457
- const result = decompileHTML(readUTF8(filename));
5047
+ function parseHTMLFile(filename, opts) {
5048
+ const { story, diagnostics } = decompileHTML(readUTF8(filename), { trim: opts.trim ?? true });
4458
5049
  return {
4459
- passages: result.story.passages,
4460
- diagnostics: [...result.diagnostics]
5050
+ passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
5051
+ name: "StoryTitle",
5052
+ tags: [],
5053
+ text: story.name
5054
+ }, ...story.passages] : story.passages,
5055
+ diagnostics: [...diagnostics]
4461
5056
  };
4462
5057
  }
5058
+ /**
5059
+ * Identity of the options that affect how a file parses, stored on its cache entry.
5060
+ * Twee files depend on `trim` and `twee2Compat`, HTML files on `trim`; every other file type
5061
+ * yields the same key for all options.
5062
+ */
5063
+ function parseOptionsKey(filename, opts) {
5064
+ const ext = normalizedFileExt(filename);
5065
+ const trim = opts.trim ?? true;
5066
+ if (ext === "htm" || ext === "html") return JSON.stringify({ trim });
5067
+ const twee2File = ext === "tw2" || ext === "twee2";
5068
+ if (!twee2File && ext !== "tw" && ext !== "twee") return "";
5069
+ return JSON.stringify({
5070
+ trim,
5071
+ twee2Compat: twee2File || (opts.twee2Compat ?? false)
5072
+ });
5073
+ }
4463
5074
  function parseFile(filename, opts) {
4464
5075
  switch (normalizedFileExt(filename)) {
4465
5076
  case "tw":
@@ -4470,7 +5081,7 @@ function parseFile(filename, opts) {
4470
5081
  twee2Compat: true
4471
5082
  });
4472
5083
  case "htm":
4473
- case "html": return parseHTMLFile(filename);
5084
+ case "html": return parseHTMLFile(filename, opts);
4474
5085
  case "css": return parseTaggedFile("stylesheet", filename);
4475
5086
  case "js": return parseTaggedFile("script", filename);
4476
5087
  case "otf":
@@ -4502,8 +5113,8 @@ function parseFile(filename, opts) {
4502
5113
  default: return;
4503
5114
  }
4504
5115
  }
4505
- function loadHTML(story, filename, diagnostics) {
4506
- const result = parseHTMLFile(filename);
5116
+ function loadHTML(story, filename, opts, diagnostics) {
5117
+ const result = parseHTMLFile(filename, opts);
4507
5118
  diagnostics.push(...result.diagnostics);
4508
5119
  for (const p of result.passages) storyAdd(story, p, diagnostics);
4509
5120
  }
@@ -4539,8 +5150,11 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
4539
5150
  continue;
4540
5151
  }
4541
5152
  currentFiles.add(filename);
4542
- const cached = cache.get(filename);
4543
- if (changedFiles && cached && !changedFiles.has(filename)) {
5153
+ const optionsKey = parseOptionsKey(filename, opts);
5154
+ const entry = cache.get(filename);
5155
+ const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
5156
+ const changed = changedFiles?.has(filename) ?? false;
5157
+ if (changedFiles && cached && !changed) {
4544
5158
  diagnostics.push(...cached.diagnostics);
4545
5159
  for (const p of cached.passages) storyAdd(story, p, diagnostics);
4546
5160
  processedFiles.add(filename);
@@ -4552,7 +5166,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
4552
5166
  } catch {
4553
5167
  continue;
4554
5168
  }
4555
- if (cached && cached.mtimeMs === mtimeMs) {
5169
+ if (cached && !changed && cached.mtimeMs === mtimeMs) {
4556
5170
  diagnostics.push(...cached.diagnostics);
4557
5171
  for (const p of cached.passages) storyAdd(story, p, diagnostics);
4558
5172
  processedFiles.add(filename);
@@ -4563,6 +5177,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
4563
5177
  if (!result) continue;
4564
5178
  cache.set(filename, {
4565
5179
  mtimeMs,
5180
+ parseOptionsKey: optionsKey,
4566
5181
  passages: result.passages,
4567
5182
  diagnostics: result.diagnostics
4568
5183
  });
@@ -4626,11 +5241,39 @@ function toTwine2HTML(story, format, startName, options) {
4626
5241
  template = template.replaceAll("{{STORY_NAME}}", () => name);
4627
5242
  }
4628
5243
  if (template.includes("{{STORY_DATA}}")) {
4629
- const data = getTwine2DataChunk(story, startName, options);
5244
+ const data = getTwine2DataChunk({
5245
+ ...story,
5246
+ twine2: {
5247
+ ...story.twine2,
5248
+ format: format.name,
5249
+ formatVersion: format.version
5250
+ }
5251
+ }, startName, options);
4630
5252
  template = template.replace("{{STORY_DATA}}", () => data);
4631
5253
  }
4632
5254
  return template;
4633
5255
  }
5256
+ const OMITTING_TAGS = [
5257
+ "Twine.private",
5258
+ "script",
5259
+ "stylesheet"
5260
+ ];
5261
+ /**
5262
+ * Why Twine 2 output leaves a passage out of its `<tw-passagedata>` elements,
5263
+ * or `undefined` when the passage is emitted (and so can be the starting passage).
5264
+ */
5265
+ function twine2PassageOmission(story, p) {
5266
+ if (p.name === "StoryTitle" || p.name === "StoryData") return {
5267
+ kind: "special-name",
5268
+ name: p.name
5269
+ };
5270
+ const tag = OMITTING_TAGS.find((t) => hasTag(p, t));
5271
+ if (tag !== void 0) return {
5272
+ kind: "tag",
5273
+ tag
5274
+ };
5275
+ if (p.name === "StorySettings" && story.twine1.settings.size === 0) return { kind: "empty-story-settings" };
5276
+ }
4634
5277
  function getTwine2DataChunk(story, startName, options) {
4635
5278
  const parts = [];
4636
5279
  let startID = "";
@@ -4653,7 +5296,7 @@ function getTwine2DataChunk(story, startName, options) {
4653
5296
  pidS++;
4654
5297
  }
4655
5298
  }
4656
- parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContent}</style>`);
5299
+ parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContentEscape(styleContent)}</style>`);
4657
5300
  let scriptContent = "";
4658
5301
  if (scripts.length === 1) scriptContent = scripts[0]?.text ?? "";
4659
5302
  else if (scripts.length > 1) {
@@ -4665,7 +5308,7 @@ function getTwine2DataChunk(story, startName, options) {
4665
5308
  pidS++;
4666
5309
  }
4667
5310
  }
4668
- parts.push(`<script role="script" id="twine-user-script" type="text/twine-javascript">${scriptContent}<\/script>`);
5311
+ parts.push(`<script role="script" id="twine-user-script" type="text/twine-javascript">${scriptContentEscape(scriptContent)}<\/script>`);
4669
5312
  const validTagColors = /* @__PURE__ */ new Set([
4670
5313
  "gray",
4671
5314
  "red",
@@ -4679,8 +5322,7 @@ function getTwine2DataChunk(story, startName, options) {
4679
5322
  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>`);
4680
5323
  pid = 1;
4681
5324
  for (const p of story.passages) {
4682
- if (p.name === "StoryTitle" || p.name === "StoryData" || hasAnyTag(p, "script", "stylesheet", "Twine.private")) continue;
4683
- if (p.name === "StorySettings" && story.twine1.settings.size === 0) continue;
5325
+ if (twine2PassageOmission(story, p) !== void 0) continue;
4684
5326
  parts.push(passageToPassagedata(p, pid, options));
4685
5327
  if (startName === p.name) startID = String(pid);
4686
5328
  pid++;
@@ -4736,12 +5378,21 @@ function toTwine1HTML(story, format, startName) {
4736
5378
  }
4737
5379
  return template;
4738
5380
  }
5381
+ /**
5382
+ * Why Twine 1 output leaves a passage out of its tiddlers, or `undefined` when the passage is emitted.
5383
+ */
5384
+ function twine1PassageOmission(p) {
5385
+ return hasTag(p, "Twine.private") ? {
5386
+ kind: "tag",
5387
+ tag: "Twine.private"
5388
+ } : void 0;
5389
+ }
4739
5390
  function getTwine1PassageChunk(story) {
4740
5391
  const obfuscateRot13 = story.twine1.settings.get("obfuscate") === "rot13";
4741
5392
  let data = "";
4742
5393
  let count = 0;
4743
5394
  for (const p of story.passages) {
4744
- if (hasTag(p, "Twine.private")) continue;
5395
+ if (twine1PassageOmission(p) !== void 0) continue;
4745
5396
  count++;
4746
5397
  data += passageToTiddler(p, count, obfuscateRot13);
4747
5398
  }
@@ -4763,10 +5414,39 @@ function tryReplaceComponent(template, placeholder, componentPath, required) {
4763
5414
  }
4764
5415
  //#endregion
4765
5416
  //#region src/output-twee.ts
4766
- function toTwee(story, outMode) {
4767
- let data = "";
4768
- for (const p of story.passages) data += passageToTwee(p, outMode);
4769
- return data;
5417
+ /**
5418
+ * Serialize the story as Twee. The StoryData passage is written from the story model rather than
5419
+ * from its loaded text, so changes made after loading (a start passage override, test mode) are kept.
5420
+ * Tweego writes the loaded text; its own normalization makes the two the same for an unchanged story.
5421
+ *
5422
+ * `addStoryData`: add a StoryData passage when the story has none, so that StoryData fields the
5423
+ * compile options set are recorded. Default: false.
5424
+ */
5425
+ function toTwee(story, outMode, options = {}) {
5426
+ return withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => passageToTwee(p, outMode)).join("");
5427
+ }
5428
+ function withEffectiveStoryData(story, addStoryData) {
5429
+ const storyData = {
5430
+ name: "StoryData",
5431
+ tags: [],
5432
+ text: marshalStoryData(story)
5433
+ };
5434
+ const index = story.passages.findIndex((p) => p.name === "StoryData");
5435
+ if (index !== -1) return story.passages.map((p, i) => i === index && isStoryDataJSON(p.text) ? {
5436
+ ...p,
5437
+ text: storyData.text
5438
+ } : p);
5439
+ if (!addStoryData) return story.passages;
5440
+ const at = story.passages[0]?.name === "StoryTitle" ? 1 : 0;
5441
+ return [
5442
+ ...story.passages.slice(0, at),
5443
+ storyData,
5444
+ ...story.passages.slice(at)
5445
+ ];
5446
+ }
5447
+ /** Whether the text loaded as StoryData. Text that did not is kept as written, for the author to fix. */
5448
+ function isStoryDataJSON(text) {
5449
+ return unmarshalStoryData(createStory(), text) === null;
4770
5450
  }
4771
5451
  //#endregion
4772
5452
  //#region src/modules.ts
@@ -4793,357 +5473,90 @@ function loadModules(filenames) {
4793
5473
  case "woff2":
4794
5474
  tag = loadModuleFont(filename);
4795
5475
  break;
4796
- default: continue;
4797
- }
4798
- if (tag) headTags.push(tag);
4799
- processed.add(filename);
4800
- }
4801
- return headTags.join("\n");
4802
- }
4803
- function loadModuleTagged(tag, filename) {
4804
- const source = readUTF8(filename).trim();
4805
- if (source.length === 0) return null;
4806
- return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${source}</${tag}>`;
4807
- }
4808
- function loadModuleFont(filename) {
4809
- const source = readBase64(filename);
4810
- const family = baseNameWithoutExt(filename);
4811
- const idSlug = `style-module-${slugify(family)}`;
4812
- const ext = normalizedFileExt(filename);
4813
- 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>`;
4814
- }
4815
- /**
4816
- * Inject modules and head file content before </head>.
4817
- */
4818
- function modifyHead(html, modulePaths, headFile, diagnostics) {
4819
- const parts = [];
4820
- if (modulePaths.length > 0) {
4821
- const modules = loadModules(modulePaths).trim();
4822
- if (modules.length > 0) parts.push(modules);
4823
- }
4824
- if (headFile) try {
4825
- const source = readUTF8(headFile).trim();
4826
- if (source.length > 0) parts.push(source);
4827
- } catch (e) {
4828
- diagnostics?.push({
4829
- level: "warning",
4830
- message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
4831
- });
4832
- }
4833
- if (parts.length > 0) {
4834
- parts.push("</head>");
4835
- const replacement = parts.join("\n");
4836
- return html.replace("</head>", () => replacement);
4837
- }
4838
- return html;
4839
- }
4840
- //#endregion
4841
- //#region src/remote-formats.ts
4842
- /**
4843
- * Remote story format fetching, caching, and checksum verification.
4844
- * Uses the Story Formats Archive (SFA) as the default source.
4845
- */
4846
- const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
4847
- /** Get the cache directory for downloaded story formats. */
4848
- function getCacheDir() {
4849
- const base = process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache");
4850
- return join(base, "twee-ts", "storyformats");
4851
- }
4852
- /** In-memory index cache, keyed by URL. Cleared each compile. */
4853
- const indexCache = /* @__PURE__ */ new Map();
4854
- /** Clear the in-memory index cache. */
4855
- function clearIndexCache() {
4856
- indexCache.clear();
4857
- }
4858
- /** Validate that a JSON value is a valid SFAIndexEntry. */
4859
- function isValidEntry(val) {
4860
- if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
4861
- const obj = val;
4862
- return typeof obj.name === "string" && typeof obj.version === "string" && typeof obj.checksums === "object" && obj.checksums !== null && !Array.isArray(obj.checksums);
4863
- }
4864
- /** Validate that a JSON value conforms to the SFAIndex shape. */
4865
- function validateSFAIndex(json) {
4866
- if (typeof json !== "object" || json === null) throw new Error("SFA index is not an object");
4867
- const obj = json;
4868
- return {
4869
- twine1: Array.isArray(obj["twine1"]) ? obj["twine1"].filter(isValidEntry) : [],
4870
- twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
4871
- };
4872
- }
4873
- /** Generate a format ID from name and version. */
4874
- function makeFormatId(name, version) {
4875
- const major = parseSemver(version)?.[0] ?? "0";
4876
- return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
4877
- }
4878
- /** Fetch and parse an SFA index.json, with in-memory caching. */
4879
- async function fetchIndex(url) {
4880
- const cached = indexCache.get(url);
4881
- if (cached) return cached;
4882
- const res = await fetch(url);
4883
- if (!res.ok) throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
4884
- const data = validateSFAIndex(await res.json());
4885
- indexCache.set(url, data);
4886
- return data;
4887
- }
4888
- /**
4889
- * Find the best matching entry in an SFA index.
4890
- * Exact version preferred, then highest version with same major.
4891
- */
4892
- function findEntry(index, name, version) {
4893
- const wanted = parseSemver(version);
4894
- const searchArrays = [{
4895
- entries: index.twine2 ?? [],
4896
- formatType: "twine2"
4897
- }, {
4898
- entries: index.twine1 ?? [],
4899
- formatType: "twine1"
4900
- }];
4901
- let bestResult;
4902
- let bestVersion = null;
4903
- for (const { entries, formatType } of searchArrays) for (const entry of entries) {
4904
- if (entry.name.toLowerCase() !== name.toLowerCase()) continue;
4905
- const have = parseSemver(entry.version);
4906
- if (!have) continue;
4907
- if (wanted && semverCompare(have, wanted) === 0) return {
4908
- entry,
4909
- formatType
4910
- };
4911
- if (wanted === null || have[0] === wanted[0] && semverCompare(have, wanted) >= 0) {
4912
- if (!bestVersion || semverCompare(have, bestVersion) > 0) {
4913
- bestVersion = have;
4914
- bestResult = {
4915
- entry,
4916
- formatType
4917
- };
4918
- }
4919
- }
4920
- }
4921
- return bestResult;
4922
- }
4923
- /** Verify SHA-256 checksum using Web Crypto API (Node 22 built-in). */
4924
- async function verifySHA256(content, expectedHex) {
4925
- const digest = await crypto.subtle.digest("SHA-256", content);
4926
- return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("") === expectedHex.toLowerCase();
4927
- }
4928
- /** Derive the download URL for a format.js from the index URL and entry. */
4929
- function getDownloadUrl(indexUrl, entry, formatType) {
4930
- return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
4931
- }
4932
- /** Download a format, verify its checksum, write to cache, and return StoryFormatInfo. */
4933
- async function fetchAndCacheFormat(entry, downloadUrl) {
4934
- const res = await fetch(downloadUrl);
4935
- if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
4936
- const text = await res.text();
4937
- const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
4938
- if (checksumKey) {
4939
- const expected = entry.checksums[checksumKey];
4940
- if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
4941
- if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
4942
- }
4943
- const id = makeFormatId(entry.name, entry.version);
4944
- const data = parseFormatJSON(text, id);
4945
- if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
4946
- const cacheDir = getCacheDir();
4947
- const formatDir = join(cacheDir, entry.name, entry.version);
4948
- mkdirSync(formatDir, { recursive: true });
4949
- const formatPath = join(formatDir, "format.js");
4950
- writeFileSync(formatPath, text, "utf-8");
4951
- return {
4952
- id,
4953
- filename: formatPath,
4954
- isTwine2: true,
4955
- name: data.name,
4956
- version: data.version,
4957
- proofing: data.proofing ?? false
4958
- };
4959
- }
4960
- /** Download a direct format.js URL, parse its JSON, cache it, and return StoryFormatInfo. */
4961
- async function fetchDirectFormat(url) {
4962
- const res = await fetch(url);
4963
- if (!res.ok) throw new Error(`Failed to download format from ${url}: ${res.status} ${res.statusText}`);
4964
- const text = await res.text();
4965
- const data = parseFormatJSON(text, "direct-url");
4966
- if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
4967
- const id = makeFormatId(data.name, data.version);
4968
- const cacheDir = getCacheDir();
4969
- const formatDir = join(cacheDir, data.name, data.version);
4970
- mkdirSync(formatDir, { recursive: true });
4971
- const formatPath = join(formatDir, "format.js");
4972
- writeFileSync(formatPath, text, "utf-8");
4973
- return {
4974
- id,
4975
- filename: formatPath,
4976
- isTwine2: true,
4977
- name: data.name,
4978
- version: data.version,
4979
- proofing: data.proofing ?? false
4980
- };
4981
- }
4982
- /**
4983
- * Try to resolve a remote story format by name and version.
4984
- * 1. Try direct format URLs
4985
- * 2. Try custom index URLs
4986
- * 3. Try default SFA indices
4987
- */
4988
- async function resolveRemoteFormat(name, version, indices, urls) {
4989
- let lastError;
4990
- if (urls) for (const url of urls) try {
4991
- const info = await fetchDirectFormat(url);
4992
- if (info.name.toLowerCase() === name.toLowerCase()) {
4993
- const wanted = parseSemver(version);
4994
- const have = parseSemver(info.version);
4995
- if (!wanted || have && have[0] === wanted[0] && semverCompare(have, wanted) >= 0) return info;
4996
- }
4997
- } catch (e) {
4998
- lastError = e instanceof Error ? e : new Error(String(e));
4999
- }
5000
- const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
5001
- for (const indexUrl of allIndices) try {
5002
- const result = findEntry(await fetchIndex(indexUrl), name, version);
5003
- if (result) {
5004
- const cached = getCachedFormat(result.entry.name, result.entry.version);
5005
- if (cached) return cached;
5006
- const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
5007
- return await fetchAndCacheFormat(result.entry, downloadUrl);
5008
- }
5009
- } catch (e) {
5010
- lastError = e instanceof Error ? e : new Error(String(e));
5011
- }
5012
- if (lastError) throw lastError;
5013
- }
5014
- /** Check if a format is already in the local cache. */
5015
- function getCachedFormat(name, version) {
5016
- const formatPath = join(getCacheDir(), name, version, "format.js");
5017
- try {
5018
- if (!existsSync(formatPath)) return void 0;
5019
- const source = readFileSync(formatPath, "utf-8");
5020
- const id = makeFormatId(name, version);
5021
- const data = parseFormatJSON(source, id);
5022
- if (!data) return void 0;
5023
- return {
5024
- id,
5025
- filename: formatPath,
5026
- isTwine2: true,
5027
- name: data.name,
5028
- version: data.version,
5029
- proofing: data.proofing ?? false
5030
- };
5031
- } catch {
5032
- return;
5033
- }
5034
- }
5035
- /** Discover all cached remote formats. Returns a Map like discoverFormats(). */
5036
- function discoverCachedFormats() {
5037
- const formats = /* @__PURE__ */ new Map();
5038
- const cacheDir = getCacheDir();
5039
- try {
5040
- if (!existsSync(cacheDir)) return formats;
5041
- } catch {
5042
- return formats;
5043
- }
5044
- let names;
5045
- try {
5046
- names = readdirSync(cacheDir);
5047
- } catch {
5048
- return formats;
5049
- }
5050
- for (const name of names) {
5051
- const nameDir = join(cacheDir, name);
5052
- try {
5053
- if (!statSync(nameDir).isDirectory()) continue;
5054
- } catch {
5055
- continue;
5056
- }
5057
- let versions;
5058
- try {
5059
- versions = readdirSync(nameDir);
5060
- } catch {
5061
- continue;
5062
- }
5063
- for (const version of versions) {
5064
- const info = getCachedFormat(name, version);
5065
- if (info) formats.set(`${info.id}-${version}`, info);
5476
+ default: continue;
5066
5477
  }
5478
+ if (tag) headTags.push(tag);
5479
+ processed.add(filename);
5067
5480
  }
5068
- return formats;
5481
+ return headTags.join("\n");
5069
5482
  }
5070
- /** List all cached formats with size and modification date. */
5071
- function listCachedFormats() {
5072
- const cacheDir = getCacheDir();
5073
- const entries = [];
5074
- try {
5075
- if (!existsSync(cacheDir)) return entries;
5076
- } catch {
5077
- return entries;
5483
+ function loadModuleTagged(tag, filename) {
5484
+ const source = readUTF8(filename).trim();
5485
+ if (source.length === 0) return null;
5486
+ return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
5487
+ }
5488
+ function loadModuleFont(filename) {
5489
+ const source = readBase64(filename);
5490
+ const family = baseNameWithoutExt(filename);
5491
+ const idSlug = `style-module-${slugify(family)}`;
5492
+ const ext = normalizedFileExt(filename);
5493
+ 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>`;
5494
+ }
5495
+ /**
5496
+ * Inject modules and head file content before </head>.
5497
+ */
5498
+ function modifyHead(html, modulePaths, headFile, diagnostics) {
5499
+ const parts = [];
5500
+ if (modulePaths.length > 0) {
5501
+ const modules = loadModules(modulePaths).trim();
5502
+ if (modules.length > 0) parts.push(modules);
5078
5503
  }
5079
- let names;
5080
- try {
5081
- names = readdirSync(cacheDir);
5082
- } catch {
5083
- return entries;
5504
+ if (headFile) try {
5505
+ const source = readUTF8(headFile).trim();
5506
+ if (source.length > 0) parts.push(source);
5507
+ } catch (e) {
5508
+ diagnostics?.push({
5509
+ level: "warning",
5510
+ message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
5511
+ });
5084
5512
  }
5085
- for (const name of names) {
5086
- const nameDir = join(cacheDir, name);
5087
- try {
5088
- if (!statSync(nameDir).isDirectory()) continue;
5089
- } catch {
5090
- continue;
5091
- }
5092
- let versions;
5093
- try {
5094
- versions = readdirSync(nameDir);
5095
- } catch {
5096
- continue;
5097
- }
5098
- for (const version of versions) {
5099
- const formatPath = join(nameDir, version, "format.js");
5100
- try {
5101
- const stat = statSync(formatPath);
5102
- entries.push({
5103
- name,
5104
- version,
5105
- sizeBytes: stat.size,
5106
- modifiedAt: stat.mtime
5107
- });
5108
- } catch {}
5109
- }
5513
+ if (parts.length > 0) {
5514
+ parts.push("</head>");
5515
+ const replacement = parts.join("\n");
5516
+ return html.replace("</head>", () => replacement);
5110
5517
  }
5111
- return entries;
5518
+ return html;
5112
5519
  }
5113
- /** Clear all cached formats, or only those matching a given name. Returns the number of entries removed. */
5114
- function clearCachedFormats(name) {
5115
- const cacheDir = getCacheDir();
5116
- if (!existsSync(cacheDir)) return 0;
5117
- if (!name) {
5118
- const count = listCachedFormats().length;
5119
- rmSync(cacheDir, {
5120
- recursive: true,
5121
- force: true
5122
- });
5123
- return count;
5520
+ //#endregion
5521
+ //#region src/passage-omission.ts
5522
+ /**
5523
+ * Why the `target` output leaves `passage` out of its passage elements, or `undefined` when it
5524
+ * emits the passage (and so a link or the start can name it).
5525
+ */
5526
+ function passageOmission(story, passage, target) {
5527
+ switch (target) {
5528
+ case "twine2": return twine2PassageOmission(story, passage);
5529
+ case "twine1": return twine1PassageOmission(passage);
5530
+ default: throw new Error(`unhandled passage output target: ${String(target)}`);
5124
5531
  }
5125
- const nameDir = join(cacheDir, name);
5126
- if (!existsSync(nameDir)) return 0;
5127
- let versions;
5128
- try {
5129
- versions = readdirSync(nameDir);
5130
- } catch {
5131
- return 0;
5532
+ }
5533
+ /** Why a passage is left out, as a predicate: `Passage "X" ${describeOmission(o)}`. */
5534
+ function describeOmission(omission) {
5535
+ switch (omission.kind) {
5536
+ case "special-name": return `is the special "${omission.name}" passage`;
5537
+ case "tag": return `is tagged "${omission.tag}"`;
5538
+ case "empty-story-settings": return "is an empty \"StorySettings\" passage";
5539
+ default: throw new Error(`unhandled passage omission: ${JSON.stringify(omission)}`);
5132
5540
  }
5133
- const count = versions.length;
5134
- rmSync(nameDir, {
5135
- recursive: true,
5136
- force: true
5137
- });
5138
- return count;
5139
5541
  }
5140
- /** Get total cache size in bytes and format count. */
5141
- function getCacheSize() {
5142
- const entries = listCachedFormats();
5143
- return {
5144
- totalBytes: entries.reduce((sum, e) => sum + e.sizeBytes, 0),
5145
- count: entries.length
5146
- };
5542
+ //#endregion
5543
+ //#region src/start-passage.ts
5544
+ /**
5545
+ * Check that `startName` names a passage the `target` output emits.
5546
+ * Returns the error diagnostics to report (empty when the start is usable).
5547
+ */
5548
+ function startPassageDiagnostics(story, startName, target) {
5549
+ const passage = story.passages.find((p) => p.name === startName);
5550
+ if (passage === void 0) return [{
5551
+ level: "error",
5552
+ message: `Starting passage "${startName}" not found.`
5553
+ }];
5554
+ const omission = passageOmission(story, passage, target);
5555
+ if (omission === void 0) return [];
5556
+ return [{
5557
+ level: "error",
5558
+ message: `Starting passage "${startName}" ${describeOmission(omission)}, so it is left out of the story data. Choose a story passage.`
5559
+ }];
5147
5560
  }
5148
5561
  //#endregion
5149
5562
  //#region src/compiler.ts
@@ -5173,36 +5586,68 @@ async function compile(options) {
5173
5586
  * Compile and write to a file.
5174
5587
  */
5175
5588
  async function compileToFile(options) {
5176
- const result = await compile(options);
5589
+ const result = await compileForOutputFile(options, options.outFile);
5177
5590
  writeFileSync(options.outFile, result.output, "utf-8");
5178
5591
  return result;
5179
5592
  }
5180
5593
  /**
5594
+ * Builds as compileToFile() does for `outFile`, without writing it: the output may sit
5595
+ * inside a source folder, so its last build is left out of the sources and modules and
5596
+ * never read back. The caller decides whether the result is written. With no `outFile`
5597
+ * (output to stdout, or none), this is compile().
5598
+ *
5599
+ * Internal, for the CLI; not part of the public API.
5600
+ */
5601
+ async function compileForOutputFile(options, outFile) {
5602
+ return buildOutput(options, void 0, void 0, outFile);
5603
+ }
5604
+ /**
5181
5605
  * Compile with incremental caching support.
5182
5606
  * Plugins and advanced users can manage their own cache and changed-file tracking.
5607
+ *
5608
+ * Without `changedFiles`, a cached file is reused while its modification time is unchanged.
5609
+ * With it, a file it names (by the path source discovery gives it, relative to the working
5610
+ * directory) is always reparsed, whatever its modification time, and every other cached
5611
+ * file is reused as it is.
5183
5612
  */
5184
5613
  async function compileIncremental(options, cache, changedFiles) {
5185
5614
  return buildOutput(options, cache, changedFiles);
5186
5615
  }
5187
5616
  /**
5188
- * Watch for file changes and recompile.
5617
+ * Watch for file changes and recompile. Every build is written to `outFile`, including
5618
+ * one whose diagnostics report errors.
5189
5619
  */
5190
5620
  async function watch(options) {
5621
+ return watchWithWriteFilter(options, () => true);
5622
+ }
5623
+ /**
5624
+ * watch(), writing to `outFile` only the builds `shouldWrite` accepts. A rejected build
5625
+ * leaves the output file as it was; `onBuild` receives every build either way.
5626
+ *
5627
+ * Internal, for the CLI, which keeps the last good output when a rebuild reports errors;
5628
+ * not part of the public API.
5629
+ */
5630
+ async function watchWithWriteFilter(options, shouldWrite) {
5191
5631
  const controller = new AbortController();
5192
5632
  const cache = /* @__PURE__ */ new Map();
5193
5633
  const filePaths = options.sources.filter((s) => typeof s === "string");
5194
5634
  const modulePaths = options.modules ?? [];
5195
- const allPaths = [...filePaths, ...modulePaths];
5635
+ const headPaths = options.headFile ? [options.headFile] : [];
5636
+ const allPaths = [
5637
+ ...filePaths,
5638
+ ...modulePaths,
5639
+ ...headPaths
5640
+ ];
5196
5641
  const exclude = options.exclude ?? [];
5197
- const moduleRoots = modulePaths.map((p) => resolve(p));
5642
+ const notExcludable = [...modulePaths, ...headPaths].map((p) => resolve(p));
5198
5643
  const ignore = (filename) => {
5199
5644
  if (!isExcluded(filename, exclude)) return false;
5200
5645
  const abs = resolve(filename);
5201
- return !moduleRoots.some((root) => abs === root || abs.startsWith(root + sep));
5646
+ return !notExcludable.some((root) => abs === root || abs.startsWith(root + sep));
5202
5647
  };
5203
5648
  const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
5204
- buildOutput(options, cache, changedFiles).then((result) => {
5205
- writeFileSync(options.outFile, result.output, "utf-8");
5649
+ buildOutput(options, cache, changedFiles, options.outFile).then((result) => {
5650
+ if (shouldWrite(result)) writeFileSync(options.outFile, result.output, "utf-8");
5206
5651
  options.onBuild?.(result);
5207
5652
  }).catch((e) => {
5208
5653
  options.onError?.(e instanceof Error ? e : new Error(String(e)));
@@ -5211,7 +5656,11 @@ async function watch(options) {
5211
5656
  controller.signal.addEventListener("abort", () => handle.close());
5212
5657
  return controller;
5213
5658
  }
5214
- async function buildOutput(options, cache, changedFiles) {
5659
+ /**
5660
+ * `outFile`: the file the build is written to, which source and module discovery
5661
+ * skip (as Tweego does), so an output inside a source folder is never loaded back.
5662
+ */
5663
+ async function buildOutput(options, cache, changedFiles, outFile) {
5215
5664
  const diagnostics = [];
5216
5665
  const outputMode = options.outputMode ?? "html";
5217
5666
  const trim = options.trim ?? true;
@@ -5224,7 +5673,7 @@ async function buildOutput(options, cache, changedFiles) {
5224
5673
  const inlineSources = [];
5225
5674
  for (const source of options.sources) if (typeof source === "string") filePaths.push(source);
5226
5675
  else inlineSources.push(source);
5227
- const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths, void 0, options.exclude);
5676
+ const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths, outFile, options.exclude);
5228
5677
  diagnostics.push(...sourcePathDiagnostics);
5229
5678
  const story = createStory();
5230
5679
  const processedFiles = /* @__PURE__ */ new Set();
@@ -5242,40 +5691,19 @@ async function buildOutput(options, cache, changedFiles) {
5242
5691
  }, diagnostics);
5243
5692
  if (options.tagAliases) story.passages = applyTagAliases(story.passages, options.tagAliases);
5244
5693
  let format;
5245
- let formatId = options.formatId ?? "";
5246
- if (outputMode === "html") {
5247
- const formats = discoverFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true));
5248
- if (!formatId && story.twine2.format) formatId = getFormatIdByNameAndVersion(formats, story.twine2.format, story.twine2.formatVersion) ?? "";
5249
- if (!formatId) formatId = DEFAULT_FORMAT_ID;
5250
- format = formats.get(formatId);
5251
- if (!format && !noRemote) {
5252
- const remoteName = story.twine2.format || formatId;
5253
- const remoteVersion = story.twine2.formatVersion || "";
5254
- try {
5255
- format = await resolveRemoteFormat(remoteName, remoteVersion, options.formatIndices, options.formatUrls);
5256
- } catch (e) {
5257
- diagnostics.push({
5258
- level: "warning",
5259
- message: `Remote format fetch failed for "${remoteName}": ${e instanceof Error ? e.message : String(e)}`
5260
- });
5261
- }
5262
- }
5263
- if (!format) {
5264
- const reason = noRemote ? " (remote fetching disabled)" : "";
5265
- diagnostics.push({
5266
- level: "error",
5267
- message: `Story format "${formatId}" is not available${reason}. Found: ${[...formats.keys()].join(", ") || "none"}`
5268
- });
5269
- }
5270
- }
5271
- const startName = options.startPassage || story.twine2.start || DEFAULT_START_NAME;
5694
+ if (outputMode === "html") format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
5695
+ ...options,
5696
+ noRemote
5697
+ }, diagnostics);
5698
+ if (options.startPassage) story.twine2.start = options.startPassage;
5699
+ const startName = story.twine2.start || DEFAULT_START_NAME;
5272
5700
  if (testMode) story.twine2.options.set("debug", true);
5273
5701
  ensureIFID(story, diagnostics);
5274
5702
  let output;
5275
5703
  switch (outputMode) {
5276
5704
  case "twee3":
5277
5705
  case "twee1":
5278
- output = toTwee(story, outputMode);
5706
+ output = toTwee(story, outputMode, { addStoryData: Boolean(options.startPassage) || testMode });
5279
5707
  break;
5280
5708
  case "twine2-archive":
5281
5709
  output = toTwine2Archive(story, startName, { sourceInfo });
@@ -5287,10 +5715,7 @@ async function buildOutput(options, cache, changedFiles) {
5287
5715
  output = storyToJSON(story);
5288
5716
  break;
5289
5717
  case "html": {
5290
- if (!storyHas(story, startName)) diagnostics.push({
5291
- level: "error",
5292
- message: `Starting passage "${startName}" not found.`
5293
- });
5718
+ diagnostics.push(...startPassageDiagnostics(story, startName, format?.isTwine2 === false ? "twine1" : "twine2"));
5294
5719
  if (!format) throw new TweeTsError("No story format available for HTML output.", diagnostics);
5295
5720
  if (format.isTwine2) output = toTwine2HTML(story, format, startName, { sourceInfo });
5296
5721
  else {
@@ -5300,7 +5725,7 @@ async function buildOutput(options, cache, changedFiles) {
5300
5725
  });
5301
5726
  output = toTwine1HTML(story, format, startName);
5302
5727
  }
5303
- const modules = getFilenames(options.modules ?? []);
5728
+ const modules = getFilenames(options.modules ?? [], outFile);
5304
5729
  diagnostics.push(...modules.diagnostics);
5305
5730
  output = modifyHead(output, modules.filenames, options.headFile, diagnostics);
5306
5731
  break;
@@ -5720,19 +6145,237 @@ function endOfRegularExpression(source, pos) {
5720
6145
  };
5721
6146
  }
5722
6147
  //#endregion
6148
+ //#region src/link-markup.ts
6149
+ /** Records that the reading has looked at the characters before `pos`, and returns `result`. */
6150
+ function readTo(reader, pos, result) {
6151
+ reader.scanned = Math.max(reader.scanned, pos);
6152
+ return result;
6153
+ }
6154
+ /**
6155
+ * Reads link or image markup that starts at `start` in `text`, or returns `undefined` where
6156
+ * SugarCube rejects it (and so prints the text instead).
6157
+ *
6158
+ * `budget.left`, if given, is how many characters the reading may look at; it is reduced by as
6159
+ * many as were read, and once it runs out, every reading fails. Unclosed markup is read to the end
6160
+ * of its line, so reading every `[[` of a long line that never closes one would otherwise take
6161
+ * quadratic time. A reading is charged for the characters it looked at, up to where it ended or
6162
+ * failed (a line feed, say), and no more, so markup left unclosed on short lines costs little.
6163
+ */
6164
+ function readSquareBracketedMarkup(text, start, budget = { left: Infinity }) {
6165
+ const reader = {
6166
+ text,
6167
+ limit: Math.min(text.length, start + Math.max(budget.left, 0)),
6168
+ scanned: start
6169
+ };
6170
+ const markup = readMarkup(reader, start);
6171
+ budget.left -= Math.max(reader.scanned, markup?.end ?? start) - start;
6172
+ return markup;
6173
+ }
6174
+ function readMarkup(reader, start) {
6175
+ const { text } = reader;
6176
+ if (text[start] !== "[") return;
6177
+ let pos = start + 1;
6178
+ let type;
6179
+ if (text[pos] === "[") {
6180
+ type = "link";
6181
+ pos += 1;
6182
+ } else {
6183
+ if (text[pos] === "<" || text[pos] === ">") pos += 1;
6184
+ if (!/^[Ii][Mm][Gg]\[/.test(text.slice(pos, pos + 4))) return;
6185
+ type = "image";
6186
+ pos += 4;
6187
+ }
6188
+ const core = readCoreComponents(reader, pos);
6189
+ if (core === void 0) return;
6190
+ if (type === "link") {
6191
+ if (!core.end.last) {
6192
+ const setterEnd = readSetter(reader, core.end.next);
6193
+ if (setterEnd === void 0) return;
6194
+ return {
6195
+ type,
6196
+ link: linkName(core.link),
6197
+ end: setterEnd
6198
+ };
6199
+ }
6200
+ return {
6201
+ type,
6202
+ link: linkName(core.link),
6203
+ end: core.end.next
6204
+ };
6205
+ }
6206
+ if (core.end.last) return {
6207
+ type,
6208
+ link: void 0,
6209
+ end: core.end.next
6210
+ };
6211
+ const linkEnd = readComponent(reader, core.end.next, false);
6212
+ if (linkEnd === void 0) return;
6213
+ const link = linkName(text.slice(core.end.next, linkEnd.textEnd));
6214
+ if (linkEnd.last) return {
6215
+ type,
6216
+ link,
6217
+ end: linkEnd.next
6218
+ };
6219
+ const setterEnd = readSetter(reader, linkEnd.next);
6220
+ return setterEnd === void 0 ? void 0 : {
6221
+ type,
6222
+ link,
6223
+ end: setterEnd
6224
+ };
6225
+ }
6226
+ /** The link component as SugarCube keeps it: trimmed, and without a leading `~`. */
6227
+ function linkName(component) {
6228
+ const trimmed = component.trim();
6229
+ return trimmed.startsWith("~") ? trimmed.slice(1) : trimmed;
6230
+ }
6231
+ /**
6232
+ * Reads the first components: the text and the link of link markup, or the title and the source
6233
+ * of image markup, divided by the first delimiter. `link` is the text of the link (or source)
6234
+ * component, untrimmed.
6235
+ */
6236
+ function readCoreComponents(reader, from) {
6237
+ const { text } = reader;
6238
+ let delimiter = "none";
6239
+ let componentStart = from;
6240
+ let link;
6241
+ let depth = 2;
6242
+ let pos = from;
6243
+ while (pos < reader.limit) {
6244
+ const ch = text[pos];
6245
+ pos += 1;
6246
+ switch (ch) {
6247
+ case "\n": return readTo(reader, pos, void 0);
6248
+ case "\"": {
6249
+ const end = readQuoted(reader, pos, "\"");
6250
+ if (end === void 0) return;
6251
+ pos = end;
6252
+ break;
6253
+ }
6254
+ case "|":
6255
+ if (delimiter === "none") {
6256
+ delimiter = "ltr";
6257
+ componentStart = pos;
6258
+ }
6259
+ break;
6260
+ case "-":
6261
+ if (delimiter === "none" && text[pos] === ">") {
6262
+ delimiter = "ltr";
6263
+ pos += 1;
6264
+ componentStart = pos;
6265
+ }
6266
+ break;
6267
+ case "<":
6268
+ if (delimiter === "none" && text[pos] === "-") {
6269
+ delimiter = "rtl";
6270
+ link = text.slice(componentStart, pos - 1);
6271
+ pos += 1;
6272
+ componentStart = pos;
6273
+ }
6274
+ break;
6275
+ case "[":
6276
+ depth += 1;
6277
+ break;
6278
+ case "]":
6279
+ depth -= 1;
6280
+ if (depth === 1) {
6281
+ const end = componentEnd(reader, pos);
6282
+ if (end === void 0) return;
6283
+ return {
6284
+ link: link ?? text.slice(componentStart, end.textEnd),
6285
+ end
6286
+ };
6287
+ }
6288
+ }
6289
+ }
6290
+ return readTo(reader, pos, void 0);
6291
+ }
6292
+ /**
6293
+ * Reads a component after `][` that holds no delimiter: an image's link (`setter` false) or a
6294
+ * setter, in which single quotes also quote.
6295
+ */
6296
+ function readComponent(reader, from, setter) {
6297
+ const { text } = reader;
6298
+ let depth = 2;
6299
+ let pos = from;
6300
+ while (pos < reader.limit) {
6301
+ const ch = text[pos];
6302
+ pos += 1;
6303
+ switch (ch) {
6304
+ case "\n": return readTo(reader, pos, void 0);
6305
+ case "\"":
6306
+ case "'": {
6307
+ if (ch === "'" && !setter) break;
6308
+ const end = readQuoted(reader, pos, ch);
6309
+ if (end === void 0) return;
6310
+ pos = end;
6311
+ break;
6312
+ }
6313
+ case "[":
6314
+ depth += 1;
6315
+ break;
6316
+ case "]":
6317
+ depth -= 1;
6318
+ if (depth === 1) return componentEnd(reader, pos);
6319
+ }
6320
+ }
6321
+ return readTo(reader, pos, void 0);
6322
+ }
6323
+ /** Reads a setter, the last component; returns the index after its closing `]]`. */
6324
+ function readSetter(reader, from) {
6325
+ const end = readComponent(reader, from, true);
6326
+ return end?.last === true ? end.next : void 0;
6327
+ }
6328
+ /** What the `]` just before `pos`, which brings the depth back to one, ends; `undefined` if malformed. */
6329
+ function componentEnd(reader, pos) {
6330
+ switch (reader.text[pos]) {
6331
+ case "[": return readTo(reader, pos + 1, {
6332
+ textEnd: pos - 1,
6333
+ next: pos + 1,
6334
+ last: false
6335
+ });
6336
+ case "]": return readTo(reader, pos + 1, {
6337
+ textEnd: pos - 1,
6338
+ next: pos + 1,
6339
+ last: true
6340
+ });
6341
+ default: return readTo(reader, pos + 1, void 0);
6342
+ }
6343
+ }
6344
+ /**
6345
+ * Reads a quoted string from just after its opening quote; a backslash escapes the character
6346
+ * after it. A line feed, or the end of the text, ends it unterminated. Returns the index after the
6347
+ * closing quote.
6348
+ */
6349
+ function readQuoted(reader, from, quote) {
6350
+ const { text } = reader;
6351
+ let pos = from;
6352
+ while (pos < reader.limit) {
6353
+ const ch = text[pos];
6354
+ pos += 1;
6355
+ if (ch === quote) return pos;
6356
+ if (ch === "\n") return readTo(reader, pos, void 0);
6357
+ if (ch === "\\") {
6358
+ if (pos >= reader.limit || text[pos] === "\n") return readTo(reader, Math.min(pos + 1, reader.limit), void 0);
6359
+ pos += 1;
6360
+ }
6361
+ }
6362
+ return readTo(reader, pos, void 0);
6363
+ }
6364
+ //#endregion
5723
6365
  //#region src/sugarcube-macros.ts
5724
6366
  /**
5725
- * Reads SugarCube 2 macro calls the way SugarCube 2.37.3 does, so the link check sees the same
5726
- * passage names that `<<link>>` and `<<goto>>` use when the story plays.
6367
+ * Reads SugarCube 2 macro calls and link markup the way SugarCube 2.37.3 does, so the link check
6368
+ * sees the same passage names that `[[…]]` links, `<<link>>` and `<<goto>>` use when the story
6369
+ * plays.
5727
6370
  *
5728
6371
  * Each step follows a part of SugarCube's macro parser:
5729
6372
  * - `findMacroTags` finds where a tag starts and ends (the parser's `lookahead` pattern);
5730
6373
  * - `parseMacroArgs` splits the text after the macro name into arguments (`parseArgs`);
5731
6374
  * - quoted arguments are evaluated as strict-mode JavaScript strings (`evalStringLiteral`).
5732
6375
  *
6376
+ * Link and image markup is read as `parseSquareBracketedMarkup` reads it (see `link-markup.ts`).
5733
6377
  * Of the rest of the wikifier, only comments, `<script>` elements and `<<script>>` bodies are
5734
- * told apart; other markup, such as verbatim text, is read as markup (see
5735
- * `findMacroPassageLinks`).
6378
+ * told apart; other markup, such as verbatim text, is read as markup (see `findPassageLinks`).
5736
6379
  */
5737
6380
  const NAME_RE = /\/?[A-Za-z][\w-]*|[=-]/y;
5738
6381
  const SPACES_RE = /\s*/y;
@@ -5777,17 +6420,22 @@ const RAW_ARGUMENT_MACROS = /* @__PURE__ */ new Set([
5777
6420
  * levels; the limit keeps the work linear in the size of the text on any input.
5778
6421
  */
5779
6422
  const MAX_STRING_DEPTH = 10;
5780
- /** A context for reading `text`: the closer search may read four times it, plus an allowance. */
6423
+ /**
6424
+ * A context for reading `text`: the closer search, and the markup reading, may each read four
6425
+ * times it, plus an allowance.
6426
+ */
5781
6427
  function readContext(text) {
6428
+ const allowance = () => ({ left: 4 * text.length + 1e5 });
5782
6429
  return {
5783
6430
  depth: 0,
5784
- budget: { left: 4 * text.length + 1e5 }
6431
+ budget: allowance(),
6432
+ markupBudget: allowance()
5785
6433
  };
5786
6434
  }
5787
6435
  function deeper(context) {
5788
6436
  return {
5789
- depth: context.depth + 1,
5790
- budget: context.budget
6437
+ ...context,
6438
+ depth: context.depth + 1
5791
6439
  };
5792
6440
  }
5793
6441
  /** Index of the first match of a global `re` at or after `from`, or -1. */
@@ -6017,7 +6665,10 @@ function lexArgument(raw, pos) {
6017
6665
  case "[": {
6018
6666
  const end = lexSquareBracketed(raw, pos + 1);
6019
6667
  return end === void 0 ? void 0 : {
6020
- arg: { kind: "markup" },
6668
+ arg: {
6669
+ kind: "markup",
6670
+ text: raw.slice(pos, end)
6671
+ },
6021
6672
  end
6022
6673
  };
6023
6674
  }
@@ -6161,13 +6812,44 @@ function ownPassageLink(tag, lexed) {
6161
6812
  const arg = passageArgument(tag.name, lexed);
6162
6813
  const passage = arg === void 0 ? void 0 : passageName(arg);
6163
6814
  return passage === void 0 ? void 0 : {
6164
- macro: tag.name,
6815
+ via: tag.name,
6165
6816
  passage
6166
6817
  };
6167
6818
  }
6168
6819
  /**
6169
- * The passages a tag names: its own, if it is a `<<link>>` or `<<goto>>` call, then those of the
6170
- * calls inside the strings of its arguments, which the macro may print or pass on
6820
+ * The passage a link's destination names. SugarCube evaluates a destination that no passage is
6821
+ * named by, and a quoted string gives its value (`[[Go|"Room"]]`), unless that is blank.
6822
+ */
6823
+ function linkDestination(link) {
6824
+ const value = evalStringLiteral(link);
6825
+ return value === void 0 || value.trim() === "" ? link : value;
6826
+ }
6827
+ /**
6828
+ * The passage that link markup from `start` in `text` names, and where the markup ends, or
6829
+ * `undefined` where SugarCube rejects the markup. Image markup names none here, nor does markup
6830
+ * with a template literal's `${…}` in it, whose passage is known only in play.
6831
+ */
6832
+ function linkMarkupAt(text, start, budget) {
6833
+ const markup = readSquareBracketedMarkup(text, start, budget);
6834
+ if (markup === void 0) return;
6835
+ const passage = markup.type === "link" && !text.slice(start, markup.end).includes("﷐") ? markup.link : void 0;
6836
+ return {
6837
+ link: passage === void 0 ? void 0 : {
6838
+ via: "markup",
6839
+ passage: linkDestination(passage)
6840
+ },
6841
+ end: markup.end
6842
+ };
6843
+ }
6844
+ /** The passage that a link markup argument names, if SugarCube reads the whole argument as markup. */
6845
+ function markupArgumentLinks(raw) {
6846
+ const markup = linkMarkupAt(raw, 0);
6847
+ return markup?.link === void 0 || markup.end !== raw.length ? [] : [markup.link];
6848
+ }
6849
+ /**
6850
+ * The passages a tag names: its own, if it is a `<<link>>` or `<<goto>>` call, then those of its
6851
+ * link markup arguments (`<<button [[Go|Room]]>>`), and those of the links and calls inside the
6852
+ * strings of its arguments, which the macro may print or pass on
6171
6853
  * (`<<set _out to '<<link "Go" "Room">><</link>>'>>`). The arguments of the macros SugarCube
6172
6854
  * doesn't split are read as JavaScript; in others, a backquoted expression is.
6173
6855
  */
@@ -6180,7 +6862,7 @@ function tagPassageLinks(tag, context) {
6180
6862
  switch (arg.kind) {
6181
6863
  case "string": return markupPassageLinks(arg.value, deeper(context));
6182
6864
  case "expression": return javaScriptPassageLinks(arg.code, context);
6183
- case "markup":
6865
+ case "markup": return markupArgumentLinks(arg.text);
6184
6866
  case "word": return [];
6185
6867
  default: throw new Error(`unhandled macro argument: ${JSON.stringify(arg)}`);
6186
6868
  }
@@ -6190,16 +6872,26 @@ function tagPassageLinks(tag, context) {
6190
6872
  /**
6191
6873
  * The passage named by a `<<link>>` or `<<goto>>` that appears unquoted inside another tag's
6192
6874
  * arguments, at `start`. SugarCube never sees such an outer "tag" when its `<<` lies in text it
6193
- * reads as something else, such as link markup (`[[<<-- Back|Prev]]`); the call inside it then
6194
- * runs. The call ends where the outer tag ends.
6875
+ * reads as something else, such as verbatim text (`{{{<<if}}}`); the call inside it then runs.
6876
+ * The call ends where the outer tag ends.
6195
6877
  */
6196
6878
  function innerPassageLink(matchTagAt, start) {
6197
6879
  const inner = matchTagAt(start)?.tag;
6198
6880
  const lexed = inner === void 0 ? void 0 : lexMacroArgs(inner.args);
6199
6881
  return inner === void 0 || lexed === void 0 ? void 0 : ownPassageLink(inner, lexed);
6200
6882
  }
6201
- const REGION_OPEN_RE = /\/\/|\/\*|\/%|<!--|<[Ss][Cc][Rr][Ii][Pp][Tt]/g;
6883
+ const REGION_OPEN_RE = /\[\[[^[]|\[[<>]?[Ii][Mm][Gg]\[|\/\/|\/\*|\/%|<!--|<[Ss][Cc][Rr][Ii][Pp][Tt]/g;
6202
6884
  const HAS_SCRIPT_OPEN_RE = /<[Ss][Cc][Rr][Ii][Pp][Tt]/;
6885
+ function regionKind(opener) {
6886
+ if (opener.startsWith("[")) return "markup";
6887
+ switch (opener) {
6888
+ case "//":
6889
+ case "/*":
6890
+ case "/%":
6891
+ case "<!--": return opener;
6892
+ default: return "script";
6893
+ }
6894
+ }
6203
6895
  const SCRIPT_CLOSE_RE = /<\/[Ss][Cc][Rr][Ii][Pp][Tt]>|[\r\u2028\u2029]/g;
6204
6896
  const COMMENT_CLOSE_RES = {
6205
6897
  "/*": /\*\/|[\r\u2028\u2029]/g,
@@ -6353,22 +7045,31 @@ function append(links, more) {
6353
7045
  for (const link of more) links.push(link);
6354
7046
  }
6355
7047
  /**
6356
- * The passages that `<<link>>` and `<<goto>>` calls in passage markup name. Calls whose passage
6357
- * is known only in play (a variable or an expression) are left out. A tag's own passage comes
6358
- * before those of the calls inside its arguments' strings.
7048
+ * The passages that link markup (`[[…]]`) and `<<link>>` and `<<goto>>` calls in passage markup
7049
+ * name, in the order they come. Calls whose passage is known only in play (a variable or an
7050
+ * expression) are left out. A tag's own passage comes before those that its arguments name, as
7051
+ * link markup or inside strings.
6359
7052
  *
6360
7053
  * The markup is walked in order, as SugarCube's wikifier walks it, with a tag tried at each
6361
- * `<<` the walk reaches. Of the wikifier's other parsers, only those whose text holds no call
6362
- * are told apart: comments (`/* … *` + `/`, `/% … %/`, `<!-- … -->`), whose calls never run,
6363
- * and `<script>` elements and `<<script>>` bodies, which are JavaScript: only the calls in their
6364
- * strings are read (see `findJavaScriptPassageLinks`). Everything else, verbatim text included,
6365
- * is read as markup.
7054
+ * `<<` the walk reaches, and link or image markup read at each `[[` or `[img[`; markup SugarCube
7055
+ * rejects is read on from just after its opener, and so is markup after a limit on how much
7056
+ * markup a reading may scan, which only input built to take quadratic time reaches (see
7057
+ * `readSquareBracketedMarkup`). Image markup names no passage here. Of the wikifier's other
7058
+ * parsers, only those whose text holds no link or call are told apart: comments (`/* … *` + `/`,
7059
+ * `/% … %/`, `<!-- … -->`), whose links and calls never run, and `<script>` elements and
7060
+ * `<<script>>` bodies, which are JavaScript: only the links and calls in their strings are read
7061
+ * (see `findJavaScriptPassageLinks`). Everything else, verbatim text included, is read as markup.
6366
7062
  *
6367
7063
  * Where this differs from SugarCube:
7064
+ * - a link whose passage SugarCube evaluates, because no passage has its name, is taken by its
7065
+ * name, `[[Go|$next]]` naming the passage `$next`, unless it is a quoted string, which is taken
7066
+ * by its value even when a passage has the name with the quotes;
7067
+ * - a link SugarCube takes for a URL, because no passage has its name and it looks like one or
7068
+ * holds a `/`, `.`, `?` or `#`, is still read as naming a passage;
6368
7069
  * - a comment or a `<<script>>` body is skipped whole, even where `parseBody` ends a container
6369
7070
  * such as `<<if>>` at a closing tag inside it and the rest then runs;
6370
- * - a `/*`, `<!--` or `<script` inside other markup that holds no call, such as link markup or a
6371
- * `<style>` element, still starts a comment or an element;
7071
+ * - a `[[`, `/*`, `<!--` or `<script` inside other markup that holds no call, such as verbatim
7072
+ * text or a `<style>` element, still starts link markup, a comment or an element;
6372
7073
  * - a `<<` in such other markup still starts a tag. When that tag runs over a `<<link` or
6373
7074
  * `<<goto` outside the parts of its arguments that the tag pattern reads as units, that call
6374
7075
  * is still read (see `innerPassageLink`), even where the tag's arguments would be JavaScript,
@@ -6383,33 +7084,43 @@ function append(links, more) {
6383
7084
  * `Config.passages.nobr`, are not joined before they are read (see `storyInspect` for passages
6384
7085
  * tagged `nobr`).
6385
7086
  */
6386
- function findMacroPassageLinks(text) {
7087
+ function findPassageLinks(text) {
6387
7088
  return markupPassageLinks(text, readContext(text));
6388
7089
  }
6389
7090
  function markupPassageLinks(text, context) {
6390
- if (context.depth > MAX_STRING_DEPTH || !text.includes("<<") && !HAS_SCRIPT_OPEN_RE.test(text)) return [];
7091
+ if (context.depth > MAX_STRING_DEPTH || !text.includes("<<") && !text.includes("[[") && !HAS_SCRIPT_OPEN_RE.test(text)) return [];
6391
7092
  const matchTagAt = tagMatcher(text);
6392
7093
  const matchInnerTagAt = tagMatcher(text);
6393
7094
  const readComment = commentReader(text);
6394
7095
  const readScriptElement = scriptElementReader(text);
6395
7096
  let findScriptCloser;
6396
7097
  const links = [];
6397
- /** Reads the region `opener` opens, adding the calls in a `<script>` element; returns where the walk goes on. */
7098
+ /**
7099
+ * Reads the region `opener` opens, adding the passage that link markup names and the links and
7100
+ * calls in a `<script>` element; returns where the walk goes on.
7101
+ */
6398
7102
  const regionEnd = (opener) => {
6399
7103
  const start = opener.index;
6400
- const kind = opener[0];
7104
+ const kind = regionKind(opener[0]);
6401
7105
  switch (kind) {
7106
+ case "markup": {
7107
+ const markup = linkMarkupAt(text, start, context.markupBudget);
7108
+ if (markup === void 0) return start + opener[0].length;
7109
+ if (markup.link !== void 0) links.push(markup.link);
7110
+ return markup.end;
7111
+ }
6402
7112
  case "//": return start + 2;
6403
7113
  case "/*":
6404
7114
  case "/%":
6405
7115
  case "<!--": return readComment(kind, start) ?? start + kind.length;
6406
- default: {
7116
+ case "script": {
6407
7117
  const element = readScriptElement(start);
6408
7118
  if (element.openerEnd === void 0) return start + 1;
6409
7119
  if (element.close === void 0) return element.openerEnd;
6410
7120
  append(links, javaScriptPassageLinks(text.slice(element.openerEnd, element.close), context));
6411
7121
  return element.close + 9;
6412
7122
  }
7123
+ default: throw new Error(`unhandled region: ${String(kind)}`);
6413
7124
  }
6414
7125
  };
6415
7126
  let macro = -2;
@@ -6449,24 +7160,26 @@ function markupPassageLinks(text, context) {
6449
7160
  }
6450
7161
  }
6451
7162
  /**
6452
- * The passages that `<<link>>` and `<<goto>>` calls in the strings of JavaScript source name,
6453
- * such as a script passage or a `<<script>>` body: `$.wiki('<<goto "Room">>')`. Code outside
6454
- * the strings isn't markup, so a call put together while the story plays, such as
6455
- * `'<<goto "' + target + '">>'`, names no passage here.
7163
+ * The passages that link markup and `<<link>>` and `<<goto>>` calls in the strings of JavaScript
7164
+ * source name, such as a script passage or a `<<script>>` body: `$.wiki('<<goto "Room">>')`.
7165
+ * Code outside the strings isn't markup, so a call put together while the story plays, such as
7166
+ * `'<<goto "' + target + '">>'`, names no passage here, nor does an array of arrays (`[[0, 1]]`).
6456
7167
  */
6457
7168
  function findJavaScriptPassageLinks(source) {
6458
7169
  return javaScriptPassageLinks(source, readContext(source));
6459
7170
  }
6460
7171
  /**
6461
7172
  * What JavaScript source must contain for one of its string or template literals to hold a call
6462
- * that is read. A value holding `<<`: each `<` in it is written as `<`, `\<`, `\x3c`, `\u003c` or
6463
- * `\u{…}`, two can be next to each other with only line continuations between, and the first
6464
- * is `<` or `\<` (then the source holds `<<` or `<\`) or one of the others. Or a value holding a
6465
- * `<script>` element, whose own strings can make `<<` from escapes the outer string encodes.
7173
+ * or link markup that is read. A value holding `<<`: each `<` in it is written as `<`, `\<`,
7174
+ * `\x3c`, `\u003c` or `\u{…}`, two can be next to each other with only line continuations
7175
+ * between, and the first is `<` or `\<` (then the source holds `<<` or `<\`) or one of the others.
7176
+ * A value holding `[[`, likewise: the source holds `[[`, a `[` before `\[` or a line continuation,
7177
+ * `\x5b`, `\u005b` or `\u{`. Or a value holding a `<script>` element, whose own strings can make
7178
+ * `<<` or `[[` from escapes the outer string encodes.
6466
7179
  */
6467
- const MAY_HOLD_MACRO_RE = /<<|<\\|\\x3c|\\u003c|\\u\{|<script/i;
7180
+ const MAY_HOLD_LINK_RE = /<<|<\\|\\x3c|\\u003c|\\u\{|<script|\[\[|\[\\[[\n\r\u2028\u2029]|\\x5b|\\u005b/i;
6468
7181
  function javaScriptPassageLinks(source, context) {
6469
- if (context.depth >= MAX_STRING_DEPTH || !MAY_HOLD_MACRO_RE.test(source)) return [];
7182
+ if (context.depth >= MAX_STRING_DEPTH || !MAY_HOLD_LINK_RE.test(source)) return [];
6470
7183
  const inner = deeper(context);
6471
7184
  const links = [];
6472
7185
  for (const value of javaScriptStrings(source)) append(links, markupPassageLinks(value, inner));
@@ -6475,43 +7188,57 @@ function javaScriptPassageLinks(source, context) {
6475
7188
  //#endregion
6476
7189
  //#region src/inspect.ts
6477
7190
  /**
6478
- * Extract all [[wiki-style links]] from passage text.
7191
+ * Extract the passages that passage text links to, read as SugarCube 2 reads them.
6479
7192
  *
6480
7193
  * Supported syntaxes:
6481
7194
  * - `[[PassageName]]`
6482
- * - `[[Display Text->PassageName]]` (Twine 2 / SugarCube arrow)
6483
- * - `[[Display Text|PassageName]]` (Twine 1 / Harlowe pipe)
6484
- * - `<<goto "PassageName">>` (SugarCube macro)
7195
+ * - `[[Display Text|PassageName]]`
7196
+ * - `[[Display Text->PassageName]]`
7197
+ * - `[[PassageName<-Display Text]]`
7198
+ * - any of those with a setter: `[[Display Text|PassageName][$x to 1]]`
7199
+ * - `<<goto "PassageName">>` (SugarCube macro)
6485
7200
  * - `<<link "Display" "PassageName">>` (SugarCube macro)
6486
7201
  *
6487
- * SugarCube macro arguments may be double- or single-quoted, with backslash escapes, or bare
6488
- * words; a passage named by a variable or an expression is known only in play and is skipped.
6489
- * Macro calls are also read inside the quoted strings of other macros' arguments, and inside the
6490
- * strings of `<<script>>` bodies and `<script>` elements; calls in comments are not read. A
6491
- * script passage (`isScript`) is JavaScript, so its macro calls are read only inside its strings.
6492
- * See `sugarcube-macros.ts` for the details and the known differences from SugarCube.
7202
+ * In link markup, the first `|`, `->` or `<-` divides the text from the passage name; image
7203
+ * markup (`[img[…][PassageName]]`) is not read. SugarCube macro arguments may be double- or
7204
+ * single-quoted, with backslash escapes, or bare words; a passage named by a variable or an
7205
+ * expression is known only in play and is skipped. Links and macro calls are also read inside the
7206
+ * quoted strings of macros' arguments, and inside the strings of `<<script>>` bodies and
7207
+ * `<script>` elements; links and calls in comments are not read. A script passage (`isScript`) is
7208
+ * JavaScript, so its links and calls are read only inside its strings. See `sugarcube-macros.ts`
7209
+ * for the details and the known differences from SugarCube.
6493
7210
  */
6494
7211
  function extractLinksFromText(text, isScript) {
6495
- const targets = /* @__PURE__ */ new Set();
6496
- const wikiLinkRe = /\[\[([^\]]+)\]\]/g;
6497
- let m;
6498
- while ((m = wikiLinkRe.exec(text)) !== null) {
6499
- const content = m[1];
6500
- const arrowIdx = content.indexOf("->");
6501
- if (arrowIdx !== -1) {
6502
- targets.add(content.slice(arrowIdx + 2).trim());
6503
- continue;
6504
- }
6505
- const pipeIdx = content.indexOf("|");
6506
- if (pipeIdx !== -1) {
6507
- targets.add(content.slice(pipeIdx + 1).trim());
6508
- continue;
6509
- }
6510
- targets.add(content.trim());
7212
+ const found = isScript ? findJavaScriptPassageLinks(text) : findPassageLinks(text);
7213
+ const ordered = [
7214
+ "markup",
7215
+ "goto",
7216
+ "link"
7217
+ ].flatMap((via) => found.filter((link) => link.via === via).map((link) => link.passage));
7218
+ return [...new Set(ordered)];
7219
+ }
7220
+ /**
7221
+ * The passages that `p` links to. A script passage is JavaScript, read for links only in its
7222
+ * strings. A stylesheet is CSS, which the story puts in a style element and never wikifies, so it
7223
+ * links nowhere; a passage tagged both is a script, as Twine 2 output takes it.
7224
+ */
7225
+ function passageLinks(p) {
7226
+ if (hasTag(p, "script")) return extractLinksFromText(textAsRead(p), true);
7227
+ if (hasTag(p, "stylesheet")) return [];
7228
+ return extractLinksFromText(textAsRead(p), false);
7229
+ }
7230
+ /**
7231
+ * Each passage name, mapped to why the `target` output leaves that passage out, or to `undefined`
7232
+ * when a link can reach it: always without a target, and otherwise when the output emits a
7233
+ * passage of that name.
7234
+ */
7235
+ function destinationOmissions(story, { target }) {
7236
+ const omissions = /* @__PURE__ */ new Map();
7237
+ for (const p of story.passages) {
7238
+ const omission = target === void 0 ? void 0 : passageOmission(story, p, target);
7239
+ if (!omissions.has(p.name) || omission === void 0) omissions.set(p.name, omission);
6511
7240
  }
6512
- const macroLinks = isScript ? findJavaScriptPassageLinks(text) : findMacroPassageLinks(text);
6513
- for (const macro of ["goto", "link"]) for (const link of macroLinks) if (link.macro === macro) targets.add(link.passage);
6514
- return [...targets];
7241
+ return omissions;
6515
7242
  }
6516
7243
  /** Passages SugarCube runs from their raw text, never joining their lines. */
6517
7244
  const RAW_TEXT_PASSAGES = /* @__PURE__ */ new Set([
@@ -6554,9 +7281,14 @@ function joinLines(text) {
6554
7281
  * expect(map.brokenLinks).toEqual([]);
6555
7282
  * expect(map.deadEnds).not.toContain('Start');
6556
7283
  * ```
7284
+ *
7285
+ * By default, a link is broken only when no passage has its name. Pass a `target` to check links
7286
+ * against what that output emits: `storyInspect(result.story, { target: 'twine2' })` also reports
7287
+ * links to passages Twine 2 output leaves out, such as script, stylesheet and `Twine.private`
7288
+ * passages.
6557
7289
  */
6558
- function storyInspect(story) {
6559
- const allNames = new Set(story.passages.map((p) => p.name));
7290
+ function storyInspect(story, options = {}) {
7291
+ const destinations = destinationOmissions(story, options);
6560
7292
  const passages = [];
6561
7293
  const storyPassages = [];
6562
7294
  const infoPassages = [];
@@ -6578,14 +7310,24 @@ function storyInspect(story) {
6578
7310
  }
6579
7311
  list.push(p.name);
6580
7312
  }
6581
- const targets = extractLinksFromText(textAsRead(p), hasTag(p, "script"));
6582
- links.set(p.name, targets);
7313
+ links.set(p.name, passageLinks(p));
6583
7314
  }
6584
7315
  const brokenLinks = [];
6585
- for (const [from, targets] of links) for (const to of targets) if (!allNames.has(to)) brokenLinks.push({
6586
- from,
6587
- to
6588
- });
7316
+ for (const [from, targets] of links) for (const to of targets) {
7317
+ if (!destinations.has(to)) {
7318
+ brokenLinks.push({
7319
+ from,
7320
+ to
7321
+ });
7322
+ continue;
7323
+ }
7324
+ const omission = destinations.get(to);
7325
+ if (omission !== void 0) brokenLinks.push({
7326
+ from,
7327
+ to,
7328
+ omission
7329
+ });
7330
+ }
6589
7331
  const deadEnds = [];
6590
7332
  for (const name of storyPassages) {
6591
7333
  const targets = links.get(name);
@@ -6614,16 +7356,29 @@ function storyInspect(story) {
6614
7356
  //#region src/lint.ts
6615
7357
  /**
6616
7358
  * Lint a story: compile without output rendering, then inspect structure.
6617
- * Uses JSON output mode internally to avoid format resolution.
7359
+ * Uses JSON output mode internally to avoid format resolution, so it checks
7360
+ * the starting passage itself, against the Twine 2 passage rules. Link
7361
+ * destinations are checked against the same rules: a link to a passage that
7362
+ * Twine 2 output leaves out is broken.
6618
7363
  */
6619
7364
  async function lint(options) {
6620
- const result = await compile({
7365
+ return lintForOutputFile(options, void 0);
7366
+ }
7367
+ /**
7368
+ * lint() for a project whose builds are written to `outFile`: like a build for that file,
7369
+ * it leaves `outFile` out of the sources and modules, so an earlier build inside a source
7370
+ * folder is not linted as a source.
7371
+ *
7372
+ * Internal, for the CLI; not part of the public API.
7373
+ */
7374
+ async function lintForOutputFile(options, outFile) {
7375
+ const result = await compileForOutputFile({
6621
7376
  ...options,
6622
7377
  outputMode: "json"
6623
- });
6624
- const map = storyInspect(result.story);
7378
+ }, outFile);
7379
+ const map = storyInspect(result.story, { target: "twine2" });
6625
7380
  return {
6626
- diagnostics: result.diagnostics,
7381
+ diagnostics: [...result.diagnostics, ...startPassageDiagnostics(result.story, map.start, "twine2")],
6627
7382
  stats: result.stats,
6628
7383
  formatName: result.story.twine2.format,
6629
7384
  formatVersion: result.story.twine2.formatVersion,
@@ -6648,7 +7403,10 @@ function formatLintReport(result) {
6648
7403
  if (result.brokenLinks.length > 0) {
6649
7404
  lines.push("");
6650
7405
  lines.push(`Broken links (${result.brokenLinks.length}):`);
6651
- for (const link of result.brokenLinks) lines.push(` ${link.from} -> ${link.to} (passage "${link.to}" does not exist)`);
7406
+ for (const link of result.brokenLinks) {
7407
+ const why = link.omission === void 0 ? "does not exist" : `${describeOmission(link.omission)}, so it is left out of the story data`;
7408
+ lines.push(` ${link.from} -> ${link.to} (passage "${link.to}" ${why})`);
7409
+ }
6652
7410
  }
6653
7411
  if (result.deadEnds.length > 0) {
6654
7412
  lines.push("");