diff --git a/.changeset/config.json b/.changeset/config.json index e67a77d1..e022652c 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -22,7 +22,8 @@ "@json-render/svelte", "@json-render/solid", "@json-render/react-three-fiber", - "@json-render/yaml" + "@json-render/yaml", + "@json-render/astro" ] ], "linked": [], diff --git a/.changeset/v0-15-astro-release.md b/.changeset/v0-15-astro-release.md new file mode 100644 index 00000000..c170e3f6 --- /dev/null +++ b/.changeset/v0-15-astro-release.md @@ -0,0 +1,18 @@ +--- +"@json-render/core": minor +"@json-render/astro": minor +--- + +Add @json-render/astro renderer + +### New: + +- **@json-render/astro**: Astro renderer with `defineRegistry` + `` for `.astro` components. Same API pattern as React, Vue, Svelte, and Solid renderers. +- `defineRegistry(catalog, { components })`: create a typed registry from a catalog with Astro components +- `` (`@json-render/astro/Renderer.astro`): walks the spec tree and renders each element using real `.astro` files with `` for children +- `` (`@json-render/astro/ElementRenderer.astro`): recursive tree walker using `Astro.self` +- `schema`: element schema with Astro-specific default rules (static HTML, semantic HTML, no interactive actions) +- Works in SSG (build time, no adapter) and SSR (request time, with any adapter: Cloudflare, Netlify, Node, Vercel) +- Astro Islands pattern: static content via `@json-render/astro` + interactive islands via framework renderers (`@json-render/react`, `/vue`, `/svelte`, `/solid`) with `client:*` directives +- Full support for `$state`, `$cond`, `$item`, `$index`, `visible`, and `repeat` expressions +- Astro example project with full static demo and hybrid islands demo (React counter) diff --git a/.gitignore b/.gitignore index 32979a7f..b307af04 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ coverage # Build Outputs .next/ +.astro/ out/ build dist diff --git a/README.md b/README.md index d2b03ecc..fbf30e87 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ npm install @json-render/core @json-render/vue npm install @json-render/core @json-render/svelte # or for SolidJS npm install @json-render/core @json-render/solid +# or for SSR HTML (Astro, Cloudflare Workers, edge) +npm install @json-render/core @json-render/astro # or for 3D scenes npm install @json-render/core @json-render/react-three-fiber @react-three/fiber @react-three/drei three ``` @@ -115,27 +117,28 @@ function Dashboard({ spec }) { ## Packages -| Package | Description | -| --------------------------- | ---------------------------------------------------------------------- | -| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities | -| `@json-render/react` | React renderer, contexts, hooks | -| `@json-render/vue` | Vue 3 renderer, composables, providers | -| `@json-render/svelte` | Svelte 5 renderer with runes-based reactivity | -| `@json-render/solid` | SolidJS renderer with fine-grained reactive contexts | -| `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) | -| `@json-render/react-three-fiber` | React Three Fiber renderer for 3D scenes (19 built-in components) | -| `@json-render/react-native` | React Native renderer with standard mobile components | -| `@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 | -| `@json-render/image` | Image renderer for SVG/PNG output (OG images, social cards) via Satori | -| `@json-render/codegen` | Utilities for generating code from json-render UI trees | -| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` | -| `@json-render/zustand` | Zustand adapter for `StateStore` | -| `@json-render/jotai` | Jotai adapter for `StateStore` | -| `@json-render/xstate` | XState Store (atom) adapter for `StateStore` | -| `@json-render/mcp` | MCP Apps integration for Claude, ChatGPT, Cursor, VS Code | -| `@json-render/yaml` | YAML wire format with streaming parser, edit modes, AI SDK transform | +| Package | Description | +| -------------------------------- | ---------------------------------------------------------------------- | +| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities | +| `@json-render/react` | React renderer, contexts, hooks | +| `@json-render/vue` | Vue 3 renderer, composables, providers | +| `@json-render/svelte` | Svelte 5 renderer with runes-based reactivity | +| `@json-render/solid` | SolidJS renderer with fine-grained reactive contexts | +| `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) | +| `@json-render/react-three-fiber` | React Three Fiber renderer for 3D scenes (19 built-in components) | +| `@json-render/react-native` | React Native renderer with standard mobile components | +| `@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 | +| `@json-render/image` | Image renderer for SVG/PNG output (OG images, social cards) via Satori | +| `@json-render/codegen` | Utilities for generating code from json-render UI trees | +| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` | +| `@json-render/zustand` | Zustand adapter for `StateStore` | +| `@json-render/jotai` | Jotai adapter for `StateStore` | +| `@json-render/xstate` | XState Store (atom) adapter for `StateStore` | +| `@json-render/astro` | Astro renderer for `.astro` components | +| `@json-render/mcp` | MCP Apps integration for Claude, ChatGPT, Cursor, VS Code | +| `@json-render/yaml` | YAML wire format with streaming parser, edit modes, AI SDK transform | ## Renderers @@ -224,6 +227,111 @@ const { registry } = defineRegistry(catalog, { ; ``` +### Astro (UI) + +Static content ships zero JS. Interactive islands use framework renderers hydrated client-side. + +```astro +--- +import Renderer from "@json-render/astro/Renderer.astro"; +import { defineRegistry } from "@json-render/astro"; +import { catalog } from "../lib/catalog"; +import Card from "../components/Card.astro"; +import Text from "../components/Text.astro"; + +const { registry } = defineRegistry(catalog, { + components: { Card, Text }, +}); +--- + + +``` + +#### Astro + React Island + +```tsx +// Counter.tsx — interactive React island using @json-render/react +import { useState, useRef, useMemo } from "react"; +import { + StateProvider, ActionProvider, VisibilityProvider, + Renderer, defineRegistry, +} from "@json-render/react"; +import { schema } from "@json-render/react/schema"; +import { defineCatalog, type Spec } from "@json-render/core"; +import { z } from "zod"; + +const catalog = defineCatalog(schema, { + components: { + Text: { props: z.object({ content: z.string() }), description: "Text" }, + Button: { props: z.object({ label: z.string() }), description: "Button" }, + }, + actions: { + increment: { description: "Increment counter" }, + }, +}); + +const { registry, handlers: createHandlers } = defineRegistry(catalog, { + components: { + Text: ({ props }) => {String(props.content ?? "")}, + Button: ({ props, emit }) => ( + + ), + }, + actions: { + increment: async (_params, setState) => { + setState((prev) => ({ ...prev, count: Number(prev.count || 0) + 1 })); + }, + }, +}); + +const spec: Spec = { + root: "root", + state: { count: 0 }, + elements: { + root: { type: "Text", props: { content: { $state: "/count" } }, children: ["btn"] }, + btn: { type: "Button", props: { label: "+" }, on: { press: { action: "increment" } } }, + }, +}; + +export default function Counter() { + const [state, setState] = useState>(spec.state ?? {}); + const stateRef = useRef(state); + const setStateRef = useRef(setState); + stateRef.current = state; + setStateRef.current = setState; + + const actionHandlers = useMemo( + () => createHandlers(() => setStateRef.current, () => stateRef.current), + [], + ); + + return ( + + + + + + + + ); +} +``` + +```astro +--- +import Renderer from "@json-render/astro/Renderer.astro"; +import Counter from "../components/Counter"; +--- + + + + + + +``` + +Islands also work with Vue (`@json-render/vue`), Svelte (`@json-render/svelte`), and Solid (`@json-render/solid`). + ### shadcn/ui (Web) ```tsx @@ -608,6 +716,7 @@ pnpm dev - Vue Example: run `pnpm dev` in `examples/vue` - Vite Renderers (React + Vue + Svelte + Solid): run `pnpm dev` in `examples/vite-renderers` - React Native example: run `npx expo start` in `examples/react-native` +- Astro SSR Example: run `pnpm dev` in `examples/astro` ## How It Works diff --git a/apps/web/app/(main)/docs/api/astro/page.mdx b/apps/web/app/(main)/docs/api/astro/page.mdx new file mode 100644 index 00000000..1875a158 --- /dev/null +++ b/apps/web/app/(main)/docs/api/astro/page.mdx @@ -0,0 +1,393 @@ +import { pageMetadata } from "@/lib/page-metadata" +export const metadata = pageMetadata("docs/api/astro") + +# @json-render/astro + +Astro renderer for @json-render/core. Static HTML output (zero JS). Works in SSG (build time) and SSR (request time, with any adapter: `@astrojs/cloudflare`, `@astrojs/netlify`, `@astrojs/node`, `@astrojs/vercel`). + +## Install + +```bash +npm install @json-render/core @json-render/astro zod +``` + +Peer dependencies: `astro >=4.0.0` and `zod ^4.0.0`. + +## schema + +The element schema for Astro specs. Use with `defineCatalog` from core. + +```typescript +import { defineCatalog } from '@json-render/core'; +import { schema } from '@json-render/astro'; +import { z } from 'zod'; + +const catalog = defineCatalog(schema, { + components: { + Card: { + props: z.object({ + title: z.string(), + subtitle: z.string().optional(), + }), + description: 'A card container', + }, + Text: { + props: z.object({ content: z.string() }), + description: 'Body text paragraph', + }, + }, +}); +``` + +The Astro schema is static: no actions, no events, no `$bindState`. For interactive features, use an Astro island with a framework renderer. + +## defineRegistry + +Create a typed registry from a catalog with Astro components. Same pattern as `defineRegistry` in `@json-render/react`, `/vue`, `/svelte`, `/solid`. + +{`\`\`\`astro +--- +import { defineRegistry } from '@json-render/astro'; +import { catalog } from '../lib/catalog'; +import Card from '../components/Card.astro'; +import Text from '../components/Text.astro'; + +const { registry } = defineRegistry(catalog, { + components: { Card, Text }, +}); +\`\`\``} + +The returned `registry` is passed to ``. The catalog enforces that all component keys have a matching Astro component. + +## Renderer + +{`\`\`\`astro +--- +import Renderer from '@json-render/astro/Renderer.astro'; +--- + + +\`\`\``} + +### Renderer Props + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PropTypeDescription
specSpecThe json-render spec to render (required)
registryAstroComponentRegistryComponent registry from defineRegistry()
state{"Record"}State for $state/$cond resolution (merged with spec.state)
loadingbooleanSuppresses warnings for missing components/children during streaming
fallbackAstroComponentFactoryRendered when a component type is not found in the registry
+ +## Component Props + +Astro components receive resolved props via `Astro.props` and render children via ``: + +{`\`\`\`astro +--- +// Card.astro +interface Props { title: string; subtitle?: string; } +const { title, subtitle } = Astro.props; +--- + +
+

