@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/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, watch as watch$1, writeFileSync } from "node:fs";
|
|
2
1
|
import * as nodePath from "node:path";
|
|
3
2
|
import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
4
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
4
|
+
import { accessSync, chmodSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, watch as watch$1, writeFileSync } from "node:fs";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
//#region src/ifid.ts
|
|
@@ -13,11 +13,27 @@ import { fileURLToPath } from "node:url";
|
|
|
13
13
|
function generateIFID() {
|
|
14
14
|
return randomUUID().toUpperCase();
|
|
15
15
|
}
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Validate and brand an IFID string. Throws on invalid input.
|
|
18
|
+
* Returns the IFID in its stored form (see `normalizeIFID`).
|
|
19
|
+
*/
|
|
17
20
|
function createIFID(value) {
|
|
18
21
|
const err = validateIFID(value);
|
|
19
22
|
if (err) throw new Error(`Invalid IFID: ${err}`);
|
|
20
|
-
return value
|
|
23
|
+
return normalizeIFID(value);
|
|
24
|
+
}
|
|
25
|
+
/** Length of the `UUID://...//` wrapper around a 36-character UUID. */
|
|
26
|
+
const WRAPPED_LENGTH = 45;
|
|
27
|
+
/**
|
|
28
|
+
* Bring an IFID into the form the story model stores and every output writes: the uppercase
|
|
29
|
+
* bare UUID. A valid `UUID://...//` wrapped IFID loses its wrapper; outputs that need the wrapper
|
|
30
|
+
* (the Treaty of Babel comment) add exactly one. Twine 2 IFIDs use only capital letters, so the
|
|
31
|
+
* value is uppercased, as Tweego does. An invalid value is only uppercased, so that validation
|
|
32
|
+
* reports it as written.
|
|
33
|
+
*/
|
|
34
|
+
function normalizeIFID(value) {
|
|
35
|
+
const upper = value.toUpperCase();
|
|
36
|
+
return upper.length === WRAPPED_LENGTH && validateIFID(upper) === null ? upper.slice(7, 43) : upper;
|
|
21
37
|
}
|
|
22
38
|
/**
|
|
23
39
|
* Validate an IFID string.
|
|
@@ -97,18 +113,44 @@ function tweeEscape(s) {
|
|
|
97
113
|
if (s.length === 0) return s;
|
|
98
114
|
return s.replace(/[\\[\]{}]/g, (ch) => "\\" + ch);
|
|
99
115
|
}
|
|
100
|
-
|
|
116
|
+
const JS_STRING_ESCAPES = {
|
|
117
|
+
"\\": "\\\\",
|
|
118
|
+
"\"": "\\\"",
|
|
119
|
+
"'": "\\'",
|
|
120
|
+
"\n": "\\n",
|
|
121
|
+
"\r": "\\r",
|
|
122
|
+
" ": "\\t",
|
|
123
|
+
"<": "\\x3C",
|
|
124
|
+
"\u2028": "\\u2028",
|
|
125
|
+
"\u2029": "\\u2029"
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* Escape a string for the inside of a single- or double-quoted JavaScript string literal, including one written
|
|
129
|
+
* into an HTML `<script>` element. Besides backslashes, quotes and line breaks (with U+2028 and U+2029, which end
|
|
130
|
+
* a string literal in JavaScript before ES2019), every `<` becomes `\x3C`, so the literal holds no `<\/script`
|
|
131
|
+
* that would end the element and no `<!--` or `<script` that would change where it ends. The string's value is
|
|
132
|
+
* unchanged.
|
|
133
|
+
*/
|
|
101
134
|
function jsStringEscape(s) {
|
|
102
135
|
if (s.length === 0) return s;
|
|
103
|
-
return s.replace(
|
|
136
|
+
return s.replace(/[\\"'\n\r\t<\u2028\u2029]/g, (ch) => JS_STRING_ESCAPES[ch] ?? ch);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Escape a string for the inside of a double-quoted CSS string (such as a `font-family` name): `\` and `"` get
|
|
140
|
+
* a backslash, and line breaks and other control characters become hex escapes followed by a space (`\a ` for a
|
|
141
|
+
* line feed), since a raw line break ends a CSS string and a backslash before one is a line continuation.
|
|
142
|
+
*/
|
|
143
|
+
function cssStringEscape(s) {
|
|
144
|
+
if (s.length === 0) return s;
|
|
145
|
+
return s.replace(/[\\"\u0000-\u001f\u007f]/g, (ch) => ch === "\\" || ch === "\"" ? `\\${ch}` : `\\${ch.charCodeAt(0).toString(16)} `);
|
|
104
146
|
}
|
|
105
147
|
/** Sanitize a string for safe inclusion in a CSS or JS block comment. */
|
|
106
148
|
function commentSanitize(s) {
|
|
107
149
|
return s.replace(/\*\//g, "* /");
|
|
108
150
|
}
|
|
109
|
-
/** Sanitize a string for safe inclusion in an HTML comment. */
|
|
151
|
+
/** Sanitize a string for safe inclusion in an HTML comment: break up `-->` and `--!>`, which both end one. */
|
|
110
152
|
function htmlCommentSanitize(s) {
|
|
111
|
-
return s.replace(
|
|
153
|
+
return s.replace(/--(!?)>/g, "--$1 >");
|
|
112
154
|
}
|
|
113
155
|
/**
|
|
114
156
|
* Escape JavaScript for the content of an HTML `<script>` element, so that the HTML parser reads all of it as
|
|
@@ -288,7 +330,19 @@ function passageToPassagedata(p, pid, options) {
|
|
|
288
330
|
if (options?.sourceInfo && p.source) attrs += ` data-source-file=${quote(attrEscape(p.source.file))} data-source-line="${p.source.line}"`;
|
|
289
331
|
return `${attrs}>${htmlEscape(p.text)}</tw-passagedata>`;
|
|
290
332
|
}
|
|
291
|
-
/**
|
|
333
|
+
/**
|
|
334
|
+
* Whether Twine 1 `obfuscate:rot13` encodes a tiddler: every one but `StorySettings` and those tagged `Twine.image`
|
|
335
|
+
* (Twine 1.4 `Tiddler.isObfuscateable()`). Its engine tests the stored name and tags, which these tiddlers keep
|
|
336
|
+
* unencoded, so the decompiler tests them the same way.
|
|
337
|
+
*/
|
|
338
|
+
function isObfuscatable(p) {
|
|
339
|
+
return p.name !== "StorySettings" && !p.tags.includes("Twine.image");
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Generate `<div tiddler>` HTML for Twine 1. With `obfuscateRot13`, an obfuscatable tiddler (see
|
|
343
|
+
* `isObfuscatable()`) has its name, each tag and its text ROT13-encoded, as Twine 1.4 writes it
|
|
344
|
+
* (`Tiddler.toHtml()`), and as its engine.js decodes it.
|
|
345
|
+
*/
|
|
292
346
|
function passageToTiddler(p, pid, obfuscateRot13 = false) {
|
|
293
347
|
let position;
|
|
294
348
|
if (hasMetadataPosition(p)) position = p.metadata?.position ?? "";
|
|
@@ -300,9 +354,10 @@ function passageToTiddler(p, pid, obfuscateRot13 = false) {
|
|
|
300
354
|
position = `${xp * 140 - 130},${yp * 140 - 130}`;
|
|
301
355
|
}
|
|
302
356
|
const created = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 12);
|
|
303
|
-
const
|
|
304
|
-
const
|
|
305
|
-
|
|
357
|
+
const encode = obfuscateRot13 && isObfuscatable(p) ? rot13 : (s) => s;
|
|
358
|
+
const name = attrEscape(encode(p.name));
|
|
359
|
+
const tags = attrEscape(p.tags.map(encode).join(" "));
|
|
360
|
+
return `<div tiddler=${quote(name)} tags=${quote(tags)} created=${quote(created)} modifier=${quote("twee")} twine-position=${quote(attrEscape(position))}>${tiddlerEscape(encode(p.text))}</div>`;
|
|
306
361
|
}
|
|
307
362
|
/**
|
|
308
363
|
* Count words in a passage.
|
|
@@ -355,19 +410,136 @@ function quote(s) {
|
|
|
355
410
|
}
|
|
356
411
|
//#endregion
|
|
357
412
|
//#region src/story.ts
|
|
358
|
-
/** Module-level index: maps passage names to their array indices for O(1) lookups. */
|
|
359
413
|
const passageIndex = /* @__PURE__ */ new WeakMap();
|
|
360
|
-
function
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
414
|
+
function buildIndex(story) {
|
|
415
|
+
const names = story.passages.map((p) => p.name);
|
|
416
|
+
const index = {
|
|
417
|
+
passages: story.passages,
|
|
418
|
+
names,
|
|
419
|
+
positions: new Map(names.map((name, i) => [name, i]))
|
|
420
|
+
};
|
|
421
|
+
passageIndex.set(story, index);
|
|
422
|
+
return index;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* The index, rebuilt if `story.passages` was reassigned or changed length. O(1); `position()`
|
|
426
|
+
* also checks every hit. A change that keeps the length, such as replacing an element, can still
|
|
427
|
+
* hide a passage from a lookup, so `storyVerifyIndex()` checks every position.
|
|
428
|
+
*/
|
|
429
|
+
function currentIndex(story) {
|
|
430
|
+
const index = passageIndex.get(story);
|
|
431
|
+
return index !== void 0 && index.passages === story.passages && index.names.length === story.passages.length ? index : buildIndex(story);
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Rebuild the index unless it matches `story.passages` at every position. O(n): for entry points
|
|
435
|
+
* that run after code outside the compiler may have changed the passages, such as StoryBuilder.
|
|
436
|
+
*/
|
|
437
|
+
function storyVerifyIndex(story) {
|
|
438
|
+
const index = currentIndex(story);
|
|
439
|
+
if (!story.passages.every((p, i) => p.name === index.names[i])) buildIndex(story);
|
|
440
|
+
}
|
|
441
|
+
/** The position of the passage named `name`, or -1. */
|
|
442
|
+
function position(story, name) {
|
|
443
|
+
const i = currentIndex(story).positions.get(name);
|
|
444
|
+
if (i === void 0) return -1;
|
|
445
|
+
if (story.passages[i]?.name === name) return i;
|
|
446
|
+
return buildIndex(story).positions.get(name) ?? -1;
|
|
447
|
+
}
|
|
448
|
+
/** Add a passage whose name no passage has yet at the end. */
|
|
449
|
+
function push(story, p) {
|
|
450
|
+
const index = currentIndex(story);
|
|
451
|
+
index.positions.set(p.name, story.passages.length);
|
|
452
|
+
index.names.push(p.name);
|
|
453
|
+
story.passages.push(p);
|
|
454
|
+
}
|
|
455
|
+
/** Put a passage at position `i`, in place of the passage there. */
|
|
456
|
+
function replaceAt(story, i, p) {
|
|
457
|
+
const renamed = story.passages[i]?.name !== p.name;
|
|
458
|
+
story.passages[i] = p;
|
|
459
|
+
if (renamed) buildIndex(story);
|
|
460
|
+
}
|
|
461
|
+
/** Names of compiler special passages, which storyAdd() reads into the story model. */
|
|
462
|
+
const SPECIAL_PASSAGE_NAMES = /* @__PURE__ */ new Set([
|
|
463
|
+
"StoryData",
|
|
464
|
+
"StoryIncludes",
|
|
465
|
+
"StorySettings",
|
|
466
|
+
"StoryTitle"
|
|
467
|
+
]);
|
|
468
|
+
/** Passages whose names were generated, kept by identity so that cached passages keep the mark. */
|
|
469
|
+
const generatedNames = /* @__PURE__ */ new WeakMap();
|
|
470
|
+
/**
|
|
471
|
+
* Mark a passage's name as generated (from a file name, or for imported story code) and return
|
|
472
|
+
* the passage. `origin.base` is the name that free names are numbered from: `base`, `base 2`, ….
|
|
473
|
+
*/
|
|
474
|
+
function withGeneratedName(p, origin) {
|
|
475
|
+
generatedNames.set(p, origin);
|
|
476
|
+
return p;
|
|
477
|
+
}
|
|
478
|
+
/** The first of `base`, `base 2`, `base 3`, … that `isFree` accepts. */
|
|
479
|
+
function freeName(base, isFree) {
|
|
480
|
+
if (isFree(base)) return base;
|
|
481
|
+
for (let n = 2;; n++) {
|
|
482
|
+
const candidate = `${base} ${n}`;
|
|
483
|
+
if (isFree(candidate)) return candidate;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
/** Whether a generated name may take `name`: no passage has it and it is no special name. */
|
|
487
|
+
function isFreeForGenerated(story, name) {
|
|
488
|
+
return !SPECIAL_PASSAGE_NAMES.has(name) && position(story, name) === -1;
|
|
489
|
+
}
|
|
490
|
+
function reportRename(origin, from, to, diagnostics) {
|
|
491
|
+
switch (origin.kind) {
|
|
492
|
+
case "code": return;
|
|
493
|
+
case "media": {
|
|
494
|
+
const reason = SPECIAL_PASSAGE_NAMES.has(from) ? `"${from}" is a compiler special passage name` : `another passage has the name "${from}"`;
|
|
495
|
+
diagnostics.push({
|
|
496
|
+
level: "warning",
|
|
497
|
+
message: `Passage "${from}" from "${origin.file}" renamed to "${to}"; ${reason}.`
|
|
498
|
+
});
|
|
499
|
+
return;
|
|
367
500
|
}
|
|
368
|
-
|
|
501
|
+
default: throw new Error(`unhandled generated name: ${JSON.stringify(origin)}`);
|
|
369
502
|
}
|
|
370
|
-
|
|
503
|
+
}
|
|
504
|
+
/** Add a passage with a generated name, under the first free name if its own is taken. */
|
|
505
|
+
function addGenerated(story, p, origin, diagnostics) {
|
|
506
|
+
if (isFreeForGenerated(story, p.name)) {
|
|
507
|
+
push(story, p);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const name = freeName(origin.base, (n) => isFreeForGenerated(story, n));
|
|
511
|
+
reportRename(origin, p.name, name, diagnostics);
|
|
512
|
+
push(story, withGeneratedName({
|
|
513
|
+
...p,
|
|
514
|
+
name
|
|
515
|
+
}, origin));
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* If a passage with a generated name has `name`, give it the first free name, in its place in the
|
|
519
|
+
* passage list, so that a passage from the sources can take the name.
|
|
520
|
+
*/
|
|
521
|
+
function moveGeneratedAside(story, name, diagnostics) {
|
|
522
|
+
const i = position(story, name);
|
|
523
|
+
const existing = i === -1 ? void 0 : story.passages[i];
|
|
524
|
+
const origin = existing === void 0 ? void 0 : generatedNames.get(existing);
|
|
525
|
+
if (existing === void 0 || origin === void 0) return;
|
|
526
|
+
const free = freeName(origin.base, (n) => isFreeForGenerated(story, n));
|
|
527
|
+
reportRename(origin, name, free, diagnostics);
|
|
528
|
+
replaceAt(story, i, withGeneratedName({
|
|
529
|
+
...existing,
|
|
530
|
+
name: free
|
|
531
|
+
}, origin));
|
|
532
|
+
}
|
|
533
|
+
function defaultTwine2Metadata() {
|
|
534
|
+
return {
|
|
535
|
+
format: "",
|
|
536
|
+
formatVersion: "",
|
|
537
|
+
options: /* @__PURE__ */ new Map(),
|
|
538
|
+
start: "",
|
|
539
|
+
tags: "",
|
|
540
|
+
tagColors: /* @__PURE__ */ new Map(),
|
|
541
|
+
zoom: 1
|
|
542
|
+
};
|
|
371
543
|
}
|
|
372
544
|
function createStory() {
|
|
373
545
|
const story = {
|
|
@@ -376,52 +548,37 @@ function createStory() {
|
|
|
376
548
|
passages: [],
|
|
377
549
|
legacyIFID: "",
|
|
378
550
|
twine1: { settings: /* @__PURE__ */ new Map() },
|
|
379
|
-
twine2:
|
|
380
|
-
format: "",
|
|
381
|
-
formatVersion: "",
|
|
382
|
-
options: /* @__PURE__ */ new Map(),
|
|
383
|
-
start: "",
|
|
384
|
-
tags: "",
|
|
385
|
-
tagColors: /* @__PURE__ */ new Map(),
|
|
386
|
-
zoom: 1
|
|
387
|
-
}
|
|
551
|
+
twine2: defaultTwine2Metadata()
|
|
388
552
|
};
|
|
389
|
-
|
|
553
|
+
buildIndex(story);
|
|
390
554
|
return story;
|
|
391
555
|
}
|
|
392
556
|
function storyHas(story, name) {
|
|
393
|
-
return
|
|
557
|
+
return position(story, name) !== -1;
|
|
394
558
|
}
|
|
395
559
|
function storyAppend(story, p, diagnostics) {
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
index.set(p.name, story.passages.length);
|
|
400
|
-
story.passages.push(p);
|
|
401
|
-
} else {
|
|
560
|
+
const i = position(story, p.name);
|
|
561
|
+
if (i === -1) push(story, p);
|
|
562
|
+
else {
|
|
402
563
|
diagnostics.push({
|
|
403
564
|
level: "warning",
|
|
404
565
|
message: `Replacing existing passage "${p.name}" with duplicate.`
|
|
405
566
|
});
|
|
406
|
-
story
|
|
567
|
+
replaceAt(story, i, p);
|
|
407
568
|
}
|
|
408
569
|
}
|
|
409
570
|
function storyPrepend(story, p, diagnostics) {
|
|
410
|
-
|
|
411
|
-
const i =
|
|
571
|
+
moveGeneratedAside(story, p.name, diagnostics);
|
|
572
|
+
const i = position(story, p.name);
|
|
412
573
|
if (i === -1) {
|
|
413
574
|
story.passages.unshift(p);
|
|
414
|
-
|
|
415
|
-
for (let j = 0; j < story.passages.length; j++) {
|
|
416
|
-
const passage = story.passages[j];
|
|
417
|
-
if (passage) index.set(passage.name, j);
|
|
418
|
-
}
|
|
575
|
+
buildIndex(story);
|
|
419
576
|
} else {
|
|
420
577
|
diagnostics.push({
|
|
421
578
|
level: "warning",
|
|
422
579
|
message: `Replacing existing passage "${p.name}" with duplicate.`
|
|
423
580
|
});
|
|
424
|
-
story
|
|
581
|
+
replaceAt(story, i, p);
|
|
425
582
|
}
|
|
426
583
|
}
|
|
427
584
|
function marshalStoryData(story) {
|
|
@@ -447,18 +604,20 @@ function unmarshalStoryData(story, json) {
|
|
|
447
604
|
}
|
|
448
605
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return "Cannot unmarshal \"StoryData\"; expected a JSON object";
|
|
449
606
|
const data = raw;
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
if (typeof data
|
|
607
|
+
const twine2 = defaultTwine2Metadata();
|
|
608
|
+
story.ifid = typeof data.ifid === "string" ? normalizeIFID(data.ifid) : "";
|
|
609
|
+
if (typeof data.format === "string") twine2.format = data.format;
|
|
610
|
+
if (typeof data["format-version"] === "string") twine2.formatVersion = data["format-version"];
|
|
453
611
|
if (Array.isArray(data.options)) {
|
|
454
|
-
for (const opt of data.options) if (typeof opt === "string")
|
|
612
|
+
for (const opt of data.options) if (typeof opt === "string") twine2.options.set(opt, true);
|
|
455
613
|
}
|
|
456
|
-
if (typeof data.start === "string"
|
|
457
|
-
if (typeof data.tags === "string")
|
|
614
|
+
if (typeof data.start === "string") twine2.start = data.start;
|
|
615
|
+
if (typeof data.tags === "string") twine2.tags = data.tags;
|
|
458
616
|
if (typeof data["tag-colors"] === "object" && data["tag-colors"] !== null && !Array.isArray(data["tag-colors"])) {
|
|
459
|
-
for (const [tag, color] of Object.entries(data["tag-colors"])) if (typeof color === "string")
|
|
617
|
+
for (const [tag, color] of Object.entries(data["tag-colors"])) if (typeof color === "string") twine2.tagColors.set(tag, color);
|
|
460
618
|
}
|
|
461
|
-
if (typeof data.zoom === "number" && data.zoom !== 0)
|
|
619
|
+
if (typeof data.zoom === "number" && data.zoom !== 0) twine2.zoom = data.zoom;
|
|
620
|
+
story.twine2 = twine2;
|
|
462
621
|
return null;
|
|
463
622
|
}
|
|
464
623
|
function unmarshalStorySettings(story, text, diagnostics) {
|
|
@@ -478,7 +637,7 @@ function unmarshalStorySettings(story, text, diagnostics) {
|
|
|
478
637
|
const val = line.slice(colonIdx + 1).trim().toLowerCase();
|
|
479
638
|
switch (key) {
|
|
480
639
|
case "ifid":
|
|
481
|
-
if (validateIFID(val) === null) story.legacyIFID = val
|
|
640
|
+
if (validateIFID(val) === null) story.legacyIFID = normalizeIFID(val);
|
|
482
641
|
obsolete.push("\"ifid\"");
|
|
483
642
|
continue;
|
|
484
643
|
case "zoom":
|
|
@@ -499,8 +658,20 @@ function unmarshalStorySettings(story, text, diagnostics) {
|
|
|
499
658
|
/**
|
|
500
659
|
* Process a passage and add it to the story, handling special passages.
|
|
501
660
|
* Creates new passage objects where text is modified rather than mutating the input.
|
|
661
|
+
*
|
|
662
|
+
* A passage replaces an earlier one with the same name, except where either name was generated
|
|
663
|
+
* (see `withGeneratedName()`): a generated name never replaces a passage and is never replaced.
|
|
664
|
+
* A passage with a generated name takes the first free name of `base`, `base 2`, … when its own
|
|
665
|
+
* is taken or is a compiler special name. A passage with a name from the sources that a generated
|
|
666
|
+
* name holds takes it over, and the generated one moves to the first free name in its place.
|
|
502
667
|
*/
|
|
503
668
|
function storyAdd(story, p, diagnostics) {
|
|
669
|
+
const origin = generatedNames.get(p);
|
|
670
|
+
if (origin !== void 0) {
|
|
671
|
+
addGenerated(story, p, origin, diagnostics);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
moveGeneratedAside(story, p.name, diagnostics);
|
|
504
675
|
let processed = p;
|
|
505
676
|
switch (p.name) {
|
|
506
677
|
case "StoryIncludes":
|
|
@@ -560,7 +731,9 @@ function getStoryStats(story, wordCountMethod = "tweego") {
|
|
|
560
731
|
}
|
|
561
732
|
/**
|
|
562
733
|
* Builder that separates the mutable construction phase from the immutable consumption phase.
|
|
563
|
-
* During construction, the internal `Story` is mutable via `add()` and direct access
|
|
734
|
+
* During construction, the internal `Story` is mutable via `add()` and direct access: you may
|
|
735
|
+
* push, splice, sort, rename or reassign `story.passages` between calls, and `add()` and `has()`
|
|
736
|
+
* see the passages as they are then.
|
|
564
737
|
* After `build()`, the story is returned as `ReadonlyStory`.
|
|
565
738
|
*/
|
|
566
739
|
var StoryBuilder = class {
|
|
@@ -571,10 +744,13 @@ var StoryBuilder = class {
|
|
|
571
744
|
}
|
|
572
745
|
/** Add a passage, handling special passages (StoryData, StoryTitle, etc.). */
|
|
573
746
|
add(passage, diagnostics) {
|
|
747
|
+
if (generatedNames.has(passage) || !storyHas(this.story, passage.name)) storyVerifyIndex(this.story);
|
|
574
748
|
storyAdd(this.story, passage, diagnostics);
|
|
575
749
|
}
|
|
576
750
|
/** Check if a passage name exists. */
|
|
577
751
|
has(name) {
|
|
752
|
+
if (storyHas(this.story, name)) return true;
|
|
753
|
+
storyVerifyIndex(this.story);
|
|
578
754
|
return storyHas(this.story, name);
|
|
579
755
|
}
|
|
580
756
|
/** Finalize and return the story as read-only. */
|
|
@@ -699,131 +875,579 @@ function isExcluded(filename, exclude) {
|
|
|
699
875
|
const rel = relative(process.cwd(), resolve(filename)) || filename;
|
|
700
876
|
return exclude.some((pattern) => matchesGlob(rel, pattern.replace(/^\.\//, "")));
|
|
701
877
|
}
|
|
878
|
+
/** A build that writes nothing: `compile()`, or output to stdout. */
|
|
879
|
+
const NO_BUILD_OUTPUTS = {
|
|
880
|
+
files: [],
|
|
881
|
+
dirs: []
|
|
882
|
+
};
|
|
883
|
+
/** The outputs of a build that writes the one file `outFile`, or none without it. */
|
|
884
|
+
function toBuildOutputs(outputs) {
|
|
885
|
+
if (outputs === void 0) return NO_BUILD_OUTPUTS;
|
|
886
|
+
return typeof outputs === "string" ? {
|
|
887
|
+
files: [outputs],
|
|
888
|
+
dirs: []
|
|
889
|
+
} : outputs;
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* The path outputs are compared by: absolute, with every symbolic link in it
|
|
893
|
+
* resolved, so two paths that reach the same file through different links (a
|
|
894
|
+
* symlinked project folder, an alias of the output folder) compare equal. A
|
|
895
|
+
* path that doesn't exist yet, such as the output before the first build, is
|
|
896
|
+
* its nearest existing folder's real path joined with the rest.
|
|
897
|
+
*/
|
|
898
|
+
function realPathOf(path) {
|
|
899
|
+
const abs = resolve(path);
|
|
900
|
+
try {
|
|
901
|
+
return realpathSync.native(abs);
|
|
902
|
+
} catch {}
|
|
903
|
+
const parent = dirname(abs);
|
|
904
|
+
return parent === abs ? abs : join(realPathOf(parent), basename(abs));
|
|
905
|
+
}
|
|
906
|
+
/** Whether the path `inner` is `outer` or inside it. */
|
|
907
|
+
function isSameOrInside(inner, outer) {
|
|
908
|
+
return inner === outer || inner.startsWith(outer.endsWith(sep) ? outer : outer + sep);
|
|
909
|
+
}
|
|
910
|
+
function outputPaths(outputs) {
|
|
911
|
+
const files = new Set(outputs.files.map(realPathOf));
|
|
912
|
+
const dirs = new Set(outputs.dirs.map(realPathOf));
|
|
913
|
+
const all = [...files, ...dirs];
|
|
914
|
+
return {
|
|
915
|
+
isFile: (real) => files.has(real),
|
|
916
|
+
isDir: (real) => dirs.has(real),
|
|
917
|
+
holds: (real) => dirs.has(real) || all.some((output) => output !== real && isSameOrInside(output, real)),
|
|
918
|
+
isOutput(path, roots) {
|
|
919
|
+
const real = realPathOf(path);
|
|
920
|
+
if (files.has(real)) return true;
|
|
921
|
+
const owners = [...dirs].filter((dir) => isSameOrInside(real, dir));
|
|
922
|
+
if (owners.length === 0) return false;
|
|
923
|
+
return roots.map(realPathOf).filter((root) => isSameOrInside(real, root)).every((root) => owners.some((dir) => dir !== root && isSameOrInside(dir, root)));
|
|
924
|
+
}
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* A folder entry as source discovery walks it, found at `pathname` with the real
|
|
929
|
+
* path `real` when it is no link: a link to a file counts as that file, and a
|
|
930
|
+
* link to anything else (a folder above all) as nothing, undefined. Throws when
|
|
931
|
+
* the entry or a link's target can't be read.
|
|
932
|
+
*/
|
|
933
|
+
function walkedEntry(pathname, real) {
|
|
934
|
+
const stat = lstatSync(pathname);
|
|
935
|
+
if (!stat.isSymbolicLink()) return {
|
|
936
|
+
stat,
|
|
937
|
+
real
|
|
938
|
+
};
|
|
939
|
+
const target = statSync(pathname);
|
|
940
|
+
return target.isFile() ? {
|
|
941
|
+
stat: target,
|
|
942
|
+
real: realPathOf(pathname)
|
|
943
|
+
} : void 0;
|
|
944
|
+
}
|
|
702
945
|
/**
|
|
703
946
|
* Recursively walk directories, collecting regular file paths.
|
|
704
|
-
*
|
|
705
|
-
*
|
|
947
|
+
*
|
|
948
|
+
* Leaves out what the build writes (`outputs`, compared by real path, see
|
|
949
|
+
* realPathOf) so its last output is never read back, and the files that match an
|
|
950
|
+
* `exclude` glob (see isExcluded). A path given that is itself an output file is
|
|
951
|
+
* not walked but listed in `outputSources`.
|
|
952
|
+
*
|
|
953
|
+
* Symbolic links: a path given is followed wherever it leads. Inside a folder, a
|
|
954
|
+
* link to a file is read, but a link to a folder is not followed, as Tweego's walk
|
|
955
|
+
* doesn't follow one. A link back to its own folder or a parent would otherwise
|
|
956
|
+
* walk the same files again at every depth.
|
|
957
|
+
*
|
|
706
958
|
* Like Tweego, a path that cannot be read is reported as a warning and skipped.
|
|
707
959
|
*/
|
|
708
|
-
function getFilenames(pathnames,
|
|
960
|
+
function getFilenames(pathnames, outputs, exclude = []) {
|
|
709
961
|
const filenames = [];
|
|
710
962
|
const diagnostics = [];
|
|
711
|
-
const
|
|
963
|
+
const outputSources = [];
|
|
964
|
+
const output = outputPaths(toBuildOutputs(outputs));
|
|
712
965
|
function warn(pathname, e) {
|
|
713
966
|
diagnostics.push({
|
|
714
967
|
level: "warning",
|
|
715
968
|
message: `path ${pathname}: ${e instanceof Error ? e.message : String(e)}`
|
|
716
969
|
});
|
|
717
970
|
}
|
|
718
|
-
function
|
|
971
|
+
function addFile(pathname) {
|
|
972
|
+
const abs = resolve(pathname);
|
|
973
|
+
const rel = relative(process.cwd(), abs);
|
|
974
|
+
if (isExcluded(rel || abs, exclude)) return;
|
|
975
|
+
filenames.push(rel || abs);
|
|
976
|
+
}
|
|
977
|
+
function walkDir(pathname, real) {
|
|
978
|
+
let entries;
|
|
979
|
+
try {
|
|
980
|
+
entries = readdirSync(pathname);
|
|
981
|
+
} catch (e) {
|
|
982
|
+
warn(pathname, e);
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
for (const entry of entries) walkEntry(join(pathname, entry), join(real, entry));
|
|
986
|
+
}
|
|
987
|
+
function walkEntry(pathname, real) {
|
|
988
|
+
let entry;
|
|
989
|
+
try {
|
|
990
|
+
entry = walkedEntry(pathname, real);
|
|
991
|
+
} catch (e) {
|
|
992
|
+
warn(pathname, e);
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
if (entry === void 0) return;
|
|
996
|
+
if (entry.stat.isFile()) {
|
|
997
|
+
if (!output.isFile(entry.real)) addFile(pathname);
|
|
998
|
+
} else if (entry.stat.isDirectory() && !output.isDir(entry.real)) walkDir(pathname, entry.real);
|
|
999
|
+
}
|
|
1000
|
+
for (const pathname of pathnames) {
|
|
719
1001
|
let stat;
|
|
720
1002
|
try {
|
|
721
1003
|
stat = statSync(pathname);
|
|
722
1004
|
} catch (e) {
|
|
723
1005
|
warn(pathname, e);
|
|
724
|
-
|
|
1006
|
+
continue;
|
|
725
1007
|
}
|
|
1008
|
+
const real = realPathOf(pathname);
|
|
726
1009
|
if (stat.isFile()) {
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
if (isExcluded(rel || abs, exclude)) return;
|
|
731
|
-
filenames.push(rel || abs);
|
|
732
|
-
} else if (stat.isDirectory()) {
|
|
733
|
-
let entries;
|
|
734
|
-
try {
|
|
735
|
-
entries = readdirSync(pathname);
|
|
736
|
-
} catch (e) {
|
|
737
|
-
warn(pathname, e);
|
|
738
|
-
return;
|
|
739
|
-
}
|
|
740
|
-
for (const entry of entries) walk(join(pathname, entry));
|
|
741
|
-
}
|
|
1010
|
+
if (output.isFile(real)) outputSources.push(pathname);
|
|
1011
|
+
else addFile(pathname);
|
|
1012
|
+
} else if (stat.isDirectory()) walkDir(pathname, real);
|
|
742
1013
|
}
|
|
743
|
-
for (const pathname of pathnames) walk(pathname);
|
|
744
1014
|
return {
|
|
745
1015
|
filenames,
|
|
746
|
-
diagnostics
|
|
1016
|
+
diagnostics,
|
|
1017
|
+
outputSources
|
|
747
1018
|
};
|
|
748
1019
|
}
|
|
749
|
-
/** Whether the absolute path `file` is the absolute path `dir` or inside it. */
|
|
750
|
-
function isInsideDir(file, dir) {
|
|
751
|
-
return file === dir || file.startsWith(dir.endsWith(sep) ? dir : dir + sep);
|
|
752
|
-
}
|
|
753
1020
|
/**
|
|
754
|
-
*
|
|
755
|
-
*
|
|
1021
|
+
* A watched path that can't be watched: fs.watch failed on it, or on the folder it is
|
|
1022
|
+
* watched through. watchFilesystem passes it to its `onError` and keeps watching the rest.
|
|
756
1023
|
*/
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
isFile = statSync(pathname).isFile();
|
|
764
|
-
} catch {}
|
|
765
|
-
if (isFile) files.add(resolve(pathname));
|
|
766
|
-
else dirs.push(pathname);
|
|
1024
|
+
var WatchPathError = class extends Error {
|
|
1025
|
+
path;
|
|
1026
|
+
constructor(path, cause) {
|
|
1027
|
+
super(`Cannot watch ${path}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
1028
|
+
this.path = path;
|
|
1029
|
+
this.name = "WatchPathError";
|
|
767
1030
|
}
|
|
1031
|
+
};
|
|
1032
|
+
function pathKind(path) {
|
|
1033
|
+
try {
|
|
1034
|
+
const stats = statSync(path);
|
|
1035
|
+
return stats.isDirectory() ? {
|
|
1036
|
+
kind: "dir",
|
|
1037
|
+
id: `${stats.dev}:${stats.ino}`
|
|
1038
|
+
} : { kind: "file" };
|
|
1039
|
+
} catch {
|
|
1040
|
+
return { kind: "missing" };
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
function rootState(abs) {
|
|
768
1044
|
return {
|
|
769
|
-
|
|
770
|
-
|
|
1045
|
+
anchor: nearestFolderAbove(abs),
|
|
1046
|
+
self: pathKind(abs)
|
|
771
1047
|
};
|
|
772
1048
|
}
|
|
1049
|
+
function nearestFolderAbove(abs) {
|
|
1050
|
+
for (let dir = dirname(abs), below = abs; dir !== below; below = dir, dir = dirname(dir)) {
|
|
1051
|
+
const kind = pathKind(dir);
|
|
1052
|
+
if (kind.kind === "dir") return {
|
|
1053
|
+
path: dir,
|
|
1054
|
+
id: kind.id
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
/** Whether two states need the same watches: the same anchor folder, and the same watched folder if any. */
|
|
1059
|
+
function sameWatches(a, b) {
|
|
1060
|
+
const selfId = (s) => s.self.kind === "dir" ? s.self.id : void 0;
|
|
1061
|
+
return a.anchor?.path === b.anchor?.path && a.anchor?.id === b.anchor?.id && selfId(a) === selfId(b);
|
|
1062
|
+
}
|
|
773
1063
|
/**
|
|
774
1064
|
* Watch paths for changes, calling the build callback on known file type changes.
|
|
775
1065
|
* A folder is watched recursively; a file is watched on its own and counts
|
|
776
1066
|
* whatever its type (a head file, say). A change to a file `ignore` returns true
|
|
777
1067
|
* for (given the path relative to the working directory) schedules no build.
|
|
778
1068
|
* Uses debouncing to avoid rapid rebuilds.
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
1069
|
+
*
|
|
1070
|
+
* A watched path that doesn't exist yet is waited for, and one that is deleted, or renamed
|
|
1071
|
+
* away and replaced, is followed to the new folder or file at its path: each path is also
|
|
1072
|
+
* watched through the folder above it. When that sets up a path's watches again, the next
|
|
1073
|
+
* build is a full one (`changedFiles` undefined), since changes made in between went unseen.
|
|
1074
|
+
* A path that can't be watched is passed to `onError` as a WatchPathError, once while the
|
|
1075
|
+
* failure persists; the other paths are still watched.
|
|
1076
|
+
*/
|
|
1077
|
+
function watchFilesystem(pathnames, outFilename, callback, ignore = () => false, onError = () => {}) {
|
|
1078
|
+
const output = outputPaths(toBuildOutputs(outFilename));
|
|
783
1079
|
let buildTimer = null;
|
|
784
1080
|
const BUILD_DEBOUNCE = 500;
|
|
785
1081
|
const pendingFiles = /* @__PURE__ */ new Set();
|
|
1082
|
+
let pendingFullBuild = false;
|
|
1083
|
+
let closed = false;
|
|
786
1084
|
function scheduleBuild(changedFile) {
|
|
787
|
-
if (changedFile)
|
|
1085
|
+
if (changedFile === void 0) pendingFullBuild = true;
|
|
1086
|
+
else pendingFiles.add(changedFile);
|
|
788
1087
|
if (buildTimer) clearTimeout(buildTimer);
|
|
789
1088
|
buildTimer = setTimeout(() => {
|
|
790
1089
|
buildTimer = null;
|
|
791
|
-
const files = pendingFiles.size
|
|
1090
|
+
const files = pendingFullBuild || pendingFiles.size === 0 ? void 0 : new Set(pendingFiles);
|
|
792
1091
|
pendingFiles.clear();
|
|
1092
|
+
pendingFullBuild = false;
|
|
793
1093
|
callback(files);
|
|
794
1094
|
}, BUILD_DEBOUNCE);
|
|
795
1095
|
}
|
|
1096
|
+
const roots = pathnames.map((path) => {
|
|
1097
|
+
const abs = resolve(path);
|
|
1098
|
+
return {
|
|
1099
|
+
path,
|
|
1100
|
+
abs,
|
|
1101
|
+
state: rootState(abs),
|
|
1102
|
+
watchers: [],
|
|
1103
|
+
failed: false,
|
|
1104
|
+
lastReport: void 0
|
|
1105
|
+
};
|
|
1106
|
+
});
|
|
1107
|
+
const isNamedFile = (abs) => roots.some((r) => r.abs === abs && r.state.self.kind === "file");
|
|
796
1108
|
function fileChanged(abs, named) {
|
|
797
|
-
if (abs === absOutFile) return;
|
|
798
1109
|
if (!named && !isKnownFileType(abs)) return;
|
|
1110
|
+
if (output.isFile(realPathOf(abs))) return;
|
|
799
1111
|
const rel = relative(process.cwd(), abs);
|
|
800
1112
|
if (!ignore(rel || abs)) scheduleBuild(rel || abs);
|
|
801
1113
|
}
|
|
802
|
-
function
|
|
1114
|
+
function report(root, cause) {
|
|
1115
|
+
const error = new WatchPathError(root.path, cause);
|
|
1116
|
+
if (error.message === root.lastReport) return;
|
|
1117
|
+
root.lastReport = error.message;
|
|
1118
|
+
onError(error);
|
|
1119
|
+
}
|
|
1120
|
+
function startWatch(root, path, recursive, listener) {
|
|
803
1121
|
try {
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
1122
|
+
if (recursive) accessSync(path, constants.R_OK | constants.X_OK);
|
|
1123
|
+
const watcher = watch$1(path, { recursive }, (_event, filename) => {
|
|
1124
|
+
if (!closed) listener(filename ?? "");
|
|
1125
|
+
});
|
|
1126
|
+
watcher.on("error", (e) => {
|
|
1127
|
+
watcher.close();
|
|
1128
|
+
root.watchers = root.watchers.filter((w) => w !== watcher);
|
|
1129
|
+
root.failed = true;
|
|
1130
|
+
if (!closed && !recheck(root, false)) report(root, e);
|
|
1131
|
+
});
|
|
1132
|
+
root.watchers.push(watcher);
|
|
1133
|
+
} catch (e) {
|
|
1134
|
+
root.failed = true;
|
|
1135
|
+
report(root, e);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
function arm(root) {
|
|
1139
|
+
for (const w of root.watchers) w.close();
|
|
1140
|
+
root.watchers = [];
|
|
1141
|
+
root.failed = false;
|
|
1142
|
+
const { anchor, self } = root.state;
|
|
1143
|
+
if (anchor) startWatch(root, anchor.path, false, (filename) => {
|
|
1144
|
+
const before = root.state.self.kind;
|
|
1145
|
+
recheck(root, true);
|
|
1146
|
+
if ((before === "file" || isNamedFile(root.abs)) && filename !== "" && resolve(anchor.path, filename) === root.abs) fileChanged(root.abs, true);
|
|
1147
|
+
});
|
|
1148
|
+
if (self.kind === "dir") startWatch(root, root.path, true, (filename) => {
|
|
1149
|
+
if (filename === "") recheck(root, true);
|
|
1150
|
+
else {
|
|
1151
|
+
const abs = resolve(root.abs, filename);
|
|
1152
|
+
fileChanged(abs, isNamedFile(abs));
|
|
1153
|
+
}
|
|
1154
|
+
});
|
|
1155
|
+
if (!root.failed) root.lastReport = void 0;
|
|
808
1156
|
}
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
const
|
|
818
|
-
|
|
819
|
-
|
|
1157
|
+
/**
|
|
1158
|
+
* Sets up `root`'s watches again when its path, or the folder above it, is no longer the
|
|
1159
|
+
* one they were started on (or, with `retry`, when one of them failed), and then schedules a
|
|
1160
|
+
* full build. Returns whether its state changed.
|
|
1161
|
+
*/
|
|
1162
|
+
function recheck(root, retry) {
|
|
1163
|
+
const before = root.state;
|
|
1164
|
+
const after = rootState(root.abs);
|
|
1165
|
+
const changed = !sameWatches(before, after);
|
|
1166
|
+
const wasFailed = root.failed;
|
|
1167
|
+
if (!changed && !(retry && wasFailed)) {
|
|
1168
|
+
root.state = after;
|
|
1169
|
+
return false;
|
|
1170
|
+
}
|
|
1171
|
+
root.state = after;
|
|
1172
|
+
arm(root);
|
|
1173
|
+
const recovered = wasFailed && !root.failed;
|
|
1174
|
+
if ((changed || recovered) && (before.self.kind !== "missing" || after.self.kind !== "missing")) scheduleBuild();
|
|
1175
|
+
return changed;
|
|
1176
|
+
}
|
|
1177
|
+
for (const root of roots) arm(root);
|
|
820
1178
|
callback();
|
|
821
1179
|
return { close() {
|
|
1180
|
+
closed = true;
|
|
822
1181
|
if (buildTimer) clearTimeout(buildTimer);
|
|
823
|
-
for (const w of watchers) w.close();
|
|
1182
|
+
for (const root of roots) for (const w of root.watchers) w.close();
|
|
824
1183
|
} };
|
|
825
1184
|
}
|
|
826
1185
|
//#endregion
|
|
1186
|
+
//#region src/relaxed-json.ts
|
|
1187
|
+
/**
|
|
1188
|
+
* A parser for the object literal a Twine 2 `format.js` passes to `window.storyFormat()`.
|
|
1189
|
+
*
|
|
1190
|
+
* The story formats spec does not require that object to be strict JSON, and Twine 2 runs
|
|
1191
|
+
* `format.js` as JavaScript. This parser accepts JSON plus the JavaScript literal syntax formats
|
|
1192
|
+
* use in practice: single-quoted strings, unquoted (identifier) and numeric keys, trailing commas,
|
|
1193
|
+
* comments, JavaScript string escapes, and hexadecimal, octal and binary numbers.
|
|
1194
|
+
*
|
|
1195
|
+
* It reads string literals token by token, so their contents come back exactly as JavaScript
|
|
1196
|
+
* evaluation gives them: text that only looks like structure (`a, }`, `word:`, an apostrophe) is
|
|
1197
|
+
* never touched. Anything outside that subset, such as a function or a variable, is a syntax error.
|
|
1198
|
+
*/
|
|
1199
|
+
const IDENTIFIER = /[A-Za-z_$][\w$]*/y;
|
|
1200
|
+
const NUMBER = /[+-]?(?:0[xX][0-9a-fA-F]+|0[oO][0-7]+|0[bB][01]+|(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)/y;
|
|
1201
|
+
const IDENTIFIER_PART = /[\w$]/;
|
|
1202
|
+
const WHITESPACE = /[\s]/;
|
|
1203
|
+
const HEX = /^[0-9a-fA-F]+$/;
|
|
1204
|
+
/** Simple single-character escapes and the characters they stand for. */
|
|
1205
|
+
const SIMPLE_ESCAPES = {
|
|
1206
|
+
b: "\b",
|
|
1207
|
+
f: "\f",
|
|
1208
|
+
n: "\n",
|
|
1209
|
+
r: "\r",
|
|
1210
|
+
t: " ",
|
|
1211
|
+
v: "\v"
|
|
1212
|
+
};
|
|
1213
|
+
/**
|
|
1214
|
+
* Parse a JavaScript object, array or primitive literal: all of `text`, or the part from `start` to
|
|
1215
|
+
* `end`. Error positions count from the start of `text`, so they point into the file it came from.
|
|
1216
|
+
*
|
|
1217
|
+
* @throws SyntaxError naming the line and column of the first thing it cannot parse.
|
|
1218
|
+
*/
|
|
1219
|
+
function parseRelaxedJSON(text, start = 0, end = text.length) {
|
|
1220
|
+
let pos = start;
|
|
1221
|
+
function fail(message, at = pos) {
|
|
1222
|
+
const before = text.slice(0, at);
|
|
1223
|
+
const line = before.split("\n").length;
|
|
1224
|
+
const column = at - before.lastIndexOf("\n");
|
|
1225
|
+
throw new SyntaxError(`${message} at line ${line}, column ${column}`);
|
|
1226
|
+
}
|
|
1227
|
+
/** The character at `pos`, or '' at the end of the input. */
|
|
1228
|
+
const peek = () => pos < end ? text[pos] ?? "" : "";
|
|
1229
|
+
const describeChar = (at) => at >= end ? "end of input" : JSON.stringify(text[at]);
|
|
1230
|
+
/** Skip whitespace and comments. */
|
|
1231
|
+
const skipTrivia = () => {
|
|
1232
|
+
while (pos < end) {
|
|
1233
|
+
const ch = peek();
|
|
1234
|
+
if (WHITESPACE.test(ch)) pos++;
|
|
1235
|
+
else if (text.startsWith("//", pos)) {
|
|
1236
|
+
const lineEnd = text.slice(pos, end).search(/[\n\r\u2028\u2029]/);
|
|
1237
|
+
pos = lineEnd === -1 ? end : pos + lineEnd;
|
|
1238
|
+
} else if (text.startsWith("/*", pos)) {
|
|
1239
|
+
const commentEnd = text.indexOf("*/", pos + 2);
|
|
1240
|
+
if (commentEnd === -1 || commentEnd + 2 > end) fail("Unterminated comment");
|
|
1241
|
+
pos = commentEnd + 2;
|
|
1242
|
+
} else return;
|
|
1243
|
+
}
|
|
1244
|
+
};
|
|
1245
|
+
const consume = (ch) => {
|
|
1246
|
+
skipTrivia();
|
|
1247
|
+
if (peek() !== ch) fail(`Expected ${JSON.stringify(ch)} but found ${describeChar(pos)}`);
|
|
1248
|
+
pos++;
|
|
1249
|
+
};
|
|
1250
|
+
/** Read exactly `count` hex digits and return their value. */
|
|
1251
|
+
const readHex = (count) => {
|
|
1252
|
+
const digits = text.slice(pos, Math.min(pos + count, end));
|
|
1253
|
+
if (digits.length !== count || !HEX.test(digits)) fail("Invalid hexadecimal escape sequence");
|
|
1254
|
+
pos += count;
|
|
1255
|
+
return parseInt(digits, 16);
|
|
1256
|
+
};
|
|
1257
|
+
/** Decode the escape sequence after a backslash; `pos` is just past the backslash. */
|
|
1258
|
+
const readEscape = () => {
|
|
1259
|
+
const ch = peek();
|
|
1260
|
+
const simple = SIMPLE_ESCAPES[ch];
|
|
1261
|
+
if (simple !== void 0) {
|
|
1262
|
+
pos++;
|
|
1263
|
+
return simple;
|
|
1264
|
+
}
|
|
1265
|
+
switch (ch) {
|
|
1266
|
+
case "": return fail("Unterminated string");
|
|
1267
|
+
case "x":
|
|
1268
|
+
pos++;
|
|
1269
|
+
return String.fromCharCode(readHex(2));
|
|
1270
|
+
case "u": {
|
|
1271
|
+
pos++;
|
|
1272
|
+
if (peek() !== "{") return String.fromCharCode(readHex(4));
|
|
1273
|
+
const close = text.indexOf("}", pos);
|
|
1274
|
+
if (close === -1 || close >= end) fail("Invalid Unicode escape sequence");
|
|
1275
|
+
pos++;
|
|
1276
|
+
const codePoint = readHex(close - pos);
|
|
1277
|
+
if (codePoint > 1114111) fail("Undefined Unicode code-point");
|
|
1278
|
+
pos = close + 1;
|
|
1279
|
+
return String.fromCodePoint(codePoint);
|
|
1280
|
+
}
|
|
1281
|
+
case "\r":
|
|
1282
|
+
pos += text[pos + 1] === "\n" ? 2 : 1;
|
|
1283
|
+
return "";
|
|
1284
|
+
case "\n":
|
|
1285
|
+
case "\u2028":
|
|
1286
|
+
case "\u2029":
|
|
1287
|
+
pos++;
|
|
1288
|
+
return "";
|
|
1289
|
+
case "0":
|
|
1290
|
+
if (/[0-9]/.test(text[pos + 1] ?? "")) fail("Octal escape sequences are not supported");
|
|
1291
|
+
pos++;
|
|
1292
|
+
return "\0";
|
|
1293
|
+
default:
|
|
1294
|
+
if (/[1-9]/.test(ch)) fail("Octal escape sequences are not supported");
|
|
1295
|
+
pos++;
|
|
1296
|
+
return ch;
|
|
1297
|
+
}
|
|
1298
|
+
};
|
|
1299
|
+
const readString = () => {
|
|
1300
|
+
const quote = peek();
|
|
1301
|
+
pos++;
|
|
1302
|
+
let out = "";
|
|
1303
|
+
let chunkStart = pos;
|
|
1304
|
+
for (;;) {
|
|
1305
|
+
if (pos >= end) fail("Unterminated string");
|
|
1306
|
+
const ch = peek();
|
|
1307
|
+
if (ch === quote) {
|
|
1308
|
+
out += text.slice(chunkStart, pos);
|
|
1309
|
+
pos++;
|
|
1310
|
+
return out;
|
|
1311
|
+
}
|
|
1312
|
+
if (ch === "\n" || ch === "\r") fail("Unterminated string");
|
|
1313
|
+
if (ch === "\\") {
|
|
1314
|
+
out += text.slice(chunkStart, pos);
|
|
1315
|
+
pos++;
|
|
1316
|
+
out += readEscape();
|
|
1317
|
+
chunkStart = pos;
|
|
1318
|
+
} else pos++;
|
|
1319
|
+
}
|
|
1320
|
+
};
|
|
1321
|
+
/** Read a match of a sticky regular expression at `pos`, or return undefined. */
|
|
1322
|
+
const readToken = (pattern) => {
|
|
1323
|
+
pattern.lastIndex = pos;
|
|
1324
|
+
const m = pattern.exec(text);
|
|
1325
|
+
if (!m || pattern.lastIndex > end) return void 0;
|
|
1326
|
+
pos = pattern.lastIndex;
|
|
1327
|
+
return m[0];
|
|
1328
|
+
};
|
|
1329
|
+
const readNumber = () => {
|
|
1330
|
+
const tokenStart = pos;
|
|
1331
|
+
const token = readToken(NUMBER);
|
|
1332
|
+
if (token === void 0) return void 0;
|
|
1333
|
+
if (IDENTIFIER_PART.test(peek())) fail("Invalid number", tokenStart);
|
|
1334
|
+
return (token.startsWith("-") ? -1 : 1) * Number(/^[+-]/.test(token) ? token.slice(1) : token);
|
|
1335
|
+
};
|
|
1336
|
+
const readKey = () => {
|
|
1337
|
+
skipTrivia();
|
|
1338
|
+
const ch = peek();
|
|
1339
|
+
if (ch === "\"" || ch === "'") return readString();
|
|
1340
|
+
const identifier = readToken(IDENTIFIER);
|
|
1341
|
+
if (identifier !== void 0) return identifier;
|
|
1342
|
+
const number = readNumber();
|
|
1343
|
+
if (number !== void 0) return String(number);
|
|
1344
|
+
return fail(`Expected a property name but found ${describeChar(pos)}`);
|
|
1345
|
+
};
|
|
1346
|
+
const readObject = () => {
|
|
1347
|
+
pos++;
|
|
1348
|
+
const out = {};
|
|
1349
|
+
for (;;) {
|
|
1350
|
+
skipTrivia();
|
|
1351
|
+
if (peek() === "}") {
|
|
1352
|
+
pos++;
|
|
1353
|
+
return out;
|
|
1354
|
+
}
|
|
1355
|
+
const key = readKey();
|
|
1356
|
+
consume(":");
|
|
1357
|
+
Object.defineProperty(out, key, {
|
|
1358
|
+
value: readValue(),
|
|
1359
|
+
enumerable: true,
|
|
1360
|
+
writable: true,
|
|
1361
|
+
configurable: true
|
|
1362
|
+
});
|
|
1363
|
+
skipTrivia();
|
|
1364
|
+
if (peek() === ",") pos++;
|
|
1365
|
+
else if (peek() !== "}") fail(`Expected "," or "}" but found ${describeChar(pos)}`);
|
|
1366
|
+
}
|
|
1367
|
+
};
|
|
1368
|
+
const readArray = () => {
|
|
1369
|
+
pos++;
|
|
1370
|
+
const out = [];
|
|
1371
|
+
for (;;) {
|
|
1372
|
+
skipTrivia();
|
|
1373
|
+
if (peek() === "]") {
|
|
1374
|
+
pos++;
|
|
1375
|
+
return out;
|
|
1376
|
+
}
|
|
1377
|
+
out.push(readValue());
|
|
1378
|
+
skipTrivia();
|
|
1379
|
+
if (peek() === ",") pos++;
|
|
1380
|
+
else if (peek() !== "]") fail(`Expected "," or "]" but found ${describeChar(pos)}`);
|
|
1381
|
+
}
|
|
1382
|
+
};
|
|
1383
|
+
function readValue() {
|
|
1384
|
+
skipTrivia();
|
|
1385
|
+
const ch = peek();
|
|
1386
|
+
if (ch === "{") return readObject();
|
|
1387
|
+
if (ch === "[") return readArray();
|
|
1388
|
+
if (ch === "\"" || ch === "'") return readString();
|
|
1389
|
+
const tokenStart = pos;
|
|
1390
|
+
const identifier = readToken(IDENTIFIER);
|
|
1391
|
+
if (identifier !== void 0) {
|
|
1392
|
+
if (identifier === "true") return true;
|
|
1393
|
+
if (identifier === "false") return false;
|
|
1394
|
+
if (identifier === "null") return null;
|
|
1395
|
+
return fail(`Unexpected identifier ${JSON.stringify(identifier)}`, tokenStart);
|
|
1396
|
+
}
|
|
1397
|
+
const number = readNumber();
|
|
1398
|
+
if (number !== void 0) return number;
|
|
1399
|
+
return fail(`Unexpected ${describeChar(pos)}`);
|
|
1400
|
+
}
|
|
1401
|
+
const value = readValue();
|
|
1402
|
+
skipTrivia();
|
|
1403
|
+
if (pos < end) fail(`Unexpected ${describeChar(pos)} after the value`);
|
|
1404
|
+
return value;
|
|
1405
|
+
}
|
|
1406
|
+
//#endregion
|
|
1407
|
+
//#region src/semver.ts
|
|
1408
|
+
const IDENTIFIERS = String.raw`[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*`;
|
|
1409
|
+
const VERSION$1 = new RegExp(String.raw`^[vV]?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-(${IDENTIFIERS}))?(?:\+${IDENTIFIERS})?$`);
|
|
1410
|
+
const NUMERIC = /^\d+$/;
|
|
1411
|
+
/** Parse a version string, or return null when it is not a version. */
|
|
1412
|
+
function parseVersion(text) {
|
|
1413
|
+
const m = VERSION$1.exec(text);
|
|
1414
|
+
if (!m) return null;
|
|
1415
|
+
return {
|
|
1416
|
+
major: Number(m[1]),
|
|
1417
|
+
minor: Number(m[2] ?? 0),
|
|
1418
|
+
patch: Number(m[3] ?? 0),
|
|
1419
|
+
prerelease: m[4] === void 0 ? [] : m[4].split(".")
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
/** Compare two prerelease identifiers: numeric ones numerically and below alphanumeric ones, which compare as ASCII. */
|
|
1423
|
+
function compareIdentifiers(a, b) {
|
|
1424
|
+
const aNumeric = NUMERIC.test(a);
|
|
1425
|
+
const bNumeric = NUMERIC.test(b);
|
|
1426
|
+
if (aNumeric && bNumeric) return Math.sign(Number(a) - Number(b));
|
|
1427
|
+
if (aNumeric !== bNumeric) return aNumeric ? -1 : 1;
|
|
1428
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
1429
|
+
}
|
|
1430
|
+
/** Compare prerelease identifier lists. An empty list (a release) ranks above any prerelease. */
|
|
1431
|
+
function comparePrerelease(a, b) {
|
|
1432
|
+
if (a.length === 0 || b.length === 0) return Math.sign(b.length - a.length);
|
|
1433
|
+
const shared = Math.min(a.length, b.length);
|
|
1434
|
+
for (let i = 0; i < shared; i++) {
|
|
1435
|
+
const order = compareIdentifiers(a[i] ?? "", b[i] ?? "");
|
|
1436
|
+
if (order !== 0) return order;
|
|
1437
|
+
}
|
|
1438
|
+
return Math.sign(a.length - b.length);
|
|
1439
|
+
}
|
|
1440
|
+
/** Compare two versions by SemVer precedence: negative when `a` ranks lower, 0 when equal, positive when higher. */
|
|
1441
|
+
function compareVersions(a, b) {
|
|
1442
|
+
return Math.sign(a.major - b.major) || Math.sign(a.minor - b.minor) || Math.sign(a.patch - b.patch) || comparePrerelease(a.prerelease, b.prerelease);
|
|
1443
|
+
}
|
|
1444
|
+
/** Whether two version strings name the same version (prerelease included, build metadata ignored). */
|
|
1445
|
+
function sameVersion(a, b) {
|
|
1446
|
+
const left = parseVersion(a);
|
|
1447
|
+
const right = parseVersion(b);
|
|
1448
|
+
return left !== null && right !== null && compareVersions(left, right) === 0;
|
|
1449
|
+
}
|
|
1450
|
+
//#endregion
|
|
827
1451
|
//#region src/source-text.ts
|
|
828
1452
|
/**
|
|
829
1453
|
* Source text normalization shared by every input path (files, in-memory sources, `parseTwee`).
|
|
@@ -835,14 +1459,119 @@ function watchFilesystem(pathnames, outFilename, callback, ignore = () => false)
|
|
|
835
1459
|
function normalizeSourceText(text) {
|
|
836
1460
|
return (text.charCodeAt(0) === 65279 ? text.slice(1) : text).replace(/\r\n?/g, "\n");
|
|
837
1461
|
}
|
|
1462
|
+
/** Byte order marks at the start of a line, directly before a passage header's `::`. */
|
|
1463
|
+
const BOMS_BEFORE_HEADER = /(?<=^|\n)\uFEFF+(?=::)/g;
|
|
1464
|
+
/**
|
|
1465
|
+
* Normalize Twee source text: {@link normalizeSourceText}, and also remove byte order marks at the start of
|
|
1466
|
+
* a line that are directly followed by `::`. Concatenating files (`cat a.tw b.tw`) leaves the BOM of every
|
|
1467
|
+
* later file in front of its first passage header, which would otherwise not be recognised as a header.
|
|
1468
|
+
* A U+FEFF anywhere else is passage text and kept. Idempotent.
|
|
1469
|
+
*/
|
|
1470
|
+
function normalizeTweeSourceText(text) {
|
|
1471
|
+
return normalizeSourceText(text).replace(BOMS_BEFORE_HEADER, "");
|
|
1472
|
+
}
|
|
838
1473
|
//#endregion
|
|
839
1474
|
//#region src/util.ts
|
|
840
1475
|
/**
|
|
841
1476
|
* Shared file I/O utilities.
|
|
842
1477
|
*/
|
|
843
|
-
/**
|
|
844
|
-
|
|
845
|
-
|
|
1478
|
+
/** The charset assumed for text that is not valid UTF-8, as Tweego assumes it. */
|
|
1479
|
+
const FALLBACK_CHARSET = "windows-1252";
|
|
1480
|
+
/**
|
|
1481
|
+
* Throws on invalid UTF-8 instead of replacing bytes with U+FFFD. `ignoreBOM` keeps a leading BOM in the
|
|
1482
|
+
* text, as `readFileSync(file, 'utf-8')` did, so {@link normalizeSourceText} strips exactly one.
|
|
1483
|
+
*/
|
|
1484
|
+
const utf8Decoder = new TextDecoder("utf-8", {
|
|
1485
|
+
fatal: true,
|
|
1486
|
+
ignoreBOM: true
|
|
1487
|
+
});
|
|
1488
|
+
/**
|
|
1489
|
+
* The code points of the Windows-1252 bytes 0x80 to 0x9F, from the WHATWG Encoding Standard. The five bytes
|
|
1490
|
+
* Windows-1252 leaves undefined map to the C1 control with the same number, as the standard and Go's charmap
|
|
1491
|
+
* do. Every other byte is the code point with the same number.
|
|
1492
|
+
*
|
|
1493
|
+
* Decoded by hand, because Node's `TextDecoder('windows-1252')` decodes as ISO-8859-1 in some releases
|
|
1494
|
+
* (Node 22.18 turns 0x80 into U+0080 instead of €), and builds without ICU lack it.
|
|
1495
|
+
*/
|
|
1496
|
+
const WINDOWS_1252_C1 = [
|
|
1497
|
+
8364,
|
|
1498
|
+
129,
|
|
1499
|
+
8218,
|
|
1500
|
+
402,
|
|
1501
|
+
8222,
|
|
1502
|
+
8230,
|
|
1503
|
+
8224,
|
|
1504
|
+
8225,
|
|
1505
|
+
710,
|
|
1506
|
+
8240,
|
|
1507
|
+
352,
|
|
1508
|
+
8249,
|
|
1509
|
+
338,
|
|
1510
|
+
141,
|
|
1511
|
+
381,
|
|
1512
|
+
143,
|
|
1513
|
+
144,
|
|
1514
|
+
8216,
|
|
1515
|
+
8217,
|
|
1516
|
+
8220,
|
|
1517
|
+
8221,
|
|
1518
|
+
8226,
|
|
1519
|
+
8211,
|
|
1520
|
+
8212,
|
|
1521
|
+
732,
|
|
1522
|
+
8482,
|
|
1523
|
+
353,
|
|
1524
|
+
8250,
|
|
1525
|
+
339,
|
|
1526
|
+
157,
|
|
1527
|
+
382,
|
|
1528
|
+
376
|
|
1529
|
+
];
|
|
1530
|
+
function decodeWindows1252(bytes) {
|
|
1531
|
+
const chunks = [];
|
|
1532
|
+
for (let start = 0; start < bytes.length; start += 8192) {
|
|
1533
|
+
const codes = Array.from(bytes.subarray(start, start + 8192), (b) => b >= 128 && b <= 159 ? WINDOWS_1252_C1[b - 128] ?? b : b);
|
|
1534
|
+
chunks.push(String.fromCharCode(...codes));
|
|
1535
|
+
}
|
|
1536
|
+
return chunks.join("");
|
|
1537
|
+
}
|
|
1538
|
+
/**
|
|
1539
|
+
* Decode bytes read from `filename` as UTF-8. Bytes that are not valid UTF-8 are decoded as Windows-1252
|
|
1540
|
+
* instead, with a warning naming the file, as Tweego does (`fileReadAllWithEncoding` in io.go), so a legacy
|
|
1541
|
+
* file keeps its non-ASCII characters instead of losing them to U+FFFD.
|
|
1542
|
+
*
|
|
1543
|
+
* The text is returned as decoded: a leading BOM and CR line endings are kept (see {@link normalizeSourceText}).
|
|
1544
|
+
*/
|
|
1545
|
+
function decodeText(bytes, filename) {
|
|
1546
|
+
try {
|
|
1547
|
+
return {
|
|
1548
|
+
text: utf8Decoder.decode(bytes),
|
|
1549
|
+
diagnostics: []
|
|
1550
|
+
};
|
|
1551
|
+
} catch {
|
|
1552
|
+
return {
|
|
1553
|
+
text: decodeWindows1252(bytes),
|
|
1554
|
+
diagnostics: [{
|
|
1555
|
+
level: "warning",
|
|
1556
|
+
message: `read ${filename}: Invalid UTF-8; assuming charset is ${FALLBACK_CHARSET}.`,
|
|
1557
|
+
file: filename
|
|
1558
|
+
}]
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
/**
|
|
1563
|
+
* Read a text file: decode it with {@link decodeText} (UTF-8, or Windows-1252 when it is not valid UTF-8),
|
|
1564
|
+
* strip a leading BOM and convert CRLF and bare CR line endings to LF. This is the one path every file read
|
|
1565
|
+
* as text goes through: sources, modules, the head file, story formats and the config file.
|
|
1566
|
+
*
|
|
1567
|
+
* @param diagnostics Receives the warning when the file is not valid UTF-8. Without it, the file is decoded
|
|
1568
|
+
* the same way and the warning is dropped.
|
|
1569
|
+
* @throws When the file cannot be read.
|
|
1570
|
+
*/
|
|
1571
|
+
function readUTF8(filename, diagnostics) {
|
|
1572
|
+
const decoded = decodeText(readFileSync(filename), filename);
|
|
1573
|
+
diagnostics?.push(...decoded.diagnostics);
|
|
1574
|
+
return normalizeSourceText(decoded.text);
|
|
846
1575
|
}
|
|
847
1576
|
/** Read a file as base64. */
|
|
848
1577
|
function readBase64(filename) {
|
|
@@ -858,20 +1587,11 @@ function baseNameWithoutExt(filename) {
|
|
|
858
1587
|
/**
|
|
859
1588
|
* Story format discovery, loading, and SemVer matching.
|
|
860
1589
|
* Ported from formats.go + config.go.
|
|
1590
|
+
*
|
|
1591
|
+
* Every lookup (local folders here, the download cache and remote indices in remote-formats.ts)
|
|
1592
|
+
* goes through the same version parsing ({@link parseVersion}) and the same matching rules
|
|
1593
|
+
* ({@link selectFormatCandidate}).
|
|
861
1594
|
*/
|
|
862
|
-
/**
|
|
863
|
-
* Attempt to fix non-strict JSON (trailing commas, single quotes, unquoted keys).
|
|
864
|
-
*/
|
|
865
|
-
function relaxedJSONParse(json) {
|
|
866
|
-
try {
|
|
867
|
-
return JSON.parse(json);
|
|
868
|
-
} catch {}
|
|
869
|
-
let fixed = json;
|
|
870
|
-
fixed = fixed.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, "\"$1\"");
|
|
871
|
-
fixed = fixed.replace(/(?<=^|[{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:/gm, "\"$1\":");
|
|
872
|
-
fixed = fixed.replace(/,\s*([}\]])/g, "$1");
|
|
873
|
-
return JSON.parse(fixed);
|
|
874
|
-
}
|
|
875
1595
|
/** A `"setup": function` property, which Harlowe appends to its otherwise-JSON format object. */
|
|
876
1596
|
const SETUP_FUNCTION_PROPERTY = /,\s*"setup"\s*:\s*function\b/g;
|
|
877
1597
|
/** Drop a trailing `"setup": function(){…}` property from a format object, if it has one. */
|
|
@@ -881,151 +1601,247 @@ function stripSetupFunction(chunk) {
|
|
|
881
1601
|
return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
|
|
882
1602
|
}
|
|
883
1603
|
/**
|
|
884
|
-
* Parse the
|
|
885
|
-
*
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
1604
|
+
* Parse the format object at `start`–`end` of `text`: strict JSON first (fast), then the
|
|
1605
|
+
* JavaScript literal subset formats use. Error positions point into `text`.
|
|
1606
|
+
*/
|
|
1607
|
+
function parseFormatObject(text, start, end) {
|
|
1608
|
+
try {
|
|
1609
|
+
return {
|
|
1610
|
+
ok: true,
|
|
1611
|
+
value: JSON.parse(text.slice(start, end))
|
|
1612
|
+
};
|
|
1613
|
+
} catch {}
|
|
1614
|
+
try {
|
|
1615
|
+
return {
|
|
1616
|
+
ok: true,
|
|
1617
|
+
value: parseRelaxedJSON(text, start, end)
|
|
1618
|
+
};
|
|
1619
|
+
} catch (e) {
|
|
1620
|
+
return {
|
|
1621
|
+
ok: false,
|
|
1622
|
+
error: e instanceof Error ? e.message : String(e)
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
/** Check a parsed format object's fields. Per spec, `name` is optional; `version` and `source` are required. */
|
|
1627
|
+
function toFormatJSON(raw) {
|
|
1628
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
|
|
1629
|
+
ok: false,
|
|
1630
|
+
reason: "Story format JSON chunk is not an object."
|
|
1631
|
+
};
|
|
1632
|
+
const obj = raw;
|
|
1633
|
+
if (typeof obj.version !== "string") return {
|
|
1634
|
+
ok: false,
|
|
1635
|
+
reason: "Story format has no \"version\" string."
|
|
1636
|
+
};
|
|
1637
|
+
if (typeof obj.source !== "string") return {
|
|
1638
|
+
ok: false,
|
|
1639
|
+
reason: "Story format has no \"source\" string."
|
|
1640
|
+
};
|
|
1641
|
+
if (!parseVersion(obj.version)) return {
|
|
1642
|
+
ok: false,
|
|
1643
|
+
reason: `Story format version ${JSON.stringify(obj.version)} is not a SemVer version.`
|
|
1644
|
+
};
|
|
1645
|
+
const data = {
|
|
1646
|
+
name: typeof obj.name === "string" ? obj.name : "Untitled Story Format",
|
|
1647
|
+
version: obj.version,
|
|
1648
|
+
source: obj.source,
|
|
1649
|
+
proofing: obj.proofing === true
|
|
1650
|
+
};
|
|
1651
|
+
if (typeof obj.author === "string") data.author = obj.author;
|
|
1652
|
+
if (typeof obj.description === "string") data.description = obj.description;
|
|
1653
|
+
if (typeof obj.image === "string") data.image = obj.image;
|
|
1654
|
+
if (typeof obj.url === "string") data.url = obj.url;
|
|
1655
|
+
if (typeof obj.license === "string") data.license = obj.license;
|
|
1656
|
+
return {
|
|
1657
|
+
ok: true,
|
|
1658
|
+
data
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
/**
|
|
1662
|
+
* Read the metadata of a Twine 2 format.js, or say why it cannot be used.
|
|
889
1663
|
*
|
|
890
|
-
*
|
|
1664
|
+
* The object passed to `window.storyFormat()` may be strict JSON or a JavaScript object literal
|
|
1665
|
+
* (single quotes, unquoted keys, trailing commas, comments). String values come back exactly as
|
|
1666
|
+
* JavaScript evaluation gives them. Harlowe's function-valued `setup` property is dropped; the
|
|
1667
|
+
* workaround keys on the property itself, so the same bytes parse whether they come from a
|
|
1668
|
+
* `harlowe-3` directory or a direct download.
|
|
891
1669
|
*/
|
|
892
|
-
function
|
|
1670
|
+
function decodeFormatJSON(source) {
|
|
893
1671
|
const first = source.indexOf("{");
|
|
894
1672
|
const last = source.lastIndexOf("}");
|
|
895
|
-
if (first === -1 || last
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
proofing: obj.proofing === true
|
|
908
|
-
};
|
|
909
|
-
if (typeof obj.author === "string") result.author = obj.author;
|
|
910
|
-
if (typeof obj.description === "string") result.description = obj.description;
|
|
911
|
-
if (typeof obj.image === "string") result.image = obj.image;
|
|
912
|
-
if (typeof obj.url === "string") result.url = obj.url;
|
|
913
|
-
if (typeof obj.license === "string") result.license = obj.license;
|
|
914
|
-
return result;
|
|
1673
|
+
if (first === -1 || last < first) return {
|
|
1674
|
+
ok: false,
|
|
1675
|
+
reason: "Could not find Twine 2 style story format JSON chunk."
|
|
1676
|
+
};
|
|
1677
|
+
const parsed = parseFormatObject(source, first, last + 1);
|
|
1678
|
+
if (parsed.ok) return toFormatJSON(parsed.value);
|
|
1679
|
+
const stripped = stripSetupFunction(source.slice(first, last + 1));
|
|
1680
|
+
const retried = stripped === void 0 ? void 0 : parseFormatObject(stripped, 0, stripped.length);
|
|
1681
|
+
if (retried?.ok) return toFormatJSON(retried.value);
|
|
1682
|
+
return {
|
|
1683
|
+
ok: false,
|
|
1684
|
+
reason: `Could not decode story format JSON chunk: ${parsed.error}`
|
|
915
1685
|
};
|
|
1686
|
+
}
|
|
1687
|
+
/**
|
|
1688
|
+
* Parse the Twine 2 format.js JSON chunk; null when it cannot be used ({@link decodeFormatJSON}
|
|
1689
|
+
* gives the reason).
|
|
1690
|
+
*
|
|
1691
|
+
* @param _formatId No longer used; kept so existing callers keep compiling.
|
|
1692
|
+
*/
|
|
1693
|
+
function parseFormatJSON(source, _formatId) {
|
|
1694
|
+
const result = decodeFormatJSON(source);
|
|
1695
|
+
return result.ok ? result.data : null;
|
|
1696
|
+
}
|
|
1697
|
+
/** A directory's entries in a stable order, or none when it cannot be read. */
|
|
1698
|
+
function listDirectory(dir) {
|
|
916
1699
|
try {
|
|
917
|
-
return
|
|
1700
|
+
return readdirSync(dir).sort();
|
|
918
1701
|
} catch {
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
1702
|
+
return [];
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
function isDirectory(path) {
|
|
1706
|
+
try {
|
|
1707
|
+
return statSync(path).isDirectory();
|
|
1708
|
+
} catch {
|
|
1709
|
+
return false;
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
function isFile(path) {
|
|
1713
|
+
try {
|
|
1714
|
+
return statSync(path).isFile();
|
|
1715
|
+
} catch {
|
|
1716
|
+
return false;
|
|
926
1717
|
}
|
|
927
1718
|
}
|
|
928
1719
|
/**
|
|
929
|
-
*
|
|
930
|
-
*
|
|
931
|
-
* only the highest minor.patch survives.
|
|
1720
|
+
* Load the format in one folder: its format.js (Twine 2), else its header.html (Twine 1).
|
|
1721
|
+
* A format.js that cannot be used is reported as a warning, as Tweego does.
|
|
932
1722
|
*/
|
|
933
|
-
function
|
|
934
|
-
const
|
|
935
|
-
|
|
936
|
-
for (const
|
|
1723
|
+
function loadFormatDir(searchDir, id, diagnostics) {
|
|
1724
|
+
const formatDir = join(searchDir, id);
|
|
1725
|
+
if (!isDirectory(formatDir)) return void 0;
|
|
1726
|
+
for (const baseFilename of ["format.js", "header.html"]) {
|
|
1727
|
+
const filename = join(formatDir, baseFilename);
|
|
1728
|
+
if (!isFile(filename)) continue;
|
|
1729
|
+
if (baseFilename === "header.html") return {
|
|
1730
|
+
id,
|
|
1731
|
+
filename,
|
|
1732
|
+
isTwine2: false,
|
|
1733
|
+
name: id,
|
|
1734
|
+
version: "",
|
|
1735
|
+
proofing: false
|
|
1736
|
+
};
|
|
1737
|
+
const encoding = [];
|
|
1738
|
+
let decoded;
|
|
937
1739
|
try {
|
|
938
|
-
|
|
939
|
-
} catch {
|
|
940
|
-
|
|
1740
|
+
decoded = decodeFormatJSON(readUTF8(filename, encoding));
|
|
1741
|
+
} catch (e) {
|
|
1742
|
+
decoded = {
|
|
1743
|
+
ok: false,
|
|
1744
|
+
reason: `Could not read ${filename}: ${e instanceof Error ? e.message : String(e)}`
|
|
1745
|
+
};
|
|
941
1746
|
}
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1747
|
+
if (!decoded.ok) {
|
|
1748
|
+
diagnostics?.push(...encoding);
|
|
1749
|
+
diagnostics?.push({
|
|
1750
|
+
level: "warning",
|
|
1751
|
+
message: `format ${id}: Skipping format; ${decoded.reason} (${filename})`
|
|
1752
|
+
});
|
|
946
1753
|
continue;
|
|
947
1754
|
}
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
if (data.url) format.url = data.url;
|
|
981
|
-
if (data.license) format.license = data.license;
|
|
982
|
-
} catch {
|
|
983
|
-
continue;
|
|
984
|
-
}
|
|
985
|
-
else format.name = entry;
|
|
986
|
-
formats.set(entry, format);
|
|
987
|
-
break;
|
|
988
|
-
}
|
|
989
|
-
}
|
|
1755
|
+
const { data } = decoded;
|
|
1756
|
+
const format = {
|
|
1757
|
+
id,
|
|
1758
|
+
filename,
|
|
1759
|
+
isTwine2: true,
|
|
1760
|
+
name: data.name,
|
|
1761
|
+
version: data.version,
|
|
1762
|
+
proofing: data.proofing ?? false
|
|
1763
|
+
};
|
|
1764
|
+
if (data.author) format.author = data.author;
|
|
1765
|
+
if (data.description) format.description = data.description;
|
|
1766
|
+
if (data.image) format.image = data.image;
|
|
1767
|
+
if (data.url) format.url = data.url;
|
|
1768
|
+
if (data.license) format.license = data.license;
|
|
1769
|
+
return format;
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
/**
|
|
1773
|
+
* Discover every story format in the search directories, keyed by folder name (the format ID),
|
|
1774
|
+
* without version pruning.
|
|
1775
|
+
*
|
|
1776
|
+
* Directories are searched in order, and a later directory outranks an earlier one: when two hold
|
|
1777
|
+
* the same folder name, the later one's format is kept. The map iterates in that order too, lowest
|
|
1778
|
+
* rank first. Formats that cannot be used are left out, with a warning in `diagnostics`.
|
|
1779
|
+
*/
|
|
1780
|
+
function discoverAllFormats(searchDirs, diagnostics) {
|
|
1781
|
+
const formats = /* @__PURE__ */ new Map();
|
|
1782
|
+
for (const searchDir of searchDirs) for (const entry of listDirectory(searchDir)) {
|
|
1783
|
+
const format = loadFormatDir(searchDir, entry, diagnostics);
|
|
1784
|
+
if (!format) continue;
|
|
1785
|
+
formats.delete(entry);
|
|
1786
|
+
formats.set(entry, format);
|
|
990
1787
|
}
|
|
991
|
-
pruneFormats(formats);
|
|
992
1788
|
return formats;
|
|
993
1789
|
}
|
|
1790
|
+
/** The version a format is pruned and selected by, or null for one outside SemVer pruning. */
|
|
1791
|
+
function prunableVersion(format) {
|
|
1792
|
+
return format.isTwine2 && format.name ? parseVersion(format.version) : null;
|
|
1793
|
+
}
|
|
1794
|
+
/** The search directory a discovered format was found in. */
|
|
1795
|
+
function searchDirOf(format) {
|
|
1796
|
+
return dirname(dirname(format.filename));
|
|
1797
|
+
}
|
|
994
1798
|
/**
|
|
995
|
-
* Prune formats by SemVer: within each (name, major) group,
|
|
996
|
-
*
|
|
1799
|
+
* Prune formats by SemVer: within each (name, major) group, keep only the version with the highest
|
|
1800
|
+
* precedence (a release outranks its prereleases). Between equal versions, the folder in the
|
|
1801
|
+
* higher-ranked search directory wins, and within one directory the first folder name in sort
|
|
1802
|
+
* order. Returns a new map in the same order.
|
|
1803
|
+
*
|
|
1804
|
+
* Pruning serves name-based selection and listings. An explicit format ID is looked up in the
|
|
1805
|
+
* unpruned map, so a pinned folder is used even when another folder holds a newer version.
|
|
997
1806
|
*/
|
|
998
1807
|
function pruneFormats(formats) {
|
|
999
|
-
const
|
|
1000
|
-
const
|
|
1001
|
-
|
|
1002
|
-
if (!
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
const
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
version: v
|
|
1808
|
+
const dirRank = /* @__PURE__ */ new Map();
|
|
1809
|
+
for (const format of formats.values()) {
|
|
1810
|
+
const dir = searchDirOf(format);
|
|
1811
|
+
if (!dirRank.has(dir)) dirRank.set(dir, dirRank.size);
|
|
1812
|
+
}
|
|
1813
|
+
const rankOf = (format) => dirRank.get(searchDirOf(format)) ?? 0;
|
|
1814
|
+
const best = /* @__PURE__ */ new Map();
|
|
1815
|
+
for (const format of formats.values()) {
|
|
1816
|
+
const version = prunableVersion(format);
|
|
1817
|
+
if (!version) continue;
|
|
1818
|
+
const group = `${format.name}@${version.major}`;
|
|
1819
|
+
const current = best.get(group);
|
|
1820
|
+
const order = current ? compareVersions(version, current.version) : 1;
|
|
1821
|
+
if (order > 0 || order === 0 && current && rankOf(format) > rankOf(current.format)) best.set(group, {
|
|
1822
|
+
format,
|
|
1823
|
+
version
|
|
1016
1824
|
});
|
|
1017
1825
|
}
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
const groupKey = `${f.name}@${v[0]}`;
|
|
1023
|
-
const best = bestByNameMajor.get(groupKey);
|
|
1024
|
-
if (best && best.id !== id) formats.delete(id);
|
|
1025
|
-
}
|
|
1826
|
+
return new Map([...formats].filter(([id, format]) => {
|
|
1827
|
+
const version = prunableVersion(format);
|
|
1828
|
+
return version === null || best.get(`${format.name}@${version.major}`)?.format.id === id;
|
|
1829
|
+
}));
|
|
1026
1830
|
}
|
|
1027
1831
|
/**
|
|
1028
|
-
*
|
|
1832
|
+
* Discover all story formats in the given search directories, pruned by SemVer: within each
|
|
1833
|
+
* (name, major) group only the highest version survives. See {@link discoverAllFormats} for the
|
|
1834
|
+
* directory ranking and {@link pruneFormats} for pruning.
|
|
1835
|
+
*/
|
|
1836
|
+
function discoverFormats(searchDirs) {
|
|
1837
|
+
return pruneFormats(discoverAllFormats(searchDirs));
|
|
1838
|
+
}
|
|
1839
|
+
/**
|
|
1840
|
+
* Get the format search directories, lowest rank first: the story format subdirectories of the
|
|
1841
|
+
* home directory, then of the working directory, then each `TWEEGO_PATH` entry, then `extraPaths`
|
|
1842
|
+
* (`formatPaths`). A later directory outranks an earlier one for the same format folder name, so
|
|
1843
|
+
* `formatPaths` override `TWEEGO_PATH`, which overrides the home and working directories, as in
|
|
1844
|
+
* Tweego.
|
|
1029
1845
|
*/
|
|
1030
1846
|
function getFormatSearchDirs(extraPaths = [], useTweegoPath = true) {
|
|
1031
1847
|
const subdirNames = [
|
|
@@ -1040,30 +1856,25 @@ function getFormatSearchDirs(extraPaths = [], useTweegoPath = true) {
|
|
|
1040
1856
|
basePaths.add(homedir());
|
|
1041
1857
|
} catch {}
|
|
1042
1858
|
basePaths.add(process.cwd());
|
|
1043
|
-
const dirs = [];
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
try {
|
|
1047
|
-
if (statSync(dir).isDirectory()) dirs.push(dir);
|
|
1048
|
-
} catch {}
|
|
1049
|
-
}
|
|
1859
|
+
const dirs = [...basePaths].flatMap((base) => subdirNames.map((sub) => join(base, sub))).filter(isDirectory);
|
|
1860
|
+
const tweegoPath = useTweegoPath ? process.env.TWEEGO_PATH : void 0;
|
|
1861
|
+
if (tweegoPath) dirs.push(...tweegoPath.split(process.platform === "win32" ? ";" : ":"));
|
|
1050
1862
|
dirs.push(...extraPaths);
|
|
1051
|
-
if (useTweegoPath) {
|
|
1052
|
-
const tweegoPath = process.env.TWEEGO_PATH;
|
|
1053
|
-
if (tweegoPath) dirs.push(...tweegoPath.split(process.platform === "win32" ? ";" : ":"));
|
|
1054
|
-
}
|
|
1055
1863
|
return dirs;
|
|
1056
1864
|
}
|
|
1057
|
-
/**
|
|
1865
|
+
/**
|
|
1866
|
+
* Parse a version string into [major, minor, patch]. Accepts what {@link parseVersion} accepts
|
|
1867
|
+
* (`v1.2.3`, `1.2`, `2.0.0-beta.1`); the prerelease and build metadata are dropped.
|
|
1868
|
+
*/
|
|
1058
1869
|
function parseSemver(v) {
|
|
1059
|
-
const
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
}
|
|
1870
|
+
const parsed = parseVersion(v);
|
|
1871
|
+
return parsed ? [
|
|
1872
|
+
parsed.major,
|
|
1873
|
+
parsed.minor,
|
|
1874
|
+
parsed.patch
|
|
1875
|
+
] : null;
|
|
1876
|
+
}
|
|
1877
|
+
/** Compare [major, minor, patch] tuples. Use {@link compareVersions} to take prereleases into account. */
|
|
1067
1878
|
function semverCompare(a, b) {
|
|
1068
1879
|
const [aMajor, aMinor, aPatch] = a;
|
|
1069
1880
|
const [bMajor, bMinor, bPatch] = b;
|
|
@@ -1074,7 +1885,7 @@ function semverCompare(a, b) {
|
|
|
1074
1885
|
}
|
|
1075
1886
|
/** Build a directory-style format ID from a name and version, e.g. ('SugarCube', '2.37.3') → 'sugarcube-2'. */
|
|
1076
1887
|
function makeFormatId(name, version) {
|
|
1077
|
-
const major =
|
|
1888
|
+
const major = parseVersion(version)?.major ?? 0;
|
|
1078
1889
|
return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
|
|
1079
1890
|
}
|
|
1080
1891
|
/** Describe a format request for diagnostics. */
|
|
@@ -1085,81 +1896,164 @@ function describeFormatRequest(request) {
|
|
|
1085
1896
|
default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
|
|
1086
1897
|
}
|
|
1087
1898
|
}
|
|
1088
|
-
/**
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
* - An ID request ('sugarcube-2') matches a candidate whose name and major version build that ID,
|
|
1092
|
-
* and takes the greatest such version.
|
|
1093
|
-
* - A name request matches the name case-insensitively. An exact version wins outright; otherwise
|
|
1094
|
-
* the greatest version with the same major that is not older than the one asked for (or, with
|
|
1095
|
-
* `allowOlder`, the greatest same-major version). An unparseable requested version takes the
|
|
1096
|
-
* greatest version of any major.
|
|
1097
|
-
*/
|
|
1098
|
-
function selectFormatCandidate(request, candidates, describe, options = {}) {
|
|
1099
|
-
const allowOlder = options.allowOlder ?? false;
|
|
1100
|
-
const wanted = request.kind === "name" ? parseSemver(request.version) : null;
|
|
1899
|
+
/** {@link selectFormatCandidate} over one set of candidates, matching names without regard to case. */
|
|
1900
|
+
function pickFormatCandidate(request, candidates, describe, allowOlder) {
|
|
1901
|
+
const wanted = request.kind === "name" ? parseVersion(request.version) : null;
|
|
1101
1902
|
let best;
|
|
1102
1903
|
for (const candidate of candidates) {
|
|
1103
1904
|
const { name, version } = describe(candidate);
|
|
1104
|
-
const have =
|
|
1905
|
+
const have = parseVersion(version);
|
|
1105
1906
|
if (!have) continue;
|
|
1106
1907
|
if (request.kind === "id") {
|
|
1107
1908
|
if (makeFormatId(name, version) !== request.id.toLowerCase()) continue;
|
|
1108
1909
|
} else {
|
|
1109
1910
|
if (name.toLowerCase() !== request.name.toLowerCase()) continue;
|
|
1110
1911
|
if (wanted) {
|
|
1111
|
-
|
|
1112
|
-
if (
|
|
1113
|
-
if (
|
|
1912
|
+
const order = compareVersions(have, wanted);
|
|
1913
|
+
if (order === 0) return candidate;
|
|
1914
|
+
if (have.major !== wanted.major) continue;
|
|
1915
|
+
if (!allowOlder && order < 0) continue;
|
|
1114
1916
|
}
|
|
1115
1917
|
}
|
|
1116
|
-
if (!best ||
|
|
1918
|
+
if (!best || compareVersions(have, best.version) > 0) best = {
|
|
1117
1919
|
candidate,
|
|
1118
1920
|
version: have
|
|
1119
1921
|
};
|
|
1120
1922
|
}
|
|
1121
1923
|
return best?.candidate;
|
|
1122
1924
|
}
|
|
1123
|
-
/**
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1925
|
+
/**
|
|
1926
|
+
* Pick the candidate that best answers a format request. Local folders, the download cache and
|
|
1927
|
+
* remote indices all select through this one function.
|
|
1928
|
+
*
|
|
1929
|
+
* - An ID request ('sugarcube-2') matches, without regard to case, a candidate whose name and
|
|
1930
|
+
* major version build that ID, and takes the greatest such version.
|
|
1931
|
+
* - A name request matches the name without regard to case, but a candidate whose name matches in
|
|
1932
|
+
* case too is preferred when one answers the request. An exact version (prerelease included)
|
|
1933
|
+
* wins outright; otherwise the greatest version with the same major that is not older than the
|
|
1934
|
+
* one asked for (or, with `allowOlder`, the greatest same-major version). An unparseable
|
|
1935
|
+
* requested version takes the greatest version of any major.
|
|
1936
|
+
*
|
|
1937
|
+
* Versions compare by SemVer precedence ({@link compareVersions}), so a release outranks its
|
|
1938
|
+
* prereleases. Between equal versions, the earlier candidate wins.
|
|
1939
|
+
*/
|
|
1940
|
+
function selectFormatCandidate(request, candidates, describe, options = {}) {
|
|
1941
|
+
const allowOlder = options.allowOlder ?? false;
|
|
1942
|
+
if (request.kind === "name") {
|
|
1943
|
+
const preferred = pickFormatCandidate(request, candidates.filter((c) => describe(c).name === request.name), describe, allowOlder);
|
|
1944
|
+
if (preferred !== void 0) return preferred;
|
|
1945
|
+
}
|
|
1946
|
+
return pickFormatCandidate(request, candidates, describe, allowOlder);
|
|
1947
|
+
}
|
|
1948
|
+
/** The Twine 2 formats of a discovered map, those from higher-ranked search directories first. */
|
|
1949
|
+
function rankedTwine2Formats(formats) {
|
|
1950
|
+
return [...formats.values()].filter((f) => f.isTwine2).reverse();
|
|
1951
|
+
}
|
|
1952
|
+
/**
|
|
1953
|
+
* Look a format ID up among discovered folders. The ID matches a folder name without regard to
|
|
1954
|
+
* case; an exact-case folder is preferred, then the highest-ranked one.
|
|
1955
|
+
*/
|
|
1956
|
+
function findFormatById(formats, id) {
|
|
1957
|
+
const exact = formats.get(id);
|
|
1958
|
+
if (exact) return exact;
|
|
1959
|
+
const wanted = id.toLowerCase();
|
|
1960
|
+
return [...formats.values()].reverse().find((f) => f.id.toLowerCase() === wanted);
|
|
1961
|
+
}
|
|
1962
|
+
/**
|
|
1963
|
+
* Read the story format source (for Twine 2, extract the `source` property from JSON).
|
|
1964
|
+
* `diagnostics` receives a warning when the format file is not valid UTF-8 (it is read as Windows-1252).
|
|
1965
|
+
*/
|
|
1966
|
+
function readFormatSource(format, diagnostics) {
|
|
1967
|
+
const source = readUTF8(format.filename, diagnostics);
|
|
1968
|
+
if (!format.isTwine2) return source;
|
|
1969
|
+
const decoded = decodeFormatJSON(source);
|
|
1970
|
+
if (!decoded.ok) throw new Error(`Cannot parse format ${format.id} JSON: ${decoded.reason}`);
|
|
1971
|
+
return decoded.data.source;
|
|
1972
|
+
}
|
|
1973
|
+
//#endregion
|
|
1974
|
+
//#region src/atomic-write.ts
|
|
1975
|
+
/**
|
|
1976
|
+
* Atomic file replacement, for every file twee-ts writes that something else may read
|
|
1977
|
+
* meanwhile: build output (which live-reload servers read on each change) and downloaded
|
|
1978
|
+
* story formats (which other twee-ts processes share).
|
|
1979
|
+
*/
|
|
1980
|
+
/**
|
|
1981
|
+
* Writes `data` to `path` so that a reader sees either the previous file or the complete new
|
|
1982
|
+
* one, never a partly written file. The data goes to a temporary file in the same folder,
|
|
1983
|
+
* which is then renamed over `path`. When `path` is a symlink, the file it points to is
|
|
1984
|
+
* replaced, and an existing file keeps its permissions.
|
|
1985
|
+
*
|
|
1986
|
+
* On failure (a full disk, a missing folder) the temporary file is removed, `path` is left
|
|
1987
|
+
* as it was, and the error is thrown with `path` in its message and its original `code`.
|
|
1988
|
+
*/
|
|
1989
|
+
function writeFileAtomic(path, data) {
|
|
1990
|
+
const target = followSymlink(path);
|
|
1991
|
+
const temp = join(dirname(target), `.${basename(target)}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
|
|
1992
|
+
try {
|
|
1993
|
+
writeFileSync(temp, data, {
|
|
1994
|
+
encoding: "utf-8",
|
|
1995
|
+
flag: "wx"
|
|
1996
|
+
});
|
|
1997
|
+
keepPermissions(target, temp);
|
|
1998
|
+
renameSync(temp, target);
|
|
1999
|
+
} catch (e) {
|
|
2000
|
+
rmSync(temp, { force: true });
|
|
2001
|
+
throw writeError(path, e);
|
|
1138
2002
|
}
|
|
1139
|
-
return bestId;
|
|
1140
2003
|
}
|
|
1141
|
-
/**
|
|
1142
|
-
function
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
return data.source;
|
|
2004
|
+
/** The file a symlink at `path` points to, or `path` itself (also when it doesn't exist yet). */
|
|
2005
|
+
function followSymlink(path) {
|
|
2006
|
+
try {
|
|
2007
|
+
return realpathSync(path);
|
|
2008
|
+
} catch {
|
|
2009
|
+
return path;
|
|
1148
2010
|
}
|
|
1149
|
-
|
|
2011
|
+
}
|
|
2012
|
+
/**
|
|
2013
|
+
* Gives `temp` the permission bits of `target`, when `target` exists. Best effort: on a file
|
|
2014
|
+
* system without Unix permissions chmod can fail, and writing in place never kept them there either.
|
|
2015
|
+
*/
|
|
2016
|
+
function keepPermissions(target, temp) {
|
|
2017
|
+
const existing = statSync(target, { throwIfNoEntry: false });
|
|
2018
|
+
if (existing === void 0) return;
|
|
2019
|
+
try {
|
|
2020
|
+
chmodSync(temp, existing.mode & 4095);
|
|
2021
|
+
} catch {}
|
|
2022
|
+
}
|
|
2023
|
+
/** `cause` as an error that names `path` and keeps the original error code. */
|
|
2024
|
+
function writeError(path, cause) {
|
|
2025
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
2026
|
+
const error = new Error(`Cannot write ${path}: ${message}`, { cause });
|
|
2027
|
+
if (cause instanceof Error && "code" in cause) error.code = cause.code;
|
|
2028
|
+
return error;
|
|
1150
2029
|
}
|
|
1151
2030
|
//#endregion
|
|
1152
2031
|
//#region src/remote-formats.ts
|
|
1153
2032
|
/**
|
|
1154
2033
|
* Remote story format fetching, caching, and checksum verification.
|
|
1155
2034
|
* Uses the Story Formats Archive (SFA) as the default source.
|
|
2035
|
+
*
|
|
2036
|
+
* Two caches hold downloads. Formats found through an index are shared by name and version
|
|
2037
|
+
* (`<cache>/<name>/<version>/format.js`). A format downloaded from a direct URL is kept under
|
|
2038
|
+
* that URL instead, so one project's copy never answers another project's request.
|
|
2039
|
+
* Cache files are replaced atomically, so another process never reads a partly written one.
|
|
1156
2040
|
*/
|
|
1157
2041
|
const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
|
|
2042
|
+
/** The longest delay a timer accepts; a longer timeout means no limit. */
|
|
2043
|
+
const MAX_TIMER_DELAY = 2147483647;
|
|
1158
2044
|
/** Get the cache directory for downloaded story formats. */
|
|
1159
2045
|
function getCacheDir() {
|
|
1160
2046
|
const base = process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache");
|
|
1161
2047
|
return join(base, "twee-ts", "storyformats");
|
|
1162
2048
|
}
|
|
2049
|
+
/** The cache directory for formats downloaded from direct URLs, beside {@link getCacheDir}. */
|
|
2050
|
+
function getUrlCacheDir() {
|
|
2051
|
+
return join(dirname(getCacheDir()), "storyformat-urls");
|
|
2052
|
+
}
|
|
2053
|
+
/** The name of one direct URL's cache entry: a hash of the URL, so each URL has its own. */
|
|
2054
|
+
function urlCacheKey(url) {
|
|
2055
|
+
return createHash("sha256").update(url).digest("hex");
|
|
2056
|
+
}
|
|
1163
2057
|
/**
|
|
1164
2058
|
* Whether a string can serve as one directory name inside the cache: not empty, not `.` or `..`,
|
|
1165
2059
|
* and free of path separators. Format names and versions come from downloaded metadata, so they
|
|
@@ -1182,25 +2076,38 @@ function cachedFormatDir(name, version) {
|
|
|
1182
2076
|
if (!isInside(root, dir)) throw new Error(`Refusing to cache a story format outside the cache directory ${root}: ${dir}`);
|
|
1183
2077
|
return dir;
|
|
1184
2078
|
}
|
|
1185
|
-
/**
|
|
2079
|
+
/** Whether `e` is a Node.js system error with the given code. */
|
|
2080
|
+
function hasErrorCode(e, code) {
|
|
2081
|
+
return e instanceof Error && "code" in e && e.code === code;
|
|
2082
|
+
}
|
|
2083
|
+
/**
|
|
2084
|
+
* Create `dir` as a plain directory, or check that it already is one (a symlink could lead out of the cache).
|
|
2085
|
+
* A directory another process creates between the check and the mkdir is fine.
|
|
2086
|
+
*/
|
|
1186
2087
|
function ensurePlainDirectory(dir) {
|
|
1187
|
-
|
|
1188
|
-
if (stat === void 0) {
|
|
2088
|
+
if (lstatSync(dir, { throwIfNoEntry: false }) === void 0) try {
|
|
1189
2089
|
mkdirSync(dir);
|
|
1190
2090
|
return;
|
|
2091
|
+
} catch (e) {
|
|
2092
|
+
if (!hasErrorCode(e, "EEXIST")) throw e;
|
|
1191
2093
|
}
|
|
1192
|
-
|
|
2094
|
+
const stat = lstatSync(dir, { throwIfNoEntry: false });
|
|
2095
|
+
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`);
|
|
1193
2096
|
}
|
|
1194
|
-
/**
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
2097
|
+
/**
|
|
2098
|
+
* Write a downloaded format.js to `<root>/<...segments>/format.js`, replacing any earlier copy
|
|
2099
|
+
* atomically, and return its path. Every directory below `root` must be a plain one.
|
|
2100
|
+
*/
|
|
2101
|
+
function writeCacheEntry(root, segments, text) {
|
|
1198
2102
|
mkdirSync(root, { recursive: true });
|
|
1199
|
-
|
|
1200
|
-
|
|
2103
|
+
const dir = segments.reduce((parent, segment) => {
|
|
2104
|
+
const child = join(parent, segment);
|
|
2105
|
+
ensurePlainDirectory(child);
|
|
2106
|
+
return child;
|
|
2107
|
+
}, root);
|
|
1201
2108
|
const formatPath = join(dir, "format.js");
|
|
1202
2109
|
if (lstatSync(formatPath, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error(`Refusing to write a story format outside the cache directory: ${formatPath} is a symlink`);
|
|
1203
|
-
|
|
2110
|
+
writeFileAtomic(formatPath, text);
|
|
1204
2111
|
return formatPath;
|
|
1205
2112
|
}
|
|
1206
2113
|
/** In-memory index cache, keyed by URL. Cleared each compile. */
|
|
@@ -1209,6 +2116,78 @@ const indexCache = /* @__PURE__ */ new Map();
|
|
|
1209
2116
|
function clearIndexCache() {
|
|
1210
2117
|
indexCache.clear();
|
|
1211
2118
|
}
|
|
2119
|
+
/** Requests in progress, by what they fetch, so concurrent compiles make each request once. */
|
|
2120
|
+
const sharedRequests = /* @__PURE__ */ new Map();
|
|
2121
|
+
/**
|
|
2122
|
+
* Runs `start` once for all callers that ask for `key` while it is in progress. A caller whose
|
|
2123
|
+
* `signal` aborts stops waiting at once and rejects with the signal's reason; the request
|
|
2124
|
+
* itself is aborted (through the signal `start` receives) once no caller waits on it.
|
|
2125
|
+
*/
|
|
2126
|
+
function shareRequest(key, signal, start) {
|
|
2127
|
+
if (signal?.aborted) return Promise.reject(signal.reason);
|
|
2128
|
+
const request = sharedRequests.get(key) ?? startSharedRequest(key, start);
|
|
2129
|
+
request.waiters++;
|
|
2130
|
+
return new Promise((resolve, reject) => {
|
|
2131
|
+
let waiting = true;
|
|
2132
|
+
const stopWaiting = () => {
|
|
2133
|
+
if (!waiting) return;
|
|
2134
|
+
waiting = false;
|
|
2135
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2136
|
+
request.waiters--;
|
|
2137
|
+
};
|
|
2138
|
+
const onAbort = () => {
|
|
2139
|
+
stopWaiting();
|
|
2140
|
+
if (request.waiters === 0) {
|
|
2141
|
+
if (sharedRequests.get(key) === request) sharedRequests.delete(key);
|
|
2142
|
+
request.controller.abort(signal?.reason);
|
|
2143
|
+
}
|
|
2144
|
+
reject(signal?.reason);
|
|
2145
|
+
};
|
|
2146
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2147
|
+
request.promise.then((value) => {
|
|
2148
|
+
stopWaiting();
|
|
2149
|
+
resolve(value);
|
|
2150
|
+
}, (e) => {
|
|
2151
|
+
stopWaiting();
|
|
2152
|
+
reject(e);
|
|
2153
|
+
});
|
|
2154
|
+
});
|
|
2155
|
+
}
|
|
2156
|
+
function startSharedRequest(key, start) {
|
|
2157
|
+
const controller = new AbortController();
|
|
2158
|
+
const request = {
|
|
2159
|
+
promise: start(controller.signal),
|
|
2160
|
+
controller,
|
|
2161
|
+
waiters: 0
|
|
2162
|
+
};
|
|
2163
|
+
sharedRequests.set(key, request);
|
|
2164
|
+
const forget = () => {
|
|
2165
|
+
if (sharedRequests.get(key) === request) sharedRequests.delete(key);
|
|
2166
|
+
};
|
|
2167
|
+
request.promise.then(forget, forget);
|
|
2168
|
+
return request;
|
|
2169
|
+
}
|
|
2170
|
+
/** The time limit for each request, from the caller's options. */
|
|
2171
|
+
function requestTimeout(options) {
|
|
2172
|
+
const timeout = options.timeout ?? 3e4;
|
|
2173
|
+
if (!(timeout >= 0)) throw new RangeError(`A story format request timeout must be 0 or more milliseconds, not ${timeout}`);
|
|
2174
|
+
return timeout;
|
|
2175
|
+
}
|
|
2176
|
+
/**
|
|
2177
|
+
* Fetch `url` as text, aborting when `signal` does or after `timeout` milliseconds (0: no limit).
|
|
2178
|
+
* `what` names the request in errors, as in "Failed to <what> from <url>".
|
|
2179
|
+
*/
|
|
2180
|
+
async function fetchText(url, what, signal, timeout) {
|
|
2181
|
+
const limit = timeout > 0 && timeout <= MAX_TIMER_DELAY ? AbortSignal.timeout(timeout) : void 0;
|
|
2182
|
+
try {
|
|
2183
|
+
const res = await fetch(url, { signal: limit ? AbortSignal.any([signal, limit]) : signal });
|
|
2184
|
+
if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
|
|
2185
|
+
return await res.text();
|
|
2186
|
+
} catch (e) {
|
|
2187
|
+
if (limit?.aborted && !signal.aborted) throw new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`, { cause: e });
|
|
2188
|
+
throw e;
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
1212
2191
|
/** Validate that a JSON value is a valid SFAIndexEntry. */
|
|
1213
2192
|
function isValidEntry(val) {
|
|
1214
2193
|
if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
|
|
@@ -1224,15 +2203,18 @@ function validateSFAIndex(json) {
|
|
|
1224
2203
|
twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
|
|
1225
2204
|
};
|
|
1226
2205
|
}
|
|
1227
|
-
/** Fetch and parse an SFA index.json, with in-memory caching. */
|
|
1228
|
-
async function fetchIndex(url) {
|
|
2206
|
+
/** Fetch and parse an SFA index.json, with in-memory caching. Concurrent calls for one URL share a request. */
|
|
2207
|
+
async function fetchIndex(url, options = {}) {
|
|
2208
|
+
options.signal?.throwIfAborted();
|
|
1229
2209
|
const cached = indexCache.get(url);
|
|
1230
2210
|
if (cached) return cached;
|
|
1231
|
-
const
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
2211
|
+
const timeout = requestTimeout(options);
|
|
2212
|
+
return shareRequest(`index\0${url}`, options.signal, async (signal) => {
|
|
2213
|
+
const text = await fetchText(url, "fetch format index", signal, timeout);
|
|
2214
|
+
const data = validateSFAIndex(JSON.parse(text));
|
|
2215
|
+
indexCache.set(url, data);
|
|
2216
|
+
return data;
|
|
2217
|
+
});
|
|
1236
2218
|
}
|
|
1237
2219
|
/** Find the best matching entry in an SFA index for a name or ID request (twine2 entries first). */
|
|
1238
2220
|
function findEntryForRequest(index, request) {
|
|
@@ -1253,99 +2235,155 @@ async function verifySHA256(content, expectedHex) {
|
|
|
1253
2235
|
function getDownloadUrl(indexUrl, entry, formatType) {
|
|
1254
2236
|
return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
|
|
1255
2237
|
}
|
|
1256
|
-
/**
|
|
1257
|
-
|
|
1258
|
-
cachedFormatDir(entry.name, entry.version);
|
|
1259
|
-
const res = await fetch(downloadUrl);
|
|
1260
|
-
if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
|
|
1261
|
-
const text = await res.text();
|
|
1262
|
-
const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
|
|
1263
|
-
if (checksumKey) {
|
|
1264
|
-
const expected = entry.checksums[checksumKey];
|
|
1265
|
-
if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
|
|
1266
|
-
if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
|
|
1267
|
-
}
|
|
1268
|
-
const id = makeFormatId(entry.name, entry.version);
|
|
1269
|
-
const data = parseFormatJSON(text, id);
|
|
1270
|
-
if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
|
|
2238
|
+
/** The StoryFormatInfo of a Twine 2 format.js cached at `filename`. */
|
|
2239
|
+
function cachedFormatInfo(id, filename, data) {
|
|
1271
2240
|
return {
|
|
1272
2241
|
id,
|
|
1273
|
-
filename
|
|
2242
|
+
filename,
|
|
1274
2243
|
isTwine2: true,
|
|
1275
2244
|
name: data.name,
|
|
1276
2245
|
version: data.version,
|
|
1277
2246
|
proofing: data.proofing ?? false
|
|
1278
2247
|
};
|
|
1279
2248
|
}
|
|
1280
|
-
/**
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
return {
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
2249
|
+
/**
|
|
2250
|
+
* Download a format, verify its checksum, write it to the cache shared by name and version, and
|
|
2251
|
+
* return its StoryFormatInfo. Concurrent calls for one download URL share one request.
|
|
2252
|
+
*/
|
|
2253
|
+
async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
|
|
2254
|
+
cachedFormatDir(entry.name, entry.version);
|
|
2255
|
+
const timeout = requestTimeout(options);
|
|
2256
|
+
return shareRequest(`format\0${getCacheDir()}\0${downloadUrl}`, options.signal, async (signal) => {
|
|
2257
|
+
const text = await fetchText(downloadUrl, "download format", signal, timeout);
|
|
2258
|
+
const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
|
|
2259
|
+
if (checksumKey) {
|
|
2260
|
+
const expected = entry.checksums[checksumKey];
|
|
2261
|
+
if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
|
|
2262
|
+
if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
|
|
2263
|
+
}
|
|
2264
|
+
const id = makeFormatId(entry.name, entry.version);
|
|
2265
|
+
const data = parseFormatJSON(text, id);
|
|
2266
|
+
if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
|
|
2267
|
+
signal.throwIfAborted();
|
|
2268
|
+
return cachedFormatInfo(id, writeCacheEntry(resolve(getCacheDir()), [entry.name, entry.version], text), data);
|
|
2269
|
+
});
|
|
2270
|
+
}
|
|
2271
|
+
/**
|
|
2272
|
+
* Download a direct format.js URL, parse its JSON, cache it under that URL (apart from the
|
|
2273
|
+
* downloads shared by name and version), and return its StoryFormatInfo. Concurrent calls for
|
|
2274
|
+
* one URL share one request.
|
|
2275
|
+
*/
|
|
2276
|
+
async function fetchDirectFormat(url, options = {}) {
|
|
2277
|
+
const timeout = requestTimeout(options);
|
|
2278
|
+
return shareRequest(`url\0${getUrlCacheDir()}\0${url}`, options.signal, async (signal) => {
|
|
2279
|
+
const text = await fetchText(url, "download format", signal, timeout);
|
|
2280
|
+
const data = parseFormatJSON(text);
|
|
2281
|
+
if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
|
|
2282
|
+
cachedFormatDir(data.name, data.version);
|
|
2283
|
+
signal.throwIfAborted();
|
|
2284
|
+
const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
|
|
2285
|
+
return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
|
|
2286
|
+
});
|
|
2287
|
+
}
|
|
2288
|
+
/** The copy of a direct format URL in the download cache, if it has been downloaded before. */
|
|
2289
|
+
function getCachedDirectFormat(url) {
|
|
2290
|
+
const formatPath = join(getUrlCacheDir(), urlCacheKey(url), "format.js");
|
|
2291
|
+
try {
|
|
2292
|
+
if (!existsSync(formatPath)) return void 0;
|
|
2293
|
+
const data = parseFormatJSON(readUTF8(formatPath));
|
|
2294
|
+
if (!data) return void 0;
|
|
2295
|
+
return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
|
|
2296
|
+
} catch {
|
|
2297
|
+
return;
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
/**
|
|
2301
|
+
* Look a request up among direct format URLs, in order, and return the first format that
|
|
2302
|
+
* answers it. A URL downloaded before is answered by its cached copy without the network;
|
|
2303
|
+
* any other is downloaded (unless `offline`). When none answers and a download failed, the
|
|
2304
|
+
* last failure is thrown. An aborted signal rejects with its reason and tries no further URL.
|
|
2305
|
+
*
|
|
2306
|
+
* Internal, for format resolution; not part of the public API.
|
|
2307
|
+
*/
|
|
2308
|
+
async function resolveFormatUrls(request, urls, options = {}) {
|
|
2309
|
+
let lastError;
|
|
2310
|
+
for (const url of urls) {
|
|
2311
|
+
const cached = getCachedDirectFormat(url);
|
|
2312
|
+
if (cached) {
|
|
2313
|
+
if (answers(request, cached)) return cached;
|
|
2314
|
+
continue;
|
|
2315
|
+
}
|
|
2316
|
+
if (options.offline) continue;
|
|
2317
|
+
try {
|
|
2318
|
+
const info = await fetchDirectFormat(url, options);
|
|
2319
|
+
if (answers(request, info)) return info;
|
|
2320
|
+
} catch (e) {
|
|
2321
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
2322
|
+
lastError = toError$1(e);
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
if (lastError) throw lastError;
|
|
2326
|
+
}
|
|
2327
|
+
/** Whether `info` is a format the request accepts. */
|
|
2328
|
+
function answers(request, info) {
|
|
2329
|
+
return selectFormatCandidate(request, [info], (f) => f) !== void 0;
|
|
2330
|
+
}
|
|
2331
|
+
function toError$1(e) {
|
|
2332
|
+
return e instanceof Error ? e : new Error(String(e));
|
|
1295
2333
|
}
|
|
1296
2334
|
/**
|
|
1297
2335
|
* Try to resolve a remote story format by name and version.
|
|
1298
|
-
*
|
|
1299
|
-
*
|
|
1300
|
-
*
|
|
1301
|
-
*
|
|
1302
|
-
*
|
|
2336
|
+
* 1. Try direct format URLs (each one's cached copy, else a download)
|
|
2337
|
+
* 2. Use an exactly matching download from the shared cache, without touching the network
|
|
2338
|
+
* 3. Try custom index URLs, then the default SFA indices
|
|
2339
|
+
* 4. Fall back to a compatible download from the shared cache (e.g. when offline)
|
|
2340
|
+
*
|
|
2341
|
+
* `options.signal` aborts the lookup, which then rejects with the signal's reason;
|
|
2342
|
+
* `options.timeout` limits each request (default {@link DEFAULT_FORMAT_FETCH_TIMEOUT} ms).
|
|
1303
2343
|
*/
|
|
1304
|
-
async function resolveRemoteFormat(name, version, indices, urls) {
|
|
2344
|
+
async function resolveRemoteFormat(name, version, indices, urls, options = {}) {
|
|
1305
2345
|
return resolveRemoteFormatRequest({
|
|
1306
2346
|
kind: "name",
|
|
1307
2347
|
name,
|
|
1308
2348
|
version
|
|
1309
|
-
}, indices, urls);
|
|
2349
|
+
}, indices, urls, options);
|
|
1310
2350
|
}
|
|
1311
2351
|
/**
|
|
1312
2352
|
* Resolve a story format request remotely, in the same order as {@link resolveRemoteFormat}.
|
|
1313
2353
|
* An ID request such as 'sugarcube-2' matches the format whose name and major version build that ID
|
|
1314
2354
|
* (SugarCube 2.x), taking the greatest version available.
|
|
1315
2355
|
*/
|
|
1316
|
-
async function resolveRemoteFormatRequest(request, indices, urls) {
|
|
2356
|
+
async function resolveRemoteFormatRequest(request, indices, urls, options = {}) {
|
|
2357
|
+
options.signal?.throwIfAborted();
|
|
1317
2358
|
let lastError;
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
const info = await fetchDirectFormat(url);
|
|
1322
|
-
if (selectFormatCandidate(request, [info], (f) => f)) return info;
|
|
2359
|
+
try {
|
|
2360
|
+
const direct = await resolveFormatUrls(request, urls ?? [], options);
|
|
2361
|
+
if (direct) return direct;
|
|
1323
2362
|
} catch (e) {
|
|
1324
|
-
|
|
2363
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
2364
|
+
lastError = toError$1(e);
|
|
1325
2365
|
}
|
|
2366
|
+
const cached = findCachedFormat(request);
|
|
2367
|
+
if (cached && request.kind === "name" && sameVersion(cached.version, request.version)) return cached;
|
|
1326
2368
|
const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
|
|
1327
2369
|
for (const indexUrl of allIndices) try {
|
|
1328
|
-
const result = findEntryForRequest(await fetchIndex(indexUrl), request);
|
|
2370
|
+
const result = findEntryForRequest(await fetchIndex(indexUrl, options), request);
|
|
1329
2371
|
if (result) {
|
|
1330
2372
|
const hit = getCachedFormat(result.entry.name, result.entry.version);
|
|
1331
2373
|
if (hit) return hit;
|
|
1332
2374
|
const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
|
|
1333
|
-
return await fetchAndCacheFormat(result.entry, downloadUrl);
|
|
2375
|
+
return await fetchAndCacheFormat(result.entry, downloadUrl, options);
|
|
1334
2376
|
}
|
|
1335
2377
|
} catch (e) {
|
|
1336
|
-
|
|
2378
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
2379
|
+
lastError = toError$1(e);
|
|
1337
2380
|
}
|
|
1338
2381
|
if (cached) return cached;
|
|
1339
2382
|
if (lastError) throw lastError;
|
|
1340
2383
|
}
|
|
1341
|
-
/** Whether two version strings name the same SemVer version. */
|
|
1342
|
-
function isExactVersion(have, wanted) {
|
|
1343
|
-
const a = parseSemver(have);
|
|
1344
|
-
const b = parseSemver(wanted);
|
|
1345
|
-
return a !== null && b !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
|
|
1346
|
-
}
|
|
1347
2384
|
/**
|
|
1348
2385
|
* Find the cached download that best answers a format request, without network access.
|
|
2386
|
+
* Only the downloads shared by name and version are searched, not those from direct URLs.
|
|
1349
2387
|
* Matching follows {@link selectFormatCandidate}.
|
|
1350
2388
|
*/
|
|
1351
2389
|
function findCachedFormat(request, options = {}) {
|
|
@@ -1357,18 +2395,11 @@ function getCachedFormat(name, version) {
|
|
|
1357
2395
|
const formatPath = join(getCacheDir(), name, version, "format.js");
|
|
1358
2396
|
try {
|
|
1359
2397
|
if (!existsSync(formatPath)) return void 0;
|
|
1360
|
-
const source =
|
|
2398
|
+
const source = readUTF8(formatPath);
|
|
1361
2399
|
const id = makeFormatId(name, version);
|
|
1362
2400
|
const data = parseFormatJSON(source, id);
|
|
1363
2401
|
if (!data) return void 0;
|
|
1364
|
-
return
|
|
1365
|
-
id,
|
|
1366
|
-
filename: formatPath,
|
|
1367
|
-
isTwine2: true,
|
|
1368
|
-
name: data.name,
|
|
1369
|
-
version: data.version,
|
|
1370
|
-
proofing: data.proofing ?? false
|
|
1371
|
-
};
|
|
2402
|
+
return cachedFormatInfo(id, formatPath, data);
|
|
1372
2403
|
} catch {
|
|
1373
2404
|
return;
|
|
1374
2405
|
}
|
|
@@ -1453,21 +2484,27 @@ function listCachedFormats() {
|
|
|
1453
2484
|
}
|
|
1454
2485
|
/**
|
|
1455
2486
|
* Clear all cached formats, or only those matching a given name. Returns the number of entries removed.
|
|
2487
|
+
* Clearing all also removes the downloads from direct format URLs; a name clears only the
|
|
2488
|
+
* downloads shared by name and version.
|
|
1456
2489
|
* A name must be a single cache entry name (as `listCachedFormats()` reports it); anything with a
|
|
1457
2490
|
* path separator or `.`/`..` throws instead of deleting outside the format's own directory.
|
|
1458
2491
|
*/
|
|
1459
2492
|
function clearCachedFormats(name) {
|
|
1460
2493
|
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.`);
|
|
1461
2494
|
const cacheDir = getCacheDir();
|
|
1462
|
-
if (!existsSync(cacheDir)) return 0;
|
|
1463
2495
|
if (!name) {
|
|
1464
|
-
const count = listCachedFormats().length;
|
|
2496
|
+
const count = listCachedFormats().length + countUrlCacheEntries();
|
|
1465
2497
|
rmSync(cacheDir, {
|
|
1466
2498
|
recursive: true,
|
|
1467
2499
|
force: true
|
|
1468
2500
|
});
|
|
2501
|
+
rmSync(getUrlCacheDir(), {
|
|
2502
|
+
recursive: true,
|
|
2503
|
+
force: true
|
|
2504
|
+
});
|
|
1469
2505
|
return count;
|
|
1470
2506
|
}
|
|
2507
|
+
if (!existsSync(cacheDir)) return 0;
|
|
1471
2508
|
const root = resolve(cacheDir);
|
|
1472
2509
|
const nameDir = resolve(root, name);
|
|
1473
2510
|
if (!isInside(root, nameDir)) throw new Error(`Refusing to clear ${JSON.stringify(name)}: it is not a cached format name.`);
|
|
@@ -1485,6 +2522,15 @@ function clearCachedFormats(name) {
|
|
|
1485
2522
|
});
|
|
1486
2523
|
return count;
|
|
1487
2524
|
}
|
|
2525
|
+
/** The number of direct format URLs with a download in the cache. */
|
|
2526
|
+
function countUrlCacheEntries() {
|
|
2527
|
+
const dir = getUrlCacheDir();
|
|
2528
|
+
try {
|
|
2529
|
+
return readdirSync(dir).filter((key) => existsSync(join(dir, key, "format.js"))).length;
|
|
2530
|
+
} catch {
|
|
2531
|
+
return 0;
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
1488
2534
|
/** Get total cache size in bytes and format count. */
|
|
1489
2535
|
function getCacheSize() {
|
|
1490
2536
|
const entries = listCachedFormats();
|
|
@@ -1511,44 +2557,65 @@ function formatRequestFor(formatId, storyFormat, storyFormatVersion, defaultId)
|
|
|
1511
2557
|
id: defaultId
|
|
1512
2558
|
};
|
|
1513
2559
|
}
|
|
1514
|
-
/**
|
|
1515
|
-
|
|
2560
|
+
/**
|
|
2561
|
+
* Look a request up among the formats found in local format directories. An ID names a folder, so
|
|
2562
|
+
* it is looked up among all of them; a name request selects by SemVer among the pruned formats.
|
|
2563
|
+
*/
|
|
2564
|
+
function findLocalFormat(all, pruned, request) {
|
|
1516
2565
|
switch (request.kind) {
|
|
1517
|
-
case "id": return
|
|
1518
|
-
case "name":
|
|
1519
|
-
const id = getFormatIdByNameAndVersion(formats, request.name, request.version);
|
|
1520
|
-
return id === void 0 ? void 0 : formats.get(id);
|
|
1521
|
-
}
|
|
2566
|
+
case "id": return findFormatById(all, request.id);
|
|
2567
|
+
case "name": return selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f);
|
|
1522
2568
|
default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
|
|
1523
2569
|
}
|
|
1524
2570
|
}
|
|
1525
2571
|
/**
|
|
1526
2572
|
* Resolve a format request to a story format.
|
|
1527
2573
|
*
|
|
1528
|
-
* Order: local format directories, then the
|
|
1529
|
-
*
|
|
2574
|
+
* Order: local format directories, then the project's direct format URLs (each one's cached
|
|
2575
|
+
* copy, else a download unless `noRemote`), then the download cache shared by name and version,
|
|
2576
|
+
* then format indices (unless `noRemote`). Names and IDs match the same way in each (see
|
|
2577
|
+
* `selectFormatCandidate`), so a request that a local format answers never reaches the cache or the
|
|
2578
|
+
* network. Local formats that cannot be used are reported as warnings. When a StoryData request
|
|
2579
|
+
* names a version that none of them has, an older version of
|
|
1530
2580
|
* the same format and major version is used with a warning. Anything else is an error diagnostic,
|
|
1531
2581
|
* and the result is `undefined`.
|
|
1532
2582
|
*/
|
|
1533
2583
|
async function resolveStoryFormat(request, options, diagnostics) {
|
|
1534
2584
|
const noRemote = options.noRemote ?? false;
|
|
1535
|
-
const
|
|
2585
|
+
const all = discoverAllFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true), diagnostics);
|
|
2586
|
+
const pruned = pruneFormats(all);
|
|
1536
2587
|
const wanted = describeFormatRequest(request);
|
|
1537
|
-
const local = findLocalFormat(
|
|
2588
|
+
const local = findLocalFormat(all, pruned, request);
|
|
1538
2589
|
if (local) return local;
|
|
2590
|
+
const fetchOptions = {
|
|
2591
|
+
signal: options.signal,
|
|
2592
|
+
timeout: options.formatFetchTimeout
|
|
2593
|
+
};
|
|
2594
|
+
const warnOnFailure = async (lookup) => {
|
|
2595
|
+
try {
|
|
2596
|
+
return await lookup();
|
|
2597
|
+
} catch (e) {
|
|
2598
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
2599
|
+
diagnostics.push({
|
|
2600
|
+
level: "warning",
|
|
2601
|
+
message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
|
|
2602
|
+
});
|
|
2603
|
+
return;
|
|
2604
|
+
}
|
|
2605
|
+
};
|
|
2606
|
+
const direct = await warnOnFailure(() => resolveFormatUrls(request, options.formatUrls ?? [], {
|
|
2607
|
+
...fetchOptions,
|
|
2608
|
+
offline: noRemote
|
|
2609
|
+
}));
|
|
2610
|
+
if (direct) return direct;
|
|
1539
2611
|
const cached = findCachedFormat(request);
|
|
1540
2612
|
if (cached) return cached;
|
|
1541
|
-
if (!noRemote)
|
|
1542
|
-
const remote = await resolveRemoteFormatRequest(request, options.formatIndices,
|
|
2613
|
+
if (!noRemote) {
|
|
2614
|
+
const remote = await warnOnFailure(() => resolveRemoteFormatRequest(request, options.formatIndices, [], fetchOptions));
|
|
1543
2615
|
if (remote) return remote;
|
|
1544
|
-
} catch (e) {
|
|
1545
|
-
diagnostics.push({
|
|
1546
|
-
level: "warning",
|
|
1547
|
-
message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
|
|
1548
|
-
});
|
|
1549
2616
|
}
|
|
1550
2617
|
if (request.kind === "name") {
|
|
1551
|
-
const older = selectFormatCandidate(request,
|
|
2618
|
+
const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
|
|
1552
2619
|
if (older) {
|
|
1553
2620
|
diagnostics.push({
|
|
1554
2621
|
level: "warning",
|
|
@@ -1560,7 +2627,7 @@ async function resolveStoryFormat(request, options, diagnostics) {
|
|
|
1560
2627
|
const reason = noRemote ? " (remote fetching disabled)" : "";
|
|
1561
2628
|
diagnostics.push({
|
|
1562
2629
|
level: "error",
|
|
1563
|
-
message: `Story format ${wanted} is not available${reason}. Found: ${[...
|
|
2630
|
+
message: `Story format ${wanted} is not available${reason}. Found: ${[...all.keys()].join(", ") || "none"}`
|
|
1564
2631
|
});
|
|
1565
2632
|
}
|
|
1566
2633
|
//#endregion
|
|
@@ -1855,12 +2922,14 @@ function twee2ToV3(s) {
|
|
|
1855
2922
|
* Parse Twee source text into passages.
|
|
1856
2923
|
*
|
|
1857
2924
|
* The source is normalized first, as files are when they are read: a leading UTF-8 BOM is
|
|
1858
|
-
* removed and CRLF and bare CR line endings become LF.
|
|
2925
|
+
* removed and CRLF and bare CR line endings become LF. A BOM at the start of a later line,
|
|
2926
|
+
* directly before `::` (left there by concatenating files), is removed too, so that line
|
|
2927
|
+
* stays a passage header.
|
|
1859
2928
|
*/
|
|
1860
2929
|
function parseTwee(source, options = {}) {
|
|
1861
2930
|
const { filename = "<inline>", trim = true, twee2Compat = false } = options;
|
|
1862
2931
|
const diagnostics = [];
|
|
1863
|
-
const normalized =
|
|
2932
|
+
const normalized = normalizeTweeSourceText(source);
|
|
1864
2933
|
const tweeSource = twee2Compat ? twee2ToV3(normalized) : normalized;
|
|
1865
2934
|
const passages = [];
|
|
1866
2935
|
let current = null;
|
|
@@ -4697,15 +5766,28 @@ function parseDocument(data, options) {
|
|
|
4697
5766
|
/**
|
|
4698
5767
|
* Parse a Twine 2 or Twine 1 compiled HTML file back into a Story model.
|
|
4699
5768
|
* Tweego always trims passage text; here `trim: false` keeps it exactly, as for Twee sources.
|
|
5769
|
+
* A missing or invalid `tw-storydata` IFID is reported as a warning; an invalid one is kept as
|
|
5770
|
+
* written (uppercased), a missing one stays empty.
|
|
4700
5771
|
*/
|
|
4701
5772
|
function decompileHTML(html, options = {}) {
|
|
5773
|
+
return decompile(html, options, { ifid: true });
|
|
5774
|
+
}
|
|
5775
|
+
/**
|
|
5776
|
+
* `decompileHTML()` for loading an HTML file into a compile. The importer's StoryData passage is
|
|
5777
|
+
* checked when it is added to the story being compiled, which reports a missing or invalid IFID,
|
|
5778
|
+
* so the IFID is not reported here as well.
|
|
5779
|
+
*/
|
|
5780
|
+
function decompileHTMLForImport(html, options = {}) {
|
|
5781
|
+
return decompile(html, options, { ifid: false });
|
|
5782
|
+
}
|
|
5783
|
+
function decompile(html, options, checks) {
|
|
4702
5784
|
const diagnostics = [];
|
|
4703
5785
|
const story = createStory();
|
|
4704
5786
|
const passageText = options.trim === false ? (text) => text : (text) => text.trim();
|
|
4705
5787
|
const doc = parseDocument(html);
|
|
4706
5788
|
const twine2Data = findElement(doc, "tw-storydata");
|
|
4707
5789
|
if (twine2Data) {
|
|
4708
|
-
decompileTwine2(twine2Data, story, passageText, diagnostics);
|
|
5790
|
+
decompileTwine2(twine2Data, story, passageText, checks, diagnostics);
|
|
4709
5791
|
return {
|
|
4710
5792
|
story,
|
|
4711
5793
|
diagnostics
|
|
@@ -4728,7 +5810,7 @@ function decompileHTML(html, options = {}) {
|
|
|
4728
5810
|
diagnostics
|
|
4729
5811
|
};
|
|
4730
5812
|
}
|
|
4731
|
-
function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
5813
|
+
function decompileTwine2(storyData, story, passageText, checks, diagnostics) {
|
|
4732
5814
|
let startnode = 0;
|
|
4733
5815
|
const attrs = storyData.attribs ?? {};
|
|
4734
5816
|
if (attrs["name"]) story.name = attrs["name"];
|
|
@@ -4740,7 +5822,8 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4740
5822
|
});
|
|
4741
5823
|
else startnode = parsed;
|
|
4742
5824
|
}
|
|
4743
|
-
if (attrs["ifid"]) story.ifid = attrs["ifid"]
|
|
5825
|
+
if (attrs["ifid"]) story.ifid = normalizeIFID(attrs["ifid"]);
|
|
5826
|
+
if (checks.ifid) diagnostics.push(...storyDataIFIDDiagnostics(attrs["ifid"] ?? ""));
|
|
4744
5827
|
if (attrs["zoom"]) {
|
|
4745
5828
|
const parsed = parseFloat(attrs["zoom"]);
|
|
4746
5829
|
if (Number.isNaN(parsed)) diagnostics.push({
|
|
@@ -4753,7 +5836,9 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4753
5836
|
if (attrs["format"]) story.twine2.format = attrs["format"];
|
|
4754
5837
|
if (attrs["format-version"]) story.twine2.formatVersion = attrs["format-version"];
|
|
4755
5838
|
if (attrs["options"]) for (const opt of attrs["options"].split(/\s+/).filter((s) => s.length > 0)) story.twine2.options.set(opt, true);
|
|
4756
|
-
|
|
5839
|
+
const children = storyData.children ?? [];
|
|
5840
|
+
const takenNames = new Set(children.filter((node) => node.type === "tag" && node.name === "tw-passagedata").map(passageDataName));
|
|
5841
|
+
for (const node of children) {
|
|
4757
5842
|
if (node.type !== "tag" && node.type !== "style" && node.type !== "script") continue;
|
|
4758
5843
|
switch (node.name) {
|
|
4759
5844
|
case "style":
|
|
@@ -4761,11 +5846,17 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4761
5846
|
const content = getTextContent(node);
|
|
4762
5847
|
if (content.trim().length === 0) continue;
|
|
4763
5848
|
const text = passageText(content);
|
|
4764
|
-
|
|
4765
|
-
|
|
5849
|
+
const base = node.name === "style" ? "Story Stylesheet" : "Story JavaScript";
|
|
5850
|
+
const name = freeName(base, (n) => !takenNames.has(n));
|
|
5851
|
+
takenNames.add(name);
|
|
5852
|
+
storyAdd(story, withGeneratedName({
|
|
5853
|
+
name,
|
|
4766
5854
|
tags: node.name === "style" ? ["stylesheet"] : ["script"],
|
|
4767
5855
|
text
|
|
4768
|
-
},
|
|
5856
|
+
}, {
|
|
5857
|
+
kind: "code",
|
|
5858
|
+
base
|
|
5859
|
+
}), diagnostics);
|
|
4769
5860
|
break;
|
|
4770
5861
|
}
|
|
4771
5862
|
case "tw-tag": {
|
|
@@ -4778,7 +5869,7 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4778
5869
|
case "tw-passagedata": {
|
|
4779
5870
|
let pid = 0;
|
|
4780
5871
|
const pAttrs = node.attribs ?? {};
|
|
4781
|
-
const name =
|
|
5872
|
+
const name = passageDataName(node);
|
|
4782
5873
|
const tags = pAttrs["tags"] ? pAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [];
|
|
4783
5874
|
const metadata = {};
|
|
4784
5875
|
if (pAttrs["pid"]) {
|
|
@@ -4809,11 +5900,29 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4809
5900
|
text: marshalStoryData(story)
|
|
4810
5901
|
}, diagnostics);
|
|
4811
5902
|
}
|
|
5903
|
+
function passageDataName(node) {
|
|
5904
|
+
return node.attribs?.["name"] ?? "";
|
|
5905
|
+
}
|
|
5906
|
+
/** Diagnostics for the `ifid` attribute of `tw-storydata`; `value` is empty when it is missing. */
|
|
5907
|
+
function storyDataIFIDDiagnostics(value) {
|
|
5908
|
+
if (value === "") return [{
|
|
5909
|
+
level: "warning",
|
|
5910
|
+
message: "Story IFID not found; the \"tw-storydata\" content attribute \"ifid\" is missing or empty."
|
|
5911
|
+
}];
|
|
5912
|
+
const err = validateIFID(value);
|
|
5913
|
+
if (err === null) return [];
|
|
5914
|
+
return [{
|
|
5915
|
+
level: "warning",
|
|
5916
|
+
message: `Cannot parse "tw-storydata" content attribute "ifid" as an IFID; value "${value}" (${err}).`
|
|
5917
|
+
}];
|
|
5918
|
+
}
|
|
4812
5919
|
function decompileTwine1(storeArea, story, passageText, diagnostics) {
|
|
4813
5920
|
const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
|
|
4814
5921
|
const obfuscated = isRot13Obfuscated(passages);
|
|
4815
|
-
for (const p of passages) storyAdd(story, obfuscated && p
|
|
5922
|
+
for (const p of passages) storyAdd(story, obfuscated && isObfuscatable(p) ? {
|
|
4816
5923
|
...p,
|
|
5924
|
+
name: rot13(p.name),
|
|
5925
|
+
tags: p.tags.map(rot13),
|
|
4817
5926
|
text: rot13(p.text)
|
|
4818
5927
|
} : p, diagnostics);
|
|
4819
5928
|
}
|
|
@@ -4958,7 +6067,12 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
|
|
|
4958
6067
|
function loadInlineSources(story, sources, opts, diagnostics) {
|
|
4959
6068
|
for (const source of sources) {
|
|
4960
6069
|
if (typeof source === "string") continue;
|
|
4961
|
-
const
|
|
6070
|
+
const decoded = typeof source.content === "string" ? {
|
|
6071
|
+
text: source.content,
|
|
6072
|
+
diagnostics: []
|
|
6073
|
+
} : decodeText(source.content, source.filename);
|
|
6074
|
+
diagnostics.push(...decoded.diagnostics);
|
|
6075
|
+
const content = normalizeSourceText(decoded.text);
|
|
4962
6076
|
const ext = normalizedFileExt(source.filename);
|
|
4963
6077
|
switch (ext) {
|
|
4964
6078
|
case "":
|
|
@@ -4977,18 +6091,10 @@ function loadInlineSources(story, sources, opts, diagnostics) {
|
|
|
4977
6091
|
break;
|
|
4978
6092
|
}
|
|
4979
6093
|
case "css":
|
|
4980
|
-
storyAdd(story,
|
|
4981
|
-
name: basename(source.filename),
|
|
4982
|
-
tags: ["stylesheet"],
|
|
4983
|
-
text: content
|
|
4984
|
-
}, diagnostics);
|
|
6094
|
+
storyAdd(story, codePassage(basename(source.filename), "stylesheet", content), diagnostics);
|
|
4985
6095
|
break;
|
|
4986
6096
|
case "js":
|
|
4987
|
-
storyAdd(story,
|
|
4988
|
-
name: basename(source.filename),
|
|
4989
|
-
tags: ["script"],
|
|
4990
|
-
text: content
|
|
4991
|
-
}, diagnostics);
|
|
6097
|
+
storyAdd(story, codePassage(basename(source.filename), "script", content), diagnostics);
|
|
4992
6098
|
break;
|
|
4993
6099
|
default: diagnostics.push({
|
|
4994
6100
|
level: "warning",
|
|
@@ -4998,35 +6104,60 @@ function loadInlineSources(story, sources, opts, diagnostics) {
|
|
|
4998
6104
|
}
|
|
4999
6105
|
}
|
|
5000
6106
|
function parseTweeFile(filename, opts) {
|
|
5001
|
-
const
|
|
6107
|
+
const readDiagnostics = [];
|
|
6108
|
+
const result = parseTwee(readUTF8(filename, readDiagnostics), {
|
|
5002
6109
|
filename,
|
|
5003
6110
|
trim: opts.trim ?? true,
|
|
5004
6111
|
twee2Compat: opts.twee2Compat ?? false
|
|
5005
6112
|
});
|
|
5006
6113
|
return {
|
|
5007
6114
|
passages: result.passages,
|
|
5008
|
-
diagnostics: [...result.diagnostics]
|
|
6115
|
+
diagnostics: [...readDiagnostics, ...result.diagnostics]
|
|
5009
6116
|
};
|
|
5010
6117
|
}
|
|
6118
|
+
/**
|
|
6119
|
+
* A stylesheet or script passage named after its file, as Tweego names it. The name only labels
|
|
6120
|
+
* the code, so when another passage has it, the passage takes a free one (see `storyAdd()`).
|
|
6121
|
+
*/
|
|
6122
|
+
function codePassage(name, tag, text) {
|
|
6123
|
+
return withGeneratedName({
|
|
6124
|
+
name,
|
|
6125
|
+
tags: [tag],
|
|
6126
|
+
text
|
|
6127
|
+
}, {
|
|
6128
|
+
kind: "code",
|
|
6129
|
+
base: name
|
|
6130
|
+
});
|
|
6131
|
+
}
|
|
6132
|
+
/** Mark every passage of a parse result as a code passage named after its file. */
|
|
6133
|
+
function codeNames(result) {
|
|
6134
|
+
for (const p of result.passages) withGeneratedName(p, {
|
|
6135
|
+
kind: "code",
|
|
6136
|
+
base: p.name
|
|
6137
|
+
});
|
|
6138
|
+
return result;
|
|
6139
|
+
}
|
|
5011
6140
|
function parseTaggedFile(tag, filename) {
|
|
5012
|
-
const
|
|
6141
|
+
const diagnostics = [];
|
|
6142
|
+
const source = readUTF8(filename, diagnostics);
|
|
5013
6143
|
return {
|
|
5014
|
-
passages: [
|
|
5015
|
-
|
|
5016
|
-
tags: [tag],
|
|
5017
|
-
text: source
|
|
5018
|
-
}],
|
|
5019
|
-
diagnostics: []
|
|
6144
|
+
passages: [codePassage(basename(filename), tag, source)],
|
|
6145
|
+
diagnostics
|
|
5020
6146
|
};
|
|
5021
6147
|
}
|
|
5022
6148
|
function parseMediaFile(tag, filename) {
|
|
5023
6149
|
const source = readBase64(filename);
|
|
6150
|
+
const name = baseNameWithoutExt(filename);
|
|
5024
6151
|
return {
|
|
5025
|
-
passages: [{
|
|
5026
|
-
name
|
|
6152
|
+
passages: [withGeneratedName({
|
|
6153
|
+
name,
|
|
5027
6154
|
tags: [tag],
|
|
5028
6155
|
text: `data:${mediaTypeFromFilename(filename)};base64,${source}`
|
|
5029
|
-
}
|
|
6156
|
+
}, {
|
|
6157
|
+
kind: "media",
|
|
6158
|
+
base: name,
|
|
6159
|
+
file: filename
|
|
6160
|
+
})],
|
|
5030
6161
|
diagnostics: []
|
|
5031
6162
|
};
|
|
5032
6163
|
}
|
|
@@ -5035,24 +6166,27 @@ function parseFontFile(filename) {
|
|
|
5035
6166
|
const name = basename(filename);
|
|
5036
6167
|
const family = baseNameWithoutExt(filename);
|
|
5037
6168
|
const ext = normalizedFileExt(filename);
|
|
5038
|
-
|
|
6169
|
+
const mediaType = mediaTypeFromExt(ext);
|
|
6170
|
+
const hint = fontFormatHint(ext);
|
|
6171
|
+
return codeNames({
|
|
5039
6172
|
passages: [{
|
|
5040
6173
|
name,
|
|
5041
6174
|
tags: ["stylesheet"],
|
|
5042
|
-
text: `@font-face {\n\tfont-family: "${family}";\n\tsrc: url("data:${
|
|
6175
|
+
text: `@font-face {\n\tfont-family: "${cssStringEscape(family)}";\n\tsrc: url("data:${mediaType};base64,${source}") format("${hint}");\n}`
|
|
5043
6176
|
}],
|
|
5044
6177
|
diagnostics: []
|
|
5045
|
-
};
|
|
6178
|
+
});
|
|
5046
6179
|
}
|
|
5047
6180
|
function parseHTMLFile(filename, opts) {
|
|
5048
|
-
const
|
|
6181
|
+
const readDiagnostics = [];
|
|
6182
|
+
const { story, diagnostics } = decompileHTMLForImport(readUTF8(filename, readDiagnostics), { trim: opts.trim ?? true });
|
|
5049
6183
|
return {
|
|
5050
6184
|
passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
|
|
5051
6185
|
name: "StoryTitle",
|
|
5052
6186
|
tags: [],
|
|
5053
6187
|
text: story.name
|
|
5054
6188
|
}, ...story.passages] : story.passages,
|
|
5055
|
-
diagnostics: [...diagnostics]
|
|
6189
|
+
diagnostics: [...readDiagnostics, ...diagnostics]
|
|
5056
6190
|
};
|
|
5057
6191
|
}
|
|
5058
6192
|
/**
|
|
@@ -5125,6 +6259,7 @@ function loadTwee(story, filename, opts, diagnostics) {
|
|
|
5125
6259
|
}
|
|
5126
6260
|
function loadTagged(story, tag, filename, diagnostics) {
|
|
5127
6261
|
const result = parseTaggedFile(tag, filename);
|
|
6262
|
+
diagnostics.push(...result.diagnostics);
|
|
5128
6263
|
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
5129
6264
|
}
|
|
5130
6265
|
function loadMedia(story, tag, filename, diagnostics) {
|
|
@@ -5141,6 +6276,7 @@ function loadFont(story, filename, diagnostics) {
|
|
|
5141
6276
|
*/
|
|
5142
6277
|
function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
|
|
5143
6278
|
const currentFiles = /* @__PURE__ */ new Set();
|
|
6279
|
+
const changedPaths = changedFiles === void 0 ? void 0 : new Set([...changedFiles].map((f) => resolve(f)));
|
|
5144
6280
|
for (const filename of filenames) {
|
|
5145
6281
|
if (processedFiles.has(filename)) {
|
|
5146
6282
|
diagnostics.push({
|
|
@@ -5153,8 +6289,8 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5153
6289
|
const optionsKey = parseOptionsKey(filename, opts);
|
|
5154
6290
|
const entry = cache.get(filename);
|
|
5155
6291
|
const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
|
|
5156
|
-
const changed =
|
|
5157
|
-
if (
|
|
6292
|
+
const changed = changedPaths?.has(resolve(filename)) ?? false;
|
|
6293
|
+
if (changedPaths && cached && !changed) {
|
|
5158
6294
|
diagnostics.push(...cached.diagnostics);
|
|
5159
6295
|
for (const p of cached.passages) storyAdd(story, p, diagnostics);
|
|
5160
6296
|
processedFiles.add(filename);
|
|
@@ -5164,6 +6300,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5164
6300
|
try {
|
|
5165
6301
|
mtimeMs = statSync(filename).mtimeMs;
|
|
5166
6302
|
} catch {
|
|
6303
|
+
cache.delete(filename);
|
|
5167
6304
|
continue;
|
|
5168
6305
|
}
|
|
5169
6306
|
if (cached && !changed && cached.mtimeMs === mtimeMs) {
|
|
@@ -5184,6 +6321,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5184
6321
|
diagnostics.push(...result.diagnostics);
|
|
5185
6322
|
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
5186
6323
|
} catch (e) {
|
|
6324
|
+
cache.delete(filename);
|
|
5187
6325
|
diagnostics.push({
|
|
5188
6326
|
level: "error",
|
|
5189
6327
|
message: `load ${filename}: ${e instanceof Error ? e.message : String(e)}`,
|
|
@@ -5201,6 +6339,164 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
5201
6339
|
}, diagnostics);
|
|
5202
6340
|
}
|
|
5203
6341
|
//#endregion
|
|
6342
|
+
//#region src/template.ts
|
|
6343
|
+
/**
|
|
6344
|
+
* An HTML closing head tag: `</head` in any letter case, followed by whitespace, `/` or `>`, up to the next `>`.
|
|
6345
|
+
* This is how the HTML tokenizer reads an end tag; it ignores whatever sits between the tag name and the `>`.
|
|
6346
|
+
*/
|
|
6347
|
+
const CLOSING_HEAD_TAG = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>";
|
|
6348
|
+
/**
|
|
6349
|
+
* The start of an HTML body start tag: `<body` in any letter case, followed by whitespace, `/` or `>` (so not
|
|
6350
|
+
* `<bodyx>`). Only the `<body` is matched; the attributes and `>` after it are left alone.
|
|
6351
|
+
*/
|
|
6352
|
+
const BODY_START_TAG = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
|
|
6353
|
+
/** The regular expression source matching exactly `text`. */
|
|
6354
|
+
function literal(text) {
|
|
6355
|
+
return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
6356
|
+
}
|
|
6357
|
+
/** Replace the `slots` placeholders found in `template`, without scanning inserted text. */
|
|
6358
|
+
function fillTemplate(template, slots) {
|
|
6359
|
+
return fillTemplateParts([{
|
|
6360
|
+
kind: "scan",
|
|
6361
|
+
text: template,
|
|
6362
|
+
slots
|
|
6363
|
+
}]);
|
|
6364
|
+
}
|
|
6365
|
+
/**
|
|
6366
|
+
* Fill the parts of a template in order, joined. A `'first'` slot replaced in one part is not replaced again in a
|
|
6367
|
+
* later part that lists the same slot object.
|
|
6368
|
+
*/
|
|
6369
|
+
function fillTemplateParts(parts) {
|
|
6370
|
+
const filled = /* @__PURE__ */ new Set();
|
|
6371
|
+
return parts.map((part) => {
|
|
6372
|
+
if (part.kind === "verbatim" || part.slots.length === 0) return part.text;
|
|
6373
|
+
const { slots } = part;
|
|
6374
|
+
const pattern = new RegExp(slots.map((slot) => `(${slot.pattern})`).join("|"), "g");
|
|
6375
|
+
return part.text.replace(pattern, (match, ...groups) => {
|
|
6376
|
+
const slot = slots.find((_, i) => groups[i] !== void 0);
|
|
6377
|
+
if (slot === void 0 || slot.occurrences === "first" && filled.has(slot)) return match;
|
|
6378
|
+
filled.add(slot);
|
|
6379
|
+
return slot.replacement(match);
|
|
6380
|
+
});
|
|
6381
|
+
}).join("");
|
|
6382
|
+
}
|
|
6383
|
+
//#endregion
|
|
6384
|
+
//#region src/modules.ts
|
|
6385
|
+
/**
|
|
6386
|
+
* Load modules and return HTML tags to inject before the closing head tag. `diagnostics` receives a warning for
|
|
6387
|
+
* each module that is not valid UTF-8 (it is read as Windows-1252).
|
|
6388
|
+
*/
|
|
6389
|
+
function loadModules(filenames, diagnostics) {
|
|
6390
|
+
const processed = /* @__PURE__ */ new Set();
|
|
6391
|
+
const headTags = [];
|
|
6392
|
+
for (const filename of filenames) {
|
|
6393
|
+
if (processed.has(filename)) continue;
|
|
6394
|
+
const ext = normalizedFileExt(filename);
|
|
6395
|
+
let tag = null;
|
|
6396
|
+
switch (ext) {
|
|
6397
|
+
case "css":
|
|
6398
|
+
tag = loadModuleTagged("style", filename, diagnostics);
|
|
6399
|
+
break;
|
|
6400
|
+
case "js":
|
|
6401
|
+
tag = loadModuleTagged("script", filename, diagnostics);
|
|
6402
|
+
break;
|
|
6403
|
+
case "otf":
|
|
6404
|
+
case "ttf":
|
|
6405
|
+
case "woff":
|
|
6406
|
+
case "woff2":
|
|
6407
|
+
tag = loadModuleFont(filename);
|
|
6408
|
+
break;
|
|
6409
|
+
default: continue;
|
|
6410
|
+
}
|
|
6411
|
+
if (tag) headTags.push(tag);
|
|
6412
|
+
processed.add(filename);
|
|
6413
|
+
}
|
|
6414
|
+
return headTags.join("\n");
|
|
6415
|
+
}
|
|
6416
|
+
function loadModuleTagged(tag, filename, diagnostics) {
|
|
6417
|
+
const source = readUTF8(filename, diagnostics).trim();
|
|
6418
|
+
if (source.length === 0) return null;
|
|
6419
|
+
return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
|
|
6420
|
+
}
|
|
6421
|
+
function loadModuleFont(filename) {
|
|
6422
|
+
const source = readBase64(filename);
|
|
6423
|
+
const family = baseNameWithoutExt(filename);
|
|
6424
|
+
const idSlug = `style-module-${slugify(family)}`;
|
|
6425
|
+
const ext = normalizedFileExt(filename);
|
|
6426
|
+
const mediaType = mediaTypeFromExt(ext);
|
|
6427
|
+
const hint = fontFormatHint(ext);
|
|
6428
|
+
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>`;
|
|
6429
|
+
}
|
|
6430
|
+
/**
|
|
6431
|
+
* Load the module tags and head file content to inject into the head (see `placeHead()`), joined by newlines,
|
|
6432
|
+
* or `''` when there is nothing to inject.
|
|
6433
|
+
*/
|
|
6434
|
+
function loadHeadContent(modulePaths, headFile, diagnostics) {
|
|
6435
|
+
const parts = [];
|
|
6436
|
+
if (modulePaths.length > 0) {
|
|
6437
|
+
const modules = loadModules(modulePaths, diagnostics).trim();
|
|
6438
|
+
if (modules.length > 0) parts.push(modules);
|
|
6439
|
+
}
|
|
6440
|
+
if (headFile) try {
|
|
6441
|
+
const source = readUTF8(headFile, diagnostics).trim();
|
|
6442
|
+
if (source.length > 0) parts.push(source);
|
|
6443
|
+
} catch (e) {
|
|
6444
|
+
diagnostics?.push({
|
|
6445
|
+
level: "warning",
|
|
6446
|
+
message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
|
|
6447
|
+
});
|
|
6448
|
+
}
|
|
6449
|
+
return parts.join("\n");
|
|
6450
|
+
}
|
|
6451
|
+
/**
|
|
6452
|
+
* Place `content` in a template, given the texts that `fillTemplateParts()` scans for the slot (the template, and
|
|
6453
|
+
* the footer of a pre-1.4 Twine 1 format):
|
|
6454
|
+
*
|
|
6455
|
+
* - on its own line before the first closing head tag, as Tweego does; the tag may use any letter case and
|
|
6456
|
+
* whitespace before its `>`, and is kept as written;
|
|
6457
|
+
* - in a template with no closing head tag (HTML lets a document leave it out), on its own line before the first
|
|
6458
|
+
* body start tag, where the HTML parser still puts it in the head, with a warning (Tweego drops it);
|
|
6459
|
+
* - in a template with neither, nowhere, with a warning.
|
|
6460
|
+
*
|
|
6461
|
+
* `owner` names the template in the warnings. Empty `content` needs no slot and gets no warning.
|
|
6462
|
+
*/
|
|
6463
|
+
function placeHead(content, texts, owner) {
|
|
6464
|
+
if (content.length === 0) return {
|
|
6465
|
+
slot: void 0,
|
|
6466
|
+
diagnostics: []
|
|
6467
|
+
};
|
|
6468
|
+
const found = (tag) => texts.some((text) => new RegExp(tag).test(text));
|
|
6469
|
+
if (found("<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>")) return {
|
|
6470
|
+
slot: beforeTag(CLOSING_HEAD_TAG, content),
|
|
6471
|
+
diagnostics: []
|
|
6472
|
+
};
|
|
6473
|
+
if (found("<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])")) {
|
|
6474
|
+
const message = `${owner} has no closing head tag; the modules and head file were injected before its body start tag.`;
|
|
6475
|
+
return {
|
|
6476
|
+
slot: beforeTag(BODY_START_TAG, content),
|
|
6477
|
+
diagnostics: [{
|
|
6478
|
+
level: "warning",
|
|
6479
|
+
message
|
|
6480
|
+
}]
|
|
6481
|
+
};
|
|
6482
|
+
}
|
|
6483
|
+
return {
|
|
6484
|
+
slot: void 0,
|
|
6485
|
+
diagnostics: [{
|
|
6486
|
+
level: "warning",
|
|
6487
|
+
message: `${owner} has no closing head tag and no body start tag; the modules and head file were not injected.`
|
|
6488
|
+
}]
|
|
6489
|
+
};
|
|
6490
|
+
}
|
|
6491
|
+
/** The slot that inserts `content` on its own line before the first match of `tag`, which is kept as written. */
|
|
6492
|
+
function beforeTag(tag, content) {
|
|
6493
|
+
return {
|
|
6494
|
+
pattern: tag,
|
|
6495
|
+
occurrences: "first",
|
|
6496
|
+
replacement: (match) => `${content}\n${match}`
|
|
6497
|
+
};
|
|
6498
|
+
}
|
|
6499
|
+
//#endregion
|
|
5204
6500
|
//#region src/version.ts
|
|
5205
6501
|
/**
|
|
5206
6502
|
* The twee-ts version, read at runtime from the package's own package.json.
|
|
@@ -5234,24 +6530,42 @@ const CREATOR_NAME$2 = "Twee-ts";
|
|
|
5234
6530
|
function toTwine2Archive(story, startName, options) {
|
|
5235
6531
|
return getTwine2DataChunk(story, startName, options) + "\n";
|
|
5236
6532
|
}
|
|
6533
|
+
/**
|
|
6534
|
+
* Fill the Twine 2 format template: every `{{STORY_NAME}}` gets the escaped story name and the first
|
|
6535
|
+
* `{{STORY_DATA}}` the story data (as Tweego does), and `head` goes before the first closing head tag (or, with a
|
|
6536
|
+
* warning, the body start tag; see `placeHead()`). All three are found in the template in one pass, so a story
|
|
6537
|
+
* name, passage text or head content holding a placeholder or a closing head tag stays literal.
|
|
6538
|
+
* `options.diagnostics` receives a warning when the format file is not valid UTF-8, and any warning about where
|
|
6539
|
+
* `head` went.
|
|
6540
|
+
*/
|
|
5237
6541
|
function toTwine2HTML(story, format, startName, options) {
|
|
5238
|
-
|
|
5239
|
-
|
|
5240
|
-
|
|
5241
|
-
|
|
5242
|
-
|
|
5243
|
-
|
|
5244
|
-
|
|
5245
|
-
|
|
5246
|
-
|
|
5247
|
-
|
|
5248
|
-
|
|
5249
|
-
|
|
6542
|
+
const name = htmlEscape(story.name);
|
|
6543
|
+
const template = readFormatSource(format, options?.diagnostics);
|
|
6544
|
+
const placement = placeHead(options?.head ?? "", [template], `Story format "${format.name}" ${format.version}`);
|
|
6545
|
+
options?.diagnostics?.push(...placement.diagnostics);
|
|
6546
|
+
const head = placement.slot;
|
|
6547
|
+
return fillTemplate(template, [
|
|
6548
|
+
{
|
|
6549
|
+
pattern: literal("{{STORY_NAME}}"),
|
|
6550
|
+
occurrences: "all",
|
|
6551
|
+
replacement: () => name
|
|
6552
|
+
},
|
|
6553
|
+
{
|
|
6554
|
+
pattern: literal("{{STORY_DATA}}"),
|
|
6555
|
+
occurrences: "first",
|
|
6556
|
+
replacement: () => {
|
|
6557
|
+
return getTwine2DataChunk({
|
|
6558
|
+
...story,
|
|
6559
|
+
twine2: {
|
|
6560
|
+
...story.twine2,
|
|
6561
|
+
format: format.name,
|
|
6562
|
+
formatVersion: format.version
|
|
6563
|
+
}
|
|
6564
|
+
}, startName, options);
|
|
5250
6565
|
}
|
|
5251
|
-
},
|
|
5252
|
-
|
|
5253
|
-
|
|
5254
|
-
return template;
|
|
6566
|
+
},
|
|
6567
|
+
...head === void 0 ? [] : [head]
|
|
6568
|
+
]);
|
|
5255
6569
|
}
|
|
5256
6570
|
const OMITTING_TAGS = [
|
|
5257
6571
|
"Twine.private",
|
|
@@ -5344,39 +6658,83 @@ function toTwine1Archive(story, _startName) {
|
|
|
5344
6658
|
const { data, count } = getTwine1PassageChunk(story);
|
|
5345
6659
|
return `<div id="storeArea" data-size="${count}">${data}</div>\n`;
|
|
5346
6660
|
}
|
|
5347
|
-
|
|
6661
|
+
/**
|
|
6662
|
+
* Fill the Twine 1 format template. The format's components are inserted first, one after another, as Tweego and
|
|
6663
|
+
* Twine 1 do. Then the first of each story placeholder (`"VERSION"`, `"TIME"`, `"START_AT"`, `"STORY_SIZE"`,
|
|
6664
|
+
* `"STORY"`), the IFID comment and `head` (before the first closing head tag or, with a warning, the body start
|
|
6665
|
+
* tag; see `placeHead()`) are found in one pass, so a start passage name or story data holding a placeholder or a
|
|
6666
|
+
* closing head tag stays literal. `options.diagnostics` receives a warning for each format file that is not valid
|
|
6667
|
+
* UTF-8, and any warning about where `head` went.
|
|
6668
|
+
*/
|
|
6669
|
+
function toTwine1HTML(story, format, startName, options) {
|
|
5348
6670
|
const formatDir = dirname(format.filename);
|
|
5349
6671
|
const parentDir = dirname(formatDir);
|
|
5350
|
-
|
|
6672
|
+
const diagnostics = options?.diagnostics;
|
|
6673
|
+
let template = readFormatSource(format, diagnostics);
|
|
5351
6674
|
const { data, count } = getTwine1PassageChunk(story);
|
|
5352
|
-
template = tryReplaceComponent(template, "\"USER_LIB\"", join(formatDir, "userlib.js"), false);
|
|
5353
|
-
template = tryReplaceComponent(template, "\"ENGINE\"", join(parentDir, "engine.js"), true);
|
|
5354
|
-
template = tryReplaceComponent(template, "\"SUGARCANE\"", join(formatDir, "code.js"), true);
|
|
5355
|
-
template = tryReplaceComponent(template, "\"JONAH\"", join(formatDir, "code.js"), true);
|
|
5356
|
-
if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", join(parentDir, "jquery.js"), true);
|
|
5357
|
-
if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", join(parentDir, "modernizr.js"), true);
|
|
6675
|
+
template = tryReplaceComponent(template, "\"USER_LIB\"", join(formatDir, "userlib.js"), false, diagnostics);
|
|
6676
|
+
template = tryReplaceComponent(template, "\"ENGINE\"", join(parentDir, "engine.js"), true, diagnostics);
|
|
6677
|
+
template = tryReplaceComponent(template, "\"SUGARCANE\"", join(formatDir, "code.js"), true, diagnostics);
|
|
6678
|
+
template = tryReplaceComponent(template, "\"JONAH\"", join(formatDir, "code.js"), true, diagnostics);
|
|
6679
|
+
if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", join(parentDir, "jquery.js"), true, diagnostics);
|
|
6680
|
+
if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", join(parentDir, "modernizr.js"), true, diagnostics);
|
|
6681
|
+
const isPre14 = !template.includes("\"STORY\"");
|
|
6682
|
+
const footer = isPre14 ? readFooter(formatDir, diagnostics) : "";
|
|
6683
|
+
const storeArea = (template + footer).includes("<div id=\"store-area\"") ? "<div id=\"store-area\"" : "<div id=\"storeArea\"";
|
|
6684
|
+
const safeIfid = htmlCommentSanitize(story.ifid);
|
|
6685
|
+
const ifid = story.ifid ? {
|
|
6686
|
+
pattern: literal(storeArea),
|
|
6687
|
+
occurrences: "first",
|
|
6688
|
+
replacement: (div) => `<!-- UUID://${safeIfid}// -->${div}`
|
|
6689
|
+
} : void 0;
|
|
6690
|
+
const placement = placeHead(options?.head ?? "", isPre14 ? [template, footer] : [template], `Story format "${format.name}"`);
|
|
6691
|
+
diagnostics?.push(...placement.diagnostics);
|
|
6692
|
+
const late = [ifid, placement.slot].filter((slot) => slot !== void 0);
|
|
5358
6693
|
const displayStart = startName === "Start" ? "" : startName;
|
|
5359
|
-
|
|
5360
|
-
|
|
5361
|
-
|
|
5362
|
-
|
|
5363
|
-
|
|
5364
|
-
|
|
5365
|
-
|
|
5366
|
-
|
|
5367
|
-
|
|
5368
|
-
|
|
5369
|
-
|
|
5370
|
-
|
|
5371
|
-
|
|
5372
|
-
|
|
5373
|
-
|
|
5374
|
-
|
|
5375
|
-
|
|
5376
|
-
|
|
5377
|
-
|
|
6694
|
+
const slots = [
|
|
6695
|
+
firstSlot("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`),
|
|
6696
|
+
firstSlot("\"TIME\"", `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`),
|
|
6697
|
+
firstSlot("\"START_AT\"", `"${jsStringEscape(displayStart)}"`),
|
|
6698
|
+
firstSlot("\"STORY_SIZE\"", `"${count}"`),
|
|
6699
|
+
firstSlot("\"STORY\"", data),
|
|
6700
|
+
...late
|
|
6701
|
+
];
|
|
6702
|
+
return fillTemplateParts(isPre14 ? [
|
|
6703
|
+
{
|
|
6704
|
+
kind: "scan",
|
|
6705
|
+
text: template,
|
|
6706
|
+
slots
|
|
6707
|
+
},
|
|
6708
|
+
{
|
|
6709
|
+
kind: "verbatim",
|
|
6710
|
+
text: data
|
|
6711
|
+
},
|
|
6712
|
+
{
|
|
6713
|
+
kind: "scan",
|
|
6714
|
+
text: footer,
|
|
6715
|
+
slots: late
|
|
6716
|
+
}
|
|
6717
|
+
] : [{
|
|
6718
|
+
kind: "scan",
|
|
6719
|
+
text: template,
|
|
6720
|
+
slots
|
|
6721
|
+
}]);
|
|
6722
|
+
}
|
|
6723
|
+
/** A slot replacing the first `token` with `value`. */
|
|
6724
|
+
function firstSlot(token, value) {
|
|
6725
|
+
return {
|
|
6726
|
+
pattern: literal(token),
|
|
6727
|
+
occurrences: "first",
|
|
6728
|
+
replacement: () => value
|
|
6729
|
+
};
|
|
6730
|
+
}
|
|
6731
|
+
/** The footer of a pre-1.4 format, or the default one when the format has none. */
|
|
6732
|
+
function readFooter(formatDir, diagnostics) {
|
|
6733
|
+
try {
|
|
6734
|
+
return readUTF8(join(formatDir, "footer.html"), diagnostics);
|
|
6735
|
+
} catch {
|
|
6736
|
+
return "</div>\n</body>\n</html>\n";
|
|
5378
6737
|
}
|
|
5379
|
-
return template;
|
|
5380
6738
|
}
|
|
5381
6739
|
/**
|
|
5382
6740
|
* Why Twine 1 output leaves a passage out of its tiddlers, or `undefined` when the passage is emitted.
|
|
@@ -5401,11 +6759,10 @@ function getTwine1PassageChunk(story) {
|
|
|
5401
6759
|
count
|
|
5402
6760
|
};
|
|
5403
6761
|
}
|
|
5404
|
-
function tryReplaceComponent(template, placeholder, componentPath, required) {
|
|
6762
|
+
function tryReplaceComponent(template, placeholder, componentPath, required, diagnostics) {
|
|
5405
6763
|
if (!template.includes(placeholder)) return template;
|
|
5406
6764
|
try {
|
|
5407
|
-
|
|
5408
|
-
if (content.charCodeAt(0) === 65279) content = content.slice(1);
|
|
6765
|
+
const content = readUTF8(componentPath, diagnostics);
|
|
5409
6766
|
return template.replace(placeholder, () => content);
|
|
5410
6767
|
} catch (e) {
|
|
5411
6768
|
if (required) throw new Error(`Required format component not found: ${componentPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -5419,11 +6776,16 @@ function tryReplaceComponent(template, placeholder, componentPath, required) {
|
|
|
5419
6776
|
* from its loaded text, so changes made after loading (a start passage override, test mode) are kept.
|
|
5420
6777
|
* Tweego writes the loaded text; its own normalization makes the two the same for an unchanged story.
|
|
5421
6778
|
*
|
|
6779
|
+
* Twee cannot write every passage so that it reads back the same (see `tweeRoundTrip()`); `diagnostics`
|
|
6780
|
+
* receives a warning for each passage that will not. Tweego writes the same Twee without one.
|
|
6781
|
+
*
|
|
5422
6782
|
* `addStoryData`: add a StoryData passage when the story has none, so that StoryData fields the
|
|
5423
6783
|
* compile options set are recorded. Default: false.
|
|
5424
6784
|
*/
|
|
5425
6785
|
function toTwee(story, outMode, options = {}) {
|
|
5426
|
-
|
|
6786
|
+
const written = withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => tweeRoundTrip(p, outMode));
|
|
6787
|
+
options.diagnostics?.push(...written.flatMap(({ diagnostics }) => diagnostics));
|
|
6788
|
+
return written.map(({ passage }) => passageToTwee(passage, outMode)).join("");
|
|
5427
6789
|
}
|
|
5428
6790
|
function withEffectiveStoryData(story, addStoryData) {
|
|
5429
6791
|
const storyData = {
|
|
@@ -5448,74 +6810,63 @@ function withEffectiveStoryData(story, addStoryData) {
|
|
|
5448
6810
|
function isStoryDataJSON(text) {
|
|
5449
6811
|
return unmarshalStoryData(createStory(), text) === null;
|
|
5450
6812
|
}
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
/**
|
|
5454
|
-
* Load modules and return HTML tags to inject before </head>.
|
|
5455
|
-
*/
|
|
5456
|
-
function loadModules(filenames) {
|
|
5457
|
-
const processed = /* @__PURE__ */ new Set();
|
|
5458
|
-
const headTags = [];
|
|
5459
|
-
for (const filename of filenames) {
|
|
5460
|
-
if (processed.has(filename)) continue;
|
|
5461
|
-
const ext = normalizedFileExt(filename);
|
|
5462
|
-
let tag = null;
|
|
5463
|
-
switch (ext) {
|
|
5464
|
-
case "css":
|
|
5465
|
-
tag = loadModuleTagged("style", filename);
|
|
5466
|
-
break;
|
|
5467
|
-
case "js":
|
|
5468
|
-
tag = loadModuleTagged("script", filename);
|
|
5469
|
-
break;
|
|
5470
|
-
case "otf":
|
|
5471
|
-
case "ttf":
|
|
5472
|
-
case "woff":
|
|
5473
|
-
case "woff2":
|
|
5474
|
-
tag = loadModuleFont(filename);
|
|
5475
|
-
break;
|
|
5476
|
-
default: continue;
|
|
5477
|
-
}
|
|
5478
|
-
if (tag) headTags.push(tag);
|
|
5479
|
-
processed.add(filename);
|
|
5480
|
-
}
|
|
5481
|
-
return headTags.join("\n");
|
|
5482
|
-
}
|
|
5483
|
-
function loadModuleTagged(tag, filename) {
|
|
5484
|
-
const source = readUTF8(filename).trim();
|
|
5485
|
-
if (source.length === 0) return null;
|
|
5486
|
-
return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
|
|
5487
|
-
}
|
|
5488
|
-
function loadModuleFont(filename) {
|
|
5489
|
-
const source = readBase64(filename);
|
|
5490
|
-
const family = baseNameWithoutExt(filename);
|
|
5491
|
-
const idSlug = `style-module-${slugify(family)}`;
|
|
5492
|
-
const ext = normalizedFileExt(filename);
|
|
5493
|
-
return `<style id="${idSlug}" type="text/css">@font-face {\n\tfont-family: "${family}";\n\tsrc: url("data:${mediaTypeFromExt(ext)};base64,${source}") format("${fontFormatHint(ext)}");\n}</style>`;
|
|
5494
|
-
}
|
|
6813
|
+
/** Characters Twee 3 escapes in names and tags, and Twee 1 cannot write there at all. */
|
|
6814
|
+
const TWEE1_UNWRITABLE = /[[\]{}\\]/;
|
|
5495
6815
|
/**
|
|
5496
|
-
*
|
|
5497
|
-
|
|
5498
|
-
|
|
5499
|
-
|
|
5500
|
-
|
|
5501
|
-
|
|
5502
|
-
|
|
5503
|
-
|
|
5504
|
-
|
|
5505
|
-
|
|
5506
|
-
|
|
5507
|
-
|
|
5508
|
-
|
|
6816
|
+
* Check that Twee reads `p` back as written, and say what it will not. Twee has no way to escape passage text,
|
|
6817
|
+
* and its parser trims names and splits tags at whitespace, so these do not survive:
|
|
6818
|
+
*
|
|
6819
|
+
* - a text line that starts with `::`, which reads as a new passage header. In stylesheet and script passages such
|
|
6820
|
+
* a line is indented by one space, which keeps the CSS or JavaScript working (only a template literal or a
|
|
6821
|
+
* string continued across lines, in either, would see the space); the warning says so. Other passages' text is
|
|
6822
|
+
* written as it is.
|
|
6823
|
+
* - a name that is empty, has leading or trailing whitespace, or holds a line break;
|
|
6824
|
+
* - a tag that is empty or holds whitespace;
|
|
6825
|
+
* - in Twee 1, which escapes nothing, a name or tag holding `[`, `]`, `{`, `}` or `\`.
|
|
6826
|
+
*/
|
|
6827
|
+
function tweeRoundTrip(p, outMode) {
|
|
6828
|
+
const label = `Passage ${JSON.stringify(p.name)}`;
|
|
6829
|
+
const diagnostics = [];
|
|
6830
|
+
const warn = (problem) => {
|
|
6831
|
+
diagnostics.push({
|
|
5509
6832
|
level: "warning",
|
|
5510
|
-
message:
|
|
6833
|
+
message: `${label} cannot be written as Twee that reads back the same: ${problem}.`
|
|
5511
6834
|
});
|
|
6835
|
+
};
|
|
6836
|
+
if (p.name.trim() === "") warn("its name is empty");
|
|
6837
|
+
else if (p.name.trim() !== p.name) warn("its name has leading or trailing whitespace, which Twee drops");
|
|
6838
|
+
if (/[\r\n]/.test(p.name)) warn("its name has a line break, which ends a Twee passage header");
|
|
6839
|
+
if (outMode === "twee1" && TWEE1_UNWRITABLE.test(p.name)) warn("its name has \"[\", \"]\", \"{\", \"}\" or \"\\\", which Twee 1 cannot escape");
|
|
6840
|
+
for (const tag of p.tags) {
|
|
6841
|
+
if (tag === "" || /\s/.test(tag)) warn(`its tag ${JSON.stringify(tag)} is empty or has whitespace, which splits tags in Twee`);
|
|
6842
|
+
if (outMode === "twee1" && TWEE1_UNWRITABLE.test(tag)) warn(`its tag ${JSON.stringify(tag)} has "[", "]", "{", "}" or "\\", which Twee 1 cannot escape`);
|
|
6843
|
+
}
|
|
6844
|
+
const lines = p.text.split(/(\r\n?|\n)/);
|
|
6845
|
+
const headerLines = lines.flatMap((line, i) => i % 2 === 0 && line.startsWith("::") ? [i / 2 + 1] : []);
|
|
6846
|
+
if (headerLines.length === 0) return {
|
|
6847
|
+
passage: p,
|
|
6848
|
+
diagnostics
|
|
6849
|
+
};
|
|
6850
|
+
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`;
|
|
6851
|
+
if (!p.tags.some((tag) => tag === "stylesheet" || tag === "script")) {
|
|
6852
|
+
warn(where);
|
|
6853
|
+
return {
|
|
6854
|
+
passage: p,
|
|
6855
|
+
diagnostics
|
|
6856
|
+
};
|
|
5512
6857
|
}
|
|
5513
|
-
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
|
|
5517
|
-
}
|
|
5518
|
-
return
|
|
6858
|
+
diagnostics.push({
|
|
6859
|
+
level: "warning",
|
|
6860
|
+
message: `${label}: ${where}; ${headerLines.length === 1 ? "it was" : "they were"} written indented by one space.`
|
|
6861
|
+
});
|
|
6862
|
+
const text = lines.map((line, i) => i % 2 === 0 && line.startsWith("::") ? ` ${line}` : line).join("");
|
|
6863
|
+
return {
|
|
6864
|
+
passage: {
|
|
6865
|
+
...p,
|
|
6866
|
+
text
|
|
6867
|
+
},
|
|
6868
|
+
diagnostics
|
|
6869
|
+
};
|
|
5519
6870
|
}
|
|
5520
6871
|
//#endregion
|
|
5521
6872
|
//#region src/passage-omission.ts
|
|
@@ -5583,39 +6934,52 @@ async function compile(options) {
|
|
|
5583
6934
|
return buildOutput(options);
|
|
5584
6935
|
}
|
|
5585
6936
|
/**
|
|
5586
|
-
* Compile and write to a file.
|
|
6937
|
+
* Compile and write to a file. The file is replaced atomically: a reader sees the previous
|
|
6938
|
+
* build or the new one, never part of it, and a failed write leaves the previous build.
|
|
5587
6939
|
*/
|
|
5588
6940
|
async function compileToFile(options) {
|
|
5589
6941
|
const result = await compileForOutputFile(options, options.outFile);
|
|
5590
|
-
|
|
6942
|
+
writeFileAtomic(options.outFile, result.output);
|
|
5591
6943
|
return result;
|
|
5592
6944
|
}
|
|
5593
6945
|
/**
|
|
5594
|
-
* Builds as compileToFile() does for `
|
|
6946
|
+
* Builds as compileToFile() does for `outputs`, without writing them: an output may sit
|
|
5595
6947
|
* inside a source folder, so its last build is left out of the sources and modules and
|
|
5596
|
-
* never read back.
|
|
5597
|
-
* (
|
|
6948
|
+
* never read back. `outputs` is the one file the CLI writes, or every path a bundler's
|
|
6949
|
+
* build writes (see BuildOutputs). The caller decides whether the result is written.
|
|
6950
|
+
* With no outputs (output to stdout, or none), this is compile(). With `cache`, files
|
|
6951
|
+
* are cached as compileIncremental() caches them.
|
|
5598
6952
|
*
|
|
5599
|
-
*
|
|
6953
|
+
* Throws a TweeTsError when a source, module or head file named directly is an output.
|
|
6954
|
+
*
|
|
6955
|
+
* Internal, for the CLI and the bundler plugins; not part of the public API.
|
|
5600
6956
|
*/
|
|
5601
|
-
async function compileForOutputFile(options,
|
|
5602
|
-
return buildOutput(options,
|
|
6957
|
+
async function compileForOutputFile(options, outputs, cache) {
|
|
6958
|
+
return buildOutput(options, cache, void 0, outputs);
|
|
5603
6959
|
}
|
|
5604
6960
|
/**
|
|
5605
6961
|
* Compile with incremental caching support.
|
|
5606
6962
|
* Plugins and advanced users can manage their own cache and changed-file tracking.
|
|
5607
6963
|
*
|
|
5608
6964
|
* Without `changedFiles`, a cached file is reused while its modification time is unchanged.
|
|
5609
|
-
* With it, a file it names
|
|
5610
|
-
*
|
|
5611
|
-
*
|
|
6965
|
+
* With it, a file it names is always reparsed, whatever its modification time, and every
|
|
6966
|
+
* other cached file is reused as it is. A file may be named by an absolute path or by a path
|
|
6967
|
+
* relative to the working directory, with or without a leading `./`: entries are matched to
|
|
6968
|
+
* the source files by resolved path. A file that fails to load is dropped from the cache, so
|
|
6969
|
+
* the next build tries it again.
|
|
5612
6970
|
*/
|
|
5613
6971
|
async function compileIncremental(options, cache, changedFiles) {
|
|
5614
6972
|
return buildOutput(options, cache, changedFiles);
|
|
5615
6973
|
}
|
|
5616
6974
|
/**
|
|
5617
|
-
* Watch for file changes and recompile. Every build is written to `outFile
|
|
5618
|
-
* one whose diagnostics report errors.
|
|
6975
|
+
* Watch for file changes and recompile. Every build is written to `outFile` (replacing it
|
|
6976
|
+
* atomically, as compileToFile() does), including one whose diagnostics report errors.
|
|
6977
|
+
* Builds run one at a time, and each one is written and reported as it finishes, in order:
|
|
6978
|
+
* changes made during a build go into one follow-up build after it. A watched path that
|
|
6979
|
+
* can't be watched is passed to `onError`.
|
|
6980
|
+
*
|
|
6981
|
+
* Aborting the returned controller, or the `signal` in the options, stops watching and
|
|
6982
|
+
* aborts the story format requests of the build in progress.
|
|
5619
6983
|
*/
|
|
5620
6984
|
async function watch(options) {
|
|
5621
6985
|
return watchWithWriteFilter(options, () => true);
|
|
@@ -5627,9 +6991,13 @@ async function watch(options) {
|
|
|
5627
6991
|
* Internal, for the CLI, which keeps the last good output when a rebuild reports errors;
|
|
5628
6992
|
* not part of the public API.
|
|
5629
6993
|
*/
|
|
5630
|
-
async function watchWithWriteFilter(options, shouldWrite) {
|
|
6994
|
+
async function watchWithWriteFilter(options, shouldWrite, hooks = {}) {
|
|
5631
6995
|
const controller = new AbortController();
|
|
5632
6996
|
const cache = /* @__PURE__ */ new Map();
|
|
6997
|
+
const buildOptions = {
|
|
6998
|
+
...options,
|
|
6999
|
+
signal: controller.signal
|
|
7000
|
+
};
|
|
5633
7001
|
const filePaths = options.sources.filter((s) => typeof s === "string");
|
|
5634
7002
|
const modulePaths = options.modules ?? [];
|
|
5635
7003
|
const headPaths = options.headFile ? [options.headFile] : [];
|
|
@@ -5645,22 +7013,82 @@ async function watchWithWriteFilter(options, shouldWrite) {
|
|
|
5645
7013
|
const abs = resolve(filename);
|
|
5646
7014
|
return !notExcludable.some((root) => abs === root || abs.startsWith(root + sep));
|
|
5647
7015
|
};
|
|
7016
|
+
let building = false;
|
|
7017
|
+
let queued;
|
|
7018
|
+
const reportError = (error) => {
|
|
7019
|
+
try {
|
|
7020
|
+
options.onError?.(error);
|
|
7021
|
+
} catch (e) {
|
|
7022
|
+
console.error(`twee-ts watch: onError threw while handling "${error.message}": ${toError(e).message}`);
|
|
7023
|
+
}
|
|
7024
|
+
};
|
|
7025
|
+
const deliver = (outcome) => {
|
|
7026
|
+
try {
|
|
7027
|
+
if (!outcome.ok) throw outcome.error;
|
|
7028
|
+
if (shouldWrite(outcome.result)) writeFileAtomic(options.outFile, outcome.result.output);
|
|
7029
|
+
options.onBuild?.(outcome.result);
|
|
7030
|
+
} catch (e) {
|
|
7031
|
+
reportError(toError(e));
|
|
7032
|
+
}
|
|
7033
|
+
};
|
|
7034
|
+
const drain = async (first) => {
|
|
7035
|
+
building = true;
|
|
7036
|
+
try {
|
|
7037
|
+
let request = first;
|
|
7038
|
+
while (request !== void 0) {
|
|
7039
|
+
const outcome = await buildOutput(buildOptions, cache, request.changedFiles, options.outFile).then((result) => ({
|
|
7040
|
+
ok: true,
|
|
7041
|
+
result
|
|
7042
|
+
}), (e) => ({
|
|
7043
|
+
ok: false,
|
|
7044
|
+
error: toError(e)
|
|
7045
|
+
}));
|
|
7046
|
+
if (controller.signal.aborted) return;
|
|
7047
|
+
deliver(outcome);
|
|
7048
|
+
request = queued;
|
|
7049
|
+
queued = void 0;
|
|
7050
|
+
}
|
|
7051
|
+
} finally {
|
|
7052
|
+
building = false;
|
|
7053
|
+
hooks.onIdle?.();
|
|
7054
|
+
}
|
|
7055
|
+
};
|
|
5648
7056
|
const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
}
|
|
5655
|
-
|
|
5656
|
-
|
|
7057
|
+
if (controller.signal.aborted) return;
|
|
7058
|
+
const request = { changedFiles };
|
|
7059
|
+
if (building) {
|
|
7060
|
+
queued = queued === void 0 ? request : mergeBuildRequests(queued, request);
|
|
7061
|
+
return;
|
|
7062
|
+
}
|
|
7063
|
+
drain(request).catch((e) => reportError(toError(e)));
|
|
7064
|
+
}, ignore, reportError);
|
|
7065
|
+
const outer = options.signal;
|
|
7066
|
+
const onOuterAbort = () => controller.abort(outer?.reason);
|
|
7067
|
+
controller.signal.addEventListener("abort", () => {
|
|
7068
|
+
queued = void 0;
|
|
7069
|
+
handle.close();
|
|
7070
|
+
outer?.removeEventListener("abort", onOuterAbort);
|
|
7071
|
+
});
|
|
7072
|
+
if (outer?.aborted) onOuterAbort();
|
|
7073
|
+
else outer?.addEventListener("abort", onOuterAbort, { once: true });
|
|
5657
7074
|
return controller;
|
|
5658
7075
|
}
|
|
7076
|
+
/** One build covering both requests: a full build if either is one, else every changed file. */
|
|
7077
|
+
function mergeBuildRequests(a, b) {
|
|
7078
|
+
if (a.changedFiles === void 0 || b.changedFiles === void 0) return { changedFiles: void 0 };
|
|
7079
|
+
return { changedFiles: /* @__PURE__ */ new Set([...a.changedFiles, ...b.changedFiles]) };
|
|
7080
|
+
}
|
|
7081
|
+
function toError(e) {
|
|
7082
|
+
return e instanceof Error ? e : new Error(String(e));
|
|
7083
|
+
}
|
|
5659
7084
|
/**
|
|
5660
|
-
* `
|
|
5661
|
-
*
|
|
7085
|
+
* `outputs`: what the build writes (an output file's path, or a bundler's outputs),
|
|
7086
|
+
* which source and module discovery skip, so an output inside a source folder is
|
|
7087
|
+
* never loaded back. A source, module or head file named directly that is an output
|
|
7088
|
+
* is a TweeTsError, as in Tweego: writing the build would overwrite it.
|
|
5662
7089
|
*/
|
|
5663
|
-
async function buildOutput(options, cache, changedFiles,
|
|
7090
|
+
async function buildOutput(options, cache, changedFiles, outputs) {
|
|
7091
|
+
const written = toBuildOutputs(outputs);
|
|
5664
7092
|
const diagnostics = [];
|
|
5665
7093
|
const outputMode = options.outputMode ?? "html";
|
|
5666
7094
|
const trim = options.trim ?? true;
|
|
@@ -5668,13 +7096,18 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5668
7096
|
const testMode = options.testMode ?? false;
|
|
5669
7097
|
const noRemote = options.noRemote ?? false;
|
|
5670
7098
|
const sourceInfo = options.sourceInfo ?? false;
|
|
7099
|
+
options.signal?.throwIfAborted();
|
|
7100
|
+
const timeout = options.formatFetchTimeout;
|
|
7101
|
+
if (timeout !== void 0 && !(timeout >= 0)) throw new TweeTsError(`formatFetchTimeout must be 0 or more milliseconds, not ${timeout}.`);
|
|
5671
7102
|
clearIndexCache();
|
|
5672
7103
|
const filePaths = [];
|
|
5673
7104
|
const inlineSources = [];
|
|
5674
7105
|
for (const source of options.sources) if (typeof source === "string") filePaths.push(source);
|
|
5675
7106
|
else inlineSources.push(source);
|
|
5676
|
-
const
|
|
5677
|
-
diagnostics.push(...
|
|
7107
|
+
const sourcePaths = getFilenames(filePaths, written, options.exclude);
|
|
7108
|
+
diagnostics.push(...sourcePaths.diagnostics);
|
|
7109
|
+
rejectOutputSources(sourcePaths.outputSources, diagnostics);
|
|
7110
|
+
const sourceFilenames = sourcePaths.filenames;
|
|
5678
7111
|
const story = createStory();
|
|
5679
7112
|
const processedFiles = /* @__PURE__ */ new Set();
|
|
5680
7113
|
if (cache) loadSourcesCached(story, sourceFilenames, {
|
|
@@ -5703,7 +7136,10 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5703
7136
|
switch (outputMode) {
|
|
5704
7137
|
case "twee3":
|
|
5705
7138
|
case "twee1":
|
|
5706
|
-
output = toTwee(story, outputMode, {
|
|
7139
|
+
output = toTwee(story, outputMode, {
|
|
7140
|
+
addStoryData: Boolean(options.startPassage) || testMode,
|
|
7141
|
+
diagnostics
|
|
7142
|
+
});
|
|
5707
7143
|
break;
|
|
5708
7144
|
case "twine2-archive":
|
|
5709
7145
|
output = toTwine2Archive(story, startName, { sourceInfo });
|
|
@@ -5717,17 +7153,24 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5717
7153
|
case "html": {
|
|
5718
7154
|
diagnostics.push(...startPassageDiagnostics(story, startName, format?.isTwine2 === false ? "twine1" : "twine2"));
|
|
5719
7155
|
if (!format) throw new TweeTsError("No story format available for HTML output.", diagnostics);
|
|
5720
|
-
if (format.isTwine2
|
|
5721
|
-
|
|
5722
|
-
|
|
5723
|
-
|
|
5724
|
-
|
|
5725
|
-
});
|
|
5726
|
-
output = toTwine1HTML(story, format, startName);
|
|
5727
|
-
}
|
|
5728
|
-
const modules = getFilenames(options.modules ?? [], outFile);
|
|
7156
|
+
if (!format.isTwine2 && story.name === "" && !storyHas(story, "StoryTitle")) diagnostics.push({
|
|
7157
|
+
level: "error",
|
|
7158
|
+
message: "Special passage \"StoryTitle\" not found."
|
|
7159
|
+
});
|
|
7160
|
+
const modules = getFilenames(options.modules ?? [], written);
|
|
5729
7161
|
diagnostics.push(...modules.diagnostics);
|
|
5730
|
-
|
|
7162
|
+
const { headFile } = options;
|
|
7163
|
+
const headIsOutput = headFile !== void 0 && outputPaths(written).isFile(realPathOf(headFile));
|
|
7164
|
+
rejectOutputSources([...modules.outputSources, ...headIsOutput ? [headFile] : []], diagnostics);
|
|
7165
|
+
const head = loadHeadContent(modules.filenames, options.headFile, diagnostics);
|
|
7166
|
+
output = format.isTwine2 ? toTwine2HTML(story, format, startName, {
|
|
7167
|
+
sourceInfo,
|
|
7168
|
+
head,
|
|
7169
|
+
diagnostics
|
|
7170
|
+
}) : toTwine1HTML(story, format, startName, {
|
|
7171
|
+
head,
|
|
7172
|
+
diagnostics
|
|
7173
|
+
});
|
|
5731
7174
|
break;
|
|
5732
7175
|
}
|
|
5733
7176
|
default: throw new TweeTsError(`Unhandled output mode: ${outputMode}`, diagnostics);
|
|
@@ -5744,6 +7187,16 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5744
7187
|
stats
|
|
5745
7188
|
};
|
|
5746
7189
|
}
|
|
7190
|
+
/**
|
|
7191
|
+
* Throws for the first of `paths`, inputs named directly that are also an output:
|
|
7192
|
+
* writing the build would overwrite them. An output found while walking a source
|
|
7193
|
+
* folder is skipped instead (see getFilenames).
|
|
7194
|
+
*/
|
|
7195
|
+
function rejectOutputSources(paths, diagnostics) {
|
|
7196
|
+
const [first] = paths;
|
|
7197
|
+
if (first === void 0) return;
|
|
7198
|
+
throw new TweeTsError(`path ${first}: Output file cannot be an input source.`, [...diagnostics]);
|
|
7199
|
+
}
|
|
5747
7200
|
function ensureIFID(story, diagnostics) {
|
|
5748
7201
|
if (story.ifid !== "") return;
|
|
5749
7202
|
if (story.legacyIFID !== "") {
|
|
@@ -7228,6 +8681,16 @@ function passageLinks(p) {
|
|
|
7228
8681
|
return extractLinksFromText(textAsRead(p), false);
|
|
7229
8682
|
}
|
|
7230
8683
|
/**
|
|
8684
|
+
* Whether the `target` output keeps the links in `p`: always without a target, which describes
|
|
8685
|
+
* every source passage. With one, a passage the output leaves out never reaches the player, so it
|
|
8686
|
+
* links nowhere, except a script passage, which Twine 2 output leaves out as a passage but runs.
|
|
8687
|
+
*/
|
|
8688
|
+
function readsLinks(story, p, { target }) {
|
|
8689
|
+
if (target === void 0) return true;
|
|
8690
|
+
const omission = passageOmission(story, p, target);
|
|
8691
|
+
return omission === void 0 || omission.kind === "tag" && omission.tag === "script";
|
|
8692
|
+
}
|
|
8693
|
+
/**
|
|
7231
8694
|
* Each passage name, mapped to why the `target` output leaves that passage out, or to `undefined`
|
|
7232
8695
|
* when a link can reach it: always without a target, and otherwise when the output emits a
|
|
7233
8696
|
* passage of that name.
|
|
@@ -7285,7 +8748,9 @@ function joinLines(text) {
|
|
|
7285
8748
|
* By default, a link is broken only when no passage has its name. Pass a `target` to check links
|
|
7286
8749
|
* against what that output emits: `storyInspect(result.story, { target: 'twine2' })` also reports
|
|
7287
8750
|
* links to passages Twine 2 output leaves out, such as script, stylesheet and `Twine.private`
|
|
7288
|
-
* passages.
|
|
8751
|
+
* passages. With a target, passages that output leaves out also give no links (script passages
|
|
8752
|
+
* excepted, as Twine 2 output runs them), so they report no broken links and keep no passage from
|
|
8753
|
+
* being an orphan.
|
|
7289
8754
|
*/
|
|
7290
8755
|
function storyInspect(story, options = {}) {
|
|
7291
8756
|
const destinations = destinationOmissions(story, options);
|
|
@@ -7301,7 +8766,7 @@ function storyInspect(story, options = {}) {
|
|
|
7301
8766
|
if (isStoryPassage(p)) storyPassages.push(p.name);
|
|
7302
8767
|
if (isInfoPassage(p)) infoPassages.push(p.name);
|
|
7303
8768
|
tagsByPassage.set(p.name, [...p.tags]);
|
|
7304
|
-
for (const tag of p.tags) {
|
|
8769
|
+
for (const tag of new Set(p.tags)) {
|
|
7305
8770
|
tagSet.add(tag);
|
|
7306
8771
|
let list = passagesByTag.get(tag);
|
|
7307
8772
|
if (!list) {
|
|
@@ -7310,7 +8775,7 @@ function storyInspect(story, options = {}) {
|
|
|
7310
8775
|
}
|
|
7311
8776
|
list.push(p.name);
|
|
7312
8777
|
}
|
|
7313
|
-
links.set(p.name, passageLinks(p));
|
|
8778
|
+
links.set(p.name, readsLinks(story, p, options) ? passageLinks(p) : []);
|
|
7314
8779
|
}
|
|
7315
8780
|
const brokenLinks = [];
|
|
7316
8781
|
for (const [from, targets] of links) for (const to of targets) {
|
|
@@ -7359,7 +8824,8 @@ function storyInspect(story, options = {}) {
|
|
|
7359
8824
|
* Uses JSON output mode internally to avoid format resolution, so it checks
|
|
7360
8825
|
* the starting passage itself, against the Twine 2 passage rules. Link
|
|
7361
8826
|
* destinations are checked against the same rules: a link to a passage that
|
|
7362
|
-
* Twine 2 output leaves out is broken
|
|
8827
|
+
* Twine 2 output leaves out is broken, and a passage it leaves out (other than
|
|
8828
|
+
* a script passage) gives no links.
|
|
7363
8829
|
*/
|
|
7364
8830
|
async function lint(options) {
|
|
7365
8831
|
return lintForOutputFile(options, void 0);
|
|
@@ -7444,46 +8910,100 @@ const VALID_OUTPUT_MODES = [
|
|
|
7444
8910
|
"json"
|
|
7445
8911
|
];
|
|
7446
8912
|
const VALID_WORD_COUNT_METHODS = ["tweego", "whitespace"];
|
|
7447
|
-
/**
|
|
7448
|
-
|
|
8913
|
+
/** One entry per {@link TweeTsConfig} key: the compiler rejects a missing or an extra key here. */
|
|
8914
|
+
const CONFIG_KEY_SET = {
|
|
8915
|
+
sources: true,
|
|
8916
|
+
exclude: true,
|
|
8917
|
+
output: true,
|
|
8918
|
+
outputMode: true,
|
|
8919
|
+
formatId: true,
|
|
8920
|
+
startPassage: true,
|
|
8921
|
+
formatPaths: true,
|
|
8922
|
+
formatIndices: true,
|
|
8923
|
+
formatUrls: true,
|
|
8924
|
+
useTweegoPath: true,
|
|
8925
|
+
modules: true,
|
|
8926
|
+
headFile: true,
|
|
8927
|
+
trim: true,
|
|
8928
|
+
twee2Compat: true,
|
|
8929
|
+
testMode: true,
|
|
8930
|
+
noRemote: true,
|
|
8931
|
+
formatFetchTimeout: true,
|
|
8932
|
+
tagAliases: true,
|
|
8933
|
+
sourceInfo: true,
|
|
8934
|
+
wordCountMethod: true
|
|
8935
|
+
};
|
|
8936
|
+
/** The keys a config file may hold besides `$schema`; the JSON schema's `properties` list the same keys. */
|
|
8937
|
+
const CONFIG_KEYS = Object.keys(CONFIG_KEY_SET);
|
|
8938
|
+
/** The key that only references the JSON schema, for editors. */
|
|
8939
|
+
const SCHEMA_KEY = "$schema";
|
|
8940
|
+
/** A key folded so that spellings differing only in letter case, `-` or `_` compare equal. */
|
|
8941
|
+
const foldKey = (key) => key.toLowerCase().replace(/[-_]/g, "");
|
|
8942
|
+
/**
|
|
8943
|
+
* Load a config file from the given directory (default: cwd). Returns null if not found.
|
|
8944
|
+
*
|
|
8945
|
+
* @param diagnostics Receives warnings that do not stop the config from loading: keys the config does not
|
|
8946
|
+
* define, and a file that is not valid UTF-8 (it is read as Windows-1252).
|
|
8947
|
+
* @throws When the file is not valid JSON or fails {@link validateConfig}.
|
|
8948
|
+
*/
|
|
8949
|
+
function loadConfig(dir, diagnostics) {
|
|
7449
8950
|
const base = dir ?? process.cwd();
|
|
7450
8951
|
const configPath = join(base, CONFIG_FILENAME);
|
|
7451
8952
|
if (!existsSync(configPath)) return null;
|
|
8953
|
+
const readDiagnostics = [];
|
|
7452
8954
|
let raw;
|
|
7453
8955
|
try {
|
|
7454
|
-
raw =
|
|
8956
|
+
raw = readUTF8(configPath, readDiagnostics);
|
|
7455
8957
|
} catch {
|
|
7456
8958
|
return null;
|
|
7457
8959
|
}
|
|
7458
|
-
|
|
7459
|
-
|
|
7460
|
-
|
|
7461
|
-
} catch (e) {
|
|
7462
|
-
throw new Error(`Invalid JSON in ${configPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
7463
|
-
}
|
|
7464
|
-
const errors = validateConfig(data);
|
|
7465
|
-
if (errors.length > 0) throw new Error(`Invalid config in ${configPath}:\n ${errors.join("\n ")}`);
|
|
7466
|
-
return data;
|
|
8960
|
+
const config = parseConfig(raw, configPath, readDiagnostics);
|
|
8961
|
+
diagnostics?.push(...readDiagnostics);
|
|
8962
|
+
return config;
|
|
7467
8963
|
}
|
|
7468
|
-
/**
|
|
7469
|
-
|
|
8964
|
+
/**
|
|
8965
|
+
* Load a config from a specific file path.
|
|
8966
|
+
*
|
|
8967
|
+
* @param diagnostics Receives warnings that do not stop the config from loading: keys the config does not
|
|
8968
|
+
* define, and a file that is not valid UTF-8 (it is read as Windows-1252).
|
|
8969
|
+
* @throws When the file cannot be read, is not valid JSON or fails {@link validateConfig}.
|
|
8970
|
+
*/
|
|
8971
|
+
function loadConfigFile(filePath, diagnostics) {
|
|
8972
|
+
const readDiagnostics = [];
|
|
7470
8973
|
let raw;
|
|
7471
8974
|
try {
|
|
7472
|
-
raw =
|
|
8975
|
+
raw = readUTF8(filePath, readDiagnostics);
|
|
7473
8976
|
} catch (e) {
|
|
7474
8977
|
throw new Error(`Cannot read config file ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
7475
8978
|
}
|
|
8979
|
+
const config = parseConfig(raw, filePath, readDiagnostics);
|
|
8980
|
+
diagnostics?.push(...readDiagnostics);
|
|
8981
|
+
return config;
|
|
8982
|
+
}
|
|
8983
|
+
/**
|
|
8984
|
+
* Parse and validate the text of the config file at `path`. Unknown keys are added to `diagnostics` as
|
|
8985
|
+
* warnings, so a config with a stray key keeps loading.
|
|
8986
|
+
*/
|
|
8987
|
+
function parseConfig(raw, path, diagnostics) {
|
|
7476
8988
|
let data;
|
|
7477
8989
|
try {
|
|
7478
8990
|
data = JSON.parse(raw);
|
|
7479
8991
|
} catch (e) {
|
|
7480
|
-
throw new Error(`Invalid JSON in ${
|
|
8992
|
+
throw new Error(`Invalid JSON in ${path}: ${e instanceof Error ? e.message : String(e)}`);
|
|
7481
8993
|
}
|
|
7482
8994
|
const errors = validateConfig(data);
|
|
7483
|
-
if (errors.length > 0) throw new Error(`Invalid config in ${
|
|
8995
|
+
if (errors.length > 0) throw new Error(`Invalid config in ${path}:\n ${errors.join("\n ")}`);
|
|
8996
|
+
diagnostics.push(...unknownConfigKeyWarnings(data).map((message) => ({
|
|
8997
|
+
level: "warning",
|
|
8998
|
+
message: `${path}: ${message}`,
|
|
8999
|
+
file: path
|
|
9000
|
+
})));
|
|
7484
9001
|
return data;
|
|
7485
9002
|
}
|
|
7486
|
-
/**
|
|
9003
|
+
/**
|
|
9004
|
+
* Validate a config object. Returns an array of error strings (empty = valid).
|
|
9005
|
+
* Keys the config does not define are not errors; {@link unknownConfigKeyWarnings} reports them.
|
|
9006
|
+
*/
|
|
7487
9007
|
function validateConfig(data) {
|
|
7488
9008
|
const errors = [];
|
|
7489
9009
|
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
@@ -7516,6 +9036,10 @@ function validateConfig(data) {
|
|
|
7516
9036
|
if (!Array.isArray(obj[key])) errors.push(`"${key}" must be an array.`);
|
|
7517
9037
|
else if (!obj[key].every((v) => typeof v === "string")) errors.push(`"${key}" must be an array of strings.`);
|
|
7518
9038
|
}
|
|
9039
|
+
if ("formatFetchTimeout" in obj) {
|
|
9040
|
+
const timeout = obj["formatFetchTimeout"];
|
|
9041
|
+
if (typeof timeout !== "number" || !(timeout >= 0)) errors.push("\"formatFetchTimeout\" must be a number of milliseconds, 0 or more.");
|
|
9042
|
+
}
|
|
7519
9043
|
if ("tagAliases" in obj) {
|
|
7520
9044
|
if (typeof obj["tagAliases"] !== "object" || obj["tagAliases"] === null || Array.isArray(obj["tagAliases"])) errors.push("\"tagAliases\" must be an object.");
|
|
7521
9045
|
else {
|
|
@@ -7531,6 +9055,20 @@ function validateConfig(data) {
|
|
|
7531
9055
|
}
|
|
7532
9056
|
return errors;
|
|
7533
9057
|
}
|
|
9058
|
+
/**
|
|
9059
|
+
* Warnings for the keys of a config object that the config does not define (the JSON schema's
|
|
9060
|
+
* `additionalProperties: false`), other than `$schema`. twee-ts ignores such a key, so a misspelt one would
|
|
9061
|
+
* otherwise leave its option at the default without a word. A key that differs from a defined one only in
|
|
9062
|
+
* letter case, `-` or `_` (`formatID`, `output-mode`) gets a suggestion. Returns `[]` for anything that is not
|
|
9063
|
+
* an object, which {@link validateConfig} reports.
|
|
9064
|
+
*/
|
|
9065
|
+
function unknownConfigKeyWarnings(data) {
|
|
9066
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) return [];
|
|
9067
|
+
return Object.keys(data).filter((key) => key !== SCHEMA_KEY && !Object.hasOwn(CONFIG_KEY_SET, key)).map((key) => {
|
|
9068
|
+
const suggestion = CONFIG_KEYS.find((known) => foldKey(known) === foldKey(key));
|
|
9069
|
+
return `Unknown config key "${key}"${suggestion === void 0 ? "" : ` (did you mean "${suggestion}"?)`}; it is ignored.`;
|
|
9070
|
+
});
|
|
9071
|
+
}
|
|
7534
9072
|
/** Return a default config JSON string for --init scaffolding. */
|
|
7535
9073
|
function scaffoldConfig() {
|
|
7536
9074
|
return JSON.stringify({
|
|
@@ -7540,6 +9078,6 @@ function scaffoldConfig() {
|
|
|
7540
9078
|
}, null, 2) + "\n";
|
|
7541
9079
|
}
|
|
7542
9080
|
//#endregion
|
|
7543
|
-
export { CONFIG_FILENAME, ItemType, StoryBuilder, TweeLexer, TweeTsError, applyTagAliases, clearCachedFormats, compile, compileIncremental, compileToFile, createIFID, decompileHTML, discoverCachedFormats, discoverFormats, fetchAndCacheFormat, fetchDirectFormat, formatLintReport, generateIFID, getCacheDir, getCacheSize, getFormatSearchDirs, lint, listCachedFormats, loadConfig, loadConfigFile, parseFormatJSON, parseSemver, parseTwee, resolveRemoteFormat, scaffoldConfig, semverCompare, storyInspect, tweeLexer, validateConfig, validateIFID, watch };
|
|
9081
|
+
export { CONFIG_FILENAME, ItemType, StoryBuilder, TweeLexer, TweeTsError, applyTagAliases, clearCachedFormats, compile, compileIncremental, compileToFile, createIFID, decompileHTML, discoverCachedFormats, discoverFormats, fetchAndCacheFormat, fetchDirectFormat, formatLintReport, generateIFID, getCacheDir, getCacheSize, getFormatSearchDirs, lint, listCachedFormats, loadConfig, loadConfigFile, parseFormatJSON, parseSemver, parseTwee, resolveRemoteFormat, scaffoldConfig, semverCompare, storyInspect, tweeLexer, unknownConfigKeyWarnings, validateConfig, validateIFID, watch };
|
|
7544
9082
|
|
|
7545
9083
|
//# sourceMappingURL=index.js.map
|