pds-cms#
An atproto-first headless CMS. Content models and content live entirely on the user's PDS — no external database anywhere. A user defines a model (e.g. "artwork: title, photo, link"), the CMS compiles it into a one-off lexicon, and content records are written to a per-model collection in their repo. A renderer pointed at nothing but a DID can discover the models, read the content, and produce a static site.
Target audience: someone who wants a simple page — text, images, links — to show off their work.
Architecture#
packages/
core/ @pds-cms/core — models, lexicon + Valibot compilers, PDS record helpers ✅ spiked
renderer/ @pds-cms/renderer — DID -> render tree -> static HTML, with CLI ✅ spiked
content-editor/ Svelte + Formisch app: OAuth sign-in, model editor, schema-driven forms,
create/edit content, blob upload ✅ spiked
(The once-planned separate model-editor package became a screen inside
content-editor — defining a model and filling it in are one workflow.)
@pds-cms/core is the spine. A Model is the single source of truth and is
compiled in three directions:
┌─> modelToLexicon(ns, model) one-off lexicon; validates content
Model (a record ────┼─> modelToValibot(model) Valibot schema; drives Formisch forms
on the user's PDS) └─> (renderer, later) render tree for the published site
Everything lives in the user's repo, under an authority derived from their
own handle (alice.bsky.social -> social.bsky.alice):
| Collection | rkey | Contents |
|---|---|---|
<authority>.model |
model name | Model definitions (modelMetaLexicon) |
<authority>.content.<model> |
tid | Content records (generated lexicon) |
com.atproto.lexicon.schema |
NSID | Published copy of each generated lexicon |
Decisions made#
- User-namespaced, unverified lexicons. One-off lexicons are minted under
the user's reversed handle (
namespaceForHandle) and published to their PDS ascom.atproto.lexicon.schemarecords — without the_lexiconDNS TXT record that full lexicon resolution requires, hence "unverified". Every user gets a globally unique namespace (no cross-user NSID collisions), the app needs no domain of its own, and users whose handle is a domain they control can add the DNS record later with zero data migration. - Models are records. The repo is fully self-describing: renderer needs only a DID.
- Svelte + Formisch for the editor UIs. Formisch is Valibot-schema-driven,
hence
modelToValibotin core (withrecordmode for stored data andformmode where images areFiles pre-upload).
Known tradeoffs / open questions#
- Handle changes. NSIDs derive from the handle at creation time; handles
can change while DIDs are stable, so a rename leaves existing collections
under the old namespace. Records are unaffected — readers should use
discoverNamespaces(agent, did)(which lists the repo's collections) rather than re-deriving the namespace from the current handle. - Hosted handles can't verify. A
*.bsky.socialuser can never add the_lexiconDNS record (Bluesky controls that domain), so their lexicons stay unverified — fine for rendering, which never needs resolution, but worth an eventual "bring your own domain" nudge in the UI. - No floats. Lexicon has no float primitive;
numberfields are integers. - Blob GC. Uploaded blobs must be referenced by a record promptly or the
PDS garbage-collects them.
uploadImage->createContentin that order. - PDS validation is best-effort. The PDS doesn't know our generated
lexicons, so it stores content records without schema validation. Core
validates locally with
@atproto/lexiconbefore every write instead. - Rich text is currently plain
longtext; deciding between markdown in a string vs. atproto facets is deferred to the renderer/editor work.
Try it#
pnpm install && pnpm build && pnpm test
End-to-end against your real PDS (writes a model, a lexicon, and one content record with an image blob to your repo — use an app password):
cd packages/core
PDS_HANDLE=you.bsky.social PDS_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx node examples/demo.mjs
Core API sketch#
import { AtpAgent } from '@atproto/api'
import { namespaceForHandle, putModel, createContent, listContent, modelToValibot } from '@pds-cms/core'
const ns = namespaceForHandle(agent.session.handle) // e.g. social.bsky.alice
const model = {
name: 'artwork',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'photo', type: 'image', required: true },
{ name: 'shopLink', type: 'link' },
],
}
await putModel(agent, ns, model) // model definition + unverified lexicon -> user's repo
await createContent(agent, ns, model, data) // validated against the lexicon locally
const formSchema = modelToValibot(model, { mode: 'form' }) // -> Formisch
Renderer#
@pds-cms/renderer turns a handle or DID into a static site with zero
configuration: resolve identity (handle -> DID via XRPC, DID -> PDS via
plc.directory or did:web), discover the repo's pds-cms namespaces
(discoverNamespaces, robust against handle changes), fetch all models and
records, compile to a framework-agnostic render tree, and emit HTML.
pnpm exec pds-cms-render alice.bsky.social -o site # from the workspace root
Or programmatically — the pipeline is split so each stage is reusable:
import { buildSite, fetchSiteData, planSite, renderSite } from '@pds-cms/renderer'
const files = await buildSite('alice.bsky.social', { title: 'My Portfolio' })
// or stage by stage:
const data = await fetchSiteData('alice.bsky.social') // network
const plan = planSite(data) // pure: render tree
const html = renderSite(plan) // pure: [{ path, html }]
The render tree (SitePlan / RenderNode in tree.ts) is deliberately
HTML-free so a Svelte/React renderer or different themes can consume the same
plan later. Record content is treated as untrusted: all text is escaped and
only http(s)/mailto/relative URIs survive into attributes. Everything runs on
plain fetch, so the same code works in Node, workers, or the browser
(client-side rendering as a fallback for no-build hosting).
Content editor#
@pds-cms/content-editor is a Vite + Svelte 5 app. The whole editing UI is
derived from model records: modelToValibot(model, { mode: 'form' }) feeds
Formisch, which renders per-type inputs
(FieldInput.svelte) including image file pickers and add/remove field
arrays. On submit, createContentFromForm (core) uploads image Files as
blobs, strips empty optional values, validates against the generated lexicon,
and writes the record.
Records can also be edited: recordToFormInput (core) turns a stored record
back into form values (numbers/dates as the strings HTML inputs hold), and
updateContentFromForm rewrites the record in place, preserving createdAt
and stamping updatedAt. Already-uploaded images ride along as an existing
blob ref that satisfies required image fields, so editing never forces a
re-upload — choosing a new file replaces the blob.
Models are created and edited in the same app (ModelForm.svelte): a field
list with per-type options (max length, select choices), reordering, and
label→name slugging, validated by core's validateModel before putModel
writes the definition and republishes its generated lexicon. Editing is
schema-only by design: existing entries are not migrated when fields change,
and the model name (= collection NSID + rkey) is immutable.
Both edit views end in a danger zone: deleteContent removes a single
entry, and deleteModel cascades — every content record (batched via
applyWrites), the published lexicon record, then the model record, in
that order so an interrupted delete leaves the repo self-describing.
Orphaned blobs are garbage-collected by the PDS.
pnpm --filter @pds-cms/content-editor dev # serves on http://127.0.0.1:5173
Sign-in supports atproto OAuth (verified end-to-end against a live PDS) with an app-password fallback. Notes from the field:
- OAuth client: on a loopback origin the app uses an atproto loopback
client (no hosted client metadata, requires the 127.0.0.1 origin — hence
the vite
hostsetting). Deployed, it uses hosted client metadata: atproto has no client registration; the HTTPS URL of aclient-metadata.jsondocument is theclient_id, and the PDS fetches it during sign-in. Ours lives inpublic/client-metadata.json(deployed next to the app, imported into the bundle so the two can't drift) and pins the deployment URL — currentlyhttps://sites.wisp.place/wilb.me/pds-cms/. Deploying elsewhere means editing that file's three URLs;vite.config.tssetsbase: './'so the build itself is subpath-agnostic. Caveat for shared-origin hosts likesites.wisp.place: OAuth sessions are stored per origin (IndexedDB), so any other site on the same origin could read them — a dedicated (sub)domain is the safer long-term home. - OAuth scopes: the default
atprotoscope is effectively read-only; PDSes with granular auth scopes reject record writes (Missing required scope "repo:..."). The editor requestsatproto transition:generic(app-password-equivalent) in both dev and the hosted metadata; granularrepo:/blob:scopes are still the plan (see next steps). - An empty repo gets a one-click starter "Page" model (title, body, cover image, links) so the editor works before the model editor exists.
- Form values are HTML-shaped (numbers/dates as strings, checkboxes as
booleans,
Filefor images); the form-mode Valibot schemas coerce them, so Formisch validates exactly what the inputs produce.
Next steps#
- More restrictive OAuth scopes — the editor currently requests
atproto transition:generic(app-password-equivalent, full repo access). A hosted deployment should publish client metadata requesting granularrepo:/blob:scopes limited to the CMS's own collections instead. - Namespace collections under a
pds-cmssegment —<authority>.modeland<authority>.content.*are generic names that pollute the user's repo and could collide with another app minting NSIDs under the same reversed handle. Move everything under e.g.<authority>.pds-cms.model/<authority>.pds-cms.content.*(hyphens are valid in NSID domain segments), and teachdiscoverNamespacesto find both layouts so existing repos keep working. - Content editor polish: image previews.
- Decide on the rich-text representation (markdown string vs. atproto facets).
- Renderer niceties: themes, an
app.bsky.actor.profileheader (avatar/bio), RSS, and a GitHub Action recipe for scheduled rebuilds.