a tool for shared writing and social publishing

handle differing uris

+374 -171
+83 -32
actions/publishToPublication.ts
··· 11 11 PubLeafletBlocksText, 12 12 PubLeafletBlocksUnorderedList, 13 13 PubLeafletDocument, 14 + SiteStandardDocument, 15 + PubLeafletContent, 14 16 PubLeafletPagesLinearDocument, 15 17 PubLeafletPagesCanvas, 16 18 PubLeafletRichtextFacet, ··· 56 58 pingIdentityToUpdateNotification, 57 59 } from "src/notifications"; 58 60 import { v7 } from "uuid"; 61 + import { 62 + isDocumentCollection, 63 + isPublicationCollection, 64 + getDocumentType, 65 + } from "src/utils/collectionHelpers"; 59 66 60 67 type PublishResult = 61 68 | { success: true; rkey: string; record: PubLeafletDocument.Record } ··· 189 196 } 190 197 } 191 198 192 - let record: PubLeafletDocument.Record = { 193 - publishedAt: new Date().toISOString(), 194 - ...existingRecord, 195 - $type: "pub.leaflet.document", 196 - author: credentialSession.did!, 197 - ...(publication_uri && { publication: publication_uri }), 198 - ...(theme && { theme }), 199 - title: title || "Untitled", 200 - description: description || "", 201 - ...(tags !== undefined && { tags }), // Include tags if provided (even if empty array to clear tags) 202 - ...(coverImageBlob && { coverImage: coverImageBlob }), // Include cover image if uploaded 203 - pages: pages.map((p) => { 204 - if (p.type === "canvas") { 205 - return { 206 - $type: "pub.leaflet.pages.canvas" as const, 207 - id: p.id, 208 - blocks: p.blocks as PubLeafletPagesCanvas.Block[], 209 - }; 210 - } else { 211 - return { 212 - $type: "pub.leaflet.pages.linearDocument" as const, 213 - id: p.id, 214 - blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], 215 - }; 216 - } 217 - }), 218 - }; 199 + // Determine the collection to use - preserve existing schema if updating 200 + const existingCollection = existingDocUri ? new AtUri(existingDocUri).collection : undefined; 201 + const documentType = getDocumentType(existingCollection); 202 + 203 + // Build the pages array (used by both formats) 204 + const pagesArray = pages.map((p) => { 205 + if (p.type === "canvas") { 206 + return { 207 + $type: "pub.leaflet.pages.canvas" as const, 208 + id: p.id, 209 + blocks: p.blocks as PubLeafletPagesCanvas.Block[], 210 + }; 211 + } else { 212 + return { 213 + $type: "pub.leaflet.pages.linearDocument" as const, 214 + id: p.id, 215 + blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], 216 + }; 217 + } 218 + }); 219 + 220 + // Create record based on the document type 221 + let record: PubLeafletDocument.Record | SiteStandardDocument.Record; 222 + 223 + if (documentType === "site.standard.document") { 224 + // site.standard.document format 225 + // For standalone docs, use a constructed site URI; for publication docs, use the publication URI 226 + const siteUri = publication_uri || `https://leaflet.pub/p/${credentialSession.did}`; 227 + 228 + record = { 229 + $type: "site.standard.document", 230 + title: title || "Untitled", 231 + site: siteUri, 232 + publishedAt: existingRecord.publishedAt || new Date().toISOString(), 233 + ...(description && { description }), 234 + ...(tags !== undefined && { tags }), 235 + ...(coverImageBlob && { coverImage: coverImageBlob }), 236 + content: { 237 + $type: "pub.leaflet.content" as const, 238 + pages: pagesArray, 239 + }, 240 + } satisfies SiteStandardDocument.Record; 241 + } else { 242 + // pub.leaflet.document format (legacy) 243 + record = { 244 + $type: "pub.leaflet.document", 245 + publishedAt: new Date().toISOString(), 246 + ...existingRecord, 247 + author: credentialSession.did!, 248 + ...(publication_uri && { publication: publication_uri }), 249 + ...(theme && { theme }), 250 + title: title || "Untitled", 251 + description: description || "", 252 + ...(tags !== undefined && { tags }), 253 + ...(coverImageBlob && { coverImage: coverImageBlob }), 254 + pages: pagesArray, 255 + } satisfies PubLeafletDocument.Record; 256 + } 219 257 220 258 // Keep the same rkey if updating an existing document 221 259 let rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr(); ··· 230 268 // Optimistically create database entries 231 269 await supabaseServerClient.from("documents").upsert({ 232 270 uri: result.uri, 233 - data: record as Json, 271 + data: record as unknown as Json, 234 272 }); 235 273 236 274 if (publication_uri) { ··· 852 890 */ 853 891 async function createMentionNotifications( 854 892 documentUri: string, 855 - record: PubLeafletDocument.Record, 893 + record: PubLeafletDocument.Record | SiteStandardDocument.Record, 856 894 authorDid: string, 857 895 ) { 858 896 const mentionedDids = new Set<string>(); 859 897 const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI 860 898 const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI 861 899 900 + // Extract pages from either format 901 + let pages: PubLeafletContent.Main["pages"] | undefined; 902 + if (record.$type === "site.standard.document") { 903 + const content = record.content; 904 + if (content && PubLeafletContent.isMain(content)) { 905 + pages = content.pages; 906 + } 907 + } else { 908 + pages = record.pages; 909 + } 910 + 911 + if (!pages) return; 912 + 862 913 // Extract mentions from all text blocks in all pages 863 - for (const page of record.pages) { 914 + for (const page of pages) { 864 915 if (page.$type === "pub.leaflet.pages.linearDocument") { 865 916 const linearPage = page as PubLeafletPagesLinearDocument.Main; 866 917 for (const blockWrapper of linearPage.blocks) { ··· 880 931 if (PubLeafletRichtextFacet.isAtMention(feature)) { 881 932 const uri = new AtUri(feature.atURI); 882 933 883 - if (uri.collection === "pub.leaflet.publication") { 934 + if (isPublicationCollection(uri.collection)) { 884 935 // Get the publication owner's DID 885 936 const { data: publication } = await supabaseServerClient 886 937 .from("publications") ··· 894 945 feature.atURI, 895 946 ); 896 947 } 897 - } else if (uri.collection === "pub.leaflet.document") { 948 + } else if (isDocumentCollection(uri.collection)) { 898 949 // Get the document owner's DID 899 950 const { data: document } = await supabaseServerClient 900 951 .from("documents")
+29 -6
app/api/inngest/functions/index_post_mention.ts
··· 6 6 import { Notification, pingIdentityToUpdateNotification } from "src/notifications"; 7 7 import { v7 } from "uuid"; 8 8 import { idResolver } from "app/(home-pages)/reader/idResolver"; 9 + import { documentUriFilter } from "src/utils/uriHelpers"; 9 10 10 11 export const index_post_mention = inngest.createFunction( 11 12 { id: "index_post_mention" }, ··· 37 38 did = resolved; 38 39 } 39 40 40 - documentUri = AtUri.make(did, ids.PubLeafletDocument, rkey).toString(); 41 + // Query the database to find the actual document URI (could be either namespace) 42 + const { data: docDataArr } = await supabaseServerClient 43 + .from("documents") 44 + .select("uri") 45 + .or(documentUriFilter(did, rkey)) 46 + .order("uri", { ascending: false }) 47 + .limit(1); 48 + const docData = docDataArr?.[0]; 49 + 50 + if (!docData) { 51 + return { message: `No document found for did:${did} rkey:${rkey}` }; 52 + } 53 + 54 + documentUri = docData.uri; 41 55 authorDid = did; 42 56 } else { 43 57 // Publication post: look up by custom domain ··· 54 68 }; 55 69 } 56 70 57 - documentUri = AtUri.make( 58 - pub.identity_did, 59 - ids.PubLeafletDocument, 60 - path[0], 61 - ).toString(); 71 + // Query the database to find the actual document URI (could be either namespace) 72 + const { data: docDataArr } = await supabaseServerClient 73 + .from("documents") 74 + .select("uri") 75 + .or(documentUriFilter(pub.identity_did, path[0])) 76 + .order("uri", { ascending: false }) 77 + .limit(1); 78 + const docData = docDataArr?.[0]; 79 + 80 + if (!docData) { 81 + return { message: `No document found for publication ${url.host}/${path[0]}` }; 82 + } 83 + 84 + documentUri = docData.uri; 62 85 authorDid = pub.identity_did; 63 86 } 64 87
+12 -5
app/api/pub_icon/route.ts
··· 6 6 normalizePublicationRecord, 7 7 type NormalizedPublication, 8 8 } from "src/utils/normalizeRecords"; 9 + import { 10 + isDocumentCollection, 11 + isPublicationCollection, 12 + } from "src/utils/collectionHelpers"; 13 + import { publicationUriFilter } from "src/utils/uriHelpers"; 9 14 import sharp from "sharp"; 10 15 11 16 const idResolver = new IdResolver(); ··· 36 41 let publicationUri: string; 37 42 38 43 // Check if it's a document or publication 39 - if (uri.collection === "pub.leaflet.document") { 44 + if (isDocumentCollection(uri.collection)) { 40 45 // Query the documents_in_publications table to get the publication 41 46 const { data: docInPub } = await supabaseServerClient 42 47 .from("documents_in_publications") ··· 50 55 51 56 publicationUri = docInPub.publication; 52 57 normalizedPub = normalizePublicationRecord(docInPub.publications.record); 53 - } else if (uri.collection === "pub.leaflet.publication") { 58 + } else if (isPublicationCollection(uri.collection)) { 54 59 // Query the publications table directly 55 - const { data: publication } = await supabaseServerClient 60 + const { data: publications } = await supabaseServerClient 56 61 .from("publications") 57 62 .select("record, uri") 58 - .eq("uri", at_uri) 59 - .single(); 63 + .or(publicationUriFilter(uri.host, uri.rkey)) 64 + .order("uri", { ascending: false }) 65 + .limit(1); 66 + const publication = publications?.[0]; 60 67 61 68 if (!publication || !publication.record) { 62 69 return new NextResponse(null, { status: 404 });
+1 -2
app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx
··· 1 1 import { AtpAgent } from "@atproto/api"; 2 - import { AtUri } from "@atproto/syntax"; 3 2 import { ids } from "lexicons/api/lexicons"; 4 3 import { 5 4 PubLeafletBlocksBskyPost, ··· 37 36 }); 38 37 39 38 let [document, profile] = await Promise.all([ 40 - getPostPageData(AtUri.make(did, ids.PubLeafletDocument, rkey).toString()), 39 + getPostPageData(did, rkey), 41 40 agent.getProfile({ actor: did }), 42 41 ]); 43 42
+6 -2
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
··· 17 17 pingIdentityToUpdateNotification, 18 18 } from "src/notifications"; 19 19 import { v7 } from "uuid"; 20 + import { 21 + isDocumentCollection, 22 + isPublicationCollection, 23 + } from "src/utils/collectionHelpers"; 20 24 21 25 type PublishCommentResult = 22 26 | { success: true; record: Json; profile: any; uri: string } ··· 180 184 if (notifiedRecipients.has(dedupeKey)) continue; 181 185 notifiedRecipients.add(dedupeKey); 182 186 183 - if (mentionedUri.collection === "pub.leaflet.publication") { 187 + if (isPublicationCollection(mentionedUri.collection)) { 184 188 notifications.push({ 185 189 id: v7(), 186 190 recipient: recipientDid, ··· 191 195 mentioned_uri: feature.atURI, 192 196 }, 193 197 }); 194 - } else if (mentionedUri.collection === "pub.leaflet.document") { 198 + } else if (isDocumentCollection(mentionedUri.collection)) { 195 199 notifications.push({ 196 200 id: v7(), 197 201 recipient: recipientDid,
+9 -6
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
··· 7 7 type NormalizedPublication, 8 8 } from "src/utils/normalizeRecords"; 9 9 import { PubLeafletPublication } from "lexicons/api"; 10 + import { documentUriFilter } from "src/utils/uriHelpers"; 10 11 11 - export async function getPostPageData(uri: string) { 12 - let { data: document } = await supabaseServerClient 12 + export async function getPostPageData(did: string, rkey: string) { 13 + let { data: documents } = await supabaseServerClient 13 14 .from("documents") 14 15 .select( 15 16 ` ··· 24 25 leaflets_in_publications(*) 25 26 `, 26 27 ) 27 - .eq("uri", uri) 28 - .single(); 28 + .or(documentUriFilter(did, rkey)) 29 + .order("uri", { ascending: false }) 30 + .limit(1); 31 + let document = documents?.[0]; 29 32 30 33 if (!document) return null; 31 34 ··· 39 42 ); 40 43 41 44 // Fetch constellation backlinks for mentions 42 - let aturi = new AtUri(uri); 45 + let aturi = new AtUri(document.uri); 43 46 const postUrl = normalizedPublication 44 47 ? `${normalizedPublication.url}/${aturi.rkey}` 45 48 : `https://leaflet.pub/p/${aturi.host}/${aturi.rkey}`; ··· 95 98 ); 96 99 97 100 // Find current document index 98 - const currentIndex = sortedDocs.findIndex((doc) => doc.uri === uri); 101 + const currentIndex = sortedDocs.findIndex((doc) => doc.uri === document.uri); 99 102 100 103 if (currentIndex !== -1) { 101 104 prevNext = {
+6 -5
app/lish/[did]/[publication]/[rkey]/opengraph-image.ts
··· 1 1 import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage"; 2 2 import { supabaseServerClient } from "supabase/serverClient"; 3 - import { AtUri } from "@atproto/syntax"; 4 - import { ids } from "lexicons/api/lexicons"; 5 3 import { jsonToLex } from "@atproto/lexicon"; 6 4 import { fetchAtprotoBlob } from "app/api/atproto_images/route"; 7 5 import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; 6 + import { documentUriFilter } from "src/utils/uriHelpers"; 8 7 9 8 export const revalidate = 60; 10 9 ··· 15 14 let did = decodeURIComponent(params.did); 16 15 17 16 // Try to get the document's cover image 18 - let { data: document } = await supabaseServerClient 17 + let { data: documents } = await supabaseServerClient 19 18 .from("documents") 20 19 .select("data") 21 - .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey).toString()) 22 - .single(); 20 + .or(documentUriFilter(did, params.rkey)) 21 + .order("uri", { ascending: false }) 22 + .limit(1); 23 + let document = documents?.[0]; 23 24 24 25 if (document) { 25 26 const docRecord = normalizeDocumentRecord(jsonToLex(document.data));
+6 -5
app/lish/[did]/[publication]/[rkey]/page.tsx
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 - import { AtUri } from "@atproto/syntax"; 3 - import { ids } from "lexicons/api/lexicons"; 4 2 import { Metadata } from "next"; 5 3 import { DocumentPageRenderer } from "./DocumentPageRenderer"; 6 4 import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; 5 + import { documentUriFilter } from "src/utils/uriHelpers"; 7 6 8 7 export async function generateMetadata(props: { 9 8 params: Promise<{ publication: string; did: string; rkey: string }>; ··· 12 11 let did = decodeURIComponent(params.did); 13 12 if (!did) return { title: "Publication 404" }; 14 13 15 - let [{ data: document }] = await Promise.all([ 14 + let [{ data: documents }] = await Promise.all([ 16 15 supabaseServerClient 17 16 .from("documents") 18 17 .select("*, documents_in_publications(publications(*))") 19 - .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey)) 20 - .single(), 18 + .or(documentUriFilter(did, params.rkey)) 19 + .order("uri", { ascending: false }) 20 + .limit(1), 21 21 ]); 22 + let document = documents?.[0]; 22 23 if (!document) return { title: "404" }; 23 24 24 25 const docRecord = normalizeDocumentRecord(document.data);
+6 -11
app/lish/[did]/[publication]/generateFeed.ts
··· 10 10 normalizeDocumentRecord, 11 11 hasLeafletContent, 12 12 } from "src/utils/normalizeRecords"; 13 + import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; 13 14 14 15 export async function generateFeed( 15 16 did: string, ··· 18 19 let renderToReadableStream = await import("react-dom/server").then( 19 20 (module) => module.renderToReadableStream, 20 21 ); 21 - let uri; 22 - if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(publication_name)) { 23 - uri = AtUri.make( 24 - did, 25 - "pub.leaflet.publication", 26 - publication_name, 27 - ).toString(); 28 - } 29 - let { data: publication } = await supabaseServerClient 22 + let { data: publications } = await supabaseServerClient 30 23 .from("publications") 31 24 .select( 32 25 `*, ··· 35 28 `, 36 29 ) 37 30 .eq("identity_did", did) 38 - .or(`name.eq."${publication_name}", uri.eq."${uri}"`) 39 - .single(); 31 + .or(publicationNameOrUriFilter(did, publication_name)) 32 + .order("uri", { ascending: false }) 33 + .limit(1); 34 + let publication = publications?.[0]; 40 35 41 36 const pubRecord = normalizePublicationRecord(publication?.record); 42 37 if (!publication || !pubRecord)
+7 -12
app/lish/[did]/[publication]/icon/route.ts
··· 1 1 import { NextRequest } from "next/server"; 2 2 import { IdResolver } from "@atproto/identity"; 3 - import { AtUri } from "@atproto/syntax"; 4 3 import { supabaseServerClient } from "supabase/serverClient"; 5 4 import sharp from "sharp"; 6 5 import { redirect } from "next/navigation"; 7 6 import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 7 + import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; 8 8 9 9 let idResolver = new IdResolver(); 10 10 ··· 18 18 const params = await props.params; 19 19 try { 20 20 let did = decodeURIComponent(params.did); 21 - let uri; 22 - if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(params.publication)) { 23 - uri = AtUri.make( 24 - did, 25 - "pub.leaflet.publication", 26 - params.publication, 27 - ).toString(); 28 - } 29 - let { data: publication } = await supabaseServerClient 21 + let publication_name = decodeURIComponent(params.publication); 22 + let { data: publications } = await supabaseServerClient 30 23 .from("publications") 31 24 .select( 32 25 `*, ··· 35 28 `, 36 29 ) 37 30 .eq("identity_did", did) 38 - .or(`name.eq."${params.publication}", uri.eq."${uri}"`) 39 - .single(); 31 + .or(publicationNameOrUriFilter(did, publication_name)) 32 + .order("uri", { ascending: false }) 33 + .limit(1); 34 + let publication = publications?.[0]; 40 35 41 36 const record = normalizePublicationRecord(publication?.record); 42 37 if (!record?.icon) return redirect("/icon.png");
+6 -12
app/lish/[did]/[publication]/layout.tsx
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 2 import { Metadata } from "next"; 3 - import { AtUri } from "@atproto/syntax"; 4 3 import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 4 + import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; 5 5 6 6 export default async function PublicationLayout(props: { 7 7 children: React.ReactNode; ··· 19 19 let did = decodeURIComponent(params.did); 20 20 if (!params.did || !params.publication) return { title: "Publication 404" }; 21 21 22 - let uri; 23 22 let publication_name = decodeURIComponent(params.publication); 24 - if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(publication_name)) { 25 - uri = AtUri.make( 26 - did, 27 - "pub.leaflet.publication", 28 - publication_name, 29 - ).toString(); 30 - } 31 - let { data: publication } = await supabaseServerClient 23 + let { data: publications } = await supabaseServerClient 32 24 .from("publications") 33 25 .select( 34 26 `*, ··· 37 29 `, 38 30 ) 39 31 .eq("identity_did", did) 40 - .or(`name.eq."${publication_name}", uri.eq."${uri}"`) 41 - .single(); 32 + .or(publicationNameOrUriFilter(did, publication_name)) 33 + .order("uri", { ascending: false }) 34 + .limit(1); 35 + let publication = publications?.[0]; 42 36 if (!publication) return { title: "Publication 404" }; 43 37 44 38 const pubRecord = normalizePublicationRecord(publication?.record);
+6 -11
app/lish/[did]/[publication]/page.tsx
··· 2 2 import { AtUri } from "@atproto/syntax"; 3 3 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4 4 import { BskyAgent } from "@atproto/api"; 5 + import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; 5 6 import { SubscribeWithBluesky } from "app/lish/Subscribe"; 6 7 import React from "react"; 7 8 import { ··· 27 28 let did = decodeURIComponent(params.did); 28 29 if (!did) return <PubNotFound />; 29 30 let agent = new BskyAgent({ service: "https://public.api.bsky.app" }); 30 - let uri; 31 31 let publication_name = decodeURIComponent(params.publication); 32 - if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(publication_name)) { 33 - uri = AtUri.make( 34 - did, 35 - "pub.leaflet.publication", 36 - publication_name, 37 - ).toString(); 38 - } 39 - let [{ data: publication }, { data: profile }] = await Promise.all([ 32 + let [{ data: publications }, { data: profile }] = await Promise.all([ 40 33 supabaseServerClient 41 34 .from("publications") 42 35 .select( ··· 50 43 `, 51 44 ) 52 45 .eq("identity_did", did) 53 - .or(`name.eq."${publication_name}", uri.eq."${uri}"`) 54 - .single(), 46 + .or(publicationNameOrUriFilter(did, publication_name)) 47 + .order("uri", { ascending: false }) 48 + .limit(1), 55 49 agent.getProfile({ actor: did }), 56 50 ]); 51 + let publication = publications?.[0]; 57 52 58 53 const record = normalizePublicationRecord(publication?.record); 59 54
+46 -21
app/lish/createPub/createPublication.ts
··· 1 1 "use server"; 2 2 import { TID } from "@atproto/common"; 3 - import { AtpBaseClient, PubLeafletPublication } from "lexicons/api"; 3 + import { 4 + AtpBaseClient, 5 + PubLeafletPublication, 6 + SiteStandardPublication, 7 + } from "lexicons/api"; 4 8 import { 5 9 restoreOAuthSession, 6 10 OAuthSessionError, 7 11 } from "src/atproto-oauth"; 8 12 import { getIdentityData } from "actions/getIdentityData"; 9 13 import { supabaseServerClient } from "supabase/serverClient"; 10 - import { Un$Typed } from "@atproto/api"; 11 14 import { Json } from "supabase/database.types"; 12 15 import { Vercel } from "@vercel/sdk"; 13 16 import { isProductionDomain } from "src/utils/isProductionDeployment"; 14 17 import { string } from "zod"; 18 + import { getPublicationType } from "src/utils/collectionHelpers"; 15 19 16 20 const VERCEL_TOKEN = process.env.VERCEL_TOKEN; 17 21 const vercel = new Vercel({ ··· 64 68 let agent = new AtpBaseClient( 65 69 credentialSession.fetchHandler.bind(credentialSession), 66 70 ); 67 - let record: Un$Typed<PubLeafletPublication.Record> = { 68 - name, 69 - base_path: domain, 70 - preferences, 71 - }; 72 71 73 - if (description) { 74 - record.description = description; 75 - } 72 + // Use site.standard.publication for new publications 73 + const publicationType = getPublicationType(); 74 + const url = `https://${domain}`; 75 + 76 + // Build record based on publication type 77 + let record: SiteStandardPublication.Record | PubLeafletPublication.Record; 78 + let iconBlob: Awaited<ReturnType<typeof agent.com.atproto.repo.uploadBlob>>["data"]["blob"] | undefined; 76 79 77 80 // Upload the icon if provided 78 81 if (iconFile && iconFile.size > 0) { ··· 81 84 new Uint8Array(buffer), 82 85 { encoding: iconFile.type }, 83 86 ); 87 + iconBlob = uploadResult.data.blob; 88 + } 84 89 85 - if (uploadResult.data.blob) { 86 - record.icon = uploadResult.data.blob; 87 - } 90 + if (publicationType === "site.standard.publication") { 91 + record = { 92 + $type: "site.standard.publication", 93 + name, 94 + url, 95 + ...(description && { description }), 96 + ...(iconBlob && { icon: iconBlob }), 97 + preferences: { 98 + showInDiscover: preferences.showInDiscover, 99 + showComments: preferences.showComments, 100 + showMentions: preferences.showMentions, 101 + showPrevNext: preferences.showPrevNext, 102 + }, 103 + } satisfies SiteStandardPublication.Record; 104 + } else { 105 + record = { 106 + $type: "pub.leaflet.publication", 107 + name, 108 + base_path: domain, 109 + ...(description && { description }), 110 + ...(iconBlob && { icon: iconBlob }), 111 + preferences, 112 + } satisfies PubLeafletPublication.Record; 88 113 } 89 114 90 - let result = await agent.pub.leaflet.publication.create( 91 - { repo: credentialSession.did!, rkey: TID.nextStr(), validate: false }, 115 + let { data: result } = await agent.com.atproto.repo.putRecord({ 116 + repo: credentialSession.did!, 117 + rkey: TID.nextStr(), 118 + collection: publicationType, 92 119 record, 93 - ); 120 + validate: false, 121 + }); 94 122 95 123 //optimistically write to our db! 96 124 let { data: publication } = await supabaseServerClient ··· 98 126 .upsert({ 99 127 uri: result.uri, 100 128 identity_did: credentialSession.did!, 101 - name: record.name, 102 - record: { 103 - ...record, 104 - $type: "pub.leaflet.publication", 105 - } as unknown as Json, 129 + name, 130 + record: record as unknown as Json, 106 131 }) 107 132 .select() 108 133 .single();
+20 -13
app/lish/createPub/updatePublication.ts
··· 15 15 normalizePublicationRecord, 16 16 type NormalizedPublication, 17 17 } from "src/utils/normalizeRecords"; 18 + import { getPublicationType } from "src/utils/collectionHelpers"; 18 19 19 20 type UpdatePublicationResult = 20 21 | { success: true; publication: any } ··· 62 63 return { success: false }; 63 64 } 64 65 let aturi = new AtUri(existingPub.uri); 66 + // Preserve existing schema when updating 67 + const publicationType = getPublicationType(aturi.collection); 65 68 66 - let record: PubLeafletPublication.Record = { 67 - $type: "pub.leaflet.publication", 69 + let record = { 70 + $type: publicationType, 68 71 ...(existingPub.record as object), 69 72 name, 70 - }; 73 + } as PubLeafletPublication.Record; 71 74 if (preferences) { 72 75 record.preferences = preferences; 73 76 } ··· 93 96 repo: credentialSession.did!, 94 97 rkey: aturi.rkey, 95 98 record, 96 - collection: record.$type, 99 + collection: publicationType, 97 100 validate: false, 98 101 }); 99 102 ··· 146 149 return { success: false }; 147 150 } 148 151 let aturi = new AtUri(existingPub.uri); 152 + // Preserve existing schema when updating 153 + const publicationType = getPublicationType(aturi.collection); 149 154 150 - // Normalize the existing record to read its properties, then build a new pub.leaflet record 155 + // Normalize the existing record to read its properties 151 156 const normalizedPub = normalizePublicationRecord(existingPub.record); 152 157 // Extract base_path from url if it exists (url format is https://domain, base_path is just domain) 153 158 const existingBasePath = normalizedPub?.url 154 159 ? normalizedPub.url.replace(/^https?:\/\//, "") 155 160 : undefined; 156 161 157 - let record: PubLeafletPublication.Record = { 158 - $type: "pub.leaflet.publication", 162 + let record = { 163 + $type: publicationType, 159 164 name: normalizedPub?.name || "", 160 165 description: normalizedPub?.description, 161 166 icon: normalizedPub?.icon, ··· 170 175 } 171 176 : undefined, 172 177 base_path, 173 - }; 178 + } as PubLeafletPublication.Record; 174 179 175 180 let result = await agent.com.atproto.repo.putRecord({ 176 181 repo: credentialSession.did!, 177 182 rkey: aturi.rkey, 178 183 record, 179 - collection: record.$type, 184 + collection: publicationType, 180 185 validate: false, 181 186 }); 182 187 ··· 242 247 return { success: false }; 243 248 } 244 249 let aturi = new AtUri(existingPub.uri); 250 + // Preserve existing schema when updating 251 + const publicationType = getPublicationType(aturi.collection); 245 252 246 253 // Normalize the existing record to read its properties 247 254 const normalizedPub = normalizePublicationRecord(existingPub.record); ··· 250 257 ? normalizedPub.url.replace(/^https?:\/\//, "") 251 258 : undefined; 252 259 253 - let record: PubLeafletPublication.Record = { 254 - $type: "pub.leaflet.publication", 260 + let record = { 261 + $type: publicationType, 255 262 name: normalizedPub?.name || "", 256 263 description: normalizedPub?.description, 257 264 icon: normalizedPub?.icon, ··· 301 308 ...theme.accentText, 302 309 }, 303 310 }, 304 - }; 311 + } as PubLeafletPublication.Record; 305 312 306 313 let result = await agent.com.atproto.repo.putRecord({ 307 314 repo: credentialSession.did!, 308 315 rkey: aturi.rkey, 309 316 record, 310 - collection: record.$type, 317 + collection: publicationType, 311 318 validate: false, 312 319 }); 313 320
+6 -2
app/lish/uri/[uri]/route.ts
··· 5 5 normalizePublicationRecord, 6 6 type NormalizedPublication, 7 7 } from "src/utils/normalizeRecords"; 8 + import { 9 + isDocumentCollection, 10 + isPublicationCollection, 11 + } from "src/utils/collectionHelpers"; 8 12 9 13 /** 10 14 * Redirect route for AT URIs (publications and documents) ··· 19 23 const atUriString = decodeURIComponent(uriParam); 20 24 const uri = new AtUri(atUriString); 21 25 22 - if (uri.collection === "pub.leaflet.publication") { 26 + if (isPublicationCollection(uri.collection)) { 23 27 // Get the publication record to retrieve base_path 24 28 const { data: publication } = await supabaseServerClient 25 29 .from("publications") ··· 40 44 41 45 // Redirect to the publication's hosted domain (temporary redirect since url can change) 42 46 return NextResponse.redirect(normalizedPub.url, 307); 43 - } else if (uri.collection === "pub.leaflet.document") { 47 + } else if (isDocumentCollection(uri.collection)) { 44 48 // Document link - need to find the publication it belongs to 45 49 const { data: docInPub } = await supabaseServerClient 46 50 .from("documents_in_publications")
+6 -5
app/p/[didOrHandle]/[rkey]/opengraph-image.ts
··· 1 1 import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage"; 2 2 import { supabaseServerClient } from "supabase/serverClient"; 3 - import { AtUri } from "@atproto/syntax"; 4 - import { ids } from "lexicons/api/lexicons"; 5 3 import { jsonToLex } from "@atproto/lexicon"; 6 4 import { idResolver } from "app/(home-pages)/reader/idResolver"; 7 5 import { fetchAtprotoBlob } from "app/api/atproto_images/route"; 8 6 import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; 7 + import { documentUriFilter } from "src/utils/uriHelpers"; 9 8 10 9 export const revalidate = 60; 11 10 ··· 28 27 29 28 if (did) { 30 29 // Try to get the document's cover image 31 - let { data: document } = await supabaseServerClient 30 + let { data: documents } = await supabaseServerClient 32 31 .from("documents") 33 32 .select("data") 34 - .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey).toString()) 35 - .single(); 33 + .or(documentUriFilter(did, params.rkey)) 34 + .order("uri", { ascending: false }) 35 + .limit(1); 36 + let document = documents?.[0]; 36 37 37 38 if (document) { 38 39 const docRecord = normalizeDocumentRecord(jsonToLex(document.data));
+8 -13
app/p/[didOrHandle]/[rkey]/page.tsx
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 - import { AtUri } from "@atproto/syntax"; 3 - import { ids } from "lexicons/api/lexicons"; 4 2 import { Metadata } from "next"; 5 3 import { idResolver } from "app/(home-pages)/reader/idResolver"; 6 4 import { DocumentPageRenderer } from "app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer"; 7 5 import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 8 6 import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; 7 + import { documentUriFilter } from "src/utils/uriHelpers"; 9 8 10 9 export async function generateMetadata(props: { 11 10 params: Promise<{ didOrHandle: string; rkey: string }>; ··· 24 23 } 25 24 } 26 25 27 - let { data: document } = await supabaseServerClient 26 + let { data: documents } = await supabaseServerClient 28 27 .from("documents") 29 - .select("*, documents_in_publications(publications(*))") 30 - .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey)) 31 - .single(); 28 + .select("*") 29 + .or(documentUriFilter(did, params.rkey)) 30 + .order("uri", { ascending: false }) 31 + .limit(1); 32 + let document = documents?.[0]; 32 33 33 34 if (!document) return { title: "404" }; 34 35 35 36 const docRecord = normalizeDocumentRecord(document.data); 36 37 if (!docRecord) return { title: "404" }; 37 38 38 - // For documents in publications, include publication name 39 - let publicationName = 40 - document.documents_in_publications[0]?.publications?.name; 41 - 42 39 return { 43 40 icons: { 44 41 other: { ··· 46 43 url: document.uri, 47 44 }, 48 45 }, 49 - title: publicationName 50 - ? `${docRecord.title} - ${publicationName}` 51 - : docRecord.title, 46 + title: docRecord.title, 52 47 description: docRecord?.description || "", 53 48 }; 54 49 }
+6 -2
components/AtMentionLink.tsx
··· 1 1 import { AtUri } from "@atproto/api"; 2 2 import { atUriToUrl } from "src/utils/mentionUtils"; 3 + import { 4 + isDocumentCollection, 5 + isPublicationCollection, 6 + } from "src/utils/collectionHelpers"; 3 7 4 8 /** 5 9 * Component for rendering at-uri mentions (publications and documents) as clickable links. ··· 16 20 className?: string; 17 21 }) { 18 22 const aturi = new AtUri(atURI); 19 - const isPublication = aturi.collection === "pub.leaflet.publication"; 20 - const isDocument = aturi.collection === "pub.leaflet.document"; 23 + const isPublication = isPublicationCollection(aturi.collection); 24 + const isDocument = isDocumentCollection(aturi.collection); 21 25 22 26 // Show publication icon if available 23 27 const icon =
+8 -4
components/Blocks/TextBlock/schema.ts
··· 2 2 import { Schema, Node, MarkSpec, NodeSpec } from "prosemirror-model"; 3 3 import { marks } from "prosemirror-schema-basic"; 4 4 import { theme } from "tailwind.config"; 5 + import { 6 + isDocumentCollection, 7 + isPublicationCollection, 8 + } from "src/utils/collectionHelpers"; 5 9 6 10 let baseSchema = { 7 11 marks: { ··· 149 153 // components/AtMentionLink.tsx. If you update one, update the other. 150 154 let className = "atMention mention"; 151 155 let aturi = new AtUri(node.attrs.atURI); 152 - if (aturi.collection === "pub.leaflet.publication") 156 + if (isPublicationCollection(aturi.collection)) 153 157 className += " font-bold"; 154 - if (aturi.collection === "pub.leaflet.document") className += " italic"; 158 + if (isDocumentCollection(aturi.collection)) className += " italic"; 155 159 156 160 // For publications and documents, show icon 157 161 if ( 158 - aturi.collection === "pub.leaflet.publication" || 159 - aturi.collection === "pub.leaflet.document" 162 + isPublicationCollection(aturi.collection) || 163 + isDocumentCollection(aturi.collection) 160 164 ) { 161 165 return [ 162 166 "span",
+57
src/utils/collectionHelpers.ts
··· 1 + import { ids } from "lexicons/api/lexicons"; 2 + 3 + /** 4 + * Check if a collection is a document collection (either namespace). 5 + */ 6 + export function isDocumentCollection(collection: string): boolean { 7 + return ( 8 + collection === ids.PubLeafletDocument || 9 + collection === ids.SiteStandardDocument 10 + ); 11 + } 12 + 13 + /** 14 + * Check if a collection is a publication collection (either namespace). 15 + */ 16 + export function isPublicationCollection(collection: string): boolean { 17 + return ( 18 + collection === ids.PubLeafletPublication || 19 + collection === ids.SiteStandardPublication 20 + ); 21 + } 22 + 23 + /** 24 + * Check if a collection belongs to the site.standard namespace. 25 + */ 26 + export function isSiteStandardCollection(collection: string): boolean { 27 + return collection.startsWith("site.standard."); 28 + } 29 + 30 + /** 31 + * Check if a collection belongs to the pub.leaflet namespace. 32 + */ 33 + export function isPubLeafletCollection(collection: string): boolean { 34 + return collection.startsWith("pub.leaflet."); 35 + } 36 + 37 + /** 38 + * Get the document $type to use based on an existing URI's collection. 39 + * If no existing URI or collection isn't a document, defaults to site.standard.document. 40 + */ 41 + export function getDocumentType(existingCollection?: string): "pub.leaflet.document" | "site.standard.document" { 42 + if (existingCollection === ids.PubLeafletDocument) { 43 + return ids.PubLeafletDocument as "pub.leaflet.document"; 44 + } 45 + return ids.SiteStandardDocument as "site.standard.document"; 46 + } 47 + 48 + /** 49 + * Get the publication $type to use based on an existing URI's collection. 50 + * If no existing URI or collection isn't a publication, defaults to site.standard.publication. 51 + */ 52 + export function getPublicationType(existingCollection?: string): "pub.leaflet.publication" | "site.standard.publication" { 53 + if (existingCollection === ids.PubLeafletPublication) { 54 + return ids.PubLeafletPublication as "pub.leaflet.publication"; 55 + } 56 + return ids.SiteStandardPublication as "site.standard.publication"; 57 + }
+6 -2
src/utils/mentionUtils.ts
··· 1 1 import { AtUri } from "@atproto/api"; 2 + import { 3 + isDocumentCollection, 4 + isPublicationCollection, 5 + } from "src/utils/collectionHelpers"; 2 6 3 7 /** 4 8 * Converts a DID to a Bluesky profile URL ··· 14 18 try { 15 19 const uri = new AtUri(atUri); 16 20 17 - if (uri.collection === "pub.leaflet.publication") { 21 + if (isPublicationCollection(uri.collection)) { 18 22 // Publication URL: /lish/{did}/{rkey} 19 23 return `/lish/${uri.host}/${uri.rkey}`; 20 - } else if (uri.collection === "pub.leaflet.document") { 24 + } else if (isDocumentCollection(uri.collection)) { 21 25 // Document URL - we need to resolve this via the API 22 26 // For now, create a redirect route that will handle it 23 27 return `/lish/uri/${encodeURIComponent(atUri)}`;
+34
src/utils/uriHelpers.ts
··· 1 + import { AtUri } from "@atproto/syntax"; 2 + import { ids } from "lexicons/api/lexicons"; 3 + 4 + /** 5 + * Returns an OR filter string for Supabase queries to match either namespace URI. 6 + * Used for querying documents that may be stored under either pub.leaflet.document 7 + * or site.standard.document namespaces. 8 + */ 9 + export function documentUriFilter(did: string, rkey: string): string { 10 + const standard = AtUri.make(did, ids.SiteStandardDocument, rkey).toString(); 11 + const legacy = AtUri.make(did, ids.PubLeafletDocument, rkey).toString(); 12 + return `uri.eq.${standard},uri.eq.${legacy}`; 13 + } 14 + 15 + /** 16 + * Returns an OR filter string for Supabase queries to match either namespace URI. 17 + * Used for querying publications that may be stored under either pub.leaflet.publication 18 + * or site.standard.publication namespaces. 19 + */ 20 + export function publicationUriFilter(did: string, rkey: string): string { 21 + const standard = AtUri.make(did, ids.SiteStandardPublication, rkey).toString(); 22 + const legacy = AtUri.make(did, ids.PubLeafletPublication, rkey).toString(); 23 + return `uri.eq.${standard},uri.eq.${legacy}`; 24 + } 25 + 26 + /** 27 + * Returns an OR filter string for Supabase queries to match a publication by name 28 + * or by either namespace URI. Used when the rkey might be the publication name. 29 + */ 30 + export function publicationNameOrUriFilter(did: string, nameOrRkey: string): string { 31 + const standard = AtUri.make(did, ids.SiteStandardPublication, nameOrRkey).toString(); 32 + const legacy = AtUri.make(did, ids.PubLeafletPublication, nameOrRkey).toString(); 33 + return `name.eq."${nameOrRkey}",uri.eq.${standard},uri.eq.${legacy}`; 34 + }