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.

Show known-provider logos beside foreign publication hostnames

Foreign `site.standard.publication` records (written by Leaflet, pckt,
Offprint, …) now display the originating service's logo next to their
hostname — on the dashboard "From other apps" list and the public
profile "Elsewhere" section, which also gains the hostname it didn't
show before.

The hostname alone can't identify the service, because the paid tiers
serve from a custom domain. So detection is two-step: an app-specific
`$type` discriminator embedded in the record first (pckt writes
`theme.$type === "blog.pckt.theme"`, which survives a custom domain),
then a dot-boundary hostname-suffix fallback (`*.leaflet.pub`,
`*.pckt.blog`, `*.offprint.app`). Leaflet records carry no such
discriminator and Offprint is unsampled, so those two on a custom
domain stay logo-less, exactly as before — see Decision 0017.

Detection and the monochrome glyph data live in one framework-agnostic
module (`lib/publish/providers.ts`), since Astro can't server-render a
React component and the read path takes no client island: the React
dashboard and the Astro profile each render the shared data through a
tiny `ProviderLogo` of their own. Leaflet's saved asset was a raster
PNG, so its glyph is a substitute vector feather (Lucide, ISC).

+454 -2
+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.
+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.
+8 -1
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 = ··· 244 245 /> 245 246 <span className="dash__pubtext"> 246 247 <span className="dash__pubname">{ pub.name }</span> 247 - <span className="dash__pubhost">{ pub.hostname }</span> 248 + <span className="dash__pubhost"> 249 + <ProviderLogo 250 + provider={ pub.provider } 251 + className="dash__pubhost-logo" 252 + /> 253 + { pub.hostname } 254 + </span> 248 255 </span> 249 256 <span className="dash__pubactions"> 250 257 <a
+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 + }
+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
+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;
+7 -1
src/pages/dashboard.astro
··· 150 150 opacity: 0.85; 151 151 } 152 152 .dash__pubhost { 153 - display: inline-block; 153 + display: inline-flex; 154 + align-items: center; 155 + gap: 0.3rem; 154 156 align-self: flex-start; 155 157 color: var(--muted); 156 158 font-family: var(--font-mono); ··· 159 161 border: 1px solid var(--line); 160 162 border-radius: 999px; 161 163 background: var(--paper-raised); 164 + } 165 + .dash__pubhost-logo { 166 + flex: none; 167 + color: var(--ink-soft); 162 168 } 163 169 .dash__pubactions { 164 170 display: flex;