@nubbin/react 0.1.1 → 0.3.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 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
- <https://effekt.github.io/nubbin/>. MIT.
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
- * `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.
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
- * name lazy importer. A literal map of `import()` calls, so the bundler emits a chunk per block.
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
- * 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.
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
- * 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.
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
- * `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.
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
- * Supplied by the consumer. The renderer decides where a value lands; the resolver decides
40
- * what it is. It receives the spec and never a value the stored placeholder was dropped at
41
- * compile, and mapping a lifecycle onto a caching layer belongs to the framework binding.
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 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.
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
- * 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.
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
- * `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.
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. 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.
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.1.1",
3
+ "version": "0.3.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.1.1"
35
+ "@nubbin/core": "0.3.0"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "react": ">=19.0.0"