Composition / foldkit-composition

Drawing a page: foldkit-composition/foldkit

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.

URLs

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):

ts
const Image = Block.define('Image', {
  Props: Schema.Struct({ src: Url, alt: Schema.String }),
  provides: [Content.Flow, Content.Media],
})

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 }] }).

Appearance: foldkit-composition/appearance

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:

ts
import { Style } from 'foldkit-mixins'
import { Appearance } from 'foldkit-composition/appearance'

const HeroLook = Appearance.make(HeroSlots, {
  recipe: Style.recipeFor(HeroSlots)({
    base: { root: Style.class('hero') },
    variants: { tone: { plain: {}, accent: { root: Style.class('accent') } } },
    defaults: { tone: 'plain' },
  }),
  tokens: { gap: Appearance.token(t.space, { slot: 'root', property: 'gap' }) }, // t = Theme.ref(theme)
})

const Hero = Block.define('Hero', { Props, provides: [Content.Section] }).pipe(
  Appearance.attach(HeroLook),
)

const SiteRenderer = Renderer.make(Site, {
  Hero: ({ props, appearance, h }) => {
    const slots = HeroLook.draw({ appearance, h })
    return h.section(slots.root.attrs(), [props.title])
  },
})
  • 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
    const grow = (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.

It needs foldkit-mixins installed; the core does not.

Blocks with state of their own

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 Model
const 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:

ts
import { QueryBlock } from 'foldkit-composition/remote'

const LatestPages = QueryBlock.define('LatestPages', {
  Props: Schema.Struct({ count: Schema.Literals([3, 5]) }),
  provides: [Content.Flow],
  query: Cms.Entries,
  input: () => ({ type: 'pages', search: '', archived: false }),
  select: Entity.select(Cms.Entities.Entry, { label: true }),
  first: props => props.count,
})

const SiteRenderer = Renderer.make(Site, {
  LatestPages: ({ data, h }) => {
    const rows = LatestPages.rows(data) // RemoteData<Page<{ label: string }>>
    return rows._tag === 'Ready' ? h.ul([], rows.value.items.map(row => h.li([], [row.label]))) : h.p([], ['Loading'])
  },
})
  • 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.

It needs foldkit-remote and foldkit-surface installed; the core does not.

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:

ts
const Blocks = SurfaceBlock.families(Site, {
  from: CmsPage,
  document: ({ page }) => page.document,
})
const subscriptions = Data.subscriptions({ page: CmsPage, ...Blocks })

A node whose props do not decode, or whose Block the Catalog lacks, resolves to no instance. Rendering still reads through reads and data.

Serving a published page

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.