@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import * as nodePath from "node:path";
|
|
3
3
|
import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
@@ -1322,10 +1322,9 @@ function readUTF8(filename, diagnostics) {
|
|
|
1322
1322
|
function readBase64(filename) {
|
|
1323
1323
|
return readFileSync(filename).toString("base64");
|
|
1324
1324
|
}
|
|
1325
|
-
/** Get the filename without extension,
|
|
1325
|
+
/** Get the filename without extension, a dotfile keeps its whole name. */
|
|
1326
1326
|
function baseNameWithoutExt(filename) {
|
|
1327
|
-
|
|
1328
|
-
return name || ext;
|
|
1327
|
+
return parse(filename).name;
|
|
1329
1328
|
}
|
|
1330
1329
|
//#endregion
|
|
1331
1330
|
//#region src/formats.ts
|
|
@@ -1345,6 +1344,89 @@ function stripSetupFunction(chunk) {
|
|
|
1345
1344
|
for (const match of chunk.matchAll(SETUP_FUNCTION_PROPERTY)) lastIndex = match.index;
|
|
1346
1345
|
return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
|
|
1347
1346
|
}
|
|
1347
|
+
/** Index after the whitespace and `//` or block comments starting at `i`. */
|
|
1348
|
+
function skipTrivia(text, i) {
|
|
1349
|
+
let pos = i;
|
|
1350
|
+
for (;;) {
|
|
1351
|
+
const ch = text[pos];
|
|
1352
|
+
if (ch !== void 0 && /\s/.test(ch)) pos++;
|
|
1353
|
+
else if (text.startsWith("//", pos)) {
|
|
1354
|
+
const newline = text.indexOf("\n", pos);
|
|
1355
|
+
pos = newline === -1 ? text.length : newline + 1;
|
|
1356
|
+
} else if (text.startsWith("/*", pos)) {
|
|
1357
|
+
const close = text.indexOf("*/", pos + 2);
|
|
1358
|
+
pos = close === -1 ? text.length : close + 2;
|
|
1359
|
+
} else return pos;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
/** Index after the quoted string starting at `i` (a quote character); template literals nest `${…}`. */
|
|
1363
|
+
function skipString(text, i) {
|
|
1364
|
+
const quote = text[i];
|
|
1365
|
+
let pos = i + 1;
|
|
1366
|
+
while (pos < text.length) {
|
|
1367
|
+
const ch = text[pos];
|
|
1368
|
+
if (ch === "\\") pos += 2;
|
|
1369
|
+
else if (ch === quote) return pos + 1;
|
|
1370
|
+
else if (quote === "`" && ch === "$" && text[pos + 1] === "{") {
|
|
1371
|
+
const close = findClosingBrace(text, pos + 1);
|
|
1372
|
+
if (close === -1) return text.length;
|
|
1373
|
+
pos = close + 1;
|
|
1374
|
+
} else pos++;
|
|
1375
|
+
}
|
|
1376
|
+
return text.length;
|
|
1377
|
+
}
|
|
1378
|
+
function startsComment(text, pos) {
|
|
1379
|
+
return text[pos] === "/" && (text[pos + 1] === "/" || text[pos + 1] === "*");
|
|
1380
|
+
}
|
|
1381
|
+
function startsString(text, pos) {
|
|
1382
|
+
const ch = text[pos];
|
|
1383
|
+
return ch === "\"" || ch === "'" || ch === "`";
|
|
1384
|
+
}
|
|
1385
|
+
/** Index of the `}` matching the `{` at `open`, skipping comments and strings; -1 when unbalanced. */
|
|
1386
|
+
function findClosingBrace(text, open) {
|
|
1387
|
+
let depth = 0;
|
|
1388
|
+
let pos = open;
|
|
1389
|
+
while (pos < text.length) if (startsComment(text, pos)) pos = skipTrivia(text, pos);
|
|
1390
|
+
else if (startsString(text, pos)) pos = skipString(text, pos);
|
|
1391
|
+
else {
|
|
1392
|
+
if (text[pos] === "{") depth++;
|
|
1393
|
+
else if (text[pos] === "}" && --depth === 0) return pos;
|
|
1394
|
+
pos++;
|
|
1395
|
+
}
|
|
1396
|
+
return -1;
|
|
1397
|
+
}
|
|
1398
|
+
const STORY_FORMAT = "storyFormat";
|
|
1399
|
+
const IDENTIFIER_CHAR = /[\w$]/;
|
|
1400
|
+
/**
|
|
1401
|
+
* Locate the object literal passed to `storyFormat(` as `[start, end)` offsets, ignoring comments and
|
|
1402
|
+
* strings outside it and never evaluating anything. Without such a call, the first `{` outside comments
|
|
1403
|
+
* and strings starts the object. When its closing brace cannot be matched (a Harlowe setup function can
|
|
1404
|
+
* hold regular expression literals), the last `}` of the file ends it.
|
|
1405
|
+
*/
|
|
1406
|
+
function locateFormatObject(text) {
|
|
1407
|
+
let start = -1;
|
|
1408
|
+
let firstBrace = -1;
|
|
1409
|
+
let pos = 0;
|
|
1410
|
+
while (pos < text.length && start === -1) if (startsComment(text, pos)) pos = skipTrivia(text, pos);
|
|
1411
|
+
else if (startsString(text, pos)) pos = skipString(text, pos);
|
|
1412
|
+
else if (text[pos] === "{") {
|
|
1413
|
+
if (firstBrace === -1) firstBrace = pos;
|
|
1414
|
+
pos++;
|
|
1415
|
+
} else if (text.startsWith(STORY_FORMAT, pos) && !IDENTIFIER_CHAR.test(text[pos - 1] ?? "")) {
|
|
1416
|
+
const paren = skipTrivia(text, pos + 11);
|
|
1417
|
+
const brace = text[paren] === "(" ? skipTrivia(text, paren + 1) : -1;
|
|
1418
|
+
if (text[brace] === "{") start = brace;
|
|
1419
|
+
pos += 11;
|
|
1420
|
+
} else pos++;
|
|
1421
|
+
if (start === -1) start = firstBrace;
|
|
1422
|
+
if (start === -1) return void 0;
|
|
1423
|
+
const close = findClosingBrace(text, start);
|
|
1424
|
+
const end = close === -1 ? text.lastIndexOf("}") : close;
|
|
1425
|
+
return end < start ? void 0 : {
|
|
1426
|
+
start,
|
|
1427
|
+
end: end + 1
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1348
1430
|
/**
|
|
1349
1431
|
* Parse the format object at `start`–`end` of `text`: strict JSON first (fast), then the
|
|
1350
1432
|
* JavaScript literal subset formats use. Error positions point into `text`.
|
|
@@ -1369,6 +1451,8 @@ function parseFormatObject(text, start, end) {
|
|
|
1369
1451
|
}
|
|
1370
1452
|
}
|
|
1371
1453
|
/** Check a parsed format object's fields. Per spec, `name` is optional; `version` and `source` are required. */
|
|
1454
|
+
/** The name given to a format.js that names no format. */
|
|
1455
|
+
const UNNAMED_FORMAT_NAME = "Untitled Story Format";
|
|
1372
1456
|
function toFormatJSON(raw) {
|
|
1373
1457
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
|
|
1374
1458
|
ok: false,
|
|
@@ -1388,7 +1472,7 @@ function toFormatJSON(raw) {
|
|
|
1388
1472
|
reason: `Story format version ${JSON.stringify(obj.version)} is not a SemVer version.`
|
|
1389
1473
|
};
|
|
1390
1474
|
const data = {
|
|
1391
|
-
name: typeof obj.name === "string" ? obj.name :
|
|
1475
|
+
name: typeof obj.name === "string" ? obj.name : UNNAMED_FORMAT_NAME,
|
|
1392
1476
|
version: obj.version,
|
|
1393
1477
|
source: obj.source,
|
|
1394
1478
|
proofing: obj.proofing === true
|
|
@@ -1413,15 +1497,14 @@ function toFormatJSON(raw) {
|
|
|
1413
1497
|
* `harlowe-3` directory or a direct download.
|
|
1414
1498
|
*/
|
|
1415
1499
|
function decodeFormatJSON(source) {
|
|
1416
|
-
const
|
|
1417
|
-
|
|
1418
|
-
if (first === -1 || last < first) return {
|
|
1500
|
+
const located = locateFormatObject(source);
|
|
1501
|
+
if (located === void 0) return {
|
|
1419
1502
|
ok: false,
|
|
1420
1503
|
reason: "Could not find Twine 2 style story format JSON chunk."
|
|
1421
1504
|
};
|
|
1422
|
-
const parsed = parseFormatObject(source,
|
|
1505
|
+
const parsed = parseFormatObject(source, located.start, located.end);
|
|
1423
1506
|
if (parsed.ok) return toFormatJSON(parsed.value);
|
|
1424
|
-
const stripped = stripSetupFunction(source.slice(
|
|
1507
|
+
const stripped = stripSetupFunction(source.slice(located.start, located.end));
|
|
1425
1508
|
const retried = stripped === void 0 ? void 0 : parseFormatObject(stripped, 0, stripped.length);
|
|
1426
1509
|
if (retried?.ok) return toFormatJSON(retried.value);
|
|
1427
1510
|
return {
|
|
@@ -1708,16 +1791,17 @@ function readFormatSource(format, diagnostics) {
|
|
|
1708
1791
|
/**
|
|
1709
1792
|
* Writes `data` to `path` so that a reader sees either the previous file or the complete new
|
|
1710
1793
|
* one, never a partly written file. The data goes to a temporary file in the same folder,
|
|
1711
|
-
* which is then renamed over `path`. When `path` is a symlink, the file it
|
|
1712
|
-
* replaced, and an existing file keeps its permissions.
|
|
1794
|
+
* which is then renamed over `path`. When `path` is a symlink (even a dangling one, or a chain of links), the file it
|
|
1795
|
+
* finally points to is replaced or created and the link stays, and an existing file keeps its permissions.
|
|
1713
1796
|
*
|
|
1714
1797
|
* On failure (a full disk, a missing folder) the temporary file is removed, `path` is left
|
|
1715
1798
|
* as it was, and the error is thrown with `path` in its message and its original `code`.
|
|
1716
1799
|
*/
|
|
1717
1800
|
function writeFileAtomic(path, data) {
|
|
1718
|
-
|
|
1719
|
-
const temp = join(dirname(target), `.${basename(target)}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
|
|
1801
|
+
let temp;
|
|
1720
1802
|
try {
|
|
1803
|
+
const target = resolveWriteTarget(path);
|
|
1804
|
+
temp = join(dirname(target), `.${basename(target)}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
|
|
1721
1805
|
writeFileSync(temp, data, {
|
|
1722
1806
|
encoding: "utf-8",
|
|
1723
1807
|
flag: "wx"
|
|
@@ -1725,17 +1809,25 @@ function writeFileAtomic(path, data) {
|
|
|
1725
1809
|
keepPermissions(target, temp);
|
|
1726
1810
|
renameSync(temp, target);
|
|
1727
1811
|
} catch (e) {
|
|
1728
|
-
rmSync(temp, { force: true });
|
|
1812
|
+
if (temp !== void 0) rmSync(temp, { force: true });
|
|
1729
1813
|
throw writeError(path, e);
|
|
1730
1814
|
}
|
|
1731
1815
|
}
|
|
1732
|
-
/**
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1816
|
+
/** Links followed before a path counts as a cycle (Linux stops at 40 as well). */
|
|
1817
|
+
const MAX_LINK_DEPTH = 40;
|
|
1818
|
+
/**
|
|
1819
|
+
* The file a symlink at `path` finally points to, or `path` itself when it is no link. Links are
|
|
1820
|
+
* followed one by one, so a link whose target does not exist yet (absolute, relative, or a chain
|
|
1821
|
+
* of them) still resolves to the file that should be created. A cycle or an unreadable link throws.
|
|
1822
|
+
*/
|
|
1823
|
+
function resolveWriteTarget(path) {
|
|
1824
|
+
let current = path;
|
|
1825
|
+
for (let depth = 0; depth <= MAX_LINK_DEPTH; depth++) {
|
|
1826
|
+
const stat = lstatSync(current, { throwIfNoEntry: false });
|
|
1827
|
+
if (stat === void 0 || !stat.isSymbolicLink()) return current;
|
|
1828
|
+
current = resolve(dirname(current), readlinkSync(current));
|
|
1738
1829
|
}
|
|
1830
|
+
throw Object.assign(/* @__PURE__ */ new Error(`ELOOP: too many levels of symbolic links, ${path}`), { code: "ELOOP" });
|
|
1739
1831
|
}
|
|
1740
1832
|
/**
|
|
1741
1833
|
* Gives `temp` the permission bits of `target`, when `target` exists. Best effort: on a file
|
|
@@ -1847,31 +1939,37 @@ function clearIndexCache() {
|
|
|
1847
1939
|
/** Requests in progress, by what they fetch, so concurrent compiles make each request once. */
|
|
1848
1940
|
const sharedRequests = /* @__PURE__ */ new Map();
|
|
1849
1941
|
/**
|
|
1850
|
-
* Runs `start` once for all callers that ask for `key` while it is in progress.
|
|
1851
|
-
*
|
|
1852
|
-
*
|
|
1942
|
+
* Runs `start` once for all callers that ask for `key` while it is in progress. Each caller waits
|
|
1943
|
+
* under its own limits: a caller whose `signal` aborts, or whose `timeout` passes, stops waiting
|
|
1944
|
+
* at once and rejects, while the others keep waiting. The request itself is aborted (through
|
|
1945
|
+
* the signal `start` receives) once no caller waits on it. `start` must not depend on any one
|
|
1946
|
+
* caller: what differs between callers belongs after the shared result.
|
|
1853
1947
|
*/
|
|
1854
|
-
function shareRequest(key, signal, start) {
|
|
1948
|
+
function shareRequest(key, { signal, timeout, timedOut }, start) {
|
|
1855
1949
|
if (signal?.aborted) return Promise.reject(signal.reason);
|
|
1856
1950
|
const request = sharedRequests.get(key) ?? startSharedRequest(key, start);
|
|
1857
1951
|
request.waiters++;
|
|
1858
1952
|
return new Promise((resolve, reject) => {
|
|
1859
1953
|
let waiting = true;
|
|
1954
|
+
let timer;
|
|
1860
1955
|
const stopWaiting = () => {
|
|
1861
1956
|
if (!waiting) return;
|
|
1862
1957
|
waiting = false;
|
|
1863
1958
|
signal?.removeEventListener("abort", onAbort);
|
|
1959
|
+
clearTimeout(timer);
|
|
1864
1960
|
request.waiters--;
|
|
1865
1961
|
};
|
|
1866
|
-
const
|
|
1962
|
+
const giveUp = (reason) => {
|
|
1867
1963
|
stopWaiting();
|
|
1868
1964
|
if (request.waiters === 0) {
|
|
1869
1965
|
if (sharedRequests.get(key) === request) sharedRequests.delete(key);
|
|
1870
|
-
request.controller.abort(
|
|
1966
|
+
request.controller.abort(reason);
|
|
1871
1967
|
}
|
|
1872
|
-
reject(
|
|
1968
|
+
reject(reason);
|
|
1873
1969
|
};
|
|
1970
|
+
const onAbort = () => giveUp(signal?.reason);
|
|
1874
1971
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1972
|
+
if (timeout > 0 && timeout <= MAX_TIMER_DELAY) timer = setTimeout(() => giveUp(timedOut()), timeout);
|
|
1875
1973
|
request.promise.then((value) => {
|
|
1876
1974
|
stopWaiting();
|
|
1877
1975
|
resolve(value);
|
|
@@ -1901,20 +1999,32 @@ function requestTimeout(options) {
|
|
|
1901
1999
|
if (!(timeout >= 0)) throw new RangeError(`A story format request timeout must be 0 or more milliseconds, not ${timeout}`);
|
|
1902
2000
|
return timeout;
|
|
1903
2001
|
}
|
|
2002
|
+
/** How one caller waits for `url`: under its own signal and timeout. `what` names the request in errors. */
|
|
2003
|
+
function waitOptions(options, what, url) {
|
|
2004
|
+
const timeout = requestTimeout(options);
|
|
2005
|
+
return {
|
|
2006
|
+
signal: options.signal,
|
|
2007
|
+
timeout,
|
|
2008
|
+
timedOut: () => /* @__PURE__ */ new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`)
|
|
2009
|
+
};
|
|
2010
|
+
}
|
|
1904
2011
|
/**
|
|
1905
|
-
* Fetch `url` as
|
|
1906
|
-
*
|
|
2012
|
+
* Fetch `url` as bytes, aborting when `signal` does. `what` names the request in errors, as in
|
|
2013
|
+
* "Failed to <what> from <url>". The bytes are returned undecoded, so checksums cover what was served.
|
|
1907
2014
|
*/
|
|
1908
|
-
async function
|
|
1909
|
-
const
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
2015
|
+
async function fetchBytes(url, what, signal) {
|
|
2016
|
+
const res = await fetch(url, { signal });
|
|
2017
|
+
if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
|
|
2018
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
2019
|
+
}
|
|
2020
|
+
/** Download a format.js once for every caller in this process that asks for `url` meanwhile. */
|
|
2021
|
+
function shareDownload(url, options) {
|
|
2022
|
+
return shareRequest(`download\0${url}`, waitOptions(options, "download format", url), (signal) => fetchBytes(url, "download format", signal));
|
|
2023
|
+
}
|
|
2024
|
+
/** Decode a downloaded format.js as local ones are (UTF-8, else Windows-1252), without a leading BOM. */
|
|
2025
|
+
function decodeDownload(bytes, url) {
|
|
2026
|
+
const { text } = decodeText(bytes, url);
|
|
2027
|
+
return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
|
|
1918
2028
|
}
|
|
1919
2029
|
/** Validate that a JSON value is a valid SFAIndexEntry. */
|
|
1920
2030
|
function isValidEntry(val) {
|
|
@@ -1936,9 +2046,8 @@ async function fetchIndex(url, options = {}) {
|
|
|
1936
2046
|
options.signal?.throwIfAborted();
|
|
1937
2047
|
const cached = indexCache.get(url);
|
|
1938
2048
|
if (cached) return cached;
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
const text = await fetchText(url, "fetch format index", signal, timeout);
|
|
2049
|
+
return shareRequest(`index\0${url}`, waitOptions(options, "fetch format index", url), async (signal) => {
|
|
2050
|
+
const text = decodeDownload(await fetchBytes(url, "fetch format index", signal), url);
|
|
1942
2051
|
const data = validateSFAIndex(JSON.parse(text));
|
|
1943
2052
|
indexCache.set(url, data);
|
|
1944
2053
|
return data;
|
|
@@ -1975,26 +2084,37 @@ function cachedFormatInfo(id, filename, data) {
|
|
|
1975
2084
|
};
|
|
1976
2085
|
}
|
|
1977
2086
|
/**
|
|
1978
|
-
*
|
|
1979
|
-
*
|
|
2087
|
+
* Check that a downloaded format is the one the index entry promised. The name matches without
|
|
2088
|
+
* regard to case, as format requests do, and the version by SemVer precedence, so "1.0" and "v1.0.0"
|
|
2089
|
+
* agree. A format.js that names no format ({@link UNNAMED_FORMAT_NAME}) is allowed: such formats
|
|
2090
|
+
* exist, and the index entry then supplies the name it is cached under.
|
|
2091
|
+
*/
|
|
2092
|
+
function checkFormatIdentity(entry, data, downloadUrl) {
|
|
2093
|
+
if ((data.name === "Untitled Story Format" || data.name.toLowerCase() === entry.name.toLowerCase()) && sameVersion(data.version, entry.version)) return;
|
|
2094
|
+
throw new Error(`Story format mismatch for ${downloadUrl}: the index lists ${entry.name} ${entry.version}, but the download is ${data.name} ${data.version}`);
|
|
2095
|
+
}
|
|
2096
|
+
/**
|
|
2097
|
+
* Download a format, verify its checksum, check that it is the format the entry names, write it to
|
|
2098
|
+
* the cache shared by name and version, and return its StoryFormatInfo. Concurrent calls for one
|
|
2099
|
+
* download URL share one request for the bytes; each call then verifies them against its own
|
|
2100
|
+
* entry, within its own timeout.
|
|
1980
2101
|
*/
|
|
1981
2102
|
async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
|
|
1982
2103
|
cachedFormatDir(entry.name, entry.version);
|
|
1983
|
-
const
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
const
|
|
1987
|
-
if (
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
});
|
|
2104
|
+
const bytes = await shareDownload(downloadUrl, options);
|
|
2105
|
+
const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
|
|
2106
|
+
if (checksumKey) {
|
|
2107
|
+
const expected = entry.checksums[checksumKey];
|
|
2108
|
+
if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
|
|
2109
|
+
if (!await verifySHA256(bytes, expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
|
|
2110
|
+
}
|
|
2111
|
+
const text = decodeDownload(bytes, downloadUrl);
|
|
2112
|
+
const id = makeFormatId(entry.name, entry.version);
|
|
2113
|
+
const data = parseFormatJSON(text, id);
|
|
2114
|
+
if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
|
|
2115
|
+
checkFormatIdentity(entry, data, downloadUrl);
|
|
2116
|
+
options.signal?.throwIfAborted();
|
|
2117
|
+
return cachedFormatInfo(id, writeCacheEntry(resolve(getCacheDir()), [entry.name, entry.version], text), data);
|
|
1998
2118
|
}
|
|
1999
2119
|
/**
|
|
2000
2120
|
* Download a direct format.js URL, parse its JSON, cache it under that URL (apart from the
|
|
@@ -2002,16 +2122,13 @@ async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
|
|
|
2002
2122
|
* one URL share one request.
|
|
2003
2123
|
*/
|
|
2004
2124
|
async function fetchDirectFormat(url, options = {}) {
|
|
2005
|
-
const
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
|
|
2013
|
-
return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
|
|
2014
|
-
});
|
|
2125
|
+
const text = decodeDownload(await shareDownload(url, options), url);
|
|
2126
|
+
const data = parseFormatJSON(text);
|
|
2127
|
+
if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
|
|
2128
|
+
cachedFormatDir(data.name, data.version);
|
|
2129
|
+
options.signal?.throwIfAborted();
|
|
2130
|
+
const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
|
|
2131
|
+
return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
|
|
2015
2132
|
}
|
|
2016
2133
|
/** The copy of a direct format URL in the download cache, if it has been downloaded before. */
|
|
2017
2134
|
function getCachedDirectFormat(url) {
|
|
@@ -2052,6 +2169,16 @@ async function resolveFormatUrls(request, urls, options = {}) {
|
|
|
2052
2169
|
}
|
|
2053
2170
|
if (lastError) throw lastError;
|
|
2054
2171
|
}
|
|
2172
|
+
/**
|
|
2173
|
+
* Find the best format among the cached copies of direct format URLs, without network access.
|
|
2174
|
+
* Matching follows {@link selectFormatCandidate}, so `allowOlder` admits a same-major older version.
|
|
2175
|
+
* Each URL keeps its own copy; the shared name and version downloads are never consulted.
|
|
2176
|
+
*
|
|
2177
|
+
* Internal, for format resolution; not part of the public API.
|
|
2178
|
+
*/
|
|
2179
|
+
function findCachedUrlFormat(request, urls, options = {}) {
|
|
2180
|
+
return selectFormatCandidate(request, urls.flatMap((url) => getCachedDirectFormat(url) ?? []), (f) => f, options);
|
|
2181
|
+
}
|
|
2055
2182
|
/** Whether `info` is a format the request accepts. */
|
|
2056
2183
|
function answers(request, info) {
|
|
2057
2184
|
return selectFormatCandidate(request, [info], (f) => f) !== void 0;
|
|
@@ -2187,8 +2314,8 @@ function findLocalFormat(all, pruned, request) {
|
|
|
2187
2314
|
* then format indices (unless `noRemote`). Names and IDs match the same way in each (see
|
|
2188
2315
|
* `selectFormatCandidate`), so a request that a local format answers never reaches the cache or the
|
|
2189
2316
|
* network. Local formats that cannot be used are reported as warnings. When a StoryData request
|
|
2190
|
-
* names a version that none of them has, an older version of
|
|
2191
|
-
*
|
|
2317
|
+
* names a version that none of them has, an older version of the same format and major version
|
|
2318
|
+
* is used with a warning, from local formats, the project's cached format URLs, or the shared cache. Anything else is an error diagnostic,
|
|
2192
2319
|
* and the result is `undefined`.
|
|
2193
2320
|
*/
|
|
2194
2321
|
async function resolveStoryFormat(request, options, diagnostics) {
|
|
@@ -2226,7 +2353,7 @@ async function resolveStoryFormat(request, options, diagnostics) {
|
|
|
2226
2353
|
if (remote) return remote;
|
|
2227
2354
|
}
|
|
2228
2355
|
if (request.kind === "name") {
|
|
2229
|
-
const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
|
|
2356
|
+
const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedUrlFormat(request, options.formatUrls ?? [], { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
|
|
2230
2357
|
if (older) {
|
|
2231
2358
|
diagnostics.push({
|
|
2232
2359
|
level: "warning",
|
|
@@ -2475,8 +2602,6 @@ function* tweeLexer(input) {
|
|
|
2475
2602
|
const items = ctx.drainItems();
|
|
2476
2603
|
for (const item of items) yield item;
|
|
2477
2604
|
}
|
|
2478
|
-
const remaining = ctx.drainItems();
|
|
2479
|
-
for (const item of remaining) yield item;
|
|
2480
2605
|
}
|
|
2481
2606
|
//#endregion
|
|
2482
2607
|
//#region src/twee2-compat.ts
|
|
@@ -2603,9 +2728,7 @@ function parseTwee(source, options = {}) {
|
|
|
2603
2728
|
};
|
|
2604
2729
|
}
|
|
2605
2730
|
try {
|
|
2606
|
-
const
|
|
2607
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error("expected a JSON object");
|
|
2608
|
-
const parsed = raw;
|
|
2731
|
+
const parsed = JSON.parse(item.val);
|
|
2609
2732
|
const meta = {};
|
|
2610
2733
|
for (const [key, value] of Object.entries(parsed)) if (typeof value === "string") meta[key] = value;
|
|
2611
2734
|
current.metadata = meta;
|
|
@@ -5850,8 +5973,8 @@ function loadFont(story, filename, diagnostics) {
|
|
|
5850
5973
|
* Load sources with mtime-based caching for incremental rebuilds.
|
|
5851
5974
|
* Always creates a fresh Story; cached passages are replayed via storyAdd().
|
|
5852
5975
|
*/
|
|
5853
|
-
function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
|
|
5854
|
-
const currentFiles =
|
|
5976
|
+
function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles, buildFiles) {
|
|
5977
|
+
const currentFiles = new Set(buildFiles);
|
|
5855
5978
|
const changedPaths = changedFiles === void 0 ? void 0 : new Set([...changedFiles].map((f) => resolve(f)));
|
|
5856
5979
|
for (const filename of filenames) {
|
|
5857
5980
|
if (processedFiles.has(filename)) {
|
|
@@ -5916,16 +6039,6 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5916
6039
|
}
|
|
5917
6040
|
//#endregion
|
|
5918
6041
|
//#region src/template.ts
|
|
5919
|
-
/**
|
|
5920
|
-
* An HTML closing head tag: `</head` in any letter case, followed by whitespace, `/` or `>`, up to the next `>`.
|
|
5921
|
-
* This is how the HTML tokenizer reads an end tag; it ignores whatever sits between the tag name and the `>`.
|
|
5922
|
-
*/
|
|
5923
|
-
const CLOSING_HEAD_TAG = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>";
|
|
5924
|
-
/**
|
|
5925
|
-
* The start of an HTML body start tag: `<body` in any letter case, followed by whitespace, `/` or `>` (so not
|
|
5926
|
-
* `<bodyx>`). Only the `<body` is matched; the attributes and `>` after it are left alone.
|
|
5927
|
-
*/
|
|
5928
|
-
const BODY_START_TAG = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
|
|
5929
6042
|
/** The regular expression source matching exactly `text`. */
|
|
5930
6043
|
function literal(text) {
|
|
5931
6044
|
return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
@@ -5951,6 +6064,8 @@ function fillTemplateParts(parts) {
|
|
|
5951
6064
|
return part.text.replace(pattern, (match, ...groups) => {
|
|
5952
6065
|
const slot = slots.find((_, i) => groups[i] !== void 0);
|
|
5953
6066
|
if (slot === void 0 || slot.occurrences === "first" && filled.has(slot)) return match;
|
|
6067
|
+
const offset = groups[slots.length];
|
|
6068
|
+
if (slot.accepts !== void 0 && typeof offset === "number" && !slot.accepts(part.text, offset)) return match;
|
|
5954
6069
|
filled.add(slot);
|
|
5955
6070
|
return slot.replacement(match);
|
|
5956
6071
|
});
|
|
@@ -6041,15 +6156,18 @@ function placeHead(content, texts, owner) {
|
|
|
6041
6156
|
slot: void 0,
|
|
6042
6157
|
diagnostics: []
|
|
6043
6158
|
};
|
|
6044
|
-
const
|
|
6045
|
-
|
|
6046
|
-
|
|
6159
|
+
const scans = texts.map((text) => ({
|
|
6160
|
+
text,
|
|
6161
|
+
tags: scanHeadTags(text)
|
|
6162
|
+
}));
|
|
6163
|
+
if (scans.some(({ tags }) => tags.closingHead !== void 0)) return {
|
|
6164
|
+
slot: beforeTag(CLOSING_HEAD_PREFIX, "closingHead", content),
|
|
6047
6165
|
diagnostics: []
|
|
6048
6166
|
};
|
|
6049
|
-
if (
|
|
6167
|
+
if (scans.some(({ tags }) => tags.bodyStart !== void 0)) {
|
|
6050
6168
|
const message = `${owner} has no closing head tag; the modules and head file were injected before its body start tag.`;
|
|
6051
6169
|
return {
|
|
6052
|
-
slot: beforeTag(
|
|
6170
|
+
slot: beforeTag(BODY_START_PREFIX, "bodyStart", content),
|
|
6053
6171
|
diagnostics: [{
|
|
6054
6172
|
level: "warning",
|
|
6055
6173
|
message
|
|
@@ -6064,12 +6182,115 @@ function placeHead(content, texts, owner) {
|
|
|
6064
6182
|
}]
|
|
6065
6183
|
};
|
|
6066
6184
|
}
|
|
6067
|
-
/**
|
|
6068
|
-
|
|
6185
|
+
/** `</head` in any letter case, followed by whitespace, `/` or `>`; the rest of the tag is left alone. */
|
|
6186
|
+
const CLOSING_HEAD_PREFIX = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])";
|
|
6187
|
+
/** `<body` in any letter case, followed by whitespace, `/` or `>` (so not `<bodyx>`). */
|
|
6188
|
+
const BODY_START_PREFIX = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
|
|
6189
|
+
/** Elements whose content is text, so a tag written there is not a tag. */
|
|
6190
|
+
const RAW_TEXT_ELEMENTS = /* @__PURE__ */ new Set([
|
|
6191
|
+
"script",
|
|
6192
|
+
"style",
|
|
6193
|
+
"textarea",
|
|
6194
|
+
"title"
|
|
6195
|
+
]);
|
|
6196
|
+
const isSpace = (c) => c === " " || c === " " || c === "\n" || c === "\f" || c === "\r";
|
|
6197
|
+
const isLetter = (c) => c !== void 0 && /^[A-Za-z]$/.test(c);
|
|
6198
|
+
/**
|
|
6199
|
+
* Find the first closing head tag and body start tag that HTML would read as tags: not those in a comment, in
|
|
6200
|
+
* the text of a script, style, textarea or title element, or in a quoted attribute value. A small scanner, not
|
|
6201
|
+
* a full HTML tokenizer; it needs no more than the tags and where they end.
|
|
6202
|
+
*/
|
|
6203
|
+
function scanHeadTags(html) {
|
|
6204
|
+
const { closingHead, bodyStart } = scanTags(html);
|
|
6069
6205
|
return {
|
|
6070
|
-
|
|
6206
|
+
closingHead,
|
|
6207
|
+
bodyStart
|
|
6208
|
+
};
|
|
6209
|
+
}
|
|
6210
|
+
/**
|
|
6211
|
+
* The offset just after the first real head start tag, where content goes to come first in the head; found like
|
|
6212
|
+
* the tags of `scanHeadTags()`, so not one in a comment, a script or an attribute value. `undefined` without one.
|
|
6213
|
+
*/
|
|
6214
|
+
function findHeadStartEnd(html) {
|
|
6215
|
+
return scanTags(html).headStartEnd;
|
|
6216
|
+
}
|
|
6217
|
+
function scanTags(html) {
|
|
6218
|
+
let closingHead;
|
|
6219
|
+
let bodyStart;
|
|
6220
|
+
let headStartEnd;
|
|
6221
|
+
let i = 0;
|
|
6222
|
+
while (i < html.length) {
|
|
6223
|
+
const lt = html.indexOf("<", i);
|
|
6224
|
+
if (lt === -1) break;
|
|
6225
|
+
const next = html[lt + 1];
|
|
6226
|
+
if (html.startsWith("<!--", lt)) {
|
|
6227
|
+
const end = html.indexOf("-->", lt + 4);
|
|
6228
|
+
i = end === -1 ? html.length : end + 3;
|
|
6229
|
+
} else if (next === "!" || next === "?") {
|
|
6230
|
+
const end = html.indexOf(">", lt);
|
|
6231
|
+
i = end === -1 ? html.length : end + 1;
|
|
6232
|
+
} else if (next === "/" && isLetter(html[lt + 2])) {
|
|
6233
|
+
const { name, end } = readTag(html, lt + 2);
|
|
6234
|
+
if (name === "head" && closingHead === void 0) closingHead = lt;
|
|
6235
|
+
i = end;
|
|
6236
|
+
} else if (isLetter(next)) {
|
|
6237
|
+
const { name, end } = readTag(html, lt + 1);
|
|
6238
|
+
if (name === "body" && bodyStart === void 0) bodyStart = lt;
|
|
6239
|
+
if (name === "head" && headStartEnd === void 0) headStartEnd = end;
|
|
6240
|
+
i = RAW_TEXT_ELEMENTS.has(name) ? rawTextEnd(html, name, end) : end;
|
|
6241
|
+
} else i = lt + 1;
|
|
6242
|
+
if (closingHead !== void 0 && bodyStart !== void 0) break;
|
|
6243
|
+
}
|
|
6244
|
+
return {
|
|
6245
|
+
closingHead,
|
|
6246
|
+
bodyStart,
|
|
6247
|
+
headStartEnd
|
|
6248
|
+
};
|
|
6249
|
+
}
|
|
6250
|
+
/** Read a tag whose name starts at `from`: its lowercase name, and the offset after its `>` (or the end). */
|
|
6251
|
+
function readTag(html, from) {
|
|
6252
|
+
let i = from;
|
|
6253
|
+
while (i < html.length && !isSpace(html[i]) && html[i] !== "/" && html[i] !== ">") i++;
|
|
6254
|
+
const name = html.slice(from, i).toLowerCase();
|
|
6255
|
+
while (i < html.length && html[i] !== ">") {
|
|
6256
|
+
if (html[i] !== "=") {
|
|
6257
|
+
i++;
|
|
6258
|
+
continue;
|
|
6259
|
+
}
|
|
6260
|
+
i++;
|
|
6261
|
+
while (isSpace(html[i])) i++;
|
|
6262
|
+
const quote = html[i];
|
|
6263
|
+
if (quote === "\"" || quote === "'") {
|
|
6264
|
+
const close = html.indexOf(quote, i + 1);
|
|
6265
|
+
i = close === -1 ? html.length : close + 1;
|
|
6266
|
+
}
|
|
6267
|
+
}
|
|
6268
|
+
return {
|
|
6269
|
+
name,
|
|
6270
|
+
end: Math.min(i + 1, html.length)
|
|
6271
|
+
};
|
|
6272
|
+
}
|
|
6273
|
+
/** The offset of the end tag that closes a raw text element, given where its text starts (or the end). */
|
|
6274
|
+
function rawTextEnd(html, name, from) {
|
|
6275
|
+
const close = new RegExp(`</${name}(?=[\\t\\n\\f\\r />])`, "gi");
|
|
6276
|
+
close.lastIndex = from;
|
|
6277
|
+
return close.exec(html)?.index ?? html.length;
|
|
6278
|
+
}
|
|
6279
|
+
/**
|
|
6280
|
+
* The slot that inserts `content` on its own line before the first real tag of the kind `which` names (see
|
|
6281
|
+
* `scanHeadTags()`), which is kept as written. A look-alike inside a script or comment is left alone.
|
|
6282
|
+
*/
|
|
6283
|
+
function beforeTag(pattern, which, content) {
|
|
6284
|
+
const cache = /* @__PURE__ */ new Map();
|
|
6285
|
+
const offsetIn = (text) => {
|
|
6286
|
+
if (!cache.has(text)) cache.set(text, scanHeadTags(text)[which]);
|
|
6287
|
+
return cache.get(text);
|
|
6288
|
+
};
|
|
6289
|
+
return {
|
|
6290
|
+
pattern,
|
|
6071
6291
|
occurrences: "first",
|
|
6072
|
-
replacement: (match) => `${content}\n${match}
|
|
6292
|
+
replacement: (match) => `${content}\n${match}`,
|
|
6293
|
+
accepts: (text, offset) => offsetIn(text) === offset
|
|
6073
6294
|
};
|
|
6074
6295
|
}
|
|
6075
6296
|
//#endregion
|
|
@@ -6532,34 +6753,61 @@ async function buildOutput(options, cache, changedFiles, outputs) {
|
|
|
6532
6753
|
const timeout = options.formatFetchTimeout;
|
|
6533
6754
|
if (timeout !== void 0 && !(timeout >= 0)) throw new TweeTsError(`formatFetchTimeout must be 0 or more milliseconds, not ${timeout}.`);
|
|
6534
6755
|
clearIndexCache();
|
|
6535
|
-
const
|
|
6536
|
-
const
|
|
6537
|
-
for (const source of options.sources)
|
|
6538
|
-
|
|
6539
|
-
|
|
6540
|
-
|
|
6541
|
-
|
|
6542
|
-
|
|
6756
|
+
const groups = [];
|
|
6757
|
+
const outputSources = [];
|
|
6758
|
+
for (const source of options.sources) {
|
|
6759
|
+
const last = groups[groups.length - 1];
|
|
6760
|
+
if (typeof source === "string") {
|
|
6761
|
+
if (last?.kind === "paths") last.paths.push(source);
|
|
6762
|
+
else groups.push({
|
|
6763
|
+
kind: "paths",
|
|
6764
|
+
paths: [source]
|
|
6765
|
+
});
|
|
6766
|
+
} else if (last?.kind === "inline") last.sources.push(source);
|
|
6767
|
+
else groups.push({
|
|
6768
|
+
kind: "inline",
|
|
6769
|
+
sources: [source]
|
|
6770
|
+
});
|
|
6771
|
+
}
|
|
6772
|
+
const walked = groups.map((group) => {
|
|
6773
|
+
if (group.kind === "inline") return group;
|
|
6774
|
+
const found = getFilenames(group.paths, written, options.exclude);
|
|
6775
|
+
diagnostics.push(...found.diagnostics);
|
|
6776
|
+
outputSources.push(...found.outputSources);
|
|
6777
|
+
return {
|
|
6778
|
+
kind: "files",
|
|
6779
|
+
filenames: found.filenames
|
|
6780
|
+
};
|
|
6781
|
+
});
|
|
6782
|
+
rejectOutputSources(outputSources, diagnostics);
|
|
6783
|
+
const buildFiles = new Set(walked.flatMap((group) => group.kind === "files" ? group.filenames : []));
|
|
6543
6784
|
const story = createStory();
|
|
6544
6785
|
const processedFiles = /* @__PURE__ */ new Set();
|
|
6545
|
-
if (
|
|
6786
|
+
for (const group of walked) if (group.kind === "inline") loadInlineSources(story, group.sources, {
|
|
6546
6787
|
trim,
|
|
6547
6788
|
twee2Compat
|
|
6548
|
-
}, diagnostics
|
|
6549
|
-
else
|
|
6789
|
+
}, diagnostics);
|
|
6790
|
+
else if (cache) loadSourcesCached(story, group.filenames, {
|
|
6791
|
+
trim,
|
|
6792
|
+
twee2Compat
|
|
6793
|
+
}, diagnostics, processedFiles, cache, changedFiles, buildFiles);
|
|
6794
|
+
else loadSources(story, group.filenames, {
|
|
6550
6795
|
trim,
|
|
6551
6796
|
twee2Compat
|
|
6552
6797
|
}, diagnostics, processedFiles);
|
|
6553
|
-
|
|
6798
|
+
if (cache && buildFiles.size === 0) loadSourcesCached(story, [], {
|
|
6554
6799
|
trim,
|
|
6555
6800
|
twee2Compat
|
|
6556
|
-
}, diagnostics);
|
|
6801
|
+
}, diagnostics, processedFiles, cache, changedFiles);
|
|
6557
6802
|
if (options.tagAliases) story.passages = applyTagAliases(story.passages, options.tagAliases);
|
|
6558
6803
|
let format;
|
|
6559
|
-
if (outputMode === "html")
|
|
6560
|
-
|
|
6561
|
-
|
|
6562
|
-
|
|
6804
|
+
if (outputMode === "html") {
|
|
6805
|
+
format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
|
|
6806
|
+
...options,
|
|
6807
|
+
noRemote
|
|
6808
|
+
}, diagnostics);
|
|
6809
|
+
options.signal?.throwIfAborted();
|
|
6810
|
+
}
|
|
6563
6811
|
if (options.startPassage) story.twine2.start = options.startPassage;
|
|
6564
6812
|
const startName = story.twine2.start || DEFAULT_START_NAME;
|
|
6565
6813
|
if (testMode) story.twine2.options.set("debug", true);
|
|
@@ -6611,6 +6859,7 @@ async function buildOutput(options, cache, changedFiles, outputs) {
|
|
|
6611
6859
|
...getStoryStats(story, options.wordCountMethod),
|
|
6612
6860
|
files: [...processedFiles]
|
|
6613
6861
|
};
|
|
6862
|
+
options.signal?.throwIfAborted();
|
|
6614
6863
|
return {
|
|
6615
6864
|
output,
|
|
6616
6865
|
story,
|
|
@@ -6790,6 +7039,6 @@ function isViteConfigTemp(file) {
|
|
|
6790
7039
|
return /\.timestamp-\d+-[0-9a-f]*\.mjs$/.test(file);
|
|
6791
7040
|
}
|
|
6792
7041
|
//#endregion
|
|
6793
|
-
export { toPosix as a, splitDiagnostics as c,
|
|
7042
|
+
export { toPosix as a, splitDiagnostics as c, getFilenames as d, isExcluded as f, mediaTypeFromFilename as g, walkedEntry as h, outputLocations as i, compileForOutputFile as l, realPathOf as m, isInside as n, fatalError as o, outputPaths as p, isViteConfigTemp as r, formatDiagnostic as s, createOutputRecord as t, findHeadStartEnd as u };
|
|
6794
7043
|
|
|
6795
|
-
//# sourceMappingURL=paths-
|
|
7044
|
+
//# sourceMappingURL=paths-BuTAg2av.js.map
|