@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
|
@@ -5,6 +5,7 @@ import { type Diagnostic, diag } from "../diagnostic.ts";
|
|
|
5
5
|
import { isPlainWord } from "../escape.ts";
|
|
6
6
|
import type { HtmlRef } from "../html-scan.ts";
|
|
7
7
|
import { isInside } from "../path.ts";
|
|
8
|
+
import { trimUrl } from "../url-attributes.ts";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* DEK020 to DEK023 for one reference, whether it comes from a slide's markup
|
|
@@ -56,9 +57,9 @@ export function assetRefDiagnostics(
|
|
|
56
57
|
if (kind === "missing") {
|
|
57
58
|
return [
|
|
58
59
|
diag("DEK021", {
|
|
59
|
-
message: `missing ${ref.tag === "img" ? "image" : "file"} "${ref.value}"`,
|
|
60
|
+
message: `missing ${ref.tag === "img" ? "image" : "file"} "${visibleSpaces(ref.value)}"`,
|
|
60
61
|
...location,
|
|
61
|
-
...missingImageHint(ref.value, deckDir),
|
|
62
|
+
...(strayedSpaceHint(ref.value, deckDir, from) ?? missingImageHint(ref.value, deckDir)),
|
|
62
63
|
data: { src: ref.value },
|
|
63
64
|
}),
|
|
64
65
|
];
|
|
@@ -66,7 +67,7 @@ export function assetRefDiagnostics(
|
|
|
66
67
|
if (!isCanonicalAssetPath(ref.value)) {
|
|
67
68
|
return [
|
|
68
69
|
diag("DEK023", {
|
|
69
|
-
message: `asset "${ref.value}" must be referenced as assets/${posix.basename(ref.value
|
|
70
|
+
message: `asset "${ref.value}" must be referenced as assets/${posix.basename(trimUrl(ref.value))}`,
|
|
70
71
|
...location,
|
|
71
72
|
data: { src: ref.value },
|
|
72
73
|
}),
|
|
@@ -76,19 +77,56 @@ export function assetRefDiagnostics(
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
function leavesFromEither(value: string, deckDir: string, from: string): boolean {
|
|
79
|
-
const path = value
|
|
80
|
+
const path = trimUrl(value).split(/[?#]/)[0] ?? "";
|
|
80
81
|
return (
|
|
81
82
|
path !== "" &&
|
|
82
83
|
[resolve(deckDir, path), resolve(from, path)].some((target) => !isInside(target, deckDir))
|
|
83
84
|
);
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
/** `value` with each space but the plain one written as `\uXXXX`, so a message shows it. */
|
|
88
|
+
function visibleSpaces(value: string): string {
|
|
89
|
+
return value.replace(
|
|
90
|
+
/[^\S ]/g,
|
|
91
|
+
(char) => `\\u${char.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The hint for a path that names a deck file once the spaces outside ASCII at its ends are gone,
|
|
97
|
+
* as ` assets/a.png` does: the browser keeps those spaces, so the path names nothing.
|
|
98
|
+
*/
|
|
99
|
+
function strayedSpaceHint(
|
|
100
|
+
value: string,
|
|
101
|
+
deckDir: string,
|
|
102
|
+
from: string,
|
|
103
|
+
): { hint: string } | undefined {
|
|
104
|
+
const url = trimUrl(value);
|
|
105
|
+
const bare = url.trim();
|
|
106
|
+
if (bare === url || classifyAssetRef(bare, { deckDir, from }).kind !== "file") {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
const at = url.indexOf(bare);
|
|
110
|
+
const before = url.slice(0, at);
|
|
111
|
+
const after = url.slice(at + bare.length);
|
|
112
|
+
const where = before && after ? "around" : before ? "before" : "after";
|
|
113
|
+
const spaces = [...new Set(before + after)].map((char) => {
|
|
114
|
+
const point = `U+${char.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")}`;
|
|
115
|
+
return char === "\u00A0" ? `no-break space (${point})` : `space (${point})`;
|
|
116
|
+
});
|
|
117
|
+
// The path is the slide's text: one that is no plain word is left out of the hint.
|
|
118
|
+
const path = isPlainWord(bare) ? bare : "the path";
|
|
119
|
+
return {
|
|
120
|
+
hint: `remove the ${spaces.join(" and the ")} ${where} ${path}: the browser keeps ${spaces.length > 1 ? "them" : "it"} as part of the path, which then names no file`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
86
124
|
/**
|
|
87
125
|
* Decks copy what they use from the project's `assets/` (so a deck stays whole when moved);
|
|
88
126
|
* when the project has the file, the fix is that copy.
|
|
89
127
|
*/
|
|
90
128
|
function missingImageHint(value: string, deckDir: string): { hint?: string } {
|
|
91
|
-
const name = value
|
|
129
|
+
const name = trimUrl(value).split(/[?#]/)[0] ?? "";
|
|
92
130
|
if (!name.startsWith("assets/")) {
|
|
93
131
|
return {};
|
|
94
132
|
}
|
|
@@ -104,7 +142,7 @@ function missingImageHint(value: string, deckDir: string): { hint?: string } {
|
|
|
104
142
|
function remoteHint(value: string): string {
|
|
105
143
|
let name = "";
|
|
106
144
|
try {
|
|
107
|
-
name = posix.basename(new URL(value
|
|
145
|
+
name = posix.basename(new URL(trimUrl(value), "https://dek.invalid").pathname);
|
|
108
146
|
} catch {}
|
|
109
147
|
// The name is the remote URL's: one that is no plain word is left out of the hint.
|
|
110
148
|
return name && isPlainWord(name)
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
+
import { attributeTokens } from "../attribute-value.ts";
|
|
1
2
|
import { type Diagnostic, diag } from "../diagnostic.ts";
|
|
2
3
|
import { isPlainWord } from "../escape.ts";
|
|
3
|
-
import
|
|
4
|
+
import {
|
|
5
|
+
type HtmlAttribute,
|
|
6
|
+
type HtmlScan,
|
|
7
|
+
isName,
|
|
8
|
+
type Picture,
|
|
9
|
+
type SourceSpot,
|
|
10
|
+
} from "../html-scan.ts";
|
|
4
11
|
import type { Section } from "../schema.ts";
|
|
5
12
|
import { beatAt, formatStepChoices, resolveStep, stepChoices } from "../step.ts";
|
|
6
13
|
import { suggest } from "../suggest.ts";
|
|
@@ -368,24 +375,22 @@ const EVENT_HANDLER_RE = /^on[a-z]+$/;
|
|
|
368
375
|
/** Tags that load a URL as a document of its own, so an SVG there runs its scripts. */
|
|
369
376
|
const DOCUMENT_TAGS = new Set(["iframe", "frame", "object", "embed"]);
|
|
370
377
|
|
|
378
|
+
/** What a relative URL resolves against; only a URL's own scheme can run script. */
|
|
379
|
+
const SOME_PAGE = "https://dek.invalid/";
|
|
380
|
+
|
|
371
381
|
/**
|
|
372
382
|
* The scheme of a URL that runs script, or undefined: `javascript:` and `vbscript:` always, and a
|
|
373
383
|
* `data:` URL holding HTML, or SVG where a tag loads it as a document. An image's `data:` stays.
|
|
374
|
-
*
|
|
384
|
+
* The value comes decoded, and the URL parser reads it as the browser's does: C0 controls and
|
|
385
|
+
* spaces trimmed, tabs and newlines dropped anywhere, the scheme in any case.
|
|
375
386
|
*/
|
|
376
387
|
function scriptUrlScheme(tag: string, value: string): string | undefined {
|
|
377
|
-
const url = value
|
|
378
|
-
|
|
379
|
-
.
|
|
380
|
-
.toLowerCase();
|
|
381
|
-
if (url.startsWith("javascript:")) {
|
|
382
|
-
return "javascript:";
|
|
383
|
-
}
|
|
384
|
-
if (url.startsWith("vbscript:")) {
|
|
385
|
-
return "vbscript:";
|
|
388
|
+
const url = URL.parse(value, SOME_PAGE);
|
|
389
|
+
if (url?.protocol === "javascript:" || url?.protocol === "vbscript:") {
|
|
390
|
+
return url.protocol;
|
|
386
391
|
}
|
|
387
|
-
if (url
|
|
388
|
-
const type = url.slice("data:".length).split(/[;,]/, 1)[0]?.trim();
|
|
392
|
+
if (url?.protocol === "data:") {
|
|
393
|
+
const type = url.href.slice("data:".length).split(/[;,]/, 1)[0]?.trim().toLowerCase();
|
|
389
394
|
const runs =
|
|
390
395
|
type === "text/html" ||
|
|
391
396
|
type === "application/xhtml+xml" ||
|
|
@@ -478,14 +483,12 @@ function unnamedPicture({
|
|
|
478
483
|
}: Picture): { message: string; hint: string; data: Record<string, string> } | undefined {
|
|
479
484
|
const value = (name: string): string | undefined =>
|
|
480
485
|
element.attributes.find((attribute) => attribute.name === name)?.value;
|
|
481
|
-
const named = [value("aria-label"), value("aria-labelledby"), value("title")].some(
|
|
482
|
-
(name) => name !== undefined && name.trim() !== "",
|
|
483
|
-
);
|
|
486
|
+
const named = [value("aria-label"), value("aria-labelledby"), value("title")].some(isName);
|
|
484
487
|
const { tag } = element;
|
|
485
488
|
if (named || (tag === "img" && value("alt") !== undefined)) {
|
|
486
489
|
return undefined;
|
|
487
490
|
}
|
|
488
|
-
if ((value("role")
|
|
491
|
+
if (attributeTokens(value("role"))[0]?.toLowerCase() === "img") {
|
|
489
492
|
if (titled) {
|
|
490
493
|
return undefined;
|
|
491
494
|
}
|
|
@@ -543,7 +546,7 @@ function unknownClassDiagnostics(
|
|
|
543
546
|
: `define it in slides/${section.slug}.css, or use one of: ${shown.join(", ")}${scriptHint}`;
|
|
544
547
|
const unknown = new Map<string, HtmlAttribute>();
|
|
545
548
|
for (const attribute of attributesNamed(scan, "class")) {
|
|
546
|
-
for (const name of attribute.value
|
|
549
|
+
for (const name of attributeTokens(attribute.value)) {
|
|
547
550
|
if (!classes.has(name) && !unknown.has(name)) {
|
|
548
551
|
unknown.set(name, attribute);
|
|
549
552
|
}
|
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";
|