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

@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

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 integrate

integrate wires the CMS into the Astro project in the current directory. It is idempotent — safe to run again any time.

What it does:

  1. Confirms you're in an Astro project (bails cleanly if not).
  2. Adds @codeyam/cms and @astrojs/react to your dependencies.
  3. Pushes codeyamCms() into your astro.config integrations: [].
  4. 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.
  5. 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/react resolves to .tsx source, so your host has to compile it: ssr.noExternal: ['@codeyam/cms'] in a Vite-driven app, transpilePackages in Next. Skip it and the server fails at runtime on the first .tsx rather 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 renders

That 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 named date, 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 one list field. 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-field markers valued with the row's key (outcome above, not outcomes). 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 a settings.json value as text (your site title in the header, your footer line).
  • data-cms-setting-href="<key>" — marks a link whose href a setting drives. Separate from the above because a link can render a setting either way, and contactEmail in particular becomes a mailto: 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 Started gives /academy/Getting%20Started/…, not getting-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 series is 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 :slug is 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" }
  }
}
  • field is the frontmatter key holding the entry's place in the sequence. It must be a real number: position: 3, not position: "3".
  • within is 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, date and html all work. list and image do 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 seo and paths, 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 siteUrl or socials would 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 reference with no collection, or one naming a collection your registry does not declare, falls back to a plain text input and validates nothing. A stray line in collections.json costs 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 a pattern would 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 pattern that will not compile is ignored. That one field validates as it did before rather than throwing, so a stray line in collections.json costs 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 a transformIgnorePatterns un-ignore and without routing the package (or its micromark dependency chain) through babel-jest.
  • No bundler noExternal / transpilePackages entry. Vite, webpack, Rollup and esbuild all resolve a built artifact through the exports map.
  • No tsconfig paths mapping. Declarations resolve under moduleResolution: "bundler" and "node16" through exports, and under the older "node" through typesVersions.
import { parseEntry } from '@codeyam/cms/lib/frontmatter';   // ESM build
const { parseEntry } = require('@codeyam/cms/lib/frontmatter'); // CJS build

Source (./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-source export condition it opts into in astro.config.mjs, so editing a .ts lib 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