@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,58 @@
1
+ /**
2
+ * Evaluates compiled slide scripts for lint, away from the dek process.
3
+ * Reads a JSON array of classic scripts on stdin and writes one JSON line per
4
+ * script as it finishes. Each script runs in a fresh context with no host
5
+ * objects, so it cannot reach Bun, process, or the file system, and a loop
6
+ * that never ends is cut off; the parent's timeout covers anything that
7
+ * escapes into the microtask queue.
8
+ */
9
+ import { readFileSync, writeSync } from "node:fs";
10
+ import vm from "node:vm";
11
+
12
+ const SLIDE_EVAL_TIMEOUT_MS = 1000;
13
+
14
+ /** Runs inside the context, so reading the module's fields cannot run code in dek. */
15
+ const SUMMARY = `(function () {
16
+ var module = globalThis.__dekSlides && globalThis.__dekSlides.slide;
17
+ if (module === null || typeof module !== "object") return JSON.stringify({ object: false });
18
+ var motion = module.motion;
19
+ return JSON.stringify({
20
+ object: true,
21
+ draw: typeof module.draw,
22
+ motion: motion === undefined ? "none"
23
+ : motion === null || typeof motion !== "object" ? "invalid"
24
+ : Object.keys(motion).map(function (key) {
25
+ var ms = motion[key];
26
+ return [key, typeof ms === "number" && isFinite(ms) ? ms : null];
27
+ }),
28
+ });
29
+ })()`;
30
+
31
+ function evaluate(code: string): string {
32
+ const context = vm.createContext(Object.create(null));
33
+ try {
34
+ return vm.runInContext(
35
+ `var window = globalThis;
36
+ try {
37
+ new Function(${JSON.stringify(code)})();
38
+ } catch (error) {
39
+ throw String(error && error.message !== undefined ? error.message : error);
40
+ }
41
+ ${SUMMARY}`,
42
+ context,
43
+ { timeout: SLIDE_EVAL_TIMEOUT_MS },
44
+ ) as string;
45
+ } catch (error) {
46
+ if (typeof error === "string") {
47
+ return JSON.stringify({ threw: error });
48
+ }
49
+ return JSON.stringify({ timedOut: true });
50
+ }
51
+ }
52
+
53
+ const scripts = JSON.parse(readFileSync(0, "utf8")) as string[];
54
+ for (const code of scripts) {
55
+ writeSync(1, `${evaluate(code)}\n`);
56
+ }
57
+ // Exit before the event loop can drain promises the scripts queued.
58
+ process.exit(0);
@@ -0,0 +1,272 @@
1
+ // This runs in the page. TypeScript 6 and later fold dom.iterable into dom; naming it keeps the
2
+ // spreads of NodeLists and DOMTokenLists typed for any compiler that reads the file.
3
+ /// <reference lib="dom" />
4
+ /// <reference lib="dom.iterable" />
5
+ /**
6
+ * The id of a still page's `<style>` holding the slide's own CSS, apart from the theme, so a
7
+ * measurement can take it away and see what the theme alone draws.
8
+ */
9
+ export const SLIDE_CSS_ID = "dek-slide-css";
10
+
11
+ /** A rectangle in viewport pixels. */
12
+ export type Box = {
13
+ left: number;
14
+ top: number;
15
+ right: number;
16
+ bottom: number;
17
+ };
18
+
19
+ /** One element inside `.slide`, as the page lays it out. */
20
+ export type MeasuredElement = {
21
+ /** A short, readable selector: tag, id, first authored class, data-step. */
22
+ box: string;
23
+ /** Index of the nearest measured ancestor, or -1 when that is the slide itself. */
24
+ parent: number;
25
+ rect: Box;
26
+ /** The element's own text, or its whole text when it has none of its own. */
27
+ text?: string;
28
+ /** Whether `text` comes from the element's own text nodes. */
29
+ ownText: boolean;
30
+ /** The line boxes of the element's own text, where its glyphs are drawn. */
31
+ textRects: Box[];
32
+ /** Its opacity with every ancestor's multiplied in: how faint the page draws it. */
33
+ opacity: number;
34
+ fontSize: number;
35
+ fontWeight: number;
36
+ /**
37
+ * Whether it is decoration: it or an ancestor is `aria-hidden="true"`, as a glow or a sample of
38
+ * bad contrast is. Neither overflow nor contrast is measured on decoration.
39
+ */
40
+ decorative: boolean;
41
+ /**
42
+ * Whether it draws a picture the audience looks at: an image, an SVG, a video, a canvas, an
43
+ * embedded frame or object, or an image set as its background.
44
+ */
45
+ picture: boolean;
46
+ /** Whether it paints its own box: a background, or a border in a color that shows. */
47
+ paints: boolean;
48
+ /**
49
+ * What an ancestor inside the slide cuts it to, with `overflow` other than `visible`: the
50
+ * nearest such ancestor, and the box every one of them leaves visible. `intended` when the cut is
51
+ * a truncation the author asked for, an ellipsis or a line clamp.
52
+ */
53
+ clip?: { box: string; rect: Box; intended: boolean };
54
+ };
55
+
56
+ /**
57
+ * Text a `::before` or `::after` draws, such as a folio from `counter()` or a running head. The page
58
+ * gives no box for it; the worker asks Chromium for one, by the mark this leaves on its host.
59
+ */
60
+ type PseudoText = {
61
+ /** The host's mark, `data-dek-text` on the element, shared by both of its pseudo-elements. */
62
+ host: string;
63
+ pseudo: "before" | "after";
64
+ /** The host's short selector with the pseudo-element, as `section.slide::after`. */
65
+ box: string;
66
+ /** The `content` it draws, as the page computes it, its strings unquoted. */
67
+ text: string;
68
+ opacity: number;
69
+ fontSize: number;
70
+ fontWeight: number;
71
+ };
72
+
73
+ /** The attribute a pseudo text's host carries while it is measured; see `PseudoText`. */
74
+ export const PSEUDO_TEXT_HOST = "data-dek-text";
75
+
76
+ export type SlideMeasure = {
77
+ slideBox: Box | undefined;
78
+ elements: MeasuredElement[];
79
+ pseudoTexts: PseudoText[];
80
+ };
81
+
82
+ /**
83
+ * Measures the first `.slide` in the current document, and marks each element that draws a pseudo
84
+ * text with `data-dek-text` (and `data-dek-text-before` or `-after`) for the worker to find. It runs
85
+ * inside the page through `page.evaluate`, which serializes only this function, so it must not
86
+ * reference anything outside its own body.
87
+ */
88
+ export function measureSlideInPage(): SlideMeasure {
89
+ const slide = document.querySelector(".slide");
90
+ if (!slide) {
91
+ return { slideBox: undefined, elements: [], pseudoTexts: [] };
92
+ }
93
+ const runtimeClasses = new Set(["is-current", "is-shown"]);
94
+ const pictures = new Set(["img", "svg", "video", "canvas", "iframe", "object", "embed"]);
95
+ // Computed colors come back as rgba() or, from a newer syntax, with a "/ alpha" at the end.
96
+ const shows = (color: string): boolean =>
97
+ color !== "transparent" && !/^rgba\([^)]*,\s*0\)$/.test(color) && !/\/\s*0\)$/.test(color);
98
+ const paints = (style: CSSStyleDeclaration): boolean =>
99
+ style.backgroundImage !== "none" ||
100
+ shows(style.backgroundColor) ||
101
+ (["Top", "Right", "Bottom", "Left"] as const).some(
102
+ (side) =>
103
+ Number.parseFloat(style[`border${side}Width`]) > 0 &&
104
+ style[`border${side}Style`] !== "none" &&
105
+ shows(style[`border${side}Color`]),
106
+ );
107
+ const toBox = (rect: DOMRect): Box => ({
108
+ left: rect.left,
109
+ top: rect.top,
110
+ right: rect.right,
111
+ bottom: rect.bottom,
112
+ });
113
+ const squash = (text: string): string => text.replace(/\s+/g, " ").trim();
114
+ // An unbreakable string (a URL, a code line) runs sideways past its box
115
+ // without growing it, so the element's own text widens the box. Only
116
+ // sideways: glyphs rise above a tight line box without overflowing anything.
117
+ const extent = (el: Element, textNodes: Node[]): Box => {
118
+ const box = toBox(el.getBoundingClientRect());
119
+ // An element that clips its own content shows none of its text past its box.
120
+ if (getComputedStyle(el).overflowX !== "visible") {
121
+ return box;
122
+ }
123
+ for (const node of textNodes) {
124
+ const range = document.createRange();
125
+ range.selectNodeContents(node);
126
+ const rect = range.getBoundingClientRect();
127
+ if (rect.width === 0 && rect.height === 0) {
128
+ continue;
129
+ }
130
+ box.left = Math.min(box.left, rect.left);
131
+ box.right = Math.max(box.right, rect.right);
132
+ }
133
+ return box;
134
+ };
135
+ const describe = (el: Element): string => {
136
+ let box = el.tagName.toLowerCase();
137
+ if (el.id) {
138
+ box += `#${el.id}`;
139
+ }
140
+ const authored = [...el.classList].find((name) => !runtimeClasses.has(name));
141
+ if (authored) {
142
+ box += `.${authored}`;
143
+ }
144
+ const step = el.getAttribute("data-step");
145
+ if (step) {
146
+ box += `[data-step="${step}"]`;
147
+ }
148
+ return box;
149
+ };
150
+ const opacityOf = (el: Element): number => {
151
+ // Hidden text is not drawn at all, however opaque.
152
+ if (getComputedStyle(el).visibility !== "visible") {
153
+ return 0;
154
+ }
155
+ let opacity = 1;
156
+ for (let current: Element | null = el; current; current = current.parentElement) {
157
+ const own = Number.parseFloat(getComputedStyle(current).opacity);
158
+ opacity *= Number.isNaN(own) ? 1 : own;
159
+ }
160
+ return opacity;
161
+ };
162
+ const clipped = (clip: MeasuredElement["clip"]) => (clip ? { clip } : {});
163
+ const truncates = (style: CSSStyleDeclaration): boolean =>
164
+ style.textOverflow === "ellipsis" ||
165
+ (style.getPropertyValue("-webkit-line-clamp") || "none") !== "none";
166
+ const clipOf = (el: Element): MeasuredElement["clip"] => {
167
+ let found: MeasuredElement["clip"];
168
+ for (let at = el.parentElement; at && at !== slide; at = at.parentElement) {
169
+ const style = getComputedStyle(at);
170
+ if (style.overflowX === "visible" && style.overflowY === "visible") {
171
+ continue;
172
+ }
173
+ const rect = toBox(at.getBoundingClientRect());
174
+ found = found
175
+ ? {
176
+ ...found,
177
+ rect: {
178
+ left: Math.max(found.rect.left, rect.left),
179
+ top: Math.max(found.rect.top, rect.top),
180
+ right: Math.min(found.rect.right, rect.right),
181
+ bottom: Math.min(found.rect.bottom, rect.bottom),
182
+ },
183
+ intended: found.intended || truncates(style),
184
+ }
185
+ : { box: describe(at), rect, intended: truncates(style) };
186
+ }
187
+ return found && { ...found, intended: found.intended || truncates(getComputedStyle(el)) };
188
+ };
189
+ const lineBoxes = (textNodes: Node[]): Box[] =>
190
+ textNodes.flatMap((node) => {
191
+ const range = document.createRange();
192
+ range.selectNodeContents(node);
193
+ return [...range.getClientRects()]
194
+ .filter((rect) => rect.width > 0 && rect.height > 0)
195
+ .map(toBox);
196
+ });
197
+
198
+ const all = [...slide.querySelectorAll("*")];
199
+ const indexOf = new Map(all.map((el, index) => [el, index]));
200
+ const elements = all.map((el): MeasuredElement => {
201
+ const style = getComputedStyle(el);
202
+ const textNodes = [...el.childNodes].filter(
203
+ (node) => node.nodeType === Node.TEXT_NODE && squash(node.textContent ?? "") !== "",
204
+ );
205
+ const own = squash(textNodes.map((node) => node.textContent ?? "").join(" "));
206
+ // innerText separates block children; textContent would run "item 0item 1" together.
207
+ // SVG and MathML have no innerText, so they fall back to textContent.
208
+ const whole = el instanceof HTMLElement ? el.innerText : el.textContent;
209
+ const text = own || squash(whole ?? "");
210
+ const parentEl = el.parentElement;
211
+ return {
212
+ box: describe(el),
213
+ parent: parentEl ? (indexOf.get(parentEl) ?? -1) : -1,
214
+ rect: extent(el, textNodes),
215
+ ...(text ? { text } : {}),
216
+ ownText: own !== "",
217
+ textRects: lineBoxes(textNodes),
218
+ opacity: opacityOf(el),
219
+ fontSize: Number.parseFloat(style.fontSize),
220
+ fontWeight: Number(style.fontWeight) || 400,
221
+ decorative: el.closest('[aria-hidden="true"]') !== null,
222
+ picture: pictures.has(el.tagName.toLowerCase()) || style.backgroundImage.includes("url("),
223
+ paints: paints(style),
224
+ ...clipped(clipOf(el)),
225
+ };
226
+ });
227
+ // A pseudo-element draws text when its content has a letter or a digit, or comes from a
228
+ // counter or an attribute. A quote mark or an arrow on its own is ornament, and stays with
229
+ // the background as a glow or a rule does.
230
+ const readable = (content: string): boolean =>
231
+ /\b(?:counters?|attr)\(/.test(content) ||
232
+ [...content.matchAll(/"((?:[^"\\]|\\.)*)"/g)].some(([, text]) =>
233
+ /[\p{L}\p{N}]/u.test(text ?? ""),
234
+ );
235
+ const pseudoTexts: PseudoText[] = [];
236
+ for (const [index, el] of [slide, ...all].entries()) {
237
+ if (el.closest('[aria-hidden="true"]') !== null) {
238
+ continue;
239
+ }
240
+ for (const pseudo of ["before", "after"] as const) {
241
+ const style = getComputedStyle(el, `::${pseudo}`);
242
+ const { content } = style;
243
+ if (content === "none" || content === "normal" || !readable(content)) {
244
+ continue;
245
+ }
246
+ const host = String(index);
247
+ el.setAttribute("data-dek-text", host);
248
+ el.setAttribute(`data-dek-text-${pseudo}`, "");
249
+ const own = Number.parseFloat(style.opacity);
250
+ pseudoTexts.push({
251
+ host,
252
+ pseudo,
253
+ box: `${describe(el)}::${pseudo}`,
254
+ // As the audience reads it: the strings without their quotes, a counter as written.
255
+ text: squash(content.replace(/"((?:[^"\\]|\\.)*)"/g, "$1")),
256
+ opacity: opacityOf(el) * (Number.isNaN(own) ? 1 : own),
257
+ fontSize: Number.parseFloat(style.fontSize),
258
+ fontWeight: Number(style.fontWeight) || 400,
259
+ });
260
+ }
261
+ }
262
+ return { slideBox: toBox(slide.getBoundingClientRect()), elements, pseudoTexts };
263
+ }
264
+
265
+ /** Runs in the page: takes away the marks `measureSlideInPage` left for pseudo texts. */
266
+ export function unmarkPseudoTextsInPage(): void {
267
+ for (const el of document.querySelectorAll("[data-dek-text]")) {
268
+ for (const name of ["data-dek-text", "data-dek-text-before", "data-dek-text-after"]) {
269
+ el.removeAttribute(name);
270
+ }
271
+ }
272
+ }
@@ -0,0 +1,258 @@
1
+ import { escapeRegExp } from "./escape.ts";
2
+ import { moduleFilePath } from "./path.ts";
3
+ import {
4
+ compileSlideScript,
5
+ IMPORTS_MESSAGE,
6
+ lineMatching,
7
+ type SlideScriptProblem,
8
+ staticProblems,
9
+ } from "./slide-script.ts";
10
+ import { suggest } from "./suggest.ts";
11
+
12
+ /** One slide script as lint checks it: its source, and the slide's step keys to check `motion` by. */
13
+ export type ScriptInput = { code: string; steps?: string[] };
14
+
15
+ /** How long lint waits for every slide script's top-level code, in total. */
16
+ const EVAL_BUDGET_MS = 5000;
17
+
18
+ /**
19
+ * What stops each `slides/<slug>.ts` from running as a slide script. A script is one
20
+ * self-contained module whose only export is the default object. Given the slide's step keys,
21
+ * the module is also evaluated so `motion` can be checked against the beats it names.
22
+ * Evaluation happens in one child process that cannot reach dek's globals and is killed if it
23
+ * hangs; this waits for it. Scripts evaluated before are answered from a cache.
24
+ */
25
+ export function slideScriptsProblems(scripts: ScriptInput[]): SlideScriptProblem[][] {
26
+ const { results, pending } = prepareScripts(scripts);
27
+ const codes = uncached(pending);
28
+ if (codes.length > 0) {
29
+ const stdout = Bun.spawnSync(evaluatorCommand(), {
30
+ ...EVALUATOR_OPTIONS,
31
+ stdin: Buffer.from(JSON.stringify(codes)),
32
+ timeout: EVAL_BUDGET_MS,
33
+ }).stdout.toString();
34
+ cacheSummaries(codes, parseSummaries(codes.length, stdout));
35
+ }
36
+ return finishProblems(results, pending);
37
+ }
38
+
39
+ /**
40
+ * `slideScriptsProblems` without blocking the event loop while the scripts run, for the dev
41
+ * server. The answer is about the code passed in, however the files change in the meantime.
42
+ */
43
+ export async function evaluateSlideScripts(
44
+ scripts: ScriptInput[],
45
+ ): Promise<SlideScriptProblem[][]> {
46
+ const { results, pending } = prepareScripts(scripts);
47
+ const codes = uncached(pending);
48
+ if (codes.length > 0) {
49
+ const child = Bun.spawn(evaluatorCommand(), {
50
+ ...EVALUATOR_OPTIONS,
51
+ stdin: Buffer.from(JSON.stringify(codes)),
52
+ });
53
+ const timer = setTimeout(() => child.kill(), EVAL_BUDGET_MS);
54
+ try {
55
+ const stdout = await new Response(child.stdout).text();
56
+ await child.exited;
57
+ cacheSummaries(codes, parseSummaries(codes.length, stdout));
58
+ } finally {
59
+ clearTimeout(timer);
60
+ }
61
+ }
62
+ return finishProblems(results, pending);
63
+ }
64
+
65
+ /**
66
+ * A pending evaluation. `withoutImports` marks a script evaluated with its
67
+ * imports removed, so lint can still check `motion`; what then fails at run
68
+ * time is the missing import's doing, already reported.
69
+ */
70
+ type PendingScript = {
71
+ index: number;
72
+ /** As written, where a problem the evaluation finds is looked for. */
73
+ source: string;
74
+ code: string;
75
+ steps: string[];
76
+ withoutImports: boolean;
77
+ };
78
+
79
+ const IMPORT_RE = /^[ \t]*import\s+(?:type\s+)?(?:[\s\S]*?\sfrom\s*)?["'][^"'\n]+["'][ \t]*;?/gm;
80
+
81
+ /** The code with its import declarations blanked out, lines kept in place. */
82
+ function withoutImports(code: string): string {
83
+ return code.replace(IMPORT_RE, (match) => match.replace(/[^\n]/g, " "));
84
+ }
85
+
86
+ function prepareScripts(scripts: ScriptInput[]): {
87
+ results: SlideScriptProblem[][];
88
+ pending: PendingScript[];
89
+ } {
90
+ const results: SlideScriptProblem[][] = [];
91
+ const pending: PendingScript[] = [];
92
+ for (const [index, script] of scripts.entries()) {
93
+ const problems = staticProblems(script.code);
94
+ // Imports alone do not stop the rest of the checks: report everything in one run.
95
+ const importsOnly = problems.length > 0 && problems.every((p) => p.message === IMPORTS_MESSAGE);
96
+ const source = importsOnly ? withoutImports(script.code) : script.code;
97
+ const compiled =
98
+ problems.length > 0 && !importsOnly ? undefined : compileSlideScript(source, "slide");
99
+ if (compiled && "problem" in compiled) {
100
+ problems.push(compiled.problem);
101
+ }
102
+ results.push(problems);
103
+ if (compiled && "code" in compiled && script.steps) {
104
+ pending.push({
105
+ index,
106
+ source: script.code,
107
+ code: compiled.code,
108
+ steps: script.steps,
109
+ withoutImports: importsOnly,
110
+ });
111
+ }
112
+ }
113
+ return { results, pending };
114
+ }
115
+
116
+ /** The compiled code of each pending script the cache has no summary for, once each. */
117
+ function uncached(pending: PendingScript[]): string[] {
118
+ return [...new Set(pending.map((entry) => entry.code))].filter((code) => !summaryCache.has(code));
119
+ }
120
+
121
+ function finishProblems(
122
+ results: SlideScriptProblem[][],
123
+ pending: PendingScript[],
124
+ ): SlideScriptProblem[][] {
125
+ for (const entry of pending) {
126
+ const summary = summaryCache.get(entry.code);
127
+ const blamedOnImports =
128
+ entry.withoutImports &&
129
+ summary !== undefined &&
130
+ ("threw" in summary || "timedOut" in summary);
131
+ const found = blamedOnImports ? [] : moduleProblems(summary, entry);
132
+ results[entry.index] = [...(results[entry.index] ?? []), ...found];
133
+ }
134
+ return results;
135
+ }
136
+
137
+ /** What the evaluator reports for one module; see slide-eval-worker.ts. */
138
+ type ModuleSummary =
139
+ | { threw: string }
140
+ | { timedOut: true }
141
+ | { object: false }
142
+ | { object: true; draw: string; motion: "none" | "invalid" | Array<[string, number | null]> };
143
+
144
+ /** Summaries by compiled code; the evaluation depends on nothing else. */
145
+ const summaryCache = new Map<string, ModuleSummary>();
146
+ const SUMMARY_CACHE_LIMIT = 256;
147
+
148
+ const EVALUATOR_OPTIONS = { stdout: "pipe", stderr: "ignore", env: {} } as const;
149
+
150
+ function evaluatorCommand(): string[] {
151
+ const worker = moduleFilePath(new URL("./slide-eval-worker.ts", import.meta.url));
152
+ return [process.execPath, "--no-install", worker];
153
+ }
154
+
155
+ /** A worker that was killed still wrote a line for every script it finished. */
156
+ function parseSummaries(count: number, stdout: string): Array<ModuleSummary | undefined> {
157
+ const lines = stdout.split("\n").filter(Boolean);
158
+ return Array.from({ length: count }, (_, index) => {
159
+ const line = lines[index];
160
+ return line ? (JSON.parse(line) as ModuleSummary) : undefined;
161
+ });
162
+ }
163
+
164
+ /** A script left unfinished because the budget ran out is not cached; it is tried again. */
165
+ function cacheSummaries(codes: string[], summaries: Array<ModuleSummary | undefined>): void {
166
+ for (const [index, code] of codes.entries()) {
167
+ const summary = summaries[index];
168
+ if (!summary) {
169
+ continue;
170
+ }
171
+ summaryCache.delete(code);
172
+ summaryCache.set(code, summary);
173
+ if (summaryCache.size > SUMMARY_CACHE_LIMIT) {
174
+ const oldest = summaryCache.keys().next().value;
175
+ if (oldest !== undefined) {
176
+ summaryCache.delete(oldest);
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ const TOP_LEVEL_HINT = "move what touches the page into draw(slide, { t }), which runs per frame";
183
+
184
+ function moduleProblems(
185
+ summary: ModuleSummary | undefined,
186
+ { source, steps }: Pick<PendingScript, "source" | "steps">,
187
+ ): SlideScriptProblem[] {
188
+ if (!summary || "timedOut" in summary) {
189
+ return [
190
+ {
191
+ message: "top-level code did not finish; touch the slide only inside draw",
192
+ hint: TOP_LEVEL_HINT,
193
+ },
194
+ ];
195
+ }
196
+ if ("threw" in summary) {
197
+ return [
198
+ {
199
+ message: `top-level code threw: ${summary.threw}; touch the slide only inside draw`,
200
+ hint: TOP_LEVEL_HINT,
201
+ },
202
+ ];
203
+ }
204
+ const exported = lineMatching(source, /\bexport\s+default\b/);
205
+ const at = (line: number | undefined) => (line === undefined ? {} : { line });
206
+ if (!summary.object) {
207
+ return [
208
+ {
209
+ message: "export default must be an object like { motion, draw }",
210
+ ...at(exported),
211
+ hint: "export default { motion: { <step>: ms }, draw(slide, { t }) {} } satisfies DekSlide",
212
+ },
213
+ ];
214
+ }
215
+ const problems: SlideScriptProblem[] = [];
216
+ if (summary.draw !== "undefined" && summary.draw !== "function") {
217
+ problems.push({
218
+ message: "draw must be a function",
219
+ ...at(lineMatching(source, /\bdraw\b/) ?? exported),
220
+ hint: "write it as draw(slide, { index, step, t }) { … }",
221
+ });
222
+ }
223
+ if (summary.motion === "none") {
224
+ return problems;
225
+ }
226
+ if (summary.motion === "invalid") {
227
+ return [
228
+ ...problems,
229
+ {
230
+ message: "motion must be an object keyed by beat",
231
+ ...at(lineMatching(source, /\bmotion\b/) ?? exported),
232
+ hint: `write it as motion: { ${steps[0] ?? "1"}: 600 }, one key per beat that moves`,
233
+ },
234
+ ];
235
+ }
236
+ for (const [key, ms] of summary.motion) {
237
+ // The key as the object literal writes it: bare, or quoted either way.
238
+ const keyLine = lineMatching(
239
+ source,
240
+ new RegExp(`(?:^|[\\s{,])(["']?)${escapeRegExp(key)}\\1\\s*:`),
241
+ );
242
+ if (!steps.includes(key)) {
243
+ const guess = suggest(key, steps);
244
+ problems.push({
245
+ message: `motion key "${key}" is not a beat of this slide`,
246
+ ...at(keyLine),
247
+ hint: `${guess ? `did you mean "${guess}"? ` : ""}use one of: ${steps.join(", ")}`,
248
+ });
249
+ } else if (ms === null || ms < 0) {
250
+ problems.push({
251
+ message: `motion "${key}" must be a non-negative number of milliseconds`,
252
+ ...at(keyLine),
253
+ hint: `give it how long the beat moves, like ${key}: 600`,
254
+ });
255
+ }
256
+ }
257
+ return problems;
258
+ }