@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,446 @@
1
+ export interface HtmlSanitizeElement {
2
+ setAttribute(name: string, value: string): void;
3
+ textContent: string;
4
+ parentNode?: unknown;
5
+ }
6
+
7
+ export interface HtmlSanitizeNode {
8
+ textContent: string;
9
+ parentNode?: unknown;
10
+ }
11
+
12
+ export interface HtmlSanitizeFactories {
13
+ createElement(tagName: string): HtmlSanitizeElement;
14
+ createText(text: string): HtmlSanitizeNode;
15
+ textElement(tagName: string, text: string): HtmlSanitizeElement;
16
+ }
17
+
18
+ const TAG_WHITELIST = new Set<string>([
19
+ "strong",
20
+ "em",
21
+ "b",
22
+ "i",
23
+ "u",
24
+ "code",
25
+ "br",
26
+ "p",
27
+ "span",
28
+ ]);
29
+
30
+ const ATTRIBUTE_WHITELIST = new Set<string>([]);
31
+
32
+ // Tags whose content must be consumed as opaque raw text (no nested parsing,
33
+ // no content emission). Mirrors the HTML5 spec's raw-text/script-data content
34
+ // models. When the tokenizer encounters an open tag with one of these names,
35
+ // it scans forward to the matching case-insensitive close tag and discards
36
+ // everything in between. This closes the `<script>alert(1)</script>` payload
37
+ // leak that would otherwise happen with a stateless tokenizer.
38
+ const RAW_TEXT_TAGS = new Set<string>([
39
+ "script",
40
+ "style",
41
+ "noscript",
42
+ "iframe",
43
+ "noembed",
44
+ "noframes",
45
+ "plaintext",
46
+ "xmp",
47
+ ]);
48
+
49
+ type Token =
50
+ | { kind: "text"; value: string }
51
+ | { kind: "tag-open"; tagName: string; selfClosing: boolean }
52
+ | { kind: "tag-close"; tagName: string };
53
+
54
+ function decodeNumericEntity(
55
+ source: string,
56
+ startIndex: number,
57
+ ): { value: string; nextIndex: number } | null {
58
+ if (source[startIndex] !== "&" || source[startIndex + 1] !== "#") {
59
+ return null;
60
+ }
61
+
62
+ let cursor = startIndex + 2;
63
+ let isHex = false;
64
+ if (source[cursor] === "x") {
65
+ isHex = true;
66
+ cursor++;
67
+ }
68
+
69
+ const digitsStart = cursor;
70
+ const digitPattern = isHex ? /[0-9a-fA-F]/ : /[0-9]/;
71
+ while (cursor < source.length && digitPattern.test(source[cursor])) {
72
+ cursor++;
73
+ }
74
+
75
+ if (cursor === digitsStart) {
76
+ return null;
77
+ }
78
+ if (source[cursor] !== ";") {
79
+ return null;
80
+ }
81
+
82
+ const digits = source.slice(digitsStart, cursor);
83
+ const code = parseInt(digits, isHex ? 16 : 10);
84
+ if (!Number.isFinite(code) || code < 0 || code > 0x10ffff) {
85
+ return null;
86
+ }
87
+
88
+ return { value: String.fromCodePoint(code), nextIndex: cursor + 1 };
89
+ }
90
+
91
+ function readCharInTagContext(
92
+ source: string,
93
+ index: number,
94
+ ): { value: string; nextIndex: number } {
95
+ if (source[index] === "&") {
96
+ const decoded = decodeNumericEntity(source, index);
97
+ if (decoded !== null) {
98
+ return decoded;
99
+ }
100
+ }
101
+ return { value: source[index], nextIndex: index + 1 };
102
+ }
103
+
104
+ function tokenize(source: string): Token[] {
105
+ const tokens: Token[] = [];
106
+ let textBuffer = "";
107
+ let i = 0;
108
+ const n = source.length;
109
+
110
+ const flushText = () => {
111
+ if (textBuffer.length > 0) {
112
+ tokens.push({ kind: "text", value: textBuffer });
113
+ textBuffer = "";
114
+ }
115
+ };
116
+
117
+ while (i < n) {
118
+ const ch = source[i];
119
+
120
+ if (ch !== "<") {
121
+ textBuffer += ch;
122
+ i++;
123
+ continue;
124
+ }
125
+
126
+ // Encountered `<` — possible tag opening.
127
+ const tagStart = i;
128
+ const next = source[i + 1];
129
+
130
+ // Comment: <!-- ... -->
131
+ if (next === "!" && source[i + 2] === "-" && source[i + 3] === "-") {
132
+ const closeIdx = source.indexOf("-->", i + 4);
133
+ if (closeIdx === -1) {
134
+ // Unterminated comment — recover as verbatim text.
135
+ textBuffer += source.slice(i);
136
+ i = n;
137
+ continue;
138
+ }
139
+ flushText();
140
+ i = closeIdx + 3;
141
+ continue;
142
+ }
143
+
144
+ // CDATA: <![CDATA[ ... ]]>
145
+ if (
146
+ next === "!" &&
147
+ source[i + 2] === "[" &&
148
+ source.slice(i + 3, i + 9) === "CDATA["
149
+ ) {
150
+ const closeIdx = source.indexOf("]]>", i + 9);
151
+ if (closeIdx === -1) {
152
+ // Unterminated CDATA — recover as verbatim text.
153
+ textBuffer += source.slice(i);
154
+ i = n;
155
+ continue;
156
+ }
157
+ flushText();
158
+ i = closeIdx + 3;
159
+ continue;
160
+ }
161
+
162
+ // DOCTYPE or other <!...> — drop entire construct up to next `>`.
163
+ if (next === "!") {
164
+ const closeIdx = source.indexOf(">", i + 2);
165
+ if (closeIdx === -1) {
166
+ // Malformed — drop partial.
167
+ flushText();
168
+ i = n;
169
+ continue;
170
+ }
171
+ flushText();
172
+ i = closeIdx + 1;
173
+ continue;
174
+ }
175
+
176
+ // Processing instruction: <?...?>
177
+ if (next === "?") {
178
+ const closeIdx = source.indexOf("?>", i + 2);
179
+ if (closeIdx === -1) {
180
+ flushText();
181
+ i = n;
182
+ continue;
183
+ }
184
+ flushText();
185
+ i = closeIdx + 2;
186
+ continue;
187
+ }
188
+
189
+ // Tag close: </name>
190
+ if (next === "/") {
191
+ i += 2;
192
+ let tagName = "";
193
+ while (i < n) {
194
+ const c = source[i];
195
+ if (c === ">") {
196
+ break;
197
+ }
198
+ if (/\s/.test(c)) {
199
+ // Skip whitespace inside close-tag — continue until `>`.
200
+ i++;
201
+ continue;
202
+ }
203
+ const read = readCharInTagContext(source, i);
204
+ tagName += read.value;
205
+ i = read.nextIndex;
206
+ }
207
+ if (i >= n) {
208
+ // EOF before `>` — drop partial close-tag.
209
+ textBuffer = "";
210
+ continue;
211
+ }
212
+ i++; // consume `>`
213
+ flushText();
214
+ tokens.push({ kind: "tag-close", tagName: tagName.toLowerCase() });
215
+ continue;
216
+ }
217
+
218
+ // Tag open: <name [attrs...]>
219
+ i++; // consume `<`
220
+ let tagName = "";
221
+ let selfClosing = false;
222
+ let inTagBody = true;
223
+
224
+ // Read tag name (may be empty if first char is whitespace or `>`).
225
+ while (i < n && inTagBody) {
226
+ const c = source[i];
227
+ if (c === ">") {
228
+ i++;
229
+ inTagBody = false;
230
+ break;
231
+ }
232
+ if (c === "/") {
233
+ selfClosing = true;
234
+ i++;
235
+ // After `/` expect `>`; consume any whitespace then `>`.
236
+ while (i < n && source[i] !== ">") {
237
+ if (/\s/.test(source[i])) {
238
+ i++;
239
+ continue;
240
+ }
241
+ // Non-whitespace, non-`>` after `/` — drop tag.
242
+ break;
243
+ }
244
+ if (i < n && source[i] === ">") {
245
+ i++;
246
+ }
247
+ inTagBody = false;
248
+ break;
249
+ }
250
+ if (/\s/.test(c)) {
251
+ // Transition to attributes — consume the whitespace and break.
252
+ i++;
253
+ break;
254
+ }
255
+ const read = readCharInTagContext(source, i);
256
+ // A decoded character drives state transitions identically to a literal
257
+ // character of the same code point — including transition triggers
258
+ // (whitespace, `>`, `/`).
259
+ if (/\s/.test(read.value)) {
260
+ i = read.nextIndex;
261
+ break;
262
+ }
263
+ if (read.value === ">") {
264
+ i = read.nextIndex;
265
+ inTagBody = false;
266
+ break;
267
+ }
268
+ if (read.value === "/") {
269
+ selfClosing = true;
270
+ i = read.nextIndex;
271
+ continue;
272
+ }
273
+ tagName += read.value;
274
+ i = read.nextIndex;
275
+ }
276
+
277
+ // If still in tag body (i.e., we hit whitespace and broke), we're now reading attributes.
278
+ while (i < n && inTagBody) {
279
+ const c = source[i];
280
+ if (c === ">") {
281
+ i++;
282
+ inTagBody = false;
283
+ break;
284
+ }
285
+ if (c === "/") {
286
+ selfClosing = true;
287
+ i++;
288
+ continue;
289
+ }
290
+ if (c === '"' || c === "'") {
291
+ // Read quoted attribute value — entity decode inside quotes for
292
+ // state-machine completeness; a decoded matching quote closes.
293
+ const quote = c;
294
+ i++;
295
+ while (i < n) {
296
+ const qc = source[i];
297
+ if (qc === "&") {
298
+ const decoded = decodeNumericEntity(source, i);
299
+ if (decoded !== null) {
300
+ if (decoded.value === quote) {
301
+ i = decoded.nextIndex;
302
+ break;
303
+ }
304
+ i = decoded.nextIndex;
305
+ continue;
306
+ }
307
+ }
308
+ if (qc === quote) {
309
+ i++;
310
+ break;
311
+ }
312
+ i++;
313
+ }
314
+ continue;
315
+ }
316
+ // Decode entities in attribute names too (for state-machine
317
+ // correctness; attribute names are discarded).
318
+ const read = readCharInTagContext(source, i);
319
+ // Decoded `>` ends the tag; decoded whitespace continues attributes.
320
+ if (read.value === ">") {
321
+ i = read.nextIndex;
322
+ inTagBody = false;
323
+ break;
324
+ }
325
+ i = read.nextIndex;
326
+ }
327
+
328
+ if (inTagBody) {
329
+ // EOF inside tag — drop partial tag.
330
+ textBuffer = "";
331
+ // But we already flushed text? No — we did NOT flush yet for the open tag path.
332
+ // Actually we need to ensure preceding text was preserved. The text up to
333
+ // tagStart is still in textBuffer. We want to drop it per spec ("text
334
+ // preceding `<` is still flushed" — wait, spec says text PRECEDING is
335
+ // still flushed). Re-read: "The text preceding `<` is still flushed."
336
+ // So we DO flush preceding text. But we did not flush in this path yet.
337
+ // Actually we need to preserve textBuffer that was accumulated BEFORE `<`.
338
+ // The `<` was at tagStart, and we accumulated text into textBuffer up to
339
+ // tagStart-1 in the TEXT loop. So textBuffer still has that preceding
340
+ // text. We should flush it.
341
+ flushText();
342
+ continue;
343
+ }
344
+
345
+ flushText();
346
+ const normalizedName = tagName.toLowerCase();
347
+ tokens.push({
348
+ kind: "tag-open",
349
+ tagName: normalizedName,
350
+ selfClosing,
351
+ });
352
+ void tagStart;
353
+
354
+ // Raw-text mode: if the opened tag is in RAW_TEXT_TAGS, consume content
355
+ // until matching case-insensitive close tag, then emit synthetic close
356
+ // token. The consumed content is NOT emitted as text. Uses the same
357
+ // entity-decoding char reader as the main tokenizer so close tags that
358
+ // are entity-obfuscated (e.g., </&#x73;cript>) still terminate.
359
+ if (RAW_TEXT_TAGS.has(normalizedName) && !selfClosing) {
360
+ let foundClose = false;
361
+ while (i < n) {
362
+ // Scan for `</` start.
363
+ if (source[i] === "<" && source[i + 1] === "/") {
364
+ // Try to read close-tag name using entity-aware reader.
365
+ let scan = i + 2;
366
+ let closeName = "";
367
+ while (scan < n) {
368
+ const ch = source[scan];
369
+ if (ch === ">") break;
370
+ if (/\s/.test(ch)) {
371
+ scan++;
372
+ continue;
373
+ }
374
+ const read = readCharInTagContext(source, scan);
375
+ closeName += read.value;
376
+ scan = read.nextIndex;
377
+ }
378
+ if (
379
+ scan < n &&
380
+ source[scan] === ">" &&
381
+ closeName.toLowerCase() === normalizedName
382
+ ) {
383
+ i = scan + 1;
384
+ tokens.push({ kind: "tag-close", tagName: normalizedName });
385
+ foundClose = true;
386
+ break;
387
+ }
388
+ }
389
+ i++;
390
+ }
391
+ if (!foundClose) {
392
+ i = n;
393
+ }
394
+ }
395
+ }
396
+
397
+ flushText();
398
+ return tokens;
399
+ }
400
+
401
+ export function sanitizeHtmlLayer(source: string): string {
402
+ const tokens = tokenize(source);
403
+ let output = "";
404
+
405
+ for (const token of tokens) {
406
+ switch (token.kind) {
407
+ case "text":
408
+ output += token.value;
409
+ break;
410
+ case "tag-open":
411
+ if (TAG_WHITELIST.has(token.tagName)) {
412
+ output += `<${token.tagName}>`;
413
+ }
414
+ break;
415
+ case "tag-close":
416
+ if (TAG_WHITELIST.has(token.tagName)) {
417
+ output += `</${token.tagName}>`;
418
+ }
419
+ break;
420
+ }
421
+ }
422
+
423
+ // Reference ATTRIBUTE_WHITELIST to keep the module-scope constant audit-visible
424
+ // even though the empty set means no attribute survives the emit pass.
425
+ void ATTRIBUTE_WHITELIST;
426
+
427
+ return output;
428
+ }
429
+
430
+ export function stripTags(source: string): string {
431
+ return source.replace(/<[^>]*>/g, "");
432
+ }
433
+
434
+ export function renderSafeHtml(
435
+ source: string,
436
+ factories: HtmlSanitizeFactories,
437
+ ): Array<HtmlSanitizeElement | HtmlSanitizeNode> {
438
+ const sanitized = sanitizeHtmlLayer(source);
439
+ const strongMatch = sanitized.match(/<strong[^>]*>([\s\S]*?)<\/strong>/i);
440
+
441
+ if (strongMatch !== null) {
442
+ return [factories.textElement("strong", stripTags(strongMatch[1]))];
443
+ }
444
+
445
+ return [factories.createText(stripTags(sanitized))];
446
+ }
package/src/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ export const rendererDomPackageName = "@velumo/renderer-dom";
2
+ export {
3
+ defaultTheme,
4
+ defaultThemeCss,
5
+ defaultThemeId,
6
+ } from "./default-theme.js";
7
+ export { tokensToCssProperties } from "./theme-tokens.js";
8
+ export { compileFontFaceCss, escapeCssString } from "./theme-fonts.js";
9
+ export * from "./camera-adapter.js";
10
+ export * from "./spatial-renderer.js";
11
+ export { mountSlideThumbnail } from "./slide-thumbnail.js";
12
+ export type {
13
+ SlideThumbnailOptions,
14
+ SlideThumbnailHandle,
15
+ } from "./slide-thumbnail.js";
16
+ export {
17
+ renderLayoutNode,
18
+ type LayoutRendererElement,
19
+ type LayoutRendererFactories,
20
+ } from "./layout-renderer.js";
21
+ export { DomRenderer } from "./dom-renderer.js";
22
+ export type {
23
+ DomRendererOptions,
24
+ DomLayerRendererContext,
25
+ } from "./dom-renderer.js";