@hajimism/dek 0.4.0 → 0.5.1
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 +92 -0
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/package.json +8 -7
- package/src/cli/address.ts +59 -15
- 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/keep-alive.ts +12 -6
- package/src/cli/lint.ts +32 -13
- package/src/cli/ls.ts +5 -1
- package/src/cli/main.ts +48 -13
- 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 +50 -16
- 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/context.ts +2 -0
- package/src/core/lint/shared-styles.ts +26 -1
- package/src/core/lint/slide-html.ts +21 -18
- package/src/core/lint/theme-css.ts +64 -3
- package/src/core/lint.ts +15 -2
- 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-measure.ts +1 -0
- package/src/core/slide-script-eval.ts +2 -0
- package/src/core/spawn.ts +136 -35
- 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 +13 -5
- package/src/server/lock.ts +43 -13
- 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/tsconfig.json +1 -1
package/src/core/lint.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { timingDiagnostics } from "./lint/timing.ts";
|
|
|
18
18
|
import { voiceDiagnostics } from "./lint/voice.ts";
|
|
19
19
|
import { asResolvedDeck, type Project, type ProjectDeck } from "./resolve.ts";
|
|
20
20
|
import type { Section } from "./schema.ts";
|
|
21
|
+
import type { SlideScriptProblem } from "./slide-script.ts";
|
|
21
22
|
import { evaluateSlideScripts, slideScriptsProblems } from "./slide-script-eval.ts";
|
|
22
23
|
import { RUNTIME_CLASSES } from "./theme-facts.ts";
|
|
23
24
|
|
|
@@ -53,7 +54,7 @@ export function lintDeck(
|
|
|
53
54
|
export function lintDeck(input: DeckInput, options?: LintDeckOptions): Diagnostic[] {
|
|
54
55
|
const { files, scripted } = readLintInput(input);
|
|
55
56
|
const problems = slideScriptsProblems(scripted.map((entry) => entry.input));
|
|
56
|
-
return runRules(
|
|
57
|
+
return runRules(lintContext(files, scripted, problems), options);
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
/**
|
|
@@ -66,7 +67,19 @@ export async function lintDeckAsync(
|
|
|
66
67
|
): Promise<Diagnostic[]> {
|
|
67
68
|
const { files, scripted } = readLintInput(input);
|
|
68
69
|
const problems = await evaluateSlideScripts(scripted.map((entry) => entry.input));
|
|
69
|
-
return runRules(
|
|
70
|
+
return runRules(lintContext(files, scripted, problems), options);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function lintContext(
|
|
74
|
+
files: DeckFiles,
|
|
75
|
+
scripted: LintedScript[],
|
|
76
|
+
problems: SlideScriptProblem[][],
|
|
77
|
+
): LintContext {
|
|
78
|
+
return {
|
|
79
|
+
...files,
|
|
80
|
+
scripts: slideScriptDiagnostics(scripted, problems),
|
|
81
|
+
scriptCode: new Map(scripted.map(({ section, input }) => [section.slug, input.code])),
|
|
82
|
+
};
|
|
70
83
|
}
|
|
71
84
|
|
|
72
85
|
function readLintInput(input: DeckInput): { files: DeckFiles; scripted: LintedScript[] } {
|
package/src/core/mv.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
// biome-ignore lint/style/noRestrictedImports: renameSync and unlinkSync, for mv's steps, each undone on failure; safe-fs does not rename.
|
|
2
|
+
import { existsSync, renameSync, unlinkSync } from "node:fs";
|
|
2
3
|
import { basename } from "node:path";
|
|
3
4
|
import { deckPaths } from "./deck-paths.ts";
|
|
4
5
|
import { DekError } from "./error.ts";
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
requireSection,
|
|
12
13
|
SLIDE_SIDECARS,
|
|
13
14
|
} from "./resolve.ts";
|
|
15
|
+
import { writeInside } from "./safe-fs.ts";
|
|
14
16
|
import { Id } from "./schema.ts";
|
|
15
17
|
import { deckLayouts, skeletonHtml } from "./skeleton.ts";
|
|
16
18
|
import { skeletonRecord } from "./skeleton-record.ts";
|
|
@@ -22,11 +24,11 @@ export function renameSection(input: string | ResolvedDeck, from: string, to: st
|
|
|
22
24
|
if (!Id.safeParse(to).success) {
|
|
23
25
|
throw new DekError(`invalid id "${to}"`, { hint: "use [a-z0-9-] with at least one letter" });
|
|
24
26
|
}
|
|
25
|
-
const { deck } = asResolvedDeck(input);
|
|
27
|
+
const { project, deck } = asResolvedDeck(input);
|
|
26
28
|
const has = (slug: string): boolean => deck.deck.sections.some((entry) => entry.slug === slug);
|
|
27
29
|
if (!has(from) && has(to)) {
|
|
28
30
|
// script.md was edited first; only the files still carry the old id.
|
|
29
|
-
const steps = slideFileSteps(deck, from, to);
|
|
31
|
+
const steps = slideFileSteps(project.root, deck, from, to);
|
|
30
32
|
if (steps.length === 0 && !existsSync(deckPaths(deck.dir).slide(to, ".html"))) {
|
|
31
33
|
throw new DekError(`slide "${from}" not found`, {
|
|
32
34
|
path: deckPaths(deck.dir).slide(from, ".html"),
|
|
@@ -56,8 +58,8 @@ export function renameSection(input: string | ResolvedDeck, from: string, to: st
|
|
|
56
58
|
}
|
|
57
59
|
lines[headingIndex] = rewriteHeadingId(heading, from, to);
|
|
58
60
|
applySteps([
|
|
59
|
-
writeStep(deck.scriptPath, source, joinLines(source, lines)),
|
|
60
|
-
...slideFileSteps(deck, from, to),
|
|
61
|
+
writeStep(project.root, deck.scriptPath, source, joinLines(source, lines)),
|
|
62
|
+
...slideFileSteps(project.root, deck, from, to),
|
|
61
63
|
]);
|
|
62
64
|
}
|
|
63
65
|
|
|
@@ -69,7 +71,12 @@ export function renameSection(input: string | ResolvedDeck, from: string, to: st
|
|
|
69
71
|
* the author wrote, one under each id, stop the rename; everything that could stop it is worked
|
|
70
72
|
* out before any file changes.
|
|
71
73
|
*/
|
|
72
|
-
function slideFileSteps(
|
|
74
|
+
function slideFileSteps(
|
|
75
|
+
root: string,
|
|
76
|
+
deck: ResolvedDeck["deck"],
|
|
77
|
+
from: string,
|
|
78
|
+
to: string,
|
|
79
|
+
): FileStep[] {
|
|
73
80
|
const paths = deckPaths(deck.dir);
|
|
74
81
|
const layouts = deckLayouts(deck.dir);
|
|
75
82
|
const record = skeletonRecord(deck.dir);
|
|
@@ -100,16 +107,16 @@ function slideFileSteps(deck: ResolvedDeck["deck"], from: string, to: string): F
|
|
|
100
107
|
const kept = source && (source.authored || !target) ? source : target;
|
|
101
108
|
if (source && kept === source) {
|
|
102
109
|
steps.push(
|
|
103
|
-
target ? writeStep(toPath, target.html, source.html) : renameStep(source.path, toPath),
|
|
110
|
+
target ? writeStep(root, toPath, target.html, source.html) : renameStep(source.path, toPath),
|
|
104
111
|
);
|
|
105
112
|
}
|
|
106
113
|
if (source && target) {
|
|
107
|
-
steps.push(removeStep(source.path, source.html));
|
|
114
|
+
steps.push(removeStep(root, source.path, source.html));
|
|
108
115
|
}
|
|
109
116
|
if (kept) {
|
|
110
117
|
const next = relabel(kept.html, from, to);
|
|
111
118
|
if (next !== kept.html) {
|
|
112
|
-
steps.push(writeStep(toPath, kept.html, next));
|
|
119
|
+
steps.push(writeStep(root, toPath, kept.html, next));
|
|
113
120
|
}
|
|
114
121
|
}
|
|
115
122
|
for (const ext of SLIDE_SIDECARS) {
|
|
@@ -119,7 +126,7 @@ function slideFileSteps(deck: ResolvedDeck["deck"], from: string, to: string): F
|
|
|
119
126
|
}
|
|
120
127
|
}
|
|
121
128
|
if (voice) {
|
|
122
|
-
steps.push(writeStep(voice.path, voice.source, voice.next));
|
|
129
|
+
steps.push(writeStep(root, voice.path, voice.source, voice.next));
|
|
123
130
|
}
|
|
124
131
|
return steps;
|
|
125
132
|
}
|
|
@@ -155,19 +162,22 @@ function again(from: string, to: string): string {
|
|
|
155
162
|
return `\`dekc mv ${end}${source} ${target}\` again`;
|
|
156
163
|
}
|
|
157
164
|
|
|
158
|
-
/**
|
|
165
|
+
/**
|
|
166
|
+
* One file change `dekc mv` makes, with how to take it back. A write goes to a new file renamed
|
|
167
|
+
* into place, so a crash part way leaves the old file or the new one, never half of one.
|
|
168
|
+
*/
|
|
159
169
|
type FileStep = { path: string; apply: () => void; undo: () => void };
|
|
160
170
|
|
|
161
|
-
function writeStep(path: string, before: string, after: string): FileStep {
|
|
171
|
+
function writeStep(root: string, path: string, before: string, after: string): FileStep {
|
|
162
172
|
return {
|
|
163
173
|
path,
|
|
164
|
-
apply: () =>
|
|
165
|
-
undo: () =>
|
|
174
|
+
apply: () => writeInside(path, after, root),
|
|
175
|
+
undo: () => writeInside(path, before, root),
|
|
166
176
|
};
|
|
167
177
|
}
|
|
168
178
|
|
|
169
|
-
function removeStep(path: string, before: string): FileStep {
|
|
170
|
-
return { path, apply: () => unlinkSync(path), undo: () =>
|
|
179
|
+
function removeStep(root: string, path: string, before: string): FileStep {
|
|
180
|
+
return { path, apply: () => unlinkSync(path), undo: () => writeInside(path, before, root) };
|
|
171
181
|
}
|
|
172
182
|
|
|
173
183
|
function renameStep(from: string, to: string): FileStep {
|
|
@@ -268,7 +278,7 @@ export function reorderSection(
|
|
|
268
278
|
place: SectionPlace,
|
|
269
279
|
): void {
|
|
270
280
|
const target = "before" in place ? place.before : place.after;
|
|
271
|
-
const { deck } = asResolvedDeck(input);
|
|
281
|
+
const { project, deck } = asResolvedDeck(input);
|
|
272
282
|
const sections = deck.deck.sections;
|
|
273
283
|
const fromIndex = sections.findIndex((entry) => entry.slug === slug);
|
|
274
284
|
if (fromIndex < 0) {
|
|
@@ -301,7 +311,7 @@ export function reorderSection(
|
|
|
301
311
|
});
|
|
302
312
|
const insertAt = "before" in place ? remainingTarget : remainingTarget + 1;
|
|
303
313
|
chunks.splice(insertAt, 0, moved);
|
|
304
|
-
|
|
314
|
+
writeInside(deck.scriptPath, joinLines(source, [...head, ...chunks.flat()]), project.root);
|
|
305
315
|
}
|
|
306
316
|
|
|
307
317
|
function rewriteHeadingId(heading: string, from: string, to: string): string {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orders two strings by their UTF-16 code units, as `Array.prototype.sort` does with no
|
|
3
|
+
* comparator, so every list dek sorts comes out in one order, the same on every machine.
|
|
4
|
+
*
|
|
5
|
+
* `localeCompare` with no locale follows the machine's, so a teammate set to Swedish or Turkish
|
|
6
|
+
* would write dict.toml in another order. A fixed locale is no cure: its collation comes from
|
|
7
|
+
* the ICU the runtime has, which Bun takes from the system on macOS and bundles on Linux, and it
|
|
8
|
+
* counts a precomposed `ä` and an `a` with a combining mark as equal, leaving their order to
|
|
9
|
+
* whichever came first. Code units cost a dictionary's order: `B` comes before `a`, `ä` after
|
|
10
|
+
* `z`, and Japanese reads hiragana, then katakana, then kanji by code point.
|
|
11
|
+
*/
|
|
12
|
+
export function compareCodeUnits(a: string, b: string): number {
|
|
13
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
14
|
+
}
|
package/src/core/path.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { holdsHidden } from "./foreign-text.ts";
|
|
3
4
|
|
|
4
5
|
export function moduleFilePath(url: string | URL): string {
|
|
5
6
|
return fileURLToPath(url);
|
|
@@ -16,8 +17,12 @@ export function deckProjectRoot(deckDir: string): string {
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export const DECK_NAME_HINT =
|
|
19
|
-
'use a plain name like 2026-10-talk: no "/" or "\\", no control
|
|
20
|
+
'use a plain name like 2026-10-talk: no "/" or "\\", no control character or one a person cannot see, no leading "-"';
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* A name `dekc new` and `dekc init` may give a deck. A pack's ref takes its deck's name, so, as
|
|
24
|
+
* `dekc pack` does, these refuse a control character and one a person cannot see.
|
|
25
|
+
*/
|
|
21
26
|
export function isDeckName(name: string): boolean {
|
|
22
27
|
return (
|
|
23
28
|
name.length > 0 &&
|
|
@@ -28,7 +33,8 @@ export function isDeckName(name: string): boolean {
|
|
|
28
33
|
// A leading "-" reads as a flag to every tool the name is passed to.
|
|
29
34
|
!name.startsWith("-") &&
|
|
30
35
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: a control in a name is the problem.
|
|
31
|
-
!/[\x00-\x1f\x7f]/.test(name)
|
|
36
|
+
!/[\x00-\x1f\x7f-\x9f]/.test(name) &&
|
|
37
|
+
!holdsHidden(name)
|
|
32
38
|
);
|
|
33
39
|
}
|
|
34
40
|
|
|
@@ -27,6 +27,11 @@ declare module "playwright" {
|
|
|
27
27
|
}): Promise<Buffer>;
|
|
28
28
|
close(): Promise<void>;
|
|
29
29
|
context(): BrowserContext;
|
|
30
|
+
mainFrame(): Frame;
|
|
31
|
+
frames(): Frame[];
|
|
32
|
+
};
|
|
33
|
+
export type Frame = {
|
|
34
|
+
url(): string;
|
|
30
35
|
};
|
|
31
36
|
/** A DevTools protocol session: Chromium's own answers, for what the page does not expose. */
|
|
32
37
|
export type CDPSession = {
|
|
@@ -44,10 +49,16 @@ declare module "playwright" {
|
|
|
44
49
|
viewport?: Viewport;
|
|
45
50
|
deviceScaleFactor?: number;
|
|
46
51
|
offline?: boolean;
|
|
52
|
+
httpCredentials?: { username: string; password: string };
|
|
47
53
|
}): Promise<BrowserContext>;
|
|
54
|
+
newBrowserCDPSession(): Promise<CDPSession>;
|
|
48
55
|
close(): Promise<void>;
|
|
49
56
|
};
|
|
50
57
|
export const chromium: {
|
|
51
|
-
launch(options?: {
|
|
58
|
+
launch(options?: {
|
|
59
|
+
headless?: boolean;
|
|
60
|
+
chromiumSandbox?: boolean;
|
|
61
|
+
executablePath?: string;
|
|
62
|
+
}): Promise<Browser>;
|
|
52
63
|
};
|
|
53
64
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// biome-ignore lint/style/noRestrictedImports: mkdirSync, in a worker, for the folder its parent chose in dek's cache, which Chromium writes into.
|
|
1
2
|
import { mkdirSync, readFileSync } from "node:fs";
|
|
2
3
|
import { dirname } from "node:path";
|
|
3
4
|
import type { Page } from "playwright";
|
|
@@ -581,13 +581,18 @@ async function visitPage(
|
|
|
581
581
|
* measuring again: first what the slide's script drew, then the slide's own CSS. A text that
|
|
582
582
|
* clears its threshold, or an element that no longer crosses an edge, once a layer is gone is
|
|
583
583
|
* that layer's to fix. With both gone, a text that still fails is the theme's, and an element that
|
|
584
|
-
* still overflows is its content's: too much for the theme's sizes.
|
|
584
|
+
* still overflows is its content's: too much for the theme's sizes. A text that shows no glyph
|
|
585
|
+
* once a layer is gone is no longer measured as the audience sees it: one the slide's CSS alone
|
|
586
|
+
* shows is marked so, and one the draw alone shows is no layer's unless it still fails with both
|
|
587
|
+
* gone. The page is not put back.
|
|
585
588
|
*/
|
|
586
589
|
async function attributeFindings(
|
|
587
590
|
page: Page,
|
|
588
591
|
pending: { failing: Failing[]; crossing: Crossing[] },
|
|
589
592
|
): Promise<void> {
|
|
590
593
|
let { failing, crossing } = pending;
|
|
594
|
+
// Texts that showed no glyph the last time they were measured.
|
|
595
|
+
const unseen = new Set<Failing>();
|
|
591
596
|
for (const layer of LAYERS) {
|
|
592
597
|
if (failing.length === 0 && crossing.length === 0) {
|
|
593
598
|
return;
|
|
@@ -615,20 +620,32 @@ async function attributeFindings(
|
|
|
615
620
|
const texts = await pageTexts(page, measured);
|
|
616
621
|
const contrasts = await textContrasts(page, texts);
|
|
617
622
|
const now = new Map(texts.map((text, at) => [text.key, { text, contrast: contrasts[at] }]));
|
|
618
|
-
failing = failing.filter((
|
|
619
|
-
const measuredText = now.get(key);
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
623
|
+
failing = failing.filter((entry) => {
|
|
624
|
+
const measuredText = now.get(entry.key);
|
|
625
|
+
const seen = !unseen.has(entry);
|
|
626
|
+
if (!measuredText?.contrast) {
|
|
627
|
+
if (layer.origin === "slide" && seen) {
|
|
628
|
+
entry.finding.onlyWithSlideCss = true;
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
unseen.add(entry);
|
|
632
|
+
return true;
|
|
633
|
+
}
|
|
634
|
+
if (measuredText.contrast.ratio >= contrastThreshold(measuredText.text)) {
|
|
635
|
+
// Hidden with the layer before gone, it may owe its low contrast to that layer as well.
|
|
636
|
+
if (seen) {
|
|
637
|
+
entry.finding.origin = layer.origin;
|
|
638
|
+
}
|
|
625
639
|
return false;
|
|
626
640
|
}
|
|
641
|
+
unseen.delete(entry);
|
|
627
642
|
return true;
|
|
628
643
|
});
|
|
629
644
|
}
|
|
630
|
-
for (const
|
|
631
|
-
|
|
645
|
+
for (const entry of failing) {
|
|
646
|
+
if (!unseen.has(entry)) {
|
|
647
|
+
entry.finding.origin = "theme";
|
|
648
|
+
}
|
|
632
649
|
}
|
|
633
650
|
for (const { finding } of crossing) {
|
|
634
651
|
finding.origin = "content";
|
package/src/core/playwright.ts
CHANGED
|
@@ -10,8 +10,9 @@ import { type Collision, EDGES, type Overflow } from "./overflow.ts";
|
|
|
10
10
|
import { moduleFilePath } from "./path.ts";
|
|
11
11
|
import type { PptxFill, PptxLayer, PptxTextBox } from "./pptx-package.ts";
|
|
12
12
|
import type { PptxPageTheme } from "./pptx-theme.ts";
|
|
13
|
+
import { chromiumFailure, launchFailure, runDrawingWorker, type SandboxMode } from "./sandbox.ts";
|
|
13
14
|
import type { MotionBeat, SheetSpec } from "./sheet.ts";
|
|
14
|
-
import { exitWorker, readWorkerRequest,
|
|
15
|
+
import { exitWorker, readWorkerRequest, workerCommand } from "./spawn.ts";
|
|
15
16
|
import type { Position } from "./step.ts";
|
|
16
17
|
|
|
17
18
|
/** One slide page to measure; a page that names a `screenshotPath` is shot there too. */
|
|
@@ -136,9 +137,15 @@ export type ContrastFinding = {
|
|
|
136
137
|
/**
|
|
137
138
|
* For text below its threshold, which stylesheet draws it so: "theme" when it stays below
|
|
138
139
|
* with the slide's own CSS taken away, "slide" when that CSS brings it there. Absent when the
|
|
139
|
-
* text passes, or shows no glyph without the slide's CSS.
|
|
140
|
+
* text passes, or shows no glyph without the slide's CSS. Absent too when it shows none with the
|
|
141
|
+
* slide script's draw undone and clears without the slide's CSS: either may have drawn it so.
|
|
140
142
|
*/
|
|
141
143
|
origin?: ContrastOrigin;
|
|
144
|
+
/**
|
|
145
|
+
* For text below its threshold that shows with the slide's own CSS and no glyph without it: the
|
|
146
|
+
* theme alone was never measured, so either stylesheet may hold its colors.
|
|
147
|
+
*/
|
|
148
|
+
onlyWithSlideCss?: true;
|
|
142
149
|
};
|
|
143
150
|
|
|
144
151
|
export type ContrastOrigin = "theme" | "slide" | "script";
|
|
@@ -231,6 +238,11 @@ type SpawnTimeoutOptions = {
|
|
|
231
238
|
timeoutMs?: number;
|
|
232
239
|
};
|
|
233
240
|
|
|
241
|
+
type RunnerOptions = SpawnTimeoutOptions & {
|
|
242
|
+
/** The pages hold a ref's deck: Chromium's sandbox only, unless the user accepted otherwise. */
|
|
243
|
+
ref?: boolean;
|
|
244
|
+
};
|
|
245
|
+
|
|
234
246
|
const requireFromDek = createRequire(import.meta.url);
|
|
235
247
|
|
|
236
248
|
export function resolvePlaywrightModule(): string | undefined {
|
|
@@ -293,49 +305,85 @@ export async function importPlaywright(): Promise<typeof import("playwright")> {
|
|
|
293
305
|
return import(pathToFileURL(spec).href) as Promise<typeof import("playwright")>;
|
|
294
306
|
}
|
|
295
307
|
|
|
308
|
+
/** Playwright's Chromium, or what stands in for it in tests. */
|
|
309
|
+
type Chromium = typeof import("playwright").chromium;
|
|
310
|
+
|
|
296
311
|
/**
|
|
297
312
|
* A Playwright worker's whole life: load Playwright, read the request, launch Chromium, answer
|
|
298
313
|
* with what `handle` returns, and close the browser. Any failure exits with its reason on stderr.
|
|
314
|
+
* Where Chromium's sandbox did not start, the answer says why in `unsandboxed`, and is only that
|
|
315
|
+
* when the request's `sandbox` required it, or with why Chromium did not start without it either
|
|
316
|
+
* in `failed`. `chromium` stands in for Playwright's in tests.
|
|
299
317
|
*/
|
|
300
318
|
export async function runPlaywrightWorker<Request, Response>(
|
|
301
319
|
handle: (browser: Browser, request: Request) => Promise<Response>,
|
|
320
|
+
chromium?: Chromium,
|
|
302
321
|
): Promise<void> {
|
|
303
|
-
let
|
|
322
|
+
let launcher: Chromium;
|
|
304
323
|
try {
|
|
305
|
-
|
|
324
|
+
launcher = chromium ?? (await importPlaywright()).chromium;
|
|
306
325
|
} catch {
|
|
307
326
|
exitWorker(`playwright not found; ${PLAYWRIGHT_INSTALL}`);
|
|
308
327
|
}
|
|
309
|
-
const request = await readWorkerRequest<Request>();
|
|
328
|
+
const request = await readWorkerRequest<Request & { sandbox?: unknown }>();
|
|
310
329
|
try {
|
|
311
|
-
|
|
330
|
+
// Anything but "preferred", or nothing, requires the sandbox.
|
|
331
|
+
const sandbox = request.sandbox === "preferred" ? "preferred" : "required";
|
|
332
|
+
const launched = await launchChromium(launcher, sandbox);
|
|
333
|
+
if (!launched.browser) {
|
|
334
|
+
const { browser: _none, ...said } = launched;
|
|
335
|
+
process.stdout.write(`${JSON.stringify(said)}\n`);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const { browser, unsandboxed } = launched;
|
|
312
339
|
try {
|
|
313
340
|
const response = await handle(browser, request);
|
|
314
|
-
|
|
341
|
+
const answer = unsandboxed === undefined ? response : { ...response, unsandboxed };
|
|
342
|
+
process.stdout.write(`${JSON.stringify(answer)}\n`);
|
|
315
343
|
} finally {
|
|
316
344
|
await browser.close();
|
|
317
345
|
}
|
|
318
346
|
} catch (error) {
|
|
319
|
-
|
|
347
|
+
// The parent's hint keeps the start of stderr, and Playwright's opens with a command line.
|
|
348
|
+
exitWorker(chromiumFailure(error) ?? error);
|
|
320
349
|
}
|
|
321
350
|
}
|
|
322
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Chromium with its sandbox, or without it and why; or none: where the sandbox was required, or
|
|
354
|
+
* where Chromium did not start without it either.
|
|
355
|
+
*/
|
|
356
|
+
type Launched =
|
|
357
|
+
| { browser: Browser; unsandboxed?: string }
|
|
358
|
+
| { browser?: never; unsandboxed: string; failed?: string };
|
|
359
|
+
|
|
323
360
|
/**
|
|
324
361
|
* Headless Chromium with its sandbox, which Playwright leaves off by default: slide scripts run in
|
|
325
|
-
* it, a ref's included.
|
|
326
|
-
*
|
|
362
|
+
* it, a ref's included. When that launch fails, as in a container running as root, it launches
|
|
363
|
+
* again without one only when `sandbox` is "preferred", and says why in Chromium's words, whether
|
|
364
|
+
* or not they name the sandbox; when that fails too, it says both.
|
|
327
365
|
*/
|
|
328
|
-
async function launchChromium(chromium:
|
|
366
|
+
export async function launchChromium(chromium: Chromium, sandbox: SandboxMode): Promise<Launched> {
|
|
367
|
+
let first: unknown;
|
|
329
368
|
try {
|
|
330
|
-
return await chromium.launch({ headless: true, chromiumSandbox: true });
|
|
331
|
-
} catch {
|
|
332
|
-
|
|
369
|
+
return { browser: await chromium.launch({ headless: true, chromiumSandbox: true }) };
|
|
370
|
+
} catch (error) {
|
|
371
|
+
first = error;
|
|
372
|
+
}
|
|
373
|
+
const unsandboxed = launchFailure(first);
|
|
374
|
+
if (sandbox !== "preferred") {
|
|
375
|
+
return { unsandboxed };
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
return { browser: await chromium.launch({ headless: true }), unsandboxed };
|
|
379
|
+
} catch (second) {
|
|
380
|
+
return { unsandboxed, failed: launchFailure(second) };
|
|
333
381
|
}
|
|
334
382
|
}
|
|
335
383
|
|
|
336
384
|
export async function defaultPlaywrightRunner(
|
|
337
385
|
request: VisualRequest,
|
|
338
|
-
options:
|
|
386
|
+
options: RunnerOptions = {},
|
|
339
387
|
): Promise<VisualResponse | null> {
|
|
340
388
|
if (!playwrightResolved()) {
|
|
341
389
|
return null;
|
|
@@ -343,6 +391,14 @@ export async function defaultPlaywrightRunner(
|
|
|
343
391
|
return spawnRunner(workerCommand(process.env.DEK_PLAYWRIGHT || workerPath()), request, options);
|
|
344
392
|
}
|
|
345
393
|
|
|
394
|
+
/**
|
|
395
|
+
* The default runner for a ref's pages: someone else's slide scripts, which Chromium runs only in
|
|
396
|
+
* its sandbox unless the user set DEK_ALLOW_UNSANDBOXED=1 (ADR 29).
|
|
397
|
+
*/
|
|
398
|
+
export function refPlaywrightRunner(request: VisualRequest): Promise<VisualResponse | null> {
|
|
399
|
+
return defaultPlaywrightRunner(request, { ref: true });
|
|
400
|
+
}
|
|
401
|
+
|
|
346
402
|
function workerPath(): string {
|
|
347
403
|
return moduleFilePath(new URL("./playwright-worker.ts", import.meta.url));
|
|
348
404
|
}
|
|
@@ -350,12 +406,13 @@ function workerPath(): string {
|
|
|
350
406
|
function spawnRunner(
|
|
351
407
|
cmd: string[],
|
|
352
408
|
request: VisualRequest,
|
|
353
|
-
options:
|
|
409
|
+
options: RunnerOptions,
|
|
354
410
|
): Promise<VisualResponse> {
|
|
355
|
-
return
|
|
411
|
+
return runDrawingWorker(cmd, request, (text) => parseVisualResponse(text, request.kind), {
|
|
356
412
|
label: "Playwright worker failed",
|
|
357
413
|
hint: `${PLAYWRIGHT_INSTALL} or check DEK_PLAYWRIGHT`,
|
|
358
414
|
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
|
415
|
+
...(options.ref ? { ref: true } : {}),
|
|
359
416
|
});
|
|
360
417
|
}
|
|
361
418
|
|
|
@@ -524,14 +581,15 @@ function isContrastFinding(value: unknown): value is ContrastFinding {
|
|
|
524
581
|
if (!isFinding(value)) {
|
|
525
582
|
return false;
|
|
526
583
|
}
|
|
527
|
-
const { ratio, fg, bg, fontSize, fontWeight, origin } = value;
|
|
584
|
+
const { ratio, fg, bg, fontSize, fontWeight, origin, onlyWithSlideCss } = value;
|
|
528
585
|
return (
|
|
529
586
|
isNumber(ratio) &&
|
|
530
587
|
isString(fg) &&
|
|
531
588
|
isString(bg) &&
|
|
532
589
|
isNumber(fontSize) &&
|
|
533
590
|
isNumber(fontWeight) &&
|
|
534
|
-
(origin === undefined || origin === "theme" || origin === "slide" || origin === "script")
|
|
591
|
+
(origin === undefined || origin === "theme" || origin === "slide" || origin === "script") &&
|
|
592
|
+
(onlyWithSlideCss === undefined || onlyWithSlideCss === true)
|
|
535
593
|
);
|
|
536
594
|
}
|
|
537
595
|
|
package/src/core/pptx.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// biome-ignore lint/style/noRestrictedImports: mkdtempSync and rmSync, for a folder of the shots' own in the system's temp folder, removed after.
|
|
1
2
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
2
3
|
import { tmpdir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
package/src/core/ref-name.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DekError } from "./error.ts";
|
|
2
|
+
import { holdsHidden } from "./foreign-text.ts";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* A ref is someone else's deck, kept as a read-only snapshot to read as a
|
|
@@ -50,7 +51,7 @@ const DIGEST_RE = /^[0-9a-f]{64}$/;
|
|
|
50
51
|
|
|
51
52
|
/** What a ref's deck name may hold, as a hint says it. */
|
|
52
53
|
export const REF_DECK_NAME_RULE =
|
|
53
|
-
"a ref names its deck with up to 64 letters, digits, dots, dashes, and underscores, not first a dash, each accented letter one character (NFC)";
|
|
54
|
+
"a ref names its deck with up to 64 letters, digits, dots, dashes, and underscores, none a person cannot see, not first a dash, each accented letter one character (NFC)";
|
|
54
55
|
|
|
55
56
|
const REF_HINT = `name a ref as owner/repo/deck, e.g. hajimism/dek/why-dek or hajimism/dek/why-dek@v1, or a pack's as file/<deck>; ${REF_DECK_NAME_RULE}`;
|
|
56
57
|
|
|
@@ -91,8 +92,10 @@ export function isPinFor(source: RefSource, pin: string): boolean {
|
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
/**
|
|
94
|
-
* True for a deck name a ref can carry: see DECK_RE; not . or ..;
|
|
95
|
-
*
|
|
95
|
+
* True for a deck name a ref can carry: see DECK_RE; not . or ..; composed (NFC), since a file
|
|
96
|
+
* system that ignores the form would give two spellings of one name one folder; and with no
|
|
97
|
+
* character a person cannot see. A pack's author chooses it, and dek prints it as the ref's name,
|
|
98
|
+
* as in AGENTS.md.
|
|
96
99
|
*/
|
|
97
100
|
export function isRefDeckName(deck: string | undefined): deck is string {
|
|
98
101
|
return (
|
|
@@ -100,6 +103,8 @@ export function isRefDeckName(deck: string | undefined): deck is string {
|
|
|
100
103
|
DECK_RE.test(deck) &&
|
|
101
104
|
deck === deck.normalize("NFC") &&
|
|
102
105
|
Buffer.byteLength(deck) <= DECK_MAX_BYTES &&
|
|
106
|
+
// DECK_RE takes no control character; a letter or a mark can still be one a person cannot see.
|
|
107
|
+
!holdsHidden(deck) &&
|
|
103
108
|
deck !== "." &&
|
|
104
109
|
deck !== ".."
|
|
105
110
|
);
|