@hajimism/dek 0.3.2 → 0.4.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.ja.md +1 -1
  3. package/README.md +1 -1
  4. package/package.json +2 -1
  5. package/skills/dek/SKILL.md +10 -3
  6. package/skills/dek-design/SKILL.md +42 -0
  7. package/src/cli/address.ts +84 -14
  8. package/src/cli/build.ts +2 -2
  9. package/src/cli/check.ts +2 -1
  10. package/src/cli/commands.ts +54 -13
  11. package/src/cli/contract.ts +18 -1
  12. package/src/cli/files.ts +9 -6
  13. package/src/cli/flags.ts +1 -1
  14. package/src/cli/goto.ts +3 -1
  15. package/src/cli/init.ts +10 -5
  16. package/src/cli/lint.ts +4 -3
  17. package/src/cli/main.ts +7 -5
  18. package/src/cli/new.ts +41 -10
  19. package/src/cli/pack.ts +21 -0
  20. package/src/cli/pptx.ts +9 -4
  21. package/src/cli/ref.ts +243 -9
  22. package/src/cli/result.ts +5 -2
  23. package/src/cli/scope.ts +25 -27
  24. package/src/cli/shot.ts +2 -1
  25. package/src/cli/text.ts +45 -4
  26. package/src/cli/theme.ts +16 -2
  27. package/src/cli/voice.ts +4 -3
  28. package/src/core/alignment.ts +320 -0
  29. package/src/core/annotation-rows.ts +16 -4
  30. package/src/core/annotations.ts +19 -1
  31. package/src/core/config-keys.ts +6 -2
  32. package/src/core/config.ts +88 -12
  33. package/src/core/diagnostic.ts +5 -1
  34. package/src/core/escape.ts +43 -0
  35. package/src/core/licenses/CC-BY-4.0.txt +396 -0
  36. package/src/core/licenses/CC0-1.0.txt +121 -0
  37. package/src/core/licenses.ts +20 -0
  38. package/src/core/line-break.ts +275 -0
  39. package/src/core/lint/asset-refs.ts +6 -2
  40. package/src/core/lint/files.ts +30 -5
  41. package/src/core/lint/keys.ts +7 -3
  42. package/src/core/lint/slide-html.ts +3 -1
  43. package/src/core/lint/slide-script.ts +7 -5
  44. package/src/core/mv.ts +30 -10
  45. package/src/core/pack.ts +137 -0
  46. package/src/core/playwright-module.d.ts +1 -0
  47. package/src/core/playwright-visual.ts +180 -20
  48. package/src/core/playwright.ts +202 -25
  49. package/src/core/pptx-package.ts +461 -33
  50. package/src/core/pptx-paint.ts +1168 -0
  51. package/src/core/pptx-plan.ts +879 -0
  52. package/src/core/pptx-text.ts +204 -47
  53. package/src/core/pptx-theme.ts +222 -0
  54. package/src/core/pptx.ts +55 -8
  55. package/src/core/prose.ts +23 -0
  56. package/src/core/pseudo-text.ts +32 -10
  57. package/src/core/ref-name.ts +100 -17
  58. package/src/core/ref.ts +318 -34
  59. package/src/core/resolve.ts +20 -9
  60. package/src/core/shot/morph.ts +1 -1
  61. package/src/core/shot/motion.ts +2 -3
  62. package/src/core/shot/still.ts +2 -2
  63. package/src/core/shot-cache.ts +25 -17
  64. package/src/core/slide-measure.ts +51 -1
  65. package/src/core/slide-script-eval.ts +10 -8
  66. package/src/core/slide-script.ts +114 -20
  67. package/src/core/sync.ts +3 -3
  68. package/src/core/visual-cache.ts +2 -0
  69. package/src/core/visual.ts +154 -15
  70. package/src/core/voice.ts +3 -5
  71. package/src/core/zod.ts +9 -1
  72. package/src/runtime/annotate-view.ts +4 -2
  73. package/src/server/dev.ts +15 -4
  74. package/src/server/lock.ts +48 -5
  75. package/src/server/routes.ts +7 -2
  76. package/src/theme/default.css +4 -0
  77. package/src/video/bake.ts +6 -2
