@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.
@@ -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
@@ -1369,6 +1368,8 @@ function parseFormatObject(text, start, end) {
1369
1368
  }
1370
1369
  }
1371
1370
  /** Check a parsed format object's fields. Per spec, `name` is optional; `version` and `source` are required. */
1371
+ /** The name given to a format.js that names no format. */
1372
+ const UNNAMED_FORMAT_NAME = "Untitled Story Format";
1372
1373
  function toFormatJSON(raw) {
1373
1374
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
1374
1375
  ok: false,
@@ -1388,7 +1389,7 @@ function toFormatJSON(raw) {
1388
1389
  reason: `Story format version ${JSON.stringify(obj.version)} is not a SemVer version.`
1389
1390
  };
1390
1391
  const data = {
1391
- name: typeof obj.name === "string" ? obj.name : "Untitled Story Format",
1392
+ name: typeof obj.name === "string" ? obj.name : UNNAMED_FORMAT_NAME,
1392
1393
  version: obj.version,
1393
1394
  source: obj.source,
1394
1395
  proofing: obj.proofing === true
@@ -1847,31 +1848,37 @@ function clearIndexCache() {
1847
1848
  /** Requests in progress, by what they fetch, so concurrent compiles make each request once. */
1848
1849
  const sharedRequests = /* @__PURE__ */ new Map();
1849
1850
  /**
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.
1851
+ * Runs `start` once for all callers that ask for `key` while it is in progress. Each caller waits
1852
+ * under its own limits: a caller whose `signal` aborts, or whose `timeout` passes, stops waiting
1853
+ * at once and rejects, while the others keep waiting. The request itself is aborted (through
1854
+ * the signal `start` receives) once no caller waits on it. `start` must not depend on any one
1855
+ * caller: what differs between callers belongs after the shared result.
1853
1856
  */
1854
- function shareRequest(key, signal, start) {
1857
+ function shareRequest(key, { signal, timeout, timedOut }, start) {
1855
1858
  if (signal?.aborted) return Promise.reject(signal.reason);
1856
1859
  const request = sharedRequests.get(key) ?? startSharedRequest(key, start);
1857
1860
  request.waiters++;
1858
1861
  return new Promise((resolve, reject) => {
1859
1862
  let waiting = true;
1863
+ let timer;
1860
1864
  const stopWaiting = () => {
1861
1865
  if (!waiting) return;
1862
1866
  waiting = false;
1863
1867
  signal?.removeEventListener("abort", onAbort);
1868
+ clearTimeout(timer);
1864
1869
  request.waiters--;
1865
1870
  };
1866
- const onAbort = () => {
1871
+ const giveUp = (reason) => {
1867
1872
  stopWaiting();
1868
1873
  if (request.waiters === 0) {
1869
1874
  if (sharedRequests.get(key) === request) sharedRequests.delete(key);
1870
- request.controller.abort(signal?.reason);
1875
+ request.controller.abort(reason);
1871
1876
  }
1872
- reject(signal?.reason);
1877
+ reject(reason);
1873
1878
  };
1879
+ const onAbort = () => giveUp(signal?.reason);
1874
1880
  signal?.addEventListener("abort", onAbort, { once: true });
1881
+ if (timeout > 0 && timeout <= MAX_TIMER_DELAY) timer = setTimeout(() => giveUp(timedOut()), timeout);
1875
1882
  request.promise.then((value) => {
1876
1883
  stopWaiting();
1877
1884
  resolve(value);
@@ -1901,20 +1908,32 @@ function requestTimeout(options) {
1901
1908
  if (!(timeout >= 0)) throw new RangeError(`A story format request timeout must be 0 or more milliseconds, not ${timeout}`);
1902
1909
  return timeout;
1903
1910
  }
1911
+ /** How one caller waits for `url`: under its own signal and timeout. `what` names the request in errors. */
1912
+ function waitOptions(options, what, url) {
1913
+ const timeout = requestTimeout(options);
1914
+ return {
1915
+ signal: options.signal,
1916
+ timeout,
1917
+ timedOut: () => /* @__PURE__ */ new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`)
1918
+ };
1919
+ }
1904
1920
  /**
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>".
1921
+ * Fetch `url` as bytes, aborting when `signal` does. `what` names the request in errors, as in
1922
+ * "Failed to <what> from <url>". The bytes are returned undecoded, so checksums cover what was served.
1907
1923
  */
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
- }
1924
+ async function fetchBytes(url, what, signal) {
1925
+ const res = await fetch(url, { signal });
1926
+ if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
1927
+ return new Uint8Array(await res.arrayBuffer());
1928
+ }
1929
+ /** Download a format.js once for every caller in this process that asks for `url` meanwhile. */
1930
+ function shareDownload(url, options) {
1931
+ return shareRequest(`download\0${url}`, waitOptions(options, "download format", url), (signal) => fetchBytes(url, "download format", signal));
1932
+ }
1933
+ /** Decode a downloaded format.js as local ones are (UTF-8, else Windows-1252), without a leading BOM. */
1934
+ function decodeDownload(bytes, url) {
1935
+ const { text } = decodeText(bytes, url);
1936
+ return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
1918
1937
  }
1919
1938
  /** Validate that a JSON value is a valid SFAIndexEntry. */
1920
1939
  function isValidEntry(val) {
@@ -1936,9 +1955,8 @@ async function fetchIndex(url, options = {}) {
1936
1955
  options.signal?.throwIfAborted();
1937
1956
  const cached = indexCache.get(url);
1938
1957
  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);
1958
+ return shareRequest(`index\0${url}`, waitOptions(options, "fetch format index", url), async (signal) => {
1959
+ const text = decodeDownload(await fetchBytes(url, "fetch format index", signal), url);
1942
1960
  const data = validateSFAIndex(JSON.parse(text));
1943
1961
  indexCache.set(url, data);
1944
1962
  return data;
@@ -1975,26 +1993,37 @@ function cachedFormatInfo(id, filename, data) {
1975
1993
  };
1976
1994
  }
1977
1995
  /**
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.
1996
+ * Check that a downloaded format is the one the index entry promised. The name matches without
1997
+ * regard to case, as format requests do, and the version by SemVer precedence, so "1.0" and "v1.0.0"
1998
+ * agree. A format.js that names no format ({@link UNNAMED_FORMAT_NAME}) is allowed: such formats
1999
+ * exist, and the index entry then supplies the name it is cached under.
2000
+ */
2001
+ function checkFormatIdentity(entry, data, downloadUrl) {
2002
+ if ((data.name === "Untitled Story Format" || data.name.toLowerCase() === entry.name.toLowerCase()) && sameVersion(data.version, entry.version)) return;
2003
+ throw new Error(`Story format mismatch for ${downloadUrl}: the index lists ${entry.name} ${entry.version}, but the download is ${data.name} ${data.version}`);
2004
+ }
2005
+ /**
2006
+ * Download a format, verify its checksum, check that it is the format the entry names, write it to
2007
+ * the cache shared by name and version, and return its StoryFormatInfo. Concurrent calls for one
2008
+ * download URL share one request for the bytes; each call then verifies them against its own
2009
+ * entry, within its own timeout.
1980
2010
  */
1981
2011
  async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
1982
2012
  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
- });
2013
+ const bytes = await shareDownload(downloadUrl, options);
2014
+ const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
2015
+ if (checksumKey) {
2016
+ const expected = entry.checksums[checksumKey];
2017
+ if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
2018
+ if (!await verifySHA256(bytes, expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
2019
+ }
2020
+ const text = decodeDownload(bytes, downloadUrl);
2021
+ const id = makeFormatId(entry.name, entry.version);
2022
+ const data = parseFormatJSON(text, id);
2023
+ if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
2024
+ checkFormatIdentity(entry, data, downloadUrl);
2025
+ options.signal?.throwIfAborted();
2026
+ return cachedFormatInfo(id, writeCacheEntry(resolve(getCacheDir()), [entry.name, entry.version], text), data);
1998
2027
  }
1999
2028
  /**
2000
2029
  * Download a direct format.js URL, parse its JSON, cache it under that URL (apart from the
@@ -2002,16 +2031,13 @@ async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
2002
2031
  * one URL share one request.
2003
2032
  */
2004
2033
  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
- });
2034
+ const text = decodeDownload(await shareDownload(url, options), url);
2035
+ const data = parseFormatJSON(text);
2036
+ if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
2037
+ cachedFormatDir(data.name, data.version);
2038
+ options.signal?.throwIfAborted();
2039
+ const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
2040
+ return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
2015
2041
  }
2016
2042
  /** The copy of a direct format URL in the download cache, if it has been downloaded before. */
2017
2043
  function getCachedDirectFormat(url) {
@@ -2475,8 +2501,6 @@ function* tweeLexer(input) {
2475
2501
  const items = ctx.drainItems();
2476
2502
  for (const item of items) yield item;
2477
2503
  }
2478
- const remaining = ctx.drainItems();
2479
- for (const item of remaining) yield item;
2480
2504
  }
2481
2505
  //#endregion
2482
2506
  //#region src/twee2-compat.ts
@@ -2603,9 +2627,7 @@ function parseTwee(source, options = {}) {
2603
2627
  };
2604
2628
  }
2605
2629
  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;
2630
+ const parsed = JSON.parse(item.val);
2609
2631
  const meta = {};
2610
2632
  for (const [key, value] of Object.entries(parsed)) if (typeof value === "string") meta[key] = value;
2611
2633
  current.metadata = meta;
@@ -5850,8 +5872,8 @@ function loadFont(story, filename, diagnostics) {
5850
5872
  * Load sources with mtime-based caching for incremental rebuilds.
5851
5873
  * Always creates a fresh Story; cached passages are replayed via storyAdd().
5852
5874
  */
5853
- function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
5854
- const currentFiles = /* @__PURE__ */ new Set();
5875
+ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles, buildFiles) {
5876
+ const currentFiles = new Set(buildFiles);
5855
5877
  const changedPaths = changedFiles === void 0 ? void 0 : new Set([...changedFiles].map((f) => resolve(f)));
5856
5878
  for (const filename of filenames) {
5857
5879
  if (processedFiles.has(filename)) {
@@ -5916,16 +5938,6 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
5916
5938
  }
5917
5939
  //#endregion
5918
5940
  //#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
5941
  /** The regular expression source matching exactly `text`. */
5930
5942
  function literal(text) {
5931
5943
  return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
@@ -5951,6 +5963,8 @@ function fillTemplateParts(parts) {
5951
5963
  return part.text.replace(pattern, (match, ...groups) => {
5952
5964
  const slot = slots.find((_, i) => groups[i] !== void 0);
5953
5965
  if (slot === void 0 || slot.occurrences === "first" && filled.has(slot)) return match;
5966
+ const offset = groups[slots.length];
5967
+ if (slot.accepts !== void 0 && typeof offset === "number" && !slot.accepts(part.text, offset)) return match;
5954
5968
  filled.add(slot);
5955
5969
  return slot.replacement(match);
5956
5970
  });
@@ -6041,15 +6055,18 @@ function placeHead(content, texts, owner) {
6041
6055
  slot: void 0,
6042
6056
  diagnostics: []
6043
6057
  };
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),
6058
+ const scans = texts.map((text) => ({
6059
+ text,
6060
+ tags: scanHeadTags(text)
6061
+ }));
6062
+ if (scans.some(({ tags }) => tags.closingHead !== void 0)) return {
6063
+ slot: beforeTag(CLOSING_HEAD_PREFIX, "closingHead", content),
6047
6064
  diagnostics: []
6048
6065
  };
6049
- if (found("<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])")) {
6066
+ if (scans.some(({ tags }) => tags.bodyStart !== void 0)) {
6050
6067
  const message = `${owner} has no closing head tag; the modules and head file were injected before its body start tag.`;
6051
6068
  return {
6052
- slot: beforeTag(BODY_START_TAG, content),
6069
+ slot: beforeTag(BODY_START_PREFIX, "bodyStart", content),
6053
6070
  diagnostics: [{
6054
6071
  level: "warning",
6055
6072
  message
@@ -6064,12 +6081,98 @@ function placeHead(content, texts, owner) {
6064
6081
  }]
6065
6082
  };
6066
6083
  }
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) {
6084
+ /** `</head` in any letter case, followed by whitespace, `/` or `>`; the rest of the tag is left alone. */
6085
+ const CLOSING_HEAD_PREFIX = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])";
6086
+ /** `<body` in any letter case, followed by whitespace, `/` or `>` (so not `<bodyx>`). */
6087
+ const BODY_START_PREFIX = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
6088
+ /** Elements whose content is text, so a tag written there is not a tag. */
6089
+ const RAW_TEXT_ELEMENTS = /* @__PURE__ */ new Set([
6090
+ "script",
6091
+ "style",
6092
+ "textarea",
6093
+ "title"
6094
+ ]);
6095
+ const isSpace = (c) => c === " " || c === " " || c === "\n" || c === "\f" || c === "\r";
6096
+ const isLetter = (c) => c !== void 0 && /^[A-Za-z]$/.test(c);
6097
+ /**
6098
+ * Find the first closing head tag and body start tag that HTML would read as tags: not those in a comment, in
6099
+ * the text of a script, style, textarea or title element, or in a quoted attribute value. A small scanner, not
6100
+ * a full HTML tokenizer; it needs no more than the tags and where they end.
6101
+ */
6102
+ function scanHeadTags(html) {
6103
+ let closingHead;
6104
+ let bodyStart;
6105
+ let i = 0;
6106
+ while (i < html.length) {
6107
+ const lt = html.indexOf("<", i);
6108
+ if (lt === -1) break;
6109
+ const next = html[lt + 1];
6110
+ if (html.startsWith("<!--", lt)) {
6111
+ const end = html.indexOf("-->", lt + 4);
6112
+ i = end === -1 ? html.length : end + 3;
6113
+ } else if (next === "!" || next === "?") {
6114
+ const end = html.indexOf(">", lt);
6115
+ i = end === -1 ? html.length : end + 1;
6116
+ } else if (next === "/" && isLetter(html[lt + 2])) {
6117
+ const { name, end } = readTag(html, lt + 2);
6118
+ if (name === "head" && closingHead === void 0) closingHead = lt;
6119
+ i = end;
6120
+ } else if (isLetter(next)) {
6121
+ const { name, end } = readTag(html, lt + 1);
6122
+ if (name === "body" && bodyStart === void 0) bodyStart = lt;
6123
+ i = RAW_TEXT_ELEMENTS.has(name) ? rawTextEnd(html, name, end) : end;
6124
+ } else i = lt + 1;
6125
+ if (closingHead !== void 0 && bodyStart !== void 0) break;
6126
+ }
6069
6127
  return {
6070
- pattern: tag,
6128
+ closingHead,
6129
+ bodyStart
6130
+ };
6131
+ }
6132
+ /** Read a tag whose name starts at `from`: its lowercase name, and the offset after its `>` (or the end). */
6133
+ function readTag(html, from) {
6134
+ let i = from;
6135
+ while (i < html.length && !isSpace(html[i]) && html[i] !== "/" && html[i] !== ">") i++;
6136
+ const name = html.slice(from, i).toLowerCase();
6137
+ while (i < html.length && html[i] !== ">") {
6138
+ if (html[i] !== "=") {
6139
+ i++;
6140
+ continue;
6141
+ }
6142
+ i++;
6143
+ while (isSpace(html[i])) i++;
6144
+ const quote = html[i];
6145
+ if (quote === "\"" || quote === "'") {
6146
+ const close = html.indexOf(quote, i + 1);
6147
+ i = close === -1 ? html.length : close + 1;
6148
+ }
6149
+ }
6150
+ return {
6151
+ name,
6152
+ end: Math.min(i + 1, html.length)
6153
+ };
6154
+ }
6155
+ /** The offset of the end tag that closes a raw text element, given where its text starts (or the end). */
6156
+ function rawTextEnd(html, name, from) {
6157
+ const close = new RegExp(`</${name}(?=[\\t\\n\\f\\r />])`, "gi");
6158
+ close.lastIndex = from;
6159
+ return close.exec(html)?.index ?? html.length;
6160
+ }
6161
+ /**
6162
+ * The slot that inserts `content` on its own line before the first real tag of the kind `which` names (see
6163
+ * `scanHeadTags()`), which is kept as written. A look-alike inside a script or comment is left alone.
6164
+ */
6165
+ function beforeTag(pattern, which, content) {
6166
+ const cache = /* @__PURE__ */ new Map();
6167
+ const offsetIn = (text) => {
6168
+ if (!cache.has(text)) cache.set(text, scanHeadTags(text)[which]);
6169
+ return cache.get(text);
6170
+ };
6171
+ return {
6172
+ pattern,
6071
6173
  occurrences: "first",
6072
- replacement: (match) => `${content}\n${match}`
6174
+ replacement: (match) => `${content}\n${match}`,
6175
+ accepts: (text, offset) => offsetIn(text) === offset
6073
6176
  };
6074
6177
  }
6075
6178
  //#endregion
@@ -6532,34 +6635,61 @@ async function buildOutput(options, cache, changedFiles, outputs) {
6532
6635
  const timeout = options.formatFetchTimeout;
6533
6636
  if (timeout !== void 0 && !(timeout >= 0)) throw new TweeTsError(`formatFetchTimeout must be 0 or more milliseconds, not ${timeout}.`);
6534
6637
  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;
6638
+ const groups = [];
6639
+ const outputSources = [];
6640
+ for (const source of options.sources) {
6641
+ const last = groups[groups.length - 1];
6642
+ if (typeof source === "string") {
6643
+ if (last?.kind === "paths") last.paths.push(source);
6644
+ else groups.push({
6645
+ kind: "paths",
6646
+ paths: [source]
6647
+ });
6648
+ } else if (last?.kind === "inline") last.sources.push(source);
6649
+ else groups.push({
6650
+ kind: "inline",
6651
+ sources: [source]
6652
+ });
6653
+ }
6654
+ const walked = groups.map((group) => {
6655
+ if (group.kind === "inline") return group;
6656
+ const found = getFilenames(group.paths, written, options.exclude);
6657
+ diagnostics.push(...found.diagnostics);
6658
+ outputSources.push(...found.outputSources);
6659
+ return {
6660
+ kind: "files",
6661
+ filenames: found.filenames
6662
+ };
6663
+ });
6664
+ rejectOutputSources(outputSources, diagnostics);
6665
+ const buildFiles = new Set(walked.flatMap((group) => group.kind === "files" ? group.filenames : []));
6543
6666
  const story = createStory();
6544
6667
  const processedFiles = /* @__PURE__ */ new Set();
6545
- if (cache) loadSourcesCached(story, sourceFilenames, {
6668
+ for (const group of walked) if (group.kind === "inline") loadInlineSources(story, group.sources, {
6546
6669
  trim,
6547
6670
  twee2Compat
6548
- }, diagnostics, processedFiles, cache, changedFiles);
6549
- else loadSources(story, sourceFilenames, {
6671
+ }, diagnostics);
6672
+ else if (cache) loadSourcesCached(story, group.filenames, {
6673
+ trim,
6674
+ twee2Compat
6675
+ }, diagnostics, processedFiles, cache, changedFiles, buildFiles);
6676
+ else loadSources(story, group.filenames, {
6550
6677
  trim,
6551
6678
  twee2Compat
6552
6679
  }, diagnostics, processedFiles);
6553
- loadInlineSources(story, inlineSources, {
6680
+ if (cache && buildFiles.size === 0) loadSourcesCached(story, [], {
6554
6681
  trim,
6555
6682
  twee2Compat
6556
- }, diagnostics);
6683
+ }, diagnostics, processedFiles, cache, changedFiles);
6557
6684
  if (options.tagAliases) story.passages = applyTagAliases(story.passages, options.tagAliases);
6558
6685
  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);
6686
+ if (outputMode === "html") {
6687
+ format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
6688
+ ...options,
6689
+ noRemote
6690
+ }, diagnostics);
6691
+ options.signal?.throwIfAborted();
6692
+ }
6563
6693
  if (options.startPassage) story.twine2.start = options.startPassage;
6564
6694
  const startName = story.twine2.start || DEFAULT_START_NAME;
6565
6695
  if (testMode) story.twine2.options.set("debug", true);
@@ -6611,6 +6741,7 @@ async function buildOutput(options, cache, changedFiles, outputs) {
6611
6741
  ...getStoryStats(story, options.wordCountMethod),
6612
6742
  files: [...processedFiles]
6613
6743
  };
6744
+ options.signal?.throwIfAborted();
6614
6745
  return {
6615
6746
  output,
6616
6747
  story,
@@ -6792,4 +6923,4 @@ function isViteConfigTemp(file) {
6792
6923
  //#endregion
6793
6924
  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 };
6794
6925
 
6795
- //# sourceMappingURL=paths-DgO9oo6G.js.map
6926
+ //# sourceMappingURL=paths-CUv1qfz-.js.map