@codeyam/cms
v0.14.0
Published
An installable, Git-backed content management system for Astro sites. Ships an Astro integration that injects a /admin dashboard plus an `integrate` CLI that wires the CMS into any Astro project. Designed for static (GitHub Pages) and server-rendered site
Maintainers
Readme
@codeyam/cms
An installable, Git-backed CMS for Astro sites. Install
one package, run one command, and your project gets a /admin content dashboard
that commits changes straight to your GitHub repo — perfect for static sites on
GitHub Pages, and equally at home on server-rendered Astro.
Unlike a starter template you copy once and own forever, this is a real
dependency: the admin dashboard is injected from node_modules by an Astro
integration, so you pick up improvements with npm update instead of
re-merging files by hand.
Requirements
| | Supported |
| --- | --- |
| Astro | 5, 6, or 7 (^5.2.0 \|\| ^6.0.0 \|\| ^7.0.0) |
| Node | 18.20.8, ^20.3.0, or >=22.0.0 on Astro 5 — >=22.12.0 on Astro 6 and 7 |
| React | 18 or 19 (only if you use the admin dashboard) |
The Node floor is set by Astro itself, not by this package: Astro 6 raised its
own engines.node to >=22.12.0, so an Astro 6-or-7 project on Node 20 is
already unsupported by Astro before the CMS enters the picture. This package
declares the wider Astro 5 range so it never blocks an Astro 5 consumer on
Node 18 or 20; npm enforces the narrower floor from Astro's own engines when
you install Astro 6 or 7.
@astrojs/react is optional, but if you install it the major must match your
Astro major — 4 with Astro 5, 5 with Astro 6, 6 with Astro 7. Each pair shares a
Vite major (6, 7, 8 respectively), and @astrojs/react declares no astro
peer, so npm will not catch a mismatch for you; a mismatched pair installs
two Vites and admin islands fail to hydrate at runtime. npx codeyam-cms
integrate picks the matching major for you.
Install
npm install @codeyam/cms
npx codeyam-cms integrateintegrate wires the CMS into the Astro project in the current directory. It is
idempotent — safe to run again any time.
What it does:
- Confirms you're in an Astro project (bails cleanly if not).
- Adds
@codeyam/cmsand@astrojs/reactto your dependencies. - Pushes
codeyamCms()into yourastro.configintegrations: []. - Drops the two editable config files the CMS reads:
src/data/cms.json— which GitHub repo/branch to commit to, and how sign-in works.src/data/collections.json— your custom collection registry.
- Prints next steps.
Options:
| Flag | Effect |
| --- | --- |
| --with-auth-worker | Also scaffold cms-auth-worker/ — a Cloudflare Worker OAuth relay for "Sign in with GitHub". |
| --with-sveltia | Also scaffold public/admin/ — the Decap/Sveltia editor bridge. |
Configure
Open src/data/cms.json and point it at your repo:
{
"repo": { "owner": "your-org", "repo": "your-site", "branch": "main" },
"authEndpoint": "/auth",
"auth": { "token": true, "worker": false }
}With auth.token: true (the default, zero-infrastructure path) an editor pastes
a GitHub token to sign in — nothing to deploy. Turn on auth.worker and deploy
the cms-auth-worker for a "Sign in with GitHub" popup instead. Both can be on.
siteUrl — only if the branch does not deploy where it looks like it does
Optional. The base URL that repo.branch actually deploys to. Leave it out and
the CMS assumes https://<owner>.github.io/<repo>/, which is right for an
ordinary single-track project site.
Set it when that assumption is wrong — most often a custom domain, or a repo
where different branches deploy to different sites (say main → a review
site, staging → the working site):
{
"repo": { "owner": "your-org", "repo": "your-site", "branch": "staging" },
"siteUrl": "https://your-org.github.io/your-site-staging/",
"auth": { "token": true, "worker": false }
}It sets two things: where "View live site" points, and which
deploy-status.json the publish watch reads to confirm a change went live. The
second one is why the two-branch case matters — pointed at the wrong site, the
watch reads a marker this branch's commits never change, so a publish is
reported as "still updating" instead of Live. (A marker that is simply
absent or unreachable is fine: unverifiable degrades to confirmed. It is a
different, real site that misleads it.)
repo.prefix — only if your site is not at the repo root
Optional. The directory within the repo that your site root maps to. Leave it out and the CMS commits to paths straight from the repo root — correct for an ordinary single-app repo, and what every install did before this field existed.
Set it when the site is one app inside a larger repo — a monorepo, or any layout
where src/content/ lives under a subdirectory:
{
"repo": { "owner": "your-org", "repo": "your-monorepo", "branch": "main", "prefix": "dashboard" },
"auth": { "token": true, "worker": false }
}With that set, editing lessons/intro commits to
dashboard/src/content/lessons/intro.md instead of src/content/lessons/intro.md.
The prefix is repo-side only: the admin UI, the review drawer, and the publish
checklist all keep showing site-relative paths, and your public URLs are
unaffected. Entry history and the co-editor drift check follow the prefixed path
too, so both keep working. Leading and trailing slashes are optional —
/dashboard/ and dashboard mean the same thing.
You can also set it from /admin/settings → Repository connection, in the "Directory in repo" field.
Start your dev server and open /admin.
Manual setup (without the CLI)
astro.config.mjs:
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import codeyamCms from '@codeyam/cms';
export default defineConfig({
integrations: [react(), codeyamCms()],
});codeyamCms() options:
| Option | Default | Meaning |
| --- | --- | --- |
| route | 'admin' | URL segment the dashboard mounts under (/admin, /admin/settings, …). Set to 'cms' for /cms. |
| ensureReactRenderer | true | Auto-register @astrojs/react if you haven't. Set false to manage React yourself. |
Mounting in a React app
The dashboard is not Astro-only. @codeyam/cms/react renders the same editor —
same screens, same islands, same staging and publish flow — from a plain React
route, so a Remix, React Router or Next app can host the CMS without an Astro
project anywhere in the picture.
Prerequisite.
@codeyam/cms/reactresolves to.tsxsource, so your host has to compile it:ssr.noExternal: ['@codeyam/cms']in a Vite-driven app,transpilePackagesin Next. Skip it and the server fails at runtime on the first.tsxrather than at build time — see Hosting the source entries on a Node server.
The CMS singletons are optional at import time. A project with no
src/data/settings.json or nav.json yet gets an empty Settings screen it can
fill in and save — never a route module that throws while your dev server is
importing it, which would 500 every route in the host app, not just the CMS.
The whole mount is one splat route: resolve the pathname to an admin screen,
load that screen's data in the loader, render <CmsAdmin>.
app/routes/content.$.tsx (Remix):
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { CmsAdmin, resolveAdminRoute, adminHeadTags, pageTitle } from '@codeyam/cms/react';
import { loadAdminRouteData } from '@codeyam/cms/react/server';
// Where you mount the dashboard. Your choice, not the package's — see below.
const BASE = '/content';
export async function loader({ request }: { request: Request }) {
const route = resolveAdminRoute(new URL(request.url).pathname, BASE);
if (!route) throw new Response('Not found', { status: 404 });
const data = loadAdminRouteData(route, { base: BASE });
if (!data) throw new Response('Not found', { status: 404 });
return json(data);
}
export function meta({ data }: { data: Awaited<ReturnType<typeof loader>> }) {
// `pageTitle` names the screen — the entry's own heading, the collection's
// label — which is how an editor tells two open tabs apart.
const head = adminHeadTags(data.shell, pageTitle(data.page));
return [{ title: head.title }, ...head.meta];
}
export default function ContentAdmin() {
return <CmsAdmin route={useLoaderData<typeof loader>()} base={BASE} />;
}The two entry points, and why they are separate
| Specifier | Runs | Exports |
| --- | --- | --- |
| @codeyam/cms/react | anywhere | CmsAdmin, AdminPage, resolveAdminRoute, adminRoutePath, adminHeadTags, pageTitle, pageWidth |
| @codeyam/cms/react/server | server only | loadAdminRouteData and the eleven per-screen load* functions |
Everything behind /react/server reads the filesystem — that is how the CMS
gets your content without a database. Keeping it behind its own specifier means
a bundler following @codeyam/cms/react never pulls fs into your browser
bundle. Import it only from code you already know is server-only (a Remix
loader, a .server.ts, a Next server component).
That guarantee is checked rather than asserted. This package's test suite
bundles every browser-reachable entry for the browser and scans the emitted
chunk for Node built-in residue — a surviving fs/path/util reference in
any shape, a process. access, the path shim's "win32" fingerprint — so it
fails here rather than rotting quietly into your production build. If you
worked around an earlier release by stubbing fs or path in your client build
(a Rollup/Vite resolveId hook returning an empty module, say), that stub is no
longer needed and is safe to delete. Keeping it is equally harmless, so there is
no need to touch your build config in the same commit as the upgrade.
Both entries ship their own declarations from dist/, so named imports resolve
under moduleResolution: "node", "node16" and "bundler" alike — you do not
need an ambient declare module '@codeyam/cms/react' shim. The components
themselves still ship as source, because your bundler has to compile them
against your React; only the types come out of dist/. That split is also why a
tsconfig stricter than this package's reports nothing inside
node_modules/@codeyam/cms — your program reads declarations, never our
.tsx.
base is one value: the whole mount
base is the entire prefix the dashboard is mounted under — /content, not
/content plus an implied /admin. Pass the same value to all three:
resolveAdminRoute(pathname, BASE) // which screen this path names
loadAdminRouteData(route, { base: BASE }) // its server-built hrefs
<CmsAdmin route={data} base={BASE} /> // every link it rendersThat one value is the whole contract. The link builder (adminHref) and the
route resolver (resolveAdminRoute) are exact inverses of each other under it,
so a link the dashboard renders always resolves back to the screen it was built
from. If you are carrying a workaround that passes one base for resolving and a
different one for building links, you can collapse it to this single value.
This is a prop rather than a package constant because /admin is frequently
already taken: most apps that want a content editor already have an admin area
of their own. Mount it at /content, /cms, /studio — whatever is free —
and every in-dashboard link agrees.
On an Astro site there is no base to pass: the mount is derived from Vite's
ambient import.meta.env.BASE_URL plus the integration's route option, which
is why the Astro install instructions above never mention it. Setting
codeyamCms({ route: 'cms' }) moves the routes and the links together.
Outside Vite neither value exists, which is exactly why the React entry takes
the mount explicitly.
siteBase, only if your site itself is served under a prefix
base is where the dashboard lives; siteBase is where your site lives.
They are different prefixes, and only site URLs — an entry's hero image, a media
file, a preview page — go through siteBase.
You usually do not need it: a host that mounts the dashboard at /content is
normally still serving its site from the root, which is the / default. Set it
only when the site itself is under a prefix (a GitHub Pages project site at
/repo/):
<CmsAdmin route={data} base="/content" siteBase="/repo/" />What you own, and what the package owns
Your host owns the document: <html>, <head>, and whatever chrome sits
outside the editor. adminHeadTags(shell, title) returns the <title> and the
<meta> values the dashboard needs (a noindex, nofollow on every admin page,
plus the sandbox marker when one is active), as data — adapt it to your
framework's head mechanism.
The package owns everything from the admin top bar inward. <CmsAdmin> derives
the page title and the shell width from the route itself, so you never have to
know that the entry editor wants a full-width shell while Settings wants a
reading measure.
If you want to place one admin screen inside a layout of your own, render
<AdminPage page={data.page} /> instead — the screen without the chrome.
Styles
Import the token sheet once, wherever your app imports global CSS:
import '@codeyam/cms/styles/tokens.css';If your host needs the emitted URL instead of the side effect — a Remix
links() export, a <link> you render yourself — ask for it with ?url, which
types as a string:
import tokensUrl from '@codeyam/cms/styles/tokens.css?url';
export const links = () => [{ rel: 'stylesheet', href: tokensUrl }];@codeyam/cms/styles/* is a source entry like ./react, so it falls under the
same requirement: the package has to be in your ssr.noExternal /
transpilePackages list, or the server build trips over the raw .css. See
Hosting the source entries on a Node
server.
Both spellings are typed for you through the package's exports and
typesVersions maps, so neither needs anything on your side. The separate
ambient *.css / *.css?url wildcards — for a host whose own resolution does
not go through those maps — are opt-in, because vite/client already declares
the same wildcards and two ambient declarations of one wildcard collide. If you
need them, name them in your env.d.ts:
/// <reference types="@codeyam/cms/styles" />or in your tsconfig's types array. A host that already has vite/client does
not.
The three public surfaces
Three CMS capabilities do not live in the dashboard at all — they live on the
public site, and on an Astro install the integration injects them for you.
A React host has no astro:config:setup hook, so it mounts them itself. Each is
three lines, each is independent, and omitting one degrades exactly one
capability and nothing else:
| Surface | Route you add | Omitting it costs |
| --- | --- | --- |
| Deploy marker | /deploy-status.json | The publish stepper can never confirm a build is actually live |
| Preview index | /previews/:token | Reviewer links stop working — there is no shareable list |
| Staged-preview gate | (one client import) | "Preview changes" can no longer show staged edits on the real site |
1. The deploy marker. A resource route serving deployMarkerBody():
// app/routes/deploy-status[.]json.ts (Remix)
import { deployMarkerBody } from '@codeyam/cms/lib/buildEnv';
export function loader() {
const { body, headers } = deployMarkerBody();
return new Response(body, { headers });
}The marker's value must be baked from your build-time environment, not
computed per request. It answers "which commit is this deployment serving?",
and the deployed site is the only witness to that. A marker recomputed on every
request reports now instead of this build, always looks fresh, and silently
defeats the check it exists for — the publish stepper would report Live while
readers were still being served the old page. On a host that prerenders, make
sure this route is prerendered; on a dynamic host, bake GITHUB_SHA /
GITHUB_RUN_ID into the build output rather than reading them at request time.
2. The preview index. resolvePreviewIndex decides both what the page
contains and whether it exists:
// app/routes/previews.$token.tsx (Remix)
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import PreviewIndexPage, { previewIndexHeadTags } from '@codeyam/cms/react/PreviewIndexPage';
import { resolvePreviewIndex } from '@codeyam/cms/server/previewIndexPage';
export async function loader({ params }: { params: { token: string } }) {
const data = resolvePreviewIndex(params.token);
// `null` means no token is configured, or this one does not match. Both are
// a 404 — telling the two apart would confirm whether a link exists at all.
if (!data) throw new Response('Not found', { status: 404 });
return json(data);
}
export function meta({ data }: { data: Awaited<ReturnType<typeof loader>> }) {
const head = previewIndexHeadTags(data);
return [{ title: head.title }, ...head.meta];
}
export default function Previews() {
return <PreviewIndexPage data={useLoaderData<typeof loader>()} />;
}Keep the literal /previews/:token path, and mount it outside your site
layout — the page renders its own minimal chrome on purpose, so a review tool
never looks like a published section of the site. The token is the entire access
control, exactly as on Astro. Render the robots tag: unguessable is not
secret, and this page links to every unpublished draft you have.
If your site is served under a path prefix, pass it as base — that is the
public site's base, which is not necessarily where you mounted the dashboard.
3. The staged-preview gate. One import in your root client entry:
import '@codeyam/cms/client/stagedPreview';This is the same module Astro's injectScript names, so both hosts run
identical code and agree on the localStorage keys they share — which is what
lets the dashboard stage an edit and the public site show it. The gate is inert
until a page is opened with ?cms-preview=1, so importing it costs a published
visitor nothing.
Content collections
Define your own collections in src/content.config.ts, built from the package's
shared SEO fields and sandbox-aware loader so the CMS knows how to edit them:
import { defineCollection } from 'astro:content';
import { z } from 'astro/zod';
import { seoFields, draftField, collectionLoader } from '@codeyam/cms/content';
const blog = defineCollection({
loader: collectionLoader('blog'),
schema: z.object({
title: z.string(),
date: z.coerce.date(),
...draftField,
...seoFields,
}),
});
export const collections = { blog };Drafts
Every collection carries a Draft toggle in the editor, which writes
draft: true into the entry's frontmatter. Declaring ...draftField in your
schema is what lets that flag reach your pages — a Zod object strips keys it
does not declare, so a schema without it drops draft silently and the toggle
has no effect on your site, with no build error to tell you. (Passing such a
collection to publishedEntries is a type error, so once you filter, a
forgotten draftField surfaces at compile time rather than in production.)
Declaring the field only preserves the flag. To act on it, filter with
publishedEntries, which hides drafts in a production build and keeps them
visible under astro dev so they stay previewable:
---
import { getCollection } from 'astro:content';
import { publishedEntries } from '@codeyam/cms/content';
const posts = publishedEntries(await getCollection('blog'));
---Filter your dynamic routes too. Hiding a draft from a listing while
getStaticPaths still generates its page leaves the entry publicly reachable at
its own URL — hidden from navigation but not private, which looks like working
draft support and is arguably worse than none:
export async function getStaticPaths() {
const posts = publishedEntries(await getCollection('blog'));
return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
}Pass { includeDrafts: false } to hide drafts everywhere including dev, or
{ includeDrafts: true } to show them everywhere.
Absent means published. The CMS writes draft: true when the toggle is on
and removes the key entirely when it is off — it never writes draft: false. An
entry with no draft key is live, so adopting this field changes nothing about
the content your site already has.
Preview links
A preview link puts an unpublished entry live at an unguessable URL so it can
be shared for review before it becomes part of the site. "Preview link" on an
entry row clones the entry into src/content/<collection>/preview-<token>.md;
your existing per-entry route builds that file into a real page at
/<collection>/preview-<token>, in the real layout. No new route, no second
build, no server — which is what makes it work on a static host.
Declare ...previewFields beside ...draftField so the marker survives Zod:
import { seoFields, draftField, previewFields, collectionLoader } from '@codeyam/cms/content';
const blog = defineCollection({
loader: collectionLoader('blog'),
schema: z.object({
title: z.string(),
date: z.coerce.date(),
...draftField,
...previewFields,
...seoFields,
}),
});Then make two render-site changes. Both are load-bearing: skip either one and the feature is broken rather than merely incomplete.
1. Route previews with routableEntries, not publishedEntries. This is the
mirror image of the draft rule above. A preview is excluded from every listing so
that nothing links to it — but it must still be built, or the link you hand a
reviewer 404s:
import { routableEntries, isPreview } from '@codeyam/cms/content';
export async function getStaticPaths() {
const posts = routableEntries(await getCollection('blog'));
return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
}Keep publishedEntries on your listings — it now excludes previews too, so an
unlisted page stays unlisted. Pass noindex={isPreview(post)} to your SEO
component while you are here: noindex, nofollow keeps a leaked URL out of search
results. (A robots.txt Disallow would not — it stops crawlers from fetching
the page, so they never see the noindex.)
2. Filter preview URLs out of your sitemap. sitemap.xml is public. A preview
left in it publishes the very URL the token exists to hide, and no noindex can
undo that disclosure:
import sitemap from '@astrojs/sitemap';
import { isPreviewUrl } from '@codeyam/cms/lib/previewPages';
export default defineConfig({
integrations: [sitemap({ filter: (page) => !isPreviewUrl(page) })],
});The link is live only after you publish. Creating a preview stages a pending change like any other edit, so the URL resolves once that change is committed and the site redeploys — the dashboard says which of the two states each link is in.
preview- is a reserved slug prefix. The editor refuses to create an ordinary
entry under it, since every filter would classify that page as a preview and
silently keep it off the site.
Putting a preview live
Once a preview has been reviewed, Make live on its row promotes it: the live
entry named by previewOf is rewritten with the preview's content (with the
previewOf / previewCreatedAt markers stripped), and the preview-<token>.md
file is deleted. Both land in one commit, so the swap is atomic. If the target
slug has no page yet, the promote creates it — the row's confirm says which of the
two it is doing, and the row names the page it will land on before you confirm.
Promoting deletes the preview file, so the shared URL 404s after the next deploy. That is intentional: a link that keeps serving the old content after the page has gone live is a leak, not a convenience. Re-share the real page URL.
A promote is refused if the live page changed underneath the preview. The promote's baseline is the target's current content, so if someone edited that page while the preview was under review, the commit stops with the same stale-baseline conflict any other edit would raise, rather than silently overwriting their work.
Password-protected previews
A preview link can carry a password. Because the site is statically built with no server, that can only honestly mean one thing: the content is encrypted at rest. When you set a password while creating the link, the dashboard encrypts the entry's title and body in your browser (AES-GCM, key derived from the password with PBKDF2-SHA-256) and commits only the ciphertext. The markdown file in the repo, the built HTML, and the CDN copy all hold unreadable bytes.
This is why it is done as encryption rather than as a password prompt. On a static
site a prompt that merely compares a password ships the content in the same
document it guards — View Source defeats it instantly. Encrypting at rest is what
survives View Source, curl, a leaked CDN cache, and a public git history.
A visitor at the preview URL gets a password field inside the real page layout; entering the password derives the key client-side and renders the markdown in place. Only the article body is client-rendered — the page chrome is still the ordinary build output.
What is encrypted: the entry's title and body. A locked preview stores the
placeholder title Protected preview, and the summary, description, cover image
and SEO override fields are dropped rather than committed in plaintext beside the
ciphertext.
What is not: that the URL exists, the entry's date, and the fact that something is there. Anyone holding the ciphertext can guess the password offline for as long as they like, which is why password length is the whole defense — the dashboard enforces a minimum and grades what you type.
The password is stored nowhere. Not in the dashboard, not in the repo, not in
localStorage. If it is lost, the only way forward is to discard the preview and
make a new one. Editing a locked preview asks for the password and re-encrypts on
save; opening one in the editor without unlocking shows a read-only Locked
preview state, so base64 is never presented as editable content.
Promoting a locked preview asks for the password once, decrypts, and writes
the plaintext to the live page — the live entry never carries previewLock, and
a promote without the password is refused rather than committing ciphertext onto
a page people can read.
Rotating a password does not erase the old ciphertext from git history. A new password re-encrypts and commits, but the previous bytes remain in the repo's history forever. If a password leaks, treat the content it protected as leaked.
The Previews section and the shareable list link
Preview links accumulate across collections, and handing a reviewer six separate
URLs is how one gets missed. /admin/previews gathers every preview page on
the site into one place — grouped by collection, newest first, each row showing
its shareable URL, the live page it stands in for, when it was created, and a
link straight to the source entry. A preview created a moment ago and not yet
committed appears immediately, under a banner saying it is not shared yet, so the
section is never mysteriously empty right when an editor first uses it.
Wherever a preview link appears, a banner says whether it actually works — and offers Publish where it does not. This is a status block with its own colour and a Publish now primary, not a muted sentence at the end of a row: the one fact that decides whether a link can be shared should not be the least visible thing next to it.
The status is observed, not inferred. "Committed" and "reachable" are different facts — a published preview still 404s while GitHub Pages rebuilds, commonly for 1-3 minutes — so once the change is committed the admin fetches the URL and reports what came back. Three outcomes, and the third matters as much as the other two:
- Live — the page loaded. Green,
Open ↗enabled, safe to share. On a password-protected preview the wording changes rather than claiming anyone with the link can read it, because there the link alone opens nothing. - Not live yet — the fetch returned a 404. The site is still rebuilding.
Open ↗stays inert rather than handing you a broken link, and a Check again button re-runs the probe. - Couldn't check — the fetch could not complete at all: offline, an
extension blocking it, or CORS on a custom-domain site whose admin is served
from another origin. This is shown as its own neutral state and never as a
failure, and
Open ↗stays enabled. A link we could not verify is not a link we know to be broken, and marking it red would be as misleading as the false "anyone with this link can see the page" it replaced.
From that page an editor can mint one shareable list link —
/previews/<token> — an unguessable, site-wide URL serving the same list as a
plain public page. Anyone handed it (a client, a board member, a copy reviewer
with no GitHub account and no CMS access) lands on a page of links to every
current preview, with no sign-in. It stays current by construction: a preview
created after the link was shared appears on it without re-sharing anything.
It is opt-in. No token means no page in dist/ at all — a site that never
creates one ships exactly what it shipped before, including an unchanged
dashboard. Creating a link is an ordinary staged change on settings.json, so it
rides the same review-and-commit flow as every other edit, and the URL does not
resolve until you publish.
Rotating is the revoke, and it takes effect on the next deploy. Rotating mints a new token; the old page stops being emitted, so the previous URL genuinely 404s rather than merely being hidden. Until that deploy lands the old link keeps working — the dashboard says so at the moment you rotate.
Unguessable is not secret. The token is committed to settings.json, so
anyone with read access to the repo can find it — the same property the
preview-<token>.md filenames already have. What it buys is "not linked, not
indexed, not enumerable", never confidentiality: anyone holding the URL can read
every preview listed on it. Content that must be unreadable is what
password-protected previews (above) are for.
No extra sitemap wiring. The isPreviewUrl filter from the preview-links
section already excludes the index, so a site that wired
sitemap({ filter: (page) => !isPreviewUrl(page) }) once is covered — including
sites that upgraded before this feature existed, which get it from npm update
alone.
Previewing staged changes on your real pages
Preview changes — in the review drawer and on the Publish page — opens your
site's own pages with the staged edits patched in, in the editor's browser,
with no commit and no build. It works because the admin and your site are the
same Astro site on the same origin: the staging set already lives in the
editor's localStorage, so a small script the integration injects into every
page can read it directly.
Nothing is required to make this work. A site that adopts none of the below
still previews — the script falls back to matching the page by shape (the first
<h1> for the entry's label, the article's last block for its body) and the
preview banner says "matched by shape, so this is an approximation" whenever it
does. What the markers buy you is an exact patch instead of a good guess.
Add three attributes to your entry template. They are inert — no styling, no behaviour, nothing about the rendered page changes:
---
import { CMS_BODY_ATTR, CMS_ENTRY_ATTR, CMS_FIELD_ATTR } from '@codeyam/cms/lib/stagedPreview';
const { post, Content } = Astro.props;
---
<article {...{ [CMS_ENTRY_ATTR]: `blog/${post.id}` }}>
<h1 {...{ [CMS_FIELD_ATTR]: 'title' }}>{post.data.title}</h1>
<time {...{ [CMS_FIELD_ATTR]: 'date' }} datetime={post.data.date.toISOString()}>
{post.data.date.toLocaleDateString()}
</time>
<div {...{ [CMS_BODY_ATTR]: '' }}><Content /></div>
</article>data-cms-entry="<collection>/<slug>"— marks the element that renders one entry. Every other marker is looked up inside it.data-cms-field="<name>"— marks an element rendering one frontmatter field. A<time>element, or a field nameddate, is formatted the way the CMS editor formats it, so the editor and the preview never disagree about a date.data-cms-body— marks the element rendering the markdown body.
A field your template does not mark keeps whatever the published page shows, rather than being guessed at.
A list field needs a fourth attribute, because a list has no single text
form to write into one element — the preview has to repeat an element once per
row instead. Mark the container and leave one item inside it as the template:
<ul {...{ [CMS_FIELD_LIST_ATTR]: 'outcomes' }}>
{post.data.outcomes.map((row) => (
<li {...{ [CMS_FIELD_ATTR]: 'outcome' }}>{row.outcome}</li>
))}
</ul>data-cms-field-list="<name>"— marks the container rendering onelistfield. Its first element child is the item template: every staged row is rendered as a clone of it, so the rows arrive with your classes, nesting and styling already correct and only their values replaced.- Inside an item, sub-fields are ordinary
data-cms-fieldmarkers valued with the row's key (outcomeabove, notoutcomes). A multi-key row — a transcript line with a heading and a timestamp — marks one element per key and each is filled independently. - An item that marks no sub-field has its text set from the row's single value, so a one-key list needs no inner marker at all.
Deleting rows works the same way: the container ends with exactly as many items as you staged, so cutting a list from four rows to two shows two.
What it cannot do: reveal a section your page left out. If your template
renders nothing at all when a list is empty, there is no container to mark and
no item to clone, and inventing one would mean the CMS authoring your site's
markup. Those rows are named on the banner instead — "List changes to
outcomes cannot be shown here — check them after publishing". To preview
them, mark a container your template always renders, even when the list is
empty.
Four more mark the site-wide surfaces — the header, footer and menu that every page carries:
data-cms-nav-item="<url>"— marks one menu item, keyed by the url it currently points at. Put it on the<li>or on the<a>; either works.data-cms-setting="<key>"— marks an element rendering asettings.jsonvalue as text (your site title in the header, your footer line).data-cms-setting-href="<key>"— marks a link whosehrefa setting drives. Separate from the above because a link can render a setting either way, andcontactEmailin particular becomes amailto:built from the address and the site title.data-cms-list="socials"— marks the footer's socials list, which is rebuilt from the staged array by cloning the first link.
Menu preview without markers. An unmarked menu item is found by the url its
link points at: inside a <nav> (or role="navigation"), the anchor whose
href is the item's published url IS that item, so a plain
<li><a href="/blog">Blog</a></li> previews a rename, a re-point and a removal
with no template edit at all. The search is confined to menu subtrees on purpose
— an unscoped match would also claim a body link pointing at the same page, and
rewriting an article card as though it were a menu edit is worse than showing
nothing. A relabel rewrites only the text node holding the old label, so an
anchor carrying an inline logo or icon beside its text keeps it.
Markers still win wherever both apply, and they still buy two things the url
match cannot reach: a menu that lives outside any <nav> element, and dropdown
items, whose parent label renders in a <summary> with no url to key on.
Settings preview without markers, too. An unmarked settings value is found by
the value the published page rendered: the staged change carries the old string
as well as the new one, so the element whose whole text is the old string gets
rewritten. That is a tighter rule than the shape guess entries fall back to, and
it is why a bare <span>{footerText}</span> previews with no template edit at
all. A paragraph that merely mentions the value is left alone — the match is on
a whole text node, not a substring.
When a staged settings key is neither marked nor rendered anywhere on the page
you are looking at, the banner names it: "Settings changes to description
cannot be shown here — check them after publishing". It is never silently
skipped, for the reason the whole feature exists: an editor who cannot see a
change is fine as long as they are told, and one who is told nothing publishes
believing they checked.
What it costs a visitor: one location.search check. The injected script
reads a single query flag (?cms-preview=1) and returns; the renderer sits
behind a dynamic import, so an ordinary reader downloads none of it. It makes no
network calls either — everything the preview shows comes from the viewer's own
browser, which is also why the flag can be a plain query param rather than an
auth check on your public site. A visitor with nothing staged sees exactly the
published site, so no unpublished content is ever exposed to anyone else.
Previewed pages carry noindex, nofollow while the flag is on.
Scope, surface by surface. Entry pages patch their marked fields, or fall back to matching the page's shape. Listing pages get a staged entry cloned into place in the list's own sort order. The menu patches relabels, re-points, removals and additions, on marked items and on unmarked ones by url. Site settings patch marked elements, and unmarked ones by value.
What is not previewable is reported rather than skipped: a menu item that moves
between the flat list and a dropdown, a menu item this page renders outside any
<nav>, a settings key this page never renders, and
a new entry whose page does not exist yet all surface on the banner or in the
preview's page switcher, which lists every staged change and greys the ones it
cannot show beside the reason.
Wiring it up outside Astro: the runtime reads two localStorage keys.
codeyam-cms:pending-changes is the staging set, and
codeyam-cms:preview-manifest is the map of which staged change previews on
which page. With only the first, the banner renders and noindex is applied but
nothing is patched — which reads as a broken integration rather than a missing
manifest, so it is worth naming. An Astro host gets the manifest written for it:
PreviewChangesButton builds it and stores it on the way out. A host that opens
the preview itself must build the manifest with buildPreviewManifest and write
it under the manifest key, exported as PREVIEW_MANIFEST_KEY.
A collection addressed from frontmatter needs its field values passed in. If
a collection's paths template carries a token other than :slug — say
/academy/:series/:slug — the address cannot be built from the staging set
alone, because a staged change holds the entry as raw markdown rather than as
parsed frontmatter. Pass the values as fields and the token resolves; omit
them and the change reports itself as unavailable with the field named, which is
honest but not previewable. One line derives them:
import { buildPreviewManifest } from '@codeyam/cms/lib/stagedPreview';
import { pageTokenFieldsForChanges } from '@codeyam/cms/lib/stagedPreviewFields';
const targets = buildPreviewManifest({
changes,
paths: pagePaths,
existingSlugs,
fields: pageTokenFieldsForChanges(changes, pagePaths),
});The helper lives in its own module rather than inside stagedPreview so that
the frontmatter parser stays out of the staging path for callers who do not need
it — a :slug-only site never pays for a parse.
Extending the built-in collections
The four built-in collections (pages / blog / events / team) expose a
fixed set of editable fields. If your site's frontmatter carries extra keys — a
blog.embedUrl, a team.active toggle — declare them in
src/data/collections.json so the dashboard edits them instead of merely
preserving them. Extras are appended after the collection's core fields and
before the SEO group; declaring none keeps today's behavior exactly.
{
"collections": [],
"builtins": {
"blog": [
{ "name": "embedUrl", "label": "Embed URL", "type": "text", "optional": true },
{ "name": "embedHtml", "label": "Embed HTML", "type": "textarea", "optional": true }
],
"team": [
{ "name": "active", "label": "Active", "type": "boolean" }
]
}
}Field type is one of text · textarea · number · date · image ·
boolean · select · list (see Repeatable list fields) ·
html (see Custom HTML fields) · reference (see
Reference fields). An extra whose name
collides with a core field, the SEO group, or a reserved key (draft, body,
slug, id) is ignored.
Where each collection appears on your site
The dashboard links out to the real page an entry lives on — the "View on site"
chip in the editor, the shareable URL on a preview link, the address on a share
card. Where that page IS, though, is a property of your routes, not of the CMS.
Left to guess, it assumes /<collection>/<slug>, which is right for a blog and
wrong for most other things.
The paths map in src/data/collections.json tells it the truth:
{
"collections": [],
"paths": {
"blog": "/blog/:slug", // a page per entry
"academy": "/academy/:series/:slug", // …addressed by its own fields
"pillars": "/donate", // one shared page, every entry on it
"siteIntegrations": null // no page of its own
}
}A collection absent from the map keeps the /<collection>/<slug> guess, so
declaring nothing behaves exactly as before.
Tokens. :slug stands for the entry's file name. Any other :name is
filled from that entry's own frontmatter — /academy/:series/:slug reads the
lesson's series field. That is what lets a collection be addressed by
something only the entry knows, which no amount of collection-level config could
supply.
Three things to know about the values:
- They are used as written, trimmed and URL-encoded per segment — not
slugified. The frontmatter value IS the routing key your route already matches
on, so
series: Getting Startedgives/academy/Getting%20Started/…, notgetting-started. If your route wants a slug, store a slug. Numbers and booleans stringify. - An unfilled token means no address, not a broken one. An entry whose
seriesis blank or missing does not get/academy/:series/deploying— a URL that would 404, which is exactly what this map exists to prevent. It gets no link at all, and the chip says which field to fill in. Fill it and the link comes back. - A template with no
:slugis still one shared page even when it carries other tokens — but those tokens still have to resolve, and an entry that cannot fill them is reported the same way.
Ordering entries across a collection
When a collection's entries have a running order — lesson 1, 2, 3 of a series — that order lives in a number on each entry, in a separate file per entry. Nothing in the admin could see the whole sequence, so two lessons both claiming position 3, or a series that jumps 1, 2, 4, stayed invisible until someone noticed the site rendering them wrong.
Declare the rule and the collection's list will tell you:
{
"order": {
"lessons": { "field": "position", "within": "series" }
}
}fieldis the frontmatter key holding the entry's place in the sequence. It must be a real number:position: 3, notposition: "3".withinis optional. Omit it and the whole collection is one ordered sequence, which is right for a flat collection. Give it a frontmatter key and entries are grouped by that value and each group is checked on its own — so two lessons in different series can both be position 3 without complaint.
A collection you do not list here is not checked, so adding this map changes nothing for the rest of your site.
Why you have to declare it. The admin cannot work this out on its own. Two
entries sharing position 3 is a collision inside one series and perfectly correct
across two, and which one you meant is a fact about your content model — the same
reason paths above is declared rather than guessed. An admin that assumed every
field named position was a flat sequence would cry wolf on every grouped
collection.
What it reports, above the collection's list:
- Two entries claiming the same position — named, and linked into their editors. Both rows are marked so you can see which they are.
- A gap in the run — "getting-started jumps from 2 to 4". Reported more quietly, because a hole is normal while you are reorganising a series.
- An entry with no usable position — missing entirely, or written as text
(
position: "3"). A quoted number is the dangerous one: the file looks right, and the site sorts it as text.
It warns; it never blocks. You can keep editing, and Save is never refused over it. A duplicate position is a fact about a set of entries, not about the one in front of you — and of two entries claiming position 3, neither is more at fault than the other. Blocking would trap you behind someone else's problem. That is deliberately unlike the required-field and reference checks, which are about the single entry you are editing.
Not the same as reordering a list field. A list field has its own
drag-reorder, and it orders rows inside one entry. This orders entries against
each other.
Custom HTML fields
An html field is a named block of markup the editor writes and your page
inserts as-is. It is the answer to "let them restyle the hero, but not the rest
of the page" — HTML in the entry BODY replaces the whole body, and an html
field replaces one region of it.
{ "name": "heroHtml", "label": "Hero block (HTML)", "type": "html", "optional": true }---
const heroHtml = entry.data.heroHtml?.trim() ?? '';
---
{heroHtml && <div set:html={heroHtml} data-cms-html-field="heroHtml" />}It stores a plain string, exactly as textarea does — only the input differs
(monospace, taller, non-wrapping, spellcheck off) — so a schema key you already
had as z.string().optional() becomes an HTML field with no migration and no
rewrite of existing entries. Test the value for blank OUTSIDE the element rather
than rendering an empty set:html: an empty wrapper is invisible until it
collapses a grid column, and then the cause is invisible too. html is not
offered as a list sub-field — a blob of markup per repeatable row is a shape
nothing needs and every consuming renderer would have to handle.
data-cms-html-field is the marker that makes a staged edit to the field preview
as markup. Marking it with the ordinary data-cms-field would write the value in
as text, printing the editor's own tags onto the page.
Where the line is. Your built site renders the editor's HTML exactly as
written; that is Astro's own behaviour for markdown and stripping it would defeat
the purpose. The CMS's PREVIEWS sanitize against an allowlist, because /admin
runs in your site's origin, where the GitHub token lives. An editor can turn that
off for their own browser (never for yours) and the preview banner announces it
while it is on.
What is yours and what is the package's. The package supplies the authoring:
the field type, its input, the preview fidelity, the sanitizer, and the markers.
Routes and layouts stay yours — a package cannot know your URL shape or your
layout components, so "a page whose body is entirely custom HTML" is a branch in
YOUR route, not a mode in the CMS. The dogfood site's src/pages/[slug].astro
is a worked example of that branch, and CMS_SETUP.md walks through it.
Redefining the SEO group
The universal SEO & Social panel defaults to
seoTitle / seoDescription / socialImage / canonicalUrl. If your content
uses different keys, add a seo section — it replaces the default group across
every collection. The names must match the keys in your seoFields schema.
{
"collections": [],
"seo": [
{ "name": "metaTitle", "label": "Meta title", "type": "text", "optional": true },
{ "name": "metaDescription", "label": "Meta description", "type": "textarea", "optional": true },
{ "name": "ogImage", "label": "Social image", "type": "image", "optional": true }
]
}Declaring your own site settings
The Settings screen edits five built-in scalars (site title, public URL,
description, contact email, footer text) plus your social links. If your site has
its own site-wide strings — banner copy, a hero eyebrow, a search placeholder —
add a settings section and they become editable there too.
They are ordinary field definitions, so you get every field type: a boolean is
a real toggle, a select a real dropdown, and hint is the guidance shown under
the input.
{
"collections": [],
"settings": [
{
"name": "announcementText",
"label": "Announcement banner",
"type": "text",
"optional": true,
"hint": "One line shown across the top of every page."
},
{
"name": "announcementEnabled",
"label": "Show the announcement banner",
"type": "boolean",
"optional": true,
"hint": "Turn the banner off without losing the copy you wrote."
}
]
}The values land as top-level keys on src/data/settings.json, and a template
reads them the ordinary way:
---
import settings from '../data/settings.json';
---
{settings.announcementEnabled && <p class="banner">{settings.announcementText}</p>}A boolean writes a real JSON true / false and a number a real number, so
what your template reads is what you would have typed by hand. Clearing an
optional text field removes its key rather than writing "".
Three things worth knowing:
Not every field type is available here.
text,textarea,number,boolean,select,dateandhtmlall work.listandimagedo not — both need the media picker that the entry editor has and the Settings screen does not, so a declared field of either type is ignored rather than rendered as a broken input. Put repeating or image-backed data in a collection.This is yours to author, not the dashboard's to edit. Like
seoandpaths, the collection builder never writes this section — it only preserves it. Edit it in your repo.A name that collides with a built-in setting is ignored. Declaring a field called
siteUrlorsocialswould shadow a key the CMS already owns, so those lines are skipped (as are duplicates) and the rest of your declaration still renders. A stray line cannot take the Settings screen down.
If your settings.json already carries keys the CMS has no input for, they keep
working exactly as before — they are preserved untouched across every save.
Declaring one is what turns it from stored-but-uneditable into an input on the
Settings screen.
Reference fields
A reference field points at an entry in another collection. It stores that
entry's slug — a plain string, exactly like a text field — so a schema key
already typed z.string() adopts one with no migration, and a value written
before the field was declared still round-trips. Only the input and the
validation differ.
It exists because a slug in a text box is a typo waiting to happen, and the
damage is invisible. A lesson whose series names a series that does not exist
has no address under /academy/:series/:slug, so it silently drops out of every
listing with nothing in the dashboard saying why. The CMS already knows which
slugs are valid — they are the target collection's own entries — so it offers
them instead of asking you to remember them.
Declare the target with collection, which is to reference what options is
to select:
{
"collections": [
{
"id": "series",
"label": "Series",
"singular": "series",
"fields": [{ "name": "title", "label": "Title", "type": "text" }]
},
{
"id": "academy",
"label": "Academy Lessons",
"singular": "lesson",
"fields": [
{ "name": "title", "label": "Title", "type": "text" },
{
"name": "series",
"label": "Series",
"type": "reference",
"collection": "series",
"optional": true
}
]
}
]
}The editor renders a picker listing the target's entries by title with the slug
beside them (Foundations — foundations), and the entry's frontmatter carries
just series: foundations.
Four behaviours are worth knowing, because each one is a deliberate choice:
- Drafts are selectable, and marked
(draft). A lesson and its series are usually written in the same sitting, so withholding a draft target would make the picker refuse the entry you most likely came to choose. Preview links are excluded — they are unlisted stand-ins, never a deliberate reference target. - A value naming no entry is kept, not erased. It stays selected, marked
— no such entry, with the reason under the input. Silently rewriting it the moment someone opened the entry is the exact data loss a picker should prevent. - A dangling reference blocks staging — unless the entry arrived that way. It uses the same machinery as a blank required field, including the same escape hatch: an entry already broken in the repo is reported inline but still saveable, so you can repair it instead of being trapped in it. An entry you break in this session, or a copy created from a broken one, is refused.
- An unresolvable target degrades quietly. A
referencewith nocollection, or one naming a collection your registry does not declare, falls back to a plain text input and validates nothing. A stray line incollections.jsoncosts you one picker, not the screen.
A blank reference is the ordinary required/optional question — mark it
"optional": true if an entry may legitimately have none.
reference is declarable in collections.json but is not offered in the
dashboard's collection builder, which has no UI for choosing a target
collection. select works the same way.
Repeatable list fields
A list field is a repeatable group of scalar sub-fields, edited in the
dashboard as add / remove / reorder rows. It's what lets a collection hold
structured, repeating content — a chapter's leads (each a name + role) or
its links (each a label + url) — and round-trips to an array-of-objects in
the entry's frontmatter. List fields work in a custom collection or as a built-in
extra; register the collection in src/data/collections.json:
{
"collections": [
{
"id": "chapters",
"label": "Chapters",
"singular": "chapter",
"fields": [
{ "name": "title", "label": "Title", "type": "text" },
{
"name": "leads",
"label": "Leads",
"type": "list",
"fields": [
{ "name": "name", "label": "Name", "type": "text" },
{ "name": "role", "label": "Role", "type": "text" }
]
},
{
"name": "links",
"label": "Links",
"type": "list",
"fields": [
{ "name": "label", "label": "Label", "type": "text" },
{ "name": "url", "label": "URL", "type": "text" }
]
}
]
}
]
}A chapters entry then edits as:
---
title: Chapter 3 — Building for the Web
leads:
- name: Dr. Amara Osei
role: Faculty lead
- name: Jonah Feld
role: Student coordinator
links:
- label: Syllabus
url: https://intech.harvard.edu/ch3/syllabus
---A list's fields are scalar only (text · textarea · date · image ·
boolean · number) — nested lists are not supported, matching the two-level
cap of the frontmatter engine. An empty list serializes to key: [], and a row
left wholly blank is dropped on save. You can also add a list field from the
Create collection builder in the dashboard: pick the Repeatable list type
and define its sub-fields inline.
Validating field values
optional answers one question about a field — may it be left empty? Two more
keys answer the questions that come after it: is the value the right shape,
and is it required because of another field.
Both are plain data on the field, so they travel in collections.json exactly
like a select's options do, and the same rule is enforced in the editor and
importable into your own tests.
pattern — the shape of a value
A field that is filled in with the wrong shape passes a blankness check and
fails on the site. A lesson runtime typed 12 minutes instead of 12:30 costs
the series its total and the lesson its JSON-LD duration; a transcript chapter
stamped 1:5 is dropped from the "In this lesson" index without a word. Nothing
about either value is blank, so nothing in the dashboard used to mention them.
{
"name": "duration",
"label": "Runtime",
"type": "text",
"optional": true,
"pattern": "^[0-9]{1,3}:[0-5][0-9]$",
"patternMessage": "Runtime reads minutes and seconds, like 12:30. The series total and this lesson's JSON-LD duration are both computed from it."
}Four things are worth knowing:
- You anchor it, not us. The string is compiled with
new RegExp(pattern)exactly as written, so"\\d+"matches anywhere — because that is what it says. Write^…$when you mean the whole value. - A blank value never trips it. Blankness is
optional's question and it already has an answer; a blank optional field is omitted from the written frontmatter anyway. Without this rule every optional field carrying apatternwould be permanently invalid. - Write the
patternMessage. It is what the editor reads under the input. Say what the field wants and what going wrong costs — "Runtime is invalid" is the failure this pair exists to fix. Omit it and a generic line stands in. - A
patternthat will not compile is ignored. That one field validates as it did before rather than throwing, so a stray line incollections.jsoncosts you one rule, not the screen.
requiredWhen — required because of a sibling
Some fields are optional right up until another field is filled in. A figure's caption is the image's alt text, so it is optional on an empty figure and a defect on one with a picture. Name the sibling:
{
"name": "figures",
"label": "Figures",
"type": "list",
"optional": true,
"fields": [
{ "name": "image", "label": "Image", "type": "image", "optional": true },
{
"name": "caption",
"label": "Caption",
"type": "text",
"optional": true,
"requiredWhen": "image"
}
]
}"Filled in" is decided by the same rule that decides what gets written to frontmatter, so the condition cannot drift from the file. Resolution is same-scope: a sub-field names another sub-field of its own row, a top-level field names another top-level field. A name that is cross-scope, unknown, a self-reference, or a list is inert — a reordered row gives a cross-scope reference no unambiguous meaning, and quietly blocking on one would be worse than ignoring it.
Both rules follow the pre-existing escape hatch
They feed the same gate blank required fields already do, including its most important property: a fault the entry arrived with is reported but does not block. A lesson committed with an unreadable runtime shows the error inline and still saves, so you can fix an unrelated typo in it — a CMS that refused would be a CMS that cannot repair the entries most in need of repair. Break the value yourself in the editor and Save greys out, with the footer naming the field.
Running the same rules in your own tests
The validator is exported, so a repo test can assert your committed content against the very registry the editor enforces — rather than a second copy of the regexes that can drift from it:
import { getCollection } from 'astro:content';
import { validateFieldValues } from '@codeyam/cms/lib/entryEditor';
import collections from './src/data/collections.json';
const academy = collections.collections.find((c) => c.id === 'academy')!;
it('every lesson carries a readable runtime and timestamps', async () => {
for (const lesson of await getCollection('academy')) {
expect(validateFieldValues({ fields: academy.fields, values: lesson.data })).toEqual([]);
}
});It reports only the two value rules above. Blank required fields need no schema
beyond optional, and dangling references need the target collection's
contents, which this function is not given.
Advanced: import the engine directly
The headless content engine is exported for consumers who want the logic without the full dashboard:
import { parseEntry } from '@codeyam/cms/lib/frontmatter';
import { loadCmsConfig } from '@codeyam/cms/lib/cmsConfig';Admin React components and the theme are exported too
(@codeyam/cms/components/admin/StagingBar.tsx,
@codeyam/cms/styles/tokens.css) for advanced composition.
How it ships
The package ships in two halves, and which one you get is decided by the specifier you import — you never configure it.
Compiled (., ./integration, ./lib/*, ./server/*, ./client/*,
./react/server). Built to ESM and CommonJS with .d.ts declarations and
source maps. These are the entry points a non-Astro consumer reaches for, and
they need nothing from your toolchain:
- No transform allowlist.
require('@codeyam/cms/lib/frontmatter')resolves to real CommonJS, so Jest's default runtime loads it without atransformIgnorePatternsun-ignore and without routing the package (or itsmicromarkdependency chain) through babel-jest. - No bundler
noExternal/transpilePackagesentry. Vite, webpack, Rollup and esbuild all resolve a built artifact through theexportsmap. - No tsconfig
pathsmapping. Declarations resolve undermoduleResolution: "bundler"and"node16"throughexports, and under the older"node"throughtypesVersions.
import { parseEntry } from '@codeyam/cms/lib/frontmatter'; // ESM build
const { parseEntry } = require('@codeyam/cms/lib/frontmatter'); // CJS buildSource (./components/*, ./layouts/*, ./pages/*, ./react,
./react/PreviewIndexPage, ./styles/*, ./content). Your Astro/Vite build
compiles these alongside your own code, which is how the integration can own
routes and islands from inside node_modules — injectRoute needs
@codeyam/cms/pages/admin/… to stay a real path in the installed package.
./content is source for a related reason: it builds a content-layer glob
loader from astro/loaders and its schema helpers from astro/zod, both of
which are resolved against whichever Astro major the consumer installed. Left as
source, the consumer's own build binds them to their Astro; precompiled, the
artifact would carry one major's copy into projects on the other two.
Shipping as source is a statement about the RUNTIME half only. The TypeScript
entries among them — ./react, ./react/PreviewIndexPage and the .tsx under
./components/* — also get .d.ts declarations built into dist/, reached
through a types export condition and a typesVersions row, so they resolve
the same three ways the compiled half does. Without those, a type resolver has
only two things to land on: our .tsx (which drags this package's sources into
your program) or the typesVersions "*" catch-all (which types
@codeyam/cms/react as the Astro integration). Both are wrong, and the second
fails silently.
The CLI is plain Node with no build step.
Working in this repo rather than consuming the package? The dogfood site resolves every one of the above to source via a
codeyam-sourceexport condition it opts into inastro.config.mjs, so editing a.tslib module still hot-reloads. No installed consumer enables that condition.
Hosting the source entries on a Node server
./react, ./react/PreviewIndexPage, ./components/* and ./styles/* resolve
to .tsx and .css — real source files, not build output, and Node can import
neither. A server-rendered React host that imports any of them must therefore
compile @codeyam/cms into its own server bundle:
// vite.config.ts — Remix, React Router, or any Vite-driven SSR host
import { defineConfig } from 'vite';
export default