package/src/core/pptx.ts CHANGED
@@ -3,7 +3,8 @@ import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { type DistOptions, distFile } from "./path.ts";
5
5
  import { type PlaywrightRunner, requirePlaywright } from "./playwright.ts";
6
- import { pptxPackage } from "./pptx-package.ts";
6
+ import { mapPictures, type PptxLayer, pptxPackage } from "./pptx-package.ts";
7
+ import { deckTheme } from "./pptx-theme.ts";
7
8
  import { presenterSlides } from "./presenter.ts";
8
9
  import { asResolvedDeck, type ResolvedDeck } from "./resolve.ts";
9
10
  import { writeInside } from "./safe-fs.ts";
@@ -11,7 +12,16 @@ import { logicalSize } from "./size.ts";
11
12
  import { lastStop } from "./step.ts";
12
13
  import { loadSlideSources, renderSlideHtml } from "./still-page.ts";
13
14
 
14
- export type PptxResult = { outPath: string };
15
+ export type PptxResult = {
16
+ outPath: string;
17
+ /**
18
+ * What was laid over the pictures: how many shapes, lines included, and pictures, and the
19
+ * slugs of the slides that kept everything in their picture.
20
+ */
21
+ lifted: PptxLifted;
22
+ };
23
+
24
+ export type PptxLifted = { shapes: number; pictures: number; untouched: string[] };
15
25
 
16
26
  export type PptxOptions = DistOptions & {
17
27
  /** For anyone who has the file: the notes leave out the stage directions and comments. */
@@ -20,9 +30,10 @@ export type PptxOptions = DistOptions & {
20
30
  };
21
31
 
22
32
  /**
23
- * The deck as a PowerPoint file: each slide at its last beat, as the PDF prints it, drawn as a
24
- * picture with its text laid over it in text boxes a recipient can edit, and its script in the
25
- * notes. A slide script that cannot run stops it, as it stops the PDF.
33
+ * The deck as a PowerPoint file: each slide at its last beat, as the PDF prints it, its plain
34
+ * boxes, pictures, and text as PowerPoint's own over a picture of the rest, in the deck's theme
35
+ * (ADRs 20 to 22), and its script in the notes. A slide script that cannot run stops it, as it
36
+ * stops the PDF.
26
37
  */
27
38
  export async function pptxDeck(dir: string, options?: PptxOptions): Promise<PptxResult>;
28
39
  export async function pptxDeck(source: ResolvedDeck, options?: PptxOptions): Promise<PptxResult>;
@@ -56,20 +67,56 @@ export async function pptxDeck(
56
67
  options.runner,
57
68
  );
58
69
  const scripts = presenterSlides(deck, options.public ? "audience" : "speaker");
70
+ // The deck's tokens are PowerPoint's theme, as most slides have them.
71
+ const theme = deckTheme(answer.slides.flatMap((slide) => (slide.theme ? [slide.theme] : [])));
59
72
  const bytes = pptxPackage({
60
73
  title: deck.deck.title,
61
74
  lang: deck.deck.lang,
62
75
  size,
76
+ theme,
63
77
  slides: answer.slides.map((slide, index) => ({
64
- picture: readFileSync(join(shots, `${index + 1}.png`)),
78
+ ...(slide.picture === false
79
+ ? {}
80
+ : { picture: readFileSync(join(shots, `${index + 1}.png`)) }),
81
+ ...(slide.background ? { background: slide.background } : {}),
65
82
  description: slide.description || (sections[index]?.title ?? slide.slug),
66
- texts: slide.boxes,
83
+ layers: mapPictures(slide.layers, ({ png, svg, ...picture }) => ({
84
+ ...picture,
85
+ png: readFileSync(png),
86
+ ...(svg === undefined ? {} : { svg }),
87
+ })),
67
88
  notes: scripts[index]?.script ?? "",
68
89
  })),
69
90
  });
70
91
  writeInside(outPath, bytes, project.root);
92
+ const lifted: PptxLifted = { shapes: 0, pictures: 0, untouched: [] };
93
+ for (const slide of answer.slides) {
94
+ const counts = countLifted(slide.layers);
95
+ lifted.shapes += counts.shapes;
96
+ lifted.pictures += counts.pictures;
97
+ if (counts.shapes + counts.pictures === 0) {
98
+ lifted.untouched.push(slide.slug);
99
+ }
100
+ }
101
+ return { outPath, lifted };
71
102
  } finally {
72
103
  rmSync(shots, { recursive: true, force: true });
73
104
  }
