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

@half-built/astro

v0.16.1

Published

Astro components, islands, and pure helpers for the half-built design system.

Readme

@half-built/astro

Astro components, islands, and pure helpers for the half-built design system.

Provenance

Extracted from the private half-built-robots-blog repository, where these components were built and used in production. The decisions behind them are in the design record.

Icons

Every glyph the package draws comes from one registry, scripts/core/icons.ts, derived from Lucide (https://lucide.dev), ISC license; see ICONS-LICENSE. Templates render one through components/Icon.astro:

<Icon name="search" size={14} />

name is a registry key (x, play, sparkles, pause, rotate-ccw, chevron-left, chevron-right, chevron-up, arrow-left, arrow-right, sun, moon, search, clock, user, calendar, circle, maximize-2), size a CSS length or pixel count (default 1em, tracking the parent's font size), strokeWidth defaults to 2.5, and class lands on the svg. The icon is decorative by contract (aria-hidden, pointer-events none), so the accessible name belongs to the button or link around it. The svg arrives through set:html and carries no scoped-style attribute; style it from the parent with :global(svg). Client scripts take the ICON_* strings from the same file. Add a glyph to the registry, never as inline <svg> in a component; a test enforces that.

Links that open a new tab

NavItem (the header's nav) and SitemapLink (the footer's sitemap groups) take an optional newTab: boolean. When true the link renders target="_blank" rel="noopener" and a visually hidden (opens in a new tab) inside the link, so the cue reaches screen readers. Without it the markup is unchanged.

Corner badges

CornerBadges.astro takes its chips as data, badges: Badge[] from scripts/core/badges.ts: a key (rendered as the badge-<key> class, the styling hook), a label, and an icon name from the registry. The two house conventions ship as GENAI_BADGE and DEMO_BADGE; a site's view-model lists the badges a post carries, in order, and can add its own without a package change. PostCardModel.badges carries them to the card. The content components (BlogImage, GalleryImage, MediaText) take the same badges array, for example badges={[GENAI_BADGE]} with GENAI_BADGE imported from @half-built/astro/scripts/core/badges.ts.

EditorNote

content/EditorNote.astro is a reminder block for content that must not ship: by default it renders only when the consuming build runs in dev mode, and a deploy build emits nothing for it. A consumer with a wider preview concept (the blog's SHOW_DRAFTS builds, for example) passes its own gate through the shown prop; the component reads no consumer config itself.

WhenPublished and PostLink

content/PostLink.astro links to a post by slug and resolves the href at build time through postPath, so a re-dated post does not strand the links pointing at it. While the target is hidden (a draft, with the draft gate off) it renders its text alone, with no element and no styling, and becomes a link on its own when the post ships. That makes it the right tool for a draft's name in published prose.

content/WhenPublished.astro renders its children only when the post at slug is visible. Use it when a whole phrase only makes sense once the post exists ("its own post", "see it here"). An optional fallback slot renders while the target is hidden:

<WhenPublished slug="my-draft">
  It has <PostLink slug="my-draft">its own post</PostLink> now.
  <Fragment slot="fallback">A post on it is coming.</Fragment>
</WhenPublished>

Both take the consumer's collection as the posts prop (the full collection, drafts included, so an unknown slug can throw instead of hiding as "still a draft") and the consumer's draft gate as showDrafts. showDrafts defaults to false, so a preview build must pass its own draft gate through to PostLink to keep draft links live. PostLink also accepts an optional tip carried as data-tooltip for the link-tip island; a hidden PostLink drops it. A consumer wraps each in a one-line site component that injects getCollection and its own gate, the way the blog does. The resolvers (postLinkHref, resolvePostHref, forwardLinkVisible) live in lib/drafts.ts for consumers that want the logic without the components.

Derived excerpts

Posts carry no excerpt frontmatter. lib/excerpt.ts derives the card text from the post body at build time: the opening prose, consecutive paragraphs joined by a space, cut at a word boundary within EXCERPT_LIMIT (200 characters) and always ended with an ellipsis, the reader's cue that the post continues. The run stops at the first heading, list, blockquote, code fence, or image line, so a card never crosses into a later section; components and the lead-break are invisible to it. content/ExcerptStart.astro on its own line moves the start to the paragraph after it, for a post that opens on a TL;DR or an aside. postExcerpt(entry) is the policy: a published post with no prose fails the build naming the slug, a draft warns once per build and renders blank. It takes any entry shaped { body?, data: { slug, draft? } }, so a CollectionEntry passes with no cast. A consumer calls postExcerpt from every place a summary renders (cards, meta description, feed, search index) and never stores the result. The blog is the reference consumer and derives all four from it. The rule was settled on the blog's 54 posts on 2026-09-13 and moved here on 2026-09-14.

Live code colors

shiki/code-theme bakes its amber values into every highlighted span at build time. shiki/code-vars is a Shiki transformer that rewrites those baked values to the css package's --code-token-* and --code-* custom properties, so highlighted code follows a runtime palette override. The variables resolve to the same hexes the theme bakes, so adopting the transformer changes no rendered pixel on its own. Pass it beside the theme: the transformers prop of astro:components' Code, or markdown.shikiConfig.transformers in an Astro config. It also encodes the > that Shiki's HTML serializer leaves raw in Code output, so that output passes the tooling html-validate preset.

Footnote separators

rehype/footnote-separators is a rehype plugin for Markdown footnotes. remark-gfm renders each [^n] as its own superscript, so stacked references like [^1][^2][^3] run together as "123". The plugin puts an aria-hidden superscript comma between references that touch (whitespace counts as touching) and leaves references split by prose alone. That check needs the text between them, which a CSS sibling selector cannot see, so it lives in the markup. Add it to markdown.rehypePlugins in an Astro config; MDX inherits it.

Palette token entries

A content/Palette.astro entry may carry token (a custom property name) instead of hex: the swatch then paints var(token) and follows the live cascade with no script, and the hex cell renders empty with a data-token-hex attribute for a consumer script to fill from computed styles. Entries with hex render exactly as before. The table's scroll box is a keyboard tab stop named by the label prop (default "Palette"), since the table scrolls sideways below the column's width.

Ecosystem island

scripts/ecosystem fills the Footer's Ecosystem column from a shared JSON document, so adding a property to a family of sites does not mean rebuilding every one of them.

import { mountEcosystem } from "@half-built/astro/scripts/ecosystem.ts";

void mountEcosystem(document, {
  endpoint: "https://example.com/ecosystem.json",
  selfKey: "ui",
});

The endpoint is a parameter and the package ships no default. Footer keeps taking ecosystem and ecosystemSelf as typed props, and those props are the static baseline the island replaces. Every failure path leaves that baseline standing: no JavaScript, a dead endpoint, a malformed payload, or a document that does not contain selfKey.

The document is { version: 1, entries: [...] } where each entry has key, label, href (null renders unlinked), priority (ascending, 0 highest) and family. Entries are sorted with the self entry's own family first, then by priority, and capped at limit, default 6.

Footer reserve for bottom-docked controls

A control cluster fixed to the viewport's bottom corner takes no room in flow, so the page's last lines end underneath it. Set the css package's --dock-bottom token to the room the cluster occupies (its height, inset, and air) and Footer pads its band by that much below the last link, so the page scrolls far enough to clear the cluster. The token is 0px by default and can be set inside the same media block that pins the cluster.

TwoColumn

components/TwoColumn.astro renders its main column as <main>; pass mainTag="div" for a demo or nested use where the page already has its own main.

SiteHeader title slot and search

SiteHeader (also exported as Masthead) has a named title slot inside the home link. Fill it to replace the site name's text while keeping the package's centering, boxed tagline, and h1/p choice:

<Masthead siteName="Sample Site" ...>
  <span slot="title" class="wordmark">Sample <em>Site</em></span>
</Masthead>

The span's own class carries any font change. siteName stays required and is the fallback when the slot is empty.

searchAction defaults to "/search/". Pass searchAction={false} to render no search block at all (no button, flyout, or form). The phone theme toggle stays.

Search flyout

scripts/site-header drives the masthead's search flyout as a disclosure: the magnifier toggles it open and closed, opening moves focus to the field, Escape closes and returns focus to the magnifier, and a press or keyboard focus leaving the flyout closes it. The island marks the wrap data-search-js; without it, the stylesheet's focus-within rule opens the flyout on focus alone, so it still works with no script.

Lightbox

scripts/lightbox opens the images the content components link through LightboxLink (BlogImage, GalleryImage, MediaText, Step) in a full-window viewer. Links inside one Gallery or Walkthrough form a set with previous and next buttons, thumbnails, and the arrow keys.

import "@half-built/css/plate-modal.css";
import "@half-built/css/lightbox.css";
import { mountLightbox } from "@half-built/astro/scripts/lightbox.ts";

mountLightbox(document);

The image opens at fit: contained in the window, very tall images fit to width, and small images never scale past 100%. The wheel or a trackpad zooms under the pointer in proportion to the scroll; a sideways scroll does nothing. + and - step the zoom, 0 returns to fit, and a drag pans. Once the image is panned mostly out of view a HOME box appears that also returns to fit. Double-click at the fit view zooms to 100% under the cursor; anywhere else it returns to fit.

A resize or rotation while the viewer is open refits the image to the new window. Any zoom and pan the reader had set is discarded, on purpose: the old view was measured against a window that no longer exists.

mountLightbox takes options: selector (default a.lightbox-link) picks the links, groupSelector names the containers whose links form one set, and labels renames the viewer's controls for a site that is not in English.

Plate frame

scripts/plate-frame.ts makes the .plate-frame pattern's corner box (@half-built/css) open the full view. The frame's [data-plate-frame-content] element moves into the plate modal whole, so live content keeps running there, and a slot of the same height holds its place. Every way out of the modal (close box, veil, Escape) moves it back and returns focus to the corner box.

import { mountPlateFrames } from "@half-built/astro/scripts/plate-frame.ts";

mountPlateFrames(document);

Per frame, optional: data-plate-label names the dialog (default "Enlarged view"); data-plate-class and data-zone-class add classes to the modal's plate and zone for sizing. Needs plate-modal.css.

Popout

components/Popout.astro puts a short note behind a table cell: detail worth keeping but not worth a column. It renders a trigger button and a template holding its slot; scripts/popout opens the note. Like link-tip, the mounted island is a document-wide singleton: root only names the document, and triggers anywhere in it share the one surface.

<Popout label="Sample row · Sample product">
  A short note with a <a href="#">link</a> and <code>code</code>.
</Popout>
import "@half-built/css/popout.css";
import { mountPopouts } from "@half-built/astro/scripts/popout.ts";

mountPopouts(document);

label is required. It captions the open note and names the trigger for assistive tech (<trigger>: <label>, so Note: <label> by default), so write it to identify the row. trigger changes the button text from Note. The slot takes inline markup: links, code, emphasis.

Above the phone breakpoint the note opens below its trigger (above it when there is no room below) and stays until Escape, the close box, a press outside, or another trigger. It also closes by itself when its trigger scrolls out of view or when keyboard focus leaves it. Shift+Tab off its first stop returns to the trigger; Tab off its last stop moves on to whatever follows the trigger. On a wide touch device a drag that starts outside the note closes it. At phone width it opens as a modal bottom sheet that closes on the close box, Escape or Back, a tap on the backdrop, or a downward swipe. One note is open at a time. With JavaScript off the trigger does nothing.

A resize or rotation that crosses the phone breakpoint, in either direction, closes an open note instead of turning the box into a sheet or the sheet into a box. The reader reopens it and it opens in the mode for the new width. Focus is not moved back to the trigger, which may have scrolled away in the new layout. Resizes that stay on one side of the breakpoint keep the note open: the box re-places itself, and the sheet rides out the URL bar collapsing and the keyboard opening.

mountPopouts takes options: selector (default .popout-trigger) picks the triggers, edge sets the anchored note's room against the viewport edges in px, and closeLabel (default Close) names the close box for a site that is not in English.

Not covered yet: structured content (lists, sub-tables, images), triggers on chart marks, and hover previews.

StatLedger

components/StatLedger.astro sets a few figures side by side in one ruled box, a label over each value. Pass label to name the section for assistive tech; one item may set accent.

<StatLedger
  label="Sample figures"
  items={[
    { label: "Sample A", value: "1,024 units" },
    { label: "Sample B", value: "512 units" },
    { label: "Ratio", value: "50.0%", accent: true },
  ]}
/>

It is a <dl> and stacks into one column at phone width.

Meter

components/Meter.astro is a segmented bar with role="meter". Props: value, max, label, segments (default 10), valueText (default the clamped value of max, such as "3 of 10"), and class. The value is clamped to 0..max, and a non-finite value reads as empty. It sets --meter-fill and --meter-segments inline; the tooling html-validate preset allows both. A consumer keeping a local copy of htmlvalidate.json must add --meter-fill and --meter-segments to its no-inline-style allowlist.

Subscribe status lines

Subscribe takes messages to replace any of its four status lines (invalid, pending, sent, failed). The lines ride the form as data-messages, so the self-mounting script picks them up per form.

Import notes

Wildcard subpath imports need explicit file extensions under TypeScript's bundler mode: @half-built/astro/lib/slug.ts and @half-built/astro/components/Shell.astro, not extensionless forms. Vite resolves either; tsc --noEmit only accepts the explicit one.

The Masthead.astro export is an alias for SiteHeader.astro, the same component under its public name.