diff --git a/src/lib/services/atproto/fetch.ts b/src/lib/services/atproto/fetch.ts index 333409d..bbc2d54 100644 --- a/src/lib/services/atproto/fetch.ts +++ b/src/lib/services/atproto/fetch.ts @@ -245,8 +245,242 @@ export interface LeafletComment { reply?: { parent: string }; } +export interface AtBacklink { + uri: string; + collection: string; + text: string; + createdAt?: string; + authorDid: string; + authorHandle: string; + authorDisplayName?: string; + url: string; +} + const CONSTELLATION = "https://constellation.microcosm.blue"; const SLINGSHOT = "https://slingshot.microcosm.blue"; +const CONSTELLATION_HEADERS = { + Accept: "application/json", + "User-Agent": "ewancroft.uk/12.2 (https://ewancroft.uk)", +}; + +interface BacklinkRef { + did: string; + collection: string; + rkey: string; +} + +interface BacklinkSources { + links?: Record>; +} + +/** + * Ask Constellation which record fields actually link to a subject, then page + * through those exact sources. This avoids baking in one lexicon/path pair and + * keeps working when a document is linked through a rich embed rather than a + * plain URI field. + */ +async function fetchBacklinkRefs( + subjects: string[], + fetchFn: typeof fetch, + collections?: Set, +): Promise { + const refs = new Map(); + + await Promise.all( + [...new Set(subjects.filter(Boolean))].map(async (subject) => { + const sourcesUrl = new URL(`${CONSTELLATION}/links/all`); + sourcesUrl.searchParams.set("target", subject); + + let sources: BacklinkSources; + try { + const response = await fetchFn(sourcesUrl, { + headers: CONSTELLATION_HEADERS, + }); + if (!response.ok) return; + sources = await response.json(); + } catch { + return; + } + + const matchingSources = Object.entries(sources.links ?? {}).flatMap( + ([collection, paths]) => { + if (collections && !collections.has(collection)) return []; + return Object.keys(paths ?? {}).map((rawPath) => ({ + collection, + rawPath, + })); + }, + ); + + await Promise.all( + matchingSources.map(async ({ collection, rawPath }) => { + // /links/all uses the JSON-path spelling (`.subject`), while the XRPC + // source parameter expects it without the leading dot (`subject`). + const path = rawPath.startsWith(".") ? rawPath.slice(1) : rawPath; + const source = `${collection}:${path}`; + let cursor: string | undefined; + + do { + const backlinksUrl = new URL( + `${CONSTELLATION}/xrpc/blue.microcosm.links.getBacklinks`, + ); + backlinksUrl.searchParams.set("subject", subject); + backlinksUrl.searchParams.set("source", source); + backlinksUrl.searchParams.set("limit", "100"); + backlinksUrl.searchParams.set("reverse", "true"); + if (cursor) backlinksUrl.searchParams.set("cursor", cursor); + + try { + const response = await fetchFn(backlinksUrl, { + headers: CONSTELLATION_HEADERS, + }); + if (!response.ok) break; + const page = await response.json(); + for (const record of page.records ?? []) { + if (!record.did || !record.rkey) continue; + const ref = { + did: record.did, + collection: record.collection ?? collection, + rkey: record.rkey, + }; + refs.set(`${ref.did}/${ref.collection}/${ref.rkey}`, ref); + } + cursor = + typeof page.cursor === "string" ? page.cursor : undefined; + } catch { + break; + } + } while (cursor && refs.size < 500); + }), + ); + }), + ); + + return [...refs.values()]; +} + +async function resolveBacklinkAuthor(did: string, fetchFn: typeof fetch) { + let authorHandle = did; + let authorDisplayName: string | undefined; + + try { + const didDoc = await resolveDid(did, fetchFn); + const handleEntry: string | undefined = didDoc.alsoKnownAs?.find( + (entry: string) => entry.startsWith("at://"), + ); + authorHandle = handleEntry?.replace("at://", "") ?? did; + + const pdsUrl = + didDoc.service?.find( + (service: { id?: string }) => service.id === "#atproto_pds", + )?.serviceEndpoint ?? didDoc.service?.[0]?.serviceEndpoint; + if (pdsUrl) { + const profileRes = await fetchFn( + `${pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.actor.profile&rkey=self`, + ); + if (profileRes.ok) { + const profileData = await profileRes.json(); + authorDisplayName = profileData.value?.displayName; + } + } + } catch {} + + return { authorHandle, authorDisplayName }; +} + +export async function fetchBacklinks( + subjects: string[], + fetchFn: typeof fetch = fetch, +): Promise { + // Constellation indexes URI strings exactly. Check the common slash/no-slash + // spellings so a link to either the local permalink or the publication's + // canonical URL is not missed merely because an editor normalised it. + const subjectVariants = subjects.flatMap((subject) => { + if (!subject?.startsWith("http")) return subject ? [subject] : []; + try { + const parsed = new URL(subject); + parsed.search = ""; + parsed.hash = ""; + const withoutSlash = parsed.href.replace(/\/$/, ""); + return [withoutSlash, `${withoutSlash}/`]; + } catch { + return [subject]; + } + }); + const uniqueSubjects = [...new Set(subjectVariants)].sort(); + const cacheKey = `backlinks:${uniqueSubjects.join("|")}`; + const cached = getCache(cacheKey); + if (cached) return cached; + + // These collections carry human-authored backlink content that can be + // rendered meaningfully. Read receipts, likes, and other relationship + // records remain discoverable in Constellation but are not "mentions". + const refs = await fetchBacklinkRefs( + uniqueSubjects, + fetchFn, + new Set(["app.bsky.feed.post", "site.standard.document"]), + ); + + const resolved = await mapWithConcurrency( + refs, + async (ref): Promise => { + const recordRes = await fetchFn( + `${SLINGSHOT}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(ref.did)}&collection=${encodeURIComponent(ref.collection)}&rkey=${encodeURIComponent(ref.rkey)}`, + { headers: { Accept: "application/json" } }, + ); + if (!recordRes.ok) throw new Error(`Backlink not found: ${ref.rkey}`); + const recordData = await recordRes.json(); + const value = recordData.value ?? {}; + const { authorHandle, authorDisplayName } = await resolveBacklinkAuthor( + ref.did, + fetchFn, + ); + const uri = `at://${ref.did}/${ref.collection}/${ref.rkey}`; + + let url = uri; + if (ref.collection === "app.bsky.feed.post") { + url = `https://bsky.app/profile/${authorHandle}/post/${ref.rkey}`; + } else if ( + typeof value.site === "string" && + !value.site.startsWith("at://") + ) { + const base = value.site.startsWith("http") + ? value.site + : `https://${value.site}`; + const path = value.path || `/${ref.rkey}`; + url = `${base.replace(/\/$/, "")}/${String(path).replace(/^\//, "")}`; + } + + return { + uri, + collection: ref.collection, + text: + value.text ?? value.title ?? value.description ?? "Linked this post", + createdAt: value.createdAt ?? value.publishedAt, + authorDid: ref.did, + authorHandle, + authorDisplayName, + url, + }; + }, + 5, + ); + + const data = resolved + .filter( + (result): result is PromiseFulfilledResult => + result.status === "fulfilled", + ) + .map((result) => result.value) + .sort( + (a, b) => + new Date(b.createdAt ?? 0).getTime() - + new Date(a.createdAt ?? 0).getTime(), + ); + + setCache(cacheKey, data, CACHE_TTL_MS); + return data; +} export async function fetchComments( subjectUri: string, @@ -256,17 +490,16 @@ export async function fetchComments( const cached = getCache(cacheKey); if (cached) return cached; - const backlinksRes = await fetchFn( - `${CONSTELLATION}/xrpc/blue.microcosm.links.getBacklinks?subject=${encodeURIComponent(subjectUri)}&source=pub.leaflet.comment:subject&limit=100`, + const commentRefs = await fetchBacklinkRefs( + [subjectUri], + fetchFn, + new Set(["pub.leaflet.comment"]), ); - if (!backlinksRes.ok) return []; - const backlinks = await backlinksRes.json(); - - if (!backlinks.records?.length) return []; + if (!commentRefs.length) return []; const comments = await mapWithConcurrency( - backlinks.records, - async (ref: { did: string; rkey: string }) => { + commentRefs, + async (ref) => { const recordRes = await fetchFn( `${SLINGSHOT}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(ref.did)}&collection=pub.leaflet.comment&rkey=${ref.rkey}`, ); @@ -274,29 +507,10 @@ export async function fetchComments( const recordData = await recordRes.json(); const value = recordData.value; - const didDoc = await resolveDid(ref.did, fetchFn); - let authorHandle = ref.did; - let authorDisplayName: string | undefined; - - if (didDoc) { - const handleEntry: string | undefined = didDoc.alsoKnownAs?.find( - (a: string) => a.startsWith("at://"), - ); - authorHandle = handleEntry ? handleEntry.replace("at://", "") : ref.did; - - const pdsUrl = didDoc.service?.[0]?.serviceEndpoint; - if (pdsUrl) { - try { - const profileRes = await fetchFn( - `${pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(ref.did)}&collection=app.bsky.actor.profile&rkey=self`, - ); - if (profileRes.ok) { - const profileData = await profileRes.json(); - authorDisplayName = profileData.value?.displayName; - } - } catch {} - } - } + const { authorHandle, authorDisplayName } = await resolveBacklinkAuthor( + ref.did, + fetchFn, + ); return { uri: `at://${ref.did}/pub.leaflet.comment/${ref.rkey}`, diff --git a/src/lib/styles/components.css b/src/lib/styles/components.css index 8ef3c0c..d675b33 100644 --- a/src/lib/styles/components.css +++ b/src/lib/styles/components.css @@ -327,6 +327,82 @@ white-space: pre-wrap; } + /* ── AT Protocol backlinks ────────────────────────────────────── */ + + .backlinks-section { + margin-top: var(--space-lg); + } + + .backlinks-section .section-heading { + display: flex; + align-items: center; + gap: var(--space-xs); + margin-bottom: var(--space-2xs); + } + + .backlinks-intro { + margin: 0 0 var(--space-sm); + color: var(--color-text-600); + font-size: var(--text-sm); + } + + .backlink-list { + display: flex; + flex-direction: column; + gap: var(--space-sm); + padding: 0; + margin: 0; + list-style: none; + } + + .backlink { + padding: var(--space-md); + border: 1px solid var(--surface-color); + border-radius: var(--radius-lg); + transition: + border-color var(--duration-fast) ease, + transform var(--duration-fast) ease; + } + + .backlink:hover { + border-color: color-mix( + in srgb, + var(--color-primary-500) 45%, + var(--surface-color) + ); + transform: translateY(-1px); + } + + .backlink-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-sm); + margin-bottom: var(--space-xs); + } + + .backlink-head > div { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--space-sm); + } + + .backlink-source { + display: inline-flex; + align-items: center; + gap: var(--space-2xs); + margin-top: var(--space-sm); + color: var(--color-primary-600); + font-size: var(--text-xs); + font-weight: 650; + text-decoration: none; + } + + .backlink-source:hover { + text-decoration: underline; + } + /* ── Share buttons ───────────────────────────────────────────── */ .share-bar { diff --git a/src/routes/blog/[year]/[month]/[day]/[slug]/+page.server.ts b/src/routes/blog/[year]/[month]/[day]/[slug]/+page.server.ts index acf81d8..9827fa1 100644 --- a/src/routes/blog/[year]/[month]/[day]/[slug]/+page.server.ts +++ b/src/routes/blog/[year]/[month]/[day]/[slug]/+page.server.ts @@ -1,7 +1,11 @@ import type { PageServerLoad } from "./$types"; import type { Config } from "@sveltejs/adapter-vercel"; import { fetchDocuments, fetchPublications } from "@ewanc26/atproto"; -import { fetchBlob, fetchComments } from "$lib/services/atproto"; +import { + fetchBacklinks, + fetchBlob, + fetchComments, +} from "$lib/services/atproto"; import { PUBLIC_ATPROTO_DID, PUBLIC_LEAFLET_BLOG_PUBLICATION, @@ -17,7 +21,12 @@ import { export const config: Config = { maxDuration: 60 }; -export const load: PageServerLoad = async ({ params, fetch, setHeaders }) => { +export const load: PageServerLoad = async ({ + params, + fetch, + setHeaders, + url, +}) => { // Long cache — posts don’t change often; Vercel CDN takes the burden off the // cold function for repeated visitors. setHeaders({ @@ -51,8 +60,6 @@ export const load: PageServerLoad = async ({ params, fetch, setHeaders }) => { if (!post) { throw error(404, "Post not found"); } - console.log("POST:", JSON.stringify(post, null, 2)); - // Generate a clean text excerpt string by removing common markdown markup indicators const cleanExcerpt = (text: string) => { return text @@ -91,7 +98,13 @@ export const load: PageServerLoad = async ({ params, fetch, setHeaders }) => { renderedContent = await renderMarkdown(markdown); } - const comments = await fetchComments(post.uri, fetch); + const canonicalUrl = new URL(url); + canonicalUrl.search = ""; + canonicalUrl.hash = ""; + const [comments, backlinks] = await Promise.all([ + fetchComments(post.uri, fetch), + fetchBacklinks([post.uri, post.url, canonicalUrl.href], fetch), + ]); const { content: _content, ...serialisable } = post; return { @@ -111,5 +124,6 @@ export const load: PageServerLoad = async ({ params, fetch, setHeaders }) => { } : null, comments, + backlinks, }; }; diff --git a/src/routes/blog/[year]/[month]/[day]/[slug]/+page.svelte b/src/routes/blog/[year]/[month]/[day]/[slug]/+page.svelte index 2572fc7..4495514 100644 --- a/src/routes/blog/[year]/[month]/[day]/[slug]/+page.svelte +++ b/src/routes/blog/[year]/[month]/[day]/[slug]/+page.svelte @@ -7,7 +7,7 @@ import Pentacle from '$lib/components/icons/Pentacle.svelte'; import Leaflet from '$lib/components/icons/Leaflet.svelte'; import StandardSite from '$lib/components/icons/StandardSite.svelte'; - import { Rss, MessageCircle } from '@lucide/svelte'; + import { ExternalLink, Link2, Rss, MessageCircle } from '@lucide/svelte'; import { page } from '$app/state'; let { data } = $props(); @@ -91,6 +91,35 @@ {/if} + {#if data.backlinks.length > 0} + + {/if} + {#if data.blog}