@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.d.cts CHANGED
@@ -582,7 +582,7 @@ export declare const CONFIG_FILENAME = "twee-ts.config.json";
582
582
  *
583
583
  * @param diagnostics Receives warnings that do not stop the config from loading: keys the config does not
584
584
  * define, and a file that is not valid UTF-8 (it is read as Windows-1252).
585
- * @throws When the file is not valid JSON or fails {@link validateConfig}.
585
+ * @throws When the file exists but cannot be read, is not valid JSON or fails {@link validateConfig}.
586
586
  */
587
587
  export declare function loadConfig(dir?: string, diagnostics?: Diagnostic[]): TweeTsConfig | null;
588
588
  /**
@@ -613,8 +613,10 @@ export declare function scaffoldConfig(): string;
613
613
  /** Get the cache directory for downloaded story formats. */
614
614
  export declare function getCacheDir(): string;
615
615
  /**
616
- * Download a format, verify its checksum, write it to the cache shared by name and version, and
617
- * return its StoryFormatInfo. Concurrent calls for one download URL share one request.
616
+ * Download a format, verify its checksum, check that it is the format the entry names, write it to
617
+ * the cache shared by name and version, and return its StoryFormatInfo. Concurrent calls for one
618
+ * download URL share one request for the bytes; each call then verifies them against its own
619
+ * entry, within its own timeout.
618
620
  */
619
621
  export declare function fetchAndCacheFormat(entry: SFAIndexEntry, downloadUrl: string, options?: RemoteFetchOptions): Promise<StoryFormatInfo>;
620
622
  /**
package/dist/index.d.ts CHANGED
@@ -582,7 +582,7 @@ export declare const CONFIG_FILENAME = "twee-ts.config.json";
582
582
  *
583
583
  * @param diagnostics Receives warnings that do not stop the config from loading: keys the config does not
584
584
  * define, and a file that is not valid UTF-8 (it is read as Windows-1252).
585
- * @throws When the file is not valid JSON or fails {@link validateConfig}.
585
+ * @throws When the file exists but cannot be read, is not valid JSON or fails {@link validateConfig}.
586
586
  */
587
587
  export declare function loadConfig(dir?: string, diagnostics?: Diagnostic[]): TweeTsConfig | null;
588
588
  /**
@@ -613,8 +613,10 @@ export declare function scaffoldConfig(): string;
613
613
  /** Get the cache directory for downloaded story formats. */
614
614
  export declare function getCacheDir(): string;
615
615
  /**
616
- * Download a format, verify its checksum, write it to the cache shared by name and version, and
617
- * return its StoryFormatInfo. Concurrent calls for one download URL share one request.
616
+ * Download a format, verify its checksum, check that it is the format the entry names, write it to
617
+ * the cache shared by name and version, and return its StoryFormatInfo. Concurrent calls for one
618
+ * download URL share one request for the bytes; each call then verifies them against its own
619
+ * entry, within its own timeout.
618
620
  */
619
621
  export declare function fetchAndCacheFormat(entry: SFAIndexEntry, downloadUrl: string, options?: RemoteFetchOptions): Promise<StoryFormatInfo>;
620
622
  /**
package/dist/index.js CHANGED
@@ -1577,10 +1577,9 @@ function readUTF8(filename, diagnostics) {
1577
1577
  function readBase64(filename) {
1578
1578
  return readFileSync(filename).toString("base64");
1579
1579
  }
1580
- /** Get the filename without extension, falling back to the full basename for dotfiles. */
1580
+ /** Get the filename without extension, a dotfile keeps its whole name. */
1581
1581
  function baseNameWithoutExt(filename) {
1582
- const { name, ext } = parse(filename);
1583
- return name || ext;
1582
+ return parse(filename).name;
1584
1583
  }
1585
1584
  //#endregion
1586
1585
  //#region src/formats.ts
