@nubbin/react 0.1.1 → 0.2.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 +1 -1
- package/dist/index.d.ts +291 -27
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -48,4 +48,4 @@ deliberately left unfrozen; `spec` is `"request"` or `{ revalidate: n }` — exa
|
|
|
48
48
|
wrote. A node with no holes never calls the resolver. A node that declares holes and gets no
|
|
49
49
|
resolver throws naming the node, rather than rendering a compile-time placeholder to a visitor.
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
Read the [Nubbin documentation](https://nubbin.io) for the complete renderer reference. MIT.
|
package/dist/index.d.ts
CHANGED
|
@@ -2,71 +2,335 @@ import { UnknownProps, FieldHintData, Artifact } from '@nubbin/core';
|
|
|
2
2
|
import { ReactNode, ReactElement } from 'react';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* The shape a block's component has: props in, markup out. Type a block with it and the render
|
|
6
|
+
* path's expectations are checked where the block is written rather than where it renders.
|
|
7
|
+
*
|
|
8
|
+
* It may be `async`, because a block renders on the server and never in the browser — a block
|
|
9
|
+
* awaiting its own data is the ordinary case, and the renderer awaits what it returns either way.
|
|
10
|
+
*
|
|
11
|
+
* The return type permits any `ReactNode`; the renderer does not. It clones what the block
|
|
12
|
+
* returned to stamp `data-nubbin-node` on it, so the root has to be exactly one HTML element. A
|
|
13
|
+
* Fragment, an array, `null`, a string, or a composite such as `<Card>` is refused at render with
|
|
14
|
+
* `not-one-host-element`, and no type here catches that earlier.
|
|
15
|
+
*
|
|
16
|
+
* @typeParam P - The block's own props. Pass `InferProps<typeof schema>` from `@nubbin/core` so
|
|
17
|
+
* they are derived from the block's schema rather than declared a second time beside it.
|
|
18
|
+
* Defaults to `UnknownProps`, which is what a component is held as once it has come out of a
|
|
19
|
+
* registry and lost its own type.
|
|
20
|
+
*
|
|
21
|
+
* @example A block typed from the schema that validates it
|
|
22
|
+
* ```tsx
|
|
23
|
+
* import type { InferProps } from "@nubbin/core";
|
|
24
|
+
* import type { BlockComponent } from "@nubbin/react";
|
|
25
|
+
* import { z } from "zod";
|
|
26
|
+
*
|
|
27
|
+
* const heroSchema = z.object({ title: z.string(), tone: z.enum(["light", "dark"]) });
|
|
28
|
+
*
|
|
29
|
+
* export const Hero: BlockComponent<InferProps<typeof heroSchema>> = ({ title, tone }) => (
|
|
30
|
+
* <section data-tone={tone}>
|
|
31
|
+
* <h1>{title}</h1>
|
|
32
|
+
* </section>
|
|
33
|
+
* );
|
|
34
|
+
* ```
|
|
7
35
|
*/
|
|
8
36
|
type BlockComponent<P extends UnknownProps = UnknownProps> = (props: P) => ReactNode | Promise<ReactNode>;
|
|
9
37
|
/**
|
|
10
|
-
*
|
|
38
|
+
* A registry as the renderer holds it: block name → a function that imports that block's
|
|
39
|
+
* component. It is the widened form of what `defineRegistry` returns, keyed by `string` because a
|
|
40
|
+
* renderer indexes it by whatever names an artifact carries — which no literal type covers.
|
|
11
41
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
42
|
+
* Annotate a variable with it to hand a registry around; build one with `defineRegistry`, which
|
|
43
|
+
* keeps the literal's own keys where it is written and only widens to this at the render seam.
|
|
44
|
+
*
|
|
45
|
+
* The stored component's props are `never`, which is why a value pulled straight out of a
|
|
46
|
+
* registry cannot be invoked: nothing satisfies `never`. `loadBlocks` is where that is undone, so
|
|
47
|
+
* load through it rather than calling an importer by hand.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```ts
|
|
51
|
+
* import type { BlockRegistry } from "@nubbin/react";
|
|
52
|
+
*
|
|
53
|
+
* const registry: BlockRegistry = {
|
|
54
|
+
* Hero: async () => (await import("./blocks/Hero")).Hero,
|
|
55
|
+
* };
|
|
56
|
+
*
|
|
57
|
+
* registry["Hero"]; // `(() => Promise<BlockComponent<never>>) | undefined`
|
|
58
|
+
* ```
|
|
17
59
|
*/
|
|
18
60
|
type BlockRegistry = Record<string, () => Promise<BlockComponent<never>>>;
|
|
19
61
|
|
|
20
62
|
/**
|
|
21
|
-
*
|
|
22
|
-
* the
|
|
63
|
+
* Declares the registry a render reads: block name → a function importing that block's component.
|
|
64
|
+
* It hands back the object it was given, unchanged and with the same identity, so the value a
|
|
65
|
+
* renderer holds is the call site's own literal — which is what a bundler analyses to emit one
|
|
66
|
+
* chunk per block.
|
|
67
|
+
*
|
|
68
|
+
* The return type is the literal's own, so `registry.Hero` is a known key where it is written and
|
|
69
|
+
* a misspelling is a compile error. Handing it to `Renderer` widens it to {@link BlockRegistry},
|
|
70
|
+
* where the keys are `string`: the renderer indexes it by whatever names an artifact carries, and
|
|
71
|
+
* no literal type covers those.
|
|
23
72
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
73
|
+
* There are no schemas here. A block's schema belongs to the authoring side — `defineBlock` and
|
|
74
|
+
* `defineCatalog` in `@nubbin/core` — and `compile` has already validated every prop against it
|
|
75
|
+
* before an artifact exists. A registry entry is therefore a component and nothing more: what a
|
|
76
|
+
* route pulls in is the block, not the schema that judged its props.
|
|
77
|
+
*
|
|
78
|
+
* @typeParam R - Inferred from the argument. Widen it deliberately by annotating the variable
|
|
79
|
+
* `BlockRegistry`; there is no reason to pass it explicitly.
|
|
80
|
+
* @param registry - Block name → an importer for that block's component. Write it as an object
|
|
81
|
+
* literal of `import()` calls: a specifier assembled at runtime, or a registry spread together
|
|
82
|
+
* from variables, defeats the static analysis the per-block splitting rests on. Each key has to
|
|
83
|
+
* match the `name` its block was registered under, casing included, because that is the name an
|
|
84
|
+
* artifact carries.
|
|
85
|
+
* @returns The same object. Nothing is copied, frozen or checked — a key naming no real block is
|
|
86
|
+
* found when an artifact naming it fails to load, not here.
|
|
87
|
+
*
|
|
88
|
+
* @example A registry the bundler can split
|
|
89
|
+
* ```ts
|
|
90
|
+
* import { defineRegistry } from "@nubbin/react";
|
|
91
|
+
*
|
|
92
|
+
* export const registry = defineRegistry({
|
|
93
|
+
* Hero: async () => (await import("./blocks/Hero")).Hero,
|
|
94
|
+
* Price: async () => (await import("./blocks/Price")).Price,
|
|
95
|
+
* });
|
|
96
|
+
*
|
|
97
|
+
* registry.Hero; // a known key here — `registry.Herro` does not compile
|
|
98
|
+
* ```
|
|
99
|
+
*
|
|
100
|
+
* @example What the renderer holds instead
|
|
101
|
+
* ```ts
|
|
102
|
+
* import type { BlockRegistry } from "@nubbin/react";
|
|
103
|
+
*
|
|
104
|
+
* const widened: BlockRegistry = registry;
|
|
105
|
+
* widened["Anything"]; // `(() => Promise<BlockComponent<never>>) | undefined`
|
|
106
|
+
* ```
|
|
27
107
|
*/
|
|
28
108
|
declare function defineRegistry<R extends BlockRegistry>(registry: R): R;
|
|
29
109
|
|
|
110
|
+
/**
|
|
111
|
+
* What the renderer tells a hole resolver about the field it is asking for. One of these reaches
|
|
112
|
+
* {@link HoleResolver} per hole per render, immediately before the block that reads that field is
|
|
113
|
+
* invoked.
|
|
114
|
+
*
|
|
115
|
+
* @example Route a hole to whichever source owns that field
|
|
116
|
+
* ```ts
|
|
117
|
+
* import type { HoleContext } from "@nubbin/react";
|
|
118
|
+
*
|
|
119
|
+
* const sourceFor = ({ block, path }: HoleContext) =>
|
|
120
|
+
* block === "Price" && path === "amount" ? pricing : cms;
|
|
121
|
+
* ```
|
|
122
|
+
*/
|
|
30
123
|
interface HoleContext {
|
|
124
|
+
/**
|
|
125
|
+
* The route the artifact was compiled for, as `artifact.route` carries it. That is the pattern
|
|
126
|
+
* a `param` or `prefix` page was published at — `/guides/[city]`, not the `/guides/lisbon` a
|
|
127
|
+
* request matched against it.
|
|
128
|
+
*/
|
|
31
129
|
route: string;
|
|
130
|
+
/**
|
|
131
|
+
* The id of the node whose field this is, stable across recompiles of the same document
|
|
132
|
+
* element. It is what the renderer stamps into `data-nubbin-node`, so a value traced in the
|
|
133
|
+
* browser leads back to the resolver call that produced it.
|
|
134
|
+
*/
|
|
32
135
|
nodeId: string;
|
|
136
|
+
/** The block's registered name — the same name the registry is keyed by. */
|
|
33
137
|
block: string;
|
|
138
|
+
/**
|
|
139
|
+
* The field, as the dotted schema path the block's `ui.fields` hint named it: `amount`,
|
|
140
|
+
* `cta.label`. The resolved value is written back at exactly this path.
|
|
141
|
+
*/
|
|
34
142
|
path: string;
|
|
35
143
|
/** `{ revalidate: n }` — exactly what compile wrote into the artifact. */
|
|
36
144
|
spec: FieldHintData;
|
|
37
145
|
}
|
|
38
146
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
147
|
+
* The consumer's answer to "what goes in this field": one async function, called once per hole
|
|
148
|
+
* per render, returning the value that field should hold.
|
|
149
|
+
*
|
|
150
|
+
* Where the value lands is the renderer's business — it is written back at the hole's dotted path
|
|
151
|
+
* before the block is invoked, so the block reads it as an ordinary prop and cannot tell a
|
|
152
|
+
* resolved field from a frozen one. What the value is, and what caching or fetching produces it,
|
|
153
|
+
* is entirely this function's.
|
|
154
|
+
*
|
|
155
|
+
* It is handed the field's spec and never a stored value: `compile` discarded whatever the author
|
|
156
|
+
* had typed into a field it turned into a hole, so there is no placeholder to fall back on. Turning
|
|
157
|
+
* `spec.revalidate` into a framework's caching options is the framework binding's job —
|
|
158
|
+
* `@nubbin/next` ships `holeFetchOptions` for exactly that.
|
|
159
|
+
*
|
|
160
|
+
* @param context - The hole being asked for: route, node, block, dotted path and spec. See
|
|
161
|
+
* {@link HoleContext}.
|
|
162
|
+
* @returns The field's value, in the shape the block's schema described at that path. Nothing
|
|
163
|
+
* re-validates it — the artifact was validated at compile and this value was not there then —
|
|
164
|
+
* so a resolver returning the wrong shape reaches the component unchallenged.
|
|
165
|
+
* @throws Nothing is caught. A rejection propagates out of `Renderer` and fails the render, rather
|
|
166
|
+
* than rendering the node with the field missing.
|
|
167
|
+
*
|
|
168
|
+
* @example Map the field's declared lifecycle onto a caching layer
|
|
169
|
+
* ```ts
|
|
170
|
+
* import type { HoleResolver } from "@nubbin/react";
|
|
171
|
+
*
|
|
172
|
+
* const resolveHole: HoleResolver = async ({ block, path, spec }) => {
|
|
173
|
+
* const response = await fetch(`https://api.example.com/${block}/${path}`, {
|
|
174
|
+
* next: { revalidate: spec.revalidate },
|
|
175
|
+
* });
|
|
176
|
+
* return response.json();
|
|
177
|
+
* };
|
|
178
|
+
* ```
|
|
42
179
|
*/
|
|
43
180
|
type HoleResolver = (context: HoleContext) => Promise<unknown>;
|
|
44
181
|
|
|
45
182
|
/**
|
|
46
|
-
* Resolves
|
|
47
|
-
*
|
|
48
|
-
*
|
|
183
|
+
* Resolves named blocks out of a registry, in parallel, into a map from name to component.
|
|
184
|
+
* `Renderer` calls it with `Object.keys(artifact.blockVersions)` before it walks the tree; call it
|
|
185
|
+
* directly to warm a route, or to check a registry against an artifact ahead of serving one.
|
|
186
|
+
*
|
|
187
|
+
* Only the named importers run — an importer for a name not asked for is never invoked, so a
|
|
188
|
+
* registry naming every block in the app costs a route only the blocks its own page uses.
|
|
189
|
+
*
|
|
190
|
+
* Every name is checked before any importer runs, so a missing one loads nothing at all: the
|
|
191
|
+
* result is the whole set or a refusal, never a partial map. The refusal names every block the
|
|
192
|
+
* registry cannot satisfy, comma-separated in one message.
|
|
49
193
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
194
|
+
* @param registry - Where the importers come from. Keys it holds and `names` does not are neither
|
|
195
|
+
* read nor invoked, so passing the application's whole registry is the intended use.
|
|
196
|
+
* @param names - The blocks to load, matched against the registry's keys exactly, casing
|
|
197
|
+
* included. A name repeated in the list loads its importer once; an empty list resolves to `{}`
|
|
198
|
+
* and invokes nothing.
|
|
199
|
+
* @returns Name → the component that name's importer resolved to, holding exactly the names asked
|
|
200
|
+
* for and no others. The components come back typed `BlockComponent`, widened from the
|
|
201
|
+
* `BlockComponent<never>` a registry stores — sound at this seam because what reaches them at
|
|
202
|
+
* render are props `compile` already validated against that block's schema.
|
|
203
|
+
* @throws {NubbinError} Coded `block-not-loaded` when the registry has no importer for one or more
|
|
204
|
+
* of `names`. It is raised before any importer runs, so nothing has loaded when it lands.
|
|
205
|
+
* @throws Whatever an importer raises. A dynamic `import()` that fails to resolve propagates
|
|
206
|
+
* unchanged, and one importer failing rejects the whole call.
|
|
207
|
+
*
|
|
208
|
+
* @example Load the blocks one artifact needs
|
|
209
|
+
* ```ts
|
|
210
|
+
* import { loadBlocks } from "@nubbin/react";
|
|
211
|
+
*
|
|
212
|
+
* const blocks = await loadBlocks(registry, Object.keys(artifact.blockVersions));
|
|
213
|
+
* const Hero = blocks.Hero;
|
|
214
|
+
* if (Hero !== undefined) {
|
|
215
|
+
* await Hero({ title: "Summer sale" });
|
|
216
|
+
* }
|
|
217
|
+
* ```
|
|
218
|
+
*
|
|
219
|
+
* @example Every missing name in one message
|
|
220
|
+
* ```ts
|
|
221
|
+
* await loadBlocks(registry, ["Ghost", "Hero", "Phantom"]);
|
|
222
|
+
* // NubbinError: registry has no importer for: Ghost, Phantom
|
|
223
|
+
* ```
|
|
52
224
|
*/
|
|
53
225
|
declare function loadBlocks(registry: BlockRegistry, names: readonly string[]): Promise<Record<string, BlockComponent>>;
|
|
54
226
|
|
|
55
227
|
/**
|
|
56
|
-
* `
|
|
57
|
-
*
|
|
58
|
-
*
|
|
228
|
+
* Everything `Renderer` reads. Nothing else on the object is looked at, so a page that carries
|
|
229
|
+
* extra keys through loses nothing by handing the whole thing over.
|
|
230
|
+
*
|
|
231
|
+
* @example
|
|
232
|
+
* ```tsx
|
|
233
|
+
* import { Renderer } from "@nubbin/react";
|
|
234
|
+
* import type { RendererProps } from "@nubbin/react";
|
|
235
|
+
*
|
|
236
|
+
* const props: RendererProps = { artifact, registry, resolveHole };
|
|
237
|
+
* const page = <Renderer {...props} />;
|
|
238
|
+
* ```
|
|
59
239
|
*/
|
|
60
240
|
interface RendererProps {
|
|
241
|
+
/**
|
|
242
|
+
* The compiled page to render, as `compile` produced it and a store handed it back. Its
|
|
243
|
+
* `blockVersions` decides which blocks load, its `tree` is walked in order, and its `route` is
|
|
244
|
+
* what every hole on the page resolves against. It is read, never mutated, and nothing it
|
|
245
|
+
* carries is evaluated.
|
|
246
|
+
*/
|
|
61
247
|
artifact: Artifact;
|
|
248
|
+
/**
|
|
249
|
+
* Where the blocks come from. It may name far more blocks than this artifact uses — the extra
|
|
250
|
+
* importers are never invoked — but a name the artifact carries and this omits refuses the
|
|
251
|
+
* render before any block loads.
|
|
252
|
+
*/
|
|
62
253
|
registry: BlockRegistry;
|
|
254
|
+
/**
|
|
255
|
+
* How a hole gets its value. Any artifact whose tree declares one needs it: omit it and that
|
|
256
|
+
* node refuses rather than rendering without the field. A wholly static artifact renders with no
|
|
257
|
+
* resolver at all, which is the fast path — the frozen props go through untouched.
|
|
258
|
+
*/
|
|
63
259
|
resolveHole?: HoleResolver | undefined;
|
|
64
260
|
}
|
|
65
261
|
|
|
66
262
|
/**
|
|
67
|
-
* An async server component
|
|
68
|
-
*
|
|
69
|
-
*
|
|
263
|
+
* An async server component that renders one compiled artifact against a registry of blocks. It
|
|
264
|
+
* loads the blocks the artifact names, walks `artifact.tree` in order, and returns every root
|
|
265
|
+
* inside a single `Fragment`. It reads nothing and writes nothing — fetching the artifact belongs
|
|
266
|
+
* to the caller.
|
|
267
|
+
*
|
|
268
|
+
* Each node fills its holes, renders its slots, then invokes its block. Holes are filled first
|
|
269
|
+
* because a block reads a resolved field as an ordinary prop, and a value arriving after the call
|
|
270
|
+
* would render as `undefined` with nothing to notice it. Slot children reach the block the same
|
|
271
|
+
* way: the nodes filling `slots.sections` arrive as `props.sections`, an array of elements the
|
|
272
|
+
* block places itself, with no wrapper invented around them.
|
|
273
|
+
*
|
|
274
|
+
* What a block returns is cloned rather than wrapped, so the element the consumer wrote comes
|
|
275
|
+
* back carrying `data-nubbin-node` and nothing is added to the tree. That is what obliges a block
|
|
276
|
+
* to return exactly one HTML element.
|
|
277
|
+
*
|
|
278
|
+
* Sibling nodes render concurrently — the roots of `tree` and the children of one slot are all in
|
|
279
|
+
* flight together — while the holes on a single node resolve one after another, in the order the
|
|
280
|
+
* artifact lists them.
|
|
281
|
+
*
|
|
282
|
+
* @param props - The artifact to render, the registry to render it against, and the optional
|
|
283
|
+
* `resolveHole`; every field is described on {@link RendererProps}. Only those three are read,
|
|
284
|
+
* and none of them is mutated.
|
|
285
|
+
* @returns One `Fragment` holding one element per entry in `artifact.tree`, in that order. A
|
|
286
|
+
* server tree can render the component directly — awaiting it by hand is only needed off the
|
|
287
|
+
* render path, such as in a test.
|
|
288
|
+
*
|
|
289
|
+
* @throws {NubbinError} Coded `block-not-loaded` when the registry has no importer for a block
|
|
290
|
+
* the artifact names, listing every missing name in one message rather than the first. The same
|
|
291
|
+
* code covers the narrower case of a node naming a block absent from `blockVersions`, which
|
|
292
|
+
* names the node.
|
|
293
|
+
* @throws {NubbinError} Coded `no-hole-resolver` when a node declares holes and no `resolveHole`
|
|
294
|
+
* was given, naming the node. Rendering the node without the field would put a compile-time
|
|
295
|
+
* artefact in front of a visitor with nothing to notice it.
|
|
296
|
+
* @throws {NubbinError} Coded `not-one-host-element` when a block returns anything but a single
|
|
297
|
+
* HTML element — a Fragment, an array, `null`, or a composite such as `<Card>`. Cloning a
|
|
298
|
+
* composite root succeeds and sets `data-nubbin-node` as a prop the component never spreads, so
|
|
299
|
+
* the block would render correctly and be unselectable; the renderer refuses instead.
|
|
300
|
+
* @throws Whatever an importer, a `resolveHole` call or a block itself raises, unchanged. None of
|
|
301
|
+
* them is caught, so a failed hole fails the render rather than rendering the node without it.
|
|
302
|
+
*
|
|
303
|
+
* @example Serve whatever is published at a route
|
|
304
|
+
* ```tsx
|
|
305
|
+
* import { Renderer, defineRegistry } from "@nubbin/react";
|
|
306
|
+
* import { notFound } from "next/navigation";
|
|
307
|
+
*
|
|
308
|
+
* const registry = defineRegistry({
|
|
309
|
+
* Hero: async () => (await import("./blocks/Hero")).Hero,
|
|
310
|
+
* Price: async () => (await import("./blocks/Price")).Price,
|
|
311
|
+
* });
|
|
312
|
+
*
|
|
313
|
+
* export default async function Page() {
|
|
314
|
+
* const pointer = await store.pointer("/promotions/summer");
|
|
315
|
+
* const artifact = pointer === null ? null : await store.read(pointer.hash);
|
|
316
|
+
* if (artifact === null) notFound();
|
|
317
|
+
* return <Renderer artifact={artifact} registry={registry} resolveHole={resolveHole} />;
|
|
318
|
+
* }
|
|
319
|
+
* ```
|
|
320
|
+
*
|
|
321
|
+
* @example Render to markup, filling the fields compile left as holes
|
|
322
|
+
* ```tsx
|
|
323
|
+
* import { renderToStaticMarkup } from "react-dom/server";
|
|
324
|
+
*
|
|
325
|
+
* const html = renderToStaticMarkup(
|
|
326
|
+
* await Renderer({
|
|
327
|
+
* artifact,
|
|
328
|
+
* registry,
|
|
329
|
+
* resolveHole: async ({ route, block, path, spec }) =>
|
|
330
|
+
* priceFor(route, block, path, spec.revalidate),
|
|
331
|
+
* }),
|
|
332
|
+
* );
|
|
333
|
+
* ```
|
|
70
334
|
*/
|
|
71
335
|
declare function Renderer({ artifact, registry, resolveHole, }: RendererProps): Promise<ReactElement>;
|
|
72
336
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nubbin/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The React render path for Nubbin: render a compiled artifact against a block registry.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nubbin",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"access": "public"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@nubbin/core": "0.
|
|
35
|
+
"@nubbin/core": "0.2.0"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"react": ">=19.0.0"
|