blockprint 0.1.1 → 0.2.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
@@ -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.
@@ -58,18 +60,70 @@ The default fallback renders a warning box in development and `null` in producti
58
60
 
59
61
  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
62
 
63
+ A common recipe — shared section chrome, deep-link anchors, and analytics attributes in one place instead of repeated in every block:
64
+
65
+ ```tsx
66
+ <Renderer
67
+ config={config}
68
+ registry={registry}
69
+ wrapBlock={(node, entry) => (
70
+ <section
71
+ id={entry.id != null ? String(entry.id) : undefined} // anchor: /page#hero-1
72
+ data-block={entry.type} // analytics / e2e hooks
73
+ className="mx-auto max-w-5xl px-6 py-10"
74
+ >
75
+ {node}
76
+ </section>
77
+ )}
78
+ />
79
+ ```
80
+
81
+ 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.
82
+
61
83
  ### `parseConfig(input)`
62
84
 
63
85
  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
86
 
87
+ ### `blockprint/config` — the React-free entry point
88
+
89
+ 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):
90
+
91
+ ```ts
92
+ // in a Node server — no react in node_modules required
93
+ import { parseConfig, ConfigError } from "blockprint/config";
94
+
95
+ app.put("/pages/:slug", (req, res) => {
96
+ const config = parseConfig(req.body); // throws ConfigError with entry index
97
+ …
98
+ });
99
+ ```
100
+
101
+ Validate structure on the server, validate props at render time in the client — the registry schemas are the real trust boundary, so the server never needs to know the blocks.
102
+
65
103
  ### Types
66
104
 
67
105
  - `Registry<T>` — the registry's captured shape.
68
106
  - `BlockConfig` — one untrusted config entry: `{ type: string; props?: unknown; id?: string | number }`.
69
107
  - `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.
108
+ - `PropsFor<R, K>` / `InputFor<R, K>` — one block type's rendered props (schema output) / authorable props (schema input). For building editors and tooling against a registry.
70
109
  - `FallbackProps` — the fallback contract.
71
110
  - `StandardSchemaV1` — re-exported for custom schema implementations.
72
111
 
112
+ ## Sharing schemas with an editor app
113
+
114
+ An admin/editor app usually can't import the rendering app's registry — that would drag every block component (and its styling, routing, data hooks) into the editor bundle. The pattern that works:
115
+
116
+ 1. Put the **zod schemas in a shared package** (`packages/blocks-contract`), importable by both apps.
117
+ 2. The rendering app joins schemas with components at the edge: `createRegistry({ hero: { component: Hero, schema: heroSchema } })`.
118
+ 3. The editor imports only the schemas (labels, defaults, field shapes) and `blockprint/config` for structure — never the components.
119
+
120
+ `PropsFor`/`InputFor` give the editor the exact prop types per block without any runtime import of the registry.
121
+
122
+ ## Config storage notes
123
+
124
+ - **Entry-level key order is irrelevant** — the array order is the only order that matters. Storage layers that canonicalize JSON (e.g. Postgres `jsonb` reorders object keys) are fully compatible; don't be alarmed when a round-tripped config diffs at the key level.
125
+ - `parseConfig` strips unknown entry-level keys (only `type`, `props`, `id` survive) and deliberately allows unknown `type` values — unknown block types are the renderer's fallback case, which is what makes configs forward-compatible across deploys.
126
+
73
127
  ## Gotchas
74
128
 
75
129
  - **`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.
