@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/bin/twee-ts.js
CHANGED
|
@@ -15,6 +15,19 @@ import { fileURLToPath } from "node:url";
|
|
|
15
15
|
function generateIFID() {
|
|
16
16
|
return randomUUID().toUpperCase();
|
|
17
17
|
}
|
|
18
|
+
/** Length of the `UUID://...//` wrapper around a 36-character UUID. */
|
|
19
|
+
const WRAPPED_LENGTH = 45;
|
|
20
|
+
/**
|
|
21
|
+
* Bring an IFID into the form the story model stores and every output writes: the uppercase
|
|
22
|
+
* bare UUID. A valid `UUID://...//` wrapped IFID loses its wrapper; outputs that need the wrapper
|
|
23
|
+
* (the Treaty of Babel comment) add exactly one. Twine 2 IFIDs use only capital letters, so the
|
|
24
|
+
* value is uppercased, as Tweego does. An invalid value is only uppercased, so that validation
|
|
25
|
+
* reports it as written.
|
|
26
|
+
*/
|
|
27
|
+
function normalizeIFID(value) {
|
|
28
|
+
const upper = value.toUpperCase();
|
|
29
|
+
return upper.length === WRAPPED_LENGTH && validateIFID(upper) === null ? upper.slice(7, 43) : upper;
|
|
30
|
+
}
|
|
18
31
|
/**
|
|
19
32
|
* Validate an IFID string.
|
|
20
33
|
* Accepts both bare UUIDs and UUID://...// wrapped format.
|
|
@@ -443,7 +456,7 @@ function unmarshalStoryData(story, json) {
|
|
|
443
456
|
}
|
|
444
457
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return "Cannot unmarshal \"StoryData\"; expected a JSON object";
|
|
445
458
|
const data = raw;
|
|
446
|
-
if (typeof data.ifid === "string" && data.ifid) story.ifid = data.ifid
|
|
459
|
+
if (typeof data.ifid === "string" && data.ifid) story.ifid = normalizeIFID(data.ifid);
|
|
447
460
|
if (typeof data.format === "string" && data.format) story.twine2.format = data.format;
|
|
448
461
|
if (typeof data["format-version"] === "string" && data["format-version"]) story.twine2.formatVersion = data["format-version"];
|
|
449
462
|
if (Array.isArray(data.options)) {
|
|
@@ -474,7 +487,7 @@ function unmarshalStorySettings(story, text, diagnostics) {
|
|
|
474
487
|
const val = line.slice(colonIdx + 1).trim().toLowerCase();
|
|
475
488
|
switch (key) {
|
|
476
489
|
case "ifid":
|
|
477
|
-
if (validateIFID(val) === null) story.legacyIFID = val
|
|
490
|
+
if (validateIFID(val) === null) story.legacyIFID = normalizeIFID(val);
|
|
478
491
|
obsolete.push("\"ifid\"");
|
|
479
492
|
continue;
|
|
480
493
|
case "zoom":
|
|
@@ -4627,14 +4640,16 @@ function parseDocument(data, options) {
|
|
|
4627
4640
|
*/
|
|
4628
4641
|
/**
|
|
4629
4642
|
* Parse a Twine 2 or Twine 1 compiled HTML file back into a Story model.
|
|
4643
|
+
* Tweego always trims passage text; here `trim: false` keeps it exactly, as for Twee sources.
|
|
4630
4644
|
*/
|
|
4631
|
-
function decompileHTML(html) {
|
|
4645
|
+
function decompileHTML(html, options = {}) {
|
|
4632
4646
|
const diagnostics = [];
|
|
4633
4647
|
const story = createStory();
|
|
4648
|
+
const passageText = options.trim === false ? (text) => text : (text) => text.trim();
|
|
4634
4649
|
const doc = parseDocument(html);
|
|
4635
4650
|
const twine2Data = findElement(doc, "tw-storydata");
|
|
4636
4651
|
if (twine2Data) {
|
|
4637
|
-
decompileTwine2(twine2Data, story, diagnostics);
|
|
4652
|
+
decompileTwine2(twine2Data, story, passageText, diagnostics);
|
|
4638
4653
|
return {
|
|
4639
4654
|
story,
|
|
4640
4655
|
diagnostics
|
|
@@ -4642,7 +4657,7 @@ function decompileHTML(html) {
|
|
|
4642
4657
|
}
|
|
4643
4658
|
const twine1Data = findElementByIdPattern(doc, /^store(?:-a|A)rea$/);
|
|
4644
4659
|
if (twine1Data) {
|
|
4645
|
-
decompileTwine1(twine1Data, story, diagnostics);
|
|
4660
|
+
decompileTwine1(twine1Data, story, passageText, diagnostics);
|
|
4646
4661
|
return {
|
|
4647
4662
|
story,
|
|
4648
4663
|
diagnostics
|
|
@@ -4657,7 +4672,7 @@ function decompileHTML(html) {
|
|
|
4657
4672
|
diagnostics
|
|
4658
4673
|
};
|
|
4659
4674
|
}
|
|
4660
|
-
function decompileTwine2(storyData, story, diagnostics) {
|
|
4675
|
+
function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
4661
4676
|
let startnode = 0;
|
|
4662
4677
|
const attrs = storyData.attribs ?? {};
|
|
4663
4678
|
if (attrs["name"]) story.name = attrs["name"];
|
|
@@ -4669,7 +4684,7 @@ function decompileTwine2(storyData, story, diagnostics) {
|
|
|
4669
4684
|
});
|
|
4670
4685
|
else startnode = parsed;
|
|
4671
4686
|
}
|
|
4672
|
-
if (attrs["ifid"]) story.ifid = attrs["ifid"]
|
|
4687
|
+
if (attrs["ifid"]) story.ifid = normalizeIFID(attrs["ifid"]);
|
|
4673
4688
|
if (attrs["zoom"]) {
|
|
4674
4689
|
const parsed = parseFloat(attrs["zoom"]);
|
|
4675
4690
|
if (Number.isNaN(parsed)) diagnostics.push({
|
|
@@ -4682,15 +4697,20 @@ function decompileTwine2(storyData, story, diagnostics) {
|
|
|
4682
4697
|
if (attrs["format"]) story.twine2.format = attrs["format"];
|
|
4683
4698
|
if (attrs["format-version"]) story.twine2.formatVersion = attrs["format-version"];
|
|
4684
4699
|
if (attrs["options"]) for (const opt of attrs["options"].split(/\s+/).filter((s) => s.length > 0)) story.twine2.options.set(opt, true);
|
|
4685
|
-
|
|
4700
|
+
const children = storyData.children ?? [];
|
|
4701
|
+
const takenNames = new Set(children.filter((node) => node.type === "tag" && node.name === "tw-passagedata").map(passageDataName));
|
|
4702
|
+
for (const node of children) {
|
|
4686
4703
|
if (node.type !== "tag" && node.type !== "style" && node.type !== "script") continue;
|
|
4687
4704
|
switch (node.name) {
|
|
4688
4705
|
case "style":
|
|
4689
4706
|
case "script": {
|
|
4690
|
-
const
|
|
4691
|
-
if (
|
|
4707
|
+
const content = getTextContent(node);
|
|
4708
|
+
if (content.trim().length === 0) continue;
|
|
4709
|
+
const text = passageText(content);
|
|
4710
|
+
const name = freeName(node.name === "style" ? "Story Stylesheet" : "Story JavaScript", takenNames);
|
|
4711
|
+
takenNames.add(name);
|
|
4692
4712
|
storyAdd(story, {
|
|
4693
|
-
name
|
|
4713
|
+
name,
|
|
4694
4714
|
tags: node.name === "style" ? ["stylesheet"] : ["script"],
|
|
4695
4715
|
text
|
|
4696
4716
|
}, diagnostics);
|
|
@@ -4706,7 +4726,7 @@ function decompileTwine2(storyData, story, diagnostics) {
|
|
|
4706
4726
|
case "tw-passagedata": {
|
|
4707
4727
|
let pid = 0;
|
|
4708
4728
|
const pAttrs = node.attribs ?? {};
|
|
4709
|
-
const name =
|
|
4729
|
+
const name = passageDataName(node);
|
|
4710
4730
|
const tags = pAttrs["tags"] ? pAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [];
|
|
4711
4731
|
const metadata = {};
|
|
4712
4732
|
if (pAttrs["pid"]) {
|
|
@@ -4723,7 +4743,7 @@ function decompileTwine2(storyData, story, diagnostics) {
|
|
|
4723
4743
|
const passage = {
|
|
4724
4744
|
name,
|
|
4725
4745
|
tags,
|
|
4726
|
-
text: getTextContent(node)
|
|
4746
|
+
text: passageText(getTextContent(node))
|
|
4727
4747
|
};
|
|
4728
4748
|
if (metadata.position || metadata.size) passage.metadata = metadata;
|
|
4729
4749
|
storyAdd(story, passage, diagnostics);
|
|
@@ -4737,8 +4757,19 @@ function decompileTwine2(storyData, story, diagnostics) {
|
|
|
4737
4757
|
text: marshalStoryData(story)
|
|
4738
4758
|
}, diagnostics);
|
|
4739
4759
|
}
|
|
4740
|
-
function
|
|
4741
|
-
|
|
4760
|
+
function passageDataName(node) {
|
|
4761
|
+
return node.attribs?.["name"] ?? "";
|
|
4762
|
+
}
|
|
4763
|
+
/** The first of `base`, `base 2`, `base 3`, … that is not in `taken`. */
|
|
4764
|
+
function freeName(base, taken) {
|
|
4765
|
+
if (!taken.has(base)) return base;
|
|
4766
|
+
for (let n = 2;; n++) {
|
|
4767
|
+
const candidate = `${base} ${n}`;
|
|
4768
|
+
if (!taken.has(candidate)) return candidate;
|
|
4769
|
+
}
|
|
4770
|
+
}
|
|
4771
|
+
function decompileTwine1(storeArea, story, passageText, diagnostics) {
|
|
4772
|
+
const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
|
|
4742
4773
|
const obfuscated = isRot13Obfuscated(passages);
|
|
4743
4774
|
for (const p of passages) storyAdd(story, obfuscated && p.name !== "StorySettings" ? {
|
|
4744
4775
|
...p,
|
|
@@ -4748,12 +4779,12 @@ function decompileTwine1(storeArea, story, diagnostics) {
|
|
|
4748
4779
|
function isTiddler(node) {
|
|
4749
4780
|
return node.type === "tag" && node.name === "div" && node.attribs !== void 0 && "tiddler" in node.attribs;
|
|
4750
4781
|
}
|
|
4751
|
-
function tiddlerToPassage(node) {
|
|
4782
|
+
function tiddlerToPassage(node, passageText) {
|
|
4752
4783
|
const nodeAttrs = node.attribs ?? {};
|
|
4753
4784
|
const passage = {
|
|
4754
4785
|
name: nodeAttrs["tiddler"] ?? "",
|
|
4755
4786
|
tags: nodeAttrs["tags"] ? nodeAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [],
|
|
4756
|
-
text: tiddlerUnescape(getTextContent(node))
|
|
4787
|
+
text: passageText(tiddlerUnescape(getTextContent(node)))
|
|
4757
4788
|
};
|
|
4758
4789
|
const position = nodeAttrs["twine-position"];
|
|
4759
4790
|
if (position) passage.metadata = { position };
|
|
@@ -4818,7 +4849,7 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
|
|
|
4818
4849
|
break;
|
|
4819
4850
|
case "htm":
|
|
4820
4851
|
case "html":
|
|
4821
|
-
loadHTML(story, filename, diagnostics);
|
|
4852
|
+
loadHTML(story, filename, opts, diagnostics);
|
|
4822
4853
|
break;
|
|
4823
4854
|
case "css":
|
|
4824
4855
|
loadTagged(story, "stylesheet", filename, diagnostics);
|
|
@@ -4972,8 +5003,8 @@ function parseFontFile(filename) {
|
|
|
4972
5003
|
diagnostics: []
|
|
4973
5004
|
};
|
|
4974
5005
|
}
|
|
4975
|
-
function parseHTMLFile(filename) {
|
|
4976
|
-
const { story, diagnostics } = decompileHTML(readUTF8(filename));
|
|
5006
|
+
function parseHTMLFile(filename, opts) {
|
|
5007
|
+
const { story, diagnostics } = decompileHTML(readUTF8(filename), { trim: opts.trim ?? true });
|
|
4977
5008
|
return {
|
|
4978
5009
|
passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
|
|
4979
5010
|
name: "StoryTitle",
|
|
@@ -4985,14 +5016,17 @@ function parseHTMLFile(filename) {
|
|
|
4985
5016
|
}
|
|
4986
5017
|
/**
|
|
4987
5018
|
* Identity of the options that affect how a file parses, stored on its cache entry.
|
|
4988
|
-
*
|
|
5019
|
+
* Twee files depend on `trim` and `twee2Compat`, HTML files on `trim`; every other file type
|
|
5020
|
+
* yields the same key for all options.
|
|
4989
5021
|
*/
|
|
4990
5022
|
function parseOptionsKey(filename, opts) {
|
|
4991
5023
|
const ext = normalizedFileExt(filename);
|
|
5024
|
+
const trim = opts.trim ?? true;
|
|
5025
|
+
if (ext === "htm" || ext === "html") return JSON.stringify({ trim });
|
|
4992
5026
|
const twee2File = ext === "tw2" || ext === "twee2";
|
|
4993
5027
|
if (!twee2File && ext !== "tw" && ext !== "twee") return "";
|
|
4994
5028
|
return JSON.stringify({
|
|
4995
|
-
trim
|
|
5029
|
+
trim,
|
|
4996
5030
|
twee2Compat: twee2File || (opts.twee2Compat ?? false)
|
|
4997
5031
|
});
|
|
4998
5032
|
}
|
|
@@ -5006,7 +5040,7 @@ function parseFile(filename, opts) {
|
|
|
5006
5040
|
twee2Compat: true
|
|
5007
5041
|
});
|
|
5008
5042
|
case "htm":
|
|
5009
|
-
case "html": return parseHTMLFile(filename);
|
|
5043
|
+
case "html": return parseHTMLFile(filename, opts);
|
|
5010
5044
|
case "css": return parseTaggedFile("stylesheet", filename);
|
|
5011
5045
|
case "js": return parseTaggedFile("script", filename);
|
|
5012
5046
|
case "otf":
|
|
@@ -5038,8 +5072,8 @@ function parseFile(filename, opts) {
|
|
|
5038
5072
|
default: return;
|
|
5039
5073
|
}
|
|
5040
5074
|
}
|
|
5041
|
-
function loadHTML(story, filename, diagnostics) {
|
|
5042
|
-
const result = parseHTMLFile(filename);
|
|
5075
|
+
function loadHTML(story, filename, opts, diagnostics) {
|
|
5076
|
+
const result = parseHTMLFile(filename, opts);
|
|
5043
5077
|
diagnostics.push(...result.diagnostics);
|
|
5044
5078
|
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
5045
5079
|
}
|
|
@@ -5078,7 +5112,8 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5078
5112
|
const optionsKey = parseOptionsKey(filename, opts);
|
|
5079
5113
|
const entry = cache.get(filename);
|
|
5080
5114
|
const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
|
|
5081
|
-
|
|
5115
|
+
const changed = changedFiles?.has(filename) ?? false;
|
|
5116
|
+
if (changedFiles && cached && !changed) {
|
|
5082
5117
|
diagnostics.push(...cached.diagnostics);
|
|
5083
5118
|
for (const p of cached.passages) storyAdd(story, p, diagnostics);
|
|
5084
5119
|
processedFiles.add(filename);
|
|
@@ -5090,7 +5125,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5090
5125
|
} catch {
|
|
5091
5126
|
continue;
|
|
5092
5127
|
}
|
|
5093
|
-
if (cached && cached.mtimeMs === mtimeMs) {
|
|
5128
|
+
if (cached && !changed && cached.mtimeMs === mtimeMs) {
|
|
5094
5129
|
diagnostics.push(...cached.diagnostics);
|
|
5095
5130
|
for (const p of cached.passages) storyAdd(story, p, diagnostics);
|
|
5096
5131
|
processedFiles.add(filename);
|
|
@@ -5125,6 +5160,121 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5125
5160
|
}, diagnostics);
|
|
5126
5161
|
}
|
|
5127
5162
|
//#endregion
|
|
5163
|
+
//#region src/template.ts
|
|
5164
|
+
/**
|
|
5165
|
+
* An HTML closing head tag: `</head` in any letter case, followed by whitespace, `/` or `>`, up to the next `>`.
|
|
5166
|
+
* This is how the HTML tokenizer reads an end tag; it ignores whatever sits between the tag name and the `>`.
|
|
5167
|
+
*/
|
|
5168
|
+
const CLOSING_HEAD_TAG = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>";
|
|
5169
|
+
/** The regular expression source matching exactly `text`. */
|
|
5170
|
+
function literal(text) {
|
|
5171
|
+
return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
5172
|
+
}
|
|
5173
|
+
/** Replace the `slots` placeholders found in `template`, without scanning inserted text. */
|
|
5174
|
+
function fillTemplate(template, slots) {
|
|
5175
|
+
return fillTemplateParts([{
|
|
5176
|
+
kind: "scan",
|
|
5177
|
+
text: template,
|
|
5178
|
+
slots
|
|
5179
|
+
}]);
|
|
5180
|
+
}
|
|
5181
|
+
/**
|
|
5182
|
+
* Fill the parts of a template in order, joined. A `'first'` slot replaced in one part is not replaced again in a
|
|
5183
|
+
* later part that lists the same slot object.
|
|
5184
|
+
*/
|
|
5185
|
+
function fillTemplateParts(parts) {
|
|
5186
|
+
const filled = /* @__PURE__ */ new Set();
|
|
5187
|
+
return parts.map((part) => {
|
|
5188
|
+
if (part.kind === "verbatim" || part.slots.length === 0) return part.text;
|
|
5189
|
+
const { slots } = part;
|
|
5190
|
+
const pattern = new RegExp(slots.map((slot) => `(${slot.pattern})`).join("|"), "g");
|
|
5191
|
+
return part.text.replace(pattern, (match, ...groups) => {
|
|
5192
|
+
const slot = slots.find((_, i) => groups[i] !== void 0);
|
|
5193
|
+
if (slot === void 0 || slot.occurrences === "first" && filled.has(slot)) return match;
|
|
5194
|
+
filled.add(slot);
|
|
5195
|
+
return slot.replacement(match);
|
|
5196
|
+
});
|
|
5197
|
+
}).join("");
|
|
5198
|
+
}
|
|
5199
|
+
//#endregion
|
|
5200
|
+
//#region src/modules.ts
|
|
5201
|
+
/**
|
|
5202
|
+
* Load modules and return HTML tags to inject before the closing head tag.
|
|
5203
|
+
*/
|
|
5204
|
+
function loadModules(filenames) {
|
|
5205
|
+
const processed = /* @__PURE__ */ new Set();
|
|
5206
|
+
const headTags = [];
|
|
5207
|
+
for (const filename of filenames) {
|
|
5208
|
+
if (processed.has(filename)) continue;
|
|
5209
|
+
const ext = normalizedFileExt(filename);
|
|
5210
|
+
let tag = null;
|
|
5211
|
+
switch (ext) {
|
|
5212
|
+
case "css":
|
|
5213
|
+
tag = loadModuleTagged("style", filename);
|
|
5214
|
+
break;
|
|
5215
|
+
case "js":
|
|
5216
|
+
tag = loadModuleTagged("script", filename);
|
|
5217
|
+
break;
|
|
5218
|
+
case "otf":
|
|
5219
|
+
case "ttf":
|
|
5220
|
+
case "woff":
|
|
5221
|
+
case "woff2":
|
|
5222
|
+
tag = loadModuleFont(filename);
|
|
5223
|
+
break;
|
|
5224
|
+
default: continue;
|
|
5225
|
+
}
|
|
5226
|
+
if (tag) headTags.push(tag);
|
|
5227
|
+
processed.add(filename);
|
|
5228
|
+
}
|
|
5229
|
+
return headTags.join("\n");
|
|
5230
|
+
}
|
|
5231
|
+
function loadModuleTagged(tag, filename) {
|
|
5232
|
+
const source = readUTF8(filename).trim();
|
|
5233
|
+
if (source.length === 0) return null;
|
|
5234
|
+
return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
|
|
5235
|
+
}
|
|
5236
|
+
function loadModuleFont(filename) {
|
|
5237
|
+
const source = readBase64(filename);
|
|
5238
|
+
const family = baseNameWithoutExt(filename);
|
|
5239
|
+
const idSlug = `style-module-${slugify(family)}`;
|
|
5240
|
+
const ext = normalizedFileExt(filename);
|
|
5241
|
+
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>`;
|
|
5242
|
+
}
|
|
5243
|
+
/**
|
|
5244
|
+
* Load the module tags and head file content to inject before the closing head tag, joined by newlines,
|
|
5245
|
+
* or `''` when there is nothing to inject.
|
|
5246
|
+
*/
|
|
5247
|
+
function loadHeadContent(modulePaths, headFile, diagnostics) {
|
|
5248
|
+
const parts = [];
|
|
5249
|
+
if (modulePaths.length > 0) {
|
|
5250
|
+
const modules = loadModules(modulePaths).trim();
|
|
5251
|
+
if (modules.length > 0) parts.push(modules);
|
|
5252
|
+
}
|
|
5253
|
+
if (headFile) try {
|
|
5254
|
+
const source = readUTF8(headFile).trim();
|
|
5255
|
+
if (source.length > 0) parts.push(source);
|
|
5256
|
+
} catch (e) {
|
|
5257
|
+
diagnostics?.push({
|
|
5258
|
+
level: "warning",
|
|
5259
|
+
message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
|
|
5260
|
+
});
|
|
5261
|
+
}
|
|
5262
|
+
return parts.join("\n");
|
|
5263
|
+
}
|
|
5264
|
+
/**
|
|
5265
|
+
* The template slot that inserts `content` on its own line before the first closing head tag (as Tweego does),
|
|
5266
|
+
* or `undefined` when `content` is empty. The tag may use any letter case and whitespace before its `>`, and is
|
|
5267
|
+
* kept as written.
|
|
5268
|
+
*/
|
|
5269
|
+
function headSlot(content) {
|
|
5270
|
+
if (content.length === 0) return void 0;
|
|
5271
|
+
return {
|
|
5272
|
+
pattern: CLOSING_HEAD_TAG,
|
|
5273
|
+
occurrences: "first",
|
|
5274
|
+
replacement: (tag) => `${content}\n${tag}`
|
|
5275
|
+
};
|
|
5276
|
+
}
|
|
5277
|
+
//#endregion
|
|
5128
5278
|
//#region src/version.ts
|
|
5129
5279
|
/**
|
|
5130
5280
|
* The twee-ts version, read at runtime from the package's own package.json.
|
|
@@ -5158,24 +5308,38 @@ const CREATOR_NAME$2 = "Twee-ts";
|
|
|
5158
5308
|
function toTwine2Archive(story, startName, options) {
|
|
5159
5309
|
return getTwine2DataChunk(story, startName, options) + "\n";
|
|
5160
5310
|
}
|
|
5311
|
+
/**
|
|
5312
|
+
* Fill the Twine 2 format template: every `{{STORY_NAME}}` gets the escaped story name and the first
|
|
5313
|
+
* `{{STORY_DATA}}` the story data (as Tweego does), and `head` goes before the first closing head tag. All three are
|
|
5314
|
+
* found in the template in one pass, so a story name, passage text or head content holding a placeholder or a
|
|
5315
|
+
* closing head tag stays literal.
|
|
5316
|
+
*/
|
|
5161
5317
|
function toTwine2HTML(story, format, startName, options) {
|
|
5162
|
-
|
|
5163
|
-
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5167
|
-
|
|
5168
|
-
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5318
|
+
const name = htmlEscape(story.name);
|
|
5319
|
+
const head = headSlot(options?.head ?? "");
|
|
5320
|
+
const slots = [
|
|
5321
|
+
{
|
|
5322
|
+
pattern: literal("{{STORY_NAME}}"),
|
|
5323
|
+
occurrences: "all",
|
|
5324
|
+
replacement: () => name
|
|
5325
|
+
},
|
|
5326
|
+
{
|
|
5327
|
+
pattern: literal("{{STORY_DATA}}"),
|
|
5328
|
+
occurrences: "first",
|
|
5329
|
+
replacement: () => {
|
|
5330
|
+
return getTwine2DataChunk({
|
|
5331
|
+
...story,
|
|
5332
|
+
twine2: {
|
|
5333
|
+
...story.twine2,
|
|
5334
|
+
format: format.name,
|
|
5335
|
+
formatVersion: format.version
|
|
5336
|
+
}
|
|
5337
|
+
}, startName, options);
|
|
5174
5338
|
}
|
|
5175
|
-
},
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
return
|
|
5339
|
+
},
|
|
5340
|
+
...head === void 0 ? [] : [head]
|
|
5341
|
+
];
|
|
5342
|
+
return fillTemplate(readFormatSource(format), slots);
|
|
5179
5343
|
}
|
|
5180
5344
|
const OMITTING_TAGS = [
|
|
5181
5345
|
"Twine.private",
|
|
@@ -5268,7 +5432,13 @@ function toTwine1Archive(story, _startName) {
|
|
|
5268
5432
|
const { data, count } = getTwine1PassageChunk(story);
|
|
5269
5433
|
return `<div id="storeArea" data-size="${count}">${data}</div>\n`;
|
|
5270
5434
|
}
|
|
5271
|
-
|
|
5435
|
+
/**
|
|
5436
|
+
* Fill the Twine 1 format template. The format's components are inserted first, one after another, as Tweego and
|
|
5437
|
+
* Twine 1 do. Then the first of each story placeholder (`"VERSION"`, `"TIME"`, `"START_AT"`, `"STORY_SIZE"`,
|
|
5438
|
+
* `"STORY"`), the IFID comment and `head` (before the first closing head tag) are found in one pass, so a start
|
|
5439
|
+
* passage name or story data holding a placeholder or a closing head tag stays literal.
|
|
5440
|
+
*/
|
|
5441
|
+
function toTwine1HTML(story, format, startName, options) {
|
|
5272
5442
|
const formatDir = dirname(format.filename);
|
|
5273
5443
|
const parentDir = dirname(formatDir);
|
|
5274
5444
|
let template = readFormatSource(format);
|
|
@@ -5279,28 +5449,60 @@ function toTwine1HTML(story, format, startName) {
|
|
|
5279
5449
|
template = tryReplaceComponent(template, "\"JONAH\"", join(formatDir, "code.js"), true);
|
|
5280
5450
|
if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", join(parentDir, "jquery.js"), true);
|
|
5281
5451
|
if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", join(parentDir, "modernizr.js"), true);
|
|
5452
|
+
const isPre14 = !template.includes("\"STORY\"");
|
|
5453
|
+
const footer = isPre14 ? readFooter(formatDir) : "";
|
|
5454
|
+
const storeArea = (template + footer).includes("<div id=\"store-area\"") ? "<div id=\"store-area\"" : "<div id=\"storeArea\"";
|
|
5455
|
+
const safeIfid = htmlCommentSanitize(story.ifid);
|
|
5456
|
+
const late = [story.ifid ? {
|
|
5457
|
+
pattern: literal(storeArea),
|
|
5458
|
+
occurrences: "first",
|
|
5459
|
+
replacement: (div) => `<!-- UUID://${safeIfid}// -->${div}`
|
|
5460
|
+
} : void 0, headSlot(options?.head ?? "")].filter((slot) => slot !== void 0);
|
|
5282
5461
|
const displayStart = startName === "Start" ? "" : startName;
|
|
5283
|
-
|
|
5284
|
-
|
|
5285
|
-
|
|
5286
|
-
|
|
5287
|
-
|
|
5288
|
-
|
|
5289
|
-
|
|
5290
|
-
|
|
5291
|
-
|
|
5292
|
-
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
5297
|
-
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
5462
|
+
const slots = [
|
|
5463
|
+
firstSlot("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`),
|
|
5464
|
+
firstSlot("\"TIME\"", `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`),
|
|
5465
|
+
firstSlot("\"START_AT\"", `"${jsStringEscape(displayStart)}"`),
|
|
5466
|
+
firstSlot("\"STORY_SIZE\"", `"${count}"`),
|
|
5467
|
+
firstSlot("\"STORY\"", data),
|
|
5468
|
+
...late
|
|
5469
|
+
];
|
|
5470
|
+
return fillTemplateParts(isPre14 ? [
|
|
5471
|
+
{
|
|
5472
|
+
kind: "scan",
|
|
5473
|
+
text: template,
|
|
5474
|
+
slots
|
|
5475
|
+
},
|
|
5476
|
+
{
|
|
5477
|
+
kind: "verbatim",
|
|
5478
|
+
text: data
|
|
5479
|
+
},
|
|
5480
|
+
{
|
|
5481
|
+
kind: "scan",
|
|
5482
|
+
text: footer,
|
|
5483
|
+
slots: late
|
|
5484
|
+
}
|
|
5485
|
+
] : [{
|
|
5486
|
+
kind: "scan",
|
|
5487
|
+
text: template,
|
|
5488
|
+
slots
|
|
5489
|
+
}]);
|
|
5490
|
+
}
|
|
5491
|
+
/** A slot replacing the first `token` with `value`. */
|
|
5492
|
+
function firstSlot(token, value) {
|
|
5493
|
+
return {
|
|
5494
|
+
pattern: literal(token),
|
|
5495
|
+
occurrences: "first",
|
|
5496
|
+
replacement: () => value
|
|
5497
|
+
};
|
|
5498
|
+
}
|
|
5499
|
+
/** The footer of a pre-1.4 format, or the default one when the format has none. */
|
|
5500
|
+
function readFooter(formatDir) {
|
|
5501
|
+
try {
|
|
5502
|
+
return readFileSync(join(formatDir, "footer.html"), "utf-8");
|
|
5503
|
+
} catch {
|
|
5504
|
+
return "</div>\n</body>\n</html>\n";
|
|
5302
5505
|
}
|
|
5303
|
-
return template;
|
|
5304
5506
|
}
|
|
5305
5507
|
/**
|
|
5306
5508
|
* Why Twine 1 output leaves a passage out of its tiddlers, or `undefined` when the passage is emitted.
|
|
@@ -5338,82 +5540,54 @@ function tryReplaceComponent(template, placeholder, componentPath, required) {
|
|
|
5338
5540
|
}
|
|
5339
5541
|
//#endregion
|
|
5340
5542
|
//#region src/output-twee.ts
|
|
5341
|
-
function toTwee(story, outMode) {
|
|
5342
|
-
let data = "";
|
|
5343
|
-
for (const p of story.passages) data += passageToTwee(p, outMode);
|
|
5344
|
-
return data;
|
|
5345
|
-
}
|
|
5346
|
-
//#endregion
|
|
5347
|
-
//#region src/modules.ts
|
|
5348
5543
|
/**
|
|
5349
|
-
*
|
|
5544
|
+
* Serialize the story as Twee. The StoryData passage is written from the story model rather than
|
|
5545
|
+
* from its loaded text, so changes made after loading (a start passage override, test mode) are kept.
|
|
5546
|
+
* Tweego writes the loaded text; its own normalization makes the two the same for an unchanged story.
|
|
5547
|
+
*
|
|
5548
|
+
* `addStoryData`: add a StoryData passage when the story has none, so that StoryData fields the
|
|
5549
|
+
* compile options set are recorded. Default: false.
|
|
5350
5550
|
*/
|
|
5351
|
-
function
|
|
5352
|
-
|
|
5353
|
-
const headTags = [];
|
|
5354
|
-
for (const filename of filenames) {
|
|
5355
|
-
if (processed.has(filename)) continue;
|
|
5356
|
-
const ext = normalizedFileExt(filename);
|
|
5357
|
-
let tag = null;
|
|
5358
|
-
switch (ext) {
|
|
5359
|
-
case "css":
|
|
5360
|
-
tag = loadModuleTagged("style", filename);
|
|
5361
|
-
break;
|
|
5362
|
-
case "js":
|
|
5363
|
-
tag = loadModuleTagged("script", filename);
|
|
5364
|
-
break;
|
|
5365
|
-
case "otf":
|
|
5366
|
-
case "ttf":
|
|
5367
|
-
case "woff":
|
|
5368
|
-
case "woff2":
|
|
5369
|
-
tag = loadModuleFont(filename);
|
|
5370
|
-
break;
|
|
5371
|
-
default: continue;
|
|
5372
|
-
}
|
|
5373
|
-
if (tag) headTags.push(tag);
|
|
5374
|
-
processed.add(filename);
|
|
5375
|
-
}
|
|
5376
|
-
return headTags.join("\n");
|
|
5551
|
+
function toTwee(story, outMode, options = {}) {
|
|
5552
|
+
return withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => passageToTwee(p, outMode)).join("");
|
|
5377
5553
|
}
|
|
5378
|
-
function
|
|
5379
|
-
const
|
|
5380
|
-
|
|
5381
|
-
|
|
5554
|
+
function withEffectiveStoryData(story, addStoryData) {
|
|
5555
|
+
const storyData = {
|
|
5556
|
+
name: "StoryData",
|
|
5557
|
+
tags: [],
|
|
5558
|
+
text: marshalStoryData(story)
|
|
5559
|
+
};
|
|
5560
|
+
const index = story.passages.findIndex((p) => p.name === "StoryData");
|
|
5561
|
+
if (index !== -1) return story.passages.map((p, i) => i === index && isStoryDataJSON(p.text) ? {
|
|
5562
|
+
...p,
|
|
5563
|
+
text: storyData.text
|
|
5564
|
+
} : p);
|
|
5565
|
+
if (!addStoryData) return story.passages;
|
|
5566
|
+
const at = story.passages[0]?.name === "StoryTitle" ? 1 : 0;
|
|
5567
|
+
return [
|
|
5568
|
+
...story.passages.slice(0, at),
|
|
5569
|
+
storyData,
|
|
5570
|
+
...story.passages.slice(at)
|
|
5571
|
+
];
|
|
5382
5572
|
}
|
|
5383
|
-
|
|
5384
|
-
|
|
5385
|
-
|
|
5386
|
-
const idSlug = `style-module-${slugify(family)}`;
|
|
5387
|
-
const ext = normalizedFileExt(filename);
|
|
5388
|
-
return `<style id="${idSlug}" type="text/css">@font-face {\n\tfont-family: "${family}";\n\tsrc: url("data:${mediaTypeFromExt(ext)};base64,${source}") format("${fontFormatHint(ext)}");\n}</style>`;
|
|
5573
|
+
/** Whether the text loaded as StoryData. Text that did not is kept as written, for the author to fix. */
|
|
5574
|
+
function isStoryDataJSON(text) {
|
|
5575
|
+
return unmarshalStoryData(createStory(), text) === null;
|
|
5389
5576
|
}
|
|
5577
|
+
//#endregion
|
|
5578
|
+
//#region src/passage-omission.ts
|
|
5390
5579
|
/**
|
|
5391
|
-
*
|
|
5580
|
+
* Why the `target` output leaves `passage` out of its passage elements, or `undefined` when it
|
|
5581
|
+
* emits the passage (and so a link or the start can name it).
|
|
5392
5582
|
*/
|
|
5393
|
-
function
|
|
5394
|
-
|
|
5395
|
-
|
|
5396
|
-
|
|
5397
|
-
|
|
5398
|
-
}
|
|
5399
|
-
if (headFile) try {
|
|
5400
|
-
const source = readUTF8(headFile).trim();
|
|
5401
|
-
if (source.length > 0) parts.push(source);
|
|
5402
|
-
} catch (e) {
|
|
5403
|
-
diagnostics?.push({
|
|
5404
|
-
level: "warning",
|
|
5405
|
-
message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
|
|
5406
|
-
});
|
|
5407
|
-
}
|
|
5408
|
-
if (parts.length > 0) {
|
|
5409
|
-
parts.push("</head>");
|
|
5410
|
-
const replacement = parts.join("\n");
|
|
5411
|
-
return html.replace("</head>", () => replacement);
|
|
5583
|
+
function passageOmission(story, passage, target) {
|
|
5584
|
+
switch (target) {
|
|
5585
|
+
case "twine2": return twine2PassageOmission(story, passage);
|
|
5586
|
+
case "twine1": return twine1PassageOmission(passage);
|
|
5587
|
+
default: throw new Error(`unhandled passage output target: ${String(target)}`);
|
|
5412
5588
|
}
|
|
5413
|
-
return html;
|
|
5414
5589
|
}
|
|
5415
|
-
|
|
5416
|
-
//#region src/start-passage.ts
|
|
5590
|
+
/** Why a passage is left out, as a predicate: `Passage "X" ${describeOmission(o)}`. */
|
|
5417
5591
|
function describeOmission(omission) {
|
|
5418
5592
|
switch (omission.kind) {
|
|
5419
5593
|
case "special-name": return `is the special "${omission.name}" passage`;
|
|
@@ -5422,13 +5596,8 @@ function describeOmission(omission) {
|
|
|
5422
5596
|
default: throw new Error(`unhandled passage omission: ${JSON.stringify(omission)}`);
|
|
5423
5597
|
}
|
|
5424
5598
|
}
|
|
5425
|
-
|
|
5426
|
-
|
|
5427
|
-
case "twine2": return twine2PassageOmission(story, passage);
|
|
5428
|
-
case "twine1": return twine1PassageOmission(passage);
|
|
5429
|
-
default: throw new Error(`unhandled start passage target: ${String(target)}`);
|
|
5430
|
-
}
|
|
5431
|
-
}
|
|
5599
|
+
//#endregion
|
|
5600
|
+
//#region src/start-passage.ts
|
|
5432
5601
|
/**
|
|
5433
5602
|
* Check that `startName` names a passage the `target` output emits.
|
|
5434
5603
|
* Returns the error diagnostics to report (empty when the start is usable).
|
|
@@ -5439,7 +5608,7 @@ function startPassageDiagnostics(story, startName, target) {
|
|
|
5439
5608
|
level: "error",
|
|
5440
5609
|
message: `Starting passage "${startName}" not found.`
|
|
5441
5610
|
}];
|
|
5442
|
-
const omission =
|
|
5611
|
+
const omission = passageOmission(story, passage, target);
|
|
5443
5612
|
if (omission === void 0) return [];
|
|
5444
5613
|
return [{
|
|
5445
5614
|
level: "error",
|
|
@@ -5465,15 +5634,25 @@ var TweeTsError = class extends Error {
|
|
|
5465
5634
|
}
|
|
5466
5635
|
};
|
|
5467
5636
|
/**
|
|
5468
|
-
*
|
|
5637
|
+
* Builds as compileToFile() does for `outFile`, without writing it: the output may sit
|
|
5638
|
+
* inside a source folder, so its last build is left out of the sources and modules and
|
|
5639
|
+
* never read back. The caller decides whether the result is written. With no `outFile`
|
|
5640
|
+
* (output to stdout, or none), this is compile(). With `cache`, files are cached as
|
|
5641
|
+
* compileIncremental() caches them.
|
|
5642
|
+
*
|
|
5643
|
+
* Internal, for the CLI and the bundler plugins; not part of the public API.
|
|
5469
5644
|
*/
|
|
5470
|
-
async function
|
|
5471
|
-
return buildOutput(options);
|
|
5645
|
+
async function compileForOutputFile(options, outFile, cache) {
|
|
5646
|
+
return buildOutput(options, cache, void 0, outFile);
|
|
5472
5647
|
}
|
|
5473
5648
|
/**
|
|
5474
|
-
*
|
|
5649
|
+
* watch(), writing to `outFile` only the builds `shouldWrite` accepts. A rejected build
|
|
5650
|
+
* leaves the output file as it was; `onBuild` receives every build either way.
|
|
5651
|
+
*
|
|
5652
|
+
* Internal, for the CLI, which keeps the last good output when a rebuild reports errors;
|
|
5653
|
+
* not part of the public API.
|
|
5475
5654
|
*/
|
|
5476
|
-
async function
|
|
5655
|
+
async function watchWithWriteFilter(options, shouldWrite) {
|
|
5477
5656
|
const controller = new AbortController();
|
|
5478
5657
|
const cache = /* @__PURE__ */ new Map();
|
|
5479
5658
|
const filePaths = options.sources.filter((s) => typeof s === "string");
|
|
@@ -5491,17 +5670,62 @@ async function watch$1(options) {
|
|
|
5491
5670
|
const abs = resolve(filename);
|
|
5492
5671
|
return !notExcludable.some((root) => abs === root || abs.startsWith(root + sep));
|
|
5493
5672
|
};
|
|
5673
|
+
let building = false;
|
|
5674
|
+
let queued;
|
|
5675
|
+
const deliver = (outcome) => {
|
|
5676
|
+
try {
|
|
5677
|
+
if (!outcome.ok) throw outcome.error;
|
|
5678
|
+
if (shouldWrite(outcome.result)) writeFileSync(options.outFile, outcome.result.output, "utf-8");
|
|
5679
|
+
options.onBuild?.(outcome.result);
|
|
5680
|
+
} catch (e) {
|
|
5681
|
+
options.onError?.(toError(e));
|
|
5682
|
+
}
|
|
5683
|
+
};
|
|
5684
|
+
const drain = async (first) => {
|
|
5685
|
+
building = true;
|
|
5686
|
+
try {
|
|
5687
|
+
let request = first;
|
|
5688
|
+
while (request !== void 0) {
|
|
5689
|
+
const current = request;
|
|
5690
|
+
const outcome = await buildOutput(options, cache, current.changedFiles, options.outFile).then((result) => ({
|
|
5691
|
+
ok: true,
|
|
5692
|
+
result
|
|
5693
|
+
}), (e) => ({
|
|
5694
|
+
ok: false,
|
|
5695
|
+
error: toError(e)
|
|
5696
|
+
}));
|
|
5697
|
+
if (controller.signal.aborted) return;
|
|
5698
|
+
request = queued === void 0 ? void 0 : mergeBuildRequests(current, queued);
|
|
5699
|
+
queued = void 0;
|
|
5700
|
+
if (request === void 0) deliver(outcome);
|
|
5701
|
+
}
|
|
5702
|
+
} finally {
|
|
5703
|
+
building = false;
|
|
5704
|
+
}
|
|
5705
|
+
};
|
|
5494
5706
|
const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
|
|
5495
|
-
|
|
5496
|
-
|
|
5497
|
-
|
|
5498
|
-
|
|
5499
|
-
|
|
5500
|
-
}
|
|
5707
|
+
if (controller.signal.aborted) return;
|
|
5708
|
+
const request = { changedFiles };
|
|
5709
|
+
if (building) {
|
|
5710
|
+
queued = queued === void 0 ? request : mergeBuildRequests(queued, request);
|
|
5711
|
+
return;
|
|
5712
|
+
}
|
|
5713
|
+
drain(request);
|
|
5501
5714
|
}, ignore);
|
|
5502
|
-
controller.signal.addEventListener("abort", () =>
|
|
5715
|
+
controller.signal.addEventListener("abort", () => {
|
|
5716
|
+
queued = void 0;
|
|
5717
|
+
handle.close();
|
|
5718
|
+
});
|
|
5503
5719
|
return controller;
|
|
5504
5720
|
}
|
|
5721
|
+
/** One build covering both requests: a full build if either is one, else every changed file. */
|
|
5722
|
+
function mergeBuildRequests(a, b) {
|
|
5723
|
+
if (a.changedFiles === void 0 || b.changedFiles === void 0) return { changedFiles: void 0 };
|
|
5724
|
+
return { changedFiles: /* @__PURE__ */ new Set([...a.changedFiles, ...b.changedFiles]) };
|
|
5725
|
+
}
|
|
5726
|
+
function toError(e) {
|
|
5727
|
+
return e instanceof Error ? e : new Error(String(e));
|
|
5728
|
+
}
|
|
5505
5729
|
/**
|
|
5506
5730
|
* `outFile`: the file the build is written to, which source and module discovery
|
|
5507
5731
|
* skip (as Tweego does), so an output inside a source folder is never loaded back.
|
|
@@ -5549,7 +5773,7 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5549
5773
|
switch (outputMode) {
|
|
5550
5774
|
case "twee3":
|
|
5551
5775
|
case "twee1":
|
|
5552
|
-
output = toTwee(story, outputMode);
|
|
5776
|
+
output = toTwee(story, outputMode, { addStoryData: Boolean(options.startPassage) || testMode });
|
|
5553
5777
|
break;
|
|
5554
5778
|
case "twine2-archive":
|
|
5555
5779
|
output = toTwine2Archive(story, startName, { sourceInfo });
|
|
@@ -5563,17 +5787,17 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5563
5787
|
case "html": {
|
|
5564
5788
|
diagnostics.push(...startPassageDiagnostics(story, startName, format?.isTwine2 === false ? "twine1" : "twine2"));
|
|
5565
5789
|
if (!format) throw new TweeTsError("No story format available for HTML output.", diagnostics);
|
|
5566
|
-
if (format.isTwine2
|
|
5567
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
5570
|
-
message: "Special passage \"StoryTitle\" not found."
|
|
5571
|
-
});
|
|
5572
|
-
output = toTwine1HTML(story, format, startName);
|
|
5573
|
-
}
|
|
5790
|
+
if (!format.isTwine2 && story.name === "" && !storyHas(story, "StoryTitle")) diagnostics.push({
|
|
5791
|
+
level: "error",
|
|
5792
|
+
message: "Special passage \"StoryTitle\" not found."
|
|
5793
|
+
});
|
|
5574
5794
|
const modules = getFilenames(options.modules ?? [], outFile);
|
|
5575
5795
|
diagnostics.push(...modules.diagnostics);
|
|
5576
|
-
|
|
5796
|
+
const head = loadHeadContent(modules.filenames, options.headFile, diagnostics);
|
|
5797
|
+
output = format.isTwine2 ? toTwine2HTML(story, format, startName, {
|
|
5798
|
+
sourceInfo,
|
|
5799
|
+
head
|
|
5800
|
+
}) : toTwine1HTML(story, format, startName, { head });
|
|
5577
5801
|
break;
|
|
5578
5802
|
}
|
|
5579
5803
|
default: throw new TweeTsError(`Unhandled output mode: ${outputMode}`, diagnostics);
|
|
@@ -5992,6 +6216,11 @@ function endOfRegularExpression(source, pos) {
|
|
|
5992
6216
|
}
|
|
5993
6217
|
//#endregion
|
|
5994
6218
|
//#region src/link-markup.ts
|
|
6219
|
+
/** Records that the reading has looked at the characters before `pos`, and returns `result`. */
|
|
6220
|
+
function readTo(reader, pos, result) {
|
|
6221
|
+
reader.scanned = Math.max(reader.scanned, pos);
|
|
6222
|
+
return result;
|
|
6223
|
+
}
|
|
5995
6224
|
/**
|
|
5996
6225
|
* Reads link or image markup that starts at `start` in `text`, or returns `undefined` where
|
|
5997
6226
|
* SugarCube rejects it (and so prints the text instead).
|
|
@@ -5999,15 +6228,17 @@ function endOfRegularExpression(source, pos) {
|
|
|
5999
6228
|
* `budget.left`, if given, is how many characters the reading may look at; it is reduced by as
|
|
6000
6229
|
* many as were read, and once it runs out, every reading fails. Unclosed markup is read to the end
|
|
6001
6230
|
* of its line, so reading every `[[` of a long line that never closes one would otherwise take
|
|
6002
|
-
* quadratic time.
|
|
6231
|
+
* quadratic time. A reading is charged for the characters it looked at, up to where it ended or
|
|
6232
|
+
* failed (a line feed, say), and no more, so markup left unclosed on short lines costs little.
|
|
6003
6233
|
*/
|
|
6004
6234
|
function readSquareBracketedMarkup(text, start, budget = { left: Infinity }) {
|
|
6005
6235
|
const reader = {
|
|
6006
6236
|
text,
|
|
6007
|
-
limit: Math.min(text.length, start + Math.max(budget.left, 0))
|
|
6237
|
+
limit: Math.min(text.length, start + Math.max(budget.left, 0)),
|
|
6238
|
+
scanned: start
|
|
6008
6239
|
};
|
|
6009
6240
|
const markup = readMarkup(reader, start);
|
|
6010
|
-
budget.left -= (markup?.end ??
|
|
6241
|
+
budget.left -= Math.max(reader.scanned, markup?.end ?? start) - start;
|
|
6011
6242
|
return markup;
|
|
6012
6243
|
}
|
|
6013
6244
|
function readMarkup(reader, start) {
|
|
@@ -6083,7 +6314,7 @@ function readCoreComponents(reader, from) {
|
|
|
6083
6314
|
const ch = text[pos];
|
|
6084
6315
|
pos += 1;
|
|
6085
6316
|
switch (ch) {
|
|
6086
|
-
case "\n": return;
|
|
6317
|
+
case "\n": return readTo(reader, pos, void 0);
|
|
6087
6318
|
case "\"": {
|
|
6088
6319
|
const end = readQuoted(reader, pos, "\"");
|
|
6089
6320
|
if (end === void 0) return;
|
|
@@ -6117,7 +6348,7 @@ function readCoreComponents(reader, from) {
|
|
|
6117
6348
|
case "]":
|
|
6118
6349
|
depth -= 1;
|
|
6119
6350
|
if (depth === 1) {
|
|
6120
|
-
const end = componentEnd(
|
|
6351
|
+
const end = componentEnd(reader, pos);
|
|
6121
6352
|
if (end === void 0) return;
|
|
6122
6353
|
return {
|
|
6123
6354
|
link: link ?? text.slice(componentStart, end.textEnd),
|
|
@@ -6126,6 +6357,7 @@ function readCoreComponents(reader, from) {
|
|
|
6126
6357
|
}
|
|
6127
6358
|
}
|
|
6128
6359
|
}
|
|
6360
|
+
return readTo(reader, pos, void 0);
|
|
6129
6361
|
}
|
|
6130
6362
|
/**
|
|
6131
6363
|
* Reads a component after `][` that holds no delimiter: an image's link (`setter` false) or a
|
|
@@ -6139,7 +6371,7 @@ function readComponent(reader, from, setter) {
|
|
|
6139
6371
|
const ch = text[pos];
|
|
6140
6372
|
pos += 1;
|
|
6141
6373
|
switch (ch) {
|
|
6142
|
-
case "\n": return;
|
|
6374
|
+
case "\n": return readTo(reader, pos, void 0);
|
|
6143
6375
|
case "\"":
|
|
6144
6376
|
case "'": {
|
|
6145
6377
|
if (ch === "'" && !setter) break;
|
|
@@ -6153,9 +6385,10 @@ function readComponent(reader, from, setter) {
|
|
|
6153
6385
|
break;
|
|
6154
6386
|
case "]":
|
|
6155
6387
|
depth -= 1;
|
|
6156
|
-
if (depth === 1) return componentEnd(
|
|
6388
|
+
if (depth === 1) return componentEnd(reader, pos);
|
|
6157
6389
|
}
|
|
6158
6390
|
}
|
|
6391
|
+
return readTo(reader, pos, void 0);
|
|
6159
6392
|
}
|
|
6160
6393
|
/** Reads a setter, the last component; returns the index after its closing `]]`. */
|
|
6161
6394
|
function readSetter(reader, from) {
|
|
@@ -6163,19 +6396,19 @@ function readSetter(reader, from) {
|
|
|
6163
6396
|
return end?.last === true ? end.next : void 0;
|
|
6164
6397
|
}
|
|
6165
6398
|
/** What the `]` just before `pos`, which brings the depth back to one, ends; `undefined` if malformed. */
|
|
6166
|
-
function componentEnd(
|
|
6167
|
-
switch (text[pos]) {
|
|
6168
|
-
case "[": return {
|
|
6399
|
+
function componentEnd(reader, pos) {
|
|
6400
|
+
switch (reader.text[pos]) {
|
|
6401
|
+
case "[": return readTo(reader, pos + 1, {
|
|
6169
6402
|
textEnd: pos - 1,
|
|
6170
6403
|
next: pos + 1,
|
|
6171
6404
|
last: false
|
|
6172
|
-
};
|
|
6173
|
-
case "]": return {
|
|
6405
|
+
});
|
|
6406
|
+
case "]": return readTo(reader, pos + 1, {
|
|
6174
6407
|
textEnd: pos - 1,
|
|
6175
6408
|
next: pos + 1,
|
|
6176
6409
|
last: true
|
|
6177
|
-
};
|
|
6178
|
-
default: return;
|
|
6410
|
+
});
|
|
6411
|
+
default: return readTo(reader, pos + 1, void 0);
|
|
6179
6412
|
}
|
|
6180
6413
|
}
|
|
6181
6414
|
/**
|
|
@@ -6190,12 +6423,13 @@ function readQuoted(reader, from, quote) {
|
|
|
6190
6423
|
const ch = text[pos];
|
|
6191
6424
|
pos += 1;
|
|
6192
6425
|
if (ch === quote) return pos;
|
|
6193
|
-
if (ch === "\n") return;
|
|
6426
|
+
if (ch === "\n") return readTo(reader, pos, void 0);
|
|
6194
6427
|
if (ch === "\\") {
|
|
6195
|
-
if (pos >= reader.limit || text[pos] === "\n") return;
|
|
6428
|
+
if (pos >= reader.limit || text[pos] === "\n") return readTo(reader, Math.min(pos + 1, reader.limit), void 0);
|
|
6196
6429
|
pos += 1;
|
|
6197
6430
|
}
|
|
6198
6431
|
}
|
|
6432
|
+
return readTo(reader, pos, void 0);
|
|
6199
6433
|
}
|
|
6200
6434
|
//#endregion
|
|
6201
6435
|
//#region src/sugarcube-macros.ts
|
|
@@ -7053,6 +7287,39 @@ function extractLinksFromText(text, isScript) {
|
|
|
7053
7287
|
].flatMap((via) => found.filter((link) => link.via === via).map((link) => link.passage));
|
|
7054
7288
|
return [...new Set(ordered)];
|
|
7055
7289
|
}
|
|
7290
|
+
/**
|
|
7291
|
+
* The passages that `p` links to. A script passage is JavaScript, read for links only in its
|
|
7292
|
+
* strings. A stylesheet is CSS, which the story puts in a style element and never wikifies, so it
|
|
7293
|
+
* links nowhere; a passage tagged both is a script, as Twine 2 output takes it.
|
|
7294
|
+
*/
|
|
7295
|
+
function passageLinks(p) {
|
|
7296
|
+
if (hasTag(p, "script")) return extractLinksFromText(textAsRead(p), true);
|
|
7297
|
+
if (hasTag(p, "stylesheet")) return [];
|
|
7298
|
+
return extractLinksFromText(textAsRead(p), false);
|
|
7299
|
+
}
|
|
7300
|
+
/**
|
|
7301
|
+
* Whether the `target` output keeps the links in `p`: always without a target, which describes
|
|
7302
|
+
* every source passage. With one, a passage the output leaves out never reaches the player, so it
|
|
7303
|
+
* links nowhere, except a script passage, which Twine 2 output leaves out as a passage but runs.
|
|
7304
|
+
*/
|
|
7305
|
+
function readsLinks(story, p, { target }) {
|
|
7306
|
+
if (target === void 0) return true;
|
|
7307
|
+
const omission = passageOmission(story, p, target);
|
|
7308
|
+
return omission === void 0 || omission.kind === "tag" && omission.tag === "script";
|
|
7309
|
+
}
|
|
7310
|
+
/**
|
|
7311
|
+
* Each passage name, mapped to why the `target` output leaves that passage out, or to `undefined`
|
|
7312
|
+
* when a link can reach it: always without a target, and otherwise when the output emits a
|
|
7313
|
+
* passage of that name.
|
|
7314
|
+
*/
|
|
7315
|
+
function destinationOmissions(story, { target }) {
|
|
7316
|
+
const omissions = /* @__PURE__ */ new Map();
|
|
7317
|
+
for (const p of story.passages) {
|
|
7318
|
+
const omission = target === void 0 ? void 0 : passageOmission(story, p, target);
|
|
7319
|
+
if (!omissions.has(p.name) || omission === void 0) omissions.set(p.name, omission);
|
|
7320
|
+
}
|
|
7321
|
+
return omissions;
|
|
7322
|
+
}
|
|
7056
7323
|
/** Passages SugarCube runs from their raw text, never joining their lines. */
|
|
7057
7324
|
const RAW_TEXT_PASSAGES = /* @__PURE__ */ new Set([
|
|
7058
7325
|
"StoryInit",
|
|
@@ -7094,9 +7361,16 @@ function joinLines(text) {
|
|
|
7094
7361
|
* expect(map.brokenLinks).toEqual([]);
|
|
7095
7362
|
* expect(map.deadEnds).not.toContain('Start');
|
|
7096
7363
|
* ```
|
|
7097
|
-
|
|
7098
|
-
|
|
7099
|
-
|
|
7364
|
+
*
|
|
7365
|
+
* By default, a link is broken only when no passage has its name. Pass a `target` to check links
|
|
7366
|
+
* against what that output emits: `storyInspect(result.story, { target: 'twine2' })` also reports
|
|
7367
|
+
* links to passages Twine 2 output leaves out, such as script, stylesheet and `Twine.private`
|
|
7368
|
+
* passages. With a target, passages that output leaves out also give no links (script passages
|
|
7369
|
+
* excepted, as Twine 2 output runs them), so they report no broken links and keep no passage from
|
|
7370
|
+
* being an orphan.
|
|
7371
|
+
*/
|
|
7372
|
+
function storyInspect(story, options = {}) {
|
|
7373
|
+
const destinations = destinationOmissions(story, options);
|
|
7100
7374
|
const passages = [];
|
|
7101
7375
|
const storyPassages = [];
|
|
7102
7376
|
const infoPassages = [];
|
|
@@ -7118,14 +7392,24 @@ function storyInspect(story) {
|
|
|
7118
7392
|
}
|
|
7119
7393
|
list.push(p.name);
|
|
7120
7394
|
}
|
|
7121
|
-
|
|
7122
|
-
links.set(p.name, targets);
|
|
7395
|
+
links.set(p.name, readsLinks(story, p, options) ? passageLinks(p) : []);
|
|
7123
7396
|
}
|
|
7124
7397
|
const brokenLinks = [];
|
|
7125
|
-
for (const [from, targets] of links) for (const to of targets)
|
|
7126
|
-
|
|
7127
|
-
|
|
7128
|
-
|
|
7398
|
+
for (const [from, targets] of links) for (const to of targets) {
|
|
7399
|
+
if (!destinations.has(to)) {
|
|
7400
|
+
brokenLinks.push({
|
|
7401
|
+
from,
|
|
7402
|
+
to
|
|
7403
|
+
});
|
|
7404
|
+
continue;
|
|
7405
|
+
}
|
|
7406
|
+
const omission = destinations.get(to);
|
|
7407
|
+
if (omission !== void 0) brokenLinks.push({
|
|
7408
|
+
from,
|
|
7409
|
+
to,
|
|
7410
|
+
omission
|
|
7411
|
+
});
|
|
7412
|
+
}
|
|
7129
7413
|
const deadEnds = [];
|
|
7130
7414
|
for (const name of storyPassages) {
|
|
7131
7415
|
const targets = links.get(name);
|
|
@@ -7153,16 +7437,18 @@ function storyInspect(story) {
|
|
|
7153
7437
|
//#endregion
|
|
7154
7438
|
//#region src/lint.ts
|
|
7155
7439
|
/**
|
|
7156
|
-
*
|
|
7157
|
-
*
|
|
7158
|
-
*
|
|
7440
|
+
* lint() for a project whose builds are written to `outFile`: like a build for that file,
|
|
7441
|
+
* it leaves `outFile` out of the sources and modules, so an earlier build inside a source
|
|
7442
|
+
* folder is not linted as a source.
|
|
7443
|
+
*
|
|
7444
|
+
* Internal, for the CLI; not part of the public API.
|
|
7159
7445
|
*/
|
|
7160
|
-
async function
|
|
7161
|
-
const result = await
|
|
7446
|
+
async function lintForOutputFile(options, outFile) {
|
|
7447
|
+
const result = await compileForOutputFile({
|
|
7162
7448
|
...options,
|
|
7163
7449
|
outputMode: "json"
|
|
7164
|
-
});
|
|
7165
|
-
const map = storyInspect(result.story);
|
|
7450
|
+
}, outFile);
|
|
7451
|
+
const map = storyInspect(result.story, { target: "twine2" });
|
|
7166
7452
|
return {
|
|
7167
7453
|
diagnostics: [...result.diagnostics, ...startPassageDiagnostics(result.story, map.start, "twine2")],
|
|
7168
7454
|
stats: result.stats,
|
|
@@ -7189,7 +7475,10 @@ function formatLintReport(result) {
|
|
|
7189
7475
|
if (result.brokenLinks.length > 0) {
|
|
7190
7476
|
lines.push("");
|
|
7191
7477
|
lines.push(`Broken links (${result.brokenLinks.length}):`);
|
|
7192
|
-
for (const link of result.brokenLinks)
|
|
7478
|
+
for (const link of result.brokenLinks) {
|
|
7479
|
+
const why = link.omission === void 0 ? "does not exist" : `${describeOmission(link.omission)}, so it is left out of the story data`;
|
|
7480
|
+
lines.push(` ${link.from} -> ${link.to} (passage "${link.to}" ${why})`);
|
|
7481
|
+
}
|
|
7193
7482
|
}
|
|
7194
7483
|
if (result.deadEnds.length > 0) {
|
|
7195
7484
|
lines.push("");
|
|
@@ -7472,8 +7761,10 @@ async function main() {
|
|
|
7472
7761
|
}
|
|
7473
7762
|
return raw;
|
|
7474
7763
|
})();
|
|
7764
|
+
const outFile = values.output ?? config?.output ?? "-";
|
|
7765
|
+
const outPath = outFile === "-" ? void 0 : outFile;
|
|
7475
7766
|
if (values.lint) {
|
|
7476
|
-
const lintResult = await
|
|
7767
|
+
const lintResult = await lintForOutputFile({
|
|
7477
7768
|
sources,
|
|
7478
7769
|
exclude: values.exclude ?? config?.exclude,
|
|
7479
7770
|
formatId: values.format ?? config?.formatId,
|
|
@@ -7490,7 +7781,7 @@ async function main() {
|
|
|
7490
7781
|
tagAliases,
|
|
7491
7782
|
sourceInfo: values["source-info"] ?? config?.sourceInfo ?? false,
|
|
7492
7783
|
wordCountMethod
|
|
7493
|
-
});
|
|
7784
|
+
}, outPath);
|
|
7494
7785
|
console.log(formatLintReport(lintResult));
|
|
7495
7786
|
const hasErrors = lintResult.brokenLinks.length > 0 || lintResult.diagnostics.some((d) => d.level === "error");
|
|
7496
7787
|
process.exit(hasErrors ? 1 : 0);
|
|
@@ -7515,39 +7806,46 @@ async function main() {
|
|
|
7515
7806
|
sourceInfo: values["source-info"] ?? config?.sourceInfo ?? false,
|
|
7516
7807
|
wordCountMethod
|
|
7517
7808
|
};
|
|
7518
|
-
const
|
|
7809
|
+
const log = {
|
|
7810
|
+
files: outPath !== void 0 && (values["log-files"] ?? false),
|
|
7811
|
+
stats: values["log-stats"] ?? false
|
|
7812
|
+
};
|
|
7519
7813
|
if (values.watch) {
|
|
7520
|
-
if (
|
|
7814
|
+
if (outPath === void 0) {
|
|
7521
7815
|
console.error("Error: Watch mode requires an output file (-o).");
|
|
7522
7816
|
process.exit(1);
|
|
7523
7817
|
}
|
|
7524
7818
|
console.log("Watch mode started. Press CTRL+C to stop.");
|
|
7525
|
-
await
|
|
7819
|
+
await watchWithWriteFilter({
|
|
7526
7820
|
...compileOptions,
|
|
7527
|
-
outFile,
|
|
7821
|
+
outFile: outPath,
|
|
7528
7822
|
onBuild(result) {
|
|
7529
7823
|
console.log(`Built: ${result.stats.passages} passages, ${result.stats.words} words`);
|
|
7530
7824
|
logDiagnostics(result.diagnostics);
|
|
7531
7825
|
const errors = countErrors(result.diagnostics);
|
|
7532
|
-
if (errors > 0) console.error(`Build has ${pluralize(errors, "error")};
|
|
7826
|
+
if (errors > 0) console.error(`Build has ${pluralize(errors, "error")}; output not written. Still watching for changes.`);
|
|
7827
|
+
logBuild(result, log);
|
|
7533
7828
|
},
|
|
7534
7829
|
onError(error) {
|
|
7535
7830
|
console.error(`Build error: ${error.message}`);
|
|
7536
7831
|
}
|
|
7537
|
-
});
|
|
7832
|
+
}, (result) => countErrors(result.diagnostics) === 0);
|
|
7538
7833
|
} else {
|
|
7539
|
-
const result = await
|
|
7834
|
+
const result = await compileForOutputFile(compileOptions, outPath);
|
|
7540
7835
|
logDiagnostics(result.diagnostics);
|
|
7541
7836
|
const errors = countErrors(result.diagnostics);
|
|
7542
7837
|
if (errors > 0) {
|
|
7543
7838
|
console.error(`Compilation failed with ${pluralize(errors, "error")}; output not written.`);
|
|
7544
7839
|
process.exitCode = 1;
|
|
7545
|
-
} else if (
|
|
7546
|
-
else writeFileSync(
|
|
7547
|
-
|
|
7548
|
-
if (values["log-stats"]) logStats(result);
|
|
7840
|
+
} else if (outPath === void 0) process.stdout.write(result.output);
|
|
7841
|
+
else writeFileSync(outPath, result.output, "utf-8");
|
|
7842
|
+
logBuild(result, log);
|
|
7549
7843
|
}
|
|
7550
7844
|
}
|
|
7845
|
+
function logBuild(result, log) {
|
|
7846
|
+
if (log.files) console.log(`\nFiles: ${result.stats.files.join(", ")}`);
|
|
7847
|
+
if (log.stats) logStats(result);
|
|
7848
|
+
}
|
|
7551
7849
|
function countErrors(diagnostics) {
|
|
7552
7850
|
return diagnostics.filter((d) => d.level === "error").length;
|
|
7553
7851
|
}
|