A Renderer is one ordinary Foldkit view per Block, from the Block's decoded
props and its Regions' drawn children to Html:
ts
import{ Renderer }from'foldkit-composition/foldkit'const SiteRenderer = Renderer.make(Site,{// `field` draws a text prop: the text for a visitor, editable in place for an author.Heading:({ field, h })=> h.h2([], [field('text')]),Section:({ props, regions, h })=> h.section([h.DataAttribute('tone', props.tone)], [...regions.body]),})Renderer.render(SiteRenderer, page, h) // ReadonlyArray<Html>, one per root
Every Block needs a view. A Catalog Block without one is a type error, and
Renderer.make throws naming it.
Nothing stored makes it throw. A node whose Block the Catalog lacks, whose
props do not decode, that is missing or reached twice, or whose view throws
is a placeholder: nothing for a visitor, a labelled box in edit mode.
The builder is a parameter, so one Renderer draws in the browser, in a
test with inertHtml, and on the server. A Renderer made with
Renderer.make sends nothing, so it takes any application's builder, the
view's own h; one made with Renderer.forMessages<Message>() takes a
builder of those Messages only.
Edit mode ({ mode: 'edit' }) wraps each node in a display: contents
element carrying data-composition-node, so an editor's canvas draws the
page a visitor sees. The wrapper also carries the editor's marks, from the
options selected, hovered and drop, each an Option as the editor's
Model holds it: data-composition-mark is
selected or hovered (a node that is both is selected), and
data-composition-drop says where a drop lands. A node whose when fails is
drawn anyway, marked data-composition-hidden. An empty Region is drawn as
one element holding its name spaced (Right), marked
data-composition-region with the Region's name and data-composition-node
with its node's id, so an author sees where a child can go and can drop into
it; a drop with region: Option.some(name) marks that element inside
instead of the node. A view that tests a Region's length sees one child
there in edit mode.
Text edited in place. A view draws a text prop with field(key), which
takes only a prop whose type is exactly string: one of a few names
('plain' | 'accent'), a branded string or one that may be absent is no
field, since typing could not keep it valid. For a visitor it is the text. In edit
mode it is a span marked data-composition-field, whose value names the node
and the prop as JSON (fieldOf(value) reads it back). While the option
editing (an Option of { id, key, initial }) names it, the span is
contenteditable="plaintext-only", a textbox named by label (default: the
prop's name spaced), multiline when asked, and it shows initial, the text
when editing began, however the page has changed since. That freezing is what
keeps a redraw from rewriting the element under the author's caret; the span
is keyed apart from the one drawn otherwise, so the element the browser
changed is replaced when editing ends. The Renderer draws; reading what is
typed is EditableText in foldkit-primitives, and what it changes is the
editor's update. Renderer.fields(renderer, document, id) says which text
props a node's view draws as fields, in order, by drawing it inert: what an
editor may offer to edit. It draws with no data and no context, so a view
that draws a field only once its data has arrived lists none.
The other options are what the page is drawn for: context, which
conditions read (Conditions), and data, each node's read by
id, which Query, Surface and stateful Blocks draw from. A page with all three
spreads them into one record:
{ ...queries.read(model), ...features.read(model), ...Stateful.views(...) }.
Renderer.make's views dispatch nothing, so their on(event) gives
none: an editor's canvas draws a page without running its actions.
Renderer.forMessages<Message>() requires every Message the Catalog's
actions make to be one of Message.
A node is drawn again only when what it reads changed. Inside a
runtime-driven render, each node's drawing is memoized with Foldkit's
createKeyedLazy on its node object, its drawn children, its read, whether
it shows, its marks, and the field being edited in it. apply shares every node it does not change, so an
edit redraws the node it touched and the nodes holding it, and a change
elsewhere in the Model redraws none. Each node is keyed by its id (the edit
wrapper, or in view mode the Block's own root, unless it set a key), so a
moved node moves with its drawing. Drawn outside a runtime (a test with
inertHtml, a server's first pass), nothing is memoized.
It adds no reconciler and no component runtime. It needs foldkit
installed; the core does not.
A Document is untrusted, so a URL is a type. Url accepts http:, https:,
mailto:, tel: and relative URLs, and refuses every other scheme,
javascript: and data: included, read the way a browser reads it (control
characters dropped, case folded):
An unsafe URL is composition:invalid-props in validate, refused by apply,
and drawn as a placeholder.
Rich text as a Block: foldkit-composition/richtext#
ts
import{ RichTextBlock }from'foldkit-composition/richtext'const Text = RichTextBlock.define('Text',{kit: ArticleKit,provides: [Content.Flow] })// Draw its body with foldkit-richtext-dom: Text: ({ props }) => renderDocument(props.body)
The Block's one prop is body, a foldkit-richtext Document. Validating the
page validates the body against the Kit, each finding reported as
composition:nested at its path, and apply refuses a body the Kit does not
accept. Any Block can do the same for its own content with check:
Block.define(name, { Props, provides, check: props => [{ path, message }] }).
A node's look is choices within a design system, not CSS. A look is a
foldkit-mixins slot recipe, whose variant axes are the
choices, and token axes, whose choices are a theme's token names. The Document
stores only the names:
Appearance.attach(look) gives the Block its appearance axes (tone:
plain | accent, gap: the token names), which are plain data in the core:
validate and Op.setAppearance check a node's names against them, with no
Mixins involved. Block.withAppearance(axes) sets them by hand.
look.draw({ appearance, h }) is the Block's Slots with the chosen Style
attached: the base, each chosen value (or the recipe's default), each
matching compound, and a token's declaration. A Renderer hands each view
appearance, the node's choices its Block offers; a stored name it does not
offer is left out, not drawn.
A Block with its own interaction attaches its Behaviors after the look:
look.draw({ appearance, h, with: [Carousel.mixin] }). A style property a
Behavior owns that a choice also sets is mixins:style-property-conflict,
thrown when the slot is drawn, never a silent winner.
Each piece is compiled once, when the look is made, and a selection
attaches the ones it picks side by side. Style.stylesheet(...look.styles)
holds every rule any node can use; pass layer to Appearance.make to
compile them in a cascade layer, such as Layers.standard.layer('app').
Arbitrary CSS, class names and selectors are not in the Document. An escape
hatch is a Block written for it, visibly outside the typed path.
A token choice may change at breakpoints. Given breakpoints (such as
Theme.tokens.breakpoint, smallest first), a node stores one name for every
viewport or a name per point, { gap: { base: 'sm', md: 'lg' } }. Each is a
rule, the breakpoints' after the base's in look.styles, so the widest that
matches wins; a view's appearance then holds the record. A variant is one
value for every viewport: its pieces may be classes, which no media query
can hold.
Measure the page, not the window. Breakpoints written
Theme.inContainer(PAGE_CONTAINER, Theme.tokens.breakpoint) are container
queries on the page (PAGE_CONTAINER, from foldkit-composition/foldkit),
and cqi units in a look are its width: an editor's narrow preview then
draws a look as a phone would, in a wide window. The Renderer draws no
container; the page's host is one (containerType: 'inline-size',
containerName: PAGE_CONTAINER), as the Builder's frame is.
A layout Block is a Mixins layout. Its look's base is the layout, and
the layout's parameters are its axes:
ts
constgrow=(left:string, right:string)=> ({left: Style.inline({flexGrow: left }),right: Style.inline({flexGrow: right }),})const ColumnsLook = Appearance.make(ColumnsSlots,{recipe: Style.recipeFor(ColumnsSlots)({base:{root: Layout.switcher() },variants:{ratio:{'1:1':grow('1','1'),'2:1':grow('2','1') },stack:{early:{root: Style.vars({'--fk-l-threshold':'48rem'}) },late:{}},},}),tokens:{gap: Appearance.token(t.space,{slot:'root',property:'gap'}) },})
A layout writes its parameters as custom properties, so every Columns on a
page shares one rule, and an author choosing { ratio: '2:1', gap: 'lg' }
adds no CSS.
Most Blocks are pure rendering. One that truly has state, such as a carousel
or an accordion, is marked stateful and backed by an ordinary Bundle, which
the page's parent places once per node, keyed by its id. Each instance is an
ordinary child Model in the application's Model; nothing keeps per-node state
anywhere else:
ts
const Carousel = Block.define('Carousel',{Props: Schema.Struct({interval: Schema.Number }),provides: [Content.Flow],stateful:true,})const Carousels = Bundle.declareEach(CarouselBundle,'carousels') // in the parent's Modelconst Placed = Page.each(Carousels)// When the page shown changes, keep the collection in step with it:Stateful.sync(Placed, Site, Carousel,{ before, after },(model, props)=> ({...model,interval: props.interval,}))// Drawing, each node is drawn by its own item:Renderer.render(SiteRenderer, page, h,{data: Stateful.views(Placed, Site, Carousel, page, model, h),})// and in the Renderer: Carousel: ({ data }) => Stateful.html(data)
Composition.statefulNodes(catalog, document, block?) lists the stateful
nodes a page draws, with their decoded props.
Stateful.sync (in /foldkit) is the Step that adds a new node's item,
its Model prepared from its props, removes a gone node's, and starts again
one whose props changed; props are compared by value, so a page loaded again
keeps what its nodes hold.
A Bundle placed per key cannot have Managed Resources: a Block that needs one
is not stateful this way.
Blocks that read data: foldkit-composition/remote#
A Query Block names a foldkit-remote query and says how
its props become the query's input. An author chooses a count or a category;
an author never writes a query, and the Document never holds one. From the CMS
example's site.ts:
QueryBlock.reads(Data, catalog, document) is every Query Block on the
page as one Projection over the application's Model, keyed by node id, an
Option: none when the page has none. A node whose props do not decode
reads nothing.
QueryBlock.active(name, App.owner, Data, catalog, model => Option.some(document))
is that read as an active Surface, for Data.wiring, Data.subscriptions or
an SSR plan's surfaces: Remote fetches, caches and authorizes it like any
read, and Remote.resume(Data) carries exactly what it selected into a
server-rendered page. documentOf returns none while there is no page.
reads.data(model) is each node's value, as a Renderer's data takes
it, and {} while there is nothing to read. reads.answered(model) is
whether every one of those reads has an answer, true while there is nothing
to read: what a page waits for before it is shown (SSR.hydrate's until).
Renderer.render(..., { data: reads.read(model) }) hands each node its
value as data, and LatestPages.rows(data) reads it typed by what the
Block selects, Initial while there is nothing.
The Block's type depends on the query, not on Data, so a Catalog stays
independent of the Model that the Builder's form is part of.
Blocks that show a feature: foldkit-composition/surface#
A Surface Block places a foldkit-surface Surface, such as a cart summary,
where an author puts it, with params its props give. The Surface still owns
what it reads and which Messages it may send:
ts
import{ SurfaceBlock }from'foldkit-composition/surface'const Cart = SurfaceBlock.define('Cart',{Props: Schema.Struct({caption: Schema.String }),provides: [Content.Flow],surface: CartSummary,params: props => ({caption: props.caption }),})// In the Renderer: Cart: ({ data, h }) => Option.match(Cart.value(data), { ... })
SurfaceBlock.reads(catalog, document) and SurfaceBlock.active(name,
App.owner, catalog, model => Option.some(document)) are the page's Surface Blocks as one
Projection, or an active Surface, exactly as a Query Block's are, and their
values reach the Renderer the same way, through data. The active Surface may
send what each placed Surface lists in its messages, and refuses a Surface of
another application when it is made. The Block holds its
Surface, whose type carries the application's Model: where the Catalog is part
of that Model, as it is when a form places the page Builder, a Block names what
it reads instead, as a Query Block does.
SurfaceBlock.families(catalog, { from, document }) is the same Blocks as one
Surface.each family per Block, keyed by Block name with each instance keyed
by its node id, for Data.wiring, Data.subscriptions, Data.satisfy, or an
SSR plan's surfaces — wherever per-node requirements, retention, and
coverage matter rather than one combined read:
Most of a composed page is content no Message changes, which is what
foldkit-ssr's static regions are for:
ts
view:(model, h)=> ({title:'Home',body: h.main([], [SSR.static('page', ih => Renderer.render(SiteRenderer, model.page, ih))]),})
The server draws the page once; the browser adopts its markup and never runs
the Renderer. With the page left out of the resume plan's state, the Document
is not sent to the browser at all, only the markup.