Add publication system: dashboard, creation flow, publication pages
SkyPress auto-created one invisible publication per user (url = /@handle)
and had no UI to manage it. Replace that with a real, user-managed system:
multiple publications, a dashboard, a create/edit flow (name, logo,
description), and publication-centric public pages.
URL model becomes handle-namespaced (Decision 0010), all resolved from the
PDS so the no-database/no-KV stance holds — no global slug registry:
/@handle author index (lists the author's SkyPress publications)
/@handle/{slug} a publication's home
/@handle/{slug}/{rkey} a document within that publication
Slugs are auto-derived from the name, de-duplicated within the writer's own
repo, and frozen into publication.url so a rename never breaks links. SkyPress
manages only publications whose url origin is its own, so a record written by
another standard.site tool (Leaflet, …) is never listed, edited, or rendered.
Deleting a publication cascades to its documents and their Bluesky posts.
Publishing now targets a chosen publication (the editor gains a selector); the
implicit ensurePublication is gone. The author index revives the paused profile
work, reading app.bsky.actor.profile straight from the PDS (no appview) for
name/bio/avatar/cover — rendered as text, never injected as HTML.
Guardrails kept: reading pages import no @wordpress JS, every PDS-derived fetch
goes through safe-fetch, and the Bluesky-post disclosure stays on publish.
Spec: docs/specs/sp10-publication-system.md. Built test-driven (records, CRUD,
publish target, cascade delete, slug rules, profile + publication resolution).
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).
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).
Reader: extract a read-context module to own the read-route spine
Every read route (author index, publication home, article, RSS)
hand-assembled the same orchestration in its frontmatter: strip the
@ from the URL param, resolve handle -> DID -> PDS, match a
publication by slug, join documents by value.site ===
publication.uri, and map each miss to an error scene. That was ~230
near-duplicate lines across four files, and the join key, the
100-document fetch bound, and the blob-ref -> logo-URL construction
were caller knowledge repeated 3-4x. Living in .astro frontmatter,
none of it was unit-testable - the colocated tests pinned the page
source with regexes instead of behaviour.
resolveAuthorContext / resolvePublicationContext /
resolveArticleContext (src/lib/reader/read-context.ts) now own that
spine behind one interface returning either a fully-shaped context
or ready-made ErrorSceneCopy; the routes keep only presentation.
Publications come back with logoUrl prebuilt so callers never touch
BlobRefJson internals, and the per-route independent fetches
(profile, publication list, documents) run in parallel where the
pages ran them serially.
The module is behaviourally tested in read-context.test.ts (mocking
only the network seam: identity.ts + records.ts); the source-pin
tests shrank to template-wiring guards. The now-unused
listReaderPublications / resolveReaderPublication wrappers are
deleted. Decision 0016 records the seam.
Side effect: RSS 404 bodies are a uniform "Not found" instead of
echoing the handle.
Reader: extract a read-context module to own the read-route spine
Every read route (author index, publication home, article, RSS)
hand-assembled the same orchestration in its frontmatter: strip the
@ from the URL param, resolve handle -> DID -> PDS, match a
publication by slug, join documents by value.site ===
publication.uri, and map each miss to an error scene. That was ~230
near-duplicate lines across four files, and the join key, the
100-document fetch bound, and the blob-ref -> logo-URL construction
were caller knowledge repeated 3-4x. Living in .astro frontmatter,
none of it was unit-testable - the colocated tests pinned the page
source with regexes instead of behaviour.
resolveAuthorContext / resolvePublicationContext /
resolveArticleContext (src/lib/reader/read-context.ts) now own that
spine behind one interface returning either a fully-shaped context
or ready-made ErrorSceneCopy; the routes keep only presentation.
Publications come back with logoUrl prebuilt so callers never touch
BlobRefJson internals, and the per-route independent fetches
(profile, publication list, documents) run in parallel where the
pages ran them serially.
The module is behaviourally tested in read-context.test.ts (mocking
only the network seam: identity.ts + records.ts); the source-pin
tests shrank to template-wiring guards. The now-unused
listReaderPublications / resolveReaderPublication wrappers are
deleted. Decision 0016 records the seam.
Side effect: RSS 404 bodies are a uniform "Not found" instead of
echoing the handle.