Something went wrong. Try again.
leaflet fork for copy edit / bug fixes forked from leaflet.pub/leaflet
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363import { cache } from "react";import { supabaseServerClient } from "supabase/serverClient";import { AtUri } from "@atproto/syntax";import { normalizeDocumentRecord, normalizePublicationRecord,} from "src/utils/normalizeRecords";import { resolvePublicationTheme } from "lexicons/src/normalize";import { PubLeafletComment } from "lexicons/api";import { documentUriFilter } from "src/utils/uriHelpers";import { getDocumentURL } from "src/utils/getPublicationURL";import { getDocumentPages } from "src/utils/normalizeRecords";import { buildMembershipTiers, getGatedPostPolicy, postHasMembersDelimiter, truncatePagesAtMembersDelimiter, type GatePolicy, type MembershipTiers,} from "src/membership";import { projectDocumentRowForClient, projectPublicationForClient,} from "./postPageProjection";import { resolveDocumentFilter } from "./resolveDocumentFilter";import { fetchPublicationForPage } from "app/(app)/(published)/lish/[did]/[publication]/getPublicationForPage";import { getPostImagePreloads } from "app/(app)/(published)/lish/[did]/[publication]/[rkey]/getPostImagePreloads";import { sortPostsForPrevNext } from "src/utils/prevNextPosts";
export const getPostPageData = cache(async function getPostPageData( did: string, rkey: string, publicationName?: string,) { // Per-request memoized alongside the page's own publication fetch, so the // path resolver can filter on the indexed publication column. let pub = publicationName ? await fetchPublicationForPage(did, publicationName) : null; let filter = pub ? await resolveDocumentFilter(did, pub.uri, rkey) : documentUriFilter(did, rkey); let { data: documents } = await supabaseServerClient .from("documents") .select( ` data, uri, comments_on_documents(record), documents_in_publications(publications(uri, name, identity_did, record, documents_in_publications(members_only, documents(uri, sort_date, title:data->>title, publishedAt:data->>publishedAt)), publication_newsletter_settings(enabled), publication_membership_settings(enabled, subscriber_tier_name, subscriber_tier_description), publication_membership_tiers(id, name, description, monthly_price_cents, annual_price_cents, active, sort_order)) ), document_mentions_in_bsky(uri, link), recommends_on_documents(count) `, ) .or(filter) .order("uri", { ascending: false }) .limit(1); let document = documents?.[0];
if (!document) return null; const documentPublication = document.documents_in_publications[0]?.publications;
// A publication-addressed URL must resolve within that publication. // resolveDocumentFilter falls back to a bare did+rkey match when the // (publication, path) lookup misses, which would otherwise serve the post // under any publication segment — minting a permanent ISR entry per // spelling. if ( publicationName && !matchesPublicationSegment(documentPublication, did, publicationName) ) return null;
// Normalize the document record - this is the primary way consumers should access document data const normalizedDocument = normalizeDocumentRecord( document.data, document.uri, ); if (!normalizedDocument) return null;
// Normalize the publication record - this is the primary way consumers should access publication data const normalizedPublication = normalizePublicationRecord( documentPublication?.record, );
// Members-only gating: when the publication has paid memberships enabled and // the post's first page carries a delimiter, drop everything after it before // the record leaves the server. This render is identity-free by construction // — every viewer gets the gated variant so the page stays cacheable — and // entitled readers unlock the tail client-side via getUnlockedPost. const gatePub = documentPublication; const membershipTiers = buildMembershipTiers( gatePub?.publication_membership_settings, gatePub?.publication_membership_tiers ?? [], ); let membersOnly: { gated: boolean; tiers: MembershipTiers; gatePolicy: GatePolicy | null; } = { gated: false, tiers: membershipTiers, gatePolicy: null, }; if ( gatePub?.publication_membership_settings?.enabled && postHasMembersDelimiter(normalizedDocument) ) { const gatePolicy = getGatedPostPolicy(normalizedDocument); // normalizeDocumentRecord shares the pages array with `document.data`, so // this one splice gates both the normalized view and the raw record we // return. See the by-reference test in src/membership.test.ts. const pages = getDocumentPages(normalizedDocument); if (pages) truncatePagesAtMembersDelimiter(pages); membersOnly = { gated: true, tiers: membershipTiers, gatePolicy, }; }
type Neighbour = { uri: string; title: string; membersOnly: boolean; images?: string[]; }; let prevNext: | { prev?: Neighbour; next?: Neighbour; first?: { uri: string; title: string }; last?: { uri: string; title: string }; prevPreload?: string; nextPreload?: string; } | undefined;
const currentPublishedAt = normalizedDocument.publishedAt; const allDocs = documentPublication?.documents_in_publications;
if (currentPublishedAt && allDocs) { const sortedDocs = sortPostsForPrevNext( allDocs.flatMap((dip) => dip.documents ? [{ ...dip.documents, membersOnly: dip.members_only }] : [], ), );
const currentIndex = sortedDocs.findIndex( (doc) => doc.uri === document.uri, );
if (currentIndex !== -1) { const lastIndex = sortedDocs.length - 1; const neighbour = (doc: (typeof sortedDocs)[number]): Neighbour => ({ uri: doc.uri || "", title: doc.title || "", membersOnly: doc.membersOnly, }); prevNext = { prev: currentIndex > 0 ? neighbour(sortedDocs[currentIndex - 1]) : undefined, next: currentIndex < lastIndex ? neighbour(sortedDocs[currentIndex + 1]) : undefined, first: currentIndex > 0 ? { uri: sortedDocs[0].uri || "", title: sortedDocs[0].title || "", } : undefined, last: currentIndex < lastIndex ? { uri: sortedDocs[lastIndex].uri || "", title: sortedDocs[lastIndex].title || "", } : undefined, prevPreload: currentIndex > 1 ? sortedDocs[currentIndex - 2].uri || "" : undefined, nextPreload: currentIndex < lastIndex - 1 ? sortedDocs[currentIndex + 2].uri || "" : undefined, }; } }
// Fetch constellation backlinks for mentions const postUrl = getDocumentURL( normalizedDocument, document.uri, normalizedPublication, ); // Constellation needs an absolute URL const absolutePostUrl = postUrl.startsWith("/") ? `https://leaflet.pub${postUrl}` : postUrl;
const warm = prevNext && (normalizedPublication?.preferences?.showPrevNext ?? true) ? [prevNext.next, prevNext.prev].filter((n): n is Neighbour => !!n) : []; const [constellationBacklinks, neighbourImages] = await Promise.all([ getConstellationBacklinks(absolutePostUrl, document.uri), getPostImagePreloads(warm), ]); for (const neighbour of warm) neighbour.images = neighbourImages.get(neighbour.uri);
// Deduplicate constellation backlinks (same post could appear in both links and embeds) const uniqueBacklinks = Array.from( new Map(constellationBacklinks.map((b) => [b.uri, b])).values(), );
// Combine database mentions (already deduplicated by DB constraint) and // constellation backlinks. Quote posts published through our share flow are // both a DB mention and an associatedRefs backlink, so filter those out. const dbMentionUris = new Set( document.document_mentions_in_bsky.map((m) => m.uri), ); const quotesAndMentions: { uri: string; link?: string }[] = [ // Database mentions (quotes with link to quoted content) ...document.document_mentions_in_bsky.map((m) => ({ uri: m.uri, link: m.link, })), // Constellation backlinks (direct post mentions without quote context) ...uniqueBacklinks.filter((b) => !dbMentionUris.has(b.uri)), ];
let theme = resolvePublicationTheme(normalizedPublication) || normalizedDocument?.theme;
// Build explicit publication context for consumers const publication = projectPublicationForClient(documentPublication); const recommendsCount = document.recommends_on_documents?.[0]?.count ?? 0;
// Comments are counted per-page so subpages (and the main page) each show only // their own discussion. Comments on the main page have no `onPage`, so they're // keyed under "". `commentsCount` remains the document-wide total. const commentRecords = document.comments_on_documents ?? []; const commentsCountByPage: Record<string, number> = {}; for (const c of commentRecords) { const onPage = (c.record as PubLeafletComment.Record)?.onPage ?? ""; commentsCountByPage[onPage] = (commentsCountByPage[onPage] ?? 0) + 1; } const commentsCount = commentRecords.length;
return { // Allow-listed subset of the query result — see postPageProjection.ts. The // rest (sibling post records, comment records, the publication's membership // config) is server-only working data for the fields computed above. ...projectDocumentRowForClient(document), // Pre-normalized data - consumers should use these instead of normalizing themselves normalizedDocument, normalizedPublication, postUrl, quotesAndMentions, theme, prevNext, membersOnly, // Explicit relational data for DocumentContext publication, commentsCount, commentsCountByPage, mentions: document.document_mentions_in_bsky, // Recommends data recommendsCount, };});
export type PostPageData = Awaited<ReturnType<typeof getPostPageData>>;
// Mirrors publicationNameOrUriFilter's semantics: the URL segment may be the// publication's name or its rkey.function matchesPublicationSegment( pub: { uri: string; name: string; identity_did: string } | null, did: string, segment: string,): boolean { if (!pub || pub.identity_did !== did) return false; if (pub.name === segment) return true; try { return new AtUri(pub.uri).rkey === segment; } catch { return false; }}
const headers = { "Content-type": "application/json", "user-agent": "leaflet.pub",};
// Fetch constellation backlinks without hydrating with Bluesky post data.// Posts that share via the standard.site external embed (like our own publish// flow) carry the document's at-uri in the embed's associatedRefs, while their// visible url is often utm-tagged and misses the plain-url subject — so when we// know the document uri we query those paths too.export async function getConstellationBacklinks( url: string, documentUri?: string,): Promise<{ uri: string }[]> { try { let queries = [ { subject: url, source: "app.bsky.feed.post:embed.external.uri" }, { subject: url, source: "app.bsky.feed.post:facets[].features[app.bsky.richtext.facet#link].uri", }, ...(documentUri ? [ { subject: documentUri, source: "app.bsky.feed.post:embed.external.associatedRefs[com.atproto.repo.strongRef].uri", }, { subject: documentUri, source: "app.bsky.feed.post:embed.media.external.associatedRefs[com.atproto.repo.strongRef].uri", }, ] : []), ];
let responses = (await Promise.all( queries.map(({ subject, source }) => fetch( `https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks?subject=${encodeURIComponent(subject)}&source=${encodeURIComponent(source)}`, { headers, next: { revalidate: 3600 } }, ).then((req) => req.json()), ), )) as ConstellationResponse[];
let uris = responses .flatMap((r) => r.records) .map((i) => AtUri.make(i.did, i.collection, i.rkey).toString());
return uris.map((uri) => ({ uri })); } catch (e) { return []; }}
type ConstellationResponse = { records: { did: string; collection: string; rkey: string }[];};