@nubbin/core 0.1.1 → 0.2.0

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