@nubbin/core 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/dist/index.d.ts CHANGED
@@ -1,369 +1,1489 @@
1
1
  import { StandardSchemaV1, StandardJSONSchemaV1 } from '@standard-schema/spec';
2
2
 
3
+ /**
4
+ * What an editing surface renders a field as. `"unknown"` is the honest answer for a JSON
5
+ * Schema node carrying no type an inspector can render — a field the studio shows read-only
6
+ * rather than guessing at.
7
+ *
8
+ * - `"string"`, `"number"`, `"boolean"` — scalars; `integer` reports as `"number"`.
9
+ * - `"enum"` — a closed set, whose members arrive on `FieldNode.members`.
10
+ * - `"array"` — a list; the row shape is a separate `FieldNode` at `path[]`.
11
+ * - `"object"` — a nested shape, whose own fields follow it in the result.
12
+ * - `"union"` — one of several branches; each branch's fields are emitted under this path.
13
+ */
3
14
  type FieldKind = "string" | "number" | "boolean" | "enum" | "array" | "object" | "union" | "unknown";
15
+ /** One addressable field of a schema, in the dotted form a hint key uses. */
4
16
  interface FieldNode {
5
17
  /** Dotted path from the schema root, with `[]` for array members: `cta.label`, `items[].title`. */
6
18
  path: string;
19
+ /** What the field is, and so what an inspector renders for it. */
7
20
  kind: FieldKind;
21
+ /** `true` when the schema does not require the field. An array's row shape is never optional. */
8
22
  optional: boolean;
9
23
  /** Present only for `enum`. */
10
24
  members?: readonly string[];
25
+ /** The schema's own upper bound on a `string` field's length, when it declares one. */
26
+ maxLength?: number;
27
+ /** The schema's own lower bound on an `array` field's row count, when it declares one. */
28
+ minItems?: number;
29
+ /** The schema's own upper bound on an `array` field's row count, when it declares one. */
30
+ maxItems?: number;
11
31
  }
32
+ /** The contract for reading a schema's field structure — what `defineCatalog` resolves hint
33
+ * paths through, and what an editing surface reads a block's fields from. */
12
34
  interface SchemaAdapter {
35
+ /**
36
+ * Describes every path a hint may target.
37
+ *
38
+ * @param schema - The schema to read. What counts as readable is the implementation's to
39
+ * decide; `zodAdapter` accepts anything exposing the Standard JSON Schema converter.
40
+ * @returns One `FieldNode` per addressable path, parent before child. The schema root itself
41
+ * has no path and no entry, so the result is exactly the set of paths a hint may name.
42
+ * @throws {NubbinError} Implementation-defined. `zodAdapter` refuses a schema with no
43
+ * converter as `no-json-schema`; the converter itself throws on a type JSON Schema cannot
44
+ * represent.
45
+ */
13
46
  describe(schema: unknown): FieldNode[];
14
47
  }
15
48
 
16
49
  /**
17
- * Reads a zod schema for the studio and hint resolution, entirely through the Standard JSON
18
- * Schema converter the schema itself carries `core` imports nothing from zod to do it.
19
- * Validation never runs against this projection; it always runs `schema["~standard"].validate()`
20
- * on the real schema.
50
+ * The shipped `SchemaAdapter` what `defineCatalog` resolves hint paths through, exported so an
51
+ * editing surface can describe a block's fields without loading the block's component.
52
+ *
53
+ * It is named for the reference validator but reads any schema exposing the Standard JSON Schema
54
+ * converter, `richText()` included.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * zodAdapter.describe(z.object({ title: z.string(), draft: z.boolean().optional() }));
59
+ * // [
60
+ * // { path: "title", kind: "string", optional: false },
61
+ * // { path: "draft", kind: "boolean", optional: true },
62
+ * // ]
63
+ *
64
+ * zodAdapter.describe(z.object({ items: z.array(z.object({ title: z.string() })) }))
65
+ * .map((field) => field.path);
66
+ * // ["items", "items[]", "items[].title"]
67
+ *
68
+ * zodAdapter.describe(z.object({ tone: z.enum(["light", "dark"]) }));
69
+ * // [{ path: "tone", kind: "enum", optional: false, members: ["light", "dark"] }]
70
+ * ```
21
71
  */
22
72
  declare const zodAdapter: SchemaAdapter;
23
73
 
74
+ /**
75
+ * Props as they stand before validation — what a document node carries and what a document
76
+ * operation writes. Every value is `unknown` until the block's schema has judged it.
77
+ *
78
+ * @example
79
+ * ```ts
80
+ * import type { UnknownProps } from "@nubbin/core";
81
+ *
82
+ * const draft: UnknownProps = { title: "Launch", tone: "light" };
83
+ * ```
84
+ */
24
85
  type UnknownProps = Record<string, unknown>;
25
86
  /**
26
- * A block's props, derived from its schema. The whole of invariant 1 in one type: there is no
27
- * second definition of a block's shape, so nothing can drift from it.
87
+ * The props a block's component receives the output side of its schema, as `validate()`
88
+ * returned it. Type the component with this instead of writing an interface beside the schema:
89
+ * a field a transform reshaped arrives in its transformed form, not as the author typed it.
90
+ *
91
+ * @typeParam Schema - The block's schema. Pass `typeof mySchema`.
92
+ * @example
93
+ * ```ts
94
+ * import type { InferProps } from "@nubbin/core";
95
+ * import { z } from "zod";
96
+ *
97
+ * const heroSchema = z.object({
98
+ * headline: z.string(),
99
+ * tone: z.enum(["light", "dark"]),
100
+ * });
28
101
  *
29
- * `InferOutput` rather than `InferInput` because a component receives what `validate()`
30
- * returned compile freezes the validated value, so a field a transform reshaped reaches the
31
- * component in its output form, not as the author typed it.
102
+ * type HeroProps = InferProps<typeof heroSchema>;
103
+ * // { headline: string; tone: "light" | "dark" }
104
+ *
105
+ * const Hero = ({ headline, tone }: HeroProps) => null;
106
+ * ```
32
107
  */
33
108
  type InferProps<Schema extends StandardSchemaV1> = StandardSchemaV1.InferOutput<Schema>;
109
+ /**
110
+ * What one slot accepts: which blocks may sit in it, and how many. `allow` is checked twice —
111
+ * its entries must name registered blocks, and every child a document puts here must be one of
112
+ * them. The bounds are checked when a document compiles.
113
+ *
114
+ * @example
115
+ * ```ts
116
+ * import type { SlotConstraint } from "@nubbin/core";
117
+ *
118
+ * const items: SlotConstraint = { allow: ["Card"], min: 1, max: 4 };
119
+ * const anything: SlotConstraint = {};
120
+ * ```
121
+ */
34
122
  interface SlotConstraint {
35
- /** Block names permitted here, each resolved at registration. Omitted means any registered block. */
123
+ /**
124
+ * Block names permitted here, each resolved at registration. Omitted means any registered
125
+ * block. An entry naming no registered block fails `createRegistry` rather than silently
126
+ * rejecting every child; a child a listed name does not cover is a compile fault
127
+ * (`slot-not-allowed`).
128
+ */
36
129
  allow?: readonly string[];
130
+ /**
131
+ * Fewest children the slot must hold, checked at compile (`slot-min`). A declared slot the
132
+ * document never mentions counts as holding zero, so a `min` is enforced there too.
133
+ */
37
134
  min?: number;
135
+ /**
136
+ * Most children the slot may hold, checked at compile (`slot-max`). `defineBlock` refuses a
137
+ * `min` above it.
138
+ */
38
139
  max?: number;
39
140
  }
141
+ /**
142
+ * One block as registered: the name documents resolve through, the schema its props are judged
143
+ * against, the component that renders it, and the slots it accepts children in. Build one with
144
+ * `defineBlock` rather than by hand — the object literal alone runs no checks.
145
+ *
146
+ * @typeParam Schema - The block's [Standard Schema](https://standardschema.dev).
147
+ * @typeParam Component - Whatever the consumer's renderer accepts.
148
+ * @example
149
+ * ```ts
150
+ * import type { Block } from "@nubbin/core";
151
+ * import { z } from "zod";
152
+ *
153
+ * const cardSchema = z.object({ title: z.string() });
154
+ *
155
+ * const card: Block<typeof cardSchema, null> = {
156
+ * name: "Card",
157
+ * schema: cardSchema,
158
+ * component: null,
159
+ * version: 2,
160
+ * slots: {},
161
+ * };
162
+ * ```
163
+ */
40
164
  interface Block<Schema extends StandardSchemaV1 = StandardSchemaV1, Component = unknown> {
41
- /** Stable identity, referenced by every node. Renaming it is a migration. */
165
+ /**
166
+ * Stable identity, referenced by every node. Renaming it is a migration. Unique across a
167
+ * registry — two blocks sharing a name are refused.
168
+ */
42
169
  name: string;
170
+ /**
171
+ * One line saying what the block is for, shown wherever an editing surface lists blocks.
172
+ * Editor metadata with the same standing as editing hints: it sits beside the schema, never
173
+ * inside it, and compile never reads it — no artifact carries a description.
174
+ */
175
+ description?: string;
176
+ /**
177
+ * A single glyph — an emoji or short string the consumer chooses — shown beside the name
178
+ * wherever an editing surface lists blocks. A string rather than a component, so `core` stays
179
+ * render-agnostic. Editor metadata like `description`: compile never reads it.
180
+ */
181
+ icon?: string;
182
+ /**
183
+ * The palette section the block files under, wherever an editing surface groups blocks. An
184
+ * opaque label the consumer chooses — Nubbin holds no taxonomy of its own, and a surface may
185
+ * derive a grouping for a block that omits it. Editor metadata like `description`: compile
186
+ * never reads it.
187
+ */
188
+ category?: string;
189
+ /**
190
+ * Opaque links keyed by destination — `docs: { figma: "…", storybook: "…" }` — that an editing
191
+ * surface renders as "Open in {Key}" for the selected block. Nubbin never inspects a URL or
192
+ * knows what is behind it; the consumer supplies them. Compile never reads it.
193
+ */
194
+ docs?: Record<string, string>;
195
+ /**
196
+ * The schema props are validated against, through its own `~standard.validate`. It must answer
197
+ * synchronously; compile refuses a schema that returns a promise.
198
+ */
43
199
  schema: Schema;
44
- /** Generic so core never imports a rendering library. */
200
+ /**
201
+ * What renders this block. `core` neither calls nor inspects it, which is why the component
202
+ * lives in the registry and never in the serializable catalog. `@nubbin/react` narrows it to a
203
+ * component type.
204
+ */
45
205
  component: Component;
46
- /** Bumped when the schema changes incompatibly. */
206
+ /**
207
+ * Bumped when the schema changes incompatibly. An integer of 1 or more, stamped into the
208
+ * `blockVersions` of every artifact whose document uses the block — which is what
209
+ * `checkRollback` compares a stored artifact against.
210
+ */
47
211
  version: number;
212
+ /**
213
+ * Slot constraints keyed by slot name. A slot the document fills but the block does not declare
214
+ * is a compile error (`slot-not-allowed`).
215
+ */
48
216
  slots: Record<string, SlotConstraint>;
49
217
  }
50
218
 
219
+ /**
220
+ * What a page says about itself. `compile` copies it into the artifact unchanged and Nubbin
221
+ * renders none of it — a framework binding decides what becomes a `<title>` or a meta tag.
222
+ */
51
223
  interface DocumentMeta {
224
+ /** The page's title. The one required field: every page is named, in an editor as much as in a tab. */
52
225
  title: string;
226
+ /** The page's description, for a meta tag. */
53
227
  description?: string;
228
+ /** A robots directive, verbatim — `noindex`, `noindex, nofollow`. Absent leaves it to the app's default. */
54
229
  robots?: string;
230
+ /** The page's canonical URL, absolute. `@nubbin/next` puts it where Next reads one. */
55
231
  canonical?: string;
56
232
  }
