@rohal12/twee-ts 1.16.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
2
  import * as nodePath from "node:path";
3
- import { basename, dirname, extname, join, parse, relative, resolve } from "node:path";
3
+ import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { homedir } from "node:os";
6
6
  import { fileURLToPath } from "node:url";
@@ -104,6 +104,61 @@ function commentSanitize(s) {
104
104
  function htmlCommentSanitize(s) {
105
105
  return s.replace(/-->/g, "-- >");
106
106
  }
107
+ /**
108
+ * Escape JavaScript for the content of an HTML `<script>` element, so that the HTML parser reads all of it as
109
+ * the element's text and ends the element at the end tag written after it.
110
+ *
111
+ * - Every `<\/script` (in any letter case) becomes `<\/script`. HTML ends a script element at the first
112
+ * `<\/script` followed by whitespace, `/` or `>`, wherever it is in the JavaScript, even inside a string.
113
+ * - Every `<!--` becomes `<\!--`, but only if the text would otherwise end in the HTML tokenizer's "script
114
+ * data double escaped" state: after a `<!--` and then a `<script` start tag with no `-->` between them, the
115
+ * end tag after the text would not end the element.
116
+ *
117
+ * Inserting a backslash after `<` keeps the value of every string, template, and regular expression literal
118
+ * and every comment where these sequences can be written (`\/` and `\!` are identity escapes; a backslash
119
+ * already before the `<` escapes the `<`, as it did before); `<\/script` is also how minifiers such as Terser
120
+ * write inline scripts. It only changes code that uses them outside literals (`a <\/script/ b`, an HTML-like `<!--`
121
+ * comment that is escaped because of a later `<script`), the raw text of a tagged template, and a `<!--` in a
122
+ * regular expression with the `u` or `v` flag. The result is unchanged by escaping it again.
123
+ */
124
+ function scriptContentEscape(s) {
125
+ const escaped = s.replace(/<(?=\/script)/gi, "<\\");
126
+ return endsDoubleEscaped(escaped) ? escaped.replace(/<(?=!--)/g, "<\\") : escaped;
127
+ }
128
+ /**
129
+ * Whether the HTML tokenizer, reading `s` as script element content with no `<\/script` in it, ends in the
130
+ * "script data double escaped" state, where an end tag after `s` would not end the element.
131
+ * See https://html.spec.whatwg.org/multipage/parsing.html#script-data-escaped-state.
132
+ */
133
+ function endsDoubleEscaped(s) {
134
+ const scriptStartTag = /<script[\t\n\f\r />]/gi;
135
+ let pos = 0;
136
+ for (;;) {
137
+ const open = s.indexOf("<!--", pos);
138
+ if (open === -1) return false;
139
+ const escapedFrom = open + 2;
140
+ const close = s.indexOf("-->", escapedFrom);
141
+ scriptStartTag.lastIndex = escapedFrom;
142
+ const start = scriptStartTag.exec(s);
143
+ if (start === null || close !== -1 && close < start.index) {
144
+ if (close === -1) return false;
145
+ pos = close + 3;
146
+ continue;
147
+ }
148
+ const end = s.indexOf("-->", start.index + start[0].length);
149
+ if (end === -1) return true;
150
+ pos = end + 3;
151
+ }
152
+ }
153
+ /**
154
+ * Escape CSS for the content of an HTML `<style>` element: every `</style` (in any letter case) becomes
155
+ * `<\/style`, since HTML ends a style element at the first `</style` followed by whitespace, `/` or `>`.
156
+ * `\/` stands for `/` in CSS strings and URLs, and comments don't matter; `</style` anywhere else isn't valid
157
+ * CSS anyway.
158
+ */
159
+ function styleContentEscape(s) {
160
+ return s.replace(/<(?=\/style)/gi, "<\\");
161
+ }
107
162
  /** Apply ROT13 encoding to a string (only affects [A-Za-z]). */
108
163
  function rot13(s) {
109
164
  if (s.length === 0) return s;
@@ -618,16 +673,25 @@ function getFilenames(pathnames, outFilename, exclude = []) {
618
673
  };
619
674
  }
620
675
  //#endregion
676
+ //#region src/source-text.ts
677
+ /**
678
+ * Source text normalization shared by every input path (files, in-memory sources, `parseTwee`).
679
+ */
680
+ /**
681
+ * Strip a leading UTF-8 byte order mark and convert CRLF and bare CR line endings to LF.
682
+ * Idempotent, so text that is already normalized passes through unchanged.
683
+ */
684
+ function normalizeSourceText(text) {
685
+ return (text.charCodeAt(0) === 65279 ? text.slice(1) : text).replace(/\r\n?/g, "\n");
686
+ }
687
+ //#endregion
621
688
  //#region src/util.ts
622
689
  /**
623
690
  * Shared file I/O utilities.
624
691
  */
625
692
  /** Read a file as UTF-8 with BOM stripping and line ending normalization. */
626
693
  function readUTF8(filename) {
627
- let content = readFileSync(filename, "utf-8");
628
- if (content.charCodeAt(0) === 65279) content = content.slice(1);
629
- content = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
630
- return content;
694
+ return normalizeSourceText(readFileSync(filename, "utf-8"));
631
695
  }
632
696
  /** Read a file as base64. */
633
697
  function readBase64(filename) {
@@ -657,16 +721,28 @@ function relaxedJSONParse(json) {
657
721
  fixed = fixed.replace(/,\s*([}\]])/g, "$1");
658
722
  return JSON.parse(fixed);
659
723
  }
724
+ /** A `"setup": function` property, which Harlowe appends to its otherwise-JSON format object. */
725
+ const SETUP_FUNCTION_PROPERTY = /,\s*"setup"\s*:\s*function\b/g;
726
+ /** Drop a trailing `"setup": function(){…}` property from a format object, if it has one. */
727
+ function stripSetupFunction(chunk) {
728
+ let lastIndex = -1;
729
+ for (const match of chunk.matchAll(SETUP_FUNCTION_PROPERTY)) lastIndex = match.index;
730
+ return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
731
+ }
660
732
  /**
661
733
  * Parse the Twine 2 format.js JSON chunk.
662
- * Handles Harlowe's malformed JSON by stripping the "setup" function property.
734
+ * Handles Harlowe's malformed JSON by stripping the "setup" function property. The workaround
735
+ * keys on the property itself, so the same bytes parse whether they come from a `harlowe-3`
736
+ * directory or a direct download.
663
737
  * Per spec, the name key is Optional.
738
+ *
739
+ * @param _formatId No longer used; kept so existing callers keep compiling.
664
740
  */
