@rohal12/twee-ts 1.16.0 → 1.16.1
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 +1184 -546
- package/dist/bin/twee-ts.js.map +1 -1
- package/dist/{compiler-BiDNwR9q.js → compiler-Bmpbelqj.js} +596 -270
- package/dist/compiler-Bmpbelqj.js.map +1 -0
- package/dist/index.cjs +1686 -1061
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +28 -7
- package/dist/index.d.ts +28 -7
- package/dist/index.js +1688 -1063
- package/dist/index.js.map +1 -1
- package/dist/plugins/rollup.d.ts +6 -0
- package/dist/plugins/rollup.js +16 -1
- package/dist/plugins/rollup.js.map +1 -1
- package/dist/plugins/vite.js +46 -6
- package/dist/plugins/vite.js.map +1 -1
- package/package.json +1 -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({
|
|
@@ -4030,6 +4470,7 @@ function decompileTwine2(storyData, story, diagnostics) {
|
|
|
4030
4470
|
});
|
|
4031
4471
|
else story.twine2.zoom = parsed;
|
|
4032
4472
|
}
|
|
4473
|
+
if (attrs["tags"]) story.twine2.tags = attrs["tags"];
|
|
4033
4474
|
if (attrs["format"]) story.twine2.format = attrs["format"];
|
|
4034
4475
|
if (attrs["format-version"]) story.twine2.formatVersion = attrs["format-version"];
|
|
4035
4476
|
if (attrs["options"]) for (const opt of attrs["options"].split(/\s+/).filter((s) => s.length > 0)) story.twine2.options.set(opt, true);
|
|
@@ -4089,22 +4530,32 @@ function decompileTwine2(storyData, story, diagnostics) {
|
|
|
4089
4530
|
}, diagnostics);
|
|
4090
4531
|
}
|
|
4091
4532
|
function decompileTwine1(storeArea, story, diagnostics) {
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4533
|
+
const passages = (storeArea.children ?? []).filter(isTiddler).map(tiddlerToPassage);
|
|
4534
|
+
const obfuscated = isRot13Obfuscated(passages);
|
|
4535
|
+
for (const p of passages) storyAdd(story, obfuscated && p.name !== "StorySettings" ? {
|
|
4536
|
+
...p,
|
|
4537
|
+
text: rot13(p.text)
|
|
4538
|
+
} : p, diagnostics);
|
|
4539
|
+
}
|
|
4540
|
+
function isTiddler(node) {
|
|
4541
|
+
return node.type === "tag" && node.name === "div" && node.attribs !== void 0 && "tiddler" in node.attribs;
|
|
4542
|
+
}
|
|
4543
|
+
function tiddlerToPassage(node) {
|
|
4544
|
+
const nodeAttrs = node.attribs ?? {};
|
|
4545
|
+
const passage = {
|
|
4546
|
+
name: nodeAttrs["tiddler"] ?? "",
|
|
4547
|
+
tags: nodeAttrs["tags"] ? nodeAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [],
|
|
4548
|
+
text: tiddlerUnescape(getTextContent(node)).trim()
|
|
4549
|
+
};
|
|
4550
|
+
const position = nodeAttrs["twine-position"];
|
|
4551
|
+
if (position) passage.metadata = { position };
|
|
4552
|
+
return passage;
|
|
4553
|
+
}
|
|
4554
|
+
/** Whether the StorySettings tiddlers turn on ROT13 obfuscation, read the way the writer reads them. */
|
|
4555
|
+
function isRot13Obfuscated(passages) {
|
|
4556
|
+
const settings = createStory();
|
|
4557
|
+
for (const p of passages) if (p.name === "StorySettings") unmarshalStorySettings(settings, p.text, []);
|
|
4558
|
+
return settings.twine1.settings.get("obfuscate") === "rot13";
|
|
4108
4559
|
}
|
|
4109
4560
|
function findElement(node, tagName) {
|
|
4110
4561
|
if (node.type === "tag" && node.name === tagName) return node;
|
|
@@ -4227,7 +4678,7 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
|
|
|
4227
4678
|
function loadInlineSources(story, sources, opts, diagnostics) {
|
|
4228
4679
|
for (const source of sources) {
|
|
4229
4680
|
if (typeof source === "string") continue;
|
|
4230
|
-
const content = typeof source.content === "string" ? source.content : source.content.toString("utf-8");
|
|
4681
|
+
const content = normalizeSourceText(typeof source.content === "string" ? source.content : source.content.toString("utf-8"));
|
|
4231
4682
|
const ext = normalizedFileExt(source.filename);
|
|
4232
4683
|
switch (ext) {
|
|
4233
4684
|
case "":
|
|
@@ -4314,12 +4765,29 @@ function parseFontFile(filename) {
|
|
|
4314
4765
|
};
|
|
4315
4766
|
}
|
|
4316
4767
|
function parseHTMLFile(filename) {
|
|
4317
|
-
const
|
|
4768
|
+
const { story, diagnostics } = decompileHTML(readUTF8(filename));
|
|
4318
4769
|
return {
|
|
4319
|
-
passages:
|
|
4320
|
-
|
|
4770
|
+
passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
|
|
4771
|
+
name: "StoryTitle",
|
|
4772
|
+
tags: [],
|
|
4773
|
+
text: story.name
|
|
4774
|
+
}, ...story.passages] : story.passages,
|
|
4775
|
+
diagnostics: [...diagnostics]
|
|
4321
4776
|
};
|
|
4322
4777
|
}
|
|
4778
|
+
/**
|
|
4779
|
+
* Identity of the options that affect how a file parses, stored on its cache entry.
|
|
4780
|
+
* Only Twee files depend on them; every other file type yields the same key for all options.
|
|
4781
|
+
*/
|
|
4782
|
+
function parseOptionsKey(filename, opts) {
|
|
4783
|
+
const ext = normalizedFileExt(filename);
|
|
4784
|
+
const twee2File = ext === "tw2" || ext === "twee2";
|
|
4785
|
+
if (!twee2File && ext !== "tw" && ext !== "twee") return "";
|
|
4786
|
+
return JSON.stringify({
|
|
4787
|
+
trim: opts.trim ?? true,
|
|
4788
|
+
twee2Compat: twee2File || (opts.twee2Compat ?? false)
|
|
4789
|
+
});
|
|
4790
|
+
}
|
|
4323
4791
|
function parseFile(filename, opts) {
|
|
4324
4792
|
switch (normalizedFileExt(filename)) {
|
|
4325
4793
|
case "tw":
|
|
@@ -4399,7 +4867,9 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
4399
4867
|
continue;
|
|
4400
4868
|
}
|
|
4401
4869
|
currentFiles.add(filename);
|
|
4402
|
-
const
|
|
4870
|
+
const optionsKey = parseOptionsKey(filename, opts);
|
|
4871
|
+
const entry = cache.get(filename);
|
|
4872
|
+
const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
|
|
4403
4873
|
if (changedFiles && cached && !changedFiles.has(filename)) {
|
|
4404
4874
|
diagnostics.push(...cached.diagnostics);
|
|
4405
4875
|
for (const p of cached.passages) storyAdd(story, p, diagnostics);
|
|
@@ -4423,6 +4893,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
4423
4893
|
if (!result) continue;
|
|
4424
4894
|
cache.set(filename, {
|
|
4425
4895
|
mtimeMs,
|
|
4896
|
+
parseOptionsKey: optionsKey,
|
|
4426
4897
|
passages: result.passages,
|
|
4427
4898
|
diagnostics: result.diagnostics
|
|
4428
4899
|
});
|
|
@@ -4486,11 +4957,39 @@ function toTwine2HTML(story, format, startName, options) {
|
|
|
4486
4957
|
template = template.replaceAll("{{STORY_NAME}}", () => name);
|
|
4487
4958
|
}
|
|
4488
4959
|
if (template.includes("{{STORY_DATA}}")) {
|
|
4489
|
-
const data = getTwine2DataChunk(
|
|
4960
|
+
const data = getTwine2DataChunk({
|
|
4961
|
+
...story,
|
|
4962
|
+
twine2: {
|
|
4963
|
+
...story.twine2,
|
|
4964
|
+
format: format.name,
|
|
4965
|
+
formatVersion: format.version
|
|
4966
|
+
}
|
|
4967
|
+
}, startName, options);
|
|
4490
4968
|
template = template.replace("{{STORY_DATA}}", () => data);
|
|
4491
4969
|
}
|
|
4492
4970
|
return template;
|
|
4493
4971
|
}
|
|
4972
|
+
const OMITTING_TAGS = [
|
|
4973
|
+
"Twine.private",
|
|
4974
|
+
"script",
|
|
4975
|
+
"stylesheet"
|
|
4976
|
+
];
|
|
4977
|
+
/**
|
|
4978
|
+
* Why Twine 2 output leaves a passage out of its `<tw-passagedata>` elements,
|
|
4979
|
+
* or `undefined` when the passage is emitted (and so can be the starting passage).
|
|
4980
|
+
*/
|
|
4981
|
+
function twine2PassageOmission(story, p) {
|
|
4982
|
+
if (p.name === "StoryTitle" || p.name === "StoryData") return {
|
|
4983
|
+
kind: "special-name",
|
|
4984
|
+
name: p.name
|
|
4985
|
+
};
|
|
4986
|
+
const tag = OMITTING_TAGS.find((t) => hasTag(p, t));
|
|
4987
|
+
if (tag !== void 0) return {
|
|
4988
|
+
kind: "tag",
|
|
4989
|
+
tag
|
|
4990
|
+
};
|
|
4991
|
+
if (p.name === "StorySettings" && story.twine1.settings.size === 0) return { kind: "empty-story-settings" };
|
|
4992
|
+
}
|
|
4494
4993
|
function getTwine2DataChunk(story, startName, options) {
|
|
4495
4994
|
const parts = [];
|
|
4496
4995
|
let startID = "";
|
|
@@ -4513,7 +5012,7 @@ function getTwine2DataChunk(story, startName, options) {
|
|
|
4513
5012
|
pidS++;
|
|
4514
5013
|
}
|
|
4515
5014
|
}
|
|
4516
|
-
parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContent}</style>`);
|
|
5015
|
+
parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContentEscape(styleContent)}</style>`);
|
|
4517
5016
|
let scriptContent = "";
|
|
4518
5017
|
if (scripts.length === 1) scriptContent = scripts[0]?.text ?? "";
|
|
4519
5018
|
else if (scripts.length > 1) {
|
|
@@ -4525,7 +5024,7 @@ function getTwine2DataChunk(story, startName, options) {
|
|
|
4525
5024
|
pidS++;
|
|
4526
5025
|
}
|
|
4527
5026
|
}
|
|
4528
|
-
parts.push(`<script role="script" id="twine-user-script" type="text/twine-javascript">${scriptContent}<\/script>`);
|
|
5027
|
+
parts.push(`<script role="script" id="twine-user-script" type="text/twine-javascript">${scriptContentEscape(scriptContent)}<\/script>`);
|
|
4529
5028
|
const validTagColors = /* @__PURE__ */ new Set([
|
|
4530
5029
|
"gray",
|
|
4531
5030
|
"red",
|
|
@@ -4539,8 +5038,7 @@ function getTwine2DataChunk(story, startName, options) {
|
|
|
4539
5038
|
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
5039
|
pid = 1;
|
|
4541
5040
|
for (const p of story.passages) {
|
|
4542
|
-
if (
|
|
4543
|
-
if (p.name === "StorySettings" && story.twine1.settings.size === 0) continue;
|
|
5041
|
+
if (twine2PassageOmission(story, p) !== void 0) continue;
|
|
4544
5042
|
parts.push(passageToPassagedata(p, pid, options));
|
|
4545
5043
|
if (startName === p.name) startID = String(pid);
|
|
4546
5044
|
pid++;
|
|
@@ -4596,12 +5094,21 @@ function toTwine1HTML(story, format, startName) {
|
|
|
4596
5094
|
}
|
|
4597
5095
|
return template;
|
|
4598
5096
|
}
|
|
5097
|
+
/**
|
|
5098
|
+
* Why Twine 1 output leaves a passage out of its tiddlers, or `undefined` when the passage is emitted.
|
|
5099
|
+
*/
|
|
5100
|
+
function twine1PassageOmission(p) {
|
|
5101
|
+
return hasTag(p, "Twine.private") ? {
|
|
5102
|
+
kind: "tag",
|
|
5103
|
+
tag: "Twine.private"
|
|
5104
|
+
} : void 0;
|
|
5105
|
+
}
|
|
4599
5106
|
function getTwine1PassageChunk(story) {
|
|
4600
5107
|
const obfuscateRot13 = story.twine1.settings.get("obfuscate") === "rot13";
|
|
4601
5108
|
let data = "";
|
|
4602
5109
|
let count = 0;
|
|
4603
5110
|
for (const p of story.passages) {
|
|
4604
|
-
if (
|
|
5111
|
+
if (twine1PassageOmission(p) !== void 0) continue;
|
|
4605
5112
|
count++;
|
|
4606
5113
|
data += passageToTiddler(p, count, obfuscateRot13);
|
|
4607
5114
|
}
|
|
@@ -4663,7 +5170,7 @@ function loadModules(filenames) {
|
|
|
4663
5170
|
function loadModuleTagged(tag, filename) {
|
|
4664
5171
|
const source = readUTF8(filename).trim();
|
|
4665
5172
|
if (source.length === 0) return null;
|
|
4666
|
-
return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${source}</${tag}>`;
|
|
5173
|
+
return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
|
|
4667
5174
|
}
|
|
4668
5175
|
function loadModuleFont(filename) {
|
|
4669
5176
|
const source = readBase64(filename);
|
|
@@ -4698,199 +5205,38 @@ function modifyHead(html, modulePaths, headFile, diagnostics) {
|
|
|
4698
5205
|
return html;
|
|
4699
5206
|
}
|
|
4700
5207
|
//#endregion
|
|
4701
|
-
//#region src/
|
|
4702
|
-
|
|
4703
|
-
|
|
4704
|
-
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
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
|
-
}
|
|
4748
|
-
/**
|
|
4749
|
-
* Find the best matching entry in an SFA index.
|
|
4750
|
-
* Exact version preferred, then highest version with same major.
|
|
4751
|
-
*/
|
|
4752
|
-
function findEntry(index, name, version) {
|
|
4753
|
-
const wanted = parseSemver(version);
|
|
4754
|
-
const searchArrays = [{
|
|
4755
|
-
entries: index.twine2 ?? [],
|
|
4756
|
-
formatType: "twine2"
|
|
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
|
-
}
|
|
5208
|
+
//#region src/start-passage.ts
|
|
5209
|
+
function describeOmission(omission) {
|
|
5210
|
+
switch (omission.kind) {
|
|
5211
|
+
case "special-name": return `is the special "${omission.name}" passage`;
|
|
5212
|
+
case "tag": return `is tagged "${omission.tag}"`;
|
|
5213
|
+
case "empty-story-settings": return "is an empty \"StorySettings\" passage";
|
|
5214
|
+
default: throw new Error(`unhandled passage omission: ${JSON.stringify(omission)}`);
|
|
4780
5215
|
}
|
|
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
5216
|
}
|
|
4792
|
-
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
|
|
4798
|
-
if (checksumKey) {
|
|
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}`);
|
|
5217
|
+
function omissionFor(story, passage, target) {
|
|
5218
|
+
switch (target) {
|
|
5219
|
+
case "twine2": return twine2PassageOmission(story, passage);
|
|
5220
|
+
case "twine1": return twine1PassageOmission(passage);
|
|
5221
|
+
default: throw new Error(`unhandled start passage target: ${String(target)}`);
|
|
4802
5222
|
}
|
|
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
5223
|
}
|
|
4842
5224
|
/**
|
|
4843
|
-
*
|
|
4844
|
-
*
|
|
4845
|
-
* 2. Try custom index URLs
|
|
4846
|
-
* 3. Try default SFA indices
|
|
5225
|
+
* Check that `startName` names a passage the `target` output emits.
|
|
5226
|
+
* Returns the error diagnostics to report (empty when the start is usable).
|
|
4847
5227
|
*/
|
|
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
|
-
}
|
|
5228
|
+
function startPassageDiagnostics(story, startName, target) {
|
|
5229
|
+
const passage = story.passages.find((p) => p.name === startName);
|
|
5230
|
+
if (passage === void 0) return [{
|
|
5231
|
+
level: "error",
|
|
5232
|
+
message: `Starting passage "${startName}" not found.`
|
|
5233
|
+
}];
|
|
5234
|
+
const omission = omissionFor(story, passage, target);
|
|
5235
|
+
if (omission === void 0) return [];
|
|
5236
|
+
return [{
|
|
5237
|
+
level: "error",
|
|
5238
|
+
message: `Starting passage "${startName}" ${describeOmission(omission)}, so it is left out of the story data. Choose a story passage.`
|
|
5239
|
+
}];
|
|
4894
5240
|
}
|
|
4895
5241
|
//#endregion
|
|
4896
5242
|
//#region src/compiler.ts
|
|
@@ -4918,7 +5264,11 @@ async function compile(options) {
|
|
|
4918
5264
|
async function compileIncremental(options, cache, changedFiles) {
|
|
4919
5265
|
return buildOutput(options, cache, changedFiles);
|
|
4920
5266
|
}
|
|
4921
|
-
|
|
5267
|
+
/**
|
|
5268
|
+
* `outFile`: the file the build is written to, which source and module discovery
|
|
5269
|
+
* skip (as Tweego does), so an output inside a source folder is never loaded back.
|
|
5270
|
+
*/
|
|
5271
|
+
async function buildOutput(options, cache, changedFiles, outFile) {
|
|
4922
5272
|
const diagnostics = [];
|
|
4923
5273
|
const outputMode = options.outputMode ?? "html";
|
|
4924
5274
|
const trim = options.trim ?? true;
|
|
@@ -4931,7 +5281,7 @@ async function buildOutput(options, cache, changedFiles) {
|
|
|
4931
5281
|
const inlineSources = [];
|
|
4932
5282
|
for (const source of options.sources) if (typeof source === "string") filePaths.push(source);
|
|
4933
5283
|
else inlineSources.push(source);
|
|
4934
|
-
const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths,
|
|
5284
|
+
const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths, outFile, options.exclude);
|
|
4935
5285
|
diagnostics.push(...sourcePathDiagnostics);
|
|
4936
5286
|
const story = createStory();
|
|
4937
5287
|
const processedFiles = /* @__PURE__ */ new Set();
|
|
@@ -4949,33 +5299,12 @@ async function buildOutput(options, cache, changedFiles) {
|
|
|
4949
5299
|
}, diagnostics);
|
|
4950
5300
|
if (options.tagAliases) story.passages = applyTagAliases(story.passages, options.tagAliases);
|
|
4951
5301
|
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;
|
|
5302
|
+
if (outputMode === "html") format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
|
|
5303
|
+
...options,
|
|
5304
|
+
noRemote
|
|
5305
|
+
}, diagnostics);
|
|
5306
|
+
if (options.startPassage) story.twine2.start = options.startPassage;
|
|
5307
|
+
const startName = story.twine2.start || DEFAULT_START_NAME;
|
|
4979
5308
|
if (testMode) story.twine2.options.set("debug", true);
|
|
4980
5309
|
ensureIFID(story, diagnostics);
|
|
4981
5310
|
let output;
|
|
@@ -4994,10 +5323,7 @@ async function buildOutput(options, cache, changedFiles) {
|
|
|
4994
5323
|
output = storyToJSON(story);
|
|
4995
5324
|
break;
|
|
4996
5325
|
case "html": {
|
|
4997
|
-
|
|
4998
|
-
level: "error",
|
|
4999
|
-
message: `Starting passage "${startName}" not found.`
|
|
5000
|
-
});
|
|
5326
|
+
diagnostics.push(...startPassageDiagnostics(story, startName, format?.isTwine2 === false ? "twine1" : "twine2"));
|
|
5001
5327
|
if (!format) throw new TweeTsError("No story format available for HTML output.", diagnostics);
|
|
5002
5328
|
if (format.isTwine2) output = toTwine2HTML(story, format, startName, { sourceInfo });
|
|
5003
5329
|
else {
|
|
@@ -5007,7 +5333,7 @@ async function buildOutput(options, cache, changedFiles) {
|
|
|
5007
5333
|
});
|
|
5008
5334
|
output = toTwine1HTML(story, format, startName);
|
|
5009
5335
|
}
|
|
5010
|
-
const modules = getFilenames(options.modules ?? []);
|
|
5336
|
+
const modules = getFilenames(options.modules ?? [], outFile);
|
|
5011
5337
|
diagnostics.push(...modules.diagnostics);
|
|
5012
5338
|
output = modifyHead(output, modules.filenames, options.headFile, diagnostics);
|
|
5013
5339
|
break;
|
|
@@ -5087,4 +5413,4 @@ function storyToJSON(story) {
|
|
|
5087
5413
|
//#endregion
|
|
5088
5414
|
export { isExcluded as a, getFilenames as i, compile as n, mediaTypeFromFilename as o, compileIncremental as r, TweeTsError as t };
|
|
5089
5415
|
|
|
5090
|
-
//# sourceMappingURL=compiler-
|
|
5416
|
+
//# sourceMappingURL=compiler-Bmpbelqj.js.map
|