blockprint 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -0
- package/dist/Renderer.d.ts +29 -2
- package/dist/Renderer.js +134 -29
- package/dist/block-config.d.ts +16 -0
- package/dist/block-config.js +7 -0
- package/dist/config.d.ts +11 -7
- package/dist/config.js +6 -4
- package/dist/createRegistry.d.ts +22 -2
- package/dist/createRegistry.js +15 -1
- package/dist/env.d.ts +7 -0
- package/dist/env.js +9 -0
- package/dist/fallback.d.ts +1 -1
- package/dist/fallback.js +7 -2
- package/dist/index.d.ts +11 -6
- package/dist/index.js +2 -0
- package/dist/manifest.d.ts +43 -0
- package/dist/manifest.js +7 -0
- package/dist/parseConfig.d.ts +1 -1
- package/dist/types.d.ts +8 -11
- package/dist/validateConfig.d.ts +43 -0
- package/dist/validateConfig.js +53 -0
- package/dist/validateProps.d.ts +15 -0
- package/dist/validateProps.js +23 -0
- package/package.json +3 -2
- package/dist/chunk-4JB5QQUK.js +0 -37
- package/dist/chunk-4JB5QQUK.js.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/index.js.map +0 -1
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
**Live demo: [blockprint.terzidest.com](https://blockprint.terzidest.com)** — three completely different pages (commerce, SaaS, editorial) from one renderer and one registry, plus a deliberately broken config showing graceful fallback. · [GitHub](https://github.com/terzidest/blockprint)
|
|
6
6
|
|
|
7
|
+
In production at [ethereal_nature](https://github.com/terzidest/ethereal_nature): storefront pages from DB-stored configs, an admin page editor, and server-side validation via `blockprint/config`.
|
|
8
|
+
|
|
7
9
|
## The problem
|
|
8
10
|
|
|
9
11
|
Content teams compose pages from a fixed set of block types; your app receives JSON like `[{ "type": "hero", "props": { … } }, …]`. Consuming that in React usually collapses into a `switch` on `block.type` — every block imported into every page, props typed `any`, unknown types crashing the page.
|
|
@@ -56,12 +58,92 @@ Renders a config against a registry. `config` is `unknown` on purpose — the ou
|
|
|
56
58
|
|
|
57
59
|
The default fallback renders a warning box in development and `null` in production — a page that wants visible failure in production must pass its own `fallback`.
|
|
58
60
|
|
|
61
|
+
Fail-soft options:
|
|
62
|
+
|
|
63
|
+
- `onFallback={(info) => …}` — telemetry hook for entries that fall back (reason, type, props, index). Invoked synchronously so it works in Server Components, and deduped per Renderer instance on `(reason, type, index)` — unstable config identities don't re-fire it. Expect one call per environment (server + hydration) and possible doubling under StrictMode in development.
|
|
64
|
+
- `invalidConfigFallback={<ErrorState />}` — rendered instead of throwing when the outer config shape is invalid. Without it the Renderer throws `ConfigError`, so boundary-less apps fail loudly rather than silently blank.
|
|
65
|
+
- `catchBlockErrors` — wraps each block in an error boundary: a block that throws while rendering becomes a `render-error` fallback instead of taking the page down, and recovers when its entry changes. **Client-side containment only**: React error boundaries do not catch during server rendering and do not exist in Server Components (enabling this there throws a descriptive error); SSR totality remains your blocks' responsibility.
|
|
66
|
+
|
|
67
|
+
> **Upgrading from ≤ 0.2**: `FallbackProps` gained a third member, `{ reason: "render-error", error }`. Custom fallbacks that narrowed with a two-way ternary must handle it — switch on `reason` exhaustively. See [CHANGELOG](https://github.com/terzidest/blockprint/blob/main/CHANGELOG.md).
|
|
68
|
+
|
|
59
69
|
The optional `wrapBlock={(node, entry, index) => …}` prop wraps every rendered entry — blocks and fallbacks alike — with access to the config entry and its index. This is the hook point for visual-editing overlays, analytics boundaries, or per-block chrome.
|
|
60
70
|
|
|
71
|
+
A common recipe — shared section chrome, deep-link anchors, and analytics attributes in one place instead of repeated in every block:
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
<Renderer
|
|
75
|
+
config={config}
|
|
76
|
+
registry={registry}
|
|
77
|
+
wrapBlock={(node, entry) => (
|
|
78
|
+
<section
|
|
79
|
+
id={entry.id != null ? String(entry.id) : undefined} // anchor: /page#hero-1
|
|
80
|
+
data-block={entry.type} // analytics / e2e hooks
|
|
81
|
+
className="mx-auto max-w-5xl px-6 py-10"
|
|
82
|
+
>
|
|
83
|
+
{node}
|
|
84
|
+
</section>
|
|
85
|
+
)}
|
|
86
|
+
/>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Rendering and schema validation are memoised per `(config, registry)` identity — keep the config referentially stable across renders (state, loader data, `useMemo`) to benefit. Duplicate `id`s warn in development and render under collision-safe keys.
|
|
90
|
+
|
|
61
91
|
### `parseConfig(input)`
|
|
62
92
|
|
|
63
93
|
The boundary validator, exported for consumers who want to fail at fetch time rather than render time. Throws `ConfigError` with the entry index and reason.
|
|
64
94
|
|
|
95
|
+
### Manifests — block definitions without components
|
|
96
|
+
|
|
97
|
+
When more than one app needs to know your blocks (an admin, a server, tests), define them once, component-free, and join components only where rendering happens:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
// packages/blocks-contract — imports blockprint/config only, no React
|
|
101
|
+
import { defineBlock } from "blockprint/config";
|
|
102
|
+
|
|
103
|
+
export const hero = defineBlock({
|
|
104
|
+
schema: heroSchema, // required; the manifest's reason to exist
|
|
105
|
+
label: "Hero",
|
|
106
|
+
defaults: { title: "A headline…" }, // typed as the schema's INPUT
|
|
107
|
+
summary: (props) => props.title, // receives the schema's OUTPUT
|
|
108
|
+
fields: { title: { label: "Headline" } }, // shallow editor metadata, uninterpreted
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
export const manifest = { hero, categoryTiles, productStrip };
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
```tsx
|
|
115
|
+
// the rendering app — the only place components exist
|
|
116
|
+
import { createRegistry } from "blockprint";
|
|
117
|
+
import { manifest } from "@yours/blocks-contract";
|
|
118
|
+
|
|
119
|
+
const registry = createRegistry(manifest, {
|
|
120
|
+
hero: Hero,
|
|
121
|
+
categoryTiles: CategoryTiles,
|
|
122
|
+
productStrip: ProductStrip,
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The join is checked per key: a component requiring a prop its schema doesn't produce, a missing component, or an extra component are all compile errors on the offending key. Editors and servers import the manifest (labels, defaults, summaries, `InputOf`/`OutputOf` types) and never touch component modules.
|
|
127
|
+
|
|
128
|
+
### `validateConfig(manifest, config)` — headless, total
|
|
129
|
+
|
|
130
|
+
Per-entry validation for save endpoints, tests, loaders, and editors. **Never throws:**
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
import { validateConfig } from "blockprint/config";
|
|
134
|
+
|
|
135
|
+
const report = validateConfig(manifest, untrustedInput);
|
|
136
|
+
report.ok; // outer shape valid and no entry failed validation
|
|
137
|
+
report.error; // outer-shape failure message, if any
|
|
138
|
+
report.entries; // per entry: { ok: true, value } | { ok: false, reason, issues? }
|
|
139
|
+
report.unknownTypes; // distinct unregistered types — NOT counted as failure:
|
|
140
|
+
// unknown blocks are the renderer's fallback case, and
|
|
141
|
+
// whether to accept them is the caller's policy
|
|
142
|
+
report.duplicateIds; // ids appearing more than once
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The Renderer applies the same per-entry semantics, so what validates at save time is what renders.
|
|
146
|
+
|
|
65
147
|
### `blockprint/config` — the React-free entry point
|
|
66
148
|
|
|
67
149
|
Backends that store and validate configs want exactly `parseConfig` and nothing React-shaped. The `blockprint/config` subpath exports `parseConfig`, `ConfigError`, and the `BlockConfig` / `StandardSchemaV1` types with **no React requirement** (the React peer dependency is marked optional):
|
package/dist/Renderer.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { ComponentType, ReactNode } from "react";
|
|
2
|
-
import type { BlockConfig, BlocksShape, FallbackProps, Registry } from "./types.
|
|
2
|
+
import type { BlockConfig, BlocksShape, FallbackProps, Registry } from "./types.js";
|
|
3
|
+
export type FallbackInfo = FallbackProps & {
|
|
4
|
+
index: number;
|
|
5
|
+
};
|
|
3
6
|
export interface RendererProps<T extends BlocksShape> {
|
|
4
7
|
/** Untrusted input; the outer shape is validated internally. */
|
|
5
8
|
config: unknown;
|
|
@@ -11,5 +14,29 @@ export interface RendererProps<T extends BlocksShape> {
|
|
|
11
14
|
* overlays, analytics boundaries, or per-block chrome.
|
|
12
15
|
*/
|
|
13
16
|
wrapBlock?: (node: ReactNode, entry: BlockConfig, index: number) => ReactNode;
|
|
17
|
+
/**
|
|
18
|
+
* Telemetry hook for entries that resolve to a fallback (and render errors
|
|
19
|
+
* caught by catchBlockErrors). Invoked synchronously so it works in Server
|
|
20
|
+
* Components, and deduped per Renderer instance on (reason, type, index) —
|
|
21
|
+
* so unstable config identities don't re-fire it. Expect one call per
|
|
22
|
+
* environment (server render + client hydration), and possible doubling
|
|
23
|
+
* under StrictMode in development.
|
|
24
|
+
*/
|
|
25
|
+
onFallback?: (info: FallbackInfo) => void;
|
|
26
|
+
/**
|
|
27
|
+
* Rendered instead of throwing when the outer config shape is invalid.
|
|
28
|
+
* Without it, invalid outer shape throws ConfigError (the default, so
|
|
29
|
+
* boundary-less apps fail loudly rather than silently blank).
|
|
30
|
+
*/
|
|
31
|
+
invalidConfigFallback?: ReactNode;
|
|
32
|
+
/**
|
|
33
|
+
* Wrap every block in an error boundary: a block that throws while
|
|
34
|
+
* rendering becomes a `render-error` fallback instead of taking the page
|
|
35
|
+
* down, and recovers when the entry changes. Client-side containment only —
|
|
36
|
+
* React error boundaries do not catch during server rendering, and class
|
|
37
|
+
* boundaries do not exist in Server Components, so enabling this outside a
|
|
38
|
+
* client environment throws a descriptive error.
|
|
39
|
+
*/
|
|
40
|
+
catchBlockErrors?: boolean;
|
|
14
41
|
}
|
|
15
|
-
export declare function Renderer<T extends BlocksShape>({ config, registry, fallback, wrapBlock, }: RendererProps<T>): ReactNode;
|
|
42
|
+
export declare function Renderer<T extends BlocksShape>({ config, registry, fallback, wrapBlock, onFallback, invalidConfigFallback, catchBlockErrors, }: RendererProps<T>): ReactNode;
|
package/dist/Renderer.js
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { Fragment } from "react";
|
|
3
|
-
import
|
|
2
|
+
import { Fragment, useMemo } from "react";
|
|
3
|
+
// Namespace import: React.Component must only be touched lazily — the
|
|
4
|
+
// react-server condition's build exports useMemo/Fragment but NOT Component,
|
|
5
|
+
// and the main entry must stay loadable from Server Components.
|
|
6
|
+
import * as React from "react";
|
|
7
|
+
import { parseConfig, ConfigError } from "./parseConfig.js";
|
|
8
|
+
import { validateProps } from "./validateProps.js";
|
|
4
9
|
import { DefaultFallback } from "./fallback.js";
|
|
10
|
+
import { isDev } from "./env.js";
|
|
11
|
+
// Module-global on purpose: this is a config-authoring diagnostic, not a
|
|
12
|
+
// per-instance signal — a second Renderer repeating the same duplicate id
|
|
13
|
+
// will not re-warn. StrictMode double-invokes are deduped by the same set.
|
|
14
|
+
const warnedDuplicateIds = new Set();
|
|
5
15
|
// `memo`/`forwardRef` components are exotic objects, not functions, so the
|
|
6
16
|
// bare-vs-definition split must discriminate on the `component` key.
|
|
7
17
|
function resolveDef(def) {
|
|
@@ -10,37 +20,132 @@ function resolveDef(def) {
|
|
|
10
20
|
}
|
|
11
21
|
return { component: def };
|
|
12
22
|
}
|
|
13
|
-
|
|
14
|
-
const Fallback = fallback ?? DefaultFallback;
|
|
23
|
+
function prepare(config, registry, emitFallback) {
|
|
15
24
|
const entries = parseConfig(config);
|
|
25
|
+
const seenKeys = new Set();
|
|
16
26
|
return entries.map((entry, index) => {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
27
|
+
let key = entry.id ?? `${entry.type}-${index}`;
|
|
28
|
+
if (seenKeys.has(key)) {
|
|
29
|
+
if (isDev() && !warnedDuplicateIds.has(key)) {
|
|
30
|
+
warnedDuplicateIds.add(key);
|
|
31
|
+
console.warn(`blockprint: duplicate block id "${key}" at config[${index}]; ids should be unique per config`);
|
|
32
|
+
}
|
|
33
|
+
key = `${key}__dup${index}`;
|
|
34
|
+
}
|
|
35
|
+
seenKeys.add(key);
|
|
36
|
+
const toFallback = (fallbackProps) => {
|
|
37
|
+
emitFallback({ ...fallbackProps, index });
|
|
38
|
+
return { key, entry, kind: "fallback", fallbackProps };
|
|
39
|
+
};
|
|
40
|
+
if (!Object.hasOwn(registry.blocks, entry.type)) {
|
|
41
|
+
return toFallback({
|
|
42
|
+
reason: "unknown-type",
|
|
43
|
+
type: entry.type,
|
|
44
|
+
props: entry.props,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const { component, schema } = resolveDef(registry.blocks[entry.type]);
|
|
48
|
+
if (!schema) {
|
|
49
|
+
return { key, entry, kind: "block", component, props: entry.props };
|
|
50
|
+
}
|
|
51
|
+
const validation = validateProps(schema, entry.props);
|
|
52
|
+
if (!validation.ok) {
|
|
53
|
+
return toFallback({
|
|
54
|
+
reason: "invalid-props",
|
|
55
|
+
type: entry.type,
|
|
56
|
+
props: entry.props,
|
|
57
|
+
issues: validation.issues,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return { key, entry, kind: "block", component, props: validation.value };
|
|
21
61
|
});
|
|
22
62
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
63
|
+
let LazyBoundary;
|
|
64
|
+
/**
|
|
65
|
+
* The boundary class is created on first use so React.Component is never
|
|
66
|
+
* touched at module load — the react-server build doesn't export it.
|
|
67
|
+
*/
|
|
68
|
+
function getBlockBoundary() {
|
|
69
|
+
if (LazyBoundary)
|
|
70
|
+
return LazyBoundary;
|
|
71
|
+
const Base = React.Component;
|
|
72
|
+
if (typeof Base !== "function") {
|
|
73
|
+
throw new Error("blockprint: catchBlockErrors requires a client React environment — error boundaries are not available in Server Components");
|
|
74
|
+
}
|
|
75
|
+
class BlockBoundary extends Base {
|
|
76
|
+
state = { hasError: false };
|
|
77
|
+
static getDerivedStateFromError(error) {
|
|
78
|
+
return { hasError: true, error };
|
|
79
|
+
}
|
|
80
|
+
componentDidCatch(error) {
|
|
81
|
+
this.props.onError?.(error);
|
|
82
|
+
}
|
|
83
|
+
componentDidUpdate(previous) {
|
|
84
|
+
if (this.state.hasError && previous.resetKey !== this.props.resetKey) {
|
|
85
|
+
this.setState({ hasError: false, error: undefined });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
render() {
|
|
89
|
+
return this.state.hasError
|
|
90
|
+
? this.props.renderFallback(this.state.error)
|
|
91
|
+
: this.props.children;
|
|
92
|
+
}
|
|
26
93
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
94
|
+
LazyBoundary = BlockBoundary;
|
|
95
|
+
return LazyBoundary;
|
|
96
|
+
}
|
|
97
|
+
export function Renderer({ config, registry, fallback, wrapBlock, onFallback, invalidConfigFallback, catchBlockErrors, }) {
|
|
98
|
+
const Fallback = fallback ?? DefaultFallback;
|
|
99
|
+
// Per-instance dedupe for onFallback: unstable config identities (inline
|
|
100
|
+
// arrays, `x ?? []`) re-run the memo, but telemetry fires at most once per
|
|
101
|
+
// (reason, type, index) for the life of this Renderer instance.
|
|
102
|
+
const firedFallbacks = useMemo(() => new Set(), []);
|
|
103
|
+
// Parsing and schema validation run once per (config, registry) identity.
|
|
104
|
+
// onFallback is deliberately not a dependency: it is only invoked inside a
|
|
105
|
+
// recompute, which always closes over the current render's value — listing
|
|
106
|
+
// it would defeat the memo for inline callbacks.
|
|
107
|
+
const prepared = useMemo(() => {
|
|
108
|
+
const emitFallback = (info) => {
|
|
109
|
+
const dedupeKey = `${info.reason}:${info.type}:${info.index}`;
|
|
110
|
+
if (firedFallbacks.has(dedupeKey))
|
|
111
|
+
return;
|
|
112
|
+
firedFallbacks.add(dedupeKey);
|
|
113
|
+
onFallback?.(info);
|
|
114
|
+
};
|
|
115
|
+
try {
|
|
116
|
+
return { entries: prepare(config, registry, emitFallback) };
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (error instanceof ConfigError)
|
|
120
|
+
return { configError: error };
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
124
|
+
}, [config, registry]);
|
|
125
|
+
if ("configError" in prepared) {
|
|
126
|
+
if (invalidConfigFallback !== undefined)
|
|
127
|
+
return invalidConfigFallback;
|
|
128
|
+
throw prepared.configError;
|
|
44
129
|
}
|
|
45
|
-
|
|
130
|
+
const Boundary = catchBlockErrors ? getBlockBoundary() : undefined;
|
|
131
|
+
return prepared.entries.map((item, index) => {
|
|
132
|
+
let node = item.kind === "block" ? (_jsx(item.component, { ...item.props })) : (_jsx(Fallback, { ...item.fallbackProps }));
|
|
133
|
+
if (Boundary && item.kind === "block") {
|
|
134
|
+
node = (_jsx(Boundary, { resetKey: item.entry, renderFallback: (error) => (_jsx(Fallback, { reason: "render-error", type: item.entry.type, props: item.entry.props, error: error })), onError: (error) => {
|
|
135
|
+
const dedupeKey = `render-error:${item.entry.type}:${index}`;
|
|
136
|
+
if (firedFallbacks.has(dedupeKey))
|
|
137
|
+
return;
|
|
138
|
+
firedFallbacks.add(dedupeKey);
|
|
139
|
+
onFallback?.({
|
|
140
|
+
reason: "render-error",
|
|
141
|
+
type: item.entry.type,
|
|
142
|
+
props: item.entry.props,
|
|
143
|
+
error,
|
|
144
|
+
index,
|
|
145
|
+
});
|
|
146
|
+
}, children: node }));
|
|
147
|
+
}
|
|
148
|
+
// The key lives on a Fragment so it survives whatever wrapBlock returns.
|
|
149
|
+
return (_jsx(Fragment, { children: wrapBlock ? wrapBlock(node, item.entry, index) : node }, item.key));
|
|
150
|
+
});
|
|
46
151
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React-free config types. Everything reachable from the `blockprint/config`
|
|
3
|
+
* entry must resolve without React's type definitions installed — keep this
|
|
4
|
+
* module (and its importers in config land) free of react imports, even
|
|
5
|
+
* type-only ones.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* A single entry as it arrives from a CMS. Deliberately open: `type` is any
|
|
9
|
+
* string, because untrusted configs may contain types the registry has never
|
|
10
|
+
* seen — those route to the fallback instead of crashing.
|
|
11
|
+
*/
|
|
12
|
+
export interface BlockConfig {
|
|
13
|
+
type: string;
|
|
14
|
+
props?: unknown;
|
|
15
|
+
id?: string | number;
|
|
16
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React-free config types. Everything reachable from the `blockprint/config`
|
|
3
|
+
* entry must resolve without React's type definitions installed — keep this
|
|
4
|
+
* module (and its importers in config land) free of react imports, even
|
|
5
|
+
* type-only ones.
|
|
6
|
+
*/
|
|
7
|
+
export {};
|
package/dist/config.d.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* React-free entry point (`blockprint/config`): the boundary validator
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* React-free entry point (`blockprint/config`): the boundary validator, the
|
|
3
|
+
* manifest system, and headless config validation — importable from Node
|
|
4
|
+
* backends and editor apps without a React peer. Keep this module's import
|
|
5
|
+
* graph free of React, including type-only imports.
|
|
6
6
|
*/
|
|
7
|
-
export { parseConfig, ConfigError } from "./parseConfig.
|
|
8
|
-
export
|
|
9
|
-
export type {
|
|
7
|
+
export { parseConfig, ConfigError } from "./parseConfig.js";
|
|
8
|
+
export { defineBlock } from "./manifest.js";
|
|
9
|
+
export type { BlockDefinition, FieldMeta, FieldsFor, InputOf, Manifest, OutputOf, } from "./manifest.js";
|
|
10
|
+
export { validateConfig } from "./validateConfig.js";
|
|
11
|
+
export type { ValidatedEntry, ValidationReport } from "./validateConfig.js";
|
|
12
|
+
export type { BlockConfig } from "./block-config.js";
|
|
13
|
+
export type { StandardSchemaV1 } from "./standard-schema.js";
|
package/dist/config.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* React-free entry point (`blockprint/config`): the boundary validator
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* React-free entry point (`blockprint/config`): the boundary validator, the
|
|
3
|
+
* manifest system, and headless config validation — importable from Node
|
|
4
|
+
* backends and editor apps without a React peer. Keep this module's import
|
|
5
|
+
* graph free of React, including type-only imports.
|
|
6
6
|
*/
|
|
7
7
|
export { parseConfig, ConfigError } from "./parseConfig.js";
|
|
8
|
+
export { defineBlock } from "./manifest.js";
|
|
9
|
+
export { validateConfig } from "./validateConfig.js";
|
package/dist/createRegistry.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ComponentType } from "react";
|
|
2
|
-
import type { StandardSchemaV1 } from "./standard-schema.
|
|
3
|
-
import type { BlocksShape, Registry } from "./types.
|
|
2
|
+
import type { StandardSchemaV1 } from "./standard-schema.js";
|
|
3
|
+
import type { BlocksShape, Registry } from "./types.js";
|
|
4
|
+
import type { Manifest, OutputOf } from "./manifest.js";
|
|
4
5
|
/**
|
|
5
6
|
* Maps every compatible entry to `unknown` (an identity when intersected) and
|
|
6
7
|
* every entry whose schema output is not assignable to its component's props
|
|
@@ -15,4 +16,23 @@ export type ValidateBlocks<T extends BlocksShape> = {
|
|
|
15
16
|
schema: `schema output is not assignable to props of '${K & string}'`;
|
|
16
17
|
} : unknown;
|
|
17
18
|
};
|
|
19
|
+
/**
|
|
20
|
+
* One component per manifest key, each able to accept its schema's output.
|
|
21
|
+
* Contravariance does the per-key checking: a component requiring a prop the
|
|
22
|
+
* schema doesn't produce fails on that key.
|
|
23
|
+
*/
|
|
24
|
+
export type ComponentsFor<M extends Manifest> = {
|
|
25
|
+
[K in keyof M]: ComponentType<OutputOf<M[K]>>;
|
|
26
|
+
};
|
|
27
|
+
/** Rejects component keys that have no manifest entry. */
|
|
28
|
+
export type NoExtraComponents<C, M> = {
|
|
29
|
+
[K in Exclude<keyof C, keyof M>]: never;
|
|
30
|
+
};
|
|
31
|
+
export type JoinedShape<M extends Manifest, C extends ComponentsFor<M>> = {
|
|
32
|
+
[K in keyof M]: {
|
|
33
|
+
component: C[K];
|
|
34
|
+
schema: M[K]["schema"];
|
|
35
|
+
};
|
|
36
|
+
};
|
|
18
37
|
export declare function createRegistry<T extends BlocksShape>(blocks: T & ValidateBlocks<T>): Registry<T>;
|
|
38
|
+
export declare function createRegistry<M extends Manifest, C extends ComponentsFor<M>>(manifest: M, components: C & NoExtraComponents<C, M>): Registry<JoinedShape<M, C>>;
|
package/dist/createRegistry.js
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
-
export function createRegistry(
|
|
1
|
+
export function createRegistry(first, components) {
|
|
2
|
+
if (components === undefined) {
|
|
3
|
+
return { blocks: first };
|
|
4
|
+
}
|
|
5
|
+
const blocks = {};
|
|
6
|
+
for (const key of Object.keys(first)) {
|
|
7
|
+
const component = components[key];
|
|
8
|
+
if (component === undefined) {
|
|
9
|
+
throw new Error(`createRegistry: no component provided for manifest block '${key}'`);
|
|
10
|
+
}
|
|
11
|
+
blocks[key] = {
|
|
12
|
+
component,
|
|
13
|
+
schema: first[key].schema,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
2
16
|
return { blocks: blocks };
|
|
3
17
|
}
|
package/dist/env.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True outside production builds. Guarded so a browser without a bundler
|
|
3
|
+
* defining `process` gets `false` instead of a ReferenceError — deliberately
|
|
4
|
+
* fail-quiet: a bundler-less browser is treated as production (silent
|
|
5
|
+
* default fallback, no dev warnings).
|
|
6
|
+
*/
|
|
7
|
+
export declare function isDev(): boolean;
|
package/dist/env.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True outside production builds. Guarded so a browser without a bundler
|
|
3
|
+
* defining `process` gets `false` instead of a ReferenceError — deliberately
|
|
4
|
+
* fail-quiet: a bundler-less browser is treated as production (silent
|
|
5
|
+
* default fallback, no dev warnings).
|
|
6
|
+
*/
|
|
7
|
+
export function isDev() {
|
|
8
|
+
return typeof process !== "undefined" && process.env.NODE_ENV !== "production";
|
|
9
|
+
}
|
package/dist/fallback.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import type { FallbackProps } from "./types.
|
|
1
|
+
import type { FallbackProps } from "./types.js";
|
|
2
2
|
export declare function DefaultFallback(props: FallbackProps): import("react").JSX.Element | null;
|
package/dist/fallback.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { isDev } from "./env.js";
|
|
2
3
|
export function DefaultFallback(props) {
|
|
3
|
-
if (
|
|
4
|
+
if (!isDev()) {
|
|
4
5
|
return null;
|
|
5
6
|
}
|
|
6
7
|
const detail = props.reason === "invalid-props"
|
|
7
8
|
? props.issues.map((issue) => issue.message).join("; ")
|
|
8
|
-
:
|
|
9
|
+
: props.reason === "render-error"
|
|
10
|
+
? props.error instanceof Error
|
|
11
|
+
? props.error.message
|
|
12
|
+
: String(props.error)
|
|
13
|
+
: "no component registered for this type";
|
|
9
14
|
return (_jsxs("div", { role: "note", style: {
|
|
10
15
|
border: "1px dashed #c00",
|
|
11
16
|
borderRadius: 4,
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
export { createRegistry } from "./createRegistry.
|
|
2
|
-
export { Renderer } from "./Renderer.
|
|
3
|
-
export type { RendererProps } from "./Renderer.
|
|
4
|
-
export { parseConfig, ConfigError } from "./parseConfig.
|
|
5
|
-
export
|
|
6
|
-
export type {
|
|
1
|
+
export { createRegistry } from "./createRegistry.js";
|
|
2
|
+
export { Renderer } from "./Renderer.js";
|
|
3
|
+
export type { RendererProps, FallbackInfo } from "./Renderer.js";
|
|
4
|
+
export { parseConfig, ConfigError } from "./parseConfig.js";
|
|
5
|
+
export { defineBlock } from "./manifest.js";
|
|
6
|
+
export type { BlockDefinition, FieldMeta, FieldsFor, InputOf, Manifest, OutputOf, } from "./manifest.js";
|
|
7
|
+
export { validateConfig } from "./validateConfig.js";
|
|
8
|
+
export type { ValidatedEntry, ValidationReport } from "./validateConfig.js";
|
|
9
|
+
export type { ComponentsFor, JoinedShape } from "./createRegistry.js";
|
|
10
|
+
export type { BlockConfig, BlockDef, BlocksShape, ConfigFor, FallbackProps, InputFor, PropsFor, Registry, } from "./types.js";
|
|
11
|
+
export type { StandardSchemaV1 } from "./standard-schema.js";
|
package/dist/index.js
CHANGED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "./standard-schema.js";
|
|
2
|
+
/**
|
|
3
|
+
* Editor metadata for one field of a block's input. blockprint stores this
|
|
4
|
+
* and does not interpret it — form generation decides what the keys mean.
|
|
5
|
+
* Shallow by design: nested paths are out of scope.
|
|
6
|
+
*/
|
|
7
|
+
export interface FieldMeta {
|
|
8
|
+
label?: string;
|
|
9
|
+
help?: string;
|
|
10
|
+
widget?: string;
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
}
|
|
13
|
+
export type FieldsFor<S extends StandardSchemaV1> = StandardSchemaV1.InferInput<S> extends Record<string, any> ? {
|
|
14
|
+
[K in keyof StandardSchemaV1.InferInput<S> & string]?: FieldMeta;
|
|
15
|
+
} : never;
|
|
16
|
+
/**
|
|
17
|
+
* A component-free block definition: everything an admin, a server, or a
|
|
18
|
+
* test needs to know about a block, importable without the component.
|
|
19
|
+
*/
|
|
20
|
+
export interface BlockDefinition<S extends StandardSchemaV1 = StandardSchemaV1> {
|
|
21
|
+
readonly schema: S;
|
|
22
|
+
/** Display name; falls back to the manifest key. */
|
|
23
|
+
readonly label?: string;
|
|
24
|
+
/** Starting props for a newly inserted block, in the schema's INPUT shape. */
|
|
25
|
+
readonly defaults?: StandardSchemaV1.InferInput<S>;
|
|
26
|
+
/** One-line description of an instance, from the schema's OUTPUT shape. */
|
|
27
|
+
readonly summary?: (props: StandardSchemaV1.InferOutput<S>) => string;
|
|
28
|
+
readonly fields?: FieldsFor<S>;
|
|
29
|
+
/** Reserved for future migration tooling. */
|
|
30
|
+
readonly version?: number;
|
|
31
|
+
}
|
|
32
|
+
export type Manifest = Record<string, BlockDefinition<any>>;
|
|
33
|
+
/** The rendered/validated props of a definition (schema OUTPUT). */
|
|
34
|
+
export type OutputOf<D extends BlockDefinition<any>> = StandardSchemaV1.InferOutput<D["schema"]>;
|
|
35
|
+
/** The authorable props of a definition (schema INPUT; defaults optional). */
|
|
36
|
+
export type InputOf<D extends BlockDefinition<any>> = StandardSchemaV1.InferInput<D["schema"]>;
|
|
37
|
+
/**
|
|
38
|
+
* Identity at runtime; the generic captures the schema so defaults check
|
|
39
|
+
* against the schema's input and summary receives its output.
|
|
40
|
+
*/
|
|
41
|
+
export declare function defineBlock<S extends StandardSchemaV1>(definition: BlockDefinition<S> & {
|
|
42
|
+
schema: S;
|
|
43
|
+
}): BlockDefinition<S>;
|
package/dist/manifest.js
ADDED
package/dist/parseConfig.d.ts
CHANGED
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ComponentType } from "react";
|
|
2
|
-
import type { StandardSchemaV1 } from "./standard-schema.
|
|
2
|
+
import type { StandardSchemaV1 } from "./standard-schema.js";
|
|
3
3
|
export type AnyComponent = ComponentType<any>;
|
|
4
4
|
export type BlockDef = AnyComponent | {
|
|
5
5
|
component: AnyComponent;
|
|
@@ -18,16 +18,7 @@ export type PropsOfDef<D> = D extends {
|
|
|
18
18
|
export interface Registry<T extends BlocksShape> {
|
|
19
19
|
readonly blocks: T;
|
|
20
20
|
}
|
|
21
|
-
|
|
22
|
-
* A single entry as it arrives from a CMS. Deliberately open: `type` is any
|
|
23
|
-
* string, because untrusted configs may contain types the registry has never
|
|
24
|
-
* seen — those route to the fallback instead of crashing.
|
|
25
|
-
*/
|
|
26
|
-
export interface BlockConfig {
|
|
27
|
-
type: string;
|
|
28
|
-
props?: unknown;
|
|
29
|
-
id?: string | number;
|
|
30
|
-
}
|
|
21
|
+
export type { BlockConfig } from "./block-config.js";
|
|
31
22
|
/**
|
|
32
23
|
* The props a consumer may author for a definition. With a schema this is the
|
|
33
24
|
* schema's INPUT type — authored configs are pre-validation data, so fields
|
|
@@ -69,4 +60,10 @@ export type FallbackProps = {
|
|
|
69
60
|
type: string;
|
|
70
61
|
props: unknown;
|
|
71
62
|
issues: ReadonlyArray<StandardSchemaV1.Issue>;
|
|
63
|
+
} | {
|
|
64
|
+
/** A block component threw while rendering (catchBlockErrors only). */
|
|
65
|
+
reason: "render-error";
|
|
66
|
+
type: string;
|
|
67
|
+
props: unknown;
|
|
68
|
+
error: unknown;
|
|
72
69
|
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { BlockConfig } from "./block-config.js";
|
|
2
|
+
import type { Manifest } from "./manifest.js";
|
|
3
|
+
import type { StandardSchemaV1 } from "./standard-schema.js";
|
|
4
|
+
export type ValidatedEntry = {
|
|
5
|
+
index: number;
|
|
6
|
+
entry: BlockConfig;
|
|
7
|
+
ok: true;
|
|
8
|
+
value: unknown;
|
|
9
|
+
} | {
|
|
10
|
+
index: number;
|
|
11
|
+
entry: BlockConfig;
|
|
12
|
+
ok: false;
|
|
13
|
+
reason: "unknown-type";
|
|
14
|
+
} | {
|
|
15
|
+
index: number;
|
|
16
|
+
entry: BlockConfig;
|
|
17
|
+
ok: false;
|
|
18
|
+
reason: "invalid-props" | "async-schema";
|
|
19
|
+
issues: ReadonlyArray<StandardSchemaV1.Issue>;
|
|
20
|
+
};
|
|
21
|
+
export interface ValidationReport {
|
|
22
|
+
/**
|
|
23
|
+
* Outer shape valid and no entry failed validation. Unknown types do NOT
|
|
24
|
+
* flip this — they are the renderer's fallback case and whether they are
|
|
25
|
+
* acceptable is the caller's policy (check `unknownTypes` for strictness).
|
|
26
|
+
*/
|
|
27
|
+
ok: boolean;
|
|
28
|
+
/** Outer-shape failure; when present, `entries` is empty. */
|
|
29
|
+
error?: {
|
|
30
|
+
message: string;
|
|
31
|
+
};
|
|
32
|
+
entries: ValidatedEntry[];
|
|
33
|
+
/** Distinct types with no manifest entry, in order of first appearance. */
|
|
34
|
+
unknownTypes: string[];
|
|
35
|
+
/** Ids appearing more than once, in order of first duplication. */
|
|
36
|
+
duplicateIds: Array<string | number>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Total, headless validation of an untrusted config against a manifest —
|
|
40
|
+
* never throws. One function for save-time validation, tests, loaders, and
|
|
41
|
+
* editors; the Renderer applies the same per-entry semantics.
|
|
42
|
+
*/
|
|
43
|
+
export declare function validateConfig(manifest: Manifest, config: unknown): ValidationReport;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { parseConfig, ConfigError } from "./parseConfig.js";
|
|
2
|
+
import { validateProps } from "./validateProps.js";
|
|
3
|
+
/**
|
|
4
|
+
* Total, headless validation of an untrusted config against a manifest —
|
|
5
|
+
* never throws. One function for save-time validation, tests, loaders, and
|
|
6
|
+
* editors; the Renderer applies the same per-entry semantics.
|
|
7
|
+
*/
|
|
8
|
+
export function validateConfig(manifest, config) {
|
|
9
|
+
let parsed;
|
|
10
|
+
try {
|
|
11
|
+
parsed = parseConfig(config);
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
return {
|
|
15
|
+
ok: false,
|
|
16
|
+
error: {
|
|
17
|
+
message: error instanceof ConfigError ? error.message : String(error),
|
|
18
|
+
},
|
|
19
|
+
entries: [],
|
|
20
|
+
unknownTypes: [],
|
|
21
|
+
duplicateIds: [],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const unknownTypes = [];
|
|
25
|
+
const duplicateIds = [];
|
|
26
|
+
const seenIds = new Set();
|
|
27
|
+
const entries = parsed.map((entry, index) => {
|
|
28
|
+
if (entry.id !== undefined) {
|
|
29
|
+
if (seenIds.has(entry.id) && !duplicateIds.includes(entry.id)) {
|
|
30
|
+
duplicateIds.push(entry.id);
|
|
31
|
+
}
|
|
32
|
+
seenIds.add(entry.id);
|
|
33
|
+
}
|
|
34
|
+
if (!Object.hasOwn(manifest, entry.type)) {
|
|
35
|
+
if (!unknownTypes.includes(entry.type))
|
|
36
|
+
unknownTypes.push(entry.type);
|
|
37
|
+
return { index, entry, ok: false, reason: "unknown-type" };
|
|
38
|
+
}
|
|
39
|
+
const validation = validateProps(manifest[entry.type].schema, entry.props);
|
|
40
|
+
if (!validation.ok) {
|
|
41
|
+
return {
|
|
42
|
+
index,
|
|
43
|
+
entry,
|
|
44
|
+
ok: false,
|
|
45
|
+
reason: validation.reason,
|
|
46
|
+
issues: validation.issues,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return { index, entry, ok: true, value: validation.value };
|
|
50
|
+
});
|
|
51
|
+
const ok = entries.every((item) => item.ok || item.reason === "unknown-type");
|
|
52
|
+
return { ok, entries, unknownTypes, duplicateIds };
|
|
53
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "./standard-schema.js";
|
|
2
|
+
export type PropsValidation = {
|
|
3
|
+
ok: true;
|
|
4
|
+
value: unknown;
|
|
5
|
+
} | {
|
|
6
|
+
ok: false;
|
|
7
|
+
reason: "invalid-props" | "async-schema";
|
|
8
|
+
issues: ReadonlyArray<StandardSchemaV1.Issue>;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* The single semantics for validating one block's props against its schema,
|
|
12
|
+
* shared by the Renderer and the headless validateConfig. Synchronous by
|
|
13
|
+
* contract: an async schema is a config mistake, reported as such.
|
|
14
|
+
*/
|
|
15
|
+
export declare function validateProps(schema: StandardSchemaV1<unknown, any>, props: unknown): PropsValidation;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single semantics for validating one block's props against its schema,
|
|
3
|
+
* shared by the Renderer and the headless validateConfig. Synchronous by
|
|
4
|
+
* contract: an async schema is a config mistake, reported as such.
|
|
5
|
+
*/
|
|
6
|
+
export function validateProps(schema, props) {
|
|
7
|
+
const result = schema["~standard"].validate(props);
|
|
8
|
+
if (result instanceof Promise) {
|
|
9
|
+
return {
|
|
10
|
+
ok: false,
|
|
11
|
+
reason: "async-schema",
|
|
12
|
+
issues: [
|
|
13
|
+
{
|
|
14
|
+
message: "schema validated asynchronously; blockprint only supports synchronous schemas",
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
if (result.issues) {
|
|
20
|
+
return { ok: false, reason: "invalid-props", issues: result.issues };
|
|
21
|
+
}
|
|
22
|
+
return { ok: true, value: result.value };
|
|
23
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "blockprint",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Typed, config-driven block renderer for React. Render arbitrary UI trees from untrusted JSON with compile-time registry safety and runtime schema validation.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://blockprint.terzidest.com",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@types/react": "^19.2.18",
|
|
45
45
|
"@types/react-dom": "^19.2.4",
|
|
46
|
+
"jsdom": "^30.1.3",
|
|
46
47
|
"react": "^19.2.8",
|
|
47
48
|
"react-dom": "^19.2.8",
|
|
48
49
|
"typescript": "^7.0.2",
|
|
@@ -55,7 +56,7 @@
|
|
|
55
56
|
}
|
|
56
57
|
},
|
|
57
58
|
"scripts": {
|
|
58
|
-
"build": "tsc -p tsconfig.build.json",
|
|
59
|
+
"build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && node scripts/fix-dts.mjs",
|
|
59
60
|
"typecheck": "tsc",
|
|
60
61
|
"test": "vitest run"
|
|
61
62
|
}
|
package/dist/chunk-4JB5QQUK.js
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
// src/parseConfig.ts
|
|
2
|
-
var ConfigError = class extends Error {
|
|
3
|
-
constructor(message) {
|
|
4
|
-
super(message);
|
|
5
|
-
this.name = "ConfigError";
|
|
6
|
-
}
|
|
7
|
-
};
|
|
8
|
-
function parseConfig(input) {
|
|
9
|
-
if (!Array.isArray(input)) {
|
|
10
|
-
throw new ConfigError(
|
|
11
|
-
`config must be an array, received ${input === null ? "null" : typeof input}`
|
|
12
|
-
);
|
|
13
|
-
}
|
|
14
|
-
return input.map((entry, index) => {
|
|
15
|
-
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
16
|
-
throw new ConfigError(`config[${index}] must be an object`);
|
|
17
|
-
}
|
|
18
|
-
const { type, props, id } = entry;
|
|
19
|
-
if (typeof type !== "string" || type.length === 0) {
|
|
20
|
-
throw new ConfigError(
|
|
21
|
-
`config[${index}].type must be a non-empty string`
|
|
22
|
-
);
|
|
23
|
-
}
|
|
24
|
-
if (id !== void 0 && typeof id !== "string" && typeof id !== "number") {
|
|
25
|
-
throw new ConfigError(
|
|
26
|
-
`config[${index}].id must be a string or number when present`
|
|
27
|
-
);
|
|
28
|
-
}
|
|
29
|
-
return id === void 0 ? { type, props } : { type, props, id };
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export {
|
|
34
|
-
ConfigError,
|
|
35
|
-
parseConfig
|
|
36
|
-
};
|
|
37
|
-
//# sourceMappingURL=chunk-4JB5QQUK.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/parseConfig.ts"],"sourcesContent":["import type { BlockConfig } from \"./types\";\n\nexport class ConfigError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"ConfigError\";\n }\n}\n\n/**\n * The single boundary where untrusted input becomes a typed config. Validates\n * the outer shape only; per-block props are validated against their registered\n * schema at render time.\n */\nexport function parseConfig(input: unknown): BlockConfig[] {\n if (!Array.isArray(input)) {\n throw new ConfigError(\n `config must be an array, received ${input === null ? \"null\" : typeof input}`,\n );\n }\n\n return input.map((entry, index) => {\n if (typeof entry !== \"object\" || entry === null || Array.isArray(entry)) {\n throw new ConfigError(`config[${index}] must be an object`);\n }\n const { type, props, id } = entry as Record<string, unknown>;\n if (typeof type !== \"string\" || type.length === 0) {\n throw new ConfigError(\n `config[${index}].type must be a non-empty string`,\n );\n }\n if (id !== undefined && typeof id !== \"string\" && typeof id !== \"number\") {\n throw new ConfigError(\n `config[${index}].id must be a string or number when present`,\n );\n }\n return id === undefined ? { type, props } : { type, props, id };\n });\n}\n"],"mappings":";AAEO,IAAM,cAAN,cAA0B,MAAM;AAAA,EACrC,YAAY,SAAiB;AAC3B,UAAM,OAAO;AACb,SAAK,OAAO;AAAA,EACd;AACF;AAOO,SAAS,YAAY,OAA+B;AACzD,MAAI,CAAC,MAAM,QAAQ,KAAK,GAAG;AACzB,UAAM,IAAI;AAAA,MACR,qCAAqC,UAAU,OAAO,SAAS,OAAO,KAAK;AAAA,IAC7E;AAAA,EACF;AAEA,SAAO,MAAM,IAAI,CAAC,OAAO,UAAU;AACjC,QAAI,OAAO,UAAU,YAAY,UAAU,QAAQ,MAAM,QAAQ,KAAK,GAAG;AACvE,YAAM,IAAI,YAAY,UAAU,KAAK,qBAAqB;AAAA,IAC5D;AACA,UAAM,EAAE,MAAM,OAAO,GAAG,IAAI;AAC5B,QAAI,OAAO,SAAS,YAAY,KAAK,WAAW,GAAG;AACjD,YAAM,IAAI;AAAA,QACR,UAAU,KAAK;AAAA,MACjB;AAAA,IACF;AACA,QAAI,OAAO,UAAa,OAAO,OAAO,YAAY,OAAO,OAAO,UAAU;AACxE,YAAM,IAAI;AAAA,QACR,UAAU,KAAK;AAAA,MACjB;AAAA,IACF;AACA,WAAO,OAAO,SAAY,EAAE,MAAM,MAAM,IAAI,EAAE,MAAM,OAAO,GAAG;AAAA,EAChE,CAAC;AACH;","names":[]}
|
package/dist/config.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
package/dist/index.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/createRegistry.ts","../src/Renderer.tsx","../src/fallback.tsx"],"sourcesContent":["import type { ComponentType } from \"react\";\nimport type { StandardSchemaV1 } from \"./standard-schema\";\nimport type { BlocksShape, Registry } from \"./types\";\n\n/**\n * Maps every compatible entry to `unknown` (an identity when intersected) and\n * every entry whose schema output is not assignable to its component's props\n * to an impossible shape — so the type error lands on the offending key with\n * a message naming it.\n */\nexport type ValidateBlocks<T extends BlocksShape> = {\n [K in keyof T]: T[K] extends {\n component: ComponentType<infer P>;\n schema: StandardSchemaV1<unknown, infer S>;\n }\n ? [S] extends [P]\n ? unknown\n : {\n schema: `schema output is not assignable to props of '${K & string}'`;\n }\n : unknown;\n};\n\nexport function createRegistry<T extends BlocksShape>(\n blocks: T & ValidateBlocks<T>,\n): Registry<T> {\n return { blocks: blocks as T };\n}\n","import { Fragment } from \"react\";\nimport type { ComponentType, ReactNode } from \"react\";\nimport type { StandardSchemaV1 } from \"./standard-schema\";\nimport type {\n AnyComponent,\n BlockConfig,\n BlocksShape,\n FallbackProps,\n Registry,\n} from \"./types\";\nimport { parseConfig } from \"./parseConfig\";\nimport { DefaultFallback } from \"./fallback\";\n\nexport interface RendererProps<T extends BlocksShape> {\n /** Untrusted input; the outer shape is validated internally. */\n config: unknown;\n registry: Registry<T>;\n fallback?: ComponentType<FallbackProps>;\n /**\n * Wraps every rendered entry — blocks and fallbacks alike — with access to\n * the config entry and its index. The hook point for visual-editing\n * overlays, analytics boundaries, or per-block chrome.\n */\n wrapBlock?: (node: ReactNode, entry: BlockConfig, index: number) => ReactNode;\n}\n\ninterface ResolvedDef {\n component: AnyComponent;\n schema?: StandardSchemaV1<unknown, any>;\n}\n\n// `memo`/`forwardRef` components are exotic objects, not functions, so the\n// bare-vs-definition split must discriminate on the `component` key.\nfunction resolveDef(def: BlocksShape[string]): ResolvedDef {\n if (typeof def === \"object\" && def !== null && \"component\" in def) {\n return def;\n }\n return { component: def };\n}\n\nexport function Renderer<T extends BlocksShape>({\n config,\n registry,\n fallback,\n wrapBlock,\n}: RendererProps<T>): ReactNode {\n const Fallback = fallback ?? DefaultFallback;\n const entries = parseConfig(config);\n\n return entries.map((entry, index) => {\n const key = entry.id ?? `${entry.type}-${index}`;\n const node = renderEntry(entry, registry, Fallback);\n // The key lives on a Fragment so it survives whatever wrapBlock returns.\n return (\n <Fragment key={key}>\n {wrapBlock ? wrapBlock(node, entry, index) : node}\n </Fragment>\n );\n });\n}\n\nfunction renderEntry(\n entry: BlockConfig,\n registry: Registry<BlocksShape>,\n Fallback: ComponentType<FallbackProps>,\n): ReactNode {\n if (!Object.hasOwn(registry.blocks, entry.type)) {\n return (\n <Fallback reason=\"unknown-type\" type={entry.type} props={entry.props} />\n );\n }\n\n const { component: Component, schema } = resolveDef(\n registry.blocks[entry.type] as BlocksShape[string],\n );\n\n let props = entry.props;\n if (schema) {\n const result = schema[\"~standard\"].validate(entry.props);\n // React render is synchronous; async schemas are a config mistake and\n // are contained like any other invalid block.\n if (result instanceof Promise) {\n return (\n <Fallback\n reason=\"invalid-props\"\n type={entry.type}\n props={entry.props}\n issues={[\n {\n message:\n \"schema validated asynchronously; blockprint only supports synchronous schemas\",\n },\n ]}\n />\n );\n }\n if (result.issues) {\n return (\n <Fallback\n reason=\"invalid-props\"\n type={entry.type}\n props={entry.props}\n issues={result.issues}\n />\n );\n }\n props = result.value;\n }\n\n return <Component {...(props as object | undefined)} />;\n}\n","import type { FallbackProps } from \"./types\";\n\n// Ambient declaration instead of @types/node: bundlers replace\n// process.env.NODE_ENV statically, and the library has no other Node surface.\ndeclare const process: { env: { NODE_ENV?: string } };\n\nexport function DefaultFallback(props: FallbackProps) {\n if (process.env.NODE_ENV === \"production\") {\n return null;\n }\n\n const detail =\n props.reason === \"invalid-props\"\n ? props.issues.map((issue) => issue.message).join(\"; \")\n : \"no component registered for this type\";\n\n return (\n <div\n role=\"note\"\n style={{\n border: \"1px dashed #c00\",\n borderRadius: 4,\n padding: \"8px 12px\",\n fontFamily: \"monospace\",\n fontSize: 13,\n color: \"#c00\",\n }}\n >\n blockprint: block "{props.type}" not rendered ({props.reason}):{\" \"}\n {detail}\n </div>\n );\n}\n"],"mappings":";;;;;;AAuBO,SAAS,eACd,QACa;AACb,SAAO,EAAE,OAAoB;AAC/B;;;AC3BA,SAAS,gBAAgB;;;ACiBrB;AAXG,SAAS,gBAAgB,OAAsB;AACpD,MAAI,QAAQ,IAAI,aAAa,cAAc;AACzC,WAAO;AAAA,EACT;AAEA,QAAM,SACJ,MAAM,WAAW,kBACb,MAAM,OAAO,IAAI,CAAC,UAAU,MAAM,OAAO,EAAE,KAAK,IAAI,IACpD;AAEN,SACE;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,OAAO;AAAA,QACL,QAAQ;AAAA,QACR,cAAc;AAAA,QACd,SAAS;AAAA,QACT,YAAY;AAAA,QACZ,UAAU;AAAA,QACV,OAAO;AAAA,MACT;AAAA,MACD;AAAA;AAAA,QAC0B,MAAM;AAAA,QAAK;AAAA,QAAsB,MAAM;AAAA,QAAO;AAAA,QAAG;AAAA,QACzE;AAAA;AAAA;AAAA,EACH;AAEJ;;;ADsBM;AArBN,SAAS,WAAW,KAAuC;AACzD,MAAI,OAAO,QAAQ,YAAY,QAAQ,QAAQ,eAAe,KAAK;AACjE,WAAO;AAAA,EACT;AACA,SAAO,EAAE,WAAW,IAAI;AAC1B;AAEO,SAAS,SAAgC;AAAA,EAC9C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAAgC;AAC9B,QAAM,WAAW,YAAY;AAC7B,QAAM,UAAU,YAAY,MAAM;AAElC,SAAO,QAAQ,IAAI,CAAC,OAAO,UAAU;AACnC,UAAM,MAAM,MAAM,MAAM,GAAG,MAAM,IAAI,IAAI,KAAK;AAC9C,UAAM,OAAO,YAAY,OAAO,UAAU,QAAQ;AAElD,WACE,oBAAC,YACE,sBAAY,UAAU,MAAM,OAAO,KAAK,IAAI,QADhC,GAEf;AAAA,EAEJ,CAAC;AACH;AAEA,SAAS,YACP,OACA,UACA,UACW;AACX,MAAI,CAAC,OAAO,OAAO,SAAS,QAAQ,MAAM,IAAI,GAAG;AAC/C,WACE,oBAAC,YAAS,QAAO,gBAAe,MAAM,MAAM,MAAM,OAAO,MAAM,OAAO;AAAA,EAE1E;AAEA,QAAM,EAAE,WAAW,WAAW,OAAO,IAAI;AAAA,IACvC,SAAS,OAAO,MAAM,IAAI;AAAA,EAC5B;AAEA,MAAI,QAAQ,MAAM;AAClB,MAAI,QAAQ;AACV,UAAM,SAAS,OAAO,WAAW,EAAE,SAAS,MAAM,KAAK;AAGvD,QAAI,kBAAkB,SAAS;AAC7B,aACE;AAAA,QAAC;AAAA;AAAA,UACC,QAAO;AAAA,UACP,MAAM,MAAM;AAAA,UACZ,OAAO,MAAM;AAAA,UACb,QAAQ;AAAA,YACN;AAAA,cACE,SACE;AAAA,YACJ;AAAA,UACF;AAAA;AAAA,MACF;AAAA,IAEJ;AACA,QAAI,OAAO,QAAQ;AACjB,aACE;AAAA,QAAC;AAAA;AAAA,UACC,QAAO;AAAA,UACP,MAAM,MAAM;AAAA,UACZ,OAAO,MAAM;AAAA,UACb,QAAQ,OAAO;AAAA;AAAA,MACjB;AAAA,IAEJ;AACA,YAAQ,OAAO;AAAA,EACjB;AAEA,SAAO,oBAAC,aAAW,GAAI,OAA8B;AACvD;","names":[]}
|