@nubbin/react 0.1.0-rc.0 → 0.1.0-rc.4

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 ADDED
@@ -0,0 +1,51 @@
1
+ # @nubbin/react
2
+
3
+ The React render path for Nubbin. It walks a compiled artifact, loads the blocks that artifact
4
+ names, resolves the data holes it declares, and renders them.
5
+
6
+ ```bash
7
+ npm install @nubbin/react@rc
8
+ ```
9
+
10
+ ```tsx
11
+ import { defineRegistry, Renderer } from "@nubbin/react";
12
+
13
+ export const registry = defineRegistry({
14
+ Hero: () => import("./blocks/Hero").then((m) => m.Hero),
15
+ });
16
+
17
+ export default async function Page() {
18
+ const artifact = await resolveArtifact(store, slug);
19
+ return <Renderer artifact={artifact} registry={registry} />;
20
+ }
21
+ ```
22
+
23
+ Each registry value is an `import()` the bundler can see, which is what gives one chunk per
24
+ block; a route resolves only the names its artifact carries, so the hundredth block costs pages
25
+ that do not use it nothing. A block author types their component as `BlockComponent<HeroProps>`;
26
+ the registry stores it with its props erased, because function parameters are contravariant and
27
+ a shared props type would be assignable from no real component.
28
+
29
+ `Renderer` is an async server component. It invokes each block and stamps `data-nubbin-node` on
30
+ the element that block returns, which is how the studio maps a node in the tree to a region on
31
+ the page. **A block is therefore a server component returning exactly one root element** — a
32
+ Fragment root or a client reference has no root to stamp, and the renderer throws naming the
33
+ block rather than wrapping it in an element the consumer's layout did not ask for.
34
+
35
+ Slot children arrive as props: `slots.sections` renders to `props.sections`, an array of
36
+ elements the block places itself.
37
+
38
+ ```tsx
39
+ <Renderer
40
+ artifact={artifact}
41
+ registry={registry}
42
+ resolveHole={async ({ block, path, spec }) => fetchLiveValue(block, path, spec)}
43
+ />
44
+ ```
45
+
46
+ A hole is a field a block marked as fetched per request or on an interval, which compile
47
+ deliberately left unfrozen; `spec` is `"request"` or `{ revalidate: n }` — exactly what compile
48
+ wrote. A node with no holes never calls the resolver. A node that declares holes and gets no
49
+ resolver throws naming the node, rather than rendering a compile-time placeholder to a visitor.
50
+
51
+ <https://effekt.github.io/nubbin/>. MIT.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,31 @@
1
- import { FieldHintData, ArtifactNode, UnknownProps } from '@nubbin/core';
1
+ import { UnknownProps, FieldHintData, Artifact } from '@nubbin/core';
2
+ import { ReactNode, ReactElement } from 'react';
3
+
4
+ /**
5
+ * `P` is the block's own props, so a block author has a name for their component —
6
+ * `BlockComponent<HeroProps>`. Async is allowed because rendering happens on the server.
7
+ */
8
+ type BlockComponent<P extends UnknownProps = UnknownProps> = (props: P) => ReactNode | Promise<ReactNode>;
9
+ /**
10
+ * name → lazy importer. A literal map of `import()` calls, so the bundler emits a chunk per block.
11
+ *
12
+ * The stored props type is `never` because parameters are contravariant: a component that reads
13
+ * `title` cannot stand in for one obliged to accept any record, so `BlockComponent<UnknownProps>`
14
+ * here would reject every real block ([#88](https://github.com/effekt/nubbin/issues/88)). The
15
+ * render site widens back with a single cast, because it is what holds the props compile
16
+ * validated against the block's schema.
17
+ */
18
+ type BlockRegistry = Record<string, () => Promise<BlockComponent<never>>>;
19
+
20
+ /**
21
+ * Identity at runtime. The call site's object literal is the point: each value is an `import()`
22
+ * the bundler can see statically, which is what per-block code-splitting rests on.
23
+ *
24
+ * `R` is returned rather than `BlockRegistry` so the map keeps its exact keys where it is
25
+ * written. Indexing the widened form by an arbitrary string is the renderer's problem, and the
26
+ * renderer takes `BlockRegistry` for exactly that reason.
27
+ */
28
+ declare function defineRegistry<R extends BlockRegistry>(registry: R): R;
2
29
 
