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 SP5 edit flow + unpublish

Resolve the "puppy problem" (Decision 0008): edit a published article in place
and unpublish it.

- updateDocument: putRecord on the SAME rkey with updatedAt, preserving
publishedAt + bskyPostRef. URL stays stable; no new Bluesky post on edit.
- unpublish: deleteRecord the document AND its companion app.bsky.feed.post.
- listMyArticles: the writer's SkyPress documents (scoped to their publication),
with stored blocks for editing.
- SkyEditor loads existing content via onLoad(parse) => parse(serialize(blocks)).
- MyArticles list (Edit / Unpublish with confirm); PublishPanel gains an update
mode; Studio lifts edit state and re-mounts per article.
- Reading page shows "updated <date>" when updatedAt is present.
- buildDocumentRecord gained optional updatedAt (unit-tested).

Verified: 37 unit tests, astro check clean, production build green. The live
edit/unpublish round-trip is pending a signed-in session (a chrome-profile reset
cleared auth mid-test); it builds on the live-proven SP2 publish path and the
same agent.com.atproto.repo.* surface (putRecord/deleteRecord).

+542 -47
+1 -1
README.md
··· 62 62 | SP2 | Lexicon (`blog.skypress.*`) + two-record publish | ✅ Complete | 63 63 | SP3 | Image/blob pipeline (`mediaUpload` → `uploadBlob`) | ✅ Complete | 64 64 | SP4 | Public renderer (`/@<handle>/<rkey>`, link tags, edge SSR, sanitisation) | ✅ Complete | 65 - | SP5 | Edit flow (the "puppy problem") | Next | 65 + | SP5 | Edit flow (the "puppy problem") + unpublish | Implemented (live verify pending re-auth) | 66 66 | SP6 | Brand identity | | 67 67 | SP7 | Deploy to a free host | | 68 68
+53
docs/decisions/0008-edit-semantics.md
··· 1 + # 0008 — Edit semantics (the "puppy problem") 2 + 3 + - **Status:** Accepted 4 + - **Date:** 2026-06-08 5 + - **Scope:** SP5 — editing and unpublishing a published article 6 + 7 + ## Context (brief §3) 8 + 9 + Bluesky deliberately does **not** auto-refresh stale embeds. When a writer edits a 10 + published article, the brief asks us to choose and justify one of: 11 + 12 + 1. **Mutate** the existing record — simple, but silently changes shared context (anyone 13 + who saw/shared the old version now sees new content under the same link). 14 + 2. **Create a new record** — preserves an audit trail, but loses URL stability (the 15 + article gets a new rkey → a new URL; old links/cards break). 16 + 3. **Version with explicit "edited" markers** — mutate, but be transparent about it. 17 + 18 + ## Decision 19 + 20 + **Mutate the existing record (option 1 + 3): `putRecord` on the same rkey, with an 21 + explicit `updatedAt` and an "Updated" marker on the reading page.** 22 + 23 + - **URL stability wins.** SkyPress URLs are `…/@<handle>/<rkey>` (Decision 0005/0006), and 24 + the Bluesky post embeds that URL. Creating a new record would orphan the post's link 25 + card and every shared link. Mutating keeps the one canonical URL working. 26 + - **Transparency mitigates the silent-change downside.** We set `updatedAt` on edit and 27 + the reader shows "Updated <date>" alongside "Published <date>". Honest about edits 28 + without breaking links. 29 + - **No new Bluesky post on edit.** Publishing creates the social post once; editing must 30 + not spam the timeline. The original post keeps pointing at the (now-updated) article; 31 + its card stays stale by Bluesky's design — that's the accepted "puppy problem" cost of 32 + option 1, made visible by the `updatedAt` marker. 33 + - **`publishedAt` and `bskyPostRef` are preserved** across edits (only `content`, 34 + `textContent`, `title`, `description`, `updatedAt` change). 35 + 36 + A future, heavier option (versioned history records) is left open by the lexicon's 37 + open-ended design, but is not warranted for v1. 38 + 39 + ## Unpublish / delete 40 + 41 + Deleting an article removes **both** records it owns: the `site.standard.document` and the 42 + companion `app.bsky.feed.post` (via `deleteRecord`). The `site.standard.publication` is 43 + left intact (it may front other articles). Blobs become unreferenced and the PDS 44 + garbage-collects them in time. The UI confirms before deleting, since the Bluesky post 45 + disappears too. 46 + 47 + ## Consequences 48 + 49 + - Editing requires loading a stored article back into the editor: stored `BlockNode[]` 50 + → `@wordpress/blocks.serialize()` → the editor's `onLoad(parse)`. Core blocks are 51 + globally registered by the time `onLoad` runs, so this round-trips. 52 + - `updatedAt` is additive to the lexicon (already a `site.standard.document` field) — no 53 + schema change.
+46
docs/specs/sp5-edit-and-unpublish.md
··· 1 + # SP5 — Edit flow + unpublish 2 + 3 + - **Date:** 2026-06-08 4 + - **Status:** Implemented + unit/build-verified. **Live edit/unpublish round-trip pending 5 + a re-auth** (a chrome-profile reset cleared the session mid-test). 6 + - **Goal (brief §3, §9.6):** Resolve the "puppy problem" — let writers edit a published 7 + article and unpublish it. 8 + 9 + Decision: [0008](../decisions/0008-edit-semantics.md) — mutate in place (`putRecord`, 10 + same rkey), stamp `updatedAt`, preserve `publishedAt` + `bskyPostRef`, no new Bluesky 11 + post; unpublish deletes the document + its post. 12 + 13 + ## What was built 14 + 15 + - `publisher.updateDocument` — `putRecord` on the same rkey with `updatedAt`, preserving 16 + `publishedAt`/`bskyPostRef`. No new post. 17 + - `publisher.unpublish` — `deleteRecord` the document + its companion `app.bsky.feed.post`. 18 + - `publisher.listMyArticles` — the writer's SkyPress documents (scoped to their 19 + publication), including stored blocks for editing. 20 + - `SkyEditor` loads existing content via `onLoad(parse) => parse(serialize(blocks))`. 21 + - `MyArticles` — list with Edit / Unpublish (confirm before deleting). 22 + - `PublishPanel` gains an update mode ("Update", no Bluesky-post confirmation). 23 + - `Studio` lifts edit state, re-mounts the editor/panel per article, offers "+ New 24 + article". 25 + - Reading page shows "updated <date>" when `updatedAt` is present. 26 + - `buildDocumentRecord` gained an optional `updatedAt` (unit-tested). 27 + 28 + ## Verified 29 + 30 + - 37 Vitest tests green (incl. `updatedAt` preserve-publishedAt); `astro check` clean; 31 + production build green. 32 + 33 + ## Pending (needs a signed-in session) 34 + 35 + 1. Edit an existing article → confirm the document updates at the **same rkey**, 36 + `updatedAt` is set, `publishedAt`/`bskyPostRef` preserved, content changed, and **no new 37 + Bluesky post** is created. 38 + 2. Unpublish → confirm both the document and its Bluesky post are deleted (also clears the 39 + accumulated test articles). 40 + 41 + The publish path this builds on (SP2) is live-proven; `updateDocument`/`unpublish` use the 42 + same `agent.com.atproto.repo.*` surface (`putRecord`/`deleteRecord`). 43 + 44 + ## Out of scope 45 + 46 + Versioned history records, draft persistence with images, brand/design (SP6), deploy (SP7).
+82
src/components/MyArticles.tsx
··· 1 + import { useEffect, useState } from 'react'; 2 + import type { Agent } from '@atproto/api'; 3 + import { listMyArticles, unpublish, type MyArticle } from '../lib/publish/publisher'; 4 + 5 + interface Props { 6 + agent: Agent; 7 + did: string; 8 + handle: string; 9 + /** Bump to re-fetch after a publish/update. */ 10 + refreshKey: number; 11 + onEdit: ( article: MyArticle ) => void; 12 + } 13 + 14 + /** Lists the signed-in writer's SkyPress articles with edit + unpublish actions (SP5). */ 15 + export default function MyArticles( { agent, did, handle, refreshKey, onEdit }: Props ) { 16 + const [ articles, setArticles ] = useState< MyArticle[] | null >( null ); 17 + const [ busy, setBusy ] = useState< string | null >( null ); 18 + 19 + useEffect( () => { 20 + let cancelled = false; 21 + listMyArticles( agent, did, handle ) 22 + .then( ( list ) => ! cancelled && setArticles( list ) ) 23 + .catch( () => ! cancelled && setArticles( [] ) ); 24 + return () => { 25 + cancelled = true; 26 + }; 27 + }, [ agent, did, handle, refreshKey ] ); 28 + 29 + async function onDelete( article: MyArticle ) { 30 + const ok = window.confirm( 31 + `Unpublish “${ article.title }”?\n\nThis deletes the article AND its Bluesky post.` 32 + ); 33 + if ( ! ok ) { 34 + return; 35 + } 36 + setBusy( article.rkey ); 37 + try { 38 + await unpublish( agent, did, { 39 + rkey: article.rkey, 40 + bskyPostRef: article.bskyPostRef, 41 + } ); 42 + setArticles( ( prev ) => prev?.filter( ( a ) => a.rkey !== article.rkey ) ?? null ); 43 + } finally { 44 + setBusy( null ); 45 + } 46 + } 47 + 48 + if ( articles === null ) { 49 + return <p className="myarticles__loading">Loading your articles…</p>; 50 + } 51 + if ( articles.length === 0 ) { 52 + return null; 53 + } 54 + 55 + return ( 56 + <section className="myarticles" aria-label="Your articles"> 57 + <h2 className="myarticles__heading">Your articles</h2> 58 + <ul className="myarticles__list"> 59 + { articles.map( ( article ) => ( 60 + <li className="myarticles__item" key={ article.rkey }> 61 + <span className="myarticles__title"> 62 + { article.title } 63 + { article.updatedAt && <em className="myarticles__edited"> · edited</em> } 64 + </span> 65 + <span className="myarticles__actions"> 66 + <button type="button" onClick={ () => onEdit( article ) }> 67 + Edit 68 + </button> 69 + <button 70 + type="button" 71 + disabled={ busy === article.rkey } 72 + onClick={ () => void onDelete( article ) } 73 + > 74 + { busy === article.rkey ? 'Unpublishing…' : 'Unpublish' } 75 + </button> 76 + </span> 77 + </li> 78 + ) ) } 79 + </ul> 80 + </section> 81 + ); 82 + }
+74 -39
src/components/PublishPanel.tsx
··· 1 1 import { useState } from 'react'; 2 2 import type { Agent } from '@atproto/api'; 3 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'; 4 + import { 5 + publish, 6 + updateDocument, 7 + type Identity, 8 + } from '../lib/publish/publisher'; 9 + import { normalizeBlocks, type StrongRef } from '../lib/publish/records'; 6 10 import { attachBlobRefs } from '../lib/media/blob'; 7 11 import type { BlobRegistry } from '../lib/media/mediaUpload'; 8 12 9 - type Phase = 'idle' | 'confirm' | 'publishing' | 'done' | 'error'; 13 + type Phase = 'idle' | 'confirm' | 'working' | 'done' | 'error'; 14 + 15 + export interface EditingTarget { 16 + rkey: string; 17 + siteUri: string; 18 + publishedAt: string; 19 + bskyPostRef?: StrongRef; 20 + } 10 21 11 22 interface Props { 12 23 agent: Agent; 13 24 identity: Identity; 14 25 blocks: BlockInstance[]; 15 26 blobRegistry: BlobRegistry; 27 + /** When set, the panel updates an existing article instead of publishing a new one. */ 28 + editing?: EditingTarget; 29 + initialTitle?: string; 30 + /** Called after a successful publish/update so the parent can refresh. */ 31 + onComplete?: () => void; 16 32 } 17 33 18 34 /** 19 - * Title + publish control. Publishing creates a PUBLIC Bluesky post in addition to 20 - * the PDS document, so it requires an explicit, unmistakable confirmation (brief §10). 35 + * Title + publish/update control. Publishing a NEW article creates a public Bluesky 36 + * post, so it requires an explicit confirmation (brief §10). Editing updates the 37 + * existing record in place and does NOT create a new post (Decision 0008). 21 38 */ 22 - export default function PublishPanel( { agent, identity, blocks, blobRegistry }: Props ) { 23 - const [ title, setTitle ] = useState( '' ); 39 + export default function PublishPanel( { 40 + agent, 41 + identity, 42 + blocks, 43 + blobRegistry, 44 + editing, 45 + initialTitle, 46 + onComplete, 47 + }: Props ) { 48 + const [ title, setTitle ] = useState( initialTitle ?? '' ); 24 49 const [ phase, setPhase ] = useState< Phase >( 'idle' ); 25 - const [ result, setResult ] = useState< PublishResult | null >( null ); 50 + const [ resultUrl, setResultUrl ] = useState< string | null >( null ); 26 51 const [ error, setError ] = useState< string | null >( null ); 27 52 28 - const canPublish = title.trim().length > 0 && blocks.length > 0; 53 + const isEditing = Boolean( editing ); 54 + const canSubmit = title.trim().length > 0 && blocks.length > 0; 29 55 30 - async function doPublish() { 31 - setPhase( 'publishing' ); 56 + async function run() { 57 + setPhase( 'working' ); 32 58 setError( null ); 33 59 try { 34 - // Normalise (strip clientId), then persist blob refs for uploaded images. 35 60 const prepared = attachBlobRefs( normalizeBlocks( blocks ), ( url ) => 36 61 blobRegistry.get( url ) 37 62 ); 38 - const res = await publish( agent, identity, { 39 - title: title.trim(), 40 - blocks: prepared, 41 - } ); 42 - setResult( res ); 63 + if ( editing ) { 64 + const res = await updateDocument( agent, identity, { 65 + rkey: editing.rkey, 66 + siteUri: editing.siteUri, 67 + publishedAt: editing.publishedAt, 68 + bskyPostRef: editing.bskyPostRef, 69 + title: title.trim(), 70 + blocks: prepared, 71 + } ); 72 + setResultUrl( res.articleUrl ); 73 + } else { 74 + const res = await publish( agent, identity, { 75 + title: title.trim(), 76 + blocks: prepared, 77 + } ); 78 + setResultUrl( res.articleUrl ); 79 + } 43 80 setPhase( 'done' ); 81 + onComplete?.(); 44 82 } catch ( err ) { 45 83 setError( err instanceof Error ? err.message : String( err ) ); 46 84 setPhase( 'error' ); ··· 48 86 } 49 87 50 88 return ( 51 - <section className="publish" aria-label="Publish"> 89 + <section className="publish" aria-label={ isEditing ? 'Update article' : 'Publish' }> 52 90 <input 53 91 className="publish__title" 54 92 type="text" 55 93 placeholder="Article title" 56 94 value={ title } 57 95 onChange={ ( event ) => setTitle( event.target.value ) } 58 - disabled={ phase === 'publishing' } 96 + disabled={ phase === 'working' } 59 97 /> 60 98 61 99 { ( phase === 'idle' || phase === 'error' || phase === 'done' ) && ( 62 100 <button 63 101 className="publish__button" 64 102 type="button" 65 - disabled={ ! canPublish } 66 - onClick={ () => setPhase( 'confirm' ) } 103 + disabled={ ! canSubmit } 104 + onClick={ () => ( isEditing ? void run() : setPhase( 'confirm' ) ) } 67 105 > 68 - Publish… 106 + { isEditing ? 'Update' : 'Publish…' } 69 107 </button> 70 108 ) } 71 109 ··· 77 115 following you will see it. 78 116 </p> 79 117 <div className="publish__actions"> 80 - <button className="publish__button" type="button" onClick={ doPublish }> 118 + <button className="publish__button" type="button" onClick={ run }> 81 119 Publish &amp; post to Bluesky 82 120 </button> 83 121 <button ··· 91 129 </div> 92 130 ) } 93 131 94 - { phase === 'publishing' && <p className="publish__status">Publishing…</p> } 132 + { phase === 'working' && ( 133 + <p className="publish__status">{ isEditing ? 'Updating…' : 'Publishing…' }</p> 134 + ) } 95 135 96 - { phase === 'done' && result && ( 136 + { phase === 'done' && resultUrl && ( 97 137 <div className="publish__result"> 98 - <p>Published ✓ (the reading page goes live once SkyPress is deployed)</p> 99 - <ul> 100 - <li> 101 - Article URL: <code>{ result.articleUrl }</code> 102 - </li> 103 - <li> 104 - Bluesky post:{ ' ' } 105 - <code>{ result.postUri }</code> 106 - </li> 107 - <li> 108 - Document: <code>{ result.documentUri }</code> 109 - </li> 110 - </ul> 138 + <p> 139 + { isEditing 140 + ? 'Updated ✓ (same URL; the original Bluesky post stays — its preview may not refresh)' 141 + : 'Published ✓ (the reading page goes live once SkyPress is deployed)' } 142 + </p> 143 + <p> 144 + Article URL: <code>{ resultUrl }</code> 145 + </p> 111 146 </div> 112 147 ) } 113 148 114 149 { phase === 'error' && error && ( 115 150 <p className="publish__error" role="alert"> 116 - Publish failed: { error } 151 + { isEditing ? 'Update' : 'Publish' } failed: { error } 117 152 </p> 118 153 ) } 119 154 </section>
+14 -2
src/components/SkyEditor.tsx
··· 1 1 import { useCallback, useState } from 'react'; 2 2 import IsolatedBlockEditor from '@automattic/isolated-block-editor'; 3 - import type { BlockInstance } from '@wordpress/blocks'; 3 + import { serialize, type BlockInstance } from '@wordpress/blocks'; 4 + import type { BlockNode } from '../lib/blocks/render'; 4 5 5 6 // Compiled editor-chrome styles (prebuilt — no Sass needed for these). 6 7 import '@automattic/isolated-block-editor/build-browser/core.css'; ··· 16 17 onChange?: ( blocks: BlockInstance[] ) => void; 17 18 /** Custom Gutenberg media handler — uploads to the PDS as a blob (SP3). */ 18 19 mediaUpload?: MediaUploadHandler; 20 + /** Existing article content to load when editing (SP5). */ 21 + initialBlocks?: BlockNode[]; 19 22 } 20 23 21 24 /** ··· 27 30 * Rendered with `client:only="react"` so its (heavy) bundle never reaches 28 31 * reading pages (Decision 0001). 29 32 */ 30 - export default function SkyEditor( { onChange, mediaUpload }: SkyEditorProps ) { 33 + export default function SkyEditor( { onChange, mediaUpload, initialBlocks }: SkyEditorProps ) { 31 34 const [ status, setStatus ] = useState< string >( 'Start writing…' ); 32 35 36 + // Load existing content (editing): serialize the stored tree to block markup and let 37 + // the editor parse it. Core blocks are registered globally by the time this runs. 38 + const onLoad = 39 + initialBlocks && initialBlocks.length > 0 40 + ? ( parse: ( html: string ) => BlockInstance[] ) => 41 + parse( serialize( initialBlocks as unknown as BlockInstance[] ) ) 42 + : undefined; 43 + 33 44 const onSaveBlocks = useCallback( 34 45 ( blocks: BlockInstance[] ) => { 35 46 window.localStorage.setItem( SPIKE_BLOCKS_KEY, JSON.stringify( blocks ) ); ··· 56 67 <IsolatedBlockEditor 57 68 settings={ settings } 58 69 onSaveBlocks={ onSaveBlocks } 70 + onLoad={ onLoad } 59 71 onError={ () => ( 60 72 <p role="alert">The editor hit an error. Reload to try again.</p> 61 73 ) }
+57 -5
src/components/Studio.tsx
··· 5 5 import LoginForm from '../lib/auth/LoginForm'; 6 6 import SkyEditor from './SkyEditor'; 7 7 import PublishPanel from './PublishPanel'; 8 + import MyArticles from './MyArticles'; 8 9 import { createMediaUpload, type BlobRegistry } from '../lib/media/mediaUpload'; 10 + import type { MyArticle } from '../lib/publish/publisher'; 9 11 10 12 /** 11 13 * The authenticated writing surface. Gates the editor behind atproto OAuth: ··· 14 16 function StudioGate() { 15 17 const { status, agent, handle, did, pdsUrl, error, signOut } = useAuth(); 16 18 const [ blocks, setBlocks ] = useState< BlockInstance[] >( [] ); 19 + const [ editing, setEditing ] = useState< MyArticle | null >( null ); 20 + const [ refreshKey, setRefreshKey ] = useState( 0 ); 17 21 // Shared between mediaUpload (writes blob refs) and publish (reads them). 18 22 const registry = useRef< BlobRegistry >( new Map() ).current; 19 23 ··· 29 33 } 30 34 31 35 if ( status === 'signed-in' && agent && did ) { 36 + // Re-mount the editor + panel when switching article so onLoad + the title reset. 37 + const editorKey = editing ? `edit-${ editing.rkey }` : `new-${ refreshKey }`; 38 + 39 + const startEdit = ( article: MyArticle ) => { 40 + setEditing( article ); 41 + setBlocks( article.blocks as unknown as BlockInstance[] ); 42 + }; 43 + const startNew = () => { 44 + setEditing( null ); 45 + setBlocks( [] ); 46 + }; 47 + 32 48 return ( 33 49 <> 34 50 <div className="studio__account"> ··· 39 55 Sign out 40 56 </button> 41 57 </div> 42 - <PublishPanel 58 + 59 + <MyArticles 43 60 agent={ agent } 44 - identity={ { did, handle } } 45 - blocks={ blocks } 46 - blobRegistry={ registry } 61 + did={ did } 62 + handle={ handle ?? did } 63 + refreshKey={ refreshKey } 64 + onEdit={ startEdit } 47 65 /> 48 - <SkyEditor onChange={ setBlocks } mediaUpload={ mediaUpload } /> 66 + 67 + <div className="studio__mode"> 68 + <span>{ editing ? `Editing: ${ editing.title }` : 'New article' }</span> 69 + { editing && ( 70 + <button type="button" onClick={ startNew }> 71 + + New article 72 + </button> 73 + ) } 74 + </div> 75 + 76 + <div key={ editorKey }> 77 + <PublishPanel 78 + agent={ agent } 79 + identity={ { did, handle } } 80 + blocks={ blocks } 81 + blobRegistry={ registry } 82 + editing={ 83 + editing 84 + ? { 85 + rkey: editing.rkey, 86 + siteUri: editing.siteUri, 87 + publishedAt: editing.publishedAt ?? new Date().toISOString(), 88 + bskyPostRef: editing.bskyPostRef, 89 + } 90 + : undefined 91 + } 92 + initialTitle={ editing?.title } 93 + onComplete={ () => setRefreshKey( ( k ) => k + 1 ) } 94 + /> 95 + <SkyEditor 96 + onChange={ setBlocks } 97 + mediaUpload={ mediaUpload } 98 + initialBlocks={ editing?.blocks } 99 + /> 100 + </div> 49 101 </> 50 102 ); 51 103 }
+136
src/lib/publish/publisher.ts
··· 137 137 articleUrl, 138 138 }; 139 139 } 140 + 141 + export interface UpdateInput extends PublishInput { 142 + /** The existing document's record key (URL stays stable, Decision 0008). */ 143 + rkey: string; 144 + /** Preserved from the original publish. */ 145 + siteUri: string; 146 + publishedAt: string; 147 + bskyPostRef?: StrongRef; 148 + } 149 + 150 + /** 151 + * Edit a published article in place (Decision 0008): `putRecord` on the SAME rkey, 152 + * stamping `updatedAt` while preserving `publishedAt` + `bskyPostRef`. Does NOT create a 153 + * new Bluesky post — the original post keeps pointing at the (now-updated) URL. 154 + */ 155 + export async function updateDocument( 156 + agent: Agent, 157 + identity: Identity, 158 + input: UpdateInput 159 + ): Promise< { documentUri: string; articleUrl: string } > { 160 + const { did } = identity; 161 + const handle = identity.handle ?? did; 162 + const now = new Date().toISOString(); 163 + const articleUrl = canonicalArticleUrl( handle, input.rkey ); 164 + 165 + const res = await agent.com.atproto.repo.putRecord( { 166 + repo: did, 167 + collection: DOCUMENT_COLLECTION, 168 + rkey: input.rkey, 169 + record: asRecord( 170 + buildDocumentRecord( { 171 + title: input.title, 172 + rkey: input.rkey, 173 + blocks: input.blocks, 174 + textContent: blocksToText( input.blocks ), 175 + siteUri: input.siteUri, 176 + publishedAt: input.publishedAt, 177 + description: input.description, 178 + bskyPostRef: input.bskyPostRef, 179 + updatedAt: now, 180 + } ) 181 + ), 182 + } ); 183 + return { documentUri: res.data.uri, articleUrl }; 184 + } 185 + 186 + /** rkey is the last segment of an AT-URI. */ 187 + function rkeyFromUri( uri: string ): string { 188 + return uri.split( '/' ).pop() ?? ''; 189 + } 190 + 191 + /** 192 + * Unpublish an article (Decision 0008): delete the document AND its companion Bluesky 193 + * post. The publication record is left intact. 194 + */ 195 + export async function unpublish( 196 + agent: Agent, 197 + did: string, 198 + input: { rkey: string; bskyPostRef?: StrongRef } 199 + ): Promise< void > { 200 + await agent.com.atproto.repo.deleteRecord( { 201 + repo: did, 202 + collection: DOCUMENT_COLLECTION, 203 + rkey: input.rkey, 204 + } ); 205 + if ( input.bskyPostRef?.uri ) { 206 + try { 207 + await agent.com.atproto.repo.deleteRecord( { 208 + repo: did, 209 + collection: POST_COLLECTION, 210 + rkey: rkeyFromUri( input.bskyPostRef.uri ), 211 + } ); 212 + } catch { 213 + // the post may already be gone; the document deletion is what matters 214 + } 215 + } 216 + } 217 + 218 + export interface MyArticle { 219 + rkey: string; 220 + title: string; 221 + description?: string; 222 + publishedAt?: string; 223 + updatedAt?: string; 224 + siteUri: string; 225 + bskyPostRef?: StrongRef; 226 + blocks: BlockNode[]; 227 + } 228 + 229 + /** List the signed-in writer's own SkyPress articles (scoped to their publication). */ 230 + export async function listMyArticles( 231 + agent: Agent, 232 + did: string, 233 + handle: string 234 + ): Promise< MyArticle[] > { 235 + const wantUrl = publicationHomeUrl( handle ); 236 + const pubs = await agent.com.atproto.repo.listRecords( { 237 + repo: did, 238 + collection: PUBLICATION_COLLECTION, 239 + limit: 100, 240 + } ); 241 + const ours = pubs.data.records.find( 242 + ( record ) => ( record.value as { url?: string } )?.url === wantUrl 243 + ); 244 + if ( ! ours ) { 245 + return []; 246 + } 247 + const docs = await agent.com.atproto.repo.listRecords( { 248 + repo: did, 249 + collection: DOCUMENT_COLLECTION, 250 + limit: 100, 251 + } ); 252 + return docs.data.records 253 + .filter( ( record ) => ( record.value as { site?: string } )?.site === ours.uri ) 254 + .map( ( record ) => { 255 + const value = record.value as { 256 + title?: string; 257 + description?: string; 258 + publishedAt?: string; 259 + updatedAt?: string; 260 + site?: string; 261 + bskyPostRef?: StrongRef; 262 + content?: { blocks?: BlockNode[] }; 263 + }; 264 + return { 265 + rkey: rkeyFromUri( record.uri ), 266 + title: value.title ?? 'Untitled', 267 + description: value.description, 268 + publishedAt: value.publishedAt, 269 + updatedAt: value.updatedAt, 270 + siteUri: value.site ?? ours.uri, 271 + bskyPostRef: value.bskyPostRef, 272 + blocks: value.content?.blocks ?? [], 273 + }; 274 + } ); 275 + }
+7
src/lib/publish/records.test.ts
··· 98 98 const ref = { uri: 'at://did:plc:abc/app.bsky.feed.post/p', cid: 'bafy' }; 99 99 expect( buildDocumentRecord( { ...base, bskyPostRef: ref } ).bskyPostRef ).toEqual( ref ); 100 100 } ); 101 + 102 + it( 'omits updatedAt on first publish, includes it on edit', () => { 103 + expect( 'updatedAt' in buildDocumentRecord( base ) ).toBe( false ); 104 + const edited = buildDocumentRecord( { ...base, updatedAt: '2026-06-09T09:00:00.000Z' } ); 105 + expect( edited.updatedAt ).toBe( '2026-06-09T09:00:00.000Z' ); 106 + expect( edited.publishedAt ).toBe( base.publishedAt ); // preserved 107 + } ); 101 108 } ); 102 109 103 110 describe( 'buildBskyPost', () => {
+4
src/lib/publish/records.ts
··· 97 97 content: GutenbergContent; 98 98 description?: string; 99 99 bskyPostRef?: StrongRef; 100 + updatedAt?: string; 100 101 } 101 102 102 103 export function buildDocumentRecord( input: { ··· 108 109 publishedAt: string; 109 110 description?: string; 110 111 bskyPostRef?: StrongRef; 112 + /** Set on edit (Decision 0008); preserves publishedAt + bskyPostRef. */ 113 + updatedAt?: string; 111 114 } ): DocumentRecord { 112 115 return { 113 116 $type: 'site.standard.document', ··· 119 122 content: buildContentObject( input.blocks ), 120 123 ...( input.description ? { description: input.description } : {} ), 121 124 ...( input.bskyPostRef ? { bskyPostRef: input.bskyPostRef } : {} ), 125 + ...( input.updatedAt ? { updatedAt: input.updatedAt } : {} ), 122 126 }; 123 127 } 124 128
+3
src/pages/[author]/[rkey].astro
··· 20 20 description?: string; 21 21 textContent?: string; 22 22 publishedAt?: string; 23 + updatedAt?: string; 23 24 site?: string; 24 25 content?: { blocks?: BlockNode[] }; 25 26 } ··· 56 57 const words = textContent.split( /\s+/ ).filter( Boolean ).length; 57 58 const readingMinutes = Math.max( 1, Math.round( words / 200 ) ); 58 59 const publishedLabel = doc.publishedAt ? doc.publishedAt.slice( 0, 10 ) : null; 60 + const updatedLabel = doc.updatedAt ? doc.updatedAt.slice( 0, 10 ) : null; 59 61 --- 60 62 61 63 <Base title={`${ title } — SkyPress`} description={description}> ··· 76 78 <p class="reader__meta"> 77 79 <a class="reader__author" href={`/@${ handle }`}>@{handle}</a> 78 80 {publishedLabel && <> · {publishedLabel}</>} 81 + {updatedLabel && <> · updated {updatedLabel}</>} 79 82 · {readingMinutes} min read 80 83 </p> 81 84 <h1 class="reader__title">{title}</h1>
+65
src/pages/editor.astro
··· 184 184 .publish__error { 185 185 color: #b3261e; 186 186 } 187 + 188 + /* Your articles + mode bar */ 189 + .myarticles { 190 + padding: 1rem 1.25rem; 191 + border-bottom: 1px solid #e7e3da; 192 + } 193 + .myarticles__heading { 194 + font-size: 0.75rem; 195 + text-transform: uppercase; 196 + letter-spacing: 0.1em; 197 + color: var(--muted); 198 + margin: 0 0 0.5rem; 199 + } 200 + .myarticles__loading { 201 + padding: 1rem 1.25rem; 202 + color: var(--muted); 203 + font-size: 0.9rem; 204 + } 205 + .myarticles__list { 206 + list-style: none; 207 + margin: 0; 208 + padding: 0; 209 + } 210 + .myarticles__item { 211 + display: flex; 212 + align-items: center; 213 + justify-content: space-between; 214 + gap: 1rem; 215 + padding: 0.4rem 0; 216 + } 217 + .myarticles__edited { 218 + color: var(--muted); 219 + font-style: normal; 220 + font-size: 0.85rem; 221 + } 222 + .myarticles__actions { 223 + display: flex; 224 + gap: 0.5rem; 225 + } 226 + .myarticles__actions button { 227 + border: 1px solid #d6d0c4; 228 + background: #fff; 229 + border-radius: 6px; 230 + padding: 0.25rem 0.6rem; 231 + font: inherit; 232 + font-size: 0.85rem; 233 + cursor: pointer; 234 + } 235 + .studio__mode { 236 + display: flex; 237 + align-items: center; 238 + justify-content: space-between; 239 + gap: 1rem; 240 + padding: 0.5rem 1.25rem; 241 + font-size: 0.85rem; 242 + color: var(--muted); 243 + } 244 + .studio__mode button { 245 + border: 1px solid #d6d0c4; 246 + background: #fff; 247 + border-radius: 6px; 248 + padding: 0.25rem 0.7rem; 249 + font: inherit; 250 + cursor: pointer; 251 + } 187 252 </style>