A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

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

Rewrite README as a self-hosting guide; make the site origin configurable

The README now targets developers deploying their own instance (prerequisites,
local dev, configuration, Cloudflare deploy, architecture, fork notes) instead of
tracking development phases.

To make self-hosting a one-setting change, the public origin is now read from
PUBLIC_SITE_URL (baked into the build) instead of a hardcoded skypress.blog:
- records.ts: SKYPRESS_BASE -> SITE_BASE = import.meta.env.PUBLIC_SITE_URL ?? default
- astro.config: site = process.env.PUBLIC_SITE_URL || default
- .env.example documents it; tests derive expected URLs from SITE_BASE so they're
domain-agnostic.

OAuth client metadata + redirect URIs already adapt to the request origin, so
PUBLIC_SITE_URL is the only thing a self-hoster must set. Verified: building with
a custom PUBLIC_SITE_URL bakes that domain into the client bundle with no
leftover default; 45 tests + astro check green.

+153 -69
+4
.env.example
··· 1 + # Public origin where YOUR instance is served. Used for the stored publication + 2 + # article URLs and the companion Bluesky post link. Must match the domain you deploy to. 3 + # OAuth client metadata + redirect URIs adapt to the request origin automatically. 4 + PUBLIC_SITE_URL=https://skypress.blog
+134 -61
README.md
··· 1 1 # SkyPress 2 2 3 - A standalone, long-form writing studio for the **AT Protocol** (the open network 4 - behind Bluesky). Write rich, block-based articles in the WordPress "Gutenberg" editor 5 - — used entirely on its own, no WordPress behind it — and publish them to **your own 6 - Personal Data Server (PDS)**. Your data is yours; SkyPress is just the studio you write 7 - in. 3 + A standalone, long-form writing studio for the **AT Protocol** (the open network behind 4 + Bluesky). Writers compose rich, block-based articles in the WordPress "Gutenberg" editor — 5 + used entirely on its own, no WordPress behind it — and publish them to **their own Personal 6 + Data Server (PDS)**. The writer owns their data; SkyPress is just the studio they write in, 7 + plus a public renderer for reading. 8 + 9 + SkyPress is an **editor + OAuth client + public renderer** — never a PDS, never a relay. 10 + It runs as a single Astro app on Cloudflare with **no database**: the public reader 11 + resolves articles on demand (`handle → DID → PDS → record → HTML`). 8 12 9 - > Status: early construction. **SP0 (Foundations + Editor Spike) is complete** — the 10 - > riskiest path is proven end-to-end. See the roadmap below. 13 + This guide is for developers who want to **run their own instance**. 11 14 12 - ## How it works 15 + --- 13 16 14 - - The editor is Automattic's [`isolated-block-editor`](https://github.com/Automattic/isolated-block-editor) 15 - (standalone Gutenberg). The canonical content is the **Gutenberg block tree**. 16 - - On publish (SP2+), SkyPress writes two records to the writer's PDS: a 17 - `site.standard.document` holding the block tree, and a companion `app.bsky.feed.post` 18 - (the POSSE pattern). Reading pages are rendered back from the block tree. 19 - - It's an **editor + OAuth client + public renderer** — never a PDS, never a relay. 17 + ## Prerequisites 20 18 21 - ## Architecture at a glance 19 + - **Node ≥ 20** and npm. 20 + - A **Cloudflare account** with a **domain managed in Cloudflare** (for production). 21 + - An **AT Protocol / Bluesky identity** to sign in and test publishing. 22 22 23 - - **Stack:** [Astro](https://astro.build) + **React 18** islands (Decision 0001). 24 - React 18 is required by the bundled `@wordpress/*` packages. 25 - - **Editor** (`/editor`): a `client:only="react"` island — its weight is confined to 26 - that route. 27 - - **Reading pages** (`/`, `/preview`, later `/<handle>/<slug>`): rendered to light HTML 28 - with a **dependency-free renderer** (`src/lib/blocks/render.ts`) whose fidelity is 29 - locked to the real `@wordpress/blocks.serialize()` by tests. Reading pages ship **0 30 - JS** (Decision 0003). 23 + --- 31 24 32 - ## Getting started 25 + ## Quick start (local) 33 26 34 27 ```sh 35 - npm install # installs @wordpress/* pinned to one consistent version line 36 - npm run dev # dev server (editor at /editor, sample article at /preview) 37 - npm run build # static production build 38 - npm test # Vitest: block round-trip + render fidelity + textContent 28 + git clone <your fork> skypress && cd skypress 29 + cp .env.example .env # set PUBLIC_SITE_URL (see Configuration) 30 + npm install # installs the @wordpress/* tree (pinned — see Notes) 31 + npm run dev # http://localhost:4321 39 32 ``` 40 33 41 - Requires Node ≥ 20. 34 + Open the studio at **`http://127.0.0.1:4321/editor`** — use the IP, not `localhost`: 35 + atproto's development ("loopback") OAuth client requires an IP origin, so sign-in only 36 + works on `127.0.0.1`. Sign in with your handle, write, and publish. 37 + 38 + > Publishing is real: it writes records to your PDS **and creates a public Bluesky post** 39 + > linking to the article. The UI says so before you confirm. 40 + 41 + Other scripts: 42 + 43 + | Command | What it does | 44 + |---|---| 45 + | `npm run dev` | Dev server (editor + reader, hot reload) | 46 + | `npm run build` | Production build (Cloudflare Worker + static assets) | 47 + | `npm run preview` | Run the built worker locally on `workerd` | 48 + | `npm run deploy` | `astro build && wrangler deploy` | 49 + | `npm test` | Vitest (block round-trip + render fidelity, publish builders, SSRF guard, …) | 50 + | `npm run check` | `astro check` (types) | 42 51 43 - ## Project layout 52 + --- 44 53 54 + ## Configuration 55 + 56 + One setting matters for self-hosting — your public origin: 57 + 58 + ```sh 59 + # .env 60 + PUBLIC_SITE_URL=https://yourdomain.example 45 61 ``` 46 - src/ 47 - pages/ index, editor (island), preview (server-rendered article) 48 - components/ SkyEditor.tsx — the editor island 49 - lib/blocks/ render.ts (dependency-free reader) · serialize.ts (@wordpress oracle) 50 - allowlist.ts (curated content model) · sample.ts (fixture) 51 - docs/ 52 - decisions/ one file per non-obvious decision (context, options, choice, why) 53 - specs/ per-sub-project specs (SP0, SP1, …) 62 + 63 + It's baked into the build and used for the stored publication/article URLs and the Bluesky 64 + post link, so it **must match the domain you serve from**. The OAuth client metadata and 65 + redirect URIs adapt to the request origin automatically, so there's nothing else to set. 66 + 67 + Optional: rename the worker in [`wrangler.toml`](./wrangler.toml) (`name`) and the 68 + displayed app name in [`src/pages/client-metadata.json.ts`](./src/pages/client-metadata.json.ts) 69 + (`client_name`, shown on the Bluesky consent screen). 70 + 71 + --- 72 + 73 + ## Deploy your own (Cloudflare) 74 + 75 + SkyPress targets **Cloudflare Workers with Static Assets** (via `@astrojs/cloudflare`). The 76 + on-demand reader runs as the Worker; everything else is static. No KV/D1/queues — 77 + `nodejs_compat` (already set in `wrangler.toml`) lets the reader's HTML sanitiser run on the 78 + edge. 79 + 80 + ```sh 81 + npx wrangler login # once 82 + echo "PUBLIC_SITE_URL=https://yourdomain.example" > .env 83 + npm run deploy 54 84 ``` 55 85 56 - ## Roadmap 86 + Then, in the Cloudflare dashboard, attach **your domain** as a custom domain on the 87 + deployed Worker. Verify: 88 + 89 + 1. `https://yourdomain.example/client-metadata.json` returns JSON (the OAuth client doc, 90 + generated from the request origin). 91 + 2. `https://yourdomain.example/editor` → sign in → publish → open the resulting 92 + `https://yourdomain.example/@<handle>/<rkey>`. 93 + 94 + Use the **apex** origin you set in `PUBLIC_SITE_URL` (don't mix `www`), since the OAuth 95 + `client_id` is origin-specific. Detailed notes + gotchas (e.g. the editor's canonical 96 + trailing-slash path) live in [`docs/specs/sp7-deploy.md`](./docs/specs/sp7-deploy.md). 97 + 98 + --- 99 + 100 + ## How it works 101 + 102 + - **Editor** (`/editor`): Automattic's [`isolated-block-editor`](https://github.com/Automattic/isolated-block-editor) 103 + (standalone Gutenberg) as a single `client:only` React island. The canonical content is 104 + the **Gutenberg block tree**. 105 + - **Publish:** writes a `site.standard.publication` (once), a `site.standard.document` 106 + (block tree + plain-text `textContent`), and a companion `app.bsky.feed.post` — the POSSE 107 + pattern. Images upload to the PDS as blobs (`uploadBlob`); the document stores the typed 108 + blob ref. 109 + - **Reader** (`/@<handle>/<rkey>`): resolves identity, fetches the record, and renders the 110 + block tree to **light HTML with zero JavaScript**, reconstructing blob image URLs and 111 + **sanitising** the untrusted content. Other apps that don't understand the block format 112 + fall back to `textContent`. 113 + - **Auth:** a browser OAuth public client (PKCE/DPoP) — no backend, no secrets. 57 114 58 - | | Sub-project | Status | 59 - |---|---|---| 60 - | SP0 | Foundations + editor spike | ✅ Complete | 61 - | SP1 | atproto OAuth, session, the `Agent` | ✅ Complete | 62 - | SP2 | Lexicon (`blog.skypress.*`) + two-record publish | ✅ Complete | 63 - | SP3 | Image/blob pipeline (`mediaUpload` → `uploadBlob`) | ✅ Complete | 64 - | SP4 | Public renderer (`/@<handle>/<rkey>`, link tags, edge SSR, sanitisation) | ✅ Complete | 65 - | SP5 | Edit flow (the "puppy problem") + unpublish | ✅ Complete | 66 - | SP6 | Brand identity | ✅ Complete | 67 - | SP7 | Deploy to Cloudflare | ✅ Deploy-ready (edge-verified; `npm run deploy`) | 115 + The content lexicon is documented in [`lexicons/`](./lexicons/README.md); the design 116 + rationale for each non-obvious choice is in [`docs/decisions/`](./docs/decisions/). 68 117 69 - ## Deploy (Cloudflare) 118 + --- 70 119 71 - SkyPress deploys to **Cloudflare Workers (Static Assets)** via `@astrojs/cloudflare` 72 - (Decision 0009). The read-through renderer runs as the Worker; everything else is static. 73 - No database/KV — `nodejs_compat` (in `wrangler.toml`) lets the reader's sanitiser run on 74 - the edge. 120 + ## Project layout 75 121 76 - ```sh 77 - npx wrangler login # one-time, owner's Cloudflare account 78 - npm run deploy # astro build && wrangler deploy 122 + ``` 123 + src/ 124 + pages/ index · editor · client-metadata.json.ts (OAuth client doc, worker route) 125 + [author]/index.astro + [author]/[rkey].astro (read-through reader) 126 + components/ Studio · SkyEditor · PublishPanel · MyArticles · Logo 127 + lib/ 128 + blocks/ render.ts (dependency-free reader) · serialize.ts (@wordpress oracle) · allowlist.ts 129 + auth/ oauth.ts · AuthProvider.tsx · config.ts · LoginForm.tsx 130 + publish/ records.ts (pure builders) · publisher.ts (Agent orchestration) 131 + media/ mediaUpload.ts · blob.ts · pds.ts 132 + reader/ identity.ts · records.ts · sanitize.ts 133 + net/ safe-fetch.ts (SSRF guard for the reader's outbound fetches) 134 + lexicons/ blog.skypress.content.gutenberg.json + README 135 + docs/ decisions/ (why) · specs/ (how) · brand/ 136 + astro.config.mjs · wrangler.toml 79 137 ``` 80 138 81 - Then attach `skypress.blog` as a custom domain in the Cloudflare dashboard. Full runbook + 82 - verification: [`docs/specs/sp7-deploy.md`](./docs/specs/sp7-deploy.md). 139 + --- 140 + 141 + ## Notes if you fork 142 + 143 + - **React 18 only.** The bundled `@wordpress/*` packages require `react@^18.3`; React 19 144 + will break the editor. 145 + - **The whole `@wordpress/*` tree is version-pinned** via `overrides` in `package.json` to 146 + the exact line `isolated-block-editor` bundles. Caret ranges otherwise pull a newer line 147 + and crash the editor with duplicate data registries. Bumping `isolated-block-editor` 148 + means regenerating that override map. 149 + - **Reading pages never import `@wordpress`** — the editor stack is browser-only and can't 150 + render server-side. The reader uses the dependency-free `src/lib/blocks/render.ts`, whose 151 + output fidelity is locked to the real serializer by tests. 152 + - **Untrusted content** from arbitrary PDSes is always sanitised before rendering, and any 153 + server-side fetch to a user-derived host goes through `src/lib/net/safe-fetch.ts`. 154 + 155 + --- 83 156 84 157 ## License 85 158 86 - [GPL-2.0-only](./LICENSE) — WordPress/Gutenberg lineage; aligns with the 87 - open-source, your-data-is-yours ethos. 159 + [GPL-2.0-only](./LICENSE) — WordPress/Gutenberg lineage; aligns with the open-source, 160 + your-data-is-yours ethos.
+2 -1
astro.config.mjs
··· 10 10 // renderer, SP4). Deployed to Cloudflare Pages (SP7). `nodejs_compat` (set in 11 11 // wrangler.toml) lets sanitize-html's Node-style deps run on the edge. 12 12 export default defineConfig( { 13 - site: 'https://skypress.blog', 13 + // Public origin. Override with PUBLIC_SITE_URL when self-hosting (see README / .env.example). 14 + site: process.env.PUBLIC_SITE_URL || 'https://skypress.blog', 14 15 adapter: cloudflare(), 15 16 // SkyPress doesn't use Astro server sessions (auth is a browser OAuth client). 16 17 // A no-binding in-memory driver stops the adapter requiring a Cloudflare KV
+4 -3
src/lib/publish/records.test.ts
··· 10 10 normalizeBlocks, 11 11 CONTENT_TYPE, 12 12 CONTENT_VERSION, 13 + SITE_BASE, 13 14 } from './records'; 14 15 import type { BlockNode } from '../blocks/render'; 15 16 ··· 21 22 describe( 'URLs', () => { 22 23 it( 'prefixes the handle with @ and addresses documents by rkey', () => { 23 24 expect( publicationHomeUrl( 'alice.bsky.social' ) ).toBe( 24 - 'https://skypress.blog/@alice.bsky.social' 25 + `${ SITE_BASE }/@alice.bsky.social` 25 26 ); 26 27 expect( articlePath( '3kabcrkey' ) ).toBe( '/3kabcrkey' ); 27 28 expect( canonicalArticleUrl( 'alice.bsky.social', '3kabcrkey' ) ).toBe( 28 - 'https://skypress.blog/@alice.bsky.social/3kabcrkey' 29 + `${ SITE_BASE }/@alice.bsky.social/3kabcrkey` 29 30 ); 30 31 } ); 31 32 } ); ··· 67 68 it( 'sets the required url + name', () => { 68 69 const pub = buildPublicationRecord( { handle: 'alice.bsky.social' } ); 69 70 expect( pub.$type ).toBe( 'site.standard.publication' ); 70 - expect( pub.url ).toBe( 'https://skypress.blog/@alice.bsky.social' ); 71 + expect( pub.url ).toBe( `${ SITE_BASE }/@alice.bsky.social` ); 71 72 expect( pub.name ).toBeTruthy(); 72 73 } ); 73 74 } );
+9 -4
src/lib/publish/records.ts
··· 6 6 */ 7 7 import type { BlockNode } from '../blocks/render'; 8 8 9 - /** Public base origin for canonical article URLs (finalised at deploy, SP7). */ 10 - export const SKYPRESS_BASE = 'https://skypress.blog'; 9 + /** 10 + * Public origin for the stored publication + article URLs (and the Bluesky post link). 11 + * Set `PUBLIC_SITE_URL` to your own domain when self-hosting; it's baked into the build. 12 + * Must match the origin the app is actually served from. 13 + */ 14 + export const SITE_BASE = 15 + import.meta.env.PUBLIC_SITE_URL ?? 'https://skypress.blog'; 11 16 12 17 export const CONTENT_TYPE = 'blog.skypress.content.gutenberg'; 13 18 export const CONTENT_VERSION = 1; ··· 19 24 20 25 /** 21 26 * The writer's SkyPress homepage — the publication's `url`. Handles are prefixed 22 - * with `@` (e.g. `https://skypress.blog/@alice.bsky.social`). 27 + * with `@` (e.g. `<site>/@alice.bsky.social`). 23 28 */ 24 29 export function publicationHomeUrl( handle: string ): string { 25 - return `${ SKYPRESS_BASE }/@${ handle }`; 30 + return `${ SITE_BASE }/@${ handle }`; 26 31 } 27 32 28 33 /**