@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
package/dist/bin/twee-ts.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
|
|
4
4
|
import * as nodePath from "node:path";
|
|
5
|
-
import { basename, dirname, extname, join, parse, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
@@ -106,6 +106,61 @@ function commentSanitize(s) {
|
|
|
106
106
|
function htmlCommentSanitize(s) {
|
|
107
107
|
return s.replace(/-->/g, "-- >");
|
|
108
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Escape JavaScript for the content of an HTML `<script>` element, so that the HTML parser reads all of it as
|
|
111
|
+
* the element's text and ends the element at the end tag written after it.
|
|
112
|
+
*
|
|
113
|
+
* - Every `<\/script` (in any letter case) becomes `<\/script`. HTML ends a script element at the first
|
|
114
|
+
* `<\/script` followed by whitespace, `/` or `>`, wherever it is in the JavaScript, even inside a string.
|
|
115
|
+
* - Every `<!--` becomes `<\!--`, but only if the text would otherwise end in the HTML tokenizer's "script
|
|
116
|
+
* data double escaped" state: after a `<!--` and then a `<script` start tag with no `-->` between them, the
|
|
117
|
+
* end tag after the text would not end the element.
|
|
118
|
+
*
|
|
119
|
+
* Inserting a backslash after `<` keeps the value of every string, template, and regular expression literal
|
|
120
|
+
* and every comment where these sequences can be written (`\/` and `\!` are identity escapes; a backslash
|
|
121
|
+
* already before the `<` escapes the `<`, as it did before); `<\/script` is also how minifiers such as Terser
|
|
122
|
+
* write inline scripts. It only changes code that uses them outside literals (`a <\/script/ b`, an HTML-like `<!--`
|
|
123
|
+
* comment that is escaped because of a later `<script`), the raw text of a tagged template, and a `<!--` in a
|
|
124
|
+
* regular expression with the `u` or `v` flag. The result is unchanged by escaping it again.
|
|
125
|
+
*/
|
|
126
|
+
function scriptContentEscape(s) {
|
|
127
|
+
const escaped = s.replace(/<(?=\/script)/gi, "<\\");
|
|
128
|
+
return endsDoubleEscaped(escaped) ? escaped.replace(/<(?=!--)/g, "<\\") : escaped;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Whether the HTML tokenizer, reading `s` as script element content with no `<\/script` in it, ends in the
|
|
132
|
+
* "script data double escaped" state, where an end tag after `s` would not end the element.
|
|
133
|
+
* See https://html.spec.whatwg.org/multipage/parsing.html#script-data-escaped-state.
|
|
134
|
+
*/
|
|
135
|
+
function endsDoubleEscaped(s) {
|
|
136
|
+
const scriptStartTag = /<script[\t\n\f\r />]/gi;
|
|
137
|
+
let pos = 0;
|
|
138
|
+
for (;;) {
|
|
139
|
+
const open = s.indexOf("<!--", pos);
|
|
140
|
+
if (open === -1) return false;
|
|
141
|
+
const escapedFrom = open + 2;
|
|
142
|
+
const close = s.indexOf("-->", escapedFrom);
|
|
143
|
+
scriptStartTag.lastIndex = escapedFrom;
|
|
144
|
+
const start = scriptStartTag.exec(s);
|
|
145
|
+
if (start === null || close !== -1 && close < start.index) {
|
|
146
|
+
if (close === -1) return false;
|
|
147
|
+
pos = close + 3;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const end = s.indexOf("-->", start.index + start[0].length);
|
|
151
|
+
if (end === -1) return true;
|
|
152
|
+
pos = end + 3;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Escape CSS for the content of an HTML `<style>` element: every `</style` (in any letter case) becomes
|
|
157
|
+
* `<\/style`, since HTML ends a style element at the first `</style` followed by whitespace, `/` or `>`.
|
|
158
|
+
* `\/` stands for `/` in CSS strings and URLs, and comments don't matter; `</style` anywhere else isn't valid
|
|
159
|
+
* CSS anyway.
|
|
160
|
+
*/
|
|
161
|
+
function styleContentEscape(s) {
|
|
162
|
+
return s.replace(/<(?=\/style)/gi, "<\\");
|
|
163
|
+
}
|
|
109
164
|
/** Apply ROT13 encoding to a string (only affects [A-Za-z]). */
|
|
110
165
|
function rot13(s) {
|
|
111
166
|
if (s.length === 0) return s;
|
|
@@ -663,10 +718,35 @@ function getFilenames(pathnames, outFilename, exclude = []) {
|
|
|
663
718
|
diagnostics
|
|
664
719
|
};
|
|
665
720
|
}
|
|
721
|
+
/** Whether the absolute path `file` is the absolute path `dir` or inside it. */
|
|
722
|
+
function isInsideDir(file, dir) {
|
|
723
|
+
return file === dir || file.startsWith(dir.endsWith(sep) ? dir : dir + sep);
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* The watched paths split into folders (as given) and files (absolute). A path
|
|
727
|
+
* that cannot be read counts as a folder, which fs.watch then fails to watch.
|
|
728
|
+
*/
|
|
729
|
+
function splitWatchRoots(pathnames) {
|
|
730
|
+
const dirs = [];
|
|
731
|
+
const files = /* @__PURE__ */ new Set();
|
|
732
|
+
for (const pathname of pathnames) {
|
|
733
|
+
let isFile = false;
|
|
734
|
+
try {
|
|
735
|
+
isFile = statSync(pathname).isFile();
|
|
736
|
+
} catch {}
|
|
737
|
+
if (isFile) files.add(resolve(pathname));
|
|
738
|
+
else dirs.push(pathname);
|
|
739
|
+
}
|
|
740
|
+
return {
|
|
741
|
+
dirs,
|
|
742
|
+
files
|
|
743
|
+
};
|
|
744
|
+
}
|
|
666
745
|
/**
|
|
667
746
|
* Watch paths for changes, calling the build callback on known file type changes.
|
|
668
|
-
* A
|
|
669
|
-
*
|
|
747
|
+
* A folder is watched recursively; a file is watched on its own and counts
|
|
748
|
+
* whatever its type (a head file, say). A change to a file `ignore` returns true
|
|
749
|
+
* for (given the path relative to the working directory) schedules no build.
|
|
670
750
|
* Uses debouncing to avoid rapid rebuilds.
|
|
671
751
|
*/
|
|
672
752
|
function watchFilesystem(pathnames, outFilename, callback, ignore = () => false) {
|
|
@@ -685,18 +765,30 @@ function watchFilesystem(pathnames, outFilename, callback, ignore = () => false)
|
|
|
685
765
|
callback(files);
|
|
686
766
|
}, BUILD_DEBOUNCE);
|
|
687
767
|
}
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
768
|
+
function fileChanged(abs, named) {
|
|
769
|
+
if (abs === absOutFile) return;
|
|
770
|
+
if (!named && !isKnownFileType(abs)) return;
|
|
771
|
+
const rel = relative(process.cwd(), abs);
|
|
772
|
+
if (!ignore(rel || abs)) scheduleBuild(rel || abs);
|
|
773
|
+
}
|
|
774
|
+
function watchPath(pathname, recursive, listener) {
|
|
775
|
+
try {
|
|
776
|
+
watchers.push(watch(pathname, { recursive }, (_event, filename) => {
|
|
777
|
+
if (filename) listener(filename);
|
|
778
|
+
}));
|
|
779
|
+
} catch {}
|
|
780
|
+
}
|
|
781
|
+
const { dirs, files } = splitWatchRoots(pathnames);
|
|
782
|
+
const absDirs = dirs.map((dir) => resolve(dir));
|
|
783
|
+
for (const dir of dirs) watchPath(dir, true, (filename) => {
|
|
784
|
+
const abs = resolve(dir, filename);
|
|
785
|
+
fileChanged(abs, files.has(abs));
|
|
786
|
+
});
|
|
787
|
+
const fileDirs = new Set([...files].filter((file) => !absDirs.some((dir) => isInsideDir(file, dir))).map((file) => dirname(file)));
|
|
788
|
+
for (const dir of fileDirs) watchPath(dir, false, (filename) => {
|
|
789
|
+
const abs = resolve(dir, filename);
|
|
790
|
+
if (files.has(abs)) fileChanged(abs, true);
|
|
791
|
+
});
|
|
700
792
|
callback();
|
|
701
793
|
return { close() {
|
|
702
794
|
if (buildTimer) clearTimeout(buildTimer);
|
|
@@ -704,16 +796,25 @@ function watchFilesystem(pathnames, outFilename, callback, ignore = () => false)
|
|
|
704
796
|
} };
|
|
705
797
|
}
|
|
706
798
|
//#endregion
|
|
799
|
+
//#region src/source-text.ts
|
|
800
|
+
/**
|
|
801
|
+
* Source text normalization shared by every input path (files, in-memory sources, `parseTwee`).
|
|
802
|
+
*/
|
|
803
|
+
/**
|
|
804
|
+
* Strip a leading UTF-8 byte order mark and convert CRLF and bare CR line endings to LF.
|
|
805
|
+
* Idempotent, so text that is already normalized passes through unchanged.
|
|
806
|
+
*/
|
|
807
|
+
function normalizeSourceText(text) {
|
|
808
|
+
return (text.charCodeAt(0) === 65279 ? text.slice(1) : text).replace(/\r\n?/g, "\n");
|
|
809
|
+
}
|
|
810
|
+
//#endregion
|
|
707
811
|
//#region src/util.ts
|
|
708
812
|
/**
|
|
709
813
|
* Shared file I/O utilities.
|
|
710
814
|
*/
|
|
711
815
|
/** Read a file as UTF-8 with BOM stripping and line ending normalization. */
|
|
712
816
|
function readUTF8(filename) {
|
|
713
|
-
|
|
714
|
-
if (content.charCodeAt(0) === 65279) content = content.slice(1);
|
|
715
|
-
content = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
716
|
-
return content;
|
|
817
|
+
return normalizeSourceText(readFileSync(filename, "utf-8"));
|
|
717
818
|
}
|
|
718
819
|
/** Read a file as base64. */
|
|
719
820
|
function readBase64(filename) {
|
|
@@ -743,16 +844,28 @@ function relaxedJSONParse(json) {
|
|
|
743
844
|
fixed = fixed.replace(/,\s*([}\]])/g, "$1");
|
|
744
845
|
return JSON.parse(fixed);
|
|
745
846
|
}
|
|
847
|
+
/** A `"setup": function` property, which Harlowe appends to its otherwise-JSON format object. */
|
|
848
|
+
const SETUP_FUNCTION_PROPERTY = /,\s*"setup"\s*:\s*function\b/g;
|
|
849
|
+
/** Drop a trailing `"setup": function(){…}` property from a format object, if it has one. */
|
|
850
|
+
function stripSetupFunction(chunk) {
|
|
851
|
+
let lastIndex = -1;
|
|
852
|
+
for (const match of chunk.matchAll(SETUP_FUNCTION_PROPERTY)) lastIndex = match.index;
|
|
853
|
+
return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
|
|
854
|
+
}
|
|
746
855
|
/**
|
|
747
856
|
* Parse the Twine 2 format.js JSON chunk.
|
|
748
|
-
* Handles Harlowe's malformed JSON by stripping the "setup" function property.
|
|
857
|
+
* Handles Harlowe's malformed JSON by stripping the "setup" function property. The workaround
|
|
858
|
+
* keys on the property itself, so the same bytes parse whether they come from a `harlowe-3`
|
|
859
|
+
* directory or a direct download.
|
|
749
860
|
* Per spec, the name key is Optional.
|
|
861
|
+
*
|
|
862
|
+
* @param _formatId No longer used; kept so existing callers keep compiling.
|
|
750
863
|
*/
|
|
751
|
-
function parseFormatJSON(source,
|
|
864
|
+
function parseFormatJSON(source, _formatId) {
|
|
752
865
|
const first = source.indexOf("{");
|
|
753
866
|
const last = source.lastIndexOf("}");
|
|
754
867
|
if (first === -1 || last === -1) return null;
|
|
755
|
-
|
|
868
|
+
const chunk = source.slice(first, last + 1);
|
|
756
869
|
const parse = (json) => {
|
|
757
870
|
const raw = relaxedJSONParse(json);
|
|
758
871
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
|
@@ -775,16 +888,13 @@ function parseFormatJSON(source, formatId) {
|
|
|
775
888
|
try {
|
|
776
889
|
return parse(chunk);
|
|
777
890
|
} catch {
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
} catch {}
|
|
785
|
-
}
|
|
891
|
+
const stripped = stripSetupFunction(chunk);
|
|
892
|
+
if (stripped === void 0) return null;
|
|
893
|
+
try {
|
|
894
|
+
return parse(stripped);
|
|
895
|
+
} catch {
|
|
896
|
+
return null;
|
|
786
897
|
}
|
|
787
|
-
return null;
|
|
788
898
|
}
|
|
789
899
|
}
|
|
790
900
|
/**
|
|
@@ -934,6 +1044,54 @@ function semverCompare(a, b) {
|
|
|
934
1044
|
if (aPatch !== bPatch) return aPatch - bPatch;
|
|
935
1045
|
return 0;
|
|
936
1046
|
}
|
|
1047
|
+
/** Build a directory-style format ID from a name and version, e.g. ('SugarCube', '2.37.3') → 'sugarcube-2'. */
|
|
1048
|
+
function makeFormatId(name, version) {
|
|
1049
|
+
const major = parseSemver(version)?.[0] ?? 0;
|
|
1050
|
+
return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
|
|
1051
|
+
}
|
|
1052
|
+
/** Describe a format request for diagnostics. */
|
|
1053
|
+
function describeFormatRequest(request) {
|
|
1054
|
+
switch (request.kind) {
|
|
1055
|
+
case "id": return `"${request.id}"`;
|
|
1056
|
+
case "name": return request.version ? `"${request.name}" at version "${request.version}"` : `"${request.name}"`;
|
|
1057
|
+
default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Pick the candidate that best answers a format request.
|
|
1062
|
+
*
|
|
1063
|
+
* - An ID request ('sugarcube-2') matches a candidate whose name and major version build that ID,
|
|
1064
|
+
* and takes the greatest such version.
|
|
1065
|
+
* - A name request matches the name case-insensitively. An exact version wins outright; otherwise
|
|
1066
|
+
* the greatest version with the same major that is not older than the one asked for (or, with
|
|
1067
|
+
* `allowOlder`, the greatest same-major version). An unparseable requested version takes the
|
|
1068
|
+
* greatest version of any major.
|
|
1069
|
+
*/
|
|
1070
|
+
function selectFormatCandidate(request, candidates, describe, options = {}) {
|
|
1071
|
+
const allowOlder = options.allowOlder ?? false;
|
|
1072
|
+
const wanted = request.kind === "name" ? parseSemver(request.version) : null;
|
|
1073
|
+
let best;
|
|
1074
|
+
for (const candidate of candidates) {
|
|
1075
|
+
const { name, version } = describe(candidate);
|
|
1076
|
+
const have = parseSemver(version);
|
|
1077
|
+
if (!have) continue;
|
|
1078
|
+
if (request.kind === "id") {
|
|
1079
|
+
if (makeFormatId(name, version) !== request.id.toLowerCase()) continue;
|
|
1080
|
+
} else {
|
|
1081
|
+
if (name.toLowerCase() !== request.name.toLowerCase()) continue;
|
|
1082
|
+
if (wanted) {
|
|
1083
|
+
if (semverCompare(have, wanted) === 0) return candidate;
|
|
1084
|
+
if (have[0] !== wanted[0]) continue;
|
|
1085
|
+
if (!allowOlder && semverCompare(have, wanted) < 0) continue;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
if (!best || semverCompare(have, best.version) > 0) best = {
|
|
1089
|
+
candidate,
|
|
1090
|
+
version: have
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
return best?.candidate;
|
|
1094
|
+
}
|
|
937
1095
|
/** Get format ID from Twine 2 name and version, using SemVer major matching. */
|
|
938
1096
|
function getFormatIdByNameAndVersion(formats, name, version) {
|
|
939
1097
|
const wanted = parseSemver(version);
|
|
@@ -963,6 +1121,406 @@ function readFormatSource(format) {
|
|
|
963
1121
|
return source;
|
|
964
1122
|
}
|
|
965
1123
|
//#endregion
|
|
1124
|
+
//#region src/remote-formats.ts
|
|
1125
|
+
/**
|
|
1126
|
+
* Remote story format fetching, caching, and checksum verification.
|
|
1127
|
+
* Uses the Story Formats Archive (SFA) as the default source.
|
|
1128
|
+
*/
|
|
1129
|
+
const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
|
|
1130
|
+
/** Get the cache directory for downloaded story formats. */
|
|
1131
|
+
function getCacheDir() {
|
|
1132
|
+
const base = process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache");
|
|
1133
|
+
return join(base, "twee-ts", "storyformats");
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* Whether a string can serve as one directory name inside the cache: not empty, not `.` or `..`,
|
|
1137
|
+
* and free of path separators. Format names and versions come from downloaded metadata, so they
|
|
1138
|
+
* are untrusted path input.
|
|
1139
|
+
*/
|
|
1140
|
+
function isSafeSegment(segment) {
|
|
1141
|
+
return segment !== "" && segment !== "." && segment !== ".." && !/[/\\\0]/.test(segment);
|
|
1142
|
+
}
|
|
1143
|
+
/** Whether absolute path `target` lies strictly inside absolute path `root`. */
|
|
1144
|
+
function isInside(root, target) {
|
|
1145
|
+
const rel = relative(root, target);
|
|
1146
|
+
return rel !== "" && !isAbsolute(rel) && rel.split(sep)[0] !== "..";
|
|
1147
|
+
}
|
|
1148
|
+
/** The cache directory for one format version. Throws when the name or version could leave the cache. */
|
|
1149
|
+
function cachedFormatDir(name, version) {
|
|
1150
|
+
if (!isSafeSegment(name)) throw new Error(`Refusing to cache a story format with an unsafe name: ${JSON.stringify(name)}`);
|
|
1151
|
+
if (!isSafeSegment(version) || !parseSemver(version)) throw new Error(`Refusing to cache story format "${name}" with an unsafe version: ${JSON.stringify(version)}`);
|
|
1152
|
+
const root = resolve(getCacheDir());
|
|
1153
|
+
const dir = resolve(root, name, version);
|
|
1154
|
+
if (!isInside(root, dir)) throw new Error(`Refusing to cache a story format outside the cache directory ${root}: ${dir}`);
|
|
1155
|
+
return dir;
|
|
1156
|
+
}
|
|
1157
|
+
/** Create `dir` as a plain directory, or check that it already is one (a symlink could lead out of the cache). */
|
|
1158
|
+
function ensurePlainDirectory(dir) {
|
|
1159
|
+
const stat = lstatSync(dir, { throwIfNoEntry: false });
|
|
1160
|
+
if (stat === void 0) {
|
|
1161
|
+
mkdirSync(dir);
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`Refusing to write a story format outside the cache directory: ${dir} is not a plain directory`);
|
|
1165
|
+
}
|
|
1166
|
+
/** Write a downloaded format.js to `<cache>/<name>/<version>/format.js` and return its path. */
|
|
1167
|
+
function writeCachedFormat(name, version, text) {
|
|
1168
|
+
const dir = cachedFormatDir(name, version);
|
|
1169
|
+
const root = resolve(getCacheDir());
|
|
1170
|
+
mkdirSync(root, { recursive: true });
|
|
1171
|
+
ensurePlainDirectory(join(root, name));
|
|
1172
|
+
ensurePlainDirectory(dir);
|
|
1173
|
+
const formatPath = join(dir, "format.js");
|
|
1174
|
+
if (lstatSync(formatPath, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error(`Refusing to write a story format outside the cache directory: ${formatPath} is a symlink`);
|
|
1175
|
+
writeFileSync(formatPath, text, "utf-8");
|
|
1176
|
+
return formatPath;
|
|
1177
|
+
}
|
|
1178
|
+
/** In-memory index cache, keyed by URL. Cleared each compile. */
|
|
1179
|
+
const indexCache = /* @__PURE__ */ new Map();
|
|
1180
|
+
/** Clear the in-memory index cache. */
|
|
1181
|
+
function clearIndexCache() {
|
|
1182
|
+
indexCache.clear();
|
|
1183
|
+
}
|
|
1184
|
+
/** Validate that a JSON value is a valid SFAIndexEntry. */
|
|
1185
|
+
function isValidEntry(val) {
|
|
1186
|
+
if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
|
|
1187
|
+
const obj = val;
|
|
1188
|
+
return typeof obj.name === "string" && typeof obj.version === "string" && typeof obj.checksums === "object" && obj.checksums !== null && !Array.isArray(obj.checksums);
|
|
1189
|
+
}
|
|
1190
|
+
/** Validate that a JSON value conforms to the SFAIndex shape. */
|
|
1191
|
+
function validateSFAIndex(json) {
|
|
1192
|
+
if (typeof json !== "object" || json === null) throw new Error("SFA index is not an object");
|
|
1193
|
+
const obj = json;
|
|
1194
|
+
return {
|
|
1195
|
+
twine1: Array.isArray(obj["twine1"]) ? obj["twine1"].filter(isValidEntry) : [],
|
|
1196
|
+
twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
/** Fetch and parse an SFA index.json, with in-memory caching. */
|
|
1200
|
+
async function fetchIndex(url) {
|
|
1201
|
+
const cached = indexCache.get(url);
|
|
1202
|
+
if (cached) return cached;
|
|
1203
|
+
const res = await fetch(url);
|
|
1204
|
+
if (!res.ok) throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
|
|
1205
|
+
const data = validateSFAIndex(await res.json());
|
|
1206
|
+
indexCache.set(url, data);
|
|
1207
|
+
return data;
|
|
1208
|
+
}
|
|
1209
|
+
/** Find the best matching entry in an SFA index for a name or ID request (twine2 entries first). */
|
|
1210
|
+
function findEntryForRequest(index, request) {
|
|
1211
|
+
return selectFormatCandidate(request, [...(index.twine2 ?? []).map((entry) => ({
|
|
1212
|
+
entry,
|
|
1213
|
+
formatType: "twine2"
|
|
1214
|
+
})), ...(index.twine1 ?? []).map((entry) => ({
|
|
1215
|
+
entry,
|
|
1216
|
+
formatType: "twine1"
|
|
1217
|
+
}))], (c) => c.entry);
|
|
1218
|
+
}
|
|
1219
|
+
/** Verify SHA-256 checksum using Web Crypto API (Node 22 built-in). */
|
|
1220
|
+
async function verifySHA256(content, expectedHex) {
|
|
1221
|
+
const digest = await crypto.subtle.digest("SHA-256", content);
|
|
1222
|
+
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("") === expectedHex.toLowerCase();
|
|
1223
|
+
}
|
|
1224
|
+
/** Derive the download URL for a format.js from the index URL and entry. */
|
|
1225
|
+
function getDownloadUrl(indexUrl, entry, formatType) {
|
|
1226
|
+
return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
|
|
1227
|
+
}
|
|
1228
|
+
/** Download a format, verify its checksum, write to cache, and return StoryFormatInfo. */
|
|
1229
|
+
async function fetchAndCacheFormat(entry, downloadUrl) {
|
|
1230
|
+
cachedFormatDir(entry.name, entry.version);
|
|
1231
|
+
const res = await fetch(downloadUrl);
|
|
1232
|
+
if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
|
|
1233
|
+
const text = await res.text();
|
|
1234
|
+
const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
|
|
1235
|
+
if (checksumKey) {
|
|
1236
|
+
const expected = entry.checksums[checksumKey];
|
|
1237
|
+
if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
|
|
1238
|
+
if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
|
|
1239
|
+
}
|
|
1240
|
+
const id = makeFormatId(entry.name, entry.version);
|
|
1241
|
+
const data = parseFormatJSON(text, id);
|
|
1242
|
+
if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
|
|
1243
|
+
return {
|
|
1244
|
+
id,
|
|
1245
|
+
filename: writeCachedFormat(entry.name, entry.version, text),
|
|
1246
|
+
isTwine2: true,
|
|
1247
|
+
name: data.name,
|
|
1248
|
+
version: data.version,
|
|
1249
|
+
proofing: data.proofing ?? false
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
/** Download a direct format.js URL, parse its JSON, cache it, and return StoryFormatInfo. */
|
|
1253
|
+
async function fetchDirectFormat(url) {
|
|
1254
|
+
const res = await fetch(url);
|
|
1255
|
+
if (!res.ok) throw new Error(`Failed to download format from ${url}: ${res.status} ${res.statusText}`);
|
|
1256
|
+
const text = await res.text();
|
|
1257
|
+
const data = parseFormatJSON(text);
|
|
1258
|
+
if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
|
|
1259
|
+
return {
|
|
1260
|
+
id: makeFormatId(data.name, data.version),
|
|
1261
|
+
filename: writeCachedFormat(data.name, data.version, text),
|
|
1262
|
+
isTwine2: true,
|
|
1263
|
+
name: data.name,
|
|
1264
|
+
version: data.version,
|
|
1265
|
+
proofing: data.proofing ?? false
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
/**
|
|
1269
|
+
* Resolve a story format request remotely, in the same order as {@link resolveRemoteFormat}.
|
|
1270
|
+
* An ID request such as 'sugarcube-2' matches the format whose name and major version build that ID
|
|
1271
|
+
* (SugarCube 2.x), taking the greatest version available.
|
|
1272
|
+
*/
|
|
1273
|
+
async function resolveRemoteFormatRequest(request, indices, urls) {
|
|
1274
|
+
let lastError;
|
|
1275
|
+
const cached = findCachedFormat(request);
|
|
1276
|
+
if (cached && request.kind === "name" && isExactVersion(cached.version, request.version)) return cached;
|
|
1277
|
+
for (const url of urls ?? []) try {
|
|
1278
|
+
const info = await fetchDirectFormat(url);
|
|
1279
|
+
if (selectFormatCandidate(request, [info], (f) => f)) return info;
|
|
1280
|
+
} catch (e) {
|
|
1281
|
+
lastError = e instanceof Error ? e : new Error(String(e));
|
|
1282
|
+
}
|
|
1283
|
+
const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
|
|
1284
|
+
for (const indexUrl of allIndices) try {
|
|
1285
|
+
const result = findEntryForRequest(await fetchIndex(indexUrl), request);
|
|
1286
|
+
if (result) {
|
|
1287
|
+
const hit = getCachedFormat(result.entry.name, result.entry.version);
|
|
1288
|
+
if (hit) return hit;
|
|
1289
|
+
const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
|
|
1290
|
+
return await fetchAndCacheFormat(result.entry, downloadUrl);
|
|
1291
|
+
}
|
|
1292
|
+
} catch (e) {
|
|
1293
|
+
lastError = e instanceof Error ? e : new Error(String(e));
|
|
1294
|
+
}
|
|
1295
|
+
if (cached) return cached;
|
|
1296
|
+
if (lastError) throw lastError;
|
|
1297
|
+
}
|
|
1298
|
+
/** Whether two version strings name the same SemVer version. */
|
|
1299
|
+
function isExactVersion(have, wanted) {
|
|
1300
|
+
const a = parseSemver(have);
|
|
1301
|
+
const b = parseSemver(wanted);
|
|
1302
|
+
return a !== null && b !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
|
|
1303
|
+
}
|
|
1304
|
+
/**
|
|
1305
|
+
* Find the cached download that best answers a format request, without network access.
|
|
1306
|
+
* Matching follows {@link selectFormatCandidate}.
|
|
1307
|
+
*/
|
|
1308
|
+
function findCachedFormat(request, options = {}) {
|
|
1309
|
+
return selectFormatCandidate(request, [...discoverCachedFormats().values()], (f) => f, options);
|
|
1310
|
+
}
|
|
1311
|
+
/** Check if a format is already in the local cache. */
|
|
1312
|
+
function getCachedFormat(name, version) {
|
|
1313
|
+
if (!isSafeSegment(name) || !isSafeSegment(version)) return void 0;
|
|
1314
|
+
const formatPath = join(getCacheDir(), name, version, "format.js");
|
|
1315
|
+
try {
|
|
1316
|
+
if (!existsSync(formatPath)) return void 0;
|
|
1317
|
+
const source = readFileSync(formatPath, "utf-8");
|
|
1318
|
+
const id = makeFormatId(name, version);
|
|
1319
|
+
const data = parseFormatJSON(source, id);
|
|
1320
|
+
if (!data) return void 0;
|
|
1321
|
+
return {
|
|
1322
|
+
id,
|
|
1323
|
+
filename: formatPath,
|
|
1324
|
+
isTwine2: true,
|
|
1325
|
+
name: data.name,
|
|
1326
|
+
version: data.version,
|
|
1327
|
+
proofing: data.proofing ?? false
|
|
1328
|
+
};
|
|
1329
|
+
} catch {
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
/** Discover all cached remote formats. Returns a Map like discoverFormats(). */
|
|
1334
|
+
function discoverCachedFormats() {
|
|
1335
|
+
const formats = /* @__PURE__ */ new Map();
|
|
1336
|
+
const cacheDir = getCacheDir();
|
|
1337
|
+
try {
|
|
1338
|
+
if (!existsSync(cacheDir)) return formats;
|
|
1339
|
+
} catch {
|
|
1340
|
+
return formats;
|
|
1341
|
+
}
|
|
1342
|
+
let names;
|
|
1343
|
+
try {
|
|
1344
|
+
names = readdirSync(cacheDir);
|
|
1345
|
+
} catch {
|
|
1346
|
+
return formats;
|
|
1347
|
+
}
|
|
1348
|
+
for (const name of names) {
|
|
1349
|
+
const nameDir = join(cacheDir, name);
|
|
1350
|
+
try {
|
|
1351
|
+
if (!statSync(nameDir).isDirectory()) continue;
|
|
1352
|
+
} catch {
|
|
1353
|
+
continue;
|
|
1354
|
+
}
|
|
1355
|
+
let versions;
|
|
1356
|
+
try {
|
|
1357
|
+
versions = readdirSync(nameDir);
|
|
1358
|
+
} catch {
|
|
1359
|
+
continue;
|
|
1360
|
+
}
|
|
1361
|
+
for (const version of versions) {
|
|
1362
|
+
const info = getCachedFormat(name, version);
|
|
1363
|
+
if (info) formats.set(`${info.id}-${version}`, info);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
return formats;
|
|
1367
|
+
}
|
|
1368
|
+
/** List all cached formats with size and modification date. */
|
|
1369
|
+
function listCachedFormats() {
|
|
1370
|
+
const cacheDir = getCacheDir();
|
|
1371
|
+
const entries = [];
|
|
1372
|
+
try {
|
|
1373
|
+
if (!existsSync(cacheDir)) return entries;
|
|
1374
|
+
} catch {
|
|
1375
|
+
return entries;
|
|
1376
|
+
}
|
|
1377
|
+
let names;
|
|
1378
|
+
try {
|
|
1379
|
+
names = readdirSync(cacheDir);
|
|
1380
|
+
} catch {
|
|
1381
|
+
return entries;
|
|
1382
|
+
}
|
|
1383
|
+
for (const name of names) {
|
|
1384
|
+
const nameDir = join(cacheDir, name);
|
|
1385
|
+
try {
|
|
1386
|
+
if (!statSync(nameDir).isDirectory()) continue;
|
|
1387
|
+
} catch {
|
|
1388
|
+
continue;
|
|
1389
|
+
}
|
|
1390
|
+
let versions;
|
|
1391
|
+
try {
|
|
1392
|
+
versions = readdirSync(nameDir);
|
|
1393
|
+
} catch {
|
|
1394
|
+
continue;
|
|
1395
|
+
}
|
|
1396
|
+
for (const version of versions) {
|
|
1397
|
+
const formatPath = join(nameDir, version, "format.js");
|
|
1398
|
+
try {
|
|
1399
|
+
const stat = statSync(formatPath);
|
|
1400
|
+
entries.push({
|
|
1401
|
+
name,
|
|
1402
|
+
version,
|
|
1403
|
+
sizeBytes: stat.size,
|
|
1404
|
+
modifiedAt: stat.mtime
|
|
1405
|
+
});
|
|
1406
|
+
} catch {}
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
return entries;
|
|
1410
|
+
}
|
|
1411
|
+
/**
|
|
1412
|
+
* Clear all cached formats, or only those matching a given name. Returns the number of entries removed.
|
|
1413
|
+
* A name must be a single cache entry name (as `listCachedFormats()` reports it); anything with a
|
|
1414
|
+
* path separator or `.`/`..` throws instead of deleting outside the format's own directory.
|
|
1415
|
+
*/
|
|
1416
|
+
function clearCachedFormats(name) {
|
|
1417
|
+
if (name && !isSafeSegment(name)) throw new Error(`Refusing to clear ${JSON.stringify(name)}: it is not a cached format name. Use a name as "cache list" shows it.`);
|
|
1418
|
+
const cacheDir = getCacheDir();
|
|
1419
|
+
if (!existsSync(cacheDir)) return 0;
|
|
1420
|
+
if (!name) {
|
|
1421
|
+
const count = listCachedFormats().length;
|
|
1422
|
+
rmSync(cacheDir, {
|
|
1423
|
+
recursive: true,
|
|
1424
|
+
force: true
|
|
1425
|
+
});
|
|
1426
|
+
return count;
|
|
1427
|
+
}
|
|
1428
|
+
const root = resolve(cacheDir);
|
|
1429
|
+
const nameDir = resolve(root, name);
|
|
1430
|
+
if (!isInside(root, nameDir)) throw new Error(`Refusing to clear ${JSON.stringify(name)}: it is not a cached format name.`);
|
|
1431
|
+
if (!lstatSync(nameDir, { throwIfNoEntry: false })?.isDirectory()) return 0;
|
|
1432
|
+
let versions;
|
|
1433
|
+
try {
|
|
1434
|
+
versions = readdirSync(nameDir);
|
|
1435
|
+
} catch {
|
|
1436
|
+
return 0;
|
|
1437
|
+
}
|
|
1438
|
+
const count = versions.length;
|
|
1439
|
+
rmSync(nameDir, {
|
|
1440
|
+
recursive: true,
|
|
1441
|
+
force: true
|
|
1442
|
+
});
|
|
1443
|
+
return count;
|
|
1444
|
+
}
|
|
1445
|
+
/** Get total cache size in bytes and format count. */
|
|
1446
|
+
function getCacheSize() {
|
|
1447
|
+
const entries = listCachedFormats();
|
|
1448
|
+
return {
|
|
1449
|
+
totalBytes: entries.reduce((sum, e) => sum + e.sizeBytes, 0),
|
|
1450
|
+
count: entries.length
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
//#endregion
|
|
1454
|
+
//#region src/format-resolution.ts
|
|
1455
|
+
/** Choose the format request: explicit format ID > StoryData format > default ID. */
|
|
1456
|
+
function formatRequestFor(formatId, storyFormat, storyFormatVersion, defaultId) {
|
|
1457
|
+
if (formatId) return {
|
|
1458
|
+
kind: "id",
|
|
1459
|
+
id: formatId
|
|
1460
|
+
};
|
|
1461
|
+
if (storyFormat) return {
|
|
1462
|
+
kind: "name",
|
|
1463
|
+
name: storyFormat,
|
|
1464
|
+
version: storyFormatVersion
|
|
1465
|
+
};
|
|
1466
|
+
return {
|
|
1467
|
+
kind: "id",
|
|
1468
|
+
id: defaultId
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
/** Look a request up among the formats found in local format directories. */
|
|
1472
|
+
function findLocalFormat(formats, request) {
|
|
1473
|
+
switch (request.kind) {
|
|
1474
|
+
case "id": return formats.get(request.id);
|
|
1475
|
+
case "name": {
|
|
1476
|
+
const id = getFormatIdByNameAndVersion(formats, request.name, request.version);
|
|
1477
|
+
return id === void 0 ? void 0 : formats.get(id);
|
|
1478
|
+
}
|
|
1479
|
+
default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
/**
|
|
1483
|
+
* Resolve a format request to a story format.
|
|
1484
|
+
*
|
|
1485
|
+
* Order: local format directories, then the download cache, then remote sources (unless
|
|
1486
|
+
* `noRemote`). When a StoryData request names a version that none of them has, an older version of
|
|
1487
|
+
* the same format and major version is used with a warning. Anything else is an error diagnostic,
|
|
1488
|
+
* and the result is `undefined`.
|
|
1489
|
+
*/
|
|
1490
|
+
async function resolveStoryFormat(request, options, diagnostics) {
|
|
1491
|
+
const noRemote = options.noRemote ?? false;
|
|
1492
|
+
const formats = discoverFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true));
|
|
1493
|
+
const wanted = describeFormatRequest(request);
|
|
1494
|
+
const local = findLocalFormat(formats, request);
|
|
1495
|
+
if (local) return local;
|
|
1496
|
+
const cached = findCachedFormat(request);
|
|
1497
|
+
if (cached) return cached;
|
|
1498
|
+
if (!noRemote) try {
|
|
1499
|
+
const remote = await resolveRemoteFormatRequest(request, options.formatIndices, options.formatUrls);
|
|
1500
|
+
if (remote) return remote;
|
|
1501
|
+
} catch (e) {
|
|
1502
|
+
diagnostics.push({
|
|
1503
|
+
level: "warning",
|
|
1504
|
+
message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
if (request.kind === "name") {
|
|
1508
|
+
const older = selectFormatCandidate(request, [...formats.values()].filter((f) => f.isTwine2), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
|
|
1509
|
+
if (older) {
|
|
1510
|
+
diagnostics.push({
|
|
1511
|
+
level: "warning",
|
|
1512
|
+
message: `Story format ${wanted} is not available; using ${older.name} ${older.version} instead.`
|
|
1513
|
+
});
|
|
1514
|
+
return older;
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
const reason = noRemote ? " (remote fetching disabled)" : "";
|
|
1518
|
+
diagnostics.push({
|
|
1519
|
+
level: "error",
|
|
1520
|
+
message: `Story format ${wanted} is not available${reason}. Found: ${[...formats.keys()].join(", ") || "none"}`
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
//#endregion
|
|
966
1524
|
//#region src/types.ts
|
|
967
1525
|
const ItemType = {
|
|
968
1526
|
Error: 0,
|
|
@@ -1226,16 +1784,20 @@ function twee2ToV3(s) {
|
|
|
1226
1784
|
//#region src/parser.ts
|
|
1227
1785
|
/**
|
|
1228
1786
|
* Parse Twee source text into passages.
|
|
1787
|
+
*
|
|
1788
|
+
* The source is normalized first, as files are when they are read: a leading UTF-8 BOM is
|
|
1789
|
+
* removed and CRLF and bare CR line endings become LF.
|
|
1229
1790
|
*/
|
|
1230
1791
|
function parseTwee(source, options = {}) {
|
|
1231
1792
|
const { filename = "<inline>", trim = true, twee2Compat = false } = options;
|
|
1232
1793
|
const diagnostics = [];
|
|
1233
|
-
|
|
1794
|
+
const normalized = normalizeSourceText(source);
|
|
1795
|
+
const tweeSource = twee2Compat ? twee2ToV3(normalized) : normalized;
|
|
1234
1796
|
const passages = [];
|
|
1235
1797
|
let current = null;
|
|
1236
1798
|
let pCount = 0;
|
|
1237
1799
|
let lastType = ItemType.EOF;
|
|
1238
|
-
for (const item of tweeLexer(
|
|
1800
|
+
for (const item of tweeLexer(tweeSource)) {
|
|
1239
1801
|
switch (item.type) {
|
|
1240
1802
|
case ItemType.Error:
|
|
1241
1803
|
diagnostics.push({
|
|
@@ -4116,6 +4678,7 @@ function decompileTwine2(storyData, story, diagnostics) {
|
|
|
4116
4678
|
});
|
|
4117
4679
|
else story.twine2.zoom = parsed;
|
|
4118
4680
|
}
|
|
4681
|
+
if (attrs["tags"]) story.twine2.tags = attrs["tags"];
|
|
4119
4682
|
if (attrs["format"]) story.twine2.format = attrs["format"];
|
|
4120
4683
|
if (attrs["format-version"]) story.twine2.formatVersion = attrs["format-version"];
|
|
4121
4684
|
if (attrs["options"]) for (const opt of attrs["options"].split(/\s+/).filter((s) => s.length > 0)) story.twine2.options.set(opt, true);
|
|
@@ -4175,22 +4738,32 @@ function decompileTwine2(storyData, story, diagnostics) {
|
|
|
4175
4738
|
}, diagnostics);
|
|
4176
4739
|
}
|
|
4177
4740
|
function decompileTwine1(storeArea, story, diagnostics) {
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4741
|
+
const passages = (storeArea.children ?? []).filter(isTiddler).map(tiddlerToPassage);
|
|
4742
|
+
const obfuscated = isRot13Obfuscated(passages);
|
|
4743
|
+
for (const p of passages) storyAdd(story, obfuscated && p.name !== "StorySettings" ? {
|
|
4744
|
+
...p,
|
|
4745
|
+
text: rot13(p.text)
|
|
4746
|
+
} : p, diagnostics);
|
|
4747
|
+
}
|
|
4748
|
+
function isTiddler(node) {
|
|
4749
|
+
return node.type === "tag" && node.name === "div" && node.attribs !== void 0 && "tiddler" in node.attribs;
|
|
4750
|
+
}
|
|
4751
|
+
function tiddlerToPassage(node) {
|
|
4752
|
+
const nodeAttrs = node.attribs ?? {};
|
|
4753
|
+
const passage = {
|
|
4754
|
+
name: nodeAttrs["tiddler"] ?? "",
|
|
4755
|
+
tags: nodeAttrs["tags"] ? nodeAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [],
|
|
4756
|
+
text: tiddlerUnescape(getTextContent(node)).trim()
|
|
4757
|
+
};
|
|
4758
|
+
const position = nodeAttrs["twine-position"];
|
|
4759
|
+
if (position) passage.metadata = { position };
|
|
4760
|
+
return passage;
|
|
4761
|
+
}
|
|
4762
|
+
/** Whether the StorySettings tiddlers turn on ROT13 obfuscation, read the way the writer reads them. */
|
|
4763
|
+
function isRot13Obfuscated(passages) {
|
|
4764
|
+
const settings = createStory();
|
|
4765
|
+
for (const p of passages) if (p.name === "StorySettings") unmarshalStorySettings(settings, p.text, []);
|
|
4766
|
+
return settings.twine1.settings.get("obfuscate") === "rot13";
|
|
4194
4767
|
}
|
|
4195
4768
|
function findElement(node, tagName) {
|
|
4196
4769
|
if (node.type === "tag" && node.name === tagName) return node;
|
|
@@ -4313,7 +4886,7 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
|
|
|
4313
4886
|
function loadInlineSources(story, sources, opts, diagnostics) {
|
|
4314
4887
|
for (const source of sources) {
|
|
4315
4888
|
if (typeof source === "string") continue;
|
|
4316
|
-
const content = typeof source.content === "string" ? source.content : source.content.toString("utf-8");
|
|
4889
|
+
const content = normalizeSourceText(typeof source.content === "string" ? source.content : source.content.toString("utf-8"));
|
|
4317
4890
|
const ext = normalizedFileExt(source.filename);
|
|
4318
4891
|
switch (ext) {
|
|
4319
4892
|
case "":
|
|
@@ -4400,12 +4973,29 @@ function parseFontFile(filename) {
|
|
|
4400
4973
|
};
|
|
4401
4974
|
}
|
|
4402
4975
|
function parseHTMLFile(filename) {
|
|
4403
|
-
const
|
|
4976
|
+
const { story, diagnostics } = decompileHTML(readUTF8(filename));
|
|
4404
4977
|
return {
|
|
4405
|
-
passages:
|
|
4406
|
-
|
|
4978
|
+
passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
|
|
4979
|
+
name: "StoryTitle",
|
|
4980
|
+
tags: [],
|
|
4981
|
+
text: story.name
|
|
4982
|
+
}, ...story.passages] : story.passages,
|
|
4983
|
+
diagnostics: [...diagnostics]
|
|
4407
4984
|
};
|
|
4408
4985
|
}
|
|
4986
|
+
/**
|
|
4987
|
+
* Identity of the options that affect how a file parses, stored on its cache entry.
|
|
4988
|
+
* Only Twee files depend on them; every other file type yields the same key for all options.
|
|
4989
|
+
*/
|
|
4990
|
+
function parseOptionsKey(filename, opts) {
|
|
4991
|
+
const ext = normalizedFileExt(filename);
|
|
4992
|
+
const twee2File = ext === "tw2" || ext === "twee2";
|
|
4993
|
+
if (!twee2File && ext !== "tw" && ext !== "twee") return "";
|
|
4994
|
+
return JSON.stringify({
|
|
4995
|
+
trim: opts.trim ?? true,
|
|
4996
|
+
twee2Compat: twee2File || (opts.twee2Compat ?? false)
|
|
4997
|
+
});
|
|
4998
|
+
}
|
|
4409
4999
|
function parseFile(filename, opts) {
|
|
4410
5000
|
switch (normalizedFileExt(filename)) {
|
|
4411
5001
|
case "tw":
|
|
@@ -4485,7 +5075,9 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
4485
5075
|
continue;
|
|
4486
5076
|
}
|
|
4487
5077
|
currentFiles.add(filename);
|
|
4488
|
-
const
|
|
5078
|
+
const optionsKey = parseOptionsKey(filename, opts);
|
|
5079
|
+
const entry = cache.get(filename);
|
|
5080
|
+
const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
|
|
4489
5081
|
if (changedFiles && cached && !changedFiles.has(filename)) {
|
|
4490
5082
|
diagnostics.push(...cached.diagnostics);
|
|
4491
5083
|
for (const p of cached.passages) storyAdd(story, p, diagnostics);
|
|
@@ -4509,6 +5101,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
4509
5101
|
if (!result) continue;
|
|
4510
5102
|
cache.set(filename, {
|
|
4511
5103
|
mtimeMs,
|
|
5104
|
+
parseOptionsKey: optionsKey,
|
|
4512
5105
|
passages: result.passages,
|
|
4513
5106
|
diagnostics: result.diagnostics
|
|
4514
5107
|
});
|
|
@@ -4572,11 +5165,39 @@ function toTwine2HTML(story, format, startName, options) {
|
|
|
4572
5165
|
template = template.replaceAll("{{STORY_NAME}}", () => name);
|
|
4573
5166
|
}
|
|
4574
5167
|
if (template.includes("{{STORY_DATA}}")) {
|
|
4575
|
-
const data = getTwine2DataChunk(
|
|
5168
|
+
const data = getTwine2DataChunk({
|
|
5169
|
+
...story,
|
|
5170
|
+
twine2: {
|
|
5171
|
+
...story.twine2,
|
|
5172
|
+
format: format.name,
|
|
5173
|
+
formatVersion: format.version
|
|
5174
|
+
}
|
|
5175
|
+
}, startName, options);
|
|
4576
5176
|
template = template.replace("{{STORY_DATA}}", () => data);
|
|
4577
5177
|
}
|
|
4578
5178
|
return template;
|
|
4579
5179
|
}
|
|
5180
|
+
const OMITTING_TAGS = [
|
|
5181
|
+
"Twine.private",
|
|
5182
|
+
"script",
|
|
5183
|
+
"stylesheet"
|
|
5184
|
+
];
|
|
5185
|
+
/**
|
|
5186
|
+
* Why Twine 2 output leaves a passage out of its `<tw-passagedata>` elements,
|
|
5187
|
+
* or `undefined` when the passage is emitted (and so can be the starting passage).
|
|
5188
|
+
*/
|
|
5189
|
+
function twine2PassageOmission(story, p) {
|
|
5190
|
+
if (p.name === "StoryTitle" || p.name === "StoryData") return {
|
|
5191
|
+
kind: "special-name",
|
|
5192
|
+
name: p.name
|
|
5193
|
+
};
|
|
5194
|
+
const tag = OMITTING_TAGS.find((t) => hasTag(p, t));
|
|
5195
|
+
if (tag !== void 0) return {
|
|
5196
|
+
kind: "tag",
|
|
5197
|
+
tag
|
|
5198
|
+
};
|
|
5199
|
+
if (p.name === "StorySettings" && story.twine1.settings.size === 0) return { kind: "empty-story-settings" };
|
|
5200
|
+
}
|
|
4580
5201
|
function getTwine2DataChunk(story, startName, options) {
|
|
4581
5202
|
const parts = [];
|
|
4582
5203
|
let startID = "";
|
|
@@ -4599,7 +5220,7 @@ function getTwine2DataChunk(story, startName, options) {
|
|
|
4599
5220
|
pidS++;
|
|
4600
5221
|
}
|
|
4601
5222
|
}
|
|
4602
|
-
parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContent}</style>`);
|
|
5223
|
+
parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContentEscape(styleContent)}</style>`);
|
|
4603
5224
|
let scriptContent = "";
|
|
4604
5225
|
if (scripts.length === 1) scriptContent = scripts[0]?.text ?? "";
|
|
4605
5226
|
else if (scripts.length > 1) {
|
|
@@ -4611,7 +5232,7 @@ function getTwine2DataChunk(story, startName, options) {
|
|
|
4611
5232
|
pidS++;
|
|
4612
5233
|
}
|
|
4613
5234
|
}
|
|
4614
|
-
parts.push(`<script role="script" id="twine-user-script" type="text/twine-javascript">${scriptContent}<\/script>`);
|
|
5235
|
+
parts.push(`<script role="script" id="twine-user-script" type="text/twine-javascript">${scriptContentEscape(scriptContent)}<\/script>`);
|
|
4615
5236
|
const validTagColors = /* @__PURE__ */ new Set([
|
|
4616
5237
|
"gray",
|
|
4617
5238
|
"red",
|
|
@@ -4625,8 +5246,7 @@ function getTwine2DataChunk(story, startName, options) {
|
|
|
4625
5246
|
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>`);
|
|
4626
5247
|
pid = 1;
|
|
4627
5248
|
for (const p of story.passages) {
|
|
4628
|
-
if (
|
|
4629
|
-
if (p.name === "StorySettings" && story.twine1.settings.size === 0) continue;
|
|
5249
|
+
if (twine2PassageOmission(story, p) !== void 0) continue;
|
|
4630
5250
|
parts.push(passageToPassagedata(p, pid, options));
|
|
4631
5251
|
if (startName === p.name) startID = String(pid);
|
|
4632
5252
|
pid++;
|
|
@@ -4682,12 +5302,21 @@ function toTwine1HTML(story, format, startName) {
|
|
|
4682
5302
|
}
|
|
4683
5303
|
return template;
|
|
4684
5304
|
}
|
|
5305
|
+
/**
|
|
5306
|
+
* Why Twine 1 output leaves a passage out of its tiddlers, or `undefined` when the passage is emitted.
|
|
5307
|
+
*/
|
|
5308
|
+
function twine1PassageOmission(p) {
|
|
5309
|
+
return hasTag(p, "Twine.private") ? {
|
|
5310
|
+
kind: "tag",
|
|
5311
|
+
tag: "Twine.private"
|
|
5312
|
+
} : void 0;
|
|
5313
|
+
}
|
|
4685
5314
|
function getTwine1PassageChunk(story) {
|
|
4686
5315
|
const obfuscateRot13 = story.twine1.settings.get("obfuscate") === "rot13";
|
|
4687
5316
|
let data = "";
|
|
4688
5317
|
let count = 0;
|
|
4689
5318
|
for (const p of story.passages) {
|
|
4690
|
-
if (
|
|
5319
|
+
if (twine1PassageOmission(p) !== void 0) continue;
|
|
4691
5320
|
count++;
|
|
4692
5321
|
data += passageToTiddler(p, count, obfuscateRot13);
|
|
4693
5322
|
}
|
|
@@ -4738,358 +5367,84 @@ function loadModules(filenames) {
|
|
|
4738
5367
|
case "woff":
|
|
4739
5368
|
case "woff2":
|
|
4740
5369
|
tag = loadModuleFont(filename);
|
|
4741
|
-
break;
|
|
4742
|
-
default: continue;
|
|
4743
|
-
}
|
|
4744
|
-
if (tag) headTags.push(tag);
|
|
4745
|
-
processed.add(filename);
|
|
4746
|
-
}
|
|
4747
|
-
return headTags.join("\n");
|
|
4748
|
-
}
|
|
4749
|
-
function loadModuleTagged(tag, filename) {
|
|
4750
|
-
const source = readUTF8(filename).trim();
|
|
4751
|
-
if (source.length === 0) return null;
|
|
4752
|
-
return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${source}</${tag}>`;
|
|
4753
|
-
}
|
|
4754
|
-
function loadModuleFont(filename) {
|
|
4755
|
-
const source = readBase64(filename);
|
|
4756
|
-
const family = baseNameWithoutExt(filename);
|
|
4757
|
-
const idSlug = `style-module-${slugify(family)}`;
|
|
4758
|
-
const ext = normalizedFileExt(filename);
|
|
4759
|
-
return `<style id="${idSlug}" type="text/css">@font-face {\n\tfont-family: "${family}";\n\tsrc: url("data:${mediaTypeFromExt(ext)};base64,${source}") format("${fontFormatHint(ext)}");\n}</style>`;
|
|
4760
|
-
}
|
|
4761
|
-
/**
|
|
4762
|
-
* Inject modules and head file content before </head>.
|
|
4763
|
-
*/
|
|
4764
|
-
function modifyHead(html, modulePaths, headFile, diagnostics) {
|
|
4765
|
-
const parts = [];
|
|
4766
|
-
if (modulePaths.length > 0) {
|
|
4767
|
-
const modules = loadModules(modulePaths).trim();
|
|
4768
|
-
if (modules.length > 0) parts.push(modules);
|
|
4769
|
-
}
|
|
4770
|
-
if (headFile) try {
|
|
4771
|
-
const source = readUTF8(headFile).trim();
|
|
4772
|
-
if (source.length > 0) parts.push(source);
|
|
4773
|
-
} catch (e) {
|
|
4774
|
-
diagnostics?.push({
|
|
4775
|
-
level: "warning",
|
|
4776
|
-
message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
|
|
4777
|
-
});
|
|
4778
|
-
}
|
|
4779
|
-
if (parts.length > 0) {
|
|
4780
|
-
parts.push("</head>");
|
|
4781
|
-
const replacement = parts.join("\n");
|
|
4782
|
-
return html.replace("</head>", () => replacement);
|
|
4783
|
-
}
|
|
4784
|
-
return html;
|
|
4785
|
-
}
|
|
4786
|
-
//#endregion
|
|
4787
|
-
//#region src/remote-formats.ts
|
|
4788
|
-
/**
|
|
4789
|
-
* Remote story format fetching, caching, and checksum verification.
|
|
4790
|
-
* Uses the Story Formats Archive (SFA) as the default source.
|
|
4791
|
-
*/
|
|
4792
|
-
const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
|
|
4793
|
-
/** Get the cache directory for downloaded story formats. */
|
|
4794
|
-
function getCacheDir() {
|
|
4795
|
-
const base = process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache");
|
|
4796
|
-
return join(base, "twee-ts", "storyformats");
|
|
4797
|
-
}
|
|
4798
|
-
/** In-memory index cache, keyed by URL. Cleared each compile. */
|
|
4799
|
-
const indexCache = /* @__PURE__ */ new Map();
|
|
4800
|
-
/** Clear the in-memory index cache. */
|
|
4801
|
-
function clearIndexCache() {
|
|
4802
|
-
indexCache.clear();
|
|
4803
|
-
}
|
|
4804
|
-
/** Validate that a JSON value is a valid SFAIndexEntry. */
|
|
4805
|
-
function isValidEntry(val) {
|
|
4806
|
-
if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
|
|
4807
|
-
const obj = val;
|
|
4808
|
-
return typeof obj.name === "string" && typeof obj.version === "string" && typeof obj.checksums === "object" && obj.checksums !== null && !Array.isArray(obj.checksums);
|
|
4809
|
-
}
|
|
4810
|
-
/** Validate that a JSON value conforms to the SFAIndex shape. */
|
|
4811
|
-
function validateSFAIndex(json) {
|
|
4812
|
-
if (typeof json !== "object" || json === null) throw new Error("SFA index is not an object");
|
|
4813
|
-
const obj = json;
|
|
4814
|
-
return {
|
|
4815
|
-
twine1: Array.isArray(obj["twine1"]) ? obj["twine1"].filter(isValidEntry) : [],
|
|
4816
|
-
twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
|
|
4817
|
-
};
|
|
4818
|
-
}
|
|
4819
|
-
/** Generate a format ID from name and version. */
|
|
4820
|
-
function makeFormatId(name, version) {
|
|
4821
|
-
const major = parseSemver(version)?.[0] ?? "0";
|
|
4822
|
-
return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
|
|
4823
|
-
}
|
|
4824
|
-
/** Fetch and parse an SFA index.json, with in-memory caching. */
|
|
4825
|
-
async function fetchIndex(url) {
|
|
4826
|
-
const cached = indexCache.get(url);
|
|
4827
|
-
if (cached) return cached;
|
|
4828
|
-
const res = await fetch(url);
|
|
4829
|
-
if (!res.ok) throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
|
|
4830
|
-
const data = validateSFAIndex(await res.json());
|
|
4831
|
-
indexCache.set(url, data);
|
|
4832
|
-
return data;
|
|
4833
|
-
}
|
|
4834
|
-
/**
|
|
4835
|
-
* Find the best matching entry in an SFA index.
|
|
4836
|
-
* Exact version preferred, then highest version with same major.
|
|
4837
|
-
*/
|
|
4838
|
-
function findEntry(index, name, version) {
|
|
4839
|
-
const wanted = parseSemver(version);
|
|
4840
|
-
const searchArrays = [{
|
|
4841
|
-
entries: index.twine2 ?? [],
|
|
4842
|
-
formatType: "twine2"
|
|
4843
|
-
}, {
|
|
4844
|
-
entries: index.twine1 ?? [],
|
|
4845
|
-
formatType: "twine1"
|
|
4846
|
-
}];
|
|
4847
|
-
let bestResult;
|
|
4848
|
-
let bestVersion = null;
|
|
4849
|
-
for (const { entries, formatType } of searchArrays) for (const entry of entries) {
|
|
4850
|
-
if (entry.name.toLowerCase() !== name.toLowerCase()) continue;
|
|
4851
|
-
const have = parseSemver(entry.version);
|
|
4852
|
-
if (!have) continue;
|
|
4853
|
-
if (wanted && semverCompare(have, wanted) === 0) return {
|
|
4854
|
-
entry,
|
|
4855
|
-
formatType
|
|
4856
|
-
};
|
|
4857
|
-
if (wanted === null || have[0] === wanted[0] && semverCompare(have, wanted) >= 0) {
|
|
4858
|
-
if (!bestVersion || semverCompare(have, bestVersion) > 0) {
|
|
4859
|
-
bestVersion = have;
|
|
4860
|
-
bestResult = {
|
|
4861
|
-
entry,
|
|
4862
|
-
formatType
|
|
4863
|
-
};
|
|
4864
|
-
}
|
|
4865
|
-
}
|
|
4866
|
-
}
|
|
4867
|
-
return bestResult;
|
|
4868
|
-
}
|
|
4869
|
-
/** Verify SHA-256 checksum using Web Crypto API (Node 22 built-in). */
|
|
4870
|
-
async function verifySHA256(content, expectedHex) {
|
|
4871
|
-
const digest = await crypto.subtle.digest("SHA-256", content);
|
|
4872
|
-
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("") === expectedHex.toLowerCase();
|
|
4873
|
-
}
|
|
4874
|
-
/** Derive the download URL for a format.js from the index URL and entry. */
|
|
4875
|
-
function getDownloadUrl(indexUrl, entry, formatType) {
|
|
4876
|
-
return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
|
|
4877
|
-
}
|
|
4878
|
-
/** Download a format, verify its checksum, write to cache, and return StoryFormatInfo. */
|
|
4879
|
-
async function fetchAndCacheFormat(entry, downloadUrl) {
|
|
4880
|
-
const res = await fetch(downloadUrl);
|
|
4881
|
-
if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
|
|
4882
|
-
const text = await res.text();
|
|
4883
|
-
const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
|
|
4884
|
-
if (checksumKey) {
|
|
4885
|
-
const expected = entry.checksums[checksumKey];
|
|
4886
|
-
if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
|
|
4887
|
-
if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
|
|
4888
|
-
}
|
|
4889
|
-
const id = makeFormatId(entry.name, entry.version);
|
|
4890
|
-
const data = parseFormatJSON(text, id);
|
|
4891
|
-
if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
|
|
4892
|
-
const cacheDir = getCacheDir();
|
|
4893
|
-
const formatDir = join(cacheDir, entry.name, entry.version);
|
|
4894
|
-
mkdirSync(formatDir, { recursive: true });
|
|
4895
|
-
const formatPath = join(formatDir, "format.js");
|
|
4896
|
-
writeFileSync(formatPath, text, "utf-8");
|
|
4897
|
-
return {
|
|
4898
|
-
id,
|
|
4899
|
-
filename: formatPath,
|
|
4900
|
-
isTwine2: true,
|
|
4901
|
-
name: data.name,
|
|
4902
|
-
version: data.version,
|
|
4903
|
-
proofing: data.proofing ?? false
|
|
4904
|
-
};
|
|
4905
|
-
}
|
|
4906
|
-
/** Download a direct format.js URL, parse its JSON, cache it, and return StoryFormatInfo. */
|
|
4907
|
-
async function fetchDirectFormat(url) {
|
|
4908
|
-
const res = await fetch(url);
|
|
4909
|
-
if (!res.ok) throw new Error(`Failed to download format from ${url}: ${res.status} ${res.statusText}`);
|
|
4910
|
-
const text = await res.text();
|
|
4911
|
-
const data = parseFormatJSON(text, "direct-url");
|
|
4912
|
-
if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
|
|
4913
|
-
const id = makeFormatId(data.name, data.version);
|
|
4914
|
-
const cacheDir = getCacheDir();
|
|
4915
|
-
const formatDir = join(cacheDir, data.name, data.version);
|
|
4916
|
-
mkdirSync(formatDir, { recursive: true });
|
|
4917
|
-
const formatPath = join(formatDir, "format.js");
|
|
4918
|
-
writeFileSync(formatPath, text, "utf-8");
|
|
4919
|
-
return {
|
|
4920
|
-
id,
|
|
4921
|
-
filename: formatPath,
|
|
4922
|
-
isTwine2: true,
|
|
4923
|
-
name: data.name,
|
|
4924
|
-
version: data.version,
|
|
4925
|
-
proofing: data.proofing ?? false
|
|
4926
|
-
};
|
|
4927
|
-
}
|
|
4928
|
-
/**
|
|
4929
|
-
* Try to resolve a remote story format by name and version.
|
|
4930
|
-
* 1. Try direct format URLs
|
|
4931
|
-
* 2. Try custom index URLs
|
|
4932
|
-
* 3. Try default SFA indices
|
|
4933
|
-
*/
|
|
4934
|
-
async function resolveRemoteFormat(name, version, indices, urls) {
|
|
4935
|
-
let lastError;
|
|
4936
|
-
if (urls) for (const url of urls) try {
|
|
4937
|
-
const info = await fetchDirectFormat(url);
|
|
4938
|
-
if (info.name.toLowerCase() === name.toLowerCase()) {
|
|
4939
|
-
const wanted = parseSemver(version);
|
|
4940
|
-
const have = parseSemver(info.version);
|
|
4941
|
-
if (!wanted || have && have[0] === wanted[0] && semverCompare(have, wanted) >= 0) return info;
|
|
4942
|
-
}
|
|
4943
|
-
} catch (e) {
|
|
4944
|
-
lastError = e instanceof Error ? e : new Error(String(e));
|
|
4945
|
-
}
|
|
4946
|
-
const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
|
|
4947
|
-
for (const indexUrl of allIndices) try {
|
|
4948
|
-
const result = findEntry(await fetchIndex(indexUrl), name, version);
|
|
4949
|
-
if (result) {
|
|
4950
|
-
const cached = getCachedFormat(result.entry.name, result.entry.version);
|
|
4951
|
-
if (cached) return cached;
|
|
4952
|
-
const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
|
|
4953
|
-
return await fetchAndCacheFormat(result.entry, downloadUrl);
|
|
4954
|
-
}
|
|
4955
|
-
} catch (e) {
|
|
4956
|
-
lastError = e instanceof Error ? e : new Error(String(e));
|
|
4957
|
-
}
|
|
4958
|
-
if (lastError) throw lastError;
|
|
4959
|
-
}
|
|
4960
|
-
/** Check if a format is already in the local cache. */
|
|
4961
|
-
function getCachedFormat(name, version) {
|
|
4962
|
-
const formatPath = join(getCacheDir(), name, version, "format.js");
|
|
4963
|
-
try {
|
|
4964
|
-
if (!existsSync(formatPath)) return void 0;
|
|
4965
|
-
const source = readFileSync(formatPath, "utf-8");
|
|
4966
|
-
const id = makeFormatId(name, version);
|
|
4967
|
-
const data = parseFormatJSON(source, id);
|
|
4968
|
-
if (!data) return void 0;
|
|
4969
|
-
return {
|
|
4970
|
-
id,
|
|
4971
|
-
filename: formatPath,
|
|
4972
|
-
isTwine2: true,
|
|
4973
|
-
name: data.name,
|
|
4974
|
-
version: data.version,
|
|
4975
|
-
proofing: data.proofing ?? false
|
|
4976
|
-
};
|
|
4977
|
-
} catch {
|
|
4978
|
-
return;
|
|
4979
|
-
}
|
|
4980
|
-
}
|
|
4981
|
-
/** Discover all cached remote formats. Returns a Map like discoverFormats(). */
|
|
4982
|
-
function discoverCachedFormats() {
|
|
4983
|
-
const formats = /* @__PURE__ */ new Map();
|
|
4984
|
-
const cacheDir = getCacheDir();
|
|
4985
|
-
try {
|
|
4986
|
-
if (!existsSync(cacheDir)) return formats;
|
|
4987
|
-
} catch {
|
|
4988
|
-
return formats;
|
|
4989
|
-
}
|
|
4990
|
-
let names;
|
|
4991
|
-
try {
|
|
4992
|
-
names = readdirSync(cacheDir);
|
|
4993
|
-
} catch {
|
|
4994
|
-
return formats;
|
|
4995
|
-
}
|
|
4996
|
-
for (const name of names) {
|
|
4997
|
-
const nameDir = join(cacheDir, name);
|
|
4998
|
-
try {
|
|
4999
|
-
if (!statSync(nameDir).isDirectory()) continue;
|
|
5000
|
-
} catch {
|
|
5001
|
-
continue;
|
|
5002
|
-
}
|
|
5003
|
-
let versions;
|
|
5004
|
-
try {
|
|
5005
|
-
versions = readdirSync(nameDir);
|
|
5006
|
-
} catch {
|
|
5007
|
-
continue;
|
|
5008
|
-
}
|
|
5009
|
-
for (const version of versions) {
|
|
5010
|
-
const info = getCachedFormat(name, version);
|
|
5011
|
-
if (info) formats.set(`${info.id}-${version}`, info);
|
|
5370
|
+
break;
|
|
5371
|
+
default: continue;
|
|
5012
5372
|
}
|
|
5373
|
+
if (tag) headTags.push(tag);
|
|
5374
|
+
processed.add(filename);
|
|
5013
5375
|
}
|
|
5014
|
-
return
|
|
5376
|
+
return headTags.join("\n");
|
|
5015
5377
|
}
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5378
|
+
function loadModuleTagged(tag, filename) {
|
|
5379
|
+
const source = readUTF8(filename).trim();
|
|
5380
|
+
if (source.length === 0) return null;
|
|
5381
|
+
return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
|
|
5382
|
+
}
|
|
5383
|
+
function loadModuleFont(filename) {
|
|
5384
|
+
const source = readBase64(filename);
|
|
5385
|
+
const family = baseNameWithoutExt(filename);
|
|
5386
|
+
const idSlug = `style-module-${slugify(family)}`;
|
|
5387
|
+
const ext = normalizedFileExt(filename);
|
|
5388
|
+
return `<style id="${idSlug}" type="text/css">@font-face {\n\tfont-family: "${family}";\n\tsrc: url("data:${mediaTypeFromExt(ext)};base64,${source}") format("${fontFormatHint(ext)}");\n}</style>`;
|
|
5389
|
+
}
|
|
5390
|
+
/**
|
|
5391
|
+
* Inject modules and head file content before </head>.
|
|
5392
|
+
*/
|
|
5393
|
+
function modifyHead(html, modulePaths, headFile, diagnostics) {
|
|
5394
|
+
const parts = [];
|
|
5395
|
+
if (modulePaths.length > 0) {
|
|
5396
|
+
const modules = loadModules(modulePaths).trim();
|
|
5397
|
+
if (modules.length > 0) parts.push(modules);
|
|
5024
5398
|
}
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
|
|
5028
|
-
} catch {
|
|
5029
|
-
|
|
5399
|
+
if (headFile) try {
|
|
5400
|
+
const source = readUTF8(headFile).trim();
|
|
5401
|
+
if (source.length > 0) parts.push(source);
|
|
5402
|
+
} catch (e) {
|
|
5403
|
+
diagnostics?.push({
|
|
5404
|
+
level: "warning",
|
|
5405
|
+
message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
|
|
5406
|
+
});
|
|
5030
5407
|
}
|
|
5031
|
-
|
|
5032
|
-
|
|
5033
|
-
|
|
5034
|
-
|
|
5035
|
-
} catch {
|
|
5036
|
-
continue;
|
|
5037
|
-
}
|
|
5038
|
-
let versions;
|
|
5039
|
-
try {
|
|
5040
|
-
versions = readdirSync(nameDir);
|
|
5041
|
-
} catch {
|
|
5042
|
-
continue;
|
|
5043
|
-
}
|
|
5044
|
-
for (const version of versions) {
|
|
5045
|
-
const formatPath = join(nameDir, version, "format.js");
|
|
5046
|
-
try {
|
|
5047
|
-
const stat = statSync(formatPath);
|
|
5048
|
-
entries.push({
|
|
5049
|
-
name,
|
|
5050
|
-
version,
|
|
5051
|
-
sizeBytes: stat.size,
|
|
5052
|
-
modifiedAt: stat.mtime
|
|
5053
|
-
});
|
|
5054
|
-
} catch {}
|
|
5055
|
-
}
|
|
5408
|
+
if (parts.length > 0) {
|
|
5409
|
+
parts.push("</head>");
|
|
5410
|
+
const replacement = parts.join("\n");
|
|
5411
|
+
return html.replace("</head>", () => replacement);
|
|
5056
5412
|
}
|
|
5057
|
-
return
|
|
5413
|
+
return html;
|
|
5058
5414
|
}
|
|
5059
|
-
|
|
5060
|
-
|
|
5061
|
-
|
|
5062
|
-
|
|
5063
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
5066
|
-
|
|
5067
|
-
force: true
|
|
5068
|
-
});
|
|
5069
|
-
return count;
|
|
5415
|
+
//#endregion
|
|
5416
|
+
//#region src/start-passage.ts
|
|
5417
|
+
function describeOmission(omission) {
|
|
5418
|
+
switch (omission.kind) {
|
|
5419
|
+
case "special-name": return `is the special "${omission.name}" passage`;
|
|
5420
|
+
case "tag": return `is tagged "${omission.tag}"`;
|
|
5421
|
+
case "empty-story-settings": return "is an empty \"StorySettings\" passage";
|
|
5422
|
+
default: throw new Error(`unhandled passage omission: ${JSON.stringify(omission)}`);
|
|
5070
5423
|
}
|
|
5071
|
-
|
|
5072
|
-
|
|
5073
|
-
|
|
5074
|
-
|
|
5075
|
-
|
|
5076
|
-
|
|
5077
|
-
return 0;
|
|
5424
|
+
}
|
|
5425
|
+
function omissionFor(story, passage, target) {
|
|
5426
|
+
switch (target) {
|
|
5427
|
+
case "twine2": return twine2PassageOmission(story, passage);
|
|
5428
|
+
case "twine1": return twine1PassageOmission(passage);
|
|
5429
|
+
default: throw new Error(`unhandled start passage target: ${String(target)}`);
|
|
5078
5430
|
}
|
|
5079
|
-
const count = versions.length;
|
|
5080
|
-
rmSync(nameDir, {
|
|
5081
|
-
recursive: true,
|
|
5082
|
-
force: true
|
|
5083
|
-
});
|
|
5084
|
-
return count;
|
|
5085
5431
|
}
|
|
5086
|
-
/**
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5432
|
+
/**
|
|
5433
|
+
* Check that `startName` names a passage the `target` output emits.
|
|
5434
|
+
* Returns the error diagnostics to report (empty when the start is usable).
|
|
5435
|
+
*/
|
|
5436
|
+
function startPassageDiagnostics(story, startName, target) {
|
|
5437
|
+
const passage = story.passages.find((p) => p.name === startName);
|
|
5438
|
+
if (passage === void 0) return [{
|
|
5439
|
+
level: "error",
|
|
5440
|
+
message: `Starting passage "${startName}" not found.`
|
|
5441
|
+
}];
|
|
5442
|
+
const omission = omissionFor(story, passage, target);
|
|
5443
|
+
if (omission === void 0) return [];
|
|
5444
|
+
return [{
|
|
5445
|
+
level: "error",
|
|
5446
|
+
message: `Starting passage "${startName}" ${describeOmission(omission)}, so it is left out of the story data. Choose a story passage.`
|
|
5447
|
+
}];
|
|
5093
5448
|
}
|
|
5094
5449
|
//#endregion
|
|
5095
5450
|
//#region src/compiler.ts
|
|
@@ -5116,14 +5471,6 @@ async function compile(options) {
|
|
|
5116
5471
|
return buildOutput(options);
|
|
5117
5472
|
}
|
|
5118
5473
|
/**
|
|
5119
|
-
* Compile and write to a file.
|
|
5120
|
-
*/
|
|
5121
|
-
async function compileToFile(options) {
|
|
5122
|
-
const result = await compile(options);
|
|
5123
|
-
writeFileSync(options.outFile, result.output, "utf-8");
|
|
5124
|
-
return result;
|
|
5125
|
-
}
|
|
5126
|
-
/**
|
|
5127
5474
|
* Watch for file changes and recompile.
|
|
5128
5475
|
*/
|
|
5129
5476
|
async function watch$1(options) {
|
|
@@ -5131,16 +5478,21 @@ async function watch$1(options) {
|
|
|
5131
5478
|
const cache = /* @__PURE__ */ new Map();
|
|
5132
5479
|
const filePaths = options.sources.filter((s) => typeof s === "string");
|
|
5133
5480
|
const modulePaths = options.modules ?? [];
|
|
5134
|
-
const
|
|
5481
|
+
const headPaths = options.headFile ? [options.headFile] : [];
|
|
5482
|
+
const allPaths = [
|
|
5483
|
+
...filePaths,
|
|
5484
|
+
...modulePaths,
|
|
5485
|
+
...headPaths
|
|
5486
|
+
];
|
|
5135
5487
|
const exclude = options.exclude ?? [];
|
|
5136
|
-
const
|
|
5488
|
+
const notExcludable = [...modulePaths, ...headPaths].map((p) => resolve(p));
|
|
5137
5489
|
const ignore = (filename) => {
|
|
5138
5490
|
if (!isExcluded(filename, exclude)) return false;
|
|
5139
5491
|
const abs = resolve(filename);
|
|
5140
|
-
return !
|
|
5492
|
+
return !notExcludable.some((root) => abs === root || abs.startsWith(root + sep));
|
|
5141
5493
|
};
|
|
5142
5494
|
const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
|
|
5143
|
-
buildOutput(options, cache, changedFiles).then((result) => {
|
|
5495
|
+
buildOutput(options, cache, changedFiles, options.outFile).then((result) => {
|
|
5144
5496
|
writeFileSync(options.outFile, result.output, "utf-8");
|
|
5145
5497
|
options.onBuild?.(result);
|
|
5146
5498
|
}).catch((e) => {
|
|
@@ -5150,7 +5502,11 @@ async function watch$1(options) {
|
|
|
5150
5502
|
controller.signal.addEventListener("abort", () => handle.close());
|
|
5151
5503
|
return controller;
|
|
5152
5504
|
}
|
|
5153
|
-
|
|
5505
|
+
/**
|
|
5506
|
+
* `outFile`: the file the build is written to, which source and module discovery
|
|
5507
|
+
* skip (as Tweego does), so an output inside a source folder is never loaded back.
|
|
5508
|
+
*/
|
|
5509
|
+
async function buildOutput(options, cache, changedFiles, outFile) {
|
|
5154
5510
|
const diagnostics = [];
|
|
5155
5511
|
const outputMode = options.outputMode ?? "html";
|
|
5156
5512
|
const trim = options.trim ?? true;
|
|
@@ -5163,7 +5519,7 @@ async function buildOutput(options, cache, changedFiles) {
|
|
|
5163
5519
|
const inlineSources = [];
|
|
5164
5520
|
for (const source of options.sources) if (typeof source === "string") filePaths.push(source);
|
|
5165
5521
|
else inlineSources.push(source);
|
|
5166
|
-
const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths,
|
|
5522
|
+
const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths, outFile, options.exclude);
|
|
5167
5523
|
diagnostics.push(...sourcePathDiagnostics);
|
|
5168
5524
|
const story = createStory();
|
|
5169
5525
|
const processedFiles = /* @__PURE__ */ new Set();
|
|
@@ -5181,33 +5537,12 @@ async function buildOutput(options, cache, changedFiles) {
|
|
|
5181
5537
|
}, diagnostics);
|
|
5182
5538
|
if (options.tagAliases) story.passages = applyTagAliases(story.passages, options.tagAliases);
|
|
5183
5539
|
let format;
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
5187
|
-
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
if (!format && !noRemote) {
|
|
5191
|
-
const remoteName = story.twine2.format || formatId;
|
|
5192
|
-
const remoteVersion = story.twine2.formatVersion || "";
|
|
5193
|
-
try {
|
|
5194
|
-
format = await resolveRemoteFormat(remoteName, remoteVersion, options.formatIndices, options.formatUrls);
|
|
5195
|
-
} catch (e) {
|
|
5196
|
-
diagnostics.push({
|
|
5197
|
-
level: "warning",
|
|
5198
|
-
message: `Remote format fetch failed for "${remoteName}": ${e instanceof Error ? e.message : String(e)}`
|
|
5199
|
-
});
|
|
5200
|
-
}
|
|
5201
|
-
}
|
|
5202
|
-
if (!format) {
|
|
5203
|
-
const reason = noRemote ? " (remote fetching disabled)" : "";
|
|
5204
|
-
diagnostics.push({
|
|
5205
|
-
level: "error",
|
|
5206
|
-
message: `Story format "${formatId}" is not available${reason}. Found: ${[...formats.keys()].join(", ") || "none"}`
|
|
5207
|
-
});
|
|
5208
|
-
}
|
|
5209
|
-
}
|
|
5210
|
-
const startName = options.startPassage || story.twine2.start || DEFAULT_START_NAME;
|
|
5540
|
+
if (outputMode === "html") format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
|
|
5541
|
+
...options,
|
|
5542
|
+
noRemote
|
|
5543
|
+
}, diagnostics);
|
|
5544
|
+
if (options.startPassage) story.twine2.start = options.startPassage;
|
|
5545
|
+
const startName = story.twine2.start || DEFAULT_START_NAME;
|
|
5211
5546
|
if (testMode) story.twine2.options.set("debug", true);
|
|
5212
5547
|
ensureIFID(story, diagnostics);
|
|
5213
5548
|
let output;
|
|
@@ -5226,10 +5561,7 @@ async function buildOutput(options, cache, changedFiles) {
|
|
|
5226
5561
|
output = storyToJSON(story);
|
|
5227
5562
|
break;
|
|
5228
5563
|
case "html": {
|
|
5229
|
-
|
|
5230
|
-
level: "error",
|
|
5231
|
-
message: `Starting passage "${startName}" not found.`
|
|
5232
|
-
});
|
|
5564
|
+
diagnostics.push(...startPassageDiagnostics(story, startName, format?.isTwine2 === false ? "twine1" : "twine2"));
|
|
5233
5565
|
if (!format) throw new TweeTsError("No story format available for HTML output.", diagnostics);
|
|
5234
5566
|
if (format.isTwine2) output = toTwine2HTML(story, format, startName, { sourceInfo });
|
|
5235
5567
|
else {
|
|
@@ -5239,7 +5571,7 @@ async function buildOutput(options, cache, changedFiles) {
|
|
|
5239
5571
|
});
|
|
5240
5572
|
output = toTwine1HTML(story, format, startName);
|
|
5241
5573
|
}
|
|
5242
|
-
const modules = getFilenames(options.modules ?? []);
|
|
5574
|
+
const modules = getFilenames(options.modules ?? [], outFile);
|
|
5243
5575
|
diagnostics.push(...modules.diagnostics);
|
|
5244
5576
|
output = modifyHead(output, modules.filenames, options.headFile, diagnostics);
|
|
5245
5577
|
break;
|
|
@@ -5659,19 +5991,227 @@ function endOfRegularExpression(source, pos) {
|
|
|
5659
5991
|
};
|
|
5660
5992
|
}
|
|
5661
5993
|
//#endregion
|
|
5994
|
+
//#region src/link-markup.ts
|
|
5995
|
+
/**
|
|
5996
|
+
* Reads link or image markup that starts at `start` in `text`, or returns `undefined` where
|
|
5997
|
+
* SugarCube rejects it (and so prints the text instead).
|
|
5998
|
+
*
|
|
5999
|
+
* `budget.left`, if given, is how many characters the reading may look at; it is reduced by as
|
|
6000
|
+
* many as were read, and once it runs out, every reading fails. Unclosed markup is read to the end
|
|
6001
|
+
* of its line, so reading every `[[` of a long line that never closes one would otherwise take
|
|
6002
|
+
* quadratic time.
|
|
6003
|
+
*/
|
|
6004
|
+
function readSquareBracketedMarkup(text, start, budget = { left: Infinity }) {
|
|
6005
|
+
const reader = {
|
|
6006
|
+
text,
|
|
6007
|
+
limit: Math.min(text.length, start + Math.max(budget.left, 0))
|
|
6008
|
+
};
|
|
6009
|
+
const markup = readMarkup(reader, start);
|
|
6010
|
+
budget.left -= (markup?.end ?? reader.limit) - start;
|
|
6011
|
+
return markup;
|
|
6012
|
+
}
|
|
6013
|
+
function readMarkup(reader, start) {
|
|
6014
|
+
const { text } = reader;
|
|
6015
|
+
if (text[start] !== "[") return;
|
|
6016
|
+
let pos = start + 1;
|
|
6017
|
+
let type;
|
|
6018
|
+
if (text[pos] === "[") {
|
|
6019
|
+
type = "link";
|
|
6020
|
+
pos += 1;
|
|
6021
|
+
} else {
|
|
6022
|
+
if (text[pos] === "<" || text[pos] === ">") pos += 1;
|
|
6023
|
+
if (!/^[Ii][Mm][Gg]\[/.test(text.slice(pos, pos + 4))) return;
|
|
6024
|
+
type = "image";
|
|
6025
|
+
pos += 4;
|
|
6026
|
+
}
|
|
6027
|
+
const core = readCoreComponents(reader, pos);
|
|
6028
|
+
if (core === void 0) return;
|
|
6029
|
+
if (type === "link") {
|
|
6030
|
+
if (!core.end.last) {
|
|
6031
|
+
const setterEnd = readSetter(reader, core.end.next);
|
|
6032
|
+
if (setterEnd === void 0) return;
|
|
6033
|
+
return {
|
|
6034
|
+
type,
|
|
6035
|
+
link: linkName(core.link),
|
|
6036
|
+
end: setterEnd
|
|
6037
|
+
};
|
|
6038
|
+
}
|
|
6039
|
+
return {
|
|
6040
|
+
type,
|
|
6041
|
+
link: linkName(core.link),
|
|
6042
|
+
end: core.end.next
|
|
6043
|
+
};
|
|
6044
|
+
}
|
|
6045
|
+
if (core.end.last) return {
|
|
6046
|
+
type,
|
|
6047
|
+
link: void 0,
|
|
6048
|
+
end: core.end.next
|
|
6049
|
+
};
|
|
6050
|
+
const linkEnd = readComponent(reader, core.end.next, false);
|
|
6051
|
+
if (linkEnd === void 0) return;
|
|
6052
|
+
const link = linkName(text.slice(core.end.next, linkEnd.textEnd));
|
|
6053
|
+
if (linkEnd.last) return {
|
|
6054
|
+
type,
|
|
6055
|
+
link,
|
|
6056
|
+
end: linkEnd.next
|
|
6057
|
+
};
|
|
6058
|
+
const setterEnd = readSetter(reader, linkEnd.next);
|
|
6059
|
+
return setterEnd === void 0 ? void 0 : {
|
|
6060
|
+
type,
|
|
6061
|
+
link,
|
|
6062
|
+
end: setterEnd
|
|
6063
|
+
};
|
|
6064
|
+
}
|
|
6065
|
+
/** The link component as SugarCube keeps it: trimmed, and without a leading `~`. */
|
|
6066
|
+
function linkName(component) {
|
|
6067
|
+
const trimmed = component.trim();
|
|
6068
|
+
return trimmed.startsWith("~") ? trimmed.slice(1) : trimmed;
|
|
6069
|
+
}
|
|
6070
|
+
/**
|
|
6071
|
+
* Reads the first components: the text and the link of link markup, or the title and the source
|
|
6072
|
+
* of image markup, divided by the first delimiter. `link` is the text of the link (or source)
|
|
6073
|
+
* component, untrimmed.
|
|
6074
|
+
*/
|
|
6075
|
+
function readCoreComponents(reader, from) {
|
|
6076
|
+
const { text } = reader;
|
|
6077
|
+
let delimiter = "none";
|
|
6078
|
+
let componentStart = from;
|
|
6079
|
+
let link;
|
|
6080
|
+
let depth = 2;
|
|
6081
|
+
let pos = from;
|
|
6082
|
+
while (pos < reader.limit) {
|
|
6083
|
+
const ch = text[pos];
|
|
6084
|
+
pos += 1;
|
|
6085
|
+
switch (ch) {
|
|
6086
|
+
case "\n": return;
|
|
6087
|
+
case "\"": {
|
|
6088
|
+
const end = readQuoted(reader, pos, "\"");
|
|
6089
|
+
if (end === void 0) return;
|
|
6090
|
+
pos = end;
|
|
6091
|
+
break;
|
|
6092
|
+
}
|
|
6093
|
+
case "|":
|
|
6094
|
+
if (delimiter === "none") {
|
|
6095
|
+
delimiter = "ltr";
|
|
6096
|
+
componentStart = pos;
|
|
6097
|
+
}
|
|
6098
|
+
break;
|
|
6099
|
+
case "-":
|
|
6100
|
+
if (delimiter === "none" && text[pos] === ">") {
|
|
6101
|
+
delimiter = "ltr";
|
|
6102
|
+
pos += 1;
|
|
6103
|
+
componentStart = pos;
|
|
6104
|
+
}
|
|
6105
|
+
break;
|
|
6106
|
+
case "<":
|
|
6107
|
+
if (delimiter === "none" && text[pos] === "-") {
|
|
6108
|
+
delimiter = "rtl";
|
|
6109
|
+
link = text.slice(componentStart, pos - 1);
|
|
6110
|
+
pos += 1;
|
|
6111
|
+
componentStart = pos;
|
|
6112
|
+
}
|
|
6113
|
+
break;
|
|
6114
|
+
case "[":
|
|
6115
|
+
depth += 1;
|
|
6116
|
+
break;
|
|
6117
|
+
case "]":
|
|
6118
|
+
depth -= 1;
|
|
6119
|
+
if (depth === 1) {
|
|
6120
|
+
const end = componentEnd(text, pos);
|
|
6121
|
+
if (end === void 0) return;
|
|
6122
|
+
return {
|
|
6123
|
+
link: link ?? text.slice(componentStart, end.textEnd),
|
|
6124
|
+
end
|
|
6125
|
+
};
|
|
6126
|
+
}
|
|
6127
|
+
}
|
|
6128
|
+
}
|
|
6129
|
+
}
|
|
6130
|
+
/**
|
|
6131
|
+
* Reads a component after `][` that holds no delimiter: an image's link (`setter` false) or a
|
|
6132
|
+
* setter, in which single quotes also quote.
|
|
6133
|
+
*/
|
|
6134
|
+
function readComponent(reader, from, setter) {
|
|
6135
|
+
const { text } = reader;
|
|
6136
|
+
let depth = 2;
|
|
6137
|
+
let pos = from;
|
|
6138
|
+
while (pos < reader.limit) {
|
|
6139
|
+
const ch = text[pos];
|
|
6140
|
+
pos += 1;
|
|
6141
|
+
switch (ch) {
|
|
6142
|
+
case "\n": return;
|
|
6143
|
+
case "\"":
|
|
6144
|
+
case "'": {
|
|
6145
|
+
if (ch === "'" && !setter) break;
|
|
6146
|
+
const end = readQuoted(reader, pos, ch);
|
|
6147
|
+
if (end === void 0) return;
|
|
6148
|
+
pos = end;
|
|
6149
|
+
break;
|
|
6150
|
+
}
|
|
6151
|
+
case "[":
|
|
6152
|
+
depth += 1;
|
|
6153
|
+
break;
|
|
6154
|
+
case "]":
|
|
6155
|
+
depth -= 1;
|
|
6156
|
+
if (depth === 1) return componentEnd(text, pos);
|
|
6157
|
+
}
|
|
6158
|
+
}
|
|
6159
|
+
}
|
|
6160
|
+
/** Reads a setter, the last component; returns the index after its closing `]]`. */
|
|
6161
|
+
function readSetter(reader, from) {
|
|
6162
|
+
const end = readComponent(reader, from, true);
|
|
6163
|
+
return end?.last === true ? end.next : void 0;
|
|
6164
|
+
}
|
|
6165
|
+
/** What the `]` just before `pos`, which brings the depth back to one, ends; `undefined` if malformed. */
|
|
6166
|
+
function componentEnd(text, pos) {
|
|
6167
|
+
switch (text[pos]) {
|
|
6168
|
+
case "[": return {
|
|
6169
|
+
textEnd: pos - 1,
|
|
6170
|
+
next: pos + 1,
|
|
6171
|
+
last: false
|
|
6172
|
+
};
|
|
6173
|
+
case "]": return {
|
|
6174
|
+
textEnd: pos - 1,
|
|
6175
|
+
next: pos + 1,
|
|
6176
|
+
last: true
|
|
6177
|
+
};
|
|
6178
|
+
default: return;
|
|
6179
|
+
}
|
|
6180
|
+
}
|
|
6181
|
+
/**
|
|
6182
|
+
* Reads a quoted string from just after its opening quote; a backslash escapes the character
|
|
6183
|
+
* after it. A line feed, or the end of the text, ends it unterminated. Returns the index after the
|
|
6184
|
+
* closing quote.
|
|
6185
|
+
*/
|
|
6186
|
+
function readQuoted(reader, from, quote) {
|
|
6187
|
+
const { text } = reader;
|
|
6188
|
+
let pos = from;
|
|
6189
|
+
while (pos < reader.limit) {
|
|
6190
|
+
const ch = text[pos];
|
|
6191
|
+
pos += 1;
|
|
6192
|
+
if (ch === quote) return pos;
|
|
6193
|
+
if (ch === "\n") return;
|
|
6194
|
+
if (ch === "\\") {
|
|
6195
|
+
if (pos >= reader.limit || text[pos] === "\n") return;
|
|
6196
|
+
pos += 1;
|
|
6197
|
+
}
|
|
6198
|
+
}
|
|
6199
|
+
}
|
|
6200
|
+
//#endregion
|
|
5662
6201
|
//#region src/sugarcube-macros.ts
|
|
5663
6202
|
/**
|
|
5664
|
-
* Reads SugarCube 2 macro calls the way SugarCube 2.37.3 does, so the link check
|
|
5665
|
-
* passage names that `<<link>>` and `<<goto>>` use when the story
|
|
6203
|
+
* Reads SugarCube 2 macro calls and link markup the way SugarCube 2.37.3 does, so the link check
|
|
6204
|
+
* sees the same passage names that `[[…]]` links, `<<link>>` and `<<goto>>` use when the story
|
|
6205
|
+
* plays.
|
|
5666
6206
|
*
|
|
5667
6207
|
* Each step follows a part of SugarCube's macro parser:
|
|
5668
6208
|
* - `findMacroTags` finds where a tag starts and ends (the parser's `lookahead` pattern);
|
|
5669
6209
|
* - `parseMacroArgs` splits the text after the macro name into arguments (`parseArgs`);
|
|
5670
6210
|
* - quoted arguments are evaluated as strict-mode JavaScript strings (`evalStringLiteral`).
|
|
5671
6211
|
*
|
|
6212
|
+
* Link and image markup is read as `parseSquareBracketedMarkup` reads it (see `link-markup.ts`).
|
|
5672
6213
|
* Of the rest of the wikifier, only comments, `<script>` elements and `<<script>>` bodies are
|
|
5673
|
-
* told apart; other markup, such as verbatim text, is read as markup (see
|
|
5674
|
-
* `findMacroPassageLinks`).
|
|
6214
|
+
* told apart; other markup, such as verbatim text, is read as markup (see `findPassageLinks`).
|
|
5675
6215
|
*/
|
|
5676
6216
|
const NAME_RE = /\/?[A-Za-z][\w-]*|[=-]/y;
|
|
5677
6217
|
const SPACES_RE = /\s*/y;
|
|
@@ -5716,17 +6256,22 @@ const RAW_ARGUMENT_MACROS = /* @__PURE__ */ new Set([
|
|
|
5716
6256
|
* levels; the limit keeps the work linear in the size of the text on any input.
|
|
5717
6257
|
*/
|
|
5718
6258
|
const MAX_STRING_DEPTH = 10;
|
|
5719
|
-
/**
|
|
6259
|
+
/**
|
|
6260
|
+
* A context for reading `text`: the closer search, and the markup reading, may each read four
|
|
6261
|
+
* times it, plus an allowance.
|
|
6262
|
+
*/
|
|
5720
6263
|
function readContext(text) {
|
|
6264
|
+
const allowance = () => ({ left: 4 * text.length + 1e5 });
|
|
5721
6265
|
return {
|
|
5722
6266
|
depth: 0,
|
|
5723
|
-
budget:
|
|
6267
|
+
budget: allowance(),
|
|
6268
|
+
markupBudget: allowance()
|
|
5724
6269
|
};
|
|
5725
6270
|
}
|
|
5726
6271
|
function deeper(context) {
|
|
5727
6272
|
return {
|
|
5728
|
-
|
|
5729
|
-
|
|
6273
|
+
...context,
|
|
6274
|
+
depth: context.depth + 1
|
|
5730
6275
|
};
|
|
5731
6276
|
}
|
|
5732
6277
|
/** Index of the first match of a global `re` at or after `from`, or -1. */
|
|
@@ -5956,7 +6501,10 @@ function lexArgument(raw, pos) {
|
|
|
5956
6501
|
case "[": {
|
|
5957
6502
|
const end = lexSquareBracketed(raw, pos + 1);
|
|
5958
6503
|
return end === void 0 ? void 0 : {
|
|
5959
|
-
arg: {
|
|
6504
|
+
arg: {
|
|
6505
|
+
kind: "markup",
|
|
6506
|
+
text: raw.slice(pos, end)
|
|
6507
|
+
},
|
|
5960
6508
|
end
|
|
5961
6509
|
};
|
|
5962
6510
|
}
|
|
@@ -6100,13 +6648,44 @@ function ownPassageLink(tag, lexed) {
|
|
|
6100
6648
|
const arg = passageArgument(tag.name, lexed);
|
|
6101
6649
|
const passage = arg === void 0 ? void 0 : passageName(arg);
|
|
6102
6650
|
return passage === void 0 ? void 0 : {
|
|
6103
|
-
|
|
6651
|
+
via: tag.name,
|
|
6104
6652
|
passage
|
|
6105
6653
|
};
|
|
6106
6654
|
}
|
|
6107
6655
|
/**
|
|
6108
|
-
* The
|
|
6109
|
-
*
|
|
6656
|
+
* The passage a link's destination names. SugarCube evaluates a destination that no passage is
|
|
6657
|
+
* named by, and a quoted string gives its value (`[[Go|"Room"]]`), unless that is blank.
|
|
6658
|
+
*/
|
|
6659
|
+
function linkDestination(link) {
|
|
6660
|
+
const value = evalStringLiteral(link);
|
|
6661
|
+
return value === void 0 || value.trim() === "" ? link : value;
|
|
6662
|
+
}
|
|
6663
|
+
/**
|
|
6664
|
+
* The passage that link markup from `start` in `text` names, and where the markup ends, or
|
|
6665
|
+
* `undefined` where SugarCube rejects the markup. Image markup names none here, nor does markup
|
|
6666
|
+
* with a template literal's `${…}` in it, whose passage is known only in play.
|
|
6667
|
+
*/
|
|
6668
|
+
function linkMarkupAt(text, start, budget) {
|
|
6669
|
+
const markup = readSquareBracketedMarkup(text, start, budget);
|
|
6670
|
+
if (markup === void 0) return;
|
|
6671
|
+
const passage = markup.type === "link" && !text.slice(start, markup.end).includes("") ? markup.link : void 0;
|
|
6672
|
+
return {
|
|
6673
|
+
link: passage === void 0 ? void 0 : {
|
|
6674
|
+
via: "markup",
|
|
6675
|
+
passage: linkDestination(passage)
|
|
6676
|
+
},
|
|
6677
|
+
end: markup.end
|
|
6678
|
+
};
|
|
6679
|
+
}
|
|
6680
|
+
/** The passage that a link markup argument names, if SugarCube reads the whole argument as markup. */
|
|
6681
|
+
function markupArgumentLinks(raw) {
|
|
6682
|
+
const markup = linkMarkupAt(raw, 0);
|
|
6683
|
+
return markup?.link === void 0 || markup.end !== raw.length ? [] : [markup.link];
|
|
6684
|
+
}
|
|
6685
|
+
/**
|
|
6686
|
+
* The passages a tag names: its own, if it is a `<<link>>` or `<<goto>>` call, then those of its
|
|
6687
|
+
* link markup arguments (`<<button [[Go|Room]]>>`), and those of the links and calls inside the
|
|
6688
|
+
* strings of its arguments, which the macro may print or pass on
|
|
6110
6689
|
* (`<<set _out to '<<link "Go" "Room">><</link>>'>>`). The arguments of the macros SugarCube
|
|
6111
6690
|
* doesn't split are read as JavaScript; in others, a backquoted expression is.
|
|
6112
6691
|
*/
|
|
@@ -6119,7 +6698,7 @@ function tagPassageLinks(tag, context) {
|
|
|
6119
6698
|
switch (arg.kind) {
|
|
6120
6699
|
case "string": return markupPassageLinks(arg.value, deeper(context));
|
|
6121
6700
|
case "expression": return javaScriptPassageLinks(arg.code, context);
|
|
6122
|
-
case "markup":
|
|
6701
|
+
case "markup": return markupArgumentLinks(arg.text);
|
|
6123
6702
|
case "word": return [];
|
|
6124
6703
|
default: throw new Error(`unhandled macro argument: ${JSON.stringify(arg)}`);
|
|
6125
6704
|
}
|
|
@@ -6129,16 +6708,26 @@ function tagPassageLinks(tag, context) {
|
|
|
6129
6708
|
/**
|
|
6130
6709
|
* The passage named by a `<<link>>` or `<<goto>>` that appears unquoted inside another tag's
|
|
6131
6710
|
* arguments, at `start`. SugarCube never sees such an outer "tag" when its `<<` lies in text it
|
|
6132
|
-
* reads as something else, such as
|
|
6133
|
-
*
|
|
6711
|
+
* reads as something else, such as verbatim text (`{{{<<if}}}`); the call inside it then runs.
|
|
6712
|
+
* The call ends where the outer tag ends.
|
|
6134
6713
|
*/
|
|
6135
6714
|
function innerPassageLink(matchTagAt, start) {
|
|
6136
6715
|
const inner = matchTagAt(start)?.tag;
|
|
6137
6716
|
const lexed = inner === void 0 ? void 0 : lexMacroArgs(inner.args);
|
|
6138
6717
|
return inner === void 0 || lexed === void 0 ? void 0 : ownPassageLink(inner, lexed);
|
|
6139
6718
|
}
|
|
6140
|
-
const REGION_OPEN_RE =
|
|
6719
|
+
const REGION_OPEN_RE = /\[\[[^[]|\[[<>]?[Ii][Mm][Gg]\[|\/\/|\/\*|\/%|<!--|<[Ss][Cc][Rr][Ii][Pp][Tt]/g;
|
|
6141
6720
|
const HAS_SCRIPT_OPEN_RE = /<[Ss][Cc][Rr][Ii][Pp][Tt]/;
|
|
6721
|
+
function regionKind(opener) {
|
|
6722
|
+
if (opener.startsWith("[")) return "markup";
|
|
6723
|
+
switch (opener) {
|
|
6724
|
+
case "//":
|
|
6725
|
+
case "/*":
|
|
6726
|
+
case "/%":
|
|
6727
|
+
case "<!--": return opener;
|
|
6728
|
+
default: return "script";
|
|
6729
|
+
}
|
|
6730
|
+
}
|
|
6142
6731
|
const SCRIPT_CLOSE_RE = /<\/[Ss][Cc][Rr][Ii][Pp][Tt]>|[\r\u2028\u2029]/g;
|
|
6143
6732
|
const COMMENT_CLOSE_RES = {
|
|
6144
6733
|
"/*": /\*\/|[\r\u2028\u2029]/g,
|
|
@@ -6292,22 +6881,31 @@ function append(links, more) {
|
|
|
6292
6881
|
for (const link of more) links.push(link);
|
|
6293
6882
|
}
|
|
6294
6883
|
/**
|
|
6295
|
-
* The passages that `<<link>>` and `<<goto>>` calls in passage markup
|
|
6296
|
-
* is known only in play (a variable or an
|
|
6297
|
-
*
|
|
6884
|
+
* The passages that link markup (`[[…]]`) and `<<link>>` and `<<goto>>` calls in passage markup
|
|
6885
|
+
* name, in the order they come. Calls whose passage is known only in play (a variable or an
|
|
6886
|
+
* expression) are left out. A tag's own passage comes before those that its arguments name, as
|
|
6887
|
+
* link markup or inside strings.
|
|
6298
6888
|
*
|
|
6299
6889
|
* The markup is walked in order, as SugarCube's wikifier walks it, with a tag tried at each
|
|
6300
|
-
* `<<` the walk reaches
|
|
6301
|
-
*
|
|
6302
|
-
*
|
|
6303
|
-
*
|
|
6304
|
-
*
|
|
6890
|
+
* `<<` the walk reaches, and link or image markup read at each `[[` or `[img[`; markup SugarCube
|
|
6891
|
+
* rejects is read on from just after its opener, and so is markup after a limit on how much
|
|
6892
|
+
* markup a reading may scan, which only input built to take quadratic time reaches (see
|
|
6893
|
+
* `readSquareBracketedMarkup`). Image markup names no passage here. Of the wikifier's other
|
|
6894
|
+
* parsers, only those whose text holds no link or call are told apart: comments (`/* … *` + `/`,
|
|
6895
|
+
* `/% … %/`, `<!-- … -->`), whose links and calls never run, and `<script>` elements and
|
|
6896
|
+
* `<<script>>` bodies, which are JavaScript: only the links and calls in their strings are read
|
|
6897
|
+
* (see `findJavaScriptPassageLinks`). Everything else, verbatim text included, is read as markup.
|
|
6305
6898
|
*
|
|
6306
6899
|
* Where this differs from SugarCube:
|
|
6900
|
+
* - a link whose passage SugarCube evaluates, because no passage has its name, is taken by its
|
|
6901
|
+
* name, `[[Go|$next]]` naming the passage `$next`, unless it is a quoted string, which is taken
|
|
6902
|
+
* by its value even when a passage has the name with the quotes;
|
|
6903
|
+
* - a link SugarCube takes for a URL, because no passage has its name and it looks like one or
|
|
6904
|
+
* holds a `/`, `.`, `?` or `#`, is still read as naming a passage;
|
|
6307
6905
|
* - a comment or a `<<script>>` body is skipped whole, even where `parseBody` ends a container
|
|
6308
6906
|
* such as `<<if>>` at a closing tag inside it and the rest then runs;
|
|
6309
|
-
* - a `/*`, `<!--` or `<script` inside other markup that holds no call, such as
|
|
6310
|
-
* `<style>` element, still starts a comment or an element;
|
|
6907
|
+
* - a `[[`, `/*`, `<!--` or `<script` inside other markup that holds no call, such as verbatim
|
|
6908
|
+
* text or a `<style>` element, still starts link markup, a comment or an element;
|
|
6311
6909
|
* - a `<<` in such other markup still starts a tag. When that tag runs over a `<<link` or
|
|
6312
6910
|
* `<<goto` outside the parts of its arguments that the tag pattern reads as units, that call
|
|
6313
6911
|
* is still read (see `innerPassageLink`), even where the tag's arguments would be JavaScript,
|
|
@@ -6322,33 +6920,43 @@ function append(links, more) {
|
|
|
6322
6920
|
* `Config.passages.nobr`, are not joined before they are read (see `storyInspect` for passages
|
|
6323
6921
|
* tagged `nobr`).
|
|
6324
6922
|
*/
|
|
6325
|
-
function
|
|
6923
|
+
function findPassageLinks(text) {
|
|
6326
6924
|
return markupPassageLinks(text, readContext(text));
|
|
6327
6925
|
}
|
|
6328
6926
|
function markupPassageLinks(text, context) {
|
|
6329
|
-
if (context.depth > MAX_STRING_DEPTH || !text.includes("<<") && !HAS_SCRIPT_OPEN_RE.test(text)) return [];
|
|
6927
|
+
if (context.depth > MAX_STRING_DEPTH || !text.includes("<<") && !text.includes("[[") && !HAS_SCRIPT_OPEN_RE.test(text)) return [];
|
|
6330
6928
|
const matchTagAt = tagMatcher(text);
|
|
6331
6929
|
const matchInnerTagAt = tagMatcher(text);
|
|
6332
6930
|
const readComment = commentReader(text);
|
|
6333
6931
|
const readScriptElement = scriptElementReader(text);
|
|
6334
6932
|
let findScriptCloser;
|
|
6335
6933
|
const links = [];
|
|
6336
|
-
/**
|
|
6934
|
+
/**
|
|
6935
|
+
* Reads the region `opener` opens, adding the passage that link markup names and the links and
|
|
6936
|
+
* calls in a `<script>` element; returns where the walk goes on.
|
|
6937
|
+
*/
|
|
6337
6938
|
const regionEnd = (opener) => {
|
|
6338
6939
|
const start = opener.index;
|
|
6339
|
-
const kind = opener[0];
|
|
6940
|
+
const kind = regionKind(opener[0]);
|
|
6340
6941
|
switch (kind) {
|
|
6942
|
+
case "markup": {
|
|
6943
|
+
const markup = linkMarkupAt(text, start, context.markupBudget);
|
|
6944
|
+
if (markup === void 0) return start + opener[0].length;
|
|
6945
|
+
if (markup.link !== void 0) links.push(markup.link);
|
|
6946
|
+
return markup.end;
|
|
6947
|
+
}
|
|
6341
6948
|
case "//": return start + 2;
|
|
6342
6949
|
case "/*":
|
|
6343
6950
|
case "/%":
|
|
6344
6951
|
case "<!--": return readComment(kind, start) ?? start + kind.length;
|
|
6345
|
-
|
|
6952
|
+
case "script": {
|
|
6346
6953
|
const element = readScriptElement(start);
|
|
6347
6954
|
if (element.openerEnd === void 0) return start + 1;
|
|
6348
6955
|
if (element.close === void 0) return element.openerEnd;
|
|
6349
6956
|
append(links, javaScriptPassageLinks(text.slice(element.openerEnd, element.close), context));
|
|
6350
6957
|
return element.close + 9;
|
|
6351
6958
|
}
|
|
6959
|
+
default: throw new Error(`unhandled region: ${String(kind)}`);
|
|
6352
6960
|
}
|
|
6353
6961
|
};
|
|
6354
6962
|
let macro = -2;
|
|
@@ -6388,24 +6996,26 @@ function markupPassageLinks(text, context) {
|
|
|
6388
6996
|
}
|
|
6389
6997
|
}
|
|
6390
6998
|
/**
|
|
6391
|
-
* The passages that `<<link>>` and `<<goto>>` calls in the strings of JavaScript
|
|
6392
|
-
* such as a script passage or a `<<script>>` body: `$.wiki('<<goto "Room">>')`.
|
|
6393
|
-
* the strings isn't markup, so a call put together while the story plays, such as
|
|
6394
|
-
* `'<<goto "' + target + '">>'`, names no passage here.
|
|
6999
|
+
* The passages that link markup and `<<link>>` and `<<goto>>` calls in the strings of JavaScript
|
|
7000
|
+
* source name, such as a script passage or a `<<script>>` body: `$.wiki('<<goto "Room">>')`.
|
|
7001
|
+
* Code outside the strings isn't markup, so a call put together while the story plays, such as
|
|
7002
|
+
* `'<<goto "' + target + '">>'`, names no passage here, nor does an array of arrays (`[[0, 1]]`).
|
|
6395
7003
|
*/
|
|
6396
7004
|
function findJavaScriptPassageLinks(source) {
|
|
6397
7005
|
return javaScriptPassageLinks(source, readContext(source));
|
|
6398
7006
|
}
|
|
6399
7007
|
/**
|
|
6400
7008
|
* What JavaScript source must contain for one of its string or template literals to hold a call
|
|
6401
|
-
* that is read. A value holding `<<`: each `<` in it is written as `<`, `\<`,
|
|
6402
|
-
* `\u{…}`, two can be next to each other with only line continuations
|
|
6403
|
-
* is `<` or `\<` (then the source holds `<<` or `<\`) or one of the others.
|
|
6404
|
-
*
|
|
7009
|
+
* or link markup that is read. A value holding `<<`: each `<` in it is written as `<`, `\<`,
|
|
7010
|
+
* `\x3c`, `\u003c` or `\u{…}`, two can be next to each other with only line continuations
|
|
7011
|
+
* between, and the first is `<` or `\<` (then the source holds `<<` or `<\`) or one of the others.
|
|
7012
|
+
* A value holding `[[`, likewise: the source holds `[[`, a `[` before `\[` or a line continuation,
|
|
7013
|
+
* `\x5b`, `\u005b` or `\u{`. Or a value holding a `<script>` element, whose own strings can make
|
|
7014
|
+
* `<<` or `[[` from escapes the outer string encodes.
|
|
6405
7015
|
*/
|
|
6406
|
-
const
|
|
7016
|
+
const MAY_HOLD_LINK_RE = /<<|<\\|\\x3c|\\u003c|\\u\{|<script|\[\[|\[\\[[\n\r\u2028\u2029]|\\x5b|\\u005b/i;
|
|
6407
7017
|
function javaScriptPassageLinks(source, context) {
|
|
6408
|
-
if (context.depth >= MAX_STRING_DEPTH || !
|
|
7018
|
+
if (context.depth >= MAX_STRING_DEPTH || !MAY_HOLD_LINK_RE.test(source)) return [];
|
|
6409
7019
|
const inner = deeper(context);
|
|
6410
7020
|
const links = [];
|
|
6411
7021
|
for (const value of javaScriptStrings(source)) append(links, markupPassageLinks(value, inner));
|
|
@@ -6414,43 +7024,34 @@ function javaScriptPassageLinks(source, context) {
|
|
|
6414
7024
|
//#endregion
|
|
6415
7025
|
//#region src/inspect.ts
|
|
6416
7026
|
/**
|
|
6417
|
-
* Extract
|
|
7027
|
+
* Extract the passages that passage text links to, read as SugarCube 2 reads them.
|
|
6418
7028
|
*
|
|
6419
7029
|
* Supported syntaxes:
|
|
6420
7030
|
* - `[[PassageName]]`
|
|
6421
|
-
* - `[[Display Text
|
|
6422
|
-
* - `[[Display Text
|
|
6423
|
-
* -
|
|
7031
|
+
* - `[[Display Text|PassageName]]`
|
|
7032
|
+
* - `[[Display Text->PassageName]]`
|
|
7033
|
+
* - `[[PassageName<-Display Text]]`
|
|
7034
|
+
* - any of those with a setter: `[[Display Text|PassageName][$x to 1]]`
|
|
7035
|
+
* - `<<goto "PassageName">>` (SugarCube macro)
|
|
6424
7036
|
* - `<<link "Display" "PassageName">>` (SugarCube macro)
|
|
6425
7037
|
*
|
|
6426
|
-
*
|
|
6427
|
-
*
|
|
6428
|
-
*
|
|
6429
|
-
*
|
|
6430
|
-
*
|
|
6431
|
-
*
|
|
7038
|
+
* In link markup, the first `|`, `->` or `<-` divides the text from the passage name; image
|
|
7039
|
+
* markup (`[img[…][PassageName]]`) is not read. SugarCube macro arguments may be double- or
|
|
7040
|
+
* single-quoted, with backslash escapes, or bare words; a passage named by a variable or an
|
|
7041
|
+
* expression is known only in play and is skipped. Links and macro calls are also read inside the
|
|
7042
|
+
* quoted strings of macros' arguments, and inside the strings of `<<script>>` bodies and
|
|
7043
|
+
* `<script>` elements; links and calls in comments are not read. A script passage (`isScript`) is
|
|
7044
|
+
* JavaScript, so its links and calls are read only inside its strings. See `sugarcube-macros.ts`
|
|
7045
|
+
* for the details and the known differences from SugarCube.
|
|
6432
7046
|
*/
|
|
6433
7047
|
function extractLinksFromText(text, isScript) {
|
|
6434
|
-
const
|
|
6435
|
-
const
|
|
6436
|
-
|
|
6437
|
-
|
|
6438
|
-
|
|
6439
|
-
|
|
6440
|
-
|
|
6441
|
-
targets.add(content.slice(arrowIdx + 2).trim());
|
|
6442
|
-
continue;
|
|
6443
|
-
}
|
|
6444
|
-
const pipeIdx = content.indexOf("|");
|
|
6445
|
-
if (pipeIdx !== -1) {
|
|
6446
|
-
targets.add(content.slice(pipeIdx + 1).trim());
|
|
6447
|
-
continue;
|
|
6448
|
-
}
|
|
6449
|
-
targets.add(content.trim());
|
|
6450
|
-
}
|
|
6451
|
-
const macroLinks = isScript ? findJavaScriptPassageLinks(text) : findMacroPassageLinks(text);
|
|
6452
|
-
for (const macro of ["goto", "link"]) for (const link of macroLinks) if (link.macro === macro) targets.add(link.passage);
|
|
6453
|
-
return [...targets];
|
|
7048
|
+
const found = isScript ? findJavaScriptPassageLinks(text) : findPassageLinks(text);
|
|
7049
|
+
const ordered = [
|
|
7050
|
+
"markup",
|
|
7051
|
+
"goto",
|
|
7052
|
+
"link"
|
|
7053
|
+
].flatMap((via) => found.filter((link) => link.via === via).map((link) => link.passage));
|
|
7054
|
+
return [...new Set(ordered)];
|
|
6454
7055
|
}
|
|
6455
7056
|
/** Passages SugarCube runs from their raw text, never joining their lines. */
|
|
6456
7057
|
const RAW_TEXT_PASSAGES = /* @__PURE__ */ new Set([
|
|
@@ -6553,7 +7154,8 @@ function storyInspect(story) {
|
|
|
6553
7154
|
//#region src/lint.ts
|
|
6554
7155
|
/**
|
|
6555
7156
|
* Lint a story: compile without output rendering, then inspect structure.
|
|
6556
|
-
* Uses JSON output mode internally to avoid format resolution
|
|
7157
|
+
* Uses JSON output mode internally to avoid format resolution, so it checks
|
|
7158
|
+
* the starting passage itself, against the Twine 2 passage rules.
|
|
6557
7159
|
*/
|
|
6558
7160
|
async function lint(options) {
|
|
6559
7161
|
const result = await compile({
|
|
@@ -6562,7 +7164,7 @@ async function lint(options) {
|
|
|
6562
7164
|
});
|
|
6563
7165
|
const map = storyInspect(result.story);
|
|
6564
7166
|
return {
|
|
6565
|
-
diagnostics: result.diagnostics,
|
|
7167
|
+
diagnostics: [...result.diagnostics, ...startPassageDiagnostics(result.story, map.start, "twine2")],
|
|
6566
7168
|
stats: result.stats,
|
|
6567
7169
|
formatName: result.story.twine2.format,
|
|
6568
7170
|
formatVersion: result.story.twine2.formatVersion,
|
|
@@ -6926,26 +7528,32 @@ async function main() {
|
|
|
6926
7528
|
onBuild(result) {
|
|
6927
7529
|
console.log(`Built: ${result.stats.passages} passages, ${result.stats.words} words`);
|
|
6928
7530
|
logDiagnostics(result.diagnostics);
|
|
7531
|
+
const errors = countErrors(result.diagnostics);
|
|
7532
|
+
if (errors > 0) console.error(`Build has ${pluralize(errors, "error")}; still watching for changes.`);
|
|
6929
7533
|
},
|
|
6930
7534
|
onError(error) {
|
|
6931
7535
|
console.error(`Build error: ${error.message}`);
|
|
6932
7536
|
}
|
|
6933
7537
|
});
|
|
6934
|
-
} else if (outFile === "-") {
|
|
6935
|
-
const result = await compile(compileOptions);
|
|
6936
|
-
process.stdout.write(result.output);
|
|
6937
|
-
logDiagnostics(result.diagnostics);
|
|
6938
|
-
if (values["log-stats"]) logStats(result);
|
|
6939
7538
|
} else {
|
|
6940
|
-
const result = await
|
|
6941
|
-
...compileOptions,
|
|
6942
|
-
outFile
|
|
6943
|
-
});
|
|
7539
|
+
const result = await compile(compileOptions);
|
|
6944
7540
|
logDiagnostics(result.diagnostics);
|
|
6945
|
-
|
|
7541
|
+
const errors = countErrors(result.diagnostics);
|
|
7542
|
+
if (errors > 0) {
|
|
7543
|
+
console.error(`Compilation failed with ${pluralize(errors, "error")}; output not written.`);
|
|
7544
|
+
process.exitCode = 1;
|
|
7545
|
+
} else if (outFile === "-") process.stdout.write(result.output);
|
|
7546
|
+
else writeFileSync(outFile, result.output, "utf-8");
|
|
7547
|
+
if (outFile !== "-" && values["log-files"]) console.log(`\nFiles: ${result.stats.files.join(", ")}`);
|
|
6946
7548
|
if (values["log-stats"]) logStats(result);
|
|
6947
7549
|
}
|
|
6948
7550
|
}
|
|
7551
|
+
function countErrors(diagnostics) {
|
|
7552
|
+
return diagnostics.filter((d) => d.level === "error").length;
|
|
7553
|
+
}
|
|
7554
|
+
function pluralize(count, noun) {
|
|
7555
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
7556
|
+
}
|
|
6949
7557
|
function listFormats() {
|
|
6950
7558
|
const formats = discoverFormats(getFormatSearchDirs());
|
|
6951
7559
|
console.log("Local story formats:");
|
|
@@ -6971,26 +7579,56 @@ function logStats(result) {
|
|
|
6971
7579
|
console.log(` Words: ${s.words}`);
|
|
6972
7580
|
console.log(` Files: ${s.files.length}`);
|
|
6973
7581
|
}
|
|
6974
|
-
|
|
6975
|
-
|
|
6976
|
-
|
|
6977
|
-
|
|
7582
|
+
const SCAFFOLD_FILES = [
|
|
7583
|
+
{
|
|
7584
|
+
path: CONFIG_FILENAME,
|
|
7585
|
+
content: scaffoldConfig
|
|
7586
|
+
},
|
|
7587
|
+
{
|
|
7588
|
+
path: "src/StoryData.tw",
|
|
7589
|
+
content: () => `:: StoryData
|
|
6978
7590
|
{
|
|
6979
7591
|
\t"ifid": "${crypto.randomUUID().toUpperCase()}"
|
|
6980
7592
|
}
|
|
6981
|
-
`
|
|
6982
|
-
|
|
7593
|
+
`
|
|
7594
|
+
},
|
|
7595
|
+
{
|
|
7596
|
+
path: "src/Start.tw",
|
|
7597
|
+
content: () => `:: Start
|
|
6983
7598
|
Welcome to your new Twine story!
|
|
6984
7599
|
|
|
6985
7600
|
This is the starting passage. Edit this file to begin writing your story.
|
|
6986
|
-
`
|
|
6987
|
-
|
|
6988
|
-
|
|
6989
|
-
|
|
6990
|
-
|
|
6991
|
-
|
|
6992
|
-
|
|
6993
|
-
|
|
7601
|
+
`
|
|
7602
|
+
}
|
|
7603
|
+
];
|
|
7604
|
+
/**
|
|
7605
|
+
* Writes `content` to `path` unless the file already exists.
|
|
7606
|
+
* The exclusive flag makes the check and the write one step, so an existing file is never truncated.
|
|
7607
|
+
* Returns false when the file was already there.
|
|
7608
|
+
*/
|
|
7609
|
+
function writeNewFile(path, content) {
|
|
7610
|
+
try {
|
|
7611
|
+
writeFileSync(path, content, { flag: "wx" });
|
|
7612
|
+
return true;
|
|
7613
|
+
} catch (e) {
|
|
7614
|
+
if (e instanceof Error && "code" in e && e.code === "EEXIST") return false;
|
|
7615
|
+
throw new Error(`Cannot create ${path}: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
|
|
7616
|
+
}
|
|
7617
|
+
}
|
|
7618
|
+
function runInit() {
|
|
7619
|
+
console.log("Initializing new twee-ts project...");
|
|
7620
|
+
mkdirSync("src", { recursive: true });
|
|
7621
|
+
const results = SCAFFOLD_FILES.map((file) => ({
|
|
7622
|
+
path: file.path,
|
|
7623
|
+
created: writeNewFile(file.path, file.content())
|
|
7624
|
+
}));
|
|
7625
|
+
const created = results.filter((r) => r.created).map((r) => r.path);
|
|
7626
|
+
const skipped = results.filter((r) => !r.created).map((r) => r.path);
|
|
7627
|
+
if (created.length > 0) {
|
|
7628
|
+
console.log("Created:");
|
|
7629
|
+
for (const path of created) console.log(` ${path}`);
|
|
7630
|
+
}
|
|
7631
|
+
for (const path of skipped) console.log(`Skipped (already exists): ${path}`);
|
|
6994
7632
|
console.log("\nRun: npx @rohal12/twee-ts");
|
|
6995
7633
|
}
|
|
6996
7634
|
function formatBytes(bytes) {
|