Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion .agent/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ ImageSource → ImageSelector
FileSource<M> → FileSelector<M>
RemoteSource<M> → GenericSelector
RichTextSource<O> → RichTextSelector<O>
SvgSource<O> → SvgSelector<O>
SourceObject → ObjectSelector<T>
SourceArray → ArraySelector<T>
string/number/boolean → StringSelector/NumberSelector/BooleanSelector
Expand All @@ -62,7 +63,8 @@ export type Source =
| RemoteSource
| FileSource
| ImageSource
| RichTextSource<RichTextOptions>;
| RichTextSource<RichTextOptions>
| SvgSource<AllSvgOptions>;
```

**SelectorSource** (`packages/core/src/selector/index.ts`):
Expand All @@ -77,6 +79,7 @@ export type SelectorSource =
| FileSource
| RemoteSource
| RichTextSource<AllRichTextOptions>
| SvgSource<AllSvgOptions>
| GenericSelector<Source>;
```

Expand Down Expand Up @@ -127,6 +130,7 @@ Each Schema class validates and types its corresponding Source type:
| `ImageSchema<T>` | `ImageSource` | `s.image()` |
| `FileSchema<T>` | `FileSource` | `s.file()` |
| `RichTextSchema<O>` | `RichTextSource<O>` | `s.richtext(options)` |
| `SvgSchema<O>` | `SvgSource<O>` | `s.svg(options)` |
| `ObjectSchema<T>` | `SourceObject` | `s.object({...})` |
| `ArraySchema<T>` | `SourceArray` | `s.array(schema)` |

Expand Down Expand Up @@ -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<O> = {
viewBox: string;
width: number | null; // null, never undefined: undefined is not Json
height: number | null;
children: SvgNode<O>[];
readonly [SVG_VAL_PATH]?: string; // injected by stega, never stored
};
type SvgNode<O> = { tag: SvgTag; attrs: SvgAttrs<O>; children: SvgNode<O>[] };
```

`SvgAttrs<O>` is a **per-attribute mapped type**, not `Record<string, unknown>`:
`fill` / `stroke` take `SvgColorValue<O>`, 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'"
Expand Down
41 changes: 41 additions & 0 deletions .changeset/svg-schema.md
Original file line number Diff line number Diff line change
@@ -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.

`<ValSvg>` 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
<ValSvg
src={icons.bell}
size={32}
vars={{ brand: "var(--brand-500)", line: "currentColor", surface: "#fff" }}
/>
```

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.
9 changes: 9 additions & 0 deletions .github/pr-assets/svg-schema/README.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file added .github/pr-assets/svg-schema/svg-example-app.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
44 changes: 43 additions & 1 deletion examples/next/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
Expand Down Expand Up @@ -54,6 +56,46 @@ export default async function Home({ params }: { params: unknown }) {
<span>{page.video.text}</span>
<video src={page.video.file.url} controls />
</section>
<section>
<div style={{ display: "flex", gap: "1.5rem", alignItems: "center" }}>
{/* Every declared variable must be mapped. Adding one to the schema
is a compile error here until it is given a color, the same way
ValRichText's theme works. */}
<ValSvg
src={icons.bookmark}
size={32}
vars={{ brand: "#0055ff", line: "currentColor", surface: "#fff" }}
/>
{/* `line` is mapped to currentColor, so the bell's clapper follows
the surrounding text color. */}
<span
style={{ color: "#b91c1c", display: "inline-flex", gap: ".5rem" }}
>
<ValSvg
src={icons.bell}
size={32}
vars={{
brand: "currentColor",
line: "currentColor",
surface: "#fff",
}}
/>
Inherits currentColor
</span>
{/* Mapped to css custom properties this app owns, so a dark mode
stylesheet can retheme the icon without re-rendering. */}
<ValSvg
src={icons.check}
size={32}
title="Done"
vars={{
brand: "var(--icon-brand, #15803d)",
line: null,
surface: "var(--icon-surface, #f0fdf4)",
}}
/>
</div>
</section>
<section
style={{
background: theme.overlay,
Expand Down
122 changes: 122 additions & 0 deletions examples/next/content/icons.val.ts
Original file line number Diff line number Diff line change
@@ -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<typeof schema>;

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: [],
},
],
},
});
1 change: 1 addition & 0 deletions examples/next/val.modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export default modules(config, [
{ def: () => import("./app/support/[slug]/page.val") },
{ def: () => import("./app/generic/[[...path]]/page.val") },
{ def: () => import("./content/media.val") },
{ def: () => import("./content/icons.val") },
{ def: () => import("./content/theme.val") },
{ def: () => import("./app/page.val") },
{ def: () => import("./app/external.val") },
Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,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,
Expand Down Expand Up @@ -145,6 +162,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,
Expand Down
Loading
Loading