@velumo/renderer-dom 0.1.0-beta.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 (58) hide show
  1. package/dist/background-renderer.d.ts +16 -0
  2. package/dist/background-renderer.d.ts.map +1 -0
  3. package/dist/background-renderer.js +41 -0
  4. package/dist/background-renderer.js.map +1 -0
  5. package/dist/camera-adapter.d.ts +16 -0
  6. package/dist/camera-adapter.d.ts.map +1 -0
  7. package/dist/camera-adapter.js +13 -0
  8. package/dist/camera-adapter.js.map +1 -0
  9. package/dist/default-theme.d.ts +5 -0
  10. package/dist/default-theme.d.ts.map +1 -0
  11. package/dist/default-theme.js +579 -0
  12. package/dist/default-theme.js.map +1 -0
  13. package/dist/dom-renderer.d.ts +62 -0
  14. package/dist/dom-renderer.d.ts.map +1 -0
  15. package/dist/dom-renderer.js +306 -0
  16. package/dist/dom-renderer.js.map +1 -0
  17. package/dist/html-sanitize.d.ts +18 -0
  18. package/dist/html-sanitize.d.ts.map +1 -0
  19. package/dist/html-sanitize.js +379 -0
  20. package/dist/html-sanitize.js.map +1 -0
  21. package/dist/index.d.ts +12 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +10 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/layout-renderer.d.ts +16 -0
  26. package/dist/layout-renderer.d.ts.map +1 -0
  27. package/dist/layout-renderer.js +450 -0
  28. package/dist/layout-renderer.js.map +1 -0
  29. package/dist/slide-thumbnail.d.ts +63 -0
  30. package/dist/slide-thumbnail.d.ts.map +1 -0
  31. package/dist/slide-thumbnail.js +93 -0
  32. package/dist/slide-thumbnail.js.map +1 -0
  33. package/dist/spatial-renderer.d.ts +18 -0
  34. package/dist/spatial-renderer.d.ts.map +1 -0
  35. package/dist/spatial-renderer.js +36 -0
  36. package/dist/spatial-renderer.js.map +1 -0
  37. package/dist/theme-fonts.d.ts +8 -0
  38. package/dist/theme-fonts.d.ts.map +1 -0
  39. package/dist/theme-fonts.js +68 -0
  40. package/dist/theme-fonts.js.map +1 -0
  41. package/dist/theme-tokens.d.ts +3 -0
  42. package/dist/theme-tokens.d.ts.map +1 -0
  43. package/dist/theme-tokens.js +43 -0
  44. package/dist/theme-tokens.js.map +1 -0
  45. package/dist/tsconfig.tsbuildinfo +1 -0
  46. package/package.json +20 -0
  47. package/src/background-renderer.ts +77 -0
  48. package/src/camera-adapter.ts +21 -0
  49. package/src/default-theme.ts +582 -0
  50. package/src/dom-renderer.ts +486 -0
  51. package/src/html-sanitize.ts +446 -0
  52. package/src/index.ts +25 -0
  53. package/src/layout-renderer.ts +721 -0
  54. package/src/slide-thumbnail.ts +146 -0
  55. package/src/spatial-renderer.ts +62 -0
  56. package/src/theme-fonts.ts +75 -0
  57. package/src/theme-tokens.ts +66 -0
  58. package/tsconfig.json +10 -0