{title}

+ {subtitle &&

{subtitle}

} + +
+\`\`\``} + +All dynamic expressions (`$state`, `$cond`, `$item`, `$index`, `$template`) are resolved before props reach the component. Astro auto-escapes expressions, so no manual escaping is needed. + +> Astro components produce static HTML. For interactive elements (click handlers, form inputs, client-side state), use an Astro island with a framework renderer. + +## Visibility Conditions + +Use `visible` on elements to show/hide based on state. The syntax is shared across all json-render renderers: + +```typescript +{'// Truthiness check'} +{'{ "$state": "/user/isAdmin" }'} + +{'// Comparisons'} +{'{ "$state": "/status", "eq": "active" }'} +{'{ "$state": "/count", "gt": 10 }'} + +{'// Negation'} +{'{ "$state": "/maintenance", "not": true }'} + +{'// Multiple conditions (implicit AND)'} +{'[{ "$state": "/feature/enabled" }, { "$state": "/maintenance", "not": true }]'} + +{'// Always / never'} +true // always visible +false // never visible +``` + +TypeScript helpers from `@json-render/core`: + +```typescript +import { visibility } from "@json-render/core"; + +visibility.when("/path") // { $state: "/path" } +visibility.unless("/path") // { $state: "/path", not: true } +visibility.eq("/path", val) // { $state: "/path", eq: val } +visibility.and(cond1, cond2) // { $and: [cond1, cond2] } +``` + +## Dynamic Prop Expressions + +Any prop value can use data-driven expressions that resolve at render time. The renderer resolves these transparently before passing props to components. + +```json +{ + "type": "Badge", + "props": { + "label": { "$state": "/user/role" }, + "color": { + "$cond": { "$state": "/user/role", "eq": "admin" }, + "$then": "red", + "$else": "gray" + } + } +} +``` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ExpressionDescription
{'{ "$state": "/path" }'}Read a value from state
{'{ "$cond": ..., "$then": ..., "$else": ... }'}Conditional value based on a visibility condition
{'{ "$template": "Hello, ${/name}!" }'}Interpolate state values into strings
{'{ "$item": "field" }'}Read from current repeat item
{'{ "$index": true }'}Current repeat index
+ +No two-way binding (`$bindState`, `$bindItem`) since Astro is static HTML. For interactive form components, use an Astro island. + +## SSG vs SSR + +The package works in both modes without modification: + +- **SSG** (default, no adapter) -- state is resolved at build time +- **SSR** (with adapter) -- state is resolved at each request + +The choice is a project-level Astro decision, not a package concern. + +## Astro Islands + +Use `@json-render/astro` for static content and framework-specific renderers for interactive islands. + +{`\`\`\`astro +--- +import Renderer from '@json-render/astro/Renderer.astro'; +import Counter from '../components/Counter'; // React, Vue, Svelte, or Solid +--- + + + + + + +\`\`\``} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Island FrameworkAstro Integrationjson-render RendererHydration Directive
React@astrojs/react@json-render/react{"client:load"} or {"client:visible"}
Vue@astrojs/vue@json-render/vue{"client:load"} or {"client:visible"}
Svelte@astrojs/svelte@json-render/svelte{"client:load"} or {"client:visible"}
Solid@astrojs/solid-js@json-render/solid{"client:idle"} or {"client:visible"}
+ +Each island uses its own `@json-render/*` renderer internally (`defineRegistry` + `Renderer`). Static sections rendered by `@json-render/astro` ship zero JavaScript to the browser. + +See the [islands example](https://github.com/vercel-labs/json-render/tree/main/examples/astro/src/pages/islands.astro) for a working implementation with React. + +## Differences from Other Renderers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AspectReact / Vue / Svelte / SolidAstro
OutputClient-side interactive UIStatic HTML (zero JS)
ComponentsFramework components / render functions.astro files with {""}
Actionsemit(), on(), built-in state actionsNone (use islands for interactivity)
State mutations$bindState, setState actionRead-only ($state, $cond)
ProvidersStateProvider, ActionProvider, etc.None (state passed as prop)
Hooks / ComposablesuseStateStore, useActions, etc.None (static rendering)
+ +## Sub-path Exports + + + + + + + + + + + + + + + + + + + + + + + + + + +
ExportDescription
@json-render/astroFull package: schema, defineRegistry, types
@json-render/astro/schemaSchema only
@json-render/astro/Renderer.astroEntry point Astro component
@json-render/astro/ElementRenderer.astroRecursive element renderer
+ +## Types + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ExportDescription
AstroSchemaSchema type
{"AstroSpec"}Infer the spec type from a catalog
AstroComponentRegistryRegistry mapping component names to Astro components
{"Components"}Typed registry for a specific catalog
DefineRegistryResultReturn type of defineRegistry()
StateModelState model type (re-export from core)
diff --git a/apps/web/app/api/docs-chat/route.ts b/apps/web/app/api/docs-chat/route.ts index ae4641c3..ea97ec09 100644 --- a/apps/web/app/api/docs-chat/route.ts +++ b/apps/web/app/api/docs-chat/route.ts @@ -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/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/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 ". Available skills: core, react, react-pdf, react-email, react-native, shadcn, react-three-fiber, image, remotion, vue, svelte, solid, codegen, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details. +npm packages: @json-render/core, @json-render/react, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate, @json-render/yaml, @json-render/astro +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 ". Available skills: core, react, react-pdf, react-email, react-native, shadcn, react-three-fiber, image, remotion, vue, svelte, solid, codegen, mcp, redux, zustand, jotai, xstate, yaml, astro. 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. diff --git a/apps/web/lib/docs-navigation.ts b/apps/web/lib/docs-navigation.ts index 172d579c..67e510d6 100644 --- a/apps/web/lib/docs-navigation.ts +++ b/apps/web/lib/docs-navigation.ts @@ -101,6 +101,11 @@ export const docsNavigation: NavSection[] = [ href: "https://github.com/vercel-labs/json-render/tree/main/examples/mcp", external: true, }, + { + title: "Astro (SSG + Islands)", + href: "https://github.com/vercel-labs/json-render/tree/main/examples/astro", + external: true, + }, ], }, { @@ -145,6 +150,7 @@ export const docsNavigation: NavSection[] = [ { title: "@json-render/jotai", href: "/docs/api/jotai" }, { title: "@json-render/xstate", href: "/docs/api/xstate" }, { title: "@json-render/yaml", href: "/docs/api/yaml" }, + { title: "@json-render/astro", href: "/docs/api/astro" }, ], }, ]; diff --git a/apps/web/lib/page-titles.ts b/apps/web/lib/page-titles.ts index 679408c7..c7c54ebe 100644 --- a/apps/web/lib/page-titles.ts +++ b/apps/web/lib/page-titles.ts @@ -59,6 +59,7 @@ export const PAGE_TITLES: Record = { "docs/api/react-three-fiber": "@json-render/react-three-fiber API", "docs/api/xstate": "@json-render/xstate API", "docs/api/yaml": "@json-render/yaml API", + "docs/api/astro": "@json-render/astro API", }; /** diff --git a/examples/astro/astro.config.mjs b/examples/astro/astro.config.mjs new file mode 100644 index 00000000..0188d701 --- /dev/null +++ b/examples/astro/astro.config.mjs @@ -0,0 +1,6 @@ +import { defineConfig } from "astro/config"; +import react from "@astrojs/react"; + +export default defineConfig({ + integrations: [react()], +}); diff --git a/examples/astro/package.json b/examples/astro/package.json new file mode 100644 index 00000000..6b150e60 --- /dev/null +++ b/examples/astro/package.json @@ -0,0 +1,28 @@ +{ + "name": "example-astro", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)", + "dev": "portless astro-demo.json-render astro dev", + "build": "astro build", + "preview": "astro preview", + "check-types": "astro check" + }, + "dependencies": { + "@astrojs/react": "^5.0.0", + "@json-render/core": "workspace:*", + "@json-render/astro": "workspace:*", + "@json-render/react": "workspace:*", + "astro": "^6.0.4", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "typescript": "^5.9.3" + } +} diff --git a/examples/astro/src/components/AstroLink.astro b/examples/astro/src/components/AstroLink.astro new file mode 100644 index 00000000..2d3dd0ba --- /dev/null +++ b/examples/astro/src/components/AstroLink.astro @@ -0,0 +1,10 @@ +--- +interface Props { + href: string; + text: string; +} + +const { href, text } = Astro.props; +--- + +{text} diff --git a/examples/astro/src/components/AstroText.astro b/examples/astro/src/components/AstroText.astro new file mode 100644 index 00000000..c4a7cbb1 --- /dev/null +++ b/examples/astro/src/components/AstroText.astro @@ -0,0 +1,9 @@ +--- +interface Props { + content: string; +} + +const { content } = Astro.props; +--- + +

{content}

diff --git a/examples/astro/src/components/Badge.astro b/examples/astro/src/components/Badge.astro new file mode 100644 index 00000000..a33bad04 --- /dev/null +++ b/examples/astro/src/components/Badge.astro @@ -0,0 +1,19 @@ +--- +interface Props { + label: string; + variant?: string | null; +} + +const { label, variant } = Astro.props; + +const colors: Record = { + default: { bg: "#e0f2fe", fg: "#0369a1", border: "#bae6fd" }, + success: { bg: "#dcfce7", fg: "#15803d", border: "#bbf7d0" }, + warning: { bg: "#fef9c3", fg: "#a16207", border: "#fde68a" }, +}; +const c = colors[variant ?? "default"] ?? colors.default!; +--- + + + {label} + diff --git a/examples/astro/src/components/Card.astro b/examples/astro/src/components/Card.astro new file mode 100644 index 00000000..3d071b4d --- /dev/null +++ b/examples/astro/src/components/Card.astro @@ -0,0 +1,14 @@ +--- +interface Props { + title: string; + subtitle?: string | null; +} + +const { title, subtitle } = Astro.props; +--- + +
+

{title}

+ {subtitle &&

{subtitle}

} + +
diff --git a/examples/astro/src/components/Counter.tsx b/examples/astro/src/components/Counter.tsx new file mode 100644 index 00000000..0a11210c --- /dev/null +++ b/examples/astro/src/components/Counter.tsx @@ -0,0 +1,210 @@ +/** + * Interactive React island using @json-render/react. + * + * This component is hydrated client-side via Astro's `client:visible` directive. + * It demonstrates that @json-render/astro (static SSR) and @json-render/react + * (interactive islands) can coexist on the same page. + */ +import { useState, useRef, useMemo } from "react"; +import { + StateProvider, + ActionProvider, + VisibilityProvider, + Renderer, + defineRegistry, +} from "@json-render/react"; +import { schema } from "@json-render/react/schema"; +import { defineCatalog } from "@json-render/core"; +import type { Spec } from "@json-render/core"; +import { z } from "zod"; + +// --- Catalog (what components/actions are available) --- + +const catalog = defineCatalog(schema, { + components: { + Stack: { + props: z.object({ + gap: z.number().optional(), + direction: z.enum(["vertical", "horizontal"]).optional(), + align: z.enum(["start", "center", "end"]).optional(), + }), + description: "Layout container", + }, + Text: { + props: z.object({ + content: z.string(), + size: z.enum(["sm", "md", "lg", "xl"]).optional(), + weight: z.enum(["normal", "bold"]).optional(), + }), + description: "Text display", + }, + Button: { + props: z.object({ + label: z.string(), + variant: z.enum(["primary", "secondary", "danger"]).optional(), + }), + description: "Clickable button", + }, + }, + actions: { + increment: { description: "Increment counter" }, + decrement: { description: "Decrement counter" }, + reset: { description: "Reset counter to 0" }, + }, +}); + +// --- Registry (how components render + action handlers) --- + +const { registry, handlers: createHandlers } = defineRegistry(catalog, { + components: { + Stack: ({ props, children }) => ( +
+ {children} +
+ ), + Text: ({ props }) => ( + + {String(props.content ?? "")} + + ), + Button: ({ props, emit }) => ( + + ), + }, + actions: { + increment: async (_params, setState) => { + setState((prev) => ({ ...prev, count: Number(prev.count || 0) + 1 })); + }, + decrement: async (_params, setState) => { + setState((prev) => ({ + ...prev, + count: Math.max(0, Number(prev.count || 0) - 1), + })); + }, + reset: async (_params, setState) => { + setState((prev) => ({ ...prev, count: 0 })); + }, + }, +}); + +// --- Spec (the UI tree, could be AI-generated) --- + +const counterSpec: Spec = { + root: "root", + state: { count: 0 }, + elements: { + root: { + type: "Stack", + props: { gap: 12, direction: "vertical" }, + children: ["controls", "milestone"], + }, + controls: { + type: "Stack", + props: { gap: 12, direction: "horizontal", align: "center" }, + children: ["dec-btn", "count-text", "inc-btn", "reset-btn"], + }, + "dec-btn": { + type: "Button", + props: { label: "-", variant: "secondary" }, + on: { press: { action: "decrement" } }, + }, + "count-text": { + type: "Text", + props: { content: { $state: "/count" }, size: "xl", weight: "bold" }, + }, + "inc-btn": { + type: "Button", + props: { label: "+", variant: "primary" }, + on: { press: { action: "increment" } }, + }, + "reset-btn": { + type: "Button", + props: { label: "Reset", variant: "danger" }, + on: { press: { action: "reset" } }, + }, + milestone: { + type: "Text", + props: { content: "Milestone: 10!", size: "sm" }, + visible: { $state: "/count", gte: 10 }, + }, + }, +}; + +// --- Island Component --- + +type SetState = ( + updater: (prev: Record) => Record, +) => void; + +export default function Counter() { + const [state, setState] = useState>( + counterSpec.state ?? {}, + ); + + const stateRef = useRef(state); + const setStateRef = useRef(setState); + stateRef.current = state; + setStateRef.current = setState; + + const actionHandlers = useMemo( + () => + createHandlers( + () => setStateRef.current, + () => stateRef.current, + ), + [], + ); + + return ( + + + + + + + + ); +} diff --git a/examples/astro/src/components/Heading.astro b/examples/astro/src/components/Heading.astro new file mode 100644 index 00000000..d8488a7d --- /dev/null +++ b/examples/astro/src/components/Heading.astro @@ -0,0 +1,12 @@ +--- +interface Props { + text: string; + level?: string | null; +} + +const { text, level } = Astro.props; +const validLevels = ["h1", "h2", "h3", "h4", "h5", "h6"]; +const Tag = (validLevels.includes(level ?? "") ? level : "h2") as any; +--- + +{text} diff --git a/examples/astro/src/components/List.astro b/examples/astro/src/components/List.astro new file mode 100644 index 00000000..baaed490 --- /dev/null +++ b/examples/astro/src/components/List.astro @@ -0,0 +1,6 @@ +--- +--- + +
    + +
