@rohal12/twee-ts 1.18.0 → 1.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1600,10 +1600,9 @@ function readUTF8(filename, diagnostics) {
1600
1600
  function readBase64(filename) {
1601
1601
  return (0, node_fs.readFileSync)(filename).toString("base64");
1602
1602
  }
1603
- /** Get the filename without extension, falling back to the full basename for dotfiles. */
1603
+ /** Get the filename without extension, a dotfile keeps its whole name. */
1604
1604
  function baseNameWithoutExt(filename) {
1605
- const { name, ext } = (0, node_path.parse)(filename);
1606
- return name || ext;
1605
+ return (0, node_path.parse)(filename).name;
1607
1606
  }
1608
1607
  //#endregion
1609
1608
  //#region src/formats.ts
@@ -1647,6 +1646,8 @@ function parseFormatObject(text, start, end) {
1647
1646
  }
1648
1647
  }
1649
1648
  /** Check a parsed format object's fields. Per spec, `name` is optional; `version` and `source` are required. */
1649
+ /** The name given to a format.js that names no format. */
1650
+ const UNNAMED_FORMAT_NAME = "Untitled Story Format";
1650
1651
  function toFormatJSON(raw) {
1651
1652
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
1652
1653
  ok: false,
@@ -1666,7 +1667,7 @@ function toFormatJSON(raw) {
1666
1667
  reason: `Story format version ${JSON.stringify(obj.version)} is not a SemVer version.`
1667
1668
  };
1668
1669
  const data = {
1669
- name: typeof obj.name === "string" ? obj.name : "Untitled Story Format",
1670
+ name: typeof obj.name === "string" ? obj.name : UNNAMED_FORMAT_NAME,
1670
1671
  version: obj.version,
1671
1672
  source: obj.source,
1672
1673
  proofing: obj.proofing === true
@@ -2142,31 +2143,37 @@ function clearIndexCache() {
2142
2143
  /** Requests in progress, by what they fetch, so concurrent compiles make each request once. */
2143
2144
  const sharedRequests = /* @__PURE__ */ new Map();
2144
2145
  /**
2145
- * Runs `start` once for all callers that ask for `key` while it is in progress. A caller whose
2146
- * `signal` aborts stops waiting at once and rejects with the signal's reason; the request
2147
- * itself is aborted (through the signal `start` receives) once no caller waits on it.
2146
+ * Runs `start` once for all callers that ask for `key` while it is in progress. Each caller waits
2147
+ * under its own limits: a caller whose `signal` aborts, or whose `timeout` passes, stops waiting
2148
+ * at once and rejects, while the others keep waiting. The request itself is aborted (through
2149
+ * the signal `start` receives) once no caller waits on it. `start` must not depend on any one
2150
+ * caller: what differs between callers belongs after the shared result.
2148
2151
  */
2149
- function shareRequest(key, signal, start) {
2152
+ function shareRequest(key, { signal, timeout, timedOut }, start) {
2150
2153
  if (signal?.aborted) return Promise.reject(signal.reason);
2151
2154
  const request = sharedRequests.get(key) ?? startSharedRequest(key, start);
2152
2155
  request.waiters++;
2153
2156
  return new Promise((resolve, reject) => {
2154
2157
  let waiting = true;
2158
+ let timer;
2155
2159
  const stopWaiting = () => {
2156
2160
  if (!waiting) return;
2157
2161
  waiting = false;
2158
2162
  signal?.removeEventListener("abort", onAbort);
2163
+ clearTimeout(timer);
2159
2164
  request.waiters--;
2160
2165
  };
2161
- const onAbort = () => {
2166
+ const giveUp = (reason) => {
2162
2167
  stopWaiting();
2163
2168
  if (request.waiters === 0) {
2164
2169
  if (sharedRequests.get(key) === request) sharedRequests.delete(key);
2165
- request.controller.abort(signal?.reason);
2170
+ request.controller.abort(reason);
2166
2171
  }
2167
- reject(signal?.reason);
2172
+ reject(reason);
2168
2173
  };
2174
+ const onAbort = () => giveUp(signal?.reason);
2169
2175
  signal?.addEventListener("abort", onAbort, { once: true });
2176
+ if (timeout > 0 && timeout <= MAX_TIMER_DELAY) timer = setTimeout(() => giveUp(timedOut()), timeout);
2170
2177
  request.promise.then((value) => {
2171
2178
  stopWaiting();
2172
2179
  resolve(value);
@@ -2196,20 +2203,32 @@ function requestTimeout(options) {
2196
2203
  if (!(timeout >= 0)) throw new RangeError(`A story format request timeout must be 0 or more milliseconds, not ${timeout}`);
2197
2204
  return timeout;
2198
2205
  }
2206
+ /** How one caller waits for `url`: under its own signal and timeout. `what` names the request in errors. */
2207
+ function waitOptions(options, what, url) {
2208
+ const timeout = requestTimeout(options);
2209
+ return {
2210
+ signal: options.signal,
2211
+ timeout,
2212
+ timedOut: () => /* @__PURE__ */ new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`)
2213
+ };
2214
+ }
2199
2215
  /**
2200
- * Fetch `url` as text, aborting when `signal` does or after `timeout` milliseconds (0: no limit).
2201
- * `what` names the request in errors, as in "Failed to <what> from <url>".
2216
+ * Fetch `url` as bytes, aborting when `signal` does. `what` names the request in errors, as in
2217
+ * "Failed to <what> from <url>". The bytes are returned undecoded, so checksums cover what was served.
2202
2218
  */
2203
- async function fetchText(url, what, signal, timeout) {
2204
- const limit = timeout > 0 && timeout <= MAX_TIMER_DELAY ? AbortSignal.timeout(timeout) : void 0;
2205
- try {
2206
- const res = await fetch(url, { signal: limit ? AbortSignal.any([signal, limit]) : signal });
2207
- if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
2208
- return await res.text();
2209
- } catch (e) {
2210
- if (limit?.aborted && !signal.aborted) throw new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`, { cause: e });
2211
- throw e;
2212
- }
2219
+ async function fetchBytes(url, what, signal) {
2220
+ const res = await fetch(url, { signal });
2221
+ if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
2222
+ return new Uint8Array(await res.arrayBuffer());
2223
+ }
2224
+ /** Download a format.js once for every caller in this process that asks for `url` meanwhile. */
2225
+ function shareDownload(url, options) {
2226
+ return shareRequest(`download\0${url}`, waitOptions(options, "download format", url), (signal) => fetchBytes(url, "download format", signal));
2227
+ }
2228
+ /** Decode a downloaded format.js as local ones are (UTF-8, else Windows-1252), without a leading BOM. */
2229
+ function decodeDownload(bytes, url) {
2230
+ const { text } = decodeText(bytes, url);
2231
+ return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
2213
2232
  }
2214
2233
  /** Validate that a JSON value is a valid SFAIndexEntry. */
2215
2234
  function isValidEntry(val) {
@@ -2231,9 +2250,8 @@ async function fetchIndex(url, options = {}) {
2231
2250
  options.signal?.throwIfAborted();
2232
2251
  const cached = indexCache.get(url);
2233
2252
  if (cached) return cached;
2234
- const timeout = requestTimeout(options);
2235
- return shareRequest(`index\0${url}`, options.signal, async (signal) => {
2236
- const text = await fetchText(url, "fetch format index", signal, timeout);
2253
+ return shareRequest(`index\0${url}`, waitOptions(options, "fetch format index", url), async (signal) => {
2254
+ const text = decodeDownload(await fetchBytes(url, "fetch format index", signal), url);
2237
2255
  const data = validateSFAIndex(JSON.parse(text));
2238
2256
  indexCache.set(url, data);
2239
2257
  return data;
@@ -2270,26 +2288,37 @@ function cachedFormatInfo(id, filename, data) {
2270
2288
  };
2271
2289
  }
2272
2290
  /**
2273
- * Download a format, verify its checksum, write it to the cache shared by name and version, and
2274
- * return its StoryFormatInfo. Concurrent calls for one download URL share one request.
2291
+ * Check that a downloaded format is the one the index entry promised. The name matches without
2292
+ * regard to case, as format requests do, and the version by SemVer precedence, so "1.0" and "v1.0.0"
2293
+ * agree. A format.js that names no format ({@link UNNAMED_FORMAT_NAME}) is allowed: such formats
2294
+ * exist, and the index entry then supplies the name it is cached under.
2295
+ */
2296
+ function checkFormatIdentity(entry, data, downloadUrl) {
2297
+ if ((data.name === "Untitled Story Format" || data.name.toLowerCase() === entry.name.toLowerCase()) && sameVersion(data.version, entry.version)) return;
2298
+ throw new Error(`Story format mismatch for ${downloadUrl}: the index lists ${entry.name} ${entry.version}, but the download is ${data.name} ${data.version}`);
2299
+ }
2300
+ /**
2301
+ * Download a format, verify its checksum, check that it is the format the entry names, write it to
2302
+ * the cache shared by name and version, and return its StoryFormatInfo. Concurrent calls for one
2303
+ * download URL share one request for the bytes; each call then verifies them against its own
2304
+ * entry, within its own timeout.
2275
2305
  */
2276
2306
  async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
2277
2307
  cachedFormatDir(entry.name, entry.version);
2278
- const timeout = requestTimeout(options);
2279
- return shareRequest(`format\0${getCacheDir()}\0${downloadUrl}`, options.signal, async (signal) => {
2280
- const text = await fetchText(downloadUrl, "download format", signal, timeout);
2281
- const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
2282
- if (checksumKey) {
2283
- const expected = entry.checksums[checksumKey];
2284
- if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
2285
- if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
2286
- }
2287
- const id = makeFormatId(entry.name, entry.version);
2288
- const data = parseFormatJSON(text, id);
2289
- if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
2290
- signal.throwIfAborted();
2291
- return cachedFormatInfo(id, writeCacheEntry((0, node_path.resolve)(getCacheDir()), [entry.name, entry.version], text), data);
2292
- });
2308
+ const bytes = await shareDownload(downloadUrl, options);
2309
+ const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
2310
+ if (checksumKey) {
2311
+ const expected = entry.checksums[checksumKey];
2312
+ if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
2313
+ if (!await verifySHA256(bytes, expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
2314
+ }
2315
+ const text = decodeDownload(bytes, downloadUrl);
2316
+ const id = makeFormatId(entry.name, entry.version);
2317
+ const data = parseFormatJSON(text, id);
2318
+ if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
2319
+ checkFormatIdentity(entry, data, downloadUrl);
2320
+ options.signal?.throwIfAborted();
2321
+ return cachedFormatInfo(id, writeCacheEntry((0, node_path.resolve)(getCacheDir()), [entry.name, entry.version], text), data);
2293
2322
  }
2294
2323
  /**
2295
2324
  * Download a direct format.js URL, parse its JSON, cache it under that URL (apart from the
@@ -2297,16 +2326,13 @@ async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
2297
2326
  * one URL share one request.
2298
2327
  */
2299
2328
  async function fetchDirectFormat(url, options = {}) {
2300
- const timeout = requestTimeout(options);
2301
- return shareRequest(`url\0${getUrlCacheDir()}\0${url}`, options.signal, async (signal) => {
2302
- const text = await fetchText(url, "download format", signal, timeout);
2303
- const data = parseFormatJSON(text);
2304
- if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
2305
- cachedFormatDir(data.name, data.version);
2306
- signal.throwIfAborted();
2307
- const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
2308
- return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
2309
- });
2329
+ const text = decodeDownload(await shareDownload(url, options), url);
2330
+ const data = parseFormatJSON(text);
2331
+ if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
2332
+ cachedFormatDir(data.name, data.version);
2333
+ options.signal?.throwIfAborted();
2334
+ const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
2335
+ return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
2310
2336
  }
2311
2337
  /** The copy of a direct format URL in the download cache, if it has been downloaded before. */
2312
2338
  function getCachedDirectFormat(url) {
@@ -2887,8 +2913,6 @@ function* tweeLexer(input) {
2887
2913
  const items = ctx.drainItems();
2888
2914
  for (const item of items) yield item;
2889
2915
  }
2890
- const remaining = ctx.drainItems();
2891
- for (const item of remaining) yield item;
2892
2916
  }
2893
2917
  /** Convenience class wrapper for the lexer generator. */
2894
2918
  var TweeLexer = class {
@@ -3041,9 +3065,7 @@ function parseTwee(source, options = {}) {
3041
3065
  };
3042
3066
  }
3043
3067
  try {
3044
- const raw = JSON.parse(item.val);
3045
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error("expected a JSON object");
3046
- const parsed = raw;
3068
+ const parsed = JSON.parse(item.val);
3047
3069
  const meta = {};
3048
3070
  for (const [key, value] of Object.entries(parsed)) if (typeof value === "string") meta[key] = value;
3049
3071
  current.metadata = meta;
@@ -6297,8 +6319,8 @@ function loadFont(story, filename, diagnostics) {
6297
6319
  * Load sources with mtime-based caching for incremental rebuilds.
6298
6320
  * Always creates a fresh Story; cached passages are replayed via storyAdd().
6299
6321
  */
6300
- function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
6301
- const currentFiles = /* @__PURE__ */ new Set();
6322
+ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles, buildFiles) {
6323
+ const currentFiles = new Set(buildFiles);
6302
6324
  const changedPaths = changedFiles === void 0 ? void 0 : new Set([...changedFiles].map((f) => (0, node_path.resolve)(f)));
6303
6325
  for (const filename of filenames) {
6304
6326
  if (processedFiles.has(filename)) {
@@ -6363,16 +6385,6 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
6363
6385
  }
6364
6386
  //#endregion
6365
6387
  //#region src/template.ts
6366
- /**
6367
- * An HTML closing head tag: `</head` in any letter case, followed by whitespace, `/` or `>`, up to the next `>`.
6368
- * This is how the HTML tokenizer reads an end tag; it ignores whatever sits between the tag name and the `>`.
6369
- */
6370
- const CLOSING_HEAD_TAG = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>";
6371
- /**
6372
- * The start of an HTML body start tag: `<body` in any letter case, followed by whitespace, `/` or `>` (so not
6373
- * `<bodyx>`). Only the `<body` is matched; the attributes and `>` after it are left alone.
6374
- */
6375
- const BODY_START_TAG = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
6376
6388
  /** The regular expression source matching exactly `text`. */
6377
6389
  function literal(text) {
6378
6390
  return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
@@ -6398,6 +6410,8 @@ function fillTemplateParts(parts) {
6398
6410
  return part.text.replace(pattern, (match, ...groups) => {
6399
6411
  const slot = slots.find((_, i) => groups[i] !== void 0);
6400
6412
  if (slot === void 0 || slot.occurrences === "first" && filled.has(slot)) return match;
6413
+ const offset = groups[slots.length];
6414
+ if (slot.accepts !== void 0 && typeof offset === "number" && !slot.accepts(part.text, offset)) return match;
6401
6415
  filled.add(slot);
6402
6416
  return slot.replacement(match);
6403
6417
  });
@@ -6488,15 +6502,18 @@ function placeHead(content, texts, owner) {
6488
6502
  slot: void 0,
6489
6503
  diagnostics: []
6490
6504
  };
6491
- const found = (tag) => texts.some((text) => new RegExp(tag).test(text));
6492
- if (found("<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>")) return {
6493
- slot: beforeTag(CLOSING_HEAD_TAG, content),
6505
+ const scans = texts.map((text) => ({
6506
+ text,
6507
+ tags: scanHeadTags(text)
6508
+ }));
6509
+ if (scans.some(({ tags }) => tags.closingHead !== void 0)) return {
6510
+ slot: beforeTag(CLOSING_HEAD_PREFIX, "closingHead", content),
6494
6511
  diagnostics: []
6495
6512
  };
6496
- if (found("<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])")) {
6513
+ if (scans.some(({ tags }) => tags.bodyStart !== void 0)) {
6497
6514
  const message = `${owner} has no closing head tag; the modules and head file were injected before its body start tag.`;
6498
6515
  return {
6499
- slot: beforeTag(BODY_START_TAG, content),
6516
+ slot: beforeTag(BODY_START_PREFIX, "bodyStart", content),
6500
6517
  diagnostics: [{
6501
6518
  level: "warning",
6502
6519
  message
@@ -6511,12 +6528,98 @@ function placeHead(content, texts, owner) {
6511
6528
  }]
6512
6529
  };
6513
6530
  }
6514
- /** The slot that inserts `content` on its own line before the first match of `tag`, which is kept as written. */
6515
- function beforeTag(tag, content) {
6531
+ /** `</head` in any letter case, followed by whitespace, `/` or `>`; the rest of the tag is left alone. */
6532
+ const CLOSING_HEAD_PREFIX = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])";
6533
+ /** `<body` in any letter case, followed by whitespace, `/` or `>` (so not `<bodyx>`). */
6534
+ const BODY_START_PREFIX = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
6535
+ /** Elements whose content is text, so a tag written there is not a tag. */
6536
+ const RAW_TEXT_ELEMENTS = /* @__PURE__ */ new Set([
6537
+ "script",
6538
+ "style",
6539
+ "textarea",
6540
+ "title"
6541
+ ]);
6542
+ const isSpace = (c) => c === " " || c === " " || c === "\n" || c === "\f" || c === "\r";
6543
+ const isLetter = (c) => c !== void 0 && /^[A-Za-z]$/.test(c);
6544
+ /**
6545
+ * Find the first closing head tag and body start tag that HTML would read as tags: not those in a comment, in
6546
+ * the text of a script, style, textarea or title element, or in a quoted attribute value. A small scanner, not
6547
+ * a full HTML tokenizer; it needs no more than the tags and where they end.
6548
+ */
6549
+ function scanHeadTags(html) {
6550
+ let closingHead;
6551
+ let bodyStart;
6552
+ let i = 0;
6553
+ while (i < html.length) {
6554
+ const lt = html.indexOf("<", i);
6555
+ if (lt === -1) break;
6556
+ const next = html[lt + 1];
6557
+ if (html.startsWith("<!--", lt)) {
6558
+ const end = html.indexOf("-->", lt + 4);
6559
+ i = end === -1 ? html.length : end + 3;
6560
+ } else if (next === "!" || next === "?") {
6561
+ const end = html.indexOf(">", lt);
6562
+ i = end === -1 ? html.length : end + 1;
6563
+ } else if (next === "/" && isLetter(html[lt + 2])) {
6564
+ const { name, end } = readTag(html, lt + 2);
6565
+ if (name === "head" && closingHead === void 0) closingHead = lt;
6566
+ i = end;
6567
+ } else if (isLetter(next)) {
6568
+ const { name, end } = readTag(html, lt + 1);
6569
+ if (name === "body" && bodyStart === void 0) bodyStart = lt;
6570
+ i = RAW_TEXT_ELEMENTS.has(name) ? rawTextEnd(html, name, end) : end;
6571
+ } else i = lt + 1;
6572
+ if (closingHead !== void 0 && bodyStart !== void 0) break;
6573
+ }
6574
+ return {
6575
+ closingHead,
6576
+ bodyStart
6577
+ };
6578
+ }
6579
+ /** Read a tag whose name starts at `from`: its lowercase name, and the offset after its `>` (or the end). */
6580
+ function readTag(html, from) {
6581
+ let i = from;
6582
+ while (i < html.length && !isSpace(html[i]) && html[i] !== "/" && html[i] !== ">") i++;
6583
+ const name = html.slice(from, i).toLowerCase();
6584
+ while (i < html.length && html[i] !== ">") {
6585
+ if (html[i] !== "=") {
6586
+ i++;
6587
+ continue;
6588
+ }
6589
+ i++;
6590
+ while (isSpace(html[i])) i++;
6591
+ const quote = html[i];
6592
+ if (quote === "\"" || quote === "'") {
6593
+ const close = html.indexOf(quote, i + 1);
6594
+ i = close === -1 ? html.length : close + 1;
6595
+ }
6596
+ }
6597
+ return {
6598
+ name,
6599
+ end: Math.min(i + 1, html.length)
6600
+ };
6601
+ }
6602
+ /** The offset of the end tag that closes a raw text element, given where its text starts (or the end). */
6603
+ function rawTextEnd(html, name, from) {
6604
+ const close = new RegExp(`</${name}(?=[\\t\\n\\f\\r />])`, "gi");
6605
+ close.lastIndex = from;
6606
+ return close.exec(html)?.index ?? html.length;
6607
+ }
6608
+ /**
6609
+ * The slot that inserts `content` on its own line before the first real tag of the kind `which` names (see
6610
+ * `scanHeadTags()`), which is kept as written. A look-alike inside a script or comment is left alone.
6611
+ */
6612
+ function beforeTag(pattern, which, content) {
6613
+ const cache = /* @__PURE__ */ new Map();
6614
+ const offsetIn = (text) => {
6615
+ if (!cache.has(text)) cache.set(text, scanHeadTags(text)[which]);
6616
+ return cache.get(text);
6617
+ };
6516
6618
  return {
6517
- pattern: tag,
6619
+ pattern,
6518
6620
  occurrences: "first",
6519
- replacement: (match) => `${content}\n${match}`
6621
+ replacement: (match) => `${content}\n${match}`,
6622
+ accepts: (text, offset) => offsetIn(text) === offset
6520
6623
  };
6521
6624
  }
6522
6625
  //#endregion
@@ -6962,6 +7065,7 @@ async function compile(options) {
6962
7065
  */
6963
7066
  async function compileToFile(options) {
6964
7067
  const result = await compileForOutputFile(options, options.outFile);
7068
+ options.signal?.throwIfAborted();
6965
7069
  writeFileAtomic(options.outFile, result.output);
6966
7070
  return result;
6967
7071
  }
@@ -7123,34 +7227,61 @@ async function buildOutput(options, cache, changedFiles, outputs) {
7123
7227
  const timeout = options.formatFetchTimeout;
7124
7228
  if (timeout !== void 0 && !(timeout >= 0)) throw new TweeTsError(`formatFetchTimeout must be 0 or more milliseconds, not ${timeout}.`);
7125
7229
  clearIndexCache();
7126
- const filePaths = [];
7127
- const inlineSources = [];
7128
- for (const source of options.sources) if (typeof source === "string") filePaths.push(source);
7129
- else inlineSources.push(source);
7130
- const sourcePaths = getFilenames(filePaths, written, options.exclude);
7131
- diagnostics.push(...sourcePaths.diagnostics);
7132
- rejectOutputSources(sourcePaths.outputSources, diagnostics);
7133
- const sourceFilenames = sourcePaths.filenames;
7230
+ const groups = [];
7231
+ const outputSources = [];
7232
+ for (const source of options.sources) {
7233
+ const last = groups[groups.length - 1];
7234
+ if (typeof source === "string") {
7235
+ if (last?.kind === "paths") last.paths.push(source);
7236
+ else groups.push({
7237
+ kind: "paths",
7238
+ paths: [source]
7239
+ });
7240
+ } else if (last?.kind === "inline") last.sources.push(source);
7241
+ else groups.push({
7242
+ kind: "inline",
7243
+ sources: [source]
7244
+ });
7245
+ }
7246
+ const walked = groups.map((group) => {
7247
+ if (group.kind === "inline") return group;
7248
+ const found = getFilenames(group.paths, written, options.exclude);
7249
+ diagnostics.push(...found.diagnostics);
7250
+ outputSources.push(...found.outputSources);
7251
+ return {
7252
+ kind: "files",
7253
+ filenames: found.filenames
7254
+ };
7255
+ });
7256
+ rejectOutputSources(outputSources, diagnostics);
7257
+ const buildFiles = new Set(walked.flatMap((group) => group.kind === "files" ? group.filenames : []));
7134
7258
  const story = createStory();
7135
7259
  const processedFiles = /* @__PURE__ */ new Set();
7136
- if (cache) loadSourcesCached(story, sourceFilenames, {
7260
+ for (const group of walked) if (group.kind === "inline") loadInlineSources(story, group.sources, {
7137
7261
  trim,
7138
7262
  twee2Compat
7139
- }, diagnostics, processedFiles, cache, changedFiles);
7140
- else loadSources(story, sourceFilenames, {
7263
+ }, diagnostics);
7264
+ else if (cache) loadSourcesCached(story, group.filenames, {
7265
+ trim,
7266
+ twee2Compat
7267
+ }, diagnostics, processedFiles, cache, changedFiles, buildFiles);
7268
+ else loadSources(story, group.filenames, {
7141
7269
  trim,
7142
7270
  twee2Compat
7143
7271
  }, diagnostics, processedFiles);
7144
- loadInlineSources(story, inlineSources, {
7272
+ if (cache && buildFiles.size === 0) loadSourcesCached(story, [], {
7145
7273
  trim,
7146
7274
  twee2Compat
7147
- }, diagnostics);
7275
+ }, diagnostics, processedFiles, cache, changedFiles);
7148
7276
  if (options.tagAliases) story.passages = applyTagAliases(story.passages, options.tagAliases);
7149
7277
  let format;
7150
- if (outputMode === "html") format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
7151
- ...options,
7152
- noRemote
7153
- }, diagnostics);
7278
+ if (outputMode === "html") {
7279
+ format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
7280
+ ...options,
7281
+ noRemote
7282
+ }, diagnostics);
7283
+ options.signal?.throwIfAborted();
7284
+ }
7154
7285
  if (options.startPassage) story.twine2.start = options.startPassage;
7155
7286
  const startName = story.twine2.start || DEFAULT_START_NAME;
7156
7287
  if (testMode) story.twine2.options.set("debug", true);
@@ -7202,6 +7333,7 @@ async function buildOutput(options, cache, changedFiles, outputs) {
7202
7333
  ...getStoryStats(story, options.wordCountMethod),
7203
7334
  files: [...processedFiles]
7204
7335
  };
7336
+ options.signal?.throwIfAborted();
7205
7337
  return {
7206
7338
  output,
7207
7339
  story,
@@ -8967,22 +9099,13 @@ const foldKey = (key) => key.toLowerCase().replace(/[-_]/g, "");
8967
9099
  *
8968
9100
  * @param diagnostics Receives warnings that do not stop the config from loading: keys the config does not
8969
9101
  * define, and a file that is not valid UTF-8 (it is read as Windows-1252).
8970
- * @throws When the file is not valid JSON or fails {@link validateConfig}.
9102
+ * @throws When the file exists but cannot be read, is not valid JSON or fails {@link validateConfig}.
8971
9103
  */
8972
9104
  function loadConfig(dir, diagnostics) {
8973
9105
  const base = dir ?? process.cwd();
8974
9106
  const configPath = (0, node_path.join)(base, CONFIG_FILENAME);
8975
9107
  if (!(0, node_fs.existsSync)(configPath)) return null;
8976
- const readDiagnostics = [];
8977
- let raw;
8978
- try {
8979
- raw = readUTF8(configPath, readDiagnostics);
8980
- } catch {
8981
- return null;
8982
- }
8983
- const config = parseConfig(raw, configPath, readDiagnostics);
8984
- diagnostics?.push(...readDiagnostics);
8985
- return config;
9108
+ return loadConfigFile(configPath, diagnostics);
8986
9109
  }
8987
9110
  /**
8988
9111
  * Load a config from a specific file path.