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

stelstone

v7.0.0

Published

Astro integration that mounts the Stelstone admin under /admin during `astro dev` and exposes content helpers.

Readme

stelstone

Astro integration for Stelstone. Mounts the CMS admin panel into astro dev, auto-generates content collection config, and exports the JSON loader + schema builder your site needs.

Install

npm i stelstone @stelstone/server

Quick start

1. Create cms.config.mjs

export default {
  mountPath: "/admin",
  locales: ["en"],
  defaultLocale: "en",

  collections: {
    blog: {
      label: "Blog Posts",
      listFields: ["title", "pubDate"],
      metaFields: [
        { key: "title",   type: "text",     label: "Title",     required: true },
        { key: "slug",    type: "text",     label: "Slug",      required: true },
        { key: "pubDate", type: "datetime", label: "Published" },
        { key: "blocks",  type: "blocks",   label: "Content" },
      ],
    },
  },

  content: {
    pagesDir: "src/pages-data",
    publishBranch: "main",
    commitMessage: (ts) => `Content updated ${ts}`,
  },

  media: { provider: "local" },

  auth: {
    provider: "basic",
    userEnv: "ADMIN_USER",
    passEnv: "ADMIN_PASS",
  },
};

2. Mount in astro.config.mjs

import { defineConfig } from "astro/config";
import stelstone from "stelstone";
import config from "./cms.config.mjs";

export default defineConfig({
  integrations: [stelstone({ config })],
});

3. Run astro dev — src/content.config.ts is generated automatically

✔ Generated src/content.config.ts — covers blog. Edit freely.

The generated file covers every collection in cms.config.mjs. You can commit it, and adding a new collection to cms.config.mjs requires no changes to it. Delete it to regenerate.

4. Add env vars (.env)

ADMIN_USER=admin
ADMIN_PASS=secret

5. Render blocks in your pages

---
import BlockRenderer from "@stelstone/astro-blocks";
const { entry } = Astro.props;
---
<BlockRenderer blocks={entry.data.blocks} />

See @stelstone/astro-blocks for the full block reference.


cms.config.mjs reference

Collection options

collections: {
  blog: {
    label: "Blog Posts",          // displayed in sidebar
    listFields: ["title", "slug"], // columns shown in entry list
    metaFields: [ ... ],          // editable fields
    defaultValues: { draft: true },
  },
}

Names metaFields cannot use

An entry file is { id, slug, lang, collection, meta: {…}, blocks: [] }, and the loader flattens it by spreading meta and then writing its own keys over the top. So a field named for one of those is edited, saved, and replaced on the way out — the editor watches their value disappear.

| key | | |---|---| | id, slug, lang, collection, blocks | taken from the record. A field here cannot work; validateConfig rejects it. | | draft | the editor already renders a toggle for it, so a field is a second control over one value — a warning. | | publishAt, redirectFrom, categorySlugs, categoryNames, tagSlugs, tagNames | read from meta and coerced, with no control of their own. Declare these freely — a field is how an editor reaches them. |

Anything else is a site's own. status, for instance, has nothing to do with the CMS's publish state, which is draft and publishAt.

Two entries for the same key in one list are rejected too: both render, both edit the same value, and whichever was filled last is the one that sticks. The same key in listFields and metaFields is fine and usual — that is how a column gets something to show.

metaFields field types

| type | Editor control | Zod type (auto) | |------------------|---------------------------------------|------------------------------| | text | Single-line input | z.string().optional() | | textarea | Multi-line input | z.string().optional() | | richtext | Rich-text editor (HTML output) | z.string().optional() | | number | Numeric input | z.number().optional() | | boolean | Toggle | z.boolean().optional() | | date | Date picker | z.coerce.date().optional() | | datetime | Date + time picker | z.coerce.date().optional() | | select | Dropdown (options — strings or {value, label}) | z.enum([...]).optional() | | image | Media picker (CDN or local) | z.string().optional() | | meta-image | OG/social image picker | z.string().nullable().optional() | | json | Raw JSON textarea | z.unknown().optional() | | collection-ref | Entry picker from another collection | z.string().optional() | | link | URL input + picker of the site's pages | z.string().optional() | | blocks | Block content editor (see astro-blocks)| auto-included always | | code | Code editor | z.string().optional() |