57
- /** The authoring shape: children are id references, so every editor operation is by id. */
233
+ /**
234
+ * One element of a document: which block renders it, the props an author has typed, and the ids
235
+ * of what sits in each of its slots.
236
+ *
237
+ * @example
238
+ * ```ts
239
+ * const section: Node = {
240
+ * id: crypto.randomUUID(),
241
+ * block: "Hero",
242
+ * props: { title: "Summer promotion", price: 10 },
243
+ * slots: { items: ["card-1", "card-2"] },
244
+ * };
245
+ * ```
246
+ */
58
247
  interface Node {
248
+ /**
249
+ * Unique within the document, and the handle every operation takes. The caller mints it —
250
+ * `core` reaches no `crypto` builtin, and a generator inside these functions would make one
251
+ * composition produce a different document each time, which content addressing cannot tolerate.
252
+ */
59
253
  id: string;
254
+ /** The `name` of a registered block. `compile` refuses a name neither the registry nor the catalog holds. */
60
255
  block: string;
256
+ /**
257
+ * What the author typed, unvalidated. `compile` runs it through the block's schema and keeps
258
+ * what `validate()` returned — a coercion, a transform or a `.default()` reaches the artifact
259
+ * in its parsed form, and a key the schema did not keep is reported as `unknown-prop`.
260
+ */
61
261
  props: UnknownProps;
62
- /** Slot name → ordered child ids. */
262
+ /**
263
+ * Slot name → ordered child ids. Order is the render order. An absent slot holds zero
264
+ * children, which a `min` of 1 or more refuses.
265
+ */
63
266
  slots?: Record<string, readonly string[]>;
64
267
  }
268
+ /**
269
+ * One version of a page's composition — the input to `compile`, and what an editing session
270
+ * reads and rewrites. Flat by design: `elements` is an index keyed by id and a node's slots hold
271
+ * child ids, so every operation addresses a node directly rather than walking a tree to reach it.
272
+ *
273
+ * `setNodeProp`, `addNode`, `removeNode` and `moveNode` each return a new one, copy-on-write,
274
+ * with every untouched node kept by reference. None of them bumps `version`.
275
+ *
276
+ * @example
277
+ * ```ts
278
+ * const version: DocumentVersion = {
279
+ * documentId: "d1",
280
+ * version: 1,
281
+ * roots: ["n1"],
282
+ * elements: {
283
+ * n1: { id: "n1", block: "Hero", props: { title: "T" }, slots: { items: ["n2"] } },
284
+ * n2: { id: "n2", block: "Card", props: { label: "L" } },
285
+ * },
286
+ * meta: { title: "Summer promotion" },
287
+ * createdAt: "2026-01-01T00:00:00Z",
288
+ * createdBy: "studio",
289
+ * };
290
+ * ```
291
+ */
65
292
  interface DocumentVersion {
293
+ /** The page this is a version of, stable across every version of it. Stamped into the artifact. */
66
294
  documentId: string;
295
+ /** Which version of that page. Appending a version belongs to the authoring store, not to one edit. */
67
296
  version: number;
68
297
  /**
69
- * Ordered entry elements — the artifact's tree is these, denormalized, in this order. See
70
- * [A document has many roots](../../../docs/decisions/a-document-has-many-roots.md).
298
+ * Ordered entry elements — the artifact's tree is these, denormalized, in this order. An id
299
+ * with no element behind it is a `dangling-child`, and an empty list is `no-roots`. See
300
+ * [A document has many roots](https://github.com/effekt/nubbin/blob/main/docs/decisions/a-document-has-many-roots.md).
71
301
  */
72
302
  roots: readonly string[];
303
+ /**
304
+ * Every node in the document, keyed by its own `id`. A node no slot and no root reaches is
305
+ * `unreachable`, which `compile` refuses rather than dropping in silence.
306
+ */
73
307
  elements: Record<string, Node>;
308
+ /** What the page says about itself. Copied into the artifact unchanged. */
74
309
  meta: DocumentMeta;
310
+ /** When this version was authored, as an ISO 8601 timestamp. Not carried into the artifact. */
75
311
  createdAt: string;
312
+ /**
313
+ * Who authored it, in whatever identity the consumer's authoring surface uses. Not carried
314
+ * into the artifact either — both fields describe the draft, not the published page.
315
+ */
76
316
  createdBy: string;
77
317
  }
78
318
 
79
319
  /**
80
- * Places a node in a parent's slot: a new `DocumentVersion` with the node registered and the
81
- * slot's order rewritten, copy-on-write, every other node untouched by reference.
320
+ * Places a node in a parent's slot and registers it among the document's elements.
82
321
  *
83
- * `node.id` arrives already minted. `core` runs in a browser, a worker and a build step, and a
84
- * generator inside it would make the same composition produce a different document every time —
85
- * so the caller mints, and the two things this can still check are the parent it names and the
86
- * id it brings.
322
+ * The caller mints `node.id`; `crypto.randomUUID()` in the calling code is the usual source.
87
323
  *
88
- * Slot legality is not one of them. Whether the block belongs in that slot, and whether the slot
89
- * is now over its `max`, is `compile`'s judgment the same division `setNodeProp` follows for a
90
- * prop's validity, and what lets a document be illegal between two edits that end legal.
324
+ * @param version - The document to edit. Read, never written.
325
+ * @param parentId - Id of the node whose slot receives the child. The document must already
326
+ * hold it.
327
+ * @param slot - Slot name on that parent. A slot the parent has never filled is opened.
328
+ * @param node - The node to place. Its `id` must be one the document does not already hold.
329
+ * @param index - Position in the slot's child list as it stands before the insert. Omit to
330
+ * append; an index past the end appends.
331
+ * @returns A new `DocumentVersion` with the node registered and the slot's order rewritten.
332
+ * The argument is not mutated, `version.version` is not bumped, and every node the edit did
333
+ * not touch is carried over by reference.
334
+ * @throws {NubbinError} `no-such-node` when `parentId` names a node the document does not
335
+ * hold; `duplicate-node-id` when `node.id` is already in use, which would replace the node
336
+ * holding it and redirect every slot that named it.
337
+ * @example
338
+ * ```ts
339
+ * const next = addNode(version, "stack", "sections", {
340
+ * id: crypto.randomUUID(),
341
+ * block: "Card",
342
+ * props: { title: "New" },
343
+ * });
91
344
  *
92
- * `index` inserts; its absence appends.
345
+ * // Second in the slot rather than last.
346
+ * const inserted = addNode(version, "stack", "sections", card, 1);
347
+ * ```
93
348
  */
94
349
  declare function addNode(version: DocumentVersion, parentId: string, slot: string, node: Node, index?: number): DocumentVersion;
95
350
 
96
- /** How a field's value resolves at render. Absent means static — the value freezes into props. */
351
+ /**
352
+ * How a field's value resolves at render. Absent means static — the value freezes into props.
353
+ * Present, it is left out of the compiled props and copied into the node's `holes` instead, for
354
+ * the consumer's resolver to fill on each render.
355
+ *
356
+ * @example
357
+ * ```ts
358
+ * import type { FieldHintData } from "@nubbin/core";
359
+ *
360
+ * const hourly: FieldHintData = { revalidate: 3600 };
361
+ * ```
362
+ */
97
363
  type FieldHintData = {
364
+ /** Seconds a resolved value may be reused before the resolver is asked for it again. */
98
365
  revalidate: number;
99
366
  };
100
367
  /**
101
- * Studio treatment for one schema path. Open by design: a consumer may carry keys core does not
102
- * read, and core reads exactly one — how the field resolves.
368
+ * How an editing surface should treat one field of a block's schema. `core` reads `data` and
369
+ * decides nothing else about the field.
370
+ *
371
+ * @example
372
+ * ```ts
373
+ * import type { FieldHint } from "@nubbin/core";
374
+ *
375
+ * const live: FieldHint = { data: { revalidate: 5 } };
376
+ * const frozen: FieldHint = {};
377
+ * ```
103
378
  */
104
379
  interface FieldHint {
380
+ /**
381
+ * Marks the field as resolved at render rather than frozen at compile. Legal only on a path
382
+ * addressing a single value — a path containing `[]`, or one nesting inside another `data`
383
+ * path, fails registration.
384
+ */
105
385
  data?: FieldHintData;
386
+ /**
387
+ * Names the control an editing surface renders for the field — `"link"` for a string holding
388
+ * a destination. Core validates the path and reads nothing else: an unrecognised name falls
389
+ * back to the field's kind, so a hint never breaks an editor that predates it.
390
+ */
391
+ control?: string;
106
392
  }
393
+ /**
394
+ * The editing half of a catalog entry: how the studio should treat this block's fields.
395
+ *
396
+ * @example
397
+ * ```ts
398
+ * import type { BlockUi } from "@nubbin/core";
399
+ *
400
+ * const ui: BlockUi = { fields: { "cta.label": { data: { revalidate: 60 } } } };
401
+ * ```
402
+ */
107
403
  interface BlockUi {
108
- /** Keyed by schema path (`title`, `cta.label`, `items[].icon`). Unresolvable paths fail registration. */
404
+ /**
405
+ * Keyed by schema path (`title`, `cta.label`, `items[].icon`). Unresolvable paths fail
406
+ * registration, read through the schema's own JSON Schema converter rather than a validator.
407
+ */
109
408
  fields?: Record<string, FieldHint>;
110
409
  }
111
- /** Serializable data only — what the studio and CI read. Components live in the registry. */
410
+ /**
411
+ * One block's registration data with no component attached — schema, editing hints and defaults.
412
+ * Serializable data only: what the studio and CI read. Components live in the registry.
413
+ *
414
+ * @example
415
+ * ```ts
416
+ * import type { CatalogEntry } from "@nubbin/core";
417
+ * import { z } from "zod";
418
+ *
419
+ * const hero: CatalogEntry = {
420
+ * schema: z.object({ title: z.string() }),
421
+ * defaults: { title: "Headline" },
422
+ * ui: { fields: { title: {} } },
423
+ * };
424
+ * ```
425
+ */
112
426
  interface CatalogEntry {
427
+ /**
428
+ * The block's schema, the same one its `Block` carries. It is judged at registration rather
429
+ * than by the type: props and defaults run through `~standard.validate`, and hint paths are
430
+ * read through `~standard.jsonSchema`.
431
+ */
113
432
  schema: unknown;
433
+ /**
434
+ * One line saying what the block is for, shown wherever an editing surface lists blocks. The
435
+ * serializable twin of `Block.description`, for a studio that fetches the catalog without the
436
+ * components. Compile never reads it.
437
+ */
438
+ description?: string;
439
+ /**
440
+ * A single glyph shown beside the name wherever an editing surface lists blocks. The
441
+ * serializable twin of `Block.icon` — a string, never a component. Compile never reads it.
442
+ */
443
+ icon?: string;
444
+ /**
445
+ * Opaque links keyed by destination, the serializable twin of `Block.docs`. An editing surface
446
+ * renders each as "Open in {Key}"; the consumer supplies the URLs. Compile never reads it.
447
+ */
448
+ docs?: Record<string, string>;
449
+ /**
450
+ * The palette section the block files under, the serializable twin of `Block.category` — an
451
+ * opaque label the consumer chooses; a surface may derive a grouping for a block that omits
452
+ * it. Compile never reads it.
453
+ */
454
+ category?: string;
455
+ /** Editing hints, keyed by schema path. Omit it and every field is treated as static. */
114
456
  ui?: BlockUi;
457
+ /**
458
+ * What a freshly dropped block renders with. Checked against `schema` at registration, because
459
+ * defaults that fail it produce a block invalid the instant it is placed.
460
+ */
115
461
  defaults?: UnknownProps;
116
462
  }
463
+ /**
464
+ * Every block a project offers, keyed by the block name the registry resolves — the half a studio
465
+ * or a CI step can fetch, since it holds no components. Build one with `defineCatalog`; the object
466
+ * literal alone runs no checks.
467
+ *
468
+ * @example
469
+ * ```ts
470
+ * import type { Catalog } from "@nubbin/core";
471
+ * import { z } from "zod";
472
+ *
473
+ * const catalog: Catalog = {
474
+ * Hero: { schema: z.object({ title: z.string() }) },
475
+ * };
476
+ * ```
477
+ */
117
478
  type Catalog = Record<string, CatalogEntry>;
118
479
 
119
- /** Field path → how the field resolves at render. */
480
+ /**
481
+ * The fields on one node that the renderer resolves rather than reads, keyed by the dotted
482
+ * schema path the block's `ui.fields` hint named. The value carries how the field resolves;
483
+ * where it resolves from is the rendering adapter's business.
484
+ *
485
+ * @example
486
+ * ```ts
487
+ * const holes: Holes = { "cta.label": { revalidate: 60 } };
488
+ * ```
489
+ */
120
490
  type Holes = Record<string, FieldHintData>;
