Skip to content

Commit 0b27dd3

Browse files
authored
Merge pull request #25 from constructive-io/feat/block-renderer
feat: add blocks-schema and blocks-renderer packages
2 parents 29c1e12 + 8214e79 commit 0b27dd3

31 files changed

Lines changed: 1703 additions & 1 deletion

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
},
1111
"scripts": {
1212
"build": "lerna run build",
13-
"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",
13+
"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",
1414
"build:registry": "pnpm --filter @constructive-io/registry build && pnpm check:console-kit-inspector",
1515
"build:pages": "pnpm build:packages && pnpm build:registry && pnpm --filter blocks build:pages && pnpm pages:artifact",
1616
"build:storybook": "pnpm --filter @constructive-io/ui build-sb",

packages/blocks-renderer/LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Constructive
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

packages/blocks-renderer/README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# blocks-renderer
2+
3+
React adapter for `blocks-schema` UI documents: a recursive
4+
renderer with layerable widget registries, binding resolution, form state, and
5+
a visible unknown-block fallback.
6+
7+
## Usage
8+
9+
```tsx
10+
import { DocumentRenderer, composeRegistry } from 'blocks-renderer';
11+
12+
const registry = composeRegistry(
13+
baseRegistry, // shadcn-style primitives
14+
appRegistry, // app-specific blocks
15+
documentOverrides, // per-document swaps
16+
);
17+
18+
<DocumentRenderer
19+
document={document}
20+
registry={registry}
21+
scope={{ row, user }}
22+
initialValues={{ title: 'Draft' }}
23+
onSubmit={(values) => save(values)}
24+
onAction={(action, event) => runFlow(action)}
25+
/>;
26+
```
27+
28+
## Concepts
29+
30+
- **Registry layering**`composeRegistry(...layers)` merges
31+
`type → component` maps left-to-right, later layers winning. Hosts customize
32+
widgets (inputs, textareas, rich text, custom blocks) by layering, never by
33+
forking the renderer.
34+
- **Blocks** — every component receives `{ node, props, children }`, where
35+
`props` has the node's `bindings` resolved against the current scope and
36+
`children` are already rendered.
37+
- **Bindings**`{{ path.to.value }}` templates resolve against
38+
`scope + values`; a lone placeholder yields the raw value, mixed text
39+
interpolates.
40+
- **Fields** — widget implementations call `useBlockField(name)` for
41+
value/error wiring; constraints from the document (`required`, length,
42+
range, `pattern`) validate on change and on submit.
43+
- **Unknown blocks** — unregistered node types render `UnknownBlock`, a
44+
visible gap instead of a crash, so documents can name blocks a host has not
45+
installed.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
{
2+
"name": "blocks-renderer",
3+
"version": "0.1.0",
4+
"description": "Recursive React renderer for Constructive Blocks JSON UI documents, with layerable widget registries",
5+
"private": false,
6+
"license": "MIT",
7+
"homepage": "https://constructive-io.github.io/blocks/",
8+
"repository": {
9+
"type": "git",
10+
"url": "git+https://github.com/constructive-io/blocks.git",
11+
"directory": "packages/blocks-renderer"
12+
},
13+
"bugs": {
14+
"url": "https://github.com/constructive-io/blocks/issues"
15+
},
16+
"publishConfig": {
17+
"access": "public"
18+
},
19+
"type": "module",
20+
"sideEffects": false,
21+
"exports": {
22+
".": {
23+
"import": {
24+
"types": "./dist/index.d.ts",
25+
"default": "./dist/index.js"
26+
},
27+
"require": {
28+
"types": "./dist/index.d.cts",
29+
"default": "./dist/index.cjs"
30+
}
31+
},
32+
"./package.json": "./package.json"
33+
},
34+
"main": "./dist/index.cjs",
35+
"module": "./dist/index.js",
36+
"types": "./dist/index.d.ts",
37+
"files": [
38+
"dist",
39+
"LICENSE",
40+
"README.md"
41+
],
42+
"scripts": {
43+
"build": "tsup",
44+
"dev": "tsup --watch",
45+
"lint:types": "tsc --noEmit",
46+
"test": "vitest run",
47+
"test:watch": "vitest",
48+
"clean": "rm -rf dist"
49+
},
50+
"dependencies": {
51+
"blocks-schema": "workspace:^"
52+
},
53+
"peerDependencies": {
54+
"react": "^18.0.0 || ^19.0.0"
55+
},
56+
"devDependencies": {
57+
"@types/react": "^19.2.7",
58+
"@types/react-dom": "^19.2.3",
59+
"react": "^19.2.3",
60+
"react-dom": "^19.2.3",
61+
"tsup": "^8.5.1",
62+
"typescript": "^5.9.3",
63+
"vitest": "^3.2.4"
64+
},
65+
"engines": {
66+
"node": ">=24.0.0"
67+
}
68+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import type { UIDocument, UINode } from 'blocks-schema';
2+
import { renderToStaticMarkup } from 'react-dom/server';
3+
import { describe, expect, it } from 'vitest';
4+
5+
import { readPath, resolveBinding, resolveNodeProps } from '../bindings';
6+
import { composeRegistry, registeredTypes } from '../registry';
7+
import { DocumentRenderer } from '../renderer';
8+
import type { BlockProps, BlockRegistry } from '../types';
9+
10+
function Passthrough({ node, children }: BlockProps) {
11+
return (
12+
<div data-type={node.type} data-key={node.key}>
13+
{children}
14+
</div>
15+
);
16+
}
17+
18+
function Text({ props }: BlockProps) {
19+
return <span>{String(props.text ?? '')}</span>;
20+
}
21+
22+
const registry: BlockRegistry = {
23+
Page: Passthrough,
24+
Section: Passthrough,
25+
Markdown: Text,
26+
};
27+
28+
function doc(page: UINode): UIDocument {
29+
return { formatVersion: '1.0', type: 'UISchema', id: 'doc-1', page };
30+
}
31+
32+
describe('DocumentRenderer', () => {
33+
it('renders the node tree recursively through the registry', () => {
34+
const html = renderToStaticMarkup(
35+
<DocumentRenderer
36+
document={doc({
37+
type: 'Page',
38+
key: 'page',
39+
props: {},
40+
children: [
41+
{
42+
type: 'Section',
43+
key: 'intro',
44+
props: {},
45+
children: [{ type: 'Markdown', key: 'text', props: { text: 'hello' }, children: [] }],
46+
},
47+
],
48+
})}
49+
registry={registry}
50+
/>,
51+
);
52+
53+
expect(html).toContain('data-key="page"');
54+
expect(html).toContain('data-key="intro"');
55+
expect(html).toContain('<span>hello</span>');
56+
});
57+
58+
it('falls back to UnknownBlock for unregistered node types', () => {
59+
const html = renderToStaticMarkup(
60+
<DocumentRenderer
61+
document={doc({ type: 'Page', key: 'page', props: {}, children: [
62+
{ type: 'HoloDeck', key: 'holo', props: {}, children: [] },
63+
] })}
64+
registry={registry}
65+
/>,
66+
);
67+
68+
expect(html).toContain('data-block-unknown="HoloDeck"');
69+
expect(html).toContain('Unknown block: HoloDeck');
70+
});
71+
72+
it('resolves bindings against the external scope', () => {
73+
const html = renderToStaticMarkup(
74+
<DocumentRenderer
75+
document={doc({
76+
type: 'Page',
77+
key: 'page',
78+
props: {},
79+
children: [
80+
{
81+
type: 'Markdown',
82+
key: 'text',
83+
props: { text: 'static' },
84+
bindings: { text: '{{ row.title }}' },
85+
children: [],
86+
},
87+
],
88+
})}
89+
registry={registry}
90+
scope={{ row: { title: 'Bound Title' } }}
91+
/>,
92+
);
93+
94+
expect(html).toContain('<span>Bound Title</span>');
95+
});
96+
});
97+
98+
describe('registry layering', () => {
99+
it('later layers win', () => {
100+
const composed = composeRegistry(registry, { Markdown: Passthrough });
101+
expect(composed.Markdown).toBe(Passthrough);
102+
expect(composed.Page).toBe(Passthrough);
103+
expect(registeredTypes(composed)).toEqual(['Markdown', 'Page', 'Section']);
104+
});
105+
});
106+
107+
describe('bindings', () => {
108+
it('reads dotted paths', () => {
109+
expect(readPath({ a: { b: { c: 1 } } }, 'a.b.c')).toBe(1);
110+
expect(readPath({ a: 1 }, 'a.b')).toBeUndefined();
111+
});
112+
113+
it('returns raw values for single-placeholder expressions', () => {
114+
expect(resolveBinding('{{ flag }}', { flag: false })).toBe(false);
115+
expect(resolveBinding('Hello {{ name }}!', { name: 'Ada' })).toBe('Hello Ada!');
116+
expect(resolveBinding('Hi {{ missing }}.', {})).toBe('Hi .');
117+
});
118+
119+
it('overlays bindings on static props', () => {
120+
const node: UINode = {
121+
type: 'Markdown',
122+
key: 'k',
123+
props: { text: 'static', keep: true },
124+
bindings: { text: '{{ title }}' },
125+
children: [],
126+
};
127+
expect(resolveNodeProps(node, { title: 'dyn' })).toEqual({ text: 'dyn', keep: true });
128+
});
129+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { UINode, UINodeProps } from 'blocks-schema';
2+
3+
const TEMPLATE = /\{\{\s*([^}\s]+)\s*\}\}/g;
4+
5+
/** Read a dotted path (`row.author.name`) out of a scope object. */
6+
export function readPath(scope: Record<string, unknown>, path: string): unknown {
7+
let current: unknown = scope;
8+
for (const segment of path.split('.')) {
9+
if (current == null || typeof current !== 'object') return undefined;
10+
current = (current as Record<string, unknown>)[segment];
11+
}
12+
return current;
13+
}
14+
15+
/**
16+
* Resolve a binding expression. A template that is exactly one placeholder
17+
* yields the raw value (so a boolean or an object survives); a template mixed
18+
* with text is interpolated as a string.
19+
*/
20+
export function resolveBinding(expression: string, scope: Record<string, unknown>): unknown {
21+
const single = expression.match(/^\{\{\s*([^}\s]+)\s*\}\}$/);
22+
if (single) {
23+
return readPath(scope, single[1]);
24+
}
25+
26+
return expression.replace(TEMPLATE, (_match, path: string) => {
27+
const value = readPath(scope, path);
28+
return value == null ? '' : String(value);
29+
});
30+
}
31+
32+
/** Apply a node's `bindings` over its static props. */
33+
export function resolveNodeProps(node: UINode, scope: Record<string, unknown>): UINodeProps {
34+
if (!node.bindings) return node.props ?? {};
35+
36+
const resolved: UINodeProps = { ...(node.props ?? {}) };
37+
for (const [prop, expression] of Object.entries(node.bindings)) {
38+
resolved[prop] = resolveBinding(expression, scope);
39+
}
40+
return resolved;
41+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
'use client';
2+
3+
import { createContext, useContext } from 'react';
4+
import type { ReactNode } from 'react';
5+
6+
import type { RendererContextValue } from './types';
7+
8+
const RendererContext = createContext<RendererContextValue | null>(null);
9+
10+
export function RendererProvider({ children, value }: { children: ReactNode; value: RendererContextValue }) {
11+
return <RendererContext.Provider value={value}>{children}</RendererContext.Provider>;
12+
}
13+
14+
export function useRenderer(): RendererContextValue {
15+
const context = useContext(RendererContext);
16+
if (!context) {
17+
throw new Error('useRenderer must be used within a RendererProvider (or a DocumentRenderer)');
18+
}
19+
return context;
20+
}
21+
22+
/**
23+
* Resolved props plus the value/error wiring for a field node. Widget
24+
* implementations use this instead of reaching into the document themselves.
25+
*/
26+
export function useBlockField(name: string | undefined) {
27+
const { values, errors, setValue, setError, mode } = useRenderer();
28+
if (!name) {
29+
return { value: undefined, error: undefined, setValue: () => {}, setError: () => {}, mode };
30+
}
31+
return {
32+
value: values[name],
33+
error: errors[name],
34+
setValue: (value: unknown) => setValue(name, value),
35+
setError: (error: string | null) => setError(name, error),
36+
mode,
37+
};
38+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export { readPath, resolveBinding, resolveNodeProps } from './bindings';
2+
export { RendererProvider, useBlockField, useRenderer } from './context';
3+
export { composeRegistry, registeredTypes, resolveBlock } from './registry';
4+
export { BlockRenderer, DocumentRenderer } from './renderer';
5+
export type { DocumentRendererProps } from './renderer';
6+
export { UnknownBlock } from './unknown-block';
7+
export type { BlockComponent, BlockProps, BlockRegistry, RenderMode, RendererContextValue } from './types';
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { BlockComponent, BlockRegistry } from './types';
2+
3+
/**
4+
* Layer registries left-to-right, later layers winning. This is how a host
5+
* customizes rendering: base primitives, then an app registry, then per-document
6+
* overrides — no forking of the renderer, and no single global map.
7+
*/
8+
export function composeRegistry(...layers: (BlockRegistry | undefined)[]): BlockRegistry {
9+
const composed: BlockRegistry = {};
10+
for (const layer of layers) {
11+
if (!layer) continue;
12+
Object.assign(composed, layer);
13+
}
14+
return composed;
15+
}
16+
17+
export function resolveBlock(registry: BlockRegistry, type: string): BlockComponent | undefined {
18+
return registry[type];
19+
}
20+
21+
/** Node types the registry can render, sorted for stable output. */
22+
export function registeredTypes(registry: BlockRegistry): string[] {
23+
return Object.keys(registry).sort();
24+
}

0 commit comments

Comments
 (0)