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.
- package/README.md +66 -40
- package/canvas/assets/index-ClaBqcaS.js +28 -0
- package/canvas/assets/lucide-all-iIF-pmzN.js +1 -0
- package/canvas/index.html +1 -1
- package/{chunk-k6ferbam.js → chunk-08sv3k41.js} +1 -1
- package/{chunk-74skmg76.js → chunk-0fe83yzt.js} +1 -1
- package/{chunk-n5007jcz.js → chunk-1h55s5he.js} +1 -1
- package/{chunk-5js0qw32.js → chunk-2c7jbx46.js} +1 -1
- package/chunk-308ynsxd.js +2 -0
- package/{chunk-w5rzk6ms.js → chunk-3e8vez4d.js} +2 -2
- package/{chunk-bk8b7f9a.js → chunk-40xgcth8.js} +4 -3
- package/chunk-4dn75m6t.js +3 -0
- package/chunk-4fj5tahv.js +3 -0
- package/chunk-6nqghjda.js +3 -0
- package/chunk-6w6ch4ce.js +2 -0
- package/chunk-735ezz23.js +86 -0
- package/chunk-7eq7nx37.js +7 -0
- package/chunk-7eyb8yrf.js +6 -0
- package/{chunk-8ssr8a8w.js → chunk-80fjfyha.js} +3 -6
- package/{chunk-b7j86xq7.js → chunk-80r93t2e.js} +1 -1
- package/chunk-94vx1yb0.js +3 -0
- package/{chunk-g02nd9hn.js → chunk-a84n72m2.js} +1 -1
- package/chunk-ampmdzz4.js +10 -0
- package/chunk-avns4dbf.js +2 -0
- package/{chunk-kxk5x7g6.js → chunk-ayrb3qnm.js} +1 -1
- package/{chunk-fgw3hp2v.js → chunk-bwnvcncx.js} +1 -1
- package/chunk-ccszg02q.js +5 -0
- package/{chunk-jnsfgtn9.js → chunk-cvn689d5.js} +1 -1
- package/{chunk-65wb0g7d.js → chunk-dg6z4vdd.js} +1 -1
- package/chunk-dgwjpbp9.js +3 -0
- package/{chunk-hs2j6s1v.js → chunk-dy41n60j.js} +8 -31
- package/{chunk-e2ag33vd.js → chunk-ek71z2fv.js} +2 -2
- package/{chunk-h0c36khx.js → chunk-er1fxb7d.js} +1 -1
- package/{chunk-xr6f94rj.js → chunk-eyr8hrzf.js} +1 -1
- package/{chunk-56vqqr8s.js → chunk-fsbsksga.js} +1 -1
- package/{chunk-t2ttkxe9.js → chunk-hcb68zt7.js} +1 -1
- package/chunk-j11fyeqh.js +2 -0
- package/{chunk-d385435m.js → chunk-jb21jkp1.js} +1 -1
- package/{chunk-7e39xe21.js → chunk-jfx9xjmr.js} +1 -1
- package/{chunk-b2ebm1t3.js → chunk-jr0ss0mm.js} +1 -1
- package/chunk-jv0xggh0.js +2 -0
- package/chunk-k17gfwq3.js +2 -0
- package/chunk-mh2pr7cq.js +5 -0
- package/chunk-mnapdp1y.js +2 -0
- package/chunk-n9gs6yx9.js +2 -0
- package/chunk-ngnymg2q.js +3 -0
- package/{chunk-79ykzywe.js → chunk-pjtg3wnm.js} +1 -1
- package/{chunk-2z0k59hz.js → chunk-pp86v6m6.js} +1 -1
- package/chunk-r22epxz2.js +3 -0
- package/chunk-rgasy862.js +5 -0
- package/{chunk-7p7ha0qk.js → chunk-rpwpeph5.js} +1 -1
- package/{chunk-k784090q.js → chunk-sjgqnykd.js} +1 -1
- package/chunk-sn3x2m0m.js +6 -0
- package/{chunk-mq4znqv2.js → chunk-t448rrfr.js} +1 -1
- package/{chunk-329e3phg.js → chunk-tdsk5wxa.js} +25 -25
- package/{chunk-2vx420g2.js → chunk-v40fn08f.js} +1 -1
- package/chunk-vdkrbzh1.js +2 -0
- package/{chunk-1gx45384.js → chunk-vgmr98cv.js} +7 -8
- package/chunk-vsv8z729.js +6 -0
- package/chunk-x4f4k9pp.js +26 -0
- package/{chunk-cep3ktr3.js → chunk-xt5ayg7g.js} +1 -1
- package/{chunk-rksq3w72.js → chunk-ydhwv29b.js} +1 -1
- package/chunk-zb6hc1y0.js +2 -0
- package/cli.js +1 -1
- package/launcher.cjs +1 -1
- package/package.json +3 -3
- package/pkgs/schema/src/config.ts +8 -1
- package/pkgs/schema/src/index.ts +8 -0
- package/pkgs/schema/src/migrate.ts +57 -4
- package/pkgs/schema/src/repo.ts +98 -16
- package/pkgs/schema/src/svg-sanitize.ts +472 -59
- package/skills/velloo-setup/SKILL.md +1 -1
- package/canvas/assets/index-BcBJ11d2.js +0 -28
- package/canvas/assets/lucide-all-BdEyryfF.js +0 -1
- package/chunk-1bywxgc5.js +0 -4
- package/chunk-1y50g9vp.js +0 -2
- package/chunk-1yhc252b.js +0 -3
- package/chunk-2mv65qxs.js +0 -5
- package/chunk-40657ggd.js +0 -10
- package/chunk-582y60bv.js +0 -2
- package/chunk-5x173xay.js +0 -2
- package/chunk-75wbh438.js +0 -2
- package/chunk-7dggmqwq.js +0 -53
- package/chunk-946pcnt5.js +0 -5
- package/chunk-aapybh30.js +0 -6
- package/chunk-bjnhhsak.js +0 -2
- package/chunk-dt03awr1.js +0 -2
- package/chunk-erwfk47h.js +0 -3
- package/chunk-nfr3q7mg.js +0 -5
- package/chunk-nsqs1bqf.js +0 -2
- package/chunk-pphgrsxa.js +0 -2
- package/chunk-qpvj6kzq.js +0 -6
- package/chunk-qwv7xkms.js +0 -2
- package/chunk-tmtzxcs5.js +0 -2
- package/chunk-y0zxzefg.js +0 -5
- 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)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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 `javascript:` is decoded before the URL check, and a `<`
|
|
14
|
+
* that was never a kept tag can only come back out as `<`. 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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
183
|
+
return sanitize(markup).removedActive;
|
|
28
184
|
}
|
|
29
185
|
|
|
30
186
|
/**
|
|
31
|
-
* Strip active content from SVG markup, leaving the static drawing intact
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function escapeAttribute(value: string): string {
|
|
477
|
+
return escapeText(value).replace(/"/g, """);
|
|
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
|
|
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
|