@rohal12/twee-ts 1.17.1 → 1.18.0
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 +1914 -580
- package/dist/bin/twee-ts.js.map +1 -1
- package/dist/index.cjs +1911 -562
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +139 -52
- package/dist/index.d.ts +139 -52
- package/dist/index.js +1912 -564
- package/dist/index.js.map +1 -1
- package/dist/{paths-w4ulhCFb.js → paths-DgO9oo6G.js} +1634 -485
- package/dist/paths-DgO9oo6G.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
package/dist/index.cjs
CHANGED
|
@@ -21,10 +21,10 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
21
21
|
enumerable: true
|
|
22
22
|
}) : target, mod));
|
|
23
23
|
//#endregion
|
|
24
|
-
let node_fs = require("node:fs");
|
|
25
24
|
let node_path = require("node:path");
|
|
26
25
|
node_path = __toESM(node_path, 1);
|
|
27
26
|
let node_crypto = require("node:crypto");
|
|
27
|
+
let node_fs = require("node:fs");
|
|
28
28
|
let node_os = require("node:os");
|
|
29
29
|
let node_url = require("node:url");
|
|
30
30
|
//#region src/ifid.ts
|
|
@@ -136,18 +136,44 @@ function tweeEscape(s) {
|
|
|
136
136
|
if (s.length === 0) return s;
|
|
137
137
|
return s.replace(/[\\[\]{}]/g, (ch) => "\\" + ch);
|
|
138
138
|
}
|
|
139
|
-
|
|
139
|
+
const JS_STRING_ESCAPES = {
|
|
140
|
+
"\\": "\\\\",
|
|
141
|
+
"\"": "\\\"",
|
|
142
|
+
"'": "\\'",
|
|
143
|
+
"\n": "\\n",
|
|
144
|
+
"\r": "\\r",
|
|
145
|
+
" ": "\\t",
|
|
146
|
+
"<": "\\x3C",
|
|
147
|
+
"\u2028": "\\u2028",
|
|
148
|
+
"\u2029": "\\u2029"
|
|
149
|
+
};
|
|
150
|
+
/**
|
|
151
|
+
* Escape a string for the inside of a single- or double-quoted JavaScript string literal, including one written
|
|
152
|
+
* into an HTML `<script>` element. Besides backslashes, quotes and line breaks (with U+2028 and U+2029, which end
|
|
153
|
+
* a string literal in JavaScript before ES2019), every `<` becomes `\x3C`, so the literal holds no `<\/script`
|
|
154
|
+
* that would end the element and no `<!--` or `<script` that would change where it ends. The string's value is
|
|
155
|
+
* unchanged.
|
|
156
|
+
*/
|
|
140
157
|
function jsStringEscape(s) {
|
|
141
158
|
if (s.length === 0) return s;
|
|
142
|
-
return s.replace(
|
|
159
|
+
return s.replace(/[\\"'\n\r\t<\u2028\u2029]/g, (ch) => JS_STRING_ESCAPES[ch] ?? ch);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Escape a string for the inside of a double-quoted CSS string (such as a `font-family` name): `\` and `"` get
|
|
163
|
+
* a backslash, and line breaks and other control characters become hex escapes followed by a space (`\a ` for a
|
|
164
|
+
* line feed), since a raw line break ends a CSS string and a backslash before one is a line continuation.
|
|
165
|
+
*/
|
|
166
|
+
function cssStringEscape(s) {
|
|
167
|
+
if (s.length === 0) return s;
|
|
168
|
+
return s.replace(/[\\"\u0000-\u001f\u007f]/g, (ch) => ch === "\\" || ch === "\"" ? `\\${ch}` : `\\${ch.charCodeAt(0).toString(16)} `);
|
|
143
169
|
}
|
|
144
170
|
/** Sanitize a string for safe inclusion in a CSS or JS block comment. */
|
|
145
171
|
function commentSanitize(s) {
|
|
146
172
|
return s.replace(/\*\//g, "* /");
|
|
147
173
|
}
|
|
148
|
-
/** Sanitize a string for safe inclusion in an HTML comment. */
|
|
174
|
+
/** Sanitize a string for safe inclusion in an HTML comment: break up `-->` and `--!>`, which both end one. */
|
|
149
175
|
function htmlCommentSanitize(s) {
|
|
150
|
-
return s.replace(
|
|
176
|
+
return s.replace(/--(!?)>/g, "--$1 >");
|
|
151
177
|
}
|
|
152
178
|
/**
|
|
153
179
|
* Escape JavaScript for the content of an HTML `<script>` element, so that the HTML parser reads all of it as
|
|
@@ -327,7 +353,19 @@ function passageToPassagedata(p, pid, options) {
|
|
|
327
353
|
if (options?.sourceInfo && p.source) attrs += ` data-source-file=${quote(attrEscape(p.source.file))} data-source-line="${p.source.line}"`;
|
|
328
354
|
return `${attrs}>${htmlEscape(p.text)}</tw-passagedata>`;
|
|
329
355
|
}
|
|
330
|
-
/**
|
|
356
|
+
/**
|
|
357
|
+
* Whether Twine 1 `obfuscate:rot13` encodes a tiddler: every one but `StorySettings` and those tagged `Twine.image`
|
|
358
|
+
* (Twine 1.4 `Tiddler.isObfuscateable()`). Its engine tests the stored name and tags, which these tiddlers keep
|
|
359
|
+
* unencoded, so the decompiler tests them the same way.
|
|
360
|
+
*/
|
|
361
|
+
function isObfuscatable(p) {
|
|
362
|
+
return p.name !== "StorySettings" && !p.tags.includes("Twine.image");
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Generate `<div tiddler>` HTML for Twine 1. With `obfuscateRot13`, an obfuscatable tiddler (see
|
|
366
|
+
* `isObfuscatable()`) has its name, each tag and its text ROT13-encoded, as Twine 1.4 writes it
|
|
367
|
+
* (`Tiddler.toHtml()`), and as its engine.js decodes it.
|
|
368
|
+
*/
|
|
331
369
|
function passageToTiddler(p, pid, obfuscateRot13 = false) {
|
|
332
370
|
let position;
|
|
333
371
|
if (hasMetadataPosition(p)) position = p.metadata?.position ?? "";
|
|
@@ -339,9 +377,10 @@ function passageToTiddler(p, pid, obfuscateRot13 = false) {
|
|
|
339
377
|
position = `${xp * 140 - 130},${yp * 140 - 130}`;
|
|
340
378
|
}
|
|
341
379
|
const created = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 12);
|
|
342
|
-
const
|
|
343
|
-
const
|
|
344
|
-
|
|
380
|
+
const encode = obfuscateRot13 && isObfuscatable(p) ? rot13 : (s) => s;
|
|
381
|
+
const name = attrEscape(encode(p.name));
|
|
382
|
+
const tags = attrEscape(p.tags.map(encode).join(" "));
|
|
383
|
+
return `<div tiddler=${quote(name)} tags=${quote(tags)} created=${quote(created)} modifier=${quote("twee")} twine-position=${quote(attrEscape(position))}>${tiddlerEscape(encode(p.text))}</div>`;
|
|
345
384
|
}
|
|
346
385
|
/**
|
|
347
386
|
* Count words in a passage.
|
|
@@ -394,19 +433,136 @@ function quote(s) {
|
|
|
394
433
|
}
|
|
395
434
|
//#endregion
|
|
396
435
|
//#region src/story.ts
|
|
397
|
-
/** Module-level index: maps passage names to their array indices for O(1) lookups. */
|
|
398
436
|
const passageIndex = /* @__PURE__ */ new WeakMap();
|
|
399
|
-
function
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
437
|
+
function buildIndex(story) {
|
|
438
|
+
const names = story.passages.map((p) => p.name);
|
|
439
|
+
const index = {
|
|
440
|
+
passages: story.passages,
|
|
441
|
+
names,
|
|
442
|
+
positions: new Map(names.map((name, i) => [name, i]))
|
|
443
|
+
};
|
|
444
|
+
passageIndex.set(story, index);
|
|
445
|
+
return index;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* The index, rebuilt if `story.passages` was reassigned or changed length. O(1); `position()`
|
|
449
|
+
* also checks every hit. A change that keeps the length, such as replacing an element, can still
|
|
450
|
+
* hide a passage from a lookup, so `storyVerifyIndex()` checks every position.
|
|
451
|
+
*/
|
|
452
|
+
function currentIndex(story) {
|
|
453
|
+
const index = passageIndex.get(story);
|
|
454
|
+
return index !== void 0 && index.passages === story.passages && index.names.length === story.passages.length ? index : buildIndex(story);
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Rebuild the index unless it matches `story.passages` at every position. O(n): for entry points
|
|
458
|
+
* that run after code outside the compiler may have changed the passages, such as StoryBuilder.
|
|
459
|
+
*/
|
|
460
|
+
function storyVerifyIndex(story) {
|
|
461
|
+
const index = currentIndex(story);
|
|
462
|
+
if (!story.passages.every((p, i) => p.name === index.names[i])) buildIndex(story);
|
|
463
|
+
}
|
|
464
|
+
/** The position of the passage named `name`, or -1. */
|
|
465
|
+
function position(story, name) {
|
|
466
|
+
const i = currentIndex(story).positions.get(name);
|
|
467
|
+
if (i === void 0) return -1;
|
|
468
|
+
if (story.passages[i]?.name === name) return i;
|
|
469
|
+
return buildIndex(story).positions.get(name) ?? -1;
|
|
470
|
+
}
|
|
471
|
+
/** Add a passage whose name no passage has yet at the end. */
|
|
472
|
+
function push(story, p) {
|
|
473
|
+
const index = currentIndex(story);
|
|
474
|
+
index.positions.set(p.name, story.passages.length);
|
|
475
|
+
index.names.push(p.name);
|
|
476
|
+
story.passages.push(p);
|
|
477
|
+
}
|
|
478
|
+
/** Put a passage at position `i`, in place of the passage there. */
|
|
479
|
+
function replaceAt(story, i, p) {
|
|
480
|
+
const renamed = story.passages[i]?.name !== p.name;
|
|
481
|
+
story.passages[i] = p;
|
|
482
|
+
if (renamed) buildIndex(story);
|
|
483
|
+
}
|
|
484
|
+
/** Names of compiler special passages, which storyAdd() reads into the story model. */
|
|
485
|
+
const SPECIAL_PASSAGE_NAMES = /* @__PURE__ */ new Set([
|
|
486
|
+
"StoryData",
|
|
487
|
+
"StoryIncludes",
|
|
488
|
+
"StorySettings",
|
|
489
|
+
"StoryTitle"
|
|
490
|
+
]);
|
|
491
|
+
/** Passages whose names were generated, kept by identity so that cached passages keep the mark. */
|
|
492
|
+
const generatedNames = /* @__PURE__ */ new WeakMap();
|
|
493
|
+
/**
|
|
494
|
+
* Mark a passage's name as generated (from a file name, or for imported story code) and return
|
|
495
|
+
* the passage. `origin.base` is the name that free names are numbered from: `base`, `base 2`, ….
|
|
496
|
+
*/
|
|
497
|
+
function withGeneratedName(p, origin) {
|
|
498
|
+
generatedNames.set(p, origin);
|
|
499
|
+
return p;
|
|
500
|
+
}
|
|
501
|
+
/** The first of `base`, `base 2`, `base 3`, … that `isFree` accepts. */
|
|
502
|
+
function freeName(base, isFree) {
|
|
503
|
+
if (isFree(base)) return base;
|
|
504
|
+
for (let n = 2;; n++) {
|
|
505
|
+
const candidate = `${base} ${n}`;
|
|
506
|
+
if (isFree(candidate)) return candidate;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
/** Whether a generated name may take `name`: no passage has it and it is no special name. */
|
|
510
|
+
function isFreeForGenerated(story, name) {
|
|
511
|
+
return !SPECIAL_PASSAGE_NAMES.has(name) && position(story, name) === -1;
|
|
512
|
+
}
|
|
513
|
+
function reportRename(origin, from, to, diagnostics) {
|
|
514
|
+
switch (origin.kind) {
|
|
515
|
+
case "code": return;
|
|
516
|
+
case "media": {
|
|
517
|
+
const reason = SPECIAL_PASSAGE_NAMES.has(from) ? `"${from}" is a compiler special passage name` : `another passage has the name "${from}"`;
|
|
518
|
+
diagnostics.push({
|
|
519
|
+
level: "warning",
|
|
520
|
+
message: `Passage "${from}" from "${origin.file}" renamed to "${to}"; ${reason}.`
|
|
521
|
+
});
|
|
522
|
+
return;
|
|
406
523
|
}
|
|
407
|
-
|
|
524
|
+
default: throw new Error(`unhandled generated name: ${JSON.stringify(origin)}`);
|
|
408
525
|
}
|
|
409
|
-
|
|
526
|
+
}
|
|
527
|
+
/** Add a passage with a generated name, under the first free name if its own is taken. */
|
|
528
|
+
function addGenerated(story, p, origin, diagnostics) {
|
|
529
|
+
if (isFreeForGenerated(story, p.name)) {
|
|
530
|
+
push(story, p);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const name = freeName(origin.base, (n) => isFreeForGenerated(story, n));
|
|
534
|
+
reportRename(origin, p.name, name, diagnostics);
|
|
535
|
+
push(story, withGeneratedName({
|
|
536
|
+
...p,
|
|
537
|
+
name
|
|
538
|
+
}, origin));
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* If a passage with a generated name has `name`, give it the first free name, in its place in the
|
|
542
|
+
* passage list, so that a passage from the sources can take the name.
|
|
543
|
+
*/
|
|
544
|
+
function moveGeneratedAside(story, name, diagnostics) {
|
|
545
|
+
const i = position(story, name);
|
|
546
|
+
const existing = i === -1 ? void 0 : story.passages[i];
|
|
547
|
+
const origin = existing === void 0 ? void 0 : generatedNames.get(existing);
|
|
548
|
+
if (existing === void 0 || origin === void 0) return;
|
|
549
|
+
const free = freeName(origin.base, (n) => isFreeForGenerated(story, n));
|
|
550
|
+
reportRename(origin, name, free, diagnostics);
|
|
551
|
+
replaceAt(story, i, withGeneratedName({
|
|
552
|
+
...existing,
|
|
553
|
+
name: free
|
|
554
|
+
}, origin));
|
|
555
|
+
}
|
|
556
|
+
function defaultTwine2Metadata() {
|
|
557
|
+
return {
|
|
558
|
+
format: "",
|
|
559
|
+
formatVersion: "",
|
|
560
|
+
options: /* @__PURE__ */ new Map(),
|
|
561
|
+
start: "",
|
|
562
|
+
tags: "",
|
|
563
|
+
tagColors: /* @__PURE__ */ new Map(),
|
|
564
|
+
zoom: 1
|
|
565
|
+
};
|
|
410
566
|
}
|
|
411
567
|
function createStory() {
|
|
412
568
|
const story = {
|
|
@@ -415,52 +571,37 @@ function createStory() {
|
|
|
415
571
|
passages: [],
|
|
416
572
|
legacyIFID: "",
|
|
417
573
|
twine1: { settings: /* @__PURE__ */ new Map() },
|
|
418
|
-
twine2:
|
|
419
|
-
format: "",
|
|
420
|
-
formatVersion: "",
|
|
421
|
-
options: /* @__PURE__ */ new Map(),
|
|
422
|
-
start: "",
|
|
423
|
-
tags: "",
|
|
424
|
-
tagColors: /* @__PURE__ */ new Map(),
|
|
425
|
-
zoom: 1
|
|
426
|
-
}
|
|
574
|
+
twine2: defaultTwine2Metadata()
|
|
427
575
|
};
|
|
428
|
-
|
|
576
|
+
buildIndex(story);
|
|
429
577
|
return story;
|
|
430
578
|
}
|
|
431
579
|
function storyHas(story, name) {
|
|
432
|
-
return
|
|
580
|
+
return position(story, name) !== -1;
|
|
433
581
|
}
|
|
434
582
|
function storyAppend(story, p, diagnostics) {
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
index.set(p.name, story.passages.length);
|
|
439
|
-
story.passages.push(p);
|
|
440
|
-
} else {
|
|
583
|
+
const i = position(story, p.name);
|
|
584
|
+
if (i === -1) push(story, p);
|
|
585
|
+
else {
|
|
441
586
|
diagnostics.push({
|
|
442
587
|
level: "warning",
|
|
443
588
|
message: `Replacing existing passage "${p.name}" with duplicate.`
|
|
444
589
|
});
|
|
445
|
-
story
|
|
590
|
+
replaceAt(story, i, p);
|
|
446
591
|
}
|
|
447
592
|
}
|
|
448
593
|
function storyPrepend(story, p, diagnostics) {
|
|
449
|
-
|
|
450
|
-
const i =
|
|
594
|
+
moveGeneratedAside(story, p.name, diagnostics);
|
|
595
|
+
const i = position(story, p.name);
|
|
451
596
|
if (i === -1) {
|
|
452
597
|
story.passages.unshift(p);
|
|
453
|
-
|
|
454
|
-
for (let j = 0; j < story.passages.length; j++) {
|
|
455
|
-
const passage = story.passages[j];
|
|
456
|
-
if (passage) index.set(passage.name, j);
|
|
457
|
-
}
|
|
598
|
+
buildIndex(story);
|
|
458
599
|
} else {
|
|
459
600
|
diagnostics.push({
|
|
460
601
|
level: "warning",
|
|
461
602
|
message: `Replacing existing passage "${p.name}" with duplicate.`
|
|
462
603
|
});
|
|
463
|
-
story
|
|
604
|
+
replaceAt(story, i, p);
|
|
464
605
|
}
|
|
465
606
|
}
|
|
466
607
|
function marshalStoryData(story) {
|
|
@@ -486,18 +627,20 @@ function unmarshalStoryData(story, json) {
|
|
|
486
627
|
}
|
|
487
628
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return "Cannot unmarshal \"StoryData\"; expected a JSON object";
|
|
488
629
|
const data = raw;
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
if (typeof data
|
|
630
|
+
const twine2 = defaultTwine2Metadata();
|
|
631
|
+
story.ifid = typeof data.ifid === "string" ? normalizeIFID(data.ifid) : "";
|
|
632
|
+
if (typeof data.format === "string") twine2.format = data.format;
|
|
633
|
+
if (typeof data["format-version"] === "string") twine2.formatVersion = data["format-version"];
|
|
492
634
|
if (Array.isArray(data.options)) {
|
|
493
|
-
for (const opt of data.options) if (typeof opt === "string")
|
|
635
|
+
for (const opt of data.options) if (typeof opt === "string") twine2.options.set(opt, true);
|
|
494
636
|
}
|
|
495
|
-
if (typeof data.start === "string"
|
|
496
|
-
if (typeof data.tags === "string")
|
|
637
|
+
if (typeof data.start === "string") twine2.start = data.start;
|
|
638
|
+
if (typeof data.tags === "string") twine2.tags = data.tags;
|
|
497
639
|
if (typeof data["tag-colors"] === "object" && data["tag-colors"] !== null && !Array.isArray(data["tag-colors"])) {
|
|
498
|
-
for (const [tag, color] of Object.entries(data["tag-colors"])) if (typeof color === "string")
|
|
640
|
+
for (const [tag, color] of Object.entries(data["tag-colors"])) if (typeof color === "string") twine2.tagColors.set(tag, color);
|
|
499
641
|
}
|
|
500
|
-
if (typeof data.zoom === "number" && data.zoom !== 0)
|
|
642
|
+
if (typeof data.zoom === "number" && data.zoom !== 0) twine2.zoom = data.zoom;
|
|
643
|
+
story.twine2 = twine2;
|
|
501
644
|
return null;
|
|
502
645
|
}
|
|
503
646
|
function unmarshalStorySettings(story, text, diagnostics) {
|
|
@@ -538,8 +681,20 @@ function unmarshalStorySettings(story, text, diagnostics) {
|
|
|
538
681
|
/**
|
|
539
682
|
* Process a passage and add it to the story, handling special passages.
|
|
540
683
|
* Creates new passage objects where text is modified rather than mutating the input.
|
|
684
|
+
*
|
|
685
|
+
* A passage replaces an earlier one with the same name, except where either name was generated
|
|
686
|
+
* (see `withGeneratedName()`): a generated name never replaces a passage and is never replaced.
|
|
687
|
+
* A passage with a generated name takes the first free name of `base`, `base 2`, … when its own
|
|
688
|
+
* is taken or is a compiler special name. A passage with a name from the sources that a generated
|
|
689
|
+
* name holds takes it over, and the generated one moves to the first free name in its place.
|
|
541
690
|
*/
|
|
542
691
|
function storyAdd(story, p, diagnostics) {
|
|
692
|
+
const origin = generatedNames.get(p);
|
|
693
|
+
if (origin !== void 0) {
|
|
694
|
+
addGenerated(story, p, origin, diagnostics);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
moveGeneratedAside(story, p.name, diagnostics);
|
|
543
698
|
let processed = p;
|
|
544
699
|
switch (p.name) {
|
|
545
700
|
case "StoryIncludes":
|
|
@@ -599,7 +754,9 @@ function getStoryStats(story, wordCountMethod = "tweego") {
|
|
|
599
754
|
}
|
|
600
755
|
/**
|
|
601
756
|
* Builder that separates the mutable construction phase from the immutable consumption phase.
|
|
602
|
-
* During construction, the internal `Story` is mutable via `add()` and direct access
|
|
757
|
+
* During construction, the internal `Story` is mutable via `add()` and direct access: you may
|
|
758
|
+
* push, splice, sort, rename or reassign `story.passages` between calls, and `add()` and `has()`
|
|
759
|
+
* see the passages as they are then.
|
|
603
760
|
* After `build()`, the story is returned as `ReadonlyStory`.
|
|
604
761
|
*/
|
|
605
762
|
var StoryBuilder = class {
|
|
@@ -610,10 +767,13 @@ var StoryBuilder = class {
|
|
|
610
767
|
}
|
|
611
768
|
/** Add a passage, handling special passages (StoryData, StoryTitle, etc.). */
|
|
612
769
|
add(passage, diagnostics) {
|
|
770
|
+
if (generatedNames.has(passage) || !storyHas(this.story, passage.name)) storyVerifyIndex(this.story);
|
|
613
771
|
storyAdd(this.story, passage, diagnostics);
|
|
614
772
|
}
|
|
615
773
|
/** Check if a passage name exists. */
|
|
616
774
|
has(name) {
|
|
775
|
+
if (storyHas(this.story, name)) return true;
|
|
776
|
+
storyVerifyIndex(this.story);
|
|
617
777
|
return storyHas(this.story, name);
|
|
618
778
|
}
|
|
619
779
|
/** Finalize and return the story as read-only. */
|
|
@@ -738,131 +898,579 @@ function isExcluded(filename, exclude) {
|
|
|
738
898
|
const rel = (0, node_path.relative)(process.cwd(), (0, node_path.resolve)(filename)) || filename;
|
|
739
899
|
return exclude.some((pattern) => matchesGlob(rel, pattern.replace(/^\.\//, "")));
|
|
740
900
|
}
|
|
901
|
+
/** A build that writes nothing: `compile()`, or output to stdout. */
|
|
902
|
+
const NO_BUILD_OUTPUTS = {
|
|
903
|
+
files: [],
|
|
904
|
+
dirs: []
|
|
905
|
+
};
|
|
906
|
+
/** The outputs of a build that writes the one file `outFile`, or none without it. */
|
|
907
|
+
function toBuildOutputs(outputs) {
|
|
908
|
+
if (outputs === void 0) return NO_BUILD_OUTPUTS;
|
|
909
|
+
return typeof outputs === "string" ? {
|
|
910
|
+
files: [outputs],
|
|
911
|
+
dirs: []
|
|
912
|
+
} : outputs;
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* The path outputs are compared by: absolute, with every symbolic link in it
|
|
916
|
+
* resolved, so two paths that reach the same file through different links (a
|
|
917
|
+
* symlinked project folder, an alias of the output folder) compare equal. A
|
|
918
|
+
* path that doesn't exist yet, such as the output before the first build, is
|
|
919
|
+
* its nearest existing folder's real path joined with the rest.
|
|
920
|
+
*/
|
|
921
|
+
function realPathOf(path) {
|
|
922
|
+
const abs = (0, node_path.resolve)(path);
|
|
923
|
+
try {
|
|
924
|
+
return node_fs.realpathSync.native(abs);
|
|
925
|
+
} catch {}
|
|
926
|
+
const parent = (0, node_path.dirname)(abs);
|
|
927
|
+
return parent === abs ? abs : (0, node_path.join)(realPathOf(parent), (0, node_path.basename)(abs));
|
|
928
|
+
}
|
|
929
|
+
/** Whether the path `inner` is `outer` or inside it. */
|
|
930
|
+
function isSameOrInside(inner, outer) {
|
|
931
|
+
return inner === outer || inner.startsWith(outer.endsWith(node_path.sep) ? outer : outer + node_path.sep);
|
|
932
|
+
}
|
|
933
|
+
function outputPaths(outputs) {
|
|
934
|
+
const files = new Set(outputs.files.map(realPathOf));
|
|
935
|
+
const dirs = new Set(outputs.dirs.map(realPathOf));
|
|
936
|
+
const all = [...files, ...dirs];
|
|
937
|
+
return {
|
|
938
|
+
isFile: (real) => files.has(real),
|
|
939
|
+
isDir: (real) => dirs.has(real),
|
|
940
|
+
holds: (real) => dirs.has(real) || all.some((output) => output !== real && isSameOrInside(output, real)),
|
|
941
|
+
isOutput(path, roots) {
|
|
942
|
+
const real = realPathOf(path);
|
|
943
|
+
if (files.has(real)) return true;
|
|
944
|
+
const owners = [...dirs].filter((dir) => isSameOrInside(real, dir));
|
|
945
|
+
if (owners.length === 0) return false;
|
|
946
|
+
return roots.map(realPathOf).filter((root) => isSameOrInside(real, root)).every((root) => owners.some((dir) => dir !== root && isSameOrInside(dir, root)));
|
|
947
|
+
}
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* A folder entry as source discovery walks it, found at `pathname` with the real
|
|
952
|
+
* path `real` when it is no link: a link to a file counts as that file, and a
|
|
953
|
+
* link to anything else (a folder above all) as nothing, undefined. Throws when
|
|
954
|
+
* the entry or a link's target can't be read.
|
|
955
|
+
*/
|
|
956
|
+
function walkedEntry(pathname, real) {
|
|
957
|
+
const stat = (0, node_fs.lstatSync)(pathname);
|
|
958
|
+
if (!stat.isSymbolicLink()) return {
|
|
959
|
+
stat,
|
|
960
|
+
real
|
|
961
|
+
};
|
|
962
|
+
const target = (0, node_fs.statSync)(pathname);
|
|
963
|
+
return target.isFile() ? {
|
|
964
|
+
stat: target,
|
|
965
|
+
real: realPathOf(pathname)
|
|
966
|
+
} : void 0;
|
|
967
|
+
}
|
|
741
968
|
/**
|
|
742
969
|
* Recursively walk directories, collecting regular file paths.
|
|
743
|
-
*
|
|
744
|
-
*
|
|
970
|
+
*
|
|
971
|
+
* Leaves out what the build writes (`outputs`, compared by real path, see
|
|
972
|
+
* realPathOf) so its last output is never read back, and the files that match an
|
|
973
|
+
* `exclude` glob (see isExcluded). A path given that is itself an output file is
|
|
974
|
+
* not walked but listed in `outputSources`.
|
|
975
|
+
*
|
|
976
|
+
* Symbolic links: a path given is followed wherever it leads. Inside a folder, a
|
|
977
|
+
* link to a file is read, but a link to a folder is not followed, as Tweego's walk
|
|
978
|
+
* doesn't follow one. A link back to its own folder or a parent would otherwise
|
|
979
|
+
* walk the same files again at every depth.
|
|
980
|
+
*
|
|
745
981
|
* Like Tweego, a path that cannot be read is reported as a warning and skipped.
|
|
746
982
|
*/
|
|
747
|
-
function getFilenames(pathnames,
|
|
983
|
+
function getFilenames(pathnames, outputs, exclude = []) {
|
|
748
984
|
const filenames = [];
|
|
749
985
|
const diagnostics = [];
|
|
750
|
-
const
|
|
986
|
+
const outputSources = [];
|
|
987
|
+
const output = outputPaths(toBuildOutputs(outputs));
|
|
751
988
|
function warn(pathname, e) {
|
|
752
989
|
diagnostics.push({
|
|
753
990
|
level: "warning",
|
|
754
991
|
message: `path ${pathname}: ${e instanceof Error ? e.message : String(e)}`
|
|
755
992
|
});
|
|
756
993
|
}
|
|
757
|
-
function
|
|
994
|
+
function addFile(pathname) {
|
|
995
|
+
const abs = (0, node_path.resolve)(pathname);
|
|
996
|
+
const rel = (0, node_path.relative)(process.cwd(), abs);
|
|
997
|
+
if (isExcluded(rel || abs, exclude)) return;
|
|
998
|
+
filenames.push(rel || abs);
|
|
999
|
+
}
|
|
1000
|
+
function walkDir(pathname, real) {
|
|
1001
|
+
let entries;
|
|
1002
|
+
try {
|
|
1003
|
+
entries = (0, node_fs.readdirSync)(pathname);
|
|
1004
|
+
} catch (e) {
|
|
1005
|
+
warn(pathname, e);
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
for (const entry of entries) walkEntry((0, node_path.join)(pathname, entry), (0, node_path.join)(real, entry));
|
|
1009
|
+
}
|
|
1010
|
+
function walkEntry(pathname, real) {
|
|
1011
|
+
let entry;
|
|
1012
|
+
try {
|
|
1013
|
+
entry = walkedEntry(pathname, real);
|
|
1014
|
+
} catch (e) {
|
|
1015
|
+
warn(pathname, e);
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
if (entry === void 0) return;
|
|
1019
|
+
if (entry.stat.isFile()) {
|
|
1020
|
+
if (!output.isFile(entry.real)) addFile(pathname);
|
|
1021
|
+
} else if (entry.stat.isDirectory() && !output.isDir(entry.real)) walkDir(pathname, entry.real);
|
|
1022
|
+
}
|
|
1023
|
+
for (const pathname of pathnames) {
|
|
758
1024
|
let stat;
|
|
759
1025
|
try {
|
|
760
1026
|
stat = (0, node_fs.statSync)(pathname);
|
|
761
1027
|
} catch (e) {
|
|
762
1028
|
warn(pathname, e);
|
|
763
|
-
|
|
1029
|
+
continue;
|
|
764
1030
|
}
|
|
1031
|
+
const real = realPathOf(pathname);
|
|
765
1032
|
if (stat.isFile()) {
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
if (isExcluded(rel || abs, exclude)) return;
|
|
770
|
-
filenames.push(rel || abs);
|
|
771
|
-
} else if (stat.isDirectory()) {
|
|
772
|
-
let entries;
|
|
773
|
-
try {
|
|
774
|
-
entries = (0, node_fs.readdirSync)(pathname);
|
|
775
|
-
} catch (e) {
|
|
776
|
-
warn(pathname, e);
|
|
777
|
-
return;
|
|
778
|
-
}
|
|
779
|
-
for (const entry of entries) walk((0, node_path.join)(pathname, entry));
|
|
780
|
-
}
|
|
1033
|
+
if (output.isFile(real)) outputSources.push(pathname);
|
|
1034
|
+
else addFile(pathname);
|
|
1035
|
+
} else if (stat.isDirectory()) walkDir(pathname, real);
|
|
781
1036
|
}
|
|
782
|
-
for (const pathname of pathnames) walk(pathname);
|
|
783
1037
|
return {
|
|
784
1038
|
filenames,
|
|
785
|
-
diagnostics
|
|
1039
|
+
diagnostics,
|
|
1040
|
+
outputSources
|
|
786
1041
|
};
|
|
787
1042
|
}
|
|
788
|
-
/** Whether the absolute path `file` is the absolute path `dir` or inside it. */
|
|
789
|
-
function isInsideDir(file, dir) {
|
|
790
|
-
return file === dir || file.startsWith(dir.endsWith(node_path.sep) ? dir : dir + node_path.sep);
|
|
791
|
-
}
|
|
792
1043
|
/**
|
|
793
|
-
*
|
|
794
|
-
*
|
|
1044
|
+
* A watched path that can't be watched: fs.watch failed on it, or on the folder it is
|
|
1045
|
+
* watched through. watchFilesystem passes it to its `onError` and keeps watching the rest.
|
|
795
1046
|
*/
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
isFile = (0, node_fs.statSync)(pathname).isFile();
|
|
803
|
-
} catch {}
|
|
804
|
-
if (isFile) files.add((0, node_path.resolve)(pathname));
|
|
805
|
-
else dirs.push(pathname);
|
|
1047
|
+
var WatchPathError = class extends Error {
|
|
1048
|
+
path;
|
|
1049
|
+
constructor(path, cause) {
|
|
1050
|
+
super(`Cannot watch ${path}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
1051
|
+
this.path = path;
|
|
1052
|
+
this.name = "WatchPathError";
|
|
806
1053
|
}
|
|
1054
|
+
};
|
|
1055
|
+
function pathKind(path) {
|
|
1056
|
+
try {
|
|
1057
|
+
const stats = (0, node_fs.statSync)(path);
|
|
1058
|
+
return stats.isDirectory() ? {
|
|
1059
|
+
kind: "dir",
|
|
1060
|
+
id: `${stats.dev}:${stats.ino}`
|
|
1061
|
+
} : { kind: "file" };
|
|
1062
|
+
} catch {
|
|
1063
|
+
return { kind: "missing" };
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
function rootState(abs) {
|
|
807
1067
|
return {
|
|
808
|
-
|
|
809
|
-
|
|
1068
|
+
anchor: nearestFolderAbove(abs),
|
|
1069
|
+
self: pathKind(abs)
|
|
810
1070
|
};
|
|
811
1071
|
}
|
|
1072
|
+
function nearestFolderAbove(abs) {
|
|
1073
|
+
for (let dir = (0, node_path.dirname)(abs), below = abs; dir !== below; below = dir, dir = (0, node_path.dirname)(dir)) {
|
|
1074
|
+
const kind = pathKind(dir);
|
|
1075
|
+
if (kind.kind === "dir") return {
|
|
1076
|
+
path: dir,
|
|
1077
|
+
id: kind.id
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
/** Whether two states need the same watches: the same anchor folder, and the same watched folder if any. */
|
|
1082
|
+
function sameWatches(a, b) {
|
|
1083
|
+
const selfId = (s) => s.self.kind === "dir" ? s.self.id : void 0;
|
|
1084
|
+
return a.anchor?.path === b.anchor?.path && a.anchor?.id === b.anchor?.id && selfId(a) === selfId(b);
|
|
1085
|
+
}
|
|
812
1086
|
/**
|
|
813
1087
|
* Watch paths for changes, calling the build callback on known file type changes.
|
|
814
1088
|
* A folder is watched recursively; a file is watched on its own and counts
|
|
815
1089
|
* whatever its type (a head file, say). A change to a file `ignore` returns true
|
|
816
1090
|
* for (given the path relative to the working directory) schedules no build.
|
|
817
1091
|
* Uses debouncing to avoid rapid rebuilds.
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
1092
|
+
*
|
|
1093
|
+
* A watched path that doesn't exist yet is waited for, and one that is deleted, or renamed
|
|
1094
|
+
* away and replaced, is followed to the new folder or file at its path: each path is also
|
|
1095
|
+
* watched through the folder above it. When that sets up a path's watches again, the next
|
|
1096
|
+
* build is a full one (`changedFiles` undefined), since changes made in between went unseen.
|
|
1097
|
+
* A path that can't be watched is passed to `onError` as a WatchPathError, once while the
|
|
1098
|
+
* failure persists; the other paths are still watched.
|
|
1099
|
+
*/
|
|
1100
|
+
function watchFilesystem(pathnames, outFilename, callback, ignore = () => false, onError = () => {}) {
|
|
1101
|
+
const output = outputPaths(toBuildOutputs(outFilename));
|
|
822
1102
|
let buildTimer = null;
|
|
823
1103
|
const BUILD_DEBOUNCE = 500;
|
|
824
1104
|
const pendingFiles = /* @__PURE__ */ new Set();
|
|
1105
|
+
let pendingFullBuild = false;
|
|
1106
|
+
let closed = false;
|
|
825
1107
|
function scheduleBuild(changedFile) {
|
|
826
|
-
if (changedFile)
|
|
1108
|
+
if (changedFile === void 0) pendingFullBuild = true;
|
|
1109
|
+
else pendingFiles.add(changedFile);
|
|
827
1110
|
if (buildTimer) clearTimeout(buildTimer);
|
|
828
1111
|
buildTimer = setTimeout(() => {
|
|
829
1112
|
buildTimer = null;
|
|
830
|
-
const files = pendingFiles.size
|
|
1113
|
+
const files = pendingFullBuild || pendingFiles.size === 0 ? void 0 : new Set(pendingFiles);
|
|
831
1114
|
pendingFiles.clear();
|
|
1115
|
+
pendingFullBuild = false;
|
|
832
1116
|
callback(files);
|
|
833
1117
|
}, BUILD_DEBOUNCE);
|
|
834
1118
|
}
|
|
1119
|
+
const roots = pathnames.map((path) => {
|
|
1120
|
+
const abs = (0, node_path.resolve)(path);
|
|
1121
|
+
return {
|
|
1122
|
+
path,
|
|
1123
|
+
abs,
|
|
1124
|
+
state: rootState(abs),
|
|
1125
|
+
watchers: [],
|
|
1126
|
+
failed: false,
|
|
1127
|
+
lastReport: void 0
|
|
1128
|
+
};
|
|
1129
|
+
});
|
|
1130
|
+
const isNamedFile = (abs) => roots.some((r) => r.abs === abs && r.state.self.kind === "file");
|
|
835
1131
|
function fileChanged(abs, named) {
|
|
836
|
-
if (abs === absOutFile) return;
|
|
837
1132
|
if (!named && !isKnownFileType(abs)) return;
|
|
1133
|
+
if (output.isFile(realPathOf(abs))) return;
|
|
838
1134
|
const rel = (0, node_path.relative)(process.cwd(), abs);
|
|
839
1135
|
if (!ignore(rel || abs)) scheduleBuild(rel || abs);
|
|
840
1136
|
}
|
|
841
|
-
function
|
|
1137
|
+
function report(root, cause) {
|
|
1138
|
+
const error = new WatchPathError(root.path, cause);
|
|
1139
|
+
if (error.message === root.lastReport) return;
|
|
1140
|
+
root.lastReport = error.message;
|
|
1141
|
+
onError(error);
|
|
1142
|
+
}
|
|
1143
|
+
function startWatch(root, path, recursive, listener) {
|
|
842
1144
|
try {
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1145
|
+
if (recursive) (0, node_fs.accessSync)(path, node_fs.constants.R_OK | node_fs.constants.X_OK);
|
|
1146
|
+
const watcher = (0, node_fs.watch)(path, { recursive }, (_event, filename) => {
|
|
1147
|
+
if (!closed) listener(filename ?? "");
|
|
1148
|
+
});
|
|
1149
|
+
watcher.on("error", (e) => {
|
|
1150
|
+
watcher.close();
|
|
1151
|
+
root.watchers = root.watchers.filter((w) => w !== watcher);
|
|
1152
|
+
root.failed = true;
|
|
1153
|
+
if (!closed && !recheck(root, false)) report(root, e);
|
|
1154
|
+
});
|
|
1155
|
+
root.watchers.push(watcher);
|
|
1156
|
+
} catch (e) {
|
|
1157
|
+
root.failed = true;
|
|
1158
|
+
report(root, e);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
function arm(root) {
|
|
1162
|
+
for (const w of root.watchers) w.close();
|
|
1163
|
+
root.watchers = [];
|
|
1164
|
+
root.failed = false;
|
|
1165
|
+
const { anchor, self } = root.state;
|
|
1166
|
+
if (anchor) startWatch(root, anchor.path, false, (filename) => {
|
|
1167
|
+
const before = root.state.self.kind;
|
|
1168
|
+
recheck(root, true);
|
|
1169
|
+
if ((before === "file" || isNamedFile(root.abs)) && filename !== "" && (0, node_path.resolve)(anchor.path, filename) === root.abs) fileChanged(root.abs, true);
|
|
1170
|
+
});
|
|
1171
|
+
if (self.kind === "dir") startWatch(root, root.path, true, (filename) => {
|
|
1172
|
+
if (filename === "") recheck(root, true);
|
|
1173
|
+
else {
|
|
1174
|
+
const abs = (0, node_path.resolve)(root.abs, filename);
|
|
1175
|
+
fileChanged(abs, isNamedFile(abs));
|
|
1176
|
+
}
|
|
1177
|
+
});
|
|
1178
|
+
if (!root.failed) root.lastReport = void 0;
|
|
847
1179
|
}
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
const
|
|
857
|
-
|
|
858
|
-
|
|
1180
|
+
/**
|
|
1181
|
+
* Sets up `root`'s watches again when its path, or the folder above it, is no longer the
|
|
1182
|
+
* one they were started on (or, with `retry`, when one of them failed), and then schedules a
|
|
1183
|
+
* full build. Returns whether its state changed.
|
|
1184
|
+
*/
|
|
1185
|
+
function recheck(root, retry) {
|
|
1186
|
+
const before = root.state;
|
|
1187
|
+
const after = rootState(root.abs);
|
|
1188
|
+
const changed = !sameWatches(before, after);
|
|
1189
|
+
const wasFailed = root.failed;
|
|
1190
|
+
if (!changed && !(retry && wasFailed)) {
|
|
1191
|
+
root.state = after;
|
|
1192
|
+
return false;
|
|
1193
|
+
}
|
|
1194
|
+
root.state = after;
|
|
1195
|
+
arm(root);
|
|
1196
|
+
const recovered = wasFailed && !root.failed;
|
|
1197
|
+
if ((changed || recovered) && (before.self.kind !== "missing" || after.self.kind !== "missing")) scheduleBuild();
|
|
1198
|
+
return changed;
|
|
1199
|
+
}
|
|
1200
|
+
for (const root of roots) arm(root);
|
|
859
1201
|
callback();
|
|
860
1202
|
return { close() {
|
|
1203
|
+
closed = true;
|
|
861
1204
|
if (buildTimer) clearTimeout(buildTimer);
|
|
862
|
-
for (const w of watchers) w.close();
|
|
1205
|
+
for (const root of roots) for (const w of root.watchers) w.close();
|
|
863
1206
|
} };
|
|
864
1207
|
}
|
|
865
1208
|
//#endregion
|
|
1209
|
+
//#region src/relaxed-json.ts
|
|
1210
|
+
/**
|
|
1211
|
+
* A parser for the object literal a Twine 2 `format.js` passes to `window.storyFormat()`.
|
|
1212
|
+
*
|
|
1213
|
+
* The story formats spec does not require that object to be strict JSON, and Twine 2 runs
|
|
1214
|
+
* `format.js` as JavaScript. This parser accepts JSON plus the JavaScript literal syntax formats
|
|
1215
|
+
* use in practice: single-quoted strings, unquoted (identifier) and numeric keys, trailing commas,
|
|
1216
|
+
* comments, JavaScript string escapes, and hexadecimal, octal and binary numbers.
|
|
1217
|
+
*
|
|
1218
|
+
* It reads string literals token by token, so their contents come back exactly as JavaScript
|
|
1219
|
+
* evaluation gives them: text that only looks like structure (`a, }`, `word:`, an apostrophe) is
|
|
1220
|
+
* never touched. Anything outside that subset, such as a function or a variable, is a syntax error.
|
|
1221
|
+
*/
|
|
1222
|
+
const IDENTIFIER = /[A-Za-z_$][\w$]*/y;
|
|
1223
|
+
const NUMBER = /[+-]?(?:0[xX][0-9a-fA-F]+|0[oO][0-7]+|0[bB][01]+|(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)/y;
|
|
1224
|
+
const IDENTIFIER_PART = /[\w$]/;
|
|
1225
|
+
const WHITESPACE = /[\s]/;
|
|
1226
|
+
const HEX = /^[0-9a-fA-F]+$/;
|
|
1227
|
+
/** Simple single-character escapes and the characters they stand for. */
|
|
1228
|
+
const SIMPLE_ESCAPES = {
|
|
1229
|
+
b: "\b",
|
|
1230
|
+
f: "\f",
|
|
1231
|
+
n: "\n",
|
|
1232
|
+
r: "\r",
|
|
1233
|
+
t: " ",
|
|
1234
|
+
v: "\v"
|
|
1235
|
+
};
|
|
1236
|
+
/**
|
|
1237
|
+
* Parse a JavaScript object, array or primitive literal: all of `text`, or the part from `start` to
|
|
1238
|
+
* `end`. Error positions count from the start of `text`, so they point into the file it came from.
|
|
1239
|
+
*
|
|
1240
|
+
* @throws SyntaxError naming the line and column of the first thing it cannot parse.
|
|
1241
|
+
*/
|
|
1242
|
+
function parseRelaxedJSON(text, start = 0, end = text.length) {
|
|
1243
|
+
let pos = start;
|
|
1244
|
+
function fail(message, at = pos) {
|
|
1245
|
+
const before = text.slice(0, at);
|
|
1246
|
+
const line = before.split("\n").length;
|
|
1247
|
+
const column = at - before.lastIndexOf("\n");
|
|
1248
|
+
throw new SyntaxError(`${message} at line ${line}, column ${column}`);
|
|
1249
|
+
}
|
|
1250
|
+
/** The character at `pos`, or '' at the end of the input. */
|
|
1251
|
+
const peek = () => pos < end ? text[pos] ?? "" : "";
|
|
1252
|
+
const describeChar = (at) => at >= end ? "end of input" : JSON.stringify(text[at]);
|
|
1253
|
+
/** Skip whitespace and comments. */
|
|
1254
|
+
const skipTrivia = () => {
|
|
1255
|
+
while (pos < end) {
|
|
1256
|
+
const ch = peek();
|
|
1257
|
+
if (WHITESPACE.test(ch)) pos++;
|
|
1258
|
+
else if (text.startsWith("//", pos)) {
|
|
1259
|
+
const lineEnd = text.slice(pos, end).search(/[\n\r\u2028\u2029]/);
|
|
1260
|
+
pos = lineEnd === -1 ? end : pos + lineEnd;
|
|
1261
|
+
} else if (text.startsWith("/*", pos)) {
|
|
1262
|
+
const commentEnd = text.indexOf("*/", pos + 2);
|
|
1263
|
+
if (commentEnd === -1 || commentEnd + 2 > end) fail("Unterminated comment");
|
|
1264
|
+
pos = commentEnd + 2;
|
|
1265
|
+
} else return;
|
|
1266
|
+
}
|
|
1267
|
+
};
|
|
1268
|
+
const consume = (ch) => {
|
|
1269
|
+
skipTrivia();
|
|
1270
|
+
if (peek() !== ch) fail(`Expected ${JSON.stringify(ch)} but found ${describeChar(pos)}`);
|
|
1271
|
+
pos++;
|
|
1272
|
+
};
|
|
1273
|
+
/** Read exactly `count` hex digits and return their value. */
|
|
1274
|
+
const readHex = (count) => {
|
|
1275
|
+
const digits = text.slice(pos, Math.min(pos + count, end));
|
|
1276
|
+
if (digits.length !== count || !HEX.test(digits)) fail("Invalid hexadecimal escape sequence");
|
|
1277
|
+
pos += count;
|
|
1278
|
+
return parseInt(digits, 16);
|
|
1279
|
+
};
|
|
1280
|
+
/** Decode the escape sequence after a backslash; `pos` is just past the backslash. */
|
|
1281
|
+
const readEscape = () => {
|
|
1282
|
+
const ch = peek();
|
|
1283
|
+
const simple = SIMPLE_ESCAPES[ch];
|
|
1284
|
+
if (simple !== void 0) {
|
|
1285
|
+
pos++;
|
|
1286
|
+
return simple;
|
|
1287
|
+
}
|
|
1288
|
+
switch (ch) {
|
|
1289
|
+
case "": return fail("Unterminated string");
|
|
1290
|
+
case "x":
|
|
1291
|
+
pos++;
|
|
1292
|
+
return String.fromCharCode(readHex(2));
|
|
1293
|
+
case "u": {
|
|
1294
|
+
pos++;
|
|
1295
|
+
if (peek() !== "{") return String.fromCharCode(readHex(4));
|
|
1296
|
+
const close = text.indexOf("}", pos);
|
|
1297
|
+
if (close === -1 || close >= end) fail("Invalid Unicode escape sequence");
|
|
1298
|
+
pos++;
|
|
1299
|
+
const codePoint = readHex(close - pos);
|
|
1300
|
+
if (codePoint > 1114111) fail("Undefined Unicode code-point");
|
|
1301
|
+
pos = close + 1;
|
|
1302
|
+
return String.fromCodePoint(codePoint);
|
|
1303
|
+
}
|
|
1304
|
+
case "\r":
|
|
1305
|
+
pos += text[pos + 1] === "\n" ? 2 : 1;
|
|
1306
|
+
return "";
|
|
1307
|
+
case "\n":
|
|
1308
|
+
case "\u2028":
|
|
1309
|
+
case "\u2029":
|
|
1310
|
+
pos++;
|
|
1311
|
+
return "";
|
|
1312
|
+
case "0":
|
|
1313
|
+
if (/[0-9]/.test(text[pos + 1] ?? "")) fail("Octal escape sequences are not supported");
|
|
1314
|
+
pos++;
|
|
1315
|
+
return "\0";
|
|
1316
|
+
default:
|
|
1317
|
+
if (/[1-9]/.test(ch)) fail("Octal escape sequences are not supported");
|
|
1318
|
+
pos++;
|
|
1319
|
+
return ch;
|
|
1320
|
+
}
|
|
1321
|
+
};
|
|
1322
|
+
const readString = () => {
|
|
1323
|
+
const quote = peek();
|
|
1324
|
+
pos++;
|
|
1325
|
+
let out = "";
|
|
1326
|
+
let chunkStart = pos;
|
|
1327
|
+
for (;;) {
|
|
1328
|
+
if (pos >= end) fail("Unterminated string");
|
|
1329
|
+
const ch = peek();
|
|
1330
|
+
if (ch === quote) {
|
|
1331
|
+
out += text.slice(chunkStart, pos);
|
|
1332
|
+
pos++;
|
|
1333
|
+
return out;
|
|
1334
|
+
}
|
|
1335
|
+
if (ch === "\n" || ch === "\r") fail("Unterminated string");
|
|
1336
|
+
if (ch === "\\") {
|
|
1337
|
+
out += text.slice(chunkStart, pos);
|
|
1338
|
+
pos++;
|
|
1339
|
+
out += readEscape();
|
|
1340
|
+
chunkStart = pos;
|
|
1341
|
+
} else pos++;
|
|
1342
|
+
}
|
|
1343
|
+
};
|
|
1344
|
+
/** Read a match of a sticky regular expression at `pos`, or return undefined. */
|
|
1345
|
+
const readToken = (pattern) => {
|
|
1346
|
+
pattern.lastIndex = pos;
|
|
1347
|
+
const m = pattern.exec(text);
|
|
1348
|
+
if (!m || pattern.lastIndex > end) return void 0;
|
|
1349
|
+
pos = pattern.lastIndex;
|
|
1350
|
+
return m[0];
|
|
1351
|
+
};
|
|
1352
|
+
const readNumber = () => {
|
|
1353
|
+
const tokenStart = pos;
|
|
1354
|
+
const token = readToken(NUMBER);
|
|
1355
|
+
if (token === void 0) return void 0;
|
|
1356
|
+
if (IDENTIFIER_PART.test(peek())) fail("Invalid number", tokenStart);
|
|
1357
|
+
return (token.startsWith("-") ? -1 : 1) * Number(/^[+-]/.test(token) ? token.slice(1) : token);
|
|
1358
|
+
};
|
|
1359
|
+
const readKey = () => {
|
|
1360
|
+
skipTrivia();
|
|
1361
|
+
const ch = peek();
|
|
1362
|
+
if (ch === "\"" || ch === "'") return readString();
|
|
1363
|
+
const identifier = readToken(IDENTIFIER);
|
|
1364
|
+
if (identifier !== void 0) return identifier;
|
|
1365
|
+
const number = readNumber();
|
|
1366
|
+
if (number !== void 0) return String(number);
|
|
1367
|
+
return fail(`Expected a property name but found ${describeChar(pos)}`);
|
|
1368
|
+
};
|
|
1369
|
+
const readObject = () => {
|
|
1370
|
+
pos++;
|
|
1371
|
+
const out = {};
|
|
1372
|
+
for (;;) {
|
|
1373
|
+
skipTrivia();
|
|
1374
|
+
if (peek() === "}") {
|
|
1375
|
+
pos++;
|
|
1376
|
+
return out;
|
|
1377
|
+
}
|
|
1378
|
+
const key = readKey();
|
|
1379
|
+
consume(":");
|
|
1380
|
+
Object.defineProperty(out, key, {
|
|
1381
|
+
value: readValue(),
|
|
1382
|
+
enumerable: true,
|
|
1383
|
+
writable: true,
|
|
1384
|
+
configurable: true
|
|
1385
|
+
});
|
|
1386
|
+
skipTrivia();
|
|
1387
|
+
if (peek() === ",") pos++;
|
|
1388
|
+
else if (peek() !== "}") fail(`Expected "," or "}" but found ${describeChar(pos)}`);
|
|
1389
|
+
}
|
|
1390
|
+
};
|
|
1391
|
+
const readArray = () => {
|
|
1392
|
+
pos++;
|
|
1393
|
+
const out = [];
|
|
1394
|
+
for (;;) {
|
|
1395
|
+
skipTrivia();
|
|
1396
|
+
if (peek() === "]") {
|
|
1397
|
+
pos++;
|
|
1398
|
+
return out;
|
|
1399
|
+
}
|
|
1400
|
+
out.push(readValue());
|
|
1401
|
+
skipTrivia();
|
|
1402
|
+
if (peek() === ",") pos++;
|
|
1403
|
+
else if (peek() !== "]") fail(`Expected "," or "]" but found ${describeChar(pos)}`);
|
|
1404
|
+
}
|
|
1405
|
+
};
|
|
1406
|
+
function readValue() {
|
|
1407
|
+
skipTrivia();
|
|
1408
|
+
const ch = peek();
|
|
1409
|
+
if (ch === "{") return readObject();
|
|
1410
|
+
if (ch === "[") return readArray();
|
|
1411
|
+
if (ch === "\"" || ch === "'") return readString();
|
|
1412
|
+
const tokenStart = pos;
|
|
1413
|
+
const identifier = readToken(IDENTIFIER);
|
|
1414
|
+
if (identifier !== void 0) {
|
|
1415
|
+
if (identifier === "true") return true;
|
|
1416
|
+
if (identifier === "false") return false;
|
|
1417
|
+
if (identifier === "null") return null;
|
|
1418
|
+
return fail(`Unexpected identifier ${JSON.stringify(identifier)}`, tokenStart);
|
|
1419
|
+
}
|
|
1420
|
+
const number = readNumber();
|
|
1421
|
+
if (number !== void 0) return number;
|
|
1422
|
+
return fail(`Unexpected ${describeChar(pos)}`);
|
|
1423
|
+
}
|
|
1424
|
+
const value = readValue();
|
|
1425
|
+
skipTrivia();
|
|
1426
|
+
if (pos < end) fail(`Unexpected ${describeChar(pos)} after the value`);
|
|
1427
|
+
return value;
|
|
1428
|
+
}
|
|
1429
|
+
//#endregion
|
|
1430
|
+
//#region src/semver.ts
|
|
1431
|
+
const IDENTIFIERS = String.raw`[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*`;
|
|
1432
|
+
const VERSION$1 = new RegExp(String.raw`^[vV]?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-(${IDENTIFIERS}))?(?:\+${IDENTIFIERS})?$`);
|
|
1433
|
+
const NUMERIC = /^\d+$/;
|
|
1434
|
+
/** Parse a version string, or return null when it is not a version. */
|
|
1435
|
+
function parseVersion(text) {
|
|
1436
|
+
const m = VERSION$1.exec(text);
|
|
1437
|
+
if (!m) return null;
|
|
1438
|
+
return {
|
|
1439
|
+
major: Number(m[1]),
|
|
1440
|
+
minor: Number(m[2] ?? 0),
|
|
1441
|
+
patch: Number(m[3] ?? 0),
|
|
1442
|
+
prerelease: m[4] === void 0 ? [] : m[4].split(".")
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
/** Compare two prerelease identifiers: numeric ones numerically and below alphanumeric ones, which compare as ASCII. */
|
|
1446
|
+
function compareIdentifiers(a, b) {
|
|
1447
|
+
const aNumeric = NUMERIC.test(a);
|
|
1448
|
+
const bNumeric = NUMERIC.test(b);
|
|
1449
|
+
if (aNumeric && bNumeric) return Math.sign(Number(a) - Number(b));
|
|
1450
|
+
if (aNumeric !== bNumeric) return aNumeric ? -1 : 1;
|
|
1451
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
1452
|
+
}
|
|
1453
|
+
/** Compare prerelease identifier lists. An empty list (a release) ranks above any prerelease. */
|
|
1454
|
+
function comparePrerelease(a, b) {
|
|
1455
|
+
if (a.length === 0 || b.length === 0) return Math.sign(b.length - a.length);
|
|
1456
|
+
const shared = Math.min(a.length, b.length);
|
|
1457
|
+
for (let i = 0; i < shared; i++) {
|
|
1458
|
+
const order = compareIdentifiers(a[i] ?? "", b[i] ?? "");
|
|
1459
|
+
if (order !== 0) return order;
|
|
1460
|
+
}
|
|
1461
|
+
return Math.sign(a.length - b.length);
|
|
1462
|
+
}
|
|
1463
|
+
/** Compare two versions by SemVer precedence: negative when `a` ranks lower, 0 when equal, positive when higher. */
|
|
1464
|
+
function compareVersions(a, b) {
|
|
1465
|
+
return Math.sign(a.major - b.major) || Math.sign(a.minor - b.minor) || Math.sign(a.patch - b.patch) || comparePrerelease(a.prerelease, b.prerelease);
|
|
1466
|
+
}
|
|
1467
|
+
/** Whether two version strings name the same version (prerelease included, build metadata ignored). */
|
|
1468
|
+
function sameVersion(a, b) {
|
|
1469
|
+
const left = parseVersion(a);
|
|
1470
|
+
const right = parseVersion(b);
|
|
1471
|
+
return left !== null && right !== null && compareVersions(left, right) === 0;
|
|
1472
|
+
}
|
|
1473
|
+
//#endregion
|
|
866
1474
|
//#region src/source-text.ts
|
|
867
1475
|
/**
|
|
868
1476
|
* Source text normalization shared by every input path (files, in-memory sources, `parseTwee`).
|
|
@@ -874,14 +1482,119 @@ function watchFilesystem(pathnames, outFilename, callback, ignore = () => false)
|
|
|
874
1482
|
function normalizeSourceText(text) {
|
|
875
1483
|
return (text.charCodeAt(0) === 65279 ? text.slice(1) : text).replace(/\r\n?/g, "\n");
|
|
876
1484
|
}
|
|
1485
|
+
/** Byte order marks at the start of a line, directly before a passage header's `::`. */
|
|
1486
|
+
const BOMS_BEFORE_HEADER = /(?<=^|\n)\uFEFF+(?=::)/g;
|
|
1487
|
+
/**
|
|
1488
|
+
* Normalize Twee source text: {@link normalizeSourceText}, and also remove byte order marks at the start of
|
|
1489
|
+
* a line that are directly followed by `::`. Concatenating files (`cat a.tw b.tw`) leaves the BOM of every
|
|
1490
|
+
* later file in front of its first passage header, which would otherwise not be recognised as a header.
|
|
1491
|
+
* A U+FEFF anywhere else is passage text and kept. Idempotent.
|
|
1492
|
+
*/
|
|
1493
|
+
function normalizeTweeSourceText(text) {
|
|
1494
|
+
return normalizeSourceText(text).replace(BOMS_BEFORE_HEADER, "");
|
|
1495
|
+
}
|
|
877
1496
|
//#endregion
|
|
878
1497
|
//#region src/util.ts
|
|
879
1498
|
/**
|
|
880
1499
|
* Shared file I/O utilities.
|
|
881
1500
|
*/
|
|
882
|
-
/**
|
|
883
|
-
|
|
884
|
-
|
|
1501
|
+
/** The charset assumed for text that is not valid UTF-8, as Tweego assumes it. */
|
|
1502
|
+
const FALLBACK_CHARSET = "windows-1252";
|
|
1503
|
+
/**
|
|
1504
|
+
* Throws on invalid UTF-8 instead of replacing bytes with U+FFFD. `ignoreBOM` keeps a leading BOM in the
|
|
1505
|
+
* text, as `readFileSync(file, 'utf-8')` did, so {@link normalizeSourceText} strips exactly one.
|
|
1506
|
+
*/
|
|
1507
|
+
const utf8Decoder = new TextDecoder("utf-8", {
|
|
1508
|
+
fatal: true,
|
|
1509
|
+
ignoreBOM: true
|
|
1510
|
+
});
|
|
1511
|
+
/**
|
|
1512
|
+
* The code points of the Windows-1252 bytes 0x80 to 0x9F, from the WHATWG Encoding Standard. The five bytes
|
|
1513
|
+
* Windows-1252 leaves undefined map to the C1 control with the same number, as the standard and Go's charmap
|
|
1514
|
+
* do. Every other byte is the code point with the same number.
|
|
1515
|
+
*
|
|
1516
|
+
* Decoded by hand, because Node's `TextDecoder('windows-1252')` decodes as ISO-8859-1 in some releases
|
|
1517
|
+
* (Node 22.18 turns 0x80 into U+0080 instead of €), and builds without ICU lack it.
|
|
1518
|
+
*/
|
|
1519
|
+
const WINDOWS_1252_C1 = [
|
|
1520
|
+
8364,
|
|
1521
|
+
129,
|
|
1522
|
+
8218,
|
|
1523
|
+
402,
|
|
1524
|
+
8222,
|
|
1525
|
+
8230,
|
|
1526
|
+
8224,
|
|
1527
|
+
8225,
|
|
1528
|
+
710,
|
|
1529
|
+
8240,
|
|
1530
|
+
352,
|
|
1531
|
+
8249,
|
|
1532
|
+
338,
|
|
1533
|
+
141,
|
|
1534
|
+
381,
|
|
1535
|
+
143,
|
|
1536
|
+
144,
|
|
1537
|
+
8216,
|
|
1538
|
+
8217,
|
|
1539
|
+
8220,
|
|
1540
|
+
8221,
|
|
1541
|
+
8226,
|
|
1542
|
+
8211,
|
|
1543
|
+
8212,
|
|
1544
|
+
732,
|
|
1545
|
+
8482,
|
|
1546
|
+
353,
|
|
1547
|
+
8250,
|
|
1548
|
+
339,
|
|
1549
|
+
157,
|
|
1550
|
+
382,
|
|
1551
|
+
376
|
|
1552
|
+
];
|
|
1553
|
+
function decodeWindows1252(bytes) {
|
|
1554
|
+
const chunks = [];
|
|
1555
|
+
for (let start = 0; start < bytes.length; start += 8192) {
|
|
1556
|
+
const codes = Array.from(bytes.subarray(start, start + 8192), (b) => b >= 128 && b <= 159 ? WINDOWS_1252_C1[b - 128] ?? b : b);
|
|
1557
|
+
chunks.push(String.fromCharCode(...codes));
|
|
1558
|
+
}
|
|
1559
|
+
return chunks.join("");
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* Decode bytes read from `filename` as UTF-8. Bytes that are not valid UTF-8 are decoded as Windows-1252
|
|
1563
|
+
* instead, with a warning naming the file, as Tweego does (`fileReadAllWithEncoding` in io.go), so a legacy
|
|
1564
|
+
* file keeps its non-ASCII characters instead of losing them to U+FFFD.
|
|
1565
|
+
*
|
|
1566
|
+
* The text is returned as decoded: a leading BOM and CR line endings are kept (see {@link normalizeSourceText}).
|
|
1567
|
+
*/
|
|
1568
|
+
function decodeText(bytes, filename) {
|
|
1569
|
+
try {
|
|
1570
|
+
return {
|
|
1571
|
+
text: utf8Decoder.decode(bytes),
|
|
1572
|
+
diagnostics: []
|
|
1573
|
+
};
|
|
1574
|
+
} catch {
|
|
1575
|
+
return {
|
|
1576
|
+
text: decodeWindows1252(bytes),
|
|
1577
|
+
diagnostics: [{
|
|
1578
|
+
level: "warning",
|
|
1579
|
+
message: `read ${filename}: Invalid UTF-8; assuming charset is ${FALLBACK_CHARSET}.`,
|
|
1580
|
+
file: filename
|
|
1581
|
+
}]
|
|
1582
|
+
};
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* Read a text file: decode it with {@link decodeText} (UTF-8, or Windows-1252 when it is not valid UTF-8),
|
|
1587
|
+
* strip a leading BOM and convert CRLF and bare CR line endings to LF. This is the one path every file read
|
|
1588
|
+
* as text goes through: sources, modules, the head file, story formats and the config file.
|
|
1589
|
+
*
|
|
1590
|
+
* @param diagnostics Receives the warning when the file is not valid UTF-8. Without it, the file is decoded
|
|
1591
|
+
* the same way and the warning is dropped.
|
|
1592
|
+
* @throws When the file cannot be read.
|
|
1593
|
+
*/
|
|
1594
|
+
function readUTF8(filename, diagnostics) {
|
|
1595
|
+
const decoded = decodeText((0, node_fs.readFileSync)(filename), filename);
|
|
1596
|
+
diagnostics?.push(...decoded.diagnostics);
|
|
1597
|
+
return normalizeSourceText(decoded.text);
|
|
885
1598
|
}
|
|
886
1599
|
/** Read a file as base64. */
|
|
887
1600
|
function readBase64(filename) {
|
|
@@ -897,20 +1610,11 @@ function baseNameWithoutExt(filename) {
|
|
|
897
1610
|
/**
|
|
898
1611
|
* Story format discovery, loading, and SemVer matching.
|
|
899
1612
|
* Ported from formats.go + config.go.
|
|
1613
|
+
*
|
|
1614
|
+
* Every lookup (local folders here, the download cache and remote indices in remote-formats.ts)
|
|
1615
|
+
* goes through the same version parsing ({@link parseVersion}) and the same matching rules
|
|
1616
|
+
* ({@link selectFormatCandidate}).
|
|
900
1617
|
*/
|
|
901
|
-
/**
|
|
902
|
-
* Attempt to fix non-strict JSON (trailing commas, single quotes, unquoted keys).
|
|
903
|
-
*/
|
|
904
|
-
function relaxedJSONParse(json) {
|
|
905
|
-
try {
|
|
906
|
-
return JSON.parse(json);
|
|
907
|
-
} catch {}
|
|
908
|
-
let fixed = json;
|
|
909
|
-
fixed = fixed.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, "\"$1\"");
|
|
910
|
-
fixed = fixed.replace(/(?<=^|[{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:/gm, "\"$1\":");
|
|
911
|
-
fixed = fixed.replace(/,\s*([}\]])/g, "$1");
|
|
912
|
-
return JSON.parse(fixed);
|
|
913
|
-
}
|
|
914
1618
|
/** A `"setup": function` property, which Harlowe appends to its otherwise-JSON format object. */
|
|
915
1619
|
const SETUP_FUNCTION_PROPERTY = /,\s*"setup"\s*:\s*function\b/g;
|
|
916
1620
|
/** Drop a trailing `"setup": function(){…}` property from a format object, if it has one. */
|
|
@@ -920,151 +1624,247 @@ function stripSetupFunction(chunk) {
|
|
|
920
1624
|
return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
|
|
921
1625
|
}
|
|
922
1626
|
/**
|
|
923
|
-
* Parse the
|
|
924
|
-
*
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
1627
|
+
* Parse the format object at `start`–`end` of `text`: strict JSON first (fast), then the
|
|
1628
|
+
* JavaScript literal subset formats use. Error positions point into `text`.
|
|
1629
|
+
*/
|
|
1630
|
+
function parseFormatObject(text, start, end) {
|
|
1631
|
+
try {
|
|
1632
|
+
return {
|
|
1633
|
+
ok: true,
|
|
1634
|
+
value: JSON.parse(text.slice(start, end))
|
|
1635
|
+
};
|
|
1636
|
+
} catch {}
|
|
1637
|
+
try {
|
|
1638
|
+
return {
|
|
1639
|
+
ok: true,
|
|
1640
|
+
value: parseRelaxedJSON(text, start, end)
|
|
1641
|
+
};
|
|
1642
|
+
} catch (e) {
|
|
1643
|
+
return {
|
|
1644
|
+
ok: false,
|
|
1645
|
+
error: e instanceof Error ? e.message : String(e)
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
/** Check a parsed format object's fields. Per spec, `name` is optional; `version` and `source` are required. */
|
|
1650
|
+
function toFormatJSON(raw) {
|
|
1651
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
|
|
1652
|
+
ok: false,
|
|
1653
|
+
reason: "Story format JSON chunk is not an object."
|
|
1654
|
+
};
|
|
1655
|
+
const obj = raw;
|
|
1656
|
+
if (typeof obj.version !== "string") return {
|
|
1657
|
+
ok: false,
|
|
1658
|
+
reason: "Story format has no \"version\" string."
|
|
1659
|
+
};
|
|
1660
|
+
if (typeof obj.source !== "string") return {
|
|
1661
|
+
ok: false,
|
|
1662
|
+
reason: "Story format has no \"source\" string."
|
|
1663
|
+
};
|
|
1664
|
+
if (!parseVersion(obj.version)) return {
|
|
1665
|
+
ok: false,
|
|
1666
|
+
reason: `Story format version ${JSON.stringify(obj.version)} is not a SemVer version.`
|
|
1667
|
+
};
|
|
1668
|
+
const data = {
|
|
1669
|
+
name: typeof obj.name === "string" ? obj.name : "Untitled Story Format",
|
|
1670
|
+
version: obj.version,
|
|
1671
|
+
source: obj.source,
|
|
1672
|
+
proofing: obj.proofing === true
|
|
1673
|
+
};
|
|
1674
|
+
if (typeof obj.author === "string") data.author = obj.author;
|
|
1675
|
+
if (typeof obj.description === "string") data.description = obj.description;
|
|
1676
|
+
if (typeof obj.image === "string") data.image = obj.image;
|
|
1677
|
+
if (typeof obj.url === "string") data.url = obj.url;
|
|
1678
|
+
if (typeof obj.license === "string") data.license = obj.license;
|
|
1679
|
+
return {
|
|
1680
|
+
ok: true,
|
|
1681
|
+
data
|
|
1682
|
+
};
|
|
1683
|
+
}
|
|
1684
|
+
/**
|
|
1685
|
+
* Read the metadata of a Twine 2 format.js, or say why it cannot be used.
|
|
928
1686
|
*
|
|
929
|
-
*
|
|
1687
|
+
* The object passed to `window.storyFormat()` may be strict JSON or a JavaScript object literal
|
|
1688
|
+
* (single quotes, unquoted keys, trailing commas, comments). String values come back exactly as
|
|
1689
|
+
* JavaScript evaluation gives them. Harlowe's function-valued `setup` property is dropped; the
|
|
1690
|
+
* workaround keys on the property itself, so the same bytes parse whether they come from a
|
|
1691
|
+
* `harlowe-3` directory or a direct download.
|
|
930
1692
|
*/
|
|
931
|
-
function
|
|
1693
|
+
function decodeFormatJSON(source) {
|
|
932
1694
|
const first = source.indexOf("{");
|
|
933
1695
|
const last = source.lastIndexOf("}");
|
|
934
|
-
if (first === -1 || last
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
const raw = relaxedJSONParse(json);
|
|
938
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
|
939
|
-
const obj = raw;
|
|
940
|
-
if (typeof obj.version !== "string" || typeof obj.source !== "string") return null;
|
|
941
|
-
if (!parseSemver(obj.version)) return null;
|
|
942
|
-
const result = {
|
|
943
|
-
name: typeof obj.name === "string" ? obj.name : "Untitled Story Format",
|
|
944
|
-
version: obj.version,
|
|
945
|
-
source: obj.source,
|
|
946
|
-
proofing: obj.proofing === true
|
|
947
|
-
};
|
|
948
|
-
if (typeof obj.author === "string") result.author = obj.author;
|
|
949
|
-
if (typeof obj.description === "string") result.description = obj.description;
|
|
950
|
-
if (typeof obj.image === "string") result.image = obj.image;
|
|
951
|
-
if (typeof obj.url === "string") result.url = obj.url;
|
|
952
|
-
if (typeof obj.license === "string") result.license = obj.license;
|
|
953
|
-
return result;
|
|
1696
|
+
if (first === -1 || last < first) return {
|
|
1697
|
+
ok: false,
|
|
1698
|
+
reason: "Could not find Twine 2 style story format JSON chunk."
|
|
954
1699
|
};
|
|
1700
|
+
const parsed = parseFormatObject(source, first, last + 1);
|
|
1701
|
+
if (parsed.ok) return toFormatJSON(parsed.value);
|
|
1702
|
+
const stripped = stripSetupFunction(source.slice(first, last + 1));
|
|
1703
|
+
const retried = stripped === void 0 ? void 0 : parseFormatObject(stripped, 0, stripped.length);
|
|
1704
|
+
if (retried?.ok) return toFormatJSON(retried.value);
|
|
1705
|
+
return {
|
|
1706
|
+
ok: false,
|
|
1707
|
+
reason: `Could not decode story format JSON chunk: ${parsed.error}`
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
/**
|
|
1711
|
+
* Parse the Twine 2 format.js JSON chunk; null when it cannot be used ({@link decodeFormatJSON}
|
|
1712
|
+
* gives the reason).
|
|
1713
|
+
*
|
|
1714
|
+
* @param _formatId No longer used; kept so existing callers keep compiling.
|
|
1715
|
+
*/
|
|
1716
|
+
function parseFormatJSON(source, _formatId) {
|
|
1717
|
+
const result = decodeFormatJSON(source);
|
|
1718
|
+
return result.ok ? result.data : null;
|
|
1719
|
+
}
|
|
1720
|
+
/** A directory's entries in a stable order, or none when it cannot be read. */
|
|
1721
|
+
function listDirectory(dir) {
|
|
955
1722
|
try {
|
|
956
|
-
return
|
|
1723
|
+
return (0, node_fs.readdirSync)(dir).sort();
|
|
957
1724
|
} catch {
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
1725
|
+
return [];
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
function isDirectory(path) {
|
|
1729
|
+
try {
|
|
1730
|
+
return (0, node_fs.statSync)(path).isDirectory();
|
|
1731
|
+
} catch {
|
|
1732
|
+
return false;
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
function isFile(path) {
|
|
1736
|
+
try {
|
|
1737
|
+
return (0, node_fs.statSync)(path).isFile();
|
|
1738
|
+
} catch {
|
|
1739
|
+
return false;
|
|
965
1740
|
}
|
|
966
1741
|
}
|
|
967
1742
|
/**
|
|
968
|
-
*
|
|
969
|
-
*
|
|
970
|
-
* only the highest minor.patch survives.
|
|
1743
|
+
* Load the format in one folder: its format.js (Twine 2), else its header.html (Twine 1).
|
|
1744
|
+
* A format.js that cannot be used is reported as a warning, as Tweego does.
|
|
971
1745
|
*/
|
|
972
|
-
function
|
|
973
|
-
const
|
|
974
|
-
|
|
975
|
-
for (const
|
|
1746
|
+
function loadFormatDir(searchDir, id, diagnostics) {
|
|
1747
|
+
const formatDir = (0, node_path.join)(searchDir, id);
|
|
1748
|
+
if (!isDirectory(formatDir)) return void 0;
|
|
1749
|
+
for (const baseFilename of ["format.js", "header.html"]) {
|
|
1750
|
+
const filename = (0, node_path.join)(formatDir, baseFilename);
|
|
1751
|
+
if (!isFile(filename)) continue;
|
|
1752
|
+
if (baseFilename === "header.html") return {
|
|
1753
|
+
id,
|
|
1754
|
+
filename,
|
|
1755
|
+
isTwine2: false,
|
|
1756
|
+
name: id,
|
|
1757
|
+
version: "",
|
|
1758
|
+
proofing: false
|
|
1759
|
+
};
|
|
1760
|
+
const encoding = [];
|
|
1761
|
+
let decoded;
|
|
976
1762
|
try {
|
|
977
|
-
|
|
978
|
-
} catch {
|
|
979
|
-
|
|
1763
|
+
decoded = decodeFormatJSON(readUTF8(filename, encoding));
|
|
1764
|
+
} catch (e) {
|
|
1765
|
+
decoded = {
|
|
1766
|
+
ok: false,
|
|
1767
|
+
reason: `Could not read ${filename}: ${e instanceof Error ? e.message : String(e)}`
|
|
1768
|
+
};
|
|
980
1769
|
}
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1770
|
+
if (!decoded.ok) {
|
|
1771
|
+
diagnostics?.push(...encoding);
|
|
1772
|
+
diagnostics?.push({
|
|
1773
|
+
level: "warning",
|
|
1774
|
+
message: `format ${id}: Skipping format; ${decoded.reason} (${filename})`
|
|
1775
|
+
});
|
|
985
1776
|
continue;
|
|
986
1777
|
}
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
if (data.url) format.url = data.url;
|
|
1020
|
-
if (data.license) format.license = data.license;
|
|
1021
|
-
} catch {
|
|
1022
|
-
continue;
|
|
1023
|
-
}
|
|
1024
|
-
else format.name = entry;
|
|
1025
|
-
formats.set(entry, format);
|
|
1026
|
-
break;
|
|
1027
|
-
}
|
|
1028
|
-
}
|
|
1778
|
+
const { data } = decoded;
|
|
1779
|
+
const format = {
|
|
1780
|
+
id,
|
|
1781
|
+
filename,
|
|
1782
|
+
isTwine2: true,
|
|
1783
|
+
name: data.name,
|
|
1784
|
+
version: data.version,
|
|
1785
|
+
proofing: data.proofing ?? false
|
|
1786
|
+
};
|
|
1787
|
+
if (data.author) format.author = data.author;
|
|
1788
|
+
if (data.description) format.description = data.description;
|
|
1789
|
+
if (data.image) format.image = data.image;
|
|
1790
|
+
if (data.url) format.url = data.url;
|
|
1791
|
+
if (data.license) format.license = data.license;
|
|
1792
|
+
return format;
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
/**
|
|
1796
|
+
* Discover every story format in the search directories, keyed by folder name (the format ID),
|
|
1797
|
+
* without version pruning.
|
|
1798
|
+
*
|
|
1799
|
+
* Directories are searched in order, and a later directory outranks an earlier one: when two hold
|
|
1800
|
+
* the same folder name, the later one's format is kept. The map iterates in that order too, lowest
|
|
1801
|
+
* rank first. Formats that cannot be used are left out, with a warning in `diagnostics`.
|
|
1802
|
+
*/
|
|
1803
|
+
function discoverAllFormats(searchDirs, diagnostics) {
|
|
1804
|
+
const formats = /* @__PURE__ */ new Map();
|
|
1805
|
+
for (const searchDir of searchDirs) for (const entry of listDirectory(searchDir)) {
|
|
1806
|
+
const format = loadFormatDir(searchDir, entry, diagnostics);
|
|
1807
|
+
if (!format) continue;
|
|
1808
|
+
formats.delete(entry);
|
|
1809
|
+
formats.set(entry, format);
|
|
1029
1810
|
}
|
|
1030
|
-
pruneFormats(formats);
|
|
1031
1811
|
return formats;
|
|
1032
1812
|
}
|
|
1813
|
+
/** The version a format is pruned and selected by, or null for one outside SemVer pruning. */
|
|
1814
|
+
function prunableVersion(format) {
|
|
1815
|
+
return format.isTwine2 && format.name ? parseVersion(format.version) : null;
|
|
1816
|
+
}
|
|
1817
|
+
/** The search directory a discovered format was found in. */
|
|
1818
|
+
function searchDirOf(format) {
|
|
1819
|
+
return (0, node_path.dirname)((0, node_path.dirname)(format.filename));
|
|
1820
|
+
}
|
|
1033
1821
|
/**
|
|
1034
|
-
* Prune formats by SemVer: within each (name, major) group,
|
|
1035
|
-
*
|
|
1822
|
+
* Prune formats by SemVer: within each (name, major) group, keep only the version with the highest
|
|
1823
|
+
* precedence (a release outranks its prereleases). Between equal versions, the folder in the
|
|
1824
|
+
* higher-ranked search directory wins, and within one directory the first folder name in sort
|
|
1825
|
+
* order. Returns a new map in the same order.
|
|
1826
|
+
*
|
|
1827
|
+
* Pruning serves name-based selection and listings. An explicit format ID is looked up in the
|
|
1828
|
+
* unpruned map, so a pinned folder is used even when another folder holds a newer version.
|
|
1036
1829
|
*/
|
|
1037
1830
|
function pruneFormats(formats) {
|
|
1038
|
-
const
|
|
1039
|
-
const
|
|
1040
|
-
|
|
1041
|
-
if (!
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
const
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
version: v
|
|
1831
|
+
const dirRank = /* @__PURE__ */ new Map();
|
|
1832
|
+
for (const format of formats.values()) {
|
|
1833
|
+
const dir = searchDirOf(format);
|
|
1834
|
+
if (!dirRank.has(dir)) dirRank.set(dir, dirRank.size);
|
|
1835
|
+
}
|
|
1836
|
+
const rankOf = (format) => dirRank.get(searchDirOf(format)) ?? 0;
|
|
1837
|
+
const best = /* @__PURE__ */ new Map();
|
|
1838
|
+
for (const format of formats.values()) {
|
|
1839
|
+
const version = prunableVersion(format);
|
|
1840
|
+
if (!version) continue;
|
|
1841
|
+
const group = `${format.name}@${version.major}`;
|
|
1842
|
+
const current = best.get(group);
|
|
1843
|
+
const order = current ? compareVersions(version, current.version) : 1;
|
|
1844
|
+
if (order > 0 || order === 0 && current && rankOf(format) > rankOf(current.format)) best.set(group, {
|
|
1845
|
+
format,
|
|
1846
|
+
version
|
|
1055
1847
|
});
|
|
1056
1848
|
}
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
const groupKey = `${f.name}@${v[0]}`;
|
|
1062
|
-
const best = bestByNameMajor.get(groupKey);
|
|
1063
|
-
if (best && best.id !== id) formats.delete(id);
|
|
1064
|
-
}
|
|
1849
|
+
return new Map([...formats].filter(([id, format]) => {
|
|
1850
|
+
const version = prunableVersion(format);
|
|
1851
|
+
return version === null || best.get(`${format.name}@${version.major}`)?.format.id === id;
|
|
1852
|
+
}));
|
|
1065
1853
|
}
|
|
1066
1854
|
/**
|
|
1067
|
-
*
|
|
1855
|
+
* Discover all story formats in the given search directories, pruned by SemVer: within each
|
|
1856
|
+
* (name, major) group only the highest version survives. See {@link discoverAllFormats} for the
|
|
1857
|
+
* directory ranking and {@link pruneFormats} for pruning.
|
|
1858
|
+
*/
|
|
1859
|
+
function discoverFormats(searchDirs) {
|
|
1860
|
+
return pruneFormats(discoverAllFormats(searchDirs));
|
|
1861
|
+
}
|
|
1862
|
+
/**
|
|
1863
|
+
* Get the format search directories, lowest rank first: the story format subdirectories of the
|
|
1864
|
+
* home directory, then of the working directory, then each `TWEEGO_PATH` entry, then `extraPaths`
|
|
1865
|
+
* (`formatPaths`). A later directory outranks an earlier one for the same format folder name, so
|
|
1866
|
+
* `formatPaths` override `TWEEGO_PATH`, which overrides the home and working directories, as in
|
|
1867
|
+
* Tweego.
|
|
1068
1868
|
*/
|
|
1069
1869
|
function getFormatSearchDirs(extraPaths = [], useTweegoPath = true) {
|
|
1070
1870
|
const subdirNames = [
|
|
@@ -1079,30 +1879,25 @@ function getFormatSearchDirs(extraPaths = [], useTweegoPath = true) {
|
|
|
1079
1879
|
basePaths.add((0, node_os.homedir)());
|
|
1080
1880
|
} catch {}
|
|
1081
1881
|
basePaths.add(process.cwd());
|
|
1082
|
-
const dirs = [];
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
try {
|
|
1086
|
-
if ((0, node_fs.statSync)(dir).isDirectory()) dirs.push(dir);
|
|
1087
|
-
} catch {}
|
|
1088
|
-
}
|
|
1882
|
+
const dirs = [...basePaths].flatMap((base) => subdirNames.map((sub) => (0, node_path.join)(base, sub))).filter(isDirectory);
|
|
1883
|
+
const tweegoPath = useTweegoPath ? process.env.TWEEGO_PATH : void 0;
|
|
1884
|
+
if (tweegoPath) dirs.push(...tweegoPath.split(process.platform === "win32" ? ";" : ":"));
|
|
1089
1885
|
dirs.push(...extraPaths);
|
|
1090
|
-
if (useTweegoPath) {
|
|
1091
|
-
const tweegoPath = process.env.TWEEGO_PATH;
|
|
1092
|
-
if (tweegoPath) dirs.push(...tweegoPath.split(process.platform === "win32" ? ";" : ":"));
|
|
1093
|
-
}
|
|
1094
1886
|
return dirs;
|
|
1095
1887
|
}
|
|
1096
|
-
/**
|
|
1888
|
+
/**
|
|
1889
|
+
* Parse a version string into [major, minor, patch]. Accepts what {@link parseVersion} accepts
|
|
1890
|
+
* (`v1.2.3`, `1.2`, `2.0.0-beta.1`); the prerelease and build metadata are dropped.
|
|
1891
|
+
*/
|
|
1097
1892
|
function parseSemver(v) {
|
|
1098
|
-
const
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
}
|
|
1893
|
+
const parsed = parseVersion(v);
|
|
1894
|
+
return parsed ? [
|
|
1895
|
+
parsed.major,
|
|
1896
|
+
parsed.minor,
|
|
1897
|
+
parsed.patch
|
|
1898
|
+
] : null;
|
|
1899
|
+
}
|
|
1900
|
+
/** Compare [major, minor, patch] tuples. Use {@link compareVersions} to take prereleases into account. */
|
|
1106
1901
|
function semverCompare(a, b) {
|
|
1107
1902
|
const [aMajor, aMinor, aPatch] = a;
|
|
1108
1903
|
const [bMajor, bMinor, bPatch] = b;
|
|
@@ -1113,7 +1908,7 @@ function semverCompare(a, b) {
|
|
|
1113
1908
|
}
|
|
1114
1909
|
/** Build a directory-style format ID from a name and version, e.g. ('SugarCube', '2.37.3') → 'sugarcube-2'. */
|
|
1115
1910
|
function makeFormatId(name, version) {
|
|
1116
|
-
const major =
|
|
1911
|
+
const major = parseVersion(version)?.major ?? 0;
|
|
1117
1912
|
return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
|
|
1118
1913
|
}
|
|
1119
1914
|
/** Describe a format request for diagnostics. */
|
|
@@ -1122,83 +1917,166 @@ function describeFormatRequest(request) {
|
|
|
1122
1917
|
case "id": return `"${request.id}"`;
|
|
1123
1918
|
case "name": return request.version ? `"${request.name}" at version "${request.version}"` : `"${request.name}"`;
|
|
1124
1919
|
default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
|
|
1125
|
-
}
|
|
1126
|
-
}
|
|
1127
|
-
/**
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
* - An ID request ('sugarcube-2') matches a candidate whose name and major version build that ID,
|
|
1131
|
-
* and takes the greatest such version.
|
|
1132
|
-
* - A name request matches the name case-insensitively. An exact version wins outright; otherwise
|
|
1133
|
-
* the greatest version with the same major that is not older than the one asked for (or, with
|
|
1134
|
-
* `allowOlder`, the greatest same-major version). An unparseable requested version takes the
|
|
1135
|
-
* greatest version of any major.
|
|
1136
|
-
*/
|
|
1137
|
-
function selectFormatCandidate(request, candidates, describe, options = {}) {
|
|
1138
|
-
const allowOlder = options.allowOlder ?? false;
|
|
1139
|
-
const wanted = request.kind === "name" ? parseSemver(request.version) : null;
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
/** {@link selectFormatCandidate} over one set of candidates, matching names without regard to case. */
|
|
1923
|
+
function pickFormatCandidate(request, candidates, describe, allowOlder) {
|
|
1924
|
+
const wanted = request.kind === "name" ? parseVersion(request.version) : null;
|
|
1140
1925
|
let best;
|
|
1141
1926
|
for (const candidate of candidates) {
|
|
1142
1927
|
const { name, version } = describe(candidate);
|
|
1143
|
-
const have =
|
|
1928
|
+
const have = parseVersion(version);
|
|
1144
1929
|
if (!have) continue;
|
|
1145
1930
|
if (request.kind === "id") {
|
|
1146
1931
|
if (makeFormatId(name, version) !== request.id.toLowerCase()) continue;
|
|
1147
1932
|
} else {
|
|
1148
1933
|
if (name.toLowerCase() !== request.name.toLowerCase()) continue;
|
|
1149
1934
|
if (wanted) {
|
|
1150
|
-
|
|
1151
|
-
if (
|
|
1152
|
-
if (
|
|
1935
|
+
const order = compareVersions(have, wanted);
|
|
1936
|
+
if (order === 0) return candidate;
|
|
1937
|
+
if (have.major !== wanted.major) continue;
|
|
1938
|
+
if (!allowOlder && order < 0) continue;
|
|
1153
1939
|
}
|
|
1154
1940
|
}
|
|
1155
|
-
if (!best ||
|
|
1941
|
+
if (!best || compareVersions(have, best.version) > 0) best = {
|
|
1156
1942
|
candidate,
|
|
1157
1943
|
version: have
|
|
1158
1944
|
};
|
|
1159
1945
|
}
|
|
1160
1946
|
return best?.candidate;
|
|
1161
1947
|
}
|
|
1162
|
-
/**
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1948
|
+
/**
|
|
1949
|
+
* Pick the candidate that best answers a format request. Local folders, the download cache and
|
|
1950
|
+
* remote indices all select through this one function.
|
|
1951
|
+
*
|
|
1952
|
+
* - An ID request ('sugarcube-2') matches, without regard to case, a candidate whose name and
|
|
1953
|
+
* major version build that ID, and takes the greatest such version.
|
|
1954
|
+
* - A name request matches the name without regard to case, but a candidate whose name matches in
|
|
1955
|
+
* case too is preferred when one answers the request. An exact version (prerelease included)
|
|
1956
|
+
* wins outright; otherwise the greatest version with the same major that is not older than the
|
|
1957
|
+
* one asked for (or, with `allowOlder`, the greatest same-major version). An unparseable
|
|
1958
|
+
* requested version takes the greatest version of any major.
|
|
1959
|
+
*
|
|
1960
|
+
* Versions compare by SemVer precedence ({@link compareVersions}), so a release outranks its
|
|
1961
|
+
* prereleases. Between equal versions, the earlier candidate wins.
|
|
1962
|
+
*/
|
|
1963
|
+
function selectFormatCandidate(request, candidates, describe, options = {}) {
|
|
1964
|
+
const allowOlder = options.allowOlder ?? false;
|
|
1965
|
+
if (request.kind === "name") {
|
|
1966
|
+
const preferred = pickFormatCandidate(request, candidates.filter((c) => describe(c).name === request.name), describe, allowOlder);
|
|
1967
|
+
if (preferred !== void 0) return preferred;
|
|
1968
|
+
}
|
|
1969
|
+
return pickFormatCandidate(request, candidates, describe, allowOlder);
|
|
1970
|
+
}
|
|
1971
|
+
/** The Twine 2 formats of a discovered map, those from higher-ranked search directories first. */
|
|
1972
|
+
function rankedTwine2Formats(formats) {
|
|
1973
|
+
return [...formats.values()].filter((f) => f.isTwine2).reverse();
|
|
1974
|
+
}
|
|
1975
|
+
/**
|
|
1976
|
+
* Look a format ID up among discovered folders. The ID matches a folder name without regard to
|
|
1977
|
+
* case; an exact-case folder is preferred, then the highest-ranked one.
|
|
1978
|
+
*/
|
|
1979
|
+
function findFormatById(formats, id) {
|
|
1980
|
+
const exact = formats.get(id);
|
|
1981
|
+
if (exact) return exact;
|
|
1982
|
+
const wanted = id.toLowerCase();
|
|
1983
|
+
return [...formats.values()].reverse().find((f) => f.id.toLowerCase() === wanted);
|
|
1984
|
+
}
|
|
1985
|
+
/**
|
|
1986
|
+
* Read the story format source (for Twine 2, extract the `source` property from JSON).
|
|
1987
|
+
* `diagnostics` receives a warning when the format file is not valid UTF-8 (it is read as Windows-1252).
|
|
1988
|
+
*/
|
|
1989
|
+
function readFormatSource(format, diagnostics) {
|
|
1990
|
+
const source = readUTF8(format.filename, diagnostics);
|
|
1991
|
+
if (!format.isTwine2) return source;
|
|
1992
|
+
const decoded = decodeFormatJSON(source);
|
|
1993
|
+
if (!decoded.ok) throw new Error(`Cannot parse format ${format.id} JSON: ${decoded.reason}`);
|
|
1994
|
+
return decoded.data.source;
|
|
1995
|
+
}
|
|
1996
|
+
//#endregion
|
|
1997
|
+
//#region src/atomic-write.ts
|
|
1998
|
+
/**
|
|
1999
|
+
* Atomic file replacement, for every file twee-ts writes that something else may read
|
|
2000
|
+
* meanwhile: build output (which live-reload servers read on each change) and downloaded
|
|
2001
|
+
* story formats (which other twee-ts processes share).
|
|
2002
|
+
*/
|
|
2003
|
+
/**
|
|
2004
|
+
* Writes `data` to `path` so that a reader sees either the previous file or the complete new
|
|
2005
|
+
* one, never a partly written file. The data goes to a temporary file in the same folder,
|
|
2006
|
+
* which is then renamed over `path`. When `path` is a symlink, the file it points to is
|
|
2007
|
+
* replaced, and an existing file keeps its permissions.
|
|
2008
|
+
*
|
|
2009
|
+
* On failure (a full disk, a missing folder) the temporary file is removed, `path` is left
|
|
2010
|
+
* as it was, and the error is thrown with `path` in its message and its original `code`.
|
|
2011
|
+
*/
|
|
2012
|
+
function writeFileAtomic(path, data) {
|
|
2013
|
+
const target = followSymlink(path);
|
|
2014
|
+
const temp = (0, node_path.join)((0, node_path.dirname)(target), `.${(0, node_path.basename)(target)}.${process.pid}-${(0, node_crypto.randomBytes)(6).toString("hex")}.tmp`);
|
|
2015
|
+
try {
|
|
2016
|
+
(0, node_fs.writeFileSync)(temp, data, {
|
|
2017
|
+
encoding: "utf-8",
|
|
2018
|
+
flag: "wx"
|
|
2019
|
+
});
|
|
2020
|
+
keepPermissions(target, temp);
|
|
2021
|
+
(0, node_fs.renameSync)(temp, target);
|
|
2022
|
+
} catch (e) {
|
|
2023
|
+
(0, node_fs.rmSync)(temp, { force: true });
|
|
2024
|
+
throw writeError(path, e);
|
|
1177
2025
|
}
|
|
1178
|
-
return bestId;
|
|
1179
2026
|
}
|
|
1180
|
-
/**
|
|
1181
|
-
function
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
return data.source;
|
|
2027
|
+
/** The file a symlink at `path` points to, or `path` itself (also when it doesn't exist yet). */
|
|
2028
|
+
function followSymlink(path) {
|
|
2029
|
+
try {
|
|
2030
|
+
return (0, node_fs.realpathSync)(path);
|
|
2031
|
+
} catch {
|
|
2032
|
+
return path;
|
|
1187
2033
|
}
|
|
1188
|
-
|
|
2034
|
+
}
|
|
2035
|
+
/**
|
|
2036
|
+
* Gives `temp` the permission bits of `target`, when `target` exists. Best effort: on a file
|
|
2037
|
+
* system without Unix permissions chmod can fail, and writing in place never kept them there either.
|
|
2038
|
+
*/
|
|
2039
|
+
function keepPermissions(target, temp) {
|
|
2040
|
+
const existing = (0, node_fs.statSync)(target, { throwIfNoEntry: false });
|
|
2041
|
+
if (existing === void 0) return;
|
|
2042
|
+
try {
|
|
2043
|
+
(0, node_fs.chmodSync)(temp, existing.mode & 4095);
|
|
2044
|
+
} catch {}
|
|
2045
|
+
}
|
|
2046
|
+
/** `cause` as an error that names `path` and keeps the original error code. */
|
|
2047
|
+
function writeError(path, cause) {
|
|
2048
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
2049
|
+
const error = new Error(`Cannot write ${path}: ${message}`, { cause });
|
|
2050
|
+
if (cause instanceof Error && "code" in cause) error.code = cause.code;
|
|
2051
|
+
return error;
|
|
1189
2052
|
}
|
|
1190
2053
|
//#endregion
|
|
1191
2054
|
//#region src/remote-formats.ts
|
|
1192
2055
|
/**
|
|
1193
2056
|
* Remote story format fetching, caching, and checksum verification.
|
|
1194
2057
|
* Uses the Story Formats Archive (SFA) as the default source.
|
|
2058
|
+
*
|
|
2059
|
+
* Two caches hold downloads. Formats found through an index are shared by name and version
|
|
2060
|
+
* (`<cache>/<name>/<version>/format.js`). A format downloaded from a direct URL is kept under
|
|
2061
|
+
* that URL instead, so one project's copy never answers another project's request.
|
|
2062
|
+
* Cache files are replaced atomically, so another process never reads a partly written one.
|
|
1195
2063
|
*/
|
|
1196
2064
|
const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
|
|
2065
|
+
/** The longest delay a timer accepts; a longer timeout means no limit. */
|
|
2066
|
+
const MAX_TIMER_DELAY = 2147483647;
|
|
1197
2067
|
/** Get the cache directory for downloaded story formats. */
|
|
1198
2068
|
function getCacheDir() {
|
|
1199
2069
|
const base = process.env["XDG_CACHE_HOME"] || (0, node_path.join)((0, node_os.homedir)(), ".cache");
|
|
1200
2070
|
return (0, node_path.join)(base, "twee-ts", "storyformats");
|
|
1201
2071
|
}
|
|
2072
|
+
/** The cache directory for formats downloaded from direct URLs, beside {@link getCacheDir}. */
|
|
2073
|
+
function getUrlCacheDir() {
|
|
2074
|
+
return (0, node_path.join)((0, node_path.dirname)(getCacheDir()), "storyformat-urls");
|
|
2075
|
+
}
|
|
2076
|
+
/** The name of one direct URL's cache entry: a hash of the URL, so each URL has its own. */
|
|
2077
|
+
function urlCacheKey(url) {
|
|
2078
|
+
return (0, node_crypto.createHash)("sha256").update(url).digest("hex");
|
|
2079
|
+
}
|
|
1202
2080
|
/**
|
|
1203
2081
|
* Whether a string can serve as one directory name inside the cache: not empty, not `.` or `..`,
|
|
1204
2082
|
* and free of path separators. Format names and versions come from downloaded metadata, so they
|
|
@@ -1221,25 +2099,38 @@ function cachedFormatDir(name, version) {
|
|
|
1221
2099
|
if (!isInside(root, dir)) throw new Error(`Refusing to cache a story format outside the cache directory ${root}: ${dir}`);
|
|
1222
2100
|
return dir;
|
|
1223
2101
|
}
|
|
1224
|
-
/**
|
|
2102
|
+
/** Whether `e` is a Node.js system error with the given code. */
|
|
2103
|
+
function hasErrorCode(e, code) {
|
|
2104
|
+
return e instanceof Error && "code" in e && e.code === code;
|
|
2105
|
+
}
|
|
2106
|
+
/**
|
|
2107
|
+
* Create `dir` as a plain directory, or check that it already is one (a symlink could lead out of the cache).
|
|
2108
|
+
* A directory another process creates between the check and the mkdir is fine.
|
|
2109
|
+
*/
|
|
1225
2110
|
function ensurePlainDirectory(dir) {
|
|
1226
|
-
|
|
1227
|
-
if (stat === void 0) {
|
|
2111
|
+
if ((0, node_fs.lstatSync)(dir, { throwIfNoEntry: false }) === void 0) try {
|
|
1228
2112
|
(0, node_fs.mkdirSync)(dir);
|
|
1229
2113
|
return;
|
|
2114
|
+
} catch (e) {
|
|
2115
|
+
if (!hasErrorCode(e, "EEXIST")) throw e;
|
|
1230
2116
|
}
|
|
1231
|
-
|
|
2117
|
+
const stat = (0, node_fs.lstatSync)(dir, { throwIfNoEntry: false });
|
|
2118
|
+
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`);
|
|
1232
2119
|
}
|
|
1233
|
-
/**
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
2120
|
+
/**
|
|
2121
|
+
* Write a downloaded format.js to `<root>/<...segments>/format.js`, replacing any earlier copy
|
|
2122
|
+
* atomically, and return its path. Every directory below `root` must be a plain one.
|
|
2123
|
+
*/
|
|
2124
|
+
function writeCacheEntry(root, segments, text) {
|
|
1237
2125
|
(0, node_fs.mkdirSync)(root, { recursive: true });
|
|
1238
|
-
|
|
1239
|
-
|
|
2126
|
+
const dir = segments.reduce((parent, segment) => {
|
|
2127
|
+
const child = (0, node_path.join)(parent, segment);
|
|
2128
|
+
ensurePlainDirectory(child);
|
|
2129
|
+
return child;
|
|
2130
|
+
}, root);
|
|
1240
2131
|
const formatPath = (0, node_path.join)(dir, "format.js");
|
|
1241
2132
|
if ((0, node_fs.lstatSync)(formatPath, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error(`Refusing to write a story format outside the cache directory: ${formatPath} is a symlink`);
|
|
1242
|
-
(
|
|
2133
|
+
writeFileAtomic(formatPath, text);
|
|
1243
2134
|
return formatPath;
|
|
1244
2135
|
}
|
|
1245
2136
|
/** In-memory index cache, keyed by URL. Cleared each compile. */
|
|
@@ -1248,6 +2139,78 @@ const indexCache = /* @__PURE__ */ new Map();
|
|
|
1248
2139
|
function clearIndexCache() {
|
|
1249
2140
|
indexCache.clear();
|
|
1250
2141
|
}
|
|
2142
|
+
/** Requests in progress, by what they fetch, so concurrent compiles make each request once. */
|
|
2143
|
+
const sharedRequests = /* @__PURE__ */ new Map();
|
|
2144
|
+
/**
|
|
2145
|
+
* Runs `start` once for all callers that ask for `key` while it is in progress. A caller whose
|
|
2146
|
+
* `signal` aborts stops waiting at once and rejects with the signal's reason; the request
|
|
2147
|
+
* itself is aborted (through the signal `start` receives) once no caller waits on it.
|
|
2148
|
+
*/
|
|
2149
|
+
function shareRequest(key, signal, start) {
|
|
2150
|
+
if (signal?.aborted) return Promise.reject(signal.reason);
|
|
2151
|
+
const request = sharedRequests.get(key) ?? startSharedRequest(key, start);
|
|
2152
|
+
request.waiters++;
|
|
2153
|
+
return new Promise((resolve, reject) => {
|
|
2154
|
+
let waiting = true;
|
|
2155
|
+
const stopWaiting = () => {
|
|
2156
|
+
if (!waiting) return;
|
|
2157
|
+
waiting = false;
|
|
2158
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2159
|
+
request.waiters--;
|
|
2160
|
+
};
|
|
2161
|
+
const onAbort = () => {
|
|
2162
|
+
stopWaiting();
|
|
2163
|
+
if (request.waiters === 0) {
|
|
2164
|
+
if (sharedRequests.get(key) === request) sharedRequests.delete(key);
|
|
2165
|
+
request.controller.abort(signal?.reason);
|
|
2166
|
+
}
|
|
2167
|
+
reject(signal?.reason);
|
|
2168
|
+
};
|
|
2169
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2170
|
+
request.promise.then((value) => {
|
|
2171
|
+
stopWaiting();
|
|
2172
|
+
resolve(value);
|
|
2173
|
+
}, (e) => {
|
|
2174
|
+
stopWaiting();
|
|
2175
|
+
reject(e);
|
|
2176
|
+
});
|
|
2177
|
+
});
|
|
2178
|
+
}
|
|
2179
|
+
function startSharedRequest(key, start) {
|
|
2180
|
+
const controller = new AbortController();
|
|
2181
|
+
const request = {
|
|
2182
|
+
promise: start(controller.signal),
|
|
2183
|
+
controller,
|
|
2184
|
+
waiters: 0
|
|
2185
|
+
};
|
|
2186
|
+
sharedRequests.set(key, request);
|
|
2187
|
+
const forget = () => {
|
|
2188
|
+
if (sharedRequests.get(key) === request) sharedRequests.delete(key);
|
|
2189
|
+
};
|
|
2190
|
+
request.promise.then(forget, forget);
|
|
2191
|
+
return request;
|
|
2192
|
+
}
|
|
2193
|
+
/** The time limit for each request, from the caller's options. */
|
|
2194
|
+
function requestTimeout(options) {
|
|
2195
|
+
const timeout = options.timeout ?? 3e4;
|
|
2196
|
+
if (!(timeout >= 0)) throw new RangeError(`A story format request timeout must be 0 or more milliseconds, not ${timeout}`);
|
|
2197
|
+
return timeout;
|
|
2198
|
+
}
|
|
2199
|
+
/**
|
|
2200
|
+
* Fetch `url` as text, aborting when `signal` does or after `timeout` milliseconds (0: no limit).
|
|
2201
|
+
* `what` names the request in errors, as in "Failed to <what> from <url>".
|
|
2202
|
+
*/
|
|
2203
|
+
async function fetchText(url, what, signal, timeout) {
|
|
2204
|
+
const limit = timeout > 0 && timeout <= MAX_TIMER_DELAY ? AbortSignal.timeout(timeout) : void 0;
|
|
2205
|
+
try {
|
|
2206
|
+
const res = await fetch(url, { signal: limit ? AbortSignal.any([signal, limit]) : signal });
|
|
2207
|
+
if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
|
|
2208
|
+
return await res.text();
|
|
2209
|
+
} catch (e) {
|
|
2210
|
+
if (limit?.aborted && !signal.aborted) throw new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`, { cause: e });
|
|
2211
|
+
throw e;
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
1251
2214
|
/** Validate that a JSON value is a valid SFAIndexEntry. */
|
|
1252
2215
|
function isValidEntry(val) {
|
|
1253
2216
|
if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
|
|
@@ -1263,15 +2226,18 @@ function validateSFAIndex(json) {
|
|
|
1263
2226
|
twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
|
|
1264
2227
|
};
|
|
1265
2228
|
}
|
|
1266
|
-
/** Fetch and parse an SFA index.json, with in-memory caching. */
|
|
1267
|
-
async function fetchIndex(url) {
|
|
2229
|
+
/** Fetch and parse an SFA index.json, with in-memory caching. Concurrent calls for one URL share a request. */
|
|
2230
|
+
async function fetchIndex(url, options = {}) {
|
|
2231
|
+
options.signal?.throwIfAborted();
|
|
1268
2232
|
const cached = indexCache.get(url);
|
|
1269
2233
|
if (cached) return cached;
|
|
1270
|
-
const
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
2234
|
+
const timeout = requestTimeout(options);
|
|
2235
|
+
return shareRequest(`index\0${url}`, options.signal, async (signal) => {
|
|
2236
|
+
const text = await fetchText(url, "fetch format index", signal, timeout);
|
|
2237
|
+
const data = validateSFAIndex(JSON.parse(text));
|
|
2238
|
+
indexCache.set(url, data);
|
|
2239
|
+
return data;
|
|
2240
|
+
});
|
|
1275
2241
|
}
|
|
1276
2242
|
/** Find the best matching entry in an SFA index for a name or ID request (twine2 entries first). */
|
|
1277
2243
|
function findEntryForRequest(index, request) {
|
|
@@ -1292,99 +2258,155 @@ async function verifySHA256(content, expectedHex) {
|
|
|
1292
2258
|
function getDownloadUrl(indexUrl, entry, formatType) {
|
|
1293
2259
|
return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
|
|
1294
2260
|
}
|
|
1295
|
-
/**
|
|
1296
|
-
|
|
1297
|
-
cachedFormatDir(entry.name, entry.version);
|
|
1298
|
-
const res = await fetch(downloadUrl);
|
|
1299
|
-
if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
|
|
1300
|
-
const text = await res.text();
|
|
1301
|
-
const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
|
|
1302
|
-
if (checksumKey) {
|
|
1303
|
-
const expected = entry.checksums[checksumKey];
|
|
1304
|
-
if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
|
|
1305
|
-
if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
|
|
1306
|
-
}
|
|
1307
|
-
const id = makeFormatId(entry.name, entry.version);
|
|
1308
|
-
const data = parseFormatJSON(text, id);
|
|
1309
|
-
if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
|
|
2261
|
+
/** The StoryFormatInfo of a Twine 2 format.js cached at `filename`. */
|
|
2262
|
+
function cachedFormatInfo(id, filename, data) {
|
|
1310
2263
|
return {
|
|
1311
2264
|
id,
|
|
1312
|
-
filename
|
|
2265
|
+
filename,
|
|
1313
2266
|
isTwine2: true,
|
|
1314
2267
|
name: data.name,
|
|
1315
2268
|
version: data.version,
|
|
1316
2269
|
proofing: data.proofing ?? false
|
|
1317
2270
|
};
|
|
1318
2271
|
}
|
|
1319
|
-
/**
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
return {
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
2272
|
+
/**
|
|
2273
|
+
* Download a format, verify its checksum, write it to the cache shared by name and version, and
|
|
2274
|
+
* return its StoryFormatInfo. Concurrent calls for one download URL share one request.
|
|
2275
|
+
*/
|
|
2276
|
+
async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
|
|
2277
|
+
cachedFormatDir(entry.name, entry.version);
|
|
2278
|
+
const timeout = requestTimeout(options);
|
|
2279
|
+
return shareRequest(`format\0${getCacheDir()}\0${downloadUrl}`, options.signal, async (signal) => {
|
|
2280
|
+
const text = await fetchText(downloadUrl, "download format", signal, timeout);
|
|
2281
|
+
const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
|
|
2282
|
+
if (checksumKey) {
|
|
2283
|
+
const expected = entry.checksums[checksumKey];
|
|
2284
|
+
if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
|
|
2285
|
+
if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
|
|
2286
|
+
}
|
|
2287
|
+
const id = makeFormatId(entry.name, entry.version);
|
|
2288
|
+
const data = parseFormatJSON(text, id);
|
|
2289
|
+
if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
|
|
2290
|
+
signal.throwIfAborted();
|
|
2291
|
+
return cachedFormatInfo(id, writeCacheEntry((0, node_path.resolve)(getCacheDir()), [entry.name, entry.version], text), data);
|
|
2292
|
+
});
|
|
2293
|
+
}
|
|
2294
|
+
/**
|
|
2295
|
+
* Download a direct format.js URL, parse its JSON, cache it under that URL (apart from the
|
|
2296
|
+
* downloads shared by name and version), and return its StoryFormatInfo. Concurrent calls for
|
|
2297
|
+
* one URL share one request.
|
|
2298
|
+
*/
|
|
2299
|
+
async function fetchDirectFormat(url, options = {}) {
|
|
2300
|
+
const timeout = requestTimeout(options);
|
|
2301
|
+
return shareRequest(`url\0${getUrlCacheDir()}\0${url}`, options.signal, async (signal) => {
|
|
2302
|
+
const text = await fetchText(url, "download format", signal, timeout);
|
|
2303
|
+
const data = parseFormatJSON(text);
|
|
2304
|
+
if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
|
|
2305
|
+
cachedFormatDir(data.name, data.version);
|
|
2306
|
+
signal.throwIfAborted();
|
|
2307
|
+
const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
|
|
2308
|
+
return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
/** The copy of a direct format URL in the download cache, if it has been downloaded before. */
|
|
2312
|
+
function getCachedDirectFormat(url) {
|
|
2313
|
+
const formatPath = (0, node_path.join)(getUrlCacheDir(), urlCacheKey(url), "format.js");
|
|
2314
|
+
try {
|
|
2315
|
+
if (!(0, node_fs.existsSync)(formatPath)) return void 0;
|
|
2316
|
+
const data = parseFormatJSON(readUTF8(formatPath));
|
|
2317
|
+
if (!data) return void 0;
|
|
2318
|
+
return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
|
|
2319
|
+
} catch {
|
|
2320
|
+
return;
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
/**
|
|
2324
|
+
* Look a request up among direct format URLs, in order, and return the first format that
|
|
2325
|
+
* answers it. A URL downloaded before is answered by its cached copy without the network;
|
|
2326
|
+
* any other is downloaded (unless `offline`). When none answers and a download failed, the
|
|
2327
|
+
* last failure is thrown. An aborted signal rejects with its reason and tries no further URL.
|
|
2328
|
+
*
|
|
2329
|
+
* Internal, for format resolution; not part of the public API.
|
|
2330
|
+
*/
|
|
2331
|
+
async function resolveFormatUrls(request, urls, options = {}) {
|
|
2332
|
+
let lastError;
|
|
2333
|
+
for (const url of urls) {
|
|
2334
|
+
const cached = getCachedDirectFormat(url);
|
|
2335
|
+
if (cached) {
|
|
2336
|
+
if (answers(request, cached)) return cached;
|
|
2337
|
+
continue;
|
|
2338
|
+
}
|
|
2339
|
+
if (options.offline) continue;
|
|
2340
|
+
try {
|
|
2341
|
+
const info = await fetchDirectFormat(url, options);
|
|
2342
|
+
if (answers(request, info)) return info;
|
|
2343
|
+
} catch (e) {
|
|
2344
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
2345
|
+
lastError = toError$1(e);
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
if (lastError) throw lastError;
|
|
2349
|
+
}
|
|
2350
|
+
/** Whether `info` is a format the request accepts. */
|
|
2351
|
+
function answers(request, info) {
|
|
2352
|
+
return selectFormatCandidate(request, [info], (f) => f) !== void 0;
|
|
2353
|
+
}
|
|
2354
|
+
function toError$1(e) {
|
|
2355
|
+
return e instanceof Error ? e : new Error(String(e));
|
|
1334
2356
|
}
|
|
1335
2357
|
/**
|
|
1336
2358
|
* Try to resolve a remote story format by name and version.
|
|
1337
|
-
*
|
|
1338
|
-
*
|
|
1339
|
-
*
|
|
1340
|
-
*
|
|
1341
|
-
*
|
|
2359
|
+
* 1. Try direct format URLs (each one's cached copy, else a download)
|
|
2360
|
+
* 2. Use an exactly matching download from the shared cache, without touching the network
|
|
2361
|
+
* 3. Try custom index URLs, then the default SFA indices
|
|
2362
|
+
* 4. Fall back to a compatible download from the shared cache (e.g. when offline)
|
|
2363
|
+
*
|
|
2364
|
+
* `options.signal` aborts the lookup, which then rejects with the signal's reason;
|
|
2365
|
+
* `options.timeout` limits each request (default {@link DEFAULT_FORMAT_FETCH_TIMEOUT} ms).
|
|
1342
2366
|
*/
|
|
1343
|
-
async function resolveRemoteFormat(name, version, indices, urls) {
|
|
2367
|
+
async function resolveRemoteFormat(name, version, indices, urls, options = {}) {
|
|
1344
2368
|
return resolveRemoteFormatRequest({
|
|
1345
2369
|
kind: "name",
|
|
1346
2370
|
name,
|
|
1347
2371
|
version
|
|
1348
|
-
}, indices, urls);
|
|
2372
|
+
}, indices, urls, options);
|
|
1349
2373
|
}
|
|
1350
2374
|
/**
|
|
1351
2375
|
* Resolve a story format request remotely, in the same order as {@link resolveRemoteFormat}.
|
|
1352
2376
|
* An ID request such as 'sugarcube-2' matches the format whose name and major version build that ID
|
|
1353
2377
|
* (SugarCube 2.x), taking the greatest version available.
|
|
1354
2378
|
*/
|
|
1355
|
-
async function resolveRemoteFormatRequest(request, indices, urls) {
|
|
2379
|
+
async function resolveRemoteFormatRequest(request, indices, urls, options = {}) {
|
|
2380
|
+
options.signal?.throwIfAborted();
|
|
1356
2381
|
let lastError;
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
const info = await fetchDirectFormat(url);
|
|
1361
|
-
if (selectFormatCandidate(request, [info], (f) => f)) return info;
|
|
2382
|
+
try {
|
|
2383
|
+
const direct = await resolveFormatUrls(request, urls ?? [], options);
|
|
2384
|
+
if (direct) return direct;
|
|
1362
2385
|
} catch (e) {
|
|
1363
|
-
|
|
2386
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
2387
|
+
lastError = toError$1(e);
|
|
1364
2388
|
}
|
|
2389
|
+
const cached = findCachedFormat(request);
|
|
2390
|
+
if (cached && request.kind === "name" && sameVersion(cached.version, request.version)) return cached;
|
|
1365
2391
|
const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
|
|
1366
2392
|
for (const indexUrl of allIndices) try {
|
|
1367
|
-
const result = findEntryForRequest(await fetchIndex(indexUrl), request);
|
|
2393
|
+
const result = findEntryForRequest(await fetchIndex(indexUrl, options), request);
|
|
1368
2394
|
if (result) {
|
|
1369
2395
|
const hit = getCachedFormat(result.entry.name, result.entry.version);
|
|
1370
2396
|
if (hit) return hit;
|
|
1371
2397
|
const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
|
|
1372
|
-
return await fetchAndCacheFormat(result.entry, downloadUrl);
|
|
2398
|
+
return await fetchAndCacheFormat(result.entry, downloadUrl, options);
|
|
1373
2399
|
}
|
|
1374
2400
|
} catch (e) {
|
|
1375
|
-
|
|
2401
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
2402
|
+
lastError = toError$1(e);
|
|
1376
2403
|
}
|
|
1377
2404
|
if (cached) return cached;
|
|
1378
2405
|
if (lastError) throw lastError;
|
|
1379
2406
|
}
|
|
1380
|
-
/** Whether two version strings name the same SemVer version. */
|
|
1381
|
-
function isExactVersion(have, wanted) {
|
|
1382
|
-
const a = parseSemver(have);
|
|
1383
|
-
const b = parseSemver(wanted);
|
|
1384
|
-
return a !== null && b !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
|
|
1385
|
-
}
|
|
1386
2407
|
/**
|
|
1387
2408
|
* Find the cached download that best answers a format request, without network access.
|
|
2409
|
+
* Only the downloads shared by name and version are searched, not those from direct URLs.
|
|
1388
2410
|
* Matching follows {@link selectFormatCandidate}.
|
|
1389
2411
|
*/
|
|
1390
2412
|
function findCachedFormat(request, options = {}) {
|
|
@@ -1396,18 +2418,11 @@ function getCachedFormat(name, version) {
|
|
|
1396
2418
|
const formatPath = (0, node_path.join)(getCacheDir(), name, version, "format.js");
|
|
1397
2419
|
try {
|
|
1398
2420
|
if (!(0, node_fs.existsSync)(formatPath)) return void 0;
|
|
1399
|
-
const source = (
|
|
2421
|
+
const source = readUTF8(formatPath);
|
|
1400
2422
|
const id = makeFormatId(name, version);
|
|
1401
2423
|
const data = parseFormatJSON(source, id);
|
|
1402
2424
|
if (!data) return void 0;
|
|
1403
|
-
return
|
|
1404
|
-
id,
|
|
1405
|
-
filename: formatPath,
|
|
1406
|
-
isTwine2: true,
|
|
1407
|
-
name: data.name,
|
|
1408
|
-
version: data.version,
|
|
1409
|
-
proofing: data.proofing ?? false
|
|
1410
|
-
};
|
|
2425
|
+
return cachedFormatInfo(id, formatPath, data);
|
|
1411
2426
|
} catch {
|
|
1412
2427
|
return;
|
|
1413
2428
|
}
|
|
@@ -1492,21 +2507,27 @@ function listCachedFormats() {
|
|
|
1492
2507
|
}
|
|
1493
2508
|
/**
|
|
1494
2509
|
* Clear all cached formats, or only those matching a given name. Returns the number of entries removed.
|
|
2510
|
+
* Clearing all also removes the downloads from direct format URLs; a name clears only the
|
|
2511
|
+
* downloads shared by name and version.
|
|
1495
2512
|
* A name must be a single cache entry name (as `listCachedFormats()` reports it); anything with a
|
|
1496
2513
|
* path separator or `.`/`..` throws instead of deleting outside the format's own directory.
|
|
1497
2514
|
*/
|
|
1498
2515
|
function clearCachedFormats(name) {
|
|
1499
2516
|
if (name && !isSafeSegment(name)) throw new Error(`Refusing to clear ${JSON.stringify(name)}: it is not a cached format name. Use a name as "cache list" shows it.`);
|
|
1500
2517
|
const cacheDir = getCacheDir();
|
|
1501
|
-
if (!(0, node_fs.existsSync)(cacheDir)) return 0;
|
|
1502
2518
|
if (!name) {
|
|
1503
|
-
const count = listCachedFormats().length;
|
|
2519
|
+
const count = listCachedFormats().length + countUrlCacheEntries();
|
|
1504
2520
|
(0, node_fs.rmSync)(cacheDir, {
|
|
1505
2521
|
recursive: true,
|
|
1506
2522
|
force: true
|
|
1507
2523
|
});
|
|
2524
|
+
(0, node_fs.rmSync)(getUrlCacheDir(), {
|
|
2525
|
+
recursive: true,
|
|
2526
|
+
force: true
|
|
2527
|
+
});
|
|
1508
2528
|
return count;
|
|
1509
2529
|
}
|
|
2530
|
+
if (!(0, node_fs.existsSync)(cacheDir)) return 0;
|
|
1510
2531
|
const root = (0, node_path.resolve)(cacheDir);
|
|
1511
2532
|
const nameDir = (0, node_path.resolve)(root, name);
|
|
1512
2533
|
if (!isInside(root, nameDir)) throw new Error(`Refusing to clear ${JSON.stringify(name)}: it is not a cached format name.`);
|
|
@@ -1524,6 +2545,15 @@ function clearCachedFormats(name) {
|
|
|
1524
2545
|
});
|
|
1525
2546
|
return count;
|
|
1526
2547
|
}
|
|
2548
|
+
/** The number of direct format URLs with a download in the cache. */
|
|
2549
|
+
function countUrlCacheEntries() {
|
|
2550
|
+
const dir = getUrlCacheDir();
|
|
2551
|
+
try {
|
|
2552
|
+
return (0, node_fs.readdirSync)(dir).filter((key) => (0, node_fs.existsSync)((0, node_path.join)(dir, key, "format.js"))).length;
|
|
2553
|
+
} catch {
|
|
2554
|
+
return 0;
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
1527
2557
|
/** Get total cache size in bytes and format count. */
|
|
1528
2558
|
function getCacheSize() {
|
|
1529
2559
|
const entries = listCachedFormats();
|
|
@@ -1550,44 +2580,65 @@ function formatRequestFor(formatId, storyFormat, storyFormatVersion, defaultId)
|
|
|
1550
2580
|
id: defaultId
|
|
1551
2581
|
};
|
|
1552
2582
|
}
|
|
1553
|
-
/**
|
|
1554
|
-
|
|
2583
|
+
/**
|
|
2584
|
+
* Look a request up among the formats found in local format directories. An ID names a folder, so
|
|
2585
|
+
* it is looked up among all of them; a name request selects by SemVer among the pruned formats.
|
|
2586
|
+
*/
|
|
2587
|
+
function findLocalFormat(all, pruned, request) {
|
|
1555
2588
|
switch (request.kind) {
|
|
1556
|
-
case "id": return
|
|
1557
|
-
case "name":
|
|
1558
|
-
const id = getFormatIdByNameAndVersion(formats, request.name, request.version);
|
|
1559
|
-
return id === void 0 ? void 0 : formats.get(id);
|
|
1560
|
-
}
|
|
2589
|
+
case "id": return findFormatById(all, request.id);
|
|
2590
|
+
case "name": return selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f);
|
|
1561
2591
|
default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
|
|
1562
2592
|
}
|
|
1563
2593
|
}
|
|
1564
2594
|
/**
|
|
1565
2595
|
* Resolve a format request to a story format.
|
|
1566
2596
|
*
|
|
1567
|
-
* Order: local format directories, then the
|
|
1568
|
-
*
|
|
2597
|
+
* Order: local format directories, then the project's direct format URLs (each one's cached
|
|
2598
|
+
* copy, else a download unless `noRemote`), then the download cache shared by name and version,
|
|
2599
|
+
* then format indices (unless `noRemote`). Names and IDs match the same way in each (see
|
|
2600
|
+
* `selectFormatCandidate`), so a request that a local format answers never reaches the cache or the
|
|
2601
|
+
* network. Local formats that cannot be used are reported as warnings. When a StoryData request
|
|
2602
|
+
* names a version that none of them has, an older version of
|
|
1569
2603
|
* the same format and major version is used with a warning. Anything else is an error diagnostic,
|
|
1570
2604
|
* and the result is `undefined`.
|
|
1571
2605
|
*/
|
|
1572
2606
|
async function resolveStoryFormat(request, options, diagnostics) {
|
|
1573
2607
|
const noRemote = options.noRemote ?? false;
|
|
1574
|
-
const
|
|
2608
|
+
const all = discoverAllFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true), diagnostics);
|
|
2609
|
+
const pruned = pruneFormats(all);
|
|
1575
2610
|
const wanted = describeFormatRequest(request);
|
|
1576
|
-
const local = findLocalFormat(
|
|
2611
|
+
const local = findLocalFormat(all, pruned, request);
|
|
1577
2612
|
if (local) return local;
|
|
2613
|
+
const fetchOptions = {
|
|
2614
|
+
signal: options.signal,
|
|
2615
|
+
timeout: options.formatFetchTimeout
|
|
2616
|
+
};
|
|
2617
|
+
const warnOnFailure = async (lookup) => {
|
|
2618
|
+
try {
|
|
2619
|
+
return await lookup();
|
|
2620
|
+
} catch (e) {
|
|
2621
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
2622
|
+
diagnostics.push({
|
|
2623
|
+
level: "warning",
|
|
2624
|
+
message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
|
|
2625
|
+
});
|
|
2626
|
+
return;
|
|
2627
|
+
}
|
|
2628
|
+
};
|
|
2629
|
+
const direct = await warnOnFailure(() => resolveFormatUrls(request, options.formatUrls ?? [], {
|
|
2630
|
+
...fetchOptions,
|
|
2631
|
+
offline: noRemote
|
|
2632
|
+
}));
|
|
2633
|
+
if (direct) return direct;
|
|
1578
2634
|
const cached = findCachedFormat(request);
|
|
1579
2635
|
if (cached) return cached;
|
|
1580
|
-
if (!noRemote)
|
|
1581
|
-
const remote = await resolveRemoteFormatRequest(request, options.formatIndices,
|
|
2636
|
+
if (!noRemote) {
|
|
2637
|
+
const remote = await warnOnFailure(() => resolveRemoteFormatRequest(request, options.formatIndices, [], fetchOptions));
|
|
1582
2638
|
if (remote) return remote;
|
|
1583
|
-
} catch (e) {
|
|
1584
|
-
diagnostics.push({
|
|
1585
|
-
level: "warning",
|
|
1586
|
-
message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
|
|
1587
|
-
});
|
|
1588
2639
|
}
|
|
1589
2640
|
if (request.kind === "name") {
|
|
1590
|
-
const older = selectFormatCandidate(request,
|
|
2641
|
+
const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
|
|
1591
2642
|
if (older) {
|
|
1592
2643
|
diagnostics.push({
|
|
1593
2644
|
level: "warning",
|
|
@@ -1599,7 +2650,7 @@ async function resolveStoryFormat(request, options, diagnostics) {
|
|
|
1599
2650
|
const reason = noRemote ? " (remote fetching disabled)" : "";
|
|
1600
2651
|
diagnostics.push({
|
|
1601
2652
|
level: "error",
|
|
1602
|
-
message: `Story format ${wanted} is not available${reason}. Found: ${[...
|
|
2653
|
+
message: `Story format ${wanted} is not available${reason}. Found: ${[...all.keys()].join(", ") || "none"}`
|
|
1603
2654
|
});
|
|
1604
2655
|
}
|
|
1605
2656
|
//#endregion
|
|
@@ -1894,12 +2945,14 @@ function twee2ToV3(s) {
|
|
|
1894
2945
|
* Parse Twee source text into passages.
|
|
1895
2946
|
*
|
|
1896
2947
|
* The source is normalized first, as files are when they are read: a leading UTF-8 BOM is
|
|
1897
|
-
* removed and CRLF and bare CR line endings become LF.
|
|
2948
|
+
* removed and CRLF and bare CR line endings become LF. A BOM at the start of a later line,
|
|
2949
|
+
* directly before `::` (left there by concatenating files), is removed too, so that line
|
|
2950
|
+
* stays a passage header.
|
|
1898
2951
|
*/
|
|
1899
2952
|
function parseTwee(source, options = {}) {
|
|
1900
2953
|
const { filename = "<inline>", trim = true, twee2Compat = false } = options;
|
|
1901
2954
|
const diagnostics = [];
|
|
1902
|
-
const normalized =
|
|
2955
|
+
const normalized = normalizeTweeSourceText(source);
|
|
1903
2956
|
const tweeSource = twee2Compat ? twee2ToV3(normalized) : normalized;
|
|
1904
2957
|
const passages = [];
|
|
1905
2958
|
let current = null;
|
|
@@ -4736,15 +5789,28 @@ function parseDocument(data, options) {
|
|
|
4736
5789
|
/**
|
|
4737
5790
|
* Parse a Twine 2 or Twine 1 compiled HTML file back into a Story model.
|
|
4738
5791
|
* Tweego always trims passage text; here `trim: false` keeps it exactly, as for Twee sources.
|
|
5792
|
+
* A missing or invalid `tw-storydata` IFID is reported as a warning; an invalid one is kept as
|
|
5793
|
+
* written (uppercased), a missing one stays empty.
|
|
4739
5794
|
*/
|
|
4740
5795
|
function decompileHTML(html, options = {}) {
|
|
5796
|
+
return decompile(html, options, { ifid: true });
|
|
5797
|
+
}
|
|
5798
|
+
/**
|
|
5799
|
+
* `decompileHTML()` for loading an HTML file into a compile. The importer's StoryData passage is
|
|
5800
|
+
* checked when it is added to the story being compiled, which reports a missing or invalid IFID,
|
|
5801
|
+
* so the IFID is not reported here as well.
|
|
5802
|
+
*/
|
|
5803
|
+
function decompileHTMLForImport(html, options = {}) {
|
|
5804
|
+
return decompile(html, options, { ifid: false });
|
|
5805
|
+
}
|
|
5806
|
+
function decompile(html, options, checks) {
|
|
4741
5807
|
const diagnostics = [];
|
|
4742
5808
|
const story = createStory();
|
|
4743
5809
|
const passageText = options.trim === false ? (text) => text : (text) => text.trim();
|
|
4744
5810
|
const doc = parseDocument(html);
|
|
4745
5811
|
const twine2Data = findElement(doc, "tw-storydata");
|
|
4746
5812
|
if (twine2Data) {
|
|
4747
|
-
decompileTwine2(twine2Data, story, passageText, diagnostics);
|
|
5813
|
+
decompileTwine2(twine2Data, story, passageText, checks, diagnostics);
|
|
4748
5814
|
return {
|
|
4749
5815
|
story,
|
|
4750
5816
|
diagnostics
|
|
@@ -4767,7 +5833,7 @@ function decompileHTML(html, options = {}) {
|
|
|
4767
5833
|
diagnostics
|
|
4768
5834
|
};
|
|
4769
5835
|
}
|
|
4770
|
-
function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
5836
|
+
function decompileTwine2(storyData, story, passageText, checks, diagnostics) {
|
|
4771
5837
|
let startnode = 0;
|
|
4772
5838
|
const attrs = storyData.attribs ?? {};
|
|
4773
5839
|
if (attrs["name"]) story.name = attrs["name"];
|
|
@@ -4780,6 +5846,7 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4780
5846
|
else startnode = parsed;
|
|
4781
5847
|
}
|
|
4782
5848
|
if (attrs["ifid"]) story.ifid = normalizeIFID(attrs["ifid"]);
|
|
5849
|
+
if (checks.ifid) diagnostics.push(...storyDataIFIDDiagnostics(attrs["ifid"] ?? ""));
|
|
4783
5850
|
if (attrs["zoom"]) {
|
|
4784
5851
|
const parsed = parseFloat(attrs["zoom"]);
|
|
4785
5852
|
if (Number.isNaN(parsed)) diagnostics.push({
|
|
@@ -4802,13 +5869,17 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4802
5869
|
const content = getTextContent(node);
|
|
4803
5870
|
if (content.trim().length === 0) continue;
|
|
4804
5871
|
const text = passageText(content);
|
|
4805
|
-
const
|
|
5872
|
+
const base = node.name === "style" ? "Story Stylesheet" : "Story JavaScript";
|
|
5873
|
+
const name = freeName(base, (n) => !takenNames.has(n));
|
|
4806
5874
|
takenNames.add(name);
|
|
4807
|
-
storyAdd(story, {
|
|
5875
|
+
storyAdd(story, withGeneratedName({
|
|
4808
5876
|
name,
|
|
4809
5877
|
tags: node.name === "style" ? ["stylesheet"] : ["script"],
|
|
4810
5878
|
text
|
|
4811
|
-
},
|
|
5879
|
+
}, {
|
|
5880
|
+
kind: "code",
|
|
5881
|
+
base
|
|
5882
|
+
}), diagnostics);
|
|
4812
5883
|
break;
|
|
4813
5884
|
}
|
|
4814
5885
|
case "tw-tag": {
|
|
@@ -4855,19 +5926,26 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4855
5926
|
function passageDataName(node) {
|
|
4856
5927
|
return node.attribs?.["name"] ?? "";
|
|
4857
5928
|
}
|
|
4858
|
-
/**
|
|
4859
|
-
function
|
|
4860
|
-
if (
|
|
4861
|
-
|
|
4862
|
-
|
|
4863
|
-
|
|
4864
|
-
|
|
5929
|
+
/** Diagnostics for the `ifid` attribute of `tw-storydata`; `value` is empty when it is missing. */
|
|
5930
|
+
function storyDataIFIDDiagnostics(value) {
|
|
5931
|
+
if (value === "") return [{
|
|
5932
|
+
level: "warning",
|
|
5933
|
+
message: "Story IFID not found; the \"tw-storydata\" content attribute \"ifid\" is missing or empty."
|
|
5934
|
+
}];
|
|
5935
|
+
const err = validateIFID(value);
|
|
5936
|
+
if (err === null) return [];
|
|
5937
|
+
return [{
|
|
5938
|
+
level: "warning",
|
|
5939
|
+
message: `Cannot parse "tw-storydata" content attribute "ifid" as an IFID; value "${value}" (${err}).`
|
|
5940
|
+
}];
|
|
4865
5941
|
}
|
|
4866
5942
|
function decompileTwine1(storeArea, story, passageText, diagnostics) {
|
|
4867
5943
|
const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
|
|
4868
5944
|
const obfuscated = isRot13Obfuscated(passages);
|
|
4869
|
-
for (const p of passages) storyAdd(story, obfuscated && p
|
|
5945
|
+
for (const p of passages) storyAdd(story, obfuscated && isObfuscatable(p) ? {
|
|
4870
5946
|
...p,
|
|
5947
|
+
name: rot13(p.name),
|
|
5948
|
+
tags: p.tags.map(rot13),
|
|
4871
5949
|
text: rot13(p.text)
|
|
4872
5950
|
} : p, diagnostics);
|
|
4873
5951
|
}
|
|
@@ -5012,7 +6090,12 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
|
|
|
5012
6090
|
function loadInlineSources(story, sources, opts, diagnostics) {
|
|
5013
6091
|
for (const source of sources) {
|
|
5014
6092
|
if (typeof source === "string") continue;
|
|
5015
|
-
const
|
|
6093
|
+
const decoded = typeof source.content === "string" ? {
|
|
6094
|
+
text: source.content,
|
|
6095
|
+
diagnostics: []
|
|
6096
|
+
} : decodeText(source.content, source.filename);
|
|
6097
|
+
diagnostics.push(...decoded.diagnostics);
|
|
6098
|
+
const content = normalizeSourceText(decoded.text);
|
|
5016
6099
|
const ext = normalizedFileExt(source.filename);
|
|
5017
6100
|
switch (ext) {
|
|
5018
6101
|
case "":
|
|
@@ -5031,18 +6114,10 @@ function loadInlineSources(story, sources, opts, diagnostics) {
|
|
|
5031
6114
|
break;
|
|
5032
6115
|
}
|
|
5033
6116
|
case "css":
|
|
5034
|
-
storyAdd(story,
|
|
5035
|
-
name: (0, node_path.basename)(source.filename),
|
|
5036
|
-
tags: ["stylesheet"],
|
|
5037
|
-
text: content
|
|
5038
|
-
}, diagnostics);
|
|
6117
|
+
storyAdd(story, codePassage((0, node_path.basename)(source.filename), "stylesheet", content), diagnostics);
|
|
5039
6118
|
break;
|
|
5040
6119
|
case "js":
|
|
5041
|
-
storyAdd(story,
|
|
5042
|
-
name: (0, node_path.basename)(source.filename),
|
|
5043
|
-
tags: ["script"],
|
|
5044
|
-
text: content
|
|
5045
|
-
}, diagnostics);
|
|
6120
|
+
storyAdd(story, codePassage((0, node_path.basename)(source.filename), "script", content), diagnostics);
|
|
5046
6121
|
break;
|
|
5047
6122
|
default: diagnostics.push({
|
|
5048
6123
|
level: "warning",
|
|
@@ -5052,35 +6127,60 @@ function loadInlineSources(story, sources, opts, diagnostics) {
|
|
|
5052
6127
|
}
|
|
5053
6128
|
}
|
|
5054
6129
|
function parseTweeFile(filename, opts) {
|
|
5055
|
-
const
|
|
6130
|
+
const readDiagnostics = [];
|
|
6131
|
+
const result = parseTwee(readUTF8(filename, readDiagnostics), {
|
|
5056
6132
|
filename,
|
|
5057
6133
|
trim: opts.trim ?? true,
|
|
5058
6134
|
twee2Compat: opts.twee2Compat ?? false
|
|
5059
6135
|
});
|
|
5060
6136
|
return {
|
|
5061
6137
|
passages: result.passages,
|
|
5062
|
-
diagnostics: [...result.diagnostics]
|
|
6138
|
+
diagnostics: [...readDiagnostics, ...result.diagnostics]
|
|
5063
6139
|
};
|
|
5064
6140
|
}
|
|
6141
|
+
/**
|
|
6142
|
+
* A stylesheet or script passage named after its file, as Tweego names it. The name only labels
|
|
6143
|
+
* the code, so when another passage has it, the passage takes a free one (see `storyAdd()`).
|
|
6144
|
+
*/
|
|
6145
|
+
function codePassage(name, tag, text) {
|
|
6146
|
+
return withGeneratedName({
|
|
6147
|
+
name,
|
|
6148
|
+
tags: [tag],
|
|
6149
|
+
text
|
|
6150
|
+
}, {
|
|
6151
|
+
kind: "code",
|
|
6152
|
+
base: name
|
|
6153
|
+
});
|
|
6154
|
+
}
|
|
6155
|
+
/** Mark every passage of a parse result as a code passage named after its file. */
|
|
6156
|
+
function codeNames(result) {
|
|
6157
|
+
for (const p of result.passages) withGeneratedName(p, {
|
|
6158
|
+
kind: "code",
|
|
6159
|
+
base: p.name
|
|
6160
|
+
});
|
|
6161
|
+
return result;
|
|
6162
|
+
}
|
|
5065
6163
|
function parseTaggedFile(tag, filename) {
|
|
5066
|
-
const
|
|
6164
|
+
const diagnostics = [];
|
|
6165
|
+
const source = readUTF8(filename, diagnostics);
|
|
5067
6166
|
return {
|
|
5068
|
-
passages: [
|
|
5069
|
-
|
|
5070
|
-
tags: [tag],
|
|
5071
|
-
text: source
|
|
5072
|
-
}],
|
|
5073
|
-
diagnostics: []
|
|
6167
|
+
passages: [codePassage((0, node_path.basename)(filename), tag, source)],
|
|
6168
|
+
diagnostics
|
|
5074
6169
|
};
|
|
5075
6170
|
}
|
|
5076
6171
|
function parseMediaFile(tag, filename) {
|
|
5077
6172
|
const source = readBase64(filename);
|
|
6173
|
+
const name = baseNameWithoutExt(filename);
|
|
5078
6174
|
return {
|
|
5079
|
-
passages: [{
|
|
5080
|
-
name
|
|
6175
|
+
passages: [withGeneratedName({
|
|
6176
|
+
name,
|
|
5081
6177
|
tags: [tag],
|
|
5082
6178
|
text: `data:${mediaTypeFromFilename(filename)};base64,${source}`
|
|
5083
|
-
}
|
|
6179
|
+
}, {
|
|
6180
|
+
kind: "media",
|
|
6181
|
+
base: name,
|
|
6182
|
+
file: filename
|
|
6183
|
+
})],
|
|
5084
6184
|
diagnostics: []
|
|
5085
6185
|
};
|
|
5086
6186
|
}
|
|
@@ -5089,24 +6189,27 @@ function parseFontFile(filename) {
|
|
|
5089
6189
|
const name = (0, node_path.basename)(filename);
|
|
5090
6190
|
const family = baseNameWithoutExt(filename);
|
|
5091
6191
|
const ext = normalizedFileExt(filename);
|
|
5092
|
-
|
|
6192
|
+
const mediaType = mediaTypeFromExt(ext);
|
|
6193
|
+
const hint = fontFormatHint(ext);
|
|
6194
|
+
return codeNames({
|
|
5093
6195
|
passages: [{
|
|
5094
6196
|
name,
|
|
5095
6197
|
tags: ["stylesheet"],
|
|
5096
|
-
text: `@font-face {\n\tfont-family: "${family}";\n\tsrc: url("data:${
|
|
6198
|
+
text: `@font-face {\n\tfont-family: "${cssStringEscape(family)}";\n\tsrc: url("data:${mediaType};base64,${source}") format("${hint}");\n}`
|
|
5097
6199
|
}],
|
|
5098
6200
|
diagnostics: []
|
|
5099
|
-
};
|
|
6201
|
+
});
|
|
5100
6202
|
}
|
|
5101
6203
|
function parseHTMLFile(filename, opts) {
|
|
5102
|
-
const
|
|
6204
|
+
const readDiagnostics = [];
|
|
6205
|
+
const { story, diagnostics } = decompileHTMLForImport(readUTF8(filename, readDiagnostics), { trim: opts.trim ?? true });
|
|
5103
6206
|
return {
|
|
5104
6207
|
passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
|
|
5105
6208
|
name: "StoryTitle",
|
|
5106
6209
|
tags: [],
|
|
5107
6210
|
text: story.name
|
|
5108
6211
|
}, ...story.passages] : story.passages,
|
|
5109
|
-
diagnostics: [...diagnostics]
|
|
6212
|
+
diagnostics: [...readDiagnostics, ...diagnostics]
|
|
5110
6213
|
};
|
|
5111
6214
|
}
|
|
5112
6215
|
/**
|
|
@@ -5179,6 +6282,7 @@ function loadTwee(story, filename, opts, diagnostics) {
|
|
|
5179
6282
|
}
|
|
5180
6283
|
function loadTagged(story, tag, filename, diagnostics) {
|
|
5181
6284
|
const result = parseTaggedFile(tag, filename);
|
|
6285
|
+
diagnostics.push(...result.diagnostics);
|
|
5182
6286
|
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
5183
6287
|
}
|
|
5184
6288
|
function loadMedia(story, tag, filename, diagnostics) {
|
|
@@ -5195,6 +6299,7 @@ function loadFont(story, filename, diagnostics) {
|
|
|
5195
6299
|
*/
|
|
5196
6300
|
function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
|
|
5197
6301
|
const currentFiles = /* @__PURE__ */ new Set();
|
|
6302
|
+
const changedPaths = changedFiles === void 0 ? void 0 : new Set([...changedFiles].map((f) => (0, node_path.resolve)(f)));
|
|
5198
6303
|
for (const filename of filenames) {
|
|
5199
6304
|
if (processedFiles.has(filename)) {
|
|
5200
6305
|
diagnostics.push({
|
|
@@ -5207,8 +6312,8 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5207
6312
|
const optionsKey = parseOptionsKey(filename, opts);
|
|
5208
6313
|
const entry = cache.get(filename);
|
|
5209
6314
|
const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
|
|
5210
|
-
const changed =
|
|
5211
|
-
if (
|
|
6315
|
+
const changed = changedPaths?.has((0, node_path.resolve)(filename)) ?? false;
|
|
6316
|
+
if (changedPaths && cached && !changed) {
|
|
5212
6317
|
diagnostics.push(...cached.diagnostics);
|
|
5213
6318
|
for (const p of cached.passages) storyAdd(story, p, diagnostics);
|
|
5214
6319
|
processedFiles.add(filename);
|
|
@@ -5218,6 +6323,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5218
6323
|
try {
|
|
5219
6324
|
mtimeMs = (0, node_fs.statSync)(filename).mtimeMs;
|
|
5220
6325
|
} catch {
|
|
6326
|
+
cache.delete(filename);
|
|
5221
6327
|
continue;
|
|
5222
6328
|
}
|
|
5223
6329
|
if (cached && !changed && cached.mtimeMs === mtimeMs) {
|
|
@@ -5238,6 +6344,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5238
6344
|
diagnostics.push(...result.diagnostics);
|
|
5239
6345
|
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
5240
6346
|
} catch (e) {
|
|
6347
|
+
cache.delete(filename);
|
|
5241
6348
|
diagnostics.push({
|
|
5242
6349
|
level: "error",
|
|
5243
6350
|
message: `load ${filename}: ${e instanceof Error ? e.message : String(e)}`,
|
|
@@ -5261,6 +6368,11 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5261
6368
|
* This is how the HTML tokenizer reads an end tag; it ignores whatever sits between the tag name and the `>`.
|
|
5262
6369
|
*/
|
|
5263
6370
|
const CLOSING_HEAD_TAG = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>";
|
|
6371
|
+
/**
|
|
6372
|
+
* The start of an HTML body start tag: `<body` in any letter case, followed by whitespace, `/` or `>` (so not
|
|
6373
|
+
* `<bodyx>`). Only the `<body` is matched; the attributes and `>` after it are left alone.
|
|
6374
|
+
*/
|
|
6375
|
+
const BODY_START_TAG = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
|
|
5264
6376
|
/** The regular expression source matching exactly `text`. */
|
|
5265
6377
|
function literal(text) {
|
|
5266
6378
|
return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
@@ -5294,9 +6406,10 @@ function fillTemplateParts(parts) {
|
|
|
5294
6406
|
//#endregion
|
|
5295
6407
|
//#region src/modules.ts
|
|
5296
6408
|
/**
|
|
5297
|
-
* Load modules and return HTML tags to inject before the closing head tag.
|
|
6409
|
+
* Load modules and return HTML tags to inject before the closing head tag. `diagnostics` receives a warning for
|
|
6410
|
+
* each module that is not valid UTF-8 (it is read as Windows-1252).
|
|
5298
6411
|
*/
|
|
5299
|
-
function loadModules(filenames) {
|
|
6412
|
+
function loadModules(filenames, diagnostics) {
|
|
5300
6413
|
const processed = /* @__PURE__ */ new Set();
|
|
5301
6414
|
const headTags = [];
|
|
5302
6415
|
for (const filename of filenames) {
|
|
@@ -5305,10 +6418,10 @@ function loadModules(filenames) {
|
|
|
5305
6418
|
let tag = null;
|
|
5306
6419
|
switch (ext) {
|
|
5307
6420
|
case "css":
|
|
5308
|
-
tag = loadModuleTagged("style", filename);
|
|
6421
|
+
tag = loadModuleTagged("style", filename, diagnostics);
|
|
5309
6422
|
break;
|
|
5310
6423
|
case "js":
|
|
5311
|
-
tag = loadModuleTagged("script", filename);
|
|
6424
|
+
tag = loadModuleTagged("script", filename, diagnostics);
|
|
5312
6425
|
break;
|
|
5313
6426
|
case "otf":
|
|
5314
6427
|
case "ttf":
|
|
@@ -5323,8 +6436,8 @@ function loadModules(filenames) {
|
|
|
5323
6436
|
}
|
|
5324
6437
|
return headTags.join("\n");
|
|
5325
6438
|
}
|
|
5326
|
-
function loadModuleTagged(tag, filename) {
|
|
5327
|
-
const source = readUTF8(filename).trim();
|
|
6439
|
+
function loadModuleTagged(tag, filename, diagnostics) {
|
|
6440
|
+
const source = readUTF8(filename, diagnostics).trim();
|
|
5328
6441
|
if (source.length === 0) return null;
|
|
5329
6442
|
return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
|
|
5330
6443
|
}
|
|
@@ -5333,20 +6446,22 @@ function loadModuleFont(filename) {
|
|
|
5333
6446
|
const family = baseNameWithoutExt(filename);
|
|
5334
6447
|
const idSlug = `style-module-${slugify(family)}`;
|
|
5335
6448
|
const ext = normalizedFileExt(filename);
|
|
5336
|
-
|
|
6449
|
+
const mediaType = mediaTypeFromExt(ext);
|
|
6450
|
+
const hint = fontFormatHint(ext);
|
|
6451
|
+
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>`;
|
|
5337
6452
|
}
|
|
5338
6453
|
/**
|
|
5339
|
-
* Load the module tags and head file content to inject
|
|
6454
|
+
* Load the module tags and head file content to inject into the head (see `placeHead()`), joined by newlines,
|
|
5340
6455
|
* or `''` when there is nothing to inject.
|
|
5341
6456
|
*/
|
|
5342
6457
|
function loadHeadContent(modulePaths, headFile, diagnostics) {
|
|
5343
6458
|
const parts = [];
|
|
5344
6459
|
if (modulePaths.length > 0) {
|
|
5345
|
-
const modules = loadModules(modulePaths).trim();
|
|
6460
|
+
const modules = loadModules(modulePaths, diagnostics).trim();
|
|
5346
6461
|
if (modules.length > 0) parts.push(modules);
|
|
5347
6462
|
}
|
|
5348
6463
|
if (headFile) try {
|
|
5349
|
-
const source = readUTF8(headFile).trim();
|
|
6464
|
+
const source = readUTF8(headFile, diagnostics).trim();
|
|
5350
6465
|
if (source.length > 0) parts.push(source);
|
|
5351
6466
|
} catch (e) {
|
|
5352
6467
|
diagnostics?.push({
|
|
@@ -5357,16 +6472,51 @@ function loadHeadContent(modulePaths, headFile, diagnostics) {
|
|
|
5357
6472
|
return parts.join("\n");
|
|
5358
6473
|
}
|
|
5359
6474
|
/**
|
|
5360
|
-
*
|
|
5361
|
-
*
|
|
5362
|
-
*
|
|
6475
|
+
* Place `content` in a template, given the texts that `fillTemplateParts()` scans for the slot (the template, and
|
|
6476
|
+
* the footer of a pre-1.4 Twine 1 format):
|
|
6477
|
+
*
|
|
6478
|
+
* - on its own line before the first closing head tag, as Tweego does; the tag may use any letter case and
|
|
6479
|
+
* whitespace before its `>`, and is kept as written;
|
|
6480
|
+
* - in a template with no closing head tag (HTML lets a document leave it out), on its own line before the first
|
|
6481
|
+
* body start tag, where the HTML parser still puts it in the head, with a warning (Tweego drops it);
|
|
6482
|
+
* - in a template with neither, nowhere, with a warning.
|
|
6483
|
+
*
|
|
6484
|
+
* `owner` names the template in the warnings. Empty `content` needs no slot and gets no warning.
|
|
5363
6485
|
*/
|
|
5364
|
-
function
|
|
5365
|
-
if (content.length === 0) return
|
|
6486
|
+
function placeHead(content, texts, owner) {
|
|
6487
|
+
if (content.length === 0) return {
|
|
6488
|
+
slot: void 0,
|
|
6489
|
+
diagnostics: []
|
|
6490
|
+
};
|
|
6491
|
+
const found = (tag) => texts.some((text) => new RegExp(tag).test(text));
|
|
6492
|
+
if (found("<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>")) return {
|
|
6493
|
+
slot: beforeTag(CLOSING_HEAD_TAG, content),
|
|
6494
|
+
diagnostics: []
|
|
6495
|
+
};
|
|
6496
|
+
if (found("<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])")) {
|
|
6497
|
+
const message = `${owner} has no closing head tag; the modules and head file were injected before its body start tag.`;
|
|
6498
|
+
return {
|
|
6499
|
+
slot: beforeTag(BODY_START_TAG, content),
|
|
6500
|
+
diagnostics: [{
|
|
6501
|
+
level: "warning",
|
|
6502
|
+
message
|
|
6503
|
+
}]
|
|
6504
|
+
};
|
|
6505
|
+
}
|
|
6506
|
+
return {
|
|
6507
|
+
slot: void 0,
|
|
6508
|
+
diagnostics: [{
|
|
6509
|
+
level: "warning",
|
|
6510
|
+
message: `${owner} has no closing head tag and no body start tag; the modules and head file were not injected.`
|
|
6511
|
+
}]
|
|
6512
|
+
};
|
|
6513
|
+
}
|
|
6514
|
+
/** The slot that inserts `content` on its own line before the first match of `tag`, which is kept as written. */
|
|
6515
|
+
function beforeTag(tag, content) {
|
|
5366
6516
|
return {
|
|
5367
|
-
pattern:
|
|
6517
|
+
pattern: tag,
|
|
5368
6518
|
occurrences: "first",
|
|
5369
|
-
replacement: (
|
|
6519
|
+
replacement: (match) => `${content}\n${match}`
|
|
5370
6520
|
};
|
|
5371
6521
|
}
|
|
5372
6522
|
//#endregion
|
|
@@ -5405,14 +6555,19 @@ function toTwine2Archive(story, startName, options) {
|
|
|
5405
6555
|
}
|
|
5406
6556
|
/**
|
|
5407
6557
|
* Fill the Twine 2 format template: every `{{STORY_NAME}}` gets the escaped story name and the first
|
|
5408
|
-
* `{{STORY_DATA}}` the story data (as Tweego does), and `head` goes before the first closing head tag
|
|
5409
|
-
* found in the template in one pass, so a story
|
|
5410
|
-
* closing head tag stays literal.
|
|
6558
|
+
* `{{STORY_DATA}}` the story data (as Tweego does), and `head` goes before the first closing head tag (or, with a
|
|
6559
|
+
* warning, the body start tag; see `placeHead()`). All three are found in the template in one pass, so a story
|
|
6560
|
+
* name, passage text or head content holding a placeholder or a closing head tag stays literal.
|
|
6561
|
+
* `options.diagnostics` receives a warning when the format file is not valid UTF-8, and any warning about where
|
|
6562
|
+
* `head` went.
|
|
5411
6563
|
*/
|
|
5412
6564
|
function toTwine2HTML(story, format, startName, options) {
|
|
5413
6565
|
const name = htmlEscape(story.name);
|
|
5414
|
-
const
|
|
5415
|
-
const
|
|
6566
|
+
const template = readFormatSource(format, options?.diagnostics);
|
|
6567
|
+
const placement = placeHead(options?.head ?? "", [template], `Story format "${format.name}" ${format.version}`);
|
|
6568
|
+
options?.diagnostics?.push(...placement.diagnostics);
|
|
6569
|
+
const head = placement.slot;
|
|
6570
|
+
return fillTemplate(template, [
|
|
5416
6571
|
{
|
|
5417
6572
|
pattern: literal("{{STORY_NAME}}"),
|
|
5418
6573
|
occurrences: "all",
|
|
@@ -5433,8 +6588,7 @@ function toTwine2HTML(story, format, startName, options) {
|
|
|
5433
6588
|
}
|
|
5434
6589
|
},
|
|
5435
6590
|
...head === void 0 ? [] : [head]
|
|
5436
|
-
];
|
|
5437
|
-
return fillTemplate(readFormatSource(format), slots);
|
|
6591
|
+
]);
|
|
5438
6592
|
}
|
|
5439
6593
|
const OMITTING_TAGS = [
|
|
5440
6594
|
"Twine.private",
|
|
@@ -5530,29 +6684,35 @@ function toTwine1Archive(story, _startName) {
|
|
|
5530
6684
|
/**
|
|
5531
6685
|
* Fill the Twine 1 format template. The format's components are inserted first, one after another, as Tweego and
|
|
5532
6686
|
* Twine 1 do. Then the first of each story placeholder (`"VERSION"`, `"TIME"`, `"START_AT"`, `"STORY_SIZE"`,
|
|
5533
|
-
* `"STORY"`), the IFID comment and `head` (before the first closing head tag
|
|
5534
|
-
* passage name or story data holding a placeholder or a
|
|
6687
|
+
* `"STORY"`), the IFID comment and `head` (before the first closing head tag or, with a warning, the body start
|
|
6688
|
+
* tag; see `placeHead()`) are found in one pass, so a start passage name or story data holding a placeholder or a
|
|
6689
|
+
* closing head tag stays literal. `options.diagnostics` receives a warning for each format file that is not valid
|
|
6690
|
+
* UTF-8, and any warning about where `head` went.
|
|
5535
6691
|
*/
|
|
5536
6692
|
function toTwine1HTML(story, format, startName, options) {
|
|
5537
6693
|
const formatDir = (0, node_path.dirname)(format.filename);
|
|
5538
6694
|
const parentDir = (0, node_path.dirname)(formatDir);
|
|
5539
|
-
|
|
6695
|
+
const diagnostics = options?.diagnostics;
|
|
6696
|
+
let template = readFormatSource(format, diagnostics);
|
|
5540
6697
|
const { data, count } = getTwine1PassageChunk(story);
|
|
5541
|
-
template = tryReplaceComponent(template, "\"USER_LIB\"", (0, node_path.join)(formatDir, "userlib.js"), false);
|
|
5542
|
-
template = tryReplaceComponent(template, "\"ENGINE\"", (0, node_path.join)(parentDir, "engine.js"), true);
|
|
5543
|
-
template = tryReplaceComponent(template, "\"SUGARCANE\"", (0, node_path.join)(formatDir, "code.js"), true);
|
|
5544
|
-
template = tryReplaceComponent(template, "\"JONAH\"", (0, node_path.join)(formatDir, "code.js"), true);
|
|
5545
|
-
if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", (0, node_path.join)(parentDir, "jquery.js"), true);
|
|
5546
|
-
if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", (0, node_path.join)(parentDir, "modernizr.js"), true);
|
|
6698
|
+
template = tryReplaceComponent(template, "\"USER_LIB\"", (0, node_path.join)(formatDir, "userlib.js"), false, diagnostics);
|
|
6699
|
+
template = tryReplaceComponent(template, "\"ENGINE\"", (0, node_path.join)(parentDir, "engine.js"), true, diagnostics);
|
|
6700
|
+
template = tryReplaceComponent(template, "\"SUGARCANE\"", (0, node_path.join)(formatDir, "code.js"), true, diagnostics);
|
|
6701
|
+
template = tryReplaceComponent(template, "\"JONAH\"", (0, node_path.join)(formatDir, "code.js"), true, diagnostics);
|
|
6702
|
+
if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", (0, node_path.join)(parentDir, "jquery.js"), true, diagnostics);
|
|
6703
|
+
if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", (0, node_path.join)(parentDir, "modernizr.js"), true, diagnostics);
|
|
5547
6704
|
const isPre14 = !template.includes("\"STORY\"");
|
|
5548
|
-
const footer = isPre14 ? readFooter(formatDir) : "";
|
|
6705
|
+
const footer = isPre14 ? readFooter(formatDir, diagnostics) : "";
|
|
5549
6706
|
const storeArea = (template + footer).includes("<div id=\"store-area\"") ? "<div id=\"store-area\"" : "<div id=\"storeArea\"";
|
|
5550
6707
|
const safeIfid = htmlCommentSanitize(story.ifid);
|
|
5551
|
-
const
|
|
6708
|
+
const ifid = story.ifid ? {
|
|
5552
6709
|
pattern: literal(storeArea),
|
|
5553
6710
|
occurrences: "first",
|
|
5554
6711
|
replacement: (div) => `<!-- UUID://${safeIfid}// -->${div}`
|
|
5555
|
-
} : void 0
|
|
6712
|
+
} : void 0;
|
|
6713
|
+
const placement = placeHead(options?.head ?? "", isPre14 ? [template, footer] : [template], `Story format "${format.name}"`);
|
|
6714
|
+
diagnostics?.push(...placement.diagnostics);
|
|
6715
|
+
const late = [ifid, placement.slot].filter((slot) => slot !== void 0);
|
|
5556
6716
|
const displayStart = startName === "Start" ? "" : startName;
|
|
5557
6717
|
const slots = [
|
|
5558
6718
|
firstSlot("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`),
|
|
@@ -5592,9 +6752,9 @@ function firstSlot(token, value) {
|
|
|
5592
6752
|
};
|
|
5593
6753
|
}
|
|
5594
6754
|
/** The footer of a pre-1.4 format, or the default one when the format has none. */
|
|
5595
|
-
function readFooter(formatDir) {
|
|
6755
|
+
function readFooter(formatDir, diagnostics) {
|
|
5596
6756
|
try {
|
|
5597
|
-
return (
|
|
6757
|
+
return readUTF8((0, node_path.join)(formatDir, "footer.html"), diagnostics);
|
|
5598
6758
|
} catch {
|
|
5599
6759
|
return "</div>\n</body>\n</html>\n";
|
|
5600
6760
|
}
|
|
@@ -5622,11 +6782,10 @@ function getTwine1PassageChunk(story) {
|
|
|
5622
6782
|
count
|
|
5623
6783
|
};
|
|
5624
6784
|
}
|
|
5625
|
-
function tryReplaceComponent(template, placeholder, componentPath, required) {
|
|
6785
|
+
function tryReplaceComponent(template, placeholder, componentPath, required, diagnostics) {
|
|
5626
6786
|
if (!template.includes(placeholder)) return template;
|
|
5627
6787
|
try {
|
|
5628
|
-
|
|
5629
|
-
if (content.charCodeAt(0) === 65279) content = content.slice(1);
|
|
6788
|
+
const content = readUTF8(componentPath, diagnostics);
|
|
5630
6789
|
return template.replace(placeholder, () => content);
|
|
5631
6790
|
} catch (e) {
|
|
5632
6791
|
if (required) throw new Error(`Required format component not found: ${componentPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -5640,11 +6799,16 @@ function tryReplaceComponent(template, placeholder, componentPath, required) {
|
|
|
5640
6799
|
* from its loaded text, so changes made after loading (a start passage override, test mode) are kept.
|
|
5641
6800
|
* Tweego writes the loaded text; its own normalization makes the two the same for an unchanged story.
|
|
5642
6801
|
*
|
|
6802
|
+
* Twee cannot write every passage so that it reads back the same (see `tweeRoundTrip()`); `diagnostics`
|
|
6803
|
+
* receives a warning for each passage that will not. Tweego writes the same Twee without one.
|
|
6804
|
+
*
|
|
5643
6805
|
* `addStoryData`: add a StoryData passage when the story has none, so that StoryData fields the
|
|
5644
6806
|
* compile options set are recorded. Default: false.
|
|
5645
6807
|
*/
|
|
5646
6808
|
function toTwee(story, outMode, options = {}) {
|
|
5647
|
-
|
|
6809
|
+
const written = withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => tweeRoundTrip(p, outMode));
|
|
6810
|
+
options.diagnostics?.push(...written.flatMap(({ diagnostics }) => diagnostics));
|
|
6811
|
+
return written.map(({ passage }) => passageToTwee(passage, outMode)).join("");
|
|
5648
6812
|
}
|
|
5649
6813
|
function withEffectiveStoryData(story, addStoryData) {
|
|
5650
6814
|
const storyData = {
|
|
@@ -5669,6 +6833,64 @@ function withEffectiveStoryData(story, addStoryData) {
|
|
|
5669
6833
|
function isStoryDataJSON(text) {
|
|
5670
6834
|
return unmarshalStoryData(createStory(), text) === null;
|
|
5671
6835
|
}
|
|
6836
|
+
/** Characters Twee 3 escapes in names and tags, and Twee 1 cannot write there at all. */
|
|
6837
|
+
const TWEE1_UNWRITABLE = /[[\]{}\\]/;
|
|
6838
|
+
/**
|
|
6839
|
+
* Check that Twee reads `p` back as written, and say what it will not. Twee has no way to escape passage text,
|
|
6840
|
+
* and its parser trims names and splits tags at whitespace, so these do not survive:
|
|
6841
|
+
*
|
|
6842
|
+
* - a text line that starts with `::`, which reads as a new passage header. In stylesheet and script passages such
|
|
6843
|
+
* a line is indented by one space, which keeps the CSS or JavaScript working (only a template literal or a
|
|
6844
|
+
* string continued across lines, in either, would see the space); the warning says so. Other passages' text is
|
|
6845
|
+
* written as it is.
|
|
6846
|
+
* - a name that is empty, has leading or trailing whitespace, or holds a line break;
|
|
6847
|
+
* - a tag that is empty or holds whitespace;
|
|
6848
|
+
* - in Twee 1, which escapes nothing, a name or tag holding `[`, `]`, `{`, `}` or `\`.
|
|
6849
|
+
*/
|
|
6850
|
+
function tweeRoundTrip(p, outMode) {
|
|
6851
|
+
const label = `Passage ${JSON.stringify(p.name)}`;
|
|
6852
|
+
const diagnostics = [];
|
|
6853
|
+
const warn = (problem) => {
|
|
6854
|
+
diagnostics.push({
|
|
6855
|
+
level: "warning",
|
|
6856
|
+
message: `${label} cannot be written as Twee that reads back the same: ${problem}.`
|
|
6857
|
+
});
|
|
6858
|
+
};
|
|
6859
|
+
if (p.name.trim() === "") warn("its name is empty");
|
|
6860
|
+
else if (p.name.trim() !== p.name) warn("its name has leading or trailing whitespace, which Twee drops");
|
|
6861
|
+
if (/[\r\n]/.test(p.name)) warn("its name has a line break, which ends a Twee passage header");
|
|
6862
|
+
if (outMode === "twee1" && TWEE1_UNWRITABLE.test(p.name)) warn("its name has \"[\", \"]\", \"{\", \"}\" or \"\\\", which Twee 1 cannot escape");
|
|
6863
|
+
for (const tag of p.tags) {
|
|
6864
|
+
if (tag === "" || /\s/.test(tag)) warn(`its tag ${JSON.stringify(tag)} is empty or has whitespace, which splits tags in Twee`);
|
|
6865
|
+
if (outMode === "twee1" && TWEE1_UNWRITABLE.test(tag)) warn(`its tag ${JSON.stringify(tag)} has "[", "]", "{", "}" or "\\", which Twee 1 cannot escape`);
|
|
6866
|
+
}
|
|
6867
|
+
const lines = p.text.split(/(\r\n?|\n)/);
|
|
6868
|
+
const headerLines = lines.flatMap((line, i) => i % 2 === 0 && line.startsWith("::") ? [i / 2 + 1] : []);
|
|
6869
|
+
if (headerLines.length === 0) return {
|
|
6870
|
+
passage: p,
|
|
6871
|
+
diagnostics
|
|
6872
|
+
};
|
|
6873
|
+
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`;
|
|
6874
|
+
if (!p.tags.some((tag) => tag === "stylesheet" || tag === "script")) {
|
|
6875
|
+
warn(where);
|
|
6876
|
+
return {
|
|
6877
|
+
passage: p,
|
|
6878
|
+
diagnostics
|
|
6879
|
+
};
|
|
6880
|
+
}
|
|
6881
|
+
diagnostics.push({
|
|
6882
|
+
level: "warning",
|
|
6883
|
+
message: `${label}: ${where}; ${headerLines.length === 1 ? "it was" : "they were"} written indented by one space.`
|
|
6884
|
+
});
|
|
6885
|
+
const text = lines.map((line, i) => i % 2 === 0 && line.startsWith("::") ? ` ${line}` : line).join("");
|
|
6886
|
+
return {
|
|
6887
|
+
passage: {
|
|
6888
|
+
...p,
|
|
6889
|
+
text
|
|
6890
|
+
},
|
|
6891
|
+
diagnostics
|
|
6892
|
+
};
|
|
6893
|
+
}
|
|
5672
6894
|
//#endregion
|
|
5673
6895
|
//#region src/passage-omission.ts
|
|
5674
6896
|
/**
|
|
@@ -5735,42 +6957,52 @@ async function compile(options) {
|
|
|
5735
6957
|
return buildOutput(options);
|
|
5736
6958
|
}
|
|
5737
6959
|
/**
|
|
5738
|
-
* Compile and write to a file.
|
|
6960
|
+
* Compile and write to a file. The file is replaced atomically: a reader sees the previous
|
|
6961
|
+
* build or the new one, never part of it, and a failed write leaves the previous build.
|
|
5739
6962
|
*/
|
|
5740
6963
|
async function compileToFile(options) {
|
|
5741
6964
|
const result = await compileForOutputFile(options, options.outFile);
|
|
5742
|
-
(
|
|
6965
|
+
writeFileAtomic(options.outFile, result.output);
|
|
5743
6966
|
return result;
|
|
5744
6967
|
}
|
|
5745
6968
|
/**
|
|
5746
|
-
* Builds as compileToFile() does for `
|
|
6969
|
+
* Builds as compileToFile() does for `outputs`, without writing them: an output may sit
|
|
5747
6970
|
* inside a source folder, so its last build is left out of the sources and modules and
|
|
5748
|
-
* never read back.
|
|
5749
|
-
*
|
|
5750
|
-
*
|
|
6971
|
+
* never read back. `outputs` is the one file the CLI writes, or every path a bundler's
|
|
6972
|
+
* build writes (see BuildOutputs). The caller decides whether the result is written.
|
|
6973
|
+
* With no outputs (output to stdout, or none), this is compile(). With `cache`, files
|
|
6974
|
+
* are cached as compileIncremental() caches them.
|
|
6975
|
+
*
|
|
6976
|
+
* Throws a TweeTsError when a source, module or head file named directly is an output.
|
|
5751
6977
|
*
|
|
5752
6978
|
* Internal, for the CLI and the bundler plugins; not part of the public API.
|
|
5753
6979
|
*/
|
|
5754
|
-
async function compileForOutputFile(options,
|
|
5755
|
-
return buildOutput(options, cache, void 0,
|
|
6980
|
+
async function compileForOutputFile(options, outputs, cache) {
|
|
6981
|
+
return buildOutput(options, cache, void 0, outputs);
|
|
5756
6982
|
}
|
|
5757
6983
|
/**
|
|
5758
6984
|
* Compile with incremental caching support.
|
|
5759
6985
|
* Plugins and advanced users can manage their own cache and changed-file tracking.
|
|
5760
6986
|
*
|
|
5761
6987
|
* Without `changedFiles`, a cached file is reused while its modification time is unchanged.
|
|
5762
|
-
* With it, a file it names
|
|
5763
|
-
*
|
|
5764
|
-
*
|
|
6988
|
+
* With it, a file it names is always reparsed, whatever its modification time, and every
|
|
6989
|
+
* other cached file is reused as it is. A file may be named by an absolute path or by a path
|
|
6990
|
+
* relative to the working directory, with or without a leading `./`: entries are matched to
|
|
6991
|
+
* the source files by resolved path. A file that fails to load is dropped from the cache, so
|
|
6992
|
+
* the next build tries it again.
|
|
5765
6993
|
*/
|
|
5766
6994
|
async function compileIncremental(options, cache, changedFiles) {
|
|
5767
6995
|
return buildOutput(options, cache, changedFiles);
|
|
5768
6996
|
}
|
|
5769
6997
|
/**
|
|
5770
|
-
* Watch for file changes and recompile. Every build is written to `outFile
|
|
5771
|
-
* one whose diagnostics report errors.
|
|
5772
|
-
*
|
|
5773
|
-
*
|
|
6998
|
+
* Watch for file changes and recompile. Every build is written to `outFile` (replacing it
|
|
6999
|
+
* atomically, as compileToFile() does), including one whose diagnostics report errors.
|
|
7000
|
+
* Builds run one at a time, and each one is written and reported as it finishes, in order:
|
|
7001
|
+
* changes made during a build go into one follow-up build after it. A watched path that
|
|
7002
|
+
* can't be watched is passed to `onError`.
|
|
7003
|
+
*
|
|
7004
|
+
* Aborting the returned controller, or the `signal` in the options, stops watching and
|
|
7005
|
+
* aborts the story format requests of the build in progress.
|
|
5774
7006
|
*/
|
|
5775
7007
|
async function watch(options) {
|
|
5776
7008
|
return watchWithWriteFilter(options, () => true);
|
|
@@ -5782,9 +7014,13 @@ async function watch(options) {
|
|
|
5782
7014
|
* Internal, for the CLI, which keeps the last good output when a rebuild reports errors;
|
|
5783
7015
|
* not part of the public API.
|
|
5784
7016
|
*/
|
|
5785
|
-
async function watchWithWriteFilter(options, shouldWrite) {
|
|
7017
|
+
async function watchWithWriteFilter(options, shouldWrite, hooks = {}) {
|
|
5786
7018
|
const controller = new AbortController();
|
|
5787
7019
|
const cache = /* @__PURE__ */ new Map();
|
|
7020
|
+
const buildOptions = {
|
|
7021
|
+
...options,
|
|
7022
|
+
signal: controller.signal
|
|
7023
|
+
};
|
|
5788
7024
|
const filePaths = options.sources.filter((s) => typeof s === "string");
|
|
5789
7025
|
const modulePaths = options.modules ?? [];
|
|
5790
7026
|
const headPaths = options.headFile ? [options.headFile] : [];
|
|
@@ -5802,13 +7038,20 @@ async function watchWithWriteFilter(options, shouldWrite) {
|
|
|
5802
7038
|
};
|
|
5803
7039
|
let building = false;
|
|
5804
7040
|
let queued;
|
|
7041
|
+
const reportError = (error) => {
|
|
7042
|
+
try {
|
|
7043
|
+
options.onError?.(error);
|
|
7044
|
+
} catch (e) {
|
|
7045
|
+
console.error(`twee-ts watch: onError threw while handling "${error.message}": ${toError(e).message}`);
|
|
7046
|
+
}
|
|
7047
|
+
};
|
|
5805
7048
|
const deliver = (outcome) => {
|
|
5806
7049
|
try {
|
|
5807
7050
|
if (!outcome.ok) throw outcome.error;
|
|
5808
|
-
if (shouldWrite(outcome.result)) (
|
|
7051
|
+
if (shouldWrite(outcome.result)) writeFileAtomic(options.outFile, outcome.result.output);
|
|
5809
7052
|
options.onBuild?.(outcome.result);
|
|
5810
7053
|
} catch (e) {
|
|
5811
|
-
|
|
7054
|
+
reportError(toError(e));
|
|
5812
7055
|
}
|
|
5813
7056
|
};
|
|
5814
7057
|
const drain = async (first) => {
|
|
@@ -5816,8 +7059,7 @@ async function watchWithWriteFilter(options, shouldWrite) {
|
|
|
5816
7059
|
try {
|
|
5817
7060
|
let request = first;
|
|
5818
7061
|
while (request !== void 0) {
|
|
5819
|
-
const
|
|
5820
|
-
const outcome = await buildOutput(options, cache, current.changedFiles, options.outFile).then((result) => ({
|
|
7062
|
+
const outcome = await buildOutput(buildOptions, cache, request.changedFiles, options.outFile).then((result) => ({
|
|
5821
7063
|
ok: true,
|
|
5822
7064
|
result
|
|
5823
7065
|
}), (e) => ({
|
|
@@ -5825,12 +7067,13 @@ async function watchWithWriteFilter(options, shouldWrite) {
|
|
|
5825
7067
|
error: toError(e)
|
|
5826
7068
|
}));
|
|
5827
7069
|
if (controller.signal.aborted) return;
|
|
5828
|
-
|
|
7070
|
+
deliver(outcome);
|
|
7071
|
+
request = queued;
|
|
5829
7072
|
queued = void 0;
|
|
5830
|
-
if (request === void 0) deliver(outcome);
|
|
5831
7073
|
}
|
|
5832
7074
|
} finally {
|
|
5833
7075
|
building = false;
|
|
7076
|
+
hooks.onIdle?.();
|
|
5834
7077
|
}
|
|
5835
7078
|
};
|
|
5836
7079
|
const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
|
|
@@ -5840,12 +7083,17 @@ async function watchWithWriteFilter(options, shouldWrite) {
|
|
|
5840
7083
|
queued = queued === void 0 ? request : mergeBuildRequests(queued, request);
|
|
5841
7084
|
return;
|
|
5842
7085
|
}
|
|
5843
|
-
drain(request);
|
|
5844
|
-
}, ignore);
|
|
7086
|
+
drain(request).catch((e) => reportError(toError(e)));
|
|
7087
|
+
}, ignore, reportError);
|
|
7088
|
+
const outer = options.signal;
|
|
7089
|
+
const onOuterAbort = () => controller.abort(outer?.reason);
|
|
5845
7090
|
controller.signal.addEventListener("abort", () => {
|
|
5846
7091
|
queued = void 0;
|
|
5847
7092
|
handle.close();
|
|
7093
|
+
outer?.removeEventListener("abort", onOuterAbort);
|
|
5848
7094
|
});
|
|
7095
|
+
if (outer?.aborted) onOuterAbort();
|
|
7096
|
+
else outer?.addEventListener("abort", onOuterAbort, { once: true });
|
|
5849
7097
|
return controller;
|
|
5850
7098
|
}
|
|
5851
7099
|
/** One build covering both requests: a full build if either is one, else every changed file. */
|
|
@@ -5857,10 +7105,13 @@ function toError(e) {
|
|
|
5857
7105
|
return e instanceof Error ? e : new Error(String(e));
|
|
5858
7106
|
}
|
|
5859
7107
|
/**
|
|
5860
|
-
* `
|
|
5861
|
-
*
|
|
7108
|
+
* `outputs`: what the build writes (an output file's path, or a bundler's outputs),
|
|
7109
|
+
* which source and module discovery skip, so an output inside a source folder is
|
|
7110
|
+
* never loaded back. A source, module or head file named directly that is an output
|
|
7111
|
+
* is a TweeTsError, as in Tweego: writing the build would overwrite it.
|
|
5862
7112
|
*/
|
|
5863
|
-
async function buildOutput(options, cache, changedFiles,
|
|
7113
|
+
async function buildOutput(options, cache, changedFiles, outputs) {
|
|
7114
|
+
const written = toBuildOutputs(outputs);
|
|
5864
7115
|
const diagnostics = [];
|
|
5865
7116
|
const outputMode = options.outputMode ?? "html";
|
|
5866
7117
|
const trim = options.trim ?? true;
|
|
@@ -5868,13 +7119,18 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5868
7119
|
const testMode = options.testMode ?? false;
|
|
5869
7120
|
const noRemote = options.noRemote ?? false;
|
|
5870
7121
|
const sourceInfo = options.sourceInfo ?? false;
|
|
7122
|
+
options.signal?.throwIfAborted();
|
|
7123
|
+
const timeout = options.formatFetchTimeout;
|
|
7124
|
+
if (timeout !== void 0 && !(timeout >= 0)) throw new TweeTsError(`formatFetchTimeout must be 0 or more milliseconds, not ${timeout}.`);
|
|
5871
7125
|
clearIndexCache();
|
|
5872
7126
|
const filePaths = [];
|
|
5873
7127
|
const inlineSources = [];
|
|
5874
7128
|
for (const source of options.sources) if (typeof source === "string") filePaths.push(source);
|
|
5875
7129
|
else inlineSources.push(source);
|
|
5876
|
-
const
|
|
5877
|
-
diagnostics.push(...
|
|
7130
|
+
const sourcePaths = getFilenames(filePaths, written, options.exclude);
|
|
7131
|
+
diagnostics.push(...sourcePaths.diagnostics);
|
|
7132
|
+
rejectOutputSources(sourcePaths.outputSources, diagnostics);
|
|
7133
|
+
const sourceFilenames = sourcePaths.filenames;
|
|
5878
7134
|
const story = createStory();
|
|
5879
7135
|
const processedFiles = /* @__PURE__ */ new Set();
|
|
5880
7136
|
if (cache) loadSourcesCached(story, sourceFilenames, {
|
|
@@ -5903,7 +7159,10 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5903
7159
|
switch (outputMode) {
|
|
5904
7160
|
case "twee3":
|
|
5905
7161
|
case "twee1":
|
|
5906
|
-
output = toTwee(story, outputMode, {
|
|
7162
|
+
output = toTwee(story, outputMode, {
|
|
7163
|
+
addStoryData: Boolean(options.startPassage) || testMode,
|
|
7164
|
+
diagnostics
|
|
7165
|
+
});
|
|
5907
7166
|
break;
|
|
5908
7167
|
case "twine2-archive":
|
|
5909
7168
|
output = toTwine2Archive(story, startName, { sourceInfo });
|
|
@@ -5921,13 +7180,20 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5921
7180
|
level: "error",
|
|
5922
7181
|
message: "Special passage \"StoryTitle\" not found."
|
|
5923
7182
|
});
|
|
5924
|
-
const modules = getFilenames(options.modules ?? [],
|
|
7183
|
+
const modules = getFilenames(options.modules ?? [], written);
|
|
5925
7184
|
diagnostics.push(...modules.diagnostics);
|
|
7185
|
+
const { headFile } = options;
|
|
7186
|
+
const headIsOutput = headFile !== void 0 && outputPaths(written).isFile(realPathOf(headFile));
|
|
7187
|
+
rejectOutputSources([...modules.outputSources, ...headIsOutput ? [headFile] : []], diagnostics);
|
|
5926
7188
|
const head = loadHeadContent(modules.filenames, options.headFile, diagnostics);
|
|
5927
7189
|
output = format.isTwine2 ? toTwine2HTML(story, format, startName, {
|
|
5928
7190
|
sourceInfo,
|
|
5929
|
-
head
|
|
5930
|
-
|
|
7191
|
+
head,
|
|
7192
|
+
diagnostics
|
|
7193
|
+
}) : toTwine1HTML(story, format, startName, {
|
|
7194
|
+
head,
|
|
7195
|
+
diagnostics
|
|
7196
|
+
});
|
|
5931
7197
|
break;
|
|
5932
7198
|
}
|
|
5933
7199
|
default: throw new TweeTsError(`Unhandled output mode: ${outputMode}`, diagnostics);
|
|
@@ -5944,6 +7210,16 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5944
7210
|
stats
|
|
5945
7211
|
};
|
|
5946
7212
|
}
|
|
7213
|
+
/**
|
|
7214
|
+
* Throws for the first of `paths`, inputs named directly that are also an output:
|
|
7215
|
+
* writing the build would overwrite them. An output found while walking a source
|
|
7216
|
+
* folder is skipped instead (see getFilenames).
|
|
7217
|
+
*/
|
|
7218
|
+
function rejectOutputSources(paths, diagnostics) {
|
|
7219
|
+
const [first] = paths;
|
|
7220
|
+
if (first === void 0) return;
|
|
7221
|
+
throw new TweeTsError(`path ${first}: Output file cannot be an input source.`, [...diagnostics]);
|
|
7222
|
+
}
|
|
5947
7223
|
function ensureIFID(story, diagnostics) {
|
|
5948
7224
|
if (story.ifid !== "") return;
|
|
5949
7225
|
if (story.legacyIFID !== "") {
|
|
@@ -7513,7 +8789,7 @@ function storyInspect(story, options = {}) {
|
|
|
7513
8789
|
if (isStoryPassage(p)) storyPassages.push(p.name);
|
|
7514
8790
|
if (isInfoPassage(p)) infoPassages.push(p.name);
|
|
7515
8791
|
tagsByPassage.set(p.name, [...p.tags]);
|
|
7516
|
-
for (const tag of p.tags) {
|
|
8792
|
+
for (const tag of new Set(p.tags)) {
|
|
7517
8793
|
tagSet.add(tag);
|
|
7518
8794
|
let list = passagesByTag.get(tag);
|
|
7519
8795
|
if (!list) {
|
|
@@ -7657,46 +8933,100 @@ const VALID_OUTPUT_MODES = [
|
|
|
7657
8933
|
"json"
|
|
7658
8934
|
];
|
|
7659
8935
|
const VALID_WORD_COUNT_METHODS = ["tweego", "whitespace"];
|
|
7660
|
-
/**
|
|
7661
|
-
|
|
8936
|
+
/** One entry per {@link TweeTsConfig} key: the compiler rejects a missing or an extra key here. */
|
|
8937
|
+
const CONFIG_KEY_SET = {
|
|
8938
|
+
sources: true,
|
|
8939
|
+
exclude: true,
|
|
8940
|
+
output: true,
|
|
8941
|
+
outputMode: true,
|
|
8942
|
+
formatId: true,
|
|
8943
|
+
startPassage: true,
|
|
8944
|
+
formatPaths: true,
|
|
8945
|
+
formatIndices: true,
|
|
8946
|
+
formatUrls: true,
|
|
8947
|
+
useTweegoPath: true,
|
|
8948
|
+
modules: true,
|
|
8949
|
+
headFile: true,
|
|
8950
|
+
trim: true,
|
|
8951
|
+
twee2Compat: true,
|
|
8952
|
+
testMode: true,
|
|
8953
|
+
noRemote: true,
|
|
8954
|
+
formatFetchTimeout: true,
|
|
8955
|
+
tagAliases: true,
|
|
8956
|
+
sourceInfo: true,
|
|
8957
|
+
wordCountMethod: true
|
|
8958
|
+
};
|
|
8959
|
+
/** The keys a config file may hold besides `$schema`; the JSON schema's `properties` list the same keys. */
|
|
8960
|
+
const CONFIG_KEYS = Object.keys(CONFIG_KEY_SET);
|
|
8961
|
+
/** The key that only references the JSON schema, for editors. */
|
|
8962
|
+
const SCHEMA_KEY = "$schema";
|
|
8963
|
+
/** A key folded so that spellings differing only in letter case, `-` or `_` compare equal. */
|
|
8964
|
+
const foldKey = (key) => key.toLowerCase().replace(/[-_]/g, "");
|
|
8965
|
+
/**
|
|
8966
|
+
* Load a config file from the given directory (default: cwd). Returns null if not found.
|
|
8967
|
+
*
|
|
8968
|
+
* @param diagnostics Receives warnings that do not stop the config from loading: keys the config does not
|
|
8969
|
+
* define, and a file that is not valid UTF-8 (it is read as Windows-1252).
|
|
8970
|
+
* @throws When the file is not valid JSON or fails {@link validateConfig}.
|
|
8971
|
+
*/
|
|
8972
|
+
function loadConfig(dir, diagnostics) {
|
|
7662
8973
|
const base = dir ?? process.cwd();
|
|
7663
8974
|
const configPath = (0, node_path.join)(base, CONFIG_FILENAME);
|
|
7664
8975
|
if (!(0, node_fs.existsSync)(configPath)) return null;
|
|
8976
|
+
const readDiagnostics = [];
|
|
7665
8977
|
let raw;
|
|
7666
8978
|
try {
|
|
7667
|
-
raw = (
|
|
8979
|
+
raw = readUTF8(configPath, readDiagnostics);
|
|
7668
8980
|
} catch {
|
|
7669
8981
|
return null;
|
|
7670
8982
|
}
|
|
7671
|
-
|
|
7672
|
-
|
|
7673
|
-
|
|
7674
|
-
} catch (e) {
|
|
7675
|
-
throw new Error(`Invalid JSON in ${configPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
7676
|
-
}
|
|
7677
|
-
const errors = validateConfig(data);
|
|
7678
|
-
if (errors.length > 0) throw new Error(`Invalid config in ${configPath}:\n ${errors.join("\n ")}`);
|
|
7679
|
-
return data;
|
|
8983
|
+
const config = parseConfig(raw, configPath, readDiagnostics);
|
|
8984
|
+
diagnostics?.push(...readDiagnostics);
|
|
8985
|
+
return config;
|
|
7680
8986
|
}
|
|
7681
|
-
/**
|
|
7682
|
-
|
|
8987
|
+
/**
|
|
8988
|
+
* Load a config from a specific file path.
|
|
8989
|
+
*
|
|
8990
|
+
* @param diagnostics Receives warnings that do not stop the config from loading: keys the config does not
|
|
8991
|
+
* define, and a file that is not valid UTF-8 (it is read as Windows-1252).
|
|
8992
|
+
* @throws When the file cannot be read, is not valid JSON or fails {@link validateConfig}.
|
|
8993
|
+
*/
|
|
8994
|
+
function loadConfigFile(filePath, diagnostics) {
|
|
8995
|
+
const readDiagnostics = [];
|
|
7683
8996
|
let raw;
|
|
7684
8997
|
try {
|
|
7685
|
-
raw = (
|
|
8998
|
+
raw = readUTF8(filePath, readDiagnostics);
|
|
7686
8999
|
} catch (e) {
|
|
7687
9000
|
throw new Error(`Cannot read config file ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
7688
9001
|
}
|
|
9002
|
+
const config = parseConfig(raw, filePath, readDiagnostics);
|
|
9003
|
+
diagnostics?.push(...readDiagnostics);
|
|
9004
|
+
return config;
|
|
9005
|
+
}
|
|
9006
|
+
/**
|
|
9007
|
+
* Parse and validate the text of the config file at `path`. Unknown keys are added to `diagnostics` as
|
|
9008
|
+
* warnings, so a config with a stray key keeps loading.
|
|
9009
|
+
*/
|
|
9010
|
+
function parseConfig(raw, path, diagnostics) {
|
|
7689
9011
|
let data;
|
|
7690
9012
|
try {
|
|
7691
9013
|
data = JSON.parse(raw);
|
|
7692
9014
|
} catch (e) {
|
|
7693
|
-
throw new Error(`Invalid JSON in ${
|
|
9015
|
+
throw new Error(`Invalid JSON in ${path}: ${e instanceof Error ? e.message : String(e)}`);
|
|
7694
9016
|
}
|
|
7695
9017
|
const errors = validateConfig(data);
|
|
7696
|
-
if (errors.length > 0) throw new Error(`Invalid config in ${
|
|
9018
|
+
if (errors.length > 0) throw new Error(`Invalid config in ${path}:\n ${errors.join("\n ")}`);
|
|
9019
|
+
diagnostics.push(...unknownConfigKeyWarnings(data).map((message) => ({
|
|
9020
|
+
level: "warning",
|
|
9021
|
+
message: `${path}: ${message}`,
|
|
9022
|
+
file: path
|
|
9023
|
+
})));
|
|
7697
9024
|
return data;
|
|
7698
9025
|
}
|
|
7699
|
-
/**
|
|
9026
|
+
/**
|
|
9027
|
+
* Validate a config object. Returns an array of error strings (empty = valid).
|
|
9028
|
+
* Keys the config does not define are not errors; {@link unknownConfigKeyWarnings} reports them.
|
|
9029
|
+
*/
|
|
7700
9030
|
function validateConfig(data) {
|
|
7701
9031
|
const errors = [];
|
|
7702
9032
|
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
@@ -7729,6 +9059,10 @@ function validateConfig(data) {
|
|
|
7729
9059
|
if (!Array.isArray(obj[key])) errors.push(`"${key}" must be an array.`);
|
|
7730
9060
|
else if (!obj[key].every((v) => typeof v === "string")) errors.push(`"${key}" must be an array of strings.`);
|
|
7731
9061
|
}
|
|
9062
|
+
if ("formatFetchTimeout" in obj) {
|
|
9063
|
+
const timeout = obj["formatFetchTimeout"];
|
|
9064
|
+
if (typeof timeout !== "number" || !(timeout >= 0)) errors.push("\"formatFetchTimeout\" must be a number of milliseconds, 0 or more.");
|
|
9065
|
+
}
|
|
7732
9066
|
if ("tagAliases" in obj) {
|
|
7733
9067
|
if (typeof obj["tagAliases"] !== "object" || obj["tagAliases"] === null || Array.isArray(obj["tagAliases"])) errors.push("\"tagAliases\" must be an object.");
|
|
7734
9068
|
else {
|
|
@@ -7744,6 +9078,20 @@ function validateConfig(data) {
|
|
|
7744
9078
|
}
|
|
7745
9079
|
return errors;
|
|
7746
9080
|
}
|
|
9081
|
+
/**
|
|
9082
|
+
* Warnings for the keys of a config object that the config does not define (the JSON schema's
|
|
9083
|
+
* `additionalProperties: false`), other than `$schema`. twee-ts ignores such a key, so a misspelt one would
|
|
9084
|
+
* otherwise leave its option at the default without a word. A key that differs from a defined one only in
|
|
9085
|
+
* letter case, `-` or `_` (`formatID`, `output-mode`) gets a suggestion. Returns `[]` for anything that is not
|
|
9086
|
+
* an object, which {@link validateConfig} reports.
|
|
9087
|
+
*/
|
|
9088
|
+
function unknownConfigKeyWarnings(data) {
|
|
9089
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) return [];
|
|
9090
|
+
return Object.keys(data).filter((key) => key !== SCHEMA_KEY && !Object.hasOwn(CONFIG_KEY_SET, key)).map((key) => {
|
|
9091
|
+
const suggestion = CONFIG_KEYS.find((known) => foldKey(known) === foldKey(key));
|
|
9092
|
+
return `Unknown config key "${key}"${suggestion === void 0 ? "" : ` (did you mean "${suggestion}"?)`}; it is ignored.`;
|
|
9093
|
+
});
|
|
9094
|
+
}
|
|
7747
9095
|
/** Return a default config JSON string for --init scaffolding. */
|
|
7748
9096
|
function scaffoldConfig() {
|
|
7749
9097
|
return JSON.stringify({
|
|
@@ -7786,6 +9134,7 @@ exports.scaffoldConfig = scaffoldConfig;
|
|
|
7786
9134
|
exports.semverCompare = semverCompare;
|
|
7787
9135
|
exports.storyInspect = storyInspect;
|
|
7788
9136
|
exports.tweeLexer = tweeLexer;
|
|
9137
|
+
exports.unknownConfigKeyWarnings = unknownConfigKeyWarnings;
|
|
7789
9138
|
exports.validateConfig = validateConfig;
|
|
7790
9139
|
exports.validateIFID = validateIFID;
|
|
7791
9140
|
exports.watch = watch;
|