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