@rohal12/twee-ts 1.15.4 → 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 +1215 -540
- package/dist/bin/twee-ts.js.map +1 -1
- package/dist/{compiler-D3V_svy2.js → compiler-Bmpbelqj.js} +615 -273
- package/dist/compiler-Bmpbelqj.js.map +1 -0
- package/dist/index.cjs +1726 -1049
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +34 -7
- package/dist/index.d.ts +34 -7
- package/dist/index.js +1711 -1056
- package/dist/index.js.map +1 -1
- package/dist/plugins/rollup.d.ts +7 -1
- package/dist/plugins/rollup.js +16 -1
- package/dist/plugins/rollup.js.map +1 -1
- package/dist/plugins/vite.d.ts +1 -1
- package/dist/plugins/vite.js +67 -19
- package/dist/plugins/vite.js.map +1 -1
- package/dist/{types-BFyb40Zq.d.ts → types-dR5vTVu2.d.ts} +5 -0
- package/package.json +1 -1
- package/schemas/twee-ts.config.schema.json +5 -0
- package/dist/compiler-D3V_svy2.js.map +0 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
-
import
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import * as nodePath from "node:path";
|
|
3
|
+
import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
4
5
|
import { homedir } from "node:os";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
@@ -103,6 +104,61 @@ function commentSanitize(s) {
|
|
|
103
104
|
function htmlCommentSanitize(s) {
|
|
104
105
|
return s.replace(/-->/g, "-- >");
|
|
105
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
|
+
}
|
|
106
162
|
/** Apply ROT13 encoding to a string (only affects [A-Za-z]). */
|
|
107
163
|
function rot13(s) {
|
|
108
164
|
if (s.length === 0) return s;
|
|
@@ -557,11 +613,25 @@ function fontFormatHint(ext) {
|
|
|
557
613
|
* Ported from filesystem.go.
|
|
558
614
|
*/
|
|
559
615
|
/**
|
|
616
|
+
* Whether `filename` matches one of the `exclude` globs. The globs are matched
|
|
617
|
+
* against the file's path relative to the working directory, the path
|
|
618
|
+
* getFilenames reports; an absolute `filename` is made relative first. A leading
|
|
619
|
+
* `./` in a glob is dropped. Matching is Node's `path.matchesGlob`.
|
|
620
|
+
*/
|
|
621
|
+
function isExcluded(filename, exclude) {
|
|
622
|
+
if (exclude.length === 0) return false;
|
|
623
|
+
const { matchesGlob } = nodePath;
|
|
624
|
+
if (typeof matchesGlob !== "function") throw new Error(`The exclude option needs Node.js 22.5 or newer (found ${process.version}).`);
|
|
625
|
+
const rel = relative(process.cwd(), resolve(filename)) || filename;
|
|
626
|
+
return exclude.some((pattern) => matchesGlob(rel, pattern.replace(/^\.\//, "")));
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
560
629
|
* Recursively walk directories, collecting regular file paths.
|
|
561
|
-
* Filters out the output file to prevent circular compilation
|
|
630
|
+
* Filters out the output file to prevent circular compilation, and the files
|
|
631
|
+
* that match an `exclude` glob (see isExcluded).
|
|
562
632
|
* Like Tweego, a path that cannot be read is reported as a warning and skipped.
|
|
563
633
|
*/
|
|
564
|
-
function getFilenames(pathnames, outFilename) {
|
|
634
|
+
function getFilenames(pathnames, outFilename, exclude = []) {
|
|
565
635
|
const filenames = [];
|
|
566
636
|
const diagnostics = [];
|
|
567
637
|
const absOutFile = outFilename ? resolve(outFilename) : "";
|
|
@@ -583,6 +653,7 @@ function getFilenames(pathnames, outFilename) {
|
|
|
583
653
|
const abs = resolve(pathname);
|
|
584
654
|
if (abs === absOutFile) return;
|
|
585
655
|
const rel = relative(process.cwd(), abs);
|
|
656
|
+
if (isExcluded(rel || abs, exclude)) return;
|
|
586
657
|
filenames.push(rel || abs);
|
|
587
658
|
} else if (stat.isDirectory()) {
|
|
588
659
|
let entries;
|
|
@@ -602,16 +673,25 @@ function getFilenames(pathnames, outFilename) {
|
|
|
602
673
|
};
|
|
603
674
|
}
|
|
604
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
|
|
605
688
|
//#region src/util.ts
|
|
606
689
|
/**
|
|
607
690
|
* Shared file I/O utilities.
|
|
608
691
|
*/
|
|
609
692
|
/** Read a file as UTF-8 with BOM stripping and line ending normalization. */
|
|
610
693
|
function readUTF8(filename) {
|
|
611
|
-
|
|
612
|
-
if (content.charCodeAt(0) === 65279) content = content.slice(1);
|
|
613
|
-
content = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
614
|
-
return content;
|
|
694
|
+
return normalizeSourceText(readFileSync(filename, "utf-8"));
|
|
615
695
|
}
|
|
616
696
|
/** Read a file as base64. */
|
|
617
697
|
function readBase64(filename) {
|
|
@@ -641,16 +721,28 @@ function relaxedJSONParse(json) {
|
|
|
641
721
|
fixed = fixed.replace(/,\s*([}\]])/g, "$1");
|
|
642
722
|
return JSON.parse(fixed);
|
|
643
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
|
+
}
|
|
644
732
|
/**
|
|
645
733
|
* Parse the Twine 2 format.js JSON chunk.
|
|
646
|
-
* 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.
|
|
647
737
|
* Per spec, the name key is Optional.
|
|
738
|
+
*
|
|
739
|
+
* @param _formatId No longer used; kept so existing callers keep compiling.
|
|
648
740
|
*/
|
|
649
|
-
function parseFormatJSON(source,
|
|
741
|
+
function parseFormatJSON(source, _formatId) {
|
|
650
742
|
const first = source.indexOf("{");
|
|
651
743
|
const last = source.lastIndexOf("}");
|
|
652
744
|
if (first === -1 || last === -1) return null;
|
|
653
|
-
|
|
745
|
+
const chunk = source.slice(first, last + 1);
|
|
654
746
|
const parse = (json) => {
|
|
655
747
|
const raw = relaxedJSONParse(json);
|
|
656
748
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
|
@@ -673,16 +765,13 @@ function parseFormatJSON(source, formatId) {
|
|
|
673
765
|
try {
|
|
674
766
|
return parse(chunk);
|
|
675
767
|
} catch {
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
} catch {}
|
|
683
|
-
}
|
|
768
|
+
const stripped = stripSetupFunction(chunk);
|
|
769
|
+
if (stripped === void 0) return null;
|
|
770
|
+
try {
|
|
771
|
+
return parse(stripped);
|
|
772
|
+
} catch {
|
|
773
|
+
return null;
|
|
684
774
|
}
|
|
685
|
-
return null;
|
|
686
775
|
}
|
|
687
776
|
}
|
|
688
777
|
/**
|
|
@@ -832,6 +921,54 @@ function semverCompare(a, b) {
|
|
|
832
921
|
if (aPatch !== bPatch) return aPatch - bPatch;
|
|
833
922
|
return 0;
|
|
834
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
|
+
}
|
|
835
972
|
/** Get format ID from Twine 2 name and version, using SemVer major matching. */
|
|
836
973
|
function getFormatIdByNameAndVersion(formats, name, version) {
|
|
837
974
|
const wanted = parseSemver(version);
|
|
@@ -861,6 +998,321 @@ function readFormatSource(format) {
|
|
|
861
998
|
return source;
|
|
862
999
|
}
|
|
863
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
|
|
864
1316
|
//#region src/types.ts
|
|
865
1317
|
const ItemType = {
|
|
866
1318
|
Error: 0,
|
|
@@ -1124,16 +1576,20 @@ function twee2ToV3(s) {
|
|
|
1124
1576
|
//#region src/parser.ts
|
|
1125
1577
|
/**
|
|
1126
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.
|
|
1127
1582
|
*/
|
|
1128
1583
|
function parseTwee(source, options = {}) {
|
|
1129
1584
|
const { filename = "<inline>", trim = true, twee2Compat = false } = options;
|
|
1130
1585
|
const diagnostics = [];
|
|
1131
|
-
|
|
1586
|
+
const normalized = normalizeSourceText(source);
|
|
1587
|
+
const tweeSource = twee2Compat ? twee2ToV3(normalized) : normalized;
|
|
1132
1588
|
const passages = [];
|
|
1133
1589
|
let current = null;
|
|
1134
1590
|
let pCount = 0;
|
|
1135
1591
|
let lastType = ItemType.EOF;
|
|
1136
|
-
for (const item of tweeLexer(
|
|
1592
|
+
for (const item of tweeLexer(tweeSource)) {
|
|
1137
1593
|
switch (item.type) {
|
|
1138
1594
|
case ItemType.Error:
|
|
1139
1595
|
diagnostics.push({
|
|
@@ -4014,6 +4470,7 @@ function decompileTwine2(storyData, story, diagnostics) {
|
|
|
4014
4470
|
});
|
|
4015
4471
|
else story.twine2.zoom = parsed;
|
|
4016
4472
|
}
|
|
4473
|
+
if (attrs["tags"]) story.twine2.tags = attrs["tags"];
|
|
4017
4474
|
if (attrs["format"]) story.twine2.format = attrs["format"];
|
|
4018
4475
|
if (attrs["format-version"]) story.twine2.formatVersion = attrs["format-version"];
|
|
4019
4476
|
if (attrs["options"]) for (const opt of attrs["options"].split(/\s+/).filter((s) => s.length > 0)) story.twine2.options.set(opt, true);
|
|
@@ -4073,22 +4530,32 @@ function decompileTwine2(storyData, story, diagnostics) {
|
|
|
4073
4530
|
}, diagnostics);
|
|
4074
4531
|
}
|
|
4075
4532
|
function decompileTwine1(storeArea, story, diagnostics) {
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
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";
|
|
4092
4559
|
}
|
|
4093
4560
|
function findElement(node, tagName) {
|
|
4094
4561
|
if (node.type === "tag" && node.name === tagName) return node;
|
|
@@ -4211,7 +4678,7 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
|
|
|
4211
4678
|
function loadInlineSources(story, sources, opts, diagnostics) {
|
|
4212
4679
|
for (const source of sources) {
|
|
4213
4680
|
if (typeof source === "string") continue;
|
|
4214
|
-
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"));
|
|
4215
4682
|
const ext = normalizedFileExt(source.filename);
|
|
4216
4683
|
switch (ext) {
|
|
4217
4684
|
case "":
|
|
@@ -4298,12 +4765,29 @@ function parseFontFile(filename) {
|
|
|
4298
4765
|
};
|
|
4299
4766
|
}
|
|
4300
4767
|
function parseHTMLFile(filename) {
|
|
4301
|
-
const
|
|
4768
|
+
const { story, diagnostics } = decompileHTML(readUTF8(filename));
|
|
4302
4769
|
return {
|
|
4303
|
-
passages:
|
|
4304
|
-
|
|
4770
|
+
passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
|
|
4771
|
+
name: "StoryTitle",
|
|
4772
|
+
tags: [],
|
|
4773
|
+
text: story.name
|
|
4774
|
+
}, ...story.passages] : story.passages,
|
|
4775
|
+
diagnostics: [...diagnostics]
|
|
4305
4776
|
};
|
|
4306
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
|
+
}
|
|
4307
4791
|
function parseFile(filename, opts) {
|
|
4308
4792
|
switch (normalizedFileExt(filename)) {
|
|
4309
4793
|
case "tw":
|
|
@@ -4383,7 +4867,9 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
4383
4867
|
continue;
|
|
4384
4868
|
}
|
|
4385
4869
|
currentFiles.add(filename);
|
|
4386
|
-
const
|
|
4870
|
+
const optionsKey = parseOptionsKey(filename, opts);
|
|
4871
|
+
const entry = cache.get(filename);
|
|
4872
|
+
const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
|
|
4387
4873
|
if (changedFiles && cached && !changedFiles.has(filename)) {
|
|
4388
4874
|
diagnostics.push(...cached.diagnostics);
|
|
4389
4875
|
for (const p of cached.passages) storyAdd(story, p, diagnostics);
|
|
@@ -4407,6 +4893,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
4407
4893
|
if (!result) continue;
|
|
4408
4894
|
cache.set(filename, {
|
|
4409
4895
|
mtimeMs,
|
|
4896
|
+
parseOptionsKey: optionsKey,
|
|
4410
4897
|
passages: result.passages,
|
|
4411
4898
|
diagnostics: result.diagnostics
|
|
4412
4899
|
});
|
|
@@ -4470,11 +4957,39 @@ function toTwine2HTML(story, format, startName, options) {
|
|
|
4470
4957
|
template = template.replaceAll("{{STORY_NAME}}", () => name);
|
|
4471
4958
|
}
|
|
4472
4959
|
if (template.includes("{{STORY_DATA}}")) {
|
|
4473
|
-
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);
|
|
4474
4968
|
template = template.replace("{{STORY_DATA}}", () => data);
|
|
4475
4969
|
}
|
|
4476
4970
|
return template;
|
|
4477
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
|
+
}
|
|
4478
4993
|
function getTwine2DataChunk(story, startName, options) {
|
|
4479
4994
|
const parts = [];
|
|
4480
4995
|
let startID = "";
|
|
@@ -4497,7 +5012,7 @@ function getTwine2DataChunk(story, startName, options) {
|
|
|
4497
5012
|
pidS++;
|
|
4498
5013
|
}
|
|
4499
5014
|
}
|
|
4500
|
-
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>`);
|
|
4501
5016
|
let scriptContent = "";
|
|
4502
5017
|
if (scripts.length === 1) scriptContent = scripts[0]?.text ?? "";
|
|
4503
5018
|
else if (scripts.length > 1) {
|
|
@@ -4509,7 +5024,7 @@ function getTwine2DataChunk(story, startName, options) {
|
|
|
4509
5024
|
pidS++;
|
|
4510
5025
|
}
|
|
4511
5026
|
}
|
|
4512
|
-
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>`);
|
|
4513
5028
|
const validTagColors = /* @__PURE__ */ new Set([
|
|
4514
5029
|
"gray",
|
|
4515
5030
|
"red",
|
|
@@ -4523,8 +5038,7 @@ function getTwine2DataChunk(story, startName, options) {
|
|
|
4523
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>`);
|
|
4524
5039
|
pid = 1;
|
|
4525
5040
|
for (const p of story.passages) {
|
|
4526
|
-
if (
|
|
4527
|
-
if (p.name === "StorySettings" && story.twine1.settings.size === 0) continue;
|
|
5041
|
+
if (twine2PassageOmission(story, p) !== void 0) continue;
|
|
4528
5042
|
parts.push(passageToPassagedata(p, pid, options));
|
|
4529
5043
|
if (startName === p.name) startID = String(pid);
|
|
4530
5044
|
pid++;
|
|
@@ -4580,12 +5094,21 @@ function toTwine1HTML(story, format, startName) {
|
|
|
4580
5094
|
}
|
|
4581
5095
|
return template;
|
|
4582
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
|
+
}
|
|
4583
5106
|
function getTwine1PassageChunk(story) {
|
|
4584
5107
|
const obfuscateRot13 = story.twine1.settings.get("obfuscate") === "rot13";
|
|
4585
5108
|
let data = "";
|
|
4586
5109
|
let count = 0;
|
|
4587
5110
|
for (const p of story.passages) {
|
|
4588
|
-
if (
|
|
5111
|
+
if (twine1PassageOmission(p) !== void 0) continue;
|
|
4589
5112
|
count++;
|
|
4590
5113
|
data += passageToTiddler(p, count, obfuscateRot13);
|
|
4591
5114
|
}
|
|
@@ -4647,7 +5170,7 @@ function loadModules(filenames) {
|
|
|
4647
5170
|
function loadModuleTagged(tag, filename) {
|
|
4648
5171
|
const source = readUTF8(filename).trim();
|
|
4649
5172
|
if (source.length === 0) return null;
|
|
4650
|
-
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}>`;
|
|
4651
5174
|
}
|
|
4652
5175
|
function loadModuleFont(filename) {
|
|
4653
5176
|
const source = readBase64(filename);
|
|
@@ -4682,199 +5205,38 @@ function modifyHead(html, modulePaths, headFile, diagnostics) {
|
|
|
4682
5205
|
return html;
|
|
4683
5206
|
}
|
|
4684
5207
|
//#endregion
|
|
4685
|
-
//#region src/
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
|
|
4692
|
-
function getCacheDir() {
|
|
4693
|
-
const base = process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache");
|
|
4694
|
-
return join(base, "twee-ts", "storyformats");
|
|
4695
|
-
}
|
|
4696
|
-
/** In-memory index cache, keyed by URL. Cleared each compile. */
|
|
4697
|
-
const indexCache = /* @__PURE__ */ new Map();
|
|
4698
|
-
/** Clear the in-memory index cache. */
|
|
4699
|
-
function clearIndexCache() {
|
|
4700
|
-
indexCache.clear();
|
|
4701
|
-
}
|
|
4702
|
-
/** Validate that a JSON value is a valid SFAIndexEntry. */
|
|
4703
|
-
function isValidEntry(val) {
|
|
4704
|
-
if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
|
|
4705
|
-
const obj = val;
|
|
4706
|
-
return typeof obj.name === "string" && typeof obj.version === "string" && typeof obj.checksums === "object" && obj.checksums !== null && !Array.isArray(obj.checksums);
|
|
4707
|
-
}
|
|
4708
|
-
/** Validate that a JSON value conforms to the SFAIndex shape. */
|
|
4709
|
-
function validateSFAIndex(json) {
|
|
4710
|
-
if (typeof json !== "object" || json === null) throw new Error("SFA index is not an object");
|
|
4711
|
-
const obj = json;
|
|
4712
|
-
return {
|
|
4713
|
-
twine1: Array.isArray(obj["twine1"]) ? obj["twine1"].filter(isValidEntry) : [],
|
|
4714
|
-
twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
|
|
4715
|
-
};
|
|
4716
|
-
}
|
|
4717
|
-
/** Generate a format ID from name and version. */
|
|
4718
|
-
function makeFormatId(name, version) {
|
|
4719
|
-
const major = parseSemver(version)?.[0] ?? "0";
|
|
4720
|
-
return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
|
|
4721
|
-
}
|
|
4722
|
-
/** Fetch and parse an SFA index.json, with in-memory caching. */
|
|
4723
|
-
async function fetchIndex(url) {
|
|
4724
|
-
const cached = indexCache.get(url);
|
|
4725
|
-
if (cached) return cached;
|
|
4726
|
-
const res = await fetch(url);
|
|
4727
|
-
if (!res.ok) throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
|
|
4728
|
-
const data = validateSFAIndex(await res.json());
|
|
4729
|
-
indexCache.set(url, data);
|
|
4730
|
-
return data;
|
|
4731
|
-
}
|
|
4732
|
-
/**
|
|
4733
|
-
* Find the best matching entry in an SFA index.
|
|
4734
|
-
* Exact version preferred, then highest version with same major.
|
|
4735
|
-
*/
|
|
4736
|
-
function findEntry(index, name, version) {
|
|
4737
|
-
const wanted = parseSemver(version);
|
|
4738
|
-
const searchArrays = [{
|
|
4739
|
-
entries: index.twine2 ?? [],
|
|
4740
|
-
formatType: "twine2"
|
|
4741
|
-
}, {
|
|
4742
|
-
entries: index.twine1 ?? [],
|
|
4743
|
-
formatType: "twine1"
|
|
4744
|
-
}];
|
|
4745
|
-
let bestResult;
|
|
4746
|
-
let bestVersion = null;
|
|
4747
|
-
for (const { entries, formatType } of searchArrays) for (const entry of entries) {
|
|
4748
|
-
if (entry.name.toLowerCase() !== name.toLowerCase()) continue;
|
|
4749
|
-
const have = parseSemver(entry.version);
|
|
4750
|
-
if (!have) continue;
|
|
4751
|
-
if (wanted && semverCompare(have, wanted) === 0) return {
|
|
4752
|
-
entry,
|
|
4753
|
-
formatType
|
|
4754
|
-
};
|
|
4755
|
-
if (wanted === null || have[0] === wanted[0] && semverCompare(have, wanted) >= 0) {
|
|
4756
|
-
if (!bestVersion || semverCompare(have, bestVersion) > 0) {
|
|
4757
|
-
bestVersion = have;
|
|
4758
|
-
bestResult = {
|
|
4759
|
-
entry,
|
|
4760
|
-
formatType
|
|
4761
|
-
};
|
|
4762
|
-
}
|
|
4763
|
-
}
|
|
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)}`);
|
|
4764
5215
|
}
|
|
4765
|
-
return bestResult;
|
|
4766
|
-
}
|
|
4767
|
-
/** Verify SHA-256 checksum using Web Crypto API (Node 22 built-in). */
|
|
4768
|
-
async function verifySHA256(content, expectedHex) {
|
|
4769
|
-
const digest = await crypto.subtle.digest("SHA-256", content);
|
|
4770
|
-
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("") === expectedHex.toLowerCase();
|
|
4771
5216
|
}
|
|
4772
|
-
|
|
4773
|
-
|
|
4774
|
-
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
async function fetchAndCacheFormat(entry, downloadUrl) {
|
|
4778
|
-
const res = await fetch(downloadUrl);
|
|
4779
|
-
if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
|
|
4780
|
-
const text = await res.text();
|
|
4781
|
-
const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
|
|
4782
|
-
if (checksumKey) {
|
|
4783
|
-
const expected = entry.checksums[checksumKey];
|
|
4784
|
-
if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
|
|
4785
|
-
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)}`);
|
|
4786
5222
|
}
|
|
4787
|
-
const id = makeFormatId(entry.name, entry.version);
|
|
4788
|
-
const data = parseFormatJSON(text, id);
|
|
4789
|
-
if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
|
|
4790
|
-
const cacheDir = getCacheDir();
|
|
4791
|
-
const formatDir = join(cacheDir, entry.name, entry.version);
|
|
4792
|
-
mkdirSync(formatDir, { recursive: true });
|
|
4793
|
-
const formatPath = join(formatDir, "format.js");
|
|
4794
|
-
writeFileSync(formatPath, text, "utf-8");
|
|
4795
|
-
return {
|
|
4796
|
-
id,
|
|
4797
|
-
filename: formatPath,
|
|
4798
|
-
isTwine2: true,
|
|
4799
|
-
name: data.name,
|
|
4800
|
-
version: data.version,
|
|
4801
|
-
proofing: data.proofing ?? false
|
|
4802
|
-
};
|
|
4803
|
-
}
|
|
4804
|
-
/** Download a direct format.js URL, parse its JSON, cache it, and return StoryFormatInfo. */
|
|
4805
|
-
async function fetchDirectFormat(url) {
|
|
4806
|
-
const res = await fetch(url);
|
|
4807
|
-
if (!res.ok) throw new Error(`Failed to download format from ${url}: ${res.status} ${res.statusText}`);
|
|
4808
|
-
const text = await res.text();
|
|
4809
|
-
const data = parseFormatJSON(text, "direct-url");
|
|
4810
|
-
if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
|
|
4811
|
-
const id = makeFormatId(data.name, data.version);
|
|
4812
|
-
const cacheDir = getCacheDir();
|
|
4813
|
-
const formatDir = join(cacheDir, data.name, data.version);
|
|
4814
|
-
mkdirSync(formatDir, { recursive: true });
|
|
4815
|
-
const formatPath = join(formatDir, "format.js");
|
|
4816
|
-
writeFileSync(formatPath, text, "utf-8");
|
|
4817
|
-
return {
|
|
4818
|
-
id,
|
|
4819
|
-
filename: formatPath,
|
|
4820
|
-
isTwine2: true,
|
|
4821
|
-
name: data.name,
|
|
4822
|
-
version: data.version,
|
|
4823
|
-
proofing: data.proofing ?? false
|
|
4824
|
-
};
|
|
4825
5223
|
}
|
|
4826
5224
|
/**
|
|
4827
|
-
*
|
|
4828
|
-
*
|
|
4829
|
-
* 2. Try custom index URLs
|
|
4830
|
-
* 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).
|
|
4831
5227
|
*/
|
|
4832
|
-
|
|
4833
|
-
|
|
4834
|
-
if (
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
|
|
4839
|
-
|
|
4840
|
-
|
|
4841
|
-
|
|
4842
|
-
|
|
4843
|
-
}
|
|
4844
|
-
const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
|
|
4845
|
-
for (const indexUrl of allIndices) try {
|
|
4846
|
-
const result = findEntry(await fetchIndex(indexUrl), name, version);
|
|
4847
|
-
if (result) {
|
|
4848
|
-
const cached = getCachedFormat(result.entry.name, result.entry.version);
|
|
4849
|
-
if (cached) return cached;
|
|
4850
|
-
const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
|
|
4851
|
-
return await fetchAndCacheFormat(result.entry, downloadUrl);
|
|
4852
|
-
}
|
|
4853
|
-
} catch (e) {
|
|
4854
|
-
lastError = e instanceof Error ? e : new Error(String(e));
|
|
4855
|
-
}
|
|
4856
|
-
if (lastError) throw lastError;
|
|
4857
|
-
}
|
|
4858
|
-
/** Check if a format is already in the local cache. */
|
|
4859
|
-
function getCachedFormat(name, version) {
|
|
4860
|
-
const formatPath = join(getCacheDir(), name, version, "format.js");
|
|
4861
|
-
try {
|
|
4862
|
-
if (!existsSync(formatPath)) return void 0;
|
|
4863
|
-
const source = readFileSync(formatPath, "utf-8");
|
|
4864
|
-
const id = makeFormatId(name, version);
|
|
4865
|
-
const data = parseFormatJSON(source, id);
|
|
4866
|
-
if (!data) return void 0;
|
|
4867
|
-
return {
|
|
4868
|
-
id,
|
|
4869
|
-
filename: formatPath,
|
|
4870
|
-
isTwine2: true,
|
|
4871
|
-
name: data.name,
|
|
4872
|
-
version: data.version,
|
|
4873
|
-
proofing: data.proofing ?? false
|
|
4874
|
-
};
|
|
4875
|
-
} catch {
|
|
4876
|
-
return;
|
|
4877
|
-
}
|
|
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
|
+
}];
|
|
4878
5240
|
}
|
|
4879
5241
|
//#endregion
|
|
4880
5242
|
//#region src/compiler.ts
|
|
@@ -4902,7 +5264,11 @@ async function compile(options) {
|
|
|
4902
5264
|
async function compileIncremental(options, cache, changedFiles) {
|
|
4903
5265
|
return buildOutput(options, cache, changedFiles);
|
|
4904
5266
|
}
|
|
4905
|
-
|
|
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) {
|
|
4906
5272
|
const diagnostics = [];
|
|
4907
5273
|
const outputMode = options.outputMode ?? "html";
|
|
4908
5274
|
const trim = options.trim ?? true;
|
|
@@ -4915,7 +5281,7 @@ async function buildOutput(options, cache, changedFiles) {
|
|
|
4915
5281
|
const inlineSources = [];
|
|
4916
5282
|
for (const source of options.sources) if (typeof source === "string") filePaths.push(source);
|
|
4917
5283
|
else inlineSources.push(source);
|
|
4918
|
-
const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths);
|
|
5284
|
+
const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths, outFile, options.exclude);
|
|
4919
5285
|
diagnostics.push(...sourcePathDiagnostics);
|
|
4920
5286
|
const story = createStory();
|
|
4921
5287
|
const processedFiles = /* @__PURE__ */ new Set();
|
|
@@ -4933,33 +5299,12 @@ async function buildOutput(options, cache, changedFiles) {
|
|
|
4933
5299
|
}, diagnostics);
|
|
4934
5300
|
if (options.tagAliases) story.passages = applyTagAliases(story.passages, options.tagAliases);
|
|
4935
5301
|
let format;
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
if (!format && !noRemote) {
|
|
4943
|
-
const remoteName = story.twine2.format || formatId;
|
|
4944
|
-
const remoteVersion = story.twine2.formatVersion || "";
|
|
4945
|
-
try {
|
|
4946
|
-
format = await resolveRemoteFormat(remoteName, remoteVersion, options.formatIndices, options.formatUrls);
|
|
4947
|
-
} catch (e) {
|
|
4948
|
-
diagnostics.push({
|
|
4949
|
-
level: "warning",
|
|
4950
|
-
message: `Remote format fetch failed for "${remoteName}": ${e instanceof Error ? e.message : String(e)}`
|
|
4951
|
-
});
|
|
4952
|
-
}
|
|
4953
|
-
}
|
|
4954
|
-
if (!format) {
|
|
4955
|
-
const reason = noRemote ? " (remote fetching disabled)" : "";
|
|
4956
|
-
diagnostics.push({
|
|
4957
|
-
level: "error",
|
|
4958
|
-
message: `Story format "${formatId}" is not available${reason}. Found: ${[...formats.keys()].join(", ") || "none"}`
|
|
4959
|
-
});
|
|
4960
|
-
}
|
|
4961
|
-
}
|
|
4962
|
-
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;
|
|
4963
5308
|
if (testMode) story.twine2.options.set("debug", true);
|
|
4964
5309
|
ensureIFID(story, diagnostics);
|
|
4965
5310
|
let output;
|
|
@@ -4978,10 +5323,7 @@ async function buildOutput(options, cache, changedFiles) {
|
|
|
4978
5323
|
output = storyToJSON(story);
|
|
4979
5324
|
break;
|
|
4980
5325
|
case "html": {
|
|
4981
|
-
|
|
4982
|
-
level: "error",
|
|
4983
|
-
message: `Starting passage "${startName}" not found.`
|
|
4984
|
-
});
|
|
5326
|
+
diagnostics.push(...startPassageDiagnostics(story, startName, format?.isTwine2 === false ? "twine1" : "twine2"));
|
|
4985
5327
|
if (!format) throw new TweeTsError("No story format available for HTML output.", diagnostics);
|
|
4986
5328
|
if (format.isTwine2) output = toTwine2HTML(story, format, startName, { sourceInfo });
|
|
4987
5329
|
else {
|
|
@@ -4991,7 +5333,7 @@ async function buildOutput(options, cache, changedFiles) {
|
|
|
4991
5333
|
});
|
|
4992
5334
|
output = toTwine1HTML(story, format, startName);
|
|
4993
5335
|
}
|
|
4994
|
-
const modules = getFilenames(options.modules ?? []);
|
|
5336
|
+
const modules = getFilenames(options.modules ?? [], outFile);
|
|
4995
5337
|
diagnostics.push(...modules.diagnostics);
|
|
4996
5338
|
output = modifyHead(output, modules.filenames, options.headFile, diagnostics);
|
|
4997
5339
|
break;
|
|
@@ -5069,6 +5411,6 @@ function storyToJSON(story) {
|
|
|
5069
5411
|
return JSON.stringify(obj, null, 2);
|
|
5070
5412
|
}
|
|
5071
5413
|
//#endregion
|
|
5072
|
-
export {
|
|
5414
|
+
export { isExcluded as a, getFilenames as i, compile as n, mediaTypeFromFilename as o, compileIncremental as r, TweeTsError as t };
|
|
5073
5415
|
|
|
5074
|
-
//# sourceMappingURL=compiler-
|
|
5416
|
+
//# sourceMappingURL=compiler-Bmpbelqj.js.map
|