@caretcms/core
v0.1.2
Published
Platform-neutral core for CaretCMS: attribute-first inline editing, mutation engine, studio admin, StorageAdapter interface.
Maintainers
Readme
@caretcms/core
Inline editing and live content collections for Astro. Add one HTML attribute to make any element editable, and use Astro's live loaders to query CMS data with getLiveEntry / getLiveCollection (stable on Astro 6, experimental on Astro 5.10+).

Install
npm install @caretcms/coreStatic Astro site? Use static delivery — no SSR adapter:
integrations: [caret({ delivery: 'static' })],Server-rendered site? Add an adapter and server output:
npm install @astrojs/nodeoutput: 'server',
adapter: node({ mode: 'standalone' }),
integrations: [caret()],See Static delivery and Rendering & output below.
Setup
Static delivery (default path for static Astro sites)
// astro.config.mjs
import { defineConfig } from 'astro/config';
import caret from '@caretcms/core';
export default defineConfig({
integrations: [caret({ delivery: 'static' })],
});astro dev— full authoring:/admin,/api/cms, inline editor.astro build— bakes stored overrides into generated HTML; authoring routes stay out of production output.- Public updates — after Publish, run CI/build/deploy (optionally via
delivery.publish.webhookUrl).
Server delivery (per-request rewriting)
// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import caret from '@caretcms/core';
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
integrations: [caret()],
});Edits are visible to visitors immediately — middleware rewrites HTML on each request.
Already have Astro content collections? If your project has collections under
src/content/ and you haven't set storage, CaretCMS auto-selects
markdownStorage() so the Studio lists those collections immediately (instead of
"No collections yet") and edits write back to your .md frontmatter. Pass an
explicit storage to override — storage: filesystemStorage() opts back out.
Choose your path
CaretCMS gives you two ways to make content editable. They share one storage layer and one login — you can mix them on the same site — but they answer different questions. Start at the top; reach for the next row only when you need it.
| Start here if… | Use | What you write |
|---|---|---|
| You have static markup (a hero, an about page) and just want to click words/images and change them | Inline editing | data-caret attributes on the elements |
| The same fields repeat or you want short attribute names | Scoped inline editing | a data-caret-scope wrapper + short data-caret names |
| Your content is dynamic data you query in frontmatter (a blog index, a list of products) | Live collections | caretLoader in src/caret.config.ts, then getLiveEntry / getLiveCollection |
The binding model in one line: every edit is addressed as collection::id::field. Inline editing lets you spell that out in pieces — data-caret-scope="pages::home" sets collection::id, and data-caret="headline" fills in the field, so the element above resolves to pages::home::headline. Live collections address the same collection + id from frontmatter instead. Same content, same storage — two ways to reach it.
Adding
data-caretto an existing site by hand?npx @caretcms/caretizescans your Astro project and interactively annotates your templates for you. The attributes below are all you need either way — caretize just writes them.
Inline editing
Add data-caret attributes to your templates:
<main data-caret-scope="pages::home">
<h1 data-caret="headline">Welcome to my site</h1>
<p data-caret="intro">This text is editable.</p>
<img data-caret="hero_image" src="/default.jpg" alt="Hero" />
</main>Start the dev server:
npm run devWith no password configured, a temporary dev password is printed in the terminal (dev only —
production stays locked). To set a permanent one, add CARET_EDIT_PASSWORD=<your-password> to a
.env file.
Log in at /admin, then click any annotated element on the page to edit it. The content Studio
lives at /admin/cms.
The inline editor only bootstraps on pages that contain data-caret bindings and only after
GET /api/cms/auth/session confirms an authenticated editor session. Session cookies are issued
as HttpOnly, SameSite=Lax, and automatically add Secure on HTTPS requests.
When you're signed in and land on a live page that has no data-caret bindings yet, CaretCMS
shows a small "signed in · no editable fields on this page" hint pointing you at the next step —
so a page that isn't annotated yet reads as "nothing to edit here" rather than "is this broken?".
The hint is editor-only (anonymous visitors never see it) and never appears inside the Studio.
Live content collections
You can also wire collections into Astro's native content layer using caretLoader. This lets you query CMS data with getLiveEntry and getLiveCollection from astro:content — the same API you use for any Astro live collection.
Astro 6: stable, no flag required. Astro 5.10+: available behind
experimental.liveContentCollections: trueinastro.config.*. Astro 5.0–5.9: the live-loader API (defineLiveCollection,getLiveEntry,getLiveCollection) doesn't exist; usedata-caretinline editing,bindEntry(), andloadEntry()instead.
1. Define collections
Create src/caret.config.ts:
import { defineLiveCollection } from 'astro:content';
import { caretLoader } from '@caretcms/core';
const pages = defineLiveCollection({
loader: caretLoader('pages'),
});
const site = defineLiveCollection({
loader: caretLoader('site'),
});
export const collections = { pages, site };2. Query in pages
---
import { getLiveEntry } from 'astro:content';
const { entry: home } = await getLiveEntry('pages', 'home');
---
<h1>{home?.data.headline}</h1>3. Both approaches work together
Inline editing (data-caret) and live collections read from the same storage adapter. Edits made inline are immediately visible through getLiveEntry / getLiveCollection, and vice versa. Use whichever approach fits the context:
data-caretfor visual, in-place editing of rendered pagesgetLiveEntry/getLiveCollectionfor programmatic data access in frontmatter
Explicit schemas (optional)
By default, Studio infers field types from your stored data. For better field labels, validation, and widget hints, pass JSON Schemas via the integration config:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import { z } from 'astro/zod';
import caret from '@caretcms/core';
const PageSchema = z.object({ headline: z.string(), intro: z.string() });
export default defineConfig({
output: 'server',
integrations: [
caret({
schemas: {
pages: z.toJSONSchema(PageSchema),
},
}),
],
});When provided, Studio uses these for field names, types, and editor widgets instead of guessing from the first entry.
Already describe your collections with Zod? If you have a content.config.ts Zod schema,
don't write it twice — @caretcms/zod derives the Studio schema from that single source:
// astro.config.mjs
import { schemaFromZod } from '@caretcms/zod';
import { blogSchema } from './src/schemas.mjs'; // the same object content.config.ts uses
caret({ schemas: { blog: schemaFromZod(blogSchema) } });(Keep your Zod schemas in a plain module that imports only zod — not astro:content — so both
content.config.ts and astro.config can import them.) Schemas remain optional: with
auto-detected markdownStorage the Studio already shows each collection with fields inferred from
existing entries; deriving from Zod just adds proper labels, types, and widget hints.
What you get
- Inline editing on any
data-caretelement (text and images) - Live content collections via
caretLoaderforgetLiveEntry/getLiveCollection(Astro 6 stable, Astro 5.10+ behindexperimental.liveContentCollections) - Content Studio at
/admin/cmsfor structured CRUD - Section composer for reordering and spacing page sections
- Response rewriting middleware (server delivery) or build-time HTML bake (static delivery)
- Scoped bindings via
data-caret-scopeto reduce repetition - Dev Toolbar app — in
astro dev, inspect and highlight every binding on the page (no login required), grouped by entry with deep-links into Studio - Revision safety with optimistic locking and restore from history
- Storage adapters — filesystem (default), in-memory, or custom via
StorageAdapterinterface
Cloudflare deployment
npm install @caretcms/cloudflareimport caret from '@caretcms/core';
import { cloudflareStorage, r2Uploads } from '@caretcms/cloudflare';
export default defineConfig({
output: 'server',
integrations: [
caret({
storage: cloudflareStorage({ binding: 'CMS_KV' }),
uploads: r2Uploads({ binding: 'CMS_R2' }),
}),
],
});API
All routes are under /api/cms by default (configurable via apiBasePath):
| Route | Method | Purpose |
|---|---|---|
| /api/cms/entries | GET | List entries by collection |
| /api/cms/schema | GET | Collection schema (explicit or inferred) |
| /api/cms/mutate | POST | Save content mutations |
| /api/cms/history | GET | Entry revision history |
| /api/cms/upload | POST | File uploads |
| /api/cms/auth/login | POST | Editor login |
| /api/cms/auth/session | GET | Editor session status |
| /api/cms/auth/logout | POST | Editor logout |
/api/cms/auth/session returns { authenticated: boolean } and is what the inline editor uses to
decide whether it should mount on the current page.
Configuration
caret({
mountPath: '/admin', // Admin UI base path (default: /admin)
apiBasePath: '/api/cms', // API route prefix (default: /api/cms)
enableAdmin: true, // Inject admin pages (default: true)
enableInlineEditor: true, // Inject inline editor (default: true)
delivery: 'static', // 'static' | 'server' | { mode, bake, publish }
storage: filesystemStorage(), // Storage adapter (default: markdownStorage when
// src/content collections exist, else filesystem)
uploads: localUploads(), // Upload handler (default: local filesystem)
schemas: {}, // Optional JSON Schema map for Studio (default: inferred)
})Rendering & output
CaretCMS supports two delivery modes via delivery:
| | Static delivery | Server delivery (default) |
|---|---|---|
| Config | caret({ delivery: 'static' }) | caret() on output: 'server' + adapter |
| Public HTML | Baked at astro build | Rewritten per request in middleware |
| Production CMS routes | Not shipped in static output | /admin, /api/cms live on the server |
| Visitor sees edits | After publish + rebuild | Immediately after save |
| SSR adapter | Not required | Required |
Static delivery
Use when your site stays output: 'static'. Local authoring works in astro dev; production
is plain static files with content baked in.
caret({
delivery: {
mode: 'static',
bake: true,
publish: {
webhookUrl: 'https://ci.example.com/hooks/rebuild',
},
},
})Full guide: docs/static-delivery.md.
Server delivery
Middleware injects stored edits at request time. Requires output: 'server' (or compatible
adapter host). Mostly-static site? Under server output you can keep individual pages static
with export const prerender = true; pages with editable content should stay server-rendered.
Migrating a static site to server delivery: switching to
output: 'server'changes howgetStaticPathspages receive props. Addexport const prerender = trueto keep a page static, or fetch at request time. This is standard Astro behavior, not CaretCMS-specific.
What gets written to disk
The default (embedded) providers write inside your project:
| Path | What | Written by |
|---|---|---|
| .caret/data/ | entry JSON | filesystemStorage() (default) |
| .caret/drafts/ | per-editor draft overlays | filesystemStorage() |
| .caretcms/ | revisions + history sidecar | both filesystem and markdown storage |
| src/content/**.md | frontmatter edits | markdownStorage() (auto-selected when you have content collections) |
| public/uploads/ | uploaded images | localUploads() (default) |
Recommended .gitignore for the transient state (keep .caret/data/ or your
src/content edits if git IS your content store — see the commit-on-publish
workflow in docs/deployment.md):
.caretcms/
.caret/drafts/
public/uploads/Production checklist
CARET_EDIT_PASSWORD— the editor password. Without it, production is locked (no dev fallback).CARET_SESSION_SECRET— required in production when a password is set; sessions are HMAC-signed with it. Generate one withopenssl rand -base64 32. If it's missing, logins return a configuration error and existing sessions are treated as signed out.markdownStorage()editssrc/content/*.mdat request time — a dev/git workflow. In production, pair it with commit-on-publish + a CI rebuild (fields rendered throughgetCollection()are baked at build time and only refresh on rebuild), or use a server-side adapter like@caretcms/cloudflare.localUploads()writes topublic/uploads, which built sites serve fromdist/client— files uploaded after the build won't be served. Treat it as dev-only and use R2 (or your ownUploadHandler) in production.- Defaults resolve paths from the server process's working directory; run the built server
from your project root, or pass explicit paths (
filesystemStorage({ dataRoot }),markdownStorage({ contentRoot }),localUploads({ uploadsDir })).
Requirements
- Astro 5 or 6
- Node 20.19.1+ or 22.12.0+
- Static delivery: default Astro static output (no adapter)
- Server delivery:
output: 'server'plus an SSR adapter
License
MIT
