A little headless CMS that stores models and content in your PDS sites.wisp.place/wilb.me/pds-cms/
0

Configure Feed

Select the types of activity you want to include in your feed.

TypeScript 70.1%
Svelte 23.9%
JavaScript 3.7%
CSS 2.1%
HTML 0.3%
7 1 0

Clone this repository

https://git.vm.fail/wilb.me/pds-cms https://git.vm.fail/did:plc:bnbo7r2rre3lhspd45dyzrfw
ssh://git@knot1.tangled.sh:2222/wilb.me/pds-cms ssh://git@knot1.tangled.sh:2222/did:plc:bnbo7r2rre3lhspd45dyzrfw

For self-hosted knots, clone URLs may differ based on your setup.


README.md

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 as com.atproto.lexicon.schema records — without the _lexicon DNS 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 modelToValibot in core (with record mode for stored data and form mode where images are Files 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.social user can never add the _lexicon DNS 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; number fields are integers.
  • Blob GC. Uploaded blobs must be referenced by a record promptly or the PDS garbage-collects them. uploadImage -> createContent in 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/lexicon before 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 host setting). Deployed, it uses hosted client metadata: atproto has no client registration; the HTTPS URL of a client-metadata.json document is the client_id, and the PDS fetches it during sign-in. Ours lives in public/client-metadata.json (deployed next to the app, imported into the bundle so the two can't drift) and pins the deployment URL — currently https://sites.wisp.place/wilb.me/pds-cms/. Deploying elsewhere means editing that file's three URLs; vite.config.ts sets base: './' so the build itself is subpath-agnostic. Caveat for shared-origin hosts like sites.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 atproto scope is effectively read-only; PDSes with granular auth scopes reject record writes (Missing required scope "repo:..."). The editor requests atproto transition:generic (app-password-equivalent) in both dev and the hosted metadata; granular repo:/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, File for images); the form-mode Valibot schemas coerce them, so Formisch validates exactly what the inputs produce.

Next steps#

  1. 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 granular repo:/blob: scopes limited to the CMS's own collections instead.
  2. Namespace collections under a pds-cms segment<authority>.model and <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 teach discoverNamespaces to find both layouts so existing repos keep working.
  3. Content editor polish: image previews.
  4. Decide on the rich-text representation (markdown string vs. atproto facets).
  5. Renderer niceties: themes, an app.bsky.actor.profile header (avatar/bio), RSS, and a GitHub Action recipe for scheduled rebuilds.