@rohal12/twee-ts 1.17.0 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/twee-ts.js +2174 -648
- package/dist/bin/twee-ts.js.map +1 -1
- package/dist/index.cjs +2176 -637
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +156 -55
- package/dist/index.d.ts +156 -55
- package/dist/index.js +2177 -639
- package/dist/index.js.map +1 -1
- package/dist/{diagnostics-DZNS7W41.js → paths-DgO9oo6G.js} +1867 -565
- package/dist/paths-DgO9oo6G.js.map +1 -0
- package/dist/plugins/rollup.d.ts +11 -2
- package/dist/plugins/rollup.js +60 -6
- package/dist/plugins/rollup.js.map +1 -1
- package/dist/plugins/vite.d.ts +1 -1
- package/dist/plugins/vite.js +104 -57
- 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/diagnostics-DZNS7W41.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
|
|
@@ -15,6 +15,19 @@ import { fileURLToPath } from "node:url";
|
|
|
15
15
|
function generateIFID() {
|
|
16
16
|
return randomUUID().toUpperCase();
|
|
17
17
|
}
|
|
18
|
+
/** Length of the `UUID://...//` wrapper around a 36-character UUID. */
|
|
19
|
+
const WRAPPED_LENGTH = 45;
|
|
20
|
+
/**
|
|
21
|
+
* Bring an IFID into the form the story model stores and every output writes: the uppercase
|
|
22
|
+
* bare UUID. A valid `UUID://...//` wrapped IFID loses its wrapper; outputs that need the wrapper
|
|
23
|
+
* (the Treaty of Babel comment) add exactly one. Twine 2 IFIDs use only capital letters, so the
|
|
24
|
+
* value is uppercased, as Tweego does. An invalid value is only uppercased, so that validation
|
|
25
|
+
* reports it as written.
|
|
26
|
+
*/
|
|
27
|
+
function normalizeIFID(value) {
|
|
28
|
+
const upper = value.toUpperCase();
|
|
29
|
+
return upper.length === WRAPPED_LENGTH && validateIFID(upper) === null ? upper.slice(7, 43) : upper;
|
|
30
|
+
}
|
|
18
31
|
/**
|
|
19
32
|
* Validate an IFID string.
|
|
20
33
|
* Accepts both bare UUIDs and UUID://...// wrapped format.
|
|
@@ -93,18 +106,44 @@ function tweeEscape(s) {
|
|
|
93
106
|
if (s.length === 0) return s;
|
|
94
107
|
return s.replace(/[\\[\]{}]/g, (ch) => "\\" + ch);
|
|
95
108
|
}
|
|
96
|
-
|
|
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
|
+
*/
|
|
97
127
|
function jsStringEscape(s) {
|
|
98
128
|
if (s.length === 0) return s;
|
|
99
|
-
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)} `);
|
|
100
139
|
}
|
|
101
140
|
/** Sanitize a string for safe inclusion in a CSS or JS block comment. */
|
|
102
141
|
function commentSanitize(s) {
|
|
103
142
|
return s.replace(/\*\//g, "* /");
|
|
104
143
|
}
|
|
105
|
-
/** 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. */
|
|
106
145
|
function htmlCommentSanitize(s) {
|
|
107
|
-
return s.replace(
|
|
146
|
+
return s.replace(/--(!?)>/g, "--$1 >");
|
|
108
147
|
}
|
|
109
148
|
/**
|
|
110
149
|
* Escape JavaScript for the content of an HTML `<script>` element, so that the HTML parser reads all of it as
|
|
@@ -284,7 +323,19 @@ function passageToPassagedata(p, pid, options) {
|
|
|
284
323
|
if (options?.sourceInfo && p.source) attrs += ` data-source-file=${quote(attrEscape(p.source.file))} data-source-line="${p.source.line}"`;
|
|
285
324
|
return `${attrs}>${htmlEscape(p.text)}</tw-passagedata>`;
|
|
286
325
|
}
|
|
287
|
-
/**
|
|
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
|
+
*/
|
|
288
339
|
function passageToTiddler(p, pid, obfuscateRot13 = false) {
|
|
289
340
|
let position;
|
|
290
341
|
if (hasMetadataPosition(p)) position = p.metadata?.position ?? "";
|
|
@@ -296,9 +347,10 @@ function passageToTiddler(p, pid, obfuscateRot13 = false) {
|
|
|
296
347
|
position = `${xp * 140 - 130},${yp * 140 - 130}`;
|
|
297
348
|
}
|
|
298
349
|
const created = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 12);
|
|
299
|
-
const
|
|
300
|
-
const
|
|
301
|
-
|
|
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>`;
|
|
302
354
|
}
|
|
303
355
|
/**
|
|
304
356
|
* Count words in a passage.
|
|
@@ -351,19 +403,128 @@ function quote(s) {
|
|
|
351
403
|
}
|
|
352
404
|
//#endregion
|
|
353
405
|
//#region src/story.ts
|
|
354
|
-
/** Module-level index: maps passage names to their array indices for O(1) lookups. */
|
|
355
406
|
const passageIndex = /* @__PURE__ */ new WeakMap();
|
|
356
|
-
function
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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;
|
|
363
485
|
}
|
|
364
|
-
|
|
486
|
+
default: throw new Error(`unhandled generated name: ${JSON.stringify(origin)}`);
|
|
365
487
|
}
|
|
366
|
-
|
|
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
|
+
};
|
|
367
528
|
}
|
|
368
529
|
function createStory() {
|
|
369
530
|
const story = {
|
|
@@ -372,52 +533,37 @@ function createStory() {
|
|
|
372
533
|
passages: [],
|
|
373
534
|
legacyIFID: "",
|
|
374
535
|
twine1: { settings: /* @__PURE__ */ new Map() },
|
|
375
|
-
twine2:
|
|
376
|
-
format: "",
|
|
377
|
-
formatVersion: "",
|
|
378
|
-
options: /* @__PURE__ */ new Map(),
|
|
379
|
-
start: "",
|
|
380
|
-
tags: "",
|
|
381
|
-
tagColors: /* @__PURE__ */ new Map(),
|
|
382
|
-
zoom: 1
|
|
383
|
-
}
|
|
536
|
+
twine2: defaultTwine2Metadata()
|
|
384
537
|
};
|
|
385
|
-
|
|
538
|
+
buildIndex(story);
|
|
386
539
|
return story;
|
|
387
540
|
}
|
|
388
541
|
function storyHas(story, name) {
|
|
389
|
-
return
|
|
542
|
+
return position(story, name) !== -1;
|
|
390
543
|
}
|
|
391
544
|
function storyAppend(story, p, diagnostics) {
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
index.set(p.name, story.passages.length);
|
|
396
|
-
story.passages.push(p);
|
|
397
|
-
} else {
|
|
545
|
+
const i = position(story, p.name);
|
|
546
|
+
if (i === -1) push(story, p);
|
|
547
|
+
else {
|
|
398
548
|
diagnostics.push({
|
|
399
549
|
level: "warning",
|
|
400
550
|
message: `Replacing existing passage "${p.name}" with duplicate.`
|
|
401
551
|
});
|
|
402
|
-
story
|
|
552
|
+
replaceAt(story, i, p);
|
|
403
553
|
}
|
|
404
554
|
}
|
|
405
555
|
function storyPrepend(story, p, diagnostics) {
|
|
406
|
-
|
|
407
|
-
const i =
|
|
556
|
+
moveGeneratedAside(story, p.name, diagnostics);
|
|
557
|
+
const i = position(story, p.name);
|
|
408
558
|
if (i === -1) {
|
|
409
559
|
story.passages.unshift(p);
|
|
410
|
-
|
|
411
|
-
for (let j = 0; j < story.passages.length; j++) {
|
|
412
|
-
const passage = story.passages[j];
|
|
413
|
-
if (passage) index.set(passage.name, j);
|
|
414
|
-
}
|
|
560
|
+
buildIndex(story);
|
|
415
561
|
} else {
|
|
416
562
|
diagnostics.push({
|
|
417
563
|
level: "warning",
|
|
418
564
|
message: `Replacing existing passage "${p.name}" with duplicate.`
|
|
419
565
|
});
|
|
420
|
-
story
|
|
566
|
+
replaceAt(story, i, p);
|
|
421
567
|
}
|
|
422
568
|
}
|
|
423
569
|
function marshalStoryData(story) {
|
|
@@ -443,18 +589,20 @@ function unmarshalStoryData(story, json) {
|
|
|
443
589
|
}
|
|
444
590
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return "Cannot unmarshal \"StoryData\"; expected a JSON object";
|
|
445
591
|
const data = raw;
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
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"];
|
|
449
596
|
if (Array.isArray(data.options)) {
|
|
450
|
-
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);
|
|
451
598
|
}
|
|
452
|
-
if (typeof data.start === "string"
|
|
453
|
-
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;
|
|
454
601
|
if (typeof data["tag-colors"] === "object" && data["tag-colors"] !== null && !Array.isArray(data["tag-colors"])) {
|
|
455
|
-
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);
|
|
456
603
|
}
|
|
457
|
-
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;
|
|
458
606
|
return null;
|
|
459
607
|
}
|
|
460
608
|
function unmarshalStorySettings(story, text, diagnostics) {
|
|
@@ -474,7 +622,7 @@ function unmarshalStorySettings(story, text, diagnostics) {
|
|
|
474
622
|
const val = line.slice(colonIdx + 1).trim().toLowerCase();
|
|
475
623
|
switch (key) {
|
|
476
624
|
case "ifid":
|
|
477
|
-
if (validateIFID(val) === null) story.legacyIFID = val
|
|
625
|
+
if (validateIFID(val) === null) story.legacyIFID = normalizeIFID(val);
|
|
478
626
|
obsolete.push("\"ifid\"");
|
|
479
627
|
continue;
|
|
480
628
|
case "zoom":
|
|
@@ -495,8 +643,20 @@ function unmarshalStorySettings(story, text, diagnostics) {
|
|
|
495
643
|
/**
|
|
496
644
|
* Process a passage and add it to the story, handling special passages.
|
|
497
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.
|
|
498
652
|
*/
|
|
499
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);
|
|
500
660
|
let processed = p;
|
|
501
661
|
switch (p.name) {
|
|
502
662
|
case "StoryIncludes":
|
|
@@ -671,131 +831,579 @@ function isExcluded(filename, exclude) {
|
|
|
671
831
|
const rel = relative(process.cwd(), resolve(filename)) || filename;
|
|
672
832
|
return exclude.some((pattern) => matchesGlob(rel, pattern.replace(/^\.\//, "")));
|
|
673
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
|
+
}
|
|
674
901
|
/**
|
|
675
902
|
* Recursively walk directories, collecting regular file paths.
|
|
676
|
-
*
|
|
677
|
-
*
|
|
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
|
+
*
|
|
678
914
|
* Like Tweego, a path that cannot be read is reported as a warning and skipped.
|
|
679
915
|
*/
|
|
680
|
-
function getFilenames(pathnames,
|
|
916
|
+
function getFilenames(pathnames, outputs, exclude = []) {
|
|
681
917
|
const filenames = [];
|
|
682
918
|
const diagnostics = [];
|
|
683
|
-
const
|
|
919
|
+
const outputSources = [];
|
|
920
|
+
const output = outputPaths(toBuildOutputs(outputs));
|
|
684
921
|
function warn(pathname, e) {
|
|
685
922
|
diagnostics.push({
|
|
686
923
|
level: "warning",
|
|
687
924
|
message: `path ${pathname}: ${e instanceof Error ? e.message : String(e)}`
|
|
688
925
|
});
|
|
689
926
|
}
|
|
690
|
-
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) {
|
|
691
957
|
let stat;
|
|
692
958
|
try {
|
|
693
959
|
stat = statSync(pathname);
|
|
694
960
|
} catch (e) {
|
|
695
961
|
warn(pathname, e);
|
|
696
|
-
|
|
962
|
+
continue;
|
|
697
963
|
}
|
|
964
|
+
const real = realPathOf(pathname);
|
|
698
965
|
if (stat.isFile()) {
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
if (isExcluded(rel || abs, exclude)) return;
|
|
703
|
-
filenames.push(rel || abs);
|
|
704
|
-
} else if (stat.isDirectory()) {
|
|
705
|
-
let entries;
|
|
706
|
-
try {
|
|
707
|
-
entries = readdirSync(pathname);
|
|
708
|
-
} catch (e) {
|
|
709
|
-
warn(pathname, e);
|
|
710
|
-
return;
|
|
711
|
-
}
|
|
712
|
-
for (const entry of entries) walk(join(pathname, entry));
|
|
713
|
-
}
|
|
966
|
+
if (output.isFile(real)) outputSources.push(pathname);
|
|
967
|
+
else addFile(pathname);
|
|
968
|
+
} else if (stat.isDirectory()) walkDir(pathname, real);
|
|
714
969
|
}
|
|
715
|
-
for (const pathname of pathnames) walk(pathname);
|
|
716
970
|
return {
|
|
717
971
|
filenames,
|
|
718
|
-
diagnostics
|
|
972
|
+
diagnostics,
|
|
973
|
+
outputSources
|
|
719
974
|
};
|
|
720
975
|
}
|
|
721
|
-
/** Whether the absolute path `file` is the absolute path `dir` or inside it. */
|
|
722
|
-
function isInsideDir(file, dir) {
|
|
723
|
-
return file === dir || file.startsWith(dir.endsWith(sep) ? dir : dir + sep);
|
|
724
|
-
}
|
|
725
976
|
/**
|
|
726
|
-
*
|
|
727
|
-
*
|
|
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.
|
|
728
979
|
*/
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
isFile = statSync(pathname).isFile();
|
|
736
|
-
} catch {}
|
|
737
|
-
if (isFile) files.add(resolve(pathname));
|
|
738
|
-
else dirs.push(pathname);
|
|
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";
|
|
739
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" };
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
function rootState(abs) {
|
|
740
1000
|
return {
|
|
741
|
-
|
|
742
|
-
|
|
1001
|
+
anchor: nearestFolderAbove(abs),
|
|
1002
|
+
self: pathKind(abs)
|
|
743
1003
|
};
|
|
744
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
|
+
}
|
|
745
1019
|
/**
|
|
746
1020
|
* Watch paths for changes, calling the build callback on known file type changes.
|
|
747
1021
|
* A folder is watched recursively; a file is watched on its own and counts
|
|
748
1022
|
* whatever its type (a head file, say). A change to a file `ignore` returns true
|
|
749
1023
|
* for (given the path relative to the working directory) schedules no build.
|
|
750
1024
|
* Uses debouncing to avoid rapid rebuilds.
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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));
|
|
755
1035
|
let buildTimer = null;
|
|
756
1036
|
const BUILD_DEBOUNCE = 500;
|
|
757
1037
|
const pendingFiles = /* @__PURE__ */ new Set();
|
|
1038
|
+
let pendingFullBuild = false;
|
|
1039
|
+
let closed = false;
|
|
758
1040
|
function scheduleBuild(changedFile) {
|
|
759
|
-
if (changedFile)
|
|
1041
|
+
if (changedFile === void 0) pendingFullBuild = true;
|
|
1042
|
+
else pendingFiles.add(changedFile);
|
|
760
1043
|
if (buildTimer) clearTimeout(buildTimer);
|
|
761
1044
|
buildTimer = setTimeout(() => {
|
|
762
1045
|
buildTimer = null;
|
|
763
|
-
const files = pendingFiles.size
|
|
1046
|
+
const files = pendingFullBuild || pendingFiles.size === 0 ? void 0 : new Set(pendingFiles);
|
|
764
1047
|
pendingFiles.clear();
|
|
1048
|
+
pendingFullBuild = false;
|
|
765
1049
|
callback(files);
|
|
766
1050
|
}, BUILD_DEBOUNCE);
|
|
767
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");
|
|
768
1064
|
function fileChanged(abs, named) {
|
|
769
|
-
if (abs === absOutFile) return;
|
|
770
1065
|
if (!named && !isKnownFileType(abs)) return;
|
|
1066
|
+
if (output.isFile(realPathOf(abs))) return;
|
|
771
1067
|
const rel = relative(process.cwd(), abs);
|
|
772
1068
|
if (!ignore(rel || abs)) scheduleBuild(rel || abs);
|
|
773
1069
|
}
|
|
774
|
-
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) {
|
|
775
1077
|
try {
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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;
|
|
780
1112
|
}
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
const
|
|
790
|
-
|
|
791
|
-
|
|
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);
|
|
792
1134
|
callback();
|
|
793
1135
|
return { close() {
|
|
1136
|
+
closed = true;
|
|
794
1137
|
if (buildTimer) clearTimeout(buildTimer);
|
|
795
|
-
for (const w of watchers) w.close();
|
|
1138
|
+
for (const root of roots) for (const w of root.watchers) w.close();
|
|
796
1139
|
} };
|
|
797
1140
|
}
|
|
798
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
|
|
799
1407
|
//#region src/source-text.ts
|
|
800
1408
|
/**
|
|
801
1409
|
* Source text normalization shared by every input path (files, in-memory sources, `parseTwee`).
|
|
@@ -807,14 +1415,119 @@ function watchFilesystem(pathnames, outFilename, callback, ignore = () => false)
|
|
|
807
1415
|
function normalizeSourceText(text) {
|
|
808
1416
|
return (text.charCodeAt(0) === 65279 ? text.slice(1) : text).replace(/\r\n?/g, "\n");
|
|
809
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
|
+
}
|
|
810
1429
|
//#endregion
|
|
811
1430
|
//#region src/util.ts
|
|
812
1431
|
/**
|
|
813
1432
|
* Shared file I/O utilities.
|
|
814
1433
|
*/
|
|
815
|
-
/**
|
|
816
|
-
|
|
817
|
-
|
|
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);
|
|
818
1531
|
}
|
|
819
1532
|
/** Read a file as base64. */
|
|
820
1533
|
function readBase64(filename) {
|
|
@@ -830,20 +1543,11 @@ function baseNameWithoutExt(filename) {
|
|
|
830
1543
|
/**
|
|
831
1544
|
* Story format discovery, loading, and SemVer matching.
|
|
832
1545
|
* Ported from formats.go + config.go.
|
|
1546
|
+
*
|
|
1547
|
+
* Every lookup (local folders here, the download cache and remote indices in remote-formats.ts)
|
|
1548
|
+
* goes through the same version parsing ({@link parseVersion}) and the same matching rules
|
|
1549
|
+
* ({@link selectFormatCandidate}).
|
|
833
1550
|
*/
|
|
834
|
-
/**
|
|
835
|
-
* Attempt to fix non-strict JSON (trailing commas, single quotes, unquoted keys).
|
|
836
|
-
*/
|
|
837
|
-
function relaxedJSONParse(json) {
|
|
838
|
-
try {
|
|
839
|
-
return JSON.parse(json);
|
|
840
|
-
} catch {}
|
|
841
|
-
let fixed = json;
|
|
842
|
-
fixed = fixed.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, "\"$1\"");
|
|
843
|
-
fixed = fixed.replace(/(?<=^|[{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:/gm, "\"$1\":");
|
|
844
|
-
fixed = fixed.replace(/,\s*([}\]])/g, "$1");
|
|
845
|
-
return JSON.parse(fixed);
|
|
846
|
-
}
|
|
847
1551
|
/** A `"setup": function` property, which Harlowe appends to its otherwise-JSON format object. */
|
|
848
1552
|
const SETUP_FUNCTION_PROPERTY = /,\s*"setup"\s*:\s*function\b/g;
|
|
849
1553
|
/** Drop a trailing `"setup": function(){…}` property from a format object, if it has one. */
|
|
@@ -853,151 +1557,239 @@ function stripSetupFunction(chunk) {
|
|
|
853
1557
|
return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
|
|
854
1558
|
}
|
|
855
1559
|
/**
|
|
856
|
-
* Parse the
|
|
857
|
-
*
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
1560
|
+
* Parse the format object at `start`–`end` of `text`: strict JSON first (fast), then the
|
|
1561
|
+
* JavaScript literal subset formats use. Error positions point into `text`.
|
|
1562
|
+
*/
|
|
1563
|
+
function parseFormatObject(text, start, end) {
|
|
1564
|
+
try {
|
|
1565
|
+
return {
|
|
1566
|
+
ok: true,
|
|
1567
|
+
value: JSON.parse(text.slice(start, end))
|
|
1568
|
+
};
|
|
1569
|
+
} catch {}
|
|
1570
|
+
try {
|
|
1571
|
+
return {
|
|
1572
|
+
ok: true,
|
|
1573
|
+
value: parseRelaxedJSON(text, start, end)
|
|
1574
|
+
};
|
|
1575
|
+
} catch (e) {
|
|
1576
|
+
return {
|
|
1577
|
+
ok: false,
|
|
1578
|
+
error: e instanceof Error ? e.message : String(e)
|
|
1579
|
+
};
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
/** Check a parsed format object's fields. Per spec, `name` is optional; `version` and `source` are required. */
|
|
1583
|
+
function toFormatJSON(raw) {
|
|
1584
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
|
|
1585
|
+
ok: false,
|
|
1586
|
+
reason: "Story format JSON chunk is not an object."
|
|
1587
|
+
};
|
|
1588
|
+
const obj = raw;
|
|
1589
|
+
if (typeof obj.version !== "string") return {
|
|
1590
|
+
ok: false,
|
|
1591
|
+
reason: "Story format has no \"version\" string."
|
|
1592
|
+
};
|
|
1593
|
+
if (typeof obj.source !== "string") return {
|
|
1594
|
+
ok: false,
|
|
1595
|
+
reason: "Story format has no \"source\" string."
|
|
1596
|
+
};
|
|
1597
|
+
if (!parseVersion(obj.version)) return {
|
|
1598
|
+
ok: false,
|
|
1599
|
+
reason: `Story format version ${JSON.stringify(obj.version)} is not a SemVer version.`
|
|
1600
|
+
};
|
|
1601
|
+
const data = {
|
|
1602
|
+
name: typeof obj.name === "string" ? obj.name : "Untitled Story Format",
|
|
1603
|
+
version: obj.version,
|
|
1604
|
+
source: obj.source,
|
|
1605
|
+
proofing: obj.proofing === true
|
|
1606
|
+
};
|
|
1607
|
+
if (typeof obj.author === "string") data.author = obj.author;
|
|
1608
|
+
if (typeof obj.description === "string") data.description = obj.description;
|
|
1609
|
+
if (typeof obj.image === "string") data.image = obj.image;
|
|
1610
|
+
if (typeof obj.url === "string") data.url = obj.url;
|
|
1611
|
+
if (typeof obj.license === "string") data.license = obj.license;
|
|
1612
|
+
return {
|
|
1613
|
+
ok: true,
|
|
1614
|
+
data
|
|
1615
|
+
};
|
|
1616
|
+
}
|
|
1617
|
+
/**
|
|
1618
|
+
* Read the metadata of a Twine 2 format.js, or say why it cannot be used.
|
|
861
1619
|
*
|
|
862
|
-
*
|
|
1620
|
+
* The object passed to `window.storyFormat()` may be strict JSON or a JavaScript object literal
|
|
1621
|
+
* (single quotes, unquoted keys, trailing commas, comments). String values come back exactly as
|
|
1622
|
+
* JavaScript evaluation gives them. Harlowe's function-valued `setup` property is dropped; the
|
|
1623
|
+
* workaround keys on the property itself, so the same bytes parse whether they come from a
|
|
1624
|
+
* `harlowe-3` directory or a direct download.
|
|
863
1625
|
*/
|
|
864
|
-
function
|
|
1626
|
+
function decodeFormatJSON(source) {
|
|
865
1627
|
const first = source.indexOf("{");
|
|
866
1628
|
const last = source.lastIndexOf("}");
|
|
867
|
-
if (first === -1 || last
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
const raw = relaxedJSONParse(json);
|
|
871
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
|
872
|
-
const obj = raw;
|
|
873
|
-
if (typeof obj.version !== "string" || typeof obj.source !== "string") return null;
|
|
874
|
-
if (!parseSemver(obj.version)) return null;
|
|
875
|
-
const result = {
|
|
876
|
-
name: typeof obj.name === "string" ? obj.name : "Untitled Story Format",
|
|
877
|
-
version: obj.version,
|
|
878
|
-
source: obj.source,
|
|
879
|
-
proofing: obj.proofing === true
|
|
880
|
-
};
|
|
881
|
-
if (typeof obj.author === "string") result.author = obj.author;
|
|
882
|
-
if (typeof obj.description === "string") result.description = obj.description;
|
|
883
|
-
if (typeof obj.image === "string") result.image = obj.image;
|
|
884
|
-
if (typeof obj.url === "string") result.url = obj.url;
|
|
885
|
-
if (typeof obj.license === "string") result.license = obj.license;
|
|
886
|
-
return result;
|
|
1629
|
+
if (first === -1 || last < first) return {
|
|
1630
|
+
ok: false,
|
|
1631
|
+
reason: "Could not find Twine 2 style story format JSON chunk."
|
|
887
1632
|
};
|
|
1633
|
+
const parsed = parseFormatObject(source, first, last + 1);
|
|
1634
|
+
if (parsed.ok) return toFormatJSON(parsed.value);
|
|
1635
|
+
const stripped = stripSetupFunction(source.slice(first, last + 1));
|
|
1636
|
+
const retried = stripped === void 0 ? void 0 : parseFormatObject(stripped, 0, stripped.length);
|
|
1637
|
+
if (retried?.ok) return toFormatJSON(retried.value);
|
|
1638
|
+
return {
|
|
1639
|
+
ok: false,
|
|
1640
|
+
reason: `Could not decode story format JSON chunk: ${parsed.error}`
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
/**
|
|
1644
|
+
* Parse the Twine 2 format.js JSON chunk; null when it cannot be used ({@link decodeFormatJSON}
|
|
1645
|
+
* gives the reason).
|
|
1646
|
+
*
|
|
1647
|
+
* @param _formatId No longer used; kept so existing callers keep compiling.
|
|
1648
|
+
*/
|
|
1649
|
+
function parseFormatJSON(source, _formatId) {
|
|
1650
|
+
const result = decodeFormatJSON(source);
|
|
1651
|
+
return result.ok ? result.data : null;
|
|
1652
|
+
}
|
|
1653
|
+
/** A directory's entries in a stable order, or none when it cannot be read. */
|
|
1654
|
+
function listDirectory(dir) {
|
|
888
1655
|
try {
|
|
889
|
-
return
|
|
1656
|
+
return readdirSync(dir).sort();
|
|
890
1657
|
} catch {
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
1658
|
+
return [];
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
function isDirectory(path) {
|
|
1662
|
+
try {
|
|
1663
|
+
return statSync(path).isDirectory();
|
|
1664
|
+
} catch {
|
|
1665
|
+
return false;
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
function isFile(path) {
|
|
1669
|
+
try {
|
|
1670
|
+
return statSync(path).isFile();
|
|
1671
|
+
} catch {
|
|
1672
|
+
return false;
|
|
898
1673
|
}
|
|
899
1674
|
}
|
|
900
1675
|
/**
|
|
901
|
-
*
|
|
902
|
-
*
|
|
903
|
-
* only the highest minor.patch survives.
|
|
1676
|
+
* Load the format in one folder: its format.js (Twine 2), else its header.html (Twine 1).
|
|
1677
|
+
* A format.js that cannot be used is reported as a warning, as Tweego does.
|
|
904
1678
|
*/
|
|
905
|
-
function
|
|
906
|
-
const
|
|
907
|
-
|
|
908
|
-
for (const
|
|
1679
|
+
function loadFormatDir(searchDir, id, diagnostics) {
|
|
1680
|
+
const formatDir = join(searchDir, id);
|
|
1681
|
+
if (!isDirectory(formatDir)) return void 0;
|
|
1682
|
+
for (const baseFilename of ["format.js", "header.html"]) {
|
|
1683
|
+
const filename = join(formatDir, baseFilename);
|
|
1684
|
+
if (!isFile(filename)) continue;
|
|
1685
|
+
if (baseFilename === "header.html") return {
|
|
1686
|
+
id,
|
|
1687
|
+
filename,
|
|
1688
|
+
isTwine2: false,
|
|
1689
|
+
name: id,
|
|
1690
|
+
version: "",
|
|
1691
|
+
proofing: false
|
|
1692
|
+
};
|
|
1693
|
+
const encoding = [];
|
|
1694
|
+
let decoded;
|
|
909
1695
|
try {
|
|
910
|
-
|
|
911
|
-
} catch {
|
|
912
|
-
|
|
1696
|
+
decoded = decodeFormatJSON(readUTF8(filename, encoding));
|
|
1697
|
+
} catch (e) {
|
|
1698
|
+
decoded = {
|
|
1699
|
+
ok: false,
|
|
1700
|
+
reason: `Could not read ${filename}: ${e instanceof Error ? e.message : String(e)}`
|
|
1701
|
+
};
|
|
913
1702
|
}
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
1703
|
+
if (!decoded.ok) {
|
|
1704
|
+
diagnostics?.push(...encoding);
|
|
1705
|
+
diagnostics?.push({
|
|
1706
|
+
level: "warning",
|
|
1707
|
+
message: `format ${id}: Skipping format; ${decoded.reason} (${filename})`
|
|
1708
|
+
});
|
|
918
1709
|
continue;
|
|
919
1710
|
}
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
if (data.url) format.url = data.url;
|
|
953
|
-
if (data.license) format.license = data.license;
|
|
954
|
-
} catch {
|
|
955
|
-
continue;
|
|
956
|
-
}
|
|
957
|
-
else format.name = entry;
|
|
958
|
-
formats.set(entry, format);
|
|
959
|
-
break;
|
|
960
|
-
}
|
|
961
|
-
}
|
|
1711
|
+
const { data } = decoded;
|
|
1712
|
+
const format = {
|
|
1713
|
+
id,
|
|
1714
|
+
filename,
|
|
1715
|
+
isTwine2: true,
|
|
1716
|
+
name: data.name,
|
|
1717
|
+
version: data.version,
|
|
1718
|
+
proofing: data.proofing ?? false
|
|
1719
|
+
};
|
|
1720
|
+
if (data.author) format.author = data.author;
|
|
1721
|
+
if (data.description) format.description = data.description;
|
|
1722
|
+
if (data.image) format.image = data.image;
|
|
1723
|
+
if (data.url) format.url = data.url;
|
|
1724
|
+
if (data.license) format.license = data.license;
|
|
1725
|
+
return format;
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
/**
|
|
1729
|
+
* Discover every story format in the search directories, keyed by folder name (the format ID),
|
|
1730
|
+
* without version pruning.
|
|
1731
|
+
*
|
|
1732
|
+
* Directories are searched in order, and a later directory outranks an earlier one: when two hold
|
|
1733
|
+
* the same folder name, the later one's format is kept. The map iterates in that order too, lowest
|
|
1734
|
+
* rank first. Formats that cannot be used are left out, with a warning in `diagnostics`.
|
|
1735
|
+
*/
|
|
1736
|
+
function discoverAllFormats(searchDirs, diagnostics) {
|
|
1737
|
+
const formats = /* @__PURE__ */ new Map();
|
|
1738
|
+
for (const searchDir of searchDirs) for (const entry of listDirectory(searchDir)) {
|
|
1739
|
+
const format = loadFormatDir(searchDir, entry, diagnostics);
|
|
1740
|
+
if (!format) continue;
|
|
1741
|
+
formats.delete(entry);
|
|
1742
|
+
formats.set(entry, format);
|
|
962
1743
|
}
|
|
963
|
-
pruneFormats(formats);
|
|
964
1744
|
return formats;
|
|
965
1745
|
}
|
|
1746
|
+
/** The version a format is pruned and selected by, or null for one outside SemVer pruning. */
|
|
1747
|
+
function prunableVersion(format) {
|
|
1748
|
+
return format.isTwine2 && format.name ? parseVersion(format.version) : null;
|
|
1749
|
+
}
|
|
1750
|
+
/** The search directory a discovered format was found in. */
|
|
1751
|
+
function searchDirOf(format) {
|
|
1752
|
+
return dirname(dirname(format.filename));
|
|
1753
|
+
}
|
|
966
1754
|
/**
|
|
967
|
-
* Prune formats by SemVer: within each (name, major) group,
|
|
968
|
-
*
|
|
1755
|
+
* Prune formats by SemVer: within each (name, major) group, keep only the version with the highest
|
|
1756
|
+
* precedence (a release outranks its prereleases). Between equal versions, the folder in the
|
|
1757
|
+
* higher-ranked search directory wins, and within one directory the first folder name in sort
|
|
1758
|
+
* order. Returns a new map in the same order.
|
|
1759
|
+
*
|
|
1760
|
+
* Pruning serves name-based selection and listings. An explicit format ID is looked up in the
|
|
1761
|
+
* unpruned map, so a pinned folder is used even when another folder holds a newer version.
|
|
969
1762
|
*/
|
|
970
1763
|
function pruneFormats(formats) {
|
|
971
|
-
const
|
|
972
|
-
const
|
|
973
|
-
|
|
974
|
-
if (!
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
const
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
version: v
|
|
1764
|
+
const dirRank = /* @__PURE__ */ new Map();
|
|
1765
|
+
for (const format of formats.values()) {
|
|
1766
|
+
const dir = searchDirOf(format);
|
|
1767
|
+
if (!dirRank.has(dir)) dirRank.set(dir, dirRank.size);
|
|
1768
|
+
}
|
|
1769
|
+
const rankOf = (format) => dirRank.get(searchDirOf(format)) ?? 0;
|
|
1770
|
+
const best = /* @__PURE__ */ new Map();
|
|
1771
|
+
for (const format of formats.values()) {
|
|
1772
|
+
const version = prunableVersion(format);
|
|
1773
|
+
if (!version) continue;
|
|
1774
|
+
const group = `${format.name}@${version.major}`;
|
|
1775
|
+
const current = best.get(group);
|
|
1776
|
+
const order = current ? compareVersions(version, current.version) : 1;
|
|
1777
|
+
if (order > 0 || order === 0 && current && rankOf(format) > rankOf(current.format)) best.set(group, {
|
|
1778
|
+
format,
|
|
1779
|
+
version
|
|
988
1780
|
});
|
|
989
1781
|
}
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
const groupKey = `${f.name}@${v[0]}`;
|
|
995
|
-
const best = bestByNameMajor.get(groupKey);
|
|
996
|
-
if (best && best.id !== id) formats.delete(id);
|
|
997
|
-
}
|
|
1782
|
+
return new Map([...formats].filter(([id, format]) => {
|
|
1783
|
+
const version = prunableVersion(format);
|
|
1784
|
+
return version === null || best.get(`${format.name}@${version.major}`)?.format.id === id;
|
|
1785
|
+
}));
|
|
998
1786
|
}
|
|
999
1787
|
/**
|
|
1000
|
-
* Get the
|
|
1788
|
+
* Get the format search directories, lowest rank first: the story format subdirectories of the
|
|
1789
|
+
* home directory, then of the working directory, then each `TWEEGO_PATH` entry, then `extraPaths`
|
|
1790
|
+
* (`formatPaths`). A later directory outranks an earlier one for the same format folder name, so
|
|
1791
|
+
* `formatPaths` override `TWEEGO_PATH`, which overrides the home and working directories, as in
|
|
1792
|
+
* Tweego.
|
|
1001
1793
|
*/
|
|
1002
1794
|
function getFormatSearchDirs(extraPaths = [], useTweegoPath = true) {
|
|
1003
1795
|
const subdirNames = [
|
|
@@ -1012,41 +1804,27 @@ function getFormatSearchDirs(extraPaths = [], useTweegoPath = true) {
|
|
|
1012
1804
|
basePaths.add(homedir());
|
|
1013
1805
|
} catch {}
|
|
1014
1806
|
basePaths.add(process.cwd());
|
|
1015
|
-
const dirs = [];
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
try {
|
|
1019
|
-
if (statSync(dir).isDirectory()) dirs.push(dir);
|
|
1020
|
-
} catch {}
|
|
1021
|
-
}
|
|
1807
|
+
const dirs = [...basePaths].flatMap((base) => subdirNames.map((sub) => join(base, sub))).filter(isDirectory);
|
|
1808
|
+
const tweegoPath = useTweegoPath ? process.env.TWEEGO_PATH : void 0;
|
|
1809
|
+
if (tweegoPath) dirs.push(...tweegoPath.split(process.platform === "win32" ? ";" : ":"));
|
|
1022
1810
|
dirs.push(...extraPaths);
|
|
1023
|
-
if (useTweegoPath) {
|
|
1024
|
-
const tweegoPath = process.env.TWEEGO_PATH;
|
|
1025
|
-
if (tweegoPath) dirs.push(...tweegoPath.split(process.platform === "win32" ? ";" : ":"));
|
|
1026
|
-
}
|
|
1027
1811
|
return dirs;
|
|
1028
1812
|
}
|
|
1029
|
-
/**
|
|
1813
|
+
/**
|
|
1814
|
+
* Parse a version string into [major, minor, patch]. Accepts what {@link parseVersion} accepts
|
|
1815
|
+
* (`v1.2.3`, `1.2`, `2.0.0-beta.1`); the prerelease and build metadata are dropped.
|
|
1816
|
+
*/
|
|
1030
1817
|
function parseSemver(v) {
|
|
1031
|
-
const
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
];
|
|
1038
|
-
}
|
|
1039
|
-
function semverCompare(a, b) {
|
|
1040
|
-
const [aMajor, aMinor, aPatch] = a;
|
|
1041
|
-
const [bMajor, bMinor, bPatch] = b;
|
|
1042
|
-
if (aMajor !== bMajor) return aMajor - bMajor;
|
|
1043
|
-
if (aMinor !== bMinor) return aMinor - bMinor;
|
|
1044
|
-
if (aPatch !== bPatch) return aPatch - bPatch;
|
|
1045
|
-
return 0;
|
|
1818
|
+
const parsed = parseVersion(v);
|
|
1819
|
+
return parsed ? [
|
|
1820
|
+
parsed.major,
|
|
1821
|
+
parsed.minor,
|
|
1822
|
+
parsed.patch
|
|
1823
|
+
] : null;
|
|
1046
1824
|
}
|
|
1047
1825
|
/** Build a directory-style format ID from a name and version, e.g. ('SugarCube', '2.37.3') → 'sugarcube-2'. */
|
|
1048
1826
|
function makeFormatId(name, version) {
|
|
1049
|
-
const major =
|
|
1827
|
+
const major = parseVersion(version)?.major ?? 0;
|
|
1050
1828
|
return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
|
|
1051
1829
|
}
|
|
1052
1830
|
/** Describe a format request for diagnostics. */
|
|
@@ -1057,81 +1835,164 @@ function describeFormatRequest(request) {
|
|
|
1057
1835
|
default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
|
|
1058
1836
|
}
|
|
1059
1837
|
}
|
|
1060
|
-
/**
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
* - An ID request ('sugarcube-2') matches a candidate whose name and major version build that ID,
|
|
1064
|
-
* and takes the greatest such version.
|
|
1065
|
-
* - A name request matches the name case-insensitively. An exact version wins outright; otherwise
|
|
1066
|
-
* the greatest version with the same major that is not older than the one asked for (or, with
|
|
1067
|
-
* `allowOlder`, the greatest same-major version). An unparseable requested version takes the
|
|
1068
|
-
* greatest version of any major.
|
|
1069
|
-
*/
|
|
1070
|
-
function selectFormatCandidate(request, candidates, describe, options = {}) {
|
|
1071
|
-
const allowOlder = options.allowOlder ?? false;
|
|
1072
|
-
const wanted = request.kind === "name" ? parseSemver(request.version) : null;
|
|
1838
|
+
/** {@link selectFormatCandidate} over one set of candidates, matching names without regard to case. */
|
|
1839
|
+
function pickFormatCandidate(request, candidates, describe, allowOlder) {
|
|
1840
|
+
const wanted = request.kind === "name" ? parseVersion(request.version) : null;
|
|
1073
1841
|
let best;
|
|
1074
1842
|
for (const candidate of candidates) {
|
|
1075
1843
|
const { name, version } = describe(candidate);
|
|
1076
|
-
const have =
|
|
1844
|
+
const have = parseVersion(version);
|
|
1077
1845
|
if (!have) continue;
|
|
1078
1846
|
if (request.kind === "id") {
|
|
1079
1847
|
if (makeFormatId(name, version) !== request.id.toLowerCase()) continue;
|
|
1080
1848
|
} else {
|
|
1081
1849
|
if (name.toLowerCase() !== request.name.toLowerCase()) continue;
|
|
1082
1850
|
if (wanted) {
|
|
1083
|
-
|
|
1084
|
-
if (
|
|
1085
|
-
if (
|
|
1851
|
+
const order = compareVersions(have, wanted);
|
|
1852
|
+
if (order === 0) return candidate;
|
|
1853
|
+
if (have.major !== wanted.major) continue;
|
|
1854
|
+
if (!allowOlder && order < 0) continue;
|
|
1086
1855
|
}
|
|
1087
1856
|
}
|
|
1088
|
-
if (!best ||
|
|
1857
|
+
if (!best || compareVersions(have, best.version) > 0) best = {
|
|
1089
1858
|
candidate,
|
|
1090
1859
|
version: have
|
|
1091
1860
|
};
|
|
1092
1861
|
}
|
|
1093
1862
|
return best?.candidate;
|
|
1094
1863
|
}
|
|
1095
|
-
/**
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1864
|
+
/**
|
|
1865
|
+
* Pick the candidate that best answers a format request. Local folders, the download cache and
|
|
1866
|
+
* remote indices all select through this one function.
|
|
1867
|
+
*
|
|
1868
|
+
* - An ID request ('sugarcube-2') matches, without regard to case, a candidate whose name and
|
|
1869
|
+
* major version build that ID, and takes the greatest such version.
|
|
1870
|
+
* - A name request matches the name without regard to case, but a candidate whose name matches in
|
|
1871
|
+
* case too is preferred when one answers the request. An exact version (prerelease included)
|
|
1872
|
+
* wins outright; otherwise the greatest version with the same major that is not older than the
|
|
1873
|
+
* one asked for (or, with `allowOlder`, the greatest same-major version). An unparseable
|
|
1874
|
+
* requested version takes the greatest version of any major.
|
|
1875
|
+
*
|
|
1876
|
+
* Versions compare by SemVer precedence ({@link compareVersions}), so a release outranks its
|
|
1877
|
+
* prereleases. Between equal versions, the earlier candidate wins.
|
|
1878
|
+
*/
|
|
1879
|
+
function selectFormatCandidate(request, candidates, describe, options = {}) {
|
|
1880
|
+
const allowOlder = options.allowOlder ?? false;
|
|
1881
|
+
if (request.kind === "name") {
|
|
1882
|
+
const preferred = pickFormatCandidate(request, candidates.filter((c) => describe(c).name === request.name), describe, allowOlder);
|
|
1883
|
+
if (preferred !== void 0) return preferred;
|
|
1884
|
+
}
|
|
1885
|
+
return pickFormatCandidate(request, candidates, describe, allowOlder);
|
|
1886
|
+
}
|
|
1887
|
+
/** The Twine 2 formats of a discovered map, those from higher-ranked search directories first. */
|
|
1888
|
+
function rankedTwine2Formats(formats) {
|
|
1889
|
+
return [...formats.values()].filter((f) => f.isTwine2).reverse();
|
|
1890
|
+
}
|
|
1891
|
+
/**
|
|
1892
|
+
* Look a format ID up among discovered folders. The ID matches a folder name without regard to
|
|
1893
|
+
* case; an exact-case folder is preferred, then the highest-ranked one.
|
|
1894
|
+
*/
|
|
1895
|
+
function findFormatById(formats, id) {
|
|
1896
|
+
const exact = formats.get(id);
|
|
1897
|
+
if (exact) return exact;
|
|
1898
|
+
const wanted = id.toLowerCase();
|
|
1899
|
+
return [...formats.values()].reverse().find((f) => f.id.toLowerCase() === wanted);
|
|
1900
|
+
}
|
|
1901
|
+
/**
|
|
1902
|
+
* Read the story format source (for Twine 2, extract the `source` property from JSON).
|
|
1903
|
+
* `diagnostics` receives a warning when the format file is not valid UTF-8 (it is read as Windows-1252).
|
|
1904
|
+
*/
|
|
1905
|
+
function readFormatSource(format, diagnostics) {
|
|
1906
|
+
const source = readUTF8(format.filename, diagnostics);
|
|
1907
|
+
if (!format.isTwine2) return source;
|
|
1908
|
+
const decoded = decodeFormatJSON(source);
|
|
1909
|
+
if (!decoded.ok) throw new Error(`Cannot parse format ${format.id} JSON: ${decoded.reason}`);
|
|
1910
|
+
return decoded.data.source;
|
|
1911
|
+
}
|
|
1912
|
+
//#endregion
|
|
1913
|
+
//#region src/atomic-write.ts
|
|
1914
|
+
/**
|
|
1915
|
+
* Atomic file replacement, for every file twee-ts writes that something else may read
|
|
1916
|
+
* meanwhile: build output (which live-reload servers read on each change) and downloaded
|
|
1917
|
+
* story formats (which other twee-ts processes share).
|
|
1918
|
+
*/
|
|
1919
|
+
/**
|
|
1920
|
+
* Writes `data` to `path` so that a reader sees either the previous file or the complete new
|
|
1921
|
+
* one, never a partly written file. The data goes to a temporary file in the same folder,
|
|
1922
|
+
* which is then renamed over `path`. When `path` is a symlink, the file it points to is
|
|
1923
|
+
* replaced, and an existing file keeps its permissions.
|
|
1924
|
+
*
|
|
1925
|
+
* On failure (a full disk, a missing folder) the temporary file is removed, `path` is left
|
|
1926
|
+
* as it was, and the error is thrown with `path` in its message and its original `code`.
|
|
1927
|
+
*/
|
|
1928
|
+
function writeFileAtomic(path, data) {
|
|
1929
|
+
const target = followSymlink(path);
|
|
1930
|
+
const temp = join(dirname(target), `.${basename(target)}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
|
|
1931
|
+
try {
|
|
1932
|
+
writeFileSync(temp, data, {
|
|
1933
|
+
encoding: "utf-8",
|
|
1934
|
+
flag: "wx"
|
|
1935
|
+
});
|
|
1936
|
+
keepPermissions(target, temp);
|
|
1937
|
+
renameSync(temp, target);
|
|
1938
|
+
} catch (e) {
|
|
1939
|
+
rmSync(temp, { force: true });
|
|
1940
|
+
throw writeError(path, e);
|
|
1110
1941
|
}
|
|
1111
|
-
return bestId;
|
|
1112
1942
|
}
|
|
1113
|
-
/**
|
|
1114
|
-
function
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
return data.source;
|
|
1943
|
+
/** The file a symlink at `path` points to, or `path` itself (also when it doesn't exist yet). */
|
|
1944
|
+
function followSymlink(path) {
|
|
1945
|
+
try {
|
|
1946
|
+
return realpathSync(path);
|
|
1947
|
+
} catch {
|
|
1948
|
+
return path;
|
|
1120
1949
|
}
|
|
1121
|
-
|
|
1950
|
+
}
|
|
1951
|
+
/**
|
|
1952
|
+
* Gives `temp` the permission bits of `target`, when `target` exists. Best effort: on a file
|
|
1953
|
+
* system without Unix permissions chmod can fail, and writing in place never kept them there either.
|
|
1954
|
+
*/
|
|
1955
|
+
function keepPermissions(target, temp) {
|
|
1956
|
+
const existing = statSync(target, { throwIfNoEntry: false });
|
|
1957
|
+
if (existing === void 0) return;
|
|
1958
|
+
try {
|
|
1959
|
+
chmodSync(temp, existing.mode & 4095);
|
|
1960
|
+
} catch {}
|
|
1961
|
+
}
|
|
1962
|
+
/** `cause` as an error that names `path` and keeps the original error code. */
|
|
1963
|
+
function writeError(path, cause) {
|
|
1964
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
1965
|
+
const error = new Error(`Cannot write ${path}: ${message}`, { cause });
|
|
1966
|
+
if (cause instanceof Error && "code" in cause) error.code = cause.code;
|
|
1967
|
+
return error;
|
|
1122
1968
|
}
|
|
1123
1969
|
//#endregion
|
|
1124
1970
|
//#region src/remote-formats.ts
|
|
1125
1971
|
/**
|
|
1126
1972
|
* Remote story format fetching, caching, and checksum verification.
|
|
1127
1973
|
* Uses the Story Formats Archive (SFA) as the default source.
|
|
1974
|
+
*
|
|
1975
|
+
* Two caches hold downloads. Formats found through an index are shared by name and version
|
|
1976
|
+
* (`<cache>/<name>/<version>/format.js`). A format downloaded from a direct URL is kept under
|
|
1977
|
+
* that URL instead, so one project's copy never answers another project's request.
|
|
1978
|
+
* Cache files are replaced atomically, so another process never reads a partly written one.
|
|
1128
1979
|
*/
|
|
1129
1980
|
const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
|
|
1981
|
+
/** The longest delay a timer accepts; a longer timeout means no limit. */
|
|
1982
|
+
const MAX_TIMER_DELAY = 2147483647;
|
|
1130
1983
|
/** Get the cache directory for downloaded story formats. */
|
|
1131
1984
|
function getCacheDir() {
|
|
1132
1985
|
const base = process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache");
|
|
1133
1986
|
return join(base, "twee-ts", "storyformats");
|
|
1134
1987
|
}
|
|
1988
|
+
/** The cache directory for formats downloaded from direct URLs, beside {@link getCacheDir}. */
|
|
1989
|
+
function getUrlCacheDir() {
|
|
1990
|
+
return join(dirname(getCacheDir()), "storyformat-urls");
|
|
1991
|
+
}
|
|
1992
|
+
/** The name of one direct URL's cache entry: a hash of the URL, so each URL has its own. */
|
|
1993
|
+
function urlCacheKey(url) {
|
|
1994
|
+
return createHash("sha256").update(url).digest("hex");
|
|
1995
|
+
}
|
|
1135
1996
|
/**
|
|
1136
1997
|
* Whether a string can serve as one directory name inside the cache: not empty, not `.` or `..`,
|
|
1137
1998
|
* and free of path separators. Format names and versions come from downloaded metadata, so they
|
|
@@ -1154,25 +2015,38 @@ function cachedFormatDir(name, version) {
|
|
|
1154
2015
|
if (!isInside(root, dir)) throw new Error(`Refusing to cache a story format outside the cache directory ${root}: ${dir}`);
|
|
1155
2016
|
return dir;
|
|
1156
2017
|
}
|
|
1157
|
-
/**
|
|
2018
|
+
/** Whether `e` is a Node.js system error with the given code. */
|
|
2019
|
+
function hasErrorCode(e, code) {
|
|
2020
|
+
return e instanceof Error && "code" in e && e.code === code;
|
|
2021
|
+
}
|
|
2022
|
+
/**
|
|
2023
|
+
* Create `dir` as a plain directory, or check that it already is one (a symlink could lead out of the cache).
|
|
2024
|
+
* A directory another process creates between the check and the mkdir is fine.
|
|
2025
|
+
*/
|
|
1158
2026
|
function ensurePlainDirectory(dir) {
|
|
1159
|
-
|
|
1160
|
-
if (stat === void 0) {
|
|
2027
|
+
if (lstatSync(dir, { throwIfNoEntry: false }) === void 0) try {
|
|
1161
2028
|
mkdirSync(dir);
|
|
1162
2029
|
return;
|
|
2030
|
+
} catch (e) {
|
|
2031
|
+
if (!hasErrorCode(e, "EEXIST")) throw e;
|
|
1163
2032
|
}
|
|
1164
|
-
|
|
2033
|
+
const stat = lstatSync(dir, { throwIfNoEntry: false });
|
|
2034
|
+
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`);
|
|
1165
2035
|
}
|
|
1166
|
-
/**
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
2036
|
+
/**
|
|
2037
|
+
* Write a downloaded format.js to `<root>/<...segments>/format.js`, replacing any earlier copy
|
|
2038
|
+
* atomically, and return its path. Every directory below `root` must be a plain one.
|
|
2039
|
+
*/
|
|
2040
|
+
function writeCacheEntry(root, segments, text) {
|
|
1170
2041
|
mkdirSync(root, { recursive: true });
|
|
1171
|
-
|
|
1172
|
-
|
|
2042
|
+
const dir = segments.reduce((parent, segment) => {
|
|
2043
|
+
const child = join(parent, segment);
|
|
2044
|
+
ensurePlainDirectory(child);
|
|
2045
|
+
return child;
|
|
2046
|
+
}, root);
|
|
1173
2047
|
const formatPath = join(dir, "format.js");
|
|
1174
2048
|
if (lstatSync(formatPath, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error(`Refusing to write a story format outside the cache directory: ${formatPath} is a symlink`);
|
|
1175
|
-
|
|
2049
|
+
writeFileAtomic(formatPath, text);
|
|
1176
2050
|
return formatPath;
|
|
1177
2051
|
}
|
|
1178
2052
|
/** In-memory index cache, keyed by URL. Cleared each compile. */
|
|
@@ -1181,6 +2055,78 @@ const indexCache = /* @__PURE__ */ new Map();
|
|
|
1181
2055
|
function clearIndexCache() {
|
|
1182
2056
|
indexCache.clear();
|
|
1183
2057
|
}
|
|
2058
|
+
/** Requests in progress, by what they fetch, so concurrent compiles make each request once. */
|
|
2059
|
+
const sharedRequests = /* @__PURE__ */ new Map();
|
|
2060
|
+
/**
|
|
2061
|
+
* Runs `start` once for all callers that ask for `key` while it is in progress. A caller whose
|
|
2062
|
+
* `signal` aborts stops waiting at once and rejects with the signal's reason; the request
|
|
2063
|
+
* itself is aborted (through the signal `start` receives) once no caller waits on it.
|
|
2064
|
+
*/
|
|
2065
|
+
function shareRequest(key, signal, start) {
|
|
2066
|
+
if (signal?.aborted) return Promise.reject(signal.reason);
|
|
2067
|
+
const request = sharedRequests.get(key) ?? startSharedRequest(key, start);
|
|
2068
|
+
request.waiters++;
|
|
2069
|
+
return new Promise((resolve, reject) => {
|
|
2070
|
+
let waiting = true;
|
|
2071
|
+
const stopWaiting = () => {
|
|
2072
|
+
if (!waiting) return;
|
|
2073
|
+
waiting = false;
|
|
2074
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2075
|
+
request.waiters--;
|
|
2076
|
+
};
|
|
2077
|
+
const onAbort = () => {
|
|
2078
|
+
stopWaiting();
|
|
2079
|
+
if (request.waiters === 0) {
|
|
2080
|
+
if (sharedRequests.get(key) === request) sharedRequests.delete(key);
|
|
2081
|
+
request.controller.abort(signal?.reason);
|
|
2082
|
+
}
|
|
2083
|
+
reject(signal?.reason);
|
|
2084
|
+
};
|
|
2085
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2086
|
+
request.promise.then((value) => {
|
|
2087
|
+
stopWaiting();
|
|
2088
|
+
resolve(value);
|
|
2089
|
+
}, (e) => {
|
|
2090
|
+
stopWaiting();
|
|
2091
|
+
reject(e);
|
|
2092
|
+
});
|
|
2093
|
+
});
|
|
2094
|
+
}
|
|
2095
|
+
function startSharedRequest(key, start) {
|
|
2096
|
+
const controller = new AbortController();
|
|
2097
|
+
const request = {
|
|
2098
|
+
promise: start(controller.signal),
|
|
2099
|
+
controller,
|
|
2100
|
+
waiters: 0
|
|
2101
|
+
};
|
|
2102
|
+
sharedRequests.set(key, request);
|
|
2103
|
+
const forget = () => {
|
|
2104
|
+
if (sharedRequests.get(key) === request) sharedRequests.delete(key);
|
|
2105
|
+
};
|
|
2106
|
+
request.promise.then(forget, forget);
|
|
2107
|
+
return request;
|
|
2108
|
+
}
|
|
2109
|
+
/** The time limit for each request, from the caller's options. */
|
|
2110
|
+
function requestTimeout(options) {
|
|
2111
|
+
const timeout = options.timeout ?? 3e4;
|
|
2112
|
+
if (!(timeout >= 0)) throw new RangeError(`A story format request timeout must be 0 or more milliseconds, not ${timeout}`);
|
|
2113
|
+
return timeout;
|
|
2114
|
+
}
|
|
2115
|
+
/**
|
|
2116
|
+
* Fetch `url` as text, aborting when `signal` does or after `timeout` milliseconds (0: no limit).
|
|
2117
|
+
* `what` names the request in errors, as in "Failed to <what> from <url>".
|
|
2118
|
+
*/
|
|
2119
|
+
async function fetchText(url, what, signal, timeout) {
|
|
2120
|
+
const limit = timeout > 0 && timeout <= MAX_TIMER_DELAY ? AbortSignal.timeout(timeout) : void 0;
|
|
2121
|
+
try {
|
|
2122
|
+
const res = await fetch(url, { signal: limit ? AbortSignal.any([signal, limit]) : signal });
|
|
2123
|
+
if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
|
|
2124
|
+
return await res.text();
|
|
2125
|
+
} catch (e) {
|
|
2126
|
+
if (limit?.aborted && !signal.aborted) throw new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`, { cause: e });
|
|
2127
|
+
throw e;
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
1184
2130
|
/** Validate that a JSON value is a valid SFAIndexEntry. */
|
|
1185
2131
|
function isValidEntry(val) {
|
|
1186
2132
|
if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
|
|
@@ -1196,15 +2142,18 @@ function validateSFAIndex(json) {
|
|
|
1196
2142
|
twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
|
|
1197
2143
|
};
|
|
1198
2144
|
}
|
|
1199
|
-
/** Fetch and parse an SFA index.json, with in-memory caching. */
|
|
1200
|
-
async function fetchIndex(url) {
|
|
2145
|
+
/** Fetch and parse an SFA index.json, with in-memory caching. Concurrent calls for one URL share a request. */
|
|
2146
|
+
async function fetchIndex(url, options = {}) {
|
|
2147
|
+
options.signal?.throwIfAborted();
|
|
1201
2148
|
const cached = indexCache.get(url);
|
|
1202
2149
|
if (cached) return cached;
|
|
1203
|
-
const
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
2150
|
+
const timeout = requestTimeout(options);
|
|
2151
|
+
return shareRequest(`index\0${url}`, options.signal, async (signal) => {
|
|
2152
|
+
const text = await fetchText(url, "fetch format index", signal, timeout);
|
|
2153
|
+
const data = validateSFAIndex(JSON.parse(text));
|
|
2154
|
+
indexCache.set(url, data);
|
|
2155
|
+
return data;
|
|
2156
|
+
});
|
|
1208
2157
|
}
|
|
1209
2158
|
/** Find the best matching entry in an SFA index for a name or ID request (twine2 entries first). */
|
|
1210
2159
|
function findEntryForRequest(index, request) {
|
|
@@ -1225,84 +2174,138 @@ async function verifySHA256(content, expectedHex) {
|
|
|
1225
2174
|
function getDownloadUrl(indexUrl, entry, formatType) {
|
|
1226
2175
|
return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
|
|
1227
2176
|
}
|
|
1228
|
-
/**
|
|
1229
|
-
|
|
1230
|
-
cachedFormatDir(entry.name, entry.version);
|
|
1231
|
-
const res = await fetch(downloadUrl);
|
|
1232
|
-
if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
|
|
1233
|
-
const text = await res.text();
|
|
1234
|
-
const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
|
|
1235
|
-
if (checksumKey) {
|
|
1236
|
-
const expected = entry.checksums[checksumKey];
|
|
1237
|
-
if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
|
|
1238
|
-
if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
|
|
1239
|
-
}
|
|
1240
|
-
const id = makeFormatId(entry.name, entry.version);
|
|
1241
|
-
const data = parseFormatJSON(text, id);
|
|
1242
|
-
if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
|
|
2177
|
+
/** The StoryFormatInfo of a Twine 2 format.js cached at `filename`. */
|
|
2178
|
+
function cachedFormatInfo(id, filename, data) {
|
|
1243
2179
|
return {
|
|
1244
2180
|
id,
|
|
1245
|
-
filename
|
|
2181
|
+
filename,
|
|
1246
2182
|
isTwine2: true,
|
|
1247
2183
|
name: data.name,
|
|
1248
2184
|
version: data.version,
|
|
1249
2185
|
proofing: data.proofing ?? false
|
|
1250
2186
|
};
|
|
1251
2187
|
}
|
|
1252
|
-
/**
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
return {
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
2188
|
+
/**
|
|
2189
|
+
* Download a format, verify its checksum, write it to the cache shared by name and version, and
|
|
2190
|
+
* return its StoryFormatInfo. Concurrent calls for one download URL share one request.
|
|
2191
|
+
*/
|
|
2192
|
+
async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
|
|
2193
|
+
cachedFormatDir(entry.name, entry.version);
|
|
2194
|
+
const timeout = requestTimeout(options);
|
|
2195
|
+
return shareRequest(`format\0${getCacheDir()}\0${downloadUrl}`, options.signal, async (signal) => {
|
|
2196
|
+
const text = await fetchText(downloadUrl, "download format", signal, timeout);
|
|
2197
|
+
const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
|
|
2198
|
+
if (checksumKey) {
|
|
2199
|
+
const expected = entry.checksums[checksumKey];
|
|
2200
|
+
if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
|
|
2201
|
+
if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
|
|
2202
|
+
}
|
|
2203
|
+
const id = makeFormatId(entry.name, entry.version);
|
|
2204
|
+
const data = parseFormatJSON(text, id);
|
|
2205
|
+
if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
|
|
2206
|
+
signal.throwIfAborted();
|
|
2207
|
+
return cachedFormatInfo(id, writeCacheEntry(resolve(getCacheDir()), [entry.name, entry.version], text), data);
|
|
2208
|
+
});
|
|
2209
|
+
}
|
|
2210
|
+
/**
|
|
2211
|
+
* Download a direct format.js URL, parse its JSON, cache it under that URL (apart from the
|
|
2212
|
+
* downloads shared by name and version), and return its StoryFormatInfo. Concurrent calls for
|
|
2213
|
+
* one URL share one request.
|
|
2214
|
+
*/
|
|
2215
|
+
async function fetchDirectFormat(url, options = {}) {
|
|
2216
|
+
const timeout = requestTimeout(options);
|
|
2217
|
+
return shareRequest(`url\0${getUrlCacheDir()}\0${url}`, options.signal, async (signal) => {
|
|
2218
|
+
const text = await fetchText(url, "download format", signal, timeout);
|
|
2219
|
+
const data = parseFormatJSON(text);
|
|
2220
|
+
if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
|
|
2221
|
+
cachedFormatDir(data.name, data.version);
|
|
2222
|
+
signal.throwIfAborted();
|
|
2223
|
+
const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
|
|
2224
|
+
return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
|
|
2225
|
+
});
|
|
2226
|
+
}
|
|
2227
|
+
/** The copy of a direct format URL in the download cache, if it has been downloaded before. */
|
|
2228
|
+
function getCachedDirectFormat(url) {
|
|
2229
|
+
const formatPath = join(getUrlCacheDir(), urlCacheKey(url), "format.js");
|
|
2230
|
+
try {
|
|
2231
|
+
if (!existsSync(formatPath)) return void 0;
|
|
2232
|
+
const data = parseFormatJSON(readUTF8(formatPath));
|
|
2233
|
+
if (!data) return void 0;
|
|
2234
|
+
return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
|
|
2235
|
+
} catch {
|
|
2236
|
+
return;
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
/**
|
|
2240
|
+
* Look a request up among direct format URLs, in order, and return the first format that
|
|
2241
|
+
* answers it. A URL downloaded before is answered by its cached copy without the network;
|
|
2242
|
+
* any other is downloaded (unless `offline`). When none answers and a download failed, the
|
|
2243
|
+
* last failure is thrown. An aborted signal rejects with its reason and tries no further URL.
|
|
2244
|
+
*
|
|
2245
|
+
* Internal, for format resolution; not part of the public API.
|
|
2246
|
+
*/
|
|
2247
|
+
async function resolveFormatUrls(request, urls, options = {}) {
|
|
2248
|
+
let lastError;
|
|
2249
|
+
for (const url of urls) {
|
|
2250
|
+
const cached = getCachedDirectFormat(url);
|
|
2251
|
+
if (cached) {
|
|
2252
|
+
if (answers(request, cached)) return cached;
|
|
2253
|
+
continue;
|
|
2254
|
+
}
|
|
2255
|
+
if (options.offline) continue;
|
|
2256
|
+
try {
|
|
2257
|
+
const info = await fetchDirectFormat(url, options);
|
|
2258
|
+
if (answers(request, info)) return info;
|
|
2259
|
+
} catch (e) {
|
|
2260
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
2261
|
+
lastError = toError$1(e);
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
if (lastError) throw lastError;
|
|
2265
|
+
}
|
|
2266
|
+
/** Whether `info` is a format the request accepts. */
|
|
2267
|
+
function answers(request, info) {
|
|
2268
|
+
return selectFormatCandidate(request, [info], (f) => f) !== void 0;
|
|
2269
|
+
}
|
|
2270
|
+
function toError$1(e) {
|
|
2271
|
+
return e instanceof Error ? e : new Error(String(e));
|
|
1267
2272
|
}
|
|
1268
2273
|
/**
|
|
1269
2274
|
* Resolve a story format request remotely, in the same order as {@link resolveRemoteFormat}.
|
|
1270
2275
|
* An ID request such as 'sugarcube-2' matches the format whose name and major version build that ID
|
|
1271
2276
|
* (SugarCube 2.x), taking the greatest version available.
|
|
1272
2277
|
*/
|
|
1273
|
-
async function resolveRemoteFormatRequest(request, indices, urls) {
|
|
2278
|
+
async function resolveRemoteFormatRequest(request, indices, urls, options = {}) {
|
|
2279
|
+
options.signal?.throwIfAborted();
|
|
1274
2280
|
let lastError;
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
const info = await fetchDirectFormat(url);
|
|
1279
|
-
if (selectFormatCandidate(request, [info], (f) => f)) return info;
|
|
2281
|
+
try {
|
|
2282
|
+
const direct = await resolveFormatUrls(request, urls ?? [], options);
|
|
2283
|
+
if (direct) return direct;
|
|
1280
2284
|
} catch (e) {
|
|
1281
|
-
|
|
2285
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
2286
|
+
lastError = toError$1(e);
|
|
1282
2287
|
}
|
|
2288
|
+
const cached = findCachedFormat(request);
|
|
2289
|
+
if (cached && request.kind === "name" && sameVersion(cached.version, request.version)) return cached;
|
|
1283
2290
|
const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
|
|
1284
2291
|
for (const indexUrl of allIndices) try {
|
|
1285
|
-
const result = findEntryForRequest(await fetchIndex(indexUrl), request);
|
|
2292
|
+
const result = findEntryForRequest(await fetchIndex(indexUrl, options), request);
|
|
1286
2293
|
if (result) {
|
|
1287
2294
|
const hit = getCachedFormat(result.entry.name, result.entry.version);
|
|
1288
2295
|
if (hit) return hit;
|
|
1289
2296
|
const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
|
|
1290
|
-
return await fetchAndCacheFormat(result.entry, downloadUrl);
|
|
2297
|
+
return await fetchAndCacheFormat(result.entry, downloadUrl, options);
|
|
1291
2298
|
}
|
|
1292
2299
|
} catch (e) {
|
|
1293
|
-
|
|
2300
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
2301
|
+
lastError = toError$1(e);
|
|
1294
2302
|
}
|
|
1295
2303
|
if (cached) return cached;
|
|
1296
2304
|
if (lastError) throw lastError;
|
|
1297
2305
|
}
|
|
1298
|
-
/** Whether two version strings name the same SemVer version. */
|
|
1299
|
-
function isExactVersion(have, wanted) {
|
|
1300
|
-
const a = parseSemver(have);
|
|
1301
|
-
const b = parseSemver(wanted);
|
|
1302
|
-
return a !== null && b !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
|
|
1303
|
-
}
|
|
1304
2306
|
/**
|
|
1305
2307
|
* Find the cached download that best answers a format request, without network access.
|
|
2308
|
+
* Only the downloads shared by name and version are searched, not those from direct URLs.
|
|
1306
2309
|
* Matching follows {@link selectFormatCandidate}.
|
|
1307
2310
|
*/
|
|
1308
2311
|
function findCachedFormat(request, options = {}) {
|
|
@@ -1314,18 +2317,11 @@ function getCachedFormat(name, version) {
|
|
|
1314
2317
|
const formatPath = join(getCacheDir(), name, version, "format.js");
|
|
1315
2318
|
try {
|
|
1316
2319
|
if (!existsSync(formatPath)) return void 0;
|
|
1317
|
-
const source =
|
|
2320
|
+
const source = readUTF8(formatPath);
|
|
1318
2321
|
const id = makeFormatId(name, version);
|
|
1319
2322
|
const data = parseFormatJSON(source, id);
|
|
1320
2323
|
if (!data) return void 0;
|
|
1321
|
-
return
|
|
1322
|
-
id,
|
|
1323
|
-
filename: formatPath,
|
|
1324
|
-
isTwine2: true,
|
|
1325
|
-
name: data.name,
|
|
1326
|
-
version: data.version,
|
|
1327
|
-
proofing: data.proofing ?? false
|
|
1328
|
-
};
|
|
2324
|
+
return cachedFormatInfo(id, formatPath, data);
|
|
1329
2325
|
} catch {
|
|
1330
2326
|
return;
|
|
1331
2327
|
}
|
|
@@ -1410,21 +2406,27 @@ function listCachedFormats() {
|
|
|
1410
2406
|
}
|
|
1411
2407
|
/**
|
|
1412
2408
|
* Clear all cached formats, or only those matching a given name. Returns the number of entries removed.
|
|
2409
|
+
* Clearing all also removes the downloads from direct format URLs; a name clears only the
|
|
2410
|
+
* downloads shared by name and version.
|
|
1413
2411
|
* A name must be a single cache entry name (as `listCachedFormats()` reports it); anything with a
|
|
1414
2412
|
* path separator or `.`/`..` throws instead of deleting outside the format's own directory.
|
|
1415
2413
|
*/
|
|
1416
2414
|
function clearCachedFormats(name) {
|
|
1417
2415
|
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.`);
|
|
1418
2416
|
const cacheDir = getCacheDir();
|
|
1419
|
-
if (!existsSync(cacheDir)) return 0;
|
|
1420
2417
|
if (!name) {
|
|
1421
|
-
const count = listCachedFormats().length;
|
|
2418
|
+
const count = listCachedFormats().length + countUrlCacheEntries();
|
|
1422
2419
|
rmSync(cacheDir, {
|
|
1423
2420
|
recursive: true,
|
|
1424
2421
|
force: true
|
|
1425
2422
|
});
|
|
2423
|
+
rmSync(getUrlCacheDir(), {
|
|
2424
|
+
recursive: true,
|
|
2425
|
+
force: true
|
|
2426
|
+
});
|
|
1426
2427
|
return count;
|
|
1427
2428
|
}
|
|
2429
|
+
if (!existsSync(cacheDir)) return 0;
|
|
1428
2430
|
const root = resolve(cacheDir);
|
|
1429
2431
|
const nameDir = resolve(root, name);
|
|
1430
2432
|
if (!isInside(root, nameDir)) throw new Error(`Refusing to clear ${JSON.stringify(name)}: it is not a cached format name.`);
|
|
@@ -1442,6 +2444,15 @@ function clearCachedFormats(name) {
|
|
|
1442
2444
|
});
|
|
1443
2445
|
return count;
|
|
1444
2446
|
}
|
|
2447
|
+
/** The number of direct format URLs with a download in the cache. */
|
|
2448
|
+
function countUrlCacheEntries() {
|
|
2449
|
+
const dir = getUrlCacheDir();
|
|
2450
|
+
try {
|
|
2451
|
+
return readdirSync(dir).filter((key) => existsSync(join(dir, key, "format.js"))).length;
|
|
2452
|
+
} catch {
|
|
2453
|
+
return 0;
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
1445
2456
|
/** Get total cache size in bytes and format count. */
|
|
1446
2457
|
function getCacheSize() {
|
|
1447
2458
|
const entries = listCachedFormats();
|
|
@@ -1468,44 +2479,65 @@ function formatRequestFor(formatId, storyFormat, storyFormatVersion, defaultId)
|
|
|
1468
2479
|
id: defaultId
|
|
1469
2480
|
};
|
|
1470
2481
|
}
|
|
1471
|
-
/**
|
|
1472
|
-
|
|
2482
|
+
/**
|
|
2483
|
+
* Look a request up among the formats found in local format directories. An ID names a folder, so
|
|
2484
|
+
* it is looked up among all of them; a name request selects by SemVer among the pruned formats.
|
|
2485
|
+
*/
|
|
2486
|
+
function findLocalFormat(all, pruned, request) {
|
|
1473
2487
|
switch (request.kind) {
|
|
1474
|
-
case "id": return
|
|
1475
|
-
case "name":
|
|
1476
|
-
const id = getFormatIdByNameAndVersion(formats, request.name, request.version);
|
|
1477
|
-
return id === void 0 ? void 0 : formats.get(id);
|
|
1478
|
-
}
|
|
2488
|
+
case "id": return findFormatById(all, request.id);
|
|
2489
|
+
case "name": return selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f);
|
|
1479
2490
|
default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
|
|
1480
2491
|
}
|
|
1481
2492
|
}
|
|
1482
2493
|
/**
|
|
1483
2494
|
* Resolve a format request to a story format.
|
|
1484
2495
|
*
|
|
1485
|
-
* Order: local format directories, then the
|
|
1486
|
-
*
|
|
2496
|
+
* Order: local format directories, then the project's direct format URLs (each one's cached
|
|
2497
|
+
* copy, else a download unless `noRemote`), then the download cache shared by name and version,
|
|
2498
|
+
* then format indices (unless `noRemote`). Names and IDs match the same way in each (see
|
|
2499
|
+
* `selectFormatCandidate`), so a request that a local format answers never reaches the cache or the
|
|
2500
|
+
* network. Local formats that cannot be used are reported as warnings. When a StoryData request
|
|
2501
|
+
* names a version that none of them has, an older version of
|
|
1487
2502
|
* the same format and major version is used with a warning. Anything else is an error diagnostic,
|
|
1488
2503
|
* and the result is `undefined`.
|
|
1489
2504
|
*/
|
|
1490
2505
|
async function resolveStoryFormat(request, options, diagnostics) {
|
|
1491
2506
|
const noRemote = options.noRemote ?? false;
|
|
1492
|
-
const
|
|
2507
|
+
const all = discoverAllFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true), diagnostics);
|
|
2508
|
+
const pruned = pruneFormats(all);
|
|
1493
2509
|
const wanted = describeFormatRequest(request);
|
|
1494
|
-
const local = findLocalFormat(
|
|
2510
|
+
const local = findLocalFormat(all, pruned, request);
|
|
1495
2511
|
if (local) return local;
|
|
2512
|
+
const fetchOptions = {
|
|
2513
|
+
signal: options.signal,
|
|
2514
|
+
timeout: options.formatFetchTimeout
|
|
2515
|
+
};
|
|
2516
|
+
const warnOnFailure = async (lookup) => {
|
|
2517
|
+
try {
|
|
2518
|
+
return await lookup();
|
|
2519
|
+
} catch (e) {
|
|
2520
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
2521
|
+
diagnostics.push({
|
|
2522
|
+
level: "warning",
|
|
2523
|
+
message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
|
|
2524
|
+
});
|
|
2525
|
+
return;
|
|
2526
|
+
}
|
|
2527
|
+
};
|
|
2528
|
+
const direct = await warnOnFailure(() => resolveFormatUrls(request, options.formatUrls ?? [], {
|
|
2529
|
+
...fetchOptions,
|
|
2530
|
+
offline: noRemote
|
|
2531
|
+
}));
|
|
2532
|
+
if (direct) return direct;
|
|
1496
2533
|
const cached = findCachedFormat(request);
|
|
1497
2534
|
if (cached) return cached;
|
|
1498
|
-
if (!noRemote)
|
|
1499
|
-
const remote = await resolveRemoteFormatRequest(request, options.formatIndices,
|
|
2535
|
+
if (!noRemote) {
|
|
2536
|
+
const remote = await warnOnFailure(() => resolveRemoteFormatRequest(request, options.formatIndices, [], fetchOptions));
|
|
1500
2537
|
if (remote) return remote;
|
|
1501
|
-
} catch (e) {
|
|
1502
|
-
diagnostics.push({
|
|
1503
|
-
level: "warning",
|
|
1504
|
-
message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
|
|
1505
|
-
});
|
|
1506
2538
|
}
|
|
1507
2539
|
if (request.kind === "name") {
|
|
1508
|
-
const older = selectFormatCandidate(request,
|
|
2540
|
+
const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
|
|
1509
2541
|
if (older) {
|
|
1510
2542
|
diagnostics.push({
|
|
1511
2543
|
level: "warning",
|
|
@@ -1517,7 +2549,7 @@ async function resolveStoryFormat(request, options, diagnostics) {
|
|
|
1517
2549
|
const reason = noRemote ? " (remote fetching disabled)" : "";
|
|
1518
2550
|
diagnostics.push({
|
|
1519
2551
|
level: "error",
|
|
1520
|
-
message: `Story format ${wanted} is not available${reason}. Found: ${[...
|
|
2552
|
+
message: `Story format ${wanted} is not available${reason}. Found: ${[...all.keys()].join(", ") || "none"}`
|
|
1521
2553
|
});
|
|
1522
2554
|
}
|
|
1523
2555
|
//#endregion
|
|
@@ -1786,12 +2818,14 @@ function twee2ToV3(s) {
|
|
|
1786
2818
|
* Parse Twee source text into passages.
|
|
1787
2819
|
*
|
|
1788
2820
|
* The source is normalized first, as files are when they are read: a leading UTF-8 BOM is
|
|
1789
|
-
* removed and CRLF and bare CR line endings become LF.
|
|
2821
|
+
* removed and CRLF and bare CR line endings become LF. A BOM at the start of a later line,
|
|
2822
|
+
* directly before `::` (left there by concatenating files), is removed too, so that line
|
|
2823
|
+
* stays a passage header.
|
|
1790
2824
|
*/
|
|
1791
2825
|
function parseTwee(source, options = {}) {
|
|
1792
2826
|
const { filename = "<inline>", trim = true, twee2Compat = false } = options;
|
|
1793
2827
|
const diagnostics = [];
|
|
1794
|
-
const normalized =
|
|
2828
|
+
const normalized = normalizeTweeSourceText(source);
|
|
1795
2829
|
const tweeSource = twee2Compat ? twee2ToV3(normalized) : normalized;
|
|
1796
2830
|
const passages = [];
|
|
1797
2831
|
let current = null;
|
|
@@ -4626,17 +5660,21 @@ function parseDocument(data, options) {
|
|
|
4626
5660
|
* Ported from storyload.go:loadHTML().
|
|
4627
5661
|
*/
|
|
4628
5662
|
/**
|
|
4629
|
-
*
|
|
4630
|
-
*
|
|
5663
|
+
* `decompileHTML()` for loading an HTML file into a compile. The importer's StoryData passage is
|
|
5664
|
+
* checked when it is added to the story being compiled, which reports a missing or invalid IFID,
|
|
5665
|
+
* so the IFID is not reported here as well.
|
|
4631
5666
|
*/
|
|
4632
|
-
function
|
|
5667
|
+
function decompileHTMLForImport(html, options = {}) {
|
|
5668
|
+
return decompile(html, options, { ifid: false });
|
|
5669
|
+
}
|
|
5670
|
+
function decompile(html, options, checks) {
|
|
4633
5671
|
const diagnostics = [];
|
|
4634
5672
|
const story = createStory();
|
|
4635
5673
|
const passageText = options.trim === false ? (text) => text : (text) => text.trim();
|
|
4636
5674
|
const doc = parseDocument(html);
|
|
4637
5675
|
const twine2Data = findElement(doc, "tw-storydata");
|
|
4638
5676
|
if (twine2Data) {
|
|
4639
|
-
decompileTwine2(twine2Data, story, passageText, diagnostics);
|
|
5677
|
+
decompileTwine2(twine2Data, story, passageText, checks, diagnostics);
|
|
4640
5678
|
return {
|
|
4641
5679
|
story,
|
|
4642
5680
|
diagnostics
|
|
@@ -4659,7 +5697,7 @@ function decompileHTML(html, options = {}) {
|
|
|
4659
5697
|
diagnostics
|
|
4660
5698
|
};
|
|
4661
5699
|
}
|
|
4662
|
-
function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
5700
|
+
function decompileTwine2(storyData, story, passageText, checks, diagnostics) {
|
|
4663
5701
|
let startnode = 0;
|
|
4664
5702
|
const attrs = storyData.attribs ?? {};
|
|
4665
5703
|
if (attrs["name"]) story.name = attrs["name"];
|
|
@@ -4671,7 +5709,8 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4671
5709
|
});
|
|
4672
5710
|
else startnode = parsed;
|
|
4673
5711
|
}
|
|
4674
|
-
if (attrs["ifid"]) story.ifid = attrs["ifid"]
|
|
5712
|
+
if (attrs["ifid"]) story.ifid = normalizeIFID(attrs["ifid"]);
|
|
5713
|
+
if (checks.ifid) diagnostics.push(...storyDataIFIDDiagnostics(attrs["ifid"] ?? ""));
|
|
4675
5714
|
if (attrs["zoom"]) {
|
|
4676
5715
|
const parsed = parseFloat(attrs["zoom"]);
|
|
4677
5716
|
if (Number.isNaN(parsed)) diagnostics.push({
|
|
@@ -4684,7 +5723,9 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4684
5723
|
if (attrs["format"]) story.twine2.format = attrs["format"];
|
|
4685
5724
|
if (attrs["format-version"]) story.twine2.formatVersion = attrs["format-version"];
|
|
4686
5725
|
if (attrs["options"]) for (const opt of attrs["options"].split(/\s+/).filter((s) => s.length > 0)) story.twine2.options.set(opt, true);
|
|
4687
|
-
|
|
5726
|
+
const children = storyData.children ?? [];
|
|
5727
|
+
const takenNames = new Set(children.filter((node) => node.type === "tag" && node.name === "tw-passagedata").map(passageDataName));
|
|
5728
|
+
for (const node of children) {
|
|
4688
5729
|
if (node.type !== "tag" && node.type !== "style" && node.type !== "script") continue;
|
|
4689
5730
|
switch (node.name) {
|
|
4690
5731
|
case "style":
|
|
@@ -4692,11 +5733,17 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4692
5733
|
const content = getTextContent(node);
|
|
4693
5734
|
if (content.trim().length === 0) continue;
|
|
4694
5735
|
const text = passageText(content);
|
|
4695
|
-
|
|
4696
|
-
|
|
5736
|
+
const base = node.name === "style" ? "Story Stylesheet" : "Story JavaScript";
|
|
5737
|
+
const name = freeName(base, (n) => !takenNames.has(n));
|
|
5738
|
+
takenNames.add(name);
|
|
5739
|
+
storyAdd(story, withGeneratedName({
|
|
5740
|
+
name,
|
|
4697
5741
|
tags: node.name === "style" ? ["stylesheet"] : ["script"],
|
|
4698
5742
|
text
|
|
4699
|
-
},
|
|
5743
|
+
}, {
|
|
5744
|
+
kind: "code",
|
|
5745
|
+
base
|
|
5746
|
+
}), diagnostics);
|
|
4700
5747
|
break;
|
|
4701
5748
|
}
|
|
4702
5749
|
case "tw-tag": {
|
|
@@ -4709,7 +5756,7 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4709
5756
|
case "tw-passagedata": {
|
|
4710
5757
|
let pid = 0;
|
|
4711
5758
|
const pAttrs = node.attribs ?? {};
|
|
4712
|
-
const name =
|
|
5759
|
+
const name = passageDataName(node);
|
|
4713
5760
|
const tags = pAttrs["tags"] ? pAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [];
|
|
4714
5761
|
const metadata = {};
|
|
4715
5762
|
if (pAttrs["pid"]) {
|
|
@@ -4740,11 +5787,29 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4740
5787
|
text: marshalStoryData(story)
|
|
4741
5788
|
}, diagnostics);
|
|
4742
5789
|
}
|
|
5790
|
+
function passageDataName(node) {
|
|
5791
|
+
return node.attribs?.["name"] ?? "";
|
|
5792
|
+
}
|
|
5793
|
+
/** Diagnostics for the `ifid` attribute of `tw-storydata`; `value` is empty when it is missing. */
|
|
5794
|
+
function storyDataIFIDDiagnostics(value) {
|
|
5795
|
+
if (value === "") return [{
|
|
5796
|
+
level: "warning",
|
|
5797
|
+
message: "Story IFID not found; the \"tw-storydata\" content attribute \"ifid\" is missing or empty."
|
|
5798
|
+
}];
|
|
5799
|
+
const err = validateIFID(value);
|
|
5800
|
+
if (err === null) return [];
|
|
5801
|
+
return [{
|
|
5802
|
+
level: "warning",
|
|
5803
|
+
message: `Cannot parse "tw-storydata" content attribute "ifid" as an IFID; value "${value}" (${err}).`
|
|
5804
|
+
}];
|
|
5805
|
+
}
|
|
4743
5806
|
function decompileTwine1(storeArea, story, passageText, diagnostics) {
|
|
4744
5807
|
const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
|
|
4745
5808
|
const obfuscated = isRot13Obfuscated(passages);
|
|
4746
|
-
for (const p of passages) storyAdd(story, obfuscated && p
|
|
5809
|
+
for (const p of passages) storyAdd(story, obfuscated && isObfuscatable(p) ? {
|
|
4747
5810
|
...p,
|
|
5811
|
+
name: rot13(p.name),
|
|
5812
|
+
tags: p.tags.map(rot13),
|
|
4748
5813
|
text: rot13(p.text)
|
|
4749
5814
|
} : p, diagnostics);
|
|
4750
5815
|
}
|
|
@@ -4889,7 +5954,12 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
|
|
|
4889
5954
|
function loadInlineSources(story, sources, opts, diagnostics) {
|
|
4890
5955
|
for (const source of sources) {
|
|
4891
5956
|
if (typeof source === "string") continue;
|
|
4892
|
-
const
|
|
5957
|
+
const decoded = typeof source.content === "string" ? {
|
|
5958
|
+
text: source.content,
|
|
5959
|
+
diagnostics: []
|
|
5960
|
+
} : decodeText(source.content, source.filename);
|
|
5961
|
+
diagnostics.push(...decoded.diagnostics);
|
|
5962
|
+
const content = normalizeSourceText(decoded.text);
|
|
4893
5963
|
const ext = normalizedFileExt(source.filename);
|
|
4894
5964
|
switch (ext) {
|
|
4895
5965
|
case "":
|
|
@@ -4908,18 +5978,10 @@ function loadInlineSources(story, sources, opts, diagnostics) {
|
|
|
4908
5978
|
break;
|
|
4909
5979
|
}
|
|
4910
5980
|
case "css":
|
|
4911
|
-
storyAdd(story,
|
|
4912
|
-
name: basename(source.filename),
|
|
4913
|
-
tags: ["stylesheet"],
|
|
4914
|
-
text: content
|
|
4915
|
-
}, diagnostics);
|
|
5981
|
+
storyAdd(story, codePassage(basename(source.filename), "stylesheet", content), diagnostics);
|
|
4916
5982
|
break;
|
|
4917
5983
|
case "js":
|
|
4918
|
-
storyAdd(story,
|
|
4919
|
-
name: basename(source.filename),
|
|
4920
|
-
tags: ["script"],
|
|
4921
|
-
text: content
|
|
4922
|
-
}, diagnostics);
|
|
5984
|
+
storyAdd(story, codePassage(basename(source.filename), "script", content), diagnostics);
|
|
4923
5985
|
break;
|
|
4924
5986
|
default: diagnostics.push({
|
|
4925
5987
|
level: "warning",
|
|
@@ -4929,35 +5991,60 @@ function loadInlineSources(story, sources, opts, diagnostics) {
|
|
|
4929
5991
|
}
|
|
4930
5992
|
}
|
|
4931
5993
|
function parseTweeFile(filename, opts) {
|
|
4932
|
-
const
|
|
5994
|
+
const readDiagnostics = [];
|
|
5995
|
+
const result = parseTwee(readUTF8(filename, readDiagnostics), {
|
|
4933
5996
|
filename,
|
|
4934
5997
|
trim: opts.trim ?? true,
|
|
4935
5998
|
twee2Compat: opts.twee2Compat ?? false
|
|
4936
5999
|
});
|
|
4937
|
-
return {
|
|
4938
|
-
passages: result.passages,
|
|
4939
|
-
diagnostics: [...result.diagnostics]
|
|
4940
|
-
};
|
|
6000
|
+
return {
|
|
6001
|
+
passages: result.passages,
|
|
6002
|
+
diagnostics: [...readDiagnostics, ...result.diagnostics]
|
|
6003
|
+
};
|
|
6004
|
+
}
|
|
6005
|
+
/**
|
|
6006
|
+
* A stylesheet or script passage named after its file, as Tweego names it. The name only labels
|
|
6007
|
+
* the code, so when another passage has it, the passage takes a free one (see `storyAdd()`).
|
|
6008
|
+
*/
|
|
6009
|
+
function codePassage(name, tag, text) {
|
|
6010
|
+
return withGeneratedName({
|
|
6011
|
+
name,
|
|
6012
|
+
tags: [tag],
|
|
6013
|
+
text
|
|
6014
|
+
}, {
|
|
6015
|
+
kind: "code",
|
|
6016
|
+
base: name
|
|
6017
|
+
});
|
|
6018
|
+
}
|
|
6019
|
+
/** Mark every passage of a parse result as a code passage named after its file. */
|
|
6020
|
+
function codeNames(result) {
|
|
6021
|
+
for (const p of result.passages) withGeneratedName(p, {
|
|
6022
|
+
kind: "code",
|
|
6023
|
+
base: p.name
|
|
6024
|
+
});
|
|
6025
|
+
return result;
|
|
4941
6026
|
}
|
|
4942
6027
|
function parseTaggedFile(tag, filename) {
|
|
4943
|
-
const
|
|
6028
|
+
const diagnostics = [];
|
|
6029
|
+
const source = readUTF8(filename, diagnostics);
|
|
4944
6030
|
return {
|
|
4945
|
-
passages: [
|
|
4946
|
-
|
|
4947
|
-
tags: [tag],
|
|
4948
|
-
text: source
|
|
4949
|
-
}],
|
|
4950
|
-
diagnostics: []
|
|
6031
|
+
passages: [codePassage(basename(filename), tag, source)],
|
|
6032
|
+
diagnostics
|
|
4951
6033
|
};
|
|
4952
6034
|
}
|
|
4953
6035
|
function parseMediaFile(tag, filename) {
|
|
4954
6036
|
const source = readBase64(filename);
|
|
6037
|
+
const name = baseNameWithoutExt(filename);
|
|
4955
6038
|
return {
|
|
4956
|
-
passages: [{
|
|
4957
|
-
name
|
|
6039
|
+
passages: [withGeneratedName({
|
|
6040
|
+
name,
|
|
4958
6041
|
tags: [tag],
|
|
4959
6042
|
text: `data:${mediaTypeFromFilename(filename)};base64,${source}`
|
|
4960
|
-
}
|
|
6043
|
+
}, {
|
|
6044
|
+
kind: "media",
|
|
6045
|
+
base: name,
|
|
6046
|
+
file: filename
|
|
6047
|
+
})],
|
|
4961
6048
|
diagnostics: []
|
|
4962
6049
|
};
|
|
4963
6050
|
}
|
|
@@ -4966,24 +6053,27 @@ function parseFontFile(filename) {
|
|
|
4966
6053
|
const name = basename(filename);
|
|
4967
6054
|
const family = baseNameWithoutExt(filename);
|
|
4968
6055
|
const ext = normalizedFileExt(filename);
|
|
4969
|
-
|
|
6056
|
+
const mediaType = mediaTypeFromExt(ext);
|
|
6057
|
+
const hint = fontFormatHint(ext);
|
|
6058
|
+
return codeNames({
|
|
4970
6059
|
passages: [{
|
|
4971
6060
|
name,
|
|
4972
6061
|
tags: ["stylesheet"],
|
|
4973
|
-
text: `@font-face {\n\tfont-family: "${family}";\n\tsrc: url("data:${
|
|
6062
|
+
text: `@font-face {\n\tfont-family: "${cssStringEscape(family)}";\n\tsrc: url("data:${mediaType};base64,${source}") format("${hint}");\n}`
|
|
4974
6063
|
}],
|
|
4975
6064
|
diagnostics: []
|
|
4976
|
-
};
|
|
6065
|
+
});
|
|
4977
6066
|
}
|
|
4978
6067
|
function parseHTMLFile(filename, opts) {
|
|
4979
|
-
const
|
|
6068
|
+
const readDiagnostics = [];
|
|
6069
|
+
const { story, diagnostics } = decompileHTMLForImport(readUTF8(filename, readDiagnostics), { trim: opts.trim ?? true });
|
|
4980
6070
|
return {
|
|
4981
6071
|
passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
|
|
4982
6072
|
name: "StoryTitle",
|
|
4983
6073
|
tags: [],
|
|
4984
6074
|
text: story.name
|
|
4985
6075
|
}, ...story.passages] : story.passages,
|
|
4986
|
-
diagnostics: [...diagnostics]
|
|
6076
|
+
diagnostics: [...readDiagnostics, ...diagnostics]
|
|
4987
6077
|
};
|
|
4988
6078
|
}
|
|
4989
6079
|
/**
|
|
@@ -5056,6 +6146,7 @@ function loadTwee(story, filename, opts, diagnostics) {
|
|
|
5056
6146
|
}
|
|
5057
6147
|
function loadTagged(story, tag, filename, diagnostics) {
|
|
5058
6148
|
const result = parseTaggedFile(tag, filename);
|
|
6149
|
+
diagnostics.push(...result.diagnostics);
|
|
5059
6150
|
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
5060
6151
|
}
|
|
5061
6152
|
function loadMedia(story, tag, filename, diagnostics) {
|
|
@@ -5072,6 +6163,7 @@ function loadFont(story, filename, diagnostics) {
|
|
|
5072
6163
|
*/
|
|
5073
6164
|
function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
|
|
5074
6165
|
const currentFiles = /* @__PURE__ */ new Set();
|
|
6166
|
+
const changedPaths = changedFiles === void 0 ? void 0 : new Set([...changedFiles].map((f) => resolve(f)));
|
|
5075
6167
|
for (const filename of filenames) {
|
|
5076
6168
|
if (processedFiles.has(filename)) {
|
|
5077
6169
|
diagnostics.push({
|
|
@@ -5084,8 +6176,8 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5084
6176
|
const optionsKey = parseOptionsKey(filename, opts);
|
|
5085
6177
|
const entry = cache.get(filename);
|
|
5086
6178
|
const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
|
|
5087
|
-
const changed =
|
|
5088
|
-
if (
|
|
6179
|
+
const changed = changedPaths?.has(resolve(filename)) ?? false;
|
|
6180
|
+
if (changedPaths && cached && !changed) {
|
|
5089
6181
|
diagnostics.push(...cached.diagnostics);
|
|
5090
6182
|
for (const p of cached.passages) storyAdd(story, p, diagnostics);
|
|
5091
6183
|
processedFiles.add(filename);
|
|
@@ -5095,6 +6187,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5095
6187
|
try {
|
|
5096
6188
|
mtimeMs = statSync(filename).mtimeMs;
|
|
5097
6189
|
} catch {
|
|
6190
|
+
cache.delete(filename);
|
|
5098
6191
|
continue;
|
|
5099
6192
|
}
|
|
5100
6193
|
if (cached && !changed && cached.mtimeMs === mtimeMs) {
|
|
@@ -5115,6 +6208,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5115
6208
|
diagnostics.push(...result.diagnostics);
|
|
5116
6209
|
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
5117
6210
|
} catch (e) {
|
|
6211
|
+
cache.delete(filename);
|
|
5118
6212
|
diagnostics.push({
|
|
5119
6213
|
level: "error",
|
|
5120
6214
|
message: `load ${filename}: ${e instanceof Error ? e.message : String(e)}`,
|
|
@@ -5132,6 +6226,164 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5132
6226
|
}, diagnostics);
|
|
5133
6227
|
}
|
|
5134
6228
|
//#endregion
|
|
6229
|
+
//#region src/template.ts
|
|
6230
|
+
/**
|
|
6231
|
+
* An HTML closing head tag: `</head` in any letter case, followed by whitespace, `/` or `>`, up to the next `>`.
|
|
6232
|
+
* This is how the HTML tokenizer reads an end tag; it ignores whatever sits between the tag name and the `>`.
|
|
6233
|
+
*/
|
|
6234
|
+
const CLOSING_HEAD_TAG = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>";
|
|
6235
|
+
/**
|
|
6236
|
+
* The start of an HTML body start tag: `<body` in any letter case, followed by whitespace, `/` or `>` (so not
|
|
6237
|
+
* `<bodyx>`). Only the `<body` is matched; the attributes and `>` after it are left alone.
|
|
6238
|
+
*/
|
|
6239
|
+
const BODY_START_TAG = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
|
|
6240
|
+
/** The regular expression source matching exactly `text`. */
|
|
6241
|
+
function literal(text) {
|
|
6242
|
+
return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
6243
|
+
}
|
|
6244
|
+
/** Replace the `slots` placeholders found in `template`, without scanning inserted text. */
|
|
6245
|
+
function fillTemplate(template, slots) {
|
|
6246
|
+
return fillTemplateParts([{
|
|
6247
|
+
kind: "scan",
|
|
6248
|
+
text: template,
|
|
6249
|
+
slots
|
|
6250
|
+
}]);
|
|
6251
|
+
}
|
|
6252
|
+
/**
|
|
6253
|
+
* Fill the parts of a template in order, joined. A `'first'` slot replaced in one part is not replaced again in a
|
|
6254
|
+
* later part that lists the same slot object.
|
|
6255
|
+
*/
|
|
6256
|
+
function fillTemplateParts(parts) {
|
|
6257
|
+
const filled = /* @__PURE__ */ new Set();
|
|
6258
|
+
return parts.map((part) => {
|
|
6259
|
+
if (part.kind === "verbatim" || part.slots.length === 0) return part.text;
|
|
6260
|
+
const { slots } = part;
|
|
6261
|
+
const pattern = new RegExp(slots.map((slot) => `(${slot.pattern})`).join("|"), "g");
|
|
6262
|
+
return part.text.replace(pattern, (match, ...groups) => {
|
|
6263
|
+
const slot = slots.find((_, i) => groups[i] !== void 0);
|
|
6264
|
+
if (slot === void 0 || slot.occurrences === "first" && filled.has(slot)) return match;
|
|
6265
|
+
filled.add(slot);
|
|
6266
|
+
return slot.replacement(match);
|
|
6267
|
+
});
|
|
6268
|
+
}).join("");
|
|
6269
|
+
}
|
|
6270
|
+
//#endregion
|
|
6271
|
+
//#region src/modules.ts
|
|
6272
|
+
/**
|
|
6273
|
+
* Load modules and return HTML tags to inject before the closing head tag. `diagnostics` receives a warning for
|
|
6274
|
+
* each module that is not valid UTF-8 (it is read as Windows-1252).
|
|
6275
|
+
*/
|
|
6276
|
+
function loadModules(filenames, diagnostics) {
|
|
6277
|
+
const processed = /* @__PURE__ */ new Set();
|
|
6278
|
+
const headTags = [];
|
|
6279
|
+
for (const filename of filenames) {
|
|
6280
|
+
if (processed.has(filename)) continue;
|
|
6281
|
+
const ext = normalizedFileExt(filename);
|
|
6282
|
+
let tag = null;
|
|
6283
|
+
switch (ext) {
|
|
6284
|
+
case "css":
|
|
6285
|
+
tag = loadModuleTagged("style", filename, diagnostics);
|
|
6286
|
+
break;
|
|
6287
|
+
case "js":
|
|
6288
|
+
tag = loadModuleTagged("script", filename, diagnostics);
|
|
6289
|
+
break;
|
|
6290
|
+
case "otf":
|
|
6291
|
+
case "ttf":
|
|
6292
|
+
case "woff":
|
|
6293
|
+
case "woff2":
|
|
6294
|
+
tag = loadModuleFont(filename);
|
|
6295
|
+
break;
|
|
6296
|
+
default: continue;
|
|
6297
|
+
}
|
|
6298
|
+
if (tag) headTags.push(tag);
|
|
6299
|
+
processed.add(filename);
|
|
6300
|
+
}
|
|
6301
|
+
return headTags.join("\n");
|
|
6302
|
+
}
|
|
6303
|
+
function loadModuleTagged(tag, filename, diagnostics) {
|
|
6304
|
+
const source = readUTF8(filename, diagnostics).trim();
|
|
6305
|
+
if (source.length === 0) return null;
|
|
6306
|
+
return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
|
|
6307
|
+
}
|
|
6308
|
+
function loadModuleFont(filename) {
|
|
6309
|
+
const source = readBase64(filename);
|
|
6310
|
+
const family = baseNameWithoutExt(filename);
|
|
6311
|
+
const idSlug = `style-module-${slugify(family)}`;
|
|
6312
|
+
const ext = normalizedFileExt(filename);
|
|
6313
|
+
const mediaType = mediaTypeFromExt(ext);
|
|
6314
|
+
const hint = fontFormatHint(ext);
|
|
6315
|
+
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>`;
|
|
6316
|
+
}
|
|
6317
|
+
/**
|
|
6318
|
+
* Load the module tags and head file content to inject into the head (see `placeHead()`), joined by newlines,
|
|
6319
|
+
* or `''` when there is nothing to inject.
|
|
6320
|
+
*/
|
|
6321
|
+
function loadHeadContent(modulePaths, headFile, diagnostics) {
|
|
6322
|
+
const parts = [];
|
|
6323
|
+
if (modulePaths.length > 0) {
|
|
6324
|
+
const modules = loadModules(modulePaths, diagnostics).trim();
|
|
6325
|
+
if (modules.length > 0) parts.push(modules);
|
|
6326
|
+
}
|
|
6327
|
+
if (headFile) try {
|
|
6328
|
+
const source = readUTF8(headFile, diagnostics).trim();
|
|
6329
|
+
if (source.length > 0) parts.push(source);
|
|
6330
|
+
} catch (e) {
|
|
6331
|
+
diagnostics?.push({
|
|
6332
|
+
level: "warning",
|
|
6333
|
+
message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
|
|
6334
|
+
});
|
|
6335
|
+
}
|
|
6336
|
+
return parts.join("\n");
|
|
6337
|
+
}
|
|
6338
|
+
/**
|
|
6339
|
+
* Place `content` in a template, given the texts that `fillTemplateParts()` scans for the slot (the template, and
|
|
6340
|
+
* the footer of a pre-1.4 Twine 1 format):
|
|
6341
|
+
*
|
|
6342
|
+
* - on its own line before the first closing head tag, as Tweego does; the tag may use any letter case and
|
|
6343
|
+
* whitespace before its `>`, and is kept as written;
|
|
6344
|
+
* - in a template with no closing head tag (HTML lets a document leave it out), on its own line before the first
|
|
6345
|
+
* body start tag, where the HTML parser still puts it in the head, with a warning (Tweego drops it);
|
|
6346
|
+
* - in a template with neither, nowhere, with a warning.
|
|
6347
|
+
*
|
|
6348
|
+
* `owner` names the template in the warnings. Empty `content` needs no slot and gets no warning.
|
|
6349
|
+
*/
|
|
6350
|
+
function placeHead(content, texts, owner) {
|
|
6351
|
+
if (content.length === 0) return {
|
|
6352
|
+
slot: void 0,
|
|
6353
|
+
diagnostics: []
|
|
6354
|
+
};
|
|
6355
|
+
const found = (tag) => texts.some((text) => new RegExp(tag).test(text));
|
|
6356
|
+
if (found("<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>")) return {
|
|
6357
|
+
slot: beforeTag(CLOSING_HEAD_TAG, content),
|
|
6358
|
+
diagnostics: []
|
|
6359
|
+
};
|
|
6360
|
+
if (found("<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])")) {
|
|
6361
|
+
const message = `${owner} has no closing head tag; the modules and head file were injected before its body start tag.`;
|
|
6362
|
+
return {
|
|
6363
|
+
slot: beforeTag(BODY_START_TAG, content),
|
|
6364
|
+
diagnostics: [{
|
|
6365
|
+
level: "warning",
|
|
6366
|
+
message
|
|
6367
|
+
}]
|
|
6368
|
+
};
|
|
6369
|
+
}
|
|
6370
|
+
return {
|
|
6371
|
+
slot: void 0,
|
|
6372
|
+
diagnostics: [{
|
|
6373
|
+
level: "warning",
|
|
6374
|
+
message: `${owner} has no closing head tag and no body start tag; the modules and head file were not injected.`
|
|
6375
|
+
}]
|
|
6376
|
+
};
|
|
6377
|
+
}
|
|
6378
|
+
/** The slot that inserts `content` on its own line before the first match of `tag`, which is kept as written. */
|
|
6379
|
+
function beforeTag(tag, content) {
|
|
6380
|
+
return {
|
|
6381
|
+
pattern: tag,
|
|
6382
|
+
occurrences: "first",
|
|
6383
|
+
replacement: (match) => `${content}\n${match}`
|
|
6384
|
+
};
|
|
6385
|
+
}
|
|
6386
|
+
//#endregion
|
|
5135
6387
|
//#region src/version.ts
|
|
5136
6388
|
/**
|
|
5137
6389
|
* The twee-ts version, read at runtime from the package's own package.json.
|
|
@@ -5165,24 +6417,42 @@ const CREATOR_NAME$2 = "Twee-ts";
|
|
|
5165
6417
|
function toTwine2Archive(story, startName, options) {
|
|
5166
6418
|
return getTwine2DataChunk(story, startName, options) + "\n";
|
|
5167
6419
|
}
|
|
6420
|
+
/**
|
|
6421
|
+
* Fill the Twine 2 format template: every `{{STORY_NAME}}` gets the escaped story name and the first
|
|
6422
|
+
* `{{STORY_DATA}}` the story data (as Tweego does), and `head` goes before the first closing head tag (or, with a
|
|
6423
|
+
* warning, the body start tag; see `placeHead()`). All three are found in the template in one pass, so a story
|
|
6424
|
+
* name, passage text or head content holding a placeholder or a closing head tag stays literal.
|
|
6425
|
+
* `options.diagnostics` receives a warning when the format file is not valid UTF-8, and any warning about where
|
|
6426
|
+
* `head` went.
|
|
6427
|
+
*/
|
|
5168
6428
|
function toTwine2HTML(story, format, startName, options) {
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
6429
|
+
const name = htmlEscape(story.name);
|
|
6430
|
+
const template = readFormatSource(format, options?.diagnostics);
|
|
6431
|
+
const placement = placeHead(options?.head ?? "", [template], `Story format "${format.name}" ${format.version}`);
|
|
6432
|
+
options?.diagnostics?.push(...placement.diagnostics);
|
|
6433
|
+
const head = placement.slot;
|
|
6434
|
+
return fillTemplate(template, [
|
|
6435
|
+
{
|
|
6436
|
+
pattern: literal("{{STORY_NAME}}"),
|
|
6437
|
+
occurrences: "all",
|
|
6438
|
+
replacement: () => name
|
|
6439
|
+
},
|
|
6440
|
+
{
|
|
6441
|
+
pattern: literal("{{STORY_DATA}}"),
|
|
6442
|
+
occurrences: "first",
|
|
6443
|
+
replacement: () => {
|
|
6444
|
+
return getTwine2DataChunk({
|
|
6445
|
+
...story,
|
|
6446
|
+
twine2: {
|
|
6447
|
+
...story.twine2,
|
|
6448
|
+
format: format.name,
|
|
6449
|
+
formatVersion: format.version
|
|
6450
|
+
}
|
|
6451
|
+
}, startName, options);
|
|
5181
6452
|
}
|
|
5182
|
-
},
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
return template;
|
|
6453
|
+
},
|
|
6454
|
+
...head === void 0 ? [] : [head]
|
|
6455
|
+
]);
|
|
5186
6456
|
}
|
|
5187
6457
|
const OMITTING_TAGS = [
|
|
5188
6458
|
"Twine.private",
|
|
@@ -5275,39 +6545,83 @@ function toTwine1Archive(story, _startName) {
|
|
|
5275
6545
|
const { data, count } = getTwine1PassageChunk(story);
|
|
5276
6546
|
return `<div id="storeArea" data-size="${count}">${data}</div>\n`;
|
|
5277
6547
|
}
|
|
5278
|
-
|
|
6548
|
+
/**
|
|
6549
|
+
* Fill the Twine 1 format template. The format's components are inserted first, one after another, as Tweego and
|
|
6550
|
+
* Twine 1 do. Then the first of each story placeholder (`"VERSION"`, `"TIME"`, `"START_AT"`, `"STORY_SIZE"`,
|
|
6551
|
+
* `"STORY"`), the IFID comment and `head` (before the first closing head tag or, with a warning, the body start
|
|
6552
|
+
* tag; see `placeHead()`) are found in one pass, so a start passage name or story data holding a placeholder or a
|
|
6553
|
+
* closing head tag stays literal. `options.diagnostics` receives a warning for each format file that is not valid
|
|
6554
|
+
* UTF-8, and any warning about where `head` went.
|
|
6555
|
+
*/
|
|
6556
|
+
function toTwine1HTML(story, format, startName, options) {
|
|
5279
6557
|
const formatDir = dirname(format.filename);
|
|
5280
6558
|
const parentDir = dirname(formatDir);
|
|
5281
|
-
|
|
6559
|
+
const diagnostics = options?.diagnostics;
|
|
6560
|
+
let template = readFormatSource(format, diagnostics);
|
|
5282
6561
|
const { data, count } = getTwine1PassageChunk(story);
|
|
5283
|
-
template = tryReplaceComponent(template, "\"USER_LIB\"", join(formatDir, "userlib.js"), false);
|
|
5284
|
-
template = tryReplaceComponent(template, "\"ENGINE\"", join(parentDir, "engine.js"), true);
|
|
5285
|
-
template = tryReplaceComponent(template, "\"SUGARCANE\"", join(formatDir, "code.js"), true);
|
|
5286
|
-
template = tryReplaceComponent(template, "\"JONAH\"", join(formatDir, "code.js"), true);
|
|
5287
|
-
if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", join(parentDir, "jquery.js"), true);
|
|
5288
|
-
if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", join(parentDir, "modernizr.js"), true);
|
|
6562
|
+
template = tryReplaceComponent(template, "\"USER_LIB\"", join(formatDir, "userlib.js"), false, diagnostics);
|
|
6563
|
+
template = tryReplaceComponent(template, "\"ENGINE\"", join(parentDir, "engine.js"), true, diagnostics);
|
|
6564
|
+
template = tryReplaceComponent(template, "\"SUGARCANE\"", join(formatDir, "code.js"), true, diagnostics);
|
|
6565
|
+
template = tryReplaceComponent(template, "\"JONAH\"", join(formatDir, "code.js"), true, diagnostics);
|
|
6566
|
+
if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", join(parentDir, "jquery.js"), true, diagnostics);
|
|
6567
|
+
if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", join(parentDir, "modernizr.js"), true, diagnostics);
|
|
6568
|
+
const isPre14 = !template.includes("\"STORY\"");
|
|
6569
|
+
const footer = isPre14 ? readFooter(formatDir, diagnostics) : "";
|
|
6570
|
+
const storeArea = (template + footer).includes("<div id=\"store-area\"") ? "<div id=\"store-area\"" : "<div id=\"storeArea\"";
|
|
6571
|
+
const safeIfid = htmlCommentSanitize(story.ifid);
|
|
6572
|
+
const ifid = story.ifid ? {
|
|
6573
|
+
pattern: literal(storeArea),
|
|
6574
|
+
occurrences: "first",
|
|
6575
|
+
replacement: (div) => `<!-- UUID://${safeIfid}// -->${div}`
|
|
6576
|
+
} : void 0;
|
|
6577
|
+
const placement = placeHead(options?.head ?? "", isPre14 ? [template, footer] : [template], `Story format "${format.name}"`);
|
|
6578
|
+
diagnostics?.push(...placement.diagnostics);
|
|
6579
|
+
const late = [ifid, placement.slot].filter((slot) => slot !== void 0);
|
|
5289
6580
|
const displayStart = startName === "Start" ? "" : startName;
|
|
5290
|
-
|
|
5291
|
-
|
|
5292
|
-
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
5297
|
-
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
5302
|
-
|
|
5303
|
-
|
|
5304
|
-
|
|
5305
|
-
|
|
5306
|
-
|
|
5307
|
-
|
|
5308
|
-
|
|
6581
|
+
const slots = [
|
|
6582
|
+
firstSlot("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`),
|
|
6583
|
+
firstSlot("\"TIME\"", `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`),
|
|
6584
|
+
firstSlot("\"START_AT\"", `"${jsStringEscape(displayStart)}"`),
|
|
6585
|
+
firstSlot("\"STORY_SIZE\"", `"${count}"`),
|
|
6586
|
+
firstSlot("\"STORY\"", data),
|
|
6587
|
+
...late
|
|
6588
|
+
];
|
|
6589
|
+
return fillTemplateParts(isPre14 ? [
|
|
6590
|
+
{
|
|
6591
|
+
kind: "scan",
|
|
6592
|
+
text: template,
|
|
6593
|
+
slots
|
|
6594
|
+
},
|
|
6595
|
+
{
|
|
6596
|
+
kind: "verbatim",
|
|
6597
|
+
text: data
|
|
6598
|
+
},
|
|
6599
|
+
{
|
|
6600
|
+
kind: "scan",
|
|
6601
|
+
text: footer,
|
|
6602
|
+
slots: late
|
|
6603
|
+
}
|
|
6604
|
+
] : [{
|
|
6605
|
+
kind: "scan",
|
|
6606
|
+
text: template,
|
|
6607
|
+
slots
|
|
6608
|
+
}]);
|
|
6609
|
+
}
|
|
6610
|
+
/** A slot replacing the first `token` with `value`. */
|
|
6611
|
+
function firstSlot(token, value) {
|
|
6612
|
+
return {
|
|
6613
|
+
pattern: literal(token),
|
|
6614
|
+
occurrences: "first",
|
|
6615
|
+
replacement: () => value
|
|
6616
|
+
};
|
|
6617
|
+
}
|
|
6618
|
+
/** The footer of a pre-1.4 format, or the default one when the format has none. */
|
|
6619
|
+
function readFooter(formatDir, diagnostics) {
|
|
6620
|
+
try {
|
|
6621
|
+
return readUTF8(join(formatDir, "footer.html"), diagnostics);
|
|
6622
|
+
} catch {
|
|
6623
|
+
return "</div>\n</body>\n</html>\n";
|
|
5309
6624
|
}
|
|
5310
|
-
return template;
|
|
5311
6625
|
}
|
|
5312
6626
|
/**
|
|
5313
6627
|
* Why Twine 1 output leaves a passage out of its tiddlers, or `undefined` when the passage is emitted.
|
|
@@ -5332,11 +6646,10 @@ function getTwine1PassageChunk(story) {
|
|
|
5332
6646
|
count
|
|
5333
6647
|
};
|
|
5334
6648
|
}
|
|
5335
|
-
function tryReplaceComponent(template, placeholder, componentPath, required) {
|
|
6649
|
+
function tryReplaceComponent(template, placeholder, componentPath, required, diagnostics) {
|
|
5336
6650
|
if (!template.includes(placeholder)) return template;
|
|
5337
6651
|
try {
|
|
5338
|
-
|
|
5339
|
-
if (content.charCodeAt(0) === 65279) content = content.slice(1);
|
|
6652
|
+
const content = readUTF8(componentPath, diagnostics);
|
|
5340
6653
|
return template.replace(placeholder, () => content);
|
|
5341
6654
|
} catch (e) {
|
|
5342
6655
|
if (required) throw new Error(`Required format component not found: ${componentPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -5350,11 +6663,16 @@ function tryReplaceComponent(template, placeholder, componentPath, required) {
|
|
|
5350
6663
|
* from its loaded text, so changes made after loading (a start passage override, test mode) are kept.
|
|
5351
6664
|
* Tweego writes the loaded text; its own normalization makes the two the same for an unchanged story.
|
|
5352
6665
|
*
|
|
6666
|
+
* Twee cannot write every passage so that it reads back the same (see `tweeRoundTrip()`); `diagnostics`
|
|
6667
|
+
* receives a warning for each passage that will not. Tweego writes the same Twee without one.
|
|
6668
|
+
*
|
|
5353
6669
|
* `addStoryData`: add a StoryData passage when the story has none, so that StoryData fields the
|
|
5354
6670
|
* compile options set are recorded. Default: false.
|
|
5355
6671
|
*/
|
|
5356
6672
|
function toTwee(story, outMode, options = {}) {
|
|
5357
|
-
|
|
6673
|
+
const written = withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => tweeRoundTrip(p, outMode));
|
|
6674
|
+
options.diagnostics?.push(...written.flatMap(({ diagnostics }) => diagnostics));
|
|
6675
|
+
return written.map(({ passage }) => passageToTwee(passage, outMode)).join("");
|
|
5358
6676
|
}
|
|
5359
6677
|
function withEffectiveStoryData(story, addStoryData) {
|
|
5360
6678
|
const storyData = {
|
|
@@ -5379,74 +6697,63 @@ function withEffectiveStoryData(story, addStoryData) {
|
|
|
5379
6697
|
function isStoryDataJSON(text) {
|
|
5380
6698
|
return unmarshalStoryData(createStory(), text) === null;
|
|
5381
6699
|
}
|
|
5382
|
-
|
|
5383
|
-
|
|
5384
|
-
/**
|
|
5385
|
-
* Load modules and return HTML tags to inject before </head>.
|
|
5386
|
-
*/
|
|
5387
|
-
function loadModules(filenames) {
|
|
5388
|
-
const processed = /* @__PURE__ */ new Set();
|
|
5389
|
-
const headTags = [];
|
|
5390
|
-
for (const filename of filenames) {
|
|
5391
|
-
if (processed.has(filename)) continue;
|
|
5392
|
-
const ext = normalizedFileExt(filename);
|
|
5393
|
-
let tag = null;
|
|
5394
|
-
switch (ext) {
|
|
5395
|
-
case "css":
|
|
5396
|
-
tag = loadModuleTagged("style", filename);
|
|
5397
|
-
break;
|
|
5398
|
-
case "js":
|
|
5399
|
-
tag = loadModuleTagged("script", filename);
|
|
5400
|
-
break;
|
|
5401
|
-
case "otf":
|
|
5402
|
-
case "ttf":
|
|
5403
|
-
case "woff":
|
|
5404
|
-
case "woff2":
|
|
5405
|
-
tag = loadModuleFont(filename);
|
|
5406
|
-
break;
|
|
5407
|
-
default: continue;
|
|
5408
|
-
}
|
|
5409
|
-
if (tag) headTags.push(tag);
|
|
5410
|
-
processed.add(filename);
|
|
5411
|
-
}
|
|
5412
|
-
return headTags.join("\n");
|
|
5413
|
-
}
|
|
5414
|
-
function loadModuleTagged(tag, filename) {
|
|
5415
|
-
const source = readUTF8(filename).trim();
|
|
5416
|
-
if (source.length === 0) return null;
|
|
5417
|
-
return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
|
|
5418
|
-
}
|
|
5419
|
-
function loadModuleFont(filename) {
|
|
5420
|
-
const source = readBase64(filename);
|
|
5421
|
-
const family = baseNameWithoutExt(filename);
|
|
5422
|
-
const idSlug = `style-module-${slugify(family)}`;
|
|
5423
|
-
const ext = normalizedFileExt(filename);
|
|
5424
|
-
return `<style id="${idSlug}" type="text/css">@font-face {\n\tfont-family: "${family}";\n\tsrc: url("data:${mediaTypeFromExt(ext)};base64,${source}") format("${fontFormatHint(ext)}");\n}</style>`;
|
|
5425
|
-
}
|
|
6700
|
+
/** Characters Twee 3 escapes in names and tags, and Twee 1 cannot write there at all. */
|
|
6701
|
+
const TWEE1_UNWRITABLE = /[[\]{}\\]/;
|
|
5426
6702
|
/**
|
|
5427
|
-
*
|
|
5428
|
-
|
|
5429
|
-
|
|
5430
|
-
|
|
5431
|
-
|
|
5432
|
-
|
|
5433
|
-
|
|
5434
|
-
|
|
5435
|
-
|
|
5436
|
-
|
|
5437
|
-
|
|
5438
|
-
|
|
5439
|
-
|
|
6703
|
+
* Check that Twee reads `p` back as written, and say what it will not. Twee has no way to escape passage text,
|
|
6704
|
+
* and its parser trims names and splits tags at whitespace, so these do not survive:
|
|
6705
|
+
*
|
|
6706
|
+
* - a text line that starts with `::`, which reads as a new passage header. In stylesheet and script passages such
|
|
6707
|
+
* a line is indented by one space, which keeps the CSS or JavaScript working (only a template literal or a
|
|
6708
|
+
* string continued across lines, in either, would see the space); the warning says so. Other passages' text is
|
|
6709
|
+
* written as it is.
|
|
6710
|
+
* - a name that is empty, has leading or trailing whitespace, or holds a line break;
|
|
6711
|
+
* - a tag that is empty or holds whitespace;
|
|
6712
|
+
* - in Twee 1, which escapes nothing, a name or tag holding `[`, `]`, `{`, `}` or `\`.
|
|
6713
|
+
*/
|
|
6714
|
+
function tweeRoundTrip(p, outMode) {
|
|
6715
|
+
const label = `Passage ${JSON.stringify(p.name)}`;
|
|
6716
|
+
const diagnostics = [];
|
|
6717
|
+
const warn = (problem) => {
|
|
6718
|
+
diagnostics.push({
|
|
5440
6719
|
level: "warning",
|
|
5441
|
-
message:
|
|
6720
|
+
message: `${label} cannot be written as Twee that reads back the same: ${problem}.`
|
|
5442
6721
|
});
|
|
6722
|
+
};
|
|
6723
|
+
if (p.name.trim() === "") warn("its name is empty");
|
|
6724
|
+
else if (p.name.trim() !== p.name) warn("its name has leading or trailing whitespace, which Twee drops");
|
|
6725
|
+
if (/[\r\n]/.test(p.name)) warn("its name has a line break, which ends a Twee passage header");
|
|
6726
|
+
if (outMode === "twee1" && TWEE1_UNWRITABLE.test(p.name)) warn("its name has \"[\", \"]\", \"{\", \"}\" or \"\\\", which Twee 1 cannot escape");
|
|
6727
|
+
for (const tag of p.tags) {
|
|
6728
|
+
if (tag === "" || /\s/.test(tag)) warn(`its tag ${JSON.stringify(tag)} is empty or has whitespace, which splits tags in Twee`);
|
|
6729
|
+
if (outMode === "twee1" && TWEE1_UNWRITABLE.test(tag)) warn(`its tag ${JSON.stringify(tag)} has "[", "]", "{", "}" or "\\", which Twee 1 cannot escape`);
|
|
6730
|
+
}
|
|
6731
|
+
const lines = p.text.split(/(\r\n?|\n)/);
|
|
6732
|
+
const headerLines = lines.flatMap((line, i) => i % 2 === 0 && line.startsWith("::") ? [i / 2 + 1] : []);
|
|
6733
|
+
if (headerLines.length === 0) return {
|
|
6734
|
+
passage: p,
|
|
6735
|
+
diagnostics
|
|
6736
|
+
};
|
|
6737
|
+
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`;
|
|
6738
|
+
if (!p.tags.some((tag) => tag === "stylesheet" || tag === "script")) {
|
|
6739
|
+
warn(where);
|
|
6740
|
+
return {
|
|
6741
|
+
passage: p,
|
|
6742
|
+
diagnostics
|
|
6743
|
+
};
|
|
5443
6744
|
}
|
|
5444
|
-
|
|
5445
|
-
|
|
5446
|
-
|
|
5447
|
-
|
|
5448
|
-
}
|
|
5449
|
-
return
|
|
6745
|
+
diagnostics.push({
|
|
6746
|
+
level: "warning",
|
|
6747
|
+
message: `${label}: ${where}; ${headerLines.length === 1 ? "it was" : "they were"} written indented by one space.`
|
|
6748
|
+
});
|
|
6749
|
+
const text = lines.map((line, i) => i % 2 === 0 && line.startsWith("::") ? ` ${line}` : line).join("");
|
|
6750
|
+
return {
|
|
6751
|
+
passage: {
|
|
6752
|
+
...p,
|
|
6753
|
+
text
|
|
6754
|
+
},
|
|
6755
|
+
diagnostics
|
|
6756
|
+
};
|
|
5450
6757
|
}
|
|
5451
6758
|
//#endregion
|
|
5452
6759
|
//#region src/passage-omission.ts
|
|
@@ -5508,15 +6815,19 @@ var TweeTsError = class extends Error {
|
|
|
5508
6815
|
}
|
|
5509
6816
|
};
|
|
5510
6817
|
/**
|
|
5511
|
-
* Builds as compileToFile() does for `
|
|
6818
|
+
* Builds as compileToFile() does for `outputs`, without writing them: an output may sit
|
|
5512
6819
|
* inside a source folder, so its last build is left out of the sources and modules and
|
|
5513
|
-
* never read back.
|
|
5514
|
-
* (
|
|
6820
|
+
* never read back. `outputs` is the one file the CLI writes, or every path a bundler's
|
|
6821
|
+
* build writes (see BuildOutputs). The caller decides whether the result is written.
|
|
6822
|
+
* With no outputs (output to stdout, or none), this is compile(). With `cache`, files
|
|
6823
|
+
* are cached as compileIncremental() caches them.
|
|
5515
6824
|
*
|
|
5516
|
-
*
|
|
6825
|
+
* Throws a TweeTsError when a source, module or head file named directly is an output.
|
|
6826
|
+
*
|
|
6827
|
+
* Internal, for the CLI and the bundler plugins; not part of the public API.
|
|
5517
6828
|
*/
|
|
5518
|
-
async function compileForOutputFile(options,
|
|
5519
|
-
return buildOutput(options,
|
|
6829
|
+
async function compileForOutputFile(options, outputs, cache) {
|
|
6830
|
+
return buildOutput(options, cache, void 0, outputs);
|
|
5520
6831
|
}
|
|
5521
6832
|
/**
|
|
5522
6833
|
* watch(), writing to `outFile` only the builds `shouldWrite` accepts. A rejected build
|
|
@@ -5525,9 +6836,13 @@ async function compileForOutputFile(options, outFile) {
|
|
|
5525
6836
|
* Internal, for the CLI, which keeps the last good output when a rebuild reports errors;
|
|
5526
6837
|
* not part of the public API.
|
|
5527
6838
|
*/
|
|
5528
|
-
async function watchWithWriteFilter(options, shouldWrite) {
|
|
6839
|
+
async function watchWithWriteFilter(options, shouldWrite, hooks = {}) {
|
|
5529
6840
|
const controller = new AbortController();
|
|
5530
6841
|
const cache = /* @__PURE__ */ new Map();
|
|
6842
|
+
const buildOptions = {
|
|
6843
|
+
...options,
|
|
6844
|
+
signal: controller.signal
|
|
6845
|
+
};
|
|
5531
6846
|
const filePaths = options.sources.filter((s) => typeof s === "string");
|
|
5532
6847
|
const modulePaths = options.modules ?? [];
|
|
5533
6848
|
const headPaths = options.headFile ? [options.headFile] : [];
|
|
@@ -5543,22 +6858,82 @@ async function watchWithWriteFilter(options, shouldWrite) {
|
|
|
5543
6858
|
const abs = resolve(filename);
|
|
5544
6859
|
return !notExcludable.some((root) => abs === root || abs.startsWith(root + sep));
|
|
5545
6860
|
};
|
|
6861
|
+
let building = false;
|
|
6862
|
+
let queued;
|
|
6863
|
+
const reportError = (error) => {
|
|
6864
|
+
try {
|
|
6865
|
+
options.onError?.(error);
|
|
6866
|
+
} catch (e) {
|
|
6867
|
+
console.error(`twee-ts watch: onError threw while handling "${error.message}": ${toError(e).message}`);
|
|
6868
|
+
}
|
|
6869
|
+
};
|
|
6870
|
+
const deliver = (outcome) => {
|
|
6871
|
+
try {
|
|
6872
|
+
if (!outcome.ok) throw outcome.error;
|
|
6873
|
+
if (shouldWrite(outcome.result)) writeFileAtomic(options.outFile, outcome.result.output);
|
|
6874
|
+
options.onBuild?.(outcome.result);
|
|
6875
|
+
} catch (e) {
|
|
6876
|
+
reportError(toError(e));
|
|
6877
|
+
}
|
|
6878
|
+
};
|
|
6879
|
+
const drain = async (first) => {
|
|
6880
|
+
building = true;
|
|
6881
|
+
try {
|
|
6882
|
+
let request = first;
|
|
6883
|
+
while (request !== void 0) {
|
|
6884
|
+
const outcome = await buildOutput(buildOptions, cache, request.changedFiles, options.outFile).then((result) => ({
|
|
6885
|
+
ok: true,
|
|
6886
|
+
result
|
|
6887
|
+
}), (e) => ({
|
|
6888
|
+
ok: false,
|
|
6889
|
+
error: toError(e)
|
|
6890
|
+
}));
|
|
6891
|
+
if (controller.signal.aborted) return;
|
|
6892
|
+
deliver(outcome);
|
|
6893
|
+
request = queued;
|
|
6894
|
+
queued = void 0;
|
|
6895
|
+
}
|
|
6896
|
+
} finally {
|
|
6897
|
+
building = false;
|
|
6898
|
+
hooks.onIdle?.();
|
|
6899
|
+
}
|
|
6900
|
+
};
|
|
5546
6901
|
const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
|
|
5547
|
-
|
|
5548
|
-
|
|
5549
|
-
|
|
5550
|
-
|
|
5551
|
-
|
|
5552
|
-
}
|
|
5553
|
-
|
|
5554
|
-
|
|
6902
|
+
if (controller.signal.aborted) return;
|
|
6903
|
+
const request = { changedFiles };
|
|
6904
|
+
if (building) {
|
|
6905
|
+
queued = queued === void 0 ? request : mergeBuildRequests(queued, request);
|
|
6906
|
+
return;
|
|
6907
|
+
}
|
|
6908
|
+
drain(request).catch((e) => reportError(toError(e)));
|
|
6909
|
+
}, ignore, reportError);
|
|
6910
|
+
const outer = options.signal;
|
|
6911
|
+
const onOuterAbort = () => controller.abort(outer?.reason);
|
|
6912
|
+
controller.signal.addEventListener("abort", () => {
|
|
6913
|
+
queued = void 0;
|
|
6914
|
+
handle.close();
|
|
6915
|
+
outer?.removeEventListener("abort", onOuterAbort);
|
|
6916
|
+
});
|
|
6917
|
+
if (outer?.aborted) onOuterAbort();
|
|
6918
|
+
else outer?.addEventListener("abort", onOuterAbort, { once: true });
|
|
5555
6919
|
return controller;
|
|
5556
6920
|
}
|
|
6921
|
+
/** One build covering both requests: a full build if either is one, else every changed file. */
|
|
6922
|
+
function mergeBuildRequests(a, b) {
|
|
6923
|
+
if (a.changedFiles === void 0 || b.changedFiles === void 0) return { changedFiles: void 0 };
|
|
6924
|
+
return { changedFiles: /* @__PURE__ */ new Set([...a.changedFiles, ...b.changedFiles]) };
|
|
6925
|
+
}
|
|
6926
|
+
function toError(e) {
|
|
6927
|
+
return e instanceof Error ? e : new Error(String(e));
|
|
6928
|
+
}
|
|
5557
6929
|
/**
|
|
5558
|
-
* `
|
|
5559
|
-
*
|
|
6930
|
+
* `outputs`: what the build writes (an output file's path, or a bundler's outputs),
|
|
6931
|
+
* which source and module discovery skip, so an output inside a source folder is
|
|
6932
|
+
* never loaded back. A source, module or head file named directly that is an output
|
|
6933
|
+
* is a TweeTsError, as in Tweego: writing the build would overwrite it.
|
|
5560
6934
|
*/
|
|
5561
|
-
async function buildOutput(options, cache, changedFiles,
|
|
6935
|
+
async function buildOutput(options, cache, changedFiles, outputs) {
|
|
6936
|
+
const written = toBuildOutputs(outputs);
|
|
5562
6937
|
const diagnostics = [];
|
|
5563
6938
|
const outputMode = options.outputMode ?? "html";
|
|
5564
6939
|
const trim = options.trim ?? true;
|
|
@@ -5566,13 +6941,18 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5566
6941
|
const testMode = options.testMode ?? false;
|
|
5567
6942
|
const noRemote = options.noRemote ?? false;
|
|
5568
6943
|
const sourceInfo = options.sourceInfo ?? false;
|
|
6944
|
+
options.signal?.throwIfAborted();
|
|
6945
|
+
const timeout = options.formatFetchTimeout;
|
|
6946
|
+
if (timeout !== void 0 && !(timeout >= 0)) throw new TweeTsError(`formatFetchTimeout must be 0 or more milliseconds, not ${timeout}.`);
|
|
5569
6947
|
clearIndexCache();
|
|
5570
6948
|
const filePaths = [];
|
|
5571
6949
|
const inlineSources = [];
|
|
5572
6950
|
for (const source of options.sources) if (typeof source === "string") filePaths.push(source);
|
|
5573
6951
|
else inlineSources.push(source);
|
|
5574
|
-
const
|
|
5575
|
-
diagnostics.push(...
|
|
6952
|
+
const sourcePaths = getFilenames(filePaths, written, options.exclude);
|
|
6953
|
+
diagnostics.push(...sourcePaths.diagnostics);
|
|
6954
|
+
rejectOutputSources(sourcePaths.outputSources, diagnostics);
|
|
6955
|
+
const sourceFilenames = sourcePaths.filenames;
|
|
5576
6956
|
const story = createStory();
|
|
5577
6957
|
const processedFiles = /* @__PURE__ */ new Set();
|
|
5578
6958
|
if (cache) loadSourcesCached(story, sourceFilenames, {
|
|
@@ -5601,7 +6981,10 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5601
6981
|
switch (outputMode) {
|
|
5602
6982
|
case "twee3":
|
|
5603
6983
|
case "twee1":
|
|
5604
|
-
output = toTwee(story, outputMode, {
|
|
6984
|
+
output = toTwee(story, outputMode, {
|
|
6985
|
+
addStoryData: Boolean(options.startPassage) || testMode,
|
|
6986
|
+
diagnostics
|
|
6987
|
+
});
|
|
5605
6988
|
break;
|
|
5606
6989
|
case "twine2-archive":
|
|
5607
6990
|
output = toTwine2Archive(story, startName, { sourceInfo });
|
|
@@ -5615,17 +6998,24 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5615
6998
|
case "html": {
|
|
5616
6999
|
diagnostics.push(...startPassageDiagnostics(story, startName, format?.isTwine2 === false ? "twine1" : "twine2"));
|
|
5617
7000
|
if (!format) throw new TweeTsError("No story format available for HTML output.", diagnostics);
|
|
5618
|
-
if (format.isTwine2
|
|
5619
|
-
|
|
5620
|
-
|
|
5621
|
-
|
|
5622
|
-
|
|
5623
|
-
});
|
|
5624
|
-
output = toTwine1HTML(story, format, startName);
|
|
5625
|
-
}
|
|
5626
|
-
const modules = getFilenames(options.modules ?? [], outFile);
|
|
7001
|
+
if (!format.isTwine2 && story.name === "" && !storyHas(story, "StoryTitle")) diagnostics.push({
|
|
7002
|
+
level: "error",
|
|
7003
|
+
message: "Special passage \"StoryTitle\" not found."
|
|
7004
|
+
});
|
|
7005
|
+
const modules = getFilenames(options.modules ?? [], written);
|
|
5627
7006
|
diagnostics.push(...modules.diagnostics);
|
|
5628
|
-
|
|
7007
|
+
const { headFile } = options;
|
|
7008
|
+
const headIsOutput = headFile !== void 0 && outputPaths(written).isFile(realPathOf(headFile));
|
|
7009
|
+
rejectOutputSources([...modules.outputSources, ...headIsOutput ? [headFile] : []], diagnostics);
|
|
7010
|
+
const head = loadHeadContent(modules.filenames, options.headFile, diagnostics);
|
|
7011
|
+
output = format.isTwine2 ? toTwine2HTML(story, format, startName, {
|
|
7012
|
+
sourceInfo,
|
|
7013
|
+
head,
|
|
7014
|
+
diagnostics
|
|
7015
|
+
}) : toTwine1HTML(story, format, startName, {
|
|
7016
|
+
head,
|
|
7017
|
+
diagnostics
|
|
7018
|
+
});
|
|
5629
7019
|
break;
|
|
5630
7020
|
}
|
|
5631
7021
|
default: throw new TweeTsError(`Unhandled output mode: ${outputMode}`, diagnostics);
|
|
@@ -5642,6 +7032,16 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5642
7032
|
stats
|
|
5643
7033
|
};
|
|
5644
7034
|
}
|
|
7035
|
+
/**
|
|
7036
|
+
* Throws for the first of `paths`, inputs named directly that are also an output:
|
|
7037
|
+
* writing the build would overwrite them. An output found while walking a source
|
|
7038
|
+
* folder is skipped instead (see getFilenames).
|
|
7039
|
+
*/
|
|
7040
|
+
function rejectOutputSources(paths, diagnostics) {
|
|
7041
|
+
const [first] = paths;
|
|
7042
|
+
if (first === void 0) return;
|
|
7043
|
+
throw new TweeTsError(`path ${first}: Output file cannot be an input source.`, [...diagnostics]);
|
|
7044
|
+
}
|
|
5645
7045
|
function ensureIFID(story, diagnostics) {
|
|
5646
7046
|
if (story.ifid !== "") return;
|
|
5647
7047
|
if (story.legacyIFID !== "") {
|
|
@@ -7126,6 +8526,16 @@ function passageLinks(p) {
|
|
|
7126
8526
|
return extractLinksFromText(textAsRead(p), false);
|
|
7127
8527
|
}
|
|
7128
8528
|
/**
|
|
8529
|
+
* Whether the `target` output keeps the links in `p`: always without a target, which describes
|
|
8530
|
+
* every source passage. With one, a passage the output leaves out never reaches the player, so it
|
|
8531
|
+
* links nowhere, except a script passage, which Twine 2 output leaves out as a passage but runs.
|
|
8532
|
+
*/
|
|
8533
|
+
function readsLinks(story, p, { target }) {
|
|
8534
|
+
if (target === void 0) return true;
|
|
8535
|
+
const omission = passageOmission(story, p, target);
|
|
8536
|
+
return omission === void 0 || omission.kind === "tag" && omission.tag === "script";
|
|
8537
|
+
}
|
|
8538
|
+
/**
|
|
7129
8539
|
* Each passage name, mapped to why the `target` output leaves that passage out, or to `undefined`
|
|
7130
8540
|
* when a link can reach it: always without a target, and otherwise when the output emits a
|
|
7131
8541
|
* passage of that name.
|
|
@@ -7183,7 +8593,9 @@ function joinLines(text) {
|
|
|
7183
8593
|
* By default, a link is broken only when no passage has its name. Pass a `target` to check links
|
|
7184
8594
|
* against what that output emits: `storyInspect(result.story, { target: 'twine2' })` also reports
|
|
7185
8595
|
* links to passages Twine 2 output leaves out, such as script, stylesheet and `Twine.private`
|
|
7186
|
-
* passages.
|
|
8596
|
+
* passages. With a target, passages that output leaves out also give no links (script passages
|
|
8597
|
+
* excepted, as Twine 2 output runs them), so they report no broken links and keep no passage from
|
|
8598
|
+
* being an orphan.
|
|
7187
8599
|
*/
|
|
7188
8600
|
function storyInspect(story, options = {}) {
|
|
7189
8601
|
const destinations = destinationOmissions(story, options);
|
|
@@ -7199,7 +8611,7 @@ function storyInspect(story, options = {}) {
|
|
|
7199
8611
|
if (isStoryPassage(p)) storyPassages.push(p.name);
|
|
7200
8612
|
if (isInfoPassage(p)) infoPassages.push(p.name);
|
|
7201
8613
|
tagsByPassage.set(p.name, [...p.tags]);
|
|
7202
|
-
for (const tag of p.tags) {
|
|
8614
|
+
for (const tag of new Set(p.tags)) {
|
|
7203
8615
|
tagSet.add(tag);
|
|
7204
8616
|
let list = passagesByTag.get(tag);
|
|
7205
8617
|
if (!list) {
|
|
@@ -7208,7 +8620,7 @@ function storyInspect(story, options = {}) {
|
|
|
7208
8620
|
}
|
|
7209
8621
|
list.push(p.name);
|
|
7210
8622
|
}
|
|
7211
|
-
links.set(p.name, passageLinks(p));
|
|
8623
|
+
links.set(p.name, readsLinks(story, p, options) ? passageLinks(p) : []);
|
|
7212
8624
|
}
|
|
7213
8625
|
const brokenLinks = [];
|
|
7214
8626
|
for (const [from, targets] of links) for (const to of targets) {
|
|
@@ -7332,46 +8744,100 @@ const VALID_OUTPUT_MODES = [
|
|
|
7332
8744
|
"json"
|
|
7333
8745
|
];
|
|
7334
8746
|
const VALID_WORD_COUNT_METHODS = ["tweego", "whitespace"];
|
|
7335
|
-
/**
|
|
7336
|
-
|
|
8747
|
+
/** One entry per {@link TweeTsConfig} key: the compiler rejects a missing or an extra key here. */
|
|
8748
|
+
const CONFIG_KEY_SET = {
|
|
8749
|
+
sources: true,
|
|
8750
|
+
exclude: true,
|
|
8751
|
+
output: true,
|
|
8752
|
+
outputMode: true,
|
|
8753
|
+
formatId: true,
|
|
8754
|
+
startPassage: true,
|
|
8755
|
+
formatPaths: true,
|
|
8756
|
+
formatIndices: true,
|
|
8757
|
+
formatUrls: true,
|
|
8758
|
+
useTweegoPath: true,
|
|
8759
|
+
modules: true,
|
|
8760
|
+
headFile: true,
|
|
8761
|
+
trim: true,
|
|
8762
|
+
twee2Compat: true,
|
|
8763
|
+
testMode: true,
|
|
8764
|
+
noRemote: true,
|
|
8765
|
+
formatFetchTimeout: true,
|
|
8766
|
+
tagAliases: true,
|
|
8767
|
+
sourceInfo: true,
|
|
8768
|
+
wordCountMethod: true
|
|
8769
|
+
};
|
|
8770
|
+
/** The keys a config file may hold besides `$schema`; the JSON schema's `properties` list the same keys. */
|
|
8771
|
+
const CONFIG_KEYS = Object.keys(CONFIG_KEY_SET);
|
|
8772
|
+
/** The key that only references the JSON schema, for editors. */
|
|
8773
|
+
const SCHEMA_KEY = "$schema";
|
|
8774
|
+
/** A key folded so that spellings differing only in letter case, `-` or `_` compare equal. */
|
|
8775
|
+
const foldKey = (key) => key.toLowerCase().replace(/[-_]/g, "");
|
|
8776
|
+
/**
|
|
8777
|
+
* Load a config file from the given directory (default: cwd). Returns null if not found.
|
|
8778
|
+
*
|
|
8779
|
+
* @param diagnostics Receives warnings that do not stop the config from loading: keys the config does not
|
|
8780
|
+
* define, and a file that is not valid UTF-8 (it is read as Windows-1252).
|
|
8781
|
+
* @throws When the file is not valid JSON or fails {@link validateConfig}.
|
|
8782
|
+
*/
|
|
8783
|
+
function loadConfig(dir, diagnostics) {
|
|
7337
8784
|
const base = dir ?? process.cwd();
|
|
7338
8785
|
const configPath = join(base, CONFIG_FILENAME);
|
|
7339
8786
|
if (!existsSync(configPath)) return null;
|
|
8787
|
+
const readDiagnostics = [];
|
|
7340
8788
|
let raw;
|
|
7341
8789
|
try {
|
|
7342
|
-
raw =
|
|
8790
|
+
raw = readUTF8(configPath, readDiagnostics);
|
|
7343
8791
|
} catch {
|
|
7344
8792
|
return null;
|
|
7345
8793
|
}
|
|
7346
|
-
|
|
7347
|
-
|
|
7348
|
-
|
|
7349
|
-
} catch (e) {
|
|
7350
|
-
throw new Error(`Invalid JSON in ${configPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
7351
|
-
}
|
|
7352
|
-
const errors = validateConfig(data);
|
|
7353
|
-
if (errors.length > 0) throw new Error(`Invalid config in ${configPath}:\n ${errors.join("\n ")}`);
|
|
7354
|
-
return data;
|
|
8794
|
+
const config = parseConfig(raw, configPath, readDiagnostics);
|
|
8795
|
+
diagnostics?.push(...readDiagnostics);
|
|
8796
|
+
return config;
|
|
7355
8797
|
}
|
|
7356
|
-
/**
|
|
7357
|
-
|
|
8798
|
+
/**
|
|
8799
|
+
* Load a config from a specific file path.
|
|
8800
|
+
*
|
|
8801
|
+
* @param diagnostics Receives warnings that do not stop the config from loading: keys the config does not
|
|
8802
|
+
* define, and a file that is not valid UTF-8 (it is read as Windows-1252).
|
|
8803
|
+
* @throws When the file cannot be read, is not valid JSON or fails {@link validateConfig}.
|
|
8804
|
+
*/
|
|
8805
|
+
function loadConfigFile(filePath, diagnostics) {
|
|
8806
|
+
const readDiagnostics = [];
|
|
7358
8807
|
let raw;
|
|
7359
8808
|
try {
|
|
7360
|
-
raw =
|
|
8809
|
+
raw = readUTF8(filePath, readDiagnostics);
|
|
7361
8810
|
} catch (e) {
|
|
7362
8811
|
throw new Error(`Cannot read config file ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
7363
8812
|
}
|
|
8813
|
+
const config = parseConfig(raw, filePath, readDiagnostics);
|
|
8814
|
+
diagnostics?.push(...readDiagnostics);
|
|
8815
|
+
return config;
|
|
8816
|
+
}
|
|
8817
|
+
/**
|
|
8818
|
+
* Parse and validate the text of the config file at `path`. Unknown keys are added to `diagnostics` as
|
|
8819
|
+
* warnings, so a config with a stray key keeps loading.
|
|
8820
|
+
*/
|
|
8821
|
+
function parseConfig(raw, path, diagnostics) {
|
|
7364
8822
|
let data;
|
|
7365
8823
|
try {
|
|
7366
8824
|
data = JSON.parse(raw);
|
|
7367
8825
|
} catch (e) {
|
|
7368
|
-
throw new Error(`Invalid JSON in ${
|
|
8826
|
+
throw new Error(`Invalid JSON in ${path}: ${e instanceof Error ? e.message : String(e)}`);
|
|
7369
8827
|
}
|
|
7370
8828
|
const errors = validateConfig(data);
|
|
7371
|
-
if (errors.length > 0) throw new Error(`Invalid config in ${
|
|
8829
|
+
if (errors.length > 0) throw new Error(`Invalid config in ${path}:\n ${errors.join("\n ")}`);
|
|
8830
|
+
diagnostics.push(...unknownConfigKeyWarnings(data).map((message) => ({
|
|
8831
|
+
level: "warning",
|
|
8832
|
+
message: `${path}: ${message}`,
|
|
8833
|
+
file: path
|
|
8834
|
+
})));
|
|
7372
8835
|
return data;
|
|
7373
8836
|
}
|
|
7374
|
-
/**
|
|
8837
|
+
/**
|
|
8838
|
+
* Validate a config object. Returns an array of error strings (empty = valid).
|
|
8839
|
+
* Keys the config does not define are not errors; {@link unknownConfigKeyWarnings} reports them.
|
|
8840
|
+
*/
|
|
7375
8841
|
function validateConfig(data) {
|
|
7376
8842
|
const errors = [];
|
|
7377
8843
|
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
@@ -7404,6 +8870,10 @@ function validateConfig(data) {
|
|
|
7404
8870
|
if (!Array.isArray(obj[key])) errors.push(`"${key}" must be an array.`);
|
|
7405
8871
|
else if (!obj[key].every((v) => typeof v === "string")) errors.push(`"${key}" must be an array of strings.`);
|
|
7406
8872
|
}
|
|
8873
|
+
if ("formatFetchTimeout" in obj) {
|
|
8874
|
+
const timeout = obj["formatFetchTimeout"];
|
|
8875
|
+
if (typeof timeout !== "number" || !(timeout >= 0)) errors.push("\"formatFetchTimeout\" must be a number of milliseconds, 0 or more.");
|
|
8876
|
+
}
|
|
7407
8877
|
if ("tagAliases" in obj) {
|
|
7408
8878
|
if (typeof obj["tagAliases"] !== "object" || obj["tagAliases"] === null || Array.isArray(obj["tagAliases"])) errors.push("\"tagAliases\" must be an object.");
|
|
7409
8879
|
else {
|
|
@@ -7419,6 +8889,20 @@ function validateConfig(data) {
|
|
|
7419
8889
|
}
|
|
7420
8890
|
return errors;
|
|
7421
8891
|
}
|
|
8892
|
+
/**
|
|
8893
|
+
* Warnings for the keys of a config object that the config does not define (the JSON schema's
|
|
8894
|
+
* `additionalProperties: false`), other than `$schema`. twee-ts ignores such a key, so a misspelt one would
|
|
8895
|
+
* otherwise leave its option at the default without a word. A key that differs from a defined one only in
|
|
8896
|
+
* letter case, `-` or `_` (`formatID`, `output-mode`) gets a suggestion. Returns `[]` for anything that is not
|
|
8897
|
+
* an object, which {@link validateConfig} reports.
|
|
8898
|
+
*/
|
|
8899
|
+
function unknownConfigKeyWarnings(data) {
|
|
8900
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) return [];
|
|
8901
|
+
return Object.keys(data).filter((key) => key !== SCHEMA_KEY && !Object.hasOwn(CONFIG_KEY_SET, key)).map((key) => {
|
|
8902
|
+
const suggestion = CONFIG_KEYS.find((known) => foldKey(known) === foldKey(key));
|
|
8903
|
+
return `Unknown config key "${key}"${suggestion === void 0 ? "" : ` (did you mean "${suggestion}"?)`}; it is ignored.`;
|
|
8904
|
+
});
|
|
8905
|
+
}
|
|
7422
8906
|
/** Return a default config JSON string for --init scaffolding. */
|
|
7423
8907
|
function scaffoldConfig() {
|
|
7424
8908
|
return JSON.stringify({
|
|
@@ -7526,10 +9010,6 @@ async function main() {
|
|
|
7526
9010
|
printUsage();
|
|
7527
9011
|
return;
|
|
7528
9012
|
}
|
|
7529
|
-
if (values["list-formats"]) {
|
|
7530
|
-
listFormats();
|
|
7531
|
-
return;
|
|
7532
|
-
}
|
|
7533
9013
|
if (values.init) {
|
|
7534
9014
|
runInit();
|
|
7535
9015
|
return;
|
|
@@ -7540,8 +9020,14 @@ async function main() {
|
|
|
7540
9020
|
}
|
|
7541
9021
|
let config = null;
|
|
7542
9022
|
if (!values["no-config"]) {
|
|
7543
|
-
|
|
7544
|
-
|
|
9023
|
+
const configDiagnostics = [];
|
|
9024
|
+
if (values.config) config = loadConfigFile(values.config, configDiagnostics);
|
|
9025
|
+
else config = loadConfig(void 0, configDiagnostics);
|
|
9026
|
+
logDiagnostics(configDiagnostics);
|
|
9027
|
+
}
|
|
9028
|
+
if (values["list-formats"]) {
|
|
9029
|
+
listFormats(config?.formatPaths ?? [], config?.useTweegoPath ?? true);
|
|
9030
|
+
return;
|
|
7545
9031
|
}
|
|
7546
9032
|
const sources = positionals.length > 0 ? positionals : config?.sources;
|
|
7547
9033
|
if (!sources || sources.length === 0) {
|
|
@@ -7594,6 +9080,7 @@ async function main() {
|
|
|
7594
9080
|
formatIndices: values["format-index"] ?? config?.formatIndices,
|
|
7595
9081
|
formatUrls: values["format-url"] ?? config?.formatUrls,
|
|
7596
9082
|
noRemote: values["no-remote"] ?? config?.noRemote ?? false,
|
|
9083
|
+
formatFetchTimeout: config?.formatFetchTimeout,
|
|
7597
9084
|
tagAliases,
|
|
7598
9085
|
sourceInfo: values["source-info"] ?? config?.sourceInfo ?? false,
|
|
7599
9086
|
wordCountMethod
|
|
@@ -7618,10 +9105,15 @@ async function main() {
|
|
|
7618
9105
|
formatIndices: values["format-index"] ?? config?.formatIndices,
|
|
7619
9106
|
formatUrls: values["format-url"] ?? config?.formatUrls,
|
|
7620
9107
|
noRemote: values["no-remote"] ?? config?.noRemote ?? false,
|
|
9108
|
+
formatFetchTimeout: config?.formatFetchTimeout,
|
|
7621
9109
|
tagAliases,
|
|
7622
9110
|
sourceInfo: values["source-info"] ?? config?.sourceInfo ?? false,
|
|
7623
9111
|
wordCountMethod
|
|
7624
9112
|
};
|
|
9113
|
+
const log = {
|
|
9114
|
+
files: outPath !== void 0 && (values["log-files"] ?? false),
|
|
9115
|
+
stats: values["log-stats"] ?? false
|
|
9116
|
+
};
|
|
7625
9117
|
if (values.watch) {
|
|
7626
9118
|
if (outPath === void 0) {
|
|
7627
9119
|
console.error("Error: Watch mode requires an output file (-o).");
|
|
@@ -7636,9 +9128,16 @@ async function main() {
|
|
|
7636
9128
|
logDiagnostics(result.diagnostics);
|
|
7637
9129
|
const errors = countErrors(result.diagnostics);
|
|
7638
9130
|
if (errors > 0) console.error(`Build has ${pluralize(errors, "error")}; output not written. Still watching for changes.`);
|
|
9131
|
+
logBuild(result, log);
|
|
7639
9132
|
},
|
|
7640
9133
|
onError(error) {
|
|
7641
|
-
|
|
9134
|
+
if (error instanceof WatchPathError) {
|
|
9135
|
+
console.error(`error: ${error.message}`);
|
|
9136
|
+
process.exitCode = 1;
|
|
9137
|
+
} else {
|
|
9138
|
+
logErrorDiagnostics(error);
|
|
9139
|
+
console.error(`Build error: ${error.message}`);
|
|
9140
|
+
}
|
|
7642
9141
|
}
|
|
7643
9142
|
}, (result) => countErrors(result.diagnostics) === 0);
|
|
7644
9143
|
} else {
|
|
@@ -7649,35 +9148,61 @@ async function main() {
|
|
|
7649
9148
|
console.error(`Compilation failed with ${pluralize(errors, "error")}; output not written.`);
|
|
7650
9149
|
process.exitCode = 1;
|
|
7651
9150
|
} else if (outPath === void 0) process.stdout.write(result.output);
|
|
7652
|
-
else
|
|
7653
|
-
|
|
7654
|
-
if (values["log-stats"]) logStats(result);
|
|
9151
|
+
else writeFileAtomic(outPath, result.output);
|
|
9152
|
+
logBuild(result, log);
|
|
7655
9153
|
}
|
|
7656
9154
|
}
|
|
9155
|
+
function logBuild(result, log) {
|
|
9156
|
+
if (log.files) console.log(`\nFiles: ${result.stats.files.join(", ")}`);
|
|
9157
|
+
if (log.stats) logStats(result);
|
|
9158
|
+
}
|
|
7657
9159
|
function countErrors(diagnostics) {
|
|
7658
9160
|
return diagnostics.filter((d) => d.level === "error").length;
|
|
7659
9161
|
}
|
|
7660
9162
|
function pluralize(count, noun) {
|
|
7661
9163
|
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
7662
9164
|
}
|
|
7663
|
-
|
|
7664
|
-
|
|
9165
|
+
/** Order version strings from highest to lowest SemVer precedence (unparseable ones last). */
|
|
9166
|
+
function byVersionDescending(a, b) {
|
|
9167
|
+
const left = parseVersion(a);
|
|
9168
|
+
const right = parseVersion(b);
|
|
9169
|
+
if (!left || !right) return Number(!left) - Number(!right);
|
|
9170
|
+
return compareVersions(right, left);
|
|
9171
|
+
}
|
|
9172
|
+
/** Print the formats --format can select: local folders (pruned by SemVer) and cached downloads, by ID. */
|
|
9173
|
+
function listFormats(formatPaths, useTweegoPath) {
|
|
9174
|
+
const diagnostics = [];
|
|
9175
|
+
const formats = pruneFormats(discoverAllFormats(getFormatSearchDirs(formatPaths, useTweegoPath), diagnostics));
|
|
9176
|
+
logDiagnostics(diagnostics);
|
|
7665
9177
|
console.log("Local story formats:");
|
|
7666
9178
|
if (formats.size === 0) console.log(" (none)");
|
|
7667
9179
|
else for (const [id, f] of formats) {
|
|
7668
9180
|
const type = f.isTwine2 ? "Twine 2" : "Twine 1";
|
|
7669
9181
|
console.log(` ${id}: ${f.name || id} ${f.version} (${type})`);
|
|
7670
9182
|
}
|
|
7671
|
-
const
|
|
7672
|
-
|
|
9183
|
+
const cachedById = /* @__PURE__ */ new Map();
|
|
9184
|
+
for (const f of discoverCachedFormats().values()) {
|
|
9185
|
+
const id = makeFormatId(f.name, f.version);
|
|
9186
|
+
cachedById.set(id, [...cachedById.get(id) ?? [], f]);
|
|
9187
|
+
}
|
|
9188
|
+
if (cachedById.size > 0) {
|
|
7673
9189
|
console.log("\nCached remote formats:");
|
|
7674
|
-
for (const [id,
|
|
9190
|
+
for (const [id, versions] of [...cachedById].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
|
|
9191
|
+
const [newest, ...older] = [...versions].sort((a, b) => byVersionDescending(a.version, b.version));
|
|
9192
|
+
if (!newest) continue;
|
|
9193
|
+
const also = older.length > 0 ? ` (also cached: ${older.map((f) => f.version).join(", ")})` : "";
|
|
9194
|
+
console.log(` ${id}: ${newest.name} ${newest.version}${also}`);
|
|
9195
|
+
}
|
|
7675
9196
|
}
|
|
7676
9197
|
}
|
|
7677
9198
|
function logDiagnostics(diagnostics) {
|
|
7678
9199
|
for (const d of diagnostics) if (d.level === "error") console.error(`error: ${d.message}`);
|
|
7679
9200
|
else console.warn(`warning: ${d.message}`);
|
|
7680
9201
|
}
|
|
9202
|
+
/** Prints what a fatal TweeTsError collected (such as why no story format was found) before its message. */
|
|
9203
|
+
function logErrorDiagnostics(error) {
|
|
9204
|
+
if (error instanceof TweeTsError) logDiagnostics(error.diagnostics);
|
|
9205
|
+
}
|
|
7681
9206
|
function logStats(result) {
|
|
7682
9207
|
const s = result.stats;
|
|
7683
9208
|
console.log(`\nStatistics:`);
|
|
@@ -7829,6 +9354,7 @@ Subcommands:
|
|
|
7829
9354
|
cache path Print cache directory path`);
|
|
7830
9355
|
}
|
|
7831
9356
|
main().catch((err) => {
|
|
9357
|
+
logErrorDiagnostics(err);
|
|
7832
9358
|
console.error(err instanceof Error ? err.message : String(err));
|
|
7833
9359
|
process.exit(1);
|
|
7834
9360
|
});
|