@rohal12/twee-ts 1.15.4 → 1.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, watch as watch$1, writeFileSync } from "node:fs";
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, watch as watch$1, writeFileSync } from "node:fs";
2
+ import * as nodePath from "node:path";
3
+ import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
2
4
  import { randomUUID } from "node:crypto";
3
- import { basename, dirname, extname, join, parse, relative, resolve } from "node:path";
4
5
  import { homedir } from "node:os";
5
6
  import { fileURLToPath } from "node:url";
6
7
  //#region src/ifid.ts
@@ -109,6 +110,61 @@ function commentSanitize(s) {
109
110
  function htmlCommentSanitize(s) {
110
111
  return s.replace(/-->/g, "-- >");
111
112
  }
113
+ /**
114
+ * Escape JavaScript for the content of an HTML `<script>` element, so that the HTML parser reads all of it as
115
+ * the element's text and ends the element at the end tag written after it.
116
+ *
117
+ * - Every `<\/script` (in any letter case) becomes `<\/script`. HTML ends a script element at the first
118
+ * `<\/script` followed by whitespace, `/` or `>`, wherever it is in the JavaScript, even inside a string.
119
+ * - Every `<!--` becomes `<\!--`, but only if the text would otherwise end in the HTML tokenizer's "script
120
+ * data double escaped" state: after a `<!--` and then a `<script` start tag with no `-->` between them, the
121
+ * end tag after the text would not end the element.
122
+ *
123
+ * Inserting a backslash after `<` keeps the value of every string, template, and regular expression literal
124
+ * and every comment where these sequences can be written (`\/` and `\!` are identity escapes; a backslash
125
+ * already before the `<` escapes the `<`, as it did before); `<\/script` is also how minifiers such as Terser
126
+ * write inline scripts. It only changes code that uses them outside literals (`a <\/script/ b`, an HTML-like `<!--`
127
+ * comment that is escaped because of a later `<script`), the raw text of a tagged template, and a `<!--` in a
128
+ * regular expression with the `u` or `v` flag. The result is unchanged by escaping it again.
129
+ */
130
+ function scriptContentEscape(s) {
131
+ const escaped = s.replace(/<(?=\/script)/gi, "<\\");
132
+ return endsDoubleEscaped(escaped) ? escaped.replace(/<(?=!--)/g, "<\\") : escaped;
133
+ }
134
+ /**
135
+ * Whether the HTML tokenizer, reading `s` as script element content with no `<\/script` in it, ends in the
136
+ * "script data double escaped" state, where an end tag after `s` would not end the element.
137
+ * See https://html.spec.whatwg.org/multipage/parsing.html#script-data-escaped-state.
138
+ */
139
+ function endsDoubleEscaped(s) {
140
+ const scriptStartTag = /<script[\t\n\f\r />]/gi;
141
+ let pos = 0;
142
+ for (;;) {
143
+ const open = s.indexOf("<!--", pos);
144
+ if (open === -1) return false;
145
+ const escapedFrom = open + 2;
146
+ const close = s.indexOf("-->", escapedFrom);
147
+ scriptStartTag.lastIndex = escapedFrom;
148
+ const start = scriptStartTag.exec(s);
149
+ if (start === null || close !== -1 && close < start.index) {
150
+ if (close === -1) return false;
151
+ pos = close + 3;
152
+ continue;
153
+ }
154
+ const end = s.indexOf("-->", start.index + start[0].length);
155
+ if (end === -1) return true;
156
+ pos = end + 3;
157
+ }
158
+ }
159
+ /**
160
+ * Escape CSS for the content of an HTML `<style>` element: every `</style` (in any letter case) becomes
161
+ * `<\/style`, since HTML ends a style element at the first `</style` followed by whitespace, `/` or `>`.
162
+ * `\/` stands for `/` in CSS strings and URLs, and comments don't matter; `</style` anywhere else isn't valid
163
+ * CSS anyway.
164
+ */
165
+ function styleContentEscape(s) {
166
+ return s.replace(/<(?=\/style)/gi, "<\\");
167
+ }
112
168
  /** Apply ROT13 encoding to a string (only affects [A-Za-z]). */
113
169
  function rot13(s) {
114
170
  if (s.length === 0) return s;
@@ -631,11 +687,25 @@ function fontFormatHint(ext) {
631
687
  * Ported from filesystem.go.
632
688
  */
633
689
  /**
690
+ * Whether `filename` matches one of the `exclude` globs. The globs are matched
691
+ * against the file's path relative to the working directory, the path
692
+ * getFilenames reports; an absolute `filename` is made relative first. A leading
693
+ * `./` in a glob is dropped. Matching is Node's `path.matchesGlob`.
694
+ */
695
+ function isExcluded(filename, exclude) {
696
+ if (exclude.length === 0) return false;
697
+ const { matchesGlob } = nodePath;
698
+ if (typeof matchesGlob !== "function") throw new Error(`The exclude option needs Node.js 22.5 or newer (found ${process.version}).`);
699
+ const rel = relative(process.cwd(), resolve(filename)) || filename;
700
+ return exclude.some((pattern) => matchesGlob(rel, pattern.replace(/^\.\//, "")));
701
+ }
702
+ /**
634
703
  * Recursively walk directories, collecting regular file paths.
635
- * Filters out the output file to prevent circular compilation.
704
+ * Filters out the output file to prevent circular compilation, and the files
705
+ * that match an `exclude` glob (see isExcluded).
636
706
  * Like Tweego, a path that cannot be read is reported as a warning and skipped.
637
707
  */
638
- function getFilenames(pathnames, outFilename) {
708
+ function getFilenames(pathnames, outFilename, exclude = []) {
639
709
  const filenames = [];
640
710
  const diagnostics = [];
641
711
  const absOutFile = outFilename ? resolve(outFilename) : "";
@@ -657,6 +727,7 @@ function getFilenames(pathnames, outFilename) {
657
727
  const abs = resolve(pathname);
658
728
  if (abs === absOutFile) return;
659
729
  const rel = relative(process.cwd(), abs);
730
+ if (isExcluded(rel || abs, exclude)) return;
660
731
  filenames.push(rel || abs);
661
732
  } else if (stat.isDirectory()) {
662
733
  let entries;
@@ -675,11 +746,38 @@ function getFilenames(pathnames, outFilename) {
675
746
  diagnostics
676
747
  };
677
748
  }
749
+ /** Whether the absolute path `file` is the absolute path `dir` or inside it. */
750
+ function isInsideDir(file, dir) {
751
+ return file === dir || file.startsWith(dir.endsWith(sep) ? dir : dir + sep);
752
+ }
753
+ /**
754
+ * The watched paths split into folders (as given) and files (absolute). A path
755
+ * that cannot be read counts as a folder, which fs.watch then fails to watch.
756
+ */
757
+ function splitWatchRoots(pathnames) {
758
+ const dirs = [];
759
+ const files = /* @__PURE__ */ new Set();
760
+ for (const pathname of pathnames) {
761
+ let isFile = false;
762
+ try {
763
+ isFile = statSync(pathname).isFile();
764
+ } catch {}
765
+ if (isFile) files.add(resolve(pathname));
766
+ else dirs.push(pathname);
767
+ }
768
+ return {
769
+ dirs,
770
+ files
771
+ };
772
+ }
678
773
  /**
679
774
  * Watch paths for changes, calling the build callback on known file type changes.
775
+ * A folder is watched recursively; a file is watched on its own and counts
776
+ * whatever its type (a head file, say). A change to a file `ignore` returns true
777
+ * for (given the path relative to the working directory) schedules no build.
680
778
  * Uses debouncing to avoid rapid rebuilds.
681
779
  */
682
- function watchFilesystem(pathnames, outFilename, callback) {
780
+ function watchFilesystem(pathnames, outFilename, callback, ignore = () => false) {
683
781
  const absOutFile = resolve(outFilename);
684
782
  const watchers = [];
685
783
  let buildTimer = null;
@@ -695,15 +793,30 @@ function watchFilesystem(pathnames, outFilename, callback) {
695
793
  callback(files);
696
794
  }, BUILD_DEBOUNCE);
697
795
  }
698
- for (const pathname of pathnames) try {
699
- const watcher = watch$1(pathname, { recursive: true }, (_event, filename) => {
700
- if (!filename) return;
701
- const abs = resolve(pathname, filename);
702
- if (abs === absOutFile) return;
703
- if (isKnownFileType(filename)) scheduleBuild(relative(process.cwd(), abs) || abs);
704
- });
705
- watchers.push(watcher);
706
- } catch {}
796
+ function fileChanged(abs, named) {
797
+ if (abs === absOutFile) return;
798
+ if (!named && !isKnownFileType(abs)) return;
799
+ const rel = relative(process.cwd(), abs);
800
+ if (!ignore(rel || abs)) scheduleBuild(rel || abs);
801
+ }
802
+ function watchPath(pathname, recursive, listener) {
803
+ try {
804
+ watchers.push(watch$1(pathname, { recursive }, (_event, filename) => {
805
+ if (filename) listener(filename);
806
+ }));
807
+ } catch {}
808
+ }
809
+ const { dirs, files } = splitWatchRoots(pathnames);
810
+ const absDirs = dirs.map((dir) => resolve(dir));
811
+ for (const dir of dirs) watchPath(dir, true, (filename) => {
812
+ const abs = resolve(dir, filename);
813
+ fileChanged(abs, files.has(abs));
814
+ });
815
+ const fileDirs = new Set([...files].filter((file) => !absDirs.some((dir) => isInsideDir(file, dir))).map((file) => dirname(file)));
816
+ for (const dir of fileDirs) watchPath(dir, false, (filename) => {
817
+ const abs = resolve(dir, filename);
818
+ if (files.has(abs)) fileChanged(abs, true);
819
+ });
707
820
  callback();
708
821
  return { close() {
709
822
  if (buildTimer) clearTimeout(buildTimer);
@@ -711,16 +824,25 @@ function watchFilesystem(pathnames, outFilename, callback) {
711
824
  } };
712
825
  }
713
826
  //#endregion
827
+ //#region src/source-text.ts
828
+ /**
829
+ * Source text normalization shared by every input path (files, in-memory sources, `parseTwee`).
830
+ */
831
+ /**
832
+ * Strip a leading UTF-8 byte order mark and convert CRLF and bare CR line endings to LF.
833
+ * Idempotent, so text that is already normalized passes through unchanged.
834
+ */
835
+ function normalizeSourceText(text) {
836
+ return (text.charCodeAt(0) === 65279 ? text.slice(1) : text).replace(/\r\n?/g, "\n");
837
+ }
838
+ //#endregion
714
839
  //#region src/util.ts
715
840
  /**
716
841
  * Shared file I/O utilities.
717
842
  */
718
843
  /** Read a file as UTF-8 with BOM stripping and line ending normalization. */
719
844
  function readUTF8(filename) {
720
- let content = readFileSync(filename, "utf-8");
721
- if (content.charCodeAt(0) === 65279) content = content.slice(1);
722
- content = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
723
- return content;
845
+ return normalizeSourceText(readFileSync(filename, "utf-8"));
724
846
  }
725
847
  /** Read a file as base64. */
726
848
  function readBase64(filename) {
@@ -750,16 +872,28 @@ function relaxedJSONParse(json) {
750
872
  fixed = fixed.replace(/,\s*([}\]])/g, "$1");
751
873
  return JSON.parse(fixed);
752
874
  }
875
+ /** A `"setup": function` property, which Harlowe appends to its otherwise-JSON format object. */
876
+ const SETUP_FUNCTION_PROPERTY = /,\s*"setup"\s*:\s*function\b/g;
877
+ /** Drop a trailing `"setup": function(){…}` property from a format object, if it has one. */
878
+ function stripSetupFunction(chunk) {
879
+ let lastIndex = -1;
880
+ for (const match of chunk.matchAll(SETUP_FUNCTION_PROPERTY)) lastIndex = match.index;
881
+ return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
882
+ }
753
883
  /**
754
884
  * Parse the Twine 2 format.js JSON chunk.
755
- * Handles Harlowe's malformed JSON by stripping the "setup" function property.
885
+ * Handles Harlowe's malformed JSON by stripping the "setup" function property. The workaround
886
+ * keys on the property itself, so the same bytes parse whether they come from a `harlowe-3`
887
+ * directory or a direct download.
756
888
  * Per spec, the name key is Optional.
889
+ *
890
+ * @param _formatId No longer used; kept so existing callers keep compiling.
757
891
  */
758
- function parseFormatJSON(source, formatId) {
892
+ function parseFormatJSON(source, _formatId) {
759
893
  const first = source.indexOf("{");
760
894
  const last = source.lastIndexOf("}");
761
895
  if (first === -1 || last === -1) return null;
762
- let chunk = source.slice(first, last + 1);
896
+ const chunk = source.slice(first, last + 1);
763
897
  const parse = (json) => {
764
898
  const raw = relaxedJSONParse(json);
765
899
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
@@ -782,16 +916,13 @@ function parseFormatJSON(source, formatId) {
782
916
  try {
783
917
  return parse(chunk);
784
918
  } catch {
785
- if (formatId.toLowerCase().startsWith("harlowe")) {
786
- const setupIdx = chunk.lastIndexOf(",\"setup\": function");
787
- if (setupIdx !== -1) {
788
- chunk = chunk.slice(0, setupIdx) + "}";
789
- try {
790
- return parse(chunk);
791
- } catch {}
792
- }
919
+ const stripped = stripSetupFunction(chunk);
920
+ if (stripped === void 0) return null;
921
+ try {
922
+ return parse(stripped);
923
+ } catch {
924
+ return null;
793
925
  }
794
- return null;
795
926
  }
796
927
  }
797
928
  /**
@@ -941,6 +1072,54 @@ function semverCompare(a, b) {
941
1072
  if (aPatch !== bPatch) return aPatch - bPatch;
942
1073
  return 0;
943
1074
  }
1075
+ /** Build a directory-style format ID from a name and version, e.g. ('SugarCube', '2.37.3') → 'sugarcube-2'. */
1076
+ function makeFormatId(name, version) {
1077
+ const major = parseSemver(version)?.[0] ?? 0;
1078
+ return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
1079
+ }
1080
+ /** Describe a format request for diagnostics. */
1081
+ function describeFormatRequest(request) {
1082
+ switch (request.kind) {
1083
+ case "id": return `"${request.id}"`;
1084
+ case "name": return request.version ? `"${request.name}" at version "${request.version}"` : `"${request.name}"`;
1085
+ default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
1086
+ }
1087
+ }
1088
+ /**
1089
+ * Pick the candidate that best answers a format request.
1090
+ *
1091
+ * - An ID request ('sugarcube-2') matches a candidate whose name and major version build that ID,
1092
+ * and takes the greatest such version.
1093
+ * - A name request matches the name case-insensitively. An exact version wins outright; otherwise
1094
+ * the greatest version with the same major that is not older than the one asked for (or, with
1095
+ * `allowOlder`, the greatest same-major version). An unparseable requested version takes the
1096
+ * greatest version of any major.
1097
+ */
1098
+ function selectFormatCandidate(request, candidates, describe, options = {}) {
1099
+ const allowOlder = options.allowOlder ?? false;
1100
+ const wanted = request.kind === "name" ? parseSemver(request.version) : null;
1101
+ let best;
1102
+ for (const candidate of candidates) {
1103
+ const { name, version } = describe(candidate);
1104
+ const have = parseSemver(version);
1105
+ if (!have) continue;
1106
+ if (request.kind === "id") {
1107
+ if (makeFormatId(name, version) !== request.id.toLowerCase()) continue;
1108
+ } else {
1109
+ if (name.toLowerCase() !== request.name.toLowerCase()) continue;
1110
+ if (wanted) {
1111
+ if (semverCompare(have, wanted) === 0) return candidate;
1112
+ if (have[0] !== wanted[0]) continue;
1113
+ if (!allowOlder && semverCompare(have, wanted) < 0) continue;
1114
+ }
1115
+ }
1116
+ if (!best || semverCompare(have, best.version) > 0) best = {
1117
+ candidate,
1118
+ version: have
1119
+ };
1120
+ }
1121
+ return best?.candidate;
1122
+ }
944
1123
  /** Get format ID from Twine 2 name and version, using SemVer major matching. */
945
1124
  function getFormatIdByNameAndVersion(formats, name, version) {
946
1125
  const wanted = parseSemver(version);
@@ -970,169 +1149,584 @@ function readFormatSource(format) {
970
1149
  return source;
971
1150
  }
972
1151
  //#endregion
973
- //#region src/types.ts
974
- const ItemType = {
975
- Error: 0,
976
- EOF: 1,
977
- Header: 2,
978
- Name: 3,
979
- Tags: 4,
980
- Metadata: 5,
981
- Content: 6
982
- };
983
- //#endregion
984
- //#region src/lexer.ts
985
- const EOF = -1;
986
- const HEADER_DELIM = "::";
987
- const NEWLINE_HEADER_DELIM = "\n::";
988
- var LexerContext = class {
989
- input;
990
- line = 1;
991
- start = 0;
992
- pos = 0;
993
- items = [];
994
- constructor(input) {
995
- this.input = input;
996
- }
997
- next() {
998
- if (this.pos >= this.input.length) return EOF;
999
- const ch = this.input.charCodeAt(this.pos);
1000
- this.pos++;
1001
- if (ch === 10) this.line++;
1002
- return ch;
1003
- }
1004
- peek() {
1005
- if (this.pos >= this.input.length) return EOF;
1006
- return this.input.charCodeAt(this.pos);
1007
- }
1008
- backup() {
1009
- if (this.pos > this.start) {
1010
- this.pos--;
1011
- if (this.input.charCodeAt(this.pos) === 10) this.line--;
1012
- } else throw new Error("backup would leave pos < start");
1013
- }
1014
- emit(type) {
1015
- const val = this.input.slice(this.start, this.pos);
1016
- this.items.push({
1017
- type,
1018
- line: this.line,
1019
- pos: this.start,
1020
- val
1021
- });
1022
- if (type === ItemType.Content) {
1023
- for (let i = this.start; i < this.pos; i++) if (this.input.charCodeAt(i) === 10) this.line++;
1024
- }
1025
- this.start = this.pos;
1026
- }
1027
- ignore() {
1028
- for (let i = this.start; i < this.pos; i++) if (this.input.charCodeAt(i) === 10) this.line++;
1029
- this.start = this.pos;
1030
- }
1031
- acceptRun(valid) {
1032
- let ch;
1033
- for (ch = this.next(); ch !== EOF && valid.includes(String.fromCharCode(ch)); ch = this.next());
1034
- if (ch !== EOF) this.backup();
1035
- }
1036
- errorf(message) {
1037
- this.items.push({
1038
- type: ItemType.Error,
1039
- line: this.line,
1040
- pos: this.start,
1041
- val: message
1042
- });
1043
- return null;
1044
- }
1045
- /** Drain all pending items and return them. */
1046
- drainItems() {
1047
- const result = this.items;
1048
- this.items = [];
1049
- return result;
1050
- }
1051
- };
1052
- function acceptQuoted(ctx, quote) {
1053
- for (;;) {
1054
- const ch = ctx.next();
1055
- switch (ch) {
1056
- case 92: {
1057
- const r = ctx.next();
1058
- if (r !== 10 && r !== EOF) break;
1059
- }
1060
- case 10:
1061
- case EOF: return "unterminated quoted string";
1062
- default: if (ch === quote) return null;
1063
- }
1064
- }
1152
+ //#region src/remote-formats.ts
1153
+ /**
1154
+ * Remote story format fetching, caching, and checksum verification.
1155
+ * Uses the Story Formats Archive (SFA) as the default source.
1156
+ */
1157
+ const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
1158
+ /** Get the cache directory for downloaded story formats. */
1159
+ function getCacheDir() {
1160
+ const base = process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache");
1161
+ return join(base, "twee-ts", "storyformats");
1065
1162
  }
1066
- function lexProlog(ctx) {
1067
- if (ctx.input.startsWith(HEADER_DELIM, ctx.pos)) return lexHeaderDelim;
1068
- const i = ctx.input.indexOf(NEWLINE_HEADER_DELIM, ctx.pos);
1069
- if (i > -1) {
1070
- ctx.pos = i + 1;
1071
- ctx.ignore();
1072
- return lexHeaderDelim;
1163
+ /**
1164
+ * Whether a string can serve as one directory name inside the cache: not empty, not `.` or `..`,
1165
+ * and free of path separators. Format names and versions come from downloaded metadata, so they
1166
+ * are untrusted path input.
1167
+ */
1168
+ function isSafeSegment(segment) {
1169
+ return segment !== "" && segment !== "." && segment !== ".." && !/[/\\\0]/.test(segment);
1170
+ }
1171
+ /** Whether absolute path `target` lies strictly inside absolute path `root`. */
1172
+ function isInside(root, target) {
1173
+ const rel = relative(root, target);
1174
+ return rel !== "" && !isAbsolute(rel) && rel.split(sep)[0] !== "..";
1175
+ }
1176
+ /** The cache directory for one format version. Throws when the name or version could leave the cache. */
1177
+ function cachedFormatDir(name, version) {
1178
+ if (!isSafeSegment(name)) throw new Error(`Refusing to cache a story format with an unsafe name: ${JSON.stringify(name)}`);
1179
+ if (!isSafeSegment(version) || !parseSemver(version)) throw new Error(`Refusing to cache story format "${name}" with an unsafe version: ${JSON.stringify(version)}`);
1180
+ const root = resolve(getCacheDir());
1181
+ const dir = resolve(root, name, version);
1182
+ if (!isInside(root, dir)) throw new Error(`Refusing to cache a story format outside the cache directory ${root}: ${dir}`);
1183
+ return dir;
1184
+ }
1185
+ /** Create `dir` as a plain directory, or check that it already is one (a symlink could lead out of the cache). */
1186
+ function ensurePlainDirectory(dir) {
1187
+ const stat = lstatSync(dir, { throwIfNoEntry: false });
1188
+ if (stat === void 0) {
1189
+ mkdirSync(dir);
1190
+ return;
1073
1191
  }
1074
- ctx.emit(ItemType.EOF);
1075
- return null;
1192
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`Refusing to write a story format outside the cache directory: ${dir} is not a plain directory`);
1193
+ }
1194
+ /** Write a downloaded format.js to `<cache>/<name>/<version>/format.js` and return its path. */
1195
+ function writeCachedFormat(name, version, text) {
1196
+ const dir = cachedFormatDir(name, version);
1197
+ const root = resolve(getCacheDir());
1198
+ mkdirSync(root, { recursive: true });
1199
+ ensurePlainDirectory(join(root, name));
1200
+ ensurePlainDirectory(dir);
1201
+ const formatPath = join(dir, "format.js");
1202
+ if (lstatSync(formatPath, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error(`Refusing to write a story format outside the cache directory: ${formatPath} is a symlink`);
1203
+ writeFileSync(formatPath, text, "utf-8");
1204
+ return formatPath;
1076
1205
  }
1077
- function lexContent(ctx) {
1078
- if (ctx.input.startsWith(HEADER_DELIM, ctx.pos)) return lexHeaderDelim;
1079
- const i = ctx.input.indexOf(NEWLINE_HEADER_DELIM, ctx.pos);
1080
- if (i > -1) {
1081
- ctx.pos = i + 1;
1082
- ctx.emit(ItemType.Content);
1083
- return lexHeaderDelim;
1084
- }
1085
- ctx.pos = ctx.input.length;
1086
- if (ctx.pos > ctx.start) ctx.emit(ItemType.Content);
1087
- ctx.emit(ItemType.EOF);
1088
- return null;
1206
+ /** In-memory index cache, keyed by URL. Cleared each compile. */
1207
+ const indexCache = /* @__PURE__ */ new Map();
1208
+ /** Clear the in-memory index cache. */
1209
+ function clearIndexCache() {
1210
+ indexCache.clear();
1089
1211
  }
1090
- function lexHeaderDelim(ctx) {
1091
- ctx.pos += 2;
1092
- ctx.emit(ItemType.Header);
1093
- return lexName;
1212
+ /** Validate that a JSON value is a valid SFAIndexEntry. */
1213
+ function isValidEntry(val) {
1214
+ if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
1215
+ const obj = val;
1216
+ return typeof obj.name === "string" && typeof obj.version === "string" && typeof obj.checksums === "object" && obj.checksums !== null && !Array.isArray(obj.checksums);
1094
1217
  }
1095
- function lexName(ctx) {
1096
- let r;
1097
- outer: for (;;) {
1098
- r = ctx.next();
1099
- switch (r) {
1100
- case 92: {
1101
- const next = ctx.next();
1102
- if (next !== 10 && next !== EOF) break;
1103
- r = next;
1104
- }
1105
- case 91:
1106
- case 93:
1107
- case 123:
1108
- case 125:
1109
- case 10:
1110
- case EOF:
1111
- if (r !== EOF) ctx.backup();
1112
- break outer;
1113
- }
1114
- }
1115
- ctx.emit(ItemType.Name);
1116
- switch (r) {
1117
- case 91: return lexTags;
1118
- case 93: return ctx.errorf(`unexpected right square bracket ']'`);
1119
- case 123: return lexMetadata;
1120
- case 125: return ctx.errorf(`unexpected right curly brace '}'`);
1121
- case 10:
1122
- ctx.pos++;
1123
- ctx.ignore();
1124
- return lexContent;
1125
- }
1126
- ctx.emit(ItemType.EOF);
1127
- return null;
1218
+ /** Validate that a JSON value conforms to the SFAIndex shape. */
1219
+ function validateSFAIndex(json) {
1220
+ if (typeof json !== "object" || json === null) throw new Error("SFA index is not an object");
1221
+ const obj = json;
1222
+ return {
1223
+ twine1: Array.isArray(obj["twine1"]) ? obj["twine1"].filter(isValidEntry) : [],
1224
+ twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
1225
+ };
1128
1226
  }
1129
- function lexNextOptionalBlock(ctx) {
1130
- ctx.acceptRun(" ");
1131
- ctx.ignore();
1132
- const r = ctx.peek();
1133
- switch (r) {
1134
- case 91: return lexTags;
1135
- case 93: return ctx.errorf(`unexpected right square bracket ']'`);
1227
+ /** Fetch and parse an SFA index.json, with in-memory caching. */
1228
+ async function fetchIndex(url) {
1229
+ const cached = indexCache.get(url);
1230
+ if (cached) return cached;
1231
+ const res = await fetch(url);
1232
+ if (!res.ok) throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
1233
+ const data = validateSFAIndex(await res.json());
1234
+ indexCache.set(url, data);
1235
+ return data;
1236
+ }
1237
+ /** Find the best matching entry in an SFA index for a name or ID request (twine2 entries first). */
1238
+ function findEntryForRequest(index, request) {
1239
+ return selectFormatCandidate(request, [...(index.twine2 ?? []).map((entry) => ({
1240
+ entry,
1241
+ formatType: "twine2"
1242
+ })), ...(index.twine1 ?? []).map((entry) => ({
1243
+ entry,
1244
+ formatType: "twine1"
1245
+ }))], (c) => c.entry);
1246
+ }
1247
+ /** Verify SHA-256 checksum using Web Crypto API (Node 22 built-in). */
1248
+ async function verifySHA256(content, expectedHex) {
1249
+ const digest = await crypto.subtle.digest("SHA-256", content);
1250
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("") === expectedHex.toLowerCase();
1251
+ }
1252
+ /** Derive the download URL for a format.js from the index URL and entry. */
1253
+ function getDownloadUrl(indexUrl, entry, formatType) {
1254
+ return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
1255
+ }
1256
+ /** Download a format, verify its checksum, write to cache, and return StoryFormatInfo. */
1257
+ async function fetchAndCacheFormat(entry, downloadUrl) {
1258
+ cachedFormatDir(entry.name, entry.version);
1259
+ const res = await fetch(downloadUrl);
1260
+ if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
1261
+ const text = await res.text();
1262
+ const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
1263
+ if (checksumKey) {
1264
+ const expected = entry.checksums[checksumKey];
1265
+ if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
1266
+ if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
1267
+ }
1268
+ const id = makeFormatId(entry.name, entry.version);
1269
+ const data = parseFormatJSON(text, id);
1270
+ if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
1271
+ return {
1272
+ id,
1273
+ filename: writeCachedFormat(entry.name, entry.version, text),
1274
+ isTwine2: true,
1275
+ name: data.name,
1276
+ version: data.version,
1277
+ proofing: data.proofing ?? false
1278
+ };
1279
+ }
1280
+ /** Download a direct format.js URL, parse its JSON, cache it, and return StoryFormatInfo. */
1281
+ async function fetchDirectFormat(url) {
1282
+ const res = await fetch(url);
1283
+ if (!res.ok) throw new Error(`Failed to download format from ${url}: ${res.status} ${res.statusText}`);
1284
+ const text = await res.text();
1285
+ const data = parseFormatJSON(text);
1286
+ if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
1287
+ return {
1288
+ id: makeFormatId(data.name, data.version),
1289
+ filename: writeCachedFormat(data.name, data.version, text),
1290
+ isTwine2: true,
1291
+ name: data.name,
1292
+ version: data.version,
1293
+ proofing: data.proofing ?? false
1294
+ };
1295
+ }
1296
+ /**
1297
+ * Try to resolve a remote story format by name and version.
1298
+ * 0. Use an exactly matching cached download, without touching the network
1299
+ * 1. Try direct format URLs
1300
+ * 2. Try custom index URLs
1301
+ * 3. Try default SFA indices
1302
+ * 4. Fall back to a compatible cached download (e.g. when offline)
1303
+ */
1304
+ async function resolveRemoteFormat(name, version, indices, urls) {
1305
+ return resolveRemoteFormatRequest({
1306
+ kind: "name",
1307
+ name,
1308
+ version
1309
+ }, indices, urls);
1310
+ }
1311
+ /**
1312
+ * Resolve a story format request remotely, in the same order as {@link resolveRemoteFormat}.
1313
+ * An ID request such as 'sugarcube-2' matches the format whose name and major version build that ID
1314
+ * (SugarCube 2.x), taking the greatest version available.
1315
+ */
1316
+ async function resolveRemoteFormatRequest(request, indices, urls) {
1317
+ let lastError;
1318
+ const cached = findCachedFormat(request);
1319
+ if (cached && request.kind === "name" && isExactVersion(cached.version, request.version)) return cached;
1320
+ for (const url of urls ?? []) try {
1321
+ const info = await fetchDirectFormat(url);
1322
+ if (selectFormatCandidate(request, [info], (f) => f)) return info;
1323
+ } catch (e) {
1324
+ lastError = e instanceof Error ? e : new Error(String(e));
1325
+ }
1326
+ const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
1327
+ for (const indexUrl of allIndices) try {
1328
+ const result = findEntryForRequest(await fetchIndex(indexUrl), request);
1329
+ if (result) {
1330
+ const hit = getCachedFormat(result.entry.name, result.entry.version);
1331
+ if (hit) return hit;
1332
+ const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
1333
+ return await fetchAndCacheFormat(result.entry, downloadUrl);
1334
+ }
1335
+ } catch (e) {
1336
+ lastError = e instanceof Error ? e : new Error(String(e));
1337
+ }
1338
+ if (cached) return cached;
1339
+ if (lastError) throw lastError;
1340
+ }
1341
+ /** Whether two version strings name the same SemVer version. */
1342
+ function isExactVersion(have, wanted) {
1343
+ const a = parseSemver(have);
1344
+ const b = parseSemver(wanted);
1345
+ return a !== null && b !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
1346
+ }
1347
+ /**
1348
+ * Find the cached download that best answers a format request, without network access.
1349
+ * Matching follows {@link selectFormatCandidate}.
1350
+ */
1351
+ function findCachedFormat(request, options = {}) {
1352
+ return selectFormatCandidate(request, [...discoverCachedFormats().values()], (f) => f, options);
1353
+ }
1354
+ /** Check if a format is already in the local cache. */
1355
+ function getCachedFormat(name, version) {
1356
+ if (!isSafeSegment(name) || !isSafeSegment(version)) return void 0;
1357
+ const formatPath = join(getCacheDir(), name, version, "format.js");
1358
+ try {
1359
+ if (!existsSync(formatPath)) return void 0;
1360
+ const source = readFileSync(formatPath, "utf-8");
1361
+ const id = makeFormatId(name, version);
1362
+ const data = parseFormatJSON(source, id);
1363
+ if (!data) return void 0;
1364
+ return {
1365
+ id,
1366
+ filename: formatPath,
1367
+ isTwine2: true,
1368
+ name: data.name,
1369
+ version: data.version,
1370
+ proofing: data.proofing ?? false
1371
+ };
1372
+ } catch {
1373
+ return;
1374
+ }
1375
+ }
1376
+ /** Discover all cached remote formats. Returns a Map like discoverFormats(). */
1377
+ function discoverCachedFormats() {
1378
+ const formats = /* @__PURE__ */ new Map();
1379
+ const cacheDir = getCacheDir();
1380
+ try {
1381
+ if (!existsSync(cacheDir)) return formats;
1382
+ } catch {
1383
+ return formats;
1384
+ }
1385
+ let names;
1386
+ try {
1387
+ names = readdirSync(cacheDir);
1388
+ } catch {
1389
+ return formats;
1390
+ }
1391
+ for (const name of names) {
1392
+ const nameDir = join(cacheDir, name);
1393
+ try {
1394
+ if (!statSync(nameDir).isDirectory()) continue;
1395
+ } catch {
1396
+ continue;
1397
+ }
1398
+ let versions;
1399
+ try {
1400
+ versions = readdirSync(nameDir);
1401
+ } catch {
1402
+ continue;
1403
+ }
1404
+ for (const version of versions) {
1405
+ const info = getCachedFormat(name, version);
1406
+ if (info) formats.set(`${info.id}-${version}`, info);
1407
+ }
1408
+ }
1409
+ return formats;
1410
+ }
1411
+ /** List all cached formats with size and modification date. */
1412
+ function listCachedFormats() {
1413
+ const cacheDir = getCacheDir();
1414
+ const entries = [];
1415
+ try {
1416
+ if (!existsSync(cacheDir)) return entries;
1417
+ } catch {
1418
+ return entries;
1419
+ }
1420
+ let names;
1421
+ try {
1422
+ names = readdirSync(cacheDir);
1423
+ } catch {
1424
+ return entries;
1425
+ }
1426
+ for (const name of names) {
1427
+ const nameDir = join(cacheDir, name);
1428
+ try {
1429
+ if (!statSync(nameDir).isDirectory()) continue;
1430
+ } catch {
1431
+ continue;
1432
+ }
1433
+ let versions;
1434
+ try {
1435
+ versions = readdirSync(nameDir);
1436
+ } catch {
1437
+ continue;
1438
+ }
1439
+ for (const version of versions) {
1440
+ const formatPath = join(nameDir, version, "format.js");
1441
+ try {
1442
+ const stat = statSync(formatPath);
1443
+ entries.push({
1444
+ name,
1445
+ version,
1446
+ sizeBytes: stat.size,
1447
+ modifiedAt: stat.mtime
1448
+ });
1449
+ } catch {}
1450
+ }
1451
+ }
1452
+ return entries;
1453
+ }
1454
+ /**
1455
+ * Clear all cached formats, or only those matching a given name. Returns the number of entries removed.
1456
+ * A name must be a single cache entry name (as `listCachedFormats()` reports it); anything with a
1457
+ * path separator or `.`/`..` throws instead of deleting outside the format's own directory.
1458
+ */
1459
+ function clearCachedFormats(name) {
1460
+ if (name && !isSafeSegment(name)) throw new Error(`Refusing to clear ${JSON.stringify(name)}: it is not a cached format name. Use a name as "cache list" shows it.`);
1461
+ const cacheDir = getCacheDir();
1462
+ if (!existsSync(cacheDir)) return 0;
1463
+ if (!name) {
1464
+ const count = listCachedFormats().length;
1465
+ rmSync(cacheDir, {
1466
+ recursive: true,
1467
+ force: true
1468
+ });
1469
+ return count;
1470
+ }
1471
+ const root = resolve(cacheDir);
1472
+ const nameDir = resolve(root, name);
1473
+ if (!isInside(root, nameDir)) throw new Error(`Refusing to clear ${JSON.stringify(name)}: it is not a cached format name.`);
1474
+ if (!lstatSync(nameDir, { throwIfNoEntry: false })?.isDirectory()) return 0;
1475
+ let versions;
1476
+ try {
1477
+ versions = readdirSync(nameDir);
1478
+ } catch {
1479
+ return 0;
1480
+ }
1481
+ const count = versions.length;
1482
+ rmSync(nameDir, {
1483
+ recursive: true,
1484
+ force: true
1485
+ });
1486
+ return count;
1487
+ }
1488
+ /** Get total cache size in bytes and format count. */
1489
+ function getCacheSize() {
1490
+ const entries = listCachedFormats();
1491
+ return {
1492
+ totalBytes: entries.reduce((sum, e) => sum + e.sizeBytes, 0),
1493
+ count: entries.length
1494
+ };
1495
+ }
1496
+ //#endregion
1497
+ //#region src/format-resolution.ts
1498
+ /** Choose the format request: explicit format ID > StoryData format > default ID. */
1499
+ function formatRequestFor(formatId, storyFormat, storyFormatVersion, defaultId) {
1500
+ if (formatId) return {
1501
+ kind: "id",
1502
+ id: formatId
1503
+ };
1504
+ if (storyFormat) return {
1505
+ kind: "name",
1506
+ name: storyFormat,
1507
+ version: storyFormatVersion
1508
+ };
1509
+ return {
1510
+ kind: "id",
1511
+ id: defaultId
1512
+ };
1513
+ }
1514
+ /** Look a request up among the formats found in local format directories. */
1515
+ function findLocalFormat(formats, request) {
1516
+ switch (request.kind) {
1517
+ case "id": return formats.get(request.id);
1518
+ case "name": {
1519
+ const id = getFormatIdByNameAndVersion(formats, request.name, request.version);
1520
+ return id === void 0 ? void 0 : formats.get(id);
1521
+ }
1522
+ default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
1523
+ }
1524
+ }
1525
+ /**
1526
+ * Resolve a format request to a story format.
1527
+ *
1528
+ * Order: local format directories, then the download cache, then remote sources (unless
1529
+ * `noRemote`). When a StoryData request names a version that none of them has, an older version of
1530
+ * the same format and major version is used with a warning. Anything else is an error diagnostic,
1531
+ * and the result is `undefined`.
1532
+ */
1533
+ async function resolveStoryFormat(request, options, diagnostics) {
1534
+ const noRemote = options.noRemote ?? false;
1535
+ const formats = discoverFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true));
1536
+ const wanted = describeFormatRequest(request);
1537
+ const local = findLocalFormat(formats, request);
1538
+ if (local) return local;
1539
+ const cached = findCachedFormat(request);
1540
+ if (cached) return cached;
1541
+ if (!noRemote) try {
1542
+ const remote = await resolveRemoteFormatRequest(request, options.formatIndices, options.formatUrls);
1543
+ if (remote) return remote;
1544
+ } catch (e) {
1545
+ diagnostics.push({
1546
+ level: "warning",
1547
+ message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
1548
+ });
1549
+ }
1550
+ if (request.kind === "name") {
1551
+ const older = selectFormatCandidate(request, [...formats.values()].filter((f) => f.isTwine2), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
1552
+ if (older) {
1553
+ diagnostics.push({
1554
+ level: "warning",
1555
+ message: `Story format ${wanted} is not available; using ${older.name} ${older.version} instead.`
1556
+ });
1557
+ return older;
1558
+ }
1559
+ }
1560
+ const reason = noRemote ? " (remote fetching disabled)" : "";
1561
+ diagnostics.push({
1562
+ level: "error",
1563
+ message: `Story format ${wanted} is not available${reason}. Found: ${[...formats.keys()].join(", ") || "none"}`
1564
+ });
1565
+ }
1566
+ //#endregion
1567
+ //#region src/types.ts
1568
+ const ItemType = {
1569
+ Error: 0,
1570
+ EOF: 1,
1571
+ Header: 2,
1572
+ Name: 3,
1573
+ Tags: 4,
1574
+ Metadata: 5,
1575
+ Content: 6
1576
+ };
1577
+ //#endregion
1578
+ //#region src/lexer.ts
1579
+ const EOF = -1;
1580
+ const HEADER_DELIM = "::";
1581
+ const NEWLINE_HEADER_DELIM = "\n::";
1582
+ var LexerContext = class {
1583
+ input;
1584
+ line = 1;
1585
+ start = 0;
1586
+ pos = 0;
1587
+ items = [];
1588
+ constructor(input) {
1589
+ this.input = input;
1590
+ }
1591
+ next() {
1592
+ if (this.pos >= this.input.length) return EOF;
1593
+ const ch = this.input.charCodeAt(this.pos);
1594
+ this.pos++;
1595
+ if (ch === 10) this.line++;
1596
+ return ch;
1597
+ }
1598
+ peek() {
1599
+ if (this.pos >= this.input.length) return EOF;
1600
+ return this.input.charCodeAt(this.pos);
1601
+ }
1602
+ backup() {
1603
+ if (this.pos > this.start) {
1604
+ this.pos--;
1605
+ if (this.input.charCodeAt(this.pos) === 10) this.line--;
1606
+ } else throw new Error("backup would leave pos < start");
1607
+ }
1608
+ emit(type) {
1609
+ const val = this.input.slice(this.start, this.pos);
1610
+ this.items.push({
1611
+ type,
1612
+ line: this.line,
1613
+ pos: this.start,
1614
+ val
1615
+ });
1616
+ if (type === ItemType.Content) {
1617
+ for (let i = this.start; i < this.pos; i++) if (this.input.charCodeAt(i) === 10) this.line++;
1618
+ }
1619
+ this.start = this.pos;
1620
+ }
1621
+ ignore() {
1622
+ for (let i = this.start; i < this.pos; i++) if (this.input.charCodeAt(i) === 10) this.line++;
1623
+ this.start = this.pos;
1624
+ }
1625
+ acceptRun(valid) {
1626
+ let ch;
1627
+ for (ch = this.next(); ch !== EOF && valid.includes(String.fromCharCode(ch)); ch = this.next());
1628
+ if (ch !== EOF) this.backup();
1629
+ }
1630
+ errorf(message) {
1631
+ this.items.push({
1632
+ type: ItemType.Error,
1633
+ line: this.line,
1634
+ pos: this.start,
1635
+ val: message
1636
+ });
1637
+ return null;
1638
+ }
1639
+ /** Drain all pending items and return them. */
1640
+ drainItems() {
1641
+ const result = this.items;
1642
+ this.items = [];
1643
+ return result;
1644
+ }
1645
+ };
1646
+ function acceptQuoted(ctx, quote) {
1647
+ for (;;) {
1648
+ const ch = ctx.next();
1649
+ switch (ch) {
1650
+ case 92: {
1651
+ const r = ctx.next();
1652
+ if (r !== 10 && r !== EOF) break;
1653
+ }
1654
+ case 10:
1655
+ case EOF: return "unterminated quoted string";
1656
+ default: if (ch === quote) return null;
1657
+ }
1658
+ }
1659
+ }
1660
+ function lexProlog(ctx) {
1661
+ if (ctx.input.startsWith(HEADER_DELIM, ctx.pos)) return lexHeaderDelim;
1662
+ const i = ctx.input.indexOf(NEWLINE_HEADER_DELIM, ctx.pos);
1663
+ if (i > -1) {
1664
+ ctx.pos = i + 1;
1665
+ ctx.ignore();
1666
+ return lexHeaderDelim;
1667
+ }
1668
+ ctx.emit(ItemType.EOF);
1669
+ return null;
1670
+ }
1671
+ function lexContent(ctx) {
1672
+ if (ctx.input.startsWith(HEADER_DELIM, ctx.pos)) return lexHeaderDelim;
1673
+ const i = ctx.input.indexOf(NEWLINE_HEADER_DELIM, ctx.pos);
1674
+ if (i > -1) {
1675
+ ctx.pos = i + 1;
1676
+ ctx.emit(ItemType.Content);
1677
+ return lexHeaderDelim;
1678
+ }
1679
+ ctx.pos = ctx.input.length;
1680
+ if (ctx.pos > ctx.start) ctx.emit(ItemType.Content);
1681
+ ctx.emit(ItemType.EOF);
1682
+ return null;
1683
+ }
1684
+ function lexHeaderDelim(ctx) {
1685
+ ctx.pos += 2;
1686
+ ctx.emit(ItemType.Header);
1687
+ return lexName;
1688
+ }
1689
+ function lexName(ctx) {
1690
+ let r;
1691
+ outer: for (;;) {
1692
+ r = ctx.next();
1693
+ switch (r) {
1694
+ case 92: {
1695
+ const next = ctx.next();
1696
+ if (next !== 10 && next !== EOF) break;
1697
+ r = next;
1698
+ }
1699
+ case 91:
1700
+ case 93:
1701
+ case 123:
1702
+ case 125:
1703
+ case 10:
1704
+ case EOF:
1705
+ if (r !== EOF) ctx.backup();
1706
+ break outer;
1707
+ }
1708
+ }
1709
+ ctx.emit(ItemType.Name);
1710
+ switch (r) {
1711
+ case 91: return lexTags;
1712
+ case 93: return ctx.errorf(`unexpected right square bracket ']'`);
1713
+ case 123: return lexMetadata;
1714
+ case 125: return ctx.errorf(`unexpected right curly brace '}'`);
1715
+ case 10:
1716
+ ctx.pos++;
1717
+ ctx.ignore();
1718
+ return lexContent;
1719
+ }
1720
+ ctx.emit(ItemType.EOF);
1721
+ return null;
1722
+ }
1723
+ function lexNextOptionalBlock(ctx) {
1724
+ ctx.acceptRun(" ");
1725
+ ctx.ignore();
1726
+ const r = ctx.peek();
1727
+ switch (r) {
1728
+ case 91: return lexTags;
1729
+ case 93: return ctx.errorf(`unexpected right square bracket ']'`);
1136
1730
  case 123: return lexMetadata;
1137
1731
  case 125: return ctx.errorf(`unexpected right curly brace '}'`);
1138
1732
  case 10:
@@ -1259,16 +1853,20 @@ function twee2ToV3(s) {
1259
1853
  //#region src/parser.ts
1260
1854
  /**
1261
1855
  * Parse Twee source text into passages.
1856
+ *
1857
+ * The source is normalized first, as files are when they are read: a leading UTF-8 BOM is
1858
+ * removed and CRLF and bare CR line endings become LF.
1262
1859
  */
1263
1860
  function parseTwee(source, options = {}) {
1264
1861
  const { filename = "<inline>", trim = true, twee2Compat = false } = options;
1265
1862
  const diagnostics = [];
1266
- if (twee2Compat) source = twee2ToV3(source);
1863
+ const normalized = normalizeSourceText(source);
1864
+ const tweeSource = twee2Compat ? twee2ToV3(normalized) : normalized;
1267
1865
  const passages = [];
1268
1866
  let current = null;
1269
1867
  let pCount = 0;
1270
1868
  let lastType = ItemType.EOF;
1271
- for (const item of tweeLexer(source)) {
1869
+ for (const item of tweeLexer(tweeSource)) {
1272
1870
  switch (item.type) {
1273
1871
  case ItemType.Error:
1274
1872
  diagnostics.push({
@@ -4149,6 +4747,7 @@ function decompileTwine2(storyData, story, diagnostics) {
4149
4747
  });
4150
4748
  else story.twine2.zoom = parsed;
4151
4749
  }
4750
+ if (attrs["tags"]) story.twine2.tags = attrs["tags"];
4152
4751
  if (attrs["format"]) story.twine2.format = attrs["format"];
4153
4752
  if (attrs["format-version"]) story.twine2.formatVersion = attrs["format-version"];
4154
4753
  if (attrs["options"]) for (const opt of attrs["options"].split(/\s+/).filter((s) => s.length > 0)) story.twine2.options.set(opt, true);
@@ -4208,22 +4807,32 @@ function decompileTwine2(storyData, story, diagnostics) {
4208
4807
  }, diagnostics);
4209
4808
  }
4210
4809
  function decompileTwine1(storeArea, story, diagnostics) {
4211
- for (const node of storeArea.children ?? []) {
4212
- if (node.type !== "tag" || node.name !== "div") continue;
4213
- const nodeAttrs = node.attribs ?? {};
4214
- if (!("tiddler" in nodeAttrs)) continue;
4215
- const name = nodeAttrs["tiddler"] ?? "";
4216
- const tags = nodeAttrs["tags"] ? nodeAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [];
4217
- const metadata = {};
4218
- if (nodeAttrs["twine-position"]) metadata.position = nodeAttrs["twine-position"];
4219
- const passage = {
4220
- name,
4221
- tags,
4222
- text: tiddlerUnescape(getTextContent(node)).trim()
4223
- };
4224
- if (metadata.position) passage.metadata = metadata;
4225
- storyAdd(story, passage, diagnostics);
4226
- }
4810
+ const passages = (storeArea.children ?? []).filter(isTiddler).map(tiddlerToPassage);
4811
+ const obfuscated = isRot13Obfuscated(passages);
4812
+ for (const p of passages) storyAdd(story, obfuscated && p.name !== "StorySettings" ? {
4813
+ ...p,
4814
+ text: rot13(p.text)
4815
+ } : p, diagnostics);
4816
+ }
4817
+ function isTiddler(node) {
4818
+ return node.type === "tag" && node.name === "div" && node.attribs !== void 0 && "tiddler" in node.attribs;
4819
+ }
4820
+ function tiddlerToPassage(node) {
4821
+ const nodeAttrs = node.attribs ?? {};
4822
+ const passage = {
4823
+ name: nodeAttrs["tiddler"] ?? "",
4824
+ tags: nodeAttrs["tags"] ? nodeAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [],
4825
+ text: tiddlerUnescape(getTextContent(node)).trim()
4826
+ };
4827
+ const position = nodeAttrs["twine-position"];
4828
+ if (position) passage.metadata = { position };
4829
+ return passage;
4830
+ }
4831
+ /** Whether the StorySettings tiddlers turn on ROT13 obfuscation, read the way the writer reads them. */
4832
+ function isRot13Obfuscated(passages) {
4833
+ const settings = createStory();
4834
+ for (const p of passages) if (p.name === "StorySettings") unmarshalStorySettings(settings, p.text, []);
4835
+ return settings.twine1.settings.get("obfuscate") === "rot13";
4227
4836
  }
4228
4837
  function findElement(node, tagName) {
4229
4838
  if (node.type === "tag" && node.name === tagName) return node;
@@ -4311,242 +4920,19 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
4311
4920
  case "opus":
4312
4921
  case "wav":
4313
4922
  case "wave":
4314
- case "weba":
4315
- loadMedia(story, "Twine.audio", filename, diagnostics);
4316
- break;
4317
- case "mp4":
4318
- case "ogv":
4319
- case "webm":
4320
- loadMedia(story, "Twine.video", filename, diagnostics);
4321
- break;
4322
- case "vtt":
4323
- loadMedia(story, "Twine.vtt", filename, diagnostics);
4324
- break;
4325
- default: continue;
4326
- }
4327
- } catch (e) {
4328
- diagnostics.push({
4329
- level: "error",
4330
- message: `load ${filename}: ${e instanceof Error ? e.message : String(e)}`,
4331
- file: filename
4332
- });
4333
- continue;
4334
- }
4335
- processedFiles.add(filename);
4336
- }
4337
- if (story.name !== "" && !story.passages.some((p) => p.name === "StoryTitle")) storyPrepend(story, {
4338
- name: "StoryTitle",
4339
- tags: [],
4340
- text: story.name
4341
- }, diagnostics);
4342
- }
4343
- /**
4344
- * Load inline sources (string content or InlineSource objects).
4345
- */
4346
- function loadInlineSources(story, sources, opts, diagnostics) {
4347
- for (const source of sources) {
4348
- if (typeof source === "string") continue;
4349
- const content = typeof source.content === "string" ? source.content : source.content.toString("utf-8");
4350
- const ext = normalizedFileExt(source.filename);
4351
- switch (ext) {
4352
- case "":
4353
- case "tw":
4354
- case "twee":
4355
- case "tw2":
4356
- case "twee2": {
4357
- const twee2 = ext === "tw2" || ext === "twee2" || opts.twee2Compat;
4358
- const result = parseTwee(content, {
4359
- filename: source.filename,
4360
- trim: opts.trim ?? true,
4361
- twee2Compat: twee2
4362
- });
4363
- diagnostics.push(...result.diagnostics);
4364
- for (const p of result.passages) storyAdd(story, p, diagnostics);
4365
- break;
4366
- }
4367
- case "css":
4368
- storyAdd(story, {
4369
- name: basename(source.filename),
4370
- tags: ["stylesheet"],
4371
- text: content
4372
- }, diagnostics);
4373
- break;
4374
- case "js":
4375
- storyAdd(story, {
4376
- name: basename(source.filename),
4377
- tags: ["script"],
4378
- text: content
4379
- }, diagnostics);
4380
- break;
4381
- default: diagnostics.push({
4382
- level: "warning",
4383
- message: `load ${source.filename}: in-memory sources of type .${ext} are not supported; skipped.`
4384
- });
4385
- }
4386
- }
4387
- }
4388
- function parseTweeFile(filename, opts) {
4389
- const result = parseTwee(readUTF8(filename), {
4390
- filename,
4391
- trim: opts.trim ?? true,
4392
- twee2Compat: opts.twee2Compat ?? false
4393
- });
4394
- return {
4395
- passages: result.passages,
4396
- diagnostics: [...result.diagnostics]
4397
- };
4398
- }
4399
- function parseTaggedFile(tag, filename) {
4400
- const source = readUTF8(filename);
4401
- return {
4402
- passages: [{
4403
- name: basename(filename),
4404
- tags: [tag],
4405
- text: source
4406
- }],
4407
- diagnostics: []
4408
- };
4409
- }
4410
- function parseMediaFile(tag, filename) {
4411
- const source = readBase64(filename);
4412
- return {
4413
- passages: [{
4414
- name: baseNameWithoutExt(filename),
4415
- tags: [tag],
4416
- text: `data:${mediaTypeFromFilename(filename)};base64,${source}`
4417
- }],
4418
- diagnostics: []
4419
- };
4420
- }
4421
- function parseFontFile(filename) {
4422
- const source = readBase64(filename);
4423
- const name = basename(filename);
4424
- const family = baseNameWithoutExt(filename);
4425
- const ext = normalizedFileExt(filename);
4426
- return {
4427
- passages: [{
4428
- name,
4429
- tags: ["stylesheet"],
4430
- text: `@font-face {\n\tfont-family: "${family}";\n\tsrc: url("data:${mediaTypeFromExt(ext)};base64,${source}") format("${fontFormatHint(ext)}");\n}`
4431
- }],
4432
- diagnostics: []
4433
- };
4434
- }
4435
- function parseHTMLFile(filename) {
4436
- const result = decompileHTML(readUTF8(filename));
4437
- return {
4438
- passages: result.story.passages,
4439
- diagnostics: [...result.diagnostics]
4440
- };
4441
- }
4442
- function parseFile(filename, opts) {
4443
- switch (normalizedFileExt(filename)) {
4444
- case "tw":
4445
- case "twee": return parseTweeFile(filename, opts);
4446
- case "tw2":
4447
- case "twee2": return parseTweeFile(filename, {
4448
- ...opts,
4449
- twee2Compat: true
4450
- });
4451
- case "htm":
4452
- case "html": return parseHTMLFile(filename);
4453
- case "css": return parseTaggedFile("stylesheet", filename);
4454
- case "js": return parseTaggedFile("script", filename);
4455
- case "otf":
4456
- case "ttf":
4457
- case "woff":
4458
- case "woff2": return parseFontFile(filename);
4459
- case "gif":
4460
- case "jpeg":
4461
- case "jpg":
4462
- case "png":
4463
- case "svg":
4464
- case "tif":
4465
- case "tiff":
4466
- case "webp": return parseMediaFile("Twine.image", filename);
4467
- case "aac":
4468
- case "flac":
4469
- case "m4a":
4470
- case "mp3":
4471
- case "oga":
4472
- case "ogg":
4473
- case "opus":
4474
- case "wav":
4475
- case "wave":
4476
- case "weba": return parseMediaFile("Twine.audio", filename);
4477
- case "mp4":
4478
- case "ogv":
4479
- case "webm": return parseMediaFile("Twine.video", filename);
4480
- case "vtt": return parseMediaFile("Twine.vtt", filename);
4481
- default: return;
4482
- }
4483
- }
4484
- function loadHTML(story, filename, diagnostics) {
4485
- const result = parseHTMLFile(filename);
4486
- diagnostics.push(...result.diagnostics);
4487
- for (const p of result.passages) storyAdd(story, p, diagnostics);
4488
- }
4489
- function loadTwee(story, filename, opts, diagnostics) {
4490
- const result = parseTweeFile(filename, opts);
4491
- diagnostics.push(...result.diagnostics);
4492
- for (const p of result.passages) storyAdd(story, p, diagnostics);
4493
- }
4494
- function loadTagged(story, tag, filename, diagnostics) {
4495
- const result = parseTaggedFile(tag, filename);
4496
- for (const p of result.passages) storyAdd(story, p, diagnostics);
4497
- }
4498
- function loadMedia(story, tag, filename, diagnostics) {
4499
- const result = parseMediaFile(tag, filename);
4500
- for (const p of result.passages) storyAdd(story, p, diagnostics);
4501
- }
4502
- function loadFont(story, filename, diagnostics) {
4503
- const result = parseFontFile(filename);
4504
- for (const p of result.passages) storyAdd(story, p, diagnostics);
4505
- }
4506
- /**
4507
- * Load sources with mtime-based caching for incremental rebuilds.
4508
- * Always creates a fresh Story; cached passages are replayed via storyAdd().
4509
- */
4510
- function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
4511
- const currentFiles = /* @__PURE__ */ new Set();
4512
- for (const filename of filenames) {
4513
- if (processedFiles.has(filename)) {
4514
- diagnostics.push({
4515
- level: "warning",
4516
- message: `load ${filename}: Skipping duplicate.`
4517
- });
4518
- continue;
4519
- }
4520
- currentFiles.add(filename);
4521
- const cached = cache.get(filename);
4522
- if (changedFiles && cached && !changedFiles.has(filename)) {
4523
- diagnostics.push(...cached.diagnostics);
4524
- for (const p of cached.passages) storyAdd(story, p, diagnostics);
4525
- processedFiles.add(filename);
4526
- continue;
4527
- }
4528
- let mtimeMs;
4529
- try {
4530
- mtimeMs = statSync(filename).mtimeMs;
4531
- } catch {
4532
- continue;
4533
- }
4534
- if (cached && cached.mtimeMs === mtimeMs) {
4535
- diagnostics.push(...cached.diagnostics);
4536
- for (const p of cached.passages) storyAdd(story, p, diagnostics);
4537
- processedFiles.add(filename);
4538
- continue;
4539
- }
4540
- try {
4541
- const result = parseFile(filename, opts);
4542
- if (!result) continue;
4543
- cache.set(filename, {
4544
- mtimeMs,
4545
- passages: result.passages,
4546
- diagnostics: result.diagnostics
4547
- });
4548
- diagnostics.push(...result.diagnostics);
4549
- for (const p of result.passages) storyAdd(story, p, diagnostics);
4923
+ case "weba":
4924
+ loadMedia(story, "Twine.audio", filename, diagnostics);
4925
+ break;
4926
+ case "mp4":
4927
+ case "ogv":
4928
+ case "webm":
4929
+ loadMedia(story, "Twine.video", filename, diagnostics);
4930
+ break;
4931
+ case "vtt":
4932
+ loadMedia(story, "Twine.vtt", filename, diagnostics);
4933
+ break;
4934
+ default: continue;
4935
+ }
4550
4936
  } catch (e) {
4551
4937
  diagnostics.push({
4552
4938
  level: "error",
@@ -4557,572 +4943,577 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
4557
4943
  }
4558
4944
  processedFiles.add(filename);
4559
4945
  }
4560
- for (const key of cache.keys()) if (!currentFiles.has(key)) cache.delete(key);
4561
4946
  if (story.name !== "" && !story.passages.some((p) => p.name === "StoryTitle")) storyPrepend(story, {
4562
4947
  name: "StoryTitle",
4563
4948
  tags: [],
4564
4949
  text: story.name
4565
4950
  }, diagnostics);
4566
4951
  }
4567
- //#endregion
4568
- //#region src/version.ts
4569
- /**
4570
- * The twee-ts version, read at runtime from the package's own package.json.
4571
- *
4572
- * The release workflow (semantic-release) writes the new version into the
4573
- * published package.json only; git keeps the old one. Reading it at runtime
4574
- * is the only way the CLI and `creator-version` report the published version.
4575
- */
4576
- const PACKAGE_NAME = "@rohal12/twee-ts";
4577
- /** Walks up from `startDir` to the nearest package.json named @rohal12/twee-ts and returns its version. */
4578
- function findPackageVersion(startDir) {
4579
- let dir = startDir;
4580
- for (;;) {
4581
- try {
4582
- const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
4583
- if (pkg.name === PACKAGE_NAME && typeof pkg.version === "string") return pkg.version;
4584
- } catch {}
4585
- const parent = dirname(dir);
4586
- if (parent === dir) return void 0;
4587
- dir = parent;
4588
- }
4589
- }
4590
- /** This module's directory: `__dirname` in the CJS build, `import.meta.url` in ESM and in source. */
4591
- function moduleDir() {
4592
- return typeof __dirname === "string" ? __dirname : dirname(fileURLToPath(import.meta.url));
4593
- }
4594
- const VERSION = findPackageVersion(moduleDir()) ?? "0.0.0";
4595
- //#endregion
4596
- //#region src/output-twine2.ts
4597
- const CREATOR_NAME$2 = "Twee-ts";
4598
- function toTwine2Archive(story, startName, options) {
4599
- return getTwine2DataChunk(story, startName, options) + "\n";
4600
- }
4601
- function toTwine2HTML(story, format, startName, options) {
4602
- let template = readFormatSource(format);
4603
- if (template.includes("{{STORY_NAME}}")) {
4604
- const name = htmlEscape(story.name);
4605
- template = template.replaceAll("{{STORY_NAME}}", () => name);
4606
- }
4607
- if (template.includes("{{STORY_DATA}}")) {
4608
- const data = getTwine2DataChunk(story, startName, options);
4609
- template = template.replace("{{STORY_DATA}}", () => data);
4610
- }
4611
- return template;
4612
- }
4613
- function getTwine2DataChunk(story, startName, options) {
4614
- const parts = [];
4615
- let startID = "";
4616
- let pid = 0;
4617
- const scripts = [];
4618
- const stylesheets = [];
4619
- for (const p of story.passages) {
4620
- if (hasTag(p, "Twine.private")) continue;
4621
- if (hasTag(p, "script")) scripts.push(p);
4622
- else if (hasTag(p, "stylesheet")) stylesheets.push(p);
4623
- }
4624
- let styleContent = "";
4625
- if (stylesheets.length === 1) styleContent = stylesheets[0]?.text ?? "";
4626
- else if (stylesheets.length > 1) {
4627
- let pidS = 1;
4628
- for (const p of stylesheets) {
4629
- if (pidS > 1 && !styleContent.endsWith("\n")) styleContent += "\n";
4630
- styleContent += `/* twine-user-stylesheet #${pidS}: "${commentSanitize(p.name)}" */\n`;
4631
- styleContent += p.text;
4632
- pidS++;
4633
- }
4634
- }
4635
- parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContent}</style>`);
4636
- let scriptContent = "";
4637
- if (scripts.length === 1) scriptContent = scripts[0]?.text ?? "";
4638
- else if (scripts.length > 1) {
4639
- let pidS = 1;
4640
- for (const p of scripts) {
4641
- if (pidS > 1 && !scriptContent.endsWith("\n")) scriptContent += "\n";
4642
- scriptContent += `/* twine-user-script #${pidS}: "${commentSanitize(p.name)}" */\n`;
4643
- scriptContent += p.text;
4644
- pidS++;
4645
- }
4646
- }
4647
- parts.push(`<script role="script" id="twine-user-script" type="text/twine-javascript">${scriptContent}<\/script>`);
4648
- const validTagColors = /* @__PURE__ */ new Set([
4649
- "gray",
4650
- "red",
4651
- "orange",
4652
- "yellow",
4653
- "green",
4654
- "blue",
4655
- "purple"
4656
- ]);
4657
- const hexColorPattern = /^#[0-9a-fA-F]{3,8}$/;
4658
- 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>`);
4659
- pid = 1;
4660
- for (const p of story.passages) {
4661
- if (p.name === "StoryTitle" || p.name === "StoryData" || hasAnyTag(p, "script", "stylesheet", "Twine.private")) continue;
4662
- if (p.name === "StorySettings" && story.twine1.settings.size === 0) continue;
4663
- parts.push(passageToPassagedata(p, pid, options));
4664
- if (startName === p.name) startID = String(pid);
4665
- pid++;
4666
- }
4667
- const opts = [];
4668
- for (const [opt, val] of story.twine2.options) if (val) opts.push(opt);
4669
- const optionsStr = opts.join(" ");
4670
- const zoom = String(story.twine2.zoom);
4671
- return `<!-- UUID://${htmlCommentSanitize(story.ifid)}// --><tw-storydata name="${attrEscape(story.name)}" startnode="${attrEscape(startID)}" creator="${attrEscape(CREATOR_NAME$2)}" creator-version="${attrEscape(VERSION)}" ifid="${attrEscape(story.ifid)}" zoom="${attrEscape(zoom)}" format="${attrEscape(story.twine2.format)}" format-version="${attrEscape(story.twine2.formatVersion)}" options="${attrEscape(optionsStr)}" tags="${attrEscape(story.twine2.tags)}" hidden>` + parts.join("") + "</tw-storydata>";
4672
- }
4673
- //#endregion
4674
- //#region src/output-twine1.ts
4675
- /**
4676
- * Twine 1 HTML and archive output.
4677
- * Ported from storyout.go.
4678
- */
4679
- const CREATOR_NAME$1 = "twee-ts";
4680
- function toTwine1Archive(story, _startName) {
4681
- const { data, count } = getTwine1PassageChunk(story);
4682
- return `<div id="storeArea" data-size="${count}">${data}</div>\n`;
4683
- }
4684
- function toTwine1HTML(story, format, startName) {
4685
- const formatDir = dirname(format.filename);
4686
- const parentDir = dirname(formatDir);
4687
- let template = readFormatSource(format);
4688
- const { data, count } = getTwine1PassageChunk(story);
4689
- template = tryReplaceComponent(template, "\"USER_LIB\"", join(formatDir, "userlib.js"), false);
4690
- template = tryReplaceComponent(template, "\"ENGINE\"", join(parentDir, "engine.js"), true);
4691
- template = tryReplaceComponent(template, "\"SUGARCANE\"", join(formatDir, "code.js"), true);
4692
- template = tryReplaceComponent(template, "\"JONAH\"", join(formatDir, "code.js"), true);
4693
- if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", join(parentDir, "jquery.js"), true);
4694
- if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", join(parentDir, "modernizr.js"), true);
4695
- const displayStart = startName === "Start" ? "" : startName;
4696
- template = template.replace("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`);
4697
- template = template.replace("\"TIME\"", `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`);
4698
- const startAtValue = `"${jsStringEscape(displayStart)}"`;
4699
- template = template.replace("\"START_AT\"", () => startAtValue);
4700
- template = template.replace("\"STORY_SIZE\"", `"${count}"`);
4701
- if (template.includes("\"STORY\"")) template = template.replace("\"STORY\"", () => data);
4702
- else {
4703
- let footer;
4704
- try {
4705
- footer = readFileSync(join(formatDir, "footer.html"), "utf-8");
4706
- } catch {
4707
- footer = "</div>\n</body>\n</html>\n";
4708
- }
4709
- template += data + footer;
4710
- }
4711
- if (story.ifid) {
4712
- const safeIfid = htmlCommentSanitize(story.ifid);
4713
- if (template.includes("<div id=\"store-area\"")) template = template.replace("<div id=\"store-area\"", `<!-- UUID://${safeIfid}// --><div id="store-area"`);
4714
- else template = template.replace("<div id=\"storeArea\"", `<!-- UUID://${safeIfid}// --><div id="storeArea"`);
4715
- }
4716
- return template;
4717
- }
4718
- function getTwine1PassageChunk(story) {
4719
- const obfuscateRot13 = story.twine1.settings.get("obfuscate") === "rot13";
4720
- let data = "";
4721
- let count = 0;
4722
- for (const p of story.passages) {
4723
- if (hasTag(p, "Twine.private")) continue;
4724
- count++;
4725
- data += passageToTiddler(p, count, obfuscateRot13);
4726
- }
4727
- return {
4728
- data,
4729
- count
4730
- };
4731
- }
4732
- function tryReplaceComponent(template, placeholder, componentPath, required) {
4733
- if (!template.includes(placeholder)) return template;
4734
- try {
4735
- let content = readFileSync(componentPath, "utf-8");
4736
- if (content.charCodeAt(0) === 65279) content = content.slice(1);
4737
- return template.replace(placeholder, () => content);
4738
- } catch (e) {
4739
- if (required) throw new Error(`Required format component not found: ${componentPath}: ${e instanceof Error ? e.message : String(e)}`);
4740
- return template;
4741
- }
4742
- }
4743
- //#endregion
4744
- //#region src/output-twee.ts
4745
- function toTwee(story, outMode) {
4746
- let data = "";
4747
- for (const p of story.passages) data += passageToTwee(p, outMode);
4748
- return data;
4749
- }
4750
- //#endregion
4751
- //#region src/modules.ts
4752
4952
  /**
4753
- * Load modules and return HTML tags to inject before </head>.
4953
+ * Load inline sources (string content or InlineSource objects).
4754
4954
  */
4755
- function loadModules(filenames) {
4756
- const processed = /* @__PURE__ */ new Set();
4757
- const headTags = [];
4758
- for (const filename of filenames) {
4759
- if (processed.has(filename)) continue;
4760
- const ext = normalizedFileExt(filename);
4761
- let tag = null;
4955
+ function loadInlineSources(story, sources, opts, diagnostics) {
4956
+ for (const source of sources) {
4957
+ if (typeof source === "string") continue;
4958
+ const content = normalizeSourceText(typeof source.content === "string" ? source.content : source.content.toString("utf-8"));
4959
+ const ext = normalizedFileExt(source.filename);
4762
4960
  switch (ext) {
4961
+ case "":
4962
+ case "tw":
4963
+ case "twee":
4964
+ case "tw2":
4965
+ case "twee2": {
4966
+ const twee2 = ext === "tw2" || ext === "twee2" || opts.twee2Compat;
4967
+ const result = parseTwee(content, {
4968
+ filename: source.filename,
4969
+ trim: opts.trim ?? true,
4970
+ twee2Compat: twee2
4971
+ });
4972
+ diagnostics.push(...result.diagnostics);
4973
+ for (const p of result.passages) storyAdd(story, p, diagnostics);
4974
+ break;
4975
+ }
4763
4976
  case "css":
4764
- tag = loadModuleTagged("style", filename);
4977
+ storyAdd(story, {
4978
+ name: basename(source.filename),
4979
+ tags: ["stylesheet"],
4980
+ text: content
4981
+ }, diagnostics);
4765
4982
  break;
4766
4983
  case "js":
4767
- tag = loadModuleTagged("script", filename);
4768
- break;
4769
- case "otf":
4770
- case "ttf":
4771
- case "woff":
4772
- case "woff2":
4773
- tag = loadModuleFont(filename);
4984
+ storyAdd(story, {
4985
+ name: basename(source.filename),
4986
+ tags: ["script"],
4987
+ text: content
4988
+ }, diagnostics);
4774
4989
  break;
4775
- default: continue;
4990
+ default: diagnostics.push({
4991
+ level: "warning",
4992
+ message: `load ${source.filename}: in-memory sources of type .${ext} are not supported; skipped.`
4993
+ });
4776
4994
  }
4777
- if (tag) headTags.push(tag);
4778
- processed.add(filename);
4779
4995
  }
4780
- return headTags.join("\n");
4781
4996
  }
4782
- function loadModuleTagged(tag, filename) {
4783
- const source = readUTF8(filename).trim();
4784
- if (source.length === 0) return null;
4785
- return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${source}</${tag}>`;
4997
+ function parseTweeFile(filename, opts) {
4998
+ const result = parseTwee(readUTF8(filename), {
4999
+ filename,
5000
+ trim: opts.trim ?? true,
5001
+ twee2Compat: opts.twee2Compat ?? false
5002
+ });
5003
+ return {
5004
+ passages: result.passages,
5005
+ diagnostics: [...result.diagnostics]
5006
+ };
5007
+ }
5008
+ function parseTaggedFile(tag, filename) {
5009
+ const source = readUTF8(filename);
5010
+ return {
5011
+ passages: [{
5012
+ name: basename(filename),
5013
+ tags: [tag],
5014
+ text: source
5015
+ }],
5016
+ diagnostics: []
5017
+ };
4786
5018
  }
4787
- function loadModuleFont(filename) {
5019
+ function parseMediaFile(tag, filename) {
5020
+ const source = readBase64(filename);
5021
+ return {
5022
+ passages: [{
5023
+ name: baseNameWithoutExt(filename),
5024
+ tags: [tag],
5025
+ text: `data:${mediaTypeFromFilename(filename)};base64,${source}`
5026
+ }],
5027
+ diagnostics: []
5028
+ };
5029
+ }
5030
+ function parseFontFile(filename) {
4788
5031
  const source = readBase64(filename);
5032
+ const name = basename(filename);
4789
5033
  const family = baseNameWithoutExt(filename);
4790
- const idSlug = `style-module-${slugify(family)}`;
4791
5034
  const ext = normalizedFileExt(filename);
4792
- 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>`;
5035
+ return {
5036
+ passages: [{
5037
+ name,
5038
+ tags: ["stylesheet"],
5039
+ text: `@font-face {\n\tfont-family: "${family}";\n\tsrc: url("data:${mediaTypeFromExt(ext)};base64,${source}") format("${fontFormatHint(ext)}");\n}`
5040
+ }],
5041
+ diagnostics: []
5042
+ };
5043
+ }
5044
+ function parseHTMLFile(filename) {
5045
+ const { story, diagnostics } = decompileHTML(readUTF8(filename));
5046
+ return {
5047
+ passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
5048
+ name: "StoryTitle",
5049
+ tags: [],
5050
+ text: story.name
5051
+ }, ...story.passages] : story.passages,
5052
+ diagnostics: [...diagnostics]
5053
+ };
4793
5054
  }
4794
5055
  /**
4795
- * Inject modules and head file content before </head>.
5056
+ * Identity of the options that affect how a file parses, stored on its cache entry.
5057
+ * Only Twee files depend on them; every other file type yields the same key for all options.
4796
5058
  */
4797
- function modifyHead(html, modulePaths, headFile, diagnostics) {
4798
- const parts = [];
4799
- if (modulePaths.length > 0) {
4800
- const modules = loadModules(modulePaths).trim();
4801
- if (modules.length > 0) parts.push(modules);
4802
- }
4803
- if (headFile) try {
4804
- const source = readUTF8(headFile).trim();
4805
- if (source.length > 0) parts.push(source);
4806
- } catch (e) {
4807
- diagnostics?.push({
4808
- level: "warning",
4809
- message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
5059
+ function parseOptionsKey(filename, opts) {
5060
+ const ext = normalizedFileExt(filename);
5061
+ const twee2File = ext === "tw2" || ext === "twee2";
5062
+ if (!twee2File && ext !== "tw" && ext !== "twee") return "";
5063
+ return JSON.stringify({
5064
+ trim: opts.trim ?? true,
5065
+ twee2Compat: twee2File || (opts.twee2Compat ?? false)
5066
+ });
5067
+ }
5068
+ function parseFile(filename, opts) {
5069
+ switch (normalizedFileExt(filename)) {
5070
+ case "tw":
5071
+ case "twee": return parseTweeFile(filename, opts);
5072
+ case "tw2":
5073
+ case "twee2": return parseTweeFile(filename, {
5074
+ ...opts,
5075
+ twee2Compat: true
4810
5076
  });
5077
+ case "htm":
5078
+ case "html": return parseHTMLFile(filename);
5079
+ case "css": return parseTaggedFile("stylesheet", filename);
5080
+ case "js": return parseTaggedFile("script", filename);
5081
+ case "otf":
5082
+ case "ttf":
5083
+ case "woff":
5084
+ case "woff2": return parseFontFile(filename);
5085
+ case "gif":
5086
+ case "jpeg":
5087
+ case "jpg":
5088
+ case "png":
5089
+ case "svg":
5090
+ case "tif":
5091
+ case "tiff":
5092
+ case "webp": return parseMediaFile("Twine.image", filename);
5093
+ case "aac":
5094
+ case "flac":
5095
+ case "m4a":
5096
+ case "mp3":
5097
+ case "oga":
5098
+ case "ogg":
5099
+ case "opus":
5100
+ case "wav":
5101
+ case "wave":
5102
+ case "weba": return parseMediaFile("Twine.audio", filename);
5103
+ case "mp4":
5104
+ case "ogv":
5105
+ case "webm": return parseMediaFile("Twine.video", filename);
5106
+ case "vtt": return parseMediaFile("Twine.vtt", filename);
5107
+ default: return;
4811
5108
  }
4812
- if (parts.length > 0) {
4813
- parts.push("</head>");
4814
- const replacement = parts.join("\n");
4815
- return html.replace("</head>", () => replacement);
4816
- }
4817
- return html;
4818
- }
4819
- //#endregion
4820
- //#region src/remote-formats.ts
4821
- /**
4822
- * Remote story format fetching, caching, and checksum verification.
4823
- * Uses the Story Formats Archive (SFA) as the default source.
4824
- */
4825
- const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
4826
- /** Get the cache directory for downloaded story formats. */
4827
- function getCacheDir() {
4828
- const base = process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache");
4829
- return join(base, "twee-ts", "storyformats");
4830
5109
  }
4831
- /** In-memory index cache, keyed by URL. Cleared each compile. */
4832
- const indexCache = /* @__PURE__ */ new Map();
4833
- /** Clear the in-memory index cache. */
4834
- function clearIndexCache() {
4835
- indexCache.clear();
5110
+ function loadHTML(story, filename, diagnostics) {
5111
+ const result = parseHTMLFile(filename);
5112
+ diagnostics.push(...result.diagnostics);
5113
+ for (const p of result.passages) storyAdd(story, p, diagnostics);
4836
5114
  }
4837
- /** Validate that a JSON value is a valid SFAIndexEntry. */
4838
- function isValidEntry(val) {
4839
- if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
4840
- const obj = val;
4841
- return typeof obj.name === "string" && typeof obj.version === "string" && typeof obj.checksums === "object" && obj.checksums !== null && !Array.isArray(obj.checksums);
5115
+ function loadTwee(story, filename, opts, diagnostics) {
5116
+ const result = parseTweeFile(filename, opts);
5117
+ diagnostics.push(...result.diagnostics);
5118
+ for (const p of result.passages) storyAdd(story, p, diagnostics);
4842
5119
  }
4843
- /** Validate that a JSON value conforms to the SFAIndex shape. */
4844
- function validateSFAIndex(json) {
4845
- if (typeof json !== "object" || json === null) throw new Error("SFA index is not an object");
4846
- const obj = json;
4847
- return {
4848
- twine1: Array.isArray(obj["twine1"]) ? obj["twine1"].filter(isValidEntry) : [],
4849
- twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
4850
- };
5120
+ function loadTagged(story, tag, filename, diagnostics) {
5121
+ const result = parseTaggedFile(tag, filename);
5122
+ for (const p of result.passages) storyAdd(story, p, diagnostics);
4851
5123
  }
4852
- /** Generate a format ID from name and version. */
4853
- function makeFormatId(name, version) {
4854
- const major = parseSemver(version)?.[0] ?? "0";
4855
- return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
5124
+ function loadMedia(story, tag, filename, diagnostics) {
5125
+ const result = parseMediaFile(tag, filename);
5126
+ for (const p of result.passages) storyAdd(story, p, diagnostics);
4856
5127
  }
4857
- /** Fetch and parse an SFA index.json, with in-memory caching. */
4858
- async function fetchIndex(url) {
4859
- const cached = indexCache.get(url);
4860
- if (cached) return cached;
4861
- const res = await fetch(url);
4862
- if (!res.ok) throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
4863
- const data = validateSFAIndex(await res.json());
4864
- indexCache.set(url, data);
4865
- return data;
5128
+ function loadFont(story, filename, diagnostics) {
5129
+ const result = parseFontFile(filename);
5130
+ for (const p of result.passages) storyAdd(story, p, diagnostics);
4866
5131
  }
4867
5132
  /**
4868
- * Find the best matching entry in an SFA index.
4869
- * Exact version preferred, then highest version with same major.
5133
+ * Load sources with mtime-based caching for incremental rebuilds.
5134
+ * Always creates a fresh Story; cached passages are replayed via storyAdd().
4870
5135
  */
4871
- function findEntry(index, name, version) {
4872
- const wanted = parseSemver(version);
4873
- const searchArrays = [{
4874
- entries: index.twine2 ?? [],
4875
- formatType: "twine2"
4876
- }, {
4877
- entries: index.twine1 ?? [],
4878
- formatType: "twine1"
4879
- }];
4880
- let bestResult;
4881
- let bestVersion = null;
4882
- for (const { entries, formatType } of searchArrays) for (const entry of entries) {
4883
- if (entry.name.toLowerCase() !== name.toLowerCase()) continue;
4884
- const have = parseSemver(entry.version);
4885
- if (!have) continue;
4886
- if (wanted && semverCompare(have, wanted) === 0) return {
4887
- entry,
4888
- formatType
4889
- };
4890
- if (wanted === null || have[0] === wanted[0] && semverCompare(have, wanted) >= 0) {
4891
- if (!bestVersion || semverCompare(have, bestVersion) > 0) {
4892
- bestVersion = have;
4893
- bestResult = {
4894
- entry,
4895
- formatType
4896
- };
4897
- }
5136
+ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
5137
+ const currentFiles = /* @__PURE__ */ new Set();
5138
+ for (const filename of filenames) {
5139
+ if (processedFiles.has(filename)) {
5140
+ diagnostics.push({
5141
+ level: "warning",
5142
+ message: `load ${filename}: Skipping duplicate.`
5143
+ });
5144
+ continue;
5145
+ }
5146
+ currentFiles.add(filename);
5147
+ const optionsKey = parseOptionsKey(filename, opts);
5148
+ const entry = cache.get(filename);
5149
+ const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
5150
+ if (changedFiles && cached && !changedFiles.has(filename)) {
5151
+ diagnostics.push(...cached.diagnostics);
5152
+ for (const p of cached.passages) storyAdd(story, p, diagnostics);
5153
+ processedFiles.add(filename);
5154
+ continue;
5155
+ }
5156
+ let mtimeMs;
5157
+ try {
5158
+ mtimeMs = statSync(filename).mtimeMs;
5159
+ } catch {
5160
+ continue;
5161
+ }
5162
+ if (cached && cached.mtimeMs === mtimeMs) {
5163
+ diagnostics.push(...cached.diagnostics);
5164
+ for (const p of cached.passages) storyAdd(story, p, diagnostics);
5165
+ processedFiles.add(filename);
5166
+ continue;
5167
+ }
5168
+ try {
5169
+ const result = parseFile(filename, opts);
5170
+ if (!result) continue;
5171
+ cache.set(filename, {
5172
+ mtimeMs,
5173
+ parseOptionsKey: optionsKey,
5174
+ passages: result.passages,
5175
+ diagnostics: result.diagnostics
5176
+ });
5177
+ diagnostics.push(...result.diagnostics);
5178
+ for (const p of result.passages) storyAdd(story, p, diagnostics);
5179
+ } catch (e) {
5180
+ diagnostics.push({
5181
+ level: "error",
5182
+ message: `load ${filename}: ${e instanceof Error ? e.message : String(e)}`,
5183
+ file: filename
5184
+ });
5185
+ continue;
4898
5186
  }
5187
+ processedFiles.add(filename);
5188
+ }
5189
+ for (const key of cache.keys()) if (!currentFiles.has(key)) cache.delete(key);
5190
+ if (story.name !== "" && !story.passages.some((p) => p.name === "StoryTitle")) storyPrepend(story, {
5191
+ name: "StoryTitle",
5192
+ tags: [],
5193
+ text: story.name
5194
+ }, diagnostics);
5195
+ }
5196
+ //#endregion
5197
+ //#region src/version.ts
5198
+ /**
5199
+ * The twee-ts version, read at runtime from the package's own package.json.
5200
+ *
5201
+ * The release workflow (semantic-release) writes the new version into the
5202
+ * published package.json only; git keeps the old one. Reading it at runtime
5203
+ * is the only way the CLI and `creator-version` report the published version.
5204
+ */
5205
+ const PACKAGE_NAME = "@rohal12/twee-ts";
5206
+ /** Walks up from `startDir` to the nearest package.json named @rohal12/twee-ts and returns its version. */
5207
+ function findPackageVersion(startDir) {
5208
+ let dir = startDir;
5209
+ for (;;) {
5210
+ try {
5211
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
5212
+ if (pkg.name === PACKAGE_NAME && typeof pkg.version === "string") return pkg.version;
5213
+ } catch {}
5214
+ const parent = dirname(dir);
5215
+ if (parent === dir) return void 0;
5216
+ dir = parent;
4899
5217
  }
4900
- return bestResult;
4901
5218
  }
4902
- /** Verify SHA-256 checksum using Web Crypto API (Node 22 built-in). */
4903
- async function verifySHA256(content, expectedHex) {
4904
- const digest = await crypto.subtle.digest("SHA-256", content);
4905
- return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("") === expectedHex.toLowerCase();
5219
+ /** This module's directory: `__dirname` in the CJS build, `import.meta.url` in ESM and in source. */
5220
+ function moduleDir() {
5221
+ return typeof __dirname === "string" ? __dirname : dirname(fileURLToPath(import.meta.url));
4906
5222
  }
4907
- /** Derive the download URL for a format.js from the index URL and entry. */
4908
- function getDownloadUrl(indexUrl, entry, formatType) {
4909
- return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
5223
+ const VERSION = findPackageVersion(moduleDir()) ?? "0.0.0";
5224
+ //#endregion
5225
+ //#region src/output-twine2.ts
5226
+ const CREATOR_NAME$2 = "Twee-ts";
5227
+ function toTwine2Archive(story, startName, options) {
5228
+ return getTwine2DataChunk(story, startName, options) + "\n";
4910
5229
  }
4911
- /** Download a format, verify its checksum, write to cache, and return StoryFormatInfo. */
4912
- async function fetchAndCacheFormat(entry, downloadUrl) {
4913
- const res = await fetch(downloadUrl);
4914
- if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
4915
- const text = await res.text();
4916
- const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
4917
- if (checksumKey) {
4918
- const expected = entry.checksums[checksumKey];
4919
- if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
4920
- if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
5230
+ function toTwine2HTML(story, format, startName, options) {
5231
+ let template = readFormatSource(format);
5232
+ if (template.includes("{{STORY_NAME}}")) {
5233
+ const name = htmlEscape(story.name);
5234
+ template = template.replaceAll("{{STORY_NAME}}", () => name);
4921
5235
  }
4922
- const id = makeFormatId(entry.name, entry.version);
4923
- const data = parseFormatJSON(text, id);
4924
- if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
4925
- const cacheDir = getCacheDir();
4926
- const formatDir = join(cacheDir, entry.name, entry.version);
4927
- mkdirSync(formatDir, { recursive: true });
4928
- const formatPath = join(formatDir, "format.js");
4929
- writeFileSync(formatPath, text, "utf-8");
4930
- return {
4931
- id,
4932
- filename: formatPath,
4933
- isTwine2: true,
4934
- name: data.name,
4935
- version: data.version,
4936
- proofing: data.proofing ?? false
4937
- };
4938
- }
4939
- /** Download a direct format.js URL, parse its JSON, cache it, and return StoryFormatInfo. */
4940
- async function fetchDirectFormat(url) {
4941
- const res = await fetch(url);
4942
- if (!res.ok) throw new Error(`Failed to download format from ${url}: ${res.status} ${res.statusText}`);
4943
- const text = await res.text();
4944
- const data = parseFormatJSON(text, "direct-url");
4945
- if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
4946
- const id = makeFormatId(data.name, data.version);
4947
- const cacheDir = getCacheDir();
4948
- const formatDir = join(cacheDir, data.name, data.version);
4949
- mkdirSync(formatDir, { recursive: true });
4950
- const formatPath = join(formatDir, "format.js");
4951
- writeFileSync(formatPath, text, "utf-8");
4952
- return {
4953
- id,
4954
- filename: formatPath,
4955
- isTwine2: true,
4956
- name: data.name,
4957
- version: data.version,
4958
- proofing: data.proofing ?? false
4959
- };
5236
+ if (template.includes("{{STORY_DATA}}")) {
5237
+ const data = getTwine2DataChunk({
5238
+ ...story,
5239
+ twine2: {
5240
+ ...story.twine2,
5241
+ format: format.name,
5242
+ formatVersion: format.version
5243
+ }
5244
+ }, startName, options);
5245
+ template = template.replace("{{STORY_DATA}}", () => data);
5246
+ }
5247
+ return template;
4960
5248
  }
5249
+ const OMITTING_TAGS = [
5250
+ "Twine.private",
5251
+ "script",
5252
+ "stylesheet"
5253
+ ];
4961
5254
  /**
4962
- * Try to resolve a remote story format by name and version.
4963
- * 1. Try direct format URLs
4964
- * 2. Try custom index URLs
4965
- * 3. Try default SFA indices
5255
+ * Why Twine 2 output leaves a passage out of its `<tw-passagedata>` elements,
5256
+ * or `undefined` when the passage is emitted (and so can be the starting passage).
4966
5257
  */
4967
- async function resolveRemoteFormat(name, version, indices, urls) {
4968
- let lastError;
4969
- if (urls) for (const url of urls) try {
4970
- const info = await fetchDirectFormat(url);
4971
- if (info.name.toLowerCase() === name.toLowerCase()) {
4972
- const wanted = parseSemver(version);
4973
- const have = parseSemver(info.version);
4974
- if (!wanted || have && have[0] === wanted[0] && semverCompare(have, wanted) >= 0) return info;
5258
+ function twine2PassageOmission(story, p) {
5259
+ if (p.name === "StoryTitle" || p.name === "StoryData") return {
5260
+ kind: "special-name",
5261
+ name: p.name
5262
+ };
5263
+ const tag = OMITTING_TAGS.find((t) => hasTag(p, t));
5264
+ if (tag !== void 0) return {
5265
+ kind: "tag",
5266
+ tag
5267
+ };
5268
+ if (p.name === "StorySettings" && story.twine1.settings.size === 0) return { kind: "empty-story-settings" };
5269
+ }
5270
+ function getTwine2DataChunk(story, startName, options) {
5271
+ const parts = [];
5272
+ let startID = "";
5273
+ let pid = 0;
5274
+ const scripts = [];
5275
+ const stylesheets = [];
5276
+ for (const p of story.passages) {
5277
+ if (hasTag(p, "Twine.private")) continue;
5278
+ if (hasTag(p, "script")) scripts.push(p);
5279
+ else if (hasTag(p, "stylesheet")) stylesheets.push(p);
5280
+ }
5281
+ let styleContent = "";
5282
+ if (stylesheets.length === 1) styleContent = stylesheets[0]?.text ?? "";
5283
+ else if (stylesheets.length > 1) {
5284
+ let pidS = 1;
5285
+ for (const p of stylesheets) {
5286
+ if (pidS > 1 && !styleContent.endsWith("\n")) styleContent += "\n";
5287
+ styleContent += `/* twine-user-stylesheet #${pidS}: "${commentSanitize(p.name)}" */\n`;
5288
+ styleContent += p.text;
5289
+ pidS++;
4975
5290
  }
4976
- } catch (e) {
4977
- lastError = e instanceof Error ? e : new Error(String(e));
4978
5291
  }
4979
- const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
4980
- for (const indexUrl of allIndices) try {
4981
- const result = findEntry(await fetchIndex(indexUrl), name, version);
4982
- if (result) {
4983
- const cached = getCachedFormat(result.entry.name, result.entry.version);
4984
- if (cached) return cached;
4985
- const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
4986
- return await fetchAndCacheFormat(result.entry, downloadUrl);
5292
+ parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContentEscape(styleContent)}</style>`);
5293
+ let scriptContent = "";
5294
+ if (scripts.length === 1) scriptContent = scripts[0]?.text ?? "";
5295
+ else if (scripts.length > 1) {
5296
+ let pidS = 1;
5297
+ for (const p of scripts) {
5298
+ if (pidS > 1 && !scriptContent.endsWith("\n")) scriptContent += "\n";
5299
+ scriptContent += `/* twine-user-script #${pidS}: "${commentSanitize(p.name)}" */\n`;
5300
+ scriptContent += p.text;
5301
+ pidS++;
4987
5302
  }
4988
- } catch (e) {
4989
- lastError = e instanceof Error ? e : new Error(String(e));
4990
5303
  }
4991
- if (lastError) throw lastError;
5304
+ parts.push(`<script role="script" id="twine-user-script" type="text/twine-javascript">${scriptContentEscape(scriptContent)}<\/script>`);
5305
+ const validTagColors = /* @__PURE__ */ new Set([
5306
+ "gray",
5307
+ "red",
5308
+ "orange",
5309
+ "yellow",
5310
+ "green",
5311
+ "blue",
5312
+ "purple"
5313
+ ]);
5314
+ const hexColorPattern = /^#[0-9a-fA-F]{3,8}$/;
5315
+ 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>`);
5316
+ pid = 1;
5317
+ for (const p of story.passages) {
5318
+ if (twine2PassageOmission(story, p) !== void 0) continue;
5319
+ parts.push(passageToPassagedata(p, pid, options));
5320
+ if (startName === p.name) startID = String(pid);
5321
+ pid++;
5322
+ }
5323
+ const opts = [];
5324
+ for (const [opt, val] of story.twine2.options) if (val) opts.push(opt);
5325
+ const optionsStr = opts.join(" ");
5326
+ const zoom = String(story.twine2.zoom);
5327
+ return `<!-- UUID://${htmlCommentSanitize(story.ifid)}// --><tw-storydata name="${attrEscape(story.name)}" startnode="${attrEscape(startID)}" creator="${attrEscape(CREATOR_NAME$2)}" creator-version="${attrEscape(VERSION)}" ifid="${attrEscape(story.ifid)}" zoom="${attrEscape(zoom)}" format="${attrEscape(story.twine2.format)}" format-version="${attrEscape(story.twine2.formatVersion)}" options="${attrEscape(optionsStr)}" tags="${attrEscape(story.twine2.tags)}" hidden>` + parts.join("") + "</tw-storydata>";
4992
5328
  }
4993
- /** Check if a format is already in the local cache. */
4994
- function getCachedFormat(name, version) {
4995
- const formatPath = join(getCacheDir(), name, version, "format.js");
4996
- try {
4997
- if (!existsSync(formatPath)) return void 0;
4998
- const source = readFileSync(formatPath, "utf-8");
4999
- const id = makeFormatId(name, version);
5000
- const data = parseFormatJSON(source, id);
5001
- if (!data) return void 0;
5002
- return {
5003
- id,
5004
- filename: formatPath,
5005
- isTwine2: true,
5006
- name: data.name,
5007
- version: data.version,
5008
- proofing: data.proofing ?? false
5009
- };
5010
- } catch {
5011
- return;
5329
+ //#endregion
5330
+ //#region src/output-twine1.ts
5331
+ /**
5332
+ * Twine 1 HTML and archive output.
5333
+ * Ported from storyout.go.
5334
+ */
5335
+ const CREATOR_NAME$1 = "twee-ts";
5336
+ function toTwine1Archive(story, _startName) {
5337
+ const { data, count } = getTwine1PassageChunk(story);
5338
+ return `<div id="storeArea" data-size="${count}">${data}</div>\n`;
5339
+ }
5340
+ function toTwine1HTML(story, format, startName) {
5341
+ const formatDir = dirname(format.filename);
5342
+ const parentDir = dirname(formatDir);
5343
+ let template = readFormatSource(format);
5344
+ const { data, count } = getTwine1PassageChunk(story);
5345
+ template = tryReplaceComponent(template, "\"USER_LIB\"", join(formatDir, "userlib.js"), false);
5346
+ template = tryReplaceComponent(template, "\"ENGINE\"", join(parentDir, "engine.js"), true);
5347
+ template = tryReplaceComponent(template, "\"SUGARCANE\"", join(formatDir, "code.js"), true);
5348
+ template = tryReplaceComponent(template, "\"JONAH\"", join(formatDir, "code.js"), true);
5349
+ if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", join(parentDir, "jquery.js"), true);
5350
+ if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", join(parentDir, "modernizr.js"), true);
5351
+ const displayStart = startName === "Start" ? "" : startName;
5352
+ template = template.replace("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`);
5353
+ template = template.replace("\"TIME\"", `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`);
5354
+ const startAtValue = `"${jsStringEscape(displayStart)}"`;
5355
+ template = template.replace("\"START_AT\"", () => startAtValue);
5356
+ template = template.replace("\"STORY_SIZE\"", `"${count}"`);
5357
+ if (template.includes("\"STORY\"")) template = template.replace("\"STORY\"", () => data);
5358
+ else {
5359
+ let footer;
5360
+ try {
5361
+ footer = readFileSync(join(formatDir, "footer.html"), "utf-8");
5362
+ } catch {
5363
+ footer = "</div>\n</body>\n</html>\n";
5364
+ }
5365
+ template += data + footer;
5366
+ }
5367
+ if (story.ifid) {
5368
+ const safeIfid = htmlCommentSanitize(story.ifid);
5369
+ if (template.includes("<div id=\"store-area\"")) template = template.replace("<div id=\"store-area\"", `<!-- UUID://${safeIfid}// --><div id="store-area"`);
5370
+ else template = template.replace("<div id=\"storeArea\"", `<!-- UUID://${safeIfid}// --><div id="storeArea"`);
5012
5371
  }
5372
+ return template;
5013
5373
  }
5014
- /** Discover all cached remote formats. Returns a Map like discoverFormats(). */
5015
- function discoverCachedFormats() {
5016
- const formats = /* @__PURE__ */ new Map();
5017
- const cacheDir = getCacheDir();
5018
- try {
5019
- if (!existsSync(cacheDir)) return formats;
5020
- } catch {
5021
- return formats;
5374
+ /**
5375
+ * Why Twine 1 output leaves a passage out of its tiddlers, or `undefined` when the passage is emitted.
5376
+ */
5377
+ function twine1PassageOmission(p) {
5378
+ return hasTag(p, "Twine.private") ? {
5379
+ kind: "tag",
5380
+ tag: "Twine.private"
5381
+ } : void 0;
5382
+ }
5383
+ function getTwine1PassageChunk(story) {
5384
+ const obfuscateRot13 = story.twine1.settings.get("obfuscate") === "rot13";
5385
+ let data = "";
5386
+ let count = 0;
5387
+ for (const p of story.passages) {
5388
+ if (twine1PassageOmission(p) !== void 0) continue;
5389
+ count++;
5390
+ data += passageToTiddler(p, count, obfuscateRot13);
5022
5391
  }
5023
- let names;
5392
+ return {
5393
+ data,
5394
+ count
5395
+ };
5396
+ }
5397
+ function tryReplaceComponent(template, placeholder, componentPath, required) {
5398
+ if (!template.includes(placeholder)) return template;
5024
5399
  try {
5025
- names = readdirSync(cacheDir);
5026
- } catch {
5027
- return formats;
5400
+ let content = readFileSync(componentPath, "utf-8");
5401
+ if (content.charCodeAt(0) === 65279) content = content.slice(1);
5402
+ return template.replace(placeholder, () => content);
5403
+ } catch (e) {
5404
+ if (required) throw new Error(`Required format component not found: ${componentPath}: ${e instanceof Error ? e.message : String(e)}`);
5405
+ return template;
5028
5406
  }
5029
- for (const name of names) {
5030
- const nameDir = join(cacheDir, name);
5031
- try {
5032
- if (!statSync(nameDir).isDirectory()) continue;
5033
- } catch {
5034
- continue;
5035
- }
5036
- let versions;
5037
- try {
5038
- versions = readdirSync(nameDir);
5039
- } catch {
5040
- continue;
5041
- }
5042
- for (const version of versions) {
5043
- const info = getCachedFormat(name, version);
5044
- if (info) formats.set(`${info.id}-${version}`, info);
5407
+ }
5408
+ //#endregion
5409
+ //#region src/output-twee.ts
5410
+ function toTwee(story, outMode) {
5411
+ let data = "";
5412
+ for (const p of story.passages) data += passageToTwee(p, outMode);
5413
+ return data;
5414
+ }
5415
+ //#endregion
5416
+ //#region src/modules.ts
5417
+ /**
5418
+ * Load modules and return HTML tags to inject before </head>.
5419
+ */
5420
+ function loadModules(filenames) {
5421
+ const processed = /* @__PURE__ */ new Set();
5422
+ const headTags = [];
5423
+ for (const filename of filenames) {
5424
+ if (processed.has(filename)) continue;
5425
+ const ext = normalizedFileExt(filename);
5426
+ let tag = null;
5427
+ switch (ext) {
5428
+ case "css":
5429
+ tag = loadModuleTagged("style", filename);
5430
+ break;
5431
+ case "js":
5432
+ tag = loadModuleTagged("script", filename);
5433
+ break;
5434
+ case "otf":
5435
+ case "ttf":
5436
+ case "woff":
5437
+ case "woff2":
5438
+ tag = loadModuleFont(filename);
5439
+ break;
5440
+ default: continue;
5045
5441
  }
5442
+ if (tag) headTags.push(tag);
5443
+ processed.add(filename);
5046
5444
  }
5047
- return formats;
5445
+ return headTags.join("\n");
5048
5446
  }
5049
- /** List all cached formats with size and modification date. */
5050
- function listCachedFormats() {
5051
- const cacheDir = getCacheDir();
5052
- const entries = [];
5053
- try {
5054
- if (!existsSync(cacheDir)) return entries;
5055
- } catch {
5056
- return entries;
5447
+ function loadModuleTagged(tag, filename) {
5448
+ const source = readUTF8(filename).trim();
5449
+ if (source.length === 0) return null;
5450
+ return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
5451
+ }
5452
+ function loadModuleFont(filename) {
5453
+ const source = readBase64(filename);
5454
+ const family = baseNameWithoutExt(filename);
5455
+ const idSlug = `style-module-${slugify(family)}`;
5456
+ const ext = normalizedFileExt(filename);
5457
+ 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>`;
5458
+ }
5459
+ /**
5460
+ * Inject modules and head file content before </head>.
5461
+ */
5462
+ function modifyHead(html, modulePaths, headFile, diagnostics) {
5463
+ const parts = [];
5464
+ if (modulePaths.length > 0) {
5465
+ const modules = loadModules(modulePaths).trim();
5466
+ if (modules.length > 0) parts.push(modules);
5057
5467
  }
5058
- let names;
5059
- try {
5060
- names = readdirSync(cacheDir);
5061
- } catch {
5062
- return entries;
5468
+ if (headFile) try {
5469
+ const source = readUTF8(headFile).trim();
5470
+ if (source.length > 0) parts.push(source);
5471
+ } catch (e) {
5472
+ diagnostics?.push({
5473
+ level: "warning",
5474
+ message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
5475
+ });
5063
5476
  }
5064
- for (const name of names) {
5065
- const nameDir = join(cacheDir, name);
5066
- try {
5067
- if (!statSync(nameDir).isDirectory()) continue;
5068
- } catch {
5069
- continue;
5070
- }
5071
- let versions;
5072
- try {
5073
- versions = readdirSync(nameDir);
5074
- } catch {
5075
- continue;
5076
- }
5077
- for (const version of versions) {
5078
- const formatPath = join(nameDir, version, "format.js");
5079
- try {
5080
- const stat = statSync(formatPath);
5081
- entries.push({
5082
- name,
5083
- version,
5084
- sizeBytes: stat.size,
5085
- modifiedAt: stat.mtime
5086
- });
5087
- } catch {}
5088
- }
5477
+ if (parts.length > 0) {
5478
+ parts.push("</head>");
5479
+ const replacement = parts.join("\n");
5480
+ return html.replace("</head>", () => replacement);
5089
5481
  }
5090
- return entries;
5482
+ return html;
5091
5483
  }
5092
- /** Clear all cached formats, or only those matching a given name. Returns the number of entries removed. */
5093
- function clearCachedFormats(name) {
5094
- const cacheDir = getCacheDir();
5095
- if (!existsSync(cacheDir)) return 0;
5096
- if (!name) {
5097
- const count = listCachedFormats().length;
5098
- rmSync(cacheDir, {
5099
- recursive: true,
5100
- force: true
5101
- });
5102
- return count;
5484
+ //#endregion
5485
+ //#region src/start-passage.ts
5486
+ function describeOmission(omission) {
5487
+ switch (omission.kind) {
5488
+ case "special-name": return `is the special "${omission.name}" passage`;
5489
+ case "tag": return `is tagged "${omission.tag}"`;
5490
+ case "empty-story-settings": return "is an empty \"StorySettings\" passage";
5491
+ default: throw new Error(`unhandled passage omission: ${JSON.stringify(omission)}`);
5103
5492
  }
5104
- const nameDir = join(cacheDir, name);
5105
- if (!existsSync(nameDir)) return 0;
5106
- let versions;
5107
- try {
5108
- versions = readdirSync(nameDir);
5109
- } catch {
5110
- return 0;
5493
+ }
5494
+ function omissionFor(story, passage, target) {
5495
+ switch (target) {
5496
+ case "twine2": return twine2PassageOmission(story, passage);
5497
+ case "twine1": return twine1PassageOmission(passage);
5498
+ default: throw new Error(`unhandled start passage target: ${String(target)}`);
5111
5499
  }
5112
- const count = versions.length;
5113
- rmSync(nameDir, {
5114
- recursive: true,
5115
- force: true
5116
- });
5117
- return count;
5118
5500
  }
5119
- /** Get total cache size in bytes and format count. */
5120
- function getCacheSize() {
5121
- const entries = listCachedFormats();
5122
- return {
5123
- totalBytes: entries.reduce((sum, e) => sum + e.sizeBytes, 0),
5124
- count: entries.length
5125
- };
5501
+ /**
5502
+ * Check that `startName` names a passage the `target` output emits.
5503
+ * Returns the error diagnostics to report (empty when the start is usable).
5504
+ */
5505
+ function startPassageDiagnostics(story, startName, target) {
5506
+ const passage = story.passages.find((p) => p.name === startName);
5507
+ if (passage === void 0) return [{
5508
+ level: "error",
5509
+ message: `Starting passage "${startName}" not found.`
5510
+ }];
5511
+ const omission = omissionFor(story, passage, target);
5512
+ if (omission === void 0) return [];
5513
+ return [{
5514
+ level: "error",
5515
+ message: `Starting passage "${startName}" ${describeOmission(omission)}, so it is left out of the story data. Choose a story passage.`
5516
+ }];
5126
5517
  }
5127
5518
  //#endregion
5128
5519
  //#region src/compiler.ts
@@ -5152,7 +5543,7 @@ async function compile(options) {
5152
5543
  * Compile and write to a file.
5153
5544
  */
5154
5545
  async function compileToFile(options) {
5155
- const result = await compile(options);
5546
+ const result = await buildOutput(options, void 0, void 0, options.outFile);
5156
5547
  writeFileSync(options.outFile, result.output, "utf-8");
5157
5548
  return result;
5158
5549
  }
@@ -5171,18 +5562,35 @@ async function watch(options) {
5171
5562
  const cache = /* @__PURE__ */ new Map();
5172
5563
  const filePaths = options.sources.filter((s) => typeof s === "string");
5173
5564
  const modulePaths = options.modules ?? [];
5174
- const handle = watchFilesystem([...filePaths, ...modulePaths], options.outFile, (changedFiles) => {
5175
- buildOutput(options, cache, changedFiles).then((result) => {
5565
+ const headPaths = options.headFile ? [options.headFile] : [];
5566
+ const allPaths = [
5567
+ ...filePaths,
5568
+ ...modulePaths,
5569
+ ...headPaths
5570
+ ];
5571
+ const exclude = options.exclude ?? [];
5572
+ const notExcludable = [...modulePaths, ...headPaths].map((p) => resolve(p));
5573
+ const ignore = (filename) => {
5574
+ if (!isExcluded(filename, exclude)) return false;
5575
+ const abs = resolve(filename);
5576
+ return !notExcludable.some((root) => abs === root || abs.startsWith(root + sep));
5577
+ };
5578
+ const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
5579
+ buildOutput(options, cache, changedFiles, options.outFile).then((result) => {
5176
5580
  writeFileSync(options.outFile, result.output, "utf-8");
5177
5581
  options.onBuild?.(result);
5178
5582
  }).catch((e) => {
5179
5583
  options.onError?.(e instanceof Error ? e : new Error(String(e)));
5180
5584
  });
5181
- });
5585
+ }, ignore);
5182
5586
  controller.signal.addEventListener("abort", () => handle.close());
5183
5587
  return controller;
5184
5588
  }
5185
- async function buildOutput(options, cache, changedFiles) {
5589
+ /**
5590
+ * `outFile`: the file the build is written to, which source and module discovery
5591
+ * skip (as Tweego does), so an output inside a source folder is never loaded back.
5592
+ */
5593
+ async function buildOutput(options, cache, changedFiles, outFile) {
5186
5594
  const diagnostics = [];
5187
5595
  const outputMode = options.outputMode ?? "html";
5188
5596
  const trim = options.trim ?? true;
@@ -5195,7 +5603,7 @@ async function buildOutput(options, cache, changedFiles) {
5195
5603
  const inlineSources = [];
5196
5604
  for (const source of options.sources) if (typeof source === "string") filePaths.push(source);
5197
5605
  else inlineSources.push(source);
5198
- const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths);
5606
+ const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths, outFile, options.exclude);
5199
5607
  diagnostics.push(...sourcePathDiagnostics);
5200
5608
  const story = createStory();
5201
5609
  const processedFiles = /* @__PURE__ */ new Set();
@@ -5213,33 +5621,12 @@ async function buildOutput(options, cache, changedFiles) {
5213
5621
  }, diagnostics);
5214
5622
  if (options.tagAliases) story.passages = applyTagAliases(story.passages, options.tagAliases);
5215
5623
  let format;
5216
- let formatId = options.formatId ?? "";
5217
- if (outputMode === "html") {
5218
- const formats = discoverFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true));
5219
- if (!formatId && story.twine2.format) formatId = getFormatIdByNameAndVersion(formats, story.twine2.format, story.twine2.formatVersion) ?? "";
5220
- if (!formatId) formatId = DEFAULT_FORMAT_ID;
5221
- format = formats.get(formatId);
5222
- if (!format && !noRemote) {
5223
- const remoteName = story.twine2.format || formatId;
5224
- const remoteVersion = story.twine2.formatVersion || "";
5225
- try {
5226
- format = await resolveRemoteFormat(remoteName, remoteVersion, options.formatIndices, options.formatUrls);
5227
- } catch (e) {
5228
- diagnostics.push({
5229
- level: "warning",
5230
- message: `Remote format fetch failed for "${remoteName}": ${e instanceof Error ? e.message : String(e)}`
5231
- });
5232
- }
5233
- }
5234
- if (!format) {
5235
- const reason = noRemote ? " (remote fetching disabled)" : "";
5236
- diagnostics.push({
5237
- level: "error",
5238
- message: `Story format "${formatId}" is not available${reason}. Found: ${[...formats.keys()].join(", ") || "none"}`
5239
- });
5240
- }
5241
- }
5242
- const startName = options.startPassage || story.twine2.start || DEFAULT_START_NAME;
5624
+ if (outputMode === "html") format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
5625
+ ...options,
5626
+ noRemote
5627
+ }, diagnostics);
5628
+ if (options.startPassage) story.twine2.start = options.startPassage;
5629
+ const startName = story.twine2.start || DEFAULT_START_NAME;
5243
5630
  if (testMode) story.twine2.options.set("debug", true);
5244
5631
  ensureIFID(story, diagnostics);
5245
5632
  let output;
@@ -5258,10 +5645,7 @@ async function buildOutput(options, cache, changedFiles) {
5258
5645
  output = storyToJSON(story);
5259
5646
  break;
5260
5647
  case "html": {
5261
- if (!storyHas(story, startName)) diagnostics.push({
5262
- level: "error",
5263
- message: `Starting passage "${startName}" not found.`
5264
- });
5648
+ diagnostics.push(...startPassageDiagnostics(story, startName, format?.isTwine2 === false ? "twine1" : "twine2"));
5265
5649
  if (!format) throw new TweeTsError("No story format available for HTML output.", diagnostics);
5266
5650
  if (format.isTwine2) output = toTwine2HTML(story, format, startName, { sourceInfo });
5267
5651
  else {
@@ -5271,7 +5655,7 @@ async function buildOutput(options, cache, changedFiles) {
5271
5655
  });
5272
5656
  output = toTwine1HTML(story, format, startName);
5273
5657
  }
5274
- const modules = getFilenames(options.modules ?? []);
5658
+ const modules = getFilenames(options.modules ?? [], outFile);
5275
5659
  diagnostics.push(...modules.diagnostics);
5276
5660
  output = modifyHead(output, modules.filenames, options.headFile, diagnostics);
5277
5661
  break;
@@ -5691,19 +6075,227 @@ function endOfRegularExpression(source, pos) {
5691
6075
  };
5692
6076
  }
5693
6077
  //#endregion
6078
+ //#region src/link-markup.ts
6079
+ /**
6080
+ * Reads link or image markup that starts at `start` in `text`, or returns `undefined` where
6081
+ * SugarCube rejects it (and so prints the text instead).
6082
+ *
6083
+ * `budget.left`, if given, is how many characters the reading may look at; it is reduced by as
6084
+ * many as were read, and once it runs out, every reading fails. Unclosed markup is read to the end
6085
+ * of its line, so reading every `[[` of a long line that never closes one would otherwise take
6086
+ * quadratic time.
6087
+ */
6088
+ function readSquareBracketedMarkup(text, start, budget = { left: Infinity }) {
6089
+ const reader = {
6090
+ text,
6091
+ limit: Math.min(text.length, start + Math.max(budget.left, 0))
6092
+ };
6093
+ const markup = readMarkup(reader, start);
6094
+ budget.left -= (markup?.end ?? reader.limit) - start;
6095
+ return markup;
6096
+ }
6097
+ function readMarkup(reader, start) {
6098
+ const { text } = reader;
6099
+ if (text[start] !== "[") return;
6100
+ let pos = start + 1;
6101
+ let type;
6102
+ if (text[pos] === "[") {
6103
+ type = "link";
6104
+ pos += 1;
6105
+ } else {
6106
+ if (text[pos] === "<" || text[pos] === ">") pos += 1;
6107
+ if (!/^[Ii][Mm][Gg]\[/.test(text.slice(pos, pos + 4))) return;
6108
+ type = "image";
6109
+ pos += 4;
6110
+ }
6111
+ const core = readCoreComponents(reader, pos);
6112
+ if (core === void 0) return;
6113
+ if (type === "link") {
6114
+ if (!core.end.last) {
6115
+ const setterEnd = readSetter(reader, core.end.next);
6116
+ if (setterEnd === void 0) return;
6117
+ return {
6118
+ type,
6119
+ link: linkName(core.link),
6120
+ end: setterEnd
6121
+ };
6122
+ }
6123
+ return {
6124
+ type,
6125
+ link: linkName(core.link),
6126
+ end: core.end.next
6127
+ };
6128
+ }
6129
+ if (core.end.last) return {
6130
+ type,
6131
+ link: void 0,
6132
+ end: core.end.next
6133
+ };
6134
+ const linkEnd = readComponent(reader, core.end.next, false);
6135
+ if (linkEnd === void 0) return;
6136
+ const link = linkName(text.slice(core.end.next, linkEnd.textEnd));
6137
+ if (linkEnd.last) return {
6138
+ type,
6139
+ link,
6140
+ end: linkEnd.next
6141
+ };
6142
+ const setterEnd = readSetter(reader, linkEnd.next);
6143
+ return setterEnd === void 0 ? void 0 : {
6144
+ type,
6145
+ link,
6146
+ end: setterEnd
6147
+ };
6148
+ }
6149
+ /** The link component as SugarCube keeps it: trimmed, and without a leading `~`. */
6150
+ function linkName(component) {
6151
+ const trimmed = component.trim();
6152
+ return trimmed.startsWith("~") ? trimmed.slice(1) : trimmed;
6153
+ }
6154
+ /**
6155
+ * Reads the first components: the text and the link of link markup, or the title and the source
6156
+ * of image markup, divided by the first delimiter. `link` is the text of the link (or source)
6157
+ * component, untrimmed.
6158
+ */
6159
+ function readCoreComponents(reader, from) {
6160
+ const { text } = reader;
6161
+ let delimiter = "none";
6162
+ let componentStart = from;
6163
+ let link;
6164
+ let depth = 2;
6165
+ let pos = from;
6166
+ while (pos < reader.limit) {
6167
+ const ch = text[pos];
6168
+ pos += 1;
6169
+ switch (ch) {
6170
+ case "\n": return;
6171
+ case "\"": {
6172
+ const end = readQuoted(reader, pos, "\"");
6173
+ if (end === void 0) return;
6174
+ pos = end;
6175
+ break;
6176
+ }
6177
+ case "|":
6178
+ if (delimiter === "none") {
6179
+ delimiter = "ltr";
6180
+ componentStart = pos;
6181
+ }
6182
+ break;
6183
+ case "-":
6184
+ if (delimiter === "none" && text[pos] === ">") {
6185
+ delimiter = "ltr";
6186
+ pos += 1;
6187
+ componentStart = pos;
6188
+ }
6189
+ break;
6190
+ case "<":
6191
+ if (delimiter === "none" && text[pos] === "-") {
6192
+ delimiter = "rtl";
6193
+ link = text.slice(componentStart, pos - 1);
6194
+ pos += 1;
6195
+ componentStart = pos;
6196
+ }
6197
+ break;
6198
+ case "[":
6199
+ depth += 1;
6200
+ break;
6201
+ case "]":
6202
+ depth -= 1;
6203
+ if (depth === 1) {
6204
+ const end = componentEnd(text, pos);
6205
+ if (end === void 0) return;
6206
+ return {
6207
+ link: link ?? text.slice(componentStart, end.textEnd),
6208
+ end
6209
+ };
6210
+ }
6211
+ }
6212
+ }
6213
+ }
6214
+ /**
6215
+ * Reads a component after `][` that holds no delimiter: an image's link (`setter` false) or a
6216
+ * setter, in which single quotes also quote.
6217
+ */
6218
+ function readComponent(reader, from, setter) {
6219
+ const { text } = reader;
6220
+ let depth = 2;
6221
+ let pos = from;
6222
+ while (pos < reader.limit) {
6223
+ const ch = text[pos];
6224
+ pos += 1;
6225
+ switch (ch) {
6226
+ case "\n": return;
6227
+ case "\"":
6228
+ case "'": {
6229
+ if (ch === "'" && !setter) break;
6230
+ const end = readQuoted(reader, pos, ch);
6231
+ if (end === void 0) return;
6232
+ pos = end;
6233
+ break;
6234
+ }
6235
+ case "[":
6236
+ depth += 1;
6237
+ break;
6238
+ case "]":
6239
+ depth -= 1;
6240
+ if (depth === 1) return componentEnd(text, pos);
6241
+ }
6242
+ }
6243
+ }
6244
+ /** Reads a setter, the last component; returns the index after its closing `]]`. */
6245
+ function readSetter(reader, from) {
6246
+ const end = readComponent(reader, from, true);
6247
+ return end?.last === true ? end.next : void 0;
6248
+ }
6249
+ /** What the `]` just before `pos`, which brings the depth back to one, ends; `undefined` if malformed. */
6250
+ function componentEnd(text, pos) {
6251
+ switch (text[pos]) {
6252
+ case "[": return {
6253
+ textEnd: pos - 1,
6254
+ next: pos + 1,
6255
+ last: false
6256
+ };
6257
+ case "]": return {
6258
+ textEnd: pos - 1,
6259
+ next: pos + 1,
6260
+ last: true
6261
+ };
6262
+ default: return;
6263
+ }
6264
+ }
6265
+ /**
6266
+ * Reads a quoted string from just after its opening quote; a backslash escapes the character
6267
+ * after it. A line feed, or the end of the text, ends it unterminated. Returns the index after the
6268
+ * closing quote.
6269
+ */
6270
+ function readQuoted(reader, from, quote) {
6271
+ const { text } = reader;
6272
+ let pos = from;
6273
+ while (pos < reader.limit) {
6274
+ const ch = text[pos];
6275
+ pos += 1;
6276
+ if (ch === quote) return pos;
6277
+ if (ch === "\n") return;
6278
+ if (ch === "\\") {
6279
+ if (pos >= reader.limit || text[pos] === "\n") return;
6280
+ pos += 1;
6281
+ }
6282
+ }
6283
+ }
6284
+ //#endregion
5694
6285
  //#region src/sugarcube-macros.ts
5695
6286
  /**
5696
- * Reads SugarCube 2 macro calls the way SugarCube 2.37.3 does, so the link check sees the same
5697
- * passage names that `<<link>>` and `<<goto>>` use when the story plays.
6287
+ * Reads SugarCube 2 macro calls and link markup the way SugarCube 2.37.3 does, so the link check
6288
+ * sees the same passage names that `[[…]]` links, `<<link>>` and `<<goto>>` use when the story
6289
+ * plays.
5698
6290
  *
5699
6291
  * Each step follows a part of SugarCube's macro parser:
5700
6292
  * - `findMacroTags` finds where a tag starts and ends (the parser's `lookahead` pattern);
5701
6293
  * - `parseMacroArgs` splits the text after the macro name into arguments (`parseArgs`);
5702
6294
  * - quoted arguments are evaluated as strict-mode JavaScript strings (`evalStringLiteral`).
5703
6295
  *
6296
+ * Link and image markup is read as `parseSquareBracketedMarkup` reads it (see `link-markup.ts`).
5704
6297
  * Of the rest of the wikifier, only comments, `<script>` elements and `<<script>>` bodies are
5705
- * told apart; other markup, such as verbatim text, is read as markup (see
5706
- * `findMacroPassageLinks`).
6298
+ * told apart; other markup, such as verbatim text, is read as markup (see `findPassageLinks`).
5707
6299
  */
5708
6300
  const NAME_RE = /\/?[A-Za-z][\w-]*|[=-]/y;
5709
6301
  const SPACES_RE = /\s*/y;
@@ -5748,17 +6340,22 @@ const RAW_ARGUMENT_MACROS = /* @__PURE__ */ new Set([
5748
6340
  * levels; the limit keeps the work linear in the size of the text on any input.
5749
6341
  */
5750
6342
  const MAX_STRING_DEPTH = 10;
5751
- /** A context for reading `text`: the closer search may read four times it, plus an allowance. */
6343
+ /**
6344
+ * A context for reading `text`: the closer search, and the markup reading, may each read four
6345
+ * times it, plus an allowance.
6346
+ */
5752
6347
  function readContext(text) {
6348
+ const allowance = () => ({ left: 4 * text.length + 1e5 });
5753
6349
  return {
5754
6350
  depth: 0,
5755
- budget: { left: 4 * text.length + 1e5 }
6351
+ budget: allowance(),
6352
+ markupBudget: allowance()
5756
6353
  };
5757
6354
  }
5758
6355
  function deeper(context) {
5759
6356
  return {
5760
- depth: context.depth + 1,
5761
- budget: context.budget
6357
+ ...context,
6358
+ depth: context.depth + 1
5762
6359
  };
5763
6360
  }
5764
6361
  /** Index of the first match of a global `re` at or after `from`, or -1. */
@@ -5988,7 +6585,10 @@ function lexArgument(raw, pos) {
5988
6585
  case "[": {
5989
6586
  const end = lexSquareBracketed(raw, pos + 1);
5990
6587
  return end === void 0 ? void 0 : {
5991
- arg: { kind: "markup" },
6588
+ arg: {
6589
+ kind: "markup",
6590
+ text: raw.slice(pos, end)
6591
+ },
5992
6592
  end
5993
6593
  };
5994
6594
  }
@@ -6132,13 +6732,44 @@ function ownPassageLink(tag, lexed) {
6132
6732
  const arg = passageArgument(tag.name, lexed);
6133
6733
  const passage = arg === void 0 ? void 0 : passageName(arg);
6134
6734
  return passage === void 0 ? void 0 : {
6135
- macro: tag.name,
6735
+ via: tag.name,
6136
6736
  passage
6137
6737
  };
6138
6738
  }
6139
6739
  /**
6140
- * The passages a tag names: its own, if it is a `<<link>>` or `<<goto>>` call, then those of the
6141
- * calls inside the strings of its arguments, which the macro may print or pass on
6740
+ * The passage a link's destination names. SugarCube evaluates a destination that no passage is
6741
+ * named by, and a quoted string gives its value (`[[Go|"Room"]]`), unless that is blank.
6742
+ */
6743
+ function linkDestination(link) {
6744
+ const value = evalStringLiteral(link);
6745
+ return value === void 0 || value.trim() === "" ? link : value;
6746
+ }
6747
+ /**
6748
+ * The passage that link markup from `start` in `text` names, and where the markup ends, or
6749
+ * `undefined` where SugarCube rejects the markup. Image markup names none here, nor does markup
6750
+ * with a template literal's `${…}` in it, whose passage is known only in play.
6751
+ */
6752
+ function linkMarkupAt(text, start, budget) {
6753
+ const markup = readSquareBracketedMarkup(text, start, budget);
6754
+ if (markup === void 0) return;
6755
+ const passage = markup.type === "link" && !text.slice(start, markup.end).includes("﷐") ? markup.link : void 0;
6756
+ return {
6757
+ link: passage === void 0 ? void 0 : {
6758
+ via: "markup",
6759
+ passage: linkDestination(passage)
6760
+ },
6761
+ end: markup.end
6762
+ };
6763
+ }
6764
+ /** The passage that a link markup argument names, if SugarCube reads the whole argument as markup. */
6765
+ function markupArgumentLinks(raw) {
6766
+ const markup = linkMarkupAt(raw, 0);
6767
+ return markup?.link === void 0 || markup.end !== raw.length ? [] : [markup.link];
6768
+ }
6769
+ /**
6770
+ * The passages a tag names: its own, if it is a `<<link>>` or `<<goto>>` call, then those of its
6771
+ * link markup arguments (`<<button [[Go|Room]]>>`), and those of the links and calls inside the
6772
+ * strings of its arguments, which the macro may print or pass on
6142
6773
  * (`<<set _out to '<<link "Go" "Room">><</link>>'>>`). The arguments of the macros SugarCube
6143
6774
  * doesn't split are read as JavaScript; in others, a backquoted expression is.
6144
6775
  */
@@ -6151,7 +6782,7 @@ function tagPassageLinks(tag, context) {
6151
6782
  switch (arg.kind) {
6152
6783
  case "string": return markupPassageLinks(arg.value, deeper(context));
6153
6784
  case "expression": return javaScriptPassageLinks(arg.code, context);
6154
- case "markup":
6785
+ case "markup": return markupArgumentLinks(arg.text);
6155
6786
  case "word": return [];
6156
6787
  default: throw new Error(`unhandled macro argument: ${JSON.stringify(arg)}`);
6157
6788
  }
@@ -6161,16 +6792,26 @@ function tagPassageLinks(tag, context) {
6161
6792
  /**
6162
6793
  * The passage named by a `<<link>>` or `<<goto>>` that appears unquoted inside another tag's
6163
6794
  * arguments, at `start`. SugarCube never sees such an outer "tag" when its `<<` lies in text it
6164
- * reads as something else, such as link markup (`[[<<-- Back|Prev]]`); the call inside it then
6165
- * runs. The call ends where the outer tag ends.
6795
+ * reads as something else, such as verbatim text (`{{{<<if}}}`); the call inside it then runs.
6796
+ * The call ends where the outer tag ends.
6166
6797
  */
6167
6798
  function innerPassageLink(matchTagAt, start) {
6168
6799
  const inner = matchTagAt(start)?.tag;
6169
6800
  const lexed = inner === void 0 ? void 0 : lexMacroArgs(inner.args);
6170
6801
  return inner === void 0 || lexed === void 0 ? void 0 : ownPassageLink(inner, lexed);
6171
6802
  }
6172
- const REGION_OPEN_RE = /\/\/|\/\*|\/%|<!--|<[Ss][Cc][Rr][Ii][Pp][Tt]/g;
6803
+ const REGION_OPEN_RE = /\[\[[^[]|\[[<>]?[Ii][Mm][Gg]\[|\/\/|\/\*|\/%|<!--|<[Ss][Cc][Rr][Ii][Pp][Tt]/g;
6173
6804
  const HAS_SCRIPT_OPEN_RE = /<[Ss][Cc][Rr][Ii][Pp][Tt]/;
6805
+ function regionKind(opener) {
6806
+ if (opener.startsWith("[")) return "markup";
6807
+ switch (opener) {
6808
+ case "//":
6809
+ case "/*":
6810
+ case "/%":
6811
+ case "<!--": return opener;
6812
+ default: return "script";
6813
+ }
6814
+ }
6174
6815
  const SCRIPT_CLOSE_RE = /<\/[Ss][Cc][Rr][Ii][Pp][Tt]>|[\r\u2028\u2029]/g;
6175
6816
  const COMMENT_CLOSE_RES = {
6176
6817
  "/*": /\*\/|[\r\u2028\u2029]/g,
@@ -6324,22 +6965,31 @@ function append(links, more) {
6324
6965
  for (const link of more) links.push(link);
6325
6966
  }
6326
6967
  /**
6327
- * The passages that `<<link>>` and `<<goto>>` calls in passage markup name. Calls whose passage
6328
- * is known only in play (a variable or an expression) are left out. A tag's own passage comes
6329
- * before those of the calls inside its arguments' strings.
6968
+ * The passages that link markup (`[[…]]`) and `<<link>>` and `<<goto>>` calls in passage markup
6969
+ * name, in the order they come. Calls whose passage is known only in play (a variable or an
6970
+ * expression) are left out. A tag's own passage comes before those that its arguments name, as
6971
+ * link markup or inside strings.
6330
6972
  *
6331
6973
  * The markup is walked in order, as SugarCube's wikifier walks it, with a tag tried at each
6332
- * `<<` the walk reaches. Of the wikifier's other parsers, only those whose text holds no call
6333
- * are told apart: comments (`/* … *` + `/`, `/% … %/`, `<!-- … -->`), whose calls never run,
6334
- * and `<script>` elements and `<<script>>` bodies, which are JavaScript: only the calls in their
6335
- * strings are read (see `findJavaScriptPassageLinks`). Everything else, verbatim text included,
6336
- * is read as markup.
6974
+ * `<<` the walk reaches, and link or image markup read at each `[[` or `[img[`; markup SugarCube
6975
+ * rejects is read on from just after its opener, and so is markup after a limit on how much
6976
+ * markup a reading may scan, which only input built to take quadratic time reaches (see
6977
+ * `readSquareBracketedMarkup`). Image markup names no passage here. Of the wikifier's other
6978
+ * parsers, only those whose text holds no link or call are told apart: comments (`/* … *` + `/`,
6979
+ * `/% … %/`, `<!-- … -->`), whose links and calls never run, and `<script>` elements and
6980
+ * `<<script>>` bodies, which are JavaScript: only the links and calls in their strings are read
6981
+ * (see `findJavaScriptPassageLinks`). Everything else, verbatim text included, is read as markup.
6337
6982
  *
6338
6983
  * Where this differs from SugarCube:
6984
+ * - a link whose passage SugarCube evaluates, because no passage has its name, is taken by its
6985
+ * name, `[[Go|$next]]` naming the passage `$next`, unless it is a quoted string, which is taken
6986
+ * by its value even when a passage has the name with the quotes;
6987
+ * - a link SugarCube takes for a URL, because no passage has its name and it looks like one or
6988
+ * holds a `/`, `.`, `?` or `#`, is still read as naming a passage;
6339
6989
  * - a comment or a `<<script>>` body is skipped whole, even where `parseBody` ends a container
6340
6990
  * such as `<<if>>` at a closing tag inside it and the rest then runs;
6341
- * - a `/*`, `<!--` or `<script` inside other markup that holds no call, such as link markup or a
6342
- * `<style>` element, still starts a comment or an element;
6991
+ * - a `[[`, `/*`, `<!--` or `<script` inside other markup that holds no call, such as verbatim
6992
+ * text or a `<style>` element, still starts link markup, a comment or an element;
6343
6993
  * - a `<<` in such other markup still starts a tag. When that tag runs over a `<<link` or
6344
6994
  * `<<goto` outside the parts of its arguments that the tag pattern reads as units, that call
6345
6995
  * is still read (see `innerPassageLink`), even where the tag's arguments would be JavaScript,
@@ -6354,33 +7004,43 @@ function append(links, more) {
6354
7004
  * `Config.passages.nobr`, are not joined before they are read (see `storyInspect` for passages
6355
7005
  * tagged `nobr`).
6356
7006
  */
6357
- function findMacroPassageLinks(text) {
7007
+ function findPassageLinks(text) {
6358
7008
  return markupPassageLinks(text, readContext(text));
6359
7009
  }
6360
7010
  function markupPassageLinks(text, context) {
6361
- if (context.depth > MAX_STRING_DEPTH || !text.includes("<<") && !HAS_SCRIPT_OPEN_RE.test(text)) return [];
7011
+ if (context.depth > MAX_STRING_DEPTH || !text.includes("<<") && !text.includes("[[") && !HAS_SCRIPT_OPEN_RE.test(text)) return [];
6362
7012
  const matchTagAt = tagMatcher(text);
6363
7013
  const matchInnerTagAt = tagMatcher(text);
6364
7014
  const readComment = commentReader(text);
6365
7015
  const readScriptElement = scriptElementReader(text);
6366
7016
  let findScriptCloser;
6367
7017
  const links = [];
6368
- /** Reads the region `opener` opens, adding the calls in a `<script>` element; returns where the walk goes on. */
7018
+ /**
7019
+ * Reads the region `opener` opens, adding the passage that link markup names and the links and
7020
+ * calls in a `<script>` element; returns where the walk goes on.
7021
+ */
6369
7022
  const regionEnd = (opener) => {
6370
7023
  const start = opener.index;
6371
- const kind = opener[0];
7024
+ const kind = regionKind(opener[0]);
6372
7025
  switch (kind) {
7026
+ case "markup": {
7027
+ const markup = linkMarkupAt(text, start, context.markupBudget);
7028
+ if (markup === void 0) return start + opener[0].length;
7029
+ if (markup.link !== void 0) links.push(markup.link);
7030
+ return markup.end;
7031
+ }
6373
7032
  case "//": return start + 2;
6374
7033
  case "/*":
6375
7034
  case "/%":
6376
7035
  case "<!--": return readComment(kind, start) ?? start + kind.length;
6377
- default: {
7036
+ case "script": {
6378
7037
  const element = readScriptElement(start);
6379
7038
  if (element.openerEnd === void 0) return start + 1;
6380
7039
  if (element.close === void 0) return element.openerEnd;
6381
7040
  append(links, javaScriptPassageLinks(text.slice(element.openerEnd, element.close), context));
6382
7041
  return element.close + 9;
6383
7042
  }
7043
+ default: throw new Error(`unhandled region: ${String(kind)}`);
6384
7044
  }
6385
7045
  };
6386
7046
  let macro = -2;
@@ -6420,24 +7080,26 @@ function markupPassageLinks(text, context) {
6420
7080
  }
6421
7081
  }
6422
7082
  /**
6423
- * The passages that `<<link>>` and `<<goto>>` calls in the strings of JavaScript source name,
6424
- * such as a script passage or a `<<script>>` body: `$.wiki('<<goto "Room">>')`. Code outside
6425
- * the strings isn't markup, so a call put together while the story plays, such as
6426
- * `'<<goto "' + target + '">>'`, names no passage here.
7083
+ * The passages that link markup and `<<link>>` and `<<goto>>` calls in the strings of JavaScript
7084
+ * source name, such as a script passage or a `<<script>>` body: `$.wiki('<<goto "Room">>')`.
7085
+ * Code outside the strings isn't markup, so a call put together while the story plays, such as
7086
+ * `'<<goto "' + target + '">>'`, names no passage here, nor does an array of arrays (`[[0, 1]]`).
6427
7087
  */
6428
7088
  function findJavaScriptPassageLinks(source) {
6429
7089
  return javaScriptPassageLinks(source, readContext(source));
6430
7090
  }
6431
7091
  /**
6432
7092
  * What JavaScript source must contain for one of its string or template literals to hold a call
6433
- * that is read. A value holding `<<`: each `<` in it is written as `<`, `\<`, `\x3c`, `\u003c` or
6434
- * `\u{…}`, two can be next to each other with only line continuations between, and the first
6435
- * is `<` or `\<` (then the source holds `<<` or `<\`) or one of the others. Or a value holding a
6436
- * `<script>` element, whose own strings can make `<<` from escapes the outer string encodes.
7093
+ * or link markup that is read. A value holding `<<`: each `<` in it is written as `<`, `\<`,
7094
+ * `\x3c`, `\u003c` or `\u{…}`, two can be next to each other with only line continuations
7095
+ * between, and the first is `<` or `\<` (then the source holds `<<` or `<\`) or one of the others.
7096
+ * A value holding `[[`, likewise: the source holds `[[`, a `[` before `\[` or a line continuation,
7097
+ * `\x5b`, `\u005b` or `\u{`. Or a value holding a `<script>` element, whose own strings can make
7098
+ * `<<` or `[[` from escapes the outer string encodes.
6437
7099
  */
6438
- const MAY_HOLD_MACRO_RE = /<<|<\\|\\x3c|\\u003c|\\u\{|<script/i;
7100
+ const MAY_HOLD_LINK_RE = /<<|<\\|\\x3c|\\u003c|\\u\{|<script|\[\[|\[\\[[\n\r\u2028\u2029]|\\x5b|\\u005b/i;
6439
7101
  function javaScriptPassageLinks(source, context) {
6440
- if (context.depth >= MAX_STRING_DEPTH || !MAY_HOLD_MACRO_RE.test(source)) return [];
7102
+ if (context.depth >= MAX_STRING_DEPTH || !MAY_HOLD_LINK_RE.test(source)) return [];
6441
7103
  const inner = deeper(context);
6442
7104
  const links = [];
6443
7105
  for (const value of javaScriptStrings(source)) append(links, markupPassageLinks(value, inner));
@@ -6446,43 +7108,34 @@ function javaScriptPassageLinks(source, context) {
6446
7108
  //#endregion
6447
7109
  //#region src/inspect.ts
6448
7110
  /**
6449
- * Extract all [[wiki-style links]] from passage text.
7111
+ * Extract the passages that passage text links to, read as SugarCube 2 reads them.
6450
7112
  *
6451
7113
  * Supported syntaxes:
6452
7114
  * - `[[PassageName]]`
6453
- * - `[[Display Text->PassageName]]` (Twine 2 / SugarCube arrow)
6454
- * - `[[Display Text|PassageName]]` (Twine 1 / Harlowe pipe)
6455
- * - `<<goto "PassageName">>` (SugarCube macro)
7115
+ * - `[[Display Text|PassageName]]`
7116
+ * - `[[Display Text->PassageName]]`
7117
+ * - `[[PassageName<-Display Text]]`
7118
+ * - any of those with a setter: `[[Display Text|PassageName][$x to 1]]`
7119
+ * - `<<goto "PassageName">>` (SugarCube macro)
6456
7120
  * - `<<link "Display" "PassageName">>` (SugarCube macro)
6457
7121
  *
6458
- * SugarCube macro arguments may be double- or single-quoted, with backslash escapes, or bare
6459
- * words; a passage named by a variable or an expression is known only in play and is skipped.
6460
- * Macro calls are also read inside the quoted strings of other macros' arguments, and inside the
6461
- * strings of `<<script>>` bodies and `<script>` elements; calls in comments are not read. A
6462
- * script passage (`isScript`) is JavaScript, so its macro calls are read only inside its strings.
6463
- * See `sugarcube-macros.ts` for the details and the known differences from SugarCube.
7122
+ * In link markup, the first `|`, `->` or `<-` divides the text from the passage name; image
7123
+ * markup (`[img[…][PassageName]]`) is not read. SugarCube macro arguments may be double- or
7124
+ * single-quoted, with backslash escapes, or bare words; a passage named by a variable or an
7125
+ * expression is known only in play and is skipped. Links and macro calls are also read inside the
7126
+ * quoted strings of macros' arguments, and inside the strings of `<<script>>` bodies and
7127
+ * `<script>` elements; links and calls in comments are not read. A script passage (`isScript`) is
7128
+ * JavaScript, so its links and calls are read only inside its strings. See `sugarcube-macros.ts`
7129
+ * for the details and the known differences from SugarCube.
6464
7130
  */
6465
7131
  function extractLinksFromText(text, isScript) {
6466
- const targets = /* @__PURE__ */ new Set();
6467
- const wikiLinkRe = /\[\[([^\]]+)\]\]/g;
6468
- let m;
6469
- while ((m = wikiLinkRe.exec(text)) !== null) {
6470
- const content = m[1];
6471
- const arrowIdx = content.indexOf("->");
6472
- if (arrowIdx !== -1) {
6473
- targets.add(content.slice(arrowIdx + 2).trim());
6474
- continue;
6475
- }
6476
- const pipeIdx = content.indexOf("|");
6477
- if (pipeIdx !== -1) {
6478
- targets.add(content.slice(pipeIdx + 1).trim());
6479
- continue;
6480
- }
6481
- targets.add(content.trim());
6482
- }
6483
- const macroLinks = isScript ? findJavaScriptPassageLinks(text) : findMacroPassageLinks(text);
6484
- for (const macro of ["goto", "link"]) for (const link of macroLinks) if (link.macro === macro) targets.add(link.passage);
6485
- return [...targets];
7132
+ const found = isScript ? findJavaScriptPassageLinks(text) : findPassageLinks(text);
7133
+ const ordered = [
7134
+ "markup",
7135
+ "goto",
7136
+ "link"
7137
+ ].flatMap((via) => found.filter((link) => link.via === via).map((link) => link.passage));
7138
+ return [...new Set(ordered)];
6486
7139
  }
6487
7140
  /** Passages SugarCube runs from their raw text, never joining their lines. */
6488
7141
  const RAW_TEXT_PASSAGES = /* @__PURE__ */ new Set([
@@ -6585,7 +7238,8 @@ function storyInspect(story) {
6585
7238
  //#region src/lint.ts
6586
7239
  /**
6587
7240
  * Lint a story: compile without output rendering, then inspect structure.
6588
- * Uses JSON output mode internally to avoid format resolution.
7241
+ * Uses JSON output mode internally to avoid format resolution, so it checks
7242
+ * the starting passage itself, against the Twine 2 passage rules.
6589
7243
  */
6590
7244
  async function lint(options) {
6591
7245
  const result = await compile({
@@ -6594,7 +7248,7 @@ async function lint(options) {
6594
7248
  });
6595
7249
  const map = storyInspect(result.story);
6596
7250
  return {
6597
- diagnostics: result.diagnostics,
7251
+ diagnostics: [...result.diagnostics, ...startPassageDiagnostics(result.story, map.start, "twine2")],
6598
7252
  stats: result.stats,
6599
7253
  formatName: result.story.twine2.format,
6600
7254
  formatVersion: result.story.twine2.formatVersion,
@@ -6720,6 +7374,7 @@ function validateConfig(data) {
6720
7374
  ]) if (key in obj && typeof obj[key] !== "boolean") errors.push(`"${key}" must be a boolean.`);
6721
7375
  for (const key of [
6722
7376
  "sources",
7377
+ "exclude",
6723
7378
  "formatPaths",
6724
7379
  "formatIndices",
6725
7380
  "formatUrls",