···22- it looks good on both mobile and desktop
33- it undo's like it ought to
44- it handles keyboard interactions reasonably well
55-- it behaves as you would expect if you lock it
65- no build errors!!!
+5-2
actions/getIdentityData.ts
···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
+219-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
203203+ ? new AtUri(existingDocUri).collection
204204+ : undefined;
205205+ const documentType = getDocumentType(existingCollection);
203206204204- // Keep the same rkey if updating an existing document
205205- let rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr();
207207+ // Build the pages array (used by both formats)
208208+ const pagesArray = pages.map((p) => {
209209+ if (p.type === "canvas") {
210210+ return {
211211+ $type: "pub.leaflet.pages.canvas" as const,
212212+ id: p.id,
213213+ blocks: p.blocks as PubLeafletPagesCanvas.Block[],
214214+ };
215215+ } else {
216216+ return {
217217+ $type: "pub.leaflet.pages.linearDocument" as const,
218218+ id: p.id,
219219+ blocks: p.blocks as PubLeafletPagesLinearDocument.Block[],
220220+ };
221221+ }
222222+ });
223223+224224+ // Determine the rkey early since we need it for the path field
225225+ const rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr();
226226+227227+ // Create record based on the document type
228228+ let record: PubLeafletDocument.Record | SiteStandardDocument.Record;
229229+230230+ if (documentType === "site.standard.document") {
231231+ // site.standard.document format
232232+ // For standalone docs, use HTTPS URL; for publication docs, use the publication AT-URI
233233+ const siteUri =
234234+ publication_uri || `https://leaflet.pub/p/${credentialSession.did}`;
235235+236236+ record = {
237237+ $type: "site.standard.document",
238238+ title: title || "Untitled",
239239+ site: siteUri,
240240+ path: "/" + rkey,
241241+ publishedAt:
242242+ publishedAt || existingRecord.publishedAt || new Date().toISOString(),
243243+ ...(description && { description }),
244244+ ...(tags !== undefined && { tags }),
245245+ ...(coverImageBlob && { coverImage: coverImageBlob }),
246246+ // Include theme for standalone documents (not for publication documents)
247247+ ...(!publication_uri && theme && { theme }),
248248+ content: {
249249+ $type: "pub.leaflet.content" as const,
250250+ pages: pagesArray,
251251+ },
252252+ } satisfies SiteStandardDocument.Record;
253253+ } else {
254254+ // pub.leaflet.document format (legacy)
255255+ record = {
256256+ $type: "pub.leaflet.document",
257257+ author: credentialSession.did!,
258258+ ...(publication_uri && { publication: publication_uri }),
259259+ ...(theme && { theme }),
260260+ title: title || "Untitled",
261261+ description: description || "",
262262+ ...(tags !== undefined && { tags }),
263263+ ...(coverImageBlob && { coverImage: coverImageBlob }),
264264+ pages: pagesArray,
265265+ publishedAt:
266266+ publishedAt || existingRecord.publishedAt || new Date().toISOString(),
267267+ } satisfies PubLeafletDocument.Record;
268268+ }
269269+206270 let { data: result } = await agent.com.atproto.repo.putRecord({
207271 rkey,
208272 repo: credentialSession.did!,
···214278 // Optimistically create database entries
215279 await supabaseServerClient.from("documents").upsert({
216280 uri: result.uri,
217217- data: record as Json,
281281+ data: record as unknown as Json,
218282 });
219283220284 if (publication_uri) {
···836900 */
837901async function createMentionNotifications(
838902 documentUri: string,
839839- record: PubLeafletDocument.Record,
903903+ record: PubLeafletDocument.Record | SiteStandardDocument.Record,
840904 authorDid: string,
841905) {
842906 const mentionedDids = new Set<string>();
843907 const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI
844908 const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI
909909+ const embeddedBskyPosts = new Map<string, string>(); // Map of author DID -> post URI
845910846846- // 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);
911911+ // Extract pages from either format
912912+ let pages: PubLeafletContent.Main["pages"] | undefined;
913913+ if (record.$type === "site.standard.document") {
914914+ const content = record.content;
915915+ if (content && PubLeafletContent.isMain(content)) {
916916+ pages = content.pages;
917917+ }
918918+ } else {
919919+ pages = record.pages;
920920+ }
921921+922922+ if (!pages) return;
923923+924924+ // Helper to extract blocks from all pages (both linear and canvas)
925925+ function getAllBlocks(pages: PubLeafletContent.Main["pages"]) {
926926+ const blocks: (
927927+ | PubLeafletPagesLinearDocument.Block["block"]
928928+ | PubLeafletPagesCanvas.Block["block"]
929929+ )[] = [];
930930+ for (const page of pages) {
931931+ if (page.$type === "pub.leaflet.pages.linearDocument") {
932932+ const linearPage = page as PubLeafletPagesLinearDocument.Main;
933933+ for (const blockWrapper of linearPage.blocks) {
934934+ blocks.push(blockWrapper.block);
935935+ }
936936+ } else if (page.$type === "pub.leaflet.pages.canvas") {
937937+ const canvasPage = page as PubLeafletPagesCanvas.Main;
938938+ for (const blockWrapper of canvasPage.blocks) {
939939+ blocks.push(blockWrapper.block);
940940+ }
941941+ }
942942+ }
943943+ return blocks;
944944+ }
945945+946946+ const allBlocks = getAllBlocks(pages);
947947+948948+ // Extract mentions from all text blocks and embedded Bluesky posts
949949+ for (const block of allBlocks) {
950950+ // Check for embedded Bluesky posts
951951+ if (PubLeafletBlocksBskyPost.isMain(block)) {
952952+ const bskyPostUri = block.postRef.uri;
953953+ // Extract the author DID from the post URI (at://did:xxx/app.bsky.feed.post/xxx)
954954+ const postAuthorDid = new AtUri(bskyPostUri).host;
955955+ if (postAuthorDid !== authorDid) {
956956+ embeddedBskyPosts.set(postAuthorDid, bskyPostUri);
957957+ }
958958+ }
959959+960960+ // Check for text blocks with mentions
961961+ if (block.$type === "pub.leaflet.blocks.text") {
962962+ const textBlock = block as PubLeafletBlocksText.Main;
963963+ if (textBlock.facets) {
964964+ for (const facet of textBlock.facets) {
965965+ for (const feature of facet.features) {
966966+ // Check for DID mentions
967967+ if (PubLeafletRichtextFacet.isDidMention(feature)) {
968968+ if (feature.did !== authorDid) {
969969+ mentionedDids.add(feature.did);
970970+ }
971971+ }
972972+ // Check for AT URI mentions (publications and documents)
973973+ if (PubLeafletRichtextFacet.isAtMention(feature)) {
974974+ const uri = new AtUri(feature.atURI);
866975867867- 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();
976976+ if (isPublicationCollection(uri.collection)) {
977977+ // Get the publication owner's DID
978978+ const { data: publication } = await supabaseServerClient
979979+ .from("publications")
980980+ .select("identity_did")
981981+ .eq("uri", feature.atURI)
982982+ .single();
874983875875- 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();
984984+ if (publication && publication.identity_did !== authorDid) {
985985+ mentionedPublications.set(
986986+ publication.identity_did,
987987+ feature.atURI,
988988+ );
989989+ }
990990+ } else if (isDocumentCollection(uri.collection)) {
991991+ // Get the document owner's DID
992992+ const { data: document } = await supabaseServerClient
993993+ .from("documents")
994994+ .select("uri, data")
995995+ .eq("uri", feature.atURI)
996996+ .single();
888997889889- 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- }
998998+ if (document) {
999999+ const normalizedMentionedDoc = normalizeDocumentRecord(
10001000+ document.data,
10011001+ );
10021002+ // Get the author from the document URI (the DID is the host part)
10031003+ const mentionedUri = new AtUri(feature.atURI);
10041004+ const docAuthor = mentionedUri.host;
10051005+ if (normalizedMentionedDoc && docAuthor !== authorDid) {
10061006+ mentionedDocuments.set(docAuthor, feature.atURI);
8961007 }
8971008 }
8981009 }
···9481059 };
9491060 await supabaseServerClient.from("notifications").insert(notification);
9501061 await pingIdentityToUpdateNotification(recipientDid);
10621062+ }
10631063+10641064+ // Create notifications for embedded Bluesky posts (only if the author has a Leaflet account)
10651065+ if (embeddedBskyPosts.size > 0) {
10661066+ // Check which of the Bluesky post authors have Leaflet accounts
10671067+ const { data: identities } = await supabaseServerClient
10681068+ .from("identities")
10691069+ .select("atp_did")
10701070+ .in("atp_did", Array.from(embeddedBskyPosts.keys()));
10711071+10721072+ const leafletUserDids = new Set(identities?.map((i) => i.atp_did) ?? []);
10731073+10741074+ for (const [postAuthorDid, bskyPostUri] of embeddedBskyPosts) {
10751075+ // Only notify if the post author has a Leaflet account
10761076+ if (leafletUserDids.has(postAuthorDid)) {
10771077+ const notification: Notification = {
10781078+ id: v7(),
10791079+ recipient: postAuthorDid,
10801080+ data: {
10811081+ type: "bsky_post_embed",
10821082+ document_uri: documentUri,
10831083+ bsky_post_uri: bskyPostUri,
10841084+ },
10851085+ };
10861086+ await supabaseServerClient.from("notifications").insert(notification);
10871087+ await pingIdentityToUpdateNotification(postAuthorDid);
10881088+ }
10891089+ }
9511090 }
9521091}
+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-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 * 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+// with the theme narrowed to only the valid pub.leaflet.publication#theme type
3535+// (isTheme validates that $type is present, so we use $Typed)
3636+// Note: We explicitly list fields rather than using Omit because the generated Record type
3737+// has an index signature [k: string]: unknown that interferes with property typing
3838+export type NormalizedPublication = {
3939+ $type: "site.standard.publication";
4040+ name: string;
4141+ url: string;
4242+ description?: string;
4343+ icon?: SiteStandardPublication.Record["icon"];
4444+ basicTheme?: SiteStandardThemeBasic.Main;
4545+ theme?: $Typed<PubLeafletPublication.Theme>;
4646+ preferences?: SiteStandardPublication.Preferences;
4747+};
4848+4949+/**
5050+ * Checks if the record is a pub.leaflet.document
5151+ */
5252+export function isLeafletDocument(
5353+ record: unknown
5454+): record is PubLeafletDocument.Record {
5555+ if (!record || typeof record !== "object") return false;
5656+ const r = record as Record<string, unknown>;
5757+ return (
5858+ r.$type === "pub.leaflet.document" ||
5959+ // Legacy records without $type but with pages array
6060+ (Array.isArray(r.pages) && typeof r.author === "string")
6161+ );
6262+}
6363+6464+/**
6565+ * Checks if the record is a site.standard.document
6666+ */
6767+export function isStandardDocument(
6868+ record: unknown
6969+): record is SiteStandardDocument.Record {
7070+ if (!record || typeof record !== "object") return false;
7171+ const r = record as Record<string, unknown>;
7272+ return r.$type === "site.standard.document";
7373+}
7474+7575+/**
7676+ * Checks if the record is a pub.leaflet.publication
7777+ */
7878+export function isLeafletPublication(
7979+ record: unknown
8080+): record is PubLeafletPublication.Record {
8181+ if (!record || typeof record !== "object") return false;
8282+ const r = record as Record<string, unknown>;
8383+ return (
8484+ r.$type === "pub.leaflet.publication" ||
8585+ // Legacy records without $type but with name and no url
8686+ (typeof r.name === "string" && !("url" in r))
8787+ );
8888+}
8989+9090+/**
9191+ * Checks if the record is a site.standard.publication
9292+ */
9393+export function isStandardPublication(
9494+ record: unknown
9595+): record is SiteStandardPublication.Record {
9696+ if (!record || typeof record !== "object") return false;
9797+ const r = record as Record<string, unknown>;
9898+ return r.$type === "site.standard.publication";
9999+}
100100+101101+/**
102102+ * Extracts RGB values from a color union type
103103+ */
104104+function extractRgb(
105105+ color:
106106+ | $Typed<PubLeafletThemeColor.Rgba>
107107+ | $Typed<PubLeafletThemeColor.Rgb>
108108+ | { $type: string }
109109+ | undefined
110110+): { r: number; g: number; b: number } | undefined {
111111+ if (!color || typeof color !== "object") return undefined;
112112+ const c = color as Record<string, unknown>;
113113+ if (
114114+ typeof c.r === "number" &&
115115+ typeof c.g === "number" &&
116116+ typeof c.b === "number"
117117+ ) {
118118+ return { r: c.r, g: c.g, b: c.b };
119119+ }
120120+ return undefined;
121121+}
122122+123123+/**
124124+ * Converts a pub.leaflet theme to a site.standard.theme.basic format
125125+ */
126126+export function leafletThemeToBasicTheme(
127127+ theme: PubLeafletPublication.Theme | undefined
128128+): SiteStandardThemeBasic.Main | undefined {
129129+ if (!theme) return undefined;
130130+131131+ const background = extractRgb(theme.backgroundColor);
132132+ const accent = extractRgb(theme.accentBackground) || extractRgb(theme.primary);
133133+ const accentForeground = extractRgb(theme.accentText);
134134+135135+ // If we don't have the required colors, return undefined
136136+ if (!background || !accent) return undefined;
137137+138138+ // Default foreground to dark if not specified
139139+ const foreground = { r: 0, g: 0, b: 0 };
140140+141141+ // Default accent foreground to white if not specified
142142+ const finalAccentForeground = accentForeground || { r: 255, g: 255, b: 255 };
143143+144144+ return {
145145+ $type: "site.standard.theme.basic",
146146+ background: { $type: "site.standard.theme.color#rgb", ...background },
147147+ foreground: { $type: "site.standard.theme.color#rgb", ...foreground },
148148+ accent: { $type: "site.standard.theme.color#rgb", ...accent },
149149+ accentForeground: {
150150+ $type: "site.standard.theme.color#rgb",
151151+ ...finalAccentForeground,
152152+ },
153153+ };
154154+}
155155+156156+/**
157157+ * Normalizes a document record from either format to the standard format.
158158+ *
159159+ * @param record - The document record from the database (either pub.leaflet or site.standard)
160160+ * @param uri - Optional document URI, used to extract the rkey for the path field when normalizing pub.leaflet records
161161+ * @returns A normalized document in site.standard format, or null if invalid/unrecognized
162162+ */
163163+export function normalizeDocument(record: unknown, uri?: string): NormalizedDocument | null {
164164+ if (!record || typeof record !== "object") return null;
165165+166166+ // Pass through site.standard records directly (theme is already in correct format if present)
167167+ if (isStandardDocument(record)) {
168168+ return {
169169+ ...record,
170170+ theme: record.theme,
171171+ } as NormalizedDocument;
172172+ }
173173+174174+ if (isLeafletDocument(record)) {
175175+ // Convert from pub.leaflet to site.standard
176176+ const publishedAt = record.publishedAt;
177177+178178+ if (!publishedAt) {
179179+ return null;
180180+ }
181181+182182+ // For standalone documents (no publication), construct a site URL from the author
183183+ // This matches the pattern used in publishToPublication.ts for new standalone docs
184184+ const site = record.publication || `https://leaflet.pub/p/${record.author}`;
185185+186186+ // Extract path from URI if available
187187+ const path = uri ? new AtUri(uri).rkey : undefined;
188188+189189+ // Wrap pages in pub.leaflet.content structure
190190+ const content: $Typed<PubLeafletContent.Main> | undefined = record.pages
191191+ ? {
192192+ $type: "pub.leaflet.content" as const,
193193+ pages: record.pages,
194194+ }
195195+ : undefined;
196196+197197+ return {
198198+ $type: "site.standard.document",
199199+ title: record.title,
200200+ site,
201201+ path,
202202+ publishedAt,
203203+ description: record.description,
204204+ tags: record.tags,
205205+ coverImage: record.coverImage,
206206+ bskyPostRef: record.postRef,
207207+ content,
208208+ theme: record.theme,
209209+ };
210210+ }
211211+212212+ return null;
213213+}
214214+215215+/**
216216+ * Normalizes a publication record from either format to the standard format.
217217+ *
218218+ * @param record - The publication record from the database (either pub.leaflet or site.standard)
219219+ * @returns A normalized publication in site.standard format, or null if invalid/unrecognized
220220+ */
221221+export function normalizePublication(
222222+ record: unknown
223223+): NormalizedPublication | null {
224224+ if (!record || typeof record !== "object") return null;
225225+226226+ // Pass through site.standard records directly, but validate the theme
227227+ if (isStandardPublication(record)) {
228228+ // Validate theme - only keep if it's a valid pub.leaflet.publication#theme
229229+ const theme = PubLeafletPublication.isTheme(record.theme)
230230+ ? (record.theme as $Typed<PubLeafletPublication.Theme>)
231231+ : undefined;
232232+ return {
233233+ ...record,
234234+ theme,
235235+ };
236236+ }
237237+238238+ if (isLeafletPublication(record)) {
239239+ // Convert from pub.leaflet to site.standard
240240+ const url = record.base_path ? `https://${record.base_path}` : undefined;
241241+242242+ if (!url) {
243243+ return null;
244244+ }
245245+246246+ const basicTheme = leafletThemeToBasicTheme(record.theme);
247247+248248+ // Validate theme - only keep if it's a valid pub.leaflet.publication#theme with $type set
249249+ // For legacy records without $type, add it during normalization
250250+ let theme: $Typed<PubLeafletPublication.Theme> | undefined;
251251+ if (record.theme) {
252252+ if (PubLeafletPublication.isTheme(record.theme)) {
253253+ theme = record.theme as $Typed<PubLeafletPublication.Theme>;
254254+ } else {
255255+ // Legacy theme without $type - add it
256256+ theme = {
257257+ ...record.theme,
258258+ $type: "pub.leaflet.publication#theme",
259259+ };
260260+ }
261261+ }
262262+263263+ // Convert preferences to site.standard format (strip/replace $type)
264264+ const preferences: SiteStandardPublication.Preferences | undefined =
265265+ record.preferences
266266+ ? {
267267+ showInDiscover: record.preferences.showInDiscover,
268268+ showComments: record.preferences.showComments,
269269+ showMentions: record.preferences.showMentions,
270270+ showPrevNext: record.preferences.showPrevNext,
271271+ }
272272+ : undefined;
273273+274274+ return {
275275+ $type: "site.standard.publication",
276276+ name: record.name,
277277+ url,
278278+ description: record.description,
279279+ icon: record.icon,
280280+ basicTheme,
281281+ theme,
282282+ preferences,
283283+ };
284284+ }
285285+286286+ return null;
287287+}
288288+289289+/**
290290+ * Type guard to check if a normalized document has leaflet content
291291+ */
292292+export function hasLeafletContent(
293293+ doc: NormalizedDocument
294294+): doc is NormalizedDocument & {
295295+ content: $Typed<PubLeafletContent.Main>;
296296+} {
297297+ return (
298298+ doc.content !== undefined &&
299299+ (doc.content as { $type?: string }).$type === "pub.leaflet.content"
300300+ );
301301+}
302302+303303+/**
304304+ * Gets the pages array from a normalized document, handling both formats
305305+ */
306306+export function getDocumentPages(
307307+ doc: NormalizedDocument
308308+): PubLeafletContent.Main["pages"] | undefined {
309309+ if (!doc.content) return undefined;
310310+311311+ if (hasLeafletContent(doc)) {
312312+ return doc.content.pages;
313313+ }
314314+315315+ // Unknown content type
316316+ return undefined;
317317+}
···6767 textData.value = base64.fromByteArray(updateBytes);
6868 }
6969 }
7070+ } else if (f.id) {
7171+ // For cardinality "many" with an explicit ID, fetch the existing fact
7272+ // so undo can restore it instead of deleting
7373+ let fact = await tx.get(f.id);
7474+ if (fact) {
7575+ existingFact = [fact as Fact<any>];
7676+ }
7077 }
7178 if (!ignoreUndo)
7279 undoManager.add({
+35-12
src/replicache/mutations.ts
···44import { SupabaseClient } from "@supabase/supabase-js";
55import { Database } from "supabase/database.types";
66import { generateKeyBetween } from "fractional-indexing";
77+import { v7 } from "uuid";
7889export type MutationContext = {
910 permission_token_id: string;
···307308 { blockEntity: string } | { blockEntity: string }[]
308309> = async (args, ctx) => {
309310 for (let block of [args].flat()) {
310310- let [isLocked] = await ctx.scanIndex.eav(
311311- block.blockEntity,
312312- "block/is-locked",
313313- );
314314- if (isLocked?.data.value) continue;
315311 let [image] = await ctx.scanIndex.eav(block.blockEntity, "block/image");
316312 await ctx.runOnServer(async ({ supabase }) => {
317313 if (image) {
···427423 },
428424 });
429425};
430430-const moveBlockDown: Mutation<{ entityID: string; parent: string }> = async (
431431- args,
432432- ctx,
433433-) => {
426426+const moveBlockDown: Mutation<{
427427+ entityID: string;
428428+ parent: string;
429429+ permission_set?: string;
430430+}> = async (args, ctx) => {
434431 let children = (await ctx.scanIndex.eav(args.parent, "card/block")).toSorted(
435432 (a, b) => (a.data.position > b.data.position ? 1 : -1),
436433 );
437434 let index = children.findIndex((f) => f.data.value === args.entityID);
438435 if (index === -1) return;
439436 let next = children[index + 1];
440440- if (!next) return;
437437+ if (!next) {
438438+ // If this is the last block, create a new empty block above it using the addBlock helper
439439+ if (!args.permission_set) return; // Can't create block without permission_set
440440+441441+ let newEntityID = v7();
442442+ let previousBlock = children[index - 1];
443443+ let position = generateKeyBetween(
444444+ previousBlock?.data.position || null,
445445+ children[index].data.position,
446446+ );
447447+448448+ // Call the addBlock mutation helper directly
449449+ await addBlock(
450450+ {
451451+ parent: args.parent,
452452+ permission_set: args.permission_set,
453453+ factID: v7(),
454454+ type: "text",
455455+ newEntityID: newEntityID,
456456+ position: position,
457457+ },
458458+ ctx,
459459+ );
460460+ return;
461461+ }
441462 await ctx.retractFact(children[index].id);
442463 await ctx.assertFact({
443464 id: children[index].id,
···637658 description?: string;
638659 tags?: string[];
639660 cover_image?: string | null;
661661+ localPublishedAt?: string | null;
640662}> = async (args, ctx) => {
641663 await ctx.runOnServer(async (serverCtx) => {
642664 console.log("updating");
···670692 }
671693 });
672694 await ctx.runOnClient(async ({ tx }) => {
673673- if (args.title !== undefined)
674674- await tx.set("publication_title", args.title);
695695+ if (args.title !== undefined) await tx.set("publication_title", args.title);
675696 if (args.description !== undefined)
676697 await tx.set("publication_description", args.description);
677698 if (args.tags !== undefined) await tx.set("publication_tags", args.tags);
678699 if (args.cover_image !== undefined)
679700 await tx.set("publication_cover_image", args.cover_image);
701701+ if (args.localPublishedAt !== undefined)
702702+ await tx.set("publication_local_published_at", args.localPublishedAt);
680703 });
681704};
682705
+57
src/utils/collectionHelpers.ts
···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+}
+10-2
src/utils/deleteBlock.ts
···44import { scanIndex } from "src/replicache/utils";
55import { getBlocksWithType } from "src/hooks/queries/useBlocks";
66import { focusBlock } from "src/utils/focusBlock";
77+import { UndoManager } from "src/undoManager";
7889export async function deleteBlock(
910 entities: string[],
1011 rep: Replicache<ReplicacheMutators>,
1212+ undoManager?: UndoManager,
1113) {
1214 // get what pagess we need to close as a result of deleting this block
1315 let pagesToClose = [] as string[];
···3234 }
3335 }
34363535- // the next and previous blocks in the block list
3636- // if the focused thing is a page and not a block, return
3737+ // figure out what to focus
3738 let focusedBlock = useUIState.getState().focusedEntity;
3839 let parent =
3940 focusedBlock?.entityType === "page"
···4445 let parentType = await rep?.query((tx) =>
4546 scanIndex(tx).eav(parent, "page/type"),
4647 );
4848+ // if the page is a canvas, focus the page
4749 if (parentType[0]?.data.value === "canvas") {
4850 useUIState
4951 .getState()
5052 .setFocusedBlock({ entityType: "page", entityID: parent });
5153 useUIState.getState().setSelectedBlocks([]);
5254 } else {
5555+ // if the page is a doc, focus the previous block (or if there isn't a prev block, focus the next block)
5356 let siblings =
5457 (await rep?.query((tx) => getBlocksWithType(tx, parent))) || [];
5558···105108 }
106109 }
107110111111+ // close the pages
108112 pagesToClose.forEach((page) => page && useUIState.getState().closePage(page));
113113+ undoManager && undoManager.startGroup();
114114+115115+ // delete the blocks
109116 await Promise.all(
110117 entities.map((entity) =>
111118 rep?.mutate.removeBlock({
···113120 }),
114121 ),
115122 );
123123+ undoManager && undoManager.endGroup();
116124}
+3-1
src/utils/focusBlock.ts
···4848 }
49495050 if (pos?.offset !== undefined) {
5151- el?.focus();
5151+ // trying to focus the block in a subpage causes the page to flash and scroll back to the parent page.
5252+ // idk how to fix so i'm giving up -- celine
5353+ // el?.focus();
5254 requestAnimationFrame(() => {
5355 el?.setSelectionRange(pos.offset, pos.offset);
5456 });
···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)}`;
···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+}