@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/bin/twee-ts.js
CHANGED
|
@@ -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,
|
|
1536
|
+
/** Get the filename without extension, a dotfile keeps its whole name. */
|
|
1537
1537
|
function baseNameWithoutExt(filename) {
|
|
1538
|
-
|
|
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 :
|
|
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.
|
|
2062
|
-
*
|
|
2063
|
-
*
|
|
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
|
|
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(
|
|
2086
|
+
request.controller.abort(reason);
|
|
2082
2087
|
}
|
|
2083
|
-
reject(
|
|
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
|
|
2117
|
-
*
|
|
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
|
|
2120
|
-
const
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
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
|
-
|
|
2151
|
-
|
|
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
|
-
*
|
|
2190
|
-
*
|
|
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
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
const
|
|
2198
|
-
if (
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
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
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
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
|
|
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 =
|
|
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
|
|
6356
|
-
|
|
6357
|
-
|
|
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 (
|
|
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(
|
|
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
|
-
/**
|
|
6379
|
-
|
|
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
|
-
|
|
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
|
|
6949
|
-
const
|
|
6950
|
-
for (const source of options.sources)
|
|
6951
|
-
|
|
6952
|
-
|
|
6953
|
-
|
|
6954
|
-
|
|
6955
|
-
|
|
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 (
|
|
7081
|
+
for (const group of walked) if (group.kind === "inline") loadInlineSources(story, group.sources, {
|
|
6959
7082
|
trim,
|
|
6960
7083
|
twee2Compat
|
|
6961
|
-
}, diagnostics
|
|
6962
|
-
else
|
|
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
|
-
|
|
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")
|
|
6973
|
-
|
|
6974
|
-
|
|
6975
|
-
|
|
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
|
-
|
|
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.
|