@rohal12/twee-ts 1.18.0 → 1.18.2
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 +360 -127
- package/dist/bin/twee-ts.js.map +1 -1
- package/dist/index.cjs +360 -126
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -4
- package/dist/index.d.ts +7 -4
- package/dist/index.js +361 -127
- package/dist/index.js.map +1 -1
- package/dist/{paths-DgO9oo6G.js → paths-BuTAg2av.js} +367 -118
- package/dist/paths-BuTAg2av.js.map +1 -0
- package/dist/plugins/rollup.js +1 -1
- package/dist/plugins/vite.js +28 -10
- package/dist/plugins/vite.js.map +1 -1
- package/package.json +1 -1
- package/dist/paths-DgO9oo6G.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as nodePath from "node:path";
|
|
2
2
|
import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
4
|
-
import { accessSync, chmodSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, watch as watch$1, writeFileSync } from "node:fs";
|
|
4
|
+
import { accessSync, chmodSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, watch as watch$1, writeFileSync } from "node:fs";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
//#region src/ifid.ts
|
|
@@ -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
|
|
@@ -1600,6 +1599,89 @@ function stripSetupFunction(chunk) {
|
|
|
1600
1599
|
for (const match of chunk.matchAll(SETUP_FUNCTION_PROPERTY)) lastIndex = match.index;
|
|
1601
1600
|
return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
|
|
1602
1601
|
}
|
|
1602
|
+
/** Index after the whitespace and `//` or block comments starting at `i`. */
|
|
1603
|
+
function skipTrivia(text, i) {
|
|
1604
|
+
let pos = i;
|
|
1605
|
+
for (;;) {
|
|
1606
|
+
const ch = text[pos];
|
|
1607
|
+
if (ch !== void 0 && /\s/.test(ch)) pos++;
|
|
1608
|
+
else if (text.startsWith("//", pos)) {
|
|
1609
|
+
const newline = text.indexOf("\n", pos);
|
|
1610
|
+
pos = newline === -1 ? text.length : newline + 1;
|
|
1611
|
+
} else if (text.startsWith("/*", pos)) {
|
|
1612
|
+
const close = text.indexOf("*/", pos + 2);
|
|
1613
|
+
pos = close === -1 ? text.length : close + 2;
|
|
1614
|
+
} else return pos;
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
/** Index after the quoted string starting at `i` (a quote character); template literals nest `${…}`. */
|
|
1618
|
+
function skipString(text, i) {
|
|
1619
|
+
const quote = text[i];
|
|
1620
|
+
let pos = i + 1;
|
|
1621
|
+
while (pos < text.length) {
|
|
1622
|
+
const ch = text[pos];
|
|
1623
|
+
if (ch === "\\") pos += 2;
|
|
1624
|
+
else if (ch === quote) return pos + 1;
|
|
1625
|
+
else if (quote === "`" && ch === "$" && text[pos + 1] === "{") {
|
|
1626
|
+
const close = findClosingBrace(text, pos + 1);
|
|
1627
|
+
if (close === -1) return text.length;
|
|
1628
|
+
pos = close + 1;
|
|
1629
|
+
} else pos++;
|
|
1630
|
+
}
|
|
1631
|
+
return text.length;
|
|
1632
|
+
}
|
|
1633
|
+
function startsComment(text, pos) {
|
|
1634
|
+
return text[pos] === "/" && (text[pos + 1] === "/" || text[pos + 1] === "*");
|
|
1635
|
+
}
|
|
1636
|
+
function startsString(text, pos) {
|
|
1637
|
+
const ch = text[pos];
|
|
1638
|
+
return ch === "\"" || ch === "'" || ch === "`";
|
|
1639
|
+
}
|
|
1640
|
+
/** Index of the `}` matching the `{` at `open`, skipping comments and strings; -1 when unbalanced. */
|
|
1641
|
+
function findClosingBrace(text, open) {
|
|
1642
|
+
let depth = 0;
|
|
1643
|
+
let pos = open;
|
|
1644
|
+
while (pos < text.length) if (startsComment(text, pos)) pos = skipTrivia(text, pos);
|
|
1645
|
+
else if (startsString(text, pos)) pos = skipString(text, pos);
|
|
1646
|
+
else {
|
|
1647
|
+
if (text[pos] === "{") depth++;
|
|
1648
|
+
else if (text[pos] === "}" && --depth === 0) return pos;
|
|
1649
|
+
pos++;
|
|
1650
|
+
}
|
|
1651
|
+
return -1;
|
|
1652
|
+
}
|
|
1653
|
+
const STORY_FORMAT = "storyFormat";
|
|
1654
|
+
const IDENTIFIER_CHAR = /[\w$]/;
|
|
1655
|
+
/**
|
|
1656
|
+
* Locate the object literal passed to `storyFormat(` as `[start, end)` offsets, ignoring comments and
|
|
1657
|
+
* strings outside it and never evaluating anything. Without such a call, the first `{` outside comments
|
|
1658
|
+
* and strings starts the object. When its closing brace cannot be matched (a Harlowe setup function can
|
|
1659
|
+
* hold regular expression literals), the last `}` of the file ends it.
|
|
1660
|
+
*/
|
|
1661
|
+
function locateFormatObject(text) {
|
|
1662
|
+
let start = -1;
|
|
1663
|
+
let firstBrace = -1;
|
|
1664
|
+
let pos = 0;
|
|
1665
|
+
while (pos < text.length && start === -1) if (startsComment(text, pos)) pos = skipTrivia(text, pos);
|
|
1666
|
+
else if (startsString(text, pos)) pos = skipString(text, pos);
|
|
1667
|
+
else if (text[pos] === "{") {
|
|
1668
|
+
if (firstBrace === -1) firstBrace = pos;
|
|
1669
|
+
pos++;
|
|
1670
|
+
} else if (text.startsWith(STORY_FORMAT, pos) && !IDENTIFIER_CHAR.test(text[pos - 1] ?? "")) {
|
|
1671
|
+
const paren = skipTrivia(text, pos + 11);
|
|
1672
|
+
const brace = text[paren] === "(" ? skipTrivia(text, paren + 1) : -1;
|
|
1673
|
+
if (text[brace] === "{") start = brace;
|
|
1674
|
+
pos += 11;
|
|
1675
|
+
} else pos++;
|
|
1676
|
+
if (start === -1) start = firstBrace;
|
|
1677
|
+
if (start === -1) return void 0;
|
|
1678
|
+
const close = findClosingBrace(text, start);
|
|
1679
|
+
const end = close === -1 ? text.lastIndexOf("}") : close;
|
|
1680
|
+
return end < start ? void 0 : {
|
|
1681
|
+
start,
|
|
1682
|
+
end: end + 1
|
|
1683
|
+
};
|
|
1684
|
+
}
|
|
1603
1685
|
/**
|
|
1604
1686
|
* Parse the format object at `start`–`end` of `text`: strict JSON first (fast), then the
|
|
1605
1687
|
* JavaScript literal subset formats use. Error positions point into `text`.
|
|
@@ -1624,6 +1706,8 @@ function parseFormatObject(text, start, end) {
|
|
|
1624
1706
|
}
|
|
1625
1707
|
}
|
|
1626
1708
|
/** Check a parsed format object's fields. Per spec, `name` is optional; `version` and `source` are required. */
|
|
1709
|
+
/** The name given to a format.js that names no format. */
|
|
1710
|
+
const UNNAMED_FORMAT_NAME = "Untitled Story Format";
|
|
1627
1711
|
function toFormatJSON(raw) {
|
|
1628
1712
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
|
|
1629
1713
|
ok: false,
|
|
@@ -1643,7 +1727,7 @@ function toFormatJSON(raw) {
|
|
|
1643
1727
|
reason: `Story format version ${JSON.stringify(obj.version)} is not a SemVer version.`
|
|
1644
1728
|
};
|
|
1645
1729
|
const data = {
|
|
1646
|
-
name: typeof obj.name === "string" ? obj.name :
|
|
1730
|
+
name: typeof obj.name === "string" ? obj.name : UNNAMED_FORMAT_NAME,
|
|
1647
1731
|
version: obj.version,
|
|
1648
1732
|
source: obj.source,
|
|
1649
1733
|
proofing: obj.proofing === true
|
|
@@ -1668,15 +1752,14 @@ function toFormatJSON(raw) {
|
|
|
1668
1752
|
* `harlowe-3` directory or a direct download.
|
|
1669
1753
|
*/
|
|
1670
1754
|
function decodeFormatJSON(source) {
|
|
1671
|
-
const
|
|
1672
|
-
|
|
1673
|
-
if (first === -1 || last < first) return {
|
|
1755
|
+
const located = locateFormatObject(source);
|
|
1756
|
+
if (located === void 0) return {
|
|
1674
1757
|
ok: false,
|
|
1675
1758
|
reason: "Could not find Twine 2 style story format JSON chunk."
|
|
1676
1759
|
};
|
|
1677
|
-
const parsed = parseFormatObject(source,
|
|
1760
|
+
const parsed = parseFormatObject(source, located.start, located.end);
|
|
1678
1761
|
if (parsed.ok) return toFormatJSON(parsed.value);
|
|
1679
|
-
const stripped = stripSetupFunction(source.slice(
|
|
1762
|
+
const stripped = stripSetupFunction(source.slice(located.start, located.end));
|
|
1680
1763
|
const retried = stripped === void 0 ? void 0 : parseFormatObject(stripped, 0, stripped.length);
|
|
1681
1764
|
if (retried?.ok) return toFormatJSON(retried.value);
|
|
1682
1765
|
return {
|
|
@@ -1980,16 +2063,17 @@ function readFormatSource(format, diagnostics) {
|
|
|
1980
2063
|
/**
|
|
1981
2064
|
* Writes `data` to `path` so that a reader sees either the previous file or the complete new
|
|
1982
2065
|
* one, never a partly written file. The data goes to a temporary file in the same folder,
|
|
1983
|
-
* which is then renamed over `path`. When `path` is a symlink, the file it
|
|
1984
|
-
* replaced, and an existing file keeps its permissions.
|
|
2066
|
+
* which is then renamed over `path`. When `path` is a symlink (even a dangling one, or a chain of links), the file it
|
|
2067
|
+
* finally points to is replaced or created and the link stays, and an existing file keeps its permissions.
|
|
1985
2068
|
*
|
|
1986
2069
|
* On failure (a full disk, a missing folder) the temporary file is removed, `path` is left
|
|
1987
2070
|
* as it was, and the error is thrown with `path` in its message and its original `code`.
|
|
1988
2071
|
*/
|
|
1989
2072
|
function writeFileAtomic(path, data) {
|
|
1990
|
-
|
|
1991
|
-
const temp = join(dirname(target), `.${basename(target)}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
|
|
2073
|
+
let temp;
|
|
1992
2074
|
try {
|
|
2075
|
+
const target = resolveWriteTarget(path);
|
|
2076
|
+
temp = join(dirname(target), `.${basename(target)}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
|
|
1993
2077
|
writeFileSync(temp, data, {
|
|
1994
2078
|
encoding: "utf-8",
|
|
1995
2079
|
flag: "wx"
|
|
@@ -1997,17 +2081,25 @@ function writeFileAtomic(path, data) {
|
|
|
1997
2081
|
keepPermissions(target, temp);
|
|
1998
2082
|
renameSync(temp, target);
|
|
1999
2083
|
} catch (e) {
|
|
2000
|
-
rmSync(temp, { force: true });
|
|
2084
|
+
if (temp !== void 0) rmSync(temp, { force: true });
|
|
2001
2085
|
throw writeError(path, e);
|
|
2002
2086
|
}
|
|
2003
2087
|
}
|
|
2004
|
-
/**
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2088
|
+
/** Links followed before a path counts as a cycle (Linux stops at 40 as well). */
|
|
2089
|
+
const MAX_LINK_DEPTH = 40;
|
|
2090
|
+
/**
|
|
2091
|
+
* The file a symlink at `path` finally points to, or `path` itself when it is no link. Links are
|
|
2092
|
+
* followed one by one, so a link whose target does not exist yet (absolute, relative, or a chain
|
|
2093
|
+
* of them) still resolves to the file that should be created. A cycle or an unreadable link throws.
|
|
2094
|
+
*/
|
|
2095
|
+
function resolveWriteTarget(path) {
|
|
2096
|
+
let current = path;
|
|
2097
|
+
for (let depth = 0; depth <= MAX_LINK_DEPTH; depth++) {
|
|
2098
|
+
const stat = lstatSync(current, { throwIfNoEntry: false });
|
|
2099
|
+
if (stat === void 0 || !stat.isSymbolicLink()) return current;
|
|
2100
|
+
current = resolve(dirname(current), readlinkSync(current));
|
|
2010
2101
|
}
|
|
2102
|
+
throw Object.assign(/* @__PURE__ */ new Error(`ELOOP: too many levels of symbolic links, ${path}`), { code: "ELOOP" });
|
|
2011
2103
|
}
|
|
2012
2104
|
/**
|
|
2013
2105
|
* Gives `temp` the permission bits of `target`, when `target` exists. Best effort: on a file
|
|
@@ -2119,31 +2211,37 @@ function clearIndexCache() {
|
|
|
2119
2211
|
/** Requests in progress, by what they fetch, so concurrent compiles make each request once. */
|
|
2120
2212
|
const sharedRequests = /* @__PURE__ */ new Map();
|
|
2121
2213
|
/**
|
|
2122
|
-
* Runs `start` once for all callers that ask for `key` while it is in progress.
|
|
2123
|
-
*
|
|
2124
|
-
*
|
|
2214
|
+
* Runs `start` once for all callers that ask for `key` while it is in progress. Each caller waits
|
|
2215
|
+
* under its own limits: a caller whose `signal` aborts, or whose `timeout` passes, stops waiting
|
|
2216
|
+
* at once and rejects, while the others keep waiting. The request itself is aborted (through
|
|
2217
|
+
* the signal `start` receives) once no caller waits on it. `start` must not depend on any one
|
|
2218
|
+
* caller: what differs between callers belongs after the shared result.
|
|
2125
2219
|
*/
|
|
2126
|
-
function shareRequest(key, signal, start) {
|
|
2220
|
+
function shareRequest(key, { signal, timeout, timedOut }, start) {
|
|
2127
2221
|
if (signal?.aborted) return Promise.reject(signal.reason);
|
|
2128
2222
|
const request = sharedRequests.get(key) ?? startSharedRequest(key, start);
|
|
2129
2223
|
request.waiters++;
|
|
2130
2224
|
return new Promise((resolve, reject) => {
|
|
2131
2225
|
let waiting = true;
|
|
2226
|
+
let timer;
|
|
2132
2227
|
const stopWaiting = () => {
|
|
2133
2228
|
if (!waiting) return;
|
|
2134
2229
|
waiting = false;
|
|
2135
2230
|
signal?.removeEventListener("abort", onAbort);
|
|
2231
|
+
clearTimeout(timer);
|
|
2136
2232
|
request.waiters--;
|
|
2137
2233
|
};
|
|
2138
|
-
const
|
|
2234
|
+
const giveUp = (reason) => {
|
|
2139
2235
|
stopWaiting();
|
|
2140
2236
|
if (request.waiters === 0) {
|
|
2141
2237
|
if (sharedRequests.get(key) === request) sharedRequests.delete(key);
|
|
2142
|
-
request.controller.abort(
|
|
2238
|
+
request.controller.abort(reason);
|
|
2143
2239
|
}
|
|
2144
|
-
reject(
|
|
2240
|
+
reject(reason);
|
|
2145
2241
|
};
|
|
2242
|
+
const onAbort = () => giveUp(signal?.reason);
|
|
2146
2243
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2244
|
+
if (timeout > 0 && timeout <= MAX_TIMER_DELAY) timer = setTimeout(() => giveUp(timedOut()), timeout);
|
|
2147
2245
|
request.promise.then((value) => {
|
|
2148
2246
|
stopWaiting();
|
|
2149
2247
|
resolve(value);
|
|
@@ -2173,20 +2271,32 @@ function requestTimeout(options) {
|
|
|
2173
2271
|
if (!(timeout >= 0)) throw new RangeError(`A story format request timeout must be 0 or more milliseconds, not ${timeout}`);
|
|
2174
2272
|
return timeout;
|
|
2175
2273
|
}
|
|
2274
|
+
/** How one caller waits for `url`: under its own signal and timeout. `what` names the request in errors. */
|
|
2275
|
+
function waitOptions(options, what, url) {
|
|
2276
|
+
const timeout = requestTimeout(options);
|
|
2277
|
+
return {
|
|
2278
|
+
signal: options.signal,
|
|
2279
|
+
timeout,
|
|
2280
|
+
timedOut: () => /* @__PURE__ */ new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`)
|
|
2281
|
+
};
|
|
2282
|
+
}
|
|
2176
2283
|
/**
|
|
2177
|
-
* Fetch `url` as
|
|
2178
|
-
*
|
|
2284
|
+
* Fetch `url` as bytes, aborting when `signal` does. `what` names the request in errors, as in
|
|
2285
|
+
* "Failed to <what> from <url>". The bytes are returned undecoded, so checksums cover what was served.
|
|
2179
2286
|
*/
|
|
2180
|
-
async function
|
|
2181
|
-
const
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2287
|
+
async function fetchBytes(url, what, signal) {
|
|
2288
|
+
const res = await fetch(url, { signal });
|
|
2289
|
+
if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
|
|
2290
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
2291
|
+
}
|
|
2292
|
+
/** Download a format.js once for every caller in this process that asks for `url` meanwhile. */
|
|
2293
|
+
function shareDownload(url, options) {
|
|
2294
|
+
return shareRequest(`download\0${url}`, waitOptions(options, "download format", url), (signal) => fetchBytes(url, "download format", signal));
|
|
2295
|
+
}
|
|
2296
|
+
/** Decode a downloaded format.js as local ones are (UTF-8, else Windows-1252), without a leading BOM. */
|
|
2297
|
+
function decodeDownload(bytes, url) {
|
|
2298
|
+
const { text } = decodeText(bytes, url);
|
|
2299
|
+
return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
|
|
2190
2300
|
}
|
|
2191
2301
|
/** Validate that a JSON value is a valid SFAIndexEntry. */
|
|
2192
2302
|
function isValidEntry(val) {
|
|
@@ -2208,9 +2318,8 @@ async function fetchIndex(url, options = {}) {
|
|
|
2208
2318
|
options.signal?.throwIfAborted();
|
|
2209
2319
|
const cached = indexCache.get(url);
|
|
2210
2320
|
if (cached) return cached;
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
const text = await fetchText(url, "fetch format index", signal, timeout);
|
|
2321
|
+
return shareRequest(`index\0${url}`, waitOptions(options, "fetch format index", url), async (signal) => {
|
|
2322
|
+
const text = decodeDownload(await fetchBytes(url, "fetch format index", signal), url);
|
|
2214
2323
|
const data = validateSFAIndex(JSON.parse(text));
|
|
2215
2324
|
indexCache.set(url, data);
|
|
2216
2325
|
return data;
|
|
@@ -2247,26 +2356,37 @@ function cachedFormatInfo(id, filename, data) {
|
|
|
2247
2356
|
};
|
|
2248
2357
|
}
|
|
2249
2358
|
/**
|
|
2250
|
-
*
|
|
2251
|
-
*
|
|
2359
|
+
* Check that a downloaded format is the one the index entry promised. The name matches without
|
|
2360
|
+
* regard to case, as format requests do, and the version by SemVer precedence, so "1.0" and "v1.0.0"
|
|
2361
|
+
* agree. A format.js that names no format ({@link UNNAMED_FORMAT_NAME}) is allowed: such formats
|
|
2362
|
+
* exist, and the index entry then supplies the name it is cached under.
|
|
2363
|
+
*/
|
|
2364
|
+
function checkFormatIdentity(entry, data, downloadUrl) {
|
|
2365
|
+
if ((data.name === "Untitled Story Format" || data.name.toLowerCase() === entry.name.toLowerCase()) && sameVersion(data.version, entry.version)) return;
|
|
2366
|
+
throw new Error(`Story format mismatch for ${downloadUrl}: the index lists ${entry.name} ${entry.version}, but the download is ${data.name} ${data.version}`);
|
|
2367
|
+
}
|
|
2368
|
+
/**
|
|
2369
|
+
* Download a format, verify its checksum, check that it is the format the entry names, write it to
|
|
2370
|
+
* the cache shared by name and version, and return its StoryFormatInfo. Concurrent calls for one
|
|
2371
|
+
* download URL share one request for the bytes; each call then verifies them against its own
|
|
2372
|
+
* entry, within its own timeout.
|
|
2252
2373
|
*/
|
|
2253
2374
|
async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
|
|
2254
2375
|
cachedFormatDir(entry.name, entry.version);
|
|
2255
|
-
const
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
const
|
|
2259
|
-
if (
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
});
|
|
2376
|
+
const bytes = await shareDownload(downloadUrl, options);
|
|
2377
|
+
const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
|
|
2378
|
+
if (checksumKey) {
|
|
2379
|
+
const expected = entry.checksums[checksumKey];
|
|
2380
|
+
if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
|
|
2381
|
+
if (!await verifySHA256(bytes, expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
|
|
2382
|
+
}
|
|
2383
|
+
const text = decodeDownload(bytes, downloadUrl);
|
|
2384
|
+
const id = makeFormatId(entry.name, entry.version);
|
|
2385
|
+
const data = parseFormatJSON(text, id);
|
|
2386
|
+
if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
|
|
2387
|
+
checkFormatIdentity(entry, data, downloadUrl);
|
|
2388
|
+
options.signal?.throwIfAborted();
|
|
2389
|
+
return cachedFormatInfo(id, writeCacheEntry(resolve(getCacheDir()), [entry.name, entry.version], text), data);
|
|
2270
2390
|
}
|
|
2271
2391
|
/**
|
|
2272
2392
|
* Download a direct format.js URL, parse its JSON, cache it under that URL (apart from the
|
|
@@ -2274,16 +2394,13 @@ async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
|
|
|
2274
2394
|
* one URL share one request.
|
|
2275
2395
|
*/
|
|
2276
2396
|
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
|
-
});
|
|
2397
|
+
const text = decodeDownload(await shareDownload(url, options), url);
|
|
2398
|
+
const data = parseFormatJSON(text);
|
|
2399
|
+
if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
|
|
2400
|
+
cachedFormatDir(data.name, data.version);
|
|
2401
|
+
options.signal?.throwIfAborted();
|
|
2402
|
+
const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
|
|
2403
|
+
return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
|
|
2287
2404
|
}
|
|
2288
2405
|
/** The copy of a direct format URL in the download cache, if it has been downloaded before. */
|
|
2289
2406
|
function getCachedDirectFormat(url) {
|
|
@@ -2324,6 +2441,16 @@ async function resolveFormatUrls(request, urls, options = {}) {
|
|
|
2324
2441
|
}
|
|
2325
2442
|
if (lastError) throw lastError;
|
|
2326
2443
|
}
|
|
2444
|
+
/**
|
|
2445
|
+
* Find the best format among the cached copies of direct format URLs, without network access.
|
|
2446
|
+
* Matching follows {@link selectFormatCandidate}, so `allowOlder` admits a same-major older version.
|
|
2447
|
+
* Each URL keeps its own copy; the shared name and version downloads are never consulted.
|
|
2448
|
+
*
|
|
2449
|
+
* Internal, for format resolution; not part of the public API.
|
|
2450
|
+
*/
|
|
2451
|
+
function findCachedUrlFormat(request, urls, options = {}) {
|
|
2452
|
+
return selectFormatCandidate(request, urls.flatMap((url) => getCachedDirectFormat(url) ?? []), (f) => f, options);
|
|
2453
|
+
}
|
|
2327
2454
|
/** Whether `info` is a format the request accepts. */
|
|
2328
2455
|
function answers(request, info) {
|
|
2329
2456
|
return selectFormatCandidate(request, [info], (f) => f) !== void 0;
|
|
@@ -2576,8 +2703,8 @@ function findLocalFormat(all, pruned, request) {
|
|
|
2576
2703
|
* then format indices (unless `noRemote`). Names and IDs match the same way in each (see
|
|
2577
2704
|
* `selectFormatCandidate`), so a request that a local format answers never reaches the cache or the
|
|
2578
2705
|
* network. Local formats that cannot be used are reported as warnings. When a StoryData request
|
|
2579
|
-
* names a version that none of them has, an older version of
|
|
2580
|
-
*
|
|
2706
|
+
* names a version that none of them has, an older version of the same format and major version
|
|
2707
|
+
* is used with a warning, from local formats, the project's cached format URLs, or the shared cache. Anything else is an error diagnostic,
|
|
2581
2708
|
* and the result is `undefined`.
|
|
2582
2709
|
*/
|
|
2583
2710
|
async function resolveStoryFormat(request, options, diagnostics) {
|
|
@@ -2615,7 +2742,7 @@ async function resolveStoryFormat(request, options, diagnostics) {
|
|
|
2615
2742
|
if (remote) return remote;
|
|
2616
2743
|
}
|
|
2617
2744
|
if (request.kind === "name") {
|
|
2618
|
-
const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
|
|
2745
|
+
const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedUrlFormat(request, options.formatUrls ?? [], { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
|
|
2619
2746
|
if (older) {
|
|
2620
2747
|
diagnostics.push({
|
|
2621
2748
|
level: "warning",
|
|
@@ -2864,8 +2991,6 @@ function* tweeLexer(input) {
|
|
|
2864
2991
|
const items = ctx.drainItems();
|
|
2865
2992
|
for (const item of items) yield item;
|
|
2866
2993
|
}
|
|
2867
|
-
const remaining = ctx.drainItems();
|
|
2868
|
-
for (const item of remaining) yield item;
|
|
2869
2994
|
}
|
|
2870
2995
|
/** Convenience class wrapper for the lexer generator. */
|
|
2871
2996
|
var TweeLexer = class {
|
|
@@ -3018,9 +3143,7 @@ function parseTwee(source, options = {}) {
|
|
|
3018
3143
|
};
|
|
3019
3144
|
}
|
|
3020
3145
|
try {
|
|
3021
|
-
const
|
|
3022
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error("expected a JSON object");
|
|
3023
|
-
const parsed = raw;
|
|
3146
|
+
const parsed = JSON.parse(item.val);
|
|
3024
3147
|
const meta = {};
|
|
3025
3148
|
for (const [key, value] of Object.entries(parsed)) if (typeof value === "string") meta[key] = value;
|
|
3026
3149
|
current.metadata = meta;
|
|
@@ -6274,8 +6397,8 @@ function loadFont(story, filename, diagnostics) {
|
|
|
6274
6397
|
* Load sources with mtime-based caching for incremental rebuilds.
|
|
6275
6398
|
* Always creates a fresh Story; cached passages are replayed via storyAdd().
|
|
6276
6399
|
*/
|
|
6277
|
-
function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
|
|
6278
|
-
const currentFiles =
|
|
6400
|
+
function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles, buildFiles) {
|
|
6401
|
+
const currentFiles = new Set(buildFiles);
|
|
6279
6402
|
const changedPaths = changedFiles === void 0 ? void 0 : new Set([...changedFiles].map((f) => resolve(f)));
|
|
6280
6403
|
for (const filename of filenames) {
|
|
6281
6404
|
if (processedFiles.has(filename)) {
|
|
@@ -6340,16 +6463,6 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
6340
6463
|
}
|
|
6341
6464
|
//#endregion
|
|
6342
6465
|
//#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
6466
|
/** The regular expression source matching exactly `text`. */
|
|
6354
6467
|
function literal(text) {
|
|
6355
6468
|
return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
@@ -6375,6 +6488,8 @@ function fillTemplateParts(parts) {
|
|
|
6375
6488
|
return part.text.replace(pattern, (match, ...groups) => {
|
|
6376
6489
|
const slot = slots.find((_, i) => groups[i] !== void 0);
|
|
6377
6490
|
if (slot === void 0 || slot.occurrences === "first" && filled.has(slot)) return match;
|
|
6491
|
+
const offset = groups[slots.length];
|
|
6492
|
+
if (slot.accepts !== void 0 && typeof offset === "number" && !slot.accepts(part.text, offset)) return match;
|
|
6378
6493
|
filled.add(slot);
|
|
6379
6494
|
return slot.replacement(match);
|
|
6380
6495
|
});
|
|
@@ -6465,15 +6580,18 @@ function placeHead(content, texts, owner) {
|
|
|
6465
6580
|
slot: void 0,
|
|
6466
6581
|
diagnostics: []
|
|
6467
6582
|
};
|
|
6468
|
-
const
|
|
6469
|
-
|
|
6470
|
-
|
|
6583
|
+
const scans = texts.map((text) => ({
|
|
6584
|
+
text,
|
|
6585
|
+
tags: scanHeadTags(text)
|
|
6586
|
+
}));
|
|
6587
|
+
if (scans.some(({ tags }) => tags.closingHead !== void 0)) return {
|
|
6588
|
+
slot: beforeTag(CLOSING_HEAD_PREFIX, "closingHead", content),
|
|
6471
6589
|
diagnostics: []
|
|
6472
6590
|
};
|
|
6473
|
-
if (
|
|
6591
|
+
if (scans.some(({ tags }) => tags.bodyStart !== void 0)) {
|
|
6474
6592
|
const message = `${owner} has no closing head tag; the modules and head file were injected before its body start tag.`;
|
|
6475
6593
|
return {
|
|
6476
|
-
slot: beforeTag(
|
|
6594
|
+
slot: beforeTag(BODY_START_PREFIX, "bodyStart", content),
|
|
6477
6595
|
diagnostics: [{
|
|
6478
6596
|
level: "warning",
|
|
6479
6597
|
message
|
|
@@ -6488,12 +6606,108 @@ function placeHead(content, texts, owner) {
|
|
|
6488
6606
|
}]
|
|
6489
6607
|
};
|
|
6490
6608
|
}
|
|
6491
|
-
/**
|
|
6492
|
-
|
|
6609
|
+
/** `</head` in any letter case, followed by whitespace, `/` or `>`; the rest of the tag is left alone. */
|
|
6610
|
+
const CLOSING_HEAD_PREFIX = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])";
|
|
6611
|
+
/** `<body` in any letter case, followed by whitespace, `/` or `>` (so not `<bodyx>`). */
|
|
6612
|
+
const BODY_START_PREFIX = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
|
|
6613
|
+
/** Elements whose content is text, so a tag written there is not a tag. */
|
|
6614
|
+
const RAW_TEXT_ELEMENTS = /* @__PURE__ */ new Set([
|
|
6615
|
+
"script",
|
|
6616
|
+
"style",
|
|
6617
|
+
"textarea",
|
|
6618
|
+
"title"
|
|
6619
|
+
]);
|
|
6620
|
+
const isSpace = (c) => c === " " || c === " " || c === "\n" || c === "\f" || c === "\r";
|
|
6621
|
+
const isLetter = (c) => c !== void 0 && /^[A-Za-z]$/.test(c);
|
|
6622
|
+
/**
|
|
6623
|
+
* Find the first closing head tag and body start tag that HTML would read as tags: not those in a comment, in
|
|
6624
|
+
* the text of a script, style, textarea or title element, or in a quoted attribute value. A small scanner, not
|
|
6625
|
+
* a full HTML tokenizer; it needs no more than the tags and where they end.
|
|
6626
|
+
*/
|
|
6627
|
+
function scanHeadTags(html) {
|
|
6628
|
+
const { closingHead, bodyStart } = scanTags$1(html);
|
|
6629
|
+
return {
|
|
6630
|
+
closingHead,
|
|
6631
|
+
bodyStart
|
|
6632
|
+
};
|
|
6633
|
+
}
|
|
6634
|
+
function scanTags$1(html) {
|
|
6635
|
+
let closingHead;
|
|
6636
|
+
let bodyStart;
|
|
6637
|
+
let headStartEnd;
|
|
6638
|
+
let i = 0;
|
|
6639
|
+
while (i < html.length) {
|
|
6640
|
+
const lt = html.indexOf("<", i);
|
|
6641
|
+
if (lt === -1) break;
|
|
6642
|
+
const next = html[lt + 1];
|
|
6643
|
+
if (html.startsWith("<!--", lt)) {
|
|
6644
|
+
const end = html.indexOf("-->", lt + 4);
|
|
6645
|
+
i = end === -1 ? html.length : end + 3;
|
|
6646
|
+
} else if (next === "!" || next === "?") {
|
|
6647
|
+
const end = html.indexOf(">", lt);
|
|
6648
|
+
i = end === -1 ? html.length : end + 1;
|
|
6649
|
+
} else if (next === "/" && isLetter(html[lt + 2])) {
|
|
6650
|
+
const { name, end } = readTag(html, lt + 2);
|
|
6651
|
+
if (name === "head" && closingHead === void 0) closingHead = lt;
|
|
6652
|
+
i = end;
|
|
6653
|
+
} else if (isLetter(next)) {
|
|
6654
|
+
const { name, end } = readTag(html, lt + 1);
|
|
6655
|
+
if (name === "body" && bodyStart === void 0) bodyStart = lt;
|
|
6656
|
+
if (name === "head" && headStartEnd === void 0) headStartEnd = end;
|
|
6657
|
+
i = RAW_TEXT_ELEMENTS.has(name) ? rawTextEnd(html, name, end) : end;
|
|
6658
|
+
} else i = lt + 1;
|
|
6659
|
+
if (closingHead !== void 0 && bodyStart !== void 0) break;
|
|
6660
|
+
}
|
|
6661
|
+
return {
|
|
6662
|
+
closingHead,
|
|
6663
|
+
bodyStart,
|
|
6664
|
+
headStartEnd
|
|
6665
|
+
};
|
|
6666
|
+
}
|
|
6667
|
+
/** Read a tag whose name starts at `from`: its lowercase name, and the offset after its `>` (or the end). */
|
|
6668
|
+
function readTag(html, from) {
|
|
6669
|
+
let i = from;
|
|
6670
|
+
while (i < html.length && !isSpace(html[i]) && html[i] !== "/" && html[i] !== ">") i++;
|
|
6671
|
+
const name = html.slice(from, i).toLowerCase();
|
|
6672
|
+
while (i < html.length && html[i] !== ">") {
|
|
6673
|
+
if (html[i] !== "=") {
|
|
6674
|
+
i++;
|
|
6675
|
+
continue;
|
|
6676
|
+
}
|
|
6677
|
+
i++;
|
|
6678
|
+
while (isSpace(html[i])) i++;
|
|
6679
|
+
const quote = html[i];
|
|
6680
|
+
if (quote === "\"" || quote === "'") {
|
|
6681
|
+
const close = html.indexOf(quote, i + 1);
|
|
6682
|
+
i = close === -1 ? html.length : close + 1;
|
|
6683
|
+
}
|
|
6684
|
+
}
|
|
6493
6685
|
return {
|
|
6494
|
-
|
|
6686
|
+
name,
|
|
6687
|
+
end: Math.min(i + 1, html.length)
|
|
6688
|
+
};
|
|
6689
|
+
}
|
|
6690
|
+
/** The offset of the end tag that closes a raw text element, given where its text starts (or the end). */
|
|
6691
|
+
function rawTextEnd(html, name, from) {
|
|
6692
|
+
const close = new RegExp(`</${name}(?=[\\t\\n\\f\\r />])`, "gi");
|
|
6693
|
+
close.lastIndex = from;
|
|
6694
|
+
return close.exec(html)?.index ?? html.length;
|
|
6695
|
+
}
|
|
6696
|
+
/**
|
|
6697
|
+
* The slot that inserts `content` on its own line before the first real tag of the kind `which` names (see
|
|
6698
|
+
* `scanHeadTags()`), which is kept as written. A look-alike inside a script or comment is left alone.
|
|
6699
|
+
*/
|
|
6700
|
+
function beforeTag(pattern, which, content) {
|
|
6701
|
+
const cache = /* @__PURE__ */ new Map();
|
|
6702
|
+
const offsetIn = (text) => {
|
|
6703
|
+
if (!cache.has(text)) cache.set(text, scanHeadTags(text)[which]);
|
|
6704
|
+
return cache.get(text);
|
|
6705
|
+
};
|
|
6706
|
+
return {
|
|
6707
|
+
pattern,
|
|
6495
6708
|
occurrences: "first",
|
|
6496
|
-
replacement: (match) => `${content}\n${match}
|
|
6709
|
+
replacement: (match) => `${content}\n${match}`,
|
|
6710
|
+
accepts: (text, offset) => offsetIn(text) === offset
|
|
6497
6711
|
};
|
|
6498
6712
|
}
|
|
6499
6713
|
//#endregion
|
|
@@ -6939,6 +7153,7 @@ async function compile(options) {
|
|
|
6939
7153
|
*/
|
|
6940
7154
|
async function compileToFile(options) {
|
|
6941
7155
|
const result = await compileForOutputFile(options, options.outFile);
|
|
7156
|
+
options.signal?.throwIfAborted();
|
|
6942
7157
|
writeFileAtomic(options.outFile, result.output);
|
|
6943
7158
|
return result;
|
|
6944
7159
|
}
|
|
@@ -7100,34 +7315,61 @@ async function buildOutput(options, cache, changedFiles, outputs) {
|
|
|
7100
7315
|
const timeout = options.formatFetchTimeout;
|
|
7101
7316
|
if (timeout !== void 0 && !(timeout >= 0)) throw new TweeTsError(`formatFetchTimeout must be 0 or more milliseconds, not ${timeout}.`);
|
|
7102
7317
|
clearIndexCache();
|
|
7103
|
-
const
|
|
7104
|
-
const
|
|
7105
|
-
for (const source of options.sources)
|
|
7106
|
-
|
|
7107
|
-
|
|
7108
|
-
|
|
7109
|
-
|
|
7110
|
-
|
|
7318
|
+
const groups = [];
|
|
7319
|
+
const outputSources = [];
|
|
7320
|
+
for (const source of options.sources) {
|
|
7321
|
+
const last = groups[groups.length - 1];
|
|
7322
|
+
if (typeof source === "string") {
|
|
7323
|
+
if (last?.kind === "paths") last.paths.push(source);
|
|
7324
|
+
else groups.push({
|
|
7325
|
+
kind: "paths",
|
|
7326
|
+
paths: [source]
|
|
7327
|
+
});
|
|
7328
|
+
} else if (last?.kind === "inline") last.sources.push(source);
|
|
7329
|
+
else groups.push({
|
|
7330
|
+
kind: "inline",
|
|
7331
|
+
sources: [source]
|
|
7332
|
+
});
|
|
7333
|
+
}
|
|
7334
|
+
const walked = groups.map((group) => {
|
|
7335
|
+
if (group.kind === "inline") return group;
|
|
7336
|
+
const found = getFilenames(group.paths, written, options.exclude);
|
|
7337
|
+
diagnostics.push(...found.diagnostics);
|
|
7338
|
+
outputSources.push(...found.outputSources);
|
|
7339
|
+
return {
|
|
7340
|
+
kind: "files",
|
|
7341
|
+
filenames: found.filenames
|
|
7342
|
+
};
|
|
7343
|
+
});
|
|
7344
|
+
rejectOutputSources(outputSources, diagnostics);
|
|
7345
|
+
const buildFiles = new Set(walked.flatMap((group) => group.kind === "files" ? group.filenames : []));
|
|
7111
7346
|
const story = createStory();
|
|
7112
7347
|
const processedFiles = /* @__PURE__ */ new Set();
|
|
7113
|
-
if (
|
|
7348
|
+
for (const group of walked) if (group.kind === "inline") loadInlineSources(story, group.sources, {
|
|
7114
7349
|
trim,
|
|
7115
7350
|
twee2Compat
|
|
7116
|
-
}, diagnostics
|
|
7117
|
-
else
|
|
7351
|
+
}, diagnostics);
|
|
7352
|
+
else if (cache) loadSourcesCached(story, group.filenames, {
|
|
7353
|
+
trim,
|
|
7354
|
+
twee2Compat
|
|
7355
|
+
}, diagnostics, processedFiles, cache, changedFiles, buildFiles);
|
|
7356
|
+
else loadSources(story, group.filenames, {
|
|
7118
7357
|
trim,
|
|
7119
7358
|
twee2Compat
|
|
7120
7359
|
}, diagnostics, processedFiles);
|
|
7121
|
-
|
|
7360
|
+
if (cache && buildFiles.size === 0) loadSourcesCached(story, [], {
|
|
7122
7361
|
trim,
|
|
7123
7362
|
twee2Compat
|
|
7124
|
-
}, diagnostics);
|
|
7363
|
+
}, diagnostics, processedFiles, cache, changedFiles);
|
|
7125
7364
|
if (options.tagAliases) story.passages = applyTagAliases(story.passages, options.tagAliases);
|
|
7126
7365
|
let format;
|
|
7127
|
-
if (outputMode === "html")
|
|
7128
|
-
|
|
7129
|
-
|
|
7130
|
-
|
|
7366
|
+
if (outputMode === "html") {
|
|
7367
|
+
format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
|
|
7368
|
+
...options,
|
|
7369
|
+
noRemote
|
|
7370
|
+
}, diagnostics);
|
|
7371
|
+
options.signal?.throwIfAborted();
|
|
7372
|
+
}
|
|
7131
7373
|
if (options.startPassage) story.twine2.start = options.startPassage;
|
|
7132
7374
|
const startName = story.twine2.start || DEFAULT_START_NAME;
|
|
7133
7375
|
if (testMode) story.twine2.options.set("debug", true);
|
|
@@ -7179,6 +7421,7 @@ async function buildOutput(options, cache, changedFiles, outputs) {
|
|
|
7179
7421
|
...getStoryStats(story, options.wordCountMethod),
|
|
7180
7422
|
files: [...processedFiles]
|
|
7181
7423
|
};
|
|
7424
|
+
options.signal?.throwIfAborted();
|
|
7182
7425
|
return {
|
|
7183
7426
|
output,
|
|
7184
7427
|
story,
|
|
@@ -8944,22 +9187,13 @@ const foldKey = (key) => key.toLowerCase().replace(/[-_]/g, "");
|
|
|
8944
9187
|
*
|
|
8945
9188
|
* @param diagnostics Receives warnings that do not stop the config from loading: keys the config does not
|
|
8946
9189
|
* 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}.
|
|
9190
|
+
* @throws When the file exists but cannot be read, is not valid JSON or fails {@link validateConfig}.
|
|
8948
9191
|
*/
|
|
8949
9192
|
function loadConfig(dir, diagnostics) {
|
|
8950
9193
|
const base = dir ?? process.cwd();
|
|
8951
9194
|
const configPath = join(base, CONFIG_FILENAME);
|
|
8952
9195
|
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;
|
|
9196
|
+
return loadConfigFile(configPath, diagnostics);
|
|
8963
9197
|
}
|
|
8964
9198
|
/**
|
|
8965
9199
|
* Load a config from a specific file path.
|