121
- /** Resolved — no lookups, no dangling references possible. */
491
+ /**
492
+ * One node of a compiled tree. Children are the nodes themselves, so a renderer walks the tree
493
+ * without resolving an id against anything.
494
+ *
495
+ * @example
496
+ * ```tsx
497
+ * function render(node: ArtifactNode) {
498
+ * const Block = components[node.block];
499
+ * const children = Object.values(node.slots ?? {}).flat().map(render);
500
+ * return <Block {...node.props}>{children}</Block>;
501
+ * }
502
+ * ```
503
+ */
122
504
  interface ArtifactNode {
505
+ /** The id of the document element this was compiled from — stable across recompiles. */
123
506
  id: string;
507
+ /** The block's registered name. Look it up in the registry to find the component. */
124
508
  block: string;
125
- /** Frozen fields only — literal values. */
509
+ /**
510
+ * The props as compiled: literal values only, already validated against the block's schema.
511
+ * A field listed in `holes` is absent here.
512
+ */
126
513
  props: UnknownProps;
514
+ /** The fields left for the renderer to resolve. Absent when the node has none. */
127
515
  holes?: Holes;
516
+ /**
517
+ * Slot name → the nodes filling it, in order. Absent when the document element declared no
518
+ * slots; a declared slot with nothing in it is an empty array.
519
+ */
128
520
  slots?: Record<string, ArtifactNode[]>;
129
521
  }
130
- /** The compiled result of one document version. Immutable and content-addressed. */
522
+ /**
523
+ * One route's page, compiled and ready to render or store. Nothing mutates it — publishing a
524
+ * change compiles a new artifact and moves the route's pointer at it, so a hash that resolves
525
+ * once resolves to the same bytes forever.
526
+ *
527
+ * @example
528
+ * ```ts
529
+ * const { artifact } = compile(version, catalog, registry, "/pricing");
530
+ * await store.write(artifact);
531
+ * await store.publish(artifact.route, artifact.hash);
532
+ * ```
533
+ */
131
534
  interface Artifact {
132
- /** Content address — the identity. */
535
+ /**
536
+ * The content address, and the name to read it back by. It is computed over every other field
537
+ * with object keys sorted first, so identical content lands at the same address however the
538
+ * compile happened to order it.
539
+ */
133
540
  hash: string;
541
+ /**
542
+ * The route this was compiled for. The CLI refuses to roll a route back to an artifact
543
+ * carrying a different one, however plausible the hash looks.
544
+ */
134
545
  route: string;
546
+ /** The document this is a compilation of. */
135
547
  documentId: string;
548
+ /** Which version of that document — the number on the `DocumentVersion` that was compiled. */
136
549
  documentVersion: number;
137
- /** What this was compiled against — only the blocks the document uses. */
550
+ /**
551
+ * Block name → the version registered when this compiled, for the blocks the document uses and
552
+ * no others. `checkRollback` and `checkCompatibility` compare it against a live registry.
553
+ */
138
554
  blockVersions: Record<string, number>;
555
+ /** One tree per entry element, in the order the document version's `roots` names them. */
139
556
  tree: ArtifactNode[];
557
+ /** The document's metadata, carried through unchanged — the head a renderer emits. */
140
558
  meta: DocumentMeta;
559
+ /** The `@nubbin/core` version that compiled it. */
141
560
  compiledWith: string;
142
561
  }
143
- /** The only mutable state in the output layer — one independently-writable record per route. */
562
+ /**
563
+ * What a route currently serves. This is the one record that changes in place: publishing,
564
+ * rolling back and unpublishing all move or remove a pointer, and touch nothing else.
565
+ *
566
+ * @example
567
+ * ```ts
568
+ * const pointer = await store.pointer("/pricing");
569
+ * const artifact = pointer === null ? null : await store.read(pointer.hash);
570
+ * ```
571
+ */
144
572
  interface RoutePointer {
573
+ /** The route served, exactly as it was published. */
145
574
  route: string;
575
+ /** How a request matches the route — derived from it by `parseMatchKind`, never supplied. */
146
576
  matchKind: "exact" | "param" | "prefix";
147
- /** Artifact currently live at this route. */
577
+ /** The hash of the artifact serving this route right now. */
148
578
  hash: string;
579
+ /** When the pointer last moved, as an ISO-8601 timestamp. */
149
580
  updatedAt: string;
150
581
  }
151
- /** Advisory aggregation over every pointer, for the studio's route list and CI. */
582
+ /**
583
+ * Every route pointer a store holds, gathered in one value — the route list an editing surface
584
+ * or a CI check reads. Derived on each call, so it is a snapshot rather than a stored document
585
+ * anything else depends on.
586
+ *
587
+ * @example
588
+ * ```ts
589
+ * const { routes } = await store.manifest();
590
+ * console.log(routes.map((pointer) => `${pointer.route} -> ${pointer.hash}`).join("\n"));
591
+ * ```
592
+ */
152
593
  interface Manifest {
594
+ /** One pointer per published route, in no guaranteed order. */
153
595
  routes: RoutePointer[];
596
+ /** When this snapshot was taken, as an ISO-8601 timestamp. */
154
597
  generatedAt: string;
155
598
  }
156
- /** The output layer's whole IO surface. Adapters implement it; core only returns values for it. */
599
+ /** One pointer move, recorded by `publish` only published states, so rollback can trust it. */
600
+ interface PointerMove {
601
+ /** What the route was pointed at. */
602
+ hash: string;
603
+ /** The document version that compiled to that hash — what a rollback resolves by. */
604
+ documentVersion: number;
605
+ /** When the pointer moved, as an ISO-8601 timestamp. */
606
+ movedAt: string;
607
+ }
608
+ /**
609
+ * The storage contract behind publishing: implement it over Postgres, S3, or anything else, and
610
+ * every part of Nubbin that publishes, rolls back or checks routes works against it. Two kinds of
611
+ * state sit behind it — artifacts keyed by content hash, written once and never changed, and one
612
+ * pointer per route, which is the only thing that moves.
613
+ *
614
+ * Callers order the two: `write` the artifact, then `publish` the route at its hash. An
615
+ * implementation holds up its half by making absence a value rather than a failure, by taking a
616
+ * repeated `write` as a no-op and a repeated `publish` as an ordinary one so a retried publish
617
+ * succeeds, and by writing each pointer whole — two publishes racing for one route must leave one
618
+ * of them intact, never a blend. `@nubbin/store-fs` is the reference implementation, and
619
+ * `runArtifactStoreContract` from `@nubbin/store-fs/testing` is the suite every implementation is
620
+ * expected to pass — call it with a factory for your store and the guarantees above are executed
621
+ * rather than read. It needs vitest, which the package declares as an optional peer.
622
+ *
623
+ * @example
624
+ * ```ts
625
+ * import { parseMatchKind } from "@nubbin/core";
626
+ * import type { ArtifactStore } from "@nubbin/core";
627
+ *
628
+ * const store: ArtifactStore = {
629
+ * read: async (hash) => (await db.artifact(hash)) ?? null,
630
+ * write: async (artifact) => { await db.putArtifactIfAbsent(artifact.hash, artifact); },
631
+ * manifest: async () => ({ routes: await db.pointers(), generatedAt: new Date().toISOString() }),
632
+ * pointer: async (route) => (await db.pointer(route)) ?? null,
633
+ * publish: async (route, hash) => { await db.movePointer(route, hash, parseMatchKind(route)); },
634
+ * unpublish: async (route) => { await db.deletePointer(route); },
635
+ * };
636
+ * ```
637
+ */
157
638
  interface ArtifactStore {
639
+ /**
640
+ * Reads one artifact by its content hash.
641
+ *
642
+ * @param hash - The `hash` of an artifact already written.
643
+ * @returns The artifact as written, or `null` when the store holds nothing at that hash.
644
+ * Absence is a value here — an unknown hash never throws.
645
+ * @example
646
+ * ```ts
647
+ * const artifact = await store.read("4a162726");
648
+ * ```
649
+ */
158
650
  read(hash: string): Promise<Artifact | null>;
651
+ /**
652
+ * Stores one artifact under its own hash. Write before publishing the route at it.
653
+ *
654
+ * @param artifact - A compiled artifact. `artifact.hash` is the key; nothing else is read.
655
+ * @returns Nothing. Writing a hash the store already holds is a no-op, so a publish retried
656
+ * after a timeout succeeds on its second attempt.
657
+ * @throws Whatever the underlying storage raises when the write itself fails.
658
+ * @example
659
+ * ```ts
660
+ * await store.write(compile(version, catalog, registry, "/pricing").artifact);
661
+ * ```
662
+ */
159
663
  write(artifact: Artifact): Promise<void>;
664
+ /**
665
+ * Lists every published route.
666
+ *
667
+ * @returns A snapshot of every pointer the store holds, with the time it was taken. An empty
668
+ * `routes` is a store with nothing published, not a failure.
669
+ * @example
670
+ * ```ts
671
+ * const { routes } = await store.manifest();
672
+ * ```
673
+ */
160
674
  manifest(): Promise<Manifest>;
675
+ /**
676
+ * Reads the pointer for one route.
677
+ *
678
+ * @param route - The route as it was published, matched exactly — `/pricing` does not find
679
+ * `/pricing/`, and a `param` or `prefix` pointer is found by its pattern, not by a path it
680
+ * would match.
681
+ * @returns The pointer, or `null` when nothing is published at that route.
682
+ * @example
683
+ * ```ts
684
+ * const pointer = await store.pointer("/guides/[city]");
685
+ * ```
686
+ */
161
687
  pointer(route: string): Promise<RoutePointer | null>;
688
+ /**
689
+ * Points a route at an artifact already in the store — the publish, and the rollback. The
690
+ * implementation derives `matchKind` with `parseMatchKind` and stamps `updatedAt` itself.
691
+ *
692
+ * @param route - The route to serve. It is validated on the way through `parseMatchKind`.
693
+ * @param hash - The artifact to serve there. It has to be written first.
694
+ * @returns Nothing. Publishing the same route and hash twice succeeds and leaves the route
695
+ * where it already pointed, which is what makes a retry safe — it is not a no-op. The pointer
696
+ * is rewritten and a store keeping history records a second move, since what is deduplicated
697
+ * is the artifact rather than the act of publishing.
698
+ * @throws NubbinError with `code` `NubbinIssueCode.InvalidRoute` when the route is not
699
+ * addressable, from `parseMatchKind`. An implementation also rejects a hash it holds no
700
+ * artifact for, so no pointer can name one that was never written — `@nubbin/store-fs` refuses
701
+ * that with `NubbinIssueCode.ArtifactNotStored`.
702
+ * @example
703
+ * ```ts
704
+ * await store.write(artifact);
705
+ * await store.publish("/pricing", artifact.hash);
706
+ * ```
707
+ */
162
708
  publish(route: string, hash: string): Promise<void>;
709
+ /**
710
+ * Takes a route offline by removing its pointer. The artifact stays, so republishing it is
711
+ * another `publish` at the same hash.
712
+ *
713
+ * @param route - The route to stop serving.
714
+ * @returns Nothing. Unpublishing a route that has no pointer is a no-op.
715
+ * @example
716
+ * ```ts
717
+ * await store.unpublish("/pricing");
718
+ * ```
719
+ */
163
720
  unpublish(route: string): Promise<void>;
721
+ /**
722
+ * Every move `publish` made at this route, oldest first, surviving `unpublish`. Optional
723
+ * because a write-only blob store is still a valid adapter — a caller degrades with a
724
+ * message rather than requiring it.
725
+ *
726
+ * @param route - The route whose moves are read.
727
+ * @returns One entry per publish, oldest first, and an empty array for a route that has never
728
+ * been published. A store that keeps no history omits the method rather than returning `[]`,
729
+ * so a caller can tell "never published" from "not recorded".
730
+ * @example
731
+ * ```ts
732
+ * const moves = (await store.history?.("/pricing")) ?? [];
733
+ * moves.at(-1)?.hash; // what it points at now
734
+ * ```
735
+ */
736
+ history?(route: string): Promise<PointerMove[]>;
164
737
  }
165
738
 
166
- /** One route pointer and the artifact it names, as an adapter read them. */
739
+ /**
740
+ * One live route, ready for `checkCompatibility`: a pointer and whatever the store returned for
741
+ * its hash. Build one per pointer in the manifest.
742
+ *
743
+ * @example
744
+ * ```ts
745
+ * import type { LiveRoute } from "@nubbin/core";
746
+ *
747
+ * const live: LiveRoute[] = await Promise.all(
748
+ * routes.map(async (pointer) => ({ pointer, artifact: await store.read(pointer.hash) })),
749
+ * );
750
+ * ```
751
+ */
167
752
  interface LiveRoute {
753
+ /** The pointer as the store holds it — `route` and `hash` name the failure in the report. */
168
754
  pointer: RoutePointer;
169
- /** `null` when the store holds no artifact at the pointer's hash — a pointer into nothing. */
755
+ /**
756
+ * What `store.read(pointer.hash)` returned. Pass the `null` through rather than skipping the
757
+ * route: an unresolvable hash is reported as its own kind of breakage.
758
+ */
170
759
  artifact: Artifact | null;
171
760
  }