@@ -1624,6 +1623,8 @@ function parseFormatObject(text, start, end) {
1624
1623
  }
1625
1624
  }
1626
1625
  /** Check a parsed format object's fields. Per spec, `name` is optional; `version` and `source` are required. */
1626
+ /** The name given to a format.js that names no format. */
1627
+ const UNNAMED_FORMAT_NAME = "Untitled Story Format";
1627
1628
  function toFormatJSON(raw) {
1628
1629
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
1629
1630
  ok: false,
@@ -1643,7 +1644,7 @@ function toFormatJSON(raw) {
1643
1644
  reason: `Story format version ${JSON.stringify(obj.version)} is not a SemVer version.`
1644
1645
  };
1645
1646
  const data = {
1646
- name: typeof obj.name === "string" ? obj.name : "Untitled Story Format",
1647
+ name: typeof obj.name === "string" ? obj.name : UNNAMED_FORMAT_NAME,
1647
1648
  version: obj.version,
1648
1649
  source: obj.source,
1649
1650
  proofing: obj.proofing === true
@@ -2119,31 +2120,37 @@ function clearIndexCache() {
2119
2120
  /** Requests in progress, by what they fetch, so concurrent compiles make each request once. */
2120
2121
  const sharedRequests = /* @__PURE__ */ new Map();
2121
2122
  /**
2122
- * Runs `start` once for all callers that ask for `key` while it is in progress. A caller whose
2123
- * `signal` aborts stops waiting at once and rejects with the signal's reason; the request
2124
- * itself is aborted (through the signal `start` receives) once no caller waits on it.
2123
+ * Runs `start` once for all callers that ask for `key` while it is in progress. Each caller waits
2124
+ * under its own limits: a caller whose `signal` aborts, or whose `timeout` passes, stops waiting
2125
+ * at once and rejects, while the others keep waiting. The request itself is aborted (through
2126
+ * the signal `start` receives) once no caller waits on it. `start` must not depend on any one
2127
+ * caller: what differs between callers belongs after the shared result.
2125
2128
  */
2126
- function shareRequest(key, signal, start) {
2129
+ function shareRequest(key, { signal, timeout, timedOut }, start) {
2127
2130
  if (signal?.aborted) return Promise.reject(signal.reason);
2128
2131
  const request = sharedRequests.get(key) ?? startSharedRequest(key, start);
2129
2132
  request.waiters++;
2130
2133
  return new Promise((resolve, reject) => {
2131
2134
  let waiting = true;
2135
+ let timer;
2132
2136
  const stopWaiting = () => {
2133
2137
  if (!waiting) return;
2134
2138
  waiting = false;
2135
2139
  signal?.removeEventListener("abort", onAbort);
2140
+ clearTimeout(timer);
2136
2141
  request.waiters--;
2137
2142
  };
2138
- const onAbort = () => {
2143
+ const giveUp = (reason) => {
2139
2144
  stopWaiting();
2140
2145
  if (request.waiters === 0) {
2141
2146
  if (sharedRequests.get(key) === request) sharedRequests.delete(key);
2142
- request.controller.abort(signal?.reason);
2147
+ request.controller.abort(reason);
2143
2148
  }
2144
- reject(signal?.reason);
2149
+ reject(reason);
2145
2150
  };
2151
+ const onAbort = () => giveUp(signal?.reason);
2146
2152
  signal?.addEventListener("abort", onAbort, { once: true });
2153
+ if (timeout > 0 && timeout <= MAX_TIMER_DELAY) timer = setTimeout(() => giveUp(timedOut()), timeout);
2147
2154
  request.promise.then((value) => {
2148
2155
  stopWaiting();
2149
2156
  resolve(value);
@@ -2173,20 +2180,32 @@ function requestTimeout(options) {
2173
2180
  if (!(timeout >= 0)) throw new RangeError(`A story format request timeout must be 0 or more milliseconds, not ${timeout}`);
2174
2181
  return timeout;
2175
2182
  }
2183
+ /** How one caller waits for `url`: under its own signal and timeout. `what` names the request in errors. */
2184
+ function waitOptions(options, what, url) {
2185
+ const timeout = requestTimeout(options);
2186
+ return {
2187
+ signal: options.signal,
2188
+ timeout,
2189
+ timedOut: () => /* @__PURE__ */ new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`)
2190
+ };
2191
+ }
2176
2192
  /**
2177
- * Fetch `url` as text, aborting when `signal` does or after `timeout` milliseconds (0: no limit).
2178
- * `what` names the request in errors, as in "Failed to <what> from <url>".
2193
+ * Fetch `url` as bytes, aborting when `signal` does. `what` names the request in errors, as in
2194
+ * "Failed to <what> from <url>". The bytes are returned undecoded, so checksums cover what was served.
2179
2195
  */
2180
- async function fetchText(url, what, signal, timeout) {
2181
- const limit = timeout > 0 && timeout <= MAX_TIMER_DELAY ? AbortSignal.timeout(timeout) : void 0;
2182
- try {
2183
- const res = await fetch(url, { signal: limit ? AbortSignal.any([signal, limit]) : signal });
2184
- if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
2185
- return await res.text();
2186
- } catch (e) {
2187
- if (limit?.aborted && !signal.aborted) throw new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`, { cause: e });
2188
- throw e;
2189
- }
2196
+ async function fetchBytes(url, what, signal) {
2197
+ const res = await fetch(url, { signal });
2198
+ if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
2199
+ return new Uint8Array(await res.arrayBuffer());
2200
+ }
2201
+ /** Download a format.js once for every caller in this process that asks for `url` meanwhile. */
2202
+ function shareDownload(url, options) {
2203
+ return shareRequest(`download\0${url}`, waitOptions(options, "download format", url), (signal) => fetchBytes(url, "download format", signal));
2204
+ }
2205
+ /** Decode a downloaded format.js as local ones are (UTF-8, else Windows-1252), without a leading BOM. */
2206
+ function decodeDownload(bytes, url) {
2207
+ const { text } = decodeText(bytes, url);
2208
+ return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
2190
2209
  }
2191
2210
  /** Validate that a JSON value is a valid SFAIndexEntry. */
2192
2211
  function isValidEntry(val) {
@@ -2208,9 +2227,8 @@ async function fetchIndex(url, options = {}) {
2208
2227
  options.signal?.throwIfAborted();
2209
2228
  const cached = indexCache.get(url);
2210
2229
  if (cached) return cached;
2211
- const timeout = requestTimeout(options);
2212
- return shareRequest(`index\0${url}`, options.signal, async (signal) => {
2213
- const text = await fetchText(url, "fetch format index", signal, timeout);
2230
+ return shareRequest(`index\0${url}`, waitOptions(options, "fetch format index", url), async (signal) => {
2231
+ const text = decodeDownload(await fetchBytes(url, "fetch format index", signal), url);
2214
2232
  const data = validateSFAIndex(JSON.parse(text));
2215
2233
  indexCache.set(url, data);
2216
2234
  return data;
@@ -2247,26 +2265,37 @@ function cachedFormatInfo(id, filename, data) {
2247
2265
  };
2248
2266
  }
2249
2267
  /**
2250
- * Download a format, verify its checksum, write it to the cache shared by name and version, and
2251
- * return its StoryFormatInfo. Concurrent calls for one download URL share one request.
2268
+ * Check that a downloaded format is the one the index entry promised. The name matches without
2269
+ * regard to case, as format requests do, and the version by SemVer precedence, so "1.0" and "v1.0.0"
2270
+ * agree. A format.js that names no format ({@link UNNAMED_FORMAT_NAME}) is allowed: such formats
2271
+ * exist, and the index entry then supplies the name it is cached under.
2272
+ */
2273
+ function checkFormatIdentity(entry, data, downloadUrl) {
2274
+ if ((data.name === "Untitled Story Format" || data.name.toLowerCase() === entry.name.toLowerCase()) && sameVersion(data.version, entry.version)) return;
2275
+ throw new Error(`Story format mismatch for ${downloadUrl}: the index lists ${entry.name} ${entry.version}, but the download is ${data.name} ${data.version}`);
2276
+ }
2277
+ /**
2278
+ * Download a format, verify its checksum, check that it is the format the entry names, write it to
2279
+ * the cache shared by name and version, and return its StoryFormatInfo. Concurrent calls for one
2280
+ * download URL share one request for the bytes; each call then verifies them against its own
2281
+ * entry, within its own timeout.
2252
2282
  */
2253
2283
  async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
2254
2284
  cachedFormatDir(entry.name, entry.version);
2255
- const timeout = requestTimeout(options);
2256
- return shareRequest(`format\0${getCacheDir()}\0${downloadUrl}`, options.signal, async (signal) => {
2257
- const text = await fetchText(downloadUrl, "download format", signal, timeout);
2258
- const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
2259
- if (checksumKey) {
2260
- const expected = entry.checksums[checksumKey];
2261
- if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
2262
- if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
2263
- }
2264
- const id = makeFormatId(entry.name, entry.version);
2265
- const data = parseFormatJSON(text, id);
2266
- if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
2267
- signal.throwIfAborted();
2268
- return cachedFormatInfo(id, writeCacheEntry(resolve(getCacheDir()), [entry.name, entry.version], text), data);
2269
- });
2285
+ const bytes = await shareDownload(downloadUrl, options);
2286
+ const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
2287
+ if (checksumKey) {
2288
+ const expected = entry.checksums[checksumKey];
2289
+ if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
2290
+ if (!await verifySHA256(bytes, expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
2291
+ }
2292
+ const text = decodeDownload(bytes, downloadUrl);
2293
+ const id = makeFormatId(entry.name, entry.version);
2294
+ const data = parseFormatJSON(text, id);
2295
+ if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
2296
+ checkFormatIdentity(entry, data, downloadUrl);
2297
+ options.signal?.throwIfAborted();
2298
+ return cachedFormatInfo(id, writeCacheEntry(resolve(getCacheDir()), [entry.name, entry.version], text), data);
2270
2299
  }
2271
2300
  /**
2272
2301
  * Download a direct format.js URL, parse its JSON, cache it under that URL (apart from the
@@ -2274,16 +2303,13 @@ async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
2274
2303
  * one URL share one request.
2275
2304
  */
2276
2305
  async function fetchDirectFormat(url, options = {}) {
2277
- const timeout = requestTimeout(options);
2278
- return shareRequest(`url\0${getUrlCacheDir()}\0${url}`, options.signal, async (signal) => {
2279
- const text = await fetchText(url, "download format", signal, timeout);
2280
- const data = parseFormatJSON(text);
2281
- if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
2282
- cachedFormatDir(data.name, data.version);
2283
- signal.throwIfAborted();
2284
- const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
2285
- return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
2286
- });
2306
+ const text = decodeDownload(await shareDownload(url, options), url);
2307
+ const data = parseFormatJSON(text);
2308
+ if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
2309
+ cachedFormatDir(data.name, data.version);
2310
+ options.signal?.throwIfAborted();
2311
+ const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
2312
+ return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
2287
2313
  }
2288
2314
  /** The copy of a direct format URL in the download cache, if it has been downloaded before. */
2289
2315
  function getCachedDirectFormat(url) {
@@ -2864,8 +2890,6 @@ function* tweeLexer(input) {
2864
2890
  const items = ctx.drainItems();
2865
2891
  for (const item of items) yield item;
2866
2892
  }
2867
- const remaining = ctx.drainItems();
2868
- for (const item of remaining) yield item;
2869
2893
  }
2870
2894
  /** Convenience class wrapper for the lexer generator. */
2871
2895
  var TweeLexer = class {
@@ -3018,9 +3042,7 @@ function parseTwee(source, options = {}) {
3018
3042
  };
3019
3043
  }
3020
3044
  try {
3021
- const raw = JSON.parse(item.val);
3022
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error("expected a JSON object");
3023
- const parsed = raw;
3045
+ const parsed = JSON.parse(item.val);
3024
3046
  const meta = {};
3025
3047
  for (const [key, value] of Object.entries(parsed)) if (typeof value === "string") meta[key] = value;
3026
3048
  current.metadata = meta;
@@ -6274,8 +6296,8 @@ function loadFont(story, filename, diagnostics) {
6274
6296
  * Load sources with mtime-based caching for incremental rebuilds.
6275
6297
  * Always creates a fresh Story; cached passages are replayed via storyAdd().
6276
6298
  */
6277
- function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
6278
- const currentFiles = /* @__PURE__ */ new Set();
6299
+ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles, buildFiles) {
6300
+ const currentFiles = new Set(buildFiles);
6279
6301
  const changedPaths = changedFiles === void 0 ? void 0 : new Set([...changedFiles].map((f) => resolve(f)));
6280
6302
  for (const filename of filenames) {
6281
6303
  if (processedFiles.has(filename)) {
@@ -6340,16 +6362,6 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
6340
6362
  }
6341
6363
  //#endregion
6342
6364
  //#region src/template.ts
6343
- /**
6344
- * An HTML closing head tag: `</head` in any letter case, followed by whitespace, `/` or `>`, up to the next `>`.
6345
- * This is how the HTML tokenizer reads an end tag; it ignores whatever sits between the tag name and the `>`.
6346
- */
6347
- const CLOSING_HEAD_TAG = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>";
6348
- /**
6349
- * The start of an HTML body start tag: `<body` in any letter case, followed by whitespace, `/` or `>` (so not
6350
- * `<bodyx>`). Only the `<body` is matched; the attributes and `>` after it are left alone.
6351
- */
6352
- const BODY_START_TAG = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
6353
6365
  /** The regular expression source matching exactly `text`. */
6354
6366
  function literal(text) {
6355
6367
  return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
@@ -6375,6 +6387,8 @@ function fillTemplateParts(parts) {
6375
6387
  return part.text.replace(pattern, (match, ...groups) => {
6376
6388
  const slot = slots.find((_, i) => groups[i] !== void 0);
6377
6389
  if (slot === void 0 || slot.occurrences === "first" && filled.has(slot)) return match;
6390
+ const offset = groups[slots.length];
6391
+ if (slot.accepts !== void 0 && typeof offset === "number" && !slot.accepts(part.text, offset)) return match;
6378
6392
  filled.add(slot);
6379
6393
  return slot.replacement(match);
6380
6394
  });
@@ -6465,15 +6479,18 @@ function placeHead(content, texts, owner) {
6465
6479
  slot: void 0,
6466
6480
  diagnostics: []
6467
6481
  };
6468
- const found = (tag) => texts.some((text) => new RegExp(tag).test(text));
6469
- if (found("<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>")) return {
6470
- slot: beforeTag(CLOSING_HEAD_TAG, content),
6482
+ const scans = texts.map((text) => ({
6483
+ text,
6484
+ tags: scanHeadTags(text)
6485
+ }));
6486
+ if (scans.some(({ tags }) => tags.closingHead !== void 0)) return {
6487
+ slot: beforeTag(CLOSING_HEAD_PREFIX, "closingHead", content),
6471
6488
  diagnostics: []
6472
6489
  };
6473
- if (found("<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])")) {
6490
+ if (scans.some(({ tags }) => tags.bodyStart !== void 0)) {
6474
6491
  const message = `${owner} has no closing head tag; the modules and head file were injected before its body start tag.`;
6475
6492
  return {
6476
- slot: beforeTag(BODY_START_TAG, content),
6493
+ slot: beforeTag(BODY_START_PREFIX, "bodyStart", content),
6477
6494
  diagnostics: [{
6478
6495
  level: "warning",
6479
6496
  message
@@ -6488,12 +6505,98 @@ function placeHead(content, texts, owner) {
6488
6505
  }]
6489
6506
  };
6490
6507
  }
6491
- /** The slot that inserts `content` on its own line before the first match of `tag`, which is kept as written. */
6492
- function beforeTag(tag, content) {
6508
+ /** `</head` in any letter case, followed by whitespace, `/` or `>`; the rest of the tag is left alone. */
6509
+ const CLOSING_HEAD_PREFIX = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])";
6510
+ /** `<body` in any letter case, followed by whitespace, `/` or `>` (so not `<bodyx>`). */
6511
+ const BODY_START_PREFIX = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
6512
+ /** Elements whose content is text, so a tag written there is not a tag. */
6513
+ const RAW_TEXT_ELEMENTS = /* @__PURE__ */ new Set([
6514
+ "script",
6515
+ "style",
6516
+ "textarea",
6517
+ "title"
6518
+ ]);
6519
+ const isSpace = (c) => c === " " || c === " " || c === "\n" || c === "\f" || c === "\r";
6520
+ const isLetter = (c) => c !== void 0 && /^[A-Za-z]$/.test(c);
6521
+ /**
6522
+ * Find the first closing head tag and body start tag that HTML would read as tags: not those in a comment, in
6523
+ * the text of a script, style, textarea or title element, or in a quoted attribute value. A small scanner, not
6524
+ * a full HTML tokenizer; it needs no more than the tags and where they end.
6525
+ */
6526
+ function scanHeadTags(html) {
6527
+ let closingHead;
6528
+ let bodyStart;
6529
+ let i = 0;
6530
+ while (i < html.length) {
6531
+ const lt = html.indexOf("<", i);
6532
+ if (lt === -1) break;
6533
+ const next = html[lt + 1];
6534
+ if (html.startsWith("<!--", lt)) {
6535
+ const end = html.indexOf("-->", lt + 4);
6536
+ i = end === -1 ? html.length : end + 3;
6537
+ } else if (next === "!" || next === "?") {
6538
+ const end = html.indexOf(">", lt);
6539
+ i = end === -1 ? html.length : end + 1;
6540
+ } else if (next === "/" && isLetter(html[lt + 2])) {
6541
+ const { name, end } = readTag(html, lt + 2);
6542
+ if (name === "head" && closingHead === void 0) closingHead = lt;
6543
+ i = end;
6544
+ } else if (isLetter(next)) {
6545
+ const { name, end } = readTag(html, lt + 1);
6546
+ if (name === "body" && bodyStart === void 0) bodyStart = lt;
6547
+ i = RAW_TEXT_ELEMENTS.has(name) ? rawTextEnd(html, name, end) : end;
6548
+ } else i = lt + 1;
6549
+ if (closingHead !== void 0 && bodyStart !== void 0) break;
6550
+ }
6551
+ return {
6552
+ closingHead,
6553
+ bodyStart
6554
+ };
6555
+ }
6556
+ /** Read a tag whose name starts at `from`: its lowercase name, and the offset after its `>` (or the end). */
6557
+ function readTag(html, from) {
6558
+ let i = from;
6559
+ while (i < html.length && !isSpace(html[i]) && html[i] !== "/" && html[i] !== ">") i++;
6560
+ const name = html.slice(from, i).toLowerCase();
6561
+ while (i < html.length && html[i] !== ">") {
6562
+ if (html[i] !== "=") {
6563
+ i++;
6564
+ continue;
6565
+ }
6566
+ i++;
6567
+ while (isSpace(html[i])) i++;
6568
+ const quote = html[i];
6569
+ if (quote === "\"" || quote === "'") {
6570
+ const close = html.indexOf(quote, i + 1);
6571
+ i = close === -1 ? html.length : close + 1;
6572
+ }
6573
+ }
6574
+ return {
6575
+ name,
6576
+ end: Math.min(i + 1, html.length)
6577
+ };
6578
+ }
6579
+ /** The offset of the end tag that closes a raw text element, given where its text starts (or the end). */
6580
+ function rawTextEnd(html, name, from) {
6581
+ const close = new RegExp(`</${name}(?=[\\t\\n\\f\\r />])`, "gi");
6582
+ close.lastIndex = from;
6583
+ return close.exec(html)?.index ?? html.length;
6584
+ }
6585
+ /**
6586
+ * The slot that inserts `content` on its own line before the first real tag of the kind `which` names (see
6587
+ * `scanHeadTags()`), which is kept as written. A look-alike inside a script or comment is left alone.
6588
+ */
6589
+ function beforeTag(pattern, which, content) {
6590
+ const cache = /* @__PURE__ */ new Map();
6591
+ const offsetIn = (text) => {
6592
+ if (!cache.has(text)) cache.set(text, scanHeadTags(text)[which]);
6593
+ return cache.get(text);
6594
+ };
6493
6595
  return {
6494
- pattern: tag,
6596
+ pattern,
6495
6597
  occurrences: "first",
6496
- replacement: (match) => `${content}\n${match}`
6598
+ replacement: (match) => `${content}\n${match}`,
6599
+ accepts: (text, offset) => offsetIn(text) === offset
6497
6600
  };
6498
6601
  }
6499
6602
  //#endregion
@@ -6939,6 +7042,7 @@ async function compile(options) {
6939
7042
  */
6940
7043
  async function compileToFile(options) {
6941
7044
  const result = await compileForOutputFile(options, options.outFile);
7045
+ options.signal?.throwIfAborted();
6942
7046
  writeFileAtomic(options.outFile, result.output);
6943
7047
  return result;
6944
7048
  }
@@ -7100,34 +7204,61 @@ async function buildOutput(options, cache, changedFiles, outputs) {
7100
7204
  const timeout = options.formatFetchTimeout;
7101
7205
  if (timeout !== void 0 && !(timeout >= 0)) throw new TweeTsError(`formatFetchTimeout must be 0 or more milliseconds, not ${timeout}.`);
7102
7206
  clearIndexCache();
7103
- const filePaths = [];
7104
- const inlineSources = [];
7105
- for (const source of options.sources) if (typeof source === "string") filePaths.push(source);
7106
- else inlineSources.push(source);
7107
- const sourcePaths = getFilenames(filePaths, written, options.exclude);
7108
- diagnostics.push(...sourcePaths.diagnostics);
7109
- rejectOutputSources(sourcePaths.outputSources, diagnostics);
7110
- const sourceFilenames = sourcePaths.filenames;
7207
+ const groups = [];
7208
+ const outputSources = [];
7209
+ for (const source of options.sources) {
7210
+ const last = groups[groups.length - 1];
7211
+ if (typeof source === "string") {
7212
+ if (last?.kind === "paths") last.paths.push(source);
7213
+ else groups.push({
7214
+ kind: "paths",
7215
+ paths: [source]
7216
+ });
7217
+ } else if (last?.kind === "inline") last.sources.push(source);
7218
+ else groups.push({
7219
+ kind: "inline",
7220
+ sources: [source]
7221
+ });
7222
+ }
7223
+ const walked = groups.map((group) => {
7224
+ if (group.kind === "inline") return group;
7225
+ const found = getFilenames(group.paths, written, options.exclude);
7226
+ diagnostics.push(...found.diagnostics);
7227
+ outputSources.push(...found.outputSources);
7228
+ return {
7229
+ kind: "files",
7230
+ filenames: found.filenames
7231
+ };
7232
+ });
7233
+ rejectOutputSources(outputSources, diagnostics);
7234
+ const buildFiles = new Set(walked.flatMap((group) => group.kind === "files" ? group.filenames : []));
7111
7235
  const story = createStory();
7112
7236
  const processedFiles = /* @__PURE__ */ new Set();
7113
- if (cache) loadSourcesCached(story, sourceFilenames, {
7237
+ for (const group of walked) if (group.kind === "inline") loadInlineSources(story, group.sources, {
7114
7238
  trim,
7115
7239
  twee2Compat
7116
- }, diagnostics, processedFiles, cache, changedFiles);
7117
- else loadSources(story, sourceFilenames, {
7240
+ }, diagnostics);
7241
+ else if (cache) loadSourcesCached(story, group.filenames, {
7242
+ trim,
7243
+ twee2Compat
7244
+ }, diagnostics, processedFiles, cache, changedFiles, buildFiles);
7245
+ else loadSources(story, group.filenames, {
7118
7246
  trim,
7119
7247
  twee2Compat
7120
7248
  }, diagnostics, processedFiles);
7121
- loadInlineSources(story, inlineSources, {
7249
+ if (cache && buildFiles.size === 0) loadSourcesCached(story, [], {
7122
7250
  trim,
7123
7251
  twee2Compat
7124
- }, diagnostics);
7252
+ }, diagnostics, processedFiles, cache, changedFiles);
7125
7253
  if (options.tagAliases) story.passages = applyTagAliases(story.passages, options.tagAliases);
7126
7254
  let format;
7127
- if (outputMode === "html") format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
7128
- ...options,
7129
- noRemote
7130
- }, diagnostics);
7255
+ if (outputMode === "html") {
7256
+ format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
7257
+ ...options,
7258
+ noRemote
7259
+ }, diagnostics);
7260
+ options.signal?.throwIfAborted();
7261
+ }
7131
7262
  if (options.startPassage) story.twine2.start = options.startPassage;
7132
7263
  const startName = story.twine2.start || DEFAULT_START_NAME;
7133
7264
  if (testMode) story.twine2.options.set("debug", true);
@@ -7179,6 +7310,7 @@ async function buildOutput(options, cache, changedFiles, outputs) {
7179
7310
  ...getStoryStats(story, options.wordCountMethod),
7180
7311
  files: [...processedFiles]
7181
7312
  };
7313
+ options.signal?.throwIfAborted();
7182
7314
  return {
7183
7315
  output,
7184
7316
  story,
@@ -8944,22 +9076,13 @@ const foldKey = (key) => key.toLowerCase().replace(/[-_]/g, "");
8944
9076
  *
8945
9077
  * @param diagnostics Receives warnings that do not stop the config from loading: keys the config does not
8946
9078
  * define, and a file that is not valid UTF-8 (it is read as Windows-1252).
8947
- * @throws When the file is not valid JSON or fails {@link validateConfig}.
9079
+ * @throws When the file exists but cannot be read, is not valid JSON or fails {@link validateConfig}.
8948
9080
  */
8949
9081
  function loadConfig(dir, diagnostics) {
8950
9082
  const base = dir ?? process.cwd();
8951
9083
  const configPath = join(base, CONFIG_FILENAME);
8952
9084
  if (!existsSync(configPath)) return null;
8953
- const readDiagnostics = [];
8954
- let raw;
8955
- try {
8956
- raw = readUTF8(configPath, readDiagnostics);
8957
- } catch {
8958
- return null;
8959
- }
8960
- const config = parseConfig(raw, configPath, readDiagnostics);
8961
- diagnostics?.push(...readDiagnostics);
8962
- return config;
9085
+ return loadConfigFile(configPath, diagnostics);
8963
9086
  }
8964
9087
  /**
8965
9088
  * Load a config from a specific file path.