mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-03 12:28:17 +08:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
708ff2f83a | ||
|
|
0d6f997a30 | ||
|
|
e4b3e5a67b | ||
|
|
fc40f210da | ||
|
|
8e3f2f7042 | ||
|
|
aafb4fae6f | ||
|
|
b76997fb4e | ||
|
|
85abd444c0 | ||
|
|
93bf92b29c | ||
|
|
e6f68c02d0 |
@@ -136,6 +136,7 @@ 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 />`) |
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
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"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,124 @@
|
||||
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
|
||||
@@ -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/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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ export const docsNavigation: NavSection[] = [
|
||||
{ title: "Visibility", href: "/docs/visibility" },
|
||||
{ title: "Watchers", href: "/docs/watchers" },
|
||||
{ title: "Validation", href: "/docs/validation" },
|
||||
{ title: "Directives", href: "/docs/directives" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -86,6 +87,7 @@ 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" },
|
||||
{
|
||||
|
||||
@@ -40,6 +40,7 @@ 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",
|
||||
@@ -51,6 +52,7 @@ 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",
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
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;
|
||||
}
|
||||
@@ -85,6 +85,15 @@ export {
|
||||
resolveActionParam,
|
||||
} from "./props";
|
||||
|
||||
// Custom Directives
|
||||
export type { DirectiveDefinition, DirectiveRegistry } from "./directives";
|
||||
|
||||
export {
|
||||
defineDirective,
|
||||
createDirectiveRegistry,
|
||||
findDirective,
|
||||
} from "./directives";
|
||||
|
||||
// Actions
|
||||
export type {
|
||||
ActionBinding,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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
|
||||
@@ -61,6 +62,8 @@ 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;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -291,8 +294,17 @@ export function resolvePropValue(
|
||||
return value.map((item) => resolvePropValue(item, ctx));
|
||||
}
|
||||
|
||||
// Plain objects (not expressions): resolve each value recursively
|
||||
// Custom directives: check registry before generic object recursion
|
||||
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);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import type { EditMode } from "./edit-modes";
|
||||
import { buildEditInstructions } from "./edit-modes";
|
||||
import type { DirectiveDefinition } from "./directives";
|
||||
|
||||
/**
|
||||
* Schema builder primitives
|
||||
@@ -146,6 +147,12 @@ 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[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -968,6 +975,22 @@ 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]) => {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# @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
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "@json-render/directives",
|
||||
"version": "0.18.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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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("");
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
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;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,497 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
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;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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,
|
||||
];
|
||||
@@ -0,0 +1,22 @@
|
||||
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) : "";
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
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;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
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}`;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
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;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@internal/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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"],
|
||||
});
|
||||
@@ -57,7 +57,8 @@
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"@types/react": "19.2.3",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "^5.4.5"
|
||||
"typescript": "^5.4.5",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.3"
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,8 @@ import type {
|
||||
SchemaDefinition,
|
||||
StateStore,
|
||||
ComputedFunction,
|
||||
DirectiveDefinition,
|
||||
DirectiveRegistry,
|
||||
} from "@json-render/core";
|
||||
import {
|
||||
resolveElementProps,
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
getByPath,
|
||||
isDevtoolsActive,
|
||||
subscribeDevtoolsActive,
|
||||
createDirectiveRegistry,
|
||||
type PropResolutionContext,
|
||||
type VisibilityContext as CoreVisibilityContext,
|
||||
} from "@json-render/core";
|
||||
@@ -154,6 +157,18 @@ 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. */
|
||||
@@ -194,8 +209,9 @@ 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 and $computed functions
|
||||
// Build context with repeat scope, $computed functions, and custom directives
|
||||
const fullCtx: PropResolutionContext = useMemo(() => {
|
||||
const base: PropResolutionContext = repeatScope
|
||||
? {
|
||||
@@ -206,8 +222,9 @@ const ElementRenderer = React.memo(function ElementRenderer({
|
||||
}
|
||||
: { ...ctx };
|
||||
base.functions = functions;
|
||||
base.directives = directives;
|
||||
return base;
|
||||
}, [ctx, repeatScope, functions]);
|
||||
}, [ctx, repeatScope, functions, directives]);
|
||||
|
||||
// Evaluate visibility (now supports $item/$index inside repeat scopes)
|
||||
const isVisible =
|
||||
@@ -554,6 +571,8 @@ 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;
|
||||
@@ -570,9 +589,14 @@ export function JSONUIProvider({
|
||||
navigate,
|
||||
validationFunctions,
|
||||
functions,
|
||||
directives,
|
||||
onStateChange,
|
||||
children,
|
||||
}: JSONUIProviderProps) {
|
||||
const directiveRegistry = useMemo(
|
||||
() => (directives ? createDirectiveRegistry(directives) : undefined),
|
||||
[directives],
|
||||
);
|
||||
return (
|
||||
<StateProvider
|
||||
store={store}
|
||||
@@ -583,8 +607,10 @@ export function JSONUIProvider({
|
||||
<ValidationProvider customFunctions={validationFunctions}>
|
||||
<ActionProvider handlers={handlers} navigate={navigate}>
|
||||
<FunctionsContext.Provider value={functions ?? EMPTY_FUNCTIONS}>
|
||||
{children}
|
||||
<ConfirmationDialogManager />
|
||||
<DirectivesContext.Provider value={directiveRegistry}>
|
||||
{children}
|
||||
<ConfirmationDialogManager />
|
||||
</DirectivesContext.Provider>
|
||||
</FunctionsContext.Provider>
|
||||
</ActionProvider>
|
||||
</ValidationProvider>
|
||||
@@ -790,6 +816,8 @@ 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 */
|
||||
@@ -844,9 +872,15 @@ 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(
|
||||
@@ -874,13 +908,15 @@ export function createRenderer<
|
||||
<ValidationProvider>
|
||||
<ActionProvider handlers={actionHandlers}>
|
||||
<FunctionsContext.Provider value={functions ?? EMPTY_FUNCTIONS}>
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
loading={loading}
|
||||
fallback={fallback}
|
||||
/>
|
||||
<ConfirmationDialogManager />
|
||||
<DirectivesContext.Provider value={directiveRegistry}>
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
loading={loading}
|
||||
fallback={fallback}
|
||||
/>
|
||||
<ConfirmationDialogManager />
|
||||
</DirectivesContext.Provider>
|
||||
</FunctionsContext.Provider>
|
||||
</ActionProvider>
|
||||
</ValidationProvider>
|
||||
|
||||
@@ -19,6 +19,8 @@ import type {
|
||||
SchemaDefinition,
|
||||
StateStore,
|
||||
ComputedFunction,
|
||||
DirectiveDefinition,
|
||||
DirectiveRegistry,
|
||||
} from "@json-render/core";
|
||||
import {
|
||||
resolveElementProps,
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
getByPath,
|
||||
isDevtoolsActive,
|
||||
subscribeDevtoolsActive,
|
||||
createDirectiveRegistry,
|
||||
type PropResolutionContext,
|
||||
type VisibilityContext as CoreVisibilityContext,
|
||||
} from "@json-render/core";
|
||||
@@ -111,6 +114,18 @@ 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. */
|
||||
@@ -142,8 +157,9 @@ function ElementRenderer(props: ElementRendererProps) {
|
||||
const stateStore = useStateStore();
|
||||
const getSnapshot = () => stateStore.getSnapshot();
|
||||
const functions = useFunctions();
|
||||
const directives = useDirectives();
|
||||
|
||||
// Build context with repeat scope and $computed functions
|
||||
// Build context with repeat scope, $computed functions, and custom directives
|
||||
const fullCtx = createMemo<PropResolutionContext>(() => {
|
||||
const repeatItem = repeatScope?.item;
|
||||
const repeatIndex = repeatScope?.index;
|
||||
@@ -157,6 +173,7 @@ function ElementRenderer(props: ElementRendererProps) {
|
||||
...(repeatIndex !== undefined ? { repeatIndex } : {}),
|
||||
...(repeatBasePath !== undefined ? { repeatBasePath } : {}),
|
||||
functions,
|
||||
directives,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -519,6 +536,8 @@ 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;
|
||||
@@ -528,6 +547,9 @@ 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}
|
||||
@@ -540,8 +562,10 @@ export function JSONUIProvider(props: JSONUIProviderProps) {
|
||||
<FunctionsContext.Provider
|
||||
value={props.functions ?? EMPTY_FUNCTIONS}
|
||||
>
|
||||
{props.children}
|
||||
<ConfirmationDialogManager />
|
||||
<DirectivesContext.Provider value={directiveRegistry()}>
|
||||
{props.children}
|
||||
<ConfirmationDialogManager />
|
||||
</DirectivesContext.Provider>
|
||||
</FunctionsContext.Provider>
|
||||
</ActionProvider>
|
||||
</ValidationProvider>
|
||||
@@ -748,6 +772,8 @@ 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 */
|
||||
@@ -796,6 +822,10 @@ 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
|
||||
@@ -826,13 +856,15 @@ export function createRenderer<
|
||||
<FunctionsContext.Provider
|
||||
value={props.functions ?? EMPTY_FUNCTIONS}
|
||||
>
|
||||
<Renderer
|
||||
spec={props.spec}
|
||||
registry={registry}
|
||||
loading={props.loading}
|
||||
fallback={props.fallback}
|
||||
/>
|
||||
<ConfirmationDialogManager />
|
||||
<DirectivesContext.Provider value={directiveRegistry()}>
|
||||
<Renderer
|
||||
spec={props.spec}
|
||||
registry={registry}
|
||||
loading={props.loading}
|
||||
fallback={props.fallback}
|
||||
/>
|
||||
<ConfirmationDialogManager />
|
||||
</DirectivesContext.Provider>
|
||||
</FunctionsContext.Provider>
|
||||
</ActionProvider>
|
||||
</ValidationProvider>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script module lang="ts">
|
||||
import type { ComputedFunction, Spec, StateStore } from "@json-render/core";
|
||||
import type { ComputedFunction, DirectiveDefinition, Spec, StateStore } from "@json-render/core";
|
||||
import type { ComponentRenderer, ComponentRegistry } from "./renderer.js";
|
||||
|
||||
export interface CatalogRendererProps {
|
||||
@@ -10,6 +10,8 @@
|
||||
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;
|
||||
}
|
||||
@@ -28,6 +30,7 @@
|
||||
onAction,
|
||||
onStateChange,
|
||||
functions,
|
||||
directives,
|
||||
loading = false,
|
||||
fallback,
|
||||
}: CatalogRendererProps = $props();
|
||||
@@ -50,6 +53,7 @@
|
||||
initialState={state}
|
||||
handlers={actionHandlers}
|
||||
{functions}
|
||||
{directives}
|
||||
{onStateChange}>
|
||||
<Renderer {spec} {registry} {loading} {fallback} />
|
||||
</JsonUIProvider>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
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";
|
||||
|
||||
@@ -49,8 +50,9 @@
|
||||
const actionCtx = getActionContext();
|
||||
const repeatScope = getRepeatScope();
|
||||
const functions = getFunctions();
|
||||
const directives = getDirectives();
|
||||
|
||||
// Build context with repeat scope and $computed functions
|
||||
// Build context with repeat scope, $computed functions, and custom directives
|
||||
let fullCtx = $derived<PropResolutionContext>(
|
||||
repeatScope
|
||||
? {
|
||||
@@ -59,8 +61,9 @@
|
||||
repeatIndex: repeatScope.index,
|
||||
repeatBasePath: repeatScope.basePath,
|
||||
functions,
|
||||
directives,
|
||||
}
|
||||
: { stateModel: stateCtx.state, functions },
|
||||
: { stateModel: stateCtx.state, functions, directives },
|
||||
);
|
||||
|
||||
// Evaluate visibility
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script module lang="ts">
|
||||
import type { ComputedFunction } from "@json-render/core";
|
||||
import type { ComputedFunction, DirectiveDefinition } from "@json-render/core";
|
||||
/**
|
||||
* Props for JSONUIProvider
|
||||
*/
|
||||
@@ -24,6 +24,8 @@
|
||||
>;
|
||||
/** 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 */
|
||||
@@ -39,6 +41,7 @@
|
||||
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";
|
||||
|
||||
@@ -49,6 +52,7 @@
|
||||
navigate,
|
||||
validationFunctions = {},
|
||||
functions,
|
||||
directives,
|
||||
onStateChange,
|
||||
children,
|
||||
}: JSONUIProviderProps = $props();
|
||||
@@ -59,8 +63,10 @@
|
||||
<ValidationProvider customFunctions={validationFunctions}>
|
||||
<ActionProvider {handlers} {navigate}>
|
||||
<FunctionsContextProvider {functions}>
|
||||
{@render children()}
|
||||
<ConfirmDialogManager />
|
||||
<DirectivesContextProvider {directives}>
|
||||
{@render children()}
|
||||
<ConfirmDialogManager />
|
||||
</DirectivesContextProvider>
|
||||
</FunctionsContextProvider>
|
||||
</ActionProvider>
|
||||
</ValidationProvider>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<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?.()}
|
||||
@@ -46,6 +46,12 @@ export {
|
||||
type FunctionsContext,
|
||||
} from "./contexts/FunctionsContextProvider.svelte";
|
||||
|
||||
export {
|
||||
default as DirectivesContextProvider,
|
||||
getDirectives,
|
||||
type DirectivesContext,
|
||||
} from "./contexts/DirectivesContextProvider.svelte";
|
||||
|
||||
// =============================================================================
|
||||
// Schema
|
||||
// =============================================================================
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
Catalog,
|
||||
ComputedFunction,
|
||||
DirectiveDefinition,
|
||||
SchemaDefinition,
|
||||
Spec,
|
||||
StateStore,
|
||||
@@ -206,6 +207,8 @@ 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;
|
||||
}
|
||||
@@ -264,6 +267,9 @@ export function createRenderer<
|
||||
get functions() {
|
||||
return props.functions;
|
||||
},
|
||||
get directives() {
|
||||
return props.directives;
|
||||
},
|
||||
get loading() {
|
||||
return props.loading;
|
||||
},
|
||||
|
||||
@@ -21,6 +21,8 @@ import type {
|
||||
ComputedFunction,
|
||||
SchemaDefinition,
|
||||
StateStore,
|
||||
DirectiveDefinition,
|
||||
DirectiveRegistry,
|
||||
} from "@json-render/core";
|
||||
import {
|
||||
resolveElementProps,
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
resolveActionParam,
|
||||
evaluateVisibility,
|
||||
getByPath,
|
||||
createDirectiveRegistry,
|
||||
isDevtoolsActive,
|
||||
subscribeDevtoolsActive,
|
||||
type PropResolutionContext,
|
||||
@@ -117,6 +120,36 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -239,8 +272,9 @@ const ElementRenderer = defineComponent({
|
||||
const { execute } = useActions();
|
||||
const { getSnapshot, state: watchState } = useStateStore();
|
||||
const functions = useFunctions();
|
||||
const directives = useDirectives();
|
||||
|
||||
// Build context with repeat scope and $computed functions
|
||||
// Build context with repeat scope, $computed functions, and custom directives
|
||||
const fullCtx = computed<PropResolutionContext>(() => {
|
||||
const base: PropResolutionContext = repeatScope
|
||||
? {
|
||||
@@ -251,6 +285,7 @@ const ElementRenderer = defineComponent({
|
||||
}
|
||||
: { ...visibilityCtx.value };
|
||||
base.functions = functions.value;
|
||||
base.directives = directives.value;
|
||||
return base;
|
||||
});
|
||||
|
||||
@@ -591,6 +626,8 @@ 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;
|
||||
}
|
||||
|
||||
@@ -638,6 +675,10 @@ 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
|
||||
@@ -672,10 +713,17 @@ export const JSONUIProvider = defineComponent({
|
||||
FunctionsProvider,
|
||||
{ functions: props.functions },
|
||||
{
|
||||
default: () => [
|
||||
slots.default?.(),
|
||||
h(ConfirmationDialogManager),
|
||||
],
|
||||
default: () =>
|
||||
h(
|
||||
DirectivesProvider,
|
||||
{ directives: props.directives },
|
||||
{
|
||||
default: () => [
|
||||
slots.default?.(),
|
||||
h(ConfirmationDialogManager),
|
||||
],
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
@@ -854,6 +902,8 @@ 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;
|
||||
}
|
||||
@@ -922,6 +972,10 @@ 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,
|
||||
@@ -972,15 +1026,22 @@ export function createRenderer<
|
||||
FunctionsProvider,
|
||||
{ functions: rendererProps.functions },
|
||||
{
|
||||
default: () => [
|
||||
h(Renderer, {
|
||||
spec: rendererProps.spec,
|
||||
registry,
|
||||
loading: rendererProps.loading,
|
||||
fallback: rendererProps.fallback,
|
||||
}),
|
||||
h(ConfirmationDialogManager),
|
||||
],
|
||||
default: () =>
|
||||
h(
|
||||
DirectivesProvider,
|
||||
{ directives: rendererProps.directives },
|
||||
{
|
||||
default: () => [
|
||||
h(Renderer, {
|
||||
spec: rendererProps.spec,
|
||||
registry,
|
||||
loading: rendererProps.loading,
|
||||
fallback: rendererProps.fallback,
|
||||
}),
|
||||
h(ConfirmationDialogManager),
|
||||
],
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
|
||||
Generated
+27
-2
@@ -1871,6 +1871,28 @@ 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':
|
||||
@@ -2074,6 +2096,9 @@ importers:
|
||||
typescript:
|
||||
specifier: ^5.4.5
|
||||
version: 5.9.3
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
|
||||
packages/react-email:
|
||||
dependencies:
|
||||
@@ -19476,7 +19501,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@8.57.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@9.39.2(jiti@2.6.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)
|
||||
@@ -20160,7 +20185,7 @@ snapshots:
|
||||
'@types/node': 22.19.6
|
||||
optional: true
|
||||
|
||||
'@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/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)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
---
|
||||
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 |
|
||||
Reference in New Issue
Block a user