74
- return { outPath };
105
+ }
106
+
107
+ function countLifted<I>(layers: PptxLayer<I>[]): { shapes: number; pictures: number } {
108
+ let shapes = 0;
109
+ let pictures = 0;
110
+ for (const layer of layers) {
111
+ if (layer.kind === "shape") {
112
+ const inner = countLifted(layer.layers);
113
+ shapes += 1 + inner.shapes;
114
+ pictures += inner.pictures;
115
+ } else if (layer.kind === "rule") {
116
+ shapes += 1;
117
+ } else if (layer.kind === "picture") {
118
+ pictures += 1;
119
+ }
120
+ }
121
+ return { shapes, pictures };
75
122
  }
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
+ }
@@ -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 async function pseudoTextBoxes(page: Page): Promise<Map<string, Box>> {
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, PSEUDO_TEXT_HOST);
36
+ const host = attribute(node, attr);
24
37
  for (const pseudo of host === undefined ? [] : (node.pseudoElements ?? [])) {
25
- const box = await contentBox(cdp, pseudo.nodeId);
26
- if (box && (pseudo.pseudoType === "before" || pseudo.pseudoType === "after")) {
27
- boxes.set(`${host}::${pseudo.pseudoType}`, box);
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 contentBox(cdp: CDPSession, nodeId: number): Promise<Box | undefined> {
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 xs = model.content.filter((_, index) => index % 2 === 0);
57
- const ys = model.content.filter((_, index) => index % 2 === 1);
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),
@@ -2,11 +2,13 @@ import { DekError } from "./error.ts";
2
2
 
3
3
  /**
4
4
  * A ref is someone else's deck, kept as a read-only snapshot to read as a
5
- * model. dek.toml `[refs]` pins each one to a commit; the snapshot under
6
- * `refs/owner/repo/deck` is only a copy of that commit and can be fetched again.
5
+ * model. dek.toml `[refs]` pins each one: a deck on GitHub to a commit, a pack
6
+ * to a digest of its files. The snapshot under `refs/` is only a copy of what
7
+ * the pin names, fetched or unpacked again whenever it is missing.
7
8
  * This module only names refs; `ref.ts` fetches and stores them.
8
9
  */
9
- export type RefSource = {
10
+ export type GithubRefSource = {
11
+ kind: "github";
10
12
  /** `owner/repo/deck`, the key in dek.toml `[refs]`. */
11
13
  name: string;
12
14
  owner: string;
@@ -18,13 +20,44 @@ export type RefSource = {
18
20
  path?: string;
19
21
  };
20
22
 
23
+ /** A deck handed over as a file by `dekc pack`, committed as `packs/<deck>.tgz`. */
24
+ export type FileRefSource = {
25
+ kind: "file";
26
+ /** `file/<deck>`, the key in dek.toml `[refs]`. */
27
+ name: string;
28
+ deck: string;
29
+ };
30
+
31
+ export type RefSource = GithubRefSource | FileRefSource;
32
+
33
+ /** The first segment of a pack's ref name; no GitHub owner may take it, so refs/ never overlaps. */
34
+ const PACK_OWNER = "file";
35
+
21
36
  const OWNER_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/;
22
37
  const REPO_RE = /^[A-Za-z0-9._-]+$/;
23
- const DECK_RE = /^[^/\\\s@]+$/;
38
+ /**
39
+ * A ref's deck name: letters, marks, and digits of any script, dots, dashes, and underscores, not
40
+ * first a dash. It is chosen by whoever made the pack or the repository, and dek prints it in the
41
+ * commands it suggests and in AGENTS.md, inside a code span: no shell, code span, or command line
42
+ * reads anything in it.
43
+ */
44
+ const DECK_RE = /^[\p{L}\p{M}\p{N}._][\p{L}\p{M}\p{N}._-]{0,63}$/u;
45
+
46
+ /** Bytes a ref's deck name may take: its pack, `<deck>.tgz`, stays within a file name's 255. */
47
+ const DECK_MAX_BYTES = 200;
24
48
  const SHA_RE = /^[0-9a-f]{40}$/;
49
+ const DIGEST_RE = /^[0-9a-f]{64}$/;
25
50
 
26
- const REF_HINT =
27
- "name a ref as owner/repo/deck, e.g. hajimism/dek/why-dek or hajimism/dek/why-dek@v1";
51
+ /** What a ref's deck name may hold, as a hint says it. */
52
+ 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
+
55
+ 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
+ /** Where a pack's ref keeps its pack, relative to the project root: committed, never ignored. */
58
+ export function packFile(deck: string): string {
59
+ return `packs/${deck}.tgz`;
60
+ }
28
61
 
29
62
  /** True for `owner/repo/deck[@rev]` or a GitHub link, which a deck name never is. */
30
63
  export function isRefName(arg: string): boolean {
@@ -34,14 +67,15 @@ export function isRefName(arg: string): boolean {
34
67
  if (arg.startsWith(".") || arg.startsWith("/") || arg.startsWith("~")) {
35
68
  return false;
36
69
  }
37
- return arg.split("@")[0]?.split("/").length === 3;
70
+ const segments = arg.split("@")[0]?.split("/") ?? [];
71
+ return segments.length === 3 || (segments.length === 2 && segments[0] === PACK_OWNER);
38
72
  }
39
73
 
40
- /** A valid `owner/repo/deck` with no rev: the form dek.toml `[refs]` keys take. */
74
+ /** A valid `owner/repo/deck` with no rev, or a pack's `file/<deck>`: the form dek.toml `[refs]` keys take. */
41
75
  export function isPlainRefName(name: string): boolean {
42
76
  try {
43
77
  const source = parseRefSource(name);
44
- return source.rev === undefined && source.name === name;
78
+ return (source.kind === "file" || source.rev === undefined) && source.name === name;
45
79
  } catch {
46
80
  return false;
47
81
  }
@@ -51,6 +85,26 @@ export function isPinnedRev(rev: string): boolean {
51
85
  return SHA_RE.test(rev);
52
86
  }
53
87
 
88
+ /** True when `pin` has the form a ref of this kind is pinned with: a commit, or a pack's digest. */
89
+ export function isPinFor(source: RefSource, pin: string): boolean {
90
+ return source.kind === "file" ? DIGEST_RE.test(pin) : SHA_RE.test(pin);
91
+ }
92
+
93
+ /**
94
+ * True for a deck name a ref can carry: see DECK_RE; not . or ..; and composed (NFC), since a
95
+ * file system that ignores the form would give two spellings of one name one folder.
96
+ */
97
+ export function isRefDeckName(deck: string | undefined): deck is string {
98
+ return (
99
+ deck !== undefined &&
100
+ DECK_RE.test(deck) &&
101
+ deck === deck.normalize("NFC") &&
102
+ Buffer.byteLength(deck) <= DECK_MAX_BYTES &&
103
+ deck !== "." &&
104
+ deck !== ".."
105
+ );
106
+ }
107
+
54
108
  export function parseRefSource(input: string): RefSource {
55
109
  const trimmed = input.trim();
56
110
  if (/^https?:\/\//i.test(trimmed)) {
@@ -60,6 +114,17 @@ export function parseRefSource(input: string): RefSource {
60
114
  const path = at === -1 ? trimmed : trimmed.slice(0, at);
61
115
  const rev = at === -1 ? undefined : trimmed.slice(at + 1);
62
116
  const [owner, repo, deck, ...extra] = path.split("/");
117
+ if (owner === PACK_OWNER && deck === undefined) {
118
+ if (rev !== undefined || !isRefDeckName(repo)) {
119
+ throw new DekError(`"${input}" is not a ref name`, { hint: REF_HINT });
120
+ }
121
+ return { kind: "file", name: `${PACK_OWNER}/${repo}`, deck: repo };
122
+ }
123
+ if (owner?.toLowerCase() === PACK_OWNER && deck !== undefined) {
124
+ throw new DekError(`"${input}" is not a ref name`, {
125
+ hint: "file is kept for packs, so no GitHub owner of that name can be pinned; name a pack's ref as file/<deck>",
126
+ });
127
+ }
63
128
  const valid =
64
129
  extra.length === 0 &&
65
130
  owner !== undefined &&
@@ -68,15 +133,13 @@ export function parseRefSource(input: string): RefSource {
68
133
  REPO_RE.test(repo) &&
69
134
  repo !== "." &&
70
135
  repo !== ".." &&
71
- deck !== undefined &&
72
- DECK_RE.test(deck) &&
73
- deck !== "." &&
74
- deck !== ".." &&
136
+ isRefDeckName(deck) &&
75
137
  (rev === undefined || /^[^\s@]+$/.test(rev));
76
138
  if (!valid) {
77
139
  throw new DekError(`"${input}" is not a ref name`, { hint: REF_HINT });
78
140
  }
79
141
  return {
142
+ kind: "github",
80
143
  name: `${owner}/${repo}/${deck}`,
81
144
  owner,
82
145
  repo,
@@ -90,21 +153,41 @@ export function parseRefSource(input: string): RefSource {
90
153
  * link people copy from a browser. A branch name with a slash cannot be told
91
154
  * apart from the path, so such a branch goes after `@` instead.
92
155
  */
93
- function parseGithubUrl(input: string): RefSource {
156
+ function parseGithubUrl(input: string): GithubRefSource {
94
157
  const url = new URL(input);
95
- const [owner = "", repo = "", kind, rev, ...rest] = url.pathname.split("/").filter(Boolean);
96
- const decks = rest.indexOf("decks");
97
- const deck = decks === -1 ? undefined : rest[decks + 1];
158
+ const [owner = "", repo = "", kind, rev, ...encoded] = url.pathname.split("/").filter(Boolean);
159
+ // The folders after the rev as the repository names them; GitHub's link encodes any but ASCII.
160
+ const rest = decodeSegments(encoded);
161
+ const decks = rest?.indexOf("decks") ?? -1;
162
+ const deck = decks === -1 ? undefined : rest?.[decks + 1];
98
163
  if (
99
164
  url.hostname !== "github.com" ||
100
165
  (kind !== "tree" && kind !== "blob") ||
101
166
  !rev ||
167
+ rest === undefined ||
102
168
  deck === undefined
103
169
  ) {
104
170
  throw new DekError(`"${input}" is not a link to a deck on GitHub`, {
105
171
  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",
106
172
  });
107
173
  }
174
+ if (!isRefDeckName(deck)) {
175
+ throw new DekError(`"${input}" is not a link to a deck a ref can name`, {
176
+ hint: REF_DECK_NAME_RULE,
177
+ });
178
+ }
108
179
  const source = parseRefSource(`${owner}/${repo.replace(/\.git$/, "")}/${deck}@${rev}`);
180
+ if (source.kind !== "github") {
181
+ throw new DekError(`"${input}" is not a link to a deck on GitHub`, { hint: REF_HINT });
182
+ }
109
183
  return { ...source, path: rest.slice(0, decks + 2).join("/") };
110
184
  }
185
+
186
+ /** Each segment of a link's path decoded, or nothing when one is not valid percent-encoding. */
187
+ function decodeSegments(segments: string[]): string[] | undefined {
188
+ try {
189
+ return segments.map((segment) => decodeURIComponent(segment));
190
+ } catch {
191
+ return undefined;
192
+ }
193
+ }