mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-04 04:48:17 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f34f0713ae |
+2
-32
@@ -1,39 +1,8 @@
|
||||
# Changelog
|
||||
|
||||
## 0.20.0
|
||||
|
||||
<!-- release:start -->
|
||||
|
||||
### New Features
|
||||
|
||||
- **Named slots for React:** Components can declare named slots such as `header` and `footer`, while `children` remains the default slot. Slots are preserved through validation, streaming, nested conversion, code export, playground views, and Devtools navigation (#320). Built from the original contribution by @wotnak in #105
|
||||
- **Nested repeats:** `repeat.statePath` now accepts item-relative paths such as `{ "$item": "employees" }`, enabling nested data rendering across React, React Native, React Email, React PDF, Image, Ink, Solid, Svelte, and Vue (#319). Built from the original contribution by @tmchow in #256
|
||||
- **Harness chat example:** Added a complete Next.js example using the AI SDK 7 harness adapter, agent delegation, sandbox transport, and json-render components (#302)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Chained action params:** Named `onSuccess` and `onError` actions now receive their configured `params` across core and all renderer bridges (#307)
|
||||
- **Consistent optional visibility:** Element `visible` fields now remain optional across Zod 4 versions, while prompts explicitly require `children` arrays for every element (#299)
|
||||
- **Spec validation and autofix:** Dangling child references are pruned, malformed visibility conditions are reported, and repeated items can be filtered safely (#300)
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Release toolchain hardening:** The workspace now requires Node.js 24 and pnpm 11, enforces package engine checks, and applies a minimum package release age (#293)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Custom renderer bridges that implement the core `executeAction` callback must now accept an `ActionBinding` instead of a bare action name. This exposes chained action params to custom integrations at compile time (#307)
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @Railly
|
||||
- @tmchow
|
||||
- @wotnak
|
||||
<!-- release:end -->
|
||||
|
||||
## 0.19.0
|
||||
|
||||
<!-- release:start -->
|
||||
### New Features
|
||||
|
||||
- **Custom directives API** — `@json-render/core` now supports custom directives via `defineDirective`, letting you declare new JSON shapes (like `$format`, `$math`) that resolve to computed values at render time. Directives compose naturally — nest `$format` over `$math` over `$state` and they resolve inside-out. All four renderers (React, Vue, Svelte, Solid) have built-in directive resolution (#279)
|
||||
@@ -46,6 +15,7 @@
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
<!-- release:end -->
|
||||
|
||||
## 0.18.0
|
||||
|
||||
|
||||
@@ -131,7 +131,6 @@ function Dashboard({ spec }) {
|
||||
| `@json-render/react-three-fiber` | React Three Fiber renderer for 3D scenes (20 built-in components, including GaussianSplat) |
|
||||
| `@json-render/react-native` | React Native renderer with standard mobile components |
|
||||
| `@json-render/next` | Next.js renderer — JSON becomes full apps with routes, layouts, SSR |
|
||||
| `@json-render/tanstack-start` | TanStack Start renderer — full apps with routes, layouts, SSR, and head metadata |
|
||||
| `@json-render/remotion` | Remotion video renderer, timeline schema |
|
||||
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
|
||||
| `@json-render/react-email` | React Email renderer for HTML/plain-text emails from specs |
|
||||
@@ -537,54 +536,6 @@ const app = createNextApp({ spec });
|
||||
// </NextAppProvider>
|
||||
```
|
||||
|
||||
### TanStack Start (Full Apps)
|
||||
|
||||
```tsx
|
||||
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||
import {
|
||||
PageRenderer,
|
||||
StartErrorBoundary,
|
||||
StartLoading,
|
||||
StartNotFound,
|
||||
type StartAppSpec,
|
||||
} from "@json-render/tanstack-start";
|
||||
import { createStartApp } from "@json-render/tanstack-start/server";
|
||||
|
||||
const spec: StartAppSpec = {
|
||||
metadata: { title: { default: "My App", template: "%s | My App" } },
|
||||
routes: {
|
||||
"/": {
|
||||
metadata: { title: "Home" },
|
||||
page: {
|
||||
root: "hero",
|
||||
elements: {
|
||||
hero: { type: "Card", props: { title: "Welcome" }, children: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { getPageData, getHead } = createStartApp({ spec });
|
||||
|
||||
export const Route = createFileRoute("/$")({
|
||||
loader: async ({ location }) => {
|
||||
const data = await getPageData({ pathname: location.pathname });
|
||||
if (!data) throw notFound();
|
||||
return data;
|
||||
},
|
||||
head: ({ match }) => getHead({ pathname: match.pathname }),
|
||||
component: () => <PageRenderer {...Route.useLoaderData()} />,
|
||||
pendingComponent: StartLoading,
|
||||
errorComponent: StartErrorBoundary,
|
||||
notFoundComponent: StartNotFound,
|
||||
});
|
||||
```
|
||||
|
||||
Wrap the root route's outlet with `<StartAppProvider spec={spec}>` so route
|
||||
fallback components can resolve the current route. Pass named `$computed`
|
||||
implementations through its `functions` prop.
|
||||
|
||||
### shadcn-svelte (Svelte)
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -339,18 +339,17 @@ setSpec({ ...applySpecPatch(spec, patch) });
|
||||
|
||||
### nestedToFlat
|
||||
|
||||
Convert a nested element tree (with inline children and named slots) into the flat `Spec` format:
|
||||
Convert a nested element tree (with inline children) into the flat `Spec` format:
|
||||
|
||||
```typescript
|
||||
import { nestedToFlat } from '@json-render/core';
|
||||
|
||||
const flat = nestedToFlat({
|
||||
type: "Layout",
|
||||
props: {},
|
||||
children: [{ type: "Text", props: { content: "Main" }, children: [] }],
|
||||
slots: {
|
||||
header: [{ type: "Heading", props: { text: "Header" }, children: [] }],
|
||||
},
|
||||
type: "Card",
|
||||
props: { title: "Hello" },
|
||||
children: [
|
||||
{ type: "Text", props: { content: "World" }, children: [] }
|
||||
],
|
||||
});
|
||||
// { root: "el-0", elements: { "el-0": ..., "el-1": ... } }
|
||||
```
|
||||
@@ -451,8 +450,6 @@ const value = getByPath(state, '/user/name'); // "Alice"
|
||||
setByPath(state, '/user/email', 'alice@example.com');
|
||||
```
|
||||
|
||||
For prototype safety, path utilities, state stores, and SpecStream patches reject JSON Pointer paths containing `__proto__`, `constructor`, or `prototype` tokens. Compound patches validate both `path` and `from` before mutating data.
|
||||
|
||||
### resolveDynamicValue
|
||||
|
||||
```typescript
|
||||
@@ -651,7 +648,6 @@ interface UIElement {
|
||||
type: string;
|
||||
props: Record<string, unknown>;
|
||||
children?: string[]; // Keys of child elements
|
||||
slots?: Record<string, string[]>; // Named slots mapped to child keys
|
||||
visible?: VisibilityCondition;
|
||||
on?: Record<string, ActionBinding | ActionBinding[]>; // Event bindings
|
||||
repeat?: { statePath: string | { $item: string }; key?: string }; // Repeat for arrays
|
||||
@@ -670,7 +666,7 @@ interface Spec {
|
||||
}
|
||||
```
|
||||
|
||||
Elements are stored as a flat map with string keys. The tree structure is built by following `children` and named `slots` references.
|
||||
Elements are stored as a flat map with string keys. The tree structure is built by following the `children` arrays.
|
||||
|
||||
### ActionBinding
|
||||
|
||||
|
||||
@@ -1,393 +0,0 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata";
|
||||
|
||||
export const metadata = pageMetadata("docs/api/tanstack-start");
|
||||
|
||||
# @json-render/tanstack-start
|
||||
|
||||
TanStack Start renderer for JSON-defined applications with routes, layouts,
|
||||
head metadata, SSR loaders, prerender paths, and client navigation.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @json-render/core @json-render/react @json-render/tanstack-start
|
||||
```
|
||||
|
||||
## schema
|
||||
|
||||
Use the Start application schema to generate full multi-page specs.
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import {
|
||||
schema,
|
||||
startComponentDefinitions,
|
||||
} from "@json-render/tanstack-start/server";
|
||||
import { z } from "zod";
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
...startComponentDefinitions,
|
||||
Card: {
|
||||
props: z.object({ title: z.string() }),
|
||||
description: "Card container",
|
||||
},
|
||||
NavBar: {
|
||||
props: z.object({}),
|
||||
slots: ["default"],
|
||||
description: "Application navigation",
|
||||
},
|
||||
},
|
||||
actions: {},
|
||||
});
|
||||
```
|
||||
|
||||
The generation prompt teaches TanStack Router's `$param` and `$` splat route
|
||||
syntax, reusable layouts, escaped JSON Patch route keys, and the built-in
|
||||
`Slot`, `Link`, and `navigate` capabilities.
|
||||
|
||||
Include `startComponentDefinitions` in the catalog so generated `Slot` and
|
||||
`Link` elements pass validation. `PageRenderer` supplies their React
|
||||
implementations automatically.
|
||||
|
||||
## createStartApp
|
||||
|
||||
Create helpers for a TanStack Start splat route.
|
||||
|
||||
```typescript
|
||||
import { createStartApp } from "@json-render/tanstack-start/server";
|
||||
|
||||
export const { getPageData, getHead, getStaticPaths } = createStartApp({
|
||||
spec,
|
||||
loaders: {
|
||||
post: async ({ slug }) => ({
|
||||
post: await getPost(slug as string),
|
||||
}),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Option</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<code>spec</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>
|
||||
{"StartAppSpec | (() => StartAppSpec | Promise<StartAppSpec>)"}
|
||||
</code>
|
||||
</td>
|
||||
<td>A static application spec or an async spec factory</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>loaders</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>{"Record<string, LoaderFn>"}</code>
|
||||
</td>
|
||||
<td>Named data loaders referenced by route specs</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### Returns
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Helper</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<code>getPageData</code>
|
||||
</td>
|
||||
<td>
|
||||
Matches a pathname, runs its loader, and returns serializable page and
|
||||
layout data
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>getHead</code>
|
||||
</td>
|
||||
<td>
|
||||
Returns TanStack Router <code>meta</code> and <code>links</code>{" "}
|
||||
descriptors
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>getStaticPaths</code>
|
||||
</td>
|
||||
<td>Returns concrete paths for TanStack Start prerendering</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
State is merged in this order: application state, layout state, page state,
|
||||
then loader data. Later sources override earlier values.
|
||||
|
||||
## StartAppSpec
|
||||
|
||||
```typescript
|
||||
interface StartAppSpec {
|
||||
metadata?: StartMetadata;
|
||||
routes: Record<string, StartRouteSpec>;
|
||||
layouts?: Record<string, Spec>;
|
||||
state?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
Each route requires a `page` spec and can select a layout, metadata, a named
|
||||
loader, loading/error/not-found specs, and static parameters.
|
||||
|
||||
### Route Patterns
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Pattern</th>
|
||||
<th>Example</th>
|
||||
<th>Params</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<code>/</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>/</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>{"{}"}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>/about</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>/about</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>{"{}"}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>{"/blog/$slug"}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>/blog/hello</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>{'{ slug: "hello" }'}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>{"/docs/$"}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>/docs/guides/intro</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>{'{ _splat: "guides/intro" }'}</code>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Loader parameters are URL-decoded before they reach named loaders. Splat
|
||||
content is a slash-delimited string under `_splat`. Parameter values supplied
|
||||
through `staticParams` are URL-encoded in the paths returned by
|
||||
`getStaticPaths()`.
|
||||
|
||||
Route matching treats trailing slashes as optional and accepts both encoded and
|
||||
decoded pathname representations. This keeps loader data and route metadata in
|
||||
sync for static paths containing spaces or non-ASCII characters.
|
||||
|
||||
For prerendered dynamic routes, provide `staticParams`:
|
||||
|
||||
```typescript
|
||||
routes: {
|
||||
'/blog/$slug': {
|
||||
page,
|
||||
staticParams: [{ slug: 'hello' }, { slug: 'world' }],
|
||||
},
|
||||
'/docs/$': {
|
||||
page: docsPage,
|
||||
staticParams: [{ _splat: 'guides/intro' }],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Map `getStaticPaths()` into TanStack Start's top-level `pages` configuration:
|
||||
|
||||
```typescript
|
||||
const pages = (await getStaticPaths()).map((path) => ({ path }));
|
||||
```
|
||||
|
||||
## TanStack Route Setup
|
||||
|
||||
Wire the helpers to a file-based `$` splat route:
|
||||
|
||||
```tsx
|
||||
// src/routes/$.tsx
|
||||
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||
import {
|
||||
PageRenderer,
|
||||
StartErrorBoundary,
|
||||
StartLoading,
|
||||
StartNotFound,
|
||||
} from "@json-render/tanstack-start";
|
||||
import { getHead, getPageData } from "@/lib/json-app";
|
||||
|
||||
export const Route = createFileRoute("/$")({
|
||||
loader: async ({ location }) => {
|
||||
const data = await getPageData({ pathname: location.pathname });
|
||||
if (!data) throw notFound();
|
||||
return data;
|
||||
},
|
||||
head: ({ match }) => getHead({ pathname: match.pathname }),
|
||||
component: Page,
|
||||
pendingComponent: StartLoading,
|
||||
errorComponent: StartErrorBoundary,
|
||||
notFoundComponent: StartNotFound,
|
||||
});
|
||||
|
||||
function Page() {
|
||||
return <PageRenderer {...Route.useLoaderData()} />;
|
||||
}
|
||||
```
|
||||
|
||||
TanStack Router loaders are isomorphic. When a spec factory or named loader
|
||||
uses database clients, credentials, or server-only imports, invoke
|
||||
`getPageData` and `getHead` inside a TanStack Start `createServerFn` and call
|
||||
that server function from the route loader.
|
||||
|
||||
## StartAppProvider
|
||||
|
||||
Provide component implementations and action handlers around the root
|
||||
`Outlet`. Render `HeadContent` for route metadata.
|
||||
|
||||
```tsx
|
||||
import {
|
||||
createRootRoute,
|
||||
HeadContent,
|
||||
Outlet,
|
||||
Scripts,
|
||||
} from "@tanstack/react-router";
|
||||
import { StartAppProvider } from "@json-render/tanstack-start";
|
||||
import { spec } from "@/lib/spec";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: () => (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
<StartAppProvider
|
||||
registry={registry}
|
||||
handlers={handlers}
|
||||
spec={spec}
|
||||
>
|
||||
<Outlet />
|
||||
</StartAppProvider>
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
),
|
||||
});
|
||||
```
|
||||
|
||||
Passing `spec` lets `StartLoading`, `StartErrorBoundary`, and `StartNotFound`
|
||||
automatically select the matched route's fallback specs. Their explicit
|
||||
`loadingSpec`, `errorSpec`, and `notFoundSpec` props take precedence. For a
|
||||
server-only application spec, omit `spec` and pass client-safe fallback specs
|
||||
explicitly.
|
||||
|
||||
Pass named functions through `functions` when props use `$computed`:
|
||||
|
||||
```tsx
|
||||
<StartAppProvider
|
||||
registry={registry}
|
||||
spec={spec}
|
||||
functions={{ uppercase: ({ value }) => String(value).toUpperCase() }}
|
||||
>
|
||||
<Outlet />
|
||||
</StartAppProvider>
|
||||
```
|
||||
|
||||
## Built-ins
|
||||
|
||||
- `Slot` inserts page content into a JSON-defined layout.
|
||||
- `Link` wraps TanStack Router's `Link`; generated specs use an `href` prop.
|
||||
- `navigate` performs client-side navigation from action bindings.
|
||||
- `StartLoading`, `StartErrorBoundary`, and `StartNotFound` resolve the matched
|
||||
route's fallback specs when they are used as TanStack Router boundary
|
||||
components and the provider receives `spec`.
|
||||
|
||||
The default `StartErrorBoundary` fallback invalidates the router and reruns the
|
||||
failed loader when the user selects **Try again**.
|
||||
|
||||
`Slot` and `Link` are automatically added to the page registry.
|
||||
|
||||
## Server Utilities
|
||||
|
||||
```typescript
|
||||
import {
|
||||
collectStaticPaths,
|
||||
matchRoute,
|
||||
metadataToHead,
|
||||
resolveMetadata,
|
||||
splatToPath,
|
||||
} from "@json-render/tanstack-start/server";
|
||||
```
|
||||
|
||||
## Entry Points
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Import</th>
|
||||
<th>Contents</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<code>@json-render/tanstack-start</code>
|
||||
</td>
|
||||
<td>Provider, page renderer, Link, and route fallback components</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>@json-render/tanstack-start/server</code>
|
||||
</td>
|
||||
<td>App factory, schema, matcher, metadata, and prerender helpers</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>@json-render/tanstack-start/catalog</code>
|
||||
</td>
|
||||
<td>Server-safe definitions for built-in Slot and Link components</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -95,7 +95,7 @@ store.set("/count", 1);
|
||||
|
||||
## defineRegistry
|
||||
|
||||
Create a type-safe component registry from a catalog. Components receive `props`, `children`, `slots`, `emit`, `on`, and `loading` with catalog-inferred types.
|
||||
Create a type-safe component registry from a catalog. Components receive `props`, `children`, `emit`, `on`, and `loading` with catalog-inferred types.
|
||||
|
||||
When the catalog declares actions, the `actions` field is required. When the catalog has no actions (e.g. `actions: {}`), the field is optional. When passing stubs, any `async () => {}` is sufficient.
|
||||
|
||||
@@ -105,12 +105,8 @@ import { defineRegistry } from "@json-render/vue";
|
||||
|
||||
const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Layout: ({ slots }) =>
|
||||
h("div", { class: "layout" }, [
|
||||
h("header", null, slots.header?.()),
|
||||
h("main", null, slots.default?.()),
|
||||
h("footer", null, slots.footer?.()),
|
||||
]),
|
||||
Card: ({ props, children }) =>
|
||||
h("div", { class: "card" }, [h("h3", null, props.title), children]),
|
||||
Button: ({ props, emit }) =>
|
||||
h("button", { onClick: () => emit("press") }, props.label),
|
||||
},
|
||||
@@ -140,12 +136,11 @@ const { registry } = defineRegistry(catalog, {
|
||||
### Component Props (via defineRegistry)
|
||||
|
||||
```typescript
|
||||
import type { Slots, VNode } from "vue";
|
||||
import type { VNode } from "vue";
|
||||
|
||||
interface ComponentContext<P> {
|
||||
props: P; // Typed props from catalog
|
||||
children?: VNode | VNode[]; // Rendered children (for container components)
|
||||
slots: Slots; // Vue-native slot functions
|
||||
emit: (event: string) => void; // Emit a named event (always defined)
|
||||
on: (event: string) => EventHandle; // Get event handle with metadata
|
||||
loading?: boolean;
|
||||
@@ -159,22 +154,6 @@ interface EventHandle {
|
||||
}
|
||||
```
|
||||
|
||||
Use `children` for the default slot. For other slots declared by the catalog, add a top-level `slots` map to the spec element:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Layout",
|
||||
"props": {},
|
||||
"children": ["main-content"],
|
||||
"slots": {
|
||||
"header": ["page-heading"],
|
||||
"footer": ["page-actions"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The component renders these regions with Vue's native slot functions: `slots.header?.()`, `slots.footer?.()`, and so on. `slots.default?.()` renders the spec's `children`; `children` is a convenience alias for that rendered result. In the JSON spec, keep default content in `children` rather than adding a `default` entry to `slots`.
|
||||
|
||||
Use `emit("press")` for simple event firing. Use `on("click")` when you need metadata like `shouldPreventDefault`:
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata";
|
||||
export const metadata = pageMetadata("docs/catalog");
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/catalog")
|
||||
|
||||
# Catalog
|
||||
|
||||
@@ -18,9 +18,9 @@ A catalog is the vocabulary for your UI. While the [schema](/docs/schemas) defin
|
||||
`defineCatalog` is from `@json-render/core`. The `schema` import comes from your platform package (`@json-render/react` or `@json-render/react-native`) and defines the element structure the catalog targets. The catalog definition itself is framework-agnostic.
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react/schema"; // or '@json-render/react-native/schema'
|
||||
import { z } from "zod";
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react/schema'; // or '@json-render/react-native/schema'
|
||||
import { z } from 'zod';
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
@@ -29,35 +29,35 @@ const catalog = defineCatalog(schema, {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
padding: z.enum(["sm", "md", "lg"]).nullable(),
|
||||
padding: z.enum(['sm', 'md', 'lg']).nullable(),
|
||||
}),
|
||||
slots: ["default"], // Can contain other components
|
||||
description: "Container card for grouping content",
|
||||
},
|
||||
|
||||
|
||||
Metric: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
value: z.union([z.string(), z.number()]),
|
||||
format: z.enum(["currency", "percent", "number"]),
|
||||
format: z.enum(['currency', 'percent', 'number']),
|
||||
}),
|
||||
description: "Display a single metric value",
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
actions: {
|
||||
submit_form: {
|
||||
params: z.object({
|
||||
formId: z.string(),
|
||||
}),
|
||||
description: "Submit a form",
|
||||
description: 'Submit a form',
|
||||
},
|
||||
|
||||
|
||||
export_data: {
|
||||
params: z.object({
|
||||
format: z.enum(["csv", "pdf", "json"]),
|
||||
format: z.enum(['csv', 'pdf', 'json']),
|
||||
}),
|
||||
description: "Export data in various formats",
|
||||
description: 'Export data in various formats',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -70,22 +70,12 @@ Each component in the catalog has:
|
||||
```typescript
|
||||
{
|
||||
props: z.object({...}), // Zod schema for props (use .nullable() for optional)
|
||||
slots?: string[], // Available slots (e.g., ["default", "header", "footer"])
|
||||
slots?: string[], // Named slots for children (e.g., ["default"])
|
||||
description?: string, // Help AI understand when to use it
|
||||
}
|
||||
```
|
||||
|
||||
Use `"default"` for regular children. Add named slots when a component places content in multiple regions:
|
||||
|
||||
```typescript
|
||||
Layout: {
|
||||
props: z.object({}),
|
||||
slots: ["default", "header", "footer"],
|
||||
description: "Page layout with header, content, and footer regions",
|
||||
}
|
||||
```
|
||||
|
||||
React specs use `children` for the default slot and a `slots` object for the other names.
|
||||
Use `slots: ["default"]` for components that can contain children. The slot name corresponds to where child elements are rendered.
|
||||
|
||||
## Generating AI Prompts
|
||||
|
||||
|
||||
@@ -5,72 +5,6 @@ export const metadata = pageMetadata("docs/changelog")
|
||||
|
||||
Notable changes and updates to json-render.
|
||||
|
||||
## v0.20.0
|
||||
|
||||
August 15, 2026
|
||||
|
||||
### New: Named Slots for React
|
||||
|
||||
React components can now declare named slots such as `header` and `footer`, while `children` remains the default slot. Named slots are preserved through validation, streaming, nested conversion, code export, playground views, and Devtools navigation.
|
||||
|
||||
This work builds on the original named slots contribution by @wotnak.
|
||||
|
||||
```tsx
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Layout: {
|
||||
props: z.object({}),
|
||||
slots: ["default", "header", "footer"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Layout: ({ children, slots }) => (
|
||||
<section>
|
||||
<header>{slots?.header}</header>
|
||||
<main>{children}</main>
|
||||
<footer>{slots?.footer}</footer>
|
||||
</section>
|
||||
),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### New: Nested Repeats
|
||||
|
||||
`repeat.statePath` now accepts item-relative paths such as `{ "$item": "employees" }`. Nested data can be rendered across React, React Native, React Email, React PDF, Image, Ink, Solid, Svelte, and Vue.
|
||||
|
||||
This work builds on the original nested repeats contribution by @tmchow.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Table",
|
||||
"repeat": { "statePath": { "$item": "employees" }, "key": "id" },
|
||||
"children": ["employee-row"],
|
||||
"props": {}
|
||||
}
|
||||
```
|
||||
|
||||
### New: Harness Chat Example
|
||||
|
||||
Added a complete Next.js example using the AI SDK 7 harness adapter, agent delegation, sandbox transport, and json-render components.
|
||||
|
||||
### Fixed: Chained Action Params
|
||||
|
||||
Named `onSuccess` and `onError` actions now receive their configured `params` across core and all renderer bridges. Custom renderer bridges must now accept an `ActionBinding` in the core `executeAction` callback instead of a bare action name.
|
||||
|
||||
### Improved: Spec Validation and Compatibility
|
||||
|
||||
Element visibility remains optional across Zod 4 versions. Autofix now prunes dangling child references, validation reports malformed visibility conditions, and repeated items can be filtered safely.
|
||||
|
||||
### Improved: Release Toolchain
|
||||
|
||||
The workspace now requires Node.js 24 and pnpm 11, enforces package engine checks, and applies a minimum package release age.
|
||||
|
||||
---
|
||||
|
||||
## v0.19.0
|
||||
|
||||
May 6, 2026
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata";
|
||||
export const metadata = pageMetadata("docs/registry");
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/registry")
|
||||
|
||||
# Registry
|
||||
|
||||
A registry maps your [catalog](/docs/catalog) definitions to platform-specific implementations. The catalog defines _what_ AI can generate; the registry provides the _how_.
|
||||
A registry maps your [catalog](/docs/catalog) definitions to platform-specific implementations. The catalog defines *what* AI can generate — the registry provides the *how*.
|
||||
|
||||
What a registry contains depends on the schema you use. Each package defines its own schema, which determines the shape of both the catalog and the registry.
|
||||
|
||||
@@ -19,8 +19,8 @@ What a registry contains depends on the schema you use. Each package defines its
|
||||
Use `defineRegistry` to create a type-safe registry from your catalog. Pass your components, actions, or both:
|
||||
|
||||
```tsx
|
||||
import { defineRegistry } from "@json-render/react";
|
||||
import { myCatalog } from "./catalog";
|
||||
import { defineRegistry } from '@json-render/react';
|
||||
import { myCatalog } from './catalog';
|
||||
|
||||
export const { registry, handlers, executeAction } = defineRegistry(myCatalog, {
|
||||
components: {
|
||||
@@ -33,14 +33,16 @@ export const { registry, handlers, executeAction } = defineRegistry(myCatalog, {
|
||||
),
|
||||
|
||||
Button: ({ props, emit }) => (
|
||||
<button onClick={() => emit("press")}>{props.label}</button>
|
||||
<button onClick={() => emit("press")}>
|
||||
{props.label}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
|
||||
actions: {
|
||||
submit_form: async (params, setState) => {
|
||||
const res = await fetch("/api/submit", {
|
||||
method: "POST",
|
||||
const res = await fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
const result = await res.json();
|
||||
@@ -67,36 +69,23 @@ Each component receives a `ComponentContext` object:
|
||||
|
||||
```typescript
|
||||
interface ComponentContext {
|
||||
props: T; // Type-safe props from your catalog
|
||||
children?: React.ReactNode; // Rendered children (for slot components)
|
||||
slots?: Record<string, React.ReactNode>; // Rendered named slots
|
||||
emit: (event: string) => void; // Emit a named event (always defined)
|
||||
on: (event: string) => EventHandle; // Get event handle with metadata
|
||||
loading?: boolean; // Whether the renderer is in a loading state
|
||||
bindings?: Record<string, string>; // State paths from $bindState/$bindItem expressions
|
||||
props: T; // Type-safe props from your catalog
|
||||
children?: React.ReactNode; // Rendered children (for slot components)
|
||||
emit: (event: string) => void; // Emit a named event (always defined)
|
||||
on: (event: string) => EventHandle; // Get event handle with metadata
|
||||
loading?: boolean; // Whether the renderer is in a loading state
|
||||
bindings?: Record<string, string>; // State paths from $bindState/$bindItem expressions
|
||||
}
|
||||
|
||||
interface EventHandle {
|
||||
emit: () => void; // Fire the event
|
||||
emit: () => void; // Fire the event
|
||||
shouldPreventDefault: boolean; // Whether any binding requested preventDefault
|
||||
bound: boolean; // Whether any handler is bound
|
||||
bound: boolean; // Whether any handler is bound
|
||||
}
|
||||
```
|
||||
|
||||
Props are automatically inferred from your catalog, so `props.title` is typed as `string` if your catalog defines it that way.
|
||||
|
||||
For components with named slots, read the default content from `children` and other regions from `slots`:
|
||||
|
||||
```tsx
|
||||
Layout: ({ children, slots }) => (
|
||||
<div>
|
||||
<header>{slots?.header}</header>
|
||||
<main>{children}</main>
|
||||
<footer>{slots?.footer}</footer>
|
||||
</div>
|
||||
),
|
||||
```
|
||||
|
||||
Use `emit("press")` for simple event firing. Use `on("click")` when you need to inspect event metadata:
|
||||
|
||||
```tsx
|
||||
@@ -139,29 +128,27 @@ TextInput: ({ props, bindings }) => {
|
||||
|
||||
### Action Handlers
|
||||
|
||||
Instead of AI generating arbitrary code, it declares _intent_ by name. Your application provides the implementation. This is a core guardrail.
|
||||
Instead of AI generating arbitrary code, it declares *intent* by name. Your application provides the implementation. This is a core guardrail.
|
||||
|
||||
Actions are declared in your [catalog](/docs/catalog). The `@json-render/react` schema supports an `actions` key where you define what operations AI can trigger:
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react/schema";
|
||||
import { z } from "zod";
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react/schema';
|
||||
import { z } from 'zod';
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
/* ... */
|
||||
},
|
||||
components: { /* ... */ },
|
||||
actions: {
|
||||
submit_form: {
|
||||
params: z.object({
|
||||
formId: z.string(),
|
||||
}),
|
||||
description: "Submit a form",
|
||||
description: 'Submit a form',
|
||||
},
|
||||
export_data: {
|
||||
params: z.object({
|
||||
format: z.enum(["csv", "pdf", "json"]),
|
||||
format: z.enum(['csv', 'pdf', 'json']),
|
||||
}),
|
||||
},
|
||||
navigate: {
|
||||
@@ -179,8 +166,8 @@ Action handlers receive `(params, setState, state)` and are defined inside `defi
|
||||
export const { handlers, executeAction } = defineRegistry(catalog, {
|
||||
actions: {
|
||||
submit_form: async (params, setState) => {
|
||||
const response = await fetch("/api/submit", {
|
||||
method: "POST",
|
||||
const response = await fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ formId: params.formId }),
|
||||
});
|
||||
const result = await response.json();
|
||||
@@ -232,14 +219,14 @@ For read-only state access (e.g. displaying a value from state), use `$state` ex
|
||||
Wire everything together with providers and the `<Renderer />` component:
|
||||
|
||||
```tsx
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useMemo, useRef } from 'react';
|
||||
import {
|
||||
Renderer,
|
||||
StateProvider,
|
||||
VisibilityProvider,
|
||||
ActionProvider,
|
||||
} from "@json-render/react";
|
||||
import { registry, handlers } from "./registry";
|
||||
} from '@json-render/react';
|
||||
import { registry, handlers } from './registry';
|
||||
|
||||
function App({ spec, state, setState }) {
|
||||
const stateRef = useRef(state);
|
||||
@@ -248,11 +235,7 @@ function App({ spec, state, setState }) {
|
||||
setStateRef.current = setState;
|
||||
|
||||
const actionHandlers = useMemo(
|
||||
() =>
|
||||
handlers(
|
||||
() => setStateRef.current,
|
||||
() => stateRef.current,
|
||||
),
|
||||
() => handlers(() => setStateRef.current, () => stateRef.current),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -273,8 +256,8 @@ function App({ spec, state, setState }) {
|
||||
`@json-render/react-native` uses the same `defineRegistry` API. The only difference is that components return React Native elements instead of HTML:
|
||||
|
||||
```tsx
|
||||
import { defineRegistry } from "@json-render/react-native";
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { defineRegistry } from '@json-render/react-native';
|
||||
import { View, Text, Pressable } from 'react-native';
|
||||
|
||||
export const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
@@ -301,14 +284,14 @@ See the [@json-render/react-native API reference](/docs/api/react-native) for th
|
||||
`@json-render/react-email` uses `defineRegistry` like React and React Native. Components render to React Email primitives (`@react-email/components`). Use `renderToHtml` or `renderToPlainText` for server-side email output:
|
||||
|
||||
```tsx
|
||||
import { defineRegistry } from "@json-render/react-email";
|
||||
import { renderToHtml } from "@json-render/react-email";
|
||||
import { Body, Container, Heading, Text } from "@react-email/components";
|
||||
import { defineRegistry } from '@json-render/react-email';
|
||||
import { renderToHtml } from '@json-render/react-email';
|
||||
import { Body, Container, Heading, Text } from '@react-email/components';
|
||||
|
||||
export const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: ({ props, children }) => (
|
||||
<Container style={{ padding: 16, backgroundColor: "#fff" }}>
|
||||
<Container style={{ padding: 16, backgroundColor: '#fff' }}>
|
||||
<Heading>{props.title}</Heading>
|
||||
{children}
|
||||
</Container>
|
||||
@@ -326,10 +309,10 @@ See the [@json-render/react-email API reference](/docs/api/react-email) for the
|
||||
`@json-render/remotion` takes a different approach. Instead of `defineRegistry`, it uses a plain component registry with built-in standard components for video production:
|
||||
|
||||
```tsx
|
||||
import { Renderer, standardComponents } from "@json-render/remotion";
|
||||
import { Renderer, standardComponents } from '@json-render/remotion';
|
||||
|
||||
// Use the standard components directly
|
||||
<Renderer spec={timelineSpec} components={standardComponents} />;
|
||||
<Renderer spec={timelineSpec} components={standardComponents} />
|
||||
|
||||
// Or extend with your own
|
||||
const components = {
|
||||
|
||||
@@ -62,15 +62,6 @@ All renderers share the same workflow:
|
||||
</td>
|
||||
<td>Native mobile views</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>TanStack Start</td>
|
||||
<td>
|
||||
<code>@json-render/tanstack-start</code>
|
||||
</td>
|
||||
<td>
|
||||
Full React applications with routes, layouts, SSR, and head metadata
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Image</td>
|
||||
<td>
|
||||
@@ -222,32 +213,6 @@ const { registry } = defineRegistry(catalog, { components: {} });
|
||||
|
||||
See the [@json-render/react-native API reference](/docs/api/react-native) for details.
|
||||
|
||||
## TanStack Start
|
||||
|
||||
Define complete TanStack Start applications with route specs, reusable layouts,
|
||||
loader-backed state, head metadata, and prerender paths. The integration uses
|
||||
the React renderer for each page and TanStack Router for navigation.
|
||||
|
||||
```tsx
|
||||
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||
import { PageRenderer } from "@json-render/tanstack-start";
|
||||
import { createStartApp } from "@json-render/tanstack-start/server";
|
||||
|
||||
const { getPageData, getHead } = createStartApp({ spec });
|
||||
|
||||
export const Route = createFileRoute("/$")({
|
||||
loader: async ({ location }) => {
|
||||
const data = await getPageData({ pathname: location.pathname });
|
||||
if (!data) throw notFound();
|
||||
return data;
|
||||
},
|
||||
head: ({ match }) => getHead({ pathname: match.pathname }),
|
||||
component: () => <PageRenderer {...Route.useLoaderData()} />,
|
||||
});
|
||||
```
|
||||
|
||||
See the [@json-render/tanstack-start API reference](/docs/api/tanstack-start) for details.
|
||||
|
||||
## Image
|
||||
|
||||
Generate SVG and PNG images from JSON specs using Satori. Ideal for OG images, social cards, and banners.
|
||||
|
||||
@@ -10,7 +10,6 @@ json-render ships with skills that teach AI coding agents how to use each packag
|
||||
|
||||
- **core** — Core schemas, catalogs, and AI prompt generation.
|
||||
- **react** — React renderer that turns JSON specs into React component trees.
|
||||
- **tanstack-start** — Full TanStack Start applications with routes, layouts, SSR loaders, and head metadata.
|
||||
- **react-pdf** — PDF renderer using `@react-pdf/renderer`.
|
||||
- **react-email** — Email renderer that produces HTML or plain-text emails.
|
||||
- **react-native** — React Native renderer for native mobile UIs.
|
||||
@@ -32,7 +31,6 @@ json-render ships with skills that teach AI coding agents how to use each packag
|
||||
```bash
|
||||
npx skills add vercel-labs/json-render --skill core
|
||||
npx skills add vercel-labs/json-render --skill react
|
||||
npx skills add vercel-labs/json-render --skill tanstack-start
|
||||
npx skills add vercel-labs/json-render --skill react-pdf
|
||||
npx skills add vercel-labs/json-render --skill react-email
|
||||
npx skills add vercel-labs/json-render --skill react-native
|
||||
@@ -60,10 +58,6 @@ The foundational skill. Teaches agents how to define catalogs, create schemas, b
|
||||
|
||||
Teaches agents how to render JSON specs as React component trees using `JsonRender`, `JsonRenderClient`, and `useJsonRender`. Covers custom component registries, client-side interactivity, state management, and streaming integration.
|
||||
|
||||
## tanstack-start
|
||||
|
||||
Teaches agents how to build JSON-defined TanStack Start applications with splat routes, reusable layouts, SSR-safe loaders, head metadata, prerender paths, and client navigation.
|
||||
|
||||
## react-pdf
|
||||
|
||||
Teaches agents how to generate PDFs from JSON specs using `@react-pdf/renderer`. Covers the PDF-specific component registry, page layout, and styling.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata";
|
||||
export const metadata = pageMetadata("docs/specs");
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/specs")
|
||||
|
||||
# Specs
|
||||
|
||||
@@ -60,10 +60,7 @@ A more complex spec with multiple nested elements:
|
||||
},
|
||||
"avatar-1": {
|
||||
"type": "Avatar",
|
||||
"props": {
|
||||
"src": { "$state": "/user/avatar" },
|
||||
"alt": { "$state": "/user/name" }
|
||||
},
|
||||
"props": { "src": { "$state": "/user/avatar" }, "alt": { "$state": "/user/name" } },
|
||||
"children": []
|
||||
},
|
||||
"stack-1": {
|
||||
@@ -105,10 +102,7 @@ A high-level spec using semantic blocks for page layouts:
|
||||
},
|
||||
"header": {
|
||||
"type": "Header",
|
||||
"props": {
|
||||
"logo": "/logo.svg",
|
||||
"navItems": ["Products", "Pricing", "Docs"]
|
||||
},
|
||||
"props": { "logo": "/logo.svg", "navItems": ["Products", "Pricing", "Docs"] },
|
||||
"children": []
|
||||
},
|
||||
"hero": {
|
||||
@@ -128,37 +122,22 @@ A high-level spec using semantic blocks for page layouts:
|
||||
},
|
||||
"feature-1": {
|
||||
"type": "Feature",
|
||||
"props": {
|
||||
"icon": "zap",
|
||||
"title": "Fast",
|
||||
"description": "Render UIs in milliseconds"
|
||||
},
|
||||
"props": { "icon": "zap", "title": "Fast", "description": "Render UIs in milliseconds" },
|
||||
"children": []
|
||||
},
|
||||
"feature-2": {
|
||||
"type": "Feature",
|
||||
"props": {
|
||||
"icon": "shield",
|
||||
"title": "Secure",
|
||||
"description": "Validate all specs against your catalog"
|
||||
},
|
||||
"props": { "icon": "shield", "title": "Secure", "description": "Validate all specs against your catalog" },
|
||||
"children": []
|
||||
},
|
||||
"feature-3": {
|
||||
"type": "Feature",
|
||||
"props": {
|
||||
"icon": "sparkles",
|
||||
"title": "AI-Ready",
|
||||
"description": "Generate prompts from your catalog"
|
||||
},
|
||||
"props": { "icon": "sparkles", "title": "AI-Ready", "description": "Generate prompts from your catalog" },
|
||||
"children": []
|
||||
},
|
||||
"footer": {
|
||||
"type": "Footer",
|
||||
"props": {
|
||||
"copyright": "2025 Acme Inc",
|
||||
"links": ["Privacy", "Terms", "Contact"]
|
||||
},
|
||||
"props": { "copyright": "2025 Acme Inc", "links": ["Privacy", "Terms", "Contact"] },
|
||||
"children": []
|
||||
}
|
||||
}
|
||||
@@ -195,18 +174,13 @@ Each element in the map has a consistent shape:
|
||||
{
|
||||
"type": "ComponentName",
|
||||
"props": { "label": "Hello" },
|
||||
"children": ["child-1", "child-2"],
|
||||
"slots": {
|
||||
"header": ["heading-1"],
|
||||
"footer": ["actions-1"]
|
||||
}
|
||||
"children": ["child-1", "child-2"]
|
||||
}
|
||||
```
|
||||
|
||||
- `type` — Component type from your catalog
|
||||
- `props` — Component properties
|
||||
- `children` — Array of child element keys
|
||||
- `slots`: Optional map of named slots to child element keys. Use `children` for the default slot. Named slot rendering is supported by `@json-render/react` and `@json-render/vue`.
|
||||
|
||||
### Dynamic Data
|
||||
|
||||
@@ -251,12 +225,12 @@ Control when elements appear using the `visible` property:
|
||||
Use `validateSpec` from `@json-render/core` to check a spec for structural issues:
|
||||
|
||||
```typescript
|
||||
import { validateSpec } from "@json-render/core";
|
||||
import { validateSpec } from '@json-render/core';
|
||||
|
||||
const result = validateSpec(spec);
|
||||
|
||||
if (!result.valid) {
|
||||
console.error("Invalid spec:", result.issues);
|
||||
console.error('Invalid spec:', result.issues);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -265,12 +239,8 @@ if (!result.valid) {
|
||||
With `@json-render/react`, wrap the `Renderer` in providers to supply state and visibility:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Renderer,
|
||||
StateProvider,
|
||||
VisibilityProvider,
|
||||
} from "@json-render/react";
|
||||
import { registry } from "./registry";
|
||||
import { Renderer, StateProvider, VisibilityProvider } from '@json-render/react';
|
||||
import { registry } from './registry';
|
||||
|
||||
function MyApp({ spec, initialState }) {
|
||||
return (
|
||||
@@ -290,14 +260,20 @@ See the [@json-render/react API reference](/docs/api/react) for full provider an
|
||||
With `@json-render/react`, use the `useUIStream` hook to stream specs incrementally:
|
||||
|
||||
```tsx
|
||||
import { useUIStream } from "@json-render/react";
|
||||
import { useUIStream } from '@json-render/react';
|
||||
|
||||
function GenerativeUI() {
|
||||
const { spec, isStreaming } = useUIStream({
|
||||
api: "/api/generate",
|
||||
api: '/api/generate',
|
||||
});
|
||||
|
||||
return <Renderer spec={spec} registry={registry} loading={isStreaming} />;
|
||||
return (
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
loading={isStreaming}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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/tanstack-start, @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, tanstack-start, ink, react-pdf, react-email, react-native, shadcn, shadcn-svelte, react-three-fiber, image, remotion, vue, svelte, solid, directives, codegen, devtools, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details.
|
||||
npm packages: @json-render/core, @json-render/react, @json-render/next, @json-render/ink, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/shadcn-svelte, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/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.
|
||||
|
||||
|
||||
@@ -195,15 +195,6 @@ function specToNested(spec: Spec): Record<string, unknown> {
|
||||
node.children = el.children.map(resolve);
|
||||
}
|
||||
|
||||
if (el.slots && Object.keys(el.slots).length > 0) {
|
||||
node.slots = Object.fromEntries(
|
||||
Object.entries(el.slots).map(([slotName, childKeys]) => [
|
||||
slotName,
|
||||
childKeys.map(resolve),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
@@ -402,45 +393,21 @@ export function Demo({
|
||||
|
||||
const propsStr = serializeProps(propsObj);
|
||||
const hasChildren = element.children && element.children.length > 0;
|
||||
const hasSlots = element.slots && Object.keys(element.slots).length > 0;
|
||||
|
||||
if (!hasChildren && !hasSlots) {
|
||||
if (!hasChildren) {
|
||||
return propsStr
|
||||
? `${spaces}<${componentName} ${propsStr} />`
|
||||
: `${spaces}<${componentName} />`;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
if (hasSlots) {
|
||||
lines.push(`${spaces}<${componentName}`);
|
||||
if (propsStr) {
|
||||
lines.push(`${spaces} ${propsStr}`);
|
||||
}
|
||||
for (const [slotName, childKeys] of Object.entries(element.slots!)) {
|
||||
const slotChildren = childKeys
|
||||
.map((childKey) => generateJSX(childKey, indent + 2))
|
||||
.filter(Boolean);
|
||||
if (slotChildren.length === 0) continue;
|
||||
lines.push(`${spaces} ${slotName}={`);
|
||||
if (slotChildren.length > 1) {
|
||||
lines.push(`${spaces} <>`);
|
||||
}
|
||||
lines.push(...slotChildren);
|
||||
if (slotChildren.length > 1) {
|
||||
lines.push(`${spaces} </>`);
|
||||
}
|
||||
lines.push(`${spaces} }`);
|
||||
}
|
||||
lines.push(`${spaces}>`);
|
||||
} else {
|
||||
lines.push(
|
||||
propsStr
|
||||
? `${spaces}<${componentName} ${propsStr}>`
|
||||
: `${spaces}<${componentName}>`,
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
propsStr
|
||||
? `${spaces}<${componentName} ${propsStr}>`
|
||||
: `${spaces}<${componentName}>`,
|
||||
);
|
||||
|
||||
for (const childKey of element.children ?? []) {
|
||||
for (const childKey of element.children!) {
|
||||
lines.push(generateJSX(childKey, indent + 1));
|
||||
}
|
||||
|
||||
|
||||
@@ -152,15 +152,6 @@ function specToNested(spec: Spec): Record<string, unknown> {
|
||||
node.children = el.children.map(resolve);
|
||||
}
|
||||
|
||||
if (el.slots && Object.keys(el.slots).length > 0) {
|
||||
node.slots = Object.fromEntries(
|
||||
Object.entries(el.slots).map(([slotName, childKeys]) => [
|
||||
slotName,
|
||||
childKeys.map(resolve),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
@@ -382,45 +373,21 @@ export function Playground() {
|
||||
|
||||
const propsStr = serializeProps(propsObj);
|
||||
const hasChildren = element.children && element.children.length > 0;
|
||||
const hasSlots = element.slots && Object.keys(element.slots).length > 0;
|
||||
|
||||
if (!hasChildren && !hasSlots) {
|
||||
if (!hasChildren) {
|
||||
return propsStr
|
||||
? `${spaces}<${componentName} ${propsStr} />`
|
||||
: `${spaces}<${componentName} />`;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
if (hasSlots) {
|
||||
lines.push(`${spaces}<${componentName}`);
|
||||
if (propsStr) {
|
||||
lines.push(`${spaces} ${propsStr}`);
|
||||
}
|
||||
for (const [slotName, childKeys] of Object.entries(element.slots!)) {
|
||||
const slotChildren = childKeys
|
||||
.map((childKey) => generateJSX(childKey, indent + 2))
|
||||
.filter(Boolean);
|
||||
if (slotChildren.length === 0) continue;
|
||||
lines.push(`${spaces} ${slotName}={`);
|
||||
if (slotChildren.length > 1) {
|
||||
lines.push(`${spaces} <>`);
|
||||
}
|
||||
lines.push(...slotChildren);
|
||||
if (slotChildren.length > 1) {
|
||||
lines.push(`${spaces} </>`);
|
||||
}
|
||||
lines.push(`${spaces} }`);
|
||||
}
|
||||
lines.push(`${spaces}>`);
|
||||
} else {
|
||||
lines.push(
|
||||
propsStr
|
||||
? `${spaces}<${componentName} ${propsStr}>`
|
||||
: `${spaces}<${componentName}>`,
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
propsStr
|
||||
? `${spaces}<${componentName} ${propsStr}>`
|
||||
: `${spaces}<${componentName}>`,
|
||||
);
|
||||
|
||||
for (const childKey of element.children ?? []) {
|
||||
for (const childKey of element.children!) {
|
||||
lines.push(generateJSX(childKey, indent + 1));
|
||||
}
|
||||
|
||||
|
||||
@@ -72,10 +72,6 @@ export const docsNavigation: NavSection[] = [
|
||||
{ title: "@json-render/core", href: "/docs/api/core" },
|
||||
{ title: "@json-render/react", href: "/docs/api/react" },
|
||||
{ title: "@json-render/next", href: "/docs/api/next" },
|
||||
{
|
||||
title: "@json-render/tanstack-start",
|
||||
href: "/docs/api/tanstack-start",
|
||||
},
|
||||
{ title: "@json-render/react-pdf", href: "/docs/api/react-pdf" },
|
||||
{ title: "@json-render/react-email", href: "/docs/api/react-email" },
|
||||
{ title: "@json-render/shadcn", href: "/docs/api/shadcn" },
|
||||
|
||||
@@ -46,7 +46,6 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
"docs/api/core": "@json-render/core API",
|
||||
"docs/api/react": "@json-render/react API",
|
||||
"docs/api/next": "@json-render/next API",
|
||||
"docs/api/tanstack-start": "@json-render/tanstack-start API",
|
||||
"docs/api/vue": "@json-render/vue API",
|
||||
"docs/api/solid": "@json-render/solid API",
|
||||
"docs/api/react-pdf": "@json-render/react-pdf API",
|
||||
|
||||
@@ -35,10 +35,6 @@ export function setSpecValue(
|
||||
type: typeof el.type === "string" ? el.type : "",
|
||||
props: el.props != null && typeof el.props === "object" ? el.props : {},
|
||||
children: Array.isArray(el.children) ? el.children : [],
|
||||
slots:
|
||||
el.slots != null && typeof el.slots === "object"
|
||||
? el.slots
|
||||
: undefined,
|
||||
} as Spec["elements"][string];
|
||||
} else {
|
||||
const element = newSpec.elements[elementKey];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/codegen",
|
||||
"version": "0.20.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Utilities for generating code from json-render UI trees",
|
||||
"keywords": [
|
||||
|
||||
@@ -43,33 +43,6 @@ describe("traverseSpec", () => {
|
||||
});
|
||||
expect(visited).toEqual([]);
|
||||
});
|
||||
|
||||
it("visits named slot children depth-first", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Layout",
|
||||
props: {},
|
||||
children: ["main"],
|
||||
slots: {
|
||||
header: ["heading"],
|
||||
footer: ["actions"],
|
||||
},
|
||||
},
|
||||
main: { type: "Content", props: {} },
|
||||
heading: { type: "Heading", props: {} },
|
||||
actions: { type: "Actions", props: {} },
|
||||
},
|
||||
};
|
||||
|
||||
const visited: string[] = [];
|
||||
traverseSpec(spec, (_element, key) => {
|
||||
visited.push(key);
|
||||
});
|
||||
|
||||
expect(visited).toEqual(["root", "main", "heading", "actions"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectUsedComponents", () => {
|
||||
|
||||
@@ -37,14 +37,6 @@ export function traverseSpec(
|
||||
visit(childKey, depth + 1, element);
|
||||
}
|
||||
}
|
||||
|
||||
if (element.slots) {
|
||||
for (const childKeys of Object.values(element.slots)) {
|
||||
for (const childKey of childKeys) {
|
||||
visit(childKey, depth + 1, element);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visit(rootKey, 0, null);
|
||||
|
||||
@@ -126,8 +126,6 @@ SpecStream format uses [RFC 6902 JSON Patch](https://datatracker.ietf.org/doc/ht
|
||||
|
||||
All six RFC 6902 operations are supported: `add`, `remove`, `replace`, `move`, `copy`, `test`.
|
||||
|
||||
For prototype safety, JSON Pointer paths containing `__proto__`, `constructor`, or `prototype` tokens are rejected by path utilities, state stores, and SpecStream. This applies to both `path` and `from` in compound patches.
|
||||
|
||||
### Low-Level Utilities
|
||||
|
||||
```typescript
|
||||
@@ -558,9 +556,9 @@ console.log(formatSpecIssues(issues));
|
||||
const { spec: fixed, fixes, fixDetails } = autoFixSpec(spec);
|
||||
```
|
||||
|
||||
`validateSpec` checks structure beyond the catalog schema: missing or dangling `children` and named `slots` references, malformed `visible` conditions (anything outside the documented forms evaluates to hidden at runtime, so it is rejected with code `invalid_visible`), `repeat` containers with no children (`repeat_without_children`), relative repeat paths outside an enclosing repeat (`repeat_item_outside_scope`), and `repeat.statePath` values that do not reference an array in the spec's own `state` (`repeat_state_mismatch`).
|
||||
`validateSpec` checks structure beyond the catalog schema: missing or dangling `children` references, malformed `visible` conditions (anything outside the documented forms evaluates to hidden at runtime, so it is rejected with code `invalid_visible`), `repeat` containers with no children (`repeat_without_children`), relative repeat paths outside an enclosing repeat (`repeat_item_outside_scope`), and `repeat.statePath` values that do not reference an array in the spec's own `state` (`repeat_state_mismatch`).
|
||||
|
||||
`autoFixSpec` distinguishes lossless fixes (relocating `visible`/`on`/`repeat`/`watch` out of `props`) from lossy ones (pruning `children` or named `slots` references to elements that were never defined). Each entry in `fixDetails` carries `{ message, lossy }`. Callers with a repair loop should apply lossless fixes immediately and prefer re-prompting over lossy fixes, passing `{ lossy: false }` to withhold pruning until retries are exhausted:
|
||||
`autoFixSpec` distinguishes lossless fixes (relocating `visible`/`on`/`repeat`/`watch` out of `props`) from lossy ones (pruning `children` references to elements that were never defined). Each entry in `fixDetails` carries `{ message, lossy }`. Callers with a repair loop should apply lossless fixes immediately and prefer re-prompting over lossy fixes, passing `{ lossy: false }` to withhold pruning until retries are exhausted:
|
||||
|
||||
```typescript
|
||||
const lastAttempt = retriesUsed >= maxRetries;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/core",
|
||||
"version": "0.20.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
|
||||
"keywords": [
|
||||
|
||||
@@ -14,7 +14,6 @@ const testSchema = defineSchema((s) => ({
|
||||
type: s.ref("catalog.components"),
|
||||
props: s.propsOf("catalog.components"),
|
||||
children: s.array(s.string()),
|
||||
slots: { ...s.record(s.array(s.string())), ...s.optional() },
|
||||
visible: { ...s.any(), ...s.optional() },
|
||||
}),
|
||||
),
|
||||
@@ -176,7 +175,7 @@ describe("catalog.prompt", () => {
|
||||
users: z.array(z.object({ name: z.string(), age: z.number() })),
|
||||
}),
|
||||
description: "A card container",
|
||||
slots: ["default", "header"],
|
||||
slots: ["default"],
|
||||
},
|
||||
},
|
||||
actions: {},
|
||||
@@ -188,8 +187,6 @@ describe("catalog.prompt", () => {
|
||||
expect(prompt).toContain("title: string");
|
||||
expect(prompt).toContain("names: Array<string>");
|
||||
expect(prompt).toContain("users: Array<{ name: string, age: number }>");
|
||||
expect(prompt).toContain("[accepts children; slots: header]");
|
||||
expect(prompt).not.toContain("slots: default");
|
||||
});
|
||||
|
||||
it("formats z.literal() as quoted value", () => {
|
||||
|
||||
@@ -660,21 +660,6 @@ function generatePrompt<TDef extends SchemaDefinition, TCatalog>(
|
||||
const allComponents = (catalog.data as Record<string, unknown>).components as
|
||||
| Record<string, CatalogComponentDef>
|
||||
| undefined;
|
||||
const specDefinition = catalog.schema.definition.spec;
|
||||
const specShape =
|
||||
specDefinition.kind === "object"
|
||||
? (specDefinition.inner as Record<string, SchemaType>)
|
||||
: undefined;
|
||||
const elementsDefinition = specShape?.elements;
|
||||
const elementDefinition =
|
||||
elementsDefinition?.kind === "record"
|
||||
? (elementsDefinition.inner as SchemaType)
|
||||
: undefined;
|
||||
const elementShape =
|
||||
elementDefinition?.kind === "object"
|
||||
? (elementDefinition.inner as Record<string, SchemaType>)
|
||||
: undefined;
|
||||
const supportsNamedSlots = elementShape?.slots !== undefined;
|
||||
const cn = catalog.componentNames;
|
||||
const comp1 = cn[0] || "Component";
|
||||
const comp2 = cn.length > 1 ? cn[1]! : comp1;
|
||||
@@ -827,28 +812,14 @@ Note: state patches appear right after the elements that use them, so the UI fil
|
||||
|
||||
for (const [name, def] of Object.entries(components)) {
|
||||
const propsStr = def.props ? formatZodType(def.props) : "{}";
|
||||
const slotNames = def.slots ?? [];
|
||||
const namedSlotNames = slotNames.filter((slot) => slot !== "default");
|
||||
const acceptsChildren = slotNames.includes("default");
|
||||
const slotsStr = supportsNamedSlots
|
||||
? [
|
||||
acceptsChildren ? "accepts children" : "",
|
||||
namedSlotNames.length > 0
|
||||
? `slots: ${namedSlotNames.join(", ")}`
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("; ")
|
||||
: slotNames.length > 0
|
||||
? "accepts children"
|
||||
: "";
|
||||
const slotsSuffix = slotsStr ? ` [${slotsStr}]` : "";
|
||||
const hasChildren = def.slots && def.slots.length > 0;
|
||||
const childrenStr = hasChildren ? " [accepts children]" : "";
|
||||
const eventsStr =
|
||||
def.events && def.events.length > 0
|
||||
? ` [events: ${def.events.join(", ")}]`
|
||||
: "";
|
||||
const descStr = def.description ? ` - ${def.description}` : "";
|
||||
lines.push(`- ${name}: ${propsStr}${descStr}${slotsSuffix}${eventsStr}`);
|
||||
lines.push(`- ${name}: ${propsStr}${descStr}${childrenStr}${eventsStr}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
@@ -59,28 +59,6 @@ describe("validateSpec", () => {
|
||||
expect(result.issues.some((i) => i.code === "missing_child")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects missing children in named slots", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Layout",
|
||||
props: {},
|
||||
slots: { header: ["nonexistent"] },
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = validateSpec(spec);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "missing_child",
|
||||
elementKey: "root",
|
||||
message: expect.stringContaining('slot "header"'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("detects visible_in_props", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
@@ -163,23 +141,6 @@ describe("validateSpec", () => {
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.issues.some((i) => i.code === "orphaned_element")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats named slot children as reachable", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Layout",
|
||||
props: {},
|
||||
slots: { header: ["heading"] },
|
||||
},
|
||||
heading: { type: "Heading", props: {} },
|
||||
},
|
||||
};
|
||||
const result = validateSpec(spec, { checkOrphans: true });
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.issues).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
@@ -324,34 +285,6 @@ describe("repeat validation", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not give named slots the repeat scope created by their element", () => {
|
||||
const result = validateSpec({
|
||||
root: "items",
|
||||
state: { items: [{ nested: [] }] },
|
||||
elements: {
|
||||
items: {
|
||||
type: "Layout",
|
||||
props: {},
|
||||
repeat: { statePath: "/items" },
|
||||
children: ["body"],
|
||||
slots: { header: ["nested"] },
|
||||
},
|
||||
body: { type: "Text", props: {}, children: [] },
|
||||
nested: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
repeat: { statePath: { $item: "nested" } },
|
||||
children: ["label"],
|
||||
},
|
||||
label: { type: "Text", props: {}, children: [] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
result.issues.some((issue) => issue.code === "repeat_item_outside_scope"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts relative repeat structure when the outer sample array is empty", () => {
|
||||
const result = validateSpec({
|
||||
root: "groups",
|
||||
@@ -586,28 +519,6 @@ describe("autoFixSpec", () => {
|
||||
expect(fixes).toEqual([]);
|
||||
});
|
||||
|
||||
it("prunes undefined children from named slots", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Layout",
|
||||
props: {},
|
||||
slots: { header: ["heading", "ghost"] },
|
||||
},
|
||||
heading: { type: "Heading", props: {} },
|
||||
},
|
||||
};
|
||||
const { spec: fixed, fixDetails } = autoFixSpec(spec);
|
||||
expect(fixed.elements.root!.slots).toEqual({ header: ["heading"] });
|
||||
expect(fixDetails).toContainEqual({
|
||||
message:
|
||||
'Removed reference to undefined element "ghost" from slot "header" of "root".',
|
||||
lossy: true,
|
||||
});
|
||||
expect(validateSpec(fixed).valid).toBe(true);
|
||||
});
|
||||
|
||||
it("does not prune a repeat container down to zero children", () => {
|
||||
const spec: Spec = {
|
||||
root: "list",
|
||||
|
||||
@@ -131,20 +131,6 @@ export function validateSpec(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (element.slots) {
|
||||
for (const [slotName, childKeys] of Object.entries(element.slots)) {
|
||||
for (const childKey of childKeys) {
|
||||
if (!spec.elements[childKey]) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Element "${key}" references child "${childKey}" in slot "${slotName}" which does not exist in the elements map.`,
|
||||
elementKey: key,
|
||||
code: "missing_child",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3b. Repeat containers that can never render anything. Both shapes pass
|
||||
// schema validation but produce silently empty regions at runtime.
|
||||
@@ -286,16 +272,6 @@ export function validateSpec(
|
||||
nextAncestors,
|
||||
);
|
||||
}
|
||||
for (const childKeys of Object.values(element.slots ?? {})) {
|
||||
for (const childKey of childKeys) {
|
||||
validateRepeatPaths(
|
||||
childKey,
|
||||
repeatBasePath,
|
||||
sampleAvailable,
|
||||
nextAncestors,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (spec.elements[spec.root]) {
|
||||
@@ -321,15 +297,6 @@ export function validateSpec(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (el?.slots) {
|
||||
for (const childKeys of Object.values(el.slots)) {
|
||||
for (const childKey of childKeys) {
|
||||
if (spec.elements[childKey]) {
|
||||
walk(childKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if (spec.elements[spec.root]) {
|
||||
walk(spec.root);
|
||||
@@ -487,32 +454,6 @@ export function autoFixSpec(
|
||||
fixedElements[key] = { ...element, children: present };
|
||||
}
|
||||
|
||||
if (applyLossy)
|
||||
for (const [key, element] of Object.entries(fixedElements)) {
|
||||
if (!element.slots) continue;
|
||||
let changed = false;
|
||||
const slots = Object.fromEntries(
|
||||
Object.entries(element.slots).map(([slotName, childKeys]) => {
|
||||
const present = childKeys.filter((child) => child in fixedElements);
|
||||
if (present.length !== childKeys.length) {
|
||||
changed = true;
|
||||
for (const child of childKeys) {
|
||||
if (!(child in fixedElements)) {
|
||||
fixes.push(
|
||||
`Removed reference to undefined element "${child}" from slot "${slotName}" of "${key}".`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [slotName, present];
|
||||
}),
|
||||
);
|
||||
if (changed) {
|
||||
fixedElements[key] = { ...element, slots };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
spec: { root: spec.root, elements: fixedElements, state: spec.state },
|
||||
fixes: fixDetails.map((fix) => fix.message),
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
createStateStore,
|
||||
flattenToPointers,
|
||||
immutableSetByPath,
|
||||
} from "./state-store";
|
||||
import { createStateStore, flattenToPointers } from "./state-store";
|
||||
|
||||
describe("createStateStore", () => {
|
||||
it("creates a store with initial state", () => {
|
||||
@@ -139,36 +135,6 @@ describe("createStateStore", () => {
|
||||
store.set("/x", 2);
|
||||
expect(store.getServerSnapshot!()).toBe(store.getSnapshot());
|
||||
});
|
||||
|
||||
it.each(["__proto__", "constructor", "prototype"])(
|
||||
"rejects %s state paths without publishing a snapshot",
|
||||
(token) => {
|
||||
const store = createStateStore({ safe: true });
|
||||
const listener = vi.fn();
|
||||
const snapshot = store.getSnapshot();
|
||||
store.subscribe(listener);
|
||||
|
||||
store.set(`/${token}/polluted`, "value");
|
||||
store.update({ [`/safe/${token}/polluted`]: "value" });
|
||||
|
||||
expect(store.getSnapshot()).toBe(snapshot);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("immutableSetByPath", () => {
|
||||
it.each(["__proto__", "constructor", "prototype"])(
|
||||
"rejects %s without changing snapshot identity or its prototype",
|
||||
(token) => {
|
||||
const state = { safe: true };
|
||||
const result = immutableSetByPath(state, `/${token}/polluted`, "value");
|
||||
|
||||
expect(result).toBe(state);
|
||||
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
|
||||
expect(result).toEqual({ safe: true });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("flattenToPointers", () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
getByPath,
|
||||
isSafeJsonPointerPath,
|
||||
parseJsonPointer,
|
||||
type StateModel,
|
||||
type StateStore,
|
||||
@@ -16,8 +15,6 @@ export function immutableSetByPath(
|
||||
path: string,
|
||||
value: unknown,
|
||||
): StateModel {
|
||||
if (!isSafeJsonPointerPath(path)) return root;
|
||||
|
||||
const segments = parseJsonPointer(path);
|
||||
if (segments.length === 0) return root;
|
||||
|
||||
@@ -75,7 +72,6 @@ export function createStateStore(initialState: StateModel = {}): StateStore {
|
||||
},
|
||||
|
||||
set(path: string, value: unknown): void {
|
||||
if (!isSafeJsonPointerPath(path)) return;
|
||||
if (getByPath(state, path) === value) return;
|
||||
state = immutableSetByPath(state, path, value);
|
||||
notify();
|
||||
@@ -85,7 +81,6 @@ export function createStateStore(initialState: StateModel = {}): StateStore {
|
||||
let changed = false;
|
||||
let next = state;
|
||||
for (const [path, value] of Object.entries(updates)) {
|
||||
if (!isSafeJsonPointerPath(path)) continue;
|
||||
if (getByPath(next, path) !== value) {
|
||||
next = immutableSetByPath(next, path, value);
|
||||
changed = true;
|
||||
@@ -142,7 +137,6 @@ export function createStoreAdapter(config: StoreAdapterConfig): StateStore {
|
||||
},
|
||||
|
||||
set(path: string, value: unknown): void {
|
||||
if (!isSafeJsonPointerPath(path)) return;
|
||||
const current = config.getSnapshot();
|
||||
if (getByPath(current, path) === value) return;
|
||||
config.setSnapshot(immutableSetByPath(current, path, value));
|
||||
@@ -152,7 +146,6 @@ export function createStoreAdapter(config: StoreAdapterConfig): StateStore {
|
||||
let next = config.getSnapshot();
|
||||
let changed = false;
|
||||
for (const [path, value] of Object.entries(updates)) {
|
||||
if (!isSafeJsonPointerPath(path)) continue;
|
||||
if (getByPath(next, path) !== value) {
|
||||
next = immutableSetByPath(next, path, value);
|
||||
changed = true;
|
||||
|
||||
@@ -215,67 +215,6 @@ describe("JSON Pointer escaping (RFC 6901)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// JSON Pointer prototype safety
|
||||
// =============================================================================
|
||||
|
||||
describe("JSON Pointer prototype safety", () => {
|
||||
const blockedTokens = ["__proto__", "constructor", "prototype"];
|
||||
|
||||
it.each(blockedTokens)("rejects %s in path utility writes", (token) => {
|
||||
const pollutionKey = "__json_render_pollution_probe__";
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
setByPath(data, `/${token}/${pollutionKey}`, "set");
|
||||
addByPath(data, `/safe/${token}/${pollutionKey}`, "add");
|
||||
|
||||
expect(data).toEqual({});
|
||||
expect(Object.prototype).not.toHaveProperty(pollutionKey);
|
||||
} finally {
|
||||
delete (Object.prototype as Record<string, unknown>)[pollutionKey];
|
||||
}
|
||||
});
|
||||
|
||||
it("does not read or remove values through Object.prototype", () => {
|
||||
const pollutionKey = "__json_render_inherited_probe__";
|
||||
(Object.prototype as Record<string, unknown>)[pollutionKey] = "keep";
|
||||
|
||||
try {
|
||||
expect(getByPath({}, `/__proto__/${pollutionKey}`)).toBeUndefined();
|
||||
removeByPath({}, `/__proto__/${pollutionKey}`);
|
||||
expect((Object.prototype as Record<string, unknown>)[pollutionKey]).toBe(
|
||||
"keep",
|
||||
);
|
||||
} finally {
|
||||
delete (Object.prototype as Record<string, unknown>)[pollutionKey];
|
||||
}
|
||||
});
|
||||
|
||||
it.each(blockedTokens)(
|
||||
"rejects compound patches containing %s before mutation",
|
||||
(token) => {
|
||||
const destination: Record<string, unknown> = { source: "one" };
|
||||
applySpecStreamPatch(destination, {
|
||||
op: "move",
|
||||
from: "/source",
|
||||
path: `/${token}/moved`,
|
||||
});
|
||||
|
||||
const source: Record<string, unknown> = {};
|
||||
applySpecStreamPatch(source, {
|
||||
op: "copy",
|
||||
from: `/${token}/value`,
|
||||
path: "/copy",
|
||||
});
|
||||
|
||||
expect(destination).toEqual({ source: "one" });
|
||||
expect(source).toEqual({});
|
||||
expect(Object.hasOwn(source, "copy")).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// addByPath (RFC 6902 "add" semantics)
|
||||
// =============================================================================
|
||||
@@ -887,27 +826,6 @@ describe("nestedToFlat", () => {
|
||||
expect(spec.elements["el-2"]!.children).toEqual([]);
|
||||
});
|
||||
|
||||
it("converts nested named slots to flat element references", () => {
|
||||
const spec = nestedToFlat({
|
||||
type: "Layout",
|
||||
props: {},
|
||||
children: [{ type: "Text", props: { content: "Main" } }],
|
||||
slots: {
|
||||
header: [{ type: "Heading", props: { text: "Header" } }],
|
||||
footer: [{ type: "Button", props: { label: "Continue" } }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(Object.keys(spec.elements)).toHaveLength(4);
|
||||
expect(spec.elements["el-0"]!.children).toEqual(["el-1"]);
|
||||
expect(spec.elements["el-0"]!.slots).toEqual({
|
||||
header: ["el-2"],
|
||||
footer: ["el-3"],
|
||||
});
|
||||
expect(spec.elements["el-2"]!.type).toBe("Heading");
|
||||
expect(spec.elements["el-3"]!.type).toBe("Button");
|
||||
});
|
||||
|
||||
it("hoists state from root node", () => {
|
||||
const spec = nestedToFlat({
|
||||
type: "Card",
|
||||
|
||||
@@ -65,7 +65,6 @@ export interface UIElement<
|
||||
props: P;
|
||||
/** Child element keys (flat structure) */
|
||||
children?: string[];
|
||||
slots?: Record<string, string[]>;
|
||||
/** Visibility condition */
|
||||
visible?: VisibilityCondition;
|
||||
/** Event bindings — maps event names to action bindings */
|
||||
@@ -280,26 +279,6 @@ export function parseJsonPointer(path: string): string[] {
|
||||
return raw.map(unescapeJsonPointer);
|
||||
}
|
||||
|
||||
const blockedJsonPointerTokens = new Set([
|
||||
"__proto__",
|
||||
"constructor",
|
||||
"prototype",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Reject tokens that can traverse or modify JavaScript prototype chains.
|
||||
* Validation happens after JSON Pointer unescaping so encoded paths cannot
|
||||
* bypass it.
|
||||
*/
|
||||
function hasBlockedJsonPointerToken(segments: string[]): boolean {
|
||||
return segments.some((segment) => blockedJsonPointerTokens.has(segment));
|
||||
}
|
||||
|
||||
/** @internal Shared by JSON Pointer-based state stores. */
|
||||
export function isSafeJsonPointerPath(path: string): boolean {
|
||||
return !hasBlockedJsonPointerToken(parseJsonPointer(path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from an object by JSON Pointer path (RFC 6901)
|
||||
*/
|
||||
@@ -309,7 +288,6 @@ export function getByPath(obj: unknown, path: string): unknown {
|
||||
}
|
||||
|
||||
const segments = parseJsonPointer(path);
|
||||
if (hasBlockedJsonPointerToken(segments)) return undefined;
|
||||
|
||||
let current: unknown = obj;
|
||||
|
||||
@@ -383,7 +361,7 @@ export function setByPath(
|
||||
): void {
|
||||
const segments = parseJsonPointer(path);
|
||||
|
||||
if (segments.length === 0 || hasBlockedJsonPointerToken(segments)) return;
|
||||
if (segments.length === 0) return;
|
||||
|
||||
let current: Record<string, unknown> | unknown[] = obj;
|
||||
|
||||
@@ -433,7 +411,7 @@ export function addByPath(
|
||||
): void {
|
||||
const segments = parseJsonPointer(path);
|
||||
|
||||
if (segments.length === 0 || hasBlockedJsonPointerToken(segments)) return;
|
||||
if (segments.length === 0) return;
|
||||
|
||||
let current: Record<string, unknown> | unknown[] = obj;
|
||||
|
||||
@@ -479,7 +457,7 @@ export function addByPath(
|
||||
export function removeByPath(obj: Record<string, unknown>, path: string): void {
|
||||
const segments = parseJsonPointer(path);
|
||||
|
||||
if (segments.length === 0 || hasBlockedJsonPointerToken(segments)) return;
|
||||
if (segments.length === 0) return;
|
||||
|
||||
let current: Record<string, unknown> | unknown[] = obj;
|
||||
|
||||
@@ -638,15 +616,6 @@ export function applySpecStreamPatch<T extends Record<string, unknown>>(
|
||||
obj: T,
|
||||
patch: SpecStreamLine,
|
||||
): T {
|
||||
if (!isSafeJsonPointerPath(patch.path)) return obj;
|
||||
if (
|
||||
(patch.op === "move" || patch.op === "copy") &&
|
||||
patch.from !== undefined &&
|
||||
!isSafeJsonPointerPath(patch.from)
|
||||
) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
switch (patch.op) {
|
||||
case "add":
|
||||
addByPath(obj, patch.path, patch.value);
|
||||
@@ -716,7 +685,6 @@ interface NestedNode {
|
||||
type: string;
|
||||
props: Record<string, unknown>;
|
||||
children?: NestedNode[];
|
||||
slots?: Record<string, NestedNode[]>;
|
||||
/** Any other top-level fields (visible, on, repeat, etc.) */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -759,13 +727,7 @@ export function nestedToFlat(nested: Record<string, unknown>): Spec {
|
||||
|
||||
function walk(node: Record<string, unknown>): string {
|
||||
const key = `el-${counter++}`;
|
||||
const {
|
||||
type,
|
||||
props,
|
||||
children: rawChildren,
|
||||
slots: rawSlots,
|
||||
...rest
|
||||
} = node as NestedNode;
|
||||
const { type, props, children: rawChildren, ...rest } = node as NestedNode;
|
||||
|
||||
// Recursively flatten children
|
||||
const childKeys: string[] = [];
|
||||
@@ -777,25 +739,12 @@ export function nestedToFlat(nested: Record<string, unknown>): Spec {
|
||||
}
|
||||
}
|
||||
|
||||
const slots: Record<string, string[]> = {};
|
||||
if (rawSlots && typeof rawSlots === "object") {
|
||||
for (const [slotName, slotChildren] of Object.entries(rawSlots)) {
|
||||
if (!Array.isArray(slotChildren)) continue;
|
||||
slots[slotName] = slotChildren.flatMap((child) =>
|
||||
child && typeof child === "object" && "type" in child
|
||||
? [walk(child as Record<string, unknown>)]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the flat element, preserving extra fields (visible, on, repeat, etc.)
|
||||
// but excluding `state` which is hoisted to spec-level.
|
||||
const element: UIElement = {
|
||||
type: type ?? "unknown",
|
||||
props: (props as Record<string, unknown>) ?? {},
|
||||
children: childKeys,
|
||||
...(Object.keys(slots).length > 0 ? { slots } : {}),
|
||||
};
|
||||
|
||||
// Copy extra fields (visible, on, repeat) but not state
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/devtools-react",
|
||||
"version": "0.20.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React adapter for @json-render/devtools. Drop-in <JsonRenderDevtools /> component.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/devtools-solid",
|
||||
"version": "0.20.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "SolidJS adapter for @json-render/devtools. Drop-in <JsonRenderDevtools /> component.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/devtools-svelte",
|
||||
"version": "0.20.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Svelte adapter for @json-render/devtools. Drop-in <JsonRenderDevtools /> component.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/devtools-vue",
|
||||
"version": "0.20.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Vue adapter for @json-render/devtools. Drop-in <JsonRenderDevtools /> component.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/devtools",
|
||||
"version": "0.20.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Framework-agnostic devtools core for json-render: event store, panel UI, picker, stream taps.",
|
||||
"keywords": [
|
||||
|
||||
@@ -223,8 +223,7 @@ function mountSpecTab(root: HTMLElement, ctx: PanelContext): TabInstance {
|
||||
return;
|
||||
}
|
||||
const el = spec.elements[current];
|
||||
const childKeys = getChildKeys(el);
|
||||
const hasChildren = childKeys.length > 0;
|
||||
const hasChildren = !!el?.children?.length;
|
||||
if (!hasChildren) return;
|
||||
if (!expanded.has(current)) {
|
||||
expanded.add(current);
|
||||
@@ -233,7 +232,7 @@ function mountSpecTab(root: HTMLElement, ctx: PanelContext): TabInstance {
|
||||
scrollSelectedIntoView();
|
||||
} else {
|
||||
// Already expanded → step into the first child.
|
||||
moveSelection(childKeys[0]);
|
||||
moveSelection(el.children![0]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -241,7 +240,7 @@ function mountSpecTab(root: HTMLElement, ctx: PanelContext): TabInstance {
|
||||
if (key === "ArrowLeft") {
|
||||
if (!current) return;
|
||||
const el = spec.elements[current];
|
||||
const hasChildren = getChildKeys(el).length > 0;
|
||||
const hasChildren = !!el?.children?.length;
|
||||
if (hasChildren && expanded.has(current)) {
|
||||
expanded.delete(current);
|
||||
render();
|
||||
@@ -259,7 +258,7 @@ function mountSpecTab(root: HTMLElement, ctx: PanelContext): TabInstance {
|
||||
return;
|
||||
}
|
||||
const el = spec.elements[current];
|
||||
if (getChildKeys(el).length > 0) {
|
||||
if (el?.children?.length) {
|
||||
toggleExpanded(current);
|
||||
scrollSelectedIntoView();
|
||||
}
|
||||
@@ -511,10 +510,9 @@ function collectVisibleKeys(spec: Spec, expanded: Set<string>): string[] {
|
||||
function walk(key: string) {
|
||||
list.push(key);
|
||||
const el = spec.elements[key];
|
||||
const childKeys = getChildKeys(el);
|
||||
if (childKeys.length === 0) return;
|
||||
if (!el?.children || el.children.length === 0) return;
|
||||
if (!expanded.has(key)) return;
|
||||
for (const child of childKeys) walk(child);
|
||||
for (const child of el.children) walk(child);
|
||||
}
|
||||
walk(spec.root);
|
||||
return list;
|
||||
@@ -522,19 +520,11 @@ function collectVisibleKeys(spec: Spec, expanded: Set<string>): string[] {
|
||||
|
||||
function findParent(spec: Spec, key: string): string | null {
|
||||
for (const [parentKey, el] of Object.entries(spec.elements)) {
|
||||
if (getChildKeys(el).includes(key)) return parentKey;
|
||||
if (el.children?.includes(key)) return parentKey;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getChildKeys(element: UIElement | undefined): string[] {
|
||||
if (!element) return [];
|
||||
return [
|
||||
...(element.children ?? []),
|
||||
...Object.values(element.slots ?? {}).flat(),
|
||||
];
|
||||
}
|
||||
|
||||
interface IssueIndex {
|
||||
all: SpecIssue[];
|
||||
byKey: Map<string, SpecIssue[]>;
|
||||
@@ -564,7 +554,8 @@ function findPath(spec: Spec, key: string): string[] {
|
||||
return true;
|
||||
}
|
||||
const el = spec.elements[current];
|
||||
for (const child of getChildKeys(el)) {
|
||||
if (!el?.children) return false;
|
||||
for (const child of el.children) {
|
||||
if (walk(child)) {
|
||||
path.push(current);
|
||||
return true;
|
||||
@@ -601,8 +592,7 @@ function renderNode(
|
||||
);
|
||||
}
|
||||
|
||||
const childKeys = getChildKeys(el);
|
||||
const hasChildren = childKeys.length > 0;
|
||||
const hasChildren = Array.isArray(el.children) && el.children.length > 0;
|
||||
const isExpanded = hasChildren && expanded.has(key);
|
||||
const isSelected = selected === key;
|
||||
const elementIssues = issues.byKey.get(key) ?? [];
|
||||
@@ -667,7 +657,7 @@ function renderNode(
|
||||
const container = h("div", null, row);
|
||||
|
||||
if (isExpanded && hasChildren) {
|
||||
for (const childKey of childKeys) {
|
||||
for (const childKey of el.children!) {
|
||||
const childNode = renderNode(
|
||||
spec,
|
||||
childKey,
|
||||
@@ -728,7 +718,7 @@ function renderDetail(
|
||||
}
|
||||
|
||||
const elIssues = issues.byKey.get(key) ?? [];
|
||||
const children = getChildKeys(el).length;
|
||||
const children = el.children?.length ?? 0;
|
||||
|
||||
replaceChildren(
|
||||
container,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/directives",
|
||||
"version": "0.20.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Pre-built directives for @json-render/core — $format, $math, $concat, $count, $truncate, $pluralize, $join, and $t (i18n).",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/image",
|
||||
"version": "0.20.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Image renderer for @json-render/core. JSON becomes SVG and PNG images via Satori.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/ink",
|
||||
"version": "0.20.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Ink terminal renderer for @json-render/core. JSON becomes terminal UIs.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/jotai",
|
||||
"version": "0.20.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Jotai adapter for json-render StateStore",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/mcp",
|
||||
"version": "0.20.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "MCP Apps integration for @json-render/core. Serve json-render UIs as interactive MCP Apps in Claude, ChatGPT, Cursor, and VS Code.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/next",
|
||||
"version": "0.20.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Next.js renderer for @json-render/core. JSON becomes full Next.js applications with routes, layouts, metadata, and SSR.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react-email",
|
||||
"version": "0.20.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React Email renderer for @json-render/core. JSON becomes HTML emails.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react-native",
|
||||
"version": "0.20.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React Native renderer for @json-render/core. JSON becomes React Native components.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react-pdf",
|
||||
"version": "0.20.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React PDF renderer for @json-render/core. JSON becomes PDF documents.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react-three-fiber",
|
||||
"version": "0.20.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React Three Fiber renderer for @json-render/core. JSON becomes 3D scenes.",
|
||||
"keywords": [
|
||||
|
||||
+68
-86
@@ -67,7 +67,9 @@ export const { registry } = defineRegistry(catalog, {
|
||||
</div>
|
||||
),
|
||||
Button: ({ props, emit }) => (
|
||||
<button onClick={() => emit("press")}>{props.label}</button>
|
||||
<button onClick={() => emit("press")}>
|
||||
{props.label}
|
||||
</button>
|
||||
),
|
||||
Input: ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp(props.value, bindings?.value);
|
||||
@@ -95,11 +97,9 @@ import { registry } from "./registry";
|
||||
function App({ spec }) {
|
||||
return (
|
||||
<StateProvider initialState={{ form: { name: "" } }}>
|
||||
<ActionProvider
|
||||
handlers={{
|
||||
submit: () => console.log("Submit"),
|
||||
}}
|
||||
>
|
||||
<ActionProvider handlers={{
|
||||
submit: () => console.log("Submit"),
|
||||
}}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</ActionProvider>
|
||||
</StateProvider>
|
||||
@@ -113,22 +113,19 @@ The React renderer uses a flat element map format:
|
||||
|
||||
```typescript
|
||||
interface Spec {
|
||||
root: string; // Key of the root element
|
||||
elements: Record<string, UIElement>; // Flat map of elements by key
|
||||
state?: Record<string, unknown>; // Optional initial state
|
||||
root: string; // Key of the root element
|
||||
elements: Record<string, UIElement>; // Flat map of elements by key
|
||||
state?: Record<string, unknown>; // Optional initial state
|
||||
}
|
||||
|
||||
interface UIElement {
|
||||
type: string; // Component name from catalog
|
||||
props: Record<string, unknown>; // Component props
|
||||
children?: string[]; // Keys of child elements
|
||||
slots?: Record<string, string[]>; // Named slots mapped to child keys
|
||||
visible?: VisibilityCondition; // Visibility condition
|
||||
type: string; // Component name from catalog
|
||||
props: Record<string, unknown>; // Component props
|
||||
children?: string[]; // Keys of child elements
|
||||
visible?: VisibilityCondition; // Visibility condition
|
||||
}
|
||||
```
|
||||
|
||||
The `slots` element field is a React renderer feature. Other renderer packages may only use catalog slot declarations for default children.
|
||||
|
||||
Example spec:
|
||||
|
||||
```json
|
||||
@@ -166,11 +163,11 @@ Share data across components with JSON Pointer paths:
|
||||
```tsx
|
||||
<StateProvider initialState={{ user: { name: "John" } }}>
|
||||
{children}
|
||||
</StateProvider>;
|
||||
</StateProvider>
|
||||
|
||||
// In components:
|
||||
const { state, get, set } = useStateStore();
|
||||
const name = get("/user/name"); // "John"
|
||||
const name = get("/user/name"); // "John"
|
||||
set("/user/age", 25);
|
||||
```
|
||||
|
||||
@@ -184,7 +181,9 @@ import { createStateStore, type StateStore } from "@json-render/react";
|
||||
// Option 1: Use the built-in store outside of React
|
||||
const store = createStateStore({ count: 0 });
|
||||
|
||||
<StateProvider store={store}>{children}</StateProvider>;
|
||||
<StateProvider store={store}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
|
||||
// Mutate from anywhere — React will re-render automatically:
|
||||
store.set("/count", 1);
|
||||
@@ -192,14 +191,8 @@ store.set("/count", 1);
|
||||
// Option 2: Implement the StateStore interface with your own backend
|
||||
const zustandStore: StateStore = {
|
||||
get: (path) => getByPath(useStore.getState(), path),
|
||||
set: (path, value) =>
|
||||
useStore.setState((prev) => {
|
||||
/* ... */
|
||||
}),
|
||||
update: (updates) =>
|
||||
useStore.setState((prev) => {
|
||||
/* ... */
|
||||
}),
|
||||
set: (path, value) => useStore.setState(prev => { /* ... */ }),
|
||||
update: (updates) => useStore.setState(prev => { /* ... */ }),
|
||||
getSnapshot: () => useStore.getState(),
|
||||
subscribe: (listener) => useStore.subscribe(listener),
|
||||
};
|
||||
@@ -244,7 +237,9 @@ Control element visibility based on data:
|
||||
Add field validation:
|
||||
|
||||
```tsx
|
||||
<ValidationProvider>{children}</ValidationProvider>;
|
||||
<ValidationProvider>
|
||||
{children}
|
||||
</ValidationProvider>
|
||||
|
||||
// Use validation hooks:
|
||||
const { errors, validate } = useFieldValidation("/form/email", {
|
||||
@@ -257,17 +252,17 @@ const { errors, validate } = useFieldValidation("/form/email", {
|
||||
|
||||
## Hooks
|
||||
|
||||
| Hook | Purpose |
|
||||
| ---------------------------------- | --------------------------------------------------------------- |
|
||||
| `useStateStore()` | Access state context (`state`, `get`, `set`, `update`) |
|
||||
| `useStateValue(path)` | Get single value from state |
|
||||
| `useStateBinding(path)` | Two-way data binding (returns `[value, setValue]`) |
|
||||
| `useIsVisible(condition)` | Check if a visibility condition is met |
|
||||
| `useActions()` | Access action context |
|
||||
| `useAction(name)` | Get a single action dispatch function |
|
||||
| `useFieldValidation(path, config)` | Field validation state |
|
||||
| `useOptionalValidation()` | Non-throwing validation context (returns `null` if no provider) |
|
||||
| `useUIStream(options)` | Stream specs from an API endpoint |
|
||||
| Hook | Purpose |
|
||||
|------|---------|
|
||||
| `useStateStore()` | Access state context (`state`, `get`, `set`, `update`) |
|
||||
| `useStateValue(path)` | Get single value from state |
|
||||
| `useStateBinding(path)` | Two-way data binding (returns `[value, setValue]`) |
|
||||
| `useIsVisible(condition)` | Check if a visibility condition is met |
|
||||
| `useActions()` | Access action context |
|
||||
| `useAction(name)` | Get a single action dispatch function |
|
||||
| `useFieldValidation(path, config)` | Field validation state |
|
||||
| `useOptionalValidation()` | Non-throwing validation context (returns `null` if no provider) |
|
||||
| `useUIStream(options)` | Stream specs from an API endpoint |
|
||||
|
||||
## Visibility Conditions
|
||||
|
||||
@@ -301,13 +296,13 @@ TypeScript helpers from `@json-render/core`:
|
||||
```typescript
|
||||
import { visibility } from "@json-render/core";
|
||||
|
||||
visibility.when("/path"); // { $state: "/path" }
|
||||
visibility.unless("/path"); // { $state: "/path", not: true }
|
||||
visibility.eq("/path", val); // { $state: "/path", eq: val }
|
||||
visibility.neq("/path", val); // { $state: "/path", neq: val }
|
||||
visibility.and(cond1, cond2); // { $and: [cond1, cond2] }
|
||||
visibility.always; // true
|
||||
visibility.never; // false
|
||||
visibility.when("/path") // { $state: "/path" }
|
||||
visibility.unless("/path") // { $state: "/path", not: true }
|
||||
visibility.eq("/path", val) // { $state: "/path", eq: val }
|
||||
visibility.neq("/path", val) // { $state: "/path", neq: val }
|
||||
visibility.and(cond1, cond2) // { $and: [cond1, cond2] }
|
||||
visibility.always // true
|
||||
visibility.never // false
|
||||
```
|
||||
|
||||
## Dynamic Prop Expressions
|
||||
@@ -427,34 +422,21 @@ When using `defineRegistry`, components receive these props:
|
||||
|
||||
```typescript
|
||||
interface ComponentContext<P> {
|
||||
props: P; // Typed props from the catalog (expressions resolved)
|
||||
children?: React.ReactNode; // Rendered children
|
||||
slots?: Record<string, React.ReactNode>; // Rendered named slots
|
||||
emit: (event: string) => void; // Emit a named event (always defined)
|
||||
props: P; // Typed props from the catalog (expressions resolved)
|
||||
children?: React.ReactNode; // Rendered children
|
||||
emit: (event: string) => void; // Emit a named event (always defined)
|
||||
on: (event: string) => EventHandle; // Get event handle with metadata
|
||||
loading?: boolean; // Whether the parent is loading
|
||||
bindings?: Record<string, string>; // State paths for $bindState/$bindItem expressions (e.g. bindings.value)
|
||||
loading?: boolean; // Whether the parent is loading
|
||||
bindings?: Record<string, string>; // State paths for $bindState/$bindItem expressions (e.g. bindings.value)
|
||||
}
|
||||
|
||||
interface EventHandle {
|
||||
emit: () => void; // Fire the event
|
||||
emit: () => void; // Fire the event
|
||||
shouldPreventDefault: boolean; // Whether any binding requested preventDefault
|
||||
bound: boolean; // Whether any handler is bound
|
||||
bound: boolean; // Whether any handler is bound
|
||||
}
|
||||
```
|
||||
|
||||
Use `children` for the catalog's `"default"` slot. Components with additional slots receive them by name:
|
||||
|
||||
```tsx
|
||||
Layout: ({ children, slots }) => (
|
||||
<div>
|
||||
<header>{slots?.header}</header>
|
||||
<main>{children}</main>
|
||||
<footer>{slots?.footer}</footer>
|
||||
</div>
|
||||
),
|
||||
```
|
||||
|
||||
Use `emit("press")` for simple event firing. Use `on("click")` when you need to check metadata like `shouldPreventDefault` or `bound`:
|
||||
|
||||
```tsx
|
||||
@@ -539,27 +521,27 @@ function App() {
|
||||
|
||||
Nested lists can set `repeat.statePath` to `{ "$item": "field" }` to iterate an array on the enclosing repeat item.
|
||||
|
||||
| Export | Purpose |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------- |
|
||||
| `defineRegistry` | Create a type-safe component registry from a catalog |
|
||||
| `Renderer` | Render a spec using a registry |
|
||||
| `schema` | Element tree schema (includes built-in actions: `setState`, `pushState`, `removeState`, `validateForm`) |
|
||||
| `useStateStore` | Access state context |
|
||||
| `useStateValue` | Get single value from state |
|
||||
| `useBoundProp` | Two-way binding for `$bindState`/`$bindItem` expressions |
|
||||
| `useActions` | Access actions context |
|
||||
| `useAction` | Get a single action dispatch function |
|
||||
| `useUIStream` | Stream specs from an API endpoint |
|
||||
| `createStateStore` | Create a framework-agnostic in-memory `StateStore` |
|
||||
| Export | Purpose |
|
||||
|--------|---------|
|
||||
| `defineRegistry` | Create a type-safe component registry from a catalog |
|
||||
| `Renderer` | Render a spec using a registry |
|
||||
| `schema` | Element tree schema (includes built-in actions: `setState`, `pushState`, `removeState`, `validateForm`) |
|
||||
| `useStateStore` | Access state context |
|
||||
| `useStateValue` | Get single value from state |
|
||||
| `useBoundProp` | Two-way binding for `$bindState`/`$bindItem` expressions |
|
||||
| `useActions` | Access actions context |
|
||||
| `useAction` | Get a single action dispatch function |
|
||||
| `useUIStream` | Stream specs from an API endpoint |
|
||||
| `createStateStore` | Create a framework-agnostic in-memory `StateStore` |
|
||||
|
||||
### Types
|
||||
|
||||
| Export | Purpose |
|
||||
| -------------------- | ----------------------------------------------------------- |
|
||||
| `ComponentContext` | Typed component render function context (catalog-aware) |
|
||||
| Export | Purpose |
|
||||
|--------|---------|
|
||||
| `ComponentContext` | Typed component render function context (catalog-aware) |
|
||||
| `BaseComponentProps` | Catalog-agnostic base type for reusable component libraries |
|
||||
| `EventHandle` | Event handle with `emit()`, `shouldPreventDefault`, `bound` |
|
||||
| `ComponentFn` | Component render function type |
|
||||
| `SetState` | State setter type |
|
||||
| `StateModel` | State model type |
|
||||
| `StateStore` | Interface for plugging in external state management |
|
||||
| `EventHandle` | Event handle with `emit()`, `shouldPreventDefault`, `bound` |
|
||||
| `ComponentFn` | Component render function type |
|
||||
| `SetState` | State setter type |
|
||||
| `StateModel` | State model type |
|
||||
| `StateStore` | Interface for plugging in external state management |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react",
|
||||
"version": "0.20.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React renderer for @json-render/core. JSON becomes React components.",
|
||||
"keywords": [
|
||||
|
||||
@@ -60,7 +60,6 @@ export interface EventHandle {
|
||||
export interface BaseComponentProps<P = Record<string, unknown>> {
|
||||
props: P;
|
||||
children?: ReactNode;
|
||||
slots?: Record<string, ReactNode>;
|
||||
/** Simple event emitter (shorthand). Fires the event and returns void. */
|
||||
emit: (event: string) => void;
|
||||
/** Get an event handle with metadata. Use when you need shouldPreventDefault or bound checks. */
|
||||
|
||||
@@ -50,7 +50,6 @@ export interface ValidationContextValue {
|
||||
}
|
||||
|
||||
const ValidationContext = createContext<ValidationContextValue | null>(null);
|
||||
const EMPTY_VALIDATION_FUNCTIONS: Record<string, ValidationFunction> = {};
|
||||
|
||||
/**
|
||||
* Props for ValidationProvider
|
||||
@@ -128,7 +127,7 @@ function validationConfigEqual(
|
||||
* Provider for validation
|
||||
*/
|
||||
export function ValidationProvider({
|
||||
customFunctions = EMPTY_VALIDATION_FUNCTIONS,
|
||||
customFunctions = {},
|
||||
children,
|
||||
}: ValidationProviderProps) {
|
||||
const { state, getSnapshot } = useStateStore();
|
||||
|
||||
@@ -311,31 +311,6 @@ describe("buildSpecFromParts", () => {
|
||||
expect(childEl!.props.content).toBe("Child");
|
||||
});
|
||||
|
||||
it("preserves named slots in nested spec parts", () => {
|
||||
const spec = buildSpecFromParts([
|
||||
{
|
||||
type: "data-spec",
|
||||
data: {
|
||||
type: "nested",
|
||||
spec: {
|
||||
type: "Layout",
|
||||
props: {},
|
||||
slots: {
|
||||
header: [
|
||||
{ type: "Heading", props: { text: "Header" }, children: [] },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(spec).not.toBeNull();
|
||||
const root = spec!.elements[spec!.root]!;
|
||||
expect(root.slots?.header).toHaveLength(1);
|
||||
expect(spec!.elements[root.slots!.header![0]!]!.type).toBe("Heading");
|
||||
});
|
||||
|
||||
it("handles mixed patch + flat + nested parts in sequence", () => {
|
||||
const parts = [
|
||||
// Start with a patch
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useStateStore } from "./contexts/state";
|
||||
import { JSONUIProvider, Renderer, type ComponentRegistry } from "./renderer";
|
||||
|
||||
function renderValue(value: unknown) {
|
||||
const registry: ComponentRegistry = {
|
||||
Value: ({ element }) => <span>{String(element.props.value)}</span>,
|
||||
};
|
||||
const spec: Spec = {
|
||||
root: "value",
|
||||
elements: { value: { type: "Value", props: { value } } },
|
||||
};
|
||||
return {
|
||||
registry,
|
||||
spec,
|
||||
view: render(
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe("JSON Render PR #325 contract probes", () => {
|
||||
it("preserves nested resolved prop identity across unrelated state writes", () => {
|
||||
let effects = 0;
|
||||
function Value({
|
||||
element,
|
||||
}: React.ComponentProps<ComponentRegistry[string]>) {
|
||||
const { set } = useStateStore();
|
||||
React.useEffect(() => {
|
||||
effects += 1;
|
||||
if (effects < 3) set("/unrelated", effects);
|
||||
}, [element.props.options, set]);
|
||||
return null;
|
||||
}
|
||||
const registry: ComponentRegistry = { Value };
|
||||
const spec: Spec = {
|
||||
root: "value",
|
||||
elements: {
|
||||
value: {
|
||||
type: "Value",
|
||||
props: { options: { layout: { gap: 8 } } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
expect(effects).toBe(1);
|
||||
});
|
||||
|
||||
it("shares unchanged resolved subtrees when a sibling changes", () => {
|
||||
const observed: Array<Record<string, unknown>> = [];
|
||||
const registry: ComponentRegistry = {
|
||||
Value: ({ element }) => {
|
||||
observed.push(element.props);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
const first: Spec = {
|
||||
root: "value",
|
||||
state: { revision: 1 },
|
||||
elements: {
|
||||
value: {
|
||||
type: "Value",
|
||||
props: {
|
||||
options: { layout: { gap: 8 }, columns: [1, 2] },
|
||||
revision: { $state: "/revision" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
let setRevision: ((value: number) => void) | undefined;
|
||||
function Controls() {
|
||||
const { set } = useStateStore();
|
||||
setRevision = (value) => set("/revision", value);
|
||||
return null;
|
||||
}
|
||||
|
||||
render(
|
||||
<JSONUIProvider registry={registry} initialState={first.state}>
|
||||
<Controls />
|
||||
<Renderer spec={first} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
act(() => setRevision?.(2));
|
||||
|
||||
expect(observed).toHaveLength(2);
|
||||
expect(observed[1]).not.toBe(observed[0]);
|
||||
expect(observed[1]?.options).toBe(observed[0]?.options);
|
||||
expect((observed[1]?.options as { layout: unknown }).layout).toBe(
|
||||
(observed[0]?.options as { layout: unknown }).layout,
|
||||
);
|
||||
expect((observed[1]?.options as { columns: unknown }).columns).toBe(
|
||||
(observed[0]?.options as { columns: unknown }).columns,
|
||||
);
|
||||
expect(observed[1]?.revision).toBe(2);
|
||||
});
|
||||
|
||||
it("does not mutate containers delivered by reference from state", () => {
|
||||
const observed: unknown[] = [];
|
||||
const registry: ComponentRegistry = {
|
||||
Value: ({ element }) => {
|
||||
observed.push(element.props.user);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
const spec: Spec = {
|
||||
root: "value",
|
||||
state: { user: { profile: { name: "a" }, revision: 1 } },
|
||||
elements: {
|
||||
value: { type: "Value", props: { user: { $state: "/user" } } },
|
||||
},
|
||||
};
|
||||
let setUser: ((value: unknown) => void) | undefined;
|
||||
function Controls() {
|
||||
const { set } = useStateStore();
|
||||
setUser = (value) => set("/user", value);
|
||||
return null;
|
||||
}
|
||||
|
||||
render(
|
||||
<JSONUIProvider registry={registry} initialState={spec.state}>
|
||||
<Controls />
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
const replacementProfile = { name: "a" };
|
||||
const replacement = { profile: replacementProfile, revision: 2 };
|
||||
act(() => setUser?.(replacement));
|
||||
|
||||
expect(replacement.profile).toBe(replacementProfile);
|
||||
expect((observed.at(-1) as { revision: number }).revision).toBe(2);
|
||||
|
||||
const frozen = Object.freeze({
|
||||
profile: Object.freeze({ name: "a" }),
|
||||
revision: 3,
|
||||
});
|
||||
expect(() => act(() => setUser?.(frozen))).not.toThrow();
|
||||
expect((observed.at(-1) as { revision: number }).revision).toBe(3);
|
||||
});
|
||||
|
||||
it("delivers changed resolved object and array subtrees as fresh values", () => {
|
||||
const observed: unknown[] = [];
|
||||
const registry: ComponentRegistry = {
|
||||
Value: ({ element }) => {
|
||||
observed.push(element.props.options);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
const spec: Spec = {
|
||||
root: "value",
|
||||
state: { gap: 8 },
|
||||
elements: {
|
||||
value: {
|
||||
type: "Value",
|
||||
props: {
|
||||
options: {
|
||||
layout: { gap: { $state: "/gap" } },
|
||||
columns: [{ $state: "/gap" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
let setGap: ((value: number) => void) | undefined;
|
||||
function Controls() {
|
||||
const { set } = useStateStore();
|
||||
setGap = (value) => set("/gap", value);
|
||||
return null;
|
||||
}
|
||||
|
||||
render(
|
||||
<JSONUIProvider registry={registry} initialState={spec.state}>
|
||||
<Controls />
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
act(() => setGap?.(12));
|
||||
|
||||
const first = observed[0] as {
|
||||
layout: Record<string, unknown>;
|
||||
columns: unknown[];
|
||||
};
|
||||
const second = observed[1] as typeof first;
|
||||
expect(second).not.toBe(first);
|
||||
expect(second.layout).not.toBe(first.layout);
|
||||
expect(second.columns).not.toBe(first.columns);
|
||||
expect(second.layout.gap).toBe(12);
|
||||
expect(second.columns[0]).toBe(12);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"nested present undefined to absent",
|
||||
{ value: { nested: undefined } },
|
||||
{ value: {} },
|
||||
],
|
||||
[
|
||||
"nested absent to present undefined",
|
||||
{ value: {} },
|
||||
{ value: { nested: undefined } },
|
||||
],
|
||||
["function identity", { value: () => "first" }, { value: () => "second" }],
|
||||
[
|
||||
"symbol identity",
|
||||
{ value: Symbol("first") },
|
||||
{ value: Symbol("second") },
|
||||
],
|
||||
["BigInt value", { value: 1n }, { value: 2n }],
|
||||
])("distinguishes changed %s", (_label, firstProps, secondProps) => {
|
||||
const display = (value: unknown) => {
|
||||
if (typeof value === "function") return value();
|
||||
if (value && typeof value === "object") {
|
||||
return Object.keys(value).join(",");
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
const registry: ComponentRegistry = {
|
||||
Value: ({ element }) => (
|
||||
<span>{`${Object.keys(element.props).join(",")}:${display(element.props.value)}`}</span>
|
||||
),
|
||||
};
|
||||
const initial: Spec = {
|
||||
root: "value",
|
||||
elements: { value: { type: "Value", props: firstProps } },
|
||||
};
|
||||
const view = render(
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer spec={initial} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
const next: Spec = {
|
||||
root: "value",
|
||||
elements: { value: { type: "Value", props: secondProps } },
|
||||
};
|
||||
|
||||
view.rerender(
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer spec={next} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
expect(view.container.textContent).toBe(
|
||||
`${Object.keys(secondProps).join(",")}:${display(secondProps.value)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts BigInt prop values without throwing", () => {
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
expect(() => renderValue(1n)).not.toThrow();
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
it("does not invalidate an unchanged nested NaN value", () => {
|
||||
const component = vi.fn(() => null);
|
||||
const registry: ComponentRegistry = { Value: component };
|
||||
const first: Spec = {
|
||||
root: "value",
|
||||
elements: {
|
||||
value: { type: "Value", props: { value: { nested: Number.NaN } } },
|
||||
},
|
||||
};
|
||||
const view = render(
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer spec={first} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
const second: Spec = {
|
||||
root: "value",
|
||||
elements: {
|
||||
value: { type: "Value", props: { value: { nested: Number.NaN } } },
|
||||
},
|
||||
};
|
||||
|
||||
view.rerender(
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer spec={second} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
expect(component).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("invalidates a nested negative zero to zero transition", () => {
|
||||
const component = vi.fn(
|
||||
({ element }: React.ComponentProps<ComponentRegistry[string]>) => (
|
||||
<span>
|
||||
{Object.is((element.props.value as { nested: number }).nested, -0)
|
||||
? "negative-zero"
|
||||
: "zero"}
|
||||
</span>
|
||||
),
|
||||
);
|
||||
const registry: ComponentRegistry = { Value: component };
|
||||
const first: Spec = {
|
||||
root: "value",
|
||||
elements: {
|
||||
value: { type: "Value", props: { value: { nested: -0 } } },
|
||||
},
|
||||
};
|
||||
const view = render(
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer spec={first} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
const second: Spec = {
|
||||
root: "value",
|
||||
elements: {
|
||||
value: { type: "Value", props: { value: { nested: 0 } } },
|
||||
},
|
||||
};
|
||||
|
||||
view.rerender(
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer spec={second} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
expect(component).toHaveBeenCalledTimes(2);
|
||||
expect(view.container.textContent).toBe("zero");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["function", () => "stable"],
|
||||
["symbol", Symbol("stable")],
|
||||
["BigInt", 1n],
|
||||
])("does not invalidate an unchanged %s value", (_label, value) => {
|
||||
const component = vi.fn(() => null);
|
||||
const registry: ComponentRegistry = { Value: component };
|
||||
const first: Spec = {
|
||||
root: "value",
|
||||
elements: { value: { type: "Value", props: { value } } },
|
||||
};
|
||||
const view = render(
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer spec={first} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
const second: Spec = {
|
||||
root: "value",
|
||||
elements: { value: { type: "Value", props: { value } } },
|
||||
};
|
||||
|
||||
view.rerender(
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer spec={second} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
expect(component).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,12 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { defineCatalog, type Spec } from "@json-render/core";
|
||||
import { z } from "zod";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import {
|
||||
defineRegistry,
|
||||
JSONUIProvider,
|
||||
Renderer,
|
||||
type ComponentRenderProps,
|
||||
} from "./renderer";
|
||||
import { schema } from "./schema";
|
||||
|
||||
describe("Renderer", () => {
|
||||
it("renders null for null spec", () => {
|
||||
@@ -50,61 +47,6 @@ describe("Renderer", () => {
|
||||
expect(element.props.fallback).toBe(Fallback);
|
||||
});
|
||||
|
||||
it("renders named slots through defineRegistry", () => {
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Layout: {
|
||||
props: z.object({}),
|
||||
slots: ["default", "header", "footer"],
|
||||
},
|
||||
Text: {
|
||||
props: z.object({ text: z.string() }),
|
||||
slots: [],
|
||||
},
|
||||
},
|
||||
actions: {},
|
||||
});
|
||||
const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Layout: ({ children, slots }) => (
|
||||
<section>
|
||||
<header data-testid="header-slot">{slots?.header}</header>
|
||||
<main data-testid="default-slot">{children}</main>
|
||||
<footer data-testid="footer-slot">{slots?.footer}</footer>
|
||||
</section>
|
||||
),
|
||||
Text: ({ props }) => <span>{props.text}</span>,
|
||||
},
|
||||
});
|
||||
const spec: Spec = {
|
||||
root: "layout",
|
||||
elements: {
|
||||
layout: {
|
||||
type: "Layout",
|
||||
props: {},
|
||||
children: ["main"],
|
||||
slots: {
|
||||
header: ["header"],
|
||||
footer: ["footer"],
|
||||
},
|
||||
},
|
||||
header: { type: "Text", props: { text: "Header" } },
|
||||
main: { type: "Text", props: { text: "Main" } },
|
||||
footer: { type: "Text", props: { text: "Footer" } },
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("header-slot").textContent).toBe("Header");
|
||||
expect(screen.getByTestId("default-slot").textContent).toBe("Main");
|
||||
expect(screen.getByTestId("footer-slot").textContent).toBe("Footer");
|
||||
});
|
||||
|
||||
it.each(["subitems", "/subitems"])(
|
||||
"resolves nested repeat statePath %s from parent $item scope",
|
||||
(itemPath) => {
|
||||
|
||||
+24
-381
@@ -62,7 +62,6 @@ export interface ComponentRenderProps<P = Record<string, unknown>> {
|
||||
element: UIElement<string, P>;
|
||||
/** Rendered children */
|
||||
children?: ReactNode;
|
||||
slots?: Record<string, ReactNode>;
|
||||
/** Emit a named event. The renderer resolves the event to action binding(s) from the element's `on` field. Always provided by the renderer. */
|
||||
emit: (event: string) => void;
|
||||
/** Get an event handle with metadata (shouldPreventDefault, bound). Use when you need to inspect event bindings. */
|
||||
@@ -89,12 +88,6 @@ export type ComponentRenderer<P = Record<string, unknown>> = ComponentType<
|
||||
*/
|
||||
export type ComponentRegistry = Record<string, ComponentRenderer<any>>;
|
||||
|
||||
const registryMetadata = new WeakMap<
|
||||
ComponentRegistry,
|
||||
Record<string, { slots?: string[] }>
|
||||
>();
|
||||
const EMPTY_ELEMENT_PROPS: Record<string, unknown> = {};
|
||||
|
||||
/**
|
||||
* Props for the Renderer component
|
||||
*/
|
||||
@@ -116,7 +109,6 @@ export interface RendererProps {
|
||||
|
||||
interface ElementErrorBoundaryProps {
|
||||
elementType: string;
|
||||
resetKey: number | undefined;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -145,12 +137,6 @@ class ElementErrorBoundary extends React.Component<
|
||||
);
|
||||
}
|
||||
|
||||
componentDidUpdate(previous: ElementErrorBoundaryProps) {
|
||||
if (this.state.hasError && previous.resetKey !== this.props.resetKey) {
|
||||
this.setState({ hasError: false });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// Render nothing – the element silently disappears rather than
|
||||
@@ -194,262 +180,8 @@ interface ElementRendererProps {
|
||||
registry: ComponentRegistry;
|
||||
loading?: boolean;
|
||||
fallback?: ComponentRenderer;
|
||||
signatures: Record<string, number>;
|
||||
}
|
||||
|
||||
function stabilizeRecord<T extends Record<string, unknown> | undefined>(
|
||||
value: T,
|
||||
ref: React.MutableRefObject<T>,
|
||||
): T {
|
||||
const previous = ref.current;
|
||||
if (previous === value) return previous;
|
||||
if (previous && value) {
|
||||
const keys = Object.keys(value);
|
||||
if (
|
||||
keys.length === Object.keys(previous).length &&
|
||||
keys.every(
|
||||
(key) =>
|
||||
Object.prototype.hasOwnProperty.call(previous, key) &&
|
||||
previous[key] === value[key],
|
||||
)
|
||||
) {
|
||||
return previous;
|
||||
}
|
||||
}
|
||||
ref.current = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
if (value === null || typeof value !== "object") return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function structurallyEqual(previous: unknown, next: unknown): boolean {
|
||||
if (Object.is(previous, next)) return true;
|
||||
const previousIsArray = Array.isArray(previous);
|
||||
if (previousIsArray !== Array.isArray(next)) return false;
|
||||
if (
|
||||
!previousIsArray &&
|
||||
(previous === null ||
|
||||
next === null ||
|
||||
typeof previous !== "object" ||
|
||||
typeof next !== "object")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const previousRecord = previous as Record<string, unknown>;
|
||||
const nextRecord = next as Record<string, unknown>;
|
||||
const keys = Object.keys(nextRecord);
|
||||
if (
|
||||
(previousIsArray &&
|
||||
(previous as unknown[]).length !== (next as unknown[]).length) ||
|
||||
keys.length !== Object.keys(previousRecord).length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return keys.every(
|
||||
(key) =>
|
||||
Object.prototype.hasOwnProperty.call(previousRecord, key) &&
|
||||
structurallyEqual(previousRecord[key], nextRecord[key]),
|
||||
);
|
||||
}
|
||||
|
||||
function snapshotStructuralValue(value: unknown): unknown {
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
const snapshot: Record<string, unknown> | unknown[] = Array.isArray(value)
|
||||
? new Array(value.length)
|
||||
: {};
|
||||
for (const key of Object.keys(value)) {
|
||||
(snapshot as Record<string, unknown>)[key] = snapshotStructuralValue(
|
||||
(value as Record<string, unknown>)[key],
|
||||
);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function shareResolvedValue(previous: unknown, next: unknown): unknown {
|
||||
if (Object.is(previous, next)) return previous;
|
||||
const previousIsArray = Array.isArray(previous);
|
||||
if (previousIsArray !== Array.isArray(next)) return next;
|
||||
if (!previousIsArray && (!isPlainRecord(previous) || !isPlainRecord(next))) {
|
||||
return next;
|
||||
}
|
||||
|
||||
// `next` can hold containers owned by the state model, directives, or
|
||||
// computed functions, so share into a copy instead of writing into it.
|
||||
const previousRecord = previous as Record<string, unknown>;
|
||||
const nextRecord = next as Record<string, unknown>;
|
||||
const keys = Object.keys(nextRecord);
|
||||
const shared: Record<string, unknown> | unknown[] = previousIsArray
|
||||
? new Array((next as unknown[]).length)
|
||||
: {};
|
||||
let unchanged =
|
||||
(!previousIsArray ||
|
||||
(previous as unknown[]).length === (next as unknown[]).length) &&
|
||||
keys.length === Object.keys(previousRecord).length;
|
||||
for (const key of keys) {
|
||||
let value = nextRecord[key];
|
||||
if (Object.prototype.hasOwnProperty.call(previousRecord, key)) {
|
||||
value = shareResolvedValue(previousRecord[key], value);
|
||||
if (!Object.is(previousRecord[key], value)) unchanged = false;
|
||||
} else {
|
||||
unchanged = false;
|
||||
}
|
||||
(shared as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
return unchanged ? previous : shared;
|
||||
}
|
||||
|
||||
interface ElementSignatureEntry {
|
||||
own: UIElement;
|
||||
children: Array<[string, number]>;
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface ElementSignatureFrame {
|
||||
key: string;
|
||||
element: UIElement;
|
||||
children: string[];
|
||||
childIndex: number;
|
||||
childVersions: Array<[string, number]>;
|
||||
}
|
||||
|
||||
function useElementSignatures(spec: Spec | null): Record<string, number> {
|
||||
const entriesRef = useRef<Record<string, ElementSignatureEntry>>({});
|
||||
const versionRef = useRef(0);
|
||||
if (!spec) return {};
|
||||
|
||||
const previous = entriesRef.current;
|
||||
const next: Record<string, ElementSignatureEntry> = {};
|
||||
const signatures: Record<string, number> = {};
|
||||
const visiting = new Set<string>();
|
||||
|
||||
for (const key of Object.keys(spec.elements)) {
|
||||
if (signatures[key] !== undefined) continue;
|
||||
const element = spec.elements[key];
|
||||
if (!element) continue;
|
||||
visiting.add(key);
|
||||
const stack: ElementSignatureFrame[] = [
|
||||
{
|
||||
key,
|
||||
element,
|
||||
children: [
|
||||
...(element.children ?? []),
|
||||
...Object.values(element.slots ?? {}).flat(),
|
||||
],
|
||||
childIndex: 0,
|
||||
childVersions: [],
|
||||
},
|
||||
];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const frame = stack[stack.length - 1]!;
|
||||
const childKey = frame.children[frame.childIndex];
|
||||
if (childKey !== undefined) {
|
||||
const childVersion = signatures[childKey];
|
||||
if (childVersion !== undefined) {
|
||||
frame.childVersions.push([childKey, childVersion]);
|
||||
frame.childIndex += 1;
|
||||
continue;
|
||||
}
|
||||
const childElement = spec.elements[childKey];
|
||||
if (!childElement || visiting.has(childKey)) {
|
||||
frame.childVersions.push([childKey, -1]);
|
||||
frame.childIndex += 1;
|
||||
continue;
|
||||
}
|
||||
visiting.add(childKey);
|
||||
stack.push({
|
||||
key: childKey,
|
||||
element: childElement,
|
||||
children: [
|
||||
...(childElement.children ?? []),
|
||||
...Object.values(childElement.slots ?? {}).flat(),
|
||||
],
|
||||
childIndex: 0,
|
||||
childVersions: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const prior = previous[frame.key];
|
||||
const version =
|
||||
prior &&
|
||||
structurallyEqual(prior.own, frame.element) &&
|
||||
structurallyEqual(prior.children, frame.childVersions)
|
||||
? prior.version
|
||||
: ++versionRef.current;
|
||||
visiting.delete(frame.key);
|
||||
next[frame.key] = {
|
||||
own: snapshotStructuralValue(frame.element) as UIElement,
|
||||
children: frame.childVersions,
|
||||
version,
|
||||
};
|
||||
signatures[frame.key] = version;
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
entriesRef.current = next;
|
||||
return signatures;
|
||||
}
|
||||
|
||||
interface CatalogComponentBoundaryProps {
|
||||
Component: ComponentRenderer;
|
||||
signature: number | undefined;
|
||||
execute: ReturnType<typeof useActions>["execute"];
|
||||
actionContext: ReturnType<typeof useRepeatScope>;
|
||||
functions: Record<string, ComputedFunction>;
|
||||
directives: DirectiveRegistry | undefined;
|
||||
element: UIElement;
|
||||
slots?: Record<string, ReactNode>;
|
||||
emit: (event: string) => void;
|
||||
on: (event: string) => EventHandle;
|
||||
bindings?: Record<string, string>;
|
||||
loading?: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const CatalogComponentBoundary = React.memo(
|
||||
function CatalogComponentBoundary({
|
||||
Component,
|
||||
element,
|
||||
slots,
|
||||
emit,
|
||||
on,
|
||||
bindings,
|
||||
loading,
|
||||
children,
|
||||
}: CatalogComponentBoundaryProps) {
|
||||
return (
|
||||
<Component
|
||||
element={element}
|
||||
slots={slots}
|
||||
emit={emit}
|
||||
on={on}
|
||||
bindings={bindings}
|
||||
loading={loading}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
},
|
||||
(previous, next) =>
|
||||
previous.Component === next.Component &&
|
||||
previous.signature === next.signature &&
|
||||
previous.execute === next.execute &&
|
||||
previous.actionContext?.item === next.actionContext?.item &&
|
||||
previous.actionContext?.index === next.actionContext?.index &&
|
||||
previous.actionContext?.basePath === next.actionContext?.basePath &&
|
||||
previous.functions === next.functions &&
|
||||
previous.directives === next.directives &&
|
||||
previous.element.props === next.element.props &&
|
||||
previous.bindings === next.bindings &&
|
||||
previous.loading === next.loading,
|
||||
);
|
||||
|
||||
/**
|
||||
* Subscribe to whether any devtools is mounted so the renderer can add a
|
||||
* `data-jr-key` wrapper for the picker. Trivially cheap when inactive.
|
||||
@@ -466,14 +198,13 @@ function useDevtoolsActive(): boolean {
|
||||
* Element renderer component.
|
||||
* Memoized to prevent re-rendering all repeat children when state changes.
|
||||
*/
|
||||
function ReactiveElementRenderer({
|
||||
const ElementRenderer = React.memo(function ElementRenderer({
|
||||
element,
|
||||
elementKey,
|
||||
spec,
|
||||
registry,
|
||||
loading,
|
||||
fallback,
|
||||
signatures,
|
||||
}: ElementRendererProps) {
|
||||
const devtoolsActive = useDevtoolsActive();
|
||||
const repeatScope = useRepeatScope();
|
||||
@@ -574,10 +305,6 @@ function ReactiveElementRenderer({
|
||||
const watchConfig = element.watch;
|
||||
const prevWatchValues = useRef<Record<string, unknown> | null>(null);
|
||||
const stableWatchRef = useRef<Record<string, unknown> | undefined>(undefined);
|
||||
const stableBindingsRef = useRef<Record<string, string> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const stableResolvedPropsRef = useRef<Record<string, unknown>>({});
|
||||
|
||||
const watchedValues = useMemo(() => {
|
||||
if (!watchConfig) return undefined;
|
||||
@@ -651,20 +378,11 @@ function ReactiveElementRenderer({
|
||||
}
|
||||
|
||||
// Resolve $bindState/$bindItem expressions → bindings map (prop name → state path)
|
||||
const rawProps =
|
||||
(element.props as Record<string, unknown> | undefined) ??
|
||||
EMPTY_ELEMENT_PROPS;
|
||||
const elementBindings = stabilizeRecord(
|
||||
resolveBindings(rawProps, fullCtx),
|
||||
stableBindingsRef,
|
||||
);
|
||||
const rawProps = element.props as Record<string, unknown>;
|
||||
const elementBindings = resolveBindings(rawProps, fullCtx);
|
||||
|
||||
// Resolve dynamic prop expressions ($state, $item, $index, $bindState, $bindItem, $cond/$then/$else)
|
||||
const resolvedProps = shareResolvedValue(
|
||||
stableResolvedPropsRef.current,
|
||||
resolveElementProps(rawProps, fullCtx),
|
||||
) as Record<string, unknown>;
|
||||
stableResolvedPropsRef.current = resolvedProps;
|
||||
const resolvedProps = resolveElementProps(rawProps, fullCtx);
|
||||
|
||||
const resolvedElement =
|
||||
resolvedProps !== element.props
|
||||
@@ -679,32 +397,23 @@ function ReactiveElementRenderer({
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadata = registryMetadata.get(registry)?.[resolvedElement.type];
|
||||
if (resolvedElement.slots && metadata?.slots) {
|
||||
const availableSlots = new Set(metadata.slots);
|
||||
for (const slotName of Object.keys(resolvedElement.slots)) {
|
||||
if (slotName === "default") {
|
||||
console.warn(
|
||||
`[json-render] Component "${resolvedElement.type}" uses slots.default. Use "children" for default slot content.`,
|
||||
);
|
||||
} else if (!availableSlots.has(slotName)) {
|
||||
console.warn(
|
||||
`[json-render] Unknown slot "${slotName}" on component "${resolvedElement.type}". Available slots: ${metadata.slots.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const renderChildKeys = (childKeys: string[], slotName?: string) =>
|
||||
childKeys.map((childKey) => {
|
||||
// ---- Render children (with repeat support) ----
|
||||
const children = resolvedElement.repeat ? (
|
||||
<RepeatChildren
|
||||
element={resolvedElement}
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
loading={loading}
|
||||
fallback={fallback}
|
||||
itemFilter={repeatItemFilter}
|
||||
/>
|
||||
) : (
|
||||
resolvedElement.children?.map((childKey) => {
|
||||
const childElement = spec.elements[childKey];
|
||||
if (!childElement) {
|
||||
if (!loading) {
|
||||
const location = slotName
|
||||
? `in slot "${slotName}" of "${resolvedElement.type}"`
|
||||
: `as child of "${resolvedElement.type}"`;
|
||||
console.warn(
|
||||
`[json-render] Missing element "${childKey}" referenced ${location}. This element will not render.`,
|
||||
`[json-render] Missing element "${childKey}" referenced as child of "${resolvedElement.type}". This element will not render.`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -718,51 +427,21 @@ function ReactiveElementRenderer({
|
||||
registry={registry}
|
||||
loading={loading}
|
||||
fallback={fallback}
|
||||
signatures={signatures}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const children = resolvedElement.repeat ? (
|
||||
<RepeatChildren
|
||||
element={resolvedElement}
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
loading={loading}
|
||||
fallback={fallback}
|
||||
itemFilter={repeatItemFilter}
|
||||
signatures={signatures}
|
||||
/>
|
||||
) : resolvedElement.children ? (
|
||||
renderChildKeys(resolvedElement.children)
|
||||
) : undefined;
|
||||
|
||||
const slots = resolvedElement.slots
|
||||
? Object.fromEntries(
|
||||
Object.entries(resolvedElement.slots).map(([slotName, childKeys]) => [
|
||||
slotName,
|
||||
renderChildKeys(childKeys, slotName),
|
||||
]),
|
||||
)
|
||||
: undefined;
|
||||
})
|
||||
);
|
||||
|
||||
const rendered = (
|
||||
<CatalogComponentBoundary
|
||||
Component={Component}
|
||||
signature={signatures[elementKey ?? ""]}
|
||||
execute={execute}
|
||||
actionContext={repeatScope}
|
||||
functions={functions}
|
||||
directives={directives}
|
||||
<Component
|
||||
element={resolvedElement}
|
||||
slots={slots}
|
||||
emit={emit}
|
||||
on={on}
|
||||
bindings={elementBindings}
|
||||
loading={loading}
|
||||
>
|
||||
{children}
|
||||
</CatalogComponentBoundary>
|
||||
</Component>
|
||||
);
|
||||
|
||||
// When devtools is mounted, wrap each element in a transparent span so the
|
||||
@@ -778,27 +457,11 @@ function ReactiveElementRenderer({
|
||||
);
|
||||
|
||||
return (
|
||||
<ElementErrorBoundary
|
||||
elementType={resolvedElement.type}
|
||||
resetKey={signatures[elementKey ?? ""]}
|
||||
>
|
||||
<ElementErrorBoundary elementType={resolvedElement.type}>
|
||||
{tagged}
|
||||
</ElementErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
const ElementRenderer = React.memo(
|
||||
function ElementRenderer(props: ElementRendererProps) {
|
||||
return <ReactiveElementRenderer {...props} />;
|
||||
},
|
||||
(previous, next) =>
|
||||
previous.elementKey === next.elementKey &&
|
||||
previous.signatures[previous.elementKey ?? ""] ===
|
||||
next.signatures[next.elementKey ?? ""] &&
|
||||
previous.registry === next.registry &&
|
||||
previous.loading === next.loading &&
|
||||
previous.fallback === next.fallback,
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RepeatChildren -- renders child elements once per item in a state array.
|
||||
@@ -811,7 +474,6 @@ function RepeatChildren({
|
||||
registry,
|
||||
loading,
|
||||
fallback,
|
||||
signatures,
|
||||
itemFilter,
|
||||
}: {
|
||||
element: UIElement;
|
||||
@@ -819,7 +481,6 @@ function RepeatChildren({
|
||||
registry: ComponentRegistry;
|
||||
loading?: boolean;
|
||||
fallback?: ComponentRenderer;
|
||||
signatures: Record<string, number>;
|
||||
itemFilter?: UIElement["visible"];
|
||||
}) {
|
||||
const { state } = useStateStore();
|
||||
@@ -891,7 +552,6 @@ function RepeatChildren({
|
||||
registry={registry}
|
||||
loading={loading}
|
||||
fallback={fallback}
|
||||
signatures={signatures}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -906,7 +566,6 @@ function RepeatChildren({
|
||||
* Main renderer component
|
||||
*/
|
||||
export function Renderer({ spec, registry, loading, fallback }: RendererProps) {
|
||||
const signatures = useElementSignatures(spec);
|
||||
if (!spec || !spec.root) {
|
||||
return null;
|
||||
}
|
||||
@@ -924,7 +583,6 @@ export function Renderer({ spec, registry, loading, fallback }: RendererProps) {
|
||||
registry={registry}
|
||||
loading={loading}
|
||||
fallback={fallback}
|
||||
signatures={signatures}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1090,7 +748,7 @@ type DefineRegistryOptions<C extends Catalog> = {
|
||||
* ```
|
||||
*/
|
||||
export function defineRegistry<C extends Catalog>(
|
||||
catalog: C,
|
||||
_catalog: C,
|
||||
options: DefineRegistryOptions<C>,
|
||||
): DefineRegistryResult {
|
||||
// Build component registry
|
||||
@@ -1100,7 +758,6 @@ export function defineRegistry<C extends Catalog>(
|
||||
registry[name] = ({
|
||||
element,
|
||||
children,
|
||||
slots,
|
||||
emit,
|
||||
on,
|
||||
bindings,
|
||||
@@ -1109,7 +766,6 @@ export function defineRegistry<C extends Catalog>(
|
||||
return (componentFn as DefineRegistryComponentFn)({
|
||||
props: element.props,
|
||||
children,
|
||||
slots,
|
||||
emit,
|
||||
on,
|
||||
bindings,
|
||||
@@ -1118,12 +774,6 @@ export function defineRegistry<C extends Catalog>(
|
||||
};
|
||||
}
|
||||
}
|
||||
const catalogComponents = (
|
||||
catalog.data as { components?: Record<string, { slots?: string[] }> }
|
||||
).components;
|
||||
if (catalogComponents) {
|
||||
registryMetadata.set(registry, catalogComponents);
|
||||
}
|
||||
|
||||
// Build action helpers
|
||||
const actionMap = options.actions
|
||||
@@ -1173,7 +823,6 @@ export function defineRegistry<C extends Catalog>(
|
||||
type DefineRegistryComponentFn = (ctx: {
|
||||
props: unknown;
|
||||
children?: React.ReactNode;
|
||||
slots?: Record<string, React.ReactNode>;
|
||||
emit: (event: string) => void;
|
||||
on: (event: string) => EventHandle;
|
||||
bindings?: Record<string, string>;
|
||||
@@ -1257,12 +906,6 @@ export function createRenderer<
|
||||
// Convert component map to registry
|
||||
const registry: ComponentRegistry =
|
||||
components as unknown as ComponentRegistry;
|
||||
const catalogComponents = (
|
||||
catalog.data as { components?: Record<string, { slots?: string[] }> }
|
||||
).components;
|
||||
if (catalogComponents) {
|
||||
registryMetadata.set(registry, catalogComponents);
|
||||
}
|
||||
|
||||
// Return the renderer component
|
||||
return function CatalogRenderer({
|
||||
|
||||
@@ -22,7 +22,6 @@ export const schema = defineSchema(
|
||||
props: s.propsOf("catalog.components"),
|
||||
/** Child element keys (flat reference) */
|
||||
children: s.array(s.string()),
|
||||
slots: { ...s.record(s.array(s.string())), ...s.optional() },
|
||||
/** Visibility condition */
|
||||
visible: { ...s.any(), ...s.optional() },
|
||||
/** Repeat children from a state array */
|
||||
@@ -81,7 +80,6 @@ export const schema = defineSchema(
|
||||
"CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.",
|
||||
"SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.",
|
||||
'REQUIRED FIELDS: Every element MUST include a "children" array. Leaf elements (text, badges, inputs, images) use an empty array: "children": []. Omitting "children" fails validation.',
|
||||
'NAMED SLOTS: Use "children" for the default slot. For other slots declared by the component, use a top-level "slots" object that maps each slot name to child element keys, for example {"slots":{"header":["heading"],"footer":["actions"]}}. Never use "slots.default". Every referenced key must exist.',
|
||||
'FILTERED LISTS: To render only the items matching a field value (kanban columns, tabbed lists, status sections), put "repeat" and a "visible" condition with $item on the same container element: {"repeat": {"statePath": "/tasks", "key": "id"}, "visible": {"$item": "status", "eq": "todo"}} renders one child per matching item. A visible condition object must use exactly one of $state, $item, or $index — never combine them in one object.',
|
||||
|
||||
// Field placement
|
||||
|
||||
@@ -1,689 +0,0 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import {
|
||||
defineDirective,
|
||||
resolvePropValue,
|
||||
type Spec,
|
||||
} from "@json-render/core";
|
||||
import React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildSpecFromParts, type DataPart } from "./hooks";
|
||||
import { JSONUIProvider, Renderer, type ComponentRegistry } from "./renderer";
|
||||
import { useStateStore } from "./contexts/state";
|
||||
|
||||
const ELEMENT_COUNT = 26;
|
||||
const PATCH_COUNT = 200;
|
||||
|
||||
function makeSpec(): Spec {
|
||||
const children = Array.from(
|
||||
{ length: ELEMENT_COUNT },
|
||||
(_, index) => `metric-${index}`,
|
||||
);
|
||||
return {
|
||||
root: "root",
|
||||
state: Object.fromEntries(children.map((key, index) => [key, index])),
|
||||
elements: {
|
||||
root: { type: "Stack", props: {}, children },
|
||||
...Object.fromEntries(
|
||||
children.map((key) => [
|
||||
key,
|
||||
{
|
||||
type: "Metric",
|
||||
props: { value: { $bindState: `/${key}` }, revision: 0 },
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function patchPart(revision: number): DataPart {
|
||||
return {
|
||||
type: "data-spec",
|
||||
data: {
|
||||
type: "patch",
|
||||
patch: {
|
||||
op: "replace",
|
||||
path: "/elements/metric-0/props/revision",
|
||||
value: revision,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("streaming render stability", () => {
|
||||
it("does not execute untouched catalog components for each patch", async () => {
|
||||
const parts: DataPart[] = [
|
||||
{ type: "data-spec", data: { type: "flat", spec: makeSpec() } },
|
||||
];
|
||||
let stackRenders = 0;
|
||||
let metricRenders = 0;
|
||||
const registry: ComponentRegistry = {
|
||||
Stack: ({ children }) => {
|
||||
stackRenders += 1;
|
||||
return <>{children}</>;
|
||||
},
|
||||
Metric: () => {
|
||||
metricRenders += 1;
|
||||
return null;
|
||||
},
|
||||
};
|
||||
const initialSpec = buildSpecFromParts(parts);
|
||||
const view = render(
|
||||
<JSONUIProvider registry={registry} initialState={initialSpec?.state}>
|
||||
<Renderer spec={initialSpec} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
for (let revision = 1; revision <= PATCH_COUNT; revision += 1) {
|
||||
await act(async () => {
|
||||
parts.push(patchPart(revision));
|
||||
const spec = buildSpecFromParts(parts);
|
||||
view.rerender(
|
||||
<JSONUIProvider registry={registry} initialState={spec?.state}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
expect(stackRenders).toBe(PATCH_COUNT + 1);
|
||||
expect(metricRenders).toBe(ELEMENT_COUNT + PATCH_COUNT);
|
||||
expect(initialSpec?.elements["metric-1"]?.props.revision).toBe(0);
|
||||
});
|
||||
|
||||
it("does not execute untouched element renderers for each patch", async () => {
|
||||
const parts: DataPart[] = [
|
||||
{ type: "data-spec", data: { type: "flat", spec: makeSpec() } },
|
||||
];
|
||||
for (const [key, element] of Object.entries(parts[0]!.data.spec.elements)) {
|
||||
if (key !== "root") element.props.probe = { $count: key };
|
||||
}
|
||||
let elementExecutions = 0;
|
||||
const countDirective = defineDirective({
|
||||
name: "$count",
|
||||
resolve(value) {
|
||||
elementExecutions += 1;
|
||||
return value.$count;
|
||||
},
|
||||
});
|
||||
const directives = [countDirective];
|
||||
const registry: ComponentRegistry = {
|
||||
Stack: ({ children }) => <>{children}</>,
|
||||
Metric: () => null,
|
||||
};
|
||||
const initialSpec = buildSpecFromParts(parts);
|
||||
const view = render(
|
||||
<JSONUIProvider
|
||||
registry={registry}
|
||||
initialState={initialSpec?.state}
|
||||
directives={directives}
|
||||
>
|
||||
<Renderer spec={initialSpec} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
for (let revision = 1; revision <= PATCH_COUNT; revision += 1) {
|
||||
await act(async () => {
|
||||
parts.push(patchPart(revision));
|
||||
const spec = buildSpecFromParts(parts);
|
||||
view.rerender(
|
||||
<JSONUIProvider
|
||||
registry={registry}
|
||||
initialState={spec?.state}
|
||||
directives={directives}
|
||||
>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
expect(elementExecutions).toBe(ELEMENT_COUNT + PATCH_COUNT);
|
||||
});
|
||||
|
||||
it("keeps binding identity stable across state writes", () => {
|
||||
let writes = 0;
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
function WritingMetric({
|
||||
bindings,
|
||||
}: React.ComponentProps<ComponentRegistry[string]>) {
|
||||
const { set } = useStateStore();
|
||||
React.useEffect(() => {
|
||||
writes += 1;
|
||||
set("/metric-0", writes);
|
||||
}, [bindings, set]);
|
||||
return null;
|
||||
}
|
||||
const spec = makeSpec();
|
||||
const registry: ComponentRegistry = {
|
||||
Stack: ({ children }) => <>{children}</>,
|
||||
Metric: WritingMetric,
|
||||
};
|
||||
|
||||
render(
|
||||
<JSONUIProvider registry={registry} initialState={spec.state}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
const output = error.mock.calls.flat().map(String).join("\n");
|
||||
expect(output).not.toMatch(/Maximum update depth exceeded/);
|
||||
expect(writes).toBe(ELEMENT_COUNT);
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
it("preserves state context updates for catalog components", async () => {
|
||||
let metricRenders = 0;
|
||||
let write: (() => void) | undefined;
|
||||
function Metric() {
|
||||
metricRenders += 1;
|
||||
const { set } = useStateStore();
|
||||
write = () => set("/metric-0", 999);
|
||||
return null;
|
||||
}
|
||||
const spec = makeSpec();
|
||||
const registry: ComponentRegistry = {
|
||||
Stack: ({ children }) => <>{children}</>,
|
||||
Metric,
|
||||
};
|
||||
render(
|
||||
<JSONUIProvider registry={registry} initialState={spec.state}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
expect(metricRenders).toBe(ELEMENT_COUNT);
|
||||
await act(async () => write?.());
|
||||
expect(metricRenders).toBe(ELEMENT_COUNT * 2);
|
||||
});
|
||||
|
||||
it("renders a child that becomes available in a later complete spec", () => {
|
||||
const registry: ComponentRegistry = {
|
||||
Stack: ({ children }) => <>{children}</>,
|
||||
Metric: ({ element }) => <span>{String(element.props.revision)}</span>,
|
||||
};
|
||||
const initial: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: { type: "Stack", props: {}, children: ["late"] },
|
||||
},
|
||||
};
|
||||
const view = render(
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer spec={initial} registry={registry} loading />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
expect(view.container.textContent).toBe("");
|
||||
const complete: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: { type: "Stack", props: {}, children: ["late"] },
|
||||
late: { type: "Metric", props: { revision: 1 } },
|
||||
},
|
||||
};
|
||||
view.rerender(
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer spec={complete} registry={registry} loading={false} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
expect(view.container.textContent).toBe("1");
|
||||
});
|
||||
|
||||
it("updates direct renderer consumers with fresh complete specs", () => {
|
||||
const registry: ComponentRegistry = {
|
||||
Stack: ({ children }) => <>{children}</>,
|
||||
Metric: ({ element }) => <span>{String(element.props.revision)}</span>,
|
||||
};
|
||||
const first = makeSpec();
|
||||
const view = render(
|
||||
<JSONUIProvider registry={registry} initialState={first.state}>
|
||||
<Renderer spec={first} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
const next = structuredClone(first);
|
||||
next.elements["metric-0"]!.props.revision = 9;
|
||||
view.rerender(
|
||||
<JSONUIProvider registry={registry} initialState={next.state}>
|
||||
<Renderer spec={next} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
expect(view.container.textContent?.startsWith("9")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not preserve stale prop keys with undefined values", () => {
|
||||
const registry: ComponentRegistry = {
|
||||
Value: ({ element }) => (
|
||||
<span>{Object.keys(element.props).join(",")}</span>
|
||||
),
|
||||
};
|
||||
const first: Spec = {
|
||||
root: "value",
|
||||
elements: { value: { type: "Value", props: { foo: undefined } } },
|
||||
};
|
||||
const view = render(
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer spec={first} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
expect(view.container.textContent).toBe("foo");
|
||||
const second: Spec = {
|
||||
root: "value",
|
||||
elements: { value: { type: "Value", props: { bar: undefined } } },
|
||||
};
|
||||
view.rerender(
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer spec={second} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
expect(view.container.textContent).toBe("bar");
|
||||
});
|
||||
|
||||
it("recovers when props arrive after the element type", () => {
|
||||
const parts: DataPart[] = [
|
||||
{
|
||||
type: "data-spec",
|
||||
data: {
|
||||
type: "patch",
|
||||
patch: { op: "add", path: "/root", value: "value" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "data-spec",
|
||||
data: {
|
||||
type: "patch",
|
||||
patch: {
|
||||
op: "add",
|
||||
path: "/elements/value",
|
||||
value: { type: "Value" },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
const registry: ComponentRegistry = {
|
||||
Value: ({ element }) => (
|
||||
<span>{String(element.props.label ?? "waiting")}</span>
|
||||
),
|
||||
};
|
||||
const incomplete = buildSpecFromParts(parts);
|
||||
const view = render(
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer spec={incomplete} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
expect(view.container.textContent).toBe("waiting");
|
||||
|
||||
parts.push({
|
||||
type: "data-spec",
|
||||
data: {
|
||||
type: "patch",
|
||||
patch: {
|
||||
op: "add",
|
||||
path: "/elements/value/props",
|
||||
value: { label: "ready" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const complete = buildSpecFromParts(parts);
|
||||
view.rerender(
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer spec={complete} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
expect(view.container.textContent).toBe("ready");
|
||||
});
|
||||
|
||||
it("keeps repeated event callbacks bound to the current item", async () => {
|
||||
let updateItem: (() => void) | undefined;
|
||||
const received: unknown[] = [];
|
||||
function Controls() {
|
||||
const { set } = useStateStore();
|
||||
updateItem = () => set("/items/0", { id: "one", label: "updated" });
|
||||
return null;
|
||||
}
|
||||
const spec: Spec = {
|
||||
root: "list",
|
||||
state: { items: [{ id: "one", label: "initial" }] },
|
||||
elements: {
|
||||
list: {
|
||||
type: "List",
|
||||
props: {},
|
||||
repeat: { statePath: "/items", key: "id" },
|
||||
children: ["button"],
|
||||
},
|
||||
button: {
|
||||
type: "Button",
|
||||
props: {},
|
||||
on: {
|
||||
press: {
|
||||
action: "select",
|
||||
params: {
|
||||
label: {
|
||||
$computed: "itemLabel",
|
||||
args: { label: { $item: "label" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const registry: ComponentRegistry = {
|
||||
List: ({ children }) => <>{children}</>,
|
||||
Button: ({ emit }) => <button onClick={() => emit("press")}>pick</button>,
|
||||
};
|
||||
const view = render(
|
||||
<JSONUIProvider
|
||||
registry={registry}
|
||||
initialState={spec.state}
|
||||
functions={{ itemLabel: ({ label }) => label }}
|
||||
handlers={{ select: ({ label }) => received.push(label) }}
|
||||
>
|
||||
<Controls />
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
await act(async () => updateItem?.());
|
||||
await act(async () => view.getByRole("button").click());
|
||||
expect(received).toEqual(["updated"]);
|
||||
});
|
||||
|
||||
it("refreshes resolved props when functions and directives change", () => {
|
||||
const spec: Spec = {
|
||||
root: "value",
|
||||
elements: {
|
||||
value: {
|
||||
type: "Value",
|
||||
props: {
|
||||
computed: { $computed: "label" },
|
||||
directed: { $prefix: "value" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const registry: ComponentRegistry = {
|
||||
Value: ({ element }) => (
|
||||
<span>{`${element.props.computed}:${element.props.directed}`}</span>
|
||||
),
|
||||
};
|
||||
const firstDirective = defineDirective({
|
||||
name: "$prefix",
|
||||
resolve(value, ctx) {
|
||||
return `first-${resolvePropValue(value.$prefix, ctx)}`;
|
||||
},
|
||||
});
|
||||
const secondDirective = defineDirective({
|
||||
name: "$prefix",
|
||||
resolve(value, ctx) {
|
||||
return `second-${resolvePropValue(value.$prefix, ctx)}`;
|
||||
},
|
||||
});
|
||||
const view = render(
|
||||
<JSONUIProvider
|
||||
registry={registry}
|
||||
functions={{ label: () => "first" }}
|
||||
directives={[firstDirective]}
|
||||
>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
expect(view.container.textContent).toBe("first:first-value");
|
||||
|
||||
view.rerender(
|
||||
<JSONUIProvider
|
||||
registry={registry}
|
||||
functions={{ label: () => "second" }}
|
||||
directives={[secondDirective]}
|
||||
>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
expect(view.container.textContent).toBe("second:second-value");
|
||||
});
|
||||
|
||||
it("keeps event callbacks fresh when functions and directives change", async () => {
|
||||
const received: unknown[] = [];
|
||||
const spec: Spec = {
|
||||
root: "button",
|
||||
elements: {
|
||||
button: {
|
||||
type: "Button",
|
||||
props: {},
|
||||
on: {
|
||||
press: {
|
||||
action: "select",
|
||||
params: {
|
||||
computed: { $computed: "label" },
|
||||
directed: { $prefix: "value" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const registry: ComponentRegistry = {
|
||||
Button: ({ emit }) => <button onClick={() => emit("press")}>pick</button>,
|
||||
};
|
||||
const firstDirective = defineDirective({
|
||||
name: "$prefix",
|
||||
resolve(value) {
|
||||
return `first-${String(value.$prefix)}`;
|
||||
},
|
||||
});
|
||||
const secondDirective = defineDirective({
|
||||
name: "$prefix",
|
||||
resolve(value) {
|
||||
return `second-${String(value.$prefix)}`;
|
||||
},
|
||||
});
|
||||
const handlers = {
|
||||
select: (params: Record<string, unknown>) => received.push(params),
|
||||
};
|
||||
const view = render(
|
||||
<JSONUIProvider
|
||||
registry={registry}
|
||||
functions={{ label: () => "first" }}
|
||||
directives={[firstDirective]}
|
||||
handlers={handlers}
|
||||
>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
view.rerender(
|
||||
<JSONUIProvider
|
||||
registry={registry}
|
||||
functions={{ label: () => "second" }}
|
||||
directives={[secondDirective]}
|
||||
handlers={handlers}
|
||||
>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
await act(async () => view.getByRole("button").click());
|
||||
expect(received).toEqual([
|
||||
{ computed: "second", directed: "second-value" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("supports non-serializable resolved state and repeat items", () => {
|
||||
const cyclic: Record<string, unknown> = { value: 1 };
|
||||
cyclic.self = cyclic;
|
||||
const spec: Spec = {
|
||||
root: "list",
|
||||
state: { cyclic, items: [{ id: 1n }] },
|
||||
elements: {
|
||||
list: {
|
||||
type: "List",
|
||||
props: { value: { $state: "/cyclic" } },
|
||||
repeat: { statePath: "/items" },
|
||||
children: ["item"],
|
||||
},
|
||||
item: { type: "Item", props: { id: { $item: "id" } } },
|
||||
},
|
||||
};
|
||||
const registry: ComponentRegistry = {
|
||||
List: ({ children }) => <>{children}</>,
|
||||
Item: ({ element }) => <span>{String(element.props.id)}</span>,
|
||||
};
|
||||
|
||||
const view = render(
|
||||
<JSONUIProvider registry={registry} initialState={spec.state}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
expect(view.container.textContent).toBe("1");
|
||||
});
|
||||
|
||||
it("propagates shared-child updates through a DAG", () => {
|
||||
const registry: ComponentRegistry = {
|
||||
Stack: ({ children }) => <>{children}</>,
|
||||
Value: ({ element }) => <span>{String(element.props.revision)}</span>,
|
||||
};
|
||||
const first: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: { type: "Stack", props: {}, children: ["left", "right"] },
|
||||
left: { type: "Stack", props: {}, children: ["shared"] },
|
||||
right: { type: "Stack", props: {}, children: ["shared"] },
|
||||
shared: { type: "Value", props: { revision: 1 } },
|
||||
},
|
||||
};
|
||||
const view = render(
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer spec={first} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
expect(view.container.textContent).toBe("11");
|
||||
const second = structuredClone(first);
|
||||
second.elements.shared!.props.revision = 2;
|
||||
view.rerender(
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer spec={second} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
expect(view.container.textContent).toBe("22");
|
||||
});
|
||||
|
||||
it("terminates signature computation for cyclic element graphs", () => {
|
||||
const spec: Spec = {
|
||||
root: "",
|
||||
elements: {
|
||||
first: { type: "Node", props: {}, children: ["second"] },
|
||||
second: { type: "Node", props: {}, children: ["first"] },
|
||||
},
|
||||
};
|
||||
expect(() =>
|
||||
render(<Renderer spec={spec} registry={{ Node: () => null }} />),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("stops a watch action chain when its element unmounts", async () => {
|
||||
let setValue: (() => void) | undefined;
|
||||
let release: (() => void) | undefined;
|
||||
const firstAction = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const secondAction = vi.fn();
|
||||
function Controls() {
|
||||
const { set } = useStateStore();
|
||||
setValue = () => set("/value", "changed");
|
||||
return null;
|
||||
}
|
||||
const registry: ComponentRegistry = {
|
||||
Stack: ({ children }) => <>{children}</>,
|
||||
Watcher: () => null,
|
||||
};
|
||||
const first: Spec = {
|
||||
root: "root",
|
||||
state: { value: "initial" },
|
||||
elements: {
|
||||
root: { type: "Stack", props: {}, children: ["watcher"] },
|
||||
watcher: {
|
||||
type: "Watcher",
|
||||
props: {},
|
||||
watch: {
|
||||
"/value": [{ action: "first" }, { action: "second" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const handlers = { first: firstAction, second: secondAction };
|
||||
const view = render(
|
||||
<JSONUIProvider
|
||||
registry={registry}
|
||||
initialState={first.state}
|
||||
handlers={handlers}
|
||||
>
|
||||
<Controls />
|
||||
<Renderer spec={first} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
await act(async () => setValue?.());
|
||||
expect(firstAction).toHaveBeenCalledTimes(1);
|
||||
const second: Spec = {
|
||||
...first,
|
||||
elements: {
|
||||
...first.elements,
|
||||
root: { type: "Stack", props: {}, children: [] },
|
||||
},
|
||||
};
|
||||
view.rerender(
|
||||
<JSONUIProvider
|
||||
registry={registry}
|
||||
initialState={first.state}
|
||||
handlers={handlers}
|
||||
>
|
||||
<Controls />
|
||||
<Renderer spec={second} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
await act(async () => release?.());
|
||||
expect(secondAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recovers a catalog component after a corrective patch", () => {
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const registry: ComponentRegistry = {
|
||||
Value: ({ element }) => {
|
||||
if (element.props.revision === 0) throw new Error("incomplete");
|
||||
return <span>{String(element.props.revision)}</span>;
|
||||
},
|
||||
};
|
||||
const first: Spec = {
|
||||
root: "value",
|
||||
elements: { value: { type: "Value", props: { revision: 0 } } },
|
||||
};
|
||||
const view = render(
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer spec={first} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
expect(view.container.textContent).toBe("");
|
||||
const second: Spec = {
|
||||
root: "value",
|
||||
elements: { value: { type: "Value", props: { revision: 1 } } },
|
||||
};
|
||||
view.rerender(
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer spec={second} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
expect(view.container.textContent).toBe("1");
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
it("computes signatures for deep element graphs without recursion", () => {
|
||||
const elements: Spec["elements"] = {};
|
||||
for (let index = 0; index < 12_000; index += 1) {
|
||||
elements[`node-${index}`] = {
|
||||
type: "Node",
|
||||
props: {},
|
||||
children: index === 11_999 ? [] : [`node-${index + 1}`],
|
||||
};
|
||||
}
|
||||
const spec: Spec = { root: "", elements };
|
||||
expect(() =>
|
||||
render(<Renderer spec={spec} registry={{ Node: () => null }} />),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/redux",
|
||||
"version": "0.20.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Redux adapter for json-render StateStore",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/remotion",
|
||||
"version": "0.20.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Remotion renderer for @json-render/core. JSON becomes video compositions.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/shadcn-svelte",
|
||||
"version": "0.20.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "shadcn-svelte component library for @json-render/svelte. JSON becomes beautiful Tailwind-styled Svelte components.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/shadcn",
|
||||
"version": "0.20.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "shadcn/ui component library for @json-render/core. JSON becomes beautiful Tailwind-styled React components.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/solid",
|
||||
"version": "0.20.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "SolidJS renderer for @json-render/core. JSON becomes Solid components.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/svelte",
|
||||
"version": "0.20.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Svelte 5 renderer for @json-render/core. JSON becomes Svelte components.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# @json-render/tanstack-start
|
||||
|
||||
## 0.20.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add TanStack Start support for full JSON-defined applications with routes,
|
||||
layouts, head metadata, SSR loaders, prerender paths, and client navigation.
|
||||
@@ -1,240 +0,0 @@
|
||||
# @json-render/tanstack-start
|
||||
|
||||
TanStack Start renderer for [@json-render/core](https://json-render.dev).
|
||||
Define routes, layouts, head metadata, state, and loader-backed pages as JSON,
|
||||
then render them through TanStack Router and Start SSR.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @json-render/core @json-render/react @json-render/tanstack-start
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Define the catalog
|
||||
|
||||
Include the definitions for the built-in `Slot` and `Link` components. Their
|
||||
React implementations are added automatically by `PageRenderer`.
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import {
|
||||
schema,
|
||||
startComponentDefinitions,
|
||||
} from "@json-render/tanstack-start/server";
|
||||
|
||||
export const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
...startComponentDefinitions,
|
||||
Card: cardDefinition,
|
||||
Container: containerDefinition,
|
||||
NavBar: navBarDefinition,
|
||||
Post: postDefinition,
|
||||
},
|
||||
actions: {},
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Define the application
|
||||
|
||||
```typescript
|
||||
import type { StartAppSpec } from "@json-render/tanstack-start";
|
||||
|
||||
export const spec: StartAppSpec = {
|
||||
metadata: {
|
||||
title: { default: "My App", template: "%s | My App" },
|
||||
},
|
||||
layouts: {
|
||||
main: {
|
||||
root: "shell",
|
||||
elements: {
|
||||
shell: {
|
||||
type: "Container",
|
||||
props: {},
|
||||
children: ["nav", "slot"],
|
||||
},
|
||||
nav: { type: "NavBar", props: {}, children: [] },
|
||||
slot: { type: "Slot", props: {}, children: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
routes: {
|
||||
"/": {
|
||||
layout: "main",
|
||||
metadata: { title: "Home" },
|
||||
page: {
|
||||
root: "hero",
|
||||
elements: {
|
||||
hero: {
|
||||
type: "Card",
|
||||
props: { title: "Welcome" },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/blog/$slug": {
|
||||
layout: "main",
|
||||
loader: "post",
|
||||
page: {
|
||||
root: "post",
|
||||
elements: {
|
||||
post: {
|
||||
type: "Post",
|
||||
props: { post: { $state: "/post" } },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Create the route helpers
|
||||
|
||||
```typescript
|
||||
// src/lib/json-app.ts
|
||||
import { createStartApp } from "@json-render/tanstack-start/server";
|
||||
import { spec } from "./spec";
|
||||
|
||||
export const { getPageData, getHead, getStaticPaths } = createStartApp({
|
||||
spec,
|
||||
loaders: {
|
||||
post: async ({ slug }) => ({ post: await getPost(slug as string) }),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Wire a splat route
|
||||
|
||||
```tsx
|
||||
// src/routes/$.tsx
|
||||
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||
import {
|
||||
PageRenderer,
|
||||
StartErrorBoundary,
|
||||
StartLoading,
|
||||
StartNotFound,
|
||||
} from "@json-render/tanstack-start";
|
||||
import { getHead, getPageData } from "@/lib/json-app";
|
||||
|
||||
export const Route = createFileRoute("/$")({
|
||||
loader: async ({ location }) => {
|
||||
const data = await getPageData({ pathname: location.pathname });
|
||||
if (!data) throw notFound();
|
||||
return data;
|
||||
},
|
||||
head: ({ match }) => getHead({ pathname: match.pathname }),
|
||||
component: Page,
|
||||
pendingComponent: StartLoading,
|
||||
errorComponent: StartErrorBoundary,
|
||||
notFoundComponent: StartNotFound,
|
||||
});
|
||||
|
||||
function Page() {
|
||||
return <PageRenderer {...Route.useLoaderData()} />;
|
||||
}
|
||||
```
|
||||
|
||||
TanStack Router loaders are isomorphic. If your spec factory or named loaders
|
||||
contain secrets or server-only imports, call `getPageData` and `getHead` from a
|
||||
TanStack Start `createServerFn` and return the resulting data from the route
|
||||
loader.
|
||||
|
||||
### 5. Provide the registry
|
||||
|
||||
```tsx
|
||||
// src/routes/__root.tsx
|
||||
import {
|
||||
createRootRoute,
|
||||
HeadContent,
|
||||
Outlet,
|
||||
Scripts,
|
||||
} from "@tanstack/react-router";
|
||||
import { StartAppProvider } from "@json-render/tanstack-start";
|
||||
import { registry, handlers } from "@/lib/registry";
|
||||
import { spec } from "@/lib/spec";
|
||||
|
||||
export const Route = createRootRoute({ component: Root });
|
||||
|
||||
function Root() {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
<StartAppProvider
|
||||
registry={registry}
|
||||
handlers={handlers}
|
||||
spec={spec}
|
||||
>
|
||||
<Outlet />
|
||||
</StartAppProvider>
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Passing `spec` lets the Router boundary components automatically render the
|
||||
matched route's `loading`, `error`, and `notFound` specs. An explicit
|
||||
`loadingSpec`, `errorSpec`, or `notFoundSpec` prop overrides this lookup. If the
|
||||
application spec is server-only, omit `spec` from the provider and supply those
|
||||
explicit props from client-safe fallback specs.
|
||||
|
||||
The default `StartErrorBoundary` fallback invalidates the router and reruns the
|
||||
failed loader when the user selects **Try again**.
|
||||
|
||||
Pass named functions through the provider when generated props use
|
||||
`$computed`:
|
||||
|
||||
```tsx
|
||||
<StartAppProvider
|
||||
registry={registry}
|
||||
spec={spec}
|
||||
functions={{ uppercase: ({ value }) => String(value).toUpperCase() }}
|
||||
>
|
||||
<Outlet />
|
||||
</StartAppProvider>
|
||||
```
|
||||
|
||||
## Route Patterns
|
||||
|
||||
| Pattern | Matches | Loader params |
|
||||
| ------------- | ------------- | ------------------------ |
|
||||
| `/` | `/` | `{}` |
|
||||
| `/about` | `/about` | `{}` |
|
||||
| `/blog/$slug` | `/blog/hello` | `{ slug: "hello" }` |
|
||||
| `/docs/$` | `/docs/a/b` | `{ _splat: "a/b" }` |
|
||||
|
||||
Static routes are included in `getStaticPaths()`. Dynamic routes are included
|
||||
when their route spec supplies `staticParams`. Loader parameters are URL-decoded,
|
||||
and splat content is a slash-delimited string under `_splat`. Parameter values
|
||||
emitted by `getStaticPaths()` are URL-encoded.
|
||||
|
||||
Route matching treats trailing slashes as optional and accepts both encoded and
|
||||
decoded pathname representations. This keeps loader data and route metadata in
|
||||
sync for static paths containing spaces or non-ASCII characters.
|
||||
|
||||
Initial state is merged in this order: application state, layout state, page
|
||||
state, then loader data. Later sources override earlier values.
|
||||
|
||||
Map the paths to TanStack Start's top-level `pages` option when prerendering:
|
||||
|
||||
```typescript
|
||||
const pages = (await getStaticPaths()).map((path) => ({ path }));
|
||||
```
|
||||
|
||||
## Entry Points
|
||||
|
||||
| Import | Description |
|
||||
| ------------------------------------- | ------------------------------------------------------------- |
|
||||
| `@json-render/tanstack-start` | Provider, page renderer, Link, and route fallback components |
|
||||
| `@json-render/tanstack-start/server` | App factory, schema, matcher, metadata, and prerender helpers |
|
||||
| `@json-render/tanstack-start/catalog` | Server-safe definitions for built-in Slot and Link components |
|
||||
|
||||
See the [full API reference](https://json-render.dev/docs/api/tanstack-start).
|
||||
@@ -1,79 +0,0 @@
|
||||
{
|
||||
"name": "@json-render/tanstack-start",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "TanStack Start renderer for @json-render/core. JSON becomes full TanStack Start applications with routes, layouts, head metadata, and SSR.",
|
||||
"keywords": [
|
||||
"json",
|
||||
"ui",
|
||||
"tanstack",
|
||||
"tanstack-start",
|
||||
"tanstack-router",
|
||||
"ai",
|
||||
"generative-ui",
|
||||
"llm",
|
||||
"renderer",
|
||||
"streaming",
|
||||
"ssr",
|
||||
"pages",
|
||||
"routing"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vercel-labs/json-render.git",
|
||||
"directory": "packages/tanstack-start"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server.d.ts",
|
||||
"import": "./dist/server.mjs",
|
||||
"require": "./dist/server.js"
|
||||
},
|
||||
"./catalog": {
|
||||
"types": "./dist/catalog.d.ts",
|
||||
"import": "./dist/catalog.mjs",
|
||||
"require": "./dist/catalog.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rm -rf dist && tsup",
|
||||
"dev": "tsup --watch",
|
||||
"check-types": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/react": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"@tanstack/react-router": "1.170.32",
|
||||
"@types/react": "19.2.14",
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^5.4.5",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/react-router": "^1.170.32",
|
||||
"react": "^19.2.3",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/** Catalog-aware types shared with the React renderer. */
|
||||
export type {
|
||||
EventHandle,
|
||||
BaseComponentProps,
|
||||
SetState,
|
||||
StateModel,
|
||||
ComponentContext,
|
||||
ComponentFn,
|
||||
Components,
|
||||
ActionFn,
|
||||
Actions,
|
||||
} from "@json-render/react";
|
||||
@@ -1,29 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Server-safe catalog definitions for components built into PageRenderer.
|
||||
* Include these in every TanStack Start catalog that generates layouts or links.
|
||||
*/
|
||||
export const startComponentDefinitions = {
|
||||
Slot: {
|
||||
props: z.object({}),
|
||||
slots: ["default"],
|
||||
description:
|
||||
"Layout placeholder where the matched route's page content is rendered.",
|
||||
example: {},
|
||||
},
|
||||
Link: {
|
||||
props: z.object({
|
||||
href: z.string(),
|
||||
replace: z.boolean().optional(),
|
||||
prefetch: z.boolean().optional(),
|
||||
className: z.string().optional(),
|
||||
style: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Client-side link to another route in the application.",
|
||||
example: { href: "/about" },
|
||||
},
|
||||
};
|
||||
|
||||
export type StartComponentDefinitions = typeof startComponentDefinitions;
|
||||
@@ -1,65 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Outlet,
|
||||
RouterProvider,
|
||||
createMemoryHistory,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
} from "@tanstack/react-router";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { StartErrorBoundary } from "./error-boundary";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
window.scrollTo = () => {};
|
||||
});
|
||||
|
||||
describe("StartErrorBoundary", () => {
|
||||
it("reruns a failed loader when the user tries again", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
let attempts = 0;
|
||||
const rootRoute = createRootRoute({ component: Outlet });
|
||||
const route = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "$",
|
||||
loader: () => {
|
||||
attempts++;
|
||||
if (attempts === 1) throw new Error("Temporary failure");
|
||||
return { message: "Loaded" };
|
||||
},
|
||||
component: Page,
|
||||
errorComponent: StartErrorBoundary,
|
||||
});
|
||||
|
||||
function Page() {
|
||||
return <div>{route.useLoaderData().message}</div>;
|
||||
}
|
||||
|
||||
const router = createRouter({
|
||||
routeTree: rootRoute.addChildren([route]),
|
||||
history: createMemoryHistory({ initialEntries: ["/retry"] }),
|
||||
});
|
||||
await router.load();
|
||||
render(<RouterProvider router={router} />);
|
||||
|
||||
expect(attempts).toBe(1);
|
||||
expect(screen.getByText("Temporary failure")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Loaded")).toBeTruthy());
|
||||
expect(attempts).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
import React from "react";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { PageRenderer } from "./page-renderer";
|
||||
import { resolveRouteFallback } from "./route-fallback";
|
||||
import { useOptionalStartApp } from "./provider";
|
||||
|
||||
/** Props accepted by TanStack Router's `errorComponent`. */
|
||||
export interface StartErrorBoundaryProps {
|
||||
error: Error;
|
||||
reset: () => void;
|
||||
/** Explicit fallback override; otherwise the matched route's spec is used. */
|
||||
errorSpec?: Spec | null;
|
||||
}
|
||||
|
||||
/** Render a route-specific error spec or a small default error view. */
|
||||
export function StartErrorBoundary({
|
||||
error,
|
||||
reset,
|
||||
errorSpec,
|
||||
}: StartErrorBoundaryProps) {
|
||||
const router = useRouter();
|
||||
const context = useOptionalStartApp();
|
||||
const retry = React.useCallback(() => {
|
||||
void router.invalidate().then(reset, reset);
|
||||
}, [reset, router]);
|
||||
const resolvedSpec = resolveRouteFallback(
|
||||
context?.spec,
|
||||
context?.pathname,
|
||||
"error",
|
||||
errorSpec,
|
||||
);
|
||||
if (resolvedSpec && context) {
|
||||
return <PageRenderer spec={resolvedSpec} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: "2rem", textAlign: "center" }}>
|
||||
<h2 style={{ marginBottom: "1rem" }}>Something went wrong</h2>
|
||||
<p style={{ color: "#666", marginBottom: "1.5rem" }}>
|
||||
{error.message || "An unexpected error occurred."}
|
||||
</p>
|
||||
<button
|
||||
onClick={retry}
|
||||
style={{
|
||||
padding: "0.5rem 1rem",
|
||||
borderRadius: "0.375rem",
|
||||
border: "1px solid #ccc",
|
||||
background: "#fff",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import React from "react";
|
||||
import { Link as RouterLink } from "@tanstack/react-router";
|
||||
import type { ComponentRenderProps } from "@json-render/react";
|
||||
|
||||
export interface LinkProps {
|
||||
href: string;
|
||||
replace?: boolean;
|
||||
/** Preload the target route on intent. */
|
||||
prefetch?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/** Built-in client navigation component for generated specs. */
|
||||
export function Link({ element, children }: ComponentRenderProps<LinkProps>) {
|
||||
const { href, replace, prefetch, className, style } =
|
||||
element.props as LinkProps;
|
||||
|
||||
return (
|
||||
<RouterLink
|
||||
to={href}
|
||||
replace={replace}
|
||||
preload={prefetch === undefined ? undefined : prefetch ? "intent" : false}
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</RouterLink>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import React from "react";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { PageRenderer } from "./page-renderer";
|
||||
import { resolveRouteFallback } from "./route-fallback";
|
||||
import { useOptionalStartApp } from "./provider";
|
||||
|
||||
export interface StartLoadingProps {
|
||||
/** Explicit fallback override; otherwise the matched route's spec is used. */
|
||||
loadingSpec?: Spec | null;
|
||||
}
|
||||
|
||||
/** Render a route-specific pending spec or a small default spinner. */
|
||||
export function StartLoading({ loadingSpec }: StartLoadingProps = {}) {
|
||||
const context = useOptionalStartApp();
|
||||
const resolvedSpec = resolveRouteFallback(
|
||||
context?.spec,
|
||||
context?.pathname,
|
||||
"loading",
|
||||
loadingSpec,
|
||||
);
|
||||
if (resolvedSpec && context) {
|
||||
return <PageRenderer spec={resolvedSpec} loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "200px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "2rem",
|
||||
height: "2rem",
|
||||
border: "2px solid #e5e7eb",
|
||||
borderTopColor: "#3b82f6",
|
||||
borderRadius: "50%",
|
||||
animation: "jr-spin 0.6s linear infinite",
|
||||
}}
|
||||
/>
|
||||
<style>{`@keyframes jr-spin { to { transform: rotate(360deg) } }`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import React from "react";
|
||||
import type { NotFoundRouteProps } from "@tanstack/react-router";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { PageRenderer } from "./page-renderer";
|
||||
import { resolveRouteFallback } from "./route-fallback";
|
||||
import { useOptionalStartApp } from "./provider";
|
||||
|
||||
export interface StartNotFoundProps extends Partial<NotFoundRouteProps> {
|
||||
/** Explicit fallback override; otherwise the matched route's spec is used. */
|
||||
notFoundSpec?: Spec | null;
|
||||
}
|
||||
|
||||
/** Render a route-specific not-found spec or a small default 404 view. */
|
||||
export function StartNotFound({ notFoundSpec }: StartNotFoundProps = {}) {
|
||||
const context = useOptionalStartApp();
|
||||
const resolvedSpec = resolveRouteFallback(
|
||||
context?.spec,
|
||||
context?.pathname,
|
||||
"notFound",
|
||||
notFoundSpec,
|
||||
);
|
||||
if (resolvedSpec && context) {
|
||||
return <PageRenderer spec={resolvedSpec} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "400px",
|
||||
padding: "2rem",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: "4rem", fontWeight: 700, margin: 0 }}>404</h1>
|
||||
<p style={{ color: "#666", marginTop: "0.5rem", fontSize: "1.125rem" }}>
|
||||
This page could not be found.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import React, { useMemo, type ReactNode } from "react";
|
||||
import { useMatch } from "@tanstack/react-router";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import {
|
||||
JSONUIProvider,
|
||||
Renderer,
|
||||
type ComponentRegistry,
|
||||
type ComponentRenderProps,
|
||||
} from "@json-render/react";
|
||||
import { Link } from "./link";
|
||||
import { useStartApp } from "./provider";
|
||||
|
||||
export interface PageRendererProps {
|
||||
spec: Spec;
|
||||
initialState?: Record<string, unknown>;
|
||||
layoutSpec?: Spec | null;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function Slot({ children }: ComponentRenderProps) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/** Render page data returned by `createStartApp` inside an optional layout. */
|
||||
export function PageRenderer({
|
||||
spec,
|
||||
initialState,
|
||||
layoutSpec,
|
||||
loading,
|
||||
}: PageRendererProps) {
|
||||
const {
|
||||
registry,
|
||||
handlers,
|
||||
spec: appSpec,
|
||||
functions,
|
||||
navigate,
|
||||
} = useStartApp();
|
||||
const renderedPathname = useMatch({
|
||||
strict: false,
|
||||
select: (match) => match.pathname,
|
||||
});
|
||||
const augmentedRegistry: ComponentRegistry = useMemo(
|
||||
() => ({ ...registry, Link, Slot }),
|
||||
[registry],
|
||||
);
|
||||
const actionHandlers = useMemo(
|
||||
() => ({
|
||||
...handlers,
|
||||
navigate: (params: Record<string, unknown>) => {
|
||||
const href = params.href;
|
||||
if (typeof href === "string") navigate(href);
|
||||
},
|
||||
}),
|
||||
[handlers, navigate],
|
||||
);
|
||||
const resolvedInitialState = useMemo(() => {
|
||||
if (initialState !== undefined) return initialState;
|
||||
if (!appSpec?.state && !layoutSpec?.state && !spec.state) return undefined;
|
||||
return { ...appSpec?.state, ...layoutSpec?.state, ...spec.state };
|
||||
}, [appSpec?.state, initialState, layoutSpec?.state, spec.state]);
|
||||
|
||||
const page = (
|
||||
<Renderer spec={spec} registry={augmentedRegistry} loading={loading} />
|
||||
);
|
||||
|
||||
// Key from the rendered match rather than the global location. During a
|
||||
// pending navigation, TanStack advances the location while keeping the
|
||||
// previous match mounted until the next page is ready.
|
||||
return (
|
||||
<JSONUIProvider
|
||||
key={renderedPathname}
|
||||
registry={augmentedRegistry}
|
||||
initialState={resolvedInitialState}
|
||||
handlers={actionHandlers}
|
||||
navigate={navigate}
|
||||
functions={functions}
|
||||
>
|
||||
{layoutSpec ? (
|
||||
<LayoutWithSlot
|
||||
layoutSpec={layoutSpec}
|
||||
registry={augmentedRegistry}
|
||||
loading={loading}
|
||||
>
|
||||
{page}
|
||||
</LayoutWithSlot>
|
||||
) : (
|
||||
page
|
||||
)}
|
||||
</JSONUIProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function LayoutWithSlot({
|
||||
layoutSpec,
|
||||
registry,
|
||||
loading,
|
||||
children,
|
||||
}: {
|
||||
layoutSpec: Spec;
|
||||
registry: ComponentRegistry;
|
||||
loading?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const layoutRegistry: ComponentRegistry = useMemo(
|
||||
() => ({
|
||||
...registry,
|
||||
Slot: function LayoutSlot() {
|
||||
return <>{children}</>;
|
||||
},
|
||||
}),
|
||||
[children, registry],
|
||||
);
|
||||
|
||||
return (
|
||||
<Renderer spec={layoutSpec} registry={layoutRegistry} loading={loading} />
|
||||
);
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Outlet,
|
||||
createMemoryHistory,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
RouterProvider,
|
||||
} from "@tanstack/react-router";
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
} from "@testing-library/react";
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import type { ComponentRenderProps } from "@json-render/react";
|
||||
import { createStartApp } from "../create-app";
|
||||
import type { StartAppSpec } from "../types";
|
||||
import { StartLoading } from "./loading-renderer";
|
||||
import { PageRenderer } from "./page-renderer";
|
||||
import { StartAppProvider } from "./provider";
|
||||
|
||||
afterEach(cleanup);
|
||||
beforeAll(() => {
|
||||
window.scrollTo = () => {};
|
||||
});
|
||||
|
||||
function Text({ element }: ComponentRenderProps<{ value: unknown }>) {
|
||||
return <span>{String(element.props.value)}</span>;
|
||||
}
|
||||
|
||||
function Container({ children }: ComponentRenderProps) {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
|
||||
function Button({ emit }: ComponentRenderProps) {
|
||||
return <button onClick={() => emit("press")}>Change</button>;
|
||||
}
|
||||
|
||||
function LabeledText({
|
||||
element,
|
||||
}: ComponentRenderProps<{ label: string; value: unknown }>) {
|
||||
return <span>{`${element.props.label}:${String(element.props.value)}`}</span>;
|
||||
}
|
||||
|
||||
async function renderInRouter(component: React.ReactNode) {
|
||||
const rootRoute = createRootRoute({ component: () => component });
|
||||
const router = createRouter({
|
||||
routeTree: rootRoute,
|
||||
history: createMemoryHistory({ initialEntries: ["/"] }),
|
||||
});
|
||||
await router.load();
|
||||
return render(<RouterProvider router={router} />);
|
||||
}
|
||||
|
||||
describe("StartAppProvider", () => {
|
||||
it("forwards named functions to $computed expressions", async () => {
|
||||
const page: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Text",
|
||||
props: {
|
||||
value: { $computed: "uppercase", args: { value: "hello" } },
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await renderInRouter(
|
||||
<StartAppProvider
|
||||
registry={{ Text }}
|
||||
functions={{ uppercase: ({ value }) => String(value).toUpperCase() }}
|
||||
>
|
||||
<PageRenderer spec={page} />
|
||||
</StartAppProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("HELLO")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the matched route's loading spec", async () => {
|
||||
const loading: Spec = {
|
||||
root: "root",
|
||||
state: { message: "Loading route" },
|
||||
elements: {
|
||||
root: {
|
||||
type: "Text",
|
||||
props: { value: { $state: "/message" } },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const spec: StartAppSpec = {
|
||||
state: { message: "Application" },
|
||||
routes: {
|
||||
"/": {
|
||||
page: loading,
|
||||
loading,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await renderInRouter(
|
||||
<StartAppProvider registry={{ Text }} spec={spec}>
|
||||
<StartLoading />
|
||||
</StartAppProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Loading route")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses layout state when page data is rendered directly", async () => {
|
||||
const page: Spec = {
|
||||
root: "page",
|
||||
elements: {
|
||||
page: { type: "Text", props: { value: "Page" }, children: [] },
|
||||
},
|
||||
};
|
||||
const layout: Spec = {
|
||||
root: "layout",
|
||||
state: { message: "Layout state" },
|
||||
elements: {
|
||||
layout: {
|
||||
type: "Container",
|
||||
props: {},
|
||||
children: ["message", "slot"],
|
||||
},
|
||||
message: {
|
||||
type: "Text",
|
||||
props: { value: { $state: "/message" } },
|
||||
children: [],
|
||||
},
|
||||
slot: { type: "Slot", props: {}, children: [] },
|
||||
},
|
||||
};
|
||||
|
||||
await renderInRouter(
|
||||
<StartAppProvider registry={{ Container, Text }}>
|
||||
<PageRenderer spec={page} layoutSpec={layout} />
|
||||
</StartAppProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Layout state")).toBeTruthy();
|
||||
expect(screen.getByText("Page")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("resets page state when the catch-all route changes", async () => {
|
||||
let resolveNextPage!: () => void;
|
||||
const nextPage = new Promise<void>((resolve) => {
|
||||
resolveNextPage = resolve;
|
||||
});
|
||||
let markNextPageStarted!: () => void;
|
||||
const nextPageStarted = new Promise<void>((resolve) => {
|
||||
markNextPageStarted = resolve;
|
||||
});
|
||||
const spec: StartAppSpec = {
|
||||
routes: {
|
||||
"/a": {
|
||||
page: {
|
||||
root: "container",
|
||||
state: { count: 0 },
|
||||
elements: {
|
||||
container: {
|
||||
type: "Container",
|
||||
props: {},
|
||||
children: ["text", "button"],
|
||||
},
|
||||
text: {
|
||||
type: "LabeledText",
|
||||
props: { label: "A", value: { $state: "/count" } },
|
||||
children: [],
|
||||
},
|
||||
button: {
|
||||
type: "Button",
|
||||
props: {},
|
||||
on: {
|
||||
press: {
|
||||
action: "setState",
|
||||
params: { statePath: "/count", value: 5 },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/b": {
|
||||
page: {
|
||||
root: "text",
|
||||
state: { count: 0 },
|
||||
elements: {
|
||||
text: {
|
||||
type: "LabeledText",
|
||||
props: { label: "B", value: { $state: "/count" } },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const app = createStartApp({ spec });
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<StartAppProvider registry={{ Button, Container, LabeledText }}>
|
||||
<Outlet />
|
||||
</StartAppProvider>
|
||||
),
|
||||
});
|
||||
const route = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "$",
|
||||
loader: async ({ location }) => {
|
||||
if (location.pathname === "/b") {
|
||||
markNextPageStarted();
|
||||
await nextPage;
|
||||
}
|
||||
return app.getPageData({ pathname: location.pathname });
|
||||
},
|
||||
component: Page,
|
||||
});
|
||||
|
||||
function Page() {
|
||||
const data = route.useLoaderData();
|
||||
return data ? <PageRenderer {...data} /> : null;
|
||||
}
|
||||
|
||||
const router = createRouter({
|
||||
routeTree: rootRoute.addChildren([route]),
|
||||
history: createMemoryHistory({ initialEntries: ["/a"] }),
|
||||
});
|
||||
await router.load();
|
||||
render(<RouterProvider router={router} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Change" }));
|
||||
expect(screen.getByText("A:5")).toBeTruthy();
|
||||
|
||||
let navigation!: Promise<void>;
|
||||
await act(async () => {
|
||||
navigation = router.navigate({ to: "/b" });
|
||||
await nextPageStarted;
|
||||
});
|
||||
|
||||
const pendingPageValue = screen.getByText(/^A:/).textContent;
|
||||
|
||||
await act(async () => {
|
||||
resolveNextPage();
|
||||
await navigation;
|
||||
});
|
||||
expect(pendingPageValue).toBe("A:5");
|
||||
expect(screen.getByText("B:0")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import React, { createContext, useContext, type ReactNode } from "react";
|
||||
import { useLocation, useRouter } from "@tanstack/react-router";
|
||||
import type { ComputedFunction } from "@json-render/core";
|
||||
import type { ComponentRegistry } from "@json-render/react";
|
||||
import type { StartAppSpec } from "../types";
|
||||
|
||||
export interface StartAppContextValue {
|
||||
registry: ComponentRegistry;
|
||||
handlers?: Record<
|
||||
string,
|
||||
(params: Record<string, unknown>) => Promise<unknown> | unknown
|
||||
>;
|
||||
spec?: StartAppSpec;
|
||||
functions?: Record<string, ComputedFunction>;
|
||||
pathname: string;
|
||||
navigate: (href: string) => void;
|
||||
}
|
||||
|
||||
const StartAppContext = createContext<StartAppContextValue | null>(null);
|
||||
|
||||
export interface StartAppProviderProps {
|
||||
registry: ComponentRegistry;
|
||||
handlers?: Record<
|
||||
string,
|
||||
(params: Record<string, unknown>) => Promise<unknown> | unknown
|
||||
>;
|
||||
/** Application spec used to resolve route-specific fallback components. */
|
||||
spec?: StartAppSpec;
|
||||
/** Named functions available to `$computed` prop expressions. */
|
||||
functions?: Record<string, ComputedFunction>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** Provide rendering dependencies, route fallbacks, and TanStack navigation. */
|
||||
export function StartAppProvider({
|
||||
registry,
|
||||
handlers,
|
||||
spec,
|
||||
functions,
|
||||
children,
|
||||
}: StartAppProviderProps) {
|
||||
const router = useRouter();
|
||||
const pathname = useLocation({ select: (location) => location.pathname });
|
||||
const navigate = React.useCallback(
|
||||
(href: string) => {
|
||||
void router.navigate({ to: href });
|
||||
},
|
||||
[router],
|
||||
);
|
||||
const value = React.useMemo(
|
||||
() => ({ registry, handlers, spec, functions, pathname, navigate }),
|
||||
[registry, handlers, spec, functions, pathname, navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
<StartAppContext.Provider value={value}>
|
||||
{children}
|
||||
</StartAppContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Access the current TanStack Start json-render application context. */
|
||||
export function useStartApp(): StartAppContextValue {
|
||||
const context = useContext(StartAppContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"[json-render/tanstack-start] useStartApp must be used within a " +
|
||||
"<StartAppProvider>.",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useOptionalStartApp(): StartAppContextValue | null {
|
||||
return useContext(StartAppContext);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import type { StartAppSpec } from "../types";
|
||||
import { resolveRouteFallback } from "./route-fallback";
|
||||
|
||||
function page(type: string): Spec {
|
||||
return {
|
||||
root: "root",
|
||||
elements: { root: { type, props: {}, children: [] } },
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveRouteFallback", () => {
|
||||
const loading = page("Loading");
|
||||
const error = page("Error");
|
||||
const notFound = page("NotFound");
|
||||
const spec: StartAppSpec = {
|
||||
routes: {
|
||||
"/blog/$slug": {
|
||||
page: page("Page"),
|
||||
loading,
|
||||
error,
|
||||
notFound,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("resolves each fallback from the matched route", () => {
|
||||
expect(resolveRouteFallback(spec, "/blog/post", "loading", undefined)).toBe(
|
||||
loading,
|
||||
);
|
||||
expect(resolveRouteFallback(spec, "/blog/post", "error", undefined)).toBe(
|
||||
error,
|
||||
);
|
||||
expect(
|
||||
resolveRouteFallback(spec, "/blog/post", "notFound", undefined),
|
||||
).toBe(notFound);
|
||||
});
|
||||
|
||||
it("prefers an explicit fallback and allows null to disable one", () => {
|
||||
const explicit = page("Explicit");
|
||||
expect(resolveRouteFallback(spec, "/blog/post", "loading", explicit)).toBe(
|
||||
explicit,
|
||||
);
|
||||
expect(
|
||||
resolveRouteFallback(spec, "/blog/post", "loading", null),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { matchRoute } from "../router";
|
||||
import type { StartAppSpec } from "../types";
|
||||
|
||||
export type RouteFallbackKind = "loading" | "error" | "notFound";
|
||||
|
||||
/** Resolve an explicit fallback or the fallback on the currently matched route. */
|
||||
export function resolveRouteFallback(
|
||||
spec: StartAppSpec | undefined,
|
||||
pathname: string | undefined,
|
||||
kind: RouteFallbackKind,
|
||||
explicitSpec: Spec | null | undefined,
|
||||
): Spec | null | undefined {
|
||||
if (explicitSpec !== undefined) return explicitSpec;
|
||||
if (!spec || pathname === undefined) return undefined;
|
||||
return matchRoute(spec, pathname)?.route[kind];
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { createStartApp } from "./create-app";
|
||||
import type { StartAppSpec } from "./types";
|
||||
|
||||
function page(state?: Record<string, unknown>): Spec {
|
||||
return {
|
||||
root: "root",
|
||||
elements: { root: { type: "Card", props: {}, children: [] } },
|
||||
...(state ? { state } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("createStartApp", () => {
|
||||
it("resolves page and layout data", async () => {
|
||||
const layout = page();
|
||||
const spec: StartAppSpec = {
|
||||
layouts: { main: layout },
|
||||
routes: { "/": { page: page(), layout: "main" } },
|
||||
};
|
||||
const data = await createStartApp({ spec }).getPageData({ pathname: "/" });
|
||||
expect(data?.spec).toEqual(page());
|
||||
expect(data?.layoutSpec).toEqual(layout);
|
||||
});
|
||||
|
||||
it("returns null for an unmatched pathname", async () => {
|
||||
const spec: StartAppSpec = { routes: { "/": { page: page() } } };
|
||||
const data = await createStartApp({ spec }).getPageData({
|
||||
pathname: "/missing",
|
||||
});
|
||||
expect(data).toBeNull();
|
||||
});
|
||||
|
||||
it("merges global, layout, page, and loader state in order", async () => {
|
||||
const spec: StartAppSpec = {
|
||||
state: { a: 1, b: 1, c: 1 },
|
||||
layouts: { main: page({ b: 2, c: 2, layout: true }) },
|
||||
routes: {
|
||||
"/blog/$slug": {
|
||||
page: page({ c: 3, page: true }),
|
||||
layout: "main",
|
||||
loader: "post",
|
||||
},
|
||||
},
|
||||
};
|
||||
const app = createStartApp({
|
||||
spec,
|
||||
loaders: {
|
||||
post: (params) => ({ c: 4, slug: params.slug }),
|
||||
},
|
||||
});
|
||||
expect(
|
||||
(await app.getPageData({ pathname: "/blog/hello" }))?.initialState,
|
||||
).toEqual({
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: 4,
|
||||
layout: true,
|
||||
page: true,
|
||||
slug: "hello",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports async spec factories", async () => {
|
||||
const spec: StartAppSpec = { routes: { "/": { page: page() } } };
|
||||
const data = await createStartApp({ spec: async () => spec }).getPageData({
|
||||
pathname: "/",
|
||||
});
|
||||
expect(data).not.toBeNull();
|
||||
});
|
||||
|
||||
it("resolves head metadata and static paths", async () => {
|
||||
const spec: StartAppSpec = {
|
||||
metadata: { title: { default: "Site", template: "%s | Site" } },
|
||||
routes: {
|
||||
"/about": {
|
||||
page: page(),
|
||||
metadata: { title: "About", description: "About us" },
|
||||
},
|
||||
"/blog/$slug": {
|
||||
page: page(),
|
||||
staticParams: [{ slug: "hello" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const app = createStartApp({ spec });
|
||||
const head = await app.getHead({ pathname: "/about" });
|
||||
expect(head.meta).toContainEqual({ title: "About | Site" });
|
||||
expect(head.meta).toContainEqual({
|
||||
name: "description",
|
||||
content: "About us",
|
||||
});
|
||||
expect(await app.getStaticPaths()).toEqual(["/about", "/blog/hello"]);
|
||||
});
|
||||
|
||||
it("resolves route metadata from an encoded static pathname", async () => {
|
||||
const spec: StartAppSpec = {
|
||||
metadata: { title: "Global" },
|
||||
routes: {
|
||||
"/café": {
|
||||
page: page(),
|
||||
metadata: { title: "Café" },
|
||||
},
|
||||
},
|
||||
};
|
||||
const app = createStartApp({ spec });
|
||||
|
||||
expect(await app.getPageData({ pathname: "/café" })).not.toBeNull();
|
||||
expect((await app.getHead({ pathname: "/caf%C3%A9" })).meta).toContainEqual(
|
||||
{
|
||||
title: "Café",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import { metadataToHead, resolveMetadata } from "./metadata";
|
||||
import { collectStaticPaths, matchRoute } from "./router";
|
||||
import type {
|
||||
CreateStartAppOptions,
|
||||
HeadDescriptors,
|
||||
PageData,
|
||||
StartAppExports,
|
||||
StartAppSpec,
|
||||
} from "./types";
|
||||
|
||||
async function resolveSpec(
|
||||
specOrFactory: StartAppSpec | (() => StartAppSpec | Promise<StartAppSpec>),
|
||||
): Promise<StartAppSpec> {
|
||||
return typeof specOrFactory === "function"
|
||||
? await specOrFactory()
|
||||
: specOrFactory;
|
||||
}
|
||||
|
||||
function mergeState(
|
||||
...sources: (Record<string, unknown> | null | undefined)[]
|
||||
): Record<string, unknown> {
|
||||
return Object.assign({}, ...sources.filter((source) => source != null));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the route-loader, head, and prerender helpers for a TanStack Start
|
||||
* catch-all route.
|
||||
*/
|
||||
export function createStartApp(
|
||||
options: CreateStartAppOptions,
|
||||
): StartAppExports {
|
||||
const { spec: specOrFactory, loaders } = options;
|
||||
|
||||
async function getPageData({
|
||||
pathname,
|
||||
}: {
|
||||
pathname: string;
|
||||
}): Promise<PageData | null> {
|
||||
const spec = await resolveSpec(specOrFactory);
|
||||
const matched = matchRoute(spec, pathname);
|
||||
if (!matched) return null;
|
||||
|
||||
const { route } = matched;
|
||||
const loader = route.loader ? loaders?.[route.loader] : undefined;
|
||||
const loaderData = loader ? await loader(matched.params) : undefined;
|
||||
const layoutSpec =
|
||||
route.layout && spec.layouts
|
||||
? (spec.layouts[route.layout] ?? null)
|
||||
: null;
|
||||
const initialState = mergeState(
|
||||
spec.state,
|
||||
layoutSpec?.state,
|
||||
route.page.state,
|
||||
loaderData,
|
||||
);
|
||||
|
||||
return {
|
||||
spec: route.page,
|
||||
initialState:
|
||||
Object.keys(initialState).length > 0 ? initialState : undefined,
|
||||
layoutSpec,
|
||||
};
|
||||
}
|
||||
|
||||
async function getHead({
|
||||
pathname,
|
||||
}: {
|
||||
pathname: string;
|
||||
}): Promise<HeadDescriptors> {
|
||||
const spec = await resolveSpec(specOrFactory);
|
||||
const matched = matchRoute(spec, pathname);
|
||||
return metadataToHead(resolveMetadata(spec, matched?.route));
|
||||
}
|
||||
|
||||
async function getStaticPaths(): Promise<string[]> {
|
||||
return collectStaticPaths(await resolveSpec(specOrFactory));
|
||||
}
|
||||
|
||||
return { getPageData, getHead, getStaticPaths };
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// React components for TanStack Start applications.
|
||||
|
||||
export {
|
||||
StartAppProvider,
|
||||
useStartApp,
|
||||
type StartAppContextValue,
|
||||
type StartAppProviderProps,
|
||||
} from "./components/provider";
|
||||
export {
|
||||
PageRenderer,
|
||||
type PageRendererProps,
|
||||
} from "./components/page-renderer";
|
||||
export {
|
||||
StartErrorBoundary,
|
||||
type StartErrorBoundaryProps,
|
||||
} from "./components/error-boundary";
|
||||
export {
|
||||
StartLoading,
|
||||
type StartLoadingProps,
|
||||
} from "./components/loading-renderer";
|
||||
export {
|
||||
StartNotFound,
|
||||
type StartNotFoundProps,
|
||||
} from "./components/not-found-renderer";
|
||||
export { Link, type LinkProps } from "./components/link";
|
||||
|
||||
export type {
|
||||
CreateStartAppOptions,
|
||||
HeadDescriptors,
|
||||
LoaderFn,
|
||||
MatchedRoute,
|
||||
PageData,
|
||||
StartAppExports,
|
||||
StartAppSpec,
|
||||
StartMetadata,
|
||||
StartRouteSpec,
|
||||
} from "./types";
|
||||
export type {
|
||||
ActionFn,
|
||||
Actions,
|
||||
BaseComponentProps,
|
||||
ComponentContext,
|
||||
ComponentFn,
|
||||
Components,
|
||||
EventHandle,
|
||||
SetState,
|
||||
StateModel,
|
||||
} from "./catalog-types";
|
||||
export type { ComputedFunction, Spec, StateStore } from "@json-render/core";
|
||||
export { createStateStore } from "@json-render/core";
|
||||
export type {
|
||||
ComponentRegistry,
|
||||
ComponentRenderProps,
|
||||
} from "@json-render/react";
|
||||
@@ -1,73 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { metadataToHead, resolveMetadata } from "./metadata";
|
||||
import type { StartAppSpec, StartMetadata } from "./types";
|
||||
|
||||
const page: Spec = {
|
||||
root: "root",
|
||||
elements: { root: { type: "Card", props: {}, children: [] } },
|
||||
};
|
||||
|
||||
function withMetadata(global?: StartMetadata, route?: StartMetadata) {
|
||||
const routeSpec = { page, metadata: route };
|
||||
return {
|
||||
spec: { metadata: global, routes: { "/": routeSpec } } as StartAppSpec,
|
||||
route: routeSpec,
|
||||
};
|
||||
}
|
||||
|
||||
describe("metadata", () => {
|
||||
it("applies title templates safely to every placeholder", () => {
|
||||
const { spec, route } = withMetadata(
|
||||
{ title: { default: "Site", template: "%s | Site | %s" } },
|
||||
{ title: "Cash $& Carry" },
|
||||
);
|
||||
expect(resolveMetadata(spec, route).title).toBe(
|
||||
"Cash $& Carry | Site | Cash $& Carry",
|
||||
);
|
||||
});
|
||||
|
||||
it("honors absolute titles and shallow-merges social metadata", () => {
|
||||
const { spec, route } = withMetadata(
|
||||
{
|
||||
title: { default: "Site", template: "%s | Site" },
|
||||
openGraph: { siteName: "Site", type: "website" },
|
||||
},
|
||||
{
|
||||
title: { default: "Page", absolute: "Standalone" },
|
||||
openGraph: { title: "Page", type: "article" },
|
||||
},
|
||||
);
|
||||
expect(resolveMetadata(spec, route)).toMatchObject({
|
||||
title: "Standalone",
|
||||
openGraph: { siteName: "Site", title: "Page", type: "article" },
|
||||
});
|
||||
});
|
||||
|
||||
it("builds TanStack head meta and link descriptors", () => {
|
||||
const head = metadataToHead({
|
||||
title: "Home",
|
||||
description: "Welcome",
|
||||
keywords: ["json", "render"],
|
||||
openGraph: { images: ["/a.png", "/b.png"] },
|
||||
twitter: { card: "summary_large_image", images: "/c.png" },
|
||||
robots: { index: false },
|
||||
alternates: { canonical: "https://example.com" },
|
||||
icons: { icon: "/favicon.ico", apple: "/apple.png" },
|
||||
});
|
||||
expect(head.meta).toContainEqual({ title: "Home" });
|
||||
expect(head.meta).toContainEqual({
|
||||
property: "og:image",
|
||||
content: "/a.png",
|
||||
});
|
||||
expect(head.meta).toContainEqual({
|
||||
name: "robots",
|
||||
content: "noindex, follow",
|
||||
});
|
||||
expect(head.links).toEqual([
|
||||
{ rel: "canonical", href: "https://example.com" },
|
||||
{ rel: "icon", href: "/favicon.ico" },
|
||||
{ rel: "apple-touch-icon", href: "/apple.png" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,196 +0,0 @@
|
||||
import type {
|
||||
HeadDescriptors,
|
||||
StartAppSpec,
|
||||
StartMetadata,
|
||||
StartRouteSpec,
|
||||
} from "./types";
|
||||
|
||||
export type ResolvedMetadata = Record<string, unknown>;
|
||||
|
||||
/** Merge application metadata with a route's overrides. */
|
||||
export function resolveMetadata(
|
||||
spec: StartAppSpec,
|
||||
route?: StartRouteSpec | null,
|
||||
): ResolvedMetadata {
|
||||
const globalMetadata = spec.metadata;
|
||||
const routeMetadata = route?.metadata;
|
||||
if (!globalMetadata && !routeMetadata) return {};
|
||||
|
||||
const result: ResolvedMetadata = {};
|
||||
const title = resolveTitle(globalMetadata?.title, routeMetadata?.title);
|
||||
if (title !== undefined) result.title = title;
|
||||
|
||||
const description = routeMetadata?.description ?? globalMetadata?.description;
|
||||
if (description) result.description = description;
|
||||
|
||||
const keywords = routeMetadata?.keywords ?? globalMetadata?.keywords;
|
||||
if (keywords) result.keywords = keywords;
|
||||
|
||||
const openGraph = mergeObject(
|
||||
globalMetadata?.openGraph,
|
||||
routeMetadata?.openGraph,
|
||||
);
|
||||
if (openGraph) result.openGraph = openGraph;
|
||||
|
||||
const twitter = mergeObject(globalMetadata?.twitter, routeMetadata?.twitter);
|
||||
if (twitter) result.twitter = twitter;
|
||||
|
||||
const robots = routeMetadata?.robots ?? globalMetadata?.robots;
|
||||
if (robots) result.robots = robots;
|
||||
|
||||
const alternates = routeMetadata?.alternates ?? globalMetadata?.alternates;
|
||||
if (alternates) result.alternates = alternates;
|
||||
|
||||
const icons = routeMetadata?.icons ?? globalMetadata?.icons;
|
||||
if (icons) result.icons = icons;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Convert resolved metadata to TanStack Router `head` descriptors. */
|
||||
export function metadataToHead(metadata: ResolvedMetadata): HeadDescriptors {
|
||||
const meta: Record<string, string>[] = [];
|
||||
const links: Record<string, string>[] = [];
|
||||
const title = titleToString(metadata.title);
|
||||
|
||||
if (title) meta.push({ title });
|
||||
if (typeof metadata.description === "string") {
|
||||
meta.push({ name: "description", content: metadata.description });
|
||||
}
|
||||
if (Array.isArray(metadata.keywords) && metadata.keywords.length > 0) {
|
||||
meta.push({ name: "keywords", content: metadata.keywords.join(", ") });
|
||||
}
|
||||
|
||||
const openGraph = metadata.openGraph as
|
||||
| StartMetadata["openGraph"]
|
||||
| undefined;
|
||||
if (openGraph) {
|
||||
if (openGraph.title) {
|
||||
meta.push({ property: "og:title", content: openGraph.title });
|
||||
}
|
||||
if (openGraph.description) {
|
||||
meta.push({ property: "og:description", content: openGraph.description });
|
||||
}
|
||||
if (openGraph.type) {
|
||||
meta.push({ property: "og:type", content: openGraph.type });
|
||||
}
|
||||
if (openGraph.url)
|
||||
meta.push({ property: "og:url", content: openGraph.url });
|
||||
if (openGraph.siteName) {
|
||||
meta.push({ property: "og:site_name", content: openGraph.siteName });
|
||||
}
|
||||
if (openGraph.locale) {
|
||||
meta.push({ property: "og:locale", content: openGraph.locale });
|
||||
}
|
||||
for (const image of toArray(openGraph.images)) {
|
||||
meta.push({ property: "og:image", content: image });
|
||||
}
|
||||
}
|
||||
|
||||
const twitter = metadata.twitter as StartMetadata["twitter"] | undefined;
|
||||
if (twitter) {
|
||||
if (twitter.card) {
|
||||
meta.push({ name: "twitter:card", content: twitter.card });
|
||||
}
|
||||
if (twitter.title) {
|
||||
meta.push({ name: "twitter:title", content: twitter.title });
|
||||
}
|
||||
if (twitter.description) {
|
||||
meta.push({ name: "twitter:description", content: twitter.description });
|
||||
}
|
||||
if (twitter.creator) {
|
||||
meta.push({ name: "twitter:creator", content: twitter.creator });
|
||||
}
|
||||
if (twitter.site) {
|
||||
meta.push({ name: "twitter:site", content: twitter.site });
|
||||
}
|
||||
for (const image of toArray(twitter.images)) {
|
||||
meta.push({ name: "twitter:image", content: image });
|
||||
}
|
||||
}
|
||||
|
||||
const robots = metadata.robots as StartMetadata["robots"] | undefined;
|
||||
if (robots) {
|
||||
const content =
|
||||
typeof robots === "string"
|
||||
? robots
|
||||
: [
|
||||
robots.index === false ? "noindex" : "index",
|
||||
robots.follow === false ? "nofollow" : "follow",
|
||||
].join(", ");
|
||||
meta.push({ name: "robots", content });
|
||||
}
|
||||
|
||||
const alternates = metadata.alternates as
|
||||
| StartMetadata["alternates"]
|
||||
| undefined;
|
||||
if (alternates?.canonical) {
|
||||
links.push({ rel: "canonical", href: alternates.canonical });
|
||||
}
|
||||
|
||||
const icons = metadata.icons as StartMetadata["icons"] | undefined;
|
||||
if (typeof icons === "string") {
|
||||
links.push({ rel: "icon", href: icons });
|
||||
} else if (icons) {
|
||||
if (icons.icon) links.push({ rel: "icon", href: icons.icon });
|
||||
if (icons.apple) {
|
||||
links.push({ rel: "apple-touch-icon", href: icons.apple });
|
||||
}
|
||||
if (icons.shortcut) {
|
||||
links.push({ rel: "shortcut icon", href: icons.shortcut });
|
||||
}
|
||||
}
|
||||
|
||||
return { meta, links };
|
||||
}
|
||||
|
||||
function resolveTitle(
|
||||
globalTitle: StartMetadata["title"],
|
||||
routeTitle: StartMetadata["title"],
|
||||
): unknown {
|
||||
if (!routeTitle && !globalTitle) return undefined;
|
||||
|
||||
if (!routeTitle) {
|
||||
if (typeof globalTitle === "string") return globalTitle;
|
||||
return globalTitle?.default;
|
||||
}
|
||||
|
||||
const template =
|
||||
typeof globalTitle === "object" ? globalTitle.template : undefined;
|
||||
|
||||
if (typeof routeTitle === "object") {
|
||||
if (routeTitle.absolute) return routeTitle.absolute;
|
||||
return applyTitleTemplate(template, routeTitle.default);
|
||||
}
|
||||
|
||||
return applyTitleTemplate(template, routeTitle);
|
||||
}
|
||||
|
||||
function applyTitleTemplate(
|
||||
template: string | undefined,
|
||||
title: string,
|
||||
): string {
|
||||
return template ? template.replace(/%s/g, () => title) : title;
|
||||
}
|
||||
|
||||
function titleToString(title: unknown): string | undefined {
|
||||
if (typeof title === "string") return title;
|
||||
if (typeof title === "object" && title !== null) {
|
||||
const value = title as { absolute?: string; default?: string };
|
||||
return value.absolute ?? value.default;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function toArray(value: string | string[] | undefined): string[] {
|
||||
if (!value) return [];
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
function mergeObject(
|
||||
base: Record<string, unknown> | undefined,
|
||||
override: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!base && !override) return undefined;
|
||||
return { ...base, ...override };
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { collectStaticPaths, matchRoute, splatToPath } from "./router";
|
||||
import type { StartAppSpec, StartRouteSpec } from "./types";
|
||||
|
||||
function page(): Spec {
|
||||
return {
|
||||
root: "root",
|
||||
elements: { root: { type: "Card", props: {}, children: [] } },
|
||||
};
|
||||
}
|
||||
|
||||
function specWith(
|
||||
routes: Record<string, Partial<StartRouteSpec>>,
|
||||
): StartAppSpec {
|
||||
return {
|
||||
routes: Object.fromEntries(
|
||||
Object.entries(routes).map(([pattern, route]) => [
|
||||
pattern,
|
||||
{ page: page(), ...route },
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe("matchRoute", () => {
|
||||
it("matches root and static routes", () => {
|
||||
const spec = specWith({ "/": {}, "/about": {} });
|
||||
expect(matchRoute(spec, "")?.pattern).toBe("/");
|
||||
expect(matchRoute(spec, "/about")?.pattern).toBe("/about");
|
||||
});
|
||||
|
||||
it("extracts dynamic parameters", () => {
|
||||
const matched = matchRoute(specWith({ "/blog/$slug": {} }), "/blog/hello");
|
||||
expect(matched?.params).toEqual({ slug: "hello" });
|
||||
});
|
||||
|
||||
it("matches static and dynamic routes with trailing slashes", () => {
|
||||
const spec = specWith({ "/about": {}, "/blog/$slug": {} });
|
||||
expect(matchRoute(spec, "/about/")?.pattern).toBe("/about");
|
||||
expect(matchRoute(spec, "/blog/hello/")?.params).toEqual({
|
||||
slug: "hello",
|
||||
});
|
||||
});
|
||||
|
||||
it("matches route patterns that end in trailing slashes", () => {
|
||||
const spec = specWith({ "/about/": {}, "/blog/$slug/": {} });
|
||||
expect(matchRoute(spec, "/about/")?.pattern).toBe("/about/");
|
||||
expect(matchRoute(spec, "/blog/hello/")?.params).toEqual({
|
||||
slug: "hello",
|
||||
});
|
||||
});
|
||||
|
||||
it("matches encoded and decoded static pathnames", () => {
|
||||
const spec = specWith({ "/café": {} });
|
||||
expect(matchRoute(spec, "/café")?.pattern).toBe("/café");
|
||||
expect(matchRoute(spec, "/caf%C3%A9")?.pattern).toBe("/café");
|
||||
});
|
||||
|
||||
it("decodes dynamic and splat parameters", () => {
|
||||
expect(
|
||||
matchRoute(specWith({ "/blog/$slug": {} }), "/blog/hello%20world")
|
||||
?.params,
|
||||
).toEqual({ slug: "hello world" });
|
||||
expect(
|
||||
matchRoute(specWith({ "/docs/$": {} }), "/docs/guides/hello%20world")
|
||||
?.params,
|
||||
).toEqual({ _splat: "guides/hello world" });
|
||||
});
|
||||
|
||||
it("decodes percent signs in dynamic and splat parameters exactly once", () => {
|
||||
expect(
|
||||
matchRoute(specWith({ "/coupon/$code": {} }), "/coupon/100%25")?.params,
|
||||
).toEqual({ code: "100%" });
|
||||
expect(
|
||||
matchRoute(specWith({ "/coupon/$code": {} }), "/coupon/%2525")?.params,
|
||||
).toEqual({ code: "%25" });
|
||||
expect(
|
||||
matchRoute(specWith({ "/docs/$": {} }), "/docs/rates/100%25")?.params,
|
||||
).toEqual({ _splat: "rates/100%" });
|
||||
});
|
||||
|
||||
it("does not match malformed encoded parameters", () => {
|
||||
expect(
|
||||
matchRoute(specWith({ "/blog/$slug": {} }), "/blog/%E0%A4%A"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("captures zero or more splat segments under _splat", () => {
|
||||
const spec = specWith({ "/docs/$": {} });
|
||||
expect(matchRoute(spec, "/docs")?.params).toEqual({ _splat: "" });
|
||||
expect(matchRoute(spec, "/docs/")?.params).toEqual({ _splat: "" });
|
||||
expect(matchRoute(spec, "/docs/guides/intro")?.params).toEqual({
|
||||
_splat: "guides/intro",
|
||||
});
|
||||
});
|
||||
|
||||
it("matches an empty top-level splat at the root pathname", () => {
|
||||
expect(matchRoute(specWith({ "/$": {} }), "/")?.params).toEqual({
|
||||
_splat: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("ranks static, dynamic, then splat routes", () => {
|
||||
const spec = specWith({
|
||||
"/blog/$": {},
|
||||
"/blog/$slug": {},
|
||||
"/blog/featured": {},
|
||||
});
|
||||
expect(matchRoute(spec, "/blog/featured")?.pattern).toBe("/blog/featured");
|
||||
expect(matchRoute(spec, "/blog/post")?.pattern).toBe("/blog/$slug");
|
||||
expect(matchRoute(spec, "/blog/2026/post")?.pattern).toBe("/blog/$");
|
||||
});
|
||||
|
||||
it("ranks earlier static segments ahead of later static segments", () => {
|
||||
const spec = specWith({
|
||||
"/$type/edit": {},
|
||||
"/posts/$id": {},
|
||||
});
|
||||
expect(matchRoute(spec, "/posts/edit")?.pattern).toBe("/posts/$id");
|
||||
});
|
||||
|
||||
it("lets an earlier static segment outrank a later splat", () => {
|
||||
const spec = specWith({
|
||||
"/$type/edit": {},
|
||||
"/posts/$": {},
|
||||
});
|
||||
expect(matchRoute(spec, "/posts/edit")?.pattern).toBe("/posts/$");
|
||||
});
|
||||
|
||||
it("matches static segments case-insensitively like TanStack Router", () => {
|
||||
expect(matchRoute(specWith({ "/about": {} }), "/ABOUT")?.pattern).toBe(
|
||||
"/about",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for an unmatched path", () => {
|
||||
expect(matchRoute(specWith({ "/": {} }), "/missing")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("static paths", () => {
|
||||
it("converts splat content to pathnames", () => {
|
||||
expect(splatToPath(undefined)).toBe("/");
|
||||
expect(splatToPath("docs/intro")).toBe("/docs/intro");
|
||||
});
|
||||
|
||||
it("includes static routes and expands dynamic route params", () => {
|
||||
const spec = specWith({
|
||||
"/": {},
|
||||
"/about": {},
|
||||
"/blog/$slug": {
|
||||
staticParams: [{ slug: "hello" }, { slug: "world" }],
|
||||
},
|
||||
"/docs/$": { staticParams: [{ _splat: "guides/intro" }] },
|
||||
"/search/$query": { staticParams: [{ query: "hello world" }] },
|
||||
"/users/$id": {},
|
||||
});
|
||||
expect(collectStaticPaths(spec)).toEqual([
|
||||
"/",
|
||||
"/about",
|
||||
"/blog/hello",
|
||||
"/blog/world",
|
||||
"/docs/guides/intro",
|
||||
"/search/hello%20world",
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits matchable paths for route patterns with trailing slashes", () => {
|
||||
const spec = specWith({
|
||||
"/about/": {},
|
||||
"/blog/$slug/": { staticParams: [{ slug: "hello" }] },
|
||||
});
|
||||
const paths = collectStaticPaths(spec);
|
||||
|
||||
expect(paths).toEqual(["/about/", "/blog/hello/"]);
|
||||
expect(
|
||||
paths.map((pathname) => matchRoute(spec, pathname)?.pattern),
|
||||
).toEqual(["/about/", "/blog/$slug/"]);
|
||||
});
|
||||
|
||||
it("round trips percent signs in static params", () => {
|
||||
const spec = specWith({
|
||||
"/coupon/$code": {
|
||||
staticParams: [{ code: "100%" }, { code: "%25" }],
|
||||
},
|
||||
"/docs/$": { staticParams: [{ _splat: "rates/100%" }] },
|
||||
});
|
||||
const paths = collectStaticPaths(spec);
|
||||
|
||||
expect(paths).toEqual([
|
||||
"/coupon/100%25",
|
||||
"/coupon/%2525",
|
||||
"/docs/rates/100%25",
|
||||
]);
|
||||
expect(paths.map((pathname) => matchRoute(spec, pathname)?.params)).toEqual(
|
||||
[{ code: "100%" }, { code: "%25" }, { _splat: "rates/100%" }],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,156 +0,0 @@
|
||||
import type { MatchedRoute, StartAppSpec } from "./types";
|
||||
|
||||
interface CompiledRoute {
|
||||
pattern: string;
|
||||
regex: RegExp;
|
||||
paramNames: string[];
|
||||
segmentRanks: number[];
|
||||
}
|
||||
|
||||
const SPLAT_PARAM = "_splat";
|
||||
const STATIC_SEGMENT_RANK = 3;
|
||||
const DYNAMIC_SEGMENT_RANK = 2;
|
||||
const SPLAT_SEGMENT_RANK = 1;
|
||||
|
||||
/** Compile a TanStack Router pattern into a pathname matcher. */
|
||||
function compileRoute(pattern: string): CompiledRoute {
|
||||
const paramNames: string[] = [];
|
||||
const normalizedPattern = normalizePathname(pattern);
|
||||
const segments =
|
||||
normalizedPattern === "/" ? [""] : normalizedPattern.split("/").slice(1);
|
||||
const regexParts: string[] = [];
|
||||
const segmentRanks: number[] = [];
|
||||
|
||||
for (const segment of segments) {
|
||||
if (segment === "$") {
|
||||
paramNames.push(SPLAT_PARAM);
|
||||
segmentRanks.push(SPLAT_SEGMENT_RANK);
|
||||
regexParts.push("(?:/(.*))?");
|
||||
} else if (segment.startsWith("$") && segment.length > 1) {
|
||||
paramNames.push(segment.slice(1));
|
||||
segmentRanks.push(DYNAMIC_SEGMENT_RANK);
|
||||
regexParts.push("/([^/]+)");
|
||||
} else {
|
||||
segmentRanks.push(STATIC_SEGMENT_RANK);
|
||||
regexParts.push(`/${escapeRegExp(segment)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pattern,
|
||||
regex: new RegExp(
|
||||
normalizedPattern === "/" ? "^/$" : `^${regexParts.join("")}$`,
|
||||
"i",
|
||||
),
|
||||
paramNames,
|
||||
segmentRanks,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** Match a pathname using TanStack Router's static, dynamic, and splat forms. */
|
||||
export function matchRoute(
|
||||
spec: StartAppSpec,
|
||||
pathname: string,
|
||||
): MatchedRoute | null {
|
||||
const normalizedPath = normalizePathname(pathname);
|
||||
const compiled = Object.keys(spec.routes).map(compileRoute);
|
||||
|
||||
compiled.sort((a, b) => {
|
||||
const segmentCount = Math.max(a.segmentRanks.length, b.segmentRanks.length);
|
||||
for (let index = 0; index < segmentCount; index++) {
|
||||
const aRank = a.segmentRanks[index] ?? 0;
|
||||
const bRank = b.segmentRanks[index] ?? 0;
|
||||
if (aRank !== bRank) return bRank - aRank;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
for (const candidate of compiled) {
|
||||
const match = candidate.regex.exec(normalizedPath);
|
||||
if (!match) continue;
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
let validParams = true;
|
||||
for (let index = 0; index < candidate.paramNames.length; index++) {
|
||||
const name = candidate.paramNames[index]!;
|
||||
const value = match[index + 1];
|
||||
try {
|
||||
params[name] = decodeURIComponent(value ?? "");
|
||||
} catch {
|
||||
validParams = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!validParams) continue;
|
||||
|
||||
return {
|
||||
route: spec.routes[candidate.pattern]!,
|
||||
pattern: candidate.pattern,
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePathname(pathname: string): string {
|
||||
const withoutTrailingSlash = pathname.replace(/\/+$/, "") || "/";
|
||||
try {
|
||||
// TanStack preserves encoded percent signs in pathnames so route params
|
||||
// can decode them exactly once. Shield them while decoding static text.
|
||||
return decodeURI(withoutTrailingSlash.replace(/%25/gi, "%2525"));
|
||||
} catch {
|
||||
return withoutTrailingSlash;
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert TanStack Router splat content to a pathname. */
|
||||
export function splatToPath(splat: string | undefined): string {
|
||||
if (!splat) return "/";
|
||||
return `/${splat}`;
|
||||
}
|
||||
|
||||
/** Collect concrete pathnames suitable for TanStack Start prerendering. */
|
||||
export function collectStaticPaths(spec: StartAppSpec): string[] {
|
||||
const results: string[] = [];
|
||||
|
||||
for (const [pattern, route] of Object.entries(spec.routes)) {
|
||||
if (route.staticParams) {
|
||||
for (const params of route.staticParams) {
|
||||
const pathname = buildPathFromPattern(pattern, params);
|
||||
if (pathname) results.push(pathname);
|
||||
}
|
||||
} else if (!pattern.includes("$")) {
|
||||
results.push(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function buildPathFromPattern(
|
||||
pattern: string,
|
||||
params: Record<string, string>,
|
||||
): string | null {
|
||||
if (pattern === "/") return "/";
|
||||
|
||||
const result: string[] = [];
|
||||
for (const segment of pattern.split("/").slice(1)) {
|
||||
if (segment === "$") {
|
||||
const value = params[SPLAT_PARAM];
|
||||
if (value) result.push(...value.split("/").map(encodeURIComponent));
|
||||
} else if (segment.startsWith("$") && segment.length > 1) {
|
||||
const value = params[segment.slice(1)];
|
||||
if (!value) return null;
|
||||
result.push(encodeURIComponent(value));
|
||||
} else {
|
||||
result.push(segment);
|
||||
}
|
||||
}
|
||||
|
||||
return result.length === 0 ? "/" : `/${result.join("/")}`;
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { startComponentDefinitions } from "./catalog";
|
||||
import { schema, type StartSpec } from "./schema";
|
||||
|
||||
const catalog = schema.createCatalog({ components: {}, actions: {} });
|
||||
|
||||
describe("@json-render/tanstack-start schema", () => {
|
||||
it("accepts a minimal Start app spec", () => {
|
||||
const spec = {
|
||||
routes: {
|
||||
"/": {
|
||||
page: {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: { type: "Card", props: {}, children: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(catalog.validate(spec)).toMatchObject({ success: true, data: spec });
|
||||
});
|
||||
|
||||
it("preserves metadata and React element features", () => {
|
||||
const spec = {
|
||||
metadata: { title: "Home", alternates: { canonical: "/" } },
|
||||
routes: {
|
||||
"/": {
|
||||
page: {
|
||||
root: "root",
|
||||
state: { active: true },
|
||||
elements: {
|
||||
root: {
|
||||
type: "Card",
|
||||
props: {},
|
||||
children: [],
|
||||
slots: { header: [] },
|
||||
visible: { $state: "/active" },
|
||||
on: { press: { action: "save" } },
|
||||
repeat: { statePath: "/items" },
|
||||
watch: { "/active": { action: "track" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(catalog.validate(spec)).toMatchObject({ success: true, data: spec });
|
||||
});
|
||||
|
||||
it("validates built-in Slot and Link elements in a real catalog", () => {
|
||||
const builtInCatalog = schema.createCatalog({
|
||||
components: startComponentDefinitions,
|
||||
actions: {},
|
||||
});
|
||||
const result = builtInCatalog.validate({
|
||||
routes: {
|
||||
"/": {
|
||||
page: {
|
||||
root: "link",
|
||||
elements: {
|
||||
link: {
|
||||
type: "Link",
|
||||
props: { href: "/about" },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
layouts: {
|
||||
main: {
|
||||
root: "slot",
|
||||
elements: {
|
||||
slot: { type: "Slot", props: {}, children: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("requires children on every element", () => {
|
||||
const result = catalog.validate({
|
||||
routes: {
|
||||
"/": {
|
||||
page: {
|
||||
root: "root",
|
||||
elements: { root: { type: "Card", props: {} } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("infers optional top-level fields", () => {
|
||||
type InferredSpec = StartSpec<Parameters<typeof schema.createCatalog>[0]>;
|
||||
const spec: InferredSpec = {
|
||||
routes: {
|
||||
"/": {
|
||||
page: {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: { type: "Card", props: {}, children: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(spec.routes["/"]?.page.root).toBe("root");
|
||||
});
|
||||
});
|
||||
@@ -1,269 +0,0 @@
|
||||
import { defineSchema, type PromptContext } from "@json-render/core";
|
||||
|
||||
function startAppPromptTemplate(context: PromptContext): string {
|
||||
const { catalog, options, formatZodType } = context;
|
||||
const {
|
||||
system = "You are a TanStack Start application generator.",
|
||||
customRules = [],
|
||||
} = options;
|
||||
const lines: string[] = [system, ""];
|
||||
|
||||
lines.push("OUTPUT FORMAT:");
|
||||
lines.push(
|
||||
"Output JSONL (one JSON object per line) with RFC 6902 JSON Patch operations to build a TanStack Start application spec.",
|
||||
);
|
||||
lines.push(
|
||||
"The spec defines routes, layouts, metadata, and state for a full TanStack Start app.",
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("Example output (each line is a separate JSON object):");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
`{"op":"add","path":"/metadata","value":{"title":{"default":"My App","template":"%s | My App"},"description":"A TanStack Start application"}}`,
|
||||
);
|
||||
lines.push(`{"op":"add","path":"/layouts","value":{}}`);
|
||||
lines.push(
|
||||
`{"op":"add","path":"/layouts/main","value":{"root":"shell","elements":{"shell":{"type":"AppShell","props":{},"children":["nav","slot"]},"nav":{"type":"NavBar","props":{},"children":[]},"slot":{"type":"Slot","props":{},"children":[]}}}}`,
|
||||
);
|
||||
lines.push(`{"op":"add","path":"/routes","value":{}}`);
|
||||
lines.push(
|
||||
`{"op":"add","path":"/routes/~1","value":{"layout":"main","metadata":{"title":"Home"},"page":{"root":"hero","elements":{"hero":{"type":"Card","props":{"title":"Welcome"},"children":[]}}}}}`,
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push("SPEC STRUCTURE:");
|
||||
lines.push("- metadata: Root SEO metadata and title templates");
|
||||
lines.push(
|
||||
"- layouts: Reusable element trees with a Slot element for page content",
|
||||
);
|
||||
lines.push("- routes: Route definitions keyed by URL pattern");
|
||||
lines.push("- state: Global initial state shared by routes");
|
||||
lines.push("");
|
||||
|
||||
lines.push("ROUTES:");
|
||||
lines.push("Route keys use TanStack Router URL patterns:");
|
||||
lines.push("- '/' - home page");
|
||||
lines.push("- '/about' - static route");
|
||||
lines.push("- '/blog/$slug' - dynamic segment");
|
||||
lines.push("- '/docs/$' - splat segment matching the remaining path");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"In JSON Patch paths, escape every forward slash in a route key as ~1.",
|
||||
);
|
||||
lines.push("- Route '/' becomes '/routes/~1'");
|
||||
lines.push("- Route '/about' becomes '/routes/~1about'");
|
||||
lines.push("- Route '/blog/$slug' becomes '/routes/~1blog~1$slug'");
|
||||
lines.push("");
|
||||
|
||||
lines.push("Each route has:");
|
||||
lines.push("- page: Element tree with root, elements, and optional state");
|
||||
lines.push("- metadata: Route-specific SEO metadata");
|
||||
lines.push("- layout: Key in the top-level layouts map");
|
||||
lines.push("- loading, error, notFound: Optional fallback element trees");
|
||||
lines.push("- loader: Optional server loader name");
|
||||
lines.push("- staticParams: Optional params for prerendered dynamic routes");
|
||||
lines.push("");
|
||||
|
||||
lines.push("PAGE ELEMENTS:");
|
||||
lines.push("- props may contain $state, $item, $index, and $computed values");
|
||||
lines.push("- on maps component events to one or more action bindings");
|
||||
lines.push("- repeat renders children for values at a state path");
|
||||
lines.push("- visible conditionally includes an element");
|
||||
lines.push("- watch runs actions when state paths change");
|
||||
lines.push(
|
||||
"- slots maps named slots to element keys; use children for default",
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
const catalogData = catalog as {
|
||||
components?: Record<
|
||||
string,
|
||||
{
|
||||
description?: string;
|
||||
props?: unknown;
|
||||
slots?: string[];
|
||||
events?: string[];
|
||||
}
|
||||
>;
|
||||
actions?: Record<string, { description?: string }>;
|
||||
};
|
||||
|
||||
if (catalogData.components) {
|
||||
lines.push(
|
||||
`AVAILABLE COMPONENTS (${Object.keys(catalogData.components).length}):`,
|
||||
);
|
||||
lines.push("");
|
||||
for (const [name, definition] of Object.entries(catalogData.components)) {
|
||||
const props = definition.props
|
||||
? formatZodType(definition.props as any)
|
||||
: "{}";
|
||||
const children = definition.slots?.length ? " [accepts children]" : "";
|
||||
const events = definition.events?.length
|
||||
? ` [events: ${definition.events.join(", ")}]`
|
||||
: "";
|
||||
const description = definition.description
|
||||
? ` - ${definition.description}`
|
||||
: "";
|
||||
lines.push(`${name}: ${props}${description}${children}${events}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("BUILT-IN COMPONENTS:");
|
||||
lines.push("- Slot: Layout placeholder for page content");
|
||||
lines.push("- Link: { href: string } client-side navigation link");
|
||||
lines.push("");
|
||||
|
||||
if (catalogData.actions && Object.keys(catalogData.actions).length > 0) {
|
||||
lines.push("AVAILABLE ACTIONS:");
|
||||
for (const [name, definition] of Object.entries(catalogData.actions)) {
|
||||
lines.push(
|
||||
`${name}${definition.description ? `: ${definition.description}` : ""}`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("BUILT-IN ACTIONS:");
|
||||
lines.push("- setState: { statePath, value }");
|
||||
lines.push("- pushState: { statePath, value, clearStatePath? }");
|
||||
lines.push("- removeState: { statePath, index }");
|
||||
lines.push("- navigate: { href }");
|
||||
lines.push("");
|
||||
|
||||
lines.push("RULES:");
|
||||
const rules = [
|
||||
"Output only JSONL patches, one JSON object per line",
|
||||
"Add metadata, then layouts, then routes",
|
||||
"Every layout must contain a Slot element",
|
||||
"Only use available components plus Slot and Link",
|
||||
"Every element must include type, props, and children",
|
||||
"Every child and named-slot key must reference an existing element",
|
||||
"Escape route-key slashes as ~1 in JSON Patch paths",
|
||||
"Use Link for navigation between routes",
|
||||
"Use repeat for lists and include realistic state data",
|
||||
"Create a cohesive application with consistent layouts",
|
||||
...customRules,
|
||||
];
|
||||
rules.forEach((rule, index) => lines.push(`${index + 1}. ${rule}`));
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** The AI generation schema for full TanStack Start applications. */
|
||||
export const schema = defineSchema(
|
||||
(s) => ({
|
||||
spec: s.object({
|
||||
metadata: {
|
||||
...s.object({
|
||||
title: { ...s.any(), ...s.optional() },
|
||||
description: { ...s.string(), ...s.optional() },
|
||||
keywords: { ...s.array(s.string()), ...s.optional() },
|
||||
openGraph: { ...s.any(), ...s.optional() },
|
||||
twitter: { ...s.any(), ...s.optional() },
|
||||
robots: { ...s.any(), ...s.optional() },
|
||||
alternates: { ...s.any(), ...s.optional() },
|
||||
icons: { ...s.any(), ...s.optional() },
|
||||
}),
|
||||
...s.optional(),
|
||||
},
|
||||
routes: s.record(
|
||||
s.object({
|
||||
page: s.object({
|
||||
root: s.string(),
|
||||
elements: s.record(
|
||||
s.object({
|
||||
type: s.ref("catalog.components"),
|
||||
props: s.propsOf("catalog.components"),
|
||||
children: s.array(s.string()),
|
||||
slots: {
|
||||
...s.record(s.array(s.string())),
|
||||
...s.optional(),
|
||||
},
|
||||
visible: { ...s.any(), ...s.optional() },
|
||||
on: { ...s.any(), ...s.optional() },
|
||||
repeat: { ...s.any(), ...s.optional() },
|
||||
watch: { ...s.any(), ...s.optional() },
|
||||
}),
|
||||
),
|
||||
state: { ...s.any(), ...s.optional() },
|
||||
}),
|
||||
metadata: { ...s.any(), ...s.optional() },
|
||||
layout: { ...s.string(), ...s.optional() },
|
||||
loading: { ...s.any(), ...s.optional() },
|
||||
error: { ...s.any(), ...s.optional() },
|
||||
notFound: { ...s.any(), ...s.optional() },
|
||||
loader: { ...s.string(), ...s.optional() },
|
||||
staticParams: { ...s.any(), ...s.optional() },
|
||||
}),
|
||||
),
|
||||
layouts: {
|
||||
...s.record(
|
||||
s.object({
|
||||
root: s.string(),
|
||||
elements: s.record(
|
||||
s.object({
|
||||
type: s.ref("catalog.components"),
|
||||
props: s.propsOf("catalog.components"),
|
||||
children: s.array(s.string()),
|
||||
slots: {
|
||||
...s.record(s.array(s.string())),
|
||||
...s.optional(),
|
||||
},
|
||||
visible: { ...s.any(), ...s.optional() },
|
||||
on: { ...s.any(), ...s.optional() },
|
||||
repeat: { ...s.any(), ...s.optional() },
|
||||
watch: { ...s.any(), ...s.optional() },
|
||||
}),
|
||||
),
|
||||
state: { ...s.any(), ...s.optional() },
|
||||
}),
|
||||
),
|
||||
...s.optional(),
|
||||
},
|
||||
state: { ...s.any(), ...s.optional() },
|
||||
}),
|
||||
catalog: s.object({
|
||||
components: s.map({
|
||||
props: s.zod(),
|
||||
slots: s.array(s.string()),
|
||||
description: s.string(),
|
||||
example: s.any(),
|
||||
}),
|
||||
actions: s.map({
|
||||
params: s.zod(),
|
||||
description: s.string(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
{
|
||||
promptTemplate: startAppPromptTemplate,
|
||||
builtInActions: [
|
||||
{
|
||||
name: "setState",
|
||||
description:
|
||||
"Update state at a JSON Pointer. Params: { statePath, value }",
|
||||
},
|
||||
{
|
||||
name: "pushState",
|
||||
description:
|
||||
"Append to an array. Params: { statePath, value, clearStatePath? }",
|
||||
},
|
||||
{
|
||||
name: "removeState",
|
||||
description: "Remove an array item. Params: { statePath, index }",
|
||||
},
|
||||
{
|
||||
name: "navigate",
|
||||
description: "Navigate within the app. Params: { href }",
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
export type StartSchema = typeof schema;
|
||||
export type StartSpec<TCatalog> = typeof schema extends {
|
||||
createCatalog: (catalog: TCatalog) => { _specType: infer S };
|
||||
}
|
||||
? S
|
||||
: never;
|
||||
@@ -1,37 +0,0 @@
|
||||
/** Server-safe TanStack Start helpers with no React or router imports. */
|
||||
|
||||
export { createStartApp } from "./create-app";
|
||||
export {
|
||||
startComponentDefinitions,
|
||||
type StartComponentDefinitions,
|
||||
} from "./catalog";
|
||||
export { schema, type StartSchema, type StartSpec } from "./schema";
|
||||
export { collectStaticPaths, matchRoute, splatToPath } from "./router";
|
||||
export {
|
||||
metadataToHead,
|
||||
resolveMetadata,
|
||||
type ResolvedMetadata,
|
||||
} from "./metadata";
|
||||
export type {
|
||||
CreateStartAppOptions,
|
||||
HeadDescriptors,
|
||||
LoaderFn,
|
||||
MatchedRoute,
|
||||
PageData,
|
||||
StartAppExports,
|
||||
StartAppSpec,
|
||||
StartMetadata,
|
||||
StartRouteSpec,
|
||||
} from "./types";
|
||||
export type {
|
||||
ActionFn,
|
||||
Actions,
|
||||
BaseComponentProps,
|
||||
ComponentContext,
|
||||
ComponentFn,
|
||||
Components,
|
||||
EventHandle,
|
||||
SetState,
|
||||
StateModel,
|
||||
} from "./catalog-types";
|
||||
export type { Spec, StateStore } from "@json-render/core";
|
||||
@@ -1,52 +0,0 @@
|
||||
import React from "react";
|
||||
import { createRootRoute, createRoute, notFound } from "@tanstack/react-router";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
PageRenderer,
|
||||
StartErrorBoundary,
|
||||
StartLoading,
|
||||
StartNotFound,
|
||||
} from "./index";
|
||||
import { createStartApp } from "./server";
|
||||
import type { StartAppSpec } from "./types";
|
||||
|
||||
const spec: StartAppSpec = {
|
||||
routes: {
|
||||
"/$": {
|
||||
page: {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: { type: "Card", props: {}, children: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const app = createStartApp({ spec });
|
||||
const rootRoute = createRootRoute();
|
||||
const route = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "$",
|
||||
loader: async ({ location }) => {
|
||||
const data = await app.getPageData({ pathname: location.pathname });
|
||||
if (!data) throw notFound();
|
||||
return data;
|
||||
},
|
||||
head: ({ match }) => app.getHead({ pathname: match.pathname }),
|
||||
component: RouteComponent,
|
||||
pendingComponent: StartLoading,
|
||||
errorComponent: StartErrorBoundary,
|
||||
notFoundComponent: StartNotFound,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return <PageRenderer {...route.useLoaderData()} />;
|
||||
}
|
||||
|
||||
describe("TanStack Router contract", () => {
|
||||
it("accepts all json-render route hooks and components", () => {
|
||||
expect(route.options.loader).toBeTypeOf("function");
|
||||
expect(route.options.head).toBeTypeOf("function");
|
||||
});
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
import type { Spec } from "@json-render/core";
|
||||
|
||||
/**
|
||||
* SEO metadata for TanStack Start pages.
|
||||
*
|
||||
* This framework-neutral shape resolves into TanStack Router `head`
|
||||
* descriptors through `metadataToHead`.
|
||||
*/
|
||||
export interface StartMetadata {
|
||||
/** Page title, or a title template configuration. */
|
||||
title?:
|
||||
| string
|
||||
| {
|
||||
/** Default title when no route overrides it. */
|
||||
default: string;
|
||||
/** Template containing `%s` for the route title. */
|
||||
template?: string;
|
||||
/** Absolute title that ignores the parent template. */
|
||||
absolute?: string;
|
||||
};
|
||||
description?: string;
|
||||
keywords?: string[];
|
||||
openGraph?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
images?: string | string[];
|
||||
type?: string;
|
||||
url?: string;
|
||||
siteName?: string;
|
||||
locale?: string;
|
||||
};
|
||||
twitter?: {
|
||||
card?: "summary" | "summary_large_image" | "app" | "player";
|
||||
title?: string;
|
||||
description?: string;
|
||||
images?: string | string[];
|
||||
creator?: string;
|
||||
site?: string;
|
||||
};
|
||||
robots?: string | { index?: boolean; follow?: boolean };
|
||||
alternates?: { canonical?: string };
|
||||
icons?: string | { icon?: string; apple?: string; shortcut?: string };
|
||||
}
|
||||
|
||||
/** A route definition within a StartAppSpec. */
|
||||
export interface StartRouteSpec {
|
||||
/** Page content as a standard json-render element tree. */
|
||||
page: Spec;
|
||||
metadata?: StartMetadata;
|
||||
/** Key of a reusable layout in `StartAppSpec.layouts`. */
|
||||
layout?: string;
|
||||
loading?: Spec;
|
||||
error?: Spec;
|
||||
notFound?: Spec;
|
||||
/** Name of a loader supplied to `createStartApp`. */
|
||||
loader?: string;
|
||||
/** Parameter sets used to produce concrete prerender paths. */
|
||||
staticParams?: Record<string, string>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A full json-render application for TanStack Start.
|
||||
*
|
||||
* Route keys use TanStack Router conventions: `/`, `/blog/$slug`, and
|
||||
* `/docs/$` for a splat route.
|
||||
*/
|
||||
export interface StartAppSpec {
|
||||
metadata?: StartMetadata;
|
||||
routes: Record<string, StartRouteSpec>;
|
||||
/** Layouts use a `Slot` element to mark where page content is inserted. */
|
||||
layouts?: Record<string, Spec>;
|
||||
state?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** The result of matching a pathname against the application spec. */
|
||||
export interface MatchedRoute {
|
||||
route: StartRouteSpec;
|
||||
pattern: string;
|
||||
/** Splat content is returned as a slash-delimited string under `_splat`. */
|
||||
params: Record<string, string>;
|
||||
}
|
||||
|
||||
export type LoaderFn = (
|
||||
params: Record<string, string>,
|
||||
) => Promise<Record<string, unknown>> | Record<string, unknown>;
|
||||
|
||||
export interface CreateStartAppOptions {
|
||||
spec: StartAppSpec | (() => StartAppSpec | Promise<StartAppSpec>);
|
||||
loaders?: Record<string, LoaderFn>;
|
||||
}
|
||||
|
||||
/** Serializable data returned from a TanStack Start route loader. */
|
||||
export interface PageData {
|
||||
spec: Spec;
|
||||
initialState?: Record<string, unknown>;
|
||||
layoutSpec?: Spec | null;
|
||||
}
|
||||
|
||||
/** Descriptors accepted by a TanStack Router route's `head` option. */
|
||||
export interface HeadDescriptors {
|
||||
meta: Record<string, string>[];
|
||||
links: Record<string, string>[];
|
||||
}
|
||||
|
||||
export interface StartAppExports {
|
||||
/** Resolve serializable page data for a pathname, or null when unmatched. */
|
||||
getPageData: (props: { pathname: string }) => Promise<PageData | null>;
|
||||
/** Resolve metadata for a route's `head` option. */
|
||||
getHead: (props: { pathname: string }) => Promise<HeadDescriptors>;
|
||||
/** Get concrete pathnames for TanStack Start prerendering. */
|
||||
getStaticPaths: () => Promise<string[]>;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "@internal/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
const sharedExternal = [
|
||||
"react",
|
||||
"react-dom",
|
||||
"@tanstack/react-router",
|
||||
"@json-render/core",
|
||||
"@json-render/react",
|
||||
"zod",
|
||||
];
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: { index: "src/index.ts" },
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
splitting: false,
|
||||
banner: ({ format }) => (format === "cjs" ? { js: '"use client";' } : {}),
|
||||
external: sharedExternal,
|
||||
},
|
||||
{
|
||||
entry: { server: "src/server.ts", catalog: "src/catalog.ts" },
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
splitting: false,
|
||||
external: sharedExternal,
|
||||
},
|
||||
]);
|
||||
+5
-38
@@ -26,7 +26,6 @@ export const catalog = defineCatalog(schema, {
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
}),
|
||||
slots: ["default", "header", "footer"],
|
||||
description: "A card container",
|
||||
},
|
||||
Button: {
|
||||
@@ -54,7 +53,7 @@ export const catalog = defineCatalog(schema, {
|
||||
|
||||
### 2. Define Component Implementations
|
||||
|
||||
Components are written using Vue's `h()` render function. The `slots` context uses Vue's native slot functions, so render a region with `slots.header?.()`. For convenience and React parity, `children` contains the already-rendered result of `slots.default?.()`.
|
||||
Components are written using Vue's `h()` render function. `children` is a `VNode | VNode[]` — pass it directly to your container element.
|
||||
|
||||
`defineRegistry` conditionally requires the `actions` field only when the catalog declares actions. Catalogs with `actions: {}` can omit it entirely.
|
||||
|
||||
@@ -65,12 +64,11 @@ import { catalog } from "./catalog";
|
||||
|
||||
export const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: ({ props, slots }) =>
|
||||
Card: ({ props, children }) =>
|
||||
h("div", { class: "card" }, [
|
||||
h("header", null, slots.header?.() ?? h("h3", null, props.title)),
|
||||
h("h3", null, props.title),
|
||||
props.description ? h("p", null, props.description) : null,
|
||||
slots.default?.(),
|
||||
h("footer", null, slots.footer?.()),
|
||||
children,
|
||||
]),
|
||||
Button: ({ props, emit }) =>
|
||||
h("button", { onClick: () => emit("press") }, props.label),
|
||||
@@ -133,7 +131,6 @@ interface UIElement {
|
||||
type: string; // Component name from catalog
|
||||
props: Record<string, unknown>; // Component props
|
||||
children?: string[]; // Keys of child elements
|
||||
slots?: Record<string, string[]>; // Named slots mapped to child keys
|
||||
visible?: VisibilityCondition; // Visibility condition
|
||||
}
|
||||
```
|
||||
@@ -166,35 +163,6 @@ Example spec:
|
||||
}
|
||||
```
|
||||
|
||||
### Named Slots
|
||||
|
||||
Use `children` for the default slot and the element's top-level `slots` object for other slot names declared by the catalog:
|
||||
|
||||
```typescript
|
||||
Layout: ({ children, slots }) =>
|
||||
h("div", null, [
|
||||
h("header", null, slots.header?.()),
|
||||
h("main", null, children),
|
||||
h("footer", null, slots.footer?.()),
|
||||
]),
|
||||
```
|
||||
|
||||
The corresponding spec maps element keys to each region:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Layout",
|
||||
"props": {},
|
||||
"children": ["main-content"],
|
||||
"slots": {
|
||||
"header": ["page-heading"],
|
||||
"footer": ["page-actions"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Registry components receive Vue-native slot functions and render them with `slots.header?.()`, `slots.footer?.()`, and so on. `slots.default?.()` renders the spec's `children`; `children` is an alias for that rendered result. In the JSON spec, keep default content in `children` rather than adding a `default` entry to `slots`.
|
||||
|
||||
## Providers
|
||||
|
||||
### StateProvider
|
||||
@@ -398,12 +366,11 @@ The `setState`, `pushState`, `removeState`, and `validateForm` actions are built
|
||||
When using `defineRegistry`, components receive these props via their render function:
|
||||
|
||||
```typescript
|
||||
import type { Slots, VNode } from "vue";
|
||||
import type { VNode } from "vue";
|
||||
|
||||
interface ComponentContext<P> {
|
||||
props: P; // Typed props from the catalog (expressions resolved)
|
||||
children?: VNode | VNode[]; // Rendered children (for container components)
|
||||
slots: Slots; // Vue-native slot functions
|
||||
emit: (event: string) => void; // Emit a named event (always defined)
|
||||
on: (event: string) => EventHandle; // Get event handle with metadata
|
||||
loading?: boolean; // Whether the parent is loading
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/vue",
|
||||
"version": "0.20.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Vue renderer for @json-render/core. JSON becomes Vue components.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Slots, VNode } from "vue";
|
||||
import type { VNode } from "vue";
|
||||
import type {
|
||||
Catalog,
|
||||
InferCatalogComponents,
|
||||
@@ -59,8 +59,6 @@ export interface BaseComponentProps<P = Record<string, unknown>> {
|
||||
props: P;
|
||||
/** Rendered children (from the default slot) */
|
||||
children?: VNode | VNode[];
|
||||
/** Vue-native slot functions, including the default slot */
|
||||
slots: Slots;
|
||||
/** Simple event emitter (shorthand). Fires the event and returns void. */
|
||||
emit: (event: string) => void;
|
||||
/** Get an event handle with metadata. Use when you need shouldPreventDefault or bound checks. */
|
||||
|
||||
@@ -128,31 +128,6 @@ describe("buildSpecFromParts", () => {
|
||||
];
|
||||
expect(buildSpecFromParts(parts)).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves named slots in nested spec parts", () => {
|
||||
const spec = buildSpecFromParts([
|
||||
{
|
||||
type: SPEC_DATA_PART_TYPE,
|
||||
data: {
|
||||
type: "nested",
|
||||
spec: {
|
||||
type: "Layout",
|
||||
props: {},
|
||||
slots: {
|
||||
header: [
|
||||
{ type: "Heading", props: { text: "Header" }, children: [] },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(spec).not.toBeNull();
|
||||
const root = spec!.elements[spec!.root]!;
|
||||
expect(root.slots?.header).toHaveLength(1);
|
||||
expect(spec!.elements[root.slots!.header![0]!]!.type).toBe("Heading");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { defineComponent, h, type Component } from "vue";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { defineCatalog, type Spec } from "@json-render/core";
|
||||
import { z } from "zod";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { StateProvider } from "./composables/state";
|
||||
import { VisibilityProvider } from "./composables/visibility";
|
||||
import { ActionProvider } from "./composables/actions";
|
||||
import { ValidationProvider } from "./composables/validation";
|
||||
import { Renderer, defineRegistry, type ComponentRegistry } from "./renderer";
|
||||
import { schema } from "./schema";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal test catalog and registry
|
||||
@@ -74,23 +68,6 @@ function mountRenderer(
|
||||
});
|
||||
}
|
||||
|
||||
function layoutSpec(slots: Record<string, string[]>): Spec {
|
||||
return {
|
||||
root: "layout",
|
||||
elements: {
|
||||
layout: {
|
||||
type: "Layout",
|
||||
props: {},
|
||||
children: ["main"],
|
||||
slots,
|
||||
},
|
||||
main: { type: "Text", props: { text: "Main" } },
|
||||
header: { type: "Text", props: { text: "Header" } },
|
||||
other: { type: "Text", props: { text: "Other" } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// defineRegistry tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -134,122 +111,6 @@ describe("defineRegistry", () => {
|
||||
expect(wrapper.find("[data-type='button']").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("renders named slots as Vue slot functions through defineRegistry", () => {
|
||||
const namedSlotsCatalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Layout: {
|
||||
props: z.object({}),
|
||||
slots: ["default", "header", "footer"],
|
||||
},
|
||||
Text: {
|
||||
props: z.object({ text: z.string() }),
|
||||
slots: [],
|
||||
},
|
||||
},
|
||||
actions: {},
|
||||
});
|
||||
const { registry: namedSlotsRegistry } = defineRegistry(namedSlotsCatalog, {
|
||||
components: {
|
||||
Layout: ({ slots }) =>
|
||||
h("section", null, [
|
||||
h("header", { "data-testid": "header-slot" }, slots.header?.()),
|
||||
h("main", { "data-testid": "default-slot" }, slots.default?.()),
|
||||
h("footer", { "data-testid": "footer-slot" }, slots.footer?.()),
|
||||
]),
|
||||
Text: ({ props }) => h("span", null, props.text),
|
||||
},
|
||||
});
|
||||
const spec: Spec = {
|
||||
root: "layout",
|
||||
elements: {
|
||||
layout: {
|
||||
type: "Layout",
|
||||
props: {},
|
||||
children: ["main"],
|
||||
slots: {
|
||||
header: ["header"],
|
||||
footer: ["footer"],
|
||||
},
|
||||
},
|
||||
header: { type: "Text", props: { text: "Header" } },
|
||||
main: { type: "Text", props: { text: "Main" } },
|
||||
footer: { type: "Text", props: { text: "Footer" } },
|
||||
},
|
||||
};
|
||||
|
||||
const wrapper = mountRenderer(spec, namedSlotsRegistry);
|
||||
|
||||
expect(wrapper.find('[data-testid="header-slot"]').text()).toBe("Header");
|
||||
expect(wrapper.find('[data-testid="default-slot"]').text()).toBe("Main");
|
||||
expect(wrapper.find('[data-testid="footer-slot"]').text()).toBe("Footer");
|
||||
});
|
||||
|
||||
it("advertises named slots in the Vue catalog prompt without default", () => {
|
||||
const namedSlotsCatalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Layout: { props: z.object({}), slots: ["default", "header"] },
|
||||
},
|
||||
actions: {},
|
||||
});
|
||||
|
||||
const prompt = namedSlotsCatalog.prompt();
|
||||
|
||||
expect(prompt).toContain("slots: header");
|
||||
expect(prompt).not.toContain("slots: default");
|
||||
});
|
||||
|
||||
it("warns and ignores slots.default while preserving children", () => {
|
||||
const namedSlotsCatalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Layout: { props: z.object({}), slots: ["default"] },
|
||||
Text: { props: z.object({ text: z.string() }), slots: [] },
|
||||
},
|
||||
actions: {},
|
||||
});
|
||||
const { registry: namedSlotsRegistry } = defineRegistry(namedSlotsCatalog, {
|
||||
components: {
|
||||
Layout: ({ children }) => h("main", null, children),
|
||||
Text: ({ props }) => h("span", null, props.text),
|
||||
},
|
||||
});
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const wrapper = mountRenderer(
|
||||
layoutSpec({ default: ["other"] }),
|
||||
namedSlotsRegistry,
|
||||
);
|
||||
|
||||
expect(wrapper.text()).toBe("Main");
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining("slots.default"));
|
||||
});
|
||||
|
||||
it("warns but renders an undeclared named slot", () => {
|
||||
const namedSlotsCatalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Layout: { props: z.object({}), slots: ["default"] },
|
||||
Text: { props: z.object({ text: z.string() }), slots: [] },
|
||||
},
|
||||
actions: {},
|
||||
});
|
||||
const { registry: namedSlotsRegistry } = defineRegistry(namedSlotsCatalog, {
|
||||
components: {
|
||||
Layout: ({ slots }) => h("aside", null, slots.sidebar?.()),
|
||||
Text: ({ props }) => h("span", null, props.text),
|
||||
},
|
||||
});
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const wrapper = mountRenderer(
|
||||
layoutSpec({ sidebar: ["other"] }),
|
||||
namedSlotsRegistry,
|
||||
);
|
||||
|
||||
expect(wrapper.text()).toBe("Other");
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Unknown slot "sidebar"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("emit('press') fires the corresponding on.press action", async () => {
|
||||
const handler = vi.fn().mockResolvedValue(undefined);
|
||||
const spec: Spec = {
|
||||
@@ -273,99 +134,6 @@ describe("defineRegistry", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Renderer", () => {
|
||||
it("delivers raw registry named slots lazily", () => {
|
||||
let footerCalls = 0;
|
||||
const Layout = defineComponent({
|
||||
setup(_, { slots }) {
|
||||
return () =>
|
||||
h("section", null, [
|
||||
h("header", null, slots.header?.()),
|
||||
h("main", null, slots.default?.()),
|
||||
slots.footer ? h("i", null, "has-footer") : null,
|
||||
]);
|
||||
},
|
||||
});
|
||||
const Text = defineComponent({
|
||||
props: { element: { type: Object, required: true } },
|
||||
setup(props) {
|
||||
return () => h("span", null, (props.element as any).props.text);
|
||||
},
|
||||
});
|
||||
const Footer = defineComponent({
|
||||
setup() {
|
||||
footerCalls += 1;
|
||||
return () => h("span", null, "Footer");
|
||||
},
|
||||
});
|
||||
const spec = layoutSpec({ header: ["header"], footer: ["footer"] });
|
||||
spec.elements.footer = { type: "Footer", props: {} };
|
||||
|
||||
const wrapper = mountRenderer(spec, { Layout, Text, Footer });
|
||||
|
||||
expect(wrapper.text()).toContain("Header");
|
||||
expect(wrapper.text()).toContain("Main");
|
||||
expect(wrapper.text()).toContain("has-footer");
|
||||
expect(wrapper.text()).not.toContain("Footer");
|
||||
expect(footerCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("suppresses missing named slot warnings while loading", () => {
|
||||
const Layout = defineComponent({
|
||||
setup(_, { slots }) {
|
||||
return () => h("header", null, slots.header?.());
|
||||
},
|
||||
});
|
||||
const spec = layoutSpec({ header: ["missing"] });
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const loadingWrapper = mountRenderer(spec, { Layout }, { loading: true });
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
loadingWrapper.unmount();
|
||||
|
||||
mountRenderer(spec, { Layout }, { loading: false });
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('in slot "header"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders owner named slots once outside the owner repeat scope", () => {
|
||||
const Layout = defineComponent({
|
||||
setup(_, { slots }) {
|
||||
return () => h("section", null, [slots.header?.(), slots.default?.()]);
|
||||
},
|
||||
});
|
||||
const Text = defineComponent({
|
||||
props: { element: { type: Object, required: true } },
|
||||
setup(props) {
|
||||
return () => h("span", null, (props.element as any).props.text);
|
||||
},
|
||||
});
|
||||
const spec: Spec = {
|
||||
root: "layout",
|
||||
elements: {
|
||||
layout: {
|
||||
type: "Layout",
|
||||
props: {},
|
||||
repeat: { statePath: "/items" },
|
||||
children: ["row"],
|
||||
slots: { header: ["header"] },
|
||||
},
|
||||
row: { type: "Text", props: { text: { $item: "name" } } },
|
||||
header: { type: "Text", props: { text: "Header" } },
|
||||
},
|
||||
};
|
||||
|
||||
const wrapper = mountRenderer(
|
||||
spec,
|
||||
{ Layout, Text },
|
||||
{},
|
||||
{},
|
||||
{ items: [{ name: "A" }, { name: "B" }] },
|
||||
);
|
||||
|
||||
expect(wrapper.text()).toBe("HeaderAB");
|
||||
});
|
||||
|
||||
it("renders a single-element spec", () => {
|
||||
const spec: Spec = {
|
||||
root: "btn1",
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type Component,
|
||||
type ComputedRef,
|
||||
type PropType,
|
||||
type Slots,
|
||||
type VNode,
|
||||
} from "vue";
|
||||
import type {
|
||||
@@ -84,11 +83,6 @@ export interface ComponentRenderProps<P = Record<string, unknown>> {
|
||||
*/
|
||||
export type ComponentRegistry = Record<string, Component>;
|
||||
|
||||
const registryMetadata = new WeakMap<
|
||||
ComponentRegistry,
|
||||
Record<string, { slots?: string[] }>
|
||||
>();
|
||||
|
||||
/**
|
||||
* Props for the Renderer component
|
||||
*/
|
||||
@@ -401,51 +395,6 @@ const ElementRenderer = defineComponent({
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadata = registryMetadata.get(props.registry)?.[
|
||||
resolvedElement.type
|
||||
];
|
||||
if (resolvedElement.slots && metadata?.slots) {
|
||||
const availableSlots = new Set(metadata.slots);
|
||||
for (const slotName of Object.keys(resolvedElement.slots)) {
|
||||
if (slotName === "default") {
|
||||
console.warn(
|
||||
`[json-render] Component "${resolvedElement.type}" uses slots.default. Use "children" for default slot content.`,
|
||||
);
|
||||
} else if (!availableSlots.has(slotName)) {
|
||||
console.warn(
|
||||
`[json-render] Unknown slot "${slotName}" on component "${resolvedElement.type}". Available slots: ${metadata.slots.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const renderChildKeys = (childKeys: string[], slotName?: string) =>
|
||||
childKeys
|
||||
.map((childKey) => {
|
||||
const childElement = props.spec.elements[childKey];
|
||||
if (!childElement) {
|
||||
if (!props.loading) {
|
||||
const location = slotName
|
||||
? `in slot "${slotName}" of "${resolvedElement.type}"`
|
||||
: `as child of "${resolvedElement.type}"`;
|
||||
console.warn(
|
||||
`[json-render] Missing element "${childKey}" referenced ${location}. This element will not render.`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return h(ElementRenderer, {
|
||||
key: childKey,
|
||||
element: childElement,
|
||||
elementKey: childKey,
|
||||
spec: props.spec,
|
||||
registry: props.registry,
|
||||
loading: props.loading,
|
||||
fallback: props.fallback,
|
||||
});
|
||||
})
|
||||
.filter((node): node is VNode => node !== null);
|
||||
|
||||
// Render children
|
||||
const childrenVNodes: VNode | VNode[] | undefined = resolvedElement.repeat
|
||||
? h(RepeatChildren, {
|
||||
@@ -455,20 +404,28 @@ const ElementRenderer = defineComponent({
|
||||
loading: props.loading,
|
||||
fallback: props.fallback,
|
||||
})
|
||||
: resolvedElement.children
|
||||
? renderChildKeys(resolvedElement.children)
|
||||
: undefined;
|
||||
|
||||
const namedSlots = resolvedElement.slots
|
||||
? Object.fromEntries(
|
||||
Object.entries(resolvedElement.slots)
|
||||
.filter(([slotName]) => slotName !== "default")
|
||||
.map(([slotName, childKeys]) => [
|
||||
slotName,
|
||||
() => renderChildKeys(childKeys, slotName),
|
||||
]),
|
||||
)
|
||||
: {};
|
||||
: (resolvedElement.children
|
||||
?.map((childKey) => {
|
||||
const childElement = props.spec.elements[childKey];
|
||||
if (!childElement) {
|
||||
if (!props.loading) {
|
||||
console.warn(
|
||||
`[json-render] Missing element "${childKey}" referenced as child of "${resolvedElement.type}". This element will not render.`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return h(ElementRenderer, {
|
||||
key: childKey,
|
||||
element: childElement,
|
||||
elementKey: childKey,
|
||||
spec: props.spec,
|
||||
registry: props.registry,
|
||||
loading: props.loading,
|
||||
fallback: props.fallback,
|
||||
});
|
||||
})
|
||||
.filter((n): n is VNode => n !== null) ?? undefined);
|
||||
|
||||
const componentVNode = h(
|
||||
Component,
|
||||
@@ -479,7 +436,7 @@ const ElementRenderer = defineComponent({
|
||||
bindings: elementBindings,
|
||||
loading: props.loading,
|
||||
},
|
||||
{ default: () => childrenVNodes, ...namedSlots },
|
||||
{ default: () => childrenVNodes },
|
||||
);
|
||||
|
||||
// When devtools is mounted, wrap each element in a transparent span so
|
||||
@@ -826,7 +783,6 @@ type DefineRegistryOptions<C extends Catalog> = {
|
||||
type DefineRegistryComponentFn = (ctx: {
|
||||
props: unknown;
|
||||
children?: VNode | VNode[];
|
||||
slots: Slots;
|
||||
emit: (event: string) => void;
|
||||
on: (event: string) => EventHandle;
|
||||
bindings?: Record<string, string>;
|
||||
@@ -859,7 +815,7 @@ type DefineRegistryActionFn = (
|
||||
* ```
|
||||
*/
|
||||
export function defineRegistry<C extends Catalog>(
|
||||
catalog: C,
|
||||
_catalog: C,
|
||||
options: DefineRegistryOptions<C>,
|
||||
): DefineRegistryResult {
|
||||
const registry: ComponentRegistry = {};
|
||||
@@ -895,7 +851,6 @@ export function defineRegistry<C extends Catalog>(
|
||||
(componentFn as DefineRegistryComponentFn)({
|
||||
props: registryProps.element.props,
|
||||
children: slots.default?.(),
|
||||
slots,
|
||||
emit: registryProps.emit,
|
||||
on: registryProps.on,
|
||||
bindings: registryProps.bindings,
|
||||
@@ -905,14 +860,6 @@ export function defineRegistry<C extends Catalog>(
|
||||
});
|
||||
}
|
||||
}
|
||||
const catalogComponents = (
|
||||
catalog as {
|
||||
data?: { components?: Record<string, { slots?: string[] }> };
|
||||
}
|
||||
).data?.components;
|
||||
if (catalogComponents) {
|
||||
registryMetadata.set(registry, catalogComponents);
|
||||
}
|
||||
|
||||
const actionMap = options.actions
|
||||
? (Object.entries(options.actions) as Array<
|
||||
@@ -1010,12 +957,6 @@ export function createRenderer<
|
||||
): Component {
|
||||
const registry: ComponentRegistry =
|
||||
components as unknown as ComponentRegistry;
|
||||
const catalogComponents = (
|
||||
catalog.data as { components?: Record<string, { slots?: string[] }> }
|
||||
).components;
|
||||
if (catalogComponents) {
|
||||
registryMetadata.set(registry, catalogComponents);
|
||||
}
|
||||
|
||||
return defineComponent({
|
||||
name: "CatalogRenderer",
|
||||
|
||||
@@ -22,8 +22,6 @@ export const schema = defineSchema(
|
||||
props: s.propsOf("catalog.components"),
|
||||
/** Child element keys (flat reference) */
|
||||
children: s.array(s.string()),
|
||||
/** Named slots mapped to child element keys */
|
||||
slots: { ...s.record(s.array(s.string())), ...s.optional() },
|
||||
/** Visibility condition */
|
||||
visible: { ...s.any(), ...s.optional() },
|
||||
/** Repeat children from a state array */
|
||||
@@ -82,7 +80,6 @@ export const schema = defineSchema(
|
||||
"CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.",
|
||||
"SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.",
|
||||
'REQUIRED FIELDS: Every element MUST include a "children" array. Leaf elements (text, badges, inputs, images) use an empty array: "children": []. Omitting "children" fails validation.',
|
||||
'NAMED SLOTS: Use "children" for the default slot. For other slots declared by the component, use a top-level "slots" object that maps each slot name to child element keys, for example {"slots":{"header":["heading"],"footer":["actions"]}}. Never use "slots.default". Every referenced key must exist.',
|
||||
|
||||
// Field placement
|
||||
'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"<ComponentName>","props":{},"visible":{"$state":"/tab","eq":"home"},"children":[...]}.',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/xstate",
|
||||
"version": "0.20.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "XState Store adapter for json-render StateStore",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/yaml",
|
||||
"version": "0.20.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "YAML wire format for @json-render/core. Progressive rendering and surgical edits via streaming YAML.",
|
||||
"keywords": [
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user