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.

Add SP2 lexicon + two-record publish

Define and document the SkyPress content lexicon and implement the core
publish mechanic: write the writer's article to their PDS as a
site.standard.document (block tree in content + de-facto-required textContent),
ensure their site.standard.publication exists, and post a companion
app.bsky.feed.post linking to the public article (POSSE).

- lexicons/: blog.skypress.content.gutenberg.json + a README documenting every
record SkyPress writes (verified site.standard.* shapes, ours, app.bsky).
- src/lib/publish/records.ts: pure record/URL/slug builders + normalizeBlocks
(strips clientId, JSON-normalises rich-text) — unit-tested.
- src/lib/publish/publisher.ts: ensurePublication (matches OUR publication by
url, so we never attach to another tool's standard.site publication) -> create
post -> create document with bskyPostRef. Order avoids a circular dependency.
- PublishPanel: title + publish with an unmistakable "this also posts to
Bluesky" confirmation (brief §10).
- Dev loopback client now requests `atproto transition:generic` so createRecord
is authorized (one re-auth; handle now resolves via appview).

Verified end-to-end against a real account: records fetched back from the PDS
confirm the block tree stored as clean JSON, correct textContent, a real
Bluesky post with an external embed, and bskyPostRef. The live test caught a bug
(reusing a foreign publication) which is fixed by matching on url.

+921 -21
+2 -2
README.md
··· 59 59 |---|---|---| 60 60 | SP0 | Foundations + editor spike | ✅ Complete | 61 61 | SP1 | atproto OAuth, session, the `Agent` | ✅ Complete | 62 - | SP2 | Lexicon (`blog.skypress.*`) + two-record publish | Next | 63 - | SP3 | Image/blob pipeline (`mediaUpload` → `uploadBlob`) | | 62 + | SP2 | Lexicon (`blog.skypress.*`) + two-record publish | ✅ Complete | 63 + | SP3 | Image/blob pipeline (`mediaUpload` → `uploadBlob`) | Next | 64 64 | SP4 | Public renderer (`/<handle>/<slug>`, link tags, edge SSR, sanitisation) | | 65 65 | SP5 | Edit flow (the "puppy problem") | | 66 66 | SP6 | Brand identity | |
+79
docs/decisions/0005-lexicon-and-publish-model.md
··· 1 + # 0005 — Lexicon design & the publish model 2 + 3 + - **Status:** Accepted 4 + - **Date:** 2026-06-08 5 + - **Scope:** SP2 (publish). Edit semantics (the "puppy problem") are SP5. 6 + 7 + Shapes below are verified against the live `site.standard.*` lexicons 8 + (tangled.org/standard.site/lexicons, fetched 2026-06-08), not just the brief. 9 + 10 + ## Records written on publish 11 + 12 + Two (or three) records on the writer's PDS, per brief §1: 13 + 14 + 1. **`site.standard.publication`** (rkey: TID) — created once per writer if missing. 15 + `url` (required) = the writer's SkyPress homepage `https://skypress.blog/<handle>`; 16 + `name` (required) = a default derived from the handle. Reused on later publishes by 17 + listing the writer's existing publication records (it is NOT a `self` singleton). 18 + 2. **`site.standard.document`** (rkey: TID) — the article. 19 + - `site` = the publication's `at://` URI (links document↔publication, which Bluesky 20 + requires for the highest-fidelity card — brief §3). 21 + - `path` = `/<slug>` (slug from the title). Canonical URL = publication `url` + `path` 22 + = `https://skypress.blog/<handle>/<slug>`. 23 + - `title`, `publishedAt` (required); `description`; `textContent` (de-facto required — 24 + Bluesky computes reading-time/search from it, ignoring `content`); `bskyPostRef` 25 + (strongRef to the post, below); `content` = our Gutenberg object. 26 + 3. **`app.bsky.feed.post`** (rkey: TID) — the social signal, with an 27 + `app.bsky.embed.external` link card pointing at the canonical article URL. 28 + 29 + ### Publish order (avoids a circular dependency) 30 + handle + slug ⇒ the article URL is known up front. So: 31 + `ensure publication → create post (embed → article URL) → create document (content + 32 + textContent + bskyPostRef = post strongRef)`. One document write, no follow-up update. 33 + 34 + ## The SkyPress content lexicon — `blog.skypress.content.gutenberg` 35 + 36 + Goes inside the document's open `content` union (brief §3). The block tree is canonical 37 + (the array from `onSaveBlocks`), not rendered HTML. 38 + 39 + ``` 40 + blog.skypress.content.gutenberg (object) 41 + version: integer — serialization version (currently 1) 42 + blocks: array of unknown — the Gutenberg block tree: [{ name, attributes, innerBlocks }] 43 + ``` 44 + 45 + - `blocks` items are typed `unknown` (arbitrary objects): Gutenberg block attributes are 46 + open-ended per block type and can't be strictly schematised. Each node matches the 47 + `BlockNode` shape `render.ts` consumes, so reconstruction is a direct read. 48 + - Lexicon authoring discipline (brief §3): optional fields, open unions, additions-only. 49 + `version` lets us evolve the serialization without breaking old records; a breaking 50 + change ships as `blog.skypress.content.gutenberg` **v2** (a new $type), never a silent 51 + mutation. 52 + - Graceful degradation: any reader that doesn't understand this `$type` falls back to the 53 + document's `textContent` — which is why we always write good `textContent`. 54 + 55 + NSID rationale: the app lives at `skypress.blog`, so the reverse-DNS namespace is 56 + `blog.skypress.*` (brief §3). Only the content format is SkyPress-owned; publication + 57 + document metadata reuse `site.standard.*` for interop. 58 + 59 + ## Scope 60 + 61 + Writing these records needs write access. Per brief §2, use **`atproto 62 + transition:generic`** for now ("keep transition:generic until read-side scopes 63 + stabilize"). This is the existing `OAUTH_SCOPE`; SP2 wires it into the dev loopback 64 + client_id (which previously defaulted to read-only `atproto`), so the user re-auths once 65 + to grant writes. The granular `repo:site.standard.*` / `repo:app.bsky.feed.post` scopes 66 + (brief §2) are a later tightening. 67 + 68 + ## Edit semantics (the "puppy problem") — deferred to SP5, but constrained now 69 + 70 + SP2 only **creates**. The document uses a TID rkey (the lexicon mandates `key: 'tid'`), 71 + so a stable URL across edits will mean updating the same record (rkey) in SP5. SP2 does 72 + not implement re-publish/update; that decision (mutate vs. version vs. new record) is 73 + SP5's, and the lexicon's `updatedAt` field is reserved for it. 74 + 75 + ## Don't surprise the user (brief §10, non-negotiable) 76 + 77 + Publishing creates a **public Bluesky post**. The publish UI must state this 78 + unmistakably and require an explicit confirm before writing. Implemented in the publish 79 + panel.
+88
docs/specs/sp2-lexicon-and-publish.md
··· 1 + # SP2 — Lexicon + two-record publish 2 + 3 + - **Date:** 2026-06-08 4 + - **Status:** ✅ Complete — verified end-to-end with a real account (see Outcome) 5 + - **Goal (brief §1, §3, §9.3):** On publish, write the writer's content to their PDS as a 6 + `site.standard.document` (block tree in `content` + `textContent`), create their 7 + `site.standard.publication` if missing, and post a companion `app.bsky.feed.post` 8 + linking to the public article. Define + document `blog.skypress.content.gutenberg`. 9 + 10 + Design + verified lexicon shapes: [Decision 0005](../decisions/0005-lexicon-and-publish-model.md) 11 + and [`lexicons/`](../../lexicons/README.md). 12 + 13 + ## Success criteria 14 + 15 + 1. **Lexicon defined + documented** (`lexicons/`). ✅ (done up front) 16 + 2. **Pure record builders** produce valid `site.standard.publication`, 17 + `site.standard.document` (with the Gutenberg content object + `textContent`), and 18 + `app.bsky.feed.post` records — unit-tested. 19 + 3. **Publisher** orchestrates via the `Agent`: ensure-publication (list/reuse or create), 20 + create post, create document with `bskyPostRef`. Returns the document URI, post URI, 21 + and canonical article URL. 22 + 4. **Write scope**: the dev loopback client requests `atproto transition:generic` so 23 + `createRecord` is authorized (one re-auth). 24 + 5. **Publish UI**: title field + publish action that **unmistakably states it also posts 25 + to Bluesky** (brief §10) and requires confirmation; shows result links. 26 + 6. **Live-verified**: publishing a test article to a real account creates the records and 27 + a real Bluesky post (consent given). 28 + 29 + ## Architecture 30 + 31 + ``` 32 + src/lib/publish/ 33 + records.ts PURE, TESTABLE: slugify, canonical URLs, build {content, document, 34 + publication, bskyPost} record objects 35 + records.test.ts Vitest unit tests (written first) 36 + publisher.ts browser: ensurePublication() + publish() via the Agent 37 + src/components/ 38 + PublishPanel.tsx title input + "Publish" + Bluesky-post disclosure/confirm + result 39 + Studio.tsx lifts block state from SkyEditor; renders PublishPanel when signed in 40 + src/lib/auth/oauth.ts loopback client_id now carries scope = OAUTH_SCOPE (writes) 41 + ``` 42 + 43 + ## TDD plan (pure `records.ts`) 44 + 45 + - `slugify('Hello, World!')` → `hello-world` 46 + - `articlePath(slug)` → `/hello-world`; `publicationHomeUrl(handle)` → 47 + `https://skypress.blog/<handle>`; `canonicalArticleUrl(handle, slug)` 48 + - `buildContentObject(blocks)` → `{ $type, version:1, blocks }` 49 + - `buildDocumentRecord(...)` → required `site`/`title`/`publishedAt`, embeds content + 50 + textContent + bskyPostRef 51 + - `buildPublicationRecord(...)` → required `url`/`name` 52 + - `buildBskyPost(...)` → text + createdAt + `app.bsky.embed.external` → article URL 53 + 54 + ## Out of scope for SP2 55 + 56 + Edit / re-publish (SP5), cover-image & inline-image blobs (SP3), the live reading page at 57 + the canonical URL + link tags (SP4), granular per-collection scopes, self-hosted handle 58 + resolver. 59 + 60 + ## Outcome (2026-06-08) 61 + 62 + Verified by publishing twice to a **real** account (`jeherve.com`). Records fetched back 63 + from the PDS (`com.atproto.repo.getRecord`) confirm: 64 + 65 + | Check | Result | 66 + |---|---| 67 + | 22 unit tests (config + records) | pass | 68 + | `site.standard.document` created | `$type`, `site`, `title`, `path` (`/skypress-publication-test`), `publishedAt` all correct | 69 + | Gutenberg `content` | `$type: blog.skypress.content.gutenberg`, `version: 1`, block tree stored as **clean JSON** (rich-text → plain string — `normalizeBlocks` works) | 70 + | `textContent` | correct plain text | 71 + | `app.bsky.feed.post` | created (real Bluesky post), `app.bsky.embed.external` → canonical article URL; PDS validated it | 72 + | `bskyPostRef` | document points back at the post (`{uri, cid}`) | 73 + | publication | reused SkyPress's own (`url: https://skypress.blog/jeherve.com`), exactly one | 74 + 75 + **Bug found & fixed by the live test:** the account already had 3 76 + `site.standard.publication` records from other standard.site tools. `ensurePublication` 77 + originally reused `records[0]` (a foreign publication with a `localhost` URL), wrongly 78 + attaching the document to it. Fixed to match SkyPress's own publication by `url` 79 + (`publicationHomeUrl(handle)`), creating one only if absent. 80 + 81 + **Notes:** 82 + - Write access uses `atproto transition:generic`; wiring it into the dev loopback 83 + client_id required a one-time re-auth. The handle now resolves (the broader scope 84 + grants the appview `getProfile` read). 85 + - Two test articles + posts now exist on the account (consented). A delete/unpublish flow 86 + comes with the edit work (SP5); meanwhile they can be removed via Bluesky. 87 + - The Bluesky link card will only render richly once the reading page is live with the 88 + `site.standard` `<link>` tags (SP4) and the app is deployed (SP7).
+82
lexicons/README.md
··· 1 + # SkyPress lexicons 2 + 3 + SkyPress publishes to the AT Protocol using a mix of the community **`site.standard.*`** 4 + lexicons (for the interoperable publication + document surface) and **one SkyPress-owned 5 + lexicon** for the rich content format. This directory documents all of them — the records 6 + a SkyPress publish writes, and exactly which fields it populates. 7 + 8 + See [Decision 0005](../docs/decisions/0005-lexicon-and-publish-model.md) for the design 9 + rationale. 10 + 11 + ## Ownership 12 + 13 + | Lexicon | Owner | Why | 14 + |---|---|---| 15 + | `site.standard.publication` | community ([standard.site]) | interop — other readers (Leaflet, Pocketblog, pdsls, Bluesky cards) already speak it | 16 + | `site.standard.document` | community ([standard.site]) | interop — the discoverable article surface | 17 + | `blog.skypress.content.gutenberg` | **SkyPress** | the Gutenberg block-tree content format; reverse-DNS of `skypress.blog` | 18 + | `app.bsky.feed.post` | Bluesky | the social signal (POSSE) | 19 + 20 + We only own the content format. Publication/document metadata reuses `site.standard.*` so 21 + SkyPress articles are discoverable beyond SkyPress. 22 + 23 + ## `blog.skypress.content.gutenberg` (ours) 24 + 25 + Schema: [`blog.skypress.content.gutenberg.json`](./blog.skypress.content.gutenberg.json). 26 + 27 + An object placed inside a document's open `content` union (`$type: 28 + "blog.skypress.content.gutenberg"`): 29 + 30 + | Field | Type | Notes | 31 + |---|---|---| 32 + | `version` | integer | Serialization version (currently `1`). Breaking changes ship as a new `$type` (…`gutenberg` v2), never a silent mutation. | 33 + | `blocks` | array&lt;unknown&gt; | The block tree from `onSaveBlocks`: `[{ name, attributes, innerBlocks }]`. `name` is the Gutenberg block name (`core/paragraph`, …). | 34 + 35 + Authoring discipline (brief §3, Paul Frazee guidance): optional fields, open unions, 36 + additions-only; shipped constraints are frozen. Readers that don't understand this `$type` 37 + fall back to the document's `textContent`. 38 + 39 + ## `site.standard.document` — fields SkyPress writes 40 + 41 + Record key: **TID**. ([source](https://tangled.org/standard.site/lexicons)) 42 + 43 + | Field | Req | SkyPress value | 44 + |---|---|---| 45 + | `site` | ✓ | the writer's `site.standard.publication` `at://` URI (links doc↔publication for high-fidelity Bluesky cards) | 46 + | `title` | ✓ | document title (≤500 graphemes) | 47 + | `publishedAt` | ✓ | ISO datetime of publish | 48 + | `path` | | `/<slug>` — combined with the publication `url` to form the canonical URL | 49 + | `description` | | excerpt (optional) | 50 + | `textContent` | | **de-facto required** — plain text from the block tree; Bluesky uses it for reading-time + search and ignores `content` | 51 + | `content` | | open union — our `blog.skypress.content.gutenberg` object | 52 + | `bskyPostRef` | | strongRef (`{uri, cid}`) to the companion Bluesky post | 53 + | `updatedAt` | | reserved for the edit flow (SP5) | 54 + 55 + ## `site.standard.publication` — fields SkyPress writes 56 + 57 + Record key: **TID** (not a `self` singleton — reused by listing existing records). 58 + 59 + | Field | Req | SkyPress value | 60 + |---|---|---| 61 + | `url` | ✓ | `https://skypress.blog/<handle>` — the writer's SkyPress homepage; canonical doc URL = `url` + document `path` | 62 + | `name` | ✓ | publication name (defaults from the handle) | 63 + | `description` | | optional | 64 + | `icon` | | optional blob (≤1MB) | 65 + 66 + ## `app.bsky.feed.post` — the social signal 67 + 68 + | Field | SkyPress value | 69 + |---|---| 70 + | `text` | a short blurb (title + note) | 71 + | `createdAt` | ISO datetime | 72 + | `embed` | `app.bsky.embed.external` → the canonical article URL, title, description | 73 + 74 + ## The publish flow 75 + 76 + `ensure publication → create post (embed → article URL) → create document (content + 77 + textContent + bskyPostRef)`. The article URL is known up front from `handle` + `slug`, 78 + which avoids a circular dependency between the post and the document. Builders live in 79 + [`src/lib/publish/records.ts`](../src/lib/publish/records.ts); orchestration in 80 + [`publisher.ts`](../src/lib/publish/publisher.ts). 81 + 82 + [standard.site]: https://tangled.org/standard.site/lexicons
+23
lexicons/blog.skypress.content.gutenberg.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "blog.skypress.content.gutenberg", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "A WordPress Gutenberg block tree — the canonical content of a SkyPress document. Placed inside the open `content` union of a `site.standard.document`. Readers that don't understand this format should fall back to the document's `textContent`.", 8 + "required": ["version", "blocks"], 9 + "properties": { 10 + "version": { 11 + "type": "integer", 12 + "description": "Serialization version of the block tree. Bump only for breaking changes; non-breaking changes are additive. Currently 1.", 13 + "minimum": 1 14 + }, 15 + "blocks": { 16 + "type": "array", 17 + "description": "The Gutenberg block tree exactly as produced by the editor's `onSaveBlocks` (NOT rendered HTML). Each item is a block node: `{ name: string, attributes: object, innerBlocks: block[] }`, where `name` is the Gutenberg block name (e.g. `core/paragraph`). Items are typed `unknown` because block attributes are open-ended per block type.", 18 + "items": { "type": "unknown" } 19 + } 20 + } 21 + } 22 + } 23 + }
+114
src/components/PublishPanel.tsx
··· 1 + import { useState } from 'react'; 2 + import type { Agent } from '@atproto/api'; 3 + import type { BlockInstance } from '@wordpress/blocks'; 4 + import { publish, type Identity, type PublishResult } from '../lib/publish/publisher'; 5 + import { normalizeBlocks } from '../lib/publish/records'; 6 + 7 + type Phase = 'idle' | 'confirm' | 'publishing' | 'done' | 'error'; 8 + 9 + interface Props { 10 + agent: Agent; 11 + identity: Identity; 12 + blocks: BlockInstance[]; 13 + } 14 + 15 + /** 16 + * Title + publish control. Publishing creates a PUBLIC Bluesky post in addition to 17 + * the PDS document, so it requires an explicit, unmistakable confirmation (brief §10). 18 + */ 19 + export default function PublishPanel( { agent, identity, blocks }: Props ) { 20 + const [ title, setTitle ] = useState( '' ); 21 + const [ phase, setPhase ] = useState< Phase >( 'idle' ); 22 + const [ result, setResult ] = useState< PublishResult | null >( null ); 23 + const [ error, setError ] = useState< string | null >( null ); 24 + 25 + const canPublish = title.trim().length > 0 && blocks.length > 0; 26 + 27 + async function doPublish() { 28 + setPhase( 'publishing' ); 29 + setError( null ); 30 + try { 31 + const res = await publish( agent, identity, { 32 + title: title.trim(), 33 + blocks: normalizeBlocks( blocks ), 34 + } ); 35 + setResult( res ); 36 + setPhase( 'done' ); 37 + } catch ( err ) { 38 + setError( err instanceof Error ? err.message : String( err ) ); 39 + setPhase( 'error' ); 40 + } 41 + } 42 + 43 + return ( 44 + <section className="publish" aria-label="Publish"> 45 + <input 46 + className="publish__title" 47 + type="text" 48 + placeholder="Article title" 49 + value={ title } 50 + onChange={ ( event ) => setTitle( event.target.value ) } 51 + disabled={ phase === 'publishing' } 52 + /> 53 + 54 + { ( phase === 'idle' || phase === 'error' || phase === 'done' ) && ( 55 + <button 56 + className="publish__button" 57 + type="button" 58 + disabled={ ! canPublish } 59 + onClick={ () => setPhase( 'confirm' ) } 60 + > 61 + Publish… 62 + </button> 63 + ) } 64 + 65 + { phase === 'confirm' && ( 66 + <div className="publish__confirm" role="alertdialog" aria-label="Confirm publish"> 67 + <p className="publish__warning"> 68 + ⚠️ Publishing saves this article to <strong>your PDS</strong> and also 69 + creates a <strong>public Bluesky post</strong> linking to it. Everyone 70 + following you will see it. 71 + </p> 72 + <div className="publish__actions"> 73 + <button className="publish__button" type="button" onClick={ doPublish }> 74 + Publish &amp; post to Bluesky 75 + </button> 76 + <button 77 + className="publish__cancel" 78 + type="button" 79 + onClick={ () => setPhase( 'idle' ) } 80 + > 81 + Cancel 82 + </button> 83 + </div> 84 + </div> 85 + ) } 86 + 87 + { phase === 'publishing' && <p className="publish__status">Publishing…</p> } 88 + 89 + { phase === 'done' && result && ( 90 + <div className="publish__result"> 91 + <p>Published ✓ (the reading page goes live once SkyPress is deployed)</p> 92 + <ul> 93 + <li> 94 + Article URL: <code>{ result.articleUrl }</code> 95 + </li> 96 + <li> 97 + Bluesky post:{ ' ' } 98 + <code>{ result.postUri }</code> 99 + </li> 100 + <li> 101 + Document: <code>{ result.documentUri }</code> 102 + </li> 103 + </ul> 104 + </div> 105 + ) } 106 + 107 + { phase === 'error' && error && ( 108 + <p className="publish__error" role="alert"> 109 + Publish failed: { error } 110 + </p> 111 + ) } 112 + </section> 113 + ); 114 + }
+18 -12
src/components/SkyEditor.tsx
··· 10 10 11 11 export const SPIKE_BLOCKS_KEY = 'skypress:spike:blocks'; 12 12 13 + interface SkyEditorProps { 14 + /** Receives the live block tree on every change (for the publish flow, SP2). */ 15 + onChange?: ( blocks: BlockInstance[] ) => void; 16 + } 17 + 13 18 /** 14 - * SP0 spike island: a standalone Gutenberg editor restricted to the curated 15 - * SkyPress block set. `onSaveBlocks` fires on every change with the live block 16 - * tree (the structured array — not HTML); the spike persists it to localStorage 17 - * to prove the canonical content path. PDS persistence arrives in SP2. 19 + * Standalone Gutenberg editor restricted to the curated SkyPress block set. 20 + * `onSaveBlocks` fires on every change with the live block tree (the structured 21 + * array — not HTML); we persist a local draft and forward it via `onChange` so the 22 + * Studio can publish it (SP2). 18 23 * 19 24 * Rendered with `client:only="react"` so its (heavy) bundle never reaches 20 25 * reading pages (Decision 0001). 21 26 */ 22 - export default function SkyEditor() { 27 + export default function SkyEditor( { onChange }: SkyEditorProps ) { 23 28 const [ status, setStatus ] = useState< string >( 'Start writing…' ); 24 29 25 - const onSaveBlocks = useCallback( ( blocks: BlockInstance[] ) => { 26 - window.localStorage.setItem( 27 - SPIKE_BLOCKS_KEY, 28 - JSON.stringify( blocks ) 29 - ); 30 - setStatus( `Captured ${ blocks.length } top-level block(s) → localStorage` ); 31 - }, [] ); 30 + const onSaveBlocks = useCallback( 31 + ( blocks: BlockInstance[] ) => { 32 + window.localStorage.setItem( SPIKE_BLOCKS_KEY, JSON.stringify( blocks ) ); 33 + setStatus( `Draft saved · ${ blocks.length } block(s)` ); 34 + onChange?.( blocks ); 35 + }, 36 + [ onChange ] 37 + ); 32 38 33 39 const settings = { 34 40 iso: {
+8 -3
src/components/Studio.tsx
··· 1 + import { useState } from 'react'; 2 + import type { BlockInstance } from '@wordpress/blocks'; 1 3 import { AuthProvider } from '../lib/auth/AuthProvider'; 2 4 import { useAuth } from '../lib/auth/useAuth'; 3 5 import LoginForm from '../lib/auth/LoginForm'; 4 6 import SkyEditor from './SkyEditor'; 7 + import PublishPanel from './PublishPanel'; 5 8 6 9 /** 7 10 * The authenticated writing surface. Gates the editor behind atproto OAuth: 8 11 * loading → (signed-out: login form) | (signed-in: account bar + editor). 9 12 */ 10 13 function StudioGate() { 11 - const { status, handle, did, error, signOut } = useAuth(); 14 + const { status, agent, handle, did, error, signOut } = useAuth(); 15 + const [ blocks, setBlocks ] = useState< BlockInstance[] >( [] ); 12 16 13 17 if ( status === 'loading' ) { 14 18 return <p className="studio__loading">Connecting to your identity…</p>; 15 19 } 16 20 17 - if ( status === 'signed-in' ) { 21 + if ( status === 'signed-in' && agent && did ) { 18 22 return ( 19 23 <> 20 24 <div className="studio__account"> ··· 25 29 Sign out 26 30 </button> 27 31 </div> 28 - <SkyEditor /> 32 + <PublishPanel agent={ agent } identity={ { did, handle } } blocks={ blocks } /> 33 + <SkyEditor onChange={ setBlocks } /> 29 34 </> 30 35 ); 31 36 }
+6 -4
src/lib/auth/oauth.ts
··· 2 2 BrowserOAuthClient, 3 3 atprotoLoopbackClientMetadata, 4 4 } from '@atproto/oauth-client-browser'; 5 - import { getClientMode, clientMetadataUrl, HANDLE_RESOLVER } from './config'; 5 + import { getClientMode, clientMetadataUrl, HANDLE_RESOLVER, OAUTH_SCOPE } from './config'; 6 6 7 7 /** 8 8 * Create the browser OAuth client (Decision 0004). BROWSER-ONLY — reads ··· 19 19 export async function createOAuthClient(): Promise< BrowserOAuthClient > { 20 20 if ( getClientMode( window.location.hostname ) === 'loopback' ) { 21 21 const redirectUri = `${ window.location.origin }${ window.location.pathname }`; 22 - const clientId = `http://localhost?redirect_uri=${ encodeURIComponent( 23 - redirectUri 24 - ) }`; 22 + // Request the write scope in dev too (the default loopback scope is read-only 23 + // `atproto`), so createRecord works without a hosted metadata file (Decision 0005). 24 + const clientId = 25 + `http://localhost?redirect_uri=${ encodeURIComponent( redirectUri ) }` + 26 + `&scope=${ encodeURIComponent( OAUTH_SCOPE ) }`; 25 27 return new BrowserOAuthClient( { 26 28 clientMetadata: atprotoLoopbackClientMetadata( clientId ), 27 29 handleResolver: HANDLE_RESOLVER,
+136
src/lib/publish/publisher.ts
··· 1 + import type { Agent } from '@atproto/api'; 2 + import { 3 + buildPublicationRecord, 4 + buildDocumentRecord, 5 + buildBskyPost, 6 + canonicalArticleUrl, 7 + publicationHomeUrl, 8 + slugify, 9 + type StrongRef, 10 + } from './records'; 11 + import { blocksToText, type BlockNode } from '../blocks/render'; 12 + 13 + const PUBLICATION_COLLECTION = 'site.standard.publication'; 14 + const DOCUMENT_COLLECTION = 'site.standard.document'; 15 + const POST_COLLECTION = 'app.bsky.feed.post'; 16 + 17 + /** createRecord types `record` as an open index signature; our records are precise. */ 18 + function asRecord( value: object ): Record< string, unknown > { 19 + return value as Record< string, unknown >; 20 + } 21 + 22 + export interface Identity { 23 + did: string; 24 + /** Handle for human-readable URLs; falls back to DID if unresolved. */ 25 + handle: string | null; 26 + } 27 + 28 + export interface PublishInput { 29 + title: string; 30 + blocks: BlockNode[]; 31 + description?: string; 32 + } 33 + 34 + export interface PublishResult { 35 + publicationUri: string; 36 + documentUri: string; 37 + postUri: string; 38 + articleUrl: string; 39 + } 40 + 41 + /** 42 + * Find SkyPress's OWN publication (matched by its `url` = the writer's SkyPress 43 + * homepage), or create one. Returns its `at://` URI. 44 + * 45 + * Matching by URL matters: a writer may already have `site.standard.publication` 46 + * records from OTHER tools (Leaflet, Pocketblog, …). We must not attach SkyPress 47 + * documents to a foreign publication. 48 + */ 49 + async function ensurePublication( 50 + agent: Agent, 51 + did: string, 52 + handle: string 53 + ): Promise< string > { 54 + const wantUrl = publicationHomeUrl( handle ); 55 + const existing = await agent.com.atproto.repo.listRecords( { 56 + repo: did, 57 + collection: PUBLICATION_COLLECTION, 58 + limit: 100, 59 + } ); 60 + const ours = existing.data.records.find( 61 + ( record ) => ( record.value as { url?: string } )?.url === wantUrl 62 + ); 63 + if ( ours ) { 64 + return ours.uri; 65 + } 66 + const created = await agent.com.atproto.repo.createRecord( { 67 + repo: did, 68 + collection: PUBLICATION_COLLECTION, 69 + record: asRecord( buildPublicationRecord( { handle } ) ), 70 + } ); 71 + return created.data.uri; 72 + } 73 + 74 + /** 75 + * The two-record publish (Decision 0005). Order avoids a circular dependency: 76 + * the article URL is known from handle+slug, so we create the Bluesky post first, 77 + * then the document with `bskyPostRef`. 78 + * 79 + * NOTE: this also creates a PUBLIC Bluesky post. Callers must have made that 80 + * unmistakable to the user first (brief §10). 81 + */ 82 + export async function publish( 83 + agent: Agent, 84 + identity: Identity, 85 + input: PublishInput 86 + ): Promise< PublishResult > { 87 + const { did } = identity; 88 + const handle = identity.handle ?? did; 89 + const now = new Date().toISOString(); 90 + const slug = slugify( input.title ); 91 + const articleUrl = canonicalArticleUrl( handle, slug ); 92 + const textContent = blocksToText( input.blocks ); 93 + 94 + const publicationUri = await ensurePublication( agent, did, handle ); 95 + 96 + const postRes = await agent.com.atproto.repo.createRecord( { 97 + repo: did, 98 + collection: POST_COLLECTION, 99 + record: asRecord( 100 + buildBskyPost( { 101 + title: input.title, 102 + articleUrl, 103 + description: input.description, 104 + createdAt: now, 105 + } ) 106 + ), 107 + } ); 108 + const bskyPostRef: StrongRef = { 109 + uri: postRes.data.uri, 110 + cid: postRes.data.cid, 111 + }; 112 + 113 + const docRes = await agent.com.atproto.repo.createRecord( { 114 + repo: did, 115 + collection: DOCUMENT_COLLECTION, 116 + record: asRecord( 117 + buildDocumentRecord( { 118 + title: input.title, 119 + slug, 120 + blocks: input.blocks, 121 + textContent, 122 + siteUri: publicationUri, 123 + publishedAt: now, 124 + description: input.description, 125 + bskyPostRef, 126 + } ) 127 + ), 128 + } ); 129 + 130 + return { 131 + publicationUri, 132 + documentUri: docRes.data.uri, 133 + postUri: postRes.data.uri, 134 + articleUrl, 135 + }; 136 + }
+133
src/lib/publish/records.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { 3 + slugify, 4 + publicationHomeUrl, 5 + articlePath, 6 + canonicalArticleUrl, 7 + buildContentObject, 8 + buildPublicationRecord, 9 + buildDocumentRecord, 10 + buildBskyPost, 11 + normalizeBlocks, 12 + CONTENT_TYPE, 13 + CONTENT_VERSION, 14 + } from './records'; 15 + import type { BlockNode } from '../blocks/render'; 16 + 17 + const BLOCKS: BlockNode[] = [ 18 + { name: 'core/heading', attributes: { level: 1, content: 'Hi' }, innerBlocks: [] }, 19 + { name: 'core/paragraph', attributes: { content: 'Body' }, innerBlocks: [] }, 20 + ]; 21 + 22 + describe( 'slugify', () => { 23 + it( 'lowercases and hyphenates', () => { 24 + expect( slugify( 'Hello, World!' ) ).toBe( 'hello-world' ); 25 + expect( slugify( ' Multiple Spaces & Symbols!! ' ) ).toBe( 'multiple-spaces-symbols' ); 26 + } ); 27 + it( 'falls back to "untitled" for empty/symbol-only input', () => { 28 + expect( slugify( '' ) ).toBe( 'untitled' ); 29 + expect( slugify( '!!!' ) ).toBe( 'untitled' ); 30 + } ); 31 + } ); 32 + 33 + describe( 'URLs', () => { 34 + it( 'builds publication home + canonical article URLs', () => { 35 + expect( publicationHomeUrl( 'alice.bsky.social' ) ).toBe( 36 + 'https://skypress.blog/alice.bsky.social' 37 + ); 38 + expect( articlePath( 'hello-world' ) ).toBe( '/hello-world' ); 39 + expect( canonicalArticleUrl( 'alice.bsky.social', 'hello-world' ) ).toBe( 40 + 'https://skypress.blog/alice.bsky.social/hello-world' 41 + ); 42 + } ); 43 + } ); 44 + 45 + describe( 'normalizeBlocks', () => { 46 + it( 'strips clientId and keeps name/attributes/innerBlocks recursively', () => { 47 + const live = [ 48 + { 49 + name: 'core/list', 50 + clientId: 'abc-123', 51 + attributes: { ordered: false }, 52 + innerBlocks: [ 53 + { name: 'core/list-item', clientId: 'def-456', attributes: { content: 'One' }, innerBlocks: [] }, 54 + ], 55 + }, 56 + ]; 57 + expect( normalizeBlocks( live ) ).toEqual( [ 58 + { 59 + name: 'core/list', 60 + attributes: { ordered: false }, 61 + innerBlocks: [ 62 + { name: 'core/list-item', attributes: { content: 'One' }, innerBlocks: [] }, 63 + ], 64 + }, 65 + ] ); 66 + } ); 67 + } ); 68 + 69 + describe( 'buildContentObject', () => { 70 + it( 'wraps the block tree with the SkyPress content $type + version', () => { 71 + const content = buildContentObject( BLOCKS ); 72 + expect( content.$type ).toBe( CONTENT_TYPE ); 73 + expect( content.version ).toBe( CONTENT_VERSION ); 74 + expect( content.blocks ).toEqual( BLOCKS ); 75 + } ); 76 + } ); 77 + 78 + describe( 'buildPublicationRecord', () => { 79 + it( 'sets the required url + name', () => { 80 + const pub = buildPublicationRecord( { handle: 'alice.bsky.social' } ); 81 + expect( pub.$type ).toBe( 'site.standard.publication' ); 82 + expect( pub.url ).toBe( 'https://skypress.blog/alice.bsky.social' ); 83 + expect( pub.name ).toBeTruthy(); 84 + } ); 85 + } ); 86 + 87 + describe( 'buildDocumentRecord', () => { 88 + const base = { 89 + title: 'Hello, World!', 90 + slug: 'hello-world', 91 + blocks: BLOCKS, 92 + textContent: 'Hi\n\nBody', 93 + siteUri: 'at://did:plc:abc/site.standard.publication/xyz', 94 + publishedAt: '2026-06-08T12:00:00.000Z', 95 + }; 96 + 97 + it( 'includes the required fields, content object and textContent', () => { 98 + const doc = buildDocumentRecord( base ); 99 + expect( doc.$type ).toBe( 'site.standard.document' ); 100 + expect( doc.site ).toBe( base.siteUri ); 101 + expect( doc.title ).toBe( base.title ); 102 + expect( doc.path ).toBe( '/hello-world' ); 103 + expect( doc.publishedAt ).toBe( base.publishedAt ); 104 + expect( doc.textContent ).toBe( base.textContent ); 105 + expect( ( doc.content as { $type: string } ).$type ).toBe( CONTENT_TYPE ); 106 + } ); 107 + 108 + it( 'omits bskyPostRef unless provided', () => { 109 + expect( 'bskyPostRef' in buildDocumentRecord( base ) ).toBe( false ); 110 + const ref = { uri: 'at://did:plc:abc/app.bsky.feed.post/p', cid: 'bafy' }; 111 + expect( buildDocumentRecord( { ...base, bskyPostRef: ref } ).bskyPostRef ).toEqual( ref ); 112 + } ); 113 + } ); 114 + 115 + describe( 'buildBskyPost', () => { 116 + it( 'creates a post with an external embed pointing at the article', () => { 117 + const post = buildBskyPost( { 118 + title: 'Hello, World!', 119 + articleUrl: 'https://skypress.blog/alice.bsky.social/hello-world', 120 + description: 'An excerpt', 121 + createdAt: '2026-06-08T12:00:00.000Z', 122 + } ); 123 + expect( post.$type ).toBe( 'app.bsky.feed.post' ); 124 + expect( post.text ).toContain( 'Hello, World!' ); 125 + expect( post.createdAt ).toBe( '2026-06-08T12:00:00.000Z' ); 126 + expect( post.embed.$type ).toBe( 'app.bsky.embed.external' ); 127 + expect( post.embed.external.uri ).toBe( 128 + 'https://skypress.blog/alice.bsky.social/hello-world' 129 + ); 130 + expect( post.embed.external.title ).toBe( 'Hello, World!' ); 131 + expect( typeof post.embed.external.description ).toBe( 'string' ); 132 + } ); 133 + } );
+162
src/lib/publish/records.ts
··· 1 + /** 2 + * Pure builders for the records a SkyPress publish writes (Decision 0005). 3 + * 4 + * No `@atproto/*` or network here — just record-shaping, so it's fully unit-testable. 5 + * Orchestration (createRecord via the Agent) lives in `publisher.ts`. 6 + */ 7 + import type { BlockNode } from '../blocks/render'; 8 + 9 + /** Public base origin for canonical article URLs (finalised at deploy, SP7). */ 10 + export const SKYPRESS_BASE = 'https://skypress.blog'; 11 + 12 + export const CONTENT_TYPE = 'blog.skypress.content.gutenberg'; 13 + export const CONTENT_VERSION = 1; 14 + 15 + export interface StrongRef { 16 + uri: string; 17 + cid: string; 18 + } 19 + 20 + /** A URL/file-safe slug from a title; `untitled` when nothing usable remains. */ 21 + export function slugify( title: string ): string { 22 + const slug = title 23 + .toLowerCase() 24 + .normalize( 'NFKD' ) 25 + .replace( /[̀-ͯ]/g, '' ) 26 + .replace( /[^a-z0-9]+/g, '-' ) 27 + .replace( /^-+|-+$/g, '' ); 28 + return slug || 'untitled'; 29 + } 30 + 31 + /** The writer's SkyPress homepage — the publication's `url`. */ 32 + export function publicationHomeUrl( handle: string ): string { 33 + return `${ SKYPRESS_BASE }/${ handle }`; 34 + } 35 + 36 + /** The document `path` (leading slash, per the lexicon). */ 37 + export function articlePath( slug: string ): string { 38 + return `/${ slug }`; 39 + } 40 + 41 + /** Canonical article URL = publication url + path. */ 42 + export function canonicalArticleUrl( handle: string, slug: string ): string { 43 + return `${ publicationHomeUrl( handle ) }${ articlePath( slug ) }`; 44 + } 45 + 46 + /** 47 + * Normalise live editor blocks to plain `BlockNode` JSON for storage: keep only 48 + * `name`/`attributes`/`innerBlocks`, drop the transient `clientId`, and JSON-round-trip 49 + * attributes so rich-text values serialise to plain strings. 50 + */ 51 + export function normalizeBlocks( 52 + blocks: ReadonlyArray< { name: string; attributes?: unknown; innerBlocks?: unknown[] } > 53 + ): BlockNode[] { 54 + return blocks.map( ( block ) => ( { 55 + name: block.name, 56 + attributes: JSON.parse( JSON.stringify( block.attributes ?? {} ) ), 57 + innerBlocks: normalizeBlocks( 58 + ( block.innerBlocks ?? [] ) as Parameters< typeof normalizeBlocks >[ 0 ] 59 + ), 60 + } ) ); 61 + } 62 + 63 + export interface GutenbergContent { 64 + $type: typeof CONTENT_TYPE; 65 + version: number; 66 + blocks: BlockNode[]; 67 + } 68 + 69 + /** Wrap the block tree as the document's `content` union member. */ 70 + export function buildContentObject( blocks: BlockNode[] ): GutenbergContent { 71 + return { $type: CONTENT_TYPE, version: CONTENT_VERSION, blocks }; 72 + } 73 + 74 + export interface PublicationRecord { 75 + $type: 'site.standard.publication'; 76 + url: string; 77 + name: string; 78 + description?: string; 79 + } 80 + 81 + export function buildPublicationRecord( input: { 82 + handle: string; 83 + name?: string; 84 + description?: string; 85 + } ): PublicationRecord { 86 + return { 87 + $type: 'site.standard.publication', 88 + url: publicationHomeUrl( input.handle ), 89 + name: input.name ?? input.handle, 90 + ...( input.description ? { description: input.description } : {} ), 91 + }; 92 + } 93 + 94 + export interface DocumentRecord { 95 + $type: 'site.standard.document'; 96 + site: string; 97 + title: string; 98 + path: string; 99 + publishedAt: string; 100 + textContent: string; 101 + content: GutenbergContent; 102 + description?: string; 103 + bskyPostRef?: StrongRef; 104 + } 105 + 106 + export function buildDocumentRecord( input: { 107 + title: string; 108 + slug: string; 109 + blocks: BlockNode[]; 110 + textContent: string; 111 + siteUri: string; 112 + publishedAt: string; 113 + description?: string; 114 + bskyPostRef?: StrongRef; 115 + } ): DocumentRecord { 116 + return { 117 + $type: 'site.standard.document', 118 + site: input.siteUri, 119 + title: input.title, 120 + path: articlePath( input.slug ), 121 + publishedAt: input.publishedAt, 122 + textContent: input.textContent, 123 + content: buildContentObject( input.blocks ), 124 + ...( input.description ? { description: input.description } : {} ), 125 + ...( input.bskyPostRef ? { bskyPostRef: input.bskyPostRef } : {} ), 126 + }; 127 + } 128 + 129 + export interface BskyPostRecord { 130 + $type: 'app.bsky.feed.post'; 131 + text: string; 132 + createdAt: string; 133 + embed: { 134 + $type: 'app.bsky.embed.external'; 135 + external: { 136 + uri: string; 137 + title: string; 138 + description: string; 139 + }; 140 + }; 141 + } 142 + 143 + export function buildBskyPost( input: { 144 + title: string; 145 + articleUrl: string; 146 + createdAt: string; 147 + description?: string; 148 + } ): BskyPostRecord { 149 + return { 150 + $type: 'app.bsky.feed.post', 151 + text: `${ input.title }\n\n${ input.articleUrl }`, 152 + createdAt: input.createdAt, 153 + embed: { 154 + $type: 'app.bsky.embed.external', 155 + external: { 156 + uri: input.articleUrl, 157 + title: input.title, 158 + description: input.description ?? '', 159 + }, 160 + }, 161 + }; 162 + }
+70
src/pages/editor.astro
··· 114 114 font-size: 0.8rem; 115 115 margin-top: 1rem; 116 116 } 117 + 118 + /* Publish panel */ 119 + .publish { 120 + display: flex; 121 + flex-wrap: wrap; 122 + align-items: center; 123 + gap: 0.75rem; 124 + padding: 0.75rem 1.25rem; 125 + border-bottom: 1px solid #e7e3da; 126 + } 127 + .publish__title { 128 + flex: 1 1 18rem; 129 + padding: 0.5rem 0.7rem; 130 + border: 1px solid #d6d0c4; 131 + border-radius: 8px; 132 + font: inherit; 133 + font-size: 1.05rem; 134 + } 135 + .publish__button { 136 + padding: 0.5rem 1rem; 137 + border: 0; 138 + border-radius: 8px; 139 + background: var(--sky); 140 + color: #fff; 141 + font: inherit; 142 + font-weight: 600; 143 + cursor: pointer; 144 + } 145 + .publish__button:disabled { 146 + opacity: 0.5; 147 + cursor: not-allowed; 148 + } 149 + .publish__cancel { 150 + padding: 0.5rem 0.9rem; 151 + border: 1px solid #d6d0c4; 152 + background: #fff; 153 + border-radius: 8px; 154 + font: inherit; 155 + cursor: pointer; 156 + } 157 + .publish__confirm { 158 + flex: 1 1 100%; 159 + background: #fff7e6; 160 + border: 1px solid #f0d9a8; 161 + border-radius: 10px; 162 + padding: 0.85rem 1rem; 163 + } 164 + .publish__warning { 165 + margin: 0 0 0.75rem; 166 + } 167 + .publish__actions { 168 + display: flex; 169 + gap: 0.75rem; 170 + flex-wrap: wrap; 171 + } 172 + .publish__result, 173 + .publish__status, 174 + .publish__error { 175 + flex: 1 1 100%; 176 + font-size: 0.9rem; 177 + } 178 + .publish__result code { 179 + word-break: break-all; 180 + background: #f1eee7; 181 + padding: 0.1rem 0.3rem; 182 + border-radius: 4px; 183 + } 184 + .publish__error { 185 + color: #b3261e; 186 + } 117 187 </style>