@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.js
CHANGED
|
@@ -13,11 +13,27 @@ import { fileURLToPath } from "node:url";
|
|
|
13
13
|
function generateIFID() {
|
|
14
14
|
return randomUUID().toUpperCase();
|
|
15
15
|
}
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Validate and brand an IFID string. Throws on invalid input.
|
|
18
|
+
* Returns the IFID in its stored form (see `normalizeIFID`).
|
|
19
|
+
*/
|
|
17
20
|
function createIFID(value) {
|
|
18
21
|
const err = validateIFID(value);
|
|
19
22
|
if (err) throw new Error(`Invalid IFID: ${err}`);
|
|
20
|
-
return value
|
|
23
|
+
return normalizeIFID(value);
|
|
24
|
+
}
|
|
25
|
+
/** Length of the `UUID://...//` wrapper around a 36-character UUID. */
|
|
26
|
+
const WRAPPED_LENGTH = 45;
|
|
27
|
+
/**
|
|
28
|
+
* Bring an IFID into the form the story model stores and every output writes: the uppercase
|
|
29
|
+
* bare UUID. A valid `UUID://...//` wrapped IFID loses its wrapper; outputs that need the wrapper
|
|
30
|
+
* (the Treaty of Babel comment) add exactly one. Twine 2 IFIDs use only capital letters, so the
|
|
31
|
+
* value is uppercased, as Tweego does. An invalid value is only uppercased, so that validation
|
|
32
|
+
* reports it as written.
|
|
33
|
+
*/
|
|
34
|
+
function normalizeIFID(value) {
|
|
35
|
+
const upper = value.toUpperCase();
|
|
36
|
+
return upper.length === WRAPPED_LENGTH && validateIFID(upper) === null ? upper.slice(7, 43) : upper;
|
|
21
37
|
}
|
|
22
38
|
/**
|
|
23
39
|
* Validate an IFID string.
|
|
@@ -447,7 +463,7 @@ function unmarshalStoryData(story, json) {
|
|
|
447
463
|
}
|
|
448
464
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return "Cannot unmarshal \"StoryData\"; expected a JSON object";
|
|
449
465
|
const data = raw;
|
|
450
|
-
if (typeof data.ifid === "string" && data.ifid) story.ifid = data.ifid
|
|
466
|
+
if (typeof data.ifid === "string" && data.ifid) story.ifid = normalizeIFID(data.ifid);
|
|
451
467
|
if (typeof data.format === "string" && data.format) story.twine2.format = data.format;
|
|
452
468
|
if (typeof data["format-version"] === "string" && data["format-version"]) story.twine2.formatVersion = data["format-version"];
|
|
453
469
|
if (Array.isArray(data.options)) {
|
|
@@ -478,7 +494,7 @@ function unmarshalStorySettings(story, text, diagnostics) {
|
|
|
478
494
|
const val = line.slice(colonIdx + 1).trim().toLowerCase();
|
|
479
495
|
switch (key) {
|
|
480
496
|
case "ifid":
|
|
481
|
-
if (validateIFID(val) === null) story.legacyIFID = val
|
|
497
|
+
if (validateIFID(val) === null) story.legacyIFID = normalizeIFID(val);
|
|
482
498
|
obsolete.push("\"ifid\"");
|
|
483
499
|
continue;
|
|
484
500
|
case "zoom":
|
|
@@ -4740,7 +4756,7 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4740
4756
|
});
|
|
4741
4757
|
else startnode = parsed;
|
|
4742
4758
|
}
|
|
4743
|
-
if (attrs["ifid"]) story.ifid = attrs["ifid"]
|
|
4759
|
+
if (attrs["ifid"]) story.ifid = normalizeIFID(attrs["ifid"]);
|
|
4744
4760
|
if (attrs["zoom"]) {
|
|
4745
4761
|
const parsed = parseFloat(attrs["zoom"]);
|
|
4746
4762
|
if (Number.isNaN(parsed)) diagnostics.push({
|
|
@@ -4753,7 +4769,9 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4753
4769
|
if (attrs["format"]) story.twine2.format = attrs["format"];
|
|
4754
4770
|
if (attrs["format-version"]) story.twine2.formatVersion = attrs["format-version"];
|
|
4755
4771
|
if (attrs["options"]) for (const opt of attrs["options"].split(/\s+/).filter((s) => s.length > 0)) story.twine2.options.set(opt, true);
|
|
4756
|
-
|
|
4772
|
+
const children = storyData.children ?? [];
|
|
4773
|
+
const takenNames = new Set(children.filter((node) => node.type === "tag" && node.name === "tw-passagedata").map(passageDataName));
|
|
4774
|
+
for (const node of children) {
|
|
4757
4775
|
if (node.type !== "tag" && node.type !== "style" && node.type !== "script") continue;
|
|
4758
4776
|
switch (node.name) {
|
|
4759
4777
|
case "style":
|
|
@@ -4761,8 +4779,10 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4761
4779
|
const content = getTextContent(node);
|
|
4762
4780
|
if (content.trim().length === 0) continue;
|
|
4763
4781
|
const text = passageText(content);
|
|
4782
|
+
const name = freeName(node.name === "style" ? "Story Stylesheet" : "Story JavaScript", takenNames);
|
|
4783
|
+
takenNames.add(name);
|
|
4764
4784
|
storyAdd(story, {
|
|
4765
|
-
name
|
|
4785
|
+
name,
|
|
4766
4786
|
tags: node.name === "style" ? ["stylesheet"] : ["script"],
|
|
4767
4787
|
text
|
|
4768
4788
|
}, diagnostics);
|
|
@@ -4778,7 +4798,7 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4778
4798
|
case "tw-passagedata": {
|
|
4779
4799
|
let pid = 0;
|
|
4780
4800
|
const pAttrs = node.attribs ?? {};
|
|
4781
|
-
const name =
|
|
4801
|
+
const name = passageDataName(node);
|
|
4782
4802
|
const tags = pAttrs["tags"] ? pAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [];
|
|
4783
4803
|
const metadata = {};
|
|
4784
4804
|
if (pAttrs["pid"]) {
|
|
@@ -4809,6 +4829,17 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4809
4829
|
text: marshalStoryData(story)
|
|
4810
4830
|
}, diagnostics);
|
|
4811
4831
|
}
|
|
4832
|
+
function passageDataName(node) {
|
|
4833
|
+
return node.attribs?.["name"] ?? "";
|
|
4834
|
+
}
|
|
4835
|
+
/** The first of `base`, `base 2`, `base 3`, … that is not in `taken`. */
|
|
4836
|
+
function freeName(base, taken) {
|
|
4837
|
+
if (!taken.has(base)) return base;
|
|
4838
|
+
for (let n = 2;; n++) {
|
|
4839
|
+
const candidate = `${base} ${n}`;
|
|
4840
|
+
if (!taken.has(candidate)) return candidate;
|
|
4841
|
+
}
|
|
4842
|
+
}
|
|
4812
4843
|
function decompileTwine1(storeArea, story, passageText, diagnostics) {
|
|
4813
4844
|
const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
|
|
4814
4845
|
const obfuscated = isRot13Obfuscated(passages);
|
|
@@ -5201,6 +5232,121 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5201
5232
|
}, diagnostics);
|
|
5202
5233
|
}
|
|
5203
5234
|
//#endregion
|
|
5235
|
+
//#region src/template.ts
|
|
5236
|
+
/**
|
|
5237
|
+
* An HTML closing head tag: `</head` in any letter case, followed by whitespace, `/` or `>`, up to the next `>`.
|
|
5238
|
+
* This is how the HTML tokenizer reads an end tag; it ignores whatever sits between the tag name and the `>`.
|
|
5239
|
+
*/
|
|
5240
|
+
const CLOSING_HEAD_TAG = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>";
|
|
5241
|
+
/** The regular expression source matching exactly `text`. */
|
|
5242
|
+
function literal(text) {
|
|
5243
|
+
return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
5244
|
+
}
|
|
5245
|
+
/** Replace the `slots` placeholders found in `template`, without scanning inserted text. */
|
|
5246
|
+
function fillTemplate(template, slots) {
|
|
5247
|
+
return fillTemplateParts([{
|
|
5248
|
+
kind: "scan",
|
|
5249
|
+
text: template,
|
|
5250
|
+
slots
|
|
5251
|
+
}]);
|
|
5252
|
+
}
|
|
5253
|
+
/**
|
|
5254
|
+
* Fill the parts of a template in order, joined. A `'first'` slot replaced in one part is not replaced again in a
|
|
5255
|
+
* later part that lists the same slot object.
|
|
5256
|
+
*/
|
|
5257
|
+
function fillTemplateParts(parts) {
|
|
5258
|
+
const filled = /* @__PURE__ */ new Set();
|
|
5259
|
+
return parts.map((part) => {
|
|
5260
|
+
if (part.kind === "verbatim" || part.slots.length === 0) return part.text;
|
|
5261
|
+
const { slots } = part;
|
|
5262
|
+
const pattern = new RegExp(slots.map((slot) => `(${slot.pattern})`).join("|"), "g");
|
|
5263
|
+
return part.text.replace(pattern, (match, ...groups) => {
|
|
5264
|
+
const slot = slots.find((_, i) => groups[i] !== void 0);
|
|
5265
|
+
if (slot === void 0 || slot.occurrences === "first" && filled.has(slot)) return match;
|
|
5266
|
+
filled.add(slot);
|
|
5267
|
+
return slot.replacement(match);
|
|
5268
|
+
});
|
|
5269
|
+
}).join("");
|
|
5270
|
+
}
|
|
5271
|
+
//#endregion
|
|
5272
|
+
//#region src/modules.ts
|
|
5273
|
+
/**
|
|
5274
|
+
* Load modules and return HTML tags to inject before the closing head tag.
|
|
5275
|
+
*/
|
|
5276
|
+
function loadModules(filenames) {
|
|
5277
|
+
const processed = /* @__PURE__ */ new Set();
|
|
5278
|
+
const headTags = [];
|
|
5279
|
+
for (const filename of filenames) {
|
|
5280
|
+
if (processed.has(filename)) continue;
|
|
5281
|
+
const ext = normalizedFileExt(filename);
|
|
5282
|
+
let tag = null;
|
|
5283
|
+
switch (ext) {
|
|
5284
|
+
case "css":
|
|
5285
|
+
tag = loadModuleTagged("style", filename);
|
|
5286
|
+
break;
|
|
5287
|
+
case "js":
|
|
5288
|
+
tag = loadModuleTagged("script", filename);
|
|
5289
|
+
break;
|
|
5290
|
+
case "otf":
|
|
5291
|
+
case "ttf":
|
|
5292
|
+
case "woff":
|
|
5293
|
+
case "woff2":
|
|
5294
|
+
tag = loadModuleFont(filename);
|
|
5295
|
+
break;
|
|
5296
|
+
default: continue;
|
|
5297
|
+
}
|
|
5298
|
+
if (tag) headTags.push(tag);
|
|
5299
|
+
processed.add(filename);
|
|
5300
|
+
}
|
|
5301
|
+
return headTags.join("\n");
|
|
5302
|
+
}
|
|
5303
|
+
function loadModuleTagged(tag, filename) {
|
|
5304
|
+
const source = readUTF8(filename).trim();
|
|
5305
|
+
if (source.length === 0) return null;
|
|
5306
|
+
return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
|
|
5307
|
+
}
|
|
5308
|
+
function loadModuleFont(filename) {
|
|
5309
|
+
const source = readBase64(filename);
|
|
5310
|
+
const family = baseNameWithoutExt(filename);
|
|
5311
|
+
const idSlug = `style-module-${slugify(family)}`;
|
|
5312
|
+
const ext = normalizedFileExt(filename);
|
|
5313
|
+
return `<style id="${idSlug}" type="text/css">@font-face {\n\tfont-family: "${family}";\n\tsrc: url("data:${mediaTypeFromExt(ext)};base64,${source}") format("${fontFormatHint(ext)}");\n}</style>`;
|
|
5314
|
+
}
|
|
5315
|
+
/**
|
|
5316
|
+
* Load the module tags and head file content to inject before the closing head tag, joined by newlines,
|
|
5317
|
+
* or `''` when there is nothing to inject.
|
|
5318
|
+
*/
|
|
5319
|
+
function loadHeadContent(modulePaths, headFile, diagnostics) {
|
|
5320
|
+
const parts = [];
|
|
5321
|
+
if (modulePaths.length > 0) {
|
|
5322
|
+
const modules = loadModules(modulePaths).trim();
|
|
5323
|
+
if (modules.length > 0) parts.push(modules);
|
|
5324
|
+
}
|
|
5325
|
+
if (headFile) try {
|
|
5326
|
+
const source = readUTF8(headFile).trim();
|
|
5327
|
+
if (source.length > 0) parts.push(source);
|
|
5328
|
+
} catch (e) {
|
|
5329
|
+
diagnostics?.push({
|
|
5330
|
+
level: "warning",
|
|
5331
|
+
message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
|
|
5332
|
+
});
|
|
5333
|
+
}
|
|
5334
|
+
return parts.join("\n");
|
|
5335
|
+
}
|
|
5336
|
+
/**
|
|
5337
|
+
* The template slot that inserts `content` on its own line before the first closing head tag (as Tweego does),
|
|
5338
|
+
* or `undefined` when `content` is empty. The tag may use any letter case and whitespace before its `>`, and is
|
|
5339
|
+
* kept as written.
|
|
5340
|
+
*/
|
|
5341
|
+
function headSlot(content) {
|
|
5342
|
+
if (content.length === 0) return void 0;
|
|
5343
|
+
return {
|
|
5344
|
+
pattern: CLOSING_HEAD_TAG,
|
|
5345
|
+
occurrences: "first",
|
|
5346
|
+
replacement: (tag) => `${content}\n${tag}`
|
|
5347
|
+
};
|
|
5348
|
+
}
|
|
5349
|
+
//#endregion
|
|
5204
5350
|
//#region src/version.ts
|
|
5205
5351
|
/**
|
|
5206
5352
|
* The twee-ts version, read at runtime from the package's own package.json.
|
|
@@ -5234,24 +5380,38 @@ const CREATOR_NAME$2 = "Twee-ts";
|
|
|
5234
5380
|
function toTwine2Archive(story, startName, options) {
|
|
5235
5381
|
return getTwine2DataChunk(story, startName, options) + "\n";
|
|
5236
5382
|
}
|
|
5383
|
+
/**
|
|
5384
|
+
* Fill the Twine 2 format template: every `{{STORY_NAME}}` gets the escaped story name and the first
|
|
5385
|
+
* `{{STORY_DATA}}` the story data (as Tweego does), and `head` goes before the first closing head tag. All three are
|
|
5386
|
+
* found in the template in one pass, so a story name, passage text or head content holding a placeholder or a
|
|
5387
|
+
* closing head tag stays literal.
|
|
5388
|
+
*/
|
|
5237
5389
|
function toTwine2HTML(story, format, startName, options) {
|
|
5238
|
-
|
|
5239
|
-
|
|
5240
|
-
|
|
5241
|
-
|
|
5242
|
-
|
|
5243
|
-
|
|
5244
|
-
|
|
5245
|
-
|
|
5246
|
-
|
|
5247
|
-
|
|
5248
|
-
|
|
5249
|
-
|
|
5390
|
+
const name = htmlEscape(story.name);
|
|
5391
|
+
const head = headSlot(options?.head ?? "");
|
|
5392
|
+
const slots = [
|
|
5393
|
+
{
|
|
5394
|
+
pattern: literal("{{STORY_NAME}}"),
|
|
5395
|
+
occurrences: "all",
|
|
5396
|
+
replacement: () => name
|
|
5397
|
+
},
|
|
5398
|
+
{
|
|
5399
|
+
pattern: literal("{{STORY_DATA}}"),
|
|
5400
|
+
occurrences: "first",
|
|
5401
|
+
replacement: () => {
|
|
5402
|
+
return getTwine2DataChunk({
|
|
5403
|
+
...story,
|
|
5404
|
+
twine2: {
|
|
5405
|
+
...story.twine2,
|
|
5406
|
+
format: format.name,
|
|
5407
|
+
formatVersion: format.version
|
|
5408
|
+
}
|
|
5409
|
+
}, startName, options);
|
|
5250
5410
|
}
|
|
5251
|
-
},
|
|
5252
|
-
|
|
5253
|
-
|
|
5254
|
-
return
|
|
5411
|
+
},
|
|
5412
|
+
...head === void 0 ? [] : [head]
|
|
5413
|
+
];
|
|
5414
|
+
return fillTemplate(readFormatSource(format), slots);
|
|
5255
5415
|
}
|
|
5256
5416
|
const OMITTING_TAGS = [
|
|
5257
5417
|
"Twine.private",
|
|
@@ -5344,7 +5504,13 @@ function toTwine1Archive(story, _startName) {
|
|
|
5344
5504
|
const { data, count } = getTwine1PassageChunk(story);
|
|
5345
5505
|
return `<div id="storeArea" data-size="${count}">${data}</div>\n`;
|
|
5346
5506
|
}
|
|
5347
|
-
|
|
5507
|
+
/**
|
|
5508
|
+
* Fill the Twine 1 format template. The format's components are inserted first, one after another, as Tweego and
|
|
5509
|
+
* Twine 1 do. Then the first of each story placeholder (`"VERSION"`, `"TIME"`, `"START_AT"`, `"STORY_SIZE"`,
|
|
5510
|
+
* `"STORY"`), the IFID comment and `head` (before the first closing head tag) are found in one pass, so a start
|
|
5511
|
+
* passage name or story data holding a placeholder or a closing head tag stays literal.
|
|
5512
|
+
*/
|
|
5513
|
+
function toTwine1HTML(story, format, startName, options) {
|
|
5348
5514
|
const formatDir = dirname(format.filename);
|
|
5349
5515
|
const parentDir = dirname(formatDir);
|
|
5350
5516
|
let template = readFormatSource(format);
|
|
@@ -5355,28 +5521,60 @@ function toTwine1HTML(story, format, startName) {
|
|
|
5355
5521
|
template = tryReplaceComponent(template, "\"JONAH\"", join(formatDir, "code.js"), true);
|
|
5356
5522
|
if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", join(parentDir, "jquery.js"), true);
|
|
5357
5523
|
if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", join(parentDir, "modernizr.js"), true);
|
|
5524
|
+
const isPre14 = !template.includes("\"STORY\"");
|
|
5525
|
+
const footer = isPre14 ? readFooter(formatDir) : "";
|
|
5526
|
+
const storeArea = (template + footer).includes("<div id=\"store-area\"") ? "<div id=\"store-area\"" : "<div id=\"storeArea\"";
|
|
5527
|
+
const safeIfid = htmlCommentSanitize(story.ifid);
|
|
5528
|
+
const late = [story.ifid ? {
|
|
5529
|
+
pattern: literal(storeArea),
|
|
5530
|
+
occurrences: "first",
|
|
5531
|
+
replacement: (div) => `<!-- UUID://${safeIfid}// -->${div}`
|
|
5532
|
+
} : void 0, headSlot(options?.head ?? "")].filter((slot) => slot !== void 0);
|
|
5358
5533
|
const displayStart = startName === "Start" ? "" : startName;
|
|
5359
|
-
|
|
5360
|
-
|
|
5361
|
-
|
|
5362
|
-
|
|
5363
|
-
|
|
5364
|
-
|
|
5365
|
-
|
|
5366
|
-
|
|
5367
|
-
|
|
5368
|
-
|
|
5369
|
-
|
|
5370
|
-
|
|
5371
|
-
|
|
5372
|
-
|
|
5373
|
-
|
|
5374
|
-
|
|
5375
|
-
|
|
5376
|
-
|
|
5377
|
-
|
|
5534
|
+
const slots = [
|
|
5535
|
+
firstSlot("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`),
|
|
5536
|
+
firstSlot("\"TIME\"", `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`),
|
|
5537
|
+
firstSlot("\"START_AT\"", `"${jsStringEscape(displayStart)}"`),
|
|
5538
|
+
firstSlot("\"STORY_SIZE\"", `"${count}"`),
|
|
5539
|
+
firstSlot("\"STORY\"", data),
|
|
5540
|
+
...late
|
|
5541
|
+
];
|
|
5542
|
+
return fillTemplateParts(isPre14 ? [
|
|
5543
|
+
{
|
|
5544
|
+
kind: "scan",
|
|
5545
|
+
text: template,
|
|
5546
|
+
slots
|
|
5547
|
+
},
|
|
5548
|
+
{
|
|
5549
|
+
kind: "verbatim",
|
|
5550
|
+
text: data
|
|
5551
|
+
},
|
|
5552
|
+
{
|
|
5553
|
+
kind: "scan",
|
|
5554
|
+
text: footer,
|
|
5555
|
+
slots: late
|
|
5556
|
+
}
|
|
5557
|
+
] : [{
|
|
5558
|
+
kind: "scan",
|
|
5559
|
+
text: template,
|
|
5560
|
+
slots
|
|
5561
|
+
}]);
|
|
5562
|
+
}
|
|
5563
|
+
/** A slot replacing the first `token` with `value`. */
|
|
5564
|
+
function firstSlot(token, value) {
|
|
5565
|
+
return {
|
|
5566
|
+
pattern: literal(token),
|
|
5567
|
+
occurrences: "first",
|
|
5568
|
+
replacement: () => value
|
|
5569
|
+
};
|
|
5570
|
+
}
|
|
5571
|
+
/** The footer of a pre-1.4 format, or the default one when the format has none. */
|
|
5572
|
+
function readFooter(formatDir) {
|
|
5573
|
+
try {
|
|
5574
|
+
return readFileSync(join(formatDir, "footer.html"), "utf-8");
|
|
5575
|
+
} catch {
|
|
5576
|
+
return "</div>\n</body>\n</html>\n";
|
|
5378
5577
|
}
|
|
5379
|
-
return template;
|
|
5380
5578
|
}
|
|
5381
5579
|
/**
|
|
5382
5580
|
* Why Twine 1 output leaves a passage out of its tiddlers, or `undefined` when the passage is emitted.
|
|
@@ -5449,75 +5647,6 @@ function isStoryDataJSON(text) {
|
|
|
5449
5647
|
return unmarshalStoryData(createStory(), text) === null;
|
|
5450
5648
|
}
|
|
5451
5649
|
//#endregion
|
|
5452
|
-
//#region src/modules.ts
|
|
5453
|
-
/**
|
|
5454
|
-
* Load modules and return HTML tags to inject before </head>.
|
|
5455
|
-
*/
|
|
5456
|
-
function loadModules(filenames) {
|
|
5457
|
-
const processed = /* @__PURE__ */ new Set();
|
|
5458
|
-
const headTags = [];
|
|
5459
|
-
for (const filename of filenames) {
|
|
5460
|
-
if (processed.has(filename)) continue;
|
|
5461
|
-
const ext = normalizedFileExt(filename);
|
|
5462
|
-
let tag = null;
|
|
5463
|
-
switch (ext) {
|
|
5464
|
-
case "css":
|
|
5465
|
-
tag = loadModuleTagged("style", filename);
|
|
5466
|
-
break;
|
|
5467
|
-
case "js":
|
|
5468
|
-
tag = loadModuleTagged("script", filename);
|
|
5469
|
-
break;
|
|
5470
|
-
case "otf":
|
|
5471
|
-
case "ttf":
|
|
5472
|
-
case "woff":
|
|
5473
|
-
case "woff2":
|
|
5474
|
-
tag = loadModuleFont(filename);
|
|
5475
|
-
break;
|
|
5476
|
-
default: continue;
|
|
5477
|
-
}
|
|
5478
|
-
if (tag) headTags.push(tag);
|
|
5479
|
-
processed.add(filename);
|
|
5480
|
-
}
|
|
5481
|
-
return headTags.join("\n");
|
|
5482
|
-
}
|
|
5483
|
-
function loadModuleTagged(tag, filename) {
|
|
5484
|
-
const source = readUTF8(filename).trim();
|
|
5485
|
-
if (source.length === 0) return null;
|
|
5486
|
-
return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
|
|
5487
|
-
}
|
|
5488
|
-
function loadModuleFont(filename) {
|
|
5489
|
-
const source = readBase64(filename);
|
|
5490
|
-
const family = baseNameWithoutExt(filename);
|
|
5491
|
-
const idSlug = `style-module-${slugify(family)}`;
|
|
5492
|
-
const ext = normalizedFileExt(filename);
|
|
5493
|
-
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>`;
|
|
5494
|
-
}
|
|
5495
|
-
/**
|
|
5496
|
-
* Inject modules and head file content before </head>.
|
|
5497
|
-
*/
|
|
5498
|
-
function modifyHead(html, modulePaths, headFile, diagnostics) {
|
|
5499
|
-
const parts = [];
|
|
5500
|
-
if (modulePaths.length > 0) {
|
|
5501
|
-
const modules = loadModules(modulePaths).trim();
|
|
5502
|
-
if (modules.length > 0) parts.push(modules);
|
|
5503
|
-
}
|
|
5504
|
-
if (headFile) try {
|
|
5505
|
-
const source = readUTF8(headFile).trim();
|
|
5506
|
-
if (source.length > 0) parts.push(source);
|
|
5507
|
-
} catch (e) {
|
|
5508
|
-
diagnostics?.push({
|
|
5509
|
-
level: "warning",
|
|
5510
|
-
message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
|
|
5511
|
-
});
|
|
5512
|
-
}
|
|
5513
|
-
if (parts.length > 0) {
|
|
5514
|
-
parts.push("</head>");
|
|
5515
|
-
const replacement = parts.join("\n");
|
|
5516
|
-
return html.replace("</head>", () => replacement);
|
|
5517
|
-
}
|
|
5518
|
-
return html;
|
|
5519
|
-
}
|
|
5520
|
-
//#endregion
|
|
5521
5650
|
//#region src/passage-omission.ts
|
|
5522
5651
|
/**
|
|
5523
5652
|
* Why the `target` output leaves `passage` out of its passage elements, or `undefined` when it
|
|
@@ -5594,12 +5723,13 @@ async function compileToFile(options) {
|
|
|
5594
5723
|
* Builds as compileToFile() does for `outFile`, without writing it: the output may sit
|
|
5595
5724
|
* inside a source folder, so its last build is left out of the sources and modules and
|
|
5596
5725
|
* never read back. The caller decides whether the result is written. With no `outFile`
|
|
5597
|
-
* (output to stdout, or none), this is compile().
|
|
5726
|
+
* (output to stdout, or none), this is compile(). With `cache`, files are cached as
|
|
5727
|
+
* compileIncremental() caches them.
|
|
5598
5728
|
*
|
|
5599
|
-
* Internal, for the CLI; not part of the public API.
|
|
5729
|
+
* Internal, for the CLI and the bundler plugins; not part of the public API.
|
|
5600
5730
|
*/
|
|
5601
|
-
async function compileForOutputFile(options, outFile) {
|
|
5602
|
-
return buildOutput(options,
|
|
5731
|
+
async function compileForOutputFile(options, outFile, cache) {
|
|
5732
|
+
return buildOutput(options, cache, void 0, outFile);
|
|
5603
5733
|
}
|
|
5604
5734
|
/**
|
|
5605
5735
|
* Compile with incremental caching support.
|
|
@@ -5615,7 +5745,9 @@ async function compileIncremental(options, cache, changedFiles) {
|
|
|
5615
5745
|
}
|
|
5616
5746
|
/**
|
|
5617
5747
|
* Watch for file changes and recompile. Every build is written to `outFile`, including
|
|
5618
|
-
* one whose diagnostics report errors.
|
|
5748
|
+
* one whose diagnostics report errors. Builds run one at a time: changes made during a
|
|
5749
|
+
* build go into one follow-up build after it, and the superseded build is neither
|
|
5750
|
+
* written nor reported.
|
|
5619
5751
|
*/
|
|
5620
5752
|
async function watch(options) {
|
|
5621
5753
|
return watchWithWriteFilter(options, () => true);
|
|
@@ -5645,17 +5777,62 @@ async function watchWithWriteFilter(options, shouldWrite) {
|
|
|
5645
5777
|
const abs = resolve(filename);
|
|
5646
5778
|
return !notExcludable.some((root) => abs === root || abs.startsWith(root + sep));
|
|
5647
5779
|
};
|
|
5780
|
+
let building = false;
|
|
5781
|
+
let queued;
|
|
5782
|
+
const deliver = (outcome) => {
|
|
5783
|
+
try {
|
|
5784
|
+
if (!outcome.ok) throw outcome.error;
|
|
5785
|
+
if (shouldWrite(outcome.result)) writeFileSync(options.outFile, outcome.result.output, "utf-8");
|
|
5786
|
+
options.onBuild?.(outcome.result);
|
|
5787
|
+
} catch (e) {
|
|
5788
|
+
options.onError?.(toError(e));
|
|
5789
|
+
}
|
|
5790
|
+
};
|
|
5791
|
+
const drain = async (first) => {
|
|
5792
|
+
building = true;
|
|
5793
|
+
try {
|
|
5794
|
+
let request = first;
|
|
5795
|
+
while (request !== void 0) {
|
|
5796
|
+
const current = request;
|
|
5797
|
+
const outcome = await buildOutput(options, cache, current.changedFiles, options.outFile).then((result) => ({
|
|
5798
|
+
ok: true,
|
|
5799
|
+
result
|
|
5800
|
+
}), (e) => ({
|
|
5801
|
+
ok: false,
|
|
5802
|
+
error: toError(e)
|
|
5803
|
+
}));
|
|
5804
|
+
if (controller.signal.aborted) return;
|
|
5805
|
+
request = queued === void 0 ? void 0 : mergeBuildRequests(current, queued);
|
|
5806
|
+
queued = void 0;
|
|
5807
|
+
if (request === void 0) deliver(outcome);
|
|
5808
|
+
}
|
|
5809
|
+
} finally {
|
|
5810
|
+
building = false;
|
|
5811
|
+
}
|
|
5812
|
+
};
|
|
5648
5813
|
const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
}
|
|
5814
|
+
if (controller.signal.aborted) return;
|
|
5815
|
+
const request = { changedFiles };
|
|
5816
|
+
if (building) {
|
|
5817
|
+
queued = queued === void 0 ? request : mergeBuildRequests(queued, request);
|
|
5818
|
+
return;
|
|
5819
|
+
}
|
|
5820
|
+
drain(request);
|
|
5655
5821
|
}, ignore);
|
|
5656
|
-
controller.signal.addEventListener("abort", () =>
|
|
5822
|
+
controller.signal.addEventListener("abort", () => {
|
|
5823
|
+
queued = void 0;
|
|
5824
|
+
handle.close();
|
|
5825
|
+
});
|
|
5657
5826
|
return controller;
|
|
5658
5827
|
}
|
|
5828
|
+
/** One build covering both requests: a full build if either is one, else every changed file. */
|
|
5829
|
+
function mergeBuildRequests(a, b) {
|
|
5830
|
+
if (a.changedFiles === void 0 || b.changedFiles === void 0) return { changedFiles: void 0 };
|
|
5831
|
+
return { changedFiles: /* @__PURE__ */ new Set([...a.changedFiles, ...b.changedFiles]) };
|
|
5832
|
+
}
|
|
5833
|
+
function toError(e) {
|
|
5834
|
+
return e instanceof Error ? e : new Error(String(e));
|
|
5835
|
+
}
|
|
5659
5836
|
/**
|
|
5660
5837
|
* `outFile`: the file the build is written to, which source and module discovery
|
|
5661
5838
|
* skip (as Tweego does), so an output inside a source folder is never loaded back.
|
|
@@ -5717,17 +5894,17 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5717
5894
|
case "html": {
|
|
5718
5895
|
diagnostics.push(...startPassageDiagnostics(story, startName, format?.isTwine2 === false ? "twine1" : "twine2"));
|
|
5719
5896
|
if (!format) throw new TweeTsError("No story format available for HTML output.", diagnostics);
|
|
5720
|
-
if (format.isTwine2
|
|
5721
|
-
|
|
5722
|
-
|
|
5723
|
-
|
|
5724
|
-
message: "Special passage \"StoryTitle\" not found."
|
|
5725
|
-
});
|
|
5726
|
-
output = toTwine1HTML(story, format, startName);
|
|
5727
|
-
}
|
|
5897
|
+
if (!format.isTwine2 && story.name === "" && !storyHas(story, "StoryTitle")) diagnostics.push({
|
|
5898
|
+
level: "error",
|
|
5899
|
+
message: "Special passage \"StoryTitle\" not found."
|
|
5900
|
+
});
|
|
5728
5901
|
const modules = getFilenames(options.modules ?? [], outFile);
|
|
5729
5902
|
diagnostics.push(...modules.diagnostics);
|
|
5730
|
-
|
|
5903
|
+
const head = loadHeadContent(modules.filenames, options.headFile, diagnostics);
|
|
5904
|
+
output = format.isTwine2 ? toTwine2HTML(story, format, startName, {
|
|
5905
|
+
sourceInfo,
|
|
5906
|
+
head
|
|
5907
|
+
}) : toTwine1HTML(story, format, startName, { head });
|
|
5731
5908
|
break;
|
|
5732
5909
|
}
|
|
5733
5910
|
default: throw new TweeTsError(`Unhandled output mode: ${outputMode}`, diagnostics);
|
|
@@ -7228,6 +7405,16 @@ function passageLinks(p) {
|
|
|
7228
7405
|
return extractLinksFromText(textAsRead(p), false);
|
|
7229
7406
|
}
|
|
7230
7407
|
/**
|
|
7408
|
+
* Whether the `target` output keeps the links in `p`: always without a target, which describes
|
|
7409
|
+
* every source passage. With one, a passage the output leaves out never reaches the player, so it
|
|
7410
|
+
* links nowhere, except a script passage, which Twine 2 output leaves out as a passage but runs.
|
|
7411
|
+
*/
|
|
7412
|
+
function readsLinks(story, p, { target }) {
|
|
7413
|
+
if (target === void 0) return true;
|
|
7414
|
+
const omission = passageOmission(story, p, target);
|
|
7415
|
+
return omission === void 0 || omission.kind === "tag" && omission.tag === "script";
|
|
7416
|
+
}
|
|
7417
|
+
/**
|
|
7231
7418
|
* Each passage name, mapped to why the `target` output leaves that passage out, or to `undefined`
|
|
7232
7419
|
* when a link can reach it: always without a target, and otherwise when the output emits a
|
|
7233
7420
|
* passage of that name.
|
|
@@ -7285,7 +7472,9 @@ function joinLines(text) {
|
|
|
7285
7472
|
* By default, a link is broken only when no passage has its name. Pass a `target` to check links
|
|
7286
7473
|
* against what that output emits: `storyInspect(result.story, { target: 'twine2' })` also reports
|
|
7287
7474
|
* links to passages Twine 2 output leaves out, such as script, stylesheet and `Twine.private`
|
|
7288
|
-
* passages.
|
|
7475
|
+
* passages. With a target, passages that output leaves out also give no links (script passages
|
|
7476
|
+
* excepted, as Twine 2 output runs them), so they report no broken links and keep no passage from
|
|
7477
|
+
* being an orphan.
|
|
7289
7478
|
*/
|
|
7290
7479
|
function storyInspect(story, options = {}) {
|
|
7291
7480
|
const destinations = destinationOmissions(story, options);
|
|
@@ -7310,7 +7499,7 @@ function storyInspect(story, options = {}) {
|
|
|
7310
7499
|
}
|
|
7311
7500
|
list.push(p.name);
|
|
7312
7501
|
}
|
|
7313
|
-
links.set(p.name, passageLinks(p));
|
|
7502
|
+
links.set(p.name, readsLinks(story, p, options) ? passageLinks(p) : []);
|
|
7314
7503
|
}
|
|
7315
7504
|
const brokenLinks = [];
|
|
7316
7505
|
for (const [from, targets] of links) for (const to of targets) {
|
|
@@ -7359,7 +7548,8 @@ function storyInspect(story, options = {}) {
|
|
|
7359
7548
|
* Uses JSON output mode internally to avoid format resolution, so it checks
|
|
7360
7549
|
* the starting passage itself, against the Twine 2 passage rules. Link
|
|
7361
7550
|
* destinations are checked against the same rules: a link to a passage that
|
|
7362
|
-
* Twine 2 output leaves out is broken
|
|
7551
|
+
* Twine 2 output leaves out is broken, and a passage it leaves out (other than
|
|
7552
|
+
* a script passage) gives no links.
|
|
7363
7553
|
*/
|
|
7364
7554
|
async function lint(options) {
|
|
7365
7555
|
return lintForOutputFile(options, void 0);
|