@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.
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from "node:util";
3
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
3
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
4
4
  import * as nodePath from "node:path";
5
- import { basename, dirname, extname, join, parse, relative, resolve, sep } from "node:path";
5
+ import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { homedir } from "node:os";
8
8
  import { fileURLToPath } from "node:url";
@@ -106,6 +106,61 @@ function commentSanitize(s) {
106
106
  function htmlCommentSanitize(s) {
107
107
  return s.replace(/-->/g, "-- >");
108
108
  }
109
+ /**
110
+ * Escape JavaScript for the content of an HTML `<script>` element, so that the HTML parser reads all of it as
111
+ * the element's text and ends the element at the end tag written after it.
112
+ *
113
+ * - Every `<\/script` (in any letter case) becomes `<\/script`. HTML ends a script element at the first
114
+ * `<\/script` followed by whitespace, `/` or `>`, wherever it is in the JavaScript, even inside a string.
115
+ * - Every `<!--` becomes `<\!--`, but only if the text would otherwise end in the HTML tokenizer's "script
116
+ * data double escaped" state: after a `<!--` and then a `<script` start tag with no `-->` between them, the
117
+ * end tag after the text would not end the element.
118
+ *
119
+ * Inserting a backslash after `<` keeps the value of every string, template, and regular expression literal
120
+ * and every comment where these sequences can be written (`\/` and `\!` are identity escapes; a backslash
121
+ * already before the `<` escapes the `<`, as it did before); `<\/script` is also how minifiers such as Terser
122
+ * write inline scripts. It only changes code that uses them outside literals (`a <\/script/ b`, an HTML-like `<!--`
123
+ * comment that is escaped because of a later `<script`), the raw text of a tagged template, and a `<!--` in a
124
+ * regular expression with the `u` or `v` flag. The result is unchanged by escaping it again.
125
+ */
126
+ function scriptContentEscape(s) {
127
+ const escaped = s.replace(/<(?=\/script)/gi, "<\\");
128
+ return endsDoubleEscaped(escaped) ? escaped.replace(/<(?=!--)/g, "<\\") : escaped;
129
+ }
130
+ /**
131
+ * Whether the HTML tokenizer, reading `s` as script element content with no `<\/script` in it, ends in the
132
+ * "script data double escaped" state, where an end tag after `s` would not end the element.
133
+ * See https://html.spec.whatwg.org/multipage/parsing.html#script-data-escaped-state.
134
+ */
135
+ function endsDoubleEscaped(s) {
136
+ const scriptStartTag = /<script[\t\n\f\r />]/gi;
137
+ let pos = 0;
138
+ for (;;) {
139
+ const open = s.indexOf("<!--", pos);
140
+ if (open === -1) return false;
141
+ const escapedFrom = open + 2;
142
+ const close = s.indexOf("-->", escapedFrom);
143
+ scriptStartTag.lastIndex = escapedFrom;
144
+ const start = scriptStartTag.exec(s);
145
+ if (start === null || close !== -1 && close < start.index) {
146
+ if (close === -1) return false;
147
+ pos = close + 3;
148
+ continue;
149
+ }
150
+ const end = s.indexOf("-->", start.index + start[0].length);
151
+ if (end === -1) return true;
152
+ pos = end + 3;
153
+ }
154
+ }
155
+ /**
156
+ * Escape CSS for the content of an HTML `<style>` element: every `</style` (in any letter case) becomes
157
+ * `<\/style`, since HTML ends a style element at the first `</style` followed by whitespace, `/` or `>`.
158
+ * `\/` stands for `/` in CSS strings and URLs, and comments don't matter; `</style` anywhere else isn't valid
159
+ * CSS anyway.
160
+ */
161
+ function styleContentEscape(s) {
162
+ return s.replace(/<(?=\/style)/gi, "<\\");
163
+ }
109
164
  /** Apply ROT13 encoding to a string (only affects [A-Za-z]). */
110
165
  function rot13(s) {
111
166
  if (s.length === 0) return s;
@@ -663,10 +718,35 @@ function getFilenames(pathnames, outFilename, exclude = []) {
663
718
  diagnostics
664
719
  };
665
720
  }
721
+ /** Whether the absolute path `file` is the absolute path `dir` or inside it. */
722
+ function isInsideDir(file, dir) {
723
+ return file === dir || file.startsWith(dir.endsWith(sep) ? dir : dir + sep);
724
+ }
725
+ /**
726
+ * The watched paths split into folders (as given) and files (absolute). A path
727
+ * that cannot be read counts as a folder, which fs.watch then fails to watch.
728
+ */
729
+ function splitWatchRoots(pathnames) {
730
+ const dirs = [];
731
+ const files = /* @__PURE__ */ new Set();
732
+ for (const pathname of pathnames) {
733
+ let isFile = false;
734
+ try {
735
+ isFile = statSync(pathname).isFile();
736
+ } catch {}
737
+ if (isFile) files.add(resolve(pathname));
738
+ else dirs.push(pathname);
739
+ }
740
+ return {
741
+ dirs,
742
+ files
743
+ };
744
+ }
666
745
  /**
667
746
  * Watch paths for changes, calling the build callback on known file type changes.
668
- * A change to a file `ignore` returns true for (given the path relative to the
669
- * working directory) schedules no build.
747
+ * A folder is watched recursively; a file is watched on its own and counts
748
+ * whatever its type (a head file, say). A change to a file `ignore` returns true
749
+ * for (given the path relative to the working directory) schedules no build.
670
750
  * Uses debouncing to avoid rapid rebuilds.
671
751
  */
672
752
  function watchFilesystem(pathnames, outFilename, callback, ignore = () => false) {
@@ -685,18 +765,30 @@ function watchFilesystem(pathnames, outFilename, callback, ignore = () => false)
685
765
  callback(files);
686
766
  }, BUILD_DEBOUNCE);
687
767
  }
688
- for (const pathname of pathnames) try {
689
- const watcher = watch(pathname, { recursive: true }, (_event, filename) => {
690
- if (!filename) return;
691
- const abs = resolve(pathname, filename);
692
- if (abs === absOutFile) return;
693
- if (isKnownFileType(filename)) {
694
- const rel = relative(process.cwd(), abs);
695
- if (!ignore(rel || abs)) scheduleBuild(rel || abs);
696
- }
697
- });
698
- watchers.push(watcher);
699
- } catch {}
768
+ function fileChanged(abs, named) {
769
+ if (abs === absOutFile) return;
770
+ if (!named && !isKnownFileType(abs)) return;
771
+ const rel = relative(process.cwd(), abs);
772
+ if (!ignore(rel || abs)) scheduleBuild(rel || abs);
773
+ }
774
+ function watchPath(pathname, recursive, listener) {
775
+ try {
776
+ watchers.push(watch(pathname, { recursive }, (_event, filename) => {
777
+ if (filename) listener(filename);
778
+ }));
779
+ } catch {}
780
+ }
781
+ const { dirs, files } = splitWatchRoots(pathnames);
782
+ const absDirs = dirs.map((dir) => resolve(dir));
783
+ for (const dir of dirs) watchPath(dir, true, (filename) => {
784
+ const abs = resolve(dir, filename);
785
+ fileChanged(abs, files.has(abs));
786
+ });
787
+ const fileDirs = new Set([...files].filter((file) => !absDirs.some((dir) => isInsideDir(file, dir))).map((file) => dirname(file)));
788
+ for (const dir of fileDirs) watchPath(dir, false, (filename) => {
789
+ const abs = resolve(dir, filename);
790
+ if (files.has(abs)) fileChanged(abs, true);
791
+ });
700
792
  callback();
701
793
  return { close() {
702
794
  if (buildTimer) clearTimeout(buildTimer);
@@ -704,16 +796,25 @@ function watchFilesystem(pathnames, outFilename, callback, ignore = () => false)
704
796
  } };
705
797
  }
706
798
  //#endregion
799
+ //#region src/source-text.ts
800
+ /**
801
+ * Source text normalization shared by every input path (files, in-memory sources, `parseTwee`).
802
+ */
803
+ /**
804
+ * Strip a leading UTF-8 byte order mark and convert CRLF and bare CR line endings to LF.
805
+ * Idempotent, so text that is already normalized passes through unchanged.
806
+ */
807
+ function normalizeSourceText(text) {
808
+ return (text.charCodeAt(0) === 65279 ? text.slice(1) : text).replace(/\r\n?/g, "\n");
809
+ }
810
+ //#endregion
707
811
  //#region src/util.ts
708
812
  /**
709
813
  * Shared file I/O utilities.
710
814
  */
711
815
  /** Read a file as UTF-8 with BOM stripping and line ending normalization. */
712
816
  function readUTF8(filename) {
713
- let content = readFileSync(filename, "utf-8");
714
- if (content.charCodeAt(0) === 65279) content = content.slice(1);
715
- content = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
716
- return content;
817
+ return normalizeSourceText(readFileSync(filename, "utf-8"));
717
818
  }
718
819
  /** Read a file as base64. */
719
820
  function readBase64(filename) {
@@ -743,16 +844,28 @@ function relaxedJSONParse(json) {
743
844
  fixed = fixed.replace(/,\s*([}\]])/g, "$1");
744
845
  return JSON.parse(fixed);
745
846
  }
847
+ /** A `"setup": function` property, which Harlowe appends to its otherwise-JSON format object. */
848
+ const SETUP_FUNCTION_PROPERTY = /,\s*"setup"\s*:\s*function\b/g;
849
+ /** Drop a trailing `"setup": function(){…}` property from a format object, if it has one. */
850
+ function stripSetupFunction(chunk) {
851
+ let lastIndex = -1;
852
+ for (const match of chunk.matchAll(SETUP_FUNCTION_PROPERTY)) lastIndex = match.index;
853
+ return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
854
+ }
746
855
  /**
747
856
  * Parse the Twine 2 format.js JSON chunk.
748
- * Handles Harlowe's malformed JSON by stripping the "setup" function property.
857
+ * Handles Harlowe's malformed JSON by stripping the "setup" function property. The workaround
858
+ * keys on the property itself, so the same bytes parse whether they come from a `harlowe-3`
859
+ * directory or a direct download.
749
860
  * Per spec, the name key is Optional.
861
+ *
862
+ * @param _formatId No longer used; kept so existing callers keep compiling.
750
863
  */
