Skip to main content

Blocks and the registry

This page describes the shipped behaviour of defineBlock, createRegistry and richText — what they reject, and why. Every signature, field and type they involve is generated from the source into the API reference; the reasoning behind the shape lives in api.md and the decisions.

defineBlock

defineBlock takes a Block and, at runtime, returns it unchanged. Its job is to fix the two generic parameters at the call site, so the component's props follow from the output side of the schema rather than from a second declaration beside it — see Props inferred from the schema, never declared.

Derived from packages/core/src/defineBlock.test.ts and the demo's examples/demo/src/blocks/Hero.block.ts:

import { defineBlock } from "@nubbin/core";
import type { InferProps } from "@nubbin/core";
import { z } from "zod";

const heroSchema = z.object({
headline: z.string(),
tone: z.enum(["light", "dark"]),
});

type HeroProps = InferProps<typeof heroSchema>;
// { headline: string; tone: "light" | "dark" }

const Hero = (props: HeroProps) => null;

export const heroBlock = defineBlock({
name: "Hero",
schema: heroSchema,
component: Hero,
version: 1,
slots: {},
});

defineBlock throws on the mistakes the type system cannot catch:

RejectedWhy
A version that is not an integer of 1 or moreArtifacts record the block versions they compiled against, and a version below 1 has no artifact that could record it
A slot whose min exceeds its maxNo composition could satisfy it

A schema that validates asynchronously is refused too, because compile and registration are both synchronous and neither can await an answer.

richText

richText() is a field of inline content as data. Marks and block kinds are both closed sets, both objects are closed, and a key the shape does not declare is rejected rather than dropped — see Rich text is typed data, never markup.

Rejected by validate()Reported at
A value that is not an arraythe field itself
A block whose kind is outside the set<index>.kind
A span whose text is not a string<index>.spans.<index>.text
A mark outside the set<index>.spans.<index>.marks.<index>
A key neither shape declaresthe key

core writes the schema itself rather than reaching for a validator, so the projection the studio reads comes from ~standard.jsonSchema.input alongside ~standard.validate. Both are what zodAdapter.describe walks to reach body[].spans[].text and the two enums.

A validator will not necessarily hold it. zod checks that every value in an object shape is a zod schema, so z.object({ body: richText() }) throws at the first parse. A block written in zod seats the schema once — examples/demo/src/blocks/shared/richText.schema.ts carries the value through z.unknown(), runs core's validate as the check, and passes ~standard.jsonSchema.input(…) to .meta() so the projection survives.

StandardDataSchema

StandardDataSchema is what a schema core writes itself guarantees over the Standard Schema interface generally: validate answers synchronously, which compile requires of any schema, and the JSON Schema converter is present rather than optional. It satisfies both StandardSchemaV1 and StandardJSONSchemaV1, so a block, a catalog entry, or an adapter takes it wherever either is accepted.

createRegistry

Derived from packages/core/src/createRegistry.test.ts:

import { createRegistry, defineBlock } from "@nubbin/core";
import { z } from "zod";

const pageBlock = defineBlock({
name: "Page",
schema: z.object({ title: z.string() }),
component: null,
version: 1,
slots: { items: { allow: ["Testimonial"] } },
});

const testimonialBlock = defineBlock({
name: "Testimonial",
schema: z.object({ quote: z.string() }),
component: null,
version: 1,
slots: {},
});

const registry = createRegistry([pageBlock, testimonialBlock]);

registry.get("Page")?.name; // "Page"
registry.get("Nope"); // undefined
registry.names(); // ["Page", "Testimonial"]

Slot allow lists resolve only once every block is in, so an entry may reference a block that appears later in the array — its order carries no meaning.

createRegistry throws on:

RejectedWhy
Two blocks sharing a nameNames are the identity nodes resolve through, so a duplicate would make resolution depend on order
An allow entry naming no registered blockThe slot would silently reject every child, including the one the author meant. Every unresolvable entry is reported at once, quoted as "entry" (Block.slot)

Registry

A registry answers two questions and holds no identity of its own. What an artifact records about the registry it compiled against is blockVersions — the name and version of each block the document actually uses — which is what the guardrail compares.