velloo 0.0.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 (267) hide show
  1. package/BUN-LICENSE.md +91 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +9 -0
  4. package/README.md +288 -2
  5. package/THIRD-PARTY-NOTICES.md +467 -0
  6. package/assets/elsewhere-alps-9fqhv029.jpg +0 -0
  7. package/assets/elsewhere-bali-vp60g115.jpg +0 -0
  8. package/assets/elsewhere-forest-xgn23zrj.jpg +0 -0
  9. package/assets/elsewhere-japan-qs7qkbr2.jpg +0 -0
  10. package/assets/elsewhere-kyoto-map-n2bvkrya.svg +1 -0
  11. package/assets/elsewhere-kyoto-qemtn6nk.jpg +0 -0
  12. package/assets/elsewhere-room-g1gaw7cs.jpg +0 -0
  13. package/canvas/apple-touch-icon.png +0 -0
  14. package/canvas/assets/index-BCvDZiBo.css +2 -0
  15. package/canvas/assets/index-ClaBqcaS.js +28 -0
  16. package/canvas/assets/lucide-all-iIF-pmzN.js +1 -0
  17. package/canvas/assets/radix-C3HaOB-d.js +41 -0
  18. package/canvas/assets/react-D0Ec-185.js +9 -0
  19. package/canvas/assets/rolldown-runtime-hePW80VL.js +1 -0
  20. package/canvas/assets/vendor-CKmQ-Zst.js +53 -0
  21. package/canvas/favicon.ico +0 -0
  22. package/canvas/favicon.svg +12 -0
  23. package/canvas/fonts/OFL-1.1.txt +93 -0
  24. package/canvas/fonts/poppins-700.woff2 +0 -0
  25. package/canvas/icon-192.png +0 -0
  26. package/canvas/icon-512.png +0 -0
  27. package/canvas/index.html +22 -0
  28. package/canvas/site.webmanifest +11 -0
  29. package/chunk-08sv3k41.js +4 -0
  30. package/chunk-0fe83yzt.js +2 -0
  31. package/chunk-0t68szgr.js +3 -0
  32. package/chunk-1h55s5he.js +2 -0
  33. package/chunk-2c7jbx46.js +2 -0
  34. package/chunk-2smsg2ct.js +7 -0
  35. package/chunk-308ynsxd.js +2 -0
  36. package/chunk-3e8vez4d.js +1229 -0
  37. package/chunk-40xgcth8.js +63 -0
  38. package/chunk-4dn75m6t.js +3 -0
  39. package/chunk-4fj5tahv.js +3 -0
  40. package/chunk-6nqghjda.js +3 -0
  41. package/chunk-6tx68d90.js +3 -0
  42. package/chunk-6w6ch4ce.js +2 -0
  43. package/chunk-735ezz23.js +86 -0
  44. package/chunk-7eq7nx37.js +7 -0
  45. package/chunk-7eyb8yrf.js +6 -0
  46. package/chunk-80fjfyha.js +86 -0
  47. package/chunk-80r93t2e.js +4 -0
  48. package/chunk-94vx1yb0.js +3 -0
  49. package/chunk-9vg3yafz.js +3 -0
  50. package/chunk-a84n72m2.js +4 -0
  51. package/chunk-ampmdzz4.js +10 -0
  52. package/chunk-avns4dbf.js +2 -0
  53. package/chunk-ayrb3qnm.js +3 -0
  54. package/chunk-bwnvcncx.js +3 -0
  55. package/chunk-ccszg02q.js +5 -0
  56. package/chunk-cvn689d5.js +3 -0
  57. package/chunk-cxe6jjwj.js +3 -0
  58. package/chunk-dg6z4vdd.js +3 -0
  59. package/chunk-dgwjpbp9.js +3 -0
  60. package/chunk-dy41n60j.js +52 -0
  61. package/chunk-ek71z2fv.js +226 -0
  62. package/chunk-er1fxb7d.js +2 -0
  63. package/chunk-eyr8hrzf.js +2 -0
  64. package/chunk-fjbzk4sw.js +3 -0
  65. package/chunk-fsbsksga.js +3 -0
  66. package/chunk-h6gchare.js +3 -0
  67. package/chunk-hcb68zt7.js +85 -0
  68. package/chunk-hggap8fq.js +3 -0
  69. package/chunk-j11fyeqh.js +2 -0
  70. package/chunk-jb21jkp1.js +8 -0
  71. package/chunk-jfx9xjmr.js +18 -0
  72. package/chunk-jr0ss0mm.js +303 -0
  73. package/chunk-jrsknzjt.js +3 -0
  74. package/chunk-jv0xggh0.js +2 -0
  75. package/chunk-k17gfwq3.js +2 -0
  76. package/chunk-mh2pr7cq.js +5 -0
  77. package/chunk-mnapdp1y.js +2 -0
  78. package/chunk-mzhdrawd.js +3 -0
  79. package/chunk-n9gs6yx9.js +2 -0
  80. package/chunk-ngnymg2q.js +3 -0
  81. package/chunk-nwx5700b.js +3 -0
  82. package/chunk-pjtg3wnm.js +4 -0
  83. package/chunk-pp86v6m6.js +3 -0
  84. package/chunk-r22epxz2.js +3 -0
  85. package/chunk-rgasy862.js +5 -0
  86. package/chunk-rpwpeph5.js +3 -0
  87. package/chunk-s23gtejf.js +3 -0
  88. package/chunk-sjgqnykd.js +6 -0
  89. package/chunk-sn3x2m0m.js +6 -0
  90. package/chunk-t448rrfr.js +2 -0
  91. package/chunk-tdsk5wxa.js +451 -0
  92. package/chunk-v40fn08f.js +442 -0
  93. package/chunk-vbk1qhdz.js +3 -0
  94. package/chunk-vdkrbzh1.js +2 -0
  95. package/chunk-vgmr98cv.js +40 -0
  96. package/chunk-vsv8z729.js +6 -0
  97. package/chunk-x4f4k9pp.js +26 -0
  98. package/chunk-xt5ayg7g.js +4 -0
  99. package/chunk-ydhwv29b.js +244 -0
  100. package/chunk-zb6hc1y0.js +2 -0
  101. package/cli.js +3 -0
  102. package/launcher.cjs +97 -0
  103. package/package.json +58 -5
  104. package/pkgs/helpers/src/box.tsx +21 -0
  105. package/pkgs/helpers/src/cn.ts +32 -0
  106. package/pkgs/helpers/src/descriptors.ts +270 -0
  107. package/pkgs/helpers/src/divider.tsx +123 -0
  108. package/pkgs/helpers/src/gradient.tsx +90 -0
  109. package/pkgs/helpers/src/heading.tsx +20 -0
  110. package/pkgs/helpers/src/icon-data.ts +8009 -0
  111. package/pkgs/helpers/src/icon.tsx +53 -0
  112. package/pkgs/helpers/src/image.tsx +111 -0
  113. package/pkgs/helpers/src/index.ts +29 -0
  114. package/pkgs/helpers/src/layer.tsx +61 -0
  115. package/pkgs/helpers/src/lowering.ts +53 -0
  116. package/pkgs/helpers/src/paths.ts +47 -0
  117. package/pkgs/helpers/src/placeholder.tsx +88 -0
  118. package/pkgs/helpers/src/prose.tsx +42 -0
  119. package/pkgs/helpers/src/registry.ts +41 -0
  120. package/pkgs/helpers/src/svg.tsx +62 -0
  121. package/pkgs/helpers/src/text.tsx +16 -0
  122. package/pkgs/provider-antd/src/index.ts +78 -0
  123. package/pkgs/provider-antd/src/intro.ts +14 -0
  124. package/pkgs/provider-antd/src/manifest.ts +439 -0
  125. package/pkgs/provider-antd/src/overlays.ts +176 -0
  126. package/pkgs/provider-antd/src/registry.ts +104 -0
  127. package/pkgs/provider-antd/src/render-pass.ts +43 -0
  128. package/pkgs/provider-antd/src/tailwind-entry.css +21 -0
  129. package/pkgs/provider-antd/src/theme.ts +132 -0
  130. package/pkgs/provider-antd/src/version.ts +8 -0
  131. package/pkgs/provider-chakra/src/index.ts +80 -0
  132. package/pkgs/provider-chakra/src/intro.ts +14 -0
  133. package/pkgs/provider-chakra/src/manifest.ts +421 -0
  134. package/pkgs/provider-chakra/src/overlays.ts +246 -0
  135. package/pkgs/provider-chakra/src/registry.ts +227 -0
  136. package/pkgs/provider-chakra/src/render-pass.ts +55 -0
  137. package/pkgs/provider-chakra/src/tailwind-entry.css +21 -0
  138. package/pkgs/provider-chakra/src/theme.ts +180 -0
  139. package/pkgs/provider-chakra/src/version.ts +8 -0
  140. package/pkgs/provider-mui/src/index.ts +117 -0
  141. package/pkgs/provider-mui/src/intro.ts +12 -0
  142. package/pkgs/provider-mui/src/manifest.ts +263 -0
  143. package/pkgs/provider-mui/src/overlays.ts +124 -0
  144. package/pkgs/provider-mui/src/registry.ts +120 -0
  145. package/pkgs/provider-mui/src/render-pass.ts +36 -0
  146. package/pkgs/provider-mui/src/tailwind-entry.css +21 -0
  147. package/pkgs/provider-mui/src/theme.ts +129 -0
  148. package/pkgs/provider-mui/src/version.ts +8 -0
  149. package/pkgs/provider-none/src/components-inline.tsx +204 -0
  150. package/pkgs/provider-none/src/components.tsx +155 -0
  151. package/pkgs/provider-none/src/index.ts +57 -0
  152. package/pkgs/provider-none/src/intro.ts +18 -0
  153. package/pkgs/provider-none/src/manifest.ts +163 -0
  154. package/pkgs/provider-none/src/registry-inline.ts +36 -0
  155. package/pkgs/provider-none/src/registry.ts +39 -0
  156. package/pkgs/provider-none/src/tailwind-entry.css +57 -0
  157. package/pkgs/provider-none/src/version.ts +10 -0
  158. package/pkgs/schema/src/annotation.ts +120 -0
  159. package/pkgs/schema/src/asset.ts +66 -0
  160. package/pkgs/schema/src/board.ts +71 -0
  161. package/pkgs/schema/src/comment.ts +107 -0
  162. package/pkgs/schema/src/config.ts +185 -0
  163. package/pkgs/schema/src/css-sanitize.ts +56 -0
  164. package/pkgs/schema/src/extension.ts +105 -0
  165. package/pkgs/schema/src/fonts.ts +690 -0
  166. package/pkgs/schema/src/frame.ts +40 -0
  167. package/pkgs/schema/src/icon-name.ts +21 -0
  168. package/pkgs/schema/src/ids.ts +14 -0
  169. package/pkgs/schema/src/index.ts +169 -0
  170. package/pkgs/schema/src/migrate.ts +297 -0
  171. package/pkgs/schema/src/node.ts +229 -0
  172. package/pkgs/schema/src/paths.ts +28 -0
  173. package/pkgs/schema/src/repo.ts +134 -0
  174. package/pkgs/schema/src/screen.ts +30 -0
  175. package/pkgs/schema/src/snippet-resolve.ts +193 -0
  176. package/pkgs/schema/src/snippet.ts +104 -0
  177. package/pkgs/schema/src/svg-sanitize.ts +487 -0
  178. package/pkgs/schema/src/theme.ts +218 -0
  179. package/pkgs/schema/src/typeset.ts +693 -0
  180. package/pkgs/schema/src/validate-ids.ts +46 -0
  181. package/pkgs/schema/src/viewport.ts +8 -0
  182. package/pkgs/shadcn-snapshot/dist/manifest.json +8109 -0
  183. package/pkgs/shadcn-snapshot/src/components/canvas-portal.tsx +55 -0
  184. package/pkgs/shadcn-snapshot/src/components/ui/accordion.tsx +122 -0
  185. package/pkgs/shadcn-snapshot/src/components/ui/alert-dialog.tsx +164 -0
  186. package/pkgs/shadcn-snapshot/src/components/ui/alert.tsx +74 -0
  187. package/pkgs/shadcn-snapshot/src/components/ui/aspect-ratio.tsx +13 -0
  188. package/pkgs/shadcn-snapshot/src/components/ui/attachment.tsx +197 -0
  189. package/pkgs/shadcn-snapshot/src/components/ui/avatar.tsx +100 -0
  190. package/pkgs/shadcn-snapshot/src/components/ui/badge.tsx +50 -0
  191. package/pkgs/shadcn-snapshot/src/components/ui/breadcrumb.tsx +109 -0
  192. package/pkgs/shadcn-snapshot/src/components/ui/bubble.tsx +130 -0
  193. package/pkgs/shadcn-snapshot/src/components/ui/button-group.tsx +82 -0
  194. package/pkgs/shadcn-snapshot/src/components/ui/button.tsx +72 -0
  195. package/pkgs/shadcn-snapshot/src/components/ui/calendar.tsx +229 -0
  196. package/pkgs/shadcn-snapshot/src/components/ui/card.tsx +92 -0
  197. package/pkgs/shadcn-snapshot/src/components/ui/carousel.tsx +235 -0
  198. package/pkgs/shadcn-snapshot/src/components/ui/chart-option.ts +250 -0
  199. package/pkgs/shadcn-snapshot/src/components/ui/chart.tsx +177 -0
  200. package/pkgs/shadcn-snapshot/src/components/ui/checkbox.tsx +41 -0
  201. package/pkgs/shadcn-snapshot/src/components/ui/collapsible.tsx +32 -0
  202. package/pkgs/shadcn-snapshot/src/components/ui/combobox.tsx +196 -0
  203. package/pkgs/shadcn-snapshot/src/components/ui/context-menu.tsx +227 -0
  204. package/pkgs/shadcn-snapshot/src/components/ui/dialog.tsx +131 -0
  205. package/pkgs/shadcn-snapshot/src/components/ui/direction.tsx +24 -0
  206. package/pkgs/shadcn-snapshot/src/components/ui/drawer.tsx +112 -0
  207. package/pkgs/shadcn-snapshot/src/components/ui/dropdown-menu.tsx +205 -0
  208. package/pkgs/shadcn-snapshot/src/components/ui/empty.tsx +98 -0
  209. package/pkgs/shadcn-snapshot/src/components/ui/field.tsx +228 -0
  210. package/pkgs/shadcn-snapshot/src/components/ui/hover-card.tsx +51 -0
  211. package/pkgs/shadcn-snapshot/src/components/ui/input-group.tsx +148 -0
  212. package/pkgs/shadcn-snapshot/src/components/ui/input.tsx +43 -0
  213. package/pkgs/shadcn-snapshot/src/components/ui/item.tsx +187 -0
  214. package/pkgs/shadcn-snapshot/src/components/ui/kbd.tsx +30 -0
  215. package/pkgs/shadcn-snapshot/src/components/ui/label.tsx +25 -0
  216. package/pkgs/shadcn-snapshot/src/components/ui/marker.tsx +71 -0
  217. package/pkgs/shadcn-snapshot/src/components/ui/menubar.tsx +243 -0
  218. package/pkgs/shadcn-snapshot/src/components/ui/message.tsx +89 -0
  219. package/pkgs/shadcn-snapshot/src/components/ui/native-select.tsx +60 -0
  220. package/pkgs/shadcn-snapshot/src/components/ui/navigation-menu.tsx +165 -0
  221. package/pkgs/shadcn-snapshot/src/components/ui/pagination.tsx +117 -0
  222. package/pkgs/shadcn-snapshot/src/components/ui/popover.tsx +66 -0
  223. package/pkgs/shadcn-snapshot/src/components/ui/progress.tsx +35 -0
  224. package/pkgs/shadcn-snapshot/src/components/ui/radio-group.tsx +48 -0
  225. package/pkgs/shadcn-snapshot/src/components/ui/scroll-area.tsx +59 -0
  226. package/pkgs/shadcn-snapshot/src/components/ui/select.tsx +180 -0
  227. package/pkgs/shadcn-snapshot/src/components/ui/separator.tsx +32 -0
  228. package/pkgs/shadcn-snapshot/src/components/ui/sheet.tsx +107 -0
  229. package/pkgs/shadcn-snapshot/src/components/ui/skeleton.tsx +17 -0
  230. package/pkgs/shadcn-snapshot/src/components/ui/slider.tsx +68 -0
  231. package/pkgs/shadcn-snapshot/src/components/ui/sonner.tsx +91 -0
  232. package/pkgs/shadcn-snapshot/src/components/ui/spinner.tsx +21 -0
  233. package/pkgs/shadcn-snapshot/src/components/ui/switch.tsx +41 -0
  234. package/pkgs/shadcn-snapshot/src/components/ui/table.tsx +93 -0
  235. package/pkgs/shadcn-snapshot/src/components/ui/tabs.tsx +84 -0
  236. package/pkgs/shadcn-snapshot/src/components/ui/textarea.tsx +24 -0
  237. package/pkgs/shadcn-snapshot/src/components/ui/toggle-group.tsx +94 -0
  238. package/pkgs/shadcn-snapshot/src/components/ui/toggle.tsx +50 -0
  239. package/pkgs/shadcn-snapshot/src/components/ui/tooltip.tsx +47 -0
  240. package/pkgs/shadcn-snapshot/src/examples.ts +36 -0
  241. package/pkgs/shadcn-snapshot/src/groups.ts +87 -0
  242. package/pkgs/shadcn-snapshot/src/index.ts +43 -0
  243. package/pkgs/shadcn-snapshot/src/lib/utils.ts +4 -0
  244. package/pkgs/shadcn-snapshot/src/manifest.ts +12 -0
  245. package/pkgs/shadcn-snapshot/src/notes.ts +97 -0
  246. package/pkgs/shadcn-snapshot/src/paths.ts +47 -0
  247. package/pkgs/shadcn-snapshot/src/registry-files.ts +293 -0
  248. package/pkgs/shadcn-snapshot/src/registry.ts +631 -0
  249. package/pkgs/shadcn-snapshot/src/shadcn-tailwind.css +632 -0
  250. package/pkgs/shadcn-snapshot/src/tailwind-entry.css +159 -0
  251. package/pkgs/shadcn-snapshot/src/version.ts +8 -0
  252. package/plugins/claude/velloo/.claude-plugin/plugin.json +6 -0
  253. package/plugins/claude/velloo/agents/velloo-design-reviewer.md +33 -0
  254. package/plugins/claude/velloo/agents/velloo-designer.md +39 -0
  255. package/plugins/claude/velloo/commands/design.md +11 -0
  256. package/plugins/claude/velloo/commands/implement.md +10 -0
  257. package/plugins/claude/velloo/commands/review.md +8 -0
  258. package/plugins/gemini/GEMINI.md +31 -0
  259. package/plugins/gemini/commands/velloo/design.toml +10 -0
  260. package/plugins/gemini/commands/velloo/implement.toml +9 -0
  261. package/plugins/gemini/gemini-extension.json +9 -0
  262. package/skills/velloo-brand/SKILL.md +124 -0
  263. package/skills/velloo-design/SKILL.md +144 -0
  264. package/skills/velloo-design-system/SKILL.md +90 -0
  265. package/skills/velloo-implement/SKILL.md +101 -0
  266. package/skills/velloo-logo/SKILL.md +93 -0
  267. package/skills/velloo-setup/SKILL.md +156 -0