751
- function parseFormatJSON(source, formatId) {
864
+ function parseFormatJSON(source, _formatId) {
752
865
  const first = source.indexOf("{");
753
866
  const last = source.lastIndexOf("}");
754
867
  if (first === -1 || last === -1) return null;
755
- let chunk = source.slice(first, last + 1);
868
+ const chunk = source.slice(first, last + 1);
756
869
  const parse = (json) => {
757
870
  const raw = relaxedJSONParse(json);
758
871
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
@@ -775,16 +888,13 @@ function parseFormatJSON(source, formatId) {
775
888
  try {
776
889
  return parse(chunk);
777
890
  } catch {
778
- if (formatId.toLowerCase().startsWith("harlowe")) {
779
- const setupIdx = chunk.lastIndexOf(",\"setup\": function");
780
- if (setupIdx !== -1) {
781
- chunk = chunk.slice(0, setupIdx) + "}";
782
- try {
783
- return parse(chunk);
784
- } catch {}
785
- }
891
+ const stripped = stripSetupFunction(chunk);
892
+ if (stripped === void 0) return null;
893
+ try {
894
+ return parse(stripped);
895
+ } catch {
896
+ return null;
786
897
  }
787
- return null;
788
898
  }
789
899
  }
790
900
  /**
@@ -934,6 +1044,54 @@ function semverCompare(a, b) {
934
1044
  if (aPatch !== bPatch) return aPatch - bPatch;
935
1045
  return 0;
936
1046
  }
1047
+ /** Build a directory-style format ID from a name and version, e.g. ('SugarCube', '2.37.3') → 'sugarcube-2'. */
1048
+ function makeFormatId(name, version) {
1049
+ const major = parseSemver(version)?.[0] ?? 0;
1050
+ return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
1051
+ }
1052
+ /** Describe a format request for diagnostics. */
1053
+ function describeFormatRequest(request) {
1054
+ switch (request.kind) {
1055
+ case "id": return `"${request.id}"`;
1056
+ case "name": return request.version ? `"${request.name}" at version "${request.version}"` : `"${request.name}"`;
1057
+ default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
1058
+ }
1059
+ }
1060
+ /**
1061
+ * Pick the candidate that best answers a format request.
1062
+ *
1063
+ * - An ID request ('sugarcube-2') matches a candidate whose name and major version build that ID,
1064
+ * and takes the greatest such version.
1065
+ * - A name request matches the name case-insensitively. An exact version wins outright; otherwise
1066
+ * the greatest version with the same major that is not older than the one asked for (or, with
1067
+ * `allowOlder`, the greatest same-major version). An unparseable requested version takes the
1068
+ * greatest version of any major.
1069
+ */
1070
+ function selectFormatCandidate(request, candidates, describe, options = {}) {
1071
+ const allowOlder = options.allowOlder ?? false;
1072
+ const wanted = request.kind === "name" ? parseSemver(request.version) : null;
1073
+ let best;
1074
+ for (const candidate of candidates) {
1075
+ const { name, version } = describe(candidate);
1076
+ const have = parseSemver(version);
1077
+ if (!have) continue;
1078
+ if (request.kind === "id") {
1079
+ if (makeFormatId(name, version) !== request.id.toLowerCase()) continue;
1080
+ } else {
1081
+ if (name.toLowerCase() !== request.name.toLowerCase()) continue;
1082
+ if (wanted) {
1083
+ if (semverCompare(have, wanted) === 0) return candidate;
1084
+ if (have[0] !== wanted[0]) continue;
1085
+ if (!allowOlder && semverCompare(have, wanted) < 0) continue;
1086
+ }
1087
+ }
1088
+ if (!best || semverCompare(have, best.version) > 0) best = {
1089
+ candidate,
1090
+ version: have
1091
+ };
1092
+ }
1093
+ return best?.candidate;
1094
+ }
937
1095
  /** Get format ID from Twine 2 name and version, using SemVer major matching. */
938
1096
  function getFormatIdByNameAndVersion(formats, name, version) {
939
1097
  const wanted = parseSemver(version);
@@ -963,171 +1121,571 @@ function readFormatSource(format) {
963
1121
  return source;
964
1122
  }
965
1123
  //#endregion
966
- //#region src/types.ts
967
- const ItemType = {
968
- Error: 0,
969
- EOF: 1,
970
- Header: 2,
971
- Name: 3,
972
- Tags: 4,
973
- Metadata: 5,
974
- Content: 6
975
- };
976
- //#endregion
977
- //#region src/lexer.ts
978
- const EOF = -1;
979
- const HEADER_DELIM = "::";
980
- const NEWLINE_HEADER_DELIM = "\n::";
981
- var LexerContext = class {
982
- input;
983
- line = 1;
984
- start = 0;
985
- pos = 0;
986
- items = [];
987
- constructor(input) {
988
- this.input = input;
989
- }
990
- next() {
991
- if (this.pos >= this.input.length) return EOF;
992
- const ch = this.input.charCodeAt(this.pos);
993
- this.pos++;
994
- if (ch === 10) this.line++;
995
- return ch;
996
- }
997
- peek() {
998
- if (this.pos >= this.input.length) return EOF;
999
- return this.input.charCodeAt(this.pos);
1000
- }
1001
- backup() {
1002
- if (this.pos > this.start) {
1003
- this.pos--;
1004
- if (this.input.charCodeAt(this.pos) === 10) this.line--;
1005
- } else throw new Error("backup would leave pos < start");
1006
- }
1007
- emit(type) {
1008
- const val = this.input.slice(this.start, this.pos);
1009
- this.items.push({
1010
- type,
1011
- line: this.line,
1012
- pos: this.start,
1013
- val
1014
- });
1015
- if (type === ItemType.Content) {
1016
- for (let i = this.start; i < this.pos; i++) if (this.input.charCodeAt(i) === 10) this.line++;
1017
- }
1018
- this.start = this.pos;
1019
- }
1020
- ignore() {
1021
- for (let i = this.start; i < this.pos; i++) if (this.input.charCodeAt(i) === 10) this.line++;
1022
- this.start = this.pos;
1023
- }
1024
- acceptRun(valid) {
1025
- let ch;
1026
- for (ch = this.next(); ch !== EOF && valid.includes(String.fromCharCode(ch)); ch = this.next());
1027
- if (ch !== EOF) this.backup();
1028
- }
1029
- errorf(message) {
1030
- this.items.push({
1031
- type: ItemType.Error,
1032
- line: this.line,
1033
- pos: this.start,
1034
- val: message
1035
- });
1036
- return null;
1037
- }
1038
- /** Drain all pending items and return them. */
1039
- drainItems() {
1040
- const result = this.items;
1041
- this.items = [];
1042
- return result;
1043
- }
1044
- };
1045
- function acceptQuoted(ctx, quote) {
1046
- for (;;) {
1047
- const ch = ctx.next();
1048
- switch (ch) {
1049
- case 92: {
1050
- const r = ctx.next();
1051
- if (r !== 10 && r !== EOF) break;
1052
- }
1053
- case 10:
1054
- case EOF: return "unterminated quoted string";
1055
- default: if (ch === quote) return null;
1056
- }
1057
- }
1124
+ //#region src/remote-formats.ts
1125
+ /**
1126
+ * Remote story format fetching, caching, and checksum verification.
1127
+ * Uses the Story Formats Archive (SFA) as the default source.
1128
+ */
1129
+ const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
1130
+ /** Get the cache directory for downloaded story formats. */
1131
+ function getCacheDir() {
1132
+ const base = process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache");
1133
+ return join(base, "twee-ts", "storyformats");
1058
1134
  }
1059
- function lexProlog(ctx) {
1060
- if (ctx.input.startsWith(HEADER_DELIM, ctx.pos)) return lexHeaderDelim;
1061
- const i = ctx.input.indexOf(NEWLINE_HEADER_DELIM, ctx.pos);
1062
- if (i > -1) {
1063
- ctx.pos = i + 1;
1064
- ctx.ignore();
1065
- return lexHeaderDelim;
1135
+ /**
1136
+ * Whether a string can serve as one directory name inside the cache: not empty, not `.` or `..`,
1137
+ * and free of path separators. Format names and versions come from downloaded metadata, so they
1138
+ * are untrusted path input.
1139
+ */
1140
+ function isSafeSegment(segment) {
1141
+ return segment !== "" && segment !== "." && segment !== ".." && !/[/\\\0]/.test(segment);
1142
+ }
1143
+ /** Whether absolute path `target` lies strictly inside absolute path `root`. */
1144
+ function isInside(root, target) {
1145
+ const rel = relative(root, target);
1146
+ return rel !== "" && !isAbsolute(rel) && rel.split(sep)[0] !== "..";
1147
+ }
1148
+ /** The cache directory for one format version. Throws when the name or version could leave the cache. */
1149
+ function cachedFormatDir(name, version) {
1150
+ if (!isSafeSegment(name)) throw new Error(`Refusing to cache a story format with an unsafe name: ${JSON.stringify(name)}`);
1151
+ if (!isSafeSegment(version) || !parseSemver(version)) throw new Error(`Refusing to cache story format "${name}" with an unsafe version: ${JSON.stringify(version)}`);
1152
+ const root = resolve(getCacheDir());
1153
+ const dir = resolve(root, name, version);
1154
+ if (!isInside(root, dir)) throw new Error(`Refusing to cache a story format outside the cache directory ${root}: ${dir}`);
1155
+ return dir;
1156
+ }
1157
+ /** Create `dir` as a plain directory, or check that it already is one (a symlink could lead out of the cache). */
1158
+ function ensurePlainDirectory(dir) {
1159
+ const stat = lstatSync(dir, { throwIfNoEntry: false });
1160
+ if (stat === void 0) {
1161
+ mkdirSync(dir);
1162
+ return;
1066
1163
  }
1067
- ctx.emit(ItemType.EOF);
1068
- return null;
1164
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`Refusing to write a story format outside the cache directory: ${dir} is not a plain directory`);
1165
+ }
1166
+ /** Write a downloaded format.js to `<cache>/<name>/<version>/format.js` and return its path. */
1167
+ function writeCachedFormat(name, version, text) {
1168
+ const dir = cachedFormatDir(name, version);
1169
+ const root = resolve(getCacheDir());
1170
+ mkdirSync(root, { recursive: true });
1171
+ ensurePlainDirectory(join(root, name));
1172
+ ensurePlainDirectory(dir);
1173
+ const formatPath = join(dir, "format.js");
1174
+ if (lstatSync(formatPath, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error(`Refusing to write a story format outside the cache directory: ${formatPath} is a symlink`);
1175
+ writeFileSync(formatPath, text, "utf-8");
1176
+ return formatPath;
1069
1177
  }
1070
- function lexContent(ctx) {
1071
- if (ctx.input.startsWith(HEADER_DELIM, ctx.pos)) return lexHeaderDelim;
1072
- const i = ctx.input.indexOf(NEWLINE_HEADER_DELIM, ctx.pos);
1073
- if (i > -1) {
1074
- ctx.pos = i + 1;
1075
- ctx.emit(ItemType.Content);
1076
- return lexHeaderDelim;
1077
- }
1078
- ctx.pos = ctx.input.length;
1079
- if (ctx.pos > ctx.start) ctx.emit(ItemType.Content);
1080
- ctx.emit(ItemType.EOF);
1081
- return null;
1178
+ /** In-memory index cache, keyed by URL. Cleared each compile. */
1179
+ const indexCache = /* @__PURE__ */ new Map();
1180
+ /** Clear the in-memory index cache. */
1181
+ function clearIndexCache() {
1182
+ indexCache.clear();
1082
1183
  }
1083
- function lexHeaderDelim(ctx) {
1084
- ctx.pos += 2;
1085
- ctx.emit(ItemType.Header);
1086
- return lexName;
1184
+ /** Validate that a JSON value is a valid SFAIndexEntry. */
1185
+ function isValidEntry(val) {
1186
+ if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
1187
+ const obj = val;
1188
+ return typeof obj.name === "string" && typeof obj.version === "string" && typeof obj.checksums === "object" && obj.checksums !== null && !Array.isArray(obj.checksums);
1087
1189
  }
1088
- function lexName(ctx) {
1089
- let r;
1090
- outer: for (;;) {
1091
- r = ctx.next();
1092
- switch (r) {
1093
- case 92: {
1094
- const next = ctx.next();
1095
- if (next !== 10 && next !== EOF) break;
1096
- r = next;
1097
- }
1098
- case 91:
1099
- case 93:
1100
- case 123:
1101
- case 125:
1102
- case 10:
1103
- case EOF:
1104
- if (r !== EOF) ctx.backup();
1105
- break outer;
1106
- }
1107
- }
1108
- ctx.emit(ItemType.Name);
1109
- switch (r) {
1110
- case 91: return lexTags;
1111
- case 93: return ctx.errorf(`unexpected right square bracket ']'`);
1112
- case 123: return lexMetadata;
1113
- case 125: return ctx.errorf(`unexpected right curly brace '}'`);
1114
- case 10:
1115
- ctx.pos++;
1116
- ctx.ignore();
1117
- return lexContent;
1118
- }
1119
- ctx.emit(ItemType.EOF);
1120
- return null;
1190
+ /** Validate that a JSON value conforms to the SFAIndex shape. */
1191
+ function validateSFAIndex(json) {
1192
+ if (typeof json !== "object" || json === null) throw new Error("SFA index is not an object");
1193
+ const obj = json;
1194
+ return {
1195
+ twine1: Array.isArray(obj["twine1"]) ? obj["twine1"].filter(isValidEntry) : [],
1196
+ twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
1197
+ };
1121
1198
  }
1122
- function lexNextOptionalBlock(ctx) {
1123
- ctx.acceptRun(" ");
1124
- ctx.ignore();
1125
- const r = ctx.peek();
1126
- switch (r) {
1127
- case 91: return lexTags;
1128
- case 93: return ctx.errorf(`unexpected right square bracket ']'`);
1129
- case 123: return lexMetadata;
1130
- case 125: return ctx.errorf(`unexpected right curly brace '}'`);
1199
+ /** Fetch and parse an SFA index.json, with in-memory caching. */
1200
+ async function fetchIndex(url) {
1201
+ const cached = indexCache.get(url);
1202
+ if (cached) return cached;
1203
+ const res = await fetch(url);
1204
+ if (!res.ok) throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
1205
+ const data = validateSFAIndex(await res.json());
1206
+ indexCache.set(url, data);
1207
+ return data;
1208
+ }
1209
+ /** Find the best matching entry in an SFA index for a name or ID request (twine2 entries first). */
1210
+ function findEntryForRequest(index, request) {
1211
+ return selectFormatCandidate(request, [...(index.twine2 ?? []).map((entry) => ({
1212
+ entry,
1213
+ formatType: "twine2"
1214
+ })), ...(index.twine1 ?? []).map((entry) => ({
1215
+ entry,
1216
+ formatType: "twine1"
1217
+ }))], (c) => c.entry);
1218
+ }
1219
+ /** Verify SHA-256 checksum using Web Crypto API (Node 22 built-in). */
1220
+ async function verifySHA256(content, expectedHex) {
1221
+ const digest = await crypto.subtle.digest("SHA-256", content);
1222
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("") === expectedHex.toLowerCase();
1223
+ }
1224
+ /** Derive the download URL for a format.js from the index URL and entry. */
1225
+ function getDownloadUrl(indexUrl, entry, formatType) {
1226
+ return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
1227
+ }
1228
+ /** Download a format, verify its checksum, write to cache, and return StoryFormatInfo. */
1229
+ async function fetchAndCacheFormat(entry, downloadUrl) {
1230
+ cachedFormatDir(entry.name, entry.version);
1231
+ const res = await fetch(downloadUrl);
1232
+ if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
1233
+ const text = await res.text();
1234
+ const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
1235
+ if (checksumKey) {
1236
+ const expected = entry.checksums[checksumKey];
1237
+ if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
1238
+ if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
1239
+ }
1240
+ const id = makeFormatId(entry.name, entry.version);
1241
+ const data = parseFormatJSON(text, id);
1242
+ if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
1243
+ return {
1244
+ id,
1245
+ filename: writeCachedFormat(entry.name, entry.version, text),
1246
+ isTwine2: true,
1247
+ name: data.name,
1248
+ version: data.version,
1249
+ proofing: data.proofing ?? false
1250
+ };
1251
+ }
1252
+ /** Download a direct format.js URL, parse its JSON, cache it, and return StoryFormatInfo. */
1253
+ async function fetchDirectFormat(url) {
1254
+ const res = await fetch(url);
1255
+ if (!res.ok) throw new Error(`Failed to download format from ${url}: ${res.status} ${res.statusText}`);
1256
+ const text = await res.text();
1257
+ const data = parseFormatJSON(text);
1258
+ if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
1259
+ return {
1260
+ id: makeFormatId(data.name, data.version),
1261
+ filename: writeCachedFormat(data.name, data.version, text),
1262
+ isTwine2: true,
1263
+ name: data.name,
1264
+ version: data.version,
1265
+ proofing: data.proofing ?? false
1266
+ };
1267
+ }
1268
+ /**
1269
+ * Resolve a story format request remotely, in the same order as {@link resolveRemoteFormat}.
1270
+ * An ID request such as 'sugarcube-2' matches the format whose name and major version build that ID
1271
+ * (SugarCube 2.x), taking the greatest version available.
1272
+ */
1273
+ async function resolveRemoteFormatRequest(request, indices, urls) {
1274
+ let lastError;
1275
+ const cached = findCachedFormat(request);
1276
+ if (cached && request.kind === "name" && isExactVersion(cached.version, request.version)) return cached;
1277
+ for (const url of urls ?? []) try {
1278
+ const info = await fetchDirectFormat(url);
1279
+ if (selectFormatCandidate(request, [info], (f) => f)) return info;
1280
+ } catch (e) {
1281
+ lastError = e instanceof Error ? e : new Error(String(e));
1282
+ }
1283
+ const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
1284
+ for (const indexUrl of allIndices) try {
1285
+ const result = findEntryForRequest(await fetchIndex(indexUrl), request);
1286
+ if (result) {
1287
+ const hit = getCachedFormat(result.entry.name, result.entry.version);
1288
+ if (hit) return hit;
1289
+ const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
1290
+ return await fetchAndCacheFormat(result.entry, downloadUrl);
1291
+ }
1292
+ } catch (e) {
1293
+ lastError = e instanceof Error ? e : new Error(String(e));
1294
+ }
1295
+ if (cached) return cached;
1296
+ if (lastError) throw lastError;
1297
+ }
1298
+ /** Whether two version strings name the same SemVer version. */
1299
+ function isExactVersion(have, wanted) {
1300
+ const a = parseSemver(have);
1301
+ const b = parseSemver(wanted);
1302
+ return a !== null && b !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
1303
+ }
1304
+ /**
1305
+ * Find the cached download that best answers a format request, without network access.
1306
+ * Matching follows {@link selectFormatCandidate}.
1307
+ */
1308
+ function findCachedFormat(request, options = {}) {
1309
+ return selectFormatCandidate(request, [...discoverCachedFormats().values()], (f) => f, options);
1310
+ }
1311
+ /** Check if a format is already in the local cache. */
1312
+ function getCachedFormat(name, version) {
1313
+ if (!isSafeSegment(name) || !isSafeSegment(version)) return void 0;
1314
+ const formatPath = join(getCacheDir(), name, version, "format.js");
1315
+ try {
1316
+ if (!existsSync(formatPath)) return void 0;
1317
+ const source = readFileSync(formatPath, "utf-8");
1318
+ const id = makeFormatId(name, version);
1319
+ const data = parseFormatJSON(source, id);
1320
+ if (!data) return void 0;
1321
+ return {
1322
+ id,
1323
+ filename: formatPath,
1324
+ isTwine2: true,
1325
+ name: data.name,
1326
+ version: data.version,
1327
+ proofing: data.proofing ?? false
1328
+ };
1329
+ } catch {
1330
+ return;
1331
+ }
1332
+ }
1333
+ /** Discover all cached remote formats. Returns a Map like discoverFormats(). */
1334
+ function discoverCachedFormats() {
1335
+ const formats = /* @__PURE__ */ new Map();
1336
+ const cacheDir = getCacheDir();
1337
+ try {
1338
+ if (!existsSync(cacheDir)) return formats;
1339
+ } catch {
1340
+ return formats;
1341
+ }
1342
+ let names;
1343
+ try {
1344
+ names = readdirSync(cacheDir);
1345
+ } catch {
1346
+ return formats;
1347
+ }
1348
+ for (const name of names) {
1349
+ const nameDir = join(cacheDir, name);
1350
+ try {
1351
+ if (!statSync(nameDir).isDirectory()) continue;
1352
+ } catch {
1353
+ continue;
1354
+ }
1355
+ let versions;
1356
+ try {
1357
+ versions = readdirSync(nameDir);
1358
+ } catch {
1359
+ continue;
1360
+ }
1361
+ for (const version of versions) {
1362
+ const info = getCachedFormat(name, version);
1363
+ if (info) formats.set(`${info.id}-${version}`, info);
1364
+ }
1365
+ }
1366
+ return formats;
1367
+ }
1368
+ /** List all cached formats with size and modification date. */
1369
+ function listCachedFormats() {
1370
+ const cacheDir = getCacheDir();
1371
+ const entries = [];
1372
+ try {
1373
+ if (!existsSync(cacheDir)) return entries;
1374
+ } catch {
1375
+ return entries;
1376
+ }
1377
+ let names;
1378
+ try {
1379
+ names = readdirSync(cacheDir);
1380
+ } catch {
1381
+ return entries;
1382
+ }
1383
+ for (const name of names) {
1384
+ const nameDir = join(cacheDir, name);
1385
+ try {
1386
+ if (!statSync(nameDir).isDirectory()) continue;
1387
+ } catch {
1388
+ continue;
1389
+ }
1390
+ let versions;
1391
+ try {
1392
+ versions = readdirSync(nameDir);
1393
+ } catch {
1394
+ continue;
1395
+ }
1396
+ for (const version of versions) {
1397
+ const formatPath = join(nameDir, version, "format.js");
1398
+ try {
1399
+ const stat = statSync(formatPath);
1400
+ entries.push({
1401
+ name,
1402
+ version,
1403
+ sizeBytes: stat.size,
1404
+ modifiedAt: stat.mtime
1405
+ });
1406
+ } catch {}
1407
+ }
1408
+ }
1409
+ return entries;
1410
+ }
1411
+ /**
1412
+ * Clear all cached formats, or only those matching a given name. Returns the number of entries removed.
1413
+ * A name must be a single cache entry name (as `listCachedFormats()` reports it); anything with a
1414
+ * path separator or `.`/`..` throws instead of deleting outside the format's own directory.
1415
+ */
1416
+ function clearCachedFormats(name) {
1417
+ if (name && !isSafeSegment(name)) throw new Error(`Refusing to clear ${JSON.stringify(name)}: it is not a cached format name. Use a name as "cache list" shows it.`);
1418
+ const cacheDir = getCacheDir();
1419
+ if (!existsSync(cacheDir)) return 0;
1420
+ if (!name) {
1421
+ const count = listCachedFormats().length;
1422
+ rmSync(cacheDir, {
1423
+ recursive: true,
1424
+ force: true
1425
+ });
1426
+ return count;
1427
+ }
1428
+ const root = resolve(cacheDir);
1429
+ const nameDir = resolve(root, name);
1430
+ if (!isInside(root, nameDir)) throw new Error(`Refusing to clear ${JSON.stringify(name)}: it is not a cached format name.`);
1431
+ if (!lstatSync(nameDir, { throwIfNoEntry: false })?.isDirectory()) return 0;
1432
+ let versions;
1433
+ try {
1434
+ versions = readdirSync(nameDir);
1435
+ } catch {
1436
+ return 0;
1437
+ }
1438
+ const count = versions.length;
1439
+ rmSync(nameDir, {
1440
+ recursive: true,
1441
+ force: true
1442
+ });
1443
+ return count;
1444
+ }
1445
+ /** Get total cache size in bytes and format count. */
1446
+ function getCacheSize() {
1447
+ const entries = listCachedFormats();
1448
+ return {
1449
+ totalBytes: entries.reduce((sum, e) => sum + e.sizeBytes, 0),
1450
+ count: entries.length
1451
+ };
1452
+ }
1453
+ //#endregion
1454
+ //#region src/format-resolution.ts
1455
+ /** Choose the format request: explicit format ID > StoryData format > default ID. */
1456
+ function formatRequestFor(formatId, storyFormat, storyFormatVersion, defaultId) {
1457
+ if (formatId) return {
1458
+ kind: "id",
1459
+ id: formatId
1460
+ };
1461
+ if (storyFormat) return {
1462
+ kind: "name",
1463
+ name: storyFormat,
1464
+ version: storyFormatVersion
1465
+ };
1466
+ return {
1467
+ kind: "id",
1468
+ id: defaultId
1469
+ };
1470
+ }
1471
+ /** Look a request up among the formats found in local format directories. */
1472
+ function findLocalFormat(formats, request) {
1473
+ switch (request.kind) {
1474
+ case "id": return formats.get(request.id);
1475
+ case "name": {
1476
+ const id = getFormatIdByNameAndVersion(formats, request.name, request.version);
1477
+ return id === void 0 ? void 0 : formats.get(id);
1478
+ }
1479
+ default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
1480
+ }
1481
+ }
1482
+ /**
1483
+ * Resolve a format request to a story format.
1484
+ *
1485
+ * Order: local format directories, then the download cache, then remote sources (unless
1486
+ * `noRemote`). When a StoryData request names a version that none of them has, an older version of
1487
+ * the same format and major version is used with a warning. Anything else is an error diagnostic,
1488
+ * and the result is `undefined`.
1489
+ */
1490
+ async function resolveStoryFormat(request, options, diagnostics) {
1491
+ const noRemote = options.noRemote ?? false;
1492
+ const formats = discoverFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true));
1493
+ const wanted = describeFormatRequest(request);
1494
+ const local = findLocalFormat(formats, request);
1495
+ if (local) return local;
1496
+ const cached = findCachedFormat(request);
1497
+ if (cached) return cached;
1498
+ if (!noRemote) try {
1499
+ const remote = await resolveRemoteFormatRequest(request, options.formatIndices, options.formatUrls);
1500
+ if (remote) return remote;
1501
+ } catch (e) {
1502
+ diagnostics.push({
1503
+ level: "warning",
1504
+ message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
1505
+ });
1506
+ }
1507
+ if (request.kind === "name") {
1508
+ const older = selectFormatCandidate(request, [...formats.values()].filter((f) => f.isTwine2), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
1509
+ if (older) {
1510
+ diagnostics.push({
1511
+ level: "warning",
1512
+ message: `Story format ${wanted} is not available; using ${older.name} ${older.version} instead.`
1513
+ });
1514
+ return older;
1515
+ }
1516
+ }
1517
+ const reason = noRemote ? " (remote fetching disabled)" : "";
1518
+ diagnostics.push({
1519
+ level: "error",
1520
+ message: `Story format ${wanted} is not available${reason}. Found: ${[...formats.keys()].join(", ") || "none"}`
1521
+ });
1522
+ }
1523
+ //#endregion
1524
+ //#region src/types.ts
1525
+ const ItemType = {
1526
+ Error: 0,
1527
+ EOF: 1,
1528
+ Header: 2,
1529
+ Name: 3,
1530
+ Tags: 4,
1531
+ Metadata: 5,
1532
+ Content: 6
1533
+ };
1534
+ //#endregion
1535
+ //#region src/lexer.ts
1536
+ const EOF = -1;
1537
+ const HEADER_DELIM = "::";
1538
+ const NEWLINE_HEADER_DELIM = "\n::";
1539
+ var LexerContext = class {
1540
+ input;
1541
+ line = 1;
1542
+ start = 0;
1543
+ pos = 0;
1544
+ items = [];
1545
+ constructor(input) {
1546
+ this.input = input;
1547
+ }
1548
+ next() {
1549
+ if (this.pos >= this.input.length) return EOF;
1550
+ const ch = this.input.charCodeAt(this.pos);
1551
+ this.pos++;
1552
+ if (ch === 10) this.line++;
1553
+ return ch;
1554
+ }
1555
+ peek() {
1556
+ if (this.pos >= this.input.length) return EOF;
1557
+ return this.input.charCodeAt(this.pos);
1558
+ }
1559
+ backup() {
1560
+ if (this.pos > this.start) {
1561
+ this.pos--;
1562
+ if (this.input.charCodeAt(this.pos) === 10) this.line--;
1563
+ } else throw new Error("backup would leave pos < start");
1564
+ }
1565
+ emit(type) {
1566
+ const val = this.input.slice(this.start, this.pos);
1567
+ this.items.push({
1568
+ type,
1569
+ line: this.line,
1570
+ pos: this.start,
1571
+ val
1572
+ });
1573
+ if (type === ItemType.Content) {
1574
+ for (let i = this.start; i < this.pos; i++) if (this.input.charCodeAt(i) === 10) this.line++;
1575
+ }
1576
+ this.start = this.pos;
1577
+ }
1578
+ ignore() {
1579
+ for (let i = this.start; i < this.pos; i++) if (this.input.charCodeAt(i) === 10) this.line++;
1580
+ this.start = this.pos;
1581
+ }
1582
+ acceptRun(valid) {
1583
+ let ch;
1584
+ for (ch = this.next(); ch !== EOF && valid.includes(String.fromCharCode(ch)); ch = this.next());
1585
+ if (ch !== EOF) this.backup();
1586
+ }
1587
+ errorf(message) {
1588
+ this.items.push({
1589
+ type: ItemType.Error,
1590
+ line: this.line,
1591
+ pos: this.start,
1592
+ val: message
1593
+ });
1594
+ return null;
1595
+ }
1596
+ /** Drain all pending items and return them. */
1597
+ drainItems() {
1598
+ const result = this.items;
1599
+ this.items = [];
1600
+ return result;
1601
+ }
1602
+ };
1603
+ function acceptQuoted(ctx, quote) {
1604
+ for (;;) {
1605
+ const ch = ctx.next();
1606
+ switch (ch) {
1607
+ case 92: {
1608
+ const r = ctx.next();
1609
+ if (r !== 10 && r !== EOF) break;
1610
+ }
1611
+ case 10:
1612
+ case EOF: return "unterminated quoted string";
1613
+ default: if (ch === quote) return null;
1614
+ }
1615
+ }
1616
+ }
1617
+ function lexProlog(ctx) {
1618
+ if (ctx.input.startsWith(HEADER_DELIM, ctx.pos)) return lexHeaderDelim;
1619
+ const i = ctx.input.indexOf(NEWLINE_HEADER_DELIM, ctx.pos);
1620
+ if (i > -1) {
1621
+ ctx.pos = i + 1;
1622
+ ctx.ignore();
1623
+ return lexHeaderDelim;
1624
+ }
1625
+ ctx.emit(ItemType.EOF);
1626
+ return null;
1627
+ }
1628
+ function lexContent(ctx) {
1629
+ if (ctx.input.startsWith(HEADER_DELIM, ctx.pos)) return lexHeaderDelim;
1630
+ const i = ctx.input.indexOf(NEWLINE_HEADER_DELIM, ctx.pos);
1631
+ if (i > -1) {
1632
+ ctx.pos = i + 1;
1633
+ ctx.emit(ItemType.Content);
1634
+ return lexHeaderDelim;
1635
+ }
1636
+ ctx.pos = ctx.input.length;
1637
+ if (ctx.pos > ctx.start) ctx.emit(ItemType.Content);
1638
+ ctx.emit(ItemType.EOF);
1639
+ return null;
1640
+ }
1641
+ function lexHeaderDelim(ctx) {
1642
+ ctx.pos += 2;
1643
+ ctx.emit(ItemType.Header);
1644
+ return lexName;
1645
+ }
1646
+ function lexName(ctx) {
1647
+ let r;
1648
+ outer: for (;;) {
1649
+ r = ctx.next();
1650
+ switch (r) {
1651
+ case 92: {
1652
+ const next = ctx.next();
1653
+ if (next !== 10 && next !== EOF) break;
1654
+ r = next;
1655
+ }
1656
+ case 91:
1657
+ case 93:
1658
+ case 123:
1659
+ case 125:
1660
+ case 10:
1661
+ case EOF:
1662
+ if (r !== EOF) ctx.backup();
1663
+ break outer;
1664
+ }
1665
+ }
1666
+ ctx.emit(ItemType.Name);
1667
+ switch (r) {
1668
+ case 91: return lexTags;
1669
+ case 93: return ctx.errorf(`unexpected right square bracket ']'`);
1670
+ case 123: return lexMetadata;
1671
+ case 125: return ctx.errorf(`unexpected right curly brace '}'`);
1672
+ case 10:
1673
+ ctx.pos++;
1674
+ ctx.ignore();
1675
+ return lexContent;
1676
+ }
1677
+ ctx.emit(ItemType.EOF);
1678
+ return null;
1679
+ }
1680
+ function lexNextOptionalBlock(ctx) {
1681
+ ctx.acceptRun(" ");
1682
+ ctx.ignore();
1683
+ const r = ctx.peek();
1684
+ switch (r) {
1685
+ case 91: return lexTags;
1686
+ case 93: return ctx.errorf(`unexpected right square bracket ']'`);
1687
+ case 123: return lexMetadata;
1688
+ case 125: return ctx.errorf(`unexpected right curly brace '}'`);
1131
1689
  case 10:
1132
1690
  ctx.pos++;
1133
1691
  ctx.ignore();
@@ -1226,16 +1784,20 @@ function twee2ToV3(s) {
1226
1784
  //#region src/parser.ts
1227
1785
  /**
1228
1786
  * Parse Twee source text into passages.
1787
+ *
1788
+ * The source is normalized first, as files are when they are read: a leading UTF-8 BOM is
1789
+ * removed and CRLF and bare CR line endings become LF.
1229
1790
  */
1230
1791
  function parseTwee(source, options = {}) {
1231
1792
  const { filename = "<inline>", trim = true, twee2Compat = false } = options;
1232
1793
  const diagnostics = [];
1233
- if (twee2Compat) source = twee2ToV3(source);
1794
+ const normalized = normalizeSourceText(source);
1795
+ const tweeSource = twee2Compat ? twee2ToV3(normalized) : normalized;
1234
1796
  const passages = [];
1235
1797
  let current = null;
1236
1798
  let pCount = 0;
1237
1799
  let lastType = ItemType.EOF;
1238
- for (const item of tweeLexer(source)) {
1800
+ for (const item of tweeLexer(tweeSource)) {
1239
1801
  switch (item.type) {
1240
1802
  case ItemType.Error:
1241
1803
  diagnostics.push({
@@ -4065,14 +4627,16 @@ function parseDocument(data, options) {
4065
4627
  */
4066
4628
  /**
4067
4629
  * Parse a Twine 2 or Twine 1 compiled HTML file back into a Story model.
4630
+ * Tweego always trims passage text; here `trim: false` keeps it exactly, as for Twee sources.
4068
4631
  */
4069
- function decompileHTML(html) {
4632
+ function decompileHTML(html, options = {}) {
4070
4633
  const diagnostics = [];
4071
4634
  const story = createStory();
4635
+ const passageText = options.trim === false ? (text) => text : (text) => text.trim();
4072
4636
  const doc = parseDocument(html);
4073
4637
  const twine2Data = findElement(doc, "tw-storydata");
4074
4638
  if (twine2Data) {
4075
- decompileTwine2(twine2Data, story, diagnostics);
4639
+ decompileTwine2(twine2Data, story, passageText, diagnostics);
4076
4640
  return {
4077
4641
  story,
4078
4642
  diagnostics
@@ -4080,7 +4644,7 @@ function decompileHTML(html) {
4080
4644
  }
4081
4645
  const twine1Data = findElementByIdPattern(doc, /^store(?:-a|A)rea$/);
4082
4646
  if (twine1Data) {
4083
- decompileTwine1(twine1Data, story, diagnostics);
4647
+ decompileTwine1(twine1Data, story, passageText, diagnostics);
4084
4648
  return {
4085
4649
  story,
4086
4650
  diagnostics
@@ -4095,7 +4659,7 @@ function decompileHTML(html) {
4095
4659
  diagnostics
4096
4660
  };
4097
4661
  }
4098
- function decompileTwine2(storyData, story, diagnostics) {
4662
+ function decompileTwine2(storyData, story, passageText, diagnostics) {
4099
4663
  let startnode = 0;
4100
4664
  const attrs = storyData.attribs ?? {};
4101
4665
  if (attrs["name"]) story.name = attrs["name"];
@@ -4116,6 +4680,7 @@ function decompileTwine2(storyData, story, diagnostics) {
4116
4680
  });
4117
4681
  else story.twine2.zoom = parsed;
4118
4682
  }
4683
+ if (attrs["tags"]) story.twine2.tags = attrs["tags"];
4119
4684
  if (attrs["format"]) story.twine2.format = attrs["format"];
4120
4685
  if (attrs["format-version"]) story.twine2.formatVersion = attrs["format-version"];
4121
4686
  if (attrs["options"]) for (const opt of attrs["options"].split(/\s+/).filter((s) => s.length > 0)) story.twine2.options.set(opt, true);
@@ -4124,8 +4689,9 @@ function decompileTwine2(storyData, story, diagnostics) {
4124
4689
  switch (node.name) {
4125
4690
  case "style":
4126
4691
  case "script": {
4127
- const text = getTextContent(node).trim();
4128
- if (text.length === 0) continue;
4692
+ const content = getTextContent(node);
4693
+ if (content.trim().length === 0) continue;
4694
+ const text = passageText(content);
4129
4695
  storyAdd(story, {
4130
4696
  name: node.name === "style" ? "Story Stylesheet" : "Story JavaScript",
4131
4697
  tags: node.name === "style" ? ["stylesheet"] : ["script"],
@@ -4160,7 +4726,7 @@ function decompileTwine2(storyData, story, diagnostics) {
4160
4726
  const passage = {
4161
4727
  name,
4162
4728
  tags,
4163
- text: getTextContent(node).trim()
4729
+ text: passageText(getTextContent(node))
4164
4730
  };
4165
4731
  if (metadata.position || metadata.size) passage.metadata = metadata;
4166
4732
  storyAdd(story, passage, diagnostics);
@@ -4174,23 +4740,33 @@ function decompileTwine2(storyData, story, diagnostics) {
4174
4740
  text: marshalStoryData(story)
4175
4741
  }, diagnostics);
4176
4742
  }
4177
- function decompileTwine1(storeArea, story, diagnostics) {
4178
- for (const node of storeArea.children ?? []) {
4179
- if (node.type !== "tag" || node.name !== "div") continue;
4180
- const nodeAttrs = node.attribs ?? {};
4181
- if (!("tiddler" in nodeAttrs)) continue;
4182
- const name = nodeAttrs["tiddler"] ?? "";
4183
- const tags = nodeAttrs["tags"] ? nodeAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [];
4184
- const metadata = {};
4185
- if (nodeAttrs["twine-position"]) metadata.position = nodeAttrs["twine-position"];
4186
- const passage = {
4187
- name,
4188
- tags,
4189
- text: tiddlerUnescape(getTextContent(node)).trim()
4190
- };
4191
- if (metadata.position) passage.metadata = metadata;
4192
- storyAdd(story, passage, diagnostics);
4193
- }
4743
+ function decompileTwine1(storeArea, story, passageText, diagnostics) {
4744
+ const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
4745
+ const obfuscated = isRot13Obfuscated(passages);
4746
+ for (const p of passages) storyAdd(story, obfuscated && p.name !== "StorySettings" ? {
4747
+ ...p,
4748
+ text: rot13(p.text)
4749
+ } : p, diagnostics);
4750
+ }
4751
+ function isTiddler(node) {
4752
+ return node.type === "tag" && node.name === "div" && node.attribs !== void 0 && "tiddler" in node.attribs;
4753
+ }
4754
+ function tiddlerToPassage(node, passageText) {
4755
+ const nodeAttrs = node.attribs ?? {};
4756
+ const passage = {
4757
+ name: nodeAttrs["tiddler"] ?? "",
4758
+ tags: nodeAttrs["tags"] ? nodeAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [],
4759
+ text: passageText(tiddlerUnescape(getTextContent(node)))
4760
+ };
4761
+ const position = nodeAttrs["twine-position"];
4762
+ if (position) passage.metadata = { position };
4763
+ return passage;
4764
+ }
4765
+ /** Whether the StorySettings tiddlers turn on ROT13 obfuscation, read the way the writer reads them. */
4766
+ function isRot13Obfuscated(passages) {
4767
+ const settings = createStory();
4768
+ for (const p of passages) if (p.name === "StorySettings") unmarshalStorySettings(settings, p.text, []);
4769
+ return settings.twine1.settings.get("obfuscate") === "rot13";
4194
4770
  }
4195
4771
  function findElement(node, tagName) {
4196
4772
  if (node.type === "tag" && node.name === tagName) return node;
@@ -4245,7 +4821,7 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
4245
4821
  break;
4246
4822
  case "htm":
4247
4823
  case "html":
4248
- loadHTML(story, filename, diagnostics);
4824
+ loadHTML(story, filename, opts, diagnostics);
4249
4825
  break;
4250
4826
  case "css":
4251
4827
  loadTagged(story, "stylesheet", filename, diagnostics);
@@ -4313,7 +4889,7 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
4313
4889
  function loadInlineSources(story, sources, opts, diagnostics) {
4314
4890
  for (const source of sources) {
4315
4891
  if (typeof source === "string") continue;
4316
- const content = typeof source.content === "string" ? source.content : source.content.toString("utf-8");
4892
+ const content = normalizeSourceText(typeof source.content === "string" ? source.content : source.content.toString("utf-8"));
4317
4893
  const ext = normalizedFileExt(source.filename);
4318
4894
  switch (ext) {
4319
4895
  case "":
@@ -4399,13 +4975,33 @@ function parseFontFile(filename) {
4399
4975
  diagnostics: []
4400
4976
  };
4401
4977
  }
4402
- function parseHTMLFile(filename) {
4403
- const result = decompileHTML(readUTF8(filename));
4978
+ function parseHTMLFile(filename, opts) {
4979
+ const { story, diagnostics } = decompileHTML(readUTF8(filename), { trim: opts.trim ?? true });
4404
4980
  return {
4405
- passages: result.story.passages,
4406
- diagnostics: [...result.diagnostics]
4981
+ passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
4982
+ name: "StoryTitle",
4983
+ tags: [],
4984
+ text: story.name
4985
+ }, ...story.passages] : story.passages,
4986
+ diagnostics: [...diagnostics]
4407
4987
  };
4408
4988
  }
4989
+ /**
4990
+ * Identity of the options that affect how a file parses, stored on its cache entry.
4991
+ * Twee files depend on `trim` and `twee2Compat`, HTML files on `trim`; every other file type
4992
+ * yields the same key for all options.
4993
+ */
4994
+ function parseOptionsKey(filename, opts) {
4995
+ const ext = normalizedFileExt(filename);
4996
+ const trim = opts.trim ?? true;
4997
+ if (ext === "htm" || ext === "html") return JSON.stringify({ trim });
4998
+ const twee2File = ext === "tw2" || ext === "twee2";
4999
+ if (!twee2File && ext !== "tw" && ext !== "twee") return "";
5000
+ return JSON.stringify({
5001
+ trim,
5002
+ twee2Compat: twee2File || (opts.twee2Compat ?? false)
5003
+ });
5004
+ }
4409
5005
  function parseFile(filename, opts) {
4410
5006
  switch (normalizedFileExt(filename)) {
4411
5007
  case "tw":
@@ -4416,7 +5012,7 @@ function parseFile(filename, opts) {
4416
5012
  twee2Compat: true
4417
5013
  });
4418
5014
  case "htm":
4419
- case "html": return parseHTMLFile(filename);
5015
+ case "html": return parseHTMLFile(filename, opts);
4420
5016
  case "css": return parseTaggedFile("stylesheet", filename);
4421
5017
  case "js": return parseTaggedFile("script", filename);
4422
5018
  case "otf":
@@ -4448,8 +5044,8 @@ function parseFile(filename, opts) {
4448
5044
  default: return;
4449
5045
  }
4450
5046
  }
4451
- function loadHTML(story, filename, diagnostics) {
4452
- const result = parseHTMLFile(filename);
5047
+ function loadHTML(story, filename, opts, diagnostics) {
5048
+ const result = parseHTMLFile(filename, opts);
4453
5049
  diagnostics.push(...result.diagnostics);
4454
5050
  for (const p of result.passages) storyAdd(story, p, diagnostics);
4455
5051
  }
@@ -4485,8 +5081,11 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
4485
5081
  continue;
4486
5082
  }
4487
5083
  currentFiles.add(filename);
4488
- const cached = cache.get(filename);
4489
- if (changedFiles && cached && !changedFiles.has(filename)) {
5084
+ const optionsKey = parseOptionsKey(filename, opts);
5085
+ const entry = cache.get(filename);
5086
+ const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
5087
+ const changed = changedFiles?.has(filename) ?? false;
5088
+ if (changedFiles && cached && !changed) {
4490
5089
  diagnostics.push(...cached.diagnostics);
4491
5090
  for (const p of cached.passages) storyAdd(story, p, diagnostics);
4492
5091
  processedFiles.add(filename);
@@ -4498,7 +5097,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
4498
5097
  } catch {
4499
5098
  continue;
4500
5099
  }
4501
- if (cached && cached.mtimeMs === mtimeMs) {
5100
+ if (cached && !changed && cached.mtimeMs === mtimeMs) {
4502
5101
  diagnostics.push(...cached.diagnostics);
4503
5102
  for (const p of cached.passages) storyAdd(story, p, diagnostics);
4504
5103
  processedFiles.add(filename);
@@ -4509,6 +5108,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
4509
5108
  if (!result) continue;
4510
5109
  cache.set(filename, {
4511
5110
  mtimeMs,
5111
+ parseOptionsKey: optionsKey,
4512
5112
  passages: result.passages,
4513
5113
  diagnostics: result.diagnostics
4514
5114
  });
@@ -4572,11 +5172,39 @@ function toTwine2HTML(story, format, startName, options) {
4572
5172
  template = template.replaceAll("{{STORY_NAME}}", () => name);
4573
5173
  }
4574
5174
  if (template.includes("{{STORY_DATA}}")) {
4575
- const data = getTwine2DataChunk(story, startName, options);
5175
+ const data = getTwine2DataChunk({
5176
+ ...story,
5177
+ twine2: {
5178
+ ...story.twine2,
5179
+ format: format.name,
5180
+ formatVersion: format.version
5181
+ }
5182
+ }, startName, options);
4576
5183
  template = template.replace("{{STORY_DATA}}", () => data);
4577
5184
  }
4578
5185
  return template;
4579
5186
  }
5187
+ const OMITTING_TAGS = [
5188
+ "Twine.private",
5189
+ "script",
5190
+ "stylesheet"
5191
+ ];
5192
+ /**
5193
+ * Why Twine 2 output leaves a passage out of its `<tw-passagedata>` elements,
5194
+ * or `undefined` when the passage is emitted (and so can be the starting passage).
5195
+ */
5196
+ function twine2PassageOmission(story, p) {
5197
+ if (p.name === "StoryTitle" || p.name === "StoryData") return {
5198
+ kind: "special-name",
5199
+ name: p.name
5200
+ };
5201
+ const tag = OMITTING_TAGS.find((t) => hasTag(p, t));
5202
+ if (tag !== void 0) return {
5203
+ kind: "tag",
5204
+ tag
5205
+ };
5206
+ if (p.name === "StorySettings" && story.twine1.settings.size === 0) return { kind: "empty-story-settings" };
5207
+ }
4580
5208
  function getTwine2DataChunk(story, startName, options) {
4581
5209
  const parts = [];
4582
5210
  let startID = "";
@@ -4599,7 +5227,7 @@ function getTwine2DataChunk(story, startName, options) {
4599
5227
  pidS++;
4600
5228
  }
4601
5229
  }
4602
- parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContent}</style>`);
5230
+ parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContentEscape(styleContent)}</style>`);
4603
5231
  let scriptContent = "";
4604
5232
  if (scripts.length === 1) scriptContent = scripts[0]?.text ?? "";
4605
5233
  else if (scripts.length > 1) {
@@ -4611,7 +5239,7 @@ function getTwine2DataChunk(story, startName, options) {
4611
5239
  pidS++;
4612
5240
  }
4613
5241
  }
4614
- parts.push(`<script role="script" id="twine-user-script" type="text/twine-javascript">${scriptContent}<\/script>`);
5242
+ parts.push(`<script role="script" id="twine-user-script" type="text/twine-javascript">${scriptContentEscape(scriptContent)}<\/script>`);
4615
5243
  const validTagColors = /* @__PURE__ */ new Set([
4616
5244
  "gray",
4617
5245
  "red",
@@ -4625,8 +5253,7 @@ function getTwine2DataChunk(story, startName, options) {
4625
5253
  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>`);
4626
5254
  pid = 1;
4627
5255
  for (const p of story.passages) {
4628
- if (p.name === "StoryTitle" || p.name === "StoryData" || hasAnyTag(p, "script", "stylesheet", "Twine.private")) continue;
4629
- if (p.name === "StorySettings" && story.twine1.settings.size === 0) continue;
5256
+ if (twine2PassageOmission(story, p) !== void 0) continue;
4630
5257
  parts.push(passageToPassagedata(p, pid, options));
4631
5258
  if (startName === p.name) startID = String(pid);
4632
5259
  pid++;
@@ -4673,423 +5300,194 @@ function toTwine1HTML(story, format, startName) {
4673
5300
  } catch {
4674
5301
  footer = "</div>\n</body>\n</html>\n";
4675
5302
  }
4676
- template += data + footer;
4677
- }
4678
- if (story.ifid) {
4679
- const safeIfid = htmlCommentSanitize(story.ifid);
4680
- if (template.includes("<div id=\"store-area\"")) template = template.replace("<div id=\"store-area\"", `<!-- UUID://${safeIfid}// --><div id="store-area"`);
4681
- else template = template.replace("<div id=\"storeArea\"", `<!-- UUID://${safeIfid}// --><div id="storeArea"`);
4682
- }
4683
- return template;
4684
- }
4685
- function getTwine1PassageChunk(story) {
4686
- const obfuscateRot13 = story.twine1.settings.get("obfuscate") === "rot13";
4687
- let data = "";
4688
- let count = 0;
4689
- for (const p of story.passages) {
4690
- if (hasTag(p, "Twine.private")) continue;
4691
- count++;
4692
- data += passageToTiddler(p, count, obfuscateRot13);
4693
- }
4694
- return {
4695
- data,
4696
- count
4697
- };
4698
- }
4699
- function tryReplaceComponent(template, placeholder, componentPath, required) {
4700
- if (!template.includes(placeholder)) return template;
4701
- try {
4702
- let content = readFileSync(componentPath, "utf-8");
4703
- if (content.charCodeAt(0) === 65279) content = content.slice(1);
4704
- return template.replace(placeholder, () => content);
4705
- } catch (e) {
4706
- if (required) throw new Error(`Required format component not found: ${componentPath}: ${e instanceof Error ? e.message : String(e)}`);
4707
- return template;
4708
- }
4709
- }
4710
- //#endregion
4711
- //#region src/output-twee.ts
4712
- function toTwee(story, outMode) {
4713
- let data = "";
4714
- for (const p of story.passages) data += passageToTwee(p, outMode);
4715
- return data;
4716
- }
4717
- //#endregion
4718
- //#region src/modules.ts
4719
- /**
4720
- * Load modules and return HTML tags to inject before </head>.
4721
- */
4722
- function loadModules(filenames) {
4723
- const processed = /* @__PURE__ */ new Set();
4724
- const headTags = [];
4725
- for (const filename of filenames) {
4726
- if (processed.has(filename)) continue;
4727
- const ext = normalizedFileExt(filename);
4728
- let tag = null;
4729
- switch (ext) {
4730
- case "css":
4731
- tag = loadModuleTagged("style", filename);
4732
- break;
4733
- case "js":
4734
- tag = loadModuleTagged("script", filename);
4735
- break;
4736
- case "otf":
4737
- case "ttf":
4738
- case "woff":
4739
- case "woff2":
4740
- tag = loadModuleFont(filename);
4741
- break;
4742
- default: continue;
4743
- }
4744
- if (tag) headTags.push(tag);
4745
- processed.add(filename);
4746
- }
4747
- return headTags.join("\n");
4748
- }
4749
- function loadModuleTagged(tag, filename) {
4750
- const source = readUTF8(filename).trim();
4751
- if (source.length === 0) return null;
4752
- return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${source}</${tag}>`;
4753
- }
4754
- function loadModuleFont(filename) {
4755
- const source = readBase64(filename);
4756
- const family = baseNameWithoutExt(filename);
4757
- const idSlug = `style-module-${slugify(family)}`;
4758
- const ext = normalizedFileExt(filename);
4759
- 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>`;
4760
- }
4761
- /**
4762
- * Inject modules and head file content before </head>.
4763
- */
4764
- function modifyHead(html, modulePaths, headFile, diagnostics) {
4765
- const parts = [];
4766
- if (modulePaths.length > 0) {
4767
- const modules = loadModules(modulePaths).trim();
4768
- if (modules.length > 0) parts.push(modules);
4769
- }
4770
- if (headFile) try {
4771
- const source = readUTF8(headFile).trim();
4772
- if (source.length > 0) parts.push(source);
4773
- } catch (e) {
4774
- diagnostics?.push({
4775
- level: "warning",
4776
- message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
4777
- });
5303
+ template += data + footer;
4778
5304
  }
4779
- if (parts.length > 0) {
4780
- parts.push("</head>");
4781
- const replacement = parts.join("\n");
4782
- return html.replace("</head>", () => replacement);
5305
+ if (story.ifid) {
5306
+ const safeIfid = htmlCommentSanitize(story.ifid);
5307
+ if (template.includes("<div id=\"store-area\"")) template = template.replace("<div id=\"store-area\"", `<!-- UUID://${safeIfid}// --><div id="store-area"`);
5308
+ else template = template.replace("<div id=\"storeArea\"", `<!-- UUID://${safeIfid}// --><div id="storeArea"`);
4783
5309
  }
4784
- return html;
5310
+ return template;
4785
5311
  }
4786
- //#endregion
4787
- //#region src/remote-formats.ts
4788
5312
  /**
4789
- * Remote story format fetching, caching, and checksum verification.
4790
- * Uses the Story Formats Archive (SFA) as the default source.
5313
+ * Why Twine 1 output leaves a passage out of its tiddlers, or `undefined` when the passage is emitted.
4791
5314
  */
4792
- const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
4793
- /** Get the cache directory for downloaded story formats. */
4794
- function getCacheDir() {
4795
- const base = process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache");
4796
- return join(base, "twee-ts", "storyformats");
4797
- }
4798
- /** In-memory index cache, keyed by URL. Cleared each compile. */
4799
- const indexCache = /* @__PURE__ */ new Map();
4800
- /** Clear the in-memory index cache. */
4801
- function clearIndexCache() {
4802
- indexCache.clear();
4803
- }
4804
- /** Validate that a JSON value is a valid SFAIndexEntry. */
4805
- function isValidEntry(val) {
4806
- if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
4807
- const obj = val;
4808
- return typeof obj.name === "string" && typeof obj.version === "string" && typeof obj.checksums === "object" && obj.checksums !== null && !Array.isArray(obj.checksums);
5315
+ function twine1PassageOmission(p) {
5316
+ return hasTag(p, "Twine.private") ? {
5317
+ kind: "tag",
5318
+ tag: "Twine.private"
5319
+ } : void 0;
4809
5320
  }
4810
- /** Validate that a JSON value conforms to the SFAIndex shape. */
4811
- function validateSFAIndex(json) {
4812
- if (typeof json !== "object" || json === null) throw new Error("SFA index is not an object");
4813
- const obj = json;
5321
+ function getTwine1PassageChunk(story) {
5322
+ const obfuscateRot13 = story.twine1.settings.get("obfuscate") === "rot13";
5323
+ let data = "";
5324
+ let count = 0;
5325
+ for (const p of story.passages) {
5326
+ if (twine1PassageOmission(p) !== void 0) continue;
5327
+ count++;
5328
+ data += passageToTiddler(p, count, obfuscateRot13);
5329
+ }
4814
5330
  return {
4815
- twine1: Array.isArray(obj["twine1"]) ? obj["twine1"].filter(isValidEntry) : [],
4816
- twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
5331
+ data,
5332
+ count
4817
5333
  };
4818
5334
  }
4819
- /** Generate a format ID from name and version. */
4820
- function makeFormatId(name, version) {
4821
- const major = parseSemver(version)?.[0] ?? "0";
4822
- return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
4823
- }
4824
- /** Fetch and parse an SFA index.json, with in-memory caching. */
4825
- async function fetchIndex(url) {
4826
- const cached = indexCache.get(url);
4827
- if (cached) return cached;
4828
- const res = await fetch(url);
4829
- if (!res.ok) throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
4830
- const data = validateSFAIndex(await res.json());
4831
- indexCache.set(url, data);
4832
- return data;
5335
+ function tryReplaceComponent(template, placeholder, componentPath, required) {
5336
+ if (!template.includes(placeholder)) return template;
5337
+ try {
5338
+ let content = readFileSync(componentPath, "utf-8");
5339
+ if (content.charCodeAt(0) === 65279) content = content.slice(1);
5340
+ return template.replace(placeholder, () => content);
5341
+ } catch (e) {
5342
+ if (required) throw new Error(`Required format component not found: ${componentPath}: ${e instanceof Error ? e.message : String(e)}`);
5343
+ return template;
5344
+ }
4833
5345
  }
5346
+ //#endregion
5347
+ //#region src/output-twee.ts
4834
5348
  /**
4835
- * Find the best matching entry in an SFA index.
4836
- * Exact version preferred, then highest version with same major.
5349
+ * Serialize the story as Twee. The StoryData passage is written from the story model rather than
5350
+ * from its loaded text, so changes made after loading (a start passage override, test mode) are kept.
5351
+ * Tweego writes the loaded text; its own normalization makes the two the same for an unchanged story.
5352
+ *
5353
+ * `addStoryData`: add a StoryData passage when the story has none, so that StoryData fields the
5354
+ * compile options set are recorded. Default: false.
4837
5355
  */
4838
- function findEntry(index, name, version) {
4839
- const wanted = parseSemver(version);
4840
- const searchArrays = [{
4841
- entries: index.twine2 ?? [],
4842
- formatType: "twine2"
4843
- }, {
4844
- entries: index.twine1 ?? [],
4845
- formatType: "twine1"
4846
- }];
4847
- let bestResult;
4848
- let bestVersion = null;
4849
- for (const { entries, formatType } of searchArrays) for (const entry of entries) {
4850
- if (entry.name.toLowerCase() !== name.toLowerCase()) continue;
4851
- const have = parseSemver(entry.version);
4852
- if (!have) continue;
4853
- if (wanted && semverCompare(have, wanted) === 0) return {
4854
- entry,
4855
- formatType
4856
- };
4857
- if (wanted === null || have[0] === wanted[0] && semverCompare(have, wanted) >= 0) {
4858
- if (!bestVersion || semverCompare(have, bestVersion) > 0) {
4859
- bestVersion = have;
4860
- bestResult = {
4861
- entry,
4862
- formatType
4863
- };
4864
- }
4865
- }
4866
- }
4867
- return bestResult;
4868
- }
4869
- /** Verify SHA-256 checksum using Web Crypto API (Node 22 built-in). */
4870
- async function verifySHA256(content, expectedHex) {
4871
- const digest = await crypto.subtle.digest("SHA-256", content);
4872
- return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("") === expectedHex.toLowerCase();
4873
- }
4874
- /** Derive the download URL for a format.js from the index URL and entry. */
4875
- function getDownloadUrl(indexUrl, entry, formatType) {
4876
- return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
5356
+ function toTwee(story, outMode, options = {}) {
5357
+ return withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => passageToTwee(p, outMode)).join("");
4877
5358
  }
4878
- /** Download a format, verify its checksum, write to cache, and return StoryFormatInfo. */
4879
- async function fetchAndCacheFormat(entry, downloadUrl) {
4880
- const res = await fetch(downloadUrl);
4881
- if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
4882
- const text = await res.text();
4883
- const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
4884
- if (checksumKey) {
4885
- const expected = entry.checksums[checksumKey];
4886
- if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
4887
- if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
4888
- }
4889
- const id = makeFormatId(entry.name, entry.version);
4890
- const data = parseFormatJSON(text, id);
4891
- if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
4892
- const cacheDir = getCacheDir();
4893
- const formatDir = join(cacheDir, entry.name, entry.version);
4894
- mkdirSync(formatDir, { recursive: true });
4895
- const formatPath = join(formatDir, "format.js");
4896
- writeFileSync(formatPath, text, "utf-8");
4897
- return {
4898
- id,
4899
- filename: formatPath,
4900
- isTwine2: true,
4901
- name: data.name,
4902
- version: data.version,
4903
- proofing: data.proofing ?? false
5359
+ function withEffectiveStoryData(story, addStoryData) {
5360
+ const storyData = {
5361
+ name: "StoryData",
5362
+ tags: [],
5363
+ text: marshalStoryData(story)
4904
5364
  };
5365
+ const index = story.passages.findIndex((p) => p.name === "StoryData");
5366
+ if (index !== -1) return story.passages.map((p, i) => i === index && isStoryDataJSON(p.text) ? {
5367
+ ...p,
5368
+ text: storyData.text
5369
+ } : p);
5370
+ if (!addStoryData) return story.passages;
5371
+ const at = story.passages[0]?.name === "StoryTitle" ? 1 : 0;
5372
+ return [
5373
+ ...story.passages.slice(0, at),
5374
+ storyData,
5375
+ ...story.passages.slice(at)
5376
+ ];
4905
5377
  }
4906
- /** Download a direct format.js URL, parse its JSON, cache it, and return StoryFormatInfo. */
4907
- async function fetchDirectFormat(url) {
4908
- const res = await fetch(url);
4909
- if (!res.ok) throw new Error(`Failed to download format from ${url}: ${res.status} ${res.statusText}`);
4910
- const text = await res.text();
4911
- const data = parseFormatJSON(text, "direct-url");
4912
- if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
4913
- const id = makeFormatId(data.name, data.version);
4914
- const cacheDir = getCacheDir();
4915
- const formatDir = join(cacheDir, data.name, data.version);
4916
- mkdirSync(formatDir, { recursive: true });
4917
- const formatPath = join(formatDir, "format.js");
4918
- writeFileSync(formatPath, text, "utf-8");
4919
- return {
4920
- id,
4921
- filename: formatPath,
4922
- isTwine2: true,
4923
- name: data.name,
4924
- version: data.version,
4925
- proofing: data.proofing ?? false
4926
- };
5378
+ /** Whether the text loaded as StoryData. Text that did not is kept as written, for the author to fix. */
5379
+ function isStoryDataJSON(text) {
5380
+ return unmarshalStoryData(createStory(), text) === null;
4927
5381
  }
5382
+ //#endregion
5383
+ //#region src/modules.ts
4928
5384
  /**
4929
- * Try to resolve a remote story format by name and version.
4930
- * 1. Try direct format URLs
4931
- * 2. Try custom index URLs
4932
- * 3. Try default SFA indices
5385
+ * Load modules and return HTML tags to inject before </head>.
4933
5386
  */
4934
- async function resolveRemoteFormat(name, version, indices, urls) {
4935
- let lastError;
4936
- if (urls) for (const url of urls) try {
4937
- const info = await fetchDirectFormat(url);
4938
- if (info.name.toLowerCase() === name.toLowerCase()) {
4939
- const wanted = parseSemver(version);
4940
- const have = parseSemver(info.version);
4941
- if (!wanted || have && have[0] === wanted[0] && semverCompare(have, wanted) >= 0) return info;
4942
- }
4943
- } catch (e) {
4944
- lastError = e instanceof Error ? e : new Error(String(e));
4945
- }
4946
- const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
4947
- for (const indexUrl of allIndices) try {
4948
- const result = findEntry(await fetchIndex(indexUrl), name, version);
4949
- if (result) {
4950
- const cached = getCachedFormat(result.entry.name, result.entry.version);
4951
- if (cached) return cached;
4952
- const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
4953
- return await fetchAndCacheFormat(result.entry, downloadUrl);
4954
- }
4955
- } catch (e) {
4956
- lastError = e instanceof Error ? e : new Error(String(e));
4957
- }
4958
- if (lastError) throw lastError;
4959
- }
4960
- /** Check if a format is already in the local cache. */
4961
- function getCachedFormat(name, version) {
4962
- const formatPath = join(getCacheDir(), name, version, "format.js");
4963
- try {
4964
- if (!existsSync(formatPath)) return void 0;
4965
- const source = readFileSync(formatPath, "utf-8");
4966
- const id = makeFormatId(name, version);
4967
- const data = parseFormatJSON(source, id);
4968
- if (!data) return void 0;
4969
- return {
4970
- id,
4971
- filename: formatPath,
4972
- isTwine2: true,
4973
- name: data.name,
4974
- version: data.version,
4975
- proofing: data.proofing ?? false
4976
- };
4977
- } catch {
4978
- return;
4979
- }
4980
- }
4981
- /** Discover all cached remote formats. Returns a Map like discoverFormats(). */
4982
- function discoverCachedFormats() {
4983
- const formats = /* @__PURE__ */ new Map();
4984
- const cacheDir = getCacheDir();
4985
- try {
4986
- if (!existsSync(cacheDir)) return formats;
4987
- } catch {
4988
- return formats;
4989
- }
4990
- let names;
4991
- try {
4992
- names = readdirSync(cacheDir);
4993
- } catch {
4994
- return formats;
4995
- }
4996
- for (const name of names) {
4997
- const nameDir = join(cacheDir, name);
4998
- try {
4999
- if (!statSync(nameDir).isDirectory()) continue;
5000
- } catch {
5001
- continue;
5002
- }
5003
- let versions;
5004
- try {
5005
- versions = readdirSync(nameDir);
5006
- } catch {
5007
- continue;
5008
- }
5009
- for (const version of versions) {
5010
- const info = getCachedFormat(name, version);
5011
- if (info) formats.set(`${info.id}-${version}`, info);
5012
- }
5013
- }
5014
- return formats;
5015
- }
5016
- /** List all cached formats with size and modification date. */
5017
- function listCachedFormats() {
5018
- const cacheDir = getCacheDir();
5019
- const entries = [];
5020
- try {
5021
- if (!existsSync(cacheDir)) return entries;
5022
- } catch {
5023
- return entries;
5024
- }
5025
- let names;
5026
- try {
5027
- names = readdirSync(cacheDir);
5028
- } catch {
5029
- return entries;
5030
- }
5031
- for (const name of names) {
5032
- const nameDir = join(cacheDir, name);
5033
- try {
5034
- if (!statSync(nameDir).isDirectory()) continue;
5035
- } catch {
5036
- continue;
5037
- }
5038
- let versions;
5039
- try {
5040
- versions = readdirSync(nameDir);
5041
- } catch {
5042
- continue;
5043
- }
5044
- for (const version of versions) {
5045
- const formatPath = join(nameDir, version, "format.js");
5046
- try {
5047
- const stat = statSync(formatPath);
5048
- entries.push({
5049
- name,
5050
- version,
5051
- sizeBytes: stat.size,
5052
- modifiedAt: stat.mtime
5053
- });
5054
- } catch {}
5387
+ function loadModules(filenames) {
5388
+ const processed = /* @__PURE__ */ new Set();
5389
+ const headTags = [];
5390
+ for (const filename of filenames) {
5391
+ if (processed.has(filename)) continue;
5392
+ const ext = normalizedFileExt(filename);
5393
+ let tag = null;
5394
+ switch (ext) {
5395
+ case "css":
5396
+ tag = loadModuleTagged("style", filename);
5397
+ break;
5398
+ case "js":
5399
+ tag = loadModuleTagged("script", filename);
5400
+ break;
5401
+ case "otf":
5402
+ case "ttf":
5403
+ case "woff":
5404
+ case "woff2":
5405
+ tag = loadModuleFont(filename);
5406
+ break;
5407
+ default: continue;
5055
5408
  }
5409
+ if (tag) headTags.push(tag);
5410
+ processed.add(filename);
5056
5411
  }
5057
- return entries;
5412
+ return headTags.join("\n");
5058
5413
  }
5059
- /** Clear all cached formats, or only those matching a given name. Returns the number of entries removed. */
5060
- function clearCachedFormats(name) {
5061
- const cacheDir = getCacheDir();
5062
- if (!existsSync(cacheDir)) return 0;
5063
- if (!name) {
5064
- const count = listCachedFormats().length;
5065
- rmSync(cacheDir, {
5066
- recursive: true,
5067
- force: true
5414
+ function loadModuleTagged(tag, filename) {
5415
+ const source = readUTF8(filename).trim();
5416
+ if (source.length === 0) return null;
5417
+ return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
5418
+ }
5419
+ function loadModuleFont(filename) {
5420
+ const source = readBase64(filename);
5421
+ const family = baseNameWithoutExt(filename);
5422
+ const idSlug = `style-module-${slugify(family)}`;
5423
+ const ext = normalizedFileExt(filename);
5424
+ 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>`;
5425
+ }
5426
+ /**
5427
+ * Inject modules and head file content before </head>.
5428
+ */
5429
+ function modifyHead(html, modulePaths, headFile, diagnostics) {
5430
+ const parts = [];
5431
+ if (modulePaths.length > 0) {
5432
+ const modules = loadModules(modulePaths).trim();
5433
+ if (modules.length > 0) parts.push(modules);
5434
+ }
5435
+ if (headFile) try {
5436
+ const source = readUTF8(headFile).trim();
5437
+ if (source.length > 0) parts.push(source);
5438
+ } catch (e) {
5439
+ diagnostics?.push({
5440
+ level: "warning",
5441
+ message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
5068
5442
  });
5069
- return count;
5070
5443
  }
5071
- const nameDir = join(cacheDir, name);
5072
- if (!existsSync(nameDir)) return 0;
5073
- let versions;
5074
- try {
5075
- versions = readdirSync(nameDir);
5076
- } catch {
5077
- return 0;
5444
+ if (parts.length > 0) {
5445
+ parts.push("</head>");
5446
+ const replacement = parts.join("\n");
5447
+ return html.replace("</head>", () => replacement);
5078
5448
  }
5079
- const count = versions.length;
5080
- rmSync(nameDir, {
5081
- recursive: true,
5082
- force: true
5083
- });
5084
- return count;
5449
+ return html;
5085
5450
  }
5086
- /** Get total cache size in bytes and format count. */
5087
- function getCacheSize() {
5088
- const entries = listCachedFormats();
5089
- return {
5090
- totalBytes: entries.reduce((sum, e) => sum + e.sizeBytes, 0),
5091
- count: entries.length
5092
- };
5451
+ //#endregion
5452
+ //#region src/passage-omission.ts
5453
+ /**
5454
+ * Why the `target` output leaves `passage` out of its passage elements, or `undefined` when it
5455
+ * emits the passage (and so a link or the start can name it).
5456
+ */
5457
+ function passageOmission(story, passage, target) {
5458
+ switch (target) {
5459
+ case "twine2": return twine2PassageOmission(story, passage);
5460
+ case "twine1": return twine1PassageOmission(passage);
5461
+ default: throw new Error(`unhandled passage output target: ${String(target)}`);
5462
+ }
5463
+ }
5464
+ /** Why a passage is left out, as a predicate: `Passage "X" ${describeOmission(o)}`. */
5465
+ function describeOmission(omission) {
5466
+ switch (omission.kind) {
5467
+ case "special-name": return `is the special "${omission.name}" passage`;
5468
+ case "tag": return `is tagged "${omission.tag}"`;
5469
+ case "empty-story-settings": return "is an empty \"StorySettings\" passage";
5470
+ default: throw new Error(`unhandled passage omission: ${JSON.stringify(omission)}`);
5471
+ }
5472
+ }
5473
+ //#endregion
5474
+ //#region src/start-passage.ts
5475
+ /**
5476
+ * Check that `startName` names a passage the `target` output emits.
5477
+ * Returns the error diagnostics to report (empty when the start is usable).
5478
+ */
5479
+ function startPassageDiagnostics(story, startName, target) {
5480
+ const passage = story.passages.find((p) => p.name === startName);
5481
+ if (passage === void 0) return [{
5482
+ level: "error",
5483
+ message: `Starting passage "${startName}" not found.`
5484
+ }];
5485
+ const omission = passageOmission(story, passage, target);
5486
+ if (omission === void 0) return [];
5487
+ return [{
5488
+ level: "error",
5489
+ message: `Starting passage "${startName}" ${describeOmission(omission)}, so it is left out of the story data. Choose a story passage.`
5490
+ }];
5093
5491
  }
5094
5492
  //#endregion
5095
5493
  //#region src/compiler.ts
@@ -5110,38 +5508,44 @@ var TweeTsError = class extends Error {
5110
5508
  }
5111
5509
  };
5112
5510
  /**
5113
- * Compile Twee sources to HTML, Twee, or JSON.
5114
- */
5115
- async function compile(options) {
5116
- return buildOutput(options);
5117
- }
5118
- /**
5119
- * Compile and write to a file.
5511
+ * Builds as compileToFile() does for `outFile`, without writing it: the output may sit
5512
+ * inside a source folder, so its last build is left out of the sources and modules and
5513
+ * never read back. The caller decides whether the result is written. With no `outFile`
5514
+ * (output to stdout, or none), this is compile().
5515
+ *
5516
+ * Internal, for the CLI; not part of the public API.
5120
5517
  */
5121
- async function compileToFile(options) {
5122
- const result = await compile(options);
5123
- writeFileSync(options.outFile, result.output, "utf-8");
5124
- return result;
5518
+ async function compileForOutputFile(options, outFile) {
5519
+ return buildOutput(options, void 0, void 0, outFile);
5125
5520
  }
5126
5521
  /**
5127
- * Watch for file changes and recompile.
5522
+ * watch(), writing to `outFile` only the builds `shouldWrite` accepts. A rejected build
5523
+ * leaves the output file as it was; `onBuild` receives every build either way.
5524
+ *
5525
+ * Internal, for the CLI, which keeps the last good output when a rebuild reports errors;
5526
+ * not part of the public API.
5128
5527
  */
5129
- async function watch$1(options) {
5528
+ async function watchWithWriteFilter(options, shouldWrite) {
5130
5529
  const controller = new AbortController();
5131
5530
  const cache = /* @__PURE__ */ new Map();
5132
5531
  const filePaths = options.sources.filter((s) => typeof s === "string");
5133
5532
  const modulePaths = options.modules ?? [];
5134
- const allPaths = [...filePaths, ...modulePaths];
5533
+ const headPaths = options.headFile ? [options.headFile] : [];
5534
+ const allPaths = [
5535
+ ...filePaths,
5536
+ ...modulePaths,
5537
+ ...headPaths
5538
+ ];
5135
5539
  const exclude = options.exclude ?? [];
5136
- const moduleRoots = modulePaths.map((p) => resolve(p));
5540
+ const notExcludable = [...modulePaths, ...headPaths].map((p) => resolve(p));
5137
5541
  const ignore = (filename) => {
5138
5542
  if (!isExcluded(filename, exclude)) return false;
5139
5543
  const abs = resolve(filename);
5140
- return !moduleRoots.some((root) => abs === root || abs.startsWith(root + sep));
5544
+ return !notExcludable.some((root) => abs === root || abs.startsWith(root + sep));
5141
5545
  };
5142
5546
  const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
5143
- buildOutput(options, cache, changedFiles).then((result) => {
5144
- writeFileSync(options.outFile, result.output, "utf-8");
5547
+ buildOutput(options, cache, changedFiles, options.outFile).then((result) => {
5548
+ if (shouldWrite(result)) writeFileSync(options.outFile, result.output, "utf-8");
5145
5549
  options.onBuild?.(result);
5146
5550
  }).catch((e) => {
5147
5551
  options.onError?.(e instanceof Error ? e : new Error(String(e)));
@@ -5150,7 +5554,11 @@ async function watch$1(options) {
5150
5554
  controller.signal.addEventListener("abort", () => handle.close());
5151
5555
  return controller;
5152
5556
  }
5153
- async function buildOutput(options, cache, changedFiles) {
5557
+ /**
5558
+ * `outFile`: the file the build is written to, which source and module discovery
5559
+ * skip (as Tweego does), so an output inside a source folder is never loaded back.
5560
+ */
5561
+ async function buildOutput(options, cache, changedFiles, outFile) {
5154
5562
  const diagnostics = [];
5155
5563
  const outputMode = options.outputMode ?? "html";
5156
5564
  const trim = options.trim ?? true;
@@ -5163,7 +5571,7 @@ async function buildOutput(options, cache, changedFiles) {
5163
5571
  const inlineSources = [];
5164
5572
  for (const source of options.sources) if (typeof source === "string") filePaths.push(source);
5165
5573
  else inlineSources.push(source);
5166
- const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths, void 0, options.exclude);
5574
+ const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths, outFile, options.exclude);
5167
5575
  diagnostics.push(...sourcePathDiagnostics);
5168
5576
  const story = createStory();
5169
5577
  const processedFiles = /* @__PURE__ */ new Set();
@@ -5181,40 +5589,19 @@ async function buildOutput(options, cache, changedFiles) {
5181
5589
  }, diagnostics);
5182
5590
  if (options.tagAliases) story.passages = applyTagAliases(story.passages, options.tagAliases);
5183
5591
  let format;
5184
- let formatId = options.formatId ?? "";
5185
- if (outputMode === "html") {
5186
- const formats = discoverFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true));
5187
- if (!formatId && story.twine2.format) formatId = getFormatIdByNameAndVersion(formats, story.twine2.format, story.twine2.formatVersion) ?? "";
5188
- if (!formatId) formatId = DEFAULT_FORMAT_ID;
5189
- format = formats.get(formatId);
5190
- if (!format && !noRemote) {
5191
- const remoteName = story.twine2.format || formatId;
5192
- const remoteVersion = story.twine2.formatVersion || "";
5193
- try {
5194
- format = await resolveRemoteFormat(remoteName, remoteVersion, options.formatIndices, options.formatUrls);
5195
- } catch (e) {
5196
- diagnostics.push({
5197
- level: "warning",
5198
- message: `Remote format fetch failed for "${remoteName}": ${e instanceof Error ? e.message : String(e)}`
5199
- });
5200
- }
5201
- }
5202
- if (!format) {
5203
- const reason = noRemote ? " (remote fetching disabled)" : "";
5204
- diagnostics.push({
5205
- level: "error",
5206
- message: `Story format "${formatId}" is not available${reason}. Found: ${[...formats.keys()].join(", ") || "none"}`
5207
- });
5208
- }
5209
- }
5210
- const startName = options.startPassage || story.twine2.start || DEFAULT_START_NAME;
5592
+ if (outputMode === "html") format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
5593
+ ...options,
5594
+ noRemote
5595
+ }, diagnostics);
5596
+ if (options.startPassage) story.twine2.start = options.startPassage;
5597
+ const startName = story.twine2.start || DEFAULT_START_NAME;
5211
5598
  if (testMode) story.twine2.options.set("debug", true);
5212
5599
  ensureIFID(story, diagnostics);
5213
5600
  let output;
5214
5601
  switch (outputMode) {
5215
5602
  case "twee3":
5216
5603
  case "twee1":
5217
- output = toTwee(story, outputMode);
5604
+ output = toTwee(story, outputMode, { addStoryData: Boolean(options.startPassage) || testMode });
5218
5605
  break;
5219
5606
  case "twine2-archive":
5220
5607
  output = toTwine2Archive(story, startName, { sourceInfo });
@@ -5226,10 +5613,7 @@ async function buildOutput(options, cache, changedFiles) {
5226
5613
  output = storyToJSON(story);
5227
5614
  break;
5228
5615
  case "html": {
5229
- if (!storyHas(story, startName)) diagnostics.push({
5230
- level: "error",
5231
- message: `Starting passage "${startName}" not found.`
5232
- });
5616
+ diagnostics.push(...startPassageDiagnostics(story, startName, format?.isTwine2 === false ? "twine1" : "twine2"));
5233
5617
  if (!format) throw new TweeTsError("No story format available for HTML output.", diagnostics);
5234
5618
  if (format.isTwine2) output = toTwine2HTML(story, format, startName, { sourceInfo });
5235
5619
  else {
@@ -5239,7 +5623,7 @@ async function buildOutput(options, cache, changedFiles) {
5239
5623
  });
5240
5624
  output = toTwine1HTML(story, format, startName);
5241
5625
  }
5242
- const modules = getFilenames(options.modules ?? []);
5626
+ const modules = getFilenames(options.modules ?? [], outFile);
5243
5627
  diagnostics.push(...modules.diagnostics);
5244
5628
  output = modifyHead(output, modules.filenames, options.headFile, diagnostics);
5245
5629
  break;
@@ -5659,19 +6043,237 @@ function endOfRegularExpression(source, pos) {
5659
6043
  };
5660
6044
  }
5661
6045
  //#endregion
6046
+ //#region src/link-markup.ts
6047
+ /** Records that the reading has looked at the characters before `pos`, and returns `result`. */
6048
+ function readTo(reader, pos, result) {
6049
+ reader.scanned = Math.max(reader.scanned, pos);
6050
+ return result;
6051
+ }
6052
+ /**
6053
+ * Reads link or image markup that starts at `start` in `text`, or returns `undefined` where
6054
+ * SugarCube rejects it (and so prints the text instead).
6055
+ *
6056
+ * `budget.left`, if given, is how many characters the reading may look at; it is reduced by as
6057
+ * many as were read, and once it runs out, every reading fails. Unclosed markup is read to the end
6058
+ * of its line, so reading every `[[` of a long line that never closes one would otherwise take
6059
+ * quadratic time. A reading is charged for the characters it looked at, up to where it ended or
6060
+ * failed (a line feed, say), and no more, so markup left unclosed on short lines costs little.
6061
+ */
6062
+ function readSquareBracketedMarkup(text, start, budget = { left: Infinity }) {
6063
+ const reader = {
6064
+ text,
6065
+ limit: Math.min(text.length, start + Math.max(budget.left, 0)),
6066
+ scanned: start
6067
+ };
6068
+ const markup = readMarkup(reader, start);
6069
+ budget.left -= Math.max(reader.scanned, markup?.end ?? start) - start;
6070
+ return markup;
6071
+ }
6072
+ function readMarkup(reader, start) {
6073
+ const { text } = reader;
6074
+ if (text[start] !== "[") return;
6075
+ let pos = start + 1;
6076
+ let type;
6077
+ if (text[pos] === "[") {
6078
+ type = "link";
6079
+ pos += 1;
6080
+ } else {
6081
+ if (text[pos] === "<" || text[pos] === ">") pos += 1;
6082
+ if (!/^[Ii][Mm][Gg]\[/.test(text.slice(pos, pos + 4))) return;
6083
+ type = "image";
6084
+ pos += 4;
6085
+ }
6086
+ const core = readCoreComponents(reader, pos);
6087
+ if (core === void 0) return;
6088
+ if (type === "link") {
6089
+ if (!core.end.last) {
6090
+ const setterEnd = readSetter(reader, core.end.next);
6091
+ if (setterEnd === void 0) return;
6092
+ return {
6093
+ type,
6094
+ link: linkName(core.link),
6095
+ end: setterEnd
6096
+ };
6097
+ }
6098
+ return {
6099
+ type,
6100
+ link: linkName(core.link),
6101
+ end: core.end.next
6102
+ };
6103
+ }
6104
+ if (core.end.last) return {
6105
+ type,
6106
+ link: void 0,
6107
+ end: core.end.next
6108
+ };
6109
+ const linkEnd = readComponent(reader, core.end.next, false);
6110
+ if (linkEnd === void 0) return;
6111
+ const link = linkName(text.slice(core.end.next, linkEnd.textEnd));
6112
+ if (linkEnd.last) return {
6113
+ type,
6114
+ link,
6115
+ end: linkEnd.next
6116
+ };
6117
+ const setterEnd = readSetter(reader, linkEnd.next);
6118
+ return setterEnd === void 0 ? void 0 : {
6119
+ type,
6120
+ link,
6121
+ end: setterEnd
6122
+ };
6123
+ }
6124
+ /** The link component as SugarCube keeps it: trimmed, and without a leading `~`. */
6125
+ function linkName(component) {
6126
+ const trimmed = component.trim();
6127
+ return trimmed.startsWith("~") ? trimmed.slice(1) : trimmed;
6128
+ }
6129
+ /**
6130
+ * Reads the first components: the text and the link of link markup, or the title and the source
6131
+ * of image markup, divided by the first delimiter. `link` is the text of the link (or source)
6132
+ * component, untrimmed.
6133
+ */
6134
+ function readCoreComponents(reader, from) {
6135
+ const { text } = reader;
6136
+ let delimiter = "none";
6137
+ let componentStart = from;
6138
+ let link;
6139
+ let depth = 2;
6140
+ let pos = from;
6141
+ while (pos < reader.limit) {
6142
+ const ch = text[pos];
6143
+ pos += 1;
6144
+ switch (ch) {
6145
+ case "\n": return readTo(reader, pos, void 0);
6146
+ case "\"": {
6147
+ const end = readQuoted(reader, pos, "\"");
6148
+ if (end === void 0) return;
6149
+ pos = end;
6150
+ break;
6151
+ }
6152
+ case "|":
6153
+ if (delimiter === "none") {
6154
+ delimiter = "ltr";
6155
+ componentStart = pos;
6156
+ }
6157
+ break;
6158
+ case "-":
6159
+ if (delimiter === "none" && text[pos] === ">") {
6160
+ delimiter = "ltr";
6161
+ pos += 1;
6162
+ componentStart = pos;
6163
+ }
6164
+ break;
6165
+ case "<":
6166
+ if (delimiter === "none" && text[pos] === "-") {
6167
+ delimiter = "rtl";
6168
+ link = text.slice(componentStart, pos - 1);
6169
+ pos += 1;
6170
+ componentStart = pos;
6171
+ }
6172
+ break;
6173
+ case "[":
6174
+ depth += 1;
6175
+ break;
6176
+ case "]":
6177
+ depth -= 1;
6178
+ if (depth === 1) {
6179
+ const end = componentEnd(reader, pos);
6180
+ if (end === void 0) return;
6181
+ return {
6182
+ link: link ?? text.slice(componentStart, end.textEnd),
6183
+ end
6184
+ };
6185
+ }
6186
+ }
6187
+ }
6188
+ return readTo(reader, pos, void 0);
6189
+ }
6190
+ /**
6191
+ * Reads a component after `][` that holds no delimiter: an image's link (`setter` false) or a
6192
+ * setter, in which single quotes also quote.
6193
+ */
6194
+ function readComponent(reader, from, setter) {
6195
+ const { text } = reader;
6196
+ let depth = 2;
6197
+ let pos = from;
6198
+ while (pos < reader.limit) {
6199
+ const ch = text[pos];
6200
+ pos += 1;
6201
+ switch (ch) {
6202
+ case "\n": return readTo(reader, pos, void 0);
6203
+ case "\"":
6204
+ case "'": {
6205
+ if (ch === "'" && !setter) break;
6206
+ const end = readQuoted(reader, pos, ch);
6207
+ if (end === void 0) return;
6208
+ pos = end;
6209
+ break;
6210
+ }
6211
+ case "[":
6212
+ depth += 1;
6213
+ break;
6214
+ case "]":
6215
+ depth -= 1;
6216
+ if (depth === 1) return componentEnd(reader, pos);
6217
+ }
6218
+ }
6219
+ return readTo(reader, pos, void 0);
6220
+ }
6221
+ /** Reads a setter, the last component; returns the index after its closing `]]`. */
6222
+ function readSetter(reader, from) {
6223
+ const end = readComponent(reader, from, true);
6224
+ return end?.last === true ? end.next : void 0;
6225
+ }
6226
+ /** What the `]` just before `pos`, which brings the depth back to one, ends; `undefined` if malformed. */
6227
+ function componentEnd(reader, pos) {
6228
+ switch (reader.text[pos]) {
6229
+ case "[": return readTo(reader, pos + 1, {
6230
+ textEnd: pos - 1,
6231
+ next: pos + 1,
6232
+ last: false
6233
+ });
6234
+ case "]": return readTo(reader, pos + 1, {
6235
+ textEnd: pos - 1,
6236
+ next: pos + 1,
6237
+ last: true
6238
+ });
6239
+ default: return readTo(reader, pos + 1, void 0);
6240
+ }
6241
+ }
6242
+ /**
6243
+ * Reads a quoted string from just after its opening quote; a backslash escapes the character
6244
+ * after it. A line feed, or the end of the text, ends it unterminated. Returns the index after the
6245
+ * closing quote.
6246
+ */
6247
+ function readQuoted(reader, from, quote) {
6248
+ const { text } = reader;
6249
+ let pos = from;
6250
+ while (pos < reader.limit) {
6251
+ const ch = text[pos];
6252
+ pos += 1;
6253
+ if (ch === quote) return pos;
6254
+ if (ch === "\n") return readTo(reader, pos, void 0);
6255
+ if (ch === "\\") {
6256
+ if (pos >= reader.limit || text[pos] === "\n") return readTo(reader, Math.min(pos + 1, reader.limit), void 0);
6257
+ pos += 1;
6258
+ }
6259
+ }
6260
+ return readTo(reader, pos, void 0);
6261
+ }
6262
+ //#endregion
5662
6263
  //#region src/sugarcube-macros.ts
5663
6264
  /**
5664
- * Reads SugarCube 2 macro calls the way SugarCube 2.37.3 does, so the link check sees the same
5665
- * passage names that `<<link>>` and `<<goto>>` use when the story plays.
6265
+ * Reads SugarCube 2 macro calls and link markup the way SugarCube 2.37.3 does, so the link check
6266
+ * sees the same passage names that `[[…]]` links, `<<link>>` and `<<goto>>` use when the story
6267
+ * plays.
5666
6268
  *
5667
6269
  * Each step follows a part of SugarCube's macro parser:
5668
6270
  * - `findMacroTags` finds where a tag starts and ends (the parser's `lookahead` pattern);
5669
6271
  * - `parseMacroArgs` splits the text after the macro name into arguments (`parseArgs`);
5670
6272
  * - quoted arguments are evaluated as strict-mode JavaScript strings (`evalStringLiteral`).
5671
6273
  *
6274
+ * Link and image markup is read as `parseSquareBracketedMarkup` reads it (see `link-markup.ts`).
5672
6275
  * Of the rest of the wikifier, only comments, `<script>` elements and `<<script>>` bodies are
5673
- * told apart; other markup, such as verbatim text, is read as markup (see
5674
- * `findMacroPassageLinks`).
6276
+ * told apart; other markup, such as verbatim text, is read as markup (see `findPassageLinks`).
5675
6277
  */
5676
6278
  const NAME_RE = /\/?[A-Za-z][\w-]*|[=-]/y;
5677
6279
  const SPACES_RE = /\s*/y;
@@ -5716,17 +6318,22 @@ const RAW_ARGUMENT_MACROS = /* @__PURE__ */ new Set([
5716
6318
  * levels; the limit keeps the work linear in the size of the text on any input.
5717
6319
  */
5718
6320
  const MAX_STRING_DEPTH = 10;
5719
- /** A context for reading `text`: the closer search may read four times it, plus an allowance. */
6321
+ /**
6322
+ * A context for reading `text`: the closer search, and the markup reading, may each read four
6323
+ * times it, plus an allowance.
6324
+ */
5720
6325
  function readContext(text) {
6326
+ const allowance = () => ({ left: 4 * text.length + 1e5 });
5721
6327
  return {
5722
6328
  depth: 0,
5723
- budget: { left: 4 * text.length + 1e5 }
6329
+ budget: allowance(),
6330
+ markupBudget: allowance()
5724
6331
  };
5725
6332
  }
5726
6333
  function deeper(context) {
5727
6334
  return {
5728
- depth: context.depth + 1,
5729
- budget: context.budget
6335
+ ...context,
6336
+ depth: context.depth + 1
5730
6337
  };
5731
6338
  }
5732
6339
  /** Index of the first match of a global `re` at or after `from`, or -1. */
@@ -5956,7 +6563,10 @@ function lexArgument(raw, pos) {
5956
6563
  case "[": {
5957
6564
  const end = lexSquareBracketed(raw, pos + 1);
5958
6565
  return end === void 0 ? void 0 : {
5959
- arg: { kind: "markup" },
6566
+ arg: {
6567
+ kind: "markup",
6568
+ text: raw.slice(pos, end)
6569
+ },
5960
6570
  end
5961
6571
  };
5962
6572
  }
@@ -6100,13 +6710,44 @@ function ownPassageLink(tag, lexed) {
6100
6710
  const arg = passageArgument(tag.name, lexed);
6101
6711
  const passage = arg === void 0 ? void 0 : passageName(arg);
6102
6712
  return passage === void 0 ? void 0 : {
6103
- macro: tag.name,
6713
+ via: tag.name,
6104
6714
  passage
6105
6715
  };
6106
6716
  }
6107
6717
  /**
6108
- * The passages a tag names: its own, if it is a `<<link>>` or `<<goto>>` call, then those of the
6109
- * calls inside the strings of its arguments, which the macro may print or pass on
6718
+ * The passage a link's destination names. SugarCube evaluates a destination that no passage is
6719
+ * named by, and a quoted string gives its value (`[[Go|"Room"]]`), unless that is blank.
6720
+ */
6721
+ function linkDestination(link) {
6722
+ const value = evalStringLiteral(link);
6723
+ return value === void 0 || value.trim() === "" ? link : value;
6724
+ }
6725
+ /**
6726
+ * The passage that link markup from `start` in `text` names, and where the markup ends, or
6727
+ * `undefined` where SugarCube rejects the markup. Image markup names none here, nor does markup
6728
+ * with a template literal's `${…}` in it, whose passage is known only in play.
6729
+ */
6730
+ function linkMarkupAt(text, start, budget) {
6731
+ const markup = readSquareBracketedMarkup(text, start, budget);
6732
+ if (markup === void 0) return;
6733
+ const passage = markup.type === "link" && !text.slice(start, markup.end).includes("﷐") ? markup.link : void 0;
6734
+ return {
6735
+ link: passage === void 0 ? void 0 : {
6736
+ via: "markup",
6737
+ passage: linkDestination(passage)
6738
+ },
6739
+ end: markup.end
6740
+ };
6741
+ }
6742
+ /** The passage that a link markup argument names, if SugarCube reads the whole argument as markup. */
6743
+ function markupArgumentLinks(raw) {
6744
+ const markup = linkMarkupAt(raw, 0);
6745
+ return markup?.link === void 0 || markup.end !== raw.length ? [] : [markup.link];
6746
+ }
6747
+ /**
6748
+ * The passages a tag names: its own, if it is a `<<link>>` or `<<goto>>` call, then those of its
6749
+ * link markup arguments (`<<button [[Go|Room]]>>`), and those of the links and calls inside the
6750
+ * strings of its arguments, which the macro may print or pass on
6110
6751
  * (`<<set _out to '<<link "Go" "Room">><</link>>'>>`). The arguments of the macros SugarCube
6111
6752
  * doesn't split are read as JavaScript; in others, a backquoted expression is.
6112
6753
  */
@@ -6119,7 +6760,7 @@ function tagPassageLinks(tag, context) {
6119
6760
  switch (arg.kind) {
6120
6761
  case "string": return markupPassageLinks(arg.value, deeper(context));
6121
6762
  case "expression": return javaScriptPassageLinks(arg.code, context);
6122
- case "markup":
6763
+ case "markup": return markupArgumentLinks(arg.text);
6123
6764
  case "word": return [];
6124
6765
  default: throw new Error(`unhandled macro argument: ${JSON.stringify(arg)}`);
6125
6766
  }
@@ -6129,16 +6770,26 @@ function tagPassageLinks(tag, context) {
6129
6770
  /**
6130
6771
  * The passage named by a `<<link>>` or `<<goto>>` that appears unquoted inside another tag's
6131
6772
  * arguments, at `start`. SugarCube never sees such an outer "tag" when its `<<` lies in text it
6132
- * reads as something else, such as link markup (`[[<<-- Back|Prev]]`); the call inside it then
6133
- * runs. The call ends where the outer tag ends.
6773
+ * reads as something else, such as verbatim text (`{{{<<if}}}`); the call inside it then runs.
6774
+ * The call ends where the outer tag ends.
6134
6775
  */
6135
6776
  function innerPassageLink(matchTagAt, start) {
6136
6777
  const inner = matchTagAt(start)?.tag;
6137
6778
  const lexed = inner === void 0 ? void 0 : lexMacroArgs(inner.args);
6138
6779
  return inner === void 0 || lexed === void 0 ? void 0 : ownPassageLink(inner, lexed);
6139
6780
  }
6140
- const REGION_OPEN_RE = /\/\/|\/\*|\/%|<!--|<[Ss][Cc][Rr][Ii][Pp][Tt]/g;
6781
+ const REGION_OPEN_RE = /\[\[[^[]|\[[<>]?[Ii][Mm][Gg]\[|\/\/|\/\*|\/%|<!--|<[Ss][Cc][Rr][Ii][Pp][Tt]/g;
6141
6782
  const HAS_SCRIPT_OPEN_RE = /<[Ss][Cc][Rr][Ii][Pp][Tt]/;
6783
+ function regionKind(opener) {
6784
+ if (opener.startsWith("[")) return "markup";
6785
+ switch (opener) {
6786
+ case "//":
6787
+ case "/*":
6788
+ case "/%":
6789
+ case "<!--": return opener;
6790
+ default: return "script";
6791
+ }
6792
+ }
6142
6793
  const SCRIPT_CLOSE_RE = /<\/[Ss][Cc][Rr][Ii][Pp][Tt]>|[\r\u2028\u2029]/g;
6143
6794
  const COMMENT_CLOSE_RES = {
6144
6795
  "/*": /\*\/|[\r\u2028\u2029]/g,
@@ -6292,22 +6943,31 @@ function append(links, more) {
6292
6943
  for (const link of more) links.push(link);
6293
6944
  }
6294
6945
  /**
6295
- * The passages that `<<link>>` and `<<goto>>` calls in passage markup name. Calls whose passage
6296
- * is known only in play (a variable or an expression) are left out. A tag's own passage comes
6297
- * before those of the calls inside its arguments' strings.
6946
+ * The passages that link markup (`[[…]]`) and `<<link>>` and `<<goto>>` calls in passage markup
6947
+ * name, in the order they come. Calls whose passage is known only in play (a variable or an
6948
+ * expression) are left out. A tag's own passage comes before those that its arguments name, as
6949
+ * link markup or inside strings.
6298
6950
  *
6299
6951
  * The markup is walked in order, as SugarCube's wikifier walks it, with a tag tried at each
6300
- * `<<` the walk reaches. Of the wikifier's other parsers, only those whose text holds no call
6301
- * are told apart: comments (`/* … *` + `/`, `/% … %/`, `<!-- … -->`), whose calls never run,
6302
- * and `<script>` elements and `<<script>>` bodies, which are JavaScript: only the calls in their
6303
- * strings are read (see `findJavaScriptPassageLinks`). Everything else, verbatim text included,
6304
- * is read as markup.
6952
+ * `<<` the walk reaches, and link or image markup read at each `[[` or `[img[`; markup SugarCube
6953
+ * rejects is read on from just after its opener, and so is markup after a limit on how much
6954
+ * markup a reading may scan, which only input built to take quadratic time reaches (see
6955
+ * `readSquareBracketedMarkup`). Image markup names no passage here. Of the wikifier's other
6956
+ * parsers, only those whose text holds no link or call are told apart: comments (`/* … *` + `/`,
6957
+ * `/% … %/`, `<!-- … -->`), whose links and calls never run, and `<script>` elements and
6958
+ * `<<script>>` bodies, which are JavaScript: only the links and calls in their strings are read
6959
+ * (see `findJavaScriptPassageLinks`). Everything else, verbatim text included, is read as markup.
6305
6960
  *
6306
6961
  * Where this differs from SugarCube:
6962
+ * - a link whose passage SugarCube evaluates, because no passage has its name, is taken by its
6963
+ * name, `[[Go|$next]]` naming the passage `$next`, unless it is a quoted string, which is taken
6964
+ * by its value even when a passage has the name with the quotes;
6965
+ * - a link SugarCube takes for a URL, because no passage has its name and it looks like one or
6966
+ * holds a `/`, `.`, `?` or `#`, is still read as naming a passage;
6307
6967
  * - a comment or a `<<script>>` body is skipped whole, even where `parseBody` ends a container
6308
6968
  * such as `<<if>>` at a closing tag inside it and the rest then runs;
6309
- * - a `/*`, `<!--` or `<script` inside other markup that holds no call, such as link markup or a
6310
- * `<style>` element, still starts a comment or an element;
6969
+ * - a `[[`, `/*`, `<!--` or `<script` inside other markup that holds no call, such as verbatim
6970
+ * text or a `<style>` element, still starts link markup, a comment or an element;
6311
6971
  * - a `<<` in such other markup still starts a tag. When that tag runs over a `<<link` or
6312
6972
  * `<<goto` outside the parts of its arguments that the tag pattern reads as units, that call
6313
6973
  * is still read (see `innerPassageLink`), even where the tag's arguments would be JavaScript,
@@ -6322,33 +6982,43 @@ function append(links, more) {
6322
6982
  * `Config.passages.nobr`, are not joined before they are read (see `storyInspect` for passages
6323
6983
  * tagged `nobr`).
6324
6984
  */
6325
- function findMacroPassageLinks(text) {
6985
+ function findPassageLinks(text) {
6326
6986
  return markupPassageLinks(text, readContext(text));
6327
6987
  }
6328
6988
  function markupPassageLinks(text, context) {
6329
- if (context.depth > MAX_STRING_DEPTH || !text.includes("<<") && !HAS_SCRIPT_OPEN_RE.test(text)) return [];
6989
+ if (context.depth > MAX_STRING_DEPTH || !text.includes("<<") && !text.includes("[[") && !HAS_SCRIPT_OPEN_RE.test(text)) return [];
6330
6990
  const matchTagAt = tagMatcher(text);
6331
6991
  const matchInnerTagAt = tagMatcher(text);
6332
6992
  const readComment = commentReader(text);
6333
6993
  const readScriptElement = scriptElementReader(text);
6334
6994
  let findScriptCloser;
6335
6995
  const links = [];
6336
- /** Reads the region `opener` opens, adding the calls in a `<script>` element; returns where the walk goes on. */
6996
+ /**
6997
+ * Reads the region `opener` opens, adding the passage that link markup names and the links and
6998
+ * calls in a `<script>` element; returns where the walk goes on.
6999
+ */
6337
7000
  const regionEnd = (opener) => {
6338
7001
  const start = opener.index;
6339
- const kind = opener[0];
7002
+ const kind = regionKind(opener[0]);
6340
7003
  switch (kind) {
7004
+ case "markup": {
7005
+ const markup = linkMarkupAt(text, start, context.markupBudget);
7006
+ if (markup === void 0) return start + opener[0].length;
7007
+ if (markup.link !== void 0) links.push(markup.link);
7008
+ return markup.end;
7009
+ }
6341
7010
  case "//": return start + 2;
6342
7011
  case "/*":
6343
7012
  case "/%":
6344
7013
  case "<!--": return readComment(kind, start) ?? start + kind.length;
6345
- default: {
7014
+ case "script": {
6346
7015
  const element = readScriptElement(start);
6347
7016
  if (element.openerEnd === void 0) return start + 1;
6348
7017
  if (element.close === void 0) return element.openerEnd;
6349
7018
  append(links, javaScriptPassageLinks(text.slice(element.openerEnd, element.close), context));
6350
7019
  return element.close + 9;
6351
7020
  }
7021
+ default: throw new Error(`unhandled region: ${String(kind)}`);
6352
7022
  }
6353
7023
  };
6354
7024
  let macro = -2;
@@ -6388,24 +7058,26 @@ function markupPassageLinks(text, context) {
6388
7058
  }
6389
7059
  }
6390
7060
  /**
6391
- * The passages that `<<link>>` and `<<goto>>` calls in the strings of JavaScript source name,
6392
- * such as a script passage or a `<<script>>` body: `$.wiki('<<goto "Room">>')`. Code outside
6393
- * the strings isn't markup, so a call put together while the story plays, such as
6394
- * `'<<goto "' + target + '">>'`, names no passage here.
7061
+ * The passages that link markup and `<<link>>` and `<<goto>>` calls in the strings of JavaScript
7062
+ * source name, such as a script passage or a `<<script>>` body: `$.wiki('<<goto "Room">>')`.
7063
+ * Code outside the strings isn't markup, so a call put together while the story plays, such as
7064
+ * `'<<goto "' + target + '">>'`, names no passage here, nor does an array of arrays (`[[0, 1]]`).
6395
7065
  */
6396
7066
  function findJavaScriptPassageLinks(source) {
6397
7067
  return javaScriptPassageLinks(source, readContext(source));
6398
7068
  }
6399
7069
  /**
6400
7070
  * What JavaScript source must contain for one of its string or template literals to hold a call
6401
- * that is read. A value holding `<<`: each `<` in it is written as `<`, `\<`, `\x3c`, `\u003c` or
6402
- * `\u{…}`, two can be next to each other with only line continuations between, and the first
6403
- * is `<` or `\<` (then the source holds `<<` or `<\`) or one of the others. Or a value holding a
6404
- * `<script>` element, whose own strings can make `<<` from escapes the outer string encodes.
7071
+ * or link markup that is read. A value holding `<<`: each `<` in it is written as `<`, `\<`,
7072
+ * `\x3c`, `\u003c` or `\u{…}`, two can be next to each other with only line continuations
7073
+ * between, and the first is `<` or `\<` (then the source holds `<<` or `<\`) or one of the others.
7074
+ * A value holding `[[`, likewise: the source holds `[[`, a `[` before `\[` or a line continuation,
7075
+ * `\x5b`, `\u005b` or `\u{`. Or a value holding a `<script>` element, whose own strings can make
7076
+ * `<<` or `[[` from escapes the outer string encodes.
6405
7077
  */
6406
- const MAY_HOLD_MACRO_RE = /<<|<\\|\\x3c|\\u003c|\\u\{|<script/i;
7078
+ const MAY_HOLD_LINK_RE = /<<|<\\|\\x3c|\\u003c|\\u\{|<script|\[\[|\[\\[[\n\r\u2028\u2029]|\\x5b|\\u005b/i;
6407
7079
  function javaScriptPassageLinks(source, context) {
6408
- if (context.depth >= MAX_STRING_DEPTH || !MAY_HOLD_MACRO_RE.test(source)) return [];
7080
+ if (context.depth >= MAX_STRING_DEPTH || !MAY_HOLD_LINK_RE.test(source)) return [];
6409
7081
  const inner = deeper(context);
6410
7082
  const links = [];
6411
7083
  for (const value of javaScriptStrings(source)) append(links, markupPassageLinks(value, inner));
@@ -6414,43 +7086,57 @@ function javaScriptPassageLinks(source, context) {
6414
7086
  //#endregion
6415
7087
  //#region src/inspect.ts
6416
7088
  /**
6417
- * Extract all [[wiki-style links]] from passage text.
7089
+ * Extract the passages that passage text links to, read as SugarCube 2 reads them.
6418
7090
  *
6419
7091
  * Supported syntaxes:
6420
7092
  * - `[[PassageName]]`
6421
- * - `[[Display Text->PassageName]]` (Twine 2 / SugarCube arrow)
6422
- * - `[[Display Text|PassageName]]` (Twine 1 / Harlowe pipe)
6423
- * - `<<goto "PassageName">>` (SugarCube macro)
7093
+ * - `[[Display Text|PassageName]]`
7094
+ * - `[[Display Text->PassageName]]`
7095
+ * - `[[PassageName<-Display Text]]`
7096
+ * - any of those with a setter: `[[Display Text|PassageName][$x to 1]]`
7097
+ * - `<<goto "PassageName">>` (SugarCube macro)
6424
7098
  * - `<<link "Display" "PassageName">>` (SugarCube macro)
6425
7099
  *
6426
- * SugarCube macro arguments may be double- or single-quoted, with backslash escapes, or bare
6427
- * words; a passage named by a variable or an expression is known only in play and is skipped.
6428
- * Macro calls are also read inside the quoted strings of other macros' arguments, and inside the
6429
- * strings of `<<script>>` bodies and `<script>` elements; calls in comments are not read. A
6430
- * script passage (`isScript`) is JavaScript, so its macro calls are read only inside its strings.
6431
- * See `sugarcube-macros.ts` for the details and the known differences from SugarCube.
7100
+ * In link markup, the first `|`, `->` or `<-` divides the text from the passage name; image
7101
+ * markup (`[img[…][PassageName]]`) is not read. SugarCube macro arguments may be double- or
7102
+ * single-quoted, with backslash escapes, or bare words; a passage named by a variable or an
7103
+ * expression is known only in play and is skipped. Links and macro calls are also read inside the
7104
+ * quoted strings of macros' arguments, and inside the strings of `<<script>>` bodies and
7105
+ * `<script>` elements; links and calls in comments are not read. A script passage (`isScript`) is
7106
+ * JavaScript, so its links and calls are read only inside its strings. See `sugarcube-macros.ts`
7107
+ * for the details and the known differences from SugarCube.
6432
7108
  */
6433
7109
  function extractLinksFromText(text, isScript) {
6434
- const targets = /* @__PURE__ */ new Set();
6435
- const wikiLinkRe = /\[\[([^\]]+)\]\]/g;
6436
- let m;
6437
- while ((m = wikiLinkRe.exec(text)) !== null) {
6438
- const content = m[1];
6439
- const arrowIdx = content.indexOf("->");
6440
- if (arrowIdx !== -1) {
6441
- targets.add(content.slice(arrowIdx + 2).trim());
6442
- continue;
6443
- }
6444
- const pipeIdx = content.indexOf("|");
6445
- if (pipeIdx !== -1) {
6446
- targets.add(content.slice(pipeIdx + 1).trim());
6447
- continue;
6448
- }
6449
- targets.add(content.trim());
7110
+ const found = isScript ? findJavaScriptPassageLinks(text) : findPassageLinks(text);
7111
+ const ordered = [
7112
+ "markup",
7113
+ "goto",
7114
+ "link"
7115
+ ].flatMap((via) => found.filter((link) => link.via === via).map((link) => link.passage));
7116
+ return [...new Set(ordered)];
7117
+ }
7118
+ /**
7119
+ * The passages that `p` links to. A script passage is JavaScript, read for links only in its
7120
+ * strings. A stylesheet is CSS, which the story puts in a style element and never wikifies, so it
7121
+ * links nowhere; a passage tagged both is a script, as Twine 2 output takes it.
7122
+ */
7123
+ function passageLinks(p) {
7124
+ if (hasTag(p, "script")) return extractLinksFromText(textAsRead(p), true);
7125
+ if (hasTag(p, "stylesheet")) return [];
7126
+ return extractLinksFromText(textAsRead(p), false);
7127
+ }
7128
+ /**
7129
+ * Each passage name, mapped to why the `target` output leaves that passage out, or to `undefined`
7130
+ * when a link can reach it: always without a target, and otherwise when the output emits a
7131
+ * passage of that name.
7132
+ */
7133
+ function destinationOmissions(story, { target }) {
7134
+ const omissions = /* @__PURE__ */ new Map();
7135
+ for (const p of story.passages) {
7136
+ const omission = target === void 0 ? void 0 : passageOmission(story, p, target);
7137
+ if (!omissions.has(p.name) || omission === void 0) omissions.set(p.name, omission);
6450
7138
  }
6451
- const macroLinks = isScript ? findJavaScriptPassageLinks(text) : findMacroPassageLinks(text);
6452
- for (const macro of ["goto", "link"]) for (const link of macroLinks) if (link.macro === macro) targets.add(link.passage);
6453
- return [...targets];
7139
+ return omissions;
6454
7140
  }
6455
7141
  /** Passages SugarCube runs from their raw text, never joining their lines. */
6456
7142
  const RAW_TEXT_PASSAGES = /* @__PURE__ */ new Set([
@@ -6493,9 +7179,14 @@ function joinLines(text) {
6493
7179
  * expect(map.brokenLinks).toEqual([]);
6494
7180
  * expect(map.deadEnds).not.toContain('Start');
6495
7181
  * ```
7182
+ *
7183
+ * By default, a link is broken only when no passage has its name. Pass a `target` to check links
7184
+ * against what that output emits: `storyInspect(result.story, { target: 'twine2' })` also reports
7185
+ * links to passages Twine 2 output leaves out, such as script, stylesheet and `Twine.private`
7186
+ * passages.
6496
7187
  */
6497
- function storyInspect(story) {
6498
- const allNames = new Set(story.passages.map((p) => p.name));
7188
+ function storyInspect(story, options = {}) {
7189
+ const destinations = destinationOmissions(story, options);
6499
7190
  const passages = [];
6500
7191
  const storyPassages = [];
6501
7192
  const infoPassages = [];
@@ -6517,14 +7208,24 @@ function storyInspect(story) {
6517
7208
  }
6518
7209
  list.push(p.name);
6519
7210
  }
6520
- const targets = extractLinksFromText(textAsRead(p), hasTag(p, "script"));
6521
- links.set(p.name, targets);
7211
+ links.set(p.name, passageLinks(p));
6522
7212
  }
6523
7213
  const brokenLinks = [];
6524
- for (const [from, targets] of links) for (const to of targets) if (!allNames.has(to)) brokenLinks.push({
6525
- from,
6526
- to
6527
- });
7214
+ for (const [from, targets] of links) for (const to of targets) {
7215
+ if (!destinations.has(to)) {
7216
+ brokenLinks.push({
7217
+ from,
7218
+ to
7219
+ });
7220
+ continue;
7221
+ }
7222
+ const omission = destinations.get(to);
7223
+ if (omission !== void 0) brokenLinks.push({
7224
+ from,
7225
+ to,
7226
+ omission
7227
+ });
7228
+ }
6528
7229
  const deadEnds = [];
6529
7230
  for (const name of storyPassages) {
6530
7231
  const targets = links.get(name);
@@ -6552,17 +7253,20 @@ function storyInspect(story) {
6552
7253
  //#endregion
6553
7254
  //#region src/lint.ts
6554
7255
  /**
6555
- * Lint a story: compile without output rendering, then inspect structure.
6556
- * Uses JSON output mode internally to avoid format resolution.
7256
+ * lint() for a project whose builds are written to `outFile`: like a build for that file,
7257
+ * it leaves `outFile` out of the sources and modules, so an earlier build inside a source
7258
+ * folder is not linted as a source.
7259
+ *
7260
+ * Internal, for the CLI; not part of the public API.
6557
7261
  */
6558
- async function lint(options) {
6559
- const result = await compile({
7262
+ async function lintForOutputFile(options, outFile) {
7263
+ const result = await compileForOutputFile({
6560
7264
  ...options,
6561
7265
  outputMode: "json"
6562
- });
6563
- const map = storyInspect(result.story);
7266
+ }, outFile);
7267
+ const map = storyInspect(result.story, { target: "twine2" });
6564
7268
  return {
6565
- diagnostics: result.diagnostics,
7269
+ diagnostics: [...result.diagnostics, ...startPassageDiagnostics(result.story, map.start, "twine2")],
6566
7270
  stats: result.stats,
6567
7271
  formatName: result.story.twine2.format,
6568
7272
  formatVersion: result.story.twine2.formatVersion,
@@ -6587,7 +7291,10 @@ function formatLintReport(result) {
6587
7291
  if (result.brokenLinks.length > 0) {
6588
7292
  lines.push("");
6589
7293
  lines.push(`Broken links (${result.brokenLinks.length}):`);
6590
- for (const link of result.brokenLinks) lines.push(` ${link.from} -> ${link.to} (passage "${link.to}" does not exist)`);
7294
+ for (const link of result.brokenLinks) {
7295
+ const why = link.omission === void 0 ? "does not exist" : `${describeOmission(link.omission)}, so it is left out of the story data`;
7296
+ lines.push(` ${link.from} -> ${link.to} (passage "${link.to}" ${why})`);
7297
+ }
6591
7298
  }
6592
7299
  if (result.deadEnds.length > 0) {
6593
7300
  lines.push("");
@@ -6870,8 +7577,10 @@ async function main() {
6870
7577
  }
6871
7578
  return raw;
6872
7579
  })();
7580
+ const outFile = values.output ?? config?.output ?? "-";
7581
+ const outPath = outFile === "-" ? void 0 : outFile;
6873
7582
  if (values.lint) {
6874
- const lintResult = await lint({
7583
+ const lintResult = await lintForOutputFile({
6875
7584
  sources,
6876
7585
  exclude: values.exclude ?? config?.exclude,
6877
7586
  formatId: values.format ?? config?.formatId,
@@ -6888,7 +7597,7 @@ async function main() {
6888
7597
  tagAliases,
6889
7598
  sourceInfo: values["source-info"] ?? config?.sourceInfo ?? false,
6890
7599
  wordCountMethod
6891
- });
7600
+ }, outPath);
6892
7601
  console.log(formatLintReport(lintResult));
6893
7602
  const hasErrors = lintResult.brokenLinks.length > 0 || lintResult.diagnostics.some((d) => d.level === "error");
6894
7603
  process.exit(hasErrors ? 1 : 0);
@@ -6913,39 +7622,44 @@ async function main() {
6913
7622
  sourceInfo: values["source-info"] ?? config?.sourceInfo ?? false,
6914
7623
  wordCountMethod
6915
7624
  };
6916
- const outFile = values.output ?? config?.output ?? "-";
6917
7625
  if (values.watch) {
6918
- if (outFile === "-") {
7626
+ if (outPath === void 0) {
6919
7627
  console.error("Error: Watch mode requires an output file (-o).");
6920
7628
  process.exit(1);
6921
7629
  }
6922
7630
  console.log("Watch mode started. Press CTRL+C to stop.");
6923
- await watch$1({
7631
+ await watchWithWriteFilter({
6924
7632
  ...compileOptions,
6925
- outFile,
7633
+ outFile: outPath,
6926
7634
  onBuild(result) {
6927
7635
  console.log(`Built: ${result.stats.passages} passages, ${result.stats.words} words`);
6928
7636
  logDiagnostics(result.diagnostics);
7637
+ const errors = countErrors(result.diagnostics);
7638
+ if (errors > 0) console.error(`Build has ${pluralize(errors, "error")}; output not written. Still watching for changes.`);
6929
7639
  },
6930
7640
  onError(error) {
6931
7641
  console.error(`Build error: ${error.message}`);
6932
7642
  }
6933
- });
6934
- } else if (outFile === "-") {
6935
- const result = await compile(compileOptions);
6936
- process.stdout.write(result.output);
6937
- logDiagnostics(result.diagnostics);
6938
- if (values["log-stats"]) logStats(result);
7643
+ }, (result) => countErrors(result.diagnostics) === 0);
6939
7644
  } else {
6940
- const result = await compileToFile({
6941
- ...compileOptions,
6942
- outFile
6943
- });
7645
+ const result = await compileForOutputFile(compileOptions, outPath);
6944
7646
  logDiagnostics(result.diagnostics);
6945
- if (values["log-files"]) console.log(`\nFiles: ${result.stats.files.join(", ")}`);
7647
+ const errors = countErrors(result.diagnostics);
7648
+ if (errors > 0) {
7649
+ console.error(`Compilation failed with ${pluralize(errors, "error")}; output not written.`);
7650
+ process.exitCode = 1;
7651
+ } else if (outPath === void 0) process.stdout.write(result.output);
7652
+ else writeFileSync(outPath, result.output, "utf-8");
7653
+ if (outPath !== void 0 && values["log-files"]) console.log(`\nFiles: ${result.stats.files.join(", ")}`);
6946
7654
  if (values["log-stats"]) logStats(result);
6947
7655
  }
6948
7656
  }
7657
+ function countErrors(diagnostics) {
7658
+ return diagnostics.filter((d) => d.level === "error").length;
7659
+ }
7660
+ function pluralize(count, noun) {
7661
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
7662
+ }
6949
7663
  function listFormats() {
6950
7664
  const formats = discoverFormats(getFormatSearchDirs());
6951
7665
  console.log("Local story formats:");
@@ -6971,26 +7685,56 @@ function logStats(result) {
6971
7685
  console.log(` Words: ${s.words}`);
6972
7686
  console.log(` Files: ${s.files.length}`);
6973
7687
  }
6974
- function runInit() {
6975
- console.log("Initializing new twee-ts project...");
6976
- mkdirSync("src", { recursive: true });
6977
- writeFileSync(join("src", "StoryData.tw"), `:: StoryData
7688
+ const SCAFFOLD_FILES = [
7689
+ {
7690
+ path: CONFIG_FILENAME,
7691
+ content: scaffoldConfig
7692
+ },
7693
+ {
7694
+ path: "src/StoryData.tw",
7695
+ content: () => `:: StoryData
6978
7696
  {
6979
7697
  \t"ifid": "${crypto.randomUUID().toUpperCase()}"
6980
7698
  }
6981
- `);
6982
- writeFileSync(join("src", "Start.tw"), `:: Start
7699
+ `
7700
+ },
7701
+ {
7702
+ path: "src/Start.tw",
7703
+ content: () => `:: Start
6983
7704
  Welcome to your new Twine story!
6984
7705
 
6985
7706
  This is the starting passage. Edit this file to begin writing your story.
6986
- `);
6987
- if (!existsSync("twee-ts.config.json")) {
6988
- writeFileSync(CONFIG_FILENAME, scaffoldConfig());
6989
- console.log(`Created: ${CONFIG_FILENAME}`);
6990
- }
6991
- console.log("Created:");
6992
- console.log(" src/StoryData.tw");
6993
- console.log(" src/Start.tw");
7707
+ `
7708
+ }
7709
+ ];
7710
+ /**
7711
+ * Writes `content` to `path` unless the file already exists.
7712
+ * The exclusive flag makes the check and the write one step, so an existing file is never truncated.
7713
+ * Returns false when the file was already there.
7714
+ */
7715
+ function writeNewFile(path, content) {
7716
+ try {
7717
+ writeFileSync(path, content, { flag: "wx" });
7718
+ return true;
7719
+ } catch (e) {
7720
+ if (e instanceof Error && "code" in e && e.code === "EEXIST") return false;
7721
+ throw new Error(`Cannot create ${path}: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
7722
+ }
7723
+ }
7724
+ function runInit() {
7725
+ console.log("Initializing new twee-ts project...");
7726
+ mkdirSync("src", { recursive: true });
7727
+ const results = SCAFFOLD_FILES.map((file) => ({
7728
+ path: file.path,
7729
+ created: writeNewFile(file.path, file.content())
7730
+ }));
7731
+ const created = results.filter((r) => r.created).map((r) => r.path);
7732
+ const skipped = results.filter((r) => !r.created).map((r) => r.path);
7733
+ if (created.length > 0) {
7734
+ console.log("Created:");
7735
+ for (const path of created) console.log(` ${path}`);
7736
+ }
7737
+ for (const path of skipped) console.log(`Skipped (already exists): ${path}`);
6994
7738
  console.log("\nRun: npx @rohal12/twee-ts");
6995
7739
  }
6996
7740
  function formatBytes(bytes) {