@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/pptx.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
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";
|
|
4
5
|
import { type DistOptions, distFile } from "./path.ts";
|
|
5
6
|
import { type PlaywrightRunner, requirePlaywright } from "./playwright.ts";
|
|
6
|
-
import { pptxPackage } from "./pptx-package.ts";
|
|
7
|
+
import { mapPictures, type PptxLayer, pptxPackage } from "./pptx-package.ts";
|
|
8
|
+
import { deckTheme } from "./pptx-theme.ts";
|
|
7
9
|
import { presenterSlides } from "./presenter.ts";
|
|
8
10
|
import { asResolvedDeck, type ResolvedDeck } from "./resolve.ts";
|
|
9
11
|
import { writeInside } from "./safe-fs.ts";
|
|
@@ -11,7 +13,16 @@ import { logicalSize } from "./size.ts";
|
|
|
11
13
|
import { lastStop } from "./step.ts";
|
|
12
14
|
import { loadSlideSources, renderSlideHtml } from "./still-page.ts";
|
|
13
15
|
|
|
14
|
-
export type PptxResult = {
|
|
16
|
+
export type PptxResult = {
|
|
17
|
+
outPath: string;
|
|
18
|
+
/**
|
|
19
|
+
* What was laid over the pictures: how many shapes, lines included, and pictures, and the
|
|
20
|
+
* slugs of the slides that kept everything in their picture.
|
|
21
|
+
*/
|
|
22
|
+
lifted: PptxLifted;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type PptxLifted = { shapes: number; pictures: number; untouched: string[] };
|
|
15
26
|
|
|
16
27
|
export type PptxOptions = DistOptions & {
|
|
17
28
|
/** For anyone who has the file: the notes leave out the stage directions and comments. */
|
|
@@ -20,9 +31,10 @@ export type PptxOptions = DistOptions & {
|
|
|
20
31
|
};
|
|
21
32
|
|
|
22
33
|
/**
|
|
23
|
-
* The deck as a PowerPoint file: each slide at its last beat, as the PDF prints it,
|
|
24
|
-
*
|
|
25
|
-
* notes. A slide script that cannot run stops it, as it
|
|
34
|
+
* The deck as a PowerPoint file: each slide at its last beat, as the PDF prints it, its plain
|
|
35
|
+
* boxes, pictures, and text as PowerPoint's own over a picture of the rest, in the deck's theme
|
|
36
|
+
* (ADRs 20 to 22), and its script in the notes. A slide script that cannot run stops it, as it
|
|
37
|
+
* stops the PDF.
|
|
26
38
|
*/
|
|
27
39
|
export async function pptxDeck(dir: string, options?: PptxOptions): Promise<PptxResult>;
|
|
28
40
|
export async function pptxDeck(source: ResolvedDeck, options?: PptxOptions): Promise<PptxResult>;
|
|
@@ -56,20 +68,56 @@ export async function pptxDeck(
|
|
|
56
68
|
options.runner,
|
|
57
69
|
);
|
|
58
70
|
const scripts = presenterSlides(deck, options.public ? "audience" : "speaker");
|
|
71
|
+
// The deck's tokens are PowerPoint's theme, as most slides have them.
|
|
72
|
+
const theme = deckTheme(answer.slides.flatMap((slide) => (slide.theme ? [slide.theme] : [])));
|
|
59
73
|
const bytes = pptxPackage({
|
|
60
74
|
title: deck.deck.title,
|
|
61
75
|
lang: deck.deck.lang,
|
|
62
76
|
size,
|
|
77
|
+
theme,
|
|
63
78
|
slides: answer.slides.map((slide, index) => ({
|
|
64
|
-
picture
|
|
79
|
+
...(slide.picture === false
|
|
80
|
+
? {}
|
|
81
|
+
: { picture: readFileSync(join(shots, `${index + 1}.png`)) }),
|
|
82
|
+
...(slide.background ? { background: slide.background } : {}),
|
|
65
83
|
description: slide.description || (sections[index]?.title ?? slide.slug),
|
|
66
|
-
|
|
84
|
+
layers: mapPictures(slide.layers, ({ png, svg, ...picture }) => ({
|
|
85
|
+
...picture,
|
|
86
|
+
png: readFileSync(png),
|
|
87
|
+
...(svg === undefined ? {} : { svg }),
|
|
88
|
+
})),
|
|
67
89
|
notes: scripts[index]?.script ?? "",
|
|
68
90
|
})),
|
|
69
91
|
});
|
|
70
92
|
writeInside(outPath, bytes, project.root);
|
|
93
|
+
const lifted: PptxLifted = { shapes: 0, pictures: 0, untouched: [] };
|
|
94
|
+
for (const slide of answer.slides) {
|
|
95
|
+
const counts = countLifted(slide.layers);
|
|
96
|
+
lifted.shapes += counts.shapes;
|
|
97
|
+
lifted.pictures += counts.pictures;
|
|
98
|
+
if (counts.shapes + counts.pictures === 0) {
|
|
99
|
+
lifted.untouched.push(slide.slug);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { outPath, lifted };
|
|
71
103
|
} finally {
|
|
72
104
|
rmSync(shots, { recursive: true, force: true });
|
|
73
105
|
}
|
|
74
|
-
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function countLifted<I>(layers: PptxLayer<I>[]): { shapes: number; pictures: number } {
|
|
109
|
+
let shapes = 0;
|
|
110
|
+
let pictures = 0;
|
|
111
|
+
for (const layer of layers) {
|
|
112
|
+
if (layer.kind === "shape") {
|
|
113
|
+
const inner = countLifted(layer.layers);
|
|
114
|
+
shapes += 1 + inner.shapes;
|
|
115
|
+
pictures += inner.pictures;
|
|
116
|
+
} else if (layer.kind === "rule") {
|
|
117
|
+
shapes += 1;
|
|
118
|
+
} else if (layer.kind === "picture") {
|
|
119
|
+
pictures += 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { shapes, pictures };
|
|
75
123
|
}
|
package/src/core/prose.ts
CHANGED
|
@@ -14,3 +14,26 @@ function joined(items: string[], conjunction: string): string {
|
|
|
14
14
|
}
|
|
15
15
|
return `${items.slice(0, -1).join(", ")}, ${conjunction} ${items.at(-1)}`;
|
|
16
16
|
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Names from someone else's files as a hint lists them: those `shows` passes, then how many others
|
|
20
|
+
* there are, in the words of `others`. An other could hold anything, and an agent takes a hint as
|
|
21
|
+
* dek's own words.
|
|
22
|
+
*/
|
|
23
|
+
export function listShown(
|
|
24
|
+
names: string[],
|
|
25
|
+
shows: (name: string) => boolean,
|
|
26
|
+
others: (count: number) => string,
|
|
27
|
+
): string {
|
|
28
|
+
const shown = names.filter(shows);
|
|
29
|
+
const left = names.length - shown.length;
|
|
30
|
+
if (left === 0) {
|
|
31
|
+
return shown.join(", ");
|
|
32
|
+
}
|
|
33
|
+
return shown.length === 0 ? others(left) : `${shown.join(", ")}, and ${others(left)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** How many names a list leaves out because they are no plain word: see `listShown`. */
|
|
37
|
+
export function unprinted(count: number): string {
|
|
38
|
+
return `${count} whose ${count === 1 ? "name" : "names"} dek does not print`;
|
|
39
|
+
}
|
package/src/core/pseudo-text.ts
CHANGED
|
@@ -14,17 +14,34 @@ type DomNode = {
|
|
|
14
14
|
* `<host>::<pseudo>`. The page exposes no box for a pseudo-element, and Chromium, which lays it
|
|
15
15
|
* out, does: through the DevTools protocol. One that is not laid out has no box and is left out.
|
|
16
16
|
*/
|
|
17
|
-
export
|
|
17
|
+
export function pseudoTextBoxes(page: Page): Promise<Map<string, Box>> {
|
|
18
|
+
return pseudoElementBoxes(page, PSEUDO_TEXT_HOST, "content", ["before", "after"]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The `content` or `border` box, in the viewport, of each pseudo-element of the `types` given
|
|
23
|
+
* of every element marked with `attr`, keyed `<the attribute's value>::<pseudo>`.
|
|
24
|
+
*/
|
|
25
|
+
export async function pseudoElementBoxes(
|
|
26
|
+
page: Page,
|
|
27
|
+
attr: string,
|
|
28
|
+
model: "content" | "border",
|
|
29
|
+
types: ReadonlyArray<"before" | "after" | "marker">,
|
|
30
|
+
): Promise<Map<string, Box>> {
|
|
18
31
|
const cdp = await page.context().newCDPSession(page);
|
|
19
32
|
try {
|
|
20
33
|
const { root } = (await cdp.send("DOM.getDocument", { depth: -1 })) as { root: DomNode };
|
|
21
34
|
const boxes = new Map<string, Box>();
|
|
22
35
|
for (const node of walk(root)) {
|
|
23
|
-
const host = attribute(node,
|
|
36
|
+
const host = attribute(node, attr);
|
|
24
37
|
for (const pseudo of host === undefined ? [] : (node.pseudoElements ?? [])) {
|
|
25
|
-
const
|
|
26
|
-
if (
|
|
27
|
-
|
|
38
|
+
const type = types.find((each) => each === pseudo.pseudoType);
|
|
39
|
+
if (type === undefined) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const box = await boxOf(cdp, pseudo.nodeId, model);
|
|
43
|
+
if (box) {
|
|
44
|
+
boxes.set(`${host}::${type}`, box);
|
|
28
45
|
}
|
|
29
46
|
}
|
|
30
47
|
}
|
|
@@ -48,13 +65,18 @@ function attribute(node: DomNode, name: string): string | undefined {
|
|
|
48
65
|
return at >= 0 && at % 2 === 0 ? attributes[at + 1] : undefined;
|
|
49
66
|
}
|
|
50
67
|
|
|
51
|
-
async function
|
|
68
|
+
async function boxOf(
|
|
69
|
+
cdp: CDPSession,
|
|
70
|
+
nodeId: number,
|
|
71
|
+
model: "content" | "border",
|
|
72
|
+
): Promise<Box | undefined> {
|
|
52
73
|
try {
|
|
53
|
-
const { model } = (await cdp.send("DOM.getBoxModel", { nodeId })) as {
|
|
54
|
-
model: { content: number[] };
|
|
74
|
+
const { model: boxes } = (await cdp.send("DOM.getBoxModel", { nodeId })) as {
|
|
75
|
+
model: { content: number[]; border: number[] };
|
|
55
76
|
};
|
|
56
|
-
const
|
|
57
|
-
const
|
|
77
|
+
const quad = boxes[model];
|
|
78
|
+
const xs = quad.filter((_, index) => index % 2 === 0);
|
|
79
|
+
const ys = quad.filter((_, index) => index % 2 === 1);
|
|
58
80
|
return {
|
|
59
81
|
left: Math.min(...xs),
|
|
60
82
|
top: Math.min(...ys),
|
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
|
|
@@ -35,12 +36,24 @@ const PACK_OWNER = "file";
|
|
|
35
36
|
|
|
36
37
|
const OWNER_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/;
|
|
37
38
|
const REPO_RE = /^[A-Za-z0-9._-]+$/;
|
|
38
|
-
|
|
39
|
+
/**
|
|
40
|
+
* A ref's deck name: letters, marks, and digits of any script, dots, dashes, and underscores, not
|
|
41
|
+
* first a dash. It is chosen by whoever made the pack or the repository, and dek prints it in the
|
|
42
|
+
* commands it suggests and in AGENTS.md, inside a code span: no shell, code span, or command line
|
|
43
|
+
* reads anything in it.
|
|
44
|
+
*/
|
|
45
|
+
const DECK_RE = /^[\p{L}\p{M}\p{N}._][\p{L}\p{M}\p{N}._-]{0,63}$/u;
|
|
46
|
+
|
|
47
|
+
/** Bytes a ref's deck name may take: its pack, `<deck>.tgz`, stays within a file name's 255. */
|
|
48
|
+
const DECK_MAX_BYTES = 200;
|
|
39
49
|
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
40
50
|
const DIGEST_RE = /^[0-9a-f]{64}$/;
|
|
41
51
|
|
|
42
|
-
|
|
43
|
-
|
|
52
|
+
/** What a ref's deck name may hold, as a hint says it. */
|
|
53
|
+
export const REF_DECK_NAME_RULE =
|
|
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)";
|
|
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}`;
|
|
44
57
|
|
|
45
58
|
/** Where a pack's ref keeps its pack, relative to the project root: committed, never ignored. */
|
|
46
59
|
export function packFile(deck: string): string {
|
|
@@ -78,9 +91,23 @@ export function isPinFor(source: RefSource, pin: string): boolean {
|
|
|
78
91
|
return source.kind === "file" ? DIGEST_RE.test(pin) : SHA_RE.test(pin);
|
|
79
92
|
}
|
|
80
93
|
|
|
81
|
-
/**
|
|
94
|
+
/**
|
|
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.
|
|
99
|
+
*/
|
|
82
100
|
export function isRefDeckName(deck: string | undefined): deck is string {
|
|
83
|
-
return
|
|
101
|
+
return (
|
|
102
|
+
deck !== undefined &&
|
|
103
|
+
DECK_RE.test(deck) &&
|
|
104
|
+
deck === deck.normalize("NFC") &&
|
|
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) &&
|
|
108
|
+
deck !== "." &&
|
|
109
|
+
deck !== ".."
|
|
110
|
+
);
|
|
84
111
|
}
|
|
85
112
|
|
|
86
113
|
export function parseRefSource(input: string): RefSource {
|
|
@@ -133,22 +160,39 @@ export function parseRefSource(input: string): RefSource {
|
|
|
133
160
|
*/
|
|
134
161
|
function parseGithubUrl(input: string): GithubRefSource {
|
|
135
162
|
const url = new URL(input);
|
|
136
|
-
const [owner = "", repo = "", kind, rev, ...
|
|
137
|
-
|
|
138
|
-
const
|
|
163
|
+
const [owner = "", repo = "", kind, rev, ...encoded] = url.pathname.split("/").filter(Boolean);
|
|
164
|
+
// The folders after the rev as the repository names them; GitHub's link encodes any but ASCII.
|
|
165
|
+
const rest = decodeSegments(encoded);
|
|
166
|
+
const decks = rest?.indexOf("decks") ?? -1;
|
|
167
|
+
const deck = decks === -1 ? undefined : rest?.[decks + 1];
|
|
139
168
|
if (
|
|
140
169
|
url.hostname !== "github.com" ||
|
|
141
170
|
(kind !== "tree" && kind !== "blob") ||
|
|
142
171
|
!rev ||
|
|
172
|
+
rest === undefined ||
|
|
143
173
|
deck === undefined
|
|
144
174
|
) {
|
|
145
175
|
throw new DekError(`"${input}" is not a link to a deck on GitHub`, {
|
|
146
176
|
hint: "link to the deck's folder, e.g. https://github.com/hajimism/dek/tree/main/sample/decks/why-dek, or name it as owner/repo/deck",
|
|
147
177
|
});
|
|
148
178
|
}
|
|
179
|
+
if (!isRefDeckName(deck)) {
|
|
180
|
+
throw new DekError(`"${input}" is not a link to a deck a ref can name`, {
|
|
181
|
+
hint: REF_DECK_NAME_RULE,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
149
184
|
const source = parseRefSource(`${owner}/${repo.replace(/\.git$/, "")}/${deck}@${rev}`);
|
|
150
185
|
if (source.kind !== "github") {
|
|
151
186
|
throw new DekError(`"${input}" is not a link to a deck on GitHub`, { hint: REF_HINT });
|
|
152
187
|
}
|
|
153
188
|
return { ...source, path: rest.slice(0, decks + 2).join("/") };
|
|
154
189
|
}
|
|
190
|
+
|
|
191
|
+
/** Each segment of a link's path decoded, or nothing when one is not valid percent-encoding. */
|
|
192
|
+
function decodeSegments(segments: string[]): string[] | undefined {
|
|
193
|
+
try {
|
|
194
|
+
return segments.map((segment) => decodeURIComponent(segment));
|
|
195
|
+
} catch {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
}
|