@hajimism/dek 0.1.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 (187) hide show
  1. package/LICENSE +21 -0
  2. package/README.ja.md +75 -0
  3. package/README.md +75 -0
  4. package/package.json +65 -0
  5. package/src/cli/address.ts +53 -0
  6. package/src/cli/build.ts +85 -0
  7. package/src/cli/check.ts +97 -0
  8. package/src/cli/commands.ts +851 -0
  9. package/src/cli/contract.ts +444 -0
  10. package/src/cli/cues.ts +18 -0
  11. package/src/cli/files.ts +309 -0
  12. package/src/cli/flags.ts +295 -0
  13. package/src/cli/format.ts +156 -0
  14. package/src/cli/goto.ts +96 -0
  15. package/src/cli/init.ts +136 -0
  16. package/src/cli/keep-alive.ts +72 -0
  17. package/src/cli/lint.ts +84 -0
  18. package/src/cli/ls.ts +119 -0
  19. package/src/cli/main.ts +231 -0
  20. package/src/cli/marks.ts +16 -0
  21. package/src/cli/mv.ts +37 -0
  22. package/src/cli/new.ts +64 -0
  23. package/src/cli/pdf.ts +16 -0
  24. package/src/cli/qr.ts +466 -0
  25. package/src/cli/ref.ts +165 -0
  26. package/src/cli/rehearse.ts +42 -0
  27. package/src/cli/result.ts +161 -0
  28. package/src/cli/scope.ts +308 -0
  29. package/src/cli/serve.ts +125 -0
  30. package/src/cli/shot.ts +134 -0
  31. package/src/cli/show.ts +103 -0
  32. package/src/cli/sync.ts +37 -0
  33. package/src/cli/text.ts +479 -0
  34. package/src/cli/theme.ts +56 -0
  35. package/src/cli/tty.ts +81 -0
  36. package/src/cli/usage.ts +198 -0
  37. package/src/cli/video.ts +17 -0
  38. package/src/cli/voice.ts +113 -0
  39. package/src/cli.ts +4 -0
  40. package/src/core/assets.ts +239 -0
  41. package/src/core/build.ts +77 -0
  42. package/src/core/bundled-theme.ts +15 -0
  43. package/src/core/capture-go.ts +72 -0
  44. package/src/core/chrome.ts +128 -0
  45. package/src/core/config-keys.ts +82 -0
  46. package/src/core/config.ts +95 -0
  47. package/src/core/css-scan.ts +158 -0
  48. package/src/core/css-transform.ts +140 -0
  49. package/src/core/css-value.ts +127 -0
  50. package/src/core/css.ts +545 -0
  51. package/src/core/cue.ts +187 -0
  52. package/src/core/deck-paths.ts +34 -0
  53. package/src/core/diagnostic.ts +114 -0
  54. package/src/core/document.ts +218 -0
  55. package/src/core/error.ts +31 -0
  56. package/src/core/escape.ts +16 -0
  57. package/src/core/fill.ts +100 -0
  58. package/src/core/finish-beat.ts +30 -0
  59. package/src/core/github.ts +234 -0
  60. package/src/core/html-scan.ts +253 -0
  61. package/src/core/html.ts +234 -0
  62. package/src/core/in-page-go.ts +128 -0
  63. package/src/core/lines.ts +43 -0
  64. package/src/core/lint/asset-refs.ts +109 -0
  65. package/src/core/lint/context.ts +116 -0
  66. package/src/core/lint/files.ts +117 -0
  67. package/src/core/lint/keys.ts +48 -0
  68. package/src/core/lint/morph.ts +91 -0
  69. package/src/core/lint/script.ts +102 -0
  70. package/src/core/lint/shared-styles.ts +165 -0
  71. package/src/core/lint/slide-html.ts +466 -0
  72. package/src/core/lint/slide-script.ts +179 -0
  73. package/src/core/lint/theme-css.ts +295 -0
  74. package/src/core/lint/timing.ts +66 -0
  75. package/src/core/lint/tokens.ts +520 -0
  76. package/src/core/lint/voice.ts +76 -0
  77. package/src/core/lint.ts +137 -0
  78. package/src/core/live-protocol.ts +130 -0
  79. package/src/core/marks.ts +198 -0
  80. package/src/core/mv.ts +293 -0
  81. package/src/core/ogp.ts +99 -0
  82. package/src/core/optional.ts +24 -0
  83. package/src/core/overflow.ts +220 -0
  84. package/src/core/page-pool.ts +53 -0
  85. package/src/core/page.ts +66 -0
  86. package/src/core/parse.ts +307 -0
  87. package/src/core/path.ts +58 -0
  88. package/src/core/pdf.ts +71 -0
  89. package/src/core/playwright-module.d.ts +43 -0
  90. package/src/core/playwright-motion.ts +42 -0
  91. package/src/core/playwright-sheet.ts +32 -0
  92. package/src/core/playwright-visual.ts +391 -0
  93. package/src/core/playwright-worker.ts +5 -0
  94. package/src/core/playwright.ts +431 -0
  95. package/src/core/position.ts +146 -0
  96. package/src/core/presenter-state.ts +43 -0
  97. package/src/core/presenter.ts +131 -0
  98. package/src/core/prose.ts +16 -0
  99. package/src/core/pseudo-text.ts +67 -0
  100. package/src/core/rail-width.ts +4 -0
  101. package/src/core/ref-name.ts +110 -0
  102. package/src/core/ref.ts +238 -0
  103. package/src/core/resolve.ts +228 -0
  104. package/src/core/rumdl.ts +99 -0
  105. package/src/core/safe-fs.ts +196 -0
  106. package/src/core/sarif.ts +135 -0
  107. package/src/core/schema.ts +89 -0
  108. package/src/core/script-lines.ts +53 -0
  109. package/src/core/sheet.ts +223 -0
  110. package/src/core/shot/morph.ts +80 -0
  111. package/src/core/shot/motion.ts +187 -0
  112. package/src/core/shot/still.ts +216 -0
  113. package/src/core/shot-cache.ts +140 -0
  114. package/src/core/size.ts +11 -0
  115. package/src/core/skeleton-record.ts +51 -0
  116. package/src/core/skeleton.ts +89 -0
  117. package/src/core/slide-draw.ts +165 -0
  118. package/src/core/slide-eval-worker.ts +58 -0
  119. package/src/core/slide-measure.ts +272 -0
  120. package/src/core/slide-script-eval.ts +258 -0
  121. package/src/core/slide-script.ts +252 -0
  122. package/src/core/spawn.ts +128 -0
  123. package/src/core/step.ts +94 -0
  124. package/src/core/still-page.ts +70 -0
  125. package/src/core/suggest.ts +33 -0
  126. package/src/core/sync.ts +316 -0
  127. package/src/core/text-contrast.ts +466 -0
  128. package/src/core/theme-excerpt.ts +187 -0
  129. package/src/core/theme-facts.ts +51 -0
  130. package/src/core/timeline.ts +216 -0
  131. package/src/core/timing.ts +88 -0
  132. package/src/core/toml-keys.ts +349 -0
  133. package/src/core/url-attributes.ts +103 -0
  134. package/src/core/video-document.ts +15 -0
  135. package/src/core/visual-cache.ts +100 -0
  136. package/src/core/visual.ts +558 -0
  137. package/src/core/voice.ts +321 -0
  138. package/src/core/zod.ts +79 -0
  139. package/src/runtime/browser.ts +203 -0
  140. package/src/runtime/clone.ts +52 -0
  141. package/src/runtime/deck-control.ts +43 -0
  142. package/src/runtime/fit.ts +32 -0
  143. package/src/runtime/go.ts +56 -0
  144. package/src/runtime/input.ts +128 -0
  145. package/src/runtime/laser-view.ts +110 -0
  146. package/src/runtime/laser.ts +145 -0
  147. package/src/runtime/live-host.ts +33 -0
  148. package/src/runtime/live-reload.ts +29 -0
  149. package/src/runtime/live.ts +88 -0
  150. package/src/runtime/marks-view.ts +74 -0
  151. package/src/runtime/motion.ts +113 -0
  152. package/src/runtime/navigator.ts +189 -0
  153. package/src/runtime/player.ts +28 -0
  154. package/src/runtime/presenter-view.ts +289 -0
  155. package/src/runtime/presenter.ts +52 -0
  156. package/src/runtime/rail-view.ts +229 -0
  157. package/src/runtime/rail.ts +52 -0
  158. package/src/runtime/rehearse.ts +264 -0
  159. package/src/runtime/routes.ts +142 -0
  160. package/src/runtime/settle.ts +45 -0
  161. package/src/runtime/slide.d.ts +28 -0
  162. package/src/runtime/socket.ts +143 -0
  163. package/src/runtime/stage.ts +259 -0
  164. package/src/runtime/step.ts +139 -0
  165. package/src/runtime/window.ts +38 -0
  166. package/src/server/auth.ts +210 -0
  167. package/src/server/deck-snapshot.ts +146 -0
  168. package/src/server/dev.ts +251 -0
  169. package/src/server/hub.ts +104 -0
  170. package/src/server/lan.ts +51 -0
  171. package/src/server/lock.ts +97 -0
  172. package/src/server/pages.ts +71 -0
  173. package/src/server/pairing.ts +40 -0
  174. package/src/server/project-watch.ts +142 -0
  175. package/src/server/rooms.ts +133 -0
  176. package/src/server/routes.ts +286 -0
  177. package/src/server/serial.ts +25 -0
  178. package/src/server/watch.ts +308 -0
  179. package/src/theme/default.css +175 -0
  180. package/src/video/bake.ts +152 -0
  181. package/src/video/mux.ts +103 -0
  182. package/src/video/recorder.ts +115 -0
  183. package/src/video/sidecar.ts +60 -0
  184. package/src/video/worker.ts +51 -0
  185. package/src/voice/engine.ts +177 -0
  186. package/src/voice/synth.ts +127 -0
  187. package/src/voice/wav.ts +115 -0
