@rohal12/twee-ts 1.16.1 → 1.17.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/README.md +1 -1
- package/dist/bin/twee-ts.js +505 -207
- package/dist/bin/twee-ts.js.map +1 -1
- package/dist/index.cjs +513 -190
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +82 -11
- package/dist/index.d.ts +82 -11
- package/dist/index.js +513 -190
- package/dist/index.js.map +1 -1
- package/dist/{compiler-Bmpbelqj.js → paths-w4ulhCFb.js} +392 -162
- package/dist/paths-w4ulhCFb.js.map +1 -0
- package/dist/plugins/rollup.d.ts +25 -2
- package/dist/plugins/rollup.js +53 -12
- package/dist/plugins/rollup.js.map +1 -1
- package/dist/plugins/vite.js +37 -62
- package/dist/plugins/vite.js.map +1 -1
- package/package.json +2 -1
- package/dist/compiler-Bmpbelqj.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -13,11 +13,27 @@ import { fileURLToPath } from "node:url";
|
|
|
13
13
|
function generateIFID() {
|
|
14
14
|
return randomUUID().toUpperCase();
|
|
15
15
|
}
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Validate and brand an IFID string. Throws on invalid input.
|
|
18
|
+
* Returns the IFID in its stored form (see `normalizeIFID`).
|
|
19
|
+
*/
|
|
17
20
|
function createIFID(value) {
|
|
18
21
|
const err = validateIFID(value);
|
|
19
22
|
if (err) throw new Error(`Invalid IFID: ${err}`);
|
|
20
|
-
return value
|
|
23
|
+
return normalizeIFID(value);
|
|
24
|
+
}
|
|
25
|
+
/** Length of the `UUID://...//` wrapper around a 36-character UUID. */
|
|
26
|
+
const WRAPPED_LENGTH = 45;
|
|
27
|
+
/**
|
|
28
|
+
* Bring an IFID into the form the story model stores and every output writes: the uppercase
|
|
29
|
+
* bare UUID. A valid `UUID://...//` wrapped IFID loses its wrapper; outputs that need the wrapper
|
|
30
|
+
* (the Treaty of Babel comment) add exactly one. Twine 2 IFIDs use only capital letters, so the
|
|
31
|
+
* value is uppercased, as Tweego does. An invalid value is only uppercased, so that validation
|
|
32
|
+
* reports it as written.
|
|
33
|
+
*/
|
|
34
|
+
function normalizeIFID(value) {
|
|
35
|
+
const upper = value.toUpperCase();
|
|
36
|
+
return upper.length === WRAPPED_LENGTH && validateIFID(upper) === null ? upper.slice(7, 43) : upper;
|
|
21
37
|
}
|
|
22
38
|
/**
|
|
23
39
|
* Validate an IFID string.
|
|
@@ -447,7 +463,7 @@ function unmarshalStoryData(story, json) {
|
|
|
447
463
|
}
|
|
448
464
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return "Cannot unmarshal \"StoryData\"; expected a JSON object";
|
|
449
465
|
const data = raw;
|
|
450
|
-
if (typeof data.ifid === "string" && data.ifid) story.ifid = data.ifid
|
|
466
|
+
if (typeof data.ifid === "string" && data.ifid) story.ifid = normalizeIFID(data.ifid);
|
|
451
467
|
if (typeof data.format === "string" && data.format) story.twine2.format = data.format;
|
|
452
468
|
if (typeof data["format-version"] === "string" && data["format-version"]) story.twine2.formatVersion = data["format-version"];
|
|
453
469
|
if (Array.isArray(data.options)) {
|
|
@@ -478,7 +494,7 @@ function unmarshalStorySettings(story, text, diagnostics) {
|
|
|
478
494
|
const val = line.slice(colonIdx + 1).trim().toLowerCase();
|
|
479
495
|
switch (key) {
|
|
480
496
|
case "ifid":
|
|
481
|
-
if (validateIFID(val) === null) story.legacyIFID = val
|
|
497
|
+
if (validateIFID(val) === null) story.legacyIFID = normalizeIFID(val);
|
|
482
498
|
obsolete.push("\"ifid\"");
|
|
483
499
|
continue;
|
|
484
500
|
case "zoom":
|
|
@@ -4696,14 +4712,16 @@ function parseDocument(data, options) {
|
|
|
4696
4712
|
*/
|
|
4697
4713
|
/**
|
|
4698
4714
|
* Parse a Twine 2 or Twine 1 compiled HTML file back into a Story model.
|
|
4715
|
+
* Tweego always trims passage text; here `trim: false` keeps it exactly, as for Twee sources.
|
|
4699
4716
|
*/
|
|
4700
|
-
function decompileHTML(html) {
|
|
4717
|
+
function decompileHTML(html, options = {}) {
|
|
4701
4718
|
const diagnostics = [];
|
|
4702
4719
|
const story = createStory();
|
|
4720
|
+
const passageText = options.trim === false ? (text) => text : (text) => text.trim();
|
|
4703
4721
|
const doc = parseDocument(html);
|
|
4704
4722
|
const twine2Data = findElement(doc, "tw-storydata");
|
|
4705
4723
|
if (twine2Data) {
|
|
4706
|
-
decompileTwine2(twine2Data, story, diagnostics);
|
|
4724
|
+
decompileTwine2(twine2Data, story, passageText, diagnostics);
|
|
4707
4725
|
return {
|
|
4708
4726
|
story,
|
|
4709
4727
|
diagnostics
|
|
@@ -4711,7 +4729,7 @@ function decompileHTML(html) {
|
|
|
4711
4729
|
}
|
|
4712
4730
|
const twine1Data = findElementByIdPattern(doc, /^store(?:-a|A)rea$/);
|
|
4713
4731
|
if (twine1Data) {
|
|
4714
|
-
decompileTwine1(twine1Data, story, diagnostics);
|
|
4732
|
+
decompileTwine1(twine1Data, story, passageText, diagnostics);
|
|
4715
4733
|
return {
|
|
4716
4734
|
story,
|
|
4717
4735
|
diagnostics
|
|
@@ -4726,7 +4744,7 @@ function decompileHTML(html) {
|
|
|
4726
4744
|
diagnostics
|
|
4727
4745
|
};
|
|
4728
4746
|
}
|
|
4729
|
-
function decompileTwine2(storyData, story, diagnostics) {
|
|
4747
|
+
function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
4730
4748
|
let startnode = 0;
|
|
4731
4749
|
const attrs = storyData.attribs ?? {};
|
|
4732
4750
|
if (attrs["name"]) story.name = attrs["name"];
|
|
@@ -4738,7 +4756,7 @@ function decompileTwine2(storyData, story, diagnostics) {
|
|
|
4738
4756
|
});
|
|
4739
4757
|
else startnode = parsed;
|
|
4740
4758
|
}
|
|
4741
|
-
if (attrs["ifid"]) story.ifid = attrs["ifid"]
|
|
4759
|
+
if (attrs["ifid"]) story.ifid = normalizeIFID(attrs["ifid"]);
|
|
4742
4760
|
if (attrs["zoom"]) {
|
|
4743
4761
|
const parsed = parseFloat(attrs["zoom"]);
|
|
4744
4762
|
if (Number.isNaN(parsed)) diagnostics.push({
|
|
@@ -4751,15 +4769,20 @@ function decompileTwine2(storyData, story, diagnostics) {
|
|
|
4751
4769
|
if (attrs["format"]) story.twine2.format = attrs["format"];
|
|
4752
4770
|
if (attrs["format-version"]) story.twine2.formatVersion = attrs["format-version"];
|
|
4753
4771
|
if (attrs["options"]) for (const opt of attrs["options"].split(/\s+/).filter((s) => s.length > 0)) story.twine2.options.set(opt, true);
|
|
4754
|
-
|
|
4772
|
+
const children = storyData.children ?? [];
|
|
4773
|
+
const takenNames = new Set(children.filter((node) => node.type === "tag" && node.name === "tw-passagedata").map(passageDataName));
|
|
4774
|
+
for (const node of children) {
|
|
4755
4775
|
if (node.type !== "tag" && node.type !== "style" && node.type !== "script") continue;
|
|
4756
4776
|
switch (node.name) {
|
|
4757
4777
|
case "style":
|
|
4758
4778
|
case "script": {
|
|
4759
|
-
const
|
|
4760
|
-
if (
|
|
4779
|
+
const content = getTextContent(node);
|
|
4780
|
+
if (content.trim().length === 0) continue;
|
|
4781
|
+
const text = passageText(content);
|
|
4782
|
+
const name = freeName(node.name === "style" ? "Story Stylesheet" : "Story JavaScript", takenNames);
|
|
4783
|
+
takenNames.add(name);
|
|
4761
4784
|
storyAdd(story, {
|
|
4762
|
-
name
|
|
4785
|
+
name,
|
|
4763
4786
|
tags: node.name === "style" ? ["stylesheet"] : ["script"],
|
|
4764
4787
|
text
|
|
4765
4788
|
}, diagnostics);
|
|
@@ -4775,7 +4798,7 @@ function decompileTwine2(storyData, story, diagnostics) {
|
|
|
4775
4798
|
case "tw-passagedata": {
|
|
4776
4799
|
let pid = 0;
|
|
4777
4800
|
const pAttrs = node.attribs ?? {};
|
|
4778
|
-
const name =
|
|
4801
|
+
const name = passageDataName(node);
|
|
4779
4802
|
const tags = pAttrs["tags"] ? pAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [];
|
|
4780
4803
|
const metadata = {};
|
|
4781
4804
|
if (pAttrs["pid"]) {
|
|
@@ -4792,7 +4815,7 @@ function decompileTwine2(storyData, story, diagnostics) {
|
|
|
4792
4815
|
const passage = {
|
|
4793
4816
|
name,
|
|
4794
4817
|
tags,
|
|
4795
|
-
text: getTextContent(node)
|
|
4818
|
+
text: passageText(getTextContent(node))
|
|
4796
4819
|
};
|
|
4797
4820
|
if (metadata.position || metadata.size) passage.metadata = metadata;
|
|
4798
4821
|
storyAdd(story, passage, diagnostics);
|
|
@@ -4806,8 +4829,19 @@ function decompileTwine2(storyData, story, diagnostics) {
|
|
|
4806
4829
|
text: marshalStoryData(story)
|
|
4807
4830
|
}, diagnostics);
|
|
4808
4831
|
}
|
|
4809
|
-
function
|
|
4810
|
-
|
|
4832
|
+
function passageDataName(node) {
|
|
4833
|
+
return node.attribs?.["name"] ?? "";
|
|
4834
|
+
}
|
|
4835
|
+
/** The first of `base`, `base 2`, `base 3`, … that is not in `taken`. */
|
|
4836
|
+
function freeName(base, taken) {
|
|
4837
|
+
if (!taken.has(base)) return base;
|
|
4838
|
+
for (let n = 2;; n++) {
|
|
4839
|
+
const candidate = `${base} ${n}`;
|
|
4840
|
+
if (!taken.has(candidate)) return candidate;
|
|
4841
|
+
}
|
|
4842
|
+
}
|
|
4843
|
+
function decompileTwine1(storeArea, story, passageText, diagnostics) {
|
|
4844
|
+
const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
|
|
4811
4845
|
const obfuscated = isRot13Obfuscated(passages);
|
|
4812
4846
|
for (const p of passages) storyAdd(story, obfuscated && p.name !== "StorySettings" ? {
|
|
4813
4847
|
...p,
|
|
@@ -4817,12 +4851,12 @@ function decompileTwine1(storeArea, story, diagnostics) {
|
|
|
4817
4851
|
function isTiddler(node) {
|
|
4818
4852
|
return node.type === "tag" && node.name === "div" && node.attribs !== void 0 && "tiddler" in node.attribs;
|
|
4819
4853
|
}
|
|
4820
|
-
function tiddlerToPassage(node) {
|
|
4854
|
+
function tiddlerToPassage(node, passageText) {
|
|
4821
4855
|
const nodeAttrs = node.attribs ?? {};
|
|
4822
4856
|
const passage = {
|
|
4823
4857
|
name: nodeAttrs["tiddler"] ?? "",
|
|
4824
4858
|
tags: nodeAttrs["tags"] ? nodeAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [],
|
|
4825
|
-
text: tiddlerUnescape(getTextContent(node))
|
|
4859
|
+
text: passageText(tiddlerUnescape(getTextContent(node)))
|
|
4826
4860
|
};
|
|
4827
4861
|
const position = nodeAttrs["twine-position"];
|
|
4828
4862
|
if (position) passage.metadata = { position };
|
|
@@ -4887,7 +4921,7 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
|
|
|
4887
4921
|
break;
|
|
4888
4922
|
case "htm":
|
|
4889
4923
|
case "html":
|
|
4890
|
-
loadHTML(story, filename, diagnostics);
|
|
4924
|
+
loadHTML(story, filename, opts, diagnostics);
|
|
4891
4925
|
break;
|
|
4892
4926
|
case "css":
|
|
4893
4927
|
loadTagged(story, "stylesheet", filename, diagnostics);
|
|
@@ -5041,8 +5075,8 @@ function parseFontFile(filename) {
|
|
|
5041
5075
|
diagnostics: []
|
|
5042
5076
|
};
|
|
5043
5077
|
}
|
|
5044
|
-
function parseHTMLFile(filename) {
|
|
5045
|
-
const { story, diagnostics } = decompileHTML(readUTF8(filename));
|
|
5078
|
+
function parseHTMLFile(filename, opts) {
|
|
5079
|
+
const { story, diagnostics } = decompileHTML(readUTF8(filename), { trim: opts.trim ?? true });
|
|
5046
5080
|
return {
|
|
5047
5081
|
passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
|
|
5048
5082
|
name: "StoryTitle",
|
|
@@ -5054,14 +5088,17 @@ function parseHTMLFile(filename) {
|
|
|
5054
5088
|
}
|
|
5055
5089
|
/**
|
|
5056
5090
|
* Identity of the options that affect how a file parses, stored on its cache entry.
|
|
5057
|
-
*
|
|
5091
|
+
* Twee files depend on `trim` and `twee2Compat`, HTML files on `trim`; every other file type
|
|
5092
|
+
* yields the same key for all options.
|
|
5058
5093
|
*/
|
|
5059
5094
|
function parseOptionsKey(filename, opts) {
|
|
5060
5095
|
const ext = normalizedFileExt(filename);
|
|
5096
|
+
const trim = opts.trim ?? true;
|
|
5097
|
+
if (ext === "htm" || ext === "html") return JSON.stringify({ trim });
|
|
5061
5098
|
const twee2File = ext === "tw2" || ext === "twee2";
|
|
5062
5099
|
if (!twee2File && ext !== "tw" && ext !== "twee") return "";
|
|
5063
5100
|
return JSON.stringify({
|
|
5064
|
-
trim
|
|
5101
|
+
trim,
|
|
5065
5102
|
twee2Compat: twee2File || (opts.twee2Compat ?? false)
|
|
5066
5103
|
});
|
|
5067
5104
|
}
|
|
@@ -5075,7 +5112,7 @@ function parseFile(filename, opts) {
|
|
|
5075
5112
|
twee2Compat: true
|
|
5076
5113
|
});
|
|
5077
5114
|
case "htm":
|
|
5078
|
-
case "html": return parseHTMLFile(filename);
|
|
5115
|
+
case "html": return parseHTMLFile(filename, opts);
|
|
5079
5116
|
case "css": return parseTaggedFile("stylesheet", filename);
|
|
5080
5117
|
case "js": return parseTaggedFile("script", filename);
|
|
5081
5118
|
case "otf":
|
|
@@ -5107,8 +5144,8 @@ function parseFile(filename, opts) {
|
|
|
5107
5144
|
default: return;
|
|
5108
5145
|
}
|
|
5109
5146
|
}
|
|
5110
|
-
function loadHTML(story, filename, diagnostics) {
|
|
5111
|
-
const result = parseHTMLFile(filename);
|
|
5147
|
+
function loadHTML(story, filename, opts, diagnostics) {
|
|
5148
|
+
const result = parseHTMLFile(filename, opts);
|
|
5112
5149
|
diagnostics.push(...result.diagnostics);
|
|
5113
5150
|
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
5114
5151
|
}
|
|
@@ -5147,7 +5184,8 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5147
5184
|
const optionsKey = parseOptionsKey(filename, opts);
|
|
5148
5185
|
const entry = cache.get(filename);
|
|
5149
5186
|
const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
|
|
5150
|
-
|
|
5187
|
+
const changed = changedFiles?.has(filename) ?? false;
|
|
5188
|
+
if (changedFiles && cached && !changed) {
|
|
5151
5189
|
diagnostics.push(...cached.diagnostics);
|
|
5152
5190
|
for (const p of cached.passages) storyAdd(story, p, diagnostics);
|
|
5153
5191
|
processedFiles.add(filename);
|
|
@@ -5159,7 +5197,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5159
5197
|
} catch {
|
|
5160
5198
|
continue;
|
|
5161
5199
|
}
|
|
5162
|
-
if (cached && cached.mtimeMs === mtimeMs) {
|
|
5200
|
+
if (cached && !changed && cached.mtimeMs === mtimeMs) {
|
|
5163
5201
|
diagnostics.push(...cached.diagnostics);
|
|
5164
5202
|
for (const p of cached.passages) storyAdd(story, p, diagnostics);
|
|
5165
5203
|
processedFiles.add(filename);
|
|
@@ -5194,6 +5232,121 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5194
5232
|
}, diagnostics);
|
|
5195
5233
|
}
|
|
5196
5234
|
//#endregion
|
|
5235
|
+
//#region src/template.ts
|
|
5236
|
+
/**
|
|
5237
|
+
* An HTML closing head tag: `</head` in any letter case, followed by whitespace, `/` or `>`, up to the next `>`.
|
|
5238
|
+
* This is how the HTML tokenizer reads an end tag; it ignores whatever sits between the tag name and the `>`.
|
|
5239
|
+
*/
|
|
5240
|
+
const CLOSING_HEAD_TAG = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>";
|
|
5241
|
+
/** The regular expression source matching exactly `text`. */
|
|
5242
|
+
function literal(text) {
|
|
5243
|
+
return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
5244
|
+
}
|
|
5245
|
+
/** Replace the `slots` placeholders found in `template`, without scanning inserted text. */
|
|
5246
|
+
function fillTemplate(template, slots) {
|
|
5247
|
+
return fillTemplateParts([{
|
|
5248
|
+
kind: "scan",
|
|
5249
|
+
text: template,
|
|
5250
|
+
slots
|
|
5251
|
+
}]);
|
|
5252
|
+
}
|
|
5253
|
+
/**
|
|
5254
|
+
* Fill the parts of a template in order, joined. A `'first'` slot replaced in one part is not replaced again in a
|
|
5255
|
+
* later part that lists the same slot object.
|
|
5256
|
+
*/
|
|
5257
|
+
function fillTemplateParts(parts) {
|
|
5258
|
+
const filled = /* @__PURE__ */ new Set();
|
|
5259
|
+
return parts.map((part) => {
|
|
5260
|
+
if (part.kind === "verbatim" || part.slots.length === 0) return part.text;
|
|
5261
|
+
const { slots } = part;
|
|
5262
|
+
const pattern = new RegExp(slots.map((slot) => `(${slot.pattern})`).join("|"), "g");
|
|
5263
|
+
return part.text.replace(pattern, (match, ...groups) => {
|
|
5264
|
+
const slot = slots.find((_, i) => groups[i] !== void 0);
|
|
5265
|
+
if (slot === void 0 || slot.occurrences === "first" && filled.has(slot)) return match;
|
|
5266
|
+
filled.add(slot);
|
|
5267
|
+
return slot.replacement(match);
|
|
5268
|
+
});
|
|
5269
|
+
}).join("");
|
|
5270
|
+
}
|
|
5271
|
+
//#endregion
|
|
5272
|
+
//#region src/modules.ts
|
|
5273
|
+
/**
|
|
5274
|
+
* Load modules and return HTML tags to inject before the closing head tag.
|
|
5275
|
+
*/
|
|
5276
|
+
function loadModules(filenames) {
|
|
5277
|
+
const processed = /* @__PURE__ */ new Set();
|
|
5278
|
+
const headTags = [];
|
|
5279
|
+
for (const filename of filenames) {
|
|
5280
|
+
if (processed.has(filename)) continue;
|
|
5281
|
+
const ext = normalizedFileExt(filename);
|
|
5282
|
+
let tag = null;
|
|
5283
|
+
switch (ext) {
|
|
5284
|
+
case "css":
|
|
5285
|
+
tag = loadModuleTagged("style", filename);
|
|
5286
|
+
break;
|
|
5287
|
+
case "js":
|
|
5288
|
+
tag = loadModuleTagged("script", filename);
|
|
5289
|
+
break;
|
|
5290
|
+
case "otf":
|
|
5291
|
+
case "ttf":
|
|
5292
|
+
case "woff":
|
|
5293
|
+
case "woff2":
|
|
5294
|
+
tag = loadModuleFont(filename);
|
|
5295
|
+
break;
|
|
5296
|
+
default: continue;
|
|
5297
|
+
}
|
|
5298
|
+
if (tag) headTags.push(tag);
|
|
5299
|
+
processed.add(filename);
|
|
5300
|
+
}
|
|
5301
|
+
return headTags.join("\n");
|
|
5302
|
+
}
|
|
5303
|
+
function loadModuleTagged(tag, filename) {
|
|
5304
|
+
const source = readUTF8(filename).trim();
|
|
5305
|
+
if (source.length === 0) return null;
|
|
5306
|
+
return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
|
|
5307
|
+
}
|
|
5308
|
+
function loadModuleFont(filename) {
|
|
5309
|
+
const source = readBase64(filename);
|
|
5310
|
+
const family = baseNameWithoutExt(filename);
|
|
5311
|
+
const idSlug = `style-module-${slugify(family)}`;
|
|
5312
|
+
const ext = normalizedFileExt(filename);
|
|
5313
|
+
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>`;
|
|
5314
|
+
}
|
|
5315
|
+
/**
|
|
5316
|
+
* Load the module tags and head file content to inject before the closing head tag, joined by newlines,
|
|
5317
|
+
* or `''` when there is nothing to inject.
|
|
5318
|
+
*/
|
|
5319
|
+
function loadHeadContent(modulePaths, headFile, diagnostics) {
|
|
5320
|
+
const parts = [];
|
|
5321
|
+
if (modulePaths.length > 0) {
|
|
5322
|
+
const modules = loadModules(modulePaths).trim();
|
|
5323
|
+
if (modules.length > 0) parts.push(modules);
|
|
5324
|
+
}
|
|
5325
|
+
if (headFile) try {
|
|
5326
|
+
const source = readUTF8(headFile).trim();
|
|
5327
|
+
if (source.length > 0) parts.push(source);
|
|
5328
|
+
} catch (e) {
|
|
5329
|
+
diagnostics?.push({
|
|
5330
|
+
level: "warning",
|
|
5331
|
+
message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
|
|
5332
|
+
});
|
|
5333
|
+
}
|
|
5334
|
+
return parts.join("\n");
|
|
5335
|
+
}
|
|
5336
|
+
/**
|
|
5337
|
+
* The template slot that inserts `content` on its own line before the first closing head tag (as Tweego does),
|
|
5338
|
+
* or `undefined` when `content` is empty. The tag may use any letter case and whitespace before its `>`, and is
|
|
5339
|
+
* kept as written.
|
|
5340
|
+
*/
|
|
5341
|
+
function headSlot(content) {
|
|
5342
|
+
if (content.length === 0) return void 0;
|
|
5343
|
+
return {
|
|
5344
|
+
pattern: CLOSING_HEAD_TAG,
|
|
5345
|
+
occurrences: "first",
|
|
5346
|
+
replacement: (tag) => `${content}\n${tag}`
|
|
5347
|
+
};
|
|
5348
|
+
}
|
|
5349
|
+
//#endregion
|
|
5197
5350
|
//#region src/version.ts
|
|
5198
5351
|
/**
|
|
5199
5352
|
* The twee-ts version, read at runtime from the package's own package.json.
|
|
@@ -5227,24 +5380,38 @@ const CREATOR_NAME$2 = "Twee-ts";
|
|
|
5227
5380
|
function toTwine2Archive(story, startName, options) {
|
|
5228
5381
|
return getTwine2DataChunk(story, startName, options) + "\n";
|
|
5229
5382
|
}
|
|
5383
|
+
/**
|
|
5384
|
+
* Fill the Twine 2 format template: every `{{STORY_NAME}}` gets the escaped story name and the first
|
|
5385
|
+
* `{{STORY_DATA}}` the story data (as Tweego does), and `head` goes before the first closing head tag. All three are
|
|
5386
|
+
* found in the template in one pass, so a story name, passage text or head content holding a placeholder or a
|
|
5387
|
+
* closing head tag stays literal.
|
|
5388
|
+
*/
|
|
5230
5389
|
function toTwine2HTML(story, format, startName, options) {
|
|
5231
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
|
|
5237
|
-
|
|
5238
|
-
|
|
5239
|
-
|
|
5240
|
-
|
|
5241
|
-
|
|
5242
|
-
|
|
5390
|
+
const name = htmlEscape(story.name);
|
|
5391
|
+
const head = headSlot(options?.head ?? "");
|
|
5392
|
+
const slots = [
|
|
5393
|
+
{
|
|
5394
|
+
pattern: literal("{{STORY_NAME}}"),
|
|
5395
|
+
occurrences: "all",
|
|
5396
|
+
replacement: () => name
|
|
5397
|
+
},
|
|
5398
|
+
{
|
|
5399
|
+
pattern: literal("{{STORY_DATA}}"),
|
|
5400
|
+
occurrences: "first",
|
|
5401
|
+
replacement: () => {
|
|
5402
|
+
return getTwine2DataChunk({
|
|
5403
|
+
...story,
|
|
5404
|
+
twine2: {
|
|
5405
|
+
...story.twine2,
|
|
5406
|
+
format: format.name,
|
|
5407
|
+
formatVersion: format.version
|
|
5408
|
+
}
|
|
5409
|
+
}, startName, options);
|
|
5243
5410
|
}
|
|
5244
|
-
},
|
|
5245
|
-
|
|
5246
|
-
|
|
5247
|
-
return
|
|
5411
|
+
},
|
|
5412
|
+
...head === void 0 ? [] : [head]
|
|
5413
|
+
];
|
|
5414
|
+
return fillTemplate(readFormatSource(format), slots);
|
|
5248
5415
|
}
|
|
5249
5416
|
const OMITTING_TAGS = [
|
|
5250
5417
|
"Twine.private",
|
|
@@ -5337,7 +5504,13 @@ function toTwine1Archive(story, _startName) {
|
|
|
5337
5504
|
const { data, count } = getTwine1PassageChunk(story);
|
|
5338
5505
|
return `<div id="storeArea" data-size="${count}">${data}</div>\n`;
|
|
5339
5506
|
}
|
|
5340
|
-
|
|
5507
|
+
/**
|
|
5508
|
+
* Fill the Twine 1 format template. The format's components are inserted first, one after another, as Tweego and
|
|
5509
|
+
* Twine 1 do. Then the first of each story placeholder (`"VERSION"`, `"TIME"`, `"START_AT"`, `"STORY_SIZE"`,
|
|
5510
|
+
* `"STORY"`), the IFID comment and `head` (before the first closing head tag) are found in one pass, so a start
|
|
5511
|
+
* passage name or story data holding a placeholder or a closing head tag stays literal.
|
|
5512
|
+
*/
|
|
5513
|
+
function toTwine1HTML(story, format, startName, options) {
|
|
5341
5514
|
const formatDir = dirname(format.filename);
|
|
5342
5515
|
const parentDir = dirname(formatDir);
|
|
5343
5516
|
let template = readFormatSource(format);
|
|
@@ -5348,28 +5521,60 @@ function toTwine1HTML(story, format, startName) {
|
|
|
5348
5521
|
template = tryReplaceComponent(template, "\"JONAH\"", join(formatDir, "code.js"), true);
|
|
5349
5522
|
if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", join(parentDir, "jquery.js"), true);
|
|
5350
5523
|
if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", join(parentDir, "modernizr.js"), true);
|
|
5524
|
+
const isPre14 = !template.includes("\"STORY\"");
|
|
5525
|
+
const footer = isPre14 ? readFooter(formatDir) : "";
|
|
5526
|
+
const storeArea = (template + footer).includes("<div id=\"store-area\"") ? "<div id=\"store-area\"" : "<div id=\"storeArea\"";
|
|
5527
|
+
const safeIfid = htmlCommentSanitize(story.ifid);
|
|
5528
|
+
const late = [story.ifid ? {
|
|
5529
|
+
pattern: literal(storeArea),
|
|
5530
|
+
occurrences: "first",
|
|
5531
|
+
replacement: (div) => `<!-- UUID://${safeIfid}// -->${div}`
|
|
5532
|
+
} : void 0, headSlot(options?.head ?? "")].filter((slot) => slot !== void 0);
|
|
5351
5533
|
const displayStart = startName === "Start" ? "" : startName;
|
|
5352
|
-
|
|
5353
|
-
|
|
5354
|
-
|
|
5355
|
-
|
|
5356
|
-
|
|
5357
|
-
|
|
5358
|
-
|
|
5359
|
-
|
|
5360
|
-
|
|
5361
|
-
|
|
5362
|
-
|
|
5363
|
-
|
|
5364
|
-
|
|
5365
|
-
|
|
5366
|
-
|
|
5367
|
-
|
|
5368
|
-
|
|
5369
|
-
|
|
5370
|
-
|
|
5534
|
+
const slots = [
|
|
5535
|
+
firstSlot("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`),
|
|
5536
|
+
firstSlot("\"TIME\"", `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`),
|
|
5537
|
+
firstSlot("\"START_AT\"", `"${jsStringEscape(displayStart)}"`),
|
|
5538
|
+
firstSlot("\"STORY_SIZE\"", `"${count}"`),
|
|
5539
|
+
firstSlot("\"STORY\"", data),
|
|
5540
|
+
...late
|
|
5541
|
+
];
|
|
5542
|
+
return fillTemplateParts(isPre14 ? [
|
|
5543
|
+
{
|
|
5544
|
+
kind: "scan",
|
|
5545
|
+
text: template,
|
|
5546
|
+
slots
|
|
5547
|
+
},
|
|
5548
|
+
{
|
|
5549
|
+
kind: "verbatim",
|
|
5550
|
+
text: data
|
|
5551
|
+
},
|
|
5552
|
+
{
|
|
5553
|
+
kind: "scan",
|
|
5554
|
+
text: footer,
|
|
5555
|
+
slots: late
|
|
5556
|
+
}
|
|
5557
|
+
] : [{
|
|
5558
|
+
kind: "scan",
|
|
5559
|
+
text: template,
|
|
5560
|
+
slots
|
|
5561
|
+
}]);
|
|
5562
|
+
}
|
|
5563
|
+
/** A slot replacing the first `token` with `value`. */
|
|
5564
|
+
function firstSlot(token, value) {
|
|
5565
|
+
return {
|
|
5566
|
+
pattern: literal(token),
|
|
5567
|
+
occurrences: "first",
|
|
5568
|
+
replacement: () => value
|
|
5569
|
+
};
|
|
5570
|
+
}
|
|
5571
|
+
/** The footer of a pre-1.4 format, or the default one when the format has none. */
|
|
5572
|
+
function readFooter(formatDir) {
|
|
5573
|
+
try {
|
|
5574
|
+
return readFileSync(join(formatDir, "footer.html"), "utf-8");
|
|
5575
|
+
} catch {
|
|
5576
|
+
return "</div>\n</body>\n</html>\n";
|
|
5371
5577
|
}
|
|
5372
|
-
return template;
|
|
5373
5578
|
}
|
|
5374
5579
|
/**
|
|
5375
5580
|
* Why Twine 1 output leaves a passage out of its tiddlers, or `undefined` when the passage is emitted.
|
|
@@ -5407,82 +5612,54 @@ function tryReplaceComponent(template, placeholder, componentPath, required) {
|
|
|
5407
5612
|
}
|
|
5408
5613
|
//#endregion
|
|
5409
5614
|
//#region src/output-twee.ts
|
|
5410
|
-
function toTwee(story, outMode) {
|
|
5411
|
-
let data = "";
|
|
5412
|
-
for (const p of story.passages) data += passageToTwee(p, outMode);
|
|
5413
|
-
return data;
|
|
5414
|
-
}
|
|
5415
|
-
//#endregion
|
|
5416
|
-
//#region src/modules.ts
|
|
5417
5615
|
/**
|
|
5418
|
-
*
|
|
5616
|
+
* Serialize the story as Twee. The StoryData passage is written from the story model rather than
|
|
5617
|
+
* from its loaded text, so changes made after loading (a start passage override, test mode) are kept.
|
|
5618
|
+
* Tweego writes the loaded text; its own normalization makes the two the same for an unchanged story.
|
|
5619
|
+
*
|
|
5620
|
+
* `addStoryData`: add a StoryData passage when the story has none, so that StoryData fields the
|
|
5621
|
+
* compile options set are recorded. Default: false.
|
|
5419
5622
|
*/
|
|
5420
|
-
function
|
|
5421
|
-
|
|
5422
|
-
const headTags = [];
|
|
5423
|
-
for (const filename of filenames) {
|
|
5424
|
-
if (processed.has(filename)) continue;
|
|
5425
|
-
const ext = normalizedFileExt(filename);
|
|
5426
|
-
let tag = null;
|
|
5427
|
-
switch (ext) {
|
|
5428
|
-
case "css":
|
|
5429
|
-
tag = loadModuleTagged("style", filename);
|
|
5430
|
-
break;
|
|
5431
|
-
case "js":
|
|
5432
|
-
tag = loadModuleTagged("script", filename);
|
|
5433
|
-
break;
|
|
5434
|
-
case "otf":
|
|
5435
|
-
case "ttf":
|
|
5436
|
-
case "woff":
|
|
5437
|
-
case "woff2":
|
|
5438
|
-
tag = loadModuleFont(filename);
|
|
5439
|
-
break;
|
|
5440
|
-
default: continue;
|
|
5441
|
-
}
|
|
5442
|
-
if (tag) headTags.push(tag);
|
|
5443
|
-
processed.add(filename);
|
|
5444
|
-
}
|
|
5445
|
-
return headTags.join("\n");
|
|
5623
|
+
function toTwee(story, outMode, options = {}) {
|
|
5624
|
+
return withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => passageToTwee(p, outMode)).join("");
|
|
5446
5625
|
}
|
|
5447
|
-
function
|
|
5448
|
-
const
|
|
5449
|
-
|
|
5450
|
-
|
|
5626
|
+
function withEffectiveStoryData(story, addStoryData) {
|
|
5627
|
+
const storyData = {
|
|
5628
|
+
name: "StoryData",
|
|
5629
|
+
tags: [],
|
|
5630
|
+
text: marshalStoryData(story)
|
|
5631
|
+
};
|
|
5632
|
+
const index = story.passages.findIndex((p) => p.name === "StoryData");
|
|
5633
|
+
if (index !== -1) return story.passages.map((p, i) => i === index && isStoryDataJSON(p.text) ? {
|
|
5634
|
+
...p,
|
|
5635
|
+
text: storyData.text
|
|
5636
|
+
} : p);
|
|
5637
|
+
if (!addStoryData) return story.passages;
|
|
5638
|
+
const at = story.passages[0]?.name === "StoryTitle" ? 1 : 0;
|
|
5639
|
+
return [
|
|
5640
|
+
...story.passages.slice(0, at),
|
|
5641
|
+
storyData,
|
|
5642
|
+
...story.passages.slice(at)
|
|
5643
|
+
];
|
|
5451
5644
|
}
|
|
5452
|
-
|
|
5453
|
-
|
|
5454
|
-
|
|
5455
|
-
const idSlug = `style-module-${slugify(family)}`;
|
|
5456
|
-
const ext = normalizedFileExt(filename);
|
|
5457
|
-
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>`;
|
|
5645
|
+
/** Whether the text loaded as StoryData. Text that did not is kept as written, for the author to fix. */
|
|
5646
|
+
function isStoryDataJSON(text) {
|
|
5647
|
+
return unmarshalStoryData(createStory(), text) === null;
|
|
5458
5648
|
}
|
|
5649
|
+
//#endregion
|
|
5650
|
+
//#region src/passage-omission.ts
|
|
5459
5651
|
/**
|
|
5460
|
-
*
|
|
5652
|
+
* Why the `target` output leaves `passage` out of its passage elements, or `undefined` when it
|
|
5653
|
+
* emits the passage (and so a link or the start can name it).
|
|
5461
5654
|
*/
|
|
5462
|
-
function
|
|
5463
|
-
|
|
5464
|
-
|
|
5465
|
-
|
|
5466
|
-
|
|
5467
|
-
}
|
|
5468
|
-
if (headFile) try {
|
|
5469
|
-
const source = readUTF8(headFile).trim();
|
|
5470
|
-
if (source.length > 0) parts.push(source);
|
|
5471
|
-
} catch (e) {
|
|
5472
|
-
diagnostics?.push({
|
|
5473
|
-
level: "warning",
|
|
5474
|
-
message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
|
|
5475
|
-
});
|
|
5476
|
-
}
|
|
5477
|
-
if (parts.length > 0) {
|
|
5478
|
-
parts.push("</head>");
|
|
5479
|
-
const replacement = parts.join("\n");
|
|
5480
|
-
return html.replace("</head>", () => replacement);
|
|
5655
|
+
function passageOmission(story, passage, target) {
|
|
5656
|
+
switch (target) {
|
|
5657
|
+
case "twine2": return twine2PassageOmission(story, passage);
|
|
5658
|
+
case "twine1": return twine1PassageOmission(passage);
|
|
5659
|
+
default: throw new Error(`unhandled passage output target: ${String(target)}`);
|
|
5481
5660
|
}
|
|
5482
|
-
return html;
|
|
5483
5661
|
}
|
|
5484
|
-
|
|
5485
|
-
//#region src/start-passage.ts
|
|
5662
|
+
/** Why a passage is left out, as a predicate: `Passage "X" ${describeOmission(o)}`. */
|
|
5486
5663
|
function describeOmission(omission) {
|
|
5487
5664
|
switch (omission.kind) {
|
|
5488
5665
|
case "special-name": return `is the special "${omission.name}" passage`;
|
|
@@ -5491,13 +5668,8 @@ function describeOmission(omission) {
|
|
|
5491
5668
|
default: throw new Error(`unhandled passage omission: ${JSON.stringify(omission)}`);
|
|
5492
5669
|
}
|
|
5493
5670
|
}
|
|
5494
|
-
|
|
5495
|
-
|
|
5496
|
-
case "twine2": return twine2PassageOmission(story, passage);
|
|
5497
|
-
case "twine1": return twine1PassageOmission(passage);
|
|
5498
|
-
default: throw new Error(`unhandled start passage target: ${String(target)}`);
|
|
5499
|
-
}
|
|
5500
|
-
}
|
|
5671
|
+
//#endregion
|
|
5672
|
+
//#region src/start-passage.ts
|
|
5501
5673
|
/**
|
|
5502
5674
|
* Check that `startName` names a passage the `target` output emits.
|
|
5503
5675
|
* Returns the error diagnostics to report (empty when the start is usable).
|
|
@@ -5508,7 +5680,7 @@ function startPassageDiagnostics(story, startName, target) {
|
|
|
5508
5680
|
level: "error",
|
|
5509
5681
|
message: `Starting passage "${startName}" not found.`
|
|
5510
5682
|
}];
|
|
5511
|
-
const omission =
|
|
5683
|
+
const omission = passageOmission(story, passage, target);
|
|
5512
5684
|
if (omission === void 0) return [];
|
|
5513
5685
|
return [{
|
|
5514
5686
|
level: "error",
|
|
@@ -5543,21 +5715,51 @@ async function compile(options) {
|
|
|
5543
5715
|
* Compile and write to a file.
|
|
5544
5716
|
*/
|
|
5545
5717
|
async function compileToFile(options) {
|
|
5546
|
-
const result = await
|
|
5718
|
+
const result = await compileForOutputFile(options, options.outFile);
|
|
5547
5719
|
writeFileSync(options.outFile, result.output, "utf-8");
|
|
5548
5720
|
return result;
|
|
5549
5721
|
}
|
|
5550
5722
|
/**
|
|
5723
|
+
* Builds as compileToFile() does for `outFile`, without writing it: the output may sit
|
|
5724
|
+
* inside a source folder, so its last build is left out of the sources and modules and
|
|
5725
|
+
* never read back. The caller decides whether the result is written. With no `outFile`
|
|
5726
|
+
* (output to stdout, or none), this is compile(). With `cache`, files are cached as
|
|
5727
|
+
* compileIncremental() caches them.
|
|
5728
|
+
*
|
|
5729
|
+
* Internal, for the CLI and the bundler plugins; not part of the public API.
|
|
5730
|
+
*/
|
|
5731
|
+
async function compileForOutputFile(options, outFile, cache) {
|
|
5732
|
+
return buildOutput(options, cache, void 0, outFile);
|
|
5733
|
+
}
|
|
5734
|
+
/**
|
|
5551
5735
|
* Compile with incremental caching support.
|
|
5552
5736
|
* Plugins and advanced users can manage their own cache and changed-file tracking.
|
|
5737
|
+
*
|
|
5738
|
+
* Without `changedFiles`, a cached file is reused while its modification time is unchanged.
|
|
5739
|
+
* With it, a file it names (by the path source discovery gives it, relative to the working
|
|
5740
|
+
* directory) is always reparsed, whatever its modification time, and every other cached
|
|
5741
|
+
* file is reused as it is.
|
|
5553
5742
|
*/
|
|
5554
5743
|
async function compileIncremental(options, cache, changedFiles) {
|
|
5555
5744
|
return buildOutput(options, cache, changedFiles);
|
|
5556
5745
|
}
|
|
5557
5746
|
/**
|
|
5558
|
-
* Watch for file changes and recompile.
|
|
5747
|
+
* Watch for file changes and recompile. Every build is written to `outFile`, including
|
|
5748
|
+
* one whose diagnostics report errors. Builds run one at a time: changes made during a
|
|
5749
|
+
* build go into one follow-up build after it, and the superseded build is neither
|
|
5750
|
+
* written nor reported.
|
|
5559
5751
|
*/
|
|
5560
5752
|
async function watch(options) {
|
|
5753
|
+
return watchWithWriteFilter(options, () => true);
|
|
5754
|
+
}
|
|
5755
|
+
/**
|
|
5756
|
+
* watch(), writing to `outFile` only the builds `shouldWrite` accepts. A rejected build
|
|
5757
|
+
* leaves the output file as it was; `onBuild` receives every build either way.
|
|
5758
|
+
*
|
|
5759
|
+
* Internal, for the CLI, which keeps the last good output when a rebuild reports errors;
|
|
5760
|
+
* not part of the public API.
|
|
5761
|
+
*/
|
|
5762
|
+
async function watchWithWriteFilter(options, shouldWrite) {
|
|
5561
5763
|
const controller = new AbortController();
|
|
5562
5764
|
const cache = /* @__PURE__ */ new Map();
|
|
5563
5765
|
const filePaths = options.sources.filter((s) => typeof s === "string");
|
|
@@ -5575,17 +5777,62 @@ async function watch(options) {
|
|
|
5575
5777
|
const abs = resolve(filename);
|
|
5576
5778
|
return !notExcludable.some((root) => abs === root || abs.startsWith(root + sep));
|
|
5577
5779
|
};
|
|
5780
|
+
let building = false;
|
|
5781
|
+
let queued;
|
|
5782
|
+
const deliver = (outcome) => {
|
|
5783
|
+
try {
|
|
5784
|
+
if (!outcome.ok) throw outcome.error;
|
|
5785
|
+
if (shouldWrite(outcome.result)) writeFileSync(options.outFile, outcome.result.output, "utf-8");
|
|
5786
|
+
options.onBuild?.(outcome.result);
|
|
5787
|
+
} catch (e) {
|
|
5788
|
+
options.onError?.(toError(e));
|
|
5789
|
+
}
|
|
5790
|
+
};
|
|
5791
|
+
const drain = async (first) => {
|
|
5792
|
+
building = true;
|
|
5793
|
+
try {
|
|
5794
|
+
let request = first;
|
|
5795
|
+
while (request !== void 0) {
|
|
5796
|
+
const current = request;
|
|
5797
|
+
const outcome = await buildOutput(options, cache, current.changedFiles, options.outFile).then((result) => ({
|
|
5798
|
+
ok: true,
|
|
5799
|
+
result
|
|
5800
|
+
}), (e) => ({
|
|
5801
|
+
ok: false,
|
|
5802
|
+
error: toError(e)
|
|
5803
|
+
}));
|
|
5804
|
+
if (controller.signal.aborted) return;
|
|
5805
|
+
request = queued === void 0 ? void 0 : mergeBuildRequests(current, queued);
|
|
5806
|
+
queued = void 0;
|
|
5807
|
+
if (request === void 0) deliver(outcome);
|
|
5808
|
+
}
|
|
5809
|
+
} finally {
|
|
5810
|
+
building = false;
|
|
5811
|
+
}
|
|
5812
|
+
};
|
|
5578
5813
|
const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
|
|
5579
|
-
|
|
5580
|
-
|
|
5581
|
-
|
|
5582
|
-
|
|
5583
|
-
|
|
5584
|
-
}
|
|
5814
|
+
if (controller.signal.aborted) return;
|
|
5815
|
+
const request = { changedFiles };
|
|
5816
|
+
if (building) {
|
|
5817
|
+
queued = queued === void 0 ? request : mergeBuildRequests(queued, request);
|
|
5818
|
+
return;
|
|
5819
|
+
}
|
|
5820
|
+
drain(request);
|
|
5585
5821
|
}, ignore);
|
|
5586
|
-
controller.signal.addEventListener("abort", () =>
|
|
5822
|
+
controller.signal.addEventListener("abort", () => {
|
|
5823
|
+
queued = void 0;
|
|
5824
|
+
handle.close();
|
|
5825
|
+
});
|
|
5587
5826
|
return controller;
|
|
5588
5827
|
}
|
|
5828
|
+
/** One build covering both requests: a full build if either is one, else every changed file. */
|
|
5829
|
+
function mergeBuildRequests(a, b) {
|
|
5830
|
+
if (a.changedFiles === void 0 || b.changedFiles === void 0) return { changedFiles: void 0 };
|
|
5831
|
+
return { changedFiles: /* @__PURE__ */ new Set([...a.changedFiles, ...b.changedFiles]) };
|
|
5832
|
+
}
|
|
5833
|
+
function toError(e) {
|
|
5834
|
+
return e instanceof Error ? e : new Error(String(e));
|
|
5835
|
+
}
|
|
5589
5836
|
/**
|
|
5590
5837
|
* `outFile`: the file the build is written to, which source and module discovery
|
|
5591
5838
|
* skip (as Tweego does), so an output inside a source folder is never loaded back.
|
|
@@ -5633,7 +5880,7 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5633
5880
|
switch (outputMode) {
|
|
5634
5881
|
case "twee3":
|
|
5635
5882
|
case "twee1":
|
|
5636
|
-
output = toTwee(story, outputMode);
|
|
5883
|
+
output = toTwee(story, outputMode, { addStoryData: Boolean(options.startPassage) || testMode });
|
|
5637
5884
|
break;
|
|
5638
5885
|
case "twine2-archive":
|
|
5639
5886
|
output = toTwine2Archive(story, startName, { sourceInfo });
|
|
@@ -5647,17 +5894,17 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5647
5894
|
case "html": {
|
|
5648
5895
|
diagnostics.push(...startPassageDiagnostics(story, startName, format?.isTwine2 === false ? "twine1" : "twine2"));
|
|
5649
5896
|
if (!format) throw new TweeTsError("No story format available for HTML output.", diagnostics);
|
|
5650
|
-
if (format.isTwine2
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
message: "Special passage \"StoryTitle\" not found."
|
|
5655
|
-
});
|
|
5656
|
-
output = toTwine1HTML(story, format, startName);
|
|
5657
|
-
}
|
|
5897
|
+
if (!format.isTwine2 && story.name === "" && !storyHas(story, "StoryTitle")) diagnostics.push({
|
|
5898
|
+
level: "error",
|
|
5899
|
+
message: "Special passage \"StoryTitle\" not found."
|
|
5900
|
+
});
|
|
5658
5901
|
const modules = getFilenames(options.modules ?? [], outFile);
|
|
5659
5902
|
diagnostics.push(...modules.diagnostics);
|
|
5660
|
-
|
|
5903
|
+
const head = loadHeadContent(modules.filenames, options.headFile, diagnostics);
|
|
5904
|
+
output = format.isTwine2 ? toTwine2HTML(story, format, startName, {
|
|
5905
|
+
sourceInfo,
|
|
5906
|
+
head
|
|
5907
|
+
}) : toTwine1HTML(story, format, startName, { head });
|
|
5661
5908
|
break;
|
|
5662
5909
|
}
|
|
5663
5910
|
default: throw new TweeTsError(`Unhandled output mode: ${outputMode}`, diagnostics);
|
|
@@ -6076,6 +6323,11 @@ function endOfRegularExpression(source, pos) {
|
|
|
6076
6323
|
}
|
|
6077
6324
|
//#endregion
|
|
6078
6325
|
//#region src/link-markup.ts
|
|
6326
|
+
/** Records that the reading has looked at the characters before `pos`, and returns `result`. */
|
|
6327
|
+
function readTo(reader, pos, result) {
|
|
6328
|
+
reader.scanned = Math.max(reader.scanned, pos);
|
|
6329
|
+
return result;
|
|
6330
|
+
}
|
|
6079
6331
|
/**
|
|
6080
6332
|
* Reads link or image markup that starts at `start` in `text`, or returns `undefined` where
|
|
6081
6333
|
* SugarCube rejects it (and so prints the text instead).
|
|
@@ -6083,15 +6335,17 @@ function endOfRegularExpression(source, pos) {
|
|
|
6083
6335
|
* `budget.left`, if given, is how many characters the reading may look at; it is reduced by as
|
|
6084
6336
|
* many as were read, and once it runs out, every reading fails. Unclosed markup is read to the end
|
|
6085
6337
|
* of its line, so reading every `[[` of a long line that never closes one would otherwise take
|
|
6086
|
-
* quadratic time.
|
|
6338
|
+
* quadratic time. A reading is charged for the characters it looked at, up to where it ended or
|
|
6339
|
+
* failed (a line feed, say), and no more, so markup left unclosed on short lines costs little.
|
|
6087
6340
|
*/
|
|
6088
6341
|
function readSquareBracketedMarkup(text, start, budget = { left: Infinity }) {
|
|
6089
6342
|
const reader = {
|
|
6090
6343
|
text,
|
|
6091
|
-
limit: Math.min(text.length, start + Math.max(budget.left, 0))
|
|
6344
|
+
limit: Math.min(text.length, start + Math.max(budget.left, 0)),
|
|
6345
|
+
scanned: start
|
|
6092
6346
|
};
|
|
6093
6347
|
const markup = readMarkup(reader, start);
|
|
6094
|
-
budget.left -= (markup?.end ??
|
|
6348
|
+
budget.left -= Math.max(reader.scanned, markup?.end ?? start) - start;
|
|
6095
6349
|
return markup;
|
|
6096
6350
|
}
|
|
6097
6351
|
function readMarkup(reader, start) {
|
|
@@ -6167,7 +6421,7 @@ function readCoreComponents(reader, from) {
|
|
|
6167
6421
|
const ch = text[pos];
|
|
6168
6422
|
pos += 1;
|
|
6169
6423
|
switch (ch) {
|
|
6170
|
-
case "\n": return;
|
|
6424
|
+
case "\n": return readTo(reader, pos, void 0);
|
|
6171
6425
|
case "\"": {
|
|
6172
6426
|
const end = readQuoted(reader, pos, "\"");
|
|
6173
6427
|
if (end === void 0) return;
|
|
@@ -6201,7 +6455,7 @@ function readCoreComponents(reader, from) {
|
|
|
6201
6455
|
case "]":
|
|
6202
6456
|
depth -= 1;
|
|
6203
6457
|
if (depth === 1) {
|
|
6204
|
-
const end = componentEnd(
|
|
6458
|
+
const end = componentEnd(reader, pos);
|
|
6205
6459
|
if (end === void 0) return;
|
|
6206
6460
|
return {
|
|
6207
6461
|
link: link ?? text.slice(componentStart, end.textEnd),
|
|
@@ -6210,6 +6464,7 @@ function readCoreComponents(reader, from) {
|
|
|
6210
6464
|
}
|
|
6211
6465
|
}
|
|
6212
6466
|
}
|
|
6467
|
+
return readTo(reader, pos, void 0);
|
|
6213
6468
|
}
|
|
6214
6469
|
/**
|
|
6215
6470
|
* Reads a component after `][` that holds no delimiter: an image's link (`setter` false) or a
|
|
@@ -6223,7 +6478,7 @@ function readComponent(reader, from, setter) {
|
|
|
6223
6478
|
const ch = text[pos];
|
|
6224
6479
|
pos += 1;
|
|
6225
6480
|
switch (ch) {
|
|
6226
|
-
case "\n": return;
|
|
6481
|
+
case "\n": return readTo(reader, pos, void 0);
|
|
6227
6482
|
case "\"":
|
|
6228
6483
|
case "'": {
|
|
6229
6484
|
if (ch === "'" && !setter) break;
|
|
@@ -6237,9 +6492,10 @@ function readComponent(reader, from, setter) {
|
|
|
6237
6492
|
break;
|
|
6238
6493
|
case "]":
|
|
6239
6494
|
depth -= 1;
|
|
6240
|
-
if (depth === 1) return componentEnd(
|
|
6495
|
+
if (depth === 1) return componentEnd(reader, pos);
|
|
6241
6496
|
}
|
|
6242
6497
|
}
|
|
6498
|
+
return readTo(reader, pos, void 0);
|
|
6243
6499
|
}
|
|
6244
6500
|
/** Reads a setter, the last component; returns the index after its closing `]]`. */
|
|
6245
6501
|
function readSetter(reader, from) {
|
|
@@ -6247,19 +6503,19 @@ function readSetter(reader, from) {
|
|
|
6247
6503
|
return end?.last === true ? end.next : void 0;
|
|
6248
6504
|
}
|
|
6249
6505
|
/** What the `]` just before `pos`, which brings the depth back to one, ends; `undefined` if malformed. */
|
|
6250
|
-
function componentEnd(
|
|
6251
|
-
switch (text[pos]) {
|
|
6252
|
-
case "[": return {
|
|
6506
|
+
function componentEnd(reader, pos) {
|
|
6507
|
+
switch (reader.text[pos]) {
|
|
6508
|
+
case "[": return readTo(reader, pos + 1, {
|
|
6253
6509
|
textEnd: pos - 1,
|
|
6254
6510
|
next: pos + 1,
|
|
6255
6511
|
last: false
|
|
6256
|
-
};
|
|
6257
|
-
case "]": return {
|
|
6512
|
+
});
|
|
6513
|
+
case "]": return readTo(reader, pos + 1, {
|
|
6258
6514
|
textEnd: pos - 1,
|
|
6259
6515
|
next: pos + 1,
|
|
6260
6516
|
last: true
|
|
6261
|
-
};
|
|
6262
|
-
default: return;
|
|
6517
|
+
});
|
|
6518
|
+
default: return readTo(reader, pos + 1, void 0);
|
|
6263
6519
|
}
|
|
6264
6520
|
}
|
|
6265
6521
|
/**
|
|
@@ -6274,12 +6530,13 @@ function readQuoted(reader, from, quote) {
|
|
|
6274
6530
|
const ch = text[pos];
|
|
6275
6531
|
pos += 1;
|
|
6276
6532
|
if (ch === quote) return pos;
|
|
6277
|
-
if (ch === "\n") return;
|
|
6533
|
+
if (ch === "\n") return readTo(reader, pos, void 0);
|
|
6278
6534
|
if (ch === "\\") {
|
|
6279
|
-
if (pos >= reader.limit || text[pos] === "\n") return;
|
|
6535
|
+
if (pos >= reader.limit || text[pos] === "\n") return readTo(reader, Math.min(pos + 1, reader.limit), void 0);
|
|
6280
6536
|
pos += 1;
|
|
6281
6537
|
}
|
|
6282
6538
|
}
|
|
6539
|
+
return readTo(reader, pos, void 0);
|
|
6283
6540
|
}
|
|
6284
6541
|
//#endregion
|
|
6285
6542
|
//#region src/sugarcube-macros.ts
|
|
@@ -7137,6 +7394,39 @@ function extractLinksFromText(text, isScript) {
|
|
|
7137
7394
|
].flatMap((via) => found.filter((link) => link.via === via).map((link) => link.passage));
|
|
7138
7395
|
return [...new Set(ordered)];
|
|
7139
7396
|
}
|
|
7397
|
+
/**
|
|
7398
|
+
* The passages that `p` links to. A script passage is JavaScript, read for links only in its
|
|
7399
|
+
* strings. A stylesheet is CSS, which the story puts in a style element and never wikifies, so it
|
|
7400
|
+
* links nowhere; a passage tagged both is a script, as Twine 2 output takes it.
|
|
7401
|
+
*/
|
|
7402
|
+
function passageLinks(p) {
|
|
7403
|
+
if (hasTag(p, "script")) return extractLinksFromText(textAsRead(p), true);
|
|
7404
|
+
if (hasTag(p, "stylesheet")) return [];
|
|
7405
|
+
return extractLinksFromText(textAsRead(p), false);
|
|
7406
|
+
}
|
|
7407
|
+
/**
|
|
7408
|
+
* Whether the `target` output keeps the links in `p`: always without a target, which describes
|
|
7409
|
+
* every source passage. With one, a passage the output leaves out never reaches the player, so it
|
|
7410
|
+
* links nowhere, except a script passage, which Twine 2 output leaves out as a passage but runs.
|
|
7411
|
+
*/
|
|
7412
|
+
function readsLinks(story, p, { target }) {
|
|
7413
|
+
if (target === void 0) return true;
|
|
7414
|
+
const omission = passageOmission(story, p, target);
|
|
7415
|
+
return omission === void 0 || omission.kind === "tag" && omission.tag === "script";
|
|
7416
|
+
}
|
|
7417
|
+
/**
|
|
7418
|
+
* Each passage name, mapped to why the `target` output leaves that passage out, or to `undefined`
|
|
7419
|
+
* when a link can reach it: always without a target, and otherwise when the output emits a
|
|
7420
|
+
* passage of that name.
|
|
7421
|
+
*/
|
|
7422
|
+
function destinationOmissions(story, { target }) {
|
|
7423
|
+
const omissions = /* @__PURE__ */ new Map();
|
|
7424
|
+
for (const p of story.passages) {
|
|
7425
|
+
const omission = target === void 0 ? void 0 : passageOmission(story, p, target);
|
|
7426
|
+
if (!omissions.has(p.name) || omission === void 0) omissions.set(p.name, omission);
|
|
7427
|
+
}
|
|
7428
|
+
return omissions;
|
|
7429
|
+
}
|
|
7140
7430
|
/** Passages SugarCube runs from their raw text, never joining their lines. */
|
|
7141
7431
|
const RAW_TEXT_PASSAGES = /* @__PURE__ */ new Set([
|
|
7142
7432
|
"StoryInit",
|
|
@@ -7178,9 +7468,16 @@ function joinLines(text) {
|
|
|
7178
7468
|
* expect(map.brokenLinks).toEqual([]);
|
|
7179
7469
|
* expect(map.deadEnds).not.toContain('Start');
|
|
7180
7470
|
* ```
|
|
7181
|
-
|
|
7182
|
-
|
|
7183
|
-
|
|
7471
|
+
*
|
|
7472
|
+
* By default, a link is broken only when no passage has its name. Pass a `target` to check links
|
|
7473
|
+
* against what that output emits: `storyInspect(result.story, { target: 'twine2' })` also reports
|
|
7474
|
+
* links to passages Twine 2 output leaves out, such as script, stylesheet and `Twine.private`
|
|
7475
|
+
* passages. With a target, passages that output leaves out also give no links (script passages
|
|
7476
|
+
* excepted, as Twine 2 output runs them), so they report no broken links and keep no passage from
|
|
7477
|
+
* being an orphan.
|
|
7478
|
+
*/
|
|
7479
|
+
function storyInspect(story, options = {}) {
|
|
7480
|
+
const destinations = destinationOmissions(story, options);
|
|
7184
7481
|
const passages = [];
|
|
7185
7482
|
const storyPassages = [];
|
|
7186
7483
|
const infoPassages = [];
|
|
@@ -7202,14 +7499,24 @@ function storyInspect(story) {
|
|
|
7202
7499
|
}
|
|
7203
7500
|
list.push(p.name);
|
|
7204
7501
|
}
|
|
7205
|
-
|
|
7206
|
-
links.set(p.name, targets);
|
|
7502
|
+
links.set(p.name, readsLinks(story, p, options) ? passageLinks(p) : []);
|
|
7207
7503
|
}
|
|
7208
7504
|
const brokenLinks = [];
|
|
7209
|
-
for (const [from, targets] of links) for (const to of targets)
|
|
7210
|
-
|
|
7211
|
-
|
|
7212
|
-
|
|
7505
|
+
for (const [from, targets] of links) for (const to of targets) {
|
|
7506
|
+
if (!destinations.has(to)) {
|
|
7507
|
+
brokenLinks.push({
|
|
7508
|
+
from,
|
|
7509
|
+
to
|
|
7510
|
+
});
|
|
7511
|
+
continue;
|
|
7512
|
+
}
|
|
7513
|
+
const omission = destinations.get(to);
|
|
7514
|
+
if (omission !== void 0) brokenLinks.push({
|
|
7515
|
+
from,
|
|
7516
|
+
to,
|
|
7517
|
+
omission
|
|
7518
|
+
});
|
|
7519
|
+
}
|
|
7213
7520
|
const deadEnds = [];
|
|
7214
7521
|
for (const name of storyPassages) {
|
|
7215
7522
|
const targets = links.get(name);
|
|
@@ -7239,14 +7546,27 @@ function storyInspect(story) {
|
|
|
7239
7546
|
/**
|
|
7240
7547
|
* Lint a story: compile without output rendering, then inspect structure.
|
|
7241
7548
|
* Uses JSON output mode internally to avoid format resolution, so it checks
|
|
7242
|
-
* the starting passage itself, against the Twine 2 passage rules.
|
|
7549
|
+
* the starting passage itself, against the Twine 2 passage rules. Link
|
|
7550
|
+
* destinations are checked against the same rules: a link to a passage that
|
|
7551
|
+
* Twine 2 output leaves out is broken, and a passage it leaves out (other than
|
|
7552
|
+
* a script passage) gives no links.
|
|
7243
7553
|
*/
|
|
7244
7554
|
async function lint(options) {
|
|
7245
|
-
|
|
7555
|
+
return lintForOutputFile(options, void 0);
|
|
7556
|
+
}
|
|
7557
|
+
/**
|
|
7558
|
+
* lint() for a project whose builds are written to `outFile`: like a build for that file,
|
|
7559
|
+
* it leaves `outFile` out of the sources and modules, so an earlier build inside a source
|
|
7560
|
+
* folder is not linted as a source.
|
|
7561
|
+
*
|
|
7562
|
+
* Internal, for the CLI; not part of the public API.
|
|
7563
|
+
*/
|
|
7564
|
+
async function lintForOutputFile(options, outFile) {
|
|
7565
|
+
const result = await compileForOutputFile({
|
|
7246
7566
|
...options,
|
|
7247
7567
|
outputMode: "json"
|
|
7248
|
-
});
|
|
7249
|
-
const map = storyInspect(result.story);
|
|
7568
|
+
}, outFile);
|
|
7569
|
+
const map = storyInspect(result.story, { target: "twine2" });
|
|
7250
7570
|
return {
|
|
7251
7571
|
diagnostics: [...result.diagnostics, ...startPassageDiagnostics(result.story, map.start, "twine2")],
|
|
7252
7572
|
stats: result.stats,
|
|
@@ -7273,7 +7593,10 @@ function formatLintReport(result) {
|
|
|
7273
7593
|
if (result.brokenLinks.length > 0) {
|
|
7274
7594
|
lines.push("");
|
|
7275
7595
|
lines.push(`Broken links (${result.brokenLinks.length}):`);
|
|
7276
|
-
for (const link of result.brokenLinks)
|
|
7596
|
+
for (const link of result.brokenLinks) {
|
|
7597
|
+
const why = link.omission === void 0 ? "does not exist" : `${describeOmission(link.omission)}, so it is left out of the story data`;
|
|
7598
|
+
lines.push(` ${link.from} -> ${link.to} (passage "${link.to}" ${why})`);
|
|
7599
|
+
}
|
|
7277
7600
|
}
|
|
7278
7601
|
if (result.deadEnds.length > 0) {
|
|
7279
7602
|
lines.push("");
|