velloo 0.1.0 → 0.2.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 (96) hide show
  1. package/README.md +66 -40
  2. package/canvas/assets/index-ClaBqcaS.js +28 -0
  3. package/canvas/assets/lucide-all-iIF-pmzN.js +1 -0
  4. package/canvas/index.html +1 -1
  5. package/{chunk-k6ferbam.js → chunk-08sv3k41.js} +1 -1
  6. package/{chunk-74skmg76.js → chunk-0fe83yzt.js} +1 -1
  7. package/{chunk-n5007jcz.js → chunk-1h55s5he.js} +1 -1
  8. package/{chunk-5js0qw32.js → chunk-2c7jbx46.js} +1 -1
  9. package/chunk-308ynsxd.js +2 -0
  10. package/{chunk-w5rzk6ms.js → chunk-3e8vez4d.js} +2 -2
  11. package/{chunk-bk8b7f9a.js → chunk-40xgcth8.js} +4 -3
  12. package/chunk-4dn75m6t.js +3 -0
  13. package/chunk-4fj5tahv.js +3 -0
  14. package/chunk-6nqghjda.js +3 -0
  15. package/chunk-6w6ch4ce.js +2 -0
  16. package/chunk-735ezz23.js +86 -0
  17. package/chunk-7eq7nx37.js +7 -0
  18. package/chunk-7eyb8yrf.js +6 -0
  19. package/{chunk-8ssr8a8w.js → chunk-80fjfyha.js} +3 -6
  20. package/{chunk-b7j86xq7.js → chunk-80r93t2e.js} +1 -1
  21. package/chunk-94vx1yb0.js +3 -0
  22. package/{chunk-g02nd9hn.js → chunk-a84n72m2.js} +1 -1
  23. package/chunk-ampmdzz4.js +10 -0
  24. package/chunk-avns4dbf.js +2 -0
  25. package/{chunk-kxk5x7g6.js → chunk-ayrb3qnm.js} +1 -1
  26. package/{chunk-fgw3hp2v.js → chunk-bwnvcncx.js} +1 -1
  27. package/chunk-ccszg02q.js +5 -0
  28. package/{chunk-jnsfgtn9.js → chunk-cvn689d5.js} +1 -1
  29. package/{chunk-65wb0g7d.js → chunk-dg6z4vdd.js} +1 -1
  30. package/chunk-dgwjpbp9.js +3 -0
  31. package/{chunk-hs2j6s1v.js → chunk-dy41n60j.js} +8 -31
  32. package/{chunk-e2ag33vd.js → chunk-ek71z2fv.js} +2 -2
  33. package/{chunk-h0c36khx.js → chunk-er1fxb7d.js} +1 -1
  34. package/{chunk-xr6f94rj.js → chunk-eyr8hrzf.js} +1 -1
  35. package/{chunk-56vqqr8s.js → chunk-fsbsksga.js} +1 -1
  36. package/{chunk-t2ttkxe9.js → chunk-hcb68zt7.js} +1 -1
  37. package/chunk-j11fyeqh.js +2 -0
  38. package/{chunk-d385435m.js → chunk-jb21jkp1.js} +1 -1
  39. package/{chunk-7e39xe21.js → chunk-jfx9xjmr.js} +1 -1
  40. package/{chunk-b2ebm1t3.js → chunk-jr0ss0mm.js} +1 -1
  41. package/chunk-jv0xggh0.js +2 -0
  42. package/chunk-k17gfwq3.js +2 -0
  43. package/chunk-mh2pr7cq.js +5 -0
  44. package/chunk-mnapdp1y.js +2 -0
  45. package/chunk-n9gs6yx9.js +2 -0
  46. package/chunk-ngnymg2q.js +3 -0
  47. package/{chunk-79ykzywe.js → chunk-pjtg3wnm.js} +1 -1
  48. package/{chunk-2z0k59hz.js → chunk-pp86v6m6.js} +1 -1
  49. package/chunk-r22epxz2.js +3 -0
  50. package/chunk-rgasy862.js +5 -0
  51. package/{chunk-7p7ha0qk.js → chunk-rpwpeph5.js} +1 -1
  52. package/{chunk-k784090q.js → chunk-sjgqnykd.js} +1 -1
  53. package/chunk-sn3x2m0m.js +6 -0
  54. package/{chunk-mq4znqv2.js → chunk-t448rrfr.js} +1 -1
  55. package/{chunk-329e3phg.js → chunk-tdsk5wxa.js} +25 -25
  56. package/{chunk-2vx420g2.js → chunk-v40fn08f.js} +1 -1
  57. package/chunk-vdkrbzh1.js +2 -0
  58. package/{chunk-1gx45384.js → chunk-vgmr98cv.js} +7 -8
  59. package/chunk-vsv8z729.js +6 -0
  60. package/chunk-x4f4k9pp.js +26 -0
  61. package/{chunk-cep3ktr3.js → chunk-xt5ayg7g.js} +1 -1
  62. package/{chunk-rksq3w72.js → chunk-ydhwv29b.js} +1 -1
  63. package/chunk-zb6hc1y0.js +2 -0
  64. package/cli.js +1 -1
  65. package/launcher.cjs +1 -1
  66. package/package.json +3 -3
  67. package/pkgs/schema/src/config.ts +8 -1
  68. package/pkgs/schema/src/index.ts +8 -0
  69. package/pkgs/schema/src/migrate.ts +57 -4
  70. package/pkgs/schema/src/repo.ts +98 -16
  71. package/pkgs/schema/src/svg-sanitize.ts +472 -59
  72. package/skills/velloo-setup/SKILL.md +1 -1
  73. package/canvas/assets/index-BcBJ11d2.js +0 -28
  74. package/canvas/assets/lucide-all-BdEyryfF.js +0 -1
  75. package/chunk-1bywxgc5.js +0 -4
  76. package/chunk-1y50g9vp.js +0 -2
  77. package/chunk-1yhc252b.js +0 -3
  78. package/chunk-2mv65qxs.js +0 -5
  79. package/chunk-40657ggd.js +0 -10
  80. package/chunk-582y60bv.js +0 -2
  81. package/chunk-5x173xay.js +0 -2
  82. package/chunk-75wbh438.js +0 -2
  83. package/chunk-7dggmqwq.js +0 -53
  84. package/chunk-946pcnt5.js +0 -5
  85. package/chunk-aapybh30.js +0 -6
  86. package/chunk-bjnhhsak.js +0 -2
  87. package/chunk-dt03awr1.js +0 -2
  88. package/chunk-erwfk47h.js +0 -3
  89. package/chunk-nfr3q7mg.js +0 -5
  90. package/chunk-nsqs1bqf.js +0 -2
  91. package/chunk-pphgrsxa.js +0 -2
  92. package/chunk-qpvj6kzq.js +0 -6
  93. package/chunk-qwv7xkms.js +0 -2
  94. package/chunk-tmtzxcs5.js +0 -2
  95. package/chunk-y0zxzefg.js +0 -5
  96. package/chunk-zf3xc1wz.js +0 -2