@@ -1,5 +1,5 @@
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
3
  export interface RendererProps<T extends BlocksShape> {
4
4
  /** Untrusted input; the outer shape is validated internally. */
5
5
  config: unknown;
package/dist/Renderer.js CHANGED
@@ -1,7 +1,11 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { Fragment } from "react";
3
- import { parseConfig } from "./parseConfig";
4
- import { DefaultFallback } from "./fallback";
2
+ import { Fragment, useMemo } from "react";
3
+ import { parseConfig } from "./parseConfig.js";
4
+ import { DefaultFallback } from "./fallback.js";
5
+ import { isDev } from "./env.js";
6
+ // StrictMode double-invokes render (and therefore the memo) in development;
7
+ // remember what we've already warned about so each duplicate id logs once.
8
+ const warnedDuplicateIds = new Set();
5
9
  // `memo`/`forwardRef` components are exotic objects, not functions, so the
6
10
  // bare-vs-definition split must discriminate on the `component` key.
7
11
  function resolveDef(def) {
@@ -10,37 +14,92 @@ function resolveDef(def) {
10
14
  }
11
15
  return { component: def };
12
16
  }
13
- export function Renderer({ config, registry, fallback, wrapBlock, }) {
14
- const Fallback = fallback ?? DefaultFallback;
17
+ function prepare(config, registry) {
15
18
  const entries = parseConfig(config);
19
+ const seenKeys = new Set();
16
20
  return entries.map((entry, index) => {
17
- const key = entry.id ?? `${entry.type}-${index}`;
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) {
21
+ let key = entry.id ?? `${entry.type}-${index}`;
22
+ if (seenKeys.has(key)) {
23
+ if (isDev() && !warnedDuplicateIds.has(key)) {
24
+ warnedDuplicateIds.add(key);
25
+ console.warn(`blockprint: duplicate block id "${key}" at config[${index}]; ids should be unique per config`);
26
+ }
27
+ key = `${key}__dup${index}`;
28
+ }
29
+ seenKeys.add(key);
30
+ if (!Object.hasOwn(registry.blocks, entry.type)) {
31
+ return {
32
+ key,
33
+ entry,
34
+ kind: "fallback",
35
+ fallbackProps: {
36
+ reason: "unknown-type",
37
+ type: entry.type,
38
+ props: entry.props,
39
+ },
40
+ };
41
+ }
42
+ const { component, schema } = resolveDef(registry.blocks[entry.type]);
43
+ if (!schema) {
44
+ return {
45
+ key,
46
+ entry,
47
+ kind: "block",
48
+ component,
49
+ props: entry.props,
50
+ };
51
+ }
30
52
  const result = schema["~standard"].validate(entry.props);
31
53
  // React render is synchronous; async schemas are a config mistake and
32
54
  // are contained like any other invalid block.
33
55
  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
- ] }));
56
+ return {
57
+ key,
58
+ entry,
59
+ kind: "fallback",
60
+ fallbackProps: {
61
+ reason: "invalid-props",
62
+ type: entry.type,
63
+ props: entry.props,
64
+ issues: [
65
+ {
66
+ message: "schema validated asynchronously; blockprint only supports synchronous schemas",
67
+ },
68
+ ],
69
+ },
70
+ };
39
71
  }
40
72
  if (result.issues) {
41
- return (_jsx(Fallback, { reason: "invalid-props", type: entry.type, props: entry.props, issues: result.issues }));
73
+ return {
74
+ key,
75
+ entry,
76
+ kind: "fallback",
77
+ fallbackProps: {
78
+ reason: "invalid-props",
79
+ type: entry.type,
80
+ props: entry.props,
81
+ issues: result.issues,
82
+ },
83
+ };
42
84
  }
43
- props = result.value;
44
- }
45
- return _jsx(Component, { ...props });
85
+ return {
86
+ key,
87
+ entry,
88
+ kind: "block",
89
+ component,
90
+ props: result.value,
91
+ };
92
+ });
93
+ }
94
+ export function Renderer({ config, registry, fallback, wrapBlock, }) {
95
+ const Fallback = fallback ?? DefaultFallback;
96
+ // Parsing and schema validation run once per (config, registry) identity,
97
+ // not on every render; a consumer recreating the config array each render
98
+ // opts out of the caching, not the correctness.
99
+ const prepared = useMemo(() => prepare(config, registry), [config, registry]);
100
+ return prepared.map((item, index) => {
101
+ const node = item.kind === "block" ? (_jsx(item.component, { ...item.props })) : (_jsx(Fallback, { ...item.fallbackProps }));
102
+ // The key lives on a Fragment so it survives whatever wrapBlock returns.
103
+ return (_jsx(Fragment, { children: wrapBlock ? wrapBlock(node, item.entry, index) : node }, item.key));
104
+ });
46
105
  }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * React-free entry point (`blockprint/config`): the boundary validator and
