blockprint 0.0.1 → 0.1.1

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 CHANGED
@@ -1,16 +1,52 @@
1
1
  # blockprint
2
2
 
3
- Typed, config-driven block rendering for React. See the [repository README](../../README.md) for the full introduction.
3
+ **Typed, config-driven block rendering for React.** Render arbitrary UI trees from CMS-controlled JSON — with compile-time safety between your components and the config, and runtime validation at the boundary.
4
+
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
+
7
+ ## The problem
8
+
9
+ 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.
10
+
11
+ blockprint inverts it: you define the blocks and their prop types, register them once, and render any config — including configs containing block types or malformed props you've never seen.
12
+
13
+ ## Usage
14
+
15
+ ```tsx
16
+ import { createRegistry, Renderer } from "blockprint";
17
+ import { z } from "zod";
18
+
19
+ const HeroBanner = ({ title, subtitle }: { title: string; subtitle?: string }) => (
20
+ <header><h1>{title}</h1>{subtitle && <p>{subtitle}</p>}</header>
21
+ );
22
+
23
+ const registry = createRegistry({
24
+ hero: HeroBanner, // compile-time trust
25
+ gameGrid: { // runtime guarantee
26
+ component: GameGrid,
27
+ schema: z.object({ games: z.array(gameSchema) }),
28
+ },
29
+ });
30
+
31
+ // config typically arrives from a CMS at runtime
32
+ <Renderer config={pageConfig} registry={registry} />;
33
+ ```
34
+
35
+ - A registered component whose props don't match its schema is a **compile error on that key**, with a message naming the block — not a runtime crash.
36
+ - Unknown block types and schema-rejected props render a configurable **fallback**; the rest of the page renders normally.
37
+ - Configs authored in TypeScript get block-name autocomplete and per-block prop checking via `ConfigFor<typeof registry>`.
38
+ - **Zero runtime dependencies.** Schemas are typed against [Standard Schema](https://standardschema.dev), so zod (≥ 3.24), valibot, and arktype all work — blockprint depends on none of them.
39
+ - Framework-agnostic: React only. No Next.js/Remix imports; the library ships the mechanism, not the content.
4
40
 
5
41
  ## API
6
42
 
7
43
  ### `createRegistry(blocks)`
8
44
 
9
- Builds a typed registry from a map of block-type strings to definitions. A definition is either a bare component or `{ component, schema? }`, where `schema` is any [Standard Schema](https://standardschema.dev) validator (zod ≥ 3.24, valibot, arktype, …).
45
+ Builds a typed registry from a map of block-type strings to definitions. A definition is either a bare component or `{ component, schema? }`, where `schema` is any Standard Schema validator.
10
46
 
11
47
  When a schema is present it is the source of truth: the component receives the schema's parsed output (so schema defaults become prop defaults), and a component that cannot accept that output is a compile error on the offending key.
12
48
 
13
- ### `<Renderer config registry fallback? />`
49
+ ### `<Renderer config registry fallback? wrapBlock? />`
14
50
 
15
51
  Renders a config against a registry. `config` is `unknown` on purpose — the outer shape is validated internally at a single boundary. Every entry resolves to exactly one of:
16
52
 
@@ -18,7 +54,9 @@ Renders a config against a registry. `config` is `unknown` on purpose — the ou
18
54
  2. unknown type → `fallback` with `{ reason: "unknown-type" }`;
19
55
  3. schema-rejected props → `fallback` with `{ reason: "invalid-props", issues }`.
20
56
 
21
- The default fallback renders a warning box in development and `null` in production.
57
+ 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
+
59
+ 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.
22
60
 
23
61
  ### `parseConfig(input)`
24
62
 
@@ -28,11 +66,16 @@ The boundary validator, exported for consumers who want to fail at fetch time ra
28
66
 
29
67
  - `Registry<T>` — the registry's captured shape.
30
68
  - `BlockConfig` — one untrusted config entry: `{ type: string; props?: unknown; id?: string | number }`.
31
- - `ConfigFor<R>` — the closed, fully typed config union for a registry; for configs authored in TypeScript.
69
+ - `ConfigFor<R>` — the closed, fully typed config union for a registry, for configs authored in TypeScript. Typed as each schema's *input*, so fields with defaults are optional to write.
32
70
  - `FallbackProps` — the fallback contract.
33
71
  - `StandardSchemaV1` — re-exported for custom schema implementations.
34
72
 
35
- ## Notes
73
+ ## Gotchas
36
74
 
37
- - Async schemas are not supported (React render is synchronous); an async validation routes the entry to the fallback with an explanatory issue.
75
+ - **`exactOptionalPropertyTypes` + zod's `.optional()`**: with that compiler flag enabled, a schema field like `z.string().optional()` produces `prop?: string | undefined`, which is not assignable to a component prop declared `prop?: string` — so the registry's compile-time check (correctly) rejects the registration. Either declare such component props as `prop?: string | undefined`, or leave the flag off in the consuming app.
76
+ - **Async schemas are not supported** (React render is synchronous); an async validation routes the entry to the fallback with an explanatory issue.
38
77
  - Entries use `id` for React keys when present, falling back to `type-index`.
78
+
79
+ ## License
80
+
81
+ MIT © [Triantafillos Terzidis](https://github.com/terzidest)
@@ -1,9 +1,15 @@
1
1
  import type { ComponentType, ReactNode } from "react";
2
- import type { BlocksShape, FallbackProps, Registry } from "./types";
2
+ import type { BlockConfig, BlocksShape, FallbackProps, Registry } from "./types";
3
3
  export interface RendererProps<T extends BlocksShape> {
4
4
  /** Untrusted input; the outer shape is validated internally. */
5
5
  config: unknown;
6
6
  registry: Registry<T>;
7
7
  fallback?: ComponentType<FallbackProps>;
8
+ /**
9
+ * Wraps every rendered entry — blocks and fallbacks alike — with access to
10
+ * the config entry and its index. The hook point for visual-editing
11
+ * overlays, analytics boundaries, or per-block chrome.
12
+ */
13
+ wrapBlock?: (node: ReactNode, entry: BlockConfig, index: number) => ReactNode;
8
14
  }
9
- export declare function Renderer<T extends BlocksShape>({ config, registry, fallback, }: RendererProps<T>): ReactNode;
15
+ export declare function Renderer<T extends BlocksShape>({ config, registry, fallback, wrapBlock, }: RendererProps<T>): ReactNode;
package/dist/Renderer.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Fragment } from "react";
2
3
  import { parseConfig } from "./parseConfig";
3
4
  import { DefaultFallback } from "./fallback";
4
5
  // `memo`/`forwardRef` components are exotic objects, not functions, so the
@@ -9,32 +10,37 @@ function resolveDef(def) {
9
10
  }
10
11
  return { component: def };
11
12
  }
12
- export function Renderer({ config, registry, fallback, }) {
13
+ export function Renderer({ config, registry, fallback, wrapBlock, }) {
13
14
  const Fallback = fallback ?? DefaultFallback;
14
15
  const entries = parseConfig(config);
15
16
  return entries.map((entry, index) => {
16
17
  const key = entry.id ?? `${entry.type}-${index}`;
17
- if (!Object.hasOwn(registry.blocks, entry.type)) {
18
- return (_jsx(Fallback, { reason: "unknown-type", type: entry.type, props: entry.props }, key));
18
+ const node = renderEntry(entry, registry, Fallback);
19
+ // The key lives on a Fragment so it survives whatever wrapBlock returns.
20
+ return (_jsx(Fragment, { children: wrapBlock ? wrapBlock(node, entry, index) : node }, key));
21
+ });
22
+ }
23
+ function renderEntry(entry, registry, Fallback) {
24
+ if (!Object.hasOwn(registry.blocks, entry.type)) {
25
+ return (_jsx(Fallback, { reason: "unknown-type", type: entry.type, props: entry.props }));
26
+ }
27
+ const { component: Component, schema } = resolveDef(registry.blocks[entry.type]);
28
+ let props = entry.props;
29
+ if (schema) {
30
+ const result = schema["~standard"].validate(entry.props);
31
+ // React render is synchronous; async schemas are a config mistake and
32
+ // are contained like any other invalid block.
33
+ if (result instanceof Promise) {
34
+ return (_jsx(Fallback, { reason: "invalid-props", type: entry.type, props: entry.props, issues: [
35
+ {
36
+ message: "schema validated asynchronously; blockprint only supports synchronous schemas",
37
+ },
38
+ ] }));
19
39
  }
20
- const { component: Component, schema } = resolveDef(registry.blocks[entry.type]);
21
- let props = entry.props;
22
- if (schema) {
23
- const result = schema["~standard"].validate(entry.props);
24
- // React render is synchronous; async schemas are a config mistake and
25
- // are contained like any other invalid block.
26
- if (result instanceof Promise) {
27
- return (_jsx(Fallback, { reason: "invalid-props", type: entry.type, props: entry.props, issues: [
28
- {
29
- message: "schema validated asynchronously; blockprint only supports synchronous schemas",
30
- },
31
- ] }, key));
32
- }
33
- if (result.issues) {
34
- return (_jsx(Fallback, { reason: "invalid-props", type: entry.type, props: entry.props, issues: result.issues }, key));
35
- }
36
- props = result.value;
40
+ if (result.issues) {
41
+ return (_jsx(Fallback, { reason: "invalid-props", type: entry.type, props: entry.props, issues: result.issues }));
37
42
  }
38
- return _jsx(Component, { ...props }, key);
39
- });
43
+ props = result.value;
44
+ }
45
+ return _jsx(Component, { ...props });
40
46
  }
package/dist/types.d.ts CHANGED
@@ -28,6 +28,15 @@ export interface BlockConfig {
28
28
  props?: unknown;
29
29
  id?: string | number;
30
30
  }
31
+ /**
32
+ * The props a consumer may author for a definition. With a schema this is the
33
+ * schema's INPUT type — authored configs are pre-validation data, so fields
34
+ * with defaults are optional. (The component still receives the parsed
35
+ * output; see PropsOfDef.)
36
+ */
37
+ export type InputOfDef<D> = D extends {
38
+ schema: StandardSchemaV1<infer I, any>;
39
+ } ? I : PropsOfDef<D>;
31
40
  /**
32
41
  * The closed, fully typed config union for a given registry — for configs
33
42
  * authored in TypeScript rather than fetched from a CMS. Block names
@@ -36,7 +45,7 @@ export interface BlockConfig {
36
45
  export type ConfigFor<R extends Registry<BlocksShape>> = R extends Registry<infer T> ? Array<{
37
46
  [K in keyof T]: {
38
47
  type: K;
39
- props: PropsOfDef<T[K]>;
48
+ props: InputOfDef<T[K]>;
40
49
  id?: string | number;
41
50
  };
42
51
  }[keyof T]> : never;
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "blockprint",
3
- "version": "0.0.1",
3
+ "version": "0.1.1",
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
+ "homepage": "https://blockprint.terzidest.com",
7
+ "bugs": {
8
+ "url": "https://github.com/terzidest/blockprint/issues"
9
+ },
6
10
  "repository": {
7
11
  "type": "git",
8
12
  "url": "git+https://github.com/terzidest/blockprint.git",