diff --git a/.agent/rules.md b/.agent/rules.md index 2800b922e..c66d9bf57 100644 --- a/.agent/rules.md +++ b/.agent/rules.md @@ -45,6 +45,7 @@ ImageSource → ImageSelector FileSource → FileSelector RemoteSource → GenericSelector RichTextSource → RichTextSelector +SvgSource → SvgSelector SourceObject → ObjectSelector SourceArray → ArraySelector string/number/boolean → StringSelector/NumberSelector/BooleanSelector @@ -62,7 +63,8 @@ export type Source = | RemoteSource | FileSource | ImageSource - | RichTextSource; + | RichTextSource + | SvgSource; ``` **SelectorSource** (`packages/core/src/selector/index.ts`): @@ -77,6 +79,7 @@ export type SelectorSource = | FileSource | RemoteSource | RichTextSource + | SvgSource | GenericSelector; ``` @@ -127,6 +130,7 @@ Each Schema class validates and types its corresponding Source type: | `ImageSchema` | `ImageSource` | `s.image()` | | `FileSchema` | `FileSource` | `s.file()` | | `RichTextSchema` | `RichTextSource` | `s.richtext(options)` | +| `SvgSchema` | `SvgSource` | `s.svg(options)` | | `ObjectSchema` | `SourceObject` | `s.object({...})` | | `ArraySchema` | `SourceArray` | `s.array(schema)` | @@ -498,6 +502,53 @@ This handles both local and remote refs, with or without pending patches. The `M The `/api/val/files` endpoint (`ValServer.ts`) serves draft files by loading them from the patch directory (via `getBase64EncodedBinaryFileFromPatch`) and published files directly from the filesystem (`getBinaryFile`). No auth is required on this endpoint (patch IDs serve as unguessable tokens). +## Working with Svg + +`s.svg()` stores an svg as a **json node tree**, not a file. Colors are +**variables**, not baked hexes, so an icon can be rethemed at render time. + +### Shape + +```typescript +// packages/core/src/source/svg.ts +type SvgSource = { + viewBox: string; + width: number | null; // null, never undefined: undefined is not Json + height: number | null; + children: SvgNode[]; + readonly [SVG_VAL_PATH]?: string; // injected by stega, never stored +}; +type SvgNode = { tag: SvgTag; attrs: SvgAttrs; children: SvgNode[] }; +``` + +`SvgAttrs` is a **per-attribute mapped type**, not `Record`: +`fill` / `stroke` take `SvgColorValue`, geometry attributes take `number`, +`stroke-linecap` and friends take their enum, and `d` / `points` / `transform` / +`stroke-dasharray` are the only free strings. This makes the palette constraint +a _compile_ error, not only a validation error: with the default +`literals: "forbid"`, `fill: "#f00"` does not typecheck. + +### Rules when touching svg code + +1. **Never stega encode an svg.** Every string in one (`d`, `viewBox`, + `points`, `transform`) is machine parsed; invisible characters corrupt the + icon. `stegaEncode` returns the source untouched and attaches the path as + `SVG_VAL_PATH`, which `ValSvg` turns into `data-val-path`. +2. **The allowlist in `packages/core/src/schema/svg/allowlist.ts` is the entire + security boundary.** `ValSvg` builds React elements, and React renders + unknown attributes on host elements verbatim - `onload` does fire on svg + elements. It must stay a strict per-tag allowlist of exact attribute names, + never an `on*` denylist. Do not add a tag or attribute to it without + thinking about what it can load, execute or leak. +3. **The parser is not a security boundary.** `packages/shared/src/internal/svg` + parses, then everything goes through the allowlist, then through validation. + Keep that order. +4. **`ValSvg`'s `vars` is exhaustive when given**, exactly like + `ValRichText`'s `theme`. That is deliberate: a new variable should break the + call sites so someone revisits them. +5. **The palette lives only in the schema.** Do not mirror it into the source - + that is what creates drift and repair fixes. + ## Common Fixes ### "Type 'X' does not satisfy constraint 'Source'" diff --git a/.changeset/svg-schema.md b/.changeset/svg-schema.md new file mode 100644 index 000000000..a6ce3d118 --- /dev/null +++ b/.changeset/svg-schema.md @@ -0,0 +1,41 @@ +--- +"@valbuild/core": patch +"@valbuild/shared": patch +"@valbuild/react": patch +"@valbuild/next": patch +"@valbuild/server": patch +"@valbuild/ui": patch +--- + +Add `s.svg()` — a schema that stores an SVG as a JSON node tree instead of a binary file, so custom icons become real, diffable, type-checked content. + +Colors are **variables**, not baked hexes. A schema declares the variables an icon may use, along with an example value that doubles as the CSS fallback and as the key used to auto-match literal colors on import: + +```ts +s.svg({ + width: 24, + height: 24, + variables: { + brand: "#0055ff", + line: { value: "currentColor" }, + surface: { value: "#ffffff", match: ["#fff", "#fefefe"] }, + }, +}); +``` + +Drop an SVG onto the field in the editor and its literal colors are matched onto those variables automatically; anything that does not match is surfaced as a mapping step where the editor picks a variable per unmatched color. With the default `literals: "forbid"`, a raw color is both a validation error and a _compile_ error — attributes are a per-attribute mapped type, so `fill: "#f00"` does not typecheck. `literals: "allow"` or an explicit allowlist relaxes that. Geometry can be constrained too, via `width` / `height` / `aspectRatio`, validated against the viewBox. + +`` renders the tree as React elements — never `dangerouslySetInnerHTML` — and is exhaustively typed the same way `ValRichText`'s `theme` is: adding a variable to the schema breaks every call site until the mapping is supplied. + +```tsx + +``` + +Notes: + +- **The allowlist in `@valbuild/core` is the security boundary.** `ValSvg` builds React elements, and React renders unknown attributes on host elements verbatim, so tags and attributes are checked against a strict per-tag allowlist of exact names. `script`, `foreignObject`, `style`, `image`, `a`, animation and filter elements, all `on*` handlers, and any non-local `href` are rejected. `id` is not an allowed attribute at all, so `url(#…)` references have nothing to point at and gradients, masks and clip paths are out of scope for this first version - which also means two icons on a page have no ids to collide over. +- **SVG sources are never stega encoded.** Every string in one (`d`, `viewBox`, `points`, `transform`) is machine parsed, and invisible characters would corrupt the icon, so `stegaEncode` returns the source untouched and attaches the path out of band for the visual editing overlay. diff --git a/.github/pr-assets/svg-schema/README.md b/.github/pr-assets/svg-schema/README.md new file mode 100644 index 000000000..6458560b9 --- /dev/null +++ b/.github/pr-assets/svg-schema/README.md @@ -0,0 +1,9 @@ +# PR review assets: `s.svg()` + +Screenshots referenced from the pull request body, captured from the storybook +stories in `packages/ui/spa/components/fields/SvgField.stories.tsx` and from +`examples/next`. + +They live here only so GitHub can render them in the PR. This whole directory +is a separate commit and can be dropped before merge without touching the +feature. diff --git a/.github/pr-assets/svg-schema/svg-example-app.png b/.github/pr-assets/svg-schema/svg-example-app.png new file mode 100644 index 000000000..d10d6f2a9 Binary files /dev/null and b/.github/pr-assets/svg-schema/svg-example-app.png differ diff --git a/.github/pr-assets/svg-schema/svg-field-color-mapper-dark.png b/.github/pr-assets/svg-schema/svg-field-color-mapper-dark.png new file mode 100644 index 000000000..bd138eb13 Binary files /dev/null and b/.github/pr-assets/svg-schema/svg-field-color-mapper-dark.png differ diff --git a/.github/pr-assets/svg-schema/svg-field-color-mapper-light.png b/.github/pr-assets/svg-schema/svg-field-color-mapper-light.png new file mode 100644 index 000000000..b9979174f Binary files /dev/null and b/.github/pr-assets/svg-schema/svg-field-color-mapper-light.png differ diff --git a/.github/pr-assets/svg-schema/svg-field-empty-dark.png b/.github/pr-assets/svg-schema/svg-field-empty-dark.png new file mode 100644 index 000000000..459cc2f95 Binary files /dev/null and b/.github/pr-assets/svg-schema/svg-field-empty-dark.png differ diff --git a/.github/pr-assets/svg-schema/svg-field-empty-light.png b/.github/pr-assets/svg-schema/svg-field-empty-light.png new file mode 100644 index 000000000..34d0b95ff Binary files /dev/null and b/.github/pr-assets/svg-schema/svg-field-empty-light.png differ diff --git a/.github/pr-assets/svg-schema/svg-field-import-dark.png b/.github/pr-assets/svg-schema/svg-field-import-dark.png new file mode 100644 index 000000000..95c44bd5d Binary files /dev/null and b/.github/pr-assets/svg-schema/svg-field-import-dark.png differ diff --git a/.github/pr-assets/svg-schema/svg-field-import-light.png b/.github/pr-assets/svg-schema/svg-field-import-light.png new file mode 100644 index 000000000..fe9dfd57f Binary files /dev/null and b/.github/pr-assets/svg-schema/svg-field-import-light.png differ diff --git a/.github/pr-assets/svg-schema/svg-field-rendering-dark.png b/.github/pr-assets/svg-schema/svg-field-rendering-dark.png new file mode 100644 index 000000000..37a068ee6 Binary files /dev/null and b/.github/pr-assets/svg-schema/svg-field-rendering-dark.png differ diff --git a/.github/pr-assets/svg-schema/svg-field-rendering-light.png b/.github/pr-assets/svg-schema/svg-field-rendering-light.png new file mode 100644 index 000000000..0f4ee573f Binary files /dev/null and b/.github/pr-assets/svg-schema/svg-field-rendering-light.png differ diff --git a/.github/pr-assets/svg-schema/svg-field-with-icon-dark.png b/.github/pr-assets/svg-schema/svg-field-with-icon-dark.png new file mode 100644 index 000000000..24acbe20d Binary files /dev/null and b/.github/pr-assets/svg-schema/svg-field-with-icon-dark.png differ diff --git a/.github/pr-assets/svg-schema/svg-field-with-icon-light.png b/.github/pr-assets/svg-schema/svg-field-with-icon-light.png new file mode 100644 index 000000000..589be5b3f Binary files /dev/null and b/.github/pr-assets/svg-schema/svg-field-with-icon-light.png differ diff --git a/examples/next/app/page.tsx b/examples/next/app/page.tsx index 37a206033..bd92befd4 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 { ValImage, ValRichText, ValSvg } from "@valbuild/next"; import authorsVal from "../content/authors.val"; +import iconsVal from "../content/icons.val"; import themeVal from "../content/theme.val"; import Link from "next/link"; import { val } from "../val.config"; @@ -13,6 +14,7 @@ export default async function Home({ params }: { params: unknown }) { notFound(); } const authors = await fetchVal(authorsVal); + const icons = await fetchVal(iconsVal); const theme = await fetchVal(themeVal); const author = authors[page.author]; return ( @@ -54,6 +56,46 @@ export default async function Home({ params }: { params: unknown }) { {page.video.text}