Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion packages/blocks-renderer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
57 changes: 57 additions & 0 deletions packages/blocks-renderer/src/__tests__/adapter.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <span>{String(props.text ?? '')}</span>;
}

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(
'<span>Bound Title</span>',
);
expect(
renderToStaticMarkup(
reactAdapter.renderUnknown({ type: 'HoloDeck', key: 'holo', props: {}, children: [] }, context()),
),
).toContain('data-block-unknown="HoloDeck"');
});
});
47 changes: 47 additions & 0 deletions packages/blocks-renderer/src/adapter.tsx
Original file line number Diff line number Diff line change
@@ -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<BlockComponent, ReactNode, UIDocument, RendererContextValue> = {
name: 'blocks-renderer/react',

resolve(type, context): NodeResolution<BlockComponent> {
return resolveNode(context.registry, type);
},

resolveProps(node, context): NodeProps {
return resolveNodeProps(node, context.scope);
},

renderNode(node, context) {
return <RendererProvider value={context}>{<BlockRenderer node={node as UINode} />}</RendererProvider>;
},

renderUnknown(node) {
return <UnknownBlock node={node as UINode} />;
},

renderDocument(document, context) {
return (
<RendererProvider value={{ ...context, document }}>
<BlockRenderer node={document.page} />
</RendererProvider>
);
},
};
43 changes: 4 additions & 39 deletions packages/blocks-renderer/src/bindings.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, 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<string, unknown>)[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<string, unknown>): 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<string, unknown>): 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';
12 changes: 10 additions & 2 deletions packages/blocks-renderer/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
27 changes: 19 additions & 8 deletions packages/blocks-renderer/src/registry.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -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<BlockComponent>(...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>): string[] {
return missingRegistryTypes(registry, usedTypes);
}
11 changes: 8 additions & 3 deletions packages/blocks-renderer/src/types.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,9 +17,13 @@ export interface BlockProps {
export type BlockComponent = ComponentType<BlockProps>;

/** Node type → component. Layered by {@link composeRegistry}. */
export type BlockRegistry = Record<string, BlockComponent>;
export type BlockRegistry = NodeRegistry<BlockComponent>;

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<BlockComponent, UIDocument, UIAction> {
document: UIDocument;
registry: BlockRegistry;
mode: RenderMode;
Expand All @@ -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<string, unknown>;
scope: BindingScope;
onAction?: (action: UIAction, event: string) => void;
}
5 changes: 3 additions & 2 deletions packages/blocks-schema/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading
Loading