@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.cjs
CHANGED
|
@@ -36,11 +36,27 @@ let node_url = require("node:url");
|
|
|
36
36
|
function generateIFID() {
|
|
37
37
|
return (0, node_crypto.randomUUID)().toUpperCase();
|
|
38
38
|
}
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Validate and brand an IFID string. Throws on invalid input.
|
|
41
|
+
* Returns the IFID in its stored form (see `normalizeIFID`).
|
|
42
|
+
*/
|
|
40
43
|
function createIFID(value) {
|
|
41
44
|
const err = validateIFID(value);
|
|
42
45
|
if (err) throw new Error(`Invalid IFID: ${err}`);
|
|
43
|
-
return value
|
|
46
|
+
return normalizeIFID(value);
|
|
47
|
+
}
|
|
48
|
+
/** Length of the `UUID://...//` wrapper around a 36-character UUID. */
|
|
49
|
+
const WRAPPED_LENGTH = 45;
|
|
50
|
+
/**
|
|
51
|
+
* Bring an IFID into the form the story model stores and every output writes: the uppercase
|
|
52
|
+
* bare UUID. A valid `UUID://...//` wrapped IFID loses its wrapper; outputs that need the wrapper
|
|
53
|
+
* (the Treaty of Babel comment) add exactly one. Twine 2 IFIDs use only capital letters, so the
|
|
54
|
+
* value is uppercased, as Tweego does. An invalid value is only uppercased, so that validation
|
|
55
|
+
* reports it as written.
|
|
56
|
+
*/
|
|
57
|
+
function normalizeIFID(value) {
|
|
58
|
+
const upper = value.toUpperCase();
|
|
59
|
+
return upper.length === WRAPPED_LENGTH && validateIFID(upper) === null ? upper.slice(7, 43) : upper;
|
|
44
60
|
}
|
|
45
61
|
/**
|
|
46
62
|
* Validate an IFID string.
|
|
@@ -470,7 +486,7 @@ function unmarshalStoryData(story, json) {
|
|
|
470
486
|
}
|
|
471
487
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return "Cannot unmarshal \"StoryData\"; expected a JSON object";
|
|
472
488
|
const data = raw;
|
|
473
|
-
if (typeof data.ifid === "string" && data.ifid) story.ifid = data.ifid
|
|
489
|
+
if (typeof data.ifid === "string" && data.ifid) story.ifid = normalizeIFID(data.ifid);
|
|
474
490
|
if (typeof data.format === "string" && data.format) story.twine2.format = data.format;
|
|
475
491
|
if (typeof data["format-version"] === "string" && data["format-version"]) story.twine2.formatVersion = data["format-version"];
|
|
476
492
|
if (Array.isArray(data.options)) {
|
|
@@ -501,7 +517,7 @@ function unmarshalStorySettings(story, text, diagnostics) {
|
|
|
501
517
|
const val = line.slice(colonIdx + 1).trim().toLowerCase();
|
|
502
518
|
switch (key) {
|
|
503
519
|
case "ifid":
|
|
504
|
-
if (validateIFID(val) === null) story.legacyIFID = val
|
|
520
|
+
if (validateIFID(val) === null) story.legacyIFID = normalizeIFID(val);
|
|
505
521
|
obsolete.push("\"ifid\"");
|
|
506
522
|
continue;
|
|
507
523
|
case "zoom":
|
|
@@ -4719,14 +4735,16 @@ function parseDocument(data, options) {
|
|
|
4719
4735
|
*/
|
|
4720
4736
|
/**
|
|
4721
4737
|
* Parse a Twine 2 or Twine 1 compiled HTML file back into a Story model.
|
|
4738
|
+
* Tweego always trims passage text; here `trim: false` keeps it exactly, as for Twee sources.
|
|
4722
4739
|
*/
|
|
4723
|
-
function decompileHTML(html) {
|
|
4740
|
+
function decompileHTML(html, options = {}) {
|
|
4724
4741
|
const diagnostics = [];
|
|
4725
4742
|
const story = createStory();
|
|
4743
|
+
const passageText = options.trim === false ? (text) => text : (text) => text.trim();
|
|
4726
4744
|
const doc = parseDocument(html);
|
|
4727
4745
|
const twine2Data = findElement(doc, "tw-storydata");
|
|
4728
4746
|
if (twine2Data) {
|
|
4729
|
-
decompileTwine2(twine2Data, story, diagnostics);
|
|
4747
|
+
decompileTwine2(twine2Data, story, passageText, diagnostics);
|
|
4730
4748
|
return {
|
|
4731
4749
|
story,
|
|
4732
4750
|
diagnostics
|
|
@@ -4734,7 +4752,7 @@ function decompileHTML(html) {
|
|
|
4734
4752
|
}
|
|
4735
4753
|
const twine1Data = findElementByIdPattern(doc, /^store(?:-a|A)rea$/);
|
|
4736
4754
|
if (twine1Data) {
|
|
4737
|
-
decompileTwine1(twine1Data, story, diagnostics);
|
|
4755
|
+
decompileTwine1(twine1Data, story, passageText, diagnostics);
|
|
4738
4756
|
return {
|
|
4739
4757
|
story,
|
|
4740
4758
|
diagnostics
|
|
@@ -4749,7 +4767,7 @@ function decompileHTML(html) {
|
|
|
4749
4767
|
diagnostics
|
|
4750
4768
|
};
|
|
4751
4769
|
}
|
|
4752
|
-
function decompileTwine2(storyData, story, diagnostics) {
|
|
4770
|
+
function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
4753
4771
|
let startnode = 0;
|
|
4754
4772
|
const attrs = storyData.attribs ?? {};
|
|
4755
4773
|
if (attrs["name"]) story.name = attrs["name"];
|
|
@@ -4761,7 +4779,7 @@ function decompileTwine2(storyData, story, diagnostics) {
|
|
|
4761
4779
|
});
|
|
4762
4780
|
else startnode = parsed;
|
|
4763
4781
|
}
|
|
4764
|
-
if (attrs["ifid"]) story.ifid = attrs["ifid"]
|
|
4782
|
+
if (attrs["ifid"]) story.ifid = normalizeIFID(attrs["ifid"]);
|
|
4765
4783
|
if (attrs["zoom"]) {
|
|
4766
4784
|
const parsed = parseFloat(attrs["zoom"]);
|
|
4767
4785
|
if (Number.isNaN(parsed)) diagnostics.push({
|
|
@@ -4774,15 +4792,20 @@ function decompileTwine2(storyData, story, diagnostics) {
|
|
|
4774
4792
|
if (attrs["format"]) story.twine2.format = attrs["format"];
|
|
4775
4793
|
if (attrs["format-version"]) story.twine2.formatVersion = attrs["format-version"];
|
|
4776
4794
|
if (attrs["options"]) for (const opt of attrs["options"].split(/\s+/).filter((s) => s.length > 0)) story.twine2.options.set(opt, true);
|
|
4777
|
-
|
|
4795
|
+
const children = storyData.children ?? [];
|
|
4796
|
+
const takenNames = new Set(children.filter((node) => node.type === "tag" && node.name === "tw-passagedata").map(passageDataName));
|
|
4797
|
+
for (const node of children) {
|
|
4778
4798
|
if (node.type !== "tag" && node.type !== "style" && node.type !== "script") continue;
|
|
4779
4799
|
switch (node.name) {
|
|
4780
4800
|
case "style":
|
|
4781
4801
|
case "script": {
|
|
4782
|
-
const
|
|
4783
|
-
if (
|
|
4802
|
+
const content = getTextContent(node);
|
|
4803
|
+
if (content.trim().length === 0) continue;
|
|
4804
|
+
const text = passageText(content);
|
|
4805
|
+
const name = freeName(node.name === "style" ? "Story Stylesheet" : "Story JavaScript", takenNames);
|
|
4806
|
+
takenNames.add(name);
|
|
4784
4807
|
storyAdd(story, {
|
|
4785
|
-
name
|
|
4808
|
+
name,
|
|
4786
4809
|
tags: node.name === "style" ? ["stylesheet"] : ["script"],
|
|
4787
4810
|
text
|
|
4788
4811
|
}, diagnostics);
|
|
@@ -4798,7 +4821,7 @@ function decompileTwine2(storyData, story, diagnostics) {
|
|
|
4798
4821
|
case "tw-passagedata": {
|
|
4799
4822
|
let pid = 0;
|
|
4800
4823
|
const pAttrs = node.attribs ?? {};
|
|
4801
|
-
const name =
|
|
4824
|
+
const name = passageDataName(node);
|
|
4802
4825
|
const tags = pAttrs["tags"] ? pAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [];
|
|
4803
4826
|
const metadata = {};
|
|
4804
4827
|
if (pAttrs["pid"]) {
|
|
@@ -4815,7 +4838,7 @@ function decompileTwine2(storyData, story, diagnostics) {
|
|
|
4815
4838
|
const passage = {
|
|
4816
4839
|
name,
|
|
4817
4840
|
tags,
|
|
4818
|
-
text: getTextContent(node)
|
|
4841
|
+
text: passageText(getTextContent(node))
|
|
4819
4842
|
};
|
|
4820
4843
|
if (metadata.position || metadata.size) passage.metadata = metadata;
|
|
4821
4844
|
storyAdd(story, passage, diagnostics);
|
|
@@ -4829,8 +4852,19 @@ function decompileTwine2(storyData, story, diagnostics) {
|
|
|
4829
4852
|
text: marshalStoryData(story)
|
|
4830
4853
|
}, diagnostics);
|
|
4831
4854
|
}
|
|
4832
|
-
function
|
|
4833
|
-
|
|
4855
|
+
function passageDataName(node) {
|
|
4856
|
+
return node.attribs?.["name"] ?? "";
|
|
4857
|
+
}
|
|
4858
|
+
/** The first of `base`, `base 2`, `base 3`, … that is not in `taken`. */
|
|
4859
|
+
function freeName(base, taken) {
|
|
4860
|
+
if (!taken.has(base)) return base;
|
|
4861
|
+
for (let n = 2;; n++) {
|
|
4862
|
+
const candidate = `${base} ${n}`;
|
|
4863
|
+
if (!taken.has(candidate)) return candidate;
|
|
4864
|
+
}
|
|
4865
|
+
}
|
|
4866
|
+
function decompileTwine1(storeArea, story, passageText, diagnostics) {
|
|
4867
|
+
const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
|
|
4834
4868
|
const obfuscated = isRot13Obfuscated(passages);
|
|
4835
4869
|
for (const p of passages) storyAdd(story, obfuscated && p.name !== "StorySettings" ? {
|
|
4836
4870
|
...p,
|
|
@@ -4840,12 +4874,12 @@ function decompileTwine1(storeArea, story, diagnostics) {
|
|
|
4840
4874
|
function isTiddler(node) {
|
|
4841
4875
|
return node.type === "tag" && node.name === "div" && node.attribs !== void 0 && "tiddler" in node.attribs;
|
|
4842
4876
|
}
|
|
4843
|
-
function tiddlerToPassage(node) {
|
|
4877
|
+
function tiddlerToPassage(node, passageText) {
|
|
4844
4878
|
const nodeAttrs = node.attribs ?? {};
|
|
4845
4879
|
const passage = {
|
|
4846
4880
|
name: nodeAttrs["tiddler"] ?? "",
|
|
4847
4881
|
tags: nodeAttrs["tags"] ? nodeAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [],
|
|
4848
|
-
text: tiddlerUnescape(getTextContent(node))
|
|
4882
|
+
text: passageText(tiddlerUnescape(getTextContent(node)))
|
|
4849
4883
|
};
|
|
4850
4884
|
const position = nodeAttrs["twine-position"];
|
|
4851
4885
|
if (position) passage.metadata = { position };
|
|
@@ -4910,7 +4944,7 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
|
|
|
4910
4944
|
break;
|
|
4911
4945
|
case "htm":
|
|
4912
4946
|
case "html":
|
|
4913
|
-
loadHTML(story, filename, diagnostics);
|
|
4947
|
+
loadHTML(story, filename, opts, diagnostics);
|
|
4914
4948
|
break;
|
|
4915
4949
|
case "css":
|
|
4916
4950
|
loadTagged(story, "stylesheet", filename, diagnostics);
|
|
@@ -5064,8 +5098,8 @@ function parseFontFile(filename) {
|
|
|
5064
5098
|
diagnostics: []
|
|
5065
5099
|
};
|
|
5066
5100
|
}
|
|
5067
|
-
function parseHTMLFile(filename) {
|
|
5068
|
-
const { story, diagnostics } = decompileHTML(readUTF8(filename));
|
|
5101
|
+
function parseHTMLFile(filename, opts) {
|
|
5102
|
+
const { story, diagnostics } = decompileHTML(readUTF8(filename), { trim: opts.trim ?? true });
|
|
5069
5103
|
return {
|
|
5070
5104
|
passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
|
|
5071
5105
|
name: "StoryTitle",
|
|
@@ -5077,14 +5111,17 @@ function parseHTMLFile(filename) {
|
|
|
5077
5111
|
}
|
|
5078
5112
|
/**
|
|
5079
5113
|
* Identity of the options that affect how a file parses, stored on its cache entry.
|
|
5080
|
-
*
|
|
5114
|
+
* Twee files depend on `trim` and `twee2Compat`, HTML files on `trim`; every other file type
|
|
5115
|
+
* yields the same key for all options.
|
|
5081
5116
|
*/
|
|
5082
5117
|
function parseOptionsKey(filename, opts) {
|
|
5083
5118
|
const ext = normalizedFileExt(filename);
|
|
5119
|
+
const trim = opts.trim ?? true;
|
|
5120
|
+
if (ext === "htm" || ext === "html") return JSON.stringify({ trim });
|
|
5084
5121
|
const twee2File = ext === "tw2" || ext === "twee2";
|
|
5085
5122
|
if (!twee2File && ext !== "tw" && ext !== "twee") return "";
|
|
5086
5123
|
return JSON.stringify({
|
|
5087
|
-
trim
|
|
5124
|
+
trim,
|
|
5088
5125
|
twee2Compat: twee2File || (opts.twee2Compat ?? false)
|
|
5089
5126
|
});
|
|
5090
5127
|
}
|
|
@@ -5098,7 +5135,7 @@ function parseFile(filename, opts) {
|
|
|
5098
5135
|
twee2Compat: true
|
|
5099
5136
|
});
|
|
5100
5137
|
case "htm":
|
|
5101
|
-
case "html": return parseHTMLFile(filename);
|
|
5138
|
+
case "html": return parseHTMLFile(filename, opts);
|
|
5102
5139
|
case "css": return parseTaggedFile("stylesheet", filename);
|
|
5103
5140
|
case "js": return parseTaggedFile("script", filename);
|
|
5104
5141
|
case "otf":
|
|
@@ -5130,8 +5167,8 @@ function parseFile(filename, opts) {
|
|
|
5130
5167
|
default: return;
|
|
5131
5168
|
}
|
|
5132
5169
|
}
|
|
5133
|
-
function loadHTML(story, filename, diagnostics) {
|
|
5134
|
-
const result = parseHTMLFile(filename);
|
|
5170
|
+
function loadHTML(story, filename, opts, diagnostics) {
|
|
5171
|
+
const result = parseHTMLFile(filename, opts);
|
|
5135
5172
|
diagnostics.push(...result.diagnostics);
|
|
5136
5173
|
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
5137
5174
|
}
|
|
@@ -5170,7 +5207,8 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5170
5207
|
const optionsKey = parseOptionsKey(filename, opts);
|
|
5171
5208
|
const entry = cache.get(filename);
|
|
5172
5209
|
const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
|
|
5173
|
-
|
|
5210
|
+
const changed = changedFiles?.has(filename) ?? false;
|
|
5211
|
+
if (changedFiles && cached && !changed) {
|
|
5174
5212
|
diagnostics.push(...cached.diagnostics);
|
|
5175
5213
|
for (const p of cached.passages) storyAdd(story, p, diagnostics);
|
|
5176
5214
|
processedFiles.add(filename);
|
|
@@ -5182,7 +5220,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5182
5220
|
} catch {
|
|
5183
5221
|
continue;
|
|
5184
5222
|
}
|
|
5185
|
-
if (cached && cached.mtimeMs === mtimeMs) {
|
|
5223
|
+
if (cached && !changed && cached.mtimeMs === mtimeMs) {
|
|
5186
5224
|
diagnostics.push(...cached.diagnostics);
|
|
5187
5225
|
for (const p of cached.passages) storyAdd(story, p, diagnostics);
|
|
5188
5226
|
processedFiles.add(filename);
|
|
@@ -5217,6 +5255,121 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5217
5255
|
}, diagnostics);
|
|
5218
5256
|
}
|
|
5219
5257
|
//#endregion
|
|
5258
|
+
//#region src/template.ts
|
|
5259
|
+
/**
|
|
5260
|
+
* An HTML closing head tag: `</head` in any letter case, followed by whitespace, `/` or `>`, up to the next `>`.
|
|
5261
|
+
* This is how the HTML tokenizer reads an end tag; it ignores whatever sits between the tag name and the `>`.
|
|
5262
|
+
*/
|
|
5263
|
+
const CLOSING_HEAD_TAG = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>";
|
|
5264
|
+
/** The regular expression source matching exactly `text`. */
|
|
5265
|
+
function literal(text) {
|
|
5266
|
+
return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
5267
|
+
}
|
|
5268
|
+
/** Replace the `slots` placeholders found in `template`, without scanning inserted text. */
|
|
5269
|
+
function fillTemplate(template, slots) {
|
|
5270
|
+
return fillTemplateParts([{
|
|
5271
|
+
kind: "scan",
|
|
5272
|
+
text: template,
|
|
5273
|
+
slots
|
|
5274
|
+
}]);
|
|
5275
|
+
}
|
|
5276
|
+
/**
|
|
5277
|
+
* Fill the parts of a template in order, joined. A `'first'` slot replaced in one part is not replaced again in a
|
|
5278
|
+
* later part that lists the same slot object.
|
|
5279
|
+
*/
|
|
5280
|
+
function fillTemplateParts(parts) {
|
|
5281
|
+
const filled = /* @__PURE__ */ new Set();
|
|
5282
|
+
return parts.map((part) => {
|
|
5283
|
+
if (part.kind === "verbatim" || part.slots.length === 0) return part.text;
|
|
5284
|
+
const { slots } = part;
|
|
5285
|
+
const pattern = new RegExp(slots.map((slot) => `(${slot.pattern})`).join("|"), "g");
|
|
5286
|
+
return part.text.replace(pattern, (match, ...groups) => {
|
|
5287
|
+
const slot = slots.find((_, i) => groups[i] !== void 0);
|
|
5288
|
+
if (slot === void 0 || slot.occurrences === "first" && filled.has(slot)) return match;
|
|
5289
|
+
filled.add(slot);
|
|
5290
|
+
return slot.replacement(match);
|
|
5291
|
+
});
|
|
5292
|
+
}).join("");
|
|
5293
|
+
}
|
|
5294
|
+
//#endregion
|
|
5295
|
+
//#region src/modules.ts
|
|
5296
|
+
/**
|
|
5297
|
+
* Load modules and return HTML tags to inject before the closing head tag.
|
|
5298
|
+
*/
|
|
5299
|
+
function loadModules(filenames) {
|
|
5300
|
+
const processed = /* @__PURE__ */ new Set();
|
|
5301
|
+
const headTags = [];
|
|
5302
|
+
for (const filename of filenames) {
|
|
5303
|
+
if (processed.has(filename)) continue;
|
|
5304
|
+
const ext = normalizedFileExt(filename);
|
|
5305
|
+
let tag = null;
|
|
5306
|
+
switch (ext) {
|
|
5307
|
+
case "css":
|
|
5308
|
+
tag = loadModuleTagged("style", filename);
|
|
5309
|
+
break;
|
|
5310
|
+
case "js":
|
|
5311
|
+
tag = loadModuleTagged("script", filename);
|
|
5312
|
+
break;
|
|
5313
|
+
case "otf":
|
|
5314
|
+
case "ttf":
|
|
5315
|
+
case "woff":
|
|
5316
|
+
case "woff2":
|
|
5317
|
+
tag = loadModuleFont(filename);
|
|
5318
|
+
break;
|
|
5319
|
+
default: continue;
|
|
5320
|
+
}
|
|
5321
|
+
if (tag) headTags.push(tag);
|
|
5322
|
+
processed.add(filename);
|
|
5323
|
+
}
|
|
5324
|
+
return headTags.join("\n");
|
|
5325
|
+
}
|
|
5326
|
+
function loadModuleTagged(tag, filename) {
|
|
5327
|
+
const source = readUTF8(filename).trim();
|
|
5328
|
+
if (source.length === 0) return null;
|
|
5329
|
+
return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
|
|
5330
|
+
}
|
|
5331
|
+
function loadModuleFont(filename) {
|
|
5332
|
+
const source = readBase64(filename);
|
|
5333
|
+
const family = baseNameWithoutExt(filename);
|
|
5334
|
+
const idSlug = `style-module-${slugify(family)}`;
|
|
5335
|
+
const ext = normalizedFileExt(filename);
|
|
5336
|
+
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>`;
|
|
5337
|
+
}
|
|
5338
|
+
/**
|
|
5339
|
+
* Load the module tags and head file content to inject before the closing head tag, joined by newlines,
|
|
5340
|
+
* or `''` when there is nothing to inject.
|
|
5341
|
+
*/
|
|
5342
|
+
function loadHeadContent(modulePaths, headFile, diagnostics) {
|
|
5343
|
+
const parts = [];
|
|
5344
|
+
if (modulePaths.length > 0) {
|
|
5345
|
+
const modules = loadModules(modulePaths).trim();
|
|
5346
|
+
if (modules.length > 0) parts.push(modules);
|
|
5347
|
+
}
|
|
5348
|
+
if (headFile) try {
|
|
5349
|
+
const source = readUTF8(headFile).trim();
|
|
5350
|
+
if (source.length > 0) parts.push(source);
|
|
5351
|
+
} catch (e) {
|
|
5352
|
+
diagnostics?.push({
|
|
5353
|
+
level: "warning",
|
|
5354
|
+
message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
|
|
5355
|
+
});
|
|
5356
|
+
}
|
|
5357
|
+
return parts.join("\n");
|
|
5358
|
+
}
|
|
5359
|
+
/**
|
|
5360
|
+
* The template slot that inserts `content` on its own line before the first closing head tag (as Tweego does),
|
|
5361
|
+
* or `undefined` when `content` is empty. The tag may use any letter case and whitespace before its `>`, and is
|
|
5362
|
+
* kept as written.
|
|
5363
|
+
*/
|
|
5364
|
+
function headSlot(content) {
|
|
5365
|
+
if (content.length === 0) return void 0;
|
|
5366
|
+
return {
|
|
5367
|
+
pattern: CLOSING_HEAD_TAG,
|
|
5368
|
+
occurrences: "first",
|
|
5369
|
+
replacement: (tag) => `${content}\n${tag}`
|
|
5370
|
+
};
|
|
5371
|
+
}
|
|
5372
|
+
//#endregion
|
|
5220
5373
|
//#region src/version.ts
|
|
5221
5374
|
/**
|
|
5222
5375
|
* The twee-ts version, read at runtime from the package's own package.json.
|
|
@@ -5250,24 +5403,38 @@ const CREATOR_NAME$2 = "Twee-ts";
|
|
|
5250
5403
|
function toTwine2Archive(story, startName, options) {
|
|
5251
5404
|
return getTwine2DataChunk(story, startName, options) + "\n";
|
|
5252
5405
|
}
|
|
5406
|
+
/**
|
|
5407
|
+
* Fill the Twine 2 format template: every `{{STORY_NAME}}` gets the escaped story name and the first
|
|
5408
|
+
* `{{STORY_DATA}}` the story data (as Tweego does), and `head` goes before the first closing head tag. All three are
|
|
5409
|
+
* found in the template in one pass, so a story name, passage text or head content holding a placeholder or a
|
|
5410
|
+
* closing head tag stays literal.
|
|
5411
|
+
*/
|
|
5253
5412
|
function toTwine2HTML(story, format, startName, options) {
|
|
5254
|
-
|
|
5255
|
-
|
|
5256
|
-
|
|
5257
|
-
|
|
5258
|
-
|
|
5259
|
-
|
|
5260
|
-
|
|
5261
|
-
|
|
5262
|
-
|
|
5263
|
-
|
|
5264
|
-
|
|
5265
|
-
|
|
5413
|
+
const name = htmlEscape(story.name);
|
|
5414
|
+
const head = headSlot(options?.head ?? "");
|
|
5415
|
+
const slots = [
|
|
5416
|
+
{
|
|
5417
|
+
pattern: literal("{{STORY_NAME}}"),
|
|
5418
|
+
occurrences: "all",
|
|
5419
|
+
replacement: () => name
|
|
5420
|
+
},
|
|
5421
|
+
{
|
|
5422
|
+
pattern: literal("{{STORY_DATA}}"),
|
|
5423
|
+
occurrences: "first",
|
|
5424
|
+
replacement: () => {
|
|
5425
|
+
return getTwine2DataChunk({
|
|
5426
|
+
...story,
|
|
5427
|
+
twine2: {
|
|
5428
|
+
...story.twine2,
|
|
5429
|
+
format: format.name,
|
|
5430
|
+
formatVersion: format.version
|
|
5431
|
+
}
|
|
5432
|
+
}, startName, options);
|
|
5266
5433
|
}
|
|
5267
|
-
},
|
|
5268
|
-
|
|
5269
|
-
|
|
5270
|
-
return
|
|
5434
|
+
},
|
|
5435
|
+
...head === void 0 ? [] : [head]
|
|
5436
|
+
];
|
|
5437
|
+
return fillTemplate(readFormatSource(format), slots);
|
|
5271
5438
|
}
|
|
5272
5439
|
const OMITTING_TAGS = [
|
|
5273
5440
|
"Twine.private",
|
|
@@ -5360,7 +5527,13 @@ function toTwine1Archive(story, _startName) {
|
|
|
5360
5527
|
const { data, count } = getTwine1PassageChunk(story);
|
|
5361
5528
|
return `<div id="storeArea" data-size="${count}">${data}</div>\n`;
|
|
5362
5529
|
}
|
|
5363
|
-
|
|
5530
|
+
/**
|
|
5531
|
+
* Fill the Twine 1 format template. The format's components are inserted first, one after another, as Tweego and
|
|
5532
|
+
* Twine 1 do. Then the first of each story placeholder (`"VERSION"`, `"TIME"`, `"START_AT"`, `"STORY_SIZE"`,
|
|
5533
|
+
* `"STORY"`), the IFID comment and `head` (before the first closing head tag) are found in one pass, so a start
|
|
5534
|
+
* passage name or story data holding a placeholder or a closing head tag stays literal.
|
|
5535
|
+
*/
|
|
5536
|
+
function toTwine1HTML(story, format, startName, options) {
|
|
5364
5537
|
const formatDir = (0, node_path.dirname)(format.filename);
|
|
5365
5538
|
const parentDir = (0, node_path.dirname)(formatDir);
|
|
5366
5539
|
let template = readFormatSource(format);
|
|
@@ -5371,28 +5544,60 @@ function toTwine1HTML(story, format, startName) {
|
|
|
5371
5544
|
template = tryReplaceComponent(template, "\"JONAH\"", (0, node_path.join)(formatDir, "code.js"), true);
|
|
5372
5545
|
if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", (0, node_path.join)(parentDir, "jquery.js"), true);
|
|
5373
5546
|
if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", (0, node_path.join)(parentDir, "modernizr.js"), true);
|
|
5547
|
+
const isPre14 = !template.includes("\"STORY\"");
|
|
5548
|
+
const footer = isPre14 ? readFooter(formatDir) : "";
|
|
5549
|
+
const storeArea = (template + footer).includes("<div id=\"store-area\"") ? "<div id=\"store-area\"" : "<div id=\"storeArea\"";
|
|
5550
|
+
const safeIfid = htmlCommentSanitize(story.ifid);
|
|
5551
|
+
const late = [story.ifid ? {
|
|
5552
|
+
pattern: literal(storeArea),
|
|
5553
|
+
occurrences: "first",
|
|
5554
|
+
replacement: (div) => `<!-- UUID://${safeIfid}// -->${div}`
|
|
5555
|
+
} : void 0, headSlot(options?.head ?? "")].filter((slot) => slot !== void 0);
|
|
5374
5556
|
const displayStart = startName === "Start" ? "" : startName;
|
|
5375
|
-
|
|
5376
|
-
|
|
5377
|
-
|
|
5378
|
-
|
|
5379
|
-
|
|
5380
|
-
|
|
5381
|
-
|
|
5382
|
-
|
|
5383
|
-
|
|
5384
|
-
|
|
5385
|
-
|
|
5386
|
-
|
|
5387
|
-
|
|
5388
|
-
|
|
5389
|
-
|
|
5390
|
-
|
|
5391
|
-
|
|
5392
|
-
|
|
5393
|
-
|
|
5557
|
+
const slots = [
|
|
5558
|
+
firstSlot("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`),
|
|
5559
|
+
firstSlot("\"TIME\"", `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`),
|
|
5560
|
+
firstSlot("\"START_AT\"", `"${jsStringEscape(displayStart)}"`),
|
|
5561
|
+
firstSlot("\"STORY_SIZE\"", `"${count}"`),
|
|
5562
|
+
firstSlot("\"STORY\"", data),
|
|
5563
|
+
...late
|
|
5564
|
+
];
|
|
5565
|
+
return fillTemplateParts(isPre14 ? [
|
|
5566
|
+
{
|
|
5567
|
+
kind: "scan",
|
|
5568
|
+
text: template,
|
|
5569
|
+
slots
|
|
5570
|
+
},
|
|
5571
|
+
{
|
|
5572
|
+
kind: "verbatim",
|
|
5573
|
+
text: data
|
|
5574
|
+
},
|
|
5575
|
+
{
|
|
5576
|
+
kind: "scan",
|
|
5577
|
+
text: footer,
|
|
5578
|
+
slots: late
|
|
5579
|
+
}
|
|
5580
|
+
] : [{
|
|
5581
|
+
kind: "scan",
|
|
5582
|
+
text: template,
|
|
5583
|
+
slots
|
|
5584
|
+
}]);
|
|
5585
|
+
}
|
|
5586
|
+
/** A slot replacing the first `token` with `value`. */
|
|
5587
|
+
function firstSlot(token, value) {
|
|
5588
|
+
return {
|
|
5589
|
+
pattern: literal(token),
|
|
5590
|
+
occurrences: "first",
|
|
5591
|
+
replacement: () => value
|
|
5592
|
+
};
|
|
5593
|
+
}
|
|
5594
|
+
/** The footer of a pre-1.4 format, or the default one when the format has none. */
|
|
5595
|
+
function readFooter(formatDir) {
|
|
5596
|
+
try {
|
|
5597
|
+
return (0, node_fs.readFileSync)((0, node_path.join)(formatDir, "footer.html"), "utf-8");
|
|
5598
|
+
} catch {
|
|
5599
|
+
return "</div>\n</body>\n</html>\n";
|
|
5394
5600
|
}
|
|
5395
|
-
return template;
|
|
5396
5601
|
}
|
|
5397
5602
|
/**
|
|
5398
5603
|
* Why Twine 1 output leaves a passage out of its tiddlers, or `undefined` when the passage is emitted.
|
|
@@ -5430,82 +5635,54 @@ function tryReplaceComponent(template, placeholder, componentPath, required) {
|
|
|
5430
5635
|
}
|
|
5431
5636
|
//#endregion
|
|
5432
5637
|
//#region src/output-twee.ts
|
|
5433
|
-
function toTwee(story, outMode) {
|
|
5434
|
-
let data = "";
|
|
5435
|
-
for (const p of story.passages) data += passageToTwee(p, outMode);
|
|
5436
|
-
return data;
|
|
5437
|
-
}
|
|
5438
|
-
//#endregion
|
|
5439
|
-
//#region src/modules.ts
|
|
5440
5638
|
/**
|
|
5441
|
-
*
|
|
5639
|
+
* Serialize the story as Twee. The StoryData passage is written from the story model rather than
|
|
5640
|
+
* from its loaded text, so changes made after loading (a start passage override, test mode) are kept.
|
|
5641
|
+
* Tweego writes the loaded text; its own normalization makes the two the same for an unchanged story.
|
|
5642
|
+
*
|
|
5643
|
+
* `addStoryData`: add a StoryData passage when the story has none, so that StoryData fields the
|
|
5644
|
+
* compile options set are recorded. Default: false.
|
|
5442
5645
|
*/
|
|
5443
|
-
function
|
|
5444
|
-
|
|
5445
|
-
const headTags = [];
|
|
5446
|
-
for (const filename of filenames) {
|
|
5447
|
-
if (processed.has(filename)) continue;
|
|
5448
|
-
const ext = normalizedFileExt(filename);
|
|
5449
|
-
let tag = null;
|
|
5450
|
-
switch (ext) {
|
|
5451
|
-
case "css":
|
|
5452
|
-
tag = loadModuleTagged("style", filename);
|
|
5453
|
-
break;
|
|
5454
|
-
case "js":
|
|
5455
|
-
tag = loadModuleTagged("script", filename);
|
|
5456
|
-
break;
|
|
5457
|
-
case "otf":
|
|
5458
|
-
case "ttf":
|
|
5459
|
-
case "woff":
|
|
5460
|
-
case "woff2":
|
|
5461
|
-
tag = loadModuleFont(filename);
|
|
5462
|
-
break;
|
|
5463
|
-
default: continue;
|
|
5464
|
-
}
|
|
5465
|
-
if (tag) headTags.push(tag);
|
|
5466
|
-
processed.add(filename);
|
|
5467
|
-
}
|
|
5468
|
-
return headTags.join("\n");
|
|
5646
|
+
function toTwee(story, outMode, options = {}) {
|
|
5647
|
+
return withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => passageToTwee(p, outMode)).join("");
|
|
5469
5648
|
}
|
|
5470
|
-
function
|
|
5471
|
-
const
|
|
5472
|
-
|
|
5473
|
-
|
|
5649
|
+
function withEffectiveStoryData(story, addStoryData) {
|
|
5650
|
+
const storyData = {
|
|
5651
|
+
name: "StoryData",
|
|
5652
|
+
tags: [],
|
|
5653
|
+
text: marshalStoryData(story)
|
|
5654
|
+
};
|
|
5655
|
+
const index = story.passages.findIndex((p) => p.name === "StoryData");
|
|
5656
|
+
if (index !== -1) return story.passages.map((p, i) => i === index && isStoryDataJSON(p.text) ? {
|
|
5657
|
+
...p,
|
|
5658
|
+
text: storyData.text
|
|
5659
|
+
} : p);
|
|
5660
|
+
if (!addStoryData) return story.passages;
|
|
5661
|
+
const at = story.passages[0]?.name === "StoryTitle" ? 1 : 0;
|
|
5662
|
+
return [
|
|
5663
|
+
...story.passages.slice(0, at),
|
|
5664
|
+
storyData,
|
|
5665
|
+
...story.passages.slice(at)
|
|
5666
|
+
];
|
|
5474
5667
|
}
|
|
5475
|
-
|
|
5476
|
-
|
|
5477
|
-
|
|
5478
|
-
const idSlug = `style-module-${slugify(family)}`;
|
|
5479
|
-
const ext = normalizedFileExt(filename);
|
|
5480
|
-
return `<style id="${idSlug}" type="text/css">@font-face {\n\tfont-family: "${family}";\n\tsrc: url("data:${mediaTypeFromExt(ext)};base64,${source}") format("${fontFormatHint(ext)}");\n}</style>`;
|
|
5668
|
+
/** Whether the text loaded as StoryData. Text that did not is kept as written, for the author to fix. */
|
|
5669
|
+
function isStoryDataJSON(text) {
|
|
5670
|
+
return unmarshalStoryData(createStory(), text) === null;
|
|
5481
5671
|
}
|
|
5672
|
+
//#endregion
|
|
5673
|
+
//#region src/passage-omission.ts
|
|
5482
5674
|
/**
|
|
5483
|
-
*
|
|
5675
|
+
* Why the `target` output leaves `passage` out of its passage elements, or `undefined` when it
|
|
5676
|
+
* emits the passage (and so a link or the start can name it).
|
|
5484
5677
|
*/
|
|
5485
|
-
function
|
|
5486
|
-
|
|
5487
|
-
|
|
5488
|
-
|
|
5489
|
-
|
|
5490
|
-
}
|
|
5491
|
-
if (headFile) try {
|
|
5492
|
-
const source = readUTF8(headFile).trim();
|
|
5493
|
-
if (source.length > 0) parts.push(source);
|
|
5494
|
-
} catch (e) {
|
|
5495
|
-
diagnostics?.push({
|
|
5496
|
-
level: "warning",
|
|
5497
|
-
message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
|
|
5498
|
-
});
|
|
5499
|
-
}
|
|
5500
|
-
if (parts.length > 0) {
|
|
5501
|
-
parts.push("</head>");
|
|
5502
|
-
const replacement = parts.join("\n");
|
|
5503
|
-
return html.replace("</head>", () => replacement);
|
|
5678
|
+
function passageOmission(story, passage, target) {
|
|
5679
|
+
switch (target) {
|
|
5680
|
+
case "twine2": return twine2PassageOmission(story, passage);
|
|
5681
|
+
case "twine1": return twine1PassageOmission(passage);
|
|
5682
|
+
default: throw new Error(`unhandled passage output target: ${String(target)}`);
|
|
5504
5683
|
}
|
|
5505
|
-
return html;
|
|
5506
5684
|
}
|
|
5507
|
-
|
|
5508
|
-
//#region src/start-passage.ts
|
|
5685
|
+
/** Why a passage is left out, as a predicate: `Passage "X" ${describeOmission(o)}`. */
|
|
5509
5686
|
function describeOmission(omission) {
|
|
5510
5687
|
switch (omission.kind) {
|
|
5511
5688
|
case "special-name": return `is the special "${omission.name}" passage`;
|
|
@@ -5514,13 +5691,8 @@ function describeOmission(omission) {
|
|
|
5514
5691
|
default: throw new Error(`unhandled passage omission: ${JSON.stringify(omission)}`);
|
|
5515
5692
|
}
|
|
5516
5693
|
}
|
|
5517
|
-
|
|
5518
|
-
|
|
5519
|
-
case "twine2": return twine2PassageOmission(story, passage);
|
|
5520
|
-
case "twine1": return twine1PassageOmission(passage);
|
|
5521
|
-
default: throw new Error(`unhandled start passage target: ${String(target)}`);
|
|
5522
|
-
}
|
|
5523
|
-
}
|
|
5694
|
+
//#endregion
|
|
5695
|
+
//#region src/start-passage.ts
|
|
5524
5696
|
/**
|
|
5525
5697
|
* Check that `startName` names a passage the `target` output emits.
|
|
5526
5698
|
* Returns the error diagnostics to report (empty when the start is usable).
|
|
@@ -5531,7 +5703,7 @@ function startPassageDiagnostics(story, startName, target) {
|
|
|
5531
5703
|
level: "error",
|
|
5532
5704
|
message: `Starting passage "${startName}" not found.`
|
|
5533
5705
|
}];
|
|
5534
|
-
const omission =
|
|
5706
|
+
const omission = passageOmission(story, passage, target);
|
|
5535
5707
|
if (omission === void 0) return [];
|
|
5536
5708
|
return [{
|
|
5537
5709
|
level: "error",
|
|
@@ -5566,21 +5738,51 @@ async function compile(options) {
|
|
|
5566
5738
|
* Compile and write to a file.
|
|
5567
5739
|
*/
|
|
5568
5740
|
async function compileToFile(options) {
|
|
5569
|
-
const result = await
|
|
5741
|
+
const result = await compileForOutputFile(options, options.outFile);
|
|
5570
5742
|
(0, node_fs.writeFileSync)(options.outFile, result.output, "utf-8");
|
|
5571
5743
|
return result;
|
|
5572
5744
|
}
|
|
5573
5745
|
/**
|
|
5746
|
+
* Builds as compileToFile() does for `outFile`, without writing it: the output may sit
|
|
5747
|
+
* inside a source folder, so its last build is left out of the sources and modules and
|
|
5748
|
+
* never read back. The caller decides whether the result is written. With no `outFile`
|
|
5749
|
+
* (output to stdout, or none), this is compile(). With `cache`, files are cached as
|
|
5750
|
+
* compileIncremental() caches them.
|
|
5751
|
+
*
|
|
5752
|
+
* Internal, for the CLI and the bundler plugins; not part of the public API.
|
|
5753
|
+
*/
|
|
5754
|
+
async function compileForOutputFile(options, outFile, cache) {
|
|
5755
|
+
return buildOutput(options, cache, void 0, outFile);
|
|
5756
|
+
}
|
|
5757
|
+
/**
|
|
5574
5758
|
* Compile with incremental caching support.
|
|
5575
5759
|
* Plugins and advanced users can manage their own cache and changed-file tracking.
|
|
5760
|
+
*
|
|
5761
|
+
* Without `changedFiles`, a cached file is reused while its modification time is unchanged.
|
|
5762
|
+
* With it, a file it names (by the path source discovery gives it, relative to the working
|
|
5763
|
+
* directory) is always reparsed, whatever its modification time, and every other cached
|
|
5764
|
+
* file is reused as it is.
|
|
5576
5765
|
*/
|
|
5577
5766
|
async function compileIncremental(options, cache, changedFiles) {
|
|
5578
5767
|
return buildOutput(options, cache, changedFiles);
|
|
5579
5768
|
}
|
|
5580
5769
|
/**
|
|
5581
|
-
* Watch for file changes and recompile.
|
|
5770
|
+
* Watch for file changes and recompile. Every build is written to `outFile`, including
|
|
5771
|
+
* one whose diagnostics report errors. Builds run one at a time: changes made during a
|
|
5772
|
+
* build go into one follow-up build after it, and the superseded build is neither
|
|
5773
|
+
* written nor reported.
|
|
5582
5774
|
*/
|
|
5583
5775
|
async function watch(options) {
|
|
5776
|
+
return watchWithWriteFilter(options, () => true);
|
|
5777
|
+
}
|
|
5778
|
+
/**
|
|
5779
|
+
* watch(), writing to `outFile` only the builds `shouldWrite` accepts. A rejected build
|
|
5780
|
+
* leaves the output file as it was; `onBuild` receives every build either way.
|
|
5781
|
+
*
|
|
5782
|
+
* Internal, for the CLI, which keeps the last good output when a rebuild reports errors;
|
|
5783
|
+
* not part of the public API.
|
|
5784
|
+
*/
|
|
5785
|
+
async function watchWithWriteFilter(options, shouldWrite) {
|
|
5584
5786
|
const controller = new AbortController();
|
|
5585
5787
|
const cache = /* @__PURE__ */ new Map();
|
|
5586
5788
|
const filePaths = options.sources.filter((s) => typeof s === "string");
|
|
@@ -5598,17 +5800,62 @@ async function watch(options) {
|
|
|
5598
5800
|
const abs = (0, node_path.resolve)(filename);
|
|
5599
5801
|
return !notExcludable.some((root) => abs === root || abs.startsWith(root + node_path.sep));
|
|
5600
5802
|
};
|
|
5803
|
+
let building = false;
|
|
5804
|
+
let queued;
|
|
5805
|
+
const deliver = (outcome) => {
|
|
5806
|
+
try {
|
|
5807
|
+
if (!outcome.ok) throw outcome.error;
|
|
5808
|
+
if (shouldWrite(outcome.result)) (0, node_fs.writeFileSync)(options.outFile, outcome.result.output, "utf-8");
|
|
5809
|
+
options.onBuild?.(outcome.result);
|
|
5810
|
+
} catch (e) {
|
|
5811
|
+
options.onError?.(toError(e));
|
|
5812
|
+
}
|
|
5813
|
+
};
|
|
5814
|
+
const drain = async (first) => {
|
|
5815
|
+
building = true;
|
|
5816
|
+
try {
|
|
5817
|
+
let request = first;
|
|
5818
|
+
while (request !== void 0) {
|
|
5819
|
+
const current = request;
|
|
5820
|
+
const outcome = await buildOutput(options, cache, current.changedFiles, options.outFile).then((result) => ({
|
|
5821
|
+
ok: true,
|
|
5822
|
+
result
|
|
5823
|
+
}), (e) => ({
|
|
5824
|
+
ok: false,
|
|
5825
|
+
error: toError(e)
|
|
5826
|
+
}));
|
|
5827
|
+
if (controller.signal.aborted) return;
|
|
5828
|
+
request = queued === void 0 ? void 0 : mergeBuildRequests(current, queued);
|
|
5829
|
+
queued = void 0;
|
|
5830
|
+
if (request === void 0) deliver(outcome);
|
|
5831
|
+
}
|
|
5832
|
+
} finally {
|
|
5833
|
+
building = false;
|
|
5834
|
+
}
|
|
5835
|
+
};
|
|
5601
5836
|
const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
|
|
5602
|
-
|
|
5603
|
-
|
|
5604
|
-
|
|
5605
|
-
|
|
5606
|
-
|
|
5607
|
-
}
|
|
5837
|
+
if (controller.signal.aborted) return;
|
|
5838
|
+
const request = { changedFiles };
|
|
5839
|
+
if (building) {
|
|
5840
|
+
queued = queued === void 0 ? request : mergeBuildRequests(queued, request);
|
|
5841
|
+
return;
|
|
5842
|
+
}
|
|
5843
|
+
drain(request);
|
|
5608
5844
|
}, ignore);
|
|
5609
|
-
controller.signal.addEventListener("abort", () =>
|
|
5845
|
+
controller.signal.addEventListener("abort", () => {
|
|
5846
|
+
queued = void 0;
|
|
5847
|
+
handle.close();
|
|
5848
|
+
});
|
|
5610
5849
|
return controller;
|
|
5611
5850
|
}
|
|
5851
|
+
/** One build covering both requests: a full build if either is one, else every changed file. */
|
|
5852
|
+
function mergeBuildRequests(a, b) {
|
|
5853
|
+
if (a.changedFiles === void 0 || b.changedFiles === void 0) return { changedFiles: void 0 };
|
|
5854
|
+
return { changedFiles: /* @__PURE__ */ new Set([...a.changedFiles, ...b.changedFiles]) };
|
|
5855
|
+
}
|
|
5856
|
+
function toError(e) {
|
|
5857
|
+
return e instanceof Error ? e : new Error(String(e));
|
|
5858
|
+
}
|
|
5612
5859
|
/**
|
|
5613
5860
|
* `outFile`: the file the build is written to, which source and module discovery
|
|
5614
5861
|
* skip (as Tweego does), so an output inside a source folder is never loaded back.
|
|
@@ -5656,7 +5903,7 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5656
5903
|
switch (outputMode) {
|
|
5657
5904
|
case "twee3":
|
|
5658
5905
|
case "twee1":
|
|
5659
|
-
output = toTwee(story, outputMode);
|
|
5906
|
+
output = toTwee(story, outputMode, { addStoryData: Boolean(options.startPassage) || testMode });
|
|
5660
5907
|
break;
|
|
5661
5908
|
case "twine2-archive":
|
|
5662
5909
|
output = toTwine2Archive(story, startName, { sourceInfo });
|
|
@@ -5670,17 +5917,17 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5670
5917
|
case "html": {
|
|
5671
5918
|
diagnostics.push(...startPassageDiagnostics(story, startName, format?.isTwine2 === false ? "twine1" : "twine2"));
|
|
5672
5919
|
if (!format) throw new TweeTsError("No story format available for HTML output.", diagnostics);
|
|
5673
|
-
if (format.isTwine2
|
|
5674
|
-
|
|
5675
|
-
|
|
5676
|
-
|
|
5677
|
-
message: "Special passage \"StoryTitle\" not found."
|
|
5678
|
-
});
|
|
5679
|
-
output = toTwine1HTML(story, format, startName);
|
|
5680
|
-
}
|
|
5920
|
+
if (!format.isTwine2 && story.name === "" && !storyHas(story, "StoryTitle")) diagnostics.push({
|
|
5921
|
+
level: "error",
|
|
5922
|
+
message: "Special passage \"StoryTitle\" not found."
|
|
5923
|
+
});
|
|
5681
5924
|
const modules = getFilenames(options.modules ?? [], outFile);
|
|
5682
5925
|
diagnostics.push(...modules.diagnostics);
|
|
5683
|
-
|
|
5926
|
+
const head = loadHeadContent(modules.filenames, options.headFile, diagnostics);
|
|
5927
|
+
output = format.isTwine2 ? toTwine2HTML(story, format, startName, {
|
|
5928
|
+
sourceInfo,
|
|
5929
|
+
head
|
|
5930
|
+
}) : toTwine1HTML(story, format, startName, { head });
|
|
5684
5931
|
break;
|
|
5685
5932
|
}
|
|
5686
5933
|
default: throw new TweeTsError(`Unhandled output mode: ${outputMode}`, diagnostics);
|
|
@@ -6099,6 +6346,11 @@ function endOfRegularExpression(source, pos) {
|
|
|
6099
6346
|
}
|
|
6100
6347
|
//#endregion
|
|
6101
6348
|
//#region src/link-markup.ts
|
|
6349
|
+
/** Records that the reading has looked at the characters before `pos`, and returns `result`. */
|
|
6350
|
+
function readTo(reader, pos, result) {
|
|
6351
|
+
reader.scanned = Math.max(reader.scanned, pos);
|
|
6352
|
+
return result;
|
|
6353
|
+
}
|
|
6102
6354
|
/**
|
|
6103
6355
|
* Reads link or image markup that starts at `start` in `text`, or returns `undefined` where
|
|
6104
6356
|
* SugarCube rejects it (and so prints the text instead).
|
|
@@ -6106,15 +6358,17 @@ function endOfRegularExpression(source, pos) {
|
|
|
6106
6358
|
* `budget.left`, if given, is how many characters the reading may look at; it is reduced by as
|
|
6107
6359
|
* many as were read, and once it runs out, every reading fails. Unclosed markup is read to the end
|
|
6108
6360
|
* of its line, so reading every `[[` of a long line that never closes one would otherwise take
|
|
6109
|
-
* quadratic time.
|
|
6361
|
+
* quadratic time. A reading is charged for the characters it looked at, up to where it ended or
|
|
6362
|
+
* failed (a line feed, say), and no more, so markup left unclosed on short lines costs little.
|
|
6110
6363
|
*/
|
|
6111
6364
|
function readSquareBracketedMarkup(text, start, budget = { left: Infinity }) {
|
|
6112
6365
|
const reader = {
|
|
6113
6366
|
text,
|
|
6114
|
-
limit: Math.min(text.length, start + Math.max(budget.left, 0))
|
|
6367
|
+
limit: Math.min(text.length, start + Math.max(budget.left, 0)),
|
|
6368
|
+
scanned: start
|
|
6115
6369
|
};
|
|
6116
6370
|
const markup = readMarkup(reader, start);
|
|
6117
|
-
budget.left -= (markup?.end ??
|
|
6371
|
+
budget.left -= Math.max(reader.scanned, markup?.end ?? start) - start;
|
|
6118
6372
|
return markup;
|
|
6119
6373
|
}
|
|
6120
6374
|
function readMarkup(reader, start) {
|
|
@@ -6190,7 +6444,7 @@ function readCoreComponents(reader, from) {
|
|
|
6190
6444
|
const ch = text[pos];
|
|
6191
6445
|
pos += 1;
|
|
6192
6446
|
switch (ch) {
|
|
6193
|
-
case "\n": return;
|
|
6447
|
+
case "\n": return readTo(reader, pos, void 0);
|
|
6194
6448
|
case "\"": {
|
|
6195
6449
|
const end = readQuoted(reader, pos, "\"");
|
|
6196
6450
|
if (end === void 0) return;
|
|
@@ -6224,7 +6478,7 @@ function readCoreComponents(reader, from) {
|
|
|
6224
6478
|
case "]":
|
|
6225
6479
|
depth -= 1;
|
|
6226
6480
|
if (depth === 1) {
|
|
6227
|
-
const end = componentEnd(
|
|
6481
|
+
const end = componentEnd(reader, pos);
|
|
6228
6482
|
if (end === void 0) return;
|
|
6229
6483
|
return {
|
|
6230
6484
|
link: link ?? text.slice(componentStart, end.textEnd),
|
|
@@ -6233,6 +6487,7 @@ function readCoreComponents(reader, from) {
|
|
|
6233
6487
|
}
|
|
6234
6488
|
}
|
|
6235
6489
|
}
|
|
6490
|
+
return readTo(reader, pos, void 0);
|
|
6236
6491
|
}
|
|
6237
6492
|
/**
|
|
6238
6493
|
* Reads a component after `][` that holds no delimiter: an image's link (`setter` false) or a
|
|
@@ -6246,7 +6501,7 @@ function readComponent(reader, from, setter) {
|
|
|
6246
6501
|
const ch = text[pos];
|
|
6247
6502
|
pos += 1;
|
|
6248
6503
|
switch (ch) {
|
|
6249
|
-
case "\n": return;
|
|
6504
|
+
case "\n": return readTo(reader, pos, void 0);
|
|
6250
6505
|
case "\"":
|
|
6251
6506
|
case "'": {
|
|
6252
6507
|
if (ch === "'" && !setter) break;
|
|
@@ -6260,9 +6515,10 @@ function readComponent(reader, from, setter) {
|
|
|
6260
6515
|
break;
|
|
6261
6516
|
case "]":
|
|
6262
6517
|
depth -= 1;
|
|
6263
|
-
if (depth === 1) return componentEnd(
|
|
6518
|
+
if (depth === 1) return componentEnd(reader, pos);
|
|
6264
6519
|
}
|
|
6265
6520
|
}
|
|
6521
|
+
return readTo(reader, pos, void 0);
|
|
6266
6522
|
}
|
|
6267
6523
|
/** Reads a setter, the last component; returns the index after its closing `]]`. */
|
|
6268
6524
|
function readSetter(reader, from) {
|
|
@@ -6270,19 +6526,19 @@ function readSetter(reader, from) {
|
|
|
6270
6526
|
return end?.last === true ? end.next : void 0;
|
|
6271
6527
|
}
|
|
6272
6528
|
/** What the `]` just before `pos`, which brings the depth back to one, ends; `undefined` if malformed. */
|
|
6273
|
-
function componentEnd(
|
|
6274
|
-
switch (text[pos]) {
|
|
6275
|
-
case "[": return {
|
|
6529
|
+
function componentEnd(reader, pos) {
|
|
6530
|
+
switch (reader.text[pos]) {
|
|
6531
|
+
case "[": return readTo(reader, pos + 1, {
|
|
6276
6532
|
textEnd: pos - 1,
|
|
6277
6533
|
next: pos + 1,
|
|
6278
6534
|
last: false
|
|
6279
|
-
};
|
|
6280
|
-
case "]": return {
|
|
6535
|
+
});
|
|
6536
|
+
case "]": return readTo(reader, pos + 1, {
|
|
6281
6537
|
textEnd: pos - 1,
|
|
6282
6538
|
next: pos + 1,
|
|
6283
6539
|
last: true
|
|
6284
|
-
};
|
|
6285
|
-
default: return;
|
|
6540
|
+
});
|
|
6541
|
+
default: return readTo(reader, pos + 1, void 0);
|
|
6286
6542
|
}
|
|
6287
6543
|
}
|
|
6288
6544
|
/**
|
|
@@ -6297,12 +6553,13 @@ function readQuoted(reader, from, quote) {
|
|
|
6297
6553
|
const ch = text[pos];
|
|
6298
6554
|
pos += 1;
|
|
6299
6555
|
if (ch === quote) return pos;
|
|
6300
|
-
if (ch === "\n") return;
|
|
6556
|
+
if (ch === "\n") return readTo(reader, pos, void 0);
|
|
6301
6557
|
if (ch === "\\") {
|
|
6302
|
-
if (pos >= reader.limit || text[pos] === "\n") return;
|
|
6558
|
+
if (pos >= reader.limit || text[pos] === "\n") return readTo(reader, Math.min(pos + 1, reader.limit), void 0);
|
|
6303
6559
|
pos += 1;
|
|
6304
6560
|
}
|
|
6305
6561
|
}
|
|
6562
|
+
return readTo(reader, pos, void 0);
|
|
6306
6563
|
}
|
|
6307
6564
|
//#endregion
|
|
6308
6565
|
//#region src/sugarcube-macros.ts
|
|
@@ -7160,6 +7417,39 @@ function extractLinksFromText(text, isScript) {
|
|
|
7160
7417
|
].flatMap((via) => found.filter((link) => link.via === via).map((link) => link.passage));
|
|
7161
7418
|
return [...new Set(ordered)];
|
|
7162
7419
|
}
|
|
7420
|
+
/**
|
|
7421
|
+
* The passages that `p` links to. A script passage is JavaScript, read for links only in its
|
|
7422
|
+
* strings. A stylesheet is CSS, which the story puts in a style element and never wikifies, so it
|
|
7423
|
+
* links nowhere; a passage tagged both is a script, as Twine 2 output takes it.
|
|
7424
|
+
*/
|
|
7425
|
+
function passageLinks(p) {
|
|
7426
|
+
if (hasTag(p, "script")) return extractLinksFromText(textAsRead(p), true);
|
|
7427
|
+
if (hasTag(p, "stylesheet")) return [];
|
|
7428
|
+
return extractLinksFromText(textAsRead(p), false);
|
|
7429
|
+
}
|
|
7430
|
+
/**
|
|
7431
|
+
* Whether the `target` output keeps the links in `p`: always without a target, which describes
|
|
7432
|
+
* every source passage. With one, a passage the output leaves out never reaches the player, so it
|
|
7433
|
+
* links nowhere, except a script passage, which Twine 2 output leaves out as a passage but runs.
|
|
7434
|
+
*/
|
|
7435
|
+
function readsLinks(story, p, { target }) {
|
|
7436
|
+
if (target === void 0) return true;
|
|
7437
|
+
const omission = passageOmission(story, p, target);
|
|
7438
|
+
return omission === void 0 || omission.kind === "tag" && omission.tag === "script";
|
|
7439
|
+
}
|
|
7440
|
+
/**
|
|
7441
|
+
* Each passage name, mapped to why the `target` output leaves that passage out, or to `undefined`
|
|
7442
|
+
* when a link can reach it: always without a target, and otherwise when the output emits a
|
|
7443
|
+
* passage of that name.
|
|
7444
|
+
*/
|
|
7445
|
+
function destinationOmissions(story, { target }) {
|
|
7446
|
+
const omissions = /* @__PURE__ */ new Map();
|
|
7447
|
+
for (const p of story.passages) {
|
|
7448
|
+
const omission = target === void 0 ? void 0 : passageOmission(story, p, target);
|
|
7449
|
+
if (!omissions.has(p.name) || omission === void 0) omissions.set(p.name, omission);
|
|
7450
|
+
}
|
|
7451
|
+
return omissions;
|
|
7452
|
+
}
|
|
7163
7453
|
/** Passages SugarCube runs from their raw text, never joining their lines. */
|
|
7164
7454
|
const RAW_TEXT_PASSAGES = /* @__PURE__ */ new Set([
|
|
7165
7455
|
"StoryInit",
|
|
@@ -7201,9 +7491,16 @@ function joinLines(text) {
|
|
|
7201
7491
|
* expect(map.brokenLinks).toEqual([]);
|
|
7202
7492
|
* expect(map.deadEnds).not.toContain('Start');
|
|
7203
7493
|
* ```
|
|
7204
|
-
|
|
7205
|
-
|
|
7206
|
-
|
|
7494
|
+
*
|
|
7495
|
+
* By default, a link is broken only when no passage has its name. Pass a `target` to check links
|
|
7496
|
+
* against what that output emits: `storyInspect(result.story, { target: 'twine2' })` also reports
|
|
7497
|
+
* links to passages Twine 2 output leaves out, such as script, stylesheet and `Twine.private`
|
|
7498
|
+
* passages. With a target, passages that output leaves out also give no links (script passages
|
|
7499
|
+
* excepted, as Twine 2 output runs them), so they report no broken links and keep no passage from
|
|
7500
|
+
* being an orphan.
|
|
7501
|
+
*/
|
|
7502
|
+
function storyInspect(story, options = {}) {
|
|
7503
|
+
const destinations = destinationOmissions(story, options);
|
|
7207
7504
|
const passages = [];
|
|
7208
7505
|
const storyPassages = [];
|
|
7209
7506
|
const infoPassages = [];
|
|
@@ -7225,14 +7522,24 @@ function storyInspect(story) {
|
|
|
7225
7522
|
}
|
|
7226
7523
|
list.push(p.name);
|
|
7227
7524
|
}
|
|
7228
|
-
|
|
7229
|
-
links.set(p.name, targets);
|
|
7525
|
+
links.set(p.name, readsLinks(story, p, options) ? passageLinks(p) : []);
|
|
7230
7526
|
}
|
|
7231
7527
|
const brokenLinks = [];
|
|
7232
|
-
for (const [from, targets] of links) for (const to of targets)
|
|
7233
|
-
|
|
7234
|
-
|
|
7235
|
-
|
|
7528
|
+
for (const [from, targets] of links) for (const to of targets) {
|
|
7529
|
+
if (!destinations.has(to)) {
|
|
7530
|
+
brokenLinks.push({
|
|
7531
|
+
from,
|
|
7532
|
+
to
|
|
7533
|
+
});
|
|
7534
|
+
continue;
|
|
7535
|
+
}
|
|
7536
|
+
const omission = destinations.get(to);
|
|
7537
|
+
if (omission !== void 0) brokenLinks.push({
|
|
7538
|
+
from,
|
|
7539
|
+
to,
|
|
7540
|
+
omission
|
|
7541
|
+
});
|
|
7542
|
+
}
|
|
7236
7543
|
const deadEnds = [];
|
|
7237
7544
|
for (const name of storyPassages) {
|
|
7238
7545
|
const targets = links.get(name);
|
|
@@ -7262,14 +7569,27 @@ function storyInspect(story) {
|
|
|
7262
7569
|
/**
|
|
7263
7570
|
* Lint a story: compile without output rendering, then inspect structure.
|
|
7264
7571
|
* Uses JSON output mode internally to avoid format resolution, so it checks
|
|
7265
|
-
* the starting passage itself, against the Twine 2 passage rules.
|
|
7572
|
+
* the starting passage itself, against the Twine 2 passage rules. Link
|
|
7573
|
+
* destinations are checked against the same rules: a link to a passage that
|
|
7574
|
+
* Twine 2 output leaves out is broken, and a passage it leaves out (other than
|
|
7575
|
+
* a script passage) gives no links.
|
|
7266
7576
|
*/
|
|
7267
7577
|
async function lint(options) {
|
|
7268
|
-
|
|
7578
|
+
return lintForOutputFile(options, void 0);
|
|
7579
|
+
}
|
|
7580
|
+
/**
|
|
7581
|
+
* lint() for a project whose builds are written to `outFile`: like a build for that file,
|
|
7582
|
+
* it leaves `outFile` out of the sources and modules, so an earlier build inside a source
|
|
7583
|
+
* folder is not linted as a source.
|
|
7584
|
+
*
|
|
7585
|
+
* Internal, for the CLI; not part of the public API.
|
|
7586
|
+
*/
|
|
7587
|
+
async function lintForOutputFile(options, outFile) {
|
|
7588
|
+
const result = await compileForOutputFile({
|
|
7269
7589
|
...options,
|
|
7270
7590
|
outputMode: "json"
|
|
7271
|
-
});
|
|
7272
|
-
const map = storyInspect(result.story);
|
|
7591
|
+
}, outFile);
|
|
7592
|
+
const map = storyInspect(result.story, { target: "twine2" });
|
|
7273
7593
|
return {
|
|
7274
7594
|
diagnostics: [...result.diagnostics, ...startPassageDiagnostics(result.story, map.start, "twine2")],
|
|
7275
7595
|
stats: result.stats,
|
|
@@ -7296,7 +7616,10 @@ function formatLintReport(result) {
|
|
|
7296
7616
|
if (result.brokenLinks.length > 0) {
|
|
7297
7617
|
lines.push("");
|
|
7298
7618
|
lines.push(`Broken links (${result.brokenLinks.length}):`);
|
|
7299
|
-
for (const link of result.brokenLinks)
|
|
7619
|
+
for (const link of result.brokenLinks) {
|
|
7620
|
+
const why = link.omission === void 0 ? "does not exist" : `${describeOmission(link.omission)}, so it is left out of the story data`;
|
|
7621
|
+
lines.push(` ${link.from} -> ${link.to} (passage "${link.to}" ${why})`);
|
|
7622
|
+
}
|
|
7300
7623
|
}
|
|
7301
7624
|
if (result.deadEnds.length > 0) {
|
|
7302
7625
|
lines.push("");
|