diff --git a/examples/astro/src/components/ListItem.astro b/examples/astro/src/components/ListItem.astro new file mode 100644 index 00000000..c1793dff --- /dev/null +++ b/examples/astro/src/components/ListItem.astro @@ -0,0 +1,9 @@ +--- +interface Props { + text: string; +} + +const { text } = Astro.props; +--- + +
  • {text}
  • diff --git a/examples/astro/src/components/Section.astro b/examples/astro/src/components/Section.astro new file mode 100644 index 00000000..557a237f --- /dev/null +++ b/examples/astro/src/components/Section.astro @@ -0,0 +1,12 @@ +--- +interface Props { + id?: string | null; + className?: string | null; +} + +const { id, className } = Astro.props; +--- + +
    + +
    diff --git a/examples/astro/src/lib/catalog.ts b/examples/astro/src/lib/catalog.ts new file mode 100644 index 00000000..8d679970 --- /dev/null +++ b/examples/astro/src/lib/catalog.ts @@ -0,0 +1,59 @@ +import { defineCatalog } from "@json-render/core"; +import { schema } from "@json-render/astro"; +import { z } from "zod"; + +export const catalog = defineCatalog(schema, { + components: { + Section: { + props: z.object({ + id: z.string().nullable(), + className: z.string().nullable(), + }), + description: "A semantic section wrapper", + }, + Card: { + props: z.object({ + title: z.string(), + subtitle: z.string().nullable(), + }), + description: "A card container with title and optional subtitle", + }, + Heading: { + props: z.object({ + text: z.string(), + level: z.enum(["h1", "h2", "h3", "h4"]).nullable(), + }), + description: "Heading text at various levels", + }, + Text: { + props: z.object({ + content: z.string(), + }), + description: "Body text paragraph", + }, + Badge: { + props: z.object({ + label: z.string(), + variant: z.enum(["default", "success", "warning"]).nullable(), + }), + description: "A small status badge", + }, + List: { + props: z.object({}), + description: "An unordered list container", + }, + ListItem: { + props: z.object({ + text: z.string(), + }), + description: "A single list item", + }, + Link: { + props: z.object({ + href: z.string(), + text: z.string(), + }), + description: "A hyperlink", + }, + }, +}); diff --git a/examples/astro/src/lib/spec.ts b/examples/astro/src/lib/spec.ts new file mode 100644 index 00000000..5b36e7ca --- /dev/null +++ b/examples/astro/src/lib/spec.ts @@ -0,0 +1,145 @@ +import type { Spec } from "@json-render/core"; + +export const demoSpec: Spec = { + root: "root", + state: { + showBanner: true, + userRole: "admin", + features: [ + { + id: "1", + name: "SSR rendering", + description: "Pure HTML output on the server", + }, + { + id: "2", + name: "Zero dependencies", + description: "No React, Vue, or Svelte required", + }, + { + id: "3", + name: "SSG + SSR", + description: + "Works with any Astro adapter (Cloudflare, Vercel, Netlify, Node)", + }, + ], + }, + elements: { + root: { + type: "Section", + props: { id: "app", className: null }, + children: [ + "header", + "banner", + "features-card", + "admin-card", + "timestamp-card", + "links-card", + ], + }, + + // Header + header: { + type: "Heading", + props: { text: "@json-render/astro demo", level: "h1" }, + children: [], + }, + + // Conditional banner (visible when showBanner is true) + banner: { + type: "Badge", + props: { label: "SSR-rendered at build time", variant: "success" }, + children: [], + visible: { $state: "/showBanner" }, + }, + + // Features card with repeat + "features-card": { + type: "Card", + props: { + title: "Features", + subtitle: "What makes this renderer special", + }, + children: ["features-list"], + }, + "features-list": { + type: "List", + props: {}, + children: ["feature-item"], + repeat: { statePath: "/features", key: "id" }, + }, + "feature-item": { + type: "ListItem", + props: { + text: { $item: "name" }, + }, + children: [], + }, + + // Admin-only card (visible when role is admin) + "admin-card": { + type: "Card", + props: { + title: "Admin Panel", + subtitle: null, + }, + children: ["admin-text"], + visible: { $state: "/userRole", eq: "admin" }, + }, + "admin-text": { + type: "Text", + props: { + content: { + $cond: { $state: "/userRole", eq: "admin" }, + $then: "You have full admin access.", + $else: "Access restricted.", + }, + }, + children: [], + }, + + // Server timestamp (resolved from request-time state) + "timestamp-card": { + type: "Card", + props: { + title: "Server Info", + subtitle: null, + }, + children: ["timestamp-text"], + }, + "timestamp-text": { + type: "Text", + props: { + content: { $state: "/serverTimestamp" }, + }, + children: [], + visible: { $state: "/serverTimestamp" }, + }, + + // Links + "links-card": { + type: "Card", + props: { + title: "Resources", + subtitle: null, + }, + children: ["link-docs", "link-github"], + }, + "link-docs": { + type: "Link", + props: { + href: "https://json-render.dev/docs/api/astro", + text: "Documentation", + }, + children: [], + }, + "link-github": { + type: "Link", + props: { + href: "https://github.com/vercel-labs/json-render/tree/main/examples/astro", + text: "Source code", + }, + children: [], + }, + }, +}; diff --git a/examples/astro/src/pages/index.astro b/examples/astro/src/pages/index.astro new file mode 100644 index 00000000..c7944e51 --- /dev/null +++ b/examples/astro/src/pages/index.astro @@ -0,0 +1,68 @@ +--- +/** + * Full static demo with defineRegistry + Renderer. + * + * Components are real .astro files with syntax highlighting, autocompletion, + * and automatic XSS protection. The walks the JSON spec tree + * and renders each element using the matching Astro component. + */ +import Renderer from "@json-render/astro/Renderer.astro"; +import { defineRegistry } from "@json-render/astro"; +import { catalog } from "../lib/catalog"; +import { demoSpec } from "../lib/spec"; + +import Section from "../components/Section.astro"; +import Card from "../components/Card.astro"; +import Heading from "../components/Heading.astro"; +import AstroText from "../components/AstroText.astro"; +import Badge from "../components/Badge.astro"; +import List from "../components/List.astro"; +import ListItem from "../components/ListItem.astro"; +import AstroLink from "../components/AstroLink.astro"; + +const { registry } = defineRegistry(catalog, { + components: { + Section, + Card, + Heading, + Text: AstroText, + Badge, + List, + ListItem, + Link: AstroLink, + }, +}); + +// Simulate request-time state (e.g. from a database, auth middleware, or API) +const requestState = { + showBanner: true, + userRole: "admin", + serverTimestamp: new Date().toISOString(), +}; +--- + + + + + + @json-render/astro demo + + + + +
    + Rendered at {requestState.serverTimestamp} | + Islands demo +
    + + diff --git a/examples/astro/src/pages/islands.astro b/examples/astro/src/pages/islands.astro new file mode 100644 index 00000000..cd6e4d9a --- /dev/null +++ b/examples/astro/src/pages/islands.astro @@ -0,0 +1,121 @@ +--- +/** + * Hybrid demo: static Astro content + interactive React island. + * + * - Static sections use @json-render/astro with native .astro components (zero JS) + * - The interactive counter uses @json-render/react hydrated client-side + * + * This is the recommended pattern: use @json-render/astro for static content + * and a framework renderer for interactive islands. + */ +import Renderer from "@json-render/astro/Renderer.astro"; +import { defineRegistry } from "@json-render/astro"; +import { catalog } from "../lib/catalog"; +import Counter from "../components/Counter"; + +import Section from "../components/Section.astro"; +import Card from "../components/Card.astro"; +import Heading from "../components/Heading.astro"; +import AstroText from "../components/AstroText.astro"; +import Badge from "../components/Badge.astro"; +import List from "../components/List.astro"; +import ListItem from "../components/ListItem.astro"; +import AstroLink from "../components/AstroLink.astro"; + +const { registry } = defineRegistry(catalog, { + components: { + Section, + Card, + Heading, + Text: AstroText, + Badge, + List, + ListItem, + Link: AstroLink, + }, +}); + +const staticSpec = { + root: "root", + elements: { + root: { + type: "Section", + props: { id: "static-content" }, + children: ["intro-card", "how-card"], + }, + "intro-card": { + type: "Card", + props: { title: "Static SSR Content" }, + children: ["intro-text"], + }, + "intro-text": { + type: "Text", + props: { + content: + "This section is rendered to static HTML on the server using @json-render/astro with native .astro components. No JavaScript is sent to the browser for this content.", + }, + children: [], + }, + "how-card": { + type: "Card", + props: { title: "How Islands Work" }, + children: ["how-text"], + }, + "how-text": { + type: "Text", + props: { + content: + "The counter below is a React island hydrated with client:visible. It uses @json-render/react with defineRegistry for interactive rendering. The rest of the page stays as static HTML.", + }, + children: [], + }, + }, +}; +--- + + + + + + Astro Islands - @json-render/astro + + + +
    +

    Astro Islands Demo

    +

    + Static SSR via @json-render/astro + + interactive island via @json-render/react +

    + + + + + +
    +
    React Island (client:visible)
    + +
    + +

    + Full static demo +

    +
    + + diff --git a/examples/astro/tsconfig.json b/examples/astro/tsconfig.json new file mode 100644 index 00000000..0fc51d71 --- /dev/null +++ b/examples/astro/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "strictNullChecks": true + } +} diff --git a/packages/astro/README.md b/packages/astro/README.md new file mode 100644 index 00000000..b9936ace --- /dev/null +++ b/packages/astro/README.md @@ -0,0 +1,354 @@ +# @json-render/astro + +Astro renderer for json-render. Turn JSON specs into Astro components with data binding and visibility. + +## Installation + +```bash +npm install @json-render/core @json-render/astro zod +``` + +Peer dependencies: `astro >=4.0.0` and `zod ^4.0.0`. + +```bash +# For interactive islands, add a framework renderer: +npm install @json-render/react # + @astrojs/react +npm install @json-render/vue # + @astrojs/vue +npm install @json-render/svelte # + @astrojs/svelte +npm install @json-render/solid # + @astrojs/solid-js +``` + +## Quick Start + +### 1. Create a Catalog + +```typescript +import { defineCatalog } from "@json-render/core"; +import { schema } from "@json-render/astro"; +import { z } from "zod"; + +export const catalog = defineCatalog(schema, { + components: { + Card: { + props: z.object({ + title: z.string(), + subtitle: z.string().optional(), + }), + description: "A card container", + }, + Text: { + props: z.object({ content: z.string() }), + description: "A text block", + }, + }, +}); +``` + +### 2. Write Components as `.astro` Files + +```astro +--- +// Card.astro +interface Props { title: string; subtitle?: string; } +const { title, subtitle } = Astro.props; +--- + +
    +

    {title}

    + {subtitle &&

    {subtitle}

    } + +
    +``` + +### 3. Create a Registry and Render + +```astro +--- +// src/pages/index.astro +import Renderer from "@json-render/astro/Renderer.astro"; +import { defineRegistry } from "@json-render/astro"; +import { catalog } from "../lib/catalog"; +import Card from "../components/Card.astro"; +import Text from "../components/Text.astro"; + +const { registry } = defineRegistry(catalog, { + components: { Card, Text }, +}); + +const spec = await getSpec(); // from DB, CMS, AI, etc. +--- + + +``` + +The `` walks the spec tree and renders each element using the matching Astro component. Props are resolved automatically (`$state`, `$cond`, `$item`, `$index`, `$template`) and children are passed via ``. + +### Renderer Props + +- `spec` (`Spec`) -- the json-render spec to render (required) +- `registry` (`AstroComponentRegistry`) -- component registry from `defineRegistry()` +- `state` (`Record`, optional) -- state for `$state`/`$cond` resolution (merged with `spec.state`) +- `loading` (`boolean`, optional) -- suppresses warnings for missing components/children during streaming +- `fallback` (`AstroComponentFactory`, optional) -- rendered when a component type is not found in the registry + +## Spec Format + +The Astro renderer uses the same flat element map format as the React, Vue, Svelte, and Solid renderers: + +```typescript +interface Spec { + root: string; // Key of the root element + elements: Record; // Flat map of elements by key + state?: Record; // Optional initial state +} + +interface UIElement { + type: string; // Component name from catalog + props: Record; // Component props + children?: string[]; // Keys of child elements + visible?: VisibilityCondition; // Visibility condition + repeat?: RepeatConfig; // Repeat over a state array +} +``` + +Example spec: + +```json +{ + "root": "card-1", + "elements": { + "card-1": { + "type": "Card", + "props": { "title": "Welcome" }, + "children": ["text-1"] + }, + "text-1": { + "type": "Text", + "props": { "content": "Hello from Astro!" }, + "children": [] + } + } +} +``` + +## Visibility Conditions + +```typescript +// Truthiness check +{ "$state": "/user/isAdmin" } + +// Comparisons (flat style) +{ "$state": "/status", "eq": "active" } +{ "$state": "/count", "gt": 10 } + +// Negation +{ "$state": "/maintenance", "not": true } + +// Multiple conditions (implicit AND) +[ + { "$state": "/feature/enabled" }, + { "$state": "/maintenance", "not": true } +] + +// Always / never +true // always visible +false // never visible +``` + +TypeScript helpers from `@json-render/core`: + +```typescript +import { visibility } from "@json-render/core"; + +visibility.when("/path") // { $state: "/path" } +visibility.unless("/path") // { $state: "/path", not: true } +visibility.eq("/path", val) // { $state: "/path", eq: val } +visibility.neq("/path", val) // { $state: "/path", neq: val } +visibility.and(cond1, cond2) // { $and: [cond1, cond2] } +visibility.always // true +visibility.never // false +``` + +## Dynamic Prop Expressions + +Any prop value can use data-driven expressions that resolve at render time. The renderer resolves these transparently before passing props to components. + +```json +{ + "type": "Badge", + "props": { + "label": { "$state": "/user/role" }, + "color": { + "$cond": { "$state": "/user/role", "eq": "admin" }, + "$then": "red", + "$else": "gray" + } + } +} +``` + +See [@json-render/core](../core/README.md) for full expression syntax. + +## Component Props + +Astro components receive resolved props via `Astro.props` and render children via ``: + +```astro +--- +// Badge.astro +interface Props { + label: string; + color?: string; +} +const { label, color = "gray" } = Astro.props; +--- + +{label} +``` + +All dynamic expressions (`$state`, `$cond`, `$item`, `$index`, `$template`) are resolved before props reach the component. Astro auto-escapes expressions, so no manual XSS protection is needed. + +> **Note:** Astro components are static HTML. For interactive elements (click handlers, form inputs, client-side state), use an Astro island with a framework renderer (`@json-render/react`, `/vue`, `/svelte`, `/solid`). + +## SSG vs SSR + +The package works in both modes without modification: + +- **SSG** (default, no adapter) -- state is resolved at build time. Ideal for static or pre-known content. +- **SSR** (with adapter) -- state is resolved at each request. Enables dynamic content (DB data, auth, etc.). + +The choice is a project-level Astro decision, not a package concern. No adapter is required for the package to work. + +## Astro Islands + +Use `@json-render/astro` for static content and framework renderers for interactive islands. + +```astro +--- +import Renderer from "@json-render/astro/Renderer.astro"; +import Counter from "../components/Counter"; // React component + +const { registry } = defineRegistry(catalog, { + components: { Section, Card, Heading, Text }, +}); +--- + + + + + + +``` + +Each island component uses its own `@json-render/*` renderer internally (e.g., `defineRegistry` + `Renderer` from `@json-render/react`). The static sections ship zero JavaScript to the browser. + +Works with any Astro framework integration: React, Vue, Svelte, Solid. + +See the [islands example](https://github.com/vercel-labs/json-render/tree/main/examples/astro/src/pages/islands.astro) for a working implementation with React. + +## Generate AI Prompts + +```typescript +const systemPrompt = catalog.prompt(); +// Returns detailed prompt with component descriptions and Astro-specific rules +``` + +## Full Example + +```astro +--- +// src/pages/index.astro +import { defineCatalog } from "@json-render/core"; +import { schema, defineRegistry } from "@json-render/astro"; +import Renderer from "@json-render/astro/Renderer.astro"; +import { z } from "zod"; + +// 1. Catalog +const catalog = defineCatalog(schema, { + components: { + Greeting: { + props: z.object({ name: z.string() }), + description: "Displays a greeting", + }, + }, +}); + +// 2. Component (inline for brevity; normally a separate .astro file) +import GreetingComponent from "../components/Greeting.astro"; + +// 3. Registry +const { registry } = defineRegistry(catalog, { + components: { Greeting: GreetingComponent }, +}); + +// 4. Spec +const spec = { + root: "greeting-1", + elements: { + "greeting-1": { + type: "Greeting", + props: { name: "World" }, + children: [], + }, + }, +}; +--- + + +``` + +```astro +--- +// src/components/Greeting.astro +interface Props { name: string; } +const { name } = Astro.props; +--- + +

    Hello, {name}!

    +``` + +## Key Exports + +| Export | Purpose | +|--------|---------| +| `defineRegistry` | Create a type-safe component registry from a catalog | +| `schema` | Element tree schema (static HTML, no actions) | + +### Sub-path Exports + +| Export | Purpose | +|--------|---------| +| `@json-render/astro` | Full package: schema, defineRegistry, types | +| `@json-render/astro/schema` | Schema only | +| `@json-render/astro/Renderer.astro` | Entry point Astro component | +| `@json-render/astro/ElementRenderer.astro` | Recursive element renderer | + +### Types + +| Export | Purpose | +|--------|---------| +| `AstroSchema` | Schema type | +| `AstroSpec` | Infer the spec type from a catalog | +| `AstroComponentRegistry` | Registry mapping component names to Astro components | +| `Components` | Typed registry for a specific catalog | +| `DefineRegistryResult` | Return type of `defineRegistry()` | +| `StateModel` | State model type (re-export from core) | + +## Differences from Other Renderers + +| Aspect | React / Vue / Svelte / Solid | Astro | +|--------|------------------------------|-------| +| Output | Client-side interactive UI | Static HTML (zero JS) | +| Components | Framework components / render functions | `.astro` files with `` | +| Actions | `emit()`, `on()`, built-in state actions | None (use islands for interactivity) | +| State mutations | `$bindState`, `setState` action | Read-only (`$state`, `$cond`) | +| Providers | StateProvider, ActionProvider, etc. | None (state passed as prop) | +| Hooks / Composables | `useStateStore`, `useActions`, etc. | None (static rendering) | + +## Documentation + +Full API reference: [json-render.dev/docs/api/astro](https://json-render.dev/docs/api/astro). + +## License + +Apache-2.0 diff --git a/packages/astro/package.json b/packages/astro/package.json new file mode 100644 index 00000000..089b36a3 --- /dev/null +++ b/packages/astro/package.json @@ -0,0 +1,79 @@ +{ + "name": "@json-render/astro", + "version": "0.1.0", + "license": "Apache-2.0", + "description": "Astro renderer for @json-render/core. JSON becomes Astro components.", + "keywords": [ + "json", + "ui", + "astro", + "ai", + "generative-ui", + "llm", + "renderer", + "streaming", + "components", + "islands", + "ssr", + "ssg" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/vercel-labs/json-render.git", + "directory": "packages/astro" + }, + "homepage": "https://json-render.dev", + "bugs": { + "url": "https://github.com/vercel-labs/json-render/issues" + }, + "publishConfig": { + "access": "public" + }, + "type": "module", + "astro": "./dist/index.js", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./schema": { + "types": "./dist/schema.d.ts", + "import": "./dist/schema.js", + "require": "./dist/schema.cjs" + }, + "./Renderer.astro": "./src/Renderer.astro", + "./ElementRenderer.astro": "./src/ElementRenderer.astro" + }, + "files": [ + "dist", + "src/Renderer.astro", + "src/ElementRenderer.astro" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@json-render/core": "workspace:*" + }, + "devDependencies": { + "@internal/typescript-config": "workspace:*", + "astro": "^6.0.4", + "tsup": "^8.0.2", + "typescript": "^5.4.5" + }, + "peerDependencies": { + "astro": ">=4.0.0", + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "astro": { + "optional": true + } + } +} diff --git a/packages/astro/src/ElementRenderer.astro b/packages/astro/src/ElementRenderer.astro new file mode 100644 index 00000000..a4166bc6 --- /dev/null +++ b/packages/astro/src/ElementRenderer.astro @@ -0,0 +1,142 @@ +--- +/** + * Internal recursive element renderer. + * Uses Astro.self for recursion (same pattern as Svelte's `import Self`). + */ +import type { UIElement, Spec } from '@json-render/core'; +import type { AstroComponentFactory } from 'astro/runtime/server/index.js'; +import { + evaluateVisibility, + resolveElementProps, + getByPath, + type PropResolutionContext, +} from '@json-render/core'; + +interface Props { + element: UIElement; + spec: Spec; + registry: Record; + stateModel: Record; + repeatItem?: unknown; + repeatIndex?: number; + repeatBasePath?: string; + /** Whether the spec is currently loading/streaming */ + loading?: boolean; + /** Fallback component rendered when a type is not found in the registry */ + fallback?: AstroComponentFactory; +} + +const { element, spec, registry, stateModel, repeatItem, repeatIndex, repeatBasePath, loading, fallback } = Astro.props; + +const ctx: PropResolutionContext = { stateModel, repeatItem, repeatIndex, repeatBasePath }; + +// Evaluate visibility +const isVisible = element.visible === undefined + ? true + : evaluateVisibility(element.visible, ctx); + +// Lookup component in registry, fall back to fallback component +const Component = isVisible ? (registry[element.type] ?? fallback ?? null) : null; + +if (isVisible && !registry[element.type] && !loading) { + console.warn(`[json-render/astro] Component type "${element.type}" not found in registry`); +} + +const childKeys = element.children ?? []; +const repeat = element.repeat; + +// Pre-compute repeat data in frontmatter (Astro template can't do complex TS) +let repeatData: Array<{ + item: unknown; + index: number; + key: string; + basePath: string; + props: Record; +}> | null = null; + +let resolvedProps: Record = {}; + +if (isVisible && Component) { + if (repeat) { + // Repeat: resolve props PER ITERATION so $item/$index are available + const items = (getByPath(stateModel, repeat.statePath) as unknown[] | undefined) ?? []; + repeatData = items.map((item, index) => { + const basePath = `${repeat.statePath}/${index}`; + // Use a stable key: prefer key field, fall back to index + const key = repeat.key && typeof item === 'object' && item !== null + ? String((item as Record)[repeat.key] ?? index) + : String(index); + const iterCtx: PropResolutionContext = { + stateModel, + repeatItem: item, + repeatIndex: index, + repeatBasePath: basePath, + }; + return { + item, + index, + key, + basePath, + props: resolveElementProps(element.props as Record, iterCtx), + }; + }); + } else { + // Non-repeat: resolve props once + resolvedProps = resolveElementProps(element.props as Record, ctx); + } +} +--- + +{isVisible && Component && repeatData ? ( + repeatData.map(({ item, index, key, basePath, props: itemProps }) => ( + + {childKeys.map((childKey) => { + const child = spec.elements[childKey]; + if (!child) { + if (!loading) { + console.warn(`[json-render/astro] Missing element "${childKey}" referenced as child of "${element.type}" (repeat)`); + } + return null; + } + return ( + + ); + })} + + )) +) : isVisible && Component ? ( + + {childKeys.map((childKey) => { + const child = spec.elements[childKey]; + if (!child) { + if (!loading) { + console.warn(`[json-render/astro] Missing element "${childKey}" referenced as child of "${element.type}"`); + } + return null; + } + return ( + + ); + })} + +) : null} diff --git a/packages/astro/src/Renderer.astro b/packages/astro/src/Renderer.astro new file mode 100644 index 00000000..bc8a0d04 --- /dev/null +++ b/packages/astro/src/Renderer.astro @@ -0,0 +1,46 @@ +--- +/** + * Astro renderer for json-render specs. + * + * Renders the spec tree to static HTML using Astro components from the registry. + * Works in both SSG (build time) and SSR (request time) modes. + * + * For interactive elements, use a framework renderer as an Astro island: + * + * + */ +import type { Spec } from '@json-render/core'; +import type { AstroComponentFactory } from 'astro/runtime/server/index.js'; +import ElementRenderer from './ElementRenderer.astro'; + +interface Props { + spec: Spec; + registry: Record; + state?: Record; + /** Whether the spec is currently loading/streaming */ + loading?: boolean; + /** Fallback component rendered when a type is not found in the registry */ + fallback?: AstroComponentFactory; +} + +const { spec, registry, state, loading, fallback } = Astro.props; + +if (!spec?.root) return; + +const rootElement = spec.elements[spec.root]; +if (!rootElement) { + console.warn(`[json-render/astro] Root element "${spec.root}" not found`); + return; +} + +const mergedState: Record = { ...spec.state, ...state }; +--- + + diff --git a/packages/astro/src/catalog-types.ts b/packages/astro/src/catalog-types.ts new file mode 100644 index 00000000..54e8fd3e --- /dev/null +++ b/packages/astro/src/catalog-types.ts @@ -0,0 +1,39 @@ +import type { AstroComponentFactory } from "astro/runtime/server/index.js"; +import type { + Catalog, + InferCatalogComponents, + StateModel, +} from "@json-render/core"; + +export type { StateModel }; + +/** + * Registry mapping component type names to Astro components. + * + * Uses `AstroComponentFactory` — the compiled type that all `.astro` files + * produce. Catalog Zod schemas provide the runtime props validation layer. + * + * @example + * ```ts + * const registry: AstroComponentRegistry = { + * Card: CardComponent, + * Hero: HeroComponent, + * }; + * ``` + */ +export type AstroComponentRegistry = Record; + +/** + * Typed registry of all Astro components for a catalog. + * Keys are enforced by the catalog, values are Astro component factories. + * + * @example + * ```ts + * const { registry } = defineRegistry(catalog, { + * components: { Card, Hero, Badge }, + * }); + * ``` + */ +export type Components = { + [K in keyof InferCatalogComponents]: AstroComponentFactory; +}; diff --git a/packages/astro/src/index.ts b/packages/astro/src/index.ts new file mode 100644 index 00000000..aa9d322f --- /dev/null +++ b/packages/astro/src/index.ts @@ -0,0 +1,16 @@ +// Schema +export { schema, type AstroSchema, type AstroSpec } from "./schema"; + +// Core re-exports +export type { Spec } from "@json-render/core"; +export { defineCatalog } from "@json-render/core"; + +// Types +export type { + AstroComponentRegistry, + Components, + StateModel, +} from "./catalog-types"; + +// Registry +export { defineRegistry, type DefineRegistryResult } from "./renderer"; diff --git a/packages/astro/src/renderer.test.ts b/packages/astro/src/renderer.test.ts new file mode 100644 index 00000000..35e85cd7 --- /dev/null +++ b/packages/astro/src/renderer.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import { defineRegistry } from "./renderer"; + +describe("defineRegistry", () => { + it("returns a registry object", () => { + const mockCard = (() => {}) as any; + const mockText = (() => {}) as any; + + const { registry } = defineRegistry(null as any, { + components: { Card: mockCard, Text: mockText }, + }); + + expect(registry).toBeDefined(); + expect(typeof registry).toBe("object"); + }); + + it("maps component names to their Astro component factories", () => { + const mockCard = (() => {}) as any; + const mockText = (() => {}) as any; + + const { registry } = defineRegistry(null as any, { + components: { Card: mockCard, Text: mockText }, + }); + + expect(registry.Card).toBe(mockCard); + expect(registry.Text).toBe(mockText); + }); + + it("handles empty components", () => { + const { registry } = defineRegistry(null as any, { + components: {} as any, + }); + + expect(registry).toEqual({}); + }); + + it("handles single component", () => { + const mockHero = (() => {}) as any; + + const { registry } = defineRegistry(null as any, { + components: { Hero: mockHero } as any, + }); + + expect(registry.Hero).toBe(mockHero); + expect(Object.keys(registry)).toHaveLength(1); + }); + + it("returns DefineRegistryResult shape", () => { + const result = defineRegistry(null as any, { components: {} as any }); + expect(result).toHaveProperty("registry"); + expect(Object.keys(result)).toEqual(["registry"]); + }); +}); diff --git a/packages/astro/src/renderer.ts b/packages/astro/src/renderer.ts new file mode 100644 index 00000000..78f532cd --- /dev/null +++ b/packages/astro/src/renderer.ts @@ -0,0 +1,29 @@ +import type { Catalog } from "@json-render/core"; +import type { AstroComponentRegistry, Components } from "./catalog-types"; + +export interface DefineRegistryResult { + registry: AstroComponentRegistry; +} + +/** + * Create a typed registry from a catalog with Astro components. + * + * @example + * ```ts + * import { defineRegistry } from '@json-render/astro'; + * import Hero from './components/Hero.astro'; + * import Card from './components/Card.astro'; + * + * const { registry } = defineRegistry(catalog, { + * components: { Hero, Card }, + * }); + * ``` + */ +export function defineRegistry( + _catalog: C, + options: { + components: Components; + }, +): DefineRegistryResult { + return { registry: options.components as AstroComponentRegistry }; +} diff --git a/packages/astro/src/schema.test.ts b/packages/astro/src/schema.test.ts new file mode 100644 index 00000000..e07b484f --- /dev/null +++ b/packages/astro/src/schema.test.ts @@ -0,0 +1,459 @@ +import { describe, it, expect } from "vitest"; +import { z } from "zod"; +import { defineCatalog } from "@json-render/core"; +import { schema } from "./schema"; + +// ============================================================================= +// schema structure +// ============================================================================= + +describe("schema", () => { + it("has spec and catalog definitions", () => { + expect(schema.definition).toBeDefined(); + expect(schema.definition.spec.kind).toBe("object"); + expect(schema.definition.catalog.kind).toBe("object"); + }); + + it("has defaultRules", () => { + expect(schema.defaultRules).toBeDefined(); + expect(Array.isArray(schema.defaultRules)).toBe(true); + expect(schema.defaultRules!.length).toBeGreaterThan(0); + }); + + it("includes Astro-specific static HTML rule", () => { + const rules = schema.defaultRules ?? []; + const hasStaticRule = rules.some( + (r) => r.includes("static HTML") && r.includes("Astro island"), + ); + expect(hasStaticRule).toBe(true); + }); + + it("exposes createCatalog method", () => { + expect(typeof schema.createCatalog).toBe("function"); + }); + + it("includes element integrity rules", () => { + const rules = schema.defaultRules ?? []; + const hasIntegrityRule = rules.some((r) => r.includes("INTEGRITY CHECK")); + expect(hasIntegrityRule).toBe(true); + }); + + it("includes semantic HTML rules", () => { + const rules = schema.defaultRules ?? []; + const hasSemanticRule = rules.some((r) => r.includes("semantic HTML")); + expect(hasSemanticRule).toBe(true); + }); + + it("includes repeat field rules", () => { + const rules = schema.defaultRules ?? []; + const hasRepeatRule = rules.some((r) => r.includes("repeat")); + expect(hasRepeatRule).toBe(true); + }); +}); + +// ============================================================================= +// defineCatalog with Astro schema +// ============================================================================= + +describe("defineCatalog (astro)", () => { + it("creates catalog with componentNames", () => { + const catalog = defineCatalog(schema, { + components: { + Card: { + props: z.object({ title: z.string() }), + description: "A card container", + }, + Text: { + props: z.object({ content: z.string() }), + description: "Body text", + }, + }, + }); + + expect(catalog.componentNames).toEqual(["Card", "Text"]); + }); + + it("has no actionNames (Astro is static)", () => { + const catalog = defineCatalog(schema, { + components: { + Card: { + props: z.object({ title: z.string() }), + description: "A card", + }, + }, + }); + + // Astro schema doesn't define actions in the catalog + expect(catalog.actionNames).toEqual([]); + }); + + it("handles empty components", () => { + const catalog = defineCatalog(schema, { + components: {}, + }); + expect(catalog.componentNames).toEqual([]); + }); + + it("exposes the schema on the catalog", () => { + const catalog = defineCatalog(schema, { + components: {}, + }); + expect(catalog.schema).toBe(schema); + }); + + it("exposes catalog data", () => { + const data = { + components: { + Badge: { + props: z.object({ label: z.string() }), + description: "A badge", + }, + }, + }; + const catalog = defineCatalog(schema, data); + expect(catalog.data).toBe(data); + }); + + it("is equivalent to schema.createCatalog", () => { + const catalogData = { + components: { + Card: { + props: z.object({ title: z.string() }), + description: "A card", + slots: ["default" as const], + }, + }, + }; + + const a = defineCatalog(schema, catalogData); + const b = schema.createCatalog(catalogData); + + expect(a.componentNames).toEqual(b.componentNames); + expect(a.data).toBe(b.data); + }); + + it("accepts components with slots", () => { + const catalog = defineCatalog(schema, { + components: { + Layout: { + props: z.object({}), + description: "Layout with named slots", + slots: ["header", "footer", "default"], + }, + }, + }); + expect(catalog.componentNames).toEqual(["Layout"]); + expect(catalog.data.components.Layout.slots).toEqual([ + "header", + "footer", + "default", + ]); + }); +}); + +// ============================================================================= +// catalog.prompt() +// ============================================================================= + +describe("catalog.prompt (astro)", () => { + const catalog = defineCatalog(schema, { + components: { + Card: { + props: z.object({ title: z.string(), subtitle: z.string().optional() }), + description: "A card container", + slots: ["default"], + }, + Text: { + props: z.object({ content: z.string() }), + description: "A text block", + }, + }, + }); + + it("includes AVAILABLE COMPONENTS section", () => { + const prompt = catalog.prompt(); + expect(prompt).toContain("AVAILABLE COMPONENTS"); + expect(prompt).toContain("Card"); + expect(prompt).toContain("Text"); + expect(prompt).toContain("A card container"); + }); + + it("includes prop type signatures", () => { + const prompt = catalog.prompt(); + expect(prompt).toContain("title: string"); + expect(prompt).toContain("content: string"); + }); + + it("includes Astro-specific defaultRules", () => { + const prompt = catalog.prompt(); + expect(prompt).toContain("static HTML"); + expect(prompt).toContain("Astro island"); + }); + + it("does not include AVAILABLE ACTIONS (Astro has none)", () => { + const prompt = catalog.prompt(); + expect(prompt).not.toContain("AVAILABLE ACTIONS"); + }); + + it("uses custom system message when provided", () => { + const prompt = catalog.prompt({ system: "You build Astro pages." }); + expect(prompt).toContain("You build Astro pages."); + }); + + it("appends customRules to prompt", () => { + const prompt = catalog.prompt({ + customRules: ["Always use semantic HTML", "Keep it accessible"], + }); + expect(prompt).toContain("Always use semantic HTML"); + expect(prompt).toContain("Keep it accessible"); + }); + + it("generates example props from Zod schemas", () => { + const prompt = catalog.prompt(); + expect(prompt).toContain('"title":"example"'); + expect(prompt).toContain('"content":"example"'); + }); + + it("uses explicit example over Zod-generated values", () => { + const catalogWithExample = defineCatalog(schema, { + components: { + Heading: { + props: z.object({ text: z.string(), level: z.enum(["h1", "h2"]) }), + description: "A heading", + example: { text: "Welcome", level: "h1" }, + }, + }, + }); + const prompt = catalogWithExample.prompt(); + expect(prompt).toContain('"text":"Welcome"'); + expect(prompt).toContain('"level":"h1"'); + }); + + it("uses actual catalog component names in examples", () => { + const prompt = catalog.prompt(); + expect(prompt).toContain('"type":"Card"'); + expect(prompt).toContain('"type":"Text"'); + }); + + it("does not include hardcoded component names not in catalog", () => { + const prompt = catalog.prompt(); + const hardcoded = ["Stack", "Grid", "Heading", "Column", "Pressable"]; + for (const comp of hardcoded) { + expect(prompt).not.toContain(`"type":"${comp}"`); + } + }); + + it("generates standalone mode prompt by default", () => { + const prompt = catalog.prompt(); + expect(prompt).toContain("Output ONLY JSONL patches"); + expect(prompt).not.toContain("conversationally"); + }); + + it("generates inline mode prompt when mode is inline", () => { + const prompt = catalog.prompt({ mode: "inline" }); + expect(prompt).toContain("conversationally"); + }); + + it("contains sections for state, repeat, and visibility", () => { + const prompt = catalog.prompt(); + expect(prompt).toContain("INITIAL STATE:"); + expect(prompt).toContain("DYNAMIC LISTS (repeat field):"); + expect(prompt).toContain("VISIBILITY CONDITIONS:"); + expect(prompt).toContain("DYNAMIC PROPS:"); + expect(prompt).toContain("RULES:"); + }); +}); + +// ============================================================================= +// catalog.validate() +// ============================================================================= + +describe("catalog.validate (astro)", () => { + const catalog = defineCatalog(schema, { + components: { + Card: { + props: z.object({ title: z.string() }), + description: "A card", + }, + Text: { + props: z.object({ content: z.string() }), + description: "Text", + }, + }, + }); + + it("validates a valid spec", () => { + const spec = { + root: "card-1", + elements: { + "card-1": { + type: "Card", + props: { title: "Hello" }, + children: ["text-1"], + }, + "text-1": { + type: "Text", + props: { content: "World" }, + children: [], + }, + }, + }; + const result = catalog.validate(spec); + expect(result.success).toBe(true); + expect(result.data).toEqual(spec); + }); + + it("rejects spec with wrong root type", () => { + const result = catalog.validate({ root: 123, elements: {} }); + expect(result.success).toBe(false); + }); + + it("rejects spec with missing root", () => { + const result = catalog.validate({ elements: {} }); + expect(result.success).toBe(false); + }); + + it("rejects spec with invalid component type", () => { + const result = catalog.validate({ + root: "x", + elements: { + x: { type: "Unknown", props: {}, children: [] }, + }, + }); + expect(result.success).toBe(false); + }); + + it("validates spec with visibility conditions", () => { + const result = catalog.validate({ + root: "card-1", + elements: { + "card-1": { + type: "Card", + props: { title: "Hello" }, + children: [], + visible: { $state: "/showCard" }, + }, + }, + }); + expect(result.success).toBe(true); + }); + + it("validates spec with nested children", () => { + const result = catalog.validate({ + root: "card", + elements: { + card: { + type: "Card", + props: { title: "Parent" }, + children: ["nested"], + }, + nested: { + type: "Card", + props: { title: "Child" }, + children: ["leaf"], + }, + leaf: { + type: "Text", + props: { content: "Leaf" }, + children: [], + }, + }, + }); + expect(result.success).toBe(true); + }); + + it("validates spec with empty elements", () => { + const result = catalog.validate({ + root: "t", + elements: { + t: { type: "Text", props: { content: "" }, children: [] }, + }, + }); + expect(result.success).toBe(true); + }); + + it("returns error details on failure", () => { + const result = catalog.validate({ root: 123, elements: {} }); + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); +}); + +// ============================================================================= +// catalog.jsonSchema() +// ============================================================================= + +describe("catalog.jsonSchema (astro)", () => { + const catalog = defineCatalog(schema, { + components: { + Text: { + props: z.object({ content: z.string() }), + description: "Text", + }, + }, + }); + + it("returns a JSON Schema object", () => { + const jsonSchema = catalog.jsonSchema(); + expect(jsonSchema).toBeDefined(); + expect(typeof jsonSchema).toBe("object"); + }); + + it("produces strict-mode schema with additionalProperties: false", () => { + const strict = catalog.jsonSchema({ strict: true }); + + function allObjectsHaveNoAdditionalProps(obj: unknown): boolean { + if (typeof obj !== "object" || obj === null) return true; + const record = obj as Record; + if (record.type === "object" && record.additionalProperties !== false) { + return false; + } + return Object.values(record).every(allObjectsHaveNoAdditionalProps); + } + + expect(allObjectsHaveNoAdditionalProps(strict)).toBe(true); + }); +}); + +// ============================================================================= +// catalog.zodSchema() +// ============================================================================= + +describe("catalog.zodSchema (astro)", () => { + const catalog = defineCatalog(schema, { + components: { + Text: { + props: z.object({ content: z.string() }), + description: "Text", + }, + }, + }); + + it("validates valid specs", () => { + const zodSchema = catalog.zodSchema(); + const result = zodSchema.safeParse({ + root: "t", + elements: { + t: { type: "Text", props: { content: "hi" }, children: [] }, + }, + }); + expect(result.success).toBe(true); + }); + + it("rejects invalid specs", () => { + const zodSchema = catalog.zodSchema(); + const result = zodSchema.safeParse({ root: 42 }); + expect(result.success).toBe(false); + }); + + it("rejects spec with unknown component type", () => { + const zodSchema = catalog.zodSchema(); + const result = zodSchema.safeParse({ + root: "x", + elements: { + x: { type: "Unknown", props: {}, children: [] }, + }, + }); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/astro/src/schema.ts b/packages/astro/src/schema.ts new file mode 100644 index 00000000..4b3ab3b0 --- /dev/null +++ b/packages/astro/src/schema.ts @@ -0,0 +1,82 @@ +import { defineSchema } from "@json-render/core"; + +/** + * The schema for @json-render/astro + * + * Defines: + * - Spec: A flat tree of elements with keys, types, props, and children references + * - Catalog: Components with props schemas and optional slots + */ +export const schema = defineSchema( + (s) => ({ + // What the AI-generated SPEC looks like + spec: s.object({ + /** Root element key */ + root: s.string(), + /** Flat map of elements by key */ + elements: s.record( + s.object({ + /** Component type from catalog */ + type: s.ref("catalog.components"), + /** Component props */ + props: s.propsOf("catalog.components"), + /** Child element keys (flat reference) */ + children: s.array(s.string()), + /** Visibility condition */ + visible: s.any(), + }), + ), + }), + + // What the CATALOG must provide + catalog: s.object({ + /** Component definitions */ + components: s.map({ + /** Zod schema for component props */ + props: s.zod(), + /** Slots for this component. Use ['default'] for children, or named slots like ['header', 'footer'] */ + slots: s.array(s.string()), + /** Description for AI generation hints */ + description: s.string(), + /** Example prop values used in prompt examples (auto-generated from Zod schema if omitted) */ + example: s.any(), + }), + }), + }), + { + defaultRules: [ + // Element integrity + "CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.", + "SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.", + + // Field placement + 'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"","props":{},"visible":{"$state":"/tab","eq":"home"},"children":[...]}.', + + // State and data + "When the user asks for a UI that displays data (e.g. blog posts, products, users), ALWAYS include a state field with realistic sample data. The state field is a top-level field on the spec (sibling of root/elements).", + 'When building repeating content backed by a state array (e.g. posts, products, items), use the "repeat" field on a container element. Example: { "type": "", "props": {}, "repeat": { "statePath": "/posts", "key": "id" }, "children": ["post-card"] }. Replace with an appropriate component from the AVAILABLE COMPONENTS list. Inside repeated children, use { "$item": "field" } to read a field from the current item, and { "$index": true } for the current array index.', + + // Design quality + "Design with visual hierarchy: use container components to group content, heading components for section titles, proper spacing, and status indicators. ONLY use components from the AVAILABLE COMPONENTS list.", + "Design with semantic HTML: correct heading hierarchy, ARIA landmarks, alt attributes,