@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/bin/twee-ts.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
|
-
import { accessSync, chmodSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
|
|
3
|
+
import { accessSync, chmodSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
|
|
4
4
|
import * as nodePath from "node:path";
|
|
5
5
|
import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
@@ -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
|
|
@@ -1556,6 +1555,89 @@ function stripSetupFunction(chunk) {
|
|
|
1556
1555
|
for (const match of chunk.matchAll(SETUP_FUNCTION_PROPERTY)) lastIndex = match.index;
|
|
1557
1556
|
return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
|
|
1558
1557
|
}
|
|
1558
|
+
/** Index after the whitespace and `//` or block comments starting at `i`. */
|
|
1559
|
+
function skipTrivia(text, i) {
|
|
1560
|
+
let pos = i;
|
|
1561
|
+
for (;;) {
|
|
1562
|
+
const ch = text[pos];
|
|
1563
|
+
if (ch !== void 0 && /\s/.test(ch)) pos++;
|
|
1564
|
+
else if (text.startsWith("//", pos)) {
|
|
1565
|
+
const newline = text.indexOf("\n", pos);
|
|
1566
|
+
pos = newline === -1 ? text.length : newline + 1;
|
|
1567
|
+
} else if (text.startsWith("/*", pos)) {
|
|
1568
|
+
const close = text.indexOf("*/", pos + 2);
|
|
1569
|
+
pos = close === -1 ? text.length : close + 2;
|
|
1570
|
+
} else return pos;
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
/** Index after the quoted string starting at `i` (a quote character); template literals nest `${…}`. */
|
|
1574
|
+
function skipString(text, i) {
|
|
1575
|
+
const quote = text[i];
|
|
1576
|
+
let pos = i + 1;
|
|
1577
|
+
while (pos < text.length) {
|
|
1578
|
+
const ch = text[pos];
|
|
1579
|
+
if (ch === "\\") pos += 2;
|
|
1580
|
+
else if (ch === quote) return pos + 1;
|
|
1581
|
+
else if (quote === "`" && ch === "$" && text[pos + 1] === "{") {
|
|
1582
|
+
const close = findClosingBrace(text, pos + 1);
|
|
1583
|
+
if (close === -1) return text.length;
|
|
1584
|
+
pos = close + 1;
|
|
1585
|
+
} else pos++;
|
|
1586
|
+
}
|
|
1587
|
+
return text.length;
|
|
1588
|
+
}
|
|
1589
|
+
function startsComment(text, pos) {
|
|
1590
|
+
return text[pos] === "/" && (text[pos + 1] === "/" || text[pos + 1] === "*");
|
|
1591
|
+
}
|
|
1592
|
+
function startsString(text, pos) {
|
|
1593
|
+
const ch = text[pos];
|
|
1594
|
+
return ch === "\"" || ch === "'" || ch === "`";
|
|
1595
|
+
}
|
|
1596
|
+
/** Index of the `}` matching the `{` at `open`, skipping comments and strings; -1 when unbalanced. */
|
|
1597
|
+
function findClosingBrace(text, open) {
|
|
1598
|
+
let depth = 0;
|
|
1599
|
+
let pos = open;
|
|
1600
|
+
while (pos < text.length) if (startsComment(text, pos)) pos = skipTrivia(text, pos);
|
|
1601
|
+
else if (startsString(text, pos)) pos = skipString(text, pos);
|
|
1602
|
+
else {
|
|
1603
|
+
if (text[pos] === "{") depth++;
|
|
1604
|
+
else if (text[pos] === "}" && --depth === 0) return pos;
|
|
1605
|
+
pos++;
|
|
1606
|
+
}
|
|
1607
|
+
return -1;
|
|
1608
|
+
}
|
|
1609
|
+
const STORY_FORMAT = "storyFormat";
|
|
1610
|
+
const IDENTIFIER_CHAR = /[\w$]/;
|
|
1611
|
+
/**
|
|
1612
|
+
* Locate the object literal passed to `storyFormat(` as `[start, end)` offsets, ignoring comments and
|
|
1613
|
+
* strings outside it and never evaluating anything. Without such a call, the first `{` outside comments
|
|
1614
|
+
* and strings starts the object. When its closing brace cannot be matched (a Harlowe setup function can
|
|
1615
|
+
* hold regular expression literals), the last `}` of the file ends it.
|
|
1616
|
+
*/
|
|
1617
|
+
function locateFormatObject(text) {
|
|
1618
|
+
let start = -1;
|
|
1619
|
+
let firstBrace = -1;
|
|
1620
|
+
let pos = 0;
|
|
1621
|
+
while (pos < text.length && start === -1) if (startsComment(text, pos)) pos = skipTrivia(text, pos);
|
|
1622
|
+
else if (startsString(text, pos)) pos = skipString(text, pos);
|
|
1623
|
+
else if (text[pos] === "{") {
|
|
1624
|
+
if (firstBrace === -1) firstBrace = pos;
|
|
1625
|
+
pos++;
|
|
1626
|
+
} else if (text.startsWith(STORY_FORMAT, pos) && !IDENTIFIER_CHAR.test(text[pos - 1] ?? "")) {
|
|
1627
|
+
const paren = skipTrivia(text, pos + 11);
|
|
1628
|
+
const brace = text[paren] === "(" ? skipTrivia(text, paren + 1) : -1;
|
|
1629
|
+
if (text[brace] === "{") start = brace;
|
|
1630
|
+
pos += 11;
|
|
1631
|
+
} else pos++;
|
|
1632
|
+
if (start === -1) start = firstBrace;
|
|
1633
|
+
if (start === -1) return void 0;
|
|
1634
|
+
const close = findClosingBrace(text, start);
|
|
1635
|
+
const end = close === -1 ? text.lastIndexOf("}") : close;
|
|
1636
|
+
return end < start ? void 0 : {
|
|
1637
|
+
start,
|
|
1638
|
+
end: end + 1
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1559
1641
|
/**
|
|
1560
1642
|
* Parse the format object at `start`–`end` of `text`: strict JSON first (fast), then the
|
|
1561
1643
|
* JavaScript literal subset formats use. Error positions point into `text`.
|
|
@@ -1580,6 +1662,8 @@ function parseFormatObject(text, start, end) {
|
|
|
1580
1662
|
}
|
|
1581
1663
|
}
|
|
1582
1664
|
/** Check a parsed format object's fields. Per spec, `name` is optional; `version` and `source` are required. */
|
|
1665
|
+
/** The name given to a format.js that names no format. */
|
|
1666
|
+
const UNNAMED_FORMAT_NAME = "Untitled Story Format";
|
|
1583
1667
|
function toFormatJSON(raw) {
|
|
1584
1668
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
|
|
1585
1669
|
ok: false,
|
|
@@ -1599,7 +1683,7 @@ function toFormatJSON(raw) {
|
|
|
1599
1683
|
reason: `Story format version ${JSON.stringify(obj.version)} is not a SemVer version.`
|
|
1600
1684
|
};
|
|
1601
1685
|
const data = {
|
|
1602
|
-
name: typeof obj.name === "string" ? obj.name :
|
|
1686
|
+
name: typeof obj.name === "string" ? obj.name : UNNAMED_FORMAT_NAME,
|
|
1603
1687
|
version: obj.version,
|
|
1604
1688
|
source: obj.source,
|
|
1605
1689
|
proofing: obj.proofing === true
|
|
@@ -1624,15 +1708,14 @@ function toFormatJSON(raw) {
|
|
|
1624
1708
|
* `harlowe-3` directory or a direct download.
|
|
1625
1709
|
*/
|
|
1626
1710
|
function decodeFormatJSON(source) {
|
|
1627
|
-
const
|
|
1628
|
-
|
|
1629
|
-
if (first === -1 || last < first) return {
|
|
1711
|
+
const located = locateFormatObject(source);
|
|
1712
|
+
if (located === void 0) return {
|
|
1630
1713
|
ok: false,
|
|
1631
1714
|
reason: "Could not find Twine 2 style story format JSON chunk."
|
|
1632
1715
|
};
|
|
1633
|
-
const parsed = parseFormatObject(source,
|
|
1716
|
+
const parsed = parseFormatObject(source, located.start, located.end);
|
|
1634
1717
|
if (parsed.ok) return toFormatJSON(parsed.value);
|
|
1635
|
-
const stripped = stripSetupFunction(source.slice(
|
|
1718
|
+
const stripped = stripSetupFunction(source.slice(located.start, located.end));
|
|
1636
1719
|
const retried = stripped === void 0 ? void 0 : parseFormatObject(stripped, 0, stripped.length);
|
|
1637
1720
|
if (retried?.ok) return toFormatJSON(retried.value);
|
|
1638
1721
|
return {
|
|
@@ -1919,16 +2002,17 @@ function readFormatSource(format, diagnostics) {
|
|
|
1919
2002
|
/**
|
|
1920
2003
|
* Writes `data` to `path` so that a reader sees either the previous file or the complete new
|
|
1921
2004
|
* one, never a partly written file. The data goes to a temporary file in the same folder,
|
|
1922
|
-
* which is then renamed over `path`. When `path` is a symlink, the file it
|
|
1923
|
-
* replaced, and an existing file keeps its permissions.
|
|
2005
|
+
* which is then renamed over `path`. When `path` is a symlink (even a dangling one, or a chain of links), the file it
|
|
2006
|
+
* finally points to is replaced or created and the link stays, and an existing file keeps its permissions.
|
|
1924
2007
|
*
|
|
1925
2008
|
* On failure (a full disk, a missing folder) the temporary file is removed, `path` is left
|
|
1926
2009
|
* as it was, and the error is thrown with `path` in its message and its original `code`.
|
|
1927
2010
|
*/
|
|
1928
2011
|
function writeFileAtomic(path, data) {
|
|
1929
|
-
|
|
1930
|
-
const temp = join(dirname(target), `.${basename(target)}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
|
|
2012
|
+
let temp;
|
|
1931
2013
|
try {
|
|
2014
|
+
const target = resolveWriteTarget(path);
|
|
2015
|
+
temp = join(dirname(target), `.${basename(target)}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
|
|
1932
2016
|
writeFileSync(temp, data, {
|
|
1933
2017
|
encoding: "utf-8",
|
|
1934
2018
|
flag: "wx"
|
|
@@ -1936,17 +2020,25 @@ function writeFileAtomic(path, data) {
|
|
|
1936
2020
|
keepPermissions(target, temp);
|
|
1937
2021
|
renameSync(temp, target);
|
|
1938
2022
|
} catch (e) {
|
|
1939
|
-
rmSync(temp, { force: true });
|
|
2023
|
+
if (temp !== void 0) rmSync(temp, { force: true });
|
|
1940
2024
|
throw writeError(path, e);
|
|
1941
2025
|
}
|
|
1942
2026
|
}
|
|
1943
|
-
/**
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
2027
|
+
/** Links followed before a path counts as a cycle (Linux stops at 40 as well). */
|
|
2028
|
+
const MAX_LINK_DEPTH = 40;
|
|
2029
|
+
/**
|
|
2030
|
+
* The file a symlink at `path` finally points to, or `path` itself when it is no link. Links are
|
|
2031
|
+
* followed one by one, so a link whose target does not exist yet (absolute, relative, or a chain
|
|
2032
|
+
* of them) still resolves to the file that should be created. A cycle or an unreadable link throws.
|
|
2033
|
+
*/
|
|
2034
|
+
function resolveWriteTarget(path) {
|
|
2035
|
+
let current = path;
|
|
2036
|
+
for (let depth = 0; depth <= MAX_LINK_DEPTH; depth++) {
|
|
2037
|
+
const stat = lstatSync(current, { throwIfNoEntry: false });
|
|
2038
|
+
if (stat === void 0 || !stat.isSymbolicLink()) return current;
|
|
2039
|
+
current = resolve(dirname(current), readlinkSync(current));
|
|
1949
2040
|
}
|
|
2041
|
+
throw Object.assign(/* @__PURE__ */ new Error(`ELOOP: too many levels of symbolic links, ${path}`), { code: "ELOOP" });
|
|
1950
2042
|
}
|
|
1951
2043
|
/**
|
|
1952
2044
|
* Gives `temp` the permission bits of `target`, when `target` exists. Best effort: on a file
|
|
@@ -2058,31 +2150,37 @@ function clearIndexCache() {
|
|
|
2058
2150
|
/** Requests in progress, by what they fetch, so concurrent compiles make each request once. */
|
|
2059
2151
|
const sharedRequests = /* @__PURE__ */ new Map();
|
|
2060
2152
|
/**
|
|
2061
|
-
* Runs `start` once for all callers that ask for `key` while it is in progress.
|
|
2062
|
-
*
|
|
2063
|
-
*
|
|
2153
|
+
* Runs `start` once for all callers that ask for `key` while it is in progress. Each caller waits
|
|
2154
|
+
* under its own limits: a caller whose `signal` aborts, or whose `timeout` passes, stops waiting
|
|
2155
|
+
* at once and rejects, while the others keep waiting. The request itself is aborted (through
|
|
2156
|
+
* the signal `start` receives) once no caller waits on it. `start` must not depend on any one
|
|
2157
|
+
* caller: what differs between callers belongs after the shared result.
|
|
2064
2158
|
*/
|
|
2065
|
-
function shareRequest(key, signal, start) {
|
|
2159
|
+
function shareRequest(key, { signal, timeout, timedOut }, start) {
|
|
2066
2160
|
if (signal?.aborted) return Promise.reject(signal.reason);
|
|
2067
2161
|
const request = sharedRequests.get(key) ?? startSharedRequest(key, start);
|
|
2068
2162
|
request.waiters++;
|
|
2069
2163
|
return new Promise((resolve, reject) => {
|
|
2070
2164
|
let waiting = true;
|
|
2165
|
+
let timer;
|
|
2071
2166
|
const stopWaiting = () => {
|
|
2072
2167
|
if (!waiting) return;
|
|
2073
2168
|
waiting = false;
|
|
2074
2169
|
signal?.removeEventListener("abort", onAbort);
|
|
2170
|
+
clearTimeout(timer);
|
|
2075
2171
|
request.waiters--;
|
|
2076
2172
|
};
|
|
2077
|
-
const
|
|
2173
|
+
const giveUp = (reason) => {
|
|
2078
2174
|
stopWaiting();
|
|
2079
2175
|
if (request.waiters === 0) {
|
|
2080
2176
|
if (sharedRequests.get(key) === request) sharedRequests.delete(key);
|
|
2081
|
-
request.controller.abort(
|
|
2177
|
+
request.controller.abort(reason);
|
|
2082
2178
|
}
|
|
2083
|
-
reject(
|
|
2179
|
+
reject(reason);
|
|
2084
2180
|
};
|
|
2181
|
+
const onAbort = () => giveUp(signal?.reason);
|
|
2085
2182
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2183
|
+
if (timeout > 0 && timeout <= MAX_TIMER_DELAY) timer = setTimeout(() => giveUp(timedOut()), timeout);
|
|
2086
2184
|
request.promise.then((value) => {
|
|
2087
2185
|
stopWaiting();
|
|
2088
2186
|
resolve(value);
|
|
@@ -2112,20 +2210,32 @@ function requestTimeout(options) {
|
|
|
2112
2210
|
if (!(timeout >= 0)) throw new RangeError(`A story format request timeout must be 0 or more milliseconds, not ${timeout}`);
|
|
2113
2211
|
return timeout;
|
|
2114
2212
|
}
|
|
2213
|
+
/** How one caller waits for `url`: under its own signal and timeout. `what` names the request in errors. */
|
|
2214
|
+
function waitOptions(options, what, url) {
|
|
2215
|
+
const timeout = requestTimeout(options);
|
|
2216
|
+
return {
|
|
2217
|
+
signal: options.signal,
|
|
2218
|
+
timeout,
|
|
2219
|
+
timedOut: () => /* @__PURE__ */ new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`)
|
|
2220
|
+
};
|
|
2221
|
+
}
|
|
2115
2222
|
/**
|
|
2116
|
-
* Fetch `url` as
|
|
2117
|
-
*
|
|
2223
|
+
* Fetch `url` as bytes, aborting when `signal` does. `what` names the request in errors, as in
|
|
2224
|
+
* "Failed to <what> from <url>". The bytes are returned undecoded, so checksums cover what was served.
|
|
2118
2225
|
*/
|
|
2119
|
-
async function
|
|
2120
|
-
const
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2226
|
+
async function fetchBytes(url, what, signal) {
|
|
2227
|
+
const res = await fetch(url, { signal });
|
|
2228
|
+
if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
|
|
2229
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
2230
|
+
}
|
|
2231
|
+
/** Download a format.js once for every caller in this process that asks for `url` meanwhile. */
|
|
2232
|
+
function shareDownload(url, options) {
|
|
2233
|
+
return shareRequest(`download\0${url}`, waitOptions(options, "download format", url), (signal) => fetchBytes(url, "download format", signal));
|
|
2234
|
+
}
|
|
2235
|
+
/** Decode a downloaded format.js as local ones are (UTF-8, else Windows-1252), without a leading BOM. */
|
|
2236
|
+
function decodeDownload(bytes, url) {
|
|
2237
|
+
const { text } = decodeText(bytes, url);
|
|
2238
|
+
return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
|
|
2129
2239
|
}
|
|
2130
2240
|
/** Validate that a JSON value is a valid SFAIndexEntry. */
|
|
2131
2241
|
function isValidEntry(val) {
|
|
@@ -2147,9 +2257,8 @@ async function fetchIndex(url, options = {}) {
|
|
|
2147
2257
|
options.signal?.throwIfAborted();
|
|
2148
2258
|
const cached = indexCache.get(url);
|
|
2149
2259
|
if (cached) return cached;
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
const text = await fetchText(url, "fetch format index", signal, timeout);
|
|
2260
|
+
return shareRequest(`index\0${url}`, waitOptions(options, "fetch format index", url), async (signal) => {
|
|
2261
|
+
const text = decodeDownload(await fetchBytes(url, "fetch format index", signal), url);
|
|
2153
2262
|
const data = validateSFAIndex(JSON.parse(text));
|
|
2154
2263
|
indexCache.set(url, data);
|
|
2155
2264
|
return data;
|
|
@@ -2186,26 +2295,37 @@ function cachedFormatInfo(id, filename, data) {
|
|
|
2186
2295
|
};
|
|
2187
2296
|
}
|
|
2188
2297
|
/**
|
|
2189
|
-
*
|
|
2190
|
-
*
|
|
2298
|
+
* Check that a downloaded format is the one the index entry promised. The name matches without
|
|
2299
|
+
* regard to case, as format requests do, and the version by SemVer precedence, so "1.0" and "v1.0.0"
|
|
2300
|
+
* agree. A format.js that names no format ({@link UNNAMED_FORMAT_NAME}) is allowed: such formats
|
|
2301
|
+
* exist, and the index entry then supplies the name it is cached under.
|
|
2302
|
+
*/
|
|
2303
|
+
function checkFormatIdentity(entry, data, downloadUrl) {
|
|
2304
|
+
if ((data.name === "Untitled Story Format" || data.name.toLowerCase() === entry.name.toLowerCase()) && sameVersion(data.version, entry.version)) return;
|
|
2305
|
+
throw new Error(`Story format mismatch for ${downloadUrl}: the index lists ${entry.name} ${entry.version}, but the download is ${data.name} ${data.version}`);
|
|
2306
|
+
}
|
|
2307
|
+
/**
|
|
2308
|
+
* Download a format, verify its checksum, check that it is the format the entry names, write it to
|
|
2309
|
+
* the cache shared by name and version, and return its StoryFormatInfo. Concurrent calls for one
|
|
2310
|
+
* download URL share one request for the bytes; each call then verifies them against its own
|
|
2311
|
+
* entry, within its own timeout.
|
|
2191
2312
|
*/
|
|
2192
2313
|
async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
|
|
2193
2314
|
cachedFormatDir(entry.name, entry.version);
|
|
2194
|
-
const
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
const
|
|
2198
|
-
if (
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
});
|
|
2315
|
+
const bytes = await shareDownload(downloadUrl, options);
|
|
2316
|
+
const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
|
|
2317
|
+
if (checksumKey) {
|
|
2318
|
+
const expected = entry.checksums[checksumKey];
|
|
2319
|
+
if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
|
|
2320
|
+
if (!await verifySHA256(bytes, expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
|
|
2321
|
+
}
|
|
2322
|
+
const text = decodeDownload(bytes, downloadUrl);
|
|
2323
|
+
const id = makeFormatId(entry.name, entry.version);
|
|
2324
|
+
const data = parseFormatJSON(text, id);
|
|
2325
|
+
if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
|
|
2326
|
+
checkFormatIdentity(entry, data, downloadUrl);
|
|
2327
|
+
options.signal?.throwIfAborted();
|
|
2328
|
+
return cachedFormatInfo(id, writeCacheEntry(resolve(getCacheDir()), [entry.name, entry.version], text), data);
|
|
2209
2329
|
}
|
|
2210
2330
|
/**
|
|
2211
2331
|
* Download a direct format.js URL, parse its JSON, cache it under that URL (apart from the
|
|
@@ -2213,16 +2333,13 @@ async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
|
|
|
2213
2333
|
* one URL share one request.
|
|
2214
2334
|
*/
|
|
2215
2335
|
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
|
-
});
|
|
2336
|
+
const text = decodeDownload(await shareDownload(url, options), url);
|
|
2337
|
+
const data = parseFormatJSON(text);
|
|
2338
|
+
if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
|
|
2339
|
+
cachedFormatDir(data.name, data.version);
|
|
2340
|
+
options.signal?.throwIfAborted();
|
|
2341
|
+
const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
|
|
2342
|
+
return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
|
|
2226
2343
|
}
|
|
2227
2344
|
/** The copy of a direct format URL in the download cache, if it has been downloaded before. */
|
|
2228
2345
|
function getCachedDirectFormat(url) {
|
|
@@ -2263,6 +2380,16 @@ async function resolveFormatUrls(request, urls, options = {}) {
|
|
|
2263
2380
|
}
|
|
2264
2381
|
if (lastError) throw lastError;
|
|
2265
2382
|
}
|
|
2383
|
+
/**
|
|
2384
|
+
* Find the best format among the cached copies of direct format URLs, without network access.
|
|
2385
|
+
* Matching follows {@link selectFormatCandidate}, so `allowOlder` admits a same-major older version.
|
|
2386
|
+
* Each URL keeps its own copy; the shared name and version downloads are never consulted.
|
|
2387
|
+
*
|
|
2388
|
+
* Internal, for format resolution; not part of the public API.
|
|
2389
|
+
*/
|
|
2390
|
+
function findCachedUrlFormat(request, urls, options = {}) {
|
|
2391
|
+
return selectFormatCandidate(request, urls.flatMap((url) => getCachedDirectFormat(url) ?? []), (f) => f, options);
|
|
2392
|
+
}
|
|
2266
2393
|
/** Whether `info` is a format the request accepts. */
|
|
2267
2394
|
function answers(request, info) {
|
|
2268
2395
|
return selectFormatCandidate(request, [info], (f) => f) !== void 0;
|
|
@@ -2498,8 +2625,8 @@ function findLocalFormat(all, pruned, request) {
|
|
|
2498
2625
|
* then format indices (unless `noRemote`). Names and IDs match the same way in each (see
|
|
2499
2626
|
* `selectFormatCandidate`), so a request that a local format answers never reaches the cache or the
|
|
2500
2627
|
* network. Local formats that cannot be used are reported as warnings. When a StoryData request
|
|
2501
|
-
* names a version that none of them has, an older version of
|
|
2502
|
-
*
|
|
2628
|
+
* names a version that none of them has, an older version of the same format and major version
|
|
2629
|
+
* is used with a warning, from local formats, the project's cached format URLs, or the shared cache. Anything else is an error diagnostic,
|
|
2503
2630
|
* and the result is `undefined`.
|
|
2504
2631
|
*/
|
|
2505
2632
|
async function resolveStoryFormat(request, options, diagnostics) {
|
|
@@ -2537,7 +2664,7 @@ async function resolveStoryFormat(request, options, diagnostics) {
|
|
|
2537
2664
|
if (remote) return remote;
|
|
2538
2665
|
}
|
|
2539
2666
|
if (request.kind === "name") {
|
|
2540
|
-
const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
|
|
2667
|
+
const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedUrlFormat(request, options.formatUrls ?? [], { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
|
|
2541
2668
|
if (older) {
|
|
2542
2669
|
diagnostics.push({
|
|
2543
2670
|
level: "warning",
|
|
@@ -2786,8 +2913,6 @@ function* tweeLexer(input) {
|
|
|
2786
2913
|
const items = ctx.drainItems();
|
|
2787
2914
|
for (const item of items) yield item;
|
|
2788
2915
|
}
|
|
2789
|
-
const remaining = ctx.drainItems();
|
|
2790
|
-
for (const item of remaining) yield item;
|
|
2791
2916
|
}
|
|
2792
2917
|
//#endregion
|
|
2793
2918
|
//#region src/twee2-compat.ts
|
|
@@ -2914,9 +3039,7 @@ function parseTwee(source, options = {}) {
|
|
|
2914
3039
|
};
|
|
2915
3040
|
}
|
|
2916
3041
|
try {
|
|
2917
|
-
const
|
|
2918
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error("expected a JSON object");
|
|
2919
|
-
const parsed = raw;
|
|
3042
|
+
const parsed = JSON.parse(item.val);
|
|
2920
3043
|
const meta = {};
|
|
2921
3044
|
for (const [key, value] of Object.entries(parsed)) if (typeof value === "string") meta[key] = value;
|
|
2922
3045
|
current.metadata = meta;
|
|
@@ -6161,8 +6284,8 @@ function loadFont(story, filename, diagnostics) {
|
|
|
6161
6284
|
* Load sources with mtime-based caching for incremental rebuilds.
|
|
6162
6285
|
* Always creates a fresh Story; cached passages are replayed via storyAdd().
|
|
6163
6286
|
*/
|
|
6164
|
-
function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
|
|
6165
|
-
const currentFiles =
|
|
6287
|
+
function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles, buildFiles) {
|
|
6288
|
+
const currentFiles = new Set(buildFiles);
|
|
6166
6289
|
const changedPaths = changedFiles === void 0 ? void 0 : new Set([...changedFiles].map((f) => resolve(f)));
|
|
6167
6290
|
for (const filename of filenames) {
|
|
6168
6291
|
if (processedFiles.has(filename)) {
|
|
@@ -6227,16 +6350,6 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
6227
6350
|
}
|
|
6228
6351
|
//#endregion
|
|
6229
6352
|
//#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
6353
|
/** The regular expression source matching exactly `text`. */
|
|
6241
6354
|
function literal(text) {
|
|
6242
6355
|
return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
@@ -6262,6 +6375,8 @@ function fillTemplateParts(parts) {
|
|
|
6262
6375
|
return part.text.replace(pattern, (match, ...groups) => {
|
|
6263
6376
|
const slot = slots.find((_, i) => groups[i] !== void 0);
|
|
6264
6377
|
if (slot === void 0 || slot.occurrences === "first" && filled.has(slot)) return match;
|
|
6378
|
+
const offset = groups[slots.length];
|
|
6379
|
+
if (slot.accepts !== void 0 && typeof offset === "number" && !slot.accepts(part.text, offset)) return match;
|
|
6265
6380
|
filled.add(slot);
|
|
6266
6381
|
return slot.replacement(match);
|
|
6267
6382
|
});
|
|
@@ -6352,15 +6467,18 @@ function placeHead(content, texts, owner) {
|
|
|
6352
6467
|
slot: void 0,
|
|
6353
6468
|
diagnostics: []
|
|
6354
6469
|
};
|
|
6355
|
-
const
|
|
6356
|
-
|
|
6357
|
-
|
|
6470
|
+
const scans = texts.map((text) => ({
|
|
6471
|
+
text,
|
|
6472
|
+
tags: scanHeadTags(text)
|
|
6473
|
+
}));
|
|
6474
|
+
if (scans.some(({ tags }) => tags.closingHead !== void 0)) return {
|
|
6475
|
+
slot: beforeTag(CLOSING_HEAD_PREFIX, "closingHead", content),
|
|
6358
6476
|
diagnostics: []
|
|
6359
6477
|
};
|
|
6360
|
-
if (
|
|
6478
|
+
if (scans.some(({ tags }) => tags.bodyStart !== void 0)) {
|
|
6361
6479
|
const message = `${owner} has no closing head tag; the modules and head file were injected before its body start tag.`;
|
|
6362
6480
|
return {
|
|
6363
|
-
slot: beforeTag(
|
|
6481
|
+
slot: beforeTag(BODY_START_PREFIX, "bodyStart", content),
|
|
6364
6482
|
diagnostics: [{
|
|
6365
6483
|
level: "warning",
|
|
6366
6484
|
message
|
|
@@ -6375,12 +6493,108 @@ function placeHead(content, texts, owner) {
|
|
|
6375
6493
|
}]
|
|
6376
6494
|
};
|
|
6377
6495
|
}
|
|
6378
|
-
/**
|
|
6379
|
-
|
|
6496
|
+
/** `</head` in any letter case, followed by whitespace, `/` or `>`; the rest of the tag is left alone. */
|
|
6497
|
+
const CLOSING_HEAD_PREFIX = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])";
|
|
6498
|
+
/** `<body` in any letter case, followed by whitespace, `/` or `>` (so not `<bodyx>`). */
|
|
6499
|
+
const BODY_START_PREFIX = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
|
|
6500
|
+
/** Elements whose content is text, so a tag written there is not a tag. */
|
|
6501
|
+
const RAW_TEXT_ELEMENTS = /* @__PURE__ */ new Set([
|
|
6502
|
+
"script",
|
|
6503
|
+
"style",
|
|
6504
|
+
"textarea",
|
|
6505
|
+
"title"
|
|
6506
|
+
]);
|
|
6507
|
+
const isSpace = (c) => c === " " || c === " " || c === "\n" || c === "\f" || c === "\r";
|
|
6508
|
+
const isLetter = (c) => c !== void 0 && /^[A-Za-z]$/.test(c);
|
|
6509
|
+
/**
|
|
6510
|
+
* Find the first closing head tag and body start tag that HTML would read as tags: not those in a comment, in
|
|
6511
|
+
* the text of a script, style, textarea or title element, or in a quoted attribute value. A small scanner, not
|
|
6512
|
+
* a full HTML tokenizer; it needs no more than the tags and where they end.
|
|
6513
|
+
*/
|
|
6514
|
+
function scanHeadTags(html) {
|
|
6515
|
+
const { closingHead, bodyStart } = scanTags$1(html);
|
|
6516
|
+
return {
|
|
6517
|
+
closingHead,
|
|
6518
|
+
bodyStart
|
|
6519
|
+
};
|
|
6520
|
+
}
|
|
6521
|
+
function scanTags$1(html) {
|
|
6522
|
+
let closingHead;
|
|
6523
|
+
let bodyStart;
|
|
6524
|
+
let headStartEnd;
|
|
6525
|
+
let i = 0;
|
|
6526
|
+
while (i < html.length) {
|
|
6527
|
+
const lt = html.indexOf("<", i);
|
|
6528
|
+
if (lt === -1) break;
|
|
6529
|
+
const next = html[lt + 1];
|
|
6530
|
+
if (html.startsWith("<!--", lt)) {
|
|
6531
|
+
const end = html.indexOf("-->", lt + 4);
|
|
6532
|
+
i = end === -1 ? html.length : end + 3;
|
|
6533
|
+
} else if (next === "!" || next === "?") {
|
|
6534
|
+
const end = html.indexOf(">", lt);
|
|
6535
|
+
i = end === -1 ? html.length : end + 1;
|
|
6536
|
+
} else if (next === "/" && isLetter(html[lt + 2])) {
|
|
6537
|
+
const { name, end } = readTag(html, lt + 2);
|
|
6538
|
+
if (name === "head" && closingHead === void 0) closingHead = lt;
|
|
6539
|
+
i = end;
|
|
6540
|
+
} else if (isLetter(next)) {
|
|
6541
|
+
const { name, end } = readTag(html, lt + 1);
|
|
6542
|
+
if (name === "body" && bodyStart === void 0) bodyStart = lt;
|
|
6543
|
+
if (name === "head" && headStartEnd === void 0) headStartEnd = end;
|
|
6544
|
+
i = RAW_TEXT_ELEMENTS.has(name) ? rawTextEnd(html, name, end) : end;
|
|
6545
|
+
} else i = lt + 1;
|
|
6546
|
+
if (closingHead !== void 0 && bodyStart !== void 0) break;
|
|
6547
|
+
}
|
|
6548
|
+
return {
|
|
6549
|
+
closingHead,
|
|
6550
|
+
bodyStart,
|
|
6551
|
+
headStartEnd
|
|
6552
|
+
};
|
|
6553
|
+
}
|
|
6554
|
+
/** Read a tag whose name starts at `from`: its lowercase name, and the offset after its `>` (or the end). */
|
|
6555
|
+
function readTag(html, from) {
|
|
6556
|
+
let i = from;
|
|
6557
|
+
while (i < html.length && !isSpace(html[i]) && html[i] !== "/" && html[i] !== ">") i++;
|
|
6558
|
+
const name = html.slice(from, i).toLowerCase();
|
|
6559
|
+
while (i < html.length && html[i] !== ">") {
|
|
6560
|
+
if (html[i] !== "=") {
|
|
6561
|
+
i++;
|
|
6562
|
+
continue;
|
|
6563
|
+
}
|
|
6564
|
+
i++;
|
|
6565
|
+
while (isSpace(html[i])) i++;
|
|
6566
|
+
const quote = html[i];
|
|
6567
|
+
if (quote === "\"" || quote === "'") {
|
|
6568
|
+
const close = html.indexOf(quote, i + 1);
|
|
6569
|
+
i = close === -1 ? html.length : close + 1;
|
|
6570
|
+
}
|
|
6571
|
+
}
|
|
6380
6572
|
return {
|
|
6381
|
-
|
|
6573
|
+
name,
|
|
6574
|
+
end: Math.min(i + 1, html.length)
|
|
6575
|
+
};
|
|
6576
|
+
}
|
|
6577
|
+
/** The offset of the end tag that closes a raw text element, given where its text starts (or the end). */
|
|
6578
|
+
function rawTextEnd(html, name, from) {
|
|
6579
|
+
const close = new RegExp(`</${name}(?=[\\t\\n\\f\\r />])`, "gi");
|
|
6580
|
+
close.lastIndex = from;
|
|
6581
|
+
return close.exec(html)?.index ?? html.length;
|
|
6582
|
+
}
|
|
6583
|
+
/**
|
|
6584
|
+
* The slot that inserts `content` on its own line before the first real tag of the kind `which` names (see
|
|
6585
|
+
* `scanHeadTags()`), which is kept as written. A look-alike inside a script or comment is left alone.
|
|
6586
|
+
*/
|
|
6587
|
+
function beforeTag(pattern, which, content) {
|
|
6588
|
+
const cache = /* @__PURE__ */ new Map();
|
|
6589
|
+
const offsetIn = (text) => {
|
|
6590
|
+
if (!cache.has(text)) cache.set(text, scanHeadTags(text)[which]);
|
|
6591
|
+
return cache.get(text);
|
|
6592
|
+
};
|
|
6593
|
+
return {
|
|
6594
|
+
pattern,
|
|
6382
6595
|
occurrences: "first",
|
|
6383
|
-
replacement: (match) => `${content}\n${match}
|
|
6596
|
+
replacement: (match) => `${content}\n${match}`,
|
|
6597
|
+
accepts: (text, offset) => offsetIn(text) === offset
|
|
6384
6598
|
};
|
|
6385
6599
|
}
|
|
6386
6600
|
//#endregion
|
|
@@ -6945,34 +7159,61 @@ async function buildOutput(options, cache, changedFiles, outputs) {
|
|
|
6945
7159
|
const timeout = options.formatFetchTimeout;
|
|
6946
7160
|
if (timeout !== void 0 && !(timeout >= 0)) throw new TweeTsError(`formatFetchTimeout must be 0 or more milliseconds, not ${timeout}.`);
|
|
6947
7161
|
clearIndexCache();
|
|
6948
|
-
const
|
|
6949
|
-
const
|
|
6950
|
-
for (const source of options.sources)
|
|
6951
|
-
|
|
6952
|
-
|
|
6953
|
-
|
|
6954
|
-
|
|
6955
|
-
|
|
7162
|
+
const groups = [];
|
|
7163
|
+
const outputSources = [];
|
|
7164
|
+
for (const source of options.sources) {
|
|
7165
|
+
const last = groups[groups.length - 1];
|
|
7166
|
+
if (typeof source === "string") {
|
|
7167
|
+
if (last?.kind === "paths") last.paths.push(source);
|
|
7168
|
+
else groups.push({
|
|
7169
|
+
kind: "paths",
|
|
7170
|
+
paths: [source]
|
|
7171
|
+
});
|
|
7172
|
+
} else if (last?.kind === "inline") last.sources.push(source);
|
|
7173
|
+
else groups.push({
|
|
7174
|
+
kind: "inline",
|
|
7175
|
+
sources: [source]
|
|
7176
|
+
});
|
|
7177
|
+
}
|
|
7178
|
+
const walked = groups.map((group) => {
|
|
7179
|
+
if (group.kind === "inline") return group;
|
|
7180
|
+
const found = getFilenames(group.paths, written, options.exclude);
|
|
7181
|
+
diagnostics.push(...found.diagnostics);
|
|
7182
|
+
outputSources.push(...found.outputSources);
|
|
7183
|
+
return {
|
|
7184
|
+
kind: "files",
|
|
7185
|
+
filenames: found.filenames
|
|
7186
|
+
};
|
|
7187
|
+
});
|
|
7188
|
+
rejectOutputSources(outputSources, diagnostics);
|
|
7189
|
+
const buildFiles = new Set(walked.flatMap((group) => group.kind === "files" ? group.filenames : []));
|
|
6956
7190
|
const story = createStory();
|
|
6957
7191
|
const processedFiles = /* @__PURE__ */ new Set();
|
|
6958
|
-
if (
|
|
7192
|
+
for (const group of walked) if (group.kind === "inline") loadInlineSources(story, group.sources, {
|
|
6959
7193
|
trim,
|
|
6960
7194
|
twee2Compat
|
|
6961
|
-
}, diagnostics
|
|
6962
|
-
else
|
|
7195
|
+
}, diagnostics);
|
|
7196
|
+
else if (cache) loadSourcesCached(story, group.filenames, {
|
|
7197
|
+
trim,
|
|
7198
|
+
twee2Compat
|
|
7199
|
+
}, diagnostics, processedFiles, cache, changedFiles, buildFiles);
|
|
7200
|
+
else loadSources(story, group.filenames, {
|
|
6963
7201
|
trim,
|
|
6964
7202
|
twee2Compat
|
|
6965
7203
|
}, diagnostics, processedFiles);
|
|
6966
|
-
|
|
7204
|
+
if (cache && buildFiles.size === 0) loadSourcesCached(story, [], {
|
|
6967
7205
|
trim,
|
|
6968
7206
|
twee2Compat
|
|
6969
|
-
}, diagnostics);
|
|
7207
|
+
}, diagnostics, processedFiles, cache, changedFiles);
|
|
6970
7208
|
if (options.tagAliases) story.passages = applyTagAliases(story.passages, options.tagAliases);
|
|
6971
7209
|
let format;
|
|
6972
|
-
if (outputMode === "html")
|
|
6973
|
-
|
|
6974
|
-
|
|
6975
|
-
|
|
7210
|
+
if (outputMode === "html") {
|
|
7211
|
+
format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
|
|
7212
|
+
...options,
|
|
7213
|
+
noRemote
|
|
7214
|
+
}, diagnostics);
|
|
7215
|
+
options.signal?.throwIfAborted();
|
|
7216
|
+
}
|
|
6976
7217
|
if (options.startPassage) story.twine2.start = options.startPassage;
|
|
6977
7218
|
const startName = story.twine2.start || DEFAULT_START_NAME;
|
|
6978
7219
|
if (testMode) story.twine2.options.set("debug", true);
|
|
@@ -7024,6 +7265,7 @@ async function buildOutput(options, cache, changedFiles, outputs) {
|
|
|
7024
7265
|
...getStoryStats(story, options.wordCountMethod),
|
|
7025
7266
|
files: [...processedFiles]
|
|
7026
7267
|
};
|
|
7268
|
+
options.signal?.throwIfAborted();
|
|
7027
7269
|
return {
|
|
7028
7270
|
output,
|
|
7029
7271
|
story,
|
|
@@ -8778,22 +9020,13 @@ const foldKey = (key) => key.toLowerCase().replace(/[-_]/g, "");
|
|
|
8778
9020
|
*
|
|
8779
9021
|
* @param diagnostics Receives warnings that do not stop the config from loading: keys the config does not
|
|
8780
9022
|
* 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}.
|
|
9023
|
+
* @throws When the file exists but cannot be read, is not valid JSON or fails {@link validateConfig}.
|
|
8782
9024
|
*/
|
|
8783
9025
|
function loadConfig(dir, diagnostics) {
|
|
8784
9026
|
const base = dir ?? process.cwd();
|
|
8785
9027
|
const configPath = join(base, CONFIG_FILENAME);
|
|
8786
9028
|
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;
|
|
9029
|
+
return loadConfigFile(configPath, diagnostics);
|
|
8797
9030
|
}
|
|
8798
9031
|
/**
|
|
8799
9032
|
* Load a config from a specific file path.
|