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.
Validation
Widgets.astro skips an unknown widget type with a dev-only warning, so a bad
CMS save can never break a render. Catch them loudly at build time instead.
This page is the section’s demonstration and it has nothing to click. The thing
worth operating here is a rendered Astro page, and an .astro component is
compiled against a project rather than in a browser, so the honest version of
that control is the storefront’s own build output embedded beside the data that
produced it. That is not built. Until it is, every example below is executed —
by nx test @evanion/astro-widget, over the real validator — and the reader
gets evidence rather than a knob.
validateItems takes the sections, the widget types the registry holds, and a
map of the props each type cannot render without. It returns one problem per
fault, in order, and throws nothing:
import { validateItems } from '@evanion/astro-widget';
const sections = [
{
id: 'header',
type: 'listing-header',
props: { title: 'Brass: Birmingham' },
},
{ id: 'price', type: 'price-box', props: {} },
{ id: 'questions', type: 'answer-wall', props: {} },
];
const required = { 'listing-header': ['title'] };
const problems = validateItems(
sections,
['listing-header', 'price-box'],
required,
);
problems; // -> [{ index: 2, id: 'questions', type: 'answer-wall', message: 'unknown widget type' }]The answer wall is not a type this registry holds, so the section would have rendered as a gap on the listing page. Run the same call over the payload before the build touches it:
import { validateItems } from '@evanion/astro-widget';
import { registry } from './src/registry';
import page from './src/data/page.json';
const problems = validateItems(page.sections, registry, {
'listing-header': ['title'],
});
if (problems.length) {
for (const problem of problems) {
console.error(
`section ${problem.index} (${problem.id}, ${problem.type}): ${problem.message}`,
);
}
process.exit(1);
}This matters more here than in @evanion/react-widget, because it is the only
check a widget’s props get. An .astro component exposes no prop types to infer
from, so there is no compile-time half.
What it reports
One WidgetProblem per fault: the item’s index within its own sibling list,
its id and type, and the message.
What it reports in the widget core is the catalogue — every
message, and the payload that produces each one. The nine below are the same
nine, run here against this package’s own build.
Six of the nine are structural and fire whatever the registry holds. Five of them show up in one payload:
import { validateItems } from '@evanion/astro-widget';
const saved = [
null,
{ type: 'listing-header', props: { title: 'Root' } },
{ id: 'grid', type: 'game-grid', props: {}, children: 'none' },
{ id: 'grid', type: 'game-grid' },
];
validateItems(saved, ['listing-header', 'game-grid']).map((p) => p.message); // -> ['item is not an object', 'item id is not a string', 'children is not a list', 'duplicate sibling id', 'props is not an object']The root itself is the ninth, and it reports one problem rather than none: a CMS that wrote an object where the schema said array has broken the page, and a clean run would say it had not.
import { validateItems } from '@evanion/astro-widget';
validateItems({ sections: [] }, ['listing-header']); // -> [{ index: -1, id: '-', type: '-', message: 'items is not a list' }]A payload with nothing wrong reports nothing, which is the case a build gate runs on every deploy:
import { validateItems } from '@evanion/astro-widget';
const page = [
{ id: 'header', type: 'listing-header', props: { title: 'Root' } },
{
id: 'grid',
type: 'game-grid',
props: {},
children: [
{ id: 'azul', type: 'listing-header', props: { title: 'Azul' } },
],
},
];
const required = { 'listing-header': ['title'] };
const problems = validateItems(page, ['listing-header', 'game-grid'], required);
problems; // -> []Required fields
validateItems(sections, registry, {
'listing-header': ['title'],
'game-grid': ['title', 'games'],
});The third argument maps a widget type to the prop names that must carry something. What blank means is the core’s, and the case it catches is the CMS text field an editor opened and left empty:
import { validateItems } from '@evanion/astro-widget';
const blank = [
{ id: 'header', type: 'listing-header', props: { title: ' ' } },
];
validateItems(blank, ['listing-header'], { 'listing-header': ['title'] }); // -> [{ index: 0, id: 'header', type: 'listing-header', message: 'missing field title' }]Required props are only checked for a widget whose type is in the registry. An unknown type reports once, as an unknown type, rather than once plus one per prop it does not have.
index is scoped to its own level
The validator recurses into children, so a problem in a top-level section and
a problem inside one of its children can both report index: 0:
import { validateItems } from '@evanion/astro-widget';
const nested = [
{
id: 'grid',
type: 'game-grid',
props: {},
children: [{ id: 'questions', type: 'answer-wall', props: {} }],
},
];
validateItems(nested, ['game-grid']); // -> [{ index: 0, id: 'questions', type: 'answer-wall', message: 'unknown widget type' }]id is what tells the two apart. The problem list is flat.
Where to run it
As a script before astro build, so a bad payload fails the build rather than
shipping a page with a section missing:
{
"scripts": {
"build": "node ./scripts/check-content.mjs && astro build"
}
}Or on the CMS webhook, so an editor learns about it at save time rather than at deploy time. Both is better: the webhook gives the fast feedback, the build gate is the one that cannot be skipped.
A WidgetRegistry is Record<string, unknown> here, and only its keys are
read, so a CI script that does not want to pull .astro modules into a plain
Node process can hand it a bare list of names — validateItems lives in
@evanion/widget and imports no framework at all:
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)); // -> []The real registry is the better argument where it is already imported, since the two cannot then disagree about which types exist.