3
+ * config types, importable from Node backends without a React peer. Keep
4
+ * this module (and its imports) free of React — servers validating stored
5
+ * configs want exactly this and nothing more.
6
+ */
7
+ export { parseConfig, ConfigError } from "./parseConfig.js";
8
+ export type { BlockConfig } from "./types.js";
9
+ export type { StandardSchemaV1 } from "./standard-schema.js";
package/dist/config.js ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * React-free entry point (`blockprint/config`): the boundary validator and
3
+ * config types, importable from Node backends without a React peer. Keep
4
+ * this module (and its imports) free of React — servers validating stored
5
+ * configs want exactly this and nothing more.
6
+ */
7
+ export { parseConfig, ConfigError } from "./parseConfig.js";
@@ -1,6 +1,6 @@
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
4
  /**
5
5
  * Maps every compatible entry to `unknown` (an identity when intersected) and
6
6
  * every entry whose schema output is not assignable to its component's props
package/dist/env.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * True outside production builds. Guarded so a browser without a bundler
3
+ * defining `process` gets `false` instead of a ReferenceError.
4
+ */
5
+ export declare function isDev(): boolean;
package/dist/env.js 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.
4
+ */
5
+ export function isDev() {
6
+ return typeof process !== "undefined" && process.env.NODE_ENV !== "production";
7
+ }
@@ -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,6 +1,7 @@
1
1
  import { jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { isDev } from "./env.js";
2
3
  export function DefaultFallback(props) {
3
- if (process.env.NODE_ENV === "production") {
4
+ if (!isDev()) {
4
5
  return null;
5
6
  }
6
7
  const detail = props.reason === "invalid-props"
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- export { createRegistry } from "./createRegistry";
2
- export { Renderer } from "./Renderer";
3
- export type { RendererProps } from "./Renderer";
4
- export { parseConfig, ConfigError } from "./parseConfig";
5
- export type { BlockConfig, BlockDef, BlocksShape, ConfigFor, FallbackProps, Registry, } from "./types";
6
- export type { StandardSchemaV1 } from "./standard-schema";
1
+ export { createRegistry } from "./createRegistry.js";
2
+ export { Renderer } from "./Renderer.js";
3
+ export type { RendererProps } from "./Renderer.js";
4
+ export { parseConfig, ConfigError } from "./parseConfig.js";
5
+ export type { BlockConfig, BlockDef, BlocksShape, ConfigFor, FallbackProps, InputFor, PropsFor, Registry, } from "./types.js";
6
+ export type { StandardSchemaV1 } from "./standard-schema.js";
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- export { createRegistry } from "./createRegistry";
2
- export { Renderer } from "./Renderer";
3
- export { parseConfig, ConfigError } from "./parseConfig";
1
+ export { createRegistry } from "./createRegistry.js";
2
+ export { Renderer } from "./Renderer.js";
3
+ export { parseConfig, ConfigError } from "./parseConfig.js";
@@ -1,4 +1,4 @@
1
- import type { BlockConfig } from "./types";
1
+ import type { BlockConfig } from "./types.js";
2
2
  export declare class ConfigError extends Error {
3
3
  constructor(message: string);
4
4
  }
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;
@@ -49,6 +49,17 @@ export type ConfigFor<R extends Registry<BlocksShape>> = R extends Registry<infe
49
49
  id?: string | number;
50
50
  };
51
51
  }[keyof T]> : never;
52
+ /**
53
+ * The props a given block type's component receives at render time (schema
54
+ * OUTPUT when a schema exists). For building editors and tooling against a
55
+ * registry without importing its components' modules at runtime.
56
+ */
57
+ export type PropsFor<R extends Registry<BlocksShape>, K extends keyof R["blocks"]> = PropsOfDef<R["blocks"][K]>;
58
+ /**
59
+ * The props a given block type accepts when authoring a config (schema INPUT
60
+ * when a schema exists — defaulted fields optional).
61
+ */
62
+ export type InputFor<R extends Registry<BlocksShape>, K extends keyof R["blocks"]> = InputOfDef<R["blocks"][K]>;
52
63
  export type FallbackProps = {
53
64
  reason: "unknown-type";
54
65
  type: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blockprint",
3
- "version": "0.1.1",
3
+ "version": "0.2.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
6
  "homepage": "https://blockprint.terzidest.com",
@@ -17,6 +17,10 @@
17
17
  ".": {
18
18
  "types": "./dist/index.d.ts",
19
19
  "import": "./dist/index.js"
20
+ },
21
+ "./config": {
22
+ "types": "./dist/config.d.ts",
23
+ "import": "./dist/config.js"
20
24
  }
21
25
  },
22
26
  "files": [
@@ -39,14 +43,20 @@
39
43
  "devDependencies": {
40
44
  "@types/react": "^19.2.18",
41
45
  "@types/react-dom": "^19.2.4",
46
+ "jsdom": "^30.1.3",
42
47
  "react": "^19.2.8",
43
48
  "react-dom": "^19.2.8",
44
49
  "typescript": "^7.0.2",
45
50
  "vitest": "^4.1.10",
46
51
  "zod": "^4.4.3"
47
52
  },
53
+ "peerDependenciesMeta": {
54
+ "react": {
55
+ "optional": true
56
+ }
57
+ },
48
58
  "scripts": {
49
- "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",
50
60
  "typecheck": "tsc",
51
61
  "test": "vitest run"
52
62
  }