Options:

{ key: "status", type: "select", label: "Status", options: ["draft", "published"], required: true }
{ key: "cover",  type: "image",  label: "Cover image" }
{ key: "source", type: "collection-ref", label: "Author", collection: "authors" }

required: true shows a red * in the editor and blocks saving if the field is empty.

select options: when the value and the label differ

A plain string is both at once, which is right whenever the stored value reads well to an editor. When it does not — a status a template branches on should stay the same in every language — write the pair:

{
  key: "status",
  type: "select",
  label: "Durum",
  options: [
    { value: "completed", label: "Tamamlandı" },
    { value: "wip",       label: "Devam eden" },
    "planned",                                   // both forms mix freely
  ],
}

The entry stores value, the editor and the list column show label, and the generated z.enum validates the values. emptyLabel renames the empty choice ("— Select —" by default) for a field where "nothing" means something, such as "inherit the default".

On a multilingual site, label each language:

options: [
  { value: "completed", label: { tr: "Tamamlandı", en: "Completed" } },
  { value: "wip",       label: { tr: "Devam eden", en: "Ongoing" } },
]

The select and the list column show the words for the entry's own language; the value stays the same in all of them, so the enum is one enum and a template, a filter or a sort compares one string.

The alternative — listing every language's words as values — makes an entry's status depend on the language it was written in. Nothing can then filter or sort across languages, and a template that compares the value has to know all of them. validateConfig warns about a label for a locale the site does not serve, and about a locale with no label of its own (it would silently show another language's words).

Custom block types

// cms.config.mjs — optional, extends the built-in block palette
blocks: {
  hero: {
    label: "Hero",
    icon: "fa-star",
    properties: {
      heading: { type: "text",     label: "Heading", required: true },
      image:   { type: "image",    label: "Background image" },
      cta:     { type: "text",     label: "Button text" },
      ctaHref: { type: "text",     label: "Button URL" },
    },
    defaults: { heading: "Welcome" },
  },
},

Content helpers

jsonContentLoader and buildCollectionSchema are re-exported from the main entry:

import { jsonContentLoader, buildCollectionSchema } from "stelstone";

jsonContentLoader(collection, opts?)

Astro Content Layer loader. Reads src/pages-data/{collection}/*.json (or opts.pagesDir).

loader: jsonContentLoader("blog")
loader: jsonContentLoader("blog", { pagesDir: "content/pages" })
loader: jsonContentLoader("blog", { visibility: false }) // keep drafts in a production build

A production build leaves out drafts and entries whose go-live date is still ahead — they are not in the collection at all. A staging build keeps them. See Visibility below for which build is which.

Visibility — siteEnv, isVisible, isListed, robotsFor

An entry says two independent things about itself: draft ("not for the live site") and publishAt ("live from this moment"). Where it may show depends on the site being built:

| entry | production | staging | |---|---|---| | draft | hidden | unlisted — built, opens by its address, named nowhere | | scheduled (date ahead) | hidden | listed | | neither | listed | listed |

siteEnv() says which build this is: STELSTONE_ENV (production | staging) first; else Netlify's CONTEXT; else NODE_ENV=development (astro dev) is staging; else production. Unset is the safe answer. Set STELSTONE_ENV=staging on the staging site's build.

import { isVisible, isListed, robotsFor } from "stelstone";

// getStaticPaths — which entries get a page
const entries = (await getCollection("blog")).filter(isVisible);

// menus, lists, sitemap, feeds, related links — which may be named
const posts = (await getCollection("blog")).filter(isListed);

// the unlisted page's robots meta
const robots = robotsFor(entry); // "noindex,nofollow" or null

Each takes an Astro entry ({ data }), a raw file ({ meta }) or flat data; visibility(entry) returns "hidden" | "unlisted" | "listed" for anything else.

Not live yet — pendingOnProduction, isPendingOnProduction

What a staging site badges "not on the live site": the entries this build carries that the live branch does not have. Answered by git — git diff --name-only origin/main...HEAD -- src/pages-data — since the build already has the repository. The base is fetched shallowly when the clone lacks it; STELSTONE_PRODUCTION_REF names another base.

---
import { pendingOnProduction, isPendingOnProduction, siteEnv } from "stelstone";
const pending = siteEnv() === "staging" ? pendingOnProduction() : null;
---
{isPendingOnProduction(entry, pending) && <span class="badge">Not live</span>}

pendingOnProduction() is null when git cannot answer (no repository, no such branch): the site then shows no badges rather than wrong ones. It is computed once per process.

buildCollectionSchema(collectionConfig, { z })

Generates a Zod schema from a collection's metaFields. Always includes id, slug, lang, draft, publishAt, redirectFrom, blocks, and standard taxonomy arrays. (id matters: zod strips undeclared keys, and without id Astro mistakes the remainder for a broken content reference — declaring it here is what makes the old .extend({ id }) workaround unnecessary.)

// Extend to tighten types:
schema: buildCollectionSchema(config.collections.blog, { z }).extend({
  pubDate: z.coerce.date(),    // make date required (not optional)
}),

Redirects — collectRedirects, hierarchicalPathOf, netlifyRedirects

Every site used to hand-roll this in astro.config.mjs. Once is enough:

import { collectRedirects, hierarchicalPathOf, netlifyRedirects, readContentDirs } from "stelstone/redirects";

const docs = readContentDirs("src/pages-data/pages");
const pathOf = hierarchicalPathOf(docs);            // walks meta.parent chains
const { redirects, problems } = collectRedirects({
  source: docs,
  pathOf,                                            // omit on flat sites
  extra: { "/sample-page-2": "/" },                  // hand rules win
});
for (const p of problems) console.warn(p);           // masked/duplicate rules

export default defineConfig({
  redirects,                                         // Astro's meta-refresh pages
  integrations: [stelstone({ config }), netlifyRedirects(redirects)],  // real 301s
});

Two sources feed the map: entries served at derived paths get their flat slug redirected, and meta.redirectFrom lists old paths on the entry itself — when a slug changes, record the old one there and the 301 ships with the next build. netlifyRedirects writes dist/_redirects with forced rules (301!); unforced rules never fire because Astro leaves a meta-refresh file at the old path and Netlify prefers files over rules.

Link integrity — checkInternalLinks

import { checkInternalLinks } from "stelstone/links";

const { broken } = checkInternalLinks(docs, { pathOf, redirects, extraPaths: ["/tesekkurler/"] });
// [{ source: "/hakkimizda/", href: "/iletism/" }] — a typo, caught at build time

Walks button hrefs and richtext anchors; reports internal links that hit neither a live path nor a redirect. External URLs, mailto:, tel: and fragments are out of scope — their validity is not knowable from content.


Integration options

| Option | Type | Default | |--------------------|------------|---------------------------------| | config | Object | required — your cms.config | | publicConfig | Function | auto-derived (strips secrets) | | rootDir | string | Astro's config.root | | adminUiSourceDir | string | admin-ui source, for HMR (see below) | | realm | string | HTTP Basic auth realm |

The admin UI in astro dev

/admin is served without any option set: the integration takes the admin UI's built bundle from @stelstone/admin-ui. Pass adminUiSourceDir only to develop the admin UI itself — it switches to Vite with HMR, and needs the package's source, which a published tarball does not contain.

The startup log says which one it mounted:

[stelstone] Stelstone mounted at /admin (admin UI: built bundle)

/admin redirects to /admin/. The SPA links its bundle relatively, so without the trailing slash a browser resolves ./assets/… against /admin and the page loads blank.