npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@evanion/astro-widget

v0.3.0

Published

Render CMS-driven Astro sections from structured widget data. Build-time, zero runtime.

Readme

npm version npm downloads CI

@evanion/astro-widget

Render CMS-driven Astro sections from structured widget data. Build-time only — no runtime, no hydration, nothing shipped to the browser.

The item shape, the registry and the validator come from @evanion/widget, which this package pins exactly and re-exports, so installing this one is enough. The React renderer of the same items is @evanion/react-widget: one item array renders through either and produces the same widgets in the same order.

Install

npm install @evanion/astro-widget

Astro >=7.3.1 is a peer dependency.

Use

// src/registry.ts
import { defineWidgets } from '@evanion/astro-widget';
import Hero from './widgets/Hero.astro';
import Cards from './widgets/Cards.astro';

export const registry = defineWidgets({ hero: Hero, cards: Cards });
---
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} ctx={{ site: 'example.com' }} />

Where page.json is whatever your CMS writes:

{
  "sections": [{ "id": "top", "type": "hero", "props": { "heading": "Hello" } }]
}

Data shape

interface AnyWidgetItem<Type extends string = string, Props = object> {
  id: string;
  type: Type; // must be a key in the registry
  props: Props; // spread into the component
  meta?: Record<string, unknown>; // placement, read by the chrome
  children?: AnyWidgetItem[];
}

id is required, and props is a named field rather than "every key the renderer does not claim for itself". A renderer's own fields would otherwise be reserved words in the CMS's vocabulary, and adding one later would take a prop away from every payload already written.

Validation

Widgets skips a type the registry does not hold, with a dev-only console.warn, so a bad CMS save can never break a render. Catch them loudly at build time instead:

import { validateItems } from '@evanion/astro-widget';

const problems = validateItems(page.sections, registry, { hero: ['heading'] });
if (problems.length) {
  for (const p of problems)
    console.error(`section ${p.index} (${p.id}, ${p.type}): ${p.message}`);
  process.exit(1);
}

index is scoped to whatever level of the tree it was found at: a problem in a top-level section and a problem in one of its children can both report index: 0, meaning different things. id is what tells them apart.

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 must render <slot />. If it doesn't, Astro silently drops the wrapped widget: no error, no warning, the section just vanishes from the page.

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. A widget that wants to render its own nested sections must do so itself:

---
// Cards.astro
import Widgets from '@evanion/astro-widget/components/Widgets.astro';
import { registry } from '../registry';
const { children } = Astro.props;
---
<Widgets items={children} registry={registry} />

Differences from @evanion/react-widget

| | react-widget | astro-widget | | ----------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------- | | Provider / useWidgets | yes | no — build-time rendering has nothing to provide; use ctx | | Prop type inference | inferred from the component map | no — Astro components are opaque at the type level. Use validateItems | | Nested children | rendered as the widget's own | no recursion — a widget must render <Widgets items={children} registry={registry} /> itself | | Region chrome | chrome.wrapper | none — a wrapper the library supplied would be markup you did not ask for |

The data is the same in both. That is the point of the split.

Migrating from 0.2.x

Five names went, and the data moved.

| Was | Now | | ---------------- | ---------------- | | defineBlocks | defineWidgets | | validateBlocks | validateItems | | BlockItem | AnyWidgetItem | | BlockRegistry | WidgetRegistry | | BlockProblem | WidgetProblem |

The import specifiers do not change, and neither does @evanion/astro-widget/components/Widgets.astro.

WidgetProblem gains id, so an exact-equality assertion on a problem object changes. Two messages changed with it: 'unknown block type' is now 'unknown widget type' and 'blocks is not a list' is 'items is not a list'.

The validator checks more than it did

validateBlocks reported an unknown type and a missing required field, and nothing else. validateItems is the one implementation both renderers share, so it also brings the five rules the React side always had. Each of these is a new problem on a payload that passed yesterday:

| Message | Raised when | | ------------------------- | ------------------------------------------- | | item is not an object | an entry is null, an array, or a primitive | | item id is not a string | id is missing or not a string | | duplicate sibling id | two items in one sibling list share an id | | props is not an object | props is missing or not a plain object | | children is not a list | children is present and not an array |

duplicate sibling id is the one to check first. A CMS that emits a constant id per section type — "hero" on every hero — or an empty string where an editor left the field alone now fails a build that passed before. Ids only have to be unique within one sibling list, so the same id at two depths is still fine.

props is not an object is the rule that catches an unmigrated payload: an item with its props still at the top level has no props key at all, and without this it would validate clean and render as an empty widget.

The data

The item's own props move under props, and id becomes required:

const toWidgetItem = ({ type, id, children, meta, ...props }) => ({
  id: id ?? crypto.randomUUID(),
  type,
  props,
  meta,
  children: children?.map(toWidgetItem),
});

id ?? … is the awkward half. A widget item needs an id — as the key, as the identity in a warning, and as what the duplicate-sibling check is about — and a CMS with no per-section id has to supply one. An index-derived value is fine as long as it is stable across renders.

chrome.item no longer receives the item's props, only type, id and meta. Under the old shape "props" meant everything the renderer did not claim, so handing them to the chrome was nearly free; now they are the widget's data and the chrome has no business with them.

Licence

MIT