@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.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from "node:util";
3
- import { accessSync, chmodSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
3
+ import { accessSync, chmodSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
4
4
  import * as nodePath from "node:path";
5
5
  import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
6
6
  import { createHash, randomBytes, randomUUID } from "node:crypto";
@@ -1533,10 +1533,9 @@ function readUTF8(filename, diagnostics) {
1533
1533
  function readBase64(filename) {
1534
1534
  return readFileSync(filename).toString("base64");
1535
1535
  }
1536
- /** Get the filename without extension, falling back to the full basename for dotfiles. */
1536
+ /** Get the filename without extension, a dotfile keeps its whole name. */
1537
1537
  function baseNameWithoutExt(filename) {
1538
- const { name, ext } = parse(filename);
1539
- return name || ext;
1538
+ return parse(filename).name;
1540
1539
  }
1541
1540
  //#endregion
1542
1541
  //#region src/formats.ts
@@ -1556,6 +1555,89 @@ function stripSetupFunction(chunk) {
1556
1555
  for (const match of chunk.matchAll(SETUP_FUNCTION_PROPERTY)) lastIndex = match.index;
1557
1556
  return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
1558
1557
  }
1558
+ /** Index after the whitespace and `//` or block comments starting at `i`. */
1559
+ function skipTrivia(text, i) {
1560
+ let pos = i;
1561
+ for (;;) {
1562
+ const ch = text[pos];
1563
+ if (ch !== void 0 && /\s/.test(ch)) pos++;
1564
+ else if (text.startsWith("//", pos)) {
1565
+ const newline = text.indexOf("\n", pos);
1566
+ pos = newline === -1 ? text.length : newline + 1;
1567
+ } else if (text.startsWith("/*", pos)) {
1568
+ const close = text.indexOf("*/", pos + 2);
1569
+ pos = close === -1 ? text.length : close + 2;
1570
+ } else return pos;
1571
+ }
1572
+ }
1573
+ /** Index after the quoted string starting at `i` (a quote character); template literals nest `${…}`. */
1574
+ function skipString(text, i) {
1575
+ const quote = text[i];
1576
+ let pos = i + 1;
1577
+ while (pos < text.length) {
1578
+ const ch = text[pos];
1579
+ if (ch === "\\") pos += 2;
1580
+ else if (ch === quote) return pos + 1;
1581
+ else if (quote === "`" && ch === "$" && text[pos + 1] === "{") {
1582
+ const close = findClosingBrace(text, pos + 1);
1583
+ if (close === -1) return text.length;
1584
+ pos = close + 1;
1585
+ } else pos++;
1586
+ }
1587
+ return text.length;
1588
+ }
1589
+ function startsComment(text, pos) {
1590
+ return text[pos] === "/" && (text[pos + 1] === "/" || text[pos + 1] === "*");
1591
+ }
1592
+ function startsString(text, pos) {
1593
+ const ch = text[pos];
1594
+ return ch === "\"" || ch === "'" || ch === "`";
1595
+ }
1596
+ /** Index of the `}` matching the `{` at `open`, skipping comments and strings; -1 when unbalanced. */
1597
+ function findClosingBrace(text, open) {
1598
+ let depth = 0;
1599
+ let pos = open;
1600
+ while (pos < text.length) if (startsComment(text, pos)) pos = skipTrivia(text, pos);
1601
+ else if (startsString(text, pos)) pos = skipString(text, pos);
1602
+ else {
1603
+ if (text[pos] === "{") depth++;
1604
+ else if (text[pos] === "}" && --depth === 0) return pos;
1605
+ pos++;
1606
+ }
1607
+ return -1;
1608
+ }
1609
+ const STORY_FORMAT = "storyFormat";
1610
+ const IDENTIFIER_CHAR = /[\w$]/;
1611
+ /**
1612
+ * Locate the object literal passed to `storyFormat(` as `[start, end)` offsets, ignoring comments and
1613
+ * strings outside it and never evaluating anything. Without such a call, the first `{` outside comments
1614
+ * and strings starts the object. When its closing brace cannot be matched (a Harlowe setup function can
1615
+ * hold regular expression literals), the last `}` of the file ends it.
1616
+ */
1617
+ function locateFormatObject(text) {
1618
+ let start = -1;
1619
+ let firstBrace = -1;
1620
+ let pos = 0;
1621
+ while (pos < text.length && start === -1) if (startsComment(text, pos)) pos = skipTrivia(text, pos);
1622
+ else if (startsString(text, pos)) pos = skipString(text, pos);
1623
+ else if (text[pos] === "{") {
1624
+ if (firstBrace === -1) firstBrace = pos;
1625
+ pos++;
1626
+ } else if (text.startsWith(STORY_FORMAT, pos) && !IDENTIFIER_CHAR.test(text[pos - 1] ?? "")) {
1627
+ const paren = skipTrivia(text, pos + 11);
1628
+ const brace = text[paren] === "(" ? skipTrivia(text, paren + 1) : -1;
1629
+ if (text[brace] === "{") start = brace;
1630
+ pos += 11;
1631
+ } else pos++;
1632
+ if (start === -1) start = firstBrace;
1633
+ if (start === -1) return void 0;
1634
+ const close = findClosingBrace(text, start);
1635
+ const end = close === -1 ? text.lastIndexOf("}") : close;
1636
+ return end < start ? void 0 : {
1637
+ start,
1638
+ end: end + 1
1639
+ };
1640
+ }
1559
1641
  /**
1560
1642
  * Parse the format object at `start`–`end` of `text`: strict JSON first (fast), then the
1561
1643
  * JavaScript literal subset formats use. Error positions point into `text`.
@@ -1580,6 +1662,8 @@ function parseFormatObject(text, start, end) {
1580
1662
  }
1581
1663
  }
1582
1664
  /** Check a parsed format object's fields. Per spec, `name` is optional; `version` and `source` are required. */
1665
+ /** The name given to a format.js that names no format. */
1666
+ const UNNAMED_FORMAT_NAME = "Untitled Story Format";
1583
1667
  function toFormatJSON(raw) {
1584
1668
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
1585
1669
  ok: false,
@@ -1599,7 +1683,7 @@ function toFormatJSON(raw) {
1599
1683
  reason: `Story format version ${JSON.stringify(obj.version)} is not a SemVer version.`
1600
1684
  };
1601
1685
  const data = {
1602
- name: typeof obj.name === "string" ? obj.name : "Untitled Story Format",
1686
+ name: typeof obj.name === "string" ? obj.name : UNNAMED_FORMAT_NAME,
1603
1687
  version: obj.version,
1604
1688
  source: obj.source,
1605
1689
  proofing: obj.proofing === true
@@ -1624,15 +1708,14 @@ function toFormatJSON(raw) {
1624
1708
  * `harlowe-3` directory or a direct download.
1625
1709
  */
1626
1710
  function decodeFormatJSON(source) {
1627
- const first = source.indexOf("{");
1628
- const last = source.lastIndexOf("}");
1629
- if (first === -1 || last < first) return {
1711
+ const located = locateFormatObject(source);
1712
+ if (located === void 0) return {
1630
1713
  ok: false,
1631
1714
  reason: "Could not find Twine 2 style story format JSON chunk."
1632
1715
  };
1633
- const parsed = parseFormatObject(source, first, last + 1);
1716
+ const parsed = parseFormatObject(source, located.start, located.end);
1634
1717
  if (parsed.ok) return toFormatJSON(parsed.value);
1635
- const stripped = stripSetupFunction(source.slice(first, last + 1));
1718
+ const stripped = stripSetupFunction(source.slice(located.start, located.end));
1636
1719
  const retried = stripped === void 0 ? void 0 : parseFormatObject(stripped, 0, stripped.length);
1637
1720
  if (retried?.ok) return toFormatJSON(retried.value);
1638
1721
  return {
@@ -1919,16 +2002,17 @@ function readFormatSource(format, diagnostics) {
1919
2002
  /**
1920
2003
  * Writes `data` to `path` so that a reader sees either the previous file or the complete new
1921
2004
  * one, never a partly written file. The data goes to a temporary file in the same folder,
1922
- * which is then renamed over `path`. When `path` is a symlink, the file it points to is
1923
- * replaced, and an existing file keeps its permissions.
2005
+ * which is then renamed over `path`. When `path` is a symlink (even a dangling one, or a chain of links), the file it
2006
+ * finally points to is replaced or created and the link stays, and an existing file keeps its permissions.
1924
2007
  *
1925
2008
  * On failure (a full disk, a missing folder) the temporary file is removed, `path` is left
1926
2009
  * as it was, and the error is thrown with `path` in its message and its original `code`.
1927
2010
  */
1928
2011
  function writeFileAtomic(path, data) {
1929
- const target = followSymlink(path);
1930
- const temp = join(dirname(target), `.${basename(target)}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
2012
+ let temp;
1931
2013
  try {
2014
+ const target = resolveWriteTarget(path);
2015
+ temp = join(dirname(target), `.${basename(target)}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
1932
2016
  writeFileSync(temp, data, {
1933
2017
  encoding: "utf-8",
1934
2018
  flag: "wx"
@@ -1936,17 +2020,25 @@ function writeFileAtomic(path, data) {
1936
2020
  keepPermissions(target, temp);
1937
2021
  renameSync(temp, target);
1938
2022
  } catch (e) {
1939
- rmSync(temp, { force: true });
2023
+ if (temp !== void 0) rmSync(temp, { force: true });
1940
2024
  throw writeError(path, e);
1941
2025
  }
1942
2026
  }
1943
- /** The file a symlink at `path` points to, or `path` itself (also when it doesn't exist yet). */
1944
- function followSymlink(path) {
1945
- try {
1946
- return realpathSync(path);
1947
- } catch {
1948
- return path;
2027
+ /** Links followed before a path counts as a cycle (Linux stops at 40 as well). */
2028
+ const MAX_LINK_DEPTH = 40;
2029
+ /**
2030
+ * The file a symlink at `path` finally points to, or `path` itself when it is no link. Links are
2031
+ * followed one by one, so a link whose target does not exist yet (absolute, relative, or a chain
2032
+ * of them) still resolves to the file that should be created. A cycle or an unreadable link throws.
2033
+ */
2034
+ function resolveWriteTarget(path) {
2035
+ let current = path;
2036
+ for (let depth = 0; depth <= MAX_LINK_DEPTH; depth++) {
2037
+ const stat = lstatSync(current, { throwIfNoEntry: false });
2038
+ if (stat === void 0 || !stat.isSymbolicLink()) return current;
2039
+ current = resolve(dirname(current), readlinkSync(current));
1949
2040
  }
2041
+ throw Object.assign(/* @__PURE__ */ new Error(`ELOOP: too many levels of symbolic links, ${path}`), { code: "ELOOP" });
1950
2042
  }
1951
2043
  /**
1952
2044
  * Gives `temp` the permission bits of `target`, when `target` exists. Best effort: on a file
@@ -2058,31 +2150,37 @@ function clearIndexCache() {
2058
2150
  /** Requests in progress, by what they fetch, so concurrent compiles make each request once. */
2059
2151
  const sharedRequests = /* @__PURE__ */ new Map();
2060
2152
  /**
2061
- * Runs `start` once for all callers that ask for `key` while it is in progress. A caller whose
2062
- * `signal` aborts stops waiting at once and rejects with the signal's reason; the request
2063
- * itself is aborted (through the signal `start` receives) once no caller waits on it.
2153
+ * Runs `start` once for all callers that ask for `key` while it is in progress. Each caller waits
2154
+ * under its own limits: a caller whose `signal` aborts, or whose `timeout` passes, stops waiting
2155
+ * at once and rejects, while the others keep waiting. The request itself is aborted (through
2156
+ * the signal `start` receives) once no caller waits on it. `start` must not depend on any one
2157
+ * caller: what differs between callers belongs after the shared result.
2064
2158
  */
2065
- function shareRequest(key, signal, start) {
2159
+ function shareRequest(key, { signal, timeout, timedOut }, start) {
2066
2160
  if (signal?.aborted) return Promise.reject(signal.reason);
2067
2161
  const request = sharedRequests.get(key) ?? startSharedRequest(key, start);
2068
2162
  request.waiters++;
2069
2163
  return new Promise((resolve, reject) => {
2070
2164
  let waiting = true;
2165
+ let timer;
2071
2166
  const stopWaiting = () => {
2072
2167
  if (!waiting) return;
2073
2168
  waiting = false;
2074
2169
  signal?.removeEventListener("abort", onAbort);
2170
+ clearTimeout(timer);
2075
2171
  request.waiters--;
2076
2172
  };
2077
- const onAbort = () => {
2173
+ const giveUp = (reason) => {
2078
2174
  stopWaiting();
2079
2175
  if (request.waiters === 0) {
2080
2176
  if (sharedRequests.get(key) === request) sharedRequests.delete(key);
2081
- request.controller.abort(signal?.reason);
2177
+ request.controller.abort(reason);
2082
2178
  }
2083
- reject(signal?.reason);
2179
+ reject(reason);
2084
2180
  };
2181
+ const onAbort = () => giveUp(signal?.reason);
2085
2182
  signal?.addEventListener("abort", onAbort, { once: true });
2183
+ if (timeout > 0 && timeout <= MAX_TIMER_DELAY) timer = setTimeout(() => giveUp(timedOut()), timeout);
2086
2184
  request.promise.then((value) => {
2087
2185
  stopWaiting();
2088
2186
  resolve(value);
@@ -2112,20 +2210,32 @@ function requestTimeout(options) {
2112
2210
  if (!(timeout >= 0)) throw new RangeError(`A story format request timeout must be 0 or more milliseconds, not ${timeout}`);
2113
2211
  return timeout;
2114
2212
  }
2213
+ /** How one caller waits for `url`: under its own signal and timeout. `what` names the request in errors. */
2214
+ function waitOptions(options, what, url) {
2215
+ const timeout = requestTimeout(options);
2216
+ return {
2217
+ signal: options.signal,
2218
+ timeout,
2219
+ timedOut: () => /* @__PURE__ */ new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`)
2220
+ };
2221
+ }
2115
2222
  /**
2116
- * Fetch `url` as text, aborting when `signal` does or after `timeout` milliseconds (0: no limit).
2117
- * `what` names the request in errors, as in "Failed to <what> from <url>".
2223
+ * Fetch `url` as bytes, aborting when `signal` does. `what` names the request in errors, as in
2224
+ * "Failed to <what> from <url>". The bytes are returned undecoded, so checksums cover what was served.
2118
2225
  */
2119
- async function fetchText(url, what, signal, timeout) {
2120
- const limit = timeout > 0 && timeout <= MAX_TIMER_DELAY ? AbortSignal.timeout(timeout) : void 0;
2121
- try {
2122
- const res = await fetch(url, { signal: limit ? AbortSignal.any([signal, limit]) : signal });
2123
- if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
2124
- return await res.text();
2125
- } catch (e) {
2126
- if (limit?.aborted && !signal.aborted) throw new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`, { cause: e });
2127
- throw e;
2128
- }
2226
+ async function fetchBytes(url, what, signal) {
2227
+ const res = await fetch(url, { signal });
2228
+ if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
2229
+ return new Uint8Array(await res.arrayBuffer());
2230
+ }
2231
+ /** Download a format.js once for every caller in this process that asks for `url` meanwhile. */
2232
+ function shareDownload(url, options) {
2233
+ return shareRequest(`download\0${url}`, waitOptions(options, "download format", url), (signal) => fetchBytes(url, "download format", signal));
2234
+ }
2235
+ /** Decode a downloaded format.js as local ones are (UTF-8, else Windows-1252), without a leading BOM. */
2236
+ function decodeDownload(bytes, url) {
2237
+ const { text } = decodeText(bytes, url);
2238
+ return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
2129
2239
  }
2130
2240
  /** Validate that a JSON value is a valid SFAIndexEntry. */
2131
2241
  function isValidEntry(val) {
@@ -2147,9 +2257,8 @@ async function fetchIndex(url, options = {}) {
2147
2257
  options.signal?.throwIfAborted();
2148
2258
  const cached = indexCache.get(url);
2149
2259
  if (cached) return cached;
2150
- const timeout = requestTimeout(options);
2151
- return shareRequest(`index\0${url}`, options.signal, async (signal) => {
2152
- const text = await fetchText(url, "fetch format index", signal, timeout);
2260
+ return shareRequest(`index\0${url}`, waitOptions(options, "fetch format index", url), async (signal) => {
2261
+ const text = decodeDownload(await fetchBytes(url, "fetch format index", signal), url);
2153
2262
  const data = validateSFAIndex(JSON.parse(text));
2154
2263
  indexCache.set(url, data);
2155
2264
  return data;
@@ -2186,26 +2295,37 @@ function cachedFormatInfo(id, filename, data) {
2186
2295
  };
2187
2296
  }
2188
2297
  /**
2189
- * Download a format, verify its checksum, write it to the cache shared by name and version, and
2190
- * return its StoryFormatInfo. Concurrent calls for one download URL share one request.
2298
+ * Check that a downloaded format is the one the index entry promised. The name matches without
2299
+ * regard to case, as format requests do, and the version by SemVer precedence, so "1.0" and "v1.0.0"
2300
+ * agree. A format.js that names no format ({@link UNNAMED_FORMAT_NAME}) is allowed: such formats
2301
+ * exist, and the index entry then supplies the name it is cached under.
2302
+ */
2303
+ function checkFormatIdentity(entry, data, downloadUrl) {
2304
+ if ((data.name === "Untitled Story Format" || data.name.toLowerCase() === entry.name.toLowerCase()) && sameVersion(data.version, entry.version)) return;
2305
+ throw new Error(`Story format mismatch for ${downloadUrl}: the index lists ${entry.name} ${entry.version}, but the download is ${data.name} ${data.version}`);
2306
+ }
2307
+ /**
2308
+ * Download a format, verify its checksum, check that it is the format the entry names, write it to
2309
+ * the cache shared by name and version, and return its StoryFormatInfo. Concurrent calls for one
2310
+ * download URL share one request for the bytes; each call then verifies them against its own
2311
+ * entry, within its own timeout.
2191
2312
  */
2192
2313
  async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
2193
2314
  cachedFormatDir(entry.name, entry.version);
2194
- const timeout = requestTimeout(options);
2195
- return shareRequest(`format\0${getCacheDir()}\0${downloadUrl}`, options.signal, async (signal) => {
2196
- const text = await fetchText(downloadUrl, "download format", signal, timeout);
2197
- const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
2198
- if (checksumKey) {
2199
- const expected = entry.checksums[checksumKey];
2200
- if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
2201
- if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
2202
- }
2203
- const id = makeFormatId(entry.name, entry.version);
2204
- const data = parseFormatJSON(text, id);
2205
- if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
2206
- signal.throwIfAborted();
2207
- return cachedFormatInfo(id, writeCacheEntry(resolve(getCacheDir()), [entry.name, entry.version], text), data);
2208
- });
2315
+ const bytes = await shareDownload(downloadUrl, options);
2316
+ const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
2317
+ if (checksumKey) {
2318
+ const expected = entry.checksums[checksumKey];
2319
+ if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
2320
+ if (!await verifySHA256(bytes, expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
2321
+ }
2322
+ const text = decodeDownload(bytes, downloadUrl);
2323
+ const id = makeFormatId(entry.name, entry.version);
2324
+ const data = parseFormatJSON(text, id);
2325
+ if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
2326
+ checkFormatIdentity(entry, data, downloadUrl);
2327
+ options.signal?.throwIfAborted();
2328
+ return cachedFormatInfo(id, writeCacheEntry(resolve(getCacheDir()), [entry.name, entry.version], text), data);
2209
2329
  }
2210
2330
  /**
2211
2331
  * Download a direct format.js URL, parse its JSON, cache it under that URL (apart from the
@@ -2213,16 +2333,13 @@ async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
2213
2333
  * one URL share one request.
2214
2334
  */
2215
2335
  async function fetchDirectFormat(url, options = {}) {
2216
- const timeout = requestTimeout(options);
2217
- return shareRequest(`url\0${getUrlCacheDir()}\0${url}`, options.signal, async (signal) => {
2218
- const text = await fetchText(url, "download format", signal, timeout);
2219
- const data = parseFormatJSON(text);
2220
- if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
2221
- cachedFormatDir(data.name, data.version);
2222
- signal.throwIfAborted();
2223
- const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
2224
- return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
2225
- });
2336
+ const text = decodeDownload(await shareDownload(url, options), url);
2337
+ const data = parseFormatJSON(text);
2338
+ if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
2339
+ cachedFormatDir(data.name, data.version);
2340
+ options.signal?.throwIfAborted();
2341
+ const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
2342
+ return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
2226
2343
  }
2227
2344
  /** The copy of a direct format URL in the download cache, if it has been downloaded before. */
2228
2345
  function getCachedDirectFormat(url) {
@@ -2263,6 +2380,16 @@ async function resolveFormatUrls(request, urls, options = {}) {
2263
2380
  }
2264
2381
  if (lastError) throw lastError;
2265
2382
  }
2383
+ /**
2384
+ * Find the best format among the cached copies of direct format URLs, without network access.
2385
+ * Matching follows {@link selectFormatCandidate}, so `allowOlder` admits a same-major older version.
2386
+ * Each URL keeps its own copy; the shared name and version downloads are never consulted.
2387
+ *
2388
+ * Internal, for format resolution; not part of the public API.
2389
+ */
2390
+ function findCachedUrlFormat(request, urls, options = {}) {
2391
+ return selectFormatCandidate(request, urls.flatMap((url) => getCachedDirectFormat(url) ?? []), (f) => f, options);
2392
+ }
2266
2393
  /** Whether `info` is a format the request accepts. */
2267
2394
  function answers(request, info) {
2268
2395
  return selectFormatCandidate(request, [info], (f) => f) !== void 0;
@@ -2498,8 +2625,8 @@ function findLocalFormat(all, pruned, request) {
2498
2625
  * then format indices (unless `noRemote`). Names and IDs match the same way in each (see
2499
2626
  * `selectFormatCandidate`), so a request that a local format answers never reaches the cache or the
2500
2627
  * network. Local formats that cannot be used are reported as warnings. When a StoryData request
2501
- * names a version that none of them has, an older version of
2502
- * the same format and major version is used with a warning. Anything else is an error diagnostic,
2628
+ * names a version that none of them has, an older version of the same format and major version
2629
+ * is used with a warning, from local formats, the project's cached format URLs, or the shared cache. Anything else is an error diagnostic,
2503
2630
  * and the result is `undefined`.
2504
2631
  */
2505
2632
  async function resolveStoryFormat(request, options, diagnostics) {
@@ -2537,7 +2664,7 @@ async function resolveStoryFormat(request, options, diagnostics) {
2537
2664
  if (remote) return remote;
2538
2665
  }
2539
2666
  if (request.kind === "name") {
2540
- const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
2667
+ const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedUrlFormat(request, options.formatUrls ?? [], { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
2541
2668
  if (older) {
2542
2669
  diagnostics.push({
2543
2670
  level: "warning",
@@ -2786,8 +2913,6 @@ function* tweeLexer(input) {
2786
2913
  const items = ctx.drainItems();
2787
2914
  for (const item of items) yield item;
2788
2915
  }
2789
- const remaining = ctx.drainItems();
2790
- for (const item of remaining) yield item;
2791
2916
  }
2792
2917
  //#endregion
2793
2918
  //#region src/twee2-compat.ts
@@ -2914,9 +3039,7 @@ function parseTwee(source, options = {}) {
2914
3039
  };
2915
3040
  }
2916
3041
  try {
2917
- const raw = JSON.parse(item.val);
2918
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error("expected a JSON object");
2919
- const parsed = raw;
3042
+ const parsed = JSON.parse(item.val);
2920
3043
  const meta = {};
2921
3044
  for (const [key, value] of Object.entries(parsed)) if (typeof value === "string") meta[key] = value;
2922
3045
  current.metadata = meta;
@@ -6161,8 +6284,8 @@ function loadFont(story, filename, diagnostics) {
6161
6284
  * Load sources with mtime-based caching for incremental rebuilds.
6162
6285
  * Always creates a fresh Story; cached passages are replayed via storyAdd().
6163
6286
  */
6164
- function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
6165
- const currentFiles = /* @__PURE__ */ new Set();
6287
+ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles, buildFiles) {
6288
+ const currentFiles = new Set(buildFiles);
6166
6289
  const changedPaths = changedFiles === void 0 ? void 0 : new Set([...changedFiles].map((f) => resolve(f)));
6167
6290
  for (const filename of filenames) {
6168
6291
  if (processedFiles.has(filename)) {
@@ -6227,16 +6350,6 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
6227
6350
  }
6228
6351
  //#endregion
6229
6352
  //#region src/template.ts
6230
- /**
6231
- * An HTML closing head tag: `</head` in any letter case, followed by whitespace, `/` or `>`, up to the next `>`.
6232
- * This is how the HTML tokenizer reads an end tag; it ignores whatever sits between the tag name and the `>`.
6233
- */
6234
- const CLOSING_HEAD_TAG = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>";
6235
- /**
6236
- * The start of an HTML body start tag: `<body` in any letter case, followed by whitespace, `/` or `>` (so not
6237
- * `<bodyx>`). Only the `<body` is matched; the attributes and `>` after it are left alone.
6238
- */
6239
- const BODY_START_TAG = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
6240
6353
  /** The regular expression source matching exactly `text`. */
6241
6354
  function literal(text) {
6242
6355
  return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
@@ -6262,6 +6375,8 @@ function fillTemplateParts(parts) {
6262
6375
  return part.text.replace(pattern, (match, ...groups) => {
6263
6376
  const slot = slots.find((_, i) => groups[i] !== void 0);
6264
6377
  if (slot === void 0 || slot.occurrences === "first" && filled.has(slot)) return match;
6378
+ const offset = groups[slots.length];
6379
+ if (slot.accepts !== void 0 && typeof offset === "number" && !slot.accepts(part.text, offset)) return match;
6265
6380
  filled.add(slot);
6266
6381
  return slot.replacement(match);
6267
6382
  });
@@ -6352,15 +6467,18 @@ function placeHead(content, texts, owner) {
6352
6467
  slot: void 0,
6353
6468
  diagnostics: []
6354
6469
  };
6355
- const found = (tag) => texts.some((text) => new RegExp(tag).test(text));
6356
- if (found("<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>")) return {
6357
- slot: beforeTag(CLOSING_HEAD_TAG, content),
6470
+ const scans = texts.map((text) => ({
6471
+ text,
6472
+ tags: scanHeadTags(text)
6473
+ }));
6474
+ if (scans.some(({ tags }) => tags.closingHead !== void 0)) return {
6475
+ slot: beforeTag(CLOSING_HEAD_PREFIX, "closingHead", content),
6358
6476
  diagnostics: []
6359
6477
  };
6360
- if (found("<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])")) {
6478
+ if (scans.some(({ tags }) => tags.bodyStart !== void 0)) {
6361
6479
  const message = `${owner} has no closing head tag; the modules and head file were injected before its body start tag.`;
6362
6480
  return {
6363
- slot: beforeTag(BODY_START_TAG, content),
6481
+ slot: beforeTag(BODY_START_PREFIX, "bodyStart", content),
6364
6482
  diagnostics: [{
6365
6483
  level: "warning",
6366
6484
  message
@@ -6375,12 +6493,108 @@ function placeHead(content, texts, owner) {
6375
6493
  }]
6376
6494
  };
6377
6495
  }
6378
- /** The slot that inserts `content` on its own line before the first match of `tag`, which is kept as written. */
6379
- function beforeTag(tag, content) {
6496
+ /** `</head` in any letter case, followed by whitespace, `/` or `>`; the rest of the tag is left alone. */
6497
+ const CLOSING_HEAD_PREFIX = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])";
6498
+ /** `<body` in any letter case, followed by whitespace, `/` or `>` (so not `<bodyx>`). */
6499
+ const BODY_START_PREFIX = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
6500
+ /** Elements whose content is text, so a tag written there is not a tag. */
6501
+ const RAW_TEXT_ELEMENTS = /* @__PURE__ */ new Set([
6502
+ "script",
6503
+ "style",
6504
+ "textarea",
6505
+ "title"
6506
+ ]);
6507
+ const isSpace = (c) => c === " " || c === " " || c === "\n" || c === "\f" || c === "\r";
6508
+ const isLetter = (c) => c !== void 0 && /^[A-Za-z]$/.test(c);
6509
+ /**
6510
+ * Find the first closing head tag and body start tag that HTML would read as tags: not those in a comment, in
6511
+ * the text of a script, style, textarea or title element, or in a quoted attribute value. A small scanner, not
6512
+ * a full HTML tokenizer; it needs no more than the tags and where they end.
6513
+ */
6514
+ function scanHeadTags(html) {
6515
+ const { closingHead, bodyStart } = scanTags$1(html);
6516
+ return {
6517
+ closingHead,
6518
+ bodyStart
6519
+ };
6520
+ }
6521
+ function scanTags$1(html) {
6522
+ let closingHead;
6523
+ let bodyStart;
6524
+ let headStartEnd;
6525
+ let i = 0;
6526
+ while (i < html.length) {
6527
+ const lt = html.indexOf("<", i);
6528
+ if (lt === -1) break;
6529
+ const next = html[lt + 1];
6530
+ if (html.startsWith("<!--", lt)) {
6531
+ const end = html.indexOf("-->", lt + 4);
6532
+ i = end === -1 ? html.length : end + 3;
6533
+ } else if (next === "!" || next === "?") {
6534
+ const end = html.indexOf(">", lt);
6535
+ i = end === -1 ? html.length : end + 1;
6536
+ } else if (next === "/" && isLetter(html[lt + 2])) {
6537
+ const { name, end } = readTag(html, lt + 2);
6538
+ if (name === "head" && closingHead === void 0) closingHead = lt;
6539
+ i = end;
6540
+ } else if (isLetter(next)) {
6541
+ const { name, end } = readTag(html, lt + 1);
6542
+ if (name === "body" && bodyStart === void 0) bodyStart = lt;
6543
+ if (name === "head" && headStartEnd === void 0) headStartEnd = end;
6544
+ i = RAW_TEXT_ELEMENTS.has(name) ? rawTextEnd(html, name, end) : end;
6545
+ } else i = lt + 1;
6546
+ if (closingHead !== void 0 && bodyStart !== void 0) break;
6547
+ }
6548
+ return {
6549
+ closingHead,
6550
+ bodyStart,
6551
+ headStartEnd
6552
+ };
6553
+ }
6554
+ /** Read a tag whose name starts at `from`: its lowercase name, and the offset after its `>` (or the end). */
6555
+ function readTag(html, from) {
6556
+ let i = from;
6557
+ while (i < html.length && !isSpace(html[i]) && html[i] !== "/" && html[i] !== ">") i++;
6558
+ const name = html.slice(from, i).toLowerCase();
6559
+ while (i < html.length && html[i] !== ">") {
6560
+ if (html[i] !== "=") {
6561
+ i++;
6562
+ continue;
6563
+ }
6564
+ i++;
6565
+ while (isSpace(html[i])) i++;
6566
+ const quote = html[i];
6567
+ if (quote === "\"" || quote === "'") {
6568
+ const close = html.indexOf(quote, i + 1);
6569
+ i = close === -1 ? html.length : close + 1;
6570
+ }
6571
+ }
6380
6572
  return {
6381
- pattern: tag,
6573
+ name,
6574
+ end: Math.min(i + 1, html.length)
6575
+ };
6576
+ }
6577
+ /** The offset of the end tag that closes a raw text element, given where its text starts (or the end). */
6578
+ function rawTextEnd(html, name, from) {
6579
+ const close = new RegExp(`</${name}(?=[\\t\\n\\f\\r />])`, "gi");
6580
+ close.lastIndex = from;
6581
+ return close.exec(html)?.index ?? html.length;
6582
+ }
6583
+ /**
6584
+ * The slot that inserts `content` on its own line before the first real tag of the kind `which` names (see
6585
+ * `scanHeadTags()`), which is kept as written. A look-alike inside a script or comment is left alone.
6586
+ */
6587
+ function beforeTag(pattern, which, content) {
6588
+ const cache = /* @__PURE__ */ new Map();
6589
+ const offsetIn = (text) => {
6590
+ if (!cache.has(text)) cache.set(text, scanHeadTags(text)[which]);
6591
+ return cache.get(text);
6592
+ };
6593
+ return {
6594
+ pattern,
6382
6595
  occurrences: "first",
6383
- replacement: (match) => `${content}\n${match}`
6596
+ replacement: (match) => `${content}\n${match}`,
6597
+ accepts: (text, offset) => offsetIn(text) === offset
6384
6598
  };
6385
6599
  }
6386
6600
  //#endregion
@@ -6945,34 +7159,61 @@ async function buildOutput(options, cache, changedFiles, outputs) {
6945
7159
  const timeout = options.formatFetchTimeout;
6946
7160
  if (timeout !== void 0 && !(timeout >= 0)) throw new TweeTsError(`formatFetchTimeout must be 0 or more milliseconds, not ${timeout}.`);
6947
7161
  clearIndexCache();
6948
- const filePaths = [];
6949
- const inlineSources = [];
6950
- for (const source of options.sources) if (typeof source === "string") filePaths.push(source);
6951
- else inlineSources.push(source);
6952
- const sourcePaths = getFilenames(filePaths, written, options.exclude);
6953
- diagnostics.push(...sourcePaths.diagnostics);
6954
- rejectOutputSources(sourcePaths.outputSources, diagnostics);
6955
- const sourceFilenames = sourcePaths.filenames;
7162
+ const groups = [];
7163
+ const outputSources = [];
7164
+ for (const source of options.sources) {
7165
+ const last = groups[groups.length - 1];
7166
+ if (typeof source === "string") {
7167
+ if (last?.kind === "paths") last.paths.push(source);
7168
+ else groups.push({
7169
+ kind: "paths",
7170
+ paths: [source]
7171
+ });
7172
+ } else if (last?.kind === "inline") last.sources.push(source);
7173
+ else groups.push({
7174
+ kind: "inline",
7175
+ sources: [source]
7176
+ });
7177
+ }
7178
+ const walked = groups.map((group) => {
7179
+ if (group.kind === "inline") return group;
7180
+ const found = getFilenames(group.paths, written, options.exclude);
7181
+ diagnostics.push(...found.diagnostics);
7182
+ outputSources.push(...found.outputSources);
7183
+ return {
7184
+ kind: "files",
7185
+ filenames: found.filenames
7186
+ };
7187
+ });
7188
+ rejectOutputSources(outputSources, diagnostics);
7189
+ const buildFiles = new Set(walked.flatMap((group) => group.kind === "files" ? group.filenames : []));
6956
7190
  const story = createStory();
6957
7191
  const processedFiles = /* @__PURE__ */ new Set();
6958
- if (cache) loadSourcesCached(story, sourceFilenames, {
7192
+ for (const group of walked) if (group.kind === "inline") loadInlineSources(story, group.sources, {
6959
7193
  trim,
6960
7194
  twee2Compat
6961
- }, diagnostics, processedFiles, cache, changedFiles);
6962
- else loadSources(story, sourceFilenames, {
7195
+ }, diagnostics);
7196
+ else if (cache) loadSourcesCached(story, group.filenames, {
7197
+ trim,
7198
+ twee2Compat
7199
+ }, diagnostics, processedFiles, cache, changedFiles, buildFiles);
7200
+ else loadSources(story, group.filenames, {
6963
7201
  trim,
6964
7202
  twee2Compat
6965
7203
  }, diagnostics, processedFiles);
6966
- loadInlineSources(story, inlineSources, {
7204
+ if (cache && buildFiles.size === 0) loadSourcesCached(story, [], {
6967
7205
  trim,
6968
7206
  twee2Compat
6969
- }, diagnostics);
7207
+ }, diagnostics, processedFiles, cache, changedFiles);
6970
7208
  if (options.tagAliases) story.passages = applyTagAliases(story.passages, options.tagAliases);
6971
7209
  let format;
6972
- if (outputMode === "html") format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
6973
- ...options,
6974
- noRemote
6975
- }, diagnostics);
7210
+ if (outputMode === "html") {
7211
+ format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
7212
+ ...options,
7213
+ noRemote
7214
+ }, diagnostics);
7215
+ options.signal?.throwIfAborted();
7216
+ }
6976
7217
  if (options.startPassage) story.twine2.start = options.startPassage;
6977
7218
  const startName = story.twine2.start || DEFAULT_START_NAME;
6978
7219
  if (testMode) story.twine2.options.set("debug", true);
@@ -7024,6 +7265,7 @@ async function buildOutput(options, cache, changedFiles, outputs) {
7024
7265
  ...getStoryStats(story, options.wordCountMethod),
7025
7266
  files: [...processedFiles]
7026
7267
  };
7268
+ options.signal?.throwIfAborted();
7027
7269
  return {
7028
7270
  output,
7029
7271
  story,
@@ -8778,22 +9020,13 @@ const foldKey = (key) => key.toLowerCase().replace(/[-_]/g, "");
8778
9020
  *
8779
9021
  * @param diagnostics Receives warnings that do not stop the config from loading: keys the config does not
8780
9022
  * define, and a file that is not valid UTF-8 (it is read as Windows-1252).
8781
- * @throws When the file is not valid JSON or fails {@link validateConfig}.
9023
+ * @throws When the file exists but cannot be read, is not valid JSON or fails {@link validateConfig}.
8782
9024
  */
8783
9025
  function loadConfig(dir, diagnostics) {
8784
9026
  const base = dir ?? process.cwd();
8785
9027
  const configPath = join(base, CONFIG_FILENAME);
8786
9028
  if (!existsSync(configPath)) return null;
8787
- const readDiagnostics = [];
8788
- let raw;
8789
- try {
8790
- raw = readUTF8(configPath, readDiagnostics);
8791
- } catch {
8792
- return null;
8793
- }
8794
- const config = parseConfig(raw, configPath, readDiagnostics);
8795
- diagnostics?.push(...readDiagnostics);
8796
- return config;
9029
+ return loadConfigFile(configPath, diagnostics);
8797
9030
  }
8798
9031
  /**
8799
9032
  * Load a config from a specific file path.