mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-02 12:00:58 +08:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ada149395 | ||
|
|
70d7383394 | ||
|
|
bfab0c4a6c | ||
|
|
1d29a71d8c | ||
|
|
e5f97d4dec | ||
|
|
e13ffc53fc | ||
|
|
6b095856f2 |
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
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
|
||||
|
||||
@@ -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`.
|
||||
Reference in New Issue
Block a user