@rohal12/twee-ts 1.17.1 → 1.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/twee-ts.js +2052 -596
- package/dist/bin/twee-ts.js.map +1 -1
- package/dist/index.cjs +2066 -594
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +141 -52
- package/dist/index.d.ts +141 -52
- package/dist/index.js +2067 -596
- package/dist/index.js.map +1 -1
- package/dist/{paths-w4ulhCFb.js → paths-CUv1qfz-.js} +1788 -508
- package/dist/paths-CUv1qfz-.js.map +1 -0
- package/dist/plugins/rollup.d.ts +3 -4
- package/dist/plugins/rollup.js +52 -28
- package/dist/plugins/rollup.js.map +1 -1
- package/dist/plugins/vite.d.ts +1 -1
- package/dist/plugins/vite.js +95 -50
- package/dist/plugins/vite.js.map +1 -1
- package/dist/{types-dR5vTVu2.d.ts → types-D8Xfe0l8.d.ts} +25 -8
- package/package.json +1 -1
- package/schemas/twee-ts.config.schema.json +6 -0
- package/dist/paths-w4ulhCFb.js.map +0 -1
package/dist/bin/twee-ts.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
|
|
3
|
+
import { accessSync, chmodSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
|
|
4
4
|
import * as nodePath from "node:path";
|
|
5
5
|
import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
6
|
-
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
//#region src/ifid.ts
|
|
@@ -106,18 +106,44 @@ function tweeEscape(s) {
|
|
|
106
106
|
if (s.length === 0) return s;
|
|
107
107
|
return s.replace(/[\\[\]{}]/g, (ch) => "\\" + ch);
|
|
108
108
|
}
|
|
109
|
-
|
|
109
|
+
const JS_STRING_ESCAPES = {
|
|
110
|
+
"\\": "\\\\",
|
|
111
|
+
"\"": "\\\"",
|
|
112
|
+
"'": "\\'",
|
|
113
|
+
"\n": "\\n",
|
|
114
|
+
"\r": "\\r",
|
|
115
|
+
" ": "\\t",
|
|
116
|
+
"<": "\\x3C",
|
|
117
|
+
"\u2028": "\\u2028",
|
|
118
|
+
"\u2029": "\\u2029"
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Escape a string for the inside of a single- or double-quoted JavaScript string literal, including one written
|
|
122
|
+
* into an HTML `<script>` element. Besides backslashes, quotes and line breaks (with U+2028 and U+2029, which end
|
|
123
|
+
* a string literal in JavaScript before ES2019), every `<` becomes `\x3C`, so the literal holds no `<\/script`
|
|
124
|
+
* that would end the element and no `<!--` or `<script` that would change where it ends. The string's value is
|
|
125
|
+
* unchanged.
|
|
126
|
+
*/
|
|
110
127
|
function jsStringEscape(s) {
|
|
111
128
|
if (s.length === 0) return s;
|
|
112
|
-
return s.replace(
|
|
129
|
+
return s.replace(/[\\"'\n\r\t<\u2028\u2029]/g, (ch) => JS_STRING_ESCAPES[ch] ?? ch);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Escape a string for the inside of a double-quoted CSS string (such as a `font-family` name): `\` and `"` get
|
|
133
|
+
* a backslash, and line breaks and other control characters become hex escapes followed by a space (`\a ` for a
|
|
134
|
+
* line feed), since a raw line break ends a CSS string and a backslash before one is a line continuation.
|
|
135
|
+
*/
|
|
136
|
+
function cssStringEscape(s) {
|
|
137
|
+
if (s.length === 0) return s;
|
|
138
|
+
return s.replace(/[\\"\u0000-\u001f\u007f]/g, (ch) => ch === "\\" || ch === "\"" ? `\\${ch}` : `\\${ch.charCodeAt(0).toString(16)} `);
|
|
113
139
|
}
|
|
114
140
|
/** Sanitize a string for safe inclusion in a CSS or JS block comment. */
|
|
115
141
|
function commentSanitize(s) {
|
|
116
142
|
return s.replace(/\*\//g, "* /");
|
|
117
143
|
}
|
|
118
|
-
/** Sanitize a string for safe inclusion in an HTML comment. */
|
|
144
|
+
/** Sanitize a string for safe inclusion in an HTML comment: break up `-->` and `--!>`, which both end one. */
|
|
119
145
|
function htmlCommentSanitize(s) {
|
|
120
|
-
return s.replace(
|
|
146
|
+
return s.replace(/--(!?)>/g, "--$1 >");
|
|
121
147
|
}
|
|
122
148
|
/**
|
|
123
149
|
* Escape JavaScript for the content of an HTML `<script>` element, so that the HTML parser reads all of it as
|
|
@@ -297,7 +323,19 @@ function passageToPassagedata(p, pid, options) {
|
|
|
297
323
|
if (options?.sourceInfo && p.source) attrs += ` data-source-file=${quote(attrEscape(p.source.file))} data-source-line="${p.source.line}"`;
|
|
298
324
|
return `${attrs}>${htmlEscape(p.text)}</tw-passagedata>`;
|
|
299
325
|
}
|
|
300
|
-
/**
|
|
326
|
+
/**
|
|
327
|
+
* Whether Twine 1 `obfuscate:rot13` encodes a tiddler: every one but `StorySettings` and those tagged `Twine.image`
|
|
328
|
+
* (Twine 1.4 `Tiddler.isObfuscateable()`). Its engine tests the stored name and tags, which these tiddlers keep
|
|
329
|
+
* unencoded, so the decompiler tests them the same way.
|
|
330
|
+
*/
|
|
331
|
+
function isObfuscatable(p) {
|
|
332
|
+
return p.name !== "StorySettings" && !p.tags.includes("Twine.image");
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Generate `<div tiddler>` HTML for Twine 1. With `obfuscateRot13`, an obfuscatable tiddler (see
|
|
336
|
+
* `isObfuscatable()`) has its name, each tag and its text ROT13-encoded, as Twine 1.4 writes it
|
|
337
|
+
* (`Tiddler.toHtml()`), and as its engine.js decodes it.
|
|
338
|
+
*/
|
|
301
339
|
function passageToTiddler(p, pid, obfuscateRot13 = false) {
|
|
302
340
|
let position;
|
|
303
341
|
if (hasMetadataPosition(p)) position = p.metadata?.position ?? "";
|
|
@@ -309,9 +347,10 @@ function passageToTiddler(p, pid, obfuscateRot13 = false) {
|
|
|
309
347
|
position = `${xp * 140 - 130},${yp * 140 - 130}`;
|
|
310
348
|
}
|
|
311
349
|
const created = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 12);
|
|
312
|
-
const
|
|
313
|
-
const
|
|
314
|
-
|
|
350
|
+
const encode = obfuscateRot13 && isObfuscatable(p) ? rot13 : (s) => s;
|
|
351
|
+
const name = attrEscape(encode(p.name));
|
|
352
|
+
const tags = attrEscape(p.tags.map(encode).join(" "));
|
|
353
|
+
return `<div tiddler=${quote(name)} tags=${quote(tags)} created=${quote(created)} modifier=${quote("twee")} twine-position=${quote(attrEscape(position))}>${tiddlerEscape(encode(p.text))}</div>`;
|
|
315
354
|
}
|
|
316
355
|
/**
|
|
317
356
|
* Count words in a passage.
|
|
@@ -364,19 +403,128 @@ function quote(s) {
|
|
|
364
403
|
}
|
|
365
404
|
//#endregion
|
|
366
405
|
//#region src/story.ts
|
|
367
|
-
/** Module-level index: maps passage names to their array indices for O(1) lookups. */
|
|
368
406
|
const passageIndex = /* @__PURE__ */ new WeakMap();
|
|
369
|
-
function
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
407
|
+
function buildIndex(story) {
|
|
408
|
+
const names = story.passages.map((p) => p.name);
|
|
409
|
+
const index = {
|
|
410
|
+
passages: story.passages,
|
|
411
|
+
names,
|
|
412
|
+
positions: new Map(names.map((name, i) => [name, i]))
|
|
413
|
+
};
|
|
414
|
+
passageIndex.set(story, index);
|
|
415
|
+
return index;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* The index, rebuilt if `story.passages` was reassigned or changed length. O(1); `position()`
|
|
419
|
+
* also checks every hit. A change that keeps the length, such as replacing an element, can still
|
|
420
|
+
* hide a passage from a lookup, so `storyVerifyIndex()` checks every position.
|
|
421
|
+
*/
|
|
422
|
+
function currentIndex(story) {
|
|
423
|
+
const index = passageIndex.get(story);
|
|
424
|
+
return index !== void 0 && index.passages === story.passages && index.names.length === story.passages.length ? index : buildIndex(story);
|
|
425
|
+
}
|
|
426
|
+
/** The position of the passage named `name`, or -1. */
|
|
427
|
+
function position(story, name) {
|
|
428
|
+
const i = currentIndex(story).positions.get(name);
|
|
429
|
+
if (i === void 0) return -1;
|
|
430
|
+
if (story.passages[i]?.name === name) return i;
|
|
431
|
+
return buildIndex(story).positions.get(name) ?? -1;
|
|
432
|
+
}
|
|
433
|
+
/** Add a passage whose name no passage has yet at the end. */
|
|
434
|
+
function push(story, p) {
|
|
435
|
+
const index = currentIndex(story);
|
|
436
|
+
index.positions.set(p.name, story.passages.length);
|
|
437
|
+
index.names.push(p.name);
|
|
438
|
+
story.passages.push(p);
|
|
439
|
+
}
|
|
440
|
+
/** Put a passage at position `i`, in place of the passage there. */
|
|
441
|
+
function replaceAt(story, i, p) {
|
|
442
|
+
const renamed = story.passages[i]?.name !== p.name;
|
|
443
|
+
story.passages[i] = p;
|
|
444
|
+
if (renamed) buildIndex(story);
|
|
445
|
+
}
|
|
446
|
+
/** Names of compiler special passages, which storyAdd() reads into the story model. */
|
|
447
|
+
const SPECIAL_PASSAGE_NAMES = /* @__PURE__ */ new Set([
|
|
448
|
+
"StoryData",
|
|
449
|
+
"StoryIncludes",
|
|
450
|
+
"StorySettings",
|
|
451
|
+
"StoryTitle"
|
|
452
|
+
]);
|
|
453
|
+
/** Passages whose names were generated, kept by identity so that cached passages keep the mark. */
|
|
454
|
+
const generatedNames = /* @__PURE__ */ new WeakMap();
|
|
455
|
+
/**
|
|
456
|
+
* Mark a passage's name as generated (from a file name, or for imported story code) and return
|
|
457
|
+
* the passage. `origin.base` is the name that free names are numbered from: `base`, `base 2`, ….
|
|
458
|
+
*/
|
|
459
|
+
function withGeneratedName(p, origin) {
|
|
460
|
+
generatedNames.set(p, origin);
|
|
461
|
+
return p;
|
|
462
|
+
}
|
|
463
|
+
/** The first of `base`, `base 2`, `base 3`, … that `isFree` accepts. */
|
|
464
|
+
function freeName(base, isFree) {
|
|
465
|
+
if (isFree(base)) return base;
|
|
466
|
+
for (let n = 2;; n++) {
|
|
467
|
+
const candidate = `${base} ${n}`;
|
|
468
|
+
if (isFree(candidate)) return candidate;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
/** Whether a generated name may take `name`: no passage has it and it is no special name. */
|
|
472
|
+
function isFreeForGenerated(story, name) {
|
|
473
|
+
return !SPECIAL_PASSAGE_NAMES.has(name) && position(story, name) === -1;
|
|
474
|
+
}
|
|
475
|
+
function reportRename(origin, from, to, diagnostics) {
|
|
476
|
+
switch (origin.kind) {
|
|
477
|
+
case "code": return;
|
|
478
|
+
case "media": {
|
|
479
|
+
const reason = SPECIAL_PASSAGE_NAMES.has(from) ? `"${from}" is a compiler special passage name` : `another passage has the name "${from}"`;
|
|
480
|
+
diagnostics.push({
|
|
481
|
+
level: "warning",
|
|
482
|
+
message: `Passage "${from}" from "${origin.file}" renamed to "${to}"; ${reason}.`
|
|
483
|
+
});
|
|
484
|
+
return;
|
|
376
485
|
}
|
|
377
|
-
|
|
486
|
+
default: throw new Error(`unhandled generated name: ${JSON.stringify(origin)}`);
|
|
378
487
|
}
|
|
379
|
-
|
|
488
|
+
}
|
|
489
|
+
/** Add a passage with a generated name, under the first free name if its own is taken. */
|
|
490
|
+
function addGenerated(story, p, origin, diagnostics) {
|
|
491
|
+
if (isFreeForGenerated(story, p.name)) {
|
|
492
|
+
push(story, p);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const name = freeName(origin.base, (n) => isFreeForGenerated(story, n));
|
|
496
|
+
reportRename(origin, p.name, name, diagnostics);
|
|
497
|
+
push(story, withGeneratedName({
|
|
498
|
+
...p,
|
|
499
|
+
name
|
|
500
|
+
}, origin));
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* If a passage with a generated name has `name`, give it the first free name, in its place in the
|
|
504
|
+
* passage list, so that a passage from the sources can take the name.
|
|
505
|
+
*/
|
|
506
|
+
function moveGeneratedAside(story, name, diagnostics) {
|
|
507
|
+
const i = position(story, name);
|
|
508
|
+
const existing = i === -1 ? void 0 : story.passages[i];
|
|
509
|
+
const origin = existing === void 0 ? void 0 : generatedNames.get(existing);
|
|
510
|
+
if (existing === void 0 || origin === void 0) return;
|
|
511
|
+
const free = freeName(origin.base, (n) => isFreeForGenerated(story, n));
|
|
512
|
+
reportRename(origin, name, free, diagnostics);
|
|
513
|
+
replaceAt(story, i, withGeneratedName({
|
|
514
|
+
...existing,
|
|
515
|
+
name: free
|
|
516
|
+
}, origin));
|
|
517
|
+
}
|
|
518
|
+
function defaultTwine2Metadata() {
|
|
519
|
+
return {
|
|
520
|
+
format: "",
|
|
521
|
+
formatVersion: "",
|
|
522
|
+
options: /* @__PURE__ */ new Map(),
|
|
523
|
+
start: "",
|
|
524
|
+
tags: "",
|
|
525
|
+
tagColors: /* @__PURE__ */ new Map(),
|
|
526
|
+
zoom: 1
|
|
527
|
+
};
|
|
380
528
|
}
|
|
381
529
|
function createStory() {
|
|
382
530
|
const story = {
|
|
@@ -385,52 +533,37 @@ function createStory() {
|
|
|
385
533
|
passages: [],
|
|
386
534
|
legacyIFID: "",
|
|
387
535
|
twine1: { settings: /* @__PURE__ */ new Map() },
|
|
388
|
-
twine2:
|
|
389
|
-
format: "",
|
|
390
|
-
formatVersion: "",
|
|
391
|
-
options: /* @__PURE__ */ new Map(),
|
|
392
|
-
start: "",
|
|
393
|
-
tags: "",
|
|
394
|
-
tagColors: /* @__PURE__ */ new Map(),
|
|
395
|
-
zoom: 1
|
|
396
|
-
}
|
|
536
|
+
twine2: defaultTwine2Metadata()
|
|
397
537
|
};
|
|
398
|
-
|
|
538
|
+
buildIndex(story);
|
|
399
539
|
return story;
|
|
400
540
|
}
|
|
401
541
|
function storyHas(story, name) {
|
|
402
|
-
return
|
|
542
|
+
return position(story, name) !== -1;
|
|
403
543
|
}
|
|
404
544
|
function storyAppend(story, p, diagnostics) {
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
index.set(p.name, story.passages.length);
|
|
409
|
-
story.passages.push(p);
|
|
410
|
-
} else {
|
|
545
|
+
const i = position(story, p.name);
|
|
546
|
+
if (i === -1) push(story, p);
|
|
547
|
+
else {
|
|
411
548
|
diagnostics.push({
|
|
412
549
|
level: "warning",
|
|
413
550
|
message: `Replacing existing passage "${p.name}" with duplicate.`
|
|
414
551
|
});
|
|
415
|
-
story
|
|
552
|
+
replaceAt(story, i, p);
|
|
416
553
|
}
|
|
417
554
|
}
|
|
418
555
|
function storyPrepend(story, p, diagnostics) {
|
|
419
|
-
|
|
420
|
-
const i =
|
|
556
|
+
moveGeneratedAside(story, p.name, diagnostics);
|
|
557
|
+
const i = position(story, p.name);
|
|
421
558
|
if (i === -1) {
|
|
422
559
|
story.passages.unshift(p);
|
|
423
|
-
|
|
424
|
-
for (let j = 0; j < story.passages.length; j++) {
|
|
425
|
-
const passage = story.passages[j];
|
|
426
|
-
if (passage) index.set(passage.name, j);
|
|
427
|
-
}
|
|
560
|
+
buildIndex(story);
|
|
428
561
|
} else {
|
|
429
562
|
diagnostics.push({
|
|
430
563
|
level: "warning",
|
|
431
564
|
message: `Replacing existing passage "${p.name}" with duplicate.`
|
|
432
565
|
});
|
|
433
|
-
story
|
|
566
|
+
replaceAt(story, i, p);
|
|
434
567
|
}
|
|
435
568
|
}
|
|
436
569
|
function marshalStoryData(story) {
|
|
@@ -456,18 +589,20 @@ function unmarshalStoryData(story, json) {
|
|
|
456
589
|
}
|
|
457
590
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return "Cannot unmarshal \"StoryData\"; expected a JSON object";
|
|
458
591
|
const data = raw;
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
if (typeof data
|
|
592
|
+
const twine2 = defaultTwine2Metadata();
|
|
593
|
+
story.ifid = typeof data.ifid === "string" ? normalizeIFID(data.ifid) : "";
|
|
594
|
+
if (typeof data.format === "string") twine2.format = data.format;
|
|
595
|
+
if (typeof data["format-version"] === "string") twine2.formatVersion = data["format-version"];
|
|
462
596
|
if (Array.isArray(data.options)) {
|
|
463
|
-
for (const opt of data.options) if (typeof opt === "string")
|
|
597
|
+
for (const opt of data.options) if (typeof opt === "string") twine2.options.set(opt, true);
|
|
464
598
|
}
|
|
465
|
-
if (typeof data.start === "string"
|
|
466
|
-
if (typeof data.tags === "string")
|
|
599
|
+
if (typeof data.start === "string") twine2.start = data.start;
|
|
600
|
+
if (typeof data.tags === "string") twine2.tags = data.tags;
|
|
467
601
|
if (typeof data["tag-colors"] === "object" && data["tag-colors"] !== null && !Array.isArray(data["tag-colors"])) {
|
|
468
|
-
for (const [tag, color] of Object.entries(data["tag-colors"])) if (typeof color === "string")
|
|
602
|
+
for (const [tag, color] of Object.entries(data["tag-colors"])) if (typeof color === "string") twine2.tagColors.set(tag, color);
|
|
469
603
|
}
|
|
470
|
-
if (typeof data.zoom === "number" && data.zoom !== 0)
|
|
604
|
+
if (typeof data.zoom === "number" && data.zoom !== 0) twine2.zoom = data.zoom;
|
|
605
|
+
story.twine2 = twine2;
|
|
471
606
|
return null;
|
|
472
607
|
}
|
|
473
608
|
function unmarshalStorySettings(story, text, diagnostics) {
|
|
@@ -508,8 +643,20 @@ function unmarshalStorySettings(story, text, diagnostics) {
|
|
|
508
643
|
/**
|
|
509
644
|
* Process a passage and add it to the story, handling special passages.
|
|
510
645
|
* Creates new passage objects where text is modified rather than mutating the input.
|
|
646
|
+
*
|
|
647
|
+
* A passage replaces an earlier one with the same name, except where either name was generated
|
|
648
|
+
* (see `withGeneratedName()`): a generated name never replaces a passage and is never replaced.
|
|
649
|
+
* A passage with a generated name takes the first free name of `base`, `base 2`, … when its own
|
|
650
|
+
* is taken or is a compiler special name. A passage with a name from the sources that a generated
|
|
651
|
+
* name holds takes it over, and the generated one moves to the first free name in its place.
|
|
511
652
|
*/
|
|
512
653
|
function storyAdd(story, p, diagnostics) {
|
|
654
|
+
const origin = generatedNames.get(p);
|
|
655
|
+
if (origin !== void 0) {
|
|
656
|
+
addGenerated(story, p, origin, diagnostics);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
moveGeneratedAside(story, p.name, diagnostics);
|
|
513
660
|
let processed = p;
|
|
514
661
|
switch (p.name) {
|
|
515
662
|
case "StoryIncludes":
|
|
@@ -684,131 +831,579 @@ function isExcluded(filename, exclude) {
|
|
|
684
831
|
const rel = relative(process.cwd(), resolve(filename)) || filename;
|
|
685
832
|
return exclude.some((pattern) => matchesGlob(rel, pattern.replace(/^\.\//, "")));
|
|
686
833
|
}
|
|
834
|
+
/** A build that writes nothing: `compile()`, or output to stdout. */
|
|
835
|
+
const NO_BUILD_OUTPUTS = {
|
|
836
|
+
files: [],
|
|
837
|
+
dirs: []
|
|
838
|
+
};
|
|
839
|
+
/** The outputs of a build that writes the one file `outFile`, or none without it. */
|
|
840
|
+
function toBuildOutputs(outputs) {
|
|
841
|
+
if (outputs === void 0) return NO_BUILD_OUTPUTS;
|
|
842
|
+
return typeof outputs === "string" ? {
|
|
843
|
+
files: [outputs],
|
|
844
|
+
dirs: []
|
|
845
|
+
} : outputs;
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* The path outputs are compared by: absolute, with every symbolic link in it
|
|
849
|
+
* resolved, so two paths that reach the same file through different links (a
|
|
850
|
+
* symlinked project folder, an alias of the output folder) compare equal. A
|
|
851
|
+
* path that doesn't exist yet, such as the output before the first build, is
|
|
852
|
+
* its nearest existing folder's real path joined with the rest.
|
|
853
|
+
*/
|
|
854
|
+
function realPathOf(path) {
|
|
855
|
+
const abs = resolve(path);
|
|
856
|
+
try {
|
|
857
|
+
return realpathSync.native(abs);
|
|
858
|
+
} catch {}
|
|
859
|
+
const parent = dirname(abs);
|
|
860
|
+
return parent === abs ? abs : join(realPathOf(parent), basename(abs));
|
|
861
|
+
}
|
|
862
|
+
/** Whether the path `inner` is `outer` or inside it. */
|
|
863
|
+
function isSameOrInside(inner, outer) {
|
|
864
|
+
return inner === outer || inner.startsWith(outer.endsWith(sep) ? outer : outer + sep);
|
|
865
|
+
}
|
|
866
|
+
function outputPaths(outputs) {
|
|
867
|
+
const files = new Set(outputs.files.map(realPathOf));
|
|
868
|
+
const dirs = new Set(outputs.dirs.map(realPathOf));
|
|
869
|
+
const all = [...files, ...dirs];
|
|
870
|
+
return {
|
|
871
|
+
isFile: (real) => files.has(real),
|
|
872
|
+
isDir: (real) => dirs.has(real),
|
|
873
|
+
holds: (real) => dirs.has(real) || all.some((output) => output !== real && isSameOrInside(output, real)),
|
|
874
|
+
isOutput(path, roots) {
|
|
875
|
+
const real = realPathOf(path);
|
|
876
|
+
if (files.has(real)) return true;
|
|
877
|
+
const owners = [...dirs].filter((dir) => isSameOrInside(real, dir));
|
|
878
|
+
if (owners.length === 0) return false;
|
|
879
|
+
return roots.map(realPathOf).filter((root) => isSameOrInside(real, root)).every((root) => owners.some((dir) => dir !== root && isSameOrInside(dir, root)));
|
|
880
|
+
}
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* A folder entry as source discovery walks it, found at `pathname` with the real
|
|
885
|
+
* path `real` when it is no link: a link to a file counts as that file, and a
|
|
886
|
+
* link to anything else (a folder above all) as nothing, undefined. Throws when
|
|
887
|
+
* the entry or a link's target can't be read.
|
|
888
|
+
*/
|
|
889
|
+
function walkedEntry(pathname, real) {
|
|
890
|
+
const stat = lstatSync(pathname);
|
|
891
|
+
if (!stat.isSymbolicLink()) return {
|
|
892
|
+
stat,
|
|
893
|
+
real
|
|
894
|
+
};
|
|
895
|
+
const target = statSync(pathname);
|
|
896
|
+
return target.isFile() ? {
|
|
897
|
+
stat: target,
|
|
898
|
+
real: realPathOf(pathname)
|
|
899
|
+
} : void 0;
|
|
900
|
+
}
|
|
687
901
|
/**
|
|
688
902
|
* Recursively walk directories, collecting regular file paths.
|
|
689
|
-
*
|
|
690
|
-
*
|
|
903
|
+
*
|
|
904
|
+
* Leaves out what the build writes (`outputs`, compared by real path, see
|
|
905
|
+
* realPathOf) so its last output is never read back, and the files that match an
|
|
906
|
+
* `exclude` glob (see isExcluded). A path given that is itself an output file is
|
|
907
|
+
* not walked but listed in `outputSources`.
|
|
908
|
+
*
|
|
909
|
+
* Symbolic links: a path given is followed wherever it leads. Inside a folder, a
|
|
910
|
+
* link to a file is read, but a link to a folder is not followed, as Tweego's walk
|
|
911
|
+
* doesn't follow one. A link back to its own folder or a parent would otherwise
|
|
912
|
+
* walk the same files again at every depth.
|
|
913
|
+
*
|
|
691
914
|
* Like Tweego, a path that cannot be read is reported as a warning and skipped.
|
|
692
915
|
*/
|
|
693
|
-
function getFilenames(pathnames,
|
|
916
|
+
function getFilenames(pathnames, outputs, exclude = []) {
|
|
694
917
|
const filenames = [];
|
|
695
918
|
const diagnostics = [];
|
|
696
|
-
const
|
|
919
|
+
const outputSources = [];
|
|
920
|
+
const output = outputPaths(toBuildOutputs(outputs));
|
|
697
921
|
function warn(pathname, e) {
|
|
698
922
|
diagnostics.push({
|
|
699
923
|
level: "warning",
|
|
700
924
|
message: `path ${pathname}: ${e instanceof Error ? e.message : String(e)}`
|
|
701
925
|
});
|
|
702
926
|
}
|
|
703
|
-
function
|
|
927
|
+
function addFile(pathname) {
|
|
928
|
+
const abs = resolve(pathname);
|
|
929
|
+
const rel = relative(process.cwd(), abs);
|
|
930
|
+
if (isExcluded(rel || abs, exclude)) return;
|
|
931
|
+
filenames.push(rel || abs);
|
|
932
|
+
}
|
|
933
|
+
function walkDir(pathname, real) {
|
|
934
|
+
let entries;
|
|
935
|
+
try {
|
|
936
|
+
entries = readdirSync(pathname);
|
|
937
|
+
} catch (e) {
|
|
938
|
+
warn(pathname, e);
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
for (const entry of entries) walkEntry(join(pathname, entry), join(real, entry));
|
|
942
|
+
}
|
|
943
|
+
function walkEntry(pathname, real) {
|
|
944
|
+
let entry;
|
|
945
|
+
try {
|
|
946
|
+
entry = walkedEntry(pathname, real);
|
|
947
|
+
} catch (e) {
|
|
948
|
+
warn(pathname, e);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
if (entry === void 0) return;
|
|
952
|
+
if (entry.stat.isFile()) {
|
|
953
|
+
if (!output.isFile(entry.real)) addFile(pathname);
|
|
954
|
+
} else if (entry.stat.isDirectory() && !output.isDir(entry.real)) walkDir(pathname, entry.real);
|
|
955
|
+
}
|
|
956
|
+
for (const pathname of pathnames) {
|
|
704
957
|
let stat;
|
|
705
958
|
try {
|
|
706
959
|
stat = statSync(pathname);
|
|
707
960
|
} catch (e) {
|
|
708
961
|
warn(pathname, e);
|
|
709
|
-
|
|
962
|
+
continue;
|
|
710
963
|
}
|
|
964
|
+
const real = realPathOf(pathname);
|
|
711
965
|
if (stat.isFile()) {
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
if (isExcluded(rel || abs, exclude)) return;
|
|
716
|
-
filenames.push(rel || abs);
|
|
717
|
-
} else if (stat.isDirectory()) {
|
|
718
|
-
let entries;
|
|
719
|
-
try {
|
|
720
|
-
entries = readdirSync(pathname);
|
|
721
|
-
} catch (e) {
|
|
722
|
-
warn(pathname, e);
|
|
723
|
-
return;
|
|
724
|
-
}
|
|
725
|
-
for (const entry of entries) walk(join(pathname, entry));
|
|
726
|
-
}
|
|
966
|
+
if (output.isFile(real)) outputSources.push(pathname);
|
|
967
|
+
else addFile(pathname);
|
|
968
|
+
} else if (stat.isDirectory()) walkDir(pathname, real);
|
|
727
969
|
}
|
|
728
|
-
for (const pathname of pathnames) walk(pathname);
|
|
729
970
|
return {
|
|
730
971
|
filenames,
|
|
731
|
-
diagnostics
|
|
972
|
+
diagnostics,
|
|
973
|
+
outputSources
|
|
732
974
|
};
|
|
733
975
|
}
|
|
734
|
-
/** Whether the absolute path `file` is the absolute path `dir` or inside it. */
|
|
735
|
-
function isInsideDir(file, dir) {
|
|
736
|
-
return file === dir || file.startsWith(dir.endsWith(sep) ? dir : dir + sep);
|
|
737
|
-
}
|
|
738
976
|
/**
|
|
739
|
-
*
|
|
740
|
-
*
|
|
977
|
+
* A watched path that can't be watched: fs.watch failed on it, or on the folder it is
|
|
978
|
+
* watched through. watchFilesystem passes it to its `onError` and keeps watching the rest.
|
|
741
979
|
*/
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
980
|
+
var WatchPathError = class extends Error {
|
|
981
|
+
path;
|
|
982
|
+
constructor(path, cause) {
|
|
983
|
+
super(`Cannot watch ${path}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
984
|
+
this.path = path;
|
|
985
|
+
this.name = "WatchPathError";
|
|
986
|
+
}
|
|
987
|
+
};
|
|
988
|
+
function pathKind(path) {
|
|
989
|
+
try {
|
|
990
|
+
const stats = statSync(path);
|
|
991
|
+
return stats.isDirectory() ? {
|
|
992
|
+
kind: "dir",
|
|
993
|
+
id: `${stats.dev}:${stats.ino}`
|
|
994
|
+
} : { kind: "file" };
|
|
995
|
+
} catch {
|
|
996
|
+
return { kind: "missing" };
|
|
752
997
|
}
|
|
998
|
+
}
|
|
999
|
+
function rootState(abs) {
|
|
753
1000
|
return {
|
|
754
|
-
|
|
755
|
-
|
|
1001
|
+
anchor: nearestFolderAbove(abs),
|
|
1002
|
+
self: pathKind(abs)
|
|
756
1003
|
};
|
|
757
1004
|
}
|
|
1005
|
+
function nearestFolderAbove(abs) {
|
|
1006
|
+
for (let dir = dirname(abs), below = abs; dir !== below; below = dir, dir = dirname(dir)) {
|
|
1007
|
+
const kind = pathKind(dir);
|
|
1008
|
+
if (kind.kind === "dir") return {
|
|
1009
|
+
path: dir,
|
|
1010
|
+
id: kind.id
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
/** Whether two states need the same watches: the same anchor folder, and the same watched folder if any. */
|
|
1015
|
+
function sameWatches(a, b) {
|
|
1016
|
+
const selfId = (s) => s.self.kind === "dir" ? s.self.id : void 0;
|
|
1017
|
+
return a.anchor?.path === b.anchor?.path && a.anchor?.id === b.anchor?.id && selfId(a) === selfId(b);
|
|
1018
|
+
}
|
|
758
1019
|
/**
|
|
759
1020
|
* Watch paths for changes, calling the build callback on known file type changes.
|
|
760
1021
|
* A folder is watched recursively; a file is watched on its own and counts
|
|
761
1022
|
* whatever its type (a head file, say). A change to a file `ignore` returns true
|
|
762
1023
|
* for (given the path relative to the working directory) schedules no build.
|
|
763
1024
|
* Uses debouncing to avoid rapid rebuilds.
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
1025
|
+
*
|
|
1026
|
+
* A watched path that doesn't exist yet is waited for, and one that is deleted, or renamed
|
|
1027
|
+
* away and replaced, is followed to the new folder or file at its path: each path is also
|
|
1028
|
+
* watched through the folder above it. When that sets up a path's watches again, the next
|
|
1029
|
+
* build is a full one (`changedFiles` undefined), since changes made in between went unseen.
|
|
1030
|
+
* A path that can't be watched is passed to `onError` as a WatchPathError, once while the
|
|
1031
|
+
* failure persists; the other paths are still watched.
|
|
1032
|
+
*/
|
|
1033
|
+
function watchFilesystem(pathnames, outFilename, callback, ignore = () => false, onError = () => {}) {
|
|
1034
|
+
const output = outputPaths(toBuildOutputs(outFilename));
|
|
768
1035
|
let buildTimer = null;
|
|
769
1036
|
const BUILD_DEBOUNCE = 500;
|
|
770
1037
|
const pendingFiles = /* @__PURE__ */ new Set();
|
|
1038
|
+
let pendingFullBuild = false;
|
|
1039
|
+
let closed = false;
|
|
771
1040
|
function scheduleBuild(changedFile) {
|
|
772
|
-
if (changedFile)
|
|
1041
|
+
if (changedFile === void 0) pendingFullBuild = true;
|
|
1042
|
+
else pendingFiles.add(changedFile);
|
|
773
1043
|
if (buildTimer) clearTimeout(buildTimer);
|
|
774
1044
|
buildTimer = setTimeout(() => {
|
|
775
1045
|
buildTimer = null;
|
|
776
|
-
const files = pendingFiles.size
|
|
1046
|
+
const files = pendingFullBuild || pendingFiles.size === 0 ? void 0 : new Set(pendingFiles);
|
|
777
1047
|
pendingFiles.clear();
|
|
1048
|
+
pendingFullBuild = false;
|
|
778
1049
|
callback(files);
|
|
779
1050
|
}, BUILD_DEBOUNCE);
|
|
780
1051
|
}
|
|
1052
|
+
const roots = pathnames.map((path) => {
|
|
1053
|
+
const abs = resolve(path);
|
|
1054
|
+
return {
|
|
1055
|
+
path,
|
|
1056
|
+
abs,
|
|
1057
|
+
state: rootState(abs),
|
|
1058
|
+
watchers: [],
|
|
1059
|
+
failed: false,
|
|
1060
|
+
lastReport: void 0
|
|
1061
|
+
};
|
|
1062
|
+
});
|
|
1063
|
+
const isNamedFile = (abs) => roots.some((r) => r.abs === abs && r.state.self.kind === "file");
|
|
781
1064
|
function fileChanged(abs, named) {
|
|
782
|
-
if (abs === absOutFile) return;
|
|
783
1065
|
if (!named && !isKnownFileType(abs)) return;
|
|
1066
|
+
if (output.isFile(realPathOf(abs))) return;
|
|
784
1067
|
const rel = relative(process.cwd(), abs);
|
|
785
1068
|
if (!ignore(rel || abs)) scheduleBuild(rel || abs);
|
|
786
1069
|
}
|
|
787
|
-
function
|
|
1070
|
+
function report(root, cause) {
|
|
1071
|
+
const error = new WatchPathError(root.path, cause);
|
|
1072
|
+
if (error.message === root.lastReport) return;
|
|
1073
|
+
root.lastReport = error.message;
|
|
1074
|
+
onError(error);
|
|
1075
|
+
}
|
|
1076
|
+
function startWatch(root, path, recursive, listener) {
|
|
788
1077
|
try {
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
1078
|
+
if (recursive) accessSync(path, constants.R_OK | constants.X_OK);
|
|
1079
|
+
const watcher = watch(path, { recursive }, (_event, filename) => {
|
|
1080
|
+
if (!closed) listener(filename ?? "");
|
|
1081
|
+
});
|
|
1082
|
+
watcher.on("error", (e) => {
|
|
1083
|
+
watcher.close();
|
|
1084
|
+
root.watchers = root.watchers.filter((w) => w !== watcher);
|
|
1085
|
+
root.failed = true;
|
|
1086
|
+
if (!closed && !recheck(root, false)) report(root, e);
|
|
1087
|
+
});
|
|
1088
|
+
root.watchers.push(watcher);
|
|
1089
|
+
} catch (e) {
|
|
1090
|
+
root.failed = true;
|
|
1091
|
+
report(root, e);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
function arm(root) {
|
|
1095
|
+
for (const w of root.watchers) w.close();
|
|
1096
|
+
root.watchers = [];
|
|
1097
|
+
root.failed = false;
|
|
1098
|
+
const { anchor, self } = root.state;
|
|
1099
|
+
if (anchor) startWatch(root, anchor.path, false, (filename) => {
|
|
1100
|
+
const before = root.state.self.kind;
|
|
1101
|
+
recheck(root, true);
|
|
1102
|
+
if ((before === "file" || isNamedFile(root.abs)) && filename !== "" && resolve(anchor.path, filename) === root.abs) fileChanged(root.abs, true);
|
|
1103
|
+
});
|
|
1104
|
+
if (self.kind === "dir") startWatch(root, root.path, true, (filename) => {
|
|
1105
|
+
if (filename === "") recheck(root, true);
|
|
1106
|
+
else {
|
|
1107
|
+
const abs = resolve(root.abs, filename);
|
|
1108
|
+
fileChanged(abs, isNamedFile(abs));
|
|
1109
|
+
}
|
|
1110
|
+
});
|
|
1111
|
+
if (!root.failed) root.lastReport = void 0;
|
|
793
1112
|
}
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
const
|
|
803
|
-
|
|
804
|
-
|
|
1113
|
+
/**
|
|
1114
|
+
* Sets up `root`'s watches again when its path, or the folder above it, is no longer the
|
|
1115
|
+
* one they were started on (or, with `retry`, when one of them failed), and then schedules a
|
|
1116
|
+
* full build. Returns whether its state changed.
|
|
1117
|
+
*/
|
|
1118
|
+
function recheck(root, retry) {
|
|
1119
|
+
const before = root.state;
|
|
1120
|
+
const after = rootState(root.abs);
|
|
1121
|
+
const changed = !sameWatches(before, after);
|
|
1122
|
+
const wasFailed = root.failed;
|
|
1123
|
+
if (!changed && !(retry && wasFailed)) {
|
|
1124
|
+
root.state = after;
|
|
1125
|
+
return false;
|
|
1126
|
+
}
|
|
1127
|
+
root.state = after;
|
|
1128
|
+
arm(root);
|
|
1129
|
+
const recovered = wasFailed && !root.failed;
|
|
1130
|
+
if ((changed || recovered) && (before.self.kind !== "missing" || after.self.kind !== "missing")) scheduleBuild();
|
|
1131
|
+
return changed;
|
|
1132
|
+
}
|
|
1133
|
+
for (const root of roots) arm(root);
|
|
805
1134
|
callback();
|
|
806
1135
|
return { close() {
|
|
1136
|
+
closed = true;
|
|
807
1137
|
if (buildTimer) clearTimeout(buildTimer);
|
|
808
|
-
for (const w of watchers) w.close();
|
|
1138
|
+
for (const root of roots) for (const w of root.watchers) w.close();
|
|
809
1139
|
} };
|
|
810
1140
|
}
|
|
811
1141
|
//#endregion
|
|
1142
|
+
//#region src/relaxed-json.ts
|
|
1143
|
+
/**
|
|
1144
|
+
* A parser for the object literal a Twine 2 `format.js` passes to `window.storyFormat()`.
|
|
1145
|
+
*
|
|
1146
|
+
* The story formats spec does not require that object to be strict JSON, and Twine 2 runs
|
|
1147
|
+
* `format.js` as JavaScript. This parser accepts JSON plus the JavaScript literal syntax formats
|
|
1148
|
+
* use in practice: single-quoted strings, unquoted (identifier) and numeric keys, trailing commas,
|
|
1149
|
+
* comments, JavaScript string escapes, and hexadecimal, octal and binary numbers.
|
|
1150
|
+
*
|
|
1151
|
+
* It reads string literals token by token, so their contents come back exactly as JavaScript
|
|
1152
|
+
* evaluation gives them: text that only looks like structure (`a, }`, `word:`, an apostrophe) is
|
|
1153
|
+
* never touched. Anything outside that subset, such as a function or a variable, is a syntax error.
|
|
1154
|
+
*/
|
|
1155
|
+
const IDENTIFIER = /[A-Za-z_$][\w$]*/y;
|
|
1156
|
+
const NUMBER = /[+-]?(?:0[xX][0-9a-fA-F]+|0[oO][0-7]+|0[bB][01]+|(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)/y;
|
|
1157
|
+
const IDENTIFIER_PART = /[\w$]/;
|
|
1158
|
+
const WHITESPACE = /[\s]/;
|
|
1159
|
+
const HEX = /^[0-9a-fA-F]+$/;
|
|
1160
|
+
/** Simple single-character escapes and the characters they stand for. */
|
|
1161
|
+
const SIMPLE_ESCAPES = {
|
|
1162
|
+
b: "\b",
|
|
1163
|
+
f: "\f",
|
|
1164
|
+
n: "\n",
|
|
1165
|
+
r: "\r",
|
|
1166
|
+
t: " ",
|
|
1167
|
+
v: "\v"
|
|
1168
|
+
};
|
|
1169
|
+
/**
|
|
1170
|
+
* Parse a JavaScript object, array or primitive literal: all of `text`, or the part from `start` to
|
|
1171
|
+
* `end`. Error positions count from the start of `text`, so they point into the file it came from.
|
|
1172
|
+
*
|
|
1173
|
+
* @throws SyntaxError naming the line and column of the first thing it cannot parse.
|
|
1174
|
+
*/
|
|
1175
|
+
function parseRelaxedJSON(text, start = 0, end = text.length) {
|
|
1176
|
+
let pos = start;
|
|
1177
|
+
function fail(message, at = pos) {
|
|
1178
|
+
const before = text.slice(0, at);
|
|
1179
|
+
const line = before.split("\n").length;
|
|
1180
|
+
const column = at - before.lastIndexOf("\n");
|
|
1181
|
+
throw new SyntaxError(`${message} at line ${line}, column ${column}`);
|
|
1182
|
+
}
|
|
1183
|
+
/** The character at `pos`, or '' at the end of the input. */
|
|
1184
|
+
const peek = () => pos < end ? text[pos] ?? "" : "";
|
|
1185
|
+
const describeChar = (at) => at >= end ? "end of input" : JSON.stringify(text[at]);
|
|
1186
|
+
/** Skip whitespace and comments. */
|
|
1187
|
+
const skipTrivia = () => {
|
|
1188
|
+
while (pos < end) {
|
|
1189
|
+
const ch = peek();
|
|
1190
|
+
if (WHITESPACE.test(ch)) pos++;
|
|
1191
|
+
else if (text.startsWith("//", pos)) {
|
|
1192
|
+
const lineEnd = text.slice(pos, end).search(/[\n\r\u2028\u2029]/);
|
|
1193
|
+
pos = lineEnd === -1 ? end : pos + lineEnd;
|
|
1194
|
+
} else if (text.startsWith("/*", pos)) {
|
|
1195
|
+
const commentEnd = text.indexOf("*/", pos + 2);
|
|
1196
|
+
if (commentEnd === -1 || commentEnd + 2 > end) fail("Unterminated comment");
|
|
1197
|
+
pos = commentEnd + 2;
|
|
1198
|
+
} else return;
|
|
1199
|
+
}
|
|
1200
|
+
};
|
|
1201
|
+
const consume = (ch) => {
|
|
1202
|
+
skipTrivia();
|
|
1203
|
+
if (peek() !== ch) fail(`Expected ${JSON.stringify(ch)} but found ${describeChar(pos)}`);
|
|
1204
|
+
pos++;
|
|
1205
|
+
};
|
|
1206
|
+
/** Read exactly `count` hex digits and return their value. */
|
|
1207
|
+
const readHex = (count) => {
|
|
1208
|
+
const digits = text.slice(pos, Math.min(pos + count, end));
|
|
1209
|
+
if (digits.length !== count || !HEX.test(digits)) fail("Invalid hexadecimal escape sequence");
|
|
1210
|
+
pos += count;
|
|
1211
|
+
return parseInt(digits, 16);
|
|
1212
|
+
};
|
|
1213
|
+
/** Decode the escape sequence after a backslash; `pos` is just past the backslash. */
|
|
1214
|
+
const readEscape = () => {
|
|
1215
|
+
const ch = peek();
|
|
1216
|
+
const simple = SIMPLE_ESCAPES[ch];
|
|
1217
|
+
if (simple !== void 0) {
|
|
1218
|
+
pos++;
|
|
1219
|
+
return simple;
|
|
1220
|
+
}
|
|
1221
|
+
switch (ch) {
|
|
1222
|
+
case "": return fail("Unterminated string");
|
|
1223
|
+
case "x":
|
|
1224
|
+
pos++;
|
|
1225
|
+
return String.fromCharCode(readHex(2));
|
|
1226
|
+
case "u": {
|
|
1227
|
+
pos++;
|
|
1228
|
+
if (peek() !== "{") return String.fromCharCode(readHex(4));
|
|
1229
|
+
const close = text.indexOf("}", pos);
|
|
1230
|
+
if (close === -1 || close >= end) fail("Invalid Unicode escape sequence");
|
|
1231
|
+
pos++;
|
|
1232
|
+
const codePoint = readHex(close - pos);
|
|
1233
|
+
if (codePoint > 1114111) fail("Undefined Unicode code-point");
|
|
1234
|
+
pos = close + 1;
|
|
1235
|
+
return String.fromCodePoint(codePoint);
|
|
1236
|
+
}
|
|
1237
|
+
case "\r":
|
|
1238
|
+
pos += text[pos + 1] === "\n" ? 2 : 1;
|
|
1239
|
+
return "";
|
|
1240
|
+
case "\n":
|
|
1241
|
+
case "\u2028":
|
|
1242
|
+
case "\u2029":
|
|
1243
|
+
pos++;
|
|
1244
|
+
return "";
|
|
1245
|
+
case "0":
|
|
1246
|
+
if (/[0-9]/.test(text[pos + 1] ?? "")) fail("Octal escape sequences are not supported");
|
|
1247
|
+
pos++;
|
|
1248
|
+
return "\0";
|
|
1249
|
+
default:
|
|
1250
|
+
if (/[1-9]/.test(ch)) fail("Octal escape sequences are not supported");
|
|
1251
|
+
pos++;
|
|
1252
|
+
return ch;
|
|
1253
|
+
}
|
|
1254
|
+
};
|
|
1255
|
+
const readString = () => {
|
|
1256
|
+
const quote = peek();
|
|
1257
|
+
pos++;
|
|
1258
|
+
let out = "";
|
|
1259
|
+
let chunkStart = pos;
|
|
1260
|
+
for (;;) {
|
|
1261
|
+
if (pos >= end) fail("Unterminated string");
|
|
1262
|
+
const ch = peek();
|
|
1263
|
+
if (ch === quote) {
|
|
1264
|
+
out += text.slice(chunkStart, pos);
|
|
1265
|
+
pos++;
|
|
1266
|
+
return out;
|
|
1267
|
+
}
|
|
1268
|
+
if (ch === "\n" || ch === "\r") fail("Unterminated string");
|
|
1269
|
+
if (ch === "\\") {
|
|
1270
|
+
out += text.slice(chunkStart, pos);
|
|
1271
|
+
pos++;
|
|
1272
|
+
out += readEscape();
|
|
1273
|
+
chunkStart = pos;
|
|
1274
|
+
} else pos++;
|
|
1275
|
+
}
|
|
1276
|
+
};
|
|
1277
|
+
/** Read a match of a sticky regular expression at `pos`, or return undefined. */
|
|
1278
|
+
const readToken = (pattern) => {
|
|
1279
|
+
pattern.lastIndex = pos;
|
|
1280
|
+
const m = pattern.exec(text);
|
|
1281
|
+
if (!m || pattern.lastIndex > end) return void 0;
|
|
1282
|
+
pos = pattern.lastIndex;
|
|
1283
|
+
return m[0];
|
|
1284
|
+
};
|
|
1285
|
+
const readNumber = () => {
|
|
1286
|
+
const tokenStart = pos;
|
|
1287
|
+
const token = readToken(NUMBER);
|
|
1288
|
+
if (token === void 0) return void 0;
|
|
1289
|
+
if (IDENTIFIER_PART.test(peek())) fail("Invalid number", tokenStart);
|
|
1290
|
+
return (token.startsWith("-") ? -1 : 1) * Number(/^[+-]/.test(token) ? token.slice(1) : token);
|
|
1291
|
+
};
|
|
1292
|
+
const readKey = () => {
|
|
1293
|
+
skipTrivia();
|
|
1294
|
+
const ch = peek();
|
|
1295
|
+
if (ch === "\"" || ch === "'") return readString();
|
|
1296
|
+
const identifier = readToken(IDENTIFIER);
|
|
1297
|
+
if (identifier !== void 0) return identifier;
|
|
1298
|
+
const number = readNumber();
|
|
1299
|
+
if (number !== void 0) return String(number);
|
|
1300
|
+
return fail(`Expected a property name but found ${describeChar(pos)}`);
|
|
1301
|
+
};
|
|
1302
|
+
const readObject = () => {
|
|
1303
|
+
pos++;
|
|
1304
|
+
const out = {};
|
|
1305
|
+
for (;;) {
|
|
1306
|
+
skipTrivia();
|
|
1307
|
+
if (peek() === "}") {
|
|
1308
|
+
pos++;
|
|
1309
|
+
return out;
|
|
1310
|
+
}
|
|
1311
|
+
const key = readKey();
|
|
1312
|
+
consume(":");
|
|
1313
|
+
Object.defineProperty(out, key, {
|
|
1314
|
+
value: readValue(),
|
|
1315
|
+
enumerable: true,
|
|
1316
|
+
writable: true,
|
|
1317
|
+
configurable: true
|
|
1318
|
+
});
|
|
1319
|
+
skipTrivia();
|
|
1320
|
+
if (peek() === ",") pos++;
|
|
1321
|
+
else if (peek() !== "}") fail(`Expected "," or "}" but found ${describeChar(pos)}`);
|
|
1322
|
+
}
|
|
1323
|
+
};
|
|
1324
|
+
const readArray = () => {
|
|
1325
|
+
pos++;
|
|
1326
|
+
const out = [];
|
|
1327
|
+
for (;;) {
|
|
1328
|
+
skipTrivia();
|
|
1329
|
+
if (peek() === "]") {
|
|
1330
|
+
pos++;
|
|
1331
|
+
return out;
|
|
1332
|
+
}
|
|
1333
|
+
out.push(readValue());
|
|
1334
|
+
skipTrivia();
|
|
1335
|
+
if (peek() === ",") pos++;
|
|
1336
|
+
else if (peek() !== "]") fail(`Expected "," or "]" but found ${describeChar(pos)}`);
|
|
1337
|
+
}
|
|
1338
|
+
};
|
|
1339
|
+
function readValue() {
|
|
1340
|
+
skipTrivia();
|
|
1341
|
+
const ch = peek();
|
|
1342
|
+
if (ch === "{") return readObject();
|
|
1343
|
+
if (ch === "[") return readArray();
|
|
1344
|
+
if (ch === "\"" || ch === "'") return readString();
|
|
1345
|
+
const tokenStart = pos;
|
|
1346
|
+
const identifier = readToken(IDENTIFIER);
|
|
1347
|
+
if (identifier !== void 0) {
|
|
1348
|
+
if (identifier === "true") return true;
|
|
1349
|
+
if (identifier === "false") return false;
|
|
1350
|
+
if (identifier === "null") return null;
|
|
1351
|
+
return fail(`Unexpected identifier ${JSON.stringify(identifier)}`, tokenStart);
|
|
1352
|
+
}
|
|
1353
|
+
const number = readNumber();
|
|
1354
|
+
if (number !== void 0) return number;
|
|
1355
|
+
return fail(`Unexpected ${describeChar(pos)}`);
|
|
1356
|
+
}
|
|
1357
|
+
const value = readValue();
|
|
1358
|
+
skipTrivia();
|
|
1359
|
+
if (pos < end) fail(`Unexpected ${describeChar(pos)} after the value`);
|
|
1360
|
+
return value;
|
|
1361
|
+
}
|
|
1362
|
+
//#endregion
|
|
1363
|
+
//#region src/semver.ts
|
|
1364
|
+
const IDENTIFIERS = String.raw`[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*`;
|
|
1365
|
+
const VERSION$1 = new RegExp(String.raw`^[vV]?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-(${IDENTIFIERS}))?(?:\+${IDENTIFIERS})?$`);
|
|
1366
|
+
const NUMERIC = /^\d+$/;
|
|
1367
|
+
/** Parse a version string, or return null when it is not a version. */
|
|
1368
|
+
function parseVersion(text) {
|
|
1369
|
+
const m = VERSION$1.exec(text);
|
|
1370
|
+
if (!m) return null;
|
|
1371
|
+
return {
|
|
1372
|
+
major: Number(m[1]),
|
|
1373
|
+
minor: Number(m[2] ?? 0),
|
|
1374
|
+
patch: Number(m[3] ?? 0),
|
|
1375
|
+
prerelease: m[4] === void 0 ? [] : m[4].split(".")
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
/** Compare two prerelease identifiers: numeric ones numerically and below alphanumeric ones, which compare as ASCII. */
|
|
1379
|
+
function compareIdentifiers(a, b) {
|
|
1380
|
+
const aNumeric = NUMERIC.test(a);
|
|
1381
|
+
const bNumeric = NUMERIC.test(b);
|
|
1382
|
+
if (aNumeric && bNumeric) return Math.sign(Number(a) - Number(b));
|
|
1383
|
+
if (aNumeric !== bNumeric) return aNumeric ? -1 : 1;
|
|
1384
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
1385
|
+
}
|
|
1386
|
+
/** Compare prerelease identifier lists. An empty list (a release) ranks above any prerelease. */
|
|
1387
|
+
function comparePrerelease(a, b) {
|
|
1388
|
+
if (a.length === 0 || b.length === 0) return Math.sign(b.length - a.length);
|
|
1389
|
+
const shared = Math.min(a.length, b.length);
|
|
1390
|
+
for (let i = 0; i < shared; i++) {
|
|
1391
|
+
const order = compareIdentifiers(a[i] ?? "", b[i] ?? "");
|
|
1392
|
+
if (order !== 0) return order;
|
|
1393
|
+
}
|
|
1394
|
+
return Math.sign(a.length - b.length);
|
|
1395
|
+
}
|
|
1396
|
+
/** Compare two versions by SemVer precedence: negative when `a` ranks lower, 0 when equal, positive when higher. */
|
|
1397
|
+
function compareVersions(a, b) {
|
|
1398
|
+
return Math.sign(a.major - b.major) || Math.sign(a.minor - b.minor) || Math.sign(a.patch - b.patch) || comparePrerelease(a.prerelease, b.prerelease);
|
|
1399
|
+
}
|
|
1400
|
+
/** Whether two version strings name the same version (prerelease included, build metadata ignored). */
|
|
1401
|
+
function sameVersion(a, b) {
|
|
1402
|
+
const left = parseVersion(a);
|
|
1403
|
+
const right = parseVersion(b);
|
|
1404
|
+
return left !== null && right !== null && compareVersions(left, right) === 0;
|
|
1405
|
+
}
|
|
1406
|
+
//#endregion
|
|
812
1407
|
//#region src/source-text.ts
|
|
813
1408
|
/**
|
|
814
1409
|
* Source text normalization shared by every input path (files, in-memory sources, `parseTwee`).
|
|
@@ -820,43 +1415,138 @@ function watchFilesystem(pathnames, outFilename, callback, ignore = () => false)
|
|
|
820
1415
|
function normalizeSourceText(text) {
|
|
821
1416
|
return (text.charCodeAt(0) === 65279 ? text.slice(1) : text).replace(/\r\n?/g, "\n");
|
|
822
1417
|
}
|
|
1418
|
+
/** Byte order marks at the start of a line, directly before a passage header's `::`. */
|
|
1419
|
+
const BOMS_BEFORE_HEADER = /(?<=^|\n)\uFEFF+(?=::)/g;
|
|
1420
|
+
/**
|
|
1421
|
+
* Normalize Twee source text: {@link normalizeSourceText}, and also remove byte order marks at the start of
|
|
1422
|
+
* a line that are directly followed by `::`. Concatenating files (`cat a.tw b.tw`) leaves the BOM of every
|
|
1423
|
+
* later file in front of its first passage header, which would otherwise not be recognised as a header.
|
|
1424
|
+
* A U+FEFF anywhere else is passage text and kept. Idempotent.
|
|
1425
|
+
*/
|
|
1426
|
+
function normalizeTweeSourceText(text) {
|
|
1427
|
+
return normalizeSourceText(text).replace(BOMS_BEFORE_HEADER, "");
|
|
1428
|
+
}
|
|
823
1429
|
//#endregion
|
|
824
1430
|
//#region src/util.ts
|
|
825
1431
|
/**
|
|
826
1432
|
* Shared file I/O utilities.
|
|
827
1433
|
*/
|
|
828
|
-
/**
|
|
829
|
-
|
|
830
|
-
|
|
1434
|
+
/** The charset assumed for text that is not valid UTF-8, as Tweego assumes it. */
|
|
1435
|
+
const FALLBACK_CHARSET = "windows-1252";
|
|
1436
|
+
/**
|
|
1437
|
+
* Throws on invalid UTF-8 instead of replacing bytes with U+FFFD. `ignoreBOM` keeps a leading BOM in the
|
|
1438
|
+
* text, as `readFileSync(file, 'utf-8')` did, so {@link normalizeSourceText} strips exactly one.
|
|
1439
|
+
*/
|
|
1440
|
+
const utf8Decoder = new TextDecoder("utf-8", {
|
|
1441
|
+
fatal: true,
|
|
1442
|
+
ignoreBOM: true
|
|
1443
|
+
});
|
|
1444
|
+
/**
|
|
1445
|
+
* The code points of the Windows-1252 bytes 0x80 to 0x9F, from the WHATWG Encoding Standard. The five bytes
|
|
1446
|
+
* Windows-1252 leaves undefined map to the C1 control with the same number, as the standard and Go's charmap
|
|
1447
|
+
* do. Every other byte is the code point with the same number.
|
|
1448
|
+
*
|
|
1449
|
+
* Decoded by hand, because Node's `TextDecoder('windows-1252')` decodes as ISO-8859-1 in some releases
|
|
1450
|
+
* (Node 22.18 turns 0x80 into U+0080 instead of €), and builds without ICU lack it.
|
|
1451
|
+
*/
|
|
1452
|
+
const WINDOWS_1252_C1 = [
|
|
1453
|
+
8364,
|
|
1454
|
+
129,
|
|
1455
|
+
8218,
|
|
1456
|
+
402,
|
|
1457
|
+
8222,
|
|
1458
|
+
8230,
|
|
1459
|
+
8224,
|
|
1460
|
+
8225,
|
|
1461
|
+
710,
|
|
1462
|
+
8240,
|
|
1463
|
+
352,
|
|
1464
|
+
8249,
|
|
1465
|
+
338,
|
|
1466
|
+
141,
|
|
1467
|
+
381,
|
|
1468
|
+
143,
|
|
1469
|
+
144,
|
|
1470
|
+
8216,
|
|
1471
|
+
8217,
|
|
1472
|
+
8220,
|
|
1473
|
+
8221,
|
|
1474
|
+
8226,
|
|
1475
|
+
8211,
|
|
1476
|
+
8212,
|
|
1477
|
+
732,
|
|
1478
|
+
8482,
|
|
1479
|
+
353,
|
|
1480
|
+
8250,
|
|
1481
|
+
339,
|
|
1482
|
+
157,
|
|
1483
|
+
382,
|
|
1484
|
+
376
|
|
1485
|
+
];
|
|
1486
|
+
function decodeWindows1252(bytes) {
|
|
1487
|
+
const chunks = [];
|
|
1488
|
+
for (let start = 0; start < bytes.length; start += 8192) {
|
|
1489
|
+
const codes = Array.from(bytes.subarray(start, start + 8192), (b) => b >= 128 && b <= 159 ? WINDOWS_1252_C1[b - 128] ?? b : b);
|
|
1490
|
+
chunks.push(String.fromCharCode(...codes));
|
|
1491
|
+
}
|
|
1492
|
+
return chunks.join("");
|
|
1493
|
+
}
|
|
1494
|
+
/**
|
|
1495
|
+
* Decode bytes read from `filename` as UTF-8. Bytes that are not valid UTF-8 are decoded as Windows-1252
|
|
1496
|
+
* instead, with a warning naming the file, as Tweego does (`fileReadAllWithEncoding` in io.go), so a legacy
|
|
1497
|
+
* file keeps its non-ASCII characters instead of losing them to U+FFFD.
|
|
1498
|
+
*
|
|
1499
|
+
* The text is returned as decoded: a leading BOM and CR line endings are kept (see {@link normalizeSourceText}).
|
|
1500
|
+
*/
|
|
1501
|
+
function decodeText(bytes, filename) {
|
|
1502
|
+
try {
|
|
1503
|
+
return {
|
|
1504
|
+
text: utf8Decoder.decode(bytes),
|
|
1505
|
+
diagnostics: []
|
|
1506
|
+
};
|
|
1507
|
+
} catch {
|
|
1508
|
+
return {
|
|
1509
|
+
text: decodeWindows1252(bytes),
|
|
1510
|
+
diagnostics: [{
|
|
1511
|
+
level: "warning",
|
|
1512
|
+
message: `read ${filename}: Invalid UTF-8; assuming charset is ${FALLBACK_CHARSET}.`,
|
|
1513
|
+
file: filename
|
|
1514
|
+
}]
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
/**
|
|
1519
|
+
* Read a text file: decode it with {@link decodeText} (UTF-8, or Windows-1252 when it is not valid UTF-8),
|
|
1520
|
+
* strip a leading BOM and convert CRLF and bare CR line endings to LF. This is the one path every file read
|
|
1521
|
+
* as text goes through: sources, modules, the head file, story formats and the config file.
|
|
1522
|
+
*
|
|
1523
|
+
* @param diagnostics Receives the warning when the file is not valid UTF-8. Without it, the file is decoded
|
|
1524
|
+
* the same way and the warning is dropped.
|
|
1525
|
+
* @throws When the file cannot be read.
|
|
1526
|
+
*/
|
|
1527
|
+
function readUTF8(filename, diagnostics) {
|
|
1528
|
+
const decoded = decodeText(readFileSync(filename), filename);
|
|
1529
|
+
diagnostics?.push(...decoded.diagnostics);
|
|
1530
|
+
return normalizeSourceText(decoded.text);
|
|
831
1531
|
}
|
|
832
1532
|
/** Read a file as base64. */
|
|
833
1533
|
function readBase64(filename) {
|
|
834
1534
|
return readFileSync(filename).toString("base64");
|
|
835
1535
|
}
|
|
836
|
-
/** Get the filename without extension,
|
|
1536
|
+
/** Get the filename without extension, a dotfile keeps its whole name. */
|
|
837
1537
|
function baseNameWithoutExt(filename) {
|
|
838
|
-
|
|
839
|
-
return name || ext;
|
|
1538
|
+
return parse(filename).name;
|
|
840
1539
|
}
|
|
841
1540
|
//#endregion
|
|
842
1541
|
//#region src/formats.ts
|
|
843
1542
|
/**
|
|
844
1543
|
* Story format discovery, loading, and SemVer matching.
|
|
845
1544
|
* Ported from formats.go + config.go.
|
|
1545
|
+
*
|
|
1546
|
+
* Every lookup (local folders here, the download cache and remote indices in remote-formats.ts)
|
|
1547
|
+
* goes through the same version parsing ({@link parseVersion}) and the same matching rules
|
|
1548
|
+
* ({@link selectFormatCandidate}).
|
|
846
1549
|
*/
|
|
847
|
-
/**
|
|
848
|
-
* Attempt to fix non-strict JSON (trailing commas, single quotes, unquoted keys).
|
|
849
|
-
*/
|
|
850
|
-
function relaxedJSONParse(json) {
|
|
851
|
-
try {
|
|
852
|
-
return JSON.parse(json);
|
|
853
|
-
} catch {}
|
|
854
|
-
let fixed = json;
|
|
855
|
-
fixed = fixed.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, "\"$1\"");
|
|
856
|
-
fixed = fixed.replace(/(?<=^|[{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:/gm, "\"$1\":");
|
|
857
|
-
fixed = fixed.replace(/,\s*([}\]])/g, "$1");
|
|
858
|
-
return JSON.parse(fixed);
|
|
859
|
-
}
|
|
860
1550
|
/** A `"setup": function` property, which Harlowe appends to its otherwise-JSON format object. */
|
|
861
1551
|
const SETUP_FUNCTION_PROPERTY = /,\s*"setup"\s*:\s*function\b/g;
|
|
862
1552
|
/** Drop a trailing `"setup": function(){…}` property from a format object, if it has one. */
|
|
@@ -866,151 +1556,241 @@ function stripSetupFunction(chunk) {
|
|
|
866
1556
|
return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
|
|
867
1557
|
}
|
|
868
1558
|
/**
|
|
869
|
-
* Parse the
|
|
870
|
-
*
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
1559
|
+
* Parse the format object at `start`–`end` of `text`: strict JSON first (fast), then the
|
|
1560
|
+
* JavaScript literal subset formats use. Error positions point into `text`.
|
|
1561
|
+
*/
|
|
1562
|
+
function parseFormatObject(text, start, end) {
|
|
1563
|
+
try {
|
|
1564
|
+
return {
|
|
1565
|
+
ok: true,
|
|
1566
|
+
value: JSON.parse(text.slice(start, end))
|
|
1567
|
+
};
|
|
1568
|
+
} catch {}
|
|
1569
|
+
try {
|
|
1570
|
+
return {
|
|
1571
|
+
ok: true,
|
|
1572
|
+
value: parseRelaxedJSON(text, start, end)
|
|
1573
|
+
};
|
|
1574
|
+
} catch (e) {
|
|
1575
|
+
return {
|
|
1576
|
+
ok: false,
|
|
1577
|
+
error: e instanceof Error ? e.message : String(e)
|
|
1578
|
+
};
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
/** Check a parsed format object's fields. Per spec, `name` is optional; `version` and `source` are required. */
|
|
1582
|
+
/** The name given to a format.js that names no format. */
|
|
1583
|
+
const UNNAMED_FORMAT_NAME = "Untitled Story Format";
|
|
1584
|
+
function toFormatJSON(raw) {
|
|
1585
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
|
|
1586
|
+
ok: false,
|
|
1587
|
+
reason: "Story format JSON chunk is not an object."
|
|
1588
|
+
};
|
|
1589
|
+
const obj = raw;
|
|
1590
|
+
if (typeof obj.version !== "string") return {
|
|
1591
|
+
ok: false,
|
|
1592
|
+
reason: "Story format has no \"version\" string."
|
|
1593
|
+
};
|
|
1594
|
+
if (typeof obj.source !== "string") return {
|
|
1595
|
+
ok: false,
|
|
1596
|
+
reason: "Story format has no \"source\" string."
|
|
1597
|
+
};
|
|
1598
|
+
if (!parseVersion(obj.version)) return {
|
|
1599
|
+
ok: false,
|
|
1600
|
+
reason: `Story format version ${JSON.stringify(obj.version)} is not a SemVer version.`
|
|
1601
|
+
};
|
|
1602
|
+
const data = {
|
|
1603
|
+
name: typeof obj.name === "string" ? obj.name : UNNAMED_FORMAT_NAME,
|
|
1604
|
+
version: obj.version,
|
|
1605
|
+
source: obj.source,
|
|
1606
|
+
proofing: obj.proofing === true
|
|
1607
|
+
};
|
|
1608
|
+
if (typeof obj.author === "string") data.author = obj.author;
|
|
1609
|
+
if (typeof obj.description === "string") data.description = obj.description;
|
|
1610
|
+
if (typeof obj.image === "string") data.image = obj.image;
|
|
1611
|
+
if (typeof obj.url === "string") data.url = obj.url;
|
|
1612
|
+
if (typeof obj.license === "string") data.license = obj.license;
|
|
1613
|
+
return {
|
|
1614
|
+
ok: true,
|
|
1615
|
+
data
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
/**
|
|
1619
|
+
* Read the metadata of a Twine 2 format.js, or say why it cannot be used.
|
|
874
1620
|
*
|
|
875
|
-
*
|
|
1621
|
+
* The object passed to `window.storyFormat()` may be strict JSON or a JavaScript object literal
|
|
1622
|
+
* (single quotes, unquoted keys, trailing commas, comments). String values come back exactly as
|
|
1623
|
+
* JavaScript evaluation gives them. Harlowe's function-valued `setup` property is dropped; the
|
|
1624
|
+
* workaround keys on the property itself, so the same bytes parse whether they come from a
|
|
1625
|
+
* `harlowe-3` directory or a direct download.
|
|
876
1626
|
*/
|
|
877
|
-
function
|
|
1627
|
+
function decodeFormatJSON(source) {
|
|
878
1628
|
const first = source.indexOf("{");
|
|
879
1629
|
const last = source.lastIndexOf("}");
|
|
880
|
-
if (first === -1 || last
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
const raw = relaxedJSONParse(json);
|
|
884
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
|
885
|
-
const obj = raw;
|
|
886
|
-
if (typeof obj.version !== "string" || typeof obj.source !== "string") return null;
|
|
887
|
-
if (!parseSemver(obj.version)) return null;
|
|
888
|
-
const result = {
|
|
889
|
-
name: typeof obj.name === "string" ? obj.name : "Untitled Story Format",
|
|
890
|
-
version: obj.version,
|
|
891
|
-
source: obj.source,
|
|
892
|
-
proofing: obj.proofing === true
|
|
893
|
-
};
|
|
894
|
-
if (typeof obj.author === "string") result.author = obj.author;
|
|
895
|
-
if (typeof obj.description === "string") result.description = obj.description;
|
|
896
|
-
if (typeof obj.image === "string") result.image = obj.image;
|
|
897
|
-
if (typeof obj.url === "string") result.url = obj.url;
|
|
898
|
-
if (typeof obj.license === "string") result.license = obj.license;
|
|
899
|
-
return result;
|
|
1630
|
+
if (first === -1 || last < first) return {
|
|
1631
|
+
ok: false,
|
|
1632
|
+
reason: "Could not find Twine 2 style story format JSON chunk."
|
|
900
1633
|
};
|
|
1634
|
+
const parsed = parseFormatObject(source, first, last + 1);
|
|
1635
|
+
if (parsed.ok) return toFormatJSON(parsed.value);
|
|
1636
|
+
const stripped = stripSetupFunction(source.slice(first, last + 1));
|
|
1637
|
+
const retried = stripped === void 0 ? void 0 : parseFormatObject(stripped, 0, stripped.length);
|
|
1638
|
+
if (retried?.ok) return toFormatJSON(retried.value);
|
|
1639
|
+
return {
|
|
1640
|
+
ok: false,
|
|
1641
|
+
reason: `Could not decode story format JSON chunk: ${parsed.error}`
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
/**
|
|
1645
|
+
* Parse the Twine 2 format.js JSON chunk; null when it cannot be used ({@link decodeFormatJSON}
|
|
1646
|
+
* gives the reason).
|
|
1647
|
+
*
|
|
1648
|
+
* @param _formatId No longer used; kept so existing callers keep compiling.
|
|
1649
|
+
*/
|
|
1650
|
+
function parseFormatJSON(source, _formatId) {
|
|
1651
|
+
const result = decodeFormatJSON(source);
|
|
1652
|
+
return result.ok ? result.data : null;
|
|
1653
|
+
}
|
|
1654
|
+
/** A directory's entries in a stable order, or none when it cannot be read. */
|
|
1655
|
+
function listDirectory(dir) {
|
|
901
1656
|
try {
|
|
902
|
-
return
|
|
1657
|
+
return readdirSync(dir).sort();
|
|
903
1658
|
} catch {
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
1659
|
+
return [];
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
function isDirectory(path) {
|
|
1663
|
+
try {
|
|
1664
|
+
return statSync(path).isDirectory();
|
|
1665
|
+
} catch {
|
|
1666
|
+
return false;
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
function isFile(path) {
|
|
1670
|
+
try {
|
|
1671
|
+
return statSync(path).isFile();
|
|
1672
|
+
} catch {
|
|
1673
|
+
return false;
|
|
911
1674
|
}
|
|
912
1675
|
}
|
|
913
1676
|
/**
|
|
914
|
-
*
|
|
915
|
-
*
|
|
916
|
-
* only the highest minor.patch survives.
|
|
1677
|
+
* Load the format in one folder: its format.js (Twine 2), else its header.html (Twine 1).
|
|
1678
|
+
* A format.js that cannot be used is reported as a warning, as Tweego does.
|
|
917
1679
|
*/
|
|
918
|
-
function
|
|
919
|
-
const
|
|
920
|
-
|
|
921
|
-
for (const
|
|
1680
|
+
function loadFormatDir(searchDir, id, diagnostics) {
|
|
1681
|
+
const formatDir = join(searchDir, id);
|
|
1682
|
+
if (!isDirectory(formatDir)) return void 0;
|
|
1683
|
+
for (const baseFilename of ["format.js", "header.html"]) {
|
|
1684
|
+
const filename = join(formatDir, baseFilename);
|
|
1685
|
+
if (!isFile(filename)) continue;
|
|
1686
|
+
if (baseFilename === "header.html") return {
|
|
1687
|
+
id,
|
|
1688
|
+
filename,
|
|
1689
|
+
isTwine2: false,
|
|
1690
|
+
name: id,
|
|
1691
|
+
version: "",
|
|
1692
|
+
proofing: false
|
|
1693
|
+
};
|
|
1694
|
+
const encoding = [];
|
|
1695
|
+
let decoded;
|
|
922
1696
|
try {
|
|
923
|
-
|
|
924
|
-
} catch {
|
|
925
|
-
|
|
1697
|
+
decoded = decodeFormatJSON(readUTF8(filename, encoding));
|
|
1698
|
+
} catch (e) {
|
|
1699
|
+
decoded = {
|
|
1700
|
+
ok: false,
|
|
1701
|
+
reason: `Could not read ${filename}: ${e instanceof Error ? e.message : String(e)}`
|
|
1702
|
+
};
|
|
926
1703
|
}
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
1704
|
+
if (!decoded.ok) {
|
|
1705
|
+
diagnostics?.push(...encoding);
|
|
1706
|
+
diagnostics?.push({
|
|
1707
|
+
level: "warning",
|
|
1708
|
+
message: `format ${id}: Skipping format; ${decoded.reason} (${filename})`
|
|
1709
|
+
});
|
|
931
1710
|
continue;
|
|
932
1711
|
}
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
if (data.url) format.url = data.url;
|
|
966
|
-
if (data.license) format.license = data.license;
|
|
967
|
-
} catch {
|
|
968
|
-
continue;
|
|
969
|
-
}
|
|
970
|
-
else format.name = entry;
|
|
971
|
-
formats.set(entry, format);
|
|
972
|
-
break;
|
|
973
|
-
}
|
|
974
|
-
}
|
|
1712
|
+
const { data } = decoded;
|
|
1713
|
+
const format = {
|
|
1714
|
+
id,
|
|
1715
|
+
filename,
|
|
1716
|
+
isTwine2: true,
|
|
1717
|
+
name: data.name,
|
|
1718
|
+
version: data.version,
|
|
1719
|
+
proofing: data.proofing ?? false
|
|
1720
|
+
};
|
|
1721
|
+
if (data.author) format.author = data.author;
|
|
1722
|
+
if (data.description) format.description = data.description;
|
|
1723
|
+
if (data.image) format.image = data.image;
|
|
1724
|
+
if (data.url) format.url = data.url;
|
|
1725
|
+
if (data.license) format.license = data.license;
|
|
1726
|
+
return format;
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
/**
|
|
1730
|
+
* Discover every story format in the search directories, keyed by folder name (the format ID),
|
|
1731
|
+
* without version pruning.
|
|
1732
|
+
*
|
|
1733
|
+
* Directories are searched in order, and a later directory outranks an earlier one: when two hold
|
|
1734
|
+
* the same folder name, the later one's format is kept. The map iterates in that order too, lowest
|
|
1735
|
+
* rank first. Formats that cannot be used are left out, with a warning in `diagnostics`.
|
|
1736
|
+
*/
|
|
1737
|
+
function discoverAllFormats(searchDirs, diagnostics) {
|
|
1738
|
+
const formats = /* @__PURE__ */ new Map();
|
|
1739
|
+
for (const searchDir of searchDirs) for (const entry of listDirectory(searchDir)) {
|
|
1740
|
+
const format = loadFormatDir(searchDir, entry, diagnostics);
|
|
1741
|
+
if (!format) continue;
|
|
1742
|
+
formats.delete(entry);
|
|
1743
|
+
formats.set(entry, format);
|
|
975
1744
|
}
|
|
976
|
-
pruneFormats(formats);
|
|
977
1745
|
return formats;
|
|
978
1746
|
}
|
|
1747
|
+
/** The version a format is pruned and selected by, or null for one outside SemVer pruning. */
|
|
1748
|
+
function prunableVersion(format) {
|
|
1749
|
+
return format.isTwine2 && format.name ? parseVersion(format.version) : null;
|
|
1750
|
+
}
|
|
1751
|
+
/** The search directory a discovered format was found in. */
|
|
1752
|
+
function searchDirOf(format) {
|
|
1753
|
+
return dirname(dirname(format.filename));
|
|
1754
|
+
}
|
|
979
1755
|
/**
|
|
980
|
-
* Prune formats by SemVer: within each (name, major) group,
|
|
981
|
-
*
|
|
1756
|
+
* Prune formats by SemVer: within each (name, major) group, keep only the version with the highest
|
|
1757
|
+
* precedence (a release outranks its prereleases). Between equal versions, the folder in the
|
|
1758
|
+
* higher-ranked search directory wins, and within one directory the first folder name in sort
|
|
1759
|
+
* order. Returns a new map in the same order.
|
|
1760
|
+
*
|
|
1761
|
+
* Pruning serves name-based selection and listings. An explicit format ID is looked up in the
|
|
1762
|
+
* unpruned map, so a pinned folder is used even when another folder holds a newer version.
|
|
982
1763
|
*/
|
|
983
1764
|
function pruneFormats(formats) {
|
|
984
|
-
const
|
|
985
|
-
const
|
|
986
|
-
|
|
987
|
-
if (!
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
const
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
version: v
|
|
1765
|
+
const dirRank = /* @__PURE__ */ new Map();
|
|
1766
|
+
for (const format of formats.values()) {
|
|
1767
|
+
const dir = searchDirOf(format);
|
|
1768
|
+
if (!dirRank.has(dir)) dirRank.set(dir, dirRank.size);
|
|
1769
|
+
}
|
|
1770
|
+
const rankOf = (format) => dirRank.get(searchDirOf(format)) ?? 0;
|
|
1771
|
+
const best = /* @__PURE__ */ new Map();
|
|
1772
|
+
for (const format of formats.values()) {
|
|
1773
|
+
const version = prunableVersion(format);
|
|
1774
|
+
if (!version) continue;
|
|
1775
|
+
const group = `${format.name}@${version.major}`;
|
|
1776
|
+
const current = best.get(group);
|
|
1777
|
+
const order = current ? compareVersions(version, current.version) : 1;
|
|
1778
|
+
if (order > 0 || order === 0 && current && rankOf(format) > rankOf(current.format)) best.set(group, {
|
|
1779
|
+
format,
|
|
1780
|
+
version
|
|
1001
1781
|
});
|
|
1002
1782
|
}
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
const groupKey = `${f.name}@${v[0]}`;
|
|
1008
|
-
const best = bestByNameMajor.get(groupKey);
|
|
1009
|
-
if (best && best.id !== id) formats.delete(id);
|
|
1010
|
-
}
|
|
1783
|
+
return new Map([...formats].filter(([id, format]) => {
|
|
1784
|
+
const version = prunableVersion(format);
|
|
1785
|
+
return version === null || best.get(`${format.name}@${version.major}`)?.format.id === id;
|
|
1786
|
+
}));
|
|
1011
1787
|
}
|
|
1012
1788
|
/**
|
|
1013
|
-
* Get the
|
|
1789
|
+
* Get the format search directories, lowest rank first: the story format subdirectories of the
|
|
1790
|
+
* home directory, then of the working directory, then each `TWEEGO_PATH` entry, then `extraPaths`
|
|
1791
|
+
* (`formatPaths`). A later directory outranks an earlier one for the same format folder name, so
|
|
1792
|
+
* `formatPaths` override `TWEEGO_PATH`, which overrides the home and working directories, as in
|
|
1793
|
+
* Tweego.
|
|
1014
1794
|
*/
|
|
1015
1795
|
function getFormatSearchDirs(extraPaths = [], useTweegoPath = true) {
|
|
1016
1796
|
const subdirNames = [
|
|
@@ -1025,41 +1805,27 @@ function getFormatSearchDirs(extraPaths = [], useTweegoPath = true) {
|
|
|
1025
1805
|
basePaths.add(homedir());
|
|
1026
1806
|
} catch {}
|
|
1027
1807
|
basePaths.add(process.cwd());
|
|
1028
|
-
const dirs = [];
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
try {
|
|
1032
|
-
if (statSync(dir).isDirectory()) dirs.push(dir);
|
|
1033
|
-
} catch {}
|
|
1034
|
-
}
|
|
1808
|
+
const dirs = [...basePaths].flatMap((base) => subdirNames.map((sub) => join(base, sub))).filter(isDirectory);
|
|
1809
|
+
const tweegoPath = useTweegoPath ? process.env.TWEEGO_PATH : void 0;
|
|
1810
|
+
if (tweegoPath) dirs.push(...tweegoPath.split(process.platform === "win32" ? ";" : ":"));
|
|
1035
1811
|
dirs.push(...extraPaths);
|
|
1036
|
-
if (useTweegoPath) {
|
|
1037
|
-
const tweegoPath = process.env.TWEEGO_PATH;
|
|
1038
|
-
if (tweegoPath) dirs.push(...tweegoPath.split(process.platform === "win32" ? ";" : ":"));
|
|
1039
|
-
}
|
|
1040
1812
|
return dirs;
|
|
1041
1813
|
}
|
|
1042
|
-
/**
|
|
1814
|
+
/**
|
|
1815
|
+
* Parse a version string into [major, minor, patch]. Accepts what {@link parseVersion} accepts
|
|
1816
|
+
* (`v1.2.3`, `1.2`, `2.0.0-beta.1`); the prerelease and build metadata are dropped.
|
|
1817
|
+
*/
|
|
1043
1818
|
function parseSemver(v) {
|
|
1044
|
-
const
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
];
|
|
1051
|
-
}
|
|
1052
|
-
function semverCompare(a, b) {
|
|
1053
|
-
const [aMajor, aMinor, aPatch] = a;
|
|
1054
|
-
const [bMajor, bMinor, bPatch] = b;
|
|
1055
|
-
if (aMajor !== bMajor) return aMajor - bMajor;
|
|
1056
|
-
if (aMinor !== bMinor) return aMinor - bMinor;
|
|
1057
|
-
if (aPatch !== bPatch) return aPatch - bPatch;
|
|
1058
|
-
return 0;
|
|
1819
|
+
const parsed = parseVersion(v);
|
|
1820
|
+
return parsed ? [
|
|
1821
|
+
parsed.major,
|
|
1822
|
+
parsed.minor,
|
|
1823
|
+
parsed.patch
|
|
1824
|
+
] : null;
|
|
1059
1825
|
}
|
|
1060
1826
|
/** Build a directory-style format ID from a name and version, e.g. ('SugarCube', '2.37.3') → 'sugarcube-2'. */
|
|
1061
1827
|
function makeFormatId(name, version) {
|
|
1062
|
-
const major =
|
|
1828
|
+
const major = parseVersion(version)?.major ?? 0;
|
|
1063
1829
|
return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
|
|
1064
1830
|
}
|
|
1065
1831
|
/** Describe a format request for diagnostics. */
|
|
@@ -1070,81 +1836,164 @@ function describeFormatRequest(request) {
|
|
|
1070
1836
|
default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
|
|
1071
1837
|
}
|
|
1072
1838
|
}
|
|
1073
|
-
/**
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
* - An ID request ('sugarcube-2') matches a candidate whose name and major version build that ID,
|
|
1077
|
-
* and takes the greatest such version.
|
|
1078
|
-
* - A name request matches the name case-insensitively. An exact version wins outright; otherwise
|
|
1079
|
-
* the greatest version with the same major that is not older than the one asked for (or, with
|
|
1080
|
-
* `allowOlder`, the greatest same-major version). An unparseable requested version takes the
|
|
1081
|
-
* greatest version of any major.
|
|
1082
|
-
*/
|
|
1083
|
-
function selectFormatCandidate(request, candidates, describe, options = {}) {
|
|
1084
|
-
const allowOlder = options.allowOlder ?? false;
|
|
1085
|
-
const wanted = request.kind === "name" ? parseSemver(request.version) : null;
|
|
1839
|
+
/** {@link selectFormatCandidate} over one set of candidates, matching names without regard to case. */
|
|
1840
|
+
function pickFormatCandidate(request, candidates, describe, allowOlder) {
|
|
1841
|
+
const wanted = request.kind === "name" ? parseVersion(request.version) : null;
|
|
1086
1842
|
let best;
|
|
1087
1843
|
for (const candidate of candidates) {
|
|
1088
1844
|
const { name, version } = describe(candidate);
|
|
1089
|
-
const have =
|
|
1845
|
+
const have = parseVersion(version);
|
|
1090
1846
|
if (!have) continue;
|
|
1091
1847
|
if (request.kind === "id") {
|
|
1092
1848
|
if (makeFormatId(name, version) !== request.id.toLowerCase()) continue;
|
|
1093
1849
|
} else {
|
|
1094
1850
|
if (name.toLowerCase() !== request.name.toLowerCase()) continue;
|
|
1095
1851
|
if (wanted) {
|
|
1096
|
-
|
|
1097
|
-
if (
|
|
1098
|
-
if (
|
|
1852
|
+
const order = compareVersions(have, wanted);
|
|
1853
|
+
if (order === 0) return candidate;
|
|
1854
|
+
if (have.major !== wanted.major) continue;
|
|
1855
|
+
if (!allowOlder && order < 0) continue;
|
|
1099
1856
|
}
|
|
1100
1857
|
}
|
|
1101
|
-
if (!best ||
|
|
1858
|
+
if (!best || compareVersions(have, best.version) > 0) best = {
|
|
1102
1859
|
candidate,
|
|
1103
1860
|
version: have
|
|
1104
1861
|
};
|
|
1105
1862
|
}
|
|
1106
1863
|
return best?.candidate;
|
|
1107
1864
|
}
|
|
1108
|
-
/**
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1865
|
+
/**
|
|
1866
|
+
* Pick the candidate that best answers a format request. Local folders, the download cache and
|
|
1867
|
+
* remote indices all select through this one function.
|
|
1868
|
+
*
|
|
1869
|
+
* - An ID request ('sugarcube-2') matches, without regard to case, a candidate whose name and
|
|
1870
|
+
* major version build that ID, and takes the greatest such version.
|
|
1871
|
+
* - A name request matches the name without regard to case, but a candidate whose name matches in
|
|
1872
|
+
* case too is preferred when one answers the request. An exact version (prerelease included)
|
|
1873
|
+
* wins outright; otherwise the greatest version with the same major that is not older than the
|
|
1874
|
+
* one asked for (or, with `allowOlder`, the greatest same-major version). An unparseable
|
|
1875
|
+
* requested version takes the greatest version of any major.
|
|
1876
|
+
*
|
|
1877
|
+
* Versions compare by SemVer precedence ({@link compareVersions}), so a release outranks its
|
|
1878
|
+
* prereleases. Between equal versions, the earlier candidate wins.
|
|
1879
|
+
*/
|
|
1880
|
+
function selectFormatCandidate(request, candidates, describe, options = {}) {
|
|
1881
|
+
const allowOlder = options.allowOlder ?? false;
|
|
1882
|
+
if (request.kind === "name") {
|
|
1883
|
+
const preferred = pickFormatCandidate(request, candidates.filter((c) => describe(c).name === request.name), describe, allowOlder);
|
|
1884
|
+
if (preferred !== void 0) return preferred;
|
|
1885
|
+
}
|
|
1886
|
+
return pickFormatCandidate(request, candidates, describe, allowOlder);
|
|
1887
|
+
}
|
|
1888
|
+
/** The Twine 2 formats of a discovered map, those from higher-ranked search directories first. */
|
|
1889
|
+
function rankedTwine2Formats(formats) {
|
|
1890
|
+
return [...formats.values()].filter((f) => f.isTwine2).reverse();
|
|
1891
|
+
}
|
|
1892
|
+
/**
|
|
1893
|
+
* Look a format ID up among discovered folders. The ID matches a folder name without regard to
|
|
1894
|
+
* case; an exact-case folder is preferred, then the highest-ranked one.
|
|
1895
|
+
*/
|
|
1896
|
+
function findFormatById(formats, id) {
|
|
1897
|
+
const exact = formats.get(id);
|
|
1898
|
+
if (exact) return exact;
|
|
1899
|
+
const wanted = id.toLowerCase();
|
|
1900
|
+
return [...formats.values()].reverse().find((f) => f.id.toLowerCase() === wanted);
|
|
1901
|
+
}
|
|
1902
|
+
/**
|
|
1903
|
+
* Read the story format source (for Twine 2, extract the `source` property from JSON).
|
|
1904
|
+
* `diagnostics` receives a warning when the format file is not valid UTF-8 (it is read as Windows-1252).
|
|
1905
|
+
*/
|
|
1906
|
+
function readFormatSource(format, diagnostics) {
|
|
1907
|
+
const source = readUTF8(format.filename, diagnostics);
|
|
1908
|
+
if (!format.isTwine2) return source;
|
|
1909
|
+
const decoded = decodeFormatJSON(source);
|
|
1910
|
+
if (!decoded.ok) throw new Error(`Cannot parse format ${format.id} JSON: ${decoded.reason}`);
|
|
1911
|
+
return decoded.data.source;
|
|
1912
|
+
}
|
|
1913
|
+
//#endregion
|
|
1914
|
+
//#region src/atomic-write.ts
|
|
1915
|
+
/**
|
|
1916
|
+
* Atomic file replacement, for every file twee-ts writes that something else may read
|
|
1917
|
+
* meanwhile: build output (which live-reload servers read on each change) and downloaded
|
|
1918
|
+
* story formats (which other twee-ts processes share).
|
|
1919
|
+
*/
|
|
1920
|
+
/**
|
|
1921
|
+
* Writes `data` to `path` so that a reader sees either the previous file or the complete new
|
|
1922
|
+
* one, never a partly written file. The data goes to a temporary file in the same folder,
|
|
1923
|
+
* which is then renamed over `path`. When `path` is a symlink, the file it points to is
|
|
1924
|
+
* replaced, and an existing file keeps its permissions.
|
|
1925
|
+
*
|
|
1926
|
+
* On failure (a full disk, a missing folder) the temporary file is removed, `path` is left
|
|
1927
|
+
* as it was, and the error is thrown with `path` in its message and its original `code`.
|
|
1928
|
+
*/
|
|
1929
|
+
function writeFileAtomic(path, data) {
|
|
1930
|
+
const target = followSymlink(path);
|
|
1931
|
+
const temp = join(dirname(target), `.${basename(target)}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
|
|
1932
|
+
try {
|
|
1933
|
+
writeFileSync(temp, data, {
|
|
1934
|
+
encoding: "utf-8",
|
|
1935
|
+
flag: "wx"
|
|
1936
|
+
});
|
|
1937
|
+
keepPermissions(target, temp);
|
|
1938
|
+
renameSync(temp, target);
|
|
1939
|
+
} catch (e) {
|
|
1940
|
+
rmSync(temp, { force: true });
|
|
1941
|
+
throw writeError(path, e);
|
|
1123
1942
|
}
|
|
1124
|
-
return bestId;
|
|
1125
1943
|
}
|
|
1126
|
-
/**
|
|
1127
|
-
function
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
return data.source;
|
|
1944
|
+
/** The file a symlink at `path` points to, or `path` itself (also when it doesn't exist yet). */
|
|
1945
|
+
function followSymlink(path) {
|
|
1946
|
+
try {
|
|
1947
|
+
return realpathSync(path);
|
|
1948
|
+
} catch {
|
|
1949
|
+
return path;
|
|
1133
1950
|
}
|
|
1134
|
-
|
|
1951
|
+
}
|
|
1952
|
+
/**
|
|
1953
|
+
* Gives `temp` the permission bits of `target`, when `target` exists. Best effort: on a file
|
|
1954
|
+
* system without Unix permissions chmod can fail, and writing in place never kept them there either.
|
|
1955
|
+
*/
|
|
1956
|
+
function keepPermissions(target, temp) {
|
|
1957
|
+
const existing = statSync(target, { throwIfNoEntry: false });
|
|
1958
|
+
if (existing === void 0) return;
|
|
1959
|
+
try {
|
|
1960
|
+
chmodSync(temp, existing.mode & 4095);
|
|
1961
|
+
} catch {}
|
|
1962
|
+
}
|
|
1963
|
+
/** `cause` as an error that names `path` and keeps the original error code. */
|
|
1964
|
+
function writeError(path, cause) {
|
|
1965
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
1966
|
+
const error = new Error(`Cannot write ${path}: ${message}`, { cause });
|
|
1967
|
+
if (cause instanceof Error && "code" in cause) error.code = cause.code;
|
|
1968
|
+
return error;
|
|
1135
1969
|
}
|
|
1136
1970
|
//#endregion
|
|
1137
1971
|
//#region src/remote-formats.ts
|
|
1138
1972
|
/**
|
|
1139
1973
|
* Remote story format fetching, caching, and checksum verification.
|
|
1140
1974
|
* Uses the Story Formats Archive (SFA) as the default source.
|
|
1975
|
+
*
|
|
1976
|
+
* Two caches hold downloads. Formats found through an index are shared by name and version
|
|
1977
|
+
* (`<cache>/<name>/<version>/format.js`). A format downloaded from a direct URL is kept under
|
|
1978
|
+
* that URL instead, so one project's copy never answers another project's request.
|
|
1979
|
+
* Cache files are replaced atomically, so another process never reads a partly written one.
|
|
1141
1980
|
*/
|
|
1142
1981
|
const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
|
|
1982
|
+
/** The longest delay a timer accepts; a longer timeout means no limit. */
|
|
1983
|
+
const MAX_TIMER_DELAY = 2147483647;
|
|
1143
1984
|
/** Get the cache directory for downloaded story formats. */
|
|
1144
1985
|
function getCacheDir() {
|
|
1145
1986
|
const base = process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache");
|
|
1146
1987
|
return join(base, "twee-ts", "storyformats");
|
|
1147
1988
|
}
|
|
1989
|
+
/** The cache directory for formats downloaded from direct URLs, beside {@link getCacheDir}. */
|
|
1990
|
+
function getUrlCacheDir() {
|
|
1991
|
+
return join(dirname(getCacheDir()), "storyformat-urls");
|
|
1992
|
+
}
|
|
1993
|
+
/** The name of one direct URL's cache entry: a hash of the URL, so each URL has its own. */
|
|
1994
|
+
function urlCacheKey(url) {
|
|
1995
|
+
return createHash("sha256").update(url).digest("hex");
|
|
1996
|
+
}
|
|
1148
1997
|
/**
|
|
1149
1998
|
* Whether a string can serve as one directory name inside the cache: not empty, not `.` or `..`,
|
|
1150
1999
|
* and free of path separators. Format names and versions come from downloaded metadata, so they
|
|
@@ -1167,25 +2016,38 @@ function cachedFormatDir(name, version) {
|
|
|
1167
2016
|
if (!isInside(root, dir)) throw new Error(`Refusing to cache a story format outside the cache directory ${root}: ${dir}`);
|
|
1168
2017
|
return dir;
|
|
1169
2018
|
}
|
|
1170
|
-
/**
|
|
2019
|
+
/** Whether `e` is a Node.js system error with the given code. */
|
|
2020
|
+
function hasErrorCode(e, code) {
|
|
2021
|
+
return e instanceof Error && "code" in e && e.code === code;
|
|
2022
|
+
}
|
|
2023
|
+
/**
|
|
2024
|
+
* Create `dir` as a plain directory, or check that it already is one (a symlink could lead out of the cache).
|
|
2025
|
+
* A directory another process creates between the check and the mkdir is fine.
|
|
2026
|
+
*/
|
|
1171
2027
|
function ensurePlainDirectory(dir) {
|
|
1172
|
-
|
|
1173
|
-
if (stat === void 0) {
|
|
2028
|
+
if (lstatSync(dir, { throwIfNoEntry: false }) === void 0) try {
|
|
1174
2029
|
mkdirSync(dir);
|
|
1175
2030
|
return;
|
|
2031
|
+
} catch (e) {
|
|
2032
|
+
if (!hasErrorCode(e, "EEXIST")) throw e;
|
|
1176
2033
|
}
|
|
1177
|
-
|
|
2034
|
+
const stat = lstatSync(dir, { throwIfNoEntry: false });
|
|
2035
|
+
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`);
|
|
1178
2036
|
}
|
|
1179
|
-
/**
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
2037
|
+
/**
|
|
2038
|
+
* Write a downloaded format.js to `<root>/<...segments>/format.js`, replacing any earlier copy
|
|
2039
|
+
* atomically, and return its path. Every directory below `root` must be a plain one.
|
|
2040
|
+
*/
|
|
2041
|
+
function writeCacheEntry(root, segments, text) {
|
|
1183
2042
|
mkdirSync(root, { recursive: true });
|
|
1184
|
-
|
|
1185
|
-
|
|
2043
|
+
const dir = segments.reduce((parent, segment) => {
|
|
2044
|
+
const child = join(parent, segment);
|
|
2045
|
+
ensurePlainDirectory(child);
|
|
2046
|
+
return child;
|
|
2047
|
+
}, root);
|
|
1186
2048
|
const formatPath = join(dir, "format.js");
|
|
1187
2049
|
if (lstatSync(formatPath, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error(`Refusing to write a story format outside the cache directory: ${formatPath} is a symlink`);
|
|
1188
|
-
|
|
2050
|
+
writeFileAtomic(formatPath, text);
|
|
1189
2051
|
return formatPath;
|
|
1190
2052
|
}
|
|
1191
2053
|
/** In-memory index cache, keyed by URL. Cleared each compile. */
|
|
@@ -1194,6 +2056,96 @@ const indexCache = /* @__PURE__ */ new Map();
|
|
|
1194
2056
|
function clearIndexCache() {
|
|
1195
2057
|
indexCache.clear();
|
|
1196
2058
|
}
|
|
2059
|
+
/** Requests in progress, by what they fetch, so concurrent compiles make each request once. */
|
|
2060
|
+
const sharedRequests = /* @__PURE__ */ new Map();
|
|
2061
|
+
/**
|
|
2062
|
+
* Runs `start` once for all callers that ask for `key` while it is in progress. Each caller waits
|
|
2063
|
+
* under its own limits: a caller whose `signal` aborts, or whose `timeout` passes, stops waiting
|
|
2064
|
+
* at once and rejects, while the others keep waiting. The request itself is aborted (through
|
|
2065
|
+
* the signal `start` receives) once no caller waits on it. `start` must not depend on any one
|
|
2066
|
+
* caller: what differs between callers belongs after the shared result.
|
|
2067
|
+
*/
|
|
2068
|
+
function shareRequest(key, { signal, timeout, timedOut }, start) {
|
|
2069
|
+
if (signal?.aborted) return Promise.reject(signal.reason);
|
|
2070
|
+
const request = sharedRequests.get(key) ?? startSharedRequest(key, start);
|
|
2071
|
+
request.waiters++;
|
|
2072
|
+
return new Promise((resolve, reject) => {
|
|
2073
|
+
let waiting = true;
|
|
2074
|
+
let timer;
|
|
2075
|
+
const stopWaiting = () => {
|
|
2076
|
+
if (!waiting) return;
|
|
2077
|
+
waiting = false;
|
|
2078
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2079
|
+
clearTimeout(timer);
|
|
2080
|
+
request.waiters--;
|
|
2081
|
+
};
|
|
2082
|
+
const giveUp = (reason) => {
|
|
2083
|
+
stopWaiting();
|
|
2084
|
+
if (request.waiters === 0) {
|
|
2085
|
+
if (sharedRequests.get(key) === request) sharedRequests.delete(key);
|
|
2086
|
+
request.controller.abort(reason);
|
|
2087
|
+
}
|
|
2088
|
+
reject(reason);
|
|
2089
|
+
};
|
|
2090
|
+
const onAbort = () => giveUp(signal?.reason);
|
|
2091
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2092
|
+
if (timeout > 0 && timeout <= MAX_TIMER_DELAY) timer = setTimeout(() => giveUp(timedOut()), timeout);
|
|
2093
|
+
request.promise.then((value) => {
|
|
2094
|
+
stopWaiting();
|
|
2095
|
+
resolve(value);
|
|
2096
|
+
}, (e) => {
|
|
2097
|
+
stopWaiting();
|
|
2098
|
+
reject(e);
|
|
2099
|
+
});
|
|
2100
|
+
});
|
|
2101
|
+
}
|
|
2102
|
+
function startSharedRequest(key, start) {
|
|
2103
|
+
const controller = new AbortController();
|
|
2104
|
+
const request = {
|
|
2105
|
+
promise: start(controller.signal),
|
|
2106
|
+
controller,
|
|
2107
|
+
waiters: 0
|
|
2108
|
+
};
|
|
2109
|
+
sharedRequests.set(key, request);
|
|
2110
|
+
const forget = () => {
|
|
2111
|
+
if (sharedRequests.get(key) === request) sharedRequests.delete(key);
|
|
2112
|
+
};
|
|
2113
|
+
request.promise.then(forget, forget);
|
|
2114
|
+
return request;
|
|
2115
|
+
}
|
|
2116
|
+
/** The time limit for each request, from the caller's options. */
|
|
2117
|
+
function requestTimeout(options) {
|
|
2118
|
+
const timeout = options.timeout ?? 3e4;
|
|
2119
|
+
if (!(timeout >= 0)) throw new RangeError(`A story format request timeout must be 0 or more milliseconds, not ${timeout}`);
|
|
2120
|
+
return timeout;
|
|
2121
|
+
}
|
|
2122
|
+
/** How one caller waits for `url`: under its own signal and timeout. `what` names the request in errors. */
|
|
2123
|
+
function waitOptions(options, what, url) {
|
|
2124
|
+
const timeout = requestTimeout(options);
|
|
2125
|
+
return {
|
|
2126
|
+
signal: options.signal,
|
|
2127
|
+
timeout,
|
|
2128
|
+
timedOut: () => /* @__PURE__ */ new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`)
|
|
2129
|
+
};
|
|
2130
|
+
}
|
|
2131
|
+
/**
|
|
2132
|
+
* Fetch `url` as bytes, aborting when `signal` does. `what` names the request in errors, as in
|
|
2133
|
+
* "Failed to <what> from <url>". The bytes are returned undecoded, so checksums cover what was served.
|
|
2134
|
+
*/
|
|
2135
|
+
async function fetchBytes(url, what, signal) {
|
|
2136
|
+
const res = await fetch(url, { signal });
|
|
2137
|
+
if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
|
|
2138
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
2139
|
+
}
|
|
2140
|
+
/** Download a format.js once for every caller in this process that asks for `url` meanwhile. */
|
|
2141
|
+
function shareDownload(url, options) {
|
|
2142
|
+
return shareRequest(`download\0${url}`, waitOptions(options, "download format", url), (signal) => fetchBytes(url, "download format", signal));
|
|
2143
|
+
}
|
|
2144
|
+
/** Decode a downloaded format.js as local ones are (UTF-8, else Windows-1252), without a leading BOM. */
|
|
2145
|
+
function decodeDownload(bytes, url) {
|
|
2146
|
+
const { text } = decodeText(bytes, url);
|
|
2147
|
+
return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
|
|
2148
|
+
}
|
|
1197
2149
|
/** Validate that a JSON value is a valid SFAIndexEntry. */
|
|
1198
2150
|
function isValidEntry(val) {
|
|
1199
2151
|
if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
|
|
@@ -1209,15 +2161,17 @@ function validateSFAIndex(json) {
|
|
|
1209
2161
|
twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
|
|
1210
2162
|
};
|
|
1211
2163
|
}
|
|
1212
|
-
/** Fetch and parse an SFA index.json, with in-memory caching. */
|
|
1213
|
-
async function fetchIndex(url) {
|
|
2164
|
+
/** Fetch and parse an SFA index.json, with in-memory caching. Concurrent calls for one URL share a request. */
|
|
2165
|
+
async function fetchIndex(url, options = {}) {
|
|
2166
|
+
options.signal?.throwIfAborted();
|
|
1214
2167
|
const cached = indexCache.get(url);
|
|
1215
2168
|
if (cached) return cached;
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
2169
|
+
return shareRequest(`index\0${url}`, waitOptions(options, "fetch format index", url), async (signal) => {
|
|
2170
|
+
const text = decodeDownload(await fetchBytes(url, "fetch format index", signal), url);
|
|
2171
|
+
const data = validateSFAIndex(JSON.parse(text));
|
|
2172
|
+
indexCache.set(url, data);
|
|
2173
|
+
return data;
|
|
2174
|
+
});
|
|
1221
2175
|
}
|
|
1222
2176
|
/** Find the best matching entry in an SFA index for a name or ID request (twine2 entries first). */
|
|
1223
2177
|
function findEntryForRequest(index, request) {
|
|
@@ -1238,84 +2192,146 @@ async function verifySHA256(content, expectedHex) {
|
|
|
1238
2192
|
function getDownloadUrl(indexUrl, entry, formatType) {
|
|
1239
2193
|
return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
|
|
1240
2194
|
}
|
|
1241
|
-
/**
|
|
1242
|
-
|
|
2195
|
+
/** The StoryFormatInfo of a Twine 2 format.js cached at `filename`. */
|
|
2196
|
+
function cachedFormatInfo(id, filename, data) {
|
|
2197
|
+
return {
|
|
2198
|
+
id,
|
|
2199
|
+
filename,
|
|
2200
|
+
isTwine2: true,
|
|
2201
|
+
name: data.name,
|
|
2202
|
+
version: data.version,
|
|
2203
|
+
proofing: data.proofing ?? false
|
|
2204
|
+
};
|
|
2205
|
+
}
|
|
2206
|
+
/**
|
|
2207
|
+
* Check that a downloaded format is the one the index entry promised. The name matches without
|
|
2208
|
+
* regard to case, as format requests do, and the version by SemVer precedence, so "1.0" and "v1.0.0"
|
|
2209
|
+
* agree. A format.js that names no format ({@link UNNAMED_FORMAT_NAME}) is allowed: such formats
|
|
2210
|
+
* exist, and the index entry then supplies the name it is cached under.
|
|
2211
|
+
*/
|
|
2212
|
+
function checkFormatIdentity(entry, data, downloadUrl) {
|
|
2213
|
+
if ((data.name === "Untitled Story Format" || data.name.toLowerCase() === entry.name.toLowerCase()) && sameVersion(data.version, entry.version)) return;
|
|
2214
|
+
throw new Error(`Story format mismatch for ${downloadUrl}: the index lists ${entry.name} ${entry.version}, but the download is ${data.name} ${data.version}`);
|
|
2215
|
+
}
|
|
2216
|
+
/**
|
|
2217
|
+
* Download a format, verify its checksum, check that it is the format the entry names, write it to
|
|
2218
|
+
* the cache shared by name and version, and return its StoryFormatInfo. Concurrent calls for one
|
|
2219
|
+
* download URL share one request for the bytes; each call then verifies them against its own
|
|
2220
|
+
* entry, within its own timeout.
|
|
2221
|
+
*/
|
|
2222
|
+
async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
|
|
1243
2223
|
cachedFormatDir(entry.name, entry.version);
|
|
1244
|
-
const
|
|
1245
|
-
if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
|
|
1246
|
-
const text = await res.text();
|
|
2224
|
+
const bytes = await shareDownload(downloadUrl, options);
|
|
1247
2225
|
const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
|
|
1248
2226
|
if (checksumKey) {
|
|
1249
2227
|
const expected = entry.checksums[checksumKey];
|
|
1250
2228
|
if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
|
|
1251
|
-
if (!await verifySHA256(
|
|
2229
|
+
if (!await verifySHA256(bytes, expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
|
|
1252
2230
|
}
|
|
2231
|
+
const text = decodeDownload(bytes, downloadUrl);
|
|
1253
2232
|
const id = makeFormatId(entry.name, entry.version);
|
|
1254
2233
|
const data = parseFormatJSON(text, id);
|
|
1255
2234
|
if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
isTwine2: true,
|
|
1260
|
-
name: data.name,
|
|
1261
|
-
version: data.version,
|
|
1262
|
-
proofing: data.proofing ?? false
|
|
1263
|
-
};
|
|
2235
|
+
checkFormatIdentity(entry, data, downloadUrl);
|
|
2236
|
+
options.signal?.throwIfAborted();
|
|
2237
|
+
return cachedFormatInfo(id, writeCacheEntry(resolve(getCacheDir()), [entry.name, entry.version], text), data);
|
|
1264
2238
|
}
|
|
1265
|
-
/**
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
2239
|
+
/**
|
|
2240
|
+
* Download a direct format.js URL, parse its JSON, cache it under that URL (apart from the
|
|
2241
|
+
* downloads shared by name and version), and return its StoryFormatInfo. Concurrent calls for
|
|
2242
|
+
* one URL share one request.
|
|
2243
|
+
*/
|
|
2244
|
+
async function fetchDirectFormat(url, options = {}) {
|
|
2245
|
+
const text = decodeDownload(await shareDownload(url, options), url);
|
|
1270
2246
|
const data = parseFormatJSON(text);
|
|
1271
2247
|
if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
2248
|
+
cachedFormatDir(data.name, data.version);
|
|
2249
|
+
options.signal?.throwIfAborted();
|
|
2250
|
+
const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
|
|
2251
|
+
return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
|
|
2252
|
+
}
|
|
2253
|
+
/** The copy of a direct format URL in the download cache, if it has been downloaded before. */
|
|
2254
|
+
function getCachedDirectFormat(url) {
|
|
2255
|
+
const formatPath = join(getUrlCacheDir(), urlCacheKey(url), "format.js");
|
|
2256
|
+
try {
|
|
2257
|
+
if (!existsSync(formatPath)) return void 0;
|
|
2258
|
+
const data = parseFormatJSON(readUTF8(formatPath));
|
|
2259
|
+
if (!data) return void 0;
|
|
2260
|
+
return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
|
|
2261
|
+
} catch {
|
|
2262
|
+
return;
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
/**
|
|
2266
|
+
* Look a request up among direct format URLs, in order, and return the first format that
|
|
2267
|
+
* answers it. A URL downloaded before is answered by its cached copy without the network;
|
|
2268
|
+
* any other is downloaded (unless `offline`). When none answers and a download failed, the
|
|
2269
|
+
* last failure is thrown. An aborted signal rejects with its reason and tries no further URL.
|
|
2270
|
+
*
|
|
2271
|
+
* Internal, for format resolution; not part of the public API.
|
|
2272
|
+
*/
|
|
2273
|
+
async function resolveFormatUrls(request, urls, options = {}) {
|
|
2274
|
+
let lastError;
|
|
2275
|
+
for (const url of urls) {
|
|
2276
|
+
const cached = getCachedDirectFormat(url);
|
|
2277
|
+
if (cached) {
|
|
2278
|
+
if (answers(request, cached)) return cached;
|
|
2279
|
+
continue;
|
|
2280
|
+
}
|
|
2281
|
+
if (options.offline) continue;
|
|
2282
|
+
try {
|
|
2283
|
+
const info = await fetchDirectFormat(url, options);
|
|
2284
|
+
if (answers(request, info)) return info;
|
|
2285
|
+
} catch (e) {
|
|
2286
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
2287
|
+
lastError = toError$1(e);
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
if (lastError) throw lastError;
|
|
2291
|
+
}
|
|
2292
|
+
/** Whether `info` is a format the request accepts. */
|
|
2293
|
+
function answers(request, info) {
|
|
2294
|
+
return selectFormatCandidate(request, [info], (f) => f) !== void 0;
|
|
2295
|
+
}
|
|
2296
|
+
function toError$1(e) {
|
|
2297
|
+
return e instanceof Error ? e : new Error(String(e));
|
|
1280
2298
|
}
|
|
1281
2299
|
/**
|
|
1282
2300
|
* Resolve a story format request remotely, in the same order as {@link resolveRemoteFormat}.
|
|
1283
2301
|
* An ID request such as 'sugarcube-2' matches the format whose name and major version build that ID
|
|
1284
2302
|
* (SugarCube 2.x), taking the greatest version available.
|
|
1285
2303
|
*/
|
|
1286
|
-
async function resolveRemoteFormatRequest(request, indices, urls) {
|
|
2304
|
+
async function resolveRemoteFormatRequest(request, indices, urls, options = {}) {
|
|
2305
|
+
options.signal?.throwIfAborted();
|
|
1287
2306
|
let lastError;
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
const info = await fetchDirectFormat(url);
|
|
1292
|
-
if (selectFormatCandidate(request, [info], (f) => f)) return info;
|
|
2307
|
+
try {
|
|
2308
|
+
const direct = await resolveFormatUrls(request, urls ?? [], options);
|
|
2309
|
+
if (direct) return direct;
|
|
1293
2310
|
} catch (e) {
|
|
1294
|
-
|
|
2311
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
2312
|
+
lastError = toError$1(e);
|
|
1295
2313
|
}
|
|
2314
|
+
const cached = findCachedFormat(request);
|
|
2315
|
+
if (cached && request.kind === "name" && sameVersion(cached.version, request.version)) return cached;
|
|
1296
2316
|
const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
|
|
1297
2317
|
for (const indexUrl of allIndices) try {
|
|
1298
|
-
const result = findEntryForRequest(await fetchIndex(indexUrl), request);
|
|
2318
|
+
const result = findEntryForRequest(await fetchIndex(indexUrl, options), request);
|
|
1299
2319
|
if (result) {
|
|
1300
2320
|
const hit = getCachedFormat(result.entry.name, result.entry.version);
|
|
1301
2321
|
if (hit) return hit;
|
|
1302
2322
|
const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
|
|
1303
|
-
return await fetchAndCacheFormat(result.entry, downloadUrl);
|
|
2323
|
+
return await fetchAndCacheFormat(result.entry, downloadUrl, options);
|
|
1304
2324
|
}
|
|
1305
2325
|
} catch (e) {
|
|
1306
|
-
|
|
2326
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
2327
|
+
lastError = toError$1(e);
|
|
1307
2328
|
}
|
|
1308
2329
|
if (cached) return cached;
|
|
1309
2330
|
if (lastError) throw lastError;
|
|
1310
2331
|
}
|
|
1311
|
-
/** Whether two version strings name the same SemVer version. */
|
|
1312
|
-
function isExactVersion(have, wanted) {
|
|
1313
|
-
const a = parseSemver(have);
|
|
1314
|
-
const b = parseSemver(wanted);
|
|
1315
|
-
return a !== null && b !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
|
|
1316
|
-
}
|
|
1317
2332
|
/**
|
|
1318
2333
|
* Find the cached download that best answers a format request, without network access.
|
|
2334
|
+
* Only the downloads shared by name and version are searched, not those from direct URLs.
|
|
1319
2335
|
* Matching follows {@link selectFormatCandidate}.
|
|
1320
2336
|
*/
|
|
1321
2337
|
function findCachedFormat(request, options = {}) {
|
|
@@ -1327,18 +2343,11 @@ function getCachedFormat(name, version) {
|
|
|
1327
2343
|
const formatPath = join(getCacheDir(), name, version, "format.js");
|
|
1328
2344
|
try {
|
|
1329
2345
|
if (!existsSync(formatPath)) return void 0;
|
|
1330
|
-
const source =
|
|
2346
|
+
const source = readUTF8(formatPath);
|
|
1331
2347
|
const id = makeFormatId(name, version);
|
|
1332
2348
|
const data = parseFormatJSON(source, id);
|
|
1333
2349
|
if (!data) return void 0;
|
|
1334
|
-
return
|
|
1335
|
-
id,
|
|
1336
|
-
filename: formatPath,
|
|
1337
|
-
isTwine2: true,
|
|
1338
|
-
name: data.name,
|
|
1339
|
-
version: data.version,
|
|
1340
|
-
proofing: data.proofing ?? false
|
|
1341
|
-
};
|
|
2350
|
+
return cachedFormatInfo(id, formatPath, data);
|
|
1342
2351
|
} catch {
|
|
1343
2352
|
return;
|
|
1344
2353
|
}
|
|
@@ -1423,21 +2432,27 @@ function listCachedFormats() {
|
|
|
1423
2432
|
}
|
|
1424
2433
|
/**
|
|
1425
2434
|
* Clear all cached formats, or only those matching a given name. Returns the number of entries removed.
|
|
2435
|
+
* Clearing all also removes the downloads from direct format URLs; a name clears only the
|
|
2436
|
+
* downloads shared by name and version.
|
|
1426
2437
|
* A name must be a single cache entry name (as `listCachedFormats()` reports it); anything with a
|
|
1427
2438
|
* path separator or `.`/`..` throws instead of deleting outside the format's own directory.
|
|
1428
2439
|
*/
|
|
1429
2440
|
function clearCachedFormats(name) {
|
|
1430
2441
|
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.`);
|
|
1431
2442
|
const cacheDir = getCacheDir();
|
|
1432
|
-
if (!existsSync(cacheDir)) return 0;
|
|
1433
2443
|
if (!name) {
|
|
1434
|
-
const count = listCachedFormats().length;
|
|
2444
|
+
const count = listCachedFormats().length + countUrlCacheEntries();
|
|
1435
2445
|
rmSync(cacheDir, {
|
|
1436
2446
|
recursive: true,
|
|
1437
2447
|
force: true
|
|
1438
2448
|
});
|
|
2449
|
+
rmSync(getUrlCacheDir(), {
|
|
2450
|
+
recursive: true,
|
|
2451
|
+
force: true
|
|
2452
|
+
});
|
|
1439
2453
|
return count;
|
|
1440
2454
|
}
|
|
2455
|
+
if (!existsSync(cacheDir)) return 0;
|
|
1441
2456
|
const root = resolve(cacheDir);
|
|
1442
2457
|
const nameDir = resolve(root, name);
|
|
1443
2458
|
if (!isInside(root, nameDir)) throw new Error(`Refusing to clear ${JSON.stringify(name)}: it is not a cached format name.`);
|
|
@@ -1455,6 +2470,15 @@ function clearCachedFormats(name) {
|
|
|
1455
2470
|
});
|
|
1456
2471
|
return count;
|
|
1457
2472
|
}
|
|
2473
|
+
/** The number of direct format URLs with a download in the cache. */
|
|
2474
|
+
function countUrlCacheEntries() {
|
|
2475
|
+
const dir = getUrlCacheDir();
|
|
2476
|
+
try {
|
|
2477
|
+
return readdirSync(dir).filter((key) => existsSync(join(dir, key, "format.js"))).length;
|
|
2478
|
+
} catch {
|
|
2479
|
+
return 0;
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
1458
2482
|
/** Get total cache size in bytes and format count. */
|
|
1459
2483
|
function getCacheSize() {
|
|
1460
2484
|
const entries = listCachedFormats();
|
|
@@ -1481,44 +2505,65 @@ function formatRequestFor(formatId, storyFormat, storyFormatVersion, defaultId)
|
|
|
1481
2505
|
id: defaultId
|
|
1482
2506
|
};
|
|
1483
2507
|
}
|
|
1484
|
-
/**
|
|
1485
|
-
|
|
2508
|
+
/**
|
|
2509
|
+
* Look a request up among the formats found in local format directories. An ID names a folder, so
|
|
2510
|
+
* it is looked up among all of them; a name request selects by SemVer among the pruned formats.
|
|
2511
|
+
*/
|
|
2512
|
+
function findLocalFormat(all, pruned, request) {
|
|
1486
2513
|
switch (request.kind) {
|
|
1487
|
-
case "id": return
|
|
1488
|
-
case "name":
|
|
1489
|
-
const id = getFormatIdByNameAndVersion(formats, request.name, request.version);
|
|
1490
|
-
return id === void 0 ? void 0 : formats.get(id);
|
|
1491
|
-
}
|
|
2514
|
+
case "id": return findFormatById(all, request.id);
|
|
2515
|
+
case "name": return selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f);
|
|
1492
2516
|
default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
|
|
1493
2517
|
}
|
|
1494
2518
|
}
|
|
1495
2519
|
/**
|
|
1496
2520
|
* Resolve a format request to a story format.
|
|
1497
2521
|
*
|
|
1498
|
-
* Order: local format directories, then the
|
|
1499
|
-
*
|
|
2522
|
+
* Order: local format directories, then the project's direct format URLs (each one's cached
|
|
2523
|
+
* copy, else a download unless `noRemote`), then the download cache shared by name and version,
|
|
2524
|
+
* then format indices (unless `noRemote`). Names and IDs match the same way in each (see
|
|
2525
|
+
* `selectFormatCandidate`), so a request that a local format answers never reaches the cache or the
|
|
2526
|
+
* network. Local formats that cannot be used are reported as warnings. When a StoryData request
|
|
2527
|
+
* names a version that none of them has, an older version of
|
|
1500
2528
|
* the same format and major version is used with a warning. Anything else is an error diagnostic,
|
|
1501
2529
|
* and the result is `undefined`.
|
|
1502
2530
|
*/
|
|
1503
2531
|
async function resolveStoryFormat(request, options, diagnostics) {
|
|
1504
2532
|
const noRemote = options.noRemote ?? false;
|
|
1505
|
-
const
|
|
2533
|
+
const all = discoverAllFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true), diagnostics);
|
|
2534
|
+
const pruned = pruneFormats(all);
|
|
1506
2535
|
const wanted = describeFormatRequest(request);
|
|
1507
|
-
const local = findLocalFormat(
|
|
2536
|
+
const local = findLocalFormat(all, pruned, request);
|
|
1508
2537
|
if (local) return local;
|
|
2538
|
+
const fetchOptions = {
|
|
2539
|
+
signal: options.signal,
|
|
2540
|
+
timeout: options.formatFetchTimeout
|
|
2541
|
+
};
|
|
2542
|
+
const warnOnFailure = async (lookup) => {
|
|
2543
|
+
try {
|
|
2544
|
+
return await lookup();
|
|
2545
|
+
} catch (e) {
|
|
2546
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
2547
|
+
diagnostics.push({
|
|
2548
|
+
level: "warning",
|
|
2549
|
+
message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
|
|
2550
|
+
});
|
|
2551
|
+
return;
|
|
2552
|
+
}
|
|
2553
|
+
};
|
|
2554
|
+
const direct = await warnOnFailure(() => resolveFormatUrls(request, options.formatUrls ?? [], {
|
|
2555
|
+
...fetchOptions,
|
|
2556
|
+
offline: noRemote
|
|
2557
|
+
}));
|
|
2558
|
+
if (direct) return direct;
|
|
1509
2559
|
const cached = findCachedFormat(request);
|
|
1510
2560
|
if (cached) return cached;
|
|
1511
|
-
if (!noRemote)
|
|
1512
|
-
const remote = await resolveRemoteFormatRequest(request, options.formatIndices,
|
|
2561
|
+
if (!noRemote) {
|
|
2562
|
+
const remote = await warnOnFailure(() => resolveRemoteFormatRequest(request, options.formatIndices, [], fetchOptions));
|
|
1513
2563
|
if (remote) return remote;
|
|
1514
|
-
} catch (e) {
|
|
1515
|
-
diagnostics.push({
|
|
1516
|
-
level: "warning",
|
|
1517
|
-
message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
|
|
1518
|
-
});
|
|
1519
2564
|
}
|
|
1520
2565
|
if (request.kind === "name") {
|
|
1521
|
-
const older = selectFormatCandidate(request,
|
|
2566
|
+
const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
|
|
1522
2567
|
if (older) {
|
|
1523
2568
|
diagnostics.push({
|
|
1524
2569
|
level: "warning",
|
|
@@ -1530,7 +2575,7 @@ async function resolveStoryFormat(request, options, diagnostics) {
|
|
|
1530
2575
|
const reason = noRemote ? " (remote fetching disabled)" : "";
|
|
1531
2576
|
diagnostics.push({
|
|
1532
2577
|
level: "error",
|
|
1533
|
-
message: `Story format ${wanted} is not available${reason}. Found: ${[...
|
|
2578
|
+
message: `Story format ${wanted} is not available${reason}. Found: ${[...all.keys()].join(", ") || "none"}`
|
|
1534
2579
|
});
|
|
1535
2580
|
}
|
|
1536
2581
|
//#endregion
|
|
@@ -1767,8 +2812,6 @@ function* tweeLexer(input) {
|
|
|
1767
2812
|
const items = ctx.drainItems();
|
|
1768
2813
|
for (const item of items) yield item;
|
|
1769
2814
|
}
|
|
1770
|
-
const remaining = ctx.drainItems();
|
|
1771
|
-
for (const item of remaining) yield item;
|
|
1772
2815
|
}
|
|
1773
2816
|
//#endregion
|
|
1774
2817
|
//#region src/twee2-compat.ts
|
|
@@ -1799,12 +2842,14 @@ function twee2ToV3(s) {
|
|
|
1799
2842
|
* Parse Twee source text into passages.
|
|
1800
2843
|
*
|
|
1801
2844
|
* The source is normalized first, as files are when they are read: a leading UTF-8 BOM is
|
|
1802
|
-
* removed and CRLF and bare CR line endings become LF.
|
|
2845
|
+
* removed and CRLF and bare CR line endings become LF. A BOM at the start of a later line,
|
|
2846
|
+
* directly before `::` (left there by concatenating files), is removed too, so that line
|
|
2847
|
+
* stays a passage header.
|
|
1803
2848
|
*/
|
|
1804
2849
|
function parseTwee(source, options = {}) {
|
|
1805
2850
|
const { filename = "<inline>", trim = true, twee2Compat = false } = options;
|
|
1806
2851
|
const diagnostics = [];
|
|
1807
|
-
const normalized =
|
|
2852
|
+
const normalized = normalizeTweeSourceText(source);
|
|
1808
2853
|
const tweeSource = twee2Compat ? twee2ToV3(normalized) : normalized;
|
|
1809
2854
|
const passages = [];
|
|
1810
2855
|
let current = null;
|
|
@@ -1893,9 +2938,7 @@ function parseTwee(source, options = {}) {
|
|
|
1893
2938
|
};
|
|
1894
2939
|
}
|
|
1895
2940
|
try {
|
|
1896
|
-
const
|
|
1897
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error("expected a JSON object");
|
|
1898
|
-
const parsed = raw;
|
|
2941
|
+
const parsed = JSON.parse(item.val);
|
|
1899
2942
|
const meta = {};
|
|
1900
2943
|
for (const [key, value] of Object.entries(parsed)) if (typeof value === "string") meta[key] = value;
|
|
1901
2944
|
current.metadata = meta;
|
|
@@ -4639,17 +5682,21 @@ function parseDocument(data, options) {
|
|
|
4639
5682
|
* Ported from storyload.go:loadHTML().
|
|
4640
5683
|
*/
|
|
4641
5684
|
/**
|
|
4642
|
-
*
|
|
4643
|
-
*
|
|
5685
|
+
* `decompileHTML()` for loading an HTML file into a compile. The importer's StoryData passage is
|
|
5686
|
+
* checked when it is added to the story being compiled, which reports a missing or invalid IFID,
|
|
5687
|
+
* so the IFID is not reported here as well.
|
|
4644
5688
|
*/
|
|
4645
|
-
function
|
|
5689
|
+
function decompileHTMLForImport(html, options = {}) {
|
|
5690
|
+
return decompile(html, options, { ifid: false });
|
|
5691
|
+
}
|
|
5692
|
+
function decompile(html, options, checks) {
|
|
4646
5693
|
const diagnostics = [];
|
|
4647
5694
|
const story = createStory();
|
|
4648
5695
|
const passageText = options.trim === false ? (text) => text : (text) => text.trim();
|
|
4649
5696
|
const doc = parseDocument(html);
|
|
4650
5697
|
const twine2Data = findElement(doc, "tw-storydata");
|
|
4651
5698
|
if (twine2Data) {
|
|
4652
|
-
decompileTwine2(twine2Data, story, passageText, diagnostics);
|
|
5699
|
+
decompileTwine2(twine2Data, story, passageText, checks, diagnostics);
|
|
4653
5700
|
return {
|
|
4654
5701
|
story,
|
|
4655
5702
|
diagnostics
|
|
@@ -4672,7 +5719,7 @@ function decompileHTML(html, options = {}) {
|
|
|
4672
5719
|
diagnostics
|
|
4673
5720
|
};
|
|
4674
5721
|
}
|
|
4675
|
-
function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
5722
|
+
function decompileTwine2(storyData, story, passageText, checks, diagnostics) {
|
|
4676
5723
|
let startnode = 0;
|
|
4677
5724
|
const attrs = storyData.attribs ?? {};
|
|
4678
5725
|
if (attrs["name"]) story.name = attrs["name"];
|
|
@@ -4685,6 +5732,7 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4685
5732
|
else startnode = parsed;
|
|
4686
5733
|
}
|
|
4687
5734
|
if (attrs["ifid"]) story.ifid = normalizeIFID(attrs["ifid"]);
|
|
5735
|
+
if (checks.ifid) diagnostics.push(...storyDataIFIDDiagnostics(attrs["ifid"] ?? ""));
|
|
4688
5736
|
if (attrs["zoom"]) {
|
|
4689
5737
|
const parsed = parseFloat(attrs["zoom"]);
|
|
4690
5738
|
if (Number.isNaN(parsed)) diagnostics.push({
|
|
@@ -4707,13 +5755,17 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4707
5755
|
const content = getTextContent(node);
|
|
4708
5756
|
if (content.trim().length === 0) continue;
|
|
4709
5757
|
const text = passageText(content);
|
|
4710
|
-
const
|
|
5758
|
+
const base = node.name === "style" ? "Story Stylesheet" : "Story JavaScript";
|
|
5759
|
+
const name = freeName(base, (n) => !takenNames.has(n));
|
|
4711
5760
|
takenNames.add(name);
|
|
4712
|
-
storyAdd(story, {
|
|
5761
|
+
storyAdd(story, withGeneratedName({
|
|
4713
5762
|
name,
|
|
4714
5763
|
tags: node.name === "style" ? ["stylesheet"] : ["script"],
|
|
4715
5764
|
text
|
|
4716
|
-
},
|
|
5765
|
+
}, {
|
|
5766
|
+
kind: "code",
|
|
5767
|
+
base
|
|
5768
|
+
}), diagnostics);
|
|
4717
5769
|
break;
|
|
4718
5770
|
}
|
|
4719
5771
|
case "tw-tag": {
|
|
@@ -4760,19 +5812,26 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4760
5812
|
function passageDataName(node) {
|
|
4761
5813
|
return node.attribs?.["name"] ?? "";
|
|
4762
5814
|
}
|
|
4763
|
-
/**
|
|
4764
|
-
function
|
|
4765
|
-
if (
|
|
4766
|
-
|
|
4767
|
-
|
|
4768
|
-
|
|
4769
|
-
|
|
5815
|
+
/** Diagnostics for the `ifid` attribute of `tw-storydata`; `value` is empty when it is missing. */
|
|
5816
|
+
function storyDataIFIDDiagnostics(value) {
|
|
5817
|
+
if (value === "") return [{
|
|
5818
|
+
level: "warning",
|
|
5819
|
+
message: "Story IFID not found; the \"tw-storydata\" content attribute \"ifid\" is missing or empty."
|
|
5820
|
+
}];
|
|
5821
|
+
const err = validateIFID(value);
|
|
5822
|
+
if (err === null) return [];
|
|
5823
|
+
return [{
|
|
5824
|
+
level: "warning",
|
|
5825
|
+
message: `Cannot parse "tw-storydata" content attribute "ifid" as an IFID; value "${value}" (${err}).`
|
|
5826
|
+
}];
|
|
4770
5827
|
}
|
|
4771
5828
|
function decompileTwine1(storeArea, story, passageText, diagnostics) {
|
|
4772
5829
|
const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
|
|
4773
5830
|
const obfuscated = isRot13Obfuscated(passages);
|
|
4774
|
-
for (const p of passages) storyAdd(story, obfuscated && p
|
|
5831
|
+
for (const p of passages) storyAdd(story, obfuscated && isObfuscatable(p) ? {
|
|
4775
5832
|
...p,
|
|
5833
|
+
name: rot13(p.name),
|
|
5834
|
+
tags: p.tags.map(rot13),
|
|
4776
5835
|
text: rot13(p.text)
|
|
4777
5836
|
} : p, diagnostics);
|
|
4778
5837
|
}
|
|
@@ -4917,7 +5976,12 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
|
|
|
4917
5976
|
function loadInlineSources(story, sources, opts, diagnostics) {
|
|
4918
5977
|
for (const source of sources) {
|
|
4919
5978
|
if (typeof source === "string") continue;
|
|
4920
|
-
const
|
|
5979
|
+
const decoded = typeof source.content === "string" ? {
|
|
5980
|
+
text: source.content,
|
|
5981
|
+
diagnostics: []
|
|
5982
|
+
} : decodeText(source.content, source.filename);
|
|
5983
|
+
diagnostics.push(...decoded.diagnostics);
|
|
5984
|
+
const content = normalizeSourceText(decoded.text);
|
|
4921
5985
|
const ext = normalizedFileExt(source.filename);
|
|
4922
5986
|
switch (ext) {
|
|
4923
5987
|
case "":
|
|
@@ -4936,18 +6000,10 @@ function loadInlineSources(story, sources, opts, diagnostics) {
|
|
|
4936
6000
|
break;
|
|
4937
6001
|
}
|
|
4938
6002
|
case "css":
|
|
4939
|
-
storyAdd(story,
|
|
4940
|
-
name: basename(source.filename),
|
|
4941
|
-
tags: ["stylesheet"],
|
|
4942
|
-
text: content
|
|
4943
|
-
}, diagnostics);
|
|
6003
|
+
storyAdd(story, codePassage(basename(source.filename), "stylesheet", content), diagnostics);
|
|
4944
6004
|
break;
|
|
4945
6005
|
case "js":
|
|
4946
|
-
storyAdd(story,
|
|
4947
|
-
name: basename(source.filename),
|
|
4948
|
-
tags: ["script"],
|
|
4949
|
-
text: content
|
|
4950
|
-
}, diagnostics);
|
|
6006
|
+
storyAdd(story, codePassage(basename(source.filename), "script", content), diagnostics);
|
|
4951
6007
|
break;
|
|
4952
6008
|
default: diagnostics.push({
|
|
4953
6009
|
level: "warning",
|
|
@@ -4957,35 +6013,60 @@ function loadInlineSources(story, sources, opts, diagnostics) {
|
|
|
4957
6013
|
}
|
|
4958
6014
|
}
|
|
4959
6015
|
function parseTweeFile(filename, opts) {
|
|
4960
|
-
const
|
|
6016
|
+
const readDiagnostics = [];
|
|
6017
|
+
const result = parseTwee(readUTF8(filename, readDiagnostics), {
|
|
4961
6018
|
filename,
|
|
4962
6019
|
trim: opts.trim ?? true,
|
|
4963
6020
|
twee2Compat: opts.twee2Compat ?? false
|
|
4964
6021
|
});
|
|
4965
6022
|
return {
|
|
4966
6023
|
passages: result.passages,
|
|
4967
|
-
diagnostics: [...result.diagnostics]
|
|
6024
|
+
diagnostics: [...readDiagnostics, ...result.diagnostics]
|
|
4968
6025
|
};
|
|
4969
6026
|
}
|
|
6027
|
+
/**
|
|
6028
|
+
* A stylesheet or script passage named after its file, as Tweego names it. The name only labels
|
|
6029
|
+
* the code, so when another passage has it, the passage takes a free one (see `storyAdd()`).
|
|
6030
|
+
*/
|
|
6031
|
+
function codePassage(name, tag, text) {
|
|
6032
|
+
return withGeneratedName({
|
|
6033
|
+
name,
|
|
6034
|
+
tags: [tag],
|
|
6035
|
+
text
|
|
6036
|
+
}, {
|
|
6037
|
+
kind: "code",
|
|
6038
|
+
base: name
|
|
6039
|
+
});
|
|
6040
|
+
}
|
|
6041
|
+
/** Mark every passage of a parse result as a code passage named after its file. */
|
|
6042
|
+
function codeNames(result) {
|
|
6043
|
+
for (const p of result.passages) withGeneratedName(p, {
|
|
6044
|
+
kind: "code",
|
|
6045
|
+
base: p.name
|
|
6046
|
+
});
|
|
6047
|
+
return result;
|
|
6048
|
+
}
|
|
4970
6049
|
function parseTaggedFile(tag, filename) {
|
|
4971
|
-
const
|
|
6050
|
+
const diagnostics = [];
|
|
6051
|
+
const source = readUTF8(filename, diagnostics);
|
|
4972
6052
|
return {
|
|
4973
|
-
passages: [
|
|
4974
|
-
|
|
4975
|
-
tags: [tag],
|
|
4976
|
-
text: source
|
|
4977
|
-
}],
|
|
4978
|
-
diagnostics: []
|
|
6053
|
+
passages: [codePassage(basename(filename), tag, source)],
|
|
6054
|
+
diagnostics
|
|
4979
6055
|
};
|
|
4980
6056
|
}
|
|
4981
6057
|
function parseMediaFile(tag, filename) {
|
|
4982
6058
|
const source = readBase64(filename);
|
|
6059
|
+
const name = baseNameWithoutExt(filename);
|
|
4983
6060
|
return {
|
|
4984
|
-
passages: [{
|
|
4985
|
-
name
|
|
6061
|
+
passages: [withGeneratedName({
|
|
6062
|
+
name,
|
|
4986
6063
|
tags: [tag],
|
|
4987
6064
|
text: `data:${mediaTypeFromFilename(filename)};base64,${source}`
|
|
4988
|
-
}
|
|
6065
|
+
}, {
|
|
6066
|
+
kind: "media",
|
|
6067
|
+
base: name,
|
|
6068
|
+
file: filename
|
|
6069
|
+
})],
|
|
4989
6070
|
diagnostics: []
|
|
4990
6071
|
};
|
|
4991
6072
|
}
|
|
@@ -4994,24 +6075,27 @@ function parseFontFile(filename) {
|
|
|
4994
6075
|
const name = basename(filename);
|
|
4995
6076
|
const family = baseNameWithoutExt(filename);
|
|
4996
6077
|
const ext = normalizedFileExt(filename);
|
|
4997
|
-
|
|
6078
|
+
const mediaType = mediaTypeFromExt(ext);
|
|
6079
|
+
const hint = fontFormatHint(ext);
|
|
6080
|
+
return codeNames({
|
|
4998
6081
|
passages: [{
|
|
4999
6082
|
name,
|
|
5000
6083
|
tags: ["stylesheet"],
|
|
5001
|
-
text: `@font-face {\n\tfont-family: "${family}";\n\tsrc: url("data:${
|
|
6084
|
+
text: `@font-face {\n\tfont-family: "${cssStringEscape(family)}";\n\tsrc: url("data:${mediaType};base64,${source}") format("${hint}");\n}`
|
|
5002
6085
|
}],
|
|
5003
6086
|
diagnostics: []
|
|
5004
|
-
};
|
|
6087
|
+
});
|
|
5005
6088
|
}
|
|
5006
6089
|
function parseHTMLFile(filename, opts) {
|
|
5007
|
-
const
|
|
6090
|
+
const readDiagnostics = [];
|
|
6091
|
+
const { story, diagnostics } = decompileHTMLForImport(readUTF8(filename, readDiagnostics), { trim: opts.trim ?? true });
|
|
5008
6092
|
return {
|
|
5009
6093
|
passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
|
|
5010
6094
|
name: "StoryTitle",
|
|
5011
6095
|
tags: [],
|
|
5012
6096
|
text: story.name
|
|
5013
6097
|
}, ...story.passages] : story.passages,
|
|
5014
|
-
diagnostics: [...diagnostics]
|
|
6098
|
+
diagnostics: [...readDiagnostics, ...diagnostics]
|
|
5015
6099
|
};
|
|
5016
6100
|
}
|
|
5017
6101
|
/**
|
|
@@ -5084,6 +6168,7 @@ function loadTwee(story, filename, opts, diagnostics) {
|
|
|
5084
6168
|
}
|
|
5085
6169
|
function loadTagged(story, tag, filename, diagnostics) {
|
|
5086
6170
|
const result = parseTaggedFile(tag, filename);
|
|
6171
|
+
diagnostics.push(...result.diagnostics);
|
|
5087
6172
|
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
5088
6173
|
}
|
|
5089
6174
|
function loadMedia(story, tag, filename, diagnostics) {
|
|
@@ -5098,8 +6183,9 @@ function loadFont(story, filename, diagnostics) {
|
|
|
5098
6183
|
* Load sources with mtime-based caching for incremental rebuilds.
|
|
5099
6184
|
* Always creates a fresh Story; cached passages are replayed via storyAdd().
|
|
5100
6185
|
*/
|
|
5101
|
-
function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
|
|
5102
|
-
const currentFiles =
|
|
6186
|
+
function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles, buildFiles) {
|
|
6187
|
+
const currentFiles = new Set(buildFiles);
|
|
6188
|
+
const changedPaths = changedFiles === void 0 ? void 0 : new Set([...changedFiles].map((f) => resolve(f)));
|
|
5103
6189
|
for (const filename of filenames) {
|
|
5104
6190
|
if (processedFiles.has(filename)) {
|
|
5105
6191
|
diagnostics.push({
|
|
@@ -5112,8 +6198,8 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5112
6198
|
const optionsKey = parseOptionsKey(filename, opts);
|
|
5113
6199
|
const entry = cache.get(filename);
|
|
5114
6200
|
const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
|
|
5115
|
-
const changed =
|
|
5116
|
-
if (
|
|
6201
|
+
const changed = changedPaths?.has(resolve(filename)) ?? false;
|
|
6202
|
+
if (changedPaths && cached && !changed) {
|
|
5117
6203
|
diagnostics.push(...cached.diagnostics);
|
|
5118
6204
|
for (const p of cached.passages) storyAdd(story, p, diagnostics);
|
|
5119
6205
|
processedFiles.add(filename);
|
|
@@ -5123,6 +6209,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5123
6209
|
try {
|
|
5124
6210
|
mtimeMs = statSync(filename).mtimeMs;
|
|
5125
6211
|
} catch {
|
|
6212
|
+
cache.delete(filename);
|
|
5126
6213
|
continue;
|
|
5127
6214
|
}
|
|
5128
6215
|
if (cached && !changed && cached.mtimeMs === mtimeMs) {
|
|
@@ -5143,6 +6230,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5143
6230
|
diagnostics.push(...result.diagnostics);
|
|
5144
6231
|
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
5145
6232
|
} catch (e) {
|
|
6233
|
+
cache.delete(filename);
|
|
5146
6234
|
diagnostics.push({
|
|
5147
6235
|
level: "error",
|
|
5148
6236
|
message: `load ${filename}: ${e instanceof Error ? e.message : String(e)}`,
|
|
@@ -5161,11 +6249,6 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5161
6249
|
}
|
|
5162
6250
|
//#endregion
|
|
5163
6251
|
//#region src/template.ts
|
|
5164
|
-
/**
|
|
5165
|
-
* An HTML closing head tag: `</head` in any letter case, followed by whitespace, `/` or `>`, up to the next `>`.
|
|
5166
|
-
* This is how the HTML tokenizer reads an end tag; it ignores whatever sits between the tag name and the `>`.
|
|
5167
|
-
*/
|
|
5168
|
-
const CLOSING_HEAD_TAG = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>";
|
|
5169
6252
|
/** The regular expression source matching exactly `text`. */
|
|
5170
6253
|
function literal(text) {
|
|
5171
6254
|
return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
@@ -5191,6 +6274,8 @@ function fillTemplateParts(parts) {
|
|
|
5191
6274
|
return part.text.replace(pattern, (match, ...groups) => {
|
|
5192
6275
|
const slot = slots.find((_, i) => groups[i] !== void 0);
|
|
5193
6276
|
if (slot === void 0 || slot.occurrences === "first" && filled.has(slot)) return match;
|
|
6277
|
+
const offset = groups[slots.length];
|
|
6278
|
+
if (slot.accepts !== void 0 && typeof offset === "number" && !slot.accepts(part.text, offset)) return match;
|
|
5194
6279
|
filled.add(slot);
|
|
5195
6280
|
return slot.replacement(match);
|
|
5196
6281
|
});
|
|
@@ -5199,9 +6284,10 @@ function fillTemplateParts(parts) {
|
|
|
5199
6284
|
//#endregion
|
|
5200
6285
|
//#region src/modules.ts
|
|
5201
6286
|
/**
|
|
5202
|
-
* Load modules and return HTML tags to inject before the closing head tag.
|
|
6287
|
+
* Load modules and return HTML tags to inject before the closing head tag. `diagnostics` receives a warning for
|
|
6288
|
+
* each module that is not valid UTF-8 (it is read as Windows-1252).
|
|
5203
6289
|
*/
|
|
5204
|
-
function loadModules(filenames) {
|
|
6290
|
+
function loadModules(filenames, diagnostics) {
|
|
5205
6291
|
const processed = /* @__PURE__ */ new Set();
|
|
5206
6292
|
const headTags = [];
|
|
5207
6293
|
for (const filename of filenames) {
|
|
@@ -5210,10 +6296,10 @@ function loadModules(filenames) {
|
|
|
5210
6296
|
let tag = null;
|
|
5211
6297
|
switch (ext) {
|
|
5212
6298
|
case "css":
|
|
5213
|
-
tag = loadModuleTagged("style", filename);
|
|
6299
|
+
tag = loadModuleTagged("style", filename, diagnostics);
|
|
5214
6300
|
break;
|
|
5215
6301
|
case "js":
|
|
5216
|
-
tag = loadModuleTagged("script", filename);
|
|
6302
|
+
tag = loadModuleTagged("script", filename, diagnostics);
|
|
5217
6303
|
break;
|
|
5218
6304
|
case "otf":
|
|
5219
6305
|
case "ttf":
|
|
@@ -5228,8 +6314,8 @@ function loadModules(filenames) {
|
|
|
5228
6314
|
}
|
|
5229
6315
|
return headTags.join("\n");
|
|
5230
6316
|
}
|
|
5231
|
-
function loadModuleTagged(tag, filename) {
|
|
5232
|
-
const source = readUTF8(filename).trim();
|
|
6317
|
+
function loadModuleTagged(tag, filename, diagnostics) {
|
|
6318
|
+
const source = readUTF8(filename, diagnostics).trim();
|
|
5233
6319
|
if (source.length === 0) return null;
|
|
5234
6320
|
return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
|
|
5235
6321
|
}
|
|
@@ -5238,20 +6324,22 @@ function loadModuleFont(filename) {
|
|
|
5238
6324
|
const family = baseNameWithoutExt(filename);
|
|
5239
6325
|
const idSlug = `style-module-${slugify(family)}`;
|
|
5240
6326
|
const ext = normalizedFileExt(filename);
|
|
5241
|
-
|
|
6327
|
+
const mediaType = mediaTypeFromExt(ext);
|
|
6328
|
+
const hint = fontFormatHint(ext);
|
|
6329
|
+
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>`;
|
|
5242
6330
|
}
|
|
5243
6331
|
/**
|
|
5244
|
-
* Load the module tags and head file content to inject
|
|
6332
|
+
* Load the module tags and head file content to inject into the head (see `placeHead()`), joined by newlines,
|
|
5245
6333
|
* or `''` when there is nothing to inject.
|
|
5246
6334
|
*/
|
|
5247
6335
|
function loadHeadContent(modulePaths, headFile, diagnostics) {
|
|
5248
6336
|
const parts = [];
|
|
5249
6337
|
if (modulePaths.length > 0) {
|
|
5250
|
-
const modules = loadModules(modulePaths).trim();
|
|
6338
|
+
const modules = loadModules(modulePaths, diagnostics).trim();
|
|
5251
6339
|
if (modules.length > 0) parts.push(modules);
|
|
5252
6340
|
}
|
|
5253
6341
|
if (headFile) try {
|
|
5254
|
-
const source = readUTF8(headFile).trim();
|
|
6342
|
+
const source = readUTF8(headFile, diagnostics).trim();
|
|
5255
6343
|
if (source.length > 0) parts.push(source);
|
|
5256
6344
|
} catch (e) {
|
|
5257
6345
|
diagnostics?.push({
|
|
@@ -5262,16 +6350,140 @@ function loadHeadContent(modulePaths, headFile, diagnostics) {
|
|
|
5262
6350
|
return parts.join("\n");
|
|
5263
6351
|
}
|
|
5264
6352
|
/**
|
|
5265
|
-
*
|
|
5266
|
-
*
|
|
5267
|
-
*
|
|
6353
|
+
* Place `content` in a template, given the texts that `fillTemplateParts()` scans for the slot (the template, and
|
|
6354
|
+
* the footer of a pre-1.4 Twine 1 format):
|
|
6355
|
+
*
|
|
6356
|
+
* - on its own line before the first closing head tag, as Tweego does; the tag may use any letter case and
|
|
6357
|
+
* whitespace before its `>`, and is kept as written;
|
|
6358
|
+
* - in a template with no closing head tag (HTML lets a document leave it out), on its own line before the first
|
|
6359
|
+
* body start tag, where the HTML parser still puts it in the head, with a warning (Tweego drops it);
|
|
6360
|
+
* - in a template with neither, nowhere, with a warning.
|
|
6361
|
+
*
|
|
6362
|
+
* `owner` names the template in the warnings. Empty `content` needs no slot and gets no warning.
|
|
6363
|
+
*/
|
|
6364
|
+
function placeHead(content, texts, owner) {
|
|
6365
|
+
if (content.length === 0) return {
|
|
6366
|
+
slot: void 0,
|
|
6367
|
+
diagnostics: []
|
|
6368
|
+
};
|
|
6369
|
+
const scans = texts.map((text) => ({
|
|
6370
|
+
text,
|
|
6371
|
+
tags: scanHeadTags(text)
|
|
6372
|
+
}));
|
|
6373
|
+
if (scans.some(({ tags }) => tags.closingHead !== void 0)) return {
|
|
6374
|
+
slot: beforeTag(CLOSING_HEAD_PREFIX, "closingHead", content),
|
|
6375
|
+
diagnostics: []
|
|
6376
|
+
};
|
|
6377
|
+
if (scans.some(({ tags }) => tags.bodyStart !== void 0)) {
|
|
6378
|
+
const message = `${owner} has no closing head tag; the modules and head file were injected before its body start tag.`;
|
|
6379
|
+
return {
|
|
6380
|
+
slot: beforeTag(BODY_START_PREFIX, "bodyStart", content),
|
|
6381
|
+
diagnostics: [{
|
|
6382
|
+
level: "warning",
|
|
6383
|
+
message
|
|
6384
|
+
}]
|
|
6385
|
+
};
|
|
6386
|
+
}
|
|
6387
|
+
return {
|
|
6388
|
+
slot: void 0,
|
|
6389
|
+
diagnostics: [{
|
|
6390
|
+
level: "warning",
|
|
6391
|
+
message: `${owner} has no closing head tag and no body start tag; the modules and head file were not injected.`
|
|
6392
|
+
}]
|
|
6393
|
+
};
|
|
6394
|
+
}
|
|
6395
|
+
/** `</head` in any letter case, followed by whitespace, `/` or `>`; the rest of the tag is left alone. */
|
|
6396
|
+
const CLOSING_HEAD_PREFIX = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])";
|
|
6397
|
+
/** `<body` in any letter case, followed by whitespace, `/` or `>` (so not `<bodyx>`). */
|
|
6398
|
+
const BODY_START_PREFIX = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
|
|
6399
|
+
/** Elements whose content is text, so a tag written there is not a tag. */
|
|
6400
|
+
const RAW_TEXT_ELEMENTS = /* @__PURE__ */ new Set([
|
|
6401
|
+
"script",
|
|
6402
|
+
"style",
|
|
6403
|
+
"textarea",
|
|
6404
|
+
"title"
|
|
6405
|
+
]);
|
|
6406
|
+
const isSpace = (c) => c === " " || c === " " || c === "\n" || c === "\f" || c === "\r";
|
|
6407
|
+
const isLetter = (c) => c !== void 0 && /^[A-Za-z]$/.test(c);
|
|
6408
|
+
/**
|
|
6409
|
+
* Find the first closing head tag and body start tag that HTML would read as tags: not those in a comment, in
|
|
6410
|
+
* the text of a script, style, textarea or title element, or in a quoted attribute value. A small scanner, not
|
|
6411
|
+
* a full HTML tokenizer; it needs no more than the tags and where they end.
|
|
6412
|
+
*/
|
|
6413
|
+
function scanHeadTags(html) {
|
|
6414
|
+
let closingHead;
|
|
6415
|
+
let bodyStart;
|
|
6416
|
+
let i = 0;
|
|
6417
|
+
while (i < html.length) {
|
|
6418
|
+
const lt = html.indexOf("<", i);
|
|
6419
|
+
if (lt === -1) break;
|
|
6420
|
+
const next = html[lt + 1];
|
|
6421
|
+
if (html.startsWith("<!--", lt)) {
|
|
6422
|
+
const end = html.indexOf("-->", lt + 4);
|
|
6423
|
+
i = end === -1 ? html.length : end + 3;
|
|
6424
|
+
} else if (next === "!" || next === "?") {
|
|
6425
|
+
const end = html.indexOf(">", lt);
|
|
6426
|
+
i = end === -1 ? html.length : end + 1;
|
|
6427
|
+
} else if (next === "/" && isLetter(html[lt + 2])) {
|
|
6428
|
+
const { name, end } = readTag(html, lt + 2);
|
|
6429
|
+
if (name === "head" && closingHead === void 0) closingHead = lt;
|
|
6430
|
+
i = end;
|
|
6431
|
+
} else if (isLetter(next)) {
|
|
6432
|
+
const { name, end } = readTag(html, lt + 1);
|
|
6433
|
+
if (name === "body" && bodyStart === void 0) bodyStart = lt;
|
|
6434
|
+
i = RAW_TEXT_ELEMENTS.has(name) ? rawTextEnd(html, name, end) : end;
|
|
6435
|
+
} else i = lt + 1;
|
|
6436
|
+
if (closingHead !== void 0 && bodyStart !== void 0) break;
|
|
6437
|
+
}
|
|
6438
|
+
return {
|
|
6439
|
+
closingHead,
|
|
6440
|
+
bodyStart
|
|
6441
|
+
};
|
|
6442
|
+
}
|
|
6443
|
+
/** Read a tag whose name starts at `from`: its lowercase name, and the offset after its `>` (or the end). */
|
|
6444
|
+
function readTag(html, from) {
|
|
6445
|
+
let i = from;
|
|
6446
|
+
while (i < html.length && !isSpace(html[i]) && html[i] !== "/" && html[i] !== ">") i++;
|
|
6447
|
+
const name = html.slice(from, i).toLowerCase();
|
|
6448
|
+
while (i < html.length && html[i] !== ">") {
|
|
6449
|
+
if (html[i] !== "=") {
|
|
6450
|
+
i++;
|
|
6451
|
+
continue;
|
|
6452
|
+
}
|
|
6453
|
+
i++;
|
|
6454
|
+
while (isSpace(html[i])) i++;
|
|
6455
|
+
const quote = html[i];
|
|
6456
|
+
if (quote === "\"" || quote === "'") {
|
|
6457
|
+
const close = html.indexOf(quote, i + 1);
|
|
6458
|
+
i = close === -1 ? html.length : close + 1;
|
|
6459
|
+
}
|
|
6460
|
+
}
|
|
6461
|
+
return {
|
|
6462
|
+
name,
|
|
6463
|
+
end: Math.min(i + 1, html.length)
|
|
6464
|
+
};
|
|
6465
|
+
}
|
|
6466
|
+
/** The offset of the end tag that closes a raw text element, given where its text starts (or the end). */
|
|
6467
|
+
function rawTextEnd(html, name, from) {
|
|
6468
|
+
const close = new RegExp(`</${name}(?=[\\t\\n\\f\\r />])`, "gi");
|
|
6469
|
+
close.lastIndex = from;
|
|
6470
|
+
return close.exec(html)?.index ?? html.length;
|
|
6471
|
+
}
|
|
6472
|
+
/**
|
|
6473
|
+
* The slot that inserts `content` on its own line before the first real tag of the kind `which` names (see
|
|
6474
|
+
* `scanHeadTags()`), which is kept as written. A look-alike inside a script or comment is left alone.
|
|
5268
6475
|
*/
|
|
5269
|
-
function
|
|
5270
|
-
|
|
6476
|
+
function beforeTag(pattern, which, content) {
|
|
6477
|
+
const cache = /* @__PURE__ */ new Map();
|
|
6478
|
+
const offsetIn = (text) => {
|
|
6479
|
+
if (!cache.has(text)) cache.set(text, scanHeadTags(text)[which]);
|
|
6480
|
+
return cache.get(text);
|
|
6481
|
+
};
|
|
5271
6482
|
return {
|
|
5272
|
-
pattern
|
|
6483
|
+
pattern,
|
|
5273
6484
|
occurrences: "first",
|
|
5274
|
-
replacement: (
|
|
6485
|
+
replacement: (match) => `${content}\n${match}`,
|
|
6486
|
+
accepts: (text, offset) => offsetIn(text) === offset
|
|
5275
6487
|
};
|
|
5276
6488
|
}
|
|
5277
6489
|
//#endregion
|
|
@@ -5310,14 +6522,19 @@ function toTwine2Archive(story, startName, options) {
|
|
|
5310
6522
|
}
|
|
5311
6523
|
/**
|
|
5312
6524
|
* Fill the Twine 2 format template: every `{{STORY_NAME}}` gets the escaped story name and the first
|
|
5313
|
-
* `{{STORY_DATA}}` the story data (as Tweego does), and `head` goes before the first closing head tag
|
|
5314
|
-
* found in the template in one pass, so a story
|
|
5315
|
-
* closing head tag stays literal.
|
|
6525
|
+
* `{{STORY_DATA}}` the story data (as Tweego does), and `head` goes before the first closing head tag (or, with a
|
|
6526
|
+
* warning, the body start tag; see `placeHead()`). All three are found in the template in one pass, so a story
|
|
6527
|
+
* name, passage text or head content holding a placeholder or a closing head tag stays literal.
|
|
6528
|
+
* `options.diagnostics` receives a warning when the format file is not valid UTF-8, and any warning about where
|
|
6529
|
+
* `head` went.
|
|
5316
6530
|
*/
|
|
5317
6531
|
function toTwine2HTML(story, format, startName, options) {
|
|
5318
6532
|
const name = htmlEscape(story.name);
|
|
5319
|
-
const
|
|
5320
|
-
const
|
|
6533
|
+
const template = readFormatSource(format, options?.diagnostics);
|
|
6534
|
+
const placement = placeHead(options?.head ?? "", [template], `Story format "${format.name}" ${format.version}`);
|
|
6535
|
+
options?.diagnostics?.push(...placement.diagnostics);
|
|
6536
|
+
const head = placement.slot;
|
|
6537
|
+
return fillTemplate(template, [
|
|
5321
6538
|
{
|
|
5322
6539
|
pattern: literal("{{STORY_NAME}}"),
|
|
5323
6540
|
occurrences: "all",
|
|
@@ -5338,8 +6555,7 @@ function toTwine2HTML(story, format, startName, options) {
|
|
|
5338
6555
|
}
|
|
5339
6556
|
},
|
|
5340
6557
|
...head === void 0 ? [] : [head]
|
|
5341
|
-
];
|
|
5342
|
-
return fillTemplate(readFormatSource(format), slots);
|
|
6558
|
+
]);
|
|
5343
6559
|
}
|
|
5344
6560
|
const OMITTING_TAGS = [
|
|
5345
6561
|
"Twine.private",
|
|
@@ -5435,29 +6651,35 @@ function toTwine1Archive(story, _startName) {
|
|
|
5435
6651
|
/**
|
|
5436
6652
|
* Fill the Twine 1 format template. The format's components are inserted first, one after another, as Tweego and
|
|
5437
6653
|
* Twine 1 do. Then the first of each story placeholder (`"VERSION"`, `"TIME"`, `"START_AT"`, `"STORY_SIZE"`,
|
|
5438
|
-
* `"STORY"`), the IFID comment and `head` (before the first closing head tag
|
|
5439
|
-
* passage name or story data holding a placeholder or a
|
|
6654
|
+
* `"STORY"`), the IFID comment and `head` (before the first closing head tag or, with a warning, the body start
|
|
6655
|
+
* tag; see `placeHead()`) are found in one pass, so a start passage name or story data holding a placeholder or a
|
|
6656
|
+
* closing head tag stays literal. `options.diagnostics` receives a warning for each format file that is not valid
|
|
6657
|
+
* UTF-8, and any warning about where `head` went.
|
|
5440
6658
|
*/
|
|
5441
6659
|
function toTwine1HTML(story, format, startName, options) {
|
|
5442
6660
|
const formatDir = dirname(format.filename);
|
|
5443
6661
|
const parentDir = dirname(formatDir);
|
|
5444
|
-
|
|
6662
|
+
const diagnostics = options?.diagnostics;
|
|
6663
|
+
let template = readFormatSource(format, diagnostics);
|
|
5445
6664
|
const { data, count } = getTwine1PassageChunk(story);
|
|
5446
|
-
template = tryReplaceComponent(template, "\"USER_LIB\"", join(formatDir, "userlib.js"), false);
|
|
5447
|
-
template = tryReplaceComponent(template, "\"ENGINE\"", join(parentDir, "engine.js"), true);
|
|
5448
|
-
template = tryReplaceComponent(template, "\"SUGARCANE\"", join(formatDir, "code.js"), true);
|
|
5449
|
-
template = tryReplaceComponent(template, "\"JONAH\"", join(formatDir, "code.js"), true);
|
|
5450
|
-
if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", join(parentDir, "jquery.js"), true);
|
|
5451
|
-
if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", join(parentDir, "modernizr.js"), true);
|
|
6665
|
+
template = tryReplaceComponent(template, "\"USER_LIB\"", join(formatDir, "userlib.js"), false, diagnostics);
|
|
6666
|
+
template = tryReplaceComponent(template, "\"ENGINE\"", join(parentDir, "engine.js"), true, diagnostics);
|
|
6667
|
+
template = tryReplaceComponent(template, "\"SUGARCANE\"", join(formatDir, "code.js"), true, diagnostics);
|
|
6668
|
+
template = tryReplaceComponent(template, "\"JONAH\"", join(formatDir, "code.js"), true, diagnostics);
|
|
6669
|
+
if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", join(parentDir, "jquery.js"), true, diagnostics);
|
|
6670
|
+
if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", join(parentDir, "modernizr.js"), true, diagnostics);
|
|
5452
6671
|
const isPre14 = !template.includes("\"STORY\"");
|
|
5453
|
-
const footer = isPre14 ? readFooter(formatDir) : "";
|
|
6672
|
+
const footer = isPre14 ? readFooter(formatDir, diagnostics) : "";
|
|
5454
6673
|
const storeArea = (template + footer).includes("<div id=\"store-area\"") ? "<div id=\"store-area\"" : "<div id=\"storeArea\"";
|
|
5455
6674
|
const safeIfid = htmlCommentSanitize(story.ifid);
|
|
5456
|
-
const
|
|
6675
|
+
const ifid = story.ifid ? {
|
|
5457
6676
|
pattern: literal(storeArea),
|
|
5458
6677
|
occurrences: "first",
|
|
5459
6678
|
replacement: (div) => `<!-- UUID://${safeIfid}// -->${div}`
|
|
5460
|
-
} : void 0
|
|
6679
|
+
} : void 0;
|
|
6680
|
+
const placement = placeHead(options?.head ?? "", isPre14 ? [template, footer] : [template], `Story format "${format.name}"`);
|
|
6681
|
+
diagnostics?.push(...placement.diagnostics);
|
|
6682
|
+
const late = [ifid, placement.slot].filter((slot) => slot !== void 0);
|
|
5461
6683
|
const displayStart = startName === "Start" ? "" : startName;
|
|
5462
6684
|
const slots = [
|
|
5463
6685
|
firstSlot("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`),
|
|
@@ -5497,9 +6719,9 @@ function firstSlot(token, value) {
|
|
|
5497
6719
|
};
|
|
5498
6720
|
}
|
|
5499
6721
|
/** The footer of a pre-1.4 format, or the default one when the format has none. */
|
|
5500
|
-
function readFooter(formatDir) {
|
|
6722
|
+
function readFooter(formatDir, diagnostics) {
|
|
5501
6723
|
try {
|
|
5502
|
-
return
|
|
6724
|
+
return readUTF8(join(formatDir, "footer.html"), diagnostics);
|
|
5503
6725
|
} catch {
|
|
5504
6726
|
return "</div>\n</body>\n</html>\n";
|
|
5505
6727
|
}
|
|
@@ -5527,11 +6749,10 @@ function getTwine1PassageChunk(story) {
|
|
|
5527
6749
|
count
|
|
5528
6750
|
};
|
|
5529
6751
|
}
|
|
5530
|
-
function tryReplaceComponent(template, placeholder, componentPath, required) {
|
|
6752
|
+
function tryReplaceComponent(template, placeholder, componentPath, required, diagnostics) {
|
|
5531
6753
|
if (!template.includes(placeholder)) return template;
|
|
5532
6754
|
try {
|
|
5533
|
-
|
|
5534
|
-
if (content.charCodeAt(0) === 65279) content = content.slice(1);
|
|
6755
|
+
const content = readUTF8(componentPath, diagnostics);
|
|
5535
6756
|
return template.replace(placeholder, () => content);
|
|
5536
6757
|
} catch (e) {
|
|
5537
6758
|
if (required) throw new Error(`Required format component not found: ${componentPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -5545,11 +6766,16 @@ function tryReplaceComponent(template, placeholder, componentPath, required) {
|
|
|
5545
6766
|
* from its loaded text, so changes made after loading (a start passage override, test mode) are kept.
|
|
5546
6767
|
* Tweego writes the loaded text; its own normalization makes the two the same for an unchanged story.
|
|
5547
6768
|
*
|
|
6769
|
+
* Twee cannot write every passage so that it reads back the same (see `tweeRoundTrip()`); `diagnostics`
|
|
6770
|
+
* receives a warning for each passage that will not. Tweego writes the same Twee without one.
|
|
6771
|
+
*
|
|
5548
6772
|
* `addStoryData`: add a StoryData passage when the story has none, so that StoryData fields the
|
|
5549
6773
|
* compile options set are recorded. Default: false.
|
|
5550
6774
|
*/
|
|
5551
6775
|
function toTwee(story, outMode, options = {}) {
|
|
5552
|
-
|
|
6776
|
+
const written = withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => tweeRoundTrip(p, outMode));
|
|
6777
|
+
options.diagnostics?.push(...written.flatMap(({ diagnostics }) => diagnostics));
|
|
6778
|
+
return written.map(({ passage }) => passageToTwee(passage, outMode)).join("");
|
|
5553
6779
|
}
|
|
5554
6780
|
function withEffectiveStoryData(story, addStoryData) {
|
|
5555
6781
|
const storyData = {
|
|
@@ -5574,6 +6800,64 @@ function withEffectiveStoryData(story, addStoryData) {
|
|
|
5574
6800
|
function isStoryDataJSON(text) {
|
|
5575
6801
|
return unmarshalStoryData(createStory(), text) === null;
|
|
5576
6802
|
}
|
|
6803
|
+
/** Characters Twee 3 escapes in names and tags, and Twee 1 cannot write there at all. */
|
|
6804
|
+
const TWEE1_UNWRITABLE = /[[\]{}\\]/;
|
|
6805
|
+
/**
|
|
6806
|
+
* Check that Twee reads `p` back as written, and say what it will not. Twee has no way to escape passage text,
|
|
6807
|
+
* and its parser trims names and splits tags at whitespace, so these do not survive:
|
|
6808
|
+
*
|
|
6809
|
+
* - a text line that starts with `::`, which reads as a new passage header. In stylesheet and script passages such
|
|
6810
|
+
* a line is indented by one space, which keeps the CSS or JavaScript working (only a template literal or a
|
|
6811
|
+
* string continued across lines, in either, would see the space); the warning says so. Other passages' text is
|
|
6812
|
+
* written as it is.
|
|
6813
|
+
* - a name that is empty, has leading or trailing whitespace, or holds a line break;
|
|
6814
|
+
* - a tag that is empty or holds whitespace;
|
|
6815
|
+
* - in Twee 1, which escapes nothing, a name or tag holding `[`, `]`, `{`, `}` or `\`.
|
|
6816
|
+
*/
|
|
6817
|
+
function tweeRoundTrip(p, outMode) {
|
|
6818
|
+
const label = `Passage ${JSON.stringify(p.name)}`;
|
|
6819
|
+
const diagnostics = [];
|
|
6820
|
+
const warn = (problem) => {
|
|
6821
|
+
diagnostics.push({
|
|
6822
|
+
level: "warning",
|
|
6823
|
+
message: `${label} cannot be written as Twee that reads back the same: ${problem}.`
|
|
6824
|
+
});
|
|
6825
|
+
};
|
|
6826
|
+
if (p.name.trim() === "") warn("its name is empty");
|
|
6827
|
+
else if (p.name.trim() !== p.name) warn("its name has leading or trailing whitespace, which Twee drops");
|
|
6828
|
+
if (/[\r\n]/.test(p.name)) warn("its name has a line break, which ends a Twee passage header");
|
|
6829
|
+
if (outMode === "twee1" && TWEE1_UNWRITABLE.test(p.name)) warn("its name has \"[\", \"]\", \"{\", \"}\" or \"\\\", which Twee 1 cannot escape");
|
|
6830
|
+
for (const tag of p.tags) {
|
|
6831
|
+
if (tag === "" || /\s/.test(tag)) warn(`its tag ${JSON.stringify(tag)} is empty or has whitespace, which splits tags in Twee`);
|
|
6832
|
+
if (outMode === "twee1" && TWEE1_UNWRITABLE.test(tag)) warn(`its tag ${JSON.stringify(tag)} has "[", "]", "{", "}" or "\\", which Twee 1 cannot escape`);
|
|
6833
|
+
}
|
|
6834
|
+
const lines = p.text.split(/(\r\n?|\n)/);
|
|
6835
|
+
const headerLines = lines.flatMap((line, i) => i % 2 === 0 && line.startsWith("::") ? [i / 2 + 1] : []);
|
|
6836
|
+
if (headerLines.length === 0) return {
|
|
6837
|
+
passage: p,
|
|
6838
|
+
diagnostics
|
|
6839
|
+
};
|
|
6840
|
+
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`;
|
|
6841
|
+
if (!p.tags.some((tag) => tag === "stylesheet" || tag === "script")) {
|
|
6842
|
+
warn(where);
|
|
6843
|
+
return {
|
|
6844
|
+
passage: p,
|
|
6845
|
+
diagnostics
|
|
6846
|
+
};
|
|
6847
|
+
}
|
|
6848
|
+
diagnostics.push({
|
|
6849
|
+
level: "warning",
|
|
6850
|
+
message: `${label}: ${where}; ${headerLines.length === 1 ? "it was" : "they were"} written indented by one space.`
|
|
6851
|
+
});
|
|
6852
|
+
const text = lines.map((line, i) => i % 2 === 0 && line.startsWith("::") ? ` ${line}` : line).join("");
|
|
6853
|
+
return {
|
|
6854
|
+
passage: {
|
|
6855
|
+
...p,
|
|
6856
|
+
text
|
|
6857
|
+
},
|
|
6858
|
+
diagnostics
|
|
6859
|
+
};
|
|
6860
|
+
}
|
|
5577
6861
|
//#endregion
|
|
5578
6862
|
//#region src/passage-omission.ts
|
|
5579
6863
|
/**
|
|
@@ -5634,16 +6918,19 @@ var TweeTsError = class extends Error {
|
|
|
5634
6918
|
}
|
|
5635
6919
|
};
|
|
5636
6920
|
/**
|
|
5637
|
-
* Builds as compileToFile() does for `
|
|
6921
|
+
* Builds as compileToFile() does for `outputs`, without writing them: an output may sit
|
|
5638
6922
|
* inside a source folder, so its last build is left out of the sources and modules and
|
|
5639
|
-
* never read back.
|
|
5640
|
-
*
|
|
5641
|
-
*
|
|
6923
|
+
* never read back. `outputs` is the one file the CLI writes, or every path a bundler's
|
|
6924
|
+
* build writes (see BuildOutputs). The caller decides whether the result is written.
|
|
6925
|
+
* With no outputs (output to stdout, or none), this is compile(). With `cache`, files
|
|
6926
|
+
* are cached as compileIncremental() caches them.
|
|
6927
|
+
*
|
|
6928
|
+
* Throws a TweeTsError when a source, module or head file named directly is an output.
|
|
5642
6929
|
*
|
|
5643
6930
|
* Internal, for the CLI and the bundler plugins; not part of the public API.
|
|
5644
6931
|
*/
|
|
5645
|
-
async function compileForOutputFile(options,
|
|
5646
|
-
return buildOutput(options, cache, void 0,
|
|
6932
|
+
async function compileForOutputFile(options, outputs, cache) {
|
|
6933
|
+
return buildOutput(options, cache, void 0, outputs);
|
|
5647
6934
|
}
|
|
5648
6935
|
/**
|
|
5649
6936
|
* watch(), writing to `outFile` only the builds `shouldWrite` accepts. A rejected build
|
|
@@ -5652,9 +6939,13 @@ async function compileForOutputFile(options, outFile, cache) {
|
|
|
5652
6939
|
* Internal, for the CLI, which keeps the last good output when a rebuild reports errors;
|
|
5653
6940
|
* not part of the public API.
|
|
5654
6941
|
*/
|
|
5655
|
-
async function watchWithWriteFilter(options, shouldWrite) {
|
|
6942
|
+
async function watchWithWriteFilter(options, shouldWrite, hooks = {}) {
|
|
5656
6943
|
const controller = new AbortController();
|
|
5657
6944
|
const cache = /* @__PURE__ */ new Map();
|
|
6945
|
+
const buildOptions = {
|
|
6946
|
+
...options,
|
|
6947
|
+
signal: controller.signal
|
|
6948
|
+
};
|
|
5658
6949
|
const filePaths = options.sources.filter((s) => typeof s === "string");
|
|
5659
6950
|
const modulePaths = options.modules ?? [];
|
|
5660
6951
|
const headPaths = options.headFile ? [options.headFile] : [];
|
|
@@ -5672,13 +6963,20 @@ async function watchWithWriteFilter(options, shouldWrite) {
|
|
|
5672
6963
|
};
|
|
5673
6964
|
let building = false;
|
|
5674
6965
|
let queued;
|
|
6966
|
+
const reportError = (error) => {
|
|
6967
|
+
try {
|
|
6968
|
+
options.onError?.(error);
|
|
6969
|
+
} catch (e) {
|
|
6970
|
+
console.error(`twee-ts watch: onError threw while handling "${error.message}": ${toError(e).message}`);
|
|
6971
|
+
}
|
|
6972
|
+
};
|
|
5675
6973
|
const deliver = (outcome) => {
|
|
5676
6974
|
try {
|
|
5677
6975
|
if (!outcome.ok) throw outcome.error;
|
|
5678
|
-
if (shouldWrite(outcome.result))
|
|
6976
|
+
if (shouldWrite(outcome.result)) writeFileAtomic(options.outFile, outcome.result.output);
|
|
5679
6977
|
options.onBuild?.(outcome.result);
|
|
5680
6978
|
} catch (e) {
|
|
5681
|
-
|
|
6979
|
+
reportError(toError(e));
|
|
5682
6980
|
}
|
|
5683
6981
|
};
|
|
5684
6982
|
const drain = async (first) => {
|
|
@@ -5686,8 +6984,7 @@ async function watchWithWriteFilter(options, shouldWrite) {
|
|
|
5686
6984
|
try {
|
|
5687
6985
|
let request = first;
|
|
5688
6986
|
while (request !== void 0) {
|
|
5689
|
-
const
|
|
5690
|
-
const outcome = await buildOutput(options, cache, current.changedFiles, options.outFile).then((result) => ({
|
|
6987
|
+
const outcome = await buildOutput(buildOptions, cache, request.changedFiles, options.outFile).then((result) => ({
|
|
5691
6988
|
ok: true,
|
|
5692
6989
|
result
|
|
5693
6990
|
}), (e) => ({
|
|
@@ -5695,12 +6992,13 @@ async function watchWithWriteFilter(options, shouldWrite) {
|
|
|
5695
6992
|
error: toError(e)
|
|
5696
6993
|
}));
|
|
5697
6994
|
if (controller.signal.aborted) return;
|
|
5698
|
-
|
|
6995
|
+
deliver(outcome);
|
|
6996
|
+
request = queued;
|
|
5699
6997
|
queued = void 0;
|
|
5700
|
-
if (request === void 0) deliver(outcome);
|
|
5701
6998
|
}
|
|
5702
6999
|
} finally {
|
|
5703
7000
|
building = false;
|
|
7001
|
+
hooks.onIdle?.();
|
|
5704
7002
|
}
|
|
5705
7003
|
};
|
|
5706
7004
|
const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
|
|
@@ -5710,12 +7008,17 @@ async function watchWithWriteFilter(options, shouldWrite) {
|
|
|
5710
7008
|
queued = queued === void 0 ? request : mergeBuildRequests(queued, request);
|
|
5711
7009
|
return;
|
|
5712
7010
|
}
|
|
5713
|
-
drain(request);
|
|
5714
|
-
}, ignore);
|
|
7011
|
+
drain(request).catch((e) => reportError(toError(e)));
|
|
7012
|
+
}, ignore, reportError);
|
|
7013
|
+
const outer = options.signal;
|
|
7014
|
+
const onOuterAbort = () => controller.abort(outer?.reason);
|
|
5715
7015
|
controller.signal.addEventListener("abort", () => {
|
|
5716
7016
|
queued = void 0;
|
|
5717
7017
|
handle.close();
|
|
7018
|
+
outer?.removeEventListener("abort", onOuterAbort);
|
|
5718
7019
|
});
|
|
7020
|
+
if (outer?.aborted) onOuterAbort();
|
|
7021
|
+
else outer?.addEventListener("abort", onOuterAbort, { once: true });
|
|
5719
7022
|
return controller;
|
|
5720
7023
|
}
|
|
5721
7024
|
/** One build covering both requests: a full build if either is one, else every changed file. */
|
|
@@ -5727,10 +7030,13 @@ function toError(e) {
|
|
|
5727
7030
|
return e instanceof Error ? e : new Error(String(e));
|
|
5728
7031
|
}
|
|
5729
7032
|
/**
|
|
5730
|
-
* `
|
|
5731
|
-
*
|
|
7033
|
+
* `outputs`: what the build writes (an output file's path, or a bundler's outputs),
|
|
7034
|
+
* which source and module discovery skip, so an output inside a source folder is
|
|
7035
|
+
* never loaded back. A source, module or head file named directly that is an output
|
|
7036
|
+
* is a TweeTsError, as in Tweego: writing the build would overwrite it.
|
|
5732
7037
|
*/
|
|
5733
|
-
async function buildOutput(options, cache, changedFiles,
|
|
7038
|
+
async function buildOutput(options, cache, changedFiles, outputs) {
|
|
7039
|
+
const written = toBuildOutputs(outputs);
|
|
5734
7040
|
const diagnostics = [];
|
|
5735
7041
|
const outputMode = options.outputMode ?? "html";
|
|
5736
7042
|
const trim = options.trim ?? true;
|
|
@@ -5738,33 +7044,65 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5738
7044
|
const testMode = options.testMode ?? false;
|
|
5739
7045
|
const noRemote = options.noRemote ?? false;
|
|
5740
7046
|
const sourceInfo = options.sourceInfo ?? false;
|
|
7047
|
+
options.signal?.throwIfAborted();
|
|
7048
|
+
const timeout = options.formatFetchTimeout;
|
|
7049
|
+
if (timeout !== void 0 && !(timeout >= 0)) throw new TweeTsError(`formatFetchTimeout must be 0 or more milliseconds, not ${timeout}.`);
|
|
5741
7050
|
clearIndexCache();
|
|
5742
|
-
const
|
|
5743
|
-
const
|
|
5744
|
-
for (const source of options.sources)
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
|
|
7051
|
+
const groups = [];
|
|
7052
|
+
const outputSources = [];
|
|
7053
|
+
for (const source of options.sources) {
|
|
7054
|
+
const last = groups[groups.length - 1];
|
|
7055
|
+
if (typeof source === "string") {
|
|
7056
|
+
if (last?.kind === "paths") last.paths.push(source);
|
|
7057
|
+
else groups.push({
|
|
7058
|
+
kind: "paths",
|
|
7059
|
+
paths: [source]
|
|
7060
|
+
});
|
|
7061
|
+
} else if (last?.kind === "inline") last.sources.push(source);
|
|
7062
|
+
else groups.push({
|
|
7063
|
+
kind: "inline",
|
|
7064
|
+
sources: [source]
|
|
7065
|
+
});
|
|
7066
|
+
}
|
|
7067
|
+
const walked = groups.map((group) => {
|
|
7068
|
+
if (group.kind === "inline") return group;
|
|
7069
|
+
const found = getFilenames(group.paths, written, options.exclude);
|
|
7070
|
+
diagnostics.push(...found.diagnostics);
|
|
7071
|
+
outputSources.push(...found.outputSources);
|
|
7072
|
+
return {
|
|
7073
|
+
kind: "files",
|
|
7074
|
+
filenames: found.filenames
|
|
7075
|
+
};
|
|
7076
|
+
});
|
|
7077
|
+
rejectOutputSources(outputSources, diagnostics);
|
|
7078
|
+
const buildFiles = new Set(walked.flatMap((group) => group.kind === "files" ? group.filenames : []));
|
|
5748
7079
|
const story = createStory();
|
|
5749
7080
|
const processedFiles = /* @__PURE__ */ new Set();
|
|
5750
|
-
if (
|
|
7081
|
+
for (const group of walked) if (group.kind === "inline") loadInlineSources(story, group.sources, {
|
|
5751
7082
|
trim,
|
|
5752
7083
|
twee2Compat
|
|
5753
|
-
}, diagnostics
|
|
5754
|
-
else
|
|
7084
|
+
}, diagnostics);
|
|
7085
|
+
else if (cache) loadSourcesCached(story, group.filenames, {
|
|
7086
|
+
trim,
|
|
7087
|
+
twee2Compat
|
|
7088
|
+
}, diagnostics, processedFiles, cache, changedFiles, buildFiles);
|
|
7089
|
+
else loadSources(story, group.filenames, {
|
|
5755
7090
|
trim,
|
|
5756
7091
|
twee2Compat
|
|
5757
7092
|
}, diagnostics, processedFiles);
|
|
5758
|
-
|
|
7093
|
+
if (cache && buildFiles.size === 0) loadSourcesCached(story, [], {
|
|
5759
7094
|
trim,
|
|
5760
7095
|
twee2Compat
|
|
5761
|
-
}, diagnostics);
|
|
7096
|
+
}, diagnostics, processedFiles, cache, changedFiles);
|
|
5762
7097
|
if (options.tagAliases) story.passages = applyTagAliases(story.passages, options.tagAliases);
|
|
5763
7098
|
let format;
|
|
5764
|
-
if (outputMode === "html")
|
|
5765
|
-
|
|
5766
|
-
|
|
5767
|
-
|
|
7099
|
+
if (outputMode === "html") {
|
|
7100
|
+
format = await resolveStoryFormat(formatRequestFor(options.formatId, story.twine2.format, story.twine2.formatVersion, DEFAULT_FORMAT_ID), {
|
|
7101
|
+
...options,
|
|
7102
|
+
noRemote
|
|
7103
|
+
}, diagnostics);
|
|
7104
|
+
options.signal?.throwIfAborted();
|
|
7105
|
+
}
|
|
5768
7106
|
if (options.startPassage) story.twine2.start = options.startPassage;
|
|
5769
7107
|
const startName = story.twine2.start || DEFAULT_START_NAME;
|
|
5770
7108
|
if (testMode) story.twine2.options.set("debug", true);
|
|
@@ -5773,7 +7111,10 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5773
7111
|
switch (outputMode) {
|
|
5774
7112
|
case "twee3":
|
|
5775
7113
|
case "twee1":
|
|
5776
|
-
output = toTwee(story, outputMode, {
|
|
7114
|
+
output = toTwee(story, outputMode, {
|
|
7115
|
+
addStoryData: Boolean(options.startPassage) || testMode,
|
|
7116
|
+
diagnostics
|
|
7117
|
+
});
|
|
5777
7118
|
break;
|
|
5778
7119
|
case "twine2-archive":
|
|
5779
7120
|
output = toTwine2Archive(story, startName, { sourceInfo });
|
|
@@ -5791,13 +7132,20 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5791
7132
|
level: "error",
|
|
5792
7133
|
message: "Special passage \"StoryTitle\" not found."
|
|
5793
7134
|
});
|
|
5794
|
-
const modules = getFilenames(options.modules ?? [],
|
|
7135
|
+
const modules = getFilenames(options.modules ?? [], written);
|
|
5795
7136
|
diagnostics.push(...modules.diagnostics);
|
|
7137
|
+
const { headFile } = options;
|
|
7138
|
+
const headIsOutput = headFile !== void 0 && outputPaths(written).isFile(realPathOf(headFile));
|
|
7139
|
+
rejectOutputSources([...modules.outputSources, ...headIsOutput ? [headFile] : []], diagnostics);
|
|
5796
7140
|
const head = loadHeadContent(modules.filenames, options.headFile, diagnostics);
|
|
5797
7141
|
output = format.isTwine2 ? toTwine2HTML(story, format, startName, {
|
|
5798
7142
|
sourceInfo,
|
|
5799
|
-
head
|
|
5800
|
-
|
|
7143
|
+
head,
|
|
7144
|
+
diagnostics
|
|
7145
|
+
}) : toTwine1HTML(story, format, startName, {
|
|
7146
|
+
head,
|
|
7147
|
+
diagnostics
|
|
7148
|
+
});
|
|
5801
7149
|
break;
|
|
5802
7150
|
}
|
|
5803
7151
|
default: throw new TweeTsError(`Unhandled output mode: ${outputMode}`, diagnostics);
|
|
@@ -5806,6 +7154,7 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5806
7154
|
...getStoryStats(story, options.wordCountMethod),
|
|
5807
7155
|
files: [...processedFiles]
|
|
5808
7156
|
};
|
|
7157
|
+
options.signal?.throwIfAborted();
|
|
5809
7158
|
return {
|
|
5810
7159
|
output,
|
|
5811
7160
|
story,
|
|
@@ -5814,6 +7163,16 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5814
7163
|
stats
|
|
5815
7164
|
};
|
|
5816
7165
|
}
|
|
7166
|
+
/**
|
|
7167
|
+
* Throws for the first of `paths`, inputs named directly that are also an output:
|
|
7168
|
+
* writing the build would overwrite them. An output found while walking a source
|
|
7169
|
+
* folder is skipped instead (see getFilenames).
|
|
7170
|
+
*/
|
|
7171
|
+
function rejectOutputSources(paths, diagnostics) {
|
|
7172
|
+
const [first] = paths;
|
|
7173
|
+
if (first === void 0) return;
|
|
7174
|
+
throw new TweeTsError(`path ${first}: Output file cannot be an input source.`, [...diagnostics]);
|
|
7175
|
+
}
|
|
5817
7176
|
function ensureIFID(story, diagnostics) {
|
|
5818
7177
|
if (story.ifid !== "") return;
|
|
5819
7178
|
if (story.legacyIFID !== "") {
|
|
@@ -7383,7 +8742,7 @@ function storyInspect(story, options = {}) {
|
|
|
7383
8742
|
if (isStoryPassage(p)) storyPassages.push(p.name);
|
|
7384
8743
|
if (isInfoPassage(p)) infoPassages.push(p.name);
|
|
7385
8744
|
tagsByPassage.set(p.name, [...p.tags]);
|
|
7386
|
-
for (const tag of p.tags) {
|
|
8745
|
+
for (const tag of new Set(p.tags)) {
|
|
7387
8746
|
tagSet.add(tag);
|
|
7388
8747
|
let list = passagesByTag.get(tag);
|
|
7389
8748
|
if (!list) {
|
|
@@ -7516,46 +8875,91 @@ const VALID_OUTPUT_MODES = [
|
|
|
7516
8875
|
"json"
|
|
7517
8876
|
];
|
|
7518
8877
|
const VALID_WORD_COUNT_METHODS = ["tweego", "whitespace"];
|
|
7519
|
-
/**
|
|
7520
|
-
|
|
8878
|
+
/** One entry per {@link TweeTsConfig} key: the compiler rejects a missing or an extra key here. */
|
|
8879
|
+
const CONFIG_KEY_SET = {
|
|
8880
|
+
sources: true,
|
|
8881
|
+
exclude: true,
|
|
8882
|
+
output: true,
|
|
8883
|
+
outputMode: true,
|
|
8884
|
+
formatId: true,
|
|
8885
|
+
startPassage: true,
|
|
8886
|
+
formatPaths: true,
|
|
8887
|
+
formatIndices: true,
|
|
8888
|
+
formatUrls: true,
|
|
8889
|
+
useTweegoPath: true,
|
|
8890
|
+
modules: true,
|
|
8891
|
+
headFile: true,
|
|
8892
|
+
trim: true,
|
|
8893
|
+
twee2Compat: true,
|
|
8894
|
+
testMode: true,
|
|
8895
|
+
noRemote: true,
|
|
8896
|
+
formatFetchTimeout: true,
|
|
8897
|
+
tagAliases: true,
|
|
8898
|
+
sourceInfo: true,
|
|
8899
|
+
wordCountMethod: true
|
|
8900
|
+
};
|
|
8901
|
+
/** The keys a config file may hold besides `$schema`; the JSON schema's `properties` list the same keys. */
|
|
8902
|
+
const CONFIG_KEYS = Object.keys(CONFIG_KEY_SET);
|
|
8903
|
+
/** The key that only references the JSON schema, for editors. */
|
|
8904
|
+
const SCHEMA_KEY = "$schema";
|
|
8905
|
+
/** A key folded so that spellings differing only in letter case, `-` or `_` compare equal. */
|
|
8906
|
+
const foldKey = (key) => key.toLowerCase().replace(/[-_]/g, "");
|
|
8907
|
+
/**
|
|
8908
|
+
* Load a config file from the given directory (default: cwd). Returns null if not found.
|
|
8909
|
+
*
|
|
8910
|
+
* @param diagnostics Receives warnings that do not stop the config from loading: keys the config does not
|
|
8911
|
+
* define, and a file that is not valid UTF-8 (it is read as Windows-1252).
|
|
8912
|
+
* @throws When the file exists but cannot be read, is not valid JSON or fails {@link validateConfig}.
|
|
8913
|
+
*/
|
|
8914
|
+
function loadConfig(dir, diagnostics) {
|
|
7521
8915
|
const base = dir ?? process.cwd();
|
|
7522
8916
|
const configPath = join(base, CONFIG_FILENAME);
|
|
7523
8917
|
if (!existsSync(configPath)) return null;
|
|
7524
|
-
|
|
7525
|
-
try {
|
|
7526
|
-
raw = readFileSync(configPath, "utf-8");
|
|
7527
|
-
} catch {
|
|
7528
|
-
return null;
|
|
7529
|
-
}
|
|
7530
|
-
let data;
|
|
7531
|
-
try {
|
|
7532
|
-
data = JSON.parse(raw);
|
|
7533
|
-
} catch (e) {
|
|
7534
|
-
throw new Error(`Invalid JSON in ${configPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
7535
|
-
}
|
|
7536
|
-
const errors = validateConfig(data);
|
|
7537
|
-
if (errors.length > 0) throw new Error(`Invalid config in ${configPath}:\n ${errors.join("\n ")}`);
|
|
7538
|
-
return data;
|
|
8918
|
+
return loadConfigFile(configPath, diagnostics);
|
|
7539
8919
|
}
|
|
7540
|
-
/**
|
|
7541
|
-
|
|
8920
|
+
/**
|
|
8921
|
+
* Load a config from a specific file path.
|
|
8922
|
+
*
|
|
8923
|
+
* @param diagnostics Receives warnings that do not stop the config from loading: keys the config does not
|
|
8924
|
+
* define, and a file that is not valid UTF-8 (it is read as Windows-1252).
|
|
8925
|
+
* @throws When the file cannot be read, is not valid JSON or fails {@link validateConfig}.
|
|
8926
|
+
*/
|
|
8927
|
+
function loadConfigFile(filePath, diagnostics) {
|
|
8928
|
+
const readDiagnostics = [];
|
|
7542
8929
|
let raw;
|
|
7543
8930
|
try {
|
|
7544
|
-
raw =
|
|
8931
|
+
raw = readUTF8(filePath, readDiagnostics);
|
|
7545
8932
|
} catch (e) {
|
|
7546
8933
|
throw new Error(`Cannot read config file ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
7547
8934
|
}
|
|
8935
|
+
const config = parseConfig(raw, filePath, readDiagnostics);
|
|
8936
|
+
diagnostics?.push(...readDiagnostics);
|
|
8937
|
+
return config;
|
|
8938
|
+
}
|
|
8939
|
+
/**
|
|
8940
|
+
* Parse and validate the text of the config file at `path`. Unknown keys are added to `diagnostics` as
|
|
8941
|
+
* warnings, so a config with a stray key keeps loading.
|
|
8942
|
+
*/
|
|
8943
|
+
function parseConfig(raw, path, diagnostics) {
|
|
7548
8944
|
let data;
|
|
7549
8945
|
try {
|
|
7550
8946
|
data = JSON.parse(raw);
|
|
7551
8947
|
} catch (e) {
|
|
7552
|
-
throw new Error(`Invalid JSON in ${
|
|
8948
|
+
throw new Error(`Invalid JSON in ${path}: ${e instanceof Error ? e.message : String(e)}`);
|
|
7553
8949
|
}
|
|
7554
8950
|
const errors = validateConfig(data);
|
|
7555
|
-
if (errors.length > 0) throw new Error(`Invalid config in ${
|
|
8951
|
+
if (errors.length > 0) throw new Error(`Invalid config in ${path}:\n ${errors.join("\n ")}`);
|
|
8952
|
+
diagnostics.push(...unknownConfigKeyWarnings(data).map((message) => ({
|
|
8953
|
+
level: "warning",
|
|
8954
|
+
message: `${path}: ${message}`,
|
|
8955
|
+
file: path
|
|
8956
|
+
})));
|
|
7556
8957
|
return data;
|
|
7557
8958
|
}
|
|
7558
|
-
/**
|
|
8959
|
+
/**
|
|
8960
|
+
* Validate a config object. Returns an array of error strings (empty = valid).
|
|
8961
|
+
* Keys the config does not define are not errors; {@link unknownConfigKeyWarnings} reports them.
|
|
8962
|
+
*/
|
|
7559
8963
|
function validateConfig(data) {
|
|
7560
8964
|
const errors = [];
|
|
7561
8965
|
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
@@ -7588,6 +8992,10 @@ function validateConfig(data) {
|
|
|
7588
8992
|
if (!Array.isArray(obj[key])) errors.push(`"${key}" must be an array.`);
|
|
7589
8993
|
else if (!obj[key].every((v) => typeof v === "string")) errors.push(`"${key}" must be an array of strings.`);
|
|
7590
8994
|
}
|
|
8995
|
+
if ("formatFetchTimeout" in obj) {
|
|
8996
|
+
const timeout = obj["formatFetchTimeout"];
|
|
8997
|
+
if (typeof timeout !== "number" || !(timeout >= 0)) errors.push("\"formatFetchTimeout\" must be a number of milliseconds, 0 or more.");
|
|
8998
|
+
}
|
|
7591
8999
|
if ("tagAliases" in obj) {
|
|
7592
9000
|
if (typeof obj["tagAliases"] !== "object" || obj["tagAliases"] === null || Array.isArray(obj["tagAliases"])) errors.push("\"tagAliases\" must be an object.");
|
|
7593
9001
|
else {
|
|
@@ -7603,6 +9011,20 @@ function validateConfig(data) {
|
|
|
7603
9011
|
}
|
|
7604
9012
|
return errors;
|
|
7605
9013
|
}
|
|
9014
|
+
/**
|
|
9015
|
+
* Warnings for the keys of a config object that the config does not define (the JSON schema's
|
|
9016
|
+
* `additionalProperties: false`), other than `$schema`. twee-ts ignores such a key, so a misspelt one would
|
|
9017
|
+
* otherwise leave its option at the default without a word. A key that differs from a defined one only in
|
|
9018
|
+
* letter case, `-` or `_` (`formatID`, `output-mode`) gets a suggestion. Returns `[]` for anything that is not
|
|
9019
|
+
* an object, which {@link validateConfig} reports.
|
|
9020
|
+
*/
|
|
9021
|
+
function unknownConfigKeyWarnings(data) {
|
|
9022
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) return [];
|
|
9023
|
+
return Object.keys(data).filter((key) => key !== SCHEMA_KEY && !Object.hasOwn(CONFIG_KEY_SET, key)).map((key) => {
|
|
9024
|
+
const suggestion = CONFIG_KEYS.find((known) => foldKey(known) === foldKey(key));
|
|
9025
|
+
return `Unknown config key "${key}"${suggestion === void 0 ? "" : ` (did you mean "${suggestion}"?)`}; it is ignored.`;
|
|
9026
|
+
});
|
|
9027
|
+
}
|
|
7606
9028
|
/** Return a default config JSON string for --init scaffolding. */
|
|
7607
9029
|
function scaffoldConfig() {
|
|
7608
9030
|
return JSON.stringify({
|
|
@@ -7710,10 +9132,6 @@ async function main() {
|
|
|
7710
9132
|
printUsage();
|
|
7711
9133
|
return;
|
|
7712
9134
|
}
|
|
7713
|
-
if (values["list-formats"]) {
|
|
7714
|
-
listFormats();
|
|
7715
|
-
return;
|
|
7716
|
-
}
|
|
7717
9135
|
if (values.init) {
|
|
7718
9136
|
runInit();
|
|
7719
9137
|
return;
|
|
@@ -7724,8 +9142,14 @@ async function main() {
|
|
|
7724
9142
|
}
|
|
7725
9143
|
let config = null;
|
|
7726
9144
|
if (!values["no-config"]) {
|
|
7727
|
-
|
|
7728
|
-
|
|
9145
|
+
const configDiagnostics = [];
|
|
9146
|
+
if (values.config) config = loadConfigFile(values.config, configDiagnostics);
|
|
9147
|
+
else config = loadConfig(void 0, configDiagnostics);
|
|
9148
|
+
logDiagnostics(configDiagnostics);
|
|
9149
|
+
}
|
|
9150
|
+
if (values["list-formats"]) {
|
|
9151
|
+
listFormats(config?.formatPaths ?? [], config?.useTweegoPath ?? true);
|
|
9152
|
+
return;
|
|
7729
9153
|
}
|
|
7730
9154
|
const sources = positionals.length > 0 ? positionals : config?.sources;
|
|
7731
9155
|
if (!sources || sources.length === 0) {
|
|
@@ -7778,6 +9202,7 @@ async function main() {
|
|
|
7778
9202
|
formatIndices: values["format-index"] ?? config?.formatIndices,
|
|
7779
9203
|
formatUrls: values["format-url"] ?? config?.formatUrls,
|
|
7780
9204
|
noRemote: values["no-remote"] ?? config?.noRemote ?? false,
|
|
9205
|
+
formatFetchTimeout: config?.formatFetchTimeout,
|
|
7781
9206
|
tagAliases,
|
|
7782
9207
|
sourceInfo: values["source-info"] ?? config?.sourceInfo ?? false,
|
|
7783
9208
|
wordCountMethod
|
|
@@ -7802,6 +9227,7 @@ async function main() {
|
|
|
7802
9227
|
formatIndices: values["format-index"] ?? config?.formatIndices,
|
|
7803
9228
|
formatUrls: values["format-url"] ?? config?.formatUrls,
|
|
7804
9229
|
noRemote: values["no-remote"] ?? config?.noRemote ?? false,
|
|
9230
|
+
formatFetchTimeout: config?.formatFetchTimeout,
|
|
7805
9231
|
tagAliases,
|
|
7806
9232
|
sourceInfo: values["source-info"] ?? config?.sourceInfo ?? false,
|
|
7807
9233
|
wordCountMethod
|
|
@@ -7827,7 +9253,13 @@ async function main() {
|
|
|
7827
9253
|
logBuild(result, log);
|
|
7828
9254
|
},
|
|
7829
9255
|
onError(error) {
|
|
7830
|
-
|
|
9256
|
+
if (error instanceof WatchPathError) {
|
|
9257
|
+
console.error(`error: ${error.message}`);
|
|
9258
|
+
process.exitCode = 1;
|
|
9259
|
+
} else {
|
|
9260
|
+
logErrorDiagnostics(error);
|
|
9261
|
+
console.error(`Build error: ${error.message}`);
|
|
9262
|
+
}
|
|
7831
9263
|
}
|
|
7832
9264
|
}, (result) => countErrors(result.diagnostics) === 0);
|
|
7833
9265
|
} else {
|
|
@@ -7838,7 +9270,7 @@ async function main() {
|
|
|
7838
9270
|
console.error(`Compilation failed with ${pluralize(errors, "error")}; output not written.`);
|
|
7839
9271
|
process.exitCode = 1;
|
|
7840
9272
|
} else if (outPath === void 0) process.stdout.write(result.output);
|
|
7841
|
-
else
|
|
9273
|
+
else writeFileAtomic(outPath, result.output);
|
|
7842
9274
|
logBuild(result, log);
|
|
7843
9275
|
}
|
|
7844
9276
|
}
|
|
@@ -7852,24 +9284,47 @@ function countErrors(diagnostics) {
|
|
|
7852
9284
|
function pluralize(count, noun) {
|
|
7853
9285
|
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
7854
9286
|
}
|
|
7855
|
-
|
|
7856
|
-
|
|
9287
|
+
/** Order version strings from highest to lowest SemVer precedence (unparseable ones last). */
|
|
9288
|
+
function byVersionDescending(a, b) {
|
|
9289
|
+
const left = parseVersion(a);
|
|
9290
|
+
const right = parseVersion(b);
|
|
9291
|
+
if (!left || !right) return Number(!left) - Number(!right);
|
|
9292
|
+
return compareVersions(right, left);
|
|
9293
|
+
}
|
|
9294
|
+
/** Print the formats --format can select: local folders (pruned by SemVer) and cached downloads, by ID. */
|
|
9295
|
+
function listFormats(formatPaths, useTweegoPath) {
|
|
9296
|
+
const diagnostics = [];
|
|
9297
|
+
const formats = pruneFormats(discoverAllFormats(getFormatSearchDirs(formatPaths, useTweegoPath), diagnostics));
|
|
9298
|
+
logDiagnostics(diagnostics);
|
|
7857
9299
|
console.log("Local story formats:");
|
|
7858
9300
|
if (formats.size === 0) console.log(" (none)");
|
|
7859
9301
|
else for (const [id, f] of formats) {
|
|
7860
9302
|
const type = f.isTwine2 ? "Twine 2" : "Twine 1";
|
|
7861
9303
|
console.log(` ${id}: ${f.name || id} ${f.version} (${type})`);
|
|
7862
9304
|
}
|
|
7863
|
-
const
|
|
7864
|
-
|
|
9305
|
+
const cachedById = /* @__PURE__ */ new Map();
|
|
9306
|
+
for (const f of discoverCachedFormats().values()) {
|
|
9307
|
+
const id = makeFormatId(f.name, f.version);
|
|
9308
|
+
cachedById.set(id, [...cachedById.get(id) ?? [], f]);
|
|
9309
|
+
}
|
|
9310
|
+
if (cachedById.size > 0) {
|
|
7865
9311
|
console.log("\nCached remote formats:");
|
|
7866
|
-
for (const [id,
|
|
9312
|
+
for (const [id, versions] of [...cachedById].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
|
|
9313
|
+
const [newest, ...older] = [...versions].sort((a, b) => byVersionDescending(a.version, b.version));
|
|
9314
|
+
if (!newest) continue;
|
|
9315
|
+
const also = older.length > 0 ? ` (also cached: ${older.map((f) => f.version).join(", ")})` : "";
|
|
9316
|
+
console.log(` ${id}: ${newest.name} ${newest.version}${also}`);
|
|
9317
|
+
}
|
|
7867
9318
|
}
|
|
7868
9319
|
}
|
|
7869
9320
|
function logDiagnostics(diagnostics) {
|
|
7870
9321
|
for (const d of diagnostics) if (d.level === "error") console.error(`error: ${d.message}`);
|
|
7871
9322
|
else console.warn(`warning: ${d.message}`);
|
|
7872
9323
|
}
|
|
9324
|
+
/** Prints what a fatal TweeTsError collected (such as why no story format was found) before its message. */
|
|
9325
|
+
function logErrorDiagnostics(error) {
|
|
9326
|
+
if (error instanceof TweeTsError) logDiagnostics(error.diagnostics);
|
|
9327
|
+
}
|
|
7873
9328
|
function logStats(result) {
|
|
7874
9329
|
const s = result.stats;
|
|
7875
9330
|
console.log(`\nStatistics:`);
|
|
@@ -8021,6 +9476,7 @@ Subcommands:
|
|
|
8021
9476
|
cache path Print cache directory path`);
|
|
8022
9477
|
}
|
|
8023
9478
|
main().catch((err) => {
|
|
9479
|
+
logErrorDiagnostics(err);
|
|
8024
9480
|
console.error(err instanceof Error ? err.message : String(err));
|
|
8025
9481
|
process.exit(1);
|
|
8026
9482
|
});
|