172
- /** One block's version delta between what a live artifact needs and what is registered now. */
761
+ /**
762
+ * One block's version delta, as it appears under a `block-drift` incompatibility. Enough to act
763
+ * on without reading the artifact: the name, what the page needs, and what the registry holds.
764
+ *
765
+ * @example
766
+ * ```ts
767
+ * import type { BlockDrift } from "@nubbin/core";
768
+ *
769
+ * const drift: BlockDrift = { block: "Hero", live: 1, registered: 2 };
770
+ * ```
771
+ */
173
772
  interface BlockDrift {
773
+ /** The block's registered name, as `blockVersions` keys it. */
174
774
  block: string;
175
- /** The version the artifact was compiled against. */
775
+ /** The version the live page needs — restore this to make the route renderable again. */
176
776
  live: number;
177
- /** The version registered now `null` when the registry no longer holds the block at all. */
777
+ /** The version the registry holds now, or `null` when the block is no longer registered. */
178
778
  registered: number | null;
179
779
  }
180
780
  /**
181
- * Why one live route would not render. `unreadable-artifact` is a pointer whose hash the store
182
- * cannot resolve, which breaks the route without any registry change at all.
781
+ * One route that would not render, and why. Discriminate on `reason`: `drifted` exists only on
782
+ * the `block-drift` branch, since an unresolvable hash has no versions to compare.
783
+ *
784
+ * @example
785
+ * ```ts
786
+ * for (const failure of report.incompatible) {
787
+ * if (failure.reason === "unreadable-artifact") {
788
+ * console.error(`${failure.route}: nothing stored at ${failure.hash}`);
789
+ * } else {
790
+ * console.error(`${failure.route}: ${failure.drifted.map((d) => d.block).join(", ")}`);
791
+ * }
792
+ * }
793
+ * ```
183
794
  */
184
795
  type RouteIncompatibility = {
796
+ /** The route whose pointer was checked. */
185
797
  route: string;
798
+ /** The hash that pointer names. */
186
799
  hash: string;
800
+ /** The store returned nothing for the hash, so the route is broken with no drift involved. */
187
801
  reason: "unreadable-artifact";
188
802
  } | {
803
+ /** The route whose pointer was checked. */
189
804
  route: string;
805
+ /** The hash that pointer names — what would render, if its blocks still matched. */
190
806
  hash: string;
807
+ /** The artifact read cleanly, and at least one block it needs has moved. */
191
808
  reason: "block-drift";
809
+ /** Every moved block, one entry each. Blocks that still match are not listed. */
192
810
  drifted: BlockDrift[];
193
811
  };
194
- /** The verdict over every live pointer. `checked` is reported so a run over nothing cannot read as a pass. */
812
+ /**
813
+ * What `checkCompatibility` returns over a whole set of live routes. Assert on `checked` before
814
+ * trusting `compatible` — a run that read no pointers is compatible with everything.
815
+ *
816
+ * @example
817
+ * ```ts
818
+ * const report = checkCompatibility(live, registry);
819
+ * if (report.checked === 0) throw new Error("no live pointers read — this gate checked nothing");
820
+ * process.exitCode = report.compatible ? 0 : 1;
821
+ * ```
822
+ */
195
823
  interface CompatibilityReport {
824
+ /** How many live routes were examined — the length of the `live` array passed in. */
196
825
  checked: number;
826
+ /** `true` when `incompatible` is empty, including when nothing was checked. */
197
827
  compatible: boolean;
828
+ /** One entry per route that would not render, in the order the routes were passed in. */
198
829
  incompatible: RouteIncompatibility[];
199
830
  }
200
831
 
832
+ /**
833
+ * The resolved block set — what compile and a renderer ask when a node names a block. Build one
834
+ * with `createRegistry`; it holds a snapshot of the array it was given, so adding a block means
835
+ * building a new registry rather than writing into this one.
836
+ *
837
+ * @example
838
+ * ```ts
839
+ * import { createRegistry, defineBlock } from "@nubbin/core";
840
+ * import type { Registry } from "@nubbin/core";
841
+ * import { z } from "zod";
842
+ *
843
+ * const registry: Registry = createRegistry([
844
+ * defineBlock({
845
+ * name: "Hero",
846
+ * schema: z.object({ title: z.string() }),
847
+ * component: null,
848
+ * version: 1,
849
+ * slots: {},
850
+ * }),
851
+ * ]);
852
+ *
853
+ * const hero = registry.get("Hero");
854
+ * if (hero !== undefined) {
855
+ * hero.version; // 1
856
+ * }
857
+ * ```
858
+ */
201
859
  interface Registry {
860
+ /**
861
+ * Resolves one block by the name a document node carries.
862
+ *
863
+ * @param name - The block name, matched exactly — casing included.
864
+ * @returns The registered block, or `undefined` when nothing carries that name. An unknown name
865
+ * is a value to handle here; compile is where it becomes an `unknown-block` fault.
866
+ */
202
867
  get(name: string): Block | undefined;
868
+ /**
869
+ * Lists what the registry holds — the names a palette or a compatibility check enumerates.
870
+ *
871
+ * @returns Every registered name, in the order the blocks were given to `createRegistry`. A
872
+ * fresh array each call, so writing to it changes nothing.
873
+ */
203
874
  names(): string[];
204
875
  }
205
876
 
206
877
  /**
207
- * The guardrail's whole question: would this registry fail to render an artifact a live route
208
- * pointer currently references? `checkRollback` decides each route one artifact against the
209
- * registry is the same comparison whether a pointer is moving or a registry is and this walks
210
- * every pointer the caller read, so publishing and merging are held to one rule.
878
+ * Runs the rollback comparison over every live route at once, and reports each failure with the
879
+ * version delta behind it: which route, which artifact, which block, what the page needs, what is
880
+ * registered. Point a CI job at it before merging a registry change to learn which pages the
881
+ * change would break.
882
+ *
883
+ * @param live - Every pointer worth checking, each paired with the artifact its hash resolves to.
884
+ * Read them from an `ArtifactStore` — a pointer the store cannot resolve is passed as `null`
885
+ * rather than dropped, because that route is already broken. Nothing here reads the store itself,
886
+ * so a caller whose live state sits somewhere else can build the pairs by hand.
887
+ * @param registry - The registry to judge them against, from `createRegistry`.
888
+ * @returns A report over the whole set: how many pointers were examined, whether all of them
889
+ * cleared, and one entry per route that did not. A block the registry gained since publish is
890
+ * not drift, so an added block never appears.
891
+ * @example
892
+ * ```ts
893
+ * import { checkCompatibility, formatCompatibilityReport } from "@nubbin/core";
894
+ * import type { LiveRoute } from "@nubbin/core";
211
895
  *
212
- * Pure, and synchronous: the caller reads the store and hands over what it found, so this runs
213
- * in CI, in a worker, or in a browser studio unchanged.
896
+ * const { routes } = await store.manifest();
897
+ * const live: LiveRoute[] = await Promise.all(
898
+ * routes.map(async (pointer) => ({ pointer, artifact: await store.read(pointer.hash) })),
899
+ * );
900
+ *
901
+ * const report = checkCompatibility(live, registry);
902
+ * console.log(formatCompatibilityReport(report));
903
+ * if (!report.compatible) process.exitCode = 1;
904
+ * ```
214
905
  */
215
906
  declare function checkCompatibility(live: readonly LiveRoute[], registry: Registry): CompatibilityReport;
216
907
 
908
+ /**
909
+ * What `checkRollback` reports about one artifact against one registry. Discriminate on
910
+ * `compatible` — the drifted names exist only on the failing branch, so a check narrows before
911
+ * anything can read them.
912
+ *
913
+ * @example
914
+ * ```ts
915
+ * import { checkRollback } from "@nubbin/core";
916
+ * import type { RollbackCheck } from "@nubbin/core";
917
+ *
918
+ * const verdict: RollbackCheck = checkRollback(artifact, registry);
919
+ * if (!verdict.compatible) {
920
+ * console.error(`refusing the rollback: ${verdict.drifted.join(", ")} moved since compile`);
921
+ * }
922
+ * ```
923
+ */
217
924
  type RollbackCheck = {
925
+ /** Every block the artifact names is registered at the version it was compiled against. */
218
926
  compatible: true;
219
927
  } | {
928
+ /** At least one block the artifact names is registered at another version, or not at all. */
220
929
  compatible: false;
930
+ /**
931
+ * The names of the drifted blocks, in the order the artifact's `blockVersions` lists them.
932
+ * A block the registry no longer holds is named here too — deletion is drift.
933
+ */
221
934
  drifted: string[];
222
935
  };
223
936
 
224
937
  /**
225
- * Compares what the artifact was compiled against with the registry live now. A name the
226
- * registry no longer holds is drift, not an absent check a deleted block is exactly the
227
- * failure a rollback must be warned about.
938
+ * Asks whether one stored artifact would still render against a registry the question to
939
+ * settle before pointing a route back at it. Every name in the artifact's `blockVersions` has to
940
+ * be registered at the version recorded there; a different version, or no entry at all, is drift.
941
+ *
942
+ * @param artifact - The rollback target, as read back from the store. Only `blockVersions` is
943
+ * read, so an artifact hand-built for a test needs nothing else to be accurate.
944
+ * @param registry - The registry the running code holds now, from `createRegistry`.
945
+ * @returns `{ compatible: true }`, or `{ compatible: false, drifted }` naming every block that
946
+ * moved. Drift is a value, not a throw: whether it stops the rollback is the caller's call.
947
+ * @example
948
+ * ```ts
949
+ * import { checkRollback, createRegistry, defineBlock } from "@nubbin/core";
950
+ * import { z } from "zod";
951
+ *
952
+ * const heroAtV2 = defineBlock({
953
+ * name: "Hero",
954
+ * schema: z.object({ title: z.string() }),
955
+ * component: null,
956
+ * version: 2,
957
+ * slots: {},
958
+ * });
959
+ *
960
+ * // artifact.blockVersions is { Hero: 1 }
961
+ * checkRollback(artifact, createRegistry([heroAtV2]));
962
+ * // { compatible: false, drifted: ["Hero"] }
963
+ * ```
228
964
  */
229
965
  declare function checkRollback(artifact: Artifact, registry: Registry): RollbackCheck;
230
966
 
231
967
  /**
232
- * Every reason Nubbin refuses something, as a value a consumer can key off rather than a string
233
- * they have to match. Each member's value is its own name in kebab-case, so a serialized issue
234
- * reads the same in a log as it does in code.
968
+ * Every reason Nubbin refuses something, as a value to branch on rather than a string to match.
969
+ * Compare it against `NubbinIssue.code` or `NubbinError.code`; a typo in a member name is a
970
+ * compile error, where a typo in a string literal is a branch that never runs.
235
971
  *
236
- * A const object rather than a TypeScript `enum`: it yields the same two things a value to
237
- * branch on and a type to check against without the runtime object an `enum` emits, and `core`
238
- * has to run unchanged in a browser, a worker and a build step.
972
+ * Each member's value is its own name in kebab-case, so a serialized issue reads the same in a
973
+ * log as it does in code. Scripts and editors match on these names, so every member below says
974
+ * what raises it and what has to change to satisfy it.
239
975
  *
240
- * Closed on purpose. A new refusal is a new member here, which is what makes the reference page
241
- * listing them checkable against this file.
976
+ * @example
977
+ * ```ts
978
+ * import { NubbinError, NubbinIssueCode } from "@nubbin/core";
979
+ *
980
+ * try {
981
+ * addNode(version, "section-1", "items", node);
982
+ * } catch (error) {
983
+ * if (error instanceof NubbinError && error.code === NubbinIssueCode.SlotMax) {
984
+ * toast(`That slot is full: ${error.issues[0]?.at}`);
985
+ * }
986
+ * }
987
+ * ```
242
988
  */
