@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.cjs
CHANGED
|
@@ -1600,10 +1600,9 @@ function readUTF8(filename, diagnostics) {
|
|
|
1600
1600
|
function readBase64(filename) {
|
|
1601
1601
|
return (0, node_fs.readFileSync)(filename).toString("base64");
|
|
1602
1602
|
}
|
|
1603
|
-
/** Get the filename without extension,
|
|
1603
|
+
/** Get the filename without extension, a dotfile keeps its whole name. */
|
|
1604
1604
|
function baseNameWithoutExt(filename) {
|
|
1605
|
-
|
|
1606
|
-
return name || ext;
|
|
1605
|
+
return (0, node_path.parse)(filename).name;
|
|
1607
1606
|
}
|
|
1608
1607
|
//#endregion
|
|
1609
1608
|
//#region src/formats.ts
|
|
@@ -1623,6 +1622,89 @@ function stripSetupFunction(chunk) {
|
|
|
1623
1622
|
for (const match of chunk.matchAll(SETUP_FUNCTION_PROPERTY)) lastIndex = match.index;
|
|
1624
1623
|
return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
|
|
1625
1624
|
}
|
|
1625
|
+
/** Index after the whitespace and `//` or block comments starting at `i`. */
|
|
1626
|
+
function skipTrivia(text, i) {
|
|
1627
|
+
let pos = i;
|
|
1628
|
+
for (;;) {
|
|
1629
|
+
const ch = text[pos];
|
|
1630
|
+
if (ch !== void 0 && /\s/.test(ch)) pos++;
|
|
1631
|
+
else if (text.startsWith("//", pos)) {
|
|
1632
|
+
const newline = text.indexOf("\n", pos);
|
|
1633
|
+
pos = newline === -1 ? text.length : newline + 1;
|
|
1634
|
+
} else if (text.startsWith("/*", pos)) {
|
|
1635
|
+
const close = text.indexOf("*/", pos + 2);
|
|
1636
|
+
pos = close === -1 ? text.length : close + 2;
|
|
1637
|
+
} else return pos;
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
/** Index after the quoted string starting at `i` (a quote character); template literals nest `${…}`. */
|
|
1641
|
+
function skipString(text, i) {
|
|
1642
|
+
const quote = text[i];
|
|
1643
|
+
let pos = i + 1;
|
|
1644
|
+
while (pos < text.length) {
|
|
1645
|
+
const ch = text[pos];
|
|
1646
|
+
if (ch === "\\") pos += 2;
|
|
1647
|
+
else if (ch === quote) return pos + 1;
|
|
1648
|
+
else if (quote === "`" && ch === "$" && text[pos + 1] === "{") {
|
|
1649
|
+
const close = findClosingBrace(text, pos + 1);
|
|
1650
|
+
if (close === -1) return text.length;
|
|
1651
|
+
pos = close + 1;
|
|
1652
|
+
} else pos++;
|
|
1653
|
+
}
|
|
1654
|
+
return text.length;
|
|
1655
|
+
}
|
|
1656
|
+
function startsComment(text, pos) {
|
|
1657
|
+
return text[pos] === "/" && (text[pos + 1] === "/" || text[pos + 1] === "*");
|
|
1658
|
+
}
|
|
1659
|
+
function startsString(text, pos) {
|
|
1660
|
+
const ch = text[pos];
|
|
1661
|
+
return ch === "\"" || ch === "'" || ch === "`";
|
|
1662
|
+
}
|
|
1663
|
+
/** Index of the `}` matching the `{` at `open`, skipping comments and strings; -1 when unbalanced. */
|
|
1664
|
+
function findClosingBrace(text, open) {
|
|
1665
|
+
let depth = 0;
|
|
1666
|
+
let pos = open;
|
|
1667
|
+
while (pos < text.length) if (startsComment(text, pos)) pos = skipTrivia(text, pos);
|
|
1668
|
+
else if (startsString(text, pos)) pos = skipString(text, pos);
|
|
1669
|
+
else {
|
|
1670
|
+
if (text[pos] === "{") depth++;
|
|
1671
|
+
else if (text[pos] === "}" && --depth === 0) return pos;
|
|
1672
|
+
pos++;
|
|
1673
|
+
}
|
|
1674
|
+
return -1;
|
|
1675
|
+
}
|
|
1676
|
+
const STORY_FORMAT = "storyFormat";
|
|
1677
|
+
const IDENTIFIER_CHAR = /[\w$]/;
|
|
1678
|
+
/**
|
|
1679
|
+
* Locate the object literal passed to `storyFormat(` as `[start, end)` offsets, ignoring comments and
|
|
1680
|
+
* strings outside it and never evaluating anything. Without such a call, the first `{` outside comments
|
|
1681
|
+
* and strings starts the object. When its closing brace cannot be matched (a Harlowe setup function can
|
|
1682
|
+
* hold regular expression literals), the last `}` of the file ends it.
|
|
1683
|
+
*/
|
|
1684
|
+
function locateFormatObject(text) {
|
|
1685
|
+
let start = -1;
|
|
1686
|
+
let firstBrace = -1;
|
|
1687
|
+
let pos = 0;
|
|
1688
|
+
while (pos < text.length && start === -1) if (startsComment(text, pos)) pos = skipTrivia(text, pos);
|
|
1689
|
+
else if (startsString(text, pos)) pos = skipString(text, pos);
|
|
1690
|
+
else if (text[pos] === "{") {
|
|
1691
|
+
if (firstBrace === -1) firstBrace = pos;
|
|
1692
|
+
pos++;
|
|
1693
|
+
} else if (text.startsWith(STORY_FORMAT, pos) && !IDENTIFIER_CHAR.test(text[pos - 1] ?? "")) {
|
|
1694
|
+
const paren = skipTrivia(text, pos + 11);
|
|
1695
|
+
const brace = text[paren] === "(" ? skipTrivia(text, paren + 1) : -1;
|
|
1696
|
+
if (text[brace] === "{") start = brace;
|
|
1697
|
+
pos += 11;
|
|
1698
|
+
} else pos++;
|
|
1699
|
+
if (start === -1) start = firstBrace;
|
|
1700
|
+
if (start === -1) return void 0;
|
|
1701
|
+
const close = findClosingBrace(text, start);
|
|
1702
|
+
const end = close === -1 ? text.lastIndexOf("}") : close;
|
|
1703
|
+
return end < start ? void 0 : {
|
|
1704
|
+
start,
|
|
1705
|
+
end: end + 1
|
|
1706
|
+
};
|
|
1707
|
+
}
|
|
1626
1708
|
/**
|
|
1627
1709
|
* Parse the format object at `start`–`end` of `text`: strict JSON first (fast), then the
|
|
1628
1710
|
* JavaScript literal subset formats use. Error positions point into `text`.
|
|
@@ -1647,6 +1729,8 @@ function parseFormatObject(text, start, end) {
|
|
|
1647
1729
|
}
|
|
1648
1730
|
}
|
|
1649
1731
|
/** Check a parsed format object's fields. Per spec, `name` is optional; `version` and `source` are required. */
|
|
1732
|
+
/** The name given to a format.js that names no format. */
|
|
1733
|
+
const UNNAMED_FORMAT_NAME = "Untitled Story Format";
|
|
1650
1734
|
function toFormatJSON(raw) {
|
|
1651
1735
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
|
|
1652
1736
|
ok: false,
|
|
@@ -1666,7 +1750,7 @@ function toFormatJSON(raw) {
|
|
|
1666
1750
|
reason: `Story format version ${JSON.stringify(obj.version)} is not a SemVer version.`
|
|
1667
1751
|
};
|
|
1668
1752
|
const data = {
|
|
1669
|
-
name: typeof obj.name === "string" ? obj.name :
|
|
1753
|
+
name: typeof obj.name === "string" ? obj.name : UNNAMED_FORMAT_NAME,
|
|
1670
1754
|
version: obj.version,
|
|
1671
1755
|
source: obj.source,
|
|
1672
1756
|
proofing: obj.proofing === true
|
|
@@ -1691,15 +1775,14 @@ function toFormatJSON(raw) {
|
|
|
1691
1775
|
* `harlowe-3` directory or a direct download.
|
|
1692
1776
|
*/
|
|
1693
1777
|
function decodeFormatJSON(source) {
|
|
1694
|
-
const
|
|
1695
|
-
|
|
1696
|
-
if (first === -1 || last < first) return {
|
|
1778
|
+
const located = locateFormatObject(source);
|
|
1779
|
+
if (located === void 0) return {
|
|
1697
1780
|
ok: false,
|
|
1698
1781
|
reason: "Could not find Twine 2 style story format JSON chunk."
|
|
1699
1782
|
};
|
|
1700
|
-
const parsed = parseFormatObject(source,
|
|
1783
|
+
const parsed = parseFormatObject(source, located.start, located.end);
|
|
1701
1784
|
if (parsed.ok) return toFormatJSON(parsed.value);
|
|
1702
|
-
const stripped = stripSetupFunction(source.slice(
|
|
1785
|
+
const stripped = stripSetupFunction(source.slice(located.start, located.end));
|
|
1703
1786
|
const retried = stripped === void 0 ? void 0 : parseFormatObject(stripped, 0, stripped.length);
|
|
1704
1787
|
if (retried?.ok) return toFormatJSON(retried.value);
|
|
1705
1788
|
return {
|
|
@@ -2003,16 +2086,17 @@ function readFormatSource(format, diagnostics) {
|
|
|
2003
2086
|
/**
|
|
2004
2087
|
* Writes `data` to `path` so that a reader sees either the previous file or the complete new
|
|
2005
2088
|
* one, never a partly written file. The data goes to a temporary file in the same folder,
|
|
2006
|
-
* which is then renamed over `path`. When `path` is a symlink, the file it
|
|
2007
|
-
* replaced, and an existing file keeps its permissions.
|
|
2089
|
+
* which is then renamed over `path`. When `path` is a symlink (even a dangling one, or a chain of links), the file it
|
|
2090
|
+
* finally points to is replaced or created and the link stays, and an existing file keeps its permissions.
|
|
2008
2091
|
*
|
|
2009
2092
|
* On failure (a full disk, a missing folder) the temporary file is removed, `path` is left
|
|
2010
2093
|
* as it was, and the error is thrown with `path` in its message and its original `code`.
|
|
2011
2094
|
*/
|
|
2012
2095
|
function writeFileAtomic(path, data) {
|
|
2013
|
-
|
|
2014
|
-
const temp = (0, node_path.join)((0, node_path.dirname)(target), `.${(0, node_path.basename)(target)}.${process.pid}-${(0, node_crypto.randomBytes)(6).toString("hex")}.tmp`);
|
|
2096
|
+
let temp;
|
|
2015
2097
|
try {
|
|
2098
|
+
const target = resolveWriteTarget(path);
|
|
2099
|
+
temp = (0, node_path.join)((0, node_path.dirname)(target), `.${(0, node_path.basename)(target)}.${process.pid}-${(0, node_crypto.randomBytes)(6).toString("hex")}.tmp`);
|
|
2016
2100
|
(0, node_fs.writeFileSync)(temp, data, {
|
|
2017
2101
|
encoding: "utf-8",
|
|
2018
2102
|
flag: "wx"
|
|
@@ -2020,17 +2104,25 @@ function writeFileAtomic(path, data) {
|
|
|
2020
2104
|
keepPermissions(target, temp);
|
|
2021
2105
|
(0, node_fs.renameSync)(temp, target);
|
|
2022
2106
|
} catch (e) {
|
|
2023
|
-
(0, node_fs.rmSync)(temp, { force: true });
|
|
2107
|
+
if (temp !== void 0) (0, node_fs.rmSync)(temp, { force: true });
|
|
2024
2108
|
throw writeError(path, e);
|
|
2025
2109
|
}
|
|
2026
2110
|
}
|
|
2027
|
-
/**
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2111
|
+
/** Links followed before a path counts as a cycle (Linux stops at 40 as well). */
|
|
2112
|
+
const MAX_LINK_DEPTH = 40;
|
|
2113
|
+
/**
|
|
2114
|
+
* The file a symlink at `path` finally points to, or `path` itself when it is no link. Links are
|
|
2115
|
+
* followed one by one, so a link whose target does not exist yet (absolute, relative, or a chain
|
|
2116
|
+
* of them) still resolves to the file that should be created. A cycle or an unreadable link throws.
|
|
2117
|
+
*/
|
|
2118
|
+
function resolveWriteTarget(path) {
|
|
2119
|
+
let current = path;
|
|
2120
|
+
for (let depth = 0; depth <= MAX_LINK_DEPTH; depth++) {
|
|
2121
|
+
const stat = (0, node_fs.lstatSync)(current, { throwIfNoEntry: false });
|
|
2122
|
+
if (stat === void 0 || !stat.isSymbolicLink()) return current;
|
|
2123
|
+
current = (0, node_path.resolve)((0, node_path.dirname)(current), (0, node_fs.readlinkSync)(current));
|
|
2033
2124
|
}
|
|
2125
|
+
throw Object.assign(/* @__PURE__ */ new Error(`ELOOP: too many levels of symbolic links, ${path}`), { code: "ELOOP" });
|
|
2034
2126
|
}
|
|
2035
2127
|
/**
|
|
2036
2128
|
* Gives `temp` the permission bits of `target`, when `target` exists. Best effort: on a file
|
|
@@ -2142,31 +2234,37 @@ function clearIndexCache() {
|
|
|
2142
2234
|
/** Requests in progress, by what they fetch, so concurrent compiles make each request once. */
|
|
2143
2235
|
const sharedRequests = /* @__PURE__ */ new Map();
|
|
2144
2236
|
/**
|
|
2145
|
-
* Runs `start` once for all callers that ask for `key` while it is in progress.
|
|
2146
|
-
*
|
|
2147
|
-
*
|
|
2237
|
+
* Runs `start` once for all callers that ask for `key` while it is in progress. Each caller waits
|
|
2238
|
+
* under its own limits: a caller whose `signal` aborts, or whose `timeout` passes, stops waiting
|
|
2239
|
+
* at once and rejects, while the others keep waiting. The request itself is aborted (through
|
|
2240
|
+
* the signal `start` receives) once no caller waits on it. `start` must not depend on any one
|
|
2241
|
+
* caller: what differs between callers belongs after the shared result.
|
|
2148
2242
|
*/
|
|
2149
|
-
function shareRequest(key, signal, start) {
|
|
2243
|
+
function shareRequest(key, { signal, timeout, timedOut }, start) {
|
|
2150
2244
|
if (signal?.aborted) return Promise.reject(signal.reason);
|
|
2151
2245
|
const request = sharedRequests.get(key) ?? startSharedRequest(key, start);
|
|
2152
2246
|
request.waiters++;
|
|
2153
2247
|
return new Promise((resolve, reject) => {
|
|
2154
2248
|
let waiting = true;
|
|
2249
|
+
let timer;
|
|
2155
2250
|
const stopWaiting = () => {
|
|
2156
2251
|
if (!waiting) return;
|
|
2157
2252
|
waiting = false;
|
|
2158
2253
|
signal?.removeEventListener("abort", onAbort);
|
|
2254
|
+
clearTimeout(timer);
|
|
2159
2255
|
request.waiters--;
|
|
2160
2256
|
};
|
|
2161
|
-
const
|
|
2257
|
+
const giveUp = (reason) => {
|
|
2162
2258
|
stopWaiting();
|
|
2163
2259
|
if (request.waiters === 0) {
|
|
2164
2260
|
if (sharedRequests.get(key) === request) sharedRequests.delete(key);
|
|
2165
|
-
request.controller.abort(
|
|
2261
|
+
request.controller.abort(reason);
|
|
2166
2262
|
}
|
|
2167
|
-
reject(
|
|
2263
|
+
reject(reason);
|
|
2168
2264
|
};
|
|
2265
|
+
const onAbort = () => giveUp(signal?.reason);
|
|
2169
2266
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2267
|
+
if (timeout > 0 && timeout <= MAX_TIMER_DELAY) timer = setTimeout(() => giveUp(timedOut()), timeout);
|
|
2170
2268
|
request.promise.then((value) => {
|
|
2171
2269
|
stopWaiting();
|
|
2172
2270
|
resolve(value);
|
|
@@ -2196,20 +2294,32 @@ function requestTimeout(options) {
|
|
|
2196
2294
|
if (!(timeout >= 0)) throw new RangeError(`A story format request timeout must be 0 or more milliseconds, not ${timeout}`);
|
|
2197
2295
|
return timeout;
|
|
2198
2296
|
}
|
|
2297
|
+
/** How one caller waits for `url`: under its own signal and timeout. `what` names the request in errors. */
|
|
2298
|
+
function waitOptions(options, what, url) {
|
|
2299
|
+
const timeout = requestTimeout(options);
|
|
2300
|
+
return {
|
|
2301
|
+
signal: options.signal,
|
|
2302
|
+
timeout,
|
|
2303
|
+
timedOut: () => /* @__PURE__ */ new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`)
|
|
2304
|
+
};
|
|
2305
|
+
}
|
|
2199
2306
|
/**
|
|
2200
|
-
* Fetch `url` as
|
|
2201
|
-
*
|
|
2307
|
+
* Fetch `url` as bytes, aborting when `signal` does. `what` names the request in errors, as in
|
|
2308
|
+
* "Failed to <what> from <url>". The bytes are returned undecoded, so checksums cover what was served.
|
|
2202
2309
|
*/
|
|
2203
|
-
async function
|
|
2204
|
-
const
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2310
|
+
async function fetchBytes(url, what, signal) {
|
|
2311
|
+
const res = await fetch(url, { signal });
|
|
2312
|
+
if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
|
|
2313
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
2314
|
+
}
|
|
2315
|
+
/** Download a format.js once for every caller in this process that asks for `url` meanwhile. */
|
|
2316
|
+
function shareDownload(url, options) {
|
|
2317
|
+
return shareRequest(`download\0${url}`, waitOptions(options, "download format", url), (signal) => fetchBytes(url, "download format", signal));
|
|
2318
|
+
}
|
|
2319
|
+
/** Decode a downloaded format.js as local ones are (UTF-8, else Windows-1252), without a leading BOM. */
|
|
2320
|
+
function decodeDownload(bytes, url) {
|
|
2321
|
+
const { text } = decodeText(bytes, url);
|
|
2322
|
+
return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
|
|
2213
2323
|
}
|
|
2214
2324
|
/** Validate that a JSON value is a valid SFAIndexEntry. */
|
|
2215
2325
|
function isValidEntry(val) {
|
|
@@ -2231,9 +2341,8 @@ async function fetchIndex(url, options = {}) {
|
|
|
2231
2341
|
options.signal?.throwIfAborted();
|
|
2232
2342
|
const cached = indexCache.get(url);
|
|
2233
2343
|
if (cached) return cached;
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
const text = await fetchText(url, "fetch format index", signal, timeout);
|
|
2344
|
+
return shareRequest(`index\0${url}`, waitOptions(options, "fetch format index", url), async (signal) => {
|
|
2345
|
+
const text = decodeDownload(await fetchBytes(url, "fetch format index", signal), url);
|
|
2237
2346
|
const data = validateSFAIndex(JSON.parse(text));
|
|
2238
2347
|
indexCache.set(url, data);
|
|
2239
2348
|
return data;
|
|
@@ -2270,26 +2379,37 @@ function cachedFormatInfo(id, filename, data) {
|
|
|
2270
2379
|
};
|
|
2271
2380
|
}
|
|
2272
2381
|
/**
|
|
2273
|
-
*
|
|
2274
|
-
*
|
|
2382
|
+
* Check that a downloaded format is the one the index entry promised. The name matches without
|
|
2383
|
+
* regard to case, as format requests do, and the version by SemVer precedence, so "1.0" and "v1.0.0"
|
|
2384
|
+
* agree. A format.js that names no format ({@link UNNAMED_FORMAT_NAME}) is allowed: such formats
|
|
2385
|
+
* exist, and the index entry then supplies the name it is cached under.
|
|
2386
|
+
*/
|
|
2387
|
+
function checkFormatIdentity(entry, data, downloadUrl) {
|
|
2388
|
+
if ((data.name === "Untitled Story Format" || data.name.toLowerCase() === entry.name.toLowerCase()) && sameVersion(data.version, entry.version)) return;
|
|
2389
|
+
throw new Error(`Story format mismatch for ${downloadUrl}: the index lists ${entry.name} ${entry.version}, but the download is ${data.name} ${data.version}`);
|
|
2390
|
+
}
|
|
2391
|
+
/**
|
|
2392
|
+
* Download a format, verify its checksum, check that it is the format the entry names, write it to
|
|
2393
|
+
* the cache shared by name and version, and return its StoryFormatInfo. Concurrent calls for one
|
|
2394
|
+
* download URL share one request for the bytes; each call then verifies them against its own
|
|
2395
|
+
* entry, within its own timeout.
|
|
2275
2396
|
*/
|
|
2276
2397
|
async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
|
|
2277
2398
|
cachedFormatDir(entry.name, entry.version);
|
|
2278
|
-
const
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
const
|
|
2282
|
-
if (
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
});
|
|
2399
|
+
const bytes = await shareDownload(downloadUrl, options);
|
|
2400
|
+
const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
|
|
2401
|
+
if (checksumKey) {
|
|
2402
|
+
const expected = entry.checksums[checksumKey];
|
|
2403
|
+
if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
|
|
2404
|
+
if (!await verifySHA256(bytes, expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
|
|
2405
|
+
}
|
|
2406
|
+
const text = decodeDownload(bytes, downloadUrl);
|
|
2407
|
+
const id = makeFormatId(entry.name, entry.version);
|
|
2408
|
+
const data = parseFormatJSON(text, id);
|
|
2409
|
+
if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
|
|
2410
|
+
checkFormatIdentity(entry, data, downloadUrl);
|
|
2411
|
+
options.signal?.throwIfAborted();
|
|
2412
|
+
return cachedFormatInfo(id, writeCacheEntry((0, node_path.resolve)(getCacheDir()), [entry.name, entry.version], text), data);
|
|
2293
2413
|
}
|
|
2294
2414
|
/**
|
|
2295
2415
|
* Download a direct format.js URL, parse its JSON, cache it under that URL (apart from the
|
|
@@ -2297,16 +2417,13 @@ async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
|
|
|
2297
2417
|
* one URL share one request.
|
|
2298
2418
|
*/
|
|
2299
2419
|
async function fetchDirectFormat(url, options = {}) {
|
|
2300
|
-
const
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
|
|
2308
|
-
return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
|
|
2309
|
-
});
|
|
2420
|
+
const text = decodeDownload(await shareDownload(url, options), url);
|
|
2421
|
+
const data = parseFormatJSON(text);
|
|
2422
|
+
if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
|
|
2423
|
+
cachedFormatDir(data.name, data.version);
|
|
2424
|
+
options.signal?.throwIfAborted();
|
|
2425
|
+
const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
|
|
2426
|
+
return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
|
|
2310
2427
|
}
|
|
2311
2428
|
/** The copy of a direct format URL in the download cache, if it has been downloaded before. */
|
|
2312
2429
|
function getCachedDirectFormat(url) {
|
|
@@ -2347,6 +2464,16 @@ async function resolveFormatUrls(request, urls, options = {}) {
|
|
|
2347
2464
|
}
|
|
2348
2465
|
if (lastError) throw lastError;
|
|
2349
2466
|
}
|
|
2467
|
+
/**
|
|
2468
|
+
* Find the best format among the cached copies of direct format URLs, without network access.
|
|
2469
|
+
* Matching follows {@link selectFormatCandidate}, so `allowOlder` admits a same-major older version.
|
|
2470
|
+
* Each URL keeps its own copy; the shared name and version downloads are never consulted.
|
|
2471
|
+
*
|
|
2472
|
+
* Internal, for format resolution; not part of the public API.
|
|
2473
|
+
*/
|
|
2474
|
+
function findCachedUrlFormat(request, urls, options = {}) {
|
|
2475
|
+
return selectFormatCandidate(request, urls.flatMap((url) => getCachedDirectFormat(url) ?? []), (f) => f, options);
|
|
2476
|
+
}
|
|
2350
2477
|
/** Whether `info` is a format the request accepts. */
|
|
2351
2478
|
function answers(request, info) {
|
|
2352
2479
|
return selectFormatCandidate(request, [info], (f) => f) !== void 0;
|
|
@@ -2599,8 +2726,8 @@ function findLocalFormat(all, pruned, request) {
|
|
|
2599
2726
|
* then format indices (unless `noRemote`). Names and IDs match the same way in each (see
|
|
2600
2727
|
* `selectFormatCandidate`), so a request that a local format answers never reaches the cache or the
|
|
2601
2728
|
* network. Local formats that cannot be used are reported as warnings. When a StoryData request
|
|
2602
|
-
* names a version that none of them has, an older version of
|
|
2603
|
-
*
|
|
2729
|
+
* names a version that none of them has, an older version of the same format and major version
|
|
2730
|
+
* is used with a warning, from local formats, the project's cached format URLs, or the shared cache. Anything else is an error diagnostic,
|
|
2604
2731
|
* and the result is `undefined`.
|
|
2605
2732
|
*/
|
|
2606
2733
|
async function resolveStoryFormat(request, options, diagnostics) {
|
|
@@ -2638,7 +2765,7 @@ async function resolveStoryFormat(request, options, diagnostics) {
|
|
|
2638
2765
|
if (remote) return remote;
|
|
2639
2766
|
}
|
|
2640
2767
|
if (request.kind === "name") {
|
|
2641
|
-
const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
|
|
2768
|
+
const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedUrlFormat(request, options.formatUrls ?? [], { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
|
|
2642
2769
|
if (older) {
|
|
2643
2770
|
diagnostics.push({
|
|
2644
2771
|
level: "warning",
|
|
@@ -2887,8 +3014,6 @@ function* tweeLexer(input) {
|
|
|
2887
3014
|
const items = ctx.drainItems();
|
|
2888
3015
|
for (const item of items) yield item;
|
|
2889
3016
|
}
|
|
2890
|
-
const remaining = ctx.drainItems();
|
|
2891
|
-
for (const item of remaining) yield item;
|
|
2892
3017
|
}
|
|
2893
3018
|
/** Convenience class wrapper for the lexer generator. */
|
|
2894
3019
|
var TweeLexer = class {
|
|
@@ -3041,9 +3166,7 @@ function parseTwee(source, options = {}) {
|
|
|
3041
3166
|
};
|
|
3042
3167
|
}
|
|
3043
3168
|
try {
|
|
3044
|
-
const
|
|
3045
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error("expected a JSON object");
|
|
3046
|
-
const parsed = raw;
|
|
3169
|
+
const parsed = JSON.parse(item.val);
|
|
3047
3170
|
const meta = {};
|
|
3048
3171
|
for (const [key, value] of Object.entries(parsed)) if (typeof value === "string") meta[key] = value;
|
|
3049
3172
|
current.metadata = meta;
|
|
@@ -6297,8 +6420,8 @@ function loadFont(story, filename, diagnostics) {
|
|
|
6297
6420
|
* Load sources with mtime-based caching for incremental rebuilds.
|
|
6298
6421
|
* Always creates a fresh Story; cached passages are replayed via storyAdd().
|
|
6299
6422
|
*/
|
|
6300
|
-
function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
|
|
6301
|
-
const currentFiles =
|
|
6423
|
+
function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles, buildFiles) {
|
|
6424
|
+
const currentFiles = new Set(buildFiles);
|
|
6302
6425
|
const changedPaths = changedFiles === void 0 ? void 0 : new Set([...changedFiles].map((f) => (0, node_path.resolve)(f)));
|
|
6303
6426
|
for (const filename of filenames) {
|
|
6304
6427
|
if (processedFiles.has(filename)) {
|
|
@@ -6363,16 +6486,6 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
6363
6486
|
}
|
|
6364
6487
|
//#endregion
|
|
6365
6488
|
//#region src/template.ts
|
|
6366
|
-
/**
|
|
6367
|
-
* An HTML closing head tag: `</head` in any letter case, followed by whitespace, `/` or `>`, up to the next `>`.
|
|
6368
|
-
* This is how the HTML tokenizer reads an end tag; it ignores whatever sits between the tag name and the `>`.
|
|
6369
|
-
*/
|
|
6370
|
-
const CLOSING_HEAD_TAG = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>";
|
|
6371
|
-
/**
|
|
6372
|
-
* The start of an HTML body start tag: `<body` in any letter case, followed by whitespace, `/` or `>` (so not
|
|
6373
|
-
* `<bodyx>`). Only the `<body` is matched; the attributes and `>` after it are left alone.
|
|
6374
|
-
*/
|
|
6375
|
-
const BODY_START_TAG = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
|
|
6376
6489
|
/** The regular expression source matching exactly `text`. */
|
|
6377
6490
|
function literal(text) {
|
|
6378
6491
|
return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
@@ -6398,6 +6511,8 @@ function fillTemplateParts(parts) {
|
|
|
6398
6511
|
return part.text.replace(pattern, (match, ...groups) => {
|
|
6399
6512
|
const slot = slots.find((_, i) => groups[i] !== void 0);
|
|
6400
6513
|
if (slot === void 0 || slot.occurrences === "first" && filled.has(slot)) return match;
|
|
6514
|
+
const offset = groups[slots.length];
|
|
6515
|
+
if (slot.accepts !== void 0 && typeof offset === "number" && !slot.accepts(part.text, offset)) return match;
|
|
6401
6516
|
filled.add(slot);
|
|
6402
6517
|
return slot.replacement(match);
|
|
6403
6518
|
});
|
|
@@ -6488,15 +6603,18 @@ function placeHead(content, texts, owner) {
|
|
|
6488
6603
|
slot: void 0,
|
|
6489
6604
|
diagnostics: []
|
|
6490
6605
|
};
|
|
6491
|
-
const
|
|
6492
|
-
|
|
6493
|
-
|
|
6606
|
+
const scans = texts.map((text) => ({
|
|
6607
|
+
text,
|
|
6608
|
+
tags: scanHeadTags(text)
|
|
6609
|
+
}));
|
|
6610
|
+
if (scans.some(({ tags }) => tags.closingHead !== void 0)) return {
|
|
6611
|
+
slot: beforeTag(CLOSING_HEAD_PREFIX, "closingHead", content),
|
|
6494
6612
|
diagnostics: []
|
|
6495
6613
|
};
|
|
6496
|
-
if (
|
|
6614
|
+
if (scans.some(({ tags }) => tags.bodyStart !== void 0)) {
|
|
6497
6615
|
const message = `${owner} has no closing head tag; the modules and head file were injected before its body start tag.`;
|
|
6498
6616
|
return {
|
|
6499
|
-
slot: beforeTag(
|
|
6617
|
+
slot: beforeTag(BODY_START_PREFIX, "bodyStart", content),
|
|
6500
6618
|
diagnostics: [{
|
|
6501
6619
|
level: "warning",
|
|
6502
6620
|
message
|
|
@@ -6511,12 +6629,108 @@ function placeHead(content, texts, owner) {
|
|
|
6511
6629
|
}]
|
|
6512
6630
|
};
|
|
6513
6631
|
}
|
|
6514
|
-
/**
|
|
6515
|
-
|
|
6632
|
+
/** `</head` in any letter case, followed by whitespace, `/` or `>`; the rest of the tag is left alone. */
|
|
6633
|
+
const CLOSING_HEAD_PREFIX = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])";
|
|
6634
|
+
/** `<body` in any letter case, followed by whitespace, `/` or `>` (so not `<bodyx>`). */
|
|
6635
|
+
const BODY_START_PREFIX = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
|
|
6636
|
+
/** Elements whose content is text, so a tag written there is not a tag. */
|
|
6637
|
+
const RAW_TEXT_ELEMENTS = /* @__PURE__ */ new Set([
|
|
6638
|
+
"script",
|
|
6639
|
+
"style",
|
|
6640
|
+
"textarea",
|
|
6641
|
+
"title"
|
|
6642
|
+
]);
|
|
6643
|
+
const isSpace = (c) => c === " " || c === " " || c === "\n" || c === "\f" || c === "\r";
|
|
6644
|
+
const isLetter = (c) => c !== void 0 && /^[A-Za-z]$/.test(c);
|
|
6645
|
+
/**
|
|
6646
|
+
* Find the first closing head tag and body start tag that HTML would read as tags: not those in a comment, in
|
|
6647
|
+
* the text of a script, style, textarea or title element, or in a quoted attribute value. A small scanner, not
|
|
6648
|
+
* a full HTML tokenizer; it needs no more than the tags and where they end.
|
|
6649
|
+
*/
|
|
6650
|
+
function scanHeadTags(html) {
|
|
6651
|
+
const { closingHead, bodyStart } = scanTags$1(html);
|
|
6652
|
+
return {
|
|
6653
|
+
closingHead,
|
|
6654
|
+
bodyStart
|
|
6655
|
+
};
|
|
6656
|
+
}
|
|
6657
|
+
function scanTags$1(html) {
|
|
6658
|
+
let closingHead;
|
|
6659
|
+
let bodyStart;
|
|
6660
|
+
let headStartEnd;
|
|
6661
|
+
let i = 0;
|
|
6662
|
+
while (i < html.length) {
|
|
6663
|
+
const lt = html.indexOf("<", i);
|
|
6664
|
+
if (lt === -1) break;
|
|
6665
|
+
const next = html[lt + 1];
|
|
6666
|
+
if (html.startsWith("<!--", lt)) {
|
|
6667
|
+
const end = html.indexOf("-->", lt + 4);
|
|
6668
|
+
i = end === -1 ? html.length : end + 3;
|
|
6669
|
+
} else if (next === "!" || next === "?") {
|
|
6670
|
+
const end = html.indexOf(">", lt);
|
|
6671
|
+
i = end === -1 ? html.length : end + 1;
|
|
6672
|
+
} else if (next === "/" && isLetter(html[lt + 2])) {
|
|
6673
|
+
const { name, end } = readTag(html, lt + 2);
|
|
6674
|
+
if (name === "head" && closingHead === void 0) closingHead = lt;
|
|
6675
|
+
i = end;
|
|
6676
|
+
} else if (isLetter(next)) {
|
|
6677
|
+
const { name, end } = readTag(html, lt + 1);
|
|
6678
|
+
if (name === "body" && bodyStart === void 0) bodyStart = lt;
|
|
6679
|
+
if (name === "head" && headStartEnd === void 0) headStartEnd = end;
|
|
6680
|
+
i = RAW_TEXT_ELEMENTS.has(name) ? rawTextEnd(html, name, end) : end;
|
|
6681
|
+
} else i = lt + 1;
|
|
6682
|
+
if (closingHead !== void 0 && bodyStart !== void 0) break;
|
|
6683
|
+
}
|
|
6684
|
+
return {
|
|
6685
|
+
closingHead,
|
|
6686
|
+
bodyStart,
|
|
6687
|
+
headStartEnd
|
|
6688
|
+
};
|
|
6689
|
+
}
|
|
6690
|
+
/** Read a tag whose name starts at `from`: its lowercase name, and the offset after its `>` (or the end). */
|
|
6691
|
+
function readTag(html, from) {
|
|
6692
|
+
let i = from;
|
|
6693
|
+
while (i < html.length && !isSpace(html[i]) && html[i] !== "/" && html[i] !== ">") i++;
|
|
6694
|
+
const name = html.slice(from, i).toLowerCase();
|
|
6695
|
+
while (i < html.length && html[i] !== ">") {
|
|
6696
|
+
if (html[i] !== "=") {
|
|
6697
|
+
i++;
|
|
6698
|
+
continue;
|
|
6699
|
+
}
|
|
6700
|
+
i++;
|
|
6701
|
+
while (isSpace(html[i])) i++;
|
|
6702
|
+
const quote = html[i];
|
|
6703
|
+
if (quote === "\"" || quote === "'") {
|
|
6704
|
+
const close = html.indexOf(quote, i + 1);
|
|
6705
|
+
i = close === -1 ? html.length : close + 1;
|
|
6706
|
+
}
|
|
6707
|
+
}
|
|
6516
6708
|
return {
|
|
6517
|
-
|
|
6709
|
+
name,
|
|
6710
|
+
end: Math.min(i + 1, html.length)
|
|
6711
|
+
};
|
|
6712
|
+
}
|
|
6713
|
+
/** The offset of the end tag that closes a raw text element, given where its text starts (or the end). */
|
|
6714
|
+
function rawTextEnd(html, name, from) {
|
|
6715
|
+
const close = new RegExp(`</${name}(?=[\\t\\n\\f\\r />])`, "gi");
|
|
6716
|
+
close.lastIndex = from;
|
|
6717
|
+
return close.exec(html)?.index ?? html.length;
|
|
6718
|
+
}
|
|
6719
|
+
/**
|
|
6720
|
+
* The slot that inserts `content` on its own line before the first real tag of the kind `which` names (see
|
|
6721
|
+
* `scanHeadTags()`), which is kept as written. A look-alike inside a script or comment is left alone.
|
|
6722
|
+
*/
|
|
6723
|
+
function beforeTag(pattern, which, content) {
|
|
6724
|
+
const cache = /* @__PURE__ */ new Map();
|
|
6725
|
+
const offsetIn = (text) => {
|
|
6726
|
+
if (!cache.has(text)) cache.set(text, scanHeadTags(text)[which]);
|
|
6727
|
+
return cache.get(text);
|
|
6728
|
+
};
|
|
6729
|
+
return {
|
|
6730
|
+
pattern,
|
|
6518
6731
|
occurrences: "first",
|
|
6519
|
-
replacement: (match) => `${content}\n${match}
|
|
6732
|
+
replacement: (match) => `${content}\n${match}`,
|
|
6733
|
+
accepts: (text, offset) => offsetIn(text) === offset
|
|
6520
6734
|
};
|
|
6521
6735
|
}
|
|
6522
6736
|
//#endregion
|
|
@@ -6962,6 +7176,7 @@ async function compile(options) {
|
|
|
6962
7176
|
*/
|
|
6963
7177
|
async function compileToFile(options) {
|
|
6964
7178
|
const result = await compileForOutputFile(options, options.outFile);
|
|
7179
|
+
options.signal?.throwIfAborted();
|
|
6965
7180
|
writeFileAtomic(options.outFile, result.output);
|
|
6966
7181
|
return result;
|
|
6967
7182
|
}
|
|
@@ -7123,34 +7338,61 @@ async function buildOutput(options, cache, changedFiles, outputs) {
|
|
|
7123
7338
|
const timeout = options.formatFetchTimeout;
|
|
7124
7339
|
if (timeout !== void 0 && !(timeout >= 0)) throw new TweeTsError(`formatFetchTimeout must be 0 or more milliseconds, not ${timeout}.`);
|
|
7125
7340
|
clearIndexCache();
|
|
7126
|
-
const
|
|
7127
|
-
const
|
|
7128
|
-
for (const source of options.sources)
|
|
7129
|
-
|
|
7130
|
-
|
|
7131
|
-
|
|
7132
|
-
|
|
7133
|
-
|
|
7341
|
+
const groups = [];
|
|
7342
|
+
const outputSources = [];
|
|
7343
|
+
for (const source of options.sources) {
|
|
7344
|
+
const last = groups[groups.length - 1];
|
|
7345
|
+
if (typeof source === "string") {
|
|
7346
|
+
if (last?.kind === "paths") last.paths.push(source);
|
|
7347
|
+
else groups.push({
|
|
7348
|
+
kind: "paths",
|
|
7349
|
+
paths: [source]
|
|
7350
|
+
});
|
|
7351
|
+
} else if (last?.kind === "inline") last.sources.push(source);
|
|
7352
|
+
else groups.push({
|
|
7353
|
+
kind: "inline",
|
|
7354
|
+
sources: [source]
|
|
7355
|
+
});
|
|
7356
|
+
}
|
|
7357
|
+
const walked = groups.map((group) => {
|
|
7358
|
+
if (group.kind === "inline") return group;
|
|
7359
|
+
const found = getFilenames(group.paths, written, options.exclude);
|
|
7360
|
+
diagnostics.push(...found.diagnostics);
|
|
7361
|
+
outputSources.push(...found.outputSources);
|
|
7362
|
+
return {
|
|
7363
|
+
kind: "files",
|
|
7364
|
+
filenames: found.filenames
|
|
7365
|
+
};
|
|
7366
|
+
});
|
|
7367
|
+
rejectOutputSources(outputSources, diagnostics);
|
|
7368
|
+
const buildFiles = new Set(walked.flatMap((group) => group.kind === "files" ? group.filenames : []));
|
|
7134
7369
|
const story = createStory();
|
|
7135
7370
|
const processedFiles = /* @__PURE__ */ new Set();
|
|
7136
|
-
if (
|
|
7371
|
+
for (const group of walked) if (group.kind === "inline") loadInlineSources(story, group.sources, {
|
|
7137
7372
|
trim,
|
|
7138
7373
|
twee2Compat
|
|
7139
|
-
}, diagnostics
|
|
7140
|
-
else
|
|
7374
|
+
}, diagnostics);
|
|
7375
|
+
else if (cache) loadSourcesCached(story, group.filenames, {
|
|
7376
|
+
trim,
|
|
7377
|
+
twee2Compat
|
|
7378
|
+
}, diagnostics, processedFiles, cache, changedFiles, buildFiles);
|
|
7379
|
+
else loadSources(story, group.filenames, {
|
|
7141
7380
|
trim,
|
|
7142
7381
|
twee2Compat
|
|
7143
7382
|
}, diagnostics, processedFiles);
|
|
7144
|
-
|
|
7383
|
+
if (cache && buildFiles.size === 0) loadSourcesCached(story, [], {
|
|
7145
7384
|
trim,
|
|
7146
7385
|
twee2Compat
|
|
7147
|
-
}, diagnostics);
|
|
7386
|
+
}, diagnostics, processedFiles, cache, changedFiles);
|
|
7148
7387
|
if (options.tagAliases) story.passages = applyTagAliases(story.passages, options.tagAliases);
|
|
7149
7388
|
let format;
|
|
7150
|
-
if (outputMode === "html")
|
|
7151
|
-
|
|
7152
|
-
|
|
7153
|
-
|
|
7389
|
+
if (outputMode === "html") {
|
|
7390
|
+
format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
|
|
7391
|
+
...options,
|
|
7392
|
+
noRemote
|
|
7393
|
+
}, diagnostics);
|
|
7394
|
+
options.signal?.throwIfAborted();
|
|
7395
|
+
}
|
|
7154
7396
|
if (options.startPassage) story.twine2.start = options.startPassage;
|
|
7155
7397
|
const startName = story.twine2.start || DEFAULT_START_NAME;
|
|
7156
7398
|
if (testMode) story.twine2.options.set("debug", true);
|
|
@@ -7202,6 +7444,7 @@ async function buildOutput(options, cache, changedFiles, outputs) {
|
|
|
7202
7444
|
...getStoryStats(story, options.wordCountMethod),
|
|
7203
7445
|
files: [...processedFiles]
|
|
7204
7446
|
};
|
|
7447
|
+
options.signal?.throwIfAborted();
|
|
7205
7448
|
return {
|
|
7206
7449
|
output,
|
|
7207
7450
|
story,
|
|
@@ -8967,22 +9210,13 @@ const foldKey = (key) => key.toLowerCase().replace(/[-_]/g, "");
|
|
|
8967
9210
|
*
|
|
8968
9211
|
* @param diagnostics Receives warnings that do not stop the config from loading: keys the config does not
|
|
8969
9212
|
* define, and a file that is not valid UTF-8 (it is read as Windows-1252).
|
|
8970
|
-
* @throws When the file is not valid JSON or fails {@link validateConfig}.
|
|
9213
|
+
* @throws When the file exists but cannot be read, is not valid JSON or fails {@link validateConfig}.
|
|
8971
9214
|
*/
|
|
8972
9215
|
function loadConfig(dir, diagnostics) {
|
|
8973
9216
|
const base = dir ?? process.cwd();
|
|
8974
9217
|
const configPath = (0, node_path.join)(base, CONFIG_FILENAME);
|
|
8975
9218
|
if (!(0, node_fs.existsSync)(configPath)) return null;
|
|
8976
|
-
|
|
8977
|
-
let raw;
|
|
8978
|
-
try {
|
|
8979
|
-
raw = readUTF8(configPath, readDiagnostics);
|
|
8980
|
-
} catch {
|
|
8981
|
-
return null;
|
|
8982
|
-
}
|
|
8983
|
-
const config = parseConfig(raw, configPath, readDiagnostics);
|
|
8984
|
-
diagnostics?.push(...readDiagnostics);
|
|
8985
|
-
return config;
|
|
9219
|
+
return loadConfigFile(configPath, diagnostics);
|
|
8986
9220
|
}
|
|
8987
9221
|
/**
|
|
8988
9222
|
* Load a config from a specific file path.
|