@rohal12/twee-ts 1.18.0 → 1.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1600,10 +1600,9 @@ function readUTF8(filename, diagnostics) {
1600
1600
  function readBase64(filename) {
1601
1601
  return (0, node_fs.readFileSync)(filename).toString("base64");
1602
1602
  }
1603
- /** Get the filename without extension, falling back to the full basename for dotfiles. */
1603
+ /** Get the filename without extension, a dotfile keeps its whole name. */
1604
1604
  function baseNameWithoutExt(filename) {
1605
- const { name, ext } = (0, node_path.parse)(filename);
1606
- return name || ext;
1605
+ return (0, node_path.parse)(filename).name;
1607
1606
  }
1608
1607
  //#endregion
1609
1608
  //#region src/formats.ts
@@ -1623,6 +1622,89 @@ function stripSetupFunction(chunk) {
1623
1622
  for (const match of chunk.matchAll(SETUP_FUNCTION_PROPERTY)) lastIndex = match.index;
1624
1623
  return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
1625
1624
  }
1625
+ /** Index after the whitespace and `//` or block comments starting at `i`. */
1626
+ function skipTrivia(text, i) {
1627
+ let pos = i;
1628
+ for (;;) {
1629
+ const ch = text[pos];
1630
+ if (ch !== void 0 && /\s/.test(ch)) pos++;
1631
+ else if (text.startsWith("//", pos)) {
1632
+ const newline = text.indexOf("\n", pos);
1633
+ pos = newline === -1 ? text.length : newline + 1;
1634
+ } else if (text.startsWith("/*", pos)) {
1635
+ const close = text.indexOf("*/", pos + 2);
1636
+ pos = close === -1 ? text.length : close + 2;
1637
+ } else return pos;
1638
+ }
1639
+ }
1640
+ /** Index after the quoted string starting at `i` (a quote character); template literals nest `${…}`. */
1641
+ function skipString(text, i) {
1642
+ const quote = text[i];
1643
+ let pos = i + 1;
1644
+ while (pos < text.length) {
1645
+ const ch = text[pos];
1646
+ if (ch === "\\") pos += 2;
1647
+ else if (ch === quote) return pos + 1;
1648
+ else if (quote === "`" && ch === "$" && text[pos + 1] === "{") {
1649
+ const close = findClosingBrace(text, pos + 1);
1650
+ if (close === -1) return text.length;
1651
+ pos = close + 1;
1652
+ } else pos++;
1653
+ }
1654
+ return text.length;
1655
+ }
1656
+ function startsComment(text, pos) {
1657
+ return text[pos] === "/" && (text[pos + 1] === "/" || text[pos + 1] === "*");
1658
+ }
1659
+ function startsString(text, pos) {
1660
+ const ch = text[pos];
1661
+ return ch === "\"" || ch === "'" || ch === "`";
1662
+ }
1663
+ /** Index of the `}` matching the `{` at `open`, skipping comments and strings; -1 when unbalanced. */
1664
+ function findClosingBrace(text, open) {
1665
+ let depth = 0;
1666
+ let pos = open;
1667
+ while (pos < text.length) if (startsComment(text, pos)) pos = skipTrivia(text, pos);
1668
+ else if (startsString(text, pos)) pos = skipString(text, pos);
1669
+ else {
1670
+ if (text[pos] === "{") depth++;
1671
+ else if (text[pos] === "}" && --depth === 0) return pos;
1672
+ pos++;
1673
+ }
1674
+ return -1;
1675
+ }
1676
+ const STORY_FORMAT = "storyFormat";
1677
+ const IDENTIFIER_CHAR = /[\w$]/;
1678
+ /**
1679
+ * Locate the object literal passed to `storyFormat(` as `[start, end)` offsets, ignoring comments and
1680
+ * strings outside it and never evaluating anything. Without such a call, the first `{` outside comments
1681
+ * and strings starts the object. When its closing brace cannot be matched (a Harlowe setup function can
1682
+ * hold regular expression literals), the last `}` of the file ends it.
1683
+ */
1684
+ function locateFormatObject(text) {
1685
+ let start = -1;
1686
+ let firstBrace = -1;
1687
+ let pos = 0;
1688
+ while (pos < text.length && start === -1) if (startsComment(text, pos)) pos = skipTrivia(text, pos);
1689
+ else if (startsString(text, pos)) pos = skipString(text, pos);
1690
+ else if (text[pos] === "{") {
1691
+ if (firstBrace === -1) firstBrace = pos;
1692
+ pos++;
1693
+ } else if (text.startsWith(STORY_FORMAT, pos) && !IDENTIFIER_CHAR.test(text[pos - 1] ?? "")) {
1694
+ const paren = skipTrivia(text, pos + 11);
1695
+ const brace = text[paren] === "(" ? skipTrivia(text, paren + 1) : -1;
1696
+ if (text[brace] === "{") start = brace;
1697
+ pos += 11;
1698
+ } else pos++;
1699
+ if (start === -1) start = firstBrace;
1700
+ if (start === -1) return void 0;
1701
+ const close = findClosingBrace(text, start);
1702
+ const end = close === -1 ? text.lastIndexOf("}") : close;
1703
+ return end < start ? void 0 : {
1704
+ start,
1705
+ end: end + 1
1706
+ };
1707
+ }
1626
1708
  /**
1627
1709
  * Parse the format object at `start`–`end` of `text`: strict JSON first (fast), then the
1628
1710
  * JavaScript literal subset formats use. Error positions point into `text`.
@@ -1647,6 +1729,8 @@ function parseFormatObject(text, start, end) {
1647
1729
  }
1648
1730
  }
1649
1731
  /** Check a parsed format object's fields. Per spec, `name` is optional; `version` and `source` are required. */
1732
+ /** The name given to a format.js that names no format. */
1733
+ const UNNAMED_FORMAT_NAME = "Untitled Story Format";
1650
1734
  function toFormatJSON(raw) {
1651
1735
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
1652
1736
  ok: false,
@@ -1666,7 +1750,7 @@ function toFormatJSON(raw) {
1666
1750
  reason: `Story format version ${JSON.stringify(obj.version)} is not a SemVer version.`
1667
1751
  };
1668
1752
  const data = {
1669
- name: typeof obj.name === "string" ? obj.name : "Untitled Story Format",
1753
+ name: typeof obj.name === "string" ? obj.name : UNNAMED_FORMAT_NAME,
1670
1754
  version: obj.version,
1671
1755
  source: obj.source,
1672
1756
  proofing: obj.proofing === true
@@ -1691,15 +1775,14 @@ function toFormatJSON(raw) {
1691
1775
  * `harlowe-3` directory or a direct download.
1692
1776
  */
1693
1777
  function decodeFormatJSON(source) {
1694
- const first = source.indexOf("{");
1695
- const last = source.lastIndexOf("}");
1696
- if (first === -1 || last < first) return {
1778
+ const located = locateFormatObject(source);
1779
+ if (located === void 0) return {
1697
1780
  ok: false,
1698
1781
  reason: "Could not find Twine 2 style story format JSON chunk."
1699
1782
  };
1700
- const parsed = parseFormatObject(source, first, last + 1);
1783
+ const parsed = parseFormatObject(source, located.start, located.end);
1701
1784
  if (parsed.ok) return toFormatJSON(parsed.value);
1702
- const stripped = stripSetupFunction(source.slice(first, last + 1));
1785
+ const stripped = stripSetupFunction(source.slice(located.start, located.end));
1703
1786
  const retried = stripped === void 0 ? void 0 : parseFormatObject(stripped, 0, stripped.length);
1704
1787
  if (retried?.ok) return toFormatJSON(retried.value);
1705
1788
  return {
@@ -2003,16 +2086,17 @@ function readFormatSource(format, diagnostics) {
2003
2086
  /**
2004
2087
  * Writes `data` to `path` so that a reader sees either the previous file or the complete new
2005
2088
  * one, never a partly written file. The data goes to a temporary file in the same folder,
2006
- * which is then renamed over `path`. When `path` is a symlink, the file it points to is
2007
- * replaced, and an existing file keeps its permissions.
2089
+ * which is then renamed over `path`. When `path` is a symlink (even a dangling one, or a chain of links), the file it
2090
+ * finally points to is replaced or created and the link stays, and an existing file keeps its permissions.
2008
2091
  *
2009
2092
  * On failure (a full disk, a missing folder) the temporary file is removed, `path` is left
2010
2093
  * as it was, and the error is thrown with `path` in its message and its original `code`.
2011
2094
  */
2012
2095
  function writeFileAtomic(path, data) {
2013
- const target = followSymlink(path);
2014
- const temp = (0, node_path.join)((0, node_path.dirname)(target), `.${(0, node_path.basename)(target)}.${process.pid}-${(0, node_crypto.randomBytes)(6).toString("hex")}.tmp`);
2096
+ let temp;
2015
2097
  try {
2098
+ const target = resolveWriteTarget(path);
2099
+ temp = (0, node_path.join)((0, node_path.dirname)(target), `.${(0, node_path.basename)(target)}.${process.pid}-${(0, node_crypto.randomBytes)(6).toString("hex")}.tmp`);
2016
2100
  (0, node_fs.writeFileSync)(temp, data, {
2017
2101
  encoding: "utf-8",
2018
2102
  flag: "wx"
@@ -2020,17 +2104,25 @@ function writeFileAtomic(path, data) {
2020
2104
  keepPermissions(target, temp);
2021
2105
  (0, node_fs.renameSync)(temp, target);
2022
2106
  } catch (e) {
2023
- (0, node_fs.rmSync)(temp, { force: true });
2107
+ if (temp !== void 0) (0, node_fs.rmSync)(temp, { force: true });
2024
2108
  throw writeError(path, e);
2025
2109
  }
2026
2110
  }
2027
- /** The file a symlink at `path` points to, or `path` itself (also when it doesn't exist yet). */
2028
- function followSymlink(path) {
2029
- try {
2030
- return (0, node_fs.realpathSync)(path);
2031
- } catch {
2032
- return path;
2111
+ /** Links followed before a path counts as a cycle (Linux stops at 40 as well). */
2112
+ const MAX_LINK_DEPTH = 40;
2113
+ /**
2114
+ * The file a symlink at `path` finally points to, or `path` itself when it is no link. Links are
2115
+ * followed one by one, so a link whose target does not exist yet (absolute, relative, or a chain
2116
+ * of them) still resolves to the file that should be created. A cycle or an unreadable link throws.
2117
+ */
2118
+ function resolveWriteTarget(path) {
2119
+ let current = path;
2120
+ for (let depth = 0; depth <= MAX_LINK_DEPTH; depth++) {
2121
+ const stat = (0, node_fs.lstatSync)(current, { throwIfNoEntry: false });
2122
+ if (stat === void 0 || !stat.isSymbolicLink()) return current;
2123
+ current = (0, node_path.resolve)((0, node_path.dirname)(current), (0, node_fs.readlinkSync)(current));
2033
2124
  }
2125
+ throw Object.assign(/* @__PURE__ */ new Error(`ELOOP: too many levels of symbolic links, ${path}`), { code: "ELOOP" });
2034
2126
  }
2035
2127
  /**
2036
2128
  * Gives `temp` the permission bits of `target`, when `target` exists. Best effort: on a file
@@ -2142,31 +2234,37 @@ function clearIndexCache() {
2142
2234
  /** Requests in progress, by what they fetch, so concurrent compiles make each request once. */
2143
2235
  const sharedRequests = /* @__PURE__ */ new Map();
2144
2236
  /**
2145
- * Runs `start` once for all callers that ask for `key` while it is in progress. A caller whose
2146
- * `signal` aborts stops waiting at once and rejects with the signal's reason; the request
2147
- * itself is aborted (through the signal `start` receives) once no caller waits on it.
2237
+ * Runs `start` once for all callers that ask for `key` while it is in progress. Each caller waits
2238
+ * under its own limits: a caller whose `signal` aborts, or whose `timeout` passes, stops waiting
2239
+ * at once and rejects, while the others keep waiting. The request itself is aborted (through
2240
+ * the signal `start` receives) once no caller waits on it. `start` must not depend on any one
2241
+ * caller: what differs between callers belongs after the shared result.
2148
2242
  */
2149
- function shareRequest(key, signal, start) {
2243
+ function shareRequest(key, { signal, timeout, timedOut }, start) {
2150
2244
  if (signal?.aborted) return Promise.reject(signal.reason);
2151
2245
  const request = sharedRequests.get(key) ?? startSharedRequest(key, start);
2152
2246
  request.waiters++;
2153
2247
  return new Promise((resolve, reject) => {
2154
2248
  let waiting = true;
2249
+ let timer;
2155
2250
  const stopWaiting = () => {
2156
2251
  if (!waiting) return;
2157
2252
  waiting = false;
2158
2253
  signal?.removeEventListener("abort", onAbort);
2254
+ clearTimeout(timer);
2159
2255
  request.waiters--;
2160
2256
  };
2161
- const onAbort = () => {
2257
+ const giveUp = (reason) => {
2162
2258
  stopWaiting();
2163
2259
  if (request.waiters === 0) {
2164
2260
  if (sharedRequests.get(key) === request) sharedRequests.delete(key);
2165
- request.controller.abort(signal?.reason);
2261
+ request.controller.abort(reason);
2166
2262
  }
2167
- reject(signal?.reason);
2263
+ reject(reason);
2168
2264
  };
2265
+ const onAbort = () => giveUp(signal?.reason);
2169
2266
  signal?.addEventListener("abort", onAbort, { once: true });
2267
+ if (timeout > 0 && timeout <= MAX_TIMER_DELAY) timer = setTimeout(() => giveUp(timedOut()), timeout);
2170
2268
  request.promise.then((value) => {
2171
2269
  stopWaiting();
2172
2270
  resolve(value);
@@ -2196,20 +2294,32 @@ function requestTimeout(options) {
2196
2294
  if (!(timeout >= 0)) throw new RangeError(`A story format request timeout must be 0 or more milliseconds, not ${timeout}`);
2197
2295
  return timeout;
2198
2296
  }
2297
+ /** How one caller waits for `url`: under its own signal and timeout. `what` names the request in errors. */
2298
+ function waitOptions(options, what, url) {
2299
+ const timeout = requestTimeout(options);
2300
+ return {
2301
+ signal: options.signal,
2302
+ timeout,
2303
+ timedOut: () => /* @__PURE__ */ new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`)
2304
+ };
2305
+ }
2199
2306
  /**
2200
- * Fetch `url` as text, aborting when `signal` does or after `timeout` milliseconds (0: no limit).
2201
- * `what` names the request in errors, as in "Failed to <what> from <url>".
2307
+ * Fetch `url` as bytes, aborting when `signal` does. `what` names the request in errors, as in
2308
+ * "Failed to <what> from <url>". The bytes are returned undecoded, so checksums cover what was served.
2202
2309
  */
2203
- async function fetchText(url, what, signal, timeout) {
2204
- const limit = timeout > 0 && timeout <= MAX_TIMER_DELAY ? AbortSignal.timeout(timeout) : void 0;
2205
- try {
2206
- const res = await fetch(url, { signal: limit ? AbortSignal.any([signal, limit]) : signal });
2207
- if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
2208
- return await res.text();
2209
- } catch (e) {
2210
- if (limit?.aborted && !signal.aborted) throw new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`, { cause: e });
2211
- throw e;
2212
- }
2310
+ async function fetchBytes(url, what, signal) {
2311
+ const res = await fetch(url, { signal });
2312
+ if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
2313
+ return new Uint8Array(await res.arrayBuffer());
2314
+ }
2315
+ /** Download a format.js once for every caller in this process that asks for `url` meanwhile. */
2316
+ function shareDownload(url, options) {
2317
+ return shareRequest(`download\0${url}`, waitOptions(options, "download format", url), (signal) => fetchBytes(url, "download format", signal));
2318
+ }
2319
+ /** Decode a downloaded format.js as local ones are (UTF-8, else Windows-1252), without a leading BOM. */
2320
+ function decodeDownload(bytes, url) {
2321
+ const { text } = decodeText(bytes, url);
2322
+ return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
2213
2323
  }
2214
2324
  /** Validate that a JSON value is a valid SFAIndexEntry. */
2215
2325
  function isValidEntry(val) {
@@ -2231,9 +2341,8 @@ async function fetchIndex(url, options = {}) {
2231
2341
  options.signal?.throwIfAborted();
2232
2342
  const cached = indexCache.get(url);
2233
2343
  if (cached) return cached;
2234
- const timeout = requestTimeout(options);
2235
- return shareRequest(`index\0${url}`, options.signal, async (signal) => {
2236
- const text = await fetchText(url, "fetch format index", signal, timeout);
2344
+ return shareRequest(`index\0${url}`, waitOptions(options, "fetch format index", url), async (signal) => {
2345
+ const text = decodeDownload(await fetchBytes(url, "fetch format index", signal), url);
2237
2346
  const data = validateSFAIndex(JSON.parse(text));
2238
2347
  indexCache.set(url, data);
2239
2348
  return data;
@@ -2270,26 +2379,37 @@ function cachedFormatInfo(id, filename, data) {
2270
2379
  };
2271
2380
  }
2272
2381
  /**
2273
- * Download a format, verify its checksum, write it to the cache shared by name and version, and
2274
- * return its StoryFormatInfo. Concurrent calls for one download URL share one request.
2382
+ * Check that a downloaded format is the one the index entry promised. The name matches without
2383
+ * regard to case, as format requests do, and the version by SemVer precedence, so "1.0" and "v1.0.0"
2384
+ * agree. A format.js that names no format ({@link UNNAMED_FORMAT_NAME}) is allowed: such formats
2385
+ * exist, and the index entry then supplies the name it is cached under.
2386
+ */
2387
+ function checkFormatIdentity(entry, data, downloadUrl) {
2388
+ if ((data.name === "Untitled Story Format" || data.name.toLowerCase() === entry.name.toLowerCase()) && sameVersion(data.version, entry.version)) return;
2389
+ throw new Error(`Story format mismatch for ${downloadUrl}: the index lists ${entry.name} ${entry.version}, but the download is ${data.name} ${data.version}`);
2390
+ }
2391
+ /**
2392
+ * Download a format, verify its checksum, check that it is the format the entry names, write it to
2393
+ * the cache shared by name and version, and return its StoryFormatInfo. Concurrent calls for one
2394
+ * download URL share one request for the bytes; each call then verifies them against its own
2395
+ * entry, within its own timeout.
2275
2396
  */
2276
2397
  async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
2277
2398
  cachedFormatDir(entry.name, entry.version);
2278
- const timeout = requestTimeout(options);
2279
- return shareRequest(`format\0${getCacheDir()}\0${downloadUrl}`, options.signal, async (signal) => {
2280
- const text = await fetchText(downloadUrl, "download format", signal, timeout);
2281
- const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
2282
- if (checksumKey) {
2283
- const expected = entry.checksums[checksumKey];
2284
- if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
2285
- if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
2286
- }
2287
- const id = makeFormatId(entry.name, entry.version);
2288
- const data = parseFormatJSON(text, id);
2289
- if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
2290
- signal.throwIfAborted();
2291
- return cachedFormatInfo(id, writeCacheEntry((0, node_path.resolve)(getCacheDir()), [entry.name, entry.version], text), data);
2292
- });
2399
+ const bytes = await shareDownload(downloadUrl, options);
2400
+ const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
2401
+ if (checksumKey) {
2402
+ const expected = entry.checksums[checksumKey];
2403
+ if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
2404
+ if (!await verifySHA256(bytes, expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
2405
+ }
2406
+ const text = decodeDownload(bytes, downloadUrl);
2407
+ const id = makeFormatId(entry.name, entry.version);
2408
+ const data = parseFormatJSON(text, id);
2409
+ if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
2410
+ checkFormatIdentity(entry, data, downloadUrl);
2411
+ options.signal?.throwIfAborted();
2412
+ return cachedFormatInfo(id, writeCacheEntry((0, node_path.resolve)(getCacheDir()), [entry.name, entry.version], text), data);
2293
2413
  }
2294
2414
  /**
2295
2415
  * Download a direct format.js URL, parse its JSON, cache it under that URL (apart from the
@@ -2297,16 +2417,13 @@ async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
2297
2417
  * one URL share one request.
2298
2418
  */
2299
2419
  async function fetchDirectFormat(url, options = {}) {
2300
- const timeout = requestTimeout(options);
2301
- return shareRequest(`url\0${getUrlCacheDir()}\0${url}`, options.signal, async (signal) => {
2302
- const text = await fetchText(url, "download format", signal, timeout);
2303
- const data = parseFormatJSON(text);
2304
- if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
2305
- cachedFormatDir(data.name, data.version);
2306
- signal.throwIfAborted();
2307
- const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
2308
- return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
2309
- });
2420
+ const text = decodeDownload(await shareDownload(url, options), url);
2421
+ const data = parseFormatJSON(text);
2422
+ if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
2423
+ cachedFormatDir(data.name, data.version);
2424
+ options.signal?.throwIfAborted();
2425
+ const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
2426
+ return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
2310
2427
  }
2311
2428
  /** The copy of a direct format URL in the download cache, if it has been downloaded before. */
2312
2429
  function getCachedDirectFormat(url) {
@@ -2347,6 +2464,16 @@ async function resolveFormatUrls(request, urls, options = {}) {
2347
2464
  }
2348
2465
  if (lastError) throw lastError;
2349
2466
  }
2467
+ /**
2468
+ * Find the best format among the cached copies of direct format URLs, without network access.
2469
+ * Matching follows {@link selectFormatCandidate}, so `allowOlder` admits a same-major older version.
2470
+ * Each URL keeps its own copy; the shared name and version downloads are never consulted.
2471
+ *
2472
+ * Internal, for format resolution; not part of the public API.
2473
+ */
2474
+ function findCachedUrlFormat(request, urls, options = {}) {
2475
+ return selectFormatCandidate(request, urls.flatMap((url) => getCachedDirectFormat(url) ?? []), (f) => f, options);
2476
+ }
2350
2477
  /** Whether `info` is a format the request accepts. */
2351
2478
  function answers(request, info) {
2352
2479
  return selectFormatCandidate(request, [info], (f) => f) !== void 0;
@@ -2599,8 +2726,8 @@ function findLocalFormat(all, pruned, request) {
2599
2726
  * then format indices (unless `noRemote`). Names and IDs match the same way in each (see
2600
2727
  * `selectFormatCandidate`), so a request that a local format answers never reaches the cache or the
2601
2728
  * network. Local formats that cannot be used are reported as warnings. When a StoryData request
2602
- * names a version that none of them has, an older version of
2603
- * the same format and major version is used with a warning. Anything else is an error diagnostic,
2729
+ * names a version that none of them has, an older version of the same format and major version
2730
+ * is used with a warning, from local formats, the project's cached format URLs, or the shared cache. Anything else is an error diagnostic,
2604
2731
  * and the result is `undefined`.
2605
2732
  */
2606
2733
  async function resolveStoryFormat(request, options, diagnostics) {
@@ -2638,7 +2765,7 @@ async function resolveStoryFormat(request, options, diagnostics) {
2638
2765
  if (remote) return remote;
2639
2766
  }
2640
2767
  if (request.kind === "name") {
2641
- const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
2768
+ const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedUrlFormat(request, options.formatUrls ?? [], { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
2642
2769
  if (older) {
2643
2770
  diagnostics.push({
2644
2771
  level: "warning",
@@ -2887,8 +3014,6 @@ function* tweeLexer(input) {
2887
3014
  const items = ctx.drainItems();
2888
3015
  for (const item of items) yield item;
2889
3016
  }
2890
- const remaining = ctx.drainItems();
2891
- for (const item of remaining) yield item;
2892
3017
  }
2893
3018
  /** Convenience class wrapper for the lexer generator. */
2894
3019
  var TweeLexer = class {
@@ -3041,9 +3166,7 @@ function parseTwee(source, options = {}) {
3041
3166
  };
3042
3167
  }
3043
3168
  try {
3044
- const raw = JSON.parse(item.val);
3045
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error("expected a JSON object");
3046
- const parsed = raw;
3169
+ const parsed = JSON.parse(item.val);
3047
3170
  const meta = {};
3048
3171
  for (const [key, value] of Object.entries(parsed)) if (typeof value === "string") meta[key] = value;
3049
3172
  current.metadata = meta;
@@ -6297,8 +6420,8 @@ function loadFont(story, filename, diagnostics) {
6297
6420
  * Load sources with mtime-based caching for incremental rebuilds.
6298
6421
  * Always creates a fresh Story; cached passages are replayed via storyAdd().
6299
6422
  */
6300
- function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
6301
- const currentFiles = /* @__PURE__ */ new Set();
6423
+ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles, buildFiles) {
6424
+ const currentFiles = new Set(buildFiles);
6302
6425
  const changedPaths = changedFiles === void 0 ? void 0 : new Set([...changedFiles].map((f) => (0, node_path.resolve)(f)));
6303
6426
  for (const filename of filenames) {
6304
6427
  if (processedFiles.has(filename)) {
@@ -6363,16 +6486,6 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
6363
6486
  }
6364
6487
  //#endregion
6365
6488
  //#region src/template.ts
6366
- /**
6367
- * An HTML closing head tag: `</head` in any letter case, followed by whitespace, `/` or `>`, up to the next `>`.
6368
- * This is how the HTML tokenizer reads an end tag; it ignores whatever sits between the tag name and the `>`.
6369
- */
6370
- const CLOSING_HEAD_TAG = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>";
6371
- /**
6372
- * The start of an HTML body start tag: `<body` in any letter case, followed by whitespace, `/` or `>` (so not
6373
- * `<bodyx>`). Only the `<body` is matched; the attributes and `>` after it are left alone.
6374
- */
6375
- const BODY_START_TAG = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
6376
6489
  /** The regular expression source matching exactly `text`. */
6377
6490
  function literal(text) {
6378
6491
  return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
@@ -6398,6 +6511,8 @@ function fillTemplateParts(parts) {
6398
6511
  return part.text.replace(pattern, (match, ...groups) => {
6399
6512
  const slot = slots.find((_, i) => groups[i] !== void 0);
6400
6513
  if (slot === void 0 || slot.occurrences === "first" && filled.has(slot)) return match;
6514
+ const offset = groups[slots.length];
6515
+ if (slot.accepts !== void 0 && typeof offset === "number" && !slot.accepts(part.text, offset)) return match;
6401
6516
  filled.add(slot);
6402
6517
  return slot.replacement(match);
6403
6518
  });
@@ -6488,15 +6603,18 @@ function placeHead(content, texts, owner) {
6488
6603
  slot: void 0,
6489
6604
  diagnostics: []
6490
6605
  };
6491
- const found = (tag) => texts.some((text) => new RegExp(tag).test(text));
6492
- if (found("<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>")) return {
6493
- slot: beforeTag(CLOSING_HEAD_TAG, content),
6606
+ const scans = texts.map((text) => ({
6607
+ text,
6608
+ tags: scanHeadTags(text)
6609
+ }));
6610
+ if (scans.some(({ tags }) => tags.closingHead !== void 0)) return {
6611
+ slot: beforeTag(CLOSING_HEAD_PREFIX, "closingHead", content),
6494
6612
  diagnostics: []
6495
6613
  };
6496
- if (found("<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])")) {
6614
+ if (scans.some(({ tags }) => tags.bodyStart !== void 0)) {
6497
6615
  const message = `${owner} has no closing head tag; the modules and head file were injected before its body start tag.`;
6498
6616
  return {
6499
- slot: beforeTag(BODY_START_TAG, content),
6617
+ slot: beforeTag(BODY_START_PREFIX, "bodyStart", content),
6500
6618
  diagnostics: [{
6501
6619
  level: "warning",
6502
6620
  message
@@ -6511,12 +6629,108 @@ function placeHead(content, texts, owner) {
6511
6629
  }]
6512
6630
  };
6513
6631
  }
6514
- /** The slot that inserts `content` on its own line before the first match of `tag`, which is kept as written. */
6515
- function beforeTag(tag, content) {
6632
+ /** `</head` in any letter case, followed by whitespace, `/` or `>`; the rest of the tag is left alone. */
6633
+ const CLOSING_HEAD_PREFIX = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])";
6634
+ /** `<body` in any letter case, followed by whitespace, `/` or `>` (so not `<bodyx>`). */
6635
+ const BODY_START_PREFIX = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
6636
+ /** Elements whose content is text, so a tag written there is not a tag. */
6637
+ const RAW_TEXT_ELEMENTS = /* @__PURE__ */ new Set([
6638
+ "script",
6639
+ "style",
6640
+ "textarea",
6641
+ "title"
6642
+ ]);
6643
+ const isSpace = (c) => c === " " || c === " " || c === "\n" || c === "\f" || c === "\r";
6644
+ const isLetter = (c) => c !== void 0 && /^[A-Za-z]$/.test(c);
6645
+ /**
6646
+ * Find the first closing head tag and body start tag that HTML would read as tags: not those in a comment, in
6647
+ * the text of a script, style, textarea or title element, or in a quoted attribute value. A small scanner, not
6648
+ * a full HTML tokenizer; it needs no more than the tags and where they end.
6649
+ */
6650
+ function scanHeadTags(html) {
6651
+ const { closingHead, bodyStart } = scanTags$1(html);
6652
+ return {
6653
+ closingHead,
6654
+ bodyStart
6655
+ };
6656
+ }
6657
+ function scanTags$1(html) {
6658
+ let closingHead;
6659
+ let bodyStart;
6660
+ let headStartEnd;
6661
+ let i = 0;
6662
+ while (i < html.length) {
6663
+ const lt = html.indexOf("<", i);
6664
+ if (lt === -1) break;
6665
+ const next = html[lt + 1];
6666
+ if (html.startsWith("<!--", lt)) {
6667
+ const end = html.indexOf("-->", lt + 4);
6668
+ i = end === -1 ? html.length : end + 3;
6669
+ } else if (next === "!" || next === "?") {
6670
+ const end = html.indexOf(">", lt);
6671
+ i = end === -1 ? html.length : end + 1;
6672
+ } else if (next === "/" && isLetter(html[lt + 2])) {
6673
+ const { name, end } = readTag(html, lt + 2);
6674
+ if (name === "head" && closingHead === void 0) closingHead = lt;
6675
+ i = end;
6676
+ } else if (isLetter(next)) {
6677
+ const { name, end } = readTag(html, lt + 1);
6678
+ if (name === "body" && bodyStart === void 0) bodyStart = lt;
6679
+ if (name === "head" && headStartEnd === void 0) headStartEnd = end;
6680
+ i = RAW_TEXT_ELEMENTS.has(name) ? rawTextEnd(html, name, end) : end;
6681
+ } else i = lt + 1;
6682
+ if (closingHead !== void 0 && bodyStart !== void 0) break;
6683
+ }
6684
+ return {
6685
+ closingHead,
6686
+ bodyStart,
6687
+ headStartEnd
6688
+ };
6689
+ }
6690
+ /** Read a tag whose name starts at `from`: its lowercase name, and the offset after its `>` (or the end). */
6691
+ function readTag(html, from) {
6692
+ let i = from;
6693
+ while (i < html.length && !isSpace(html[i]) && html[i] !== "/" && html[i] !== ">") i++;
6694
+ const name = html.slice(from, i).toLowerCase();
6695
+ while (i < html.length && html[i] !== ">") {
6696
+ if (html[i] !== "=") {
6697
+ i++;
6698
+ continue;
6699
+ }
6700
+ i++;
6701
+ while (isSpace(html[i])) i++;
6702
+ const quote = html[i];
6703
+ if (quote === "\"" || quote === "'") {
6704
+ const close = html.indexOf(quote, i + 1);
6705
+ i = close === -1 ? html.length : close + 1;
6706
+ }
6707
+ }
6516
6708
  return {
6517
- pattern: tag,
6709
+ name,
6710
+ end: Math.min(i + 1, html.length)
6711
+ };
6712
+ }
6713
+ /** The offset of the end tag that closes a raw text element, given where its text starts (or the end). */
6714
+ function rawTextEnd(html, name, from) {
6715
+ const close = new RegExp(`</${name}(?=[\\t\\n\\f\\r />])`, "gi");
6716
+ close.lastIndex = from;
6717
+ return close.exec(html)?.index ?? html.length;
6718
+ }
6719
+ /**
6720
+ * The slot that inserts `content` on its own line before the first real tag of the kind `which` names (see
6721
+ * `scanHeadTags()`), which is kept as written. A look-alike inside a script or comment is left alone.
6722
+ */
6723
+ function beforeTag(pattern, which, content) {
6724
+ const cache = /* @__PURE__ */ new Map();
6725
+ const offsetIn = (text) => {
6726
+ if (!cache.has(text)) cache.set(text, scanHeadTags(text)[which]);
6727
+ return cache.get(text);
6728
+ };
6729
+ return {
6730
+ pattern,
6518
6731
  occurrences: "first",
6519
- replacement: (match) => `${content}\n${match}`
6732
+ replacement: (match) => `${content}\n${match}`,
6733
+ accepts: (text, offset) => offsetIn(text) === offset
6520
6734
  };
6521
6735
  }
6522
6736
  //#endregion
@@ -6962,6 +7176,7 @@ async function compile(options) {
6962
7176
  */
6963
7177
  async function compileToFile(options) {
6964
7178
  const result = await compileForOutputFile(options, options.outFile);
7179
+ options.signal?.throwIfAborted();
6965
7180
  writeFileAtomic(options.outFile, result.output);
6966
7181
  return result;
6967
7182
  }
@@ -7123,34 +7338,61 @@ async function buildOutput(options, cache, changedFiles, outputs) {
7123
7338
  const timeout = options.formatFetchTimeout;
7124
7339
  if (timeout !== void 0 && !(timeout >= 0)) throw new TweeTsError(`formatFetchTimeout must be 0 or more milliseconds, not ${timeout}.`);
7125
7340
  clearIndexCache();
7126
- const filePaths = [];
7127
- const inlineSources = [];
7128
- for (const source of options.sources) if (typeof source === "string") filePaths.push(source);
7129
- else inlineSources.push(source);
7130
- const sourcePaths = getFilenames(filePaths, written, options.exclude);
7131
- diagnostics.push(...sourcePaths.diagnostics);
7132
- rejectOutputSources(sourcePaths.outputSources, diagnostics);
7133
- const sourceFilenames = sourcePaths.filenames;
7341
+ const groups = [];
7342
+ const outputSources = [];
7343
+ for (const source of options.sources) {
7344
+ const last = groups[groups.length - 1];
7345
+ if (typeof source === "string") {
7346
+ if (last?.kind === "paths") last.paths.push(source);
7347
+ else groups.push({
7348
+ kind: "paths",
7349
+ paths: [source]
7350
+ });
7351
+ } else if (last?.kind === "inline") last.sources.push(source);
7352
+ else groups.push({
7353
+ kind: "inline",
7354
+ sources: [source]
7355
+ });
7356
+ }
7357
+ const walked = groups.map((group) => {
7358
+ if (group.kind === "inline") return group;
7359
+ const found = getFilenames(group.paths, written, options.exclude);
7360
+ diagnostics.push(...found.diagnostics);
7361
+ outputSources.push(...found.outputSources);
7362
+ return {
7363
+ kind: "files",
7364
+ filenames: found.filenames
7365
+ };
7366
+ });
7367
+ rejectOutputSources(outputSources, diagnostics);
7368
+ const buildFiles = new Set(walked.flatMap((group) => group.kind === "files" ? group.filenames : []));
7134
7369
  const story = createStory();
7135
7370
  const processedFiles = /* @__PURE__ */ new Set();
7136
- if (cache) loadSourcesCached(story, sourceFilenames, {
7371
+ for (const group of walked) if (group.kind === "inline") loadInlineSources(story, group.sources, {
7137
7372
  trim,
7138
7373
  twee2Compat
7139
- }, diagnostics, processedFiles, cache, changedFiles);
7140
- else loadSources(story, sourceFilenames, {
7374
+ }, diagnostics);
7375
+ else if (cache) loadSourcesCached(story, group.filenames, {
7376
+ trim,
7377
+ twee2Compat
7378
+ }, diagnostics, processedFiles, cache, changedFiles, buildFiles);
7379
+ else loadSources(story, group.filenames, {
7141
7380
  trim,
7142
7381
  twee2Compat
7143
7382
  }, diagnostics, processedFiles);
7144
- loadInlineSources(story, inlineSources, {
7383
+ if (cache && buildFiles.size === 0) loadSourcesCached(story, [], {
7145
7384
  trim,
7146
7385
  twee2Compat
7147
- }, diagnostics);
7386
+ }, diagnostics, processedFiles, cache, changedFiles);
7148
7387
  if (options.tagAliases) story.passages = applyTagAliases(story.passages, options.tagAliases);
7149
7388
  let format;
7150
- if (outputMode === "html") format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
7151
- ...options,
7152
- noRemote
7153
- }, diagnostics);
7389
+ if (outputMode === "html") {
7390
+ format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
7391
+ ...options,
7392
+ noRemote
7393
+ }, diagnostics);
7394
+ options.signal?.throwIfAborted();
7395
+ }
7154
7396
  if (options.startPassage) story.twine2.start = options.startPassage;
7155
7397
  const startName = story.twine2.start || DEFAULT_START_NAME;
7156
7398
  if (testMode) story.twine2.options.set("debug", true);
@@ -7202,6 +7444,7 @@ async function buildOutput(options, cache, changedFiles, outputs) {
7202
7444
  ...getStoryStats(story, options.wordCountMethod),
7203
7445
  files: [...processedFiles]
7204
7446
  };
7447
+ options.signal?.throwIfAborted();
7205
7448
  return {
7206
7449
  output,
7207
7450
  story,
@@ -8967,22 +9210,13 @@ const foldKey = (key) => key.toLowerCase().replace(/[-_]/g, "");
8967
9210
  *
8968
9211
  * @param diagnostics Receives warnings that do not stop the config from loading: keys the config does not
8969
9212
  * define, and a file that is not valid UTF-8 (it is read as Windows-1252).
8970
- * @throws When the file is not valid JSON or fails {@link validateConfig}.
9213
+ * @throws When the file exists but cannot be read, is not valid JSON or fails {@link validateConfig}.
8971
9214
  */
8972
9215
  function loadConfig(dir, diagnostics) {
8973
9216
  const base = dir ?? process.cwd();
8974
9217
  const configPath = (0, node_path.join)(base, CONFIG_FILENAME);
8975
9218
  if (!(0, node_fs.existsSync)(configPath)) return null;
8976
- const readDiagnostics = [];
8977
- let raw;
8978
- try {
8979
- raw = readUTF8(configPath, readDiagnostics);
8980
- } catch {
8981
- return null;
8982
- }
8983
- const config = parseConfig(raw, configPath, readDiagnostics);
8984
- diagnostics?.push(...readDiagnostics);
8985
- return config;
9219
+ return loadConfigFile(configPath, diagnostics);
8986
9220
  }
8987
9221
  /**
8988
9222
  * Load a config from a specific file path.