665
- function parseFormatJSON(source, formatId) {
741
+ function parseFormatJSON(source, _formatId) {
666
742
  const first = source.indexOf("{");
667
743
  const last = source.lastIndexOf("}");
668
744
  if (first === -1 || last === -1) return null;
669
- let chunk = source.slice(first, last + 1);
745
+ const chunk = source.slice(first, last + 1);
670
746
  const parse = (json) => {
671
747
  const raw = relaxedJSONParse(json);
672
748
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
@@ -689,16 +765,13 @@ function parseFormatJSON(source, formatId) {
689
765
  try {
690
766
  return parse(chunk);
691
767
  } catch {
692
- if (formatId.toLowerCase().startsWith("harlowe")) {
693
- const setupIdx = chunk.lastIndexOf(",\"setup\": function");
694
- if (setupIdx !== -1) {
695
- chunk = chunk.slice(0, setupIdx) + "}";
696
- try {
697
- return parse(chunk);
698
- } catch {}
699
- }
768
+ const stripped = stripSetupFunction(chunk);
769
+ if (stripped === void 0) return null;
770
+ try {
771
+ return parse(stripped);
772
+ } catch {
773
+ return null;
700
774
  }
701
- return null;
702
775
  }
703
776
  }
704
777
  /**
@@ -848,6 +921,54 @@ function semverCompare(a, b) {
848
921
  if (aPatch !== bPatch) return aPatch - bPatch;
849
922
  return 0;
850
923
  }
924
+ /** Build a directory-style format ID from a name and version, e.g. ('SugarCube', '2.37.3') → 'sugarcube-2'. */
925
+ function makeFormatId(name, version) {
926
+ const major = parseSemver(version)?.[0] ?? 0;
927
+ return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
928
+ }
929
+ /** Describe a format request for diagnostics. */
930
+ function describeFormatRequest(request) {
931
+ switch (request.kind) {
932
+ case "id": return `"${request.id}"`;
933
+ case "name": return request.version ? `"${request.name}" at version "${request.version}"` : `"${request.name}"`;
934
+ default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
935
+ }
936
+ }
937
+ /**
938
+ * Pick the candidate that best answers a format request.
939
+ *
940
+ * - An ID request ('sugarcube-2') matches a candidate whose name and major version build that ID,
941
+ * and takes the greatest such version.
942
+ * - A name request matches the name case-insensitively. An exact version wins outright; otherwise
943
+ * the greatest version with the same major that is not older than the one asked for (or, with
944
+ * `allowOlder`, the greatest same-major version). An unparseable requested version takes the
945
+ * greatest version of any major.
946
+ */
947
+ function selectFormatCandidate(request, candidates, describe, options = {}) {
948
+ const allowOlder = options.allowOlder ?? false;
949
+ const wanted = request.kind === "name" ? parseSemver(request.version) : null;
950
+ let best;
951
+ for (const candidate of candidates) {
952
+ const { name, version } = describe(candidate);
953
+ const have = parseSemver(version);
954
+ if (!have) continue;
955
+ if (request.kind === "id") {
956
+ if (makeFormatId(name, version) !== request.id.toLowerCase()) continue;
957
+ } else {
958
+ if (name.toLowerCase() !== request.name.toLowerCase()) continue;
959
+ if (wanted) {
960
+ if (semverCompare(have, wanted) === 0) return candidate;
961
+ if (have[0] !== wanted[0]) continue;
962
+ if (!allowOlder && semverCompare(have, wanted) < 0) continue;
963
+ }
964
+ }
965
+ if (!best || semverCompare(have, best.version) > 0) best = {
966
+ candidate,
967
+ version: have
968
+ };
969
+ }
970
+ return best?.candidate;
971
+ }
851
972
  /** Get format ID from Twine 2 name and version, using SemVer major matching. */
852
973
  function getFormatIdByNameAndVersion(formats, name, version) {
853
974
  const wanted = parseSemver(version);
@@ -877,6 +998,321 @@ function readFormatSource(format) {
877
998
  return source;
878
999
  }
879
1000
  //#endregion
1001
+ //#region src/remote-formats.ts
1002
+ /**
1003
+ * Remote story format fetching, caching, and checksum verification.
1004
+ * Uses the Story Formats Archive (SFA) as the default source.
1005
+ */
1006
+ const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
1007
+ /** Get the cache directory for downloaded story formats. */
1008
+ function getCacheDir() {
1009
+ const base = process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache");
1010
+ return join(base, "twee-ts", "storyformats");
1011
+ }
1012
+ /**
1013
+ * Whether a string can serve as one directory name inside the cache: not empty, not `.` or `..`,
1014
+ * and free of path separators. Format names and versions come from downloaded metadata, so they
1015
+ * are untrusted path input.
1016
+ */
1017
+ function isSafeSegment(segment) {
1018
+ return segment !== "" && segment !== "." && segment !== ".." && !/[/\\\0]/.test(segment);
1019
+ }
1020
+ /** Whether absolute path `target` lies strictly inside absolute path `root`. */
1021
+ function isInside(root, target) {
1022
+ const rel = relative(root, target);
1023
+ return rel !== "" && !isAbsolute(rel) && rel.split(sep)[0] !== "..";
1024
+ }
1025
+ /** The cache directory for one format version. Throws when the name or version could leave the cache. */
1026
+ function cachedFormatDir(name, version) {
1027
+ if (!isSafeSegment(name)) throw new Error(`Refusing to cache a story format with an unsafe name: ${JSON.stringify(name)}`);
1028
+ if (!isSafeSegment(version) || !parseSemver(version)) throw new Error(`Refusing to cache story format "${name}" with an unsafe version: ${JSON.stringify(version)}`);
1029
+ const root = resolve(getCacheDir());
1030
+ const dir = resolve(root, name, version);
1031
+ if (!isInside(root, dir)) throw new Error(`Refusing to cache a story format outside the cache directory ${root}: ${dir}`);
1032
+ return dir;
1033
+ }
1034
+ /** Create `dir` as a plain directory, or check that it already is one (a symlink could lead out of the cache). */
1035
+ function ensurePlainDirectory(dir) {
1036
+ const stat = lstatSync(dir, { throwIfNoEntry: false });
1037
+ if (stat === void 0) {
1038
+ mkdirSync(dir);
1039
+ return;
1040
+ }
1041
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`Refusing to write a story format outside the cache directory: ${dir} is not a plain directory`);
1042
+ }
1043
+ /** Write a downloaded format.js to `<cache>/<name>/<version>/format.js` and return its path. */
1044
+ function writeCachedFormat(name, version, text) {
1045
+ const dir = cachedFormatDir(name, version);
1046
+ const root = resolve(getCacheDir());
1047
+ mkdirSync(root, { recursive: true });
1048
+ ensurePlainDirectory(join(root, name));
1049
+ ensurePlainDirectory(dir);
1050
+ const formatPath = join(dir, "format.js");
1051
+ if (lstatSync(formatPath, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error(`Refusing to write a story format outside the cache directory: ${formatPath} is a symlink`);
1052
+ writeFileSync(formatPath, text, "utf-8");
1053
+ return formatPath;
1054
+ }
1055
+ /** In-memory index cache, keyed by URL. Cleared each compile. */
1056
+ const indexCache = /* @__PURE__ */ new Map();
1057
+ /** Clear the in-memory index cache. */
1058
+ function clearIndexCache() {
1059
+ indexCache.clear();
1060
+ }
1061
+ /** Validate that a JSON value is a valid SFAIndexEntry. */
1062
+ function isValidEntry(val) {
1063
+ if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
1064
+ const obj = val;
1065
+ return typeof obj.name === "string" && typeof obj.version === "string" && typeof obj.checksums === "object" && obj.checksums !== null && !Array.isArray(obj.checksums);
1066
+ }
1067
+ /** Validate that a JSON value conforms to the SFAIndex shape. */
1068
+ function validateSFAIndex(json) {
1069
+ if (typeof json !== "object" || json === null) throw new Error("SFA index is not an object");
1070
+ const obj = json;
1071
+ return {
1072
+ twine1: Array.isArray(obj["twine1"]) ? obj["twine1"].filter(isValidEntry) : [],
1073
+ twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
1074
+ };
1075
+ }
1076
+ /** Fetch and parse an SFA index.json, with in-memory caching. */
1077
+ async function fetchIndex(url) {
1078
+ const cached = indexCache.get(url);
1079
+ if (cached) return cached;
1080
+ const res = await fetch(url);
1081
+ if (!res.ok) throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
1082
+ const data = validateSFAIndex(await res.json());
1083
+ indexCache.set(url, data);
1084
+ return data;
1085
+ }
1086
+ /** Find the best matching entry in an SFA index for a name or ID request (twine2 entries first). */
1087
+ function findEntryForRequest(index, request) {
1088
+ return selectFormatCandidate(request, [...(index.twine2 ?? []).map((entry) => ({
1089
+ entry,
1090
+ formatType: "twine2"
1091
+ })), ...(index.twine1 ?? []).map((entry) => ({
1092
+ entry,
1093
+ formatType: "twine1"
1094
+ }))], (c) => c.entry);
1095
+ }
1096
+ /** Verify SHA-256 checksum using Web Crypto API (Node 22 built-in). */
1097
+ async function verifySHA256(content, expectedHex) {
1098
+ const digest = await crypto.subtle.digest("SHA-256", content);
1099
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("") === expectedHex.toLowerCase();
1100
+ }
1101
+ /** Derive the download URL for a format.js from the index URL and entry. */
1102
+ function getDownloadUrl(indexUrl, entry, formatType) {
1103
+ return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
1104
+ }
1105
+ /** Download a format, verify its checksum, write to cache, and return StoryFormatInfo. */
1106
+ async function fetchAndCacheFormat(entry, downloadUrl) {
1107
+ cachedFormatDir(entry.name, entry.version);
1108
+ const res = await fetch(downloadUrl);
1109
+ if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
1110
+ const text = await res.text();
1111
+ const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
1112
+ if (checksumKey) {
1113
+ const expected = entry.checksums[checksumKey];
1114
+ if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
1115
+ if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
1116
+ }
1117
+ const id = makeFormatId(entry.name, entry.version);
1118
+ const data = parseFormatJSON(text, id);
1119
+ if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
1120
+ return {
1121
+ id,
1122
+ filename: writeCachedFormat(entry.name, entry.version, text),
1123
+ isTwine2: true,
1124
+ name: data.name,
1125
+ version: data.version,
1126
+ proofing: data.proofing ?? false
1127
+ };
1128
+ }
1129
+ /** Download a direct format.js URL, parse its JSON, cache it, and return StoryFormatInfo. */
1130
+ async function fetchDirectFormat(url) {
1131
+ const res = await fetch(url);
1132
+ if (!res.ok) throw new Error(`Failed to download format from ${url}: ${res.status} ${res.statusText}`);
1133
+ const text = await res.text();
1134
+ const data = parseFormatJSON(text);
1135
+ if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
1136
+ return {
1137
+ id: makeFormatId(data.name, data.version),
1138
+ filename: writeCachedFormat(data.name, data.version, text),
1139
+ isTwine2: true,
1140
+ name: data.name,
1141
+ version: data.version,
1142
+ proofing: data.proofing ?? false
1143
+ };
1144
+ }
1145
+ /**
1146
+ * Resolve a story format request remotely, in the same order as {@link resolveRemoteFormat}.
1147
+ * An ID request such as 'sugarcube-2' matches the format whose name and major version build that ID
1148
+ * (SugarCube 2.x), taking the greatest version available.
1149
+ */
1150
+ async function resolveRemoteFormatRequest(request, indices, urls) {
1151
+ let lastError;
1152
+ const cached = findCachedFormat(request);
1153
+ if (cached && request.kind === "name" && isExactVersion(cached.version, request.version)) return cached;
1154
+ for (const url of urls ?? []) try {
1155
+ const info = await fetchDirectFormat(url);
1156
+ if (selectFormatCandidate(request, [info], (f) => f)) return info;
1157
+ } catch (e) {
1158
+ lastError = e instanceof Error ? e : new Error(String(e));
1159
+ }
1160
+ const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
1161
+ for (const indexUrl of allIndices) try {
1162
+ const result = findEntryForRequest(await fetchIndex(indexUrl), request);
1163
+ if (result) {
1164
+ const hit = getCachedFormat(result.entry.name, result.entry.version);
1165
+ if (hit) return hit;
1166
+ const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
1167
+ return await fetchAndCacheFormat(result.entry, downloadUrl);
1168
+ }
1169
+ } catch (e) {
1170
+ lastError = e instanceof Error ? e : new Error(String(e));
1171
+ }
1172
+ if (cached) return cached;
1173
+ if (lastError) throw lastError;
1174
+ }
1175
+ /** Whether two version strings name the same SemVer version. */
1176
+ function isExactVersion(have, wanted) {
1177
+ const a = parseSemver(have);
1178
+ const b = parseSemver(wanted);
1179
+ return a !== null && b !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
1180
+ }
1181
+ /**
1182
+ * Find the cached download that best answers a format request, without network access.
1183
+ * Matching follows {@link selectFormatCandidate}.
1184
+ */
1185
+ function findCachedFormat(request, options = {}) {
1186
+ return selectFormatCandidate(request, [...discoverCachedFormats().values()], (f) => f, options);
1187
+ }
1188
+ /** Check if a format is already in the local cache. */
1189
+ function getCachedFormat(name, version) {
1190
+ if (!isSafeSegment(name) || !isSafeSegment(version)) return void 0;
1191
+ const formatPath = join(getCacheDir(), name, version, "format.js");
1192
+ try {
1193
+ if (!existsSync(formatPath)) return void 0;
1194
+ const source = readFileSync(formatPath, "utf-8");
1195
+ const id = makeFormatId(name, version);
1196
+ const data = parseFormatJSON(source, id);
1197
+ if (!data) return void 0;
1198
+ return {
1199
+ id,
1200
+ filename: formatPath,
1201
+ isTwine2: true,
1202
+ name: data.name,
1203
+ version: data.version,
1204
+ proofing: data.proofing ?? false
1205
+ };
1206
+ } catch {
1207
+ return;
1208
+ }
1209
+ }
1210
+ /** Discover all cached remote formats. Returns a Map like discoverFormats(). */
1211
+ function discoverCachedFormats() {
1212
+ const formats = /* @__PURE__ */ new Map();
1213
+ const cacheDir = getCacheDir();
1214
+ try {
1215
+ if (!existsSync(cacheDir)) return formats;
1216
+ } catch {
1217
+ return formats;
1218
+ }
1219
+ let names;
1220
+ try {
1221
+ names = readdirSync(cacheDir);
1222
+ } catch {
1223
+ return formats;
1224
+ }
1225
+ for (const name of names) {
1226
+ const nameDir = join(cacheDir, name);
1227
+ try {
1228
+ if (!statSync(nameDir).isDirectory()) continue;
1229
+ } catch {
1230
+ continue;
1231
+ }
1232
+ let versions;
1233
+ try {
1234
+ versions = readdirSync(nameDir);
1235
+ } catch {
1236
+ continue;
1237
+ }
1238
+ for (const version of versions) {
1239
+ const info = getCachedFormat(name, version);
1240
+ if (info) formats.set(`${info.id}-${version}`, info);
1241
+ }
1242
+ }
1243
+ return formats;
1244
+ }
1245
+ //#endregion
1246
+ //#region src/format-resolution.ts
1247
+ /** Choose the format request: explicit format ID > StoryData format > default ID. */
1248
+ function formatRequestFor(formatId, storyFormat, storyFormatVersion, defaultId) {
1249
+ if (formatId) return {
1250
+ kind: "id",
1251
+ id: formatId
1252
+ };
1253
+ if (storyFormat) return {
1254
+ kind: "name",
1255
+ name: storyFormat,
1256
+ version: storyFormatVersion
1257
+ };
1258
+ return {
1259
+ kind: "id",
1260
+ id: defaultId
1261
+ };
1262
+ }
1263
+ /** Look a request up among the formats found in local format directories. */
1264
+ function findLocalFormat(formats, request) {
1265
+ switch (request.kind) {
1266
+ case "id": return formats.get(request.id);
1267
+ case "name": {
1268
+ const id = getFormatIdByNameAndVersion(formats, request.name, request.version);
1269
+ return id === void 0 ? void 0 : formats.get(id);
1270
+ }
1271
+ default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
1272
+ }
1273
+ }
1274
+ /**
1275
+ * Resolve a format request to a story format.
1276
+ *
1277
+ * Order: local format directories, then the download cache, then remote sources (unless
1278
+ * `noRemote`). When a StoryData request names a version that none of them has, an older version of
1279
+ * the same format and major version is used with a warning. Anything else is an error diagnostic,
1280
+ * and the result is `undefined`.
1281
+ */
1282
+ async function resolveStoryFormat(request, options, diagnostics) {
1283
+ const noRemote = options.noRemote ?? false;
1284
+ const formats = discoverFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true));
1285
+ const wanted = describeFormatRequest(request);
1286
+ const local = findLocalFormat(formats, request);
1287
+ if (local) return local;
1288
+ const cached = findCachedFormat(request);
1289
+ if (cached) return cached;
1290
+ if (!noRemote) try {
1291
+ const remote = await resolveRemoteFormatRequest(request, options.formatIndices, options.formatUrls);
1292
+ if (remote) return remote;
1293
+ } catch (e) {
1294
+ diagnostics.push({
1295
+ level: "warning",
1296
+ message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
1297
+ });
1298
+ }
1299
+ if (request.kind === "name") {
1300
+ const older = selectFormatCandidate(request, [...formats.values()].filter((f) => f.isTwine2), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
1301
+ if (older) {
1302
+ diagnostics.push({
1303
+ level: "warning",
1304
+ message: `Story format ${wanted} is not available; using ${older.name} ${older.version} instead.`
1305
+ });
1306
+ return older;
1307
+ }
1308
+ }
1309
+ const reason = noRemote ? " (remote fetching disabled)" : "";
1310
+ diagnostics.push({
1311
+ level: "error",
1312
+ message: `Story format ${wanted} is not available${reason}. Found: ${[...formats.keys()].join(", ") || "none"}`
1313
+ });
1314
+ }
1315
+ //#endregion
880
1316
  //#region src/types.ts
881
1317
  const ItemType = {
882
1318
  Error: 0,
@@ -1140,16 +1576,20 @@ function twee2ToV3(s) {
1140
1576
  //#region src/parser.ts
1141
1577
  /**
1142
1578
  * Parse Twee source text into passages.
1579
+ *
1580
+ * The source is normalized first, as files are when they are read: a leading UTF-8 BOM is
1581
+ * removed and CRLF and bare CR line endings become LF.
1143
1582
  */
1144
1583
  function parseTwee(source, options = {}) {
1145
1584
  const { filename = "<inline>", trim = true, twee2Compat = false } = options;
1146
1585
  const diagnostics = [];
1147
- if (twee2Compat) source = twee2ToV3(source);
1586
+ const normalized = normalizeSourceText(source);
1587
+ const tweeSource = twee2Compat ? twee2ToV3(normalized) : normalized;
1148
1588
  const passages = [];
1149
1589
  let current = null;
1150
1590
  let pCount = 0;
1151
1591
  let lastType = ItemType.EOF;
1152
- for (const item of tweeLexer(source)) {
1592
+ for (const item of tweeLexer(tweeSource)) {
1153
1593
  switch (item.type) {
1154
1594
  case ItemType.Error:
1155
1595
  diagnostics.push({
@@ -3979,14 +4419,16 @@ function parseDocument(data, options) {
3979
4419
  */
3980
4420
  /**
3981
4421
  * Parse a Twine 2 or Twine 1 compiled HTML file back into a Story model.
4422
+ * Tweego always trims passage text; here `trim: false` keeps it exactly, as for Twee sources.
3982
4423
  */
3983
- function decompileHTML(html) {
4424
+ function decompileHTML(html, options = {}) {
3984
4425
  const diagnostics = [];
3985
4426
  const story = createStory();
4427
+ const passageText = options.trim === false ? (text) => text : (text) => text.trim();
3986
4428
  const doc = parseDocument(html);
3987
4429
  const twine2Data = findElement(doc, "tw-storydata");
3988
4430
  if (twine2Data) {
3989
- decompileTwine2(twine2Data, story, diagnostics);
4431
+ decompileTwine2(twine2Data, story, passageText, diagnostics);
3990
4432
  return {
3991
4433
  story,
3992
4434
  diagnostics
@@ -3994,7 +4436,7 @@ function decompileHTML(html) {
3994
4436
  }
3995
4437
  const twine1Data = findElementByIdPattern(doc, /^store(?:-a|A)rea$/);
3996
4438
  if (twine1Data) {
3997
- decompileTwine1(twine1Data, story, diagnostics);
4439
+ decompileTwine1(twine1Data, story, passageText, diagnostics);
3998
4440
  return {
3999
4441
  story,
4000
4442
  diagnostics
@@ -4009,7 +4451,7 @@ function decompileHTML(html) {
4009
4451
  diagnostics
4010
4452
  };
4011
4453
  }
4012
- function decompileTwine2(storyData, story, diagnostics) {
4454
+ function decompileTwine2(storyData, story, passageText, diagnostics) {
4013
4455
  let startnode = 0;
4014
4456
  const attrs = storyData.attribs ?? {};
4015
4457
  if (attrs["name"]) story.name = attrs["name"];
@@ -4030,6 +4472,7 @@ function decompileTwine2(storyData, story, diagnostics) {
4030
4472
  });
4031
4473
  else story.twine2.zoom = parsed;
4032
4474
  }
4475
+ if (attrs["tags"]) story.twine2.tags = attrs["tags"];
4033
4476
  if (attrs["format"]) story.twine2.format = attrs["format"];
4034
4477
  if (attrs["format-version"]) story.twine2.formatVersion = attrs["format-version"];
4035
4478
  if (attrs["options"]) for (const opt of attrs["options"].split(/\s+/).filter((s) => s.length > 0)) story.twine2.options.set(opt, true);
@@ -4038,8 +4481,9 @@ function decompileTwine2(storyData, story, diagnostics) {
4038
4481
  switch (node.name) {
4039
4482
  case "style":
4040
4483
  case "script": {
4041
- const text = getTextContent(node).trim();
4042
- if (text.length === 0) continue;
4484
+ const content = getTextContent(node);
4485
+ if (content.trim().length === 0) continue;
4486
+ const text = passageText(content);
4043
4487
  storyAdd(story, {
4044
4488
  name: node.name === "style" ? "Story Stylesheet" : "Story JavaScript",
4045
4489
  tags: node.name === "style" ? ["stylesheet"] : ["script"],
@@ -4074,7 +4518,7 @@ function decompileTwine2(storyData, story, diagnostics) {
4074
4518
  const passage = {
4075
4519
  name,
4076
4520
  tags,
4077
- text: getTextContent(node).trim()
4521
+ text: passageText(getTextContent(node))
4078
4522
  };
4079
4523
  if (metadata.position || metadata.size) passage.metadata = metadata;
4080
4524
  storyAdd(story, passage, diagnostics);
@@ -4088,23 +4532,33 @@ function decompileTwine2(storyData, story, diagnostics) {
4088
4532
  text: marshalStoryData(story)
4089
4533
  }, diagnostics);
4090
4534
  }
4091
- function decompileTwine1(storeArea, story, diagnostics) {
4092
- for (const node of storeArea.children ?? []) {
4093
- if (node.type !== "tag" || node.name !== "div") continue;
4094
- const nodeAttrs = node.attribs ?? {};
4095
- if (!("tiddler" in nodeAttrs)) continue;
4096
- const name = nodeAttrs["tiddler"] ?? "";
4097
- const tags = nodeAttrs["tags"] ? nodeAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [];
4098
- const metadata = {};
4099
- if (nodeAttrs["twine-position"]) metadata.position = nodeAttrs["twine-position"];
4100
- const passage = {
4101
- name,
4102
- tags,
4103
- text: tiddlerUnescape(getTextContent(node)).trim()
4104
- };
4105
- if (metadata.position) passage.metadata = metadata;
4106
- storyAdd(story, passage, diagnostics);
4107
- }
4535
+ function decompileTwine1(storeArea, story, passageText, diagnostics) {
4536
+ const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
4537
+ const obfuscated = isRot13Obfuscated(passages);
4538
+ for (const p of passages) storyAdd(story, obfuscated && p.name !== "StorySettings" ? {
4539
+ ...p,
4540
+ text: rot13(p.text)
4541
+ } : p, diagnostics);
4542
+ }
4543
+ function isTiddler(node) {
4544
+ return node.type === "tag" && node.name === "div" && node.attribs !== void 0 && "tiddler" in node.attribs;
4545
+ }
4546
+ function tiddlerToPassage(node, passageText) {
4547
+ const nodeAttrs = node.attribs ?? {};
4548
+ const passage = {
4549
+ name: nodeAttrs["tiddler"] ?? "",
4550
+ tags: nodeAttrs["tags"] ? nodeAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [],
4551
+ text: passageText(tiddlerUnescape(getTextContent(node)))
4552
+ };
4553
+ const position = nodeAttrs["twine-position"];
4554
+ if (position) passage.metadata = { position };
4555
+ return passage;
4556
+ }
4557
+ /** Whether the StorySettings tiddlers turn on ROT13 obfuscation, read the way the writer reads them. */
4558
+ function isRot13Obfuscated(passages) {
4559
+ const settings = createStory();
4560
+ for (const p of passages) if (p.name === "StorySettings") unmarshalStorySettings(settings, p.text, []);
4561
+ return settings.twine1.settings.get("obfuscate") === "rot13";
4108
4562
  }
4109
4563
  function findElement(node, tagName) {
4110
4564
  if (node.type === "tag" && node.name === tagName) return node;
@@ -4159,7 +4613,7 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
4159
4613
  break;
4160
4614
  case "htm":
4161
4615
  case "html":
4162
- loadHTML(story, filename, diagnostics);
4616
+ loadHTML(story, filename, opts, diagnostics);
4163
4617
  break;
4164
4618
  case "css":
4165
4619
  loadTagged(story, "stylesheet", filename, diagnostics);
@@ -4227,7 +4681,7 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
4227
4681
  function loadInlineSources(story, sources, opts, diagnostics) {
4228
4682
  for (const source of sources) {
4229
4683
  if (typeof source === "string") continue;
4230
- const content = typeof source.content === "string" ? source.content : source.content.toString("utf-8");
4684
+ const content = normalizeSourceText(typeof source.content === "string" ? source.content : source.content.toString("utf-8"));
4231
4685
  const ext = normalizedFileExt(source.filename);
4232
4686
  switch (ext) {
4233
4687
  case "":
@@ -4313,13 +4767,33 @@ function parseFontFile(filename) {
4313
4767
  diagnostics: []
4314
4768
  };
4315
4769
  }
4316
- function parseHTMLFile(filename) {
4317
- const result = decompileHTML(readUTF8(filename));
4770
+ function parseHTMLFile(filename, opts) {
4771
+ const { story, diagnostics } = decompileHTML(readUTF8(filename), { trim: opts.trim ?? true });
4318
4772
  return {
4319
- passages: result.story.passages,
4320
- diagnostics: [...result.diagnostics]
4773
+ passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
4774
+ name: "StoryTitle",
4775
+ tags: [],
4776
+ text: story.name
4777
+ }, ...story.passages] : story.passages,
4778
+ diagnostics: [...diagnostics]
4321
4779
  };
4322
4780
  }
4781
+ /**
4782
+ * Identity of the options that affect how a file parses, stored on its cache entry.
4783
+ * Twee files depend on `trim` and `twee2Compat`, HTML files on `trim`; every other file type
4784
+ * yields the same key for all options.
4785
+ */
4786
+ function parseOptionsKey(filename, opts) {
4787
+ const ext = normalizedFileExt(filename);
4788
+ const trim = opts.trim ?? true;
4789
+ if (ext === "htm" || ext === "html") return JSON.stringify({ trim });
4790
+ const twee2File = ext === "tw2" || ext === "twee2";
4791
+ if (!twee2File && ext !== "tw" && ext !== "twee") return "";
4792
+ return JSON.stringify({
4793
+ trim,
4794
+ twee2Compat: twee2File || (opts.twee2Compat ?? false)
4795
+ });
4796
+ }
4323
4797
  function parseFile(filename, opts) {
4324
4798
  switch (normalizedFileExt(filename)) {
4325
4799
  case "tw":
@@ -4330,7 +4804,7 @@ function parseFile(filename, opts) {
4330
4804
  twee2Compat: true
4331
4805
  });
4332
4806
  case "htm":
4333
- case "html": return parseHTMLFile(filename);
4807
+ case "html": return parseHTMLFile(filename, opts);
4334
4808
  case "css": return parseTaggedFile("stylesheet", filename);
4335
4809
  case "js": return parseTaggedFile("script", filename);
4336
4810
  case "otf":
@@ -4362,8 +4836,8 @@ function parseFile(filename, opts) {
4362
4836
  default: return;
4363
4837
  }
4364
4838
  }
4365
- function loadHTML(story, filename, diagnostics) {
4366
- const result = parseHTMLFile(filename);
4839
+ function loadHTML(story, filename, opts, diagnostics) {
4840
+ const result = parseHTMLFile(filename, opts);
4367
4841
  diagnostics.push(...result.diagnostics);
4368
4842
  for (const p of result.passages) storyAdd(story, p, diagnostics);
4369
4843
  }
@@ -4399,8 +4873,11 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
4399
4873
  continue;
4400
4874
  }
4401
4875
  currentFiles.add(filename);
4402
- const cached = cache.get(filename);
4403
- if (changedFiles && cached && !changedFiles.has(filename)) {
4876
+ const optionsKey = parseOptionsKey(filename, opts);
4877
+ const entry = cache.get(filename);
4878
+ const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
4879
+ const changed = changedFiles?.has(filename) ?? false;
4880
+ if (changedFiles && cached && !changed) {
4404
4881
  diagnostics.push(...cached.diagnostics);
4405
4882
  for (const p of cached.passages) storyAdd(story, p, diagnostics);
4406
4883
  processedFiles.add(filename);
@@ -4412,7 +4889,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
4412
4889
  } catch {
4413
4890
  continue;
4414
4891
  }
4415
- if (cached && cached.mtimeMs === mtimeMs) {
4892
+ if (cached && !changed && cached.mtimeMs === mtimeMs) {
4416
4893
  diagnostics.push(...cached.diagnostics);
4417
4894
  for (const p of cached.passages) storyAdd(story, p, diagnostics);
4418
4895
  processedFiles.add(filename);
@@ -4423,6 +4900,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
4423
4900
  if (!result) continue;
4424
4901
  cache.set(filename, {
4425
4902
  mtimeMs,
4903
+ parseOptionsKey: optionsKey,
4426
4904
  passages: result.passages,
4427
4905
  diagnostics: result.diagnostics
4428
4906
  });
@@ -4486,11 +4964,39 @@ function toTwine2HTML(story, format, startName, options) {
4486
4964
  template = template.replaceAll("{{STORY_NAME}}", () => name);
4487
4965
  }
4488
4966
  if (template.includes("{{STORY_DATA}}")) {
4489
- const data = getTwine2DataChunk(story, startName, options);
4967
+ const data = getTwine2DataChunk({
4968
+ ...story,
4969
+ twine2: {
4970
+ ...story.twine2,
4971
+ format: format.name,
4972
+ formatVersion: format.version
4973
+ }
4974
+ }, startName, options);
4490
4975
  template = template.replace("{{STORY_DATA}}", () => data);
4491
4976
  }
4492
4977
  return template;
4493
4978
  }
4979
+ const OMITTING_TAGS = [
4980
+ "Twine.private",
4981
+ "script",
4982
+ "stylesheet"
4983
+ ];
4984
+ /**
4985
+ * Why Twine 2 output leaves a passage out of its `<tw-passagedata>` elements,
4986
+ * or `undefined` when the passage is emitted (and so can be the starting passage).
4987
+ */
4988
+ function twine2PassageOmission(story, p) {
4989
+ if (p.name === "StoryTitle" || p.name === "StoryData") return {
4990
+ kind: "special-name",
4991
+ name: p.name
4992
+ };
4993
+ const tag = OMITTING_TAGS.find((t) => hasTag(p, t));
4994
+ if (tag !== void 0) return {
4995
+ kind: "tag",
4996
+ tag
4997
+ };
4998
+ if (p.name === "StorySettings" && story.twine1.settings.size === 0) return { kind: "empty-story-settings" };
4999
+ }
4494
5000
  function getTwine2DataChunk(story, startName, options) {
4495
5001
  const parts = [];
4496
5002
  let startID = "";
@@ -4513,7 +5019,7 @@ function getTwine2DataChunk(story, startName, options) {
4513
5019
  pidS++;
4514
5020
  }
4515
5021
  }
4516
- parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContent}</style>`);
5022
+ parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContentEscape(styleContent)}</style>`);
4517
5023
  let scriptContent = "";
4518
5024
  if (scripts.length === 1) scriptContent = scripts[0]?.text ?? "";
4519
5025
  else if (scripts.length > 1) {
@@ -4525,7 +5031,7 @@ function getTwine2DataChunk(story, startName, options) {
4525
5031
  pidS++;
4526
5032
  }
4527
5033
  }
4528
- parts.push(`<script role="script" id="twine-user-script" type="text/twine-javascript">${scriptContent}<\/script>`);
5034
+ parts.push(`<script role="script" id="twine-user-script" type="text/twine-javascript">${scriptContentEscape(scriptContent)}<\/script>`);
4529
5035
  const validTagColors = /* @__PURE__ */ new Set([
4530
5036
  "gray",
4531
5037
  "red",
@@ -4539,8 +5045,7 @@ function getTwine2DataChunk(story, startName, options) {
4539
5045
  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>`);
4540
5046
  pid = 1;
4541
5047
  for (const p of story.passages) {
4542
- if (p.name === "StoryTitle" || p.name === "StoryData" || hasAnyTag(p, "script", "stylesheet", "Twine.private")) continue;
4543
- if (p.name === "StorySettings" && story.twine1.settings.size === 0) continue;
5048
+ if (twine2PassageOmission(story, p) !== void 0) continue;
4544
5049
  parts.push(passageToPassagedata(p, pid, options));
4545
5050
  if (startName === p.name) startID = String(pid);
4546
5051
  pid++;
@@ -4596,12 +5101,21 @@ function toTwine1HTML(story, format, startName) {
4596
5101
  }
4597
5102
  return template;
4598
5103
  }
5104
+ /**
5105
+ * Why Twine 1 output leaves a passage out of its tiddlers, or `undefined` when the passage is emitted.
5106
+ */
5107
+ function twine1PassageOmission(p) {
5108
+ return hasTag(p, "Twine.private") ? {
5109
+ kind: "tag",
5110
+ tag: "Twine.private"
5111
+ } : void 0;
5112
+ }
4599
5113
  function getTwine1PassageChunk(story) {
4600
5114
  const obfuscateRot13 = story.twine1.settings.get("obfuscate") === "rot13";
4601
5115
  let data = "";
4602
5116
  let count = 0;
4603
5117
  for (const p of story.passages) {
4604
- if (hasTag(p, "Twine.private")) continue;
5118
+ if (twine1PassageOmission(p) !== void 0) continue;
4605
5119
  count++;
4606
5120
  data += passageToTiddler(p, count, obfuscateRot13);
4607
5121
  }
@@ -4623,10 +5137,39 @@ function tryReplaceComponent(template, placeholder, componentPath, required) {
4623
5137
  }
4624
5138
  //#endregion
4625
5139
  //#region src/output-twee.ts
4626
- function toTwee(story, outMode) {
4627
- let data = "";
4628
- for (const p of story.passages) data += passageToTwee(p, outMode);
4629
- return data;
5140
+ /**
5141
+ * Serialize the story as Twee. The StoryData passage is written from the story model rather than
5142
+ * from its loaded text, so changes made after loading (a start passage override, test mode) are kept.
5143
+ * Tweego writes the loaded text; its own normalization makes the two the same for an unchanged story.
5144
+ *
5145
+ * `addStoryData`: add a StoryData passage when the story has none, so that StoryData fields the
5146
+ * compile options set are recorded. Default: false.
5147
+ */
5148
+ function toTwee(story, outMode, options = {}) {
5149
+ return withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => passageToTwee(p, outMode)).join("");
5150
+ }
5151
+ function withEffectiveStoryData(story, addStoryData) {
5152
+ const storyData = {
5153
+ name: "StoryData",
5154
+ tags: [],
5155
+ text: marshalStoryData(story)
5156
+ };
5157
+ const index = story.passages.findIndex((p) => p.name === "StoryData");
5158
+ if (index !== -1) return story.passages.map((p, i) => i === index && isStoryDataJSON(p.text) ? {
5159
+ ...p,
5160
+ text: storyData.text
5161
+ } : p);
5162
+ if (!addStoryData) return story.passages;
5163
+ const at = story.passages[0]?.name === "StoryTitle" ? 1 : 0;
5164
+ return [
5165
+ ...story.passages.slice(0, at),
5166
+ storyData,
5167
+ ...story.passages.slice(at)
5168
+ ];
5169
+ }
5170
+ /** Whether the text loaded as StoryData. Text that did not is kept as written, for the author to fix. */
5171
+ function isStoryDataJSON(text) {
5172
+ return unmarshalStoryData(createStory(), text) === null;
4630
5173
  }
4631
5174
  //#endregion
4632
5175
  //#region src/modules.ts
@@ -4663,7 +5206,7 @@ function loadModules(filenames) {
4663
5206
  function loadModuleTagged(tag, filename) {
4664
5207
  const source = readUTF8(filename).trim();
4665
5208
  if (source.length === 0) return null;
4666
- return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${source}</${tag}>`;
5209
+ return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
4667
5210
  }
4668
5211
  function loadModuleFont(filename) {
4669
5212
  const source = readBase64(filename);
@@ -4698,199 +5241,45 @@ function modifyHead(html, modulePaths, headFile, diagnostics) {
4698
5241
  return html;
4699
5242
  }
4700
5243
  //#endregion
4701
- //#region src/remote-formats.ts
4702
- /**
4703
- * Remote story format fetching, caching, and checksum verification.
4704
- * Uses the Story Formats Archive (SFA) as the default source.
4705
- */
4706
- const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
4707
- /** Get the cache directory for downloaded story formats. */
4708
- function getCacheDir() {
4709
- const base = process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache");
4710
- return join(base, "twee-ts", "storyformats");
4711
- }
4712
- /** In-memory index cache, keyed by URL. Cleared each compile. */
4713
- const indexCache = /* @__PURE__ */ new Map();
4714
- /** Clear the in-memory index cache. */
4715
- function clearIndexCache() {
4716
- indexCache.clear();
4717
- }
4718
- /** Validate that a JSON value is a valid SFAIndexEntry. */
4719
- function isValidEntry(val) {
4720
- if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
4721
- const obj = val;
4722
- return typeof obj.name === "string" && typeof obj.version === "string" && typeof obj.checksums === "object" && obj.checksums !== null && !Array.isArray(obj.checksums);
4723
- }
4724
- /** Validate that a JSON value conforms to the SFAIndex shape. */
4725
- function validateSFAIndex(json) {
4726
- if (typeof json !== "object" || json === null) throw new Error("SFA index is not an object");
4727
- const obj = json;
4728
- return {
4729
- twine1: Array.isArray(obj["twine1"]) ? obj["twine1"].filter(isValidEntry) : [],
4730
- twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
4731
- };
4732
- }
4733
- /** Generate a format ID from name and version. */
4734
- function makeFormatId(name, version) {
4735
- const major = parseSemver(version)?.[0] ?? "0";
4736
- return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
4737
- }
4738
- /** Fetch and parse an SFA index.json, with in-memory caching. */
4739
- async function fetchIndex(url) {
4740
- const cached = indexCache.get(url);
4741
- if (cached) return cached;
4742
- const res = await fetch(url);
4743
- if (!res.ok) throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
4744
- const data = validateSFAIndex(await res.json());
4745
- indexCache.set(url, data);
4746
- return data;
4747
- }
5244
+ //#region src/passage-omission.ts
4748
5245
  /**
4749
- * Find the best matching entry in an SFA index.
4750
- * Exact version preferred, then highest version with same major.
5246
+ * Why the `target` output leaves `passage` out of its passage elements, or `undefined` when it
5247
+ * emits the passage (and so a link or the start can name it).
4751
5248
  */
4752
- function findEntry(index, name, version) {
4753
- const wanted = parseSemver(version);
4754
- const searchArrays = [{
4755
- entries: index.twine2 ?? [],
4756
- formatType: "twine2"
4757
- }, {
4758
- entries: index.twine1 ?? [],
4759
- formatType: "twine1"
4760
- }];
4761
- let bestResult;
4762
- let bestVersion = null;
4763
- for (const { entries, formatType } of searchArrays) for (const entry of entries) {
4764
- if (entry.name.toLowerCase() !== name.toLowerCase()) continue;
4765
- const have = parseSemver(entry.version);
4766
- if (!have) continue;
4767
- if (wanted && semverCompare(have, wanted) === 0) return {
4768
- entry,
4769
- formatType
4770
- };
4771
- if (wanted === null || have[0] === wanted[0] && semverCompare(have, wanted) >= 0) {
4772
- if (!bestVersion || semverCompare(have, bestVersion) > 0) {
4773
- bestVersion = have;
4774
- bestResult = {
4775
- entry,
4776
- formatType
4777
- };
4778
- }
4779
- }
5249
+ function passageOmission(story, passage, target) {
5250
+ switch (target) {
5251
+ case "twine2": return twine2PassageOmission(story, passage);
5252
+ case "twine1": return twine1PassageOmission(passage);
5253
+ default: throw new Error(`unhandled passage output target: ${String(target)}`);
4780
5254
  }
4781
- return bestResult;
4782
- }
4783
- /** Verify SHA-256 checksum using Web Crypto API (Node 22 built-in). */
4784
- async function verifySHA256(content, expectedHex) {
4785
- const digest = await crypto.subtle.digest("SHA-256", content);
4786
- return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("") === expectedHex.toLowerCase();
4787
- }
4788
- /** Derive the download URL for a format.js from the index URL and entry. */
4789
- function getDownloadUrl(indexUrl, entry, formatType) {
4790
- return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
4791
5255
  }
4792
- /** Download a format, verify its checksum, write to cache, and return StoryFormatInfo. */
4793
- async function fetchAndCacheFormat(entry, downloadUrl) {
4794
- const res = await fetch(downloadUrl);
4795
- if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
4796
- const text = await res.text();
4797
- const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
4798
- if (checksumKey) {
4799
- const expected = entry.checksums[checksumKey];
4800
- if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
4801
- if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
5256
+ /** Why a passage is left out, as a predicate: `Passage "X" ${describeOmission(o)}`. */
5257
+ function describeOmission(omission) {
5258
+ switch (omission.kind) {
5259
+ case "special-name": return `is the special "${omission.name}" passage`;
5260
+ case "tag": return `is tagged "${omission.tag}"`;
5261
+ case "empty-story-settings": return "is an empty \"StorySettings\" passage";
5262
+ default: throw new Error(`unhandled passage omission: ${JSON.stringify(omission)}`);
4802
5263
  }
4803
- const id = makeFormatId(entry.name, entry.version);
4804
- const data = parseFormatJSON(text, id);
4805
- if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
4806
- const cacheDir = getCacheDir();
4807
- const formatDir = join(cacheDir, entry.name, entry.version);
4808
- mkdirSync(formatDir, { recursive: true });
4809
- const formatPath = join(formatDir, "format.js");
4810
- writeFileSync(formatPath, text, "utf-8");
4811
- return {
4812
- id,
4813
- filename: formatPath,
4814
- isTwine2: true,
4815
- name: data.name,
4816
- version: data.version,
4817
- proofing: data.proofing ?? false
4818
- };
4819
- }
4820
- /** Download a direct format.js URL, parse its JSON, cache it, and return StoryFormatInfo. */
4821
- async function fetchDirectFormat(url) {
4822
- const res = await fetch(url);
4823
- if (!res.ok) throw new Error(`Failed to download format from ${url}: ${res.status} ${res.statusText}`);
4824
- const text = await res.text();
4825
- const data = parseFormatJSON(text, "direct-url");
4826
- if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
4827
- const id = makeFormatId(data.name, data.version);
4828
- const cacheDir = getCacheDir();
4829
- const formatDir = join(cacheDir, data.name, data.version);
4830
- mkdirSync(formatDir, { recursive: true });
4831
- const formatPath = join(formatDir, "format.js");
4832
- writeFileSync(formatPath, text, "utf-8");
4833
- return {
4834
- id,
4835
- filename: formatPath,
4836
- isTwine2: true,
4837
- name: data.name,
4838
- version: data.version,
4839
- proofing: data.proofing ?? false
4840
- };
4841
5264
  }
5265
+ //#endregion
5266
+ //#region src/start-passage.ts
4842
5267
  /**
4843
- * Try to resolve a remote story format by name and version.
4844
- * 1. Try direct format URLs
4845
- * 2. Try custom index URLs
4846
- * 3. Try default SFA indices
5268
+ * Check that `startName` names a passage the `target` output emits.
5269
+ * Returns the error diagnostics to report (empty when the start is usable).
4847
5270
  */
4848
- async function resolveRemoteFormat(name, version, indices, urls) {
4849
- let lastError;
4850
- if (urls) for (const url of urls) try {
4851
- const info = await fetchDirectFormat(url);
4852
- if (info.name.toLowerCase() === name.toLowerCase()) {
4853
- const wanted = parseSemver(version);
4854
- const have = parseSemver(info.version);
4855
- if (!wanted || have && have[0] === wanted[0] && semverCompare(have, wanted) >= 0) return info;
4856
- }
4857
- } catch (e) {
4858
- lastError = e instanceof Error ? e : new Error(String(e));
4859
- }
4860
- const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
4861
- for (const indexUrl of allIndices) try {
4862
- const result = findEntry(await fetchIndex(indexUrl), name, version);
4863
- if (result) {
4864
- const cached = getCachedFormat(result.entry.name, result.entry.version);
4865
- if (cached) return cached;
4866
- const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
4867
- return await fetchAndCacheFormat(result.entry, downloadUrl);
4868
- }
4869
- } catch (e) {
4870
- lastError = e instanceof Error ? e : new Error(String(e));
4871
- }
4872
- if (lastError) throw lastError;
4873
- }
4874
- /** Check if a format is already in the local cache. */
4875
- function getCachedFormat(name, version) {
4876
- const formatPath = join(getCacheDir(), name, version, "format.js");
4877
- try {
4878
- if (!existsSync(formatPath)) return void 0;
4879
- const source = readFileSync(formatPath, "utf-8");
4880
- const id = makeFormatId(name, version);
4881
- const data = parseFormatJSON(source, id);
4882
- if (!data) return void 0;
4883
- return {
4884
- id,
4885
- filename: formatPath,
4886
- isTwine2: true,
4887
- name: data.name,
4888
- version: data.version,
4889
- proofing: data.proofing ?? false
4890
- };
4891
- } catch {
4892
- return;
4893
- }
5271
+ function startPassageDiagnostics(story, startName, target) {
5272
+ const passage = story.passages.find((p) => p.name === startName);
5273
+ if (passage === void 0) return [{
5274
+ level: "error",
5275
+ message: `Starting passage "${startName}" not found.`
5276
+ }];
5277
+ const omission = passageOmission(story, passage, target);
5278
+ if (omission === void 0) return [];
5279
+ return [{
5280
+ level: "error",
5281
+ message: `Starting passage "${startName}" ${describeOmission(omission)}, so it is left out of the story data. Choose a story passage.`
5282
+ }];
4894
5283
  }
4895
5284
  //#endregion
4896
5285
  //#region src/compiler.ts
@@ -4914,11 +5303,20 @@ async function compile(options) {
4914
5303
  /**
4915
5304
  * Compile with incremental caching support.
4916
5305
  * Plugins and advanced users can manage their own cache and changed-file tracking.
5306
+ *
5307
+ * Without `changedFiles`, a cached file is reused while its modification time is unchanged.
5308
+ * With it, a file it names (by the path source discovery gives it, relative to the working
5309
+ * directory) is always reparsed, whatever its modification time, and every other cached
5310
+ * file is reused as it is.
4917
5311
  */
4918
5312
  async function compileIncremental(options, cache, changedFiles) {
4919
5313
  return buildOutput(options, cache, changedFiles);
4920
5314
  }
4921
- async function buildOutput(options, cache, changedFiles) {
5315
+ /**
5316
+ * `outFile`: the file the build is written to, which source and module discovery
5317
+ * skip (as Tweego does), so an output inside a source folder is never loaded back.
5318
+ */
5319
+ async function buildOutput(options, cache, changedFiles, outFile) {
4922
5320
  const diagnostics = [];
4923
5321
  const outputMode = options.outputMode ?? "html";
4924
5322
  const trim = options.trim ?? true;
@@ -4931,7 +5329,7 @@ async function buildOutput(options, cache, changedFiles) {
4931
5329
  const inlineSources = [];
4932
5330
  for (const source of options.sources) if (typeof source === "string") filePaths.push(source);
4933
5331
  else inlineSources.push(source);
4934
- const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths, void 0, options.exclude);
5332
+ const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths, outFile, options.exclude);
4935
5333
  diagnostics.push(...sourcePathDiagnostics);
4936
5334
  const story = createStory();
4937
5335
  const processedFiles = /* @__PURE__ */ new Set();
@@ -4949,40 +5347,19 @@ async function buildOutput(options, cache, changedFiles) {
4949
5347
  }, diagnostics);
4950
5348
  if (options.tagAliases) story.passages = applyTagAliases(story.passages, options.tagAliases);
4951
5349
  let format;
4952
- let formatId = options.formatId ?? "";
4953
- if (outputMode === "html") {
4954
- const formats = discoverFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true));
4955
- if (!formatId && story.twine2.format) formatId = getFormatIdByNameAndVersion(formats, story.twine2.format, story.twine2.formatVersion) ?? "";
4956
- if (!formatId) formatId = DEFAULT_FORMAT_ID;
4957
- format = formats.get(formatId);
4958
- if (!format && !noRemote) {
4959
- const remoteName = story.twine2.format || formatId;
4960
- const remoteVersion = story.twine2.formatVersion || "";
4961
- try {
4962
- format = await resolveRemoteFormat(remoteName, remoteVersion, options.formatIndices, options.formatUrls);
4963
- } catch (e) {
4964
- diagnostics.push({
4965
- level: "warning",
4966
- message: `Remote format fetch failed for "${remoteName}": ${e instanceof Error ? e.message : String(e)}`
4967
- });
4968
- }
4969
- }
4970
- if (!format) {
4971
- const reason = noRemote ? " (remote fetching disabled)" : "";
4972
- diagnostics.push({
4973
- level: "error",
4974
- message: `Story format "${formatId}" is not available${reason}. Found: ${[...formats.keys()].join(", ") || "none"}`
4975
- });
4976
- }
4977
- }
4978
- const startName = options.startPassage || story.twine2.start || DEFAULT_START_NAME;
5350
+ if (outputMode === "html") format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
5351
+ ...options,
5352
+ noRemote
5353
+ }, diagnostics);
5354
+ if (options.startPassage) story.twine2.start = options.startPassage;
5355
+ const startName = story.twine2.start || DEFAULT_START_NAME;
4979
5356
  if (testMode) story.twine2.options.set("debug", true);
4980
5357
  ensureIFID(story, diagnostics);
4981
5358
  let output;
4982
5359
  switch (outputMode) {
4983
5360
  case "twee3":
4984
5361
  case "twee1":
4985
- output = toTwee(story, outputMode);
5362
+ output = toTwee(story, outputMode, { addStoryData: Boolean(options.startPassage) || testMode });
4986
5363
  break;
4987
5364
  case "twine2-archive":
4988
5365
  output = toTwine2Archive(story, startName, { sourceInfo });
@@ -4994,10 +5371,7 @@ async function buildOutput(options, cache, changedFiles) {
4994
5371
  output = storyToJSON(story);
4995
5372
  break;
4996
5373
  case "html": {
4997
- if (!storyHas(story, startName)) diagnostics.push({
4998
- level: "error",
4999
- message: `Starting passage "${startName}" not found.`
5000
- });
5374
+ diagnostics.push(...startPassageDiagnostics(story, startName, format?.isTwine2 === false ? "twine1" : "twine2"));
5001
5375
  if (!format) throw new TweeTsError("No story format available for HTML output.", diagnostics);
5002
5376
  if (format.isTwine2) output = toTwine2HTML(story, format, startName, { sourceInfo });
5003
5377
  else {
@@ -5007,7 +5381,7 @@ async function buildOutput(options, cache, changedFiles) {
5007
5381
  });
5008
5382
  output = toTwine1HTML(story, format, startName);
5009
5383
  }
5010
- const modules = getFilenames(options.modules ?? []);
5384
+ const modules = getFilenames(options.modules ?? [], outFile);
5011
5385
  diagnostics.push(...modules.diagnostics);
5012
5386
  output = modifyHead(output, modules.filenames, options.headFile, diagnostics);
5013
5387
  break;
@@ -5085,6 +5459,35 @@ function storyToJSON(story) {
5085
5459
  return JSON.stringify(obj, null, 2);
5086
5460
  }
5087
5461
  //#endregion
5088
- export { isExcluded as a, getFilenames as i, compile as n, mediaTypeFromFilename as o, compileIncremental as r, TweeTsError as t };
5462
+ //#region src/plugins/diagnostics.ts
5463
+ function formatDiagnostic(d) {
5464
+ return `${d.file ? `${d.file}${d.line ? `:${d.line}` : ""}: ` : ""}${d.file && d.line ? d.message.replace(/^line \d+: /, "") : d.message}`;
5465
+ }
5466
+ /** Returns the warnings; throws a LocatedError when the compile reported errors. */
5467
+ function splitDiagnostics(result) {
5468
+ const errors = result.diagnostics.filter((d) => d.level === "error");
5469
+ if (errors.length === 0) return result.diagnostics.filter((d) => d.level === "warning");
5470
+ const error = new Error(errors.map(formatDiagnostic).join("\n"));
5471
+ const [first] = errors;
5472
+ if (first?.file) {
5473
+ error.id = first.file;
5474
+ error.loc = {
5475
+ file: first.file,
5476
+ line: first.line ?? 1,
5477
+ column: 1
5478
+ };
5479
+ }
5480
+ throw error;
5481
+ }
5482
+ /** An error thrown by the compiler or bundler, with a TweeTsError's error diagnostics folded into its message. */
5483
+ function fatalError(e) {
5484
+ if (e instanceof TweeTsError) {
5485
+ const errors = e.diagnostics.filter((d) => d.level === "error").map(formatDiagnostic);
5486
+ return new Error([...errors, e.message].join("\n"));
5487
+ }
5488
+ return e instanceof Error ? e : new Error(String(e));
5489
+ }
5490
+ //#endregion
5491
+ export { compileIncremental as a, mediaTypeFromFilename as c, compile as i, formatDiagnostic as n, getFilenames as o, splitDiagnostics as r, isExcluded as s, fatalError as t };
5089
5492
 
5090
- //# sourceMappingURL=compiler-BiDNwR9q.js.map
5493
+ //# sourceMappingURL=diagnostics-DZNS7W41.js.map