243
989
  declare const NubbinIssueCode: {
990
+ /** `defineBlock` was given a `version` that is not an integer of 1 or more. */
244
991
  readonly BlockVersion: "block-version";
992
+ /** `defineBlock` was given a slot whose `min` is above its `max`, which no composition satisfies. */
245
993
  readonly SlotBounds: "slot-bounds";
994
+ /** `createRegistry` found a slot's `allow` naming a block no registered block answers to. */
246
995
  readonly SlotAllowUnknown: "slot-allow-unknown";
996
+ /** `createRegistry` was given two blocks claiming one name — the identity every node resolves through. */
247
997
  readonly DuplicateBlockName: "duplicate-block-name";
998
+ /** `defineCatalog` found an entry's `defaults` do not satisfy that entry's own schema. */
248
999
  readonly InvalidDefaults: "invalid-defaults";
1000
+ /** `defineCatalog` found `ui.fields` naming a path the schema does not define. Check the spelling against the schema. */
249
1001
  readonly HintPathUnresolvable: "hint-path-unresolvable";
1002
+ /**
1003
+ * `defineCatalog` found a `data` hint with no single target: a path through `[]`, which names
1004
+ * every member of an array, or two hints whose paths nest and would write one value twice.
1005
+ */
250
1006
  readonly HintNotAddressable: "hint-not-addressable";
1007
+ /**
1008
+ * A schema exposes no `~standard.validate`, or answers with a promise. Validation is
1009
+ * synchronous at registration and at compile, so an async validator is refused rather than
1010
+ * awaited.
1011
+ */
251
1012
  readonly NotStandardSchema: "not-standard-schema";
1013
+ /**
1014
+ * A schema exposes no Standard JSON Schema converter, which field introspection needs — the
1015
+ * studio reads a block's fields through it.
1016
+ */
252
1017
  readonly NoJsonSchema: "no-json-schema";
1018
+ /** The document's `roots` is empty, so it names no entry element and there is no tree to build. */
253
1019
  readonly NoRoots: "no-roots";
1020
+ /**
1021
+ * A node names a block neither the registry nor the catalog holds. Register the block, or
1022
+ * correct the node's `block`.
1023
+ */
254
1024
  readonly UnknownBlock: "unknown-block";
1025
+ /** A slot or a `roots` entry references an id that `elements` does not hold. */
255
1026
  readonly DanglingChild: "dangling-child";
1027
+ /** A node reaches back to one of its own ancestors, so the graph cannot flatten into a tree. */
256
1028
  readonly Cycle: "cycle";
1029
+ /** No slot reaches the node from any root, so it would be dropped silently on compile. */
257
1030
  readonly Unreachable: "unreachable";
1031
+ /** A slot the block never declared, or a child the slot's `allow` list rejects. */
258
1032
  readonly SlotNotAllowed: "slot-not-allowed";
1033
+ /** A slot holds fewer children than its `min`. An omitted slot holds zero. */
259
1034
  readonly SlotMin: "slot-min";
1035
+ /** A slot holds more children than its `max`. */
260
1036
  readonly SlotMax: "slot-max";
1037
+ /**
1038
+ * A node's props failed its schema, or parsed to something other than an object. `path` names
1039
+ * the offending field.
1040
+ */
261
1041
  readonly InvalidProps: "invalid-props";
1042
+ /**
1043
+ * A key the author wrote and the schema did not keep — almost always a typo, `heading` where
1044
+ * the schema says `headline`. **Returned in `CompileResult.issues`, never thrown:** the
1045
+ * artifact is valid and publishable without that key.
1046
+ */
262
1047
  readonly UnknownProp: "unknown-prop";
1048
+ /** `setNodeProp`, `addNode`, `removeNode` or `moveNode` named a node id no element backs. */
263
1049
  readonly NoSuchNode: "no-such-node";
1050
+ /**
1051
+ * `addNode` was given an id the document already uses. Reusing one would replace a node and
1052
+ * redirect every slot that named it.
1053
+ */
264
1054
  readonly DuplicateNodeId: "duplicate-node-id";
1055
+ /**
1056
+ * A prop path with no single target: an empty segment, an `[]`, or a descent into an array.
1057
+ * Raised by `setAtPath` and by `setNodeProp`.
1058
+ */
265
1059
  readonly PathNotAddressable: "path-not-addressable";
1060
+ /**
1061
+ * A route no request could match — no leading slash, a trailing slash, an empty or malformed
1062
+ * segment, or a `*` that is not the last one. Raised by `compile` and by `parseMatchKind`.
1063
+ */
266
1064
  readonly InvalidRoute: "invalid-route";
1065
+ /** `@nubbin/react`: the block registry has no importer for a block the artifact names. */
267
1066
  readonly BlockNotLoaded: "block-not-loaded";
1067
+ /** `@nubbin/react`: a node declares holes and the render was given no `resolveHole`. */
268
1068
  readonly NoHoleResolver: "no-hole-resolver";
1069
+ /** `@nubbin/react`: a block returned a Fragment, a composite, or several roots where one host element is required. */
269
1070
  readonly NotOneHostElement: "not-one-host-element";
1071
+ /** A publish names a hash the store does not hold. Write the artifact before pointing a route at it. */
270
1072
  readonly ArtifactNotStored: "artifact-not-stored";
271
1073
  };
272
1074
  /** The value of any member, for a consumer narrowing on `issue.code`. */
273
1075
  type NubbinIssueCode = (typeof NubbinIssueCode)[keyof typeof NubbinIssueCode];
274
1076
 
275
1077
  /**
276
- * One reason Nubbin refused something. The shape every refusal takes, whether it was thrown or
277
- * returned, so a consumer writes one handler and serializes one thing.
1078
+ * One reason Nubbin refused something. The shape every refusal takes, whether it arrives thrown
1079
+ * inside a `NubbinError` or returned in `CompileResult.issues`, so a consumer writes one handler
1080
+ * and serializes one thing.
278
1081
  *
279
- * `code` is for branching and `message` is for reading the prose says what a person needs and
280
- * the code says what a program can act on, and neither is asked to do the other's job.
1082
+ * It is plain JSON: safe to log, store, or send across a wire without reshaping.
281
1083
  *
282
- * `at` and `path` are two coordinates rather than one string because an editing surface needs
283
- * both: `at` names the thing to select — a node, a block, a route — and `path` names where
284
- * inside it to highlight. Joining them into prose would leave a studio parsing its own error
285
- * messages to find the field an author has to fix.
1084
+ * @example Turn an issue into an editor selection
1085
+ * ```ts
1086
+ * function reveal(issue: NubbinIssue): void {
1087
+ * if (issue.at !== undefined) editor.select(issue.at);
1088
+ * if (issue.path !== undefined) editor.highlightField(issue.path);
1089
+ * editor.explain(issue.message);
1090
+ * }
1091
+ * ```
286
1092
  */
287
1093
  interface NubbinIssue {
1094
+ /**
1095
+ * Which refusal this is. Branch on it against `NubbinIssueCode` — never on `message`, which is
1096
+ * prose and is reworded whenever a clearer wording is found.
1097
+ */
288
1098
  code: NubbinIssueCode;
1099
+ /** Prose for a person, naming the specific value or id at fault. Not a stable contract. */
289
1100
  message: string;
290
- /** What it concerns: a node id, a block name, or a route. */
1101
+ /** What it concerns: a node id, a block name, or a route. Absent when nothing names it. */
291
1102
  at?: string;
292
- /** Where within that: a dotted prop path, `slots.items`, or `block`. */
1103
+ /**
1104
+ * Where within that: a dotted prop path, `slots.items`, or `block`. Absent when the whole
1105
+ * subject is at fault rather than one place inside it.
1106
+ */
293
1107
  path?: string;
294
1108
  }
295
1109
 
296
1110
  /**
297
- * What `compile` produces: the artifact, and everything it has to say about the document that
298
- * did not stop it producing one.
1111
+ * What `compile` returns whenever it produced an artifact. Receiving one of these means the
1112
+ * document compiled; a document that did not reaches the caller as a thrown `NubbinError`
1113
+ * instead.
1114
+ *
1115
+ * @example
1116
+ * ```ts
1117
+ * const { artifact, issues } = compile(version, catalog, registry, "/pricing");
1118
+ *
1119
+ * await store.write(artifact);
1120
+ * await store.publish(artifact.route, artifact.hash);
299
1121
  *
300
- * The two travel together because the split is not severity, it is whether an artifact exists.
301
- * A document the schema refuses has none, and `compile` throws; a document carrying a key the
302
- * schema did not declare has a perfectly good one, and what became of that key is the consumer's
303
- * to log, ship, or ignore.
1122
+ * for (const issue of issues) {
1123
+ * logger.warn({ code: issue.code, at: issue.at, path: issue.path, message: issue.message });
1124
+ * }
1125
+ * ```
304
1126
  */
305
1127
  interface CompileResult {
1128
+ /**
1129
+ * The compiled document: immutable, addressed by its own `hash`, and ready to hand to a store.
1130
+ * `tree` holds one denormalized tree per entry in the document's `roots`, in that order.
1131
+ */
306
1132
  artifact: Artifact;
1133
+ /**
1134
+ * Everything `compile` has to say about the document that did not stop it producing one. A key
1135
+ * an author wrote and the schema did not keep arrives here as `unknown-prop`, naming the node
1136
+ * in `at` and the dotted path in `path`. Empty means nothing was dropped.
1137
+ *
1138
+ * These do not change the content address: two documents differing only by a key the schema
1139
+ * never kept compile to the same `hash`, because they render identically.
1140
+ */
307
1141
  issues: readonly NubbinIssue[];
308
1142
  }
309
1143
 
310
1144
  /**
311
- * Orchestration only. Structure first, and stop there if it failed prop validation on a
312
- * document with dangling references produces cascading noise that buries the real cause.
1145
+ * Validates one document version and serializes it into an immutable, content-addressed
1146
+ * {@link Artifact}. It reads nothing and writes nothing fetching the document and storing the
1147
+ * artifact belong to an adapter.
1148
+ *
1149
+ * Two passes, each collecting rather than stopping at the first fault. Structure comes first:
1150
+ * every node names a registered block, every child id resolves, the graph flattens from `roots`
1151
+ * without cycles, and every filled slot is declared and inside its `allow`, `min` and `max`.
1152
+ * Props come second, each node's values run through its catalog entry's own schema. The second
1153
+ * pass runs only when the first found nothing.
1154
+ *
1155
+ * Compiling the same document against the same catalog and registry yields the same
1156
+ * `artifact.hash` every time — key order is normalized before hashing, so an insertion order
1157
+ * cannot change the address.
1158
+ *
1159
+ * @param version - The document to compile. `roots` names the entry elements in order and
1160
+ * `elements` indexes every node by id. Both are read, neither is mutated.
1161
+ * @param catalog - The serializable half of registration, keyed by block name: the schema each
1162
+ * node's props are judged by, and the `ui.fields` hints deciding which fields freeze into the
1163
+ * artifact and which become holes filled at render. A block a node names and the catalog omits
1164
+ * is a fault.
1165
+ * @param registry - The registered blocks, keyed by name. It supplies the slot constraints the
1166
+ * structural pass judges against, and the version number each used block is stamped with in
1167
+ * `artifact.blockVersions`.
1168
+ * @param route - Where the artifact is published. It is baked into the artifact and into the
1169
+ * content address, and it is judged before the document is looked at: absolute, no trailing
1170
+ * slash unless it is `/` itself, `[slug]` for a param segment, and `*` only as the final
1171
+ * segment.
1172
+ *
1173
+ * @returns The artifact, and the issues that did not stop one existing. A key an author wrote
1174
+ * and the schema did not keep comes back as `unknown-prop`, naming the node and the dotted
1175
+ * path — the artifact is publishable either way, so logging it, shipping it or ignoring it is
1176
+ * the caller's call.
1177
+ *
1178
+ * @throws {NubbinError} A `NubbinError` coded `invalid-route` when `route` addresses no page,
1179
+ * raised before anything in the document is read.
1180
+ * @throws {NubbinError} One `NubbinError` carrying every structural fault at once, in `issues`:
1181
+ * `no-roots`, `unknown-block`, `dangling-child`, `cycle`, `unreachable`, `slot-not-allowed`,
1182
+ * `slot-min`, `slot-max`.
1183
+ * @throws {NubbinError} One `NubbinError` carrying every prop fault at once: `invalid-props` for
1184
+ * a value a schema rejects or one that parses to something other than an object, and
1185
+ * `unknown-block` for a node whose block has no catalog entry.
1186
+ * @throws {NubbinError} A `NubbinError` coded `not-standard-schema` when a catalog entry's schema
1187
+ * exposes no `~standard.validate`, or answers with a promise — compiling is synchronous.
1188
+ *
1189
+ * @example Compile a document and store what comes back
1190
+ * ```ts
1191
+ * import { compile, createRegistry, defineBlock, defineCatalog } from "@nubbin/core";
1192
+ * import type { DocumentVersion } from "@nubbin/core";
1193
+ * import { z } from "zod";
313
1194
  *
314
- * The route is judged before any of it, because it is baked into the artifact and into the
315
- * content address: an unaddressable route would otherwise compile, hash, and store cleanly, and
316
- * only fail to match a request once it was live.
1195
+ * const heroSchema = z.object({ title: z.string(), price: z.number() });
1196
+ * const hero = defineBlock({
1197
+ * name: "Hero",
1198
+ * schema: heroSchema,
1199
+ * component: null,
1200
+ * version: 1,
1201
+ * slots: {},
1202
+ * });
1203
+ *
1204
+ * const registry = createRegistry([hero]);
1205
+ * const catalog = defineCatalog({
1206
+ * Hero: { schema: heroSchema, ui: { fields: { price: { data: { revalidate: 60 } } } } },
1207
+ * });
1208
+ *
1209
+ * const version: DocumentVersion = {
1210
+ * documentId: "d1",
1211
+ * version: 1,
1212
+ * roots: ["n1"],
1213
+ * elements: { n1: { id: "n1", block: "Hero", props: { title: "T", price: 10 } } },
1214
+ * meta: { title: "Summer promotion" },
1215
+ * createdAt: "2026-01-01T00:00:00Z",
1216
+ * createdBy: "studio",
1217
+ * };
1218
+ *
1219
+ * const { artifact, issues } = compile(version, catalog, registry, "/promotions/summer");
1220
+ *
1221
+ * artifact.tree[0]?.props; // { title: "T" } — frozen into the artifact
1222
+ * artifact.tree[0]?.holes; // { price: { revalidate: 60 } } — resolved at render instead
1223
+ * artifact.blockVersions; // { Hero: 1 }
1224
+ * issues; // [] — nothing the schema dropped
1225
+ * ```
1226
+ *
1227
+ * @example Branch on a refusal rather than reading its prose
1228
+ * ```ts
1229
+ * import { NubbinError, NubbinIssueCode } from "@nubbin/core";
1230
+ *
1231
+ * try {
1232
+ * const { artifact } = compile(version, catalog, registry, route);
1233
+ * await store.write(artifact);
1234
+ * } catch (error) {
1235
+ * if (!(error instanceof NubbinError)) throw error;
1236
+ * if (error.code === NubbinIssueCode.InvalidRoute) return rejectRoute(error.message);
1237
+ * for (const issue of error.issues) editor.mark(issue.at, issue.path, issue.message);
1238
+ * }
1239
+ * ```
317
1240
  */