@@ -0,0 +1,486 @@
1
+ import type {
2
+ JsonValue,
3
+ Layer,
4
+ Manifest,
5
+ SlideEntry,
6
+ Theme,
7
+ ThemeTokens,
8
+ } from "@velumo/core";
9
+ import {
10
+ ActionDispatcher,
11
+ dispatchControlAction,
12
+ type ActionContext,
13
+ type ActionHandler,
14
+ type LayerRenderer,
15
+ type RuntimeController,
16
+ } from "@velumo/runtime";
17
+ import { defaultTheme } from "./default-theme.js";
18
+ import { compileFontFaceCss, escapeCssString } from "./theme-fonts.js";
19
+ import { tokensToCssProperties } from "./theme-tokens.js";
20
+ import { renderBackground } from "./background-renderer.js";
21
+ import { renderLayoutNode } from "./layout-renderer.js";
22
+ import { renderSafeHtml } from "./html-sanitize.js";
23
+
24
+ interface DomDocumentLike {
25
+ createElement(tagName: string): DomElementLike;
26
+ createElementNS(namespace: string, tagName: string): DomElementLike;
27
+ createTextNode(text: string): DomNodeLike;
28
+ readonly head?: DomElementLike;
29
+ querySelector?(selectors: string): DomElementLike | null;
30
+ }
31
+
32
+ type DomNodeChild = DomElementLike | DomNodeLike;
33
+ type DomChild = DomNodeChild | string;
34
+ type DomLayerRenderResult = DomChild | readonly DomChild[] | null | undefined;
35
+
36
+ interface DomNodeLike {
37
+ parentNode?: unknown;
38
+ textContent: string;
39
+ }
40
+
41
+ interface DomElementLike extends DomNodeLike {
42
+ readonly ownerDocument: DomDocumentLike;
43
+ readonly style?: DomStyleLike;
44
+ append(...children: DomChild[]): void;
45
+ appendChild(child: DomNodeChild): DomNodeChild;
46
+ removeAttribute(name: string): void;
47
+ replaceChildren(...children: DomChild[]): void;
48
+ setAttribute(name: string, value: string): void;
49
+ }
50
+
51
+ interface DomStyleLike {
52
+ removeProperty(name: string): string;
53
+ setProperty(name: string, value: string): void;
54
+ }
55
+
56
+ export interface DomLayerRendererContext {
57
+ readonly layer: Layer;
58
+ readonly slide: SlideEntry;
59
+ readonly manifest: Manifest;
60
+ readonly runtime: RuntimeController;
61
+ createElement(tagName: string): DomElementLike;
62
+ createText(text: string): DomNodeLike;
63
+ // Imperative control (click) dispatch. Always bound; routes to a registered
64
+ // action handler via the Ward 042 ActionDispatcher with source:"control".
65
+ // A no-op when the registry has no matching handler (or no action handlers
66
+ // at all), never throws.
67
+ dispatch(action: { type: string; payload?: JsonValue }): void;
68
+ }
69
+
70
+ interface DomRuntimeRegistry {
71
+ getLayerRenderer(
72
+ type: string,
73
+ ): LayerRenderer<DomLayerRendererContext, DomLayerRenderResult> | undefined;
74
+ // Control-path handler lookup. Typed to the control context (ActionContext);
75
+ // the real generic RuntimeRegistry's ActionHandler<unknown> is assignable
76
+ // here. IMPORTANT: handlers reached this way are invoked with
77
+ // source:"control" and NO cue/cueIndex/timeMs. A handler shared with the
78
+ // timeline cue path must guard `if (context.source === "cue")` before
79
+ // touching cue-only fields (gate-3).
80
+ getActionHandler?(type: string): ActionHandler<ActionContext> | undefined;
81
+ }
82
+
83
+ export interface DomRendererOptions {
84
+ readonly root: DomElementLike;
85
+ readonly manifest: Manifest;
86
+ readonly runtime: RuntimeController;
87
+ readonly registry?: DomRuntimeRegistry;
88
+ }
89
+
90
+ export class DomRenderer {
91
+ readonly #root: DomElementLike;
92
+ readonly #manifest: Manifest;
93
+ readonly #runtime: RuntimeController;
94
+ readonly #registry: DomRuntimeRegistry | undefined;
95
+ readonly #actionDispatcher: ActionDispatcher<ActionContext> | undefined;
96
+ readonly #themeTokenProperties = new Set<string>();
97
+
98
+ constructor(options: DomRendererOptions) {
99
+ this.#root = options.root;
100
+ this.#manifest = options.manifest;
101
+ this.#runtime = options.runtime;
102
+ this.#registry = options.registry;
103
+ // One dispatcher over the active registry, reused by every layer context's
104
+ // dispatch. Absent when the registry has no action-handler seam (a minimal
105
+ // host) — control clicks then render inert.
106
+ const registry = options.registry;
107
+ this.#actionDispatcher =
108
+ registry?.getActionHandler !== undefined
109
+ ? new ActionDispatcher<ActionContext>({
110
+ registry: {
111
+ getActionHandler: (type) => registry.getActionHandler?.(type),
112
+ },
113
+ })
114
+ : undefined;
115
+ }
116
+
117
+ getRoot(): DomElementLike {
118
+ return this.#root;
119
+ }
120
+
121
+ mount(): void {
122
+ this.#root.setAttribute("data-velumo-renderer", "dom");
123
+ this.render();
124
+ }
125
+
126
+ render(): void {
127
+ this.#applyTheme();
128
+ this.#root.replaceChildren(this.#renderSlide(this.#currentSlide()));
129
+ }
130
+
131
+ unmount(): void {
132
+ this.#root.replaceChildren();
133
+ this.#root.removeAttribute("data-velumo-renderer");
134
+ this.#root.removeAttribute("data-velumo-theme");
135
+ this.#clearThemeTokenProperties();
136
+ }
137
+
138
+ dispose(): void {
139
+ this.unmount();
140
+ }
141
+
142
+ #applyTheme(): void {
143
+ const theme = this.#manifest.theme ?? defaultTheme;
144
+ const themeId = theme.id;
145
+
146
+ this.#root.setAttribute("data-velumo-theme", themeId);
147
+ this.#applyThemeTokenProperties(theme.tokens);
148
+ this.#applyThemeCss(theme);
149
+ this.#applyThemeFonts(theme);
150
+ }
151
+
152
+ // Injects the active theme's own CSS into document.head once per
153
+ // (document, theme id). The injected <style> is intentionally NOT removed on
154
+ // unmount: it is document-level and may be shared by other live renderers
155
+ // (e.g. the presenter mounts several). This is only safe because every rule a
156
+ // theme ships in `theme.css` is expected to be scoped to
157
+ // `[data-velumo-theme="<id>"]`, so a lingering style for an inactive theme
158
+ // matches nothing. A theme shipping unscoped rules here would leak across the page.
159
+ #applyThemeCss(theme: Theme): void {
160
+ const css = typeof theme.css === "string" ? theme.css : undefined;
161
+ if (css === undefined || css === "") {
162
+ return;
163
+ }
164
+ const doc = this.#root.ownerDocument;
165
+ const head = doc.head;
166
+ if (head === undefined || typeof doc.querySelector !== "function") {
167
+ return;
168
+ }
169
+ const marker = "data-velumo-theme-style";
170
+ if (
171
+ doc.querySelector(`[${marker}="${escapeCssString(theme.id)}"]`) !== null
172
+ ) {
173
+ return;
174
+ }
175
+ const style = doc.createElement("style");
176
+ style.setAttribute(marker, theme.id);
177
+ style.textContent = css;
178
+ head.appendChild(style);
179
+ }
180
+
181
+ // Injects the active theme's declared font faces into document.head as a
182
+ // single `<style>`, deduped per (document, theme id), no-op when there are no
183
+ // fonts or the host lacks head/querySelector. The injected text is exactly
184
+ // `compileFontFaceCss(theme.fonts)` — there is no second, hand-rolled code
185
+ // path. Like the theme-css style it is NOT removed on unmount: it is
186
+ // document-level and may be shared by other live renderers (the presenter
187
+ // mounts several of the same theme).
188
+ //
189
+ // The safety argument differs from `#applyThemeCss`. Theme CSS is selector-
190
+ // scoped to `[data-velumo-theme="<id>"]`, so a lingering style for an
191
+ // inactive theme matches nothing. `@font-face` is NOT selector-scoped — it
192
+ // registers a font family document-globally. Deduping per theme id keeps each
193
+ // theme's faces under their own marker so they coexist without clobbering, and
194
+ // a lingering face for an inactive theme is mostly inert (nothing references
195
+ // the family; a data-URI triggers no network fetch). The one residual edge:
196
+ // two themes declaring the SAME `family` with different bytes in one document
197
+ // resolve by cascade/head order. Not reachable today (a document carries one
198
+ // theme id); themes should namespace family names.
199
+ #applyThemeFonts(theme: Theme): void {
200
+ const fonts = theme.fonts;
201
+ if (fonts === undefined || fonts.length === 0) {
202
+ return;
203
+ }
204
+ const doc = this.#root.ownerDocument;
205
+ const head = doc.head;
206
+ if (head === undefined || typeof doc.querySelector !== "function") {
207
+ return;
208
+ }
209
+ const marker = "data-velumo-theme-fonts";
210
+ if (
211
+ doc.querySelector(`[${marker}="${escapeCssString(theme.id)}"]`) !== null
212
+ ) {
213
+ return;
214
+ }
215
+ const style = doc.createElement("style");
216
+ style.setAttribute(marker, theme.id);
217
+ style.textContent = compileFontFaceCss(fonts);
218
+ head.appendChild(style);
219
+ }
220
+
221
+ #currentSlide(): SlideEntry {
222
+ const state = this.#runtime.getState();
223
+ return (
224
+ this.#manifest.slides.find((slide) => slide.id === state.slideId) ??
225
+ this.#manifest.slides[0]
226
+ );
227
+ }
228
+
229
+ #renderSlide(slide: SlideEntry): DomElementLike {
230
+ const slideElement = this.#createElement("section");
231
+ slideElement.setAttribute("data-velumo-slide-id", slide.id);
232
+ slideElement.setAttribute("data-velumo-slide-kind", slide.kind);
233
+ slideElement.setAttribute(
234
+ "aria-label",
235
+ `${this.#manifest.deck.title} ${slide.id}`,
236
+ );
237
+
238
+ if (slide.transition !== undefined) {
239
+ slideElement.setAttribute("data-velumo-transition", slide.transition);
240
+ }
241
+
242
+ const watermark = this.#manifest.deck.watermark;
243
+ const hasWatermark =
244
+ typeof watermark === "string" &&
245
+ watermark.trim().length > 0 &&
246
+ slide.chrome !== false;
247
+
248
+ const needsPositioning =
249
+ slide.background !== undefined ||
250
+ slide.layout?.kind === "canvas" ||
251
+ hasWatermark;
252
+ if (needsPositioning) {
253
+ this.#setStyleProperty(slideElement, "position", "relative");
254
+ }
255
+
256
+ if (slide.background !== undefined) {
257
+ this.#setStyleProperty(slideElement, "z-index", "0");
258
+ slideElement.appendChild(
259
+ renderBackground(slide.background, {
260
+ createElement: (tag) => this.#createElement(tag),
261
+ setStyleProperty: (el, prop, val) =>
262
+ this.#setStyleProperty(el, prop, val),
263
+ }),
264
+ );
265
+ }
266
+
267
+ if (slide.layout !== undefined) {
268
+ const layoutRoot = this.#createElement("div");
269
+ layoutRoot.setAttribute("data-velumo-layout-root", "");
270
+ layoutRoot.appendChild(
271
+ renderLayoutNode(slide.layout, {
272
+ createElement: (tag) => this.#createElement(tag),
273
+ createElementNS: (ns, tag) => this.#createElementNS(ns, tag),
274
+ setStyleProperty: (el, prop, val) =>
275
+ this.#setStyleProperty(el, prop, val),
276
+ }),
277
+ );
278
+ slideElement.appendChild(layoutRoot);
279
+ }
280
+
281
+ for (const layer of slide.layers ?? []) {
282
+ slideElement.appendChild(this.#renderLayer(slide, layer));
283
+ }
284
+
285
+ if (hasWatermark) {
286
+ const mark = this.#createElement("div");
287
+ mark.setAttribute("data-velumo-watermark", "");
288
+ mark.setAttribute("aria-hidden", "true");
289
+ mark.textContent = watermark;
290
+ slideElement.appendChild(mark);
291
+ }
292
+
293
+ return slideElement;
294
+ }
295
+
296
+ #renderLayer(slide: SlideEntry, layer: Layer): DomElementLike {
297
+ const layerElement = this.#createElement("div");
298
+ layerElement.setAttribute("data-velumo-layer-type", layer.type);
299
+
300
+ if (layer.id !== undefined) {
301
+ layerElement.setAttribute("data-velumo-layer-id", layer.id);
302
+ }
303
+
304
+ this.#applyFragmentState(slide, layer, layerElement);
305
+
306
+ const customRenderer = this.#registry?.getLayerRenderer(layer.type);
307
+ if (customRenderer !== undefined) {
308
+ this.#appendCustomLayerResult(
309
+ layerElement,
310
+ customRenderer(this.#createLayerContext(slide, layer)),
311
+ );
312
+ return layerElement;
313
+ }
314
+
315
+ switch (layer.type) {
316
+ case "markdown":
317
+ layerElement.append(
318
+ ...this.#renderMarkdown(String(layer.content ?? "")),
319
+ );
320
+ return layerElement;
321
+ case "html":
322
+ layerElement.append(
323
+ ...renderSafeHtml(String(layer.content ?? ""), {
324
+ createElement: (tag) => this.#createElement(tag),
325
+ createText: (text) => this.#createText(text),
326
+ textElement: (tag, text) => this.#textElement(tag, text),
327
+ }),
328
+ );
329
+ return layerElement;
330
+ default:
331
+ layerElement.textContent = layer.type;
332
+ return layerElement;
333
+ }
334
+ }
335
+
336
+ #createLayerContext(
337
+ slide: SlideEntry,
338
+ layer: Layer,
339
+ ): DomLayerRendererContext {
340
+ const dispatcher = this.#actionDispatcher;
341
+ const runtime = this.#runtime;
342
+ return {
343
+ layer,
344
+ slide,
345
+ manifest: this.#manifest,
346
+ runtime,
347
+ createElement: (tagName) => this.#createElement(tagName),
348
+ createText: (text) => this.#createText(text),
349
+ dispatch: (action) => {
350
+ if (dispatcher === undefined) {
351
+ return; // inert on a minimal / no-action-handler host
352
+ }
353
+ dispatchControlAction(
354
+ dispatcher,
355
+ {
356
+ type: action.type,
357
+ ...(action.payload === undefined
358
+ ? {}
359
+ : { payload: action.payload }),
360
+ },
361
+ runtime,
362
+ );
363
+ },
364
+ };
365
+ }
366
+
367
+ #appendCustomLayerResult(
368
+ layerElement: DomElementLike,
369
+ result: DomLayerRenderResult,
370
+ ): void {
371
+ if (result === null || result === undefined) {
372
+ return;
373
+ }
374
+
375
+ const children = Array.isArray(result) ? result : [result];
376
+ layerElement.append(...children);
377
+ }
378
+
379
+ #applyFragmentState(
380
+ slide: SlideEntry,
381
+ layer: Layer,
382
+ layerElement: DomElementLike,
383
+ ): void {
384
+ const fragmentIndex = this.#fragmentIndexForLayer(slide, layer);
385
+
386
+ if (fragmentIndex === undefined) {
387
+ return;
388
+ }
389
+
390
+ const visible = fragmentIndex + 1 <= this.#runtime.getState().fragmentIndex;
391
+ layerElement.setAttribute(
392
+ "data-velumo-fragment",
393
+ visible ? "visible" : "hidden",
394
+ );
395
+ }
396
+
397
+ #fragmentIndexForLayer(slide: SlideEntry, layer: Layer): number | undefined {
398
+ if (layer.id === undefined) {
399
+ return undefined;
400
+ }
401
+
402
+ const fragmentIndex = slide.fragments?.findIndex(
403
+ (fragment) => fragment.layerId === layer.id,
404
+ );
405
+
406
+ return fragmentIndex === undefined || fragmentIndex === -1
407
+ ? undefined
408
+ : fragmentIndex;
409
+ }
410
+
411
+ #renderMarkdown(source: string): DomElementLike[] {
412
+ const elements: DomElementLike[] = [];
413
+
414
+ for (const block of source.split(/\n{2,}/)) {
415
+ const lines = block.split(/\n/).filter((line) => line.length > 0);
416
+
417
+ for (const line of lines) {
418
+ if (line.startsWith("## ")) {
419
+ elements.push(this.#textElement("h2", line.slice(3)));
420
+ continue;
421
+ }
422
+
423
+ if (line.startsWith("# ")) {
424
+ elements.push(this.#textElement("h1", line.slice(2)));
425
+ continue;
426
+ }
427
+
428
+ elements.push(this.#textElement("p", line));
429
+ }
430
+ }
431
+
432
+ return elements;
433
+ }
434
+
435
+ #textElement(tagName: string, text: string): DomElementLike {
436
+ const element = this.#createElement(tagName);
437
+ element.textContent = text;
438
+ return element;
439
+ }
440
+
441
+ #createElement(tagName: string): DomElementLike {
442
+ return this.#root.ownerDocument.createElement(tagName);
443
+ }
444
+
445
+ #createElementNS(namespace: string, tagName: string): DomElementLike {
446
+ return this.#root.ownerDocument.createElementNS(namespace, tagName);
447
+ }
448
+
449
+ #createText(text: string): DomNodeLike {
450
+ return this.#root.ownerDocument.createTextNode(text);
451
+ }
452
+
453
+ #applyThemeTokenProperties(tokens: ThemeTokens | undefined): void {
454
+ const nextProperties = tokensToCssProperties(tokens);
455
+
456
+ for (const property of this.#themeTokenProperties) {
457
+ if (!nextProperties.has(property)) {
458
+ this.#root.style?.removeProperty(property);
459
+ this.#themeTokenProperties.delete(property);
460
+ }
461
+ }
462
+
463
+ for (const [property, value] of nextProperties) {
464
+ this.#setStyleProperty(this.#root, property, value);
465
+ this.#themeTokenProperties.add(property);
466
+ }
467
+ }
468
+
469
+ #clearThemeTokenProperties(): void {
470
+ for (const property of this.#themeTokenProperties) {
471
+ this.#root.style?.removeProperty(property);
472
+ }
473
+
474
+ this.#themeTokenProperties.clear();
475
+ }
476
+
477
+ #setStyleProperty(
478
+ element: {
479
+ readonly style?: { setProperty(name: string, value: string): void };
480
+ },
481
+ property: string,
482
+ value: string,
483
+ ): void {
484
+ element.style?.setProperty(property, value);
485
+ }
486
+ }