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 publication system: dashboard, creation flow, publication pages

SkyPress auto-created one invisible publication per user (url = /@handle)
and had no UI to manage it. Replace that with a real, user-managed system:
multiple publications, a dashboard, a create/edit flow (name, logo,
description), and publication-centric public pages.

URL model becomes handle-namespaced (Decision 0010), all resolved from the
PDS so the no-database/no-KV stance holds — no global slug registry:

/@handle author index (lists the author's SkyPress publications)
/@handle/{slug} a publication's home
/@handle/{slug}/{rkey} a document within that publication

Slugs are auto-derived from the name, de-duplicated within the writer's own
repo, and frozen into publication.url so a rename never breaks links. SkyPress
manages only publications whose url origin is its own, so a record written by
another standard.site tool (Leaflet, …) is never listed, edited, or rendered.
Deleting a publication cascades to its documents and their Bluesky posts.

Publishing now targets a chosen publication (the editor gains a selector); the
implicit ensurePublication is gone. The author index revives the paused profile
work, reading app.bsky.actor.profile straight from the PDS (no appview) for
name/bio/avatar/cover — rendered as text, never injected as HTML.

Guardrails kept: reading pages import no @wordpress JS, every PDS-derived fetch
goes through safe-fetch, and the Bluesky-post disclosure stays on publish.

Spec: docs/specs/sp10-publication-system.md. Built test-driven (records, CRUD,
publish target, cascade delete, slug rules, profile + publication resolution).

+2785 -241
+7 -6
README.md
··· 121 121 122 122 ``` 123 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 124 + pages/ index · editor · dashboard · client-metadata.json.ts (OAuth client doc, worker route) 125 + [author]/index.astro (author index) · [author]/[slug]/index.astro (publication) 126 + · [author]/[slug]/[rkey].astro (read-through document reader) 127 + components/ Studio · SkyEditor · PublishPanel · MyArticles · Dashboard · PublicationForm · Logo 127 128 lib/ 128 129 blocks/ render.ts (dependency-free reader) · serialize.ts (@wordpress oracle) · allowlist.ts 129 130 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 131 + publish/ records.ts (pure builders) · publisher.ts (Agent orchestration) · publications.ts (publication CRUD) 132 + media/ mediaUpload.ts · uploadImage.ts (logo) · blob.ts · pds.ts 133 + reader/ identity.ts · records.ts · publications.ts · profile.ts · sanitize.ts 133 134 net/ safe-fetch.ts (SSRF guard for the reader's outbound fetches) 134 135 lexicons/ blog.skypress.content.gutenberg.json + README 135 136 docs/ decisions/ (why) · specs/ (how) · brand/
+75
docs/decisions/0010-publication-system-url-model.md
··· 1 + # 0010 — Publication system: handle-namespaced URLs, frozen slugs, ownership, delete 2 + 3 + - **Status:** Accepted 4 + - **Date:** 2026-06-08 5 + - **Scope:** SP10 — publication dashboard, creation flow, publication pages 6 + 7 + ## Context 8 + 9 + SkyPress auto-created one invisible publication per user (name = handle, `url = /@handle`) 10 + and matched it by URL. We now support **multiple, user-managed publications** with a 11 + dashboard, logos, and publication-centric public pages. This forces decisions on the URL 12 + model, slugs, which publications SkyPress "owns", and what deleting one does — all under the 13 + brief's hard **no-database / no-KV** constraint. 14 + 15 + ## Decision 16 + 17 + ### 1. Handle-namespaced URLs (option A), no global slug registry 18 + 19 + ``` 20 + /@handle author index 21 + /@handle/{slug} publication home 22 + /@handle/{slug}/{rkey} document 23 + ``` 24 + 25 + A publication's `url` becomes `https://skypress.blog/@{handle}/{slug}`. Everything resolves 26 + from the PDS: resolve handle → DID → PDS, list that one repo's publications, match by slug 27 + segment. **No global registry** means subdomains (`slug.skypress.blog`) are explicitly out 28 + for v1 (they'd need a KV/D1 registry + wildcard DNS — a separate sub-project). 29 + 30 + ### 2. Slugs are auto-derived and frozen 31 + 32 + The slug is derived from the name at creation (`slugify`), de-duplicated **within the user's 33 + own repo only** (`-2`, `-3`, …), and **frozen into `publication.url`**. Later name edits never 34 + change it — URLs must never break (consistent with documents addressed by stable rkey, 35 + Decisions 0005/0008). Empty/emoji-only names fall back to `pub-{rkey}` (the publication 36 + record's own TID rkey is globally unique → no collision check). The rkey is generated up 37 + front (`TID.nextStr()`, the publisher.ts pattern) so the fallback URL is known before the 38 + record is written. 39 + 40 + ### 3. SkyPress only manages publications it owns (origin === SITE_BASE) 41 + 42 + A user may hold `site.standard.publication` records from other tools sharing the lexicon 43 + (Leaflet, etc.). SkyPress lists, edits, deletes, renders, and resolves **only** publications 44 + whose `url` origin equals `SITE_BASE` (`isSkyPressPublicationUrl`). This keeps the dashboard 45 + from offering to delete a foreign tool's publication, keeps slug resolution unambiguous, and 46 + honours the lexicon's "additions only / don't touch others' data" discipline. The replaced 47 + `ensurePublication` matched a single pub by exact URL for the same reason; this generalises it. 48 + 49 + ### 4. Publishing targets a chosen publication 50 + 51 + `ensurePublication` (auto-create one pub per handle) is removed. `publish()` now takes the 52 + target publication's `{ uri, slug }`; the editor presents a selector. A user with no 53 + publication is sent to the dashboard to create one first. Resolution still verifies 54 + `doc.site === publication.uri` so a document only renders under its true publication. 55 + 56 + ### 5. Deleting a publication cascades 57 + 58 + Danger-zone delete removes the publication **and** its documents **and** each document's 59 + companion `app.bsky.feed.post` (reusing `unpublish` semantics, Decision 0008), then the 60 + publication record. The alternative — refusing to delete a non-empty publication — leaves 61 + orphaned, unreachable documents in the PDS and is worse UX. The confirm dialog states exactly 62 + how many articles and Bluesky posts will be removed, since this is destructive and outward- 63 + facing. Blobs (logo, images) become unreferenced and the PDS garbage-collects them. 64 + 65 + ## Consequences 66 + 67 + - `publicationHomeUrl(handle, slug)` and `canonicalArticleUrl(handle, slug, rkey)` change 68 + signatures; `records.test.ts` is updated (TDD). 69 + - The reader route splits: `[author]/[slug]/index.astro` + `[author]/[slug]/[rkey].astro`; 70 + `[author]/[rkey].astro` is removed. No migration — skypress.blog is not yet live. 71 + - `/@handle` becomes the author index, reviving the paused profile work: it reads 72 + `app.bsky.actor.profile` from the PDS directly (no Bluesky appview dependency) for 73 + name/bio/avatar/banner; those are rendered as **text**/images, never injected as HTML. 74 + - Slug freezing means a renamed publication keeps a slug derived from its old name. Accepted: 75 + URL stability beats prettiness, and there is no slug registry to rename against.
+74
docs/specs/sp10-publication-system.md
··· 1 + # SP10 — Publication system (dashboard + creation flow + publication pages) 2 + 3 + - **Status:** Built 4 + - **Depends on:** SP2 (lexicon/publish), SP3 (blob pipeline), SP4 (public renderer), SP5 (edit/unpublish) 5 + - **Decisions:** 0010 (URL model + slug + ownership + delete semantics) 6 + 7 + ## Goal 8 + 9 + Turn SkyPress's single invisible auto-publication into a real, user-managed 10 + **publication system**: a dashboard, a create/edit flow (name, logo, description), multiple 11 + publications per user, a handle-namespaced URL model, and publication-centric public pages. 12 + `/@handle` becomes an author index. 13 + 14 + Everything resolves from the PDS — **no database, no KV, no global slug registry** (brief §2). 15 + 16 + ## URL model (Decision 0010) 17 + 18 + ``` 19 + skypress.blog/@handle → author index (the author's SkyPress publications) 20 + skypress.blog/@handle/{slug} → a publication's home (its article list + identity) 21 + skypress.blog/@handle/{slug}/{rkey} → a single document within that publication 22 + ``` 23 + 24 + Falls out of `standard.site`: document URL = `publication.url` + `document.path`. 25 + - `publication.url` = `https://skypress.blog/@{handle}/{slug}` (was `…/@{handle}`). 26 + - `document.path` stays `/{rkey}` (unchanged `articlePath`). 27 + 28 + Resolving `/@handle/{slug}/{rkey}`: 29 + 1. `resolveAuthor(handle)` → `{ did, pdsUrl }`. 30 + 2. `listRecords(site.standard.publication)` for that repo; keep only **SkyPress-origin** 31 + publications (`url` origin === `SITE_BASE`); pick the one whose url slug segment === `{slug}`. 32 + 3. `getRecord` the document by rkey; **verify `doc.site === publication.uri`**. 33 + 34 + ## Slug rules (Decision 0010) 35 + 36 + - Auto-derived from the name at creation, never user-entered. 37 + - `slugify`: NFKD-normalise → lowercase → trim → spaces→`-` → strip to `[a-z0-9-]` → 38 + collapse/trim dashes. 39 + - Uniqueness **within the user's own repo only** (their existing SkyPress pub slugs); on 40 + collision append `-2`, `-3`, … 41 + - **Frozen at creation** — baked into `publication.url`; name edits never change it. 42 + - Empty/emoji-only name → fallback `pub-{rkey}` (rkey of the publication record, globally 43 + unique, so no collision check needed). 44 + 45 + ## Lexicon additions (`site.standard.publication`) 46 + 47 + Add the optional `icon` blob (the logo, ≤1MB, `image/*`). `theme`/`basicTheme`/`preferences` 48 + remain out of scope. Additions only; treat shipped fields as frozen. 49 + 50 + ## Work breakdown 51 + 52 + - **A. records.ts** — `PublicationRecord.icon`; slug in `url`; `publicationHomeUrl(handle, slug)`; 53 + `canonicalArticleUrl(handle, slug, rkey)`; pure `slugify` + `uniquePublicationSlug` + 54 + `publicationSlugFromUrl` + `isSkyPressPublicationUrl`. (TDD) 55 + - **B. publications.ts** — `createPublication` / `listPublications` / `updatePublication` / 56 + `deletePublication` (cascade). Replace `ensurePublication`: `publish()` targets a **chosen** 57 + publication. (TDD with a mock Agent) 58 + - **C. Logo upload** — `uploadImageBlob(agent, file)` reusing the blob pipeline; client-side 59 + ≤1MB / `image/*` enforcement. 60 + - **D. Dashboard island** — `src/pages/dashboard.astro` → `Dashboard.tsx` (authed, client-only, 61 + no `@wordpress/*`). Lists publications + "New publication". 62 + - **E. Create/edit UI** — `PublicationForm.tsx` (name, logo, description); edit preserves the 63 + frozen slug. 64 + - **F. Routing** — `[author]/[slug]/[rkey].astro` (doc) + `[author]/[slug]/index.astro` (pub 65 + page); update document template links + head tags. Remove `[author]/[rkey].astro`. 66 + - **G. `/@handle` author index** — list the author's SkyPress publications + profile hero 67 + (avatar/bio/cover) read from `app.bsky.actor.profile` on the PDS directly (no appview). 68 + - **H. Migration** — none needed; skypress.blog is not live yet (§7 of the handoff). 69 + 70 + ## Guardrails (AGENTS.md + decisions) 71 + 72 + No DB/KV. React 18 only; reading pages never import `@wordpress/*`. SSRF guard on every 73 + user-derived server fetch. Sanitise PDS HTML; profile/pub name+description are plain text. 74 + Keep the Bluesky-post disclosure on publish. License GPL-2.0-or-later.
+385
src/components/Dashboard.tsx
··· 1 + import { useEffect, useState } from 'react'; 2 + import type { Agent } from '@atproto/api'; 3 + import { AuthProvider } from '../lib/auth/AuthProvider'; 4 + import { useAuth } from '../lib/auth/useAuth'; 5 + import LoginForm from '../lib/auth/LoginForm'; 6 + import PublicationForm from './PublicationForm'; 7 + import { 8 + listPublications, 9 + deletePublication, 10 + type Publication, 11 + } from '../lib/publish/publications'; 12 + import { 13 + listPublicationArticles, 14 + unpublish, 15 + type MyArticle, 16 + } from '../lib/publish/publisher'; 17 + import { buildGetBlobUrl } from '../lib/media/blob'; 18 + 19 + type View = 20 + | { kind: 'list' } 21 + | { kind: 'create' } 22 + | { kind: 'manage'; pub: Publication }; 23 + 24 + /** The publication dashboard (SP10, step D). Authed, client-only — no `@wordpress/*`. */ 25 + function DashboardGate() { 26 + const { status, agent, handle, did, pdsUrl, error, signOut } = useAuth(); 27 + const [ publications, setPublications ] = useState< Publication[] | null >( null ); 28 + const [ view, setView ] = useState< View >( { kind: 'list' } ); 29 + 30 + useEffect( () => { 31 + if ( ! agent || ! did ) { 32 + return; 33 + } 34 + let cancelled = false; 35 + listPublications( agent, did ) 36 + .then( ( list ) => ! cancelled && setPublications( list ) ) 37 + .catch( () => ! cancelled && setPublications( [] ) ); 38 + return () => { 39 + cancelled = true; 40 + }; 41 + }, [ agent, did ] ); 42 + 43 + if ( status === 'loading' ) { 44 + return <p className="dash__loading">Connecting to your identity…</p>; 45 + } 46 + 47 + if ( status !== 'signed-in' || ! agent || ! did ) { 48 + return ( 49 + <div className="dash__login"> 50 + <LoginForm /> 51 + { status === 'error' && error && ( 52 + <p className="dash__error" role="alert"> 53 + Couldn't start the auth client: { error } 54 + </p> 55 + ) } 56 + </div> 57 + ); 58 + } 59 + 60 + const writerHandle = handle ?? did; 61 + 62 + const reload = () => { 63 + listPublications( agent, did ) 64 + .then( setPublications ) 65 + .catch( () => setPublications( [] ) ); 66 + }; 67 + 68 + return ( 69 + <div className="dash"> 70 + <header className="dash__bar"> 71 + <button 72 + type="button" 73 + className="dash__crumb" 74 + onClick={ () => setView( { kind: 'list' } ) } 75 + disabled={ view.kind === 'list' } 76 + > 77 + Your publications 78 + </button> 79 + <span className="dash__bar-actions"> 80 + <a className="dash__link" href="/editor"> 81 + Open the studio 82 + </a> 83 + <button type="button" className="dash__signout" onClick={ () => void signOut() }> 84 + Sign out 85 + </button> 86 + </span> 87 + </header> 88 + 89 + { view.kind === 'create' && ( 90 + <PublicationForm 91 + agent={ agent } 92 + did={ did } 93 + pdsUrl={ pdsUrl } 94 + handle={ writerHandle } 95 + onSaved={ () => { 96 + reload(); 97 + setView( { kind: 'list' } ); 98 + } } 99 + onCancel={ () => setView( { kind: 'list' } ) } 100 + /> 101 + ) } 102 + 103 + { view.kind === 'manage' && ( 104 + <PublicationManager 105 + agent={ agent } 106 + did={ did } 107 + pdsUrl={ pdsUrl } 108 + handle={ writerHandle } 109 + pub={ view.pub } 110 + onChanged={ ( pub ) => { 111 + reload(); 112 + setView( { kind: 'manage', pub } ); 113 + } } 114 + onDeleted={ () => { 115 + reload(); 116 + setView( { kind: 'list' } ); 117 + } } 118 + onBack={ () => setView( { kind: 'list' } ) } 119 + /> 120 + ) } 121 + 122 + { view.kind === 'list' && ( 123 + <PublicationList 124 + publications={ publications } 125 + did={ did } 126 + handle={ writerHandle } 127 + pdsUrl={ pdsUrl } 128 + onNew={ () => setView( { kind: 'create' } ) } 129 + onManage={ ( pub ) => setView( { kind: 'manage', pub } ) } 130 + /> 131 + ) } 132 + </div> 133 + ); 134 + } 135 + 136 + function PublicationList( { 137 + publications, 138 + did, 139 + handle, 140 + pdsUrl, 141 + onNew, 142 + onManage, 143 + }: { 144 + publications: Publication[] | null; 145 + did: string; 146 + handle: string; 147 + pdsUrl: string | null; 148 + onNew: () => void; 149 + onManage: ( pub: Publication ) => void; 150 + } ) { 151 + if ( publications === null ) { 152 + return <p className="dash__loading">Loading your publications…</p>; 153 + } 154 + return ( 155 + <section className="dash__section"> 156 + <div className="dash__section-head"> 157 + <h1 className="dash__h1">Your publications</h1> 158 + <button type="button" className="dash__new" onClick={ onNew }> 159 + + New publication 160 + </button> 161 + </div> 162 + 163 + { publications.length === 0 ? ( 164 + <p className="dash__empty"> 165 + You don't have any publications yet. Create one to start publishing. 166 + </p> 167 + ) : ( 168 + <ul className="dash__pubs"> 169 + { publications.map( ( pub ) => { 170 + const logoUrl = 171 + pub.icon && pdsUrl 172 + ? buildGetBlobUrl( pdsUrl, did, pub.icon.ref.$link ) 173 + : null; 174 + return ( 175 + <li className="dash__pub" key={ pub.uri }> 176 + { logoUrl ? ( 177 + <img className="dash__publogo" src={ logoUrl } alt="" width={ 48 } height={ 48 } /> 178 + ) : ( 179 + <span className="dash__publogo dash__publogo--fallback" aria-hidden="true"> 180 + { pub.name.charAt( 0 ).toUpperCase() } 181 + </span> 182 + ) } 183 + <span className="dash__pubtext"> 184 + <span className="dash__pubname">{ pub.name }</span> 185 + <span className="dash__pubslug">/{ pub.slug }</span> 186 + </span> 187 + <span className="dash__pubactions"> 188 + <a className="dash__link" href={ `/@${ handle }/${ pub.slug }` }> 189 + View 190 + </a> 191 + <button type="button" onClick={ () => onManage( pub ) }> 192 + Manage 193 + </button> 194 + </span> 195 + </li> 196 + ); 197 + } ) } 198 + </ul> 199 + ) } 200 + </section> 201 + ); 202 + } 203 + 204 + type Tab = 'posts' | 'settings' | 'delete'; 205 + 206 + function PublicationManager( { 207 + agent, 208 + did, 209 + pdsUrl, 210 + handle, 211 + pub, 212 + onChanged, 213 + onDeleted, 214 + onBack, 215 + }: { 216 + agent: Agent; 217 + did: string; 218 + pdsUrl: string | null; 219 + handle: string; 220 + pub: Publication; 221 + onChanged: ( pub: Publication ) => void; 222 + onDeleted: () => void; 223 + onBack: () => void; 224 + } ) { 225 + const [ tab, setTab ] = useState< Tab >( 'posts' ); 226 + const [ articles, setArticles ] = useState< MyArticle[] | null >( null ); 227 + const [ busy, setBusy ] = useState< string | null >( null ); 228 + const [ deleting, setDeleting ] = useState( false ); 229 + 230 + useEffect( () => { 231 + let cancelled = false; 232 + listPublicationArticles( agent, did, { uri: pub.uri, slug: pub.slug } ) 233 + .then( ( list ) => ! cancelled && setArticles( list ) ) 234 + .catch( () => ! cancelled && setArticles( [] ) ); 235 + return () => { 236 + cancelled = true; 237 + }; 238 + }, [ agent, did, pub.uri, pub.slug ] ); 239 + 240 + async function onUnpublish( article: MyArticle ) { 241 + const ok = window.confirm( 242 + `Unpublish “${ article.title }”?\n\nThis deletes the article AND its Bluesky post.` 243 + ); 244 + if ( ! ok ) { 245 + return; 246 + } 247 + setBusy( article.rkey ); 248 + try { 249 + await unpublish( agent, did, { rkey: article.rkey, bskyPostRef: article.bskyPostRef } ); 250 + setArticles( ( prev ) => prev?.filter( ( a ) => a.rkey !== article.rkey ) ?? null ); 251 + } finally { 252 + setBusy( null ); 253 + } 254 + } 255 + 256 + async function onDelete() { 257 + const count = articles?.length ?? 0; 258 + const ok = window.confirm( 259 + `Delete the publication “${ pub.name }”?\n\n` + 260 + `This permanently removes the publication and its ${ count } article${ 261 + count === 1 ? '' : 's' 262 + } — and each article's companion Bluesky post. This cannot be undone.` 263 + ); 264 + if ( ! ok ) { 265 + return; 266 + } 267 + setDeleting( true ); 268 + try { 269 + await deletePublication( agent, did, { uri: pub.uri, rkey: pub.rkey } ); 270 + onDeleted(); 271 + } catch { 272 + setDeleting( false ); 273 + } 274 + } 275 + 276 + return ( 277 + <section className="dash__section"> 278 + <div className="dash__section-head"> 279 + <h1 className="dash__h1">{ pub.name }</h1> 280 + <a className="dash__link" href={ `/@${ handle }/${ pub.slug }` }> 281 + View public page 282 + </a> 283 + </div> 284 + 285 + <nav className="dash__tabs"> 286 + <button 287 + type="button" 288 + className={ tab === 'posts' ? 'is-active' : '' } 289 + onClick={ () => setTab( 'posts' ) } 290 + > 291 + Posts 292 + </button> 293 + <button 294 + type="button" 295 + className={ tab === 'settings' ? 'is-active' : '' } 296 + onClick={ () => setTab( 'settings' ) } 297 + > 298 + Settings 299 + </button> 300 + <button 301 + type="button" 302 + className={ tab === 'delete' ? 'is-active' : '' } 303 + onClick={ () => setTab( 'delete' ) } 304 + > 305 + Delete 306 + </button> 307 + </nav> 308 + 309 + { tab === 'posts' && ( 310 + <div className="dash__posts"> 311 + { articles === null ? ( 312 + <p className="dash__loading">Loading posts…</p> 313 + ) : articles.length === 0 ? ( 314 + <p className="dash__empty"> 315 + No posts yet. <a href="/editor">Open the studio</a> to write one. 316 + </p> 317 + ) : ( 318 + <ul className="dash__postlist"> 319 + { articles.map( ( article ) => ( 320 + <li className="dash__post" key={ article.rkey }> 321 + <a 322 + className="dash__postlink" 323 + href={ `/@${ handle }/${ pub.slug }/${ article.rkey }` } 324 + > 325 + { article.title } 326 + </a> 327 + { article.publishedAt && ( 328 + <span className="dash__postdate"> 329 + { article.publishedAt.slice( 0, 10 ) } 330 + </span> 331 + ) } 332 + <button 333 + type="button" 334 + disabled={ busy === article.rkey } 335 + onClick={ () => void onUnpublish( article ) } 336 + > 337 + { busy === article.rkey ? 'Unpublishing…' : 'Unpublish' } 338 + </button> 339 + </li> 340 + ) ) } 341 + </ul> 342 + ) } 343 + </div> 344 + ) } 345 + 346 + { tab === 'settings' && ( 347 + <PublicationForm 348 + agent={ agent } 349 + did={ did } 350 + pdsUrl={ pdsUrl } 351 + handle={ handle } 352 + existing={ pub } 353 + onSaved={ onChanged } 354 + onCancel={ onBack } 355 + /> 356 + ) } 357 + 358 + { tab === 'delete' && ( 359 + <div className="dash__danger"> 360 + <h2>Delete this publication</h2> 361 + <p> 362 + This permanently removes <strong>{ pub.name }</strong>, all of its articles, and 363 + each article's companion Bluesky post. This cannot be undone. 364 + </p> 365 + <button 366 + type="button" 367 + className="dash__delete" 368 + onClick={ () => void onDelete() } 369 + disabled={ deleting } 370 + > 371 + { deleting ? 'Deleting…' : 'Delete publication' } 372 + </button> 373 + </div> 374 + ) } 375 + </section> 376 + ); 377 + } 378 + 379 + export default function Dashboard() { 380 + return ( 381 + <AuthProvider> 382 + <DashboardGate /> 383 + </AuthProvider> 384 + ); 385 + }
+6 -6
src/components/MyArticles.tsx
··· 1 1 import { useEffect, useState } from 'react'; 2 2 import type { Agent } from '@atproto/api'; 3 - import { listMyArticles, unpublish, type MyArticle } from '../lib/publish/publisher'; 3 + import { listAllMyArticles, unpublish, type MyArticle } from '../lib/publish/publisher'; 4 4 5 5 interface Props { 6 6 agent: Agent; 7 7 did: string; 8 - handle: string; 9 8 /** Bump to re-fetch after a publish/update. */ 10 9 refreshKey: number; 11 10 onEdit: ( article: MyArticle ) => void; 12 11 } 13 12 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 ) { 13 + /** Lists the signed-in writer's SkyPress articles across all their publications (SP5/SP10). */ 14 + export default function MyArticles( { agent, did, refreshKey, onEdit }: Props ) { 16 15 const [ articles, setArticles ] = useState< MyArticle[] | null >( null ); 17 16 const [ busy, setBusy ] = useState< string | null >( null ); 18 17 19 18 useEffect( () => { 20 19 let cancelled = false; 21 - listMyArticles( agent, did, handle ) 20 + listAllMyArticles( agent, did ) 22 21 .then( ( list ) => ! cancelled && setArticles( list ) ) 23 22 .catch( () => ! cancelled && setArticles( [] ) ); 24 23 return () => { 25 24 cancelled = true; 26 25 }; 27 - }, [ agent, did, handle, refreshKey ] ); 26 + }, [ agent, did, refreshKey ] ); 28 27 29 28 async function onDelete( article: MyArticle ) { 30 29 const ok = window.confirm( ··· 60 59 <li className="myarticles__item" key={ article.rkey }> 61 60 <span className="myarticles__title"> 62 61 { article.title } 62 + <em className="myarticles__pub"> · { article.siteSlug }</em> 63 63 { article.updatedAt && <em className="myarticles__edited"> · edited</em> } 64 64 </span> 65 65 <span className="myarticles__actions">
+200
src/components/PublicationForm.tsx
··· 1 + import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react'; 2 + import type { Agent } from '@atproto/api'; 3 + import { 4 + createPublication, 5 + updatePublication, 6 + type Publication, 7 + } from '../lib/publish/publications'; 8 + import { 9 + uploadImageBlob, 10 + ImageValidationError, 11 + PUBLICATION_ICON_MAX_BYTES, 12 + } from '../lib/media/uploadImage'; 13 + import { buildGetBlobUrl, type BlobRefJson } from '../lib/media/blob'; 14 + 15 + interface Props { 16 + agent: Agent; 17 + did: string; 18 + /** Needed to preview an already-stored logo via getBlob; may be null if unresolved. */ 19 + pdsUrl: string | null; 20 + handle: string; 21 + /** When set, the form edits this publication (slug stays frozen). Otherwise it creates one. */ 22 + existing?: Publication; 23 + onSaved: ( pub: Publication ) => void; 24 + onCancel: () => void; 25 + } 26 + 27 + const ICON_LIMIT_MB = Math.round( ( PUBLICATION_ICON_MAX_BYTES / 1_000_000 ) * 100 ) / 100; 28 + 29 + /** 30 + * Create / edit a publication (SP10, step E): name (required), logo (optional, ≤1MB image), 31 + * description (optional). The slug is auto-derived and never shown as an input. On edit the 32 + * slug is frozen — a rename never changes the URL. 33 + */ 34 + export default function PublicationForm( { 35 + agent, 36 + did, 37 + pdsUrl, 38 + handle, 39 + existing, 40 + onSaved, 41 + onCancel, 42 + }: Props ) { 43 + const [ name, setName ] = useState( existing?.name ?? '' ); 44 + const [ description, setDescription ] = useState( existing?.description ?? '' ); 45 + const [ icon, setIcon ] = useState< BlobRefJson | null >( existing?.icon ?? null ); 46 + const [ iconPreview, setIconPreview ] = useState< string | null >( 47 + existing?.icon && pdsUrl 48 + ? buildGetBlobUrl( pdsUrl, did, existing.icon.ref.$link ) 49 + : null 50 + ); 51 + const [ uploading, setUploading ] = useState( false ); 52 + const [ saving, setSaving ] = useState( false ); 53 + const [ error, setError ] = useState< string | null >( null ); 54 + // Track any object URL we minted for the preview so we can revoke it (avoid a blob leak). 55 + const objectUrlRef = useRef< string | null >( null ); 56 + 57 + useEffect( 58 + () => () => { 59 + if ( objectUrlRef.current ) { 60 + URL.revokeObjectURL( objectUrlRef.current ); 61 + } 62 + }, 63 + [] 64 + ); 65 + 66 + const isEditing = Boolean( existing ); 67 + 68 + async function onPickFile( event: ChangeEvent< HTMLInputElement > ) { 69 + const file = event.target.files?.[ 0 ]; 70 + if ( ! file ) { 71 + return; 72 + } 73 + setError( null ); 74 + setUploading( true ); 75 + try { 76 + const ref = await uploadImageBlob( agent, file ); 77 + setIcon( ref ); 78 + // Preview from a local object URL — instant, and avoids a getBlob round-trip lag. 79 + // Revoke any previous one first so re-picking doesn't leak blob URLs. 80 + if ( objectUrlRef.current ) { 81 + URL.revokeObjectURL( objectUrlRef.current ); 82 + } 83 + objectUrlRef.current = URL.createObjectURL( file ); 84 + setIconPreview( objectUrlRef.current ); 85 + } catch ( err ) { 86 + setError( 87 + err instanceof ImageValidationError || err instanceof Error 88 + ? err.message 89 + : String( err ) 90 + ); 91 + } finally { 92 + setUploading( false ); 93 + } 94 + } 95 + 96 + async function onSubmit( event: FormEvent ) { 97 + event.preventDefault(); 98 + if ( ! name.trim() ) { 99 + setError( 'Give your publication a name.' ); 100 + return; 101 + } 102 + setSaving( true ); 103 + setError( null ); 104 + try { 105 + const input = { 106 + name: name.trim(), 107 + description: description.trim() || undefined, 108 + icon: icon ?? undefined, 109 + }; 110 + const saved = existing 111 + ? await updatePublication( agent, did, handle, existing, input ) 112 + : await createPublication( agent, did, handle, input ); 113 + onSaved( saved ); 114 + } catch ( err ) { 115 + setError( err instanceof Error ? err.message : String( err ) ); 116 + setSaving( false ); 117 + } 118 + } 119 + 120 + return ( 121 + <form className="pubform" onSubmit={ onSubmit }> 122 + <h2 className="pubform__title"> 123 + { isEditing ? 'Publication settings' : 'New publication' } 124 + </h2> 125 + 126 + <div className="pubform__logo-row"> 127 + { iconPreview ? ( 128 + <img className="pubform__logo" src={ iconPreview } alt="" width={ 72 } height={ 72 } /> 129 + ) : ( 130 + <span className="pubform__logo pubform__logo--fallback" aria-hidden="true"> 131 + { ( name.trim().charAt( 0 ) || '✦' ).toUpperCase() } 132 + </span> 133 + ) } 134 + <label className="pubform__logo-label"> 135 + <span>Logo</span> 136 + <input 137 + type="file" 138 + accept="image/*" 139 + onChange={ ( event ) => void onPickFile( event ) } 140 + disabled={ uploading || saving } 141 + /> 142 + <small>Optional · square image · {ICON_LIMIT_MB}MB max</small> 143 + { uploading && <small>Uploading…</small> } 144 + </label> 145 + </div> 146 + 147 + <label className="pubform__field"> 148 + <span>Name</span> 149 + <input 150 + type="text" 151 + value={ name } 152 + onChange={ ( event ) => setName( event.target.value ) } 153 + placeholder="My Publication" 154 + maxLength={ 128 } 155 + disabled={ saving } 156 + required 157 + /> 158 + </label> 159 + 160 + <label className="pubform__field"> 161 + <span>Description</span> 162 + <textarea 163 + value={ description } 164 + onChange={ ( event ) => setDescription( event.target.value ) } 165 + placeholder="What this publication is about (optional)." 166 + maxLength={ 300 } 167 + rows={ 3 } 168 + disabled={ saving } 169 + /> 170 + </label> 171 + 172 + { isEditing && ( 173 + <p className="pubform__note"> 174 + The URL (<code>/{ existing!.slug }</code>) stays the same when you rename — links 175 + never break. 176 + </p> 177 + ) } 178 + 179 + { error && ( 180 + <p className="pubform__error" role="alert"> 181 + { error } 182 + </p> 183 + ) } 184 + 185 + <div className="pubform__actions"> 186 + <button className="pubform__save" type="submit" disabled={ saving || uploading }> 187 + { saving ? 'Saving…' : isEditing ? 'Save changes' : 'Create publication' } 188 + </button> 189 + <button 190 + className="pubform__cancel" 191 + type="button" 192 + onClick={ onCancel } 193 + disabled={ saving } 194 + > 195 + Cancel 196 + </button> 197 + </div> 198 + </form> 199 + ); 200 + }
+60 -4
src/components/PublishPanel.tsx
··· 6 6 updateDocument, 7 7 type Identity, 8 8 } from '../lib/publish/publisher'; 9 + import type { Publication } from '../lib/publish/publications'; 9 10 import { normalizeBlocks, type StrongRef } from '../lib/publish/records'; 10 11 import { attachBlobRefs } from '../lib/media/blob'; 11 12 import type { BlobRegistry } from '../lib/media/mediaUpload'; ··· 15 16 export interface EditingTarget { 16 17 rkey: string; 17 18 siteUri: string; 19 + /** The owning publication's frozen slug (for the article URL on update). */ 20 + siteSlug: string; 18 21 publishedAt: string; 19 22 bskyPostRef?: StrongRef; 20 23 } ··· 24 27 identity: Identity; 25 28 blocks: BlockInstance[]; 26 29 blobRegistry: BlobRegistry; 30 + /** The writer's SkyPress publications — the publish target is chosen from these. */ 31 + publications: Publication[]; 27 32 /** When set, the panel updates an existing article instead of publishing a new one. */ 28 33 editing?: EditingTarget; 29 34 initialTitle?: string; ··· 32 37 } 33 38 34 39 /** 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). 40 + * Title + publish/update control. Publishing a NEW article targets a CHOSEN publication 41 + * (Decision 0010) and creates a public Bluesky post, so it requires an explicit confirmation 42 + * (brief §10). Editing updates the existing record in place, in its own publication, and does 43 + * NOT create a new post (Decision 0008). 38 44 */ 39 45 export default function PublishPanel( { 40 46 agent, 41 47 identity, 42 48 blocks, 43 49 blobRegistry, 50 + publications, 44 51 editing, 45 52 initialTitle, 46 53 onComplete, 47 54 }: Props ) { 48 55 const [ title, setTitle ] = useState( initialTitle ?? '' ); 56 + const [ targetUri, setTargetUri ] = useState( 57 + () => editing?.siteUri ?? publications[ 0 ]?.uri ?? '' 58 + ); 49 59 const [ phase, setPhase ] = useState< Phase >( 'idle' ); 50 60 const [ resultUrl, setResultUrl ] = useState< string | null >( null ); 51 61 const [ error, setError ] = useState< string | null >( null ); 52 62 53 63 const isEditing = Boolean( editing ); 54 - const canSubmit = title.trim().length > 0 && blocks.length > 0; 64 + const target = isEditing 65 + ? { uri: editing!.siteUri, slug: editing!.siteSlug } 66 + : publications.find( ( pub ) => pub.uri === targetUri ); 67 + const editingPubName = 68 + isEditing && publications.find( ( pub ) => pub.uri === editing!.siteUri )?.name; 69 + const hasTarget = Boolean( target ); 70 + const canSubmit = title.trim().length > 0 && blocks.length > 0 && hasTarget; 71 + 72 + // New article, but the writer has no publication to publish into yet. 73 + if ( ! isEditing && publications.length === 0 ) { 74 + return ( 75 + <section className="publish" aria-label="Publish"> 76 + <p className="publish__status"> 77 + You don't have a publication yet.{ ' ' } 78 + <a href="/dashboard">Create one in your dashboard</a> to start publishing. 79 + </p> 80 + </section> 81 + ); 82 + } 55 83 56 84 async function run() { 85 + if ( ! target ) { 86 + return; 87 + } 57 88 setPhase( 'working' ); 58 89 setError( null ); 59 90 try { ··· 64 95 const res = await updateDocument( agent, identity, { 65 96 rkey: editing.rkey, 66 97 siteUri: editing.siteUri, 98 + publicationSlug: editing.siteSlug, 67 99 publishedAt: editing.publishedAt, 68 100 bskyPostRef: editing.bskyPostRef, 69 101 title: title.trim(), ··· 74 106 const res = await publish( agent, identity, { 75 107 title: title.trim(), 76 108 blocks: prepared, 109 + publicationUri: target.uri, 110 + publicationSlug: target.slug, 77 111 } ); 78 112 setResultUrl( res.articleUrl ); 79 113 } ··· 95 129 onChange={ ( event ) => setTitle( event.target.value ) } 96 130 disabled={ phase === 'working' } 97 131 /> 132 + 133 + { isEditing ? ( 134 + <span className="publish__target publish__target--fixed"> 135 + In <strong>{ editingPubName ?? 'this publication' }</strong> 136 + </span> 137 + ) : ( 138 + <label className="publish__target"> 139 + <span className="publish__target-label">Publish to</span> 140 + <select 141 + className="publish__select" 142 + value={ targetUri } 143 + onChange={ ( event ) => setTargetUri( event.target.value ) } 144 + disabled={ phase === 'working' } 145 + > 146 + { publications.map( ( pub ) => ( 147 + <option key={ pub.uri } value={ pub.uri }> 148 + { pub.name } 149 + </option> 150 + ) ) } 151 + </select> 152 + </label> 153 + ) } 98 154 99 155 { ( phase === 'idle' || phase === 'error' || phase === 'done' ) && ( 100 156 <button
+22 -2
src/components/Studio.tsx
··· 1 - import { useMemo, useRef, useState } from 'react'; 1 + import { useEffect, useMemo, useRef, useState } from 'react'; 2 2 import type { BlockInstance } from '@wordpress/blocks'; 3 3 import { AuthProvider } from '../lib/auth/AuthProvider'; 4 4 import { useAuth } from '../lib/auth/useAuth'; ··· 9 9 import { createMediaUpload, type BlobRegistry } from '../lib/media/mediaUpload'; 10 10 import { displayNameFor, authorPath } from '../lib/auth/profile'; 11 11 import type { MyArticle } from '../lib/publish/publisher'; 12 + import { listPublications, type Publication } from '../lib/publish/publications'; 12 13 13 14 /** 14 15 * The authenticated writing surface. Gates the editor behind atproto OAuth: ··· 20 21 const [ editing, setEditing ] = useState< MyArticle | null >( null ); 21 22 const [ refreshKey, setRefreshKey ] = useState( 0 ); 22 23 const [ avatarOk, setAvatarOk ] = useState( true ); 24 + const [ publications, setPublications ] = useState< Publication[] >( [] ); 23 25 // Shared between mediaUpload (writes blob refs) and publish (reads them). 24 26 const registry = useRef< BlobRegistry >( new Map() ).current; 25 27 28 + // Load the writer's SkyPress publications (the publish targets / selector). 29 + useEffect( () => { 30 + if ( ! agent || ! did ) { 31 + return; 32 + } 33 + let cancelled = false; 34 + listPublications( agent, did ) 35 + .then( ( list ) => ! cancelled && setPublications( list ) ) 36 + .catch( () => ! cancelled && setPublications( [] ) ); 37 + return () => { 38 + cancelled = true; 39 + }; 40 + }, [ agent, did, refreshKey ] ); 41 + 26 42 const mediaUpload = useMemo( () => { 27 43 if ( ! agent || ! did || ! pdsUrl ) { 28 44 return undefined; ··· 73 89 </span> 74 90 </span> 75 91 <span className="studio__account-actions"> 92 + <a className="studio__viewpage" href="/dashboard"> 93 + Dashboard 94 + </a> 76 95 { publicPath && ( 77 96 <a className="studio__viewpage" href={ publicPath }> 78 97 View my public page ··· 91 110 <MyArticles 92 111 agent={ agent } 93 112 did={ did } 94 - handle={ handle ?? did } 95 113 refreshKey={ refreshKey } 96 114 onEdit={ startEdit } 97 115 /> ··· 111 129 identity={ { did, handle } } 112 130 blocks={ blocks } 113 131 blobRegistry={ registry } 132 + publications={ publications } 114 133 editing={ 115 134 editing 116 135 ? { 117 136 rkey: editing.rkey, 118 137 siteUri: editing.siteUri, 138 + siteSlug: editing.siteSlug, 119 139 publishedAt: editing.publishedAt ?? new Date().toISOString(), 120 140 bskyPostRef: editing.bskyPostRef, 121 141 }
+50
src/lib/media/uploadImage.test.ts
··· 1 + import { describe, expect, it, vi } from 'vitest'; 2 + import type { Agent } from '@atproto/api'; 3 + import { 4 + uploadImageBlob, 5 + ImageValidationError, 6 + PUBLICATION_ICON_MAX_BYTES, 7 + } from './uploadImage'; 8 + 9 + function fileOf( type: string, size: number, name = 'logo.png' ): File { 10 + const file = new File( [ 'x' ], name, { type } ); 11 + Object.defineProperty( file, 'size', { value: size } ); 12 + return file; 13 + } 14 + 15 + function agentUploading( blob: Record< string, unknown > ): Agent { 16 + return { uploadBlob: vi.fn().mockResolvedValue( { data: { blob } } ) } as unknown as Agent; 17 + } 18 + 19 + describe( 'uploadImageBlob', () => { 20 + it( 'rejects non-image files without uploading', async () => { 21 + const agent = agentUploading( {} ); 22 + await expect( 23 + uploadImageBlob( agent, fileOf( 'application/pdf', 10 ) ) 24 + ).rejects.toBeInstanceOf( ImageValidationError ); 25 + expect( ( agent.uploadBlob as ReturnType< typeof vi.fn > ) ).not.toHaveBeenCalled(); 26 + } ); 27 + 28 + it( 'rejects files over the 1MB icon limit without uploading', async () => { 29 + const agent = agentUploading( {} ); 30 + await expect( 31 + uploadImageBlob( agent, fileOf( 'image/png', PUBLICATION_ICON_MAX_BYTES + 1 ) ) 32 + ).rejects.toBeInstanceOf( ImageValidationError ); 33 + expect( ( agent.uploadBlob as ReturnType< typeof vi.fn > ) ).not.toHaveBeenCalled(); 34 + } ); 35 + 36 + it( 'uploads a valid image and returns its blob ref json', async () => { 37 + const agent = agentUploading( { 38 + ref: { toString: () => 'bafyicon' }, 39 + mimeType: 'image/png', 40 + size: 500, 41 + } ); 42 + const ref = await uploadImageBlob( agent, fileOf( 'image/png', 500 ) ); 43 + expect( ref ).toEqual( { 44 + $type: 'blob', 45 + ref: { $link: 'bafyicon' }, 46 + mimeType: 'image/png', 47 + size: 500, 48 + } ); 49 + } ); 50 + } );
+42
src/lib/media/uploadImage.ts
··· 1 + /** 2 + * Upload a single image (the publication logo) to the writer's PDS and return its stored blob 3 + * ref (SP10, step C). Reuses the same `agent.uploadBlob` path as the editor's media pipeline 4 + * (Decision 0006); the returned `BlobRefJson` is what gets baked into the publication record's 5 + * `icon` field. Enforces the lexicon's icon limits (≤1MB, `image/*`) client-side, before upload. 6 + */ 7 + import type { Agent } from '@atproto/api'; 8 + import type { BlobRefJson } from './blob'; 9 + 10 + /** The `site.standard.publication.icon` lexicon limit. */ 11 + export const PUBLICATION_ICON_MAX_BYTES = 1_000_000; 12 + 13 + /** Thrown for client-side validation failures (wrong type / too large) — shown to the user. */ 14 + export class ImageValidationError extends Error { 15 + constructor( message: string ) { 16 + super( message ); 17 + this.name = 'ImageValidationError'; 18 + } 19 + } 20 + 21 + export async function uploadImageBlob( 22 + agent: Agent, 23 + file: File, 24 + options: { maxBytes?: number } = {} 25 + ): Promise< BlobRefJson > { 26 + const maxBytes = options.maxBytes ?? PUBLICATION_ICON_MAX_BYTES; 27 + if ( ! file.type.startsWith( 'image/' ) ) { 28 + throw new ImageValidationError( 'Choose an image file (PNG, JPG, GIF, WebP, …).' ); 29 + } 30 + if ( file.size > maxBytes ) { 31 + const limitMb = Math.round( ( maxBytes / 1_000_000 ) * 100 ) / 100; 32 + throw new ImageValidationError( `Image must be ${ limitMb }MB or smaller.` ); 33 + } 34 + const res = await agent.uploadBlob( file, { encoding: file.type } ); 35 + const { blob } = res.data; 36 + return { 37 + $type: 'blob', 38 + ref: { $link: blob.ref.toString() }, 39 + mimeType: blob.mimeType, 40 + size: blob.size, 41 + }; 42 + }
+188
src/lib/publish/publications.test.ts
··· 1 + import { describe, expect, it, vi } from 'vitest'; 2 + import type { Agent } from '@atproto/api'; 3 + import { 4 + listPublications, 5 + createPublication, 6 + updatePublication, 7 + deletePublication, 8 + } from './publications'; 9 + import { SITE_BASE } from './records'; 10 + import type { BlobRefJson } from '../media/blob'; 11 + 12 + const DID = 'did:plc:me'; 13 + 14 + function pubRecord( rkey: string, url: string, extra: Record< string, unknown > = {} ) { 15 + return { 16 + uri: `at://${ DID }/site.standard.publication/${ rkey }`, 17 + cid: `bafy-${ rkey }`, 18 + value: { $type: 'site.standard.publication', url, name: `Pub ${ rkey }`, ...extra }, 19 + }; 20 + } 21 + function docRecord( rkey: string, site: string, extra: Record< string, unknown > = {} ) { 22 + return { 23 + uri: `at://${ DID }/site.standard.document/${ rkey }`, 24 + cid: `bafy-${ rkey }`, 25 + value: { $type: 'site.standard.document', site, title: `Doc ${ rkey }`, ...extra }, 26 + }; 27 + } 28 + 29 + /** A mock Agent whose `com.atproto.repo` records the calls made against it. */ 30 + function mockAgent( byCollection: Record< string, unknown[] > = {} ) { 31 + const created: Array< { collection: string; rkey?: string; record: Record< string, unknown > } > = []; 32 + const put: Array< { collection: string; rkey: string; record: Record< string, unknown > } > = []; 33 + const deleted: Array< { collection: string; rkey: string } > = []; 34 + const agent = { 35 + com: { 36 + atproto: { 37 + repo: { 38 + listRecords: vi.fn( async ( { collection }: { collection: string } ) => ( { 39 + data: { records: byCollection[ collection ] ?? [] }, 40 + } ) ), 41 + createRecord: vi.fn( 42 + async ( { 43 + collection, 44 + rkey, 45 + record, 46 + }: { 47 + collection: string; 48 + rkey?: string; 49 + record: Record< string, unknown >; 50 + } ) => { 51 + const id = rkey ?? 'generated'; 52 + created.push( { collection, rkey, record } ); 53 + return { data: { uri: `at://${ DID }/${ collection }/${ id }`, cid: 'bafy-new' } }; 54 + } 55 + ), 56 + putRecord: vi.fn( 57 + async ( { 58 + collection, 59 + rkey, 60 + record, 61 + }: { 62 + collection: string; 63 + rkey: string; 64 + record: Record< string, unknown >; 65 + } ) => { 66 + put.push( { collection, rkey, record } ); 67 + return { data: { uri: `at://${ DID }/${ collection }/${ rkey }`, cid: 'bafy-put' } }; 68 + } 69 + ), 70 + deleteRecord: vi.fn( 71 + async ( { collection, rkey }: { collection: string; rkey: string } ) => { 72 + deleted.push( { collection, rkey } ); 73 + return {}; 74 + } 75 + ), 76 + }, 77 + }, 78 + }, 79 + } as unknown as Agent; 80 + return { agent, created, put, deleted }; 81 + } 82 + 83 + describe( 'listPublications', () => { 84 + it( 'returns only SkyPress-origin publications, mapped with their slug', async () => { 85 + const { agent } = mockAgent( { 86 + 'site.standard.publication': [ 87 + pubRecord( 'a', `${ SITE_BASE }/@me.bsky.social/my-blog`, { description: 'Hi' } ), 88 + pubRecord( 'b', 'https://leaflet.pub/lish/did:plc:me/xyz' ), // foreign → excluded 89 + pubRecord( 'c', `${ SITE_BASE }/@me.bsky.social` ), // slugless → excluded 90 + ], 91 + } ); 92 + const pubs = await listPublications( agent, DID ); 93 + expect( pubs ).toHaveLength( 1 ); 94 + expect( pubs[ 0 ] ).toMatchObject( { 95 + uri: `at://${ DID }/site.standard.publication/a`, 96 + rkey: 'a', 97 + slug: 'my-blog', 98 + name: 'Pub a', 99 + description: 'Hi', 100 + } ); 101 + } ); 102 + } ); 103 + 104 + describe( 'createPublication', () => { 105 + it( 'derives the slug from the name and writes the record under a fresh rkey', async () => { 106 + const { agent, created } = mockAgent(); 107 + const pub = await createPublication( agent, DID, 'me.bsky.social', { name: 'My Blog' } ); 108 + expect( pub.slug ).toBe( 'my-blog' ); 109 + expect( created ).toHaveLength( 1 ); 110 + expect( created[ 0 ].collection ).toBe( 'site.standard.publication' ); 111 + expect( created[ 0 ].rkey ).toBeTruthy(); // rkey generated up front 112 + expect( created[ 0 ].record.url ).toBe( `${ SITE_BASE }/@me.bsky.social/my-blog` ); 113 + } ); 114 + 115 + it( 'de-duplicates the slug against the writer’s existing publications', async () => { 116 + const { agent } = mockAgent( { 117 + 'site.standard.publication': [ pubRecord( 'a', `${ SITE_BASE }/@me.bsky.social/my-blog` ) ], 118 + } ); 119 + const pub = await createPublication( agent, DID, 'me.bsky.social', { name: 'My Blog' } ); 120 + expect( pub.slug ).toBe( 'my-blog-2' ); 121 + } ); 122 + 123 + it( 'falls back to pub-{rkey} for an unslugifiable name', async () => { 124 + const { agent, created } = mockAgent(); 125 + const pub = await createPublication( agent, DID, 'me.bsky.social', { name: '🎉' } ); 126 + expect( pub.slug ).toBe( `pub-${ created[ 0 ].rkey }` ); 127 + expect( pub.slug ).toMatch( /^pub-/ ); 128 + } ); 129 + 130 + it( 'stores the icon blob when provided', async () => { 131 + const { agent, created } = mockAgent(); 132 + const icon: BlobRefJson = { 133 + $type: 'blob', 134 + ref: { $link: 'bafyicon' }, 135 + mimeType: 'image/png', 136 + size: 10, 137 + }; 138 + await createPublication( agent, DID, 'me.bsky.social', { name: 'Logo Blog', icon } ); 139 + expect( created[ 0 ].record.icon ).toEqual( icon ); 140 + } ); 141 + } ); 142 + 143 + describe( 'updatePublication', () => { 144 + it( 'preserves the frozen slug while updating name/description', async () => { 145 + const { agent, put } = mockAgent(); 146 + const existing = { 147 + uri: `at://${ DID }/site.standard.publication/a`, 148 + rkey: 'a', 149 + slug: 'my-blog', 150 + name: 'My Blog', 151 + }; 152 + const updated = await updatePublication( agent, DID, 'me.bsky.social', existing, { 153 + name: 'Renamed Blog', 154 + description: 'Now with a description', 155 + } ); 156 + expect( put ).toHaveLength( 1 ); 157 + expect( put[ 0 ].rkey ).toBe( 'a' ); 158 + // URL (and thus slug) unchanged despite the rename. 159 + expect( put[ 0 ].record.url ).toBe( `${ SITE_BASE }/@me.bsky.social/my-blog` ); 160 + expect( updated.slug ).toBe( 'my-blog' ); 161 + expect( updated.name ).toBe( 'Renamed Blog' ); 162 + expect( updated.description ).toBe( 'Now with a description' ); 163 + } ); 164 + } ); 165 + 166 + describe( 'deletePublication', () => { 167 + it( 'cascades: deletes the publication’s documents, their posts, then the record', async () => { 168 + const pubUri = `at://${ DID }/site.standard.publication/a`; 169 + const { agent, deleted } = mockAgent( { 170 + 'site.standard.document': [ 171 + docRecord( 'd1', pubUri, { 172 + bskyPostRef: { uri: `at://${ DID }/app.bsky.feed.post/p1`, cid: 'c' }, 173 + } ), 174 + docRecord( 'd2', pubUri ), // no companion post 175 + docRecord( 'd3', `at://${ DID }/site.standard.publication/other` ), // other pub → untouched 176 + ], 177 + } ); 178 + const result = await deletePublication( agent, DID, { uri: pubUri, rkey: 'a' } ); 179 + expect( result ).toEqual( { deletedArticles: 2, deletedPosts: 1 } ); 180 + 181 + const collectionsDeleted = deleted.map( ( d ) => `${ d.collection }/${ d.rkey }` ); 182 + expect( collectionsDeleted ).toContain( 'site.standard.document/d1' ); 183 + expect( collectionsDeleted ).toContain( 'site.standard.document/d2' ); 184 + expect( collectionsDeleted ).toContain( 'app.bsky.feed.post/p1' ); 185 + expect( collectionsDeleted ).toContain( 'site.standard.publication/a' ); 186 + expect( collectionsDeleted ).not.toContain( 'site.standard.document/d3' ); 187 + } ); 188 + } );
+220
src/lib/publish/publications.ts
··· 1 + /** 2 + * Publication CRUD over `site.standard.publication` records (SP10, Decision 0010). 3 + * 4 + * Replaces the old implicit single-pub `ensurePublication`: SkyPress now manages MULTIPLE, 5 + * user-created publications. Everything resolves from the PDS — no database, no global slug 6 + * registry. SkyPress only ever lists/edits/deletes publications it OWNS (`isSkyPressPublicationUrl`), 7 + * never a record some other tool (Leaflet, …) wrote into the same collection. 8 + */ 9 + import type { Agent } from '@atproto/api'; 10 + import { TID } from '@atproto/common-web'; 11 + import { 12 + buildPublicationRecord, 13 + isSkyPressPublicationUrl, 14 + publicationSlugFromUrl, 15 + uniquePublicationSlug, 16 + type PublicationRecord, 17 + } from './records'; 18 + import type { BlobRefJson } from '../media/blob'; 19 + 20 + const PUBLICATION_COLLECTION = 'site.standard.publication'; 21 + const DOCUMENT_COLLECTION = 'site.standard.document'; 22 + const POST_COLLECTION = 'app.bsky.feed.post'; 23 + 24 + /** createRecord/putRecord type `record` as an open index signature; ours are precise. */ 25 + function asRecord( value: object ): Record< string, unknown > { 26 + return value as Record< string, unknown >; 27 + } 28 + 29 + /** rkey is the last segment of an AT-URI. */ 30 + function rkeyFromUri( uri: string ): string { 31 + return uri.split( '/' ).pop() ?? ''; 32 + } 33 + 34 + /** A SkyPress publication, resolved to the shape the dashboard + editor consume. */ 35 + export interface Publication { 36 + uri: string; 37 + rkey: string; 38 + slug: string; 39 + name: string; 40 + description?: string; 41 + icon?: BlobRefJson; 42 + } 43 + 44 + /** Fields a writer can set/change on a publication (the slug is derived, never entered). */ 45 + export interface PublicationInput { 46 + name: string; 47 + description?: string; 48 + icon?: BlobRefJson; 49 + } 50 + 51 + /** Map a raw repo record to a Publication, or null if it isn't a usable SkyPress publication. */ 52 + function toPublication( record: { 53 + uri: string; 54 + value: unknown; 55 + } ): Publication | null { 56 + const value = record.value as Partial< PublicationRecord > | undefined; 57 + if ( ! value?.url || ! isSkyPressPublicationUrl( value.url ) ) { 58 + return null; 59 + } 60 + const slug = publicationSlugFromUrl( value.url ); 61 + if ( ! slug ) { 62 + return null; 63 + } 64 + return { 65 + uri: record.uri, 66 + rkey: rkeyFromUri( record.uri ), 67 + slug, 68 + name: value.name ?? slug, 69 + ...( value.description ? { description: value.description } : {} ), 70 + ...( value.icon ? { icon: value.icon } : {} ), 71 + }; 72 + } 73 + 74 + /** List the writer's SkyPress publications (newest first), filtered to this app's origin. */ 75 + export async function listPublications( agent: Agent, did: string ): Promise< Publication[] > { 76 + const res = await agent.com.atproto.repo.listRecords( { 77 + repo: did, 78 + collection: PUBLICATION_COLLECTION, 79 + limit: 100, 80 + } ); 81 + return res.data.records 82 + .map( ( record ) => toPublication( record ) ) 83 + .filter( ( pub ): pub is Publication => pub !== null ); 84 + } 85 + 86 + /** 87 + * Create a new publication. The slug is derived from the name and de-duplicated against the 88 + * writer's existing SkyPress slugs; an unslugifiable name falls back to `pub-{rkey}`. The rkey 89 + * is generated up front so that fallback URL is known before the record is written. 90 + */ 91 + export async function createPublication( 92 + agent: Agent, 93 + did: string, 94 + handle: string, 95 + input: PublicationInput 96 + ): Promise< Publication > { 97 + const existing = await listPublications( agent, did ); 98 + const rkey = TID.nextStr(); 99 + const slug = 100 + uniquePublicationSlug( 101 + input.name, 102 + existing.map( ( pub ) => pub.slug ) 103 + ) || `pub-${ rkey }`; 104 + const record = buildPublicationRecord( { 105 + handle, 106 + slug, 107 + name: input.name, 108 + description: input.description, 109 + icon: input.icon, 110 + } ); 111 + const res = await agent.com.atproto.repo.createRecord( { 112 + repo: did, 113 + collection: PUBLICATION_COLLECTION, 114 + rkey, 115 + record: asRecord( record ), 116 + } ); 117 + return { 118 + uri: res.data.uri, 119 + rkey, 120 + slug, 121 + name: record.name, 122 + ...( record.description ? { description: record.description } : {} ), 123 + ...( record.icon ? { icon: record.icon } : {} ), 124 + }; 125 + } 126 + 127 + /** 128 + * Update a publication in place (`putRecord` on the same rkey). The slug is FROZEN: it is read 129 + * back from `existing` and re-baked into the url, so a rename never changes the URL (Decision 0010). 130 + */ 131 + export async function updatePublication( 132 + agent: Agent, 133 + did: string, 134 + handle: string, 135 + existing: Publication, 136 + input: PublicationInput 137 + ): Promise< Publication > { 138 + const record = buildPublicationRecord( { 139 + handle, 140 + slug: existing.slug, 141 + name: input.name, 142 + description: input.description, 143 + icon: input.icon, 144 + } ); 145 + await agent.com.atproto.repo.putRecord( { 146 + repo: did, 147 + collection: PUBLICATION_COLLECTION, 148 + rkey: existing.rkey, 149 + record: asRecord( record ), 150 + } ); 151 + return { 152 + uri: existing.uri, 153 + rkey: existing.rkey, 154 + slug: existing.slug, 155 + name: record.name, 156 + ...( record.description ? { description: record.description } : {} ), 157 + ...( record.icon ? { icon: record.icon } : {} ), 158 + }; 159 + } 160 + 161 + export interface DeletePublicationResult { 162 + deletedArticles: number; 163 + deletedPosts: number; 164 + } 165 + 166 + /** 167 + * Delete a publication and CASCADE (Decision 0010): remove every document under it AND each 168 + * document's companion `app.bsky.feed.post` (Decision 0008 semantics), then the publication 169 + * record itself. Documents belonging to OTHER publications are left untouched. Returns counts 170 + * so the UI can tell the user exactly what was removed. 171 + * 172 + * NOTE: lists up to 100 documents without following `cursor` (the codebase-wide cap). A 173 + * publication with >100 documents would delete the first 100 and then the publication record, 174 + * orphaning the remainder. v1 has no draft/scale story; revisit with pagination before that 175 + * becomes reachable. 176 + */ 177 + export async function deletePublication( 178 + agent: Agent, 179 + did: string, 180 + publication: { uri: string; rkey: string } 181 + ): Promise< DeletePublicationResult > { 182 + const docs = await agent.com.atproto.repo.listRecords( { 183 + repo: did, 184 + collection: DOCUMENT_COLLECTION, 185 + limit: 100, 186 + } ); 187 + const mine = docs.data.records.filter( 188 + ( record ) => ( record.value as { site?: string } )?.site === publication.uri 189 + ); 190 + 191 + let deletedPosts = 0; 192 + for ( const doc of mine ) { 193 + await agent.com.atproto.repo.deleteRecord( { 194 + repo: did, 195 + collection: DOCUMENT_COLLECTION, 196 + rkey: rkeyFromUri( doc.uri ), 197 + } ); 198 + const postUri = ( doc.value as { bskyPostRef?: { uri?: string } } )?.bskyPostRef?.uri; 199 + if ( postUri ) { 200 + try { 201 + await agent.com.atproto.repo.deleteRecord( { 202 + repo: did, 203 + collection: POST_COLLECTION, 204 + rkey: rkeyFromUri( postUri ), 205 + } ); 206 + deletedPosts++; 207 + } catch { 208 + // The post may already be gone; the document deletion is what matters. 209 + } 210 + } 211 + } 212 + 213 + await agent.com.atproto.repo.deleteRecord( { 214 + repo: did, 215 + collection: PUBLICATION_COLLECTION, 216 + rkey: publication.rkey, 217 + } ); 218 + 219 + return { deletedArticles: mine.length, deletedPosts }; 220 + }
+161
src/lib/publish/publisher.test.ts
··· 1 + import { describe, expect, it, vi } from 'vitest'; 2 + import type { Agent } from '@atproto/api'; 3 + import { publish, updateDocument, listAllMyArticles, listPublicationArticles } from './publisher'; 4 + import { SITE_BASE } from './records'; 5 + import type { BlockNode } from '../blocks/render'; 6 + 7 + const DID = 'did:plc:me'; 8 + const HANDLE = 'me.bsky.social'; 9 + const BLOCKS: BlockNode[] = [ 10 + { name: 'core/paragraph', attributes: { content: 'Body' }, innerBlocks: [] }, 11 + ]; 12 + 13 + function mockAgent( byCollection: Record< string, unknown[] > = {} ) { 14 + const created: Array< { collection: string; rkey?: string; record: Record< string, unknown > } > = []; 15 + const put: Array< { collection: string; rkey: string; record: Record< string, unknown > } > = []; 16 + const agent = { 17 + com: { 18 + atproto: { 19 + repo: { 20 + listRecords: vi.fn( async ( { collection }: { collection: string } ) => ( { 21 + data: { records: byCollection[ collection ] ?? [] }, 22 + } ) ), 23 + createRecord: vi.fn( 24 + async ( { 25 + collection, 26 + rkey, 27 + record, 28 + }: { 29 + collection: string; 30 + rkey?: string; 31 + record: Record< string, unknown >; 32 + } ) => { 33 + created.push( { collection, rkey, record } ); 34 + const id = rkey ?? `gen-${ created.length }`; 35 + return { data: { uri: `at://${ DID }/${ collection }/${ id }`, cid: 'bafy-new' } }; 36 + } 37 + ), 38 + putRecord: vi.fn( 39 + async ( { 40 + collection, 41 + rkey, 42 + record, 43 + }: { 44 + collection: string; 45 + rkey: string; 46 + record: Record< string, unknown >; 47 + } ) => { 48 + put.push( { collection, rkey, record } ); 49 + return { data: { uri: `at://${ DID }/${ collection }/${ rkey }`, cid: 'bafy-put' } }; 50 + } 51 + ), 52 + }, 53 + }, 54 + }, 55 + } as unknown as Agent; 56 + return { agent, created, put }; 57 + } 58 + 59 + const TARGET = { 60 + publicationUri: `at://${ DID }/site.standard.publication/pub1`, 61 + publicationSlug: 'my-blog', 62 + }; 63 + 64 + describe( 'publish', () => { 65 + it( 'targets the chosen publication and builds a slug-namespaced article url', async () => { 66 + const { agent, created } = mockAgent(); 67 + const result = await publish( 68 + agent, 69 + { did: DID, handle: HANDLE }, 70 + { title: 'Hello', blocks: BLOCKS, ...TARGET } 71 + ); 72 + 73 + expect( result.publicationUri ).toBe( TARGET.publicationUri ); 74 + expect( result.articleUrl ).toMatch( 75 + new RegExp( `^${ SITE_BASE }/@${ HANDLE }/my-blog/` ) 76 + ); 77 + 78 + // One post + one document, the document pinned to the chosen publication. 79 + const post = created.find( ( c ) => c.collection === 'app.bsky.feed.post' ); 80 + const doc = created.find( ( c ) => c.collection === 'site.standard.document' ); 81 + expect( post ).toBeTruthy(); 82 + expect( doc ).toBeTruthy(); 83 + expect( doc!.record.site ).toBe( TARGET.publicationUri ); 84 + 85 + // It does NOT auto-create a publication (no ensurePublication anymore). 86 + expect( created.some( ( c ) => c.collection === 'site.standard.publication' ) ).toBe( false ); 87 + } ); 88 + } ); 89 + 90 + describe( 'updateDocument', () => { 91 + it( 'putRecords the same rkey and keeps the slug-namespaced url', async () => { 92 + const { agent, put } = mockAgent(); 93 + const res = await updateDocument( 94 + agent, 95 + { did: DID, handle: HANDLE }, 96 + { 97 + rkey: '3kdoc', 98 + siteUri: TARGET.publicationUri, 99 + publicationSlug: 'my-blog', 100 + publishedAt: '2026-06-08T00:00:00.000Z', 101 + title: 'Hello again', 102 + blocks: BLOCKS, 103 + } 104 + ); 105 + expect( put ).toHaveLength( 1 ); 106 + expect( put[ 0 ].rkey ).toBe( '3kdoc' ); 107 + expect( put[ 0 ].record.site ).toBe( TARGET.publicationUri ); 108 + expect( res.articleUrl ).toBe( `${ SITE_BASE }/@${ HANDLE }/my-blog/3kdoc` ); 109 + } ); 110 + } ); 111 + 112 + describe( 'listPublicationArticles', () => { 113 + it( 'returns the publication’s documents annotated with its slug', async () => { 114 + const pubUri = `at://${ DID }/site.standard.publication/pub1`; 115 + const { agent } = mockAgent( { 116 + 'site.standard.document': [ 117 + { 118 + uri: `at://${ DID }/site.standard.document/d1`, 119 + value: { title: 'One', site: pubUri, publishedAt: '2026-01-01' }, 120 + }, 121 + { 122 + uri: `at://${ DID }/site.standard.document/d2`, 123 + value: { title: 'Two', site: `at://${ DID }/site.standard.publication/other` }, 124 + }, 125 + ], 126 + } ); 127 + const articles = await listPublicationArticles( agent, DID, { uri: pubUri, slug: 'my-blog' } ); 128 + expect( articles ).toHaveLength( 1 ); 129 + expect( articles[ 0 ] ).toMatchObject( { rkey: 'd1', title: 'One', siteSlug: 'my-blog' } ); 130 + } ); 131 + } ); 132 + 133 + describe( 'listAllMyArticles', () => { 134 + it( 'annotates each article with its publication slug and drops orphans', async () => { 135 + const { agent } = mockAgent( { 136 + 'site.standard.publication': [ 137 + { 138 + uri: `at://${ DID }/site.standard.publication/pub1`, 139 + value: { 140 + $type: 'site.standard.publication', 141 + url: `${ SITE_BASE }/@${ HANDLE }/blog-a`, 142 + name: 'Blog A', 143 + }, 144 + }, 145 + ], 146 + 'site.standard.document': [ 147 + { 148 + uri: `at://${ DID }/site.standard.document/d1`, 149 + value: { title: 'In A', site: `at://${ DID }/site.standard.publication/pub1` }, 150 + }, 151 + { 152 + uri: `at://${ DID }/site.standard.document/d2`, 153 + value: { title: 'Orphan', site: `at://${ DID }/site.standard.publication/gone` }, 154 + }, 155 + ], 156 + } ); 157 + const articles = await listAllMyArticles( agent, DID ); 158 + expect( articles ).toHaveLength( 1 ); 159 + expect( articles[ 0 ] ).toMatchObject( { rkey: 'd1', siteSlug: 'blog-a' } ); 160 + } ); 161 + } );
+85 -91
src/lib/publish/publisher.ts
··· 1 1 import type { Agent } from '@atproto/api'; 2 2 import { TID } from '@atproto/common-web'; 3 3 import { 4 - buildPublicationRecord, 5 4 buildDocumentRecord, 6 5 buildBskyPost, 7 6 canonicalArticleUrl, 8 - publicationHomeUrl, 9 7 type StrongRef, 10 8 } from './records'; 9 + import { listPublications } from './publications'; 11 10 import { blocksToText, type BlockNode } from '../blocks/render'; 12 11 13 - const PUBLICATION_COLLECTION = 'site.standard.publication'; 14 12 const DOCUMENT_COLLECTION = 'site.standard.document'; 15 13 const POST_COLLECTION = 'app.bsky.feed.post'; 16 14 ··· 19 17 return value as Record< string, unknown >; 20 18 } 21 19 20 + /** rkey is the last segment of an AT-URI. */ 21 + function rkeyFromUri( uri: string ): string { 22 + return uri.split( '/' ).pop() ?? ''; 23 + } 24 + 22 25 export interface Identity { 23 26 did: string; 24 27 /** Handle for human-readable URLs; falls back to DID if unresolved. */ ··· 29 32 title: string; 30 33 blocks: BlockNode[]; 31 34 description?: string; 35 + /** The chosen target publication's AT-URI (Decision 0010 — no more auto-create). */ 36 + publicationUri: string; 37 + /** Its frozen slug, needed to build the article URL. */ 38 + publicationSlug: string; 32 39 } 33 40 34 41 export interface PublishResult { ··· 39 46 } 40 47 41 48 /** 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`. 49 + * The two-record publish (Decision 0005), now targeting a CHOSEN publication (Decision 0010). 50 + * Order avoids a circular dependency: the article URL is known from handle+slug+rkey, so we 51 + * create the Bluesky post first, then the document with `bskyPostRef`. 78 52 * 79 - * NOTE: this also creates a PUBLIC Bluesky post. Callers must have made that 80 - * unmistakable to the user first (brief §10). 53 + * NOTE: this also creates a PUBLIC Bluesky post. Callers must have made that unmistakable to 54 + * the user first (brief §10). 81 55 */ 82 56 export async function publish( 83 57 agent: Agent, ··· 90 64 // Generate the document's rkey up front so the article URL (which the Bluesky post 91 65 // embeds) is known before either record is written — no circular dependency. 92 66 const rkey = TID.nextStr(); 93 - const articleUrl = canonicalArticleUrl( handle, rkey ); 67 + const articleUrl = canonicalArticleUrl( handle, input.publicationSlug, rkey ); 94 68 const textContent = blocksToText( input.blocks ); 95 - 96 - const publicationUri = await ensurePublication( agent, did, handle ); 97 69 98 70 const postRes = await agent.com.atproto.repo.createRecord( { 99 71 repo: did, ··· 122 94 rkey, 123 95 blocks: input.blocks, 124 96 textContent, 125 - siteUri: publicationUri, 97 + siteUri: input.publicationUri, 126 98 publishedAt: now, 127 99 description: input.description, 128 100 bskyPostRef, ··· 131 103 } ); 132 104 133 105 return { 134 - publicationUri, 106 + publicationUri: input.publicationUri, 135 107 documentUri: docRes.data.uri, 136 108 postUri: postRes.data.uri, 137 109 articleUrl, 138 110 }; 139 111 } 140 112 141 - export interface UpdateInput extends PublishInput { 113 + export interface UpdateInput { 114 + title: string; 115 + blocks: BlockNode[]; 116 + description?: string; 142 117 /** The existing document's record key (URL stays stable, Decision 0008). */ 143 118 rkey: string; 144 119 /** Preserved from the original publish. */ 145 120 siteUri: string; 121 + /** The owning publication's frozen slug, for the article URL. */ 122 + publicationSlug: string; 146 123 publishedAt: string; 147 124 bskyPostRef?: StrongRef; 148 125 } 149 126 150 127 /** 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. 128 + * Edit a published article in place (Decision 0008): `putRecord` on the SAME rkey, stamping 129 + * `updatedAt` while preserving `publishedAt` + `bskyPostRef`. Does NOT create a new Bluesky 130 + * post — the original post keeps pointing at the (now-updated) URL. 154 131 */ 155 132 export async function updateDocument( 156 133 agent: Agent, ··· 160 137 const { did } = identity; 161 138 const handle = identity.handle ?? did; 162 139 const now = new Date().toISOString(); 163 - const articleUrl = canonicalArticleUrl( handle, input.rkey ); 140 + const articleUrl = canonicalArticleUrl( handle, input.publicationSlug, input.rkey ); 164 141 165 142 const res = await agent.com.atproto.repo.putRecord( { 166 143 repo: did, ··· 183 160 return { documentUri: res.data.uri, articleUrl }; 184 161 } 185 162 186 - /** rkey is the last segment of an AT-URI. */ 187 - function rkeyFromUri( uri: string ): string { 188 - return uri.split( '/' ).pop() ?? ''; 189 - } 190 - 191 163 /** 192 - * Unpublish an article (Decision 0008): delete the document AND its companion Bluesky 193 - * post. The publication record is left intact. 164 + * Unpublish an article (Decision 0008): delete the document AND its companion Bluesky post. 165 + * The publication record is left intact. 194 166 */ 195 167 export async function unpublish( 196 168 agent: Agent, ··· 221 193 description?: string; 222 194 publishedAt?: string; 223 195 updatedAt?: string; 196 + /** The owning publication's AT-URI (`doc.site`). */ 224 197 siteUri: string; 198 + /** The owning publication's frozen slug (for building URLs + the update call). */ 199 + siteSlug: string; 225 200 bskyPostRef?: StrongRef; 226 201 blocks: BlockNode[]; 227 202 } 228 203 229 - /** List the signed-in writer's own SkyPress articles (scoped to their publication). */ 230 - export async function listMyArticles( 204 + interface RawDocValue { 205 + title?: string; 206 + description?: string; 207 + publishedAt?: string; 208 + updatedAt?: string; 209 + site?: string; 210 + bskyPostRef?: StrongRef; 211 + content?: { blocks?: BlockNode[] }; 212 + } 213 + 214 + function toMyArticle( 215 + record: { uri: string; value: unknown }, 216 + siteUri: string, 217 + siteSlug: string 218 + ): MyArticle { 219 + const value = record.value as RawDocValue; 220 + return { 221 + rkey: rkeyFromUri( record.uri ), 222 + title: value.title ?? 'Untitled', 223 + description: value.description, 224 + publishedAt: value.publishedAt, 225 + updatedAt: value.updatedAt, 226 + siteUri, 227 + siteSlug, 228 + bskyPostRef: value.bskyPostRef, 229 + blocks: value.content?.blocks ?? [], 230 + }; 231 + } 232 + 233 + /** List one publication's documents, annotated with its slug (the per-publication Posts tab). */ 234 + export async function listPublicationArticles( 231 235 agent: Agent, 232 236 did: string, 233 - handle: string 237 + publication: { uri: string; slug: string } 234 238 ): Promise< MyArticle[] > { 235 - const wantUrl = publicationHomeUrl( handle ); 236 - const pubs = await agent.com.atproto.repo.listRecords( { 239 + const docs = await agent.com.atproto.repo.listRecords( { 237 240 repo: did, 238 - collection: PUBLICATION_COLLECTION, 241 + collection: DOCUMENT_COLLECTION, 239 242 limit: 100, 240 243 } ); 241 - const ours = pubs.data.records.find( 242 - ( record ) => ( record.value as { url?: string } )?.url === wantUrl 243 - ); 244 - if ( ! ours ) { 245 - return []; 246 - } 244 + return docs.data.records 245 + .filter( ( record ) => ( record.value as RawDocValue )?.site === publication.uri ) 246 + .map( ( record ) => toMyArticle( record, publication.uri, publication.slug ) ); 247 + } 248 + 249 + /** 250 + * List ALL the writer's SkyPress articles across their publications, each annotated with its 251 + * publication slug. Documents whose `site` isn't one of the writer's known SkyPress 252 + * publications (orphans, or another tool's) are dropped — the editor only edits its own. 253 + */ 254 + export async function listAllMyArticles( agent: Agent, did: string ): Promise< MyArticle[] > { 255 + const pubs = await listPublications( agent, did ); 256 + const bySite = new Map( pubs.map( ( pub ) => [ pub.uri, pub ] as const ) ); 247 257 const docs = await agent.com.atproto.repo.listRecords( { 248 258 repo: did, 249 259 collection: DOCUMENT_COLLECTION, 250 260 limit: 100, 251 261 } ); 252 262 return docs.data.records 253 - .filter( ( record ) => ( record.value as { site?: string } )?.site === ours.uri ) 254 263 .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 - } ); 264 + const site = ( record.value as RawDocValue )?.site; 265 + const pub = site ? bySite.get( site ) : undefined; 266 + return pub ? toMyArticle( record, pub.uri, pub.slug ) : null; 267 + } ) 268 + .filter( ( article ): article is MyArticle => article !== null ); 275 269 }
+99 -11
src/lib/publish/records.test.ts
··· 3 3 publicationHomeUrl, 4 4 articlePath, 5 5 canonicalArticleUrl, 6 + publicationSlugFromUrl, 7 + isSkyPressPublicationUrl, 8 + slugify, 9 + uniquePublicationSlug, 6 10 buildContentObject, 7 11 buildPublicationRecord, 8 12 buildDocumentRecord, ··· 13 17 SITE_BASE, 14 18 } from './records'; 15 19 import type { BlockNode } from '../blocks/render'; 20 + import type { BlobRefJson } from '../media/blob'; 16 21 17 22 const BLOCKS: BlockNode[] = [ 18 23 { name: 'core/heading', attributes: { level: 1, content: 'Hi' }, innerBlocks: [] }, ··· 20 25 ]; 21 26 22 27 describe( 'URLs', () => { 23 - it( 'prefixes the handle with @ and addresses documents by rkey', () => { 24 - expect( publicationHomeUrl( 'alice.bsky.social' ) ).toBe( 25 - `${ SITE_BASE }/@alice.bsky.social` 28 + it( 'namespaces publications under @handle/slug and documents under that', () => { 29 + expect( publicationHomeUrl( 'alice.bsky.social', 'my-blog' ) ).toBe( 30 + `${ SITE_BASE }/@alice.bsky.social/my-blog` 26 31 ); 27 32 expect( articlePath( '3kabcrkey' ) ).toBe( '/3kabcrkey' ); 28 - expect( canonicalArticleUrl( 'alice.bsky.social', '3kabcrkey' ) ).toBe( 29 - `${ SITE_BASE }/@alice.bsky.social/3kabcrkey` 33 + expect( canonicalArticleUrl( 'alice.bsky.social', 'my-blog', '3kabcrkey' ) ).toBe( 34 + `${ SITE_BASE }/@alice.bsky.social/my-blog/3kabcrkey` 35 + ); 36 + } ); 37 + } ); 38 + 39 + describe( 'publicationSlugFromUrl', () => { 40 + it( 'returns the trailing slug segment of a publication url', () => { 41 + expect( publicationSlugFromUrl( `${ SITE_BASE }/@alice.bsky.social/my-blog` ) ).toBe( 42 + 'my-blog' 43 + ); 44 + } ); 45 + it( 'returns null for a slugless (legacy) or malformed url', () => { 46 + expect( publicationSlugFromUrl( `${ SITE_BASE }/@alice.bsky.social` ) ).toBeNull(); 47 + expect( publicationSlugFromUrl( 'not a url' ) ).toBeNull(); 48 + } ); 49 + } ); 50 + 51 + describe( 'isSkyPressPublicationUrl', () => { 52 + it( 'accepts only urls on the SkyPress origin', () => { 53 + expect( isSkyPressPublicationUrl( `${ SITE_BASE }/@alice.bsky.social/my-blog` ) ).toBe( 54 + true 30 55 ); 56 + expect( isSkyPressPublicationUrl( 'https://leaflet.pub/lish/did/abc' ) ).toBe( false ); 57 + expect( isSkyPressPublicationUrl( 'garbage' ) ).toBe( false ); 58 + } ); 59 + } ); 60 + 61 + describe( 'slugify', () => { 62 + it( 'lowercases, dashes spaces, strips to [a-z0-9-] and trims/collapses dashes', () => { 63 + expect( slugify( ' My Great Blog! ' ) ).toBe( 'my-great-blog' ); 64 + expect( slugify( 'Hello---World' ) ).toBe( 'hello-world' ); 65 + expect( slugify( '___weird@@@name___' ) ).toBe( 'weirdname' ); 66 + } ); 67 + it( 'normalises accents to their base letters', () => { 68 + expect( slugify( 'Café Life' ) ).toBe( 'cafe-life' ); 69 + } ); 70 + it( 'returns empty string for empty / emoji-only names', () => { 71 + expect( slugify( '🎉🎉' ) ).toBe( '' ); 72 + expect( slugify( ' ' ) ).toBe( '' ); 73 + } ); 74 + } ); 75 + 76 + describe( 'uniquePublicationSlug', () => { 77 + it( 'returns the bare slug when free', () => { 78 + expect( uniquePublicationSlug( 'My Blog', [] ) ).toBe( 'my-blog' ); 79 + } ); 80 + it( 'appends -2, -3 … on collision within the repo', () => { 81 + expect( uniquePublicationSlug( 'My Blog', [ 'my-blog' ] ) ).toBe( 'my-blog-2' ); 82 + expect( uniquePublicationSlug( 'My Blog', [ 'my-blog', 'my-blog-2' ] ) ).toBe( 83 + 'my-blog-3' 84 + ); 85 + } ); 86 + it( 'returns empty string for an unslugifiable name (caller applies pub-{rkey})', () => { 87 + expect( uniquePublicationSlug( '🎉', [] ) ).toBe( '' ); 31 88 } ); 32 89 } ); 33 90 ··· 65 122 } ); 66 123 67 124 describe( 'buildPublicationRecord', () => { 68 - it( 'sets the required url + name', () => { 69 - const pub = buildPublicationRecord( { handle: 'alice.bsky.social' } ); 125 + it( 'bakes the slug into the required url + name', () => { 126 + const pub = buildPublicationRecord( { 127 + handle: 'alice.bsky.social', 128 + slug: 'my-blog', 129 + name: 'My Blog', 130 + } ); 70 131 expect( pub.$type ).toBe( 'site.standard.publication' ); 71 - expect( pub.url ).toBe( `${ SITE_BASE }/@alice.bsky.social` ); 72 - expect( pub.name ).toBeTruthy(); 132 + expect( pub.url ).toBe( `${ SITE_BASE }/@alice.bsky.social/my-blog` ); 133 + expect( pub.name ).toBe( 'My Blog' ); 134 + } ); 135 + 136 + it( 'falls back to the handle when no name is given', () => { 137 + const pub = buildPublicationRecord( { handle: 'alice.bsky.social', slug: 'pub-x' } ); 138 + expect( pub.name ).toBe( 'alice.bsky.social' ); 139 + } ); 140 + 141 + it( 'includes description and icon only when provided', () => { 142 + const bare = buildPublicationRecord( { handle: 'a.b', slug: 's', name: 'N' } ); 143 + expect( 'description' in bare ).toBe( false ); 144 + expect( 'icon' in bare ).toBe( false ); 145 + 146 + const icon: BlobRefJson = { 147 + $type: 'blob', 148 + ref: { $link: 'bafyicon' }, 149 + mimeType: 'image/png', 150 + size: 1234, 151 + }; 152 + const full = buildPublicationRecord( { 153 + handle: 'a.b', 154 + slug: 's', 155 + name: 'N', 156 + description: 'A blog', 157 + icon, 158 + } ); 159 + expect( full.description ).toBe( 'A blog' ); 160 + expect( full.icon ).toEqual( icon ); 73 161 } ); 74 162 } ); 75 163 ··· 112 200 it( 'creates a post with an external embed pointing at the article', () => { 113 201 const post = buildBskyPost( { 114 202 title: 'Hello, World!', 115 - articleUrl: 'https://skypress.blog/@alice.bsky.social/3kabcrkey', 203 + articleUrl: 'https://skypress.blog/@alice.bsky.social/my-blog/3kabcrkey', 116 204 description: 'An excerpt', 117 205 createdAt: '2026-06-08T12:00:00.000Z', 118 206 } ); ··· 121 209 expect( post.createdAt ).toBe( '2026-06-08T12:00:00.000Z' ); 122 210 expect( post.embed.$type ).toBe( 'app.bsky.embed.external' ); 123 211 expect( post.embed.external.uri ).toBe( 124 - 'https://skypress.blog/@alice.bsky.social/3kabcrkey' 212 + 'https://skypress.blog/@alice.bsky.social/my-blog/3kabcrkey' 125 213 ); 126 214 expect( post.embed.external.title ).toBe( 'Hello, World!' ); 127 215 expect( typeof post.embed.external.description ).toBe( 'string' );
+88 -11
src/lib/publish/records.ts
··· 5 5 * Orchestration (createRecord via the Agent) lives in `publisher.ts`. 6 6 */ 7 7 import type { BlockNode } from '../blocks/render'; 8 + import type { BlobRefJson } from '../media/blob'; 8 9 9 10 /** 10 11 * Public origin for the stored publication + article URLs (and the Bluesky post link). ··· 23 24 } 24 25 25 26 /** 26 - * The writer's SkyPress homepage — the publication's `url`. Handles are prefixed 27 - * with `@` (e.g. `<site>/@alice.bsky.social`). 27 + * A publication's `url` — handle-namespaced under SkyPress (Decision 0010): 28 + * `<site>/@<handle>/<slug>`. The slug is frozen at creation; see `uniquePublicationSlug`. 28 29 */ 29 - export function publicationHomeUrl( handle: string ): string { 30 - return `${ SITE_BASE }/@${ handle }`; 30 + export function publicationHomeUrl( handle: string, slug: string ): string { 31 + return `${ SITE_BASE }/@${ handle }/${ slug }`; 31 32 } 32 33 33 34 /** 34 35 * The document `path` (leading slash, per the lexicon). SkyPress addresses documents 35 36 * by their record key (rkey), not a slug — stable across title edits and a direct 36 - * `getRecord` lookup for the renderer. 37 + * `getRecord` lookup for the renderer. The path stays publication-relative. 37 38 */ 38 39 export function articlePath( rkey: string ): string { 39 40 return `/${ rkey }`; 40 41 } 41 42 42 - /** Canonical article URL = publication url + path = `<base>/@<handle>/<rkey>`. */ 43 - export function canonicalArticleUrl( handle: string, rkey: string ): string { 44 - return `${ publicationHomeUrl( handle ) }${ articlePath( rkey ) }`; 43 + /** Canonical article URL = publication url + path = `<base>/@<handle>/<slug>/<rkey>`. */ 44 + export function canonicalArticleUrl( handle: string, slug: string, rkey: string ): string { 45 + return `${ publicationHomeUrl( handle, slug ) }${ articlePath( rkey ) }`; 46 + } 47 + 48 + /** 49 + * The trailing slug segment of a publication `url` (the resolution key, Decision 0010), 50 + * or null for a slugless/legacy/malformed url. Matching by this segment (rather than the 51 + * full url) keeps resolution robust if the writer's handle later changes. 52 + */ 53 + export function publicationSlugFromUrl( url: string ): string | null { 54 + try { 55 + const segments = new URL( url ).pathname.split( '/' ).filter( Boolean ); 56 + return segments.length >= 2 ? decodeURIComponent( segments[ segments.length - 1 ] ) : null; 57 + } catch { 58 + return null; 59 + } 60 + } 61 + 62 + /** 63 + * Whether a `site.standard.publication` url belongs to SkyPress (its origin === `SITE_BASE`). 64 + * SkyPress lists/edits/deletes/renders only its OWN publications — a writer may hold records 65 + * from other tools sharing the lexicon (Leaflet, …) and we must not touch those (Decision 0010). 66 + */ 67 + export function isSkyPressPublicationUrl( url: string ): boolean { 68 + try { 69 + return new URL( url ).origin === new URL( SITE_BASE ).origin; 70 + } catch { 71 + return false; 72 + } 73 + } 74 + 75 + /** 76 + * Derive a URL-safe slug from a publication name (Decision 0010): NFKD-normalise (so accents 77 + * fold to their base letter) → lowercase → trim → spaces to `-` → strip to `[a-z0-9-]` → 78 + * collapse and trim dashes. Returns '' for empty / emoji-only names; the caller then applies 79 + * the `pub-{rkey}` fallback. 80 + */ 81 + export function slugify( name: string ): string { 82 + return name 83 + .normalize( 'NFKD' ) 84 + .replace( /[\u0300-\u036f]/g, '' ) 85 + .toLowerCase() 86 + .trim() 87 + .replace( /\s+/g, '-' ) 88 + .replace( /[^a-z0-9-]/g, '' ) 89 + .replace( /-+/g, '-' ) 90 + .replace( /^-+|-+$/g, '' ); 91 + } 92 + 93 + /** 94 + * The frozen slug for a new publication: `slugify(name)`, de-duplicated against the writer's 95 + * existing SkyPress slugs by appending `-2`, `-3`, … (uniqueness is per-repo only — no global 96 + * registry, Decision 0010). Returns '' for an unslugifiable name so the caller can fall back 97 + * to `pub-{rkey}`. 98 + */ 99 + export function uniquePublicationSlug( name: string, existingSlugs: Iterable< string > ): string { 100 + const base = slugify( name ); 101 + if ( ! base ) { 102 + return ''; 103 + } 104 + const taken = new Set( existingSlugs ); 105 + if ( ! taken.has( base ) ) { 106 + return base; 107 + } 108 + for ( let n = 2; ; n++ ) { 109 + const candidate = `${ base }-${ n }`; 110 + if ( ! taken.has( candidate ) ) { 111 + return candidate; 112 + } 113 + } 45 114 } 46 115 47 116 /** ··· 77 146 url: string; 78 147 name: string; 79 148 description?: string; 149 + /** The publication logo (≤1MB, image/*) — the lexicon's `icon` blob (Decision 0010). */ 150 + icon?: BlobRefJson; 80 151 } 81 152 82 153 export function buildPublicationRecord( input: { 83 154 handle: string; 155 + /** Frozen at creation; preserved verbatim on edit so the url never breaks. */ 156 + slug: string; 84 157 name?: string; 85 158 description?: string; 159 + icon?: BlobRefJson; 86 160 } ): PublicationRecord { 161 + const trimmedName = input.name?.trim(); 162 + const description = input.description?.trim(); 87 163 return { 88 164 $type: 'site.standard.publication', 89 - url: publicationHomeUrl( input.handle ), 90 - name: input.name ?? input.handle, 91 - ...( input.description ? { description: input.description } : {} ), 165 + url: publicationHomeUrl( input.handle, input.slug ), 166 + name: trimmedName || input.handle, 167 + ...( description ? { description } : {} ), 168 + ...( input.icon ? { icon: input.icon } : {} ), 92 169 }; 93 170 } 94 171
+64
src/lib/reader/profile.test.ts
··· 1 + import { describe, expect, it, vi, beforeEach } from 'vitest'; 2 + import { fetchActorProfile } from './profile'; 3 + import { getRecord } from './records'; 4 + 5 + vi.mock( './records', () => ( { getRecord: vi.fn() } ) ); 6 + 7 + const mockedGetRecord = getRecord as unknown as ReturnType< typeof vi.fn >; 8 + const PDS = 'https://pds.example'; 9 + const DID = 'did:plc:me'; 10 + 11 + beforeEach( () => { 12 + mockedGetRecord.mockReset(); 13 + } ); 14 + 15 + describe( 'fetchActorProfile', () => { 16 + it( 'reads app.bsky.actor.profile/self and resolves blobs to getBlob URLs', async () => { 17 + mockedGetRecord.mockResolvedValue( { 18 + uri: `at://${ DID }/app.bsky.actor.profile/self`, 19 + cid: 'bafy', 20 + value: { 21 + displayName: 'Jane Rivera', 22 + description: ' Writer & gardener ', 23 + avatar: { $type: 'blob', ref: { $link: 'bafyavatar' }, mimeType: 'image/png', size: 1 }, 24 + banner: { $type: 'blob', ref: { $link: 'bafybanner' }, mimeType: 'image/png', size: 1 }, 25 + }, 26 + } ); 27 + const profile = await fetchActorProfile( PDS, DID ); 28 + 29 + expect( mockedGetRecord ).toHaveBeenCalledWith( 30 + PDS, 31 + DID, 32 + 'app.bsky.actor.profile', 33 + 'self' 34 + ); 35 + expect( profile.displayName ).toBe( 'Jane Rivera' ); 36 + expect( profile.description ).toBe( 'Writer & gardener' ); // trimmed 37 + expect( profile.avatar ).toContain( 'cid=bafyavatar' ); 38 + expect( profile.banner ).toContain( 'cid=bafybanner' ); 39 + } ); 40 + 41 + it( 'returns all-null when the profile record is missing', async () => { 42 + mockedGetRecord.mockResolvedValue( null ); 43 + const profile = await fetchActorProfile( PDS, DID ); 44 + expect( profile ).toEqual( { 45 + displayName: null, 46 + description: null, 47 + avatar: null, 48 + banner: null, 49 + } ); 50 + } ); 51 + 52 + it( 'coerces empty strings and missing blobs to null', async () => { 53 + mockedGetRecord.mockResolvedValue( { 54 + uri: 'x', 55 + cid: 'y', 56 + value: { displayName: ' ', description: '' }, 57 + } ); 58 + const profile = await fetchActorProfile( PDS, DID ); 59 + expect( profile.displayName ).toBeNull(); 60 + expect( profile.description ).toBeNull(); 61 + expect( profile.avatar ).toBeNull(); 62 + expect( profile.banner ).toBeNull(); 63 + } ); 64 + } );
+47
src/lib/reader/profile.ts
··· 1 + /** 2 + * Read a writer's `app.bsky.actor.profile` straight from their PDS (SP10, step G). 3 + * 4 + * The public author index borrows the writer's Bluesky identity (name, bio, avatar, cover) to 5 + * decorate the page. Per Decision 0010 this comes from the PDS record directly — NOT the 6 + * Bluesky appview — so the page has no third-party-service dependency. `getRecord` already 7 + * guards the (DID-doc-derived) PDS host against SSRF and degrades to null on failure. 8 + * 9 + * `displayName`/`description` are plain text; callers render them as text, never as HTML. 10 + */ 11 + import { getRecord } from './records'; 12 + import { buildGetBlobUrl, type BlobRefJson } from '../media/blob'; 13 + 14 + export interface ActorProfile { 15 + displayName: string | null; 16 + description: string | null; 17 + avatar: string | null; 18 + banner: string | null; 19 + } 20 + 21 + interface RawActorProfile { 22 + displayName?: string; 23 + description?: string; 24 + avatar?: BlobRefJson; 25 + banner?: BlobRefJson; 26 + } 27 + 28 + function blobUrl( pdsUrl: string, did: string, blob?: BlobRefJson ): string | null { 29 + const cid = blob?.ref?.$link; 30 + return cid ? buildGetBlobUrl( pdsUrl, did, cid ) : null; 31 + } 32 + 33 + export async function fetchActorProfile( pdsUrl: string, did: string ): Promise< ActorProfile > { 34 + const record = await getRecord< RawActorProfile >( 35 + pdsUrl, 36 + did, 37 + 'app.bsky.actor.profile', 38 + 'self' 39 + ); 40 + const value = record?.value ?? {}; 41 + return { 42 + displayName: value.displayName?.trim() || null, 43 + description: value.description?.trim() || null, 44 + avatar: blobUrl( pdsUrl, did, value.avatar ), 45 + banner: blobUrl( pdsUrl, did, value.banner ), 46 + }; 47 + }
+50
src/lib/reader/publications.test.ts
··· 1 + import { describe, expect, it, vi, beforeEach } from 'vitest'; 2 + import { listReaderPublications, resolveReaderPublication } from './publications'; 3 + import { listRecords } from './records'; 4 + import { SITE_BASE } from '../publish/records'; 5 + 6 + vi.mock( './records', () => ( { listRecords: vi.fn() } ) ); 7 + const mockedList = listRecords as unknown as ReturnType< typeof vi.fn >; 8 + 9 + const PDS = 'https://pds.example'; 10 + const DID = 'did:plc:me'; 11 + 12 + function rec( rkey: string, url: string, extra: Record< string, unknown > = {} ) { 13 + return { uri: `at://${ DID }/site.standard.publication/${ rkey }`, cid: 'c', value: { url, name: `P${ rkey }`, ...extra } }; 14 + } 15 + 16 + beforeEach( () => mockedList.mockReset() ); 17 + 18 + describe( 'listReaderPublications', () => { 19 + it( 'keeps only SkyPress-origin, slugged publications', async () => { 20 + mockedList.mockResolvedValue( [ 21 + rec( 'a', `${ SITE_BASE }/@me.bsky.social/blog`, { description: ' Hi ' } ), 22 + rec( 'b', 'https://leaflet.pub/x/y' ), 23 + rec( 'c', `${ SITE_BASE }/@me.bsky.social` ), 24 + ] ); 25 + const pubs = await listReaderPublications( PDS, DID ); 26 + expect( pubs ).toHaveLength( 1 ); 27 + expect( pubs[ 0 ] ).toMatchObject( { 28 + uri: `at://${ DID }/site.standard.publication/a`, 29 + slug: 'blog', 30 + name: 'Pa', 31 + description: 'Hi', 32 + } ); 33 + } ); 34 + } ); 35 + 36 + describe( 'resolveReaderPublication', () => { 37 + it( 'finds the publication whose slug matches', async () => { 38 + mockedList.mockResolvedValue( [ 39 + rec( 'a', `${ SITE_BASE }/@me.bsky.social/blog` ), 40 + rec( 'b', `${ SITE_BASE }/@me.bsky.social/notes` ), 41 + ] ); 42 + const pub = await resolveReaderPublication( PDS, DID, 'notes' ); 43 + expect( pub?.uri ).toBe( `at://${ DID }/site.standard.publication/b` ); 44 + } ); 45 + 46 + it( 'returns null when no slug matches', async () => { 47 + mockedList.mockResolvedValue( [ rec( 'a', `${ SITE_BASE }/@me.bsky.social/blog` ) ] ); 48 + expect( await resolveReaderPublication( PDS, DID, 'missing' ) ).toBeNull(); 49 + } ); 50 + } );
+73
src/lib/reader/publications.ts
··· 1 + /** 2 + * Reader-side (public, no-auth) resolution of a writer's SkyPress publications (SP10, step F/G). 3 + * 4 + * Reads `site.standard.publication` over the public `com.atproto.repo` XRPC (`listRecords`, 5 + * SSRF-guarded), keeping only publications SkyPress OWNS (`isSkyPressPublicationUrl`) so a 6 + * record some other tool wrote into the shared collection never renders under a skypress.blog 7 + * URL. The authed CRUD counterpart lives in `../publish/publications.ts`. 8 + */ 9 + import { listRecords } from './records'; 10 + import { 11 + isSkyPressPublicationUrl, 12 + publicationSlugFromUrl, 13 + } from '../publish/records'; 14 + import type { BlobRefJson } from '../media/blob'; 15 + 16 + export interface ReaderPublication { 17 + uri: string; 18 + slug: string; 19 + name: string; 20 + description: string | null; 21 + icon: BlobRefJson | null; 22 + } 23 + 24 + interface RawPublication { 25 + url?: string; 26 + name?: string; 27 + description?: string; 28 + icon?: BlobRefJson; 29 + } 30 + 31 + function toReaderPublication( record: { uri: string; value: RawPublication } ): ReaderPublication | null { 32 + const value = record.value; 33 + if ( ! value?.url || ! isSkyPressPublicationUrl( value.url ) ) { 34 + return null; 35 + } 36 + const slug = publicationSlugFromUrl( value.url ); 37 + if ( ! slug ) { 38 + return null; 39 + } 40 + return { 41 + uri: record.uri, 42 + slug, 43 + name: value.name ?? slug, 44 + description: value.description?.trim() || null, 45 + icon: value.icon ?? null, 46 + }; 47 + } 48 + 49 + /** All of a writer's SkyPress publications (newest first) — for the author index. */ 50 + export async function listReaderPublications( 51 + pdsUrl: string, 52 + did: string 53 + ): Promise< ReaderPublication[] > { 54 + const records = await listRecords< RawPublication >( 55 + pdsUrl, 56 + did, 57 + 'site.standard.publication', 58 + 100 59 + ); 60 + return records 61 + .map( ( record ) => toReaderPublication( record ) ) 62 + .filter( ( pub ): pub is ReaderPublication => pub !== null ); 63 + } 64 + 65 + /** Resolve one publication by its slug (the publication + document routes), or null. */ 66 + export async function resolveReaderPublication( 67 + pdsUrl: string, 68 + did: string, 69 + slug: string 70 + ): Promise< ReaderPublication | null > { 71 + const pubs = await listReaderPublications( pdsUrl, did ); 72 + return pubs.find( ( pub ) => pub.slug === slug ) ?? null; 73 + }
+28 -18
src/pages/[author]/[rkey].astro src/pages/[author]/[slug]/[rkey].astro
··· 1 1 --- 2 - import Base from '../../layouts/Base.astro'; 3 - import Logo from '../../components/Logo.astro'; 4 - import { resolveAuthor } from '../../lib/reader/identity'; 5 - import { getRecord } from '../../lib/reader/records'; 6 - import { resolveBlobImageUrls } from '../../lib/media/blob'; 7 - import { renderBlocks, blocksToText, type BlockNode } from '../../lib/blocks/render'; 8 - import { sanitizeArticleHtml } from '../../lib/reader/sanitize'; 9 - import { canonicalArticleUrl } from '../../lib/publish/records'; 2 + import Base from '../../../layouts/Base.astro'; 3 + import Logo from '../../../components/Logo.astro'; 4 + import { resolveAuthor } from '../../../lib/reader/identity'; 5 + import { getRecord } from '../../../lib/reader/records'; 6 + import { resolveReaderPublication } from '../../../lib/reader/publications'; 7 + import { resolveBlobImageUrls } from '../../../lib/media/blob'; 8 + import { renderBlocks, blocksToText, type BlockNode } from '../../../lib/blocks/render'; 9 + import { sanitizeArticleHtml } from '../../../lib/reader/sanitize'; 10 + import { canonicalArticleUrl } from '../../../lib/publish/records'; 10 11 11 12 // Frontend block styles only — no editor chrome, no JS. 12 13 import '@wordpress/block-library/build-style/common.css'; ··· 26 27 content?: { blocks?: BlockNode[] }; 27 28 } 28 29 29 - const { author, rkey } = Astro.params; 30 + const { author, slug, rkey } = Astro.params; 30 31 31 - if ( ! author || ! author.startsWith( '@' ) || ! rkey ) { 32 + if ( ! author || ! author.startsWith( '@' ) || ! slug || ! rkey ) { 32 33 return new Response( 'Not found', { status: 404 } ); 33 34 } 34 35 const handle = author.slice( 1 ); ··· 39 40 } 40 41 const { did, pdsUrl } = resolved; 41 42 43 + // Resolve the publication by slug (Decision 0010), then the document by rkey. 44 + const publication = await resolveReaderPublication( pdsUrl, did, slug ); 45 + if ( ! publication ) { 46 + return new Response( 'Publication not found', { status: 404 } ); 47 + } 48 + 42 49 const record = await getRecord< SkyDocument >( pdsUrl, did, 'site.standard.document', rkey ); 43 50 if ( ! record?.value ) { 44 51 return new Response( 'Article not found', { status: 404 } ); 45 52 } 46 53 54 + // A document only renders under the publication it belongs to (Decision 0010, §3 step 3). 55 + if ( record.value.site !== publication.uri ) { 56 + return new Response( 'Article not found in this publication', { status: 404 } ); 57 + } 58 + 47 59 const doc = record.value; 48 60 const blocks = resolveBlobImageUrls( doc.content?.blocks ?? [], { pdsUrl, did } ); 49 61 const html = sanitizeArticleHtml( renderBlocks( blocks ) ); ··· 51 63 52 64 const title = doc.title ?? 'Untitled'; 53 65 const description = doc.description || textContent.slice( 0, 200 ); 54 - const canonical = canonicalArticleUrl( handle, rkey ); 66 + const canonical = canonicalArticleUrl( handle, slug, rkey ); 55 67 const docUri = `at://${ did }/site.standard.document/${ rkey }`; 56 - const pubUri = doc.site; 68 + const pubUrl = `/${ author }/${ slug }`; 57 69 58 70 const words = textContent.split( /\s+/ ).filter( Boolean ).length; 59 71 const readingMinutes = Math.max( 1, Math.round( words / 200 ) ); ··· 61 73 const updatedLabel = doc.updatedAt ? doc.updatedAt.slice( 0, 10 ) : null; 62 74 --- 63 75 64 - <Base title={`${ title } — SkyPress`} description={description}> 76 + <Base title={`${ title } — ${ publication.name }`} description={description}> 65 77 <Fragment slot="head"> 66 78 {/* standard.site indexing tags (brief §3) — Bluesky cards + AppView pickup */} 67 79 <link rel="site.standard.document" href={docUri} /> 68 - {pubUri?.startsWith( 'at://' ) && ( 69 - <link rel="site.standard.publication" href={pubUri} /> 70 - )} 80 + <link rel="site.standard.publication" href={publication.uri} /> 71 81 <link rel="canonical" href={canonical} /> 72 82 <meta property="og:type" content="article" /> 73 83 <meta property="og:title" content={title} /> ··· 80 90 </header> 81 91 <main class="reader"> 82 92 <p class="reader__meta eyebrow"> 83 - <a class="reader__author" href={`/@${ handle }`}>@{handle}</a> 93 + <a class="reader__author" href={pubUrl}>{publication.name}</a> 84 94 {publishedLabel && <> · {publishedLabel}</>} 85 95 {updatedLabel && <> · updated {updatedLabel}</>} 86 96 · {readingMinutes} min read ··· 88 98 <h1 class="reader__title">{title}</h1> 89 99 <article class="reader__article" set:html={html} /> 90 100 <footer class="reader__foot"> 91 - <a class="reader__author" href={`/@${ handle }`}>More from @{handle}</a> 101 + <a class="reader__author" href={pubUrl}>More from {publication.name}</a> 92 102 </footer> 93 103 </main> 94 104 </Base>
+221
src/pages/[author]/[slug]/index.astro
··· 1 + --- 2 + import Base from '../../../layouts/Base.astro'; 3 + import Logo from '../../../components/Logo.astro'; 4 + import { resolveAuthor } from '../../../lib/reader/identity'; 5 + import { listRecords } from '../../../lib/reader/records'; 6 + import { resolveReaderPublication } from '../../../lib/reader/publications'; 7 + import { fetchActorProfile } from '../../../lib/reader/profile'; 8 + import { buildGetBlobUrl } from '../../../lib/media/blob'; 9 + 10 + export const prerender = false; 11 + 12 + interface DocumentValue { 13 + title?: string; 14 + description?: string; 15 + site?: string; 16 + publishedAt?: string; 17 + } 18 + 19 + const { author, slug } = Astro.params; 20 + if ( ! author || ! author.startsWith( '@' ) || ! slug ) { 21 + return new Response( 'Not found', { status: 404 } ); 22 + } 23 + const handle = author.slice( 1 ); 24 + 25 + const resolved = await resolveAuthor( handle ); 26 + if ( ! resolved ) { 27 + return new Response( `Could not resolve @${ handle }`, { status: 404 } ); 28 + } 29 + const { did, pdsUrl } = resolved; 30 + 31 + const publication = await resolveReaderPublication( pdsUrl, did, slug ); 32 + if ( ! publication ) { 33 + return new Response( 'Publication not found', { status: 404 } ); 34 + } 35 + 36 + const profile = await fetchActorProfile( pdsUrl, did ); 37 + 38 + const allDocs = await listRecords< DocumentValue >( 39 + pdsUrl, 40 + did, 41 + 'site.standard.document', 42 + 100 43 + ); 44 + const articles = allDocs 45 + .filter( ( record ) => record.value.site === publication.uri ) 46 + .map( ( record ) => ( { 47 + rkey: record.uri.split( '/' ).pop() as string, 48 + title: record.value.title ?? 'Untitled', 49 + description: record.value.description, 50 + publishedLabel: record.value.publishedAt?.slice( 0, 10 ) ?? null, 51 + } ) ); 52 + 53 + const logoUrl = publication.icon 54 + ? buildGetBlobUrl( pdsUrl, did, publication.icon.ref.$link ) 55 + : null; 56 + const authorName = profile.displayName ?? `@${ handle }`; 57 + const initial = publication.name.charAt( 0 ).toUpperCase(); 58 + --- 59 + 60 + <Base title={`${ publication.name } — SkyPress`} description={publication.description ?? undefined}> 61 + <Fragment slot="head"> 62 + <link rel="site.standard.publication" href={publication.uri} /> 63 + </Fragment> 64 + 65 + <header class="masthead"> 66 + <a href="/"><Logo /></a> 67 + </header> 68 + 69 + <main class="pub"> 70 + <div class="pub__hero"> 71 + {profile.banner && ( 72 + <div class="pub__cover" style={`background-image:url("${ profile.banner }")`} aria-hidden="true"></div> 73 + )} 74 + <div class={`pub__identity${ profile.banner ? ' pub__identity--overlap' : '' }`}> 75 + {logoUrl ? ( 76 + <img class="pub__logo" src={logoUrl} alt="" width="88" height="88" /> 77 + ) : ( 78 + <span class="pub__logo pub__logo--fallback" aria-hidden="true">{initial}</span> 79 + )} 80 + <h1 class="pub__title">{publication.name}</h1> 81 + <p class="pub__byline"> 82 + <a href={`/@${ handle }`}> 83 + {profile.avatar && ( 84 + <img class="pub__byavatar" src={profile.avatar} alt="" width="22" height="22" /> 85 + )} 86 + by {authorName} 87 + </a> 88 + </p> 89 + {publication.description && <p class="pub__lede">{publication.description}</p>} 90 + </div> 91 + </div> 92 + 93 + {articles.length === 0 ? ( 94 + <p class="pub__empty">No articles published in this publication yet.</p> 95 + ) : ( 96 + <ul class="pub__list"> 97 + {articles.map( ( article ) => ( 98 + <li class="pub__item"> 99 + <a href={`/@${ handle }/${ slug }/${ article.rkey }`}>{article.title}</a> 100 + {article.publishedLabel && <span class="pub__date">{article.publishedLabel}</span>} 101 + {article.description && <p class="pub__desc">{article.description}</p>} 102 + </li> 103 + ) )} 104 + </ul> 105 + )} 106 + </main> 107 + </Base> 108 + 109 + <style> 110 + .masthead { 111 + padding: 1.5rem clamp(1.25rem, 5vw, 4rem); 112 + border-bottom: 1px solid var(--line); 113 + } 114 + .masthead a { 115 + text-decoration: none; 116 + } 117 + .pub { 118 + max-width: 44rem; 119 + margin: 0 auto; 120 + padding: 0 1.5rem 6rem; 121 + } 122 + .pub__hero { 123 + margin: 0 0 2rem; 124 + } 125 + .pub__cover { 126 + height: clamp(8rem, 26vw, 14rem); 127 + margin: 0 calc(-1 * clamp(1.5rem, 5vw, 4rem)); 128 + background-size: cover; 129 + background-position: center; 130 + border-bottom: 1px solid var(--line); 131 + } 132 + .pub__identity { 133 + text-align: center; 134 + padding-top: 2.5rem; 135 + } 136 + .pub__identity--overlap { 137 + margin-top: -3.25rem; 138 + } 139 + .pub__logo { 140 + width: 88px; 141 + height: 88px; 142 + border-radius: 20px; 143 + object-fit: cover; 144 + border: 3px solid var(--paper); 145 + box-shadow: var(--shadow); 146 + background: var(--paper-raised); 147 + } 148 + .pub__logo--fallback { 149 + display: inline-flex; 150 + align-items: center; 151 + justify-content: center; 152 + font-family: var(--font-display); 153 + font-size: 2.4rem; 154 + font-weight: 700; 155 + color: var(--sun); 156 + background: var(--sun-tint); 157 + } 158 + .pub__title { 159 + font-size: clamp(2.2rem, 6vw, 3.4rem); 160 + margin: 0.75rem 0 0.4rem; 161 + } 162 + .pub__byline { 163 + margin: 0; 164 + font-size: 0.95rem; 165 + } 166 + .pub__byline a { 167 + display: inline-flex; 168 + align-items: center; 169 + gap: 0.4rem; 170 + color: var(--ink-soft); 171 + text-decoration: none; 172 + } 173 + .pub__byline a:hover { 174 + color: var(--sun); 175 + } 176 + .pub__byavatar { 177 + width: 22px; 178 + height: 22px; 179 + border-radius: 50%; 180 + object-fit: cover; 181 + } 182 + .pub__lede { 183 + color: var(--ink-soft); 184 + font-size: 1.15rem; 185 + max-width: 42ch; 186 + margin: 1rem auto 0; 187 + } 188 + .pub__empty { 189 + color: var(--muted); 190 + text-align: center; 191 + } 192 + .pub__list { 193 + list-style: none; 194 + padding: 0; 195 + margin: 1rem 0 0; 196 + } 197 + .pub__item { 198 + padding: 1.35rem 0; 199 + border-top: 1px solid var(--line); 200 + } 201 + .pub__item a { 202 + font-family: var(--font-display); 203 + font-size: 1.4rem; 204 + font-weight: 560; 205 + color: var(--ink); 206 + text-decoration: none; 207 + } 208 + .pub__item a:hover { 209 + color: var(--sun); 210 + } 211 + .pub__date { 212 + color: var(--muted); 213 + font-family: var(--font-mono); 214 + font-size: 0.72rem; 215 + margin-left: 0.75rem; 216 + } 217 + .pub__desc { 218 + color: var(--ink-soft); 219 + margin: 0.4rem 0 0; 220 + } 221 + </style>
+149 -91
src/pages/[author]/index.astro
··· 2 2 import Base from '../../layouts/Base.astro'; 3 3 import Logo from '../../components/Logo.astro'; 4 4 import { resolveAuthor } from '../../lib/reader/identity'; 5 - import { listRecords } from '../../lib/reader/records'; 6 - import { publicationHomeUrl } from '../../lib/publish/records'; 5 + import { listReaderPublications } from '../../lib/reader/publications'; 6 + import { fetchActorProfile } from '../../lib/reader/profile'; 7 + import { buildGetBlobUrl } from '../../lib/media/blob'; 7 8 8 9 export const prerender = false; 9 10 10 - interface PublicationValue { 11 - url?: string; 12 - name?: string; 13 - description?: string; 14 - } 15 - interface DocumentValue { 16 - title?: string; 17 - description?: string; 18 - site?: string; 19 - publishedAt?: string; 20 - } 21 - 22 11 const { author } = Astro.params; 23 12 if ( ! author || ! author.startsWith( '@' ) ) { 24 13 return new Response( 'Not found', { status: 404 } ); ··· 31 20 } 32 21 const { did, pdsUrl } = resolved; 33 22 34 - const wantUrl = publicationHomeUrl( handle ); 35 - const publications = await listRecords< PublicationValue >( 36 - pdsUrl, 37 - did, 38 - 'site.standard.publication' 39 - ); 40 - const publication = publications.find( ( record ) => record.value.url === wantUrl ); 41 - 42 - const allDocs = await listRecords< DocumentValue >( 43 - pdsUrl, 44 - did, 45 - 'site.standard.document', 46 - 100 47 - ); 48 - const docs = publication 49 - ? allDocs.filter( ( record ) => record.value.site === publication.uri ) 50 - : []; 23 + const [ profile, publications ] = await Promise.all( [ 24 + fetchActorProfile( pdsUrl, did ), 25 + listReaderPublications( pdsUrl, did ), 26 + ] ); 51 27 52 - const pubName = publication?.value.name ?? `@${ handle }`; 53 - const pubUri = publication?.uri; 54 - 55 - const articles = docs.map( ( record ) => ( { 56 - rkey: record.uri.split( '/' ).pop() as string, 57 - title: record.value.title ?? 'Untitled', 58 - description: record.value.description, 59 - publishedLabel: record.value.publishedAt?.slice( 0, 10 ) ?? null, 60 - } ) ); 28 + const authorName = profile.displayName ?? `@${ handle }`; 29 + const initial = authorName.replace( /^@/, '' ).charAt( 0 ).toUpperCase(); 61 30 --- 62 31 63 - <Base title={`${ pubName } — SkyPress`} description={publication?.value.description}> 64 - <Fragment slot="head"> 65 - {pubUri && <link rel="site.standard.publication" href={pubUri} />} 66 - </Fragment> 67 - 32 + <Base title={`${ authorName } — SkyPress`} description={profile.description ?? undefined}> 68 33 <header class="masthead"> 69 34 <a href="/"><Logo /></a> 70 35 </header> 71 - <main class="home"> 72 - <p class="home__handle eyebrow">@{handle}</p> 73 - <h1 class="home__title">{pubName}</h1> 74 - {publication?.value.description && ( 75 - <p class="home__lede">{publication.value.description}</p> 76 - )} 77 36 78 - {articles.length === 0 ? ( 79 - <p class="home__empty">No SkyPress articles published yet.</p> 37 + <main class="author"> 38 + <div class="author__hero"> 39 + {profile.banner && ( 40 + <div class="author__cover" style={`background-image:url("${ profile.banner }")`} aria-hidden="true"></div> 41 + )} 42 + <div class={`author__identity${ profile.banner ? ' author__identity--overlap' : '' }`}> 43 + {profile.avatar ? ( 44 + <img class="author__avatar" src={profile.avatar} alt="" width="96" height="96" /> 45 + ) : ( 46 + <span class="author__avatar author__avatar--fallback" aria-hidden="true">{initial}</span> 47 + )} 48 + <h1 class="author__name">{authorName}</h1> 49 + <p class="author__handle">@{handle}</p> 50 + {profile.description && <p class="author__bio">{profile.description}</p>} 51 + </div> 52 + </div> 53 + 54 + <h2 class="author__heading">Publications</h2> 55 + {publications.length === 0 ? ( 56 + <p class="author__empty">No SkyPress publications yet.</p> 80 57 ) : ( 81 - <ul class="home__list"> 82 - {articles.map( ( article ) => ( 83 - <li class="home__item"> 84 - <a href={`/@${ handle }/${ article.rkey }`}>{article.title}</a> 85 - {article.publishedLabel && ( 86 - <span class="home__date">{article.publishedLabel}</span> 87 - )} 88 - {article.description && <p class="home__desc">{article.description}</p>} 89 - </li> 90 - ) )} 58 + <ul class="author__list"> 59 + {publications.map( ( pub ) => { 60 + const logoUrl = pub.icon 61 + ? buildGetBlobUrl( pdsUrl, did, pub.icon.ref.$link ) 62 + : null; 63 + return ( 64 + <li class="author__item"> 65 + <a class="author__pub" href={`/@${ handle }/${ pub.slug }`}> 66 + {logoUrl ? ( 67 + <img class="author__publogo" src={logoUrl} alt="" width="52" height="52" /> 68 + ) : ( 69 + <span class="author__publogo author__publogo--fallback" aria-hidden="true"> 70 + {pub.name.charAt( 0 ).toUpperCase()} 71 + </span> 72 + )} 73 + <span class="author__pubtext"> 74 + <span class="author__pubname">{pub.name}</span> 75 + {pub.description && <span class="author__pubdesc">{pub.description}</span>} 76 + </span> 77 + </a> 78 + </li> 79 + ); 80 + } )} 91 81 </ul> 92 82 )} 93 83 </main> ··· 101 91 .masthead a { 102 92 text-decoration: none; 103 93 } 104 - .home { 105 - max-width: 42rem; 94 + .author { 95 + max-width: 44rem; 106 96 margin: 0 auto; 107 - padding: clamp(2.5rem, 6vw, 4.5rem) 1.5rem 6rem; 97 + padding: 0 1.5rem 6rem; 108 98 } 109 - .home__handle { 110 - margin: 0; 99 + .author__hero { 100 + margin: 0 0 2rem; 111 101 } 112 - .home__title { 113 - font-size: clamp(2.2rem, 6vw, 3.6rem); 114 - margin: 0.4rem 0 1rem; 102 + .author__cover { 103 + height: clamp(8rem, 26vw, 14rem); 104 + margin: 0 calc(-1 * clamp(1.5rem, 5vw, 4rem)); 105 + background-size: cover; 106 + background-position: center; 107 + border-bottom: 1px solid var(--line); 115 108 } 116 - .home__lede { 109 + .author__identity { 110 + text-align: center; 111 + padding-top: 2.5rem; 112 + } 113 + .author__identity--overlap { 114 + margin-top: -3.5rem; 115 + } 116 + .author__avatar { 117 + width: 96px; 118 + height: 96px; 119 + border-radius: 50%; 120 + object-fit: cover; 121 + border: 3px solid var(--paper); 122 + box-shadow: var(--shadow); 123 + background: var(--paper-raised); 124 + } 125 + .author__avatar--fallback { 126 + display: inline-flex; 127 + align-items: center; 128 + justify-content: center; 129 + font-family: var(--font-display); 130 + font-size: 2.6rem; 131 + font-weight: 700; 132 + color: var(--sun); 133 + background: var(--sun-tint); 134 + } 135 + .author__name { 136 + font-size: clamp(2rem, 5.5vw, 3rem); 137 + margin: 0.75rem 0 0.1rem; 138 + } 139 + .author__handle { 140 + color: var(--muted); 141 + font-family: var(--font-mono); 142 + font-size: 0.85rem; 143 + margin: 0; 144 + } 145 + .author__bio { 117 146 color: var(--ink-soft); 118 - font-size: 1.2rem; 119 - max-width: 42ch; 147 + max-width: 46ch; 148 + margin: 1rem auto 0; 120 149 } 121 - .home__empty { 150 + .author__heading { 151 + font-size: 0.75rem; 152 + text-transform: uppercase; 153 + letter-spacing: 0.1em; 122 154 color: var(--muted); 155 + margin: 0 0 0.75rem; 123 156 } 124 - .home__list { 157 + .author__empty { 158 + color: var(--muted); 159 + } 160 + .author__list { 125 161 list-style: none; 126 162 padding: 0; 127 - margin: 2.5rem 0 0; 163 + margin: 0; 128 164 } 129 - .home__item { 130 - padding: 1.35rem 0; 165 + .author__item { 131 166 border-top: 1px solid var(--line); 132 167 } 133 - .home__item a { 168 + .author__pub { 169 + display: flex; 170 + align-items: center; 171 + gap: 1rem; 172 + padding: 1.1rem 0; 173 + text-decoration: none; 174 + color: var(--ink); 175 + } 176 + .author__publogo { 177 + width: 52px; 178 + height: 52px; 179 + border-radius: 12px; 180 + object-fit: cover; 181 + flex: none; 182 + background: var(--paper-raised); 183 + } 184 + .author__publogo--fallback { 185 + display: inline-flex; 186 + align-items: center; 187 + justify-content: center; 134 188 font-family: var(--font-display); 189 + font-weight: 700; 135 190 font-size: 1.4rem; 191 + color: var(--sun); 192 + background: var(--sun-tint); 193 + } 194 + .author__pubtext { 195 + display: flex; 196 + flex-direction: column; 197 + min-width: 0; 198 + } 199 + .author__pubname { 200 + font-family: var(--font-display); 201 + font-size: 1.3rem; 136 202 font-weight: 560; 137 - color: var(--ink); 138 - text-decoration: none; 139 203 } 140 - .home__item a:hover { 204 + .author__pub:hover .author__pubname { 141 205 color: var(--sun); 142 206 } 143 - .home__date { 144 - color: var(--muted); 145 - font-family: var(--font-mono); 146 - font-size: 0.72rem; 147 - margin-left: 0.75rem; 148 - } 149 - .home__desc { 207 + .author__pubdesc { 150 208 color: var(--ink-soft); 151 - margin: 0.4rem 0 0; 209 + font-size: 0.95rem; 152 210 } 153 211 </style>
+366
src/pages/dashboard.astro
··· 1 + --- 2 + import Base from '../layouts/Base.astro'; 3 + import Logo from '../components/Logo.astro'; 4 + import Dashboard from '../components/Dashboard.tsx'; 5 + --- 6 + 7 + <Base title="Dashboard — SkyPress"> 8 + <main class="dash-shell"> 9 + <header class="dash-shell__bar"> 10 + <a class="dash-shell__home" href="/"><Logo size={24} /></a> 11 + <span class="dash-shell__hint eyebrow">Publications</span> 12 + </header> 13 + <!-- client:only — auth runs only in the browser; its bundle never reaches 14 + reading pages (Decisions 0003 & 0004). No `@wordpress/*` here. --> 15 + <Dashboard client:only="react"> 16 + <p slot="fallback" class="dash__loading">Loading…</p> 17 + </Dashboard> 18 + </main> 19 + </Base> 20 + 21 + <style> 22 + .dash-shell__bar { 23 + display: flex; 24 + align-items: center; 25 + gap: 1rem; 26 + padding: 0.75rem 1.25rem; 27 + border-bottom: 1px solid var(--line); 28 + flex-wrap: wrap; 29 + } 30 + .dash-shell__home { 31 + display: inline-flex; 32 + font-weight: 700; 33 + color: var(--ink); 34 + text-decoration: none; 35 + } 36 + .dash-shell__hint { 37 + font-size: 0.85rem; 38 + line-height: 1; 39 + } 40 + </style> 41 + 42 + <!-- Dashboard is a `client:only` React island, so Astro's scoped styles never reach 43 + its DOM. These rules must be global. --> 44 + <style is:global> 45 + .dash__loading, 46 + .dash__empty { 47 + padding: 1.5rem; 48 + color: var(--muted); 49 + } 50 + .dash__login { 51 + max-width: 30rem; 52 + margin: 0 auto; 53 + padding: 4rem 1.5rem; 54 + } 55 + .dash__error { 56 + color: var(--ember); 57 + font-size: 0.9rem; 58 + } 59 + .dash { 60 + max-width: 48rem; 61 + margin: 0 auto; 62 + padding: 0 1.25rem 5rem; 63 + } 64 + .dash__bar { 65 + display: flex; 66 + align-items: center; 67 + justify-content: space-between; 68 + gap: 1rem; 69 + padding: 0.75rem 0; 70 + flex-wrap: wrap; 71 + } 72 + .dash__crumb { 73 + border: 0; 74 + background: none; 75 + font: inherit; 76 + font-weight: 600; 77 + color: var(--sun); 78 + cursor: pointer; 79 + padding: 0; 80 + } 81 + .dash__crumb:disabled { 82 + color: var(--muted); 83 + cursor: default; 84 + } 85 + .dash__bar-actions { 86 + display: flex; 87 + align-items: center; 88 + gap: 0.75rem; 89 + } 90 + .dash__link { 91 + color: var(--sun); 92 + text-decoration: none; 93 + font-size: 0.9rem; 94 + } 95 + .dash__link:hover { 96 + text-decoration: underline; 97 + } 98 + .dash__signout { 99 + border: 1px solid var(--line-strong); 100 + background: var(--paper-raised); 101 + border-radius: var(--radius-sm); 102 + padding: 0.3rem 0.7rem; 103 + cursor: pointer; 104 + font: inherit; 105 + font-size: 0.85rem; 106 + } 107 + .dash__section-head { 108 + display: flex; 109 + align-items: center; 110 + justify-content: space-between; 111 + gap: 1rem; 112 + flex-wrap: wrap; 113 + margin: 1rem 0 1.5rem; 114 + } 115 + .dash__h1 { 116 + font-size: clamp(1.6rem, 4vw, 2.2rem); 117 + margin: 0; 118 + } 119 + .dash__new { 120 + border: 0; 121 + border-radius: 8px; 122 + background: var(--sun); 123 + color: #fff; 124 + font: inherit; 125 + font-weight: 600; 126 + padding: 0.5rem 1rem; 127 + cursor: pointer; 128 + } 129 + .dash__pubs, 130 + .dash__postlist { 131 + list-style: none; 132 + margin: 0; 133 + padding: 0; 134 + } 135 + .dash__pub { 136 + display: flex; 137 + align-items: center; 138 + gap: 1rem; 139 + padding: 0.9rem 0; 140 + border-top: 1px solid var(--line); 141 + } 142 + .dash__publogo { 143 + width: 48px; 144 + height: 48px; 145 + border-radius: 10px; 146 + object-fit: cover; 147 + flex: none; 148 + background: var(--paper-raised); 149 + } 150 + .dash__publogo--fallback { 151 + display: inline-flex; 152 + align-items: center; 153 + justify-content: center; 154 + font-family: var(--font-display); 155 + font-weight: 700; 156 + color: var(--sun); 157 + background: var(--sun-tint); 158 + } 159 + .dash__pubtext { 160 + display: flex; 161 + flex-direction: column; 162 + flex: 1; 163 + min-width: 0; 164 + } 165 + .dash__pubname { 166 + font-weight: 600; 167 + font-size: 1.1rem; 168 + } 169 + .dash__pubslug { 170 + color: var(--muted); 171 + font-family: var(--font-mono); 172 + font-size: 0.78rem; 173 + } 174 + .dash__pubactions { 175 + display: flex; 176 + align-items: center; 177 + gap: 0.6rem; 178 + } 179 + .dash__pubactions button, 180 + .dash__post button { 181 + border: 1px solid var(--line-strong); 182 + background: var(--paper-raised); 183 + border-radius: 6px; 184 + padding: 0.3rem 0.7rem; 185 + font: inherit; 186 + font-size: 0.85rem; 187 + cursor: pointer; 188 + } 189 + .dash__tabs { 190 + display: flex; 191 + gap: 0.5rem; 192 + border-bottom: 1px solid var(--line); 193 + margin-bottom: 1.5rem; 194 + } 195 + .dash__tabs button { 196 + border: 0; 197 + background: none; 198 + font: inherit; 199 + padding: 0.5rem 0.25rem; 200 + margin-bottom: -1px; 201 + border-bottom: 2px solid transparent; 202 + color: var(--muted); 203 + cursor: pointer; 204 + } 205 + .dash__tabs button.is-active { 206 + color: var(--ink); 207 + border-bottom-color: var(--sun); 208 + font-weight: 600; 209 + } 210 + .dash__post { 211 + display: flex; 212 + align-items: center; 213 + gap: 0.85rem; 214 + padding: 0.65rem 0; 215 + border-top: 1px solid var(--line); 216 + } 217 + .dash__postlink { 218 + flex: 1; 219 + min-width: 0; 220 + color: var(--ink); 221 + text-decoration: none; 222 + font-weight: 540; 223 + } 224 + .dash__postlink:hover { 225 + color: var(--sun); 226 + } 227 + .dash__postdate { 228 + color: var(--muted); 229 + font-family: var(--font-mono); 230 + font-size: 0.72rem; 231 + } 232 + .dash__danger { 233 + border: 1px solid var(--ember); 234 + border-radius: var(--radius); 235 + padding: 1.25rem; 236 + background: color-mix(in srgb, var(--ember) 6%, transparent); 237 + } 238 + .dash__danger h2 { 239 + margin: 0 0 0.5rem; 240 + font-size: 1.2rem; 241 + } 242 + .dash__danger p { 243 + color: var(--ink-soft); 244 + margin: 0 0 1rem; 245 + } 246 + .dash__delete { 247 + border: 0; 248 + border-radius: 8px; 249 + background: var(--ember); 250 + color: #fff; 251 + font: inherit; 252 + font-weight: 600; 253 + padding: 0.55rem 1rem; 254 + cursor: pointer; 255 + } 256 + .dash__delete:disabled { 257 + opacity: 0.6; 258 + cursor: not-allowed; 259 + } 260 + 261 + /* Publication form */ 262 + .pubform { 263 + max-width: 34rem; 264 + } 265 + .pubform__title { 266 + font-size: 1.4rem; 267 + margin: 0 0 1.25rem; 268 + } 269 + .pubform__logo-row { 270 + display: flex; 271 + align-items: center; 272 + gap: 1rem; 273 + margin-bottom: 1.25rem; 274 + } 275 + .pubform__logo { 276 + width: 72px; 277 + height: 72px; 278 + border-radius: 16px; 279 + object-fit: cover; 280 + flex: none; 281 + background: var(--paper-raised); 282 + border: 1px solid var(--line); 283 + } 284 + .pubform__logo--fallback { 285 + display: inline-flex; 286 + align-items: center; 287 + justify-content: center; 288 + font-family: var(--font-display); 289 + font-size: 1.8rem; 290 + font-weight: 700; 291 + color: var(--sun); 292 + background: var(--sun-tint); 293 + } 294 + .pubform__logo-label { 295 + display: flex; 296 + flex-direction: column; 297 + gap: 0.25rem; 298 + font-size: 0.85rem; 299 + font-weight: 600; 300 + } 301 + .pubform__logo-label small { 302 + font-weight: 400; 303 + color: var(--muted); 304 + } 305 + .pubform__field { 306 + display: block; 307 + margin-bottom: 1rem; 308 + } 309 + .pubform__field span { 310 + display: block; 311 + font-size: 0.85rem; 312 + font-weight: 600; 313 + margin-bottom: 0.35rem; 314 + } 315 + .pubform__field input, 316 + .pubform__field textarea { 317 + width: 100%; 318 + box-sizing: border-box; 319 + padding: 0.6rem 0.7rem; 320 + border: 1px solid var(--line-strong); 321 + border-radius: 8px; 322 + font: inherit; 323 + } 324 + .pubform__field textarea { 325 + resize: vertical; 326 + } 327 + .pubform__note { 328 + font-size: 0.82rem; 329 + color: var(--muted); 330 + } 331 + .pubform__note code, 332 + .dash__danger code { 333 + font-family: var(--font-mono); 334 + } 335 + .pubform__error { 336 + color: var(--ember); 337 + font-size: 0.9rem; 338 + } 339 + .pubform__actions { 340 + display: flex; 341 + gap: 0.75rem; 342 + margin-top: 1.25rem; 343 + } 344 + .pubform__save { 345 + border: 0; 346 + border-radius: 8px; 347 + background: var(--sun); 348 + color: #fff; 349 + font: inherit; 350 + font-weight: 600; 351 + padding: 0.55rem 1.1rem; 352 + cursor: pointer; 353 + } 354 + .pubform__save:disabled { 355 + opacity: 0.6; 356 + cursor: not-allowed; 357 + } 358 + .pubform__cancel { 359 + border: 1px solid var(--line-strong); 360 + background: var(--paper-raised); 361 + border-radius: 8px; 362 + padding: 0.55rem 1rem; 363 + font: inherit; 364 + cursor: pointer; 365 + } 366 + </style>
+24 -1
src/pages/editor.astro
··· 190 190 font: inherit; 191 191 font-size: 1.05rem; 192 192 } 193 + .publish__target { 194 + display: inline-flex; 195 + align-items: center; 196 + gap: 0.4rem; 197 + font-size: 0.85rem; 198 + color: var(--muted); 199 + } 200 + .publish__target--fixed strong { 201 + color: var(--ink); 202 + } 203 + .publish__select { 204 + padding: 0.45rem 0.6rem; 205 + border: 1px solid var(--line-strong); 206 + border-radius: 8px; 207 + background: var(--paper-raised); 208 + font: inherit; 209 + font-size: 0.9rem; 210 + } 193 211 .publish__button { 194 212 padding: 0.5rem 1rem; 195 213 border: 0; ··· 272 290 gap: 1rem; 273 291 padding: 0.4rem 0; 274 292 } 275 - .myarticles__edited { 293 + .myarticles__edited, 294 + .myarticles__pub { 276 295 color: var(--muted); 277 296 font-style: normal; 278 297 font-size: 0.85rem; 298 + } 299 + .myarticles__pub { 300 + font-family: var(--font-mono); 301 + font-size: 0.78rem; 279 302 } 280 303 .myarticles__actions { 281 304 display: flex;
+1
src/pages/index.astro
··· 41 41 <Logo /> 42 42 <div class="masthead__right"> 43 43 <AuthorPill client:only="react" /> 44 + <a class="btn btn--ghost" href="/dashboard">Dashboard</a> 44 45 <a class="btn btn--ghost" href="/editor">Open the studio</a> 45 46 </div> 46 47 </header>