318
1241
  declare function compile(version: DocumentVersion, catalog: Catalog, registry: Registry, route: string): CompileResult;
319
1242
 
320
1243
  /**
321
- * Slot `allow` lists resolve once the whole array is ingested, so a block may name a sibling
322
- * registered after it.
1244
+ * Turns the blocks an app ships into the lookup a renderer resolves nodes through: one name to
1245
+ * one block, with every slot `allow` entry checked against the set. Hold the result for the life
1246
+ * of the process — it reads a snapshot of the array and never re-reads it.
1247
+ *
1248
+ * @param blocks - Every block the app renders, each from `defineBlock`. Names must be unique
1249
+ * across the array, and order does not matter: a slot may name a block declared later.
1250
+ * @returns A registry resolving a block by name and listing the names it holds, in the order the
1251
+ * blocks were given.
1252
+ * @throws {NubbinError} `duplicate-block-name` when two blocks share a name, carrying the name in
1253
+ * `at`.
1254
+ * @throws {NubbinError} `slot-allow-unknown` when a slot's `allow` names a block no entry defines.
1255
+ * Every bad entry is reported in one error as `"Name" (Block.slot)`, alongside the names that
1256
+ * are registered — two typos take one round trip, not two.
1257
+ * @example
1258
+ * ```ts
1259
+ * import { createRegistry, defineBlock } from "@nubbin/core";
1260
+ * import { z } from "zod";
1261
+ *
1262
+ * const pageBlock = defineBlock({
1263
+ * name: "Page",
1264
+ * schema: z.object({ title: z.string() }),
1265
+ * component: null,
1266
+ * version: 1,
1267
+ * slots: { items: { allow: ["Testimonial"] } },
1268
+ * });
1269
+ *
1270
+ * const testimonialBlock = defineBlock({
1271
+ * name: "Testimonial",
1272
+ * schema: z.object({ quote: z.string() }),
1273
+ * component: null,
1274
+ * version: 1,
1275
+ * slots: {},
1276
+ * });
1277
+ *
1278
+ * const registry = createRegistry([pageBlock, testimonialBlock]);
1279
+ *
1280
+ * registry.get("Page")?.name; // "Page"
1281
+ * registry.get("Nope"); // undefined
1282
+ * registry.names(); // ["Page", "Testimonial"]
1283
+ * ```
323
1284
  */
324
1285
  declare function createRegistry(blocks: readonly Block[]): Registry;
325
1286
 
326
1287
  /**
327
- * Identity at runtime; its job is to fix the generic parameters at the call site so props are
328
- * inferred from the schema rather than declared beside it. The checks here are the ones the
329
- * type system cannot make a version below 1, or a slot that no composition could satisfy.
1288
+ * Declares a block the name documents resolve through, the schema its props are validated
1289
+ * against, the component that renders it, and the slots it accepts children in. The block comes
1290
+ * back unchanged, with `Schema` and `Component` pinned at the call site so `InferProps` derives
1291
+ * the component's props from the schema.
1292
+ *
1293
+ * @typeParam Schema - The block's [Standard Schema](https://standardschema.dev). Inferred from
1294
+ * `block.schema`; validation always runs its `~standard.validate`, which must answer
1295
+ * synchronously.
1296
+ * @typeParam Component - Whatever the consumer's renderer accepts. `core` never inspects it.
1297
+ * @param block - The block to declare. `version` must be an integer of 1 or more, and every slot
1298
+ * declaring both bounds must keep `min` at or below `max`. Names in a slot's `allow` are not
1299
+ * resolved here — `createRegistry` resolves them once every sibling is present.
1300
+ * @returns The same object, typed `Block<Schema, Component>`.
1301
+ * @throws {NubbinError} `block-version` when `version` is not an integer of 1 or more.
1302
+ * @throws {NubbinError} `slot-bounds` when a slot's `min` exceeds its `max`, naming the block and
1303
+ * the slot in `at`.
1304
+ * @example
1305
+ * ```ts
1306
+ * import { defineBlock } from "@nubbin/core";
1307
+ * import type { InferProps } from "@nubbin/core";
1308
+ * import { z } from "zod";
1309
+ *
1310
+ * const heroSchema = z.object({
1311
+ * headline: z.string(),
1312
+ * tone: z.enum(["light", "dark"]),
1313
+ * });
1314
+ *
1315
+ * const Hero = (props: InferProps<typeof heroSchema>) => null;
1316
+ *
1317
+ * export const heroBlock = defineBlock({
1318
+ * name: "Hero",
1319
+ * schema: heroSchema,
1320
+ * component: Hero,
1321
+ * version: 1,
1322
+ * slots: { actions: { allow: ["CtaBanner"], max: 2 } },
1323
+ * });
1324
+ * ```
330
1325
  */
331
1326
  declare function defineBlock<Schema extends StandardSchemaV1, Component>(block: Block<Schema, Component>): Block<Schema, Component>;
332
1327
 
333
1328
  /**
334
- * The serializable half of the catalog/registry split: schema, ui, defaults, docs no
335
- * components. Everything checkable at registration is checked here, because a bad hint or
336
- * bad defaults are silent at every later point.
1329
+ * Declares the catalog the component-free half of a block set, holding each block's schema,
1330
+ * its editing hints and its defaults. Entries come back unchanged once every hint path and every
1331
+ * set of defaults has been checked against the schema it belongs to.
1332
+ *
1333
+ * @param entries - Keyed by block name, matching the names `createRegistry` resolves. An entry
1334
+ * carrying only `schema` is stored as-is; `ui.fields` and `defaults` are each checked only when
1335
+ * present. Hint paths are read through the schema's own Standard JSON Schema converter, so an
1336
+ * entry with `ui.fields` needs a schema that exposes one.
1337
+ * @returns The same object, typed `Catalog`.
1338
+ * @throws {NubbinError} `hint-path-unresolvable` when a `ui.fields` key names a path the schema
1339
+ * does not define, naming the block, every unresolved path, and the paths the schema does
1340
+ * define.
1341
+ * @throws {NubbinError} `hint-not-addressable` when a `data` hint sits on a path containing `[]`,
1342
+ * or when two `data` hints on one block nest — `cta` and `cta.label` write one value with no
1343
+ * defined order.
1344
+ * @throws {NubbinError} `no-json-schema` when an entry carrying `ui.fields` has a schema without
1345
+ * the Standard JSON Schema converter (Standard Schema spec 1.1).
1346
+ * @throws {NubbinError} `invalid-defaults` when `defaults` fail their entry's schema, carrying
1347
+ * every failing path and its message.
1348
+ * @throws {NubbinError} `not-standard-schema` when an entry carrying `defaults` has a schema
1349
+ * without `~standard.validate`, or one that validates asynchronously.
1350
+ * @throws {Error} From the schema's own JSON Schema converter, when a field has a type JSON
1351
+ * Schema cannot express — the converter runs with `unrepresentable: "throw"`.
1352
+ * @example
1353
+ * ```ts
1354
+ * import { defineCatalog } from "@nubbin/core";
1355
+ * import { z } from "zod";
1356
+ *
1357
+ * const liveBandSchema = z.object({
1358
+ * label: z.string(),
1359
+ * items: z.array(z.object({ text: z.string(), at: z.string() })),
1360
+ * });
1361
+ *
1362
+ * export const catalog = defineCatalog({
1363
+ * LiveBand: {
1364
+ * schema: liveBandSchema,
1365
+ * defaults: { label: "On now", items: [] },
1366
+ * ui: { fields: { items: { data: { revalidate: 60 } } } },
1367
+ * },
1368
+ * });
1369
+ * ```
337
1370
  */
338
1371
  declare function defineCatalog(entries: Record<string, CatalogEntry>): Catalog;
339
1372
 
340
1373
  /**
341
- * The report as a CI log reads it. `checked` leads every line, including the passing one: a run
342
- * that found no pointers and a run that cleared eight are the same word otherwise, and the first
343
- * of those is a gate certifying nothing.
1374
+ * Renders a report as the plain text a CI log or a terminal wants one line when everything
1375
+ * cleared, otherwise a counted heading followed by each broken route with its blocks indented
1376
+ * beneath it. The count of pointers examined opens both forms, so a run that read an empty store
1377
+ * cannot be mistaken for a run that cleared a full one.
1378
+ *
1379
+ * @param report - A report from `checkCompatibility`, or any value of that shape.
1380
+ * @returns The whole report as one string, newline-separated and with no trailing newline. Split
1381
+ * it on `\n` for a logger that takes lines.
1382
+ * @example
1383
+ * ```ts
1384
+ * import { checkCompatibility, formatCompatibilityReport } from "@nubbin/core";
1385
+ *
1386
+ * formatCompatibilityReport(checkCompatibility(live, registry));
1387
+ * // 1 of 8 live route pointer(s) are incompatible with this registry:
1388
+ * // / (artifact 4a162726)
1389
+ * // Hero: page needs v1, no longer in the registry
1390
+ * ```
344
1391
  */
345
1392
  declare function formatCompatibilityReport(report: CompatibilityReport): string;
346
1393
 
