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