@nubbin/react 0.1.0-rc.0 → 0.1.0-rc.5
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 +51 -0
- package/dist/index.d.ts +52 -9
- package/dist/index.js +66 -12
- package/package.json +22 -2
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 {
|
|
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
|
-
*
|
|
20
|
-
*
|
|
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
|
-
*
|
|
23
|
-
*
|
|
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
|
-
|
|
60
|
+
interface RendererProps {
|
|
61
|
+
artifact: Artifact;
|
|
62
|
+
registry: BlockRegistry;
|
|
63
|
+
resolveHole?: HoleResolver | undefined;
|
|
64
|
+
}
|
|
26
65
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
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,
|
|
73
|
+
export { type BlockComponent, type BlockRegistry, type HoleContext, type HoleResolver, Renderer, type RendererProps, defineRegistry, loadBlocks };
|
package/dist/index.js
CHANGED
|
@@ -1,18 +1,48 @@
|
|
|
1
|
-
// src/
|
|
2
|
-
function
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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(", ")}`);
|
|
6
11
|
}
|
|
7
|
-
|
|
8
|
-
|
|
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 } from "react";
|
|
21
|
+
|
|
22
|
+
// src/invokeBlock.ts
|
|
23
|
+
import { cloneElement, isValidElement } from "react";
|
|
24
|
+
async function invokeBlock(component, props, node) {
|
|
25
|
+
const rendered = await component(props);
|
|
26
|
+
if (!isValidElement(rendered) || typeof rendered.type !== "string") {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`block "${node.block}" (node ${node.id}) must render exactly one root HTML element`
|
|
29
|
+
);
|
|
9
30
|
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
31
|
+
return cloneElement(rendered, { "data-nubbin-node": node.id, key: node.id });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// src/renderSlots.ts
|
|
35
|
+
async function renderSlots(slots, renderChild) {
|
|
36
|
+
const rendered = await Promise.all(
|
|
37
|
+
Object.entries(slots ?? {}).map(
|
|
38
|
+
async ([slot, children]) => [slot, await Promise.all(children.map((child) => renderChild(child)))]
|
|
39
|
+
)
|
|
40
|
+
);
|
|
41
|
+
return Object.fromEntries(rendered);
|
|
13
42
|
}
|
|
14
43
|
|
|
15
44
|
// src/resolveNodeHoles.ts
|
|
45
|
+
import { setAtPath } from "@nubbin/core";
|
|
16
46
|
async function resolveNodeHoles(node, route, resolveHole) {
|
|
17
47
|
const holes = Object.entries(node.holes ?? {});
|
|
18
48
|
if (holes.length === 0) {
|
|
@@ -28,7 +58,31 @@ async function resolveNodeHoles(node, route, resolveHole) {
|
|
|
28
58
|
}
|
|
29
59
|
return props;
|
|
30
60
|
}
|
|
61
|
+
|
|
62
|
+
// src/renderNode.ts
|
|
63
|
+
async function renderNode(node, context) {
|
|
64
|
+
const component = context.blocks[node.block];
|
|
65
|
+
if (component === void 0) {
|
|
66
|
+
throw new Error(`artifact for ${context.route} names "${node.block}" but it was not loaded`);
|
|
67
|
+
}
|
|
68
|
+
const props = await resolveNodeHoles(node, context.route, context.resolveHole);
|
|
69
|
+
const slotProps = await renderSlots(node.slots, (child) => renderNode(child, context));
|
|
70
|
+
return invokeBlock(component, { ...props, ...slotProps }, node);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/Renderer.ts
|
|
74
|
+
async function Renderer({
|
|
75
|
+
artifact,
|
|
76
|
+
registry,
|
|
77
|
+
resolveHole
|
|
78
|
+
}) {
|
|
79
|
+
const blocks = await loadBlocks(registry, Object.keys(artifact.blockVersions));
|
|
80
|
+
const context = { route: artifact.route, blocks, resolveHole };
|
|
81
|
+
const children = await Promise.all(artifact.tree.map((node) => renderNode(node, context)));
|
|
82
|
+
return createElement(Fragment, null, children);
|
|
83
|
+
}
|
|
31
84
|
export {
|
|
32
|
-
|
|
33
|
-
|
|
85
|
+
Renderer,
|
|
86
|
+
defineRegistry,
|
|
87
|
+
loadBlocks
|
|
34
88
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nubbin/react",
|
|
3
|
-
"version": "0.1.0-rc.
|
|
3
|
+
"version": "0.1.0-rc.5",
|
|
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.
|
|
35
|
+
"@nubbin/core": "0.1.0-rc.5"
|
|
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"
|