Compare commits

..
Author SHA1 Message Date
Chris Tate 7edecbc599 docs(examples): add READMEs to chat, dashboard, game-engine, and no-ai examples
These high-traffic examples had no README, requiring contributors to
read source code or root docs to understand setup and purpose.
2026-04-27 09:42:38 -05:00
68 changed files with 113 additions and 2646 deletions
+36 -40
View File
@@ -6,18 +6,15 @@ on:
- main
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
check-release:
name: Check for new version
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
should_release: ${{ steps.check.outputs.should_release }}
needs_github_release: ${{ steps.check.outputs.needs_github_release }}
@@ -56,16 +53,14 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish:
name: Publish to npm
build:
name: Build
needs: check-release
if: needs.check-release.outputs.should_release == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
environment: Release
permissions:
contents: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -76,7 +71,34 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build packages
run: pnpm run build
publish:
name: Publish to npm
needs: [check-release, build]
if: needs.check-release.outputs.should_release == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
registry-url: "https://registry.npmjs.org"
@@ -87,34 +109,9 @@ jobs:
run: pnpm run build
- name: Publish all public packages
run: |
LOCAL_VERSION="${{ needs.check-release.outputs.version }}"
FAILED=""
publish_pkg() {
local dir="$1" name="$2"
REGISTRY_VERSION=$(npm view "$name" version 2>/dev/null || echo "0.0.0")
if [ "$LOCAL_VERSION" = "$REGISTRY_VERSION" ]; then
echo "$name@$LOCAL_VERSION already published, skipping"
return 0
fi
echo "Publishing $name@$LOCAL_VERSION..."
TARBALL=$(cd "$dir" && pnpm pack --pack-destination /tmp | tail -1)
if ! npm publish "$TARBALL" --provenance --access public; then
FAILED="$FAILED $name"
fi
}
for dir in packages/*/; do
PKG_NAME=$(node -p "try { const p = require('./$dir/package.json'); p.private ? '' : p.name } catch { '' }")
[ -z "$PKG_NAME" ] && continue
publish_pkg "$dir" "$PKG_NAME"
done
if [ -n "$FAILED" ]; then
echo "Failed to publish:$FAILED"
exit 1
fi
run: pnpm -r publish --no-git-checks --filter '@json-render/*'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_VERCEL_TOKEN_ELEVATED }}
github-release:
name: Create GitHub Release
@@ -154,7 +151,6 @@ jobs:
echo "Creating release $TAG..."
gh release create "$TAG" \
--title "$TAG" \
--target ${{ github.sha }} \
--notes-file /tmp/release-notes.md
fi
env:
+2 -17
View File
@@ -1,24 +1,8 @@
# Changelog
## 0.19.0
<!-- release:start -->
### New Features
- **Custom directives API** — `@json-render/core` now supports custom directives via `defineDirective`, letting you declare new JSON shapes (like `$format`, `$math`) that resolve to computed values at render time. Directives compose naturally — nest `$format` over `$math` over `$state` and they resolve inside-out. All four renderers (React, Vue, Svelte, Solid) have built-in directive resolution (#279)
- **`@json-render/directives`** — New package shipping seven ready-made directives: `$format` (date, currency, number, percent via `Intl`), `$math` (add, subtract, multiply, divide, mod, min, max, round, floor, ceil, abs), `$concat`, `$count`, `$truncate`, `$pluralize`, and `$join`. Also exports `createI18nDirective` for `$t` translation keys with `{{param}}` interpolation, and `standardDirectives` for one-line registration (#279)
### Improvements
- **Example READMEs** — Added documentation to the chat, dashboard, game-engine, and no-ai examples (#277)
### Contributors
- @ctate
<!-- release:end -->
## 0.18.0
<!-- release:start -->
### New Features
- **Devtools** — Five new packages for inspecting json-render apps in the browser: `@json-render/devtools` (framework-agnostic core), plus `@json-render/devtools-react`, `@json-render/devtools-vue`, `@json-render/devtools-svelte`, and `@json-render/devtools-solid` adapters. Drop `<JsonRenderDevtools />` into your app to get a shadow-DOM-isolated panel with six tabs (Spec, State, Actions, Stream, Catalog, Pick), a DOM picker that maps clicked elements back to spec keys via `data-jr-key`, a capped event store, and server-side stream tap utilities. Floating toggle or `Cmd`/`Ctrl` + `Shift` + `J`, tree-shakes to `null` in production (#273)
@@ -37,6 +21,7 @@
- @ctate
- @mvanhorn
<!-- release:end -->
## 0.17.0
-1
View File
@@ -136,7 +136,6 @@ function Dashboard({ spec }) {
| `@json-render/react-email` | React Email renderer for HTML/plain-text emails from specs |
| `@json-render/ink` | Ink terminal renderer with built-in components for interactive TUIs. |
| `@json-render/image` | Image renderer for SVG/PNG output (OG images, social cards) via Satori |
| `@json-render/directives` | Pre-built custom directives — $format, $math, $concat, $count, $truncate, $pluralize, $join, $t (i18n) |
| `@json-render/codegen` | Utilities for generating code from json-render UI trees |
| `@json-render/devtools` | Framework-agnostic devtools core — panel UI, event store, picker, stream taps |
| `@json-render/devtools-react` | React adapter for `@json-render/devtools` (drop-in `<JsonRenderDevtools />`) |
@@ -1,407 +0,0 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/directives")
# @json-render/directives
Pre-built custom directives for `@json-render/core`. Drop them into your catalog and renderer to add formatting, math, string manipulation, and i18n.
## Install
```bash
npm install @json-render/directives
```
## Quick Start
```typescript
import { standardDirectives } from '@json-render/directives';
// Wire into prompt generation
const prompt = catalog.prompt({ directives: standardDirectives });
// Wire into the renderer
<JSONUIProvider spec={spec} directives={standardDirectives}>
...
</JSONUIProvider>
```
To add factory directives like `createI18nDirective`, spread the array:
```typescript
import { standardDirectives, createI18nDirective } from '@json-render/directives';
const directives = [...standardDirectives, createI18nDirective(config)];
```
## Directives
### `$format` — Locale-aware value formatting
Formats values using `Intl` formatters. Supports `date`, `currency`, `number`, and `percent`.
```json
{ "$format": "currency", "value": { "$state": "/cart/total" }, "currency": "USD" }
```
```json
{ "$format": "date", "value": { "$state": "/user/createdAt" } }
```
```json
{ "$format": "number", "value": 1234567, "notation": "compact" }
```
```json
{ "$format": "percent", "value": 0.75 }
```
Relative dates are also supported:
```json
{ "$format": "date", "value": { "$state": "/post/createdAt" }, "style": "relative" }
```
This returns strings like `"3h ago"`, `"2d from now"`, or `"just now"`.
<table>
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>$format</code></td>
<td><code>{'\"date\" | \"currency\" | \"number\" | \"percent\"'}</code></td>
<td>Format type.</td>
</tr>
<tr>
<td><code>value</code></td>
<td><code>unknown</code></td>
<td>Value to format. Accepts any dynamic expression.</td>
</tr>
<tr>
<td><code>locale</code></td>
<td><code>string</code></td>
<td>Optional. Locale for formatting (e.g. <code>"en-US"</code>).</td>
</tr>
<tr>
<td><code>currency</code></td>
<td><code>string</code></td>
<td>Optional. Currency code for <code>"currency"</code> format. Default: <code>"USD"</code>.</td>
</tr>
<tr>
<td><code>notation</code></td>
<td><code>string</code></td>
<td>Optional. Notation for <code>"number"</code> format (e.g. <code>"compact"</code>).</td>
</tr>
<tr>
<td><code>style</code></td>
<td><code>string</code></td>
<td>Optional. Set to <code>"relative"</code> for relative date formatting.</td>
</tr>
<tr>
<td><code>options</code></td>
<td><code>{'Record<string, unknown>'}</code></td>
<td>Optional. Extra <code>Intl</code> formatter options.</td>
</tr>
</tbody>
</table>
### `$math` — Arithmetic operations
Performs arithmetic on one or two operands. Operands accept any dynamic expression.
```json
{ "$math": "add", "a": { "$state": "/subtotal" }, "b": { "$state": "/tax" } }
```
```json
{ "$math": "round", "a": 3.7 }
```
<table>
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>$math</code></td>
<td><code>{'\"add\" | \"subtract\" | \"multiply\" | \"divide\" | \"mod\" | \"min\" | \"max\" | \"round\" | \"floor\" | \"ceil\" | \"abs\"'}</code></td>
<td>Operation to perform.</td>
</tr>
<tr>
<td><code>a</code></td>
<td><code>unknown</code></td>
<td>First operand. Defaults to <code>0</code> if missing.</td>
</tr>
<tr>
<td><code>b</code></td>
<td><code>unknown</code></td>
<td>Second operand (binary ops only). Defaults to <code>0</code> if missing.</td>
</tr>
</tbody>
</table>
Unary operations (`round`, `floor`, `ceil`, `abs`) only use `a`. Division by zero returns `0`.
### `$concat` — String concatenation
Concatenates multiple values into a single string. Each element is resolved then joined.
```json
{ "$concat": [{ "$state": "/user/firstName" }, " ", { "$state": "/user/lastName" }] }
```
<table>
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>$concat</code></td>
<td><code>{'unknown[]'}</code></td>
<td>Array of values to concatenate. Each is resolved, converted to string, and joined.</td>
</tr>
</tbody>
</table>
### `$count` — Array/string length
Returns the length of an array or string. Returns `0` for other types.
```json
{ "$count": { "$state": "/cart/items" } }
```
<table>
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>$count</code></td>
<td><code>unknown</code></td>
<td>Value to count. Accepts arrays and strings.</td>
</tr>
</tbody>
</table>
### `$truncate` — Text truncation
Truncates text to a maximum length with a configurable suffix.
```json
{ "$truncate": { "$state": "/post/body" }, "length": 140, "suffix": "..." }
```
<table>
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>$truncate</code></td>
<td><code>unknown</code></td>
<td>Value to truncate.</td>
</tr>
<tr>
<td><code>length</code></td>
<td><code>number</code></td>
<td>Optional. Max character length. Default: <code>100</code>.</td>
</tr>
<tr>
<td><code>suffix</code></td>
<td><code>string</code></td>
<td>Optional. Suffix to append when truncated. Default: <code>"..."</code>.</td>
</tr>
</tbody>
</table>
### `$pluralize` — Singular/plural forms
Selects a singular, plural, or zero form based on a count.
```json
{ "$pluralize": { "$state": "/cart/itemCount" }, "one": "item", "other": "items", "zero": "no items" }
```
Output: `"3 items"`, `"1 item"`, or `"no items"`.
<table>
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>$pluralize</code></td>
<td><code>unknown</code></td>
<td>Count value. Accepts dynamic expressions.</td>
</tr>
<tr>
<td><code>one</code></td>
<td><code>string</code></td>
<td>Singular form label.</td>
</tr>
<tr>
<td><code>other</code></td>
<td><code>string</code></td>
<td>Plural form label.</td>
</tr>
<tr>
<td><code>zero</code></td>
<td><code>string</code></td>
<td>Optional. Label for count of zero. If omitted, uses <code>"0 {'<other>'}"</code>.</td>
</tr>
</tbody>
</table>
### `$join` — Join array elements
Joins array elements with a separator string.
```json
{ "$join": { "$state": "/tags" }, "separator": ", " }
```
<table>
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>$join</code></td>
<td><code>unknown</code></td>
<td>Array to join. Non-array values are converted to string.</td>
</tr>
<tr>
<td><code>separator</code></td>
<td><code>string</code></td>
<td>Optional. Separator between elements. Default: <code>", "</code>.</td>
</tr>
</tbody>
</table>
### `createI18nDirective` — Internationalization
Factory function that creates a `$t` directive for translations with `{'{{param}}'}` interpolation.
```typescript
import { createI18nDirective } from '@json-render/directives';
const tDirective = createI18nDirective({
locale: 'en',
messages: {
en: { "greeting": "Hello, {'{{name}}'}!", "checkout.submit": "Place Order" },
es: { "greeting": "Hola, {'{{name}}'}!", "checkout.submit": "Realizar Pedido" },
},
fallbackLocale: 'en',
});
```
Usage in specs:
```json
{ "$t": "checkout.submit" }
```
```json
{ "$t": "greeting", "params": { "name": { "$state": "/user/name" } } }
```
<table>
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>$t</code></td>
<td><code>string</code></td>
<td>Translation key.</td>
</tr>
<tr>
<td><code>params</code></td>
<td><code>{'Record<string, unknown>'}</code></td>
<td>Optional. Interpolation parameters. Values accept dynamic expressions.</td>
</tr>
</tbody>
</table>
#### `I18nConfig`
<table>
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>locale</code></td>
<td><code>string</code></td>
<td>Current locale (e.g. <code>"en"</code>).</td>
</tr>
<tr>
<td><code>messages</code></td>
<td><code>{'Record<string, Record<string, string>>'}</code></td>
<td>Map of locale to key-value translation pairs.</td>
</tr>
<tr>
<td><code>fallbackLocale</code></td>
<td><code>string</code></td>
<td>Optional. Fallback locale when a key is missing in the current locale.</td>
</tr>
</tbody>
</table>
## Composition
Directives compose naturally. Each resolver calls `resolvePropValue` on its inputs, so you can nest directives:
```json
{
"$format": "currency",
"value": { "$math": "multiply", "a": { "$state": "/price" }, "b": { "$state": "/qty" } },
"currency": "USD"
}
```
```json
{
"$pluralize": { "$count": { "$state": "/items" } },
"one": "item",
"other": "items"
}
```
@@ -5,35 +5,6 @@ export const metadata = pageMetadata("docs/changelog")
Notable changes and updates to json-render.
## v0.19.0
May 6, 2026
### New: Custom Directives API
`@json-render/core` now supports custom directives via `defineDirective`, letting you declare new JSON shapes (like `$format`, `$math`) that resolve to computed values at render time. Directives compose naturally -- nest `$format` over `$math` over `$state` and they resolve inside-out. All four renderers (React, Vue, Svelte, Solid) have built-in directive resolution.
### New: `@json-render/directives`
New package shipping seven ready-made directives: `$format` (date, currency, number, percent via `Intl`), `$math` (arithmetic and rounding), `$concat`, `$count`, `$truncate`, `$pluralize`, and `$join`. Also exports `createI18nDirective` for `$t` translation keys with interpolation, and `standardDirectives` for one-line registration.
```bash
npm install @json-render/directives
```
```tsx
import { standardDirectives } from "@json-render/directives";
const catalog = createCatalog({
directives: standardDirectives,
// ...
});
```
See the [Directives guide](/docs/directives) and the [API reference](/docs/api/directives) for details.
---
## v0.18.0
April 17, 2026
@@ -1,124 +0,0 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/directives")
# Directives
Extend the spec language with custom `$`-prefixed dynamic values. Directives let you add formatting, math, string manipulation, i18n, and any other transformation without modifying core.
## Overview
A directive is a user-defined dynamic value expression, like `$state` or `$computed`, but defined in userland. Each directive has a `$`-prefixed name, a Zod schema for validation, and a resolver function.
```json
{
"type": "Text",
"props": {
"text": {
"$format": "currency",
"value": { "$state": "/cart/total" },
"currency": "USD"
}
},
"children": []
}
```
## Defining a Directive
Use `defineDirective` from `@json-render/core`:
```typescript
import { defineDirective, resolvePropValue } from '@json-render/core';
import { z } from 'zod';
const doubleDirective = defineDirective({
name: '$double',
description: 'Double a numeric value.',
schema: z.object({
$double: z.unknown(),
}),
resolve(value, ctx) {
const resolved = resolvePropValue(value.$double, ctx);
return (resolved as number) * 2;
},
});
```
The `description` field is optional. When generating prompts, the directive's schema fields are auto-described from the Zod schema; the `description` adds short behavioral context the schema can't express.
## Wiring Directives
Pass directives to both the renderer (for runtime resolution) and the catalog prompt (for AI generation).
### Runtime
```tsx
import { JSONUIProvider, Renderer } from '@json-render/react';
import { standardDirectives } from '@json-render/directives';
<JSONUIProvider registry={registry} directives={standardDirectives}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
```
Or with `createRenderer`:
```tsx
const MyRenderer = createRenderer(catalog, components);
<MyRenderer spec={spec} directives={directives} />
```
All four renderers (React, Vue, Svelte, Solid) accept the `directives` prop on their provider and `createRenderer` output.
### Prompt Generation
```typescript
const prompt = catalog.prompt({ directives });
```
Each directive's schema is auto-described in the "CUSTOM DYNAMIC VALUES" section of the system prompt. The optional `description` field adds behavioral context inline.
## Pre-built Directives
The `@json-render/directives` package ships ready-to-use directives:
```typescript
import { standardDirectives, createI18nDirective } from '@json-render/directives';
```
`standardDirectives` includes `$format`, `$math`, `$concat`, `$count`, `$truncate`, `$pluralize`, and `$join`. Add factory directives by spreading:
```typescript
const directives = [...standardDirectives, createI18nDirective(config)];
```
See the [API reference](/docs/api/directives) for details on each directive.
## Composition
Directives compose naturally. Each resolver calls `resolvePropValue` on its inputs, so directives can wrap other directives or built-in expressions like `$state`:
```json
{
"$format": "currency",
"value": {
"$math": "multiply",
"a": { "$state": "/price" },
"b": { "$state": "/qty" }
},
"currency": "USD"
}
```
This resolves inside-out: `$state` reads from state, `$math` multiplies the values, and `$format` formats the result as currency.
## Built-in Precedence
Built-in expressions (`$state`, `$computed`, `$cond`, `$template`, etc.) always take precedence over custom directives. `defineDirective` throws if you try to register a name that conflicts with a built-in key.
## Next
- [API Reference](/docs/api/directives) — full directive reference
- [Computed Values](/docs/computed-values) — `$computed` and `$template` expressions
- [Data Binding](/docs/data-binding) — `$state`, `$item`, and binding expressions
+2 -2
View File
@@ -16,8 +16,8 @@ const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render
GitHub repository: https://github.com/vercel-labs/json-render
Documentation: https://json-render.dev/docs
npm packages: @json-render/core, @json-render/react, @json-render/next, @json-render/ink, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/shadcn-svelte, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/directives, @json-render/codegen, @json-render/devtools, @json-render/devtools-react, @json-render/devtools-vue, @json-render/devtools-svelte, @json-render/devtools-solid, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate, @json-render/yaml
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, next, ink, react-pdf, react-email, react-native, shadcn, shadcn-svelte, react-three-fiber, image, remotion, vue, svelte, solid, directives, codegen, devtools, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details.
npm packages: @json-render/core, @json-render/react, @json-render/next, @json-render/ink, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/shadcn-svelte, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/devtools, @json-render/devtools-react, @json-render/devtools-vue, @json-render/devtools-svelte, @json-render/devtools-solid, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate, @json-render/yaml
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, next, ink, react-pdf, react-email, react-native, shadcn, shadcn-svelte, react-three-fiber, image, remotion, vue, svelte, solid, codegen, devtools, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details.
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
-2
View File
@@ -32,7 +32,6 @@ export const docsNavigation: NavSection[] = [
{ title: "Visibility", href: "/docs/visibility" },
{ title: "Watchers", href: "/docs/watchers" },
{ title: "Validation", href: "/docs/validation" },
{ title: "Directives", href: "/docs/directives" },
],
},
{
@@ -87,7 +86,6 @@ export const docsNavigation: NavSection[] = [
title: "@json-render/react-three-fiber",
href: "/docs/api/react-three-fiber",
},
{ title: "@json-render/directives", href: "/docs/api/directives" },
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
{ title: "@json-render/devtools", href: "/docs/api/devtools" },
{
-2
View File
@@ -40,7 +40,6 @@ export const PAGE_TITLES: Record<string, string> = {
"docs/migration": "Migration Guide",
"docs/changelog": "Changelog",
"docs/skills": "Skills",
"docs/directives": "Directives",
// API references
"docs/api/core": "@json-render/core API",
@@ -52,7 +51,6 @@ export const PAGE_TITLES: Record<string, string> = {
"docs/api/react-email": "@json-render/react-email API",
"docs/api/react-native": "@json-render/react-native API",
"docs/api/svelte": "@json-render/svelte API",
"docs/api/directives": "@json-render/directives API",
"docs/api/codegen": "@json-render/codegen API",
"docs/api/devtools": "@json-render/devtools API",
"docs/api/devtools-react": "@json-render/devtools-react API",
+1 -1
View File
@@ -25,7 +25,7 @@
"prepare": "husky",
"version:sync": "node scripts/sync-version.js",
"version:check": "node scripts/check-version-sync.js",
"ci:publish": "pnpm run build && pnpm -r publish --provenance --no-git-checks --access public --filter '@json-render/*'",
"ci:publish": "pnpm run build && pnpm -r publish --no-git-checks --filter '@json-render/*'",
"generate:og": "npx tsx scripts/generate-og-images.mts"
},
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/codegen",
"version": "0.19.0",
"version": "0.18.0",
"license": "Apache-2.0",
"description": "Utilities for generating code from json-render UI trees",
"keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/core",
"version": "0.19.0",
"version": "0.18.0",
"license": "Apache-2.0",
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
"keywords": [
-214
View File
@@ -1,214 +0,0 @@
import { describe, it, expect } from "vitest";
import { z } from "zod";
import {
defineDirective,
createDirectiveRegistry,
findDirective,
} from "./directives";
import { resolvePropValue } from "./props";
import type { PropResolutionContext } from "./props";
describe("defineDirective", () => {
it("returns the definition unchanged", () => {
const def = defineDirective({
name: "$double",
schema: z.object({ $double: z.number() }),
resolve: (v) => (v as { $double: number }).$double * 2,
});
expect(def.name).toBe("$double");
expect(typeof def.resolve).toBe("function");
});
it("throws when name does not start with $", () => {
expect(() =>
defineDirective({
name: "double",
schema: z.object({ double: z.number() }),
resolve: () => 0,
}),
).toThrow('Directive name must start with "$"');
});
it("throws when name conflicts with a built-in key", () => {
for (const name of [
"$state",
"$item",
"$index",
"$bindState",
"$bindItem",
"$cond",
"$computed",
"$template",
]) {
expect(() =>
defineDirective({
name,
schema: z.object({}),
resolve: () => 0,
}),
).toThrow(`conflicts with a built-in`);
}
});
});
describe("createDirectiveRegistry", () => {
it("creates a Map from an array of definitions", () => {
const d1 = defineDirective({
name: "$a",
schema: z.object({ $a: z.string() }),
resolve: () => "a",
});
const d2 = defineDirective({
name: "$b",
schema: z.object({ $b: z.string() }),
resolve: () => "b",
});
const reg = createDirectiveRegistry([d1, d2]);
expect(reg.size).toBe(2);
expect(reg.get("$a")).toBe(d1);
expect(reg.get("$b")).toBe(d2);
});
it("returns an empty Map for an empty array", () => {
const reg = createDirectiveRegistry([]);
expect(reg.size).toBe(0);
});
});
describe("findDirective", () => {
const d = defineDirective({
name: "$upper",
schema: z.object({ $upper: z.string() }),
resolve: (v) => String((v as { $upper: string }).$upper).toUpperCase(),
});
const registry = createDirectiveRegistry([d]);
it("finds a matching directive", () => {
expect(findDirective({ $upper: "hello" }, registry)).toBe(d);
});
it("returns undefined for non-matching objects", () => {
expect(findDirective({ foo: "bar" }, registry)).toBeUndefined();
});
it("returns undefined when registry is undefined", () => {
expect(findDirective({ $upper: "hello" }, undefined)).toBeUndefined();
});
it("returns undefined for empty registry", () => {
expect(findDirective({ $upper: "hello" }, new Map())).toBeUndefined();
});
it("ignores $ keys not in registry", () => {
expect(findDirective({ $unknown: 1 }, registry)).toBeUndefined();
});
it("throws when multiple directive keys match", () => {
const d2 = defineDirective({
name: "$lower",
schema: z.object({ $lower: z.string() }),
resolve: (v) => String((v as { $lower: string }).$lower).toLowerCase(),
});
const multiRegistry = createDirectiveRegistry([d, d2]);
expect(() =>
findDirective({ $upper: "hello", $lower: "WORLD" }, multiRegistry),
).toThrow("Ambiguous directive");
});
});
describe("resolvePropValue with custom directives", () => {
const doubleDirective = defineDirective({
name: "$double",
schema: z.object({ $double: z.unknown() }),
resolve(value, ctx) {
const resolved = resolvePropValue(
(value as { $double: unknown }).$double,
ctx,
);
return (resolved as number) * 2;
},
});
const upperDirective = defineDirective({
name: "$upper",
schema: z.object({ $upper: z.unknown() }),
resolve(value, ctx) {
const resolved = resolvePropValue(
(value as { $upper: unknown }).$upper,
ctx,
);
return String(resolved).toUpperCase();
},
});
const registry = createDirectiveRegistry([doubleDirective, upperDirective]);
it("resolves a custom directive", () => {
const ctx: PropResolutionContext = { stateModel: {}, directives: registry };
expect(resolvePropValue({ $double: 5 }, ctx)).toBe(10);
});
it("resolves a directive with $state sub-value", () => {
const ctx: PropResolutionContext = {
stateModel: { count: 7 },
directives: registry,
};
expect(resolvePropValue({ $double: { $state: "/count" } }, ctx)).toBe(14);
});
it("resolves nested directives (composition)", () => {
const ctx: PropResolutionContext = {
stateModel: { val: 3 },
directives: registry,
};
const value = { $double: { $double: { $state: "/val" } } };
expect(resolvePropValue(value, ctx)).toBe(12);
});
it("resolves directives inside object props", () => {
const ctx: PropResolutionContext = {
stateModel: { name: "alice" },
directives: registry,
};
const value = { label: { $upper: { $state: "/name" } }, count: 1 };
expect(resolvePropValue(value, ctx)).toEqual({
label: "ALICE",
count: 1,
});
});
it("resolves directives inside arrays", () => {
const ctx: PropResolutionContext = {
stateModel: { x: 5 },
directives: registry,
};
const value = [{ $double: { $state: "/x" } }, "literal"];
expect(resolvePropValue(value, ctx)).toEqual([10, "literal"]);
});
it("falls through to plain object resolution when no directive matches", () => {
const ctx: PropResolutionContext = {
stateModel: { a: 1 },
directives: registry,
};
expect(resolvePropValue({ foo: { $state: "/a" } }, ctx)).toEqual({
foo: 1,
});
});
it("cannot register a directive that shadows a built-in key", () => {
expect(() =>
defineDirective({
name: "$state",
schema: z.object({ $state: z.string() }),
resolve: () => "should-not-reach",
}),
).toThrow("conflicts with a built-in");
});
it("works without directives in context (backward compat)", () => {
const ctx: PropResolutionContext = { stateModel: { x: 1 } };
expect(resolvePropValue({ $state: "/x" }, ctx)).toBe(1);
expect(resolvePropValue({ foo: "bar" }, ctx)).toEqual({ foo: "bar" });
});
});
-124
View File
@@ -1,124 +0,0 @@
import type { z } from "zod";
import type { PropResolutionContext } from "./props";
/**
* Definition for a custom directive — a user-defined `$`-prefixed dynamic
* value that extends the spec language.
*
* @example
* ```ts
* const formatDirective = defineDirective({
* name: '$format',
* description: 'Locale-aware value formatting (date, currency, number, percent).',
* schema: z.object({
* $format: z.enum(['date', 'currency', 'number']),
* value: z.unknown(),
* }),
* resolve(value, ctx) {
* const resolved = resolvePropValue(value.value, ctx);
* return new Intl.NumberFormat().format(resolved);
* },
* });
* ```
*/
export interface DirectiveDefinition<TSchema extends z.ZodType = z.ZodType> {
/** The `$`-prefixed key that triggers this directive (e.g. `"$format"`). */
name: string;
/**
* Short description of the directive for the AI system prompt.
* The schema fields are auto-generated; this adds behavioral context.
*/
description?: string;
/** Zod schema for validating the directive object. */
schema: TSchema;
/**
* Resolver function. Receives the raw directive value and the current
* {@link PropResolutionContext}. May call `resolvePropValue` on sub-values
* to support composition with other dynamic expressions.
*/
resolve: (value: z.infer<TSchema>, ctx: PropResolutionContext) => unknown;
}
/**
* A Map from directive name (e.g. `"$format"`) to its definition.
* Passed through {@link PropResolutionContext} for runtime resolution.
*/
export type DirectiveRegistry = Map<string, DirectiveDefinition>;
/** Keys handled by built-in prop resolution — directives must not shadow these. */
const BUILT_IN_KEYS = new Set([
"$state",
"$item",
"$index",
"$bindState",
"$bindItem",
"$cond",
"$computed",
"$template",
]);
/**
* Define a custom directive.
*
* This is an identity function that provides type checking and serves as
* a documentation convention. Throws if the name collides with a built-in
* prop expression key.
*/
export function defineDirective<TSchema extends z.ZodType>(
definition: DirectiveDefinition<TSchema>,
): DirectiveDefinition<TSchema> {
if (!definition.name.startsWith("$")) {
throw new Error(
`Directive name must start with "$": got "${definition.name}"`,
);
}
if (BUILT_IN_KEYS.has(definition.name)) {
throw new Error(
`Directive name "${definition.name}" conflicts with a built-in prop expression key`,
);
}
return definition;
}
/**
* Convert an array of directive definitions into a {@link DirectiveRegistry}.
*/
export function createDirectiveRegistry(
directives: DirectiveDefinition[],
): DirectiveRegistry {
const registry: DirectiveRegistry = new Map();
for (const d of directives) {
registry.set(d.name, d);
}
return registry;
}
/**
* Look up a custom directive for a plain-object value.
*
* Iterates the registry and checks whether the object contains a matching key.
* Returns `undefined` when no match is found or when no registry is provided.
*
* This is only called **after** all built-in expressions (`$state`, `$cond`,
* etc.) have been checked in `resolvePropValue`, so built-ins always take
* precedence. {@link defineDirective} enforces this at registration time by
* rejecting names that collide with built-in keys.
*/
export function findDirective(
value: Record<string, unknown>,
directives?: DirectiveRegistry,
): DirectiveDefinition | undefined {
if (!directives || directives.size === 0) return undefined;
let match: DirectiveDefinition | undefined;
for (const [key, def] of directives) {
if (key in value) {
if (match) {
throw new Error(
`Ambiguous directive: object has multiple directive keys ("${match.name}" and "${key}")`,
);
}
match = def;
}
}
return match;
}
-9
View File
@@ -85,15 +85,6 @@ export {
resolveActionParam,
} from "./props";
// Custom Directives
export type { DirectiveDefinition, DirectiveRegistry } from "./directives";
export {
defineDirective,
createDirectiveRegistry,
findDirective,
} from "./directives";
// Actions
export type {
ActionBinding,
+1 -13
View File
@@ -1,7 +1,6 @@
import type { VisibilityCondition, StateModel } from "./types";
import { getByPath } from "./types";
import { evaluateVisibility, type VisibilityContext } from "./visibility";
import { findDirective, type DirectiveRegistry } from "./directives";
// =============================================================================
// Prop Expression Types
@@ -62,8 +61,6 @@ export interface PropResolutionContext extends VisibilityContext {
repeatBasePath?: string;
/** Named functions available for `$computed` expressions. */
functions?: Record<string, ComputedFunction>;
/** Custom directive registry for user-defined `$`-prefixed dynamic values. */
directives?: DirectiveRegistry;
}
// =============================================================================
@@ -294,17 +291,8 @@ export function resolvePropValue(
return value.map((item) => resolvePropValue(item, ctx));
}
// Custom directives: check registry before generic object recursion
// Plain objects (not expressions): resolve each value recursively
if (typeof value === "object") {
const directive = findDirective(
value as Record<string, unknown>,
ctx.directives,
);
if (directive) {
return directive.resolve(value, ctx);
}
// Plain objects (not expressions): resolve each value recursively
const resolved: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
resolved[key] = resolvePropValue(val, ctx);
-23
View File
@@ -1,7 +1,6 @@
import { z } from "zod";
import type { EditMode } from "./edit-modes";
import { buildEditInstructions } from "./edit-modes";
import type { DirectiveDefinition } from "./directives";
/**
* Schema builder primitives
@@ -147,12 +146,6 @@ export interface PromptOptions {
mode?: "standalone" | "inline" | "generate" | "chat";
/** Edit modes to document in the system prompt. Default: `["patch"]`. */
editModes?: EditMode[];
/**
* Custom directives to include in the system prompt.
* Each directive's schema is auto-described; the optional `description`
* field adds behavioral context. Pass the same array used at runtime.
*/
directives?: DirectiveDefinition[];
}
/**
@@ -975,22 +968,6 @@ Note: state patches appear right after the elements that use them, so the UI fil
lines.push("");
}
// Custom directives section — auto-describe schema + optional description
const directives = options.directives;
if (directives && directives.length > 0) {
lines.push("CUSTOM DYNAMIC VALUES:");
lines.push("");
for (const d of directives) {
const desc = d.description ? ` (${d.description})` : "";
lines.push(`- ${d.name}${desc}: ${formatZodType(d.schema)}`);
}
lines.push("");
lines.push(
"Directives compose: any value field can contain another directive or a $state expression, resolved inside-out.",
);
lines.push("");
}
// Validation section — only emit when at least one component has a `checks` prop
const hasChecksComponents = allComponents
? Object.entries(allComponents).some(([, def]) => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/devtools-react",
"version": "0.19.0",
"version": "0.18.0",
"license": "Apache-2.0",
"description": "React adapter for @json-render/devtools. Drop-in <JsonRenderDevtools /> component.",
"keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/devtools-solid",
"version": "0.19.0",
"version": "0.18.0",
"license": "Apache-2.0",
"description": "SolidJS adapter for @json-render/devtools. Drop-in <JsonRenderDevtools /> component.",
"keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/devtools-svelte",
"version": "0.19.0",
"version": "0.18.0",
"license": "Apache-2.0",
"description": "Svelte adapter for @json-render/devtools. Drop-in <JsonRenderDevtools /> component.",
"keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/devtools-vue",
"version": "0.19.0",
"version": "0.18.0",
"license": "Apache-2.0",
"description": "Vue adapter for @json-render/devtools. Drop-in <JsonRenderDevtools /> component.",
"keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/devtools",
"version": "0.19.0",
"version": "0.18.0",
"license": "Apache-2.0",
"description": "Framework-agnostic devtools core for json-render: event store, panel UI, picker, stream taps.",
"keywords": [
-126
View File
@@ -1,126 +0,0 @@
# @json-render/directives
Pre-built custom directives for `@json-render/core`. Drop them into your catalog and renderer to add formatting, math, string manipulation, and i18n to your AI-generated UIs.
## Install
```bash
npm install @json-render/directives
```
## Quick Start
```typescript
import { standardDirectives } from '@json-render/directives';
// Wire into prompt generation
const prompt = catalog.prompt({ directives: standardDirectives });
// Wire into the renderer
<JSONUIProvider spec={spec} directives={directives}>
...
</JSONUIProvider>
```
## Available Directives
### `$format` — Locale-aware value formatting
```json
{ "$format": "currency", "value": { "$state": "/cart/total" }, "currency": "USD" }
{ "$format": "date", "value": { "$state": "/user/createdAt" } }
{ "$format": "number", "value": 1234567, "notation": "compact" }
{ "$format": "percent", "value": 0.75 }
```
Formats: `date`, `currency`, `number`, `percent`. The `value` field accepts any dynamic expression.
### `$math` — Arithmetic operations
```json
{ "$math": "add", "a": { "$state": "/subtotal" }, "b": { "$state": "/tax" } }
{ "$math": "multiply", "a": { "$state": "/price" }, "b": { "$state": "/qty" } }
{ "$math": "round", "a": 3.7 }
```
Operations: `add`, `subtract`, `multiply`, `divide`, `mod`, `min`, `max`, `round`, `floor`, `ceil`, `abs`. Unary ops only use `a`.
### `$concat` — String concatenation
```json
{ "$concat": [{ "$state": "/user/firstName" }, " ", { "$state": "/user/lastName" }] }
```
Each element is resolved then joined into a single string.
### `$count` — Array/string length
```json
{ "$count": { "$state": "/cart/items" } }
```
Returns the length of an array or string. Returns `0` for other types.
### `$truncate` — Text truncation
```json
{ "$truncate": { "$state": "/post/body" }, "length": 140, "suffix": "..." }
```
Default length is 100, default suffix is `"..."`.
### `$pluralize` — Singular/plural forms
```json
{ "$pluralize": { "$state": "/cart/itemCount" }, "one": "item", "other": "items", "zero": "no items" }
```
Outputs: `"3 items"`, `"1 item"`, or `"no items"`. The `zero` form is optional.
### `$join` — Join array elements
```json
{ "$join": { "$state": "/tags" }, "separator": ", " }
```
Default separator is `", "`.
### `createI18nDirective` — Internationalization
```typescript
import { createI18nDirective } from '@json-render/directives';
const t = createI18nDirective({
locale: 'en',
messages: {
en: { "greeting": "Hello, {{name}}!", "checkout.submit": "Place Order" },
es: { "greeting": "Hola, {{name}}!", "checkout.submit": "Realizar Pedido" },
},
fallbackLocale: 'en',
});
```
```json
{ "$t": "checkout.submit" }
{ "$t": "greeting", "params": { "name": { "$state": "/user/name" } } }
```
This is a factory function because it requires locale configuration at setup time.
## Composition
Directives compose naturally — each resolver calls `resolvePropValue` on its inputs:
```json
{
"$format": "currency",
"value": { "$math": "multiply", "a": { "$state": "/price" }, "b": { "$state": "/qty" } },
"currency": "USD"
}
```
This resolves inside-out: `$math` first, then `$format` on the result.
## License
Apache-2.0
-58
View File
@@ -1,58 +0,0 @@
{
"name": "@json-render/directives",
"version": "0.19.0",
"license": "Apache-2.0",
"description": "Pre-built directives for @json-render/core — $format, $math, $concat, $count, $truncate, $pluralize, $join, and $t (i18n).",
"keywords": [
"json-render",
"directives",
"format",
"i18n",
"math",
"generative-ui"
],
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/directives"
},
"homepage": "https://json-render.dev",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
"publishConfig": {
"access": "public"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@json-render/core": "workspace:*"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
"tsup": "^8.0.2",
"typescript": "^5.4.5",
"vitest": "^4.0.17",
"zod": "^4.3.6"
},
"peerDependencies": {
"zod": "^4.0.0"
}
}
-18
View File
@@ -1,18 +0,0 @@
import { z } from "zod";
import { defineDirective, resolvePropValue } from "@json-render/core";
export const concatDirective = defineDirective({
name: "$concat",
description: "Concatenate multiple dynamic values into a string.",
schema: z.object({
$concat: z.array(z.unknown()),
}),
resolve(raw, ctx) {
return raw.$concat
.map((part) => {
const resolved = resolvePropValue(part, ctx);
return resolved != null ? String(resolved) : "";
})
.join("");
},
});
-16
View File
@@ -1,16 +0,0 @@
import { z } from "zod";
import { defineDirective, resolvePropValue } from "@json-render/core";
export const countDirective = defineDirective({
name: "$count",
description: "Get the length of an array or string.",
schema: z.object({
$count: z.unknown(),
}),
resolve(raw, ctx) {
const resolved = resolvePropValue(raw.$count, ctx);
if (Array.isArray(resolved)) return resolved.length;
if (typeof resolved === "string") return resolved.length;
return 0;
},
});
-497
View File
@@ -1,497 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import {
resolvePropValue,
createDirectiveRegistry,
type PropResolutionContext,
} from "@json-render/core";
import { formatDirective } from "./format";
import { mathDirective } from "./math";
import { concatDirective } from "./concat";
import { countDirective } from "./count";
import { truncateDirective } from "./truncate";
import { pluralizeDirective } from "./pluralize";
import { joinDirective } from "./join";
import { createI18nDirective } from "./i18n";
const allDirectives = [
formatDirective,
mathDirective,
concatDirective,
countDirective,
truncateDirective,
pluralizeDirective,
joinDirective,
];
function makeCtx(
state: Record<string, unknown> = {},
extra: Partial<PropResolutionContext> = {},
): PropResolutionContext {
return {
stateModel: state,
directives: createDirectiveRegistry(allDirectives),
...extra,
};
}
// ============================================================================
// $format
// ============================================================================
describe("$format", () => {
it("formats a number", () => {
const ctx = makeCtx();
const result = resolvePropValue({ $format: "number", value: 1234.56 }, ctx);
expect(typeof result).toBe("string");
expect(result).toContain("1");
});
it("formats currency", () => {
const ctx = makeCtx({ total: 42.5 });
const result = resolvePropValue(
{ $format: "currency", value: { $state: "/total" }, currency: "USD" },
ctx,
);
expect(typeof result).toBe("string");
expect(result).toContain("42");
});
it("formats percent", () => {
const ctx = makeCtx();
const result = resolvePropValue({ $format: "percent", value: 0.75 }, ctx);
expect(typeof result).toBe("string");
expect(result).toContain("75");
});
it("formats a date", () => {
const ctx = makeCtx();
const result = resolvePropValue(
{ $format: "date", value: "2024-01-15" },
ctx,
);
expect(typeof result).toBe("string");
expect(result).toContain("2024");
});
it("formats a relative date with injectable now", () => {
const ctx = makeCtx();
const baseDate = new Date("2024-06-15T12:00:00Z").getTime();
const result = resolvePropValue(
{
$format: "date",
value: "2024-06-15T12:00:00Z",
style: "relative",
now: baseDate + 3 * 60 * 60 * 1000,
},
ctx,
);
expect(result).toBe("3h ago");
});
it("formats a future relative date", () => {
const ctx = makeCtx();
const baseDate = new Date("2024-06-15T12:00:00Z").getTime();
const result = resolvePropValue(
{
$format: "date",
value: "2024-06-15T12:00:00Z",
style: "relative",
now: baseDate - 2 * 60 * 60 * 1000,
},
ctx,
);
expect(result).toBe("2h from now");
});
it("returns 'just now' when date equals now", () => {
const ctx = makeCtx();
const ts = new Date("2024-06-15T12:00:00Z").getTime();
const result = resolvePropValue(
{
$format: "date",
value: "2024-06-15T12:00:00Z",
style: "relative",
now: ts,
},
ctx,
);
expect(result).toBe("just now");
});
});
// ============================================================================
// $math
// ============================================================================
describe("$math", () => {
it("adds two values", () => {
const ctx = makeCtx({ a: 10, b: 5 });
expect(
resolvePropValue(
{ $math: "add", a: { $state: "/a" }, b: { $state: "/b" } },
ctx,
),
).toBe(15);
});
it("subtracts", () => {
const ctx = makeCtx();
expect(resolvePropValue({ $math: "subtract", a: 10, b: 3 }, ctx)).toBe(7);
});
it("multiplies", () => {
const ctx = makeCtx();
expect(resolvePropValue({ $math: "multiply", a: 4, b: 5 }, ctx)).toBe(20);
});
it("divides", () => {
const ctx = makeCtx();
expect(resolvePropValue({ $math: "divide", a: 10, b: 4 }, ctx)).toBe(2.5);
});
it("handles division by zero", () => {
const ctx = makeCtx();
expect(resolvePropValue({ $math: "divide", a: 10, b: 0 }, ctx)).toBe(0);
});
it("computes modulo", () => {
const ctx = makeCtx();
expect(resolvePropValue({ $math: "mod", a: 10, b: 3 }, ctx)).toBe(1);
});
it("computes min", () => {
const ctx = makeCtx();
expect(resolvePropValue({ $math: "min", a: 10, b: 3 }, ctx)).toBe(3);
});
it("computes max", () => {
const ctx = makeCtx();
expect(resolvePropValue({ $math: "max", a: 10, b: 3 }, ctx)).toBe(10);
});
it("rounds", () => {
const ctx = makeCtx();
expect(resolvePropValue({ $math: "round", a: 3.7 }, ctx)).toBe(4);
});
it("floors", () => {
const ctx = makeCtx();
expect(resolvePropValue({ $math: "floor", a: 3.7 }, ctx)).toBe(3);
});
it("ceils", () => {
const ctx = makeCtx();
expect(resolvePropValue({ $math: "ceil", a: 3.2 }, ctx)).toBe(4);
});
it("computes abs", () => {
const ctx = makeCtx();
expect(resolvePropValue({ $math: "abs", a: -5 }, ctx)).toBe(5);
});
it("defaults missing operand b to 0", () => {
const ctx = makeCtx();
expect(resolvePropValue({ $math: "add", a: 5 }, ctx)).toBe(5);
});
it("defaults missing operand a to 0", () => {
const ctx = makeCtx();
expect(resolvePropValue({ $math: "add", b: 3 }, ctx)).toBe(3);
});
it("warns when a non-numeric value is coerced to 0", () => {
const ctx = makeCtx();
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
const result = resolvePropValue({ $math: "add", a: "foo", b: 3 }, ctx);
expect(result).toBe(3);
expect(spy).toHaveBeenCalledWith(
"$math: non-numeric value coerced to 0:",
"foo",
);
spy.mockRestore();
});
});
// ============================================================================
// $concat
// ============================================================================
describe("$concat", () => {
it("concatenates strings", () => {
const ctx = makeCtx({ first: "John", last: "Doe" });
expect(
resolvePropValue(
{ $concat: [{ $state: "/first" }, " ", { $state: "/last" }] },
ctx,
),
).toBe("John Doe");
});
it("handles null values", () => {
const ctx = makeCtx();
expect(resolvePropValue({ $concat: ["hello", null, "world"] }, ctx)).toBe(
"helloworld",
);
});
it("converts non-strings", () => {
const ctx = makeCtx();
expect(resolvePropValue({ $concat: ["count: ", 42] }, ctx)).toBe(
"count: 42",
);
});
});
// ============================================================================
// $count
// ============================================================================
describe("$count", () => {
it("counts array items", () => {
const ctx = makeCtx({ items: [1, 2, 3] });
expect(resolvePropValue({ $count: { $state: "/items" } }, ctx)).toBe(3);
});
it("counts string length", () => {
const ctx = makeCtx();
expect(resolvePropValue({ $count: "hello" }, ctx)).toBe(5);
});
it("returns 0 for non-countable", () => {
const ctx = makeCtx();
expect(resolvePropValue({ $count: 42 }, ctx)).toBe(0);
});
it("returns 0 for empty array", () => {
const ctx = makeCtx({ items: [] });
expect(resolvePropValue({ $count: { $state: "/items" } }, ctx)).toBe(0);
});
});
// ============================================================================
// $truncate
// ============================================================================
describe("$truncate", () => {
it("truncates long text", () => {
const ctx = makeCtx();
const text = "a".repeat(200);
const result = resolvePropValue(
{ $truncate: text, length: 10, suffix: "..." },
ctx,
) as string;
expect(result.length).toBe(13);
expect(result).toBe("a".repeat(10) + "...");
});
it("does not truncate short text", () => {
const ctx = makeCtx();
expect(resolvePropValue({ $truncate: "hello", length: 10 }, ctx)).toBe(
"hello",
);
});
it("uses default length and suffix", () => {
const ctx = makeCtx();
const text = "a".repeat(200);
const result = resolvePropValue({ $truncate: text }, ctx) as string;
expect(result.length).toBe(103); // 100 + "..."
});
it("resolves dynamic values", () => {
const ctx = makeCtx({ body: "hello world this is a test" });
expect(
resolvePropValue(
{ $truncate: { $state: "/body" }, length: 11, suffix: "…" },
ctx,
),
).toBe("hello world…");
});
});
// ============================================================================
// $pluralize
// ============================================================================
describe("$pluralize", () => {
it("handles singular", () => {
const ctx = makeCtx();
expect(
resolvePropValue({ $pluralize: 1, one: "item", other: "items" }, ctx),
).toBe("1 item");
});
it("handles plural", () => {
const ctx = makeCtx();
expect(
resolvePropValue({ $pluralize: 5, one: "item", other: "items" }, ctx),
).toBe("5 items");
});
it("handles zero with zero form", () => {
const ctx = makeCtx();
expect(
resolvePropValue(
{ $pluralize: 0, one: "item", other: "items", zero: "no items" },
ctx,
),
).toBe("no items");
});
it("handles zero without zero form", () => {
const ctx = makeCtx();
expect(
resolvePropValue({ $pluralize: 0, one: "item", other: "items" }, ctx),
).toBe("0 items");
});
it("resolves count from state", () => {
const ctx = makeCtx({ count: 3 });
expect(
resolvePropValue(
{ $pluralize: { $state: "/count" }, one: "file", other: "files" },
ctx,
),
).toBe("3 files");
});
it("coerces string count to number", () => {
const ctx = makeCtx();
expect(
resolvePropValue({ $pluralize: "3", one: "item", other: "items" }, ctx),
).toBe("3 items");
});
});
// ============================================================================
// $join
// ============================================================================
describe("$join", () => {
it("joins array with default separator", () => {
const ctx = makeCtx({ tags: ["red", "green", "blue"] });
expect(resolvePropValue({ $join: { $state: "/tags" } }, ctx)).toBe(
"red, green, blue",
);
});
it("joins with custom separator", () => {
const ctx = makeCtx({ tags: ["a", "b", "c"] });
expect(
resolvePropValue({ $join: { $state: "/tags" }, separator: " | " }, ctx),
).toBe("a | b | c");
});
it("handles non-array values", () => {
const ctx = makeCtx();
expect(resolvePropValue({ $join: "hello" }, ctx)).toBe("hello");
});
it("handles null items", () => {
const ctx = makeCtx({ items: ["a", null, "b"] });
expect(
resolvePropValue({ $join: { $state: "/items" }, separator: "-" }, ctx),
).toBe("a--b");
});
});
// ============================================================================
// $t (i18n)
// ============================================================================
describe("createI18nDirective", () => {
const tDirective = createI18nDirective({
locale: "en",
messages: {
en: {
greeting: "Hello, {{name}}!",
"checkout.submit": "Place Order",
"items.count": "{{count}} items in cart",
},
es: {
greeting: "Hola, {{name}}!",
"checkout.submit": "Realizar Pedido",
},
},
fallbackLocale: "en",
});
function makeI18nCtx(
state: Record<string, unknown> = {},
): PropResolutionContext {
return {
stateModel: state,
directives: createDirectiveRegistry([tDirective]),
};
}
it("translates a simple key", () => {
const ctx = makeI18nCtx();
expect(resolvePropValue({ $t: "checkout.submit" }, ctx)).toBe(
"Place Order",
);
});
it("interpolates parameters", () => {
const ctx = makeI18nCtx({ name: "Alice" });
expect(
resolvePropValue(
{ $t: "greeting", params: { name: { $state: "/name" } } },
ctx,
),
).toBe("Hello, Alice!");
});
it("returns key for missing translations", () => {
const ctx = makeI18nCtx();
expect(resolvePropValue({ $t: "missing.key" }, ctx)).toBe("missing.key");
});
it("handles multiple params", () => {
const ctx = makeI18nCtx({ count: 3 });
expect(
resolvePropValue(
{ $t: "items.count", params: { count: { $state: "/count" } } },
ctx,
),
).toBe("3 items in cart");
});
});
// ============================================================================
// Composition tests
// ============================================================================
describe("directive composition", () => {
it("composes $math inside $format", () => {
const ctx = makeCtx({ price: 10, qty: 3 });
const result = resolvePropValue(
{
$format: "currency",
value: {
$math: "multiply",
a: { $state: "/price" },
b: { $state: "/qty" },
},
currency: "USD",
},
ctx,
);
expect(typeof result).toBe("string");
expect(result).toContain("30");
});
it("composes $count inside $pluralize", () => {
const ctx = makeCtx({ items: [1, 2, 3] });
expect(
resolvePropValue(
{
$pluralize: { $count: { $state: "/items" } },
one: "item",
other: "items",
},
ctx,
),
).toBe("3 items");
});
});
-71
View File
@@ -1,71 +0,0 @@
import { z } from "zod";
import { defineDirective, resolvePropValue } from "@json-render/core";
export const formatDirective = defineDirective({
name: "$format",
description:
'Locale-aware value formatting (date, currency, number, percent). Supports style: "relative" for relative dates.',
schema: z.object({
$format: z.enum(["date", "currency", "number", "percent"]),
value: z.unknown(),
locale: z.string().optional(),
currency: z.string().optional(),
notation: z.string().optional(),
style: z.string().optional(),
options: z.record(z.string(), z.unknown()).optional(),
now: z.number().optional(),
}),
resolve(raw, ctx) {
const value = resolvePropValue(raw.value, ctx);
const locale = raw.locale ?? undefined;
const extra = raw.options ?? {};
switch (raw.$format) {
case "date": {
const date =
value instanceof Date ? value : new Date(value as string | number);
if (raw.style === "relative") {
const now = raw.now ?? Date.now();
const diff = now - date.getTime();
if (diff === 0) return "just now";
const absDiff = Math.abs(diff);
const suffix = diff > 0 ? "ago" : "from now";
const seconds = Math.floor(absDiff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ${suffix}`;
if (hours > 0) return `${hours}h ${suffix}`;
if (minutes > 0) return `${minutes}m ${suffix}`;
return `${seconds}s ${suffix}`;
}
return new Intl.DateTimeFormat(
locale,
extra as Intl.DateTimeFormatOptions,
).format(date);
}
case "currency":
return new Intl.NumberFormat(locale, {
style: "currency",
currency: raw.currency ?? "USD",
...(extra as Intl.NumberFormatOptions),
}).format(value as number);
case "number":
return new Intl.NumberFormat(locale, {
...(raw.notation
? {
notation: raw.notation as Intl.NumberFormatOptions["notation"],
}
: {}),
...(extra as Intl.NumberFormatOptions),
}).format(value as number);
case "percent":
return new Intl.NumberFormat(locale, {
style: "percent",
...(extra as Intl.NumberFormatOptions),
}).format(value as number);
default:
return value;
}
},
});
-58
View File
@@ -1,58 +0,0 @@
import { z } from "zod";
import { defineDirective, resolvePropValue } from "@json-render/core";
import type { DirectiveDefinition } from "@json-render/core";
export interface I18nConfig {
/** Current locale (e.g. "en", "es") */
locale: string;
/** Map of locale → key → translated string */
messages: Record<string, Record<string, string>>;
/** Fallback locale when a key is missing in the current locale */
fallbackLocale?: string;
}
/**
* Create a `$t` directive for internationalization.
*
* @example
* ```ts
* const t = createI18nDirective({
* locale: 'en',
* messages: {
* en: { "greeting": "Hello, {{name}}!" },
* es: { "greeting": "Hola, {{name}}!" },
* },
* });
* ```
*/
export function createI18nDirective(config: I18nConfig): DirectiveDefinition {
return defineDirective({
name: "$t",
description: "Translated text with {{param}} interpolation.",
schema: z.object({
$t: z.string(),
params: z.record(z.string(), z.unknown()).optional(),
}),
resolve(raw, ctx) {
const key = raw.$t;
const localeMessages = config.messages[config.locale];
const fallbackMessages = config.fallbackLocale
? config.messages[config.fallbackLocale]
: undefined;
let template = localeMessages?.[key] ?? fallbackMessages?.[key] ?? key;
if (raw.params) {
for (const [paramKey, paramValue] of Object.entries(raw.params)) {
const resolved = resolvePropValue(paramValue, ctx);
template = template.replace(
new RegExp(`\\{\\{${paramKey}\\}\\}`, "g"),
resolved != null ? String(resolved) : "",
);
}
}
return template;
},
});
}
-31
View File
@@ -1,31 +0,0 @@
export { formatDirective } from "./format";
export { mathDirective } from "./math";
export { concatDirective } from "./concat";
export { countDirective } from "./count";
export { truncateDirective } from "./truncate";
export { pluralizeDirective } from "./pluralize";
export { joinDirective } from "./join";
export { createI18nDirective, type I18nConfig } from "./i18n";
import { formatDirective } from "./format";
import { mathDirective } from "./math";
import { concatDirective } from "./concat";
import { countDirective } from "./count";
import { truncateDirective } from "./truncate";
import { pluralizeDirective } from "./pluralize";
import { joinDirective } from "./join";
/**
* All non-factory directives in a single array.
* Spread with factory directives as needed:
* `[...standardDirectives, createI18nDirective(config)]`
*/
export const standardDirectives = [
formatDirective,
mathDirective,
concatDirective,
countDirective,
truncateDirective,
pluralizeDirective,
joinDirective,
];
-22
View File
@@ -1,22 +0,0 @@
import { z } from "zod";
import { defineDirective, resolvePropValue } from "@json-render/core";
export const joinDirective = defineDirective({
name: "$join",
description: "Join array elements with a separator.",
schema: z.object({
$join: z.unknown(),
separator: z.string().optional(),
}),
resolve(raw, ctx) {
const resolved = resolvePropValue(raw.$join, ctx);
const separator = raw.separator ?? ", ";
if (Array.isArray(resolved)) {
return resolved
.map((item) => (item != null ? String(item) : ""))
.join(separator);
}
return resolved != null ? String(resolved) : "";
},
});
-66
View File
@@ -1,66 +0,0 @@
import { z } from "zod";
import { defineDirective, resolvePropValue } from "@json-render/core";
function toNum(v: unknown): number {
if (v == null) return 0;
const n = Number(v);
if (Number.isNaN(n)) {
console.warn(`$math: non-numeric value coerced to 0:`, v);
return 0;
}
return n;
}
export const mathDirective = defineDirective({
name: "$math",
description:
'Arithmetic operations. Unary ops (round, floor, ceil, abs) only use "a". Division by zero returns 0.',
schema: z.object({
$math: z.enum([
"add",
"subtract",
"multiply",
"divide",
"mod",
"min",
"max",
"round",
"floor",
"ceil",
"abs",
]),
a: z.unknown().optional(),
b: z.unknown().optional(),
}),
resolve(raw, ctx) {
const a = toNum(resolvePropValue(raw.a, ctx));
const b = toNum(resolvePropValue(raw.b, ctx));
switch (raw.$math) {
case "add":
return a + b;
case "subtract":
return a - b;
case "multiply":
return a * b;
case "divide":
return b !== 0 ? a / b : 0;
case "mod":
return b !== 0 ? a % b : 0;
case "min":
return Math.min(a, b);
case "max":
return Math.max(a, b);
case "round":
return Math.round(a);
case "floor":
return Math.floor(a);
case "ceil":
return Math.ceil(a);
case "abs":
return Math.abs(a);
default:
return a;
}
},
});
-23
View File
@@ -1,23 +0,0 @@
import { z } from "zod";
import { defineDirective, resolvePropValue } from "@json-render/core";
export const pluralizeDirective = defineDirective({
name: "$pluralize",
description:
'Select singular/plural/zero form based on count. Output: "3 items", "1 item", or "no items".',
schema: z.object({
$pluralize: z.unknown(),
zero: z.string().optional(),
one: z.string(),
other: z.string(),
}),
resolve(raw, ctx) {
const resolved = resolvePropValue(raw.$pluralize, ctx);
const n = Number(resolved);
const count = Number.isNaN(n) ? 0 : n;
if (count === 0 && raw.zero != null) return raw.zero;
if (count === 1) return `${count} ${raw.one}`;
return `${count} ${raw.other}`;
},
});
-21
View File
@@ -1,21 +0,0 @@
import { z } from "zod";
import { defineDirective, resolvePropValue } from "@json-render/core";
export const truncateDirective = defineDirective({
name: "$truncate",
description: "Truncate text to a max length with a suffix.",
schema: z.object({
$truncate: z.unknown(),
length: z.number().optional(),
suffix: z.string().optional(),
}),
resolve(raw, ctx) {
const resolved = resolvePropValue(raw.$truncate, ctx);
const text = resolved != null ? String(resolved) : "";
const maxLength = raw.length ?? 100;
const suffix = raw.suffix ?? "...";
if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + suffix;
},
});
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "@internal/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
-10
View File
@@ -1,10 +0,0 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["cjs", "esm"],
dts: true,
sourcemap: true,
clean: true,
external: ["@json-render/core", "zod"],
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/image",
"version": "0.19.0",
"version": "0.18.0",
"license": "Apache-2.0",
"description": "Image renderer for @json-render/core. JSON becomes SVG and PNG images via Satori.",
"keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/ink",
"version": "0.19.0",
"version": "0.18.0",
"license": "Apache-2.0",
"description": "Ink terminal renderer for @json-render/core. JSON becomes terminal UIs.",
"keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/jotai",
"version": "0.19.0",
"version": "0.18.0",
"license": "Apache-2.0",
"description": "Jotai adapter for json-render StateStore",
"keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/mcp",
"version": "0.19.0",
"version": "0.18.0",
"license": "Apache-2.0",
"description": "MCP Apps integration for @json-render/core. Serve json-render UIs as interactive MCP Apps in Claude, ChatGPT, Cursor, and VS Code.",
"keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/next",
"version": "0.19.0",
"version": "0.18.0",
"license": "Apache-2.0",
"description": "Next.js renderer for @json-render/core. JSON becomes full Next.js applications with routes, layouts, metadata, and SSR.",
"keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react-email",
"version": "0.19.0",
"version": "0.18.0",
"license": "Apache-2.0",
"description": "React Email renderer for @json-render/core. JSON becomes HTML emails.",
"keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react-native",
"version": "0.19.0",
"version": "0.18.0",
"license": "Apache-2.0",
"description": "React Native renderer for @json-render/core. JSON becomes React Native components.",
"keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react-pdf",
"version": "0.19.0",
"version": "0.18.0",
"license": "Apache-2.0",
"description": "React PDF renderer for @json-render/core. JSON becomes PDF documents.",
"keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react-three-fiber",
"version": "0.19.0",
"version": "0.18.0",
"license": "Apache-2.0",
"description": "React Three Fiber renderer for @json-render/core. JSON becomes 3D scenes.",
"keywords": [
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react",
"version": "0.19.0",
"version": "0.18.0",
"license": "Apache-2.0",
"description": "React renderer for @json-render/core. JSON becomes React components.",
"keywords": [
@@ -57,8 +57,7 @@
"@internal/typescript-config": "workspace:*",
"@types/react": "19.2.3",
"tsup": "^8.0.2",
"typescript": "^5.4.5",
"zod": "^4.3.6"
"typescript": "^5.4.5"
},
"peerDependencies": {
"react": "^19.2.3"
-110
View File
@@ -1,110 +0,0 @@
import { describe, it, expect } from "vitest";
import React from "react";
import { render, screen } from "@testing-library/react";
import { z } from "zod";
import type { Spec } from "@json-render/core";
import { defineDirective, resolvePropValue } from "@json-render/core";
import {
JSONUIProvider,
Renderer,
type ComponentRenderProps,
} from "./renderer";
function Text({ element }: ComponentRenderProps<{ text: unknown }>) {
const value = element.props.text;
return <span data-testid="text">{value == null ? "" : String(value)}</span>;
}
const registry = { Text };
const doubleDirective = defineDirective({
name: "$double",
description: "Double a numeric value.",
schema: z.object({ $double: z.unknown() }),
resolve(value, ctx) {
const resolved = resolvePropValue(value.$double, ctx);
return (resolved as number) * 2;
},
});
const upperDirective = defineDirective({
name: "$upper",
schema: z.object({ $upper: z.unknown() }),
resolve(value, ctx) {
const resolved = resolvePropValue(value.$upper, ctx);
return String(resolved).toUpperCase();
},
});
describe("directives in React renderer", () => {
it("resolves a custom directive in rendered props", () => {
const spec: Spec = {
root: "main",
elements: {
main: {
type: "Text",
props: { text: { $upper: "hello" } },
children: [],
},
},
};
render(
<JSONUIProvider
registry={registry}
directives={[doubleDirective, upperDirective]}
>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
expect(screen.getByTestId("text").textContent).toBe("HELLO");
});
it("resolves a directive that reads from state", () => {
const spec: Spec = {
root: "main",
elements: {
main: {
type: "Text",
props: { text: { $double: { $state: "/count" } } },
children: [],
},
},
state: { count: 7 },
};
render(
<JSONUIProvider
registry={registry}
initialState={{ count: 7 }}
directives={[doubleDirective, upperDirective]}
>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
expect(screen.getByTestId("text").textContent).toBe("14");
});
it("renders without directives (backward compat)", () => {
const spec: Spec = {
root: "main",
elements: {
main: {
type: "Text",
props: { text: "plain" },
children: [],
},
},
};
render(
<JSONUIProvider registry={registry}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
expect(screen.getByTestId("text").textContent).toBe("plain");
});
});
+11 -47
View File
@@ -17,8 +17,6 @@ import type {
SchemaDefinition,
StateStore,
ComputedFunction,
DirectiveDefinition,
DirectiveRegistry,
} from "@json-render/core";
import {
resolveElementProps,
@@ -28,7 +26,6 @@ import {
getByPath,
isDevtoolsActive,
subscribeDevtoolsActive,
createDirectiveRegistry,
type PropResolutionContext,
type VisibilityContext as CoreVisibilityContext,
} from "@json-render/core";
@@ -157,18 +154,6 @@ function useFunctions(): Record<string, ComputedFunction> {
return React.useContext(FunctionsContext);
}
// ---------------------------------------------------------------------------
// DirectivesContext – provides custom directive registry to the element tree
// ---------------------------------------------------------------------------
const DirectivesContext = React.createContext<DirectiveRegistry | undefined>(
undefined,
);
function useDirectives(): DirectiveRegistry | undefined {
return React.useContext(DirectivesContext);
}
interface ElementRendererProps {
element: UIElement;
/** Spec key for this element. Used by the devtools picker. */
@@ -209,9 +194,8 @@ const ElementRenderer = React.memo(function ElementRenderer({
const { execute } = useActions();
const { getSnapshot, state: watchState } = useStateStore();
const functions = useFunctions();
const directives = useDirectives();
// Build context with repeat scope, $computed functions, and custom directives
// Build context with repeat scope and $computed functions
const fullCtx: PropResolutionContext = useMemo(() => {
const base: PropResolutionContext = repeatScope
? {
@@ -222,9 +206,8 @@ const ElementRenderer = React.memo(function ElementRenderer({
}
: { ...ctx };
base.functions = functions;
base.directives = directives;
return base;
}, [ctx, repeatScope, functions, directives]);
}, [ctx, repeatScope, functions]);
// Evaluate visibility (now supports $item/$index inside repeat scopes)
const isVisible =
@@ -571,8 +554,6 @@ export interface JSONUIProviderProps {
>;
/** Named functions for `$computed` expressions in props */
functions?: Record<string, ComputedFunction>;
/** Custom directives for user-defined `$`-prefixed dynamic values */
directives?: DirectiveDefinition[];
/** Callback when state changes (uncontrolled mode) */
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
children: ReactNode;
@@ -589,14 +570,9 @@ export function JSONUIProvider({
navigate,
validationFunctions,
functions,
directives,
onStateChange,
children,
}: JSONUIProviderProps) {
const directiveRegistry = useMemo(
() => (directives ? createDirectiveRegistry(directives) : undefined),
[directives],
);
return (
<StateProvider
store={store}
@@ -607,10 +583,8 @@ export function JSONUIProvider({
<ValidationProvider customFunctions={validationFunctions}>
<ActionProvider handlers={handlers} navigate={navigate}>
<FunctionsContext.Provider value={functions ?? EMPTY_FUNCTIONS}>
<DirectivesContext.Provider value={directiveRegistry}>
{children}
<ConfirmationDialogManager />
</DirectivesContext.Provider>
{children}
<ConfirmationDialogManager />
</FunctionsContext.Provider>
</ActionProvider>
</ValidationProvider>
@@ -816,8 +790,6 @@ export interface CreateRendererProps {
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
/** Named functions for `$computed` expressions in props */
functions?: Record<string, ComputedFunction>;
/** Custom directives for user-defined `$`-prefixed dynamic values */
directives?: DirectiveDefinition[];
/** Whether the spec is currently loading/streaming */
loading?: boolean;
/** Fallback component for unknown types */
@@ -872,15 +844,9 @@ export function createRenderer<
onAction,
onStateChange,
functions,
directives,
loading,
fallback,
}: CreateRendererProps) {
const directiveRegistry = useMemo(
() => (directives ? createDirectiveRegistry(directives) : undefined),
[directives],
);
// Wrap onAction with a Proxy so any action name routes to the callback
const actionHandlers = onAction
? new Proxy(
@@ -908,15 +874,13 @@ export function createRenderer<
<ValidationProvider>
<ActionProvider handlers={actionHandlers}>
<FunctionsContext.Provider value={functions ?? EMPTY_FUNCTIONS}>
<DirectivesContext.Provider value={directiveRegistry}>
<Renderer
spec={spec}
registry={registry}
loading={loading}
fallback={fallback}
/>
<ConfirmationDialogManager />
</DirectivesContext.Provider>
<Renderer
spec={spec}
registry={registry}
loading={loading}
fallback={fallback}
/>
<ConfirmationDialogManager />
</FunctionsContext.Provider>
</ActionProvider>
</ValidationProvider>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/redux",
"version": "0.19.0",
"version": "0.18.0",
"license": "Apache-2.0",
"description": "Redux adapter for json-render StateStore",
"keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/remotion",
"version": "0.19.0",
"version": "0.18.0",
"license": "Apache-2.0",
"description": "Remotion renderer for @json-render/core. JSON becomes video compositions.",
"keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/shadcn-svelte",
"version": "0.19.0",
"version": "0.18.0",
"license": "Apache-2.0",
"description": "shadcn-svelte component library for @json-render/svelte. JSON becomes beautiful Tailwind-styled Svelte components.",
"keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/shadcn",
"version": "0.19.0",
"version": "0.18.0",
"license": "Apache-2.0",
"description": "shadcn/ui component library for @json-render/core. JSON becomes beautiful Tailwind-styled React components.",
"keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/solid",
"version": "0.19.0",
"version": "0.18.0",
"license": "Apache-2.0",
"description": "SolidJS renderer for @json-render/core. JSON becomes Solid components.",
"keywords": [
+10 -42
View File
@@ -19,8 +19,6 @@ import type {
SchemaDefinition,
StateStore,
ComputedFunction,
DirectiveDefinition,
DirectiveRegistry,
} from "@json-render/core";
import {
resolveElementProps,
@@ -30,7 +28,6 @@ import {
getByPath,
isDevtoolsActive,
subscribeDevtoolsActive,
createDirectiveRegistry,
type PropResolutionContext,
type VisibilityContext as CoreVisibilityContext,
} from "@json-render/core";
@@ -114,18 +111,6 @@ function useFunctions(): Record<string, ComputedFunction> {
return useContext(FunctionsContext) ?? EMPTY_FUNCTIONS;
}
// ---------------------------------------------------------------------------
// DirectivesContext – provides custom directive registry to the element tree
// ---------------------------------------------------------------------------
const DirectivesContext = createContext<DirectiveRegistry | undefined>(
undefined,
);
function useDirectives(): DirectiveRegistry | undefined {
return useContext(DirectivesContext);
}
interface ElementRendererProps {
element: UIElement;
/** Spec key for this element. Used by the devtools picker. */
@@ -157,9 +142,8 @@ function ElementRenderer(props: ElementRendererProps) {
const stateStore = useStateStore();
const getSnapshot = () => stateStore.getSnapshot();
const functions = useFunctions();
const directives = useDirectives();
// Build context with repeat scope, $computed functions, and custom directives
// Build context with repeat scope and $computed functions
const fullCtx = createMemo<PropResolutionContext>(() => {
const repeatItem = repeatScope?.item;
const repeatIndex = repeatScope?.index;
@@ -173,7 +157,6 @@ function ElementRenderer(props: ElementRendererProps) {
...(repeatIndex !== undefined ? { repeatIndex } : {}),
...(repeatBasePath !== undefined ? { repeatBasePath } : {}),
functions,
directives,
};
});
@@ -536,8 +519,6 @@ export interface JSONUIProviderProps {
>;
/** Named functions for `$computed` expressions in props */
functions?: Record<string, ComputedFunction>;
/** Custom directives for user-defined `$`-prefixed dynamic values */
directives?: DirectiveDefinition[];
/** Callback when state changes (uncontrolled mode) */
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
children: JSX.Element;
@@ -547,9 +528,6 @@ export interface JSONUIProviderProps {
* Combined provider for all JSONUI contexts
*/
export function JSONUIProvider(props: JSONUIProviderProps) {
const directiveRegistry = createMemo(() =>
props.directives ? createDirectiveRegistry(props.directives) : undefined,
);
return (
<StateProvider
store={props.store}
@@ -562,10 +540,8 @@ export function JSONUIProvider(props: JSONUIProviderProps) {
<FunctionsContext.Provider
value={props.functions ?? EMPTY_FUNCTIONS}
>
<DirectivesContext.Provider value={directiveRegistry()}>
{props.children}
<ConfirmationDialogManager />
</DirectivesContext.Provider>
{props.children}
<ConfirmationDialogManager />
</FunctionsContext.Provider>
</ActionProvider>
</ValidationProvider>
@@ -772,8 +748,6 @@ export interface CreateRendererProps {
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
/** Named functions for `$computed` expressions in props */
functions?: Record<string, ComputedFunction>;
/** Custom directives for user-defined `$`-prefixed dynamic values */
directives?: DirectiveDefinition[];
/** Whether the spec is currently loading/streaming */
loading?: boolean;
/** Fallback component for unknown types */
@@ -822,10 +796,6 @@ export function createRenderer<
// Return the renderer component
return function CatalogRenderer(props: CreateRendererProps) {
const directiveRegistry = createMemo(() =>
props.directives ? createDirectiveRegistry(props.directives) : undefined,
);
// Wrap onAction with a Proxy so any action name routes to the callback
const actionHandlers = () =>
props.onAction
@@ -856,15 +826,13 @@ export function createRenderer<
<FunctionsContext.Provider
value={props.functions ?? EMPTY_FUNCTIONS}
>
<DirectivesContext.Provider value={directiveRegistry()}>
<Renderer
spec={props.spec}
registry={registry}
loading={props.loading}
fallback={props.fallback}
/>
<ConfirmationDialogManager />
</DirectivesContext.Provider>
<Renderer
spec={props.spec}
registry={registry}
loading={props.loading}
fallback={props.fallback}
/>
<ConfirmationDialogManager />
</FunctionsContext.Provider>
</ActionProvider>
</ValidationProvider>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/svelte",
"version": "0.19.0",
"version": "0.18.0",
"license": "Apache-2.0",
"description": "Svelte 5 renderer for @json-render/core. JSON becomes Svelte components.",
"keywords": [
+1 -5
View File
@@ -1,5 +1,5 @@
<script module lang="ts">
import type { ComputedFunction, DirectiveDefinition, Spec, StateStore } from "@json-render/core";
import type { ComputedFunction, Spec, StateStore } from "@json-render/core";
import type { ComponentRenderer, ComponentRegistry } from "./renderer.js";
export interface CatalogRendererProps {
@@ -10,8 +10,6 @@
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
functions?: Record<string, ComputedFunction>;
/** Custom directives for user-defined `$`-prefixed dynamic values */
directives?: DirectiveDefinition[];
loading?: boolean;
fallback?: ComponentRenderer;
}
@@ -30,7 +28,6 @@
onAction,
onStateChange,
functions,
directives,
loading = false,
fallback,
}: CatalogRendererProps = $props();
@@ -53,7 +50,6 @@
initialState={state}
handlers={actionHandlers}
{functions}
{directives}
{onStateChange}>
<Renderer {spec} {registry} {loading} {fallback} />
</JsonUIProvider>
+2 -5
View File
@@ -16,7 +16,6 @@
import { getActionContext } from "./contexts/ActionProvider.svelte";
import { getRepeatScope } from "./contexts/RepeatScopeProvider.svelte";
import { getFunctions } from "./contexts/FunctionsContextProvider.svelte";
import { getDirectives } from "./contexts/DirectivesContextProvider.svelte";
import RepeatChildren from "./RepeatChildren.svelte";
import Self from "./ElementRenderer.svelte";
@@ -50,9 +49,8 @@
const actionCtx = getActionContext();
const repeatScope = getRepeatScope();
const functions = getFunctions();
const directives = getDirectives();
// Build context with repeat scope, $computed functions, and custom directives
// Build context with repeat scope and $computed functions
let fullCtx = $derived<PropResolutionContext>(
repeatScope
? {
@@ -61,9 +59,8 @@
repeatIndex: repeatScope.index,
repeatBasePath: repeatScope.basePath,
functions,
directives,
}
: { stateModel: stateCtx.state, functions, directives },
: { stateModel: stateCtx.state, functions },
);
// Evaluate visibility
+3 -9
View File
@@ -1,5 +1,5 @@
<script module lang="ts">
import type { ComputedFunction, DirectiveDefinition } from "@json-render/core";
import type { ComputedFunction } from "@json-render/core";
/**
* Props for JSONUIProvider
*/
@@ -24,8 +24,6 @@
>;
/** Named functions for `$computed` expressions in props */
functions?: Record<string, ComputedFunction>;
/** Custom directives for user-defined `$`-prefixed dynamic values */
directives?: DirectiveDefinition[];
/** Callback when state changes (uncontrolled mode) */
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
/** Children snippet */
@@ -41,7 +39,6 @@
import ValidationProvider from "./contexts/ValidationProvider.svelte";
import ActionProvider from "./contexts/ActionProvider.svelte";
import FunctionsContextProvider from "./contexts/FunctionsContextProvider.svelte";
import DirectivesContextProvider from "./contexts/DirectivesContextProvider.svelte";
import ConfirmDialogManager from "./ConfirmDialogManager.svelte";
import type { ComponentRegistry } from "./renderer.js";
@@ -52,7 +49,6 @@
navigate,
validationFunctions = {},
functions,
directives,
onStateChange,
children,
}: JSONUIProviderProps = $props();
@@ -63,10 +59,8 @@
<ValidationProvider customFunctions={validationFunctions}>
<ActionProvider {handlers} {navigate}>
<FunctionsContextProvider {functions}>
<DirectivesContextProvider {directives}>
{@render children()}
<ConfirmDialogManager />
</DirectivesContextProvider>
{@render children()}
<ConfirmDialogManager />
</FunctionsContextProvider>
</ActionProvider>
</ValidationProvider>
@@ -1,46 +0,0 @@
<script module lang="ts">
import { getContext } from "svelte";
import type { DirectiveDefinition, DirectiveRegistry } from "@json-render/core";
import { createDirectiveRegistry } from "@json-render/core";
const DIRECTIVES_KEY = Symbol.for("json-render-directives");
/**
* Directives context value
*/
export interface DirectivesContext {
/** Custom directive registry */
registry: DirectiveRegistry | undefined;
}
/**
* Get the directives registry from component tree
*/
export function getDirectives(): DirectiveRegistry | undefined {
const ctx = getContext<DirectivesContext>(DIRECTIVES_KEY);
return ctx?.registry;
}
</script>
<script lang="ts">
import { setContext, type Snippet } from "svelte";
interface Props {
directives?: DirectiveDefinition[];
children?: Snippet;
}
let { directives, children }: Props = $props();
let registry = $derived(
directives ? createDirectiveRegistry(directives) : undefined,
);
setContext(DIRECTIVES_KEY, {
get registry() {
return registry;
},
} satisfies DirectivesContext);
</script>
{@render children?.()}
-6
View File
@@ -46,12 +46,6 @@ export {
type FunctionsContext,
} from "./contexts/FunctionsContextProvider.svelte";
export {
default as DirectivesContextProvider,
getDirectives,
type DirectivesContext,
} from "./contexts/DirectivesContextProvider.svelte";
// =============================================================================
// Schema
// =============================================================================
-6
View File
@@ -1,7 +1,6 @@
import type {
Catalog,
ComputedFunction,
DirectiveDefinition,
SchemaDefinition,
Spec,
StateStore,
@@ -207,8 +206,6 @@ export interface CreateRendererProps {
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
/** Named functions for `$computed` expressions in props */
functions?: Record<string, ComputedFunction>;
/** Custom directives for user-defined `$`-prefixed dynamic values */
directives?: DirectiveDefinition[];
loading?: boolean;
fallback?: Component;
}
@@ -267,9 +264,6 @@ export function createRenderer<
get functions() {
return props.functions;
},
get directives() {
return props.directives;
},
get loading() {
return props.loading;
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/vue",
"version": "0.19.0",
"version": "0.18.0",
"license": "Apache-2.0",
"description": "Vue renderer for @json-render/core. JSON becomes Vue components.",
"keywords": [
+14 -75
View File
@@ -21,8 +21,6 @@ import type {
ComputedFunction,
SchemaDefinition,
StateStore,
DirectiveDefinition,
DirectiveRegistry,
} from "@json-render/core";
import {
resolveElementProps,
@@ -30,7 +28,6 @@ import {
resolveActionParam,
evaluateVisibility,
getByPath,
createDirectiveRegistry,
isDevtoolsActive,
subscribeDevtoolsActive,
type PropResolutionContext,
@@ -120,36 +117,6 @@ function useFunctions(): ComputedRef<Record<string, ComputedFunction>> {
);
}
// ---------------------------------------------------------------------------
// DirectivesContext — provides custom directive registry to the element tree
// ---------------------------------------------------------------------------
const DIRECTIVES_KEY = Symbol("json-render:directives");
const DirectivesProvider = defineComponent({
name: "DirectivesProvider",
props: {
directives: {
type: Array as PropType<DirectiveDefinition[]>,
default: undefined,
},
},
setup(props, { slots }) {
const registry = computed(() =>
props.directives ? createDirectiveRegistry(props.directives) : undefined,
);
provide(DIRECTIVES_KEY, registry);
return () => slots.default?.();
},
});
function useDirectives(): ComputedRef<DirectiveRegistry | undefined> {
return inject<ComputedRef<DirectiveRegistry | undefined>>(
DIRECTIVES_KEY,
computed(() => undefined),
);
}
// ---------------------------------------------------------------------------
// ElementErrorBoundary — catches rendering errors in individual elements
// ---------------------------------------------------------------------------
@@ -272,9 +239,8 @@ const ElementRenderer = defineComponent({
const { execute } = useActions();
const { getSnapshot, state: watchState } = useStateStore();
const functions = useFunctions();
const directives = useDirectives();
// Build context with repeat scope, $computed functions, and custom directives
// Build context with repeat scope and $computed functions
const fullCtx = computed<PropResolutionContext>(() => {
const base: PropResolutionContext = repeatScope
? {
@@ -285,7 +251,6 @@ const ElementRenderer = defineComponent({
}
: { ...visibilityCtx.value };
base.functions = functions.value;
base.directives = directives.value;
return base;
});
@@ -626,8 +591,6 @@ export interface JSONUIProviderProps {
>;
/** Named functions for `$computed` expressions in props */
functions?: Record<string, ComputedFunction>;
/** Custom directives for user-defined `$`-prefixed dynamic values */
directives?: DirectiveDefinition[];
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
}
@@ -675,10 +638,6 @@ export const JSONUIProvider = defineComponent({
type: Object as PropType<Record<string, ComputedFunction>>,
default: undefined,
},
directives: {
type: Array as PropType<DirectiveDefinition[]>,
default: undefined,
},
onStateChange: {
type: Function as PropType<
(changes: Array<{ path: string; value: unknown }>) => void
@@ -713,17 +672,10 @@ export const JSONUIProvider = defineComponent({
FunctionsProvider,
{ functions: props.functions },
{
default: () =>
h(
DirectivesProvider,
{ directives: props.directives },
{
default: () => [
slots.default?.(),
h(ConfirmationDialogManager),
],
},
),
default: () => [
slots.default?.(),
h(ConfirmationDialogManager),
],
},
),
},
@@ -902,8 +854,6 @@ export interface CreateRendererProps {
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
/** Named functions for `$computed` expressions in props */
functions?: Record<string, ComputedFunction>;
/** Custom directives for user-defined `$`-prefixed dynamic values */
directives?: DirectiveDefinition[];
loading?: boolean;
fallback?: Component;
}
@@ -972,10 +922,6 @@ export function createRenderer<
type: Object as PropType<Record<string, ComputedFunction>>,
default: undefined,
},
directives: {
type: Array as PropType<DirectiveDefinition[]>,
default: undefined,
},
loading: {
type: Boolean,
default: undefined,
@@ -1026,22 +972,15 @@ export function createRenderer<
FunctionsProvider,
{ functions: rendererProps.functions },
{
default: () =>
h(
DirectivesProvider,
{ directives: rendererProps.directives },
{
default: () => [
h(Renderer, {
spec: rendererProps.spec,
registry,
loading: rendererProps.loading,
fallback: rendererProps.fallback,
}),
h(ConfirmationDialogManager),
],
},
),
default: () => [
h(Renderer, {
spec: rendererProps.spec,
registry,
loading: rendererProps.loading,
fallback: rendererProps.fallback,
}),
h(ConfirmationDialogManager),
],
},
),
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/xstate",
"version": "0.19.0",
"version": "0.18.0",
"license": "Apache-2.0",
"description": "XState Store adapter for json-render StateStore",
"keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/yaml",
"version": "0.19.0",
"version": "0.18.0",
"license": "Apache-2.0",
"description": "YAML wire format for @json-render/core. Progressive rendering and surgical edits via streaming YAML.",
"keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/zustand",
"version": "0.19.0",
"version": "0.18.0",
"license": "Apache-2.0",
"description": "Zustand adapter for json-render StateStore",
"keywords": [
+2 -27
View File
@@ -1871,28 +1871,6 @@ importers:
specifier: ^3.5.0
version: 3.5.29(typescript@5.9.3)
packages/directives:
dependencies:
'@json-render/core':
specifier: workspace:*
version: link:../core
devDependencies:
'@internal/typescript-config':
specifier: workspace:*
version: link:../typescript-config
tsup:
specifier: ^8.0.2
version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
typescript:
specifier: ^5.4.5
version: 5.9.3
vitest:
specifier: ^4.0.17
version: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@22.19.6)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.32.0)(msw@2.12.10(@types/node@22.19.6)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)
zod:
specifier: ^4.3.6
version: 4.3.6
packages/eslint-config:
devDependencies:
'@eslint/js':
@@ -2096,9 +2074,6 @@ importers:
typescript:
specifier: ^5.4.5
version: 5.9.3
zod:
specifier: ^4.3.6
version: 4.3.6
packages/react-email:
dependencies:
@@ -19501,7 +19476,7 @@ snapshots:
'@stripe/ui-extension-tools@0.0.1(@babel/core@7.29.0)(babel-jest@27.5.1(@babel/core@7.29.0))':
dependencies:
'@types/jest': 28.1.8
'@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)
'@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)
'@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
eslint: 8.57.1
eslint-plugin-react: 7.37.5(eslint@8.57.1)
@@ -20185,7 +20160,7 @@ snapshots:
'@types/node': 22.19.6
optional: true
'@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)':
'@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
-205
View File
@@ -1,205 +0,0 @@
---
name: directives
description: Pre-built custom directives for json-render — formatting, math, string manipulation, and i18n. Use when working with @json-render/directives, defining custom directives with defineDirective, or adding $format, $math, $concat, $count, $truncate, $pluralize, $join, or $t to specs.
---
# @json-render/directives
Pre-built custom directives for `@json-render/core`. Drop them into your catalog and renderer to add formatting, math, string manipulation, and i18n.
## Quick Start
```typescript
import { standardDirectives } from '@json-render/directives';
// Wire into prompt generation
const prompt = catalog.prompt({ directives: standardDirectives });
// Wire into the renderer (React example)
import { JSONUIProvider, Renderer } from '@json-render/react';
<JSONUIProvider registry={registry} directives={standardDirectives}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
```
To add factory directives like `createI18nDirective`, spread the array:
```typescript
import { standardDirectives, createI18nDirective } from '@json-render/directives';
const directives = [...standardDirectives, createI18nDirective(config)];
```
## Defining Custom Directives
Use `defineDirective` from `@json-render/core`:
```typescript
import { defineDirective, resolvePropValue } from '@json-render/core';
import { z } from 'zod';
const doubleDirective = defineDirective({
name: '$double',
description: 'Double a numeric value.',
schema: z.object({
$double: z.unknown(),
}),
resolve(value, ctx) {
const resolved = resolvePropValue(value.$double, ctx);
return (resolved as number) * 2;
},
});
```
Rules:
- Name must start with `$`
- Name must not conflict with built-in keys (`$state`, `$cond`, `$computed`, `$template`, `$item`, `$index`, `$bindState`, `$bindItem`)
- Resolvers should call `resolvePropValue` on sub-values to support composition
## Built-in Directives
### `$format` — Locale-aware value formatting
Formats values using `Intl` formatters. Supports `date`, `currency`, `number`, and `percent`.
```json
{ "$format": "currency", "value": { "$state": "/cart/total" }, "currency": "USD" }
{ "$format": "date", "value": { "$state": "/user/createdAt" } }
{ "$format": "number", "value": 1234567, "notation": "compact" }
{ "$format": "percent", "value": 0.75 }
{ "$format": "date", "value": { "$state": "/post/createdAt" }, "style": "relative" }
```
Fields: `$format` (date | currency | number | percent), `value` (any expression), `locale?` (string), `currency?` (string, default "USD"), `notation?` (string), `style?` ("relative" for relative dates), `options?` (extra Intl options).
### `$math` — Arithmetic operations
```json
{ "$math": "add", "a": { "$state": "/subtotal" }, "b": { "$state": "/tax" } }
{ "$math": "round", "a": 3.7 }
```
Operations: `add`, `subtract`, `multiply`, `divide`, `mod`, `min`, `max`, `round`, `floor`, `ceil`, `abs`. Unary ops (`round`, `floor`, `ceil`, `abs`) only use `a`. Division by zero returns `0`.
Fields: `$math` (operation enum), `a?` (first operand, default 0), `b?` (second operand, default 0).
### `$concat` — String concatenation
```json
{ "$concat": [{ "$state": "/user/firstName" }, " ", { "$state": "/user/lastName" }] }
```
Fields: `$concat` (array of values to resolve and join into a string).
### `$count` — Array/string length
```json
{ "$count": { "$state": "/cart/items" } }
```
Returns `.length` of arrays or strings, `0` for other types.
Fields: `$count` (value to count).
### `$truncate` — Text truncation
```json
{ "$truncate": { "$state": "/post/body" }, "length": 140, "suffix": "..." }
```
Fields: `$truncate` (value to truncate), `length?` (max chars, default 100), `suffix?` (string, default "...").
### `$pluralize` — Singular/plural forms
```json
{ "$pluralize": { "$state": "/cart/itemCount" }, "one": "item", "other": "items", "zero": "no items" }
```
Output: `"3 items"`, `"1 item"`, or `"no items"`.
Fields: `$pluralize` (count value), `one` (singular label), `other` (plural label), `zero?` (zero label).
### `$join` — Join array elements
```json
{ "$join": { "$state": "/tags" }, "separator": ", " }
```
Fields: `$join` (array to join), `separator?` (string, default ", ").
### `createI18nDirective` — Internationalization factory
```typescript
import { createI18nDirective } from '@json-render/directives';
const tDirective = createI18nDirective({
locale: 'en',
messages: {
en: { "greeting": "Hello, {{name}}!", "checkout.submit": "Place Order" },
es: { "greeting": "Hola, {{name}}!", "checkout.submit": "Realizar Pedido" },
},
fallbackLocale: 'en',
});
```
Usage in specs:
```json
{ "$t": "checkout.submit" }
{ "$t": "greeting", "params": { "name": { "$state": "/user/name" } } }
```
Fields: `$t` (translation key), `params?` (interpolation parameters, values accept expressions).
Config: `locale` (current locale), `messages` (Record<locale, Record<key, string>>), `fallbackLocale?` (fallback when key missing).
## Composition
Directives compose naturally — each resolver calls `resolvePropValue` on its inputs, so directives can wrap other directives or built-in expressions:
```json
{
"$format": "currency",
"value": { "$math": "multiply", "a": { "$state": "/price" }, "b": { "$state": "/qty" } },
"currency": "USD"
}
```
Resolves inside-out: `$state` reads from state, `$math` multiplies, `$format` formats as currency.
## Wiring into Renderers
All four renderers (React, Vue, Svelte, Solid) accept `directives` on their provider and `createRenderer` output:
```tsx
// Provider pattern
<JSONUIProvider registry={registry} directives={directives}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
// createRenderer pattern
const MyRenderer = createRenderer(catalog, components);
<MyRenderer spec={spec} directives={directives} />
```
For prompt generation, pass the same array:
```typescript
const prompt = catalog.prompt({ directives });
```
## Key Exports
| Export | Purpose |
|--------|---------|
| `formatDirective` | `$format` directive definition |
| `mathDirective` | `$math` directive definition |
| `concatDirective` | `$concat` directive definition |
| `countDirective` | `$count` directive definition |
| `truncateDirective` | `$truncate` directive definition |
| `pluralizeDirective` | `$pluralize` directive definition |
| `joinDirective` | `$join` directive definition |
| `createI18nDirective` | Factory for `$t` i18n directive |
| `standardDirectives` | Array of all 7 non-factory directives |
| `I18nConfig` | Type for i18n configuration |