Something went wrong. Try again.
leaflet fork for copy edit / bug fixes forked from leaflet.pub/leaflet
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249import { revalidatePublicationPaths } from "src/utils/revalidatePublication";import { TID } from "@atproto/common";import { AtUri } from "@atproto/syntax";import { AtpBaseClient } from "lexicons/api";import { leafletThemeToBasicTheme } from "lexicons/src/normalize";import { Json } from "supabase/database.types";import type { Fact } from "src/replicache";import type { Attribute } from "src/replicache/attributes";import { readNavEntries } from "src/utils/publicationNavEntries";import { OAuthSessionError, restoreOAuthSession } from "src/atproto-oauth";import { leafletToPublicationPageRecord } from "src/utils/leafletToPublicationPageRecord";import { extractThemeFromFacts, makePublishUploadHooks,} from "src/utils/publishHelpers";import { normalizePublicationRecord } from "src/utils/normalizeRecords";import { getPublicationType } from "src/utils/collectionHelpers";import { buildRecord, type PublicationType,} from "src/utils/buildPublicationRecord";import { isExternalLink } from "src/utils/externalPublicationLink";import { supabaseServerClient } from "supabase/serverClient";
export type PublishPagesResult = | { success: true; published: { entity: string; uri: string }[] } | { success: false; error: OAuthSessionError | { type: "other"; message: string }; };
const err = (message: string): PublishPagesResult => ({ success: false, error: { type: "other", message },});
// Content page routes are single-segment lowercase slugs, plus the reserved// home route "/".export const routeShape = /^\/[a-z0-9-]+$/;
// Publish a publication's draft leaflet in one go: page records, nav rows,// and theme, writing to `actorDid`'s PDS with their stored session. Callers// are responsible for having authorized `actorDid`. Re-running converges:// rkeys are stable per page entity, rows are upserts, and stale state is// re-diffed every time.export async function publishPublicationPages({ publication_uri, actorDid,}: { publication_uri: string; actorDid: string;}): Promise<PublishPagesResult> { const { data: publication } = await supabaseServerClient .from("publications") .select("uri, identity_did, record, draft_leaflet") .eq("uri", publication_uri) .single(); if (!publication || publication.identity_did !== actorDid) return err("Not the publication owner"); if (!publication.draft_leaflet) return err("This publication has no draft yet");
const { data: token } = await supabaseServerClient .from("permission_tokens") .select("root_entity") .eq("id", publication.draft_leaflet) .single(); if (!token) return err("Draft leaflet not found"); const root_entity = token.root_entity;
const { data: factData } = await supabaseServerClient.rpc("get_facts", { root: root_entity, }); const facts = (factData as unknown as Fact<Attribute>[]) || [];
const entries = readNavEntries(facts, root_entity); const contentPages = entries.filter((e) => !e.externalUrl); const externalLinks = entries.filter((e) => e.externalUrl);
// Validate the whole nav before writing anything. const errors: string[] = []; if (!contentPages.some((p) => p.route === "/")) errors.push("The publication needs a home page at /"); const seenRoutes = new Set<string>(); for (const page of contentPages) { const label = page.title || page.route || "untitled page"; if (!page.route) { errors.push(`"${label}" has no path`); continue; } if (page.route !== "/" && !routeShape.test(page.route)) { errors.push(`"${label}" has an invalid path (${page.route})`); continue; } if (seenRoutes.has(page.route)) errors.push(`Multiple pages share the path ${page.route}`); seenRoutes.add(page.route); } for (const link of externalLinks) { if (!isExternalLink(link.externalUrl)) errors.push( `"${link.title || "external link"}" has an invalid url (${link.externalUrl})`, ); } if (errors.length > 0) return err(errors.join(". "));
const sessionResult = await restoreOAuthSession(actorDid); if (!sessionResult.ok) { return { success: false, error: sessionResult.error }; } const credentialSession = sessionResult.value; const agent = new AtpBaseClient( credentialSession.fetchHandler.bind(credentialSession), );
const { data: existingRows } = await supabaseServerClient .from("publication_pages") .select("id, page_entity, record_uri") .eq("publication", publication_uri);
const hooks = makePublishUploadHooks(agent, credentialSession.did!);
const published: { entity: string; uri: string }[] = []; for (const page of contentPages) { const record = await leafletToPublicationPageRecord({ facts, root_entity, start_page: page.entity, publication_uri, path: page.route!, title: page.title, hooks, });
// Reuse the published page's rkey so the record updates in place and // stays stable across renames and re-routes. New pages get a tid. const existing = existingRows?.find((r) => r.page_entity === page.entity); const rkey = existing?.record_uri ? new AtUri(existing.record_uri).rkey : TID.nextStr(); const { data: putResult } = await agent.com.atproto.repo.putRecord({ rkey, repo: credentialSession.did!, collection: "pub.leaflet.publicationPage", record, validate: false, });
await supabaseServerClient.from("publication_pages").upsert( { publication: publication_uri, page_entity: page.entity, path: page.route, title: page.title, sort_order: page.position, record: record as unknown as Json, record_uri: putResult.uri, }, { onConflict: "publication,page_entity" }, );
published.push({ entity: page.entity, uri: putResult.uri }); }
// External link tabs have no record on the network; their url lives in `path`. if (externalLinks.length > 0) await supabaseServerClient.from("publication_pages").upsert( externalLinks.map((link) => ({ publication: publication_uri, page_entity: link.entity, path: link.externalUrl, title: link.title, sort_order: link.position, record: null, record_uri: null, })), { onConflict: "publication,page_entity" }, );
// Delete rows and PDS records for pages no longer in the draft, including // rows from before the single-draft-leaflet model (no page_entity at all). const draftEntities = new Set(entries.map((e) => e.entity)); const staleRows = (existingRows ?? []).filter( (r) => !r.page_entity || !draftEntities.has(r.page_entity), ); for (const row of staleRows) { if (!row.record_uri) continue; const uri = new AtUri(row.record_uri); await agent.com.atproto.repo.deleteRecord({ repo: credentialSession.did!, collection: uri.collection, rkey: uri.rkey, }); } if (staleRows.length > 0) await supabaseServerClient .from("publication_pages") .delete() .in( "id", staleRows.map((r) => r.id), );
// Fold the draft theme facts into the publication record (pub.leaflet theme // plus the derived basicTheme for site.standard publications). const theme = await extractThemeFromFacts(facts, root_entity, agent); const normalizedPub = normalizePublicationRecord(publication.record); const aturi = new AtUri(publication.uri); const publicationType = getPublicationType( aturi.collection, ) as PublicationType; const existingBasePath = normalizedPub?.url ? normalizedPub.url.replace(/^https?:\/\//, "") : undefined; const basicTheme = leafletThemeToBasicTheme(theme); const pubRecord = buildRecord( normalizedPub, existingBasePath, publicationType, { theme, ...(basicTheme && { basicTheme }), }, ); await agent.com.atproto.repo.putRecord({ repo: credentialSession.did!, rkey: aturi.rkey, record: pubRecord, collection: publicationType, validate: false, }); await supabaseServerClient .from("publications") .update({ record: pubRecord as Json }) .eq("uri", publication_uri);
// Bust the cached reader routes so edits to existing pages show up — without // this only brand-new (uncached) page paths would reflect the latest content. revalidatePublicationPaths(publication_uri, normalizedPub?.name, [ "", "/archive", ...contentPages .filter((p) => p.route && p.route !== "/") .map((p) => p.route as string), ]);
return { success: true, published };}