@rohal12/twee-ts 1.15.4 → 1.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/twee-ts.js +1215 -540
- package/dist/bin/twee-ts.js.map +1 -1
- package/dist/{compiler-D3V_svy2.js → compiler-Bmpbelqj.js} +615 -273
- package/dist/compiler-Bmpbelqj.js.map +1 -0
- package/dist/index.cjs +1726 -1049
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +34 -7
- package/dist/index.d.ts +34 -7
- package/dist/index.js +1711 -1056
- package/dist/index.js.map +1 -1
- package/dist/plugins/rollup.d.ts +7 -1
- package/dist/plugins/rollup.js +16 -1
- package/dist/plugins/rollup.js.map +1 -1
- package/dist/plugins/vite.d.ts +1 -1
- package/dist/plugins/vite.js +67 -19
- package/dist/plugins/vite.js.map +1 -1
- package/dist/{types-BFyb40Zq.d.ts → types-dR5vTVu2.d.ts} +5 -0
- package/package.json +1 -1
- package/schemas/twee-ts.config.schema.json +5 -0
- package/dist/compiler-D3V_svy2.js.map +0 -1
package/dist/index.cjs
CHANGED
|
@@ -1,7 +1,30 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region \0rolldown/runtime.js
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
11
|
+
key = keys[i];
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
13
|
+
get: ((k) => from[k]).bind(null, key),
|
|
14
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
|
|
20
|
+
value: mod,
|
|
21
|
+
enumerable: true
|
|
22
|
+
}) : target, mod));
|
|
23
|
+
//#endregion
|
|
2
24
|
let node_fs = require("node:fs");
|
|
3
|
-
let node_crypto = require("node:crypto");
|
|
4
25
|
let node_path = require("node:path");
|
|
26
|
+
node_path = __toESM(node_path, 1);
|
|
27
|
+
let node_crypto = require("node:crypto");
|
|
5
28
|
let node_os = require("node:os");
|
|
6
29
|
let node_url = require("node:url");
|
|
7
30
|
//#region src/ifid.ts
|
|
@@ -110,6 +133,61 @@ function commentSanitize(s) {
|
|
|
110
133
|
function htmlCommentSanitize(s) {
|
|
111
134
|
return s.replace(/-->/g, "-- >");
|
|
112
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Escape JavaScript for the content of an HTML `<script>` element, so that the HTML parser reads all of it as
|
|
138
|
+
* the element's text and ends the element at the end tag written after it.
|
|
139
|
+
*
|
|
140
|
+
* - Every `<\/script` (in any letter case) becomes `<\/script`. HTML ends a script element at the first
|
|
141
|
+
* `<\/script` followed by whitespace, `/` or `>`, wherever it is in the JavaScript, even inside a string.
|
|
142
|
+
* - Every `<!--` becomes `<\!--`, but only if the text would otherwise end in the HTML tokenizer's "script
|
|
143
|
+
* data double escaped" state: after a `<!--` and then a `<script` start tag with no `-->` between them, the
|
|
144
|
+
* end tag after the text would not end the element.
|
|
145
|
+
*
|
|
146
|
+
* Inserting a backslash after `<` keeps the value of every string, template, and regular expression literal
|
|
147
|
+
* and every comment where these sequences can be written (`\/` and `\!` are identity escapes; a backslash
|
|
148
|
+
* already before the `<` escapes the `<`, as it did before); `<\/script` is also how minifiers such as Terser
|
|
149
|
+
* write inline scripts. It only changes code that uses them outside literals (`a <\/script/ b`, an HTML-like `<!--`
|
|
150
|
+
* comment that is escaped because of a later `<script`), the raw text of a tagged template, and a `<!--` in a
|
|
151
|
+
* regular expression with the `u` or `v` flag. The result is unchanged by escaping it again.
|
|
152
|
+
*/
|
|
153
|
+
function scriptContentEscape(s) {
|
|
154
|
+
const escaped = s.replace(/<(?=\/script)/gi, "<\\");
|
|
155
|
+
return endsDoubleEscaped(escaped) ? escaped.replace(/<(?=!--)/g, "<\\") : escaped;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Whether the HTML tokenizer, reading `s` as script element content with no `<\/script` in it, ends in the
|
|
159
|
+
* "script data double escaped" state, where an end tag after `s` would not end the element.
|
|
160
|
+
* See https://html.spec.whatwg.org/multipage/parsing.html#script-data-escaped-state.
|
|
161
|
+
*/
|
|
162
|
+
function endsDoubleEscaped(s) {
|
|
163
|
+
const scriptStartTag = /<script[\t\n\f\r />]/gi;
|
|
164
|
+
let pos = 0;
|
|
165
|
+
for (;;) {
|
|
166
|
+
const open = s.indexOf("<!--", pos);
|
|
167
|
+
if (open === -1) return false;
|
|
168
|
+
const escapedFrom = open + 2;
|
|
169
|
+
const close = s.indexOf("-->", escapedFrom);
|
|
170
|
+
scriptStartTag.lastIndex = escapedFrom;
|
|
171
|
+
const start = scriptStartTag.exec(s);
|
|
172
|
+
if (start === null || close !== -1 && close < start.index) {
|
|
173
|
+
if (close === -1) return false;
|
|
174
|
+
pos = close + 3;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const end = s.indexOf("-->", start.index + start[0].length);
|
|
178
|
+
if (end === -1) return true;
|
|
179
|
+
pos = end + 3;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Escape CSS for the content of an HTML `<style>` element: every `</style` (in any letter case) becomes
|
|
184
|
+
* `<\/style`, since HTML ends a style element at the first `</style` followed by whitespace, `/` or `>`.
|
|
185
|
+
* `\/` stands for `/` in CSS strings and URLs, and comments don't matter; `</style` anywhere else isn't valid
|
|
186
|
+
* CSS anyway.
|
|
187
|
+
*/
|
|
188
|
+
function styleContentEscape(s) {
|
|
189
|
+
return s.replace(/<(?=\/style)/gi, "<\\");
|
|
190
|
+
}
|
|
113
191
|
/** Apply ROT13 encoding to a string (only affects [A-Za-z]). */
|
|
114
192
|
function rot13(s) {
|
|
115
193
|
if (s.length === 0) return s;
|
|
@@ -632,11 +710,25 @@ function fontFormatHint(ext) {
|
|
|
632
710
|
* Ported from filesystem.go.
|
|
633
711
|
*/
|
|
634
712
|
/**
|
|
713
|
+
* Whether `filename` matches one of the `exclude` globs. The globs are matched
|
|
714
|
+
* against the file's path relative to the working directory, the path
|
|
715
|
+
* getFilenames reports; an absolute `filename` is made relative first. A leading
|
|
716
|
+
* `./` in a glob is dropped. Matching is Node's `path.matchesGlob`.
|
|
717
|
+
*/
|
|
718
|
+
function isExcluded(filename, exclude) {
|
|
719
|
+
if (exclude.length === 0) return false;
|
|
720
|
+
const { matchesGlob } = node_path;
|
|
721
|
+
if (typeof matchesGlob !== "function") throw new Error(`The exclude option needs Node.js 22.5 or newer (found ${process.version}).`);
|
|
722
|
+
const rel = (0, node_path.relative)(process.cwd(), (0, node_path.resolve)(filename)) || filename;
|
|
723
|
+
return exclude.some((pattern) => matchesGlob(rel, pattern.replace(/^\.\//, "")));
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
635
726
|
* Recursively walk directories, collecting regular file paths.
|
|
636
|
-
* Filters out the output file to prevent circular compilation
|
|
727
|
+
* Filters out the output file to prevent circular compilation, and the files
|
|
728
|
+
* that match an `exclude` glob (see isExcluded).
|
|
637
729
|
* Like Tweego, a path that cannot be read is reported as a warning and skipped.
|
|
638
730
|
*/
|
|
639
|
-
function getFilenames(pathnames, outFilename) {
|
|
731
|
+
function getFilenames(pathnames, outFilename, exclude = []) {
|
|
640
732
|
const filenames = [];
|
|
641
733
|
const diagnostics = [];
|
|
642
734
|
const absOutFile = outFilename ? (0, node_path.resolve)(outFilename) : "";
|
|
@@ -658,6 +750,7 @@ function getFilenames(pathnames, outFilename) {
|
|
|
658
750
|
const abs = (0, node_path.resolve)(pathname);
|
|
659
751
|
if (abs === absOutFile) return;
|
|
660
752
|
const rel = (0, node_path.relative)(process.cwd(), abs);
|
|
753
|
+
if (isExcluded(rel || abs, exclude)) return;
|
|
661
754
|
filenames.push(rel || abs);
|
|
662
755
|
} else if (stat.isDirectory()) {
|
|
663
756
|
let entries;
|
|
@@ -676,11 +769,38 @@ function getFilenames(pathnames, outFilename) {
|
|
|
676
769
|
diagnostics
|
|
677
770
|
};
|
|
678
771
|
}
|
|
772
|
+
/** Whether the absolute path `file` is the absolute path `dir` or inside it. */
|
|
773
|
+
function isInsideDir(file, dir) {
|
|
774
|
+
return file === dir || file.startsWith(dir.endsWith(node_path.sep) ? dir : dir + node_path.sep);
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* The watched paths split into folders (as given) and files (absolute). A path
|
|
778
|
+
* that cannot be read counts as a folder, which fs.watch then fails to watch.
|
|
779
|
+
*/
|
|
780
|
+
function splitWatchRoots(pathnames) {
|
|
781
|
+
const dirs = [];
|
|
782
|
+
const files = /* @__PURE__ */ new Set();
|
|
783
|
+
for (const pathname of pathnames) {
|
|
784
|
+
let isFile = false;
|
|
785
|
+
try {
|
|
786
|
+
isFile = (0, node_fs.statSync)(pathname).isFile();
|
|
787
|
+
} catch {}
|
|
788
|
+
if (isFile) files.add((0, node_path.resolve)(pathname));
|
|
789
|
+
else dirs.push(pathname);
|
|
790
|
+
}
|
|
791
|
+
return {
|
|
792
|
+
dirs,
|
|
793
|
+
files
|
|
794
|
+
};
|
|
795
|
+
}
|
|
679
796
|
/**
|
|
680
797
|
* Watch paths for changes, calling the build callback on known file type changes.
|
|
798
|
+
* A folder is watched recursively; a file is watched on its own and counts
|
|
799
|
+
* whatever its type (a head file, say). A change to a file `ignore` returns true
|
|
800
|
+
* for (given the path relative to the working directory) schedules no build.
|
|
681
801
|
* Uses debouncing to avoid rapid rebuilds.
|
|
682
802
|
*/
|
|
683
|
-
function watchFilesystem(pathnames, outFilename, callback) {
|
|
803
|
+
function watchFilesystem(pathnames, outFilename, callback, ignore = () => false) {
|
|
684
804
|
const absOutFile = (0, node_path.resolve)(outFilename);
|
|
685
805
|
const watchers = [];
|
|
686
806
|
let buildTimer = null;
|
|
@@ -696,15 +816,30 @@ function watchFilesystem(pathnames, outFilename, callback) {
|
|
|
696
816
|
callback(files);
|
|
697
817
|
}, BUILD_DEBOUNCE);
|
|
698
818
|
}
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
819
|
+
function fileChanged(abs, named) {
|
|
820
|
+
if (abs === absOutFile) return;
|
|
821
|
+
if (!named && !isKnownFileType(abs)) return;
|
|
822
|
+
const rel = (0, node_path.relative)(process.cwd(), abs);
|
|
823
|
+
if (!ignore(rel || abs)) scheduleBuild(rel || abs);
|
|
824
|
+
}
|
|
825
|
+
function watchPath(pathname, recursive, listener) {
|
|
826
|
+
try {
|
|
827
|
+
watchers.push((0, node_fs.watch)(pathname, { recursive }, (_event, filename) => {
|
|
828
|
+
if (filename) listener(filename);
|
|
829
|
+
}));
|
|
830
|
+
} catch {}
|
|
831
|
+
}
|
|
832
|
+
const { dirs, files } = splitWatchRoots(pathnames);
|
|
833
|
+
const absDirs = dirs.map((dir) => (0, node_path.resolve)(dir));
|
|
834
|
+
for (const dir of dirs) watchPath(dir, true, (filename) => {
|
|
835
|
+
const abs = (0, node_path.resolve)(dir, filename);
|
|
836
|
+
fileChanged(abs, files.has(abs));
|
|
837
|
+
});
|
|
838
|
+
const fileDirs = new Set([...files].filter((file) => !absDirs.some((dir) => isInsideDir(file, dir))).map((file) => (0, node_path.dirname)(file)));
|
|
839
|
+
for (const dir of fileDirs) watchPath(dir, false, (filename) => {
|
|
840
|
+
const abs = (0, node_path.resolve)(dir, filename);
|
|
841
|
+
if (files.has(abs)) fileChanged(abs, true);
|
|
842
|
+
});
|
|
708
843
|
callback();
|
|
709
844
|
return { close() {
|
|
710
845
|
if (buildTimer) clearTimeout(buildTimer);
|
|
@@ -712,16 +847,25 @@ function watchFilesystem(pathnames, outFilename, callback) {
|
|
|
712
847
|
} };
|
|
713
848
|
}
|
|
714
849
|
//#endregion
|
|
850
|
+
//#region src/source-text.ts
|
|
851
|
+
/**
|
|
852
|
+
* Source text normalization shared by every input path (files, in-memory sources, `parseTwee`).
|
|
853
|
+
*/
|
|
854
|
+
/**
|
|
855
|
+
* Strip a leading UTF-8 byte order mark and convert CRLF and bare CR line endings to LF.
|
|
856
|
+
* Idempotent, so text that is already normalized passes through unchanged.
|
|
857
|
+
*/
|
|
858
|
+
function normalizeSourceText(text) {
|
|
859
|
+
return (text.charCodeAt(0) === 65279 ? text.slice(1) : text).replace(/\r\n?/g, "\n");
|
|
860
|
+
}
|
|
861
|
+
//#endregion
|
|
715
862
|
//#region src/util.ts
|
|
716
863
|
/**
|
|
717
864
|
* Shared file I/O utilities.
|
|
718
865
|
*/
|
|
719
866
|
/** Read a file as UTF-8 with BOM stripping and line ending normalization. */
|
|
720
867
|
function readUTF8(filename) {
|
|
721
|
-
|
|
722
|
-
if (content.charCodeAt(0) === 65279) content = content.slice(1);
|
|
723
|
-
content = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
724
|
-
return content;
|
|
868
|
+
return normalizeSourceText((0, node_fs.readFileSync)(filename, "utf-8"));
|
|
725
869
|
}
|
|
726
870
|
/** Read a file as base64. */
|
|
727
871
|
function readBase64(filename) {
|
|
@@ -751,16 +895,28 @@ function relaxedJSONParse(json) {
|
|
|
751
895
|
fixed = fixed.replace(/,\s*([}\]])/g, "$1");
|
|
752
896
|
return JSON.parse(fixed);
|
|
753
897
|
}
|
|
898
|
+
/** A `"setup": function` property, which Harlowe appends to its otherwise-JSON format object. */
|
|
899
|
+
const SETUP_FUNCTION_PROPERTY = /,\s*"setup"\s*:\s*function\b/g;
|
|
900
|
+
/** Drop a trailing `"setup": function(){…}` property from a format object, if it has one. */
|
|
901
|
+
function stripSetupFunction(chunk) {
|
|
902
|
+
let lastIndex = -1;
|
|
903
|
+
for (const match of chunk.matchAll(SETUP_FUNCTION_PROPERTY)) lastIndex = match.index;
|
|
904
|
+
return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
|
|
905
|
+
}
|
|
754
906
|
/**
|
|
755
907
|
* Parse the Twine 2 format.js JSON chunk.
|
|
756
|
-
* Handles Harlowe's malformed JSON by stripping the "setup" function property.
|
|
908
|
+
* Handles Harlowe's malformed JSON by stripping the "setup" function property. The workaround
|
|
909
|
+
* keys on the property itself, so the same bytes parse whether they come from a `harlowe-3`
|
|
910
|
+
* directory or a direct download.
|
|
757
911
|
* Per spec, the name key is Optional.
|
|
912
|
+
*
|
|
913
|
+
* @param _formatId No longer used; kept so existing callers keep compiling.
|
|
758
914
|
*/
|
|
759
|
-
function parseFormatJSON(source,
|
|
915
|
+
function parseFormatJSON(source, _formatId) {
|
|
760
916
|
const first = source.indexOf("{");
|
|
761
917
|
const last = source.lastIndexOf("}");
|
|
762
918
|
if (first === -1 || last === -1) return null;
|
|
763
|
-
|
|
919
|
+
const chunk = source.slice(first, last + 1);
|
|
764
920
|
const parse = (json) => {
|
|
765
921
|
const raw = relaxedJSONParse(json);
|
|
766
922
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
|
@@ -783,16 +939,13 @@ function parseFormatJSON(source, formatId) {
|
|
|
783
939
|
try {
|
|
784
940
|
return parse(chunk);
|
|
785
941
|
} catch {
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
} catch {}
|
|
793
|
-
}
|
|
942
|
+
const stripped = stripSetupFunction(chunk);
|
|
943
|
+
if (stripped === void 0) return null;
|
|
944
|
+
try {
|
|
945
|
+
return parse(stripped);
|
|
946
|
+
} catch {
|
|
947
|
+
return null;
|
|
794
948
|
}
|
|
795
|
-
return null;
|
|
796
949
|
}
|
|
797
950
|
}
|
|
798
951
|
/**
|
|
@@ -942,6 +1095,54 @@ function semverCompare(a, b) {
|
|
|
942
1095
|
if (aPatch !== bPatch) return aPatch - bPatch;
|
|
943
1096
|
return 0;
|
|
944
1097
|
}
|
|
1098
|
+
/** Build a directory-style format ID from a name and version, e.g. ('SugarCube', '2.37.3') → 'sugarcube-2'. */
|
|
1099
|
+
function makeFormatId(name, version) {
|
|
1100
|
+
const major = parseSemver(version)?.[0] ?? 0;
|
|
1101
|
+
return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
|
|
1102
|
+
}
|
|
1103
|
+
/** Describe a format request for diagnostics. */
|
|
1104
|
+
function describeFormatRequest(request) {
|
|
1105
|
+
switch (request.kind) {
|
|
1106
|
+
case "id": return `"${request.id}"`;
|
|
1107
|
+
case "name": return request.version ? `"${request.name}" at version "${request.version}"` : `"${request.name}"`;
|
|
1108
|
+
default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Pick the candidate that best answers a format request.
|
|
1113
|
+
*
|
|
1114
|
+
* - An ID request ('sugarcube-2') matches a candidate whose name and major version build that ID,
|
|
1115
|
+
* and takes the greatest such version.
|
|
1116
|
+
* - A name request matches the name case-insensitively. An exact version wins outright; otherwise
|
|
1117
|
+
* the greatest version with the same major that is not older than the one asked for (or, with
|
|
1118
|
+
* `allowOlder`, the greatest same-major version). An unparseable requested version takes the
|
|
1119
|
+
* greatest version of any major.
|
|
1120
|
+
*/
|
|
1121
|
+
function selectFormatCandidate(request, candidates, describe, options = {}) {
|
|
1122
|
+
const allowOlder = options.allowOlder ?? false;
|
|
1123
|
+
const wanted = request.kind === "name" ? parseSemver(request.version) : null;
|
|
1124
|
+
let best;
|
|
1125
|
+
for (const candidate of candidates) {
|
|
1126
|
+
const { name, version } = describe(candidate);
|
|
1127
|
+
const have = parseSemver(version);
|
|
1128
|
+
if (!have) continue;
|
|
1129
|
+
if (request.kind === "id") {
|
|
1130
|
+
if (makeFormatId(name, version) !== request.id.toLowerCase()) continue;
|
|
1131
|
+
} else {
|
|
1132
|
+
if (name.toLowerCase() !== request.name.toLowerCase()) continue;
|
|
1133
|
+
if (wanted) {
|
|
1134
|
+
if (semverCompare(have, wanted) === 0) return candidate;
|
|
1135
|
+
if (have[0] !== wanted[0]) continue;
|
|
1136
|
+
if (!allowOlder && semverCompare(have, wanted) < 0) continue;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
if (!best || semverCompare(have, best.version) > 0) best = {
|
|
1140
|
+
candidate,
|
|
1141
|
+
version: have
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
return best?.candidate;
|
|
1145
|
+
}
|
|
945
1146
|
/** Get format ID from Twine 2 name and version, using SemVer major matching. */
|
|
946
1147
|
function getFormatIdByNameAndVersion(formats, name, version) {
|
|
947
1148
|
const wanted = parseSemver(version);
|
|
@@ -971,163 +1172,578 @@ function readFormatSource(format) {
|
|
|
971
1172
|
return source;
|
|
972
1173
|
}
|
|
973
1174
|
//#endregion
|
|
974
|
-
//#region src/
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
//#endregion
|
|
985
|
-
//#region src/lexer.ts
|
|
986
|
-
const EOF = -1;
|
|
987
|
-
const HEADER_DELIM = "::";
|
|
988
|
-
const NEWLINE_HEADER_DELIM = "\n::";
|
|
989
|
-
var LexerContext = class {
|
|
990
|
-
input;
|
|
991
|
-
line = 1;
|
|
992
|
-
start = 0;
|
|
993
|
-
pos = 0;
|
|
994
|
-
items = [];
|
|
995
|
-
constructor(input) {
|
|
996
|
-
this.input = input;
|
|
997
|
-
}
|
|
998
|
-
next() {
|
|
999
|
-
if (this.pos >= this.input.length) return EOF;
|
|
1000
|
-
const ch = this.input.charCodeAt(this.pos);
|
|
1001
|
-
this.pos++;
|
|
1002
|
-
if (ch === 10) this.line++;
|
|
1003
|
-
return ch;
|
|
1004
|
-
}
|
|
1005
|
-
peek() {
|
|
1006
|
-
if (this.pos >= this.input.length) return EOF;
|
|
1007
|
-
return this.input.charCodeAt(this.pos);
|
|
1008
|
-
}
|
|
1009
|
-
backup() {
|
|
1010
|
-
if (this.pos > this.start) {
|
|
1011
|
-
this.pos--;
|
|
1012
|
-
if (this.input.charCodeAt(this.pos) === 10) this.line--;
|
|
1013
|
-
} else throw new Error("backup would leave pos < start");
|
|
1014
|
-
}
|
|
1015
|
-
emit(type) {
|
|
1016
|
-
const val = this.input.slice(this.start, this.pos);
|
|
1017
|
-
this.items.push({
|
|
1018
|
-
type,
|
|
1019
|
-
line: this.line,
|
|
1020
|
-
pos: this.start,
|
|
1021
|
-
val
|
|
1022
|
-
});
|
|
1023
|
-
if (type === ItemType.Content) {
|
|
1024
|
-
for (let i = this.start; i < this.pos; i++) if (this.input.charCodeAt(i) === 10) this.line++;
|
|
1025
|
-
}
|
|
1026
|
-
this.start = this.pos;
|
|
1027
|
-
}
|
|
1028
|
-
ignore() {
|
|
1029
|
-
for (let i = this.start; i < this.pos; i++) if (this.input.charCodeAt(i) === 10) this.line++;
|
|
1030
|
-
this.start = this.pos;
|
|
1031
|
-
}
|
|
1032
|
-
acceptRun(valid) {
|
|
1033
|
-
let ch;
|
|
1034
|
-
for (ch = this.next(); ch !== EOF && valid.includes(String.fromCharCode(ch)); ch = this.next());
|
|
1035
|
-
if (ch !== EOF) this.backup();
|
|
1036
|
-
}
|
|
1037
|
-
errorf(message) {
|
|
1038
|
-
this.items.push({
|
|
1039
|
-
type: ItemType.Error,
|
|
1040
|
-
line: this.line,
|
|
1041
|
-
pos: this.start,
|
|
1042
|
-
val: message
|
|
1043
|
-
});
|
|
1044
|
-
return null;
|
|
1045
|
-
}
|
|
1046
|
-
/** Drain all pending items and return them. */
|
|
1047
|
-
drainItems() {
|
|
1048
|
-
const result = this.items;
|
|
1049
|
-
this.items = [];
|
|
1050
|
-
return result;
|
|
1051
|
-
}
|
|
1052
|
-
};
|
|
1053
|
-
function acceptQuoted(ctx, quote) {
|
|
1054
|
-
for (;;) {
|
|
1055
|
-
const ch = ctx.next();
|
|
1056
|
-
switch (ch) {
|
|
1057
|
-
case 92: {
|
|
1058
|
-
const r = ctx.next();
|
|
1059
|
-
if (r !== 10 && r !== EOF) break;
|
|
1060
|
-
}
|
|
1061
|
-
case 10:
|
|
1062
|
-
case EOF: return "unterminated quoted string";
|
|
1063
|
-
default: if (ch === quote) return null;
|
|
1064
|
-
}
|
|
1065
|
-
}
|
|
1175
|
+
//#region src/remote-formats.ts
|
|
1176
|
+
/**
|
|
1177
|
+
* Remote story format fetching, caching, and checksum verification.
|
|
1178
|
+
* Uses the Story Formats Archive (SFA) as the default source.
|
|
1179
|
+
*/
|
|
1180
|
+
const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
|
|
1181
|
+
/** Get the cache directory for downloaded story formats. */
|
|
1182
|
+
function getCacheDir() {
|
|
1183
|
+
const base = process.env["XDG_CACHE_HOME"] || (0, node_path.join)((0, node_os.homedir)(), ".cache");
|
|
1184
|
+
return (0, node_path.join)(base, "twee-ts", "storyformats");
|
|
1066
1185
|
}
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1186
|
+
/**
|
|
1187
|
+
* Whether a string can serve as one directory name inside the cache: not empty, not `.` or `..`,
|
|
1188
|
+
* and free of path separators. Format names and versions come from downloaded metadata, so they
|
|
1189
|
+
* are untrusted path input.
|
|
1190
|
+
*/
|
|
1191
|
+
function isSafeSegment(segment) {
|
|
1192
|
+
return segment !== "" && segment !== "." && segment !== ".." && !/[/\\\0]/.test(segment);
|
|
1193
|
+
}
|
|
1194
|
+
/** Whether absolute path `target` lies strictly inside absolute path `root`. */
|
|
1195
|
+
function isInside(root, target) {
|
|
1196
|
+
const rel = (0, node_path.relative)(root, target);
|
|
1197
|
+
return rel !== "" && !(0, node_path.isAbsolute)(rel) && rel.split(node_path.sep)[0] !== "..";
|
|
1198
|
+
}
|
|
1199
|
+
/** The cache directory for one format version. Throws when the name or version could leave the cache. */
|
|
1200
|
+
function cachedFormatDir(name, version) {
|
|
1201
|
+
if (!isSafeSegment(name)) throw new Error(`Refusing to cache a story format with an unsafe name: ${JSON.stringify(name)}`);
|
|
1202
|
+
if (!isSafeSegment(version) || !parseSemver(version)) throw new Error(`Refusing to cache story format "${name}" with an unsafe version: ${JSON.stringify(version)}`);
|
|
1203
|
+
const root = (0, node_path.resolve)(getCacheDir());
|
|
1204
|
+
const dir = (0, node_path.resolve)(root, name, version);
|
|
1205
|
+
if (!isInside(root, dir)) throw new Error(`Refusing to cache a story format outside the cache directory ${root}: ${dir}`);
|
|
1206
|
+
return dir;
|
|
1207
|
+
}
|
|
1208
|
+
/** Create `dir` as a plain directory, or check that it already is one (a symlink could lead out of the cache). */
|
|
1209
|
+
function ensurePlainDirectory(dir) {
|
|
1210
|
+
const stat = (0, node_fs.lstatSync)(dir, { throwIfNoEntry: false });
|
|
1211
|
+
if (stat === void 0) {
|
|
1212
|
+
(0, node_fs.mkdirSync)(dir);
|
|
1213
|
+
return;
|
|
1074
1214
|
}
|
|
1075
|
-
|
|
1076
|
-
|
|
1215
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`Refusing to write a story format outside the cache directory: ${dir} is not a plain directory`);
|
|
1216
|
+
}
|
|
1217
|
+
/** Write a downloaded format.js to `<cache>/<name>/<version>/format.js` and return its path. */
|
|
1218
|
+
function writeCachedFormat(name, version, text) {
|
|
1219
|
+
const dir = cachedFormatDir(name, version);
|
|
1220
|
+
const root = (0, node_path.resolve)(getCacheDir());
|
|
1221
|
+
(0, node_fs.mkdirSync)(root, { recursive: true });
|
|
1222
|
+
ensurePlainDirectory((0, node_path.join)(root, name));
|
|
1223
|
+
ensurePlainDirectory(dir);
|
|
1224
|
+
const formatPath = (0, node_path.join)(dir, "format.js");
|
|
1225
|
+
if ((0, node_fs.lstatSync)(formatPath, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error(`Refusing to write a story format outside the cache directory: ${formatPath} is a symlink`);
|
|
1226
|
+
(0, node_fs.writeFileSync)(formatPath, text, "utf-8");
|
|
1227
|
+
return formatPath;
|
|
1077
1228
|
}
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
ctx.emit(ItemType.Content);
|
|
1084
|
-
return lexHeaderDelim;
|
|
1085
|
-
}
|
|
1086
|
-
ctx.pos = ctx.input.length;
|
|
1087
|
-
if (ctx.pos > ctx.start) ctx.emit(ItemType.Content);
|
|
1088
|
-
ctx.emit(ItemType.EOF);
|
|
1089
|
-
return null;
|
|
1229
|
+
/** In-memory index cache, keyed by URL. Cleared each compile. */
|
|
1230
|
+
const indexCache = /* @__PURE__ */ new Map();
|
|
1231
|
+
/** Clear the in-memory index cache. */
|
|
1232
|
+
function clearIndexCache() {
|
|
1233
|
+
indexCache.clear();
|
|
1090
1234
|
}
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1235
|
+
/** Validate that a JSON value is a valid SFAIndexEntry. */
|
|
1236
|
+
function isValidEntry(val) {
|
|
1237
|
+
if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
|
|
1238
|
+
const obj = val;
|
|
1239
|
+
return typeof obj.name === "string" && typeof obj.version === "string" && typeof obj.checksums === "object" && obj.checksums !== null && !Array.isArray(obj.checksums);
|
|
1095
1240
|
}
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
r = next;
|
|
1105
|
-
}
|
|
1106
|
-
case 91:
|
|
1107
|
-
case 93:
|
|
1108
|
-
case 123:
|
|
1109
|
-
case 125:
|
|
1110
|
-
case 10:
|
|
1111
|
-
case EOF:
|
|
1112
|
-
if (r !== EOF) ctx.backup();
|
|
1113
|
-
break outer;
|
|
1114
|
-
}
|
|
1115
|
-
}
|
|
1116
|
-
ctx.emit(ItemType.Name);
|
|
1117
|
-
switch (r) {
|
|
1118
|
-
case 91: return lexTags;
|
|
1119
|
-
case 93: return ctx.errorf(`unexpected right square bracket ']'`);
|
|
1120
|
-
case 123: return lexMetadata;
|
|
1121
|
-
case 125: return ctx.errorf(`unexpected right curly brace '}'`);
|
|
1122
|
-
case 10:
|
|
1123
|
-
ctx.pos++;
|
|
1124
|
-
ctx.ignore();
|
|
1125
|
-
return lexContent;
|
|
1126
|
-
}
|
|
1127
|
-
ctx.emit(ItemType.EOF);
|
|
1128
|
-
return null;
|
|
1241
|
+
/** Validate that a JSON value conforms to the SFAIndex shape. */
|
|
1242
|
+
function validateSFAIndex(json) {
|
|
1243
|
+
if (typeof json !== "object" || json === null) throw new Error("SFA index is not an object");
|
|
1244
|
+
const obj = json;
|
|
1245
|
+
return {
|
|
1246
|
+
twine1: Array.isArray(obj["twine1"]) ? obj["twine1"].filter(isValidEntry) : [],
|
|
1247
|
+
twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
|
|
1248
|
+
};
|
|
1129
1249
|
}
|
|
1130
|
-
|
|
1250
|
+
/** Fetch and parse an SFA index.json, with in-memory caching. */
|
|
1251
|
+
async function fetchIndex(url) {
|
|
1252
|
+
const cached = indexCache.get(url);
|
|
1253
|
+
if (cached) return cached;
|
|
1254
|
+
const res = await fetch(url);
|
|
1255
|
+
if (!res.ok) throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
|
|
1256
|
+
const data = validateSFAIndex(await res.json());
|
|
1257
|
+
indexCache.set(url, data);
|
|
1258
|
+
return data;
|
|
1259
|
+
}
|
|
1260
|
+
/** Find the best matching entry in an SFA index for a name or ID request (twine2 entries first). */
|
|
1261
|
+
function findEntryForRequest(index, request) {
|
|
1262
|
+
return selectFormatCandidate(request, [...(index.twine2 ?? []).map((entry) => ({
|
|
1263
|
+
entry,
|
|
1264
|
+
formatType: "twine2"
|
|
1265
|
+
})), ...(index.twine1 ?? []).map((entry) => ({
|
|
1266
|
+
entry,
|
|
1267
|
+
formatType: "twine1"
|
|
1268
|
+
}))], (c) => c.entry);
|
|
1269
|
+
}
|
|
1270
|
+
/** Verify SHA-256 checksum using Web Crypto API (Node 22 built-in). */
|
|
1271
|
+
async function verifySHA256(content, expectedHex) {
|
|
1272
|
+
const digest = await crypto.subtle.digest("SHA-256", content);
|
|
1273
|
+
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("") === expectedHex.toLowerCase();
|
|
1274
|
+
}
|
|
1275
|
+
/** Derive the download URL for a format.js from the index URL and entry. */
|
|
1276
|
+
function getDownloadUrl(indexUrl, entry, formatType) {
|
|
1277
|
+
return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
|
|
1278
|
+
}
|
|
1279
|
+
/** Download a format, verify its checksum, write to cache, and return StoryFormatInfo. */
|
|
1280
|
+
async function fetchAndCacheFormat(entry, downloadUrl) {
|
|
1281
|
+
cachedFormatDir(entry.name, entry.version);
|
|
1282
|
+
const res = await fetch(downloadUrl);
|
|
1283
|
+
if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
|
|
1284
|
+
const text = await res.text();
|
|
1285
|
+
const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
|
|
1286
|
+
if (checksumKey) {
|
|
1287
|
+
const expected = entry.checksums[checksumKey];
|
|
1288
|
+
if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
|
|
1289
|
+
if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
|
|
1290
|
+
}
|
|
1291
|
+
const id = makeFormatId(entry.name, entry.version);
|
|
1292
|
+
const data = parseFormatJSON(text, id);
|
|
1293
|
+
if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
|
|
1294
|
+
return {
|
|
1295
|
+
id,
|
|
1296
|
+
filename: writeCachedFormat(entry.name, entry.version, text),
|
|
1297
|
+
isTwine2: true,
|
|
1298
|
+
name: data.name,
|
|
1299
|
+
version: data.version,
|
|
1300
|
+
proofing: data.proofing ?? false
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
/** Download a direct format.js URL, parse its JSON, cache it, and return StoryFormatInfo. */
|
|
1304
|
+
async function fetchDirectFormat(url) {
|
|
1305
|
+
const res = await fetch(url);
|
|
1306
|
+
if (!res.ok) throw new Error(`Failed to download format from ${url}: ${res.status} ${res.statusText}`);
|
|
1307
|
+
const text = await res.text();
|
|
1308
|
+
const data = parseFormatJSON(text);
|
|
1309
|
+
if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
|
|
1310
|
+
return {
|
|
1311
|
+
id: makeFormatId(data.name, data.version),
|
|
1312
|
+
filename: writeCachedFormat(data.name, data.version, text),
|
|
1313
|
+
isTwine2: true,
|
|
1314
|
+
name: data.name,
|
|
1315
|
+
version: data.version,
|
|
1316
|
+
proofing: data.proofing ?? false
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
/**
|
|
1320
|
+
* Try to resolve a remote story format by name and version.
|
|
1321
|
+
* 0. Use an exactly matching cached download, without touching the network
|
|
1322
|
+
* 1. Try direct format URLs
|
|
1323
|
+
* 2. Try custom index URLs
|
|
1324
|
+
* 3. Try default SFA indices
|
|
1325
|
+
* 4. Fall back to a compatible cached download (e.g. when offline)
|
|
1326
|
+
*/
|
|
1327
|
+
async function resolveRemoteFormat(name, version, indices, urls) {
|
|
1328
|
+
return resolveRemoteFormatRequest({
|
|
1329
|
+
kind: "name",
|
|
1330
|
+
name,
|
|
1331
|
+
version
|
|
1332
|
+
}, indices, urls);
|
|
1333
|
+
}
|
|
1334
|
+
/**
|
|
1335
|
+
* Resolve a story format request remotely, in the same order as {@link resolveRemoteFormat}.
|
|
1336
|
+
* An ID request such as 'sugarcube-2' matches the format whose name and major version build that ID
|
|
1337
|
+
* (SugarCube 2.x), taking the greatest version available.
|
|
1338
|
+
*/
|
|
1339
|
+
async function resolveRemoteFormatRequest(request, indices, urls) {
|
|
1340
|
+
let lastError;
|
|
1341
|
+
const cached = findCachedFormat(request);
|
|
1342
|
+
if (cached && request.kind === "name" && isExactVersion(cached.version, request.version)) return cached;
|
|
1343
|
+
for (const url of urls ?? []) try {
|
|
1344
|
+
const info = await fetchDirectFormat(url);
|
|
1345
|
+
if (selectFormatCandidate(request, [info], (f) => f)) return info;
|
|
1346
|
+
} catch (e) {
|
|
1347
|
+
lastError = e instanceof Error ? e : new Error(String(e));
|
|
1348
|
+
}
|
|
1349
|
+
const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
|
|
1350
|
+
for (const indexUrl of allIndices) try {
|
|
1351
|
+
const result = findEntryForRequest(await fetchIndex(indexUrl), request);
|
|
1352
|
+
if (result) {
|
|
1353
|
+
const hit = getCachedFormat(result.entry.name, result.entry.version);
|
|
1354
|
+
if (hit) return hit;
|
|
1355
|
+
const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
|
|
1356
|
+
return await fetchAndCacheFormat(result.entry, downloadUrl);
|
|
1357
|
+
}
|
|
1358
|
+
} catch (e) {
|
|
1359
|
+
lastError = e instanceof Error ? e : new Error(String(e));
|
|
1360
|
+
}
|
|
1361
|
+
if (cached) return cached;
|
|
1362
|
+
if (lastError) throw lastError;
|
|
1363
|
+
}
|
|
1364
|
+
/** Whether two version strings name the same SemVer version. */
|
|
1365
|
+
function isExactVersion(have, wanted) {
|
|
1366
|
+
const a = parseSemver(have);
|
|
1367
|
+
const b = parseSemver(wanted);
|
|
1368
|
+
return a !== null && b !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
|
|
1369
|
+
}
|
|
1370
|
+
/**
|
|
1371
|
+
* Find the cached download that best answers a format request, without network access.
|
|
1372
|
+
* Matching follows {@link selectFormatCandidate}.
|
|
1373
|
+
*/
|
|
1374
|
+
function findCachedFormat(request, options = {}) {
|
|
1375
|
+
return selectFormatCandidate(request, [...discoverCachedFormats().values()], (f) => f, options);
|
|
1376
|
+
}
|
|
1377
|
+
/** Check if a format is already in the local cache. */
|
|
1378
|
+
function getCachedFormat(name, version) {
|
|
1379
|
+
if (!isSafeSegment(name) || !isSafeSegment(version)) return void 0;
|
|
1380
|
+
const formatPath = (0, node_path.join)(getCacheDir(), name, version, "format.js");
|
|
1381
|
+
try {
|
|
1382
|
+
if (!(0, node_fs.existsSync)(formatPath)) return void 0;
|
|
1383
|
+
const source = (0, node_fs.readFileSync)(formatPath, "utf-8");
|
|
1384
|
+
const id = makeFormatId(name, version);
|
|
1385
|
+
const data = parseFormatJSON(source, id);
|
|
1386
|
+
if (!data) return void 0;
|
|
1387
|
+
return {
|
|
1388
|
+
id,
|
|
1389
|
+
filename: formatPath,
|
|
1390
|
+
isTwine2: true,
|
|
1391
|
+
name: data.name,
|
|
1392
|
+
version: data.version,
|
|
1393
|
+
proofing: data.proofing ?? false
|
|
1394
|
+
};
|
|
1395
|
+
} catch {
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
/** Discover all cached remote formats. Returns a Map like discoverFormats(). */
|
|
1400
|
+
function discoverCachedFormats() {
|
|
1401
|
+
const formats = /* @__PURE__ */ new Map();
|
|
1402
|
+
const cacheDir = getCacheDir();
|
|
1403
|
+
try {
|
|
1404
|
+
if (!(0, node_fs.existsSync)(cacheDir)) return formats;
|
|
1405
|
+
} catch {
|
|
1406
|
+
return formats;
|
|
1407
|
+
}
|
|
1408
|
+
let names;
|
|
1409
|
+
try {
|
|
1410
|
+
names = (0, node_fs.readdirSync)(cacheDir);
|
|
1411
|
+
} catch {
|
|
1412
|
+
return formats;
|
|
1413
|
+
}
|
|
1414
|
+
for (const name of names) {
|
|
1415
|
+
const nameDir = (0, node_path.join)(cacheDir, name);
|
|
1416
|
+
try {
|
|
1417
|
+
if (!(0, node_fs.statSync)(nameDir).isDirectory()) continue;
|
|
1418
|
+
} catch {
|
|
1419
|
+
continue;
|
|
1420
|
+
}
|
|
1421
|
+
let versions;
|
|
1422
|
+
try {
|
|
1423
|
+
versions = (0, node_fs.readdirSync)(nameDir);
|
|
1424
|
+
} catch {
|
|
1425
|
+
continue;
|
|
1426
|
+
}
|
|
1427
|
+
for (const version of versions) {
|
|
1428
|
+
const info = getCachedFormat(name, version);
|
|
1429
|
+
if (info) formats.set(`${info.id}-${version}`, info);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
return formats;
|
|
1433
|
+
}
|
|
1434
|
+
/** List all cached formats with size and modification date. */
|
|
1435
|
+
function listCachedFormats() {
|
|
1436
|
+
const cacheDir = getCacheDir();
|
|
1437
|
+
const entries = [];
|
|
1438
|
+
try {
|
|
1439
|
+
if (!(0, node_fs.existsSync)(cacheDir)) return entries;
|
|
1440
|
+
} catch {
|
|
1441
|
+
return entries;
|
|
1442
|
+
}
|
|
1443
|
+
let names;
|
|
1444
|
+
try {
|
|
1445
|
+
names = (0, node_fs.readdirSync)(cacheDir);
|
|
1446
|
+
} catch {
|
|
1447
|
+
return entries;
|
|
1448
|
+
}
|
|
1449
|
+
for (const name of names) {
|
|
1450
|
+
const nameDir = (0, node_path.join)(cacheDir, name);
|
|
1451
|
+
try {
|
|
1452
|
+
if (!(0, node_fs.statSync)(nameDir).isDirectory()) continue;
|
|
1453
|
+
} catch {
|
|
1454
|
+
continue;
|
|
1455
|
+
}
|
|
1456
|
+
let versions;
|
|
1457
|
+
try {
|
|
1458
|
+
versions = (0, node_fs.readdirSync)(nameDir);
|
|
1459
|
+
} catch {
|
|
1460
|
+
continue;
|
|
1461
|
+
}
|
|
1462
|
+
for (const version of versions) {
|
|
1463
|
+
const formatPath = (0, node_path.join)(nameDir, version, "format.js");
|
|
1464
|
+
try {
|
|
1465
|
+
const stat = (0, node_fs.statSync)(formatPath);
|
|
1466
|
+
entries.push({
|
|
1467
|
+
name,
|
|
1468
|
+
version,
|
|
1469
|
+
sizeBytes: stat.size,
|
|
1470
|
+
modifiedAt: stat.mtime
|
|
1471
|
+
});
|
|
1472
|
+
} catch {}
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
return entries;
|
|
1476
|
+
}
|
|
1477
|
+
/**
|
|
1478
|
+
* Clear all cached formats, or only those matching a given name. Returns the number of entries removed.
|
|
1479
|
+
* A name must be a single cache entry name (as `listCachedFormats()` reports it); anything with a
|
|
1480
|
+
* path separator or `.`/`..` throws instead of deleting outside the format's own directory.
|
|
1481
|
+
*/
|
|
1482
|
+
function clearCachedFormats(name) {
|
|
1483
|
+
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.`);
|
|
1484
|
+
const cacheDir = getCacheDir();
|
|
1485
|
+
if (!(0, node_fs.existsSync)(cacheDir)) return 0;
|
|
1486
|
+
if (!name) {
|
|
1487
|
+
const count = listCachedFormats().length;
|
|
1488
|
+
(0, node_fs.rmSync)(cacheDir, {
|
|
1489
|
+
recursive: true,
|
|
1490
|
+
force: true
|
|
1491
|
+
});
|
|
1492
|
+
return count;
|
|
1493
|
+
}
|
|
1494
|
+
const root = (0, node_path.resolve)(cacheDir);
|
|
1495
|
+
const nameDir = (0, node_path.resolve)(root, name);
|
|
1496
|
+
if (!isInside(root, nameDir)) throw new Error(`Refusing to clear ${JSON.stringify(name)}: it is not a cached format name.`);
|
|
1497
|
+
if (!(0, node_fs.lstatSync)(nameDir, { throwIfNoEntry: false })?.isDirectory()) return 0;
|
|
1498
|
+
let versions;
|
|
1499
|
+
try {
|
|
1500
|
+
versions = (0, node_fs.readdirSync)(nameDir);
|
|
1501
|
+
} catch {
|
|
1502
|
+
return 0;
|
|
1503
|
+
}
|
|
1504
|
+
const count = versions.length;
|
|
1505
|
+
(0, node_fs.rmSync)(nameDir, {
|
|
1506
|
+
recursive: true,
|
|
1507
|
+
force: true
|
|
1508
|
+
});
|
|
1509
|
+
return count;
|
|
1510
|
+
}
|
|
1511
|
+
/** Get total cache size in bytes and format count. */
|
|
1512
|
+
function getCacheSize() {
|
|
1513
|
+
const entries = listCachedFormats();
|
|
1514
|
+
return {
|
|
1515
|
+
totalBytes: entries.reduce((sum, e) => sum + e.sizeBytes, 0),
|
|
1516
|
+
count: entries.length
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
//#endregion
|
|
1520
|
+
//#region src/format-resolution.ts
|
|
1521
|
+
/** Choose the format request: explicit format ID > StoryData format > default ID. */
|
|
1522
|
+
function formatRequestFor(formatId, storyFormat, storyFormatVersion, defaultId) {
|
|
1523
|
+
if (formatId) return {
|
|
1524
|
+
kind: "id",
|
|
1525
|
+
id: formatId
|
|
1526
|
+
};
|
|
1527
|
+
if (storyFormat) return {
|
|
1528
|
+
kind: "name",
|
|
1529
|
+
name: storyFormat,
|
|
1530
|
+
version: storyFormatVersion
|
|
1531
|
+
};
|
|
1532
|
+
return {
|
|
1533
|
+
kind: "id",
|
|
1534
|
+
id: defaultId
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
/** Look a request up among the formats found in local format directories. */
|
|
1538
|
+
function findLocalFormat(formats, request) {
|
|
1539
|
+
switch (request.kind) {
|
|
1540
|
+
case "id": return formats.get(request.id);
|
|
1541
|
+
case "name": {
|
|
1542
|
+
const id = getFormatIdByNameAndVersion(formats, request.name, request.version);
|
|
1543
|
+
return id === void 0 ? void 0 : formats.get(id);
|
|
1544
|
+
}
|
|
1545
|
+
default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* Resolve a format request to a story format.
|
|
1550
|
+
*
|
|
1551
|
+
* Order: local format directories, then the download cache, then remote sources (unless
|
|
1552
|
+
* `noRemote`). When a StoryData request names a version that none of them has, an older version of
|
|
1553
|
+
* the same format and major version is used with a warning. Anything else is an error diagnostic,
|
|
1554
|
+
* and the result is `undefined`.
|
|
1555
|
+
*/
|
|
1556
|
+
async function resolveStoryFormat(request, options, diagnostics) {
|
|
1557
|
+
const noRemote = options.noRemote ?? false;
|
|
1558
|
+
const formats = discoverFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true));
|
|
1559
|
+
const wanted = describeFormatRequest(request);
|
|
1560
|
+
const local = findLocalFormat(formats, request);
|
|
1561
|
+
if (local) return local;
|
|
1562
|
+
const cached = findCachedFormat(request);
|
|
1563
|
+
if (cached) return cached;
|
|
1564
|
+
if (!noRemote) try {
|
|
1565
|
+
const remote = await resolveRemoteFormatRequest(request, options.formatIndices, options.formatUrls);
|
|
1566
|
+
if (remote) return remote;
|
|
1567
|
+
} catch (e) {
|
|
1568
|
+
diagnostics.push({
|
|
1569
|
+
level: "warning",
|
|
1570
|
+
message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
if (request.kind === "name") {
|
|
1574
|
+
const older = selectFormatCandidate(request, [...formats.values()].filter((f) => f.isTwine2), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
|
|
1575
|
+
if (older) {
|
|
1576
|
+
diagnostics.push({
|
|
1577
|
+
level: "warning",
|
|
1578
|
+
message: `Story format ${wanted} is not available; using ${older.name} ${older.version} instead.`
|
|
1579
|
+
});
|
|
1580
|
+
return older;
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
const reason = noRemote ? " (remote fetching disabled)" : "";
|
|
1584
|
+
diagnostics.push({
|
|
1585
|
+
level: "error",
|
|
1586
|
+
message: `Story format ${wanted} is not available${reason}. Found: ${[...formats.keys()].join(", ") || "none"}`
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
//#endregion
|
|
1590
|
+
//#region src/types.ts
|
|
1591
|
+
const ItemType = {
|
|
1592
|
+
Error: 0,
|
|
1593
|
+
EOF: 1,
|
|
1594
|
+
Header: 2,
|
|
1595
|
+
Name: 3,
|
|
1596
|
+
Tags: 4,
|
|
1597
|
+
Metadata: 5,
|
|
1598
|
+
Content: 6
|
|
1599
|
+
};
|
|
1600
|
+
//#endregion
|
|
1601
|
+
//#region src/lexer.ts
|
|
1602
|
+
const EOF = -1;
|
|
1603
|
+
const HEADER_DELIM = "::";
|
|
1604
|
+
const NEWLINE_HEADER_DELIM = "\n::";
|
|
1605
|
+
var LexerContext = class {
|
|
1606
|
+
input;
|
|
1607
|
+
line = 1;
|
|
1608
|
+
start = 0;
|
|
1609
|
+
pos = 0;
|
|
1610
|
+
items = [];
|
|
1611
|
+
constructor(input) {
|
|
1612
|
+
this.input = input;
|
|
1613
|
+
}
|
|
1614
|
+
next() {
|
|
1615
|
+
if (this.pos >= this.input.length) return EOF;
|
|
1616
|
+
const ch = this.input.charCodeAt(this.pos);
|
|
1617
|
+
this.pos++;
|
|
1618
|
+
if (ch === 10) this.line++;
|
|
1619
|
+
return ch;
|
|
1620
|
+
}
|
|
1621
|
+
peek() {
|
|
1622
|
+
if (this.pos >= this.input.length) return EOF;
|
|
1623
|
+
return this.input.charCodeAt(this.pos);
|
|
1624
|
+
}
|
|
1625
|
+
backup() {
|
|
1626
|
+
if (this.pos > this.start) {
|
|
1627
|
+
this.pos--;
|
|
1628
|
+
if (this.input.charCodeAt(this.pos) === 10) this.line--;
|
|
1629
|
+
} else throw new Error("backup would leave pos < start");
|
|
1630
|
+
}
|
|
1631
|
+
emit(type) {
|
|
1632
|
+
const val = this.input.slice(this.start, this.pos);
|
|
1633
|
+
this.items.push({
|
|
1634
|
+
type,
|
|
1635
|
+
line: this.line,
|
|
1636
|
+
pos: this.start,
|
|
1637
|
+
val
|
|
1638
|
+
});
|
|
1639
|
+
if (type === ItemType.Content) {
|
|
1640
|
+
for (let i = this.start; i < this.pos; i++) if (this.input.charCodeAt(i) === 10) this.line++;
|
|
1641
|
+
}
|
|
1642
|
+
this.start = this.pos;
|
|
1643
|
+
}
|
|
1644
|
+
ignore() {
|
|
1645
|
+
for (let i = this.start; i < this.pos; i++) if (this.input.charCodeAt(i) === 10) this.line++;
|
|
1646
|
+
this.start = this.pos;
|
|
1647
|
+
}
|
|
1648
|
+
acceptRun(valid) {
|
|
1649
|
+
let ch;
|
|
1650
|
+
for (ch = this.next(); ch !== EOF && valid.includes(String.fromCharCode(ch)); ch = this.next());
|
|
1651
|
+
if (ch !== EOF) this.backup();
|
|
1652
|
+
}
|
|
1653
|
+
errorf(message) {
|
|
1654
|
+
this.items.push({
|
|
1655
|
+
type: ItemType.Error,
|
|
1656
|
+
line: this.line,
|
|
1657
|
+
pos: this.start,
|
|
1658
|
+
val: message
|
|
1659
|
+
});
|
|
1660
|
+
return null;
|
|
1661
|
+
}
|
|
1662
|
+
/** Drain all pending items and return them. */
|
|
1663
|
+
drainItems() {
|
|
1664
|
+
const result = this.items;
|
|
1665
|
+
this.items = [];
|
|
1666
|
+
return result;
|
|
1667
|
+
}
|
|
1668
|
+
};
|
|
1669
|
+
function acceptQuoted(ctx, quote) {
|
|
1670
|
+
for (;;) {
|
|
1671
|
+
const ch = ctx.next();
|
|
1672
|
+
switch (ch) {
|
|
1673
|
+
case 92: {
|
|
1674
|
+
const r = ctx.next();
|
|
1675
|
+
if (r !== 10 && r !== EOF) break;
|
|
1676
|
+
}
|
|
1677
|
+
case 10:
|
|
1678
|
+
case EOF: return "unterminated quoted string";
|
|
1679
|
+
default: if (ch === quote) return null;
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
function lexProlog(ctx) {
|
|
1684
|
+
if (ctx.input.startsWith(HEADER_DELIM, ctx.pos)) return lexHeaderDelim;
|
|
1685
|
+
const i = ctx.input.indexOf(NEWLINE_HEADER_DELIM, ctx.pos);
|
|
1686
|
+
if (i > -1) {
|
|
1687
|
+
ctx.pos = i + 1;
|
|
1688
|
+
ctx.ignore();
|
|
1689
|
+
return lexHeaderDelim;
|
|
1690
|
+
}
|
|
1691
|
+
ctx.emit(ItemType.EOF);
|
|
1692
|
+
return null;
|
|
1693
|
+
}
|
|
1694
|
+
function lexContent(ctx) {
|
|
1695
|
+
if (ctx.input.startsWith(HEADER_DELIM, ctx.pos)) return lexHeaderDelim;
|
|
1696
|
+
const i = ctx.input.indexOf(NEWLINE_HEADER_DELIM, ctx.pos);
|
|
1697
|
+
if (i > -1) {
|
|
1698
|
+
ctx.pos = i + 1;
|
|
1699
|
+
ctx.emit(ItemType.Content);
|
|
1700
|
+
return lexHeaderDelim;
|
|
1701
|
+
}
|
|
1702
|
+
ctx.pos = ctx.input.length;
|
|
1703
|
+
if (ctx.pos > ctx.start) ctx.emit(ItemType.Content);
|
|
1704
|
+
ctx.emit(ItemType.EOF);
|
|
1705
|
+
return null;
|
|
1706
|
+
}
|
|
1707
|
+
function lexHeaderDelim(ctx) {
|
|
1708
|
+
ctx.pos += 2;
|
|
1709
|
+
ctx.emit(ItemType.Header);
|
|
1710
|
+
return lexName;
|
|
1711
|
+
}
|
|
1712
|
+
function lexName(ctx) {
|
|
1713
|
+
let r;
|
|
1714
|
+
outer: for (;;) {
|
|
1715
|
+
r = ctx.next();
|
|
1716
|
+
switch (r) {
|
|
1717
|
+
case 92: {
|
|
1718
|
+
const next = ctx.next();
|
|
1719
|
+
if (next !== 10 && next !== EOF) break;
|
|
1720
|
+
r = next;
|
|
1721
|
+
}
|
|
1722
|
+
case 91:
|
|
1723
|
+
case 93:
|
|
1724
|
+
case 123:
|
|
1725
|
+
case 125:
|
|
1726
|
+
case 10:
|
|
1727
|
+
case EOF:
|
|
1728
|
+
if (r !== EOF) ctx.backup();
|
|
1729
|
+
break outer;
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
ctx.emit(ItemType.Name);
|
|
1733
|
+
switch (r) {
|
|
1734
|
+
case 91: return lexTags;
|
|
1735
|
+
case 93: return ctx.errorf(`unexpected right square bracket ']'`);
|
|
1736
|
+
case 123: return lexMetadata;
|
|
1737
|
+
case 125: return ctx.errorf(`unexpected right curly brace '}'`);
|
|
1738
|
+
case 10:
|
|
1739
|
+
ctx.pos++;
|
|
1740
|
+
ctx.ignore();
|
|
1741
|
+
return lexContent;
|
|
1742
|
+
}
|
|
1743
|
+
ctx.emit(ItemType.EOF);
|
|
1744
|
+
return null;
|
|
1745
|
+
}
|
|
1746
|
+
function lexNextOptionalBlock(ctx) {
|
|
1131
1747
|
ctx.acceptRun(" ");
|
|
1132
1748
|
ctx.ignore();
|
|
1133
1749
|
const r = ctx.peek();
|
|
@@ -1260,16 +1876,20 @@ function twee2ToV3(s) {
|
|
|
1260
1876
|
//#region src/parser.ts
|
|
1261
1877
|
/**
|
|
1262
1878
|
* Parse Twee source text into passages.
|
|
1879
|
+
*
|
|
1880
|
+
* The source is normalized first, as files are when they are read: a leading UTF-8 BOM is
|
|
1881
|
+
* removed and CRLF and bare CR line endings become LF.
|
|
1263
1882
|
*/
|
|
1264
1883
|
function parseTwee(source, options = {}) {
|
|
1265
1884
|
const { filename = "<inline>", trim = true, twee2Compat = false } = options;
|
|
1266
1885
|
const diagnostics = [];
|
|
1267
|
-
|
|
1886
|
+
const normalized = normalizeSourceText(source);
|
|
1887
|
+
const tweeSource = twee2Compat ? twee2ToV3(normalized) : normalized;
|
|
1268
1888
|
const passages = [];
|
|
1269
1889
|
let current = null;
|
|
1270
1890
|
let pCount = 0;
|
|
1271
1891
|
let lastType = ItemType.EOF;
|
|
1272
|
-
for (const item of tweeLexer(
|
|
1892
|
+
for (const item of tweeLexer(tweeSource)) {
|
|
1273
1893
|
switch (item.type) {
|
|
1274
1894
|
case ItemType.Error:
|
|
1275
1895
|
diagnostics.push({
|
|
@@ -4150,6 +4770,7 @@ function decompileTwine2(storyData, story, diagnostics) {
|
|
|
4150
4770
|
});
|
|
4151
4771
|
else story.twine2.zoom = parsed;
|
|
4152
4772
|
}
|
|
4773
|
+
if (attrs["tags"]) story.twine2.tags = attrs["tags"];
|
|
4153
4774
|
if (attrs["format"]) story.twine2.format = attrs["format"];
|
|
4154
4775
|
if (attrs["format-version"]) story.twine2.formatVersion = attrs["format-version"];
|
|
4155
4776
|
if (attrs["options"]) for (const opt of attrs["options"].split(/\s+/).filter((s) => s.length > 0)) story.twine2.options.set(opt, true);
|
|
@@ -4209,22 +4830,32 @@ function decompileTwine2(storyData, story, diagnostics) {
|
|
|
4209
4830
|
}, diagnostics);
|
|
4210
4831
|
}
|
|
4211
4832
|
function decompileTwine1(storeArea, story, diagnostics) {
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4833
|
+
const passages = (storeArea.children ?? []).filter(isTiddler).map(tiddlerToPassage);
|
|
4834
|
+
const obfuscated = isRot13Obfuscated(passages);
|
|
4835
|
+
for (const p of passages) storyAdd(story, obfuscated && p.name !== "StorySettings" ? {
|
|
4836
|
+
...p,
|
|
4837
|
+
text: rot13(p.text)
|
|
4838
|
+
} : p, diagnostics);
|
|
4839
|
+
}
|
|
4840
|
+
function isTiddler(node) {
|
|
4841
|
+
return node.type === "tag" && node.name === "div" && node.attribs !== void 0 && "tiddler" in node.attribs;
|
|
4842
|
+
}
|
|
4843
|
+
function tiddlerToPassage(node) {
|
|
4844
|
+
const nodeAttrs = node.attribs ?? {};
|
|
4845
|
+
const passage = {
|
|
4846
|
+
name: nodeAttrs["tiddler"] ?? "",
|
|
4847
|
+
tags: nodeAttrs["tags"] ? nodeAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [],
|
|
4848
|
+
text: tiddlerUnescape(getTextContent(node)).trim()
|
|
4849
|
+
};
|
|
4850
|
+
const position = nodeAttrs["twine-position"];
|
|
4851
|
+
if (position) passage.metadata = { position };
|
|
4852
|
+
return passage;
|
|
4853
|
+
}
|
|
4854
|
+
/** Whether the StorySettings tiddlers turn on ROT13 obfuscation, read the way the writer reads them. */
|
|
4855
|
+
function isRot13Obfuscated(passages) {
|
|
4856
|
+
const settings = createStory();
|
|
4857
|
+
for (const p of passages) if (p.name === "StorySettings") unmarshalStorySettings(settings, p.text, []);
|
|
4858
|
+
return settings.twine1.settings.get("obfuscate") === "rot13";
|
|
4228
4859
|
}
|
|
4229
4860
|
function findElement(node, tagName) {
|
|
4230
4861
|
if (node.type === "tag" && node.name === tagName) return node;
|
|
@@ -4312,242 +4943,19 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
|
|
|
4312
4943
|
case "opus":
|
|
4313
4944
|
case "wav":
|
|
4314
4945
|
case "wave":
|
|
4315
|
-
case "weba":
|
|
4316
|
-
loadMedia(story, "Twine.audio", filename, diagnostics);
|
|
4317
|
-
break;
|
|
4318
|
-
case "mp4":
|
|
4319
|
-
case "ogv":
|
|
4320
|
-
case "webm":
|
|
4321
|
-
loadMedia(story, "Twine.video", filename, diagnostics);
|
|
4322
|
-
break;
|
|
4323
|
-
case "vtt":
|
|
4324
|
-
loadMedia(story, "Twine.vtt", filename, diagnostics);
|
|
4325
|
-
break;
|
|
4326
|
-
default: continue;
|
|
4327
|
-
}
|
|
4328
|
-
} catch (e) {
|
|
4329
|
-
diagnostics.push({
|
|
4330
|
-
level: "error",
|
|
4331
|
-
message: `load ${filename}: ${e instanceof Error ? e.message : String(e)}`,
|
|
4332
|
-
file: filename
|
|
4333
|
-
});
|
|
4334
|
-
continue;
|
|
4335
|
-
}
|
|
4336
|
-
processedFiles.add(filename);
|
|
4337
|
-
}
|
|
4338
|
-
if (story.name !== "" && !story.passages.some((p) => p.name === "StoryTitle")) storyPrepend(story, {
|
|
4339
|
-
name: "StoryTitle",
|
|
4340
|
-
tags: [],
|
|
4341
|
-
text: story.name
|
|
4342
|
-
}, diagnostics);
|
|
4343
|
-
}
|
|
4344
|
-
/**
|
|
4345
|
-
* Load inline sources (string content or InlineSource objects).
|
|
4346
|
-
*/
|
|
4347
|
-
function loadInlineSources(story, sources, opts, diagnostics) {
|
|
4348
|
-
for (const source of sources) {
|
|
4349
|
-
if (typeof source === "string") continue;
|
|
4350
|
-
const content = typeof source.content === "string" ? source.content : source.content.toString("utf-8");
|
|
4351
|
-
const ext = normalizedFileExt(source.filename);
|
|
4352
|
-
switch (ext) {
|
|
4353
|
-
case "":
|
|
4354
|
-
case "tw":
|
|
4355
|
-
case "twee":
|
|
4356
|
-
case "tw2":
|
|
4357
|
-
case "twee2": {
|
|
4358
|
-
const twee2 = ext === "tw2" || ext === "twee2" || opts.twee2Compat;
|
|
4359
|
-
const result = parseTwee(content, {
|
|
4360
|
-
filename: source.filename,
|
|
4361
|
-
trim: opts.trim ?? true,
|
|
4362
|
-
twee2Compat: twee2
|
|
4363
|
-
});
|
|
4364
|
-
diagnostics.push(...result.diagnostics);
|
|
4365
|
-
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
4366
|
-
break;
|
|
4367
|
-
}
|
|
4368
|
-
case "css":
|
|
4369
|
-
storyAdd(story, {
|
|
4370
|
-
name: (0, node_path.basename)(source.filename),
|
|
4371
|
-
tags: ["stylesheet"],
|
|
4372
|
-
text: content
|
|
4373
|
-
}, diagnostics);
|
|
4374
|
-
break;
|
|
4375
|
-
case "js":
|
|
4376
|
-
storyAdd(story, {
|
|
4377
|
-
name: (0, node_path.basename)(source.filename),
|
|
4378
|
-
tags: ["script"],
|
|
4379
|
-
text: content
|
|
4380
|
-
}, diagnostics);
|
|
4381
|
-
break;
|
|
4382
|
-
default: diagnostics.push({
|
|
4383
|
-
level: "warning",
|
|
4384
|
-
message: `load ${source.filename}: in-memory sources of type .${ext} are not supported; skipped.`
|
|
4385
|
-
});
|
|
4386
|
-
}
|
|
4387
|
-
}
|
|
4388
|
-
}
|
|
4389
|
-
function parseTweeFile(filename, opts) {
|
|
4390
|
-
const result = parseTwee(readUTF8(filename), {
|
|
4391
|
-
filename,
|
|
4392
|
-
trim: opts.trim ?? true,
|
|
4393
|
-
twee2Compat: opts.twee2Compat ?? false
|
|
4394
|
-
});
|
|
4395
|
-
return {
|
|
4396
|
-
passages: result.passages,
|
|
4397
|
-
diagnostics: [...result.diagnostics]
|
|
4398
|
-
};
|
|
4399
|
-
}
|
|
4400
|
-
function parseTaggedFile(tag, filename) {
|
|
4401
|
-
const source = readUTF8(filename);
|
|
4402
|
-
return {
|
|
4403
|
-
passages: [{
|
|
4404
|
-
name: (0, node_path.basename)(filename),
|
|
4405
|
-
tags: [tag],
|
|
4406
|
-
text: source
|
|
4407
|
-
}],
|
|
4408
|
-
diagnostics: []
|
|
4409
|
-
};
|
|
4410
|
-
}
|
|
4411
|
-
function parseMediaFile(tag, filename) {
|
|
4412
|
-
const source = readBase64(filename);
|
|
4413
|
-
return {
|
|
4414
|
-
passages: [{
|
|
4415
|
-
name: baseNameWithoutExt(filename),
|
|
4416
|
-
tags: [tag],
|
|
4417
|
-
text: `data:${mediaTypeFromFilename(filename)};base64,${source}`
|
|
4418
|
-
}],
|
|
4419
|
-
diagnostics: []
|
|
4420
|
-
};
|
|
4421
|
-
}
|
|
4422
|
-
function parseFontFile(filename) {
|
|
4423
|
-
const source = readBase64(filename);
|
|
4424
|
-
const name = (0, node_path.basename)(filename);
|
|
4425
|
-
const family = baseNameWithoutExt(filename);
|
|
4426
|
-
const ext = normalizedFileExt(filename);
|
|
4427
|
-
return {
|
|
4428
|
-
passages: [{
|
|
4429
|
-
name,
|
|
4430
|
-
tags: ["stylesheet"],
|
|
4431
|
-
text: `@font-face {\n\tfont-family: "${family}";\n\tsrc: url("data:${mediaTypeFromExt(ext)};base64,${source}") format("${fontFormatHint(ext)}");\n}`
|
|
4432
|
-
}],
|
|
4433
|
-
diagnostics: []
|
|
4434
|
-
};
|
|
4435
|
-
}
|
|
4436
|
-
function parseHTMLFile(filename) {
|
|
4437
|
-
const result = decompileHTML(readUTF8(filename));
|
|
4438
|
-
return {
|
|
4439
|
-
passages: result.story.passages,
|
|
4440
|
-
diagnostics: [...result.diagnostics]
|
|
4441
|
-
};
|
|
4442
|
-
}
|
|
4443
|
-
function parseFile(filename, opts) {
|
|
4444
|
-
switch (normalizedFileExt(filename)) {
|
|
4445
|
-
case "tw":
|
|
4446
|
-
case "twee": return parseTweeFile(filename, opts);
|
|
4447
|
-
case "tw2":
|
|
4448
|
-
case "twee2": return parseTweeFile(filename, {
|
|
4449
|
-
...opts,
|
|
4450
|
-
twee2Compat: true
|
|
4451
|
-
});
|
|
4452
|
-
case "htm":
|
|
4453
|
-
case "html": return parseHTMLFile(filename);
|
|
4454
|
-
case "css": return parseTaggedFile("stylesheet", filename);
|
|
4455
|
-
case "js": return parseTaggedFile("script", filename);
|
|
4456
|
-
case "otf":
|
|
4457
|
-
case "ttf":
|
|
4458
|
-
case "woff":
|
|
4459
|
-
case "woff2": return parseFontFile(filename);
|
|
4460
|
-
case "gif":
|
|
4461
|
-
case "jpeg":
|
|
4462
|
-
case "jpg":
|
|
4463
|
-
case "png":
|
|
4464
|
-
case "svg":
|
|
4465
|
-
case "tif":
|
|
4466
|
-
case "tiff":
|
|
4467
|
-
case "webp": return parseMediaFile("Twine.image", filename);
|
|
4468
|
-
case "aac":
|
|
4469
|
-
case "flac":
|
|
4470
|
-
case "m4a":
|
|
4471
|
-
case "mp3":
|
|
4472
|
-
case "oga":
|
|
4473
|
-
case "ogg":
|
|
4474
|
-
case "opus":
|
|
4475
|
-
case "wav":
|
|
4476
|
-
case "wave":
|
|
4477
|
-
case "weba": return parseMediaFile("Twine.audio", filename);
|
|
4478
|
-
case "mp4":
|
|
4479
|
-
case "ogv":
|
|
4480
|
-
case "webm": return parseMediaFile("Twine.video", filename);
|
|
4481
|
-
case "vtt": return parseMediaFile("Twine.vtt", filename);
|
|
4482
|
-
default: return;
|
|
4483
|
-
}
|
|
4484
|
-
}
|
|
4485
|
-
function loadHTML(story, filename, diagnostics) {
|
|
4486
|
-
const result = parseHTMLFile(filename);
|
|
4487
|
-
diagnostics.push(...result.diagnostics);
|
|
4488
|
-
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
4489
|
-
}
|
|
4490
|
-
function loadTwee(story, filename, opts, diagnostics) {
|
|
4491
|
-
const result = parseTweeFile(filename, opts);
|
|
4492
|
-
diagnostics.push(...result.diagnostics);
|
|
4493
|
-
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
4494
|
-
}
|
|
4495
|
-
function loadTagged(story, tag, filename, diagnostics) {
|
|
4496
|
-
const result = parseTaggedFile(tag, filename);
|
|
4497
|
-
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
4498
|
-
}
|
|
4499
|
-
function loadMedia(story, tag, filename, diagnostics) {
|
|
4500
|
-
const result = parseMediaFile(tag, filename);
|
|
4501
|
-
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
4502
|
-
}
|
|
4503
|
-
function loadFont(story, filename, diagnostics) {
|
|
4504
|
-
const result = parseFontFile(filename);
|
|
4505
|
-
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
4506
|
-
}
|
|
4507
|
-
/**
|
|
4508
|
-
* Load sources with mtime-based caching for incremental rebuilds.
|
|
4509
|
-
* Always creates a fresh Story; cached passages are replayed via storyAdd().
|
|
4510
|
-
*/
|
|
4511
|
-
function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
|
|
4512
|
-
const currentFiles = /* @__PURE__ */ new Set();
|
|
4513
|
-
for (const filename of filenames) {
|
|
4514
|
-
if (processedFiles.has(filename)) {
|
|
4515
|
-
diagnostics.push({
|
|
4516
|
-
level: "warning",
|
|
4517
|
-
message: `load ${filename}: Skipping duplicate.`
|
|
4518
|
-
});
|
|
4519
|
-
continue;
|
|
4520
|
-
}
|
|
4521
|
-
currentFiles.add(filename);
|
|
4522
|
-
const cached = cache.get(filename);
|
|
4523
|
-
if (changedFiles && cached && !changedFiles.has(filename)) {
|
|
4524
|
-
diagnostics.push(...cached.diagnostics);
|
|
4525
|
-
for (const p of cached.passages) storyAdd(story, p, diagnostics);
|
|
4526
|
-
processedFiles.add(filename);
|
|
4527
|
-
continue;
|
|
4528
|
-
}
|
|
4529
|
-
let mtimeMs;
|
|
4530
|
-
try {
|
|
4531
|
-
mtimeMs = (0, node_fs.statSync)(filename).mtimeMs;
|
|
4532
|
-
} catch {
|
|
4533
|
-
continue;
|
|
4534
|
-
}
|
|
4535
|
-
if (cached && cached.mtimeMs === mtimeMs) {
|
|
4536
|
-
diagnostics.push(...cached.diagnostics);
|
|
4537
|
-
for (const p of cached.passages) storyAdd(story, p, diagnostics);
|
|
4538
|
-
processedFiles.add(filename);
|
|
4539
|
-
continue;
|
|
4540
|
-
}
|
|
4541
|
-
try {
|
|
4542
|
-
const result = parseFile(filename, opts);
|
|
4543
|
-
if (!result) continue;
|
|
4544
|
-
cache.set(filename, {
|
|
4545
|
-
mtimeMs,
|
|
4546
|
-
passages: result.passages,
|
|
4547
|
-
diagnostics: result.diagnostics
|
|
4548
|
-
});
|
|
4549
|
-
diagnostics.push(...result.diagnostics);
|
|
4550
|
-
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
4946
|
+
case "weba":
|
|
4947
|
+
loadMedia(story, "Twine.audio", filename, diagnostics);
|
|
4948
|
+
break;
|
|
4949
|
+
case "mp4":
|
|
4950
|
+
case "ogv":
|
|
4951
|
+
case "webm":
|
|
4952
|
+
loadMedia(story, "Twine.video", filename, diagnostics);
|
|
4953
|
+
break;
|
|
4954
|
+
case "vtt":
|
|
4955
|
+
loadMedia(story, "Twine.vtt", filename, diagnostics);
|
|
4956
|
+
break;
|
|
4957
|
+
default: continue;
|
|
4958
|
+
}
|
|
4551
4959
|
} catch (e) {
|
|
4552
4960
|
diagnostics.push({
|
|
4553
4961
|
level: "error",
|
|
@@ -4558,572 +4966,577 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
4558
4966
|
}
|
|
4559
4967
|
processedFiles.add(filename);
|
|
4560
4968
|
}
|
|
4561
|
-
for (const key of cache.keys()) if (!currentFiles.has(key)) cache.delete(key);
|
|
4562
4969
|
if (story.name !== "" && !story.passages.some((p) => p.name === "StoryTitle")) storyPrepend(story, {
|
|
4563
4970
|
name: "StoryTitle",
|
|
4564
4971
|
tags: [],
|
|
4565
4972
|
text: story.name
|
|
4566
4973
|
}, diagnostics);
|
|
4567
4974
|
}
|
|
4568
|
-
//#endregion
|
|
4569
|
-
//#region src/version.ts
|
|
4570
|
-
/**
|
|
4571
|
-
* The twee-ts version, read at runtime from the package's own package.json.
|
|
4572
|
-
*
|
|
4573
|
-
* The release workflow (semantic-release) writes the new version into the
|
|
4574
|
-
* published package.json only; git keeps the old one. Reading it at runtime
|
|
4575
|
-
* is the only way the CLI and `creator-version` report the published version.
|
|
4576
|
-
*/
|
|
4577
|
-
const PACKAGE_NAME = "@rohal12/twee-ts";
|
|
4578
|
-
/** Walks up from `startDir` to the nearest package.json named @rohal12/twee-ts and returns its version. */
|
|
4579
|
-
function findPackageVersion(startDir) {
|
|
4580
|
-
let dir = startDir;
|
|
4581
|
-
for (;;) {
|
|
4582
|
-
try {
|
|
4583
|
-
const pkg = JSON.parse((0, node_fs.readFileSync)((0, node_path.join)(dir, "package.json"), "utf-8"));
|
|
4584
|
-
if (pkg.name === PACKAGE_NAME && typeof pkg.version === "string") return pkg.version;
|
|
4585
|
-
} catch {}
|
|
4586
|
-
const parent = (0, node_path.dirname)(dir);
|
|
4587
|
-
if (parent === dir) return void 0;
|
|
4588
|
-
dir = parent;
|
|
4589
|
-
}
|
|
4590
|
-
}
|
|
4591
|
-
/** This module's directory: `__dirname` in the CJS build, `import.meta.url` in ESM and in source. */
|
|
4592
|
-
function moduleDir() {
|
|
4593
|
-
return typeof __dirname === "string" ? __dirname : (0, node_path.dirname)((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
|
|
4594
|
-
}
|
|
4595
|
-
const VERSION = findPackageVersion(moduleDir()) ?? "0.0.0";
|
|
4596
|
-
//#endregion
|
|
4597
|
-
//#region src/output-twine2.ts
|
|
4598
|
-
const CREATOR_NAME$2 = "Twee-ts";
|
|
4599
|
-
function toTwine2Archive(story, startName, options) {
|
|
4600
|
-
return getTwine2DataChunk(story, startName, options) + "\n";
|
|
4601
|
-
}
|
|
4602
|
-
function toTwine2HTML(story, format, startName, options) {
|
|
4603
|
-
let template = readFormatSource(format);
|
|
4604
|
-
if (template.includes("{{STORY_NAME}}")) {
|
|
4605
|
-
const name = htmlEscape(story.name);
|
|
4606
|
-
template = template.replaceAll("{{STORY_NAME}}", () => name);
|
|
4607
|
-
}
|
|
4608
|
-
if (template.includes("{{STORY_DATA}}")) {
|
|
4609
|
-
const data = getTwine2DataChunk(story, startName, options);
|
|
4610
|
-
template = template.replace("{{STORY_DATA}}", () => data);
|
|
4611
|
-
}
|
|
4612
|
-
return template;
|
|
4613
|
-
}
|
|
4614
|
-
function getTwine2DataChunk(story, startName, options) {
|
|
4615
|
-
const parts = [];
|
|
4616
|
-
let startID = "";
|
|
4617
|
-
let pid = 0;
|
|
4618
|
-
const scripts = [];
|
|
4619
|
-
const stylesheets = [];
|
|
4620
|
-
for (const p of story.passages) {
|
|
4621
|
-
if (hasTag(p, "Twine.private")) continue;
|
|
4622
|
-
if (hasTag(p, "script")) scripts.push(p);
|
|
4623
|
-
else if (hasTag(p, "stylesheet")) stylesheets.push(p);
|
|
4624
|
-
}
|
|
4625
|
-
let styleContent = "";
|
|
4626
|
-
if (stylesheets.length === 1) styleContent = stylesheets[0]?.text ?? "";
|
|
4627
|
-
else if (stylesheets.length > 1) {
|
|
4628
|
-
let pidS = 1;
|
|
4629
|
-
for (const p of stylesheets) {
|
|
4630
|
-
if (pidS > 1 && !styleContent.endsWith("\n")) styleContent += "\n";
|
|
4631
|
-
styleContent += `/* twine-user-stylesheet #${pidS}: "${commentSanitize(p.name)}" */\n`;
|
|
4632
|
-
styleContent += p.text;
|
|
4633
|
-
pidS++;
|
|
4634
|
-
}
|
|
4635
|
-
}
|
|
4636
|
-
parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContent}</style>`);
|
|
4637
|
-
let scriptContent = "";
|
|
4638
|
-
if (scripts.length === 1) scriptContent = scripts[0]?.text ?? "";
|
|
4639
|
-
else if (scripts.length > 1) {
|
|
4640
|
-
let pidS = 1;
|
|
4641
|
-
for (const p of scripts) {
|
|
4642
|
-
if (pidS > 1 && !scriptContent.endsWith("\n")) scriptContent += "\n";
|
|
4643
|
-
scriptContent += `/* twine-user-script #${pidS}: "${commentSanitize(p.name)}" */\n`;
|
|
4644
|
-
scriptContent += p.text;
|
|
4645
|
-
pidS++;
|
|
4646
|
-
}
|
|
4647
|
-
}
|
|
4648
|
-
parts.push(`<script role="script" id="twine-user-script" type="text/twine-javascript">${scriptContent}<\/script>`);
|
|
4649
|
-
const validTagColors = /* @__PURE__ */ new Set([
|
|
4650
|
-
"gray",
|
|
4651
|
-
"red",
|
|
4652
|
-
"orange",
|
|
4653
|
-
"yellow",
|
|
4654
|
-
"green",
|
|
4655
|
-
"blue",
|
|
4656
|
-
"purple"
|
|
4657
|
-
]);
|
|
4658
|
-
const hexColorPattern = /^#[0-9a-fA-F]{3,8}$/;
|
|
4659
|
-
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>`);
|
|
4660
|
-
pid = 1;
|
|
4661
|
-
for (const p of story.passages) {
|
|
4662
|
-
if (p.name === "StoryTitle" || p.name === "StoryData" || hasAnyTag(p, "script", "stylesheet", "Twine.private")) continue;
|
|
4663
|
-
if (p.name === "StorySettings" && story.twine1.settings.size === 0) continue;
|
|
4664
|
-
parts.push(passageToPassagedata(p, pid, options));
|
|
4665
|
-
if (startName === p.name) startID = String(pid);
|
|
4666
|
-
pid++;
|
|
4667
|
-
}
|
|
4668
|
-
const opts = [];
|
|
4669
|
-
for (const [opt, val] of story.twine2.options) if (val) opts.push(opt);
|
|
4670
|
-
const optionsStr = opts.join(" ");
|
|
4671
|
-
const zoom = String(story.twine2.zoom);
|
|
4672
|
-
return `<!-- UUID://${htmlCommentSanitize(story.ifid)}// --><tw-storydata name="${attrEscape(story.name)}" startnode="${attrEscape(startID)}" creator="${attrEscape(CREATOR_NAME$2)}" creator-version="${attrEscape(VERSION)}" ifid="${attrEscape(story.ifid)}" zoom="${attrEscape(zoom)}" format="${attrEscape(story.twine2.format)}" format-version="${attrEscape(story.twine2.formatVersion)}" options="${attrEscape(optionsStr)}" tags="${attrEscape(story.twine2.tags)}" hidden>` + parts.join("") + "</tw-storydata>";
|
|
4673
|
-
}
|
|
4674
|
-
//#endregion
|
|
4675
|
-
//#region src/output-twine1.ts
|
|
4676
|
-
/**
|
|
4677
|
-
* Twine 1 HTML and archive output.
|
|
4678
|
-
* Ported from storyout.go.
|
|
4679
|
-
*/
|
|
4680
|
-
const CREATOR_NAME$1 = "twee-ts";
|
|
4681
|
-
function toTwine1Archive(story, _startName) {
|
|
4682
|
-
const { data, count } = getTwine1PassageChunk(story);
|
|
4683
|
-
return `<div id="storeArea" data-size="${count}">${data}</div>\n`;
|
|
4684
|
-
}
|
|
4685
|
-
function toTwine1HTML(story, format, startName) {
|
|
4686
|
-
const formatDir = (0, node_path.dirname)(format.filename);
|
|
4687
|
-
const parentDir = (0, node_path.dirname)(formatDir);
|
|
4688
|
-
let template = readFormatSource(format);
|
|
4689
|
-
const { data, count } = getTwine1PassageChunk(story);
|
|
4690
|
-
template = tryReplaceComponent(template, "\"USER_LIB\"", (0, node_path.join)(formatDir, "userlib.js"), false);
|
|
4691
|
-
template = tryReplaceComponent(template, "\"ENGINE\"", (0, node_path.join)(parentDir, "engine.js"), true);
|
|
4692
|
-
template = tryReplaceComponent(template, "\"SUGARCANE\"", (0, node_path.join)(formatDir, "code.js"), true);
|
|
4693
|
-
template = tryReplaceComponent(template, "\"JONAH\"", (0, node_path.join)(formatDir, "code.js"), true);
|
|
4694
|
-
if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", (0, node_path.join)(parentDir, "jquery.js"), true);
|
|
4695
|
-
if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", (0, node_path.join)(parentDir, "modernizr.js"), true);
|
|
4696
|
-
const displayStart = startName === "Start" ? "" : startName;
|
|
4697
|
-
template = template.replace("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`);
|
|
4698
|
-
template = template.replace("\"TIME\"", `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`);
|
|
4699
|
-
const startAtValue = `"${jsStringEscape(displayStart)}"`;
|
|
4700
|
-
template = template.replace("\"START_AT\"", () => startAtValue);
|
|
4701
|
-
template = template.replace("\"STORY_SIZE\"", `"${count}"`);
|
|
4702
|
-
if (template.includes("\"STORY\"")) template = template.replace("\"STORY\"", () => data);
|
|
4703
|
-
else {
|
|
4704
|
-
let footer;
|
|
4705
|
-
try {
|
|
4706
|
-
footer = (0, node_fs.readFileSync)((0, node_path.join)(formatDir, "footer.html"), "utf-8");
|
|
4707
|
-
} catch {
|
|
4708
|
-
footer = "</div>\n</body>\n</html>\n";
|
|
4709
|
-
}
|
|
4710
|
-
template += data + footer;
|
|
4711
|
-
}
|
|
4712
|
-
if (story.ifid) {
|
|
4713
|
-
const safeIfid = htmlCommentSanitize(story.ifid);
|
|
4714
|
-
if (template.includes("<div id=\"store-area\"")) template = template.replace("<div id=\"store-area\"", `<!-- UUID://${safeIfid}// --><div id="store-area"`);
|
|
4715
|
-
else template = template.replace("<div id=\"storeArea\"", `<!-- UUID://${safeIfid}// --><div id="storeArea"`);
|
|
4716
|
-
}
|
|
4717
|
-
return template;
|
|
4718
|
-
}
|
|
4719
|
-
function getTwine1PassageChunk(story) {
|
|
4720
|
-
const obfuscateRot13 = story.twine1.settings.get("obfuscate") === "rot13";
|
|
4721
|
-
let data = "";
|
|
4722
|
-
let count = 0;
|
|
4723
|
-
for (const p of story.passages) {
|
|
4724
|
-
if (hasTag(p, "Twine.private")) continue;
|
|
4725
|
-
count++;
|
|
4726
|
-
data += passageToTiddler(p, count, obfuscateRot13);
|
|
4727
|
-
}
|
|
4728
|
-
return {
|
|
4729
|
-
data,
|
|
4730
|
-
count
|
|
4731
|
-
};
|
|
4732
|
-
}
|
|
4733
|
-
function tryReplaceComponent(template, placeholder, componentPath, required) {
|
|
4734
|
-
if (!template.includes(placeholder)) return template;
|
|
4735
|
-
try {
|
|
4736
|
-
let content = (0, node_fs.readFileSync)(componentPath, "utf-8");
|
|
4737
|
-
if (content.charCodeAt(0) === 65279) content = content.slice(1);
|
|
4738
|
-
return template.replace(placeholder, () => content);
|
|
4739
|
-
} catch (e) {
|
|
4740
|
-
if (required) throw new Error(`Required format component not found: ${componentPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
4741
|
-
return template;
|
|
4742
|
-
}
|
|
4743
|
-
}
|
|
4744
|
-
//#endregion
|
|
4745
|
-
//#region src/output-twee.ts
|
|
4746
|
-
function toTwee(story, outMode) {
|
|
4747
|
-
let data = "";
|
|
4748
|
-
for (const p of story.passages) data += passageToTwee(p, outMode);
|
|
4749
|
-
return data;
|
|
4750
|
-
}
|
|
4751
|
-
//#endregion
|
|
4752
|
-
//#region src/modules.ts
|
|
4753
4975
|
/**
|
|
4754
|
-
* Load
|
|
4976
|
+
* Load inline sources (string content or InlineSource objects).
|
|
4755
4977
|
*/
|
|
4756
|
-
function
|
|
4757
|
-
const
|
|
4758
|
-
|
|
4759
|
-
|
|
4760
|
-
|
|
4761
|
-
const ext = normalizedFileExt(filename);
|
|
4762
|
-
let tag = null;
|
|
4978
|
+
function loadInlineSources(story, sources, opts, diagnostics) {
|
|
4979
|
+
for (const source of sources) {
|
|
4980
|
+
if (typeof source === "string") continue;
|
|
4981
|
+
const content = normalizeSourceText(typeof source.content === "string" ? source.content : source.content.toString("utf-8"));
|
|
4982
|
+
const ext = normalizedFileExt(source.filename);
|
|
4763
4983
|
switch (ext) {
|
|
4984
|
+
case "":
|
|
4985
|
+
case "tw":
|
|
4986
|
+
case "twee":
|
|
4987
|
+
case "tw2":
|
|
4988
|
+
case "twee2": {
|
|
4989
|
+
const twee2 = ext === "tw2" || ext === "twee2" || opts.twee2Compat;
|
|
4990
|
+
const result = parseTwee(content, {
|
|
4991
|
+
filename: source.filename,
|
|
4992
|
+
trim: opts.trim ?? true,
|
|
4993
|
+
twee2Compat: twee2
|
|
4994
|
+
});
|
|
4995
|
+
diagnostics.push(...result.diagnostics);
|
|
4996
|
+
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
4997
|
+
break;
|
|
4998
|
+
}
|
|
4764
4999
|
case "css":
|
|
4765
|
-
|
|
5000
|
+
storyAdd(story, {
|
|
5001
|
+
name: (0, node_path.basename)(source.filename),
|
|
5002
|
+
tags: ["stylesheet"],
|
|
5003
|
+
text: content
|
|
5004
|
+
}, diagnostics);
|
|
4766
5005
|
break;
|
|
4767
5006
|
case "js":
|
|
4768
|
-
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
|
|
4773
|
-
case "woff2":
|
|
4774
|
-
tag = loadModuleFont(filename);
|
|
5007
|
+
storyAdd(story, {
|
|
5008
|
+
name: (0, node_path.basename)(source.filename),
|
|
5009
|
+
tags: ["script"],
|
|
5010
|
+
text: content
|
|
5011
|
+
}, diagnostics);
|
|
4775
5012
|
break;
|
|
4776
|
-
default:
|
|
5013
|
+
default: diagnostics.push({
|
|
5014
|
+
level: "warning",
|
|
5015
|
+
message: `load ${source.filename}: in-memory sources of type .${ext} are not supported; skipped.`
|
|
5016
|
+
});
|
|
4777
5017
|
}
|
|
4778
|
-
if (tag) headTags.push(tag);
|
|
4779
|
-
processed.add(filename);
|
|
4780
5018
|
}
|
|
4781
|
-
return headTags.join("\n");
|
|
4782
5019
|
}
|
|
4783
|
-
function
|
|
4784
|
-
const
|
|
4785
|
-
|
|
4786
|
-
|
|
5020
|
+
function parseTweeFile(filename, opts) {
|
|
5021
|
+
const result = parseTwee(readUTF8(filename), {
|
|
5022
|
+
filename,
|
|
5023
|
+
trim: opts.trim ?? true,
|
|
5024
|
+
twee2Compat: opts.twee2Compat ?? false
|
|
5025
|
+
});
|
|
5026
|
+
return {
|
|
5027
|
+
passages: result.passages,
|
|
5028
|
+
diagnostics: [...result.diagnostics]
|
|
5029
|
+
};
|
|
5030
|
+
}
|
|
5031
|
+
function parseTaggedFile(tag, filename) {
|
|
5032
|
+
const source = readUTF8(filename);
|
|
5033
|
+
return {
|
|
5034
|
+
passages: [{
|
|
5035
|
+
name: (0, node_path.basename)(filename),
|
|
5036
|
+
tags: [tag],
|
|
5037
|
+
text: source
|
|
5038
|
+
}],
|
|
5039
|
+
diagnostics: []
|
|
5040
|
+
};
|
|
4787
5041
|
}
|
|
4788
|
-
function
|
|
5042
|
+
function parseMediaFile(tag, filename) {
|
|
5043
|
+
const source = readBase64(filename);
|
|
5044
|
+
return {
|
|
5045
|
+
passages: [{
|
|
5046
|
+
name: baseNameWithoutExt(filename),
|
|
5047
|
+
tags: [tag],
|
|
5048
|
+
text: `data:${mediaTypeFromFilename(filename)};base64,${source}`
|
|
5049
|
+
}],
|
|
5050
|
+
diagnostics: []
|
|
5051
|
+
};
|
|
5052
|
+
}
|
|
5053
|
+
function parseFontFile(filename) {
|
|
4789
5054
|
const source = readBase64(filename);
|
|
5055
|
+
const name = (0, node_path.basename)(filename);
|
|
4790
5056
|
const family = baseNameWithoutExt(filename);
|
|
4791
|
-
const idSlug = `style-module-${slugify(family)}`;
|
|
4792
5057
|
const ext = normalizedFileExt(filename);
|
|
4793
|
-
return
|
|
5058
|
+
return {
|
|
5059
|
+
passages: [{
|
|
5060
|
+
name,
|
|
5061
|
+
tags: ["stylesheet"],
|
|
5062
|
+
text: `@font-face {\n\tfont-family: "${family}";\n\tsrc: url("data:${mediaTypeFromExt(ext)};base64,${source}") format("${fontFormatHint(ext)}");\n}`
|
|
5063
|
+
}],
|
|
5064
|
+
diagnostics: []
|
|
5065
|
+
};
|
|
5066
|
+
}
|
|
5067
|
+
function parseHTMLFile(filename) {
|
|
5068
|
+
const { story, diagnostics } = decompileHTML(readUTF8(filename));
|
|
5069
|
+
return {
|
|
5070
|
+
passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
|
|
5071
|
+
name: "StoryTitle",
|
|
5072
|
+
tags: [],
|
|
5073
|
+
text: story.name
|
|
5074
|
+
}, ...story.passages] : story.passages,
|
|
5075
|
+
diagnostics: [...diagnostics]
|
|
5076
|
+
};
|
|
4794
5077
|
}
|
|
4795
5078
|
/**
|
|
4796
|
-
*
|
|
5079
|
+
* Identity of the options that affect how a file parses, stored on its cache entry.
|
|
5080
|
+
* Only Twee files depend on them; every other file type yields the same key for all options.
|
|
4797
5081
|
*/
|
|
4798
|
-
function
|
|
4799
|
-
const
|
|
4800
|
-
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
5082
|
+
function parseOptionsKey(filename, opts) {
|
|
5083
|
+
const ext = normalizedFileExt(filename);
|
|
5084
|
+
const twee2File = ext === "tw2" || ext === "twee2";
|
|
5085
|
+
if (!twee2File && ext !== "tw" && ext !== "twee") return "";
|
|
5086
|
+
return JSON.stringify({
|
|
5087
|
+
trim: opts.trim ?? true,
|
|
5088
|
+
twee2Compat: twee2File || (opts.twee2Compat ?? false)
|
|
5089
|
+
});
|
|
5090
|
+
}
|
|
5091
|
+
function parseFile(filename, opts) {
|
|
5092
|
+
switch (normalizedFileExt(filename)) {
|
|
5093
|
+
case "tw":
|
|
5094
|
+
case "twee": return parseTweeFile(filename, opts);
|
|
5095
|
+
case "tw2":
|
|
5096
|
+
case "twee2": return parseTweeFile(filename, {
|
|
5097
|
+
...opts,
|
|
5098
|
+
twee2Compat: true
|
|
4811
5099
|
});
|
|
5100
|
+
case "htm":
|
|
5101
|
+
case "html": return parseHTMLFile(filename);
|
|
5102
|
+
case "css": return parseTaggedFile("stylesheet", filename);
|
|
5103
|
+
case "js": return parseTaggedFile("script", filename);
|
|
5104
|
+
case "otf":
|
|
5105
|
+
case "ttf":
|
|
5106
|
+
case "woff":
|
|
5107
|
+
case "woff2": return parseFontFile(filename);
|
|
5108
|
+
case "gif":
|
|
5109
|
+
case "jpeg":
|
|
5110
|
+
case "jpg":
|
|
5111
|
+
case "png":
|
|
5112
|
+
case "svg":
|
|
5113
|
+
case "tif":
|
|
5114
|
+
case "tiff":
|
|
5115
|
+
case "webp": return parseMediaFile("Twine.image", filename);
|
|
5116
|
+
case "aac":
|
|
5117
|
+
case "flac":
|
|
5118
|
+
case "m4a":
|
|
5119
|
+
case "mp3":
|
|
5120
|
+
case "oga":
|
|
5121
|
+
case "ogg":
|
|
5122
|
+
case "opus":
|
|
5123
|
+
case "wav":
|
|
5124
|
+
case "wave":
|
|
5125
|
+
case "weba": return parseMediaFile("Twine.audio", filename);
|
|
5126
|
+
case "mp4":
|
|
5127
|
+
case "ogv":
|
|
5128
|
+
case "webm": return parseMediaFile("Twine.video", filename);
|
|
5129
|
+
case "vtt": return parseMediaFile("Twine.vtt", filename);
|
|
5130
|
+
default: return;
|
|
4812
5131
|
}
|
|
4813
|
-
if (parts.length > 0) {
|
|
4814
|
-
parts.push("</head>");
|
|
4815
|
-
const replacement = parts.join("\n");
|
|
4816
|
-
return html.replace("</head>", () => replacement);
|
|
4817
|
-
}
|
|
4818
|
-
return html;
|
|
4819
|
-
}
|
|
4820
|
-
//#endregion
|
|
4821
|
-
//#region src/remote-formats.ts
|
|
4822
|
-
/**
|
|
4823
|
-
* Remote story format fetching, caching, and checksum verification.
|
|
4824
|
-
* Uses the Story Formats Archive (SFA) as the default source.
|
|
4825
|
-
*/
|
|
4826
|
-
const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
|
|
4827
|
-
/** Get the cache directory for downloaded story formats. */
|
|
4828
|
-
function getCacheDir() {
|
|
4829
|
-
const base = process.env["XDG_CACHE_HOME"] || (0, node_path.join)((0, node_os.homedir)(), ".cache");
|
|
4830
|
-
return (0, node_path.join)(base, "twee-ts", "storyformats");
|
|
4831
5132
|
}
|
|
4832
|
-
|
|
4833
|
-
const
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
indexCache.clear();
|
|
5133
|
+
function loadHTML(story, filename, diagnostics) {
|
|
5134
|
+
const result = parseHTMLFile(filename);
|
|
5135
|
+
diagnostics.push(...result.diagnostics);
|
|
5136
|
+
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
4837
5137
|
}
|
|
4838
|
-
|
|
4839
|
-
|
|
4840
|
-
|
|
4841
|
-
const
|
|
4842
|
-
return typeof obj.name === "string" && typeof obj.version === "string" && typeof obj.checksums === "object" && obj.checksums !== null && !Array.isArray(obj.checksums);
|
|
5138
|
+
function loadTwee(story, filename, opts, diagnostics) {
|
|
5139
|
+
const result = parseTweeFile(filename, opts);
|
|
5140
|
+
diagnostics.push(...result.diagnostics);
|
|
5141
|
+
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
4843
5142
|
}
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
const obj = json;
|
|
4848
|
-
return {
|
|
4849
|
-
twine1: Array.isArray(obj["twine1"]) ? obj["twine1"].filter(isValidEntry) : [],
|
|
4850
|
-
twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
|
|
4851
|
-
};
|
|
5143
|
+
function loadTagged(story, tag, filename, diagnostics) {
|
|
5144
|
+
const result = parseTaggedFile(tag, filename);
|
|
5145
|
+
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
4852
5146
|
}
|
|
4853
|
-
|
|
4854
|
-
|
|
4855
|
-
const
|
|
4856
|
-
return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
|
|
5147
|
+
function loadMedia(story, tag, filename, diagnostics) {
|
|
5148
|
+
const result = parseMediaFile(tag, filename);
|
|
5149
|
+
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
4857
5150
|
}
|
|
4858
|
-
|
|
4859
|
-
|
|
4860
|
-
const
|
|
4861
|
-
if (cached) return cached;
|
|
4862
|
-
const res = await fetch(url);
|
|
4863
|
-
if (!res.ok) throw new Error(`Failed to fetch format index from ${url}: ${res.status} ${res.statusText}`);
|
|
4864
|
-
const data = validateSFAIndex(await res.json());
|
|
4865
|
-
indexCache.set(url, data);
|
|
4866
|
-
return data;
|
|
5151
|
+
function loadFont(story, filename, diagnostics) {
|
|
5152
|
+
const result = parseFontFile(filename);
|
|
5153
|
+
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
4867
5154
|
}
|
|
4868
5155
|
/**
|
|
4869
|
-
*
|
|
4870
|
-
*
|
|
5156
|
+
* Load sources with mtime-based caching for incremental rebuilds.
|
|
5157
|
+
* Always creates a fresh Story; cached passages are replayed via storyAdd().
|
|
4871
5158
|
*/
|
|
4872
|
-
function
|
|
4873
|
-
const
|
|
4874
|
-
const
|
|
4875
|
-
|
|
4876
|
-
|
|
4877
|
-
|
|
4878
|
-
|
|
4879
|
-
|
|
4880
|
-
|
|
4881
|
-
|
|
4882
|
-
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
const
|
|
4886
|
-
if (!
|
|
4887
|
-
|
|
4888
|
-
|
|
4889
|
-
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
4898
|
-
|
|
5159
|
+
function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
|
|
5160
|
+
const currentFiles = /* @__PURE__ */ new Set();
|
|
5161
|
+
for (const filename of filenames) {
|
|
5162
|
+
if (processedFiles.has(filename)) {
|
|
5163
|
+
diagnostics.push({
|
|
5164
|
+
level: "warning",
|
|
5165
|
+
message: `load ${filename}: Skipping duplicate.`
|
|
5166
|
+
});
|
|
5167
|
+
continue;
|
|
5168
|
+
}
|
|
5169
|
+
currentFiles.add(filename);
|
|
5170
|
+
const optionsKey = parseOptionsKey(filename, opts);
|
|
5171
|
+
const entry = cache.get(filename);
|
|
5172
|
+
const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
|
|
5173
|
+
if (changedFiles && cached && !changedFiles.has(filename)) {
|
|
5174
|
+
diagnostics.push(...cached.diagnostics);
|
|
5175
|
+
for (const p of cached.passages) storyAdd(story, p, diagnostics);
|
|
5176
|
+
processedFiles.add(filename);
|
|
5177
|
+
continue;
|
|
5178
|
+
}
|
|
5179
|
+
let mtimeMs;
|
|
5180
|
+
try {
|
|
5181
|
+
mtimeMs = (0, node_fs.statSync)(filename).mtimeMs;
|
|
5182
|
+
} catch {
|
|
5183
|
+
continue;
|
|
5184
|
+
}
|
|
5185
|
+
if (cached && cached.mtimeMs === mtimeMs) {
|
|
5186
|
+
diagnostics.push(...cached.diagnostics);
|
|
5187
|
+
for (const p of cached.passages) storyAdd(story, p, diagnostics);
|
|
5188
|
+
processedFiles.add(filename);
|
|
5189
|
+
continue;
|
|
5190
|
+
}
|
|
5191
|
+
try {
|
|
5192
|
+
const result = parseFile(filename, opts);
|
|
5193
|
+
if (!result) continue;
|
|
5194
|
+
cache.set(filename, {
|
|
5195
|
+
mtimeMs,
|
|
5196
|
+
parseOptionsKey: optionsKey,
|
|
5197
|
+
passages: result.passages,
|
|
5198
|
+
diagnostics: result.diagnostics
|
|
5199
|
+
});
|
|
5200
|
+
diagnostics.push(...result.diagnostics);
|
|
5201
|
+
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
5202
|
+
} catch (e) {
|
|
5203
|
+
diagnostics.push({
|
|
5204
|
+
level: "error",
|
|
5205
|
+
message: `load ${filename}: ${e instanceof Error ? e.message : String(e)}`,
|
|
5206
|
+
file: filename
|
|
5207
|
+
});
|
|
5208
|
+
continue;
|
|
4899
5209
|
}
|
|
5210
|
+
processedFiles.add(filename);
|
|
5211
|
+
}
|
|
5212
|
+
for (const key of cache.keys()) if (!currentFiles.has(key)) cache.delete(key);
|
|
5213
|
+
if (story.name !== "" && !story.passages.some((p) => p.name === "StoryTitle")) storyPrepend(story, {
|
|
5214
|
+
name: "StoryTitle",
|
|
5215
|
+
tags: [],
|
|
5216
|
+
text: story.name
|
|
5217
|
+
}, diagnostics);
|
|
5218
|
+
}
|
|
5219
|
+
//#endregion
|
|
5220
|
+
//#region src/version.ts
|
|
5221
|
+
/**
|
|
5222
|
+
* The twee-ts version, read at runtime from the package's own package.json.
|
|
5223
|
+
*
|
|
5224
|
+
* The release workflow (semantic-release) writes the new version into the
|
|
5225
|
+
* published package.json only; git keeps the old one. Reading it at runtime
|
|
5226
|
+
* is the only way the CLI and `creator-version` report the published version.
|
|
5227
|
+
*/
|
|
5228
|
+
const PACKAGE_NAME = "@rohal12/twee-ts";
|
|
5229
|
+
/** Walks up from `startDir` to the nearest package.json named @rohal12/twee-ts and returns its version. */
|
|
5230
|
+
function findPackageVersion(startDir) {
|
|
5231
|
+
let dir = startDir;
|
|
5232
|
+
for (;;) {
|
|
5233
|
+
try {
|
|
5234
|
+
const pkg = JSON.parse((0, node_fs.readFileSync)((0, node_path.join)(dir, "package.json"), "utf-8"));
|
|
5235
|
+
if (pkg.name === PACKAGE_NAME && typeof pkg.version === "string") return pkg.version;
|
|
5236
|
+
} catch {}
|
|
5237
|
+
const parent = (0, node_path.dirname)(dir);
|
|
5238
|
+
if (parent === dir) return void 0;
|
|
5239
|
+
dir = parent;
|
|
4900
5240
|
}
|
|
4901
|
-
return bestResult;
|
|
4902
5241
|
}
|
|
4903
|
-
/**
|
|
4904
|
-
|
|
4905
|
-
|
|
4906
|
-
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("") === expectedHex.toLowerCase();
|
|
5242
|
+
/** This module's directory: `__dirname` in the CJS build, `import.meta.url` in ESM and in source. */
|
|
5243
|
+
function moduleDir() {
|
|
5244
|
+
return typeof __dirname === "string" ? __dirname : (0, node_path.dirname)((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
|
|
4907
5245
|
}
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
5246
|
+
const VERSION = findPackageVersion(moduleDir()) ?? "0.0.0";
|
|
5247
|
+
//#endregion
|
|
5248
|
+
//#region src/output-twine2.ts
|
|
5249
|
+
const CREATOR_NAME$2 = "Twee-ts";
|
|
5250
|
+
function toTwine2Archive(story, startName, options) {
|
|
5251
|
+
return getTwine2DataChunk(story, startName, options) + "\n";
|
|
4911
5252
|
}
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
|
|
4917
|
-
const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
|
|
4918
|
-
if (checksumKey) {
|
|
4919
|
-
const expected = entry.checksums[checksumKey];
|
|
4920
|
-
if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
|
|
4921
|
-
if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
|
|
5253
|
+
function toTwine2HTML(story, format, startName, options) {
|
|
5254
|
+
let template = readFormatSource(format);
|
|
5255
|
+
if (template.includes("{{STORY_NAME}}")) {
|
|
5256
|
+
const name = htmlEscape(story.name);
|
|
5257
|
+
template = template.replaceAll("{{STORY_NAME}}", () => name);
|
|
4922
5258
|
}
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
|
|
4930
|
-
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
name: data.name,
|
|
4936
|
-
version: data.version,
|
|
4937
|
-
proofing: data.proofing ?? false
|
|
4938
|
-
};
|
|
4939
|
-
}
|
|
4940
|
-
/** Download a direct format.js URL, parse its JSON, cache it, and return StoryFormatInfo. */
|
|
4941
|
-
async function fetchDirectFormat(url) {
|
|
4942
|
-
const res = await fetch(url);
|
|
4943
|
-
if (!res.ok) throw new Error(`Failed to download format from ${url}: ${res.status} ${res.statusText}`);
|
|
4944
|
-
const text = await res.text();
|
|
4945
|
-
const data = parseFormatJSON(text, "direct-url");
|
|
4946
|
-
if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
|
|
4947
|
-
const id = makeFormatId(data.name, data.version);
|
|
4948
|
-
const cacheDir = getCacheDir();
|
|
4949
|
-
const formatDir = (0, node_path.join)(cacheDir, data.name, data.version);
|
|
4950
|
-
(0, node_fs.mkdirSync)(formatDir, { recursive: true });
|
|
4951
|
-
const formatPath = (0, node_path.join)(formatDir, "format.js");
|
|
4952
|
-
(0, node_fs.writeFileSync)(formatPath, text, "utf-8");
|
|
4953
|
-
return {
|
|
4954
|
-
id,
|
|
4955
|
-
filename: formatPath,
|
|
4956
|
-
isTwine2: true,
|
|
4957
|
-
name: data.name,
|
|
4958
|
-
version: data.version,
|
|
4959
|
-
proofing: data.proofing ?? false
|
|
4960
|
-
};
|
|
5259
|
+
if (template.includes("{{STORY_DATA}}")) {
|
|
5260
|
+
const data = getTwine2DataChunk({
|
|
5261
|
+
...story,
|
|
5262
|
+
twine2: {
|
|
5263
|
+
...story.twine2,
|
|
5264
|
+
format: format.name,
|
|
5265
|
+
formatVersion: format.version
|
|
5266
|
+
}
|
|
5267
|
+
}, startName, options);
|
|
5268
|
+
template = template.replace("{{STORY_DATA}}", () => data);
|
|
5269
|
+
}
|
|
5270
|
+
return template;
|
|
4961
5271
|
}
|
|
5272
|
+
const OMITTING_TAGS = [
|
|
5273
|
+
"Twine.private",
|
|
5274
|
+
"script",
|
|
5275
|
+
"stylesheet"
|
|
5276
|
+
];
|
|
4962
5277
|
/**
|
|
4963
|
-
*
|
|
4964
|
-
*
|
|
4965
|
-
* 2. Try custom index URLs
|
|
4966
|
-
* 3. Try default SFA indices
|
|
5278
|
+
* Why Twine 2 output leaves a passage out of its `<tw-passagedata>` elements,
|
|
5279
|
+
* or `undefined` when the passage is emitted (and so can be the starting passage).
|
|
4967
5280
|
*/
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
|
|
4971
|
-
|
|
4972
|
-
|
|
4973
|
-
|
|
4974
|
-
|
|
4975
|
-
|
|
5281
|
+
function twine2PassageOmission(story, p) {
|
|
5282
|
+
if (p.name === "StoryTitle" || p.name === "StoryData") return {
|
|
5283
|
+
kind: "special-name",
|
|
5284
|
+
name: p.name
|
|
5285
|
+
};
|
|
5286
|
+
const tag = OMITTING_TAGS.find((t) => hasTag(p, t));
|
|
5287
|
+
if (tag !== void 0) return {
|
|
5288
|
+
kind: "tag",
|
|
5289
|
+
tag
|
|
5290
|
+
};
|
|
5291
|
+
if (p.name === "StorySettings" && story.twine1.settings.size === 0) return { kind: "empty-story-settings" };
|
|
5292
|
+
}
|
|
5293
|
+
function getTwine2DataChunk(story, startName, options) {
|
|
5294
|
+
const parts = [];
|
|
5295
|
+
let startID = "";
|
|
5296
|
+
let pid = 0;
|
|
5297
|
+
const scripts = [];
|
|
5298
|
+
const stylesheets = [];
|
|
5299
|
+
for (const p of story.passages) {
|
|
5300
|
+
if (hasTag(p, "Twine.private")) continue;
|
|
5301
|
+
if (hasTag(p, "script")) scripts.push(p);
|
|
5302
|
+
else if (hasTag(p, "stylesheet")) stylesheets.push(p);
|
|
5303
|
+
}
|
|
5304
|
+
let styleContent = "";
|
|
5305
|
+
if (stylesheets.length === 1) styleContent = stylesheets[0]?.text ?? "";
|
|
5306
|
+
else if (stylesheets.length > 1) {
|
|
5307
|
+
let pidS = 1;
|
|
5308
|
+
for (const p of stylesheets) {
|
|
5309
|
+
if (pidS > 1 && !styleContent.endsWith("\n")) styleContent += "\n";
|
|
5310
|
+
styleContent += `/* twine-user-stylesheet #${pidS}: "${commentSanitize(p.name)}" */\n`;
|
|
5311
|
+
styleContent += p.text;
|
|
5312
|
+
pidS++;
|
|
4976
5313
|
}
|
|
4977
|
-
} catch (e) {
|
|
4978
|
-
lastError = e instanceof Error ? e : new Error(String(e));
|
|
4979
5314
|
}
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
5315
|
+
parts.push(`<style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css">${styleContentEscape(styleContent)}</style>`);
|
|
5316
|
+
let scriptContent = "";
|
|
5317
|
+
if (scripts.length === 1) scriptContent = scripts[0]?.text ?? "";
|
|
5318
|
+
else if (scripts.length > 1) {
|
|
5319
|
+
let pidS = 1;
|
|
5320
|
+
for (const p of scripts) {
|
|
5321
|
+
if (pidS > 1 && !scriptContent.endsWith("\n")) scriptContent += "\n";
|
|
5322
|
+
scriptContent += `/* twine-user-script #${pidS}: "${commentSanitize(p.name)}" */\n`;
|
|
5323
|
+
scriptContent += p.text;
|
|
5324
|
+
pidS++;
|
|
4988
5325
|
}
|
|
4989
|
-
} catch (e) {
|
|
4990
|
-
lastError = e instanceof Error ? e : new Error(String(e));
|
|
4991
5326
|
}
|
|
4992
|
-
|
|
5327
|
+
parts.push(`<script role="script" id="twine-user-script" type="text/twine-javascript">${scriptContentEscape(scriptContent)}<\/script>`);
|
|
5328
|
+
const validTagColors = /* @__PURE__ */ new Set([
|
|
5329
|
+
"gray",
|
|
5330
|
+
"red",
|
|
5331
|
+
"orange",
|
|
5332
|
+
"yellow",
|
|
5333
|
+
"green",
|
|
5334
|
+
"blue",
|
|
5335
|
+
"purple"
|
|
5336
|
+
]);
|
|
5337
|
+
const hexColorPattern = /^#[0-9a-fA-F]{3,8}$/;
|
|
5338
|
+
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>`);
|
|
5339
|
+
pid = 1;
|
|
5340
|
+
for (const p of story.passages) {
|
|
5341
|
+
if (twine2PassageOmission(story, p) !== void 0) continue;
|
|
5342
|
+
parts.push(passageToPassagedata(p, pid, options));
|
|
5343
|
+
if (startName === p.name) startID = String(pid);
|
|
5344
|
+
pid++;
|
|
5345
|
+
}
|
|
5346
|
+
const opts = [];
|
|
5347
|
+
for (const [opt, val] of story.twine2.options) if (val) opts.push(opt);
|
|
5348
|
+
const optionsStr = opts.join(" ");
|
|
5349
|
+
const zoom = String(story.twine2.zoom);
|
|
5350
|
+
return `<!-- UUID://${htmlCommentSanitize(story.ifid)}// --><tw-storydata name="${attrEscape(story.name)}" startnode="${attrEscape(startID)}" creator="${attrEscape(CREATOR_NAME$2)}" creator-version="${attrEscape(VERSION)}" ifid="${attrEscape(story.ifid)}" zoom="${attrEscape(zoom)}" format="${attrEscape(story.twine2.format)}" format-version="${attrEscape(story.twine2.formatVersion)}" options="${attrEscape(optionsStr)}" tags="${attrEscape(story.twine2.tags)}" hidden>` + parts.join("") + "</tw-storydata>";
|
|
4993
5351
|
}
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
|
|
5004
|
-
|
|
5005
|
-
|
|
5006
|
-
|
|
5007
|
-
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
|
|
5352
|
+
//#endregion
|
|
5353
|
+
//#region src/output-twine1.ts
|
|
5354
|
+
/**
|
|
5355
|
+
* Twine 1 HTML and archive output.
|
|
5356
|
+
* Ported from storyout.go.
|
|
5357
|
+
*/
|
|
5358
|
+
const CREATOR_NAME$1 = "twee-ts";
|
|
5359
|
+
function toTwine1Archive(story, _startName) {
|
|
5360
|
+
const { data, count } = getTwine1PassageChunk(story);
|
|
5361
|
+
return `<div id="storeArea" data-size="${count}">${data}</div>\n`;
|
|
5362
|
+
}
|
|
5363
|
+
function toTwine1HTML(story, format, startName) {
|
|
5364
|
+
const formatDir = (0, node_path.dirname)(format.filename);
|
|
5365
|
+
const parentDir = (0, node_path.dirname)(formatDir);
|
|
5366
|
+
let template = readFormatSource(format);
|
|
5367
|
+
const { data, count } = getTwine1PassageChunk(story);
|
|
5368
|
+
template = tryReplaceComponent(template, "\"USER_LIB\"", (0, node_path.join)(formatDir, "userlib.js"), false);
|
|
5369
|
+
template = tryReplaceComponent(template, "\"ENGINE\"", (0, node_path.join)(parentDir, "engine.js"), true);
|
|
5370
|
+
template = tryReplaceComponent(template, "\"SUGARCANE\"", (0, node_path.join)(formatDir, "code.js"), true);
|
|
5371
|
+
template = tryReplaceComponent(template, "\"JONAH\"", (0, node_path.join)(formatDir, "code.js"), true);
|
|
5372
|
+
if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", (0, node_path.join)(parentDir, "jquery.js"), true);
|
|
5373
|
+
if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", (0, node_path.join)(parentDir, "modernizr.js"), true);
|
|
5374
|
+
const displayStart = startName === "Start" ? "" : startName;
|
|
5375
|
+
template = template.replace("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`);
|
|
5376
|
+
template = template.replace("\"TIME\"", `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`);
|
|
5377
|
+
const startAtValue = `"${jsStringEscape(displayStart)}"`;
|
|
5378
|
+
template = template.replace("\"START_AT\"", () => startAtValue);
|
|
5379
|
+
template = template.replace("\"STORY_SIZE\"", `"${count}"`);
|
|
5380
|
+
if (template.includes("\"STORY\"")) template = template.replace("\"STORY\"", () => data);
|
|
5381
|
+
else {
|
|
5382
|
+
let footer;
|
|
5383
|
+
try {
|
|
5384
|
+
footer = (0, node_fs.readFileSync)((0, node_path.join)(formatDir, "footer.html"), "utf-8");
|
|
5385
|
+
} catch {
|
|
5386
|
+
footer = "</div>\n</body>\n</html>\n";
|
|
5387
|
+
}
|
|
5388
|
+
template += data + footer;
|
|
5389
|
+
}
|
|
5390
|
+
if (story.ifid) {
|
|
5391
|
+
const safeIfid = htmlCommentSanitize(story.ifid);
|
|
5392
|
+
if (template.includes("<div id=\"store-area\"")) template = template.replace("<div id=\"store-area\"", `<!-- UUID://${safeIfid}// --><div id="store-area"`);
|
|
5393
|
+
else template = template.replace("<div id=\"storeArea\"", `<!-- UUID://${safeIfid}// --><div id="storeArea"`);
|
|
5013
5394
|
}
|
|
5395
|
+
return template;
|
|
5014
5396
|
}
|
|
5015
|
-
/**
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5397
|
+
/**
|
|
5398
|
+
* Why Twine 1 output leaves a passage out of its tiddlers, or `undefined` when the passage is emitted.
|
|
5399
|
+
*/
|
|
5400
|
+
function twine1PassageOmission(p) {
|
|
5401
|
+
return hasTag(p, "Twine.private") ? {
|
|
5402
|
+
kind: "tag",
|
|
5403
|
+
tag: "Twine.private"
|
|
5404
|
+
} : void 0;
|
|
5405
|
+
}
|
|
5406
|
+
function getTwine1PassageChunk(story) {
|
|
5407
|
+
const obfuscateRot13 = story.twine1.settings.get("obfuscate") === "rot13";
|
|
5408
|
+
let data = "";
|
|
5409
|
+
let count = 0;
|
|
5410
|
+
for (const p of story.passages) {
|
|
5411
|
+
if (twine1PassageOmission(p) !== void 0) continue;
|
|
5412
|
+
count++;
|
|
5413
|
+
data += passageToTiddler(p, count, obfuscateRot13);
|
|
5023
5414
|
}
|
|
5024
|
-
|
|
5415
|
+
return {
|
|
5416
|
+
data,
|
|
5417
|
+
count
|
|
5418
|
+
};
|
|
5419
|
+
}
|
|
5420
|
+
function tryReplaceComponent(template, placeholder, componentPath, required) {
|
|
5421
|
+
if (!template.includes(placeholder)) return template;
|
|
5025
5422
|
try {
|
|
5026
|
-
|
|
5027
|
-
|
|
5028
|
-
return
|
|
5423
|
+
let content = (0, node_fs.readFileSync)(componentPath, "utf-8");
|
|
5424
|
+
if (content.charCodeAt(0) === 65279) content = content.slice(1);
|
|
5425
|
+
return template.replace(placeholder, () => content);
|
|
5426
|
+
} catch (e) {
|
|
5427
|
+
if (required) throw new Error(`Required format component not found: ${componentPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
5428
|
+
return template;
|
|
5029
5429
|
}
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
|
|
5033
|
-
|
|
5034
|
-
|
|
5035
|
-
|
|
5036
|
-
|
|
5037
|
-
|
|
5038
|
-
|
|
5039
|
-
|
|
5040
|
-
|
|
5041
|
-
|
|
5042
|
-
|
|
5043
|
-
|
|
5044
|
-
|
|
5045
|
-
|
|
5430
|
+
}
|
|
5431
|
+
//#endregion
|
|
5432
|
+
//#region src/output-twee.ts
|
|
5433
|
+
function toTwee(story, outMode) {
|
|
5434
|
+
let data = "";
|
|
5435
|
+
for (const p of story.passages) data += passageToTwee(p, outMode);
|
|
5436
|
+
return data;
|
|
5437
|
+
}
|
|
5438
|
+
//#endregion
|
|
5439
|
+
//#region src/modules.ts
|
|
5440
|
+
/**
|
|
5441
|
+
* Load modules and return HTML tags to inject before </head>.
|
|
5442
|
+
*/
|
|
5443
|
+
function loadModules(filenames) {
|
|
5444
|
+
const processed = /* @__PURE__ */ new Set();
|
|
5445
|
+
const headTags = [];
|
|
5446
|
+
for (const filename of filenames) {
|
|
5447
|
+
if (processed.has(filename)) continue;
|
|
5448
|
+
const ext = normalizedFileExt(filename);
|
|
5449
|
+
let tag = null;
|
|
5450
|
+
switch (ext) {
|
|
5451
|
+
case "css":
|
|
5452
|
+
tag = loadModuleTagged("style", filename);
|
|
5453
|
+
break;
|
|
5454
|
+
case "js":
|
|
5455
|
+
tag = loadModuleTagged("script", filename);
|
|
5456
|
+
break;
|
|
5457
|
+
case "otf":
|
|
5458
|
+
case "ttf":
|
|
5459
|
+
case "woff":
|
|
5460
|
+
case "woff2":
|
|
5461
|
+
tag = loadModuleFont(filename);
|
|
5462
|
+
break;
|
|
5463
|
+
default: continue;
|
|
5046
5464
|
}
|
|
5465
|
+
if (tag) headTags.push(tag);
|
|
5466
|
+
processed.add(filename);
|
|
5047
5467
|
}
|
|
5048
|
-
return
|
|
5468
|
+
return headTags.join("\n");
|
|
5049
5469
|
}
|
|
5050
|
-
|
|
5051
|
-
|
|
5052
|
-
|
|
5053
|
-
|
|
5054
|
-
|
|
5055
|
-
|
|
5056
|
-
|
|
5057
|
-
|
|
5470
|
+
function loadModuleTagged(tag, filename) {
|
|
5471
|
+
const source = readUTF8(filename).trim();
|
|
5472
|
+
if (source.length === 0) return null;
|
|
5473
|
+
return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
|
|
5474
|
+
}
|
|
5475
|
+
function loadModuleFont(filename) {
|
|
5476
|
+
const source = readBase64(filename);
|
|
5477
|
+
const family = baseNameWithoutExt(filename);
|
|
5478
|
+
const idSlug = `style-module-${slugify(family)}`;
|
|
5479
|
+
const ext = normalizedFileExt(filename);
|
|
5480
|
+
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>`;
|
|
5481
|
+
}
|
|
5482
|
+
/**
|
|
5483
|
+
* Inject modules and head file content before </head>.
|
|
5484
|
+
*/
|
|
5485
|
+
function modifyHead(html, modulePaths, headFile, diagnostics) {
|
|
5486
|
+
const parts = [];
|
|
5487
|
+
if (modulePaths.length > 0) {
|
|
5488
|
+
const modules = loadModules(modulePaths).trim();
|
|
5489
|
+
if (modules.length > 0) parts.push(modules);
|
|
5058
5490
|
}
|
|
5059
|
-
|
|
5060
|
-
|
|
5061
|
-
|
|
5062
|
-
} catch {
|
|
5063
|
-
|
|
5491
|
+
if (headFile) try {
|
|
5492
|
+
const source = readUTF8(headFile).trim();
|
|
5493
|
+
if (source.length > 0) parts.push(source);
|
|
5494
|
+
} catch (e) {
|
|
5495
|
+
diagnostics?.push({
|
|
5496
|
+
level: "warning",
|
|
5497
|
+
message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
|
|
5498
|
+
});
|
|
5064
5499
|
}
|
|
5065
|
-
|
|
5066
|
-
|
|
5067
|
-
|
|
5068
|
-
|
|
5069
|
-
} catch {
|
|
5070
|
-
continue;
|
|
5071
|
-
}
|
|
5072
|
-
let versions;
|
|
5073
|
-
try {
|
|
5074
|
-
versions = (0, node_fs.readdirSync)(nameDir);
|
|
5075
|
-
} catch {
|
|
5076
|
-
continue;
|
|
5077
|
-
}
|
|
5078
|
-
for (const version of versions) {
|
|
5079
|
-
const formatPath = (0, node_path.join)(nameDir, version, "format.js");
|
|
5080
|
-
try {
|
|
5081
|
-
const stat = (0, node_fs.statSync)(formatPath);
|
|
5082
|
-
entries.push({
|
|
5083
|
-
name,
|
|
5084
|
-
version,
|
|
5085
|
-
sizeBytes: stat.size,
|
|
5086
|
-
modifiedAt: stat.mtime
|
|
5087
|
-
});
|
|
5088
|
-
} catch {}
|
|
5089
|
-
}
|
|
5500
|
+
if (parts.length > 0) {
|
|
5501
|
+
parts.push("</head>");
|
|
5502
|
+
const replacement = parts.join("\n");
|
|
5503
|
+
return html.replace("</head>", () => replacement);
|
|
5090
5504
|
}
|
|
5091
|
-
return
|
|
5505
|
+
return html;
|
|
5092
5506
|
}
|
|
5093
|
-
|
|
5094
|
-
|
|
5095
|
-
|
|
5096
|
-
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
5101
|
-
force: true
|
|
5102
|
-
});
|
|
5103
|
-
return count;
|
|
5507
|
+
//#endregion
|
|
5508
|
+
//#region src/start-passage.ts
|
|
5509
|
+
function describeOmission(omission) {
|
|
5510
|
+
switch (omission.kind) {
|
|
5511
|
+
case "special-name": return `is the special "${omission.name}" passage`;
|
|
5512
|
+
case "tag": return `is tagged "${omission.tag}"`;
|
|
5513
|
+
case "empty-story-settings": return "is an empty \"StorySettings\" passage";
|
|
5514
|
+
default: throw new Error(`unhandled passage omission: ${JSON.stringify(omission)}`);
|
|
5104
5515
|
}
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
return 0;
|
|
5516
|
+
}
|
|
5517
|
+
function omissionFor(story, passage, target) {
|
|
5518
|
+
switch (target) {
|
|
5519
|
+
case "twine2": return twine2PassageOmission(story, passage);
|
|
5520
|
+
case "twine1": return twine1PassageOmission(passage);
|
|
5521
|
+
default: throw new Error(`unhandled start passage target: ${String(target)}`);
|
|
5112
5522
|
}
|
|
5113
|
-
const count = versions.length;
|
|
5114
|
-
(0, node_fs.rmSync)(nameDir, {
|
|
5115
|
-
recursive: true,
|
|
5116
|
-
force: true
|
|
5117
|
-
});
|
|
5118
|
-
return count;
|
|
5119
5523
|
}
|
|
5120
|
-
/**
|
|
5121
|
-
|
|
5122
|
-
|
|
5123
|
-
|
|
5124
|
-
|
|
5125
|
-
|
|
5126
|
-
|
|
5524
|
+
/**
|
|
5525
|
+
* Check that `startName` names a passage the `target` output emits.
|
|
5526
|
+
* Returns the error diagnostics to report (empty when the start is usable).
|
|
5527
|
+
*/
|
|
5528
|
+
function startPassageDiagnostics(story, startName, target) {
|
|
5529
|
+
const passage = story.passages.find((p) => p.name === startName);
|
|
5530
|
+
if (passage === void 0) return [{
|
|
5531
|
+
level: "error",
|
|
5532
|
+
message: `Starting passage "${startName}" not found.`
|
|
5533
|
+
}];
|
|
5534
|
+
const omission = omissionFor(story, passage, target);
|
|
5535
|
+
if (omission === void 0) return [];
|
|
5536
|
+
return [{
|
|
5537
|
+
level: "error",
|
|
5538
|
+
message: `Starting passage "${startName}" ${describeOmission(omission)}, so it is left out of the story data. Choose a story passage.`
|
|
5539
|
+
}];
|
|
5127
5540
|
}
|
|
5128
5541
|
//#endregion
|
|
5129
5542
|
//#region src/compiler.ts
|
|
@@ -5153,7 +5566,7 @@ async function compile(options) {
|
|
|
5153
5566
|
* Compile and write to a file.
|
|
5154
5567
|
*/
|
|
5155
5568
|
async function compileToFile(options) {
|
|
5156
|
-
const result = await
|
|
5569
|
+
const result = await buildOutput(options, void 0, void 0, options.outFile);
|
|
5157
5570
|
(0, node_fs.writeFileSync)(options.outFile, result.output, "utf-8");
|
|
5158
5571
|
return result;
|
|
5159
5572
|
}
|
|
@@ -5172,18 +5585,35 @@ async function watch(options) {
|
|
|
5172
5585
|
const cache = /* @__PURE__ */ new Map();
|
|
5173
5586
|
const filePaths = options.sources.filter((s) => typeof s === "string");
|
|
5174
5587
|
const modulePaths = options.modules ?? [];
|
|
5175
|
-
const
|
|
5176
|
-
|
|
5588
|
+
const headPaths = options.headFile ? [options.headFile] : [];
|
|
5589
|
+
const allPaths = [
|
|
5590
|
+
...filePaths,
|
|
5591
|
+
...modulePaths,
|
|
5592
|
+
...headPaths
|
|
5593
|
+
];
|
|
5594
|
+
const exclude = options.exclude ?? [];
|
|
5595
|
+
const notExcludable = [...modulePaths, ...headPaths].map((p) => (0, node_path.resolve)(p));
|
|
5596
|
+
const ignore = (filename) => {
|
|
5597
|
+
if (!isExcluded(filename, exclude)) return false;
|
|
5598
|
+
const abs = (0, node_path.resolve)(filename);
|
|
5599
|
+
return !notExcludable.some((root) => abs === root || abs.startsWith(root + node_path.sep));
|
|
5600
|
+
};
|
|
5601
|
+
const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
|
|
5602
|
+
buildOutput(options, cache, changedFiles, options.outFile).then((result) => {
|
|
5177
5603
|
(0, node_fs.writeFileSync)(options.outFile, result.output, "utf-8");
|
|
5178
5604
|
options.onBuild?.(result);
|
|
5179
5605
|
}).catch((e) => {
|
|
5180
5606
|
options.onError?.(e instanceof Error ? e : new Error(String(e)));
|
|
5181
5607
|
});
|
|
5182
|
-
});
|
|
5608
|
+
}, ignore);
|
|
5183
5609
|
controller.signal.addEventListener("abort", () => handle.close());
|
|
5184
5610
|
return controller;
|
|
5185
5611
|
}
|
|
5186
|
-
|
|
5612
|
+
/**
|
|
5613
|
+
* `outFile`: the file the build is written to, which source and module discovery
|
|
5614
|
+
* skip (as Tweego does), so an output inside a source folder is never loaded back.
|
|
5615
|
+
*/
|
|
5616
|
+
async function buildOutput(options, cache, changedFiles, outFile) {
|
|
5187
5617
|
const diagnostics = [];
|
|
5188
5618
|
const outputMode = options.outputMode ?? "html";
|
|
5189
5619
|
const trim = options.trim ?? true;
|
|
@@ -5196,7 +5626,7 @@ async function buildOutput(options, cache, changedFiles) {
|
|
|
5196
5626
|
const inlineSources = [];
|
|
5197
5627
|
for (const source of options.sources) if (typeof source === "string") filePaths.push(source);
|
|
5198
5628
|
else inlineSources.push(source);
|
|
5199
|
-
const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths);
|
|
5629
|
+
const { filenames: sourceFilenames, diagnostics: sourcePathDiagnostics } = getFilenames(filePaths, outFile, options.exclude);
|
|
5200
5630
|
diagnostics.push(...sourcePathDiagnostics);
|
|
5201
5631
|
const story = createStory();
|
|
5202
5632
|
const processedFiles = /* @__PURE__ */ new Set();
|
|
@@ -5214,33 +5644,12 @@ async function buildOutput(options, cache, changedFiles) {
|
|
|
5214
5644
|
}, diagnostics);
|
|
5215
5645
|
if (options.tagAliases) story.passages = applyTagAliases(story.passages, options.tagAliases);
|
|
5216
5646
|
let format;
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
5220
|
-
|
|
5221
|
-
|
|
5222
|
-
|
|
5223
|
-
if (!format && !noRemote) {
|
|
5224
|
-
const remoteName = story.twine2.format || formatId;
|
|
5225
|
-
const remoteVersion = story.twine2.formatVersion || "";
|
|
5226
|
-
try {
|
|
5227
|
-
format = await resolveRemoteFormat(remoteName, remoteVersion, options.formatIndices, options.formatUrls);
|
|
5228
|
-
} catch (e) {
|
|
5229
|
-
diagnostics.push({
|
|
5230
|
-
level: "warning",
|
|
5231
|
-
message: `Remote format fetch failed for "${remoteName}": ${e instanceof Error ? e.message : String(e)}`
|
|
5232
|
-
});
|
|
5233
|
-
}
|
|
5234
|
-
}
|
|
5235
|
-
if (!format) {
|
|
5236
|
-
const reason = noRemote ? " (remote fetching disabled)" : "";
|
|
5237
|
-
diagnostics.push({
|
|
5238
|
-
level: "error",
|
|
5239
|
-
message: `Story format "${formatId}" is not available${reason}. Found: ${[...formats.keys()].join(", ") || "none"}`
|
|
5240
|
-
});
|
|
5241
|
-
}
|
|
5242
|
-
}
|
|
5243
|
-
const startName = options.startPassage || story.twine2.start || DEFAULT_START_NAME;
|
|
5647
|
+
if (outputMode === "html") format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
|
|
5648
|
+
...options,
|
|
5649
|
+
noRemote
|
|
5650
|
+
}, diagnostics);
|
|
5651
|
+
if (options.startPassage) story.twine2.start = options.startPassage;
|
|
5652
|
+
const startName = story.twine2.start || DEFAULT_START_NAME;
|
|
5244
5653
|
if (testMode) story.twine2.options.set("debug", true);
|
|
5245
5654
|
ensureIFID(story, diagnostics);
|
|
5246
5655
|
let output;
|
|
@@ -5259,10 +5668,7 @@ async function buildOutput(options, cache, changedFiles) {
|
|
|
5259
5668
|
output = storyToJSON(story);
|
|
5260
5669
|
break;
|
|
5261
5670
|
case "html": {
|
|
5262
|
-
|
|
5263
|
-
level: "error",
|
|
5264
|
-
message: `Starting passage "${startName}" not found.`
|
|
5265
|
-
});
|
|
5671
|
+
diagnostics.push(...startPassageDiagnostics(story, startName, format?.isTwine2 === false ? "twine1" : "twine2"));
|
|
5266
5672
|
if (!format) throw new TweeTsError("No story format available for HTML output.", diagnostics);
|
|
5267
5673
|
if (format.isTwine2) output = toTwine2HTML(story, format, startName, { sourceInfo });
|
|
5268
5674
|
else {
|
|
@@ -5272,7 +5678,7 @@ async function buildOutput(options, cache, changedFiles) {
|
|
|
5272
5678
|
});
|
|
5273
5679
|
output = toTwine1HTML(story, format, startName);
|
|
5274
5680
|
}
|
|
5275
|
-
const modules = getFilenames(options.modules ?? []);
|
|
5681
|
+
const modules = getFilenames(options.modules ?? [], outFile);
|
|
5276
5682
|
diagnostics.push(...modules.diagnostics);
|
|
5277
5683
|
output = modifyHead(output, modules.filenames, options.headFile, diagnostics);
|
|
5278
5684
|
break;
|
|
@@ -5692,19 +6098,227 @@ function endOfRegularExpression(source, pos) {
|
|
|
5692
6098
|
};
|
|
5693
6099
|
}
|
|
5694
6100
|
//#endregion
|
|
6101
|
+
//#region src/link-markup.ts
|
|
6102
|
+
/**
|
|
6103
|
+
* Reads link or image markup that starts at `start` in `text`, or returns `undefined` where
|
|
6104
|
+
* SugarCube rejects it (and so prints the text instead).
|
|
6105
|
+
*
|
|
6106
|
+
* `budget.left`, if given, is how many characters the reading may look at; it is reduced by as
|
|
6107
|
+
* many as were read, and once it runs out, every reading fails. Unclosed markup is read to the end
|
|
6108
|
+
* of its line, so reading every `[[` of a long line that never closes one would otherwise take
|
|
6109
|
+
* quadratic time.
|
|
6110
|
+
*/
|
|
6111
|
+
function readSquareBracketedMarkup(text, start, budget = { left: Infinity }) {
|
|
6112
|
+
const reader = {
|
|
6113
|
+
text,
|
|
6114
|
+
limit: Math.min(text.length, start + Math.max(budget.left, 0))
|
|
6115
|
+
};
|
|
6116
|
+
const markup = readMarkup(reader, start);
|
|
6117
|
+
budget.left -= (markup?.end ?? reader.limit) - start;
|
|
6118
|
+
return markup;
|
|
6119
|
+
}
|
|
6120
|
+
function readMarkup(reader, start) {
|
|
6121
|
+
const { text } = reader;
|
|
6122
|
+
if (text[start] !== "[") return;
|
|
6123
|
+
let pos = start + 1;
|
|
6124
|
+
let type;
|
|
6125
|
+
if (text[pos] === "[") {
|
|
6126
|
+
type = "link";
|
|
6127
|
+
pos += 1;
|
|
6128
|
+
} else {
|
|
6129
|
+
if (text[pos] === "<" || text[pos] === ">") pos += 1;
|
|
6130
|
+
if (!/^[Ii][Mm][Gg]\[/.test(text.slice(pos, pos + 4))) return;
|
|
6131
|
+
type = "image";
|
|
6132
|
+
pos += 4;
|
|
6133
|
+
}
|
|
6134
|
+
const core = readCoreComponents(reader, pos);
|
|
6135
|
+
if (core === void 0) return;
|
|
6136
|
+
if (type === "link") {
|
|
6137
|
+
if (!core.end.last) {
|
|
6138
|
+
const setterEnd = readSetter(reader, core.end.next);
|
|
6139
|
+
if (setterEnd === void 0) return;
|
|
6140
|
+
return {
|
|
6141
|
+
type,
|
|
6142
|
+
link: linkName(core.link),
|
|
6143
|
+
end: setterEnd
|
|
6144
|
+
};
|
|
6145
|
+
}
|
|
6146
|
+
return {
|
|
6147
|
+
type,
|
|
6148
|
+
link: linkName(core.link),
|
|
6149
|
+
end: core.end.next
|
|
6150
|
+
};
|
|
6151
|
+
}
|
|
6152
|
+
if (core.end.last) return {
|
|
6153
|
+
type,
|
|
6154
|
+
link: void 0,
|
|
6155
|
+
end: core.end.next
|
|
6156
|
+
};
|
|
6157
|
+
const linkEnd = readComponent(reader, core.end.next, false);
|
|
6158
|
+
if (linkEnd === void 0) return;
|
|
6159
|
+
const link = linkName(text.slice(core.end.next, linkEnd.textEnd));
|
|
6160
|
+
if (linkEnd.last) return {
|
|
6161
|
+
type,
|
|
6162
|
+
link,
|
|
6163
|
+
end: linkEnd.next
|
|
6164
|
+
};
|
|
6165
|
+
const setterEnd = readSetter(reader, linkEnd.next);
|
|
6166
|
+
return setterEnd === void 0 ? void 0 : {
|
|
6167
|
+
type,
|
|
6168
|
+
link,
|
|
6169
|
+
end: setterEnd
|
|
6170
|
+
};
|
|
6171
|
+
}
|
|
6172
|
+
/** The link component as SugarCube keeps it: trimmed, and without a leading `~`. */
|
|
6173
|
+
function linkName(component) {
|
|
6174
|
+
const trimmed = component.trim();
|
|
6175
|
+
return trimmed.startsWith("~") ? trimmed.slice(1) : trimmed;
|
|
6176
|
+
}
|
|
6177
|
+
/**
|
|
6178
|
+
* Reads the first components: the text and the link of link markup, or the title and the source
|
|
6179
|
+
* of image markup, divided by the first delimiter. `link` is the text of the link (or source)
|
|
6180
|
+
* component, untrimmed.
|
|
6181
|
+
*/
|
|
6182
|
+
function readCoreComponents(reader, from) {
|
|
6183
|
+
const { text } = reader;
|
|
6184
|
+
let delimiter = "none";
|
|
6185
|
+
let componentStart = from;
|
|
6186
|
+
let link;
|
|
6187
|
+
let depth = 2;
|
|
6188
|
+
let pos = from;
|
|
6189
|
+
while (pos < reader.limit) {
|
|
6190
|
+
const ch = text[pos];
|
|
6191
|
+
pos += 1;
|
|
6192
|
+
switch (ch) {
|
|
6193
|
+
case "\n": return;
|
|
6194
|
+
case "\"": {
|
|
6195
|
+
const end = readQuoted(reader, pos, "\"");
|
|
6196
|
+
if (end === void 0) return;
|
|
6197
|
+
pos = end;
|
|
6198
|
+
break;
|
|
6199
|
+
}
|
|
6200
|
+
case "|":
|
|
6201
|
+
if (delimiter === "none") {
|
|
6202
|
+
delimiter = "ltr";
|
|
6203
|
+
componentStart = pos;
|
|
6204
|
+
}
|
|
6205
|
+
break;
|
|
6206
|
+
case "-":
|
|
6207
|
+
if (delimiter === "none" && text[pos] === ">") {
|
|
6208
|
+
delimiter = "ltr";
|
|
6209
|
+
pos += 1;
|
|
6210
|
+
componentStart = pos;
|
|
6211
|
+
}
|
|
6212
|
+
break;
|
|
6213
|
+
case "<":
|
|
6214
|
+
if (delimiter === "none" && text[pos] === "-") {
|
|
6215
|
+
delimiter = "rtl";
|
|
6216
|
+
link = text.slice(componentStart, pos - 1);
|
|
6217
|
+
pos += 1;
|
|
6218
|
+
componentStart = pos;
|
|
6219
|
+
}
|
|
6220
|
+
break;
|
|
6221
|
+
case "[":
|
|
6222
|
+
depth += 1;
|
|
6223
|
+
break;
|
|
6224
|
+
case "]":
|
|
6225
|
+
depth -= 1;
|
|
6226
|
+
if (depth === 1) {
|
|
6227
|
+
const end = componentEnd(text, pos);
|
|
6228
|
+
if (end === void 0) return;
|
|
6229
|
+
return {
|
|
6230
|
+
link: link ?? text.slice(componentStart, end.textEnd),
|
|
6231
|
+
end
|
|
6232
|
+
};
|
|
6233
|
+
}
|
|
6234
|
+
}
|
|
6235
|
+
}
|
|
6236
|
+
}
|
|
6237
|
+
/**
|
|
6238
|
+
* Reads a component after `][` that holds no delimiter: an image's link (`setter` false) or a
|
|
6239
|
+
* setter, in which single quotes also quote.
|
|
6240
|
+
*/
|
|
6241
|
+
function readComponent(reader, from, setter) {
|
|
6242
|
+
const { text } = reader;
|
|
6243
|
+
let depth = 2;
|
|
6244
|
+
let pos = from;
|
|
6245
|
+
while (pos < reader.limit) {
|
|
6246
|
+
const ch = text[pos];
|
|
6247
|
+
pos += 1;
|
|
6248
|
+
switch (ch) {
|
|
6249
|
+
case "\n": return;
|
|
6250
|
+
case "\"":
|
|
6251
|
+
case "'": {
|
|
6252
|
+
if (ch === "'" && !setter) break;
|
|
6253
|
+
const end = readQuoted(reader, pos, ch);
|
|
6254
|
+
if (end === void 0) return;
|
|
6255
|
+
pos = end;
|
|
6256
|
+
break;
|
|
6257
|
+
}
|
|
6258
|
+
case "[":
|
|
6259
|
+
depth += 1;
|
|
6260
|
+
break;
|
|
6261
|
+
case "]":
|
|
6262
|
+
depth -= 1;
|
|
6263
|
+
if (depth === 1) return componentEnd(text, pos);
|
|
6264
|
+
}
|
|
6265
|
+
}
|
|
6266
|
+
}
|
|
6267
|
+
/** Reads a setter, the last component; returns the index after its closing `]]`. */
|
|
6268
|
+
function readSetter(reader, from) {
|
|
6269
|
+
const end = readComponent(reader, from, true);
|
|
6270
|
+
return end?.last === true ? end.next : void 0;
|
|
6271
|
+
}
|
|
6272
|
+
/** What the `]` just before `pos`, which brings the depth back to one, ends; `undefined` if malformed. */
|
|
6273
|
+
function componentEnd(text, pos) {
|
|
6274
|
+
switch (text[pos]) {
|
|
6275
|
+
case "[": return {
|
|
6276
|
+
textEnd: pos - 1,
|
|
6277
|
+
next: pos + 1,
|
|
6278
|
+
last: false
|
|
6279
|
+
};
|
|
6280
|
+
case "]": return {
|
|
6281
|
+
textEnd: pos - 1,
|
|
6282
|
+
next: pos + 1,
|
|
6283
|
+
last: true
|
|
6284
|
+
};
|
|
6285
|
+
default: return;
|
|
6286
|
+
}
|
|
6287
|
+
}
|
|
6288
|
+
/**
|
|
6289
|
+
* Reads a quoted string from just after its opening quote; a backslash escapes the character
|
|
6290
|
+
* after it. A line feed, or the end of the text, ends it unterminated. Returns the index after the
|
|
6291
|
+
* closing quote.
|
|
6292
|
+
*/
|
|
6293
|
+
function readQuoted(reader, from, quote) {
|
|
6294
|
+
const { text } = reader;
|
|
6295
|
+
let pos = from;
|
|
6296
|
+
while (pos < reader.limit) {
|
|
6297
|
+
const ch = text[pos];
|
|
6298
|
+
pos += 1;
|
|
6299
|
+
if (ch === quote) return pos;
|
|
6300
|
+
if (ch === "\n") return;
|
|
6301
|
+
if (ch === "\\") {
|
|
6302
|
+
if (pos >= reader.limit || text[pos] === "\n") return;
|
|
6303
|
+
pos += 1;
|
|
6304
|
+
}
|
|
6305
|
+
}
|
|
6306
|
+
}
|
|
6307
|
+
//#endregion
|
|
5695
6308
|
//#region src/sugarcube-macros.ts
|
|
5696
6309
|
/**
|
|
5697
|
-
* Reads SugarCube 2 macro calls the way SugarCube 2.37.3 does, so the link check
|
|
5698
|
-
* passage names that `<<link>>` and `<<goto>>` use when the story
|
|
6310
|
+
* Reads SugarCube 2 macro calls and link markup the way SugarCube 2.37.3 does, so the link check
|
|
6311
|
+
* sees the same passage names that `[[…]]` links, `<<link>>` and `<<goto>>` use when the story
|
|
6312
|
+
* plays.
|
|
5699
6313
|
*
|
|
5700
6314
|
* Each step follows a part of SugarCube's macro parser:
|
|
5701
6315
|
* - `findMacroTags` finds where a tag starts and ends (the parser's `lookahead` pattern);
|
|
5702
6316
|
* - `parseMacroArgs` splits the text after the macro name into arguments (`parseArgs`);
|
|
5703
6317
|
* - quoted arguments are evaluated as strict-mode JavaScript strings (`evalStringLiteral`).
|
|
5704
6318
|
*
|
|
6319
|
+
* Link and image markup is read as `parseSquareBracketedMarkup` reads it (see `link-markup.ts`).
|
|
5705
6320
|
* Of the rest of the wikifier, only comments, `<script>` elements and `<<script>>` bodies are
|
|
5706
|
-
* told apart; other markup, such as verbatim text, is read as markup (see
|
|
5707
|
-
* `findMacroPassageLinks`).
|
|
6321
|
+
* told apart; other markup, such as verbatim text, is read as markup (see `findPassageLinks`).
|
|
5708
6322
|
*/
|
|
5709
6323
|
const NAME_RE = /\/?[A-Za-z][\w-]*|[=-]/y;
|
|
5710
6324
|
const SPACES_RE = /\s*/y;
|
|
@@ -5749,17 +6363,22 @@ const RAW_ARGUMENT_MACROS = /* @__PURE__ */ new Set([
|
|
|
5749
6363
|
* levels; the limit keeps the work linear in the size of the text on any input.
|
|
5750
6364
|
*/
|
|
5751
6365
|
const MAX_STRING_DEPTH = 10;
|
|
5752
|
-
/**
|
|
6366
|
+
/**
|
|
6367
|
+
* A context for reading `text`: the closer search, and the markup reading, may each read four
|
|
6368
|
+
* times it, plus an allowance.
|
|
6369
|
+
*/
|
|
5753
6370
|
function readContext(text) {
|
|
6371
|
+
const allowance = () => ({ left: 4 * text.length + 1e5 });
|
|
5754
6372
|
return {
|
|
5755
6373
|
depth: 0,
|
|
5756
|
-
budget:
|
|
6374
|
+
budget: allowance(),
|
|
6375
|
+
markupBudget: allowance()
|
|
5757
6376
|
};
|
|
5758
6377
|
}
|
|
5759
6378
|
function deeper(context) {
|
|
5760
6379
|
return {
|
|
5761
|
-
|
|
5762
|
-
|
|
6380
|
+
...context,
|
|
6381
|
+
depth: context.depth + 1
|
|
5763
6382
|
};
|
|
5764
6383
|
}
|
|
5765
6384
|
/** Index of the first match of a global `re` at or after `from`, or -1. */
|
|
@@ -5989,7 +6608,10 @@ function lexArgument(raw, pos) {
|
|
|
5989
6608
|
case "[": {
|
|
5990
6609
|
const end = lexSquareBracketed(raw, pos + 1);
|
|
5991
6610
|
return end === void 0 ? void 0 : {
|
|
5992
|
-
arg: {
|
|
6611
|
+
arg: {
|
|
6612
|
+
kind: "markup",
|
|
6613
|
+
text: raw.slice(pos, end)
|
|
6614
|
+
},
|
|
5993
6615
|
end
|
|
5994
6616
|
};
|
|
5995
6617
|
}
|
|
@@ -6133,13 +6755,44 @@ function ownPassageLink(tag, lexed) {
|
|
|
6133
6755
|
const arg = passageArgument(tag.name, lexed);
|
|
6134
6756
|
const passage = arg === void 0 ? void 0 : passageName(arg);
|
|
6135
6757
|
return passage === void 0 ? void 0 : {
|
|
6136
|
-
|
|
6758
|
+
via: tag.name,
|
|
6137
6759
|
passage
|
|
6138
6760
|
};
|
|
6139
6761
|
}
|
|
6140
6762
|
/**
|
|
6141
|
-
* The
|
|
6142
|
-
*
|
|
6763
|
+
* The passage a link's destination names. SugarCube evaluates a destination that no passage is
|
|
6764
|
+
* named by, and a quoted string gives its value (`[[Go|"Room"]]`), unless that is blank.
|
|
6765
|
+
*/
|
|
6766
|
+
function linkDestination(link) {
|
|
6767
|
+
const value = evalStringLiteral(link);
|
|
6768
|
+
return value === void 0 || value.trim() === "" ? link : value;
|
|
6769
|
+
}
|
|
6770
|
+
/**
|
|
6771
|
+
* The passage that link markup from `start` in `text` names, and where the markup ends, or
|
|
6772
|
+
* `undefined` where SugarCube rejects the markup. Image markup names none here, nor does markup
|
|
6773
|
+
* with a template literal's `${…}` in it, whose passage is known only in play.
|
|
6774
|
+
*/
|
|
6775
|
+
function linkMarkupAt(text, start, budget) {
|
|
6776
|
+
const markup = readSquareBracketedMarkup(text, start, budget);
|
|
6777
|
+
if (markup === void 0) return;
|
|
6778
|
+
const passage = markup.type === "link" && !text.slice(start, markup.end).includes("") ? markup.link : void 0;
|
|
6779
|
+
return {
|
|
6780
|
+
link: passage === void 0 ? void 0 : {
|
|
6781
|
+
via: "markup",
|
|
6782
|
+
passage: linkDestination(passage)
|
|
6783
|
+
},
|
|
6784
|
+
end: markup.end
|
|
6785
|
+
};
|
|
6786
|
+
}
|
|
6787
|
+
/** The passage that a link markup argument names, if SugarCube reads the whole argument as markup. */
|
|
6788
|
+
function markupArgumentLinks(raw) {
|
|
6789
|
+
const markup = linkMarkupAt(raw, 0);
|
|
6790
|
+
return markup?.link === void 0 || markup.end !== raw.length ? [] : [markup.link];
|
|
6791
|
+
}
|
|
6792
|
+
/**
|
|
6793
|
+
* The passages a tag names: its own, if it is a `<<link>>` or `<<goto>>` call, then those of its
|
|
6794
|
+
* link markup arguments (`<<button [[Go|Room]]>>`), and those of the links and calls inside the
|
|
6795
|
+
* strings of its arguments, which the macro may print or pass on
|
|
6143
6796
|
* (`<<set _out to '<<link "Go" "Room">><</link>>'>>`). The arguments of the macros SugarCube
|
|
6144
6797
|
* doesn't split are read as JavaScript; in others, a backquoted expression is.
|
|
6145
6798
|
*/
|
|
@@ -6152,7 +6805,7 @@ function tagPassageLinks(tag, context) {
|
|
|
6152
6805
|
switch (arg.kind) {
|
|
6153
6806
|
case "string": return markupPassageLinks(arg.value, deeper(context));
|
|
6154
6807
|
case "expression": return javaScriptPassageLinks(arg.code, context);
|
|
6155
|
-
case "markup":
|
|
6808
|
+
case "markup": return markupArgumentLinks(arg.text);
|
|
6156
6809
|
case "word": return [];
|
|
6157
6810
|
default: throw new Error(`unhandled macro argument: ${JSON.stringify(arg)}`);
|
|
6158
6811
|
}
|
|
@@ -6162,16 +6815,26 @@ function tagPassageLinks(tag, context) {
|
|
|
6162
6815
|
/**
|
|
6163
6816
|
* The passage named by a `<<link>>` or `<<goto>>` that appears unquoted inside another tag's
|
|
6164
6817
|
* arguments, at `start`. SugarCube never sees such an outer "tag" when its `<<` lies in text it
|
|
6165
|
-
* reads as something else, such as
|
|
6166
|
-
*
|
|
6818
|
+
* reads as something else, such as verbatim text (`{{{<<if}}}`); the call inside it then runs.
|
|
6819
|
+
* The call ends where the outer tag ends.
|
|
6167
6820
|
*/
|
|
6168
6821
|
function innerPassageLink(matchTagAt, start) {
|
|
6169
6822
|
const inner = matchTagAt(start)?.tag;
|
|
6170
6823
|
const lexed = inner === void 0 ? void 0 : lexMacroArgs(inner.args);
|
|
6171
6824
|
return inner === void 0 || lexed === void 0 ? void 0 : ownPassageLink(inner, lexed);
|
|
6172
6825
|
}
|
|
6173
|
-
const REGION_OPEN_RE =
|
|
6826
|
+
const REGION_OPEN_RE = /\[\[[^[]|\[[<>]?[Ii][Mm][Gg]\[|\/\/|\/\*|\/%|<!--|<[Ss][Cc][Rr][Ii][Pp][Tt]/g;
|
|
6174
6827
|
const HAS_SCRIPT_OPEN_RE = /<[Ss][Cc][Rr][Ii][Pp][Tt]/;
|
|
6828
|
+
function regionKind(opener) {
|
|
6829
|
+
if (opener.startsWith("[")) return "markup";
|
|
6830
|
+
switch (opener) {
|
|
6831
|
+
case "//":
|
|
6832
|
+
case "/*":
|
|
6833
|
+
case "/%":
|
|
6834
|
+
case "<!--": return opener;
|
|
6835
|
+
default: return "script";
|
|
6836
|
+
}
|
|
6837
|
+
}
|
|
6175
6838
|
const SCRIPT_CLOSE_RE = /<\/[Ss][Cc][Rr][Ii][Pp][Tt]>|[\r\u2028\u2029]/g;
|
|
6176
6839
|
const COMMENT_CLOSE_RES = {
|
|
6177
6840
|
"/*": /\*\/|[\r\u2028\u2029]/g,
|
|
@@ -6325,22 +6988,31 @@ function append(links, more) {
|
|
|
6325
6988
|
for (const link of more) links.push(link);
|
|
6326
6989
|
}
|
|
6327
6990
|
/**
|
|
6328
|
-
* The passages that `<<link>>` and `<<goto>>` calls in passage markup
|
|
6329
|
-
* is known only in play (a variable or an
|
|
6330
|
-
*
|
|
6991
|
+
* The passages that link markup (`[[…]]`) and `<<link>>` and `<<goto>>` calls in passage markup
|
|
6992
|
+
* name, in the order they come. Calls whose passage is known only in play (a variable or an
|
|
6993
|
+
* expression) are left out. A tag's own passage comes before those that its arguments name, as
|
|
6994
|
+
* link markup or inside strings.
|
|
6331
6995
|
*
|
|
6332
6996
|
* The markup is walked in order, as SugarCube's wikifier walks it, with a tag tried at each
|
|
6333
|
-
* `<<` the walk reaches
|
|
6334
|
-
*
|
|
6335
|
-
*
|
|
6336
|
-
*
|
|
6337
|
-
*
|
|
6997
|
+
* `<<` the walk reaches, and link or image markup read at each `[[` or `[img[`; markup SugarCube
|
|
6998
|
+
* rejects is read on from just after its opener, and so is markup after a limit on how much
|
|
6999
|
+
* markup a reading may scan, which only input built to take quadratic time reaches (see
|
|
7000
|
+
* `readSquareBracketedMarkup`). Image markup names no passage here. Of the wikifier's other
|
|
7001
|
+
* parsers, only those whose text holds no link or call are told apart: comments (`/* … *` + `/`,
|
|
7002
|
+
* `/% … %/`, `<!-- … -->`), whose links and calls never run, and `<script>` elements and
|
|
7003
|
+
* `<<script>>` bodies, which are JavaScript: only the links and calls in their strings are read
|
|
7004
|
+
* (see `findJavaScriptPassageLinks`). Everything else, verbatim text included, is read as markup.
|
|
6338
7005
|
*
|
|
6339
7006
|
* Where this differs from SugarCube:
|
|
7007
|
+
* - a link whose passage SugarCube evaluates, because no passage has its name, is taken by its
|
|
7008
|
+
* name, `[[Go|$next]]` naming the passage `$next`, unless it is a quoted string, which is taken
|
|
7009
|
+
* by its value even when a passage has the name with the quotes;
|
|
7010
|
+
* - a link SugarCube takes for a URL, because no passage has its name and it looks like one or
|
|
7011
|
+
* holds a `/`, `.`, `?` or `#`, is still read as naming a passage;
|
|
6340
7012
|
* - a comment or a `<<script>>` body is skipped whole, even where `parseBody` ends a container
|
|
6341
7013
|
* such as `<<if>>` at a closing tag inside it and the rest then runs;
|
|
6342
|
-
* - a `/*`, `<!--` or `<script` inside other markup that holds no call, such as
|
|
6343
|
-
* `<style>` element, still starts a comment or an element;
|
|
7014
|
+
* - a `[[`, `/*`, `<!--` or `<script` inside other markup that holds no call, such as verbatim
|
|
7015
|
+
* text or a `<style>` element, still starts link markup, a comment or an element;
|
|
6344
7016
|
* - a `<<` in such other markup still starts a tag. When that tag runs over a `<<link` or
|
|
6345
7017
|
* `<<goto` outside the parts of its arguments that the tag pattern reads as units, that call
|
|
6346
7018
|
* is still read (see `innerPassageLink`), even where the tag's arguments would be JavaScript,
|
|
@@ -6355,33 +7027,43 @@ function append(links, more) {
|
|
|
6355
7027
|
* `Config.passages.nobr`, are not joined before they are read (see `storyInspect` for passages
|
|
6356
7028
|
* tagged `nobr`).
|
|
6357
7029
|
*/
|
|
6358
|
-
function
|
|
7030
|
+
function findPassageLinks(text) {
|
|
6359
7031
|
return markupPassageLinks(text, readContext(text));
|
|
6360
7032
|
}
|
|
6361
7033
|
function markupPassageLinks(text, context) {
|
|
6362
|
-
if (context.depth > MAX_STRING_DEPTH || !text.includes("<<") && !HAS_SCRIPT_OPEN_RE.test(text)) return [];
|
|
7034
|
+
if (context.depth > MAX_STRING_DEPTH || !text.includes("<<") && !text.includes("[[") && !HAS_SCRIPT_OPEN_RE.test(text)) return [];
|
|
6363
7035
|
const matchTagAt = tagMatcher(text);
|
|
6364
7036
|
const matchInnerTagAt = tagMatcher(text);
|
|
6365
7037
|
const readComment = commentReader(text);
|
|
6366
7038
|
const readScriptElement = scriptElementReader(text);
|
|
6367
7039
|
let findScriptCloser;
|
|
6368
7040
|
const links = [];
|
|
6369
|
-
/**
|
|
7041
|
+
/**
|
|
7042
|
+
* Reads the region `opener` opens, adding the passage that link markup names and the links and
|
|
7043
|
+
* calls in a `<script>` element; returns where the walk goes on.
|
|
7044
|
+
*/
|
|
6370
7045
|
const regionEnd = (opener) => {
|
|
6371
7046
|
const start = opener.index;
|
|
6372
|
-
const kind = opener[0];
|
|
7047
|
+
const kind = regionKind(opener[0]);
|
|
6373
7048
|
switch (kind) {
|
|
7049
|
+
case "markup": {
|
|
7050
|
+
const markup = linkMarkupAt(text, start, context.markupBudget);
|
|
7051
|
+
if (markup === void 0) return start + opener[0].length;
|
|
7052
|
+
if (markup.link !== void 0) links.push(markup.link);
|
|
7053
|
+
return markup.end;
|
|
7054
|
+
}
|
|
6374
7055
|
case "//": return start + 2;
|
|
6375
7056
|
case "/*":
|
|
6376
7057
|
case "/%":
|
|
6377
7058
|
case "<!--": return readComment(kind, start) ?? start + kind.length;
|
|
6378
|
-
|
|
7059
|
+
case "script": {
|
|
6379
7060
|
const element = readScriptElement(start);
|
|
6380
7061
|
if (element.openerEnd === void 0) return start + 1;
|
|
6381
7062
|
if (element.close === void 0) return element.openerEnd;
|
|
6382
7063
|
append(links, javaScriptPassageLinks(text.slice(element.openerEnd, element.close), context));
|
|
6383
7064
|
return element.close + 9;
|
|
6384
7065
|
}
|
|
7066
|
+
default: throw new Error(`unhandled region: ${String(kind)}`);
|
|
6385
7067
|
}
|
|
6386
7068
|
};
|
|
6387
7069
|
let macro = -2;
|
|
@@ -6421,24 +7103,26 @@ function markupPassageLinks(text, context) {
|
|
|
6421
7103
|
}
|
|
6422
7104
|
}
|
|
6423
7105
|
/**
|
|
6424
|
-
* The passages that `<<link>>` and `<<goto>>` calls in the strings of JavaScript
|
|
6425
|
-
* such as a script passage or a `<<script>>` body: `$.wiki('<<goto "Room">>')`.
|
|
6426
|
-
* the strings isn't markup, so a call put together while the story plays, such as
|
|
6427
|
-
* `'<<goto "' + target + '">>'`, names no passage here.
|
|
7106
|
+
* The passages that link markup and `<<link>>` and `<<goto>>` calls in the strings of JavaScript
|
|
7107
|
+
* source name, such as a script passage or a `<<script>>` body: `$.wiki('<<goto "Room">>')`.
|
|
7108
|
+
* Code outside the strings isn't markup, so a call put together while the story plays, such as
|
|
7109
|
+
* `'<<goto "' + target + '">>'`, names no passage here, nor does an array of arrays (`[[0, 1]]`).
|
|
6428
7110
|
*/
|
|
6429
7111
|
function findJavaScriptPassageLinks(source) {
|
|
6430
7112
|
return javaScriptPassageLinks(source, readContext(source));
|
|
6431
7113
|
}
|
|
6432
7114
|
/**
|
|
6433
7115
|
* What JavaScript source must contain for one of its string or template literals to hold a call
|
|
6434
|
-
* that is read. A value holding `<<`: each `<` in it is written as `<`, `\<`,
|
|
6435
|
-
* `\u{…}`, two can be next to each other with only line continuations
|
|
6436
|
-
* is `<` or `\<` (then the source holds `<<` or `<\`) or one of the others.
|
|
6437
|
-
*
|
|
7116
|
+
* or link markup that is read. A value holding `<<`: each `<` in it is written as `<`, `\<`,
|
|
7117
|
+
* `\x3c`, `\u003c` or `\u{…}`, two can be next to each other with only line continuations
|
|
7118
|
+
* between, and the first is `<` or `\<` (then the source holds `<<` or `<\`) or one of the others.
|
|
7119
|
+
* A value holding `[[`, likewise: the source holds `[[`, a `[` before `\[` or a line continuation,
|
|
7120
|
+
* `\x5b`, `\u005b` or `\u{`. Or a value holding a `<script>` element, whose own strings can make
|
|
7121
|
+
* `<<` or `[[` from escapes the outer string encodes.
|
|
6438
7122
|
*/
|
|
6439
|
-
const
|
|
7123
|
+
const MAY_HOLD_LINK_RE = /<<|<\\|\\x3c|\\u003c|\\u\{|<script|\[\[|\[\\[[\n\r\u2028\u2029]|\\x5b|\\u005b/i;
|
|
6440
7124
|
function javaScriptPassageLinks(source, context) {
|
|
6441
|
-
if (context.depth >= MAX_STRING_DEPTH || !
|
|
7125
|
+
if (context.depth >= MAX_STRING_DEPTH || !MAY_HOLD_LINK_RE.test(source)) return [];
|
|
6442
7126
|
const inner = deeper(context);
|
|
6443
7127
|
const links = [];
|
|
6444
7128
|
for (const value of javaScriptStrings(source)) append(links, markupPassageLinks(value, inner));
|
|
@@ -6447,43 +7131,34 @@ function javaScriptPassageLinks(source, context) {
|
|
|
6447
7131
|
//#endregion
|
|
6448
7132
|
//#region src/inspect.ts
|
|
6449
7133
|
/**
|
|
6450
|
-
* Extract
|
|
7134
|
+
* Extract the passages that passage text links to, read as SugarCube 2 reads them.
|
|
6451
7135
|
*
|
|
6452
7136
|
* Supported syntaxes:
|
|
6453
7137
|
* - `[[PassageName]]`
|
|
6454
|
-
* - `[[Display Text
|
|
6455
|
-
* - `[[Display Text
|
|
6456
|
-
* -
|
|
7138
|
+
* - `[[Display Text|PassageName]]`
|
|
7139
|
+
* - `[[Display Text->PassageName]]`
|
|
7140
|
+
* - `[[PassageName<-Display Text]]`
|
|
7141
|
+
* - any of those with a setter: `[[Display Text|PassageName][$x to 1]]`
|
|
7142
|
+
* - `<<goto "PassageName">>` (SugarCube macro)
|
|
6457
7143
|
* - `<<link "Display" "PassageName">>` (SugarCube macro)
|
|
6458
7144
|
*
|
|
6459
|
-
*
|
|
6460
|
-
*
|
|
6461
|
-
*
|
|
6462
|
-
*
|
|
6463
|
-
*
|
|
6464
|
-
*
|
|
7145
|
+
* In link markup, the first `|`, `->` or `<-` divides the text from the passage name; image
|
|
7146
|
+
* markup (`[img[…][PassageName]]`) is not read. SugarCube macro arguments may be double- or
|
|
7147
|
+
* single-quoted, with backslash escapes, or bare words; a passage named by a variable or an
|
|
7148
|
+
* expression is known only in play and is skipped. Links and macro calls are also read inside the
|
|
7149
|
+
* quoted strings of macros' arguments, and inside the strings of `<<script>>` bodies and
|
|
7150
|
+
* `<script>` elements; links and calls in comments are not read. A script passage (`isScript`) is
|
|
7151
|
+
* JavaScript, so its links and calls are read only inside its strings. See `sugarcube-macros.ts`
|
|
7152
|
+
* for the details and the known differences from SugarCube.
|
|
6465
7153
|
*/
|
|
6466
7154
|
function extractLinksFromText(text, isScript) {
|
|
6467
|
-
const
|
|
6468
|
-
const
|
|
6469
|
-
|
|
6470
|
-
|
|
6471
|
-
|
|
6472
|
-
|
|
6473
|
-
|
|
6474
|
-
targets.add(content.slice(arrowIdx + 2).trim());
|
|
6475
|
-
continue;
|
|
6476
|
-
}
|
|
6477
|
-
const pipeIdx = content.indexOf("|");
|
|
6478
|
-
if (pipeIdx !== -1) {
|
|
6479
|
-
targets.add(content.slice(pipeIdx + 1).trim());
|
|
6480
|
-
continue;
|
|
6481
|
-
}
|
|
6482
|
-
targets.add(content.trim());
|
|
6483
|
-
}
|
|
6484
|
-
const macroLinks = isScript ? findJavaScriptPassageLinks(text) : findMacroPassageLinks(text);
|
|
6485
|
-
for (const macro of ["goto", "link"]) for (const link of macroLinks) if (link.macro === macro) targets.add(link.passage);
|
|
6486
|
-
return [...targets];
|
|
7155
|
+
const found = isScript ? findJavaScriptPassageLinks(text) : findPassageLinks(text);
|
|
7156
|
+
const ordered = [
|
|
7157
|
+
"markup",
|
|
7158
|
+
"goto",
|
|
7159
|
+
"link"
|
|
7160
|
+
].flatMap((via) => found.filter((link) => link.via === via).map((link) => link.passage));
|
|
7161
|
+
return [...new Set(ordered)];
|
|
6487
7162
|
}
|
|
6488
7163
|
/** Passages SugarCube runs from their raw text, never joining their lines. */
|
|
6489
7164
|
const RAW_TEXT_PASSAGES = /* @__PURE__ */ new Set([
|
|
@@ -6586,7 +7261,8 @@ function storyInspect(story) {
|
|
|
6586
7261
|
//#region src/lint.ts
|
|
6587
7262
|
/**
|
|
6588
7263
|
* Lint a story: compile without output rendering, then inspect structure.
|
|
6589
|
-
* Uses JSON output mode internally to avoid format resolution
|
|
7264
|
+
* Uses JSON output mode internally to avoid format resolution, so it checks
|
|
7265
|
+
* the starting passage itself, against the Twine 2 passage rules.
|
|
6590
7266
|
*/
|
|
6591
7267
|
async function lint(options) {
|
|
6592
7268
|
const result = await compile({
|
|
@@ -6595,7 +7271,7 @@ async function lint(options) {
|
|
|
6595
7271
|
});
|
|
6596
7272
|
const map = storyInspect(result.story);
|
|
6597
7273
|
return {
|
|
6598
|
-
diagnostics: result.diagnostics,
|
|
7274
|
+
diagnostics: [...result.diagnostics, ...startPassageDiagnostics(result.story, map.start, "twine2")],
|
|
6599
7275
|
stats: result.stats,
|
|
6600
7276
|
formatName: result.story.twine2.format,
|
|
6601
7277
|
formatVersion: result.story.twine2.formatVersion,
|
|
@@ -6721,6 +7397,7 @@ function validateConfig(data) {
|
|
|
6721
7397
|
]) if (key in obj && typeof obj[key] !== "boolean") errors.push(`"${key}" must be a boolean.`);
|
|
6722
7398
|
for (const key of [
|
|
6723
7399
|
"sources",
|
|
7400
|
+
"exclude",
|
|
6724
7401
|
"formatPaths",
|
|
6725
7402
|
"formatIndices",
|
|
6726
7403
|
"formatUrls",
|