@hajimism/dek 0.4.0 → 0.5.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/CHANGELOG.md +73 -0
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/package.json +4 -3
- package/src/cli/address.ts +11 -4
- package/src/cli/commands.ts +47 -14
- package/src/cli/contract.ts +49 -3
- package/src/cli/files.ts +1 -0
- package/src/cli/format.ts +5 -4
- package/src/cli/goto.ts +41 -10
- package/src/cli/lint.ts +32 -13
- package/src/cli/ls.ts +5 -1
- package/src/cli/main.ts +46 -11
- package/src/cli/new.ts +14 -1
- package/src/cli/ref.ts +1 -0
- package/src/cli/result.ts +42 -4
- package/src/cli/scope.ts +40 -1
- package/src/cli/shot.ts +77 -6
- package/src/cli/show.ts +15 -2
- package/src/cli/text.ts +61 -9
- package/src/cli/theme.ts +49 -15
- package/src/cli/tty.ts +16 -2
- package/src/cli/voice.ts +11 -6
- package/src/core/assets.ts +18 -10
- package/src/core/attribute-value.ts +188 -0
- package/src/core/build.ts +3 -3
- package/src/core/css-scan.ts +173 -39
- package/src/core/css-transform.ts +57 -11
- package/src/core/css.ts +19 -13
- package/src/core/foreign-text.ts +138 -0
- package/src/core/github.ts +1 -0
- package/src/core/html-entities.ts +318 -0
- package/src/core/html-scan.ts +49 -27
- package/src/core/html.ts +21 -15
- package/src/core/lint/asset-refs.ts +44 -6
- package/src/core/lint/slide-html.ts +21 -18
- package/src/core/mv.ts +28 -18
- package/src/core/order.ts +14 -0
- package/src/core/path.ts +8 -2
- package/src/core/playwright-module.d.ts +12 -1
- package/src/core/playwright-motion.ts +1 -0
- package/src/core/playwright-sheet.ts +1 -0
- package/src/core/playwright-visual.ts +27 -10
- package/src/core/playwright.ts +77 -19
- package/src/core/pptx.ts +1 -0
- package/src/core/ref-name.ts +8 -3
- package/src/core/ref.ts +145 -47
- package/src/core/resolve.ts +3 -2
- package/src/core/rumdl.ts +51 -25
- package/src/core/safe-fs.ts +19 -2
- package/src/core/sandbox.ts +217 -0
- package/src/core/shot-cache.ts +1 -0
- package/src/core/slide-eval-worker.ts +1 -0
- package/src/core/slide-script-eval.ts +2 -0
- package/src/core/spawn.ts +131 -32
- package/src/core/sync.ts +3 -12
- package/src/core/tar.ts +315 -0
- package/src/core/text-contrast.ts +2 -0
- package/src/core/toml-keys.ts +116 -51
- package/src/core/url-attributes.ts +27 -9
- package/src/core/visual.ts +28 -9
- package/src/core/voice.ts +2 -1
- package/src/server/auth.ts +21 -0
- package/src/server/dev.ts +11 -3
- package/src/server/lock.ts +4 -4
- package/src/server/serial.ts +8 -2
- package/src/video/mux.ts +27 -3
- package/src/video/recorder.ts +3 -2
- package/src/video/worker.ts +1 -0
- package/src/voice/engine.ts +81 -18
package/src/cli/theme.ts
CHANGED
|
@@ -3,10 +3,11 @@ import { type CssToken, parseCss } from "../core/css.ts";
|
|
|
3
3
|
import { deckPaths } from "../core/deck-paths.ts";
|
|
4
4
|
import { DekError } from "../core/error.ts";
|
|
5
5
|
import { isPlainWord } from "../core/escape.ts";
|
|
6
|
+
import { foreignList, type HiddenText, hiddenText } from "../core/foreign-text.ts";
|
|
6
7
|
import { listShown, unprinted } from "../core/prose.ts";
|
|
7
8
|
import { readDeckFile } from "../core/resolve.ts";
|
|
8
9
|
import { type ThemeLayout, themeFacts } from "../core/theme-facts.ts";
|
|
9
|
-
import type
|
|
10
|
+
import { hiddenField, type ReadableDeck, type RefInfo } from "./scope.ts";
|
|
10
11
|
|
|
11
12
|
export type ThemeResult = {
|
|
12
13
|
/** The deck's theme.css: the one lint and the slides use. */
|
|
@@ -25,6 +26,8 @@ export type ThemeResult = {
|
|
|
25
26
|
layout?: { name: string; example: string };
|
|
26
27
|
/** Set when the deck is a ref. */
|
|
27
28
|
ref?: RefInfo;
|
|
29
|
+
/** A ref's lines of theme.css that hold characters a person cannot see. */
|
|
30
|
+
hidden?: HiddenText[];
|
|
28
31
|
};
|
|
29
32
|
|
|
30
33
|
/** The deck's theme, or one layout's example markup when `name` is given. */
|
|
@@ -36,9 +39,8 @@ export function themeCommand({ deck, ref }: ReadableDeck, name?: string): ThemeR
|
|
|
36
39
|
hint: "copy the project theme.css into the deck, or run `dekc new <name>` for a fresh deck",
|
|
37
40
|
});
|
|
38
41
|
}
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
);
|
|
42
|
+
const css = readDeckFile(deck.dir, path) ?? "";
|
|
43
|
+
const { classes, stateClasses, layouts, tokens } = themeFacts(parseCss(css));
|
|
42
44
|
const printed = layouts.filter((layout) => isPlainWord(layout.name));
|
|
43
45
|
const left = layouts.length - printed.length;
|
|
44
46
|
const result: ThemeResult = {
|
|
@@ -49,28 +51,60 @@ export function themeCommand({ deck, ref }: ReadableDeck, name?: string): ThemeR
|
|
|
49
51
|
layouts: printed,
|
|
50
52
|
...(left > 0 ? { unprintedLayouts: left } : {}),
|
|
51
53
|
...(ref ? { ref } : {}),
|
|
54
|
+
...hiddenField(ref, hiddenText(css, path)),
|
|
52
55
|
};
|
|
53
56
|
if (!name) {
|
|
54
57
|
return result;
|
|
55
58
|
}
|
|
56
59
|
const layout = layouts.find((entry) => entry.name === name);
|
|
57
60
|
if (!layout) {
|
|
58
|
-
throw
|
|
59
|
-
path,
|
|
60
|
-
hint: `use one of: ${
|
|
61
|
-
listShown(
|
|
62
|
-
layouts.map((entry) => entry.name),
|
|
63
|
-
isPlainWord,
|
|
64
|
-
unprinted,
|
|
65
|
-
) || "(none)"
|
|
66
|
-
}`,
|
|
67
|
-
});
|
|
61
|
+
throw layoutNotFound(name, layouts, path, ref);
|
|
68
62
|
}
|
|
69
63
|
if (layout.example === undefined) {
|
|
70
64
|
throw new DekError(`layout "${name}" has no example in theme.css`, {
|
|
71
65
|
path,
|
|
72
|
-
hint:
|
|
66
|
+
hint: ref
|
|
67
|
+
? `a ref is read-only; read the markup of a slide that uses it with \`dekc show ${ref.name} <slug>\``
|
|
68
|
+
: `add a /* @layout ${name} ... */ comment with its markup above .slide[data-layout="${name}"]`,
|
|
73
69
|
});
|
|
74
70
|
}
|
|
75
71
|
return { ...result, layout: { name, example: layout.example } };
|
|
76
72
|
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A layout the theme does not define, named among the layouts whose names are plain words. A
|
|
76
|
+
* ref's layout names are its author's, so for a ref they go in the message, as foreignList shows
|
|
77
|
+
* them, and the hint is dek's words.
|
|
78
|
+
*/
|
|
79
|
+
function layoutNotFound(
|
|
80
|
+
name: string,
|
|
81
|
+
layouts: ThemeLayout[],
|
|
82
|
+
path: string,
|
|
83
|
+
ref: RefInfo | undefined,
|
|
84
|
+
): DekError {
|
|
85
|
+
const names = layouts.map((entry) => entry.name);
|
|
86
|
+
if (!ref) {
|
|
87
|
+
return new DekError(`layout "${name}" is not defined in theme.css`, {
|
|
88
|
+
path,
|
|
89
|
+
hint: `use one of: ${listShown(names, isPlainWord, unprinted) || "(none)"}`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const shown = names.filter(isPlainWord);
|
|
93
|
+
const left = names.length - shown.length;
|
|
94
|
+
const others = left > 0 ? unprinted(left) : "";
|
|
95
|
+
const defined =
|
|
96
|
+
shown.length === 0
|
|
97
|
+
? others
|
|
98
|
+
: others
|
|
99
|
+
? `${foreignList(shown)}, and ${others}`
|
|
100
|
+
: foreignList(shown);
|
|
101
|
+
return shown.length > 0
|
|
102
|
+
? new DekError(`layout "${name}" is not defined in theme.css, which defines ${defined}`, {
|
|
103
|
+
path,
|
|
104
|
+
hint: `pass one of them: \`dekc theme ${ref.name} <layout>\``,
|
|
105
|
+
})
|
|
106
|
+
: new DekError(
|
|
107
|
+
`layout "${name}" is not defined in theme.css, which defines ${defined || "none"}`,
|
|
108
|
+
{ path, hint: `read the markup of a slide with \`dekc show ${ref.name} <slug>\`` },
|
|
109
|
+
);
|
|
110
|
+
}
|
package/src/cli/tty.ts
CHANGED
|
@@ -10,15 +10,29 @@ const codes = {
|
|
|
10
10
|
bold: "\x1b[1m",
|
|
11
11
|
} as const;
|
|
12
12
|
|
|
13
|
+
/** The codes dek's own palette writes, which are all terminalSafe lets through. */
|
|
14
|
+
const PALETTE = new Set<string>([reset, ...Object.values(codes)]);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Text with no control character but a newline and a tab: each other shows as U+FFFD, the escape
|
|
18
|
+
* that starts a color code included. A result's and an error's text go through it before dek
|
|
19
|
+
* paints them, so what a deck or a ref wrote carries no color, conceal, or cursor move of its own.
|
|
20
|
+
*/
|
|
21
|
+
export function plainText(text: string): string {
|
|
22
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching them is the point.
|
|
23
|
+
return text.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "\uFFFD");
|
|
24
|
+
}
|
|
25
|
+
|
|
13
26
|
/**
|
|
14
27
|
* Text for a terminal: dek's own colors, newlines, and tabs pass, and every other control
|
|
15
28
|
* character shows as U+FFFD. A deck title, a file name, or a ref's title fetched from GitHub
|
|
16
|
-
* could otherwise set the window title, draw a fake link (OSC 8),
|
|
29
|
+
* could otherwise set the window title, draw a fake link (OSC 8), clear the screen, or hide
|
|
30
|
+
* itself from a person while an agent reading the bytes sees it, as conceal (SGR 8) does.
|
|
17
31
|
*/
|
|
18
32
|
export function terminalSafe(text: string): string {
|
|
19
33
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching them is the point.
|
|
20
34
|
return text.replace(/\x1b\[[0-9;]*m|[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, (match) =>
|
|
21
|
-
match
|
|
35
|
+
PALETTE.has(match) ? match : plainText(match),
|
|
22
36
|
);
|
|
23
37
|
}
|
|
24
38
|
|
package/src/cli/voice.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
1
|
import { readFileSync } from "node:fs";
|
|
3
2
|
import { join } from "node:path";
|
|
4
3
|
import { DekError } from "../core/error.ts";
|
|
5
4
|
import { deckProjectRoot } from "../core/path.ts";
|
|
6
5
|
import { isCachedFile, requireInside, writeInside } from "../core/safe-fs.ts";
|
|
6
|
+
import { runPiped } from "../core/spawn.ts";
|
|
7
7
|
import { loadVoiceDict, loadVoiceSettings, voiceCacheFile, writeVoiceDict } from "../core/voice.ts";
|
|
8
8
|
import {
|
|
9
9
|
engineBaseUrl,
|
|
@@ -97,6 +97,9 @@ export function pinVoice({ deck }: DeckTarget): VoiceCliResult {
|
|
|
97
97
|
return { action: "pin", timelinePath: pinTimeline, audioPath: pinAudio };
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/** A clip of a sentence or two plays well inside this. */
|
|
101
|
+
const PLAY_TIMEOUT_MS = 5 * 60 * 1000;
|
|
102
|
+
|
|
100
103
|
async function playWav(path: string): Promise<void> {
|
|
101
104
|
if (process.env.DEK_VOICE_PLAY === "0") {
|
|
102
105
|
return;
|
|
@@ -106,9 +109,11 @@ async function playWav(path: string): Promise<void> {
|
|
|
106
109
|
if (!bin) {
|
|
107
110
|
return;
|
|
108
111
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
// Playing is a courtesy: the clip is written either way, so a player that is missing, fails,
|
|
113
|
+
// or hangs is let go.
|
|
114
|
+
await runPiped([bin, path], {
|
|
115
|
+
label: `${bin} failed`,
|
|
116
|
+
hint: `play ${path} yourself`,
|
|
117
|
+
timeoutMs: PLAY_TIMEOUT_MS,
|
|
118
|
+
}).catch(() => {});
|
|
114
119
|
}
|
package/src/core/assets.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { extname, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { decodeAttribute } from "./attribute-value.ts";
|
|
3
4
|
import { parseCss } from "./css.ts";
|
|
4
5
|
import {
|
|
5
6
|
type CssEditor,
|
|
@@ -10,9 +11,10 @@ import {
|
|
|
10
11
|
shareTokensWithTransitions,
|
|
11
12
|
} from "./css-transform.ts";
|
|
12
13
|
import { deckPaths } from "./deck-paths.ts";
|
|
14
|
+
import { escapeAttr } from "./escape.ts";
|
|
13
15
|
import { isInside } from "./path.ts";
|
|
14
16
|
import { listSlideFiles, readDeckFile } from "./resolve.ts";
|
|
15
|
-
import { attributeUrls } from "./url-attributes.ts";
|
|
17
|
+
import { attributeUrls, trimUrl } from "./url-attributes.ts";
|
|
16
18
|
|
|
17
19
|
/** Where one reference from a slide or a stylesheet points. */
|
|
18
20
|
export type AssetRef =
|
|
@@ -35,13 +37,14 @@ const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
|
|
35
37
|
* Resolves a reference the one way lint, show, and the build agree on: the
|
|
36
38
|
* deck root first, which is where the dev server and the build look for
|
|
37
39
|
* `assets/`, then next to the file that names it, so `../assets/x` from
|
|
38
|
-
* slides/ still resolves. A query string or fragment is not part of the path
|
|
40
|
+
* slides/ still resolves. A query string or fragment is not part of the path, and only the C0
|
|
41
|
+
* controls and spaces at its ends are trimmed, as the URL parser trims them.
|
|
39
42
|
*/
|
|
40
43
|
export function classifyAssetRef(
|
|
41
44
|
raw: string,
|
|
42
45
|
options: { deckDir: string; from: string },
|
|
43
46
|
): AssetRef {
|
|
44
|
-
const value = raw
|
|
47
|
+
const value = trimUrl(raw).split(/[?#]/)[0] ?? "";
|
|
45
48
|
if (!value) {
|
|
46
49
|
return { kind: "skip" };
|
|
47
50
|
}
|
|
@@ -93,7 +96,7 @@ export function fileInside(
|
|
|
93
96
|
|
|
94
97
|
/** True when a reference is written as `assets/...` from the deck root, or is not a local path at all. */
|
|
95
98
|
export function isCanonicalAssetPath(raw: string): boolean {
|
|
96
|
-
const value = raw
|
|
99
|
+
const value = trimUrl(raw);
|
|
97
100
|
if (!value || value.startsWith("#") || SCHEME_RE.test(value) || REMOTE_RE.test(value)) {
|
|
98
101
|
return true;
|
|
99
102
|
}
|
|
@@ -118,8 +121,10 @@ export function inlineAssets(
|
|
|
118
121
|
|
|
119
122
|
/**
|
|
120
123
|
* The rewriter handler `inlineAssets` runs, for a pass that rewrites more than URLs. The URLs are
|
|
121
|
-
* the ones lint checks, read by the same parser
|
|
122
|
-
* descriptors, links and text stay as written.
|
|
124
|
+
* the ones lint checks, read by the same parser and decoded as the browser decodes them: every
|
|
125
|
+
* candidate of a `srcset` keeps its descriptors, links and text stay as written. The rewriter
|
|
126
|
+
* writes a value as it is given, escaping only `"`, so each data: URI goes in escaped, in place
|
|
127
|
+
* of the URL as written, and an attribute with nothing to inline is never written at all.
|
|
123
128
|
*/
|
|
124
129
|
export function assetInliner(
|
|
125
130
|
deckDir: string,
|
|
@@ -128,7 +133,8 @@ export function assetInliner(
|
|
|
128
133
|
return {
|
|
129
134
|
element(el) {
|
|
130
135
|
const tag = el.tagName.toLowerCase();
|
|
131
|
-
for (const [name,
|
|
136
|
+
for (const [name, written] of [...el.attributes]) {
|
|
137
|
+
const { value, writtenAt } = decodeAttribute(written);
|
|
132
138
|
const named = attributeUrls(tag, name.toLowerCase(), value);
|
|
133
139
|
if (named?.use !== "resource") {
|
|
134
140
|
continue;
|
|
@@ -137,9 +143,11 @@ export function assetInliner(
|
|
|
137
143
|
const uri = inlinedUri(url, deckDir, from);
|
|
138
144
|
return uri === undefined
|
|
139
145
|
? rewritten
|
|
140
|
-
: rewritten.slice(0, offset) +
|
|
141
|
-
|
|
142
|
-
|
|
146
|
+
: rewritten.slice(0, writtenAt(offset)) +
|
|
147
|
+
escapeAttr(uri) +
|
|
148
|
+
rewritten.slice(writtenAt(offset + url.length));
|
|
149
|
+
}, written);
|
|
150
|
+
if (inlined !== written) {
|
|
143
151
|
el.setAttribute(name, inlined);
|
|
144
152
|
}
|
|
145
153
|
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An attribute's value as the browser reads it. Bun's HTMLRewriter gives each value as written,
|
|
3
|
+
* character references and all, so `assets/Q&A.png` would name a file the page never loads.
|
|
4
|
+
* Every value lint or the build reads off the rewriter goes through here first; a value written
|
|
5
|
+
* back into the markup is written from the one as written, never from this.
|
|
6
|
+
*/
|
|
7
|
+
import { LEGACY_REFERENCES, NAMED_REFERENCES } from "./html-entities.ts";
|
|
8
|
+
|
|
9
|
+
/** A decoded value, and where in the value as written each of its UTF-16 units comes from. */
|
|
10
|
+
export type DecodedAttribute = {
|
|
11
|
+
value: string;
|
|
12
|
+
/** The offset in the written value of the unit at `index`; `value.length` maps past the end. */
|
|
13
|
+
writtenAt(index: number): number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** The value of `name` on a rewriter's element as the browser reads it, or null when absent. */
|
|
17
|
+
export function attributeOf(
|
|
18
|
+
element: { getAttribute(name: string): string | null },
|
|
19
|
+
name: string,
|
|
20
|
+
): string | null {
|
|
21
|
+
const written = element.getAttribute(name);
|
|
22
|
+
return written === null ? null : decodeAttribute(written).value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether `char` is ASCII whitespace, the only whitespace HTML splits a list of tokens on or trims
|
|
27
|
+
* a value of. A no-break space, written as itself or as ` `, is part of the value.
|
|
28
|
+
*/
|
|
29
|
+
export function isAsciiWhitespace(char: string | undefined): boolean {
|
|
30
|
+
return char === " " || char === "\t" || char === "\n" || char === "\f" || char === "\r";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The tokens of a value such as `class` or `role`, split on ASCII whitespace as HTML splits it. */
|
|
34
|
+
export function attributeTokens(value: string | null | undefined): string[] {
|
|
35
|
+
return (value ?? "").split(/[\t\n\f\r ]+/).filter(Boolean);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `value` without the ASCII whitespace at its ends, as HTML trims a keyword or a name. */
|
|
39
|
+
export function trimAsciiWhitespace(value: string): string {
|
|
40
|
+
return value.replace(/^[\t\n\f\r ]+|[\t\n\f\r ]+$/g, "");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `written` read the way the HTML standard reads an attribute value: every numeric and named
|
|
45
|
+
* character reference decoded, a carriage return read as a line feed, and a NUL as U+FFFD. A
|
|
46
|
+
* name the standard allows without its `;` stays as written when a letter, a digit, or `=`
|
|
47
|
+
* follows it, so the `©` of `?a=1©=2` is left alone, as the browser leaves it.
|
|
48
|
+
*/
|
|
49
|
+
export function decodeAttribute(written: string): DecodedAttribute {
|
|
50
|
+
// Each replacement, where it is in the decoded value and where in the written one, in order.
|
|
51
|
+
const spans: Array<{ start: number; end: number; writtenStart: number; writtenEnd: number }> = [];
|
|
52
|
+
let shift = 0;
|
|
53
|
+
const value = written.replace(
|
|
54
|
+
REFERENCE_RE,
|
|
55
|
+
(match: string, hex?: string, decimal?: string, name?: string, semicolon?: string, at = 0) => {
|
|
56
|
+
const text = replacement(written, match, at, { hex, decimal, name, semicolon });
|
|
57
|
+
if (text !== match) {
|
|
58
|
+
const start = at - shift;
|
|
59
|
+
spans.push({
|
|
60
|
+
start,
|
|
61
|
+
end: start + text.length,
|
|
62
|
+
writtenStart: at,
|
|
63
|
+
writtenEnd: at + match.length,
|
|
64
|
+
});
|
|
65
|
+
shift += match.length - text.length;
|
|
66
|
+
}
|
|
67
|
+
return text;
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
return {
|
|
71
|
+
value,
|
|
72
|
+
writtenAt(index) {
|
|
73
|
+
let offset = index;
|
|
74
|
+
for (const span of spans) {
|
|
75
|
+
if (index < span.start) {
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
if (index < span.end) {
|
|
79
|
+
return span.writtenStart;
|
|
80
|
+
}
|
|
81
|
+
offset = span.writtenEnd + (index - span.end);
|
|
82
|
+
}
|
|
83
|
+
return offset;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A character reference, with its parts as groups; a carriage return, which the input stream reads
|
|
90
|
+
* as a line feed; or a NUL, which the tokenizer reads as U+FFFD in a value.
|
|
91
|
+
*/
|
|
92
|
+
const REFERENCE_RE = /&(?:#(?:[xX]([0-9a-fA-F]+)|([0-9]+));?|([A-Za-z0-9]+)(;?))|\r\n?|\0/g;
|
|
93
|
+
|
|
94
|
+
/** What one match of REFERENCE_RE reads as: itself, when the standard leaves it as written. */
|
|
95
|
+
function replacement(
|
|
96
|
+
written: string,
|
|
97
|
+
match: string,
|
|
98
|
+
at: number,
|
|
99
|
+
parts: { hex?: string; decimal?: string; name?: string; semicolon?: string },
|
|
100
|
+
): string {
|
|
101
|
+
if (match === "\0") {
|
|
102
|
+
return "\uFFFD";
|
|
103
|
+
}
|
|
104
|
+
if (match.startsWith("\r")) {
|
|
105
|
+
return "\n";
|
|
106
|
+
}
|
|
107
|
+
if (parts.hex !== undefined || parts.decimal !== undefined) {
|
|
108
|
+
return numericReference(
|
|
109
|
+
parts.hex === undefined
|
|
110
|
+
? Number.parseInt(parts.decimal ?? "", 10)
|
|
111
|
+
: Number.parseInt(parts.hex, 16),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const name = parts.name ?? "";
|
|
115
|
+
if (parts.semicolon) {
|
|
116
|
+
return namedReferences().get(name) ?? match;
|
|
117
|
+
}
|
|
118
|
+
// The browser takes the longest name it knows. A legacy name shorter than the run of letters
|
|
119
|
+
// and digits is followed by one of them, which keeps it as written, so only the whole run can.
|
|
120
|
+
if (legacyReferences().has(name) && written[at + match.length] !== "=") {
|
|
121
|
+
return namedReferences().get(name) ?? match;
|
|
122
|
+
}
|
|
123
|
+
return match;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The character a numeric reference stands for: U+FFFD for NUL, a surrogate, or a number past
|
|
128
|
+
* Unicode, and for 0x80 to 0x9F the Windows-1252 character the standard reads it as.
|
|
129
|
+
*/
|
|
130
|
+
function numericReference(number: number): string {
|
|
131
|
+
if (number === 0 || number > 0x10ffff || (number >= 0xd800 && number <= 0xdfff)) {
|
|
132
|
+
return "\uFFFD";
|
|
133
|
+
}
|
|
134
|
+
return String.fromCodePoint(C1_REPLACEMENTS.get(number) ?? number);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The HTML standard's table for a numeric reference to a C1 control. */
|
|
138
|
+
const C1_REPLACEMENTS: ReadonlyMap<number, number> = new Map([
|
|
139
|
+
[0x80, 0x20ac],
|
|
140
|
+
[0x82, 0x201a],
|
|
141
|
+
[0x83, 0x0192],
|
|
142
|
+
[0x84, 0x201e],
|
|
143
|
+
[0x85, 0x2026],
|
|
144
|
+
[0x86, 0x2020],
|
|
145
|
+
[0x87, 0x2021],
|
|
146
|
+
[0x88, 0x02c6],
|
|
147
|
+
[0x89, 0x2030],
|
|
148
|
+
[0x8a, 0x0160],
|
|
149
|
+
[0x8b, 0x2039],
|
|
150
|
+
[0x8c, 0x0152],
|
|
151
|
+
[0x8e, 0x017d],
|
|
152
|
+
[0x91, 0x2018],
|
|
153
|
+
[0x92, 0x2019],
|
|
154
|
+
[0x93, 0x201c],
|
|
155
|
+
[0x94, 0x201d],
|
|
156
|
+
[0x95, 0x2022],
|
|
157
|
+
[0x96, 0x2013],
|
|
158
|
+
[0x97, 0x2014],
|
|
159
|
+
[0x98, 0x02dc],
|
|
160
|
+
[0x99, 0x2122],
|
|
161
|
+
[0x9a, 0x0161],
|
|
162
|
+
[0x9b, 0x203a],
|
|
163
|
+
[0x9c, 0x0153],
|
|
164
|
+
[0x9e, 0x017e],
|
|
165
|
+
[0x9f, 0x0178],
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
let named: Map<string, string> | undefined;
|
|
169
|
+
let legacy: Set<string> | undefined;
|
|
170
|
+
|
|
171
|
+
/** Each named reference, without its `;`, and the characters it stands for; read once. */
|
|
172
|
+
function namedReferences(): Map<string, string> {
|
|
173
|
+
named ??= new Map(
|
|
174
|
+
NAMED_REFERENCES.split(" ").map((entry) => {
|
|
175
|
+
const [name = "", points = ""] = entry.split(":");
|
|
176
|
+
return [
|
|
177
|
+
name,
|
|
178
|
+
String.fromCodePoint(...points.split("+").map((point) => Number.parseInt(point, 16))),
|
|
179
|
+
];
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
return named;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function legacyReferences(): Set<string> {
|
|
186
|
+
legacy ??= new Set(LEGACY_REFERENCES.split(" "));
|
|
187
|
+
return legacy;
|
|
188
|
+
}
|
package/src/core/build.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
2
|
import { basename } from "node:path";
|
|
3
3
|
import { loadConfig } from "./config.ts";
|
|
4
4
|
import { renderDeckDocument } from "./document.ts";
|
|
5
5
|
import { type DistOptions, distFile } from "./path.ts";
|
|
6
6
|
import type { PlaywrightRunner } from "./playwright.ts";
|
|
7
7
|
import { asResolvedDeck, type ResolvedDeck } from "./resolve.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { removeInside, writeInside } from "./safe-fs.ts";
|
|
9
9
|
import { coverShot } from "./shot/still.ts";
|
|
10
10
|
import { logicalSize } from "./size.ts";
|
|
11
11
|
|
|
@@ -43,7 +43,7 @@ export async function buildDeck(
|
|
|
43
43
|
const shot = baseUrl ? await coverShot(deck, options.runner) : undefined;
|
|
44
44
|
|
|
45
45
|
if (shot) {
|
|
46
|
-
|
|
46
|
+
writeInside(imagePath, readFileSync(shot), project.root);
|
|
47
47
|
} else {
|
|
48
48
|
// An image from an earlier build would outlive the tag that pointed at it.
|
|
49
49
|
removeInside(imagePath, project.root);
|