diff --git a/README.md b/README.md
index 61ad886..3884f61 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,8 @@ component documentation, and published React packages.
- `packages/sheets` — the metadata-driven application CRUD grid.
- `packages/command-palette` — the headless command registry, shortcuts, workflows, and background-task engine.
- `packages/schema-builder` — the source-installable Schema Builder, its host-adapter contract, and its npm package.
-- `packages/blocks-schema` — the `blocks-schema` JSON UI document format, validators, and composition API.
+- `packages/json-renderer` — the framework-agnostic `json-renderer` core: document envelope and node tree, validation, composition, binding resolution, and the renderer adapter contract.
+- `packages/blocks-schema` — the `blocks-schema` JSON UI document format, validators, and composition API, specializing `json-renderer` with the Constructive block vocabulary.
- `packages/blocks-renderer` — the `blocks-renderer` React adapter for those documents.
- `packages/json-schema-to-blocks` — the `json-schema-to-blocks` lowering of JSON Schema into those documents.
- `packages/meta-to-blocks` — the `meta-to-blocks` lowering of database metadata into generated form, list, and detail documents.
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
index 83542e6..2adb78b 100644
--- a/docs/RELEASING.md
+++ b/docs/RELEASING.md
@@ -23,11 +23,12 @@ For the repository cutover, the intended releases are:
- `@constructive-io/command-palette@0.4.0`
- `@constructive-io/schema-builder@0.4.0`
-`blocks-schema`, `blocks-renderer`, `json-schema-to-blocks`, `meta-to-blocks`, and
-`flow-to-blocks` release independently of that cutover set.
+`json-renderer`, `blocks-schema`, `blocks-renderer`, `json-schema-to-blocks`,
+`meta-to-blocks`, and `flow-to-blocks` release independently of that cutover set.
+`blocks-schema` depends on `json-renderer`, so publish `json-renderer` first.
They are built with `makage` and publish from `dist` (`publishConfig.directory`),
so their entry points are root-level files and consumers get deep imports
-(`blocks-schema/compose`) without an exports map. `pnpm pack:check` verifies that
+(`blocks-schema/compose`, `json-renderer/compose`) without an exports map. `pnpm pack:check` verifies that
layout in an isolated consumer, including packed dependents resolving the packed
schema.
diff --git a/package.json b/package.json
index e6ae5f5..1215999 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,7 @@
},
"scripts": {
"build": "lerna run build",
- "build:packages": "pnpm --filter @constructive-io/ui build && pnpm --filter @constructive-io/data build && pnpm --filter @constructive-io/command-palette build && pnpm --filter @constructive-io/sheets build && pnpm --filter @constructive-io/schema-builder build && pnpm --filter blocks-schema build && pnpm --filter blocks-renderer build && pnpm --filter json-schema-to-blocks build && pnpm --filter meta-to-blocks build && pnpm --filter flow-to-blocks build",
+ "build:packages": "pnpm --filter @constructive-io/ui build && pnpm --filter @constructive-io/data build && pnpm --filter @constructive-io/command-palette build && pnpm --filter @constructive-io/sheets build && pnpm --filter @constructive-io/schema-builder build && pnpm --filter json-renderer build && pnpm --filter blocks-schema build && pnpm --filter blocks-renderer build && pnpm --filter json-schema-to-blocks build && pnpm --filter meta-to-blocks build && pnpm --filter flow-to-blocks build",
"build:registry": "pnpm --filter @constructive-io/registry build && pnpm check:console-kit-inspector",
"build:pages": "pnpm build:packages && pnpm build:registry && pnpm --filter blocks build:pages && pnpm pages:artifact",
"build:storybook": "pnpm --filter @constructive-io/ui build-sb",
diff --git a/packages/blocks-renderer/package.json b/packages/blocks-renderer/package.json
index e7cfe72..c6009dc 100644
--- a/packages/blocks-renderer/package.json
+++ b/packages/blocks-renderer/package.json
@@ -30,7 +30,8 @@
"clean": "makage clean"
},
"dependencies": {
- "blocks-schema": "workspace:^"
+ "blocks-schema": "workspace:^",
+ "json-renderer": "workspace:^"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0"
diff --git a/packages/blocks-renderer/src/__tests__/adapter.test.tsx b/packages/blocks-renderer/src/__tests__/adapter.test.tsx
new file mode 100644
index 0000000..a699004
--- /dev/null
+++ b/packages/blocks-renderer/src/__tests__/adapter.test.tsx
@@ -0,0 +1,57 @@
+import type { UIDocument, UINode } from 'blocks-schema';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { describe, expect, it } from 'vitest';
+
+import { reactAdapter } from '../adapter';
+import type { BlockProps, BlockRegistry, RendererContextValue } from '../types';
+
+function Text({ props }: BlockProps) {
+ return {String(props.text ?? '')};
+}
+
+const registry: BlockRegistry = { Markdown: Text };
+
+const page: UINode = {
+ type: 'Markdown',
+ key: 'text',
+ props: { text: 'static' },
+ bindings: { text: '{{ row.title }}' },
+ children: [],
+};
+
+const document: UIDocument = { formatVersion: '1.0', type: 'UISchema', id: 'doc-1', page };
+
+function context(): RendererContextValue {
+ return {
+ document,
+ registry,
+ mode: 'preview',
+ values: {},
+ errors: {},
+ setValue: () => {},
+ setError: () => {},
+ scope: { row: { title: 'Bound Title' } },
+ };
+}
+
+describe('reactAdapter', () => {
+ it('resolves a node type to a registered component', () => {
+ expect(reactAdapter.resolve('Markdown', context())).toMatchObject({ status: 'resolved', handler: Text });
+ expect(reactAdapter.resolve('HoloDeck', context()).status).toBe('unknown');
+ });
+
+ it('resolves props through the binding scope', () => {
+ expect(reactAdapter.resolveProps(page, context())).toEqual({ text: 'Bound Title' });
+ });
+
+ it('renders a document and an unknown node', () => {
+ expect(renderToStaticMarkup(reactAdapter.renderDocument(document, context()))).toContain(
+ 'Bound Title',
+ );
+ expect(
+ renderToStaticMarkup(
+ reactAdapter.renderUnknown({ type: 'HoloDeck', key: 'holo', props: {}, children: [] }, context()),
+ ),
+ ).toContain('data-block-unknown="HoloDeck"');
+ });
+});
diff --git a/packages/blocks-renderer/src/adapter.tsx b/packages/blocks-renderer/src/adapter.tsx
new file mode 100644
index 0000000..4f324b4
--- /dev/null
+++ b/packages/blocks-renderer/src/adapter.tsx
@@ -0,0 +1,47 @@
+'use client';
+
+import type { UIDocument, UINode } from 'blocks-schema';
+import { resolveNode, resolveNodeProps, type NodeProps, type NodeResolution, type RendererAdapter } from 'json-renderer';
+import type { ReactNode } from 'react';
+
+import { RendererProvider } from './context';
+import { BlockRenderer } from './renderer';
+import type { BlockComponent, RendererContextValue } from './types';
+import { UnknownBlock } from './unknown-block';
+
+/**
+ * `blocks-renderer` as an explicit {@link RendererAdapter}: node type resolves to
+ * a React component, output is a React element, and an unsatisfied type renders
+ * {@link UnknownBlock}.
+ *
+ * The React components ({@link DocumentRenderer}, {@link BlockRenderer}) remain
+ * the ergonomic entry point; this object states the contract they satisfy so a
+ * second adapter has something to conform to.
+ */
+export const reactAdapter: RendererAdapter = {
+ name: 'blocks-renderer/react',
+
+ resolve(type, context): NodeResolution {
+ return resolveNode(context.registry, type);
+ },
+
+ resolveProps(node, context): NodeProps {
+ return resolveNodeProps(node, context.scope);
+ },
+
+ renderNode(node, context) {
+ return {};
+ },
+
+ renderUnknown(node) {
+ return ;
+ },
+
+ renderDocument(document, context) {
+ return (
+
+
+
+ );
+ },
+};
diff --git a/packages/blocks-renderer/src/bindings.ts b/packages/blocks-renderer/src/bindings.ts
index 6439a1f..390b9e6 100644
--- a/packages/blocks-renderer/src/bindings.ts
+++ b/packages/blocks-renderer/src/bindings.ts
@@ -1,41 +1,6 @@
-import type { UINode, UINodeProps } from 'blocks-schema';
-
-const TEMPLATE = /\{\{\s*([^}\s]+)\s*\}\}/g;
-
-/** Read a dotted path (`row.author.name`) out of a scope object. */
-export function readPath(scope: Record, path: string): unknown {
- let current: unknown = scope;
- for (const segment of path.split('.')) {
- if (current == null || typeof current !== 'object') return undefined;
- current = (current as Record)[segment];
- }
- return current;
-}
-
/**
- * Resolve a binding expression. A template that is exactly one placeholder
- * yields the raw value (so a boolean or an object survives); a template mixed
- * with text is interpolated as a string.
+ * Binding resolution is framework-agnostic, so it lives in `json-renderer`.
+ * These re-exports keep `blocks-renderer/bindings` a stable import path.
*/
-export function resolveBinding(expression: string, scope: Record): unknown {
- const single = expression.match(/^\{\{\s*([^}\s]+)\s*\}\}$/);
- if (single) {
- return readPath(scope, single[1]);
- }
-
- return expression.replace(TEMPLATE, (_match, path: string) => {
- const value = readPath(scope, path);
- return value == null ? '' : String(value);
- });
-}
-
-/** Apply a node's `bindings` over its static props. */
-export function resolveNodeProps(node: UINode, scope: Record): UINodeProps {
- if (!node.bindings) return node.props ?? {};
-
- const resolved: UINodeProps = { ...(node.props ?? {}) };
- for (const [prop, expression] of Object.entries(node.bindings)) {
- resolved[prop] = resolveBinding(expression, scope);
- }
- return resolved;
-}
+export { composeScope, readPath, resolveBinding, resolveNodeProps } from 'json-renderer';
+export type { BindingScope } from 'json-renderer';
diff --git a/packages/blocks-renderer/src/index.ts b/packages/blocks-renderer/src/index.ts
index 3a32368..b0271aa 100644
--- a/packages/blocks-renderer/src/index.ts
+++ b/packages/blocks-renderer/src/index.ts
@@ -1,7 +1,15 @@
-export { readPath, resolveBinding, resolveNodeProps } from './bindings';
+export { reactAdapter } from './adapter';
+export { composeScope, readPath, resolveBinding, resolveNodeProps } from './bindings';
+export type { BindingScope } from './bindings';
export { RendererProvider, useBlockField, useRenderer } from './context';
-export { composeRegistry, registeredTypes, resolveBlock } from './registry';
+export { composeRegistry, missingTypes, registeredTypes, resolveBlock } from './registry';
export { BlockRenderer, DocumentRenderer } from './renderer';
export type { DocumentRendererProps } from './renderer';
export { UnknownBlock } from './unknown-block';
export type { BlockComponent, BlockProps, BlockRegistry, RenderMode, RendererContextValue } from './types';
+export type {
+ NodeResolution,
+ RenderContext,
+ RendererAdapter,
+ UnknownNodePolicy,
+} from 'json-renderer';
diff --git a/packages/blocks-renderer/src/registry.ts b/packages/blocks-renderer/src/registry.ts
index 8eaa0f3..2631355 100644
--- a/packages/blocks-renderer/src/registry.ts
+++ b/packages/blocks-renderer/src/registry.ts
@@ -1,3 +1,14 @@
+/**
+ * Registry layering for the React adapter: `json-renderer`'s generic registry
+ * ops, typed over React block components.
+ */
+import {
+ composeRegistry as composeNodeRegistry,
+ missingTypes as missingRegistryTypes,
+ registeredTypes as registeredNodeTypes,
+ resolveHandler,
+} from 'json-renderer';
+
import type { BlockComponent, BlockRegistry } from './types';
/**
@@ -6,19 +17,19 @@ import type { BlockComponent, BlockRegistry } from './types';
* overrides — no forking of the renderer, and no single global map.
*/
export function composeRegistry(...layers: (BlockRegistry | undefined)[]): BlockRegistry {
- const composed: BlockRegistry = {};
- for (const layer of layers) {
- if (!layer) continue;
- Object.assign(composed, layer);
- }
- return composed;
+ return composeNodeRegistry(...layers);
}
export function resolveBlock(registry: BlockRegistry, type: string): BlockComponent | undefined {
- return registry[type];
+ return resolveHandler(registry, type);
}
/** Node types the registry can render, sorted for stable output. */
export function registeredTypes(registry: BlockRegistry): string[] {
- return Object.keys(registry).sort();
+ return registeredNodeTypes(registry);
+}
+
+/** Node types a document uses that no registry layer satisfies. */
+export function missingTypes(registry: BlockRegistry, usedTypes: Iterable): string[] {
+ return missingRegistryTypes(registry, usedTypes);
}
diff --git a/packages/blocks-renderer/src/types.ts b/packages/blocks-renderer/src/types.ts
index bc601c0..babb8e6 100644
--- a/packages/blocks-renderer/src/types.ts
+++ b/packages/blocks-renderer/src/types.ts
@@ -1,4 +1,5 @@
import type { UIAction, UIDocument, UINode, UINodeProps } from 'blocks-schema';
+import type { BindingScope, NodeRegistry, RenderContext } from 'json-renderer';
import type { ComponentType, ReactNode } from 'react';
export type RenderMode = 'preview' | 'edit';
@@ -16,9 +17,13 @@ export interface BlockProps {
export type BlockComponent = ComponentType;
/** Node type → component. Layered by {@link composeRegistry}. */
-export type BlockRegistry = Record;
+export type BlockRegistry = NodeRegistry;
-export interface RendererContextValue {
+/**
+ * The React adapter's render context: `json-renderer`'s generic
+ * {@link RenderContext} plus the field state a form needs while rendering.
+ */
+export interface RendererContextValue extends RenderContext {
document: UIDocument;
registry: BlockRegistry;
mode: RenderMode;
@@ -27,6 +32,6 @@ export interface RendererContextValue {
setValue: (name: string, value: unknown) => void;
setError: (name: string, error: string | null) => void;
/** Scope for binding expressions (`{{ row.title }}`), merged with `values`. */
- scope: Record;
+ scope: BindingScope;
onAction?: (action: UIAction, event: string) => void;
}
diff --git a/packages/blocks-schema/package.json b/packages/blocks-schema/package.json
index 3aa160b..d236fc5 100644
--- a/packages/blocks-schema/package.json
+++ b/packages/blocks-schema/package.json
@@ -22,14 +22,15 @@
"module": "esm/index.js",
"types": "index.d.ts",
"scripts": {
- "build": "makage build",
- "build:dev": "makage build --dev",
+ "build": "makage clean && makage build-ts && makage assets",
+ "build:dev": "makage clean && makage build-ts --dev && makage assets",
"lint:types": "tsc --noEmit -p tsconfig.lint.json",
"test": "vitest run",
"test:watch": "vitest",
"clean": "makage clean"
},
"dependencies": {
+ "json-renderer": "workspace:^",
"zod": "^4.3.4"
},
"devDependencies": {
diff --git a/packages/blocks-schema/src/compose.ts b/packages/blocks-schema/src/compose.ts
index c30f649..a4d17ee 100644
--- a/packages/blocks-schema/src/compose.ts
+++ b/packages/blocks-schema/src/compose.ts
@@ -1,3 +1,11 @@
+/**
+ * Document composition for Constructive documents: the generic ops from
+ * `json-renderer`, typed over this package's `UINode`/`UIDocument` and its
+ * `Fragment`/`Slot` vocabulary.
+ */
+import { composeEnvelope, composeNodeTree as composeGenericNodeTree, mergeEnvelopes, mergeNodeTrees } from 'json-renderer';
+import type { ComposeOptions as GenericComposeOptions, NodeOverride as GenericNodeOverride } from 'json-renderer';
+
import type { UIDocument } from './envelope';
import type { UIActions, UIBinding, UINode, UINodeProps, UINodeType } from './node';
@@ -6,7 +14,7 @@ import type { UIActions, UIBinding, UINode, UINodeProps, UINodeType } from './no
* per document, so a generated default can be customized in a few places
* without giving up generation.
*/
-export interface NodeOverride {
+export interface NodeOverride extends GenericNodeOverride {
type?: UINodeType;
props?: UINodeProps;
bindings?: UIBinding;
@@ -29,48 +37,8 @@ export interface ComposeOptions {
overrides?: NodeOverrides;
}
-function applyOverride(node: UINode, override: NodeOverride): UINode {
- return {
- ...node,
- ...(override.type ? { type: override.type } : {}),
- props: { ...node.props, ...override.props },
- ...(override.bindings ? { bindings: { ...node.bindings, ...override.bindings } } : {}),
- ...(override.actions ? { actions: { ...node.actions, ...override.actions } } : {}),
- };
-}
-
-function expandChild(node: UINode, options: ComposeOptions): UINode[] {
- if (node.type === 'Fragment') {
- const ref = node.props?.ref;
- const fragment = typeof ref === 'string' ? options.fragments?.[ref] : undefined;
- // An unresolved reference stays in the tree so the renderer surfaces it
- // rather than silently dropping content.
- return fragment ? [composeNode(fragment, options)] : [composeNode({ ...node, children: [] }, options)];
- }
-
- if (node.type === 'Slot') {
- const name = node.props?.name;
- const filler = typeof name === 'string' ? options.slots?.[name] : undefined;
- if (filler === undefined) {
- // No filler: fall back to the slot's own children (its default content).
- return (node.children ?? []).flatMap((child) => expandChild(child, options));
- }
- const nodes = Array.isArray(filler) ? filler : [filler];
- return nodes.map((filled) => composeNode(filled, options));
- }
-
- return [composeNode(node, options)];
-}
-
-function composeNode(node: UINode, options: ComposeOptions): UINode {
- const override = options.overrides?.[node.key];
- const base = override ? applyOverride(node, override) : node;
-
- const children = (base.children ?? [])
- .filter((child) => !options.overrides?.[child.key]?.remove)
- .flatMap((child) => expandChild(child, options));
-
- return { ...base, children };
+function genericOptions(options: ComposeOptions): GenericComposeOptions {
+ return options as GenericComposeOptions;
}
/**
@@ -78,9 +46,21 @@ function composeNode(node: UINode, options: ComposeOptions): UINode {
* apply per-node overrides. Pure — the input document is never mutated.
*/
export function composeDocument(document: UIDocument, options: ComposeOptions = {}): UIDocument {
- return { ...document, page: composeNode(document.page, options) };
+ return composeEnvelope(document, genericOptions(options));
}
export function composeNodeTree(node: UINode, options: ComposeOptions = {}): UINode {
- return composeNode(node, options);
+ return composeGenericNodeTree(node, genericOptions(options));
+}
+
+/**
+ * Merge an overlay document onto a generated one by node `key`: hand-authored
+ * content wins per node, not per document.
+ */
+export function mergeDocuments(base: UIDocument, overlay: Partial> & { page?: UINode }): UIDocument {
+ return mergeEnvelopes(base, overlay);
+}
+
+export function mergeNodes(base: UINode, overlay: UINode): UINode {
+ return mergeNodeTrees(base, overlay);
}
diff --git a/packages/blocks-schema/src/core.ts b/packages/blocks-schema/src/core.ts
new file mode 100644
index 0000000..f1ce415
--- /dev/null
+++ b/packages/blocks-schema/src/core.ts
@@ -0,0 +1,32 @@
+/**
+ * The generic core this package specializes.
+ *
+ * `blocks-schema` is the Constructive *vocabulary* over `json-renderer`'s
+ * framework-agnostic document format. These re-exports let a consumer name the
+ * generic types (and the renderer adapter contract) without adding a second
+ * dependency, and without this package restating them.
+ */
+export type {
+ AnyDocumentEnvelope,
+ AnyDocumentNode,
+ BindingScope,
+ ComposeVocabulary,
+ DocumentDataSource,
+ DocumentEnvelope,
+ DocumentMetadata,
+ DocumentNode,
+ EnvelopeKind,
+ FieldNodePredicate,
+ FieldStateAccess,
+ NodeAction,
+ NodeConstraints,
+ NodeRegistry,
+ NodeResolution,
+ RegistrySource,
+ RenderContext,
+ RenderContextBase,
+ RendererAdapter,
+ UnknownNodePolicy,
+} from 'json-renderer';
+
+export { DOCUMENT_FORMAT_VERSION } from 'json-renderer';
diff --git a/packages/blocks-schema/src/envelope.ts b/packages/blocks-schema/src/envelope.ts
index 609bd20..9ed613f 100644
--- a/packages/blocks-schema/src/envelope.ts
+++ b/packages/blocks-schema/src/envelope.ts
@@ -1,9 +1,24 @@
+import { createEnvelope, isDocumentEnvelope } from 'json-renderer';
+import type {
+ DocumentDataSource,
+ DocumentEnvelope,
+ DocumentMetadata,
+ EnvelopeKind,
+ RegistrySource,
+} from 'json-renderer';
+
import type { UINode } from './node';
export const UI_DOCUMENT_FORMAT_VERSION = '1.0';
export const UI_DOCUMENT_TYPE = 'UISchema';
-export interface UIDocumentMetadata {
+/** The envelope kind this package specializes out of the generic core. */
+export const UI_DOCUMENT_KIND: EnvelopeKind = {
+ documentType: UI_DOCUMENT_TYPE,
+ formatVersion: UI_DOCUMENT_FORMAT_VERSION,
+};
+
+export interface UIDocumentMetadata extends DocumentMetadata {
title?: string;
description?: string;
[key: string]: unknown;
@@ -13,13 +28,10 @@ export interface UIDocumentMetadata {
* A resolution source for node types: a shadcn-style registry URL template,
* e.g. `https://constructive-io.github.io/blocks/r/{name}.json`.
*/
-export interface UIRegistrySource {
- name: string;
- url: string;
-}
+export type UIRegistrySource = RegistrySource;
/** A named, read-only query a document's blocks can bind against. */
-export interface UIDataSource {
+export interface UIDataSource extends DocumentDataSource {
name: string;
table?: string;
query?: string;
@@ -30,14 +42,15 @@ export interface UIDataSource {
first?: number;
}
-export interface UIDocument {
- formatVersion: typeof UI_DOCUMENT_FORMAT_VERSION;
- type: typeof UI_DOCUMENT_TYPE;
- id: string;
+/**
+ * The Constructive UI document: the generic `DocumentEnvelope` pinned to this
+ * package's node vocabulary, envelope discriminator, and format version.
+ */
+export interface UIDocument
+ extends DocumentEnvelope {
meta?: UIDocumentMetadata;
registries?: UIRegistrySource[];
dataSources?: UIDataSource[];
- page: UINode;
}
/**
@@ -47,24 +60,12 @@ export interface UIDocument {
export type UISchema = UIDocument;
export function isUIDocument(value: unknown): value is UIDocument {
- if (!value || typeof value !== 'object') return false;
- const candidate = value as Record;
- return (
- candidate.type === UI_DOCUMENT_TYPE &&
- candidate.formatVersion === UI_DOCUMENT_FORMAT_VERSION &&
- !!candidate.page
- );
+ return isDocumentEnvelope(value, UI_DOCUMENT_KIND);
}
/** @deprecated Use {@link isUIDocument}. */
export const isUISchema = isUIDocument;
export function createDocument(page: UINode, options: { id?: string; meta?: UIDocumentMetadata } = {}): UIDocument {
- return {
- formatVersion: UI_DOCUMENT_FORMAT_VERSION,
- type: UI_DOCUMENT_TYPE,
- id: options.id ?? 'document',
- ...(options.meta ? { meta: options.meta } : {}),
- page,
- };
+ return createEnvelope(UI_DOCUMENT_KIND, page, options) as UIDocument;
}
diff --git a/packages/blocks-schema/src/index.ts b/packages/blocks-schema/src/index.ts
index 5c19e8d..0f5f90f 100644
--- a/packages/blocks-schema/src/index.ts
+++ b/packages/blocks-schema/src/index.ts
@@ -1,4 +1,5 @@
export * from './compose';
+export * from './core';
export * from './envelope';
export * from './json-schema';
export * from './node';
diff --git a/packages/blocks-schema/src/json-schema.ts b/packages/blocks-schema/src/json-schema.ts
index ceb4b07..4cb4e9e 100644
--- a/packages/blocks-schema/src/json-schema.ts
+++ b/packages/blocks-schema/src/json-schema.ts
@@ -1,4 +1,4 @@
-import { z } from 'zod';
+import { toJsonSchema } from 'json-renderer';
import { uiDocumentSchema, uiNodeSchema } from './zod';
@@ -7,9 +7,9 @@ import { uiDocumentSchema, uiNodeSchema } from './zod';
* output and for registry/editor tooling that validates without importing zod.
*/
export function toDocumentJsonSchema(): Record {
- return z.toJSONSchema(uiDocumentSchema, { io: 'input' }) as Record;
+ return toJsonSchema(uiDocumentSchema);
}
export function toNodeJsonSchema(): Record {
- return z.toJSONSchema(uiNodeSchema, { io: 'input' }) as Record;
+ return toJsonSchema(uiNodeSchema);
}
diff --git a/packages/blocks-schema/src/node.ts b/packages/blocks-schema/src/node.ts
index fa60fa4..7117761 100644
--- a/packages/blocks-schema/src/node.ts
+++ b/packages/blocks-schema/src/node.ts
@@ -1,10 +1,30 @@
/**
- * Node-level types for the portable JSON UI document format.
+ * Node-level types for the Constructive JSON UI document format.
*
- * A document is a tree of typed nodes. A node's `type` is resolved to a
- * component by the renderer's widget registry, so this package never imports
- * React and stays usable on a server, in an agent, or in a validator.
+ * The tree model, the walk, and the field-collection primitives live in
+ * `json-renderer`; this module owns the Constructive *vocabulary* — which node
+ * types exist and which of them are fields — and specializes the generic
+ * helpers over it. A node's `type` is resolved to a component by the renderer's
+ * widget registry, so this package never imports React and stays usable on a
+ * server, in an agent, or in a validator.
*/
+import {
+ collectDefaultValues as collectNodeDefaultValues,
+ collectFieldConstraints as collectNodeFieldConstraints,
+ collectFieldNames as collectNodeFieldNames,
+ findNodeByKey as findNodeInTreeByKey,
+ walkNodes as walkNodeTree,
+} from 'json-renderer';
+import type {
+ DocumentNode,
+ FieldConstraintEntry,
+ NodeActions,
+ NodeBindings,
+ NodeConstraints,
+ NodeProps,
+} from 'json-renderer';
+
+export type { FieldConstraintEntry } from 'json-renderer';
/** Field widget node types (a form's leaves). */
export const WIDGET_NODE_TYPES = [
@@ -58,7 +78,7 @@ export type UINodeType = KnownNodeType | (string & {});
export type InputType = 'text' | 'email' | 'url' | 'password' | 'tel' | 'search';
-export interface UINodeConstraints {
+export interface UINodeConstraints extends NodeConstraints {
minLength?: number;
maxLength?: number;
minValue?: number;
@@ -85,7 +105,7 @@ export interface UINodePropsBase {
export type UINodeProps = UINodePropsBase & Record;
/** Prop name → template expression, e.g. `{ label: '{{ row.title }}' }`. */
-export interface UIBinding {
+export interface UIBinding extends NodeBindings {
[propName: string]: string;
}
@@ -102,10 +122,8 @@ export interface UIActions {
[eventName: string]: UIAction;
}
-export interface UINode {
- type: UINodeType;
- key: string;
- props: UINodeProps;
+/** The generic node tree pinned to the Constructive vocabulary and props. */
+export interface UINode extends DocumentNode {
children: UINode[];
bindings?: UIBinding;
actions?: UIActions;
@@ -136,57 +154,28 @@ export function isContainerNode(node: UINode): boolean {
}
/** Depth-first walk over a node and its descendants. */
-export function* walkNodes(node: UINode): Generator {
- yield node;
- for (const child of node.children ?? []) {
- yield* walkNodes(child);
- }
+export function walkNodes(node: UINode): Generator {
+ return walkNodeTree(node);
}
/** Named fields in document order; widget nodes without a `name` are skipped. */
export function collectFieldNames(node: UINode): string[] {
- const names: string[] = [];
- for (const current of walkNodes(node)) {
- if (isWidgetNode(current) && typeof current.props?.name === 'string') {
- names.push(current.props.name);
- }
- }
- return names;
+ return collectNodeFieldNames(node, isWidgetNode);
}
/** Default values declared by widget nodes, keyed by field name. */
export function collectDefaultValues(node: UINode): Record {
- const values: Record = {};
- for (const current of walkNodes(node)) {
- if (!isWidgetNode(current) || typeof current.props?.name !== 'string') continue;
- if (current.props.defaultValue !== undefined) {
- values[current.props.name] = current.props.defaultValue;
- }
- }
- return values;
-}
-
-export interface FieldConstraintEntry {
- constraints?: UINodeConstraints;
- required?: boolean;
+ return collectNodeDefaultValues(node, isWidgetNode);
}
/** Validation metadata declared by widget nodes, keyed by field name. */
export function collectFieldConstraints(node: UINode): Record {
- const result: Record = {};
- for (const current of walkNodes(node)) {
- if (!isWidgetNode(current) || typeof current.props?.name !== 'string') continue;
- result[current.props.name] = {
- constraints: current.props.constraints,
- required: current.props.required,
- };
- }
- return result;
+ return collectNodeFieldConstraints(node, isWidgetNode);
}
export function findNodeByKey(node: UINode, key: string): UINode | undefined {
- for (const current of walkNodes(node)) {
- if (current.key === key) return current;
- }
- return undefined;
+ return findNodeInTreeByKey(node, key);
}
+
+/** Re-exported so `UINodeProps` consumers can name the generic props shape. */
+export type { NodeActions, NodeBindings, NodeProps };
diff --git a/packages/blocks-schema/src/validation.ts b/packages/blocks-schema/src/validation.ts
index afa2780..83c4ab0 100644
--- a/packages/blocks-schema/src/validation.ts
+++ b/packages/blocks-schema/src/validation.ts
@@ -1,54 +1,14 @@
+import { validateValue } from 'json-renderer';
+
import type { UINodeConstraints } from './node';
/**
* Validate a single field value against the constraints declared on its node.
* Returns a human-readable message, or `null` when the value is acceptable.
+ *
+ * The check itself is `json-renderer`'s `validateValue`; this is the named,
+ * vocabulary-typed entry point Constructive consumers already import.
*/
export function validateField(value: unknown, constraints?: UINodeConstraints, required?: boolean): string | null {
- const stringValue = value == null ? '' : String(value);
- const isEmpty = stringValue.trim() === '';
-
- if (required && isEmpty) {
- return 'This field is required';
- }
-
- if (isEmpty) return null;
-
- if (constraints?.minLength != null && stringValue.length < constraints.minLength) {
- return `Minimum ${constraints.minLength} characters required`;
- }
-
- if (constraints?.maxLength != null && stringValue.length > constraints.maxLength) {
- return `Maximum ${constraints.maxLength} characters allowed`;
- }
-
- if (constraints?.minValue != null && typeof value === 'number' && value < constraints.minValue) {
- return `Minimum value is ${constraints.minValue}`;
- }
-
- if (constraints?.maxValue != null && typeof value === 'number' && value > constraints.maxValue) {
- return `Maximum value is ${constraints.maxValue}`;
- }
-
- if (constraints?.pattern) {
- const regex = compilePattern(constraints.pattern);
- if (regex && !regex.test(stringValue)) {
- return 'Invalid format';
- }
- }
-
- return null;
-}
-
-/**
- * Patterns arrive from documents authored elsewhere (a JSON Schema, a database
- * check constraint), so an uncompilable one must not take the form down — it is
- * reported as unconstrained rather than as a failed field.
- */
-function compilePattern(pattern: string): RegExp | null {
- try {
- return new RegExp(pattern);
- } catch {
- return null;
- }
+ return validateValue(value, constraints, required);
}
diff --git a/packages/blocks-schema/src/zod.ts b/packages/blocks-schema/src/zod.ts
index 9f3cb50..3897756 100644
--- a/packages/blocks-schema/src/zod.ts
+++ b/packages/blocks-schema/src/zod.ts
@@ -1,20 +1,27 @@
+/**
+ * Runtime validation for Constructive documents. The tree and envelope rules
+ * come from `json-renderer`'s schema factories; this module narrows props and
+ * actions to the Constructive vocabulary.
+ */
+import {
+ createDocumentSchema,
+ createNodeSchema,
+ dataSourceSchema,
+ documentMetadataSchema,
+ nodeBindingsSchema,
+ nodeConstraintsSchema,
+ nodePropsSchema,
+ registrySourceSchema,
+} from 'json-renderer';
import { z } from 'zod';
-import { UI_DOCUMENT_FORMAT_VERSION, UI_DOCUMENT_TYPE } from './envelope';
+import { UI_DOCUMENT_KIND } from './envelope';
import type { UIDocument } from './envelope';
import type { UINode } from './node';
-export const uiNodeConstraintsSchema = z.object({
- minLength: z.number().int().nonnegative().optional(),
- maxLength: z.number().int().nonnegative().optional(),
- minValue: z.number().optional(),
- maxValue: z.number().optional(),
- pattern: z.string().optional(),
- precision: z.number().int().nonnegative().optional(),
- scale: z.number().int().nonnegative().optional(),
-});
+export const uiNodeConstraintsSchema = nodeConstraintsSchema;
-export const uiNodePropsSchema = z.looseObject({
+export const uiNodePropsSchema = nodePropsSchema.extend({
fieldId: z.string().optional(),
name: z.string().optional(),
label: z.string().optional(),
@@ -28,7 +35,7 @@ export const uiNodePropsSchema = z.looseObject({
className: z.string().optional(),
});
-export const uiBindingSchema = z.record(z.string(), z.string());
+export const uiBindingSchema = nodeBindingsSchema;
export const uiActionSchema = z.object({
type: z.enum(['flow', 'handler']),
@@ -40,42 +47,22 @@ export const uiActionSchema = z.object({
export const uiActionsSchema = z.record(z.string(), uiActionSchema);
-export const uiNodeSchema: z.ZodType = z.lazy(() =>
- z.object({
- type: z.string().min(1),
- key: z.string().min(1),
- props: uiNodePropsSchema.default({}),
- children: z.array(uiNodeSchema).default([]),
- bindings: uiBindingSchema.optional(),
- actions: uiActionsSchema.optional(),
- }),
-);
-
-export const uiRegistrySourceSchema = z.object({
- name: z.string().min(1),
- url: z.string().min(1),
+export const uiNodeSchema: z.ZodType = createNodeSchema({
+ propsSchema: uiNodePropsSchema,
+ actionsSchema: uiActionsSchema,
});
-export const uiDataSourceSchema = z.looseObject({
- name: z.string().min(1),
+export const uiRegistrySourceSchema = registrySourceSchema;
+
+export const uiDataSourceSchema = dataSourceSchema.extend({
table: z.string().optional(),
- query: z.string().optional(),
- variables: z.record(z.string(), z.unknown()).optional(),
});
-export const uiDocumentMetadataSchema = z.looseObject({
- title: z.string().optional(),
- description: z.string().optional(),
-});
+export const uiDocumentMetadataSchema = documentMetadataSchema;
-export const uiDocumentSchema: z.ZodType = z.object({
- formatVersion: z.literal(UI_DOCUMENT_FORMAT_VERSION),
- type: z.literal(UI_DOCUMENT_TYPE),
- id: z.string().min(1),
- meta: uiDocumentMetadataSchema.optional(),
- registries: z.array(uiRegistrySourceSchema).optional(),
- dataSources: z.array(uiDataSourceSchema).optional(),
- page: uiNodeSchema,
+export const uiDocumentSchema: z.ZodType = createDocumentSchema({
+ kind: UI_DOCUMENT_KIND,
+ nodeSchema: uiNodeSchema,
});
/** Throws a `ZodError` describing every problem in the document. */
diff --git a/packages/json-renderer/README.md b/packages/json-renderer/README.md
new file mode 100644
index 0000000..a3a94e8
--- /dev/null
+++ b/packages/json-renderer/README.md
@@ -0,0 +1,119 @@
+# json-renderer
+
+
+
+
+
+The framework-agnostic core of a declarative JSON UI document: the document
+envelope and node tree, runtime validation, JSON Schema export, composition
+(fragments, slots, per-node overrides, merge), binding resolution, and the
+**adapter contract** a renderer implements.
+
+No React, no component library, and no node vocabulary of its own — a document
+names whatever node types its registry can satisfy. `blocks-schema` is the
+Constructive vocabulary over this core, and `blocks-renderer` is its React
+adapter.
+
+## Install
+
+```bash
+pnpm add json-renderer
+```
+
+Built with `makage` and published from `dist`, so every module is a root-level
+entry point and deep imports need no exports map:
+
+```ts
+import { createEnvelope, parseEnvelope } from 'json-renderer';
+import { composeEnvelope } from 'json-renderer/compose';
+import { resolveBinding } from 'json-renderer/bindings';
+```
+
+## Overview
+
+| Layer | Purpose | File |
+|-------|---------|------|
+| **Envelope** | `DocumentEnvelope` container (`formatVersion`, `type`, `id`, `page`) | `envelope.ts` |
+| **Nodes** | Recursive `DocumentNode` tree, traversal and mapping | `node.ts` |
+| **Validation** | Zod schema factories for the envelope and node tree | `zod.ts` |
+| **Constraints** | Framework-free field constraint checking | `constraints.ts` |
+| **Fields** | Field name, default value, and constraint collection | `fields.ts` |
+| **JSON Schema** | Exported JSON Schema of the format, for agents and tooling | `json-schema.ts` |
+| **Compose** | Fragment expansion, slot filling, overrides, merge | `compose.ts` |
+| **Bindings** | `{{ scope.path }}` resolution and scope layering | `bindings.ts` |
+| **Registry** | Layered node type → handler resolution | `registry.ts` |
+| **Adapter** | The interface a renderer implements | `adapter.ts` |
+
+## Document
+
+```json
+{
+ "formatVersion": "1.0",
+ "type": "UISchema",
+ "id": "orders-form",
+ "meta": { "title": "Orders" },
+ "page": { "type": "Page", "key": "page", "props": {}, "children": [] }
+}
+```
+
+A node is `{ type, key, props, children, bindings?, actions? }`. `type` is any
+string: unknown types are valid documents, because resolution happens at render
+time and an adapter must render a visible fallback rather than throw.
+
+`DocumentNode` and `DocumentEnvelope` are generic over the vocabulary, so a
+format narrows them without redeclaring the tree:
+
+```ts
+type UINode = DocumentNode;
+type UIDocument = DocumentEnvelope;
+```
+
+## Composition
+
+```ts
+const composed = composeEnvelope(document, {
+ fragments: { address: addressSubtree },
+ slots: { header: customHeaderNode },
+ overrides: { title: { props: { label: 'Headline' } }, legacy: { remove: true } },
+});
+```
+
+Composition is pure and never mutates its input. An unresolved fragment stays in
+the tree so the renderer surfaces the gap. A vocabulary that spells its
+indirection nodes differently configures `vocabulary` instead of forking
+composition:
+
+```ts
+composeEnvelope(document, { fragments, vocabulary: { fragmentNodeType: 'include' } });
+```
+
+## Adapter contract
+
+A renderer is generic over the handler it resolves a node type to (`THandler`)
+and the output it produces (`TOutput`), and owes three answers:
+
+1. **Registry resolution** — node type → handler, layered with `composeRegistry`
+ so a host adds or replaces node types without forking the renderer.
+2. **Binding scope access** — the scope a node's bindings resolve against.
+3. **Unknown-node handling** — `UnknownNodePolicy`; the default is a visible
+ placeholder, never a thrown render.
+
+```ts
+import type { RendererAdapter } from 'json-renderer';
+
+const stringAdapter: RendererAdapter<(props: NodeProps) => string, string> = {
+ name: 'example/string',
+ resolve: (type, context) => resolveNode(context.registry, type),
+ resolveProps: (node, context) => resolveNodeProps(node, context.scope),
+ renderNode: (node, context) => { /* ... */ },
+ renderUnknown: (node) => ``,
+ renderDocument: (document, context) => { /* ... */ },
+};
+```
+
+`blocks-renderer` is the reference implementation (React + shadcn); its
+`reactAdapter` export states the contract its components satisfy.
+
+## License
+
+MIT
diff --git a/packages/json-renderer/package.json b/packages/json-renderer/package.json
new file mode 100644
index 0000000..dbe3b9f
--- /dev/null
+++ b/packages/json-renderer/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "json-renderer",
+ "version": "0.0.1",
+ "description": "Framework-agnostic JSON UI document core: envelope and node tree, validation, composition, binding resolution, and the renderer adapter contract",
+ "private": false,
+ "license": "MIT",
+ "homepage": "https://constructive-io.github.io/blocks/",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/constructive-io/blocks.git",
+ "directory": "packages/json-renderer"
+ },
+ "bugs": {
+ "url": "https://github.com/constructive-io/blocks/issues"
+ },
+ "publishConfig": {
+ "access": "public",
+ "directory": "dist"
+ },
+ "sideEffects": false,
+ "main": "index.js",
+ "module": "esm/index.js",
+ "types": "index.d.ts",
+ "scripts": {
+ "build": "makage build",
+ "build:dev": "makage build --dev",
+ "lint:types": "tsc --noEmit -p tsconfig.lint.json",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "clean": "makage clean"
+ },
+ "dependencies": {
+ "zod": "^4.3.4"
+ },
+ "devDependencies": {
+ "makage": "^0.6.0",
+ "typescript": "^5.9.3",
+ "vitest": "^3.2.4"
+ },
+ "engines": {
+ "node": ">=24.0.0"
+ }
+}
diff --git a/packages/json-renderer/src/__tests__/adapter.test.ts b/packages/json-renderer/src/__tests__/adapter.test.ts
new file mode 100644
index 0000000..0335258
--- /dev/null
+++ b/packages/json-renderer/src/__tests__/adapter.test.ts
@@ -0,0 +1,102 @@
+import { describe, expect, it } from 'vitest';
+
+import { resolveNode, type RendererAdapter } from '../adapter';
+import { composeScope, readPath, resolveBinding, resolveNodeProps } from '../bindings';
+import { createEnvelope } from '../envelope';
+import { collectNodeTypes, createNode, type AnyDocumentNode, type NodeProps } from '../node';
+import { composeRegistry, missingTypes, registeredTypes, resolveHandler } from '../registry';
+
+describe('bindings', () => {
+ const scope = { row: { title: 'Post', author: { name: 'Dan' } }, ready: false };
+
+ it('reads dotted paths and survives missing branches', () => {
+ expect(readPath(scope, 'row.author.name')).toBe('Dan');
+ expect(readPath(scope, 'row.missing.name')).toBeUndefined();
+ });
+
+ it('yields raw values for a lone placeholder and interpolates mixed templates', () => {
+ expect(resolveBinding('{{ ready }}', scope)).toBe(false);
+ expect(resolveBinding('{{ row.author }}', scope)).toEqual({ name: 'Dan' });
+ expect(resolveBinding('By {{ row.author.name }}', scope)).toBe('By Dan');
+ expect(resolveBinding('By {{ row.missing }}', scope)).toBe('By ');
+ });
+
+ it('resolves a node\u2019s bindings over its static props', () => {
+ const node = createNode('Field', 'title', {
+ props: { label: 'Static', name: 'title' },
+ bindings: { label: '{{ row.title }}' },
+ });
+ expect(resolveNodeProps(node, scope)).toEqual({ label: 'Post', name: 'title' });
+ });
+
+ it('layers scopes left to right', () => {
+ expect(composeScope({ a: 1, b: 1 }, undefined, { b: 2 })).toEqual({ a: 1, b: 2 });
+ });
+});
+
+describe('registry', () => {
+ it('layers registries, later layers winning', () => {
+ const registry = composeRegistry({ A: 'base', B: 'base' }, undefined, { B: 'app' });
+ expect(registry).toEqual({ A: 'base', B: 'app' });
+ expect(registeredTypes(registry)).toEqual(['A', 'B']);
+ expect(resolveHandler(registry, 'B')).toBe('app');
+ expect(resolveHandler(registry, 'C')).toBeUndefined();
+ });
+
+ it('reports the node types a document uses that no layer satisfies', () => {
+ const page = createNode('Root', 'root', { children: [createNode('Leaf', 'leaf')] });
+ expect(missingTypes({ Root: 'x' }, collectNodeTypes(page))).toEqual(['Leaf']);
+ });
+});
+
+describe('adapter contract', () => {
+ it('resolves known and unknown node types', () => {
+ expect(resolveNode({ Leaf: 'handler' }, 'Leaf')).toEqual({
+ status: 'resolved',
+ type: 'Leaf',
+ handler: 'handler',
+ });
+ expect(resolveNode({}, 'Leaf')).toEqual({ status: 'unknown', type: 'Leaf' });
+ });
+
+ it('is implementable without a framework', () => {
+ type Handler = (props: NodeProps, children: string) => string;
+
+ const stringAdapter: RendererAdapter = {
+ name: 'test/string',
+ resolve: (type, context) => resolveNode(context.registry, type),
+ resolveProps: (node, context) => resolveNodeProps(node, context.scope),
+ renderNode(node, context) {
+ const resolution = this.resolve(node.type, context);
+ if (resolution.status === 'unknown') return this.renderUnknown(node, context);
+ const children = (node.children ?? [])
+ .map((child: AnyDocumentNode) => this.renderNode(child, context))
+ .join('');
+ return resolution.handler(this.resolveProps(node, context), children);
+ },
+ renderUnknown: (node) => `[unknown:${node.type}]`,
+ renderDocument: (document, context) => stringAdapter.renderNode(document.page, context),
+ };
+
+ const document = createEnvelope(
+ { documentType: 'Report', formatVersion: '1.0' },
+ createNode('Root', 'root', {
+ children: [
+ createNode('Text', 'text', { bindings: { value: '{{ row.title }}' } }),
+ createNode('Nope', 'nope'),
+ ],
+ }),
+ );
+
+ const output = stringAdapter.renderDocument(document, {
+ document,
+ registry: {
+ Root: (_props, children) => `${children}
`,
+ Text: (props) => String(props.value),
+ },
+ scope: { row: { title: 'Post' } },
+ });
+
+ expect(output).toBe('Post[unknown:Nope]
');
+ });
+});
diff --git a/packages/json-renderer/src/__tests__/compose.test.ts b/packages/json-renderer/src/__tests__/compose.test.ts
new file mode 100644
index 0000000..9755fb5
--- /dev/null
+++ b/packages/json-renderer/src/__tests__/compose.test.ts
@@ -0,0 +1,162 @@
+import { describe, expect, it } from 'vitest';
+
+import { composeEnvelope, composeNodeTree, mergeEnvelopes, mergeNodeTrees } from '../compose';
+import { createEnvelope } from '../envelope';
+import { node } from './helpers';
+
+const KIND = { documentType: 'Report', formatVersion: '1.0' } as const;
+
+describe('fragment expansion', () => {
+ it('replaces a fragment node with the referenced subtree', () => {
+ const tree = node('Root', 'root', {
+ children: [node('Fragment', 'ref', { props: { ref: 'address' } })],
+ });
+ const composed = composeNodeTree(tree, {
+ fragments: { address: node('Group', 'address', { props: { label: 'Address' } }) },
+ });
+ expect(composed.children).toEqual([
+ { type: 'Group', key: 'address', props: { label: 'Address' }, children: [] },
+ ]);
+ });
+
+ it('keeps an unresolved fragment in the tree so the gap is visible', () => {
+ const composed = composeNodeTree(
+ node('Root', 'root', { children: [node('Fragment', 'ref', { props: { ref: 'missing' } })] }),
+ );
+ expect(composed.children[0]).toMatchObject({ type: 'Fragment', props: { ref: 'missing' } });
+ });
+
+ it('expands fragments inside fragments', () => {
+ const composed = composeNodeTree(
+ node('Root', 'root', { children: [node('Fragment', 'a', { props: { ref: 'outer' } })] }),
+ {
+ fragments: {
+ outer: node('Group', 'outer', {
+ children: [node('Fragment', 'b', { props: { ref: 'inner' } })],
+ }),
+ inner: node('Leaf', 'inner'),
+ },
+ },
+ );
+ expect(composed.children[0].children[0].type).toBe('Leaf');
+ });
+
+ it('honours a vocabulary that spells indirection differently', () => {
+ const composed = composeNodeTree(
+ node('Root', 'root', { children: [node('include', 'i', { props: { from: 'body' } })] }),
+ {
+ fragments: { body: node('Leaf', 'body') },
+ vocabulary: { fragmentNodeType: 'include', fragmentRefProp: 'from' },
+ },
+ );
+ expect(composed.children[0].type).toBe('Leaf');
+ });
+});
+
+describe('slot filling', () => {
+ const tree = node('Root', 'root', {
+ children: [
+ node('Slot', 'header', {
+ props: { name: 'header' },
+ children: [node('Leaf', 'default-header')],
+ }),
+ ],
+ });
+
+ it('fills a named slot, accepting one node or many', () => {
+ expect(composeNodeTree(tree, { slots: { header: node('Custom', 'custom') } }).children).toHaveLength(1);
+ expect(
+ composeNodeTree(tree, { slots: { header: [node('A', 'a'), node('B', 'b')] } }).children.map(
+ (child) => child.key,
+ ),
+ ).toEqual(['a', 'b']);
+ });
+
+ it('falls back to the slot children as default content', () => {
+ expect(composeNodeTree(tree).children).toEqual([{ type: 'Leaf', key: 'default-header', props: {}, children: [] }]);
+ });
+});
+
+describe('overrides', () => {
+ const tree = node('Root', 'root', {
+ children: [
+ node('Field', 'title', { props: { label: 'Title', name: 'title' } }),
+ node('Field', 'legacy'),
+ ],
+ });
+
+ it('merges props, bindings, actions, and can retype a node', () => {
+ const composed = composeNodeTree(tree, {
+ overrides: {
+ title: {
+ type: 'Textarea',
+ props: { label: 'Headline' },
+ bindings: { value: '{{ row.title }}' },
+ actions: { change: { type: 'handler', handler: 'onTitle' } },
+ },
+ },
+ });
+ expect(composed.children[0]).toMatchObject({
+ type: 'Textarea',
+ props: { label: 'Headline', name: 'title' },
+ bindings: { value: '{{ row.title }}' },
+ actions: { change: { type: 'handler', handler: 'onTitle' } },
+ });
+ });
+
+ it('removes a node and its subtree', () => {
+ const composed = composeNodeTree(tree, { overrides: { legacy: { remove: true } } });
+ expect(composed.children.map((child) => child.key)).toEqual(['title']);
+ });
+
+ it('never mutates its input', () => {
+ composeNodeTree(tree, { overrides: { title: { props: { label: 'Changed' } } } });
+ expect(tree.children[0].props.label).toBe('Title');
+ });
+});
+
+describe('merge', () => {
+ it('merges node trees by key and appends unmatched children', () => {
+ const base = node('Root', 'root', {
+ children: [node('Field', 'title', { props: { label: 'Title', name: 'title' } })],
+ });
+ const overlay = node('Root', 'root', {
+ children: [node('Field', 'title', { props: { label: 'Headline' } }), node('Field', 'extra')],
+ });
+ const merged = mergeNodeTrees(base, overlay);
+ expect(merged.children[0].props).toEqual({ label: 'Headline', name: 'title' });
+ expect(merged.children.map((child) => child.key)).toEqual(['title', 'extra']);
+ });
+
+ it('merges envelopes: meta shallow, registries and data sources by name, page by key', () => {
+ const base = createEnvelope(KIND, node('Root', 'root'), {
+ id: 'base',
+ meta: { title: 'Base', description: 'Kept' },
+ registries: [{ name: 'core', url: 'https://a/{name}.json' }],
+ dataSources: [{ name: 'rows', query: 'base' }],
+ });
+ const merged = mergeEnvelopes(base, {
+ meta: { title: 'Overlay' },
+ registries: [{ name: 'core', url: 'https://b/{name}.json' }],
+ dataSources: [{ name: 'extra' }],
+ page: node('Root', 'root', { props: { padded: true } }),
+ });
+ expect(merged.meta).toEqual({ title: 'Overlay', description: 'Kept' });
+ expect(merged.registries).toEqual([{ name: 'core', url: 'https://b/{name}.json' }]);
+ expect(merged.dataSources?.map((source) => source.name)).toEqual(['rows', 'extra']);
+ expect(merged.page.props).toEqual({ padded: true });
+ });
+});
+
+describe('composeEnvelope', () => {
+ it('composes the page while preserving the envelope', () => {
+ const document = createEnvelope(
+ KIND,
+ node('Root', 'root', { children: [node('Fragment', 'f', { props: { ref: 'body' } })] }),
+ { id: 'report' },
+ );
+ const composed = composeEnvelope(document, { fragments: { body: node('Leaf', 'body') } });
+ expect(composed).toMatchObject({ formatVersion: '1.0', type: 'Report', id: 'report' });
+ expect(composed.page.children[0].type).toBe('Leaf');
+ });
+});
diff --git a/packages/json-renderer/src/__tests__/document.test.ts b/packages/json-renderer/src/__tests__/document.test.ts
new file mode 100644
index 0000000..6a8557d
--- /dev/null
+++ b/packages/json-renderer/src/__tests__/document.test.ts
@@ -0,0 +1,125 @@
+import { describe, expect, it } from 'vitest';
+
+import { createEnvelope, DOCUMENT_FORMAT_VERSION, isDocumentEnvelope } from '../envelope';
+import { collectFieldConstraints, collectDefaultValues, collectFieldNames } from '../fields';
+import { toEnvelopeJsonSchema, toNodeJsonSchema } from '../json-schema';
+import { collectNodeTypes, findNodeByKey, mapNodes, walkNodes } from '../node';
+import { node } from './helpers';
+import { validateValue } from '../constraints';
+import { createDocumentSchema, parseEnvelope, parseNode, safeParseEnvelope } from '../zod';
+
+const KIND = { documentType: 'Report', formatVersion: DOCUMENT_FORMAT_VERSION } as const;
+
+function document() {
+ return createEnvelope(
+ KIND,
+ node('Root', 'root', {
+ children: [
+ node('Field', 'title', { props: { name: 'title', defaultValue: 'Hi', required: true } }),
+ node('Field', 'count', { props: { name: 'count', constraints: { maxValue: 10 } } }),
+ ],
+ }),
+ { id: 'report' },
+ );
+}
+
+const isField = (node: { type: string }) => node.type === 'Field';
+
+describe('envelope', () => {
+ it('creates a versioned envelope around a node tree', () => {
+ const created = document();
+ expect(created).toMatchObject({ formatVersion: '1.0', type: 'Report', id: 'report' });
+ expect(created.page.children).toHaveLength(2);
+ });
+
+ it('discriminates envelopes by kind', () => {
+ expect(isDocumentEnvelope(document(), KIND)).toBe(true);
+ expect(isDocumentEnvelope(document(), { documentType: 'Other', formatVersion: '1.0' })).toBe(false);
+ expect(isDocumentEnvelope({ type: 'Report' }, KIND)).toBe(false);
+ });
+});
+
+describe('nodes', () => {
+ it('walks, finds, and collects types', () => {
+ const page = document().page;
+ expect([...walkNodes(page)].map((node) => node.key)).toEqual(['root', 'title', 'count']);
+ expect(findNodeByKey(page, 'count')?.props.name).toBe('count');
+ expect(findNodeByKey(page, 'missing')).toBeUndefined();
+ expect(collectNodeTypes(page)).toEqual(['Field', 'Root']);
+ });
+
+ it('rewrites a tree without mutating the input', () => {
+ const page = document().page;
+ const mapped = mapNodes(page, (node) => ({ ...node, props: { ...node.props, seen: true } }));
+ expect(mapped.children[0].props.seen).toBe(true);
+ expect(page.children[0].props.seen).toBeUndefined();
+ });
+});
+
+describe('fields', () => {
+ it('collects names, defaults, and constraints through a predicate', () => {
+ const page = document().page;
+ expect(collectFieldNames(page, isField)).toEqual(['title', 'count']);
+ expect(collectDefaultValues(page, isField)).toEqual({ title: 'Hi' });
+ expect(collectFieldConstraints(page, isField)).toEqual({
+ title: { constraints: undefined, required: true },
+ count: { constraints: { maxValue: 10 }, required: undefined },
+ });
+ });
+
+ it('ignores nodes the predicate rejects', () => {
+ expect(collectFieldNames(document().page, (node) => node.type === 'Nope')).toEqual([]);
+ });
+});
+
+describe('validateValue', () => {
+ it('reports required, length, numeric, and pattern failures', () => {
+ expect(validateValue('', undefined, true)).toBe('This field is required');
+ expect(validateValue('ab', { minLength: 3 })).toMatch(/Minimum 3 characters/);
+ expect(validateValue('abcd', { maxLength: 3 })).toMatch(/Maximum 3 characters/);
+ expect(validateValue(1, { minValue: 2 })).toMatch(/Minimum value is 2/);
+ expect(validateValue(3, { maxValue: 2 })).toMatch(/Maximum value is 2/);
+ expect(validateValue('nope', { pattern: '^[0-9]+$' })).toBeTruthy();
+ });
+
+ it('passes valid and absent optional values', () => {
+ expect(validateValue('abc', { minLength: 3, maxLength: 3 })).toBeNull();
+ expect(validateValue(undefined, { minLength: 3 })).toBeNull();
+ });
+});
+
+describe('validation', () => {
+ it('parses a valid envelope and defaults optional node members', () => {
+ const parsed = parseEnvelope({
+ formatVersion: '1.0',
+ type: 'Report',
+ id: 'report',
+ page: { type: 'Root', key: 'root' },
+ });
+ expect(parsed.page.children).toEqual([]);
+ expect(parsed.page.props).toEqual({});
+ });
+
+ it('accepts node types it has never heard of', () => {
+ expect(parseNode({ type: 'chart.line', key: 'chart' }).type).toBe('chart.line');
+ });
+
+ it('rejects a missing page and an empty key', () => {
+ expect(safeParseEnvelope({ formatVersion: '1.0', type: 'Report', id: 'r' }).success).toBe(false);
+ expect(safeParseEnvelope({ formatVersion: '1.0', type: 'Report', id: 'r', page: { type: 'Root', key: '' } }).success).toBe(false);
+ });
+
+ it('pins kind through a specialized schema', () => {
+ const schema = createDocumentSchema({ kind: { documentType: 'Report', formatVersion: '1.0' } });
+ expect(schema.safeParse(document()).success).toBe(true);
+ expect(schema.safeParse({ ...document(), type: 'Other' }).success).toBe(false);
+ });
+});
+
+describe('JSON Schema export', () => {
+ it('exports the envelope and node schemas', () => {
+ const envelope = toEnvelopeJsonSchema() as Record;
+ expect(envelope).toHaveProperty('properties');
+ expect(toNodeJsonSchema()).toHaveProperty('properties');
+ });
+});
diff --git a/packages/json-renderer/src/__tests__/helpers.ts b/packages/json-renderer/src/__tests__/helpers.ts
new file mode 100644
index 0000000..1f5be6d
--- /dev/null
+++ b/packages/json-renderer/src/__tests__/helpers.ts
@@ -0,0 +1,24 @@
+import {
+ createNode,
+ type AnyDocumentNode,
+ type NodeActions,
+ type NodeBindings,
+ type NodeProps,
+} from '../node';
+
+/**
+ * Builds a node in an open vocabulary, which is how a generic host uses the
+ * model: `type` is any string and props are unconstrained.
+ */
+export function node(
+ type: string,
+ key: string,
+ options: {
+ props?: NodeProps;
+ children?: AnyDocumentNode[];
+ bindings?: NodeBindings;
+ actions?: NodeActions;
+ } = {},
+): AnyDocumentNode {
+ return createNode(type, key, options);
+}
diff --git a/packages/json-renderer/src/adapter.ts b/packages/json-renderer/src/adapter.ts
new file mode 100644
index 0000000..8ab428b
--- /dev/null
+++ b/packages/json-renderer/src/adapter.ts
@@ -0,0 +1,106 @@
+/**
+ * The adapter contract.
+ *
+ * `json-renderer` never renders anything: it defines the document, and the
+ * interface a renderer implements to turn that document into output. An adapter
+ * is generic over two things — the handler it resolves a node type to
+ * (`THandler`: a React component, a string template, a serializer) and the output
+ * it produces (`TOutput`: a React element, a string, a DOM node).
+ *
+ * An adapter owes three answers:
+ *
+ * 1. **Registry resolution** — node type → handler, layered (see
+ * {@link composeRegistry}).
+ * 2. **Binding scope access** — the scope a node's bindings resolve against, and
+ * how scope is layered for nested content.
+ * 3. **Unknown-node handling** — what happens when no layer satisfies a type.
+ * A document may name nodes a given host has not installed, so the default is
+ * a visible placeholder, never a thrown render.
+ *
+ * `blocks-renderer` is the reference implementation (React + shadcn).
+ */
+import type { BindingScope } from './bindings';
+import type { AnyDocumentEnvelope } from './envelope';
+import type { AnyDocumentNode, NodeAction, NodeProps } from './node';
+import type { NodeRegistry } from './registry';
+
+/** How an adapter treats a node type no registry layer satisfies. */
+export type UnknownNodePolicy = 'fallback' | 'omit' | 'throw';
+
+/** Resolution outcome for one node type. */
+export type NodeResolution =
+ | { status: 'resolved'; type: string; handler: THandler }
+ | { status: 'unknown'; type: string };
+
+export function resolveNode(registry: NodeRegistry, type: string): NodeResolution {
+ const handler = registry[type];
+ return handler === undefined ? { status: 'unknown', type } : { status: 'resolved', type, handler };
+}
+
+/**
+ * Everything an adapter needs while walking one document. A React adapter puts
+ * this in context; a string adapter threads it through its recursion.
+ */
+export interface RenderContextBase {
+ document: TDocument;
+ registry: NodeRegistry;
+ /** Scope for binding expressions (`{{ row.title }}`). */
+ scope: BindingScope;
+ unknownNodePolicy?: UnknownNodePolicy;
+}
+
+/**
+ * The context plus action dispatch. `TAction` lets a vocabulary narrow its
+ * action union (`blocks-schema`'s `UIAction`) without restating the context.
+ */
+export interface RenderContext<
+ THandler,
+ TDocument extends AnyDocumentEnvelope = AnyDocumentEnvelope,
+ TAction extends NodeAction = NodeAction,
+> extends RenderContextBase {
+ /** Declarative actions are dispatched to the host, never executed here. */
+ onAction?: (action: TAction, event: string) => void;
+}
+
+/** Props an adapter hands a resolved handler: the node plus its resolved props. */
+export interface NodeRenderInput {
+ node: AnyDocumentNode;
+ props: NodeProps;
+ children?: TOutput;
+}
+
+/**
+ * The interface a renderer implements. Methods are the contract, not a base
+ * class: adapters are free to be a single function, a class, or a React tree.
+ */
+export interface RendererAdapter<
+ THandler,
+ TOutput,
+ TDocument extends AnyDocumentEnvelope = AnyDocumentEnvelope,
+ TContext extends RenderContextBase = RenderContext,
+> {
+ /** Adapter identity, for diagnostics: e.g. `blocks-renderer/react`. */
+ readonly name: string;
+ /** Node type → handler, after layering. */
+ resolve(type: string, context: TContext): NodeResolution;
+ /** Resolve a node's bindings against the context scope. */
+ resolveProps(node: AnyDocumentNode, context: TContext): NodeProps;
+ /** Render one node (and, recursively, its children). */
+ renderNode(node: AnyDocumentNode, context: TContext): TOutput;
+ /** Render the placeholder for an unsatisfied node type. */
+ renderUnknown(node: AnyDocumentNode, context: TContext): TOutput;
+ /** Render a whole document. */
+ renderDocument(document: TDocument, context: TContext): TOutput;
+}
+
+/**
+ * A field-value store an adapter exposes to interactive nodes. Kept in the core
+ * so form semantics (value, error, validate-on-change) are one contract across
+ * adapters instead of per-framework inventions.
+ */
+export interface FieldStateAccess {
+ getValue(name: string): unknown;
+ setValue(name: string, value: unknown): void;
+ getError(name: string): string | undefined;
+ setError(name: string, error: string | null): void;
+}
diff --git a/packages/json-renderer/src/bindings.ts b/packages/json-renderer/src/bindings.ts
new file mode 100644
index 0000000..ee99a91
--- /dev/null
+++ b/packages/json-renderer/src/bindings.ts
@@ -0,0 +1,68 @@
+/**
+ * Binding and scope resolution. A binding is a template expression over a scope
+ * object; resolution is pure string/object work, so it runs in a renderer, in an
+ * SSR pass, or in a validator with no framework present.
+ */
+import type { AnyDocumentNode, NodeProps } from './node';
+
+const TEMPLATE = /\{\{\s*([^}\s]+)\s*\}\}/g;
+
+/** The data a document's expressions read from, e.g. `{ row, user, values }`. */
+export interface BindingScope {
+ [key: string]: unknown;
+}
+
+/** Read a dotted path (`row.author.name`) out of a scope object. */
+export function readPath(scope: BindingScope, path: string): unknown {
+ let current: unknown = scope;
+ for (const segment of path.split('.')) {
+ if (current == null || typeof current !== 'object') return undefined;
+ current = (current as Record)[segment];
+ }
+ return current;
+}
+
+/**
+ * Resolve a binding expression. A template that is exactly one placeholder
+ * yields the raw value (so a boolean or an object survives); a template mixed
+ * with text is interpolated as a string.
+ */
+export function resolveBinding(expression: string, scope: BindingScope): unknown {
+ const single = expression.match(/^\{\{\s*([^}\s]+)\s*\}\}$/);
+ if (single) {
+ return readPath(scope, single[1]);
+ }
+
+ return expression.replace(TEMPLATE, (_match, path: string) => {
+ const value = readPath(scope, path);
+ return value == null ? '' : String(value);
+ });
+}
+
+/** Apply a node's `bindings` over its static props. */
+export function resolveNodeProps(
+ node: AnyDocumentNode,
+ scope: BindingScope,
+): TProps {
+ if (!node.bindings) return (node.props ?? {}) as TProps;
+
+ const resolved: NodeProps = { ...(node.props ?? {}) };
+ for (const [prop, expression] of Object.entries(node.bindings)) {
+ resolved[prop] = resolveBinding(expression, scope);
+ }
+ return resolved as TProps;
+}
+
+/**
+ * Layer scopes left-to-right, later layers winning. Scope layering is how a
+ * renderer adds row/item context inside a repeating node without rebuilding the
+ * document's scope.
+ */
+export function composeScope(...layers: (BindingScope | undefined)[]): BindingScope {
+ const composed: BindingScope = {};
+ for (const layer of layers) {
+ if (!layer) continue;
+ Object.assign(composed, layer);
+ }
+ return composed;
+}
diff --git a/packages/json-renderer/src/compose.ts b/packages/json-renderer/src/compose.ts
new file mode 100644
index 0000000..8ca69e6
--- /dev/null
+++ b/packages/json-renderer/src/compose.ts
@@ -0,0 +1,198 @@
+/**
+ * Composition ops: fragment expansion, slot filling, per-node overrides, and
+ * document merge. Pure JSON in, pure JSON out — nothing here needs a renderer,
+ * so a generated document can be customized on a server before it ships.
+ */
+import type { AnyDocumentEnvelope, DocumentDataSource, RegistrySource } from './envelope';
+import type { AnyDocumentNode, NodeActions, NodeBindings, NodeProps } from './node';
+
+/**
+ * A patch applied to the node with a given `key`. Composition is per node, not
+ * per document, so a generated default can be customized in a few places
+ * without giving up generation.
+ */
+export interface NodeOverride {
+ type?: TType;
+ props?: TProps;
+ bindings?: NodeBindings;
+ actions?: NodeActions;
+ /** Drop the node (and its subtree) from the composed document. */
+ remove?: boolean;
+}
+
+export type NodeOverrides = Record<
+ string,
+ NodeOverride
+>;
+
+/** Reusable subtrees addressed by fragment nodes. */
+export type FragmentMap = Record;
+
+/** Subtrees that fill slot nodes, addressed by slot name. */
+export type SlotMap = Record;
+
+/**
+ * Which node types and props carry composition. A vocabulary that spells its
+ * indirection nodes differently (`include`/`outlet`) configures them here rather
+ * than forking composition.
+ */
+export interface ComposeVocabulary {
+ fragmentNodeType: string;
+ slotNodeType: string;
+ /** Prop on a fragment node naming the fragment to expand. */
+ fragmentRefProp: string;
+ /** Prop on a slot node naming the slot to fill. */
+ slotNameProp: string;
+}
+
+export const DEFAULT_COMPOSE_VOCABULARY: ComposeVocabulary = {
+ fragmentNodeType: 'Fragment',
+ slotNodeType: 'Slot',
+ fragmentRefProp: 'ref',
+ slotNameProp: 'name',
+};
+
+export interface ComposeOptions {
+ fragments?: FragmentMap;
+ slots?: SlotMap;
+ overrides?: NodeOverrides;
+ vocabulary?: Partial;
+}
+
+function vocabularyOf(options: ComposeOptions | ComposeOptions): ComposeVocabulary {
+ return { ...DEFAULT_COMPOSE_VOCABULARY, ...options.vocabulary };
+}
+
+function applyOverride(node: TNode, override: NodeOverride): TNode {
+ return {
+ ...node,
+ ...(override.type ? { type: override.type } : {}),
+ props: { ...node.props, ...override.props },
+ ...(override.bindings ? { bindings: { ...node.bindings, ...override.bindings } } : {}),
+ ...(override.actions ? { actions: { ...node.actions, ...override.actions } } : {}),
+ };
+}
+
+function expandChild(
+ node: TNode,
+ options: ComposeOptions,
+ vocabulary: ComposeVocabulary,
+): TNode[] {
+ if (node.type === vocabulary.fragmentNodeType) {
+ const ref = node.props?.[vocabulary.fragmentRefProp];
+ const fragment = typeof ref === 'string' ? options.fragments?.[ref] : undefined;
+ // An unresolved reference stays in the tree so the renderer surfaces it
+ // rather than silently dropping content.
+ return fragment
+ ? [composeNode(fragment, options, vocabulary)]
+ : [composeNode({ ...node, children: [] }, options, vocabulary)];
+ }
+
+ if (node.type === vocabulary.slotNodeType) {
+ const name = node.props?.[vocabulary.slotNameProp];
+ const filler = typeof name === 'string' ? options.slots?.[name] : undefined;
+ if (filler === undefined) {
+ // No filler: fall back to the slot's own children (its default content).
+ return ((node.children ?? []) as TNode[]).flatMap((child) => expandChild(child, options, vocabulary));
+ }
+ const nodes = Array.isArray(filler) ? filler : [filler];
+ return nodes.map((filled) => composeNode(filled, options, vocabulary));
+ }
+
+ return [composeNode(node, options, vocabulary)];
+}
+
+function composeNode(
+ node: TNode,
+ options: ComposeOptions,
+ vocabulary: ComposeVocabulary,
+): TNode {
+ const override = options.overrides?.[node.key];
+ const base = override ? applyOverride(node, override) : node;
+
+ const children = ((base.children ?? []) as TNode[])
+ .filter((child) => !options.overrides?.[child.key]?.remove)
+ .flatMap((child) => expandChild(child, options, vocabulary));
+
+ return { ...base, children } as TNode;
+}
+
+/**
+ * Compose a document: expand fragment references, fill slots, then apply
+ * per-node overrides. Pure — the input document is never mutated.
+ */
+export function composeEnvelope(
+ document: TEnvelope,
+ options: ComposeOptions = {},
+): TEnvelope {
+ return { ...document, page: composeNode(document.page, options, vocabularyOf(options)) };
+}
+
+export function composeNodeTree(
+ node: TNode,
+ options: ComposeOptions = {},
+): TNode {
+ return composeNode(node, options, vocabularyOf(options));
+}
+
+/**
+ * Merge an overlay tree onto a base tree by node `key`: matching nodes have
+ * their props, bindings, and actions shallow-merged (overlay wins) and their
+ * children merged recursively; overlay children with no match are appended.
+ * This is the "hand-authored beats generated, at the node level" rule.
+ */
+export function mergeNodeTrees(base: TNode, overlay: TNode): TNode {
+ const merged: TNode = {
+ ...base,
+ ...(overlay.type ? { type: overlay.type } : {}),
+ props: { ...base.props, ...overlay.props },
+ ...(base.bindings || overlay.bindings ? { bindings: { ...base.bindings, ...overlay.bindings } } : {}),
+ ...(base.actions || overlay.actions ? { actions: { ...base.actions, ...overlay.actions } } : {}),
+ };
+
+ const overlayChildren = (overlay.children ?? []) as TNode[];
+ const byKey = new Map(overlayChildren.map((child) => [child.key, child]));
+ const consumed = new Set();
+
+ const children = ((base.children ?? []) as TNode[]).map((child) => {
+ const patch = byKey.get(child.key);
+ if (!patch) return child;
+ consumed.add(child.key);
+ return mergeNodeTrees(child, patch);
+ });
+
+ for (const child of overlayChildren) {
+ if (!consumed.has(child.key)) children.push(child);
+ }
+
+ return { ...merged, children } as TNode;
+}
+
+function mergeNamed(base?: T[], overlay?: T[]): T[] | undefined {
+ if (!base && !overlay) return undefined;
+ const merged = new Map((base ?? []).map((entry) => [entry.name, entry]));
+ for (const entry of overlay ?? []) merged.set(entry.name, entry);
+ return [...merged.values()];
+}
+
+/**
+ * Merge an overlay document onto a base document: envelope identity comes from
+ * the overlay when set, `meta` is shallow-merged, `registries` and `dataSources`
+ * are merged by name, and the page trees are merged by node key.
+ */
+export function mergeEnvelopes(
+ base: TEnvelope,
+ overlay: Partial> & { page?: TEnvelope['page'] },
+): TEnvelope {
+ const registries = mergeNamed(base.registries, overlay.registries);
+ const dataSources = mergeNamed(base.dataSources, overlay.dataSources);
+
+ return {
+ ...base,
+ ...overlay,
+ ...(base.meta || overlay.meta ? { meta: { ...base.meta, ...overlay.meta } } : {}),
+ ...(registries ? { registries } : {}),
+ ...(dataSources ? { dataSources } : {}),
+ page: overlay.page ? mergeNodeTrees(base.page, overlay.page) : base.page,
+ } as TEnvelope;
+}
diff --git a/packages/json-renderer/src/constraints.ts b/packages/json-renderer/src/constraints.ts
new file mode 100644
index 0000000..ea9951d
--- /dev/null
+++ b/packages/json-renderer/src/constraints.ts
@@ -0,0 +1,67 @@
+/**
+ * Value constraints a node can declare, and the pure check over them. Documents
+ * are authored elsewhere (a JSON Schema, a database constraint, an agent), so
+ * this layer never throws on malformed input.
+ */
+
+export interface NodeConstraints {
+ minLength?: number;
+ maxLength?: number;
+ minValue?: number;
+ maxValue?: number;
+ pattern?: string;
+ precision?: number;
+ scale?: number;
+}
+
+/**
+ * Validate a single value against the constraints declared on its node.
+ * Returns a human-readable message, or `null` when the value is acceptable.
+ */
+export function validateValue(value: unknown, constraints?: NodeConstraints, required?: boolean): string | null {
+ const stringValue = value == null ? '' : String(value);
+ const isEmpty = stringValue.trim() === '';
+
+ if (required && isEmpty) {
+ return 'This field is required';
+ }
+
+ if (isEmpty) return null;
+
+ if (constraints?.minLength != null && stringValue.length < constraints.minLength) {
+ return `Minimum ${constraints.minLength} characters required`;
+ }
+
+ if (constraints?.maxLength != null && stringValue.length > constraints.maxLength) {
+ return `Maximum ${constraints.maxLength} characters allowed`;
+ }
+
+ if (constraints?.minValue != null && typeof value === 'number' && value < constraints.minValue) {
+ return `Minimum value is ${constraints.minValue}`;
+ }
+
+ if (constraints?.maxValue != null && typeof value === 'number' && value > constraints.maxValue) {
+ return `Maximum value is ${constraints.maxValue}`;
+ }
+
+ if (constraints?.pattern) {
+ const regex = compilePattern(constraints.pattern);
+ if (regex && !regex.test(stringValue)) {
+ return 'Invalid format';
+ }
+ }
+
+ return null;
+}
+
+/**
+ * An uncompilable pattern must not take the document down — it is reported as
+ * unconstrained rather than as a failed value.
+ */
+function compilePattern(pattern: string): RegExp | null {
+ try {
+ return new RegExp(pattern);
+ } catch {
+ return null;
+ }
+}
diff --git a/packages/json-renderer/src/envelope.ts b/packages/json-renderer/src/envelope.ts
new file mode 100644
index 0000000..b1e00e8
--- /dev/null
+++ b/packages/json-renderer/src/envelope.ts
@@ -0,0 +1,95 @@
+/**
+ * The document envelope: a versioned container around one node tree, plus the
+ * resolution sources and named data the tree binds against.
+ */
+import type { AnyDocumentNode } from './node';
+
+export const DOCUMENT_FORMAT_VERSION = '1.0';
+
+export interface DocumentMetadata {
+ title?: string;
+ description?: string;
+ [key: string]: unknown;
+}
+
+/**
+ * A resolution source for node types: a registry name and a URL template, e.g.
+ * `https://example.com/r/{name}.json`.
+ */
+export interface RegistrySource {
+ name: string;
+ url: string;
+}
+
+/** A named, read-only query a document's nodes can bind against. */
+export interface DocumentDataSource {
+ name: string;
+ query?: string;
+ variables?: Record;
+ [key: string]: unknown;
+}
+
+/**
+ * The generic envelope. `TNode` is the node tree, `TKind` the envelope's `type`
+ * discriminator, and `TVersion` its format version — a concrete format pins
+ * those as literal types (see `blocks-schema`'s `UIDocument`).
+ */
+export interface DocumentEnvelope<
+ TNode extends AnyDocumentNode = AnyDocumentNode,
+ TKind extends string = string,
+ TVersion extends string = string,
+> {
+ formatVersion: TVersion;
+ type: TKind;
+ id: string;
+ meta?: DocumentMetadata;
+ registries?: RegistrySource[];
+ dataSources?: DocumentDataSource[];
+ page: TNode;
+}
+
+/** Any envelope, whatever its kind and vocabulary. */
+export type AnyDocumentEnvelope = DocumentEnvelope;
+
+export interface EnvelopeKind {
+ /** The envelope's `type` discriminator, e.g. `UISchema`. */
+ documentType: TKind;
+ formatVersion: TVersion;
+}
+
+/**
+ * Structural check against one envelope kind. Deliberately shallow: a full
+ * check is `parseDocument` (zod), this is the cheap discriminator a router or a
+ * content-kind switch needs.
+ */
+export function isDocumentEnvelope(
+ value: unknown,
+ kind: EnvelopeKind,
+): value is DocumentEnvelope {
+ if (!value || typeof value !== 'object') return false;
+ const candidate = value as Record;
+ return (
+ candidate.type === kind.documentType && candidate.formatVersion === kind.formatVersion && !!candidate.page
+ );
+}
+
+export function createEnvelope(
+ kind: EnvelopeKind,
+ page: TNode,
+ options: {
+ id?: string;
+ meta?: DocumentMetadata;
+ registries?: RegistrySource[];
+ dataSources?: DocumentDataSource[];
+ } = {},
+): DocumentEnvelope {
+ return {
+ formatVersion: kind.formatVersion,
+ type: kind.documentType,
+ id: options.id ?? 'document',
+ ...(options.meta ? { meta: options.meta } : {}),
+ ...(options.registries ? { registries: options.registries } : {}),
+ ...(options.dataSources ? { dataSources: options.dataSources } : {}),
+ page,
+ };
+}
diff --git a/packages/json-renderer/src/fields.ts b/packages/json-renderer/src/fields.ts
new file mode 100644
index 0000000..0a4a10b
--- /dev/null
+++ b/packages/json-renderer/src/fields.ts
@@ -0,0 +1,73 @@
+/**
+ * Field collection over a node tree. Which nodes are fields is a vocabulary
+ * decision, so every helper takes a predicate: the core knows the *shape* of a
+ * field (a `name`, an optional `defaultValue`, optional `constraints`) without
+ * knowing the node types that carry it.
+ */
+import type { NodeConstraints } from './constraints';
+import type { AnyDocumentNode } from './node';
+import { walkNodes } from './node';
+
+/** Decides whether a node contributes a field. */
+export type FieldNodePredicate = (node: TNode) => boolean;
+
+export interface FieldConstraintEntry {
+ constraints?: NodeConstraints;
+ required?: boolean;
+}
+
+function fieldName(node: AnyDocumentNode): string | undefined {
+ const name = node.props?.name;
+ return typeof name === 'string' ? name : undefined;
+}
+
+function* fieldNodes(
+ node: TNode,
+ isFieldNode: FieldNodePredicate,
+): Generator<[TNode, string]> {
+ for (const current of walkNodes(node)) {
+ if (!isFieldNode(current)) continue;
+ const name = fieldName(current);
+ if (name === undefined) continue;
+ yield [current, name];
+ }
+}
+
+/** Named fields in document order; field nodes without a `name` are skipped. */
+export function collectFieldNames(
+ node: TNode,
+ isFieldNode: FieldNodePredicate,
+): string[] {
+ const names: string[] = [];
+ for (const [, name] of fieldNodes(node, isFieldNode)) names.push(name);
+ return names;
+}
+
+/** Default values declared by field nodes, keyed by field name. */
+export function collectDefaultValues(
+ node: TNode,
+ isFieldNode: FieldNodePredicate,
+): Record {
+ const values: Record = {};
+ for (const [current, name] of fieldNodes(node, isFieldNode)) {
+ if (current.props.defaultValue !== undefined) {
+ values[name] = current.props.defaultValue;
+ }
+ }
+ return values;
+}
+
+/** Validation metadata declared by field nodes, keyed by field name. */
+export function collectFieldConstraints(
+ node: TNode,
+ isFieldNode: FieldNodePredicate,
+): Record {
+ const result: Record = {};
+ for (const [current, name] of fieldNodes(node, isFieldNode)) {
+ result[name] = {
+ constraints: current.props.constraints as NodeConstraints | undefined,
+ required: current.props.required as boolean | undefined,
+ };
+ }
+ return result;
+}
diff --git a/packages/json-renderer/src/index.ts b/packages/json-renderer/src/index.ts
new file mode 100644
index 0000000..25beeca
--- /dev/null
+++ b/packages/json-renderer/src/index.ts
@@ -0,0 +1,10 @@
+export * from './adapter';
+export * from './bindings';
+export * from './compose';
+export * from './constraints';
+export * from './envelope';
+export * from './fields';
+export * from './json-schema';
+export * from './node';
+export * from './registry';
+export * from './zod';
diff --git a/packages/json-renderer/src/json-schema.ts b/packages/json-renderer/src/json-schema.ts
new file mode 100644
index 0000000..c80a16d
--- /dev/null
+++ b/packages/json-renderer/src/json-schema.ts
@@ -0,0 +1,19 @@
+/**
+ * JSON Schema export of the format, for agents emitting documents as tool output
+ * and for editors/registries that validate without importing zod.
+ */
+import { z } from 'zod';
+
+import { documentEnvelopeSchema, documentNodeSchema } from './zod';
+
+export function toJsonSchema(schema: z.ZodType): Record {
+ return z.toJSONSchema(schema, { io: 'input' }) as Record;
+}
+
+export function toEnvelopeJsonSchema(): Record {
+ return toJsonSchema(documentEnvelopeSchema);
+}
+
+export function toNodeJsonSchema(): Record {
+ return toJsonSchema(documentNodeSchema);
+}
diff --git a/packages/json-renderer/src/node.ts b/packages/json-renderer/src/node.ts
new file mode 100644
index 0000000..3279d52
--- /dev/null
+++ b/packages/json-renderer/src/node.ts
@@ -0,0 +1,110 @@
+/**
+ * The node tree model: a document is a tree of typed nodes, and a node's `type`
+ * is a plain string resolved by an adapter's registry. The vocabulary is a
+ * parameter, never a fixed list — a host that renders `Input`/`DataTable` and a
+ * host that renders `chart.line` share this model.
+ */
+
+/** Node props are open: an adapter reads what its components need. */
+export interface NodeProps {
+ [propName: string]: unknown;
+}
+
+/** Prop name → template expression, e.g. `{ label: '{{ row.title }}' }`. */
+export interface NodeBindings {
+ [propName: string]: string;
+}
+
+/**
+ * A declarative reference to behaviour that lives outside the document. Keeping
+ * actions declarative is what stops the format becoming a programming language.
+ */
+export interface NodeAction {
+ type: string;
+ flowId?: string;
+ handler?: string;
+ inputMapping?: Record;
+ params?: Record;
+}
+
+/** Event name → action, e.g. `{ submit: { type: 'flow', flowId } }`. */
+export interface NodeActions {
+ [eventName: string]: NodeAction;
+}
+
+/**
+ * A node in the document tree, generic over the node vocabulary (`TType`) and
+ * the props shape (`TProps`) a vocabulary declares.
+ */
+export interface DocumentNode {
+ type: TType;
+ key: string;
+ props: TProps;
+ children: DocumentNode[];
+ bindings?: NodeBindings;
+ actions?: NodeActions;
+}
+
+/** Any node tree, whatever its vocabulary. */
+export type AnyDocumentNode = DocumentNode;
+
+/** Depth-first walk over a node and its descendants. */
+export function* walkNodes(node: TNode): Generator {
+ yield node;
+ for (const child of (node.children ?? []) as TNode[]) {
+ yield* walkNodes(child);
+ }
+}
+
+export function findNodeByKey(node: TNode, key: string): TNode | undefined {
+ for (const current of walkNodes(node)) {
+ if (current.key === key) return current;
+ }
+ return undefined;
+}
+
+export function collectNodes(node: TNode, predicate: (node: TNode) => boolean): TNode[] {
+ const matches: TNode[] = [];
+ for (const current of walkNodes(node)) {
+ if (predicate(current)) matches.push(current);
+ }
+ return matches;
+}
+
+/**
+ * Rewrite a tree bottom-up. Pure: the input node is never mutated, so a
+ * transform is safe to run on a document that is also being rendered.
+ */
+export function mapNodes(node: TNode, transform: (node: TNode) => TNode): TNode {
+ const children = (node.children ?? []).map((child) => mapNodes(child as TNode, transform));
+ return transform({ ...node, children } as TNode);
+}
+
+/** Node types used anywhere in a tree, sorted for stable output. */
+export function collectNodeTypes(node: AnyDocumentNode): string[] {
+ const types = new Set();
+ for (const current of walkNodes(node)) {
+ types.add(current.type);
+ }
+ return [...types].sort();
+}
+
+export function createNode(
+ type: TType,
+ key: string,
+ options: {
+ props?: TProps;
+ children?: DocumentNode[];
+ bindings?: NodeBindings;
+ actions?: NodeActions;
+ } = {},
+): DocumentNode {
+ return {
+ type,
+ key,
+ props: options.props ?? ({} as TProps),
+ children: options.children ?? [],
+ ...(options.bindings ? { bindings: options.bindings } : {}),
+ ...(options.actions ? { actions: options.actions } : {}),
+ };
+}
diff --git a/packages/json-renderer/src/registry.ts b/packages/json-renderer/src/registry.ts
new file mode 100644
index 0000000..4c20048
--- /dev/null
+++ b/packages/json-renderer/src/registry.ts
@@ -0,0 +1,42 @@
+/**
+ * Registry resolution: node type → whatever an adapter renders with (a React
+ * component, a template function, an HTML serializer). The core owns the
+ * layering rules; the handler type is the adapter's business.
+ */
+
+/** Node type → handler. Layered by {@link composeRegistry}. */
+export type NodeRegistry = Record;
+
+/**
+ * Layer registries left-to-right, later layers winning. This is how a host
+ * customizes rendering: base primitives, then an app registry, then per-document
+ * overrides — no forking of the renderer, and no single global map.
+ */
+export function composeRegistry(
+ ...layers: (NodeRegistry | undefined)[]
+): NodeRegistry {
+ const composed: NodeRegistry = {};
+ for (const layer of layers) {
+ if (!layer) continue;
+ Object.assign(composed, layer);
+ }
+ return composed;
+}
+
+export function resolveHandler(registry: NodeRegistry, type: string): THandler | undefined {
+ return registry[type];
+}
+
+/** Node types the registry can render, sorted for stable output. */
+export function registeredTypes(registry: NodeRegistry): string[] {
+ return Object.keys(registry).sort();
+}
+
+/** Node types a document uses that no registry layer satisfies. */
+export function missingTypes(registry: NodeRegistry, usedTypes: Iterable): string[] {
+ const missing = new Set();
+ for (const type of usedTypes) {
+ if (!(type in registry)) missing.add(type);
+ }
+ return [...missing].sort();
+}
diff --git a/packages/json-renderer/src/zod.ts b/packages/json-renderer/src/zod.ts
new file mode 100644
index 0000000..148019b
--- /dev/null
+++ b/packages/json-renderer/src/zod.ts
@@ -0,0 +1,124 @@
+/**
+ * Runtime validation for the generic envelope. Concrete formats build their own
+ * schemas from these factories, so a vocabulary narrows props, actions, or node
+ * types without restating the tree rules.
+ */
+import { z } from 'zod';
+
+import { DOCUMENT_FORMAT_VERSION } from './envelope';
+import type { AnyDocumentEnvelope, EnvelopeKind } from './envelope';
+import type { AnyDocumentNode } from './node';
+
+export const nodeConstraintsSchema = z.object({
+ minLength: z.number().int().nonnegative().optional(),
+ maxLength: z.number().int().nonnegative().optional(),
+ minValue: z.number().optional(),
+ maxValue: z.number().optional(),
+ pattern: z.string().optional(),
+ precision: z.number().int().nonnegative().optional(),
+ scale: z.number().int().nonnegative().optional(),
+});
+
+/** Props are open by default: a vocabulary extends this with what it knows. */
+export const nodePropsSchema = z.looseObject({});
+
+export const nodeBindingsSchema = z.record(z.string(), z.string());
+
+export const nodeActionSchema = z.object({
+ type: z.string().min(1),
+ flowId: z.string().optional(),
+ handler: z.string().optional(),
+ inputMapping: z.record(z.string(), z.string()).optional(),
+ params: z.record(z.string(), z.unknown()).optional(),
+});
+
+export const nodeActionsSchema = z.record(z.string(), nodeActionSchema);
+
+export const registrySourceSchema = z.object({
+ name: z.string().min(1),
+ url: z.string().min(1),
+});
+
+export const dataSourceSchema = z.looseObject({
+ name: z.string().min(1),
+ query: z.string().optional(),
+ variables: z.record(z.string(), z.unknown()).optional(),
+});
+
+export const documentMetadataSchema = z.looseObject({
+ title: z.string().optional(),
+ description: z.string().optional(),
+});
+
+export interface NodeSchemaOptions {
+ /** Narrow the node vocabulary; unknown strings stay valid by default. */
+ typeSchema?: z.ZodType;
+ propsSchema?: z.ZodType>;
+ actionsSchema?: z.ZodType>;
+}
+
+/**
+ * Build the recursive node schema. Unknown node types pass by default: a
+ * registry may satisfy types this package has never heard of, and rejecting them
+ * here would make the format closed.
+ */
+export function createNodeSchema(
+ options: NodeSchemaOptions = {},
+): z.ZodType {
+ const schema: z.ZodType = z.lazy(() =>
+ z.object({
+ type: options.typeSchema ?? z.string().min(1),
+ key: z.string().min(1),
+ props: (options.propsSchema ?? nodePropsSchema).default({}),
+ children: z.array(schema).default([]),
+ bindings: nodeBindingsSchema.optional(),
+ actions: (options.actionsSchema ?? nodeActionsSchema).optional(),
+ }),
+ ) as unknown as z.ZodType;
+ return schema;
+}
+
+export const documentNodeSchema: z.ZodType = createNodeSchema();
+
+export interface DocumentSchemaOptions extends NodeSchemaOptions {
+ /** Pin the envelope discriminator and version, e.g. `UISchema` / `1.0`. */
+ kind?: Partial;
+ nodeSchema?: z.ZodType;
+}
+
+/** Build the envelope schema around a node schema. */
+export function createDocumentSchema<
+ TDocument extends AnyDocumentEnvelope = AnyDocumentEnvelope,
+ TNode extends AnyDocumentNode = AnyDocumentNode,
+>(options: DocumentSchemaOptions = {}): z.ZodType {
+ const nodeSchema = options.nodeSchema ?? (createNodeSchema(options) as z.ZodType);
+ return z.object({
+ formatVersion: options.kind?.formatVersion
+ ? z.literal(options.kind.formatVersion)
+ : z.string().min(1),
+ type: options.kind?.documentType ? z.literal(options.kind.documentType) : z.string().min(1),
+ id: z.string().min(1),
+ meta: documentMetadataSchema.optional(),
+ registries: z.array(registrySourceSchema).optional(),
+ dataSources: z.array(dataSourceSchema).optional(),
+ page: nodeSchema,
+ }) as unknown as z.ZodType;
+}
+
+export const documentEnvelopeSchema: z.ZodType = createDocumentSchema();
+
+/** Throws a `ZodError` describing every problem in the document. */
+export function parseEnvelope(value: unknown): AnyDocumentEnvelope {
+ return documentEnvelopeSchema.parse(value);
+}
+
+export function safeParseEnvelope(value: unknown) {
+ return documentEnvelopeSchema.safeParse(value);
+}
+
+export function parseNode(value: unknown): AnyDocumentNode {
+ return documentNodeSchema.parse(value);
+}
+
+/** The version this package's generic envelope schema accepts by default. */
+export const SCHEMA_FORMAT_VERSION = DOCUMENT_FORMAT_VERSION;
diff --git a/packages/json-renderer/tsconfig.esm.json b/packages/json-renderer/tsconfig.esm.json
new file mode 100644
index 0000000..aff046f
--- /dev/null
+++ b/packages/json-renderer/tsconfig.esm.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "dist/esm",
+ "module": "es2022",
+ "moduleResolution": "bundler",
+ "declaration": false
+ }
+}
diff --git a/packages/json-renderer/tsconfig.json b/packages/json-renderer/tsconfig.json
new file mode 100644
index 0000000..8028254
--- /dev/null
+++ b/packages/json-renderer/tsconfig.json
@@ -0,0 +1,29 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "node16",
+ "moduleResolution": "node16",
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "declaration": true,
+ "outDir": "dist",
+ "rootDir": "src",
+ "lib": [
+ "ES2022"
+ ]
+ },
+ "include": [
+ "src"
+ ],
+ "exclude": [
+ "dist",
+ "node_modules",
+ "src/**/__tests__/**",
+ "src/**/*.test.ts",
+ "src/**/*.test.tsx"
+ ]
+}
diff --git a/packages/json-renderer/tsconfig.lint.json b/packages/json-renderer/tsconfig.lint.json
new file mode 100644
index 0000000..91e4e00
--- /dev/null
+++ b/packages/json-renderer/tsconfig.lint.json
@@ -0,0 +1,14 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "declaration": false
+ },
+ "include": [
+ "src"
+ ],
+ "exclude": [
+ "dist",
+ "node_modules"
+ ]
+}
diff --git a/packages/json-renderer/vitest.config.ts b/packages/json-renderer/vitest.config.ts
new file mode 100644
index 0000000..9bf38fe
--- /dev/null
+++ b/packages/json-renderer/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ environment: 'node',
+ include: ['src/**/*.{test,spec}.ts'],
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 371417e..5c1564c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -180,6 +180,9 @@ importers:
blocks-schema:
specifier: workspace:^
version: link:../blocks-schema/dist
+ json-renderer:
+ specifier: workspace:^
+ version: link:../json-renderer/dist
devDependencies:
'@types/react':
specifier: ^19.2.7
@@ -206,6 +209,9 @@ importers:
packages/blocks-schema:
dependencies:
+ json-renderer:
+ specifier: workspace:^
+ version: link:../json-renderer/dist
zod:
specifier: ^4.3.4
version: 4.4.3
@@ -305,6 +311,23 @@ importers:
version: 3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(tsx@4.23.1)(yaml@2.9.0)
publishDirectory: dist
+ packages/json-renderer:
+ dependencies:
+ zod:
+ specifier: ^4.3.4
+ version: 4.4.3
+ devDependencies:
+ makage:
+ specifier: ^0.6.0
+ version: 0.6.0
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+ vitest:
+ specifier: ^3.2.4
+ version: 3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(msw@2.15.0(@types/node@24.13.3)(typescript@5.9.3))(tsx@4.23.1)(yaml@2.9.0)
+ publishDirectory: dist
+
packages/json-schema-to-blocks:
dependencies:
blocks-schema:
diff --git a/scripts/check-packed-packages.ts b/scripts/check-packed-packages.ts
index 1ee06d1..0b19635 100644
--- a/scripts/check-packed-packages.ts
+++ b/scripts/check-packed-packages.ts
@@ -18,9 +18,9 @@ interface PackageManifest {
exports: Record;
}
-// blocks-schema, blocks-renderer, and json-schema-to-blocks publish from `dist`
-// with makage, so their entry points are plain files at the package root and
-// there is no exports map.
+// json-renderer, blocks-schema, blocks-renderer, and json-schema-to-blocks
+// publish from `dist` with makage, so their entry points are plain files at the
+// package root and there is no exports map.
interface DocumentPackageManifest {
name: string;
version: string;
@@ -438,6 +438,9 @@ async function checkPackedDocumentPackages(): Promise {
// The document packages publish from `dist`, so this consumer proves the
// published layout: root entry points, working deep imports without an
// exports map, and a renderer that resolves its schema dependency.
+ const coreManifest = JSON.parse(
+ await readFile(path.join(root, 'packages/json-renderer/package.json'), 'utf8')
+ ) as DocumentPackageManifest;
const schemaManifest = JSON.parse(
await readFile(path.join(root, 'packages/blocks-schema/package.json'), 'utf8')
) as DocumentPackageManifest;
@@ -454,6 +457,7 @@ async function checkPackedDocumentPackages(): Promise {
await readFile(path.join(root, 'packages/flow-to-blocks/package.json'), 'utf8')
) as DocumentPackageManifest;
for (const manifest of [
+ coreManifest,
schemaManifest,
rendererManifest,
jsonSchemaManifest,
@@ -467,6 +471,7 @@ async function checkPackedDocumentPackages(): Promise {
throw new Error(`${manifest.name} must declare dist-relative entry points`);
}
}
+ const coreTarball = path.join(artifacts, `json-renderer-${coreManifest.version}.tgz`);
const schemaTarball = path.join(artifacts, `blocks-schema-${schemaManifest.version}.tgz`);
const rendererTarball = path.join(artifacts, `blocks-renderer-${rendererManifest.version}.tgz`);
const jsonSchemaTarball = path.join(
@@ -476,6 +481,7 @@ async function checkPackedDocumentPackages(): Promise {
const metaTarball = path.join(artifacts, `meta-to-blocks-${metaManifest.version}.tgz`);
const flowTarball = path.join(artifacts, `flow-to-blocks-${flowManifest.version}.tgz`);
await Promise.all([
+ access(coreTarball),
access(schemaTarball),
access(rendererTarball),
access(jsonSchemaTarball),
@@ -499,6 +505,7 @@ async function checkPackedDocumentPackages(): Promise {
dependencies: {
'blocks-renderer': `file:${rendererTarball}`,
'blocks-schema': `file:${schemaTarball}`,
+ 'json-renderer': `file:${coreTarball}`,
'json-schema-to-blocks': `file:${jsonSchemaTarball}`,
'meta-to-blocks': `file:${metaTarball}`,
'flow-to-blocks': `file:${flowTarball}`,
@@ -512,6 +519,7 @@ async function checkPackedDocumentPackages(): Promise {
// The packed dependents resolve the packed schema, not the registry copy.
pnpm: {
overrides: {
+ 'json-renderer': `file:${coreTarball}`,
'blocks-schema': `file:${schemaTarball}`,
'json-schema-to-blocks': `file:${jsonSchemaTarball}`,
// @fbp/evaluator@1.3.0 shipped `"@fbp/types": "workspace:*"`, which no
@@ -539,6 +547,11 @@ const packedRenderer = JSON.parse(
await readFile(require.resolve('blocks-renderer/package.json'), 'utf8')
);
assert.doesNotMatch(packedRenderer.dependencies['blocks-schema'], /^workspace:/);
+assert.doesNotMatch(packedRenderer.dependencies['json-renderer'], /^workspace:/);
+const packedSchema = JSON.parse(
+ await readFile(require.resolve('blocks-schema/package.json'), 'utf8')
+);
+assert.doesNotMatch(packedSchema.dependencies['json-renderer'], /^workspace:/);
const packedJsonSchema = JSON.parse(
await readFile(require.resolve('json-schema-to-blocks/package.json'), 'utf8')
);
@@ -555,6 +568,9 @@ assert.doesNotMatch(packedFlow.dependencies['blocks-schema'], /^workspace:/);
assert.doesNotMatch(packedFlow.dependencies['json-schema-to-blocks'], /^workspace:/);
// CJS entry points and deep imports resolve without an exports map.
+assert.ok(require('json-renderer').createEnvelope);
+assert.ok(require('json-renderer/compose').composeEnvelope);
+assert.ok(require('json-renderer/adapter').resolveNode);
assert.ok(require('blocks-schema').parseDocument);
assert.ok(require('blocks-schema/compose').composeDocument);
assert.ok(require('blocks-schema/validation').validateField);
@@ -567,6 +583,28 @@ assert.ok(require('meta-to-blocks/schema').tableToSchema);
assert.ok(require('flow-to-blocks').flowToDocument);
assert.ok(require('flow-to-blocks/definitions').uiNodeDefinitions);
+const { DOCUMENT_FORMAT_VERSION, createEnvelope } = await import('json-renderer');
+const { composeEnvelope } = await import('json-renderer/compose');
+assert.equal(DOCUMENT_FORMAT_VERSION, '1.0');
+
+// The generic core is usable on its own, with a vocabulary of its own naming.
+const genericDocument = createEnvelope(
+ { documentType: 'Report', formatVersion: '1.0' },
+ {
+ type: 'Root',
+ key: 'root',
+ props: {},
+ children: [{ type: 'Fragment', key: 'f', props: { ref: 'body' }, children: [] }]
+ },
+ { id: 'packed-generic' }
+);
+assert.equal(
+ composeEnvelope(genericDocument, {
+ fragments: { body: { type: 'Text', key: 'body', props: { value: 'hi' }, children: [] } }
+ }).page.children[0].type,
+ 'Text'
+);
+
const { UI_DOCUMENT_FORMAT_VERSION, parseDocument } = await import('blocks-schema');
const { composeDocument } = await import('blocks-schema/compose');
const { DocumentRenderer } = await import('blocks-renderer');
@@ -642,6 +680,7 @@ console.log('Packed document packages resolved from root entry points and deep i
documentConsumer
);
await Promise.all([
+ access(path.join(documentConsumer, 'node_modules', 'json-renderer', 'LICENSE')),
access(path.join(documentConsumer, 'node_modules', 'blocks-schema', 'LICENSE')),
access(path.join(documentConsumer, 'node_modules', 'blocks-renderer', 'LICENSE')),
access(path.join(documentConsumer, 'node_modules', 'json-schema-to-blocks', 'LICENSE')),
diff --git a/scripts/pack-local.ts b/scripts/pack-local.ts
index d3436df..e636246 100644
--- a/scripts/pack-local.ts
+++ b/scripts/pack-local.ts
@@ -11,6 +11,7 @@ const packages = [
'@constructive-io/command-palette',
'@constructive-io/sheets',
'@constructive-io/schema-builder',
+ 'json-renderer',
'blocks-schema',
'blocks-renderer',
'json-schema-to-blocks',