From e623d2588e905d0947e8e0c03b3fa5f47186986d Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 22 Aug 2026 02:23:51 +0000 Subject: [PATCH] feat: extract json-renderer core Extract the framework-agnostic JSON UI document core into the new json-renderer package: document envelope + node tree generic over the node vocabulary, validation primitives and JSON Schema export, composition (fragments, slots, per-node overrides, merge), React-free binding/scope resolution, and an explicit renderer adapter contract. blocks-schema now specializes that core with the Constructive vocabulary and blocks-renderer is its first adapter (reactAdapter). Public APIs of both packages are unchanged. --- README.md | 3 +- docs/RELEASING.md | 7 +- package.json | 2 +- packages/blocks-renderer/package.json | 3 +- .../src/__tests__/adapter.test.tsx | 57 +++++ packages/blocks-renderer/src/adapter.tsx | 47 +++++ packages/blocks-renderer/src/bindings.ts | 43 +--- packages/blocks-renderer/src/index.ts | 12 +- packages/blocks-renderer/src/registry.ts | 27 ++- packages/blocks-renderer/src/types.ts | 11 +- packages/blocks-schema/package.json | 5 +- packages/blocks-schema/src/compose.ts | 70 +++---- packages/blocks-schema/src/core.ts | 32 +++ packages/blocks-schema/src/envelope.ts | 51 ++--- packages/blocks-schema/src/index.ts | 1 + packages/blocks-schema/src/json-schema.ts | 6 +- packages/blocks-schema/src/node.ts | 85 ++++---- packages/blocks-schema/src/validation.ts | 52 +---- packages/blocks-schema/src/zod.ts | 71 +++---- packages/json-renderer/README.md | 119 +++++++++++ packages/json-renderer/package.json | 43 ++++ .../src/__tests__/adapter.test.ts | 102 +++++++++ .../src/__tests__/compose.test.ts | 162 ++++++++++++++ .../src/__tests__/document.test.ts | 125 +++++++++++ .../json-renderer/src/__tests__/helpers.ts | 24 +++ packages/json-renderer/src/adapter.ts | 106 ++++++++++ packages/json-renderer/src/bindings.ts | 68 ++++++ packages/json-renderer/src/compose.ts | 198 ++++++++++++++++++ packages/json-renderer/src/constraints.ts | 67 ++++++ packages/json-renderer/src/envelope.ts | 95 +++++++++ packages/json-renderer/src/fields.ts | 73 +++++++ packages/json-renderer/src/index.ts | 10 + packages/json-renderer/src/json-schema.ts | 19 ++ packages/json-renderer/src/node.ts | 110 ++++++++++ packages/json-renderer/src/registry.ts | 42 ++++ packages/json-renderer/src/zod.ts | 124 +++++++++++ packages/json-renderer/tsconfig.esm.json | 9 + packages/json-renderer/tsconfig.json | 29 +++ packages/json-renderer/tsconfig.lint.json | 14 ++ packages/json-renderer/vitest.config.ts | 8 + pnpm-lock.yaml | 23 ++ scripts/check-packed-packages.ts | 45 +++- scripts/pack-local.ts | 1 + 43 files changed, 1929 insertions(+), 272 deletions(-) create mode 100644 packages/blocks-renderer/src/__tests__/adapter.test.tsx create mode 100644 packages/blocks-renderer/src/adapter.tsx create mode 100644 packages/blocks-schema/src/core.ts create mode 100644 packages/json-renderer/README.md create mode 100644 packages/json-renderer/package.json create mode 100644 packages/json-renderer/src/__tests__/adapter.test.ts create mode 100644 packages/json-renderer/src/__tests__/compose.test.ts create mode 100644 packages/json-renderer/src/__tests__/document.test.ts create mode 100644 packages/json-renderer/src/__tests__/helpers.ts create mode 100644 packages/json-renderer/src/adapter.ts create mode 100644 packages/json-renderer/src/bindings.ts create mode 100644 packages/json-renderer/src/compose.ts create mode 100644 packages/json-renderer/src/constraints.ts create mode 100644 packages/json-renderer/src/envelope.ts create mode 100644 packages/json-renderer/src/fields.ts create mode 100644 packages/json-renderer/src/index.ts create mode 100644 packages/json-renderer/src/json-schema.ts create mode 100644 packages/json-renderer/src/node.ts create mode 100644 packages/json-renderer/src/registry.ts create mode 100644 packages/json-renderer/src/zod.ts create mode 100644 packages/json-renderer/tsconfig.esm.json create mode 100644 packages/json-renderer/tsconfig.json create mode 100644 packages/json-renderer/tsconfig.lint.json create mode 100644 packages/json-renderer/vitest.config.ts 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',