mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-04 12:58:17 +08:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ad20d3ede | ||
|
|
6c7164342a | ||
|
|
ea3326046f | ||
|
|
a4d033cf04 |
@@ -131,6 +131,7 @@ 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 |
|
||||
@@ -536,6 +537,54 @@ 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
|
||||
|
||||
@@ -451,6 +451,8 @@ 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
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
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`, `emit`, `on`, and `loading` with catalog-inferred types.
|
||||
Create a type-safe component registry from a catalog. Components receive `props`, `children`, `slots`, `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,8 +105,12 @@ import { defineRegistry } from "@json-render/vue";
|
||||
|
||||
const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: ({ props, children }) =>
|
||||
h("div", { class: "card" }, [h("h3", null, props.title), children]),
|
||||
Layout: ({ slots }) =>
|
||||
h("div", { class: "layout" }, [
|
||||
h("header", null, slots.header?.()),
|
||||
h("main", null, slots.default?.()),
|
||||
h("footer", null, slots.footer?.()),
|
||||
]),
|
||||
Button: ({ props, emit }) =>
|
||||
h("button", { onClick: () => emit("press") }, props.label),
|
||||
},
|
||||
@@ -136,11 +140,12 @@ const { registry } = defineRegistry(catalog, {
|
||||
### Component Props (via defineRegistry)
|
||||
|
||||
```typescript
|
||||
import type { VNode } from "vue";
|
||||
import type { Slots, 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;
|
||||
@@ -154,6 +159,22 @@ 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
|
||||
|
||||
@@ -62,6 +62,15 @@ 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>
|
||||
@@ -213,6 +222,32 @@ 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,6 +10,7 @@ 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.
|
||||
@@ -31,6 +32,7 @@ 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
|
||||
@@ -58,6 +60,10 @@ 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.
|
||||
|
||||
@@ -206,7 +206,7 @@ Each element in the map has a consistent shape:
|
||||
- `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 currently supported by `@json-render/react`.
|
||||
- `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
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render
|
||||
|
||||
GitHub repository: https://github.com/vercel-labs/json-render
|
||||
Documentation: https://json-render.dev/docs
|
||||
npm packages: @json-render/core, @json-render/react, @json-render/next, @json-render/ink, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/shadcn-svelte, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/directives, @json-render/codegen, @json-render/devtools, @json-render/devtools-react, @json-render/devtools-vue, @json-render/devtools-svelte, @json-render/devtools-solid, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate, @json-render/yaml
|
||||
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, next, ink, react-pdf, react-email, react-native, shadcn, shadcn-svelte, react-three-fiber, image, remotion, vue, svelte, solid, directives, codegen, devtools, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details.
|
||||
npm packages: @json-render/core, @json-render/react, @json-render/next, @json-render/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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -72,6 +72,10 @@ 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,6 +46,7 @@ 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",
|
||||
|
||||
@@ -126,6 +126,8 @@ 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
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createStateStore, flattenToPointers } from "./state-store";
|
||||
import {
|
||||
createStateStore,
|
||||
flattenToPointers,
|
||||
immutableSetByPath,
|
||||
} from "./state-store";
|
||||
|
||||
describe("createStateStore", () => {
|
||||
it("creates a store with initial state", () => {
|
||||
@@ -135,6 +139,36 @@ 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,5 +1,6 @@
|
||||
import {
|
||||
getByPath,
|
||||
isSafeJsonPointerPath,
|
||||
parseJsonPointer,
|
||||
type StateModel,
|
||||
type StateStore,
|
||||
@@ -15,6 +16,8 @@ export function immutableSetByPath(
|
||||
path: string,
|
||||
value: unknown,
|
||||
): StateModel {
|
||||
if (!isSafeJsonPointerPath(path)) return root;
|
||||
|
||||
const segments = parseJsonPointer(path);
|
||||
if (segments.length === 0) return root;
|
||||
|
||||
@@ -72,6 +75,7 @@ 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();
|
||||
@@ -81,6 +85,7 @@ 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;
|
||||
@@ -137,6 +142,7 @@ 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));
|
||||
@@ -146,6 +152,7 @@ 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,6 +215,67 @@ 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)
|
||||
// =============================================================================
|
||||
|
||||
@@ -280,6 +280,26 @@ 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)
|
||||
*/
|
||||
@@ -289,6 +309,7 @@ export function getByPath(obj: unknown, path: string): unknown {
|
||||
}
|
||||
|
||||
const segments = parseJsonPointer(path);
|
||||
if (hasBlockedJsonPointerToken(segments)) return undefined;
|
||||
|
||||
let current: unknown = obj;
|
||||
|
||||
@@ -362,7 +383,7 @@ export function setByPath(
|
||||
): void {
|
||||
const segments = parseJsonPointer(path);
|
||||
|
||||
if (segments.length === 0) return;
|
||||
if (segments.length === 0 || hasBlockedJsonPointerToken(segments)) return;
|
||||
|
||||
let current: Record<string, unknown> | unknown[] = obj;
|
||||
|
||||
@@ -412,7 +433,7 @@ export function addByPath(
|
||||
): void {
|
||||
const segments = parseJsonPointer(path);
|
||||
|
||||
if (segments.length === 0) return;
|
||||
if (segments.length === 0 || hasBlockedJsonPointerToken(segments)) return;
|
||||
|
||||
let current: Record<string, unknown> | unknown[] = obj;
|
||||
|
||||
@@ -458,7 +479,7 @@ export function addByPath(
|
||||
export function removeByPath(obj: Record<string, unknown>, path: string): void {
|
||||
const segments = parseJsonPointer(path);
|
||||
|
||||
if (segments.length === 0) return;
|
||||
if (segments.length === 0 || hasBlockedJsonPointerToken(segments)) return;
|
||||
|
||||
let current: Record<string, unknown> | unknown[] = obj;
|
||||
|
||||
@@ -617,6 +638,15 @@ 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);
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface ValidationContextValue {
|
||||
}
|
||||
|
||||
const ValidationContext = createContext<ValidationContextValue | null>(null);
|
||||
const EMPTY_VALIDATION_FUNCTIONS: Record<string, ValidationFunction> = {};
|
||||
|
||||
/**
|
||||
* Props for ValidationProvider
|
||||
@@ -127,7 +128,7 @@ function validationConfigEqual(
|
||||
* Provider for validation
|
||||
*/
|
||||
export function ValidationProvider({
|
||||
customFunctions = {},
|
||||
customFunctions = EMPTY_VALIDATION_FUNCTIONS,
|
||||
children,
|
||||
}: ValidationProviderProps) {
|
||||
const { state, getSnapshot } = useStateStore();
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -93,6 +93,7 @@ const registryMetadata = new WeakMap<
|
||||
ComponentRegistry,
|
||||
Record<string, { slots?: string[] }>
|
||||
>();
|
||||
const EMPTY_ELEMENT_PROPS: Record<string, unknown> = {};
|
||||
|
||||
/**
|
||||
* Props for the Renderer component
|
||||
@@ -115,6 +116,7 @@ export interface RendererProps {
|
||||
|
||||
interface ElementErrorBoundaryProps {
|
||||
elementType: string;
|
||||
resetKey: number | undefined;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -143,6 +145,12 @@ 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
|
||||
@@ -186,8 +194,262 @@ 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.
|
||||
@@ -204,13 +466,14 @@ function useDevtoolsActive(): boolean {
|
||||
* Element renderer component.
|
||||
* Memoized to prevent re-rendering all repeat children when state changes.
|
||||
*/
|
||||
const ElementRenderer = React.memo(function ElementRenderer({
|
||||
function ReactiveElementRenderer({
|
||||
element,
|
||||
elementKey,
|
||||
spec,
|
||||
registry,
|
||||
loading,
|
||||
fallback,
|
||||
signatures,
|
||||
}: ElementRendererProps) {
|
||||
const devtoolsActive = useDevtoolsActive();
|
||||
const repeatScope = useRepeatScope();
|
||||
@@ -311,6 +574,10 @@ const ElementRenderer = React.memo(function ElementRenderer({
|
||||
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;
|
||||
@@ -384,11 +651,20 @@ const ElementRenderer = React.memo(function ElementRenderer({
|
||||
}
|
||||
|
||||
// Resolve $bindState/$bindItem expressions → bindings map (prop name → state path)
|
||||
const rawProps = element.props as Record<string, unknown>;
|
||||
const elementBindings = resolveBindings(rawProps, fullCtx);
|
||||
const rawProps =
|
||||
(element.props as Record<string, unknown> | undefined) ??
|
||||
EMPTY_ELEMENT_PROPS;
|
||||
const elementBindings = stabilizeRecord(
|
||||
resolveBindings(rawProps, fullCtx),
|
||||
stableBindingsRef,
|
||||
);
|
||||
|
||||
// Resolve dynamic prop expressions ($state, $item, $index, $bindState, $bindItem, $cond/$then/$else)
|
||||
const resolvedProps = resolveElementProps(rawProps, fullCtx);
|
||||
const resolvedProps = shareResolvedValue(
|
||||
stableResolvedPropsRef.current,
|
||||
resolveElementProps(rawProps, fullCtx),
|
||||
) as Record<string, unknown>;
|
||||
stableResolvedPropsRef.current = resolvedProps;
|
||||
|
||||
const resolvedElement =
|
||||
resolvedProps !== element.props
|
||||
@@ -442,6 +718,7 @@ const ElementRenderer = React.memo(function ElementRenderer({
|
||||
registry={registry}
|
||||
loading={loading}
|
||||
fallback={fallback}
|
||||
signatures={signatures}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -454,6 +731,7 @@ const ElementRenderer = React.memo(function ElementRenderer({
|
||||
loading={loading}
|
||||
fallback={fallback}
|
||||
itemFilter={repeatItemFilter}
|
||||
signatures={signatures}
|
||||
/>
|
||||
) : resolvedElement.children ? (
|
||||
renderChildKeys(resolvedElement.children)
|
||||
@@ -469,7 +747,13 @@ const ElementRenderer = React.memo(function ElementRenderer({
|
||||
: undefined;
|
||||
|
||||
const rendered = (
|
||||
<Component
|
||||
<CatalogComponentBoundary
|
||||
Component={Component}
|
||||
signature={signatures[elementKey ?? ""]}
|
||||
execute={execute}
|
||||
actionContext={repeatScope}
|
||||
functions={functions}
|
||||
directives={directives}
|
||||
element={resolvedElement}
|
||||
slots={slots}
|
||||
emit={emit}
|
||||
@@ -478,7 +762,7 @@ const ElementRenderer = React.memo(function ElementRenderer({
|
||||
loading={loading}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
</CatalogComponentBoundary>
|
||||
);
|
||||
|
||||
// When devtools is mounted, wrap each element in a transparent span so the
|
||||
@@ -494,11 +778,27 @@ const ElementRenderer = React.memo(function ElementRenderer({
|
||||
);
|
||||
|
||||
return (
|
||||
<ElementErrorBoundary elementType={resolvedElement.type}>
|
||||
<ElementErrorBoundary
|
||||
elementType={resolvedElement.type}
|
||||
resetKey={signatures[elementKey ?? ""]}
|
||||
>
|
||||
{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.
|
||||
@@ -511,6 +811,7 @@ function RepeatChildren({
|
||||
registry,
|
||||
loading,
|
||||
fallback,
|
||||
signatures,
|
||||
itemFilter,
|
||||
}: {
|
||||
element: UIElement;
|
||||
@@ -518,6 +819,7 @@ function RepeatChildren({
|
||||
registry: ComponentRegistry;
|
||||
loading?: boolean;
|
||||
fallback?: ComponentRenderer;
|
||||
signatures: Record<string, number>;
|
||||
itemFilter?: UIElement["visible"];
|
||||
}) {
|
||||
const { state } = useStateStore();
|
||||
@@ -589,6 +891,7 @@ function RepeatChildren({
|
||||
registry={registry}
|
||||
loading={loading}
|
||||
fallback={fallback}
|
||||
signatures={signatures}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -603,6 +906,7 @@ function RepeatChildren({
|
||||
* Main renderer component
|
||||
*/
|
||||
export function Renderer({ spec, registry, loading, fallback }: RendererProps) {
|
||||
const signatures = useElementSignatures(spec);
|
||||
if (!spec || !spec.root) {
|
||||
return null;
|
||||
}
|
||||
@@ -620,6 +924,7 @@ export function Renderer({ spec, registry, loading, fallback }: RendererProps) {
|
||||
registry={registry}
|
||||
loading={loading}
|
||||
fallback={fallback}
|
||||
signatures={signatures}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,689 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
# @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.
|
||||
@@ -0,0 +1,240 @@
|
||||
# @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).
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Catalog-aware types shared with the React renderer. */
|
||||
export type {
|
||||
EventHandle,
|
||||
BaseComponentProps,
|
||||
SetState,
|
||||
StateModel,
|
||||
ComponentContext,
|
||||
ComponentFn,
|
||||
Components,
|
||||
ActionFn,
|
||||
Actions,
|
||||
} from "@json-render/react";
|
||||
@@ -0,0 +1,29 @@
|
||||
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;
|
||||
@@ -0,0 +1,65 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
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} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
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];
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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é",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"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";
|
||||
@@ -0,0 +1,73 @@
|
||||
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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
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%" }],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
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("/")}`;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
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;
|
||||
@@ -0,0 +1,37 @@
|
||||
/** 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";
|
||||
@@ -0,0 +1,52 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
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[]>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@internal/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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,
|
||||
},
|
||||
]);
|
||||
+38
-5
@@ -26,6 +26,7 @@ export const catalog = defineCatalog(schema, {
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
}),
|
||||
slots: ["default", "header", "footer"],
|
||||
description: "A card container",
|
||||
},
|
||||
Button: {
|
||||
@@ -53,7 +54,7 @@ export const catalog = defineCatalog(schema, {
|
||||
|
||||
### 2. Define Component Implementations
|
||||
|
||||
Components are written using Vue's `h()` render function. `children` is a `VNode | VNode[]` — pass it directly to your container element.
|
||||
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?.()`.
|
||||
|
||||
`defineRegistry` conditionally requires the `actions` field only when the catalog declares actions. Catalogs with `actions: {}` can omit it entirely.
|
||||
|
||||
@@ -64,11 +65,12 @@ import { catalog } from "./catalog";
|
||||
|
||||
export const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: ({ props, children }) =>
|
||||
Card: ({ props, slots }) =>
|
||||
h("div", { class: "card" }, [
|
||||
h("h3", null, props.title),
|
||||
h("header", null, slots.header?.() ?? h("h3", null, props.title)),
|
||||
props.description ? h("p", null, props.description) : null,
|
||||
children,
|
||||
slots.default?.(),
|
||||
h("footer", null, slots.footer?.()),
|
||||
]),
|
||||
Button: ({ props, emit }) =>
|
||||
h("button", { onClick: () => emit("press") }, props.label),
|
||||
@@ -131,6 +133,7 @@ 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
|
||||
}
|
||||
```
|
||||
@@ -163,6 +166,35 @@ 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
|
||||
@@ -366,11 +398,12 @@ The `setState`, `pushState`, `removeState`, and `validateForm` actions are built
|
||||
When using `defineRegistry`, components receive these props via their render function:
|
||||
|
||||
```typescript
|
||||
import type { VNode } from "vue";
|
||||
import type { Slots, 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,4 +1,4 @@
|
||||
import type { VNode } from "vue";
|
||||
import type { Slots, VNode } from "vue";
|
||||
import type {
|
||||
Catalog,
|
||||
InferCatalogComponents,
|
||||
@@ -59,6 +59,8 @@ 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,6 +128,31 @@ 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,12 +1,18 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import { defineComponent, h, type Component } from "vue";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { defineCatalog, type Spec } from "@json-render/core";
|
||||
import { z } from "zod";
|
||||
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
|
||||
@@ -68,6 +74,23 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -111,6 +134,122 @@ 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 = {
|
||||
@@ -134,6 +273,99 @@ 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,6 +11,7 @@ import {
|
||||
type Component,
|
||||
type ComputedRef,
|
||||
type PropType,
|
||||
type Slots,
|
||||
type VNode,
|
||||
} from "vue";
|
||||
import type {
|
||||
@@ -83,6 +84,11 @@ 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
|
||||
*/
|
||||
@@ -395,6 +401,51 @@ 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, {
|
||||
@@ -404,28 +455,20 @@ const ElementRenderer = defineComponent({
|
||||
loading: props.loading,
|
||||
fallback: props.fallback,
|
||||
})
|
||||
: (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);
|
||||
: 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),
|
||||
]),
|
||||
)
|
||||
: {};
|
||||
|
||||
const componentVNode = h(
|
||||
Component,
|
||||
@@ -436,7 +479,7 @@ const ElementRenderer = defineComponent({
|
||||
bindings: elementBindings,
|
||||
loading: props.loading,
|
||||
},
|
||||
{ default: () => childrenVNodes },
|
||||
{ default: () => childrenVNodes, ...namedSlots },
|
||||
);
|
||||
|
||||
// When devtools is mounted, wrap each element in a transparent span so
|
||||
@@ -783,6 +826,7 @@ 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>;
|
||||
@@ -815,7 +859,7 @@ type DefineRegistryActionFn = (
|
||||
* ```
|
||||
*/
|
||||
export function defineRegistry<C extends Catalog>(
|
||||
_catalog: C,
|
||||
catalog: C,
|
||||
options: DefineRegistryOptions<C>,
|
||||
): DefineRegistryResult {
|
||||
const registry: ComponentRegistry = {};
|
||||
@@ -851,6 +895,7 @@ 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,
|
||||
@@ -860,6 +905,14 @@ 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<
|
||||
@@ -957,6 +1010,12 @@ 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,6 +22,8 @@ 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 */
|
||||
@@ -80,6 +82,7 @@ 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":[...]}.',
|
||||
|
||||
Generated
+109
@@ -2568,6 +2568,37 @@ importers:
|
||||
specifier: ^5.4.5
|
||||
version: 5.9.3
|
||||
|
||||
packages/tanstack-start:
|
||||
dependencies:
|
||||
'@json-render/core':
|
||||
specifier: workspace:*
|
||||
version: link:../core
|
||||
'@json-render/react':
|
||||
specifier: workspace:*
|
||||
version: link:../react
|
||||
react:
|
||||
specifier: ^19.2.3
|
||||
version: 19.2.4
|
||||
devDependencies:
|
||||
'@internal/typescript-config':
|
||||
specifier: workspace:*
|
||||
version: link:../typescript-config
|
||||
'@tanstack/react-router':
|
||||
specifier: 1.170.32
|
||||
version: 1.170.32(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@types/react':
|
||||
specifier: 19.2.14
|
||||
version: 19.2.14
|
||||
tsup:
|
||||
specifier: ^8.5.1
|
||||
version: 8.5.1(jiti@2.7.0)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0)
|
||||
typescript:
|
||||
specifier: ^5.4.5
|
||||
version: 5.9.3
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
|
||||
packages/typescript-config: {}
|
||||
|
||||
packages/ui:
|
||||
@@ -7324,6 +7355,30 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7
|
||||
|
||||
'@tanstack/history@1.162.1':
|
||||
resolution: {integrity: sha512-DR9t6lfLVdrjgCwpglrR9DR7Ok8/HlXjcOE+goWXF3zyuLUO/ug7vMbSFxTqrQTtbRghJfyhmIZ0S6LhPIy44w==}
|
||||
engines: {node: '>=20.19'}
|
||||
|
||||
'@tanstack/react-router@1.170.32':
|
||||
resolution: {integrity: sha512-SIpxvaTKco100a5ZR3ePmArbhtm3XOx+w1dpGYY9gxHDta4iXSKDdQuhLonwJbIMkVJsU1rwXf0UDHMrF/1snw==}
|
||||
engines: {node: '>=20.19'}
|
||||
peerDependencies:
|
||||
react: '>=18.0.0 || >=19.0.0'
|
||||
react-dom: '>=18.0.0 || >=19.0.0'
|
||||
|
||||
'@tanstack/react-store@0.9.3':
|
||||
resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/router-core@1.171.27':
|
||||
resolution: {integrity: sha512-wDwSLvoLwIaNcnx9UNcN9Mb7Y8QwCYq1U1RQZwyN186gnkIoIYI2SOxy8VqH1vFigbkHkk4FmwMAQlghPgDK2g==}
|
||||
engines: {node: '>=20.19'}
|
||||
|
||||
'@tanstack/store@0.9.3':
|
||||
resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -8880,6 +8935,9 @@ packages:
|
||||
resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
cookie-es@3.1.1:
|
||||
resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==}
|
||||
|
||||
cookie-signature@1.2.2:
|
||||
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
|
||||
engines: {node: '>=6.6.0'}
|
||||
@@ -10898,6 +10956,10 @@ packages:
|
||||
isarray@2.0.5:
|
||||
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
|
||||
|
||||
isbot@5.2.2:
|
||||
resolution: {integrity: sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
@@ -13417,10 +13479,20 @@ packages:
|
||||
peerDependencies:
|
||||
seroval: ^1.0
|
||||
|
||||
seroval-plugins@1.6.4:
|
||||
resolution: {integrity: sha512-R0f1U9hmn38+dFMz6b6ab8lwucmw4AtiY7St+JPWudy1dm+Bs3g884nyrsH9Cy6rKpZKLYayXuMda9GZ/fl8JQ==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
seroval: ^1.0
|
||||
|
||||
seroval@1.5.1:
|
||||
resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
seroval@1.6.4:
|
||||
resolution: {integrity: sha512-LErWMNS2RRFdu2RMA5u/PA59/IWs0XsikyEXGQ2/36iEWFrdG0ABmg17E17cikrv76891kOAMq3TkTFXpwAHXw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
serve-static@1.16.3:
|
||||
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -21250,6 +21322,33 @@ snapshots:
|
||||
tailwindcss: 4.2.1
|
||||
vite: 7.3.1(@types/node@22.19.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
|
||||
|
||||
'@tanstack/history@1.162.1': {}
|
||||
|
||||
'@tanstack/react-router@1.170.32(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@tanstack/history': 1.162.1
|
||||
'@tanstack/react-store': 0.9.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@tanstack/router-core': 1.171.27
|
||||
isbot: 5.2.2
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
'@tanstack/react-store@0.9.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@tanstack/store': 0.9.3
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
|
||||
'@tanstack/router-core@1.171.27':
|
||||
dependencies:
|
||||
'@tanstack/history': 1.162.1
|
||||
cookie-es: 3.1.1
|
||||
seroval: 1.6.4
|
||||
seroval-plugins: 1.6.4(seroval@1.6.4)
|
||||
|
||||
'@tanstack/store@0.9.3': {}
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
@@ -23067,6 +23166,8 @@ snapshots:
|
||||
|
||||
convert-to-spaces@2.0.1: {}
|
||||
|
||||
cookie-es@3.1.1: {}
|
||||
|
||||
cookie-signature@1.2.2: {}
|
||||
|
||||
cookie@0.6.0: {}
|
||||
@@ -25575,6 +25676,8 @@ snapshots:
|
||||
|
||||
isarray@2.0.5: {}
|
||||
|
||||
isbot@5.2.2: {}
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
isexe@3.1.5: {}
|
||||
@@ -29179,8 +29282,14 @@ snapshots:
|
||||
dependencies:
|
||||
seroval: 1.5.1
|
||||
|
||||
seroval-plugins@1.6.4(seroval@1.6.4):
|
||||
dependencies:
|
||||
seroval: 1.6.4
|
||||
|
||||
seroval@1.5.1: {}
|
||||
|
||||
seroval@1.6.4: {}
|
||||
|
||||
serve-static@1.16.3:
|
||||
dependencies:
|
||||
encodeurl: 2.0.0
|
||||
|
||||
@@ -77,6 +77,8 @@ const { result, newPatches } = compiler.push(chunk);
|
||||
const finalSpec = compiler.getResult();
|
||||
```
|
||||
|
||||
JSON Pointer paths containing `__proto__`, `constructor`, or `prototype` tokens are rejected by path utilities, state stores, and SpecStream. For `move` and `copy`, this applies to both `path` and `from`.
|
||||
|
||||
## Dynamic Prop Expressions
|
||||
|
||||
Any prop value can be a dynamic expression resolved at render time:
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
---
|
||||
name: tanstack-start
|
||||
description: Build JSON-defined TanStack Start applications with @json-render/tanstack-start. Use for Start route specs, splat routing, SSR loaders, head metadata, layouts, and client navigation. Do not use for generic TanStack Router apps that do not use json-render.
|
||||
---
|
||||
|
||||
# @json-render/tanstack-start
|
||||
|
||||
Use this integration when a TanStack Start app needs complete pages or routes
|
||||
described by json-render specs.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @json-render/core @json-render/react @json-render/tanstack-start
|
||||
```
|
||||
|
||||
## Application Spec
|
||||
|
||||
Include the server-safe built-in definitions in the generation catalog. The
|
||||
renderer supplies their component implementations.
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import {
|
||||
schema,
|
||||
startComponentDefinitions,
|
||||
} from "@json-render/tanstack-start/server";
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
...startComponentDefinitions,
|
||||
Card: cardDefinition,
|
||||
Shell: shellDefinition,
|
||||
Navigation: navigationDefinition,
|
||||
Home: homeDefinition,
|
||||
Post: postDefinition,
|
||||
},
|
||||
actions: {},
|
||||
});
|
||||
```
|
||||
|
||||
Then use `StartAppSpec` and TanStack Router route patterns:
|
||||
|
||||
```typescript
|
||||
import type { StartAppSpec } from "@json-render/tanstack-start";
|
||||
|
||||
export const spec: StartAppSpec = {
|
||||
metadata: {
|
||||
title: { default: "Site", template: "%s | Site" },
|
||||
},
|
||||
layouts: {
|
||||
main: {
|
||||
root: "shell",
|
||||
elements: {
|
||||
shell: { type: "Shell", props: {}, children: ["nav", "slot"] },
|
||||
nav: { type: "Navigation", props: {}, children: [] },
|
||||
slot: { type: "Slot", props: {}, children: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
routes: {
|
||||
"/": {
|
||||
layout: "main",
|
||||
metadata: { title: "Home" },
|
||||
page: {
|
||||
root: "home",
|
||||
elements: {
|
||||
home: { type: "Home", props: {}, children: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/posts/$slug": {
|
||||
layout: "main",
|
||||
loader: "post",
|
||||
staticParams: [{ slug: "hello" }],
|
||||
page: {
|
||||
root: "post",
|
||||
elements: {
|
||||
post: {
|
||||
type: "Post",
|
||||
props: { value: { $state: "/post" } },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Routes use `/posts/$slug` for named parameters and `/docs/$` for a splat.
|
||||
Splat loader parameters are slash-delimited strings under `_splat`. Escape
|
||||
route-key slashes as `~1` when generating RFC 6902 patches.
|
||||
|
||||
Every layout needs a `Slot` element. Declare `Slot` and `Link` through
|
||||
`startComponentDefinitions`; do not require consumers to register React
|
||||
implementations for them.
|
||||
|
||||
## Server Helpers
|
||||
|
||||
```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) }),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
State merge precedence is application state, layout state, page state, then
|
||||
loader data. `getHead` merges app and route metadata into TanStack `meta` and
|
||||
`links` descriptors. `getStaticPaths` includes static routes plus dynamic
|
||||
routes with `staticParams`. Convert its strings to `{ path }` objects for
|
||||
TanStack Start's top-level `pages` plugin option. Loader params are URL-decoded,
|
||||
while values from `staticParams` are URL-encoded in generated paths.
|
||||
Route matching treats trailing slashes as optional and accepts encoded or
|
||||
decoded pathname representations so loader data and metadata resolve the same
|
||||
static route.
|
||||
|
||||
## Route Wiring
|
||||
|
||||
```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: () => <PageRenderer {...Route.useLoaderData()} />,
|
||||
pendingComponent: StartLoading,
|
||||
errorComponent: StartErrorBoundary,
|
||||
notFoundComponent: StartNotFound,
|
||||
});
|
||||
```
|
||||
|
||||
TanStack Router loaders run on both the server and client. If a spec factory or
|
||||
named loader uses credentials, database clients, or server-only imports, wrap
|
||||
`getPageData` and `getHead` in a TanStack Start `createServerFn`; do not import
|
||||
that server code directly into an isomorphic route loader.
|
||||
|
||||
## Root Provider
|
||||
|
||||
Wrap the root route's `Outlet` with `StartAppProvider`. Render `HeadContent` so
|
||||
metadata from `getHead` reaches the document.
|
||||
|
||||
```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>
|
||||
),
|
||||
});
|
||||
```
|
||||
|
||||
Use `StartLoading`, `StartErrorBoundary`, and `StartNotFound` for TanStack
|
||||
Router's `pendingComponent`, `errorComponent`, and `notFoundComponent` options.
|
||||
When `StartAppProvider` receives `spec`, each component selects the matched
|
||||
route's corresponding fallback. Explicit fallback props override that lookup.
|
||||
Pass named `$computed` implementations through `StartAppProvider.functions`.
|
||||
The default error boundary invalidates the router and reruns a failed loader
|
||||
when the user selects **Try again**.
|
||||
|
||||
Import React components from `@json-render/tanstack-start`. Import `schema`,
|
||||
`createStartApp`, `matchRoute`, `resolveMetadata`, and static path helpers from
|
||||
`@json-render/tanstack-start/server`.
|
||||
@@ -28,8 +28,14 @@ export const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Card: {
|
||||
props: z.object({ title: z.string(), description: z.string().nullable() }),
|
||||
slots: ["default"],
|
||||
description: "A card container",
|
||||
},
|
||||
Layout: {
|
||||
props: z.object({}),
|
||||
slots: ["default", "header", "footer"],
|
||||
description: "Layout with named content regions",
|
||||
},
|
||||
Button: {
|
||||
props: z.object({ label: z.string(), action: z.string() }),
|
||||
description: "A clickable button",
|
||||
@@ -54,12 +60,36 @@ export const { registry } = defineRegistry(catalog, {
|
||||
props.description ? h("p", null, props.description) : null,
|
||||
children,
|
||||
]),
|
||||
Layout: ({ slots }) =>
|
||||
h("div", null, [
|
||||
h("header", null, slots.header?.()),
|
||||
h("main", null, slots.default?.()),
|
||||
h("footer", null, slots.footer?.()),
|
||||
]),
|
||||
Button: ({ props, emit }) =>
|
||||
h("button", { onClick: () => emit("press") }, props.label),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Named Slots
|
||||
|
||||
Use `children` for the `"default"` slot. Use the element's top-level `slots` object for other slot names declared by the catalog:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Layout",
|
||||
"props": {},
|
||||
"children": ["main"],
|
||||
"slots": {
|
||||
"header": ["heading"],
|
||||
"footer": ["actions"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Registry components receive Vue-native slot functions. Render them with `slots.header?.()`, `slots.footer?.()`, and so on. `slots.default?.()` renders the spec's `children`; the `children` context field 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`.
|
||||
|
||||
### Render Specs
|
||||
|
||||
```vue
|
||||
|
||||
Reference in New Issue
Block a user