@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,216 @@
1
+ import { DekError } from "../error.ts";
2
+ import {
3
+ askPlaywright,
4
+ type PagesRequest,
5
+ type PlaywrightRunner,
6
+ requirePlaywright,
7
+ type VisualPage,
8
+ } from "../playwright.ts";
9
+ import { asResolvedDeck, type ResolvedDeck, requireSection } from "../resolve.ts";
10
+ import { dropLinks, isCachedFile } from "../safe-fs.ts";
11
+ import type { Section } from "../schema.ts";
12
+ import { overviewSheets, SHEET_LAYOUT_VERSION, type SheetSpec } from "../sheet.ts";
13
+ import { bundleEntry, type CacheEntry, stillEntry } from "../shot-cache.ts";
14
+ import { logicalSize } from "../size.ts";
15
+ import { formatStepChoices, lastStop, resolveStop, stepKey } from "../step.ts";
16
+ import { loadSlideSources, renderSlideHtml, type SlideSources } from "../still-page.ts";
17
+
18
+ export type ShotFile = {
19
+ slug: string;
20
+ step: string;
21
+ path: string;
22
+ /** Set on a morph frame: the slide the transition goes to. */
23
+ to?: string;
24
+ /** Set on a morph frame: where in the transition the frame was taken, 0..1. */
25
+ at?: number;
26
+ };
27
+
28
+ export type ShotDeckOptions = {
29
+ slug?: string;
30
+ step?: string;
31
+ runner?: PlaywrightRunner;
32
+ };
33
+
34
+ export async function shotDeck(dir: string, options?: ShotDeckOptions): Promise<ShotFile[]>;
35
+ export async function shotDeck(
36
+ source: ResolvedDeck,
37
+ options?: ShotDeckOptions,
38
+ ): Promise<ShotFile[]>;
39
+ export async function shotDeck(
40
+ input: string | ResolvedDeck,
41
+ options: ShotDeckOptions = {},
42
+ ): Promise<ShotFile[]> {
43
+ const { deck } = asResolvedDeck(input);
44
+ const sections = options.slug
45
+ ? deck.deck.sections.filter((section) => section.slug === options.slug)
46
+ : deck.deck.sections;
47
+ if (options.slug && sections.length === 0) {
48
+ requireSection(deck, options.slug);
49
+ }
50
+ const stills = stillPages(deck, sections, options.step);
51
+ await shootStills(deck, stills, options.runner);
52
+ return stills.map(shotOf);
53
+ }
54
+
55
+ export type ShotSheet = {
56
+ /** The contact sheets, in order; one unless the deck is too long for one. */
57
+ sheets: string[];
58
+ /** Each slide's own shot, the sheets' tiles at full size. */
59
+ shots: ShotFile[];
60
+ };
61
+
62
+ /**
63
+ * Every slide at its last beat, tiled on contact sheets, so the whole deck reads in one look.
64
+ * The tiles are the slides' own shots, taken in the same browser run when they are stale; a deck
65
+ * where nothing changed starts no browser at all.
66
+ */
67
+ export async function shotSheet(
68
+ dir: string,
69
+ options?: { runner?: PlaywrightRunner },
70
+ ): Promise<ShotSheet>;
71
+ export async function shotSheet(
72
+ source: ResolvedDeck,
73
+ options?: { runner?: PlaywrightRunner },
74
+ ): Promise<ShotSheet>;
75
+ export async function shotSheet(
76
+ input: string | ResolvedDeck,
77
+ options: { runner?: PlaywrightRunner } = {},
78
+ ): Promise<ShotSheet> {
79
+ const { deck } = asResolvedDeck(input);
80
+ const stills = stillPages(deck, deck.deck.sections);
81
+ const cells = stills.map((still, i) => ({
82
+ image: still.screenshotPath,
83
+ label: `${i + 1} · ${still.slug}`,
84
+ }));
85
+ // The tiles' paths hash what they show, so the key changes whenever a tile does.
86
+ const bundle = bundleEntry(
87
+ deck.dir,
88
+ "sheets",
89
+ [],
90
+ JSON.stringify([SHEET_LAYOUT_VERSION, deck.deck.title, cells]),
91
+ );
92
+ dropLinks(bundle.dir);
93
+ const sheets = overviewSheets(cells, {
94
+ slide: logicalSize(deck.deck.ratio),
95
+ title: deck.deck.title,
96
+ dir: bundle.dir,
97
+ });
98
+ await shootStills(deck, stills, options.runner, { sheets });
99
+ bundle.commit();
100
+ return { sheets: sheets.map((spec) => spec.path), shots: stills.map(shotOf) };
101
+ }
102
+
103
+ /**
104
+ * The first slide at its last beat, as a link preview shows the talk. The shot is the one
105
+ * `dekc shot` takes of that slide, cached by the rendered HTML, so a build only starts Chromium
106
+ * when the first slide changed. Returns undefined when Playwright is not installed.
107
+ */
108
+ export async function coverShot(
109
+ deck: ResolvedDeck["deck"],
110
+ runner?: PlaywrightRunner,
111
+ ): Promise<string | undefined> {
112
+ const stills = stillPages(deck, deck.deck.sections.slice(0, 1));
113
+ const [still] = stills;
114
+ if (!still) {
115
+ return undefined;
116
+ }
117
+ const shot = await shootStills(deck, stills, runner, { ifMissing: "skip" });
118
+ return shot ? still.screenshotPath : undefined;
119
+ }
120
+
121
+ export type Still = {
122
+ html: string;
123
+ slug: string;
124
+ /** The step as it was asked for, or the beat's key. */
125
+ step: string;
126
+ screenshotPath: string;
127
+ entry: CacheEntry;
128
+ };
129
+
130
+ /** One slide at one beat as `dekc shot` takes it: its page, and the cached shot it is named by. */
131
+ export function still(
132
+ deck: ResolvedDeck["deck"],
133
+ section: Section,
134
+ beatIndex: number,
135
+ sources: SlideSources,
136
+ step: string = stepKey(section.beats, beatIndex),
137
+ ): Still {
138
+ const html = renderSlideHtml(sources, section.slug, beatIndex);
139
+ const entry = stillEntry(deck.dir, section, beatIndex, html);
140
+ return { html, slug: section.slug, step, screenshotPath: entry.path, entry };
141
+ }
142
+
143
+ /** The still as a page to render; it is shot only when its shot is not on disk yet. */
144
+ export function stillPage(still: Still): VisualPage {
145
+ return {
146
+ html: still.html,
147
+ slug: still.slug,
148
+ step: still.step,
149
+ ...(still.entry.cached ? {} : { screenshotPath: still.screenshotPath }),
150
+ };
151
+ }
152
+
153
+ /** Each section's still at `step` (its last beat when left out). */
154
+ function stillPages(deck: ResolvedDeck["deck"], sections: Section[], step?: string): Still[] {
155
+ const sources = loadSlideSources(deck);
156
+ return sections.map((section) => {
157
+ const beat = resolveBeat(section, step);
158
+ return still(deck, section, beat.index, sources, beat.label);
159
+ });
160
+ }
161
+
162
+ /**
163
+ * Shoots the stills not on disk yet, and draws `sheets` in the same browser run; with nothing to
164
+ * shoot or draw, no browser starts. Older shots of the same stills go only once the run is done.
165
+ * Without Playwright this throws, or with `ifMissing: "skip"` shoots nothing and answers false.
166
+ */
167
+ async function shootStills(
168
+ deck: ResolvedDeck["deck"],
169
+ stills: Still[],
170
+ runner: PlaywrightRunner | undefined,
171
+ { sheets, ifMissing = "throw" }: { sheets?: SheetSpec[]; ifMissing?: "throw" | "skip" } = {},
172
+ ): Promise<boolean> {
173
+ const stale = stills.filter((still) => !still.entry.cached);
174
+ if (stale.length > 0 || sheets?.some((spec) => !isCachedFile(spec.path))) {
175
+ const request: PagesRequest = {
176
+ kind: "pages",
177
+ viewport: logicalSize(deck.deck.ratio),
178
+ actions: [],
179
+ pages: stale.map(stillPage),
180
+ ...(sheets ? { sheets } : {}),
181
+ };
182
+ if (ifMissing === "throw") {
183
+ await requirePlaywright(request, runner);
184
+ } else if ((await askPlaywright(request, runner)) === null) {
185
+ return false;
186
+ }
187
+ }
188
+ for (const still of stills) {
189
+ still.entry.commit();
190
+ }
191
+ return true;
192
+ }
193
+
194
+ function shotOf(still: Still): ShotFile {
195
+ return { slug: still.slug, step: still.step, path: still.screenshotPath };
196
+ }
197
+
198
+ /** The beat `step` names in `section` (its last beat when left out), and its label. */
199
+ export function resolveBeat(section: Section, step?: string): { index: number; label: string } {
200
+ const index = step === undefined ? lastStop(section.beats) : resolveStop(section.beats, step);
201
+ if (index === undefined) {
202
+ throw new DekError(`step "${step}" not found in "${section.slug}"`, {
203
+ hint: stepNotFoundHint(section),
204
+ });
205
+ }
206
+ return { index, label: step ?? stepKey(section.beats, index) };
207
+ }
208
+
209
+ function stepNotFoundHint(section: Section): string {
210
+ return section.beats.length === 0
211
+ ? "this slide has no beats; use 0, or leave out --step"
212
+ : formatStepChoices([
213
+ ...section.beats.flatMap((beat) => (beat.id ? [beat.id] : [])),
214
+ `0-${section.beats.length}`,
215
+ ]);
216
+ }
@@ -0,0 +1,140 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readdirSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { escapeRegExp } from "./escape.ts";
5
+ import { cacheDir, deckProjectRoot } from "./path.ts";
6
+ import { isCachedFile, outputDir } from "./safe-fs.ts";
7
+ import type { Section } from "./schema.ts";
8
+ import { lastStop, stepKey } from "./step.ts";
9
+
10
+ /**
11
+ * Every capture dek caches lives under `.cache/shots`, named by what it is and a hash of what it
12
+ * shows. The parts of a name are joined with "~", which no slug, beat id or number can contain,
13
+ * so no two captures share a name, and pruning one never reaches another.
14
+ */
15
+ const SEPARATOR = "~";
16
+ const HASH = "[0-9a-f]{8}";
17
+
18
+ /** A cached file: where it goes, whether it is already there, and `commit` once it is written. */
19
+ export type CacheEntry = {
20
+ path: string;
21
+ cached: boolean;
22
+ /** Removes the older runs of the same capture; call it only once this one is on disk. */
23
+ commit(): void;
24
+ };
25
+
26
+ /** A cached folder of files, such as a set of contact sheets. */
27
+ type CacheBundle = {
28
+ dir: string;
29
+ commit(): void;
30
+ };
31
+
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));
35
+ }
36
+
37
+ function contentHash(text: string): string {
38
+ return createHash("sha256").update(text).digest("hex").slice(0, 8);
39
+ }
40
+
41
+ /**
42
+ * `<part>~<part>.<hash>.png`, where the hash covers what the page renders (theme, slide, shown
43
+ * steps, inlined assets). A changed rendering gets a new path, so nothing that caches by path can
44
+ * show a stale image.
45
+ */
46
+ export function shotName(parts: string[], content: string): string {
47
+ return `${parts.join(SEPARATOR)}.${contentHash(content)}.png`;
48
+ }
49
+
50
+ /**
51
+ * The still of `section` at `beatIndex`. It is named by the beat's own key, so `--step hook`,
52
+ * `--step 2` and a still at the last beat, `dekc build`'s link preview included, are one file.
53
+ */
54
+ export function stillEntry(
55
+ deckDir: string,
56
+ section: Section,
57
+ beatIndex: number,
58
+ html: string,
59
+ ): CacheEntry {
60
+ const step = stepKey(section.beats, beatIndex);
61
+ // Names from before "~": `<slug>` for the last beat, `<slug>-<step>` for one asked for.
62
+ const legacy = [
63
+ ...(beatIndex === lastStop(section.beats) ? [section.slug] : []),
64
+ `${section.slug}-${step}`,
65
+ `${section.slug}-${beatIndex}`,
66
+ ];
67
+ return fileEntry(deckDir, [section.slug, step], html, legacy);
68
+ }
69
+
70
+ /** The frame `at` of the move from `from` into `to`. */
71
+ export function morphEntry(
72
+ deckDir: string,
73
+ from: string,
74
+ to: string,
75
+ at: number,
76
+ html: string,
77
+ ): CacheEntry {
78
+ return fileEntry(deckDir, [from, to, String(at)], `${at}\0${html}`, [`${from}-to-${to}-${at}`]);
79
+ }
80
+
81
+ /**
82
+ * `.cache/shots/<kind>/<parts>.<hash>`, a folder a capture fills. Each set of parts keeps one
83
+ * folder, so runs of different captures neither evict nor race each other.
84
+ */
85
+ export function bundleEntry(
86
+ deckDir: string,
87
+ kind: "sheets" | "motion",
88
+ parts: string[],
89
+ content: string,
90
+ ): CacheBundle {
91
+ const root = deckProjectRoot(deckDir);
92
+ const parent = outputDir(join(shotsDir(deckDir), kind), root);
93
+ const base = parts.join(SEPARATOR);
94
+ const name = base ? `${base}.${contentHash(content)}` : contentHash(content);
95
+ const pattern = new RegExp(`^${base ? `${escapeRegExp(base)}\\.` : ""}${HASH}$`);
96
+ return {
97
+ dir: outputDir(join(parent, name), root),
98
+ commit: () => prune(parent, [pattern], name, { recursive: true }),
99
+ };
100
+ }
101
+
102
+ function fileEntry(
103
+ deckDir: string,
104
+ parts: string[],
105
+ content: string,
106
+ legacy: string[],
107
+ ): CacheEntry {
108
+ const dir = shotsDir(deckDir);
109
+ const name = shotName(parts, content);
110
+ const path = join(dir, name);
111
+ const patterns = [
112
+ new RegExp(`^${escapeRegExp(parts.join(SEPARATOR))}\\.${HASH}\\.png$`),
113
+ ...legacy.map((base) => new RegExp(`^${escapeRegExp(base)}(?:\\.${HASH})?\\.png$`)),
114
+ ];
115
+ return {
116
+ path,
117
+ cached: isCachedFile(path),
118
+ commit: () => prune(dir, patterns, name),
119
+ };
120
+ }
121
+
122
+ /** Removes the entries of `dir` that a pattern names, all but `keep`. */
123
+ function prune(
124
+ dir: string,
125
+ patterns: RegExp[],
126
+ keep: string,
127
+ options: { recursive?: boolean } = {},
128
+ ): void {
129
+ let names: string[];
130
+ try {
131
+ names = readdirSync(dir);
132
+ } catch {
133
+ return;
134
+ }
135
+ for (const name of names) {
136
+ if (name !== keep && patterns.some((pattern) => pattern.test(name))) {
137
+ rmSync(join(dir, name), { force: true, recursive: options.recursive === true });
138
+ }
139
+ }
140
+ }
@@ -0,0 +1,11 @@
1
+ export type Size = {
2
+ width: number;
3
+ height: number;
4
+ };
5
+
6
+ export function logicalSize(ratio?: "16:9" | "4:3"): Size {
7
+ if (ratio === "4:3") {
8
+ return { width: 1024, height: 768 };
9
+ }
10
+ return { width: 1280, height: 720 };
11
+ }
@@ -0,0 +1,51 @@
1
+ import { createHash } from "node:crypto";
2
+ import { join } from "node:path";
3
+ import { deckProjectRoot } from "./path.ts";
4
+ import { readSourceIfExists, writeInside } from "./safe-fs.ts";
5
+
6
+ /**
7
+ * Which slides are still dek's own: the skeletons dek wrote that nobody has touched since, kept as
8
+ * hashes of their bytes in `.cache/skeletons`. The shape of a skeleton proves nothing, since an
9
+ * author who retypes its heading keeps the shape; only the bytes dek wrote do.
10
+ *
11
+ * The record is a cache, and anyone may delete it. Without it, a slide that is exactly the
12
+ * skeleton dek would write now is still provably dek's, and every other slide is the author's, so
13
+ * a lost record costs refreshes, never edits.
14
+ */
15
+ export type SkeletonRecord = {
16
+ /** Whether `html` is dek's: what it wrote before, or byte for byte what it would write now. */
17
+ owns(html: string, now: string | undefined): boolean;
18
+ /** Notes `html` as dek's for the next run; what is not kept is forgotten on `save`. */
19
+ keep(html: string): void;
20
+ /** Writes what was kept, when that differs from what was read. */
21
+ save(): void;
22
+ };
23
+
24
+ export function skeletonRecord(deckDir: string): SkeletonRecord {
25
+ const root = deckProjectRoot(deckDir);
26
+ const path = join(deckDir, ".cache", "skeletons");
27
+ const source = readSourceIfExists(path, root);
28
+ const written = new Set(source?.split("\n").filter((line) => HASH_RE.test(line)));
29
+ const kept = new Set<string>();
30
+ return {
31
+ owns: (html, now) => html === now || written.has(hashOf(html)),
32
+ keep: (html) => {
33
+ kept.add(hashOf(html));
34
+ },
35
+ save: () => {
36
+ const next = [...kept]
37
+ .sort()
38
+ .map((hash) => `${hash}\n`)
39
+ .join("");
40
+ if (next !== (source ?? "")) {
41
+ writeInside(path, next, root);
42
+ }
43
+ },
44
+ };
45
+ }
46
+
47
+ const HASH_RE = /^[0-9a-f]{64}$/;
48
+
49
+ function hashOf(html: string): string {
50
+ return createHash("sha256").update(html).digest("hex");
51
+ }
@@ -0,0 +1,89 @@
1
+ import { cssLayoutNames, parseCss } from "./css.ts";
2
+ import { deckPaths } from "./deck-paths.ts";
3
+ import { escapeHtml } from "./escape.ts";
4
+ import { readDeckFile } from "./resolve.ts";
5
+ import type { Deck, Section } from "./schema.ts";
6
+ import { beatStep } from "./step.ts";
7
+
8
+ // The slide HTML `dekc sync` writes for a section nobody has written yet. Rendering, lint, and
9
+ // `dekc mv` read it too, so it lives apart from the writes in sync.ts.
10
+
11
+ /**
12
+ * The layouts a deck's theme.css lays out. A skeleton names a layout only from these, so a new
13
+ * deck passes lint in whatever theme it was made with.
14
+ */
15
+ export type SkeletonLayouts = ReadonlySet<string>;
16
+
17
+ export function deckLayouts(deckDir: string): SkeletonLayouts {
18
+ const css = readDeckFile(deckDir, deckPaths(deckDir).theme);
19
+ return css === undefined ? new Set() : cssLayoutNames(parseCss(css));
20
+ }
21
+
22
+ /**
23
+ * A heading that is only an id (`## intro`) has no display text. The first
24
+ * section takes the deck title; later ones stay empty so the slug never ends
25
+ * up on a published slide. Lint reports the empty heading (DEK024) with the
26
+ * fix: a title in script.md, which the next sync writes here.
27
+ */
28
+ function skeletonHeading(
29
+ section: Pick<Section, "slug" | "title">,
30
+ index: number,
31
+ deckTitle: string,
32
+ ): string {
33
+ if (section.title !== section.slug) {
34
+ return section.title;
35
+ }
36
+ return index === 0 ? deckTitle : "";
37
+ }
38
+
39
+ /** The HTML `dekc sync` would generate for `slug`, or undefined when the deck has no such section. */
40
+ export function skeletonHtml(
41
+ deck: Deck,
42
+ slug: string,
43
+ layouts: SkeletonLayouts,
44
+ ): string | undefined {
45
+ const index = deck.sections.findIndex((section) => section.slug === slug);
46
+ return index < 0 ? undefined : sectionSkeleton(deck, index, layouts);
47
+ }
48
+
49
+ /** The skeleton of the section at `index`, which must exist. */
50
+ export function sectionSkeleton(deck: Deck, index: number, layouts: SkeletonLayouts): string {
51
+ const section = deck.sections[index];
52
+ if (!section) {
53
+ throw new RangeError(`no section at ${index}`);
54
+ }
55
+ const heading = skeletonHeading(section, index, deck.title);
56
+ const inner =
57
+ section.beats.length === 0
58
+ ? renderTitleSlide(heading, layouts)
59
+ : renderBeatSlide(section, heading, layouts);
60
+ return `${inner}\n`;
61
+ }
62
+
63
+ /** The opening tag, naming `layout` only when the theme lays it out; a bare slide is valid. */
64
+ function openSlide(layout: string, layouts: SkeletonLayouts): string {
65
+ return layouts.has(layout)
66
+ ? `<section class="slide" data-layout="${layout}">`
67
+ : `<section class="slide">`;
68
+ }
69
+
70
+ function renderTitleSlide(heading: string, layouts: SkeletonLayouts): string {
71
+ return `${openSlide("title", layouts)}
72
+ <h2 class="slide-title">${escapeHtml(heading)}</h2>
73
+ </section>`;
74
+ }
75
+
76
+ function renderBeatSlide(section: Section, heading: string, layouts: SkeletonLayouts): string {
77
+ const items = section.beats
78
+ .map(
79
+ (beat, index) =>
80
+ ` <li data-step="${beatStep(section.beats, index)}">${escapeHtml(beat.title)}</li>`,
81
+ )
82
+ .join("\n");
83
+ return `${openSlide("default", layouts)}
84
+ <h2 class="slide-title">${escapeHtml(heading)}</h2>
85
+ <ul>
86
+ ${items}
87
+ </ul>
88
+ </section>`;
89
+ }
@@ -0,0 +1,165 @@
1
+ // Runs in the page; dom.iterable for older compilers, as in slide-measure.ts.
2
+ /// <reference lib="dom" />
3
+ /// <reference lib="dom.iterable" />
4
+ /**
5
+ * Draws slide scripts' frames. The player calls these directly; pages without
6
+ * the player (shots, lint --visual, PDF) embed the same functions as source
7
+ * via `stillDrawScript`, so they reference nothing outside this file and use
8
+ * only the global `DekSlide` types.
9
+ */
10
+ import { finishBeat } from "./finish-beat.ts";
11
+
12
+ /** A beat's motion in ms, keyed like `data-step`; 0 when it has none or it is not positive. */
13
+ export function stepMotionMs(module: DekSlide | undefined, step: string): number {
14
+ const ms = module?.motion?.[step];
15
+ return typeof ms === "number" && ms > 0 ? ms : 0;
16
+ }
17
+
18
+ /**
19
+ * The one place a slide's `draw` runs: a throw is logged and handed back, never passed to the
20
+ * player, which keeps presenting whatever a slide does.
21
+ */
22
+ export function drawFrame(
23
+ module: DekSlide | undefined,
24
+ slide: HTMLElement,
25
+ frame: DekMotionFrame,
26
+ ): { error: unknown } | undefined {
27
+ if (!module || typeof module.draw !== "function") {
28
+ return undefined;
29
+ }
30
+ try {
31
+ module.draw(slide, frame);
32
+ return undefined;
33
+ } catch (error) {
34
+ console.error(error);
35
+ return { error };
36
+ }
37
+ }
38
+
39
+ /** Draws a beat as it ends, for pages that show a still: thumbnails, previews, shots, PDF. */
40
+ export function drawAtEnd(
41
+ module: DekSlide | undefined,
42
+ slide: HTMLElement,
43
+ index: number,
44
+ step: string,
45
+ ): { t: number; error: unknown } | undefined {
46
+ const t = stepMotionMs(module, step);
47
+ const failed = drawFrame(module, slide, { index, step, t });
48
+ return failed && { t, error: failed.error };
49
+ }
50
+
51
+ /**
52
+ * Draws every slide `markBeat` marked, at the end of its beat, and checks each draw as it goes. A
53
+ * draw that throws leaves the slide as it was before; one that changes the page outside its slide
54
+ * reaches another slide in the built deck; and one that draws the end of its beat differently after
55
+ * drawing its start keeps state between calls, which a seek in video and shots cannot replay. Each
56
+ * is kept in `window.__dekDrawErrors` for whoever measures the page. What the draws change in
57
+ * attributes, inline styles above all, can be taken back with `window.__dekUndoDraw`, so a
58
+ * measurement can tell what the script draws from what CSS does.
59
+ */
60
+ function drawMarkedSlides(): void {
61
+ const modules = window.__dekSlides ?? {};
62
+ const errors: NonNullable<Window["__dekDrawErrors"]> = [];
63
+ window.__dekDrawErrors = errors;
64
+ const observer = new window.MutationObserver(() => {});
65
+ observer.observe(document.documentElement, {
66
+ attributes: true,
67
+ attributeOldValue: true,
68
+ characterData: true,
69
+ childList: true,
70
+ subtree: true,
71
+ });
72
+ const changes: MutationRecord[] = [];
73
+ /** What the draws since the last call changed, kept for the undo as well. */
74
+ const taken = (): MutationRecord[] => {
75
+ const records = observer.takeRecords();
76
+ changes.push(...records);
77
+ return records;
78
+ };
79
+ const describe = (node: Node): string => {
80
+ const el = node instanceof Element ? node : node.parentElement;
81
+ const tag = `<${(el?.tagName ?? "document").toLowerCase()}>`;
82
+ const other = el?.closest(".slide[data-slug]")?.getAttribute("data-slug");
83
+ return other ? `the "${other}" slide's ${tag}` : tag;
84
+ };
85
+ for (const el of document.querySelectorAll<HTMLElement>(".slide[data-slug]")) {
86
+ // markBeat writes the step key, so this page never derives one itself.
87
+ const step = el.getAttribute("data-dek-step");
88
+ if (step === null) {
89
+ continue;
90
+ }
91
+ const slug = el.getAttribute("data-slug") ?? "";
92
+ const module = modules[slug];
93
+ const index = Number(el.getAttribute("data-dek-beat") || 0);
94
+ taken();
95
+ const failed = drawAtEnd(module, el, index, step);
96
+ const t = stepMotionMs(module, step);
97
+ if (failed) {
98
+ const { error } = failed;
99
+ const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
100
+ errors.push({ slug, step, t, kind: "throw", message });
101
+ continue;
102
+ }
103
+ if (typeof module?.draw !== "function") {
104
+ continue;
105
+ }
106
+ const end = el.outerHTML;
107
+ drawFrame(module, el, { index, step, t: 0 });
108
+ drawFrame(module, el, { index, step, t });
109
+ if (el.outerHTML !== end) {
110
+ errors.push({
111
+ slug,
112
+ step,
113
+ t,
114
+ kind: "seek",
115
+ message: "draws the end of the beat differently after drawing its start",
116
+ });
117
+ }
118
+ const outside = new Set(
119
+ taken()
120
+ .filter((change) => !el.contains(change.target))
121
+ .map((change) => describe(change.target)),
122
+ );
123
+ for (const where of outside) {
124
+ errors.push({ slug, step, t, kind: "reach", message: `changes ${where} outside its slide` });
125
+ }
126
+ }
127
+ taken();
128
+ observer.disconnect();
129
+ const attributeChanges = changes.filter((change) => change.type === "attributes");
130
+ if (attributeChanges.length > 0) {
131
+ window.__dekUndoDraw = () => {
132
+ // The first old value of each attribute is what it held before any draw ran.
133
+ const before = new Map<Element, Map<string, string | null>>();
134
+ for (const change of attributeChanges) {
135
+ const el = change.target as Element;
136
+ const attrs = before.get(el) ?? new Map<string, string | null>();
137
+ if (change.attributeName !== null && !attrs.has(change.attributeName)) {
138
+ attrs.set(change.attributeName, change.oldValue);
139
+ }
140
+ before.set(el, attrs);
141
+ }
142
+ for (const [el, attrs] of before) {
143
+ for (const [name, value] of attrs) {
144
+ if (value === null) {
145
+ el.removeAttribute(name);
146
+ } else {
147
+ el.setAttribute(name, value);
148
+ }
149
+ }
150
+ }
151
+ };
152
+ }
153
+ }
154
+
155
+ /** What a still page runs: each marked slide drawn at the end of its beat, then every animation ended. */
156
+ export function stillDrawScript(): string {
157
+ return `(function () {
158
+ ${stepMotionMs}
159
+ ${drawFrame}
160
+ ${drawAtEnd}
161
+ (${drawMarkedSlides})();
162
+ (${finishBeat})();
163
+ })();
164
+ `;
165
+ }