From 3645e931629b039be07d775dcdc04031b3b4287e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 18:47:11 +0000 Subject: [PATCH 1/7] Add s.svg() schema with themeable color variables Adds a new schema that stores an svg as a json node tree, so custom icons can be content rather than an opaque binary. Until now an svg could only be an s.image() / s.file() reference to a blob: not recolorable, not validatable against the design system, not diffable. Colors are declared as *variables* rather than baked hexes: s.svg({ width: 24, height: 24, variables: { brand: "#0055ff", line: "currentColor" }, }) The color on a variable is an example. It is what the editor previews, what svgVarsCss() writes into the stylesheet, and what a pasted literal color is matched against on import. What actually renders resolves from --val-svg-, so one icon supports currentColor, dark mode and per-usage overrides: [data-theme="dark"] { --val-svg-brand: #6699ff } How permissive to be about raw colors is up to the schema: `literals` is "forbid" (the default), "allow", or an allowlist. This is enforced at the type level as well as by the validator, so a raw hex in a .val.ts is a compile error, not only a validation error. Notable decisions: - Svg sources are excluded from stega encoding entirely. Every string in an svg (d, viewBox, points, transform) is machine parsed, so injecting invisible characters would corrupt the icon. The source path is attached as an ordinary serializable field (SVG_VAL_PATH) instead, which ValSvg turns into data-val-path - a symbol would not survive RSC serialization. - ValSvg builds React elements tag by tag; there is no innerHTML anywhere. Safety is therefore entirely the allowlist, and it is a strict per-tag allowlist of exact attribute names rather than an on* denylist: React renders unknown attributes on host elements verbatim, and onload does fire on svg elements. script, style, foreignObject, a, use, image, animation and filter elements are rejected, as are style/id/class/href/xlink/data-*. d, points, transform and stroke-dasharray are the only free-form strings left, and each is regex constrained and length capped. - The svg parser is hand rolled and dependency free. @valbuild/shared ships into every user's server bundle, and svg-as-xml is a small grammar. Entity declarations and doctypes with an internal subset are rejected outright (XXE / billion laughs). Parser output is filtered through the allowlist before anything else sees it - the parser is not a security boundary. - Import matches literal colors onto variables by exact normalized value, or by a variable's declared match aliases. Nothing is snapped to a nearby variable unless that variable opted in with `tolerance`; anything left over is reported so the editor can ask. Quietly rewriting a brand color is worse than asking about it. - No new ValidationFix code: because the palette lives only in the schema and is never mirrored into the source, there is nothing that can drift and nothing to repair. Gradients are deliberately out of scope for now. Adding them later needs a third attribute value kind for url(#...) plus per-instance id namespacing, and is additive rather than breaking. Also adds the editor field (paste markup or drop a .svg, with a prompt for colors that are not in the palette), storybook stories for it, and an icons module in examples/next. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01B3Wbr5HZDGfdRzAnZ5aH7n --- examples/next/app/page.tsx | 29 +- examples/next/content/icons.val.ts | 122 +++ examples/next/val.modules.ts | 1 + packages/core/src/index.ts | 33 + packages/core/src/initSchema.ts | 34 + packages/core/src/module.ts | 20 + packages/core/src/schema/describe.test.ts | 23 + packages/core/src/schema/deserialize.ts | 10 + packages/core/src/schema/hidden.test.ts | 1 + packages/core/src/schema/index.ts | 19 +- packages/core/src/schema/readonly.test.ts | 1 + packages/core/src/schema/svg.test.ts | 162 ++++ packages/core/src/schema/svg.ts | 727 ++++++++++++++++++ packages/core/src/schema/svg/allowlist.ts | 160 ++++ packages/core/src/schema/validation.test.ts | 249 ++++++ packages/core/src/selector/index.ts | 31 +- packages/core/src/selector/svg.ts | 6 + packages/core/src/source/index.ts | 4 +- packages/core/src/source/svg.ts | 232 ++++++ .../src/external_exempt_from_val_quickjs.ts | 14 +- packages/react/src/internal/ValSvg.tsx | 132 ++++ packages/react/src/internal/index.ts | 1 + packages/react/src/stega/index.ts | 1 + packages/react/src/stega/stegaEncode.test.ts | 81 ++ packages/react/src/stega/stegaEncode.ts | 74 +- packages/server/src/hasRemoteFileSchema.ts | 3 +- packages/shared/src/internal/index.ts | 1 + packages/shared/src/internal/svg/colors.ts | 175 +++++ packages/shared/src/internal/svg/index.ts | 23 + .../shared/src/internal/svg/parseSvg.test.ts | 321 ++++++++ packages/shared/src/internal/svg/parseSvg.ts | 333 ++++++++ .../shared/src/internal/svg/svgToString.ts | 150 ++++ packages/shared/src/internal/svg/xml.ts | 208 +++++ .../src/internal/zod/SerializedSchema.ts | 37 + packages/ui/spa/ValSyncEngine.ts | 1 + packages/ui/spa/components/AnyField.tsx | 3 + packages/ui/spa/components/NodeIcon.tsx | 3 + packages/ui/spa/components/Preview.tsx | 3 + .../ui/spa/components/ValFieldProvider.tsx | 12 + packages/ui/spa/components/ValProvider.tsx | 12 + .../components/fields/SvgField.stories.tsx | 186 +++++ .../ui/spa/components/fields/SvgField.tsx | 559 ++++++++++++++ packages/ui/spa/components/fields/emptyOf.ts | 7 + packages/ui/spa/components/getKeysOf.ts | 1 + .../ui/spa/components/getReferencedFiles.ts | 1 + packages/ui/spa/resolvePatchPath.ts | 11 +- packages/ui/spa/search/createSearchIndex.ts | 4 + .../ui/spa/utils/findRequiredRemoteFiles.ts | 1 + .../ui/spa/utils/getDependentModuleFiles.ts | 1 + packages/ui/spa/utils/schemaTypesOfPath.ts | 3 + packages/ui/spa/utils/traverseSchemaSource.ts | 6 + 51 files changed, 4185 insertions(+), 47 deletions(-) create mode 100644 examples/next/content/icons.val.ts create mode 100644 packages/core/src/schema/svg.test.ts create mode 100644 packages/core/src/schema/svg.ts create mode 100644 packages/core/src/schema/svg/allowlist.ts create mode 100644 packages/core/src/selector/svg.ts create mode 100644 packages/core/src/source/svg.ts create mode 100644 packages/react/src/internal/ValSvg.tsx create mode 100644 packages/shared/src/internal/svg/colors.ts create mode 100644 packages/shared/src/internal/svg/index.ts create mode 100644 packages/shared/src/internal/svg/parseSvg.test.ts create mode 100644 packages/shared/src/internal/svg/parseSvg.ts create mode 100644 packages/shared/src/internal/svg/svgToString.ts create mode 100644 packages/shared/src/internal/svg/xml.ts create mode 100644 packages/ui/spa/components/fields/SvgField.stories.tsx create mode 100644 packages/ui/spa/components/fields/SvgField.tsx diff --git a/examples/next/app/page.tsx b/examples/next/app/page.tsx index a4dc0d39f..0fc640adf 100644 --- a/examples/next/app/page.tsx +++ b/examples/next/app/page.tsx @@ -1,8 +1,9 @@ import { notFound } from "next/navigation"; import { fetchVal, fetchValRoute } from "../val/rsc"; import pageVal from "./page.val"; -import { ValImage, ValRichText } from "@valbuild/next"; +import { svgVarsCss, ValImage, ValRichText, ValSvg } from "@valbuild/next"; import authorsVal from "../content/authors.val"; +import iconsVal, { iconSchema } from "../content/icons.val"; import Link from "next/link"; import { val } from "../val.config"; @@ -12,6 +13,7 @@ export default async function Home({ params }: { params: unknown }) { notFound(); } const authors = await fetchVal(authorsVal); + const icons = await fetchVal(iconsVal); const author = authors[page.author]; return (
@@ -52,6 +54,31 @@ export default async function Home({ params }: { params: unknown }) { {page.video.text}
); } diff --git a/examples/next/content/icons.val.ts b/examples/next/content/icons.val.ts new file mode 100644 index 000000000..533ff92b2 --- /dev/null +++ b/examples/next/content/icons.val.ts @@ -0,0 +1,122 @@ +import { s, c, type t } from "../val.config"; + +/** + * A set of custom icons. + * + * Colors are declared as variables rather than baked into the markup, so the + * same icon can inherit the surrounding text color, follow a dark mode + * stylesheet, or be recolored per usage. The color on each variable is an + * example: it is what the editor previews, what `svgVarsCss` writes into the + * stylesheet, and what a pasted color is matched against on import. + */ +export const iconSchema = s + .svg({ + width: 24, + height: 24, + aspectRatio: "1:1", + variables: { + brand: { + value: "#0055ff", + match: ["#0055FF", "#0050f0"], + description: "The primary shape of the icon", + }, + line: { + value: "currentColor", + description: "Strokes: inherits the surrounding text color", + }, + surface: { + value: "#ffffff", + match: ["#fff", "#fefefe"], + description: "Cut-outs and badges", + }, + }, + }) + .describe("A 24x24 icon. Paste svg markup to replace it."); + +export const schema = s.record(iconSchema); + +export type Icons = t.inferSchema; + +export default c.define("/content/icons.val.ts", schema, { + bell: { + viewBox: "0 0 24 24", + width: 24, + height: 24, + children: [ + { + tag: "path", + attrs: { + d: "M12 2.5A5.5 5.5 0 0 0 6.5 8v4.2L4.8 15.2a.6.6 0 0 0 .52.9h13.36a.6.6 0 0 0 .52-.9L17.5 12.2V8A5.5 5.5 0 0 0 12 2.5Z", + fill: { var: "brand" }, + }, + children: [], + }, + { + tag: "path", + attrs: { + d: "M9.6 18.5a2.4 2.4 0 0 0 4.8 0", + stroke: { var: "line" }, + "stroke-width": 1.6, + "stroke-linecap": "round", + fill: "none", + }, + children: [], + }, + { + tag: "circle", + attrs: { cx: 17.5, cy: 6, r: 2.6, fill: { var: "surface" } }, + children: [], + }, + ], + }, + bookmark: { + viewBox: "0 0 24 24", + width: 24, + height: 24, + children: [ + { + tag: "path", + attrs: { + d: "M6.5 3.5h11a1 1 0 0 1 1 1v16l-6.5-4.2-6.5 4.2v-16a1 1 0 0 1 1-1Z", + fill: { var: "brand" }, + }, + children: [], + }, + { + tag: "path", + attrs: { + d: "M9.5 8.5h5", + stroke: { var: "surface" }, + "stroke-width": 1.6, + "stroke-linecap": "round", + fill: "none", + }, + children: [], + }, + ], + }, + check: { + viewBox: "0 0 24 24", + width: 24, + height: 24, + children: [ + { + tag: "circle", + attrs: { cx: 12, cy: 12, r: 9.5, fill: { var: "brand" } }, + children: [], + }, + { + tag: "path", + attrs: { + d: "M7.5 12.3 10.6 15.4 16.5 9.5", + stroke: { var: "surface" }, + "stroke-width": 2, + "stroke-linecap": "round", + "stroke-linejoin": "round", + fill: "none", + }, + children: [], + }, + ], + }, +}); diff --git a/examples/next/val.modules.ts b/examples/next/val.modules.ts index 88a1873ba..3fbba3d6a 100644 --- a/examples/next/val.modules.ts +++ b/examples/next/val.modules.ts @@ -6,6 +6,7 @@ export default modules(config, [ { def: () => import("./app/blogs/[blog]/page.val") }, { def: () => import("./app/generic/[[...path]]/page.val") }, { def: () => import("./content/media.val") }, + { def: () => import("./content/icons.val") }, { def: () => import("./app/page.val") }, { def: () => import("./app/external.val") }, ]); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1ac1a663f..05a37b4f9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -42,6 +42,23 @@ export type { SpanNode, UnorderedListNode, } from "./source/richtext"; +export type { + AllSvgOptions, + GenericSvgNode, + GenericSvgSource, + SvgAttrs, + SvgColorValue, + SvgKeywordColor, + SvgLiterals, + SvgNode, + SvgOptions, + SvgSource, + SvgTag, + SvgVarRef, + SvgVariable, + SvgVariableName, +} from "./source/svg"; +export { SVG_VAL_PATH, isSvgVarRef, svgVariableValue } from "./source/svg"; export { type Val, type SerializedVal, @@ -125,6 +142,22 @@ export { type SerializedRichTextSchema, RichTextSchema, } from "./schema/richtext"; +export { type SerializedSvgSchema, SvgSchema, svgVarsCss } from "./schema/svg"; +export { + SVG_TAGS, + SVG_COMMON_ATTRS, + SVG_TAG_ATTRS, + SVG_COLOR_ATTRS, + SVG_NUMBER_ATTRS, + SVG_ENUM_ATTRS, + SVG_STRING_ATTRS, + SVG_KEYWORD_COLORS, + SVG_DEFAULT_MAX_NODES, + SVG_DEFAULT_MAX_DEPTH, + isSvgTag, + isAllowedSvgAttr, + parseSvgViewBox, +} from "./schema/svg/allowlist"; export { type SerializedUnionSchema, UnionSchema, diff --git a/packages/core/src/initSchema.ts b/packages/core/src/initSchema.ts index 8fc376384..a7e2b4778 100644 --- a/packages/core/src/initSchema.ts +++ b/packages/core/src/initSchema.ts @@ -6,6 +6,7 @@ import { string } from "./schema/string"; import { boolean } from "./schema/boolean"; import { union } from "./schema/union"; import { richtext } from "./schema/richtext"; +import { svg } from "./schema/svg"; import { image } from "./schema/image"; import { literal } from "./schema/literal"; import { keyOf } from "./schema/keyOf"; @@ -99,6 +100,38 @@ export type InitSchema = { * ]); */ readonly richtext: typeof richtext; + /** + * Define an svg icon. + * + * The svg is stored as a json tree, so colors can be constrained to a set of + * named variables. Each variable declares an example color, which is what the + * editor previews, what `svgVarsCss` emits, and what literal fills are matched + * against when an svg is pasted in. The color that actually renders is + * resolved from `--val-svg-`, so an icon supports `currentColor`, dark + * mode and per-usage overrides. + * + * Render it with `ValSvg`. + * + * @example + * const schema = s.svg({ + * width: 24, + * height: 24, + * variables: { brand: "#0055ff", line: "currentColor" }, + * }); + * export default c.define("/example.val.ts", schema, { + * viewBox: "0 0 24 24", + * width: 24, + * height: 24, + * children: [ + * { + * tag: "path", + * attrs: { d: "M4 12h16", stroke: { var: "line" }, fill: "none" }, + * children: [], + * }, + * ], + * }); + */ + readonly svg: typeof svg; /** * Define an image. * @@ -267,6 +300,7 @@ export function initSchema() { union, // oneOf, richtext, + svg, image, literal, keyOf, diff --git a/packages/core/src/module.ts b/packages/core/src/module.ts index 9cae09e7a..16c8deb3c 100644 --- a/packages/core/src/module.ts +++ b/packages/core/src/module.ts @@ -14,6 +14,7 @@ import { ArraySchema, SerializedArraySchema } from "./schema/array"; import { UnionSchema, SerializedUnionSchema } from "./schema/union"; import { Json } from "./Json"; import { RichTextSchema, SerializedRichTextSchema } from "./schema/richtext"; +import { SerializedSvgSchema, SvgSchema } from "./schema/svg"; import { ImageMetadata, ImageSchema, @@ -21,6 +22,7 @@ import { } from "./schema/image"; import { FILE_REF_PROP, FileSource } from "./source/file"; import { AllRichTextOptions, RichTextSource } from "./source/richtext"; +import { AllSvgOptions, SvgSource } from "./source/svg"; import { RecordSchema, SerializedRecordSchema } from "./schema/record"; import { RawString } from "./schema/string"; import { ImageSelector } from "./selector/image"; @@ -255,6 +257,17 @@ function isRichTextSchema( ); } +function isSvgSchema( + schema: Schema | SerializedSchema, +): schema is + | SvgSchema> + | SerializedSvgSchema { + return ( + schema instanceof SvgSchema || + (typeof schema === "object" && "type" in schema && schema.type === "svg") + ); +} + function isImageSchema( schema: Schema | SerializedSchema, ): schema is @@ -451,6 +464,10 @@ export function resolvePath< : resolvedSchema; } resolvedSource = resolvedSource[part]; + } else if (isSvgSchema(resolvedSchema)) { + // Svg sources are edited as a whole: the schema stays pinned while the + // path walks down into the node tree (same as richtext). + resolvedSource = resolvedSource[part]; } else { throw Error( `Invalid path: ${part} resolved to an unexpected schema ${JSON.stringify( @@ -716,6 +733,9 @@ export function safeResolvePath< : resolvedSchema; } resolvedSource = resolvedSource[part]; + } else if (isSvgSchema(resolvedSchema)) { + // See the note in resolvePath. + resolvedSource = resolvedSource[part]; } else { return { status: "error", diff --git a/packages/core/src/schema/describe.test.ts b/packages/core/src/schema/describe.test.ts index b182c4c39..88291331f 100644 --- a/packages/core/src/schema/describe.test.ts +++ b/packages/core/src/schema/describe.test.ts @@ -3,6 +3,7 @@ import { SelectorSource } from "../selector"; import { array } from "./array"; import { boolean } from "./boolean"; import { date } from "./date"; +import { svg } from "./svg"; import { deserializeSchema } from "./deserialize"; import { image } from "./image"; import { literal } from "./literal"; @@ -39,6 +40,16 @@ describe("Schema.describe()", () => { }); }); + test("svg: describe is serialized", () => { + const schema = svg({ variables: { brand: "#0055ff" } }).describe( + "Brand icon", + ); + expect(schema["executeSerialize"]()).toMatchObject({ + type: "svg", + description: "Brand icon", + }); + }); + test("date: describe is serialized", () => { const schema = date().describe("Birthday"); expect(schema["executeSerialize"]()).toMatchObject({ @@ -270,6 +281,18 @@ describe("Schema.describe() survives serialize → deserialize round-trip", () = }); }); + test("svg", () => { + expect( + roundTrip( + svg({ variables: { brand: "#0055ff" } }).describe("Brand icon"), + ), + ).toMatchObject({ + type: "svg", + description: "Brand icon", + options: { variables: { brand: "#0055ff" } }, + }); + }); + test("literal", () => { expect(roundTrip(literal("admin").describe("Access tier"))).toMatchObject({ type: "literal", diff --git a/packages/core/src/schema/deserialize.ts b/packages/core/src/schema/deserialize.ts index 8da5e0a92..058530324 100644 --- a/packages/core/src/schema/deserialize.ts +++ b/packages/core/src/schema/deserialize.ts @@ -17,6 +17,7 @@ import { RecordSchema } from "./record"; import { RichTextSchema } from "./richtext"; import { RouteSchema } from "./route"; import { StringSchema } from "./string"; +import { SvgSchema } from "./svg"; import { UnionSchema } from "./union"; export function deserializeSchema( @@ -156,6 +157,15 @@ function deserializeSchemaImpl( serialized.description, ); } + case "svg": + return new SvgSchema( + serialized.options ?? {}, + serialized.opt, + [], + false, + false, + serialized.description, + ); case "record": return new RecordSchema( deserializeSchema(serialized.item), diff --git a/packages/core/src/schema/hidden.test.ts b/packages/core/src/schema/hidden.test.ts index 85fa0e160..4c24610fd 100644 --- a/packages/core/src/schema/hidden.test.ts +++ b/packages/core/src/schema/hidden.test.ts @@ -47,6 +47,7 @@ describe("Schema.hidden()", () => { expect(s.number().hidden()["executeSerialize"]().hidden).toBe(true); expect(s.boolean().hidden()["executeSerialize"]().hidden).toBe(true); expect(s.date().hidden()["executeSerialize"]().hidden).toBe(true); + expect(s.svg().hidden()["executeSerialize"]().hidden).toBe(true); expect(s.array(s.string()).hidden()["executeSerialize"]().hidden).toBe( true, ); diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index c9172ae0e..faec83c78 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -11,6 +11,7 @@ import { SerializedNumberSchema } from "./number"; import { SerializedObjectSchema } from "./object"; import { SerializedRecordSchema } from "./record"; import { SerializedRichTextSchema } from "./richtext"; +import { SerializedSvgSchema } from "./svg"; import { RawString, SerializedStringSchema } from "./string"; import { SerializedUnionSchema } from "./union"; import { SerializedDateSchema } from "./date"; @@ -22,6 +23,7 @@ import { } from "./validation/ValidationError"; import { FileSource } from "../source/file"; import { GenericRichTextSourceNode, RichTextSource } from "../source/richtext"; +import { GenericSvgSource, SvgOptions, SvgSource } from "../source/svg"; import { ReifiedRender } from "../render"; // import { SerializedI18nSchema } from "./future/i18n"; // import { SerializedOneOfSchema } from "./future/oneOf"; @@ -37,6 +39,7 @@ export type SerializedSchema = | SerializedArraySchema | SerializedUnionSchema | SerializedRichTextSchema + | SerializedSvgSchema | SerializedRecordSchema | SerializedKeyOfSchema | SerializedFileSchema @@ -67,13 +70,15 @@ export type SchemaAssertResult = : // eslint-disable-next-line @typescript-eslint/no-empty-object-type Src extends RichTextSource<{}> ? GenericRichTextSourceNode[] - : Src extends Primitives - ? Src - : Src extends Array - ? SelectorSource[] - : Src extends { [key: string]: SelectorSource } - ? { [key in keyof Src]: SelectorSource } - : never; + : Src extends SvgSource + ? GenericSvgSource + : Src extends Primitives + ? Src + : Src extends Array + ? SelectorSource[] + : Src extends { [key: string]: SelectorSource } + ? { [key in keyof Src]: SelectorSource } + : never; success: true; } | { success: false; errors: Record }; diff --git a/packages/core/src/schema/readonly.test.ts b/packages/core/src/schema/readonly.test.ts index a52465395..a6430c960 100644 --- a/packages/core/src/schema/readonly.test.ts +++ b/packages/core/src/schema/readonly.test.ts @@ -40,6 +40,7 @@ describe("Schema.readonly()", () => { expect(s.number().readonly()["executeSerialize"]().readonly).toBe(true); expect(s.boolean().readonly()["executeSerialize"]().readonly).toBe(true); expect(s.date().readonly()["executeSerialize"]().readonly).toBe(true); + expect(s.svg().readonly()["executeSerialize"]().readonly).toBe(true); expect(s.array(s.string()).readonly()["executeSerialize"]().readonly).toBe( true, ); diff --git a/packages/core/src/schema/svg.test.ts b/packages/core/src/schema/svg.test.ts new file mode 100644 index 000000000..d19d3e63b --- /dev/null +++ b/packages/core/src/schema/svg.test.ts @@ -0,0 +1,162 @@ +import { initVal } from "../initVal"; +import { deserializeSchema } from "./deserialize"; +import { svgVarsCss, SvgSchema } from "./svg"; +import { SourcePath } from "../val"; + +const { s } = initVal(); + +const path = "/test" as SourcePath; + +const iconSchema = s.svg({ + width: 24, + height: 24, + variables: { brand: "#0055ff", line: "currentColor" }, +}); + +const icon = { + viewBox: "0 0 24 24", + width: 24, + height: 24, + children: [ + { + tag: "path" as const, + attrs: { + d: "M4 12h16", + stroke: { var: "line" as const }, + fill: "none" as const, + }, + children: [], + }, + ], +}; + +describe("SvgSchema.assert", () => { + test("accepts a well formed svg", () => { + expect(iconSchema["executeAssert"](path, icon)).toStrictEqual({ + success: true, + data: icon, + }); + }); + + test("only checks the root type, not the nodes", () => { + // A node with an unsupported tag still asserts: assert is a runtime type + // check, validate is what checks values. + const result = iconSchema["executeAssert"](path, { + viewBox: "0 0 24 24", + width: null, + height: null, + children: [{ tag: "script", attrs: {}, children: [] }], + }); + expect(result.success).toBe(true); + }); + + test("rejects a non object", () => { + expect(iconSchema["executeAssert"](path, "nope").success).toBe(false); + expect(iconSchema["executeAssert"](path, []).success).toBe(false); + expect(iconSchema["executeAssert"](path, null).success).toBe(false); + }); + + test("rejects an object without viewBox or children", () => { + expect(iconSchema["executeAssert"](path, { children: [] }).success).toBe( + false, + ); + expect( + iconSchema["executeAssert"](path, { viewBox: "0 0 1 1" }).success, + ).toBe(false); + }); + + test("accepts null when nullable", () => { + expect(iconSchema.nullable()["executeAssert"](path, null)).toStrictEqual({ + success: true, + data: null, + }); + }); +}); + +describe("SvgSchema serialization", () => { + test("serializes its options", () => { + expect(iconSchema["executeSerialize"]()).toStrictEqual({ + type: "svg", + opt: false, + options: { + width: 24, + height: 24, + variables: { brand: "#0055ff", line: "currentColor" }, + }, + customValidate: false, + readonly: false, + hidden: false, + description: undefined, + }); + }); + + test("round trips through deserializeSchema", () => { + const serialized = iconSchema["executeSerialize"](); + const deserialized = deserializeSchema(serialized); + expect(deserialized).toBeInstanceOf(SvgSchema); + expect(deserialized["executeSerialize"]()).toStrictEqual(serialized); + // and it still validates the same way + expect(deserialized["executeValidate"](path, icon)).toBe(false); + }); + + test("renders nothing special", () => { + expect(iconSchema["executeRender"]()).toStrictEqual({}); + }); +}); + +describe("SvgSchema builders", () => { + test("width / height / aspectRatio return new instances", () => { + const base = s.svg({ variables: {} }); + const constrained = base.width(16).height(16).aspectRatio("1:1"); + expect(constrained["executeSerialize"]()).toMatchObject({ + options: { width: 16, height: 16, aspectRatio: "1:1" }, + }); + expect(base["executeSerialize"]()).toMatchObject({ options: {} }); + }); + + test("describe / readonly / hidden are serialized", () => { + expect( + iconSchema.describe("An icon").readonly().hidden()["executeSerialize"](), + ).toMatchObject({ + description: "An icon", + readonly: true, + hidden: true, + }); + }); + + test("custom validate functions run", () => { + const schema = s + .svg({ variables: {} }) + .validate((src) => (src.children.length === 0 ? "Icon is empty" : false)); + const errors = schema["executeValidate"](path, { + viewBox: "0 0 24 24", + width: null, + height: null, + children: [], + }); + expect(errors).toStrictEqual({ + [path]: [{ message: "Icon is empty", value: expect.anything() }], + }); + }); +}); + +describe("svgVarsCss", () => { + test("emits the declared example colors", () => { + expect(svgVarsCss(iconSchema)).toBe( + ":root{--val-svg-brand:#0055ff;--val-svg-line:currentColor}", + ); + }); + + test("accepts a custom selector, for dark mode blocks", () => { + expect( + svgVarsCss( + s.svg({ variables: { brand: "#6699ff" } }), + '[data-theme="dark"]', + ), + ).toBe('[data-theme="dark"]{--val-svg-brand:#6699ff}'); + }); + + test("is empty when there are no variables", () => { + expect(svgVarsCss(s.svg())).toBe(""); + }); +}); diff --git a/packages/core/src/schema/svg.ts b/packages/core/src/schema/svg.ts new file mode 100644 index 000000000..bd1054ef4 --- /dev/null +++ b/packages/core/src/schema/svg.ts @@ -0,0 +1,727 @@ +import { + CustomValidateFunction, + Schema, + SchemaAssertResult, + SerializedSchema, +} from "."; +import { ReifiedRender } from "../render"; +import { + SvgOptions, + SvgSource, + SvgTag, + SvgVariable, + SVG_VAL_PATH, + isSvgVarRef, + svgVariableValue, +} from "../source/svg"; +import { SourcePath } from "../val"; +import { + ValidationError, + ValidationErrors, +} from "./validation/ValidationError"; +import { + SVG_ASPECT_RATIO_EPSILON, + SVG_DEFAULT_MAX_DEPTH, + SVG_DEFAULT_MAX_NODES, + SVG_ATTR_NAME_PATTERN, + SVG_COLOR_ATTRS, + SVG_ENUM_ATTRS, + SVG_KEYWORD_COLORS, + SVG_NUMBER_ATTRS, + SVG_STRING_ATTRS, + isAllowedSvgAttr, + isSvgTag, + parseSvgViewBox, +} from "./svg/allowlist"; +import { unsafeCreateSourcePath } from "../selector/SelectorProxy"; + +export type SerializedSvgSchema = { + type: "svg"; + options?: SvgOptions; + opt: boolean; + customValidate?: boolean; + readonly?: boolean; + hidden?: boolean; + description?: string; +}; + +type SizeConstraint = number | { min?: number; max?: number }; + +function checkSize( + actual: number, + constraint: SizeConstraint | undefined, +): string | null { + if (constraint === undefined) { + return null; + } + if (typeof constraint === "number") { + return actual === constraint + ? null + : `Expected ${constraint}, got ${actual}`; + } + if (constraint.min !== undefined && actual < constraint.min) { + return `Expected at least ${constraint.min}, got ${actual}`; + } + if (constraint.max !== undefined && actual > constraint.max) { + return `Expected at most ${constraint.max}, got ${actual}`; + } + return null; +} + +function parseAspectRatio( + aspectRatio: number | `${number}:${number}`, +): number | null { + if (typeof aspectRatio === "number") { + return Number.isFinite(aspectRatio) && aspectRatio > 0 ? aspectRatio : null; + } + const parts = aspectRatio.split(":"); + if (parts.length !== 2) { + return null; + } + const w = Number(parts[0]); + const h = Number(parts[1]); + if (!Number.isFinite(w) || !Number.isFinite(h) || h === 0 || w <= 0) { + return null; + } + return w / h; +} + +/** + * Builds the CSS custom properties that back an svg schema's color variables. + * + * `ValSvg` renders `fill="var(--val-svg-, currentColor)"`, so an app emits + * this once - typically a ` +
+ + + + `); + if (result.status !== "success") throw new Error(result.message); + expect(result.source.children).toHaveLength(1); + expect(result.droppedTags).toStrictEqual([ + "script", + "style", + "foreignObject", + "image", + "a", + ]); + }); + + test("drops event handlers and other unsupported attributes", () => { + const result = parse( + ``, + ); + if (result.status !== "success") throw new Error(result.message); + expect(result.source.children[0].attrs).toStrictEqual({ + cx: 4, + cy: 4, + r: 4, + }); + expect(result.droppedAttrs.map((a) => a.attr).sort()).toStrictEqual([ + "class", + "data-x", + "id", + "onclick", + "onload", + "style", + "xlink:href", + ]); + }); + + test("drops a d attribute containing characters a path cannot have", () => { + const result = parse( + ``, + ); + if (result.status !== "success") throw new Error(result.message); + expect(result.source.children[0].attrs).not.toHaveProperty("d"); + }); + + test("drops an out of range enum value", () => { + const result = parse( + ``, + ); + if (result.status !== "success") throw new Error(result.message); + expect(result.source.children[0].attrs).not.toHaveProperty( + "stroke-linecap", + ); + }); + }); + + describe("rejections", () => { + test.each([ + [ + "entity declarations", + `]>`, + ], + [ + "a doctype with an internal subset", + ` ]>`, + ], + ["an unclosed tag", ``], + ["a mismatched closing tag", ``], + ["a non svg root", `
`], + ["no viewBox and no size", ``], + ["a malformed viewBox", ``], + ["empty input", ``], + ])("rejects %s", (_name, markup) => { + expect(parse(markup).status).toBe("error"); + }); + + test("rejects an svg that exceeds the node budget", () => { + const many = Array.from( + { length: 30 }, + () => ``, + ).join(""); + const result = parseSvg(`${many}`, { + maxNodes: 10, + }); + expect(result).toMatchObject({ status: "error" }); + }); + + test("rejects an svg nested deeper than the budget", () => { + const deep = "".repeat(10) + "".repeat(10); + const result = parseSvg(`${deep}`, { + maxDepth: 3, + }); + expect(result).toMatchObject({ status: "error" }); + }); + + test("rejects a d attribute larger than the cap", () => { + const huge = "M0 0 " + "L1 1 ".repeat(30_000); + const result = parse(``); + if (result.status !== "success") throw new Error(result.message); + expect(result.source.children[0].attrs).not.toHaveProperty("d"); + }); + }); +}); + +describe("svgToString", () => { + const markup = ` + + + +`; + + test("round trips parse -> string -> parse", () => { + const first = parse(markup); + if (first.status !== "success") throw new Error(first.message); + const printed = svgToString(first.source, { pretty: true }); + expect(printed).toBe(markup); + const second = parse(printed); + if (second.status !== "success") throw new Error(second.message); + expect(second.source).toStrictEqual(first.source); + }); + + test("resolves variables to concrete colors when asked", () => { + const result = parse( + ``, + ); + if (result.status !== "success") throw new Error(result.message); + expect( + svgToString(result.source, { variables: options.variables }), + ).toContain('stroke="#0055ff"'); + }); + + test("escapes attribute values", () => { + expect( + svgToString({ + viewBox: '0 0 1 1" onload="alert(1)', + width: null, + height: null, + children: [], + }), + ).not.toContain('onload="alert(1)"'); + }); +}); diff --git a/packages/shared/src/internal/svg/parseSvg.ts b/packages/shared/src/internal/svg/parseSvg.ts new file mode 100644 index 000000000..834606e84 --- /dev/null +++ b/packages/shared/src/internal/svg/parseSvg.ts @@ -0,0 +1,333 @@ +import { + isAllowedSvgAttr, + isSvgTag, + parseSvgViewBox, + svgVariableValue, + SvgNode, + SvgOptions, + SvgSource, + SvgTag, + SvgVariable, + SVG_COLOR_ATTRS, + SVG_DEFAULT_MAX_DEPTH, + SVG_DEFAULT_MAX_NODES, + SVG_ENUM_ATTRS, + SVG_KEYWORD_COLORS, + SVG_NUMBER_ATTRS, + SVG_STRING_ATTRS, +} from "@valbuild/core"; +import { colorDistance, normalizeColor } from "./colors"; +import { parseXml, XmlElement } from "./xml"; + +/** A literal color that could not be mapped onto a declared variable. */ +export type SvgUnmatchedColor = { + /** The color exactly as it appeared in the markup. */ + raw: string; + /** Canonical `#rrggbb` form, when we could parse it. */ + normalized: string | null; + /** How many attributes used it. */ + count: number; +}; + +/** An attribute that was dropped because the allowlist does not include it. */ +export type SvgDroppedAttr = { + tag: string; + attr: string; +}; + +export type ParseSvgResult = + | { + status: "success"; + source: SvgSource; + /** Literal colors with no home. The editor prompts for these. */ + unmatched: SvgUnmatchedColor[]; + /** Tags removed by the allowlist. */ + droppedTags: string[]; + /** Attributes removed by the allowlist. */ + droppedAttrs: SvgDroppedAttr[]; + } + | { status: "error"; message: string }; + +/** + * How a literal color should be resolved, when the caller has already made a + * decision for it (the editor's unmatched-color prompt). + */ +export type SvgColorOverride = + | { type: "var"; var: string } + | { type: "keyword"; keyword: "currentColor" | "none" | "transparent" } + | { type: "literal" }; + +export type ParseSvgOptions = { + /** Keyed by the *normalized* color, or by the raw string if unparseable. */ + overrides?: Record; +}; + +type VariableEntry = { + name: string; + normalized: string | null; + keyword: string | null; + matches: (string | null)[]; + tolerance: number; +}; + +function variableEntries(options: SvgOptions): VariableEntry[] { + return Object.entries(options.variables ?? {}).map(([name, variable]) => { + const spec: SvgVariable = variable; + const value = svgVariableValue(spec); + const extra = typeof spec === "string" ? [] : (spec.match ?? []); + return { + name, + normalized: normalizeColor(value), + keyword: SVG_KEYWORD_COLORS.includes(value) ? value : null, + matches: extra.map((m) => normalizeColor(m)), + tolerance: typeof spec === "string" ? 0 : (spec.tolerance ?? 0), + }; + }); +} + +function numberOf(raw: string): number | null { + // Strip a unit: exports commonly write width="24px". + const value = raw.trim().replace(/px$/i, ""); + if (value === "") { + return null; + } + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + +/** + * Parses svg markup into an {@link SvgSource}, mapping literal colors onto the + * schema's declared variables. + * + * Everything outside the allowlist is dropped rather than rejected, so pasting + * a real-world export mostly works; what was dropped is reported so the editor + * can say so. Colors that cannot be mapped are reported too - we never guess a + * nearest variable unless that variable opted in with `tolerance`. + */ +export function parseSvg( + markup: string, + options: O, + parseOptions: ParseSvgOptions = {}, +): ParseSvgResult { + const parsed = parseXml(markup); + if (parsed.status === "error") { + return parsed; + } + const root = parsed.root; + if (root.tag.toLowerCase() !== "svg") { + return { + status: "error", + message: `Expected a root element, got <${root.tag}>`, + }; + } + + const viewBoxAttr = root.attrs.viewBox ?? root.attrs.viewbox; + const widthAttr = root.attrs.width ? numberOf(root.attrs.width) : null; + const heightAttr = root.attrs.height ? numberOf(root.attrs.height) : null; + let viewBox = viewBoxAttr?.trim().replace(/[\s,]+/g, " "); + if (!viewBox && widthAttr !== null && heightAttr !== null) { + viewBox = `0 0 ${widthAttr} ${heightAttr}`; + } + if (!viewBox) { + return { + status: "error", + message: "The svg has no viewBox, and no width/height to derive one from", + }; + } + if (!parseSvgViewBox(viewBox)) { + return { status: "error", message: `Invalid viewBox: '${viewBox}'` }; + } + + const variables = variableEntries(options); + const literals = options.literals ?? "forbid"; + const overrides = parseOptions.overrides ?? {}; + const unmatched = new Map(); + const droppedTags: string[] = []; + const droppedAttrs: SvgDroppedAttr[] = []; + const maxNodes = options.maxNodes ?? SVG_DEFAULT_MAX_NODES; + const maxDepth = options.maxDepth ?? SVG_DEFAULT_MAX_DEPTH; + let nodeCount = 0; + let exceeded = false; + + const resolveColor = (raw: string): unknown | undefined => { + const trimmed = raw.trim(); + // Read back the form svgToString emits, so "copy as svg" and re-paste is + // lossless rather than silently dropping every variable. + const varRef = /^var\(\s*--val-svg-([a-zA-Z0-9_-]+)\s*(?:,[^)]*)?\)$/.exec( + trimmed, + ); + if (varRef) { + const name = varRef[1]; + if (variables.some((v) => v.name === name)) { + return { var: name }; + } + } + const keyword = SVG_KEYWORD_COLORS.find( + (k) => k.toLowerCase() === trimmed.toLowerCase(), + ); + if (keyword) { + return keyword; + } + const normalized = normalizeColor(trimmed); + const key = normalized ?? trimmed; + const override = overrides[key]; + if (override) { + if (override.type === "var") { + return { var: override.var }; + } + if (override.type === "keyword") { + return override.keyword; + } + return normalized ?? trimmed; + } + if (normalized) { + const exact = variables.find( + (v) => + v.normalized === normalized || + v.matches.includes(normalized) || + (v.keyword !== null && + v.keyword.toLowerCase() === trimmed.toLowerCase()), + ); + if (exact) { + return { var: exact.name }; + } + let best: { name: string; distance: number } | null = null; + for (const variable of variables) { + if (variable.tolerance <= 0 || !variable.normalized) { + continue; + } + const distance = colorDistance(normalized, variable.normalized); + if (distance === null || distance > variable.tolerance) { + continue; + } + if (!best || distance < best.distance) { + best = { name: variable.name, distance }; + } + } + if (best) { + return { var: best.name }; + } + } + const canKeepLiteral = + literals === "allow" || + (Array.isArray(literals) && + (literals as readonly string[]).includes(normalized ?? trimmed)); + if (canKeepLiteral) { + return normalized ?? trimmed; + } + const existing = unmatched.get(key); + if (existing) { + existing.count++; + } else { + unmatched.set(key, { raw: trimmed, normalized, count: 1 }); + } + return undefined; + }; + + const convertAttrs = ( + tag: SvgTag, + element: XmlElement, + ): Record => { + const attrs: Record = {}; + for (const [rawName, rawValue] of Object.entries(element.attrs)) { + const name = rawName.toLowerCase(); + if (!isAllowedSvgAttr(tag, name)) { + droppedAttrs.push({ tag, attr: rawName }); + continue; + } + if ((SVG_COLOR_ATTRS as readonly string[]).includes(name)) { + const color = resolveColor(rawValue); + if (color !== undefined) { + attrs[name] = color; + } + continue; + } + if ((SVG_NUMBER_ATTRS as readonly string[]).includes(name)) { + const n = numberOf(rawValue); + if (n !== null) { + attrs[name] = n; + } else { + droppedAttrs.push({ tag, attr: rawName }); + } + continue; + } + if (name in SVG_ENUM_ATTRS) { + const allowed = SVG_ENUM_ATTRS[ + name as keyof typeof SVG_ENUM_ATTRS + ] as readonly string[]; + const value = rawValue.trim(); + if (allowed.includes(value)) { + attrs[name] = value; + } else { + droppedAttrs.push({ tag, attr: rawName }); + } + continue; + } + if (name in SVG_STRING_ATTRS) { + const { pattern, maxLength } = + SVG_STRING_ATTRS[name as keyof typeof SVG_STRING_ATTRS]; + const value = rawValue.trim(); + if (value.length <= maxLength && pattern.test(value)) { + attrs[name] = value; + } else { + droppedAttrs.push({ tag, attr: rawName }); + } + continue; + } + droppedAttrs.push({ tag, attr: rawName }); + } + return attrs; + }; + + const convert = (elements: XmlElement[], depth: number): unknown[] => { + const nodes: unknown[] = []; + for (const element of elements) { + const tag = element.tag.toLowerCase(); + if (!isSvgTag(tag)) { + if (!droppedTags.includes(element.tag)) { + droppedTags.push(element.tag); + } + continue; + } + if (depth > maxDepth) { + exceeded = true; + return nodes; + } + nodeCount++; + if (nodeCount > maxNodes) { + exceeded = true; + return nodes; + } + nodes.push({ + tag, + attrs: convertAttrs(tag, element), + children: convert(element.children, depth + 1), + }); + } + return nodes; + }; + + const children = convert(root.children, 1); + if (exceeded) { + return { + status: "error", + message: `The svg is too large: max is ${maxNodes} nodes and ${maxDepth} levels of nesting`, + }; + } + + const box = parseSvgViewBox(viewBox); + const source = { + viewBox, + width: widthAttr ?? box?.width ?? null, + height: heightAttr ?? box?.height ?? null, + children: children as SvgNode[], + } as SvgSource; + + return { + status: "success", + source, + unmatched: Array.from(unmatched.values()), + droppedTags, + droppedAttrs, + }; +} diff --git a/packages/shared/src/internal/svg/svgToString.ts b/packages/shared/src/internal/svg/svgToString.ts new file mode 100644 index 000000000..f87c24088 --- /dev/null +++ b/packages/shared/src/internal/svg/svgToString.ts @@ -0,0 +1,150 @@ +import { + isSvgVarRef, + svgVariableValue, + GenericSvgNode, + GenericSvgSource, + SvgOptions, + SvgVariable, + SVG_COLOR_ATTRS, + SVG_VAL_PATH, +} from "@valbuild/core"; +import { type JSONValue } from "@valbuild/core/patch"; +import { encodeXmlText } from "./xml"; + +export type SvgToStringOptions = { + /** + * Resolve variables to concrete colors instead of `var(--val-svg-*)`. + * Pass the schema's variables to get markup that stands on its own - which is + * what "copy as svg" in the editor wants. + */ + variables?: Record; + /** Indent the output. Off by default. */ + pretty?: boolean; +}; + +function colorToAttrValue( + value: unknown, + variables: Record | undefined, +): string | null { + if (isSvgVarRef(value)) { + const declared = variables?.[value.var]; + if (declared !== undefined) { + return svgVariableValue(declared); + } + return `var(--val-svg-${value.var}, currentColor)`; + } + if (typeof value === "string") { + return value; + } + return null; +} + +/** + * Serializes an svg source back to markup. + * + * The inverse of `parseSvg` for everything the allowlist keeps, so the two can + * be round-trip tested against each other. + */ +export function svgToString( + source: GenericSvgSource, + options: SvgToStringOptions = {}, +): string { + const { variables, pretty } = options; + const nl = pretty ? "\n" : ""; + const pad = (depth: number) => (pretty ? " ".repeat(depth) : ""); + + const renderNode = (node: GenericSvgNode, depth: number): string => { + const attrs: string[] = []; + for (const [name, value] of Object.entries(node.attrs ?? {})) { + const rendered = (SVG_COLOR_ATTRS as readonly string[]).includes(name) + ? colorToAttrValue(value, variables) + : typeof value === "number" + ? String(value) + : typeof value === "string" + ? value + : null; + if (rendered === null) { + continue; + } + attrs.push(`${name}="${encodeXmlText(rendered)}"`); + } + const open = [node.tag, ...attrs].join(" "); + const children = node.children ?? []; + if (children.length === 0) { + return `${pad(depth)}<${open}/>`; + } + const inner = children + .map((child) => renderNode(child, depth + 1)) + .join(nl); + return `${pad(depth)}<${open}>${nl}${inner}${nl}${pad(depth)}`; + }; + + const rootAttrs = [ + 'xmlns="http://www.w3.org/2000/svg"', + `viewBox="${encodeXmlText(source.viewBox)}"`, + ]; + if (source.width !== null && source.width !== undefined) { + rootAttrs.push(`width="${source.width}"`); + } + if (source.height !== null && source.height !== undefined) { + rootAttrs.push(`height="${source.height}"`); + } + const children = (source.children ?? []) + .map((child) => renderNode(child, 1)) + .join(nl); + if (!children) { + return ``; + } + return `${nl}${children}${nl}`; +} + +/** + * Rebuilds an svg source as plain, mutable json. + * + * Patches are typed as `JSONValue`, while a source is `Json` (deeply readonly) + * and may carry the stega-injected path field. Rebuilding is how we bridge the + * two without a type assertion, and it drops `_valPath` on the way. + */ +export function svgSourceToJson(source: GenericSvgSource): JSONValue { + const node = (n: GenericSvgNode): JSONValue => { + const attrs: { [key: string]: JSONValue } = {}; + for (const [name, value] of Object.entries(n.attrs ?? {})) { + if (typeof value === "string" || typeof value === "number") { + attrs[name] = value; + } else if (isSvgVarRef(value)) { + attrs[name] = { var: value.var }; + } + } + return { + tag: n.tag, + attrs, + children: (n.children ?? []).map(node), + }; + }; + return { + viewBox: source.viewBox, + width: source.width ?? null, + height: source.height ?? null, + children: (source.children ?? []).map(node), + }; +} + +/** + * Strips the stega-injected path field, so a source can be compared or written + * back to a module. + */ +export function stripSvgValPath(source: T): T { + if (!(SVG_VAL_PATH in source)) { + return source; + } + const copy = { ...source } as Record; + delete copy[SVG_VAL_PATH]; + return copy as T; +} + +/** Convenience: the variables of a schema, in the shape `svgToString` wants. */ +export function svgVariablesOf( + options: SvgOptions | undefined, +): Record | undefined { + return options?.variables; +} diff --git a/packages/shared/src/internal/svg/xml.ts b/packages/shared/src/internal/svg/xml.ts new file mode 100644 index 000000000..273c8ff58 --- /dev/null +++ b/packages/shared/src/internal/svg/xml.ts @@ -0,0 +1,208 @@ +/** + * A minimal, dependency free XML reader, scoped to what an exported svg + * contains. + * + * We hand roll rather than take a dependency because `@valbuild/shared` ships + * into every Val user's server bundle and svg-as-xml is a small grammar: no + * optional end tags, no implicit closing, no raw text elements. `DOMParser` is + * deliberately not used - it does not exist in node or QuickJS, and two + * implementations would be two divergence surfaces. + * + * This reader is *not* a security boundary. Its output is filtered against the + * allowlist in `@valbuild/core` before anything else looks at it. + */ + +export type XmlElement = { + tag: string; + attrs: Record; + children: XmlElement[]; +}; + +export type XmlParseResult = + | { status: "success"; root: XmlElement } + | { status: "error"; message: string }; + +const ENTITIES: Readonly> = { + amp: "&", + lt: "<", + gt: ">", + quot: '"', + apos: "'", +}; + +export function decodeXmlEntities(input: string): string { + return input.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, body) => { + if (body.startsWith("#x") || body.startsWith("#X")) { + const code = parseInt(body.slice(2), 16); + return Number.isFinite(code) ? String.fromCodePoint(code) : match; + } + if (body.startsWith("#")) { + const code = parseInt(body.slice(1), 10); + return Number.isFinite(code) ? String.fromCodePoint(code) : match; + } + const named = ENTITIES[body as keyof typeof ENTITIES]; + return named === undefined ? match : named; + }); +} + +export function encodeXmlText(input: string): string { + return input + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +/** + * Parses xml and returns the single root element. + * + * Rejects entity declarations and doctypes with an internal subset outright: + * those are the XXE / billion-laughs vectors, and a parser that silently + * ignores them is worse than no parser at all. + */ +export function parseXml(input: string): XmlParseResult { + if (/ ({ + status: "error", + message, + }); + + while (i < src.length) { + const lt = src.indexOf("<", i); + if (lt === -1) { + break; + } + i = lt; + if (src.startsWith("", i + 4); + if (end === -1) { + return error("Unterminated comment"); + } + i = end + 3; + continue; + } + if (src.startsWith("", i)) { + const end = src.indexOf("", i + 9); + if (end === -1) { + return error("Unterminated CDATA section"); + } + i = end + 3; + continue; + } + if (src.startsWith("", i + 2); + if (end === -1) { + return error("Unterminated processing instruction"); + } + i = end + 2; + continue; + } + if (src.startsWith(". An internal subset ('[' before '>') is rejected. + const gt = src.indexOf(">", i + 2); + const bracket = src.indexOf("[", i + 2); + if (gt === -1) { + return error("Unterminated declaration"); + } + if (bracket !== -1 && bracket < gt) { + return error("Doctype with an internal subset is not allowed"); + } + i = gt + 1; + continue; + } + if (src.startsWith("", i + 2); + if (gt === -1) { + return error("Unterminated closing tag"); + } + const tag = src.slice(i + 2, gt).trim(); + const open = stack.pop(); + if (!open) { + return error(`Unexpected closing tag '${tag}'`); + } + if (open.tag !== tag) { + return error( + `Mismatched closing tag: expected '${open.tag}', got '${tag}'`, + ); + } + i = gt + 1; + continue; + } + + // Opening tag. Scan to '>' while respecting quoted attribute values. + let j = i + 1; + let quote: string | null = null; + while (j < src.length) { + const ch = src[j]; + if (quote) { + if (ch === quote) { + quote = null; + } + } else if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === ">") { + break; + } + j++; + } + if (j >= src.length) { + return error("Unterminated tag"); + } + let body = src.slice(i + 1, j); + const selfClosing = body.trimEnd().endsWith("/"); + if (selfClosing) { + body = body.trimEnd().slice(0, -1); + } + const nameMatch = /^([^\s/>]+)/.exec(body); + if (!nameMatch) { + return error("Malformed tag"); + } + const tag = nameMatch[1]; + const attrs: Record = {}; + const attrSrc = body.slice(nameMatch[1].length); + const attrRe = /([^\s=/]+)\s*(?:=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g; + let attrMatch: RegExpExecArray | null; + while ((attrMatch = attrRe.exec(attrSrc)) !== null) { + const name = attrMatch[1]; + if (!name) { + continue; + } + const raw = attrMatch[3] ?? attrMatch[4] ?? attrMatch[5] ?? ""; + // Later duplicates lose, matching how browsers read xml attributes. + if (!(name in attrs)) { + attrs[name] = decodeXmlEntities(raw); + } + } + const element: XmlElement = { tag, attrs, children: [] }; + const parent = stack[stack.length - 1]; + if (parent) { + parent.children.push(element); + } else if (root) { + return error("Expected a single root element"); + } else { + root = element; + } + if (!selfClosing) { + stack.push(element); + } + i = j + 1; + } + + if (stack.length > 0) { + return error(`Unclosed tag '${stack[stack.length - 1].tag}'`); + } + if (!root) { + return error("No element found"); + } + return { status: "success", root }; +} diff --git a/packages/shared/src/internal/zod/SerializedSchema.ts b/packages/shared/src/internal/zod/SerializedSchema.ts index 4303c7d56..0bdf42360 100644 --- a/packages/shared/src/internal/zod/SerializedSchema.ts +++ b/packages/shared/src/internal/zod/SerializedSchema.ts @@ -17,6 +17,7 @@ import { type SerializedDateSchema as SerializedDateSchemaT, type SerializedDateTimeSchema as SerializedDateTimeSchemaT, type SerializedImageSchema as SerializedImageSchemaT, + type SerializedSvgSchema as SerializedSvgSchemaT, } from "@valbuild/core"; import { SourcePath } from "./SourcePath"; @@ -176,6 +177,41 @@ export const SerializedRichTextSchema: z.ZodType = hidden: z.boolean().optional(), }); +export const SvgVariable = z.union([ + z.string(), + z.object({ + value: z.string(), + match: z.array(z.string()).optional(), + tolerance: z.number().optional(), + description: z.string().optional(), + }), +]); +const SvgSizeConstraint = z.union([ + z.number(), + z.object({ min: z.number().optional(), max: z.number().optional() }), +]); +export const SvgOptions = z.object({ + variables: z.record(z.string(), SvgVariable).optional(), + literals: z + .union([z.literal("forbid"), z.literal("allow"), z.array(z.string())]) + .optional(), + width: SvgSizeConstraint.optional(), + height: SvgSizeConstraint.optional(), + aspectRatio: z.union([z.number(), z.string()]).optional(), + maxNodes: z.number().optional(), + maxDepth: z.number().optional(), +}); +export const SerializedSvgSchema: z.ZodType = z.object({ + type: z.literal("svg"), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + options: SvgOptions.optional() as any, + opt: z.boolean(), + customValidate: z.boolean().optional(), + readonly: z.boolean().optional(), + hidden: z.boolean().optional(), + description: z.string().optional(), +}); + export const SerializedRecordSchema: z.ZodType = z.lazy(() => { return z @@ -296,6 +332,7 @@ export const SerializedSchema: z.ZodType = z.union([ SerializedArraySchema, SerializedUnionSchema, SerializedRichTextSchema, + SerializedSvgSchema, SerializedRecordSchema, SerializedKeyOfSchema, SerializedRouteSchema, diff --git a/packages/ui/spa/ValSyncEngine.ts b/packages/ui/spa/ValSyncEngine.ts index 7892c4898..8f6226611 100644 --- a/packages/ui/spa/ValSyncEngine.ts +++ b/packages/ui/spa/ValSyncEngine.ts @@ -3437,6 +3437,7 @@ const nonInterDependentTypes = [ "date", "dateTime", "richtext", + "svg", "file", "image", ]; diff --git a/packages/ui/spa/components/AnyField.tsx b/packages/ui/spa/components/AnyField.tsx index 935cf2671..4448950ee 100644 --- a/packages/ui/spa/components/AnyField.tsx +++ b/packages/ui/spa/components/AnyField.tsx @@ -7,6 +7,7 @@ import { NumberField } from "./fields/NumberField"; import { ObjectFields } from "./fields/ObjectFields"; import { RecordFields } from "./fields/RecordFields"; import { RichTextField } from "./fields/RichTextField"; +import { SvgField } from "./fields/SvgField"; import { RouteField } from "./fields/RouteField"; import { StringField } from "./fields/StringField"; import { UnionField } from "./fields/UnionField"; @@ -122,6 +123,8 @@ export function AnyField({ {...leafProps} /> ); + } else if (schema.type === "svg") { + leaf = ; } else if (schema.type === "date") { leaf = ; } else if (schema.type === "dateTime") { diff --git a/packages/ui/spa/components/NodeIcon.tsx b/packages/ui/spa/components/NodeIcon.tsx index efaf8b31b..e351b10a7 100644 --- a/packages/ui/spa/components/NodeIcon.tsx +++ b/packages/ui/spa/components/NodeIcon.tsx @@ -16,6 +16,7 @@ import { ToggleRight, HelpCircle, Layers, + Shapes, } from "lucide-react"; export function NodeIcon({ @@ -46,6 +47,8 @@ export function NodeIcon({ return ; case "union": return ; + case "svg": + return ; case "richtext": return ; case "record": diff --git a/packages/ui/spa/components/Preview.tsx b/packages/ui/spa/components/Preview.tsx index 19adebcae..e9d983b97 100644 --- a/packages/ui/spa/components/Preview.tsx +++ b/packages/ui/spa/components/Preview.tsx @@ -14,6 +14,7 @@ import { DateTimePreview } from "./fields/DateTimeField"; import { LiteralPreview } from "./fields/LiteralPreview"; import { RecordPreview } from "./fields/RecordFields"; import { RichTextPreview } from "./fields/RichTextField"; +import { SvgPreview } from "./fields/SvgField"; import { FilePreview } from "./fields/FileField"; import { Loader2 } from "lucide-react"; @@ -68,6 +69,8 @@ export function Preview({ return ; } else if (type === "richtext") { return ; + } else if (type === "svg") { + return ; } else if (type === "file") { return ; } else { diff --git a/packages/ui/spa/components/ValFieldProvider.tsx b/packages/ui/spa/components/ValFieldProvider.tsx index 62e95143d..e5d2a282c 100644 --- a/packages/ui/spa/components/ValFieldProvider.tsx +++ b/packages/ui/spa/components/ValFieldProvider.tsx @@ -823,6 +823,7 @@ type ShallowSource = { }; literal: string; richtext: unknown[]; + svg: { readonly [key: string]: Json }; }; function getShallowSourceAtSourcePath< @@ -941,6 +942,17 @@ function mapSource( status: "success", data: source as ShallowSource[SchemaType], }; + } else if (type === "svg") { + if (typeof source !== "object" || source === null || isJsonArray(source)) { + return { + status: "error", + error: `Expected svg (i.e. object), got ${typeof source}`, + }; + } + return { + status: "success", + data: source as ShallowSource[SchemaType], + }; } else if ( type === "date" || type === "dateTime" || diff --git a/packages/ui/spa/components/ValProvider.tsx b/packages/ui/spa/components/ValProvider.tsx index 4f67ac1c2..3869a5976 100644 --- a/packages/ui/spa/components/ValProvider.tsx +++ b/packages/ui/spa/components/ValProvider.tsx @@ -1385,6 +1385,7 @@ export type ShallowSource = EnsureAllTypes<{ }; literal: string; richtext: unknown[]; + svg: { readonly [key: string]: Json }; }>; export function useCurrentProfile() { @@ -1799,6 +1800,17 @@ function mapSource( status: "success", data: source as ShallowSource[SchemaType], }; + } else if (type === "svg") { + if (typeof source !== "object" || source === null || isJsonArray(source)) { + return { + status: "error", + error: `Expected svg (i.e. object), got ${typeof source}`, + }; + } + return { + status: "success", + data: source as ShallowSource[SchemaType], + }; } else if ( type === "date" || type === "dateTime" || diff --git a/packages/ui/spa/components/fields/SvgField.stories.tsx b/packages/ui/spa/components/fields/SvgField.stories.tsx new file mode 100644 index 000000000..141b3cc1d --- /dev/null +++ b/packages/ui/spa/components/fields/SvgField.stories.tsx @@ -0,0 +1,186 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { SerializedSvgSchema } from "@valbuild/core"; +import { useState } from "react"; +import { SvgColorMapper, SvgEditor, SvgRender } from "./SvgField"; +import type { GenericSvgSource } from "@valbuild/core"; + +const iconSchema: SerializedSvgSchema = { + type: "svg", + opt: false, + options: { + width: 24, + height: 24, + aspectRatio: "1:1", + variables: { + brand: "#0055ff", + line: "#1f2933", + surface: { value: "#ffffff", match: ["#fefefe"] }, + }, + }, +}; + +const bell: GenericSvgSource = { + viewBox: "0 0 24 24", + width: 24, + height: 24, + children: [ + { + tag: "path", + attrs: { + d: "M12 2.5A5.5 5.5 0 0 0 6.5 8v4.2L4.8 15.2a.6.6 0 0 0 .52.9h13.36a.6.6 0 0 0 .52-.9L17.5 12.2V8A5.5 5.5 0 0 0 12 2.5Z", + fill: { var: "brand" }, + }, + children: [], + }, + { + tag: "path", + attrs: { + d: "M9.6 18.5a2.4 2.4 0 0 0 4.8 0", + stroke: { var: "line" }, + "stroke-width": 1.6, + "stroke-linecap": "round", + fill: "none", + }, + children: [], + }, + { + tag: "circle", + attrs: { cx: 17.5, cy: 6, r: 2.6, fill: { var: "surface" } }, + children: [], + }, + ], +}; + +const meta: Meta = { + title: "Fields/SvgField", + component: SvgEditor, + parameters: { layout: "padded" }, +}; +export default meta; + +/** + * The field as it looks with an icon already stored: a live preview, the + * palette the schema declares, and a dark mode toggle that swaps the CSS + * variables the icon references. + */ +export const WithIcon: StoryObj = { + render: () => { + const [source, setSource] = useState(bell); + return ( +
+ +
+ ); + }, +}; + +/** An empty field, waiting for markup to be pasted or a file to be dropped. */ +export const Empty: StoryObj = { + render: () => { + const [source, setSource] = useState(null); + return ( +
+ +
+ ); + }, +}; + +/** + * Paste an export whose colors are not in the palette and the field asks where + * each one should go, rather than guessing. Press "Import svg" to see it. + */ +export const UnmatchedColors: StoryObj = { + render: () => { + const [source, setSource] = useState(null); + return ( +
+ +

+ Paste this, then press Import svg: +

+
+          {`
+  
+  
+`}
+        
+
+ ); + }, +}; + +/** The color mapper on its own, with two colors still to place. */ +export const ColorMapper: StoryObj = { + render: () => { + const [value, setValue] = useState({}); + return ( +
+ +
+ ); + }, +}; + +/** + * The same source at several sizes, and with the variables overridden - which + * is what `` and a dark mode stylesheet each do. + */ +export const Rendering: StoryObj = { + render: () => { + const variables = iconSchema.options?.variables ?? {}; + return ( +
+
+ {[16, 24, 32, 48, 64].map((size) => ( +
+ + {size}px +
+ ))} +
+
+
+ + example colors +
+
+ + dark mode +
+
+ + vars override +
+
+
+ ); + }, +}; diff --git a/packages/ui/spa/components/fields/SvgField.tsx b/packages/ui/spa/components/fields/SvgField.tsx new file mode 100644 index 000000000..8dab1a7c5 --- /dev/null +++ b/packages/ui/spa/components/fields/SvgField.tsx @@ -0,0 +1,559 @@ +import { + isSvgVarRef, + svgVariableValue, + GenericSvgNode, + GenericSvgSource, + SerializedSvgSchema, + SourcePath, + SvgVariable, + SVG_COLOR_ATTRS, +} from "@valbuild/core"; +import { + parseSvg, + svgSourceToJson, + svgToString, + type SvgColorOverride, + type SvgUnmatchedColor, +} from "@valbuild/shared/internal"; +import React, { useMemo, useRef, useState } from "react"; +import { Button } from "../designSystem/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../designSystem/select"; +import { cn } from "../designSystem/cn"; +import { FieldLoading } from "../FieldLoading"; +import { FieldNotFound } from "../FieldNotFound"; +import { FieldSchemaError } from "../FieldSchemaError"; +import { FieldSchemaMismatchError } from "../FieldSchemaMismatchError"; +import { FieldSourceError } from "../FieldSourceError"; +import { PreviewLoading, PreviewNull } from "../Preview"; +import { ValidationErrors } from "../ValidationError"; +import { + useAddPatch, + useSchemaAtPath, + useShallowSourceAtPath, +} from "../ValFieldProvider"; + +const KEYWORD_OPTIONS = ["currentColor", "none", "transparent"] as const; + +type Variables = Record; + +function isSvgSource(source: unknown): source is GenericSvgSource { + return ( + typeof source === "object" && + source !== null && + !Array.isArray(source) && + typeof (source as { viewBox?: unknown }).viewBox === "string" + ); +} + +function colorAttrValue( + value: unknown, + overrides: Record, +): string | null { + if (isSvgVarRef(value)) { + // In the editor we resolve variables eagerly, so the preview can show the + // example colors - and a dark mode override - without a stylesheet. + return overrides[value.var] ?? `var(--val-svg-${value.var}, currentColor)`; + } + if (typeof value === "string") { + return value; + } + return null; +} + +function buildNode( + node: GenericSvgNode, + key: number, + overrides: Record, +): React.ReactElement | null { + if (!node || typeof node !== "object" || typeof node.tag !== "string") { + return null; + } + const props: Record = { key }; + for (const [name, value] of Object.entries(node.attrs ?? {})) { + if ((SVG_COLOR_ATTRS as readonly string[]).includes(name)) { + const color = colorAttrValue(value, overrides); + if (color !== null) { + props[name] = color; + } + continue; + } + if (typeof value === "string" || typeof value === "number") { + props[name] = value; + } + } + const children = (node.children ?? []) + .map((child, i) => buildNode(child, i, overrides)) + .filter((child): child is React.ReactElement => child !== null); + return React.createElement( + node.tag, + props, + children.length > 0 ? children : undefined, + ); +} + +/** + * Renders an svg source. + * + * Deliberately a separate implementation from `ValSvg` in `@valbuild/react`: + * the editor cannot depend on that package, and it needs to resolve variables + * eagerly so the preview shows real colors rather than unresolved custom + * properties. + */ +export function SvgRender({ + source, + variables, + overrides, + size, + className, +}: { + source: GenericSvgSource; + variables?: Variables; + /** Per-variable color overrides, e.g. a dark mode preview. */ + overrides?: Record; + size?: number; + className?: string; +}) { + const resolved = useMemo(() => { + const map: Record = {}; + for (const [name, variable] of Object.entries(variables ?? {})) { + map[name] = svgVariableValue(variable); + } + return { ...map, ...overrides }; + }, [variables, overrides]); + if (!isSvgSource(source)) { + return null; + } + const children = (source.children ?? []) + .map((child, i) => buildNode(child, i, resolved)) + .filter((child): child is React.ReactElement => child !== null); + return React.createElement( + "svg", + { + xmlns: "http://www.w3.org/2000/svg", + viewBox: source.viewBox, + width: size ?? source.width ?? undefined, + height: size ?? source.height ?? undefined, + role: "presentation", + "aria-hidden": true, + className, + }, + children, + ); +} + +/** + * One row per literal color the import could not place, with the set of + * variables it may be assigned to. + * + * We never snap a color to the nearest variable on our own: a brand color that + * is quietly rewritten is worse than one the editor asks about. A variable can + * opt in to fuzzy matching with `tolerance`, and then it never reaches here. + */ +export function SvgColorMapper({ + unmatched, + variables, + value, + onChange, + allowLiterals, +}: { + unmatched: SvgUnmatchedColor[]; + variables: Variables; + value: Record; + onChange: (next: Record) => void; + allowLiterals: boolean; +}) { + if (unmatched.length === 0) { + return null; + } + const variableNames = Object.keys(variables); + return ( +
+
+ {unmatched.length === 1 + ? "1 color is not in the palette. Pick where it should go:" + : `${unmatched.length} colors are not in the palette. Pick where they should go:`} +
+ {unmatched.map((color) => { + const key = color.normalized ?? color.raw; + const current = value[key]; + const selected = + current === undefined + ? "" + : current.type === "var" + ? `var:${current.var}` + : current.type === "keyword" + ? `keyword:${current.keyword}` + : "literal"; + return ( +
+ + {color.raw} + + {color.count === 1 ? "1 use" : `${color.count} uses`} + + +
+ ); + })} +
+ ); +} + +/** + * The presentational half of the svg field: paste markup in, get a source out. + * + * Kept free of Val providers so it can be driven directly from storybook and + * from tests. + */ +export function SvgEditor({ + schema, + source, + onChange, + readonly, +}: { + schema: SerializedSvgSchema; + source: GenericSvgSource | null; + onChange: (source: GenericSvgSource) => void; + readonly?: boolean; +}) { + const variables = (schema.options?.variables ?? {}) as Variables; + const literals = schema.options?.literals ?? "forbid"; + const [markup, setMarkup] = useState(""); + const [error, setError] = useState(null); + const [notes, setNotes] = useState([]); + const [unmatched, setUnmatched] = useState([]); + const [overrides, setOverrides] = useState>( + {}, + ); + const [dark, setDark] = useState(false); + const fileInput = useRef(null); + + const darkOverrides = useMemo(() => { + if (!dark) { + return undefined; + } + // A stand-in for the app's dark mode stylesheet: invert the example colors + // so it is obvious which parts of the icon are actually themeable. + const map: Record = {}; + for (const name of Object.keys(variables)) { + map[name] = "#f5f5f5"; + } + return map; + }, [dark, variables]); + + const runImport = ( + input: string, + colorOverrides: Record, + ) => { + if (!input.trim()) { + setError("Paste some svg markup first"); + return; + } + const result = parseSvg(input, schema.options ?? {}, { + overrides: colorOverrides, + }); + if (result.status === "error") { + setError(result.message); + setUnmatched([]); + setNotes([]); + return; + } + setError(null); + setUnmatched(result.unmatched); + const nextNotes: string[] = []; + if (result.droppedTags.length > 0) { + nextNotes.push(`Removed unsupported: ${result.droppedTags.join(", ")}`); + } + if (result.droppedAttrs.length > 0) { + const attrs = Array.from(new Set(result.droppedAttrs.map((a) => a.attr))); + nextNotes.push(`Removed attributes: ${attrs.join(", ")}`); + } + setNotes(nextNotes); + if (result.unmatched.length === 0) { + onChange(result.source as unknown as GenericSvgSource); + } + }; + + const onOverridesChange = (next: Record) => { + setOverrides(next); + const allChosen = unmatched.every( + (color) => next[color.normalized ?? color.raw] !== undefined, + ); + if (allChosen) { + runImport(markup, next); + } + }; + + return ( +
+ {source && ( +
+
+ +
+
+
+ viewBox {source.viewBox} +
+
+ {Object.entries(variables).map(([name, variable]) => ( + + + {name} + + ))} +
+ +
+
+ )} + {!readonly && ( + <> +