A generic, broadsheet-flavoured editor for standard.site records, built on atproto. Sign in with your atproto account, edit your publication's masthead and theme, and write posts in Markdown — all read from and written directly to your own PDS. There is no backend and no index; everything is live from your repo.
standard.site's document content is an open union, so each platform stores
posts in its own richtext format. standard.horse edits in GFM Markdown and
converts to/from each format via a provider (src/lib/providers/):
| Provider | $type |
Images |
|---|---|---|
| markpub | at.markpub.markdown |
not supported (no blob slot) |
| Leaflet | pub.leaflet.content |
✓ (PDS blob) |
| pckt | blog.pckt.content |
✓ (PDS blob or URL) |
| Offprint | app.offprint.content |
✓ (PDS blob) |
- Reading converts the stored blocks/facets to Markdown. Anything Markdown can't represent (polls, buttons, embeds, tables, callouts, highlight/underline, mentions, text alignment…) is dropped, and the editor shows a warning listing exactly what won't survive a save.
- Writing converts the Markdown back into the chosen format. New posts pick a format from a dropdown that defaults to whatever the publication's existing posts use; the URL-path shape is likewise inferred from sibling posts.
- Images are rendered as
; on save the CID is matched back to a real blob ref (from this session's uploads or the post's previous content) so existing images survive without re-uploading. markpub has no blob slot, so in-post image upload is disabled there.
A post whose content is a format no provider understands stays read-only —
editing would overwrite the original.
- Vite + React + TypeScript (SPA, no server)
@atproto/lex— lexicon codegen and the runtimeClientfor XRPC / records / blobs@atproto/oauth-client-browser— OAuth with granular scopes- CodeMirror + react-markdown / remark-gfm — split-pane Markdown editor
- Typography: Newsreader (headlines) + IBM Plex Sans (body)
pnpm install # also runs `lex build` (postinstall) to generate src/lexicons
pnpm dev # serves on http://127.0.0.1:3000Open http://127.0.0.1:3000 (not localhost — see OAuth note below) and sign
in with a handle whose account already has a site.standard.publication record.
You need an existing publication. This first draft only edits existing publications. Create one with a standard.site-compatible tool (e.g. Leaflet) first, then manage it here.
| Script | Purpose |
|---|---|
pnpm dev |
Dev server (Vite) |
pnpm build |
Typecheck + production build (regenerates lexicons first) |
pnpm typecheck |
tsc --noEmit |
pnpm test |
Run the provider conversion tests (Vitest) |
pnpm lex:install |
Re-fetch the standard.site lexicon JSON from the network |
pnpm lex:build |
Regenerate src/lexicons/ TypeScript from the JSON |
Requested scopes (writes only — reads are public XRPC):
atproto blob:image/* include:site.standard.authFull
include:site.standard.authFull pulls in standard.site's published permission
set (repo access to its publication/document/subscription/recommend
collections) instead of hand-listing repo: scopes. blob:image/* (icon &
cover/in-post image uploads) and the base atproto scope aren't part of that
set, so they stay explicit.
-
Development uses an atproto loopback client. The OAuth server serves hard-coded metadata for
http://localhost, but honours theredirect_uriandscopequery params we encode into the client_id — so you still get the exact granular scopes above on the consent screen. The dev server binds127.0.0.1because loopback clients must use an IP origin, notlocalhost. -
Production & previews serve the client metadata document dynamically from an edge function (
api/oauth-client-metadata.ts), rewritten to the magic path/oauth-client-metadata.jsoninvercel.json. atproto requires the document'sclient_idto equal the URL it was fetched from and everyredirect_urito share that origin, so a static file can only describe one domain — no good for Vercel preview deployments, which each get their own*.vercel.apporigin. The function therefore binds those URLs to the origin it's served from:- production (
VERCEL_ENV=production) is locked tostandard.horse; any other Host (including the bare*.vercel.appproduction URL) gets a 404 soclient_idcan never mismatch. ChangePROD_HOSTif you fork this. - preview reflects the deployment's own
*.vercel.apporigin, so each preview is a valid, self-consistent OAuth client (you re-consent per preview domain — expected).
The app loads it via
BrowserOAuthClient.load({ clientId: '<origin>/oauth-client-metadata.json' }); the filename is deliberate — atproto's consent screen hides the raw client_id URL when it ends in exactly/oauth-client-metadata.json.Two deploy-time gotchas:
- Deployment Protection breaks the flow. The atproto auth server fetches the metadata document server-to-server, so previews must be publicly reachable — if Vercel Authentication / password protection is on, that fetch 401s and login fails. Disable protection for previews (or use a bypass).
- Production must be reached via
standard.horse. Since prod 404s the metadata on any other Host, start the flow on the canonical domain. If your production deployment's*.vercel.appURL is reachable, add a redirect to the apex for that exact host (don't use a broadstandard-horse.*pattern — it would also catch preview hosts and break their metadata fetch).
- production (
Handle resolution uses bsky.social by default (it will see handles + IPs).
Self-hosters can point HANDLE_RESOLVER in src/auth/client.ts at their PDS.
The standard.site lexicon JSON lives in lexicons/ with a
lexicons.json manifest — these are committed. The
generated TypeScript in src/lexicons/ is git-ignored and rebuilt by
pnpm lex:build (and automatically on install/build).
Some lexicons need manual handling, all vendored as JSON under lexicons/:
- markpub (
at.markpub.*) isn't published to the network, so its lexicons are vendored verbatim. - pckt and Offprint publish their lexicons, but their
richtext.facetdocuments are invalid under@atproto/lex(marker features like#bolddeclared with noproperties), solex installrejects them. The fix: the broken facet JSON is vendored with aproperties: {}patch; once it's in the local indexer,lex installreuses it and pulls in the (valid) block lexicons normally. The patched facets are intentionally not manifest roots — that would re-trigger the failing network resolution — solex:cistays green.
- Edits existing publications only — no create-a-publication flow.
- Block-format conversion is lossy by design: anything Markdown can't express is dropped on save (the editor warns first). Tables aren't written in any format yet.
- Bodies are written inline. Blob-backed bodies (markpub
text.textBlob, LeafletblobPages, pcktblob) are read but not written, so very large posts aren't re-offloaded to a blob on save. - markpub in-post images would be garbage-collected (no blob reference in the
record), so image upload is disabled for markpub. The eventual fix is a
horse.standard.markdownlexicon that carries real blob refs. - No contributors or facets/lenses authoring UI yet.
- Single bundle is large (CodeMirror's
language-data); could be code-split.