@@ -2,73 +2,486 @@
2
2
  * Active-content sanitization for SVG markup. SVG that ends up inlined via
3
3
  * `dangerouslySetInnerHTML` (the `<SVG content>` helper), emitted into a
4
4
  * consumer app by codegen, or written to a design folder's `assets/` store is
5
- * untrusted (a design-JSON author or a hosted generator authored it) and can
6
- * carry executing-capable content: `<script>`, `<foreignObject>`, inline `on*=`
7
- * handlers, `javascript:`/`data:text/html` URLs, and SMIL animation elements
8
- * (`<set>`/`<animate>` can script). This module is the single source of that
9
- * logic — the render boundary (`@velloo/helpers`' SVG component), codegen
10
- * emit, and the asset store all route through it so no path inlines
11
- * unsanitized SVG.
5
+ * untrusted (a design-JSON author or a hosted generator authored it). This
6
+ * module is the single source of that logic — the render boundary
7
+ * (`@velloo/helpers`' SVG component), codegen emit, the asset store and publish
8
+ * all route through it so no path inlines unsanitized SVG.
9
+ *
10
+ * It tokenizes the markup the way an HTML parser reads it, keeps only static
11
+ * SVG elements and attributes, and re-serializes with every value escaped. The
12
+ * browser therefore parses exactly the tree that was checked: an
13
+ * entity-encoded `&#106;avascript:` is decoded before the URL check, and a `<`
14
+ * that was never a kept tag can only come back out as `&lt;`. Pattern-matching
15
+ * the raw string can't offer that, because entities, tabs and parser recovery
16
+ * rewrite what the browser ends up seeing.
12
17
  */
13
18
 
14
- const ACTIVE_SVG_PATTERNS: readonly RegExp[] = [
15
- /<\s*script[\s>]/i,
16
- /<\s*foreignObject[\s>]/i,
17
- // onload= / onclick= / onerror= / … — the lead char class catches a handler
18
- // that abuts a preceding attribute's closing quote or a `/` (`class="x"onclick=`,
19
- // `<a/onmouseover=`), not just whitespace-separated ones.
20
- /[\s/"']on[a-z]+\s*=/i,
21
- /(?:xlink:href|href|src)\s*=\s*["']?\s*(?:javascript:|data:text\/html)/i,
22
- /<\s*(?:animate|animateTransform|animateMotion|animateColor|set)[\s>]/i, // SMIL
23
- ];
19
+ const ALLOWED_ELEMENTS: ReadonlyMap<string, string> = new Map(
20
+ [
21
+ "a",
22
+ "circle",
23
+ "clipPath",
24
+ "defs",
25
+ "desc",
26
+ "ellipse",
27
+ "feBlend",
28
+ "feColorMatrix",
29
+ "feComponentTransfer",
30
+ "feComposite",
31
+ "feConvolveMatrix",
32
+ "feDiffuseLighting",
33
+ "feDisplacementMap",
34
+ "feDistantLight",
35
+ "feDropShadow",
36
+ "feFlood",
37
+ "feFuncA",
38
+ "feFuncB",
39
+ "feFuncG",
40
+ "feFuncR",
41
+ "feGaussianBlur",
42
+ "feImage",
43
+ "feMerge",
44
+ "feMergeNode",
45
+ "feMorphology",
46
+ "feOffset",
47
+ "fePointLight",
48
+ "feSpecularLighting",
49
+ "feSpotLight",
50
+ "feTile",
51
+ "feTurbulence",
52
+ "filter",
53
+ "g",
54
+ "image",
55
+ "line",
56
+ "linearGradient",
57
+ "marker",
58
+ "mask",
59
+ "path",
60
+ "pattern",
61
+ "polygon",
62
+ "polyline",
63
+ "radialGradient",
64
+ "rect",
65
+ "stop",
66
+ "style",
67
+ "svg",
68
+ "switch",
69
+ "symbol",
70
+ "text",
71
+ "textPath",
72
+ "title",
73
+ "tspan",
74
+ "use",
75
+ "view",
76
+ ].map((name) => [name.toLowerCase(), name]),
77
+ );
78
+
79
+ /**
80
+ * Elements whose presence means the input tried to run or load something.
81
+ * They are dropped like any unknown element; naming them is what lets
82
+ * `svgLooksActive` tell hostile input from an editor's harmless `<metadata>`.
83
+ */
84
+ const ACTIVE_ELEMENTS: ReadonlySet<string> = new Set([
85
+ "animate",
86
+ "animatecolor",
87
+ "animatemotion",
88
+ "animatetransform",
89
+ "applet",
90
+ "audio",
91
+ "base",
92
+ "embed",
93
+ "foreignobject",
94
+ "form",
95
+ "frame",
96
+ "frameset",
97
+ "iframe",
98
+ "img",
99
+ "input",
100
+ "link",
101
+ "meta",
102
+ "noscript",
103
+ "object",
104
+ "script",
105
+ "set",
106
+ "template",
107
+ "video",
108
+ ]);
109
+
110
+ /** HTML raw-text elements: their content is not markup, so skip it by end tag. */
111
+ const RAW_TEXT_ELEMENTS: ReadonlySet<string> = new Set([
112
+ "iframe",
113
+ "noembed",
114
+ "noframes",
115
+ "noscript",
116
+ "plaintext",
117
+ "script",
118
+ "style",
119
+ "textarea",
120
+ "xmp",
121
+ ]);
122
+
123
+ /** HTML void elements: they never have content, with or without a closing slash. */
124
+ const VOID_ELEMENTS: ReadonlySet<string> = new Set([
125
+ "area",
126
+ "base",
127
+ "br",
128
+ "col",
129
+ "embed",
130
+ "hr",
131
+ "img",
132
+ "input",
133
+ "link",
134
+ "meta",
135
+ "param",
136
+ "source",
137
+ "track",
138
+ "wbr",
139
+ ]);
140
+
141
+ const NAMED_ENTITIES: Readonly<Record<string, string>> = {
142
+ amp: "&",
143
+ apos: "'",
144
+ bull: "•",
145
+ colon: ":",
146
+ copy: "©",
147
+ deg: "°",
148
+ divide: "÷",
149
+ gt: ">",
150
+ hellip: "…",
151
+ laquo: "«",
152
+ ldquo: "“",
153
+ lsquo: "‘",
154
+ lt: "<",
155
+ mdash: "—",
156
+ middot: "·",
157
+ nbsp: " ",
158
+ ndash: "–",
159
+ NewLine: "\n",
160
+ quot: '"',
161
+ raquo: "»",
162
+ rdquo: "”",
163
+ reg: "®",
164
+ rsquo: "’",
165
+ sol: "/",
166
+ Tab: "\t",
167
+ times: "×",
168
+ trade: "™",
169
+ };
170
+
171
+ const ATTRIBUTE_NAME = /^[A-Za-z_][-A-Za-z0-9_.:]*$/;
172
+ const SAFE_DATA_IMAGE = /^data:image\/(?:png|jpe?g|gif|webp|avif)[;,]/i;
173
+ const SCHEME = /^([A-Za-z][A-Za-z0-9+.-]*):/;
174
+
175
+ interface SanitizeResult {
176
+ markup: string;
177
+ /** True when something executable or externally loading was removed. */
178
+ removedActive: boolean;
179
+ }
24
180
 
25
181
  /** True when `markup` contains executing-capable SVG (script/handlers/URLs/SMIL). */
26
182
  export function svgLooksActive(markup: string): boolean {
27
- return ACTIVE_SVG_PATTERNS.some((re) => re.test(markup));
183
+ return sanitize(markup).removedActive;
28
184
  }
29
185
 
30
186
  /**
31
- * Strip active content from SVG markup, leaving the static drawing intact.
32
- * Removes script/foreignObject/SMIL elements, inline event handlers, and
33
- * neutralizes `javascript:`/`data:text/html` URLs. Prefer this over
34
- * `svgLooksActive` where a clean render is wanted rather than an outright
35
- * rejection; the two agree on what counts as "active".
187
+ * Strip active content from SVG markup, leaving the static drawing intact:
188
+ * only known SVG elements survive, `on*` handlers are dropped, links must be
189
+ * fragments, relative, http(s), mailto or raster `data:` images, and CSS may
190
+ * not import or fetch anything but in-document `url(#id)` references.
36
191
  */
37
192
  export function sanitizeSvgMarkup(markup: string): string {
38
- // Repeat to a fixpoint: one removal can splice a new tag together
39
- // (`<scr<set>ipt>` becomes `<script>` once `<set>` is gone).
40
- let previous: string;
41
- let current = markup;
42
- do {
43
- previous = current;
44
- // Drop <script>/<foreignObject> elements wholesale, including their contents.
45
- current = removeAll(current, /<\s*(script|foreignObject)\b[\s\S]*?<\s*\/\s*\1\s*>/gi);
46
- // Drop any leftover script/foreignObject/SMIL tags (unpaired or self-closing).
47
- current = removeAll(
48
- current,
49
- /<\s*(?:\/\s*)?(?:script|foreignObject|animate|animateTransform|animateMotion|animateColor|set)\b[^>]*>/gi,
50
- );
51
- current = current
52
- // Strip inline event handlers (on*="…" / on*='…' / on*=bare), including
53
- // ones that abut a preceding attribute's quote or a `/`. Keep the lead
54
- // separator so the previous attribute stays terminated.
55
- .replace(/([\s/"'])on[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "$1")
56
- // Neutralize javascript:/data:text/html in href/src/xlink:href.
57
- .replace(
58
- /((?:xlink:href|href|src)\s*=\s*)(?:"\s*(?:javascript:|data:text\/html)[^"]*"|'\s*(?:javascript:|data:text\/html)[^']*'|(?:javascript:|data:text\/html)[^\s>]*)/gi,
59
- '$1"#"',
60
- );
61
- } while (current !== previous);
62
- return current;
63
- }
64
-
65
- /** Remove every match of `pattern`, including matches a removal splices together. */
66
- function removeAll(markup: string, pattern: RegExp): string {
67
- let previous: string;
68
- let current = markup;
69
- do {
70
- previous = current;
71
- current = current.replace(pattern, "");
72
- } while (current !== previous);
73
- return current;
193
+ return sanitize(markup).markup;
194
+ }
195
+
196
+ interface OpenElement {
197
+ /** Canonical SVG name, or null for an element dropped along with its children. */
198
+ name: string | null;
199
+ lower: string;
200
+ }
201
+
202
+ function sanitize(input: string): SanitizeResult {
203
+ const lower = input.toLowerCase();
204
+ const out: string[] = [];
205
+ const stack: OpenElement[] = [];
206
+ let removedActive = false;
207
+ /** How many dropped elements enclose the cursor; their content is discarded. */
208
+ let dropped = 0;
209
+ let i = 0;
210
+
211
+ const emitText = (text: string) => {
212
+ if (dropped === 0 && text !== "") out.push(escapeText(decodeEntities(text)));
213
+ };
214
+
215
+ while (i < input.length) {
216
+ const lt = input.indexOf("<", i);
217
+ if (lt === -1) {
218
+ emitText(input.slice(i));
219
+ break;
220
+ }
221
+ emitText(input.slice(i, lt));
222
+ i = lt;
223
+
224
+ if (input.startsWith("<!--", i)) {
225
+ const end = input.indexOf("-->", i + 4);
226
+ i = end === -1 ? input.length : end + 3;
227
+ continue;
228
+ }
229
+ if (input.startsWith("<![CDATA[", i)) {
230
+ const end = input.indexOf("]]>", i + 9);
231
+ const stop = end === -1 ? input.length : end;
232
+ if (dropped === 0) out.push(escapeText(input.slice(i + 9, stop)));
233
+ i = end === -1 ? input.length : end + 3;
234
+ continue;
235
+ }
236
+ const next = input[i + 1] ?? "";
237
+ if (next === "!" || next === "?") {
238
+ // A DOCTYPE's internal subset (`[<!ENTITY …>]`) contains `>` of its own.
239
+ const gt = input.indexOf(">", i + 2);
240
+ const bracket = lower.startsWith("<!doctype", i) ? input.indexOf("[", i) : -1;
241
+ const subsetEnd =
242
+ bracket !== -1 && (gt === -1 || bracket < gt) ? input.indexOf("]", bracket) : -1;
243
+ const end = input.indexOf(">", subsetEnd === -1 ? i + 2 : subsetEnd);
244
+ i = end === -1 ? input.length : end + 1;
245
+ continue;
246
+ }
247
+ if (next === "/" && isNameStart(input[i + 2] ?? "")) {
248
+ const end = input.indexOf(">", i + 2);
249
+ const raw = input.slice(i + 2, end === -1 ? input.length : end);
250
+ i = end === -1 ? input.length : end + 1;
251
+ closeElement(tagName(raw).toLowerCase());
252
+ continue;
253
+ }
254
+ if (!isNameStart(next)) {
255
+ emitText("<");
256
+ i += 1;
257
+ continue;
258
+ }
259
+
260
+ const tag = readStartTag(input, i + 1);
261
+ i = tag.end;
262
+ const tagLower = tag.name.toLowerCase();
263
+ const localName = tagLower.slice(tagLower.lastIndexOf(":") + 1);
264
+ const canonical = tagLower.includes(":") ? undefined : ALLOWED_ELEMENTS.get(tagLower);
265
+
266
+ const report = (active: boolean) => {
267
+ if (active) removedActive = true;
268
+ };
269
+
270
+ if (RAW_TEXT_ELEMENTS.has(tagLower) && !tag.selfClosing) {
271
+ const close = lower.indexOf(`</${tagLower}`, i);
272
+ const content = input.slice(i, close === -1 ? input.length : close);
273
+ const gt = close === -1 ? -1 : input.indexOf(">", close);
274
+ i = gt === -1 ? input.length : gt + 1;
275
+ if (dropped > 0) continue;
276
+ if (canonical === "style") {
277
+ const css = filterCss(content.replace(/<!\[CDATA\[|\]\]>/g, ""));
278
+ report(css.removed);
279
+ out.push(`<style${serializeAttributes(tag.attributes, report)}>`);
280
+ out.push(escapeText(css.value), "</style>");
281
+ } else {
282
+ report(ACTIVE_ELEMENTS.has(localName));
283
+ }
284
+ continue;
285
+ }
286
+
287
+ if (dropped > 0 || !canonical) {
288
+ if (dropped === 0) report(ACTIVE_ELEMENTS.has(localName));
289
+ if (!tag.selfClosing && !VOID_ELEMENTS.has(tagLower)) {
290
+ stack.push({ name: null, lower: tagLower });
291
+ dropped += 1;
292
+ }
293
+ continue;
294
+ }
295
+
296
+ const attributes = serializeAttributes(tag.attributes, report);
297
+ if (tag.selfClosing) {
298
+ out.push(`<${canonical}${attributes}/>`);
299
+ } else {
300
+ out.push(`<${canonical}${attributes}>`);
301
+ stack.push({ name: canonical, lower: tagLower });
302
+ }
303
+ }
304
+
305
+ while (stack.length > 0) popElement();
306
+ return { markup: out.join(""), removedActive };
307
+
308
+ function closeElement(name: string) {
309
+ let index = stack.length - 1;
310
+ while (index >= 0 && stack[index]?.lower !== name) index -= 1;
311
+ if (index === -1) return;
312
+ while (stack.length > index) popElement();
313
+ }
314
+
315
+ function popElement() {
316
+ const top = stack.pop();
317
+ if (!top) return;
318
+ if (top.name === null) dropped -= 1;
319
+ else out.push(`</${top.name}>`);
320
+ }
321
+ }
322
+
323
+ interface RawAttribute {
324
+ name: string;
325
+ value: string;
326
+ }
327
+
328
+ interface StartTag {
329
+ name: string;
330
+ attributes: RawAttribute[];
331
+ selfClosing: boolean;
332
+ /** Index just past the tag's `>`. */
333
+ end: number;
334
+ }
335
+
336
+ /** Read a start tag the way the HTML tokenizer does, from just after its `<`. */
337
+ function readStartTag(input: string, from: number): StartTag {
338
+ let i = from;
339
+ while (i < input.length && !/[\s/>]/.test(input[i] as string)) i += 1;
340
+ const name = input.slice(from, i);
341
+ const attributes: RawAttribute[] = [];
342
+ let selfClosing = false;
343
+
344
+ while (i < input.length) {
345
+ const ch = input[i] as string;
346
+ if (/\s/.test(ch)) {
347
+ i += 1;
348
+ continue;
349
+ }
350
+ if (ch === ">") return { name, attributes, selfClosing, end: i + 1 };
351
+ if (ch === "/") {
352
+ selfClosing = input[i + 1] === ">";
353
+ i += 1;
354
+ continue;
355
+ }
356
+ selfClosing = false;
357
+ const nameStart = i;
358
+ i += 1;
359
+ while (i < input.length && !/[\s/>=]/.test(input[i] as string)) i += 1;
360
+ const attrName = input.slice(nameStart, i);
361
+ while (i < input.length && /\s/.test(input[i] as string)) i += 1;
362
+ let value = "";
363
+ if (input[i] === "=") {
364
+ i += 1;
365
+ while (i < input.length && /\s/.test(input[i] as string)) i += 1;
366
+ const quote = input[i];
367
+ if (quote === '"' || quote === "'") {
368
+ const close = input.indexOf(quote, i + 1);
369
+ const stop = close === -1 ? input.length : close;
370
+ value = input.slice(i + 1, stop);
371
+ i = close === -1 ? input.length : close + 1;
372
+ } else {
373
+ const valueStart = i;
374
+ while (i < input.length && !/[\s>]/.test(input[i] as string)) i += 1;
375
+ value = input.slice(valueStart, i);
376
+ }
377
+ }
378
+ attributes.push({ name: attrName, value: decodeEntities(value) });
379
+ }
380
+ return { name, attributes, selfClosing, end: input.length };
381
+ }
382
+
383
+ function serializeAttributes(
384
+ attributes: readonly RawAttribute[],
385
+ report: (removedActive: boolean) => void,
386
+ ): string {
387
+ const seen = new Set<string>();
388
+ let result = "";
389
+ for (const { name, value } of attributes) {
390
+ const lower = name.toLowerCase();
391
+ if (seen.has(lower)) continue;
392
+ seen.add(lower);
393
+ if (!ATTRIBUTE_NAME.test(name)) continue;
394
+ if (lower.startsWith("on")) {
395
+ report(true);
396
+ continue;
397
+ }
398
+ const colon = lower.indexOf(":");
399
+ const prefix = colon === -1 ? "" : lower.slice(0, colon);
400
+ const local = lower.slice(colon + 1);
401
+ // A file may bind the xlink namespace to any prefix (`xmlns:x` + `x:href`),
402
+ // so every prefixed href is a link; other editor-namespaced attributes go.
403
+ const isLink = local === "href" || lower === "src";
404
+ if (!isLink && prefix !== "" && prefix !== "xlink" && prefix !== "xml" && prefix !== "xmlns") {
405
+ continue;
406
+ }
407
+
408
+ let kept = value;
409
+ if (isLink) {
410
+ if (!isSafeUrl(value)) {
411
+ report(true);
412
+ continue;
413
+ }
414
+ } else if (lower === "style" || /url\s*\(|@import/i.test(value)) {
415
+ const css = filterCss(value);
416
+ if (css.removed) report(true);
417
+ kept = css.value;
418
+ }
419
+ result += ` ${name}="${escapeAttribute(kept)}"`;
420
+ }
421
+ return result;
422
+ }
423
+
424
+ function isSafeUrl(value: string): boolean {
425
+ // Browsers drop C0 controls and spaces around a URL and tabs/newlines inside
426
+ // it, so `java\tscript:` and ` javascript:` both navigate.
427
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching the controls browsers strip
428
+ const url = value.replace(/[- -Ÿ]/g, "");
429
+ const scheme = SCHEME.exec(url)?.[1]?.toLowerCase();
430
+ if (scheme === undefined) return true;
431
+ if (scheme === "http" || scheme === "https" || scheme === "mailto") return true;
432
+ return scheme === "data" && SAFE_DATA_IMAGE.test(url);
433
+ }
434
+
435
+ /**
436
+ * Keep CSS to in-document drawing: no `@import`, no `url()` other than a
437
+ * `#fragment`, and no escapes (`u\72l(` spells `url(` to the CSS parser).
438
+ */
439
+ function filterCss(css: string): { value: string; removed: boolean } {
440
+ let removed = false;
441
+ const value = css
442
+ .replace(/\\/g, () => {
443
+ removed = true;
444
+ return "";
445
+ })
446
+ .replace(/@import[^;]*;?/gi, () => {
447
+ removed = true;
448
+ return "";
449
+ })
450
+ .replace(/url\s*\(([^)]*)\)?/gi, (match, target: string) => {
451
+ const inner = target.trim().replace(/^["']|["']$/g, "");
452
+ if (inner.startsWith("#")) return match;
453
+ removed = true;
454
+ return "none";
455
+ });
456
+ return { value, removed };
457
+ }
458
+
459
+ function decodeEntities(text: string): string {
460
+ if (!text.includes("&")) return text;
461
+ return text.replace(
462
+ /&(?:#[xX]([0-9a-fA-F]{1,8})|#([0-9]{1,9})|([A-Za-z][A-Za-z0-9]{0,31}));?/g,
463
+ (match, hex: string | undefined, dec: string | undefined, named: string | undefined) => {
464
+ if (named !== undefined) return NAMED_ENTITIES[named] ?? match;
465
+ const code = hex !== undefined ? Number.parseInt(hex, 16) : Number(dec);
466
+ const valid = code > 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff);
467
+ return valid ? String.fromCodePoint(code) : "�";
468
+ },
469
+ );
470
+ }
471
+
472
+ function escapeText(text: string): string {
473
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
474
+ }
475
+
476
+ function escapeAttribute(value: string): string {
477
+ return escapeText(value).replace(/"/g, "&quot;");
478
+ }
479
+
480
+ function isNameStart(ch: string): boolean {
481
+ return /[A-Za-z]/.test(ch);
482
+ }
483
+
484
+ function tagName(raw: string): string {
485
+ const match = /^[^\s/>]+/.exec(raw);
486
+ return match?.[0] ?? "";
74
487
  }
@@ -97,7 +97,7 @@ statuses say. Fix or replace the blocking component before trusting any
97
97
  - **`unavailable`** — there is no usable canvas source, so a screen using it
98
98
  does not mount at all (`mounted: false`); read its errors — a resolution
99
99
  failure usually means the recorded app root is wrong
100
- (`velloo folder set-app-root`).
100
+ (`velloo design set-app-root`).
101
101
 
102
102
  Host-source edits invalidate the canvas bundle automatically. After changing a
103
103
  component, wait for the frame to reload and call `component_status` again; do