···33import { cookies } from "next/headers";
44import { supabaseServerClient } from "supabase/serverClient";
55import { cache } from "react";
66+import { deduplicateByUri } from "src/utils/deduplicateRecords";
67export const getIdentityData = cache(uncachedGetIdentityData);
78export async function uncachedGetIdentityData() {
89 let cookieStore = await cookies();
···4445 if (!auth_res?.data?.identities) return null;
4546 if (auth_res.data.identities.atp_did) {
4647 //I should create a relationship table so I can do this in the above query
4747- let { data: publications } = await supabaseServerClient
4848+ let { data: rawPublications } = await supabaseServerClient
4849 .from("publications")
4950 .select("*")
5051 .eq("identity_did", auth_res.data.identities.atp_did);
5252+ // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces
5353+ const publications = deduplicateByUri(rawPublications || []);
5154 return {
5255 ...auth_res.data.identities,
5353- publications: publications || [],
5656+ publications,
5457 };
5558 }
5659
+216-80
actions/publishToPublication.ts
···1111 PubLeafletBlocksText,
1212 PubLeafletBlocksUnorderedList,
1313 PubLeafletDocument,
1414+ SiteStandardDocument,
1515+ PubLeafletContent,
1416 PubLeafletPagesLinearDocument,
1517 PubLeafletPagesCanvas,
1618 PubLeafletRichtextFacet,
···4345import { Lock } from "src/utils/lock";
4446import type { PubLeafletPublication } from "lexicons/api";
4547import {
4848+ normalizeDocumentRecord,
4949+ type NormalizedDocument,
5050+} from "src/utils/normalizeRecords";
5151+import {
4652 ColorToRGB,
4753 ColorToRGBA,
4854} from "components/ThemeManager/colorToLexicons";
···5258 pingIdentityToUpdateNotification,
5359} from "src/notifications";
5460import { v7 } from "uuid";
6161+import {
6262+ isDocumentCollection,
6363+ isPublicationCollection,
6464+ getDocumentType,
6565+} from "src/utils/collectionHelpers";
55665667type PublishResult =
5768 | { success: true; rkey: string; record: PubLeafletDocument.Record }
···6677 tags,
6778 cover_image,
6879 entitiesToDelete,
8080+ publishedAt,
6981}: {
7082 root_entity: string;
7183 publication_uri?: string;
···7587 tags?: string[];
7688 cover_image?: string | null;
7789 entitiesToDelete?: string[];
9090+ publishedAt?: string;
7891}): Promise<PublishResult> {
7992 let identity = await getIdentityData();
8093 if (!identity || !identity.atp_did) {
···147160 credentialSession.did!,
148161 );
149162150150- let existingRecord =
151151- (draft?.documents?.data as PubLeafletDocument.Record | undefined) || {};
163163+ let existingRecord: Partial<PubLeafletDocument.Record> = {};
164164+ const normalizedDoc = normalizeDocumentRecord(draft?.documents?.data);
165165+ if (normalizedDoc) {
166166+ // When reading existing data, use normalized format to extract fields
167167+ // The theme is preserved in NormalizedDocument for backward compatibility
168168+ existingRecord = {
169169+ publishedAt: normalizedDoc.publishedAt,
170170+ title: normalizedDoc.title,
171171+ description: normalizedDoc.description,
172172+ tags: normalizedDoc.tags,
173173+ coverImage: normalizedDoc.coverImage,
174174+ theme: normalizedDoc.theme,
175175+ };
176176+ }
152177153178 // Extract theme for standalone documents (not for publications)
154179 let theme: PubLeafletPublication.Theme | undefined;
···173198 }
174199 }
175200176176- let record: PubLeafletDocument.Record = {
177177- publishedAt: new Date().toISOString(),
178178- ...existingRecord,
179179- $type: "pub.leaflet.document",
180180- author: credentialSession.did!,
181181- ...(publication_uri && { publication: publication_uri }),
182182- ...(theme && { theme }),
183183- title: title || "Untitled",
184184- description: description || "",
185185- ...(tags !== undefined && { tags }), // Include tags if provided (even if empty array to clear tags)
186186- ...(coverImageBlob && { coverImage: coverImageBlob }), // Include cover image if uploaded
187187- pages: pages.map((p) => {
188188- if (p.type === "canvas") {
189189- return {
190190- $type: "pub.leaflet.pages.canvas" as const,
191191- id: p.id,
192192- blocks: p.blocks as PubLeafletPagesCanvas.Block[],
193193- };
194194- } else {
195195- return {
196196- $type: "pub.leaflet.pages.linearDocument" as const,
197197- id: p.id,
198198- blocks: p.blocks as PubLeafletPagesLinearDocument.Block[],
199199- };
200200- }
201201- }),
202202- };
201201+ // Determine the collection to use - preserve existing schema if updating
202202+ const existingCollection = existingDocUri ? new AtUri(existingDocUri).collection : undefined;
203203+ const documentType = getDocumentType(existingCollection);
204204+205205+ // Build the pages array (used by both formats)
206206+ const pagesArray = pages.map((p) => {
207207+ if (p.type === "canvas") {
208208+ return {
209209+ $type: "pub.leaflet.pages.canvas" as const,
210210+ id: p.id,
211211+ blocks: p.blocks as PubLeafletPagesCanvas.Block[],
212212+ };
213213+ } else {
214214+ return {
215215+ $type: "pub.leaflet.pages.linearDocument" as const,
216216+ id: p.id,
217217+ blocks: p.blocks as PubLeafletPagesLinearDocument.Block[],
218218+ };
219219+ }
220220+ });
221221+222222+ // Determine the rkey early since we need it for the path field
223223+ const rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr();
224224+225225+ // Create record based on the document type
226226+ let record: PubLeafletDocument.Record | SiteStandardDocument.Record;
227227+228228+ if (documentType === "site.standard.document") {
229229+ // site.standard.document format
230230+ // For standalone docs, use HTTPS URL; for publication docs, use the publication AT-URI
231231+ const siteUri = publication_uri || `https://leaflet.pub/p/${credentialSession.did}`;
232232+233233+ record = {
234234+ $type: "site.standard.document",
235235+ title: title || "Untitled",
236236+ site: siteUri,
237237+ path: rkey,
238238+ publishedAt:
239239+ publishedAt || existingRecord.publishedAt || new Date().toISOString(),
240240+ ...(description && { description }),
241241+ ...(tags !== undefined && { tags }),
242242+ ...(coverImageBlob && { coverImage: coverImageBlob }),
243243+ // Include theme for standalone documents (not for publication documents)
244244+ ...(!publication_uri && theme && { theme }),
245245+ content: {
246246+ $type: "pub.leaflet.content" as const,
247247+ pages: pagesArray,
248248+ },
249249+ } satisfies SiteStandardDocument.Record;
250250+ } else {
251251+ // pub.leaflet.document format (legacy)
252252+ record = {
253253+ $type: "pub.leaflet.document",
254254+ author: credentialSession.did!,
255255+ ...(publication_uri && { publication: publication_uri }),
256256+ ...(theme && { theme }),
257257+ title: title || "Untitled",
258258+ description: description || "",
259259+ ...(tags !== undefined && { tags }),
260260+ ...(coverImageBlob && { coverImage: coverImageBlob }),
261261+ pages: pagesArray,
262262+ publishedAt:
263263+ publishedAt || existingRecord.publishedAt || new Date().toISOString(),
264264+ } satisfies PubLeafletDocument.Record;
265265+ }
203266204204- // Keep the same rkey if updating an existing document
205205- let rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr();
206267 let { data: result } = await agent.com.atproto.repo.putRecord({
207268 rkey,
208269 repo: credentialSession.did!,
···214275 // Optimistically create database entries
215276 await supabaseServerClient.from("documents").upsert({
216277 uri: result.uri,
217217- data: record as Json,
278278+ data: record as unknown as Json,
218279 });
219280220281 if (publication_uri) {
···836897 */
837898async function createMentionNotifications(
838899 documentUri: string,
839839- record: PubLeafletDocument.Record,
900900+ record: PubLeafletDocument.Record | SiteStandardDocument.Record,
840901 authorDid: string,
841902) {
842903 const mentionedDids = new Set<string>();
843904 const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI
844905 const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI
906906+ const embeddedBskyPosts = new Map<string, string>(); // Map of author DID -> post URI
845907846846- // Extract mentions from all text blocks in all pages
847847- for (const page of record.pages) {
848848- if (page.$type === "pub.leaflet.pages.linearDocument") {
849849- const linearPage = page as PubLeafletPagesLinearDocument.Main;
850850- for (const blockWrapper of linearPage.blocks) {
851851- const block = blockWrapper.block;
852852- if (block.$type === "pub.leaflet.blocks.text") {
853853- const textBlock = block as PubLeafletBlocksText.Main;
854854- if (textBlock.facets) {
855855- for (const facet of textBlock.facets) {
856856- for (const feature of facet.features) {
857857- // Check for DID mentions
858858- if (PubLeafletRichtextFacet.isDidMention(feature)) {
859859- if (feature.did !== authorDid) {
860860- mentionedDids.add(feature.did);
861861- }
862862- }
863863- // Check for AT URI mentions (publications and documents)
864864- if (PubLeafletRichtextFacet.isAtMention(feature)) {
865865- const uri = new AtUri(feature.atURI);
908908+ // Extract pages from either format
909909+ let pages: PubLeafletContent.Main["pages"] | undefined;
910910+ if (record.$type === "site.standard.document") {
911911+ const content = record.content;
912912+ if (content && PubLeafletContent.isMain(content)) {
913913+ pages = content.pages;
914914+ }
915915+ } else {
916916+ pages = record.pages;
917917+ }
918918+919919+ if (!pages) return;
920920+921921+ // Helper to extract blocks from all pages (both linear and canvas)
922922+ function getAllBlocks(pages: PubLeafletContent.Main["pages"]) {
923923+ const blocks: (
924924+ | PubLeafletPagesLinearDocument.Block["block"]
925925+ | PubLeafletPagesCanvas.Block["block"]
926926+ )[] = [];
927927+ for (const page of pages) {
928928+ if (page.$type === "pub.leaflet.pages.linearDocument") {
929929+ const linearPage = page as PubLeafletPagesLinearDocument.Main;
930930+ for (const blockWrapper of linearPage.blocks) {
931931+ blocks.push(blockWrapper.block);
932932+ }
933933+ } else if (page.$type === "pub.leaflet.pages.canvas") {
934934+ const canvasPage = page as PubLeafletPagesCanvas.Main;
935935+ for (const blockWrapper of canvasPage.blocks) {
936936+ blocks.push(blockWrapper.block);
937937+ }
938938+ }
939939+ }
940940+ return blocks;
941941+ }
942942+943943+ const allBlocks = getAllBlocks(pages);
944944+945945+ // Extract mentions from all text blocks and embedded Bluesky posts
946946+ for (const block of allBlocks) {
947947+ // Check for embedded Bluesky posts
948948+ if (PubLeafletBlocksBskyPost.isMain(block)) {
949949+ const bskyPostUri = block.postRef.uri;
950950+ // Extract the author DID from the post URI (at://did:xxx/app.bsky.feed.post/xxx)
951951+ const postAuthorDid = new AtUri(bskyPostUri).host;
952952+ if (postAuthorDid !== authorDid) {
953953+ embeddedBskyPosts.set(postAuthorDid, bskyPostUri);
954954+ }
955955+ }
956956+957957+ // Check for text blocks with mentions
958958+ if (block.$type === "pub.leaflet.blocks.text") {
959959+ const textBlock = block as PubLeafletBlocksText.Main;
960960+ if (textBlock.facets) {
961961+ for (const facet of textBlock.facets) {
962962+ for (const feature of facet.features) {
963963+ // Check for DID mentions
964964+ if (PubLeafletRichtextFacet.isDidMention(feature)) {
965965+ if (feature.did !== authorDid) {
966966+ mentionedDids.add(feature.did);
967967+ }
968968+ }
969969+ // Check for AT URI mentions (publications and documents)
970970+ if (PubLeafletRichtextFacet.isAtMention(feature)) {
971971+ const uri = new AtUri(feature.atURI);
866972867867- if (uri.collection === "pub.leaflet.publication") {
868868- // Get the publication owner's DID
869869- const { data: publication } = await supabaseServerClient
870870- .from("publications")
871871- .select("identity_did")
872872- .eq("uri", feature.atURI)
873873- .single();
973973+ if (isPublicationCollection(uri.collection)) {
974974+ // Get the publication owner's DID
975975+ const { data: publication } = await supabaseServerClient
976976+ .from("publications")
977977+ .select("identity_did")
978978+ .eq("uri", feature.atURI)
979979+ .single();
874980875875- if (publication && publication.identity_did !== authorDid) {
876876- mentionedPublications.set(
877877- publication.identity_did,
878878- feature.atURI,
879879- );
880880- }
881881- } else if (uri.collection === "pub.leaflet.document") {
882882- // Get the document owner's DID
883883- const { data: document } = await supabaseServerClient
884884- .from("documents")
885885- .select("uri, data")
886886- .eq("uri", feature.atURI)
887887- .single();
981981+ if (publication && publication.identity_did !== authorDid) {
982982+ mentionedPublications.set(
983983+ publication.identity_did,
984984+ feature.atURI,
985985+ );
986986+ }
987987+ } else if (isDocumentCollection(uri.collection)) {
988988+ // Get the document owner's DID
989989+ const { data: document } = await supabaseServerClient
990990+ .from("documents")
991991+ .select("uri, data")
992992+ .eq("uri", feature.atURI)
993993+ .single();
888994889889- if (document) {
890890- const docRecord =
891891- document.data as PubLeafletDocument.Record;
892892- if (docRecord.author !== authorDid) {
893893- mentionedDocuments.set(docRecord.author, feature.atURI);
894894- }
895895- }
995995+ if (document) {
996996+ const normalizedMentionedDoc = normalizeDocumentRecord(
997997+ document.data,
998998+ );
999999+ // Get the author from the document URI (the DID is the host part)
10001000+ const mentionedUri = new AtUri(feature.atURI);
10011001+ const docAuthor = mentionedUri.host;
10021002+ if (normalizedMentionedDoc && docAuthor !== authorDid) {
10031003+ mentionedDocuments.set(docAuthor, feature.atURI);
8961004 }
8971005 }
8981006 }
···9481056 };
9491057 await supabaseServerClient.from("notifications").insert(notification);
9501058 await pingIdentityToUpdateNotification(recipientDid);
10591059+ }
10601060+10611061+ // Create notifications for embedded Bluesky posts (only if the author has a Leaflet account)
10621062+ if (embeddedBskyPosts.size > 0) {
10631063+ // Check which of the Bluesky post authors have Leaflet accounts
10641064+ const { data: identities } = await supabaseServerClient
10651065+ .from("identities")
10661066+ .select("atp_did")
10671067+ .in("atp_did", Array.from(embeddedBskyPosts.keys()));
10681068+10691069+ const leafletUserDids = new Set(identities?.map((i) => i.atp_did) ?? []);
10701070+10711071+ for (const [postAuthorDid, bskyPostUri] of embeddedBskyPosts) {
10721072+ // Only notify if the post author has a Leaflet account
10731073+ if (leafletUserDids.has(postAuthorDid)) {
10741074+ const notification: Notification = {
10751075+ id: v7(),
10761076+ recipient: postAuthorDid,
10771077+ data: {
10781078+ type: "bsky_post_embed",
10791079+ document_uri: documentUri,
10801080+ bsky_post_uri: bskyPostUri,
10811081+ },
10821082+ };
10831083+ await supabaseServerClient.from("notifications").insert(notification);
10841084+ await pingIdentityToUpdateNotification(postAuthorDid);
10851085+ }
10861086+ }
9511087 }
9521088}
+3-5
app/(home-pages)/discover/PubListing.tsx
···66import { Separator } from "components/Layout";
77import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
88import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider";
99-import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api";
109import { blobRefToSrc } from "src/utils/blobRefToSrc";
1110import { timeAgo } from "src/utils/timeAgo";
1212-import { Json } from "supabase/database.types";
13111412export const PubListing = (
1513 props: PublicationSubscription & {
1614 resizeHeight?: boolean;
1715 },
1816) => {
1919- let record = props.record as PubLeafletPublication.Record;
2020- let theme = usePubTheme(record.theme);
1717+ let record = props.record;
1818+ let theme = usePubTheme(record?.theme);
2119 let backgroundImage = record?.theme?.backgroundImage?.image?.ref
2220 ? blobRefToSrc(
2321 record?.theme?.backgroundImage?.image?.ref,
···3129 return (
3230 <BaseThemeProvider {...theme} local>
3331 <a
3434- href={`https://${record.base_path}`}
3232+ href={record.url}
3533 className={`no-underline! flex flex-row gap-2
3634 bg-bg-leaflet
3735 border border-border-light rounded-lg
···77 updatePublication,
88 updatePublicationBasePath,
99} from "./updatePublication";
1010-import { usePublicationData } from "../[did]/[publication]/dashboard/PublicationSWRProvider";
1111-import { PubLeafletPublication } from "lexicons/api";
1010+import {
1111+ usePublicationData,
1212+ useNormalizedPublicationRecord,
1313+} from "../[did]/[publication]/dashboard/PublicationSWRProvider";
1214import useSWR, { mutate } from "swr";
1315import { AddTiny } from "components/Icons/AddTiny";
1416import { DotLoader } from "components/utils/DotLoader";
···3032}) => {
3133 let { data } = usePublicationData();
3234 let { publication: pubData } = data || {};
3333- let record = pubData?.record as PubLeafletPublication.Record;
3535+ let record = useNormalizedPublicationRecord();
3436 let [formState, setFormState] = useState<"normal" | "loading">("normal");
35373638 let [nameValue, setNameValue] = useState(record?.name || "");
···6062 let [iconPreview, setIconPreview] = useState<string | null>(null);
6163 let fileInputRef = useRef<HTMLInputElement>(null);
6264 useEffect(() => {
6363- if (!pubData || !pubData.record) return;
6565+ if (!pubData || !pubData.record || !record) return;
6466 setNameValue(record.name);
6567 setDescriptionValue(record.description || "");
6668 if (record.icon)
6769 setIconPreview(
6870 `/api/atproto_images?did=${pubData.identity_did}&cid=${(record.icon.ref as unknown as { $link: string })["$link"]}`,
6971 );
7070- }, [pubData]);
7272+ }, [pubData, record]);
7173 let toast = useToaster();
72747375 return (
···202204export function CustomDomainForm() {
203205 let { data } = usePublicationData();
204206 let { publication: pubData } = data || {};
207207+ let record = useNormalizedPublicationRecord();
205208 if (!pubData) return null;
206206- let record = pubData?.record as PubLeafletPublication.Record;
209209+ if (!record) return null;
207210 let [state, setState] = useState<
208211 | { type: "default" }
209212 | { type: "addDomain" }
···243246 <Domain
244247 domain={d.domain}
245248 publication_uri={pubData.uri}
246246- base_path={record.base_path || ""}
249249+ base_path={record.url.replace(/^https?:\/\//, "")}
247250 setDomain={(v) => {
248251 setState({
249252 type: "domainSettings",
+54-21
app/lish/createPub/createPublication.ts
···11"use server";
22import { TID } from "@atproto/common";
33-import { AtpBaseClient, PubLeafletPublication } from "lexicons/api";
33+import {
44+ AtpBaseClient,
55+ PubLeafletPublication,
66+ SiteStandardPublication,
77+} from "lexicons/api";
48import {
59 restoreOAuthSession,
610 OAuthSessionError,
711} from "src/atproto-oauth";
812import { getIdentityData } from "actions/getIdentityData";
913import { supabaseServerClient } from "supabase/serverClient";
1010-import { Un$Typed } from "@atproto/api";
1114import { Json } from "supabase/database.types";
1215import { Vercel } from "@vercel/sdk";
1316import { isProductionDomain } from "src/utils/isProductionDeployment";
1417import { string } from "zod";
1818+import { getPublicationType } from "src/utils/collectionHelpers";
1919+import { PubThemeDefaultsRGB } from "components/ThemeManager/themeDefaults";
15201621const VERCEL_TOKEN = process.env.VERCEL_TOKEN;
1722const vercel = new Vercel({
···6469 let agent = new AtpBaseClient(
6570 credentialSession.fetchHandler.bind(credentialSession),
6671 );
6767- let record: Un$Typed<PubLeafletPublication.Record> = {
6868- name,
6969- base_path: domain,
7070- preferences,
7171- };
7272+7373+ // Use site.standard.publication for new publications
7474+ const publicationType = getPublicationType();
7575+ const url = `https://${domain}`;
72767373- if (description) {
7474- record.description = description;
7575- }
7777+ // Build record based on publication type
7878+ let record: SiteStandardPublication.Record | PubLeafletPublication.Record;
7979+ let iconBlob: Awaited<ReturnType<typeof agent.com.atproto.repo.uploadBlob>>["data"]["blob"] | undefined;
76807781 // Upload the icon if provided
7882 if (iconFile && iconFile.size > 0) {
···8185 new Uint8Array(buffer),
8286 { encoding: iconFile.type },
8387 );
8888+ iconBlob = uploadResult.data.blob;
8989+ }
84908585- if (uploadResult.data.blob) {
8686- record.icon = uploadResult.data.blob;
8787- }
9191+ if (publicationType === "site.standard.publication") {
9292+ record = {
9393+ $type: "site.standard.publication",
9494+ name,
9595+ url,
9696+ ...(description && { description }),
9797+ ...(iconBlob && { icon: iconBlob }),
9898+ basicTheme: {
9999+ $type: "site.standard.theme.basic",
100100+ background: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.background },
101101+ foreground: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.foreground },
102102+ accent: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.accent },
103103+ accentForeground: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.accentForeground },
104104+ },
105105+ preferences: {
106106+ showInDiscover: preferences.showInDiscover,
107107+ showComments: preferences.showComments,
108108+ showMentions: preferences.showMentions,
109109+ showPrevNext: preferences.showPrevNext,
110110+ },
111111+ } satisfies SiteStandardPublication.Record;
112112+ } else {
113113+ record = {
114114+ $type: "pub.leaflet.publication",
115115+ name,
116116+ base_path: domain,
117117+ ...(description && { description }),
118118+ ...(iconBlob && { icon: iconBlob }),
119119+ preferences,
120120+ } satisfies PubLeafletPublication.Record;
88121 }
891229090- let result = await agent.pub.leaflet.publication.create(
9191- { repo: credentialSession.did!, rkey: TID.nextStr(), validate: false },
123123+ let { data: result } = await agent.com.atproto.repo.putRecord({
124124+ repo: credentialSession.did!,
125125+ rkey: TID.nextStr(),
126126+ collection: publicationType,
92127 record,
9393- );
128128+ validate: false,
129129+ });
9413095131 //optimistically write to our db!
96132 let { data: publication } = await supabaseServerClient
···98134 .upsert({
99135 uri: result.uri,
100136 identity_did: credentialSession.did!,
101101- name: record.name,
102102- record: {
103103- ...record,
104104- $type: "pub.leaflet.publication",
105105- } as unknown as Json,
137137+ name,
138138+ record: record as unknown as Json,
106139 })
107140 .select()
108141 .single();
+34-9
app/lish/createPub/getPublicationURL.ts
···22import { PubLeafletPublication } from "lexicons/api";
33import { isProductionDomain } from "src/utils/isProductionDeployment";
44import { Json } from "supabase/database.types";
55+import {
66+ normalizePublicationRecord,
77+ isLeafletPublication,
88+ type NormalizedPublication,
99+} from "src/utils/normalizeRecords";
51066-export function getPublicationURL(pub: { uri: string; record: Json }) {
77- let record = pub.record as PubLeafletPublication.Record;
88- if (isProductionDomain() && record?.base_path)
99- return `https://${record.base_path}`;
1010- else return getBasePublicationURL(pub);
1111+type PublicationInput =
1212+ | { uri: string; record: Json | NormalizedPublication | null }
1313+ | { uri: string; record: unknown };
1414+1515+/**
1616+ * Gets the public URL for a publication.
1717+ * Works with both pub.leaflet.publication and site.standard.publication records.
1818+ */
1919+export function getPublicationURL(pub: PublicationInput): string {
2020+ const normalized = normalizePublicationRecord(pub.record);
2121+2222+ // If we have a normalized record with a URL (site.standard format), use it
2323+ if (normalized?.url && isProductionDomain()) {
2424+ return normalized.url;
2525+ }
2626+2727+ // Fall back to checking raw record for legacy base_path
2828+ if (isLeafletPublication(pub.record) && pub.record.base_path && isProductionDomain()) {
2929+ return `https://${pub.record.base_path}`;
3030+ }
3131+3232+ return getBasePublicationURL(pub);
1133}
12341313-export function getBasePublicationURL(pub: { uri: string; record: Json }) {
1414- let record = pub.record as PubLeafletPublication.Record;
1515- let aturi = new AtUri(pub.uri);
1616- return `/lish/${aturi.host}/${encodeURIComponent(aturi.rkey || record?.name)}`;
3535+export function getBasePublicationURL(pub: PublicationInput): string {
3636+ const normalized = normalizePublicationRecord(pub.record);
3737+ const aturi = new AtUri(pub.uri);
3838+3939+ // Use normalized name if available, fall back to rkey
4040+ const name = normalized?.name || aturi.rkey;
4141+ return `/lish/${aturi.host}/${encodeURIComponent(name || "")}`;
1742}
···22import { DidResolver } from "@atproto/identity";
33import { parseReqNsid, verifyJwt } from "@atproto/xrpc-server";
44import { supabaseServerClient } from "supabase/serverClient";
55-import { PubLeafletDocument } from "lexicons/api";
55+import {
66+ normalizeDocumentRecord,
77+ type NormalizedDocument,
88+} from "src/utils/normalizeRecords";
69710const serviceDid = "did:web:leaflet.pub:lish:feeds";
811export async function GET(
···3437 let posts = pub.publications?.documents_in_publications || [];
3538 return posts.flatMap((p) => {
3639 if (!p.documents?.data) return [];
3737- let record = p.documents.data as PubLeafletDocument.Record;
3838- if (!record.postRef) return [];
3939- return { post: record.postRef.uri };
4040+ const normalizedDoc = normalizeDocumentRecord(p.documents.data, p.documents.uri);
4141+ if (!normalizedDoc?.bskyPostRef) return [];
4242+ return { post: normalizedDoc.bskyPostRef.uri };
4043 });
4144 }),
4245 ],
+9-5
app/lish/subscribeToPublication.ts
···4848 let agent = new AtpBaseClient(
4949 credentialSession.fetchHandler.bind(credentialSession),
5050 );
5151- let record = await agent.pub.leaflet.graph.subscription.create(
5151+ let record = await agent.site.standard.graph.subscription.create(
5252 { repo: credentialSession.did!, rkey: TID.nextStr() },
5353 {
5454 publication,
···140140 .eq("publication", publication)
141141 .single();
142142 if (!existingSubscription) return { success: true };
143143- await agent.pub.leaflet.graph.subscription.delete({
144144- repo: credentialSession.did!,
145145- rkey: new AtUri(existingSubscription.uri).rkey,
146146- });
143143+144144+ // Delete from both collections (old and new schema) - one or both may exist
145145+ let rkey = new AtUri(existingSubscription.uri).rkey;
146146+ await Promise.all([
147147+ agent.pub.leaflet.graph.subscription.delete({ repo: credentialSession.did!, rkey }).catch(() => {}),
148148+ agent.site.standard.graph.subscription.delete({ repo: credentialSession.did!, rkey }).catch(() => {}),
149149+ ]);
150150+147151 await supabaseServerClient
148152 .from("publication_subscriptions")
149153 .delete()
+26-24
app/lish/uri/[uri]/route.ts
···11import { NextRequest, NextResponse } from "next/server";
22import { AtUri } from "@atproto/api";
33import { supabaseServerClient } from "supabase/serverClient";
44-import { PubLeafletPublication } from "lexicons/api";
44+import {
55+ normalizePublicationRecord,
66+ type NormalizedPublication,
77+} from "src/utils/normalizeRecords";
88+import {
99+ isDocumentCollection,
1010+ isPublicationCollection,
1111+} from "src/utils/collectionHelpers";
512613/**
714 * Redirect route for AT URIs (publications and documents)
···1623 const atUriString = decodeURIComponent(uriParam);
1724 const uri = new AtUri(atUriString);
18251919- if (uri.collection === "pub.leaflet.publication") {
2626+ if (isPublicationCollection(uri.collection)) {
2027 // Get the publication record to retrieve base_path
2128 const { data: publication } = await supabaseServerClient
2229 .from("publications")
···2835 return new NextResponse("Publication not found", { status: 404 });
2936 }
30373131- const record = publication.record as PubLeafletPublication.Record;
3232- const basePath = record.base_path;
3333-3434- if (!basePath) {
3535- return new NextResponse("Publication has no base_path", {
3838+ const normalizedPub = normalizePublicationRecord(publication.record);
3939+ if (!normalizedPub?.url) {
4040+ return new NextResponse("Publication has no url", {
3641 status: 404,
3742 });
3843 }
39444040- // Redirect to the publication's hosted domain (temporary redirect since base_path can change)
4141- return NextResponse.redirect(basePath, 307);
4242- } else if (uri.collection === "pub.leaflet.document") {
4545+ // Redirect to the publication's hosted domain (temporary redirect since url can change)
4646+ return NextResponse.redirect(normalizedPub.url, 307);
4747+ } else if (isDocumentCollection(uri.collection)) {
4348 // Document link - need to find the publication it belongs to
4449 const { data: docInPub } = await supabaseServerClient
4550 .from("documents_in_publications")
···49545055 if (docInPub?.publication && docInPub.publications) {
5156 // Document is in a publication - redirect to domain/rkey
5252- const record = docInPub.publications
5353- .record as PubLeafletPublication.Record;
5454- const basePath = record.base_path;
5757+ const normalizedPub = normalizePublicationRecord(
5858+ docInPub.publications.record,
5959+ );
55605656- if (!basePath) {
5757- return new NextResponse("Publication has no base_path", {
6161+ if (!normalizedPub?.url) {
6262+ return new NextResponse("Publication has no url", {
5863 status: 404,
5964 });
6065 }
61666262- // Ensure basePath ends without trailing slash
6363- const cleanBasePath = basePath.endsWith("/")
6464- ? basePath.slice(0, -1)
6565- : basePath;
6767+ // Ensure url ends without trailing slash
6868+ const cleanUrl = normalizedPub.url.endsWith("/")
6969+ ? normalizedPub.url.slice(0, -1)
7070+ : normalizedPub.url;
66716767- // Redirect to the document on the publication's domain (temporary redirect since base_path can change)
6868- return NextResponse.redirect(
6969- `https://${cleanBasePath}/${uri.rkey}`,
7070- 307,
7171- );
7272+ // Redirect to the document on the publication's domain (temporary redirect since url can change)
7373+ return NextResponse.redirect(`${cleanUrl}/${uri.rkey}`, 307);
7274 }
73757476 // If not in a publication, check if it's a standalone document
+9-8
app/p/[didOrHandle]/[rkey]/opengraph-image.ts
···11import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage";
22import { supabaseServerClient } from "supabase/serverClient";
33-import { AtUri } from "@atproto/syntax";
44-import { ids } from "lexicons/api/lexicons";
55-import { PubLeafletDocument } from "lexicons/api";
63import { jsonToLex } from "@atproto/lexicon";
74import { idResolver } from "app/(home-pages)/reader/idResolver";
85import { fetchAtprotoBlob } from "app/api/atproto_images/route";
66+import { normalizeDocumentRecord } from "src/utils/normalizeRecords";
77+import { documentUriFilter } from "src/utils/uriHelpers";
98109export const revalidate = 60;
1110···28272928 if (did) {
3029 // Try to get the document's cover image
3131- let { data: document } = await supabaseServerClient
3030+ let { data: documents } = await supabaseServerClient
3231 .from("documents")
3332 .select("data")
3434- .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey).toString())
3535- .single();
3333+ .or(documentUriFilter(did, params.rkey))
3434+ .order("uri", { ascending: false })
3535+ .limit(1);
3636+ let document = documents?.[0];
36373738 if (document) {
3838- let docRecord = jsonToLex(document.data) as PubLeafletDocument.Record;
3939- if (docRecord.coverImage) {
3939+ const docRecord = normalizeDocumentRecord(jsonToLex(document.data));
4040+ if (docRecord?.coverImage) {
4041 try {
4142 // Get CID from the blob ref (handle both serialized and hydrated forms)
4243 let cid =
+11-15
app/p/[didOrHandle]/[rkey]/page.tsx
···11import { supabaseServerClient } from "supabase/serverClient";
22-import { AtUri } from "@atproto/syntax";
33-import { ids } from "lexicons/api/lexicons";
44-import { PubLeafletDocument } from "lexicons/api";
52import { Metadata } from "next";
63import { idResolver } from "app/(home-pages)/reader/idResolver";
74import { DocumentPageRenderer } from "app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer";
85import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout";
66+import { normalizeDocumentRecord } from "src/utils/normalizeRecords";
77+import { documentUriFilter } from "src/utils/uriHelpers";
98109export async function generateMetadata(props: {
1110 params: Promise<{ didOrHandle: string; rkey: string }>;
···2423 }
2524 }
26252727- let { data: document } = await supabaseServerClient
2626+ let { data: documents } = await supabaseServerClient
2827 .from("documents")
2929- .select("*, documents_in_publications(publications(*))")
3030- .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey))
3131- .single();
2828+ .select("*")
2929+ .or(documentUriFilter(did, params.rkey))
3030+ .order("uri", { ascending: false })
3131+ .limit(1);
3232+ let document = documents?.[0];
32333334 if (!document) return { title: "404" };
34353535- let docRecord = document.data as PubLeafletDocument.Record;
3636-3737- // For documents in publications, include publication name
3838- let publicationName =
3939- document.documents_in_publications[0]?.publications?.name;
3636+ const docRecord = normalizeDocumentRecord(document.data);
3737+ if (!docRecord) return { title: "404" };
40384139 return {
4240 icons: {
···4543 url: document.uri,
4644 },
4745 },
4848- title: publicationName
4949- ? `${docRecord.title} - ${publicationName}`
5050- : docRecord.title,
4646+ title: docRecord.title,
5147 description: docRecord?.description || "",
5248 };
5349}
+98
appview/index.ts
···1111 PubLeafletComment,
1212 PubLeafletPollVote,
1313 PubLeafletPollDefinition,
1414+ SiteStandardDocument,
1515+ SiteStandardPublication,
1616+ SiteStandardGraphSubscription,
1417} from "lexicons/api";
1518import {
1619 AppBskyEmbedExternal,
···4750 ids.PubLeafletPollDefinition,
4851 // ids.AppBskyActorProfile,
4952 "app.bsky.feed.post",
5353+ ids.SiteStandardDocument,
5454+ ids.SiteStandardPublication,
5555+ ids.SiteStandardGraphSubscription,
5056 ],
5157 handleEvent,
5258 onError: (err) => {
···207213 if (evt.collection === ids.PubLeafletGraphSubscription) {
208214 if (evt.event === "create" || evt.event === "update") {
209215 let record = PubLeafletGraphSubscription.validateRecord(evt.record);
216216+ if (!record.success) return;
217217+ await supabase
218218+ .from("identities")
219219+ .upsert({ atp_did: evt.did }, { onConflict: "atp_did" });
220220+ await supabase.from("publication_subscriptions").upsert({
221221+ uri: evt.uri.toString(),
222222+ identity: evt.did,
223223+ publication: record.value.publication,
224224+ record: record.value as Json,
225225+ });
226226+ }
227227+ if (evt.event === "delete") {
228228+ await supabase
229229+ .from("publication_subscriptions")
230230+ .delete()
231231+ .eq("uri", evt.uri.toString());
232232+ }
233233+ }
234234+ // site.standard.document records go into the main "documents" table
235235+ // The normalization layer handles reading both pub.leaflet and site.standard formats
236236+ if (evt.collection === ids.SiteStandardDocument) {
237237+ if (evt.event === "create" || evt.event === "update") {
238238+ let record = SiteStandardDocument.validateRecord(evt.record);
239239+ if (!record.success) {
240240+ console.log(record.error);
241241+ return;
242242+ }
243243+ let docResult = await supabase.from("documents").upsert({
244244+ uri: evt.uri.toString(),
245245+ data: record.value as Json,
246246+ });
247247+ if (docResult.error) console.log(docResult.error);
248248+249249+ // site.standard.document uses "site" field to reference the publication
250250+ // For documents in publications, site is an AT-URI (at://did:plc:xxx/site.standard.publication/rkey)
251251+ // For standalone documents, site is an HTTPS URL (https://leaflet.pub/p/did:plc:xxx)
252252+ // Only link to publications table for AT-URI sites
253253+ if (record.value.site && record.value.site.startsWith("at://")) {
254254+ let siteURI = new AtUri(record.value.site);
255255+256256+ if (siteURI.host !== evt.uri.host) {
257257+ console.log("Unauthorized to create document in site!");
258258+ return;
259259+ }
260260+ let docInPublicationResult = await supabase
261261+ .from("documents_in_publications")
262262+ .upsert({
263263+ publication: record.value.site,
264264+ document: evt.uri.toString(),
265265+ });
266266+ await supabase
267267+ .from("documents_in_publications")
268268+ .delete()
269269+ .neq("publication", record.value.site)
270270+ .eq("document", evt.uri.toString());
271271+272272+ if (docInPublicationResult.error)
273273+ console.log(docInPublicationResult.error);
274274+ }
275275+ }
276276+ if (evt.event === "delete") {
277277+ await supabase.from("documents").delete().eq("uri", evt.uri.toString());
278278+ }
279279+ }
280280+281281+ // site.standard.publication records go into the main "publications" table
282282+ if (evt.collection === ids.SiteStandardPublication) {
283283+ if (evt.event === "create" || evt.event === "update") {
284284+ let record = SiteStandardPublication.validateRecord(evt.record);
285285+ if (!record.success) return;
286286+ await supabase
287287+ .from("identities")
288288+ .upsert({ atp_did: evt.did }, { onConflict: "atp_did" });
289289+ await supabase.from("publications").upsert({
290290+ uri: evt.uri.toString(),
291291+ identity_did: evt.did,
292292+ name: record.value.name,
293293+ record: record.value as Json,
294294+ });
295295+ }
296296+ if (evt.event === "delete") {
297297+ await supabase
298298+ .from("publications")
299299+ .delete()
300300+ .eq("uri", evt.uri.toString());
301301+ }
302302+ }
303303+304304+ // site.standard.graph.subscription records go into the main "publication_subscriptions" table
305305+ if (evt.collection === ids.SiteStandardGraphSubscription) {
306306+ if (evt.event === "create" || evt.event === "update") {
307307+ let record = SiteStandardGraphSubscription.validateRecord(evt.record);
210308 if (!record.success) return;
211309 await supabase
212310 .from("identities")
+7-4
components/ActionBar/Publications.tsx
···55import { theme } from "tailwind.config";
66import { getBasePublicationURL } from "app/lish/createPub/getPublicationURL";
77import { Json } from "supabase/database.types";
88-import { PubLeafletPublication } from "lexicons/api";
98import { AtUri } from "@atproto/syntax";
109import { ActionButton } from "./ActionButton";
1010+import {
1111+ normalizePublicationRecord,
1212+ type NormalizedPublication,
1313+} from "src/utils/normalizeRecords";
1114import { SpeedyLink } from "components/SpeedyLink";
1215import { PublishSmall } from "components/Icons/PublishSmall";
1316import { Popover } from "components/Popover";
···8588 record: Json;
8689 current?: boolean;
8790}) => {
8888- let record = props.record as PubLeafletPublication.Record | null;
9191+ let record = normalizePublicationRecord(props.record);
8992 if (!record) return;
90939194 return (
···181184};
182185183186export const PubIcon = (props: {
184184- record: PubLeafletPublication.Record;
187187+ record: NormalizedPublication | null;
185188 uri: string;
186189 small?: boolean;
187190 large?: boolean;
188191 className?: string;
189192}) => {
190190- if (!props.record) return;
193193+ if (!props.record) return null;
191194192195 let iconSizeClassName = `${props.small ? "w-4 h-4" : props.large ? "w-12 h-12" : "w-6 h-6"} rounded-full`;
193196
+6-2
components/AtMentionLink.tsx
···11import { AtUri } from "@atproto/api";
22import { atUriToUrl } from "src/utils/mentionUtils";
33+import {
44+ isDocumentCollection,
55+ isPublicationCollection,
66+} from "src/utils/collectionHelpers";
3748/**
59 * Component for rendering at-uri mentions (publications and documents) as clickable links.
···1620 className?: string;
1721}) {
1822 const aturi = new AtUri(atURI);
1919- const isPublication = aturi.collection === "pub.leaflet.publication";
2020- const isDocument = aturi.collection === "pub.leaflet.document";
2323+ const isPublication = isPublicationCollection(aturi.collection);
2424+ const isDocument = isDocumentCollection(aturi.collection);
21252226 // Show publication icon if available
2327 const icon =
···1111import { useLeafletPublicationData } from "components/PageSWRDataProvider";
1212import {
1313 PubLeafletBlocksPoll,
1414- PubLeafletDocument,
1514 PubLeafletPagesLinearDocument,
1615} from "lexicons/api";
1616+import { getDocumentPages } from "src/utils/normalizeRecords";
1717import { ids } from "lexicons/api/lexicons";
18181919/**
···3333 );
3434 // Check if this poll has been published in a publication document
3535 const isPublished = useMemo(() => {
3636- if (!publicationData?.documents?.data) return false;
3636+ if (!normalizedDocument) return false;
37373838- const docRecord = publicationData.documents
3939- .data as PubLeafletDocument.Record;
3838+ const pages = getDocumentPages(normalizedDocument);
3939+ if (!pages) return false;
40404141 // Search through all pages and blocks to find if this poll entity has been published
4242- for (const page of docRecord.pages || []) {
4242+ for (const page of pages) {
4343 if (page.$type === "pub.leaflet.pages.linearDocument") {
4444 const linearPage = page as PubLeafletPagesLinearDocument.Main;
4545 for (const blockWrapper of linearPage.blocks || []) {
···5555 }
5656 }
5757 return false;
5858- }, [publicationData, props.entityID]);
5858+ }, [normalizedDocument, props.entityID]);
59596060 return (
6161 <BlockLayout
+8-4
components/Blocks/TextBlock/schema.ts
···22import { Schema, Node, MarkSpec, NodeSpec } from "prosemirror-model";
33import { marks } from "prosemirror-schema-basic";
44import { theme } from "tailwind.config";
55+import {
66+ isDocumentCollection,
77+ isPublicationCollection,
88+} from "src/utils/collectionHelpers";
59610let baseSchema = {
711 marks: {
···149153 // components/AtMentionLink.tsx. If you update one, update the other.
150154 let className = "atMention mention";
151155 let aturi = new AtUri(node.attrs.atURI);
152152- if (aturi.collection === "pub.leaflet.publication")
156156+ if (isPublicationCollection(aturi.collection))
153157 className += " font-bold";
154154- if (aturi.collection === "pub.leaflet.document") className += " italic";
158158+ if (isDocumentCollection(aturi.collection)) className += " italic";
155159156160 // For publications and documents, show icon
157161 if (
158158- aturi.collection === "pub.leaflet.publication" ||
159159- aturi.collection === "pub.leaflet.document"
162162+ isPublicationCollection(aturi.collection) ||
163163+ isDocumentCollection(aturi.collection)
160164 ) {
161165 return [
162166 "span",
+4-8
components/Canvas.tsx
···2121import { QuoteTiny } from "./Icons/QuoteTiny";
2222import { PublicationMetadata } from "./Pages/PublicationMetadata";
2323import { useLeafletPublicationData } from "./PageSWRDataProvider";
2424-import {
2525- PubLeafletPublication,
2626- PubLeafletPublicationRecord,
2727-} from "lexicons/api";
2824import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop";
2925import { useBlockMouseHandlers } from "./Blocks/useBlockMouseHandlers";
3026···166162}
167163168164const CanvasMetadata = (props: { isSubpage: boolean | undefined }) => {
169169- let { data: pub } = useLeafletPublicationData();
165165+ let { data: pub, normalizedPublication } = useLeafletPublicationData();
170166 if (!pub || !pub.publications) return null;
171167172172- let pubRecord = pub.publications.record as PubLeafletPublication.Record;
173173- let showComments = pubRecord.preferences?.showComments;
174174- let showMentions = pubRecord.preferences?.showMentions;
168168+ if (!normalizedPublication) return null;
169169+ let showComments = normalizedPublication.preferences?.showComments !== false;
170170+ let showMentions = normalizedPublication.preferences?.showMentions !== false;
175171176172 return (
177173 <div className="flex flex-row gap-3 items-center absolute top-6 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20">
···77import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
88import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider";
99import { useSmoker } from "components/Toast";
1010-import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api";
1110import { blobRefToSrc } from "src/utils/blobRefToSrc";
1111+import type {
1212+ NormalizedDocument,
1313+ NormalizedPublication,
1414+} from "src/utils/normalizeRecords";
1215import type { Post } from "app/(home-pages)/reader/getReaderFeed";
13161417import Link from "next/link";
···17201821export const PostListing = (props: Post) => {
1922 let pubRecord = props.publication?.pubRecord as
2020- | PubLeafletPublication.Record
2323+ | NormalizedPublication
2124 | undefined;
22252323- let postRecord = props.documents.data as PubLeafletDocument.Record;
2626+ let postRecord = props.documents.data as NormalizedDocument | null;
2727+2828+ // Don't render anything for records that can't be normalized (e.g., site.standard records without expected fields)
2929+ if (!postRecord) {
3030+ return null;
3131+ }
2432 let postUri = new AtUri(props.documents.uri);
2533 let uri = props.publication ? props.publication?.uri : props.documents.uri;
2634···96104 quotesCount={quotes}
97105 commentsCount={comments}
98106 tags={tags}
9999- showComments={pubRecord?.preferences?.showComments}
100100- showMentions={pubRecord?.preferences?.showMentions}
107107+ showComments={pubRecord?.preferences?.showComments !== false}
108108+ showMentions={pubRecord?.preferences?.showMentions !== false}
101109 share
102110 />
103111 </div>
···110118111119const PubInfo = (props: {
112120 href: string;
113113- pubRecord: PubLeafletPublication.Record;
121121+ pubRecord: NormalizedPublication;
114122 uri: string;
115123}) => {
116124 return (
+8-8
components/ThemeManager/PubThemeSetter.tsx
···11-import { usePublicationData } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider";
11+import {
22+ usePublicationData,
33+ useNormalizedPublicationRecord,
44+} from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider";
25import { useState } from "react";
36import { pickers, SectionArrow } from "./ThemeSetter";
47import { Color } from "react-aria-components";
55-import {
66- PubLeafletPublication,
77- PubLeafletThemeBackgroundImage,
88-} from "lexicons/api";
88+import { PubLeafletThemeBackgroundImage } from "lexicons/api";
99import { AtUri } from "@atproto/syntax";
1010import { useLocalPubTheme } from "./PublicationThemeProvider";
1111import { BaseThemeProvider } from "./ThemeProvider";
···3535 let [openPicker, setOpenPicker] = useState<pickers>("null");
3636 let { data, mutate } = usePublicationData();
3737 let { publication: pub } = data || {};
3838- let record = pub?.record as PubLeafletPublication.Record | undefined;
3838+ let record = useNormalizedPublicationRecord();
3939 let [showPageBackground, setShowPageBackground] = useState(
4040 !!record?.theme?.showPageBackground,
4141 );
···246246}) => {
247247 let { data } = usePublicationData();
248248 let { publication } = data || {};
249249- let record = publication?.record as PubLeafletPublication.Record | null;
249249+ let record = useNormalizedPublicationRecord();
250250251251 return (
252252 <div
···314314}) => {
315315 let { data } = usePublicationData();
316316 let { publication } = data || {};
317317- let record = publication?.record as PubLeafletPublication.Record | null;
317317+ let record = useNormalizedPublicationRecord();
318318 return (
319319 <div
320320 style={{
···2121 PublicationBackgroundProvider,
2222 PublicationThemeProvider,
2323} from "./PublicationThemeProvider";
2424-import { PubLeafletPublication } from "lexicons/api";
2524import { getColorDifference } from "./themeUtils";
26252726// define a function to set an Aria Color to a CSS Variable in RGB
···4039 children: React.ReactNode;
4140 className?: string;
4241}) {
4343- let { data: pub } = useLeafletPublicationData();
4242+ let { data: pub, normalizedPublication } = useLeafletPublicationData();
4443 if (!pub || !pub.publications) return <LeafletThemeProvider {...props} />;
4544 return (
4645 <PublicationThemeProvider
4746 {...props}
4848- theme={(pub.publications?.record as PubLeafletPublication.Record)?.theme}
4747+ theme={normalizedPublication?.theme}
4948 pub_creator={pub.publications?.identity_did}
5049 />
5150 );
···134133 // pageBg should inherit from leafletBg
135134 const bgPage =
136135 !showPageBackground && !hasBackgroundImage ? bgLeaflet : bgPageProp;
137137- // set accent contrast to the accent color that has the highest contrast with the page background
136136+138137 let accentContrast;
139139-140140- //sorting the accents by contrast on background
141138 let sortedAccents = [accent1, accent2].sort((a, b) => {
139139+ // sort accents by contrast against the background
142140 return (
143141 getColorDifference(
144142 colorToString(b, "rgb"),
···150148 )
151149 );
152150 });
153153-154154- // if the contrast-y accent is too similar to the primary text color,
155155- // and the not contrast-y option is different from the backgrond,
156156- // then use the not contrasty option
157157-158151 if (
152152+ // if the contrast-y accent is too similar to text color
159153 getColorDifference(
160154 colorToString(sortedAccents[0], "rgb"),
161155 colorToString(primary, "rgb"),
162156 ) < 0.15 &&
157157+ // and if the other accent is different enough from the background
163158 getColorDifference(
164159 colorToString(sortedAccents[1], "rgb"),
165160 colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"),
166166- ) > 0.08
161161+ ) > 0.31
167162 ) {
163163+ //then choose the less contrast-y accent
168164 accentContrast = sortedAccents[1];
169169- } else accentContrast = sortedAccents[0];
165165+ } else {
166166+ // otherwise, choose the more contrast-y option
167167+ accentContrast = sortedAccents[0];
168168+ }
170169171170 useEffect(() => {
172171 if (local) return;
···328327 entityID: string;
329328 children: React.ReactNode;
330329}) => {
331331- let { data: pub } = useLeafletPublicationData();
330330+ let { data: pub, normalizedPublication } = useLeafletPublicationData();
332331 let backgroundImage = useEntity(props.entityID, "theme/background-image");
333332 let backgroundImageRepeat = useEntity(
334333 props.entityID,
···338337 return (
339338 <PublicationBackgroundProvider
340339 pub_creator={pub?.publications.identity_did || ""}
341341- theme={
342342- (pub.publications?.record as PubLeafletPublication.Record)?.theme
343343- }
340340+ theme={normalizedPublication?.theme}
344341 >
345342 {props.children}
346343 </PublicationBackgroundProvider>
···11+/**
22+ * GENERATED CODE - DO NOT MODIFY
33+ */
44+import { type ValidationResult, BlobRef } from '@atproto/lexicon'
55+import { CID } from 'multiformats/cid'
66+import { validate as _validate } from '../../../lexicons'
77+import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
88+import type * as PubLeafletPagesLinearDocument from './pages/linearDocument'
99+import type * as PubLeafletPagesCanvas from './pages/canvas'
1010+1111+const is$typed = _is$typed,
1212+ validate = _validate
1313+const id = 'pub.leaflet.content'
1414+1515+/** Content format for leaflet documents */
1616+export interface Main {
1717+ $type?: 'pub.leaflet.content'
1818+ pages: (
1919+ | $Typed<PubLeafletPagesLinearDocument.Main>
2020+ | $Typed<PubLeafletPagesCanvas.Main>
2121+ | { $type: string }
2222+ )[]
2323+}
2424+2525+const hashMain = 'main'
2626+2727+export function isMain<V>(v: V) {
2828+ return is$typed(v, id, hashMain)
2929+}
3030+3131+export function validateMain<V>(v: V) {
3232+ return validate<Main & V>(v, id, hashMain)
3333+}
+43
lexicons/api/types/site/standard/document.ts
···11+/**
22+ * GENERATED CODE - DO NOT MODIFY
33+ */
44+import { type ValidationResult, BlobRef } from '@atproto/lexicon'
55+import { CID } from 'multiformats/cid'
66+import { validate as _validate } from '../../../lexicons'
77+import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
88+import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef'
99+import type * as PubLeafletContent from '../../pub/leaflet/content'
1010+import type * as PubLeafletPublication from '../../pub/leaflet/publication'
1111+1212+const is$typed = _is$typed,
1313+ validate = _validate
1414+const id = 'site.standard.document'
1515+1616+export interface Record {
1717+ $type: 'site.standard.document'
1818+ bskyPostRef?: ComAtprotoRepoStrongRef.Main
1919+ content?: $Typed<PubLeafletContent.Main> | { $type: string }
2020+ coverImage?: BlobRef
2121+ description?: string
2222+ /** combine with the publication url or the document site to construct a full url to the document */
2323+ path?: string
2424+ publishedAt: string
2525+ /** URI to the site or publication this document belongs to. Supports both AT-URIs (at://did/collection/rkey) for publication references and HTTPS URLs (https://example.com) for standalone documents or external sites. */
2626+ site: string
2727+ tags?: string[]
2828+ textContent?: string
2929+ theme?: PubLeafletPublication.Theme
3030+ title: string
3131+ updatedAt?: string
3232+ [k: string]: unknown
3333+}
3434+3535+const hashRecord = 'main'
3636+3737+export function isRecord<V>(v: V) {
3838+ return is$typed(v, id, hashRecord)
3939+}
4040+4141+export function validateRecord<V>(v: V) {
4242+ return validate<Record & V>(v, id, hashRecord, true)
4343+}
···11+/**
22+ * Normalization utilities for converting between pub.leaflet and site.standard lexicon formats.
33+ *
44+ * The standard format (site.standard.*) is used as the canonical representation for
55+ * reading data from the database, while both formats are accepted for storage.
66+ *
77+ * ## Site Field Format
88+ *
99+ * The `site` field in site.standard.document supports two URI formats:
1010+ * - AT-URIs (at://did/collection/rkey) - Used when document belongs to an AT Protocol publication
1111+ * - HTTPS URLs (https://example.com) - Used for standalone documents or external sites
1212+ *
1313+ * Both formats are valid and should be handled by consumers.
1414+ */
1515+1616+import type * as PubLeafletDocument from "../api/types/pub/leaflet/document";
1717+import type * as PubLeafletPublication from "../api/types/pub/leaflet/publication";
1818+import type * as PubLeafletContent from "../api/types/pub/leaflet/content";
1919+import type * as SiteStandardDocument from "../api/types/site/standard/document";
2020+import type * as SiteStandardPublication from "../api/types/site/standard/publication";
2121+import type * as SiteStandardThemeBasic from "../api/types/site/standard/theme/basic";
2222+import type * as PubLeafletThemeColor from "../api/types/pub/leaflet/theme/color";
2323+import type { $Typed } from "../api/util";
2424+import { AtUri } from "@atproto/syntax";
2525+2626+// Normalized document type - uses the generated site.standard.document type
2727+// with an additional optional theme field for backwards compatibility
2828+export type NormalizedDocument = SiteStandardDocument.Record & {
2929+ // Keep the original theme for components that need leaflet-specific styling
3030+ theme?: PubLeafletPublication.Theme;
3131+};
3232+3333+// Normalized publication type - uses the generated site.standard.publication type
3434+export type NormalizedPublication = SiteStandardPublication.Record;
3535+3636+/**
3737+ * Checks if the record is a pub.leaflet.document
3838+ */
3939+export function isLeafletDocument(
4040+ record: unknown
4141+): record is PubLeafletDocument.Record {
4242+ if (!record || typeof record !== "object") return false;
4343+ const r = record as Record<string, unknown>;
4444+ return (
4545+ r.$type === "pub.leaflet.document" ||
4646+ // Legacy records without $type but with pages array
4747+ (Array.isArray(r.pages) && typeof r.author === "string")
4848+ );
4949+}
5050+5151+/**
5252+ * Checks if the record is a site.standard.document
5353+ */
5454+export function isStandardDocument(
5555+ record: unknown
5656+): record is SiteStandardDocument.Record {
5757+ if (!record || typeof record !== "object") return false;
5858+ const r = record as Record<string, unknown>;
5959+ return r.$type === "site.standard.document";
6060+}
6161+6262+/**
6363+ * Checks if the record is a pub.leaflet.publication
6464+ */
6565+export function isLeafletPublication(
6666+ record: unknown
6767+): record is PubLeafletPublication.Record {
6868+ if (!record || typeof record !== "object") return false;
6969+ const r = record as Record<string, unknown>;
7070+ return (
7171+ r.$type === "pub.leaflet.publication" ||
7272+ // Legacy records without $type but with name and no url
7373+ (typeof r.name === "string" && !("url" in r))
7474+ );
7575+}
7676+7777+/**
7878+ * Checks if the record is a site.standard.publication
7979+ */
8080+export function isStandardPublication(
8181+ record: unknown
8282+): record is SiteStandardPublication.Record {
8383+ if (!record || typeof record !== "object") return false;
8484+ const r = record as Record<string, unknown>;
8585+ return r.$type === "site.standard.publication";
8686+}
8787+8888+/**
8989+ * Extracts RGB values from a color union type
9090+ */
9191+function extractRgb(
9292+ color:
9393+ | $Typed<PubLeafletThemeColor.Rgba>
9494+ | $Typed<PubLeafletThemeColor.Rgb>
9595+ | { $type: string }
9696+ | undefined
9797+): { r: number; g: number; b: number } | undefined {
9898+ if (!color || typeof color !== "object") return undefined;
9999+ const c = color as Record<string, unknown>;
100100+ if (
101101+ typeof c.r === "number" &&
102102+ typeof c.g === "number" &&
103103+ typeof c.b === "number"
104104+ ) {
105105+ return { r: c.r, g: c.g, b: c.b };
106106+ }
107107+ return undefined;
108108+}
109109+110110+/**
111111+ * Converts a pub.leaflet theme to a site.standard.theme.basic format
112112+ */
113113+export function leafletThemeToBasicTheme(
114114+ theme: PubLeafletPublication.Theme | undefined
115115+): SiteStandardThemeBasic.Main | undefined {
116116+ if (!theme) return undefined;
117117+118118+ const background = extractRgb(theme.backgroundColor);
119119+ const accent = extractRgb(theme.accentBackground) || extractRgb(theme.primary);
120120+ const accentForeground = extractRgb(theme.accentText);
121121+122122+ // If we don't have the required colors, return undefined
123123+ if (!background || !accent) return undefined;
124124+125125+ // Default foreground to dark if not specified
126126+ const foreground = { r: 0, g: 0, b: 0 };
127127+128128+ // Default accent foreground to white if not specified
129129+ const finalAccentForeground = accentForeground || { r: 255, g: 255, b: 255 };
130130+131131+ return {
132132+ $type: "site.standard.theme.basic",
133133+ background: { $type: "site.standard.theme.color#rgb", ...background },
134134+ foreground: { $type: "site.standard.theme.color#rgb", ...foreground },
135135+ accent: { $type: "site.standard.theme.color#rgb", ...accent },
136136+ accentForeground: {
137137+ $type: "site.standard.theme.color#rgb",
138138+ ...finalAccentForeground,
139139+ },
140140+ };
141141+}
142142+143143+/**
144144+ * Normalizes a document record from either format to the standard format.
145145+ *
146146+ * @param record - The document record from the database (either pub.leaflet or site.standard)
147147+ * @param uri - Optional document URI, used to extract the rkey for the path field when normalizing pub.leaflet records
148148+ * @returns A normalized document in site.standard format, or null if invalid/unrecognized
149149+ */
150150+export function normalizeDocument(record: unknown, uri?: string): NormalizedDocument | null {
151151+ if (!record || typeof record !== "object") return null;
152152+153153+ // Pass through site.standard records directly (theme is already in correct format if present)
154154+ if (isStandardDocument(record)) {
155155+ return {
156156+ ...record,
157157+ theme: record.theme,
158158+ } as NormalizedDocument;
159159+ }
160160+161161+ if (isLeafletDocument(record)) {
162162+ // Convert from pub.leaflet to site.standard
163163+ const publishedAt = record.publishedAt;
164164+165165+ if (!publishedAt) {
166166+ return null;
167167+ }
168168+169169+ // For standalone documents (no publication), construct a site URL from the author
170170+ // This matches the pattern used in publishToPublication.ts for new standalone docs
171171+ const site = record.publication || `https://leaflet.pub/p/${record.author}`;
172172+173173+ // Extract path from URI if available
174174+ const path = uri ? new AtUri(uri).rkey : undefined;
175175+176176+ // Wrap pages in pub.leaflet.content structure
177177+ const content: $Typed<PubLeafletContent.Main> | undefined = record.pages
178178+ ? {
179179+ $type: "pub.leaflet.content" as const,
180180+ pages: record.pages,
181181+ }
182182+ : undefined;
183183+184184+ return {
185185+ $type: "site.standard.document",
186186+ title: record.title,
187187+ site,
188188+ path,
189189+ publishedAt,
190190+ description: record.description,
191191+ tags: record.tags,
192192+ coverImage: record.coverImage,
193193+ bskyPostRef: record.postRef,
194194+ content,
195195+ theme: record.theme,
196196+ };
197197+ }
198198+199199+ return null;
200200+}
201201+202202+/**
203203+ * Normalizes a publication record from either format to the standard format.
204204+ *
205205+ * @param record - The publication record from the database (either pub.leaflet or site.standard)
206206+ * @returns A normalized publication in site.standard format, or null if invalid/unrecognized
207207+ */
208208+export function normalizePublication(
209209+ record: unknown
210210+): NormalizedPublication | null {
211211+ if (!record || typeof record !== "object") return null;
212212+213213+ // Pass through site.standard records directly
214214+ if (isStandardPublication(record)) {
215215+ return record;
216216+ }
217217+218218+ if (isLeafletPublication(record)) {
219219+ // Convert from pub.leaflet to site.standard
220220+ const url = record.base_path ? `https://${record.base_path}` : undefined;
221221+222222+ if (!url) {
223223+ return null;
224224+ }
225225+226226+ const basicTheme = leafletThemeToBasicTheme(record.theme);
227227+228228+ // Convert preferences to site.standard format (strip/replace $type)
229229+ const preferences: SiteStandardPublication.Preferences | undefined =
230230+ record.preferences
231231+ ? {
232232+ showInDiscover: record.preferences.showInDiscover,
233233+ showComments: record.preferences.showComments,
234234+ showMentions: record.preferences.showMentions,
235235+ showPrevNext: record.preferences.showPrevNext,
236236+ }
237237+ : undefined;
238238+239239+ return {
240240+ $type: "site.standard.publication",
241241+ name: record.name,
242242+ url,
243243+ description: record.description,
244244+ icon: record.icon,
245245+ basicTheme,
246246+ theme: record.theme,
247247+ preferences,
248248+ };
249249+ }
250250+251251+ return null;
252252+}
253253+254254+/**
255255+ * Type guard to check if a normalized document has leaflet content
256256+ */
257257+export function hasLeafletContent(
258258+ doc: NormalizedDocument
259259+): doc is NormalizedDocument & {
260260+ content: $Typed<PubLeafletContent.Main>;
261261+} {
262262+ return (
263263+ doc.content !== undefined &&
264264+ (doc.content as { $type?: string }).$type === "pub.leaflet.content"
265265+ );
266266+}
267267+268268+/**
269269+ * Gets the pages array from a normalized document, handling both formats
270270+ */
271271+export function getDocumentPages(
272272+ doc: NormalizedDocument
273273+): PubLeafletContent.Main["pages"] | undefined {
274274+ if (!doc.content) return undefined;
275275+276276+ if (hasLeafletContent(doc)) {
277277+ return doc.content.pages;
278278+ }
279279+280280+ // Unknown content type
281281+ return undefined;
282282+}
···11+import { ids } from "lexicons/api/lexicons";
22+33+/**
44+ * Check if a collection is a document collection (either namespace).
55+ */
66+export function isDocumentCollection(collection: string): boolean {
77+ return (
88+ collection === ids.PubLeafletDocument ||
99+ collection === ids.SiteStandardDocument
1010+ );
1111+}
1212+1313+/**
1414+ * Check if a collection is a publication collection (either namespace).
1515+ */
1616+export function isPublicationCollection(collection: string): boolean {
1717+ return (
1818+ collection === ids.PubLeafletPublication ||
1919+ collection === ids.SiteStandardPublication
2020+ );
2121+}
2222+2323+/**
2424+ * Check if a collection belongs to the site.standard namespace.
2525+ */
2626+export function isSiteStandardCollection(collection: string): boolean {
2727+ return collection.startsWith("site.standard.");
2828+}
2929+3030+/**
3131+ * Check if a collection belongs to the pub.leaflet namespace.
3232+ */
3333+export function isPubLeafletCollection(collection: string): boolean {
3434+ return collection.startsWith("pub.leaflet.");
3535+}
3636+3737+/**
3838+ * Get the document $type to use based on an existing URI's collection.
3939+ * If no existing URI or collection isn't a document, defaults to site.standard.document.
4040+ */
4141+export function getDocumentType(existingCollection?: string): "pub.leaflet.document" | "site.standard.document" {
4242+ if (existingCollection === ids.PubLeafletDocument) {
4343+ return ids.PubLeafletDocument as "pub.leaflet.document";
4444+ }
4545+ return ids.SiteStandardDocument as "site.standard.document";
4646+}
4747+4848+/**
4949+ * Get the publication $type to use based on an existing URI's collection.
5050+ * If no existing URI or collection isn't a publication, defaults to site.standard.publication.
5151+ */
5252+export function getPublicationType(existingCollection?: string): "pub.leaflet.publication" | "site.standard.publication" {
5353+ if (existingCollection === ids.PubLeafletPublication) {
5454+ return ids.PubLeafletPublication as "pub.leaflet.publication";
5555+ }
5656+ return ids.SiteStandardPublication as "site.standard.publication";
5757+}
+122
src/utils/deduplicateRecords.ts
···11+/**
22+ * Utilities for deduplicating records that may exist under both
33+ * pub.leaflet.* and site.standard.* namespaces.
44+ *
55+ * After the migration to site.standard.*, records can exist in both namespaces
66+ * with the same DID and rkey. This utility deduplicates them, preferring
77+ * site.standard.* records when available.
88+ */
99+1010+import { AtUri } from "@atproto/syntax";
1111+1212+/**
1313+ * Extracts the identity key (DID + rkey) from an AT URI.
1414+ * This key uniquely identifies a record across namespaces.
1515+ *
1616+ * @example
1717+ * getRecordIdentityKey("at://did:plc:abc/pub.leaflet.document/3abc")
1818+ * // Returns: "did:plc:abc/3abc"
1919+ *
2020+ * getRecordIdentityKey("at://did:plc:abc/site.standard.document/3abc")
2121+ * // Returns: "did:plc:abc/3abc" (same key, different namespace)
2222+ */
2323+function getRecordIdentityKey(uri: string): string | null {
2424+ try {
2525+ const parsed = new AtUri(uri);
2626+ return `${parsed.host}/${parsed.rkey}`;
2727+ } catch {
2828+ return null;
2929+ }
3030+}
3131+3232+/**
3333+ * Checks if a URI is from the site.standard namespace.
3434+ */
3535+function isSiteStandardUri(uri: string): boolean {
3636+ return uri.includes("/site.standard.");
3737+}
3838+3939+/**
4040+ * Deduplicates an array of records that have a `uri` property.
4141+ *
4242+ * When records exist under both pub.leaflet.* and site.standard.* namespaces
4343+ * (same DID and rkey), this function keeps only the site.standard version.
4444+ *
4545+ * @param records - Array of records with a `uri` property
4646+ * @returns Deduplicated array, preferring site.standard records
4747+ *
4848+ * @example
4949+ * const docs = [
5050+ * { uri: "at://did:plc:abc/pub.leaflet.document/3abc", data: {...} },
5151+ * { uri: "at://did:plc:abc/site.standard.document/3abc", data: {...} },
5252+ * { uri: "at://did:plc:abc/pub.leaflet.document/3def", data: {...} },
5353+ * ];
5454+ * const deduped = deduplicateByUri(docs);
5555+ * // Returns: [
5656+ * // { uri: "at://did:plc:abc/site.standard.document/3abc", data: {...} },
5757+ * // { uri: "at://did:plc:abc/pub.leaflet.document/3def", data: {...} },
5858+ * // ]
5959+ */
6060+export function deduplicateByUri<T extends { uri: string }>(records: T[]): T[] {
6161+ const recordsByKey = new Map<string, T>();
6262+6363+ for (const record of records) {
6464+ const key = getRecordIdentityKey(record.uri);
6565+ if (!key) {
6666+ // Invalid URI, keep the record as-is
6767+ continue;
6868+ }
6969+7070+ const existing = recordsByKey.get(key);
7171+ if (!existing) {
7272+ recordsByKey.set(key, record);
7373+ } else {
7474+ // Prefer site.standard records over pub.leaflet records
7575+ if (isSiteStandardUri(record.uri) && !isSiteStandardUri(existing.uri)) {
7676+ recordsByKey.set(key, record);
7777+ }
7878+ // If both are same namespace or existing is already site.standard, keep existing
7979+ }
8080+ }
8181+8282+ return Array.from(recordsByKey.values());
8383+}
8484+8585+/**
8686+ * Deduplicates records while preserving the original order based on the first
8787+ * occurrence of each unique record.
8888+ *
8989+ * Same deduplication logic as deduplicateByUri, but maintains insertion order.
9090+ *
9191+ * @param records - Array of records with a `uri` property
9292+ * @returns Deduplicated array in original order, preferring site.standard records
9393+ */
9494+export function deduplicateByUriOrdered<T extends { uri: string }>(
9595+ records: T[]
9696+): T[] {
9797+ const recordsByKey = new Map<string, { record: T; index: number }>();
9898+9999+ for (let i = 0; i < records.length; i++) {
100100+ const record = records[i];
101101+ const key = getRecordIdentityKey(record.uri);
102102+ if (!key) {
103103+ continue;
104104+ }
105105+106106+ const existing = recordsByKey.get(key);
107107+ if (!existing) {
108108+ recordsByKey.set(key, { record, index: i });
109109+ } else {
110110+ // Prefer site.standard records over pub.leaflet records
111111+ if (isSiteStandardUri(record.uri) && !isSiteStandardUri(existing.record.uri)) {
112112+ // Replace with site.standard but keep original position
113113+ recordsByKey.set(key, { record, index: existing.index });
114114+ }
115115+ }
116116+ }
117117+118118+ // Sort by original index to maintain order
119119+ return Array.from(recordsByKey.values())
120120+ .sort((a, b) => a.index - b.index)
121121+ .map((entry) => entry.record);
122122+}
···11import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data";
22import { Json } from "supabase/database.types";
3344+/**
55+ * Return type for publication metadata extraction.
66+ * Note: `publications.record` and `documents.data` are raw JSON from the database.
77+ * Consumers should use `normalizePublicationRecord()` and `normalizeDocumentRecord()`
88+ * from `src/utils/normalizeRecords` to get properly typed data.
99+ */
1010+export type PublicationMetadata = {
1111+ description: string;
1212+ title: string;
1313+ leaflet: string;
1414+ doc: string | null;
1515+ publications: {
1616+ identity_did: string;
1717+ name: string;
1818+ indexed_at: string;
1919+ /** Raw record - use normalizePublicationRecord() to get typed data */
2020+ record: Json | null;
2121+ uri: string;
2222+ } | null;
2323+ documents: {
2424+ /** Raw data - use normalizeDocumentRecord() to get typed data */
2525+ data: Json;
2626+ indexed_at: string;
2727+ uri: string;
2828+ } | null;
2929+} | null;
3030+431export function getPublicationMetadataFromLeafletData(
532 data?: GetLeafletDataReturnType["result"]["data"],
66-) {
3333+): PublicationMetadata {
734 if (!data) return null;
835936 let pubData:
1010- | {
1111- description: string;
1212- title: string;
1313- leaflet: string;
1414- doc: string | null;
1515- publications: {
1616- identity_did: string;
1717- name: string;
1818- indexed_at: string;
1919- record: Json | null;
2020- uri: string;
2121- } | null;
2222- documents: {
2323- data: Json;
2424- indexed_at: string;
2525- uri: string;
2626- } | null;
2727- }
3737+ | NonNullable<PublicationMetadata>
2838 | undefined
2939 | null =
3040 data?.leaflets_in_publications?.[0] ||
···4656 doc: standaloneDoc.document,
4757 };
4858 }
4949- return pubData;
5959+ return pubData || null;
5060}
+6-2
src/utils/mentionUtils.ts
···11import { AtUri } from "@atproto/api";
22+import {
33+ isDocumentCollection,
44+ isPublicationCollection,
55+} from "src/utils/collectionHelpers";
2637/**
48 * Converts a DID to a Bluesky profile URL
···1418 try {
1519 const uri = new AtUri(atUri);
16201717- if (uri.collection === "pub.leaflet.publication") {
2121+ if (isPublicationCollection(uri.collection)) {
1822 // Publication URL: /lish/{did}/{rkey}
1923 return `/lish/${uri.host}/${uri.rkey}`;
2020- } else if (uri.collection === "pub.leaflet.document") {
2424+ } else if (isDocumentCollection(uri.collection)) {
2125 // Document URL - we need to resolve this via the API
2226 // For now, create a redirect route that will handle it
2327 return `/lish/uri/${encodeURIComponent(atUri)}`;
+134
src/utils/normalizeRecords.ts
···11+/**
22+ * Utilities for normalizing pub.leaflet and site.standard records from database queries.
33+ *
44+ * These helpers apply the normalization functions from lexicons/src/normalize.ts
55+ * to database query results, providing properly typed normalized records.
66+ */
77+88+import {
99+ normalizeDocument,
1010+ normalizePublication,
1111+ type NormalizedDocument,
1212+ type NormalizedPublication,
1313+} from "lexicons/src/normalize";
1414+import type { Json } from "supabase/database.types";
1515+1616+/**
1717+ * Normalizes a document record from a database query result.
1818+ * Returns the normalized document or null if the record is invalid/unrecognized.
1919+ *
2020+ * @param data - The document record data from the database
2121+ * @param uri - Optional document URI, used to extract the rkey for the path field when normalizing pub.leaflet records
2222+ *
2323+ * @example
2424+ * const doc = normalizeDocumentRecord(dbResult.data, dbResult.uri);
2525+ * if (doc) {
2626+ * // doc is NormalizedDocument with proper typing
2727+ * console.log(doc.title, doc.site, doc.publishedAt);
2828+ * }
2929+ */
3030+export function normalizeDocumentRecord(
3131+ data: Json | unknown,
3232+ uri?: string
3333+): NormalizedDocument | null {
3434+ return normalizeDocument(data, uri);
3535+}
3636+3737+/**
3838+ * Normalizes a publication record from a database query result.
3939+ * Returns the normalized publication or null if the record is invalid/unrecognized.
4040+ *
4141+ * @example
4242+ * const pub = normalizePublicationRecord(dbResult.record);
4343+ * if (pub) {
4444+ * // pub is NormalizedPublication with proper typing
4545+ * console.log(pub.name, pub.url);
4646+ * }
4747+ */
4848+export function normalizePublicationRecord(
4949+ record: Json | unknown
5050+): NormalizedPublication | null {
5151+ return normalizePublication(record);
5252+}
5353+5454+/**
5555+ * Type helper for a document row from the database with normalized data.
5656+ * Use this when you need the full row but with typed data.
5757+ */
5858+export type DocumentRowWithNormalizedData<
5959+ T extends { data: Json | unknown }
6060+> = Omit<T, "data"> & {
6161+ data: NormalizedDocument | null;
6262+};
6363+6464+/**
6565+ * Type helper for a publication row from the database with normalized record.
6666+ * Use this when you need the full row but with typed record.
6767+ */
6868+export type PublicationRowWithNormalizedRecord<
6969+ T extends { record: Json | unknown }
7070+> = Omit<T, "record"> & {
7171+ record: NormalizedPublication | null;
7272+};
7373+7474+/**
7575+ * Normalizes a document row in place, returning a properly typed row.
7676+ * If the row has a `uri` field, it will be used to extract the path.
7777+ */
7878+export function normalizeDocumentRow<T extends { data: Json | unknown; uri?: string }>(
7979+ row: T
8080+): DocumentRowWithNormalizedData<T> {
8181+ return {
8282+ ...row,
8383+ data: normalizeDocumentRecord(row.data, row.uri),
8484+ };
8585+}
8686+8787+/**
8888+ * Normalizes a publication row in place, returning a properly typed row.
8989+ */
9090+export function normalizePublicationRow<T extends { record: Json | unknown }>(
9191+ row: T
9292+): PublicationRowWithNormalizedRecord<T> {
9393+ return {
9494+ ...row,
9595+ record: normalizePublicationRecord(row.record),
9696+ };
9797+}
9898+9999+/**
100100+ * Type guard for filtering normalized document rows with non-null data.
101101+ * Use with .filter() after .map(normalizeDocumentRow) to narrow the type.
102102+ */
103103+export function hasValidDocument<T extends { data: NormalizedDocument | null }>(
104104+ row: T
105105+): row is T & { data: NormalizedDocument } {
106106+ return row.data !== null;
107107+}
108108+109109+/**
110110+ * Type guard for filtering normalized publication rows with non-null record.
111111+ * Use with .filter() after .map(normalizePublicationRow) to narrow the type.
112112+ */
113113+export function hasValidPublication<
114114+ T extends { record: NormalizedPublication | null }
115115+>(row: T): row is T & { record: NormalizedPublication } {
116116+ return row.record !== null;
117117+}
118118+119119+// Re-export the core types and functions for convenience
120120+export {
121121+ normalizeDocument,
122122+ normalizePublication,
123123+ type NormalizedDocument,
124124+ type NormalizedPublication,
125125+} from "lexicons/src/normalize";
126126+127127+export {
128128+ isLeafletDocument,
129129+ isStandardDocument,
130130+ isLeafletPublication,
131131+ isStandardPublication,
132132+ hasLeafletContent,
133133+ getDocumentPages,
134134+} from "lexicons/src/normalize";
···11+import { AtUri } from "@atproto/syntax";
22+import { ids } from "lexicons/api/lexicons";
33+44+/**
55+ * Returns an OR filter string for Supabase queries to match either namespace URI.
66+ * Used for querying documents that may be stored under either pub.leaflet.document
77+ * or site.standard.document namespaces.
88+ */
99+export function documentUriFilter(did: string, rkey: string): string {
1010+ const standard = AtUri.make(did, ids.SiteStandardDocument, rkey).toString();
1111+ const legacy = AtUri.make(did, ids.PubLeafletDocument, rkey).toString();
1212+ return `uri.eq.${standard},uri.eq.${legacy}`;
1313+}
1414+1515+/**
1616+ * Returns an OR filter string for Supabase queries to match either namespace URI.
1717+ * Used for querying publications that may be stored under either pub.leaflet.publication
1818+ * or site.standard.publication namespaces.
1919+ */
2020+export function publicationUriFilter(did: string, rkey: string): string {
2121+ const standard = AtUri.make(
2222+ did,
2323+ ids.SiteStandardPublication,
2424+ rkey,
2525+ ).toString();
2626+ const legacy = AtUri.make(did, ids.PubLeafletPublication, rkey).toString();
2727+ return `uri.eq.${standard},uri.eq.${legacy}`;
2828+}
2929+3030+/**
3131+ * Returns an OR filter string for Supabase queries to match a publication by name
3232+ * or by either namespace URI. Used when the rkey might be the publication name.
3333+ */
3434+export function publicationNameOrUriFilter(
3535+ did: string,
3636+ nameOrRkey: string,
3737+): string {
3838+ let standard, legacy;
3939+ if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(nameOrRkey)) {
4040+ standard = AtUri.make(
4141+ did,
4242+ ids.SiteStandardPublication,
4343+ nameOrRkey,
4444+ ).toString();
4545+ legacy = AtUri.make(did, ids.PubLeafletPublication, nameOrRkey).toString();
4646+ }
4747+ return `name.eq."${nameOrRkey}"",uri.eq."${standard}",uri.eq."${legacy}"`;
4848+}