347
1394
  /**
348
- * Moves a node into a slot: a new `DocumentVersion` with the reference taken out of whatever held
349
- * it a slot or the roots — and placed in the target, the node itself untouched by reference.
1395
+ * The inline emphasis a span may carry. A mark outside this set is a validation issue at the
1396
+ * mark's own path, never a dropped value.
1397
+ */
1398
+ type RichTextMark = "strong" | "em" | "code";
1399
+ /** The block kinds rich text is built from. A renderer maps each to an element it chooses. */
1400
+ type RichTextBlockKind = "paragraph" | "listItem";
1401
+ /** A run of text and what is true of it. Inert: nothing here is parsed or evaluated at render. */
1402
+ interface RichTextSpan {
1403
+ /** The literal text of the run. Never markup — a tag here renders as the characters typed. */
1404
+ text: string;
1405
+ /** Emphasis over the whole run. Absent and empty mean the same thing: plain text. */
1406
+ marks?: readonly RichTextMark[];
1407
+ /** Link target for the whole run. Absent leaves the run unlinked. */
1408
+ href?: string;
1409
+ }
1410
+ /** One block of a rich-text value: its kind, and the ordered spans it reads as. */
1411
+ interface RichTextBlock {
1412
+ /** What the block is, which decides how a renderer wraps its spans. */
1413
+ kind: RichTextBlockKind;
1414
+ /** The runs the block reads as, in order. An empty array is a block with no text. */
1415
+ spans: readonly RichTextSpan[];
1416
+ }
1417
+ /**
1418
+ * An ordered array of blocks — the whole value a `richText()` field holds. An empty array is a
1419
+ * valid empty document.
1420
+ */
1421
+ type RichText = readonly RichTextBlock[];
1422
+
1423
+ /** Narrows a value to a member of the closed block-kind set. */
1424
+ declare function isRichTextBlockKind(value: unknown): value is RichTextBlockKind;
1425
+
1426
+ /** Narrows a value to a member of the closed mark set. */
1427
+ declare function isRichTextMark(value: unknown): value is RichTextMark;
1428
+
1429
+ /**
1430
+ * Moves a node into a slot: the reference is taken out of whatever held it — a slot, or the
1431
+ * document's roots — and placed in the target slot.
1432
+ *
1433
+ * A move rewrites references, never the node. Reordering within one slot is a move to the same
1434
+ * parent and slot with a new `index`.
1435
+ *
1436
+ * @param version - The document to edit. Read, never written.
1437
+ * @param nodeId - Id of the node to move. The document must hold it.
1438
+ * @param toParentId - Id of the node whose slot receives it. The document must hold it, and it
1439
+ * may be the parent the node already sits under.
1440
+ * @param toSlot - Slot name on that parent. A slot the parent has never filled is opened.
1441
+ * @param index - Position in the target slot as it stands *after* the node is taken out, so the
1442
+ * last position is that slot's length. Omit to append.
1443
+ * @returns A new `DocumentVersion` with the reference moved. The argument is not mutated, the
1444
+ * moved node is carried over by reference along with every node the edit did not touch, and
1445
+ * `version.version` is not bumped.
1446
+ * @throws {NubbinError} `no-such-node` when `nodeId` or `toParentId` names a node the document
1447
+ * does not hold.
1448
+ * @example
1449
+ * ```ts
1450
+ * // sections: [a, b, c] → aside: [a]
1451
+ * const next = moveNode(version, "a", "stack", "aside");
350
1452
  *
351
- * `index` names a position in the target slot *as it stands after the node is taken out*, which
352
- * is the only reading under which "move it to the end" is the slot's length. Its absence appends.
1453
+ * // Reorder inside one slot: [a, b, c] [b, c, a]
1454
+ * const last = moveNode(version, "a", "stack", "sections", 2);
353
1455
  *
354
- * It does not refuse a move into the node's own subtree. That makes a cycle and detaches the node
355
- * from the roots, and `compile` refuses both by name — judging it here would be a second opinion
356
- * on one question, which is the division every operation in this package follows.
1456
+ * // A root becomes a child, and drops out of `roots`.
1457
+ * const nested = moveNode(version, "loose", "stack", "sections");
1458
+ * ```
357
1459
  */
358
1460
  declare function moveNode(version: DocumentVersion, nodeId: string, toParentId: string, toSlot: string, index?: number): DocumentVersion;
359
1461
 
360
1462
  /**
361
- * Every refusal Nubbin throws, carrying its causes as data rather than only as prose. One class
362
- * for the whole surface: a consumer writes one `catch`, reads `code` or `issues`, and ships that
363
- * shape to whatever tooling they keep — the package neither logs nor decides what a refusal
364
- * means to them.
1463
+ * Every refusal Nubbin throws, from any package, carrying its causes as data rather than only as
1464
+ * prose. One `catch` and one `instanceof` hold the whole surface.
365
1465
  *
366
- * It extends `Error`, so a handler that only ever read `.message` keeps working.
1466
+ * It extends `Error`, so a handler that only ever read `.message` keeps working. `message`
1467
+ * summarises every cause: one issue reads as itself, several read as a count and a line each.
1468
+ *
1469
+ * @example
1470
+ * ```ts
1471
+ * import { NubbinError, NubbinIssueCode, compile } from "@nubbin/core";
1472
+ *
1473
+ * try {
1474
+ * const { artifact } = compile(version, catalog, registry, "/pricing");
1475
+ * await store.write(artifact);
1476
+ * } catch (error) {
1477
+ * if (!(error instanceof NubbinError)) throw error;
1478
+ *
1479
+ * if (error.code === NubbinIssueCode.InvalidRoute) {
1480
+ * return rejectRoute(error.message);
1481
+ * }
1482
+ * for (const issue of error.issues) {
1483
+ * editor.mark(issue.at, issue.path, issue.message);
1484
+ * }
1485
+ * }
1486
+ * ```
367
1487
  */
368
1488
  declare class NubbinError extends Error {
369
1489
  /**
@@ -372,103 +1492,250 @@ declare class NubbinError extends Error {
372
1492
  * Every refusal but `compile`'s carries exactly one issue; read `issues` for the rest.
373
1493
  */
374
1494
  readonly code: NubbinIssue["code"];
1495
+ /**
1496
+ * Every cause, in the order they were found, and never empty. This is what a log, a tracker or
1497
+ * an editing surface serializes — each issue carries its own `code`, `at` and `path`.
1498
+ */
375
1499
  readonly issues: readonly NubbinIssue[];
1500
+ /**
1501
+ * @param issues - The causes, first one first. `code` is taken from `issues[0]` and `message`
1502
+ * is a summary of all of them.
1503
+ * @throws {Error} A plain `Error`, not a `NubbinError`, when `issues` is empty — a refusal
1504
+ * with no cause names nothing.
1505
+ */
376
1506
  constructor(issues: readonly NubbinIssue[]);
377
1507
  }
378
1508
 
379
1509
  /**
380
- * matchKind is parsed from the route at publish, never caller-supplied. It lives in core so
381
- * every adapter derives it from one implementation a second parser is free to disagree.
1510
+ * Derives the `matchKind` for a route pointer. An `ArtifactStore` implementation calls this
1511
+ * inside `publish` and puts the result on the pointer it writes, so the caller of `publish` never
1512
+ * supplies one.
382
1513
  *
383
- * The route is judged before it is classified: this is the last point before a pointer is
384
- * written, so an adapter that never called `compile` still cannot publish an unaddressable one.
1514
+ * @param route - The route the pointer will address: rooted at `/`, no trailing slash except at
1515
+ * the root itself, `[bracketed]` param segments, and `*` only as the final segment.
1516
+ * @returns `"prefix"` for a route ending in `/*`, `"param"` for one carrying a `[bracketed]`
1517
+ * segment, and `"exact"` for anything else — `/` included.
1518
+ * @throws NubbinError with `code` `NubbinIssueCode.InvalidRoute` when the route is not
1519
+ * addressable: it does not start at `/`, it trails a slash, a segment is empty or carries a
1520
+ * character a URL path cannot, a bracketed segment names nothing, or `*` sits mid-route. The
1521
+ * `at` on the issue is the offending route.
1522
+ * @example
1523
+ * ```ts
1524
+ * import { parseMatchKind } from "@nubbin/core";
1525
+ *
1526
+ * parseMatchKind("/about"); // "exact"
1527
+ * parseMatchKind("/guides/[city]"); // "param"
1528
+ * parseMatchKind("/collections/*"); // "prefix"
1529
+ * parseMatchKind("pricing"); // throws NubbinError — a route starts at the root
1530
+ * ```
385
1531
  */
386
1532
  declare function parseMatchKind(route: string): RoutePointer["matchKind"];
387
1533
 
388
1534
  /**
389
- * Throws the one-cause refusal that almost every surface raises, so a site says what it refuses
390
- * and why in one line rather than assembling an issue array around it.
1535
+ * Throws the one-cause refusal almost every Nubbin surface raises, in one line rather than
1536
+ * assembling an issue array around it.
1537
+ *
1538
+ * It is exported so a consumer's own adapter — a store, a schema adapter, a framework binding —
1539
+ * refuses in the shape core does, and a caller's single `catch (error) { if (error instanceof
1540
+ * NubbinError) … }` still holds everything.
391
1541
  *
392
- * `compile` is the exception and builds its own `NubbinError`, because it collects: an author
393
- * fixing six problems should see six, not the first.
1542
+ * @param code - Which refusal this is, from `NubbinIssueCode`.
1543
+ * @param message - Prose for a person, naming the specific value or id at fault.
1544
+ * @param at - What the refusal concerns: a node id, a block name, or a route. Omit it and the
1545
+ * issue carries no `at` key at all, rather than one set to `undefined`.
1546
+ * @returns Never. It always throws, so a caller needs no `return` after it for TypeScript's
1547
+ * narrowing to hold past the call.
1548
+ * @throws {NubbinError} A `NubbinError` — always, carrying exactly one issue built from the
1549
+ * arguments.
394
1550
  *
395
- * Returns `never`, so a caller need not `return` after it for the narrowing to hold.
1551
+ * @example An adapter refusing in the same shape core does
1552
+ * ```ts
1553
+ * import { NubbinIssueCode, refuse } from "@nubbin/core";
1554
+ * import type { Artifact } from "@nubbin/core";
1555
+ *
1556
+ * async function requireArtifact(hash: string): Promise<Artifact> {
1557
+ * const stored = await store.read(hash);
1558
+ * // `refuse` returns never, so `stored` is narrowed to Artifact below.
1559
+ * if (stored === null) {
1560
+ * refuse(NubbinIssueCode.ArtifactNotStored, `no artifact stored at ${hash}`, hash);
1561
+ * }
1562
+ * return stored;
1563
+ * }
1564
+ * ```
396
1565
  */
397
1566
  declare function refuse(code: NubbinIssue["code"], message: string, at?: string): never;
398
1567
 
399
1568
  /**
400
- * Removes a node and everything beneath it: a new `DocumentVersion` with the subtree gone, the
401
- * reference to it dropped from whatever held it, and every other node untouched by reference.
1569
+ * Removes a node and everything beneath it, and drops the reference to it from whatever held it
1570
+ * a slot, or the document's roots.
1571
+ *
1572
+ * The cascade follows slot references from the named node, so a descendant that a second slot
1573
+ * elsewhere also references goes with it.
402
1574
  *
403
- * It cascades because removing a section means removing what was in it. Left behind, the children
404
- * would be unreachable which `compile` already refuses so the alternative is a document that
405
- * cannot compile until an author deletes each orphan by hand.
1575
+ * @param version - The document to edit. Read, never written.
1576
+ * @param nodeId - Id of the node to remove. The document must hold it.
1577
+ * @returns A new `DocumentVersion` with the subtree gone from `elements` and its id gone from
1578
+ * every slot and from `roots`. The argument is not mutated, `version.version` is not bumped,
1579
+ * and every surviving node the edit did not touch is carried over by reference.
1580
+ * @throws {NubbinError} `no-such-node` when `nodeId` names a node the document does not hold.
1581
+ * @example
1582
+ * ```ts
1583
+ * // stack → [a, b]; a → [a1, a2]
1584
+ * const next = removeNode(version, "a");
1585
+ * next.elements.a1; // undefined — the cascade reached it
1586
+ * next.elements.stack?.slots?.sections; // ["b"]
406
1587
  *
407
- * Whether the slot it emptied is now below its `min` is `compile`'s judgment, as it is for every
408
- * other operation here.
1588
+ * // Removing the last root leaves an empty document, which `compile` refuses as `no-roots`.
1589
+ * removeNode(version, "stack").roots; // []
1590
+ * ```
409
1591
  */
410
1592
  declare function removeNode(version: DocumentVersion, nodeId: string): DocumentVersion;
411
1593
 