@@ -0,0 +1,104 @@
1
+ import { z } from "zod";
2
+ import { ResourceIdSchema } from "./ids.ts";
3
+ import { NodeSchema } from "./node.ts";
4
+
5
+ /**
6
+ * A parameter declared by a snippet. The agent passes a matching arg value
7
+ * at every instantiation; `default` is used when omitted.
8
+ *
9
+ * `type` drives both validation and the inspector's UI:
10
+ * - "string" → text input
11
+ * - "number" → numeric input with step/min/max
12
+ * - "boolean" → checkbox
13
+ * - "icon" → lucide icon picker (string-valued)
14
+ * - "color" → color swatch + token-aware picker (string-valued)
15
+ * - "enum" → select with the declared `enum` values
16
+ * - "node" → subtree slot
17
+ *
18
+ * For `enum`, declare the allowed strings in `enum`; for `number`,
19
+ * `min`/`max`/`step` shape the input.
20
+ *
21
+ * `icon` is for ONE icon chosen at design time — `emit_code` bakes it into
22
+ * the JSX as a literal `<Sparkles/>`. For an icon that varies per instance
23
+ * (by status, priority, …), use a `node` param instead: it emits as a
24
+ * `{slot}` the caller fills, where an `icon` param would collapse every
25
+ * instance to the same glyph (a lucide name must be a literal JSX tag).
26
+ */
27
+ export const SnippetParamSchema = z
28
+ .object({
29
+ name: z.string().min(1),
30
+ type: z.enum(["string", "number", "boolean", "node", "icon", "color", "enum"]),
31
+ default: z.unknown().optional(),
32
+ /**
33
+ * When true, the param may be omitted at instantiation with no `default`:
34
+ * it resolves to nothing (an omitted `node` slot renders/emits nothing; an
35
+ * omitted prop is absent) instead of erroring. Use it for optional slots —
36
+ * a header `action`, a card `badge` — so callers don't pass a throwaway
37
+ * placeholder. Distinct from `default`, which supplies a concrete value.
38
+ */
39
+ optional: z.boolean().optional(),
40
+ /** Allowed values for `type: "enum"`. */
41
+ enum: z.array(z.string()).optional(),
42
+ /** Numeric constraints for `type: "number"`. */
43
+ min: z.number().optional(),
44
+ max: z.number().optional(),
45
+ step: z.number().optional(),
46
+ /** Free-form description for the inspector tooltip. */
47
+ description: z.string().optional(),
48
+ })
49
+ .superRefine((param, ctx) => {
50
+ // Catch type/default mismatches at parse time — otherwise the canvas
51
+ // inspector and render-time substitution each have to coerce bad data.
52
+ if (param.type === "enum") {
53
+ if (!param.enum || param.enum.length === 0) {
54
+ ctx.addIssue({
55
+ code: "custom",
56
+ path: ["enum"],
57
+ message: `param "${param.name}": type "enum" requires a non-empty \`enum\` array`,
58
+ });
59
+ return;
60
+ }
61
+ if (param.default !== undefined && !param.enum.includes(param.default as string)) {
62
+ ctx.addIssue({
63
+ code: "custom",
64
+ path: ["default"],
65
+ message: `param "${param.name}": default ${JSON.stringify(param.default)} is not one of the declared enum values`,
66
+ });
67
+ }
68
+ return;
69
+ }
70
+ if (param.default === undefined || param.type === "node") return;
71
+ const expected =
72
+ param.type === "number" ? "number" : param.type === "boolean" ? "boolean" : "string";
73
+ if (typeof param.default !== expected) {
74
+ ctx.addIssue({
75
+ code: "custom",
76
+ path: ["default"],
77
+ message: `param "${param.name}": default ${JSON.stringify(param.default)} does not match declared type "${param.type}"`,
78
+ });
79
+ }
80
+ });
81
+
82
+ export type SnippetParam = z.infer<typeof SnippetParamSchema>;
83
+
84
+ /**
85
+ * A reusable subtree living in `design/snippets/<id>.json`. Parameter
86
+ * placeholders inside `tree` are encoded as `{ $param: name }` and
87
+ * substituted with the corresponding arg at render time.
88
+ */
89
+ export const SnippetSchema = z.object({
90
+ id: ResourceIdSchema,
91
+ name: z.string().min(1),
92
+ params: z.array(SnippetParamSchema),
93
+ /**
94
+ * Library id (key in `Config.libraries`) this snippet's body
95
+ * resolves against. Optional — when absent the folder's
96
+ * `defaultLibrary` is used. Snippets are pinned to one library; an
97
+ * instance placed in a screen using a different library renders
98
+ * against the snippet's library, not the embedding screen's.
99
+ */
100
+ library: z.string().min(1).optional(),
101
+ tree: NodeSchema,
102
+ });
103
+
104
+ export type Snippet = z.infer<typeof SnippetSchema>;
@@ -0,0 +1,487 @@
1
+ /**
2
+ * Active-content sanitization for SVG markup. SVG that ends up inlined via
3
+ * `dangerouslySetInnerHTML` (the `<SVG content>` helper), emitted into a
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). 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.
17
+ */
18
+
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
+ }
180
+
181
+ /** True when `markup` contains executing-capable SVG (script/handlers/URLs/SMIL). */
182
+ export function svgLooksActive(markup: string): boolean {
183
+ return sanitize(markup).removedActive;
184
+ }
185
+
186
+ /**
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.
191
+ */
192
+ export function sanitizeSvgMarkup(markup: string): string {
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] ?? "";
487
+ }