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.

Merge remote-tracking branch 'origin/trunk' into editor-publish-success-pill

# Conflicts:
# src/components/Studio.tsx

+1162 -104
+3 -1
AGENTS.md
··· 40 40 **sanitises** HTML before injecting it (`src/lib/reader/sanitize.ts`). Three standing 41 41 rules for the read path: (a) any server-side `fetch` to a host derived from user input 42 42 (a handle, `did:web`, a PDS `serviceEndpoint`) MUST go through `src/lib/net/safe-fetch.ts` 43 - (SSRF guard); (b) never inject PDS-sourced HTML without sanitising; (c) read routes 43 + (SSRF guard); (b) never inject PDS-sourced HTML without sanitising — turn document 44 + blocks into HTML through `src/lib/reader/render-article.ts`, which runs 45 + blob-resolve → render → sanitise in the one safe order (Decision 0017); (c) read routes 44 46 resolve author/publication/document through `src/lib/reader/read-context.ts` — don't 45 47 re-assemble the handle → DID → PDS → slug-match → site-join chain in page frontmatter. 46 48 (Decision 0016)
+1 -1
README.md
··· 134 134 auth/ oauth.ts · AuthProvider.tsx · config.ts · LoginForm.tsx · profile.ts · nav.ts · cta.ts 135 135 publish/ records.ts (pure builders) · publisher.ts (Agent orchestration) · publications.ts (publication CRUD) · themes.ts 136 136 media/ mediaUpload.ts · uploadImage.ts (logo) · blob.ts · pds.ts 137 - reader/ read-context.ts (read-route orchestration) · identity.ts · records.ts · publications.ts · profile.ts · sanitize.ts 137 + reader/ read-context.ts (read-route orchestration) · render-article.ts (blocks → safe HTML + text) · identity.ts · records.ts · publications.ts · profile.ts · sanitize.ts 138 138 feed/ rss.ts · publication-feed.ts (full-content RSS, hand-rolled) 139 139 seo/ meta.ts (Open Graph + Twitter card tags) 140 140 editor/ edit-link.ts (dashboard → editor edit links)
+5
docs/decisions/0007-read-through-renderer.md
··· 39 39 - `records.getRecord` / `listRecords` — public `com.atproto.repo` XRPC (no auth). 40 40 41 41 ### Rendering 42 + 43 + > **Amended by Decision 0017 (2026-06-10):** callers no longer assemble this pipeline 44 + > themselves — `src/lib/reader/render-article.ts` owns the ordering behind one 45 + > interface. The pipeline itself is unchanged. 46 + 42 47 `fetch document → resolveBlobImageUrls → renderBlocks → sanitizeArticleHtml → inject`. 43 48 - **Images:** `resolveBlobImageUrls` rebuilds each blob-backed image's `getBlob` URL from 44 49 the article's DID + the stored CID (portable across PDS migrations, Decision 0006);
+2 -1
docs/decisions/0011-full-content-rss-feeds.md
··· 42 42 `buildPublicationFeedXml` is a pure transform fed by the same `listRecords` call the 43 43 publication page already makes (`listRecords` returns each document's full `content.blocks`, 44 44 so there is **no per-article round-trip**). Each item runs through the exact reader pipeline: 45 - `resolveBlobImageUrls` → `renderBlocks` → `sanitizeArticleHtml`. The feed body is therefore 45 + `resolveBlobImageUrls` → `renderBlocks` → `sanitizeArticleHtml` (since Decision 0017, owned 46 + by `src/lib/reader/render-article.ts`). The feed body is therefore 46 47 the same sanitised HTML a browser sees (AGENTS.md #6b satisfied by reuse), and the route's 47 48 single PDS fetch goes through `safeFetch` (AGENTS.md #6a satisfied by reuse). 48 49
+10
docs/decisions/0015-social-actions-on-posts.md
··· 61 61 there is no SkyPress server write endpoint. Rate-limiting / abuse is the PDS's concern. 62 62 - **Legacy documents without `bskyPostRef`** (pre–Decision 0013, or a failed third write) 63 63 render no action bar — there is no Bluesky thread to act on. 64 + - **Deleted companion post** (the `bskyPostRef` exists, but the post it points at was later 65 + deleted on Bluesky): the island confirms the post is live before showing anything, and 66 + renders **nothing** when it is gone — no buttons, note, or thread link, since none would 67 + work. The check is client-side and three-state: signed-in readers learn it from the 68 + authenticated `getPosts` (a `null` result ⇒ gone); signed-out readers from an 69 + unauthenticated `fetchPostExists` against the public AppView. It is **optimistic** — the 70 + bar shows by default and only hides on a *definitive* "gone", so a transient network error 71 + never hides a live post (fail open). `getPosts` can't distinguish *deleted* from 72 + *not-yet-indexed*, so a just-published post viewed before AppView indexing is briefly 73 + hidden too (self-heals on reload). 64 74 - **"Don't surprise users" (brief §10):** the UI states, in both signed-out and signed-in 65 75 states, that actions are public and happen on Bluesky. 66 76
+2 -1
docs/decisions/0016-read-context-module.md
··· 30 30 in parallel inside the module; the old pages ran them serially. 31 31 - The article render pipeline (`resolveBlobImageUrls → renderBlocks → 32 32 sanitizeArticleHtml`) deliberately stays in the article page / feed builder — sealing 33 - it behind its own interface is a separate, orthogonal deepening. 33 + it behind its own interface is a separate, orthogonal deepening. *(Since done: 34 + Decision 0017 sealed it behind `src/lib/reader/render-article.ts`.)* 34 35 - The shallow wrappers this obsoleted (`listReaderPublications`, 35 36 `resolveReaderPublication`) were deleted; `listAllReaderPublications` remains the one 36 37 reader-side publication fetch.
+44
docs/decisions/0017-known-provider-detection.md
··· 1 + # 0017 — Recognising the app behind a foreign publication 2 + 3 + - **Status:** Accepted 4 + - **Date:** 2026-06-10 5 + - **Scope:** foreign `site.standard.publication` records on both the authed dashboard 6 + ("From other apps") and the public profile ("Elsewhere") — `src/lib/publish/providers.ts`, 7 + the two `publications.ts` mappers, `ProviderLogo.{tsx,astro}`. 8 + 9 + ## Context 10 + 11 + `site.standard.publication` is a shared collection: other apps (Leaflet, pckt, Offprint, 12 + …) write their own records into a writer's repo. We list these as "foreign" and want to 13 + show the originating app's logo next to its hostname. The obvious signal — the 14 + publication's hostname — fails for the paid tiers of these services, which serve from a 15 + **custom domain** (`myblog.com`, not `*.leaflet.pub`). 16 + 17 + ## Decision 18 + 19 + `detectProvider( url, value )` recognises a provider in two steps, framework-agnostic 20 + (no JSX) so the React dashboard and the server-rendered Astro reader share one core: 21 + 22 + 1. **Record discriminator (primary).** App-specific namespaced `$type`s embedded in the 23 + record survive a custom domain. Observed in real records: pckt writes 24 + `theme.$type === "blog.pckt.theme"`. This is the only custom-domain-proof signal. 25 + 2. **Hostname suffix (fallback).** `*.leaflet.pub`, `*.pckt.blog`, `*.offprint.app`, 26 + matched at a dot boundary (`host === domain || host.endsWith('.' + domain)`) so 27 + `evil-leaflet.pub.com` and `notleaflet.pub` do **not** match. 28 + 29 + The logo glyphs live as monochrome (`currentColor`) inner-SVG `body` strings in 30 + `KNOWN_PROVIDERS`, rendered inside each framework's own fixed-size `<svg>` wrapper. 31 + 32 + ## Consequences 33 + 34 + - **Leaflet and Offprint on a custom domain are unrecognised** — their records carry no 35 + app-specific `$type` (verified against a real `*.leaflet.pub` record; Offprint 36 + unsampled), so they fall back to a bare hostname, exactly as before. Accepted for v1. 37 + If either later adds a discriminator, extend step 1. 38 + - Recognising a new provider = one `KNOWN_PROVIDERS` entry + (its discriminator and/or) 39 + one `PROVIDER_DOMAINS` line + a `detectProvider` test case. No renderer changes. 40 + - The glyph data is trusted constant content, so `ProviderLogo` injects it via 41 + `dangerouslySetInnerHTML` / `set:html` without sanitising — it is never PDS-derived, 42 + unlike article HTML (which still goes through the reader sanitiser, AGENTS.md rule 6). 43 + - Leaflet's brand asset was a raster PNG, so its glyph is a substitute vector feather 44 + (Lucide, ISC) rather than the official mark.
+47
docs/decisions/0017-render-article-module.md
··· 1 + # 0017 — The article render pipeline lives in one render-article module 2 + 3 + - **Status:** Accepted 4 + - **Date:** 2026-06-10 5 + - **Scope:** the article render pipeline (`src/lib/reader/render-article.ts`, the 6 + article page, `src/lib/feed/publication-feed.ts`) 7 + 8 + ## Context 9 + 10 + Turning a stored block tree into safe HTML takes three calls in one load-bearing 11 + order — `resolveBlobImageUrls` → `renderBlocks` → `sanitizeArticleHtml` — plus the 12 + plain-text fallback (`doc.textContent || blocksToText( blocks )`). The ordering is the 13 + AGENTS.md #6b invariant (sanitise is the LAST step before injection), but no module 14 + owned it: the article page and the feed builder each assembled it by hand. A third 15 + read surface (AMP, email, embeds) would have had to re-learn the order, and getting it 16 + wrong ships unsanitised PDS HTML. Decision 0016 sealed route *resolution* behind 17 + `read-context.ts` and explicitly deferred this orthogonal deepening. 18 + 19 + ## Decision 20 + 21 + One deep module, `src/lib/reader/render-article.ts`: 22 + 23 + ```ts 24 + renderArticle( doc, { pdsUrl, did } ) → { html, text } 25 + ``` 26 + 27 + - It accepts the document-value slice (`textContent` + `content.blocks`) rather than 28 + raw blocks, so the text fallback concentrates here too. `read-context.ts`'s 29 + `SkyDocumentValue` and the feed's `FeedDocumentValue` both satisfy the 30 + `RenderableDocument` parameter structurally — no adapter layer. 31 + - The pipeline-order invariant gains locality: it lives once, behaviourally tested in 32 + `render-article.test.ts` (a `<script>` in block content never survives; blob-backed 33 + image URLs are rebuilt against the author's current PDS before render). 34 + - Like `render.ts` underneath, the module is dependency-free — it must never import 35 + `@wordpress/*` (Decision 0003). It wraps `render.ts`, sitting beside it; render 36 + fidelity stays locked by `render.test.ts`, untouched. 37 + - The article page and `buildPublicationFeedXml` are now callers; 38 + `publication-feed.ts` stays a pure transform. New read surfaces get sanitisation for 39 + free by construction — AGENTS.md rule 6(b) now points here. 40 + 41 + ## Consequences 42 + 43 + - `resolveBlobImageUrls`, `renderBlocks`, and `sanitizeArticleHtml` keep their own 44 + unit suites; no page or transform should compose them by hand again 45 + (`_[rkey].meta.test.ts` pins the article page's delegation). 46 + - A surface needing different rendering rules (e.g. email's stricter tag set) extends 47 + this module's interface rather than re-assembling the pipeline at the call site.
+3
docs/superpowers/specs/2026-06-09-profile-elsewhere-section-design.md
··· 17 17 18 18 - **Section heading:** "Elsewhere". 19 19 - **Per-item content:** publication **name** only (plus its logo). No hostname. 20 + - **Superseded 2026-06-10** (`2026-06-10-provider-logos-design.md`): each row now also 21 + shows a hostname pill, with the originating service's logo before it when recognised 22 + (Decision 0017). `ReaderForeignPublication` gained `hostname` + `provider` for this. 20 23 - Render the section only when there is at least one foreign publication. 21 24 22 25 ## Data layer — `src/lib/reader/publications.ts`
+101
docs/superpowers/specs/2026-06-10-provider-logos-design.md
··· 1 + # Provider logos next to foreign-publication hostnames — design 2 + 3 + **Date:** 2026-06-10 4 + **Status:** Approved (ready for implementation) 5 + 6 + ## Goal 7 + 8 + When a "foreign" publication (a `site.standard.publication` record SkyPress doesn't 9 + own — written by Leaflet, pckt, Offprint, …) is shown, display the originating 10 + service's logo next to its hostname **if** we can recognise the service. 11 + 12 + Two interfaces, sharing one detection + logo core: 13 + 14 + - **Dashboard "From other apps"** (`src/components/Dashboard.tsx`) — logo before the 15 + hostname in the existing `.dash__pubhost` pill. 16 + - **Public profile "Elsewhere"** (`src/pages/[author]/index.astro`) — currently shows 17 + the publication name only; now also shows a hostname pill with the logo before it. 18 + (This reverses the "name only, no hostname" decision in 19 + `2026-06-09-profile-elsewhere-section-design.md`, which is updated accordingly.) 20 + 21 + ## Detecting the service 22 + 23 + A two-step strategy, because the hostname alone misses services on custom domains: 24 + 25 + 1. **Record discriminator (primary).** App-specific records carry namespaced `$type`s 26 + that survive a custom domain. Observed: pckt embeds `theme.$type === 27 + "blog.pckt.theme"`. Detect pckt this way regardless of hostname. 28 + 2. **Hostname suffix (fallback).** `*.leaflet.pub` → leaflet, `*.pckt.blog` → pckt, 29 + `*.offprint.app` → offprint. Suffix test is exact-or-dot-boundary 30 + (`host === domain || host.endsWith('.' + domain)`) to avoid `evil-leaflet.pub.com`. 31 + 32 + **Known limitation:** Leaflet records are fully standard (no app-specific `$type` — 33 + verified against a real `*.leaflet.pub` record) and Offprint is unsampled. So Leaflet 34 + and Offprint published on a **custom domain** can't be recognised and fall back to a 35 + bare hostname, exactly as today. Acceptable for v1. 36 + 37 + ## Shared core — `src/lib/publish/providers.ts` (new) 38 + 39 + Framework-agnostic, pure TS (no JSX), the reusable part both interfaces consume: 40 + 41 + ```ts 42 + export type ProviderId = 'leaflet' | 'pckt' | 'offprint'; 43 + export interface KnownProvider { id: ProviderId; label: string; viewBox: string; body: string; } 44 + export const KNOWN_PROVIDERS: Record<ProviderId, KnownProvider>; 45 + export function detectProvider( url: string, value: unknown ): ProviderId | null; 46 + ``` 47 + 48 + - `body` is the inner SVG markup (paths/etc.), self-contained and monochrome 49 + (`currentColor` — fill-based for pckt/offprint, `fill="none" stroke="currentColor"` 50 + for the leaflet feather). Rendered inside a fixed-size `<svg viewBox>` wrapper, so the 51 + source asset's dimensions don't matter, and both fill- and stroke-based glyphs work. 52 + - `label` (e.g. "Leaflet") is the SVG's accessible name (`aria-label`). 53 + 54 + ### Logo assets 55 + 56 + - **pckt, offprint** — extracted from the vector SVGs the user saved in `public/` 57 + (already single-colour). pckt's hardcoded `fill="black"` becomes `currentColor`. 58 + - **leaflet** — the saved `public/leaflet.svg` is a 147KB raster PNG wrapped in SVG, so 59 + there is no vector to recolour. Substitute a clean vector **feather** glyph 60 + (Leaflet's mark is a feather), monochrome, `currentColor`. 61 + - The now-inlined `public/{leaflet,pckt,offprint}.svg` files are removed. 62 + 63 + ## Data layer 64 + 65 + Both mappers call `detectProvider( url, value )` and store the result. 66 + 67 + - `ForeignPublication` (`src/lib/publish/publications.ts`) gains 68 + `provider: ProviderId | null`. 69 + - `ReaderForeignPublication` (`src/lib/reader/publications.ts`) gains 70 + `provider: ProviderId | null` **and** `hostname: string` (it has neither today; the 71 + profile page needs the hostname to display it). 72 + 73 + ## Renderers — share the data, one thin renderer per framework 74 + 75 + Astro can't server-render a React component, and the read path must not take a client 76 + island just for a logo. So the *data + detection* is shared; each framework gets a 77 + ~5-line SVG renderer reading from `KNOWN_PROVIDERS[ id ]`: 78 + 79 + - **`src/components/ProviderLogo.tsx`** (React) — used in `Dashboard.tsx`. Renders 80 + `null` when `provider` is null. Placed before the hostname text in `.dash__pubhost`. 81 + - **`src/components/ProviderLogo.astro`** — used in `[author]/index.astro` "Elsewhere" 82 + rows, which gain a hostname pill (logo + hostname) reusing the dashboard's pill look. 83 + 84 + ## Tests (TDD — failing test first) 85 + 86 + - `src/lib/publish/providers.test.ts` (new): `detectProvider` → 87 + - pckt via `theme.$type === 'blog.pckt.theme'` even on a non-pckt hostname; 88 + - leaflet/pckt/offprint via hostname suffix (exact + subdomain); 89 + - rejects look-alike hosts (`evil-leaflet.pub.com`, `notleaflet.pub`); 90 + - returns null for unknown hosts / unparseable urls; 91 + - `KNOWN_PROVIDERS` has an entry for every `ProviderId`. 92 + - `src/lib/publish/publications.test.ts`: `listAllPublications` sets `provider` on 93 + foreign entries (pckt by discriminator, leaflet by host, null when unknown). 94 + - `src/lib/reader/publications.test.ts`: `listAllReaderPublications` sets `provider` 95 + and `hostname` on foreign entries; owned-only regression still holds. 96 + 97 + ## Out of scope 98 + 99 + No lexicon changes, no curated-block changes, no reader-sanitiser changes. The read 100 + path still never imports `@wordpress/*`. Custom-domain Leaflet/Offprint detection is 101 + explicitly not solved.
+71
src/components/AppBar.test.tsx
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { createElement } from 'react'; 3 + import { renderToStaticMarkup } from 'react-dom/server'; 4 + import AppBar from './AppBar'; 5 + import { AuthContext, type AuthContextValue } from '../lib/auth/AuthProvider'; 6 + import type { AppBarContext } from '../lib/auth/nav'; 7 + 8 + /** Render the AppBar under a hand-rolled auth context (bypasses real OAuth). */ 9 + function renderBar( current: AppBarContext, auth: Partial< AuthContextValue > ): string { 10 + const value: AuthContextValue = { 11 + status: 'signed-out', 12 + agent: null, 13 + did: null, 14 + handle: null, 15 + displayName: null, 16 + avatar: null, 17 + pdsUrl: null, 18 + error: null, 19 + signIn: async () => {}, 20 + signOut: async () => {}, 21 + ...auth, 22 + }; 23 + return renderToStaticMarkup( 24 + createElement( AuthContext.Provider, { value }, createElement( AppBar, { current } ) ) 25 + ); 26 + } 27 + 28 + describe( 'AppBar contextual nav visibility', () => { 29 + it( 'hides the Publications link on the editor when signed out', () => { 30 + const markup = renderBar( 'editor', { status: 'signed-out' } ); 31 + expect( markup ).not.toContain( 'Publications' ); 32 + expect( markup ).not.toContain( 'href="/dashboard"' ); 33 + } ); 34 + 35 + it( 'hides the Write link on the dashboard when signed out', () => { 36 + const markup = renderBar( 'dashboard', { status: 'signed-out' } ); 37 + expect( markup ).not.toContain( 'Write' ); 38 + expect( markup ).not.toContain( 'href="/editor"' ); 39 + } ); 40 + 41 + it( 'hides the contextual nav while auth is still loading', () => { 42 + const markup = renderBar( 'editor', { status: 'loading' } ); 43 + expect( markup ).not.toContain( 'Publications' ); 44 + } ); 45 + 46 + it( 'shows the Publications link on the editor when signed in', () => { 47 + const markup = renderBar( 'editor', { 48 + status: 'signed-in', 49 + agent: {} as never, 50 + did: 'did:plc:writer', 51 + handle: 'writer.test', 52 + } ); 53 + expect( markup ).toContain( 'Publications' ); 54 + expect( markup ).toContain( 'href="/dashboard"' ); 55 + } ); 56 + 57 + it( 'shows the Write link on the dashboard when signed in', () => { 58 + const markup = renderBar( 'dashboard', { 59 + status: 'signed-in', 60 + agent: {} as never, 61 + did: 'did:plc:writer', 62 + handle: 'writer.test', 63 + } ); 64 + expect( markup ).toContain( 'Write' ); 65 + expect( markup ).toContain( 'href="/editor"' ); 66 + } ); 67 + 68 + it( 'always shows the SkyPress home link, regardless of auth state', () => { 69 + expect( renderBar( 'editor', { status: 'signed-out' } ) ).toContain( 'SkyPress home' ); 70 + } ); 71 + } );
+4 -3
src/components/AppBar.tsx
··· 44 44 /** 45 45 * The shared top bar for the editor + dashboard islands. Logo on the left; 46 46 * contextual nav + account + sign-out on the right. Rendered inside AuthProvider 47 - * in every auth state: logo-only while loading, + nav when signed out, + account 48 - * and sign-out when signed in. 47 + * in every auth state: logo-only while loading or signed out, + contextual nav, 48 + * account, and sign-out once signed in. The cross-link to Publications / Write is 49 + * gated on auth so signed-out visitors aren't pointed at editor-only routes. 49 50 */ 50 51 export default function AppBar( { current }: { current: AppBarContext } ) { 51 52 const { status, handle, displayName, avatar, did, signOut } = useAuth(); ··· 67 68 68 69 <span className="app-bar__spacer" /> 69 70 70 - { status !== 'loading' && ( 71 + { signedIn && ( 71 72 <a className="app-bar__nav" href={ nav.href }> 72 73 <NavIcon name={ nav.icon } /> 73 74 { nav.label }
+2 -2
src/components/CoverImagePicker.tsx
··· 84 84 { uploading ? 'Uploading…' : 'Upload cover image' } 85 85 </button> 86 86 <p className="studio__cover-hint"> 87 - No cover set — the first image in your article will be used. PNG, JPG, 88 - or GIF, max 1 MB. 87 + No cover set. If you don&apos;t add one, the first image in your 88 + article will be used. PNG, JPG, or GIF, max 1 MB. 89 89 </p> 90 90 </div> 91 91 ) }
+47 -33
src/components/Dashboard.tsx
··· 17 17 } from '../lib/publish/publisher'; 18 18 import { buildGetBlobUrl, type BlobRefJson } from '../lib/media/blob'; 19 19 import AppBar from './AppBar'; 20 + import ProviderLogo from './ProviderLogo'; 20 21 import { editLinkFor } from '../lib/editor/edit-link'; 21 22 22 23 type View = ··· 188 189 <section className="dash__section"> 189 190 <div className="dash__section-head"> 190 191 <h1 className="dash__h1">Your publications</h1> 191 - <button type="button" className="dash__new" onClick={ onNew }> 192 - + New publication 193 - </button> 194 192 </div> 195 193 196 194 { publications.length === 0 ? ( 197 - <p className="dash__empty"> 198 - You don't have any publications yet. Create one to start publishing. 199 - </p> 195 + <div className="dash__empty-state"> 196 + <p>You don't have any publications yet. Create one to start publishing.</p> 197 + <button type="button" className="dash__new" onClick={ onNew }> 198 + + New publication 199 + </button> 200 + </div> 200 201 ) : ( 201 - <ul className="dash__pubs"> 202 - { publications.map( ( pub ) => { 203 - return ( 204 - <li className="dash__pub" key={ pub.uri }> 205 - <PublicationLogo 206 - icon={ pub.icon } 207 - name={ pub.name } 208 - pdsUrl={ pdsUrl } 209 - did={ did } 210 - /> 211 - <span className="dash__pubtext"> 212 - <span className="dash__pubname">{ pub.name }</span> 213 - <span className="dash__pubslug">/{ pub.slug }</span> 214 - </span> 215 - <span className="dash__pubactions"> 216 - <a className="dash__link" href={ `/@${ handle }/${ pub.slug }` }> 217 - View 218 - </a> 219 - <button type="button" onClick={ () => onManage( pub ) }> 220 - Manage 221 - </button> 222 - </span> 223 - </li> 224 - ); 225 - } ) } 226 - </ul> 202 + <> 203 + <ul className="dash__pubs"> 204 + { publications.map( ( pub ) => { 205 + return ( 206 + <li className="dash__pub" key={ pub.uri }> 207 + <PublicationLogo 208 + icon={ pub.icon } 209 + name={ pub.name } 210 + pdsUrl={ pdsUrl } 211 + did={ did } 212 + /> 213 + <span className="dash__pubtext"> 214 + <span className="dash__pubname">{ pub.name }</span> 215 + <span className="dash__pubslug">/{ pub.slug }</span> 216 + </span> 217 + <span className="dash__pubactions"> 218 + <a className="dash__link" href={ `/@${ handle }/${ pub.slug }` }> 219 + View 220 + </a> 221 + <button type="button" onClick={ () => onManage( pub ) }> 222 + Manage 223 + </button> 224 + </span> 225 + </li> 226 + ); 227 + } ) } 228 + </ul> 229 + <div className="dash__add"> 230 + <button type="button" className="dash__new-quiet" onClick={ onNew }> 231 + + New publication 232 + </button> 233 + </div> 234 + </> 227 235 ) } 228 236 229 237 { foreign.length > 0 && ( ··· 244 252 /> 245 253 <span className="dash__pubtext"> 246 254 <span className="dash__pubname">{ pub.name }</span> 247 - <span className="dash__pubhost">{ pub.hostname }</span> 255 + <span className="dash__pubhost"> 256 + <ProviderLogo 257 + provider={ pub.provider } 258 + className="dash__pubhost-logo" 259 + /> 260 + { pub.hostname } 261 + </span> 248 262 </span> 249 263 <span className="dash__pubactions"> 250 264 <a
+118
src/components/PostActions.presence.test.tsx
··· 1 + import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 + import { act, createElement } from 'react'; 3 + import { createRoot } from 'react-dom/client'; 4 + import type { Agent } from '@atproto/api'; 5 + 6 + ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; 7 + 8 + // Mock the orchestration layer so we control whether the companion post "exists". 9 + const { fetchPostState, fetchPostExists } = vi.hoisted( () => ( { 10 + fetchPostState: vi.fn(), 11 + fetchPostExists: vi.fn(), 12 + } ) ); 13 + vi.mock( '../lib/social/interactions', () => ( { fetchPostState, fetchPostExists } ) ); 14 + 15 + import { ActionsGate } from './PostActions'; 16 + import { AuthContext, type AuthContextValue } from '../lib/auth/AuthProvider'; 17 + 18 + const PROPS = { postUri: 'at://did:plc:writer/app.bsky.feed.post/3kpost', postCid: 'bafypost' }; 19 + 20 + function authValue( overrides: Partial< AuthContextValue > ): AuthContextValue { 21 + return { 22 + status: 'signed-out', 23 + agent: null, 24 + did: null, 25 + handle: null, 26 + displayName: null, 27 + avatar: null, 28 + pdsUrl: null, 29 + error: null, 30 + signIn: async () => {}, 31 + signOut: async () => {}, 32 + ...overrides, 33 + }; 34 + } 35 + 36 + /** Mount ActionsGate under a hand-rolled auth context and flush its effects. */ 37 + async function mountGate( auth: Partial< AuthContextValue > ): Promise< { html: string; cleanup: () => void } > { 38 + const container = document.createElement( 'div' ); 39 + document.body.appendChild( container ); 40 + const root = createRoot( container ); 41 + await act( async () => { 42 + root.render( 43 + createElement( 44 + AuthContext.Provider, 45 + { value: authValue( auth ) }, 46 + createElement( ActionsGate, PROPS ) 47 + ) 48 + ); 49 + } ); 50 + return { 51 + html: container.innerHTML, 52 + cleanup: () => { 53 + act( () => { 54 + root.unmount(); 55 + } ); 56 + container.remove(); 57 + }, 58 + }; 59 + } 60 + 61 + beforeEach( () => { 62 + fetchPostState.mockReset(); 63 + fetchPostExists.mockReset(); 64 + } ); 65 + 66 + describe( 'PostActions presence gate', () => { 67 + it( 'renders nothing when the companion post is gone (signed in)', async () => { 68 + fetchPostState.mockResolvedValue( null ); // getPosts found no post 69 + const { html, cleanup } = await mountGate( { 70 + status: 'signed-in', 71 + agent: {} as Agent, 72 + did: 'did:plc:reader', 73 + handle: 'reader.test', 74 + } ); 75 + expect( html ).toBe( '' ); 76 + cleanup(); 77 + } ); 78 + 79 + it( 'renders nothing when the companion post is gone (signed out)', async () => { 80 + fetchPostExists.mockResolvedValue( false ); // public AppView says: deleted 81 + const { html, cleanup } = await mountGate( { status: 'signed-out' } ); 82 + expect( html ).toBe( '' ); 83 + cleanup(); 84 + } ); 85 + 86 + it( 'keeps the action bar when the post exists (signed in)', async () => { 87 + fetchPostState.mockResolvedValue( { 88 + likeCount: 0, 89 + repostCount: 0, 90 + replyCount: 0, 91 + viewerLikeUri: null, 92 + viewerRepostUri: null, 93 + } ); 94 + const { html, cleanup } = await mountGate( { 95 + status: 'signed-in', 96 + agent: {} as Agent, 97 + did: 'did:plc:reader', 98 + handle: 'reader.test', 99 + } ); 100 + expect( html ).toContain( 'Like' ); 101 + expect( html ).toContain( 'Reply' ); 102 + cleanup(); 103 + } ); 104 + 105 + it( 'keeps the sign-in prompt when the post exists (signed out)', async () => { 106 + fetchPostExists.mockResolvedValue( true ); 107 + const { html, cleanup } = await mountGate( { status: 'signed-out' } ); 108 + expect( html ).toContain( 'Sign in to react' ); 109 + cleanup(); 110 + } ); 111 + 112 + it( 'keeps the action bar when existence is undetermined (signed out, network error → fail open)', async () => { 113 + fetchPostExists.mockResolvedValue( null ); 114 + const { html, cleanup } = await mountGate( { status: 'signed-out' } ); 115 + expect( html ).toContain( 'Sign in to react' ); 116 + cleanup(); 117 + } ); 118 + } );
+37 -11
src/components/PostActions.tsx
··· 9 9 postReply, 10 10 postQuote, 11 11 fetchPostState, 12 + fetchPostExists, 12 13 type PostState, 13 14 } from '../lib/social/interactions'; 14 15 import { ··· 46 47 const subject: StrongRef = { uri: postUri, cid: postCid }; 47 48 48 49 const [ state, setState ] = useState< PostState | null >( null ); 50 + // Whether the companion Bluesky post still exists. Optimistic: we show the bar and only 51 + // hide it once a read confirms the post is gone — a transient error must never hide a 52 + // live post, and this keeps the common (post present) case flicker-free. 53 + const [ present, setPresent ] = useState( true ); 49 54 const [ busy, setBusy ] = useState< null | 'like' | 'repost' >( null ); 50 55 const [ composer, setComposer ] = useState< Composer >( null ); 51 56 const [ text, setText ] = useState( '' ); ··· 60 65 mounted.current = false; 61 66 }, [] ); 62 67 63 - // Load counts + viewer state once signed in (and refresh after each write). 68 + // Resolve the companion post once auth settles: signed-in readers get counts + viewer 69 + // state from an authenticated read (a `null` result means the post is gone); signed-out 70 + // readers get an unauthenticated existence check (only a definitive `false` hides the 71 + // bar — `true`/`null` keep it, so a transient error fails open). 64 72 useEffect( () => { 65 - if ( status !== 'signed-in' || ! agent ) { 66 - return; 73 + if ( status === 'loading' ) { 74 + return; // wait for auth before choosing the read path 67 75 } 68 76 let cancelled = false; 69 - fetchPostState( agent, postUri ) 70 - .then( ( next ) => ! cancelled && next && setState( next ) ) 71 - .catch( () => { 72 - /* counts are best-effort; the action buttons still work */ 73 - } ); 77 + if ( status === 'signed-in' && agent ) { 78 + fetchPostState( agent, postUri ) 79 + .then( ( next ) => { 80 + if ( cancelled ) { 81 + return; 82 + } 83 + if ( next ) { 84 + setState( next ); 85 + } else { 86 + setPresent( false ); // getPosts found no post → deleted/unindexed 87 + } 88 + } ) 89 + .catch( () => { 90 + /* counts are best-effort; fail open — the action buttons still work */ 91 + } ); 92 + } else { 93 + fetchPostExists( postUri ) 94 + .then( ( exists ) => ! cancelled && exists === false && setPresent( false ) ) 95 + .catch( () => {} ); 96 + } 74 97 return () => { 75 98 cancelled = true; 76 99 }; ··· 158 181 159 182 if ( status === 'loading' ) { 160 183 return <div className="post-actions post-actions--loading">Loading actions…</div>; 184 + } 185 + 186 + // The companion post is gone — there's no live Bluesky thread to act on, so render 187 + // nothing rather than buttons / a sign-in prompt / a dead thread link that won't work. 188 + if ( ! present ) { 189 + return null; 161 190 } 162 191 163 192 const threadUrl = bskyPostWebUrl( postUri ); ··· 303 332 <p className="post-actions__note"> 304 333 Likes, reposts, quotes, and replies are public and happen on Bluesky. 305 334 </p> 306 - <a className="post-actions__thread" href={ threadUrl } target="_blank" rel="noopener noreferrer"> 307 - View the full thread on Bluesky 308 - </a> 309 335 </div> 310 336 ); 311 337 }
+29
src/components/ProviderLogo.astro
··· 1 + --- 2 + import { KNOWN_PROVIDERS, type ProviderId } from '../lib/publish/providers'; 3 + 4 + interface Props { 5 + provider: ProviderId | null; 6 + size?: number; 7 + class?: string; 8 + } 9 + 10 + const { provider, size = 14, class: className } = Astro.props; 11 + 12 + // Glyph + label data is shared with the React `ProviderLogo.tsx` via lib/publish/providers. 13 + // `body` is a hardcoded registry constant (never user/PDS content), so `set:html` is trusted. 14 + const logo = provider ? KNOWN_PROVIDERS[ provider ] : null; 15 + --- 16 + 17 + { 18 + logo && ( 19 + <svg 20 + class={className} 21 + width={size} 22 + height={size} 23 + viewBox={logo.viewBox} 24 + role="img" 25 + aria-label={logo.label} 26 + set:html={logo.body} 27 + /> 28 + ) 29 + }
+18
src/components/ProviderLogo.test.tsx
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { createElement } from 'react'; 3 + import { renderToStaticMarkup } from 'react-dom/server'; 4 + import ProviderLogo from './ProviderLogo'; 5 + 6 + describe( 'ProviderLogo', () => { 7 + it( 'renders nothing when the provider is null', () => { 8 + expect( renderToStaticMarkup( createElement( ProviderLogo, { provider: null } ) ) ).toBe( '' ); 9 + } ); 10 + 11 + it( "renders the provider's labelled svg glyph", () => { 12 + const html = renderToStaticMarkup( createElement( ProviderLogo, { provider: 'leaflet' } ) ); 13 + expect( html ).toContain( '<svg' ); 14 + expect( html ).toContain( 'aria-label="Leaflet"' ); 15 + expect( html ).toContain( 'role="img"' ); 16 + expect( html ).toContain( 'currentColor' ); 17 + } ); 18 + } );
+37
src/components/ProviderLogo.tsx
··· 1 + import { KNOWN_PROVIDERS, type ProviderId } from '../lib/publish/providers'; 2 + 3 + /** 4 + * A small monochrome logo for a recognised foreign-publication provider (Leaflet, pckt, 5 + * …). Renders nothing for an unrecognised provider, so callers can pass `provider` 6 + * straight through. The glyph inherits `currentColor`; size it with the `size` prop. 7 + * 8 + * Shares its glyph + label data with the reader's `ProviderLogo.astro` via 9 + * `lib/publish/providers` — the detection/asset core lives there, not in either renderer. 10 + */ 11 + export default function ProviderLogo( { 12 + provider, 13 + size = 14, 14 + className, 15 + }: { 16 + provider: ProviderId | null; 17 + size?: number; 18 + className?: string; 19 + } ) { 20 + if ( ! provider ) { 21 + return null; 22 + } 23 + // `body` is a hardcoded constant from our own registry (never user/PDS content), and 24 + // `provider` is constrained to the ProviderId union — so this innerHTML is trusted. 25 + const { label, viewBox, body } = KNOWN_PROVIDERS[ provider ]; 26 + return ( 27 + <svg 28 + className={ className } 29 + width={ size } 30 + height={ size } 31 + viewBox={ viewBox } 32 + role="img" 33 + aria-label={ label } 34 + dangerouslySetInnerHTML={ { __html: body } } 35 + /> 36 + ); 37 + }
+23 -2
src/components/Studio.tsx
··· 42 42 const [ publications, setPublications ] = useState< Publication[] | null >( null ); 43 43 // Shared between mediaUpload (writes blob refs) and publish (reads them). 44 44 const registry = useRef< BlobRegistry >( new Map() ).current; 45 + const titleRef = useRef< HTMLTextAreaElement >( null ); 45 46 const ledeRef = useRef< HTMLTextAreaElement >( null ); 46 47 47 48 // Load the writer's SkyPress publications (the publish targets / selector). ··· 113 114 114 115 // Release the preview object URLs this session minted when the Studio unmounts. 115 116 useEffect( () => () => revokeBlobRegistry( registry ), [ registry ] ); 117 + 118 + // Grow the title textarea to fit its content so long titles wrap into view 119 + // instead of clipping on one line (esp. on narrow mobile viewports). Layout 120 + // effect so it sizes before paint — same reasoning as the lede below. 121 + useLayoutEffect( () => { 122 + const el = titleRef.current; 123 + if ( ! el ) { 124 + return; 125 + } 126 + el.style.height = 'auto'; 127 + el.style.height = `${ el.scrollHeight }px`; 128 + }, [ title ] ); 116 129 117 130 // Grow the lede textarea to fit its content (and on hydrate from an edit-load). 118 131 // Layout effect so it sizes before paint — avoids a one-row collapse flash when ··· 229 242 } 230 243 } } 231 244 /> 232 - <input 245 + <textarea 246 + ref={ titleRef } 233 247 className="studio__title" 234 - type="text" 248 + rows={ 1 } 235 249 placeholder="Add title" 236 250 aria-label="Article title" 237 251 value={ title } 252 + // The title is a single-line string: let it wrap visually, but 253 + // don't let Enter insert a literal newline into the stored value. 254 + onKeyDown={ ( event ) => { 255 + if ( event.key === 'Enter' ) { 256 + event.preventDefault(); 257 + } 258 + } } 238 259 onChange={ ( event ) => { 239 260 setPublished( null ); 240 261 setTitle( event.target.value );
+8 -14
src/lib/feed/publication-feed.ts
··· 1 1 /** 2 2 * Assemble a publication's full-content RSS feed (Decision 0011). 3 3 * 4 - * Pure transform — no network. It reuses the EXACT reader pipeline the article page uses 5 - * (`resolveBlobImageUrls` → `renderBlocks` → `sanitizeArticleHtml`), so the feed body is 6 - * the same sanitised HTML a browser would see (AGENTS.md #6b), then hands off to the 7 - * test-locked `buildRssFeed`. The feed route fetches the records (SSRF-guarded) and calls 8 - * this; keeping the transform pure makes the sort/cap/filter rules unit-testable. 4 + * Pure transform — no network. It renders each item through `renderArticle` — the same 5 + * deep module the article page uses — so the feed body is the same sanitised HTML a 6 + * browser would see (AGENTS.md #6b), then hands off to the test-locked `buildRssFeed`. 7 + * The feed route fetches the records (SSRF-guarded) and calls this; keeping the 8 + * transform pure makes the sort/cap/filter rules unit-testable. 9 9 */ 10 - import { renderBlocks, blocksToText, type BlockNode } from '../blocks/render'; 11 - import { sanitizeArticleHtml } from '../reader/sanitize'; 12 - import { resolveBlobImageUrls } from '../media/blob'; 10 + import type { BlockNode } from '../blocks/render'; 11 + import { renderArticle } from '../reader/render-article'; 13 12 import { canonicalArticleUrl, publicationHomeUrl } from '../publish/records'; 14 13 import { buildRssFeed, type FeedChannel, type FeedItem } from './rss'; 15 14 import type { RepoRecord } from '../reader/records'; ··· 59 58 .slice( 0, FEED_ITEM_LIMIT ) 60 59 .map( ( record ) => { 61 60 const rkey = record.uri.split( '/' ).pop() as string; 62 - const blocks = resolveBlobImageUrls( record.value.content?.blocks ?? [], { 63 - pdsUrl, 64 - did, 65 - } ); 66 - const contentHtml = sanitizeArticleHtml( renderBlocks( blocks ) ); 67 - const text = record.value.textContent || blocksToText( blocks ); 61 + const { html: contentHtml, text } = renderArticle( record.value, { pdsUrl, did } ); 68 62 const description = 69 63 record.value.description?.trim() || 70 64 text.slice( 0, DESCRIPTION_FALLBACK_CHARS ).trim() ||
+48
src/lib/publish/providers.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { detectProvider, KNOWN_PROVIDERS, type ProviderId } from './providers'; 3 + 4 + describe( 'detectProvider', () => { 5 + it( 'detects pckt by the blog.pckt.theme discriminator, even on a custom domain', () => { 6 + expect( 7 + detectProvider( 'https://my-custom-domain.example', { 8 + theme: { $type: 'blog.pckt.theme' }, 9 + } ) 10 + ).toBe( 'pckt' ); 11 + } ); 12 + 13 + it( 'detects each provider by its default hostname', () => { 14 + expect( detectProvider( 'https://jehervecom.leaflet.pub', {} ) ).toBe( 'leaflet' ); 15 + expect( detectProvider( 'https://check-whats-in-my-pckt.pckt.blog', {} ) ).toBe( 'pckt' ); 16 + expect( detectProvider( 'https://news.offprint.app/', {} ) ).toBe( 'offprint' ); 17 + } ); 18 + 19 + it( 'matches the bare provider domain as well as subdomains', () => { 20 + expect( detectProvider( 'https://leaflet.pub', {} ) ).toBe( 'leaflet' ); 21 + expect( detectProvider( 'https://offprint.app/x', {} ) ).toBe( 'offprint' ); 22 + } ); 23 + 24 + it( 'rejects look-alike hostnames', () => { 25 + expect( detectProvider( 'https://evil-leaflet.pub.com', {} ) ).toBeNull(); 26 + expect( detectProvider( 'https://notleaflet.pub', {} ) ).toBeNull(); 27 + expect( detectProvider( 'https://offprint.app.evil.com', {} ) ).toBeNull(); 28 + } ); 29 + 30 + it( 'returns null for unknown hosts and unparseable urls', () => { 31 + expect( detectProvider( 'https://example.com', {} ) ).toBeNull(); 32 + expect( detectProvider( 'not a url', {} ) ).toBeNull(); 33 + expect( detectProvider( '', undefined ) ).toBeNull(); 34 + } ); 35 + } ); 36 + 37 + describe( 'KNOWN_PROVIDERS', () => { 38 + it( 'has a complete, renderable entry for every provider id', () => { 39 + const ids: ProviderId[] = [ 'leaflet', 'pckt', 'offprint' ]; 40 + for ( const id of ids ) { 41 + const provider = KNOWN_PROVIDERS[ id ]; 42 + expect( provider.id ).toBe( id ); 43 + expect( provider.label.length ).toBeGreaterThan( 0 ); 44 + expect( provider.viewBox ).toMatch( /^\d+ \d+ \d+ \d+$/ ); 45 + expect( provider.body ).toContain( 'currentColor' ); 46 + } 47 + } ); 48 + } );
+94
src/lib/publish/providers.ts
··· 1 + /** 2 + * Recognising the third-party app that wrote a foreign `site.standard.publication` 3 + * record (Leaflet, pckt, Offprint, …) so the UI can show its logo next to the hostname. 4 + * 5 + * Detection is two-step: an app-specific `$type` discriminator embedded in the record 6 + * (survives a custom domain) first, then a hostname-suffix fallback. Leaflet records are 7 + * fully standard and Offprint is unsampled, so those two are only recognised on their 8 + * default domains — a custom-domain Leaflet/Offprint falls back to a bare hostname. 9 + * 10 + * Framework-agnostic on purpose (no JSX): the React dashboard and the server-rendered 11 + * Astro reader both render `KNOWN_PROVIDERS[ id ].body` inside their own tiny `<svg>`. 12 + */ 13 + 14 + export type ProviderId = 'leaflet' | 'pckt' | 'offprint'; 15 + 16 + export interface KnownProvider { 17 + id: ProviderId; 18 + /** Accessible name for the logo (e.g. "Leaflet"). */ 19 + label: string; 20 + viewBox: string; 21 + /** Inner SVG markup, self-contained and monochrome (`currentColor`). */ 22 + body: string; 23 + } 24 + 25 + export const KNOWN_PROVIDERS: Record< ProviderId, KnownProvider > = { 26 + // Leaflet's saved asset is a raster PNG, so we use a clean vector feather (Lucide, 27 + // ISC) — Leaflet's mark is a feather — stroked in currentColor. 28 + leaflet: { 29 + id: 'leaflet', 30 + label: 'Leaflet', 31 + viewBox: '0 0 24 24', 32 + body: 33 + '<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + 34 + '<path d="M20.24 12.24a6 6 0 0 0-8.49-8.49L5 10.5V19h8.5z"/>' + 35 + '<path d="M16 8 2 22"/>' + 36 + '<path d="M17.5 15H9"/>' + 37 + '</g>', 38 + }, 39 + pckt: { 40 + id: 'pckt', 41 + label: 'pckt', 42 + viewBox: '0 0 93 107', 43 + body: 44 + '<g fill="currentColor">' + 45 + '<path d="M43 28.4316C49.0182 28.4316 50.9999 32.9208 51 40.4316C51 47.9427 49.0183 52.4316 43 52.4316C36.9817 52.4316 35 47.9427 35 40.4316C35.0001 32.9208 36.9818 28.4316 43 28.4316Z"/>' + 46 + '<path fill-rule="evenodd" clip-rule="evenodd" d="M51.3115 0C61.4858 0 70.1235 3.41104 76.0547 10.7363C76.6352 11.4533 77.1811 12.2001 77.6963 12.9736C80.0589 14.5183 82.1905 16.4339 84.0547 18.7363C89.8115 25.8465 92.3203 35.7949 92.3203 47.5361C92.3203 56.8683 90.7323 65.0802 87.1973 71.6416C87.555 71.8872 87.8993 72.1614 88.2275 72.4639C89.7962 73.9093 90.7073 75.7198 91.2803 77.3252L91.3135 77.4189L91.3447 77.5146C92.0855 79.8314 92.5794 83.2003 90.6436 86.3193C90.2445 86.9622 89.7874 87.5179 89.2939 88C89.3984 88.6569 89.4353 89.3495 89.3809 90.0762C89.105 93.7548 86.7034 96.2192 84.6309 97.6953L84.627 97.6982C83.0852 98.7944 80.886 99.9999 78.124 100C76.524 99.9999 75.153 99.6078 73.9863 98.9902C72.8052 99.6106 71.4261 100 69.832 100C67.1912 99.9998 64.9313 98.7943 63.3896 97.6982L63.3193 97.6475C61.6106 96.3947 59.5544 94.3932 58.8428 91.4941C57.4844 91.0415 56.3089 90.3518 55.3896 89.6982L55.3193 89.6475C54.2639 88.8736 53.0762 87.8138 52.1543 86.4336C51.3191 86.2498 50.4993 86.0361 49.6963 85.79V96.6885C49.6962 98.8934 49.139 101.688 46.9102 103.862C44.7096 106.009 41.9236 106.528 39.7275 106.528H17.8398C15.6351 106.528 12.8798 105.972 10.7178 103.811C9.09088 102.184 8.37361 100.221 8.11816 98.4092C6.30699 98.1536 4.3444 97.4371 2.71777 95.8105C0.555853 93.6486 7.94166e-05 90.8933 0 88.6885V11.1201C0 8.91525 0.555694 6.15915 2.71777 3.99707C4.87975 1.83534 7.63512 1.2803 9.83984 1.28027H31.7275C33.5137 1.28027 35.6607 1.62338 37.5752 2.86719C41.6669 0.976593 46.2905 6.17063e-05 51.3115 0ZM51.3115 6C45.0398 6.00009 39.7922 7.79232 35.6963 10.9922C35.6963 8.56019 34.4155 7.28027 31.7275 7.28027H9.83984C7.27995 7.28033 6 8.56017 6 11.1201V88.6885C6.00016 91.2481 7.28011 92.5283 9.83984 92.5283H31.7275C34.4154 92.5283 35.6961 91.2482 35.6963 88.6885V68.0801C39.8495 71.2264 44.9925 73.1332 51.0039 73.1963C51.036 72.2441 51.2486 71.3016 51.5518 70.4229C51.9966 69.03 52.6684 67.6698 53.7695 66.6406C54.978 65.5113 56.4604 65.0049 58.0195 65.0049C58.2332 65.0049 58.4479 65.0174 58.6631 65.0381C58.8865 64.059 59.3111 63.1152 60.0332 62.2871C61.593 60.4986 63.8728 60 66.0088 60C68.1185 60 70.4052 60.5007 71.957 62.2988C72.1747 62.5511 72.3616 62.816 72.5264 63.0869C76.3427 57.4793 78.3203 49.5894 78.3203 39.5361C78.3203 17.5201 68.8475 6 51.3115 6ZM66.0088 63C62.4983 63 61.1066 64.6691 61.5303 68.0654L61.7715 69.3164L60.6221 68.7207C59.6538 68.2441 58.7458 68.0059 58.0195 68.0059C56.2643 68.0059 55.114 69.0785 54.3877 71.4023C53.2985 74.5601 54.5092 76.4071 57.959 77.0029L59.2295 77.1816L58.3223 78.1943C55.8408 80.5777 56.0219 82.7232 58.8662 84.8086C59.9555 85.5831 60.9848 85.9999 61.832 86C63.224 86 64.435 85.0471 65.4033 83.2002L66.0088 82.0674L66.6143 83.2002C67.5825 85.0469 68.7322 85.9999 70.124 86C71.0924 86 72.061 85.5832 73.1504 84.8086C75.9951 82.7827 76.1163 80.5778 73.6953 78.1348L72.8477 77.1816L74.0586 77.0029C77.5687 76.4071 78.6585 74.56 77.6299 71.3428C76.8431 69.1382 75.6927 68.0655 74.0586 68.0654C73.2718 68.0654 72.4244 68.3036 71.3955 68.7803L70.1846 69.3164L70.3662 68.0654C70.9109 64.6691 69.4586 63.0001 66.0088 63Z"/>' + 47 + '</g>', 48 + }, 49 + offprint: { 50 + id: 'offprint', 51 + label: 'Offprint', 52 + viewBox: '0 0 24 24', 53 + body: 54 + '<path fill="currentColor" d="M5.39061 11.8098C5.53372 13.0032 6.05847 15.4376 7.25109 17.4901C7.3942 17.7765 7.77584 17.7288 7.87125 17.4901L14.7407 4.8884C14.8838 4.64973 14.8361 4.3156 14.5976 4.12466C13.5958 3.26545 12.3078 2.74038 10.7812 2.78811C5.2952 2.93131 5.2475 9.85272 5.39061 11.8098ZM12.9279 21.1656C18.2708 21.0224 18.4616 14.1488 18.2708 12.2394C18.1754 10.9983 17.6507 8.51617 16.4104 6.36815C16.3149 6.17722 15.981 6.17722 15.8856 6.36815L8.96845 19.0654C8.87304 19.3518 8.92075 19.6382 9.11156 19.8291C10.1134 20.6883 11.4014 21.2134 12.9279 21.1656ZM18.7479 1.11743L18.4616 1.6425C18.2708 1.97664 18.3662 2.35851 18.6525 2.50171C22.4688 4.93614 23.7091 9.13671 23.7091 11.9053C23.7091 15.3421 20.8469 23.3614 12.1169 23.3614C10.3996 23.3614 8.92075 23.1227 7.58502 22.6931C7.3465 22.5977 7.01256 22.6931 6.86945 22.9318L6.48781 23.6955C6.3447 23.9819 6.01077 24.0774 5.72454 23.9342L5.19979 23.6478C4.91357 23.5046 4.81816 23.1705 4.96127 22.8841L5.2952 22.2635C5.43831 21.9771 5.34291 21.643 5.10438 21.4998C1.28802 19.0176 0 14.7693 0 12.0962C0 8.65938 2.62375 0.640089 11.4014 0.640089C13.2142 0.640089 14.7407 0.878758 16.0764 1.30836C16.3626 1.40383 16.6966 1.26063 16.8397 1.02196L17.269 0.258218C17.4121 0.0195488 17.7461 -0.0759191 17.9846 0.0672834L18.5571 0.353686C18.7956 0.496888 18.891 0.831025 18.7479 1.11743Z"/>', 55 + }, 56 + }; 57 + 58 + /** Provider domains for the hostname-suffix fallback. */ 59 + const PROVIDER_DOMAINS: Record< string, ProviderId > = { 60 + 'leaflet.pub': 'leaflet', 61 + 'pckt.blog': 'pckt', 62 + 'offprint.app': 'offprint', 63 + }; 64 + 65 + /** True when `host` is exactly `domain` or a subdomain of it (dot-boundary, no look-alikes). */ 66 + function hostMatches( host: string, domain: string ): boolean { 67 + return host === domain || host.endsWith( '.' + domain ); 68 + } 69 + 70 + /** 71 + * Recognise the originating app for a foreign publication, or `null` if unknown. 72 + * `value` is the raw record value; we inspect its embedded `$type` discriminators. 73 + */ 74 + export function detectProvider( url: string, value: unknown ): ProviderId | null { 75 + // Primary: an app-specific theme discriminator survives even on a custom domain. 76 + const themeType = ( value as { theme?: { $type?: unknown } } | undefined )?.theme?.$type; 77 + if ( themeType === 'blog.pckt.theme' ) { 78 + return 'pckt'; 79 + } 80 + 81 + // Fallback: the publication's default hostname. 82 + let host: string; 83 + try { 84 + host = new URL( url ).hostname; 85 + } catch { 86 + return null; 87 + } 88 + for ( const [ domain, id ] of Object.entries( PROVIDER_DOMAINS ) ) { 89 + if ( hostMatches( host, domain ) ) { 90 + return id; 91 + } 92 + } 93 + return null; 94 + }
+13
src/lib/publish/publications.test.ts
··· 156 156 } ); 157 157 } ); 158 158 159 + it( 'tags each foreign publication with its detected provider', async () => { 160 + const { agent } = mockAgent( { 161 + 'site.standard.publication': [ 162 + pubRecord( 'a', 'https://jehervecom.leaflet.pub' ), 163 + pubRecord( 'b', 'https://my-domain.example', { theme: { $type: 'blog.pckt.theme' } } ), 164 + pubRecord( 'c', 'https://unknown.example' ), 165 + ], 166 + } ); 167 + const { foreign } = await listAllPublications( agent, DID ); 168 + const byRkey = Object.fromEntries( foreign.map( ( p ) => [ p.uri.split( '/' ).pop(), p.provider ] ) ); 169 + expect( byRkey ).toEqual( { a: 'leaflet', b: 'pckt', c: null } ); 170 + } ); 171 + 159 172 it( 'drops a slugless SkyPress-origin record from BOTH buckets', async () => { 160 173 const { agent } = mockAgent( { 161 174 'site.standard.publication': [ pubRecord( 'c', `${ SITE_BASE }/@me.bsky.social` ) ],
+4
src/lib/publish/publications.ts
··· 17 17 } from './records'; 18 18 import { normalizeBlobRefJson, type BlobRefJson } from '../media/blob'; 19 19 import { parseBasicTheme, type BasicTheme } from './themes'; 20 + import { detectProvider, type ProviderId } from './providers'; 20 21 21 22 const PUBLICATION_COLLECTION = 'site.standard.publication'; 22 23 const DOCUMENT_COLLECTION = 'site.standard.document'; ··· 52 53 hostname: string; 53 54 url: string; 54 55 icon?: BlobRefJson; 56 + /** The app that wrote the record (Leaflet, pckt, …), or null when unrecognised. */ 57 + provider: ProviderId | null; 55 58 } 56 59 57 60 /** Fields a writer can set/change on a publication (the slug is derived, never entered). */ ··· 116 119 name: value.name ?? parsed.hostname, 117 120 hostname: parsed.hostname, 118 121 url: value.url, 122 + provider: detectProvider( value.url, value ), 119 123 ...( icon ? { icon } : {} ), 120 124 }; 121 125 }
+19
src/lib/reader/publications.test.ts
··· 50 50 expect( foreign[ 0 ] ).toEqual( { 51 51 uri: `at://${ DID }/site.standard.publication/b`, 52 52 name: 'My Leaflet', 53 + hostname: 'leaflet.pub', 53 54 url: 'https://leaflet.pub/lish/did:plc:me/xyz', 54 55 icon, 56 + provider: 'leaflet', 57 + } ); 58 + } ); 59 + 60 + it( 'tags each foreign publication with its hostname and detected provider', async () => { 61 + mockedList.mockResolvedValue( [ 62 + rec( 'a', 'https://jehervecom.leaflet.pub' ), 63 + rec( 'b', 'https://my-domain.example', { theme: { $type: 'blog.pckt.theme' } } ), 64 + rec( 'c', 'https://unknown.example/path' ), 65 + ] ); 66 + const { foreign } = await listAllReaderPublications( PDS, DID ); 67 + const byRkey = Object.fromEntries( 68 + foreign.map( ( p ) => [ p.uri.split( '/' ).pop(), { hostname: p.hostname, provider: p.provider } ] ) 69 + ); 70 + expect( byRkey ).toEqual( { 71 + a: { hostname: 'jehervecom.leaflet.pub', provider: 'leaflet' }, 72 + b: { hostname: 'my-domain.example', provider: 'pckt' }, 73 + c: { hostname: 'unknown.example', provider: null }, 55 74 } ); 56 75 } ); 57 76
+6
src/lib/reader/publications.ts
··· 11 11 isSkyPressPublicationUrl, 12 12 publicationSlugFromUrl, 13 13 } from '../publish/records'; 14 + import { detectProvider, type ProviderId } from '../publish/providers'; 14 15 import type { BlobRefJson } from '../media/blob'; 15 16 import { parseBasicTheme, type BasicTheme } from '../publish/themes'; 16 17 ··· 27 28 export interface ReaderForeignPublication { 28 29 uri: string; 29 30 name: string; 31 + hostname: string; 30 32 url: string; 31 33 icon: BlobRefJson | null; 34 + /** The app that wrote the record (Leaflet, pckt, …), or null when unrecognised. */ 35 + provider: ProviderId | null; 32 36 } 33 37 34 38 interface RawPublication { ··· 78 82 return { 79 83 uri: record.uri, 80 84 name: value.name ?? parsed.hostname, 85 + hostname: parsed.hostname, 81 86 url: value.url, 82 87 icon: value.icon ?? null, 88 + provider: detectProvider( value.url, value ), 83 89 }; 84 90 } 85 91
+124
src/lib/reader/render-article.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { renderArticle } from './render-article'; 3 + 4 + const AUTHOR = { pdsUrl: 'https://pds.example', did: 'did:plc:alice' }; 5 + 6 + describe( 'renderArticle', () => { 7 + it( 'renders block content to HTML', () => { 8 + const { html } = renderArticle( 9 + { 10 + content: { 11 + blocks: [ 12 + { name: 'core/heading', attributes: { level: 2, content: 'Section' } }, 13 + { name: 'core/paragraph', attributes: { content: 'A paragraph.' } }, 14 + ], 15 + }, 16 + }, 17 + AUTHOR 18 + ); 19 + expect( html ).toContain( '<h2 class="wp-block-heading">Section</h2>' ); 20 + expect( html ).toContain( '<p>A paragraph.</p>' ); 21 + } ); 22 + 23 + it( 'never lets a script in block content survive (sanitise is the last step)', () => { 24 + const { html } = renderArticle( 25 + { 26 + content: { 27 + blocks: [ 28 + { 29 + name: 'core/paragraph', 30 + attributes: { content: 'Hello <script>alert(1)</script>world' }, 31 + }, 32 + ], 33 + }, 34 + }, 35 + AUTHOR 36 + ); 37 + expect( html ).not.toContain( '<script' ); 38 + expect( html ).not.toContain( 'alert(1)' ); 39 + expect( html ).toContain( 'Hello' ); 40 + } ); 41 + 42 + it( 'strips event-handler attributes smuggled through rich text', () => { 43 + const { html } = renderArticle( 44 + { 45 + content: { 46 + blocks: [ 47 + { 48 + name: 'core/paragraph', 49 + attributes: { content: '<em onmouseover="steal()">hover me</em>' }, 50 + }, 51 + ], 52 + }, 53 + }, 54 + AUTHOR 55 + ); 56 + expect( html ).toContain( '<em>hover me</em>' ); 57 + expect( html ).not.toContain( 'onmouseover' ); 58 + } ); 59 + 60 + it( 'rewrites blob-backed image URLs to the author PDS before rendering', () => { 61 + const { html } = renderArticle( 62 + { 63 + content: { 64 + blocks: [ 65 + { 66 + name: 'core/image', 67 + attributes: { 68 + url: 'https://stale-pds.example/old-blob.png', 69 + alt: 'A photo', 70 + skypressBlob: { 71 + $type: 'blob', 72 + ref: { $link: 'bafyreidcid' }, 73 + mimeType: 'image/png', 74 + size: 1234, 75 + }, 76 + }, 77 + }, 78 + ], 79 + }, 80 + }, 81 + AUTHOR 82 + ); 83 + // `&amp;` (not `&`) — the URL came out the far side of the sanitiser. 84 + expect( html ).toContain( 85 + 'src="https://pds.example/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aalice&amp;cid=bafyreidcid"' 86 + ); 87 + expect( html ).not.toContain( 'stale-pds.example' ); 88 + } ); 89 + 90 + it( 'prefers the stored textContent for the plain-text view', () => { 91 + const { text } = renderArticle( 92 + { 93 + textContent: 'Stored summary.', 94 + content: { 95 + blocks: [ { name: 'core/paragraph', attributes: { content: 'Body.' } } ], 96 + }, 97 + }, 98 + AUTHOR 99 + ); 100 + expect( text ).toBe( 'Stored summary.' ); 101 + } ); 102 + 103 + it( 'falls back to the block text when textContent is absent or empty', () => { 104 + const { text } = renderArticle( 105 + { 106 + textContent: '', 107 + content: { 108 + blocks: [ 109 + { name: 'core/heading', attributes: { level: 2, content: 'Section' } }, 110 + { name: 'core/paragraph', attributes: { content: 'Body text.' } }, 111 + ], 112 + }, 113 + }, 114 + AUTHOR 115 + ); 116 + expect( text ).toBe( 'Section\n\nBody text.' ); 117 + } ); 118 + 119 + it( 'returns empty html and text for a document with no blocks', () => { 120 + const { html, text } = renderArticle( {}, AUTHOR ); 121 + expect( html ).toBe( '' ); 122 + expect( text ).toBe( '' ); 123 + } ); 124 + } );
+46
src/lib/reader/render-article.ts
··· 1 + /** 2 + * The render-article module: one deep interface for turning a stored document 3 + * into safe HTML + plain text (Decision 0017). 4 + * 5 + * Turning a block tree into injectable HTML requires three calls in one 6 + * load-bearing order — `resolveBlobImageUrls` → `renderBlocks` → 7 + * `sanitizeArticleHtml` — and the text fallback (`textContent || blocksToText`) 8 + * alongside it. That ordering is the AGENTS.md #6b invariant (sanitise is the 9 + * LAST step before any injection); it lives here, once, so every read surface 10 + * (article page, RSS, future AMP/email/embeds) gets it by construction instead 11 + * of by convention. Like `render.ts` underneath, this module is dependency-free 12 + * and must never import `@wordpress/*` (Decision 0003). 13 + */ 14 + import { renderBlocks, blocksToText } from '../blocks/render'; 15 + import { resolveBlobImageUrls } from '../media/blob'; 16 + import { sanitizeArticleHtml } from './sanitize'; 17 + import type { BlockNode } from '../blocks/render'; 18 + 19 + /** The slice of a `site.standard.document` value the renderer consumes. */ 20 + export interface RenderableDocument { 21 + textContent?: string; 22 + content?: { blocks?: BlockNode[] }; 23 + } 24 + 25 + export interface RenderedArticle { 26 + /** Sanitised article HTML, safe to inject (`set:html`, RSS CDATA, …). */ 27 + html: string; 28 + /** Plain text: the stored `textContent`, else derived from the blocks. */ 29 + text: string; 30 + } 31 + 32 + /** 33 + * Render a document's blocks to sanitised HTML and plain text. `author` is the 34 + * document's writer (current PDS + DID) — blob-backed image URLs are rebuilt 35 + * against it before rendering, so images survive a PDS migration. 36 + */ 37 + export function renderArticle( 38 + doc: RenderableDocument, 39 + author: { pdsUrl: string; did: string } 40 + ): RenderedArticle { 41 + const blocks = resolveBlobImageUrls( doc.content?.blocks ?? [], author ); 42 + return { 43 + html: sanitizeArticleHtml( renderBlocks( blocks ) ), 44 + text: doc.textContent || blocksToText( blocks ), 45 + }; 46 + }
+37
src/lib/social/interactions.test.ts
··· 7 7 postReply, 8 8 postQuote, 9 9 fetchPostState, 10 + fetchPostExists, 10 11 } from './interactions'; 11 12 import type { StrongRef } from './records'; 12 13 ··· 149 150 expect( await fetchPostState( agent, SUBJECT.uri ) ).toBeNull(); 150 151 } ); 151 152 } ); 153 + 154 + describe( 'fetchPostExists', () => { 155 + const ok = ( body: unknown ) => 156 + ( { ok: true, json: async () => body } ) as unknown as Response; 157 + const notOk = () => ( { ok: false, json: async () => ( {} ) } ) as unknown as Response; 158 + 159 + it( 'queries the public AppView getPosts endpoint with the encoded uri', async () => { 160 + const fetchImpl = vi.fn().mockResolvedValue( ok( { posts: [ { uri: SUBJECT.uri } ] } ) ); 161 + await fetchPostExists( SUBJECT.uri, fetchImpl as unknown as typeof fetch ); 162 + expect( fetchImpl ).toHaveBeenCalledWith( 163 + `https://public.api.bsky.app/xrpc/app.bsky.feed.getPosts?uris=${ encodeURIComponent( 164 + SUBJECT.uri 165 + ) }` 166 + ); 167 + } ); 168 + 169 + it( 'returns true when the post is present', async () => { 170 + const fetchImpl = vi.fn().mockResolvedValue( ok( { posts: [ { uri: SUBJECT.uri } ] } ) ); 171 + expect( await fetchPostExists( SUBJECT.uri, fetchImpl as unknown as typeof fetch ) ).toBe( true ); 172 + } ); 173 + 174 + it( 'returns false when the post is gone (no posts returned)', async () => { 175 + const fetchImpl = vi.fn().mockResolvedValue( ok( { posts: [] } ) ); 176 + expect( await fetchPostExists( SUBJECT.uri, fetchImpl as unknown as typeof fetch ) ).toBe( false ); 177 + } ); 178 + 179 + it( 'returns null (undetermined) on a non-ok response', async () => { 180 + const fetchImpl = vi.fn().mockResolvedValue( notOk() ); 181 + expect( await fetchPostExists( SUBJECT.uri, fetchImpl as unknown as typeof fetch ) ).toBeNull(); 182 + } ); 183 + 184 + it( 'returns null (undetermined) when the network throws', async () => { 185 + const fetchImpl = vi.fn().mockRejectedValue( new Error( 'offline' ) ); 186 + expect( await fetchPostExists( SUBJECT.uri, fetchImpl as unknown as typeof fetch ) ).toBeNull(); 187 + } ); 188 + } );
+32
src/lib/social/interactions.ts
··· 19 19 const REPOST_COLLECTION = 'app.bsky.feed.repost'; 20 20 const POST_COLLECTION = 'app.bsky.feed.post'; 21 21 22 + /** The public, unauthenticated Bluesky AppView (same host as the landing actor lookup). */ 23 + const APPVIEW = 'https://public.api.bsky.app'; 24 + 22 25 /** createRecord types `record` as an open index signature; our records are precise. */ 23 26 function asRecord( value: object ): Record< string, unknown > { 24 27 return value as Record< string, unknown >; ··· 128 131 viewerRepostUri: post.viewer?.repost ?? null, 129 132 }; 130 133 } 134 + 135 + /** 136 + * Does the companion post still exist on Bluesky? An UNAUTHENTICATED read of the public 137 + * AppView (no OAuth, no agent), so signed-out readers can also tell when a post is gone. 138 + * Mirrors `landing/actor-lookup.ts`: fixed host, the post AT-URI URL-encoded as the only 139 + * input, and a deliberate three-state result — 140 + * 141 + * - `true` — the post is present, 142 + * - `false` — the post is gone (deleted, or not yet indexed), 143 + * - `null` — undetermined (network/HTTP error), so callers can fail OPEN and keep the 144 + * action bar rather than hide a live post on a transient blip. 145 + */ 146 + export async function fetchPostExists( 147 + postUri: string, 148 + fetchImpl: typeof fetch = fetch 149 + ): Promise< boolean | null > { 150 + try { 151 + const res = await fetchImpl( 152 + `${ APPVIEW }/xrpc/app.bsky.feed.getPosts?uris=${ encodeURIComponent( postUri ) }` 153 + ); 154 + if ( ! res.ok ) { 155 + return null; 156 + } 157 + const data = ( await res.json() ) as { posts?: unknown[] }; 158 + return Array.isArray( data?.posts ) && data.posts.length > 0; 159 + } catch { 160 + return null; 161 + } 162 + }
+6 -7
src/pages/[author]/[slug]/[rkey].astro
··· 3 3 import PublicationFooter from '../../../components/PublicationFooter.astro'; 4 4 import { resolveArticleContext } from '../../../lib/reader/read-context'; 5 5 import { formatLongDate } from '../../../lib/reader/dates'; 6 - import { resolveBlobImageUrls } from '../../../lib/media/blob'; 7 - import { renderBlocks, blocksToText } from '../../../lib/blocks/render'; 8 - import { sanitizeArticleHtml } from '../../../lib/reader/sanitize'; 6 + import { renderArticle } from '../../../lib/reader/render-article'; 9 7 import { canonicalArticleUrl } from '../../../lib/publish/records'; 10 8 import { deriveExcerpt } from '../../../lib/publish/excerpt'; 11 9 import { themeStyleBlock } from '../../../lib/publish/themes'; ··· 26 24 const { author, slug, rkey } = Astro.params; 27 25 28 26 // Resolve author → publication → document (site-joined) behind one interface; 29 - // this page keeps the render pipeline and presentation. 27 + // rendering lives behind render-article — this page keeps only presentation. 30 28 const result = await resolveArticleContext( author, slug, rkey ); 31 29 const error = result.ok ? null : result.error; 32 30 const ctx = result.ok ? result.context : null; ··· 60 58 handle = readAuthor.handle; 61 59 62 60 const doc = document.value; 63 - const blocks = resolveBlobImageUrls( doc.content?.blocks ?? [], { pdsUrl, did } ); 64 - html = sanitizeArticleHtml( renderBlocks( blocks ) ); 65 - const textContent = doc.textContent || blocksToText( blocks ); 61 + // Blob URLs, render, sanitise, and the text fallback all live in render-article. 62 + const rendered = renderArticle( doc, { pdsUrl, did } ); 63 + html = rendered.html; 64 + const textContent = rendered.text; 66 65 67 66 title = doc.title ?? 'Untitled'; 68 67 description = doc.description || deriveExcerpt( textContent );
+11
src/pages/[author]/[slug]/_[rkey].meta.test.ts
··· 62 62 expect( page ).toMatch( /Astro\.response\.status\s*=/ ); 63 63 } ); 64 64 65 + it( 'delegates the render pipeline to the render-article module', () => { 66 + // The ordering invariant (resolve blobs → render → sanitise) is behaviourally 67 + // tested in src/lib/reader/render-article.test.ts; the page only injects. 68 + expect( page ).toMatch( 69 + /import\s*\{\s*renderArticle\s*\}\s*from\s*'[^']*lib\/reader\/render-article'/ 70 + ); 71 + expect( page ).toMatch( /renderArticle\(/ ); 72 + // No hand-assembled pipeline: these belong inside render-article now. 73 + expect( page ).not.toMatch( /sanitizeArticleHtml|renderBlocks|resolveBlobImageUrls/ ); 74 + } ); 75 + 65 76 it( 'derives the og:description fallback via deriveExcerpt (shared with publish)', () => { 66 77 expect( page ).toMatch( 67 78 /import\s*\{\s*deriveExcerpt\s*\}\s*from\s*'[^']*lib\/publish\/excerpt'/
+23
src/pages/[author]/index.astro
··· 4 4 import { resolveAuthorContext } from '../../lib/reader/read-context'; 5 5 import { detectBioSegments } from '../../lib/reader/rich-text'; 6 6 import CreatePublicationCta from '../../components/CreatePublicationCta.tsx'; 7 + import ProviderLogo from '../../components/ProviderLogo.astro'; 7 8 import ErrorScene from '../../components/ErrorScene.astro'; 8 9 9 10 export const prerender = false; ··· 154 155 )} 155 156 <span class="author__pubtext"> 156 157 <span class="author__pubname">{pub.name}</span> 158 + <span class="author__pubhost"> 159 + <ProviderLogo provider={pub.provider} class="author__pubhost-logo" /> 160 + {pub.hostname} 161 + </span> 157 162 </span> 158 163 <svg 159 164 class="author__pub-arrow" ··· 351 356 .author__pubdesc { 352 357 color: var(--ink-soft); 353 358 font-size: 0.95rem; 359 + } 360 + .author__pubhost { 361 + display: inline-flex; 362 + align-items: center; 363 + gap: 0.3rem; 364 + align-self: flex-start; 365 + margin-top: 0.3rem; 366 + color: var(--muted); 367 + font-family: var(--font-mono); 368 + font-size: 0.72rem; 369 + padding: 0.05rem 0.4rem; 370 + border: 1px solid var(--line); 371 + border-radius: 999px; 372 + background: var(--paper-raised); 373 + } 374 + .author__pubhost-logo { 375 + flex: none; 376 + color: var(--ink-soft); 354 377 } 355 378 .author__elsewhere { 356 379 margin-top: 2.5rem;
+40 -1
src/pages/dashboard.astro
··· 87 87 padding: 0.5rem 1rem; 88 88 cursor: pointer; 89 89 } 90 + /* Empty state: creating a publication is the wanted action, so the CTA is 91 + prominent here. */ 92 + .dash__empty-state { 93 + display: flex; 94 + flex-direction: column; 95 + align-items: flex-start; 96 + gap: 1rem; 97 + padding: 1.5rem; 98 + } 99 + .dash__empty-state p { 100 + margin: 0; 101 + color: var(--muted); 102 + } 103 + /* Once a publication exists, "Write" (in the top bar) is the wanted action, so 104 + the add-another button is de-emphasized to a quiet secondary control. */ 105 + .dash__add { 106 + margin-top: 1.5rem; 107 + padding-top: 1.25rem; 108 + border-top: 1px solid var(--line); 109 + } 110 + .dash__new-quiet { 111 + border: 1px solid var(--line-strong); 112 + background: var(--paper-raised); 113 + border-radius: 6px; 114 + color: var(--ink-soft); 115 + font: inherit; 116 + font-size: 0.85rem; 117 + padding: 0.35rem 0.75rem; 118 + cursor: pointer; 119 + } 120 + .dash__new-quiet:hover { 121 + color: var(--ink); 122 + } 90 123 .dash__pubs, 91 124 .dash__postlist { 92 125 list-style: none; ··· 150 183 opacity: 0.85; 151 184 } 152 185 .dash__pubhost { 153 - display: inline-block; 186 + display: inline-flex; 187 + align-items: center; 188 + gap: 0.3rem; 154 189 align-self: flex-start; 155 190 color: var(--muted); 156 191 font-family: var(--font-mono); ··· 159 194 border: 1px solid var(--line); 160 195 border-radius: 999px; 161 196 background: var(--paper-raised); 197 + } 198 + .dash__pubhost-logo { 199 + flex: none; 200 + color: var(--ink-soft); 162 201 } 163 202 .dash__pubactions { 164 203 display: flex;
+40 -26
src/pages/index.astro
··· 33 33 </Fragment> 34 34 35 35 <div class="page"> 36 - <div class="sky" aria-hidden="true"> 37 - <div class="stars"></div> 38 - <span class="shootingstar"></span> 39 - <div class="bloom"></div> 40 - <div class="halo"></div> 41 - <div class="horizon"></div> 42 - </div> 43 - 44 - <header class="masthead"> 45 - <Logo /> 46 - <div class="masthead__right"> 47 - <a class="btn btn--ghost masthead-write" href="/editor">Write</a> 48 - <AccountMenu client:only="react" /> 36 + <!-- The sky backs the masthead + hero together. Wrapping them lets the sky fill the 37 + zone exactly (inset: 0) instead of guessing a fixed height — on a narrow phone the 38 + wrapping title can push the hero past any fixed height, dropping the trailing copy 39 + onto the bare dark page background as unreadable dark-on-dark text. --> 40 + <div class="skyzone"> 41 + <div class="sky" aria-hidden="true"> 42 + <div class="stars"></div> 43 + <span class="shootingstar"></span> 44 + <div class="bloom"></div> 45 + <div class="halo"></div> 46 + <div class="horizon"></div> 49 47 </div> 50 - </header> 51 48 52 - <main class="hero"> 53 - <p class="eyebrow" id="greet">{fallback.greeting}</p> 54 - <h1 class="hero__title" id="headline" set:html={fallback.headlineHtml} /> 55 - <p class="hero__lede" id="lede">{fallback.lede}</p> 56 - <div class="hero__cta"> 57 - <HandleStart client:only="react" /> 58 - </div> 59 - <p class="hero__free">Free &amp; open-source. Your words live in your account, not ours.</p> 60 - </main> 49 + <header class="masthead"> 50 + <Logo /> 51 + <div class="masthead__right"> 52 + <a class="btn btn--ghost masthead-write" href="/editor">Write</a> 53 + <AccountMenu client:only="react" /> 54 + </div> 55 + </header> 56 + 57 + <main class="hero"> 58 + <p class="eyebrow" id="greet">{fallback.greeting}</p> 59 + <h1 class="hero__title" id="headline" set:html={fallback.headlineHtml} /> 60 + <p class="hero__lede" id="lede">{fallback.lede}</p> 61 + <div class="hero__cta"> 62 + <HandleStart client:only="react" /> 63 + </div> 64 + <p class="hero__free">Free &amp; open-source. Your words live in your account, not ours.</p> 65 + </main> 66 + </div> 61 67 62 68 <section class="showcase"> 63 69 <p class="showcase__label">See it in action</p> ··· 105 111 overflow: hidden; 106 112 } 107 113 114 + /* The sky zone holds the masthead + hero; the sky fills it exactly so the backdrop always 115 + reaches the bottom of the hero, however tall the (wrapping) title makes it. flex: 1 lets 116 + it grow to push the showcase down when the content is short. */ 117 + .skyzone { 118 + position: relative; 119 + flex: 1; 120 + display: flex; 121 + flex-direction: column; 122 + } 123 + 108 124 /* ===== Atmospheric sky (hero backdrop) — varies by [data-phase] ===== */ 109 125 .sky { 110 126 position: absolute; 111 - inset: 0 0 auto 0; 112 - height: 78vh; 113 - min-height: 32rem; 127 + inset: 0; 114 128 z-index: 0; 115 129 pointer-events: none; 116 130 }
+7 -1
src/styles/editor-chrome.css
··· 137 137 } 138 138 139 139 /* Borderless article title, sitting above the framed editor canvas — echoes the 140 - block-editor post title (large display heading, no box). */ 140 + block-editor post title (large display heading, no box). A `<textarea>` (not a 141 + single-line input) so long titles wrap and the field auto-grows to fit instead 142 + of clipping on one line — it stays single-line semantically (Enter is blocked 143 + in the markup). Auto-grows via JS, like the lede; resize/overflow off. */ 141 144 .studio__title { 142 145 display: block; 143 146 max-width: var(--studio-measure); ··· 152 155 font-size: clamp(1.9rem, 4vw, 2.6rem); 153 156 font-weight: 700; 154 157 line-height: 1.15; 158 + resize: none; 159 + overflow: hidden; 160 + overflow-wrap: break-word; 155 161 } 156 162 .studio__title::placeholder { 157 163 color: var(--muted);