@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,4 +1,4 @@
1
- import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import * as nodePath from "node:path";
3
3
  import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
4
4
  import { createHash, randomBytes, randomUUID } from "node:crypto";
@@ -1322,10 +1322,9 @@ function readUTF8(filename, diagnostics) {
1322
1322
  function readBase64(filename) {
1323
1323
  return readFileSync(filename).toString("base64");
1324
1324
  }
1325
- /** Get the filename without extension, falling back to the full basename for dotfiles. */
1325
+ /** Get the filename without extension, a dotfile keeps its whole name. */
1326
1326
  function baseNameWithoutExt(filename) {
1327
- const { name, ext } = parse(filename);
1328
- return name || ext;
1327
+ return parse(filename).name;
1329
1328
  }
1330
1329
  //#endregion
1331
1330
  //#region src/formats.ts
@@ -1345,6 +1344,89 @@ function stripSetupFunction(chunk) {
1345
1344
  for (const match of chunk.matchAll(SETUP_FUNCTION_PROPERTY)) lastIndex = match.index;
1346
1345
  return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
1347
1346
  }
1347
+ /** Index after the whitespace and `//` or block comments starting at `i`. */
1348
+ function skipTrivia(text, i) {
1349
+ let pos = i;
1350
+ for (;;) {
1351
+ const ch = text[pos];
1352
+ if (ch !== void 0 && /\s/.test(ch)) pos++;
1353
+ else if (text.startsWith("//", pos)) {
1354
+ const newline = text.indexOf("\n", pos);
1355
+ pos = newline === -1 ? text.length : newline + 1;
1356
+ } else if (text.startsWith("/*", pos)) {
1357
+ const close = text.indexOf("*/", pos + 2);
1358
+ pos = close === -1 ? text.length : close + 2;
1359
+ } else return pos;
1360
+ }
1361
+ }
1362
+ /** Index after the quoted string starting at `i` (a quote character); template literals nest `${…}`. */
1363
+ function skipString(text, i) {
1364
+ const quote = text[i];
1365
+ let pos = i + 1;
1366
+ while (pos < text.length) {
1367
+ const ch = text[pos];
1368
+ if (ch === "\\") pos += 2;
1369
+ else if (ch === quote) return pos + 1;
1370
+ else if (quote === "`" && ch === "$" && text[pos + 1] === "{") {
1371
+ const close = findClosingBrace(text, pos + 1);
1372
+ if (close === -1) return text.length;
1373
+ pos = close + 1;
1374
+ } else pos++;
1375
+ }
1376
+ return text.length;
1377
+ }
1378
+ function startsComment(text, pos) {
1379
+ return text[pos] === "/" && (text[pos + 1] === "/" || text[pos + 1] === "*");
1380
+ }
1381
+ function startsString(text, pos) {
1382
+ const ch = text[pos];
1383
+ return ch === "\"" || ch === "'" || ch === "`";
1384
+ }
1385
+ /** Index of the `}` matching the `{` at `open`, skipping comments and strings; -1 when unbalanced. */
1386
+ function findClosingBrace(text, open) {
1387
+ let depth = 0;
1388
+ let pos = open;
1389
+ while (pos < text.length) if (startsComment(text, pos)) pos = skipTrivia(text, pos);
1390
+ else if (startsString(text, pos)) pos = skipString(text, pos);
1391
+ else {
1392
+ if (text[pos] === "{") depth++;
1393
+ else if (text[pos] === "}" && --depth === 0) return pos;
1394
+ pos++;
1395
+ }
1396
+ return -1;
1397
+ }
1398
+ const STORY_FORMAT = "storyFormat";
1399
+ const IDENTIFIER_CHAR = /[\w$]/;
1400
+ /**
1401
+ * Locate the object literal passed to `storyFormat(` as `[start, end)` offsets, ignoring comments and
1402
+ * strings outside it and never evaluating anything. Without such a call, the first `{` outside comments
1403
+ * and strings starts the object. When its closing brace cannot be matched (a Harlowe setup function can
1404
+ * hold regular expression literals), the last `}` of the file ends it.
1405
+ */
1406
+ function locateFormatObject(text) {
1407
+ let start = -1;
1408
+ let firstBrace = -1;
1409
+ let pos = 0;
1410
+ while (pos < text.length && start === -1) if (startsComment(text, pos)) pos = skipTrivia(text, pos);
1411
+ else if (startsString(text, pos)) pos = skipString(text, pos);
1412
+ else if (text[pos] === "{") {
1413
+ if (firstBrace === -1) firstBrace = pos;
1414
+ pos++;
1415
+ } else if (text.startsWith(STORY_FORMAT, pos) && !IDENTIFIER_CHAR.test(text[pos - 1] ?? "")) {
1416
+ const paren = skipTrivia(text, pos + 11);
1417
+ const brace = text[paren] === "(" ? skipTrivia(text, paren + 1) : -1;
1418
+ if (text[brace] === "{") start = brace;
1419
+ pos += 11;
1420
+ } else pos++;
1421
+ if (start === -1) start = firstBrace;
1422
+ if (start === -1) return void 0;
1423
+ const close = findClosingBrace(text, start);
1424
+ const end = close === -1 ? text.lastIndexOf("}") : close;
1425
+ return end < start ? void 0 : {
1426
+ start,
1427
+ end: end + 1
1428
+ };
1429
+ }
1348
1430
  /**
1349
1431
  * Parse the format object at `start`–`end` of `text`: strict JSON first (fast), then the
1350
1432
  * JavaScript literal subset formats use. Error positions point into `text`.
@@ -1369,6 +1451,8 @@ function parseFormatObject(text, start, end) {
1369
1451
  }
1370
1452
  }
1371
1453
  /** Check a parsed format object's fields. Per spec, `name` is optional; `version` and `source` are required. */
1454
+ /** The name given to a format.js that names no format. */
1455
+ const UNNAMED_FORMAT_NAME = "Untitled Story Format";
1372
1456
  function toFormatJSON(raw) {
1373
1457
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
1374
1458
  ok: false,
@@ -1388,7 +1472,7 @@ function toFormatJSON(raw) {
1388
1472
  reason: `Story format version ${JSON.stringify(obj.version)} is not a SemVer version.`
1389
1473
  };
1390
1474
  const data = {
1391
- name: typeof obj.name === "string" ? obj.name : "Untitled Story Format",
1475
+ name: typeof obj.name === "string" ? obj.name : UNNAMED_FORMAT_NAME,
1392
1476
  version: obj.version,
1393
1477
  source: obj.source,
1394
1478
  proofing: obj.proofing === true
@@ -1413,15 +1497,14 @@ function toFormatJSON(raw) {
1413
1497
  * `harlowe-3` directory or a direct download.
1414
1498
  */
1415
1499
  function decodeFormatJSON(source) {
1416
- const first = source.indexOf("{");
1417
- const last = source.lastIndexOf("}");
1418
- if (first === -1 || last < first) return {
1500
+ const located = locateFormatObject(source);
1501
+ if (located === void 0) return {
1419
1502
  ok: false,
1420
1503
  reason: "Could not find Twine 2 style story format JSON chunk."
1421
1504
  };
1422
- const parsed = parseFormatObject(source, first, last + 1);
1505
+ const parsed = parseFormatObject(source, located.start, located.end);
1423
1506
  if (parsed.ok) return toFormatJSON(parsed.value);
1424
- const stripped = stripSetupFunction(source.slice(first, last + 1));
1507
+ const stripped = stripSetupFunction(source.slice(located.start, located.end));
1425
1508
  const retried = stripped === void 0 ? void 0 : parseFormatObject(stripped, 0, stripped.length);
1426
1509
  if (retried?.ok) return toFormatJSON(retried.value);
1427
1510
  return {
@@ -1708,16 +1791,17 @@ function readFormatSource(format, diagnostics) {
1708
1791
  /**
1709
1792
  * Writes `data` to `path` so that a reader sees either the previous file or the complete new
1710
1793
  * one, never a partly written file. The data goes to a temporary file in the same folder,
1711
- * which is then renamed over `path`. When `path` is a symlink, the file it points to is
1712
- * replaced, and an existing file keeps its permissions.
1794
+ * which is then renamed over `path`. When `path` is a symlink (even a dangling one, or a chain of links), the file it
1795
+ * finally points to is replaced or created and the link stays, and an existing file keeps its permissions.
1713
1796
  *
1714
1797
  * On failure (a full disk, a missing folder) the temporary file is removed, `path` is left
1715
1798
  * as it was, and the error is thrown with `path` in its message and its original `code`.
1716
1799
  */
1717
1800
  function writeFileAtomic(path, data) {
1718
- const target = followSymlink(path);
1719
- const temp = join(dirname(target), `.${basename(target)}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
1801
+ let temp;
1720
1802
  try {
1803
+ const target = resolveWriteTarget(path);
1804
+ temp = join(dirname(target), `.${basename(target)}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
1721
1805
  writeFileSync(temp, data, {
1722
1806
  encoding: "utf-8",
1723
1807
  flag: "wx"
@@ -1725,17 +1809,25 @@ function writeFileAtomic(path, data) {
1725
1809
  keepPermissions(target, temp);
1726
1810
  renameSync(temp, target);
1727
1811
  } catch (e) {
1728
- rmSync(temp, { force: true });
1812
+ if (temp !== void 0) rmSync(temp, { force: true });
1729
1813
  throw writeError(path, e);
1730
1814
  }
1731
1815
  }
1732
- /** The file a symlink at `path` points to, or `path` itself (also when it doesn't exist yet). */
1733
- function followSymlink(path) {
1734
- try {
1735
- return realpathSync(path);
1736
- } catch {
1737
- return path;
1816
+ /** Links followed before a path counts as a cycle (Linux stops at 40 as well). */
1817
+ const MAX_LINK_DEPTH = 40;
1818
+ /**
1819
+ * The file a symlink at `path` finally points to, or `path` itself when it is no link. Links are
1820
+ * followed one by one, so a link whose target does not exist yet (absolute, relative, or a chain
1821
+ * of them) still resolves to the file that should be created. A cycle or an unreadable link throws.
1822
+ */
1823
+ function resolveWriteTarget(path) {
1824
+ let current = path;
1825
+ for (let depth = 0; depth <= MAX_LINK_DEPTH; depth++) {
1826
+ const stat = lstatSync(current, { throwIfNoEntry: false });
1827
+ if (stat === void 0 || !stat.isSymbolicLink()) return current;
1828
+ current = resolve(dirname(current), readlinkSync(current));
1738
1829
  }
1830
+ throw Object.assign(/* @__PURE__ */ new Error(`ELOOP: too many levels of symbolic links, ${path}`), { code: "ELOOP" });
1739
1831
  }
1740
1832
  /**
1741
1833
  * Gives `temp` the permission bits of `target`, when `target` exists. Best effort: on a file
@@ -1847,31 +1939,37 @@ function clearIndexCache() {
1847
1939
  /** Requests in progress, by what they fetch, so concurrent compiles make each request once. */
1848
1940
  const sharedRequests = /* @__PURE__ */ new Map();
1849
1941
  /**
1850
- * Runs `start` once for all callers that ask for `key` while it is in progress. A caller whose
1851
- * `signal` aborts stops waiting at once and rejects with the signal's reason; the request
1852
- * itself is aborted (through the signal `start` receives) once no caller waits on it.
1942
+ * Runs `start` once for all callers that ask for `key` while it is in progress. Each caller waits
1943
+ * under its own limits: a caller whose `signal` aborts, or whose `timeout` passes, stops waiting
1944
+ * at once and rejects, while the others keep waiting. The request itself is aborted (through
1945
+ * the signal `start` receives) once no caller waits on it. `start` must not depend on any one
1946
+ * caller: what differs between callers belongs after the shared result.
1853
1947
  */
1854
- function shareRequest(key, signal, start) {
1948
+ function shareRequest(key, { signal, timeout, timedOut }, start) {
1855
1949
  if (signal?.aborted) return Promise.reject(signal.reason);
1856
1950
  const request = sharedRequests.get(key) ?? startSharedRequest(key, start);
1857
1951
  request.waiters++;
1858
1952
  return new Promise((resolve, reject) => {
1859
1953
  let waiting = true;
1954
+ let timer;
1860
1955
  const stopWaiting = () => {
1861
1956
  if (!waiting) return;
1862
1957
  waiting = false;
1863
1958
  signal?.removeEventListener("abort", onAbort);
1959
+ clearTimeout(timer);
1864
1960
  request.waiters--;
1865
1961
  };
1866
- const onAbort = () => {
1962
+ const giveUp = (reason) => {
1867
1963
  stopWaiting();
1868
1964
  if (request.waiters === 0) {
1869
1965
  if (sharedRequests.get(key) === request) sharedRequests.delete(key);
1870
- request.controller.abort(signal?.reason);
1966
+ request.controller.abort(reason);
1871
1967
  }
1872
- reject(signal?.reason);
1968
+ reject(reason);
1873
1969
  };
1970
+ const onAbort = () => giveUp(signal?.reason);
1874
1971
  signal?.addEventListener("abort", onAbort, { once: true });
1972
+ if (timeout > 0 && timeout <= MAX_TIMER_DELAY) timer = setTimeout(() => giveUp(timedOut()), timeout);
1875
1973
  request.promise.then((value) => {
1876
1974
  stopWaiting();
1877
1975
  resolve(value);
@@ -1901,20 +1999,32 @@ function requestTimeout(options) {
1901
1999
  if (!(timeout >= 0)) throw new RangeError(`A story format request timeout must be 0 or more milliseconds, not ${timeout}`);
1902
2000
  return timeout;
1903
2001
  }
2002
+ /** How one caller waits for `url`: under its own signal and timeout. `what` names the request in errors. */
2003
+ function waitOptions(options, what, url) {
2004
+ const timeout = requestTimeout(options);
2005
+ return {
2006
+ signal: options.signal,
2007
+ timeout,
2008
+ timedOut: () => /* @__PURE__ */ new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`)
2009
+ };
2010
+ }
1904
2011
  /**
1905
- * Fetch `url` as text, aborting when `signal` does or after `timeout` milliseconds (0: no limit).
1906
- * `what` names the request in errors, as in "Failed to <what> from <url>".
2012
+ * Fetch `url` as bytes, aborting when `signal` does. `what` names the request in errors, as in
2013
+ * "Failed to <what> from <url>". The bytes are returned undecoded, so checksums cover what was served.
1907
2014
  */
1908
- async function fetchText(url, what, signal, timeout) {
1909
- const limit = timeout > 0 && timeout <= MAX_TIMER_DELAY ? AbortSignal.timeout(timeout) : void 0;
1910
- try {
1911
- const res = await fetch(url, { signal: limit ? AbortSignal.any([signal, limit]) : signal });
1912
- if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
1913
- return await res.text();
1914
- } catch (e) {
1915
- if (limit?.aborted && !signal.aborted) throw new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`, { cause: e });
1916
- throw e;
1917
- }
2015
+ async function fetchBytes(url, what, signal) {
2016
+ const res = await fetch(url, { signal });
2017
+ if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
2018
+ return new Uint8Array(await res.arrayBuffer());
2019
+ }
2020
+ /** Download a format.js once for every caller in this process that asks for `url` meanwhile. */
2021
+ function shareDownload(url, options) {
2022
+ return shareRequest(`download\0${url}`, waitOptions(options, "download format", url), (signal) => fetchBytes(url, "download format", signal));
2023
+ }
2024
+ /** Decode a downloaded format.js as local ones are (UTF-8, else Windows-1252), without a leading BOM. */
2025
+ function decodeDownload(bytes, url) {
2026
+ const { text } = decodeText(bytes, url);
2027
+ return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
1918
2028
  }
1919
2029
  /** Validate that a JSON value is a valid SFAIndexEntry. */
1920
2030
  function isValidEntry(val) {
@@ -1936,9 +2046,8 @@ async function fetchIndex(url, options = {}) {
1936
2046
  options.signal?.throwIfAborted();
1937
2047
  const cached = indexCache.get(url);
1938
2048
  if (cached) return cached;
1939
- const timeout = requestTimeout(options);
1940
- return shareRequest(`index\0${url}`, options.signal, async (signal) => {
1941
- const text = await fetchText(url, "fetch format index", signal, timeout);
2049
+ return shareRequest(`index\0${url}`, waitOptions(options, "fetch format index", url), async (signal) => {
2050
+ const text = decodeDownload(await fetchBytes(url, "fetch format index", signal), url);
1942
2051
  const data = validateSFAIndex(JSON.parse(text));
1943
2052
  indexCache.set(url, data);
1944
2053
  return data;
@@ -1975,26 +2084,37 @@ function cachedFormatInfo(id, filename, data) {
1975
2084
  };
1976
2085
  }
1977
2086
  /**
1978
- * Download a format, verify its checksum, write it to the cache shared by name and version, and
1979
- * return its StoryFormatInfo. Concurrent calls for one download URL share one request.
2087
+ * Check that a downloaded format is the one the index entry promised. The name matches without
2088
+ * regard to case, as format requests do, and the version by SemVer precedence, so "1.0" and "v1.0.0"
2089
+ * agree. A format.js that names no format ({@link UNNAMED_FORMAT_NAME}) is allowed: such formats
2090
+ * exist, and the index entry then supplies the name it is cached under.
2091
+ */
2092
+ function checkFormatIdentity(entry, data, downloadUrl) {
2093
+ if ((data.name === "Untitled Story Format" || data.name.toLowerCase() === entry.name.toLowerCase()) && sameVersion(data.version, entry.version)) return;
2094
+ throw new Error(`Story format mismatch for ${downloadUrl}: the index lists ${entry.name} ${entry.version}, but the download is ${data.name} ${data.version}`);
2095
+ }
2096
+ /**
2097
+ * Download a format, verify its checksum, check that it is the format the entry names, write it to
2098
+ * the cache shared by name and version, and return its StoryFormatInfo. Concurrent calls for one
2099
+ * download URL share one request for the bytes; each call then verifies them against its own
2100
+ * entry, within its own timeout.
1980
2101
  */
1981
2102
  async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
1982
2103
  cachedFormatDir(entry.name, entry.version);
1983
- const timeout = requestTimeout(options);
1984
- return shareRequest(`format\0${getCacheDir()}\0${downloadUrl}`, options.signal, async (signal) => {
1985
- const text = await fetchText(downloadUrl, "download format", signal, timeout);
1986
- const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
1987
- if (checksumKey) {
1988
- const expected = entry.checksums[checksumKey];
1989
- if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
1990
- if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
1991
- }
1992
- const id = makeFormatId(entry.name, entry.version);
1993
- const data = parseFormatJSON(text, id);
1994
- if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
1995
- signal.throwIfAborted();
1996
- return cachedFormatInfo(id, writeCacheEntry(resolve(getCacheDir()), [entry.name, entry.version], text), data);
1997
- });
2104
+ const bytes = await shareDownload(downloadUrl, options);
2105
+ const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
2106
+ if (checksumKey) {
2107
+ const expected = entry.checksums[checksumKey];
2108
+ if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
2109
+ if (!await verifySHA256(bytes, expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
2110
+ }
2111
+ const text = decodeDownload(bytes, downloadUrl);
2112
+ const id = makeFormatId(entry.name, entry.version);
2113
+ const data = parseFormatJSON(text, id);
2114
+ if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
2115
+ checkFormatIdentity(entry, data, downloadUrl);
2116
+ options.signal?.throwIfAborted();
2117
+ return cachedFormatInfo(id, writeCacheEntry(resolve(getCacheDir()), [entry.name, entry.version], text), data);
1998
2118
  }
1999
2119
  /**
2000
2120
  * Download a direct format.js URL, parse its JSON, cache it under that URL (apart from the
@@ -2002,16 +2122,13 @@ async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
2002
2122
  * one URL share one request.
2003
2123
  */
2004
2124
  async function fetchDirectFormat(url, options = {}) {
2005
- const timeout = requestTimeout(options);
2006
- return shareRequest(`url\0${getUrlCacheDir()}\0${url}`, options.signal, async (signal) => {
2007
- const text = await fetchText(url, "download format", signal, timeout);
2008
- const data = parseFormatJSON(text);
2009
- if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
2010
- cachedFormatDir(data.name, data.version);
2011
- signal.throwIfAborted();
2012
- const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
2013
- return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
2014
- });
2125
+ const text = decodeDownload(await shareDownload(url, options), url);
2126
+ const data = parseFormatJSON(text);
2127
+ if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
2128
+ cachedFormatDir(data.name, data.version);
2129
+ options.signal?.throwIfAborted();
2130
+ const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
2131
+ return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
2015
2132
  }
2016
2133
  /** The copy of a direct format URL in the download cache, if it has been downloaded before. */
2017
2134
  function getCachedDirectFormat(url) {
@@ -2052,6 +2169,16 @@ async function resolveFormatUrls(request, urls, options = {}) {
2052
2169
  }
2053
2170
  if (lastError) throw lastError;
2054
2171
  }
2172
+ /**
2173
+ * Find the best format among the cached copies of direct format URLs, without network access.
2174
+ * Matching follows {@link selectFormatCandidate}, so `allowOlder` admits a same-major older version.
2175
+ * Each URL keeps its own copy; the shared name and version downloads are never consulted.
2176
+ *
2177
+ * Internal, for format resolution; not part of the public API.
2178
+ */
2179
+ function findCachedUrlFormat(request, urls, options = {}) {
2180
+ return selectFormatCandidate(request, urls.flatMap((url) => getCachedDirectFormat(url) ?? []), (f) => f, options);
2181
+ }
2055
2182
  /** Whether `info` is a format the request accepts. */
2056
2183
  function answers(request, info) {
2057
2184
  return selectFormatCandidate(request, [info], (f) => f) !== void 0;
@@ -2187,8 +2314,8 @@ function findLocalFormat(all, pruned, request) {
2187
2314
  * then format indices (unless `noRemote`). Names and IDs match the same way in each (see
2188
2315
  * `selectFormatCandidate`), so a request that a local format answers never reaches the cache or the
2189
2316
  * network. Local formats that cannot be used are reported as warnings. When a StoryData request
2190
- * names a version that none of them has, an older version of
2191
- * the same format and major version is used with a warning. Anything else is an error diagnostic,
2317
+ * names a version that none of them has, an older version of the same format and major version
2318
+ * is used with a warning, from local formats, the project's cached format URLs, or the shared cache. Anything else is an error diagnostic,
2192
2319
  * and the result is `undefined`.
2193
2320
  */
2194
2321
  async function resolveStoryFormat(request, options, diagnostics) {
@@ -2226,7 +2353,7 @@ async function resolveStoryFormat(request, options, diagnostics) {
2226
2353
  if (remote) return remote;
2227
2354
  }
2228
2355
  if (request.kind === "name") {
2229
- const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
2356
+ const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedUrlFormat(request, options.formatUrls ?? [], { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
2230
2357
  if (older) {
2231
2358
  diagnostics.push({
2232
2359
  level: "warning",
@@ -2475,8 +2602,6 @@ function* tweeLexer(input) {
2475
2602
  const items = ctx.drainItems();
2476
2603
  for (const item of items) yield item;
2477
2604
  }
2478
- const remaining = ctx.drainItems();
2479
- for (const item of remaining) yield item;
2480
2605
  }
2481
2606
  //#endregion
2482
2607
  //#region src/twee2-compat.ts
@@ -2603,9 +2728,7 @@ function parseTwee(source, options = {}) {
2603
2728
  };
2604
2729
  }
2605
2730
  try {
2606
- const raw = JSON.parse(item.val);
2607
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error("expected a JSON object");
2608
- const parsed = raw;
2731
+ const parsed = JSON.parse(item.val);
2609
2732
  const meta = {};
2610
2733
  for (const [key, value] of Object.entries(parsed)) if (typeof value === "string") meta[key] = value;
2611
2734
  current.metadata = meta;
@@ -5850,8 +5973,8 @@ function loadFont(story, filename, diagnostics) {
5850
5973
  * Load sources with mtime-based caching for incremental rebuilds.
5851
5974
  * Always creates a fresh Story; cached passages are replayed via storyAdd().
5852
5975
  */
5853
- function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
5854
- const currentFiles = /* @__PURE__ */ new Set();
5976
+ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles, buildFiles) {
5977
+ const currentFiles = new Set(buildFiles);
5855
5978
  const changedPaths = changedFiles === void 0 ? void 0 : new Set([...changedFiles].map((f) => resolve(f)));
5856
5979
  for (const filename of filenames) {
5857
5980
  if (processedFiles.has(filename)) {
@@ -5916,16 +6039,6 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5916
6039
  }
5917
6040
  //#endregion
5918
6041
  //#region src/template.ts
5919
- /**
5920
- * An HTML closing head tag: `</head` in any letter case, followed by whitespace, `/` or `>`, up to the next `>`.
5921
- * This is how the HTML tokenizer reads an end tag; it ignores whatever sits between the tag name and the `>`.
5922
- */
5923
- const CLOSING_HEAD_TAG = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>";
5924
- /**
5925
- * The start of an HTML body start tag: `<body` in any letter case, followed by whitespace, `/` or `>` (so not
5926
- * `<bodyx>`). Only the `<body` is matched; the attributes and `>` after it are left alone.
5927
- */
5928
- const BODY_START_TAG = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
5929
6042
  /** The regular expression source matching exactly `text`. */
5930
6043
  function literal(text) {
5931
6044
  return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
@@ -5951,6 +6064,8 @@ function fillTemplateParts(parts) {
5951
6064
  return part.text.replace(pattern, (match, ...groups) => {
5952
6065
  const slot = slots.find((_, i) => groups[i] !== void 0);
5953
6066
  if (slot === void 0 || slot.occurrences === "first" && filled.has(slot)) return match;
6067
+ const offset = groups[slots.length];
6068
+ if (slot.accepts !== void 0 && typeof offset === "number" && !slot.accepts(part.text, offset)) return match;
5954
6069
  filled.add(slot);
5955
6070
  return slot.replacement(match);
5956
6071
  });
@@ -6041,15 +6156,18 @@ function placeHead(content, texts, owner) {
6041
6156
  slot: void 0,
6042
6157
  diagnostics: []
6043
6158
  };
6044
- const found = (tag) => texts.some((text) => new RegExp(tag).test(text));
6045
- if (found("<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>")) return {
6046
- slot: beforeTag(CLOSING_HEAD_TAG, content),
6159
+ const scans = texts.map((text) => ({
6160
+ text,
6161
+ tags: scanHeadTags(text)
6162
+ }));
6163
+ if (scans.some(({ tags }) => tags.closingHead !== void 0)) return {
6164
+ slot: beforeTag(CLOSING_HEAD_PREFIX, "closingHead", content),
6047
6165
  diagnostics: []
6048
6166
  };
6049
- if (found("<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])")) {
6167
+ if (scans.some(({ tags }) => tags.bodyStart !== void 0)) {
6050
6168
  const message = `${owner} has no closing head tag; the modules and head file were injected before its body start tag.`;
6051
6169
  return {
6052
- slot: beforeTag(BODY_START_TAG, content),
6170
+ slot: beforeTag(BODY_START_PREFIX, "bodyStart", content),
6053
6171
  diagnostics: [{
6054
6172
  level: "warning",
6055
6173
  message
@@ -6064,12 +6182,115 @@ function placeHead(content, texts, owner) {
6064
6182
  }]
6065
6183
  };
6066
6184
  }
6067
- /** The slot that inserts `content` on its own line before the first match of `tag`, which is kept as written. */
6068
- function beforeTag(tag, content) {
6185
+ /** `</head` in any letter case, followed by whitespace, `/` or `>`; the rest of the tag is left alone. */
6186
+ const CLOSING_HEAD_PREFIX = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])";
6187
+ /** `<body` in any letter case, followed by whitespace, `/` or `>` (so not `<bodyx>`). */
6188
+ const BODY_START_PREFIX = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
6189
+ /** Elements whose content is text, so a tag written there is not a tag. */
6190
+ const RAW_TEXT_ELEMENTS = /* @__PURE__ */ new Set([
6191
+ "script",
6192
+ "style",
6193
+ "textarea",
6194
+ "title"
6195
+ ]);
6196
+ const isSpace = (c) => c === " " || c === " " || c === "\n" || c === "\f" || c === "\r";
6197
+ const isLetter = (c) => c !== void 0 && /^[A-Za-z]$/.test(c);
6198
+ /**
6199
+ * Find the first closing head tag and body start tag that HTML would read as tags: not those in a comment, in
6200
+ * the text of a script, style, textarea or title element, or in a quoted attribute value. A small scanner, not
6201
+ * a full HTML tokenizer; it needs no more than the tags and where they end.
6202
+ */
6203
+ function scanHeadTags(html) {
6204
+ const { closingHead, bodyStart } = scanTags(html);
6069
6205
  return {
6070
- pattern: tag,
6206
+ closingHead,
6207
+ bodyStart
6208
+ };
6209
+ }
6210
+ /**
6211
+ * The offset just after the first real head start tag, where content goes to come first in the head; found like
6212
+ * the tags of `scanHeadTags()`, so not one in a comment, a script or an attribute value. `undefined` without one.
6213
+ */
6214
+ function findHeadStartEnd(html) {
6215
+ return scanTags(html).headStartEnd;
6216
+ }
6217
+ function scanTags(html) {
6218
+ let closingHead;
6219
+ let bodyStart;
6220
+ let headStartEnd;
6221
+ let i = 0;
6222
+ while (i < html.length) {
6223
+ const lt = html.indexOf("<", i);
6224
+ if (lt === -1) break;
6225
+ const next = html[lt + 1];
6226
+ if (html.startsWith("<!--", lt)) {
6227
+ const end = html.indexOf("-->", lt + 4);
6228
+ i = end === -1 ? html.length : end + 3;
6229
+ } else if (next === "!" || next === "?") {
6230
+ const end = html.indexOf(">", lt);
6231
+ i = end === -1 ? html.length : end + 1;
6232
+ } else if (next === "/" && isLetter(html[lt + 2])) {
6233
+ const { name, end } = readTag(html, lt + 2);
6234
+ if (name === "head" && closingHead === void 0) closingHead = lt;
6235
+ i = end;
6236
+ } else if (isLetter(next)) {
6237
+ const { name, end } = readTag(html, lt + 1);
6238
+ if (name === "body" && bodyStart === void 0) bodyStart = lt;
6239
+ if (name === "head" && headStartEnd === void 0) headStartEnd = end;
6240
+ i = RAW_TEXT_ELEMENTS.has(name) ? rawTextEnd(html, name, end) : end;
6241
+ } else i = lt + 1;
6242
+ if (closingHead !== void 0 && bodyStart !== void 0) break;
6243
+ }
6244
+ return {
6245
+ closingHead,
6246
+ bodyStart,
6247
+ headStartEnd
6248
+ };
6249
+ }
6250
+ /** Read a tag whose name starts at `from`: its lowercase name, and the offset after its `>` (or the end). */
6251
+ function readTag(html, from) {
6252
+ let i = from;
6253
+ while (i < html.length && !isSpace(html[i]) && html[i] !== "/" && html[i] !== ">") i++;
6254
+ const name = html.slice(from, i).toLowerCase();
6255
+ while (i < html.length && html[i] !== ">") {
6256
+ if (html[i] !== "=") {
6257
+ i++;
6258
+ continue;
6259
+ }
6260
+ i++;
6261
+ while (isSpace(html[i])) i++;
6262
+ const quote = html[i];
6263
+ if (quote === "\"" || quote === "'") {
6264
+ const close = html.indexOf(quote, i + 1);
6265
+ i = close === -1 ? html.length : close + 1;
6266
+ }
6267
+ }
6268
+ return {
6269
+ name,
6270
+ end: Math.min(i + 1, html.length)
6271
+ };
6272
+ }
6273
+ /** The offset of the end tag that closes a raw text element, given where its text starts (or the end). */
6274
+ function rawTextEnd(html, name, from) {
6275
+ const close = new RegExp(`</${name}(?=[\\t\\n\\f\\r />])`, "gi");
6276
+ close.lastIndex = from;
6277
+ return close.exec(html)?.index ?? html.length;
6278
+ }
6279
+ /**
6280
+ * The slot that inserts `content` on its own line before the first real tag of the kind `which` names (see
6281
+ * `scanHeadTags()`), which is kept as written. A look-alike inside a script or comment is left alone.
6282
+ */
6283
+ function beforeTag(pattern, which, content) {
6284
+ const cache = /* @__PURE__ */ new Map();
6285
+ const offsetIn = (text) => {
6286
+ if (!cache.has(text)) cache.set(text, scanHeadTags(text)[which]);
6287
+ return cache.get(text);
6288
+ };
6289
+ return {
6290
+ pattern,
6071
6291
  occurrences: "first",
6072
- replacement: (match) => `${content}\n${match}`
6292
+ replacement: (match) => `${content}\n${match}`,
6293
+ accepts: (text, offset) => offsetIn(text) === offset
6073
6294
  };
6074
6295
  }
6075
6296
  //#endregion
@@ -6532,34 +6753,61 @@ async function buildOutput(options, cache, changedFiles, outputs) {
6532
6753
  const timeout = options.formatFetchTimeout;
6533
6754
  if (timeout !== void 0 && !(timeout >= 0)) throw new TweeTsError(`formatFetchTimeout must be 0 or more milliseconds, not ${timeout}.`);
6534
6755
  clearIndexCache();
6535
- const filePaths = [];
6536
- const inlineSources = [];
6537
- for (const source of options.sources) if (typeof source === "string") filePaths.push(source);
6538
- else inlineSources.push(source);
6539
- const sourcePaths = getFilenames(filePaths, written, options.exclude);
6540
- diagnostics.push(...sourcePaths.diagnostics);
6541
- rejectOutputSources(sourcePaths.outputSources, diagnostics);
6542
- const sourceFilenames = sourcePaths.filenames;
6756
+ const groups = [];
6757
+ const outputSources = [];
6758
+ for (const source of options.sources) {
6759
+ const last = groups[groups.length - 1];
6760
+ if (typeof source === "string") {
6761
+ if (last?.kind === "paths") last.paths.push(source);
6762
+ else groups.push({
6763
+ kind: "paths",
6764
+ paths: [source]
6765
+ });
6766
+ } else if (last?.kind === "inline") last.sources.push(source);
6767
+ else groups.push({
6768
+ kind: "inline",
6769
+ sources: [source]
6770
+ });
6771
+ }
6772
+ const walked = groups.map((group) => {
6773
+ if (group.kind === "inline") return group;
6774
+ const found = getFilenames(group.paths, written, options.exclude);
6775
+ diagnostics.push(...found.diagnostics);
6776
+ outputSources.push(...found.outputSources);
6777
+ return {
6778
+ kind: "files",
6779
+ filenames: found.filenames
6780
+ };
6781
+ });
6782
+ rejectOutputSources(outputSources, diagnostics);
6783
+ const buildFiles = new Set(walked.flatMap((group) => group.kind === "files" ? group.filenames : []));
6543
6784
  const story = createStory();
6544
6785
  const processedFiles = /* @__PURE__ */ new Set();
6545
- if (cache) loadSourcesCached(story, sourceFilenames, {
6786
+ for (const group of walked) if (group.kind === "inline") loadInlineSources(story, group.sources, {
6546
6787
  trim,
6547
6788
  twee2Compat
6548
- }, diagnostics, processedFiles, cache, changedFiles);
6549
- else loadSources(story, sourceFilenames, {
6789
+ }, diagnostics);
6790
+ else if (cache) loadSourcesCached(story, group.filenames, {
6791
+ trim,
6792
+ twee2Compat
6793
+ }, diagnostics, processedFiles, cache, changedFiles, buildFiles);
6794
+ else loadSources(story, group.filenames, {
6550
6795
  trim,
6551
6796
  twee2Compat
6552
6797
  }, diagnostics, processedFiles);
6553
- loadInlineSources(story, inlineSources, {
6798
+ if (cache && buildFiles.size === 0) loadSourcesCached(story, [], {
6554
6799
  trim,
6555
6800
  twee2Compat
6556
- }, diagnostics);
6801
+ }, diagnostics, processedFiles, cache, changedFiles);
6557
6802
  if (options.tagAliases) story.passages = applyTagAliases(story.passages, options.tagAliases);
6558
6803
  let format;
6559
- if (outputMode === "html") format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
6560
- ...options,
6561
- noRemote
6562
- }, diagnostics);
6804
+ if (outputMode === "html") {
6805
+ format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
6806
+ ...options,
6807
+ noRemote
6808
+ }, diagnostics);
6809
+ options.signal?.throwIfAborted();
6810
+ }
6563
6811
  if (options.startPassage) story.twine2.start = options.startPassage;
6564
6812
  const startName = story.twine2.start || DEFAULT_START_NAME;
6565
6813
  if (testMode) story.twine2.options.set("debug", true);
@@ -6611,6 +6859,7 @@ async function buildOutput(options, cache, changedFiles, outputs) {
6611
6859
  ...getStoryStats(story, options.wordCountMethod),
6612
6860
  files: [...processedFiles]
6613
6861
  };
6862
+ options.signal?.throwIfAborted();
6614
6863
  return {
6615
6864
  output,
6616
6865
  story,
@@ -6790,6 +7039,6 @@ function isViteConfigTemp(file) {
6790
7039
  return /\.timestamp-\d+-[0-9a-f]*\.mjs$/.test(file);
6791
7040
  }
6792
7041
  //#endregion
6793
- export { toPosix as a, splitDiagnostics as c, isExcluded as d, outputPaths as f, mediaTypeFromFilename as h, outputLocations as i, compileForOutputFile as l, walkedEntry as m, isInside as n, fatalError as o, realPathOf as p, isViteConfigTemp as r, formatDiagnostic as s, createOutputRecord as t, getFilenames as u };
7042
+ export { toPosix as a, splitDiagnostics as c, getFilenames as d, isExcluded as f, mediaTypeFromFilename as g, walkedEntry as h, outputLocations as i, compileForOutputFile as l, realPathOf as m, isInside as n, fatalError as o, outputPaths as p, isViteConfigTemp as r, formatDiagnostic as s, createOutputRecord as t, findHeadStartEnd as u };
6794
7043
 
6795
- //# sourceMappingURL=paths-DgO9oo6G.js.map
7044
+ //# sourceMappingURL=paths-BuTAg2av.js.map