412
1594
  /**
413
- * The inline emphasis a span may carry. Semantic, never stylistic, and closed: a construct
414
- * outside this set is added as a member deliberately, not smuggled in as markup.
415
- */
416
- type RichTextMark = "strong" | "em" | "code";
417
- /** The block kinds rich text is built from. Closed for the same reason the marks are. */
418
- type RichTextBlockKind = "paragraph" | "listItem";
419
- /** A run of text and what is true of it. Inert: nothing here is parsed or evaluated at render. */
420
- interface RichTextSpan {
421
- text: string;
422
- marks?: readonly RichTextMark[];
423
- href?: string;
424
- }
425
- /** One block of a rich-text value: its kind, and the ordered spans it reads as. */
426
- interface RichTextBlock {
427
- kind: RichTextBlockKind;
428
- spans: readonly RichTextSpan[];
429
- }
430
- /** An ordered array of blocks — the whole value a `richText()` field holds. */
431
- type RichText = readonly RichTextBlock[];
432
-
433
- /**
434
- * A schema `core` hand-writes rather than one a validator brings. Narrower than
435
- * `StandardSchemaV1` in the two ways that matter to a consumer hosting it: `validate` is
436
- * synchronous, which compile requires anyway, and the JSON Schema converter is always there,
437
- * so the studio can read the field tree without testing for it.
1595
+ * A schema `core` writes itself, such as the one `richText()` returns. It satisfies both
1596
+ * `StandardSchemaV1` and `StandardJSONSchemaV1`, so a block, a catalog entry or an adapter takes
1597
+ * it wherever either is accepted — while guaranteeing two things the interfaces leave open.
1598
+ *
1599
+ * @typeParam Value - What a successful `validate` yields, and so what a component receives for
1600
+ * the field.
438
1601
  */
439
1602
  interface StandardDataSchema<Value> {
1603
+ /** The Standard Schema entry point. Everything a consumer reads about the schema is here. */
440
1604
  readonly "~standard": {
1605
+ /** The Standard Schema version this shape speaks. */
441
1606
  readonly version: 1;
1607
+ /** Who wrote the schema, for a tool reporting where a field's rules came from. */
442
1608
  readonly vendor: string;
1609
+ /**
1610
+ * Checks a value, synchronously — never a promise, which is what `compile` requires of any
1611
+ * schema it runs. A refusal comes back as `issues` on the result rather than as a throw.
1612
+ */
443
1613
  readonly validate: (value: unknown) => StandardSchemaV1.Result<Value>;
1614
+ /**
1615
+ * The JSON Schema projection, present rather than optional — what `zodAdapter.describe`
1616
+ * walks to reach the field tree an editing surface renders.
1617
+ */
444
1618
  readonly jsonSchema: StandardJSONSchemaV1.Converter;
445
1619
  };
446
1620
  }
447
1621
 
448
1622
  /**
449
- * The rich-text field type: an ordered array of blocks, each an ordered array of inert spans,
450
- * over closed mark and kind sets. Nothing in the value is parsed or evaluated at render, so an
451
- * artifact carrying it is as inert as one carrying a string.
1623
+ * The schema for a rich-text field: an ordered array of blocks, each an ordered array of spans,
1624
+ * over closed mark and kind sets. Declare it as a block's field to give an author inline
1625
+ * emphasis and links without giving anyone markup.
1626
+ *
1627
+ * Nothing in the value is parsed or evaluated at render, so an artifact carrying one is as inert
1628
+ * as an artifact carrying a string. Both sets and both object shapes are closed: a key the shape
1629
+ * does not declare is reported, never dropped.
1630
+ *
1631
+ * @returns The rich-text schema — a `StandardDataSchema<RichText>` whose `~standard.validate` is
1632
+ * synchronous and whose `~standard.jsonSchema` converter is always present. Every call yields
1633
+ * the same value, so a registry keyed by schema identity sees one schema across every field
1634
+ * that declares it.
1635
+ * @example
1636
+ * ```ts
1637
+ * const schema = richText();
1638
+ *
1639
+ * const body: RichText = [
1640
+ * {
1641
+ * kind: "paragraph",
1642
+ * spans: [
1643
+ * { text: "How we keep that safe is on our " },
1644
+ * { text: "security page", href: "/security" },
1645
+ * { text: ", not in a PDF.", marks: ["strong", "em"] },
1646
+ * ],
1647
+ * },
1648
+ * { kind: "listItem", spans: [{ text: "encrypted", marks: ["code"] }] },
1649
+ * ];
1650
+ *
1651
+ * schema["~standard"].validate(body).issues; // undefined
452
1652
  *
453
- * A call rather than a bare value, so a field declaration reads the way the rest of a block's
454
- * schema does and so options can arrive without changing call sites that pass none. A scan for
455
- * this call finds every rich-text field in a registry.
1653
+ * // A refusal names the offending path rather than throwing.
1654
+ * schema["~standard"].validate([{ kind: "heading", spans: [] }]).issues;
1655
+ * // [{ path: [0, "kind"], message: 'unknown kind "heading"; expected one of paragraph, listItem' }]
1656
+ * ```
1657
+ * @example
1658
+ * Seating it in a validator that will not hold a foreign schema — zod rejects one inside an
1659
+ * object shape, so the field is carried as `unknown` and `core` decides what is valid:
1660
+ * ```ts
1661
+ * const spec = richText();
1662
+ *
1663
+ * const body = z
1664
+ * .unknown()
1665
+ * .check((ctx) => {
1666
+ * for (const issue of spec["~standard"].validate(ctx.value).issues ?? []) {
1667
+ * ctx.issues.push({ code: "custom", message: issue.message, input: ctx.value });
1668
+ * }
1669
+ * })
1670
+ * .pipe(z.custom<RichText>())
1671
+ * .meta(spec["~standard"].jsonSchema.input({ target: "draft-2020-12" }));
1672
+ * ```
456
1673
  */
457
1674
  declare function richText(): StandardDataSchema<RichText>;
458
1675
 
459
- /** Copy-on-write down one dotted path. Paths address object fields only; `[]` has no single target. */
1676
+ /** The closed mark set, in the order an editor would offer it. */
1677
+ declare const RICH_TEXT_MARKS: readonly ["strong", "em", "code"];
1678
+ /** The closed block-kind set. */
1679
+ declare const RICH_TEXT_BLOCK_KINDS: readonly ["paragraph", "listItem"];
1680
+
1681
+ /**
1682
+ * Writes a value at a dotted path in a plain object, copying every level the path passes
1683
+ * through. `setNodeProp` edits props with it, and the renderer fills a resolved hole with it.
1684
+ *
1685
+ * The path is `.`-separated object keys — `price`, `cta.link.label`. Each segment names a key,
1686
+ * never an array index: a numeric segment is the key `"0"`, and an array met on the way down is
1687
+ * refused rather than descended into. The last segment is written wholesale, so an array or
1688
+ * object already sitting there is replaced entire. An intermediate key holding no object — a
1689
+ * missing key, `null`, a string, a number — is replaced by a fresh object.
1690
+ *
1691
+ * @param target - The object to write into. Read, never written.
1692
+ * @param path - Dotted path of object keys. Every segment must be non-empty and free of `[]`.
1693
+ * @param value - What to write at the path. Anything, including `undefined`.
1694
+ * @returns A new record with the path written. The argument is not mutated: each level along
1695
+ * the path is a fresh object, and every key off the path is carried over by reference.
1696
+ * @throws {NubbinError} `path-not-addressable` when a segment is empty, carries `[]` — which
1697
+ * names every member of an array rather than one target — or descends into an array.
1698
+ * @example
1699
+ * ```ts
1700
+ * setAtPath({ title: "T", price: 0 }, "price", 42); // { title: "T", price: 42 }
1701
+ * setAtPath({}, "cta.price", 42); // { cta: { price: 42 } } — intermediates are created
1702
+ * setAtPath({ cta: "text" }, "cta.price", 42); // { cta: { price: 42 } } — the string is gone
1703
+ * setAtPath({ items: ["a", "b"] }, "items", ["c"]); // { items: ["c"] } — a whole-field write
1704
+ *
1705
+ * setAtPath({ items: ["a", "b"] }, "items.0", "X"); // throws — `items` is an array
1706
+ * setAtPath({}, "items[].price", 42); // throws — `[]` names every member, not one
1707
+ * ```
1708
+ */
460
1709
  declare function setAtPath(target: Record<string, unknown>, path: string, value: unknown): Record<string, unknown>;
461
1710
 
462
1711
  /**
463
- * The first document operation: a new `DocumentVersion` with one prop set on one node,
464
- * copy-on-write, every other node untouched by reference. It lives beside `compile` so any
465
- * caller an editor, a script, an agent writes a document the same way
466
- * ([#134](https://github.com/effekt/nubbin/issues/134)).
1712
+ * Sets one prop on one node, addressed by a dotted path into that node's `props`.
1713
+ *
1714
+ * The value is not checked against the block's schema here. `compile` reports a bad one as an
1715
+ * `invalid-props` issue at the offending path, so a document may hold a value its schema
1716
+ * rejects between two edits that end valid.
1717
+ *
1718
+ * @param version - The document to edit. Read, never written.
1719
+ * @param nodeId - Id of the node whose props are rewritten. The document must hold it.
1720
+ * @param path - Dotted path into the node's props, in the syntax `setAtPath` defines:
1721
+ * `headline`, `cta.label`. Missing intermediate objects are created; `items[]` is refused.
1722
+ * @param value - What to write at that path. Unvalidated, and replaces whatever sits there.
1723
+ * @returns A new `DocumentVersion` carrying a new `Node` for `nodeId` with the prop set. The
1724
+ * argument is not mutated, `version.version`, `roots`, `meta` and `createdAt` are untouched,
1725
+ * and every other node is carried over by reference.
1726
+ * @throws {NubbinError} `no-such-node` when `nodeId` names a node the document does not hold;
1727
+ * `path-not-addressable` when the path names no single field — an `items[]` segment, an empty
1728
+ * segment, or a descent into an array.
1729
+ * @example
1730
+ * ```ts
1731
+ * const next = setNodeProp(version, "hero", "headline", "After");
1732
+ * next.elements.hero?.props.headline; // "After"
467
1733
  *
468
- * It does not validate the value (that is `compile`'s job at the next compile) and does not
469
- * bump `version` appending a version is the authoring store's concern
470
- * ([#11](https://github.com/effekt/nubbin/issues/11)), not a property of one edit.
1734
+ * // A dotted path reaches inside an object prop, leaving its siblings in place.
1735
+ * setNodeProp(version, "hero", "cta.label", "Buy").elements.hero?.props.cta;
1736
+ * // { label: "Buy", href: "/" }
1737
+ * ```
471
1738
  */
472
1739
  declare function setNodeProp(version: DocumentVersion, nodeId: string, path: string, value: unknown): DocumentVersion;
473
1740
 
474
- export { type Artifact, type ArtifactNode, type ArtifactStore, type Block, type BlockDrift, type BlockUi, type Catalog, type CatalogEntry, type CompatibilityReport, type CompileResult, type DocumentMeta, type DocumentVersion, type FieldHint, type FieldHintData, type FieldKind, type FieldNode, type Holes, type InferProps, type LiveRoute, type Manifest, type Node, NubbinError, type NubbinIssue, NubbinIssueCode, type Registry, type RichText, type RichTextBlock, type RichTextBlockKind, type RichTextMark, type RichTextSpan, type RollbackCheck, type RouteIncompatibility, type RoutePointer, type SchemaAdapter, type SlotConstraint, type StandardDataSchema, type UnknownProps, addNode, checkCompatibility, checkRollback, compile, createRegistry, defineBlock, defineCatalog, formatCompatibilityReport, moveNode, parseMatchKind, refuse, removeNode, richText, setAtPath, setNodeProp, zodAdapter };
1741
+ export { type Artifact, type ArtifactNode, type ArtifactStore, type Block, type BlockDrift, type BlockUi, type Catalog, type CatalogEntry, type CompatibilityReport, type CompileResult, type DocumentMeta, type DocumentVersion, type FieldHint, type FieldHintData, type FieldKind, type FieldNode, type Holes, type InferProps, type LiveRoute, type Manifest, type Node, NubbinError, type NubbinIssue, NubbinIssueCode, type PointerMove, RICH_TEXT_BLOCK_KINDS, RICH_TEXT_MARKS, type Registry, type RichText, type RichTextBlock, type RichTextBlockKind, type RichTextMark, type RichTextSpan, type RollbackCheck, type RouteIncompatibility, type RoutePointer, type SchemaAdapter, type SlotConstraint, type StandardDataSchema, type UnknownProps, addNode, checkCompatibility, checkRollback, compile, createRegistry, defineBlock, defineCatalog, formatCompatibilityReport, isRichTextBlockKind, isRichTextMark, moveNode, parseMatchKind, refuse, removeNode, richText, setAtPath, setNodeProp, zodAdapter };