@@ -0,0 +1,66 @@
1
+ import { escapeAttr } from "./escape.ts";
2
+
3
+ // What the deck page and the player runtime agree on: the ids the page is built with, and the
4
+ // settings it carries on <body>. The runtime bundles this for the browser: no Node imports.
5
+
6
+ /** The ids of the page's parts, by what they are. */
7
+ export const PAGE_ID = {
8
+ deck: "deck",
9
+ data: "dek-data",
10
+ shell: "dek-shell",
11
+ rail: "dek-rail",
12
+ railResize: "dek-rail-resize",
13
+ current: "dek-current",
14
+ currentStage: "dek-current-stage",
15
+ presenter: "dek-presenter",
16
+ progress: "dek-progress",
17
+ nextPanel: "dek-next-panel",
18
+ nextStage: "dek-next-stage",
19
+ nextEnd: "dek-next-end",
20
+ next: "dek-next",
21
+ notesPanel: "dek-notes-panel",
22
+ beats: "dek-beats",
23
+ script: "dek-script",
24
+ presenterBar: "dek-presenter-bar",
25
+ page: "dek-page",
26
+ elapsed: "dek-elapsed",
27
+ budget: "dek-budget",
28
+ laser: "dek-laser",
29
+ laserToggle: "dek-laser-toggle",
30
+ markToggle: "dek-mark-toggle",
31
+ hint: "dek-hint",
32
+ announce: "dek-announce",
33
+ } as const;
34
+
35
+ export type PageMode = "player" | "presenter" | "video";
36
+
37
+ /** The page's settings, written on <body> as `data-*` and read back once by the runtime. */
38
+ export type PageConfig = {
39
+ mode: PageMode;
40
+ deck: string;
41
+ /** The dev server's page: it follows the server's socket and events. */
42
+ live: boolean;
43
+ /** What a presenter page sends as `?token=` on its live channels. */
44
+ liveToken?: string;
45
+ };
46
+
47
+ /** `config` as the attributes of <body>, values escaped. */
48
+ export function pageConfigAttrs(config: PageConfig): string {
49
+ return [
50
+ ` data-mode="${config.mode}"`,
51
+ ` data-deck="${escapeAttr(config.deck)}"`,
52
+ config.live ? ` data-live="true"` : "",
53
+ config.liveToken ? ` data-live-token="${escapeAttr(config.liveToken)}"` : "",
54
+ ].join("");
55
+ }
56
+
57
+ /** The settings `pageConfigAttrs` wrote on `body`. */
58
+ export function readPageConfig(body: HTMLElement): PageConfig {
59
+ const { mode, deck, live, liveToken } = body.dataset;
60
+ return {
61
+ mode: mode === "presenter" || mode === "video" ? mode : "player",
62
+ deck: deck ?? "",
63
+ live: live === "true",
64
+ ...(liveToken ? { liveToken } : {}),
65
+ };
66
+ }
@@ -0,0 +1,307 @@
1
+ import { DekError } from "./error.ts";
2
+ import { splitLines } from "./lines.ts";
3
+ import { type Beat, Deck, Frontmatter, Id, inferLang, type Section } from "./schema.ts";
4
+ import { scriptLines } from "./script-lines.ts";
5
+ import { configHint, formatZodIssues, parseFailure } from "./zod.ts";
6
+
7
+ const HEADING_RE = /^(#{2,3})(?!#)\s+(.*)$/;
8
+ const TRAILING_ATTR_RE = /^(.*?)\s*\{([^}]*)\}\s*$/;
9
+ const ID_ATTR_RE = /^#([a-z0-9-]+)$/;
10
+ const YAML_PAIR_RE = /^(\s*[\w-]+\s*:)(\s*)(.*)$/;
11
+
12
+ /** One thing that stops a script.md from being read, where it is and how to fix it. */
13
+ export type ScriptProblem = { message: string; line?: number; hint?: string };
14
+
15
+ /**
16
+ * A script.md that cannot be read, with every problem that stops it, so one run reports them all.
17
+ * The first is the error's own message, line, and hint, for a command that stops on it.
18
+ */
19
+ export class ScriptError extends DekError {
20
+ readonly problems: ScriptProblem[];
21
+
22
+ constructor(problems: [ScriptProblem, ...ScriptProblem[]], path: string | undefined) {
23
+ const [first] = problems;
24
+ super(first.message, {
25
+ ...(path !== undefined ? { path } : {}),
26
+ ...(first.line !== undefined ? { line: first.line } : {}),
27
+ ...(first.hint !== undefined ? { hint: first.hint } : {}),
28
+ });
29
+ this.name = "ScriptError";
30
+ this.problems = problems;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * The deck a script.md describes. Every problem is collected before any is thrown: the
36
+ * frontmatter and each heading are read on their own, and a heading that cannot be read still
37
+ * opens its section, so the beats under it are not reported as well.
38
+ */
39
+ export function parseScript(source: string, filename?: string): Deck {
40
+ const problems: ScriptProblem[] = [];
41
+ const { yaml, body, bodyStartLine } = splitFrontmatter(source, filename);
42
+ const frontmatter = collect(problems, () => parseFrontmatter(yaml, filename));
43
+ const sections = parseSections(body, bodyStartLine, problems);
44
+
45
+ if (frontmatter !== undefined && problems.length === 0) {
46
+ const lang = frontmatter.lang ?? inferLang(`${frontmatter.title}\n${body}`);
47
+ const result = Deck.safeParse({ ...frontmatter, lang, sections });
48
+ if (result.success) {
49
+ return result.data;
50
+ }
51
+ problems.push({ message: formatZodIssues(result.error) });
52
+ }
53
+ // A frontmatter that was not read left its problem, so there is always a first one.
54
+ const [first = { message: "script.md could not be read" }, ...rest] = problems;
55
+ throw new ScriptError([first, ...rest], filename);
56
+ }
57
+
58
+ /** `read`'s value, or undefined with its DekError kept as a problem. */
59
+ function collect<T>(problems: ScriptProblem[], read: () => T): T | undefined {
60
+ try {
61
+ return read();
62
+ } catch (error) {
63
+ if (!(error instanceof DekError)) {
64
+ throw error;
65
+ }
66
+ problems.push({
67
+ message: error.message,
68
+ ...(error.line !== undefined ? { line: error.line } : {}),
69
+ ...(error.hint !== undefined ? { hint: error.hint } : {}),
70
+ });
71
+ return undefined;
72
+ }
73
+ }
74
+
75
+ /** A script.md cut at its frontmatter fences; the YAML starts on line 2. */
76
+ export type ScriptParts = { yaml: string; body: string; bodyStartLine: number };
77
+
78
+ export function splitFrontmatter(source: string, filename?: string): ScriptParts {
79
+ const lines = splitLines(source);
80
+ if (lines[0]?.trim() !== "---") {
81
+ throw new DekError("script.md must start with YAML frontmatter", {
82
+ line: 1,
83
+ path: filename,
84
+ hint: "start it with three lines: ---, title: Your talk, ---",
85
+ });
86
+ }
87
+
88
+ for (let i = 1; i < lines.length; i++) {
89
+ if (lines[i]?.trim() === "---") {
90
+ return {
91
+ yaml: lines.slice(1, i).join("\n"),
92
+ body: lines.slice(i + 1).join("\n"),
93
+ bodyStartLine: i + 2,
94
+ };
95
+ }
96
+ }
97
+
98
+ throw new DekError("YAML frontmatter is not closed", { line: 1, path: filename });
99
+ }
100
+
101
+ /** The frontmatter's YAML as a value, before the schema reads it; a syntax error throws. */
102
+ export function readFrontmatterYaml(yaml: string): unknown {
103
+ return Bun.YAML.parse(quoteUnquotedHashes(yaml));
104
+ }
105
+
106
+ function parseFrontmatter(yaml: string, filename?: string): Frontmatter {
107
+ let parsed: unknown;
108
+ try {
109
+ parsed = readFrontmatterYaml(yaml);
110
+ } catch (error) {
111
+ throw new DekError(`invalid YAML frontmatter: ${parseFailure(error)}`, {
112
+ path: filename,
113
+ cause: error,
114
+ hint: configHint("frontmatter"),
115
+ });
116
+ }
117
+
118
+ const result = Frontmatter.safeParse(parsed);
119
+ if (!result.success) {
120
+ throw new DekError(formatZodIssues(result.error), {
121
+ path: filename,
122
+ hint: configHint("frontmatter"),
123
+ });
124
+ }
125
+ return result.data;
126
+ }
127
+
128
+ /** Keep values like `Meetup #42` intact; YAML would otherwise treat `#` as a comment. */
129
+ function quoteUnquotedHashes(yaml: string): string {
130
+ return splitLines(yaml)
131
+ .map((line) => {
132
+ if (/^\s*#/.test(line) || !line.includes("#")) {
133
+ return line;
134
+ }
135
+ const match = line.match(YAML_PAIR_RE);
136
+ if (!match) {
137
+ return line;
138
+ }
139
+ let value = (match[3] ?? "").trimEnd();
140
+ if (!value || /^["'[{]/.test(value)) {
141
+ return line;
142
+ }
143
+ const comment = value.match(/^(.*) # .+$/);
144
+ if (comment) {
145
+ value = (comment[1] ?? "").trimEnd();
146
+ if (!value) {
147
+ return line;
148
+ }
149
+ }
150
+ const prefix = `${match[1] ?? ""}${match[2] ?? ""}`;
151
+ return `${prefix}${JSON.stringify(value)}`;
152
+ })
153
+ .join("\n");
154
+ }
155
+
156
+ function parseSections(body: string, startLine: number, problems: ScriptProblem[]): Section[] {
157
+ const sections: Section[] = [];
158
+ let current: DraftSection | undefined;
159
+ let bodyLines: string[] = [];
160
+
161
+ const flushBody = (): string => {
162
+ const text = bodyLines.join("\n").replace(/^\n+/, "").replace(/\n+$/, "");
163
+ bodyLines = [];
164
+ return text;
165
+ };
166
+
167
+ const finishBeat = (): void => {
168
+ if (!current?.beat) {
169
+ return;
170
+ }
171
+ current.beats.push({ ...current.beat, body: flushBody() });
172
+ current.beat = undefined;
173
+ };
174
+
175
+ const finishSection = (): void => {
176
+ if (!current) {
177
+ return;
178
+ }
179
+ finishBeat();
180
+ if (current.beats.length === 0) {
181
+ current.body = flushBody();
182
+ }
183
+ sections.push({
184
+ slug: current.slug,
185
+ title: current.title,
186
+ body: current.body,
187
+ beats: current.beats,
188
+ line: current.line,
189
+ });
190
+ current = undefined;
191
+ };
192
+
193
+ for (const [i, { text: line, literal }] of scriptLines(body).entries()) {
194
+ const lineNumber = startLine + i;
195
+ const heading = literal ? null : line.match(HEADING_RE);
196
+ if (!heading) {
197
+ bodyLines.push(line);
198
+ continue;
199
+ }
200
+
201
+ const hashes = heading[1] ?? "";
202
+ const text = heading[2] ?? "";
203
+ if (hashes === "##") {
204
+ finishSection();
205
+ const parsed = collect(problems, () =>
206
+ parseHeading(text, lineNumber, { idRequired: true, hashes }),
207
+ );
208
+ // A heading that cannot be read still opens its section, so its beats are not reported too.
209
+ current = {
210
+ slug: parsed?.id ?? "",
211
+ title: parsed?.title ?? text.trim(),
212
+ body: "",
213
+ beats: [],
214
+ line: lineNumber,
215
+ };
216
+ continue;
217
+ }
218
+
219
+ if (!current) {
220
+ problems.push({ message: "### beat is not inside a ## section", line: lineNumber });
221
+ continue;
222
+ }
223
+ finishBeat();
224
+ if (current.beats.length === 0) {
225
+ current.body = flushBody();
226
+ }
227
+ const parsed = collect(problems, () =>
228
+ parseHeading(text, lineNumber, { idRequired: false, hashes }),
229
+ );
230
+ current.beat = {
231
+ ...(parsed?.id ? { id: parsed.id } : {}),
232
+ title: parsed?.title ?? text.trim(),
233
+ line: lineNumber,
234
+ };
235
+ }
236
+
237
+ finishSection();
238
+ return sections;
239
+ }
240
+
241
+ type DraftSection = {
242
+ slug: string;
243
+ title: string;
244
+ body: string;
245
+ beats: Beat[];
246
+ line: number;
247
+ beat?: { id?: string; title: string; line: number };
248
+ };
249
+
250
+ function parseHeading(
251
+ text: string,
252
+ line: number,
253
+ options: { idRequired: true; hashes: string },
254
+ ): { title: string; id: string };
255
+ function parseHeading(
256
+ text: string,
257
+ line: number,
258
+ options: { idRequired: false; hashes: string },
259
+ ): { title: string; id?: string };
260
+ function parseHeading(
261
+ text: string,
262
+ line: number,
263
+ options: { idRequired: boolean; hashes: string },
264
+ ): { title: string; id?: string } {
265
+ const attrMatch = text.match(TRAILING_ATTR_RE);
266
+ if (attrMatch) {
267
+ const title = (attrMatch[1] ?? "").trim();
268
+ const attrs = attrMatch[2] ?? "";
269
+ const idMatch = attrs.match(ID_ATTR_RE);
270
+ const id = idMatch?.[1];
271
+ if (!id || !Id.safeParse(id).success) {
272
+ throw new DekError("heading attribute must be {#id}", {
273
+ line,
274
+ hint: headingIdHint(options.hashes, title, attrs.trim().match(/^#(.+)$/)?.[1]),
275
+ });
276
+ }
277
+ return { title, id };
278
+ }
279
+
280
+ const title = text.trim();
281
+ if (Id.safeParse(title).success) {
282
+ return { title, id: title };
283
+ }
284
+ if (options.idRequired) {
285
+ throw new DekError("heading requires {#id}", {
286
+ line,
287
+ hint: headingIdHint(options.hashes, title),
288
+ });
289
+ }
290
+ return { title };
291
+ }
292
+
293
+ /** An id built from the ASCII words of `source`, when it has any letters. */
294
+ function suggestId(source: string): string | undefined {
295
+ const id = source
296
+ .toLowerCase()
297
+ .match(/[a-z0-9]+/g)
298
+ ?.join("-");
299
+ return id && Id.safeParse(id).success ? id : undefined;
300
+ }
301
+
302
+ function headingIdHint(hashes: string, title: string, given?: string): string {
303
+ const id = (given && suggestId(given)) ?? suggestId(title);
304
+ return id
305
+ ? `write it as \`${hashes} ${title} {#${id}}\``
306
+ : `write it as \`${hashes} ${title} {#your-id}\`; ids use a-z, 0-9, and -`;
307
+ }
@@ -0,0 +1,58 @@
1
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ export function moduleFilePath(url: string | URL): string {
5
+ return fileURLToPath(url);
6
+ }
7
+
8
+ export function isInside(filePath: string, root: string): boolean {
9
+ const rel = relative(resolve(root), resolve(filePath));
10
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
11
+ }
12
+
13
+ /** The project a deck belongs to: decks live at `<root>/decks/<name>`. */
14
+ export function deckProjectRoot(deckDir: string): string {
15
+ return resolve(deckDir, "..", "..");
16
+ }
17
+
18
+ export const DECK_NAME_HINT =
19
+ 'use a plain name like 2026-10-talk: no "/" or "\\", no control characters, no leading "-"';
20
+
21
+ export function isDeckName(name: string): boolean {
22
+ return (
23
+ name.length > 0 &&
24
+ !name.includes("/") &&
25
+ !name.includes("\\") &&
26
+ name !== "." &&
27
+ name !== ".." &&
28
+ // A leading "-" reads as a flag to every tool the name is passed to.
29
+ !name.startsWith("-") &&
30
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: a control in a name is the problem.
31
+ !/[\x00-\x1f\x7f]/.test(name)
32
+ );
33
+ }
34
+
35
+ export type DistOptions = {
36
+ rootDist?: boolean;
37
+ };
38
+
39
+ export function distDir(
40
+ project: { root: string },
41
+ deck: { dir: string },
42
+ options?: DistOptions,
43
+ ): string {
44
+ return options?.rootDist ? join(project.root, "dist") : join(deck.dir, "dist");
45
+ }
46
+
47
+ export function distFile(
48
+ project: { root: string },
49
+ deck: { dir: string; name: string },
50
+ ext: string,
51
+ options?: DistOptions,
52
+ ): string {
53
+ return join(distDir(project, deck, options), `${deck.name}.${ext}`);
54
+ }
55
+
56
+ export function cacheDir(deckDir: string, kind: "voice" | "video" | "shots" | "visual"): string {
57
+ return join(deckDir, ".cache", kind);
58
+ }
@@ -0,0 +1,71 @@
1
+ import { readTheme } from "./assets.ts";
2
+ import { printPageCss } from "./chrome.ts";
3
+ import { deckSlides, htmlShell, slidePlace, stampSlide } from "./html.ts";
4
+ import { type DistOptions, distFile } from "./path.ts";
5
+ import { type PlaywrightRunner, requirePlaywright } from "./playwright.ts";
6
+ import { asResolvedDeck, type ProjectDeck, type ResolvedDeck } from "./resolve.ts";
7
+ import { outputPath } from "./safe-fs.ts";
8
+ import { logicalSize } from "./size.ts";
9
+ import { readSlideScripts, stillPageScript } from "./slide-script.ts";
10
+ import { lastStop, stepKey, stepValuesForBeat } from "./step.ts";
11
+
12
+ export type PdfResult = {
13
+ outPath: string;
14
+ };
15
+
16
+ export type PdfOptions = DistOptions & {
17
+ runner?: PlaywrightRunner;
18
+ };
19
+
20
+ export async function pdfDeck(dir: string, options?: PdfOptions): Promise<PdfResult>;
21
+ export async function pdfDeck(source: ResolvedDeck, options?: PdfOptions): Promise<PdfResult>;
22
+ export async function pdfDeck(
23
+ input: string | ResolvedDeck,
24
+ options: PdfOptions = {},
25
+ ): Promise<PdfResult> {
26
+ const { project, deck } = asResolvedDeck(input);
27
+ const outPath = distFile(project, deck, "pdf", options);
28
+ await requirePlaywright(
29
+ {
30
+ kind: "pdf",
31
+ viewport: logicalSize(deck.deck.ratio),
32
+ html: renderPdfHtml(deck),
33
+ // Chromium opens the path itself, so what is there must not be a link it would write through.
34
+ pdfPath: outputPath(outPath, project.root),
35
+ },
36
+ options.runner,
37
+ );
38
+ return { outPath };
39
+ }
40
+
41
+ /**
42
+ * Every slide of the deck on one page, each at its last beat, as the PDF prints it. `strict` stops
43
+ * on a slide script that cannot run, as a PDF must; a lenient page leaves it out.
44
+ */
45
+ export function renderPdfHtml(deck: ProjectDeck, options: { strict?: boolean } = {}): string {
46
+ const slides = deckSlides(deck);
47
+ // Every slide at its last beat, as a handout shows it.
48
+ const { sections } = deck.deck;
49
+ const slidesHtml = sections
50
+ .map((section, index) => {
51
+ const last = lastStop(section.beats);
52
+ return stampSlide(slides.section(section.slug), {
53
+ slug: section.slug,
54
+ place: slidePlace(sections, index),
55
+ shown: stepValuesForBeat(section.beats, last),
56
+ beat: { index: last, step: stepKey(section.beats, last) },
57
+ inline: { deckDir: deck.dir },
58
+ });
59
+ })
60
+ .join("");
61
+ const themeCss = readTheme(deck.dir, true);
62
+ const size = logicalSize(deck.deck.ratio);
63
+ return htmlShell({
64
+ lang: deck.deck.lang,
65
+ title: deck.deck.title,
66
+ head: `<style>${themeCss}</style>
67
+ <style>${printPageCss(size)}</style>`,
68
+ body: `<div id="deck">${slidesHtml}</div>
69
+ ${stillPageScript(readSlideScripts(deck.dir, { strict: options.strict ?? true }))}`,
70
+ });
71
+ }
@@ -0,0 +1,43 @@
1
+ declare module "playwright" {
2
+ type Viewport = { width: number; height: number };
3
+ export type Page = {
4
+ goto(url: string): Promise<unknown>;
5
+ setContent(html: string, options?: { waitUntil?: string }): Promise<void>;
6
+ emulateMedia(options: { reducedMotion?: "reduce" | "no-preference" }): Promise<void>;
7
+ setViewportSize(size: Viewport): Promise<void>;
8
+ evaluate<T, A = unknown>(fn: (arg: A) => T | Promise<T>, arg?: A): Promise<T>;
9
+ screenshot(options: {
10
+ path?: string;
11
+ fullPage?: boolean;
12
+ type?: "png" | "jpeg";
13
+ }): Promise<Buffer>;
14
+ addScriptTag(options: { content: string }): Promise<unknown>;
15
+ pdf(options: {
16
+ path: string;
17
+ width?: string;
18
+ height?: string;
19
+ printBackground?: boolean;
20
+ margin?: { top?: string; right?: string; bottom?: string; left?: string };
21
+ }): Promise<Buffer>;
22
+ close(): Promise<void>;
23
+ context(): BrowserContext;
24
+ };
25
+ /** A DevTools protocol session: Chromium's own answers, for what the page does not expose. */
26
+ export type CDPSession = {
27
+ send(method: string, params?: Record<string, unknown>): Promise<unknown>;
28
+ detach(): Promise<void>;
29
+ };
30
+ export type BrowserContext = {
31
+ newPage(): Promise<Page>;
32
+ newCDPSession(page: Page): Promise<CDPSession>;
33
+ close(): Promise<void>;
34
+ };
35
+ export type Browser = {
36
+ newPage(options?: { viewport?: Viewport }): Promise<Page>;
37
+ newContext(options?: { viewport?: Viewport }): Promise<BrowserContext>;
38
+ close(): Promise<void>;
39
+ };
40
+ export const chromium: {
41
+ launch(options?: { headless?: boolean }): Promise<Browser>;
42
+ };
43
+ }
@@ -0,0 +1,42 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { Page } from "playwright";
4
+ import { captureGo, loadVideoDoc, settleAt } from "./capture-go.ts";
5
+ import type { MotionSpec } from "./playwright.ts";
6
+ import type { MotionBeat, MotionFrame } from "./sheet.ts";
7
+
8
+ /**
9
+ * Plays each beat of the request in the video document `html` the way the talk reaches it, from the
10
+ * beat before. Each go is held at `fractions` of how long it runs, then ended the way a still
11
+ * of that beat ends it, so the last frame is the beat as `dekc shot` shows it. A go that moves
12
+ * nothing is that last frame alone.
13
+ */
14
+ export async function captureMotion(
15
+ page: Page,
16
+ html: string,
17
+ motion: MotionSpec,
18
+ ): Promise<MotionBeat[]> {
19
+ await loadVideoDoc(page, html);
20
+ if (motion.from) {
21
+ await settleAt(page, motion.from);
22
+ }
23
+ const framesDir = join(motion.dir, "frames");
24
+ mkdirSync(framesDir, { recursive: true });
25
+ const beats: MotionBeat[] = [];
26
+ for (const [index, beat] of motion.beats.entries()) {
27
+ const frames: MotionFrame[] = [];
28
+ await captureGo(
29
+ page,
30
+ beat.position,
31
+ (span) => (span > 0 ? motion.fractions.map((fraction) => fraction * span) : []),
32
+ async (ms, end) => {
33
+ const name = end ? "end" : String(Math.round(ms)).padStart(5, "0");
34
+ const path = join(framesDir, `${index + 1}-${name}.png`);
35
+ await page.screenshot({ path, fullPage: false });
36
+ frames.push(end ? { ms, path, end: true } : { ms, path });
37
+ },
38
+ );
39
+ beats.push({ label: beat.label, frames });
40
+ }
41
+ return beats;
42
+ }
@@ -0,0 +1,32 @@
1
+ import { mkdirSync, readFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import type { Page } from "playwright";
4
+ import { type SheetSpec, sheetHtml, sheetLayout } from "./sheet.ts";
5
+
6
+ /**
7
+ * Draws each contact sheet from the captures on disk, one page at its own size apiece, and
8
+ * returns their paths in order. The images go in as data: URIs, so the page loads nothing.
9
+ */
10
+ export async function renderSheets(
11
+ specs: SheetSpec[],
12
+ newPage: () => Promise<Page>,
13
+ ): Promise<string[]> {
14
+ const drawn: string[] = [];
15
+ for (const spec of specs) {
16
+ const page = await newPage();
17
+ try {
18
+ await page.setViewportSize(sheetLayout(spec).size);
19
+ await page.setContent(sheetHtml(spec, pngDataUri), { waitUntil: "load" });
20
+ mkdirSync(dirname(spec.path), { recursive: true });
21
+ await page.screenshot({ path: spec.path, fullPage: false });
22
+ drawn.push(spec.path);
23
+ } finally {
24
+ await page.close();
25
+ }
26
+ }
27
+ return drawn;
28
+ }
29
+
30
+ function pngDataUri(path: string): string {
31
+ return `data:image/png;base64,${readFileSync(path).toString("base64")}`;
32
+ }