3
30
  interface HoleContext {
4
31
  route: string;
@@ -16,15 +43,31 @@ interface HoleContext {
16
43
  type HoleResolver = (context: HoleContext) => Promise<unknown>;
17
44
 
18
45
  /**
19
- * The static path is the fast path: no holes means the frozen props object is returned as-is,
20
- * with no clone and no resolver call that absence is asserted, not assumed.
46
+ * Resolves only the named importers, in parallel. The unnamed rest of the registry is never
47
+ * touched that, plus one chunk per importer, is why the hundredth block costs this route
48
+ * nothing.
21
49
  *
22
- * A node declaring holes with no resolver throws naming the node. Rendering a placeholder
23
- * would put a compile-time artefact in front of a visitor with nothing to notice it.
50
+ * Every missing name is reported at once: an artifact compiled against a registry the app has
51
+ * since shrunk needs each name fixed separately, so failing on the first hides the work.
52
+ */
53
+ declare function loadBlocks(registry: BlockRegistry, names: readonly string[]): Promise<Record<string, BlockComponent>>;
54
+
55
+ /**
56
+ * `resolveHole` is written `?: HoleResolver | undefined` rather than `?: HoleResolver` because
57
+ * `exactOptionalPropertyTypes` is on: destructuring an absent optional yields `undefined`, and
58
+ * `Renderer` assigns exactly that into `RenderContext`. Callers that omit it still typecheck.
24
59
  */
25
- declare function resolveNodeHoles(node: ArtifactNode, route: string, resolveHole: HoleResolver | undefined): Promise<UnknownProps>;
60
+ interface RendererProps {
61
+ artifact: Artifact;
62
+ registry: BlockRegistry;
63
+ resolveHole?: HoleResolver | undefined;
64
+ }
26
65
 
27
- /** Copy-on-write down one dotted path. Holes address object fields only; `[]` has no single target. */
28
- declare function setAtPath(target: Record<string, unknown>, path: string, value: unknown): Record<string, unknown>;
66
+ /**
67
+ * An async server component. It reads an already-validated artifact — no schema is parsed
68
+ * here, and nothing the artifact carries is evaluated. `blockVersions` is the whole list of
69
+ * blocks the artifact names, so a registry of any size costs this route only those imports.
70
+ */
71
+ declare function Renderer({ artifact, registry, resolveHole, }: RendererProps): Promise<ReactElement>;
29
72
 
30
- export { type HoleContext, type HoleResolver, resolveNodeHoles, setAtPath };
73
+ export { type BlockComponent, type BlockRegistry, type HoleContext, type HoleResolver, Renderer, type RendererProps, defineRegistry, loadBlocks };
package/dist/index.js CHANGED
@@ -1,3 +1,44 @@
1
+ // src/defineRegistry.ts
2
+ function defineRegistry(registry) {
3
+ return registry;
4
+ }
5
+
6
+ // src/loadBlocks.ts
7
+ async function loadBlocks(registry, names) {
8
+ const missing = names.filter((name) => registry[name] === void 0);
9
+ if (missing.length > 0) {
10
+ throw new Error(`registry has no importer for: ${missing.join(", ")}`);
11
+ }
12
+ const wanted = new Set(names);
13
+ const loaded = await Promise.all(
14
+ Object.entries(registry).filter(([name]) => wanted.has(name)).map(async ([name, importer]) => [name, await importer()])
15
+ );
16
+ return Object.fromEntries(loaded);
17
+ }
18
+
19
+ // src/Renderer.ts
20
+ import { createElement, Fragment as Fragment2 } from "react";
21
+
22
+ // src/invokeBlock.ts
23
+ import { cloneElement, Fragment, isValidElement } from "react";
24
+ async function invokeBlock(component, props, node) {
25
+ const rendered = await component(props);
26
+ if (!isValidElement(rendered) || rendered.type === Fragment) {
27
+ throw new Error(`block "${node.block}" (node ${node.id}) must render exactly one root element`);
28
+ }
29
+ return cloneElement(rendered, { "data-nubbin-node": node.id, key: node.id });
30
+ }
31
+
32
+ // src/renderSlots.ts
33
+ async function renderSlots(slots, renderChild) {
34
+ const rendered = await Promise.all(
35
+ Object.entries(slots ?? {}).map(
36
+ async ([slot, children]) => [slot, await Promise.all(children.map((child) => renderChild(child)))]
37
+ )
38
+ );
39
+ return Object.fromEntries(rendered);
40
+ }
41
+
1
42
  // src/setAtPath.ts
2
43
  function setAtPath(target, path, value) {
3
44
  const [head, ...rest] = path.split(".");
@@ -28,7 +69,31 @@ async function resolveNodeHoles(node, route, resolveHole) {
28
69
  }
29
70
  return props;
30
71
  }
72
+
73
+ // src/renderNode.ts
74
+ async function renderNode(node, context) {
75
+ const component = context.blocks[node.block];
76
+ if (component === void 0) {
77
+ throw new Error(`artifact for ${context.route} names "${node.block}" but it was not loaded`);
78
+ }
79
+ const props = await resolveNodeHoles(node, context.route, context.resolveHole);
80
+ const slotProps = await renderSlots(node.slots, (child) => renderNode(child, context));
81
+ return invokeBlock(component, { ...props, ...slotProps }, node);
82
+ }
83
+
84
+ // src/Renderer.ts
85
+ async function Renderer({
86
+ artifact,
87
+ registry,
88
+ resolveHole
89
+ }) {
90
+ const blocks = await loadBlocks(registry, Object.keys(artifact.blockVersions));
91
+ const context = { route: artifact.route, blocks, resolveHole };
92
+ const children = await Promise.all(artifact.tree.map((node) => renderNode(node, context)));
93
+ return createElement(Fragment2, null, children);
94
+ }
31
95
  export {
32
- resolveNodeHoles,
33
- setAtPath
96
+ Renderer,
97
+ defineRegistry,
98
+ loadBlocks
34
99
  };
package/package.json CHANGED
@@ -1,6 +1,22 @@
1
1
  {
2
2
  "name": "@nubbin/react",
3
- "version": "0.1.0-rc.0",
3
+ "version": "0.1.0-rc.4",
4
+ "description": "The React render path for Nubbin: render a compiled artifact against a block registry.",
5
+ "keywords": [
6
+ "nubbin",
7
+ "react",
8
+ "page-builder",
9
+ "cms",
10
+ "server-components"
11
+ ],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/effekt/nubbin.git",
16
+ "directory": "packages/react"
17
+ },
18
+ "homepage": "https://effekt.github.io/nubbin/",
19
+ "bugs": "https://github.com/effekt/nubbin/issues",
4
20
  "type": "module",
5
21
  "sideEffects": false,
6
22
  "exports": {
@@ -16,12 +32,16 @@
16
32
  "access": "public"
17
33
  },
18
34
  "dependencies": {
19
- "@nubbin/core": "0.1.0-rc.0"
35
+ "@nubbin/core": "0.1.0-rc.4"
20
36
  },
21
37
  "peerDependencies": {
22
38
  "react": ">=19.0.0"
23
39
  },
24
40
  "devDependencies": {
41
+ "@types/react": "19.2.17",
42
+ "@types/react-dom": "19.2.3",
43
+ "react": "19.2.7",
44
+ "react-dom": "19.2.7",
25
45
  "tsup": "8.5.1",
26
46
  "typescript": "6.0.3",
27
47
  "vitest": "4.1.10"