stelstone
v7.0.0
Published
Astro integration that mounts the Stelstone admin under /admin during `astro dev` and exposes content helpers.
Maintainers
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/serverQuick 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=secret5. 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 buildA 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 nullEach 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 timeWalks 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.
