@hajimism/dek 0.3.3 → 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 +136 -0
- package/README.ja.md +2 -2
- package/README.md +2 -2
- package/package.json +5 -3
- package/skills/dek/SKILL.md +10 -3
- package/skills/dek-design/SKILL.md +42 -0
- package/src/cli/address.ts +95 -18
- package/src/cli/build.ts +2 -2
- package/src/cli/check.ts +2 -1
- package/src/cli/commands.ts +57 -19
- package/src/cli/contract.ts +58 -4
- package/src/cli/files.ts +2 -5
- package/src/cli/flags.ts +1 -1
- package/src/cli/format.ts +5 -4
- package/src/cli/goto.ts +44 -11
- package/src/cli/init.ts +10 -5
- package/src/cli/lint.ts +36 -16
- package/src/cli/ls.ts +5 -1
- package/src/cli/main.ts +51 -14
- package/src/cli/new.ts +28 -8
- package/src/cli/pptx.ts +9 -4
- package/src/cli/ref.ts +92 -9
- package/src/cli/result.ts +47 -6
- package/src/cli/scope.ts +57 -29
- package/src/cli/shot.ts +78 -6
- package/src/cli/show.ts +15 -2
- package/src/cli/text.ts +88 -11
- package/src/cli/theme.ts +58 -10
- package/src/cli/tty.ts +16 -2
- package/src/cli/voice.ts +15 -9
- package/src/core/alignment.ts +320 -0
- package/src/core/annotation-rows.ts +16 -4
- package/src/core/annotations.ts +19 -1
- 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/config.ts +61 -12
- 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/diagnostic.ts +5 -1
- package/src/core/escape.ts +43 -0
- 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/line-break.ts +275 -0
- package/src/core/lint/asset-refs.ts +50 -8
- package/src/core/lint/files.ts +30 -5
- package/src/core/lint/keys.ts +7 -3
- package/src/core/lint/slide-html.ts +24 -19
- package/src/core/lint/slide-script.ts +7 -5
- package/src/core/mv.ts +57 -27
- package/src/core/order.ts +14 -0
- package/src/core/pack.ts +2 -2
- package/src/core/path.ts +8 -2
- package/src/core/playwright-module.d.ts +13 -1
- package/src/core/playwright-motion.ts +1 -0
- package/src/core/playwright-sheet.ts +1 -0
- package/src/core/playwright-visual.ts +207 -30
- package/src/core/playwright.ts +279 -44
- package/src/core/pptx-package.ts +461 -33
- package/src/core/pptx-paint.ts +1168 -0
- package/src/core/pptx-plan.ts +879 -0
- package/src/core/pptx-text.ts +204 -47
- package/src/core/pptx-theme.ts +222 -0
- package/src/core/pptx.ts +56 -8
- package/src/core/prose.ts +23 -0
- package/src/core/pseudo-text.ts +32 -10
- package/src/core/ref-name.ts +52 -8
- package/src/core/ref.ts +308 -54
- package/src/core/resolve.ts +23 -11
- 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/morph.ts +1 -1
- package/src/core/shot/motion.ts +2 -3
- package/src/core/shot/still.ts +2 -2
- package/src/core/shot-cache.ts +26 -17
- package/src/core/slide-eval-worker.ts +1 -0
- package/src/core/slide-measure.ts +51 -1
- package/src/core/slide-script-eval.ts +12 -8
- package/src/core/slide-script.ts +114 -20
- package/src/core/spawn.ts +131 -32
- package/src/core/sync.ts +5 -14
- 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-cache.ts +2 -0
- package/src/core/visual.ts +182 -24
- package/src/core/voice.ts +5 -6
- package/src/core/zod.ts +9 -1
- package/src/server/auth.ts +21 -0
- package/src/server/dev.ts +26 -7
- package/src/server/lock.ts +50 -7
- package/src/server/routes.ts +7 -2
- package/src/server/serial.ts +8 -2
- package/src/theme/default.css +4 -0
- package/src/video/bake.ts +6 -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/core/rumdl.ts
CHANGED
|
@@ -2,9 +2,16 @@ import { dirname } from "node:path";
|
|
|
2
2
|
import { type Diagnostic, errorDiagnostic } from "./diagnostic.ts";
|
|
3
3
|
import { resolveBinFromAncestors } from "./optional.ts";
|
|
4
4
|
import type { SarifLog } from "./sarif.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { OverrunError, runPiped, workerCommand } from "./spawn.ts";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
/** Why lint has no findings from rumdl for a script: the `reason` when dek knows it, and `hung`. */
|
|
8
|
+
export type RumdlSkip = { reason?: string; hung?: true };
|
|
9
|
+
|
|
10
|
+
/** rumdl's SARIF for a script, or why there is none. */
|
|
11
|
+
export type RumdlRunner = (scriptPath: string) => Promise<string | RumdlSkip>;
|
|
12
|
+
|
|
13
|
+
/** rumdl checks a script in well under a second; a run this long is one that hangs. */
|
|
14
|
+
const RUMDL_TIMEOUT_MS = 30 * 1000;
|
|
8
15
|
|
|
9
16
|
export function resolveRumdlBin(): string | undefined {
|
|
10
17
|
if (process.env.DEK_RUMDL) {
|
|
@@ -17,30 +24,45 @@ export function resolveRumdlBin(): string | undefined {
|
|
|
17
24
|
return onPath ?? resolveBinFromAncestors("rumdl", import.meta.dir);
|
|
18
25
|
}
|
|
19
26
|
|
|
20
|
-
|
|
27
|
+
/** Runs rumdl beside the script; one that cannot start says nothing, one that hangs is stopped. */
|
|
28
|
+
export async function defaultRumdlRunner(
|
|
29
|
+
scriptPath: string,
|
|
30
|
+
options: { timeoutMs?: number } = {},
|
|
31
|
+
): Promise<string | RumdlSkip> {
|
|
21
32
|
const bin = resolveRumdlBin();
|
|
22
33
|
if (!bin) {
|
|
23
|
-
return
|
|
34
|
+
return {};
|
|
24
35
|
}
|
|
25
36
|
|
|
37
|
+
const timeoutMs = options.timeoutMs ?? RUMDL_TIMEOUT_MS;
|
|
26
38
|
try {
|
|
27
39
|
const args = ["check", scriptPath, "--output-format", "sarif"];
|
|
28
|
-
|
|
29
|
-
const
|
|
40
|
+
// A rumdl that cannot start is skipped below, so its label and hint never reach the user.
|
|
41
|
+
const { stdout, exitCode, cutShort } = await runPiped(workerCommand(bin, args), {
|
|
42
|
+
label: "rumdl failed",
|
|
43
|
+
hint: "",
|
|
44
|
+
// runPiped passes the environment as it is now, like the PATH rumdl was found on.
|
|
30
45
|
cwd: dirname(scriptPath),
|
|
31
|
-
|
|
32
|
-
// the one the process started with.
|
|
33
|
-
env: process.env,
|
|
34
|
-
stdout: "pipe",
|
|
35
|
-
stderr: "pipe",
|
|
46
|
+
timeoutMs,
|
|
36
47
|
});
|
|
37
|
-
const { stdout, exitCode } = await awaitPiped(proc);
|
|
38
48
|
if (exitCode === 2) {
|
|
39
|
-
return
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
if (cutShort) {
|
|
52
|
+
return {
|
|
53
|
+
reason:
|
|
54
|
+
"rumdl exited while a process it started still held its output, so it was cut short",
|
|
55
|
+
};
|
|
40
56
|
}
|
|
41
57
|
return stdout;
|
|
42
|
-
} catch {
|
|
43
|
-
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error instanceof OverrunError) {
|
|
60
|
+
return {
|
|
61
|
+
reason: `rumdl did not finish within ${timeoutMs / 1000}s and was stopped`,
|
|
62
|
+
hung: true,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return {};
|
|
44
66
|
}
|
|
45
67
|
}
|
|
46
68
|
|
|
@@ -51,34 +73,38 @@ export async function rumdlDiagnostics(
|
|
|
51
73
|
return (await runRumdl(scriptPath, run)).diagnostics;
|
|
52
74
|
}
|
|
53
75
|
|
|
76
|
+
/**
|
|
77
|
+
* rumdl's findings on the script, or why there are none (`skipped`): the deck is not what is
|
|
78
|
+
* wrong, so lint goes on without them and says why.
|
|
79
|
+
*/
|
|
54
80
|
export async function runRumdl(
|
|
55
81
|
scriptPath: string,
|
|
56
82
|
run: RumdlRunner = defaultRumdlRunner,
|
|
57
|
-
): Promise<{ diagnostics: Diagnostic[]; sarif?: SarifLog; skipped?:
|
|
83
|
+
): Promise<{ diagnostics: Diagnostic[]; sarif?: SarifLog; skipped?: RumdlSkip }> {
|
|
58
84
|
const text = await run(scriptPath);
|
|
59
|
-
if (text
|
|
60
|
-
return { diagnostics: [], skipped:
|
|
85
|
+
if (typeof text !== "string") {
|
|
86
|
+
return { diagnostics: [], skipped: text };
|
|
61
87
|
}
|
|
62
88
|
return parseRumdlSarif(text);
|
|
63
89
|
}
|
|
64
90
|
|
|
65
|
-
|
|
91
|
+
/** rumdl prints SARIF even for a clean script, so anything else is a check that did not happen. */
|
|
92
|
+
const NOT_SARIF: RumdlSkip = { reason: "rumdl printed something other than SARIF" };
|
|
93
|
+
|
|
94
|
+
function parseRumdlSarif(text: string): {
|
|
66
95
|
diagnostics: Diagnostic[];
|
|
67
96
|
sarif?: SarifLog;
|
|
97
|
+
skipped?: RumdlSkip;
|
|
68
98
|
} {
|
|
69
|
-
if (!text?.trim()) {
|
|
70
|
-
return { diagnostics: [] };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
99
|
let parsed: unknown;
|
|
74
100
|
try {
|
|
75
101
|
parsed = JSON.parse(text);
|
|
76
102
|
} catch {
|
|
77
|
-
return { diagnostics: [] };
|
|
103
|
+
return { diagnostics: [], skipped: NOT_SARIF };
|
|
78
104
|
}
|
|
79
105
|
|
|
80
106
|
if (!parsed || typeof parsed !== "object" || !("runs" in parsed) || !Array.isArray(parsed.runs)) {
|
|
81
|
-
return { diagnostics: [] };
|
|
107
|
+
return { diagnostics: [], skipped: NOT_SARIF };
|
|
82
108
|
}
|
|
83
109
|
|
|
84
110
|
const sarif = parsed as SarifLog;
|
package/src/core/safe-fs.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import {
|
|
3
|
+
chmodSync,
|
|
3
4
|
existsSync,
|
|
4
5
|
lstatSync,
|
|
5
6
|
mkdirSync,
|
|
@@ -146,7 +147,8 @@ export function writeInside(
|
|
|
146
147
|
/**
|
|
147
148
|
* Write `data` as a new file at `path`, in a folder already known to be inside the project: a
|
|
148
149
|
* link at `path`, such as one planted at a cache entry's predictable name, is replaced, never
|
|
149
|
-
* written through.
|
|
150
|
+
* written through. The new file takes `mode`, else the mode of the file it replaces, as a write
|
|
151
|
+
* in place keeps it, so a script.md only its owner may read stays that way.
|
|
150
152
|
*/
|
|
151
153
|
export function replaceFile(
|
|
152
154
|
path: string,
|
|
@@ -154,11 +156,16 @@ export function replaceFile(
|
|
|
154
156
|
options: { mode?: number } = {},
|
|
155
157
|
): void {
|
|
156
158
|
const temp = join(dirname(path), `.${basename(path)}.${randomUUID().slice(0, 8)}.tmp`);
|
|
159
|
+
const mode = options.mode ?? modeOf(path);
|
|
157
160
|
writeFileSync(temp, data, {
|
|
158
161
|
flag: "wx",
|
|
159
|
-
...(
|
|
162
|
+
...(mode !== undefined ? { mode } : {}),
|
|
160
163
|
});
|
|
161
164
|
try {
|
|
165
|
+
// The mode as it was, not as the umask leaves it on a new file.
|
|
166
|
+
if (options.mode === undefined && mode !== undefined) {
|
|
167
|
+
chmodSync(temp, mode);
|
|
168
|
+
}
|
|
162
169
|
renameSync(temp, path);
|
|
163
170
|
} catch (error) {
|
|
164
171
|
rmSync(temp, { force: true });
|
|
@@ -166,6 +173,16 @@ export function replaceFile(
|
|
|
166
173
|
}
|
|
167
174
|
}
|
|
168
175
|
|
|
176
|
+
/** The permission bits of the plain file at `path`, if one is there; a link's target is not asked. */
|
|
177
|
+
function modeOf(path: string): number | undefined {
|
|
178
|
+
try {
|
|
179
|
+
const stat = lstatSync(path);
|
|
180
|
+
return stat.isFile() ? stat.mode & 0o777 : undefined;
|
|
181
|
+
} catch {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
169
186
|
/** Remove every link directly in `dir`, a folder of dek's own output that a writer fills by name. */
|
|
170
187
|
export function dropLinks(dir: string): void {
|
|
171
188
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { DekError } from "./error.ts";
|
|
2
|
+
import { runJsonWorker } from "./spawn.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Set to 1, the user accepts that Chromium draws without its sandbox where the sandbox cannot
|
|
6
|
+
* start, as in a container running as root: a ref's slides too, which are refused otherwise, and
|
|
7
|
+
* the project's own with no warning. Read only from the environment dek starts in (ADR 12).
|
|
8
|
+
*/
|
|
9
|
+
const ALLOW_UNSANDBOXED = "DEK_ALLOW_UNSANDBOXED";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What a Playwright worker is told of Chromium's sandbox: draw only inside it, or inside it where
|
|
13
|
+
* it starts and without it where it does not. A worker told anything else requires it.
|
|
14
|
+
*/
|
|
15
|
+
export type SandboxMode = "required" | "preferred";
|
|
16
|
+
|
|
17
|
+
/** Chromium drew without its sandbox: why, in Chromium's words, and how to have it back. */
|
|
18
|
+
type Unsandboxed = { reason: string; hint: string };
|
|
19
|
+
|
|
20
|
+
function accepted(): boolean {
|
|
21
|
+
return process.env[ALLOW_UNSANDBOXED] === "1";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How a worker may draw: a ref's slide scripts are someone else's, so only in the sandbox unless
|
|
26
|
+
* the user accepted otherwise; the project's own wherever Chromium starts (ADR 29).
|
|
27
|
+
*/
|
|
28
|
+
export function sandboxMode(ref: boolean): SandboxMode {
|
|
29
|
+
return ref && !accepted() ? "required" : "preferred";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What Chromium says when its sandbox cannot start: as root, without user namespaces, or with its
|
|
34
|
+
* SUID helper set up wrong; then what Playwright 1.64 puts in place of Chromium's log when it holds
|
|
35
|
+
* one of those, which the call log after it still holds.
|
|
36
|
+
*/
|
|
37
|
+
const SANDBOX_SAYS = [
|
|
38
|
+
/No usable sandbox!|Running as root without --no-sandbox|SUID sandbox helper|Failed to move to new namespace|Received invalid process ID for zygote/,
|
|
39
|
+
/Chromium sandboxing failed!/,
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/** A line Chromium wrote to stderr, as Playwright's call log marks it, or one it logged as FATAL. */
|
|
43
|
+
const CHROMIUM_SAYS = /\[err\]|FATAL/;
|
|
44
|
+
|
|
45
|
+
/** The most of a line a reason keeps, in whole sentences; it keeps the first however long. */
|
|
46
|
+
const REASON_CHARS = 280;
|
|
47
|
+
|
|
48
|
+
/** A sentence: up to a `.`, `!`, or `?` that ends a word, outside parentheses, as `(0 vs. 1)` is. */
|
|
49
|
+
const SENTENCE = /(?:\([^)]*\)|\((?!\)|$)|[^()!?\.]|[.!?](?!\s|$))+(?:[.!?]+|$)/g;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What Chromium said when it failed: its words for a sandbox that cannot start, else its first
|
|
53
|
+
* line of error, past the call log's `- [pid=…][err]` and its own `[…:FATAL:…]`. Undefined when
|
|
54
|
+
* it said nothing, as when Playwright found no Chromium to start.
|
|
55
|
+
*/
|
|
56
|
+
export function chromiumFailure(error: unknown): string | undefined {
|
|
57
|
+
const lines = (error instanceof Error ? error.message : String(error)).split("\n");
|
|
58
|
+
for (const pattern of [...SANDBOX_SAYS, CHROMIUM_SAYS]) {
|
|
59
|
+
const line = lines.find((text) => pattern.test(text));
|
|
60
|
+
if (line !== undefined) {
|
|
61
|
+
return sentences(line.replace(/^[\s-]*(?:\[[^\]]*\]\s*)*/, ""));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Why a launch failed, in a line: what Chromium said, else the first line of Playwright's error.
|
|
69
|
+
* Never the command line Playwright's call log opens with, or its advice to launch without the
|
|
70
|
+
* sandbox.
|
|
71
|
+
*/
|
|
72
|
+
export function launchFailure(error: unknown): string {
|
|
73
|
+
const first = (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
|
|
74
|
+
return chromiumFailure(error) ?? sentences(first);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sentences(text: string): string {
|
|
78
|
+
let kept = "";
|
|
79
|
+
for (const [found] of text.trim().matchAll(SENTENCE)) {
|
|
80
|
+
const sentence = found.trim();
|
|
81
|
+
const next = kept ? `${kept} ${sentence}` : sentence;
|
|
82
|
+
if (kept && next.length > REASON_CHARS) {
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
kept = next;
|
|
86
|
+
}
|
|
87
|
+
return kept || text.trim();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const GENERAL_HELP =
|
|
91
|
+
"run dekc where Chromium's sandbox can start: as a user other than root, with user namespaces allowed (on Ubuntu 23.10 and later, `sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0`)";
|
|
92
|
+
|
|
93
|
+
/** What lets the sandbox start, for what Chromium said. */
|
|
94
|
+
function sandboxHelp(reason: string): string {
|
|
95
|
+
if (/Running as root/.test(reason)) {
|
|
96
|
+
return "run dekc as a user other than root, where Chromium's sandbox can start";
|
|
97
|
+
}
|
|
98
|
+
if (/No usable sandbox|namespace|zygote|AppArmor/i.test(reason)) {
|
|
99
|
+
return "let Chromium make user namespaces: on Ubuntu 23.10 and later, `sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0`, or an AppArmor profile for it; in a container, a seccomp profile that allows them";
|
|
100
|
+
}
|
|
101
|
+
return GENERAL_HELP;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** How to have the sandbox back, and while the user has not, how to accept going without it. */
|
|
105
|
+
function unsandboxedHint(reason: string): string {
|
|
106
|
+
const help = sandboxHelp(reason);
|
|
107
|
+
return accepted() ? help : `${help}; or set ${ALLOW_UNSANDBOXED}=1 to accept drawing without it`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Why Chromium drew without its sandbox in this run, once it has; see `takeUnsandboxed`. */
|
|
111
|
+
let drewUnsandboxed: string | undefined;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Says on stderr that Chromium drew without its sandbox, once a run, as lint --visual starts a
|
|
115
|
+
* worker or two per deck and the dev server one per pass; and keeps why, for the result. The
|
|
116
|
+
* project's own pages go unsaid once the user accepted it, a ref's never: it is someone else's code.
|
|
117
|
+
*/
|
|
118
|
+
function noteUnsandboxed(reason: string, ref: boolean): void {
|
|
119
|
+
if (drewUnsandboxed === undefined && (ref || !accepted())) {
|
|
120
|
+
process.stderr.write(
|
|
121
|
+
ref
|
|
122
|
+
? `warning: Chromium drew a ref without its sandbox, as ${ALLOW_UNSANDBOXED}=1 lets it: ${reason}\n help: ${sandboxHelp(reason)}\n`
|
|
123
|
+
: `warning: Chromium drew without its sandbox, which did not start here: ${reason}\n help: ${unsandboxedHint(reason)}\n`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
drewUnsandboxed = reason;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* `{ unsandboxed }` when Chromium drew without its sandbox since the last call, for the result's
|
|
131
|
+
* `--json`, and nothing otherwise, as `skipped` is absent when every check ran. Each call starts
|
|
132
|
+
* the next run afresh.
|
|
133
|
+
*/
|
|
134
|
+
export function takeUnsandboxed(): { unsandboxed?: Unsandboxed } {
|
|
135
|
+
const reason = drewUnsandboxed;
|
|
136
|
+
drewUnsandboxed = undefined;
|
|
137
|
+
return reason === undefined ? {} : { unsandboxed: { reason, hint: unsandboxedHint(reason) } };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A worker's answer and why it drew without the sandbox; or, having drawn nothing, why the
|
|
142
|
+
* sandbox did not start, and why Chromium did not start without it either when it tried.
|
|
143
|
+
*/
|
|
144
|
+
type Drawn<T> =
|
|
145
|
+
| { answer: T; unsandboxed?: string }
|
|
146
|
+
| { refused: string }
|
|
147
|
+
| { failed: string; unsandboxed: string };
|
|
148
|
+
|
|
149
|
+
/** The worker's JSON as `parse` reads it, with what it says of the sandbox. */
|
|
150
|
+
function readDrawn<T>(
|
|
151
|
+
text: string,
|
|
152
|
+
sandbox: SandboxMode,
|
|
153
|
+
parse: (text: string) => T | null,
|
|
154
|
+
): Drawn<T> | null {
|
|
155
|
+
let said: { unsandboxed?: unknown; failed?: unknown } | null;
|
|
156
|
+
try {
|
|
157
|
+
said = JSON.parse(text);
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const { unsandboxed, failed } = said ?? {};
|
|
162
|
+
if (unsandboxed !== undefined && typeof unsandboxed !== "string") {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
if (typeof unsandboxed === "string" && typeof failed === "string") {
|
|
166
|
+
return { failed, unsandboxed };
|
|
167
|
+
}
|
|
168
|
+
if (unsandboxed !== undefined && sandbox === "required") {
|
|
169
|
+
return { refused: unsandboxed };
|
|
170
|
+
}
|
|
171
|
+
const answer = parse(text);
|
|
172
|
+
return answer === null ? null : { answer, ...(unsandboxed === undefined ? {} : { unsandboxed }) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Runs a Playwright worker as `runJsonWorker` does, telling it how it may draw: `ref` pages only in
|
|
177
|
+
* Chromium's sandbox unless the user accepted otherwise. A draw without the sandbox is said and
|
|
178
|
+
* kept for the result; a ref's pages the sandbox could not hold are refused, undrawn.
|
|
179
|
+
*/
|
|
180
|
+
export async function runDrawingWorker<T>(
|
|
181
|
+
cmd: string[],
|
|
182
|
+
request: object,
|
|
183
|
+
parse: (text: string) => T | null,
|
|
184
|
+
options: Parameters<typeof runJsonWorker>[3] & { ref?: boolean },
|
|
185
|
+
): Promise<T> {
|
|
186
|
+
const { ref = false, ...worker } = options;
|
|
187
|
+
const sandbox = sandboxMode(ref);
|
|
188
|
+
const drawn = await runJsonWorker(
|
|
189
|
+
cmd,
|
|
190
|
+
{ ...request, sandbox },
|
|
191
|
+
(text) => readDrawn(text, sandbox, parse),
|
|
192
|
+
worker,
|
|
193
|
+
);
|
|
194
|
+
if ("failed" in drawn) {
|
|
195
|
+
const first =
|
|
196
|
+
drawn.failed === drawn.unsandboxed ? "" : `; with it, Chromium said: ${drawn.unsandboxed}`;
|
|
197
|
+
throw new DekError(`Chromium did not start, with its sandbox or without: ${drawn.failed}`, {
|
|
198
|
+
hint: `${worker.hint}${first}`,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
if ("refused" in drawn) {
|
|
202
|
+
// A failure that names no sandbox may be Chromium missing as well.
|
|
203
|
+
const missing = /sandbox|namespace|zygote|as root/i.test(drawn.refused)
|
|
204
|
+
? ""
|
|
205
|
+
: `${worker.hint}; or `;
|
|
206
|
+
throw new DekError(
|
|
207
|
+
`Chromium did not start with its sandbox here, and a ref is drawn only inside it: ${drawn.refused}`,
|
|
208
|
+
{
|
|
209
|
+
hint: `${missing}${sandboxHelp(drawn.refused)}; if you accept running this ref's slide scripts without the sandbox, set ${ALLOW_UNSANDBOXED}=1 and run again`,
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
if (drawn.unsandboxed !== undefined) {
|
|
214
|
+
noteUnsandboxed(drawn.unsandboxed, ref);
|
|
215
|
+
}
|
|
216
|
+
return drawn.answer;
|
|
217
|
+
}
|
package/src/core/shot/morph.ts
CHANGED
|
@@ -62,7 +62,7 @@ export async function shotMorph(
|
|
|
62
62
|
const step = stepKey(fromSection.beats, from.beatIndex);
|
|
63
63
|
|
|
64
64
|
const html = videoDocument(resolved, options.playerScript);
|
|
65
|
-
const entry = morphEntry(deck
|
|
65
|
+
const entry = morphEntry(deck, options.from, options.to, at, html);
|
|
66
66
|
if (!entry.cached) {
|
|
67
67
|
await requirePlaywright(
|
|
68
68
|
{
|
package/src/core/shot/motion.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { isAbsolute, join, relative } from "node:path";
|
|
3
3
|
import pkg from "../../../package.json";
|
|
4
4
|
import { DekError } from "../error.ts";
|
|
5
|
-
import { deckProjectRoot } from "../path.ts";
|
|
6
5
|
import { type PlaywrightRunner, requirePlaywright } from "../playwright.ts";
|
|
7
6
|
import { deckStops, retreat } from "../position.ts";
|
|
8
7
|
import { asResolvedDeck, type ResolvedDeck, requireSection } from "../resolve.ts";
|
|
@@ -103,7 +102,7 @@ export async function shotMotion(
|
|
|
103
102
|
const key = `${JSON.stringify([pkg.version, SHEET_LAYOUT_VERSION, MOTION_FRACTIONS, plan.from, beats, title])}\0${html}`;
|
|
104
103
|
// Each step keeps its own entry, so runs of different steps neither evict nor race each other.
|
|
105
104
|
const capture = options.step === undefined ? [section.slug] : [section.slug, String(first)];
|
|
106
|
-
const bundle = bundleEntry(deck
|
|
105
|
+
const bundle = bundleEntry(deck, "motion", capture, key);
|
|
107
106
|
const manifest = join(bundle.dir, "motion.json");
|
|
108
107
|
const cached = readMotionManifest(manifest, bundle.dir);
|
|
109
108
|
if (cached) {
|
|
@@ -112,7 +111,7 @@ export async function shotMotion(
|
|
|
112
111
|
}
|
|
113
112
|
// Chromium writes the frames and sheets by name, so no link may stand where one goes.
|
|
114
113
|
dropLinks(bundle.dir);
|
|
115
|
-
dropLinks(outputDir(join(bundle.dir, "frames"),
|
|
114
|
+
dropLinks(outputDir(join(bundle.dir, "frames"), bundle.root));
|
|
116
115
|
|
|
117
116
|
const response = await requirePlaywright(
|
|
118
117
|
{
|
package/src/core/shot/still.ts
CHANGED
|
@@ -84,7 +84,7 @@ export async function shotSheet(
|
|
|
84
84
|
}));
|
|
85
85
|
// The tiles' paths hash what they show, so the key changes whenever a tile does.
|
|
86
86
|
const bundle = bundleEntry(
|
|
87
|
-
deck
|
|
87
|
+
deck,
|
|
88
88
|
"sheets",
|
|
89
89
|
[],
|
|
90
90
|
JSON.stringify([SHEET_LAYOUT_VERSION, deck.deck.title, cells]),
|
|
@@ -136,7 +136,7 @@ export function still(
|
|
|
136
136
|
step: string = stepKey(section.beats, beatIndex),
|
|
137
137
|
): Still {
|
|
138
138
|
const html = renderSlideHtml(sources, section.slug, beatIndex);
|
|
139
|
-
const entry = stillEntry(deck
|
|
139
|
+
const entry = stillEntry(deck, section, beatIndex, html);
|
|
140
140
|
return { html, slug: section.slug, step, screenshotPath: entry.path, entry };
|
|
141
141
|
}
|
|
142
142
|
|
package/src/core/shot-cache.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
// biome-ignore lint/style/noRestrictedImports: rmSync, which prunes old entries by name in the folder outputDir returned, and takes a link, not its target.
|
|
2
3
|
import { readdirSync, rmSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { escapeRegExp } from "./escape.ts";
|
|
5
6
|
import { cacheDir, deckProjectRoot } from "./path.ts";
|
|
7
|
+
import type { ProjectDeck } from "./resolve.ts";
|
|
6
8
|
import { isCachedFile, outputDir } from "./safe-fs.ts";
|
|
7
9
|
import type { Section } from "./schema.ts";
|
|
8
10
|
import { lastStop, stepKey } from "./step.ts";
|
|
@@ -26,12 +28,22 @@ export type CacheEntry = {
|
|
|
26
28
|
/** A cached folder of files, such as a set of contact sheets. */
|
|
27
29
|
type CacheBundle = {
|
|
28
30
|
dir: string;
|
|
31
|
+
/** The project the folder is in, for what a capture writes inside it. */
|
|
32
|
+
root: string;
|
|
29
33
|
commit(): void;
|
|
30
34
|
};
|
|
31
35
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
/** The deck a capture is of: its folder, and for a ref, where its cache is instead. */
|
|
37
|
+
type ShotDeck = Pick<ProjectDeck, "dir" | "cache">;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `.cache/shots`, made if missing, where it really is, and the project it is in; a link out of
|
|
41
|
+
* the project is an error. A ref's shots go in the cache of the project that reads it.
|
|
42
|
+
*/
|
|
43
|
+
function shotsDir(deck: ShotDeck): { dir: string; root: string } {
|
|
44
|
+
const root = deck.cache?.root ?? deckProjectRoot(deck.dir);
|
|
45
|
+
const dir = deck.cache ? join(deck.cache.dir, "shots") : cacheDir(deck.dir, "shots");
|
|
46
|
+
return { dir: outputDir(dir, root), root };
|
|
35
47
|
}
|
|
36
48
|
|
|
37
49
|
function contentHash(text: string): string {
|
|
@@ -52,7 +64,7 @@ export function shotName(parts: string[], content: string): string {
|
|
|
52
64
|
* `--step 2` and a still at the last beat, `dekc build`'s link preview included, are one file.
|
|
53
65
|
*/
|
|
54
66
|
export function stillEntry(
|
|
55
|
-
|
|
67
|
+
deck: ShotDeck,
|
|
56
68
|
section: Section,
|
|
57
69
|
beatIndex: number,
|
|
58
70
|
html: string,
|
|
@@ -64,18 +76,18 @@ export function stillEntry(
|
|
|
64
76
|
`${section.slug}-${step}`,
|
|
65
77
|
`${section.slug}-${beatIndex}`,
|
|
66
78
|
];
|
|
67
|
-
return fileEntry(
|
|
79
|
+
return fileEntry(deck, [section.slug, step], html, legacy);
|
|
68
80
|
}
|
|
69
81
|
|
|
70
82
|
/** The frame `at` of the move from `from` into `to`. */
|
|
71
83
|
export function morphEntry(
|
|
72
|
-
|
|
84
|
+
deck: ShotDeck,
|
|
73
85
|
from: string,
|
|
74
86
|
to: string,
|
|
75
87
|
at: number,
|
|
76
88
|
html: string,
|
|
77
89
|
): CacheEntry {
|
|
78
|
-
return fileEntry(
|
|
90
|
+
return fileEntry(deck, [from, to, String(at)], `${at}\0${html}`, [`${from}-to-${to}-${at}`]);
|
|
79
91
|
}
|
|
80
92
|
|
|
81
93
|
/**
|
|
@@ -83,29 +95,26 @@ export function morphEntry(
|
|
|
83
95
|
* folder, so runs of different captures neither evict nor race each other.
|
|
84
96
|
*/
|
|
85
97
|
export function bundleEntry(
|
|
86
|
-
|
|
98
|
+
deck: ShotDeck,
|
|
87
99
|
kind: "sheets" | "motion",
|
|
88
100
|
parts: string[],
|
|
89
101
|
content: string,
|
|
90
102
|
): CacheBundle {
|
|
91
|
-
const
|
|
92
|
-
const
|
|
103
|
+
const shots = shotsDir(deck);
|
|
104
|
+
const { root } = shots;
|
|
105
|
+
const parent = outputDir(join(shots.dir, kind), root);
|
|
93
106
|
const base = parts.join(SEPARATOR);
|
|
94
107
|
const name = base ? `${base}.${contentHash(content)}` : contentHash(content);
|
|
95
108
|
const pattern = new RegExp(`^${base ? `${escapeRegExp(base)}\\.` : ""}${HASH}$`);
|
|
96
109
|
return {
|
|
97
110
|
dir: outputDir(join(parent, name), root),
|
|
111
|
+
root,
|
|
98
112
|
commit: () => prune(parent, [pattern], name, { recursive: true }),
|
|
99
113
|
};
|
|
100
114
|
}
|
|
101
115
|
|
|
102
|
-
function fileEntry(
|
|
103
|
-
|
|
104
|
-
parts: string[],
|
|
105
|
-
content: string,
|
|
106
|
-
legacy: string[],
|
|
107
|
-
): CacheEntry {
|
|
108
|
-
const dir = shotsDir(deckDir);
|
|
116
|
+
function fileEntry(deck: ShotDeck, parts: string[], content: string, legacy: string[]): CacheEntry {
|
|
117
|
+
const { dir } = shotsDir(deck);
|
|
109
118
|
const name = shotName(parts, content);
|
|
110
119
|
const path = join(dir, name);
|
|
111
120
|
const patterns = [
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* that never ends is cut off; the parent's timeout covers anything that
|
|
7
7
|
* escapes into the microtask queue.
|
|
8
8
|
*/
|
|
9
|
+
// biome-ignore lint/style/noRestrictedImports: writeSync, to stdout (fd 1), so each answer is out before the worker is killed or exits.
|
|
9
10
|
import { readFileSync, writeSync } from "node:fs";
|
|
10
11
|
import vm from "node:vm";
|
|
11
12
|
|
|
@@ -45,6 +45,22 @@ export type MeasuredElement = {
|
|
|
45
45
|
picture: boolean;
|
|
46
46
|
/** Whether it paints its own box: a background, or a border in a color that shows. */
|
|
47
47
|
paints: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Whether it is laid out on a line of text, as `<strong>` or an inline-block chip is, rather
|
|
50
|
+
* than as a box of its own: it sits on the line's baseline.
|
|
51
|
+
*/
|
|
52
|
+
inline: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Whether a transform turns or skews it, its own or an ancestor's: its box is then the box
|
|
55
|
+
* around its shape, not the shape the audience sees.
|
|
56
|
+
*/
|
|
57
|
+
tilted: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Where the first line of its own text sits, in viewport pixels from the top: the baseline of
|
|
60
|
+
* its first text node's first line, which is not its first line when an inline element such as
|
|
61
|
+
* `<strong>` opens it.
|
|
62
|
+
*/
|
|
63
|
+
baseline?: number;
|
|
48
64
|
/**
|
|
49
65
|
* What an ancestor inside the slide cuts it to, with `overflow` other than `visible`: the
|
|
50
66
|
* nearest such ancestor, and the box every one of them leaves visible. `intended` when the cut is
|
|
@@ -196,6 +212,35 @@ export function measureSlideInPage(): SlideMeasure {
|
|
|
196
212
|
.map(toBox);
|
|
197
213
|
});
|
|
198
214
|
|
|
215
|
+
// Turned or skewed, by its own transform or rotate, or an ancestor's.
|
|
216
|
+
const tilts = new Map<Element, boolean>();
|
|
217
|
+
const tilted = (el: Element | null): boolean => {
|
|
218
|
+
if (!el || el === slide) {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
const known = tilts.get(el);
|
|
222
|
+
if (known !== undefined) {
|
|
223
|
+
return known;
|
|
224
|
+
}
|
|
225
|
+
const style = getComputedStyle(el);
|
|
226
|
+
const matrix = style.transform === "none" ? undefined : new DOMMatrixReadOnly(style.transform);
|
|
227
|
+
const own =
|
|
228
|
+
(matrix !== undefined && (matrix.b !== 0 || matrix.c !== 0)) ||
|
|
229
|
+
(style.rotate !== "none" && style.rotate !== "");
|
|
230
|
+
const found = own || tilted(el.parentElement);
|
|
231
|
+
tilts.set(el, found);
|
|
232
|
+
return found;
|
|
233
|
+
};
|
|
234
|
+
// A line's baseline sits the font's ascent below the top of the box its text is drawn in.
|
|
235
|
+
const metrics = document.createElement("canvas").getContext("2d");
|
|
236
|
+
const baselineOf = (style: CSSStyleDeclaration, line: Box | undefined): number | undefined => {
|
|
237
|
+
if (!line || !metrics) {
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
metrics.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
|
|
241
|
+
return line.top + metrics.measureText("x").fontBoundingBoxAscent;
|
|
242
|
+
};
|
|
243
|
+
|
|
199
244
|
const all = [...slide.querySelectorAll("*")];
|
|
200
245
|
const indexOf = new Map(all.map((el, index) => [el, index]));
|
|
201
246
|
const elements = all.map((el): MeasuredElement => {
|
|
@@ -209,19 +254,24 @@ export function measureSlideInPage(): SlideMeasure {
|
|
|
209
254
|
const whole = el instanceof HTMLElement ? el.innerText : el.textContent;
|
|
210
255
|
const text = own || squash(whole ?? "");
|
|
211
256
|
const parentEl = el.parentElement;
|
|
257
|
+
const textRects = lineBoxes(textNodes);
|
|
258
|
+
const baseline = baselineOf(style, textRects[0]);
|
|
212
259
|
return {
|
|
213
260
|
box: describe(el),
|
|
214
261
|
parent: parentEl ? (indexOf.get(parentEl) ?? -1) : -1,
|
|
215
262
|
rect: extent(el, textNodes),
|
|
216
263
|
...(text ? { text } : {}),
|
|
217
264
|
ownText: own !== "",
|
|
218
|
-
textRects
|
|
265
|
+
textRects,
|
|
266
|
+
...(baseline !== undefined ? { baseline } : {}),
|
|
219
267
|
opacity: opacityOf(el),
|
|
220
268
|
fontSize: Number.parseFloat(style.fontSize),
|
|
221
269
|
fontWeight: Number(style.fontWeight) || 400,
|
|
222
270
|
decorative: el.closest('[aria-hidden="true"]') !== null,
|
|
223
271
|
picture: pictures.has(el.tagName.toLowerCase()) || style.backgroundImage.includes("url("),
|
|
224
272
|
paints: paints(style),
|
|
273
|
+
inline: style.display.startsWith("inline"),
|
|
274
|
+
tilted: tilted(el),
|
|
225
275
|
...clipped(clipOf(el)),
|
|
226
276
|
};
|
|
227
277
|
});
|