@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
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import * as nodePath from "node:path";
|
|
3
3
|
import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
4
|
-
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
//#region src/ifid.ts
|
|
@@ -13,6 +13,19 @@ import { fileURLToPath } from "node:url";
|
|
|
13
13
|
function generateIFID() {
|
|
14
14
|
return randomUUID().toUpperCase();
|
|
15
15
|
}
|
|
16
|
+
/** Length of the `UUID://...//` wrapper around a 36-character UUID. */
|
|
17
|
+
const WRAPPED_LENGTH = 45;
|
|
18
|
+
/**
|
|
19
|
+
* Bring an IFID into the form the story model stores and every output writes: the uppercase
|
|
20
|
+
* bare UUID. A valid `UUID://...//` wrapped IFID loses its wrapper; outputs that need the wrapper
|
|
21
|
+
* (the Treaty of Babel comment) add exactly one. Twine 2 IFIDs use only capital letters, so the
|
|
22
|
+
* value is uppercased, as Tweego does. An invalid value is only uppercased, so that validation
|
|
23
|
+
* reports it as written.
|
|
24
|
+
*/
|
|
25
|
+
function normalizeIFID(value) {
|
|
26
|
+
const upper = value.toUpperCase();
|
|
27
|
+
return upper.length === WRAPPED_LENGTH && validateIFID(upper) === null ? upper.slice(7, 43) : upper;
|
|
28
|
+
}
|
|
16
29
|
/**
|
|
17
30
|
* Validate an IFID string.
|
|
18
31
|
* Accepts both bare UUIDs and UUID://...// wrapped format.
|
|
@@ -91,18 +104,44 @@ function tweeEscape(s) {
|
|
|
91
104
|
if (s.length === 0) return s;
|
|
92
105
|
return s.replace(/[\\[\]{}]/g, (ch) => "\\" + ch);
|
|
93
106
|
}
|
|
94
|
-
|
|
107
|
+
const JS_STRING_ESCAPES = {
|
|
108
|
+
"\\": "\\\\",
|
|
109
|
+
"\"": "\\\"",
|
|
110
|
+
"'": "\\'",
|
|
111
|
+
"\n": "\\n",
|
|
112
|
+
"\r": "\\r",
|
|
113
|
+
" ": "\\t",
|
|
114
|
+
"<": "\\x3C",
|
|
115
|
+
"\u2028": "\\u2028",
|
|
116
|
+
"\u2029": "\\u2029"
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Escape a string for the inside of a single- or double-quoted JavaScript string literal, including one written
|
|
120
|
+
* into an HTML `<script>` element. Besides backslashes, quotes and line breaks (with U+2028 and U+2029, which end
|
|
121
|
+
* a string literal in JavaScript before ES2019), every `<` becomes `\x3C`, so the literal holds no `<\/script`
|
|
122
|
+
* that would end the element and no `<!--` or `<script` that would change where it ends. The string's value is
|
|
123
|
+
* unchanged.
|
|
124
|
+
*/
|
|
95
125
|
function jsStringEscape(s) {
|
|
96
126
|
if (s.length === 0) return s;
|
|
97
|
-
return s.replace(
|
|
127
|
+
return s.replace(/[\\"'\n\r\t<\u2028\u2029]/g, (ch) => JS_STRING_ESCAPES[ch] ?? ch);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Escape a string for the inside of a double-quoted CSS string (such as a `font-family` name): `\` and `"` get
|
|
131
|
+
* a backslash, and line breaks and other control characters become hex escapes followed by a space (`\a ` for a
|
|
132
|
+
* line feed), since a raw line break ends a CSS string and a backslash before one is a line continuation.
|
|
133
|
+
*/
|
|
134
|
+
function cssStringEscape(s) {
|
|
135
|
+
if (s.length === 0) return s;
|
|
136
|
+
return s.replace(/[\\"\u0000-\u001f\u007f]/g, (ch) => ch === "\\" || ch === "\"" ? `\\${ch}` : `\\${ch.charCodeAt(0).toString(16)} `);
|
|
98
137
|
}
|
|
99
138
|
/** Sanitize a string for safe inclusion in a CSS or JS block comment. */
|
|
100
139
|
function commentSanitize(s) {
|
|
101
140
|
return s.replace(/\*\//g, "* /");
|
|
102
141
|
}
|
|
103
|
-
/** Sanitize a string for safe inclusion in an HTML comment. */
|
|
142
|
+
/** Sanitize a string for safe inclusion in an HTML comment: break up `-->` and `--!>`, which both end one. */
|
|
104
143
|
function htmlCommentSanitize(s) {
|
|
105
|
-
return s.replace(
|
|
144
|
+
return s.replace(/--(!?)>/g, "--$1 >");
|
|
106
145
|
}
|
|
107
146
|
/**
|
|
108
147
|
* Escape JavaScript for the content of an HTML `<script>` element, so that the HTML parser reads all of it as
|
|
@@ -279,7 +318,19 @@ function passageToPassagedata(p, pid, options) {
|
|
|
279
318
|
if (options?.sourceInfo && p.source) attrs += ` data-source-file=${quote(attrEscape(p.source.file))} data-source-line="${p.source.line}"`;
|
|
280
319
|
return `${attrs}>${htmlEscape(p.text)}</tw-passagedata>`;
|
|
281
320
|
}
|
|
282
|
-
/**
|
|
321
|
+
/**
|
|
322
|
+
* Whether Twine 1 `obfuscate:rot13` encodes a tiddler: every one but `StorySettings` and those tagged `Twine.image`
|
|
323
|
+
* (Twine 1.4 `Tiddler.isObfuscateable()`). Its engine tests the stored name and tags, which these tiddlers keep
|
|
324
|
+
* unencoded, so the decompiler tests them the same way.
|
|
325
|
+
*/
|
|
326
|
+
function isObfuscatable(p) {
|
|
327
|
+
return p.name !== "StorySettings" && !p.tags.includes("Twine.image");
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Generate `<div tiddler>` HTML for Twine 1. With `obfuscateRot13`, an obfuscatable tiddler (see
|
|
331
|
+
* `isObfuscatable()`) has its name, each tag and its text ROT13-encoded, as Twine 1.4 writes it
|
|
332
|
+
* (`Tiddler.toHtml()`), and as its engine.js decodes it.
|
|
333
|
+
*/
|
|
283
334
|
function passageToTiddler(p, pid, obfuscateRot13 = false) {
|
|
284
335
|
let position;
|
|
285
336
|
if (hasMetadataPosition(p)) position = p.metadata?.position ?? "";
|
|
@@ -291,9 +342,10 @@ function passageToTiddler(p, pid, obfuscateRot13 = false) {
|
|
|
291
342
|
position = `${xp * 140 - 130},${yp * 140 - 130}`;
|
|
292
343
|
}
|
|
293
344
|
const created = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 12);
|
|
294
|
-
const
|
|
295
|
-
const
|
|
296
|
-
|
|
345
|
+
const encode = obfuscateRot13 && isObfuscatable(p) ? rot13 : (s) => s;
|
|
346
|
+
const name = attrEscape(encode(p.name));
|
|
347
|
+
const tags = attrEscape(p.tags.map(encode).join(" "));
|
|
348
|
+
return `<div tiddler=${quote(name)} tags=${quote(tags)} created=${quote(created)} modifier=${quote("twee")} twine-position=${quote(attrEscape(position))}>${tiddlerEscape(encode(p.text))}</div>`;
|
|
297
349
|
}
|
|
298
350
|
/**
|
|
299
351
|
* Count words in a passage.
|
|
@@ -346,19 +398,128 @@ function quote(s) {
|
|
|
346
398
|
}
|
|
347
399
|
//#endregion
|
|
348
400
|
//#region src/story.ts
|
|
349
|
-
/** Module-level index: maps passage names to their array indices for O(1) lookups. */
|
|
350
401
|
const passageIndex = /* @__PURE__ */ new WeakMap();
|
|
351
|
-
function
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
402
|
+
function buildIndex(story) {
|
|
403
|
+
const names = story.passages.map((p) => p.name);
|
|
404
|
+
const index = {
|
|
405
|
+
passages: story.passages,
|
|
406
|
+
names,
|
|
407
|
+
positions: new Map(names.map((name, i) => [name, i]))
|
|
408
|
+
};
|
|
409
|
+
passageIndex.set(story, index);
|
|
410
|
+
return index;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* The index, rebuilt if `story.passages` was reassigned or changed length. O(1); `position()`
|
|
414
|
+
* also checks every hit. A change that keeps the length, such as replacing an element, can still
|
|
415
|
+
* hide a passage from a lookup, so `storyVerifyIndex()` checks every position.
|
|
416
|
+
*/
|
|
417
|
+
function currentIndex(story) {
|
|
418
|
+
const index = passageIndex.get(story);
|
|
419
|
+
return index !== void 0 && index.passages === story.passages && index.names.length === story.passages.length ? index : buildIndex(story);
|
|
420
|
+
}
|
|
421
|
+
/** The position of the passage named `name`, or -1. */
|
|
422
|
+
function position(story, name) {
|
|
423
|
+
const i = currentIndex(story).positions.get(name);
|
|
424
|
+
if (i === void 0) return -1;
|
|
425
|
+
if (story.passages[i]?.name === name) return i;
|
|
426
|
+
return buildIndex(story).positions.get(name) ?? -1;
|
|
427
|
+
}
|
|
428
|
+
/** Add a passage whose name no passage has yet at the end. */
|
|
429
|
+
function push(story, p) {
|
|
430
|
+
const index = currentIndex(story);
|
|
431
|
+
index.positions.set(p.name, story.passages.length);
|
|
432
|
+
index.names.push(p.name);
|
|
433
|
+
story.passages.push(p);
|
|
434
|
+
}
|
|
435
|
+
/** Put a passage at position `i`, in place of the passage there. */
|
|
436
|
+
function replaceAt(story, i, p) {
|
|
437
|
+
const renamed = story.passages[i]?.name !== p.name;
|
|
438
|
+
story.passages[i] = p;
|
|
439
|
+
if (renamed) buildIndex(story);
|
|
440
|
+
}
|
|
441
|
+
/** Names of compiler special passages, which storyAdd() reads into the story model. */
|
|
442
|
+
const SPECIAL_PASSAGE_NAMES = /* @__PURE__ */ new Set([
|
|
443
|
+
"StoryData",
|
|
444
|
+
"StoryIncludes",
|
|
445
|
+
"StorySettings",
|
|
446
|
+
"StoryTitle"
|
|
447
|
+
]);
|
|
448
|
+
/** Passages whose names were generated, kept by identity so that cached passages keep the mark. */
|
|
449
|
+
const generatedNames = /* @__PURE__ */ new WeakMap();
|
|
450
|
+
/**
|
|
451
|
+
* Mark a passage's name as generated (from a file name, or for imported story code) and return
|
|
452
|
+
* the passage. `origin.base` is the name that free names are numbered from: `base`, `base 2`, ….
|
|
453
|
+
*/
|
|
454
|
+
function withGeneratedName(p, origin) {
|
|
455
|
+
generatedNames.set(p, origin);
|
|
456
|
+
return p;
|
|
457
|
+
}
|
|
458
|
+
/** The first of `base`, `base 2`, `base 3`, … that `isFree` accepts. */
|
|
459
|
+
function freeName(base, isFree) {
|
|
460
|
+
if (isFree(base)) return base;
|
|
461
|
+
for (let n = 2;; n++) {
|
|
462
|
+
const candidate = `${base} ${n}`;
|
|
463
|
+
if (isFree(candidate)) return candidate;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
/** Whether a generated name may take `name`: no passage has it and it is no special name. */
|
|
467
|
+
function isFreeForGenerated(story, name) {
|
|
468
|
+
return !SPECIAL_PASSAGE_NAMES.has(name) && position(story, name) === -1;
|
|
469
|
+
}
|
|
470
|
+
function reportRename(origin, from, to, diagnostics) {
|
|
471
|
+
switch (origin.kind) {
|
|
472
|
+
case "code": return;
|
|
473
|
+
case "media": {
|
|
474
|
+
const reason = SPECIAL_PASSAGE_NAMES.has(from) ? `"${from}" is a compiler special passage name` : `another passage has the name "${from}"`;
|
|
475
|
+
diagnostics.push({
|
|
476
|
+
level: "warning",
|
|
477
|
+
message: `Passage "${from}" from "${origin.file}" renamed to "${to}"; ${reason}.`
|
|
478
|
+
});
|
|
479
|
+
return;
|
|
358
480
|
}
|
|
359
|
-
|
|
481
|
+
default: throw new Error(`unhandled generated name: ${JSON.stringify(origin)}`);
|
|
360
482
|
}
|
|
361
|
-
|
|
483
|
+
}
|
|
484
|
+
/** Add a passage with a generated name, under the first free name if its own is taken. */
|
|
485
|
+
function addGenerated(story, p, origin, diagnostics) {
|
|
486
|
+
if (isFreeForGenerated(story, p.name)) {
|
|
487
|
+
push(story, p);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const name = freeName(origin.base, (n) => isFreeForGenerated(story, n));
|
|
491
|
+
reportRename(origin, p.name, name, diagnostics);
|
|
492
|
+
push(story, withGeneratedName({
|
|
493
|
+
...p,
|
|
494
|
+
name
|
|
495
|
+
}, origin));
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* If a passage with a generated name has `name`, give it the first free name, in its place in the
|
|
499
|
+
* passage list, so that a passage from the sources can take the name.
|
|
500
|
+
*/
|
|
501
|
+
function moveGeneratedAside(story, name, diagnostics) {
|
|
502
|
+
const i = position(story, name);
|
|
503
|
+
const existing = i === -1 ? void 0 : story.passages[i];
|
|
504
|
+
const origin = existing === void 0 ? void 0 : generatedNames.get(existing);
|
|
505
|
+
if (existing === void 0 || origin === void 0) return;
|
|
506
|
+
const free = freeName(origin.base, (n) => isFreeForGenerated(story, n));
|
|
507
|
+
reportRename(origin, name, free, diagnostics);
|
|
508
|
+
replaceAt(story, i, withGeneratedName({
|
|
509
|
+
...existing,
|
|
510
|
+
name: free
|
|
511
|
+
}, origin));
|
|
512
|
+
}
|
|
513
|
+
function defaultTwine2Metadata() {
|
|
514
|
+
return {
|
|
515
|
+
format: "",
|
|
516
|
+
formatVersion: "",
|
|
517
|
+
options: /* @__PURE__ */ new Map(),
|
|
518
|
+
start: "",
|
|
519
|
+
tags: "",
|
|
520
|
+
tagColors: /* @__PURE__ */ new Map(),
|
|
521
|
+
zoom: 1
|
|
522
|
+
};
|
|
362
523
|
}
|
|
363
524
|
function createStory() {
|
|
364
525
|
const story = {
|
|
@@ -367,52 +528,37 @@ function createStory() {
|
|
|
367
528
|
passages: [],
|
|
368
529
|
legacyIFID: "",
|
|
369
530
|
twine1: { settings: /* @__PURE__ */ new Map() },
|
|
370
|
-
twine2:
|
|
371
|
-
format: "",
|
|
372
|
-
formatVersion: "",
|
|
373
|
-
options: /* @__PURE__ */ new Map(),
|
|
374
|
-
start: "",
|
|
375
|
-
tags: "",
|
|
376
|
-
tagColors: /* @__PURE__ */ new Map(),
|
|
377
|
-
zoom: 1
|
|
378
|
-
}
|
|
531
|
+
twine2: defaultTwine2Metadata()
|
|
379
532
|
};
|
|
380
|
-
|
|
533
|
+
buildIndex(story);
|
|
381
534
|
return story;
|
|
382
535
|
}
|
|
383
536
|
function storyHas(story, name) {
|
|
384
|
-
return
|
|
537
|
+
return position(story, name) !== -1;
|
|
385
538
|
}
|
|
386
539
|
function storyAppend(story, p, diagnostics) {
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
index.set(p.name, story.passages.length);
|
|
391
|
-
story.passages.push(p);
|
|
392
|
-
} else {
|
|
540
|
+
const i = position(story, p.name);
|
|
541
|
+
if (i === -1) push(story, p);
|
|
542
|
+
else {
|
|
393
543
|
diagnostics.push({
|
|
394
544
|
level: "warning",
|
|
395
545
|
message: `Replacing existing passage "${p.name}" with duplicate.`
|
|
396
546
|
});
|
|
397
|
-
story
|
|
547
|
+
replaceAt(story, i, p);
|
|
398
548
|
}
|
|
399
549
|
}
|
|
400
550
|
function storyPrepend(story, p, diagnostics) {
|
|
401
|
-
|
|
402
|
-
const i =
|
|
551
|
+
moveGeneratedAside(story, p.name, diagnostics);
|
|
552
|
+
const i = position(story, p.name);
|
|
403
553
|
if (i === -1) {
|
|
404
554
|
story.passages.unshift(p);
|
|
405
|
-
|
|
406
|
-
for (let j = 0; j < story.passages.length; j++) {
|
|
407
|
-
const passage = story.passages[j];
|
|
408
|
-
if (passage) index.set(passage.name, j);
|
|
409
|
-
}
|
|
555
|
+
buildIndex(story);
|
|
410
556
|
} else {
|
|
411
557
|
diagnostics.push({
|
|
412
558
|
level: "warning",
|
|
413
559
|
message: `Replacing existing passage "${p.name}" with duplicate.`
|
|
414
560
|
});
|
|
415
|
-
story
|
|
561
|
+
replaceAt(story, i, p);
|
|
416
562
|
}
|
|
417
563
|
}
|
|
418
564
|
function marshalStoryData(story) {
|
|
@@ -438,18 +584,20 @@ function unmarshalStoryData(story, json) {
|
|
|
438
584
|
}
|
|
439
585
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return "Cannot unmarshal \"StoryData\"; expected a JSON object";
|
|
440
586
|
const data = raw;
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
if (typeof data
|
|
587
|
+
const twine2 = defaultTwine2Metadata();
|
|
588
|
+
story.ifid = typeof data.ifid === "string" ? normalizeIFID(data.ifid) : "";
|
|
589
|
+
if (typeof data.format === "string") twine2.format = data.format;
|
|
590
|
+
if (typeof data["format-version"] === "string") twine2.formatVersion = data["format-version"];
|
|
444
591
|
if (Array.isArray(data.options)) {
|
|
445
|
-
for (const opt of data.options) if (typeof opt === "string")
|
|
592
|
+
for (const opt of data.options) if (typeof opt === "string") twine2.options.set(opt, true);
|
|
446
593
|
}
|
|
447
|
-
if (typeof data.start === "string"
|
|
448
|
-
if (typeof data.tags === "string")
|
|
594
|
+
if (typeof data.start === "string") twine2.start = data.start;
|
|
595
|
+
if (typeof data.tags === "string") twine2.tags = data.tags;
|
|
449
596
|
if (typeof data["tag-colors"] === "object" && data["tag-colors"] !== null && !Array.isArray(data["tag-colors"])) {
|
|
450
|
-
for (const [tag, color] of Object.entries(data["tag-colors"])) if (typeof color === "string")
|
|
597
|
+
for (const [tag, color] of Object.entries(data["tag-colors"])) if (typeof color === "string") twine2.tagColors.set(tag, color);
|
|
451
598
|
}
|
|
452
|
-
if (typeof data.zoom === "number" && data.zoom !== 0)
|
|
599
|
+
if (typeof data.zoom === "number" && data.zoom !== 0) twine2.zoom = data.zoom;
|
|
600
|
+
story.twine2 = twine2;
|
|
453
601
|
return null;
|
|
454
602
|
}
|
|
455
603
|
function unmarshalStorySettings(story, text, diagnostics) {
|
|
@@ -469,7 +617,7 @@ function unmarshalStorySettings(story, text, diagnostics) {
|
|
|
469
617
|
const val = line.slice(colonIdx + 1).trim().toLowerCase();
|
|
470
618
|
switch (key) {
|
|
471
619
|
case "ifid":
|
|
472
|
-
if (validateIFID(val) === null) story.legacyIFID = val
|
|
620
|
+
if (validateIFID(val) === null) story.legacyIFID = normalizeIFID(val);
|
|
473
621
|
obsolete.push("\"ifid\"");
|
|
474
622
|
continue;
|
|
475
623
|
case "zoom":
|
|
@@ -490,8 +638,20 @@ function unmarshalStorySettings(story, text, diagnostics) {
|
|
|
490
638
|
/**
|
|
491
639
|
* Process a passage and add it to the story, handling special passages.
|
|
492
640
|
* Creates new passage objects where text is modified rather than mutating the input.
|
|
641
|
+
*
|
|
642
|
+
* A passage replaces an earlier one with the same name, except where either name was generated
|
|
643
|
+
* (see `withGeneratedName()`): a generated name never replaces a passage and is never replaced.
|
|
644
|
+
* A passage with a generated name takes the first free name of `base`, `base 2`, … when its own
|
|
645
|
+
* is taken or is a compiler special name. A passage with a name from the sources that a generated
|
|
646
|
+
* name holds takes it over, and the generated one moves to the first free name in its place.
|
|
493
647
|
*/
|
|
494
648
|
function storyAdd(story, p, diagnostics) {
|
|
649
|
+
const origin = generatedNames.get(p);
|
|
650
|
+
if (origin !== void 0) {
|
|
651
|
+
addGenerated(story, p, origin, diagnostics);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
moveGeneratedAside(story, p.name, diagnostics);
|
|
495
655
|
let processed = p;
|
|
496
656
|
switch (p.name) {
|
|
497
657
|
case "StoryIncludes":
|
|
@@ -625,53 +785,413 @@ function isExcluded(filename, exclude) {
|
|
|
625
785
|
const rel = relative(process.cwd(), resolve(filename)) || filename;
|
|
626
786
|
return exclude.some((pattern) => matchesGlob(rel, pattern.replace(/^\.\//, "")));
|
|
627
787
|
}
|
|
788
|
+
/** A build that writes nothing: `compile()`, or output to stdout. */
|
|
789
|
+
const NO_BUILD_OUTPUTS = {
|
|
790
|
+
files: [],
|
|
791
|
+
dirs: []
|
|
792
|
+
};
|
|
793
|
+
/** The outputs of a build that writes the one file `outFile`, or none without it. */
|
|
794
|
+
function toBuildOutputs(outputs) {
|
|
795
|
+
if (outputs === void 0) return NO_BUILD_OUTPUTS;
|
|
796
|
+
return typeof outputs === "string" ? {
|
|
797
|
+
files: [outputs],
|
|
798
|
+
dirs: []
|
|
799
|
+
} : outputs;
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* The path outputs are compared by: absolute, with every symbolic link in it
|
|
803
|
+
* resolved, so two paths that reach the same file through different links (a
|
|
804
|
+
* symlinked project folder, an alias of the output folder) compare equal. A
|
|
805
|
+
* path that doesn't exist yet, such as the output before the first build, is
|
|
806
|
+
* its nearest existing folder's real path joined with the rest.
|
|
807
|
+
*/
|
|
808
|
+
function realPathOf(path) {
|
|
809
|
+
const abs = resolve(path);
|
|
810
|
+
try {
|
|
811
|
+
return realpathSync.native(abs);
|
|
812
|
+
} catch {}
|
|
813
|
+
const parent = dirname(abs);
|
|
814
|
+
return parent === abs ? abs : join(realPathOf(parent), basename(abs));
|
|
815
|
+
}
|
|
816
|
+
/** Whether the path `inner` is `outer` or inside it. */
|
|
817
|
+
function isSameOrInside(inner, outer) {
|
|
818
|
+
return inner === outer || inner.startsWith(outer.endsWith(sep) ? outer : outer + sep);
|
|
819
|
+
}
|
|
820
|
+
function outputPaths(outputs) {
|
|
821
|
+
const files = new Set(outputs.files.map(realPathOf));
|
|
822
|
+
const dirs = new Set(outputs.dirs.map(realPathOf));
|
|
823
|
+
const all = [...files, ...dirs];
|
|
824
|
+
return {
|
|
825
|
+
isFile: (real) => files.has(real),
|
|
826
|
+
isDir: (real) => dirs.has(real),
|
|
827
|
+
holds: (real) => dirs.has(real) || all.some((output) => output !== real && isSameOrInside(output, real)),
|
|
828
|
+
isOutput(path, roots) {
|
|
829
|
+
const real = realPathOf(path);
|
|
830
|
+
if (files.has(real)) return true;
|
|
831
|
+
const owners = [...dirs].filter((dir) => isSameOrInside(real, dir));
|
|
832
|
+
if (owners.length === 0) return false;
|
|
833
|
+
return roots.map(realPathOf).filter((root) => isSameOrInside(real, root)).every((root) => owners.some((dir) => dir !== root && isSameOrInside(dir, root)));
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* A folder entry as source discovery walks it, found at `pathname` with the real
|
|
839
|
+
* path `real` when it is no link: a link to a file counts as that file, and a
|
|
840
|
+
* link to anything else (a folder above all) as nothing, undefined. Throws when
|
|
841
|
+
* the entry or a link's target can't be read.
|
|
842
|
+
*/
|
|
843
|
+
function walkedEntry(pathname, real) {
|
|
844
|
+
const stat = lstatSync(pathname);
|
|
845
|
+
if (!stat.isSymbolicLink()) return {
|
|
846
|
+
stat,
|
|
847
|
+
real
|
|
848
|
+
};
|
|
849
|
+
const target = statSync(pathname);
|
|
850
|
+
return target.isFile() ? {
|
|
851
|
+
stat: target,
|
|
852
|
+
real: realPathOf(pathname)
|
|
853
|
+
} : void 0;
|
|
854
|
+
}
|
|
628
855
|
/**
|
|
629
856
|
* Recursively walk directories, collecting regular file paths.
|
|
630
|
-
*
|
|
631
|
-
*
|
|
857
|
+
*
|
|
858
|
+
* Leaves out what the build writes (`outputs`, compared by real path, see
|
|
859
|
+
* realPathOf) so its last output is never read back, and the files that match an
|
|
860
|
+
* `exclude` glob (see isExcluded). A path given that is itself an output file is
|
|
861
|
+
* not walked but listed in `outputSources`.
|
|
862
|
+
*
|
|
863
|
+
* Symbolic links: a path given is followed wherever it leads. Inside a folder, a
|
|
864
|
+
* link to a file is read, but a link to a folder is not followed, as Tweego's walk
|
|
865
|
+
* doesn't follow one. A link back to its own folder or a parent would otherwise
|
|
866
|
+
* walk the same files again at every depth.
|
|
867
|
+
*
|
|
632
868
|
* Like Tweego, a path that cannot be read is reported as a warning and skipped.
|
|
633
869
|
*/
|
|
634
|
-
function getFilenames(pathnames,
|
|
870
|
+
function getFilenames(pathnames, outputs, exclude = []) {
|
|
635
871
|
const filenames = [];
|
|
636
872
|
const diagnostics = [];
|
|
637
|
-
const
|
|
873
|
+
const outputSources = [];
|
|
874
|
+
const output = outputPaths(toBuildOutputs(outputs));
|
|
638
875
|
function warn(pathname, e) {
|
|
639
876
|
diagnostics.push({
|
|
640
877
|
level: "warning",
|
|
641
878
|
message: `path ${pathname}: ${e instanceof Error ? e.message : String(e)}`
|
|
642
879
|
});
|
|
643
880
|
}
|
|
644
|
-
function
|
|
881
|
+
function addFile(pathname) {
|
|
882
|
+
const abs = resolve(pathname);
|
|
883
|
+
const rel = relative(process.cwd(), abs);
|
|
884
|
+
if (isExcluded(rel || abs, exclude)) return;
|
|
885
|
+
filenames.push(rel || abs);
|
|
886
|
+
}
|
|
887
|
+
function walkDir(pathname, real) {
|
|
888
|
+
let entries;
|
|
889
|
+
try {
|
|
890
|
+
entries = readdirSync(pathname);
|
|
891
|
+
} catch (e) {
|
|
892
|
+
warn(pathname, e);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
for (const entry of entries) walkEntry(join(pathname, entry), join(real, entry));
|
|
896
|
+
}
|
|
897
|
+
function walkEntry(pathname, real) {
|
|
898
|
+
let entry;
|
|
899
|
+
try {
|
|
900
|
+
entry = walkedEntry(pathname, real);
|
|
901
|
+
} catch (e) {
|
|
902
|
+
warn(pathname, e);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
if (entry === void 0) return;
|
|
906
|
+
if (entry.stat.isFile()) {
|
|
907
|
+
if (!output.isFile(entry.real)) addFile(pathname);
|
|
908
|
+
} else if (entry.stat.isDirectory() && !output.isDir(entry.real)) walkDir(pathname, entry.real);
|
|
909
|
+
}
|
|
910
|
+
for (const pathname of pathnames) {
|
|
645
911
|
let stat;
|
|
646
912
|
try {
|
|
647
913
|
stat = statSync(pathname);
|
|
648
914
|
} catch (e) {
|
|
649
915
|
warn(pathname, e);
|
|
650
|
-
|
|
916
|
+
continue;
|
|
651
917
|
}
|
|
918
|
+
const real = realPathOf(pathname);
|
|
652
919
|
if (stat.isFile()) {
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
920
|
+
if (output.isFile(real)) outputSources.push(pathname);
|
|
921
|
+
else addFile(pathname);
|
|
922
|
+
} else if (stat.isDirectory()) walkDir(pathname, real);
|
|
923
|
+
}
|
|
924
|
+
return {
|
|
925
|
+
filenames,
|
|
926
|
+
diagnostics,
|
|
927
|
+
outputSources
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
//#endregion
|
|
931
|
+
//#region src/relaxed-json.ts
|
|
932
|
+
/**
|
|
933
|
+
* A parser for the object literal a Twine 2 `format.js` passes to `window.storyFormat()`.
|
|
934
|
+
*
|
|
935
|
+
* The story formats spec does not require that object to be strict JSON, and Twine 2 runs
|
|
936
|
+
* `format.js` as JavaScript. This parser accepts JSON plus the JavaScript literal syntax formats
|
|
937
|
+
* use in practice: single-quoted strings, unquoted (identifier) and numeric keys, trailing commas,
|
|
938
|
+
* comments, JavaScript string escapes, and hexadecimal, octal and binary numbers.
|
|
939
|
+
*
|
|
940
|
+
* It reads string literals token by token, so their contents come back exactly as JavaScript
|
|
941
|
+
* evaluation gives them: text that only looks like structure (`a, }`, `word:`, an apostrophe) is
|
|
942
|
+
* never touched. Anything outside that subset, such as a function or a variable, is a syntax error.
|
|
943
|
+
*/
|
|
944
|
+
const IDENTIFIER = /[A-Za-z_$][\w$]*/y;
|
|
945
|
+
const NUMBER = /[+-]?(?:0[xX][0-9a-fA-F]+|0[oO][0-7]+|0[bB][01]+|(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)/y;
|
|
946
|
+
const IDENTIFIER_PART = /[\w$]/;
|
|
947
|
+
const WHITESPACE = /[\s]/;
|
|
948
|
+
const HEX = /^[0-9a-fA-F]+$/;
|
|
949
|
+
/** Simple single-character escapes and the characters they stand for. */
|
|
950
|
+
const SIMPLE_ESCAPES = {
|
|
951
|
+
b: "\b",
|
|
952
|
+
f: "\f",
|
|
953
|
+
n: "\n",
|
|
954
|
+
r: "\r",
|
|
955
|
+
t: " ",
|
|
956
|
+
v: "\v"
|
|
957
|
+
};
|
|
958
|
+
/**
|
|
959
|
+
* Parse a JavaScript object, array or primitive literal: all of `text`, or the part from `start` to
|
|
960
|
+
* `end`. Error positions count from the start of `text`, so they point into the file it came from.
|
|
961
|
+
*
|
|
962
|
+
* @throws SyntaxError naming the line and column of the first thing it cannot parse.
|
|
963
|
+
*/
|
|
964
|
+
function parseRelaxedJSON(text, start = 0, end = text.length) {
|
|
965
|
+
let pos = start;
|
|
966
|
+
function fail(message, at = pos) {
|
|
967
|
+
const before = text.slice(0, at);
|
|
968
|
+
const line = before.split("\n").length;
|
|
969
|
+
const column = at - before.lastIndexOf("\n");
|
|
970
|
+
throw new SyntaxError(`${message} at line ${line}, column ${column}`);
|
|
971
|
+
}
|
|
972
|
+
/** The character at `pos`, or '' at the end of the input. */
|
|
973
|
+
const peek = () => pos < end ? text[pos] ?? "" : "";
|
|
974
|
+
const describeChar = (at) => at >= end ? "end of input" : JSON.stringify(text[at]);
|
|
975
|
+
/** Skip whitespace and comments. */
|
|
976
|
+
const skipTrivia = () => {
|
|
977
|
+
while (pos < end) {
|
|
978
|
+
const ch = peek();
|
|
979
|
+
if (WHITESPACE.test(ch)) pos++;
|
|
980
|
+
else if (text.startsWith("//", pos)) {
|
|
981
|
+
const lineEnd = text.slice(pos, end).search(/[\n\r\u2028\u2029]/);
|
|
982
|
+
pos = lineEnd === -1 ? end : pos + lineEnd;
|
|
983
|
+
} else if (text.startsWith("/*", pos)) {
|
|
984
|
+
const commentEnd = text.indexOf("*/", pos + 2);
|
|
985
|
+
if (commentEnd === -1 || commentEnd + 2 > end) fail("Unterminated comment");
|
|
986
|
+
pos = commentEnd + 2;
|
|
987
|
+
} else return;
|
|
988
|
+
}
|
|
989
|
+
};
|
|
990
|
+
const consume = (ch) => {
|
|
991
|
+
skipTrivia();
|
|
992
|
+
if (peek() !== ch) fail(`Expected ${JSON.stringify(ch)} but found ${describeChar(pos)}`);
|
|
993
|
+
pos++;
|
|
994
|
+
};
|
|
995
|
+
/** Read exactly `count` hex digits and return their value. */
|
|
996
|
+
const readHex = (count) => {
|
|
997
|
+
const digits = text.slice(pos, Math.min(pos + count, end));
|
|
998
|
+
if (digits.length !== count || !HEX.test(digits)) fail("Invalid hexadecimal escape sequence");
|
|
999
|
+
pos += count;
|
|
1000
|
+
return parseInt(digits, 16);
|
|
1001
|
+
};
|
|
1002
|
+
/** Decode the escape sequence after a backslash; `pos` is just past the backslash. */
|
|
1003
|
+
const readEscape = () => {
|
|
1004
|
+
const ch = peek();
|
|
1005
|
+
const simple = SIMPLE_ESCAPES[ch];
|
|
1006
|
+
if (simple !== void 0) {
|
|
1007
|
+
pos++;
|
|
1008
|
+
return simple;
|
|
1009
|
+
}
|
|
1010
|
+
switch (ch) {
|
|
1011
|
+
case "": return fail("Unterminated string");
|
|
1012
|
+
case "x":
|
|
1013
|
+
pos++;
|
|
1014
|
+
return String.fromCharCode(readHex(2));
|
|
1015
|
+
case "u": {
|
|
1016
|
+
pos++;
|
|
1017
|
+
if (peek() !== "{") return String.fromCharCode(readHex(4));
|
|
1018
|
+
const close = text.indexOf("}", pos);
|
|
1019
|
+
if (close === -1 || close >= end) fail("Invalid Unicode escape sequence");
|
|
1020
|
+
pos++;
|
|
1021
|
+
const codePoint = readHex(close - pos);
|
|
1022
|
+
if (codePoint > 1114111) fail("Undefined Unicode code-point");
|
|
1023
|
+
pos = close + 1;
|
|
1024
|
+
return String.fromCodePoint(codePoint);
|
|
665
1025
|
}
|
|
666
|
-
|
|
1026
|
+
case "\r":
|
|
1027
|
+
pos += text[pos + 1] === "\n" ? 2 : 1;
|
|
1028
|
+
return "";
|
|
1029
|
+
case "\n":
|
|
1030
|
+
case "\u2028":
|
|
1031
|
+
case "\u2029":
|
|
1032
|
+
pos++;
|
|
1033
|
+
return "";
|
|
1034
|
+
case "0":
|
|
1035
|
+
if (/[0-9]/.test(text[pos + 1] ?? "")) fail("Octal escape sequences are not supported");
|
|
1036
|
+
pos++;
|
|
1037
|
+
return "\0";
|
|
1038
|
+
default:
|
|
1039
|
+
if (/[1-9]/.test(ch)) fail("Octal escape sequences are not supported");
|
|
1040
|
+
pos++;
|
|
1041
|
+
return ch;
|
|
667
1042
|
}
|
|
1043
|
+
};
|
|
1044
|
+
const readString = () => {
|
|
1045
|
+
const quote = peek();
|
|
1046
|
+
pos++;
|
|
1047
|
+
let out = "";
|
|
1048
|
+
let chunkStart = pos;
|
|
1049
|
+
for (;;) {
|
|
1050
|
+
if (pos >= end) fail("Unterminated string");
|
|
1051
|
+
const ch = peek();
|
|
1052
|
+
if (ch === quote) {
|
|
1053
|
+
out += text.slice(chunkStart, pos);
|
|
1054
|
+
pos++;
|
|
1055
|
+
return out;
|
|
1056
|
+
}
|
|
1057
|
+
if (ch === "\n" || ch === "\r") fail("Unterminated string");
|
|
1058
|
+
if (ch === "\\") {
|
|
1059
|
+
out += text.slice(chunkStart, pos);
|
|
1060
|
+
pos++;
|
|
1061
|
+
out += readEscape();
|
|
1062
|
+
chunkStart = pos;
|
|
1063
|
+
} else pos++;
|
|
1064
|
+
}
|
|
1065
|
+
};
|
|
1066
|
+
/** Read a match of a sticky regular expression at `pos`, or return undefined. */
|
|
1067
|
+
const readToken = (pattern) => {
|
|
1068
|
+
pattern.lastIndex = pos;
|
|
1069
|
+
const m = pattern.exec(text);
|
|
1070
|
+
if (!m || pattern.lastIndex > end) return void 0;
|
|
1071
|
+
pos = pattern.lastIndex;
|
|
1072
|
+
return m[0];
|
|
1073
|
+
};
|
|
1074
|
+
const readNumber = () => {
|
|
1075
|
+
const tokenStart = pos;
|
|
1076
|
+
const token = readToken(NUMBER);
|
|
1077
|
+
if (token === void 0) return void 0;
|
|
1078
|
+
if (IDENTIFIER_PART.test(peek())) fail("Invalid number", tokenStart);
|
|
1079
|
+
return (token.startsWith("-") ? -1 : 1) * Number(/^[+-]/.test(token) ? token.slice(1) : token);
|
|
1080
|
+
};
|
|
1081
|
+
const readKey = () => {
|
|
1082
|
+
skipTrivia();
|
|
1083
|
+
const ch = peek();
|
|
1084
|
+
if (ch === "\"" || ch === "'") return readString();
|
|
1085
|
+
const identifier = readToken(IDENTIFIER);
|
|
1086
|
+
if (identifier !== void 0) return identifier;
|
|
1087
|
+
const number = readNumber();
|
|
1088
|
+
if (number !== void 0) return String(number);
|
|
1089
|
+
return fail(`Expected a property name but found ${describeChar(pos)}`);
|
|
1090
|
+
};
|
|
1091
|
+
const readObject = () => {
|
|
1092
|
+
pos++;
|
|
1093
|
+
const out = {};
|
|
1094
|
+
for (;;) {
|
|
1095
|
+
skipTrivia();
|
|
1096
|
+
if (peek() === "}") {
|
|
1097
|
+
pos++;
|
|
1098
|
+
return out;
|
|
1099
|
+
}
|
|
1100
|
+
const key = readKey();
|
|
1101
|
+
consume(":");
|
|
1102
|
+
Object.defineProperty(out, key, {
|
|
1103
|
+
value: readValue(),
|
|
1104
|
+
enumerable: true,
|
|
1105
|
+
writable: true,
|
|
1106
|
+
configurable: true
|
|
1107
|
+
});
|
|
1108
|
+
skipTrivia();
|
|
1109
|
+
if (peek() === ",") pos++;
|
|
1110
|
+
else if (peek() !== "}") fail(`Expected "," or "}" but found ${describeChar(pos)}`);
|
|
1111
|
+
}
|
|
1112
|
+
};
|
|
1113
|
+
const readArray = () => {
|
|
1114
|
+
pos++;
|
|
1115
|
+
const out = [];
|
|
1116
|
+
for (;;) {
|
|
1117
|
+
skipTrivia();
|
|
1118
|
+
if (peek() === "]") {
|
|
1119
|
+
pos++;
|
|
1120
|
+
return out;
|
|
1121
|
+
}
|
|
1122
|
+
out.push(readValue());
|
|
1123
|
+
skipTrivia();
|
|
1124
|
+
if (peek() === ",") pos++;
|
|
1125
|
+
else if (peek() !== "]") fail(`Expected "," or "]" but found ${describeChar(pos)}`);
|
|
1126
|
+
}
|
|
1127
|
+
};
|
|
1128
|
+
function readValue() {
|
|
1129
|
+
skipTrivia();
|
|
1130
|
+
const ch = peek();
|
|
1131
|
+
if (ch === "{") return readObject();
|
|
1132
|
+
if (ch === "[") return readArray();
|
|
1133
|
+
if (ch === "\"" || ch === "'") return readString();
|
|
1134
|
+
const tokenStart = pos;
|
|
1135
|
+
const identifier = readToken(IDENTIFIER);
|
|
1136
|
+
if (identifier !== void 0) {
|
|
1137
|
+
if (identifier === "true") return true;
|
|
1138
|
+
if (identifier === "false") return false;
|
|
1139
|
+
if (identifier === "null") return null;
|
|
1140
|
+
return fail(`Unexpected identifier ${JSON.stringify(identifier)}`, tokenStart);
|
|
1141
|
+
}
|
|
1142
|
+
const number = readNumber();
|
|
1143
|
+
if (number !== void 0) return number;
|
|
1144
|
+
return fail(`Unexpected ${describeChar(pos)}`);
|
|
668
1145
|
}
|
|
669
|
-
|
|
1146
|
+
const value = readValue();
|
|
1147
|
+
skipTrivia();
|
|
1148
|
+
if (pos < end) fail(`Unexpected ${describeChar(pos)} after the value`);
|
|
1149
|
+
return value;
|
|
1150
|
+
}
|
|
1151
|
+
//#endregion
|
|
1152
|
+
//#region src/semver.ts
|
|
1153
|
+
const IDENTIFIERS = String.raw`[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*`;
|
|
1154
|
+
const VERSION$1 = new RegExp(String.raw`^[vV]?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-(${IDENTIFIERS}))?(?:\+${IDENTIFIERS})?$`);
|
|
1155
|
+
const NUMERIC = /^\d+$/;
|
|
1156
|
+
/** Parse a version string, or return null when it is not a version. */
|
|
1157
|
+
function parseVersion(text) {
|
|
1158
|
+
const m = VERSION$1.exec(text);
|
|
1159
|
+
if (!m) return null;
|
|
670
1160
|
return {
|
|
671
|
-
|
|
672
|
-
|
|
1161
|
+
major: Number(m[1]),
|
|
1162
|
+
minor: Number(m[2] ?? 0),
|
|
1163
|
+
patch: Number(m[3] ?? 0),
|
|
1164
|
+
prerelease: m[4] === void 0 ? [] : m[4].split(".")
|
|
673
1165
|
};
|
|
674
1166
|
}
|
|
1167
|
+
/** Compare two prerelease identifiers: numeric ones numerically and below alphanumeric ones, which compare as ASCII. */
|
|
1168
|
+
function compareIdentifiers(a, b) {
|
|
1169
|
+
const aNumeric = NUMERIC.test(a);
|
|
1170
|
+
const bNumeric = NUMERIC.test(b);
|
|
1171
|
+
if (aNumeric && bNumeric) return Math.sign(Number(a) - Number(b));
|
|
1172
|
+
if (aNumeric !== bNumeric) return aNumeric ? -1 : 1;
|
|
1173
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
1174
|
+
}
|
|
1175
|
+
/** Compare prerelease identifier lists. An empty list (a release) ranks above any prerelease. */
|
|
1176
|
+
function comparePrerelease(a, b) {
|
|
1177
|
+
if (a.length === 0 || b.length === 0) return Math.sign(b.length - a.length);
|
|
1178
|
+
const shared = Math.min(a.length, b.length);
|
|
1179
|
+
for (let i = 0; i < shared; i++) {
|
|
1180
|
+
const order = compareIdentifiers(a[i] ?? "", b[i] ?? "");
|
|
1181
|
+
if (order !== 0) return order;
|
|
1182
|
+
}
|
|
1183
|
+
return Math.sign(a.length - b.length);
|
|
1184
|
+
}
|
|
1185
|
+
/** Compare two versions by SemVer precedence: negative when `a` ranks lower, 0 when equal, positive when higher. */
|
|
1186
|
+
function compareVersions(a, b) {
|
|
1187
|
+
return Math.sign(a.major - b.major) || Math.sign(a.minor - b.minor) || Math.sign(a.patch - b.patch) || comparePrerelease(a.prerelease, b.prerelease);
|
|
1188
|
+
}
|
|
1189
|
+
/** Whether two version strings name the same version (prerelease included, build metadata ignored). */
|
|
1190
|
+
function sameVersion(a, b) {
|
|
1191
|
+
const left = parseVersion(a);
|
|
1192
|
+
const right = parseVersion(b);
|
|
1193
|
+
return left !== null && right !== null && compareVersions(left, right) === 0;
|
|
1194
|
+
}
|
|
675
1195
|
//#endregion
|
|
676
1196
|
//#region src/source-text.ts
|
|
677
1197
|
/**
|
|
@@ -684,14 +1204,119 @@ function getFilenames(pathnames, outFilename, exclude = []) {
|
|
|
684
1204
|
function normalizeSourceText(text) {
|
|
685
1205
|
return (text.charCodeAt(0) === 65279 ? text.slice(1) : text).replace(/\r\n?/g, "\n");
|
|
686
1206
|
}
|
|
1207
|
+
/** Byte order marks at the start of a line, directly before a passage header's `::`. */
|
|
1208
|
+
const BOMS_BEFORE_HEADER = /(?<=^|\n)\uFEFF+(?=::)/g;
|
|
1209
|
+
/**
|
|
1210
|
+
* Normalize Twee source text: {@link normalizeSourceText}, and also remove byte order marks at the start of
|
|
1211
|
+
* a line that are directly followed by `::`. Concatenating files (`cat a.tw b.tw`) leaves the BOM of every
|
|
1212
|
+
* later file in front of its first passage header, which would otherwise not be recognised as a header.
|
|
1213
|
+
* A U+FEFF anywhere else is passage text and kept. Idempotent.
|
|
1214
|
+
*/
|
|
1215
|
+
function normalizeTweeSourceText(text) {
|
|
1216
|
+
return normalizeSourceText(text).replace(BOMS_BEFORE_HEADER, "");
|
|
1217
|
+
}
|
|
687
1218
|
//#endregion
|
|
688
1219
|
//#region src/util.ts
|
|
689
1220
|
/**
|
|
690
1221
|
* Shared file I/O utilities.
|
|
691
1222
|
*/
|
|
692
|
-
/**
|
|
693
|
-
|
|
694
|
-
|
|
1223
|
+
/** The charset assumed for text that is not valid UTF-8, as Tweego assumes it. */
|
|
1224
|
+
const FALLBACK_CHARSET = "windows-1252";
|
|
1225
|
+
/**
|
|
1226
|
+
* Throws on invalid UTF-8 instead of replacing bytes with U+FFFD. `ignoreBOM` keeps a leading BOM in the
|
|
1227
|
+
* text, as `readFileSync(file, 'utf-8')` did, so {@link normalizeSourceText} strips exactly one.
|
|
1228
|
+
*/
|
|
1229
|
+
const utf8Decoder = new TextDecoder("utf-8", {
|
|
1230
|
+
fatal: true,
|
|
1231
|
+
ignoreBOM: true
|
|
1232
|
+
});
|
|
1233
|
+
/**
|
|
1234
|
+
* The code points of the Windows-1252 bytes 0x80 to 0x9F, from the WHATWG Encoding Standard. The five bytes
|
|
1235
|
+
* Windows-1252 leaves undefined map to the C1 control with the same number, as the standard and Go's charmap
|
|
1236
|
+
* do. Every other byte is the code point with the same number.
|
|
1237
|
+
*
|
|
1238
|
+
* Decoded by hand, because Node's `TextDecoder('windows-1252')` decodes as ISO-8859-1 in some releases
|
|
1239
|
+
* (Node 22.18 turns 0x80 into U+0080 instead of €), and builds without ICU lack it.
|
|
1240
|
+
*/
|
|
1241
|
+
const WINDOWS_1252_C1 = [
|
|
1242
|
+
8364,
|
|
1243
|
+
129,
|
|
1244
|
+
8218,
|
|
1245
|
+
402,
|
|
1246
|
+
8222,
|
|
1247
|
+
8230,
|
|
1248
|
+
8224,
|
|
1249
|
+
8225,
|
|
1250
|
+
710,
|
|
1251
|
+
8240,
|
|
1252
|
+
352,
|
|
1253
|
+
8249,
|
|
1254
|
+
338,
|
|
1255
|
+
141,
|
|
1256
|
+
381,
|
|
1257
|
+
143,
|
|
1258
|
+
144,
|
|
1259
|
+
8216,
|
|
1260
|
+
8217,
|
|
1261
|
+
8220,
|
|
1262
|
+
8221,
|
|
1263
|
+
8226,
|
|
1264
|
+
8211,
|
|
1265
|
+
8212,
|
|
1266
|
+
732,
|
|
1267
|
+
8482,
|
|
1268
|
+
353,
|
|
1269
|
+
8250,
|
|
1270
|
+
339,
|
|
1271
|
+
157,
|
|
1272
|
+
382,
|
|
1273
|
+
376
|
|
1274
|
+
];
|
|
1275
|
+
function decodeWindows1252(bytes) {
|
|
1276
|
+
const chunks = [];
|
|
1277
|
+
for (let start = 0; start < bytes.length; start += 8192) {
|
|
1278
|
+
const codes = Array.from(bytes.subarray(start, start + 8192), (b) => b >= 128 && b <= 159 ? WINDOWS_1252_C1[b - 128] ?? b : b);
|
|
1279
|
+
chunks.push(String.fromCharCode(...codes));
|
|
1280
|
+
}
|
|
1281
|
+
return chunks.join("");
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* Decode bytes read from `filename` as UTF-8. Bytes that are not valid UTF-8 are decoded as Windows-1252
|
|
1285
|
+
* instead, with a warning naming the file, as Tweego does (`fileReadAllWithEncoding` in io.go), so a legacy
|
|
1286
|
+
* file keeps its non-ASCII characters instead of losing them to U+FFFD.
|
|
1287
|
+
*
|
|
1288
|
+
* The text is returned as decoded: a leading BOM and CR line endings are kept (see {@link normalizeSourceText}).
|
|
1289
|
+
*/
|
|
1290
|
+
function decodeText(bytes, filename) {
|
|
1291
|
+
try {
|
|
1292
|
+
return {
|
|
1293
|
+
text: utf8Decoder.decode(bytes),
|
|
1294
|
+
diagnostics: []
|
|
1295
|
+
};
|
|
1296
|
+
} catch {
|
|
1297
|
+
return {
|
|
1298
|
+
text: decodeWindows1252(bytes),
|
|
1299
|
+
diagnostics: [{
|
|
1300
|
+
level: "warning",
|
|
1301
|
+
message: `read ${filename}: Invalid UTF-8; assuming charset is ${FALLBACK_CHARSET}.`,
|
|
1302
|
+
file: filename
|
|
1303
|
+
}]
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Read a text file: decode it with {@link decodeText} (UTF-8, or Windows-1252 when it is not valid UTF-8),
|
|
1309
|
+
* strip a leading BOM and convert CRLF and bare CR line endings to LF. This is the one path every file read
|
|
1310
|
+
* as text goes through: sources, modules, the head file, story formats and the config file.
|
|
1311
|
+
*
|
|
1312
|
+
* @param diagnostics Receives the warning when the file is not valid UTF-8. Without it, the file is decoded
|
|
1313
|
+
* the same way and the warning is dropped.
|
|
1314
|
+
* @throws When the file cannot be read.
|
|
1315
|
+
*/
|
|
1316
|
+
function readUTF8(filename, diagnostics) {
|
|
1317
|
+
const decoded = decodeText(readFileSync(filename), filename);
|
|
1318
|
+
diagnostics?.push(...decoded.diagnostics);
|
|
1319
|
+
return normalizeSourceText(decoded.text);
|
|
695
1320
|
}
|
|
696
1321
|
/** Read a file as base64. */
|
|
697
1322
|
function readBase64(filename) {
|
|
@@ -707,20 +1332,11 @@ function baseNameWithoutExt(filename) {
|
|
|
707
1332
|
/**
|
|
708
1333
|
* Story format discovery, loading, and SemVer matching.
|
|
709
1334
|
* Ported from formats.go + config.go.
|
|
1335
|
+
*
|
|
1336
|
+
* Every lookup (local folders here, the download cache and remote indices in remote-formats.ts)
|
|
1337
|
+
* goes through the same version parsing ({@link parseVersion}) and the same matching rules
|
|
1338
|
+
* ({@link selectFormatCandidate}).
|
|
710
1339
|
*/
|
|
711
|
-
/**
|
|
712
|
-
* Attempt to fix non-strict JSON (trailing commas, single quotes, unquoted keys).
|
|
713
|
-
*/
|
|
714
|
-
function relaxedJSONParse(json) {
|
|
715
|
-
try {
|
|
716
|
-
return JSON.parse(json);
|
|
717
|
-
} catch {}
|
|
718
|
-
let fixed = json;
|
|
719
|
-
fixed = fixed.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, "\"$1\"");
|
|
720
|
-
fixed = fixed.replace(/(?<=^|[{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:/gm, "\"$1\":");
|
|
721
|
-
fixed = fixed.replace(/,\s*([}\]])/g, "$1");
|
|
722
|
-
return JSON.parse(fixed);
|
|
723
|
-
}
|
|
724
1340
|
/** A `"setup": function` property, which Harlowe appends to its otherwise-JSON format object. */
|
|
725
1341
|
const SETUP_FUNCTION_PROPERTY = /,\s*"setup"\s*:\s*function\b/g;
|
|
726
1342
|
/** Drop a trailing `"setup": function(){…}` property from a format object, if it has one. */
|
|
@@ -730,151 +1346,239 @@ function stripSetupFunction(chunk) {
|
|
|
730
1346
|
return lastIndex === -1 ? void 0 : chunk.slice(0, lastIndex) + "}";
|
|
731
1347
|
}
|
|
732
1348
|
/**
|
|
733
|
-
* Parse the
|
|
734
|
-
*
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
1349
|
+
* Parse the format object at `start`–`end` of `text`: strict JSON first (fast), then the
|
|
1350
|
+
* JavaScript literal subset formats use. Error positions point into `text`.
|
|
1351
|
+
*/
|
|
1352
|
+
function parseFormatObject(text, start, end) {
|
|
1353
|
+
try {
|
|
1354
|
+
return {
|
|
1355
|
+
ok: true,
|
|
1356
|
+
value: JSON.parse(text.slice(start, end))
|
|
1357
|
+
};
|
|
1358
|
+
} catch {}
|
|
1359
|
+
try {
|
|
1360
|
+
return {
|
|
1361
|
+
ok: true,
|
|
1362
|
+
value: parseRelaxedJSON(text, start, end)
|
|
1363
|
+
};
|
|
1364
|
+
} catch (e) {
|
|
1365
|
+
return {
|
|
1366
|
+
ok: false,
|
|
1367
|
+
error: e instanceof Error ? e.message : String(e)
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
/** Check a parsed format object's fields. Per spec, `name` is optional; `version` and `source` are required. */
|
|
1372
|
+
function toFormatJSON(raw) {
|
|
1373
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
|
|
1374
|
+
ok: false,
|
|
1375
|
+
reason: "Story format JSON chunk is not an object."
|
|
1376
|
+
};
|
|
1377
|
+
const obj = raw;
|
|
1378
|
+
if (typeof obj.version !== "string") return {
|
|
1379
|
+
ok: false,
|
|
1380
|
+
reason: "Story format has no \"version\" string."
|
|
1381
|
+
};
|
|
1382
|
+
if (typeof obj.source !== "string") return {
|
|
1383
|
+
ok: false,
|
|
1384
|
+
reason: "Story format has no \"source\" string."
|
|
1385
|
+
};
|
|
1386
|
+
if (!parseVersion(obj.version)) return {
|
|
1387
|
+
ok: false,
|
|
1388
|
+
reason: `Story format version ${JSON.stringify(obj.version)} is not a SemVer version.`
|
|
1389
|
+
};
|
|
1390
|
+
const data = {
|
|
1391
|
+
name: typeof obj.name === "string" ? obj.name : "Untitled Story Format",
|
|
1392
|
+
version: obj.version,
|
|
1393
|
+
source: obj.source,
|
|
1394
|
+
proofing: obj.proofing === true
|
|
1395
|
+
};
|
|
1396
|
+
if (typeof obj.author === "string") data.author = obj.author;
|
|
1397
|
+
if (typeof obj.description === "string") data.description = obj.description;
|
|
1398
|
+
if (typeof obj.image === "string") data.image = obj.image;
|
|
1399
|
+
if (typeof obj.url === "string") data.url = obj.url;
|
|
1400
|
+
if (typeof obj.license === "string") data.license = obj.license;
|
|
1401
|
+
return {
|
|
1402
|
+
ok: true,
|
|
1403
|
+
data
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* Read the metadata of a Twine 2 format.js, or say why it cannot be used.
|
|
738
1408
|
*
|
|
739
|
-
*
|
|
1409
|
+
* The object passed to `window.storyFormat()` may be strict JSON or a JavaScript object literal
|
|
1410
|
+
* (single quotes, unquoted keys, trailing commas, comments). String values come back exactly as
|
|
1411
|
+
* JavaScript evaluation gives them. Harlowe's function-valued `setup` property is dropped; the
|
|
1412
|
+
* workaround keys on the property itself, so the same bytes parse whether they come from a
|
|
1413
|
+
* `harlowe-3` directory or a direct download.
|
|
740
1414
|
*/
|
|
741
|
-
function
|
|
1415
|
+
function decodeFormatJSON(source) {
|
|
742
1416
|
const first = source.indexOf("{");
|
|
743
1417
|
const last = source.lastIndexOf("}");
|
|
744
|
-
if (first === -1 || last
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
proofing: obj.proofing === true
|
|
757
|
-
};
|
|
758
|
-
if (typeof obj.author === "string") result.author = obj.author;
|
|
759
|
-
if (typeof obj.description === "string") result.description = obj.description;
|
|
760
|
-
if (typeof obj.image === "string") result.image = obj.image;
|
|
761
|
-
if (typeof obj.url === "string") result.url = obj.url;
|
|
762
|
-
if (typeof obj.license === "string") result.license = obj.license;
|
|
763
|
-
return result;
|
|
1418
|
+
if (first === -1 || last < first) return {
|
|
1419
|
+
ok: false,
|
|
1420
|
+
reason: "Could not find Twine 2 style story format JSON chunk."
|
|
1421
|
+
};
|
|
1422
|
+
const parsed = parseFormatObject(source, first, last + 1);
|
|
1423
|
+
if (parsed.ok) return toFormatJSON(parsed.value);
|
|
1424
|
+
const stripped = stripSetupFunction(source.slice(first, last + 1));
|
|
1425
|
+
const retried = stripped === void 0 ? void 0 : parseFormatObject(stripped, 0, stripped.length);
|
|
1426
|
+
if (retried?.ok) return toFormatJSON(retried.value);
|
|
1427
|
+
return {
|
|
1428
|
+
ok: false,
|
|
1429
|
+
reason: `Could not decode story format JSON chunk: ${parsed.error}`
|
|
764
1430
|
};
|
|
1431
|
+
}
|
|
1432
|
+
/**
|
|
1433
|
+
* Parse the Twine 2 format.js JSON chunk; null when it cannot be used ({@link decodeFormatJSON}
|
|
1434
|
+
* gives the reason).
|
|
1435
|
+
*
|
|
1436
|
+
* @param _formatId No longer used; kept so existing callers keep compiling.
|
|
1437
|
+
*/
|
|
1438
|
+
function parseFormatJSON(source, _formatId) {
|
|
1439
|
+
const result = decodeFormatJSON(source);
|
|
1440
|
+
return result.ok ? result.data : null;
|
|
1441
|
+
}
|
|
1442
|
+
/** A directory's entries in a stable order, or none when it cannot be read. */
|
|
1443
|
+
function listDirectory(dir) {
|
|
765
1444
|
try {
|
|
766
|
-
return
|
|
1445
|
+
return readdirSync(dir).sort();
|
|
767
1446
|
} catch {
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
1447
|
+
return [];
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
function isDirectory(path) {
|
|
1451
|
+
try {
|
|
1452
|
+
return statSync(path).isDirectory();
|
|
1453
|
+
} catch {
|
|
1454
|
+
return false;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
function isFile(path) {
|
|
1458
|
+
try {
|
|
1459
|
+
return statSync(path).isFile();
|
|
1460
|
+
} catch {
|
|
1461
|
+
return false;
|
|
775
1462
|
}
|
|
776
1463
|
}
|
|
777
1464
|
/**
|
|
778
|
-
*
|
|
779
|
-
*
|
|
780
|
-
* only the highest minor.patch survives.
|
|
1465
|
+
* Load the format in one folder: its format.js (Twine 2), else its header.html (Twine 1).
|
|
1466
|
+
* A format.js that cannot be used is reported as a warning, as Tweego does.
|
|
781
1467
|
*/
|
|
782
|
-
function
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
for (const
|
|
1468
|
+
function loadFormatDir(searchDir, id, diagnostics) {
|
|
1469
|
+
const formatDir = join(searchDir, id);
|
|
1470
|
+
if (!isDirectory(formatDir)) return void 0;
|
|
1471
|
+
for (const baseFilename of ["format.js", "header.html"]) {
|
|
1472
|
+
const filename = join(formatDir, baseFilename);
|
|
1473
|
+
if (!isFile(filename)) continue;
|
|
1474
|
+
if (baseFilename === "header.html") return {
|
|
1475
|
+
id,
|
|
1476
|
+
filename,
|
|
1477
|
+
isTwine2: false,
|
|
1478
|
+
name: id,
|
|
1479
|
+
version: "",
|
|
1480
|
+
proofing: false
|
|
1481
|
+
};
|
|
1482
|
+
const encoding = [];
|
|
1483
|
+
let decoded;
|
|
786
1484
|
try {
|
|
787
|
-
|
|
788
|
-
} catch {
|
|
789
|
-
|
|
1485
|
+
decoded = decodeFormatJSON(readUTF8(filename, encoding));
|
|
1486
|
+
} catch (e) {
|
|
1487
|
+
decoded = {
|
|
1488
|
+
ok: false,
|
|
1489
|
+
reason: `Could not read ${filename}: ${e instanceof Error ? e.message : String(e)}`
|
|
1490
|
+
};
|
|
790
1491
|
}
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
1492
|
+
if (!decoded.ok) {
|
|
1493
|
+
diagnostics?.push(...encoding);
|
|
1494
|
+
diagnostics?.push({
|
|
1495
|
+
level: "warning",
|
|
1496
|
+
message: `format ${id}: Skipping format; ${decoded.reason} (${filename})`
|
|
1497
|
+
});
|
|
795
1498
|
continue;
|
|
796
1499
|
}
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
if (data.url) format.url = data.url;
|
|
830
|
-
if (data.license) format.license = data.license;
|
|
831
|
-
} catch {
|
|
832
|
-
continue;
|
|
833
|
-
}
|
|
834
|
-
else format.name = entry;
|
|
835
|
-
formats.set(entry, format);
|
|
836
|
-
break;
|
|
837
|
-
}
|
|
838
|
-
}
|
|
1500
|
+
const { data } = decoded;
|
|
1501
|
+
const format = {
|
|
1502
|
+
id,
|
|
1503
|
+
filename,
|
|
1504
|
+
isTwine2: true,
|
|
1505
|
+
name: data.name,
|
|
1506
|
+
version: data.version,
|
|
1507
|
+
proofing: data.proofing ?? false
|
|
1508
|
+
};
|
|
1509
|
+
if (data.author) format.author = data.author;
|
|
1510
|
+
if (data.description) format.description = data.description;
|
|
1511
|
+
if (data.image) format.image = data.image;
|
|
1512
|
+
if (data.url) format.url = data.url;
|
|
1513
|
+
if (data.license) format.license = data.license;
|
|
1514
|
+
return format;
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
/**
|
|
1518
|
+
* Discover every story format in the search directories, keyed by folder name (the format ID),
|
|
1519
|
+
* without version pruning.
|
|
1520
|
+
*
|
|
1521
|
+
* Directories are searched in order, and a later directory outranks an earlier one: when two hold
|
|
1522
|
+
* the same folder name, the later one's format is kept. The map iterates in that order too, lowest
|
|
1523
|
+
* rank first. Formats that cannot be used are left out, with a warning in `diagnostics`.
|
|
1524
|
+
*/
|
|
1525
|
+
function discoverAllFormats(searchDirs, diagnostics) {
|
|
1526
|
+
const formats = /* @__PURE__ */ new Map();
|
|
1527
|
+
for (const searchDir of searchDirs) for (const entry of listDirectory(searchDir)) {
|
|
1528
|
+
const format = loadFormatDir(searchDir, entry, diagnostics);
|
|
1529
|
+
if (!format) continue;
|
|
1530
|
+
formats.delete(entry);
|
|
1531
|
+
formats.set(entry, format);
|
|
839
1532
|
}
|
|
840
|
-
pruneFormats(formats);
|
|
841
1533
|
return formats;
|
|
842
1534
|
}
|
|
1535
|
+
/** The version a format is pruned and selected by, or null for one outside SemVer pruning. */
|
|
1536
|
+
function prunableVersion(format) {
|
|
1537
|
+
return format.isTwine2 && format.name ? parseVersion(format.version) : null;
|
|
1538
|
+
}
|
|
1539
|
+
/** The search directory a discovered format was found in. */
|
|
1540
|
+
function searchDirOf(format) {
|
|
1541
|
+
return dirname(dirname(format.filename));
|
|
1542
|
+
}
|
|
843
1543
|
/**
|
|
844
|
-
* Prune formats by SemVer: within each (name, major) group,
|
|
845
|
-
*
|
|
1544
|
+
* Prune formats by SemVer: within each (name, major) group, keep only the version with the highest
|
|
1545
|
+
* precedence (a release outranks its prereleases). Between equal versions, the folder in the
|
|
1546
|
+
* higher-ranked search directory wins, and within one directory the first folder name in sort
|
|
1547
|
+
* order. Returns a new map in the same order.
|
|
1548
|
+
*
|
|
1549
|
+
* Pruning serves name-based selection and listings. An explicit format ID is looked up in the
|
|
1550
|
+
* unpruned map, so a pinned folder is used even when another folder holds a newer version.
|
|
846
1551
|
*/
|
|
847
1552
|
function pruneFormats(formats) {
|
|
848
|
-
const
|
|
849
|
-
const
|
|
850
|
-
|
|
851
|
-
if (!
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
const
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
version: v
|
|
1553
|
+
const dirRank = /* @__PURE__ */ new Map();
|
|
1554
|
+
for (const format of formats.values()) {
|
|
1555
|
+
const dir = searchDirOf(format);
|
|
1556
|
+
if (!dirRank.has(dir)) dirRank.set(dir, dirRank.size);
|
|
1557
|
+
}
|
|
1558
|
+
const rankOf = (format) => dirRank.get(searchDirOf(format)) ?? 0;
|
|
1559
|
+
const best = /* @__PURE__ */ new Map();
|
|
1560
|
+
for (const format of formats.values()) {
|
|
1561
|
+
const version = prunableVersion(format);
|
|
1562
|
+
if (!version) continue;
|
|
1563
|
+
const group = `${format.name}@${version.major}`;
|
|
1564
|
+
const current = best.get(group);
|
|
1565
|
+
const order = current ? compareVersions(version, current.version) : 1;
|
|
1566
|
+
if (order > 0 || order === 0 && current && rankOf(format) > rankOf(current.format)) best.set(group, {
|
|
1567
|
+
format,
|
|
1568
|
+
version
|
|
865
1569
|
});
|
|
866
1570
|
}
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
const groupKey = `${f.name}@${v[0]}`;
|
|
872
|
-
const best = bestByNameMajor.get(groupKey);
|
|
873
|
-
if (best && best.id !== id) formats.delete(id);
|
|
874
|
-
}
|
|
1571
|
+
return new Map([...formats].filter(([id, format]) => {
|
|
1572
|
+
const version = prunableVersion(format);
|
|
1573
|
+
return version === null || best.get(`${format.name}@${version.major}`)?.format.id === id;
|
|
1574
|
+
}));
|
|
875
1575
|
}
|
|
876
1576
|
/**
|
|
877
|
-
* Get the
|
|
1577
|
+
* Get the format search directories, lowest rank first: the story format subdirectories of the
|
|
1578
|
+
* home directory, then of the working directory, then each `TWEEGO_PATH` entry, then `extraPaths`
|
|
1579
|
+
* (`formatPaths`). A later directory outranks an earlier one for the same format folder name, so
|
|
1580
|
+
* `formatPaths` override `TWEEGO_PATH`, which overrides the home and working directories, as in
|
|
1581
|
+
* Tweego.
|
|
878
1582
|
*/
|
|
879
1583
|
function getFormatSearchDirs(extraPaths = [], useTweegoPath = true) {
|
|
880
1584
|
const subdirNames = [
|
|
@@ -889,41 +1593,27 @@ function getFormatSearchDirs(extraPaths = [], useTweegoPath = true) {
|
|
|
889
1593
|
basePaths.add(homedir());
|
|
890
1594
|
} catch {}
|
|
891
1595
|
basePaths.add(process.cwd());
|
|
892
|
-
const dirs = [];
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
try {
|
|
896
|
-
if (statSync(dir).isDirectory()) dirs.push(dir);
|
|
897
|
-
} catch {}
|
|
898
|
-
}
|
|
1596
|
+
const dirs = [...basePaths].flatMap((base) => subdirNames.map((sub) => join(base, sub))).filter(isDirectory);
|
|
1597
|
+
const tweegoPath = useTweegoPath ? process.env.TWEEGO_PATH : void 0;
|
|
1598
|
+
if (tweegoPath) dirs.push(...tweegoPath.split(process.platform === "win32" ? ";" : ":"));
|
|
899
1599
|
dirs.push(...extraPaths);
|
|
900
|
-
if (useTweegoPath) {
|
|
901
|
-
const tweegoPath = process.env.TWEEGO_PATH;
|
|
902
|
-
if (tweegoPath) dirs.push(...tweegoPath.split(process.platform === "win32" ? ";" : ":"));
|
|
903
|
-
}
|
|
904
1600
|
return dirs;
|
|
905
1601
|
}
|
|
906
|
-
/**
|
|
1602
|
+
/**
|
|
1603
|
+
* Parse a version string into [major, minor, patch]. Accepts what {@link parseVersion} accepts
|
|
1604
|
+
* (`v1.2.3`, `1.2`, `2.0.0-beta.1`); the prerelease and build metadata are dropped.
|
|
1605
|
+
*/
|
|
907
1606
|
function parseSemver(v) {
|
|
908
|
-
const
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
];
|
|
915
|
-
}
|
|
916
|
-
function semverCompare(a, b) {
|
|
917
|
-
const [aMajor, aMinor, aPatch] = a;
|
|
918
|
-
const [bMajor, bMinor, bPatch] = b;
|
|
919
|
-
if (aMajor !== bMajor) return aMajor - bMajor;
|
|
920
|
-
if (aMinor !== bMinor) return aMinor - bMinor;
|
|
921
|
-
if (aPatch !== bPatch) return aPatch - bPatch;
|
|
922
|
-
return 0;
|
|
1607
|
+
const parsed = parseVersion(v);
|
|
1608
|
+
return parsed ? [
|
|
1609
|
+
parsed.major,
|
|
1610
|
+
parsed.minor,
|
|
1611
|
+
parsed.patch
|
|
1612
|
+
] : null;
|
|
923
1613
|
}
|
|
924
1614
|
/** Build a directory-style format ID from a name and version, e.g. ('SugarCube', '2.37.3') → 'sugarcube-2'. */
|
|
925
1615
|
function makeFormatId(name, version) {
|
|
926
|
-
const major =
|
|
1616
|
+
const major = parseVersion(version)?.major ?? 0;
|
|
927
1617
|
return `${name.toLowerCase().replace(/\s+/g, "-")}-${major}`;
|
|
928
1618
|
}
|
|
929
1619
|
/** Describe a format request for diagnostics. */
|
|
@@ -934,81 +1624,164 @@ function describeFormatRequest(request) {
|
|
|
934
1624
|
default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
|
|
935
1625
|
}
|
|
936
1626
|
}
|
|
937
|
-
/**
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
* - An ID request ('sugarcube-2') matches a candidate whose name and major version build that ID,
|
|
941
|
-
* and takes the greatest such version.
|
|
942
|
-
* - A name request matches the name case-insensitively. An exact version wins outright; otherwise
|
|
943
|
-
* the greatest version with the same major that is not older than the one asked for (or, with
|
|
944
|
-
* `allowOlder`, the greatest same-major version). An unparseable requested version takes the
|
|
945
|
-
* greatest version of any major.
|
|
946
|
-
*/
|
|
947
|
-
function selectFormatCandidate(request, candidates, describe, options = {}) {
|
|
948
|
-
const allowOlder = options.allowOlder ?? false;
|
|
949
|
-
const wanted = request.kind === "name" ? parseSemver(request.version) : null;
|
|
1627
|
+
/** {@link selectFormatCandidate} over one set of candidates, matching names without regard to case. */
|
|
1628
|
+
function pickFormatCandidate(request, candidates, describe, allowOlder) {
|
|
1629
|
+
const wanted = request.kind === "name" ? parseVersion(request.version) : null;
|
|
950
1630
|
let best;
|
|
951
1631
|
for (const candidate of candidates) {
|
|
952
1632
|
const { name, version } = describe(candidate);
|
|
953
|
-
const have =
|
|
1633
|
+
const have = parseVersion(version);
|
|
954
1634
|
if (!have) continue;
|
|
955
1635
|
if (request.kind === "id") {
|
|
956
1636
|
if (makeFormatId(name, version) !== request.id.toLowerCase()) continue;
|
|
957
1637
|
} else {
|
|
958
1638
|
if (name.toLowerCase() !== request.name.toLowerCase()) continue;
|
|
959
1639
|
if (wanted) {
|
|
960
|
-
|
|
961
|
-
if (
|
|
962
|
-
if (
|
|
1640
|
+
const order = compareVersions(have, wanted);
|
|
1641
|
+
if (order === 0) return candidate;
|
|
1642
|
+
if (have.major !== wanted.major) continue;
|
|
1643
|
+
if (!allowOlder && order < 0) continue;
|
|
963
1644
|
}
|
|
964
1645
|
}
|
|
965
|
-
if (!best ||
|
|
1646
|
+
if (!best || compareVersions(have, best.version) > 0) best = {
|
|
966
1647
|
candidate,
|
|
967
1648
|
version: have
|
|
968
1649
|
};
|
|
969
1650
|
}
|
|
970
1651
|
return best?.candidate;
|
|
971
1652
|
}
|
|
972
|
-
/**
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
1653
|
+
/**
|
|
1654
|
+
* Pick the candidate that best answers a format request. Local folders, the download cache and
|
|
1655
|
+
* remote indices all select through this one function.
|
|
1656
|
+
*
|
|
1657
|
+
* - An ID request ('sugarcube-2') matches, without regard to case, a candidate whose name and
|
|
1658
|
+
* major version build that ID, and takes the greatest such version.
|
|
1659
|
+
* - A name request matches the name without regard to case, but a candidate whose name matches in
|
|
1660
|
+
* case too is preferred when one answers the request. An exact version (prerelease included)
|
|
1661
|
+
* wins outright; otherwise the greatest version with the same major that is not older than the
|
|
1662
|
+
* one asked for (or, with `allowOlder`, the greatest same-major version). An unparseable
|
|
1663
|
+
* requested version takes the greatest version of any major.
|
|
1664
|
+
*
|
|
1665
|
+
* Versions compare by SemVer precedence ({@link compareVersions}), so a release outranks its
|
|
1666
|
+
* prereleases. Between equal versions, the earlier candidate wins.
|
|
1667
|
+
*/
|
|
1668
|
+
function selectFormatCandidate(request, candidates, describe, options = {}) {
|
|
1669
|
+
const allowOlder = options.allowOlder ?? false;
|
|
1670
|
+
if (request.kind === "name") {
|
|
1671
|
+
const preferred = pickFormatCandidate(request, candidates.filter((c) => describe(c).name === request.name), describe, allowOlder);
|
|
1672
|
+
if (preferred !== void 0) return preferred;
|
|
987
1673
|
}
|
|
988
|
-
return
|
|
1674
|
+
return pickFormatCandidate(request, candidates, describe, allowOlder);
|
|
1675
|
+
}
|
|
1676
|
+
/** The Twine 2 formats of a discovered map, those from higher-ranked search directories first. */
|
|
1677
|
+
function rankedTwine2Formats(formats) {
|
|
1678
|
+
return [...formats.values()].filter((f) => f.isTwine2).reverse();
|
|
1679
|
+
}
|
|
1680
|
+
/**
|
|
1681
|
+
* Look a format ID up among discovered folders. The ID matches a folder name without regard to
|
|
1682
|
+
* case; an exact-case folder is preferred, then the highest-ranked one.
|
|
1683
|
+
*/
|
|
1684
|
+
function findFormatById(formats, id) {
|
|
1685
|
+
const exact = formats.get(id);
|
|
1686
|
+
if (exact) return exact;
|
|
1687
|
+
const wanted = id.toLowerCase();
|
|
1688
|
+
return [...formats.values()].reverse().find((f) => f.id.toLowerCase() === wanted);
|
|
1689
|
+
}
|
|
1690
|
+
/**
|
|
1691
|
+
* Read the story format source (for Twine 2, extract the `source` property from JSON).
|
|
1692
|
+
* `diagnostics` receives a warning when the format file is not valid UTF-8 (it is read as Windows-1252).
|
|
1693
|
+
*/
|
|
1694
|
+
function readFormatSource(format, diagnostics) {
|
|
1695
|
+
const source = readUTF8(format.filename, diagnostics);
|
|
1696
|
+
if (!format.isTwine2) return source;
|
|
1697
|
+
const decoded = decodeFormatJSON(source);
|
|
1698
|
+
if (!decoded.ok) throw new Error(`Cannot parse format ${format.id} JSON: ${decoded.reason}`);
|
|
1699
|
+
return decoded.data.source;
|
|
989
1700
|
}
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1701
|
+
//#endregion
|
|
1702
|
+
//#region src/atomic-write.ts
|
|
1703
|
+
/**
|
|
1704
|
+
* Atomic file replacement, for every file twee-ts writes that something else may read
|
|
1705
|
+
* meanwhile: build output (which live-reload servers read on each change) and downloaded
|
|
1706
|
+
* story formats (which other twee-ts processes share).
|
|
1707
|
+
*/
|
|
1708
|
+
/**
|
|
1709
|
+
* Writes `data` to `path` so that a reader sees either the previous file or the complete new
|
|
1710
|
+
* one, never a partly written file. The data goes to a temporary file in the same folder,
|
|
1711
|
+
* which is then renamed over `path`. When `path` is a symlink, the file it points to is
|
|
1712
|
+
* replaced, and an existing file keeps its permissions.
|
|
1713
|
+
*
|
|
1714
|
+
* On failure (a full disk, a missing folder) the temporary file is removed, `path` is left
|
|
1715
|
+
* as it was, and the error is thrown with `path` in its message and its original `code`.
|
|
1716
|
+
*/
|
|
1717
|
+
function writeFileAtomic(path, data) {
|
|
1718
|
+
const target = followSymlink(path);
|
|
1719
|
+
const temp = join(dirname(target), `.${basename(target)}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
|
|
1720
|
+
try {
|
|
1721
|
+
writeFileSync(temp, data, {
|
|
1722
|
+
encoding: "utf-8",
|
|
1723
|
+
flag: "wx"
|
|
1724
|
+
});
|
|
1725
|
+
keepPermissions(target, temp);
|
|
1726
|
+
renameSync(temp, target);
|
|
1727
|
+
} catch (e) {
|
|
1728
|
+
rmSync(temp, { force: true });
|
|
1729
|
+
throw writeError(path, e);
|
|
997
1730
|
}
|
|
998
|
-
|
|
1731
|
+
}
|
|
1732
|
+
/** The file a symlink at `path` points to, or `path` itself (also when it doesn't exist yet). */
|
|
1733
|
+
function followSymlink(path) {
|
|
1734
|
+
try {
|
|
1735
|
+
return realpathSync(path);
|
|
1736
|
+
} catch {
|
|
1737
|
+
return path;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
/**
|
|
1741
|
+
* Gives `temp` the permission bits of `target`, when `target` exists. Best effort: on a file
|
|
1742
|
+
* system without Unix permissions chmod can fail, and writing in place never kept them there either.
|
|
1743
|
+
*/
|
|
1744
|
+
function keepPermissions(target, temp) {
|
|
1745
|
+
const existing = statSync(target, { throwIfNoEntry: false });
|
|
1746
|
+
if (existing === void 0) return;
|
|
1747
|
+
try {
|
|
1748
|
+
chmodSync(temp, existing.mode & 4095);
|
|
1749
|
+
} catch {}
|
|
1750
|
+
}
|
|
1751
|
+
/** `cause` as an error that names `path` and keeps the original error code. */
|
|
1752
|
+
function writeError(path, cause) {
|
|
1753
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
1754
|
+
const error = new Error(`Cannot write ${path}: ${message}`, { cause });
|
|
1755
|
+
if (cause instanceof Error && "code" in cause) error.code = cause.code;
|
|
1756
|
+
return error;
|
|
999
1757
|
}
|
|
1000
1758
|
//#endregion
|
|
1001
1759
|
//#region src/remote-formats.ts
|
|
1002
1760
|
/**
|
|
1003
1761
|
* Remote story format fetching, caching, and checksum verification.
|
|
1004
1762
|
* Uses the Story Formats Archive (SFA) as the default source.
|
|
1763
|
+
*
|
|
1764
|
+
* Two caches hold downloads. Formats found through an index are shared by name and version
|
|
1765
|
+
* (`<cache>/<name>/<version>/format.js`). A format downloaded from a direct URL is kept under
|
|
1766
|
+
* that URL instead, so one project's copy never answers another project's request.
|
|
1767
|
+
* Cache files are replaced atomically, so another process never reads a partly written one.
|
|
1005
1768
|
*/
|
|
1006
1769
|
const DEFAULT_SFA_INDICES = ["https://videlais.github.io/story-formats-archive/official/index.json", "https://videlais.github.io/story-formats-archive/unofficial/index.json"];
|
|
1770
|
+
/** The longest delay a timer accepts; a longer timeout means no limit. */
|
|
1771
|
+
const MAX_TIMER_DELAY = 2147483647;
|
|
1007
1772
|
/** Get the cache directory for downloaded story formats. */
|
|
1008
1773
|
function getCacheDir() {
|
|
1009
1774
|
const base = process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache");
|
|
1010
1775
|
return join(base, "twee-ts", "storyformats");
|
|
1011
1776
|
}
|
|
1777
|
+
/** The cache directory for formats downloaded from direct URLs, beside {@link getCacheDir}. */
|
|
1778
|
+
function getUrlCacheDir() {
|
|
1779
|
+
return join(dirname(getCacheDir()), "storyformat-urls");
|
|
1780
|
+
}
|
|
1781
|
+
/** The name of one direct URL's cache entry: a hash of the URL, so each URL has its own. */
|
|
1782
|
+
function urlCacheKey(url) {
|
|
1783
|
+
return createHash("sha256").update(url).digest("hex");
|
|
1784
|
+
}
|
|
1012
1785
|
/**
|
|
1013
1786
|
* Whether a string can serve as one directory name inside the cache: not empty, not `.` or `..`,
|
|
1014
1787
|
* and free of path separators. Format names and versions come from downloaded metadata, so they
|
|
@@ -1018,7 +1791,7 @@ function isSafeSegment(segment) {
|
|
|
1018
1791
|
return segment !== "" && segment !== "." && segment !== ".." && !/[/\\\0]/.test(segment);
|
|
1019
1792
|
}
|
|
1020
1793
|
/** Whether absolute path `target` lies strictly inside absolute path `root`. */
|
|
1021
|
-
function isInside(root, target) {
|
|
1794
|
+
function isInside$1(root, target) {
|
|
1022
1795
|
const rel = relative(root, target);
|
|
1023
1796
|
return rel !== "" && !isAbsolute(rel) && rel.split(sep)[0] !== "..";
|
|
1024
1797
|
}
|
|
@@ -1028,28 +1801,41 @@ function cachedFormatDir(name, version) {
|
|
|
1028
1801
|
if (!isSafeSegment(version) || !parseSemver(version)) throw new Error(`Refusing to cache story format "${name}" with an unsafe version: ${JSON.stringify(version)}`);
|
|
1029
1802
|
const root = resolve(getCacheDir());
|
|
1030
1803
|
const dir = resolve(root, name, version);
|
|
1031
|
-
if (!isInside(root, dir)) throw new Error(`Refusing to cache a story format outside the cache directory ${root}: ${dir}`);
|
|
1804
|
+
if (!isInside$1(root, dir)) throw new Error(`Refusing to cache a story format outside the cache directory ${root}: ${dir}`);
|
|
1032
1805
|
return dir;
|
|
1033
1806
|
}
|
|
1034
|
-
/**
|
|
1807
|
+
/** Whether `e` is a Node.js system error with the given code. */
|
|
1808
|
+
function hasErrorCode(e, code) {
|
|
1809
|
+
return e instanceof Error && "code" in e && e.code === code;
|
|
1810
|
+
}
|
|
1811
|
+
/**
|
|
1812
|
+
* Create `dir` as a plain directory, or check that it already is one (a symlink could lead out of the cache).
|
|
1813
|
+
* A directory another process creates between the check and the mkdir is fine.
|
|
1814
|
+
*/
|
|
1035
1815
|
function ensurePlainDirectory(dir) {
|
|
1036
|
-
|
|
1037
|
-
if (stat === void 0) {
|
|
1816
|
+
if (lstatSync(dir, { throwIfNoEntry: false }) === void 0) try {
|
|
1038
1817
|
mkdirSync(dir);
|
|
1039
1818
|
return;
|
|
1819
|
+
} catch (e) {
|
|
1820
|
+
if (!hasErrorCode(e, "EEXIST")) throw e;
|
|
1040
1821
|
}
|
|
1041
|
-
|
|
1822
|
+
const stat = lstatSync(dir, { throwIfNoEntry: false });
|
|
1823
|
+
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`);
|
|
1042
1824
|
}
|
|
1043
|
-
/**
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1825
|
+
/**
|
|
1826
|
+
* Write a downloaded format.js to `<root>/<...segments>/format.js`, replacing any earlier copy
|
|
1827
|
+
* atomically, and return its path. Every directory below `root` must be a plain one.
|
|
1828
|
+
*/
|
|
1829
|
+
function writeCacheEntry(root, segments, text) {
|
|
1047
1830
|
mkdirSync(root, { recursive: true });
|
|
1048
|
-
|
|
1049
|
-
|
|
1831
|
+
const dir = segments.reduce((parent, segment) => {
|
|
1832
|
+
const child = join(parent, segment);
|
|
1833
|
+
ensurePlainDirectory(child);
|
|
1834
|
+
return child;
|
|
1835
|
+
}, root);
|
|
1050
1836
|
const formatPath = join(dir, "format.js");
|
|
1051
1837
|
if (lstatSync(formatPath, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error(`Refusing to write a story format outside the cache directory: ${formatPath} is a symlink`);
|
|
1052
|
-
|
|
1838
|
+
writeFileAtomic(formatPath, text);
|
|
1053
1839
|
return formatPath;
|
|
1054
1840
|
}
|
|
1055
1841
|
/** In-memory index cache, keyed by URL. Cleared each compile. */
|
|
@@ -1058,6 +1844,78 @@ const indexCache = /* @__PURE__ */ new Map();
|
|
|
1058
1844
|
function clearIndexCache() {
|
|
1059
1845
|
indexCache.clear();
|
|
1060
1846
|
}
|
|
1847
|
+
/** Requests in progress, by what they fetch, so concurrent compiles make each request once. */
|
|
1848
|
+
const sharedRequests = /* @__PURE__ */ new Map();
|
|
1849
|
+
/**
|
|
1850
|
+
* Runs `start` once for all callers that ask for `key` while it is in progress. A caller whose
|
|
1851
|
+
* `signal` aborts stops waiting at once and rejects with the signal's reason; the request
|
|
1852
|
+
* itself is aborted (through the signal `start` receives) once no caller waits on it.
|
|
1853
|
+
*/
|
|
1854
|
+
function shareRequest(key, signal, start) {
|
|
1855
|
+
if (signal?.aborted) return Promise.reject(signal.reason);
|
|
1856
|
+
const request = sharedRequests.get(key) ?? startSharedRequest(key, start);
|
|
1857
|
+
request.waiters++;
|
|
1858
|
+
return new Promise((resolve, reject) => {
|
|
1859
|
+
let waiting = true;
|
|
1860
|
+
const stopWaiting = () => {
|
|
1861
|
+
if (!waiting) return;
|
|
1862
|
+
waiting = false;
|
|
1863
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1864
|
+
request.waiters--;
|
|
1865
|
+
};
|
|
1866
|
+
const onAbort = () => {
|
|
1867
|
+
stopWaiting();
|
|
1868
|
+
if (request.waiters === 0) {
|
|
1869
|
+
if (sharedRequests.get(key) === request) sharedRequests.delete(key);
|
|
1870
|
+
request.controller.abort(signal?.reason);
|
|
1871
|
+
}
|
|
1872
|
+
reject(signal?.reason);
|
|
1873
|
+
};
|
|
1874
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1875
|
+
request.promise.then((value) => {
|
|
1876
|
+
stopWaiting();
|
|
1877
|
+
resolve(value);
|
|
1878
|
+
}, (e) => {
|
|
1879
|
+
stopWaiting();
|
|
1880
|
+
reject(e);
|
|
1881
|
+
});
|
|
1882
|
+
});
|
|
1883
|
+
}
|
|
1884
|
+
function startSharedRequest(key, start) {
|
|
1885
|
+
const controller = new AbortController();
|
|
1886
|
+
const request = {
|
|
1887
|
+
promise: start(controller.signal),
|
|
1888
|
+
controller,
|
|
1889
|
+
waiters: 0
|
|
1890
|
+
};
|
|
1891
|
+
sharedRequests.set(key, request);
|
|
1892
|
+
const forget = () => {
|
|
1893
|
+
if (sharedRequests.get(key) === request) sharedRequests.delete(key);
|
|
1894
|
+
};
|
|
1895
|
+
request.promise.then(forget, forget);
|
|
1896
|
+
return request;
|
|
1897
|
+
}
|
|
1898
|
+
/** The time limit for each request, from the caller's options. */
|
|
1899
|
+
function requestTimeout(options) {
|
|
1900
|
+
const timeout = options.timeout ?? 3e4;
|
|
1901
|
+
if (!(timeout >= 0)) throw new RangeError(`A story format request timeout must be 0 or more milliseconds, not ${timeout}`);
|
|
1902
|
+
return timeout;
|
|
1903
|
+
}
|
|
1904
|
+
/**
|
|
1905
|
+
* Fetch `url` as text, aborting when `signal` does or after `timeout` milliseconds (0: no limit).
|
|
1906
|
+
* `what` names the request in errors, as in "Failed to <what> from <url>".
|
|
1907
|
+
*/
|
|
1908
|
+
async function fetchText(url, what, signal, timeout) {
|
|
1909
|
+
const limit = timeout > 0 && timeout <= MAX_TIMER_DELAY ? AbortSignal.timeout(timeout) : void 0;
|
|
1910
|
+
try {
|
|
1911
|
+
const res = await fetch(url, { signal: limit ? AbortSignal.any([signal, limit]) : signal });
|
|
1912
|
+
if (!res.ok) throw new Error(`Failed to ${what} from ${url}: ${res.status} ${res.statusText}`);
|
|
1913
|
+
return await res.text();
|
|
1914
|
+
} catch (e) {
|
|
1915
|
+
if (limit?.aborted && !signal.aborted) throw new Error(`Failed to ${what} from ${url}: timed out after ${timeout} ms`, { cause: e });
|
|
1916
|
+
throw e;
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1061
1919
|
/** Validate that a JSON value is a valid SFAIndexEntry. */
|
|
1062
1920
|
function isValidEntry(val) {
|
|
1063
1921
|
if (typeof val !== "object" || val === null || Array.isArray(val)) return false;
|
|
@@ -1073,15 +1931,18 @@ function validateSFAIndex(json) {
|
|
|
1073
1931
|
twine2: Array.isArray(obj["twine2"]) ? obj["twine2"].filter(isValidEntry) : []
|
|
1074
1932
|
};
|
|
1075
1933
|
}
|
|
1076
|
-
/** Fetch and parse an SFA index.json, with in-memory caching. */
|
|
1077
|
-
async function fetchIndex(url) {
|
|
1934
|
+
/** Fetch and parse an SFA index.json, with in-memory caching. Concurrent calls for one URL share a request. */
|
|
1935
|
+
async function fetchIndex(url, options = {}) {
|
|
1936
|
+
options.signal?.throwIfAborted();
|
|
1078
1937
|
const cached = indexCache.get(url);
|
|
1079
1938
|
if (cached) return cached;
|
|
1080
|
-
const
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1939
|
+
const timeout = requestTimeout(options);
|
|
1940
|
+
return shareRequest(`index\0${url}`, options.signal, async (signal) => {
|
|
1941
|
+
const text = await fetchText(url, "fetch format index", signal, timeout);
|
|
1942
|
+
const data = validateSFAIndex(JSON.parse(text));
|
|
1943
|
+
indexCache.set(url, data);
|
|
1944
|
+
return data;
|
|
1945
|
+
});
|
|
1085
1946
|
}
|
|
1086
1947
|
/** Find the best matching entry in an SFA index for a name or ID request (twine2 entries first). */
|
|
1087
1948
|
function findEntryForRequest(index, request) {
|
|
@@ -1102,84 +1963,138 @@ async function verifySHA256(content, expectedHex) {
|
|
|
1102
1963
|
function getDownloadUrl(indexUrl, entry, formatType) {
|
|
1103
1964
|
return `${indexUrl.replace(/\/index\.json$/, "")}/${formatType}/${entry.name}/${entry.version}/format.js`;
|
|
1104
1965
|
}
|
|
1105
|
-
/**
|
|
1106
|
-
|
|
1107
|
-
cachedFormatDir(entry.name, entry.version);
|
|
1108
|
-
const res = await fetch(downloadUrl);
|
|
1109
|
-
if (!res.ok) throw new Error(`Failed to download format from ${downloadUrl}: ${res.status} ${res.statusText}`);
|
|
1110
|
-
const text = await res.text();
|
|
1111
|
-
const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
|
|
1112
|
-
if (checksumKey) {
|
|
1113
|
-
const expected = entry.checksums[checksumKey];
|
|
1114
|
-
if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
|
|
1115
|
-
if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
|
|
1116
|
-
}
|
|
1117
|
-
const id = makeFormatId(entry.name, entry.version);
|
|
1118
|
-
const data = parseFormatJSON(text, id);
|
|
1119
|
-
if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
|
|
1966
|
+
/** The StoryFormatInfo of a Twine 2 format.js cached at `filename`. */
|
|
1967
|
+
function cachedFormatInfo(id, filename, data) {
|
|
1120
1968
|
return {
|
|
1121
1969
|
id,
|
|
1122
|
-
filename
|
|
1970
|
+
filename,
|
|
1123
1971
|
isTwine2: true,
|
|
1124
1972
|
name: data.name,
|
|
1125
1973
|
version: data.version,
|
|
1126
1974
|
proofing: data.proofing ?? false
|
|
1127
1975
|
};
|
|
1128
1976
|
}
|
|
1129
|
-
/**
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
return {
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1977
|
+
/**
|
|
1978
|
+
* Download a format, verify its checksum, write it to the cache shared by name and version, and
|
|
1979
|
+
* return its StoryFormatInfo. Concurrent calls for one download URL share one request.
|
|
1980
|
+
*/
|
|
1981
|
+
async function fetchAndCacheFormat(entry, downloadUrl, options = {}) {
|
|
1982
|
+
cachedFormatDir(entry.name, entry.version);
|
|
1983
|
+
const timeout = requestTimeout(options);
|
|
1984
|
+
return shareRequest(`format\0${getCacheDir()}\0${downloadUrl}`, options.signal, async (signal) => {
|
|
1985
|
+
const text = await fetchText(downloadUrl, "download format", signal, timeout);
|
|
1986
|
+
const checksumKey = Object.keys(entry.checksums ?? {}).find((k) => k.endsWith("format.js"));
|
|
1987
|
+
if (checksumKey) {
|
|
1988
|
+
const expected = entry.checksums[checksumKey];
|
|
1989
|
+
if (!expected) throw new Error(`Missing checksum value for key "${checksumKey}"`);
|
|
1990
|
+
if (!await verifySHA256(new TextEncoder().encode(text), expected)) throw new Error(`Checksum verification failed for ${entry.name} ${entry.version}`);
|
|
1991
|
+
}
|
|
1992
|
+
const id = makeFormatId(entry.name, entry.version);
|
|
1993
|
+
const data = parseFormatJSON(text, id);
|
|
1994
|
+
if (!data) throw new Error(`Failed to parse format JSON from ${downloadUrl}`);
|
|
1995
|
+
signal.throwIfAborted();
|
|
1996
|
+
return cachedFormatInfo(id, writeCacheEntry(resolve(getCacheDir()), [entry.name, entry.version], text), data);
|
|
1997
|
+
});
|
|
1998
|
+
}
|
|
1999
|
+
/**
|
|
2000
|
+
* Download a direct format.js URL, parse its JSON, cache it under that URL (apart from the
|
|
2001
|
+
* downloads shared by name and version), and return its StoryFormatInfo. Concurrent calls for
|
|
2002
|
+
* one URL share one request.
|
|
2003
|
+
*/
|
|
2004
|
+
async function fetchDirectFormat(url, options = {}) {
|
|
2005
|
+
const timeout = requestTimeout(options);
|
|
2006
|
+
return shareRequest(`url\0${getUrlCacheDir()}\0${url}`, options.signal, async (signal) => {
|
|
2007
|
+
const text = await fetchText(url, "download format", signal, timeout);
|
|
2008
|
+
const data = parseFormatJSON(text);
|
|
2009
|
+
if (!data) throw new Error(`Failed to parse format JSON from ${url}`);
|
|
2010
|
+
cachedFormatDir(data.name, data.version);
|
|
2011
|
+
signal.throwIfAborted();
|
|
2012
|
+
const formatPath = writeCacheEntry(getUrlCacheDir(), [urlCacheKey(url)], text);
|
|
2013
|
+
return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
|
|
2014
|
+
});
|
|
2015
|
+
}
|
|
2016
|
+
/** The copy of a direct format URL in the download cache, if it has been downloaded before. */
|
|
2017
|
+
function getCachedDirectFormat(url) {
|
|
2018
|
+
const formatPath = join(getUrlCacheDir(), urlCacheKey(url), "format.js");
|
|
2019
|
+
try {
|
|
2020
|
+
if (!existsSync(formatPath)) return void 0;
|
|
2021
|
+
const data = parseFormatJSON(readUTF8(formatPath));
|
|
2022
|
+
if (!data) return void 0;
|
|
2023
|
+
return cachedFormatInfo(makeFormatId(data.name, data.version), formatPath, data);
|
|
2024
|
+
} catch {
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
/**
|
|
2029
|
+
* Look a request up among direct format URLs, in order, and return the first format that
|
|
2030
|
+
* answers it. A URL downloaded before is answered by its cached copy without the network;
|
|
2031
|
+
* any other is downloaded (unless `offline`). When none answers and a download failed, the
|
|
2032
|
+
* last failure is thrown. An aborted signal rejects with its reason and tries no further URL.
|
|
2033
|
+
*
|
|
2034
|
+
* Internal, for format resolution; not part of the public API.
|
|
2035
|
+
*/
|
|
2036
|
+
async function resolveFormatUrls(request, urls, options = {}) {
|
|
2037
|
+
let lastError;
|
|
2038
|
+
for (const url of urls) {
|
|
2039
|
+
const cached = getCachedDirectFormat(url);
|
|
2040
|
+
if (cached) {
|
|
2041
|
+
if (answers(request, cached)) return cached;
|
|
2042
|
+
continue;
|
|
2043
|
+
}
|
|
2044
|
+
if (options.offline) continue;
|
|
2045
|
+
try {
|
|
2046
|
+
const info = await fetchDirectFormat(url, options);
|
|
2047
|
+
if (answers(request, info)) return info;
|
|
2048
|
+
} catch (e) {
|
|
2049
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
2050
|
+
lastError = toError(e);
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
if (lastError) throw lastError;
|
|
2054
|
+
}
|
|
2055
|
+
/** Whether `info` is a format the request accepts. */
|
|
2056
|
+
function answers(request, info) {
|
|
2057
|
+
return selectFormatCandidate(request, [info], (f) => f) !== void 0;
|
|
2058
|
+
}
|
|
2059
|
+
function toError(e) {
|
|
2060
|
+
return e instanceof Error ? e : new Error(String(e));
|
|
1144
2061
|
}
|
|
1145
2062
|
/**
|
|
1146
2063
|
* Resolve a story format request remotely, in the same order as {@link resolveRemoteFormat}.
|
|
1147
2064
|
* An ID request such as 'sugarcube-2' matches the format whose name and major version build that ID
|
|
1148
2065
|
* (SugarCube 2.x), taking the greatest version available.
|
|
1149
2066
|
*/
|
|
1150
|
-
async function resolveRemoteFormatRequest(request, indices, urls) {
|
|
2067
|
+
async function resolveRemoteFormatRequest(request, indices, urls, options = {}) {
|
|
2068
|
+
options.signal?.throwIfAborted();
|
|
1151
2069
|
let lastError;
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
const info = await fetchDirectFormat(url);
|
|
1156
|
-
if (selectFormatCandidate(request, [info], (f) => f)) return info;
|
|
2070
|
+
try {
|
|
2071
|
+
const direct = await resolveFormatUrls(request, urls ?? [], options);
|
|
2072
|
+
if (direct) return direct;
|
|
1157
2073
|
} catch (e) {
|
|
1158
|
-
|
|
2074
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
2075
|
+
lastError = toError(e);
|
|
1159
2076
|
}
|
|
2077
|
+
const cached = findCachedFormat(request);
|
|
2078
|
+
if (cached && request.kind === "name" && sameVersion(cached.version, request.version)) return cached;
|
|
1160
2079
|
const allIndices = [...indices ?? [], ...DEFAULT_SFA_INDICES];
|
|
1161
2080
|
for (const indexUrl of allIndices) try {
|
|
1162
|
-
const result = findEntryForRequest(await fetchIndex(indexUrl), request);
|
|
2081
|
+
const result = findEntryForRequest(await fetchIndex(indexUrl, options), request);
|
|
1163
2082
|
if (result) {
|
|
1164
2083
|
const hit = getCachedFormat(result.entry.name, result.entry.version);
|
|
1165
2084
|
if (hit) return hit;
|
|
1166
2085
|
const downloadUrl = getDownloadUrl(indexUrl, result.entry, result.formatType);
|
|
1167
|
-
return await fetchAndCacheFormat(result.entry, downloadUrl);
|
|
2086
|
+
return await fetchAndCacheFormat(result.entry, downloadUrl, options);
|
|
1168
2087
|
}
|
|
1169
2088
|
} catch (e) {
|
|
1170
|
-
|
|
2089
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
2090
|
+
lastError = toError(e);
|
|
1171
2091
|
}
|
|
1172
2092
|
if (cached) return cached;
|
|
1173
2093
|
if (lastError) throw lastError;
|
|
1174
2094
|
}
|
|
1175
|
-
/** Whether two version strings name the same SemVer version. */
|
|
1176
|
-
function isExactVersion(have, wanted) {
|
|
1177
|
-
const a = parseSemver(have);
|
|
1178
|
-
const b = parseSemver(wanted);
|
|
1179
|
-
return a !== null && b !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
|
|
1180
|
-
}
|
|
1181
2095
|
/**
|
|
1182
2096
|
* Find the cached download that best answers a format request, without network access.
|
|
2097
|
+
* Only the downloads shared by name and version are searched, not those from direct URLs.
|
|
1183
2098
|
* Matching follows {@link selectFormatCandidate}.
|
|
1184
2099
|
*/
|
|
1185
2100
|
function findCachedFormat(request, options = {}) {
|
|
@@ -1191,18 +2106,11 @@ function getCachedFormat(name, version) {
|
|
|
1191
2106
|
const formatPath = join(getCacheDir(), name, version, "format.js");
|
|
1192
2107
|
try {
|
|
1193
2108
|
if (!existsSync(formatPath)) return void 0;
|
|
1194
|
-
const source =
|
|
2109
|
+
const source = readUTF8(formatPath);
|
|
1195
2110
|
const id = makeFormatId(name, version);
|
|
1196
2111
|
const data = parseFormatJSON(source, id);
|
|
1197
2112
|
if (!data) return void 0;
|
|
1198
|
-
return
|
|
1199
|
-
id,
|
|
1200
|
-
filename: formatPath,
|
|
1201
|
-
isTwine2: true,
|
|
1202
|
-
name: data.name,
|
|
1203
|
-
version: data.version,
|
|
1204
|
-
proofing: data.proofing ?? false
|
|
1205
|
-
};
|
|
2113
|
+
return cachedFormatInfo(id, formatPath, data);
|
|
1206
2114
|
} catch {
|
|
1207
2115
|
return;
|
|
1208
2116
|
}
|
|
@@ -1260,44 +2168,65 @@ function formatRequestFor(formatId, storyFormat, storyFormatVersion, defaultId)
|
|
|
1260
2168
|
id: defaultId
|
|
1261
2169
|
};
|
|
1262
2170
|
}
|
|
1263
|
-
/**
|
|
1264
|
-
|
|
2171
|
+
/**
|
|
2172
|
+
* Look a request up among the formats found in local format directories. An ID names a folder, so
|
|
2173
|
+
* it is looked up among all of them; a name request selects by SemVer among the pruned formats.
|
|
2174
|
+
*/
|
|
2175
|
+
function findLocalFormat(all, pruned, request) {
|
|
1265
2176
|
switch (request.kind) {
|
|
1266
|
-
case "id": return
|
|
1267
|
-
case "name":
|
|
1268
|
-
const id = getFormatIdByNameAndVersion(formats, request.name, request.version);
|
|
1269
|
-
return id === void 0 ? void 0 : formats.get(id);
|
|
1270
|
-
}
|
|
2177
|
+
case "id": return findFormatById(all, request.id);
|
|
2178
|
+
case "name": return selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f);
|
|
1271
2179
|
default: throw new Error(`unhandled format request: ${JSON.stringify(request)}`);
|
|
1272
2180
|
}
|
|
1273
2181
|
}
|
|
1274
2182
|
/**
|
|
1275
2183
|
* Resolve a format request to a story format.
|
|
1276
2184
|
*
|
|
1277
|
-
* Order: local format directories, then the
|
|
1278
|
-
*
|
|
2185
|
+
* Order: local format directories, then the project's direct format URLs (each one's cached
|
|
2186
|
+
* copy, else a download unless `noRemote`), then the download cache shared by name and version,
|
|
2187
|
+
* then format indices (unless `noRemote`). Names and IDs match the same way in each (see
|
|
2188
|
+
* `selectFormatCandidate`), so a request that a local format answers never reaches the cache or the
|
|
2189
|
+
* network. Local formats that cannot be used are reported as warnings. When a StoryData request
|
|
2190
|
+
* names a version that none of them has, an older version of
|
|
1279
2191
|
* the same format and major version is used with a warning. Anything else is an error diagnostic,
|
|
1280
2192
|
* and the result is `undefined`.
|
|
1281
2193
|
*/
|
|
1282
2194
|
async function resolveStoryFormat(request, options, diagnostics) {
|
|
1283
2195
|
const noRemote = options.noRemote ?? false;
|
|
1284
|
-
const
|
|
2196
|
+
const all = discoverAllFormats(getFormatSearchDirs(options.formatPaths, options.useTweegoPath ?? true), diagnostics);
|
|
2197
|
+
const pruned = pruneFormats(all);
|
|
1285
2198
|
const wanted = describeFormatRequest(request);
|
|
1286
|
-
const local = findLocalFormat(
|
|
2199
|
+
const local = findLocalFormat(all, pruned, request);
|
|
1287
2200
|
if (local) return local;
|
|
2201
|
+
const fetchOptions = {
|
|
2202
|
+
signal: options.signal,
|
|
2203
|
+
timeout: options.formatFetchTimeout
|
|
2204
|
+
};
|
|
2205
|
+
const warnOnFailure = async (lookup) => {
|
|
2206
|
+
try {
|
|
2207
|
+
return await lookup();
|
|
2208
|
+
} catch (e) {
|
|
2209
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
2210
|
+
diagnostics.push({
|
|
2211
|
+
level: "warning",
|
|
2212
|
+
message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
|
|
2213
|
+
});
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2216
|
+
};
|
|
2217
|
+
const direct = await warnOnFailure(() => resolveFormatUrls(request, options.formatUrls ?? [], {
|
|
2218
|
+
...fetchOptions,
|
|
2219
|
+
offline: noRemote
|
|
2220
|
+
}));
|
|
2221
|
+
if (direct) return direct;
|
|
1288
2222
|
const cached = findCachedFormat(request);
|
|
1289
2223
|
if (cached) return cached;
|
|
1290
|
-
if (!noRemote)
|
|
1291
|
-
const remote = await resolveRemoteFormatRequest(request, options.formatIndices,
|
|
2224
|
+
if (!noRemote) {
|
|
2225
|
+
const remote = await warnOnFailure(() => resolveRemoteFormatRequest(request, options.formatIndices, [], fetchOptions));
|
|
1292
2226
|
if (remote) return remote;
|
|
1293
|
-
} catch (e) {
|
|
1294
|
-
diagnostics.push({
|
|
1295
|
-
level: "warning",
|
|
1296
|
-
message: `Remote format fetch failed for ${wanted}: ${e instanceof Error ? e.message : String(e)}`
|
|
1297
|
-
});
|
|
1298
2227
|
}
|
|
1299
2228
|
if (request.kind === "name") {
|
|
1300
|
-
const older = selectFormatCandidate(request,
|
|
2229
|
+
const older = selectFormatCandidate(request, rankedTwine2Formats(pruned), (f) => f, { allowOlder: true }) ?? findCachedFormat(request, { allowOlder: true });
|
|
1301
2230
|
if (older) {
|
|
1302
2231
|
diagnostics.push({
|
|
1303
2232
|
level: "warning",
|
|
@@ -1309,7 +2238,7 @@ async function resolveStoryFormat(request, options, diagnostics) {
|
|
|
1309
2238
|
const reason = noRemote ? " (remote fetching disabled)" : "";
|
|
1310
2239
|
diagnostics.push({
|
|
1311
2240
|
level: "error",
|
|
1312
|
-
message: `Story format ${wanted} is not available${reason}. Found: ${[...
|
|
2241
|
+
message: `Story format ${wanted} is not available${reason}. Found: ${[...all.keys()].join(", ") || "none"}`
|
|
1313
2242
|
});
|
|
1314
2243
|
}
|
|
1315
2244
|
//#endregion
|
|
@@ -1578,12 +2507,14 @@ function twee2ToV3(s) {
|
|
|
1578
2507
|
* Parse Twee source text into passages.
|
|
1579
2508
|
*
|
|
1580
2509
|
* The source is normalized first, as files are when they are read: a leading UTF-8 BOM is
|
|
1581
|
-
* removed and CRLF and bare CR line endings become LF.
|
|
2510
|
+
* removed and CRLF and bare CR line endings become LF. A BOM at the start of a later line,
|
|
2511
|
+
* directly before `::` (left there by concatenating files), is removed too, so that line
|
|
2512
|
+
* stays a passage header.
|
|
1582
2513
|
*/
|
|
1583
2514
|
function parseTwee(source, options = {}) {
|
|
1584
2515
|
const { filename = "<inline>", trim = true, twee2Compat = false } = options;
|
|
1585
2516
|
const diagnostics = [];
|
|
1586
|
-
const normalized =
|
|
2517
|
+
const normalized = normalizeTweeSourceText(source);
|
|
1587
2518
|
const tweeSource = twee2Compat ? twee2ToV3(normalized) : normalized;
|
|
1588
2519
|
const passages = [];
|
|
1589
2520
|
let current = null;
|
|
@@ -4418,17 +5349,21 @@ function parseDocument(data, options) {
|
|
|
4418
5349
|
* Ported from storyload.go:loadHTML().
|
|
4419
5350
|
*/
|
|
4420
5351
|
/**
|
|
4421
|
-
*
|
|
4422
|
-
*
|
|
5352
|
+
* `decompileHTML()` for loading an HTML file into a compile. The importer's StoryData passage is
|
|
5353
|
+
* checked when it is added to the story being compiled, which reports a missing or invalid IFID,
|
|
5354
|
+
* so the IFID is not reported here as well.
|
|
4423
5355
|
*/
|
|
4424
|
-
function
|
|
5356
|
+
function decompileHTMLForImport(html, options = {}) {
|
|
5357
|
+
return decompile(html, options, { ifid: false });
|
|
5358
|
+
}
|
|
5359
|
+
function decompile(html, options, checks) {
|
|
4425
5360
|
const diagnostics = [];
|
|
4426
5361
|
const story = createStory();
|
|
4427
5362
|
const passageText = options.trim === false ? (text) => text : (text) => text.trim();
|
|
4428
5363
|
const doc = parseDocument(html);
|
|
4429
5364
|
const twine2Data = findElement(doc, "tw-storydata");
|
|
4430
5365
|
if (twine2Data) {
|
|
4431
|
-
decompileTwine2(twine2Data, story, passageText, diagnostics);
|
|
5366
|
+
decompileTwine2(twine2Data, story, passageText, checks, diagnostics);
|
|
4432
5367
|
return {
|
|
4433
5368
|
story,
|
|
4434
5369
|
diagnostics
|
|
@@ -4451,7 +5386,7 @@ function decompileHTML(html, options = {}) {
|
|
|
4451
5386
|
diagnostics
|
|
4452
5387
|
};
|
|
4453
5388
|
}
|
|
4454
|
-
function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
5389
|
+
function decompileTwine2(storyData, story, passageText, checks, diagnostics) {
|
|
4455
5390
|
let startnode = 0;
|
|
4456
5391
|
const attrs = storyData.attribs ?? {};
|
|
4457
5392
|
if (attrs["name"]) story.name = attrs["name"];
|
|
@@ -4463,7 +5398,8 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4463
5398
|
});
|
|
4464
5399
|
else startnode = parsed;
|
|
4465
5400
|
}
|
|
4466
|
-
if (attrs["ifid"]) story.ifid = attrs["ifid"]
|
|
5401
|
+
if (attrs["ifid"]) story.ifid = normalizeIFID(attrs["ifid"]);
|
|
5402
|
+
if (checks.ifid) diagnostics.push(...storyDataIFIDDiagnostics(attrs["ifid"] ?? ""));
|
|
4467
5403
|
if (attrs["zoom"]) {
|
|
4468
5404
|
const parsed = parseFloat(attrs["zoom"]);
|
|
4469
5405
|
if (Number.isNaN(parsed)) diagnostics.push({
|
|
@@ -4476,7 +5412,9 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4476
5412
|
if (attrs["format"]) story.twine2.format = attrs["format"];
|
|
4477
5413
|
if (attrs["format-version"]) story.twine2.formatVersion = attrs["format-version"];
|
|
4478
5414
|
if (attrs["options"]) for (const opt of attrs["options"].split(/\s+/).filter((s) => s.length > 0)) story.twine2.options.set(opt, true);
|
|
4479
|
-
|
|
5415
|
+
const children = storyData.children ?? [];
|
|
5416
|
+
const takenNames = new Set(children.filter((node) => node.type === "tag" && node.name === "tw-passagedata").map(passageDataName));
|
|
5417
|
+
for (const node of children) {
|
|
4480
5418
|
if (node.type !== "tag" && node.type !== "style" && node.type !== "script") continue;
|
|
4481
5419
|
switch (node.name) {
|
|
4482
5420
|
case "style":
|
|
@@ -4484,11 +5422,17 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4484
5422
|
const content = getTextContent(node);
|
|
4485
5423
|
if (content.trim().length === 0) continue;
|
|
4486
5424
|
const text = passageText(content);
|
|
4487
|
-
|
|
4488
|
-
|
|
5425
|
+
const base = node.name === "style" ? "Story Stylesheet" : "Story JavaScript";
|
|
5426
|
+
const name = freeName(base, (n) => !takenNames.has(n));
|
|
5427
|
+
takenNames.add(name);
|
|
5428
|
+
storyAdd(story, withGeneratedName({
|
|
5429
|
+
name,
|
|
4489
5430
|
tags: node.name === "style" ? ["stylesheet"] : ["script"],
|
|
4490
5431
|
text
|
|
4491
|
-
},
|
|
5432
|
+
}, {
|
|
5433
|
+
kind: "code",
|
|
5434
|
+
base
|
|
5435
|
+
}), diagnostics);
|
|
4492
5436
|
break;
|
|
4493
5437
|
}
|
|
4494
5438
|
case "tw-tag": {
|
|
@@ -4501,7 +5445,7 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4501
5445
|
case "tw-passagedata": {
|
|
4502
5446
|
let pid = 0;
|
|
4503
5447
|
const pAttrs = node.attribs ?? {};
|
|
4504
|
-
const name =
|
|
5448
|
+
const name = passageDataName(node);
|
|
4505
5449
|
const tags = pAttrs["tags"] ? pAttrs["tags"].split(/\s+/).filter((s) => s.length > 0) : [];
|
|
4506
5450
|
const metadata = {};
|
|
4507
5451
|
if (pAttrs["pid"]) {
|
|
@@ -4532,11 +5476,29 @@ function decompileTwine2(storyData, story, passageText, diagnostics) {
|
|
|
4532
5476
|
text: marshalStoryData(story)
|
|
4533
5477
|
}, diagnostics);
|
|
4534
5478
|
}
|
|
5479
|
+
function passageDataName(node) {
|
|
5480
|
+
return node.attribs?.["name"] ?? "";
|
|
5481
|
+
}
|
|
5482
|
+
/** Diagnostics for the `ifid` attribute of `tw-storydata`; `value` is empty when it is missing. */
|
|
5483
|
+
function storyDataIFIDDiagnostics(value) {
|
|
5484
|
+
if (value === "") return [{
|
|
5485
|
+
level: "warning",
|
|
5486
|
+
message: "Story IFID not found; the \"tw-storydata\" content attribute \"ifid\" is missing or empty."
|
|
5487
|
+
}];
|
|
5488
|
+
const err = validateIFID(value);
|
|
5489
|
+
if (err === null) return [];
|
|
5490
|
+
return [{
|
|
5491
|
+
level: "warning",
|
|
5492
|
+
message: `Cannot parse "tw-storydata" content attribute "ifid" as an IFID; value "${value}" (${err}).`
|
|
5493
|
+
}];
|
|
5494
|
+
}
|
|
4535
5495
|
function decompileTwine1(storeArea, story, passageText, diagnostics) {
|
|
4536
5496
|
const passages = (storeArea.children ?? []).filter(isTiddler).map((node) => tiddlerToPassage(node, passageText));
|
|
4537
5497
|
const obfuscated = isRot13Obfuscated(passages);
|
|
4538
|
-
for (const p of passages) storyAdd(story, obfuscated && p
|
|
5498
|
+
for (const p of passages) storyAdd(story, obfuscated && isObfuscatable(p) ? {
|
|
4539
5499
|
...p,
|
|
5500
|
+
name: rot13(p.name),
|
|
5501
|
+
tags: p.tags.map(rot13),
|
|
4540
5502
|
text: rot13(p.text)
|
|
4541
5503
|
} : p, diagnostics);
|
|
4542
5504
|
}
|
|
@@ -4681,7 +5643,12 @@ function loadSources(story, filenames, opts, diagnostics, processedFiles) {
|
|
|
4681
5643
|
function loadInlineSources(story, sources, opts, diagnostics) {
|
|
4682
5644
|
for (const source of sources) {
|
|
4683
5645
|
if (typeof source === "string") continue;
|
|
4684
|
-
const
|
|
5646
|
+
const decoded = typeof source.content === "string" ? {
|
|
5647
|
+
text: source.content,
|
|
5648
|
+
diagnostics: []
|
|
5649
|
+
} : decodeText(source.content, source.filename);
|
|
5650
|
+
diagnostics.push(...decoded.diagnostics);
|
|
5651
|
+
const content = normalizeSourceText(decoded.text);
|
|
4685
5652
|
const ext = normalizedFileExt(source.filename);
|
|
4686
5653
|
switch (ext) {
|
|
4687
5654
|
case "":
|
|
@@ -4700,18 +5667,10 @@ function loadInlineSources(story, sources, opts, diagnostics) {
|
|
|
4700
5667
|
break;
|
|
4701
5668
|
}
|
|
4702
5669
|
case "css":
|
|
4703
|
-
storyAdd(story,
|
|
4704
|
-
name: basename(source.filename),
|
|
4705
|
-
tags: ["stylesheet"],
|
|
4706
|
-
text: content
|
|
4707
|
-
}, diagnostics);
|
|
5670
|
+
storyAdd(story, codePassage(basename(source.filename), "stylesheet", content), diagnostics);
|
|
4708
5671
|
break;
|
|
4709
5672
|
case "js":
|
|
4710
|
-
storyAdd(story,
|
|
4711
|
-
name: basename(source.filename),
|
|
4712
|
-
tags: ["script"],
|
|
4713
|
-
text: content
|
|
4714
|
-
}, diagnostics);
|
|
5673
|
+
storyAdd(story, codePassage(basename(source.filename), "script", content), diagnostics);
|
|
4715
5674
|
break;
|
|
4716
5675
|
default: diagnostics.push({
|
|
4717
5676
|
level: "warning",
|
|
@@ -4721,35 +5680,60 @@ function loadInlineSources(story, sources, opts, diagnostics) {
|
|
|
4721
5680
|
}
|
|
4722
5681
|
}
|
|
4723
5682
|
function parseTweeFile(filename, opts) {
|
|
4724
|
-
const
|
|
5683
|
+
const readDiagnostics = [];
|
|
5684
|
+
const result = parseTwee(readUTF8(filename, readDiagnostics), {
|
|
4725
5685
|
filename,
|
|
4726
5686
|
trim: opts.trim ?? true,
|
|
4727
5687
|
twee2Compat: opts.twee2Compat ?? false
|
|
4728
5688
|
});
|
|
4729
5689
|
return {
|
|
4730
5690
|
passages: result.passages,
|
|
4731
|
-
diagnostics: [...result.diagnostics]
|
|
5691
|
+
diagnostics: [...readDiagnostics, ...result.diagnostics]
|
|
4732
5692
|
};
|
|
4733
5693
|
}
|
|
5694
|
+
/**
|
|
5695
|
+
* A stylesheet or script passage named after its file, as Tweego names it. The name only labels
|
|
5696
|
+
* the code, so when another passage has it, the passage takes a free one (see `storyAdd()`).
|
|
5697
|
+
*/
|
|
5698
|
+
function codePassage(name, tag, text) {
|
|
5699
|
+
return withGeneratedName({
|
|
5700
|
+
name,
|
|
5701
|
+
tags: [tag],
|
|
5702
|
+
text
|
|
5703
|
+
}, {
|
|
5704
|
+
kind: "code",
|
|
5705
|
+
base: name
|
|
5706
|
+
});
|
|
5707
|
+
}
|
|
5708
|
+
/** Mark every passage of a parse result as a code passage named after its file. */
|
|
5709
|
+
function codeNames(result) {
|
|
5710
|
+
for (const p of result.passages) withGeneratedName(p, {
|
|
5711
|
+
kind: "code",
|
|
5712
|
+
base: p.name
|
|
5713
|
+
});
|
|
5714
|
+
return result;
|
|
5715
|
+
}
|
|
4734
5716
|
function parseTaggedFile(tag, filename) {
|
|
4735
|
-
const
|
|
5717
|
+
const diagnostics = [];
|
|
5718
|
+
const source = readUTF8(filename, diagnostics);
|
|
4736
5719
|
return {
|
|
4737
|
-
passages: [
|
|
4738
|
-
|
|
4739
|
-
tags: [tag],
|
|
4740
|
-
text: source
|
|
4741
|
-
}],
|
|
4742
|
-
diagnostics: []
|
|
5720
|
+
passages: [codePassage(basename(filename), tag, source)],
|
|
5721
|
+
diagnostics
|
|
4743
5722
|
};
|
|
4744
5723
|
}
|
|
4745
5724
|
function parseMediaFile(tag, filename) {
|
|
4746
5725
|
const source = readBase64(filename);
|
|
5726
|
+
const name = baseNameWithoutExt(filename);
|
|
4747
5727
|
return {
|
|
4748
|
-
passages: [{
|
|
4749
|
-
name
|
|
5728
|
+
passages: [withGeneratedName({
|
|
5729
|
+
name,
|
|
4750
5730
|
tags: [tag],
|
|
4751
5731
|
text: `data:${mediaTypeFromFilename(filename)};base64,${source}`
|
|
4752
|
-
}
|
|
5732
|
+
}, {
|
|
5733
|
+
kind: "media",
|
|
5734
|
+
base: name,
|
|
5735
|
+
file: filename
|
|
5736
|
+
})],
|
|
4753
5737
|
diagnostics: []
|
|
4754
5738
|
};
|
|
4755
5739
|
}
|
|
@@ -4758,24 +5742,27 @@ function parseFontFile(filename) {
|
|
|
4758
5742
|
const name = basename(filename);
|
|
4759
5743
|
const family = baseNameWithoutExt(filename);
|
|
4760
5744
|
const ext = normalizedFileExt(filename);
|
|
4761
|
-
|
|
5745
|
+
const mediaType = mediaTypeFromExt(ext);
|
|
5746
|
+
const hint = fontFormatHint(ext);
|
|
5747
|
+
return codeNames({
|
|
4762
5748
|
passages: [{
|
|
4763
5749
|
name,
|
|
4764
5750
|
tags: ["stylesheet"],
|
|
4765
|
-
text: `@font-face {\n\tfont-family: "${family}";\n\tsrc: url("data:${
|
|
5751
|
+
text: `@font-face {\n\tfont-family: "${cssStringEscape(family)}";\n\tsrc: url("data:${mediaType};base64,${source}") format("${hint}");\n}`
|
|
4766
5752
|
}],
|
|
4767
5753
|
diagnostics: []
|
|
4768
|
-
};
|
|
5754
|
+
});
|
|
4769
5755
|
}
|
|
4770
5756
|
function parseHTMLFile(filename, opts) {
|
|
4771
|
-
const
|
|
5757
|
+
const readDiagnostics = [];
|
|
5758
|
+
const { story, diagnostics } = decompileHTMLForImport(readUTF8(filename, readDiagnostics), { trim: opts.trim ?? true });
|
|
4772
5759
|
return {
|
|
4773
5760
|
passages: story.name !== "" && !storyHas(story, "StoryTitle") ? [{
|
|
4774
5761
|
name: "StoryTitle",
|
|
4775
5762
|
tags: [],
|
|
4776
5763
|
text: story.name
|
|
4777
5764
|
}, ...story.passages] : story.passages,
|
|
4778
|
-
diagnostics: [...diagnostics]
|
|
5765
|
+
diagnostics: [...readDiagnostics, ...diagnostics]
|
|
4779
5766
|
};
|
|
4780
5767
|
}
|
|
4781
5768
|
/**
|
|
@@ -4848,6 +5835,7 @@ function loadTwee(story, filename, opts, diagnostics) {
|
|
|
4848
5835
|
}
|
|
4849
5836
|
function loadTagged(story, tag, filename, diagnostics) {
|
|
4850
5837
|
const result = parseTaggedFile(tag, filename);
|
|
5838
|
+
diagnostics.push(...result.diagnostics);
|
|
4851
5839
|
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
4852
5840
|
}
|
|
4853
5841
|
function loadMedia(story, tag, filename, diagnostics) {
|
|
@@ -4864,6 +5852,7 @@ function loadFont(story, filename, diagnostics) {
|
|
|
4864
5852
|
*/
|
|
4865
5853
|
function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
|
|
4866
5854
|
const currentFiles = /* @__PURE__ */ new Set();
|
|
5855
|
+
const changedPaths = changedFiles === void 0 ? void 0 : new Set([...changedFiles].map((f) => resolve(f)));
|
|
4867
5856
|
for (const filename of filenames) {
|
|
4868
5857
|
if (processedFiles.has(filename)) {
|
|
4869
5858
|
diagnostics.push({
|
|
@@ -4876,8 +5865,8 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
4876
5865
|
const optionsKey = parseOptionsKey(filename, opts);
|
|
4877
5866
|
const entry = cache.get(filename);
|
|
4878
5867
|
const cached = entry?.parseOptionsKey === optionsKey ? entry : void 0;
|
|
4879
|
-
const changed =
|
|
4880
|
-
if (
|
|
5868
|
+
const changed = changedPaths?.has(resolve(filename)) ?? false;
|
|
5869
|
+
if (changedPaths && cached && !changed) {
|
|
4881
5870
|
diagnostics.push(...cached.diagnostics);
|
|
4882
5871
|
for (const p of cached.passages) storyAdd(story, p, diagnostics);
|
|
4883
5872
|
processedFiles.add(filename);
|
|
@@ -4887,6 +5876,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
4887
5876
|
try {
|
|
4888
5877
|
mtimeMs = statSync(filename).mtimeMs;
|
|
4889
5878
|
} catch {
|
|
5879
|
+
cache.delete(filename);
|
|
4890
5880
|
continue;
|
|
4891
5881
|
}
|
|
4892
5882
|
if (cached && !changed && cached.mtimeMs === mtimeMs) {
|
|
@@ -4907,6 +5897,7 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
4907
5897
|
diagnostics.push(...result.diagnostics);
|
|
4908
5898
|
for (const p of result.passages) storyAdd(story, p, diagnostics);
|
|
4909
5899
|
} catch (e) {
|
|
5900
|
+
cache.delete(filename);
|
|
4910
5901
|
diagnostics.push({
|
|
4911
5902
|
level: "error",
|
|
4912
5903
|
message: `load ${filename}: ${e instanceof Error ? e.message : String(e)}`,
|
|
@@ -4924,6 +5915,164 @@ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles,
|
|
|
4924
5915
|
}, diagnostics);
|
|
4925
5916
|
}
|
|
4926
5917
|
//#endregion
|
|
5918
|
+
//#region src/template.ts
|
|
5919
|
+
/**
|
|
5920
|
+
* An HTML closing head tag: `</head` in any letter case, followed by whitespace, `/` or `>`, up to the next `>`.
|
|
5921
|
+
* This is how the HTML tokenizer reads an end tag; it ignores whatever sits between the tag name and the `>`.
|
|
5922
|
+
*/
|
|
5923
|
+
const CLOSING_HEAD_TAG = "<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>";
|
|
5924
|
+
/**
|
|
5925
|
+
* The start of an HTML body start tag: `<body` in any letter case, followed by whitespace, `/` or `>` (so not
|
|
5926
|
+
* `<bodyx>`). Only the `<body` is matched; the attributes and `>` after it are left alone.
|
|
5927
|
+
*/
|
|
5928
|
+
const BODY_START_TAG = "<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])";
|
|
5929
|
+
/** The regular expression source matching exactly `text`. */
|
|
5930
|
+
function literal(text) {
|
|
5931
|
+
return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
5932
|
+
}
|
|
5933
|
+
/** Replace the `slots` placeholders found in `template`, without scanning inserted text. */
|
|
5934
|
+
function fillTemplate(template, slots) {
|
|
5935
|
+
return fillTemplateParts([{
|
|
5936
|
+
kind: "scan",
|
|
5937
|
+
text: template,
|
|
5938
|
+
slots
|
|
5939
|
+
}]);
|
|
5940
|
+
}
|
|
5941
|
+
/**
|
|
5942
|
+
* Fill the parts of a template in order, joined. A `'first'` slot replaced in one part is not replaced again in a
|
|
5943
|
+
* later part that lists the same slot object.
|
|
5944
|
+
*/
|
|
5945
|
+
function fillTemplateParts(parts) {
|
|
5946
|
+
const filled = /* @__PURE__ */ new Set();
|
|
5947
|
+
return parts.map((part) => {
|
|
5948
|
+
if (part.kind === "verbatim" || part.slots.length === 0) return part.text;
|
|
5949
|
+
const { slots } = part;
|
|
5950
|
+
const pattern = new RegExp(slots.map((slot) => `(${slot.pattern})`).join("|"), "g");
|
|
5951
|
+
return part.text.replace(pattern, (match, ...groups) => {
|
|
5952
|
+
const slot = slots.find((_, i) => groups[i] !== void 0);
|
|
5953
|
+
if (slot === void 0 || slot.occurrences === "first" && filled.has(slot)) return match;
|
|
5954
|
+
filled.add(slot);
|
|
5955
|
+
return slot.replacement(match);
|
|
5956
|
+
});
|
|
5957
|
+
}).join("");
|
|
5958
|
+
}
|
|
5959
|
+
//#endregion
|
|
5960
|
+
//#region src/modules.ts
|
|
5961
|
+
/**
|
|
5962
|
+
* Load modules and return HTML tags to inject before the closing head tag. `diagnostics` receives a warning for
|
|
5963
|
+
* each module that is not valid UTF-8 (it is read as Windows-1252).
|
|
5964
|
+
*/
|
|
5965
|
+
function loadModules(filenames, diagnostics) {
|
|
5966
|
+
const processed = /* @__PURE__ */ new Set();
|
|
5967
|
+
const headTags = [];
|
|
5968
|
+
for (const filename of filenames) {
|
|
5969
|
+
if (processed.has(filename)) continue;
|
|
5970
|
+
const ext = normalizedFileExt(filename);
|
|
5971
|
+
let tag = null;
|
|
5972
|
+
switch (ext) {
|
|
5973
|
+
case "css":
|
|
5974
|
+
tag = loadModuleTagged("style", filename, diagnostics);
|
|
5975
|
+
break;
|
|
5976
|
+
case "js":
|
|
5977
|
+
tag = loadModuleTagged("script", filename, diagnostics);
|
|
5978
|
+
break;
|
|
5979
|
+
case "otf":
|
|
5980
|
+
case "ttf":
|
|
5981
|
+
case "woff":
|
|
5982
|
+
case "woff2":
|
|
5983
|
+
tag = loadModuleFont(filename);
|
|
5984
|
+
break;
|
|
5985
|
+
default: continue;
|
|
5986
|
+
}
|
|
5987
|
+
if (tag) headTags.push(tag);
|
|
5988
|
+
processed.add(filename);
|
|
5989
|
+
}
|
|
5990
|
+
return headTags.join("\n");
|
|
5991
|
+
}
|
|
5992
|
+
function loadModuleTagged(tag, filename, diagnostics) {
|
|
5993
|
+
const source = readUTF8(filename, diagnostics).trim();
|
|
5994
|
+
if (source.length === 0) return null;
|
|
5995
|
+
return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
|
|
5996
|
+
}
|
|
5997
|
+
function loadModuleFont(filename) {
|
|
5998
|
+
const source = readBase64(filename);
|
|
5999
|
+
const family = baseNameWithoutExt(filename);
|
|
6000
|
+
const idSlug = `style-module-${slugify(family)}`;
|
|
6001
|
+
const ext = normalizedFileExt(filename);
|
|
6002
|
+
const mediaType = mediaTypeFromExt(ext);
|
|
6003
|
+
const hint = fontFormatHint(ext);
|
|
6004
|
+
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>`;
|
|
6005
|
+
}
|
|
6006
|
+
/**
|
|
6007
|
+
* Load the module tags and head file content to inject into the head (see `placeHead()`), joined by newlines,
|
|
6008
|
+
* or `''` when there is nothing to inject.
|
|
6009
|
+
*/
|
|
6010
|
+
function loadHeadContent(modulePaths, headFile, diagnostics) {
|
|
6011
|
+
const parts = [];
|
|
6012
|
+
if (modulePaths.length > 0) {
|
|
6013
|
+
const modules = loadModules(modulePaths, diagnostics).trim();
|
|
6014
|
+
if (modules.length > 0) parts.push(modules);
|
|
6015
|
+
}
|
|
6016
|
+
if (headFile) try {
|
|
6017
|
+
const source = readUTF8(headFile, diagnostics).trim();
|
|
6018
|
+
if (source.length > 0) parts.push(source);
|
|
6019
|
+
} catch (e) {
|
|
6020
|
+
diagnostics?.push({
|
|
6021
|
+
level: "warning",
|
|
6022
|
+
message: `Failed to read head file "${headFile}": ${e instanceof Error ? e.message : String(e)}`
|
|
6023
|
+
});
|
|
6024
|
+
}
|
|
6025
|
+
return parts.join("\n");
|
|
6026
|
+
}
|
|
6027
|
+
/**
|
|
6028
|
+
* Place `content` in a template, given the texts that `fillTemplateParts()` scans for the slot (the template, and
|
|
6029
|
+
* the footer of a pre-1.4 Twine 1 format):
|
|
6030
|
+
*
|
|
6031
|
+
* - on its own line before the first closing head tag, as Tweego does; the tag may use any letter case and
|
|
6032
|
+
* whitespace before its `>`, and is kept as written;
|
|
6033
|
+
* - in a template with no closing head tag (HTML lets a document leave it out), on its own line before the first
|
|
6034
|
+
* body start tag, where the HTML parser still puts it in the head, with a warning (Tweego drops it);
|
|
6035
|
+
* - in a template with neither, nowhere, with a warning.
|
|
6036
|
+
*
|
|
6037
|
+
* `owner` names the template in the warnings. Empty `content` needs no slot and gets no warning.
|
|
6038
|
+
*/
|
|
6039
|
+
function placeHead(content, texts, owner) {
|
|
6040
|
+
if (content.length === 0) return {
|
|
6041
|
+
slot: void 0,
|
|
6042
|
+
diagnostics: []
|
|
6043
|
+
};
|
|
6044
|
+
const found = (tag) => texts.some((text) => new RegExp(tag).test(text));
|
|
6045
|
+
if (found("<\\/[Hh][Ee][Aa][Dd](?=[\\t\\n\\f\\r />])[^>]*>")) return {
|
|
6046
|
+
slot: beforeTag(CLOSING_HEAD_TAG, content),
|
|
6047
|
+
diagnostics: []
|
|
6048
|
+
};
|
|
6049
|
+
if (found("<[Bb][Oo][Dd][Yy](?=[\\t\\n\\f\\r />])")) {
|
|
6050
|
+
const message = `${owner} has no closing head tag; the modules and head file were injected before its body start tag.`;
|
|
6051
|
+
return {
|
|
6052
|
+
slot: beforeTag(BODY_START_TAG, content),
|
|
6053
|
+
diagnostics: [{
|
|
6054
|
+
level: "warning",
|
|
6055
|
+
message
|
|
6056
|
+
}]
|
|
6057
|
+
};
|
|
6058
|
+
}
|
|
6059
|
+
return {
|
|
6060
|
+
slot: void 0,
|
|
6061
|
+
diagnostics: [{
|
|
6062
|
+
level: "warning",
|
|
6063
|
+
message: `${owner} has no closing head tag and no body start tag; the modules and head file were not injected.`
|
|
6064
|
+
}]
|
|
6065
|
+
};
|
|
6066
|
+
}
|
|
6067
|
+
/** The slot that inserts `content` on its own line before the first match of `tag`, which is kept as written. */
|
|
6068
|
+
function beforeTag(tag, content) {
|
|
6069
|
+
return {
|
|
6070
|
+
pattern: tag,
|
|
6071
|
+
occurrences: "first",
|
|
6072
|
+
replacement: (match) => `${content}\n${match}`
|
|
6073
|
+
};
|
|
6074
|
+
}
|
|
6075
|
+
//#endregion
|
|
4927
6076
|
//#region src/version.ts
|
|
4928
6077
|
/**
|
|
4929
6078
|
* The twee-ts version, read at runtime from the package's own package.json.
|
|
@@ -4957,24 +6106,42 @@ const CREATOR_NAME$2 = "Twee-ts";
|
|
|
4957
6106
|
function toTwine2Archive(story, startName, options) {
|
|
4958
6107
|
return getTwine2DataChunk(story, startName, options) + "\n";
|
|
4959
6108
|
}
|
|
6109
|
+
/**
|
|
6110
|
+
* Fill the Twine 2 format template: every `{{STORY_NAME}}` gets the escaped story name and the first
|
|
6111
|
+
* `{{STORY_DATA}}` the story data (as Tweego does), and `head` goes before the first closing head tag (or, with a
|
|
6112
|
+
* warning, the body start tag; see `placeHead()`). All three are found in the template in one pass, so a story
|
|
6113
|
+
* name, passage text or head content holding a placeholder or a closing head tag stays literal.
|
|
6114
|
+
* `options.diagnostics` receives a warning when the format file is not valid UTF-8, and any warning about where
|
|
6115
|
+
* `head` went.
|
|
6116
|
+
*/
|
|
4960
6117
|
function toTwine2HTML(story, format, startName, options) {
|
|
4961
|
-
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
|
|
4971
|
-
|
|
4972
|
-
|
|
6118
|
+
const name = htmlEscape(story.name);
|
|
6119
|
+
const template = readFormatSource(format, options?.diagnostics);
|
|
6120
|
+
const placement = placeHead(options?.head ?? "", [template], `Story format "${format.name}" ${format.version}`);
|
|
6121
|
+
options?.diagnostics?.push(...placement.diagnostics);
|
|
6122
|
+
const head = placement.slot;
|
|
6123
|
+
return fillTemplate(template, [
|
|
6124
|
+
{
|
|
6125
|
+
pattern: literal("{{STORY_NAME}}"),
|
|
6126
|
+
occurrences: "all",
|
|
6127
|
+
replacement: () => name
|
|
6128
|
+
},
|
|
6129
|
+
{
|
|
6130
|
+
pattern: literal("{{STORY_DATA}}"),
|
|
6131
|
+
occurrences: "first",
|
|
6132
|
+
replacement: () => {
|
|
6133
|
+
return getTwine2DataChunk({
|
|
6134
|
+
...story,
|
|
6135
|
+
twine2: {
|
|
6136
|
+
...story.twine2,
|
|
6137
|
+
format: format.name,
|
|
6138
|
+
formatVersion: format.version
|
|
6139
|
+
}
|
|
6140
|
+
}, startName, options);
|
|
4973
6141
|
}
|
|
4974
|
-
},
|
|
4975
|
-
|
|
4976
|
-
|
|
4977
|
-
return template;
|
|
6142
|
+
},
|
|
6143
|
+
...head === void 0 ? [] : [head]
|
|
6144
|
+
]);
|
|
4978
6145
|
}
|
|
4979
6146
|
const OMITTING_TAGS = [
|
|
4980
6147
|
"Twine.private",
|
|
@@ -5067,39 +6234,83 @@ function toTwine1Archive(story, _startName) {
|
|
|
5067
6234
|
const { data, count } = getTwine1PassageChunk(story);
|
|
5068
6235
|
return `<div id="storeArea" data-size="${count}">${data}</div>\n`;
|
|
5069
6236
|
}
|
|
5070
|
-
|
|
6237
|
+
/**
|
|
6238
|
+
* Fill the Twine 1 format template. The format's components are inserted first, one after another, as Tweego and
|
|
6239
|
+
* Twine 1 do. Then the first of each story placeholder (`"VERSION"`, `"TIME"`, `"START_AT"`, `"STORY_SIZE"`,
|
|
6240
|
+
* `"STORY"`), the IFID comment and `head` (before the first closing head tag or, with a warning, the body start
|
|
6241
|
+
* tag; see `placeHead()`) are found in one pass, so a start passage name or story data holding a placeholder or a
|
|
6242
|
+
* closing head tag stays literal. `options.diagnostics` receives a warning for each format file that is not valid
|
|
6243
|
+
* UTF-8, and any warning about where `head` went.
|
|
6244
|
+
*/
|
|
6245
|
+
function toTwine1HTML(story, format, startName, options) {
|
|
5071
6246
|
const formatDir = dirname(format.filename);
|
|
5072
6247
|
const parentDir = dirname(formatDir);
|
|
5073
|
-
|
|
6248
|
+
const diagnostics = options?.diagnostics;
|
|
6249
|
+
let template = readFormatSource(format, diagnostics);
|
|
5074
6250
|
const { data, count } = getTwine1PassageChunk(story);
|
|
5075
|
-
template = tryReplaceComponent(template, "\"USER_LIB\"", join(formatDir, "userlib.js"), false);
|
|
5076
|
-
template = tryReplaceComponent(template, "\"ENGINE\"", join(parentDir, "engine.js"), true);
|
|
5077
|
-
template = tryReplaceComponent(template, "\"SUGARCANE\"", join(formatDir, "code.js"), true);
|
|
5078
|
-
template = tryReplaceComponent(template, "\"JONAH\"", join(formatDir, "code.js"), true);
|
|
5079
|
-
if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", join(parentDir, "jquery.js"), true);
|
|
5080
|
-
if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", join(parentDir, "modernizr.js"), true);
|
|
6251
|
+
template = tryReplaceComponent(template, "\"USER_LIB\"", join(formatDir, "userlib.js"), false, diagnostics);
|
|
6252
|
+
template = tryReplaceComponent(template, "\"ENGINE\"", join(parentDir, "engine.js"), true, diagnostics);
|
|
6253
|
+
template = tryReplaceComponent(template, "\"SUGARCANE\"", join(formatDir, "code.js"), true, diagnostics);
|
|
6254
|
+
template = tryReplaceComponent(template, "\"JONAH\"", join(formatDir, "code.js"), true, diagnostics);
|
|
6255
|
+
if (story.twine1.settings.get("jquery") === "on") template = tryReplaceComponent(template, "\"JQUERY\"", join(parentDir, "jquery.js"), true, diagnostics);
|
|
6256
|
+
if (story.twine1.settings.get("modernizr") === "on") template = tryReplaceComponent(template, "\"MODERNIZR\"", join(parentDir, "modernizr.js"), true, diagnostics);
|
|
6257
|
+
const isPre14 = !template.includes("\"STORY\"");
|
|
6258
|
+
const footer = isPre14 ? readFooter(formatDir, diagnostics) : "";
|
|
6259
|
+
const storeArea = (template + footer).includes("<div id=\"store-area\"") ? "<div id=\"store-area\"" : "<div id=\"storeArea\"";
|
|
6260
|
+
const safeIfid = htmlCommentSanitize(story.ifid);
|
|
6261
|
+
const ifid = story.ifid ? {
|
|
6262
|
+
pattern: literal(storeArea),
|
|
6263
|
+
occurrences: "first",
|
|
6264
|
+
replacement: (div) => `<!-- UUID://${safeIfid}// -->${div}`
|
|
6265
|
+
} : void 0;
|
|
6266
|
+
const placement = placeHead(options?.head ?? "", isPre14 ? [template, footer] : [template], `Story format "${format.name}"`);
|
|
6267
|
+
diagnostics?.push(...placement.diagnostics);
|
|
6268
|
+
const late = [ifid, placement.slot].filter((slot) => slot !== void 0);
|
|
5081
6269
|
const displayStart = startName === "Start" ? "" : startName;
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
6270
|
+
const slots = [
|
|
6271
|
+
firstSlot("\"VERSION\"", `Compiled with ${CREATOR_NAME$1}, ${VERSION}`),
|
|
6272
|
+
firstSlot("\"TIME\"", `Built on ${(/* @__PURE__ */ new Date()).toUTCString()}`),
|
|
6273
|
+
firstSlot("\"START_AT\"", `"${jsStringEscape(displayStart)}"`),
|
|
6274
|
+
firstSlot("\"STORY_SIZE\"", `"${count}"`),
|
|
6275
|
+
firstSlot("\"STORY\"", data),
|
|
6276
|
+
...late
|
|
6277
|
+
];
|
|
6278
|
+
return fillTemplateParts(isPre14 ? [
|
|
6279
|
+
{
|
|
6280
|
+
kind: "scan",
|
|
6281
|
+
text: template,
|
|
6282
|
+
slots
|
|
6283
|
+
},
|
|
6284
|
+
{
|
|
6285
|
+
kind: "verbatim",
|
|
6286
|
+
text: data
|
|
6287
|
+
},
|
|
6288
|
+
{
|
|
6289
|
+
kind: "scan",
|
|
6290
|
+
text: footer,
|
|
6291
|
+
slots: late
|
|
5094
6292
|
}
|
|
5095
|
-
|
|
5096
|
-
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
6293
|
+
] : [{
|
|
6294
|
+
kind: "scan",
|
|
6295
|
+
text: template,
|
|
6296
|
+
slots
|
|
6297
|
+
}]);
|
|
6298
|
+
}
|
|
6299
|
+
/** A slot replacing the first `token` with `value`. */
|
|
6300
|
+
function firstSlot(token, value) {
|
|
6301
|
+
return {
|
|
6302
|
+
pattern: literal(token),
|
|
6303
|
+
occurrences: "first",
|
|
6304
|
+
replacement: () => value
|
|
6305
|
+
};
|
|
6306
|
+
}
|
|
6307
|
+
/** The footer of a pre-1.4 format, or the default one when the format has none. */
|
|
6308
|
+
function readFooter(formatDir, diagnostics) {
|
|
6309
|
+
try {
|
|
6310
|
+
return readUTF8(join(formatDir, "footer.html"), diagnostics);
|
|
6311
|
+
} catch {
|
|
6312
|
+
return "</div>\n</body>\n</html>\n";
|
|
5101
6313
|
}
|
|
5102
|
-
return template;
|
|
5103
6314
|
}
|
|
5104
6315
|
/**
|
|
5105
6316
|
* Why Twine 1 output leaves a passage out of its tiddlers, or `undefined` when the passage is emitted.
|
|
@@ -5124,11 +6335,10 @@ function getTwine1PassageChunk(story) {
|
|
|
5124
6335
|
count
|
|
5125
6336
|
};
|
|
5126
6337
|
}
|
|
5127
|
-
function tryReplaceComponent(template, placeholder, componentPath, required) {
|
|
6338
|
+
function tryReplaceComponent(template, placeholder, componentPath, required, diagnostics) {
|
|
5128
6339
|
if (!template.includes(placeholder)) return template;
|
|
5129
6340
|
try {
|
|
5130
|
-
|
|
5131
|
-
if (content.charCodeAt(0) === 65279) content = content.slice(1);
|
|
6341
|
+
const content = readUTF8(componentPath, diagnostics);
|
|
5132
6342
|
return template.replace(placeholder, () => content);
|
|
5133
6343
|
} catch (e) {
|
|
5134
6344
|
if (required) throw new Error(`Required format component not found: ${componentPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -5142,11 +6352,16 @@ function tryReplaceComponent(template, placeholder, componentPath, required) {
|
|
|
5142
6352
|
* from its loaded text, so changes made after loading (a start passage override, test mode) are kept.
|
|
5143
6353
|
* Tweego writes the loaded text; its own normalization makes the two the same for an unchanged story.
|
|
5144
6354
|
*
|
|
6355
|
+
* Twee cannot write every passage so that it reads back the same (see `tweeRoundTrip()`); `diagnostics`
|
|
6356
|
+
* receives a warning for each passage that will not. Tweego writes the same Twee without one.
|
|
6357
|
+
*
|
|
5145
6358
|
* `addStoryData`: add a StoryData passage when the story has none, so that StoryData fields the
|
|
5146
6359
|
* compile options set are recorded. Default: false.
|
|
5147
6360
|
*/
|
|
5148
6361
|
function toTwee(story, outMode, options = {}) {
|
|
5149
|
-
|
|
6362
|
+
const written = withEffectiveStoryData(story, options.addStoryData ?? false).map((p) => tweeRoundTrip(p, outMode));
|
|
6363
|
+
options.diagnostics?.push(...written.flatMap(({ diagnostics }) => diagnostics));
|
|
6364
|
+
return written.map(({ passage }) => passageToTwee(passage, outMode)).join("");
|
|
5150
6365
|
}
|
|
5151
6366
|
function withEffectiveStoryData(story, addStoryData) {
|
|
5152
6367
|
const storyData = {
|
|
@@ -5171,74 +6386,63 @@ function withEffectiveStoryData(story, addStoryData) {
|
|
|
5171
6386
|
function isStoryDataJSON(text) {
|
|
5172
6387
|
return unmarshalStoryData(createStory(), text) === null;
|
|
5173
6388
|
}
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
/**
|
|
5177
|
-
* Load modules and return HTML tags to inject before </head>.
|
|
5178
|
-
*/
|
|
5179
|
-
function loadModules(filenames) {
|
|
5180
|
-
const processed = /* @__PURE__ */ new Set();
|
|
5181
|
-
const headTags = [];
|
|
5182
|
-
for (const filename of filenames) {
|
|
5183
|
-
if (processed.has(filename)) continue;
|
|
5184
|
-
const ext = normalizedFileExt(filename);
|
|
5185
|
-
let tag = null;
|
|
5186
|
-
switch (ext) {
|
|
5187
|
-
case "css":
|
|
5188
|
-
tag = loadModuleTagged("style", filename);
|
|
5189
|
-
break;
|
|
5190
|
-
case "js":
|
|
5191
|
-
tag = loadModuleTagged("script", filename);
|
|
5192
|
-
break;
|
|
5193
|
-
case "otf":
|
|
5194
|
-
case "ttf":
|
|
5195
|
-
case "woff":
|
|
5196
|
-
case "woff2":
|
|
5197
|
-
tag = loadModuleFont(filename);
|
|
5198
|
-
break;
|
|
5199
|
-
default: continue;
|
|
5200
|
-
}
|
|
5201
|
-
if (tag) headTags.push(tag);
|
|
5202
|
-
processed.add(filename);
|
|
5203
|
-
}
|
|
5204
|
-
return headTags.join("\n");
|
|
5205
|
-
}
|
|
5206
|
-
function loadModuleTagged(tag, filename) {
|
|
5207
|
-
const source = readUTF8(filename).trim();
|
|
5208
|
-
if (source.length === 0) return null;
|
|
5209
|
-
return `<${tag} id="${`${tag}-module-${slugify(baseNameWithoutExt(filename))}`}" type="${tag === "script" ? "text/javascript" : "text/css"}">${tag === "script" ? scriptContentEscape(source) : styleContentEscape(source)}</${tag}>`;
|
|
5210
|
-
}
|
|
5211
|
-
function loadModuleFont(filename) {
|
|
5212
|
-
const source = readBase64(filename);
|
|
5213
|
-
const family = baseNameWithoutExt(filename);
|
|
5214
|
-
const idSlug = `style-module-${slugify(family)}`;
|
|
5215
|
-
const ext = normalizedFileExt(filename);
|
|
5216
|
-
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>`;
|
|
5217
|
-
}
|
|
6389
|
+
/** Characters Twee 3 escapes in names and tags, and Twee 1 cannot write there at all. */
|
|
6390
|
+
const TWEE1_UNWRITABLE = /[[\]{}\\]/;
|
|
5218
6391
|
/**
|
|
5219
|
-
*
|
|
6392
|
+
* Check that Twee reads `p` back as written, and say what it will not. Twee has no way to escape passage text,
|
|
6393
|
+
* and its parser trims names and splits tags at whitespace, so these do not survive:
|
|
6394
|
+
*
|
|
6395
|
+
* - a text line that starts with `::`, which reads as a new passage header. In stylesheet and script passages such
|
|
6396
|
+
* a line is indented by one space, which keeps the CSS or JavaScript working (only a template literal or a
|
|
6397
|
+
* string continued across lines, in either, would see the space); the warning says so. Other passages' text is
|
|
6398
|
+
* written as it is.
|
|
6399
|
+
* - a name that is empty, has leading or trailing whitespace, or holds a line break;
|
|
6400
|
+
* - a tag that is empty or holds whitespace;
|
|
6401
|
+
* - in Twee 1, which escapes nothing, a name or tag holding `[`, `]`, `{`, `}` or `\`.
|
|
5220
6402
|
*/
|
|
5221
|
-
function
|
|
5222
|
-
const
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
}
|
|
5227
|
-
if (headFile) try {
|
|
5228
|
-
const source = readUTF8(headFile).trim();
|
|
5229
|
-
if (source.length > 0) parts.push(source);
|
|
5230
|
-
} catch (e) {
|
|
5231
|
-
diagnostics?.push({
|
|
6403
|
+
function tweeRoundTrip(p, outMode) {
|
|
6404
|
+
const label = `Passage ${JSON.stringify(p.name)}`;
|
|
6405
|
+
const diagnostics = [];
|
|
6406
|
+
const warn = (problem) => {
|
|
6407
|
+
diagnostics.push({
|
|
5232
6408
|
level: "warning",
|
|
5233
|
-
message:
|
|
6409
|
+
message: `${label} cannot be written as Twee that reads back the same: ${problem}.`
|
|
5234
6410
|
});
|
|
6411
|
+
};
|
|
6412
|
+
if (p.name.trim() === "") warn("its name is empty");
|
|
6413
|
+
else if (p.name.trim() !== p.name) warn("its name has leading or trailing whitespace, which Twee drops");
|
|
6414
|
+
if (/[\r\n]/.test(p.name)) warn("its name has a line break, which ends a Twee passage header");
|
|
6415
|
+
if (outMode === "twee1" && TWEE1_UNWRITABLE.test(p.name)) warn("its name has \"[\", \"]\", \"{\", \"}\" or \"\\\", which Twee 1 cannot escape");
|
|
6416
|
+
for (const tag of p.tags) {
|
|
6417
|
+
if (tag === "" || /\s/.test(tag)) warn(`its tag ${JSON.stringify(tag)} is empty or has whitespace, which splits tags in Twee`);
|
|
6418
|
+
if (outMode === "twee1" && TWEE1_UNWRITABLE.test(tag)) warn(`its tag ${JSON.stringify(tag)} has "[", "]", "{", "}" or "\\", which Twee 1 cannot escape`);
|
|
6419
|
+
}
|
|
6420
|
+
const lines = p.text.split(/(\r\n?|\n)/);
|
|
6421
|
+
const headerLines = lines.flatMap((line, i) => i % 2 === 0 && line.startsWith("::") ? [i / 2 + 1] : []);
|
|
6422
|
+
if (headerLines.length === 0) return {
|
|
6423
|
+
passage: p,
|
|
6424
|
+
diagnostics
|
|
6425
|
+
};
|
|
6426
|
+
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`;
|
|
6427
|
+
if (!p.tags.some((tag) => tag === "stylesheet" || tag === "script")) {
|
|
6428
|
+
warn(where);
|
|
6429
|
+
return {
|
|
6430
|
+
passage: p,
|
|
6431
|
+
diagnostics
|
|
6432
|
+
};
|
|
5235
6433
|
}
|
|
5236
|
-
|
|
5237
|
-
|
|
5238
|
-
|
|
5239
|
-
|
|
5240
|
-
}
|
|
5241
|
-
return
|
|
6434
|
+
diagnostics.push({
|
|
6435
|
+
level: "warning",
|
|
6436
|
+
message: `${label}: ${where}; ${headerLines.length === 1 ? "it was" : "they were"} written indented by one space.`
|
|
6437
|
+
});
|
|
6438
|
+
const text = lines.map((line, i) => i % 2 === 0 && line.startsWith("::") ? ` ${line}` : line).join("");
|
|
6439
|
+
return {
|
|
6440
|
+
passage: {
|
|
6441
|
+
...p,
|
|
6442
|
+
text
|
|
6443
|
+
},
|
|
6444
|
+
diagnostics
|
|
6445
|
+
};
|
|
5242
6446
|
}
|
|
5243
6447
|
//#endregion
|
|
5244
6448
|
//#region src/passage-omission.ts
|
|
@@ -5295,28 +6499,28 @@ var TweeTsError = class extends Error {
|
|
|
5295
6499
|
}
|
|
5296
6500
|
};
|
|
5297
6501
|
/**
|
|
5298
|
-
*
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
5302
|
-
|
|
5303
|
-
|
|
5304
|
-
*
|
|
5305
|
-
*
|
|
6502
|
+
* Builds as compileToFile() does for `outputs`, without writing them: an output may sit
|
|
6503
|
+
* inside a source folder, so its last build is left out of the sources and modules and
|
|
6504
|
+
* never read back. `outputs` is the one file the CLI writes, or every path a bundler's
|
|
6505
|
+
* build writes (see BuildOutputs). The caller decides whether the result is written.
|
|
6506
|
+
* With no outputs (output to stdout, or none), this is compile(). With `cache`, files
|
|
6507
|
+
* are cached as compileIncremental() caches them.
|
|
6508
|
+
*
|
|
6509
|
+
* Throws a TweeTsError when a source, module or head file named directly is an output.
|
|
5306
6510
|
*
|
|
5307
|
-
*
|
|
5308
|
-
* With it, a file it names (by the path source discovery gives it, relative to the working
|
|
5309
|
-
* directory) is always reparsed, whatever its modification time, and every other cached
|
|
5310
|
-
* file is reused as it is.
|
|
6511
|
+
* Internal, for the CLI and the bundler plugins; not part of the public API.
|
|
5311
6512
|
*/
|
|
5312
|
-
async function
|
|
5313
|
-
return buildOutput(options, cache,
|
|
6513
|
+
async function compileForOutputFile(options, outputs, cache) {
|
|
6514
|
+
return buildOutput(options, cache, void 0, outputs);
|
|
5314
6515
|
}
|
|
5315
6516
|
/**
|
|
5316
|
-
* `
|
|
5317
|
-
*
|
|
6517
|
+
* `outputs`: what the build writes (an output file's path, or a bundler's outputs),
|
|
6518
|
+
* which source and module discovery skip, so an output inside a source folder is
|
|
6519
|
+
* never loaded back. A source, module or head file named directly that is an output
|
|
6520
|
+
* is a TweeTsError, as in Tweego: writing the build would overwrite it.
|
|
5318
6521
|
*/
|
|
5319
|
-
async function buildOutput(options, cache, changedFiles,
|
|
6522
|
+
async function buildOutput(options, cache, changedFiles, outputs) {
|
|
6523
|
+
const written = toBuildOutputs(outputs);
|
|
5320
6524
|
const diagnostics = [];
|
|
5321
6525
|
const outputMode = options.outputMode ?? "html";
|
|
5322
6526
|
const trim = options.trim ?? true;
|
|
@@ -5324,13 +6528,18 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5324
6528
|
const testMode = options.testMode ?? false;
|
|
5325
6529
|
const noRemote = options.noRemote ?? false;
|
|
5326
6530
|
const sourceInfo = options.sourceInfo ?? false;
|
|
6531
|
+
options.signal?.throwIfAborted();
|
|
6532
|
+
const timeout = options.formatFetchTimeout;
|
|
6533
|
+
if (timeout !== void 0 && !(timeout >= 0)) throw new TweeTsError(`formatFetchTimeout must be 0 or more milliseconds, not ${timeout}.`);
|
|
5327
6534
|
clearIndexCache();
|
|
5328
6535
|
const filePaths = [];
|
|
5329
6536
|
const inlineSources = [];
|
|
5330
6537
|
for (const source of options.sources) if (typeof source === "string") filePaths.push(source);
|
|
5331
6538
|
else inlineSources.push(source);
|
|
5332
|
-
const
|
|
5333
|
-
diagnostics.push(...
|
|
6539
|
+
const sourcePaths = getFilenames(filePaths, written, options.exclude);
|
|
6540
|
+
diagnostics.push(...sourcePaths.diagnostics);
|
|
6541
|
+
rejectOutputSources(sourcePaths.outputSources, diagnostics);
|
|
6542
|
+
const sourceFilenames = sourcePaths.filenames;
|
|
5334
6543
|
const story = createStory();
|
|
5335
6544
|
const processedFiles = /* @__PURE__ */ new Set();
|
|
5336
6545
|
if (cache) loadSourcesCached(story, sourceFilenames, {
|
|
@@ -5359,7 +6568,10 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5359
6568
|
switch (outputMode) {
|
|
5360
6569
|
case "twee3":
|
|
5361
6570
|
case "twee1":
|
|
5362
|
-
output = toTwee(story, outputMode, {
|
|
6571
|
+
output = toTwee(story, outputMode, {
|
|
6572
|
+
addStoryData: Boolean(options.startPassage) || testMode,
|
|
6573
|
+
diagnostics
|
|
6574
|
+
});
|
|
5363
6575
|
break;
|
|
5364
6576
|
case "twine2-archive":
|
|
5365
6577
|
output = toTwine2Archive(story, startName, { sourceInfo });
|
|
@@ -5373,17 +6585,24 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5373
6585
|
case "html": {
|
|
5374
6586
|
diagnostics.push(...startPassageDiagnostics(story, startName, format?.isTwine2 === false ? "twine1" : "twine2"));
|
|
5375
6587
|
if (!format) throw new TweeTsError("No story format available for HTML output.", diagnostics);
|
|
5376
|
-
if (format.isTwine2
|
|
5377
|
-
|
|
5378
|
-
|
|
5379
|
-
|
|
5380
|
-
|
|
5381
|
-
});
|
|
5382
|
-
output = toTwine1HTML(story, format, startName);
|
|
5383
|
-
}
|
|
5384
|
-
const modules = getFilenames(options.modules ?? [], outFile);
|
|
6588
|
+
if (!format.isTwine2 && story.name === "" && !storyHas(story, "StoryTitle")) diagnostics.push({
|
|
6589
|
+
level: "error",
|
|
6590
|
+
message: "Special passage \"StoryTitle\" not found."
|
|
6591
|
+
});
|
|
6592
|
+
const modules = getFilenames(options.modules ?? [], written);
|
|
5385
6593
|
diagnostics.push(...modules.diagnostics);
|
|
5386
|
-
|
|
6594
|
+
const { headFile } = options;
|
|
6595
|
+
const headIsOutput = headFile !== void 0 && outputPaths(written).isFile(realPathOf(headFile));
|
|
6596
|
+
rejectOutputSources([...modules.outputSources, ...headIsOutput ? [headFile] : []], diagnostics);
|
|
6597
|
+
const head = loadHeadContent(modules.filenames, options.headFile, diagnostics);
|
|
6598
|
+
output = format.isTwine2 ? toTwine2HTML(story, format, startName, {
|
|
6599
|
+
sourceInfo,
|
|
6600
|
+
head,
|
|
6601
|
+
diagnostics
|
|
6602
|
+
}) : toTwine1HTML(story, format, startName, {
|
|
6603
|
+
head,
|
|
6604
|
+
diagnostics
|
|
6605
|
+
});
|
|
5387
6606
|
break;
|
|
5388
6607
|
}
|
|
5389
6608
|
default: throw new TweeTsError(`Unhandled output mode: ${outputMode}`, diagnostics);
|
|
@@ -5400,6 +6619,16 @@ async function buildOutput(options, cache, changedFiles, outFile) {
|
|
|
5400
6619
|
stats
|
|
5401
6620
|
};
|
|
5402
6621
|
}
|
|
6622
|
+
/**
|
|
6623
|
+
* Throws for the first of `paths`, inputs named directly that are also an output:
|
|
6624
|
+
* writing the build would overwrite them. An output found while walking a source
|
|
6625
|
+
* folder is skipped instead (see getFilenames).
|
|
6626
|
+
*/
|
|
6627
|
+
function rejectOutputSources(paths, diagnostics) {
|
|
6628
|
+
const [first] = paths;
|
|
6629
|
+
if (first === void 0) return;
|
|
6630
|
+
throw new TweeTsError(`path ${first}: Output file cannot be an input source.`, [...diagnostics]);
|
|
6631
|
+
}
|
|
5403
6632
|
function ensureIFID(story, diagnostics) {
|
|
5404
6633
|
if (story.ifid !== "") return;
|
|
5405
6634
|
if (story.legacyIFID !== "") {
|
|
@@ -5488,6 +6717,79 @@ function fatalError(e) {
|
|
|
5488
6717
|
return e instanceof Error ? e : new Error(String(e));
|
|
5489
6718
|
}
|
|
5490
6719
|
//#endregion
|
|
5491
|
-
|
|
6720
|
+
//#region src/plugins/paths.ts
|
|
6721
|
+
/**
|
|
6722
|
+
* Path helpers for the bundler plugins. Vite reports module ids with forward
|
|
6723
|
+
* slashes on every platform, while node:path gives backslashes on Windows, so
|
|
6724
|
+
* the Vite plugin compares paths in forward-slash form only. Paths a build
|
|
6725
|
+
* writes are compared by real path instead (see filesystem.ts).
|
|
6726
|
+
*/
|
|
6727
|
+
/**
|
|
6728
|
+
* The absolute path a bundle writes the file it emits as `fileName` to: inside
|
|
6729
|
+
* `output.dir`, or next to `output.file`, either resolved against the working
|
|
6730
|
+
* directory, as the bundler resolves them. Undefined when the output names
|
|
6731
|
+
* neither, as for a bundle only generated in memory.
|
|
6732
|
+
*/
|
|
6733
|
+
function emittedFilePath(output, fileName) {
|
|
6734
|
+
const dir = output.dir ?? (output.file === void 0 ? void 0 : dirname(output.file));
|
|
6735
|
+
return dir === void 0 ? void 0 : resolve(dir, fileName);
|
|
6736
|
+
}
|
|
6737
|
+
/**
|
|
6738
|
+
* The locations a bundler's `output` option names: one output's settings or an
|
|
6739
|
+
* array of them, as a config gives them. Outputs that name neither `dir` nor
|
|
6740
|
+
* `file`, and anything that isn't an output's settings, are left out.
|
|
6741
|
+
*/
|
|
6742
|
+
function outputLocations(option) {
|
|
6743
|
+
return (Array.isArray(option) ? option : [option]).flatMap((output) => {
|
|
6744
|
+
if (typeof output !== "object" || output === null) return [];
|
|
6745
|
+
const { dir, file } = output;
|
|
6746
|
+
const location = {
|
|
6747
|
+
...typeof dir === "string" ? { dir } : {},
|
|
6748
|
+
...typeof file === "string" ? { file } : {}
|
|
6749
|
+
};
|
|
6750
|
+
return location.dir === void 0 && location.file === void 0 ? [] : [location];
|
|
6751
|
+
});
|
|
6752
|
+
}
|
|
6753
|
+
/** An empty OutputRecord for a plugin that writes its story as `storyFileName`. */
|
|
6754
|
+
function createOutputRecord(storyFileName) {
|
|
6755
|
+
const files = /* @__PURE__ */ new Set();
|
|
6756
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
6757
|
+
const addFiles = (output, fileNames) => {
|
|
6758
|
+
for (const fileName of fileNames) {
|
|
6759
|
+
const path = emittedFilePath(output, fileName);
|
|
6760
|
+
if (path !== void 0) files.add(path);
|
|
6761
|
+
}
|
|
6762
|
+
};
|
|
6763
|
+
return {
|
|
6764
|
+
addLocation(output) {
|
|
6765
|
+
if (output.dir !== void 0) dirs.add(resolve(output.dir));
|
|
6766
|
+
if (output.file !== void 0) files.add(resolve(output.file));
|
|
6767
|
+
addFiles(output, [storyFileName]);
|
|
6768
|
+
},
|
|
6769
|
+
addFiles,
|
|
6770
|
+
outputs: () => ({
|
|
6771
|
+
files: [...files],
|
|
6772
|
+
dirs: [...dirs]
|
|
6773
|
+
})
|
|
6774
|
+
};
|
|
6775
|
+
}
|
|
6776
|
+
/** The path with Windows separators turned into forward slashes. */
|
|
6777
|
+
function toPosix(path) {
|
|
6778
|
+
return path.replace(/\\/g, "/");
|
|
6779
|
+
}
|
|
6780
|
+
/** Whether `file` is one of `dirs` or inside one of them. All paths in forward-slash form. */
|
|
6781
|
+
function isInside(file, dirs) {
|
|
6782
|
+
return dirs.some((dir) => file === dir || file.startsWith(dir.endsWith("/") ? dir : `${dir}/`));
|
|
6783
|
+
}
|
|
6784
|
+
/**
|
|
6785
|
+
* Whether `file` is a temporary copy of a Vite config file. Vite writes one next
|
|
6786
|
+
* to the config file each time it loads it (when no node_modules folder is
|
|
6787
|
+
* above the config, and always under Deno), then deletes it.
|
|
6788
|
+
*/
|
|
6789
|
+
function isViteConfigTemp(file) {
|
|
6790
|
+
return /\.timestamp-\d+-[0-9a-f]*\.mjs$/.test(file);
|
|
6791
|
+
}
|
|
6792
|
+
//#endregion
|
|
6793
|
+
export { toPosix as a, splitDiagnostics as c, isExcluded as d, outputPaths as f, mediaTypeFromFilename as h, outputLocations as i, compileForOutputFile as l, walkedEntry as m, isInside as n, fatalError as o, realPathOf as p, isViteConfigTemp as r, formatDiagnostic as s, createOutputRecord as t, getFilenames as u };
|
|
5492
6794
|
|
|
5493
|
-
//# sourceMappingURL=
|
|
6795
|
+
//# sourceMappingURL=paths-DgO9oo6G.js.map
|