a tool for shared writing and social publishing

handle differing uris

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