Ahead of the release
npm install @evanion/astro-widget gives you 0.3.0. These pages document main, which has changes that release does not.
Getting started
The registry
// src/registry.ts
import { defineWidgets } from '@evanion/astro-widget';
import ListingHeader from './widgets/ListingHeader.astro';
import GameGrid from './widgets/GameGrid.astro';
export const registry = defineWidgets({
'listing-header': ListingHeader,
'game-grid': GameGrid,
});defineWidgets comes from @evanion/widget and returns the object unchanged;
the core’s entry is where it is written down. That declaration is what gives an editor the key union it completes on.
Only the keys are read, so the helper is shown here over stand-ins for the two
.astro modules — what it returns is the object it was handed:
import { defineWidgets, validateItems } from '@evanion/astro-widget';
// In a project these are `.astro` modules; the helper reads their keys and
// nothing else, so a stand-in is enough to show what it returns.
const registry = defineWidgets({
'listing-header': () => null,
'game-grid': () => null,
});
Object.keys(registry); // -> ['listing-header', 'game-grid']
validateItems([], Object.keys(registry)); // -> []Rendering
Every .astro block on this page carries a mark saying nothing executed it,
and that is the package rather than the budget: an .astro module is compiled
by Astro’s own compiler against a project, there is no runtime for one in a
browser, and no bundler in scope produces one. The half of this package that is
plain TypeScript — the registry, the item shape, the validator — runs under
nx test @evanion/astro-widget and the pages cite those runs.
---
import Widgets from '@evanion/astro-widget/components/Widgets.astro';
import { registry } from '../registry';
import page from '../data/page.json';
---
<Widgets items={page.sections} registry={registry} />| Prop | Meaning |
|---|---|
items | the widgets to render |
registry | widget type to component |
ctx | page-level data, passed to every widget as a ctx prop |
chrome | { item } — a component rendered around every widget |
An items that is not an array renders nothing rather than throwing.
The data shape
An item is id, type, props, and optional meta and children.
Getting started in the
widget core teaches the four fields and
the core’s reference carries the interface. The
same item an @evanion/react-widget consumer writes, which is what makes one
payload render through both.
What this renderer does with it: a CMS that writes
{
"id": "header",
"type": "listing-header",
"props": { "title": "Brass: Birmingham", "mechanism": "Economic" }
}renders <ListingHeader title="Brass: Birmingham" mechanism="Economic" id="header" ctx={ctx} />.
props is spread. id reaches the widget as well as the chrome, because a
widget often wants it as an anchor target. meta does not: where a widget sits
is not something the widget should know.
ctx
<Widgets items={sections} registry={registry} ctx={{ site: Astro.site, locale }} />Every widget receives ctx as a prop. A prop rather than a provider: an Astro
component runs once, top down, and has no render-time context to read from.
This is the counterpart to @evanion/react-widget’s ctx, under the same name,
so a widget ported between the two reads the same field.
Chrome
Wrap every widget without each widget reimplementing section markup:
<Widgets items={items} registry={registry} chrome={{ item: Section }} />Section receives the item’s type, id and meta — never its props, which
are the widget’s own business — and it must render <slot />:
---
// Section.astro
interface Props {
type: string;
id: string;
meta?: { background?: string };
}
const { type, id, meta } = Astro.props;
---
<section
id={id}
data-widget-type={type}
class:list={['widget', meta?.background && `bg-${meta.background}`]}
>
<slot />
</section>An Astro component that does not render its slot discards the content passed to
it, so a chrome missing <slot /> makes every widget vanish from the page — with
nothing thrown and nothing logged. That is the first thing to check when a page
comes out empty.
meta is the placement channel: grid column, background, CMS edit affordances.
It is handed to the chrome and never spread into the widget.
There is no region wrapper. The container is whatever element you put
<Widgets> inside, and one the library supplied would be markup you did not ask
for. @evanion/react-widget has a chrome.wrapper because a React region has
to return one element; an Astro fragment does not.
Nesting
The renderer does not recurse. children on an item is forwarded to its widget
as ordinary prop data, nothing more — Astro projects child content through
<slot />, never through a children prop, so there is nothing for a renderer
to pass nested items into.
A widget that wants nested sections renders them itself:
---
// GameGrid.astro
import Widgets from '@evanion/astro-widget/components/Widgets.astro';
import { registry } from '../registry';
const { children = [], title } = Astro.props;
---
<section>
<h2>{title}</h2>
<Widgets items={children} registry={registry} />
</section>The registry has to be imported again, because the nested <Widgets> is a fresh
call with its own props. Importing it from one module — as
src/registry.ts above — keeps that from becoming a second registry that drifts.
Unknown types are skipped
An item whose type is not a key of the registry renders nothing and draws a
dev-only console.warn naming the type and the id. Nothing throws: a gap in
build-time validation must not take a page down mid-render.
Run validateItems at build time, or a bad CMS save
is a missing section with only a line in a log to say so.
Registry lookup uses Object.prototype.hasOwnProperty, so an item typed
constructor, toString or __proto__ is skipped like any other unknown type
rather than resolving to a function off Object.prototype and reaching Astro as
a component. Items are CMS data, so any string is reachable.