@rohal12/twee-ts 1.17.1 → 1.18.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 +2052 -596
- package/dist/bin/twee-ts.js.map +1 -1
- package/dist/index.cjs +2066 -594
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +141 -52
- package/dist/index.d.ts +141 -52
- package/dist/index.js +2067 -596
- package/dist/index.js.map +1 -1
- package/dist/{paths-w4ulhCFb.js → paths-CUv1qfz-.js} +1788 -508
- package/dist/paths-CUv1qfz-.js.map +1 -0
- package/dist/plugins/rollup.d.ts +3 -4
- package/dist/plugins/rollup.js +52 -28
- package/dist/plugins/rollup.js.map +1 -1
- package/dist/plugins/vite.d.ts +1 -1
- package/dist/plugins/vite.js +95 -50
- package/dist/plugins/vite.js.map +1 -1
- package/dist/{types-dR5vTVu2.d.ts → types-D8Xfe0l8.d.ts} +25 -8
- package/package.json +1 -1
- package/schemas/twee-ts.config.schema.json +6 -0
- package/dist/paths-w4ulhCFb.js.map +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import * as nodePath from "node:path";
|
|
3
3
|
import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
4
|
-
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
//#region src/ifid.ts
|
|
@@ -104,18 +104,44 @@ function tweeEscape(s) {
|
|
|
104
104
|
if (s.length === 0) return s;
|
|
105
105
|
return s.replace(/[\\[\]{}]/g, (ch) => "\\" + ch);
|
|
106
106
|
}
|
|
107
|
-
|
|
107
|
+
const JS_STRING_ESCAPES = {
|
|
108
|
+
"\\": "\\\\",
|
|
109
|
+
"\"": "\\\"",
|
|
110
|
+
"'": "\\'",
|
|
111
|
+
"\n": "\\n",
|
|
112
|
+
"\r": "\\r",
|
|
113
|
+
" ": "\\t",
|
|
114
|
+
"<": "\\x3C",
|
|
115
|
+
"\u2028": "\\u2028",
|
|
116
|
+
"\u2029": "\\u2029"
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Escape a string for the inside of a single- or double-quoted JavaScript string literal, including one written
|
|
120
|
+
* into an HTML `<script>` element. Besides backslashes, quotes and line breaks (with U+2028 and U+2029, which end
|
|
121
|
+
* a string literal in JavaScript before ES2019), every `<` becomes `\x3C`, so the literal holds no `<\/script`
|
|
122
|
+
* that would end the element and no `<!--` or `<script` that would change where it ends. The string's value is
|
|
123
|
+
* unchanged.
|
|
124
|
+
*/
|
|
108
125
|
function jsStringEscape(s) {
|
|
109
126
|
if (s.length === 0) return s;
|
|
110
|
-
return s.replace(
|
|
127
|
+
return s.replace(/[\\"'\n\r\t<\u2028\u2029]/g, (ch) => JS_STRING_ESCAPES[ch] ?? ch);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Escape a string for the inside of a double-quoted CSS string (such as a `font-family` name): `\` and `"` get
|
|
131
|
+
* a backslash, and line breaks and other control characters become hex escapes followed by a space (`\a ` for a
|
|
132
|
+
* line feed), since a raw line break ends a CSS string and a backslash before one is a line continuation.
|
|
133
|
+
*/
|
|
134
|
+
function cssStringEscape(s) {
|
|
135
|
+
if (s.length === 0) return s;
|
|
136
|
+
return s.replace(/[\\"\u0000-\u001f\u007f]/g, (ch) => ch === "\\" || ch === "\"" ? `\\${ch}` : `\\${ch.charCodeAt(0).toString(16)} `);
|
|
111
137
|
}
|
|
112
138
|
/** Sanitize a string for safe inclusion in a CSS or JS block comment. */
|
|
113
139
|
function commentSanitize(s) {
|
|
114
140
|
return s.replace(/\*\//g, "* /");
|
|
115
141
|
}
|
|
116
|
-
/** Sanitize a string for safe inclusion in an HTML comment. */
|
|
142
|
+
/** Sanitize a string for safe inclusion in an HTML comment: break up `-->` and `--!>`, which both end one. */
|
|
117
143
|
function htmlCommentSanitize(s) {
|
|
118
|
-
return s.replace(
|
|
144
|
+
return s.replace(/--(!?)>/g, "--$1 >");
|
|
119
145
|
}
|
|
120
146
|
/**
|
|
121
147
|
* Escape JavaScript for the content of an HTML `<script>` element, so that the HTML parser reads all of it as
|
|
@@ -292,7 +318,19 @@ function passageToPassagedata(p, pid, options) {
|
|
|
292
318
|
if (options?.sourceInfo && p.source) attrs += ` data-source-file=${quote(attrEscape(p.source.file))} data-source-line="${p.source.line}"`;
|
|
293
319
|
return `${attrs}>${htmlEscape(p.text)}</tw-passagedata>`;
|
|
294
320
|
}
|
|
295
|
-
/**
|
|
321
|
+
/**
|
|
322
|
+
* Whether Twine 1 `obfuscate:rot13` encodes a tiddler: every one but `StorySettings` and those tagged `Twine.image`
|
|
323
|
+
* (Twine 1.4 `Tiddler.isObfuscateable()`). Its engine tests the stored name and tags, which these tiddlers keep
|
|
324
|
+
* unencoded, so the decompiler tests them the same way.
|
|
325
|
+
*/
|
|
326
|
+
function isObfuscatable(p) {
|
|
327
|
+
return p.name !== "StorySettings" && !p.tags.includes("Twine.image");
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Generate `<div tiddler>` HTML for Twine 1. With `obfuscateRot13`, an obfuscatable tiddler (see
|
|
331
|
+
* `isObfuscatable()`) has its name, each tag and its text ROT13-encoded, as Twine 1.4 writes it
|
|
332
|
+
* (`Tiddler.toHtml()`), and as its engine.js decodes it.
|
|
333
|
+
*/
|
|
296
334
|
function passageToTiddler(p, pid, obfuscateRot13 = false) {
|
|
297
335
|
let position;
|
|
298
336
|
if (hasMetadataPosition(p)) position = p.metadata?.position ?? "";
|
|
@@ -304,9 +342,10 @@ function passageToTiddler(p, pid, obfuscateRot13 = false) {
|
|
|
304
342
|
position = `${xp * 140 - 130},${yp * 140 - 130}`;
|
|
305
343
|
}
|
|
306
344
|
const created = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 12);
|
|
307
|
-
const
|
|
308
|
-
const
|
|
309
|
-
|
|
345
|
+
const encode = obfuscateRot13 && isObfuscatable(p) ? rot13 : (s) => s;
|
|
346
|
+
const name = attrEscape(encode(p.name));
|
|
347
|
+
const tags = attrEscape(p.tags.map(encode).join(" "));
|
|
348
|
+
return `<div tiddler=${quote(name)} tags=${quote(tags)} created=${quote(created)} modifier=${quote("twee")} twine-position=${quote(attrEscape(position))}>${tiddlerEscape(encode(p.text))}</div>`;
|
|
310
349
|
}
|
|
311
350
|
/**
|
|
312
351
|
* Count words in a passage.
|
|
@@ -359,19 +398,128 @@ function quote(s) {
|
|
|
359
398
|
}
|
|
360
399
|
//#endregion
|
|
361
400
|
//#region src/story.ts
|
|
362
|
-
/** Module-level index: maps passage names to their array indices for O(1) lookups. */
|
|
363
401
|
const passageIndex = /* @__PURE__ */ new WeakMap();
|
|
364
|
-
function
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
402
|
+
function buildIndex(story) {
|
|
403
|
+
const names = story.passages.map((p) => p.name);
|
|
404
|
+
const index = {
|
|
405
|
+
passages: story.passages,
|
|
406
|
+
names,
|
|
407
|
+
positions: new Map(names.map((name, i) => [name, i]))
|
|
408
|
+
};
|
|
409
|
+
passageIndex.set(story, index);
|
|
410
|
+
return index;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* The index, rebuilt if `story.passages` was reassigned or changed length. O(1); `position()`
|
|
414
|
+
* also checks every hit. A change that keeps the length, such as replacing an element, can still
|
|
415
|
+
* hide a passage from a lookup, so `storyVerifyIndex()` checks every position.
|
|
416
|
+
*/
|
|
417
|
+
function currentIndex(story) {
|
|
418
|
+
const index = passageIndex.get(story);
|
|
419
|
+
return index !== void 0 && index.passages === story.passages && index.names.length === story.passages.length ? index : buildIndex(story);
|
|
420
|
+
}
|
|
421
|
+
/** The position of the passage named `name`, or -1. */
|
|
422
|
+
function position(story, name) {
|
|
423
|
+
const i = currentIndex(story).positions.get(name);
|
|
424
|
+
if (i === void 0) return -1;
|
|
425
|
+
if (story.passages[i]?.name === name) return i;
|
|
426
|
+
return buildIndex(story).positions.get(name) ?? -1;
|
|
427
|
+
}
|
|
428
|
+
/** Add a passage whose name no passage has yet at the end. */
|
|
429
|
+
function push(story, p) {
|
|
430
|
+
const index = currentIndex(story);
|
|
431
|
+
index.positions.set(p.name, story.passages.length);
|
|
432
|
+
index.names.push(p.name);
|
|
433
|
+
story.passages.push(p);
|
|
434
|
+
}
|
|
435
|
+
/** Put a passage at position `i`, in place of the passage there. */
|
|
436
|
+
function replaceAt(story, i, p) {
|
|
437
|
+
const renamed = story.passages[i]?.name !== p.name;
|
|
438
|
+
story.passages[i] = p;
|
|
439
|
+
if (renamed) buildIndex(story);
|
|
440
|
+
}
|
|
441
|
+
/** Names of compiler special passages, which storyAdd() reads into the story model. */
|
|
442
|
+
const SPECIAL_PASSAGE_NAMES = /* @__PURE__ */ new Set([
|
|
443
|
+
"StoryData",
|
|
444
|
+
"StoryIncludes",
|
|
445
|
+
"StorySettings",
|
|
446
|
+
"StoryTitle"
|
|
447
|
+
]);
|
|
448
|
+
/** Passages whose names were generated, kept by identity so that cached passages keep the mark. */
|
|
449
|
+
const generatedNames = /* @__PURE__ */ new WeakMap();
|
|
450
|
+
/**
|
|
451
|
+
* Mark a passage's name as generated (from a file name, or for imported story code) and return
|
|
452
|
+
* the passage. `origin.base` is the name that free names are numbered from: `base`, `base 2`, ….
|
|
453
|
+
*/
|
|
454
|
+
function withGeneratedName(p, origin) {
|
|
455
|
+
generatedNames.set(p, origin);
|
|
456
|
+
return p;
|
|
457
|
+
}
|
|
458
|
+
/** The first of `base`, `base 2`, `base 3`, … that `isFree` accepts. */
|
|
459
|
+
function freeName(base, isFree) {
|
|
460
|
+
if (isFree(base)) return base;
|
|
461
|
+
for (let n = 2;; n++) {
|
|
462
|
+
const candidate = `${base} ${n}`;
|
|
463
|
+
if (isFree(candidate)) return candidate;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
/** Whether a generated name may take `name`: no passage has it and it is no special name. */
|
|
467
|
+
function isFreeForGenerated(story, name) {
|
|
468
|
+
return !SPECIAL_PASSAGE_NAMES.has(name) && position(story, name) === -1;
|
|
469
|
+
}
|
|
470
|
+
function reportRename(origin, from, to, diagnostics) {
|
|
471
|
+
switch (origin.kind) {
|
|
472
|
+
case "code": return;
|
|
473
|
+
case "media": {
|
|
474
|
+
const reason = SPECIAL_PASSAGE_NAMES.has(from) ? `"${from}" is a compiler special passage name` : `another passage has the name "${from}"`;
|
|
475
|
+
diagnostics.push({
|
|
476
|
+
level: "warning",
|
|
477
|
+
message: `Passage "${from}" from "${origin.file}" renamed to "${to}"; ${reason}.`
|
|
478
|
+
});
|
|
479
|
+
return;
|
|
371
480
|
}
|
|
372
|
-
|
|
481
|
+
default: throw new Error(`unhandled generated name: ${JSON.stringify(origin)}`);
|
|
373
482
|
}
|
|
374
|
-
|
|
483
|
+
}
|
|
484
|
+
/** Add a passage with a generated name, under the first free name if its own is taken. */
|
|
485
|
+
function addGenerated(story, p, origin, diagnostics) {
|
|
486
|
+
if (isFreeForGenerated(story, p.name)) {
|
|
487
|
+
push(story, p);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const name = freeName(origin.base, (n) => isFreeForGenerated(story, n));
|
|
491
|
+
reportRename(origin, p.name, name, diagnostics);
|
|
492
|
+
push(story, withGeneratedName({
|
|
493
|
+
...p,
|
|
494
|
+
name
|
|
495
|
+
}, origin));
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* If a passage with a generated name has `name`, give it the first free name, in its place in the
|
|
499
|
+
* passage list, so that a passage from the sources can take the name.
|
|
500
|
+
*/
|
|
501
|
+
function moveGeneratedAside(story, name, diagnostics) {
|
|
502
|
+
const i = position(story, name);
|
|
503
|
+
const existing = i === -1 ? void 0 : story.passages[i];
|
|
504
|
+
const origin = existing === void 0 ? void 0 : generatedNames.get(existing);
|
|
505
|
+
if (existing === void 0 || origin === void 0) return;
|
|
506
|
+
const free = freeName(origin.base, (n) => isFreeForGenerated(story, n));
|
|
507
|
+
reportRename(origin, name, free, diagnostics);
|
|
508
|
+
replaceAt(story, i, withGeneratedName({
|
|
509
|
+
...existing,
|
|
510
|
+
name: free
|
|
511
|
+
}, origin));
|
|
512
|
+
}
|
|
513
|
+
function defaultTwine2Metadata() {
|
|
514
|
+
return {
|
|
515
|
+
format: "",
|
|
516
|
+
formatVersion: "",
|
|
517
|
+
options: /* @__PURE__ */ new Map(),
|
|
518
|
+
start: "",
|
|
519
|
+
tags: "",
|
|
520
|
+
tagColors: /* @__PURE__ */ new Map(),
|
|
521
|
+
zoom: 1
|
|
522
|
+
};
|
|
375
523
|
}
|
|
376
524
|
function createStory() {
|
|
377
525
|
const story = {
|
|
@@ -380,52 +528,37 @@ function createStory() {
|
|
|
380
528
|
passages: [],
|
|
381
529
|
legacyIFID: "",
|
|
382
530
|
twine1: { settings: /* @__PURE__ */ new Map() },
|
|
383
|
-
twine2:
|
|
384
|
-
format: "",
|
|
385
|
-
formatVersion: "",
|
|
386
|
-
options: /* @__PURE__ */ new Map(),
|
|
387
|
-
start: "",
|
|
388
|
-
tags: "",
|
|
389
|
-
tagColors: /* @__PURE__ */ new Map(),
|
|
390
|
-
zoom: 1
|
|
391
|
-
}
|
|
531
|
+
twine2: defaultTwine2Metadata()
|
|
392
532
|
};
|
|
393
|
-
|
|
533
|
+
buildIndex(story);
|
|
394
534
|
return story;
|
|
395
535
|
}
|
|
396
536
|
function storyHas(story, name) {
|
|
397
|
-
return
|
|
537
|
+
return position(story, name) !== -1;
|
|
398
538
|
}
|
|
399
539
|
function storyAppend(story, p, diagnostics) {
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
index.set(p.name, story.passages.length);
|
|
404
|
-
story.passages.push(p);
|
|
405
|
-
} else {
|
|
540
|
+
const i = position(story, p.name);
|
|
541
|
+
if (i === -1) push(story, p);
|
|
542
|
+
else {
|
|
406
543
|
diagnostics.push({
|
|
407
544
|
level: "warning",
|
|
408
545
|
message: `Replacing existing passage "${p.name}" with duplicate.`
|
|
409
546
|
});
|
|
410
|
-
story
|
|
547
|
+
replaceAt(story, i, p);
|
|
411
548
|
}
|
|
412
549
|
}
|
|
413
550
|
function storyPrepend(story, p, diagnostics) {
|
|
414
|
-
|
|
415
|
-
const i =
|
|
551
|
+
moveGeneratedAside(story, p.name, diagnostics);
|
|
552
|
+
const i = position(story, p.name);
|
|
416
553
|
if (i === -1) {
|
|
417
554
|
story.passages.unshift(p);
|
|
418
|
-
|
|
419
|
-
for (let j = 0; j < story.passages.length; j++) {
|
|
420
|
-
const passage = story.passages[j];
|
|
421
|
-
if (passage) index.set(passage.name, j);
|
|
422
|
-
}
|
|
555
|
+
buildIndex(story);
|
|
423
556
|
} else {
|
|
424
557
|
diagnostics.push({
|
|
425
558
|
level: "warning",
|
|
426
559
|
message: `Replacing existing passage "${p.name}" with duplicate.`
|
|
427
560
|
});
|
|
428
|
-
story
|
|
561
|
+
replaceAt(story, i, p);
|
|
429
562
|
}
|
|
430
563
|
}
|
|
431
564
|
function marshalStoryData(story) {
|
|
@@ -451,18 +584,20 @@ function unmarshalStoryData(story, json) {
|
|
|
451
584
|
}
|
|
452
585
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return "Cannot unmarshal \"StoryData\"; expected a JSON object";
|
|
453
586
|
const data = raw;
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
if (typeof data
|
|
587
|
+
const twine2 = defaultTwine2Metadata();
|
|
588
|
+
story.ifid = typeof data.ifid === "string" ? normalizeIFID(data.ifid) : "";
|
|
589
|
+
if (typeof data.format === "string") twine2.format = data.format;
|
|
590
|
+
if (typeof data["format-version"] === "string") twine2.formatVersion = data["format-version"];
|
|
457
591
|
if (Array.isArray(data.options)) {
|
|
458
|
-
for (const opt of data.options) if (typeof opt === "string")
|
|
592
|
+
for (const opt of data.options) if (typeof opt === "string") twine2.options.set(opt, true);
|
|
459
593
|
}
|
|
460
|
-
if (typeof data.start === "string"
|
|
461
|
-
if (typeof data.tags === "string")
|
|
594
|
+
if (typeof data.start === "string") twine2.start = data.start;
|
|
595
|
+
if (typeof data.tags === "string") twine2.tags = data.tags;
|
|
462
596
|
if (typeof data["tag-colors"] === "object" && data["tag-colors"] !== null && !Array.isArray(data["tag-colors"])) {
|
|
463
|
-
for (const [tag, color] of Object.entries(data["tag-colors"])) if (typeof color === "string")
|
|
597
|
+
for (const [tag, color] of Object.entries(data["tag-colors"])) if (typeof color === "string") twine2.tagColors.set(tag, color);
|
|
464
598
|
}
|
|
465
|
-
if (typeof data.zoom === "number" && data.zoom !== 0)
|
|
599
|
+
if (typeof data.zoom === "number" && data.zoom !== 0) twine2.zoom = data.zoom;
|
|
600
|
+
story.twine2 = twine2;
|
|
466
601
|
return null;
|
|
467
602
|
}
|
|
468
603
|
function unmarshalStorySettings(story, text, diagnostics) {
|
|
@@ -503,8 +638,20 @@ function unmarshalStorySettings(story, text, diagnostics) {
|
|
|
503
638
|
/**
|
|
504
639
|
* Process a passage and add it to the story, handling special passages.
|
|
505
640
|
* Creates new passage objects where text is modified rather than mutating the input.
|
|
641
|
+
*
|
|
642
|
+
* A passage replaces an earlier one with the same name, except where either name was generated
|
|
643
|
+
* (see `withGeneratedName()`): a generated name never replaces a passage and is never replaced.
|
|
644
|
+
* A passage with a generated name takes the first free name of `base`, `base 2`, … when its own
|
|
645
|
+
* is taken or is a compiler special name. A passage with a name from the sources that a generated
|
|
646
|
+
* name holds takes it over, and the generated one moves to the first free name in its place.
|
|
506
647
|
*/
|
|
507
648
|
function storyAdd(story, p, diagnostics) {
|
|
649
|
+
const origin = generatedNames.get(p);
|
|
650
|
+
if (origin !== void 0) {
|
|
651
|
+
addGenerated(story, p, origin, diagnostics);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
moveGeneratedAside(story, p.name, diagnostics);
|
|
508
655
|
let processed = p;
|
|
509
656
|
switch (p.name) {
|
|
510
657
|
case "StoryIncludes":
|
|
@@ -638,53 +785,413 @@ function isExcluded(filename, exclude) {
|
|
|
638
785
|
const rel = relative(process.cwd(), resolve(filename)) || filename;
|
|
639
786
|
return exclude.some((pattern) => matchesGlob(rel, pattern.replace(/^\.\//, "")));
|
|
640
787
|
}
|
|
788
|
+
/** A build that writes nothing: `compile()`, or output to stdout. */
|
|
789
|
+
const NO_BUILD_OUTPUTS = {
|
|
790
|
+
files: [],
|
|
791
|
+
dirs: []
|
|
792
|
+
};
|
|
793
|
+
/** The outputs of a build that writes the one file `outFile`, or none without it. */
|
|
794
|
+
function toBuildOutputs(outputs) {
|
|
795
|
+
if (outputs === void 0) return NO_BUILD_OUTPUTS;
|
|
796
|
+
return typeof outputs === "string" ? {
|
|
797
|
+
files: [outputs],
|
|
798
|
+
dirs: []
|
|
799
|
+
} : outputs;
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* The path outputs are compared by: absolute, with every symbolic link in it
|
|
803
|
+
* resolved, so two paths that reach the same file through different links (a
|
|
804
|
+
* symlinked project folder, an alias of the output folder) compare equal. A
|
|
805
|
+
* path that doesn't exist yet, such as the output before the first build, is
|
|
806
|
+
* its nearest existing folder's real path joined with the rest.
|
|
807
|
+
*/
|
|
808
|
+
function realPathOf(path) {
|
|
809
|
+
const abs = resolve(path);
|
|
810
|
+
try {
|
|
811
|
+
return realpathSync.native(abs);
|
|
812
|
+
} catch {}
|
|
813
|
+
const parent = dirname(abs);
|
|
814
|
+
return parent === abs ? abs : join(realPathOf(parent), basename(abs));
|
|
815
|
+
}
|
|
816
|
+
/** Whether the path `inner` is `outer` or inside it. */
|
|
817
|
+
function isSameOrInside(inner, outer) {
|
|
818
|
+
return inner === outer || inner.startsWith(outer.endsWith(sep) ? outer : outer + sep);
|
|
819
|
+
}
|
|
820
|
+
function outputPaths(outputs) {
|
|
821
|
+
const files = new Set(outputs.files.map(realPathOf));
|
|
822
|
+
const dirs = new Set(outputs.dirs.map(realPathOf));
|
|
823
|
+
const all = [...files, ...dirs];
|
|
824
|
+
return {
|
|
825
|
+
isFile: (real) => files.has(real),
|
|
826
|
+
isDir: (real) => dirs.has(real),
|
|
827
|
+
holds: (real) => dirs.has(real) || all.some((output) => output !== real && isSameOrInside(output, real)),
|
|
828
|
+
isOutput(path, roots) {
|
|
829
|
+
const real = realPathOf(path);
|
|
830
|
+
if (files.has(real)) return true;
|
|
831
|
+
const owners = [...dirs].filter((dir) => isSameOrInside(real, dir));
|
|
832
|
+
if (owners.length === 0) return false;
|
|
833
|
+
return roots.map(realPathOf).filter((root) => isSameOrInside(real, root)).every((root) => owners.some((dir) => dir !== root && isSameOrInside(dir, root)));
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* A folder entry as source discovery walks it, found at `pathname` with the real
|
|
839
|
+
* path `real` when it is no link: a link to a file counts as that file, and a
|
|
840
|
+
* link to anything else (a folder above all) as nothing, undefined. Throws when
|
|
841
|
+
* the entry or a link's target can't be read.
|
|
842
|
+
*/
|
|
843
|
+
function walkedEntry(pathname, real) {
|
|
844
|
+
const stat = lstatSync(pathname);
|
|
845
|
+
if (!stat.isSymbolicLink()) return {
|
|
846
|
+
stat,
|
|
847
|
+
real
|
|
848
|
+
};
|
|
849
|
+
const target = statSync(pathname);
|
|
850
|
+
return target.isFile() ? {
|
|
851
|
+
stat: target,
|
|
852
|
+
real: realPathOf(pathname)
|
|
853
|
+
} : void 0;
|
|
854
|
+
}
|
|
641
855
|
/**
|
|
642
856
|
* Recursively walk directories, collecting regular file paths.
|
|
643
|
-
*
|
|
644
|
-
*
|
|
857
|
+
*
|
|
858
|
+
* Leaves out what the build writes (`outputs`, compared by real path, see
|
|
859
|
+
* realPathOf) so its last output is never read back, and the files that match an
|
|
860
|
+
* `exclude` glob (see isExcluded). A path given that is itself an output file is
|
|
861
|
+
* not walked but listed in `outputSources`.
|
|
862
|
+
*
|
|
863
|
+
* Symbolic links: a path given is followed wherever it leads. Inside a folder, a
|
|
864
|
+
* link to a file is read, but a link to a folder is not followed, as Tweego's walk
|
|
865
|
+
* doesn't follow one. A link back to its own folder or a parent would otherwise
|
|
866
|
+
* walk the same files again at every depth.
|
|
867
|
+
*
|
|
645
868
|
* Like Tweego, a path that cannot be read is reported as a warning and skipped.
|
|
646
869
|
*/
|
|
647
|
-
function getFilenames(pathnames,
|
|
870
|
+
function getFilenames(pathnames, outputs, exclude = []) {
|
|
648
871
|
const filenames = [];
|
|
649
872
|
const diagnostics = [];
|
|
650
|
-
const
|
|
873
|
+
const outputSources = [];
|
|
874
|
+
const output = outputPaths(toBuildOutputs(outputs));
|
|
651
875
|
function warn(pathname, e) {
|
|
652
876
|
diagnostics.push({
|
|
653
877
|
level: "warning",
|
|
654
878
|
message: `path ${pathname}: ${e instanceof Error ? e.message : String(e)}`
|
|
655
879
|
});
|
|
656
880
|
}
|
|
657
|
-
function
|
|
881
|
+
function addFile(pathname) {
|
|
882
|
+
const abs = resolve(pathname);
|
|
883
|
+
const rel = relative(process.cwd(), abs);
|
|
884
|
+
if (isExcluded(rel || abs, exclude)) return;
|
|
885
|
+
filenames.push(rel || abs);
|
|
886
|
+
}
|
|
887
|
+
function walkDir(pathname, real) {
|
|
888
|
+
let entries;
|
|
889
|
+
try {
|
|
890
|
+
entries = readdirSync(pathname);
|
|
891
|
+
} catch (e) {
|
|
892
|
+
warn(pathname, e);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
for (const entry of entries) walkEntry(join(pathname, entry), join(real, entry));
|
|
896
|
+
}
|
|
897
|
+
function walkEntry(pathname, real) {
|
|
898
|
+
let entry;
|
|
899
|
+
try {
|
|
900
|
+
entry = walkedEntry(pathname, real);
|
|
901
|
+
} catch (e) {
|
|
902
|
+
warn(pathname, e);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
if (entry === void 0) return;
|
|
906
|
+
if (entry.stat.isFile()) {
|
|
907
|
+
if (!output.isFile(entry.real)) addFile(pathname);
|
|
908
|
+
} else if (entry.stat.isDirectory() && !output.isDir(entry.real)) walkDir(pathname, entry.real);
|
|
909
|
+
}
|
|
910
|
+
for (const pathname of pathnames) {
|
|
658
911
|
let stat;
|
|
659
912
|
try {
|
|
660
913
|
stat = statSync(pathname);
|
|
661
914
|
} catch (e) {
|
|
662
915
|
warn(pathname, e);
|
|
663
|
-
|
|
916
|
+
continue;
|
|
664
917
|
}
|
|
918
|
+
const real = realPathOf(pathname);
|
|
665
919
|
if (stat.isFile()) {
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
920
|
+
if (output.isFile(real)) outputSources.push(pathname);
|
|
921
|
+
else addFile(pathname);
|
|
922
|
+
} else if (stat.isDirectory()) walkDir(pathname, real);
|
|
923
|
+
}
|
|
924
|
+
return {
|
|
925
|
+
filenames,
|
|
926
|
+
diagnostics,
|
|
927
|
+
outputSources
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
//#endregion
|
|
931
|
+
//#region src/relaxed-json.ts
|
|
932
|
+
/**
|
|
933
|
+
* A parser for the object literal a Twine 2 `format.js` passes to `window.storyFormat()`.
|
|
934
|
+
*
|
|
935
|
+
* The story formats spec does not require that object to be strict JSON, and Twine 2 runs
|
|
936
|
+
* `format.js` as JavaScript. This parser accepts JSON plus the JavaScript literal syntax formats
|
|
937
|
+
* use in practice: single-quoted strings, unquoted (identifier) and numeric keys, trailing commas,
|
|
938
|
+
* comments, JavaScript string escapes, and hexadecimal, octal and binary numbers.
|
|
939
|
+
*
|
|
940
|
+
* It reads string literals token by token, so their contents come back exactly as JavaScript
|
|
941
|
+
* evaluation gives them: text that only looks like structure (`a, }`, `word:`, an apostrophe) is
|
|
942
|
+
* never touched. Anything outside that subset, such as a function or a variable, is a syntax error.
|
|
943
|
+
*/
|
|
944
|
+
const IDENTIFIER = /[A-Za-z_$][\w$]*/y;
|
|
945
|
+
const NUMBER = /[+-]?(?:0[xX][0-9a-fA-F]+|0[oO][0-7]+|0[bB][01]+|(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)/y;
|
|
946
|
+
const IDENTIFIER_PART = /[\w$]/;
|
|
947
|
+
const WHITESPACE = /[\s]/;
|
|
948
|
+
const HEX = /^[0-9a-fA-F]+$/;
|
|
949
|
+
/** Simple single-character escapes and the characters they stand for. */
|
|
950
|
+
const SIMPLE_ESCAPES = {
|
|
951
|
+
b: "\b",
|
|
952
|
+
f: "\f",
|
|
953
|
+
n: "\n",
|
|
954
|
+
r: "\r",
|
|
955
|
+
t: " ",
|
|
956
|
+
v: "\v"
|
|
957
|
+
};
|
|
958
|
+
/**
|
|
959
|
+
* Parse a JavaScript object, array or primitive literal: all of `text`, or the part from `start` to
|
|
960
|
+
* `end`. Error positions count from the start of `text`, so they point into the file it came from.
|
|
961
|
+
*
|
|
962
|
+
* @throws SyntaxError naming the line and column of the first thing it cannot parse.
|
|
963
|
+
*/
|
|
964
|
+
function parseRelaxedJSON(text, start = 0, end = text.length) {
|
|
965
|
+
let pos = start;
|
|
966
|
+
function fail(message, at = pos) {
|
|
967
|
+
const before = text.slice(0, at);
|
|
968
|
+
const line = before.split("\n").length;
|
|
969
|
+
const column = at - before.lastIndexOf("\n");
|
|
970
|
+
throw new SyntaxError(`${message} at line ${line}, column ${column}`);
|
|
971
|
+
}
|
|
972
|
+
/** The character at `pos`, or '' at the end of the input. */
|
|
973
|
+
const peek = () => pos < end ? text[pos] ?? "" : "";
|
|
974
|
+
const describeChar = (at) => at >= end ? "end of input" : JSON.stringify(text[at]);
|
|
975
|
+
/** Skip whitespace and comments. */
|
|
976
|
+
const skipTrivia = () => {
|
|
977
|
+
while (pos < end) {
|
|
978
|
+
const ch = peek();
|
|
979
|
+
if (WHITESPACE.test(ch)) pos++;
|
|
980
|
+
else if (text.startsWith("//", pos)) {
|
|
981
|
+
const lineEnd = text.slice(pos, end).search(/[\n\r\u2028\u2029]/);
|
|
982
|
+
pos = lineEnd === -1 ? end : pos + lineEnd;
|
|
983
|
+
} else if (text.startsWith("/*", pos)) {
|
|
984
|
+
const commentEnd = text.indexOf("*/", pos + 2);
|
|
985
|
+
if (commentEnd === -1 || commentEnd + 2 > end) fail("Unterminated comment");
|
|
986
|
+
pos = commentEnd + 2;
|
|
987
|
+
} else return;
|
|
988
|
+
}
|
|
989
|
+
};
|
|
990
|
+
const consume = (ch) => {
|
|
991
|
+
skipTrivia();
|
|
992
|
+
if (peek() !== ch) fail(`Expected ${JSON.stringify(ch)} but found ${describeChar(pos)}`);
|
|
993
|
+
pos++;
|
|
994
|
+
};
|
|
995
|
+
/** Read exactly `count` hex digits and return their value. */
|
|
996
|
+
const readHex = (count) => {
|
|
997
|
+
const digits = text.slice(pos, Math.min(pos + count, end));
|
|
998
|
+
if (digits.length !== count || !HEX.test(digits)) fail("Invalid hexadecimal escape sequence");
|
|
999
|
+
pos += count;
|
|
1000
|
+
return parseInt(digits, 16);
|
|
1001
|
+
};
|
|
1002
|
+
/** Decode the escape sequence after a backslash; `pos` is just past the backslash. */
|
|
1003
|
+
const readEscape = () => {
|
|
1004
|
+
const ch = peek();
|
|
1005
|
+
const simple = SIMPLE_ESCAPES[ch];
|
|
1006
|
+
if (simple !== void 0) {
|
|
1007
|
+
pos++;
|
|
1008
|
+
return simple;
|
|
1009
|
+
}
|
|
1010
|
+
switch (ch) {
|
|
1011
|
+
case "": return fail("Unterminated string");
|
|
1012
|
+
case "x":
|
|
1013
|
+
pos++;
|
|
1014
|
+
return String.fromCharCode(readHex(2));
|
|
1015
|
+
case "u": {
|
|
1016
|
+
pos++;
|
|
1017
|
+
if (peek() !== "{") return String.fromCharCode(readHex(4));
|
|
1018
|
+
const close = text.indexOf("}", pos);
|
|
1019
|
+
if (close === -1 || close >= end) fail("Invalid Unicode escape sequence");
|
|
1020
|
+
pos++;
|
|
1021
|
+
const codePoint = readHex(close - pos);
|
|
1022
|
+
if (codePoint > 1114111) fail("Undefined Unicode code-point");
|
|
1023
|
+
pos = close + 1;
|
|
1024
|
+
return String.fromCodePoint(codePoint);
|
|
1025
|
+
}
|
|
1026
|
+
case "\r":
|
|
1027
|
+
pos += text[pos + 1] === "\n" ? 2 : 1;
|
|
1028
|
+
return "";
|
|
1029
|
+
case "\n":
|
|
1030
|
+
case "\u2028":
|
|
1031
|
+
case "\u2029":
|
|
1032
|
+
pos++;
|
|
1033
|
+
return "";
|
|
1034
|
+
case "0":
|
|
1035
|
+
if (/[0-9]/.test(text[pos + 1] ?? "")) fail("Octal escape sequences are not supported");
|
|
1036
|
+
pos++;
|
|
1037
|
+
return "\0";
|
|
1038
|
+
default:
|
|
1039
|
+
if (/[1-9]/.test(ch)) fail("Octal escape sequences are not supported");
|
|
1040
|
+
pos++;
|
|
1041
|
+
return ch;
|
|
1042
|
+
}
|
|
1043
|
+
};
|
|
1044
|
+
const readString = () => {
|
|
1045
|
+
const quote = peek();
|
|
1046
|
+
pos++;
|
|
1047
|
+
let out = "";
|
|
1048
|
+
let chunkStart = pos;
|
|
1049
|
+
for (;;) {
|
|
1050
|
+
if (pos >= end) fail("Unterminated string");
|
|
1051
|
+
const ch = peek();
|
|
1052
|
+
if (ch === quote) {
|
|
1053
|
+
out += text.slice(chunkStart, pos);
|
|
1054
|
+
pos++;
|
|
1055
|
+
return out;
|
|
1056
|
+
}
|
|
1057
|
+
if (ch === "\n" || ch === "\r") fail("Unterminated string");
|
|
1058
|
+
if (ch === "\\") {
|
|
1059
|
+
out += text.slice(chunkStart, pos);
|
|
1060
|
+
pos++;
|
|
1061
|
+
out += readEscape();
|
|
1062
|
+
chunkStart = pos;
|
|
1063
|
+
} else pos++;
|
|
1064
|
+
}
|
|
1065
|
+
};
|
|
1066
|
+
/** Read a match of a sticky regular expression at `pos`, or return undefined. */
|
|
1067
|
+
const readToken = (pattern) => {
|
|
1068
|
+
pattern.lastIndex = pos;
|
|
1069
|
+
const m = pattern.exec(text);
|
|
1070
|
+
if (!m || pattern.lastIndex > end) return void 0;
|
|
1071
|
+
pos = pattern.lastIndex;
|
|
1072
|
+
return m[0];
|
|
1073
|
+
};
|
|
1074
|
+
const readNumber = () => {
|
|
1075
|
+
const tokenStart = pos;
|
|
1076
|
+
const token = readToken(NUMBER);
|
|
1077
|
+
if (token === void 0) return void 0;
|
|
1078
|
+
if (IDENTIFIER_PART.test(peek())) fail("Invalid number", tokenStart);
|
|
1079
|
+
return (token.startsWith("-") ? -1 : 1) * Number(/^[+-]/.test(token) ? token.slice(1) : token);
|
|
1080
|
+
};
|
|
1081
|
+
const readKey = () => {
|
|
1082
|
+
skipTrivia();
|
|
1083
|
+
const ch = peek();
|
|
1084
|
+
if (ch === "\"" || ch === "'") return readString();
|
|
1085
|
+
const identifier = readToken(IDENTIFIER);
|
|
1086
|
+
if (identifier !== void 0) return identifier;
|
|
1087
|
+
const number = readNumber();
|
|
1088
|
+
if (number !== void 0) return String(number);
|
|
1089
|
+
return fail(`Expected a property name but found ${describeChar(pos)}`);
|
|
1090
|
+
};
|
|
1091
|
+
const readObject = () => {
|
|
1092
|
+
pos++;
|
|
1093
|
+
const out = {};
|
|
1094
|
+
for (;;) {
|
|
1095
|
+
skipTrivia();
|
|
1096
|
+
if (peek() === "}") {
|
|
1097
|
+
pos++;
|
|
1098
|
+
return out;
|
|
1099
|
+
}
|
|
1100
|
+
const key = readKey();
|
|
1101
|
+
consume(":");
|
|
1102
|
+
Object.defineProperty(out, key, {
|
|
1103
|
+
value: readValue(),
|
|
1104
|
+
enumerable: true,
|
|
1105
|
+
writable: true,
|
|
1106
|
+
configurable: true
|
|
1107
|
+
});
|
|
1108
|
+
skipTrivia();
|
|
1109
|
+
if (peek() === ",") pos++;
|
|
1110
|
+
else if (peek() !== "}") fail(`Expected "," or "}" but found ${describeChar(pos)}`);
|
|
1111
|
+
}
|
|
1112
|
+
};
|
|
1113
|
+
const readArray = () => {
|
|
1114
|
+
pos++;
|
|
1115
|
+
const out = [];
|
|
1116
|
+
for (;;) {
|
|
1117
|
+
skipTrivia();
|
|
1118
|
+
if (peek() === "]") {
|
|
1119
|
+
pos++;
|
|
1120
|
+
return out;
|
|
678
1121
|
}
|
|
679
|
-
|
|
1122
|
+
out.push(readValue());
|
|
1123
|
+
skipTrivia();
|
|
1124
|
+
if (peek() === ",") pos++;
|
|
1125
|
+
else if (peek() !== "]") fail(`Expected "," or "]" but found ${describeChar(pos)}`);
|
|
680
1126
|
}
|
|
1127
|
+
};
|
|
1128
|
+
function readValue() {
|
|
1129
|
+
skipTrivia();
|
|
1130
|
+
const ch = peek();
|
|
1131
|
+
if (ch === "{") return readObject();
|
|
1132
|
+
if (ch === "[") return readArray();
|
|
1133
|
+
if (ch === "\"" || ch === "'") return readString();
|
|
1134
|
+
const tokenStart = pos;
|
|
1135
|
+
const identifier = readToken(IDENTIFIER);
|
|
1136
|
+
if (identifier !== void 0) {
|
|
1137
|
+
if (identifier === "true") return true;
|
|
1138
|
+
if (identifier === "false") return false;
|
|
1139
|
+
if (identifier === "null") return null;
|
|
1140
|
+
return fail(`Unexpected identifier ${JSON.stringify(identifier)}`, tokenStart);
|
|
1141
|
+
}
|
|
1142
|
+
const number = readNumber();
|
|
1143
|
+
if (number !== void 0) return number;
|
|
1144
|
+
return fail(`Unexpected ${describeChar(pos)}`);
|
|
681
1145
|
}
|
|
682
|
-
|
|
1146
|
+
const value = readValue();
|
|
1147
|
+
skipTrivia();
|
|
1148
|
+
if (pos < end) fail(`Unexpected ${describeChar(pos)} after the value`);
|
|
1149
|
+
return value;
|
|
1150
|
+
}
|
|
1151
|
+
//#endregion
|
|
1152
|
+
//#region src/semver.ts
|
|
1153
|
+
const IDENTIFIERS = String.raw`[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*`;
|
|
1154
|
+
const VERSION$1 = new RegExp(String.raw`^[vV]?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-(${IDENTIFIERS}))?(?:\+${IDENTIFIERS})?$`);
|
|
1155
|
+
const NUMERIC = /^\d+$/;
|
|
1156
|
+
/** Parse a version string, or return null when it is not a version. */
|
|
1157
|
+
function parseVersion(text) {
|
|
1158
|
+
const m = VERSION$1.exec(text);
|
|
1159
|
+
if (!m) return null;
|
|
683
1160
|
return {
|
|
684
|
-
|
|
685
|
-
|
|
1161
|
+
major: Number(m[1]),
|
|
1162
|
+
minor: Number(m[2] ?? 0),
|
|
1163
|
+
patch: Number(m[3] ?? 0),
|
|
1164
|
+
prerelease: m[4] === void 0 ? [] : m[4].split(".")
|
|
686
1165
|
};
|
|
687
1166
|
}
|
|
1167
|
+
/** Compare two prerelease identifiers: numeric ones numerically and below alphanumeric ones, which compare as ASCII. */
|
|
1168
|
+
function compareIdentifiers(a, b) {
|
|
1169
|
+
const aNumeric = NUMERIC.test(a);
|
|
1170
|
+
const bNumeric = NUMERIC.test(b);
|
|
1171
|
+
if (aNumeric && bNumeric) return Math.sign(Number(a) - Number(b));
|
|
1172
|
+
if (aNumeric !== bNumeric) return aNumeric ? -1 : 1;
|
|
1173
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
1174
|
+
}
|
|
1175
|
+
/** Compare prerelease identifier lists. An empty list (a release) ranks above any prerelease. */
|
|
1176
|
+
function comparePrerelease(a, b) {
|
|
1177
|
+
if (a.length === 0 || b.length === 0) return Math.sign(b.length - a.length);
|
|
1178
|
+
const shared = Math.min(a.length, b.length);
|
|
1179
|
+
for (let i = 0; i < shared; i++) {
|
|
1180
|
+
const order = compareIdentifiers(a[i] ?? "", b[i] ?? "");
|
|
1181
|
+
if (order !== 0) return order;
|
|
1182
|
+
}
|
|
1183
|
+
return Math.sign(a.length - b.length);
|
|
1184
|
+
}
|
|
1185
|
+
/** Compare two versions by SemVer precedence: negative when `a` ranks lower, 0 when equal, positive when higher. */
|
|
1186
|
+
function compareVersions(a, b) {
|
|
1187
|
+
return Math.sign(a.major - b.major) || Math.sign(a.minor - b.minor) || Math.sign(a.patch - b.patch) || comparePrerelease(a.prerelease, b.prerelease);
|
|
1188
|
+
}
|
|
1189
|
+
/** Whether two version strings name the same version (prerelease included, build metadata ignored). */
|
|
1190
|
+
function sameVersion(a, b) {
|
|
1191
|
+
const left = parseVersion(a);
|
|
1192
|
+
const right = parseVersion(b);
|
|
1193
|
+
return left !== null && right !== null && compareVersions(left, right) === 0;
|
|
1194
|
+
}
|
|
688
1195
|
//#endregion
|
|
689
1196
|
//#region src/source-text.ts
|
|
690
1197
|
/**
|
|
@@ -697,43 +1204,138 @@ function getFilenames(pathnames, outFilename, exclude = []) {
|
|
|
697
1204
|
function normalizeSourceText(text) {
|
|
698
1205
|
return (text.charCodeAt(0) === 65279 ? text.slice(1) : text).replace(/\r\n?/g, "\n");
|
|
699
1206
|
}
|
|
1207
|
+
/** Byte order marks at the start of a line, directly before a passage header's `::`. */
|
|
1208
|
+
const BOMS_BEFORE_HEADER = /(?<=^|\n)\uFEFF+(?=::)/g;
|
|
1209
|
+
/**
|
|
1210
|
+
* Normalize Twee source text: {@link normalizeSourceText}, and also remove byte order marks at the start of
|
|
1211
|
+
* a line that are directly followed by `::`. Concatenating files (`cat a.tw b.tw`) leaves the BOM of every
|
|
1212
|
+
* later file in front of its first passage header, which would otherwise not be recognised as a header.
|
|
1213
|
+
* A U+FEFF anywhere else is passage text and kept. Idempotent.
|
|
1214
|
+
*/
|
|
1215
|
+
function normalizeTweeSourceText(text) {
|
|
1216
|
+
return normalizeSourceText(text).replace(BOMS_BEFORE_HEADER, "");
|
|
1217
|
+
}
|
|
700
1218
|
//#endregion
|
|
701
1219
|
//#region src/util.ts
|
|
702
1220
|
/**
|
|
703
1221
|
* Shared file I/O utilities.
|
|
704
1222
|
*/
|
|
705
|
-
/**
|
|
706
|
-
|
|
707
|
-
|
|
1223
|
+
/** The charset assumed for text that is not valid UTF-8, as Tweego assumes it. */
|
|
1224
|
+
const FALLBACK_CHARSET = "windows-1252";
|
|
1225
|
+
/**
|
|
1226
|
+
* Throws on invalid UTF-8 instead of replacing bytes with U+FFFD. `ignoreBOM` keeps a leading BOM in the
|
|
1227
|
+
* text, as `readFileSync(file, 'utf-8')` did, so {@link normalizeSourceText} strips exactly one.
|
|
1228
|
+
*/
|
|
1229
|
+
const utf8Decoder = new TextDecoder("utf-8", {
|
|
1230
|
+
fatal: true,
|
|
1231
|
+
ignoreBOM: true
|
|
1232
|
+
});
|
|
1233
|
+
/**
|
|
1234
|
+
* The code points of the Windows-1252 bytes 0x80 to 0x9F, from the WHATWG Encoding Standard. The five bytes
|
|
1235
|
+
* Windows-1252 leaves undefined map to the C1 control with the same number, as the standard and Go's charmap
|
|
1236
|
+
* do. Every other byte is the code point with the same number.
|
|
1237
|
+
*
|
|
1238
|
+
* Decoded by hand, because Node's `TextDecoder('windows-1252')` decodes as ISO-8859-1 in some releases
|
|
1239
|
+
* (Node 22.18 turns 0x80 into U+0080 instead of €), and builds without ICU lack it.
|
|
1240
|
+
*/
|
|
1241
|
+
const WINDOWS_1252_C1 = [
|
|
1242
|
+
8364,
|
|
1243
|
+
129,
|
|
1244
|
+
8218,
|
|
1245
|
+
402,
|
|
1246
|
+
8222,
|
|
1247
|
+
8230,
|
|
1248
|
+
8224,
|
|
1249
|
+
8225,
|
|
1250
|
+
710,
|
|
1251
|
+
8240,
|
|
1252
|
+
352,
|
|
1253
|
+
8249,
|
|
1254
|
+
338,
|
|
1255
|
+
141,
|
|
1256
|
+
381,
|
|
1257
|
+
143,
|
|
1258
|
+
144,
|
|
1259
|
+
8216,
|
|
1260
|
+
8217,
|
|
1261
|
+
8220,
|
|
1262
|
+
8221,
|
|
1263
|
+
8226,
|
|
1264
|
+
8211,
|
|
1265
|
+
8212,
|
|
1266
|
+
732,
|
|
1267
|
+
8482,
|
|
1268
|
+
353,
|
|
1269
|
+
8250,
|
|
1270
|
+
339,
|
|
1271
|
+
157,
|
|
1272
|
+
382,
|
|
1273
|
+
376
|
|
1274
|
+
];
|
|
1275
|
+
function decodeWindows1252(bytes) {
|
|
1276
|
+
const chunks = [];
|
|
1277
|
+
for (let start = 0; start < bytes.length; start += 8192) {
|
|
1278
|
+
const codes = Array.from(bytes.subarray(start, start + 8192), (b) => b >= 128 && b <= 159 ? WINDOWS_1252_C1[b - 128] ?? b : b);
|
|
1279
|
+
chunks.push(String.fromCharCode(...codes));
|
|
1280
|
+
}
|
|
1281
|
+
return chunks.join("");
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* Decode bytes read from `filename` as UTF-8. Bytes that are not valid UTF-8 are decoded as Windows-1252
|
|
1285
|
+
* instead, with a warning naming the file, as Tweego does (`fileReadAllWithEncoding` in io.go), so a legacy
|
|
1286
|
+
* file keeps its non-ASCII characters instead of losing them to U+FFFD.
|
|
1287
|
+
*
|
|
1288
|
+
* The text is returned as decoded: a leading BOM and CR line endings are kept (see {@link normalizeSourceText}).
|
|
1289
|
+
*/
|
|
1290
|
+
function decodeText(bytes, filename) {
|
|
1291
|
+
try {
|
|
1292
|
+
return {
|
|
1293
|
+
text: utf8Decoder.decode(bytes),
|
|
1294
|
+
diagnostics: []
|
|
1295
|
+
};
|
|
1296
|
+
} catch {
|
|
1297
|
+
return {
|
|
1298
|
+
text: decodeWindows1252(bytes),
|
|
1299
|
+
diagnostics: [{
|
|
1300
|
+
level: "warning",
|
|
1301
|
+
message: `read ${filename}: Invalid UTF-8; assuming charset is ${FALLBACK_CHARSET}.`,
|
|
1302
|
+
file: filename
|
|
1303
|
+
}]
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Read a text file: decode it with {@link decodeText} (UTF-8, or Windows-1252 when it is not valid UTF-8),
|
|
1309
|
+
* strip a leading BOM and convert CRLF and bare CR line endings to LF. This is the one path every file read
|
|
1310
|
+
* as text goes through: sources, modules, the head file, story formats and the config file.
|
|
1311
|
+
*
|
|
1312
|
+
* @param diagnostics Receives the warning when the file is not valid UTF-8. Without it, the file is decoded
|
|
1313
|
+
* the same way and the warning is dropped.
|
|
1314
|
+
* @throws When the file cannot be read.
|
|
1315
|
+
*/
|
|
1316
|
+
function readUTF8(filename, diagnostics) {
|
|
1317
|
+
const decoded = decodeText(readFileSync(filename), filename);
|
|
1318
|
+
diagnostics?.push(...decoded.diagnostics);
|
|
1319
|
+
return normalizeSourceText(decoded.text);
|
|
708
1320
|
}
|
|
709
1321
|
/** Read a file as base64. */
|
|
710
1322
|
function readBase64(filename) {
|
|
711
1323
|
return readFileSync(filename).toString("base64");
|
|
712
1324
|
}
|
|
713
|
-
/** Get the filename without extension,
|
|
1325
|
+
/** Get the filename without extension, a dotfile keeps its whole name. */
|
|
714
1326
|
function baseNameWithoutExt(filename) {
|
|
715
|
-
|
|
716
|
-
return name || ext;
|
|
1327
|
+
return parse(filename).name;
|
|
717
1328
|
}
|
|
718
1329
|
//#endregion
|
|
719
1330
|
//#region src/formats.ts
|
|
720
1331
|
/**
|
|
721
1332
|
* Story format discovery, loading, and SemVer matching.
|
|
722
1333
|
* Ported from formats.go + config.go.
|
|
1334
|
+
*
|
|
1335
|
+
* Every lookup (local folders here, the download cache and remote indices in remote-formats.ts)
|
|
1336
|
+
* goes through the same version parsing ({@link parseVersion}) and the same matching rules
|
|
1337
|
+
* ({@link selectFormatCandidate}).
|
|
723
1338
|
*/
|
|
724
|
-
/**
|
|
725
|
-
* Attempt to fix non-strict JSON (trailing commas, single quotes, unquoted keys).
|
|
726
|
-
*/
|
|
727
|
-
function relaxedJSONParse(json) {
|
|
728
|
-
try {
|
|
729
|
-
return JSON.parse(json);
|
|
730
|
-
} catch {}
|
|
731
|
-
let fixed = json;
|
|
732
|
-
fixed = fixed.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, "\"$1\"");
|
|
733
|
-
fixed = fixed.replace(/(?<=^|[{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:/gm, "\"$1\":");
|
|
734
|
-
fixed = fixed.replace(/,\s*([}\]])/g, "$1");
|
|
735
|
-
return JSON.parse(fixed);
|
|
736
|
-
}
|
|
737
1339
|
/** A `"setup": function` property, which Harlowe appends to its otherwise-JSON format object. */
|
|
738
1340
|
const SETUP_FUNCTION_PROPERTY = /,\s*"setup"\s*:\s*function\b/g;
|
|
739
1341
|
/** Drop a trailing `"setup": function(){…}` property from a format object, if it has one. */
|
|
@@ -743,151 +1345,241 @@ function stripSetupFunction(chunk) {
|
|
|
743
1345
|
return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
|
|
744
1346
|
}
|
|
745
1347
|
/**
|
|
746
|
-
* Parse the
|
|
747
|
-
*
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
1348
|
+
* Parse the format object at `start`–`end` of `text`: strict JSON first (fast), then the
|
|
1349
|
+
* JavaScript literal subset formats use. Error positions point into `text`.
|
|
1350
|
+
*/
|
|
1351
|
+
function parseFormatObject(text, start, end) {
|
|
1352
|
+
try {
|
|
1353
|
+
return {
|
|
1354
|
+
ok: true,
|
|
1355
|
+
value: JSON.parse(text.slice(start, end))
|
|
1356
|
+
};
|
|
1357
|
+
} catch {}
|
|
1358
|
+
try {
|
|
1359
|
+
return {
|
|
1360
|
+
ok: true,
|
|
1361
|
+
value: parseRelaxedJSON(text, start, end)
|
|
1362
|
+
};
|
|
1363
|
+
} catch (e) {
|
|
1364
|
+
return {
|
|
1365
|
+
ok: false,
|
|
1366
|
+
error: e instanceof Error ? e.message : String(e)
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
/** Check a parsed format object's fields. Per spec, `name` is optional; `version` and `source` are required. */
|
|
1371
|
+
/** The name given to a format.js that names no format. */
|
|
1372
|
+
const UNNAMED_FORMAT_NAME = "Untitled Story Format";
|
|
1373
|
+
function toFormatJSON(raw) {
|
|
1374
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
|
|
1375
|
+
ok: false,
|
|
1376
|
+
reason: "Story format JSON chunk is not an object."
|
|
1377
|
+
};
|
|
1378
|
+
const obj = raw;
|
|
1379
|
+
if (typeof obj.version !== "string") return {
|
|
1380
|
+
ok: false,
|
|
1381
|
+
reason: "Story format has no \"version\" string."
|
|
1382
|
+
};
|
|
1383
|
+
if (typeof obj.source !== "string") return {
|
|
1384
|
+
ok: false,
|
|
1385
|
+
reason: "Story format has no \"source\" string."
|
|
1386
|
+
};
|
|
1387
|
+
if (!parseVersion(obj.version)) return {
|
|
1388
|
+
ok: false,
|
|
1389
|
+
reason: `Story format version ${JSON.stringify(obj.version)} is not a SemVer version.`
|
|
1390
|
+
};
|
|
1391
|
+
const data = {
|
|
1392
|
+
name: typeof obj.name === "string" ? obj.name : UNNAMED_FORMAT_NAME,
|
|
1393
|
+
version: obj.version,
|
|
1394
|
+
source: obj.source,
|
|
1395
|
+
proofing: obj.proofing === true
|
|
1396
|
+
};
|
|
1397
|
+
if (typeof obj.author === "string") data.author = obj.author;
|
|
1398
|
+
if (typeof obj.description === "string") data.description = obj.description;
|
|
1399
|
+
if (typeof obj.image === "string") data.image = obj.image;
|
|
1400
|
+
if (typeof obj.url === "string") data.url = obj.url;
|
|
1401
|
+
if (typeof obj.license === "string") data.license = obj.license;
|
|
1402
|
+
return {
|
|
1403
|
+
ok: true,
|
|
1404
|
+
data
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
/**
|
|
1408
|
+
* Read the metadata of a Twine 2 format.js, or say why it cannot be used.
|
|
751
1409
|
*
|
|
752
|
-
*
|
|
1410
|
+
* The object passed to `window.storyFormat()` may be strict JSON or a JavaScript object literal
|
|
1411
|
+
* (single quotes, unquoted keys, trailing commas, comments). String values come back exactly as
|
|
1412
|
+
* JavaScript evaluation gives them. Harlowe's function-valued `setup` property is dropped; the
|
|
1413
|
+
* workaround keys on the property itself, so the same bytes parse whether they come from a
|
|
1414
|
+
* `harlowe-3` directory or a direct download.
|
|
753
1415
|
*/
|
|
754
|
-
function
|
|
1416
|
+
function decodeFormatJSON(source) {
|
|
755
1417
|
const first = source.indexOf("{");
|
|
756
1418
|
const last = source.lastIndexOf("}");
|
|
757
|
-
if (first === -1 || last
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
proofing: obj.proofing === true
|
|
770
|
-
};
|
|
771
|
-
if (typeof obj.author === "string") result.author = obj.author;
|
|
772
|
-
if (typeof obj.description === "string") result.description = obj.description;
|
|
773
|
-
if (typeof obj.image === "string") result.image = obj.image;
|
|
774
|
-
if (typeof obj.url === "string") result.url = obj.url;
|
|
775
|
-
if (typeof obj.license === "string") result.license = obj.license;
|
|
776
|
-
return result;
|
|
1419
|
+
if (first === -1 || last < first) return {
|
|
1420
|
+
ok: false,
|
|
1421
|
+
reason: "Could not find Twine 2 style story format JSON chunk."
|
|
1422
|
+
};
|
|
1423
|
+
const parsed = parseFormatObject(source, first, last + 1);
|
|
1424
|
+
if (parsed.ok) return toFormatJSON(parsed.value);
|
|
1425
|
+
const stripped = stripSetupFunction(source.slice(first, last + 1));
|
|
1426
|
+
const retried = stripped === void 0 ? void 0 : parseFormatObject(stripped, 0, stripped.length);
|
|
1427
|
+
if (retried?.ok) return toFormatJSON(retried.value);
|
|
1428
|
+
return {
|
|
1429
|
+
ok: false,
|
|
1430
|
+
reason: `Could not decode story format JSON chunk: ${parsed.error}`
|
|
777
1431
|
};
|
|
1432
|
+
}
|
|
1433
|
+
/**
|
|
1434
|
+
* Parse the Twine 2 format.js JSON chunk; null when it cannot be used ({@link decodeFormatJSON}
|
|
1435
|
+
* gives the reason).
|
|
1436
|
+
*
|
|
1437
|
+
* @param _formatId No longer used; kept so existing callers keep compiling.
|
|
1438
|
+
*/
|
|
1439
|
+
function parseFormatJSON(source, _formatId) {
|
|
1440
|
+
const result = decodeFormatJSON(source);
|
|
1441
|
+
return result.ok ? result.data : null;
|
|
1442
|
+
}
|
|
1443
|
+
/** A directory's entries in a stable order, or none when it cannot be read. */
|
|
1444
|
+
function listDirectory(dir) {
|
|
778
1445
|
try {
|
|
779
|
-
return
|
|
1446
|
+
return readdirSync(dir).sort();
|
|
780
1447
|
} catch {
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1448
|
+
return [];
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
function isDirectory(path) {
|
|
1452
|
+
try {
|
|
1453
|
+
return statSync(path).isDirectory();
|
|
1454
|
+
} catch {
|
|
1455
|
+
return false;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
function isFile(path) {
|
|
1459
|
+
try {
|
|
1460
|
+
return statSync(path).isFile();
|
|
1461
|
+
} catch {
|
|
1462
|
+
return false;
|
|
788
1463
|
}
|
|
789
1464
|
}
|
|
790
1465
|
/**
|
|
791
|
-
*
|
|
792
|
-
*
|
|
793
|
-
* only the highest minor.patch survives.
|
|
1466
|
+
* Load the format in one folder: its format.js (Twine 2), else its header.html (Twine 1).
|
|
1467
|
+
* A format.js that cannot be used is reported as a warning, as Tweego does.
|
|
794
1468
|
*/
|
|
795
|
-
function
|
|
796
|
-
const
|
|
797
|
-
|
|
798
|
-
for (const
|
|
1469
|
+
function loadFormatDir(searchDir, id, diagnostics) {
|
|
1470
|
+
const formatDir = join(searchDir, id);
|
|
1471
|
+
if (!isDirectory(formatDir)) return void 0;
|
|
1472
|
+
for (const baseFilename of ["format.js", "header.html"]) {
|
|
1473
|
+
const filename = join(formatDir, baseFilename);
|
|
1474
|
+
if (!isFile(filename)) continue;
|
|
1475
|
+
if (baseFilename === "header.html") return {
|
|
1476
|
+
id,
|
|
1477
|
+
filename,
|
|
1478
|
+
isTwine2: false,
|
|
1479
|
+
name: id,
|
|
1480
|
+
version: "",
|
|
1481
|
+
proofing: false
|
|
1482
|
+
};
|
|
1483
|
+
const encoding = [];
|
|
1484
|
+
let decoded;
|
|
799
1485
|
try {
|
|
800
|
-
|
|
801
|
-
} catch {
|
|
802
|
-
|
|
1486
|
+
decoded = decodeFormatJSON(readUTF8(filename, encoding));
|
|
1487
|
+
} catch (e) {
|
|
1488
|
+
decoded = {
|
|
1489
|
+
ok: false,
|
|
1490
|
+
reason: `Could not read ${filename}: ${e instanceof Error ? e.message : String(e)}`
|
|
1491
|
+
};
|
|
803
1492
|
}
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
1493
|
+
if (!decoded.ok) {
|
|
1494
|
+
diagnostics?.push(...encoding);
|
|
1495
|
+
diagnostics?.push({
|
|
1496
|
+
level: "warning",
|
|
1497
|
+
message: `format ${id}: Skipping format; ${decoded.reason} (${filename})`
|
|
1498
|
+
});
|
|
808
1499
|
continue;
|
|
809
1500
|
}
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
if (data.url) format.url = data.url;
|
|
843
|
-
if (data.license) format.license = data.license;
|
|
844
|
-
} catch {
|
|
845
|
-
continue;
|
|
846
|
-
}
|
|
847
|
-
else format.name = entry;
|
|
848
|
-
formats.set(entry, format);
|
|
849
|
-
break;
|
|
850
|
-
}
|
|
851
|
-
}
|
|
1501
|
+
const { data } = decoded;
|
|
1502
|
+
const format = {
|
|
1503
|
+
id,
|
|
1504
|
+
filename,
|
|
1505
|
+
isTwine2: true,
|
|
1506
|
+
name: data.name,
|
|
1507
|
+
version: data.version,
|
|
1508
|
+
proofing: data.proofing ?? false
|
|
1509
|
+
};
|
|
1510
|
+
if (data.author) format.author = data.author;
|
|
1511
|
+
if (data.description) format.description = data.description;
|
|
1512
|
+
if (data.image) format.image = data.image;
|
|
1513
|
+
if (data.url) format.url = data.url;
|
|
1514
|
+
if (data.license) format.license = data.license;
|
|
1515
|
+
return format;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
/**
|
|
1519
|
+
* Discover every story format in the search directories, keyed by folder name (the format ID),
|
|
1520
|
+
* without version pruning.
|
|
1521
|
+
*
|
|
1522
|
+
* Directories are searched in order, and a later directory outranks an earlier one: when two hold
|
|
1523
|
+
* the same folder name, the later one's format is kept. The map iterates in that order too, lowest
|
|
1524
|
+
* rank first. Formats that cannot be used are left out, with a warning in `diagnostics`.
|
|
1525
|
+
*/
|
|
1526
|
+
function discoverAllFormats(searchDirs, diagnostics) {
|
|
1527
|
+
const formats = /* @__PURE__ */ new Map();
|
|
1528
|
+
for (const searchDir of searchDirs) for (const entry of listDirectory(searchDir)) {
|
|
1529
|
+
const format = loadFormatDir(searchDir, entry, diagnostics);
|
|
1530
|
+
if (!format) continue;
|
|
1531
|
+
formats.delete(entry);
|
|
1532
|
+
formats.set(entry, format);
|
|
852
1533
|
}
|
|
853
|
-
pruneFormats(formats);
|
|
854
1534
|
return formats;
|
|
855
1535
|
}
|
|
1536
|
+
/** The version a format is pruned and selected by, or null for one outside SemVer pruning. */
|
|
1537
|
+
function prunableVersion(format) {
|
|
1538
|
+
return format.isTwine2 && format.name ? parseVersion(format.version) : null;
|
|
1539
|
+
}
|
|
1540
|
+
/** The search directory a discovered format was found in. */
|
|
1541
|
+
function searchDirOf(format) {
|
|
1542
|
+
return dirname(dirname(format.filename));
|
|
1543
|
+
}
|
|
856
1544
|
/**
|
|
857
|
-
* Prune formats by SemVer: within each (name, major) group,
|
|
858
|
-
*
|
|
1545
|
+
* Prune formats by SemVer: within each (name, major) group, keep only the version with the highest
|
|
1546
|
+
* precedence (a release outranks its prereleases). Between equal versions, the folder in the
|
|
1547
|
+
* higher-ranked search directory wins, and within one directory the first folder name in sort
|
|
1548
|
+
* order. Returns a new map in the same order.
|
|
1549
|
+
*
|
|
1550
|
+
* Pruning serves name-based selection and listings. An explicit format ID is looked up in the
|
|
1551
|
+
* unpruned map, so a pinned folder is used even when another folder holds a newer version.
|
|
859
1552
|
*/
|
|
860
1553
|
function pruneFormats(formats) {
|
|
861
|
-
const
|
|
862
|
-
const
|
|
863
|
-
|
|
864
|
-
if (!
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
const
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
version: v
|
|
1554
|
+
const dirRank = /* @__PURE__ */ new Map();
|
|
1555
|
+
for (const format of formats.values()) {
|
|
1556
|
+
const dir = searchDirOf(format);
|
|
1557
|
+
if (!dirRank.has(dir)) dirRank.set(dir, dirRank.size);
|
|
1558
|
+
}
|
|
1559
|
+
const rankOf = (format) => dirRank.get(searchDirOf(format)) ?? 0;
|
|
1560
|
+
const best = /* @__PURE__ */ new Map();
|
|
1561
|
+
for (const format of formats.values()) {
|
|
1562
|
+
const version = prunableVersion(format);
|
|
1563
|
+
if (!version) continue;
|
|
1564
|
+
const group = `${format.name}@${version.major}`;
|
|
1565
|
+
const current = best.get(group);
|
|
1566
|
+
const order = current ? compareVersions(version, current.version) : 1;
|
|
1567
|
+
if (order > 0 || order === 0 && current && rankOf(format) > rankOf(current.format)) best.set(group, {
|
|
1568
|
+
format,
|
|
1569
|
+
version
|
|
878
1570
|
});
|
|
879
1571
|
}
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
const groupKey = `${f.name}@${v[0]}`;
|
|
885
|
-
const best = bestByNameMajor.get(groupKey);
|
|
886
|
-
if (best && best.id !== id) formats.delete(id);
|
|
887
|
-
}
|
|
1572
|
+
return new Map([...formats].filter(([id, format]) => {
|
|
1573
|
+
const version = prunableVersion(format);
|
|
1574
|
+
return version === null || best.get(`${format.name}@${version.major}`)?.format.id === id;
|
|
1575
|
+
}));
|
|
888
1576
|
}
|
|
889
1577
|
/**
|
|
890
|
-
* Get the
|
|
1578
|
+
* Get the format search directories, lowest rank first: the story format subdirectories of the
|
|
1579
|
+
* home directory, then of the working directory, then each `TWEEGO_PATH` entry, then `extraPaths`
|
|
1580
|
+
* (`formatPaths`). A later directory outranks an earlier one for the same format folder name, so
|
|
1581
|
+
* `formatPaths` override `TWEEGO_PATH`, which overrides the home and working directories, as in
|
|
1582
|
+
* Tweego.
|
|
891
1583
|
*/
|
|
892
1584
|
function getFormatSearchDirs(extraPaths = [], useTweegoPath = true) {
|
|
893
1585
|
const subdirNames = [
|
|
@@ -902,41 +1594,27 @@ function getFormatSearchDirs(extraPaths = [], useTweegoPath = true) {
|
|
|
902
1594
|
basePaths.add(homedir());
|
|
903
1595
|
} catch {}
|
|
904
1596
|
basePaths.add(process.cwd());
|
|
905
|
-
const dirs = [];
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
try {
|
|
909
|
-
if (statSync(dir).isDirectory()) dirs.push(dir);
|
|
910
|
-
} catch {}
|
|
911
|
-
}
|
|
1597
|
+
const dirs = [...basePaths].flatMap((base) => subdirNames.map((sub) => join(base, sub))).filter(isDirectory);
|
|
1598
|
+
const tweegoPath = useTweegoPath ? process.env.TWEEGO_PATH : void 0;
|
|
1599
|
+
if (tweegoPath) dirs.push(...tweegoPath.split(process.platform === "win32" ? ";" : ":"));
|
|
912
1600
|
dirs.push(...extraPaths);
|
|
913
|
-
if (useTweegoPath) {
|
|
914
|
-
const tweegoPath = process.env.TWEEGO_PATH;
|
|
915
|
-
if (tweegoPath) dirs.push(...tweegoPath.split(process.platform === "win32" ? ";" : ":"));
|
|
916
|
-
}
|
|
917
1601
|
return dirs;
|
|
918
1602
|
}
|
|
919
|
-
/**
|
|
1603
|
+
/**
|
|
1604
|
+
* Parse a version string into [major, minor, patch]. Accepts what {@link parseVersion} accepts
|
|
1605
|
+
* (`v1.2.3`, `1.2`, `2.0.0-beta.1`); the prerelease and build metadata are dropped.
|
|
1606
|
+
*/
|
|
920
1607
|
function parseSemver(v) {
|
|
921
|
-
const
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
];
|
|
928
|
-
}
|
|
929
|
-
function semverCompare(a, b) {
|
|
930
|
-
const [aMajor, aMinor, aPatch] = a;
|
|
931
|
-
const [bMajor, bMinor, bPatch] = b;
|
|
932
|
-
if (aMajor !== bMajor) return aMajor - bMajor;
|
|
933
|
-
if (aMinor !== bMinor) return aMinor - bMinor;
|
|
934
|
-
if (aPatch !== bPatch) return aPatch - bPatch;
|
|
935
|
-
return 0;
|
|
1608
|
+
const parsed = parseVersion(v);
|
|
1609
|
+
return parsed ? [
|
|
1610
|
+
parsed.major,
|
|
1611
|
+
parsed.minor,
|
|
1612
|
+
parsed.patch
|
|
1613
|
+
] : null;
|
|
936
1614
|
}
|
|
937
1615
|
/** Build a directory-style format ID from a name and version, e.g. ('SugarCube', '2.37.3') → 'sugarcube-2'. */
|
|
938
1616
|
function makeFormatId(name, version) {
|
|
939
|
-
const major =
|
|
1617
|
+
const major = parseVersion(version)?.major ?? 0;
|
|
940
1618
|
return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
|
|
941
1619
|
}
|
|
942
1620
|
/** Describe a format request for diagnostics. */
|
|
@@ -947,81 +1625,164 @@ function describeFormatRequest(request) {
|
|
|
947
1625
|
default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
|
|
948
1626
|
}
|
|
949
1627
|
}
|
|
950
|
-
/**
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
* - An ID request ('sugarcube-2') matches a candidate whose name and major version build that ID,
|
|
954
|
-
* and takes the greatest such version.
|
|
955
|
-
* - A name request matches the name case-insensitively. An exact version wins outright; otherwise
|
|
956
|
-
* the greatest version with the same major that is not older than the one asked for (or, with
|
|
957
|
-
* `allowOlder`, the greatest same-major version). An unparseable requested version takes the
|
|
958
|
-
* greatest version of any major.
|
|
959
|
-
*/
|
|
960
|
-
function selectFormatCandidate(request, candidates, describe, options = {}) {
|
|
961
|
-
const allowOlder = options.allowOlder ?? false;
|
|
962
|
-
const wanted = request.kind === "name" ? parseSemver(request.version) : null;
|
|
1628
|
+
/** {@link selectFormatCandidate} over one set of candidates, matching names without regard to case. */
|
|
1629
|
+
function pickFormatCandidate(request, candidates, describe, allowOlder) {
|
|
1630
|
+
const wanted = request.kind === "name" ? parseVersion(request.version) : null;
|
|
963
1631
|
let best;
|
|
964
1632
|
for (const candidate of candidates) {
|
|
965
1633
|
const { name, version } = describe(candidate);
|
|
966
|
-
const have =
|
|
1634
|
+
const have = parseVersion(version);
|
|
967
1635
|
if (!have) continue;
|
|
968
1636
|
if (request.kind === "id") {
|
|
969
1637
|
if (makeFormatId(name, version) !== request.id.toLowerCase()) continue;
|
|
970
1638
|
} else {
|
|
971
1639
|
if (name.toLowerCase() !== request.name.toLowerCase()) continue;
|
|
972
1640
|
if (wanted) {
|
|
973
|
-
|
|
974
|
-
if (
|
|
975
|
-
if (
|
|
1641
|
+
const order = compareVersions(have, wanted);
|
|
1642
|
+
if (order === 0) return candidate;
|
|
1643
|
+
if (have.major !== wanted.major) continue;
|
|
1644
|
+
if (!allowOlder && order < 0) continue;
|
|
976
1645
|
}
|
|
977
1646
|
}
|
|
978
|
-
if (!best ||
|
|
1647
|
+
if (!best || compareVersions(have, best.version) > 0) best = {
|
|
979
1648
|
candidate,
|
|
980
1649
|
version: have
|
|
981
1650
|
};
|
|
982
1651
|
}
|
|
983
1652
|
return best?.candidate;
|
|
984
1653
|
}
|
|
985
|
-
/**
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1654
|
+
/**
|
|
1655
|
+
* Pick the candidate that best answers a format request. Local folders, the download cache and
|
|
1656
|
+
* remote indices all select through this one function.
|
|
1657
|
+
*
|
|
1658
|
+
* - An ID request ('sugarcube-2') matches, without regard to case, a candidate whose name and
|
|
1659
|
+
* major version build that ID, and takes the greatest such version.
|
|
1660
|
+
* - A name request matches the name without regard to case, but a candidate whose name matches in
|
|
1661
|
+
* case too is preferred when one answers the request. An exact version (prerelease included)
|
|
1662
|
+
* wins outright; otherwise the greatest version with the same major that is not older than the
|
|
1663
|
+
* one asked for (or, with `allowOlder`, the greatest same-major version). An unparseable
|
|
1664
|
+
* requested version takes the greatest version of any major.
|
|
1665
|
+
*
|
|
1666
|
+
* Versions compare by SemVer precedence ({@link compareVersions}), so a release outranks its
|
|
1667
|
+
* prereleases. Between equal versions, the earlier candidate wins.
|
|
1668
|
+
*/
|
|
1669
|
+
function selectFormatCandidate(request, candidates, describe, options = {}) {
|
|
1670
|
+
const allowOlder = options.allowOlder ?? false;
|
|
1671
|
+
if (request.kind === "name") {
|
|
1672
|
+
const preferred = pickFormatCandidate(request, candidates.filter((c) => describe(c).name === request.name), describe, allowOlder);
|
|
1673
|
+
if (preferred !== void 0) return preferred;
|
|
1674
|
+
}
|
|
1675
|
+
return pickFormatCandidate(request, candidates, describe, allowOlder);
|
|
1676
|
+
}
|
|
1677
|
+
/** The Twine 2 formats of a discovered map, those from higher-ranked search directories first. */
|
|
1678
|
+
function rankedTwine2Formats(formats) {
|
|
1679
|
+
return [...formats.values()].filter((f) => f.isTwine2).reverse();
|
|
1680
|
+
}
|
|
1681
|
+
/**
|
|
1682
|
+
* Look a format ID up among discovered folders. The ID matches a folder name without regard to
|
|
1683
|
+
* case; an exact-case folder is preferred, then the highest-ranked one.
|
|
1684
|
+
*/
|
|
1685
|
+
function findFormatById(formats, id) {
|
|
1686
|
+
const exact = formats.get(id);
|
|
1687
|
+
if (exact) return exact;
|
|
1688
|
+
const wanted = id.toLowerCase();
|
|
1689
|
+
return [...formats.values()].reverse().find((f) => f.id.toLowerCase() === wanted);
|
|
1690
|
+
}
|
|
1691
|
+
/**
|
|
1692
|
+
* Read the story format source (for Twine 2, extract the `source` property from JSON).
|
|
1693
|
+
* `diagnostics` receives a warning when the format file is not valid UTF-8 (it is read as Windows-1252).
|
|
1694
|
+
*/
|
|
1695
|
+
function readFormatSource(format, diagnostics) {
|
|
1696
|
+
const source = readUTF8(format.filename, diagnostics);
|
|
1697
|
+
if (!format.isTwine2) return source;
|
|
1698
|
+
const decoded = decodeFormatJSON(source);
|
|
1699
|
+
if (!decoded.ok) throw new Error(`Cannot parse format ${format.id} JSON: ${decoded.reason}`);
|
|
1700
|
+
return decoded.data.source;
|
|
1701
|
+
}
|
|
1702
|
+
//#endregion
|
|
1703
|
+
//#region src/atomic-write.ts
|
|
1704
|
+
/**
|
|
1705
|
+
* Atomic file replacement, for every file twee-ts writes that something else may read
|
|
1706
|
+
* meanwhile: build output (which live-reload servers read on each change) and downloaded
|
|
1707
|
+
* story formats (which other twee-ts processes share).
|
|
1708
|
+
*/
|
|
1709
|
+
/**
|
|
1710
|
+
* Writes `data` to `path` so that a reader sees either the previous file or the complete new
|
|
1711
|
+
* one, never a partly written file. The data goes to a temporary file in the same folder,
|
|
1712
|
+
* which is then renamed over `path`. When `path` is a symlink, the file it points to is
|
|
1713
|
+
* replaced, and an existing file keeps its permissions.
|
|
1714
|
+
*
|
|
1715
|
+
* On failure (a full disk, a missing folder) the temporary file is removed, `path` is left
|
|
1716
|
+
* as it was, and the error is thrown with `path` in its message and its original `code`.
|
|
1717
|
+
*/
|
|
1718
|
+
function writeFileAtomic(path, data) {
|
|
1719
|
+
const target = followSymlink(path);
|
|
1720
|
+
const temp = join(dirname(target), `.${basename(target)}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
|
|
1721
|
+
try {
|
|
1722
|
+
writeFileSync(temp, data, {
|
|
1723
|
+
encoding: "utf-8",
|
|
1724
|
+
flag: "wx"
|
|
1725
|
+
});
|
|
1726
|
+
keepPermissions(target, temp);
|
|
1727
|
+
renameSync(temp, target);
|
|
1728
|
+
} catch (e) {
|
|
1729
|
+
rmSync(temp, { force: true });
|
|
1730
|
+
throw writeError(path, e);
|
|
1000
1731
|
}
|
|
1001
|
-
return bestId;
|
|
1002
1732
|
}
|
|
1003
|
-
/**
|
|
1004
|
-
function
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
return data.source;
|
|
1733
|
+
/** The file a symlink at `path` points to, or `path` itself (also when it doesn't exist yet). */
|
|
1734
|
+
function followSymlink(path) {
|
|
1735
|
+
try {
|
|
1736
|
+
return realpathSync(path);
|
|
1737
|
+
} catch {
|
|
1738
|
+
return path;
|
|
1010
1739
|
}
|
|
1011
|
-
|
|
1740
|
+
}
|
|
1741
|
+
/**
|
|
1742
|
+
* Gives `temp` the permission bits of `target`, when `target` exists. Best effort: on a file
|
|
1743
|
+
* system without Unix permissions chmod can fail, and writing in place never kept them there either.
|
|
1744
|
+
*/
|
|
1745
|
+
function keepPermissions(target, temp) {
|
|
1746
|
+
const existing = statSync(target, { throwIfNoEntry: false });
|
|
1747
|
+
if (existing === void 0) return;
|
|
1748
|
+
try {
|
|
1749
|
+
chmodSync(temp, existing.mode & 4095);
|
|
1750
|
+
} catch {}
|
|
1751
|
+
}
|
|
1752
|
+
/** `cause` as an error that names `path` and keeps the original error code. */
|
|
1753
|
+
function writeError(path, cause) {
|
|
1754
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
1755
|
+
const error = new Error(`Cannot write ${path}: ${message}`, { cause });
|
|
1756
|
+
if (cause instanceof Error && "code" in cause) error.code = cause.code;
|
|
1757
|
+
return error;
|
|
1012
1758
|
}
|
|
1013
1759
|
//#endregion
|
|
1014
1760
|
//#region src/remote-formats.ts
|
|
1015
1761
|
/**
|
|
1016
1762
|
* Remote story format fetching, caching, and checksum verification.
|
|
1017
1763
|
* Uses the Story Formats Archive (SFA) as the default source.
|
|
1764
|
+
*
|
|
1765
|
+
* Two caches hold downloads. Formats found through an index are shared by name and version
|
|
1766
|
+
* (`<cache>/<name>/<version>/format.js`). A format downloaded from a direct URL is kept under
|
|
1767
|
+
* that URL instead, so one project's copy never answers another project's request.
|
|
1768
|
+
* Cache files are replaced atomically, so another process never reads a partly written one.
|
|
1018
1769
|
*/
|
|
1019
1770
|
const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
|
|
1771
|
+
/** The longest delay a timer accepts; a longer timeout means no limit. */
|
|
1772
|
+
const MAX_TIMER_DELAY = 2147483647;
|
|
1020
1773
|
/** Get the cache directory for downloaded story formats. */
|
|
1021
1774
|
function getCacheDir() {
|
|
1022
1775
|
const base = process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache");
|
|
1023
1776
|
return join(base, "twee-ts", "storyformats");
|
|
1024
1777
|
}
|
|
1778
|
+
/** The cache directory for formats downloaded from direct URLs, beside {@link getCacheDir}. */
|
|
1779
|
+
function getUrlCacheDir() {
|
|
1780
|
+
return join(dirname(getCacheDir()), "storyformat-urls");
|
|
1781
|
+
}
|
|
1782
|
+
/** The name of one direct URL's cache entry: a hash of the URL, so each URL has its own. */
|
|
1783
|
+
function urlCacheKey(url) {
|
|
1784
|
+
return createHash("sha256").update(url).digest("hex");
|
|
1785
|
+
}
|
|
1025
1786
|
/**
|
|
1026
1787
|
* Whether a string can serve as one directory name inside the cache: not empty, not `.` or `..`,
|
|
1027
1788
|
* and free of path separators. Format names and versions come from downloaded metadata, so they
|
|
@@ -1044,25 +1805,38 @@ function cachedFormatDir(name, version) {
|
|
|
1044
1805
|
if (!isInside$1(root, dir)) throw new Error(`Refusing to cache a story format outside the cache directory ${root}: ${dir}`);
|
|
1045
1806
|
return dir;
|
|
1046
1807
|
}
|
|
1047
|
-
/**
|
|
1808
|
+
/** Whether `e` is a Node.js system error with the given code. */
|
|
1809
|
+
function hasErrorCode(e, code) {
|
|
1810
|
+
return e instanceof Error && "code" in e && e.code === code;
|
|
1811
|
+
}
|
|
1812
|
+
/**
|
|
1813
|
+
* Create `dir` as a plain directory, or check that it already is one (a symlink could lead out of the cache).
|
|
1814
|
+
* A directory another process creates between the check and the mkdir is fine.
|
|
1815
|
+
*/
|
|
1048
1816
|
function ensurePlainDirectory(dir) {
|
|
1049
|
-
|
|
1050
|
-
if (stat === void 0) {
|
|
1817
|
+
if (lstatSync(dir, { throwIfNoEntry: false }) === void 0) try {
|
|
1051
1818
|
mkdirSync(dir);
|
|
1052
1819
|
return;
|
|
1820
|
+
} catch (e) {
|
|
1821
|
+
if (!hasErrorCode(e, "EEXIST")) throw e;
|
|
1053
1822
|
}
|
|
1054
|
-
|
|
1823
|
+
const stat = lstatSync(dir, { throwIfNoEntry: false });
|
|
1824
|
+
if (stat === void 0 || stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`Refusing to write a story format outside the cache directory: ${dir} is not a plain directory`);
|
|
1055
1825
|
}
|
|
1056
|
-
/**
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1826
|
+
/**
|
|
1827
|
+
* Write a downloaded format.js to `<root>/<...segments>/format.js`, replacing any earlier copy
|
|
1828
|
+
* atomically, and return its path. Every directory below `root` must be a plain one.
|
|
1829
|
+
*/
|
|
1830
|
+
function writeCacheEntry(root, segments, text) {
|
|
1060
1831
|
mkdirSync(root, { recursive: true });
|
|
1061
|
-
|
|
1062
|
-
|
|
1832
|
+
const dir = segments.reduce((parent, segment) => {
|
|
1833
|
+
const child = join(parent, segment);
|
|
1834
|
+
ensurePlainDirectory(child);
|
|
1835
|
+
return child;
|
|
1836
|
+
}, root);
|
|
1063
1837
|
const formatPath = join(dir, "format.js");
|
|
1064
1838
|
if (lstatSync(formatPath, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error(`Refusing to write a story format outside the cache directory: ${formatPath} is a symlink`);
|
|
1065
|
-
|
|
1839
|
+
writeFileAtomic(formatPath, text);
|
|
1066
1840
|
return formatPath;
|
|
1067
1841
|
}
|
|
1068
1842
|
/** In-memory index cache, keyed by URL. Cleared each compile. */
|
|
@@ -1071,6 +1845,96 @@ const indexCache = /* @__PURE__ */ new Map();
|
|
|
1071
1845
|
function clearIndexCache() {
|
|
1072
1846
|
indexCache.clear();
|
|
1073
1847
|
}
|
|
1848
|
+
/** Requests in progress, by what they fetch, so concurrent compiles make each request once. */
|
|
1849
|
+
const sharedRequests = /* @__PURE__ */ new Map();
|
|
1850
|
+
/**
|
|
1851
|
+
* Runs `start` once for all callers that ask for `key` while it is in progress. Each caller waits
|
|
1852
|
+
* under its own limits: a caller whose `signal` aborts, or whose `timeout` passes, stops waiting
|
|
1853
|
+
* at once and rejects, while the others keep waiting. The request itself is aborted (through
|
|
1854
|
+
* the signal `start` receives) once no caller waits on it. `start` must not depend on any one
|
|
1855
|
+
* caller: what differs between callers belongs after the shared result.
|
|
1856
|
+
*/
|
|
1857
|
+
function shareRequest(key, { signal, timeout, timedOut }, start) {
|
|
1858
|
+
if (signal?.aborted) return Promise.reject(signal.reason);
|
|
1859
|
+
const request = sharedRequests.get(key) ?? startSharedRequest(key, start);
|
|
1860
|
+
request.waiters++;
|
|
1861
|
+
return new Promise((resolve, reject) => {
|
|
1862
|
+
let waiting = true;
|
|
1863
|
+
let timer;
|
|
1864
|
+
const stopWaiting = () => {
|
|
1865
|
+
if (!waiting) return;
|
|
1866
|
+
waiting = false;
|
|
1867
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1868
|
+
clearTimeout(timer);
|
|
1869
|
+
request.waiters--;
|
|
1870
|
+
};
|
|
1871
|
+
const giveUp = (reason) => {
|
|
1872
|
+
stopWaiting();
|
|
1873
|
+
if (request.waiters === 0) {
|
|
1874
|
+
if (sharedRequests.get(key) === request) sharedRequests.delete(key);
|
|
1875
|
+
request.controller.abort(reason);
|
|
1876
|
+
}
|
|
1877
|
+
reject(reason);
|
|
1878
|
+
};
|
|
1879
|
+
const onAbort = () => giveUp(signal?.reason);
|
|
1880
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1881
|
+
if (timeout > 0 && timeout <= MAX_TIMER_DELAY) timer = setTimeout(() => giveUp(timedOut()), timeout);
|
|
1882
|
+
request.promise.then((value) => {
|
|
1883
|
+
stopWaiting();
|
|
1884
|
+
resolve(value);
|
|
1885
|
+
}, (e) => {
|
|
1886
|
+
stopWaiting();
|
|
1887
|
+
reject(e);
|
|
1888
|
+
});
|
|
1889
|
+
});
|
|
1890
|
+
}
|
|
1891
|
+
function startSharedRequest(key, start) {
|
|
1892
|
+
const controller = new AbortController();
|
|
1893
|
+
const request = {
|
|
1894
|
+
promise: start(controller.signal),
|
|
1895
|
+
controller,
|
|
1896
|
+
waiters: 0
|
|
1897
|
+
};
|
|
1898
|
+
sharedRequests.set(key, request);
|
|
1899
|
+
const forget = () => {
|
|
1900
|
+
if (sharedRequests.get(key) === request) sharedRequests.delete(key);
|
|
1901
|
+
};
|
|
1902
|
+
request.promise.then(forget, forget);
|
|
1903
|
+
return request;
|
|
1904
|
+
}
|
|
1905
|
+
/** The time limit for each request, from the caller's options. */
|
|
1906
|
+
function requestTimeout(options) {
|
|
1907
|
+
const timeout = options.timeout ?? 3e4;
|
|
1908
|
+
if (!(timeout >= 0)) throw new RangeError(`A story format request timeout must be 0 or more milliseconds, not ${timeout}`);
|
|
1909
|
+
return timeout;
|
|
1910
|
+
}
|
|
1911
|
+
/** How one caller waits for `url`: under its own signal and timeout. `what` names the request in errors. */
|
|
1912
|
+
function waitOptions(options, what, url) {
|
|
1913
|
+
const timeout = requestTimeout(options);
|
|
1914
|
+
return {
|
|
1915
|
+
signal: options.signal,
|
|
1916
|
+
timeout,
|
|
1917
|
+
timedOut: () => /* @__PURE__ */ new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`)
|
|
1918
|
+
};
|
|
1919
|
+
}
|
|
1920
|
+
/**
|
|
1921
|
+
* Fetch `url` as bytes, aborting when `signal` does. `what` names the request in errors, as in
|
|
1922
|
+
* "Failed to <what> from <url>". The bytes are returned undecoded, so checksums cover what was served.
|
|
1923
|
+
*/
|
|
1924
|
+
async function fetchBytes(url, what, signal) {
|
|
1925
|
+
const res = await fetch(url, { signal });
|
|
1926
|
+
if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
|
|
1927
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
1928
|
+
}
|
|
1929
|
+
/** Download a format.js once for every caller in this process that asks for `url` meanwhile. */
|
|
1930
|
+
function shareDownload(url, options) {
|
|
1931
|
+
return shareRequest(`download\0${url}`, waitOptions(options, "download format", url), (signal) => fetchBytes(url, "download format", signal));
|
|
1932
|
+
}
|
|
1933
|
+
/** Decode a downloaded format.js as local ones are (UTF-8, else Windows-1252), without a leading BOM. */
|
|
1934
|
+
function decodeDownload(bytes, url) {
|
|
1935
|
+
const { text } = decodeText(bytes, url);
|
|
1936
|
+
return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
|
|
1937
|
+
}
|
|
1074
1938
|
/** Validate that a JSON value is a valid SFAIndexEntry. */
|
|
1075
1939
|
function isValidEntry(val) {
|
|
1076
1940
|
if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
|
|
@@ -1086,15 +1950,17 @@ function validateSFAIndex(json) {
|
|
|
1086
1950
|
twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
|
|
1087
1951
|
};
|
|
1088
1952
|
}
|
|
1089
|
-
/** Fetch and parse an SFA index.json, with in-memory caching. */
|
|
1090
|
-
async function fetchIndex(url) {
|
|
1953
|
+
/** Fetch and parse an SFA index.json, with in-memory caching. Concurrent calls for one URL share a request. */
|
|
1954
|
+
async function fetchIndex(url, options = {}) {
|
|
1955
|
+
options.signal?.throwIfAborted();
|
|
1091
1956
|
const cached = indexCache.get(url);
|
|
1092
1957
|
if (cached) return cached;
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1958
|
+
return shareRequest(`index\0${url}`, waitOptions(options, "fetch format index", url), async (signal) => {
|
|
1959
|
+
const text = decodeDownload(await fetchBytes(url, "fetch format index", signal), url);
|
|
1960
|
+
const data = validateSFAIndex(JSON.parse(text));
|
|
1961
|
+
indexCache.set(url, data);
|
|
1962
|
+
return data;
|
|
1963
|
+
});
|
|
1098
1964
|
}
|
|
1099
1965
|
/** Find the best matching entry in an SFA index for a name or ID request (twine2 entries first). */
|
|
1100
1966
|
function findEntryForRequest(index, request) {
|
|
@@ -1115,84 +1981,146 @@ async function verifySHA256(content, expectedHex) {
|
|
|
1115
1981
|
function getDownloadUrl(indexUrl, entry, formatType) {
|
|
1116
1982
|
return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
|
|
1117
1983
|
}
|
|
1118
|
-
/**
|
|
1119
|
-
|
|
1984
|
+
/** The StoryFormatInfo of a Twine 2 format.js cached at `filename`. */
|
|
1985
|
+
function cachedFormatInfo(id, filename, data) {
|
|
1986
|
+
return {
|
|
1987
|
+
id,
|
|
1988
|
+
filename,
|
|
1989
|
+
isTwine2: true,
|
|
1990
|
+
name: data.name,
|
|
1991
|
+
version: data.version,
|
|
1992
|
+
proofing: data.proofing ?? false
|
|
1993
|
+
};
|
|
1994
|
+
}
|
|
1995
|
+
/**
|
|
1996
|
+
* Check that a downloaded format is the one the index entry promised. The name matches without
|
|
1997
|
+
* regard to case, as format requests do, and the version by SemVer precedence, so "1.0" and "v1.0.0"
|
|
1998
|
+
* agree. A format.js that names no format ({@link UNNAMED_FORMAT_NAME}) is allowed: such formats
|
|
1999
|
+
* exist, and the index entry then supplies the name it is cached under.
|
|
2000
|
+
*/
|
|
2001
|
+
function checkFormatIdentity(entry, data, downloadUrl) {
|
|
2002
|
+
if ((data.name === "Untitled Story Format" || data.name.toLowerCase() === entry.name.toLowerCase()) && sameVersion(data.version, entry.version)) return;
|
|
2003
|
+
throw new Error(`Story format mismatch for ${downloadUrl}: the index lists ${entry.name} ${entry.version}, but the download is ${data.name} ${data.version}`);
|
|
2004
|
+
}
|
|
2005
|
+
/**
|
|
2006
|
+
* Download a format, verify its checksum, check that it is the format the entry names, write it to
|
|
2007
|
+
* the cache shared by name and version, and return its StoryFormatInfo. Concurrent calls for one
|
|
2008
|
+
* download URL share one request for the bytes; each call then verifies them against its own
|
|
2009
|
+
* entry, within its own timeout.
|
|
2010
|
+
*/
|
|
2011
|
+
async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
|
|
1120
2012
|
cachedFormatDir(entry.name, entry.version);
|
|
1121
|
-
const
|
|
1122
|
-
if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
|
|
1123
|
-
const text = await res.text();
|
|
2013
|
+
const bytes = await shareDownload(downloadUrl, options);
|
|
1124
2014
|
const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
|
|
1125
2015
|
if (checksumKey) {
|
|
1126
2016
|
const expected = entry.checksums[checksumKey];
|
|
1127
2017
|
if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
|
|
1128
|
-
if (!await verifySHA256(
|
|
2018
|
+
if (!await verifySHA256(bytes, expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
|
|
1129
2019
|
}
|
|
2020
|
+
const text = decodeDownload(bytes, downloadUrl);
|
|
1130
2021
|
const id = makeFormatId(entry.name, entry.version);
|
|
1131
2022
|
const data = parseFormatJSON(text, id);
|
|
1132
2023
|
if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
isTwine2: true,
|
|
1137
|
-
name: data.name,
|
|
1138
|
-
version: data.version,
|
|
1139
|
-
proofing: data.proofing ?? false
|
|
1140
|
-
};
|
|
2024
|
+
checkFormatIdentity(entry, data, downloadUrl);
|
|
2025
|
+
options.signal?.throwIfAborted();
|
|
2026
|
+
return cachedFormatInfo(id, writeCacheEntry(resolve(getCacheDir()), [entry.name, entry.version], text), data);
|
|
1141
2027
|
}
|
|
1142
|
-
/**
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
2028
|
+
/**
|
|
2029
|
+
* Download a direct format.js URL, parse its JSON, cache it under that URL (apart from the
|
|
2030
|
+
* downloads shared by name and version), and return its StoryFormatInfo. Concurrent calls for
|
|
2031
|
+
* one URL share one request.
|
|
2032
|
+
*/
|
|
2033
|
+
async function fetchDirectFormat(url, options = {}) {
|
|
2034
|
+
const text = decodeDownload(await shareDownload(url, options), url);
|
|
1147
2035
|
const data = parseFormatJSON(text);
|
|
1148
2036
|
if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
2037
|
+
cachedFormatDir(data.name, data.version);
|
|
2038
|
+
options.signal?.throwIfAborted();
|
|
2039
|
+
const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
|
|
2040
|
+
return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
|
|
2041
|
+
}
|
|
2042
|
+
/** The copy of a direct format URL in the download cache, if it has been downloaded before. */
|
|
2043
|
+
function getCachedDirectFormat(url) {
|
|
2044
|
+
const formatPath = join(getUrlCacheDir(), urlCacheKey(url), "format.js");
|
|
2045
|
+
try {
|
|
2046
|
+
if (!existsSync(formatPath)) return void 0;
|
|
2047
|
+
const data = parseFormatJSON(readUTF8(formatPath));
|
|
2048
|
+
if (!data) return void 0;
|
|
2049
|
+
return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
|
|
2050
|
+
} catch {
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
/**
|
|
2055
|
+
* Look a request up among direct format URLs, in order, and return the first format that
|
|
2056
|
+
* answers it. A URL downloaded before is answered by its cached copy without the network;
|
|
2057
|
+
* any other is downloaded (unless `offline`). When none answers and a download failed, the
|
|
2058
|
+
* last failure is thrown. An aborted signal rejects with its reason and tries no further URL.
|
|
2059
|
+
*
|
|
2060
|
+
* Internal, for format resolution; not part of the public API.
|
|
2061
|
+
*/
|
|
2062
|
+
async function resolveFormatUrls(request, urls, options = {}) {
|
|
2063
|
+
let lastError;
|
|
2064
|
+
for (const url of urls) {
|
|
2065
|
+
const cached = getCachedDirectFormat(url);
|
|
2066
|
+
if (cached) {
|
|
2067
|
+
if (answers(request, cached)) return cached;
|
|
2068
|
+
continue;
|
|
2069
|
+
}
|
|
2070
|
+
if (options.offline) continue;
|
|
2071
|
+
try {
|
|
2072
|
+
const info = await fetchDirectFormat(url, options);
|
|
2073
|
+
if (answers(request, info)) return info;
|
|
2074
|
+
} catch (e) {
|
|
2075
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
2076
|
+
lastError = toError(e);
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
if (lastError) throw lastError;
|
|
2080
|
+
}
|
|
2081
|
+
/** Whether `info` is a format the request accepts. */
|
|
2082
|
+
function answers(request, info) {
|
|
2083
|
+
return selectFormatCandidate(request, [info], (f) => f) !== void 0;
|
|
2084
|
+
}
|
|
2085
|
+
function toError(e) {
|
|
2086
|
+
return e instanceof Error ? e : new Error(String(e));
|
|
1157
2087
|
}
|
|
1158
2088
|
/**
|
|
1159
2089
|
* Resolve a story format request remotely, in the same order as {@link resolveRemoteFormat}.
|
|
1160
2090
|
* An ID request such as 'sugarcube-2' matches the format whose name and major version build that ID
|
|
1161
2091
|
* (SugarCube 2.x), taking the greatest version available.
|
|
1162
2092
|
*/
|
|
1163
|
-
async function resolveRemoteFormatRequest(request, indices, urls) {
|
|
2093
|
+
async function resolveRemoteFormatRequest(request, indices, urls, options = {}) {
|
|
2094
|
+
options.signal?.throwIfAborted();
|
|
1164
2095
|
let lastError;
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
const info = await fetchDirectFormat(url);
|
|
1169
|
-
if (selectFormatCandidate(request, [info], (f) => f)) return info;
|
|
2096
|
+
try {
|
|
2097
|
+
const direct = await resolveFormatUrls(request, urls ?? [], options);
|
|
2098
|
+
if (direct) return direct;
|
|
1170
2099
|
} catch (e) {
|
|
1171
|
-
|
|
2100
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
2101
|
+
lastError = toError(e);
|
|
1172
2102
|
}
|
|
2103
|
+
const cached = findCachedFormat(request);
|
|
2104
|
+
if (cached && request.kind === "name" && sameVersion(cached.version, request.version)) return cached;
|
|
1173
2105
|
const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
|
|
1174
2106
|
for (const indexUrl of allIndices) try {
|
|
1175
|
-
const result = findEntryForRequest(await fetchIndex(indexUrl), request);
|
|
2107
|
+
const result = findEntryForRequest(await fetchIndex(indexUrl, options), request);
|
|
1176
2108
|
if (result) {
|
|
1177
2109
|
const hit = getCachedFormat(result.entry.name, result.entry.version);
|
|
1178
2110
|
if (hit) return hit;
|
|
1179
2111
|
const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
|
|
1180
|
-
return await fetchAndCacheFormat(result.entry, downloadUrl);
|
|
2112
|
+
return await fetchAndCacheFormat(result.entry, downloadUrl, options);
|
|
1181
2113
|
}
|
|
1182
2114
|
} catch (e) {
|
|
1183
|
-
|
|
2115
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
2116
|
+
lastError = toError(e);
|
|
1184
2117
|
}
|
|
1185
2118
|
if (cached) return cached;
|
|
1186
2119
|
if (lastError) throw lastError;
|
|
1187
2120
|
}
|
|
1188
|
-
/** Whether two version strings name the same SemVer version. */
|
|
1189
|
-
function isExactVersion(have, wanted) {
|
|
1190
|
-
const a = parseSemver(have);
|
|
1191
|
-
const b = parseSemver(wanted);
|
|
1192
|
-
return a !== null && b !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
|
|
1193
|
-
}
|
|
1194
2121
|
/**
|
|
1195
2122
|
* Find the cached download that best answers a format request, without network access.
|
|
2123
|
+
* Only the downloads shared by name and version are searched, not those from direct URLs.
|
|
1196
2124
|
* Matching follows {@link selectFormatCandidate}.
|
|
1197
2125
|
*/
|
|
1198
2126
|
function findCachedFormat(request, options = {}) {
|
|
@@ -1204,18 +2132,11 @@ function getCachedFormat(name, version) {
|
|
|
1204
2132
|
const formatPath = join(getCacheDir(), name, version, "format.js");
|
|
1205
2133
|
try {
|
|
1206
2134
|
if (!existsSync(formatPath)) return void 0;
|
|
1207
|
-
const source =
|
|
2135
|
+
const source = readUTF8(formatPath);
|
|
1208
2136
|
const id = makeFormatId(name, version);
|
|
1209
2137
|
const data = parseFormatJSON(source, id);
|
|
1210
2138
|
if (!data) return void 0;
|
|
1211
|
-
return
|
|
1212
|
-
id,
|
|
1213
|
-
filename: formatPath,
|
|
1214
|
-
isTwine2: true,
|
|
1215
|
-
name: data.name,
|
|
1216
|
-
version: data.version,
|
|
1217
|
-
proofing: data.proofing ?? false
|
|
1218
|
-
};
|
|
2139
|
+
return cachedFormatInfo(id, formatPath, data);
|
|
1219
2140
|
} catch {
|
|
1220
2141
|
return;
|
|
1221
2142
|
}
|
|
@@ -1273,44 +2194,65 @@ function formatRequestFor(formatId, storyFormat, storyFormatVersion, defaultId)
|
|
|
1273
2194
|
id: defaultId
|
|
1274
2195
|
};
|
|
1275
2196
|
}
|
|
1276
|
-
/**
|
|
1277
|
-
|
|
2197
|
+
/**
|
|
2198
|
+
* Look a request up among the formats found in local format directories. An ID names a folder, so
|
|
2199
|
+
* it is looked up among all of them; a name request selects by SemVer among the pruned formats.
|
|
2200
|
+
*/
|
|
2201
|
+
function findLocalFormat(all, pruned, request) {
|
|
1278
2202
|
switch (request.kind) {
|
|
1279
|
-
case "id": return
|
|
1280
|
-
case "name":
|
|
1281
|
-
const id = getFormatIdByNameAndVersion(formats, request.name, request.version);
|
|
1282
|
-
return id === void 0 ? void 0 : formats.get(id);
|
|
1283
|
-
}
|
|
2203
|
+
case "id": return findFormatById(all, request.id);
|
|
2204
|
+
case "name": return selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f);
|
|
1284
2205
|
default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
|
|
1285
2206
|
}
|
|
1286
2207
|
}
|
|
1287
2208
|
/**
|
|
1288
2209
|
* Resolve a format request to a story format.
|
|
1289
2210
|
*
|
|
1290
|
-
* Order: local format directories, then the
|
|
1291
|
-
*
|
|
2211
|
+
* Order: local format directories, then the project's direct format URLs (each one's cached
|
|
2212
|
+
* copy, else a download unless `noRemote`), then the download cache shared by name and version,
|
|
2213
|
+
* then format indices (unless `noRemote`). Names and IDs match the same way in each (see
|
|
2214
|
+
* `selectFormatCandidate`), so a request that a local format answers never reaches the cache or the
|
|
2215
|
+
* network. Local formats that cannot be used are reported as warnings. When a StoryData request
|
|
2216
|
+
* names a version that none of them has, an older version of
|
|
1292
2217
|
* the same format and major version is used with a warning. Anything else is an error diagnostic,
|
|
1293
2218
|
* and the result is `undefined`.
|
|
1294
2219
|
*/
|
|
1295
2220
|
async function resolveStoryFormat(request, options, diagnostics) {
|
|
1296
2221
|
const noRemote = options.noRemote ?? false;
|
|
1297
|
-
const
|
|
2222
|
+
const all = discoverAllFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true), diagnostics);
|
|
2223
|
+
const pruned = pruneFormats(all);
|
|
1298
2224
|
const wanted = describeFormatRequest(request);
|
|
1299
|
-
const local = findLocalFormat(
|
|
2225
|
+
const local = findLocalFormat(all, pruned, request);
|
|
1300
2226
|
if (local) return local;
|
|
2227
|
+
const fetchOptions = {
|
|
2228
|
+
signal: options.signal,
|
|
2229
|
+
timeout: options.formatFetchTimeout
|
|
2230
|
+
};
|
|
2231
|
+
const warnOnFailure = async (lookup) => {
|
|
2232
|
+
try {
|
|
2233
|
+
return await lookup();
|
|
2234
|
+
} catch (e) {
|
|
2235
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
2236
|
+
diagnostics.push({
|
|
2237
|
+
level: "warning",
|
|
2238
|
+
message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
|
|
2239
|
+
});
|
|
2240
|
+
return;
|
|
2241
|
+
}
|
|
2242
|
+
};
|
|
2243
|
+
const direct = await warnOnFailure(() => resolveFormatUrls(request, options.formatUrls ?? [], {
|
|
2244
|
+
...fetchOptions,
|
|
2245
|
+
offline: noRemote
|
|
2246
|
+
}));
|
|
2247
|
+
if (direct) return direct;
|
|
1301
2248
|
const cached = findCachedFormat(request);
|
|
1302
2249
|
if (cached) return cached;
|
|
1303
|
-
if (!noRemote)
|
|
1304
|
-
const remote = await resolveRemoteFormatRequest(request, options.formatIndices,
|
|
2250
|
+
if (!noRemote) {
|
|
2251
|
+
const remote = await warnOnFailure(() => resolveRemoteFormatRequest(request, options.formatIndices, [], fetchOptions));
|
|
1305
2252
|
if (remote) return remote;
|
|
1306
|
-
} catch (e) {
|
|
1307
|
-
diagnostics.push({
|
|
1308
|
-
level: "warning",
|
|
1309
|
-
message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
|
|
1310
|
-
});
|
|
1311
2253
|
}
|
|
1312
2254
|
if (request.kind === "name") {
|
|
1313
|
-
const older = selectFormatCandidate(request,
|
|
2255
|
+
const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
|
|
1314
2256
|
if (older) {
|
|
1315
2257
|
diagnostics.push({
|
|
1316
2258
|
level: "warning",
|
|
@@ -1322,7 +2264,7 @@ async function resolveStoryFormat(request, options, diagnostics) {
|
|
|
1322
2264
|
const reason = noRemote ? " (remote fetching disabled)" : "";
|
|
1323
2265
|
diagnostics.push({
|
|
1324
2266
|
level: "error",
|
|
1325
|
-
message: `Story format ${wanted} is not available${reason}. Found: ${[...
|
|
2267
|
+
message: `Story format ${wanted} is not available${reason}. Found: ${[...all.keys()].join(", ") || "none"}`
|
|
1326
2268
|
});
|
|
1327
2269
|
}
|
|
1328
2270
|
//#endregion
|
|
@@ -1559,8 +2501,6 @@ function* tweeLexer(input) {
|
|
|
1559
2501
|
const items = ctx.drainItems();
|
|
1560
2502
|
for (const item of items) yield item;
|
|
1561
2503
|
}
|
|
1562
|
-
const remaining = ctx.drainItems();
|
|
1563
|
-
for (const item of remaining) yield item;
|
|
1564
2504
|
}
|
|
1565
2505
|
//#endregion
|
|
1566
2506
|
//#region src/twee2-compat.ts
|
|
@@ -1591,12 +2531,14 @@ function twee2ToV3(s) {
|
|
|
1591
2531
|
* Parse Twee source text into passages.
|
|
1592
2532
|
*
|
|
1593
2533
|
* The source is normalized first, as files are when they are read: a leading UTF-8 BOM is
|
|
1594
|
-
* removed and CRLF and bare CR line endings become LF.
|
|
2534
|
+
* removed and CRLF and bare CR line endings become LF. A BOM at the start of a later line,
|
|
2535
|
+
* directly before `::` (left there by concatenating files), is removed too, so that line
|
|
2536
|
+
* stays a passage header.
|
|
1595
2537
|
*/
|
|
1596
2538
|
function parseTwee(source, options = {}) {
|
|
1597
2539
|
const { filename = "<inline>", trim = true, twee2Compat = false } = options;
|
|
1598
2540
|
const diagnostics = [];
|
|
1599
|
-
const normalized =
|
|
2541
|
+
const normalized = normalizeTweeSourceText(source);
|
|
1600
2542
|
const tweeSource = twee2Compat ? twee2ToV3(normalized) : normalized;
|
|
1601
2543
|
const passages = [];
|
|
1602
2544
|
let current = null;
|
|
@@ -1685,9 +2627,7 @@ function parseTwee(source, options = {}) {
|
|
|
1685
2627
|
};
|
|
1686
2628
|
}
|
|
1687
2629
|
try {
|
|
1688
|
-
const
|
|
1689
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error("expected a JSON object");
|
|
1690
|
-
const parsed = raw;
|
|
2630
|
+
const parsed = JSON.parse(item.val);
|
|
1691
2631
|
const meta = {};
|
|
1692
2632
|
for (const [key, value] of Object.entries(parsed)) if (typeof value === "string") meta[key] = value;
|
|
1693
2633
|
current.metadata = meta;
|
|
@@ -4431,17 +5371,21 @@ function parseDocument(data, options) {
|
|
|
4431
5371
|
* Ported from storyload.go:loadHTML().
|
|
4432
5372
|
*/
|
|
4433
5373
|
/**
|
|
4434
|
-
*
|
|
4435
|
-
*
|
|
5374
|
+
* `decompileHTML()` for loading an HTML file into a compile. The importer's StoryData passage is
|
|
5375
|
+
* checked when it is added to the story being compiled, which reports a missing or invalid IFID,
|
|
5376
|
+
* so the IFID is not reported here as well.
|
|
4436
5377
|
*/
|
|
4437
|
-
function
|
|
5378
|
+
function decompileHTMLForImport(html, options = {}) {
|
|
5379
|
+
return decompile(html, options, { ifid: false });
|
|
5380
|
+
}
|
|
5381
|
+
function decompile(html, options, checks) {
|
|
4438
5382
|
const diagnostics = [];
|
|
4439
5383
|
const story = createStory();
|
|
4440
5384
|
const passageText = options.trim === false ? (text) => text : (text) => text.trim();
|
|
4441
5385
|
const doc = parseDocument(html);
|
|
4442
5386
|
const twine2Data = findElement(doc, "tw-storydata");
|
|
4443
5387
|
if (twine2Data) {
|
|
4444
|
-
decompileTwine2(twine2Data, story, passageText, diagnostics);
|
|
5388
|
+
decompileTwine2(twine2Data, story, passageText, checks, diagnostics);
|
|
4445
5389
|
return {
|
|
4446
5390
|
story,
|
|
4447
5391
|
diagnostics
|
|
@@ -4464,7 +5408,7 @@ function decompileHTML(html, options = {}) {
|
|
|
4464
5408
|
diagnostics
|
|
4465
5409
|
};
|
|
4466
5410
|
}
|
|
4467
|
-
function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
5411
|
+
function decompileTwine2(storyData, story, passageText, checks, diagnostics) {
|
|
4468
5412
|
let startnode = 0;
|
|
4469
5413
|
const attrs = storyData.attribs ?? {};
|
|
4470
5414
|
if (attrs["name"]) story.name = attrs["name"];
|
|
@@ -4477,6 +5421,7 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4477
5421
|
else startnode = parsed;
|
|
4478
5422
|
}
|
|
4479
5423
|
if (attrs["ifid"]) story.ifid = normalizeIFID(attrs["ifid"]);
|
|
5424
|
+
if (checks.ifid) diagnostics.push(...storyDataIFIDDiagnostics(attrs["ifid"] ?? ""));
|
|
4480
5425
|
if (attrs["zoom"]) {
|
|
4481
5426
|
const parsed = parseFloat(attrs["zoom"]);
|
|
4482
5427
|
if (Number.isNaN(parsed)) diagnostics.push({
|
|
@@ -4499,13 +5444,17 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4499
5444
|
const content = getTextContent(node);
|
|
4500
5445
|
if (content.trim().length === 0) continue;
|
|
4501
5446
|
const text = passageText(content);
|
|
4502
|
-
const
|
|
5447
|
+
const base = node.name === "style" ? "Story Stylesheet" : "Story JavaScript";
|
|
5448
|
+
const name = freeName(base, (n) => !takenNames.has(n));
|
|
4503
5449
|
takenNames.add(name);
|
|
4504
|
-
storyAdd(story, {
|
|
5450
|
+
storyAdd(story, withGeneratedName({
|
|
4505
5451
|
name,
|
|
4506
5452
|
tags: node.name === "style" ? ["stylesheet"] : ["script"],
|
|
4507
5453
|
text
|
|
4508
|
-
},
|
|
5454
|
+
}, {
|
|
5455
|
+
kind: "code",
|
|
5456
|
+
base
|
|
5457
|
+
}), diagnostics);
|
|
4509
5458
|
break;
|
|
4510
5459
|
}
|
|
4511
5460
|
case "tw-tag": {
|
|
@@ -4552,19 +5501,26 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4552
5501
|
function passageDataName(node) {
|
|
4553
5502
|
return node.attribs?.["name"] ?? "";
|
|
4554
5503
|
}
|
|
4555
|
-
/**
|
|
4556
|
-
function
|
|
4557
|
-
if (
|
|
4558
|
-
|
|
4559
|
-
|
|
4560
|
-
|
|
4561
|
-
|
|
5504
|
+
/** Diagnostics for the `ifid` attribute of `tw-storydata`; `value` is empty when it is missing. */
|
|
5505
|
+
function storyDataIFIDDiagnostics(value) {
|
|
5506
|
+
if (value === "") return [{
|
|
5507
|
+
level: "warning",
|
|
5508
|
+
message: "Story IFID not found; the \"tw-storydata\" content attribute \"ifid\" is missing or empty."
|
|
5509
|
+
}];
|
|
5510
|
+
const err = validateIFID(value);
|
|
5511
|
+
if (err === null) return [];
|
|
5512
|
+
return [{
|
|
5513
|
+
level: "warning",
|
|
5514
|
+
message: `Cannot parse "tw-storydata" content attribute "ifid" as an IFID; value "${value}" (${err}).`
|
|
5515
|
+
}];
|
|
4562
5516
|
}
|
|
4563
5517
|
function decompileTwine1(storeArea, story, passageText, diagnostics) {
|
|
4564
5518
|
const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
|
|
4565
5519
|
const obfuscated = isRot13Obfuscated(passages);
|
|
4566
|
-
for (const p of passages) storyAdd(story, obfuscated && p
|
|
5520
|
+
for (const p of passages) storyAdd(story, obfuscated && isObfuscatable(p) ? {
|
|
4567
5521
|
...p,
|
|
5522
|
+
name: rot13(p.name),
|
|
5523
|
+
tags: p.tags.map(rot13),
|
|
4568
5524
|
text: rot13(p.text)
|
|
4569
5525
|
} : p, diagnostics);
|
|
4570
5526
|
}
|
|
@@ -4709,7 +5665,12 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
|
|
|
4709
5665
|
function loadInlineSources(story, sources, opts, diagnostics) {
|
|
4710
5666
|
for (const source of sources) {
|
|
4711
5667
|
if (typeof source === "string") continue;
|
|
4712
|
-
const
|
|
5668
|
+
const decoded = typeof source.content === "string" ? {
|
|
5669
|
+
text: source.content,
|
|
5670
|
+
diagnostics: []
|
|
5671
|
+
} : decodeText(source.content, source.filename);
|
|
5672
|
+
diagnostics.push(...decoded.diagnostics);
|
|
5673
|
+
const content = normalizeSourceText(decoded.text);
|
|
4713
5674
|
const ext = normalizedFileExt(source.filename);
|
|
4714
5675
|
switch (ext) {
|
|
4715
5676
|
case "":
|
|
@@ -4728,18 +5689,10 @@ function loadInlineSources(story, sources, opts, diagnostics) {
|
|
|
4728
5689
|
break;
|
|
4729
5690
|
}
|
|
4730
5691
|
case "css":
|
|
4731
|
-
storyAdd(story,
|
|
4732
|
-
name: basename(source.filename),
|
|
4733
|
-
tags: ["stylesheet"],
|
|
4734
|
-
text: content
|
|
4735
|
-
}, diagnostics);
|
|
5692
|
+
storyAdd(story, codePassage(basename(source.filename), "stylesheet", content), diagnostics);
|
|
4736
5693
|
break;
|
|
4737
5694
|
case "js":
|
|
4738
|
-
storyAdd(story,
|
|
4739
|
-
name: basename(source.filename),
|
|
4740
|
-
tags: ["script"],
|
|
4741
|
-
text: content
|
|
4742
|
-
}, diagnostics);
|
|
5695
|
+
storyAdd(story, codePassage(basename(source.filename), "script", content), diagnostics);
|
|
4743
5696
|
break;
|
|
4744
5697
|
default: diagnostics.push({
|
|
4745
5698
|
level: "warning",
|
|
@@ -4749,35 +5702,60 @@ function loadInlineSources(story, sources, opts, diagnostics) {
|
|
|
4749
5702
|
}
|
|
4750
5703
|
}
|
|
4751
5704
|
function parseTweeFile(filename, opts) {
|
|
4752
|
-
const
|
|
5705
|
+
const readDiagnostics = [];
|
|
5706
|
+
const result = parseTwee(readUTF8(filename, readDiagnostics), {
|
|
4753
5707
|
filename,
|
|
4754
5708
|
trim: opts.trim ?? true,
|
|
4755
5709
|
twee2Compat: opts.twee2Compat ?? false
|
|
4756
5710
|
});
|
|
4757
5711
|
return {
|
|
4758
5712
|
passages: result.passages,
|
|
4759
|
-
diagnostics: [...result.diagnostics]
|
|
5713
|
+
diagnostics: [...readDiagnostics, ...result.diagnostics]
|
|
4760
5714
|
};
|
|
4761
5715
|
}
|
|
5716
|
+
/**
|
|
5717
|
+
* A stylesheet or script passage named after its file, as Tweego names it. The name only labels
|
|
5718
|
+
* the code, so when another passage has it, the passage takes a free one (see `storyAdd()`).
|
|
5719
|
+
*/
|
|
5720
|
+
function codePassage(name, tag, text) {
|
|
5721
|
+
return withGeneratedName({
|
|
5722
|
+
name,
|
|
5723
|
+
tags: [tag],
|
|
5724
|
+
text
|
|
5725
|
+
}, {
|
|
5726
|
+
kind: "code",
|
|
5727
|
+
base: name
|
|
5728
|
+
});
|
|
5729
|
+
}
|
|
5730
|
+
/** Mark every passage of a parse result as a code passage named after its file. */
|
|
5731
|
+
function codeNames(result) {
|
|
5732
|
+
for (const p of result.passages) withGeneratedName(p, {
|
|
5733
|
+
kind: "code",
|
|
5734
|
+
base: p.name
|
|
5735
|
+
});
|
|
5736
|
+
return result;
|
|
5737
|
+
}
|
|
4762
5738
|
function parseTaggedFile(tag, filename) {
|
|
4763
|
-
const
|
|
5739
|
+
const diagnostics = [];
|
|
5740
|
+
const source = readUTF8(filename, diagnostics);
|
|
4764
5741
|
return {
|
|
4765
|
-
passages: [
|
|
4766
|
-
|
|
4767
|
-
tags: [tag],
|
|
4768
|
-
text: source
|
|
4769
|
-
}],
|
|
4770
|
-
diagnostics: []
|
|
5742
|
+
passages: [codePassage(basename(filename), tag, source)],
|
|
5743
|
+
diagnostics
|
|
4771
5744
|
};
|
|
4772
5745
|
}
|
|
4773
5746
|
function parseMediaFile(tag, filename) {
|
|
4774
5747
|
const source = readBase64(filename);
|
|
5748
|
+
const name = baseNameWithoutExt(filename);
|
|
4775
5749
|
return {
|
|
4776
|
-
passages: [{
|
|
4777
|
-
name
|
|
5750
|
+
passages: [withGeneratedName({
|
|
5751
|
+
name,
|
|
4778
5752
|
tags: [tag],
|
|
4779
5753
|
text: `data:${mediaTypeFromFilename(filename)};base64,${source}`
|
|
4780
|
-
}
|
|
5754
|
+
}, {
|
|
5755
|
+
kind: "media",
|
|
5756
|
+
base: name,
|
|
5757
|
+
file: filename
|
|
5758
|
+
})],
|
|
4781
5759
|
diagnostics: []
|
|
4782
5760
|
};
|
|
4783
5761
|
}
|
|
@@ -4786,24 +5764,27 @@ function parseFontFile(filename) {
|
|
|
4786
5764
|
const name = basename(filename);
|
|
4787
5765
|
const family = baseNameWithoutExt(filename);
|
|
4788
5766
|
const ext = normalizedFileExt(filename);
|
|
4789
|
-
|
|
5767
|
+
const mediaType = mediaTypeFromExt(ext);
|
|
5768
|
+
const hint = fontFormatHint(ext);
|
|
5769
|
+
return codeNames({
|
|
4790
5770
|
passages: [{
|
|
4791
5771
|
name,
|
|
4792
5772
|
tags: ["stylesheet"],
|
|
4793
|
-
text: `@font-face {\n\tfont-family: "${family}";\n\tsrc: url("data:${
|
|
5773
|
+
text: `@font-face {\n\tfont-family: "${cssStringEscape(family)}";\n\tsrc: url("data:${mediaType};base64,${source}") format("${hint}");\n}`
|
|
4794
5774
|
}],
|
|
4795
5775
|
diagnostics: []
|
|
4796
|
-
};
|
|
5776
|
+
});
|
|
4797
5777
|
}
|
|
4798
5778
|
function parseHTMLFile(filename, opts) {
|
|
4799
|
-
const
|
|
5779
|
+
const readDiagnostics = [];
|
|
5780
|
+
const { story, diagnostics } = decompileHTMLForImport(readUTF8(filename, readDiagnostics), { trim: opts.trim ?? true });
|
|
4800
5781
|
return {
|
|
4801
5782
|
passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
|
|
4802
5783
|
name: "StoryTitle",
|
|
4803
5784
|
tags: [],
|
|
4804
5785
|
text: story.name
|
|
4805
5786
|
}, ...story.passages] : story.passages,
|
|
4806
|
-
diagnostics: [...diagnostics]
|
|
5787
|
+
diagnostics: [...readDiagnostics, ...diagnostics]
|
|
4807
5788
|
};
|
|
4808
5789
|
}
|
|
4809
5790
|
/**
|
|
@@ -4876,6 +5857,7 @@ function loadTwee(story, filename, opts, diagnostics) {
|
|
|
4876
5857
|
}
|
|
4877
5858
|
function loadTagged(story, tag, filename, diagnostics) {
|
|
4878
5859
|
const result = parseTaggedFile(tag, filename);
|
|
5860
|
+
diagnostics.push(...result.diagnostics);
|
|
4879
5861
|
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
4880
5862
|
}
|
|
4881
5863
|
function loadMedia(story, tag, filename, diagnostics) {
|
|
@@ -4890,8 +5872,9 @@ function loadFont(story, filename, diagnostics) {
|
|
|
4890
5872
|
* Load sources with mtime-based caching for incremental rebuilds.
|
|
4891
5873
|
* Always creates a fresh Story; cached passages are replayed via storyAdd().
|
|
4892
5874
|
*/
|
|
4893
|
-
function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
|
|
4894
|
-
const currentFiles =
|
|
5875
|
+
function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles, buildFiles) {
|
|
5876
|
+
const currentFiles = new Set(buildFiles);
|
|
5877
|
+
const changedPaths = changedFiles === void 0 ? void 0 : new Set([...changedFiles].map((f) => resolve(f)));
|
|
4895
5878
|
for (const filename of filenames) {
|
|
4896
5879
|
if (processedFiles.has(filename)) {
|
|
4897
5880
|
diagnostics.push({
|
|
@@ -4904,8 +5887,8 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
4904
5887
|
const optionsKey = parseOptionsKey(filename, opts);
|
|
4905
5888
|
const entry = cache.get(filename);
|
|
4906
5889
|
const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
|
|
4907
|
-
const changed =
|
|
4908
|
-
if (
|
|
5890
|
+
const changed = changedPaths?.has(resolve(filename)) ?? false;
|
|
5891
|
+
if (changedPaths && cached && !changed) {
|
|
4909
5892
|
diagnostics.push(...cached.diagnostics);
|
|
4910
5893
|
for (const p of cached.passages) storyAdd(story, p, diagnostics);
|
|
4911
5894
|
processedFiles.add(filename);
|
|
@@ -4915,6 +5898,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
4915
5898
|
try {
|
|
4916
5899
|
mtimeMs = statSync(filename).mtimeMs;
|
|
4917
5900
|
} catch {
|
|
5901
|
+
cache.delete(filename);
|
|
4918
5902
|
continue;
|
|
4919
5903
|
}
|
|
4920
5904
|
if (cached && !changed && cached.mtimeMs === mtimeMs) {
|
|
@@ -4935,6 +5919,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
4935
5919
|
diagnostics.push(...result.diagnostics);
|
|
4936
5920
|
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
4937
5921
|
} catch (e) {
|
|
5922
|
+
cache.delete(filename);
|
|
4938
5923
|
diagnostics.push({
|
|
4939
5924
|
level: "error",
|
|
4940
5925
|
message: `load ${filename}: ${e instanceof Error ? e.message : String(e)}`,
|
|
@@ -4953,11 +5938,6 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
4953
5938
|
}
|
|
4954
5939
|
//#endregion
|
|
4955
5940
|
//#region src/template.ts
|
|
4956
|
-
/**
|
|
4957
|
-
* An HTML closing head tag: `</head` in any letter case, followed by whitespace, `/` or `>`, up to the next `>`.
|
|
4958
|
-
* This is how the HTML tokenizer reads an end tag; it ignores whatever sits between the tag name and the `>`.
|
|
4959
|
-
*/
|
|
4960
|
-
const CLOSING_HEAD_TAG = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>";
|
|
4961
5941
|
/** The regular expression source matching exactly `text`. */
|
|
4962
5942
|
function literal(text) {
|
|
4963
5943
|
return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
@@ -4983,6 +5963,8 @@ function fillTemplateParts(parts) {
|
|
|
4983
5963
|
return part.text.replace(pattern, (match, ...groups) => {
|
|
4984
5964
|
const slot = slots.find((_, i) => groups[i] !== void 0);
|
|
4985
5965
|
if (slot === void 0 || slot.occurrences === "first" && filled.has(slot)) return match;
|
|
5966
|
+
const offset = groups[slots.length];
|
|
5967
|
+
if (slot.accepts !== void 0 && typeof offset === "number" && !slot.accepts(part.text, offset)) return match;
|
|
4986
5968
|
filled.add(slot);
|
|
4987
5969
|
return slot.replacement(match);
|
|
4988
5970
|
});
|
|
@@ -4991,9 +5973,10 @@ function fillTemplateParts(parts) {
|
|
|
4991
5973
|
//#endregion
|
|
4992
5974
|
//#region src/modules.ts
|
|
4993
5975
|
/**
|
|
4994
|
-
* Load modules and return HTML tags to inject before the closing head tag.
|
|
5976
|
+
* Load modules and return HTML tags to inject before the closing head tag. `diagnostics` receives a warning for
|
|
5977
|
+
* each module that is not valid UTF-8 (it is read as Windows-1252).
|
|
4995
5978
|
*/
|
|
4996
|
-
function loadModules(filenames) {
|
|
5979
|
+
function loadModules(filenames, diagnostics) {
|
|
4997
5980
|
const processed = /* @__PURE__ */ new Set();
|
|
4998
5981
|
const headTags = [];
|
|
4999
5982
|
for (const filename of filenames) {
|
|
@@ -5002,10 +5985,10 @@ function loadModules(filenames) {
|
|
|
5002
5985
|
let tag = null;
|
|
5003
5986
|
switch (ext) {
|
|
5004
5987
|
case "css":
|
|
5005
|
-
tag = loadModuleTagged("style", filename);
|
|
5988
|
+
tag = loadModuleTagged("style", filename, diagnostics);
|
|
5006
5989
|
break;
|
|
5007
5990
|
case "js":
|
|
5008
|
-
tag = loadModuleTagged("script", filename);
|
|
5991
|
+
tag = loadModuleTagged("script", filename, diagnostics);
|
|
5009
5992
|
break;
|
|
5010
5993
|
case "otf":
|
|
5011
5994
|
case "ttf":
|
|
@@ -5020,8 +6003,8 @@ function loadModules(filenames) {
|
|
|
5020
6003
|
}
|
|
5021
6004
|
return headTags.join("\n");
|
|
5022
6005
|
}
|
|
5023
|
-
function loadModuleTagged(tag, filename) {
|
|
5024
|
-
const source = readUTF8(filename).trim();
|
|
6006
|
+
function loadModuleTagged(tag, filename, diagnostics) {
|
|
6007
|
+
const source = readUTF8(filename, diagnostics).trim();
|
|
5025
6008
|
if (source.length === 0) return null;
|
|
5026
6009
|
return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
|
|
5027
6010
|
}
|
|
@@ -5030,20 +6013,22 @@ function loadModuleFont(filename) {
|
|
|
5030
6013
|
const family = baseNameWithoutExt(filename);
|
|
5031
6014
|
const idSlug = `style-module-${slugify(family)}`;
|
|
5032
6015
|
const ext = normalizedFileExt(filename);
|
|
5033
|
-
|
|
6016
|
+
const mediaType = mediaTypeFromExt(ext);
|
|
6017
|
+
const hint = fontFormatHint(ext);
|
|
6018
|
+
return `<style id="${idSlug}" type="text/css">@font-face {\n\tfont-family: "${cssStringEscape(family)}";\n\tsrc: url("data:${mediaType};base64,${source}") format("${hint}");\n}</style>`;
|
|
5034
6019
|
}
|
|
5035
6020
|
/**
|
|
5036
|
-
* Load the module tags and head file content to inject
|
|
6021
|
+
* Load the module tags and head file content to inject into the head (see `placeHead()`), joined by newlines,
|
|
5037
6022
|
* or `''` when there is nothing to inject.
|
|
5038
6023
|
*/
|
|
5039
6024
|
function loadHeadContent(modulePaths, headFile, diagnostics) {
|
|
5040
6025
|
const parts = [];
|
|
5041
6026
|
if (modulePaths.length > 0) {
|
|
5042
|
-
const modules = loadModules(modulePaths).trim();
|
|
6027
|
+
const modules = loadModules(modulePaths, diagnostics).trim();
|
|
5043
6028
|
if (modules.length > 0) parts.push(modules);
|
|
5044
6029
|
}
|
|
5045
6030
|
if (headFile) try {
|
|
5046
|
-
const source = readUTF8(headFile).trim();
|
|
6031
|
+
const source = readUTF8(headFile, diagnostics).trim();
|
|
5047
6032
|
if (source.length > 0) parts.push(source);
|
|
5048
6033
|
} catch (e) {
|
|
5049
6034
|
diagnostics?.push({
|
|
@@ -5054,16 +6039,140 @@ function loadHeadContent(modulePaths, headFile, diagnostics) {
|
|
|
5054
6039
|
return parts.join("\n");
|
|
5055
6040
|
}
|
|
5056
6041
|
/**
|
|
5057
|
-
*
|
|
5058
|
-
*
|
|
5059
|
-
*
|
|
6042
|
+
* Place `content` in a template, given the texts that `fillTemplateParts()` scans for the slot (the template, and
|
|
6043
|
+
* the footer of a pre-1.4 Twine 1 format):
|
|
6044
|
+
*
|
|
6045
|
+
* - on its own line before the first closing head tag, as Tweego does; the tag may use any letter case and
|
|
6046
|
+
* whitespace before its `>`, and is kept as written;
|
|
6047
|
+
* - in a template with no closing head tag (HTML lets a document leave it out), on its own line before the first
|
|
6048
|
+
* body start tag, where the HTML parser still puts it in the head, with a warning (Tweego drops it);
|
|
6049
|
+
* - in a template with neither, nowhere, with a warning.
|
|
6050
|
+
*
|
|
6051
|
+
* `owner` names the template in the warnings. Empty `content` needs no slot and gets no warning.
|
|
6052
|
+
*/
|
|
6053
|
+
function placeHead(content, texts, owner) {
|
|
6054
|
+
if (content.length === 0) return {
|
|
6055
|
+
slot: void 0,
|
|
6056
|
+
diagnostics: []
|
|
6057
|
+
};
|
|
6058
|
+
const scans = texts.map((text) => ({
|
|
6059
|
+
text,
|
|
6060
|
+
tags: scanHeadTags(text)
|
|
6061
|
+
}));
|
|
6062
|
+
if (scans.some(({ tags }) => tags.closingHead !== void 0)) return {
|
|
6063
|
+
slot: beforeTag(CLOSING_HEAD_PREFIX, "closingHead", content),
|
|
6064
|
+
diagnostics: []
|
|
6065
|
+
};
|
|
6066
|
+
if (scans.some(({ tags }) => tags.bodyStart !== void 0)) {
|
|
6067
|
+
const message = `${owner} has no closing head tag; the modules and head file were injected before its body start tag.`;
|
|
6068
|
+
return {
|
|
6069
|
+
slot: beforeTag(BODY_START_PREFIX, "bodyStart", content),
|
|
6070
|
+
diagnostics: [{
|
|
6071
|
+
level: "warning",
|
|
6072
|
+
message
|
|
6073
|
+
}]
|
|
6074
|
+
};
|
|
6075
|
+
}
|
|
6076
|
+
return {
|
|
6077
|
+
slot: void 0,
|
|
6078
|
+
diagnostics: [{
|
|
6079
|
+
level: "warning",
|
|
6080
|
+
message: `${owner} has no closing head tag and no body start tag; the modules and head file were not injected.`
|
|
6081
|
+
}]
|
|
6082
|
+
};
|
|
6083
|
+
}
|
|
6084
|
+
/** `</head` in any letter case, followed by whitespace, `/` or `>`; the rest of the tag is left alone. */
|
|
6085
|
+
const CLOSING_HEAD_PREFIX = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])";
|
|
6086
|
+
/** `<body` in any letter case, followed by whitespace, `/` or `>` (so not `<bodyx>`). */
|
|
6087
|
+
const BODY_START_PREFIX = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
|
|
6088
|
+
/** Elements whose content is text, so a tag written there is not a tag. */
|
|
6089
|
+
const RAW_TEXT_ELEMENTS = /* @__PURE__ */ new Set([
|
|
6090
|
+
"script",
|
|
6091
|
+
"style",
|
|
6092
|
+
"textarea",
|
|
6093
|
+
"title"
|
|
6094
|
+
]);
|
|
6095
|
+
const isSpace = (c) => c === " " || c === " " || c === "\n" || c === "\f" || c === "\r";
|
|
6096
|
+
const isLetter = (c) => c !== void 0 && /^[A-Za-z]$/.test(c);
|
|
6097
|
+
/**
|
|
6098
|
+
* Find the first closing head tag and body start tag that HTML would read as tags: not those in a comment, in
|
|
6099
|
+
* the text of a script, style, textarea or title element, or in a quoted attribute value. A small scanner, not
|
|
6100
|
+
* a full HTML tokenizer; it needs no more than the tags and where they end.
|
|
6101
|
+
*/
|
|
6102
|
+
function scanHeadTags(html) {
|
|
6103
|
+
let closingHead;
|
|
6104
|
+
let bodyStart;
|
|
6105
|
+
let i = 0;
|
|
6106
|
+
while (i < html.length) {
|
|
6107
|
+
const lt = html.indexOf("<", i);
|
|
6108
|
+
if (lt === -1) break;
|
|
6109
|
+
const next = html[lt + 1];
|
|
6110
|
+
if (html.startsWith("<!--", lt)) {
|
|
6111
|
+
const end = html.indexOf("-->", lt + 4);
|
|
6112
|
+
i = end === -1 ? html.length : end + 3;
|
|
6113
|
+
} else if (next === "!" || next === "?") {
|
|
6114
|
+
const end = html.indexOf(">", lt);
|
|
6115
|
+
i = end === -1 ? html.length : end + 1;
|
|
6116
|
+
} else if (next === "/" && isLetter(html[lt + 2])) {
|
|
6117
|
+
const { name, end } = readTag(html, lt + 2);
|
|
6118
|
+
if (name === "head" && closingHead === void 0) closingHead = lt;
|
|
6119
|
+
i = end;
|
|
6120
|
+
} else if (isLetter(next)) {
|
|
6121
|
+
const { name, end } = readTag(html, lt + 1);
|
|
6122
|
+
if (name === "body" && bodyStart === void 0) bodyStart = lt;
|
|
6123
|
+
i = RAW_TEXT_ELEMENTS.has(name) ? rawTextEnd(html, name, end) : end;
|
|
6124
|
+
} else i = lt + 1;
|
|
6125
|
+
if (closingHead !== void 0 && bodyStart !== void 0) break;
|
|
6126
|
+
}
|
|
6127
|
+
return {
|
|
6128
|
+
closingHead,
|
|
6129
|
+
bodyStart
|
|
6130
|
+
};
|
|
6131
|
+
}
|
|
6132
|
+
/** Read a tag whose name starts at `from`: its lowercase name, and the offset after its `>` (or the end). */
|
|
6133
|
+
function readTag(html, from) {
|
|
6134
|
+
let i = from;
|
|
6135
|
+
while (i < html.length && !isSpace(html[i]) && html[i] !== "/" && html[i] !== ">") i++;
|
|
6136
|
+
const name = html.slice(from, i).toLowerCase();
|
|
6137
|
+
while (i < html.length && html[i] !== ">") {
|
|
6138
|
+
if (html[i] !== "=") {
|
|
6139
|
+
i++;
|
|
6140
|
+
continue;
|
|
6141
|
+
}
|
|
6142
|
+
i++;
|
|
6143
|
+
while (isSpace(html[i])) i++;
|
|
6144
|
+
const quote = html[i];
|
|
6145
|
+
if (quote === "\"" || quote === "'") {
|
|
6146
|
+
const close = html.indexOf(quote, i + 1);
|
|
6147
|
+
i = close === -1 ? html.length : close + 1;
|
|
6148
|
+
}
|
|
6149
|
+
}
|
|
6150
|
+
return {
|
|
6151
|
+
name,
|
|
6152
|
+
end: Math.min(i + 1, html.length)
|
|
6153
|
+
};
|
|
6154
|
+
}
|
|
6155
|
+
/** The offset of the end tag that closes a raw text element, given where its text starts (or the end). */
|
|
6156
|
+
function rawTextEnd(html, name, from) {
|
|
6157
|
+
const close = new RegExp(`</${name}(?=[\\t\\n\\f\\r />])`, "gi");
|
|
6158
|
+
close.lastIndex = from;
|
|
6159
|
+
return close.exec(html)?.index ?? html.length;
|
|
6160
|
+
}
|
|
6161
|
+
/**
|
|
6162
|
+
* The slot that inserts `content` on its own line before the first real tag of the kind `which` names (see
|
|
6163
|
+
* `scanHeadTags()`), which is kept as written. A look-alike inside a script or comment is left alone.
|
|
5060
6164
|
*/
|
|
5061
|
-
function
|
|
5062
|
-
|
|
6165
|
+
function beforeTag(pattern, which, content) {
|
|
6166
|
+
const cache = /* @__PURE__ */ new Map();
|
|
6167
|
+
const offsetIn = (text) => {
|
|
6168
|
+
if (!cache.has(text)) cache.set(text, scanHeadTags(text)[which]);
|
|
6169
|
+
return cache.get(text);
|
|
6170
|
+
};
|
|
5063
6171
|
return {
|
|
5064
|
-
pattern
|
|
6172
|
+
pattern,
|
|
5065
6173
|
occurrences: "first",
|
|
5066
|
-
replacement: (
|
|
6174
|
+
replacement: (match) => `${content}\n${match}`,
|
|
6175
|
+
accepts: (text, offset) => offsetIn(text) === offset
|
|
5067
6176
|
};
|
|
5068
6177
|
}
|
|
5069
6178
|
//#endregion
|
|
@@ -5102,14 +6211,19 @@ function toTwine2Archive(story, startName, options) {
|
|
|
5102
6211
|
}
|
|
5103
6212
|
/**
|
|
5104
6213
|
* Fill the Twine 2 format template: every `{{STORY_NAME}}` gets the escaped story name and the first
|
|
5105
|
-
* `{{STORY_DATA}}` the story data (as Tweego does), and `head` goes before the first closing head tag
|
|
5106
|
-
* found in the template in one pass, so a story
|
|
5107
|
-
* closing head tag stays literal.
|
|
6214
|
+
* `{{STORY_DATA}}` the story data (as Tweego does), and `head` goes before the first closing head tag (or, with a
|
|
6215
|
+
* warning, the body start tag; see `placeHead()`). All three are found in the template in one pass, so a story
|
|
6216
|
+
* name, passage text or head content holding a placeholder or a closing head tag stays literal.
|
|
6217
|
+
* `options.diagnostics` receives a warning when the format file is not valid UTF-8, and any warning about where
|
|
6218
|
+
* `head` went.
|
|
5108
6219
|
*/
|
|
5109
6220
|
function toTwine2HTML(story, format, startName, options) {
|
|
5110
6221
|
const name = htmlEscape(story.name);
|
|
5111
|
-
const
|
|
5112
|
-
const
|
|
6222
|
+
const template = readFormatSource(format, options?.diagnostics);
|
|
6223
|
+
const placement = placeHead(options?.head ?? "", [template], `Story format "${format.name}" ${format.version}`);
|
|
6224
|
+
options?.diagnostics?.push(...placement.diagnostics);
|
|
6225
|
+
const head = placement.slot;
|
|
6226
|
+
return fillTemplate(template, [
|
|
5113
6227
|
{
|
|
5114
6228
|
pattern: literal("{{STORY_NAME}}"),
|
|
5115
6229
|
occurrences: "all",
|
|
@@ -5130,8 +6244,7 @@ function toTwine2HTML(story, format, startName, options) {
|
|
|
5130
6244
|
}
|
|
5131
6245
|
},
|
|
5132
6246
|
...head === void 0 ? [] : [head]
|
|
5133
|
-
];
|
|
5134
|
-
return fillTemplate(readFormatSource(format), slots);
|
|
6247
|
+
]);
|
|
5135
6248
|
}
|
|
5136
6249
|
const OMITTING_TAGS = [
|
|
5137
6250
|
"Twine.private",
|
|
@@ -5227,29 +6340,35 @@ function toTwine1Archive(story, _startName) {
|
|
|
5227
6340
|
/**
|
|
5228
6341
|
* Fill the Twine 1 format template. The format's components are inserted first, one after another, as Tweego and
|
|
5229
6342
|
* Twine 1 do. Then the first of each story placeholder (`"VERSION"`, `"TIME"`, `"START_AT"`, `"STORY_SIZE"`,
|
|
5230
|
-
* `"STORY"`), the IFID comment and `head` (before the first closing head tag
|
|
5231
|
-
* passage name or story data holding a placeholder or a
|
|
6343
|
+
* `"STORY"`), the IFID comment and `head` (before the first closing head tag or, with a warning, the body start
|
|
6344
|
+
* tag; see `placeHead()`) are found in one pass, so a start passage name or story data holding a placeholder or a
|
|
6345
|
+
* closing head tag stays literal. `options.diagnostics` receives a warning for each format file that is not valid
|
|
6346
|
+
* UTF-8, and any warning about where `head` went.
|
|
5232
6347
|
*/
|
|
5233
6348
|
function toTwine1HTML(story, format, startName, options) {
|
|
5234
6349
|
const formatDir = dirname(format.filename);
|
|
5235
6350
|
const parentDir = dirname(formatDir);
|
|
5236
|
-
|
|
6351
|
+
const diagnostics = options?.diagnostics;
|
|
6352
|
+
let template = readFormatSource(format, diagnostics);
|
|
5237
6353
|
const { data, count } = getTwine1PassageChunk(story);
|
|
5238
|
-
template = tryReplaceComponent(template, "\"USER_LIB\"", join(formatDir, "userlib.js"), false);
|
|
5239
|
-
template = tryReplaceComponent(template, "\"ENGINE\"", join(parentDir, "engine.js"), true);
|
|
5240
|
-
template = tryReplaceComponent(template, "\"SUGARCANE\"", join(formatDir, "code.js"), true);
|
|
5241
|
-
template = tryReplaceComponent(template, "\"JONAH\"", join(formatDir, "code.js"), true);
|
|
5242
|
-
if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", join(parentDir, "jquery.js"), true);
|
|
5243
|
-
if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", join(parentDir, "modernizr.js"), true);
|
|
6354
|
+
template = tryReplaceComponent(template, "\"USER_LIB\"", join(formatDir, "userlib.js"), false, diagnostics);
|
|
6355
|
+
template = tryReplaceComponent(template, "\"ENGINE\"", join(parentDir, "engine.js"), true, diagnostics);
|
|
6356
|
+
template = tryReplaceComponent(template, "\"SUGARCANE\"", join(formatDir, "code.js"), true, diagnostics);
|
|
6357
|
+
template = tryReplaceComponent(template, "\"JONAH\"", join(formatDir, "code.js"), true, diagnostics);
|
|
6358
|
+
if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", join(parentDir, "jquery.js"), true, diagnostics);
|
|
6359
|
+
if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", join(parentDir, "modernizr.js"), true, diagnostics);
|
|
5244
6360
|
const isPre14 = !template.includes("\"STORY\"");
|
|
5245
|
-
const footer = isPre14 ? readFooter(formatDir) : "";
|
|
6361
|
+
const footer = isPre14 ? readFooter(formatDir, diagnostics) : "";
|
|
5246
6362
|
const storeArea = (template + footer).includes("<div id=\"store-area\"") ? "<div id=\"store-area\"" : "<div id=\"storeArea\"";
|
|
5247
6363
|
const safeIfid = htmlCommentSanitize(story.ifid);
|
|
5248
|
-
const
|
|
6364
|
+
const ifid = story.ifid ? {
|
|
5249
6365
|
pattern: literal(storeArea),
|
|
5250
6366
|
occurrences: "first",
|
|
5251
6367
|
replacement: (div) => `<!-- UUID://${safeIfid}// -->${div}`
|
|
5252
|
-
} : void 0
|
|
6368
|
+
} : void 0;
|
|
6369
|
+
const placement = placeHead(options?.head ?? "", isPre14 ? [template, footer] : [template], `Story format "${format.name}"`);
|
|
6370
|
+
diagnostics?.push(...placement.diagnostics);
|
|
6371
|
+
const late = [ifid, placement.slot].filter((slot) => slot !== void 0);
|
|
5253
6372
|
const displayStart = startName === "Start" ? "" : startName;
|
|
5254
6373
|
const slots = [
|
|
5255
6374
|
firstSlot("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`),
|
|
@@ -5289,9 +6408,9 @@ function firstSlot(token, value) {
|
|
|
5289
6408
|
};
|
|
5290
6409
|
}
|
|
5291
6410
|
/** The footer of a pre-1.4 format, or the default one when the format has none. */
|
|
5292
|
-
function readFooter(formatDir) {
|
|
6411
|
+
function readFooter(formatDir, diagnostics) {
|
|
5293
6412
|
try {
|
|
5294
|
-
return
|
|
6413
|
+
return readUTF8(join(formatDir, "footer.html"), diagnostics);
|
|
5295
6414
|
} catch {
|
|
5296
6415
|
return "</div>\n</body>\n</html>\n";
|
|
5297
6416
|
}
|
|
@@ -5319,11 +6438,10 @@ function getTwine1PassageChunk(story) {
|
|
|
5319
6438
|
count
|
|
5320
6439
|
};
|
|
5321
6440
|
}
|
|
5322
|
-
function tryReplaceComponent(template, placeholder, componentPath, required) {
|
|
6441
|
+
function tryReplaceComponent(template, placeholder, componentPath, required, diagnostics) {
|
|
5323
6442
|
if (!template.includes(placeholder)) return template;
|
|
5324
6443
|
try {
|
|
5325
|
-
|
|
5326
|
-
if (content.charCodeAt(0) === 65279) content = content.slice(1);
|
|
6444
|
+
const content = readUTF8(componentPath, diagnostics);
|
|
5327
6445
|
return template.replace(placeholder, () => content);
|
|
5328
6446
|
} catch (e) {
|
|
5329
6447
|
if (required) throw new Error(`Required format component not found: ${componentPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -5337,11 +6455,16 @@ function tryReplaceComponent(template, placeholder, componentPath, required) {
|
|
|
5337
6455
|
* from its loaded text, so changes made after loading (a start passage override, test mode) are kept.
|
|
5338
6456
|
* Tweego writes the loaded text; its own normalization makes the two the same for an unchanged story.
|
|
5339
6457
|
*
|
|
6458
|
+
* Twee cannot write every passage so that it reads back the same (see `tweeRoundTrip()`); `diagnostics`
|
|
6459
|
+
* receives a warning for each passage that will not. Tweego writes the same Twee without one.
|
|
6460
|
+
*
|
|
5340
6461
|
* `addStoryData`: add a StoryData passage when the story has none, so that StoryData fields the
|
|
5341
6462
|
* compile options set are recorded. Default: false.
|
|
5342
6463
|
*/
|
|
5343
6464
|
function toTwee(story, outMode, options = {}) {
|
|
5344
|
-
|
|
6465
|
+
const written = withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => tweeRoundTrip(p, outMode));
|
|
6466
|
+
options.diagnostics?.push(...written.flatMap(({ diagnostics }) => diagnostics));
|
|
6467
|
+
return written.map(({ passage }) => passageToTwee(passage, outMode)).join("");
|
|
5345
6468
|
}
|
|
5346
6469
|
function withEffectiveStoryData(story, addStoryData) {
|
|
5347
6470
|
const storyData = {
|
|
@@ -5366,6 +6489,64 @@ function withEffectiveStoryData(story, addStoryData) {
|
|
|
5366
6489
|
function isStoryDataJSON(text) {
|
|
5367
6490
|
return unmarshalStoryData(createStory(), text) === null;
|
|
5368
6491
|
}
|
|
6492
|
+
/** Characters Twee 3 escapes in names and tags, and Twee 1 cannot write there at all. */
|
|
6493
|
+
const TWEE1_UNWRITABLE = /[[\]{}\\]/;
|
|
6494
|
+
/**
|
|
6495
|
+
* Check that Twee reads `p` back as written, and say what it will not. Twee has no way to escape passage text,
|
|
6496
|
+
* and its parser trims names and splits tags at whitespace, so these do not survive:
|
|
6497
|
+
*
|
|
6498
|
+
* - a text line that starts with `::`, which reads as a new passage header. In stylesheet and script passages such
|
|
6499
|
+
* a line is indented by one space, which keeps the CSS or JavaScript working (only a template literal or a
|
|
6500
|
+
* string continued across lines, in either, would see the space); the warning says so. Other passages' text is
|
|
6501
|
+
* written as it is.
|
|
6502
|
+
* - a name that is empty, has leading or trailing whitespace, or holds a line break;
|
|
6503
|
+
* - a tag that is empty or holds whitespace;
|
|
6504
|
+
* - in Twee 1, which escapes nothing, a name or tag holding `[`, `]`, `{`, `}` or `\`.
|
|
6505
|
+
*/
|
|
6506
|
+
function tweeRoundTrip(p, outMode) {
|
|
6507
|
+
const label = `Passage ${JSON.stringify(p.name)}`;
|
|
6508
|
+
const diagnostics = [];
|
|
6509
|
+
const warn = (problem) => {
|
|
6510
|
+
diagnostics.push({
|
|
6511
|
+
level: "warning",
|
|
6512
|
+
message: `${label} cannot be written as Twee that reads back the same: ${problem}.`
|
|
6513
|
+
});
|
|
6514
|
+
};
|
|
6515
|
+
if (p.name.trim() === "") warn("its name is empty");
|
|
6516
|
+
else if (p.name.trim() !== p.name) warn("its name has leading or trailing whitespace, which Twee drops");
|
|
6517
|
+
if (/[\r\n]/.test(p.name)) warn("its name has a line break, which ends a Twee passage header");
|
|
6518
|
+
if (outMode === "twee1" && TWEE1_UNWRITABLE.test(p.name)) warn("its name has \"[\", \"]\", \"{\", \"}\" or \"\\\", which Twee 1 cannot escape");
|
|
6519
|
+
for (const tag of p.tags) {
|
|
6520
|
+
if (tag === "" || /\s/.test(tag)) warn(`its tag ${JSON.stringify(tag)} is empty or has whitespace, which splits tags in Twee`);
|
|
6521
|
+
if (outMode === "twee1" && TWEE1_UNWRITABLE.test(tag)) warn(`its tag ${JSON.stringify(tag)} has "[", "]", "{", "}" or "\\", which Twee 1 cannot escape`);
|
|
6522
|
+
}
|
|
6523
|
+
const lines = p.text.split(/(\r\n?|\n)/);
|
|
6524
|
+
const headerLines = lines.flatMap((line, i) => i % 2 === 0 && line.startsWith("::") ? [i / 2 + 1] : []);
|
|
6525
|
+
if (headerLines.length === 0) return {
|
|
6526
|
+
passage: p,
|
|
6527
|
+
diagnostics
|
|
6528
|
+
};
|
|
6529
|
+
const where = `${headerLines.length === 1 ? "line" : "lines"} ${headerLines.join(", ")} of its text start${headerLines.length === 1 ? "s" : ""} with "::", which Twee reads as a passage header`;
|
|
6530
|
+
if (!p.tags.some((tag) => tag === "stylesheet" || tag === "script")) {
|
|
6531
|
+
warn(where);
|
|
6532
|
+
return {
|
|
6533
|
+
passage: p,
|
|
6534
|
+
diagnostics
|
|
6535
|
+
};
|
|
6536
|
+
}
|
|
6537
|
+
diagnostics.push({
|
|
6538
|
+
level: "warning",
|
|
6539
|
+
message: `${label}: ${where}; ${headerLines.length === 1 ? "it was" : "they were"} written indented by one space.`
|
|
6540
|
+
});
|
|
6541
|
+
const text = lines.map((line, i) => i % 2 === 0 && line.startsWith("::") ? ` ${line}` : line).join("");
|
|
6542
|
+
return {
|
|
6543
|
+
passage: {
|
|
6544
|
+
...p,
|
|
6545
|
+
text
|
|
6546
|
+
},
|
|
6547
|
+
diagnostics
|
|
6548
|
+
};
|
|
6549
|
+
}
|
|
5369
6550
|
//#endregion
|
|
5370
6551
|
//#region src/passage-omission.ts
|
|
5371
6552
|
/**
|
|
@@ -5421,22 +6602,28 @@ var TweeTsError = class extends Error {
|
|
|
5421
6602
|
}
|
|
5422
6603
|
};
|
|
5423
6604
|
/**
|
|
5424
|
-
* Builds as compileToFile() does for `
|
|
6605
|
+
* Builds as compileToFile() does for `outputs`, without writing them: an output may sit
|
|
5425
6606
|
* inside a source folder, so its last build is left out of the sources and modules and
|
|
5426
|
-
* never read back.
|
|
5427
|
-
*
|
|
5428
|
-
*
|
|
6607
|
+
* never read back. `outputs` is the one file the CLI writes, or every path a bundler's
|
|
6608
|
+
* build writes (see BuildOutputs). The caller decides whether the result is written.
|
|
6609
|
+
* With no outputs (output to stdout, or none), this is compile(). With `cache`, files
|
|
6610
|
+
* are cached as compileIncremental() caches them.
|
|
6611
|
+
*
|
|
6612
|
+
* Throws a TweeTsError when a source, module or head file named directly is an output.
|
|
5429
6613
|
*
|
|
5430
6614
|
* Internal, for the CLI and the bundler plugins; not part of the public API.
|
|
5431
6615
|
*/
|
|
5432
|
-
async function compileForOutputFile(options,
|
|
5433
|
-
return buildOutput(options, cache, void 0,
|
|
6616
|
+
async function compileForOutputFile(options, outputs, cache) {
|
|
6617
|
+
return buildOutput(options, cache, void 0, outputs);
|
|
5434
6618
|
}
|
|
5435
6619
|
/**
|
|
5436
|
-
* `
|
|
5437
|
-
*
|
|
6620
|
+
* `outputs`: what the build writes (an output file's path, or a bundler's outputs),
|
|
6621
|
+
* which source and module discovery skip, so an output inside a source folder is
|
|
6622
|
+
* never loaded back. A source, module or head file named directly that is an output
|
|
6623
|
+
* is a TweeTsError, as in Tweego: writing the build would overwrite it.
|
|
5438
6624
|
*/
|
|
5439
|
-
async function buildOutput(options, cache, changedFiles,
|
|
6625
|
+
async function buildOutput(options, cache, changedFiles, outputs) {
|
|
6626
|
+
const written = toBuildOutputs(outputs);
|
|
5440
6627
|
const diagnostics = [];
|
|
5441
6628
|
const outputMode = options.outputMode ?? "html";
|
|
5442
6629
|
const trim = options.trim ?? true;
|
|
@@ -5444,33 +6631,65 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5444
6631
|
const testMode = options.testMode ?? false;
|
|
5445
6632
|
const noRemote = options.noRemote ?? false;
|
|
5446
6633
|
const sourceInfo = options.sourceInfo ?? false;
|
|
6634
|
+
options.signal?.throwIfAborted();
|
|
6635
|
+
const timeout = options.formatFetchTimeout;
|
|
6636
|
+
if (timeout !== void 0 && !(timeout >= 0)) throw new TweeTsError(`formatFetchTimeout must be 0 or more milliseconds, not ${timeout}.`);
|
|
5447
6637
|
clearIndexCache();
|
|
5448
|
-
const
|
|
5449
|
-
const
|
|
5450
|
-
for (const source of options.sources)
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
6638
|
+
const groups = [];
|
|
6639
|
+
const outputSources = [];
|
|
6640
|
+
for (const source of options.sources) {
|
|
6641
|
+
const last = groups[groups.length - 1];
|
|
6642
|
+
if (typeof source === "string") {
|
|
6643
|
+
if (last?.kind === "paths") last.paths.push(source);
|
|
6644
|
+
else groups.push({
|
|
6645
|
+
kind: "paths",
|
|
6646
|
+
paths: [source]
|
|
6647
|
+
});
|
|
6648
|
+
} else if (last?.kind === "inline") last.sources.push(source);
|
|
6649
|
+
else groups.push({
|
|
6650
|
+
kind: "inline",
|
|
6651
|
+
sources: [source]
|
|
6652
|
+
});
|
|
6653
|
+
}
|
|
6654
|
+
const walked = groups.map((group) => {
|
|
6655
|
+
if (group.kind === "inline") return group;
|
|
6656
|
+
const found = getFilenames(group.paths, written, options.exclude);
|
|
6657
|
+
diagnostics.push(...found.diagnostics);
|
|
6658
|
+
outputSources.push(...found.outputSources);
|
|
6659
|
+
return {
|
|
6660
|
+
kind: "files",
|
|
6661
|
+
filenames: found.filenames
|
|
6662
|
+
};
|
|
6663
|
+
});
|
|
6664
|
+
rejectOutputSources(outputSources, diagnostics);
|
|
6665
|
+
const buildFiles = new Set(walked.flatMap((group) => group.kind === "files" ? group.filenames : []));
|
|
5454
6666
|
const story = createStory();
|
|
5455
6667
|
const processedFiles = /* @__PURE__ */ new Set();
|
|
5456
|
-
if (
|
|
6668
|
+
for (const group of walked) if (group.kind === "inline") loadInlineSources(story, group.sources, {
|
|
5457
6669
|
trim,
|
|
5458
6670
|
twee2Compat
|
|
5459
|
-
}, diagnostics
|
|
5460
|
-
else
|
|
6671
|
+
}, diagnostics);
|
|
6672
|
+
else if (cache) loadSourcesCached(story, group.filenames, {
|
|
6673
|
+
trim,
|
|
6674
|
+
twee2Compat
|
|
6675
|
+
}, diagnostics, processedFiles, cache, changedFiles, buildFiles);
|
|
6676
|
+
else loadSources(story, group.filenames, {
|
|
5461
6677
|
trim,
|
|
5462
6678
|
twee2Compat
|
|
5463
6679
|
}, diagnostics, processedFiles);
|
|
5464
|
-
|
|
6680
|
+
if (cache && buildFiles.size === 0) loadSourcesCached(story, [], {
|
|
5465
6681
|
trim,
|
|
5466
6682
|
twee2Compat
|
|
5467
|
-
}, diagnostics);
|
|
6683
|
+
}, diagnostics, processedFiles, cache, changedFiles);
|
|
5468
6684
|
if (options.tagAliases) story.passages = applyTagAliases(story.passages, options.tagAliases);
|
|
5469
6685
|
let format;
|
|
5470
|
-
if (outputMode === "html")
|
|
5471
|
-
|
|
5472
|
-
|
|
5473
|
-
|
|
6686
|
+
if (outputMode === "html") {
|
|
6687
|
+
format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
|
|
6688
|
+
...options,
|
|
6689
|
+
noRemote
|
|
6690
|
+
}, diagnostics);
|
|
6691
|
+
options.signal?.throwIfAborted();
|
|
6692
|
+
}
|
|
5474
6693
|
if (options.startPassage) story.twine2.start = options.startPassage;
|
|
5475
6694
|
const startName = story.twine2.start || DEFAULT_START_NAME;
|
|
5476
6695
|
if (testMode) story.twine2.options.set("debug", true);
|
|
@@ -5479,7 +6698,10 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5479
6698
|
switch (outputMode) {
|
|
5480
6699
|
case "twee3":
|
|
5481
6700
|
case "twee1":
|
|
5482
|
-
output = toTwee(story, outputMode, {
|
|
6701
|
+
output = toTwee(story, outputMode, {
|
|
6702
|
+
addStoryData: Boolean(options.startPassage) || testMode,
|
|
6703
|
+
diagnostics
|
|
6704
|
+
});
|
|
5483
6705
|
break;
|
|
5484
6706
|
case "twine2-archive":
|
|
5485
6707
|
output = toTwine2Archive(story, startName, { sourceInfo });
|
|
@@ -5497,13 +6719,20 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5497
6719
|
level: "error",
|
|
5498
6720
|
message: "Special passage \"StoryTitle\" not found."
|
|
5499
6721
|
});
|
|
5500
|
-
const modules = getFilenames(options.modules ?? [],
|
|
6722
|
+
const modules = getFilenames(options.modules ?? [], written);
|
|
5501
6723
|
diagnostics.push(...modules.diagnostics);
|
|
6724
|
+
const { headFile } = options;
|
|
6725
|
+
const headIsOutput = headFile !== void 0 && outputPaths(written).isFile(realPathOf(headFile));
|
|
6726
|
+
rejectOutputSources([...modules.outputSources, ...headIsOutput ? [headFile] : []], diagnostics);
|
|
5502
6727
|
const head = loadHeadContent(modules.filenames, options.headFile, diagnostics);
|
|
5503
6728
|
output = format.isTwine2 ? toTwine2HTML(story, format, startName, {
|
|
5504
6729
|
sourceInfo,
|
|
5505
|
-
head
|
|
5506
|
-
|
|
6730
|
+
head,
|
|
6731
|
+
diagnostics
|
|
6732
|
+
}) : toTwine1HTML(story, format, startName, {
|
|
6733
|
+
head,
|
|
6734
|
+
diagnostics
|
|
6735
|
+
});
|
|
5507
6736
|
break;
|
|
5508
6737
|
}
|
|
5509
6738
|
default: throw new TweeTsError(`Unhandled output mode: ${outputMode}`, diagnostics);
|
|
@@ -5512,6 +6741,7 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5512
6741
|
...getStoryStats(story, options.wordCountMethod),
|
|
5513
6742
|
files: [...processedFiles]
|
|
5514
6743
|
};
|
|
6744
|
+
options.signal?.throwIfAborted();
|
|
5515
6745
|
return {
|
|
5516
6746
|
output,
|
|
5517
6747
|
story,
|
|
@@ -5520,6 +6750,16 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5520
6750
|
stats
|
|
5521
6751
|
};
|
|
5522
6752
|
}
|
|
6753
|
+
/**
|
|
6754
|
+
* Throws for the first of `paths`, inputs named directly that are also an output:
|
|
6755
|
+
* writing the build would overwrite them. An output found while walking a source
|
|
6756
|
+
* folder is skipped instead (see getFilenames).
|
|
6757
|
+
*/
|
|
6758
|
+
function rejectOutputSources(paths, diagnostics) {
|
|
6759
|
+
const [first] = paths;
|
|
6760
|
+
if (first === void 0) return;
|
|
6761
|
+
throw new TweeTsError(`path ${first}: Output file cannot be an input source.`, [...diagnostics]);
|
|
6762
|
+
}
|
|
5523
6763
|
function ensureIFID(story, diagnostics) {
|
|
5524
6764
|
if (story.ifid !== "") return;
|
|
5525
6765
|
if (story.legacyIFID !== "") {
|
|
@@ -5612,7 +6852,8 @@ function fatalError(e) {
|
|
|
5612
6852
|
/**
|
|
5613
6853
|
* Path helpers for the bundler plugins. Vite reports module ids with forward
|
|
5614
6854
|
* slashes on every platform, while node:path gives backslashes on Windows, so
|
|
5615
|
-
* the Vite plugin compares paths in forward-slash form only.
|
|
6855
|
+
* the Vite plugin compares paths in forward-slash form only. Paths a build
|
|
6856
|
+
* writes are compared by real path instead (see filesystem.ts).
|
|
5616
6857
|
*/
|
|
5617
6858
|
/**
|
|
5618
6859
|
* The absolute path a bundle writes the file it emits as `fileName` to: inside
|
|
@@ -5624,6 +6865,45 @@ function emittedFilePath(output, fileName) {
|
|
|
5624
6865
|
const dir = output.dir ?? (output.file === void 0 ? void 0 : dirname(output.file));
|
|
5625
6866
|
return dir === void 0 ? void 0 : resolve(dir, fileName);
|
|
5626
6867
|
}
|
|
6868
|
+
/**
|
|
6869
|
+
* The locations a bundler's `output` option names: one output's settings or an
|
|
6870
|
+
* array of them, as a config gives them. Outputs that name neither `dir` nor
|
|
6871
|
+
* `file`, and anything that isn't an output's settings, are left out.
|
|
6872
|
+
*/
|
|
6873
|
+
function outputLocations(option) {
|
|
6874
|
+
return (Array.isArray(option) ? option : [option]).flatMap((output) => {
|
|
6875
|
+
if (typeof output !== "object" || output === null) return [];
|
|
6876
|
+
const { dir, file } = output;
|
|
6877
|
+
const location = {
|
|
6878
|
+
...typeof dir === "string" ? { dir } : {},
|
|
6879
|
+
...typeof file === "string" ? { file } : {}
|
|
6880
|
+
};
|
|
6881
|
+
return location.dir === void 0 && location.file === void 0 ? [] : [location];
|
|
6882
|
+
});
|
|
6883
|
+
}
|
|
6884
|
+
/** An empty OutputRecord for a plugin that writes its story as `storyFileName`. */
|
|
6885
|
+
function createOutputRecord(storyFileName) {
|
|
6886
|
+
const files = /* @__PURE__ */ new Set();
|
|
6887
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
6888
|
+
const addFiles = (output, fileNames) => {
|
|
6889
|
+
for (const fileName of fileNames) {
|
|
6890
|
+
const path = emittedFilePath(output, fileName);
|
|
6891
|
+
if (path !== void 0) files.add(path);
|
|
6892
|
+
}
|
|
6893
|
+
};
|
|
6894
|
+
return {
|
|
6895
|
+
addLocation(output) {
|
|
6896
|
+
if (output.dir !== void 0) dirs.add(resolve(output.dir));
|
|
6897
|
+
if (output.file !== void 0) files.add(resolve(output.file));
|
|
6898
|
+
addFiles(output, [storyFileName]);
|
|
6899
|
+
},
|
|
6900
|
+
addFiles,
|
|
6901
|
+
outputs: () => ({
|
|
6902
|
+
files: [...files],
|
|
6903
|
+
dirs: [...dirs]
|
|
6904
|
+
})
|
|
6905
|
+
};
|
|
6906
|
+
}
|
|
5627
6907
|
/** The path with Windows separators turned into forward slashes. */
|
|
5628
6908
|
function toPosix(path) {
|
|
5629
6909
|
return path.replace(/\\/g, "/");
|
|
@@ -5641,6 +6921,6 @@ function isViteConfigTemp(file) {
|
|
|
5641
6921
|
return /\.timestamp-\d+-[0-9a-f]*\.mjs$/.test(file);
|
|
5642
6922
|
}
|
|
5643
6923
|
//#endregion
|
|
5644
|
-
export {
|
|
6924
|
+
export { toPosix as a, splitDiagnostics as c, isExcluded as d, outputPaths as f, mediaTypeFromFilename as h, outputLocations as i, compileForOutputFile as l, walkedEntry as m, isInside as n, fatalError as o, realPathOf as p, isViteConfigTemp as r, formatDiagnostic as s, createOutputRecord as t, getFilenames as u };
|
|
5645
6925
|
|
|
5646
|
-
//# sourceMappingURL=paths-
|
|
6926
|
+
//# sourceMappingURL=paths-CUv1qfz-.js.map
|