https://github.com/mozzius/standard.horse
a standard.site editor
https://github.com/mozzius/standard.horse
Last synced: 25 days ago
JSON representation
a standard.site editor
- Host: GitHub
- URL: https://github.com/mozzius/standard.horse
- Owner: mozzius
- Created: 2026-05-29T06:54:28.000Z (about 2 months ago)
- Default Branch: main
- Last Pushed: 2026-05-29T09:00:08.000Z (about 2 months ago)
- Last Synced: 2026-05-29T09:11:02.184Z (about 2 months ago)
- Language: TypeScript
- Homepage: https://standardhorse.vercel.app
- Size: 185 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
Awesome Lists containing this project
README
# standard.horse
A generic, broadsheet-flavoured editor for [standard.site](https://standard.site)
records, built on [atproto](https://atproto.com). 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.
## Content formats
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 `![alt]()`; 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.
## Stack
- **Vite + React + TypeScript** (SPA, no server)
- **[`@atproto/lex`](https://www.npmjs.com/package/@atproto/lex)** — lexicon
codegen _and_ the runtime `Client` for XRPC / records / blobs
- **[`@atproto/oauth-client-browser`](https://www.npmjs.com/package/@atproto/oauth-client-browser)**
— OAuth with granular scopes
- **CodeMirror** + **react-markdown / remark-gfm** — split-pane Markdown editor
- Typography: **Newsreader** (headlines) + **IBM Plex Sans** (body)
## Getting started
```bash
pnpm install # also runs `lex build` (postinstall) to generate src/lexicons
pnpm dev # serves on http://127.0.0.1:3000
```
Open **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](https://leaflet.pub)) first, then manage it here.
### Scripts
| 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 |
## OAuth
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 the `redirect_uri` and
`scope` query params we encode into the client_id — so you still get the exact
granular scopes above on the consent screen. The dev server binds `127.0.0.1`
because loopback clients must use an IP origin, not `localhost`.
- **Production & previews** serve the client metadata document _dynamically_
from an edge function ([`api/oauth-client-metadata.ts`](./api/oauth-client-metadata.ts)),
rewritten to the magic path `/oauth-client-metadata.json` in
[`vercel.json`](./vercel.json). atproto requires the document's `client_id` to
equal the URL it was fetched from and every `redirect_uri` to share that
origin, so a static file can only describe one domain — no good for Vercel
preview deployments, which each get their own `*.vercel.app` origin. The
function therefore binds those URLs to the origin it's served from:
- **production** (`VERCEL_ENV=production`) is locked to `standard.horse`; any
other Host (including the bare `*.vercel.app` production URL) gets a 404 so
`client_id` can never mismatch. Change `PROD_HOST` if you fork this.
- **preview** reflects the deployment's own `*.vercel.app` origin, 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: '/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.app` URL is reachable, add a redirect to
the apex for _that exact host_ (don't use a broad `standard-horse.*` pattern
— it would also catch preview hosts and break their metadata fetch).
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.
## Lexicons
The standard.site lexicon JSON lives in [`lexicons/`](./lexicons) with a
[`lexicons.json`](./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.facet`
documents are **invalid** under `@atproto/lex` (marker features like `#bold`
declared with no `properties`), so `lex install` rejects them. The fix: the
broken facet JSON is vendored with a `properties: {}` patch; once it's in the
local indexer, `lex install` reuses 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 — so `lex:ci` stays green.
## Known limitations
- 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`,
Leaflet `blobPages`, pckt `blob`) 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.markdown` lexicon 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.