@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.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as nodePath from "node:path";
2
2
  import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
3
3
  import { createHash, randomBytes, randomUUID } from "node:crypto";
4
- import { accessSync, chmodSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, watch as watch$1, writeFileSync } from "node:fs";
4
+ import { accessSync, chmodSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, watch as watch$1, writeFileSync } from "node:fs";
5
5
  import { homedir } from "node:os";
6
6
  import { fileURLToPath } from "node:url";
7
7
  //#region src/ifid.ts
@@ -1577,10 +1577,9 @@ function readUTF8(filename, diagnostics) {
1577
1577
  function readBase64(filename) {
1578
1578
  return readFileSync(filename).toString("base64");
1579
1579
  }
1580
- /** Get the filename without extension, falling back to the full basename for dotfiles. */
1580
+ /** Get the filename without extension, a dotfile keeps its whole name. */
1581
1581
  function baseNameWithoutExt(filename) {
1582
- const { name, ext } = parse(filename);
1583
- return name || ext;
1582
+ return parse(filename).name;
1584
1583
  }
1585
1584
  //#endregion
1586
1585
  //#region src/formats.ts
@@ -1600,6 +1599,89 @@ function stripSetupFunction(chunk) {
1600
1599
  for (const match of chunk.matchAll(SETUP_FUNCTION_PROPERTY)) lastIndex = match.index;
1601
1600
  return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
1602
1601
  }
1602
+ /** Index after the whitespace and `//` or block comments starting at `i`. */
1603
+ function skipTrivia(text, i) {
1604
+ let pos = i;
1605
+ for (;;) {
1606
+ const ch = text[pos];
1607
+ if (ch !== void 0 && /\s/.test(ch)) pos++;
1608
+ else if (text.startsWith("//", pos)) {
1609
+ const newline = text.indexOf("\n", pos);
1610
+ pos = newline === -1 ? text.length : newline + 1;
1611
+ } else if (text.startsWith("/*", pos)) {
1612
+ const close = text.indexOf("*/", pos + 2);
1613
+ pos = close === -1 ? text.length : close + 2;
1614
+ } else return pos;
1615
+ }
1616
+ }
1617
+ /** Index after the quoted string starting at `i` (a quote character); template literals nest `${…}`. */
1618
+ function skipString(text, i) {
1619
+ const quote = text[i];
1620
+ let pos = i + 1;
1621
+ while (pos < text.length) {
1622
+ const ch = text[pos];
1623
+ if (ch === "\\") pos += 2;
1624
+ else if (ch === quote) return pos + 1;
1625
+ else if (quote === "`" && ch === "$" && text[pos + 1] === "{") {
1626
+ const close = findClosingBrace(text, pos + 1);
1627
+ if (close === -1) return text.length;
1628
+ pos = close + 1;
1629
+ } else pos++;
1630
+ }
1631
+ return text.length;
1632
+ }
1633
+ function startsComment(text, pos) {
1634
+ return text[pos] === "/" && (text[pos + 1] === "/" || text[pos + 1] === "*");
1635
+ }
1636
+ function startsString(text, pos) {
1637
+ const ch = text[pos];
1638
+ return ch === "\"" || ch === "'" || ch === "`";
1639
+ }
1640
+ /** Index of the `}` matching the `{` at `open`, skipping comments and strings; -1 when unbalanced. */
1641
+ function findClosingBrace(text, open) {
1642
+ let depth = 0;
1643
+ let pos = open;
1644
+ while (pos < text.length) if (startsComment(text, pos)) pos = skipTrivia(text, pos);
1645
+ else if (startsString(text, pos)) pos = skipString(text, pos);
1646
+ else {
1647
+ if (text[pos] === "{") depth++;
1648
+ else if (text[pos] === "}" && --depth === 0) return pos;
1649
+ pos++;
1650
+ }
1651
+ return -1;
1652
+ }
1653
+ const STORY_FORMAT = "storyFormat";
1654
+ const IDENTIFIER_CHAR = /[\w$]/;
1655
+ /**
1656
+ * Locate the object literal passed to `storyFormat(` as `[start, end)` offsets, ignoring comments and
1657
+ * strings outside it and never evaluating anything. Without such a call, the first `{` outside comments
1658
+ * and strings starts the object. When its closing brace cannot be matched (a Harlowe setup function can
1659
+ * hold regular expression literals), the last `}` of the file ends it.
1660
+ */
1661
+ function locateFormatObject(text) {
1662
+ let start = -1;
1663
+ let firstBrace = -1;
1664
+ let pos = 0;
1665
+ while (pos < text.length && start === -1) if (startsComment(text, pos)) pos = skipTrivia(text, pos);
1666
+ else if (startsString(text, pos)) pos = skipString(text, pos);
1667
+ else if (text[pos] === "{") {
1668
+ if (firstBrace === -1) firstBrace = pos;
1669
+ pos++;
1670
+ } else if (text.startsWith(STORY_FORMAT, pos) && !IDENTIFIER_CHAR.test(text[pos - 1] ?? "")) {
1671
+ const paren = skipTrivia(text, pos + 11);
1672
+ const brace = text[paren] === "(" ? skipTrivia(text, paren + 1) : -1;
1673
+ if (text[brace] === "{") start = brace;
1674
+ pos += 11;
1675
+ } else pos++;
1676
+ if (start === -1) start = firstBrace;
1677
+ if (start === -1) return void 0;
1678
+ const close = findClosingBrace(text, start);
1679
+ const end = close === -1 ? text.lastIndexOf("}") : close;
1680
+ return end < start ? void 0 : {
1681
+ start,
1682
+ end: end + 1
1683
+ };
1684
+ }
1603
1685
  /**
1604
1686
  * Parse the format object at `start`–`end` of `text`: strict JSON first (fast), then the
1605
1687
  * JavaScript literal subset formats use. Error positions point into `text`.
@@ -1624,6 +1706,8 @@ function parseFormatObject(text, start, end) {
1624
1706
  }
1625
1707
  }
1626
1708
  /** Check a parsed format object's fields. Per spec, `name` is optional; `version` and `source` are required. */
1709
+ /** The name given to a format.js that names no format. */
1710
+ const UNNAMED_FORMAT_NAME = "Untitled Story Format";
1627
1711
  function toFormatJSON(raw) {
1628
1712
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
1629
1713
  ok: false,
@@ -1643,7 +1727,7 @@ function toFormatJSON(raw) {
1643
1727
  reason: `Story format version ${JSON.stringify(obj.version)} is not a SemVer version.`
1644
1728
  };
1645
1729
  const data = {
1646
- name: typeof obj.name === "string" ? obj.name : "Untitled Story Format",
1730
+ name: typeof obj.name === "string" ? obj.name : UNNAMED_FORMAT_NAME,
1647
1731
  version: obj.version,
1648
1732
  source: obj.source,
1649
1733
  proofing: obj.proofing === true
@@ -1668,15 +1752,14 @@ function toFormatJSON(raw) {
1668
1752
  * `harlowe-3` directory or a direct download.
1669
1753
  */
1670
1754
  function decodeFormatJSON(source) {
1671
- const first = source.indexOf("{");
1672
- const last = source.lastIndexOf("}");
1673
- if (first === -1 || last < first) return {
1755
+ const located = locateFormatObject(source);
1756
+ if (located === void 0) return {
1674
1757
  ok: false,
1675
1758
  reason: "Could not find Twine 2 style story format JSON chunk."
1676
1759
  };
1677
- const parsed = parseFormatObject(source, first, last + 1);
1760
+ const parsed = parseFormatObject(source, located.start, located.end);
1678
1761
  if (parsed.ok) return toFormatJSON(parsed.value);
1679
- const stripped = stripSetupFunction(source.slice(first, last + 1));
1762
+ const stripped = stripSetupFunction(source.slice(located.start, located.end));
1680
1763
  const retried = stripped === void 0 ? void 0 : parseFormatObject(stripped, 0, stripped.length);
1681
1764
  if (retried?.ok) return toFormatJSON(retried.value);
1682
1765
  return {
@@ -1980,16 +2063,17 @@ function readFormatSource(format, diagnostics) {
1980
2063
  /**
1981
2064
  * Writes `data` to `path` so that a reader sees either the previous file or the complete new
1982
2065
  * one, never a partly written file. The data goes to a temporary file in the same folder,
1983
- * which is then renamed over `path`. When `path` is a symlink, the file it points to is
1984
- * replaced, and an existing file keeps its permissions.
2066
+ * which is then renamed over `path`. When `path` is a symlink (even a dangling one, or a chain of links), the file it
2067
+ * finally points to is replaced or created and the link stays, and an existing file keeps its permissions.
1985
2068
  *
1986
2069
  * On failure (a full disk, a missing folder) the temporary file is removed, `path` is left
1987
2070
  * as it was, and the error is thrown with `path` in its message and its original `code`.
1988
2071
  */
1989
2072
  function writeFileAtomic(path, data) {
1990
- const target = followSymlink(path);
1991
- const temp = join(dirname(target), `.${basename(target)}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
2073
+ let temp;
1992
2074
  try {
2075
+ const target = resolveWriteTarget(path);
2076
+ temp = join(dirname(target), `.${basename(target)}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
1993
2077
  writeFileSync(temp, data, {
1994
2078
  encoding: "utf-8",
1995
2079
  flag: "wx"
@@ -1997,17 +2081,25 @@ function writeFileAtomic(path, data) {
1997
2081
  keepPermissions(target, temp);
1998
2082
  renameSync(temp, target);
1999
2083
  } catch (e) {
2000
- rmSync(temp, { force: true });
2084
+ if (temp !== void 0) rmSync(temp, { force: true });
2001
2085
  throw writeError(path, e);
2002
2086
  }
2003
2087
  }
2004
- /** The file a symlink at `path` points to, or `path` itself (also when it doesn't exist yet). */
2005
- function followSymlink(path) {
2006
- try {
2007
- return realpathSync(path);
2008
- } catch {
2009
- return path;
2088
+ /** Links followed before a path counts as a cycle (Linux stops at 40 as well). */
2089
+ const MAX_LINK_DEPTH = 40;
2090
+ /**
2091
+ * The file a symlink at `path` finally points to, or `path` itself when it is no link. Links are
2092
+ * followed one by one, so a link whose target does not exist yet (absolute, relative, or a chain
2093
+ * of them) still resolves to the file that should be created. A cycle or an unreadable link throws.
2094
+ */
2095
+ function resolveWriteTarget(path) {
2096
+ let current = path;
2097
+ for (let depth = 0; depth <= MAX_LINK_DEPTH; depth++) {
2098
+ const stat = lstatSync(current, { throwIfNoEntry: false });
2099
+ if (stat === void 0 || !stat.isSymbolicLink()) return current;
2100
+ current = resolve(dirname(current), readlinkSync(current));
2010
2101
  }
2102
+ throw Object.assign(/* @__PURE__ */ new Error(`ELOOP: too many levels of symbolic links, ${path}`), { code: "ELOOP" });
2011
2103
  }
2012
2104
  /**
2013
2105
  * Gives `temp` the permission bits of `target`, when `target` exists. Best effort: on a file
@@ -2119,31 +2211,37 @@ function clearIndexCache() {
2119
2211
  /** Requests in progress, by what they fetch, so concurrent compiles make each request once. */
2120
2212
  const sharedRequests = /* @__PURE__ */ new Map();
2121
2213
  /**
2122
- * Runs `start` once for all callers that ask for `key` while it is in progress. A caller whose
2123
- * `signal` aborts stops waiting at once and rejects with the signal's reason; the request
2124
- * itself is aborted (through the signal `start` receives) once no caller waits on it.
2214
+ * Runs `start` once for all callers that ask for `key` while it is in progress. Each caller waits
2215
+ * under its own limits: a caller whose `signal` aborts, or whose `timeout` passes, stops waiting
2216
+ * at once and rejects, while the others keep waiting. The request itself is aborted (through
2217
+ * the signal `start` receives) once no caller waits on it. `start` must not depend on any one
2218
+ * caller: what differs between callers belongs after the shared result.
2125
2219
  */
2126
- function shareRequest(key, signal, start) {
2220
+ function shareRequest(key, { signal, timeout, timedOut }, start) {
2127
2221
  if (signal?.aborted) return Promise.reject(signal.reason);
2128
2222
  const request = sharedRequests.get(key) ?? startSharedRequest(key, start);
2129
2223
  request.waiters++;
2130
2224
  return new Promise((resolve, reject) => {
2131
2225
  let waiting = true;
2226
+ let timer;
2132
2227
  const stopWaiting = () => {
2133
2228
  if (!waiting) return;
2134
2229
  waiting = false;
2135
2230
  signal?.removeEventListener("abort", onAbort);
2231
+ clearTimeout(timer);
2136
2232
  request.waiters--;
2137
2233
  };
2138
- const onAbort = () => {
2234
+ const giveUp = (reason) => {
2139
2235
  stopWaiting();
2140
2236
  if (request.waiters === 0) {
2141
2237
  if (sharedRequests.get(key) === request) sharedRequests.delete(key);
2142
- request.controller.abort(signal?.reason);
2238
+ request.controller.abort(reason);
2143
2239
  }
2144
- reject(signal?.reason);
2240
+ reject(reason);
2145
2241
  };
2242
+ const onAbort = () => giveUp(signal?.reason);
2146
2243
  signal?.addEventListener("abort", onAbort, { once: true });
2244
+ if (timeout > 0 && timeout <= MAX_TIMER_DELAY) timer = setTimeout(() => giveUp(timedOut()), timeout);
2147
2245
  request.promise.then((value) => {
2148
2246
  stopWaiting();
2149
2247
  resolve(value);
@@ -2173,20 +2271,32 @@ function requestTimeout(options) {
2173
2271
  if (!(timeout >= 0)) throw new RangeError(`A story format request timeout must be 0 or more milliseconds, not ${timeout}`);
2174
2272
  return timeout;
2175
2273
  }
2274
+ /** How one caller waits for `url`: under its own signal and timeout. `what` names the request in errors. */
2275
+ function waitOptions(options, what, url) {
2276
+ const timeout = requestTimeout(options);
2277
+ return {
2278
+ signal: options.signal,
2279
+ timeout,
2280
+ timedOut: () => /* @__PURE__ */ new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`)
2281
+ };
2282
+ }
2176
2283
  /**
2177
- * Fetch `url` as text, aborting when `signal` does or after `timeout` milliseconds (0: no limit).
2178
- * `what` names the request in errors, as in "Failed to <what> from <url>".
2284
+ * Fetch `url` as bytes, aborting when `signal` does. `what` names the request in errors, as in
2285
+ * "Failed to <what> from <url>". The bytes are returned undecoded, so checksums cover what was served.
2179
2286
  */
2180
- async function fetchText(url, what, signal, timeout) {
2181
- const limit = timeout > 0 && timeout <= MAX_TIMER_DELAY ? AbortSignal.timeout(timeout) : void 0;
2182
- try {
2183
- const res = await fetch(url, { signal: limit ? AbortSignal.any([signal, limit]) : signal });
2184
- if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
2185
- return await res.text();
2186
- } catch (e) {
2187
- if (limit?.aborted && !signal.aborted) throw new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`, { cause: e });
2188
- throw e;
2189
- }
2287
+ async function fetchBytes(url, what, signal) {
2288
+ const res = await fetch(url, { signal });
2289
+ if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
2290
+ return new Uint8Array(await res.arrayBuffer());
2291
+ }
2292
+ /** Download a format.js once for every caller in this process that asks for `url` meanwhile. */
2293
+ function shareDownload(url, options) {
2294
+ return shareRequest(`download\0${url}`, waitOptions(options, "download format", url), (signal) => fetchBytes(url, "download format", signal));
2295
+ }
2296
+ /** Decode a downloaded format.js as local ones are (UTF-8, else Windows-1252), without a leading BOM. */
2297
+ function decodeDownload(bytes, url) {
2298
+ const { text } = decodeText(bytes, url);
2299
+ return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
2190
2300
  }
2191
2301
  /** Validate that a JSON value is a valid SFAIndexEntry. */
2192
2302
  function isValidEntry(val) {
@@ -2208,9 +2318,8 @@ async function fetchIndex(url, options = {}) {
2208
2318
  options.signal?.throwIfAborted();
2209
2319
  const cached = indexCache.get(url);
2210
2320
  if (cached) return cached;
2211
- const timeout = requestTimeout(options);
2212
- return shareRequest(`index\0${url}`, options.signal, async (signal) => {
2213
- const text = await fetchText(url, "fetch format index", signal, timeout);
2321
+ return shareRequest(`index\0${url}`, waitOptions(options, "fetch format index", url), async (signal) => {
2322
+ const text = decodeDownload(await fetchBytes(url, "fetch format index", signal), url);
2214
2323
  const data = validateSFAIndex(JSON.parse(text));
2215
2324
  indexCache.set(url, data);
2216
2325
  return data;
@@ -2247,26 +2356,37 @@ function cachedFormatInfo(id, filename, data) {
2247
2356
  };
2248
2357
  }
2249
2358
  /**
2250
- * Download a format, verify its checksum, write it to the cache shared by name and version, and
2251
- * return its StoryFormatInfo. Concurrent calls for one download URL share one request.
2359
+ * Check that a downloaded format is the one the index entry promised. The name matches without
2360
+ * regard to case, as format requests do, and the version by SemVer precedence, so "1.0" and "v1.0.0"
2361
+ * agree. A format.js that names no format ({@link UNNAMED_FORMAT_NAME}) is allowed: such formats
2362
+ * exist, and the index entry then supplies the name it is cached under.
2363
+ */
2364
+ function checkFormatIdentity(entry, data, downloadUrl) {
2365
+ if ((data.name === "Untitled Story Format" || data.name.toLowerCase() === entry.name.toLowerCase()) && sameVersion(data.version, entry.version)) return;
2366
+ throw new Error(`Story format mismatch for ${downloadUrl}: the index lists ${entry.name} ${entry.version}, but the download is ${data.name} ${data.version}`);
2367
+ }
2368
+ /**
2369
+ * Download a format, verify its checksum, check that it is the format the entry names, write it to
2370
+ * the cache shared by name and version, and return its StoryFormatInfo. Concurrent calls for one
2371
+ * download URL share one request for the bytes; each call then verifies them against its own
2372
+ * entry, within its own timeout.
2252
2373
  */
2253
2374
  async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
2254
2375
  cachedFormatDir(entry.name, entry.version);
2255
- const timeout = requestTimeout(options);
2256
- return shareRequest(`format\0${getCacheDir()}\0${downloadUrl}`, options.signal, async (signal) => {
2257
- const text = await fetchText(downloadUrl, "download format", signal, timeout);
2258
- const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
2259
- if (checksumKey) {
2260
- const expected = entry.checksums[checksumKey];
2261
- if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
2262
- if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
2263
- }
2264
- const id = makeFormatId(entry.name, entry.version);
2265
- const data = parseFormatJSON(text, id);
2266
- if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
2267
- signal.throwIfAborted();
2268
- return cachedFormatInfo(id, writeCacheEntry(resolve(getCacheDir()), [entry.name, entry.version], text), data);
2269
- });
2376
+ const bytes = await shareDownload(downloadUrl, options);
2377
+ const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
2378
+ if (checksumKey) {
2379
+ const expected = entry.checksums[checksumKey];
2380
+ if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
2381
+ if (!await verifySHA256(bytes, expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
2382
+ }
2383
+ const text = decodeDownload(bytes, downloadUrl);
2384
+ const id = makeFormatId(entry.name, entry.version);
2385
+ const data = parseFormatJSON(text, id);
2386
+ if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
2387
+ checkFormatIdentity(entry, data, downloadUrl);
2388
+ options.signal?.throwIfAborted();
2389
+ return cachedFormatInfo(id, writeCacheEntry(resolve(getCacheDir()), [entry.name, entry.version], text), data);
2270
2390
  }
2271
2391
  /**
2272
2392
  * Download a direct format.js URL, parse its JSON, cache it under that URL (apart from the
@@ -2274,16 +2394,13 @@ async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
2274
2394
  * one URL share one request.
2275
2395
  */
2276
2396
  async function fetchDirectFormat(url, options = {}) {
2277
- const timeout = requestTimeout(options);
2278
- return shareRequest(`url\0${getUrlCacheDir()}\0${url}`, options.signal, async (signal) => {
2279
- const text = await fetchText(url, "download format", signal, timeout);
2280
- const data = parseFormatJSON(text);
2281
- if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
2282
- cachedFormatDir(data.name, data.version);
2283
- signal.throwIfAborted();
2284
- const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
2285
- return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
2286
- });
2397
+ const text = decodeDownload(await shareDownload(url, options), url);
2398
+ const data = parseFormatJSON(text);
2399
+ if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
2400
+ cachedFormatDir(data.name, data.version);
2401
+ options.signal?.throwIfAborted();
2402
+ const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
2403
+ return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
2287
2404
  }
2288
2405
  /** The copy of a direct format URL in the download cache, if it has been downloaded before. */
2289
2406
  function getCachedDirectFormat(url) {
@@ -2324,6 +2441,16 @@ async function resolveFormatUrls(request, urls, options = {}) {
2324
2441
  }
2325
2442
  if (lastError) throw lastError;
2326
2443
  }
2444
+ /**
2445
+ * Find the best format among the cached copies of direct format URLs, without network access.
2446
+ * Matching follows {@link selectFormatCandidate}, so `allowOlder` admits a same-major older version.
2447
+ * Each URL keeps its own copy; the shared name and version downloads are never consulted.
2448
+ *
2449
+ * Internal, for format resolution; not part of the public API.
2450
+ */
2451
+ function findCachedUrlFormat(request, urls, options = {}) {
2452
+ return selectFormatCandidate(request, urls.flatMap((url) => getCachedDirectFormat(url) ?? []), (f) => f, options);
2453
+ }
2327
2454
  /** Whether `info` is a format the request accepts. */
2328
2455
  function answers(request, info) {
2329
2456
  return selectFormatCandidate(request, [info], (f) => f) !== void 0;
@@ -2576,8 +2703,8 @@ function findLocalFormat(all, pruned, request) {
2576
2703
  * then format indices (unless `noRemote`). Names and IDs match the same way in each (see
2577
2704
  * `selectFormatCandidate`), so a request that a local format answers never reaches the cache or the
2578
2705
  * network. Local formats that cannot be used are reported as warnings. When a StoryData request
2579
- * names a version that none of them has, an older version of
2580
- * the same format and major version is used with a warning. Anything else is an error diagnostic,
2706
+ * names a version that none of them has, an older version of the same format and major version
2707
+ * is used with a warning, from local formats, the project's cached format URLs, or the shared cache. Anything else is an error diagnostic,
2581
2708
  * and the result is `undefined`.
2582
2709
  */
2583
2710
  async function resolveStoryFormat(request, options, diagnostics) {
@@ -2615,7 +2742,7 @@ async function resolveStoryFormat(request, options, diagnostics) {
2615
2742
  if (remote) return remote;
2616
2743
  }
2617
2744
  if (request.kind === "name") {
2618
- const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
2745
+ const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedUrlFormat(request, options.formatUrls ?? [], { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
2619
2746
  if (older) {
2620
2747
  diagnostics.push({
2621
2748
  level: "warning",
@@ -2864,8 +2991,6 @@ function* tweeLexer(input) {
2864
2991
  const items = ctx.drainItems();
2865
2992
  for (const item of items) yield item;
2866
2993
  }
2867
- const remaining = ctx.drainItems();
2868
- for (const item of remaining) yield item;
2869
2994
  }
2870
2995
  /** Convenience class wrapper for the lexer generator. */
2871
2996
  var TweeLexer = class {
@@ -3018,9 +3143,7 @@ function parseTwee(source, options = {}) {
3018
3143
  };
3019
3144
  }
3020
3145
  try {
3021
- const raw = JSON.parse(item.val);
3022
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error("expected a JSON object");
3023
- const parsed = raw;
3146
+ const parsed = JSON.parse(item.val);
3024
3147
  const meta = {};
3025
3148
  for (const [key, value] of Object.entries(parsed)) if (typeof value === "string") meta[key] = value;
3026
3149
  current.metadata = meta;
@@ -6274,8 +6397,8 @@ function loadFont(story, filename, diagnostics) {
6274
6397
  * Load sources with mtime-based caching for incremental rebuilds.
6275
6398
  * Always creates a fresh Story; cached passages are replayed via storyAdd().
6276
6399
  */
6277
- function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
6278
- const currentFiles = /* @__PURE__ */ new Set();
6400
+ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles, buildFiles) {
6401
+ const currentFiles = new Set(buildFiles);
6279
6402
  const changedPaths = changedFiles === void 0 ? void 0 : new Set([...changedFiles].map((f) => resolve(f)));
6280
6403
  for (const filename of filenames) {
6281
6404
  if (processedFiles.has(filename)) {
@@ -6340,16 +6463,6 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
6340
6463
  }
6341
6464
  //#endregion
6342
6465
  //#region src/template.ts
6343
- /**
6344
- * An HTML closing head tag: `</head` in any letter case, followed by whitespace, `/` or `>`, up to the next `>`.
6345
- * This is how the HTML tokenizer reads an end tag; it ignores whatever sits between the tag name and the `>`.
6346
- */
6347
- const CLOSING_HEAD_TAG = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>";
6348
- /**
6349
- * The start of an HTML body start tag: `<body` in any letter case, followed by whitespace, `/` or `>` (so not
6350
- * `<bodyx>`). Only the `<body` is matched; the attributes and `>` after it are left alone.
6351
- */
6352
- const BODY_START_TAG = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
6353
6466
  /** The regular expression source matching exactly `text`. */
6354
6467
  function literal(text) {
6355
6468
  return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
@@ -6375,6 +6488,8 @@ function fillTemplateParts(parts) {
6375
6488
  return part.text.replace(pattern, (match, ...groups) => {
6376
6489
  const slot = slots.find((_, i) => groups[i] !== void 0);
6377
6490
  if (slot === void 0 || slot.occurrences === "first" && filled.has(slot)) return match;
6491
+ const offset = groups[slots.length];
6492
+ if (slot.accepts !== void 0 && typeof offset === "number" && !slot.accepts(part.text, offset)) return match;
6378
6493
  filled.add(slot);
6379
6494
  return slot.replacement(match);
6380
6495
  });
@@ -6465,15 +6580,18 @@ function placeHead(content, texts, owner) {
6465
6580
  slot: void 0,
6466
6581
  diagnostics: []
6467
6582
  };
6468
- const found = (tag) => texts.some((text) => new RegExp(tag).test(text));
6469
- if (found("<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>")) return {
6470
- slot: beforeTag(CLOSING_HEAD_TAG, content),
6583
+ const scans = texts.map((text) => ({
6584
+ text,
6585
+ tags: scanHeadTags(text)
6586
+ }));
6587
+ if (scans.some(({ tags }) => tags.closingHead !== void 0)) return {
6588
+ slot: beforeTag(CLOSING_HEAD_PREFIX, "closingHead", content),
6471
6589
  diagnostics: []
6472
6590
  };
6473
- if (found("<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])")) {
6591
+ if (scans.some(({ tags }) => tags.bodyStart !== void 0)) {
6474
6592
  const message = `${owner} has no closing head tag; the modules and head file were injected before its body start tag.`;
6475
6593
  return {
6476
- slot: beforeTag(BODY_START_TAG, content),
6594
+ slot: beforeTag(BODY_START_PREFIX, "bodyStart", content),
6477
6595
  diagnostics: [{
6478
6596
  level: "warning",
6479
6597
  message
@@ -6488,12 +6606,108 @@ function placeHead(content, texts, owner) {
6488
6606
  }]
6489
6607
  };
6490
6608
  }
6491
- /** The slot that inserts `content` on its own line before the first match of `tag`, which is kept as written. */
6492
- function beforeTag(tag, content) {
6609
+ /** `</head` in any letter case, followed by whitespace, `/` or `>`; the rest of the tag is left alone. */
6610
+ const CLOSING_HEAD_PREFIX = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])";
6611
+ /** `<body` in any letter case, followed by whitespace, `/` or `>` (so not `<bodyx>`). */
6612
+ const BODY_START_PREFIX = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
6613
+ /** Elements whose content is text, so a tag written there is not a tag. */
6614
+ const RAW_TEXT_ELEMENTS = /* @__PURE__ */ new Set([
6615
+ "script",
6616
+ "style",
6617
+ "textarea",
6618
+ "title"
6619
+ ]);
6620
+ const isSpace = (c) => c === " " || c === " " || c === "\n" || c === "\f" || c === "\r";
6621
+ const isLetter = (c) => c !== void 0 && /^[A-Za-z]$/.test(c);
6622
+ /**
6623
+ * Find the first closing head tag and body start tag that HTML would read as tags: not those in a comment, in
6624
+ * the text of a script, style, textarea or title element, or in a quoted attribute value. A small scanner, not
6625
+ * a full HTML tokenizer; it needs no more than the tags and where they end.
6626
+ */
6627
+ function scanHeadTags(html) {
6628
+ const { closingHead, bodyStart } = scanTags$1(html);
6629
+ return {
6630
+ closingHead,
6631
+ bodyStart
6632
+ };
6633
+ }
6634
+ function scanTags$1(html) {
6635
+ let closingHead;
6636
+ let bodyStart;
6637
+ let headStartEnd;
6638
+ let i = 0;
6639
+ while (i < html.length) {
6640
+ const lt = html.indexOf("<", i);
6641
+ if (lt === -1) break;
6642
+ const next = html[lt + 1];
6643
+ if (html.startsWith("<!--", lt)) {
6644
+ const end = html.indexOf("-->", lt + 4);
6645
+ i = end === -1 ? html.length : end + 3;
6646
+ } else if (next === "!" || next === "?") {
6647
+ const end = html.indexOf(">", lt);
6648
+ i = end === -1 ? html.length : end + 1;
6649
+ } else if (next === "/" && isLetter(html[lt + 2])) {
6650
+ const { name, end } = readTag(html, lt + 2);
6651
+ if (name === "head" && closingHead === void 0) closingHead = lt;
6652
+ i = end;
6653
+ } else if (isLetter(next)) {
6654
+ const { name, end } = readTag(html, lt + 1);
6655
+ if (name === "body" && bodyStart === void 0) bodyStart = lt;
6656
+ if (name === "head" && headStartEnd === void 0) headStartEnd = end;
6657
+ i = RAW_TEXT_ELEMENTS.has(name) ? rawTextEnd(html, name, end) : end;
6658
+ } else i = lt + 1;
6659
+ if (closingHead !== void 0 && bodyStart !== void 0) break;
6660
+ }
6661
+ return {
6662
+ closingHead,
6663
+ bodyStart,
6664
+ headStartEnd
6665
+ };
6666
+ }
6667
+ /** Read a tag whose name starts at `from`: its lowercase name, and the offset after its `>` (or the end). */
6668
+ function readTag(html, from) {
6669
+ let i = from;
6670
+ while (i < html.length && !isSpace(html[i]) && html[i] !== "/" && html[i] !== ">") i++;
6671
+ const name = html.slice(from, i).toLowerCase();
6672
+ while (i < html.length && html[i] !== ">") {
6673
+ if (html[i] !== "=") {
6674
+ i++;
6675
+ continue;
6676
+ }
6677
+ i++;
6678
+ while (isSpace(html[i])) i++;
6679
+ const quote = html[i];
6680
+ if (quote === "\"" || quote === "'") {
6681
+ const close = html.indexOf(quote, i + 1);
6682
+ i = close === -1 ? html.length : close + 1;
6683
+ }
6684
+ }
6493
6685
  return {
6494
- pattern: tag,
6686
+ name,
6687
+ end: Math.min(i + 1, html.length)
6688
+ };
6689
+ }
6690
+ /** The offset of the end tag that closes a raw text element, given where its text starts (or the end). */
6691
+ function rawTextEnd(html, name, from) {
6692
+ const close = new RegExp(`</${name}(?=[\\t\\n\\f\\r />])`, "gi");
6693
+ close.lastIndex = from;
6694
+ return close.exec(html)?.index ?? html.length;
6695
+ }
6696
+ /**
6697
+ * The slot that inserts `content` on its own line before the first real tag of the kind `which` names (see
6698
+ * `scanHeadTags()`), which is kept as written. A look-alike inside a script or comment is left alone.
6699
+ */
6700
+ function beforeTag(pattern, which, content) {
6701
+ const cache = /* @__PURE__ */ new Map();
6702
+ const offsetIn = (text) => {
6703
+ if (!cache.has(text)) cache.set(text, scanHeadTags(text)[which]);
6704
+ return cache.get(text);
6705
+ };
6706
+ return {
6707
+ pattern,
6495
6708
  occurrences: "first",
6496
- replacement: (match) => `${content}\n${match}`
6709
+ replacement: (match) => `${content}\n${match}`,
6710
+ accepts: (text, offset) => offsetIn(text) === offset
6497
6711
  };
6498
6712
  }
6499
6713
  //#endregion
@@ -6939,6 +7153,7 @@ async function compile(options) {
6939
7153
  */
6940
7154
  async function compileToFile(options) {
6941
7155
  const result = await compileForOutputFile(options, options.outFile);
7156
+ options.signal?.throwIfAborted();
6942
7157
  writeFileAtomic(options.outFile, result.output);
6943
7158
  return result;
6944
7159
  }
@@ -7100,34 +7315,61 @@ async function buildOutput(options, cache, changedFiles, outputs) {
7100
7315
  const timeout = options.formatFetchTimeout;
7101
7316
  if (timeout !== void 0 && !(timeout >= 0)) throw new TweeTsError(`formatFetchTimeout must be 0 or more milliseconds, not ${timeout}.`);
7102
7317
  clearIndexCache();
7103
- const filePaths = [];
7104
- const inlineSources = [];
7105
- for (const source of options.sources) if (typeof source === "string") filePaths.push(source);
7106
- else inlineSources.push(source);
7107
- const sourcePaths = getFilenames(filePaths, written, options.exclude);
7108
- diagnostics.push(...sourcePaths.diagnostics);
7109
- rejectOutputSources(sourcePaths.outputSources, diagnostics);
7110
- const sourceFilenames = sourcePaths.filenames;
7318
+ const groups = [];
7319
+ const outputSources = [];
7320
+ for (const source of options.sources) {
7321
+ const last = groups[groups.length - 1];
7322
+ if (typeof source === "string") {
7323
+ if (last?.kind === "paths") last.paths.push(source);
7324
+ else groups.push({
7325
+ kind: "paths",
7326
+ paths: [source]
7327
+ });
7328
+ } else if (last?.kind === "inline") last.sources.push(source);
7329
+ else groups.push({
7330
+ kind: "inline",
7331
+ sources: [source]
7332
+ });
7333
+ }
7334
+ const walked = groups.map((group) => {
7335
+ if (group.kind === "inline") return group;
7336
+ const found = getFilenames(group.paths, written, options.exclude);
7337
+ diagnostics.push(...found.diagnostics);
7338
+ outputSources.push(...found.outputSources);
7339
+ return {
7340
+ kind: "files",
7341
+ filenames: found.filenames
7342
+ };
7343
+ });
7344
+ rejectOutputSources(outputSources, diagnostics);
7345
+ const buildFiles = new Set(walked.flatMap((group) => group.kind === "files" ? group.filenames : []));
7111
7346
  const story = createStory();
7112
7347
  const processedFiles = /* @__PURE__ */ new Set();
7113
- if (cache) loadSourcesCached(story, sourceFilenames, {
7348
+ for (const group of walked) if (group.kind === "inline") loadInlineSources(story, group.sources, {
7114
7349
  trim,
7115
7350
  twee2Compat
7116
- }, diagnostics, processedFiles, cache, changedFiles);
7117
- else loadSources(story, sourceFilenames, {
7351
+ }, diagnostics);
7352
+ else if (cache) loadSourcesCached(story, group.filenames, {
7353
+ trim,
7354
+ twee2Compat
7355
+ }, diagnostics, processedFiles, cache, changedFiles, buildFiles);
7356
+ else loadSources(story, group.filenames, {
7118
7357
  trim,
7119
7358
  twee2Compat
7120
7359
  }, diagnostics, processedFiles);
7121
- loadInlineSources(story, inlineSources, {
7360
+ if (cache && buildFiles.size === 0) loadSourcesCached(story, [], {
7122
7361
  trim,
7123
7362
  twee2Compat
7124
- }, diagnostics);
7363
+ }, diagnostics, processedFiles, cache, changedFiles);
7125
7364
  if (options.tagAliases) story.passages = applyTagAliases(story.passages, options.tagAliases);
7126
7365
  let format;
7127
- if (outputMode === "html") format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
7128
- ...options,
7129
- noRemote
7130
- }, diagnostics);
7366
+ if (outputMode === "html") {
7367
+ format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
7368
+ ...options,
7369
+ noRemote
7370
+ }, diagnostics);
7371
+ options.signal?.throwIfAborted();
7372
+ }
7131
7373
  if (options.startPassage) story.twine2.start = options.startPassage;
7132
7374
  const startName = story.twine2.start || DEFAULT_START_NAME;
7133
7375
  if (testMode) story.twine2.options.set("debug", true);
@@ -7179,6 +7421,7 @@ async function buildOutput(options, cache, changedFiles, outputs) {
7179
7421
  ...getStoryStats(story, options.wordCountMethod),
7180
7422
  files: [...processedFiles]
7181
7423
  };
7424
+ options.signal?.throwIfAborted();
7182
7425
  return {
7183
7426
  output,
7184
7427
  story,
@@ -8944,22 +9187,13 @@ const foldKey = (key) => key.toLowerCase().replace(/[-_]/g, "");
8944
9187
  *
8945
9188
  * @param diagnostics Receives warnings that do not stop the config from loading: keys the config does not
8946
9189
  * define, and a file that is not valid UTF-8 (it is read as Windows-1252).
8947
- * @throws When the file is not valid JSON or fails {@link validateConfig}.
9190
+ * @throws When the file exists but cannot be read, is not valid JSON or fails {@link validateConfig}.
8948
9191
  */
8949
9192
  function loadConfig(dir, diagnostics) {
8950
9193
  const base = dir ?? process.cwd();
8951
9194
  const configPath = join(base, CONFIG_FILENAME);
8952
9195
  if (!existsSync(configPath)) return null;
8953
- const readDiagnostics = [];
8954
- let raw;
8955
- try {
8956
- raw = readUTF8(configPath, readDiagnostics);
8957
- } catch {
8958
- return null;
8959
- }
8960
- const config = parseConfig(raw, configPath, readDiagnostics);
8961
- diagnostics?.push(...readDiagnostics);
8962
- return config;
9196
+ return loadConfigFile(configPath, diagnostics);
8963
9197
  }
8964
9198
  /**
8965
9199
  * Load a config from a specific file path.