@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.
@@ -1,5 +1,6 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
- import { basename, dirname, extname, join, parse, relative, resolve } from "node:path";
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync, 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";
3
4
  import { randomUUID } from "node:crypto";
4
5
  import { homedir } from "node:os";
5
6
  import { fileURLToPath } from "node:url";
@@ -103,6 +104,61 @@ function commentSanitize(s) {
103
104
  function htmlCommentSanitize(s) {
104
105
  return s.replace(/-->/g, "-- >");
105
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
+ }
106
162
  /** Apply ROT13 encoding to a string (only affects [A-Za-z]). */
107
163
  function rot13(s) {
108
164
  if (s.length === 0) return s;
@@ -557,11 +613,25 @@ function fontFormatHint(ext) {
557
613
  * Ported from filesystem.go.
558
614
  */
559
615
  /**
616
+ * Whether `filename` matches one of the `exclude` globs. The globs are matched
617
+ * against the file's path relative to the working directory, the path
618
+ * getFilenames reports; an absolute `filename` is made relative first. A leading
619
+ * `./` in a glob is dropped. Matching is Node's `path.matchesGlob`.
620
+ */
621
+ function isExcluded(filename, exclude) {
622
+ if (exclude.length === 0) return false;
623
+ const { matchesGlob } = nodePath;
624
+ if (typeof matchesGlob !== "function") throw new Error(`The exclude option needs Node.js 22.5 or newer (found ${process.version}).`);
625
+ const rel = relative(process.cwd(), resolve(filename)) || filename;
626
+ return exclude.some((pattern) => matchesGlob(rel, pattern.replace(/^\.\//, "")));
627
+ }
628
+ /**
560
629
  * Recursively walk directories, collecting regular file paths.
561
- * Filters out the output file to prevent circular compilation.
630
+ * Filters out the output file to prevent circular compilation, and the files
631
+ * that match an `exclude` glob (see isExcluded).
562
632
  * Like Tweego, a path that cannot be read is reported as a warning and skipped.
563
633
  */
564
- function getFilenames(pathnames, outFilename) {
634
+ function getFilenames(pathnames, outFilename, exclude = []) {
565
635
  const filenames = [];
566
636
  const diagnostics = [];
567
637
  const absOutFile = outFilename ? resolve(outFilename) : "";
@@ -583,6 +653,7 @@ function getFilenames(pathnames, outFilename) {
583
653
  const abs = resolve(pathname);
584
654
  if (abs === absOutFile) return;
585
655
  const rel = relative(process.cwd(), abs);
656
+ if (isExcluded(rel || abs, exclude)) return;
586
657
  filenames.push(rel || abs);
587
658
  } else if (stat.isDirectory()) {
588
659
  let entries;
@@ -602,16 +673,25 @@ function getFilenames(pathnames, outFilename) {
602
673
  };
603
674
  }
604
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
605
688
  //#region src/util.ts
606
689
  /**
607
690
  * Shared file I/O utilities.
608
691
  */
609
692
  /** Read a file as UTF-8 with BOM stripping and line ending normalization. */
610
693
  function readUTF8(filename) {
611
- let content = readFileSync(filename, "utf-8");
612
- if (content.charCodeAt(0) === 65279) content = content.slice(1);
613
- content = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
614
- return content;
694
+ return normalizeSourceText(readFileSync(filename, "utf-8"));
615
695
  }
616
696
  /** Read a file as base64. */
617
697
  function readBase64(filename) {
@@ -641,16 +721,28 @@ function relaxedJSONParse(json) {
641
721
  fixed = fixed.replace(/,\s*([}\]])/g, "$1");
642
722
  return JSON.parse(fixed);
643
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
+ }
644
732
  /**
645
733
  * Parse the Twine 2 format.js JSON chunk.
646
- * 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.
647
737
  * Per spec, the name key is Optional.
738
+ *
739
+ * @param _formatId No longer used; kept so existing callers keep compiling.
648
740
  */
649
- function parseFormatJSON(source, formatId) {
741
+ function parseFormatJSON(source, _formatId) {
650
742
  const first = source.indexOf("{");
651
743
  const last = source.lastIndexOf("}");
652
744
  if (first === -1 || last === -1) return null;
653
- let chunk = source.slice(first, last + 1);
745
+ const chunk = source.slice(first, last + 1);
654
746
  const parse = (json) => {
655
747
  const raw = relaxedJSONParse(json);
656
748
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
@@ -673,16 +765,13 @@ function parseFormatJSON(source, formatId) {
673
765
  try {
674
766
  return parse(chunk);
675
767
  } catch {
676
- if (formatId.toLowerCase().startsWith("harlowe")) {
677
- const setupIdx = chunk.lastIndexOf(",\"setup\": function");
678
- if (setupIdx !== -1) {
679
- chunk = chunk.slice(0, setupIdx) + "}";
680
- try {
681
- return parse(chunk);
682
- } catch {}
683
- }
768
+ const stripped = stripSetupFunction(chunk);
769
+ if (stripped === void 0) return null;
770
+ try {
771
+ return parse(stripped);
772
+ } catch {
773
+ return null;
684
774
  }
685
- return null;
686
775
  }
687
776
  }
688
777
  /**
@@ -832,6 +921,54 @@ function semverCompare(a, b) {
832
921
  if (aPatch !== bPatch) return aPatch - bPatch;
833
922
  return 0;
834
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
+ }
835
972
  /** Get format ID from Twine 2 name and version, using SemVer major matching. */
836
973
  function getFormatIdByNameAndVersion(formats, name, version) {
837
974
  const wanted = parseSemver(version);
@@ -861,6 +998,321 @@ function readFormatSource(format) {
861
998
  return source;
862
999
  }
863
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
864
1316
  //#region src/types.ts
865
1317
  const ItemType = {
866
1318
  Error: 0,
@@ -1124,16 +1576,20 @@ function twee2ToV3(s) {
1124
1576
  //#region src/parser.ts
1125
1577
  /**
1126
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.
1127
1582
  */
1128
1583
  function parseTwee(source, options = {}) {
1129
1584
  const { filename = "<inline>", trim = true, twee2Compat = false } = options;
1130
1585
  const diagnostics = [];
1131
- if (twee2Compat) source = twee2ToV3(source);
1586
+ const normalized = normalizeSourceText(source);
1587
+ const tweeSource = twee2Compat ? twee2ToV3(normalized) : normalized;
1132
1588
  const passages = [];
1133
1589
  let current = null;
1134
1590
  let pCount = 0;
1135
1591
  let lastType = ItemType.EOF;
1136
- for (const item of tweeLexer(source)) {
1592
+ for (const item of tweeLexer(tweeSource)) {
1137
1593
  switch (item.type) {
1138
1594
  case ItemType.Error:
1139
1595
  diagnostics.push({
@@ -4014,6 +4470,7 @@ function decompileTwine2(storyData, story, diagnostics) {
4014
4470
  });
4015
4471
  else story.twine2.zoom = parsed;
4016
4472
  }
4473
+ if (attrs["tags"]) story.twine2.tags = attrs["tags"];
4017
4474
  if (attrs["format"]) story.twine2.format = attrs["format"];
4018
4475
  if (attrs["format-version"]) story.twine2.formatVersion = attrs["format-version"];
4019
4476
  if (attrs["options"]) for (const opt of attrs["options"].split(/\s+/).filter((s) => s.length > 0)) story.twine2.options.set(opt, true);
@@ -4073,22 +4530,32 @@ function decompileTwine2(storyData, story, diagnostics) {
4073
4530
  }, diagnostics);
4074
4531
  }
4075
4532
  function decompileTwine1(storeArea, story, diagnostics) {
4076
- for (const node of storeArea.children ?? []) {
4077
- if (node.type !== "tag" || node.name !== "div") continue;
4078
- const nodeAttrs = node.attribs ?? {};
4079
- if (!("tiddler" in nodeAttrs)) continue;
4080
- const name = nodeAttrs["tiddler"] ?? "";
4081
- const tags = nodeAttrs["tags"] ? nodeAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [];
4082
- const metadata = {};
4083
- if (nodeAttrs["twine-position"]) metadata.position = nodeAttrs["twine-position"];
4084
- const passage = {
4085
- name,
4086
- tags,
4087
- text: tiddlerUnescape(getTextContent(node)).trim()
4088
- };
4089
- if (metadata.position) passage.metadata = metadata;
4090
- storyAdd(story, passage, diagnostics);
4091
- }
4533
+ const passages = (storeArea.children ?? []).filter(isTiddler).map(tiddlerToPassage);
4534
+ const obfuscated = isRot13Obfuscated(passages);
4535
+ for (const p of passages) storyAdd(story, obfuscated && p.name !== "StorySettings" ? {
4536
+ ...p,
4537
+ text: rot13(p.text)
4538
+ } : p, diagnostics);
4539
+ }
4540
+ function isTiddler(node) {
4541
+ return node.type === "tag" && node.name === "div" && node.attribs !== void 0 && "tiddler" in node.attribs;
4542
+ }
4543
+ function tiddlerToPassage(node) {
4544
+ const nodeAttrs = node.attribs ?? {};
4545
+ const passage = {
4546
+ name: nodeAttrs["tiddler"] ?? "",
4547
+ tags: nodeAttrs["tags"] ? nodeAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [],
4548
+ text: tiddlerUnescape(getTextContent(node)).trim()
4549
+ };
4550
+ const position = nodeAttrs["twine-position"];
4551
+ if (position) passage.metadata = { position };
4552
+ return passage;
4553
+ }
4554
+ /** Whether the StorySettings tiddlers turn on ROT13 obfuscation, read the way the writer reads them. */
4555
+ function isRot13Obfuscated(passages) {
4556
+ const settings = createStory();
4557
+ for (const p of passages) if (p.name === "StorySettings") unmarshalStorySettings(settings, p.text, []);
4558
+ return settings.twine1.settings.get("obfuscate") === "rot13";
4092
4559
  }
4093
4560
  function findElement(node, tagName) {
4094
4561
  if (node.type === "tag" && node.name === tagName) return node;
@@ -4211,7 +4678,7 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
4211
4678
  function loadInlineSources(story, sources, opts, diagnostics) {
4212
4679
  for (const source of sources) {
4213
4680
  if (typeof source === "string") continue;
4214
- const content = typeof source.content === "string" ? source.content : source.content.toString("utf-8");
4681
+ const content = normalizeSourceText(typeof source.content === "string" ? source.content : source.content.toString("utf-8"));
4215
4682
  const ext = normalizedFileExt(source.filename);
4216
4683
  switch (ext) {
4217
4684
  case "":
@@ -4298,12 +4765,29 @@ function parseFontFile(filename) {
4298
4765
  };
4299
4766
  }
4300
4767
  function parseHTMLFile(filename) {
4301
- const result = decompileHTML(readUTF8(filename));
4768
+ const { story, diagnostics } = decompileHTML(readUTF8(filename));
4302
4769
  return {
4303
- passages: result.story.passages,
4304
- diagnostics: [...result.diagnostics]
4770
+ passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
4771
+ name: "StoryTitle",
4772
+ tags: [],
4773
+ text: story.name
4774
+ }, ...story.passages] : story.passages,
4775
+ diagnostics: [...diagnostics]
4305
4776
  };
4306
4777
  }
4778
+ /**
4779
+ * Identity of the options that affect how a file parses, stored on its cache entry.
4780
+ * Only Twee files depend on them; every other file type yields the same key for all options.
4781
+ */
4782
+ function parseOptionsKey(filename, opts) {
4783
+ const ext = normalizedFileExt(filename);
4784
+ const twee2File = ext === "tw2" || ext === "twee2";
4785
+ if (!twee2File && ext !== "tw" && ext !== "twee") return "";
4786
+ return JSON.stringify({
4787
+ trim: opts.trim ?? true,
4788
+ twee2Compat: twee2File || (opts.twee2Compat ?? false)
4789
+ });
4790
+ }
4307
4791
  function parseFile(filename, opts) {
4308
4792
  switch (normalizedFileExt(filename)) {
4309
4793
  case "tw":
@@ -4383,7 +4867,9 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
4383
4867
  continue;
4384
4868
  }
4385
4869
  currentFiles.add(filename);
4386
- const cached = cache.get(filename);
4870
+ const optionsKey = parseOptionsKey(filename, opts);
4871
+ const entry = cache.get(filename);
4872
+ const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
4387
4873
  if (changedFiles && cached && !changedFiles.has(filename)) {
4388
4874
  diagnostics.push(...cached.diagnostics);
4389
4875
  for (const p of cached.passages) storyAdd(story, p, diagnostics);
@@ -4407,6 +4893,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
4407
4893
  if (!result) continue;
4408
4894
  cache.set(filename, {
4409
4895
  mtimeMs,
4896
+ parseOptionsKey: optionsKey,
4410
4897
  passages: result.passages,
4411
4898
  diagnostics: result.diagnostics
4412
4899
  });
@@ -4470,11 +4957,39 @@ function toTwine2HTML(story, format, startName, options) {
4470
4957
  template = template.replaceAll("{{STORY_NAME}}", () => name);
4471
4958
  }
4472
4959
  if (template.includes("{{STORY_DATA}}")) {
4473
- const data = getTwine2DataChunk(story, startName, options);
4960
+ const data = getTwine2DataChunk({
4961
+ ...story,
4962
+ twine2: {
4963
+ ...story.twine2,
4964
+ format: format.name,
4965
+ formatVersion: format.version
4966
+ }
4967
+ }, startName, options);
4474
4968
  template = template.replace("{{STORY_DATA}}", () => data);
4475
4969
  }
4476
4970
  return template;
4477
4971
  }
4972
+ const OMITTING_TAGS = [
4973
+ "Twine.private",
4974
+ "script",
4975
+ "stylesheet"
4976
+ ];
4977
+ /**
4978
+ * Why Twine 2 output leaves a passage out of its `<tw-passagedata>` elements,
4979
+ * or `undefined` when the passage is emitted (and so can be the starting passage).
4980
+ */
4981
+ function twine2PassageOmission(story, p) {
4982
+ if (p.name === "StoryTitle" || p.name === "StoryData") return {
4983
+ kind: "special-name",
4984
+ name: p.name
4985
+ };
4986
+ const tag = OMITTING_TAGS.find((t) => hasTag(p, t));
4987
+ if (tag !== void 0) return {
4988
+ kind: "tag",
4989
+ tag
4990
+ };
4991
+ if (p.name === "StorySettings" && story.twine1.settings.size === 0) return { kind: "empty-story-settings" };
4992
+ }
4478
4993
  function getTwine2DataChunk(story, startName, options) {
4479
4994
  const parts = [];
4480
4995
  let startID = "";
@@ -4497,7 +5012,7 @@ function getTwine2DataChunk(story, startName, options) {
4497
5012
  pidS++;
4498
5013
  }
4499
5014
  }
4500
- parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContent}</style>`);
5015
+ parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContentEscape(styleContent)}</style>`);
4501
5016
  let scriptContent = "";
4502
5017
  if (scripts.length === 1) scriptContent = scripts[0]?.text ?? "";
4503
5018
  else if (scripts.length > 1) {
@@ -4509,7 +5024,7 @@ function getTwine2DataChunk(story, startName, options) {
4509
5024
  pidS++;
4510
5025
  }
4511
5026
  }
4512
- parts.push(`<script role="script" id="twine-user-script" type="text/twine-javascript">${scriptContent}<\/script>`);
5027
+ parts.push(`<script role="script" id="twine-user-script" type="text/twine-javascript">${scriptContentEscape(scriptContent)}<\/script>`);
4513
5028
  const validTagColors = /* @__PURE__ */ new Set([
4514
5029
  "gray",
4515
5030
  "red",
@@ -4523,8 +5038,7 @@ function getTwine2DataChunk(story, startName, options) {
4523
5038
  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>`);
4524
5039
  pid = 1;
4525
5040
  for (const p of story.passages) {
4526
- if (p.name === "StoryTitle" || p.name === "StoryData" || hasAnyTag(p, "script", "stylesheet", "Twine.private")) continue;
4527
- if (p.name === "StorySettings" && story.twine1.settings.size === 0) continue;
5041
+ if (twine2PassageOmission(story, p) !== void 0) continue;
4528
5042
  parts.push(passageToPassagedata(p, pid, options));
4529
5043
  if (startName === p.name) startID = String(pid);
4530
5044
  pid++;
@@ -4580,12 +5094,21 @@ function toTwine1HTML(story, format, startName) {
4580
5094
  }
4581
5095
  return template;
4582
5096
  }
5097
+ /**
5098
+ * Why Twine 1 output leaves a passage out of its tiddlers, or `undefined` when the passage is emitted.
5099
+ */
5100
+ function twine1PassageOmission(p) {
5101
+ return hasTag(p, "Twine.private") ? {
5102
+ kind: "tag",
5103
+ tag: "Twine.private"
5104
+ } : void 0;
5105
+ }
4583
5106
  function getTwine1PassageChunk(story) {
4584
5107
  const obfuscateRot13 = story.twine1.settings.get("obfuscate") === "rot13";
4585
5108
  let data = "";
4586
5109
  let count = 0;
4587
5110
  for (const p of story.passages) {
4588
- if (hasTag(p, "Twine.private")) continue;
5111
+ if (twine1PassageOmission(p) !== void 0) continue;
4589
5112
  count++;
4590
5113
  data += passageToTiddler(p, count, obfuscateRot13);
4591
5114
  }
@@ -4647,7 +5170,7 @@ function loadModules(filenames) {
4647
5170
  function loadModuleTagged(tag, filename) {
4648
5171
  const source = readUTF8(filename).trim();
4649
5172
  if (source.length === 0) return null;
4650
- return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${source}</${tag}>`;
5173
+ return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
4651
5174
  }
4652
5175
  function loadModuleFont(filename) {
4653
5176
  const source = readBase64(filename);
@@ -4682,199 +5205,38 @@ function modifyHead(html, modulePaths, headFile, diagnostics) {
4682
5205
  return html;
4683
5206
  }
4684
5207
  //#endregion
4685
- //#region src/remote-formats.ts
4686
- /**
4687
- * Remote story format fetching, caching, and checksum verification.
4688
- * Uses the Story Formats Archive (SFA) as the default source.
4689
- */
4690
- const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
4691
- /** Get the cache directory for downloaded story formats. */
4692
- function getCacheDir() {
4693
- const base = process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache");
4694
- return join(base, "twee-ts", "storyformats");
4695
- }
4696
- /** In-memory index cache, keyed by URL. Cleared each compile. */
4697
- const indexCache = /* @__PURE__ */ new Map();
4698
- /** Clear the in-memory index cache. */
4699
- function clearIndexCache() {
4700
- indexCache.clear();
4701
- }
4702
- /** Validate that a JSON value is a valid SFAIndexEntry. */
4703
- function isValidEntry(val) {
4704
- if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
4705
- const obj = val;
4706
- return typeof obj.name === "string" && typeof obj.version === "string" && typeof obj.checksums === "object" && obj.checksums !== null && !Array.isArray(obj.checksums);
4707
- }
4708
- /** Validate that a JSON value conforms to the SFAIndex shape. */
4709
- function validateSFAIndex(json) {
4710
- if (typeof json !== "object" || json === null) throw new Error("SFA index is not an object");
4711
- const obj = json;
4712
- return {
4713
- twine1: Array.isArray(obj["twine1"]) ? obj["twine1"].filter(isValidEntry) : [],
4714
- twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
4715
- };
4716
- }
4717
- /** Generate a format ID from name and version. */
4718
- function makeFormatId(name, version) {
4719
- const major = parseSemver(version)?.[0] ?? "0";
4720
- return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
4721
- }
4722
- /** Fetch and parse an SFA index.json, with in-memory caching. */
4723
- async function fetchIndex(url) {
4724
- const cached = indexCache.get(url);
4725
- if (cached) return cached;
4726
- const res = await fetch(url);
4727
- if (!res.ok) throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
4728
- const data = validateSFAIndex(await res.json());
4729
- indexCache.set(url, data);
4730
- return data;
4731
- }
4732
- /**
4733
- * Find the best matching entry in an SFA index.
4734
- * Exact version preferred, then highest version with same major.
4735
- */
4736
- function findEntry(index, name, version) {
4737
- const wanted = parseSemver(version);
4738
- const searchArrays = [{
4739
- entries: index.twine2 ?? [],
4740
- formatType: "twine2"
4741
- }, {
4742
- entries: index.twine1 ?? [],
4743
- formatType: "twine1"
4744
- }];
4745
- let bestResult;
4746
- let bestVersion = null;
4747
- for (const { entries, formatType } of searchArrays) for (const entry of entries) {
4748
- if (entry.name.toLowerCase() !== name.toLowerCase()) continue;
4749
- const have = parseSemver(entry.version);
4750
- if (!have) continue;
4751
- if (wanted && semverCompare(have, wanted) === 0) return {
4752
- entry,
4753
- formatType
4754
- };
4755
- if (wanted === null || have[0] === wanted[0] && semverCompare(have, wanted) >= 0) {
4756
- if (!bestVersion || semverCompare(have, bestVersion) > 0) {
4757
- bestVersion = have;
4758
- bestResult = {
4759
- entry,
4760
- formatType
4761
- };
4762
- }
4763
- }
5208
+ //#region src/start-passage.ts
5209
+ function describeOmission(omission) {
5210
+ switch (omission.kind) {
5211
+ case "special-name": return `is the special "${omission.name}" passage`;
5212
+ case "tag": return `is tagged "${omission.tag}"`;
5213
+ case "empty-story-settings": return "is an empty \"StorySettings\" passage";
5214
+ default: throw new Error(`unhandled passage omission: ${JSON.stringify(omission)}`);
4764
5215
  }
4765
- return bestResult;
4766
- }
4767
- /** Verify SHA-256 checksum using Web Crypto API (Node 22 built-in). */
4768
- async function verifySHA256(content, expectedHex) {
4769
- const digest = await crypto.subtle.digest("SHA-256", content);
4770
- return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("") === expectedHex.toLowerCase();
4771
5216
  }
4772
- /** Derive the download URL for a format.js from the index URL and entry. */
4773
- function getDownloadUrl(indexUrl, entry, formatType) {
4774
- return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
4775
- }
4776
- /** Download a format, verify its checksum, write to cache, and return StoryFormatInfo. */
4777
- async function fetchAndCacheFormat(entry, downloadUrl) {
4778
- const res = await fetch(downloadUrl);
4779
- if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
4780
- const text = await res.text();
4781
- const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
4782
- if (checksumKey) {
4783
- const expected = entry.checksums[checksumKey];
4784
- if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
4785
- if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
5217
+ function omissionFor(story, passage, target) {
5218
+ switch (target) {
5219
+ case "twine2": return twine2PassageOmission(story, passage);
5220
+ case "twine1": return twine1PassageOmission(passage);
5221
+ default: throw new Error(`unhandled start passage target: ${String(target)}`);
4786
5222
  }
4787
- const id = makeFormatId(entry.name, entry.version);
4788
- const data = parseFormatJSON(text, id);
4789
- if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
4790
- const cacheDir = getCacheDir();
4791
- const formatDir = join(cacheDir, entry.name, entry.version);
4792
- mkdirSync(formatDir, { recursive: true });
4793
- const formatPath = join(formatDir, "format.js");
4794
- writeFileSync(formatPath, text, "utf-8");
4795
- return {
4796
- id,
4797
- filename: formatPath,
4798
- isTwine2: true,
4799
- name: data.name,
4800
- version: data.version,
4801
- proofing: data.proofing ?? false
4802
- };
4803
- }
4804
- /** Download a direct format.js URL, parse its JSON, cache it, and return StoryFormatInfo. */
4805
- async function fetchDirectFormat(url) {
4806
- const res = await fetch(url);
4807
- if (!res.ok) throw new Error(`Failed to download format from ${url}: ${res.status} ${res.statusText}`);
4808
- const text = await res.text();
4809
- const data = parseFormatJSON(text, "direct-url");
4810
- if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
4811
- const id = makeFormatId(data.name, data.version);
4812
- const cacheDir = getCacheDir();
4813
- const formatDir = join(cacheDir, data.name, data.version);
4814
- mkdirSync(formatDir, { recursive: true });
4815
- const formatPath = join(formatDir, "format.js");
4816
- writeFileSync(formatPath, text, "utf-8");
4817
- return {
4818
- id,
4819
- filename: formatPath,
4820
- isTwine2: true,
4821
- name: data.name,
4822
- version: data.version,
4823
- proofing: data.proofing ?? false
4824
- };
4825
5223
  }
4826
5224
  /**
4827
- * Try to resolve a remote story format by name and version.
4828
- * 1. Try direct format URLs
4829
- * 2. Try custom index URLs
4830
- * 3. Try default SFA indices
5225
+ * Check that `startName` names a passage the `target` output emits.
5226
+ * Returns the error diagnostics to report (empty when the start is usable).
4831
5227
  */
4832
- async function resolveRemoteFormat(name, version, indices, urls) {
4833
- let lastError;
4834
- if (urls) for (const url of urls) try {
4835
- const info = await fetchDirectFormat(url);
4836
- if (info.name.toLowerCase() === name.toLowerCase()) {
4837
- const wanted = parseSemver(version);
4838
- const have = parseSemver(info.version);
4839
- if (!wanted || have && have[0] === wanted[0] && semverCompare(have, wanted) >= 0) return info;
4840
- }
4841
- } catch (e) {
4842
- lastError = e instanceof Error ? e : new Error(String(e));
4843
- }
4844
- const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
4845
- for (const indexUrl of allIndices) try {
4846
- const result = findEntry(await fetchIndex(indexUrl), name, version);
4847
- if (result) {
4848
- const cached = getCachedFormat(result.entry.name, result.entry.version);
4849
- if (cached) return cached;
4850
- const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
4851
- return await fetchAndCacheFormat(result.entry, downloadUrl);
4852
- }
4853
- } catch (e) {
4854
- lastError = e instanceof Error ? e : new Error(String(e));
4855
- }
4856
- if (lastError) throw lastError;
4857
- }
4858
- /** Check if a format is already in the local cache. */
4859
- function getCachedFormat(name, version) {
4860
- const formatPath = join(getCacheDir(), name, version, "format.js");
4861
- try {
4862
- if (!existsSync(formatPath)) return void 0;
4863
- const source = readFileSync(formatPath, "utf-8");
4864
- const id = makeFormatId(name, version);
4865
- const data = parseFormatJSON(source, id);
4866
- if (!data) return void 0;
4867
- return {
4868
- id,
4869
- filename: formatPath,
4870
- isTwine2: true,
4871
- name: data.name,
4872
- version: data.version,
4873
- proofing: data.proofing ?? false
4874
- };
4875
- } catch {
4876
- return;
4877
- }
5228
+ function startPassageDiagnostics(story, startName, target) {
5229
+ const passage = story.passages.find((p) => p.name === startName);
5230
+ if (passage === void 0) return [{
5231
+ level: "error",
5232
+ message: `Starting passage "${startName}" not found.`
5233
+ }];
5234
+ const omission = omissionFor(story, passage, target);
5235
+ if (omission === void 0) return [];
5236
+ return [{
5237
+ level: "error",
5238
+ message: `Starting passage "${startName}" ${describeOmission(omission)}, so it is left out of the story data. Choose a story passage.`
5239
+ }];
4878
5240
  }
4879
5241
  //#endregion
4880
5242
  //#region src/compiler.ts
@@ -4902,7 +5264,11 @@ async function compile(options) {
4902
5264
  async function compileIncremental(options, cache, changedFiles) {
4903
5265
  return buildOutput(options, cache, changedFiles);
4904
5266
  }
4905
- async function buildOutput(options, cache, changedFiles) {
5267
+ /**
5268
+ * `outFile`: the file the build is written to, which source and module discovery
5269
+ * skip (as Tweego does), so an output inside a source folder is never loaded back.
5270
+ */
5271
+ async function buildOutput(options, cache, changedFiles, outFile) {
4906
5272
  const diagnostics = [];
4907
5273
  const outputMode = options.outputMode ?? "html";
4908
5274
  const trim = options.trim ?? true;
@@ -4915,7 +5281,7 @@ async function buildOutput(options, cache, changedFiles) {
4915
5281
  const inlineSources = [];
4916
5282
  for (const source of options.sources) if (typeof source === "string") filePaths.push(source);
4917
5283
  else inlineSources.push(source);
4918
- const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths);
5284
+ const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths, outFile, options.exclude);
4919
5285
  diagnostics.push(...sourcePathDiagnostics);
4920
5286
  const story = createStory();
4921
5287
  const processedFiles = /* @__PURE__ */ new Set();
@@ -4933,33 +5299,12 @@ async function buildOutput(options, cache, changedFiles) {
4933
5299
  }, diagnostics);
4934
5300
  if (options.tagAliases) story.passages = applyTagAliases(story.passages, options.tagAliases);
4935
5301
  let format;
4936
- let formatId = options.formatId ?? "";
4937
- if (outputMode === "html") {
4938
- const formats = discoverFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true));
4939
- if (!formatId && story.twine2.format) formatId = getFormatIdByNameAndVersion(formats, story.twine2.format, story.twine2.formatVersion) ?? "";
4940
- if (!formatId) formatId = DEFAULT_FORMAT_ID;
4941
- format = formats.get(formatId);
4942
- if (!format && !noRemote) {
4943
- const remoteName = story.twine2.format || formatId;
4944
- const remoteVersion = story.twine2.formatVersion || "";
4945
- try {
4946
- format = await resolveRemoteFormat(remoteName, remoteVersion, options.formatIndices, options.formatUrls);
4947
- } catch (e) {
4948
- diagnostics.push({
4949
- level: "warning",
4950
- message: `Remote format fetch failed for "${remoteName}": ${e instanceof Error ? e.message : String(e)}`
4951
- });
4952
- }
4953
- }
4954
- if (!format) {
4955
- const reason = noRemote ? " (remote fetching disabled)" : "";
4956
- diagnostics.push({
4957
- level: "error",
4958
- message: `Story format "${formatId}" is not available${reason}. Found: ${[...formats.keys()].join(", ") || "none"}`
4959
- });
4960
- }
4961
- }
4962
- const startName = options.startPassage || story.twine2.start || DEFAULT_START_NAME;
5302
+ if (outputMode === "html") format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
5303
+ ...options,
5304
+ noRemote
5305
+ }, diagnostics);
5306
+ if (options.startPassage) story.twine2.start = options.startPassage;
5307
+ const startName = story.twine2.start || DEFAULT_START_NAME;
4963
5308
  if (testMode) story.twine2.options.set("debug", true);
4964
5309
  ensureIFID(story, diagnostics);
4965
5310
  let output;
@@ -4978,10 +5323,7 @@ async function buildOutput(options, cache, changedFiles) {
4978
5323
  output = storyToJSON(story);
4979
5324
  break;
4980
5325
  case "html": {
4981
- if (!storyHas(story, startName)) diagnostics.push({
4982
- level: "error",
4983
- message: `Starting passage "${startName}" not found.`
4984
- });
5326
+ diagnostics.push(...startPassageDiagnostics(story, startName, format?.isTwine2 === false ? "twine1" : "twine2"));
4985
5327
  if (!format) throw new TweeTsError("No story format available for HTML output.", diagnostics);
4986
5328
  if (format.isTwine2) output = toTwine2HTML(story, format, startName, { sourceInfo });
4987
5329
  else {
@@ -4991,7 +5333,7 @@ async function buildOutput(options, cache, changedFiles) {
4991
5333
  });
4992
5334
  output = toTwine1HTML(story, format, startName);
4993
5335
  }
4994
- const modules = getFilenames(options.modules ?? []);
5336
+ const modules = getFilenames(options.modules ?? [], outFile);
4995
5337
  diagnostics.push(...modules.diagnostics);
4996
5338
  output = modifyHead(output, modules.filenames, options.headFile, diagnostics);
4997
5339
  break;
@@ -5069,6 +5411,6 @@ function storyToJSON(story) {
5069
5411
  return JSON.stringify(obj, null, 2);
5070
5412
  }
5071
5413
  //#endregion
5072
- export { mediaTypeFromFilename as a, getFilenames as i, compile as n, compileIncremental as r, TweeTsError as t };
5414
+ export { isExcluded as a, getFilenames as i, compile as n, mediaTypeFromFilename as o, compileIncremental as r, TweeTsError as t };
5073
5415
 
5074
- //# sourceMappingURL=compiler-D3V_svy2.js.map
5416
+ //# sourceMappingURL=compiler-Bmpbelqj.js.map