@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/bin/twee-ts.js +229 -107
- package/dist/bin/twee-ts.js.map +1 -1
- package/dist/index.cjs +230 -107
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -3
- package/dist/index.d.ts +5 -3
- package/dist/index.js +230 -107
- package/dist/index.js.map +1 -1
- package/dist/{paths-DgO9oo6G.js → paths-CUv1qfz-.js} +228 -97
- package/dist/paths-CUv1qfz-.js.map +1 -0
- package/dist/plugins/rollup.js +1 -1
- package/dist/plugins/vite.js +1 -1
- package/package.json +1 -1
- package/dist/paths-DgO9oo6G.js.map +0 -1
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,
|
|
617
|
-
* return its StoryFormatInfo. Concurrent calls for one
|
|
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,
|
|
617
|
-
* return its StoryFormatInfo. Concurrent calls for one
|
|
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,
|
|
1580
|
+
/** Get the filename without extension, a dotfile keeps its whole name. */
|
|
1581
1581
|
function baseNameWithoutExt(filename) {
|
|
1582
|
-
|
|
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 :
|
|
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.
|
|
2123
|
-
*
|
|
2124
|
-
*
|
|
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
|
|
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(
|
|
2147
|
+
request.controller.abort(reason);
|
|
2143
2148
|
}
|
|
2144
|
-
reject(
|
|
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
|
|
2178
|
-
*
|
|
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
|
|
2181
|
-
const
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
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
|
-
|
|
2212
|
-
|
|
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
|
-
*
|
|
2251
|
-
*
|
|
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
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
const
|
|
2259
|
-
if (
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
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
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
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
|
|
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 =
|
|
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
|
|
6469
|
-
|
|
6470
|
-
|
|
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 (
|
|
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(
|
|
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
|
-
/**
|
|
6492
|
-
|
|
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
|
|
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
|
|
7104
|
-
const
|
|
7105
|
-
for (const source of options.sources)
|
|
7106
|
-
|
|
7107
|
-
|
|
7108
|
-
|
|
7109
|
-
|
|
7110
|
-
|
|
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 (
|
|
7237
|
+
for (const group of walked) if (group.kind === "inline") loadInlineSources(story, group.sources, {
|
|
7114
7238
|
trim,
|
|
7115
7239
|
twee2Compat
|
|
7116
|
-
}, diagnostics
|
|
7117
|
-
else
|
|
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
|
-
|
|
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")
|
|
7128
|
-
|
|
7129
|
-
|
|
7130
|
-
|
|
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
|
-
|
|
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.
|