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