@drigosihombinga/astro-scribe
v0.3.7
Published
Scribe — a git-backed CMS for Astro. Staff edit content on the live page, see it update as they type, and publish with one button.
Maintainers
Readme
Scribe
A git-backed CMS for Astro. Non-technical staff edit content on the live page, watch it update as they type, and publish with one button. The repository is the truth; the server keeps nothing.
npm install @drigosihombinga/astro-scribeStatus: 0.3.0, pre-release. The architecture runs in production on one site; the public API is not yet stable.
What it is
Most CMSs give you an admin panel that renders an approximation of your site. Scribe has no preview renderer. When you type, the server re-renders the real page with your draft applied and morphs the result into the DOM you are looking at. Fidelity is exact because there is nothing to keep in sync.
Content lives in your git repository. Every save is a commit on a shared draft branch
(scribe/draft), merged per field so two teachers editing one document never see a
conflict. Publishing squashes the draft branch onto main as one commit; your CI builds and
deploys the static site from there. The server holds no state between requests, uses only Web
APIs (plus node:async_hooks), and runs on any Astro adapter.
Requirements
- Astro 7,
@astrojs/react(the editor is a React island), Node ≥ 22.12 - Zod 4 — Scribe reads
.meta()throughz.toJSONSchema()to build forms - A GitHub repository (other providers implement the same nine-method interface)
Two deployments from one codebase
A prerendered route cannot also be rendered on demand, and previews must render the real pages with drafts applied — so the same commit is built twice by the same CI:
| Deployment | output | Serves |
| --- | --- | --- |
| Public — example.com | static | the site; carries the scribe-build meta |
| Editor — studio.example.com | server | the site again (for preview), the Studio UI, /studio/* |
The public build carries no Scribe route, middleware or script. astro dev always runs as the
editor, against an in-memory repository when none is configured.
Setup
1. Add the integration, switching output on one variable.
// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import react from '@astrojs/react';
import scribe from '@drigosihombinga/astro-scribe';
const editor = process.env.SCRIBE_MODE === 'editor';
export default defineConfig({
output: editor ? 'server' : 'static',
adapter: editor ? node({ mode: 'standalone' }) : undefined,
integrations: [react(), scribe()],
});2. Describe your content and name your repository.
// src/scribe.config.ts
import { z } from 'zod';
import { defineScribeConfig, editor } from '@drigosihombinga/astro-scribe';
const Article = z.object({
title: z.string(),
img: editor(z.string(), { input: 'media', label: 'Cover photo' }),
body: editor(z.string(), { input: 'markdown', rows: 16 }),
});
export default defineScribeConfig({
collections: [
{ name: 'articles', label: 'News', kind: 'list', schema: Article, formSource: 'schema' },
],
pages: { '/': 'Home', '/news': 'News' },
repository: { provider: 'github', owner: 'acme', repo: 'site', branch: 'main', draftBranch: 'scribe/draft' },
media: { dir: 'public/media', maxBytes: 5_000_000 },
});3. Put the build sha on the public site, so the editor can tell when a publish is live:
<meta name="scribe-build" content={import.meta.env.SCRIBE_BUILD_SHA ?? ''} />and have CI export SCRIBE_BUILD_SHA=$GITHUB_SHA into both builds.
4. Set the environment on the editor host.
| Variable | Purpose |
| --- | --- |
| SCRIBE_SESSION_SECRET | 32+ chars; encrypts the session cookie |
| SCRIBE_PUBLIC_ORIGIN | the editor host, https://studio.example.com; required behind a TLS proxy |
| SCRIBE_SITE_ORIGIN | the public host, read for the scribe-build meta |
| SCRIBE_GITHUB_CLIENT_ID / _SECRET | GitHub OAuth app; callback <PUBLIC_ORIGIN>/studio/auth/github/callback |
| SCRIBE_GOOGLE_CLIENT_ID / _SECRET | Google OAuth client; callback <PUBLIC_ORIGIN>/studio/auth/callback |
| SCRIBE_GIT_TOKEN | fine-grained PAT (Contents read/write) held by a dedicated editorial account; used for Google sign-ins |
| SCRIBE_GIT_COMMITTER_NAME / _EMAIL | the editorial account's identity (default Scribe <scribe@localhost>) |
| SCRIBE_EDITORS | comma-separated addresses that may draft |
| SCRIBE_PUBLISHERS | comma-separated addresses that may draft and publish |
Either sign-in method alone is enough. SCRIBE_ALLOWED_EMAILS is still read as an alias for
SCRIBE_EDITORS for one release. Empty lists fail closed: nobody publishes until a publisher is
named.
Sign-in and roles
- GitHub — the teacher's own OAuth token is sealed into the httpOnly cookie and commits are
made as them. A token that can push to the repository makes its owner an editor even when
absent from the lists; publishing still requires
SCRIBE_PUBLISHERS. - Google — identity only. Commits use the editorial token; the teacher is the author and the
commit carries
Co-authored-by.
Tokens never reach browser JavaScript; GET /studio/session returns { email, name, role } and
nothing else. Roles are re-read from the environment on every request.
How it works
| Concern | Approach |
| --- | --- |
| Forms | Zod schemas → JSON Schema → a form tree. .meta({ editor }) carries labels and widgets. |
| Drafts | One shared branch. Each save is a commit with a Scribe-Fields: trailer naming the fields it touched; the browser keeps only a typing buffer (IndexedDB). |
| Merging | Three-way, per field. My changed fields win; everything else follows the branch. A field a colleague also changed is written as mine and shown as a chip — Changed by Pak Andi · use their version — the only surface a conflict ever has. |
| Preview | POST /studio/preview { route, values } renders the page with those documents overlaid, then idiomorph morphs it in. No repository call. |
| Publish | Publishers squash the draft branch onto main in one commit (developer fixes on main are folded in, chosen fields can keep their live value), then the draft branch is reset with a Scribe-Base: marker. Both ref updates are compare-and-swap. |
| Status | /studio/status combines CI checks for main with the public site's scribe-build meta: building, live, or failed. |
| Media | An upload rides inside the next save, named by its content hash, so the photo and the field that points at it land in one commit. The editor host serves draft-line photos from the branch; the public build ships them from public/media. |
| Portability | npm run lint:portability forbids every node:* import but async_hooks under src/ and routes/. |
The editor
Nothing is added to your markup. When a signed-in editor loads any page, a bar appears along the
bottom edge and every section the CMS knows about gets a hover outline with its name; click one
and an inspector docks on the right with that document's fields, grouped into sections. ⌘K
opens a browser of everything else, grouped by page, with Write a new … under each list
collection. Review is a sheet that rises from the bar: every pending field with who changed it
and when; publishers can keep the live version of any field and publish the rest, editors see
the same list under Waiting to be published.
Mark a section so the editor can find it on the page:
<section data-scribe="home/latest-news">…</section>Widgets, labels, locations
Fields render through a registry (registerWidget), two widgets ship — bilingual and
markdown (Tiptap, optional peers) — and every collection accepts documentLabels,
fieldLabels and locate so what staff see is named in their language and a click goes
somewhere. See the docs in the repository for the full reference.
What changed in 0.3.5
Fixes from the first content pass a real editor made through the browser (see
docs/superpowers/specs/2026-08-30-scribe-report-fixes-design.md in the repository):
- The draft line is what a signed-in editor sees on every page. The middleware
overlays pending documents on any
GETthat carries a session; the bar'sDraft | Publishedswitch writes the<cookiePrefix>viewcookie and reloads. Anonymous visitors are untouched. - Review opens in a blink.
/studio/reviewand the page overlay are memoised under the two branch heads (cachedByHeads), so a review costs two head lookups instead of a tree walk. - Rows diff as rows. Deleting the fifth of eight list rows reads as 3 rows removed, not as edits to rows 5–8. Publishers keep or drop whole rows; editors put back or withdraw their own.
- Editors can withdraw one field:
DELETE /studio/draft?path=…&field=…[&row=added|removed]. - The review sheet says who you are and what you can do, groups documents by page, marks
changes older than a day, shows a skeleton while loading, locks while publishing, and ends on a
confirmation naming the commit and the branch (with a note under
astro devthat the local copy does not change). - Honest save states: Not saved yet while a change is typed but not sent,
⌘Sto send it now, and the browser asks before you leave mid-save. /studiois a page: documents by page with pending counts, the last commit on the publish branch, the build state, your role and the sign-in methods.- Locales.
scribe({ locales: { default: 'id', prefixes: { en: '/en' } } })keeps the language you are reading while the editor navigates and adds anID | ENswitch to the inspector. - Input hints for data-driven forms. A collection with
formSource: 'data'may setfieldInputs: { 'story.img': 'media' }so a plain string leaf renders as a photo picker. - Accessible names on rows, palette options and the bar's buttons; the decorative drag glyph is gone (nothing reorders yet).
- The status endpoint returns
branchesandrepoWebUrl; publish returnsbranch.
GitHub sign-in needs SCRIBE_GITHUB_CLIENT_ID / _SECRET; without them only the Google button
renders.
Development
apps/example runs the whole editor against an in-memory repository seeded from its content
directory: npm run example:dev, sign-in bypassed. npm run verify runs the portability lint,
typecheck, tests and build. The provider contract runs against GitHub when
SCRIBE_TEST_GITHUB_TOKEN, SCRIBE_TEST_REPO and SCRIBE_TEST_BRANCH are set.
Licence
MIT. Portions adapted from Nuxt Studio (MIT);
see NOTICE.
