@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.
@@ -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
@@ -1580,6 +1579,8 @@ function parseFormatObject(text, start, end) {
1580
1579
  }
1581
1580
  }
1582
1581
  /** Check a parsed format object's fields. Per spec, `name` is optional; `version` and `source` are required. */
1582
+ /** The name given to a format.js that names no format. */
1583
+ const UNNAMED_FORMAT_NAME = "Untitled Story Format";
1583
1584
  function toFormatJSON(raw) {
1584
1585
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
1585
1586
  ok: false,
@@ -1599,7 +1600,7 @@ function toFormatJSON(raw) {
1599
1600
  reason: `Story format version ${JSON.stringify(obj.version)} is not a SemVer version.`
1600
1601
  };
1601
1602
  const data = {
1602
- name: typeof obj.name === "string" ? obj.name : "Untitled Story Format",
1603
+ name: typeof obj.name === "string" ? obj.name : UNNAMED_FORMAT_NAME,
1603
1604
  version: obj.version,
1604
1605
  source: obj.source,
1605
1606
  proofing: obj.proofing === true
@@ -2058,31 +2059,37 @@ function clearIndexCache() {
2058
2059
  /** Requests in progress, by what they fetch, so concurrent compiles make each request once. */
2059
2060
  const sharedRequests = /* @__PURE__ */ new Map();
2060
2061
  /**
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.
2062
+ * Runs `start` once for all callers that ask for `key` while it is in progress. Each caller waits
2063
+ * under its own limits: a caller whose `signal` aborts, or whose `timeout` passes, stops waiting
2064
+ * at once and rejects, while the others keep waiting. The request itself is aborted (through
2065
+ * the signal `start` receives) once no caller waits on it. `start` must not depend on any one
2066
+ * caller: what differs between callers belongs after the shared result.
2064
2067
  */
2065
- function shareRequest(key, signal, start) {
2068
+ function shareRequest(key, { signal, timeout, timedOut }, start) {
2066
2069
  if (signal?.aborted) return Promise.reject(signal.reason);
2067
2070
  const request = sharedRequests.get(key) ?? startSharedRequest(key, start);
2068
2071
  request.waiters++;
2069
2072
  return new Promise((resolve, reject) => {
2070
2073
  let waiting = true;
2074
+ let timer;
2071
2075
  const stopWaiting = () => {
2072
2076
  if (!waiting) return;
2073
2077
  waiting = false;
2074
2078
  signal?.removeEventListener("abort", onAbort);
2079
+ clearTimeout(timer);
2075
2080
  request.waiters--;
2076
2081
  };
2077
- const onAbort = () => {
2082
+ const giveUp = (reason) => {
2078
2083
  stopWaiting();
2079
2084
  if (request.waiters === 0) {
2080
2085
  if (sharedRequests.get(key) === request) sharedRequests.delete(key);
2081
- request.controller.abort(signal?.reason);
2086
+ request.controller.abort(reason);
2082
2087
  }
2083
- reject(signal?.reason);
2088
+ reject(reason);
2084
2089
  };
2090
+ const onAbort = () => giveUp(signal?.reason);
2085
2091
  signal?.addEventListener("abort", onAbort, { once: true });
2092
+ if (timeout > 0 && timeout <= MAX_TIMER_DELAY) timer = setTimeout(() => giveUp(timedOut()), timeout);
2086
2093
  request.promise.then((value) => {
2087
2094
  stopWaiting();
2088
2095
  resolve(value);
@@ -2112,20 +2119,32 @@ function requestTimeout(options) {
2112
2119
  if (!(timeout >= 0)) throw new RangeError(`A story format request timeout must be 0 or more milliseconds, not ${timeout}`);
2113
2120
  return timeout;
2114
2121
  }
2122
+ /** How one caller waits for `url`: under its own signal and timeout. `what` names the request in errors. */
2123
+ function waitOptions(options, what, url) {
2124
+ const timeout = requestTimeout(options);
2125
+ return {
2126
+ signal: options.signal,
2127
+ timeout,
2128
+ timedOut: () => /* @__PURE__ */ new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`)
2129
+ };
2130
+ }
2115
2131
  /**
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>".
2132
+ * Fetch `url` as bytes, aborting when `signal` does. `what` names the request in errors, as in
2133
+ * "Failed to <what> from <url>". The bytes are returned undecoded, so checksums cover what was served.
2118
2134
  */
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
- }
2135
+ async function fetchBytes(url, what, signal) {
2136
+ const res = await fetch(url, { signal });
2137
+ if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
2138
+ return new Uint8Array(await res.arrayBuffer());
2139
+ }
2140
+ /** Download a format.js once for every caller in this process that asks for `url` meanwhile. */
2141
+ function shareDownload(url, options) {
2142
+ return shareRequest(`download\0${url}`, waitOptions(options, "download format", url), (signal) => fetchBytes(url, "download format", signal));
2143
+ }
2144
+ /** Decode a downloaded format.js as local ones are (UTF-8, else Windows-1252), without a leading BOM. */
2145
+ function decodeDownload(bytes, url) {
2146
+ const { text } = decodeText(bytes, url);
2147
+ return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
2129
2148
  }
2130
2149
  /** Validate that a JSON value is a valid SFAIndexEntry. */
2131
2150
  function isValidEntry(val) {
@@ -2147,9 +2166,8 @@ async function fetchIndex(url, options = {}) {
2147
2166
  options.signal?.throwIfAborted();
2148
2167
  const cached = indexCache.get(url);
2149
2168
  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);
2169
+ return shareRequest(`index\0${url}`, waitOptions(options, "fetch format index", url), async (signal) => {
2170
+ const text = decodeDownload(await fetchBytes(url, "fetch format index", signal), url);
2153
2171
  const data = validateSFAIndex(JSON.parse(text));
2154
2172
  indexCache.set(url, data);
2155
2173
  return data;
@@ -2186,26 +2204,37 @@ function cachedFormatInfo(id, filename, data) {
2186
2204
  };
2187
2205
  }
2188
2206
  /**
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.
2207
+ * Check that a downloaded format is the one the index entry promised. The name matches without
2208
+ * regard to case, as format requests do, and the version by SemVer precedence, so "1.0" and "v1.0.0"
2209
+ * agree. A format.js that names no format ({@link UNNAMED_FORMAT_NAME}) is allowed: such formats
2210
+ * exist, and the index entry then supplies the name it is cached under.
2211
+ */
2212
+ function checkFormatIdentity(entry, data, downloadUrl) {
2213
+ if ((data.name === "Untitled Story Format" || data.name.toLowerCase() === entry.name.toLowerCase()) && sameVersion(data.version, entry.version)) return;
2214
+ throw new Error(`Story format mismatch for ${downloadUrl}: the index lists ${entry.name} ${entry.version}, but the download is ${data.name} ${data.version}`);
2215
+ }
2216
+ /**
2217
+ * Download a format, verify its checksum, check that it is the format the entry names, write it to
2218
+ * the cache shared by name and version, and return its StoryFormatInfo. Concurrent calls for one
2219
+ * download URL share one request for the bytes; each call then verifies them against its own
2220
+ * entry, within its own timeout.
2191
2221
  */
2192
2222
  async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
2193
2223
  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
- });
2224
+ const bytes = await shareDownload(downloadUrl, options);
2225
+ const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
2226
+ if (checksumKey) {
2227
+ const expected = entry.checksums[checksumKey];
2228
+ if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
2229
+ if (!await verifySHA256(bytes, expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
2230
+ }
2231
+ const text = decodeDownload(bytes, downloadUrl);
2232
+ const id = makeFormatId(entry.name, entry.version);
2233
+ const data = parseFormatJSON(text, id);
2234
+ if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
2235
+ checkFormatIdentity(entry, data, downloadUrl);
2236
+ options.signal?.throwIfAborted();
2237
+ return cachedFormatInfo(id, writeCacheEntry(resolve(getCacheDir()), [entry.name, entry.version], text), data);
2209
2238
  }
2210
2239
  /**
2211
2240
  * Download a direct format.js URL, parse its JSON, cache it under that URL (apart from the
@@ -2213,16 +2242,13 @@ async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
2213
2242
  * one URL share one request.
2214
2243
  */
2215
2244
  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
- });
2245
+ const text = decodeDownload(await shareDownload(url, options), url);
2246
+ const data = parseFormatJSON(text);
2247
+ if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
2248
+ cachedFormatDir(data.name, data.version);
2249
+ options.signal?.throwIfAborted();
2250
+ const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
2251
+ return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
2226
2252
  }
2227
2253
  /** The copy of a direct format URL in the download cache, if it has been downloaded before. */
2228
2254
  function getCachedDirectFormat(url) {
@@ -2786,8 +2812,6 @@ function* tweeLexer(input) {
2786
2812
  const items = ctx.drainItems();
2787
2813
  for (const item of items) yield item;
2788
2814
  }
2789
- const remaining = ctx.drainItems();
2790
- for (const item of remaining) yield item;
2791
2815
  }
2792
2816
  //#endregion
2793
2817
  //#region src/twee2-compat.ts
@@ -2914,9 +2938,7 @@ function parseTwee(source, options = {}) {
2914
2938
  };
2915
2939
  }
2916
2940
  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;
2941
+ const parsed = JSON.parse(item.val);
2920
2942
  const meta = {};
2921
2943
  for (const [key, value] of Object.entries(parsed)) if (typeof value === "string") meta[key] = value;
2922
2944
  current.metadata = meta;
@@ -6161,8 +6183,8 @@ function loadFont(story, filename, diagnostics) {
6161
6183
  * Load sources with mtime-based caching for incremental rebuilds.
6162
6184
  * Always creates a fresh Story; cached passages are replayed via storyAdd().
6163
6185
  */
6164
- function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
6165
- const currentFiles = /* @__PURE__ */ new Set();
6186
+ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles, buildFiles) {
6187
+ const currentFiles = new Set(buildFiles);
6166
6188
  const changedPaths = changedFiles === void 0 ? void 0 : new Set([...changedFiles].map((f) => resolve(f)));
6167
6189
  for (const filename of filenames) {
6168
6190
  if (processedFiles.has(filename)) {
@@ -6227,16 +6249,6 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
6227
6249
  }
6228
6250
  //#endregion
6229
6251
  //#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
6252
  /** The regular expression source matching exactly `text`. */
6241
6253
  function literal(text) {
6242
6254
  return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
@@ -6262,6 +6274,8 @@ function fillTemplateParts(parts) {
6262
6274
  return part.text.replace(pattern, (match, ...groups) => {
6263
6275
  const slot = slots.find((_, i) => groups[i] !== void 0);
6264
6276
  if (slot === void 0 || slot.occurrences === "first" && filled.has(slot)) return match;
6277
+ const offset = groups[slots.length];
6278
+ if (slot.accepts !== void 0 && typeof offset === "number" && !slot.accepts(part.text, offset)) return match;
6265
6279
  filled.add(slot);
6266
6280
  return slot.replacement(match);
6267
6281
  });
@@ -6352,15 +6366,18 @@ function placeHead(content, texts, owner) {
6352
6366
  slot: void 0,
6353
6367
  diagnostics: []
6354
6368
  };
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),
6369
+ const scans = texts.map((text) => ({
6370
+ text,
6371
+ tags: scanHeadTags(text)
6372
+ }));
6373
+ if (scans.some(({ tags }) => tags.closingHead !== void 0)) return {
6374
+ slot: beforeTag(CLOSING_HEAD_PREFIX, "closingHead", content),
6358
6375
  diagnostics: []
6359
6376
  };
6360
- if (found("<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])")) {
6377
+ if (scans.some(({ tags }) => tags.bodyStart !== void 0)) {
6361
6378
  const message = `${owner} has no closing head tag; the modules and head file were injected before its body start tag.`;
6362
6379
  return {
6363
- slot: beforeTag(BODY_START_TAG, content),
6380
+ slot: beforeTag(BODY_START_PREFIX, "bodyStart", content),
6364
6381
  diagnostics: [{
6365
6382
  level: "warning",
6366
6383
  message
@@ -6375,12 +6392,98 @@ function placeHead(content, texts, owner) {
6375
6392
  }]
6376
6393
  };
6377
6394
  }
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) {
6395
+ /** `</head` in any letter case, followed by whitespace, `/` or `>`; the rest of the tag is left alone. */
6396
+ const CLOSING_HEAD_PREFIX = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])";
6397
+ /** `<body` in any letter case, followed by whitespace, `/` or `>` (so not `<bodyx>`). */
6398
+ const BODY_START_PREFIX = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
6399
+ /** Elements whose content is text, so a tag written there is not a tag. */
6400
+ const RAW_TEXT_ELEMENTS = /* @__PURE__ */ new Set([
6401
+ "script",
6402
+ "style",
6403
+ "textarea",
6404
+ "title"
6405
+ ]);
6406
+ const isSpace = (c) => c === " " || c === " " || c === "\n" || c === "\f" || c === "\r";
6407
+ const isLetter = (c) => c !== void 0 && /^[A-Za-z]$/.test(c);
6408
+ /**
6409
+ * Find the first closing head tag and body start tag that HTML would read as tags: not those in a comment, in
6410
+ * the text of a script, style, textarea or title element, or in a quoted attribute value. A small scanner, not
6411
+ * a full HTML tokenizer; it needs no more than the tags and where they end.
6412
+ */
6413
+ function scanHeadTags(html) {
6414
+ let closingHead;
6415
+ let bodyStart;
6416
+ let i = 0;
6417
+ while (i < html.length) {
6418
+ const lt = html.indexOf("<", i);
6419
+ if (lt === -1) break;
6420
+ const next = html[lt + 1];
6421
+ if (html.startsWith("<!--", lt)) {
6422
+ const end = html.indexOf("-->", lt + 4);
6423
+ i = end === -1 ? html.length : end + 3;
6424
+ } else if (next === "!" || next === "?") {
6425
+ const end = html.indexOf(">", lt);
6426
+ i = end === -1 ? html.length : end + 1;
6427
+ } else if (next === "/" && isLetter(html[lt + 2])) {
6428
+ const { name, end } = readTag(html, lt + 2);
6429
+ if (name === "head" && closingHead === void 0) closingHead = lt;
6430
+ i = end;
6431
+ } else if (isLetter(next)) {
6432
+ const { name, end } = readTag(html, lt + 1);
6433
+ if (name === "body" && bodyStart === void 0) bodyStart = lt;
6434
+ i = RAW_TEXT_ELEMENTS.has(name) ? rawTextEnd(html, name, end) : end;
6435
+ } else i = lt + 1;
6436
+ if (closingHead !== void 0 && bodyStart !== void 0) break;
6437
+ }
6380
6438
  return {
6381
- pattern: tag,
6439
+ closingHead,
6440
+ bodyStart
6441
+ };
6442
+ }
6443
+ /** Read a tag whose name starts at `from`: its lowercase name, and the offset after its `>` (or the end). */
6444
+ function readTag(html, from) {
6445
+ let i = from;
6446
+ while (i < html.length && !isSpace(html[i]) && html[i] !== "/" && html[i] !== ">") i++;
6447
+ const name = html.slice(from, i).toLowerCase();
6448
+ while (i < html.length && html[i] !== ">") {
6449
+ if (html[i] !== "=") {
6450
+ i++;
6451
+ continue;
6452
+ }
6453
+ i++;
6454
+ while (isSpace(html[i])) i++;
6455
+ const quote = html[i];
6456
+ if (quote === "\"" || quote === "'") {
6457
+ const close = html.indexOf(quote, i + 1);
6458
+ i = close === -1 ? html.length : close + 1;
6459
+ }
6460
+ }
6461
+ return {
6462
+ name,
6463
+ end: Math.min(i + 1, html.length)
6464
+ };
6465
+ }
6466
+ /** The offset of the end tag that closes a raw text element, given where its text starts (or the end). */
6467
+ function rawTextEnd(html, name, from) {
6468
+ const close = new RegExp(`</${name}(?=[\\t\\n\\f\\r />])`, "gi");
6469
+ close.lastIndex = from;
6470
+ return close.exec(html)?.index ?? html.length;
6471
+ }
6472
+ /**
6473
+ * The slot that inserts `content` on its own line before the first real tag of the kind `which` names (see
6474
+ * `scanHeadTags()`), which is kept as written. A look-alike inside a script or comment is left alone.
6475
+ */
6476
+ function beforeTag(pattern, which, content) {
6477
+ const cache = /* @__PURE__ */ new Map();
6478
+ const offsetIn = (text) => {
6479
+ if (!cache.has(text)) cache.set(text, scanHeadTags(text)[which]);
6480
+ return cache.get(text);
6481
+ };
6482
+ return {
6483
+ pattern,
6382
6484
  occurrences: "first",
6383
- replacement: (match) => `${content}\n${match}`
6485
+ replacement: (match) => `${content}\n${match}`,
6486
+ accepts: (text, offset) => offsetIn(text) === offset
6384
6487
  };
6385
6488
  }
6386
6489
  //#endregion
@@ -6945,34 +7048,61 @@ async function buildOutput(options, cache, changedFiles, outputs) {
6945
7048
  const timeout = options.formatFetchTimeout;
6946
7049
  if (timeout !== void 0 && !(timeout >= 0)) throw new TweeTsError(`formatFetchTimeout must be 0 or more milliseconds, not ${timeout}.`);
6947
7050
  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;
7051
+ const groups = [];
7052
+ const outputSources = [];
7053
+ for (const source of options.sources) {
7054
+ const last = groups[groups.length - 1];
7055
+ if (typeof source === "string") {
7056
+ if (last?.kind === "paths") last.paths.push(source);
7057
+ else groups.push({
7058
+ kind: "paths",
7059
+ paths: [source]
7060
+ });
7061
+ } else if (last?.kind === "inline") last.sources.push(source);
7062
+ else groups.push({
7063
+ kind: "inline",
7064
+ sources: [source]
7065
+ });
7066
+ }
7067
+ const walked = groups.map((group) => {
7068
+ if (group.kind === "inline") return group;
7069
+ const found = getFilenames(group.paths, written, options.exclude);
7070
+ diagnostics.push(...found.diagnostics);
7071
+ outputSources.push(...found.outputSources);
7072
+ return {
7073
+ kind: "files",
7074
+ filenames: found.filenames
7075
+ };
7076
+ });
7077
+ rejectOutputSources(outputSources, diagnostics);
7078
+ const buildFiles = new Set(walked.flatMap((group) => group.kind === "files" ? group.filenames : []));
6956
7079
  const story = createStory();
6957
7080
  const processedFiles = /* @__PURE__ */ new Set();
6958
- if (cache) loadSourcesCached(story, sourceFilenames, {
7081
+ for (const group of walked) if (group.kind === "inline") loadInlineSources(story, group.sources, {
6959
7082
  trim,
6960
7083
  twee2Compat
6961
- }, diagnostics, processedFiles, cache, changedFiles);
6962
- else loadSources(story, sourceFilenames, {
7084
+ }, diagnostics);
7085
+ else if (cache) loadSourcesCached(story, group.filenames, {
7086
+ trim,
7087
+ twee2Compat
7088
+ }, diagnostics, processedFiles, cache, changedFiles, buildFiles);
7089
+ else loadSources(story, group.filenames, {
6963
7090
  trim,
6964
7091
  twee2Compat
6965
7092
  }, diagnostics, processedFiles);
6966
- loadInlineSources(story, inlineSources, {
7093
+ if (cache && buildFiles.size === 0) loadSourcesCached(story, [], {
6967
7094
  trim,
6968
7095
  twee2Compat
6969
- }, diagnostics);
7096
+ }, diagnostics, processedFiles, cache, changedFiles);
6970
7097
  if (options.tagAliases) story.passages = applyTagAliases(story.passages, options.tagAliases);
6971
7098
  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);
7099
+ if (outputMode === "html") {
7100
+ format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
7101
+ ...options,
7102
+ noRemote
7103
+ }, diagnostics);
7104
+ options.signal?.throwIfAborted();
7105
+ }
6976
7106
  if (options.startPassage) story.twine2.start = options.startPassage;
6977
7107
  const startName = story.twine2.start || DEFAULT_START_NAME;
6978
7108
  if (testMode) story.twine2.options.set("debug", true);
@@ -7024,6 +7154,7 @@ async function buildOutput(options, cache, changedFiles, outputs) {
7024
7154
  ...getStoryStats(story, options.wordCountMethod),
7025
7155
  files: [...processedFiles]
7026
7156
  };
7157
+ options.signal?.throwIfAborted();
7027
7158
  return {
7028
7159
  output,
7029
7160
  story,
@@ -8778,22 +8909,13 @@ const foldKey = (key) => key.toLowerCase().replace(/[-_]/g, "");
8778
8909
  *
8779
8910
  * @param diagnostics Receives warnings that do not stop the config from loading: keys the config does not
8780
8911
  * 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}.
8912
+ * @throws When the file exists but cannot be read, is not valid JSON or fails {@link validateConfig}.
8782
8913
  */
8783
8914
  function loadConfig(dir, diagnostics) {
8784
8915
  const base = dir ?? process.cwd();
8785
8916
  const configPath = join(base, CONFIG_FILENAME);
8786
8917
  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;
8918
+ return loadConfigFile(configPath, diagnostics);
8797
8919
  }
8798
8920
  /**
8799
8921
  * Load a config from a specific file path.