@rohal12/twee-ts 1.17.0 → 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/dist/bin/twee-ts.js +328 -136
- package/dist/bin/twee-ts.js.map +1 -1
- package/dist/index.cjs +328 -138
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +22 -8
- package/dist/index.d.ts +22 -8
- package/dist/index.js +328 -138
- package/dist/index.js.map +1 -1
- package/dist/{diagnostics-DZNS7W41.js → paths-w4ulhCFb.js} +291 -138
- package/dist/paths-w4ulhCFb.js.map +1 -0
- package/dist/plugins/rollup.d.ts +11 -1
- package/dist/plugins/rollup.js +36 -6
- package/dist/plugins/rollup.js.map +1 -1
- package/dist/plugins/vite.js +37 -35
- package/dist/plugins/vite.js.map +1 -1
- package/package.json +1 -1
- package/dist/diagnostics-DZNS7W41.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":
|
|
@@ -4763,7 +4779,7 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4763
4779
|
});
|
|
4764
4780
|
else startnode = parsed;
|
|
4765
4781
|
}
|
|
4766
|
-
if (attrs["ifid"]) story.ifid = attrs["ifid"]
|
|
4782
|
+
if (attrs["ifid"]) story.ifid = normalizeIFID(attrs["ifid"]);
|
|
4767
4783
|
if (attrs["zoom"]) {
|
|
4768
4784
|
const parsed = parseFloat(attrs["zoom"]);
|
|
4769
4785
|
if (Number.isNaN(parsed)) diagnostics.push({
|
|
@@ -4776,7 +4792,9 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4776
4792
|
if (attrs["format"]) story.twine2.format = attrs["format"];
|
|
4777
4793
|
if (attrs["format-version"]) story.twine2.formatVersion = attrs["format-version"];
|
|
4778
4794
|
if (attrs["options"]) for (const opt of attrs["options"].split(/\s+/).filter((s) => s.length > 0)) story.twine2.options.set(opt, true);
|
|
4779
|
-
|
|
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) {
|
|
4780
4798
|
if (node.type !== "tag" && node.type !== "style" && node.type !== "script") continue;
|
|
4781
4799
|
switch (node.name) {
|
|
4782
4800
|
case "style":
|
|
@@ -4784,8 +4802,10 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4784
4802
|
const content = getTextContent(node);
|
|
4785
4803
|
if (content.trim().length === 0) continue;
|
|
4786
4804
|
const text = passageText(content);
|
|
4805
|
+
const name = freeName(node.name === "style" ? "Story Stylesheet" : "Story JavaScript", takenNames);
|
|
4806
|
+
takenNames.add(name);
|
|
4787
4807
|
storyAdd(story, {
|
|
4788
|
-
name
|
|
4808
|
+
name,
|
|
4789
4809
|
tags: node.name === "style" ? ["stylesheet"] : ["script"],
|
|
4790
4810
|
text
|
|
4791
4811
|
}, diagnostics);
|
|
@@ -4801,7 +4821,7 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4801
4821
|
case "tw-passagedata": {
|
|
4802
4822
|
let pid = 0;
|
|
4803
4823
|
const pAttrs = node.attribs ?? {};
|
|
4804
|
-
const name =
|
|
4824
|
+
const name = passageDataName(node);
|
|
4805
4825
|
const tags = pAttrs["tags"] ? pAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [];
|
|
4806
4826
|
const metadata = {};
|
|
4807
4827
|
if (pAttrs["pid"]) {
|
|
@@ -4832,6 +4852,17 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4832
4852
|
text: marshalStoryData(story)
|
|
4833
4853
|
}, diagnostics);
|
|
4834
4854
|
}
|
|
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
|
+
}
|
|
4835
4866
|
function decompileTwine1(storeArea, story, passageText, diagnostics) {
|
|
4836
4867
|
const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
|
|
4837
4868
|
const obfuscated = isRot13Obfuscated(passages);
|
|
@@ -5224,6 +5255,121 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5224
5255
|
}, diagnostics);
|
|
5225
5256
|
}
|
|
5226
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
|
|
5227
5373
|
//#region src/version.ts
|
|
5228
5374
|
/**
|
|
5229
5375
|
* The twee-ts version, read at runtime from the package's own package.json.
|
|
@@ -5257,24 +5403,38 @@ const CREATOR_NAME$2 = "Twee-ts";
|
|
|
5257
5403
|
function toTwine2Archive(story, startName, options) {
|
|
5258
5404
|
return getTwine2DataChunk(story, startName, options) + "\n";
|
|
5259
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
|
+
*/
|
|
5260
5412
|
function toTwine2HTML(story, format, startName, options) {
|
|
5261
|
-
|
|
5262
|
-
|
|
5263
|
-
|
|
5264
|
-
|
|
5265
|
-
|
|
5266
|
-
|
|
5267
|
-
|
|
5268
|
-
|
|
5269
|
-
|
|
5270
|
-
|
|
5271
|
-
|
|
5272
|
-
|
|
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);
|
|
5273
5433
|
}
|
|
5274
|
-
},
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
return
|
|
5434
|
+
},
|
|
5435
|
+
...head === void 0 ? [] : [head]
|
|
5436
|
+
];
|
|
5437
|
+
return fillTemplate(readFormatSource(format), slots);
|
|
5278
5438
|
}
|
|
5279
5439
|
const OMITTING_TAGS = [
|
|
5280
5440
|
"Twine.private",
|
|
@@ -5367,7 +5527,13 @@ function toTwine1Archive(story, _startName) {
|
|
|
5367
5527
|
const { data, count } = getTwine1PassageChunk(story);
|
|
5368
5528
|
return `<div id="storeArea" data-size="${count}">${data}</div>\n`;
|
|
5369
5529
|
}
|
|
5370
|
-
|
|
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) {
|
|
5371
5537
|
const formatDir = (0, node_path.dirname)(format.filename);
|
|
5372
5538
|
const parentDir = (0, node_path.dirname)(formatDir);
|
|
5373
5539
|
let template = readFormatSource(format);
|
|
@@ -5378,28 +5544,60 @@ function toTwine1HTML(story, format, startName) {
|
|
|
5378
5544
|
template = tryReplaceComponent(template, "\"JONAH\"", (0, node_path.join)(formatDir, "code.js"), true);
|
|
5379
5545
|
if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", (0, node_path.join)(parentDir, "jquery.js"), true);
|
|
5380
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);
|
|
5381
5556
|
const displayStart = startName === "Start" ? "" : startName;
|
|
5382
|
-
|
|
5383
|
-
|
|
5384
|
-
|
|
5385
|
-
|
|
5386
|
-
|
|
5387
|
-
|
|
5388
|
-
|
|
5389
|
-
|
|
5390
|
-
|
|
5391
|
-
|
|
5392
|
-
|
|
5393
|
-
|
|
5394
|
-
|
|
5395
|
-
|
|
5396
|
-
|
|
5397
|
-
|
|
5398
|
-
|
|
5399
|
-
|
|
5400
|
-
|
|
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";
|
|
5401
5600
|
}
|
|
5402
|
-
return template;
|
|
5403
5601
|
}
|
|
5404
5602
|
/**
|
|
5405
5603
|
* Why Twine 1 output leaves a passage out of its tiddlers, or `undefined` when the passage is emitted.
|
|
@@ -5472,75 +5670,6 @@ function isStoryDataJSON(text) {
|
|
|
5472
5670
|
return unmarshalStoryData(createStory(), text) === null;
|
|
5473
5671
|
}
|
|
5474
5672
|
//#endregion
|
|
5475
|
-
//#region src/modules.ts
|
|
5476
|
-
/**
|
|
5477
|
-
* Load modules and return HTML tags to inject before </head>.
|
|
5478
|
-
*/
|
|
5479
|
-
function loadModules(filenames) {
|
|
5480
|
-
const processed = /* @__PURE__ */ new Set();
|
|
5481
|
-
const headTags = [];
|
|
5482
|
-
for (const filename of filenames) {
|
|
5483
|
-
if (processed.has(filename)) continue;
|
|
5484
|
-
const ext = normalizedFileExt(filename);
|
|
5485
|
-
let tag = null;
|
|
5486
|
-
switch (ext) {
|
|
5487
|
-
case "css":
|
|
5488
|
-
tag = loadModuleTagged("style", filename);
|
|
5489
|
-
break;
|
|
5490
|
-
case "js":
|
|
5491
|
-
tag = loadModuleTagged("script", filename);
|
|
5492
|
-
break;
|
|
5493
|
-
case "otf":
|
|
5494
|
-
case "ttf":
|
|
5495
|
-
case "woff":
|
|
5496
|
-
case "woff2":
|
|
5497
|
-
tag = loadModuleFont(filename);
|
|
5498
|
-
break;
|
|
5499
|
-
default: continue;
|
|
5500
|
-
}
|
|
5501
|
-
if (tag) headTags.push(tag);
|
|
5502
|
-
processed.add(filename);
|
|
5503
|
-
}
|
|
5504
|
-
return headTags.join("\n");
|
|
5505
|
-
}
|
|
5506
|
-
function loadModuleTagged(tag, filename) {
|
|
5507
|
-
const source = readUTF8(filename).trim();
|
|
5508
|
-
if (source.length === 0) return null;
|
|
5509
|
-
return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
|
|
5510
|
-
}
|
|
5511
|
-
function loadModuleFont(filename) {
|
|
5512
|
-
const source = readBase64(filename);
|
|
5513
|
-
const family = baseNameWithoutExt(filename);
|
|
5514
|
-
const idSlug = `style-module-${slugify(family)}`;
|
|
5515
|
-
const ext = normalizedFileExt(filename);
|
|
5516
|
-
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>`;
|
|
5517
|
-
}
|
|
5518
|
-
/**
|
|
5519
|
-
* Inject modules and head file content before </head>.
|
|
5520
|
-
*/
|
|
5521
|
-
function modifyHead(html, modulePaths, headFile, diagnostics) {
|
|
5522
|
-
const parts = [];
|
|
5523
|
-
if (modulePaths.length > 0) {
|
|
5524
|
-
const modules = loadModules(modulePaths).trim();
|
|
5525
|
-
if (modules.length > 0) parts.push(modules);
|
|
5526
|
-
}
|
|
5527
|
-
if (headFile) try {
|
|
5528
|
-
const source = readUTF8(headFile).trim();
|
|
5529
|
-
if (source.length > 0) parts.push(source);
|
|
5530
|
-
} catch (e) {
|
|
5531
|
-
diagnostics?.push({
|
|
5532
|
-
level: "warning",
|
|
5533
|
-
message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
|
|
5534
|
-
});
|
|
5535
|
-
}
|
|
5536
|
-
if (parts.length > 0) {
|
|
5537
|
-
parts.push("</head>");
|
|
5538
|
-
const replacement = parts.join("\n");
|
|
5539
|
-
return html.replace("</head>", () => replacement);
|
|
5540
|
-
}
|
|
5541
|
-
return html;
|
|
5542
|
-
}
|
|
5543
|
-
//#endregion
|
|
5544
5673
|
//#region src/passage-omission.ts
|
|
5545
5674
|
/**
|
|
5546
5675
|
* Why the `target` output leaves `passage` out of its passage elements, or `undefined` when it
|
|
@@ -5617,12 +5746,13 @@ async function compileToFile(options) {
|
|
|
5617
5746
|
* Builds as compileToFile() does for `outFile`, without writing it: the output may sit
|
|
5618
5747
|
* inside a source folder, so its last build is left out of the sources and modules and
|
|
5619
5748
|
* never read back. The caller decides whether the result is written. With no `outFile`
|
|
5620
|
-
* (output to stdout, or none), this is compile().
|
|
5749
|
+
* (output to stdout, or none), this is compile(). With `cache`, files are cached as
|
|
5750
|
+
* compileIncremental() caches them.
|
|
5621
5751
|
*
|
|
5622
|
-
* Internal, for the CLI; not part of the public API.
|
|
5752
|
+
* Internal, for the CLI and the bundler plugins; not part of the public API.
|
|
5623
5753
|
*/
|
|
5624
|
-
async function compileForOutputFile(options, outFile) {
|
|
5625
|
-
return buildOutput(options,
|
|
5754
|
+
async function compileForOutputFile(options, outFile, cache) {
|
|
5755
|
+
return buildOutput(options, cache, void 0, outFile);
|
|
5626
5756
|
}
|
|
5627
5757
|
/**
|
|
5628
5758
|
* Compile with incremental caching support.
|
|
@@ -5638,7 +5768,9 @@ async function compileIncremental(options, cache, changedFiles) {
|
|
|
5638
5768
|
}
|
|
5639
5769
|
/**
|
|
5640
5770
|
* Watch for file changes and recompile. Every build is written to `outFile`, including
|
|
5641
|
-
* one whose diagnostics report errors.
|
|
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.
|
|
5642
5774
|
*/
|
|
5643
5775
|
async function watch(options) {
|
|
5644
5776
|
return watchWithWriteFilter(options, () => true);
|
|
@@ -5668,17 +5800,62 @@ async function watchWithWriteFilter(options, shouldWrite) {
|
|
|
5668
5800
|
const abs = (0, node_path.resolve)(filename);
|
|
5669
5801
|
return !notExcludable.some((root) => abs === root || abs.startsWith(root + node_path.sep));
|
|
5670
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
|
+
};
|
|
5671
5836
|
const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
|
|
5672
|
-
|
|
5673
|
-
|
|
5674
|
-
|
|
5675
|
-
|
|
5676
|
-
|
|
5677
|
-
}
|
|
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);
|
|
5678
5844
|
}, ignore);
|
|
5679
|
-
controller.signal.addEventListener("abort", () =>
|
|
5845
|
+
controller.signal.addEventListener("abort", () => {
|
|
5846
|
+
queued = void 0;
|
|
5847
|
+
handle.close();
|
|
5848
|
+
});
|
|
5680
5849
|
return controller;
|
|
5681
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
|
+
}
|
|
5682
5859
|
/**
|
|
5683
5860
|
* `outFile`: the file the build is written to, which source and module discovery
|
|
5684
5861
|
* skip (as Tweego does), so an output inside a source folder is never loaded back.
|
|
@@ -5740,17 +5917,17 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5740
5917
|
case "html": {
|
|
5741
5918
|
diagnostics.push(...startPassageDiagnostics(story, startName, format?.isTwine2 === false ? "twine1" : "twine2"));
|
|
5742
5919
|
if (!format) throw new TweeTsError("No story format available for HTML output.", diagnostics);
|
|
5743
|
-
if (format.isTwine2
|
|
5744
|
-
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
message: "Special passage \"StoryTitle\" not found."
|
|
5748
|
-
});
|
|
5749
|
-
output = toTwine1HTML(story, format, startName);
|
|
5750
|
-
}
|
|
5920
|
+
if (!format.isTwine2 && story.name === "" && !storyHas(story, "StoryTitle")) diagnostics.push({
|
|
5921
|
+
level: "error",
|
|
5922
|
+
message: "Special passage \"StoryTitle\" not found."
|
|
5923
|
+
});
|
|
5751
5924
|
const modules = getFilenames(options.modules ?? [], outFile);
|
|
5752
5925
|
diagnostics.push(...modules.diagnostics);
|
|
5753
|
-
|
|
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 });
|
|
5754
5931
|
break;
|
|
5755
5932
|
}
|
|
5756
5933
|
default: throw new TweeTsError(`Unhandled output mode: ${outputMode}`, diagnostics);
|
|
@@ -7251,6 +7428,16 @@ function passageLinks(p) {
|
|
|
7251
7428
|
return extractLinksFromText(textAsRead(p), false);
|
|
7252
7429
|
}
|
|
7253
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
|
+
/**
|
|
7254
7441
|
* Each passage name, mapped to why the `target` output leaves that passage out, or to `undefined`
|
|
7255
7442
|
* when a link can reach it: always without a target, and otherwise when the output emits a
|
|
7256
7443
|
* passage of that name.
|
|
@@ -7308,7 +7495,9 @@ function joinLines(text) {
|
|
|
7308
7495
|
* By default, a link is broken only when no passage has its name. Pass a `target` to check links
|
|
7309
7496
|
* against what that output emits: `storyInspect(result.story, { target: 'twine2' })` also reports
|
|
7310
7497
|
* links to passages Twine 2 output leaves out, such as script, stylesheet and `Twine.private`
|
|
7311
|
-
* passages.
|
|
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.
|
|
7312
7501
|
*/
|
|
7313
7502
|
function storyInspect(story, options = {}) {
|
|
7314
7503
|
const destinations = destinationOmissions(story, options);
|
|
@@ -7333,7 +7522,7 @@ function storyInspect(story, options = {}) {
|
|
|
7333
7522
|
}
|
|
7334
7523
|
list.push(p.name);
|
|
7335
7524
|
}
|
|
7336
|
-
links.set(p.name, passageLinks(p));
|
|
7525
|
+
links.set(p.name, readsLinks(story, p, options) ? passageLinks(p) : []);
|
|
7337
7526
|
}
|
|
7338
7527
|
const brokenLinks = [];
|
|
7339
7528
|
for (const [from, targets] of links) for (const to of targets) {
|
|
@@ -7382,7 +7571,8 @@ function storyInspect(story, options = {}) {
|
|
|
7382
7571
|
* Uses JSON output mode internally to avoid format resolution, so it checks
|
|
7383
7572
|
* the starting passage itself, against the Twine 2 passage rules. Link
|
|
7384
7573
|
* destinations are checked against the same rules: a link to a passage that
|
|
7385
|
-
* Twine 2 output leaves out is broken
|
|
7574
|
+
* Twine 2 output leaves out is broken, and a passage it leaves out (other than
|
|
7575
|
+
* a script passage) gives no links.
|
|
7386
7576
|
*/
|
|
7387
7577
|
async function lint(options) {
|
|
7388
7578
|
return lintForOutputFile(options, void 0);
|