@rohal12/twee-ts 1.16.0 → 1.17.0
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/README.md +1 -1
- package/dist/bin/twee-ts.js +1540 -796
- package/dist/bin/twee-ts.js.map +1 -1
- package/dist/{compiler-BiDNwR9q.js → diagnostics-DZNS7W41.js} +692 -289
- package/dist/diagnostics-DZNS7W41.js.map +1 -0
- package/dist/index.cjs +1462 -704
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +93 -15
- package/dist/index.d.ts +93 -15
- package/dist/index.js +1464 -706
- package/dist/index.js.map +1 -1
- package/dist/plugins/rollup.d.ts +20 -1
- package/dist/plugins/rollup.js +34 -8
- package/dist/plugins/rollup.js.map +1 -1
- package/dist/plugins/vite.js +46 -33
- package/dist/plugins/vite.js.map +1 -1
- package/package.json +2 -1
- package/dist/compiler-BiDNwR9q.js.map +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import * as nodePath from "node:path";
|
|
3
|
-
import { basename, dirname, extname, join, parse, relative, resolve } from "node:path";
|
|
3
|
+
import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
@@ -104,6 +104,61 @@ function commentSanitize(s) {
|
|
|
104
104
|
function htmlCommentSanitize(s) {
|
|
105
105
|
return s.replace(/-->/g, "-- >");
|
|
106
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Escape JavaScript for the content of an HTML `<script>` element, so that the HTML parser reads all of it as
|
|
109
|
+
* the element's text and ends the element at the end tag written after it.
|
|
110
|
+
*
|
|
111
|
+
* - Every `<\/script` (in any letter case) becomes `<\/script`. HTML ends a script element at the first
|
|
112
|
+
* `<\/script` followed by whitespace, `/` or `>`, wherever it is in the JavaScript, even inside a string.
|
|
113
|
+
* - Every `<!--` becomes `<\!--`, but only if the text would otherwise end in the HTML tokenizer's "script
|
|
114
|
+
* data double escaped" state: after a `<!--` and then a `<script` start tag with no `-->` between them, the
|
|
115
|
+
* end tag after the text would not end the element.
|
|
116
|
+
*
|
|
117
|
+
* Inserting a backslash after `<` keeps the value of every string, template, and regular expression literal
|
|
118
|
+
* and every comment where these sequences can be written (`\/` and `\!` are identity escapes; a backslash
|
|
119
|
+
* already before the `<` escapes the `<`, as it did before); `<\/script` is also how minifiers such as Terser
|
|
120
|
+
* write inline scripts. It only changes code that uses them outside literals (`a <\/script/ b`, an HTML-like `<!--`
|
|
121
|
+
* comment that is escaped because of a later `<script`), the raw text of a tagged template, and a `<!--` in a
|
|
122
|
+
* regular expression with the `u` or `v` flag. The result is unchanged by escaping it again.
|
|
123
|
+
*/
|
|
124
|
+
function scriptContentEscape(s) {
|
|
125
|
+
const escaped = s.replace(/<(?=\/script)/gi, "<\\");
|
|
126
|
+
return endsDoubleEscaped(escaped) ? escaped.replace(/<(?=!--)/g, "<\\") : escaped;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Whether the HTML tokenizer, reading `s` as script element content with no `<\/script` in it, ends in the
|
|
130
|
+
* "script data double escaped" state, where an end tag after `s` would not end the element.
|
|
131
|
+
* See https://html.spec.whatwg.org/multipage/parsing.html#script-data-escaped-state.
|
|
132
|
+
*/
|
|
133
|
+
function endsDoubleEscaped(s) {
|
|
134
|
+
const scriptStartTag = /<script[\t\n\f\r />]/gi;
|
|
135
|
+
let pos = 0;
|
|
136
|
+
for (;;) {
|
|
137
|
+
const open = s.indexOf("<!--", pos);
|
|
138
|
+
if (open === -1) return false;
|
|
139
|
+
const escapedFrom = open + 2;
|
|
140
|
+
const close = s.indexOf("-->", escapedFrom);
|
|
141
|
+
scriptStartTag.lastIndex = escapedFrom;
|
|
142
|
+
const start = scriptStartTag.exec(s);
|
|
143
|
+
if (start === null || close !== -1 && close < start.index) {
|
|
144
|
+
if (close === -1) return false;
|
|
145
|
+
pos = close + 3;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const end = s.indexOf("-->", start.index + start[0].length);
|
|
149
|
+
if (end === -1) return true;
|
|
150
|
+
pos = end + 3;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Escape CSS for the content of an HTML `<style>` element: every `</style` (in any letter case) becomes
|
|
155
|
+
* `<\/style`, since HTML ends a style element at the first `</style` followed by whitespace, `/` or `>`.
|
|
156
|
+
* `\/` stands for `/` in CSS strings and URLs, and comments don't matter; `</style` anywhere else isn't valid
|
|
157
|
+
* CSS anyway.
|
|
158
|
+
*/
|
|
159
|
+
function styleContentEscape(s) {
|
|
160
|
+
return s.replace(/<(?=\/style)/gi, "<\\");
|
|
161
|
+
}
|
|
107
162
|
/** Apply ROT13 encoding to a string (only affects [A-Za-z]). */
|
|
108
163
|
function rot13(s) {
|
|
109
164
|
if (s.length === 0) return s;
|
|
@@ -618,16 +673,25 @@ function getFilenames(pathnames, outFilename, exclude = []) {
|
|
|
618
673
|
};
|
|
619
674
|
}
|
|
620
675
|
//#endregion
|
|
676
|
+
//#region src/source-text.ts
|
|
677
|
+
/**
|
|
678
|
+
* Source text normalization shared by every input path (files, in-memory sources, `parseTwee`).
|
|
679
|
+
*/
|
|
680
|
+
/**
|
|
681
|
+
* Strip a leading UTF-8 byte order mark and convert CRLF and bare CR line endings to LF.
|
|
682
|
+
* Idempotent, so text that is already normalized passes through unchanged.
|
|
683
|
+
*/
|
|
684
|
+
function normalizeSourceText(text) {
|
|
685
|
+
return (text.charCodeAt(0) === 65279 ? text.slice(1) : text).replace(/\r\n?/g, "\n");
|
|
686
|
+
}
|
|
687
|
+
//#endregion
|
|
621
688
|
//#region src/util.ts
|
|
622
689
|
/**
|
|
623
690
|
* Shared file I/O utilities.
|
|
624
691
|
*/
|
|
625
692
|
/** Read a file as UTF-8 with BOM stripping and line ending normalization. */
|
|
626
693
|
function readUTF8(filename) {
|
|
627
|
-
|
|
628
|
-
if (content.charCodeAt(0) === 65279) content = content.slice(1);
|
|
629
|
-
content = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
630
|
-
return content;
|
|
694
|
+
return normalizeSourceText(readFileSync(filename, "utf-8"));
|
|
631
695
|
}
|
|
632
696
|
/** Read a file as base64. */
|
|
633
697
|
function readBase64(filename) {
|
|
@@ -657,16 +721,28 @@ function relaxedJSONParse(json) {
|
|
|
657
721
|
fixed = fixed.replace(/,\s*([}\]])/g, "$1");
|
|
658
722
|
return JSON.parse(fixed);
|
|
659
723
|
}
|
|
724
|
+
/** A `"setup": function` property, which Harlowe appends to its otherwise-JSON format object. */
|
|
725
|
+
const SETUP_FUNCTION_PROPERTY = /,\s*"setup"\s*:\s*function\b/g;
|
|
726
|
+
/** Drop a trailing `"setup": function(){…}` property from a format object, if it has one. */
|
|
727
|
+
function stripSetupFunction(chunk) {
|
|
728
|
+
let lastIndex = -1;
|
|
729
|
+
for (const match of chunk.matchAll(SETUP_FUNCTION_PROPERTY)) lastIndex = match.index;
|
|
730
|
+
return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
|
|
731
|
+
}
|
|
660
732
|
/**
|
|
661
733
|
* Parse the Twine 2 format.js JSON chunk.
|
|
662
|
-
* Handles Harlowe's malformed JSON by stripping the "setup" function property.
|
|
734
|
+
* Handles Harlowe's malformed JSON by stripping the "setup" function property. The workaround
|
|
735
|
+
* keys on the property itself, so the same bytes parse whether they come from a `harlowe-3`
|
|
736
|
+
* directory or a direct download.
|
|
663
737
|
* Per spec, the name key is Optional.
|
|
738
|
+
*
|
|
739
|
+
* @param _formatId No longer used; kept so existing callers keep compiling.
|
|
664
740
|
*/
|
|
665
|
-
function parseFormatJSON(source,
|
|
741
|
+
function parseFormatJSON(source, _formatId) {
|
|
666
742
|
const first = source.indexOf("{");
|
|
667
743
|
const last = source.lastIndexOf("}");
|
|
668
744
|
if (first === -1 || last === -1) return null;
|
|
669
|
-
|
|
745
|
+
const chunk = source.slice(first, last + 1);
|
|
670
746
|
const parse = (json) => {
|
|
671
747
|
const raw = relaxedJSONParse(json);
|
|
672
748
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
|
@@ -689,16 +765,13 @@ function parseFormatJSON(source, formatId) {
|
|
|
689
765
|
try {
|
|
690
766
|
return parse(chunk);
|
|
691
767
|
} catch {
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
} catch {}
|
|
699
|
-
}
|
|
768
|
+
const stripped = stripSetupFunction(chunk);
|
|
769
|
+
if (stripped === void 0) return null;
|
|
770
|
+
try {
|
|
771
|
+
return parse(stripped);
|
|
772
|
+
} catch {
|
|
773
|
+
return null;
|
|
700
774
|
}
|
|
701
|
-
return null;
|
|
702
775
|
}
|
|
703
776
|
}
|
|
704
777
|
/**
|
|
@@ -848,6 +921,54 @@ function semverCompare(a, b) {
|
|
|
848
921
|
if (aPatch !== bPatch) return aPatch - bPatch;
|
|
849
922
|
return 0;
|
|
850
923
|
}
|
|
924
|
+
/** Build a directory-style format ID from a name and version, e.g. ('SugarCube', '2.37.3') → 'sugarcube-2'. */
|
|
925
|
+
function makeFormatId(name, version) {
|
|
926
|
+
const major = parseSemver(version)?.[0] ?? 0;
|
|
927
|
+
return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
|
|
928
|
+
}
|
|
929
|
+
/** Describe a format request for diagnostics. */
|
|
930
|
+
function describeFormatRequest(request) {
|
|
931
|
+
switch (request.kind) {
|
|
932
|
+
case "id": return `"${request.id}"`;
|
|
933
|
+
case "name": return request.version ? `"${request.name}" at version "${request.version}"` : `"${request.name}"`;
|
|
934
|
+
default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* Pick the candidate that best answers a format request.
|
|
939
|
+
*
|
|
940
|
+
* - An ID request ('sugarcube-2') matches a candidate whose name and major version build that ID,
|
|
941
|
+
* and takes the greatest such version.
|
|
942
|
+
* - A name request matches the name case-insensitively. An exact version wins outright; otherwise
|
|
943
|
+
* the greatest version with the same major that is not older than the one asked for (or, with
|
|
944
|
+
* `allowOlder`, the greatest same-major version). An unparseable requested version takes the
|
|
945
|
+
* greatest version of any major.
|
|
946
|
+
*/
|
|
947
|
+
function selectFormatCandidate(request, candidates, describe, options = {}) {
|
|
948
|
+
const allowOlder = options.allowOlder ?? false;
|
|
949
|
+
const wanted = request.kind === "name" ? parseSemver(request.version) : null;
|
|
950
|
+
let best;
|
|
951
|
+
for (const candidate of candidates) {
|
|
952
|
+
const { name, version } = describe(candidate);
|
|
953
|
+
const have = parseSemver(version);
|
|
954
|
+
if (!have) continue;
|
|
955
|
+
if (request.kind === "id") {
|
|
956
|
+
if (makeFormatId(name, version) !== request.id.toLowerCase()) continue;
|
|
957
|
+
} else {
|
|
958
|
+
if (name.toLowerCase() !== request.name.toLowerCase()) continue;
|
|
959
|
+
if (wanted) {
|
|
960
|
+
if (semverCompare(have, wanted) === 0) return candidate;
|
|
961
|
+
if (have[0] !== wanted[0]) continue;
|
|
962
|
+
if (!allowOlder && semverCompare(have, wanted) < 0) continue;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
if (!best || semverCompare(have, best.version) > 0) best = {
|
|
966
|
+
candidate,
|
|
967
|
+
version: have
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
return best?.candidate;
|
|
971
|
+
}
|
|
851
972
|
/** Get format ID from Twine 2 name and version, using SemVer major matching. */
|
|
852
973
|
function getFormatIdByNameAndVersion(formats, name, version) {
|
|
853
974
|
const wanted = parseSemver(version);
|
|
@@ -877,6 +998,321 @@ function readFormatSource(format) {
|
|
|
877
998
|
return source;
|
|
878
999
|
}
|
|
879
1000
|
//#endregion
|
|
1001
|
+
//#region src/remote-formats.ts
|
|
1002
|
+
/**
|
|
1003
|
+
* Remote story format fetching, caching, and checksum verification.
|
|
1004
|
+
* Uses the Story Formats Archive (SFA) as the default source.
|
|
1005
|
+
*/
|
|
1006
|
+
const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
|
|
1007
|
+
/** Get the cache directory for downloaded story formats. */
|
|
1008
|
+
function getCacheDir() {
|
|
1009
|
+
const base = process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache");
|
|
1010
|
+
return join(base, "twee-ts", "storyformats");
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Whether a string can serve as one directory name inside the cache: not empty, not `.` or `..`,
|
|
1014
|
+
* and free of path separators. Format names and versions come from downloaded metadata, so they
|
|
1015
|
+
* are untrusted path input.
|
|
1016
|
+
*/
|
|
1017
|
+
function isSafeSegment(segment) {
|
|
1018
|
+
return segment !== "" && segment !== "." && segment !== ".." && !/[/\\\0]/.test(segment);
|
|
1019
|
+
}
|
|
1020
|
+
/** Whether absolute path `target` lies strictly inside absolute path `root`. */
|
|
1021
|
+
function isInside(root, target) {
|
|
1022
|
+
const rel = relative(root, target);
|
|
1023
|
+
return rel !== "" && !isAbsolute(rel) && rel.split(sep)[0] !== "..";
|
|
1024
|
+
}
|
|
1025
|
+
/** The cache directory for one format version. Throws when the name or version could leave the cache. */
|
|
1026
|
+
function cachedFormatDir(name, version) {
|
|
1027
|
+
if (!isSafeSegment(name)) throw new Error(`Refusing to cache a story format with an unsafe name: ${JSON.stringify(name)}`);
|
|
1028
|
+
if (!isSafeSegment(version) || !parseSemver(version)) throw new Error(`Refusing to cache story format "${name}" with an unsafe version: ${JSON.stringify(version)}`);
|
|
1029
|
+
const root = resolve(getCacheDir());
|
|
1030
|
+
const dir = resolve(root, name, version);
|
|
1031
|
+
if (!isInside(root, dir)) throw new Error(`Refusing to cache a story format outside the cache directory ${root}: ${dir}`);
|
|
1032
|
+
return dir;
|
|
1033
|
+
}
|
|
1034
|
+
/** Create `dir` as a plain directory, or check that it already is one (a symlink could lead out of the cache). */
|
|
1035
|
+
function ensurePlainDirectory(dir) {
|
|
1036
|
+
const stat = lstatSync(dir, { throwIfNoEntry: false });
|
|
1037
|
+
if (stat === void 0) {
|
|
1038
|
+
mkdirSync(dir);
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`Refusing to write a story format outside the cache directory: ${dir} is not a plain directory`);
|
|
1042
|
+
}
|
|
1043
|
+
/** Write a downloaded format.js to `<cache>/<name>/<version>/format.js` and return its path. */
|
|
1044
|
+
function writeCachedFormat(name, version, text) {
|
|
1045
|
+
const dir = cachedFormatDir(name, version);
|
|
1046
|
+
const root = resolve(getCacheDir());
|
|
1047
|
+
mkdirSync(root, { recursive: true });
|
|
1048
|
+
ensurePlainDirectory(join(root, name));
|
|
1049
|
+
ensurePlainDirectory(dir);
|
|
1050
|
+
const formatPath = join(dir, "format.js");
|
|
1051
|
+
if (lstatSync(formatPath, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error(`Refusing to write a story format outside the cache directory: ${formatPath} is a symlink`);
|
|
1052
|
+
writeFileSync(formatPath, text, "utf-8");
|
|
1053
|
+
return formatPath;
|
|
1054
|
+
}
|
|
1055
|
+
/** In-memory index cache, keyed by URL. Cleared each compile. */
|
|
1056
|
+
const indexCache = /* @__PURE__ */ new Map();
|
|
1057
|
+
/** Clear the in-memory index cache. */
|
|
1058
|
+
function clearIndexCache() {
|
|
1059
|
+
indexCache.clear();
|
|
1060
|
+
}
|
|
1061
|
+
/** Validate that a JSON value is a valid SFAIndexEntry. */
|
|
1062
|
+
function isValidEntry(val) {
|
|
1063
|
+
if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
|
|
1064
|
+
const obj = val;
|
|
1065
|
+
return typeof obj.name === "string" && typeof obj.version === "string" && typeof obj.checksums === "object" && obj.checksums !== null && !Array.isArray(obj.checksums);
|
|
1066
|
+
}
|
|
1067
|
+
/** Validate that a JSON value conforms to the SFAIndex shape. */
|
|
1068
|
+
function validateSFAIndex(json) {
|
|
1069
|
+
if (typeof json !== "object" || json === null) throw new Error("SFA index is not an object");
|
|
1070
|
+
const obj = json;
|
|
1071
|
+
return {
|
|
1072
|
+
twine1: Array.isArray(obj["twine1"]) ? obj["twine1"].filter(isValidEntry) : [],
|
|
1073
|
+
twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
/** Fetch and parse an SFA index.json, with in-memory caching. */
|
|
1077
|
+
async function fetchIndex(url) {
|
|
1078
|
+
const cached = indexCache.get(url);
|
|
1079
|
+
if (cached) return cached;
|
|
1080
|
+
const res = await fetch(url);
|
|
1081
|
+
if (!res.ok) throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
|
|
1082
|
+
const data = validateSFAIndex(await res.json());
|
|
1083
|
+
indexCache.set(url, data);
|
|
1084
|
+
return data;
|
|
1085
|
+
}
|
|
1086
|
+
/** Find the best matching entry in an SFA index for a name or ID request (twine2 entries first). */
|
|
1087
|
+
function findEntryForRequest(index, request) {
|
|
1088
|
+
return selectFormatCandidate(request, [...(index.twine2 ?? []).map((entry) => ({
|
|
1089
|
+
entry,
|
|
1090
|
+
formatType: "twine2"
|
|
1091
|
+
})), ...(index.twine1 ?? []).map((entry) => ({
|
|
1092
|
+
entry,
|
|
1093
|
+
formatType: "twine1"
|
|
1094
|
+
}))], (c) => c.entry);
|
|
1095
|
+
}
|
|
1096
|
+
/** Verify SHA-256 checksum using Web Crypto API (Node 22 built-in). */
|
|
1097
|
+
async function verifySHA256(content, expectedHex) {
|
|
1098
|
+
const digest = await crypto.subtle.digest("SHA-256", content);
|
|
1099
|
+
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("") === expectedHex.toLowerCase();
|
|
1100
|
+
}
|
|
1101
|
+
/** Derive the download URL for a format.js from the index URL and entry. */
|
|
1102
|
+
function getDownloadUrl(indexUrl, entry, formatType) {
|
|
1103
|
+
return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
|
|
1104
|
+
}
|
|
1105
|
+
/** Download a format, verify its checksum, write to cache, and return StoryFormatInfo. */
|
|
1106
|
+
async function fetchAndCacheFormat(entry, downloadUrl) {
|
|
1107
|
+
cachedFormatDir(entry.name, entry.version);
|
|
1108
|
+
const res = await fetch(downloadUrl);
|
|
1109
|
+
if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
|
|
1110
|
+
const text = await res.text();
|
|
1111
|
+
const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
|
|
1112
|
+
if (checksumKey) {
|
|
1113
|
+
const expected = entry.checksums[checksumKey];
|
|
1114
|
+
if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
|
|
1115
|
+
if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
|
|
1116
|
+
}
|
|
1117
|
+
const id = makeFormatId(entry.name, entry.version);
|
|
1118
|
+
const data = parseFormatJSON(text, id);
|
|
1119
|
+
if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
|
|
1120
|
+
return {
|
|
1121
|
+
id,
|
|
1122
|
+
filename: writeCachedFormat(entry.name, entry.version, text),
|
|
1123
|
+
isTwine2: true,
|
|
1124
|
+
name: data.name,
|
|
1125
|
+
version: data.version,
|
|
1126
|
+
proofing: data.proofing ?? false
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
/** Download a direct format.js URL, parse its JSON, cache it, and return StoryFormatInfo. */
|
|
1130
|
+
async function fetchDirectFormat(url) {
|
|
1131
|
+
const res = await fetch(url);
|
|
1132
|
+
if (!res.ok) throw new Error(`Failed to download format from ${url}: ${res.status} ${res.statusText}`);
|
|
1133
|
+
const text = await res.text();
|
|
1134
|
+
const data = parseFormatJSON(text);
|
|
1135
|
+
if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
|
|
1136
|
+
return {
|
|
1137
|
+
id: makeFormatId(data.name, data.version),
|
|
1138
|
+
filename: writeCachedFormat(data.name, data.version, text),
|
|
1139
|
+
isTwine2: true,
|
|
1140
|
+
name: data.name,
|
|
1141
|
+
version: data.version,
|
|
1142
|
+
proofing: data.proofing ?? false
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Resolve a story format request remotely, in the same order as {@link resolveRemoteFormat}.
|
|
1147
|
+
* An ID request such as 'sugarcube-2' matches the format whose name and major version build that ID
|
|
1148
|
+
* (SugarCube 2.x), taking the greatest version available.
|
|
1149
|
+
*/
|
|
1150
|
+
async function resolveRemoteFormatRequest(request, indices, urls) {
|
|
1151
|
+
let lastError;
|
|
1152
|
+
const cached = findCachedFormat(request);
|
|
1153
|
+
if (cached && request.kind === "name" && isExactVersion(cached.version, request.version)) return cached;
|
|
1154
|
+
for (const url of urls ?? []) try {
|
|
1155
|
+
const info = await fetchDirectFormat(url);
|
|
1156
|
+
if (selectFormatCandidate(request, [info], (f) => f)) return info;
|
|
1157
|
+
} catch (e) {
|
|
1158
|
+
lastError = e instanceof Error ? e : new Error(String(e));
|
|
1159
|
+
}
|
|
1160
|
+
const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
|
|
1161
|
+
for (const indexUrl of allIndices) try {
|
|
1162
|
+
const result = findEntryForRequest(await fetchIndex(indexUrl), request);
|
|
1163
|
+
if (result) {
|
|
1164
|
+
const hit = getCachedFormat(result.entry.name, result.entry.version);
|
|
1165
|
+
if (hit) return hit;
|
|
1166
|
+
const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
|
|
1167
|
+
return await fetchAndCacheFormat(result.entry, downloadUrl);
|
|
1168
|
+
}
|
|
1169
|
+
} catch (e) {
|
|
1170
|
+
lastError = e instanceof Error ? e : new Error(String(e));
|
|
1171
|
+
}
|
|
1172
|
+
if (cached) return cached;
|
|
1173
|
+
if (lastError) throw lastError;
|
|
1174
|
+
}
|
|
1175
|
+
/** Whether two version strings name the same SemVer version. */
|
|
1176
|
+
function isExactVersion(have, wanted) {
|
|
1177
|
+
const a = parseSemver(have);
|
|
1178
|
+
const b = parseSemver(wanted);
|
|
1179
|
+
return a !== null && b !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
|
|
1180
|
+
}
|
|
1181
|
+
/**
|
|
1182
|
+
* Find the cached download that best answers a format request, without network access.
|
|
1183
|
+
* Matching follows {@link selectFormatCandidate}.
|
|
1184
|
+
*/
|
|
1185
|
+
function findCachedFormat(request, options = {}) {
|
|
1186
|
+
return selectFormatCandidate(request, [...discoverCachedFormats().values()], (f) => f, options);
|
|
1187
|
+
}
|
|
1188
|
+
/** Check if a format is already in the local cache. */
|
|
1189
|
+
function getCachedFormat(name, version) {
|
|
1190
|
+
if (!isSafeSegment(name) || !isSafeSegment(version)) return void 0;
|
|
1191
|
+
const formatPath = join(getCacheDir(), name, version, "format.js");
|
|
1192
|
+
try {
|
|
1193
|
+
if (!existsSync(formatPath)) return void 0;
|
|
1194
|
+
const source = readFileSync(formatPath, "utf-8");
|
|
1195
|
+
const id = makeFormatId(name, version);
|
|
1196
|
+
const data = parseFormatJSON(source, id);
|
|
1197
|
+
if (!data) return void 0;
|
|
1198
|
+
return {
|
|
1199
|
+
id,
|
|
1200
|
+
filename: formatPath,
|
|
1201
|
+
isTwine2: true,
|
|
1202
|
+
name: data.name,
|
|
1203
|
+
version: data.version,
|
|
1204
|
+
proofing: data.proofing ?? false
|
|
1205
|
+
};
|
|
1206
|
+
} catch {
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
/** Discover all cached remote formats. Returns a Map like discoverFormats(). */
|
|
1211
|
+
function discoverCachedFormats() {
|
|
1212
|
+
const formats = /* @__PURE__ */ new Map();
|
|
1213
|
+
const cacheDir = getCacheDir();
|
|
1214
|
+
try {
|
|
1215
|
+
if (!existsSync(cacheDir)) return formats;
|
|
1216
|
+
} catch {
|
|
1217
|
+
return formats;
|
|
1218
|
+
}
|
|
1219
|
+
let names;
|
|
1220
|
+
try {
|
|
1221
|
+
names = readdirSync(cacheDir);
|
|
1222
|
+
} catch {
|
|
1223
|
+
return formats;
|
|
1224
|
+
}
|
|
1225
|
+
for (const name of names) {
|
|
1226
|
+
const nameDir = join(cacheDir, name);
|
|
1227
|
+
try {
|
|
1228
|
+
if (!statSync(nameDir).isDirectory()) continue;
|
|
1229
|
+
} catch {
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1232
|
+
let versions;
|
|
1233
|
+
try {
|
|
1234
|
+
versions = readdirSync(nameDir);
|
|
1235
|
+
} catch {
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1238
|
+
for (const version of versions) {
|
|
1239
|
+
const info = getCachedFormat(name, version);
|
|
1240
|
+
if (info) formats.set(`${info.id}-${version}`, info);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
return formats;
|
|
1244
|
+
}
|
|
1245
|
+
//#endregion
|
|
1246
|
+
//#region src/format-resolution.ts
|
|
1247
|
+
/** Choose the format request: explicit format ID > StoryData format > default ID. */
|
|
1248
|
+
function formatRequestFor(formatId, storyFormat, storyFormatVersion, defaultId) {
|
|
1249
|
+
if (formatId) return {
|
|
1250
|
+
kind: "id",
|
|
1251
|
+
id: formatId
|
|
1252
|
+
};
|
|
1253
|
+
if (storyFormat) return {
|
|
1254
|
+
kind: "name",
|
|
1255
|
+
name: storyFormat,
|
|
1256
|
+
version: storyFormatVersion
|
|
1257
|
+
};
|
|
1258
|
+
return {
|
|
1259
|
+
kind: "id",
|
|
1260
|
+
id: defaultId
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
/** Look a request up among the formats found in local format directories. */
|
|
1264
|
+
function findLocalFormat(formats, request) {
|
|
1265
|
+
switch (request.kind) {
|
|
1266
|
+
case "id": return formats.get(request.id);
|
|
1267
|
+
case "name": {
|
|
1268
|
+
const id = getFormatIdByNameAndVersion(formats, request.name, request.version);
|
|
1269
|
+
return id === void 0 ? void 0 : formats.get(id);
|
|
1270
|
+
}
|
|
1271
|
+
default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
/**
|
|
1275
|
+
* Resolve a format request to a story format.
|
|
1276
|
+
*
|
|
1277
|
+
* Order: local format directories, then the download cache, then remote sources (unless
|
|
1278
|
+
* `noRemote`). When a StoryData request names a version that none of them has, an older version of
|
|
1279
|
+
* the same format and major version is used with a warning. Anything else is an error diagnostic,
|
|
1280
|
+
* and the result is `undefined`.
|
|
1281
|
+
*/
|
|
1282
|
+
async function resolveStoryFormat(request, options, diagnostics) {
|
|
1283
|
+
const noRemote = options.noRemote ?? false;
|
|
1284
|
+
const formats = discoverFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true));
|
|
1285
|
+
const wanted = describeFormatRequest(request);
|
|
1286
|
+
const local = findLocalFormat(formats, request);
|
|
1287
|
+
if (local) return local;
|
|
1288
|
+
const cached = findCachedFormat(request);
|
|
1289
|
+
if (cached) return cached;
|
|
1290
|
+
if (!noRemote) try {
|
|
1291
|
+
const remote = await resolveRemoteFormatRequest(request, options.formatIndices, options.formatUrls);
|
|
1292
|
+
if (remote) return remote;
|
|
1293
|
+
} catch (e) {
|
|
1294
|
+
diagnostics.push({
|
|
1295
|
+
level: "warning",
|
|
1296
|
+
message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
if (request.kind === "name") {
|
|
1300
|
+
const older = selectFormatCandidate(request, [...formats.values()].filter((f) => f.isTwine2), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
|
|
1301
|
+
if (older) {
|
|
1302
|
+
diagnostics.push({
|
|
1303
|
+
level: "warning",
|
|
1304
|
+
message: `Story format ${wanted} is not available; using ${older.name} ${older.version} instead.`
|
|
1305
|
+
});
|
|
1306
|
+
return older;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
const reason = noRemote ? " (remote fetching disabled)" : "";
|
|
1310
|
+
diagnostics.push({
|
|
1311
|
+
level: "error",
|
|
1312
|
+
message: `Story format ${wanted} is not available${reason}. Found: ${[...formats.keys()].join(", ") || "none"}`
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
//#endregion
|
|
880
1316
|
//#region src/types.ts
|
|
881
1317
|
const ItemType = {
|
|
882
1318
|
Error: 0,
|
|
@@ -1140,16 +1576,20 @@ function twee2ToV3(s) {
|
|
|
1140
1576
|
//#region src/parser.ts
|
|
1141
1577
|
/**
|
|
1142
1578
|
* Parse Twee source text into passages.
|
|
1579
|
+
*
|
|
1580
|
+
* The source is normalized first, as files are when they are read: a leading UTF-8 BOM is
|
|
1581
|
+
* removed and CRLF and bare CR line endings become LF.
|
|
1143
1582
|
*/
|
|
1144
1583
|
function parseTwee(source, options = {}) {
|
|
1145
1584
|
const { filename = "<inline>", trim = true, twee2Compat = false } = options;
|
|
1146
1585
|
const diagnostics = [];
|
|
1147
|
-
|
|
1586
|
+
const normalized = normalizeSourceText(source);
|
|
1587
|
+
const tweeSource = twee2Compat ? twee2ToV3(normalized) : normalized;
|
|
1148
1588
|
const passages = [];
|
|
1149
1589
|
let current = null;
|
|
1150
1590
|
let pCount = 0;
|
|
1151
1591
|
let lastType = ItemType.EOF;
|
|
1152
|
-
for (const item of tweeLexer(
|
|
1592
|
+
for (const item of tweeLexer(tweeSource)) {
|
|
1153
1593
|
switch (item.type) {
|
|
1154
1594
|
case ItemType.Error:
|
|
1155
1595
|
diagnostics.push({
|
|
@@ -3979,14 +4419,16 @@ function parseDocument(data, options) {
|
|
|
3979
4419
|
*/
|
|
3980
4420
|
/**
|
|
3981
4421
|
* Parse a Twine 2 or Twine 1 compiled HTML file back into a Story model.
|
|
4422
|
+
* Tweego always trims passage text; here `trim: false` keeps it exactly, as for Twee sources.
|
|
3982
4423
|
*/
|
|
3983
|
-
function decompileHTML(html) {
|
|
4424
|
+
function decompileHTML(html, options = {}) {
|
|
3984
4425
|
const diagnostics = [];
|
|
3985
4426
|
const story = createStory();
|
|
4427
|
+
const passageText = options.trim === false ? (text) => text : (text) => text.trim();
|
|
3986
4428
|
const doc = parseDocument(html);
|
|
3987
4429
|
const twine2Data = findElement(doc, "tw-storydata");
|
|
3988
4430
|
if (twine2Data) {
|
|
3989
|
-
decompileTwine2(twine2Data, story, diagnostics);
|
|
4431
|
+
decompileTwine2(twine2Data, story, passageText, diagnostics);
|
|
3990
4432
|
return {
|
|
3991
4433
|
story,
|
|
3992
4434
|
diagnostics
|
|
@@ -3994,7 +4436,7 @@ function decompileHTML(html) {
|
|
|
3994
4436
|
}
|
|
3995
4437
|
const twine1Data = findElementByIdPattern(doc, /^store(?:-a|A)rea$/);
|
|
3996
4438
|
if (twine1Data) {
|
|
3997
|
-
decompileTwine1(twine1Data, story, diagnostics);
|
|
4439
|
+
decompileTwine1(twine1Data, story, passageText, diagnostics);
|
|
3998
4440
|
return {
|
|
3999
4441
|
story,
|
|
4000
4442
|
diagnostics
|
|
@@ -4009,7 +4451,7 @@ function decompileHTML(html) {
|
|
|
4009
4451
|
diagnostics
|
|
4010
4452
|
};
|
|
4011
4453
|
}
|
|
4012
|
-
function decompileTwine2(storyData, story, diagnostics) {
|
|
4454
|
+
function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
4013
4455
|
let startnode = 0;
|
|
4014
4456
|
const attrs = storyData.attribs ?? {};
|
|
4015
4457
|
if (attrs["name"]) story.name = attrs["name"];
|
|
@@ -4030,6 +4472,7 @@ function decompileTwine2(storyData, story, diagnostics) {
|
|
|
4030
4472
|
});
|
|
4031
4473
|
else story.twine2.zoom = parsed;
|
|
4032
4474
|
}
|
|
4475
|
+
if (attrs["tags"]) story.twine2.tags = attrs["tags"];
|
|
4033
4476
|
if (attrs["format"]) story.twine2.format = attrs["format"];
|
|
4034
4477
|
if (attrs["format-version"]) story.twine2.formatVersion = attrs["format-version"];
|
|
4035
4478
|
if (attrs["options"]) for (const opt of attrs["options"].split(/\s+/).filter((s) => s.length > 0)) story.twine2.options.set(opt, true);
|
|
@@ -4038,8 +4481,9 @@ function decompileTwine2(storyData, story, diagnostics) {
|
|
|
4038
4481
|
switch (node.name) {
|
|
4039
4482
|
case "style":
|
|
4040
4483
|
case "script": {
|
|
4041
|
-
const
|
|
4042
|
-
if (
|
|
4484
|
+
const content = getTextContent(node);
|
|
4485
|
+
if (content.trim().length === 0) continue;
|
|
4486
|
+
const text = passageText(content);
|
|
4043
4487
|
storyAdd(story, {
|
|
4044
4488
|
name: node.name === "style" ? "Story Stylesheet" : "Story JavaScript",
|
|
4045
4489
|
tags: node.name === "style" ? ["stylesheet"] : ["script"],
|
|
@@ -4074,7 +4518,7 @@ function decompileTwine2(storyData, story, diagnostics) {
|
|
|
4074
4518
|
const passage = {
|
|
4075
4519
|
name,
|
|
4076
4520
|
tags,
|
|
4077
|
-
text: getTextContent(node)
|
|
4521
|
+
text: passageText(getTextContent(node))
|
|
4078
4522
|
};
|
|
4079
4523
|
if (metadata.position || metadata.size) passage.metadata = metadata;
|
|
4080
4524
|
storyAdd(story, passage, diagnostics);
|
|
@@ -4088,23 +4532,33 @@ function decompileTwine2(storyData, story, diagnostics) {
|
|
|
4088
4532
|
text: marshalStoryData(story)
|
|
4089
4533
|
}, diagnostics);
|
|
4090
4534
|
}
|
|
4091
|
-
function decompileTwine1(storeArea, story, diagnostics) {
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4535
|
+
function decompileTwine1(storeArea, story, passageText, diagnostics) {
|
|
4536
|
+
const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
|
|
4537
|
+
const obfuscated = isRot13Obfuscated(passages);
|
|
4538
|
+
for (const p of passages) storyAdd(story, obfuscated && p.name !== "StorySettings" ? {
|
|
4539
|
+
...p,
|
|
4540
|
+
text: rot13(p.text)
|
|
4541
|
+
} : p, diagnostics);
|
|
4542
|
+
}
|
|
4543
|
+
function isTiddler(node) {
|
|
4544
|
+
return node.type === "tag" && node.name === "div" && node.attribs !== void 0 && "tiddler" in node.attribs;
|
|
4545
|
+
}
|
|
4546
|
+
function tiddlerToPassage(node, passageText) {
|
|
4547
|
+
const nodeAttrs = node.attribs ?? {};
|
|
4548
|
+
const passage = {
|
|
4549
|
+
name: nodeAttrs["tiddler"] ?? "",
|
|
4550
|
+
tags: nodeAttrs["tags"] ? nodeAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [],
|
|
4551
|
+
text: passageText(tiddlerUnescape(getTextContent(node)))
|
|
4552
|
+
};
|
|
4553
|
+
const position = nodeAttrs["twine-position"];
|
|
4554
|
+
if (position) passage.metadata = { position };
|
|
4555
|
+
return passage;
|
|
4556
|
+
}
|
|
4557
|
+
/** Whether the StorySettings tiddlers turn on ROT13 obfuscation, read the way the writer reads them. */
|
|
4558
|
+
function isRot13Obfuscated(passages) {
|
|
4559
|
+
const settings = createStory();
|
|
4560
|
+
for (const p of passages) if (p.name === "StorySettings") unmarshalStorySettings(settings, p.text, []);
|
|
4561
|
+
return settings.twine1.settings.get("obfuscate") === "rot13";
|
|
4108
4562
|
}
|
|
4109
4563
|
function findElement(node, tagName) {
|
|
4110
4564
|
if (node.type === "tag" && node.name === tagName) return node;
|
|
@@ -4159,7 +4613,7 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
|
|
|
4159
4613
|
break;
|
|
4160
4614
|
case "htm":
|
|
4161
4615
|
case "html":
|
|
4162
|
-
loadHTML(story, filename, diagnostics);
|
|
4616
|
+
loadHTML(story, filename, opts, diagnostics);
|
|
4163
4617
|
break;
|
|
4164
4618
|
case "css":
|
|
4165
4619
|
loadTagged(story, "stylesheet", filename, diagnostics);
|
|
@@ -4227,7 +4681,7 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
|
|
|
4227
4681
|
function loadInlineSources(story, sources, opts, diagnostics) {
|
|
4228
4682
|
for (const source of sources) {
|
|
4229
4683
|
if (typeof source === "string") continue;
|
|
4230
|
-
const content = typeof source.content === "string" ? source.content : source.content.toString("utf-8");
|
|
4684
|
+
const content = normalizeSourceText(typeof source.content === "string" ? source.content : source.content.toString("utf-8"));
|
|
4231
4685
|
const ext = normalizedFileExt(source.filename);
|
|
4232
4686
|
switch (ext) {
|
|
4233
4687
|
case "":
|
|
@@ -4313,13 +4767,33 @@ function parseFontFile(filename) {
|
|
|
4313
4767
|
diagnostics: []
|
|
4314
4768
|
};
|
|
4315
4769
|
}
|
|
4316
|
-
function parseHTMLFile(filename) {
|
|
4317
|
-
const
|
|
4770
|
+
function parseHTMLFile(filename, opts) {
|
|
4771
|
+
const { story, diagnostics } = decompileHTML(readUTF8(filename), { trim: opts.trim ?? true });
|
|
4318
4772
|
return {
|
|
4319
|
-
passages:
|
|
4320
|
-
|
|
4773
|
+
passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
|
|
4774
|
+
name: "StoryTitle",
|
|
4775
|
+
tags: [],
|
|
4776
|
+
text: story.name
|
|
4777
|
+
}, ...story.passages] : story.passages,
|
|
4778
|
+
diagnostics: [...diagnostics]
|
|
4321
4779
|
};
|
|
4322
4780
|
}
|
|
4781
|
+
/**
|
|
4782
|
+
* Identity of the options that affect how a file parses, stored on its cache entry.
|
|
4783
|
+
* Twee files depend on `trim` and `twee2Compat`, HTML files on `trim`; every other file type
|
|
4784
|
+
* yields the same key for all options.
|
|
4785
|
+
*/
|
|
4786
|
+
function parseOptionsKey(filename, opts) {
|
|
4787
|
+
const ext = normalizedFileExt(filename);
|
|
4788
|
+
const trim = opts.trim ?? true;
|
|
4789
|
+
if (ext === "htm" || ext === "html") return JSON.stringify({ trim });
|
|
4790
|
+
const twee2File = ext === "tw2" || ext === "twee2";
|
|
4791
|
+
if (!twee2File && ext !== "tw" && ext !== "twee") return "";
|
|
4792
|
+
return JSON.stringify({
|
|
4793
|
+
trim,
|
|
4794
|
+
twee2Compat: twee2File || (opts.twee2Compat ?? false)
|
|
4795
|
+
});
|
|
4796
|
+
}
|
|
4323
4797
|
function parseFile(filename, opts) {
|
|
4324
4798
|
switch (normalizedFileExt(filename)) {
|
|
4325
4799
|
case "tw":
|
|
@@ -4330,7 +4804,7 @@ function parseFile(filename, opts) {
|
|
|
4330
4804
|
twee2Compat: true
|
|
4331
4805
|
});
|
|
4332
4806
|
case "htm":
|
|
4333
|
-
case "html": return parseHTMLFile(filename);
|
|
4807
|
+
case "html": return parseHTMLFile(filename, opts);
|
|
4334
4808
|
case "css": return parseTaggedFile("stylesheet", filename);
|
|
4335
4809
|
case "js": return parseTaggedFile("script", filename);
|
|
4336
4810
|
case "otf":
|
|
@@ -4362,8 +4836,8 @@ function parseFile(filename, opts) {
|
|
|
4362
4836
|
default: return;
|
|
4363
4837
|
}
|
|
4364
4838
|
}
|
|
4365
|
-
function loadHTML(story, filename, diagnostics) {
|
|
4366
|
-
const result = parseHTMLFile(filename);
|
|
4839
|
+
function loadHTML(story, filename, opts, diagnostics) {
|
|
4840
|
+
const result = parseHTMLFile(filename, opts);
|
|
4367
4841
|
diagnostics.push(...result.diagnostics);
|
|
4368
4842
|
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
4369
4843
|
}
|
|
@@ -4399,8 +4873,11 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
4399
4873
|
continue;
|
|
4400
4874
|
}
|
|
4401
4875
|
currentFiles.add(filename);
|
|
4402
|
-
const
|
|
4403
|
-
|
|
4876
|
+
const optionsKey = parseOptionsKey(filename, opts);
|
|
4877
|
+
const entry = cache.get(filename);
|
|
4878
|
+
const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
|
|
4879
|
+
const changed = changedFiles?.has(filename) ?? false;
|
|
4880
|
+
if (changedFiles && cached && !changed) {
|
|
4404
4881
|
diagnostics.push(...cached.diagnostics);
|
|
4405
4882
|
for (const p of cached.passages) storyAdd(story, p, diagnostics);
|
|
4406
4883
|
processedFiles.add(filename);
|
|
@@ -4412,7 +4889,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
4412
4889
|
} catch {
|
|
4413
4890
|
continue;
|
|
4414
4891
|
}
|
|
4415
|
-
if (cached && cached.mtimeMs === mtimeMs) {
|
|
4892
|
+
if (cached && !changed && cached.mtimeMs === mtimeMs) {
|
|
4416
4893
|
diagnostics.push(...cached.diagnostics);
|
|
4417
4894
|
for (const p of cached.passages) storyAdd(story, p, diagnostics);
|
|
4418
4895
|
processedFiles.add(filename);
|
|
@@ -4423,6 +4900,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
4423
4900
|
if (!result) continue;
|
|
4424
4901
|
cache.set(filename, {
|
|
4425
4902
|
mtimeMs,
|
|
4903
|
+
parseOptionsKey: optionsKey,
|
|
4426
4904
|
passages: result.passages,
|
|
4427
4905
|
diagnostics: result.diagnostics
|
|
4428
4906
|
});
|
|
@@ -4486,11 +4964,39 @@ function toTwine2HTML(story, format, startName, options) {
|
|
|
4486
4964
|
template = template.replaceAll("{{STORY_NAME}}", () => name);
|
|
4487
4965
|
}
|
|
4488
4966
|
if (template.includes("{{STORY_DATA}}")) {
|
|
4489
|
-
const data = getTwine2DataChunk(
|
|
4967
|
+
const data = getTwine2DataChunk({
|
|
4968
|
+
...story,
|
|
4969
|
+
twine2: {
|
|
4970
|
+
...story.twine2,
|
|
4971
|
+
format: format.name,
|
|
4972
|
+
formatVersion: format.version
|
|
4973
|
+
}
|
|
4974
|
+
}, startName, options);
|
|
4490
4975
|
template = template.replace("{{STORY_DATA}}", () => data);
|
|
4491
4976
|
}
|
|
4492
4977
|
return template;
|
|
4493
4978
|
}
|
|
4979
|
+
const OMITTING_TAGS = [
|
|
4980
|
+
"Twine.private",
|
|
4981
|
+
"script",
|
|
4982
|
+
"stylesheet"
|
|
4983
|
+
];
|
|
4984
|
+
/**
|
|
4985
|
+
* Why Twine 2 output leaves a passage out of its `<tw-passagedata>` elements,
|
|
4986
|
+
* or `undefined` when the passage is emitted (and so can be the starting passage).
|
|
4987
|
+
*/
|
|
4988
|
+
function twine2PassageOmission(story, p) {
|
|
4989
|
+
if (p.name === "StoryTitle" || p.name === "StoryData") return {
|
|
4990
|
+
kind: "special-name",
|
|
4991
|
+
name: p.name
|
|
4992
|
+
};
|
|
4993
|
+
const tag = OMITTING_TAGS.find((t) => hasTag(p, t));
|
|
4994
|
+
if (tag !== void 0) return {
|
|
4995
|
+
kind: "tag",
|
|
4996
|
+
tag
|
|
4997
|
+
};
|
|
4998
|
+
if (p.name === "StorySettings" && story.twine1.settings.size === 0) return { kind: "empty-story-settings" };
|
|
4999
|
+
}
|
|
4494
5000
|
function getTwine2DataChunk(story, startName, options) {
|
|
4495
5001
|
const parts = [];
|
|
4496
5002
|
let startID = "";
|
|
@@ -4513,7 +5019,7 @@ function getTwine2DataChunk(story, startName, options) {
|
|
|
4513
5019
|
pidS++;
|
|
4514
5020
|
}
|
|
4515
5021
|
}
|
|
4516
|
-
parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContent}</style>`);
|
|
5022
|
+
parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContentEscape(styleContent)}</style>`);
|
|
4517
5023
|
let scriptContent = "";
|
|
4518
5024
|
if (scripts.length === 1) scriptContent = scripts[0]?.text ?? "";
|
|
4519
5025
|
else if (scripts.length > 1) {
|
|
@@ -4525,7 +5031,7 @@ function getTwine2DataChunk(story, startName, options) {
|
|
|
4525
5031
|
pidS++;
|
|
4526
5032
|
}
|
|
4527
5033
|
}
|
|
4528
|
-
parts.push(`<script role="script" id="twine-user-script" type="text/twine-javascript">${scriptContent}<\/script>`);
|
|
5034
|
+
parts.push(`<script role="script" id="twine-user-script" type="text/twine-javascript">${scriptContentEscape(scriptContent)}<\/script>`);
|
|
4529
5035
|
const validTagColors = /* @__PURE__ */ new Set([
|
|
4530
5036
|
"gray",
|
|
4531
5037
|
"red",
|
|
@@ -4539,8 +5045,7 @@ function getTwine2DataChunk(story, startName, options) {
|
|
|
4539
5045
|
for (const [tag, color] of story.twine2.tagColors) if (validTagColors.has(color) || hexColorPattern.test(color)) parts.push(`<tw-tag name="${attrEscape(tag)}" color="${attrEscape(color)}"></tw-tag>`);
|
|
4540
5046
|
pid = 1;
|
|
4541
5047
|
for (const p of story.passages) {
|
|
4542
|
-
if (
|
|
4543
|
-
if (p.name === "StorySettings" && story.twine1.settings.size === 0) continue;
|
|
5048
|
+
if (twine2PassageOmission(story, p) !== void 0) continue;
|
|
4544
5049
|
parts.push(passageToPassagedata(p, pid, options));
|
|
4545
5050
|
if (startName === p.name) startID = String(pid);
|
|
4546
5051
|
pid++;
|
|
@@ -4596,12 +5101,21 @@ function toTwine1HTML(story, format, startName) {
|
|
|
4596
5101
|
}
|
|
4597
5102
|
return template;
|
|
4598
5103
|
}
|
|
5104
|
+
/**
|
|
5105
|
+
* Why Twine 1 output leaves a passage out of its tiddlers, or `undefined` when the passage is emitted.
|
|
5106
|
+
*/
|
|
5107
|
+
function twine1PassageOmission(p) {
|
|
5108
|
+
return hasTag(p, "Twine.private") ? {
|
|
5109
|
+
kind: "tag",
|
|
5110
|
+
tag: "Twine.private"
|
|
5111
|
+
} : void 0;
|
|
5112
|
+
}
|
|
4599
5113
|
function getTwine1PassageChunk(story) {
|
|
4600
5114
|
const obfuscateRot13 = story.twine1.settings.get("obfuscate") === "rot13";
|
|
4601
5115
|
let data = "";
|
|
4602
5116
|
let count = 0;
|
|
4603
5117
|
for (const p of story.passages) {
|
|
4604
|
-
if (
|
|
5118
|
+
if (twine1PassageOmission(p) !== void 0) continue;
|
|
4605
5119
|
count++;
|
|
4606
5120
|
data += passageToTiddler(p, count, obfuscateRot13);
|
|
4607
5121
|
}
|
|
@@ -4623,10 +5137,39 @@ function tryReplaceComponent(template, placeholder, componentPath, required) {
|
|
|
4623
5137
|
}
|
|
4624
5138
|
//#endregion
|
|
4625
5139
|
//#region src/output-twee.ts
|
|
4626
|
-
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
|
|
5140
|
+
/**
|
|
5141
|
+
* Serialize the story as Twee. The StoryData passage is written from the story model rather than
|
|
5142
|
+
* from its loaded text, so changes made after loading (a start passage override, test mode) are kept.
|
|
5143
|
+
* Tweego writes the loaded text; its own normalization makes the two the same for an unchanged story.
|
|
5144
|
+
*
|
|
5145
|
+
* `addStoryData`: add a StoryData passage when the story has none, so that StoryData fields the
|
|
5146
|
+
* compile options set are recorded. Default: false.
|
|
5147
|
+
*/
|
|
5148
|
+
function toTwee(story, outMode, options = {}) {
|
|
5149
|
+
return withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => passageToTwee(p, outMode)).join("");
|
|
5150
|
+
}
|
|
5151
|
+
function withEffectiveStoryData(story, addStoryData) {
|
|
5152
|
+
const storyData = {
|
|
5153
|
+
name: "StoryData",
|
|
5154
|
+
tags: [],
|
|
5155
|
+
text: marshalStoryData(story)
|
|
5156
|
+
};
|
|
5157
|
+
const index = story.passages.findIndex((p) => p.name === "StoryData");
|
|
5158
|
+
if (index !== -1) return story.passages.map((p, i) => i === index && isStoryDataJSON(p.text) ? {
|
|
5159
|
+
...p,
|
|
5160
|
+
text: storyData.text
|
|
5161
|
+
} : p);
|
|
5162
|
+
if (!addStoryData) return story.passages;
|
|
5163
|
+
const at = story.passages[0]?.name === "StoryTitle" ? 1 : 0;
|
|
5164
|
+
return [
|
|
5165
|
+
...story.passages.slice(0, at),
|
|
5166
|
+
storyData,
|
|
5167
|
+
...story.passages.slice(at)
|
|
5168
|
+
];
|
|
5169
|
+
}
|
|
5170
|
+
/** Whether the text loaded as StoryData. Text that did not is kept as written, for the author to fix. */
|
|
5171
|
+
function isStoryDataJSON(text) {
|
|
5172
|
+
return unmarshalStoryData(createStory(), text) === null;
|
|
4630
5173
|
}
|
|
4631
5174
|
//#endregion
|
|
4632
5175
|
//#region src/modules.ts
|
|
@@ -4663,7 +5206,7 @@ function loadModules(filenames) {
|
|
|
4663
5206
|
function loadModuleTagged(tag, filename) {
|
|
4664
5207
|
const source = readUTF8(filename).trim();
|
|
4665
5208
|
if (source.length === 0) return null;
|
|
4666
|
-
return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${source}</${tag}>`;
|
|
5209
|
+
return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
|
|
4667
5210
|
}
|
|
4668
5211
|
function loadModuleFont(filename) {
|
|
4669
5212
|
const source = readBase64(filename);
|
|
@@ -4698,199 +5241,45 @@ function modifyHead(html, modulePaths, headFile, diagnostics) {
|
|
|
4698
5241
|
return html;
|
|
4699
5242
|
}
|
|
4700
5243
|
//#endregion
|
|
4701
|
-
//#region src/
|
|
4702
|
-
/**
|
|
4703
|
-
* Remote story format fetching, caching, and checksum verification.
|
|
4704
|
-
* Uses the Story Formats Archive (SFA) as the default source.
|
|
4705
|
-
*/
|
|
4706
|
-
const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
|
|
4707
|
-
/** Get the cache directory for downloaded story formats. */
|
|
4708
|
-
function getCacheDir() {
|
|
4709
|
-
const base = process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache");
|
|
4710
|
-
return join(base, "twee-ts", "storyformats");
|
|
4711
|
-
}
|
|
4712
|
-
/** In-memory index cache, keyed by URL. Cleared each compile. */
|
|
4713
|
-
const indexCache = /* @__PURE__ */ new Map();
|
|
4714
|
-
/** Clear the in-memory index cache. */
|
|
4715
|
-
function clearIndexCache() {
|
|
4716
|
-
indexCache.clear();
|
|
4717
|
-
}
|
|
4718
|
-
/** Validate that a JSON value is a valid SFAIndexEntry. */
|
|
4719
|
-
function isValidEntry(val) {
|
|
4720
|
-
if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
|
|
4721
|
-
const obj = val;
|
|
4722
|
-
return typeof obj.name === "string" && typeof obj.version === "string" && typeof obj.checksums === "object" && obj.checksums !== null && !Array.isArray(obj.checksums);
|
|
4723
|
-
}
|
|
4724
|
-
/** Validate that a JSON value conforms to the SFAIndex shape. */
|
|
4725
|
-
function validateSFAIndex(json) {
|
|
4726
|
-
if (typeof json !== "object" || json === null) throw new Error("SFA index is not an object");
|
|
4727
|
-
const obj = json;
|
|
4728
|
-
return {
|
|
4729
|
-
twine1: Array.isArray(obj["twine1"]) ? obj["twine1"].filter(isValidEntry) : [],
|
|
4730
|
-
twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
|
|
4731
|
-
};
|
|
4732
|
-
}
|
|
4733
|
-
/** Generate a format ID from name and version. */
|
|
4734
|
-
function makeFormatId(name, version) {
|
|
4735
|
-
const major = parseSemver(version)?.[0] ?? "0";
|
|
4736
|
-
return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
|
|
4737
|
-
}
|
|
4738
|
-
/** Fetch and parse an SFA index.json, with in-memory caching. */
|
|
4739
|
-
async function fetchIndex(url) {
|
|
4740
|
-
const cached = indexCache.get(url);
|
|
4741
|
-
if (cached) return cached;
|
|
4742
|
-
const res = await fetch(url);
|
|
4743
|
-
if (!res.ok) throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
|
|
4744
|
-
const data = validateSFAIndex(await res.json());
|
|
4745
|
-
indexCache.set(url, data);
|
|
4746
|
-
return data;
|
|
4747
|
-
}
|
|
5244
|
+
//#region src/passage-omission.ts
|
|
4748
5245
|
/**
|
|
4749
|
-
*
|
|
4750
|
-
*
|
|
5246
|
+
* Why the `target` output leaves `passage` out of its passage elements, or `undefined` when it
|
|
5247
|
+
* emits the passage (and so a link or the start can name it).
|
|
4751
5248
|
*/
|
|
4752
|
-
function
|
|
4753
|
-
|
|
4754
|
-
|
|
4755
|
-
|
|
4756
|
-
|
|
4757
|
-
}, {
|
|
4758
|
-
entries: index.twine1 ?? [],
|
|
4759
|
-
formatType: "twine1"
|
|
4760
|
-
}];
|
|
4761
|
-
let bestResult;
|
|
4762
|
-
let bestVersion = null;
|
|
4763
|
-
for (const { entries, formatType } of searchArrays) for (const entry of entries) {
|
|
4764
|
-
if (entry.name.toLowerCase() !== name.toLowerCase()) continue;
|
|
4765
|
-
const have = parseSemver(entry.version);
|
|
4766
|
-
if (!have) continue;
|
|
4767
|
-
if (wanted && semverCompare(have, wanted) === 0) return {
|
|
4768
|
-
entry,
|
|
4769
|
-
formatType
|
|
4770
|
-
};
|
|
4771
|
-
if (wanted === null || have[0] === wanted[0] && semverCompare(have, wanted) >= 0) {
|
|
4772
|
-
if (!bestVersion || semverCompare(have, bestVersion) > 0) {
|
|
4773
|
-
bestVersion = have;
|
|
4774
|
-
bestResult = {
|
|
4775
|
-
entry,
|
|
4776
|
-
formatType
|
|
4777
|
-
};
|
|
4778
|
-
}
|
|
4779
|
-
}
|
|
5249
|
+
function passageOmission(story, passage, target) {
|
|
5250
|
+
switch (target) {
|
|
5251
|
+
case "twine2": return twine2PassageOmission(story, passage);
|
|
5252
|
+
case "twine1": return twine1PassageOmission(passage);
|
|
5253
|
+
default: throw new Error(`unhandled passage output target: ${String(target)}`);
|
|
4780
5254
|
}
|
|
4781
|
-
return bestResult;
|
|
4782
|
-
}
|
|
4783
|
-
/** Verify SHA-256 checksum using Web Crypto API (Node 22 built-in). */
|
|
4784
|
-
async function verifySHA256(content, expectedHex) {
|
|
4785
|
-
const digest = await crypto.subtle.digest("SHA-256", content);
|
|
4786
|
-
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("") === expectedHex.toLowerCase();
|
|
4787
|
-
}
|
|
4788
|
-
/** Derive the download URL for a format.js from the index URL and entry. */
|
|
4789
|
-
function getDownloadUrl(indexUrl, entry, formatType) {
|
|
4790
|
-
return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
|
|
4791
5255
|
}
|
|
4792
|
-
/**
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
4799
|
-
const expected = entry.checksums[checksumKey];
|
|
4800
|
-
if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
|
|
4801
|
-
if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
|
|
5256
|
+
/** Why a passage is left out, as a predicate: `Passage "X" ${describeOmission(o)}`. */
|
|
5257
|
+
function describeOmission(omission) {
|
|
5258
|
+
switch (omission.kind) {
|
|
5259
|
+
case "special-name": return `is the special "${omission.name}" passage`;
|
|
5260
|
+
case "tag": return `is tagged "${omission.tag}"`;
|
|
5261
|
+
case "empty-story-settings": return "is an empty \"StorySettings\" passage";
|
|
5262
|
+
default: throw new Error(`unhandled passage omission: ${JSON.stringify(omission)}`);
|
|
4802
5263
|
}
|
|
4803
|
-
const id = makeFormatId(entry.name, entry.version);
|
|
4804
|
-
const data = parseFormatJSON(text, id);
|
|
4805
|
-
if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
|
|
4806
|
-
const cacheDir = getCacheDir();
|
|
4807
|
-
const formatDir = join(cacheDir, entry.name, entry.version);
|
|
4808
|
-
mkdirSync(formatDir, { recursive: true });
|
|
4809
|
-
const formatPath = join(formatDir, "format.js");
|
|
4810
|
-
writeFileSync(formatPath, text, "utf-8");
|
|
4811
|
-
return {
|
|
4812
|
-
id,
|
|
4813
|
-
filename: formatPath,
|
|
4814
|
-
isTwine2: true,
|
|
4815
|
-
name: data.name,
|
|
4816
|
-
version: data.version,
|
|
4817
|
-
proofing: data.proofing ?? false
|
|
4818
|
-
};
|
|
4819
|
-
}
|
|
4820
|
-
/** Download a direct format.js URL, parse its JSON, cache it, and return StoryFormatInfo. */
|
|
4821
|
-
async function fetchDirectFormat(url) {
|
|
4822
|
-
const res = await fetch(url);
|
|
4823
|
-
if (!res.ok) throw new Error(`Failed to download format from ${url}: ${res.status} ${res.statusText}`);
|
|
4824
|
-
const text = await res.text();
|
|
4825
|
-
const data = parseFormatJSON(text, "direct-url");
|
|
4826
|
-
if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
|
|
4827
|
-
const id = makeFormatId(data.name, data.version);
|
|
4828
|
-
const cacheDir = getCacheDir();
|
|
4829
|
-
const formatDir = join(cacheDir, data.name, data.version);
|
|
4830
|
-
mkdirSync(formatDir, { recursive: true });
|
|
4831
|
-
const formatPath = join(formatDir, "format.js");
|
|
4832
|
-
writeFileSync(formatPath, text, "utf-8");
|
|
4833
|
-
return {
|
|
4834
|
-
id,
|
|
4835
|
-
filename: formatPath,
|
|
4836
|
-
isTwine2: true,
|
|
4837
|
-
name: data.name,
|
|
4838
|
-
version: data.version,
|
|
4839
|
-
proofing: data.proofing ?? false
|
|
4840
|
-
};
|
|
4841
5264
|
}
|
|
5265
|
+
//#endregion
|
|
5266
|
+
//#region src/start-passage.ts
|
|
4842
5267
|
/**
|
|
4843
|
-
*
|
|
4844
|
-
*
|
|
4845
|
-
* 2. Try custom index URLs
|
|
4846
|
-
* 3. Try default SFA indices
|
|
5268
|
+
* Check that `startName` names a passage the `target` output emits.
|
|
5269
|
+
* Returns the error diagnostics to report (empty when the start is usable).
|
|
4847
5270
|
*/
|
|
4848
|
-
|
|
4849
|
-
|
|
4850
|
-
if (
|
|
4851
|
-
|
|
4852
|
-
|
|
4853
|
-
|
|
4854
|
-
|
|
4855
|
-
|
|
4856
|
-
|
|
4857
|
-
|
|
4858
|
-
|
|
4859
|
-
}
|
|
4860
|
-
const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
|
|
4861
|
-
for (const indexUrl of allIndices) try {
|
|
4862
|
-
const result = findEntry(await fetchIndex(indexUrl), name, version);
|
|
4863
|
-
if (result) {
|
|
4864
|
-
const cached = getCachedFormat(result.entry.name, result.entry.version);
|
|
4865
|
-
if (cached) return cached;
|
|
4866
|
-
const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
|
|
4867
|
-
return await fetchAndCacheFormat(result.entry, downloadUrl);
|
|
4868
|
-
}
|
|
4869
|
-
} catch (e) {
|
|
4870
|
-
lastError = e instanceof Error ? e : new Error(String(e));
|
|
4871
|
-
}
|
|
4872
|
-
if (lastError) throw lastError;
|
|
4873
|
-
}
|
|
4874
|
-
/** Check if a format is already in the local cache. */
|
|
4875
|
-
function getCachedFormat(name, version) {
|
|
4876
|
-
const formatPath = join(getCacheDir(), name, version, "format.js");
|
|
4877
|
-
try {
|
|
4878
|
-
if (!existsSync(formatPath)) return void 0;
|
|
4879
|
-
const source = readFileSync(formatPath, "utf-8");
|
|
4880
|
-
const id = makeFormatId(name, version);
|
|
4881
|
-
const data = parseFormatJSON(source, id);
|
|
4882
|
-
if (!data) return void 0;
|
|
4883
|
-
return {
|
|
4884
|
-
id,
|
|
4885
|
-
filename: formatPath,
|
|
4886
|
-
isTwine2: true,
|
|
4887
|
-
name: data.name,
|
|
4888
|
-
version: data.version,
|
|
4889
|
-
proofing: data.proofing ?? false
|
|
4890
|
-
};
|
|
4891
|
-
} catch {
|
|
4892
|
-
return;
|
|
4893
|
-
}
|
|
5271
|
+
function startPassageDiagnostics(story, startName, target) {
|
|
5272
|
+
const passage = story.passages.find((p) => p.name === startName);
|
|
5273
|
+
if (passage === void 0) return [{
|
|
5274
|
+
level: "error",
|
|
5275
|
+
message: `Starting passage "${startName}" not found.`
|
|
5276
|
+
}];
|
|
5277
|
+
const omission = passageOmission(story, passage, target);
|
|
5278
|
+
if (omission === void 0) return [];
|
|
5279
|
+
return [{
|
|
5280
|
+
level: "error",
|
|
5281
|
+
message: `Starting passage "${startName}" ${describeOmission(omission)}, so it is left out of the story data. Choose a story passage.`
|
|
5282
|
+
}];
|
|
4894
5283
|
}
|
|
4895
5284
|
//#endregion
|
|
4896
5285
|
//#region src/compiler.ts
|
|
@@ -4914,11 +5303,20 @@ async function compile(options) {
|
|
|
4914
5303
|
/**
|
|
4915
5304
|
* Compile with incremental caching support.
|
|
4916
5305
|
* Plugins and advanced users can manage their own cache and changed-file tracking.
|
|
5306
|
+
*
|
|
5307
|
+
* Without `changedFiles`, a cached file is reused while its modification time is unchanged.
|
|
5308
|
+
* With it, a file it names (by the path source discovery gives it, relative to the working
|
|
5309
|
+
* directory) is always reparsed, whatever its modification time, and every other cached
|
|
5310
|
+
* file is reused as it is.
|
|
4917
5311
|
*/
|
|
4918
5312
|
async function compileIncremental(options, cache, changedFiles) {
|
|
4919
5313
|
return buildOutput(options, cache, changedFiles);
|
|
4920
5314
|
}
|
|
4921
|
-
|
|
5315
|
+
/**
|
|
5316
|
+
* `outFile`: the file the build is written to, which source and module discovery
|
|
5317
|
+
* skip (as Tweego does), so an output inside a source folder is never loaded back.
|
|
5318
|
+
*/
|
|
5319
|
+
async function buildOutput(options, cache, changedFiles, outFile) {
|
|
4922
5320
|
const diagnostics = [];
|
|
4923
5321
|
const outputMode = options.outputMode ?? "html";
|
|
4924
5322
|
const trim = options.trim ?? true;
|
|
@@ -4931,7 +5329,7 @@ async function buildOutput(options, cache, changedFiles) {
|
|
|
4931
5329
|
const inlineSources = [];
|
|
4932
5330
|
for (const source of options.sources) if (typeof source === "string") filePaths.push(source);
|
|
4933
5331
|
else inlineSources.push(source);
|
|
4934
|
-
const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths,
|
|
5332
|
+
const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths, outFile, options.exclude);
|
|
4935
5333
|
diagnostics.push(...sourcePathDiagnostics);
|
|
4936
5334
|
const story = createStory();
|
|
4937
5335
|
const processedFiles = /* @__PURE__ */ new Set();
|
|
@@ -4949,40 +5347,19 @@ async function buildOutput(options, cache, changedFiles) {
|
|
|
4949
5347
|
}, diagnostics);
|
|
4950
5348
|
if (options.tagAliases) story.passages = applyTagAliases(story.passages, options.tagAliases);
|
|
4951
5349
|
let format;
|
|
4952
|
-
|
|
4953
|
-
|
|
4954
|
-
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
4958
|
-
if (!format && !noRemote) {
|
|
4959
|
-
const remoteName = story.twine2.format || formatId;
|
|
4960
|
-
const remoteVersion = story.twine2.formatVersion || "";
|
|
4961
|
-
try {
|
|
4962
|
-
format = await resolveRemoteFormat(remoteName, remoteVersion, options.formatIndices, options.formatUrls);
|
|
4963
|
-
} catch (e) {
|
|
4964
|
-
diagnostics.push({
|
|
4965
|
-
level: "warning",
|
|
4966
|
-
message: `Remote format fetch failed for "${remoteName}": ${e instanceof Error ? e.message : String(e)}`
|
|
4967
|
-
});
|
|
4968
|
-
}
|
|
4969
|
-
}
|
|
4970
|
-
if (!format) {
|
|
4971
|
-
const reason = noRemote ? " (remote fetching disabled)" : "";
|
|
4972
|
-
diagnostics.push({
|
|
4973
|
-
level: "error",
|
|
4974
|
-
message: `Story format "${formatId}" is not available${reason}. Found: ${[...formats.keys()].join(", ") || "none"}`
|
|
4975
|
-
});
|
|
4976
|
-
}
|
|
4977
|
-
}
|
|
4978
|
-
const startName = options.startPassage || story.twine2.start || DEFAULT_START_NAME;
|
|
5350
|
+
if (outputMode === "html") format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
|
|
5351
|
+
...options,
|
|
5352
|
+
noRemote
|
|
5353
|
+
}, diagnostics);
|
|
5354
|
+
if (options.startPassage) story.twine2.start = options.startPassage;
|
|
5355
|
+
const startName = story.twine2.start || DEFAULT_START_NAME;
|
|
4979
5356
|
if (testMode) story.twine2.options.set("debug", true);
|
|
4980
5357
|
ensureIFID(story, diagnostics);
|
|
4981
5358
|
let output;
|
|
4982
5359
|
switch (outputMode) {
|
|
4983
5360
|
case "twee3":
|
|
4984
5361
|
case "twee1":
|
|
4985
|
-
output = toTwee(story, outputMode);
|
|
5362
|
+
output = toTwee(story, outputMode, { addStoryData: Boolean(options.startPassage) || testMode });
|
|
4986
5363
|
break;
|
|
4987
5364
|
case "twine2-archive":
|
|
4988
5365
|
output = toTwine2Archive(story, startName, { sourceInfo });
|
|
@@ -4994,10 +5371,7 @@ async function buildOutput(options, cache, changedFiles) {
|
|
|
4994
5371
|
output = storyToJSON(story);
|
|
4995
5372
|
break;
|
|
4996
5373
|
case "html": {
|
|
4997
|
-
|
|
4998
|
-
level: "error",
|
|
4999
|
-
message: `Starting passage "${startName}" not found.`
|
|
5000
|
-
});
|
|
5374
|
+
diagnostics.push(...startPassageDiagnostics(story, startName, format?.isTwine2 === false ? "twine1" : "twine2"));
|
|
5001
5375
|
if (!format) throw new TweeTsError("No story format available for HTML output.", diagnostics);
|
|
5002
5376
|
if (format.isTwine2) output = toTwine2HTML(story, format, startName, { sourceInfo });
|
|
5003
5377
|
else {
|
|
@@ -5007,7 +5381,7 @@ async function buildOutput(options, cache, changedFiles) {
|
|
|
5007
5381
|
});
|
|
5008
5382
|
output = toTwine1HTML(story, format, startName);
|
|
5009
5383
|
}
|
|
5010
|
-
const modules = getFilenames(options.modules ?? []);
|
|
5384
|
+
const modules = getFilenames(options.modules ?? [], outFile);
|
|
5011
5385
|
diagnostics.push(...modules.diagnostics);
|
|
5012
5386
|
output = modifyHead(output, modules.filenames, options.headFile, diagnostics);
|
|
5013
5387
|
break;
|
|
@@ -5085,6 +5459,35 @@ function storyToJSON(story) {
|
|
|
5085
5459
|
return JSON.stringify(obj, null, 2);
|
|
5086
5460
|
}
|
|
5087
5461
|
//#endregion
|
|
5088
|
-
|
|
5462
|
+
//#region src/plugins/diagnostics.ts
|
|
5463
|
+
function formatDiagnostic(d) {
|
|
5464
|
+
return `${d.file ? `${d.file}${d.line ? `:${d.line}` : ""}: ` : ""}${d.file && d.line ? d.message.replace(/^line \d+: /, "") : d.message}`;
|
|
5465
|
+
}
|
|
5466
|
+
/** Returns the warnings; throws a LocatedError when the compile reported errors. */
|
|
5467
|
+
function splitDiagnostics(result) {
|
|
5468
|
+
const errors = result.diagnostics.filter((d) => d.level === "error");
|
|
5469
|
+
if (errors.length === 0) return result.diagnostics.filter((d) => d.level === "warning");
|
|
5470
|
+
const error = new Error(errors.map(formatDiagnostic).join("\n"));
|
|
5471
|
+
const [first] = errors;
|
|
5472
|
+
if (first?.file) {
|
|
5473
|
+
error.id = first.file;
|
|
5474
|
+
error.loc = {
|
|
5475
|
+
file: first.file,
|
|
5476
|
+
line: first.line ?? 1,
|
|
5477
|
+
column: 1
|
|
5478
|
+
};
|
|
5479
|
+
}
|
|
5480
|
+
throw error;
|
|
5481
|
+
}
|
|
5482
|
+
/** An error thrown by the compiler or bundler, with a TweeTsError's error diagnostics folded into its message. */
|
|
5483
|
+
function fatalError(e) {
|
|
5484
|
+
if (e instanceof TweeTsError) {
|
|
5485
|
+
const errors = e.diagnostics.filter((d) => d.level === "error").map(formatDiagnostic);
|
|
5486
|
+
return new Error([...errors, e.message].join("\n"));
|
|
5487
|
+
}
|
|
5488
|
+
return e instanceof Error ? e : new Error(String(e));
|
|
5489
|
+
}
|
|
5490
|
+
//#endregion
|
|
5491
|
+
export { compileIncremental as a, mediaTypeFromFilename as c, compile as i, formatDiagnostic as n, getFilenames as o, splitDiagnostics as r, isExcluded as s, fatalError as t };
|
|
5089
5492
|
|
|
5090
|
-
//# sourceMappingURL=
|
|
5493
|
+
//# sourceMappingURL=diagnostics-DZNS7W41.js.map
|