a tool for shared writing and social publishing

Refactor/standard.site (#259)

* add basic site.standard stuff and normalization function

* normalize data from publication and document tables

* handle differing uris

* fix a few bugs

* fix feed construction and simplify updatePub functions

* don't delete old records

* add themes for standalone docs, etc

* handle migrating all records

* simplify updatePublication

* delete old docs and new ones

* handle setting basicTheme on create/update pubs

* set doc path properly

* fix type errors

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

authored by awarm.space

Claude Opus 4.5 and committed by
GitHub
69de7db6 e13306ec

+4703 -1158
+116 -41
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, ··· 43 45 import { Lock } from "src/utils/lock"; 44 46 import type { PubLeafletPublication } from "lexicons/api"; 45 47 import { 48 + normalizeDocumentRecord, 49 + type NormalizedDocument, 50 + } from "src/utils/normalizeRecords"; 51 + import { 46 52 ColorToRGB, 47 53 ColorToRGBA, 48 54 } from "components/ThemeManager/colorToLexicons"; ··· 52 58 pingIdentityToUpdateNotification, 53 59 } from "src/notifications"; 54 60 import { v7 } from "uuid"; 61 + import { 62 + isDocumentCollection, 63 + isPublicationCollection, 64 + getDocumentType, 65 + } from "src/utils/collectionHelpers"; 55 66 56 67 type PublishResult = 57 68 | { success: true; rkey: string; record: PubLeafletDocument.Record } ··· 149 160 credentialSession.did!, 150 161 ); 151 162 152 - let existingRecord = draft?.documents?.data as 153 - | PubLeafletDocument.Record 154 - | undefined; 163 + let existingRecord: Partial<PubLeafletDocument.Record> = {}; 164 + const normalizedDoc = normalizeDocumentRecord(draft?.documents?.data); 165 + if (normalizedDoc) { 166 + // When reading existing data, use normalized format to extract fields 167 + // The theme is preserved in NormalizedDocument for backward compatibility 168 + existingRecord = { 169 + publishedAt: normalizedDoc.publishedAt, 170 + title: normalizedDoc.title, 171 + description: normalizedDoc.description, 172 + tags: normalizedDoc.tags, 173 + coverImage: normalizedDoc.coverImage, 174 + theme: normalizedDoc.theme, 175 + }; 176 + } 155 177 156 178 // Extract theme for standalone documents (not for publications) 157 179 let theme: PubLeafletPublication.Theme | undefined; ··· 176 198 } 177 199 } 178 200 179 - let record: PubLeafletDocument.Record = { 180 - $type: "pub.leaflet.document", 181 - author: credentialSession.did!, 182 - ...(publication_uri && { publication: publication_uri }), 183 - ...(theme && { theme }), 184 - title: title || "Untitled", 185 - description: description || "", 186 - ...(tags !== undefined && { tags }), // Include tags if provided (even if empty array to clear tags) 187 - ...(coverImageBlob && { coverImage: coverImageBlob }), // Include cover image if uploaded 188 - pages: pages.map((p) => { 189 - if (p.type === "canvas") { 190 - return { 191 - $type: "pub.leaflet.pages.canvas" as const, 192 - id: p.id, 193 - blocks: p.blocks as PubLeafletPagesCanvas.Block[], 194 - }; 195 - } else { 196 - return { 197 - $type: "pub.leaflet.pages.linearDocument" as const, 198 - id: p.id, 199 - blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], 200 - }; 201 - } 202 - }), 203 - publishedAt: 204 - publishedAt || existingRecord?.publishedAt || new Date().toISOString(), 205 - }; 201 + // Determine the collection to use - preserve existing schema if updating 202 + const existingCollection = existingDocUri ? new AtUri(existingDocUri).collection : undefined; 203 + const documentType = getDocumentType(existingCollection); 206 204 207 - // Keep the same rkey if updating an existing document 208 - let rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr(); 205 + // Build the pages array (used by both formats) 206 + const pagesArray = pages.map((p) => { 207 + if (p.type === "canvas") { 208 + return { 209 + $type: "pub.leaflet.pages.canvas" as const, 210 + id: p.id, 211 + blocks: p.blocks as PubLeafletPagesCanvas.Block[], 212 + }; 213 + } else { 214 + return { 215 + $type: "pub.leaflet.pages.linearDocument" as const, 216 + id: p.id, 217 + blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], 218 + }; 219 + } 220 + }); 221 + 222 + // Determine the rkey early since we need it for the path field 223 + const rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr(); 224 + 225 + // Create record based on the document type 226 + let record: PubLeafletDocument.Record | SiteStandardDocument.Record; 227 + 228 + if (documentType === "site.standard.document") { 229 + // site.standard.document format 230 + // For standalone docs, use HTTPS URL; for publication docs, use the publication AT-URI 231 + const siteUri = publication_uri || `https://leaflet.pub/p/${credentialSession.did}`; 232 + 233 + record = { 234 + $type: "site.standard.document", 235 + title: title || "Untitled", 236 + site: siteUri, 237 + path: rkey, 238 + publishedAt: 239 + publishedAt || existingRecord.publishedAt || new Date().toISOString(), 240 + ...(description && { description }), 241 + ...(tags !== undefined && { tags }), 242 + ...(coverImageBlob && { coverImage: coverImageBlob }), 243 + // Include theme for standalone documents (not for publication documents) 244 + ...(!publication_uri && theme && { theme }), 245 + content: { 246 + $type: "pub.leaflet.content" as const, 247 + pages: pagesArray, 248 + }, 249 + } satisfies SiteStandardDocument.Record; 250 + } else { 251 + // pub.leaflet.document format (legacy) 252 + record = { 253 + $type: "pub.leaflet.document", 254 + author: credentialSession.did!, 255 + ...(publication_uri && { publication: publication_uri }), 256 + ...(theme && { theme }), 257 + title: title || "Untitled", 258 + description: description || "", 259 + ...(tags !== undefined && { tags }), 260 + ...(coverImageBlob && { coverImage: coverImageBlob }), 261 + pages: pagesArray, 262 + publishedAt: 263 + publishedAt || existingRecord.publishedAt || new Date().toISOString(), 264 + } satisfies PubLeafletDocument.Record; 265 + } 266 + 209 267 let { data: result } = await agent.com.atproto.repo.putRecord({ 210 268 rkey, 211 269 repo: credentialSession.did!, ··· 217 275 // Optimistically create database entries 218 276 await supabaseServerClient.from("documents").upsert({ 219 277 uri: result.uri, 220 - data: record as Json, 278 + data: record as unknown as Json, 221 279 }); 222 280 223 281 if (publication_uri) { ··· 839 897 */ 840 898 async function createMentionNotifications( 841 899 documentUri: string, 842 - record: PubLeafletDocument.Record, 900 + record: PubLeafletDocument.Record | SiteStandardDocument.Record, 843 901 authorDid: string, 844 902 ) { 845 903 const mentionedDids = new Set<string>(); 846 904 const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI 847 905 const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI 848 906 907 + // Extract pages from either format 908 + let pages: PubLeafletContent.Main["pages"] | undefined; 909 + if (record.$type === "site.standard.document") { 910 + const content = record.content; 911 + if (content && PubLeafletContent.isMain(content)) { 912 + pages = content.pages; 913 + } 914 + } else { 915 + pages = record.pages; 916 + } 917 + 918 + if (!pages) return; 919 + 849 920 // Extract mentions from all text blocks in all pages 850 - for (const page of record.pages) { 921 + for (const page of pages) { 851 922 if (page.$type === "pub.leaflet.pages.linearDocument") { 852 923 const linearPage = page as PubLeafletPagesLinearDocument.Main; 853 924 for (const blockWrapper of linearPage.blocks) { ··· 867 938 if (PubLeafletRichtextFacet.isAtMention(feature)) { 868 939 const uri = new AtUri(feature.atURI); 869 940 870 - if (uri.collection === "pub.leaflet.publication") { 941 + if (isPublicationCollection(uri.collection)) { 871 942 // Get the publication owner's DID 872 943 const { data: publication } = await supabaseServerClient 873 944 .from("publications") ··· 881 952 feature.atURI, 882 953 ); 883 954 } 884 - } else if (uri.collection === "pub.leaflet.document") { 955 + } else if (isDocumentCollection(uri.collection)) { 885 956 // Get the document owner's DID 886 957 const { data: document } = await supabaseServerClient 887 958 .from("documents") ··· 890 961 .single(); 891 962 892 963 if (document) { 893 - const docRecord = 894 - document.data as PubLeafletDocument.Record; 895 - if (docRecord.author !== authorDid) { 896 - mentionedDocuments.set(docRecord.author, feature.atURI); 964 + const normalizedMentionedDoc = normalizeDocumentRecord( 965 + document.data, 966 + ); 967 + // Get the author from the document URI (the DID is the host part) 968 + const mentionedUri = new AtUri(feature.atURI); 969 + const docAuthor = mentionedUri.host; 970 + if (normalizedMentionedDoc && docAuthor !== authorDid) { 971 + mentionedDocuments.set(docAuthor, feature.atURI); 897 972 } 898 973 } 899 974 }
+3 -5
app/(home-pages)/discover/PubListing.tsx
··· 6 6 import { Separator } from "components/Layout"; 7 7 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 8 8 import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; 9 - import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; 10 9 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 10 import { timeAgo } from "src/utils/timeAgo"; 12 - import { Json } from "supabase/database.types"; 13 11 14 12 export const PubListing = ( 15 13 props: PublicationSubscription & { 16 14 resizeHeight?: boolean; 17 15 }, 18 16 ) => { 19 - let record = props.record as PubLeafletPublication.Record; 20 - let theme = usePubTheme(record.theme); 17 + let record = props.record; 18 + let theme = usePubTheme(record?.theme); 21 19 let backgroundImage = record?.theme?.backgroundImage?.image?.ref 22 20 ? blobRefToSrc( 23 21 record?.theme?.backgroundImage?.image?.ref, ··· 31 29 return ( 32 30 <BaseThemeProvider {...theme} local> 33 31 <a 34 - href={`https://${record.base_path}`} 32 + href={record.url} 35 33 className={`no-underline! flex flex-row gap-2 36 34 bg-bg-leaflet 37 35 border border-border-light rounded-lg
+17 -7
app/(home-pages)/discover/getPublications.ts
··· 1 1 "use server"; 2 2 3 3 import { supabaseServerClient } from "supabase/serverClient"; 4 + import { 5 + normalizePublicationRow, 6 + hasValidPublication, 7 + } from "src/utils/normalizeRecords"; 4 8 5 9 export type Cursor = { 6 10 indexed_at?: string; ··· 98 102 // Get the page 99 103 const page = allPubs.slice(startIndex, startIndex + limit); 100 104 101 - // Create next cursor 105 + // Normalize publication records 106 + const normalizedPage = page 107 + .map(normalizePublicationRow) 108 + .filter(hasValidPublication); 109 + 110 + // Create next cursor based on last item in normalizedPage 111 + const lastItem = normalizedPage[normalizedPage.length - 1]; 102 112 const nextCursor = 103 - page.length === limit && startIndex + limit < allPubs.length 113 + normalizedPage.length > 0 && startIndex + limit < allPubs.length 104 114 ? order === "recentlyUpdated" 105 115 ? { 106 - indexed_at: page[page.length - 1].documents_in_publications[0]?.indexed_at, 107 - uri: page[page.length - 1].uri, 116 + indexed_at: lastItem.documents_in_publications[0]?.indexed_at, 117 + uri: lastItem.uri, 108 118 } 109 119 : { 110 - count: page[page.length - 1].publication_subscriptions[0]?.count || 0, 111 - uri: page[page.length - 1].uri, 120 + count: lastItem.publication_subscriptions[0]?.count || 0, 121 + uri: lastItem.uri, 112 122 } 113 123 : null; 114 124 115 125 return { 116 - publications: page, 126 + publications: normalizedPage, 117 127 nextCursor, 118 128 }; 119 129 }
+11 -18
app/(home-pages)/notifications/CommentMentionNotification.tsx
··· 1 - import { 2 - AppBskyActorProfile, 3 - PubLeafletComment, 4 - PubLeafletDocument, 5 - PubLeafletPublication, 6 - } from "lexicons/api"; 1 + import { AppBskyActorProfile, PubLeafletComment } from "lexicons/api"; 7 2 import { HydratedCommentMentionNotification } from "src/notifications"; 8 3 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 9 4 import { MentionTiny } from "components/Icons/MentionTiny"; ··· 17 12 export const CommentMentionNotification = ( 18 13 props: HydratedCommentMentionNotification, 19 14 ) => { 20 - const docRecord = props.commentData.documents 21 - ?.data as PubLeafletDocument.Record; 15 + const docRecord = props.normalizedDocument; 16 + if (!docRecord) return null; 17 + 22 18 const commentRecord = props.commentData.record as PubLeafletComment.Record; 23 19 const profileRecord = props.commentData.bsky_profiles 24 20 ?.record as AppBskyActorProfile.Record; 25 - const pubRecord = props.commentData.documents?.documents_in_publications[0] 26 - ?.publications?.record as PubLeafletPublication.Record | undefined; 21 + const pubRecord = props.normalizedPublication; 27 22 const docUri = new AtUri(props.commentData.documents?.uri!); 28 23 const rkey = docUri.rkey; 29 24 const did = docUri.host; 30 25 31 26 const href = pubRecord 32 - ? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments` 27 + ? `${pubRecord.url}/${rkey}?interactionDrawer=comments` 33 28 : `/p/${did}/${rkey}?interactionDrawer=comments`; 34 29 35 30 const commenter = props.commenterHandle ··· 37 32 : "Someone"; 38 33 39 34 let actionText: React.ReactNode; 40 - let mentionedDocRecord = props.mentionedDocument 41 - ?.data as PubLeafletDocument.Record; 35 + const mentionedDocRecord = props.normalizedMentionedDocument; 42 36 43 37 if (props.mention_type === "did") { 44 38 actionText = <>{commenter} mentioned you in a comment</>; ··· 46 40 props.mention_type === "publication" && 47 41 props.mentionedPublication 48 42 ) { 49 - const mentionedPubRecord = props.mentionedPublication 50 - .record as PubLeafletPublication.Record; 43 + const mentionedPubRecord = props.normalizedMentionedPublication; 51 44 actionText = ( 52 45 <> 53 46 {commenter} mentioned your publication{" "} 54 - <span className="italic">{mentionedPubRecord.name}</span> in a comment 47 + <span className="italic">{mentionedPubRecord?.name}</span> in a comment 55 48 </> 56 49 ); 57 - } else if (props.mention_type === "document" && props.mentionedDocument) { 50 + } else if (props.mention_type === "document" && mentionedDocRecord) { 58 51 actionText = ( 59 52 <> 60 53 {commenter} mentioned your post{" "} ··· 72 65 icon={<MentionTiny />} 73 66 actionText={actionText} 74 67 content={ 75 - <ContentLayout postTitle={docRecord?.title} pubRecord={pubRecord}> 68 + <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 76 69 <CommentInNotification 77 70 className="" 78 71 avatar={
+13 -17
app/(home-pages)/notifications/CommentNotication.tsx
··· 1 1 import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; 2 - import { 3 - AppBskyActorProfile, 4 - PubLeafletComment, 5 - PubLeafletDocument, 6 - PubLeafletPublication, 7 - } from "lexicons/api"; 2 + import { AppBskyActorProfile, PubLeafletComment } from "lexicons/api"; 8 3 import { HydratedCommentNotification } from "src/notifications"; 9 4 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 10 5 import { Avatar } from "components/Avatar"; ··· 17 12 import { AtUri } from "@atproto/api"; 18 13 19 14 export const CommentNotification = (props: HydratedCommentNotification) => { 20 - let docRecord = props.commentData.documents 21 - ?.data as PubLeafletDocument.Record; 22 - let commentRecord = props.commentData.record as PubLeafletComment.Record; 23 - let profileRecord = props.commentData.bsky_profiles 15 + const docRecord = props.normalizedDocument; 16 + const commentRecord = props.commentData.record as PubLeafletComment.Record; 17 + const profileRecord = props.commentData.bsky_profiles 24 18 ?.record as AppBskyActorProfile.Record; 19 + 20 + if (!docRecord) return null; 21 + 25 22 const displayName = 26 - profileRecord.displayName || 23 + profileRecord?.displayName || 27 24 props.commentData.bsky_profiles?.handle || 28 25 "Someone"; 29 - const pubRecord = props.commentData.documents?.documents_in_publications[0] 30 - ?.publications?.record as PubLeafletPublication.Record | undefined; 31 - let docUri = new AtUri(props.commentData.documents?.uri!); 32 - let rkey = docUri.rkey; 33 - let did = docUri.host; 26 + const pubRecord = props.normalizedPublication; 27 + const docUri = new AtUri(props.commentData.documents?.uri!); 28 + const rkey = docUri.rkey; 29 + const did = docUri.host; 34 30 35 31 const href = pubRecord 36 - ? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments` 32 + ? `${pubRecord.url}/${rkey}?interactionDrawer=comments` 37 33 : `/p/${did}/${rkey}?interactionDrawer=comments`; 38 34 39 35 return (
+3 -4
app/(home-pages)/notifications/FollowNotification.tsx
··· 2 2 import { Notification } from "./Notification"; 3 3 import { HydratedSubscribeNotification } from "src/notifications"; 4 4 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 5 - import { AppBskyActorProfile, PubLeafletPublication } from "lexicons/api"; 5 + import { AppBskyActorProfile } from "lexicons/api"; 6 6 7 7 export const FollowNotification = (props: HydratedSubscribeNotification) => { 8 8 const profileRecord = props.subscriptionData?.identities?.bsky_profiles ··· 11 11 profileRecord?.displayName || 12 12 props.subscriptionData?.identities?.bsky_profiles?.handle || 13 13 "Someone"; 14 - const pubRecord = props.subscriptionData?.publications 15 - ?.record as PubLeafletPublication.Record; 14 + const pubRecord = props.normalizedPublication; 16 15 const avatarSrc = 17 16 profileRecord?.avatar?.ref && 18 17 blobRefToSrc( ··· 23 22 return ( 24 23 <Notification 25 24 timestamp={props.created_at} 26 - href={`https://${pubRecord?.base_path}`} 25 + href={pubRecord ? pubRecord.url : "#"} 27 26 icon={<Avatar src={avatarSrc} displayName={displayName} tiny />} 28 27 actionText={ 29 28 <>
+11 -12
app/(home-pages)/notifications/MentionNotification.tsx
··· 1 1 import { MentionTiny } from "components/Icons/MentionTiny"; 2 2 import { ContentLayout, Notification } from "./Notification"; 3 3 import { HydratedMentionNotification } from "src/notifications"; 4 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 5 - import { Agent, AtUri } from "@atproto/api"; 4 + import { AtUri } from "@atproto/api"; 6 5 7 6 export const MentionNotification = (props: HydratedMentionNotification) => { 8 - const docRecord = props.document.data as PubLeafletDocument.Record; 9 - const pubRecord = props.document.documents_in_publications?.[0]?.publications 10 - ?.record as PubLeafletPublication.Record | undefined; 7 + const docRecord = props.normalizedDocument; 8 + const pubRecord = props.normalizedPublication; 9 + 10 + if (!docRecord) return null; 11 + 11 12 const docUri = new AtUri(props.document.uri); 12 13 const rkey = docUri.rkey; 13 14 const did = docUri.host; 14 15 15 16 const href = pubRecord 16 - ? `https://${pubRecord.base_path}/${rkey}` 17 + ? `${pubRecord.url}/${rkey}` 17 18 : `/p/${did}/${rkey}`; 18 19 19 20 let actionText: React.ReactNode; 20 21 let mentionedItemName: string | undefined; 21 - let mentionedDocRecord = props.mentionedDocument 22 - ?.data as PubLeafletDocument.Record; 22 + const mentionedDocRecord = props.normalizedMentionedDocument; 23 23 24 24 const mentioner = props.documentCreatorHandle 25 25 ? `@${props.documentCreatorHandle}` ··· 31 31 props.mention_type === "publication" && 32 32 props.mentionedPublication 33 33 ) { 34 - const mentionedPubRecord = props.mentionedPublication 35 - .record as PubLeafletPublication.Record; 36 - mentionedItemName = mentionedPubRecord.name; 34 + const mentionedPubRecord = props.normalizedMentionedPublication; 35 + mentionedItemName = mentionedPubRecord?.name; 37 36 actionText = ( 38 37 <> 39 38 {mentioner} mentioned your publication{" "} 40 39 <span className="italic">{mentionedItemName}</span> 41 40 </> 42 41 ); 43 - } else if (props.mention_type === "document" && props.mentionedDocument) { 42 + } else if (props.mention_type === "document" && mentionedDocRecord) { 44 43 mentionedItemName = mentionedDocRecord.title; 45 44 actionText = ( 46 45 <>
+5 -4
app/(home-pages)/notifications/Notification.tsx
··· 1 1 "use client"; 2 2 import { Avatar } from "components/Avatar"; 3 3 import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; 4 - import { PubLeafletPublication, PubLeafletRichtextFacet } from "lexicons/api"; 4 + import { PubLeafletRichtextFacet } from "lexicons/api"; 5 5 import { timeAgo } from "src/utils/timeAgo"; 6 6 import { useReplicache, useEntity } from "src/replicache"; 7 + import type { NormalizedPublication } from "src/utils/normalizeRecords"; 7 8 8 9 export const Notification = (props: { 9 10 icon: React.ReactNode; ··· 58 59 59 60 export const ContentLayout = (props: { 60 61 children: React.ReactNode; 61 - postTitle: string; 62 - pubRecord?: PubLeafletPublication.Record; 62 + postTitle: string | undefined; 63 + pubRecord?: NormalizedPublication | null; 63 64 }) => { 64 65 let { rootEntity } = useReplicache(); 65 66 let cardBorderHidden = useEntity(rootEntity, "theme/card-border-hidden")?.data ··· 77 78 <> 78 79 <hr className="mt-1 mb-1 border-border-light" /> 79 80 <a 80 - href={`https://${props.pubRecord.base_path}`} 81 + href={props.pubRecord.url} 81 82 className="relative text-xs text-tertiary flex gap-[6px] items-center font-bold hover:no-underline!" 82 83 > 83 84 {props.pubRecord.name}
+6 -5
app/(home-pages)/notifications/QuoteNotification.tsx
··· 1 1 import { QuoteTiny } from "components/Icons/QuoteTiny"; 2 2 import { ContentLayout, Notification } from "./Notification"; 3 3 import { HydratedQuoteNotification } from "src/notifications"; 4 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 5 4 import { AtUri } from "@atproto/api"; 6 5 import { Avatar } from "components/Avatar"; 7 6 ··· 9 8 const postView = props.bskyPost.post_view as any; 10 9 const author = postView.author; 11 10 const displayName = author.displayName || author.handle || "Someone"; 12 - const docRecord = props.document.data as PubLeafletDocument.Record; 13 - const pubRecord = props.document.documents_in_publications[0]?.publications 14 - ?.record as PubLeafletPublication.Record | undefined; 11 + const docRecord = props.normalizedDocument; 12 + const pubRecord = props.normalizedPublication; 13 + 14 + if (!docRecord) return null; 15 + 15 16 const docUri = new AtUri(props.document.uri); 16 17 const rkey = docUri.rkey; 17 18 const did = docUri.host; 18 19 const postText = postView.record?.text || ""; 19 20 20 21 const href = pubRecord 21 - ? `https://${pubRecord.base_path}/${rkey}` 22 + ? `${pubRecord.url}/${rkey}` 22 23 : `/p/${did}/${rkey}`; 23 24 24 25 return (
+16 -19
app/(home-pages)/notifications/ReplyNotification.tsx
··· 7 7 Notification, 8 8 } from "./Notification"; 9 9 import { HydratedCommentNotification } from "src/notifications"; 10 - import { 11 - PubLeafletComment, 12 - PubLeafletDocument, 13 - PubLeafletPublication, 14 - } from "lexicons/api"; 10 + import { PubLeafletComment } from "lexicons/api"; 15 11 import { AppBskyActorProfile, AtUri } from "@atproto/api"; 16 12 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 17 13 18 14 export const ReplyNotification = (props: HydratedCommentNotification) => { 19 - let docRecord = props.commentData.documents 20 - ?.data as PubLeafletDocument.Record; 21 - let commentRecord = props.commentData.record as PubLeafletComment.Record; 22 - let profileRecord = props.commentData.bsky_profiles 15 + const docRecord = props.normalizedDocument; 16 + const commentRecord = props.commentData.record as PubLeafletComment.Record; 17 + const profileRecord = props.commentData.bsky_profiles 23 18 ?.record as AppBskyActorProfile.Record; 19 + 20 + if (!docRecord) return null; 21 + 24 22 const displayName = 25 - profileRecord.displayName || 23 + profileRecord?.displayName || 26 24 props.commentData.bsky_profiles?.handle || 27 25 "Someone"; 28 26 29 - let parentRecord = props.parentData?.record as PubLeafletComment.Record; 30 - let parentProfile = props.parentData?.bsky_profiles 27 + const parentRecord = props.parentData?.record as PubLeafletComment.Record; 28 + const parentProfile = props.parentData?.bsky_profiles 31 29 ?.record as AppBskyActorProfile.Record; 32 30 const parentDisplayName = 33 - parentProfile.displayName || 31 + parentProfile?.displayName || 34 32 props.parentData?.bsky_profiles?.handle || 35 33 "Someone"; 36 34 37 - let docUri = new AtUri(props.commentData.documents?.uri!); 38 - let rkey = docUri.rkey; 39 - let did = docUri.host; 40 - const pubRecord = props.commentData.documents?.documents_in_publications[0] 41 - ?.publications?.record as PubLeafletPublication.Record | undefined; 35 + const docUri = new AtUri(props.commentData.documents?.uri!); 36 + const rkey = docUri.rkey; 37 + const did = docUri.host; 38 + const pubRecord = props.normalizedPublication; 42 39 43 40 const href = pubRecord 44 - ? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments` 41 + ? `${pubRecord.url}/${rkey}?interactionDrawer=comments` 45 42 : `/p/${did}/${rkey}?interactionDrawer=comments`; 46 43 47 44 return (
+6 -14
app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx
··· 1 1 "use client"; 2 2 import { Avatar } from "components/Avatar"; 3 - import { PubLeafletPublication } from "lexicons/api"; 4 3 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 5 4 import { colorToString } from "components/ThemeManager/useColorAttribute"; 6 5 import { PubIcon } from "components/ActionBar/Publications"; 7 - import { Json } from "supabase/database.types"; 6 + import { type NormalizedPublication } from "src/utils/normalizeRecords"; 8 7 import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 9 8 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 10 9 import { SpeedyLink } from "components/SpeedyLink"; ··· 13 12 14 13 export const ProfileHeader = (props: { 15 14 profile: ProfileViewDetailed; 16 - publications: { record: Json; uri: string }[]; 15 + publications: { record: NormalizedPublication; uri: string }[]; 17 16 popover?: boolean; 18 17 }) => { 19 18 let profileRecord = props.profile; ··· 80 79 className={`grid grid-flow-col gap-2 mx-auto w-fit px-3 sm:px-4 ${props.popover ? "auto-cols-[164px]" : "auto-cols-[164px] sm:auto-cols-[240px]"}`} 81 80 > 82 81 {props.publications.map((p) => ( 83 - <PublicationCard 84 - key={p.uri} 85 - record={p.record as PubLeafletPublication.Record} 86 - uri={p.uri} 87 - /> 82 + <PublicationCard key={p.uri} record={p.record} uri={p.uri} /> 88 83 ))} 89 84 </div> 90 85 </div> ··· 105 100 </div> 106 101 ); 107 102 }; 108 - const PublicationCard = (props: { 109 - record: PubLeafletPublication.Record; 110 - uri: string; 111 - }) => { 103 + const PublicationCard = (props: { record: NormalizedPublication; uri: string }) => { 112 104 const { record, uri } = props; 113 105 const { bgLeaflet, bgPage, primary } = usePubTheme(record.theme); 114 106 115 107 return ( 116 108 <a 117 - href={`https://${record.base_path}`} 118 - className="profilePublicationCard border border-border p-2 rounded-lg hover:no-underline! text-primary basis-1/2 " 109 + href={record.url} 110 + className="profilePublicationCard border border-border p-2 rounded-lg hover:no-underline! text-primary basis-1/2" 119 111 style={{ backgroundColor: `rgb(${colorToString(bgLeaflet, "rgb")})` }} 120 112 > 121 113 <div
+10 -2
app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts
··· 3 3 import { supabaseServerClient } from "supabase/serverClient"; 4 4 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 5 import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 6 + import { 7 + normalizeDocumentRecord, 8 + normalizePublicationRecord, 9 + } from "src/utils/normalizeRecords"; 6 10 7 11 export type Cursor = { 8 12 indexed_at: string; ··· 58 62 let posts: Post[] = []; 59 63 60 64 for (let doc of docs || []) { 65 + // Normalize records - filter out unrecognized formats 66 + const normalizedData = normalizeDocumentRecord(doc.data, doc.uri); 67 + if (!normalizedData) continue; 68 + 61 69 let pubFromDoc = doc.documents_in_publications?.[0]?.publications; 62 70 let pub = pubFromDoc ? pubMap.get(pubFromDoc.uri) || pubFromDoc : null; 63 71 64 72 let post: Post = { 65 73 author: handle, 66 74 documents: { 67 - data: doc.data, 75 + data: normalizedData, 68 76 uri: doc.uri, 69 77 indexed_at: doc.indexed_at, 70 78 comments_on_documents: doc.comments_on_documents, ··· 75 83 if (pub) { 76 84 post.publication = { 77 85 href: getPublicationURL(pub), 78 - pubRecord: pub.record, 86 + pubRecord: normalizePublicationRecord(pub.record), 79 87 uri: pub.uri, 80 88 }; 81 89 }
+44 -38
app/(home-pages)/reader/getReaderFeed.ts
··· 7 7 import type { DidCache, CacheResult, DidDocument } from "@atproto/identity"; 8 8 import Client from "ioredis"; 9 9 import { AtUri } from "@atproto/api"; 10 - import { Json } from "supabase/database.types"; 11 10 import { idResolver } from "./idResolver"; 11 + import { 12 + normalizeDocumentRecord, 13 + normalizePublicationRecord, 14 + type NormalizedDocument, 15 + type NormalizedPublication, 16 + } from "src/utils/normalizeRecords"; 12 17 13 18 export type Cursor = { 14 19 timestamp: string; ··· 42 47 } 43 48 let { data: feed, error } = await query; 44 49 45 - let posts = await Promise.all( 46 - feed?.map(async (post) => { 47 - let pub = post.documents_in_publications[0].publications!; 48 - let uri = new AtUri(post.uri); 49 - let handle = await idResolver.did.resolve(uri.host); 50 - let p: Post = { 51 - publication: { 52 - href: getPublicationURL(pub), 53 - pubRecord: pub?.record || null, 54 - uri: pub?.uri || "", 55 - }, 56 - author: handle?.alsoKnownAs?.[0] 57 - ? `@${handle.alsoKnownAs[0].slice(5)}` 58 - : null, 59 - documents: { 60 - comments_on_documents: post.comments_on_documents, 61 - document_mentions_in_bsky: post.document_mentions_in_bsky, 62 - data: post.data, 63 - uri: post.uri, 64 - indexed_at: post.indexed_at, 65 - }, 66 - }; 67 - return p; 68 - }) || [], 69 - ); 50 + let posts = ( 51 + await Promise.all( 52 + feed?.map(async (post) => { 53 + let pub = post.documents_in_publications[0].publications!; 54 + let uri = new AtUri(post.uri); 55 + let handle = await idResolver.did.resolve(uri.host); 56 + 57 + // Normalize records - filter out unrecognized formats 58 + const normalizedData = normalizeDocumentRecord(post.data, post.uri); 59 + if (!normalizedData) return null; 60 + 61 + const normalizedPubRecord = normalizePublicationRecord(pub?.record); 62 + 63 + let p: Post = { 64 + publication: { 65 + href: getPublicationURL(pub), 66 + pubRecord: normalizedPubRecord, 67 + uri: pub?.uri || "", 68 + }, 69 + author: handle?.alsoKnownAs?.[0] 70 + ? `@${handle.alsoKnownAs[0].slice(5)}` 71 + : null, 72 + documents: { 73 + comments_on_documents: post.comments_on_documents, 74 + document_mentions_in_bsky: post.document_mentions_in_bsky, 75 + data: normalizedData, 76 + uri: post.uri, 77 + indexed_at: post.indexed_at, 78 + }, 79 + }; 80 + return p; 81 + }) || [], 82 + ) 83 + ).filter((post): post is Post => post !== null); 70 84 const nextCursor = 71 85 posts.length > 0 72 86 ? { ··· 85 99 author: string | null; 86 100 publication?: { 87 101 href: string; 88 - pubRecord: Json; 102 + pubRecord: NormalizedPublication | null; 89 103 uri: string; 90 104 }; 91 105 documents: { 92 - data: Json; 106 + data: NormalizedDocument | null; 93 107 uri: string; 94 108 indexed_at: string; 95 - comments_on_documents: 96 - | { 97 - count: number; 98 - }[] 99 - | undefined; 100 - document_mentions_in_bsky: 101 - | { 102 - count: number; 103 - }[] 104 - | undefined; 109 + comments_on_documents: { count: number }[] | undefined; 110 + document_mentions_in_bsky: { count: number }[] | undefined; 105 111 }; 106 112 };
+23 -12
app/(home-pages)/reader/getSubscriptions.ts
··· 7 7 import { supabaseServerClient } from "supabase/serverClient"; 8 8 import { idResolver } from "./idResolver"; 9 9 import { Cursor } from "./getReaderFeed"; 10 + import { 11 + normalizePublicationRecord, 12 + type NormalizedPublication, 13 + } from "src/utils/normalizeRecords"; 10 14 11 15 export async function getSubscriptions( 12 16 did?: string | null, ··· 43 47 } 44 48 let { data: pubs, error } = await query; 45 49 46 - const hydratedSubscriptions: PublicationSubscription[] = await Promise.all( 47 - pubs?.map(async (pub) => { 48 - let id = await idResolver.did.resolve(pub.publications?.identity_did!); 49 - return { 50 - ...pub.publications!, 51 - authorProfile: id?.alsoKnownAs?.[0] 52 - ? { handle: `@${id.alsoKnownAs[0].slice(5)}` } 53 - : undefined, 54 - }; 55 - }) || [], 56 - ); 50 + const hydratedSubscriptions = ( 51 + await Promise.all( 52 + pubs?.map(async (pub) => { 53 + const normalizedRecord = normalizePublicationRecord( 54 + pub.publications?.record 55 + ); 56 + if (!normalizedRecord) return null; 57 + let id = await idResolver.did.resolve(pub.publications?.identity_did!); 58 + return { 59 + ...pub.publications!, 60 + record: normalizedRecord, 61 + authorProfile: id?.alsoKnownAs?.[0] 62 + ? { handle: `@${id.alsoKnownAs[0].slice(5)}` } 63 + : undefined, 64 + } as PublicationSubscription; 65 + }) || [] 66 + ) 67 + ).filter((sub): sub is PublicationSubscription => sub !== null); 57 68 58 69 const nextCursor = 59 70 pubs && pubs.length > 0 ··· 71 82 72 83 export type PublicationSubscription = { 73 84 authorProfile?: { handle: string }; 74 - record: Json; 85 + record: NormalizedPublication; 75 86 uri: string; 76 87 documents_in_publications: { 77 88 documents: { data?: Json; indexed_at: string } | null;
+14 -3
app/(home-pages)/tag/[tag]/getDocumentsByTag.ts
··· 3 3 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4 4 import { supabaseServerClient } from "supabase/serverClient"; 5 5 import { AtUri } from "@atproto/api"; 6 - import { Json } from "supabase/database.types"; 7 6 import { idResolver } from "app/(home-pages)/reader/idResolver"; 8 7 import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 8 + import { 9 + normalizeDocumentRecord, 10 + normalizePublicationRecord, 11 + } from "src/utils/normalizeRecords"; 9 12 10 13 export async function getDocumentsByTag( 11 14 tag: string, ··· 37 40 return null; 38 41 } 39 42 43 + // Normalize the document data - skip unrecognized formats 44 + const normalizedData = normalizeDocumentRecord(doc.data, doc.uri); 45 + if (!normalizedData) { 46 + return null; 47 + } 48 + 49 + const normalizedPubRecord = normalizePublicationRecord(pub?.record); 50 + 40 51 const uri = new AtUri(doc.uri); 41 52 const handle = await idResolver.did.resolve(uri.host); 42 53 43 54 const post: Post = { 44 55 publication: { 45 56 href: getPublicationURL(pub), 46 - pubRecord: pub?.record || null, 57 + pubRecord: normalizedPubRecord, 47 58 uri: pub?.uri || "", 48 59 }, 49 60 author: handle?.alsoKnownAs?.[0] ··· 52 63 documents: { 53 64 comments_on_documents: doc.comments_on_documents, 54 65 document_mentions_in_bsky: doc.document_mentions_in_bsky, 55 - data: doc.data, 66 + data: normalizedData, 56 67 uri: doc.uri, 57 68 indexed_at: doc.indexed_at, 58 69 },
+2 -2
app/[leaflet_id]/actions/PublishButton.tsx
··· 22 22 import { SpeedyLink } from "components/SpeedyLink"; 23 23 import { useToaster } from "components/Toast"; 24 24 import { DotLoader } from "components/utils/DotLoader"; 25 - import { PubLeafletPublication } from "lexicons/api"; 25 + import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 26 26 import { useParams, useRouter, useSearchParams } from "next/navigation"; 27 27 import { useState, useMemo, useEffect } from "react"; 28 28 import { useIsMobile } from "src/hooks/isMobile"; ··· 377 377 </PubOption> 378 378 <hr className="border-border-light border-dashed " /> 379 379 {props.publications.map((p) => { 380 - let pubRecord = p.record as PubLeafletPublication.Record; 380 + let pubRecord = normalizePublicationRecord(p.record); 381 381 return ( 382 382 <PubOption 383 383 key={p.uri}
+1 -4
app/[leaflet_id]/actions/ShareOptions/index.tsx
··· 14 14 useLeafletPublicationData, 15 15 } from "components/PageSWRDataProvider"; 16 16 import { ShareSmall } from "components/Icons/ShareSmall"; 17 - import { PubLeafletDocument } from "lexicons/api"; 18 17 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 19 18 import { AtUri } from "@atproto/syntax"; 20 19 import { useIsMobile } from "src/hooks/isMobile"; ··· 88 87 isPub?: boolean; 89 88 }) => { 90 89 let { permission_token } = useReplicache(); 91 - let { data: pub } = useLeafletPublicationData(); 92 - 93 - let record = pub?.documents?.data as PubLeafletDocument.Record | null; 90 + let { data: pub, normalizedDocument } = useLeafletPublicationData(); 94 91 95 92 let docURI = pub?.documents ? new AtUri(pub?.documents.uri) : null; 96 93 let postLink = !docURI
+7 -7
app/[leaflet_id]/publish/PublishPost.tsx
··· 7 7 import { useParams } from "next/navigation"; 8 8 import Link from "next/link"; 9 9 10 - import { PubLeafletPublication } from "lexicons/api"; 10 + import type { NormalizedPublication } from "src/utils/normalizeRecords"; 11 11 import { publishPostToBsky } from "./publishBskyPost"; 12 12 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 13 13 import { AtUri } from "@atproto/syntax"; ··· 36 36 profile: ProfileViewDetailed; 37 37 description: string; 38 38 publication_uri?: string; 39 - record?: PubLeafletPublication.Record; 39 + record?: NormalizedPublication | null; 40 40 posts_in_pub?: number; 41 41 entitiesToDelete?: string[]; 42 42 hasDraft: boolean; ··· 135 135 } 136 136 137 137 // Generate post URL based on whether it's in a publication or standalone 138 - let post_url = props.record?.base_path 139 - ? `https://${props.record.base_path}/${result.rkey}` 138 + let post_url = props.record?.url 139 + ? `${props.record.url}/${result.rkey}` 140 140 : `https://leaflet.pub/p/${props.profile.did}/${result.rkey}`; 141 141 142 142 let [text, facets] = editorStateRef.current ··· 331 331 title: string; 332 332 profile: ProfileViewDetailed; 333 333 description: string; 334 - record?: PubLeafletPublication.Record; 334 + record?: NormalizedPublication | null; 335 335 }) => { 336 336 return ( 337 337 <div className="flex flex-col gap-2"> ··· 398 398 <div className="text-tertiary">{props.description}</div> 399 399 <hr className="border-border mt-2 mb-1" /> 400 400 <p className="text-xs text-tertiary"> 401 - {props.record?.base_path} 401 + {props.record?.url?.replace(/^https?:\/\//, "")} 402 402 </p> 403 403 </div> 404 404 </div> ··· 415 415 416 416 const PublishingTo = (props: { 417 417 publication_uri?: string; 418 - record?: PubLeafletPublication.Record; 418 + record?: NormalizedPublication | null; 419 419 }) => { 420 420 if (props.publication_uri && props.record) { 421 421 return (
+2 -2
app/[leaflet_id]/publish/page.tsx
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 2 import { PublishPost } from "./PublishPost"; 3 - import { PubLeafletPublication } from "lexicons/api"; 3 + import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 4 4 import { getIdentityData } from "actions/getIdentityData"; 5 5 6 6 import { AtpAgent } from "@atproto/api"; ··· 118 118 title={title} 119 119 description={description} 120 120 publication_uri={publication?.uri} 121 - record={publication?.record as PubLeafletPublication.Record | undefined} 121 + record={normalizePublicationRecord(publication?.record)} 122 122 posts_in_pub={publication?.documents_in_publications[0]?.count} 123 123 entitiesToDelete={entitiesToDelete} 124 124 hasDraft={hasDraft}
+5
app/api/inngest/client.ts
··· 21 21 }; 22 22 }; 23 23 "appview/come-online": { data: {} }; 24 + "user/migrate-to-standard": { 25 + data: { 26 + did: string; 27 + }; 28 + }; 24 29 }; 25 30 26 31 // Create a client to send and receive events
+32 -7
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 58 + // Support both old format (pub.leaflet.publication with base_path) and 59 + // new format (site.standard.publication with url as https://domain) 44 60 let { data: pub, error } = await supabaseServerClient 45 61 .from("publications") 46 62 .select("*") 47 - .eq("record->>base_path", url.host) 63 + .or(`record->>base_path.eq.${url.host},record->>url.eq.https://${url.host}`) 48 64 .single(); 49 65 50 66 if (!pub) { ··· 54 70 }; 55 71 } 56 72 57 - documentUri = AtUri.make( 58 - pub.identity_did, 59 - ids.PubLeafletDocument, 60 - path[0], 61 - ).toString(); 73 + // Query the database to find the actual document URI (could be either namespace) 74 + const { data: docDataArr } = await supabaseServerClient 75 + .from("documents") 76 + .select("uri") 77 + .or(documentUriFilter(pub.identity_did, path[0])) 78 + .order("uri", { ascending: false }) 79 + .limit(1); 80 + const docData = docDataArr?.[0]; 81 + 82 + if (!docData) { 83 + return { message: `No document found for publication ${url.host}/${path[0]}` }; 84 + } 85 + 86 + documentUri = docData.uri; 62 87 authorDid = pub.identity_did; 63 88 } 64 89
+428
app/api/inngest/functions/migrate_user_to_standard.ts
··· 1 + import { supabaseServerClient } from "supabase/serverClient"; 2 + import { inngest } from "../client"; 3 + import { restoreOAuthSession } from "src/atproto-oauth"; 4 + import { AtpBaseClient, SiteStandardPublication, SiteStandardDocument, SiteStandardGraphSubscription } from "lexicons/api"; 5 + import { AtUri } from "@atproto/syntax"; 6 + import { Json } from "supabase/database.types"; 7 + import { normalizePublicationRecord, normalizeDocumentRecord } from "src/utils/normalizeRecords"; 8 + 9 + type MigrationResult = 10 + | { success: true; oldUri: string; newUri: string; skipped?: boolean } 11 + | { success: false; error: string }; 12 + 13 + async function createAuthenticatedAgent(did: string): Promise<AtpBaseClient> { 14 + const result = await restoreOAuthSession(did); 15 + if (!result.ok) { 16 + throw new Error(`Failed to restore OAuth session: ${result.error.message}`); 17 + } 18 + const credentialSession = result.value; 19 + return new AtpBaseClient( 20 + credentialSession.fetchHandler.bind(credentialSession) 21 + ); 22 + } 23 + 24 + export const migrate_user_to_standard = inngest.createFunction( 25 + { id: "migrate_user_to_standard" }, 26 + { event: "user/migrate-to-standard" }, 27 + async ({ event, step }) => { 28 + const { did } = event.data; 29 + 30 + const stats = { 31 + publicationsMigrated: 0, 32 + documentsMigrated: 0, 33 + userSubscriptionsMigrated: 0, 34 + referencesUpdated: 0, 35 + errors: [] as string[], 36 + }; 37 + 38 + // Step 1: Verify OAuth session is valid 39 + await step.run("verify-oauth-session", async () => { 40 + const result = await restoreOAuthSession(did); 41 + if (!result.ok) { 42 + throw new Error(`Failed to restore OAuth session: ${result.error.message}`); 43 + } 44 + return { success: true }; 45 + }); 46 + 47 + // Step 2: Get user's pub.leaflet.publication records 48 + const oldPublications = await step.run("fetch-old-publications", async () => { 49 + const { data, error } = await supabaseServerClient 50 + .from("publications") 51 + .select("*") 52 + .eq("identity_did", did) 53 + .like("uri", `at://${did}/pub.leaflet.publication/%`); 54 + 55 + if (error) throw new Error(`Failed to fetch publications: ${error.message}`); 56 + return data || []; 57 + }); 58 + 59 + // Step 3: Migrate each publication 60 + const publicationUriMap: Record<string, string> = {}; // old URI -> new URI 61 + 62 + for (const pub of oldPublications) { 63 + const aturi = new AtUri(pub.uri); 64 + 65 + // Skip if already a site.standard.publication 66 + if (aturi.collection === "site.standard.publication") { 67 + publicationUriMap[pub.uri] = pub.uri; 68 + continue; 69 + } 70 + 71 + const rkey = aturi.rkey; 72 + const normalized = normalizePublicationRecord(pub.record); 73 + 74 + if (!normalized) { 75 + stats.errors.push(`Publication ${pub.uri}: Failed to normalize publication record`); 76 + continue; 77 + } 78 + 79 + // Build site.standard.publication record 80 + const newRecord: SiteStandardPublication.Record = { 81 + $type: "site.standard.publication", 82 + name: normalized.name, 83 + url: normalized.url, 84 + description: normalized.description, 85 + icon: normalized.icon, 86 + theme: normalized.theme, 87 + basicTheme: normalized.basicTheme, 88 + preferences: normalized.preferences, 89 + }; 90 + 91 + // Step: Write to PDS 92 + const pdsResult = await step.run(`pds-write-publication-${pub.uri}`, async () => { 93 + const agent = await createAuthenticatedAgent(did); 94 + const putResult = await agent.com.atproto.repo.putRecord({ 95 + repo: did, 96 + collection: "site.standard.publication", 97 + rkey, 98 + record: newRecord, 99 + validate: false, 100 + }); 101 + return { newUri: putResult.data.uri }; 102 + }); 103 + 104 + const newUri = pdsResult.newUri; 105 + 106 + // Step: Write to database 107 + const dbResult = await step.run(`db-write-publication-${pub.uri}`, async () => { 108 + const { error: dbError } = await supabaseServerClient 109 + .from("publications") 110 + .upsert({ 111 + uri: newUri, 112 + identity_did: did, 113 + name: normalized.name, 114 + record: newRecord as Json, 115 + }); 116 + 117 + if (dbError) { 118 + return { success: false as const, error: dbError.message }; 119 + } 120 + return { success: true as const }; 121 + }); 122 + 123 + if (dbResult.success) { 124 + publicationUriMap[pub.uri] = newUri; 125 + stats.publicationsMigrated++; 126 + } else { 127 + stats.errors.push(`Publication ${pub.uri}: Database error: ${dbResult.error}`); 128 + } 129 + } 130 + 131 + // Step 4: Get ALL user's pub.leaflet.document records (both in publications and standalone) 132 + const oldDocuments = await step.run("fetch-old-documents", async () => { 133 + const { data, error } = await supabaseServerClient 134 + .from("documents") 135 + .select("uri, data") 136 + .like("uri", `at://${did}/pub.leaflet.document/%`); 137 + 138 + if (error) throw new Error(`Failed to fetch documents: ${error.message}`); 139 + return data || []; 140 + }); 141 + 142 + // Also fetch publication associations for documents 143 + const documentPublicationMap = await step.run("fetch-document-publications", async () => { 144 + const docUris = oldDocuments.map(d => d.uri); 145 + if (docUris.length === 0) return {}; 146 + 147 + const { data, error } = await supabaseServerClient 148 + .from("documents_in_publications") 149 + .select("document, publication") 150 + .in("document", docUris); 151 + 152 + if (error) throw new Error(`Failed to fetch document publications: ${error.message}`); 153 + 154 + // Create a map of document URI -> publication URI 155 + const map: Record<string, string> = {}; 156 + for (const row of data || []) { 157 + map[row.document] = row.publication; 158 + } 159 + return map; 160 + }); 161 + 162 + const documentUriMap: Record<string, string> = {}; // old URI -> new URI 163 + 164 + for (const doc of oldDocuments) { 165 + const aturi = new AtUri(doc.uri); 166 + 167 + // Skip if already a site.standard.document 168 + if (aturi.collection === "site.standard.document") { 169 + documentUriMap[doc.uri] = doc.uri; 170 + continue; 171 + } 172 + 173 + const rkey = aturi.rkey; 174 + const normalized = normalizeDocumentRecord(doc.data, doc.uri); 175 + 176 + if (!normalized) { 177 + stats.errors.push(`Document ${doc.uri}: Failed to normalize document record`); 178 + continue; 179 + } 180 + 181 + // Determine the site field: 182 + // - If document is in a publication, use the new publication URI (if migrated) or old URI 183 + // - If standalone, use the HTTPS URL format 184 + const oldPubUri = documentPublicationMap[doc.uri]; 185 + let siteValue: string; 186 + 187 + if (oldPubUri) { 188 + // Document is in a publication - use new URI if migrated, otherwise keep old 189 + siteValue = publicationUriMap[oldPubUri] || oldPubUri; 190 + } else { 191 + // Standalone document - use HTTPS URL format 192 + siteValue = `https://leaflet.pub/p/${did}`; 193 + } 194 + 195 + // Build site.standard.document record 196 + const newRecord: SiteStandardDocument.Record = { 197 + $type: "site.standard.document", 198 + title: normalized.title || "Untitled", 199 + site: siteValue, 200 + path: rkey, 201 + publishedAt: normalized.publishedAt || new Date().toISOString(), 202 + description: normalized.description, 203 + content: normalized.content, 204 + tags: normalized.tags, 205 + coverImage: normalized.coverImage, 206 + bskyPostRef: normalized.bskyPostRef, 207 + }; 208 + 209 + // Step: Write to PDS 210 + const pdsResult = await step.run(`pds-write-document-${doc.uri}`, async () => { 211 + const agent = await createAuthenticatedAgent(did); 212 + const putResult = await agent.com.atproto.repo.putRecord({ 213 + repo: did, 214 + collection: "site.standard.document", 215 + rkey, 216 + record: newRecord, 217 + validate: false, 218 + }); 219 + return { newUri: putResult.data.uri }; 220 + }); 221 + 222 + const newUri = pdsResult.newUri; 223 + 224 + // Step: Write to database 225 + const dbResult = await step.run(`db-write-document-${doc.uri}`, async () => { 226 + const { error: dbError } = await supabaseServerClient 227 + .from("documents") 228 + .upsert({ 229 + uri: newUri, 230 + data: newRecord as Json, 231 + }); 232 + 233 + if (dbError) { 234 + return { success: false as const, error: dbError.message }; 235 + } 236 + 237 + // If document was in a publication, add to documents_in_publications with new URIs 238 + if (oldPubUri) { 239 + const newPubUri = publicationUriMap[oldPubUri] || oldPubUri; 240 + await supabaseServerClient 241 + .from("documents_in_publications") 242 + .upsert({ 243 + publication: newPubUri, 244 + document: newUri, 245 + }); 246 + } 247 + 248 + return { success: true as const }; 249 + }); 250 + 251 + if (dbResult.success) { 252 + documentUriMap[doc.uri] = newUri; 253 + stats.documentsMigrated++; 254 + } else { 255 + stats.errors.push(`Document ${doc.uri}: Database error: ${dbResult.error}`); 256 + } 257 + } 258 + 259 + // Step 5: Update references in database tables 260 + await step.run("update-references", async () => { 261 + // Update leaflets_in_publications - update publication and doc references 262 + for (const [oldUri, newUri] of Object.entries(publicationUriMap)) { 263 + const { error } = await supabaseServerClient 264 + .from("leaflets_in_publications") 265 + .update({ publication: newUri }) 266 + .eq("publication", oldUri); 267 + 268 + if (!error) stats.referencesUpdated++; 269 + } 270 + 271 + for (const [oldUri, newUri] of Object.entries(documentUriMap)) { 272 + const { error } = await supabaseServerClient 273 + .from("leaflets_in_publications") 274 + .update({ doc: newUri }) 275 + .eq("doc", oldUri); 276 + 277 + if (!error) stats.referencesUpdated++; 278 + } 279 + 280 + // Update leaflets_to_documents - update document references 281 + for (const [oldUri, newUri] of Object.entries(documentUriMap)) { 282 + const { error } = await supabaseServerClient 283 + .from("leaflets_to_documents") 284 + .update({ document: newUri }) 285 + .eq("document", oldUri); 286 + 287 + if (!error) stats.referencesUpdated++; 288 + } 289 + 290 + // Update publication_domains - update publication references 291 + for (const [oldUri, newUri] of Object.entries(publicationUriMap)) { 292 + const { error } = await supabaseServerClient 293 + .from("publication_domains") 294 + .update({ publication: newUri }) 295 + .eq("publication", oldUri); 296 + 297 + if (!error) stats.referencesUpdated++; 298 + } 299 + 300 + // Update comments_on_documents - update document references 301 + for (const [oldUri, newUri] of Object.entries(documentUriMap)) { 302 + const { error } = await supabaseServerClient 303 + .from("comments_on_documents") 304 + .update({ document: newUri }) 305 + .eq("document", oldUri); 306 + 307 + if (!error) stats.referencesUpdated++; 308 + } 309 + 310 + // Update document_mentions_in_bsky - update document references 311 + for (const [oldUri, newUri] of Object.entries(documentUriMap)) { 312 + const { error } = await supabaseServerClient 313 + .from("document_mentions_in_bsky") 314 + .update({ document: newUri }) 315 + .eq("document", oldUri); 316 + 317 + if (!error) stats.referencesUpdated++; 318 + } 319 + 320 + // Update subscribers_to_publications - update publication references 321 + for (const [oldUri, newUri] of Object.entries(publicationUriMap)) { 322 + const { error } = await supabaseServerClient 323 + .from("subscribers_to_publications") 324 + .update({ publication: newUri }) 325 + .eq("publication", oldUri); 326 + 327 + if (!error) stats.referencesUpdated++; 328 + } 329 + 330 + // Update publication_subscriptions - update publication references for incoming subscriptions 331 + for (const [oldUri, newUri] of Object.entries(publicationUriMap)) { 332 + const { error } = await supabaseServerClient 333 + .from("publication_subscriptions") 334 + .update({ publication: newUri }) 335 + .eq("publication", oldUri); 336 + 337 + if (!error) stats.referencesUpdated++; 338 + } 339 + 340 + return stats.referencesUpdated; 341 + }); 342 + 343 + // Step 6: Migrate user's own subscriptions - subscriptions BY this user to other publications 344 + const userSubscriptions = await step.run("fetch-user-subscriptions", async () => { 345 + const { data, error } = await supabaseServerClient 346 + .from("publication_subscriptions") 347 + .select("*") 348 + .eq("identity", did) 349 + .like("uri", `at://${did}/pub.leaflet.graph.subscription/%`); 350 + 351 + if (error) throw new Error(`Failed to fetch user subscriptions: ${error.message}`); 352 + return data || []; 353 + }); 354 + 355 + const userSubscriptionUriMap: Record<string, string> = {}; // old URI -> new URI 356 + 357 + for (const sub of userSubscriptions) { 358 + const aturi = new AtUri(sub.uri); 359 + 360 + // Skip if already a site.standard.graph.subscription 361 + if (aturi.collection === "site.standard.graph.subscription") { 362 + userSubscriptionUriMap[sub.uri] = sub.uri; 363 + continue; 364 + } 365 + 366 + const rkey = aturi.rkey; 367 + 368 + // Build site.standard.graph.subscription record 369 + const newRecord: SiteStandardGraphSubscription.Record = { 370 + $type: "site.standard.graph.subscription", 371 + publication: sub.publication, 372 + }; 373 + 374 + // Step: Write to PDS 375 + const pdsResult = await step.run(`pds-write-subscription-${sub.uri}`, async () => { 376 + const agent = await createAuthenticatedAgent(did); 377 + const putResult = await agent.com.atproto.repo.putRecord({ 378 + repo: did, 379 + collection: "site.standard.graph.subscription", 380 + rkey, 381 + record: newRecord, 382 + validate: false, 383 + }); 384 + return { newUri: putResult.data.uri }; 385 + }); 386 + 387 + const newUri = pdsResult.newUri; 388 + 389 + // Step: Write to database 390 + const dbResult = await step.run(`db-write-subscription-${sub.uri}`, async () => { 391 + const { error: dbError } = await supabaseServerClient 392 + .from("publication_subscriptions") 393 + .update({ 394 + uri: newUri, 395 + record: newRecord as Json, 396 + }) 397 + .eq("uri", sub.uri); 398 + 399 + if (dbError) { 400 + return { success: false as const, error: dbError.message }; 401 + } 402 + return { success: true as const }; 403 + }); 404 + 405 + if (dbResult.success) { 406 + userSubscriptionUriMap[sub.uri] = newUri; 407 + stats.userSubscriptionsMigrated++; 408 + } else { 409 + stats.errors.push(`User subscription ${sub.uri}: Database error: ${dbResult.error}`); 410 + } 411 + } 412 + 413 + // NOTE: We intentionally keep old documents, publications, and documents_in_publications entries. 414 + // New entries are created with the new URIs, but the old entries remain so that: 415 + // 1. Notifications referencing old document/publication URIs can still resolve 416 + // 2. External references (e.g., from other AT Proto apps) to old URIs continue to work 417 + // 3. The normalization layer handles both schemas transparently for reads 418 + // Old records are also kept on the user's PDS so existing AT-URI references remain valid. 419 + 420 + return { 421 + success: stats.errors.length === 0, 422 + stats, 423 + publicationUriMap, 424 + documentUriMap, 425 + userSubscriptionUriMap, 426 + }; 427 + } 428 + );
+2
app/api/inngest/route.tsx
··· 4 4 import { come_online } from "./functions/come_online"; 5 5 import { batched_update_profiles } from "./functions/batched_update_profiles"; 6 6 import { index_follows } from "./functions/index_follows"; 7 + import { migrate_user_to_standard } from "./functions/migrate_user_to_standard"; 7 8 8 9 export const { GET, POST, PUT } = serve({ 9 10 client: inngest, ··· 12 13 come_online, 13 14 batched_update_profiles, 14 15 index_follows, 16 + migrate_user_to_standard, 15 17 ], 16 18 });
+22 -13
app/api/pub_icon/route.ts
··· 1 1 import { AtUri } from "@atproto/syntax"; 2 2 import { IdResolver } from "@atproto/identity"; 3 3 import { NextRequest, NextResponse } from "next/server"; 4 - import { PubLeafletPublication } from "lexicons/api"; 5 4 import { supabaseServerClient } from "supabase/serverClient"; 5 + import { 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"; 6 14 import sharp from "sharp"; 7 15 8 16 const idResolver = new IdResolver(); ··· 29 37 return new NextResponse(null, { status: 400 }); 30 38 } 31 39 32 - let publicationRecord: PubLeafletPublication.Record | null = null; 40 + let normalizedPub: NormalizedPublication | null = null; 33 41 let publicationUri: string; 34 42 35 43 // Check if it's a document or publication 36 - if (uri.collection === "pub.leaflet.document") { 44 + if (isDocumentCollection(uri.collection)) { 37 45 // Query the documents_in_publications table to get the publication 38 46 const { data: docInPub } = await supabaseServerClient 39 47 .from("documents_in_publications") ··· 46 54 } 47 55 48 56 publicationUri = docInPub.publication; 49 - publicationRecord = docInPub.publications 50 - .record as PubLeafletPublication.Record; 51 - } else if (uri.collection === "pub.leaflet.publication") { 57 + normalizedPub = normalizePublicationRecord(docInPub.publications.record); 58 + } else if (isPublicationCollection(uri.collection)) { 52 59 // Query the publications table directly 53 - const { data: publication } = await supabaseServerClient 60 + const { data: publications } = await supabaseServerClient 54 61 .from("publications") 55 62 .select("record, uri") 56 - .eq("uri", at_uri) 57 - .single(); 63 + .or(publicationUriFilter(uri.host, uri.rkey)) 64 + .order("uri", { ascending: false }) 65 + .limit(1); 66 + const publication = publications?.[0]; 58 67 59 68 if (!publication || !publication.record) { 60 69 return new NextResponse(null, { status: 404 }); 61 70 } 62 71 63 72 publicationUri = publication.uri; 64 - publicationRecord = publication.record as PubLeafletPublication.Record; 73 + normalizedPub = normalizePublicationRecord(publication.record); 65 74 } else { 66 75 // Not a supported collection 67 76 return new NextResponse(null, { status: 404 }); 68 77 } 69 78 70 79 // Check if the publication has an icon 71 - if (!publicationRecord?.icon) { 80 + if (!normalizedPub?.icon) { 72 81 // Generate a placeholder with the first letter of the publication name 73 - const firstLetter = (publicationRecord?.name || "?") 82 + const firstLetter = (normalizedPub?.name || "?") 74 83 .slice(0, 1) 75 84 .toUpperCase(); 76 85 ··· 94 103 const pubUri = new AtUri(publicationUri); 95 104 96 105 // Get the CID from the icon blob 97 - const cid = (publicationRecord.icon.ref as unknown as { $link: string })[ 106 + const cid = (normalizedPub.icon.ref as unknown as { $link: string })[ 98 107 "$link" 99 108 ]; 100 109
+10 -1
app/api/rpc/[command]/get_profile_data.ts
··· 6 6 import { Agent } from "@atproto/api"; 7 7 import { getIdentityData } from "actions/getIdentityData"; 8 8 import { createOauthClient } from "src/atproto-oauth"; 9 + import { 10 + normalizePublicationRow, 11 + hasValidPublication, 12 + } from "src/utils/normalizeRecords"; 9 13 10 14 export type GetProfileDataReturnType = Awaited< 11 15 ReturnType<(typeof get_profile_data)["handler"]> ··· 59 63 publicationsReq, 60 64 ]); 61 65 66 + // Normalize publication records before returning 67 + const normalizedPublications = (publications || []) 68 + .map(normalizePublicationRow) 69 + .filter(hasValidPublication); 70 + 62 71 return { 63 72 result: { 64 73 profile, 65 - publications: publications || [], 74 + publications: normalizedPublications, 66 75 }, 67 76 }; 68 77 },
+49 -5
app/api/rpc/[command]/get_publication_data.ts
··· 3 3 import type { Env } from "./route"; 4 4 import { AtUri } from "@atproto/syntax"; 5 5 import { getFactsFromHomeLeaflets } from "./getFactsFromHomeLeaflets"; 6 + import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; 7 + import { ids } from "lexicons/api/lexicons"; 6 8 7 9 export type GetPublicationDataReturnType = Awaited< 8 10 ReturnType<(typeof get_publication_data)["handler"]> ··· 17 19 { did, publication_name }, 18 20 { supabase }: Pick<Env, "supabase">, 19 21 ) => { 20 - let uri; 22 + let pubLeafletUri; 23 + let siteStandardUri; 21 24 if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(publication_name)) { 22 - uri = AtUri.make( 25 + pubLeafletUri = AtUri.make( 23 26 did, 24 - "pub.leaflet.publication", 27 + ids.PubLeafletPublication, 28 + publication_name, 29 + ).toString(); 30 + siteStandardUri = AtUri.make( 31 + did, 32 + ids.SiteStandardPublication, 25 33 publication_name, 26 34 ).toString(); 27 35 } ··· 44 52 ) 45 53 )`, 46 54 ) 47 - .or(`name.eq."${publication_name}", uri.eq."${uri}"`) 55 + .or(`name.eq."${publication_name}", uri.eq."${pubLeafletUri}", uri.eq."${siteStandardUri}"`) 48 56 .eq("identity_did", did) 49 57 .single(); 50 58 ··· 58 66 { supabase }, 59 67 ); 60 68 61 - return { result: { publication, leaflet_data: leaflet_data.result } }; 69 + // Pre-normalize documents from documents_in_publications 70 + const documents = (publication?.documents_in_publications || []) 71 + .map((dip) => { 72 + if (!dip.documents) return null; 73 + const normalized = normalizeDocumentRecord(dip.documents.data, dip.documents.uri); 74 + if (!normalized) return null; 75 + return { 76 + uri: dip.documents.uri, 77 + record: normalized, 78 + indexed_at: dip.documents.indexed_at, 79 + data: dip.documents.data, 80 + commentsCount: dip.documents.comments_on_documents[0]?.count || 0, 81 + mentionsCount: dip.documents.document_mentions_in_bsky[0]?.count || 0, 82 + }; 83 + }) 84 + .filter((d): d is NonNullable<typeof d> => d !== null); 85 + 86 + // Pre-filter drafts (leaflets without published documents, not archived) 87 + const drafts = (publication?.leaflets_in_publications || []) 88 + .filter((l) => !l.documents) 89 + .filter((l) => !(l as { archived?: boolean }).archived) 90 + .map((l) => ({ 91 + leaflet: l.leaflet, 92 + title: l.title, 93 + permission_tokens: l.permission_tokens, 94 + // Keep the full leaflet data for LeafletList compatibility 95 + _raw: l, 96 + })); 97 + 98 + return { 99 + result: { 100 + publication, 101 + documents, 102 + drafts, 103 + leaflet_data: leaflet_data.result, 104 + }, 105 + }; 62 106 }, 63 107 });
+6 -10
app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx
··· 2 2 3 3 import { useEntity, useReplicache } from "src/replicache"; 4 4 import { useUIState } from "src/useUIState"; 5 - import { CSSProperties, useContext, useRef } from "react"; 5 + import { CSSProperties, useRef } from "react"; 6 6 import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 7 7 import { PostContent, Block } from "../PostContent"; 8 8 import { ··· 15 15 } from "lexicons/api"; 16 16 import { AppBskyFeedDefs } from "@atproto/api"; 17 17 import { TextBlock } from "./TextBlock"; 18 - import { PostPageContext } from "../PostPageContext"; 18 + import { useDocument } from "contexts/DocumentContext"; 19 19 import { openPage, useOpenPages } from "../PostPages"; 20 20 import { 21 21 openInteractionDrawer, ··· 155 155 }) { 156 156 let previewRef = useRef<HTMLDivElement | null>(null); 157 157 let { rootEntity } = useReplicache(); 158 - let data = useContext(PostPageContext); 159 - let theme = data?.theme; 158 + const { theme } = useDocument(); 160 159 let pageWidth = `var(--page-width-unitless)`; 161 160 let cardBorderHidden = !theme?.showPageBackground; 162 161 return ( ··· 195 194 } 196 195 197 196 const Interactions = (props: { pageId: string; parentPageId?: string }) => { 198 - const data = useContext(PostPageContext); 199 - const document_uri = data?.uri; 200 - if (!document_uri) 201 - throw new Error("document_uri not available in PostPageContext"); 202 - let comments = data.comments_on_documents.filter( 197 + const { uri: document_uri, comments: allComments, mentions } = useDocument(); 198 + let comments = allComments.filter( 203 199 (c) => (c.record as PubLeafletComment.Record)?.onPage === props.pageId, 204 200 ).length; 205 - let quotes = data.document_mentions_in_bsky.filter((q) => 201 + let quotes = mentions.filter((q) => 206 202 q.link.includes(props.pageId), 207 203 ).length; 208 204
+2 -2
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 69 69 data={document} 70 70 profile={profile} 71 71 preferences={preferences} 72 - commentsCount={getCommentCount(document, pageId)} 73 - quotesCount={getQuoteCount(document, pageId)} 72 + commentsCount={getCommentCount(document.comments_on_documents, pageId)} 73 + quotesCount={getQuoteCount(document.quotesAndMentions, pageId)} 74 74 /> 75 75 <CanvasContent 76 76 blocks={blocks}
+35 -38
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, 6 - PubLeafletDocument, 7 5 PubLeafletPagesLinearDocument, 8 6 PubLeafletPagesCanvas, 9 - PubLeafletPublication, 10 7 } from "lexicons/api"; 11 8 import { QuoteHandler } from "./QuoteHandler"; 12 9 import { ··· 14 11 PublicationThemeProvider, 15 12 } from "components/ThemeManager/PublicationThemeProvider"; 16 13 import { getPostPageData } from "./getPostPageData"; 17 - import { PostPageContextProvider } from "./PostPageContext"; 18 14 import { PostPages } from "./PostPages"; 19 15 import { extractCodeBlocks } from "./extractCodeBlocks"; 20 16 import { LeafletLayout } from "components/LeafletLayout"; 21 17 import { fetchPollData } from "./fetchPollData"; 18 + import { getDocumentPages, hasLeafletContent } from "src/utils/normalizeRecords"; 19 + import { DocumentProvider } from "contexts/DocumentContext"; 20 + import { LeafletContentProvider } from "contexts/LeafletContentContext"; 22 21 23 22 export async function DocumentPageRenderer({ 24 23 did, ··· 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 44 - if (!document?.data) 43 + const record = document?.normalizedDocument; 44 + const pages = record ? getDocumentPages(record) : undefined; 45 + 46 + if (!document?.data || !record || !pages) 45 47 return ( 46 48 <div className="bg-bg-leaflet h-full p-3 text-center relative"> 47 49 <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 max-w-md w-full"> ··· 55 57 </div> 56 58 </div> 57 59 ); 58 - 59 - let record = document.data as PubLeafletDocument.Record; 60 60 let bskyPosts = 61 - record.pages.flatMap((p) => { 61 + pages.flatMap((p) => { 62 62 let page = p as PubLeafletPagesLinearDocument.Main; 63 63 return page.blocks?.filter( 64 64 (b) => b.block.$type === ids.PubLeafletBlocksBskyPost, ··· 91 91 : []; 92 92 93 93 // Extract poll blocks and fetch vote data 94 - let pollBlocks = record.pages.flatMap((p) => { 94 + let pollBlocks = pages.flatMap((p) => { 95 95 let page = p as PubLeafletPagesLinearDocument.Main; 96 96 return ( 97 97 page.blocks?.filter((b) => b.block.$type === ids.PubLeafletBlocksPoll) || ··· 102 102 pollBlocks.map((b) => (b.block as any).pollRef.uri), 103 103 ); 104 104 105 - // Get theme from publication or document (for standalone docs) 106 - let pubRecord = document.documents_in_publications[0]?.publications 107 - ?.record as PubLeafletPublication.Record | undefined; 108 - let theme = pubRecord?.theme || record.theme || null; 109 - let pub_creator = 110 - document.documents_in_publications[0]?.publications?.identity_did || did; 105 + const pubRecord = document.normalizedPublication; 106 + let pub_creator = document.publication?.identity_did || did; 111 107 let isStandalone = !pubRecord; 112 108 113 - let firstPage = record.pages[0]; 114 - 109 + let firstPage = pages[0]; 115 110 let firstPageBlocks = 116 111 ( 117 112 firstPage as ··· 121 116 let prerenderedCodeBlocks = await extractCodeBlocks(firstPageBlocks); 122 117 123 118 return ( 124 - <PostPageContextProvider value={document}> 125 - <PublicationThemeProvider theme={theme} pub_creator={pub_creator} isStandalone={isStandalone}> 126 - <PublicationBackgroundProvider theme={theme} pub_creator={pub_creator}> 127 - <LeafletLayout> 128 - <PostPages 129 - document_uri={document.uri} 130 - preferences={pubRecord?.preferences || {}} 131 - pubRecord={pubRecord} 132 - profile={JSON.parse(JSON.stringify(profile.data))} 133 - document={document} 134 - bskyPostData={bskyPostData} 135 - did={did} 136 - prerenderedCodeBlocks={prerenderedCodeBlocks} 137 - pollData={pollData} 138 - /> 139 - </LeafletLayout> 119 + <DocumentProvider value={document}> 120 + <LeafletContentProvider value={{ pages }}> 121 + <PublicationThemeProvider theme={document.theme} pub_creator={pub_creator} isStandalone={isStandalone}> 122 + <PublicationBackgroundProvider theme={document.theme} pub_creator={pub_creator}> 123 + <LeafletLayout> 124 + <PostPages 125 + document_uri={document.uri} 126 + preferences={pubRecord?.preferences || {}} 127 + pubRecord={pubRecord} 128 + profile={JSON.parse(JSON.stringify(profile.data))} 129 + document={document} 130 + bskyPostData={bskyPostData} 131 + did={did} 132 + prerenderedCodeBlocks={prerenderedCodeBlocks} 133 + pollData={pollData} 134 + /> 135 + </LeafletLayout> 140 136 141 - <QuoteHandler /> 142 - </PublicationBackgroundProvider> 143 - </PublicationThemeProvider> 144 - </PostPageContextProvider> 137 + <QuoteHandler /> 138 + </PublicationBackgroundProvider> 139 + </PublicationThemeProvider> 140 + </LeafletContentProvider> 141 + </DocumentProvider> 145 142 ); 146 143 }
+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,
+27 -38
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 6 6 import { create } from "zustand"; 7 7 import type { Comment } from "./Comments"; 8 8 import { decodeQuotePosition, QuotePosition } from "../quotePosition"; 9 - import { useContext } from "react"; 10 - import { PostPageContext } from "../PostPageContext"; 9 + import { useDocument } from "contexts/DocumentContext"; 11 10 import { scrollIntoView } from "src/utils/scrollIntoView"; 12 11 import { TagTiny } from "components/Icons/TagTiny"; 13 12 import { Tag } from "components/Tags"; 14 13 import { Popover } from "components/Popover"; 15 - import { PostPageData } from "../getPostPageData"; 16 - import { PubLeafletComment, PubLeafletPublication } from "lexicons/api"; 14 + import { PubLeafletComment } from "lexicons/api"; 15 + import { type CommentOnDocument } from "contexts/DocumentContext"; 17 16 import { prefetchQuotesData } from "./Quotes"; 18 17 import { useIdentityData } from "components/IdentityProvider"; 19 18 import { ManageSubscription, SubscribeWithBluesky } from "app/lish/Subscribe"; ··· 111 110 showMentions: boolean; 112 111 pageId?: string; 113 112 }) => { 114 - const data = useContext(PostPageContext); 115 - const document_uri = data?.uri; 113 + const { uri: document_uri, quotesAndMentions, normalizedDocument } = useDocument(); 116 114 let { identity } = useIdentityData(); 117 - if (!document_uri) 118 - throw new Error("document_uri not available in PostPageContext"); 119 115 120 116 let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); 121 117 122 118 const handleQuotePrefetch = () => { 123 - if (data?.quotesAndMentions) { 124 - prefetchQuotesData(data.quotesAndMentions); 119 + if (quotesAndMentions) { 120 + prefetchQuotesData(quotesAndMentions); 125 121 } 126 122 }; 127 123 128 - const tags = (data?.data as any)?.tags as string[] | undefined; 124 + const tags = normalizedDocument.tags; 129 125 const tagCount = tags?.length || 0; 130 126 131 127 return ( ··· 172 168 showMentions: boolean; 173 169 pageId?: string; 174 170 }) => { 175 - const data = useContext(PostPageContext); 171 + const { uri: document_uri, quotesAndMentions, normalizedDocument, publication, leafletId } = useDocument(); 176 172 let { identity } = useIdentityData(); 177 173 178 - const document_uri = data?.uri; 179 - if (!document_uri) 180 - throw new Error("document_uri not available in PostPageContext"); 181 - 182 174 let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); 183 175 184 176 const handleQuotePrefetch = () => { 185 - if (data?.quotesAndMentions) { 186 - prefetchQuotesData(data.quotesAndMentions); 177 + if (quotesAndMentions) { 178 + prefetchQuotesData(quotesAndMentions); 187 179 } 188 180 }; 189 - let publication = data?.documents_in_publications[0]?.publications; 190 181 191 - const tags = (data?.data as any)?.tags as string[] | undefined; 182 + const tags = normalizedDocument.tags; 192 183 const tagCount = tags?.length || 0; 193 184 194 185 let noInteractions = !props.showComments && !props.showMentions; ··· 202 193 203 194 let isAuthor = 204 195 identity && 205 - identity.atp_did === 206 - data.documents_in_publications[0]?.publications?.identity_did && 207 - data.leaflets_in_publications[0]; 196 + identity.atp_did === publication?.identity_did && 197 + leafletId; 208 198 209 199 return ( 210 200 <div ··· 282 272 </> 283 273 )} 284 274 285 - <EditButton document={data} /> 275 + <EditButton publication={publication} leafletId={leafletId} /> 286 276 {subscribed && publication && ( 287 277 <ManageSubscription 288 278 base_url={getPublicationURL(publication)} ··· 323 313 </div> 324 314 ); 325 315 }; 326 - export function getQuoteCount(document: PostPageData, pageId?: string) { 327 - if (!document) return; 328 - return getQuoteCountFromArray(document.quotesAndMentions, pageId); 316 + export function getQuoteCount(quotesAndMentions: { uri: string; link?: string }[], pageId?: string) { 317 + return getQuoteCountFromArray(quotesAndMentions, pageId); 329 318 } 330 319 331 320 export function getQuoteCountFromArray( ··· 349 338 } 350 339 } 351 340 352 - export function getCommentCount(document: PostPageData, pageId?: string) { 353 - if (!document) return; 341 + export function getCommentCount(comments: CommentOnDocument[], pageId?: string) { 354 342 if (pageId) 355 - return document.comments_on_documents.filter( 343 + return comments.filter( 356 344 (c) => (c.record as PubLeafletComment.Record)?.onPage === pageId, 357 345 ).length; 358 346 else 359 - return document.comments_on_documents.filter( 347 + return comments.filter( 360 348 (c) => !(c.record as PubLeafletComment.Record)?.onPage, 361 349 ).length; 362 350 } 363 351 364 - const EditButton = (props: { document: PostPageData }) => { 352 + const EditButton = (props: { 353 + publication: { identity_did: string } | null; 354 + leafletId: string | null; 355 + }) => { 365 356 let { identity } = useIdentityData(); 366 - if (!props.document) return; 367 357 if ( 368 358 identity && 369 - identity.atp_did === 370 - props.document.documents_in_publications[0]?.publications?.identity_did && 371 - props.document.leaflets_in_publications[0] 359 + identity.atp_did === props.publication?.identity_did && 360 + props.leafletId 372 361 ) 373 362 return ( 374 363 <a 375 - href={`https://leaflet.pub/${props.document.leaflets_in_publications[0]?.leaflet}`} 364 + href={`https://leaflet.pub/${props.leafletId}`} 376 365 className="flex gap-2 items-center hover:!no-underline selected-outline px-2 py-0.5 bg-accent-1 text-accent-2 font-bold w-fit rounded-lg !border-accent-1 !outline-accent-1" 377 366 > 378 367 <EditTiny /> Edit Post 379 368 </a> 380 369 ); 381 - return; 370 + return null; 382 371 };
+7 -12
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
··· 1 1 "use client"; 2 2 import { CloseTiny } from "components/Icons/CloseTiny"; 3 - import { useContext } from "react"; 4 3 import { useIsMobile } from "src/hooks/isMobile"; 5 4 import { setInteractionState } from "./Interactions"; 6 5 import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 7 6 import { AtUri, AppBskyFeedPost } from "@atproto/api"; 8 - import { PostPageContext } from "../PostPageContext"; 9 7 import { 10 8 PubLeafletBlocksText, 11 9 PubLeafletBlocksUnorderedList, 12 10 PubLeafletBlocksHeader, 13 - PubLeafletDocument, 14 11 PubLeafletPagesLinearDocument, 15 12 PubLeafletBlocksCode, 16 13 } from "lexicons/api"; 14 + import { useDocument } from "contexts/DocumentContext"; 15 + import { useLeafletContent } from "contexts/LeafletContentContext"; 17 16 import { decodeQuotePosition, QuotePosition } from "../quotePosition"; 18 17 import { useActiveHighlightState } from "../useHighlight"; 19 18 import { PostContent } from "../PostContent"; ··· 66 65 quotesAndMentions: { uri: string; link?: string }[]; 67 66 did: string; 68 67 }) => { 69 - let data = useContext(PostPageContext); 70 - const document_uri = data?.uri; 71 - if (!document_uri) 72 - throw new Error("document_uri not available in PostPageContext"); 68 + const { uri: document_uri } = useDocument(); 73 69 74 70 // Fetch Bluesky post data for all URIs 75 71 const uris = props.quotesAndMentions.map((q) => q.uri); ··· 182 178 did: string; 183 179 }) => { 184 180 let isMobile = useIsMobile(); 185 - const data = useContext(PostPageContext); 186 - const document_uri = data?.uri; 181 + const { uri: document_uri } = useDocument(); 182 + const { pages } = useLeafletContent(); 187 183 188 - let record = data?.data as PubLeafletDocument.Record; 189 184 let page: PubLeafletPagesLinearDocument.Main | undefined = ( 190 185 props.position.pageId 191 - ? record.pages.find( 186 + ? pages.find( 192 187 (p) => 193 188 (p as PubLeafletPagesLinearDocument.Main).id === 194 189 props.position.pageId, 195 190 ) 196 - : record.pages[0] 191 + : pages[0] 197 192 ) as PubLeafletPagesLinearDocument.Main; 198 193 // Extract blocks within the quote range 199 194 const content = extractQuotedBlocks(page.blocks || [], props.position, []);
+6 -11
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 1 1 "use client"; 2 - import { 3 - PubLeafletComment, 4 - PubLeafletDocument, 5 - PubLeafletPagesLinearDocument, 6 - PubLeafletPublication, 7 - } from "lexicons/api"; 2 + import { PubLeafletPagesLinearDocument } from "lexicons/api"; 3 + import { useLeafletContent } from "contexts/LeafletContentContext"; 8 4 import { PostPageData } from "./getPostPageData"; 9 5 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 10 6 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; ··· 50 46 hasPageBackground, 51 47 } = props; 52 48 let drawer = useDrawerOpen(document_uri); 49 + const { pages } = useLeafletContent(); 53 50 54 51 if (!document) return null; 55 52 56 - let record = document.data as PubLeafletDocument.Record; 57 - 58 53 const isSubpage = !!pageId; 59 54 60 55 return ( ··· 77 72 )} 78 73 <PostContent 79 74 pollData={pollData} 80 - pages={record.pages as PubLeafletPagesLinearDocument.Main[]} 75 + pages={pages as PubLeafletPagesLinearDocument.Main[]} 81 76 pageId={pageId} 82 77 bskyPostData={bskyPostData} 83 78 blocks={blocks} ··· 92 87 pageId={pageId} 93 88 showComments={preferences.showComments !== false} 94 89 showMentions={preferences.showMentions !== false} 95 - commentsCount={getCommentCount(document, pageId) || 0} 96 - quotesCount={getQuoteCount(document, pageId) || 0} 90 + commentsCount={getCommentCount(document.comments_on_documents, pageId) || 0} 91 + quotesCount={getQuoteCount(document.quotesAndMentions, pageId) || 0} 97 92 /> 98 93 {!hasPageBackground && <div className={`spacer h-8 w-full`} />} 99 94 </PageWrapper>
+5 -10
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 1 1 "use client"; 2 - import { 3 - PubLeafletComment, 4 - PubLeafletDocument, 5 - PubLeafletPublication, 6 - } from "lexicons/api"; 7 2 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 8 3 import { 9 4 Interactions, ··· 28 23 let { identity } = useIdentityData(); 29 24 let document = props.data; 30 25 31 - let record = document?.data as PubLeafletDocument.Record; 26 + const record = document?.normalizedDocument; 32 27 let profile = props.profile; 33 28 let pub = props.data?.documents_in_publications[0]?.publications; 34 29 35 30 const formattedDate = useLocalizedDate( 36 - record.publishedAt || new Date().toISOString(), 31 + record?.publishedAt || new Date().toISOString(), 37 32 { 38 33 year: "numeric", 39 34 month: "long", ··· 41 36 }, 42 37 ); 43 38 44 - if (!document?.data) return; 39 + if (!document?.data || !record) return null; 45 40 return ( 46 41 <PostHeaderLayout 47 42 pubLink={ ··· 92 87 <Interactions 93 88 showComments={props.preferences.showComments !== false} 94 89 showMentions={props.preferences.showMentions !== false} 95 - quotesCount={getQuoteCount(document) || 0} 96 - commentsCount={getCommentCount(document) || 0} 90 + quotesCount={getQuoteCount(document?.quotesAndMentions || []) || 0} 91 + commentsCount={getCommentCount(document?.comments_on_documents || []) || 0} 97 92 /> 98 93 </> 99 94 }
-19
app/lish/[did]/[publication]/[rkey]/PostPageContext.tsx
··· 1 - "use client"; 2 - import { createContext } from "react"; 3 - import { PostPageData } from "./getPostPageData"; 4 - 5 - export const PostPageContext = createContext<PostPageData>(null); 6 - 7 - export const PostPageContextProvider = ({ 8 - children, 9 - value, 10 - }: { 11 - children: React.ReactNode; 12 - value: PostPageData; 13 - }) => { 14 - return ( 15 - <PostPageContext.Provider value={value}> 16 - {children} 17 - </PostPageContext.Provider> 18 - ); 19 - };
+12 -9
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 1 1 "use client"; 2 2 import { 3 - PubLeafletDocument, 4 3 PubLeafletPagesLinearDocument, 5 4 PubLeafletPagesCanvas, 6 5 PubLeafletPublication, 7 6 } from "lexicons/api"; 7 + import { type NormalizedPublication } from "src/utils/normalizeRecords"; 8 + import { useLeafletContent } from "contexts/LeafletContentContext"; 9 + import { useDocument } from "contexts/DocumentContext"; 8 10 import { PostPageData } from "./getPostPageData"; 9 11 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 10 12 import { AppBskyFeedDefs } from "@atproto/api"; ··· 152 154 showMentions?: boolean; 153 155 showPrevNext?: boolean; 154 156 }; 155 - pubRecord?: PubLeafletPublication.Record; 157 + pubRecord?: NormalizedPublication | null; 156 158 theme?: PubLeafletPublication.Theme | null; 157 159 prerenderedCodeBlocks?: Map<string, string>; 158 160 bskyPostData: AppBskyFeedDefs.PostView[]; ··· 206 208 document_uri: string; 207 209 document: PostPageData; 208 210 profile: ProfileViewDetailed; 209 - pubRecord?: PubLeafletPublication.Record; 211 + pubRecord?: NormalizedPublication | null; 210 212 did: string; 211 213 prerenderedCodeBlocks?: Map<string, string>; 212 214 bskyPostData: AppBskyFeedDefs.PostView[]; ··· 220 222 let drawer = useDrawerOpen(document_uri); 221 223 useInitializeOpenPages(); 222 224 let openPageIds = useOpenPages(); 223 - if (!document) return null; 225 + const { pages } = useLeafletContent(); 226 + const { quotesAndMentions } = useDocument(); 227 + const record = document?.normalizedDocument; 228 + if (!document || !record) return null; 224 229 225 - let record = document.data as PubLeafletDocument.Record; 226 230 let theme = pubRecord?.theme || record.theme || null; 227 231 // For publication posts, respect the publication's showPageBackground setting 228 232 // For standalone documents, default to showing page background 229 233 let isInPublication = !!pubRecord; 230 234 let hasPageBackground = isInPublication ? !!theme?.showPageBackground : true; 231 - let quotesAndMentions = document.quotesAndMentions; 232 235 233 - let firstPage = record.pages[0] as 236 + let firstPage = pages[0] as 234 237 | PubLeafletPagesLinearDocument.Main 235 238 | PubLeafletPagesCanvas.Main; 236 239 ··· 250 253 pollData, 251 254 document_uri, 252 255 hasPageBackground, 253 - allPages: record.pages as ( 256 + allPages: pages as ( 254 257 | PubLeafletPagesLinearDocument.Main 255 258 | PubLeafletPagesCanvas.Main 256 259 )[], ··· 329 332 } 330 333 331 334 // Handle document pages 332 - let page = record.pages.find( 335 + let page = pages.find( 333 336 (p) => 334 337 ( 335 338 p as
+7 -13
app/lish/[did]/[publication]/[rkey]/PostPrevNextButtons.tsx
··· 1 1 "use client"; 2 - import { PubLeafletDocument } from "lexicons/api"; 3 - import { usePublicationData } from "../dashboard/PublicationSWRProvider"; 4 2 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 3 import { AtUri } from "@atproto/api"; 6 - import { useParams } from "next/navigation"; 7 - import { getPostPageData } from "./getPostPageData"; 8 - import { PostPageContext } from "./PostPageContext"; 9 - import { useContext } from "react"; 4 + import { useDocument } from "contexts/DocumentContext"; 10 5 import { SpeedyLink } from "components/SpeedyLink"; 11 6 import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 12 7 13 8 export const PostPrevNextButtons = (props: { showPrevNext: boolean }) => { 14 - let postData = useContext(PostPageContext); 15 - let pub = postData?.documents_in_publications[0]?.publications; 9 + const { prevNext, publication } = useDocument(); 16 10 17 - if (!props.showPrevNext || !pub || !postData) return; 11 + if (!props.showPrevNext || !publication) return null; 18 12 19 13 function getPostLink(uri: string) { 20 - return pub && uri 21 - ? `${getPublicationURL(pub)}/${new AtUri(uri).rkey}` 14 + return publication && uri 15 + ? `${getPublicationURL(publication)}/${new AtUri(uri).rkey}` 22 16 : "leaflet.pub/not-found"; 23 17 } 24 - let prevPost = postData?.prevNext?.prev; 25 - let nextPost = postData?.prevNext?.next; 18 + let prevPost = prevNext?.prev; 19 + let nextPost = prevNext?.next; 26 20 27 21 return ( 28 22 <div className="flex flex-col gap-1 w-full px-3 sm:px-4 pb-2 pt-2">
+8 -9
app/lish/[did]/[publication]/[rkey]/PostSubscribe.tsx
··· 1 1 "use client"; 2 - import { useContext } from "react"; 3 - import { PostPageContext } from "./PostPageContext"; 2 + import { useDocumentOptional } from "contexts/DocumentContext"; 4 3 import { useIdentityData } from "components/IdentityProvider"; 5 4 import { SubscribeWithBluesky } from "app/lish/Subscribe"; 6 5 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 7 6 8 7 export const PostSubscribe = () => { 9 - const data = useContext(PostPageContext); 8 + const data = useDocumentOptional(); 10 9 let { identity } = useIdentityData(); 11 10 12 - let publication = data?.documents_in_publications[0]?.publications; 11 + let publication = data?.publication; 12 + let normalizedPublication = data?.normalizedPublication; 13 13 14 14 let subscribed = 15 15 identity?.atp_did && ··· 20 20 21 21 let isAuthor = 22 22 identity && 23 - identity.atp_did === 24 - data?.documents_in_publications[0]?.publications?.identity_did && 25 - data?.leaflets_in_publications[0]; 23 + identity.atp_did === publication?.identity_did && 24 + data?.leafletId; 26 25 27 - if (!subscribed && !isAuthor && publication && publication.record) 26 + if (!subscribed && !isAuthor && publication && normalizedPublication) 28 27 return ( 29 28 <div className="text-center flex flex-col accent-container rounded-md mb-3 mx-3 sm:mx-4"> 30 29 <div className="flex flex-col py-4"> ··· 41 40 </div> 42 41 </div> 43 42 ); 44 - else return; 43 + else return null; 45 44 };
+4 -10
app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx
··· 3 3 import { CopyTiny } from "components/Icons/CopyTiny"; 4 4 import { Separator } from "components/Layout"; 5 5 import { useSmoker } from "components/Toast"; 6 - import { useEffect, useMemo, useState, useContext } from "react"; 6 + import { useEffect, useMemo, useState } from "react"; 7 7 import { 8 8 encodeQuotePosition, 9 9 decodeQuotePosition, ··· 12 12 import { useIdentityData } from "components/IdentityProvider"; 13 13 import { CommentTiny } from "components/Icons/CommentTiny"; 14 14 import { setInteractionState } from "./Interactions/Interactions"; 15 - import { PostPageContext } from "./PostPageContext"; 16 - import { PubLeafletPublication } from "lexicons/api"; 15 + import { useDocument } from "contexts/DocumentContext"; 17 16 import { flushSync } from "react-dom"; 18 17 import { scrollIntoView } from "src/utils/scrollIntoView"; 19 18 ··· 148 147 export const QuoteOptionButtons = (props: { position: string }) => { 149 148 let smoker = useSmoker(); 150 149 let { identity } = useIdentityData(); 151 - const data = useContext(PostPageContext); 152 - const document_uri = data?.uri; 153 - if (!document_uri) 154 - throw new Error("document_uri not available in PostPageContext"); 150 + const { uri: document_uri, publication } = useDocument(); 155 151 let [url, position] = useMemo(() => { 156 152 let currentUrl = new URL(window.location.href); 157 153 let pos = decodeQuotePosition(props.position); ··· 169 165 currentUrl.hash = `#${fragmentId}`; 170 166 return [currentUrl.toString(), pos]; 171 167 }, [props.position]); 172 - let pubRecord = data.documents_in_publications[0]?.publications?.record as 173 - | PubLeafletPublication.Record 174 - | undefined; 168 + let pubRecord = publication?.record; 175 169 176 170 return ( 177 171 <>
+58 -27
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 2 import { AtUri } from "@atproto/syntax"; 3 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 3 + import { 4 + normalizeDocumentRecord, 5 + normalizePublicationRecord, 6 + type NormalizedDocument, 7 + type NormalizedPublication, 8 + } from "src/utils/normalizeRecords"; 9 + import { PubLeafletPublication, SiteStandardPublication } from "lexicons/api"; 10 + import { documentUriFilter } from "src/utils/uriHelpers"; 4 11 5 - export async function getPostPageData(uri: string) { 6 - let { data: document } = await supabaseServerClient 12 + export async function getPostPageData(did: string, rkey: string) { 13 + let { data: documents } = await supabaseServerClient 7 14 .from("documents") 8 15 .select( 9 16 ` ··· 18 25 leaflets_in_publications(*) 19 26 `, 20 27 ) 21 - .eq("uri", uri) 22 - .single(); 28 + .or(documentUriFilter(did, rkey)) 29 + .order("uri", { ascending: false }) 30 + .limit(1); 31 + let document = documents?.[0]; 23 32 24 33 if (!document) return null; 25 34 35 + // Normalize the document record - this is the primary way consumers should access document data 36 + const normalizedDocument = normalizeDocumentRecord(document.data, document.uri); 37 + if (!normalizedDocument) return null; 38 + 39 + // Normalize the publication record - this is the primary way consumers should access publication data 40 + const normalizedPublication = normalizePublicationRecord( 41 + document.documents_in_publications[0]?.publications?.record 42 + ); 43 + 26 44 // Fetch constellation backlinks for mentions 27 - const pubRecord = document.documents_in_publications[0]?.publications 28 - ?.record as PubLeafletPublication.Record; 29 - let aturi = new AtUri(uri); 30 - const postUrl = pubRecord 31 - ? `https://${pubRecord?.base_path}/${aturi.rkey}` 45 + let aturi = new AtUri(document.uri); 46 + const postUrl = normalizedPublication 47 + ? `${normalizedPublication.url}/${aturi.rkey}` 32 48 : `https://leaflet.pub/p/${aturi.host}/${aturi.rkey}`; 33 49 const constellationBacklinks = await getConstellationBacklinks(postUrl); 34 50 ··· 48 64 ...uniqueBacklinks, 49 65 ]; 50 66 51 - let theme = 52 - ( 53 - document?.documents_in_publications[0]?.publications 54 - ?.record as PubLeafletPublication.Record 55 - )?.theme || (document?.data as PubLeafletDocument.Record)?.theme; 67 + let theme = normalizedPublication?.theme || normalizedDocument?.theme; 56 68 57 69 // Calculate prev/next documents from the fetched publication documents 58 70 let prevNext: ··· 62 74 } 63 75 | undefined; 64 76 65 - const currentPublishedAt = (document.data as PubLeafletDocument.Record) 66 - ?.publishedAt; 77 + const currentPublishedAt = normalizedDocument.publishedAt; 67 78 const allDocs = 68 79 document.documents_in_publications[0]?.publications 69 80 ?.documents_in_publications; ··· 71 82 if (currentPublishedAt && allDocs) { 72 83 // Filter and sort documents by publishedAt 73 84 const sortedDocs = allDocs 74 - .map((dip) => ({ 75 - uri: dip?.documents?.uri, 76 - title: (dip?.documents?.data as PubLeafletDocument.Record).title, 77 - publishedAt: (dip?.documents?.data as PubLeafletDocument.Record) 78 - .publishedAt, 79 - })) 80 - .filter((doc) => doc.publishedAt) // Only include docs with publishedAt 85 + .map((dip) => { 86 + const normalizedData = normalizeDocumentRecord(dip?.documents?.data, dip?.documents?.uri); 87 + return { 88 + uri: dip?.documents?.uri, 89 + title: normalizedData?.title, 90 + publishedAt: normalizedData?.publishedAt, 91 + }; 92 + }) 93 + .filter((doc) => doc.publishedAt && doc.title) // Only include docs with publishedAt and valid data 81 94 .sort( 82 95 (a, b) => 83 96 new Date(a.publishedAt!).getTime() - ··· 85 98 ); 86 99 87 100 // Find current document index 88 - const currentIndex = sortedDocs.findIndex((doc) => doc.uri === uri); 101 + const currentIndex = sortedDocs.findIndex((doc) => doc.uri === document.uri); 89 102 90 103 if (currentIndex !== -1) { 91 104 prevNext = { ··· 93 106 currentIndex > 0 94 107 ? { 95 108 uri: sortedDocs[currentIndex - 1].uri || "", 96 - title: sortedDocs[currentIndex - 1].title, 109 + title: sortedDocs[currentIndex - 1].title || "", 97 110 } 98 111 : undefined, 99 112 next: 100 113 currentIndex < sortedDocs.length - 1 101 114 ? { 102 115 uri: sortedDocs[currentIndex + 1].uri || "", 103 - title: sortedDocs[currentIndex + 1].title, 116 + title: sortedDocs[currentIndex + 1].title || "", 104 117 } 105 118 : undefined, 106 119 }; 107 120 } 108 121 } 109 122 123 + // Build explicit publication context for consumers 124 + const rawPub = document.documents_in_publications[0]?.publications; 125 + const publication = rawPub ? { 126 + uri: rawPub.uri, 127 + name: rawPub.name, 128 + identity_did: rawPub.identity_did, 129 + record: rawPub.record as PubLeafletPublication.Record | SiteStandardPublication.Record | null, 130 + publication_subscriptions: rawPub.publication_subscriptions || [], 131 + } : null; 132 + 110 133 return { 111 134 ...document, 135 + // Pre-normalized data - consumers should use these instead of normalizing themselves 136 + normalizedDocument, 137 + normalizedPublication, 112 138 quotesAndMentions, 113 139 theme, 114 140 prevNext, 141 + // Explicit relational data for DocumentContext 142 + publication, 143 + comments: document.comments_on_documents, 144 + mentions: document.document_mentions_in_bsky, 145 + leafletId: document.leaflets_in_publications[0]?.leaflet || null, 115 146 }; 116 147 } 117 148
+9 -8
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 - import { PubLeafletDocument } from "lexicons/api"; 6 3 import { jsonToLex } from "@atproto/lexicon"; 7 4 import { fetchAtprotoBlob } from "app/api/atproto_images/route"; 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 - let docRecord = jsonToLex(document.data) as PubLeafletDocument.Record; 26 - if (docRecord.coverImage) { 26 + const docRecord = normalizeDocumentRecord(jsonToLex(document.data)); 27 + if (docRecord?.coverImage) { 27 28 try { 28 29 // Get CID from the blob ref (handle both serialized and hydrated forms) 29 30 let cid =
+9 -7
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 - import { PubLeafletDocument } from "lexicons/api"; 5 2 import { Metadata } from "next"; 6 3 import { DocumentPageRenderer } from "./DocumentPageRenderer"; 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 - let docRecord = document.data as PubLeafletDocument.Record; 25 + const docRecord = normalizeDocumentRecord(document.data); 26 + if (!docRecord) return { title: "404" }; 25 27 26 28 return { 27 29 icons: {
-3
app/lish/[did]/[publication]/[rkey]/useHighlight.tsx
··· 2 2 "use client"; 3 3 4 4 import { useParams } from "next/navigation"; 5 - import { useContext } from "react"; 6 - import { PostPageContext } from "./PostPageContext"; 7 5 import { create } from "zustand"; 8 6 import { decodeQuotePosition, QuotePosition } from "./quotePosition"; 9 7 ··· 12 10 })); 13 11 14 12 export const useHighlight = (pos: number[], pageId?: string) => { 15 - let doc = useContext(PostPageContext); 16 13 let { quote } = useParams(); 17 14 let activeHighlight = useActiveHighlightState( 18 15 (state) => state.activeHighlight,
+32 -28
app/lish/[did]/[publication]/dashboard/DraftList.tsx
··· 2 2 3 3 import { NewDraftSecondaryButton } from "./NewDraftButton"; 4 4 import React from "react"; 5 - import { usePublicationData } from "./PublicationSWRProvider"; 5 + import { 6 + usePublicationData, 7 + useNormalizedPublicationRecord, 8 + } from "./PublicationSWRProvider"; 6 9 import { LeafletList } from "app/(home-pages)/home/HomeLayout"; 7 10 8 11 export function DraftList(props: { ··· 10 13 showPageBackground: boolean; 11 14 }) { 12 15 let { data: pub_data } = usePublicationData(); 16 + // Normalize the publication record - skip rendering if unrecognized format 17 + const normalizedPubRecord = useNormalizedPublicationRecord(); 13 18 if (!pub_data?.publication) return null; 14 - let { leaflets_in_publications, ...publication } = pub_data.publication; 19 + const { drafts, leaflet_data } = pub_data; 20 + const { leaflets_in_publications, ...publication } = pub_data.publication; 21 + 22 + if (!normalizedPubRecord) return null; 23 + 15 24 return ( 16 25 <div className="flex flex-col gap-4"> 17 26 <NewDraftSecondaryButton ··· 23 32 searchValue={props.searchValue} 24 33 showPreview={false} 25 34 defaultDisplay="list" 26 - leaflets={leaflets_in_publications 27 - .filter((l) => !l.documents) 28 - .filter((l) => !l.archived) 29 - .map((l) => { 30 - return { 31 - archived: l.archived, 32 - added_at: "", 33 - token: { 34 - ...l.permission_tokens!, 35 - leaflets_in_publications: [ 36 - { 37 - ...l, 38 - publications: { 39 - ...publication, 40 - }, 41 - }, 42 - ], 43 - }, 44 - }; 45 - })} 46 - initialFacts={pub_data.leaflet_data.facts || {}} 35 + leaflets={drafts 36 + .filter((d) => d.permission_tokens) 37 + .map((d) => ({ 38 + archived: (d._raw as { archived?: boolean }).archived, 39 + added_at: "", 40 + token: { 41 + ...d.permission_tokens!, 42 + leaflets_in_publications: [ 43 + { 44 + ...d._raw, 45 + publications: publication, 46 + }, 47 + ], 48 + }, 49 + }))} 50 + initialFacts={leaflet_data.facts || {}} 47 51 titles={{ 48 - ...leaflets_in_publications.reduce( 49 - (acc, leaflet) => { 50 - if (leaflet.permission_tokens) 51 - acc[leaflet.permission_tokens.root_entity] = 52 - leaflet.title || "Untitled"; 52 + ...drafts.reduce( 53 + (acc, draft) => { 54 + if (draft.permission_tokens) 55 + acc[draft.permission_tokens.root_entity] = 56 + draft.title || "Untitled"; 53 57 return acc; 54 58 }, 55 59 {} as { [l: string]: string },
+2 -4
app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
··· 5 5 import { Actions } from "./Actions"; 6 6 import React, { useState } from "react"; 7 7 import { PublishedPostsList } from "./PublishedPostsLists"; 8 - import { PubLeafletPublication } from "lexicons/api"; 9 8 import { PublicationSubscribers } from "./PublicationSubscribers"; 10 - import { AtUri } from "@atproto/syntax"; 11 9 import { 12 - HomeDashboardControls, 13 10 DashboardLayout, 14 11 PublicationDashboardControls, 15 12 } from "components/PageLayouts/DashboardLayout"; 16 13 import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 14 + import { type NormalizedPublication } from "src/utils/normalizeRecords"; 17 15 18 16 export default function PublicationDashboard({ 19 17 publication, 20 18 record, 21 19 }: { 22 - record: PubLeafletPublication.Record; 20 + record: NormalizedPublication; 23 21 publication: Exclude< 24 22 GetPublicationDataReturnType["result"]["publication"], 25 23 null
+22 -3
app/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx
··· 2 2 3 3 import type { GetPublicationDataReturnType } from "app/api/rpc/[command]/get_publication_data"; 4 4 import { callRPC } from "app/api/rpc/client"; 5 - import { createContext, useContext, useEffect } from "react"; 5 + import { createContext, useContext, useEffect, useMemo } from "react"; 6 6 import useSWR, { SWRConfig, KeyedMutator, mutate } from "swr"; 7 - import { produce, Draft } from "immer"; 7 + import { produce, Draft as ImmerDraft } from "immer"; 8 + import { 9 + normalizePublicationRecord, 10 + type NormalizedPublication, 11 + } from "src/utils/normalizeRecords"; 8 12 13 + // Derive all types from the RPC return type 9 14 export type PublicationData = GetPublicationDataReturnType["result"]; 15 + export type PublishedDocument = NonNullable<PublicationData>["documents"][number]; 16 + export type PublicationDraft = NonNullable<PublicationData>["drafts"][number]; 10 17 11 18 const PublicationContext = createContext({ name: "", did: "" }); 12 19 export function PublicationSWRDataProvider(props: { ··· 49 56 return { data, mutate }; 50 57 } 51 58 59 + /** 60 + * Returns the normalized publication record from the publication data. 61 + * Use this instead of manually calling normalizePublicationRecord on data.publication.record 62 + */ 63 + export function useNormalizedPublicationRecord(): NormalizedPublication | null { 64 + const { data } = usePublicationData(); 65 + return useMemo( 66 + () => normalizePublicationRecord(data?.publication?.record), 67 + [data?.publication?.record] 68 + ); 69 + } 70 + 52 71 export function mutatePublicationData( 53 72 mutate: KeyedMutator<PublicationData>, 54 - recipe: (draft: Draft<NonNullable<PublicationData>>) => void, 73 + recipe: (draft: ImmerDraft<NonNullable<PublicationData>>) => void, 55 74 ) { 56 75 mutate( 57 76 (data) => {
+123 -129
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 1 1 "use client"; 2 2 import { AtUri } from "@atproto/syntax"; 3 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 4 3 import { EditTiny } from "components/Icons/EditTiny"; 5 4 6 - import { usePublicationData } from "./PublicationSWRProvider"; 7 - import { Fragment, useState } from "react"; 5 + import { 6 + usePublicationData, 7 + useNormalizedPublicationRecord, 8 + type PublishedDocument, 9 + } from "./PublicationSWRProvider"; 10 + import { Fragment } from "react"; 8 11 import { useParams } from "next/navigation"; 9 12 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 10 - import { Menu, MenuItem } from "components/Menu"; 11 - import { deletePost } from "./deletePost"; 12 - import { ButtonPrimary } from "components/Buttons"; 13 - import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 14 - import { DeleteSmall } from "components/Icons/DeleteSmall"; 15 - import { ShareSmall } from "components/Icons/ShareSmall"; 16 - import { ShareButton } from "app/[leaflet_id]/actions/ShareOptions"; 17 13 import { SpeedyLink } from "components/SpeedyLink"; 18 - import { QuoteTiny } from "components/Icons/QuoteTiny"; 19 - import { CommentTiny } from "components/Icons/CommentTiny"; 20 14 import { InteractionPreview } from "components/InteractionsPreview"; 21 15 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 22 16 import { LeafletOptions } from "app/(home-pages)/home/LeafletList/LeafletOptions"; ··· 27 21 showPageBackground: boolean; 28 22 }) { 29 23 let { data } = usePublicationData(); 30 - let params = useParams(); 31 - let { publication } = data!; 32 - let pubRecord = publication?.record as PubLeafletPublication.Record; 24 + let { publication, documents } = data || {}; 25 + const pubRecord = useNormalizedPublicationRecord(); 33 26 34 27 if (!publication) return null; 35 - if (publication.documents_in_publications.length === 0) 28 + if (!documents || documents.length === 0) 36 29 return ( 37 30 <div className="italic text-tertiary w-full container text-center place-items-center flex flex-col gap-3 p-3"> 38 31 Nothing's been published yet... 39 32 </div> 40 33 ); 34 + 35 + // Sort by publishedAt (most recent first) 36 + const sortedDocuments = [...documents].sort((a, b) => { 37 + const aDate = a.record.publishedAt 38 + ? new Date(a.record.publishedAt) 39 + : new Date(0); 40 + const bDate = b.record.publishedAt 41 + ? new Date(b.record.publishedAt) 42 + : new Date(0); 43 + return bDate.getTime() - aDate.getTime(); 44 + }); 45 + 41 46 return ( 42 47 <div className="publishedList w-full flex flex-col gap-2 pb-4"> 43 - {publication.documents_in_publications 44 - .sort((a, b) => { 45 - let aRecord = a.documents?.data! as PubLeafletDocument.Record; 46 - let bRecord = b.documents?.data! as PubLeafletDocument.Record; 47 - const aDate = aRecord.publishedAt 48 - ? new Date(aRecord.publishedAt) 49 - : new Date(0); 50 - const bDate = bRecord.publishedAt 51 - ? new Date(bRecord.publishedAt) 52 - : new Date(0); 53 - return bDate.getTime() - aDate.getTime(); // Sort by most recent first 54 - }) 55 - .map((doc) => { 56 - if (!doc.documents) return null; 57 - let leaflet = publication.leaflets_in_publications.find( 58 - (l) => doc.documents && l.doc === doc.documents.uri, 59 - ); 60 - let uri = new AtUri(doc.documents.uri); 61 - let postRecord = doc.documents.data as PubLeafletDocument.Record; 62 - let quotes = doc.documents.document_mentions_in_bsky[0]?.count || 0; 63 - let comments = doc.documents.comments_on_documents[0]?.count || 0; 64 - let tags = (postRecord?.tags as string[] | undefined) || []; 65 - 66 - let postLink = data?.publication 67 - ? `${getPublicationURL(data?.publication)}/${new AtUri(doc.documents.uri).rkey}` 68 - : ""; 48 + {sortedDocuments.map((doc) => ( 49 + <PublishedPostItem 50 + key={doc.uri} 51 + doc={doc} 52 + publication={publication} 53 + pubRecord={pubRecord} 54 + showPageBackground={props.showPageBackground} 55 + /> 56 + ))} 57 + </div> 58 + ); 59 + } 69 60 70 - return ( 71 - <Fragment key={doc.documents?.uri}> 72 - <div className="flex gap-2 w-full "> 73 - <div 74 - className={`publishedPost grow flex flex-col hover:no-underline! rounded-lg border ${props.showPageBackground ? "border-border-light py-1 px-2" : "border-transparent px-1"}`} 75 - style={{ 76 - backgroundColor: props.showPageBackground 77 - ? "rgba(var(--bg-page), var(--bg-page-alpha))" 78 - : "transparent", 79 - }} 80 - > 81 - <div className="flex justify-between gap-2"> 82 - <a 83 - className="hover:no-underline!" 84 - target="_blank" 85 - href={`${getPublicationURL(publication)}/${uri.rkey}`} 86 - > 87 - <h3 className="text-primary grow leading-snug"> 88 - {postRecord.title} 89 - </h3> 90 - </a> 91 - <div className="flex justify-start align-top flex-row gap-1"> 92 - {leaflet && leaflet.permission_tokens && ( 93 - <> 94 - <SpeedyLink 95 - className="pt-[6px]" 96 - href={`/${leaflet.leaflet}`} 97 - > 98 - <EditTiny /> 99 - </SpeedyLink> 61 + function PublishedPostItem(props: { 62 + doc: PublishedDocument; 63 + publication: NonNullable<NonNullable<ReturnType<typeof usePublicationData>["data"]>["publication"]>; 64 + pubRecord: ReturnType<typeof useNormalizedPublicationRecord>; 65 + showPageBackground: boolean; 66 + }) { 67 + const { doc, publication, pubRecord, showPageBackground } = props; 68 + const uri = new AtUri(doc.uri); 69 + const leaflet = publication.leaflets_in_publications.find( 70 + (l) => l.doc === doc.uri, 71 + ); 100 72 101 - <StaticLeafletDataContext 102 - value={{ 103 - ...leaflet.permission_tokens, 104 - leaflets_in_publications: [ 105 - { 106 - ...leaflet, 107 - publications: publication, 108 - documents: doc.documents 109 - ? { 110 - uri: doc.documents.uri, 111 - indexed_at: doc.documents.indexed_at, 112 - data: doc.documents.data, 113 - } 114 - : null, 115 - }, 116 - ], 117 - leaflets_to_documents: [], 118 - blocked_by_admin: null, 119 - custom_domain_routes: [], 120 - }} 121 - > 122 - <LeafletOptions loggedIn={true} /> 123 - </StaticLeafletDataContext> 124 - </> 125 - )} 126 - </div> 127 - </div> 73 + return ( 74 + <Fragment> 75 + <div className="flex gap-2 w-full "> 76 + <div 77 + className={`publishedPost grow flex flex-col hover:no-underline! rounded-lg border ${showPageBackground ? "border-border-light py-1 px-2" : "border-transparent px-1"}`} 78 + style={{ 79 + backgroundColor: showPageBackground 80 + ? "rgba(var(--bg-page), var(--bg-page-alpha))" 81 + : "transparent", 82 + }} 83 + > 84 + <div className="flex justify-between gap-2"> 85 + <a 86 + className="hover:no-underline!" 87 + target="_blank" 88 + href={`${getPublicationURL(publication)}/${uri.rkey}`} 89 + > 90 + <h3 className="text-primary grow leading-snug"> 91 + {doc.record.title} 92 + </h3> 93 + </a> 94 + <div className="flex justify-start align-top flex-row gap-1"> 95 + {leaflet && leaflet.permission_tokens && ( 96 + <> 97 + <SpeedyLink 98 + className="pt-[6px]" 99 + href={`/${leaflet.leaflet}`} 100 + > 101 + <EditTiny /> 102 + </SpeedyLink> 128 103 129 - {postRecord.description ? ( 130 - <p className="italic text-secondary"> 131 - {postRecord.description} 132 - </p> 133 - ) : null} 134 - <div className="text-sm text-tertiary flex gap-3 justify-between sm:justify-start items-center pt-3"> 135 - {postRecord.publishedAt ? ( 136 - <PublishedDate dateString={postRecord.publishedAt} /> 137 - ) : null} 138 - <InteractionPreview 139 - quotesCount={quotes} 140 - commentsCount={comments} 141 - tags={tags} 142 - showComments={ 143 - pubRecord?.preferences?.showComments !== false 144 - } 145 - showMentions={ 146 - pubRecord?.preferences?.showMentions !== false 147 - } 148 - postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 149 - /> 150 - </div> 151 - </div> 152 - </div> 153 - {!props.showPageBackground && ( 154 - <hr className="last:hidden border-border-light" /> 104 + <StaticLeafletDataContext 105 + value={{ 106 + ...leaflet.permission_tokens, 107 + leaflets_in_publications: [ 108 + { 109 + ...leaflet, 110 + publications: publication, 111 + documents: { 112 + uri: doc.uri, 113 + indexed_at: doc.indexed_at, 114 + data: doc.data, 115 + }, 116 + }, 117 + ], 118 + leaflets_to_documents: [], 119 + blocked_by_admin: null, 120 + custom_domain_routes: [], 121 + }} 122 + > 123 + <LeafletOptions loggedIn={true} /> 124 + </StaticLeafletDataContext> 125 + </> 155 126 )} 156 - </Fragment> 157 - ); 158 - })} 159 - </div> 127 + </div> 128 + </div> 129 + 130 + {doc.record.description ? ( 131 + <p className="italic text-secondary"> 132 + {doc.record.description} 133 + </p> 134 + ) : null} 135 + <div className="text-sm text-tertiary flex gap-3 justify-between sm:justify-start items-center pt-3"> 136 + {doc.record.publishedAt ? ( 137 + <PublishedDate dateString={doc.record.publishedAt} /> 138 + ) : null} 139 + <InteractionPreview 140 + quotesCount={doc.mentionsCount} 141 + commentsCount={doc.commentsCount} 142 + tags={doc.record.tags || []} 143 + showComments={pubRecord?.preferences?.showComments !== false} 144 + showMentions={pubRecord?.preferences?.showMentions !== false} 145 + postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 146 + /> 147 + </div> 148 + </div> 149 + </div> 150 + {!showPageBackground && ( 151 + <hr className="last:hidden border-border-light" /> 152 + )} 153 + </Fragment> 160 154 ); 161 155 } 162 156
+12 -2
app/lish/[did]/[publication]/dashboard/deletePost.ts
··· 39 39 } 40 40 41 41 await Promise.all([ 42 + // Delete from both PDS collections (document exists in one or the other) 42 43 agent.pub.leaflet.document.delete({ 43 44 repo: credentialSession.did, 44 45 rkey: uri.rkey, 45 - }), 46 + }).catch(() => {}), 47 + agent.site.standard.document.delete({ 48 + repo: credentialSession.did, 49 + rkey: uri.rkey, 50 + }).catch(() => {}), 46 51 supabaseServerClient.from("documents").delete().eq("uri", document_uri), 47 52 supabaseServerClient 48 53 .from("leaflets_in_publications") ··· 83 88 } 84 89 85 90 await Promise.all([ 91 + // Delete from both PDS collections (document exists in one or the other) 86 92 agent.pub.leaflet.document.delete({ 87 93 repo: credentialSession.did, 88 94 rkey: uri.rkey, 89 - }), 95 + }).catch(() => {}), 96 + agent.site.standard.document.delete({ 97 + repo: credentialSession.did, 98 + rkey: uri.rkey, 99 + }).catch(() => {}), 90 100 supabaseServerClient.from("documents").delete().eq("uri", document_uri), 91 101 ]); 92 102 revalidatePath("/lish/[did]/[publication]/dashboard", "layout");
+3 -5
app/lish/[did]/[publication]/dashboard/page.tsx
··· 3 3 import { getIdentityData } from "actions/getIdentityData"; 4 4 import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; 5 5 import { PublicationSWRDataProvider } from "./PublicationSWRProvider"; 6 - import { PubLeafletPublication } from "lexicons/api"; 7 6 import { PublicationThemeProviderDashboard } from "components/ThemeManager/PublicationThemeProvider"; 8 7 import { AtUri } from "@atproto/syntax"; 9 8 import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 10 9 import PublicationDashboard from "./PublicationDashboard"; 11 - import Link from "next/link"; 10 + import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 12 11 13 12 export async function generateMetadata(props: { 14 13 params: Promise<{ publication: string; did: string }>; ··· 24 23 { supabase: supabaseServerClient }, 25 24 ); 26 25 let { publication } = publication_data; 27 - let record = 28 - (publication?.record as PubLeafletPublication.Record) || undefined; 26 + const record = normalizePublicationRecord(publication?.record); 29 27 if (!publication) return { title: "404 Publication" }; 30 28 return { title: record?.name || "Untitled Publication" }; 31 29 } ··· 56 54 { supabase: supabaseServerClient }, 57 55 ); 58 56 let { publication, leaflet_data } = publication_data; 59 - let record = publication?.record as PubLeafletPublication.Record | null; 57 + const record = normalizePublicationRecord(publication?.record); 60 58 61 59 if (!publication || identity.atp_did !== publication.identity_did || !record) 62 60 return <PubNotFound />;
+6 -4
app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx
··· 1 - import { PubLeafletPublication } from "lexicons/api"; 2 - import { usePublicationData } from "../PublicationSWRProvider"; 1 + import { 2 + usePublicationData, 3 + useNormalizedPublicationRecord, 4 + } from "../PublicationSWRProvider"; 3 5 import { PubSettingsHeader } from "./PublicationSettings"; 4 6 import { useState } from "react"; 5 7 import { Toggle } from "components/Toggle"; ··· 15 17 let { data } = usePublicationData(); 16 18 17 19 let { publication: pubData } = data || {}; 18 - let record = pubData?.record as PubLeafletPublication.Record; 20 + const record = useNormalizedPublicationRecord(); 19 21 20 22 let [showComments, setShowComments] = useState( 21 23 record?.preferences?.showComments === undefined ··· 37 39 return ( 38 40 <form 39 41 onSubmit={async (e) => { 40 - if (!pubData) return; 42 + if (!pubData || !record) return; 41 43 e.preventDefault(); 42 44 props.setLoading(true); 43 45 let data = await updatePublication({
+30 -31
app/lish/[did]/[publication]/generateFeed.ts
··· 1 1 import { AtUri } from "@atproto/syntax"; 2 2 import { Feed } from "feed"; 3 - import { 4 - PubLeafletDocument, 5 - PubLeafletPagesLinearDocument, 6 - PubLeafletPublication, 7 - } from "lexicons/api"; 3 + import { PubLeafletPagesLinearDocument } from "lexicons/api"; 8 4 import { createElement } from "react"; 9 5 import { StaticPostContent } from "./[rkey]/StaticPostContent"; 10 6 import { supabaseServerClient } from "supabase/serverClient"; 11 7 import { NextResponse } from "next/server"; 8 + import { 9 + normalizePublicationRecord, 10 + normalizeDocumentRecord, 11 + hasLeafletContent, 12 + } from "src/utils/normalizeRecords"; 13 + import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; 12 14 13 15 export async function generateFeed( 14 16 did: string, ··· 17 19 let renderToReadableStream = await import("react-dom/server").then( 18 20 (module) => module.renderToReadableStream, 19 21 ); 20 - let uri; 21 - if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(publication_name)) { 22 - uri = AtUri.make( 23 - did, 24 - "pub.leaflet.publication", 25 - publication_name, 26 - ).toString(); 27 - } 28 - let { data: publication } = await supabaseServerClient 22 + let { data: publications } = await supabaseServerClient 29 23 .from("publications") 30 24 .select( 31 25 `*, ··· 34 28 `, 35 29 ) 36 30 .eq("identity_did", did) 37 - .or(`name.eq."${publication_name}", uri.eq."${uri}"`) 38 - .single(); 31 + .or(publicationNameOrUriFilter(did, publication_name)) 32 + .order("uri", { ascending: false }) 33 + .limit(1); 34 + let publication = publications?.[0]; 39 35 40 - let pubRecord = publication?.record as PubLeafletPublication.Record; 36 + const pubRecord = normalizePublicationRecord(publication?.record); 41 37 if (!publication || !pubRecord) 42 38 return new NextResponse(null, { status: 404 }); 43 39 44 40 const feed = new Feed({ 45 41 title: pubRecord.name, 46 42 description: pubRecord.description, 47 - id: `https://${pubRecord.base_path}`, 48 - link: `https://${pubRecord.base_path}`, 43 + id: pubRecord.url, 44 + link: pubRecord.url, 49 45 language: "en", // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes 50 46 copyright: "", 51 47 feedLinks: { 52 - rss: `https://${pubRecord.base_path}/rss`, 53 - atom: `https://${pubRecord.base_path}/atom`, 54 - json: `https://${pubRecord.base_path}/json`, 48 + rss: `${pubRecord.url}/rss`, 49 + atom: `${pubRecord.url}/atom`, 50 + json: `${pubRecord.url}/json`, 55 51 }, 56 52 }); 57 53 58 54 await Promise.all( 59 55 publication.documents_in_publications.map(async (doc) => { 60 56 if (!doc.documents) return; 61 - let record = doc.documents?.data as PubLeafletDocument.Record; 62 - let uri = new AtUri(doc.documents?.uri); 63 - let rkey = uri.rkey; 57 + const record = normalizeDocumentRecord(doc.documents?.data, doc.documents?.uri); 58 + const uri = new AtUri(doc.documents?.uri); 59 + const rkey = uri.rkey; 64 60 if (!record) return; 65 - let firstPage = record.pages[0]; 61 + 66 62 let blocks: PubLeafletPagesLinearDocument.Block[] = []; 67 - if (PubLeafletPagesLinearDocument.isMain(firstPage)) { 68 - blocks = firstPage.blocks || []; 63 + if (hasLeafletContent(record) && record.content.pages[0]) { 64 + const firstPage = record.content.pages[0]; 65 + if (PubLeafletPagesLinearDocument.isMain(firstPage)) { 66 + blocks = firstPage.blocks || []; 67 + } 69 68 } 70 - let stream = await renderToReadableStream( 69 + const stream = await renderToReadableStream( 71 70 createElement(StaticPostContent, { blocks, did: uri.host }), 72 71 ); 73 72 const reader = stream.getReader(); ··· 85 84 title: record.title, 86 85 description: record.description, 87 86 date: record.publishedAt ? new Date(record.publishedAt) : new Date(), 88 - id: `https://${pubRecord.base_path}/${rkey}`, 89 - link: `https://${pubRecord.base_path}/${rkey}`, 87 + id: `${pubRecord.url}/${rkey}`, 88 + link: `${pubRecord.url}/${rkey}`, 90 89 content: chunks.join(""), 91 90 }); 92 91 }),
+9 -14
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 - import { PubLeafletPublication } from "lexicons/api"; 5 3 import { supabaseServerClient } from "supabase/serverClient"; 6 4 import sharp from "sharp"; 7 5 import { redirect } from "next/navigation"; 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 - let record = publication?.record as PubLeafletPublication.Record | null; 36 + const record = normalizePublicationRecord(publication?.record); 42 37 if (!record?.icon) return redirect("/icon.png"); 43 38 44 39 let identity = await idResolver.did.resolve(did);
+12 -18
app/lish/[did]/[publication]/layout.tsx
··· 1 - import { PubLeafletPublication } from "lexicons/api"; 2 1 import { supabaseServerClient } from "supabase/serverClient"; 3 2 import { Metadata } from "next"; 4 - import { AtUri } from "@atproto/syntax"; 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 - let pubRecord = publication?.record as PubLeafletPublication.Record; 38 + const pubRecord = normalizePublicationRecord(publication?.record); 45 39 46 40 return { 47 41 title: pubRecord?.name || "Untitled Publication", ··· 60 54 url: publication.uri, 61 55 }, 62 56 }, 63 - alternates: pubRecord?.base_path 57 + alternates: pubRecord?.url 64 58 ? { 65 59 types: { 66 - "application/rss+xml": `https://${pubRecord?.base_path}/rss`, 67 - "application/atom+xml": `https://${pubRecord?.base_path}/atom`, 68 - "application/json": `https://${pubRecord?.base_path}/json`, 60 + "application/rss+xml": `${pubRecord.url}/rss`, 61 + "application/atom+xml": `${pubRecord.url}/atom`, 62 + "application/json": `${pubRecord.url}/json`, 69 63 }, 70 64 } 71 65 : undefined,
+18 -23
app/lish/[did]/[publication]/page.tsx
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 2 import { AtUri } from "@atproto/syntax"; 3 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 4 - import Link from "next/link"; 5 3 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 6 4 import { BskyAgent } from "@atproto/api"; 5 + import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; 7 6 import { SubscribeWithBluesky } from "app/lish/Subscribe"; 8 7 import React from "react"; 9 8 import { ··· 12 11 } from "components/ThemeManager/PublicationThemeProvider"; 13 12 import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 14 13 import { SpeedyLink } from "components/SpeedyLink"; 15 - import { QuoteTiny } from "components/Icons/QuoteTiny"; 16 - import { CommentTiny } from "components/Icons/CommentTiny"; 17 14 import { InteractionPreview } from "components/InteractionsPreview"; 18 15 import { LocalizedDate } from "./LocalizedDate"; 19 16 import { PublicationHomeLayout } from "./PublicationHomeLayout"; 20 17 import { PublicationAuthor } from "./PublicationAuthor"; 21 18 import { Separator } from "components/Layout"; 19 + import { 20 + normalizePublicationRecord, 21 + normalizeDocumentRecord, 22 + } from "src/utils/normalizeRecords"; 22 23 23 24 export default async function Publication(props: { 24 25 params: Promise<{ publication: string; did: string }>; ··· 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 - let record = publication?.record as PubLeafletPublication.Record | null; 53 + const record = normalizePublicationRecord(publication?.record); 59 54 60 55 let showPageBackground = record?.theme?.showPageBackground; 61 56 ··· 112 107 {publication.documents_in_publications 113 108 .filter((d) => !!d?.documents) 114 109 .sort((a, b) => { 115 - let aRecord = a.documents?.data! as PubLeafletDocument.Record; 116 - let bRecord = b.documents?.data! as PubLeafletDocument.Record; 117 - const aDate = aRecord.publishedAt 110 + const aRecord = normalizeDocumentRecord(a.documents?.data); 111 + const bRecord = normalizeDocumentRecord(b.documents?.data); 112 + const aDate = aRecord?.publishedAt 118 113 ? new Date(aRecord.publishedAt) 119 114 : new Date(0); 120 - const bDate = bRecord.publishedAt 115 + const bDate = bRecord?.publishedAt 121 116 ? new Date(bRecord.publishedAt) 122 117 : new Date(0); 123 118 return bDate.getTime() - aDate.getTime(); // Sort by most recent first 124 119 }) 125 120 .map((doc) => { 126 121 if (!doc.documents) return null; 122 + const doc_record = normalizeDocumentRecord(doc.documents.data); 123 + if (!doc_record) return null; 127 124 let uri = new AtUri(doc.documents.uri); 128 - let doc_record = doc.documents 129 - .data as PubLeafletDocument.Record; 130 125 let quotes = 131 126 doc.documents.document_mentions_in_bsky[0].count || 0; 132 127 let comments = 133 128 record?.preferences?.showComments === false 134 129 ? 0 135 130 : doc.documents.comments_on_documents[0].count || 0; 136 - let tags = (doc_record?.tags as string[] | undefined) || []; 131 + let tags = doc_record.tags || []; 137 132 138 133 return ( 139 134 <React.Fragment key={doc.documents?.uri}>
+10 -7
app/lish/createPub/UpdatePubForm.tsx
··· 7 7 updatePublication, 8 8 updatePublicationBasePath, 9 9 } from "./updatePublication"; 10 - import { usePublicationData } from "../[did]/[publication]/dashboard/PublicationSWRProvider"; 11 - import { PubLeafletPublication } from "lexicons/api"; 10 + import { 11 + usePublicationData, 12 + useNormalizedPublicationRecord, 13 + } from "../[did]/[publication]/dashboard/PublicationSWRProvider"; 12 14 import useSWR, { mutate } from "swr"; 13 15 import { AddTiny } from "components/Icons/AddTiny"; 14 16 import { DotLoader } from "components/utils/DotLoader"; ··· 30 32 }) => { 31 33 let { data } = usePublicationData(); 32 34 let { publication: pubData } = data || {}; 33 - let record = pubData?.record as PubLeafletPublication.Record; 35 + let record = useNormalizedPublicationRecord(); 34 36 let [formState, setFormState] = useState<"normal" | "loading">("normal"); 35 37 36 38 let [nameValue, setNameValue] = useState(record?.name || ""); ··· 60 62 let [iconPreview, setIconPreview] = useState<string | null>(null); 61 63 let fileInputRef = useRef<HTMLInputElement>(null); 62 64 useEffect(() => { 63 - if (!pubData || !pubData.record) return; 65 + if (!pubData || !pubData.record || !record) return; 64 66 setNameValue(record.name); 65 67 setDescriptionValue(record.description || ""); 66 68 if (record.icon) 67 69 setIconPreview( 68 70 `/api/atproto_images?did=${pubData.identity_did}&cid=${(record.icon.ref as unknown as { $link: string })["$link"]}`, 69 71 ); 70 - }, [pubData]); 72 + }, [pubData, record]); 71 73 let toast = useToaster(); 72 74 73 75 return ( ··· 202 204 export function CustomDomainForm() { 203 205 let { data } = usePublicationData(); 204 206 let { publication: pubData } = data || {}; 207 + let record = useNormalizedPublicationRecord(); 205 208 if (!pubData) return null; 206 - let record = pubData?.record as PubLeafletPublication.Record; 209 + if (!record) return null; 207 210 let [state, setState] = useState< 208 211 | { type: "default" } 209 212 | { type: "addDomain" } ··· 243 246 <Domain 244 247 domain={d.domain} 245 248 publication_uri={pubData.uri} 246 - base_path={record.base_path || ""} 249 + base_path={record.url.replace(/^https?:\/\//, "")} 247 250 setDomain={(v) => { 248 251 setState({ 249 252 type: "domainSettings",
+54 -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"; 19 + import { PubThemeDefaultsRGB } from "components/ThemeManager/themeDefaults"; 15 20 16 21 const VERCEL_TOKEN = process.env.VERCEL_TOKEN; 17 22 const vercel = new Vercel({ ··· 64 69 let agent = new AtpBaseClient( 65 70 credentialSession.fetchHandler.bind(credentialSession), 66 71 ); 67 - let record: Un$Typed<PubLeafletPublication.Record> = { 68 - name, 69 - base_path: domain, 70 - preferences, 71 - }; 72 + 73 + // Use site.standard.publication for new publications 74 + const publicationType = getPublicationType(); 75 + const url = `https://${domain}`; 72 76 73 - if (description) { 74 - record.description = description; 75 - } 77 + // Build record based on publication type 78 + let record: SiteStandardPublication.Record | PubLeafletPublication.Record; 79 + let iconBlob: Awaited<ReturnType<typeof agent.com.atproto.repo.uploadBlob>>["data"]["blob"] | undefined; 76 80 77 81 // Upload the icon if provided 78 82 if (iconFile && iconFile.size > 0) { ··· 81 85 new Uint8Array(buffer), 82 86 { encoding: iconFile.type }, 83 87 ); 88 + iconBlob = uploadResult.data.blob; 89 + } 84 90 85 - if (uploadResult.data.blob) { 86 - record.icon = uploadResult.data.blob; 87 - } 91 + if (publicationType === "site.standard.publication") { 92 + record = { 93 + $type: "site.standard.publication", 94 + name, 95 + url, 96 + ...(description && { description }), 97 + ...(iconBlob && { icon: iconBlob }), 98 + basicTheme: { 99 + $type: "site.standard.theme.basic", 100 + background: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.background }, 101 + foreground: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.foreground }, 102 + accent: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.accent }, 103 + accentForeground: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.accentForeground }, 104 + }, 105 + preferences: { 106 + showInDiscover: preferences.showInDiscover, 107 + showComments: preferences.showComments, 108 + showMentions: preferences.showMentions, 109 + showPrevNext: preferences.showPrevNext, 110 + }, 111 + } satisfies SiteStandardPublication.Record; 112 + } else { 113 + record = { 114 + $type: "pub.leaflet.publication", 115 + name, 116 + base_path: domain, 117 + ...(description && { description }), 118 + ...(iconBlob && { icon: iconBlob }), 119 + preferences, 120 + } satisfies PubLeafletPublication.Record; 88 121 } 89 122 90 - let result = await agent.pub.leaflet.publication.create( 91 - { repo: credentialSession.did!, rkey: TID.nextStr(), validate: false }, 123 + let { data: result } = await agent.com.atproto.repo.putRecord({ 124 + repo: credentialSession.did!, 125 + rkey: TID.nextStr(), 126 + collection: publicationType, 92 127 record, 93 - ); 128 + validate: false, 129 + }); 94 130 95 131 //optimistically write to our db! 96 132 let { data: publication } = await supabaseServerClient ··· 98 134 .upsert({ 99 135 uri: result.uri, 100 136 identity_did: credentialSession.did!, 101 - name: record.name, 102 - record: { 103 - ...record, 104 - $type: "pub.leaflet.publication", 105 - } as unknown as Json, 137 + name, 138 + record: record as unknown as Json, 106 139 }) 107 140 .select() 108 141 .single();
+34 -9
app/lish/createPub/getPublicationURL.ts
··· 2 2 import { PubLeafletPublication } from "lexicons/api"; 3 3 import { isProductionDomain } from "src/utils/isProductionDeployment"; 4 4 import { Json } from "supabase/database.types"; 5 + import { 6 + normalizePublicationRecord, 7 + isLeafletPublication, 8 + type NormalizedPublication, 9 + } from "src/utils/normalizeRecords"; 5 10 6 - export function getPublicationURL(pub: { uri: string; record: Json }) { 7 - let record = pub.record as PubLeafletPublication.Record; 8 - if (isProductionDomain() && record?.base_path) 9 - return `https://${record.base_path}`; 10 - else return getBasePublicationURL(pub); 11 + type PublicationInput = 12 + | { uri: string; record: Json | NormalizedPublication | null } 13 + | { uri: string; record: unknown }; 14 + 15 + /** 16 + * Gets the public URL for a publication. 17 + * Works with both pub.leaflet.publication and site.standard.publication records. 18 + */ 19 + export function getPublicationURL(pub: PublicationInput): string { 20 + const normalized = normalizePublicationRecord(pub.record); 21 + 22 + // If we have a normalized record with a URL (site.standard format), use it 23 + if (normalized?.url && isProductionDomain()) { 24 + return normalized.url; 25 + } 26 + 27 + // Fall back to checking raw record for legacy base_path 28 + if (isLeafletPublication(pub.record) && pub.record.base_path && isProductionDomain()) { 29 + return `https://${pub.record.base_path}`; 30 + } 31 + 32 + return getBasePublicationURL(pub); 11 33 } 12 34 13 - export function getBasePublicationURL(pub: { uri: string; record: Json }) { 14 - let record = pub.record as PubLeafletPublication.Record; 15 - let aturi = new AtUri(pub.uri); 16 - return `/lish/${aturi.host}/${encodeURIComponent(aturi.rkey || record?.name)}`; 35 + export function getBasePublicationURL(pub: PublicationInput): string { 36 + const normalized = normalizePublicationRecord(pub.record); 37 + const aturi = new AtUri(pub.uri); 38 + 39 + // Use normalized name if available, fall back to rkey 40 + const name = normalized?.name || aturi.rkey; 41 + return `/lish/${aturi.host}/${encodeURIComponent(name || "")}`; 17 42 }
+203 -155
app/lish/createPub/updatePublication.ts
··· 1 1 "use server"; 2 - import { TID } from "@atproto/common"; 3 2 import { 4 3 AtpBaseClient, 5 4 PubLeafletPublication, 6 5 PubLeafletThemeColor, 6 + SiteStandardPublication, 7 7 } from "lexicons/api"; 8 8 import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; 9 9 import { getIdentityData } from "actions/getIdentityData"; ··· 11 11 import { Json } from "supabase/database.types"; 12 12 import { AtUri } from "@atproto/syntax"; 13 13 import { $Typed } from "@atproto/api"; 14 + import { 15 + normalizePublicationRecord, 16 + type NormalizedPublication, 17 + } from "src/utils/normalizeRecords"; 18 + import { getPublicationType } from "src/utils/collectionHelpers"; 14 19 15 20 type UpdatePublicationResult = 16 21 | { success: true; publication: any } 17 22 | { success: false; error?: OAuthSessionError }; 18 23 19 - export async function updatePublication({ 20 - uri, 21 - name, 22 - description, 23 - iconFile, 24 - preferences, 25 - }: { 26 - uri: string; 27 - name: string; 28 - description?: string; 29 - iconFile?: File | null; 30 - preferences?: Omit<PubLeafletPublication.Preferences, "$type">; 31 - }): Promise<UpdatePublicationResult> { 32 - let identity = await getIdentityData(); 24 + type PublicationType = "pub.leaflet.publication" | "site.standard.publication"; 25 + 26 + type RecordBuilder = (args: { 27 + normalizedPub: NormalizedPublication | null; 28 + existingBasePath: string | undefined; 29 + publicationType: PublicationType; 30 + agent: AtpBaseClient; 31 + }) => Promise<PubLeafletPublication.Record | SiteStandardPublication.Record>; 32 + 33 + /** 34 + * Shared helper for publication updates. Handles: 35 + * - Authentication and session restoration 36 + * - Fetching existing publication from database 37 + * - Normalizing the existing record 38 + * - Calling the record builder to create the updated record 39 + * - Writing to PDS via putRecord 40 + * - Writing to database 41 + */ 42 + async function withPublicationUpdate( 43 + uri: string, 44 + recordBuilder: RecordBuilder, 45 + ): Promise<UpdatePublicationResult> { 46 + // Get identity and validate authentication 47 + const identity = await getIdentityData(); 33 48 if (!identity || !identity.atp_did) { 34 49 return { 35 50 success: false, ··· 41 56 }; 42 57 } 43 58 59 + // Restore OAuth session 44 60 const sessionResult = await restoreOAuthSession(identity.atp_did); 45 61 if (!sessionResult.ok) { 46 62 return { success: false, error: sessionResult.error }; 47 63 } 48 - let credentialSession = sessionResult.value; 49 - let agent = new AtpBaseClient( 64 + const credentialSession = sessionResult.value; 65 + const agent = new AtpBaseClient( 50 66 credentialSession.fetchHandler.bind(credentialSession), 51 67 ); 52 - let { data: existingPub } = await supabaseServerClient 68 + 69 + // Fetch existing publication from database 70 + const { data: existingPub } = await supabaseServerClient 53 71 .from("publications") 54 72 .select("*") 55 73 .eq("uri", uri) ··· 57 75 if (!existingPub || existingPub.identity_did !== identity.atp_did) { 58 76 return { success: false }; 59 77 } 60 - let aturi = new AtUri(existingPub.uri); 61 78 62 - let record: PubLeafletPublication.Record = { 63 - $type: "pub.leaflet.publication", 64 - ...(existingPub.record as object), 65 - name, 66 - }; 67 - if (preferences) { 68 - record.preferences = preferences; 69 - } 79 + const aturi = new AtUri(existingPub.uri); 80 + const publicationType = getPublicationType(aturi.collection) as PublicationType; 70 81 71 - if (description !== undefined) { 72 - record.description = description; 73 - } 82 + // Normalize existing record 83 + const normalizedPub = normalizePublicationRecord(existingPub.record); 84 + const existingBasePath = normalizedPub?.url 85 + ? normalizedPub.url.replace(/^https?:\/\//, "") 86 + : undefined; 74 87 75 - // Upload the icon if provided How do I tell if there isn't a new one? 76 - if (iconFile && iconFile.size > 0) { 77 - const buffer = await iconFile.arrayBuffer(); 78 - const uploadResult = await agent.com.atproto.repo.uploadBlob( 79 - new Uint8Array(buffer), 80 - { encoding: iconFile.type }, 81 - ); 88 + // Build the updated record 89 + const record = await recordBuilder({ 90 + normalizedPub, 91 + existingBasePath, 92 + publicationType, 93 + agent, 94 + }); 82 95 83 - if (uploadResult.data.blob) { 84 - record.icon = uploadResult.data.blob; 85 - } 86 - } 87 - 88 - let result = await agent.com.atproto.repo.putRecord({ 96 + // Write to PDS 97 + await agent.com.atproto.repo.putRecord({ 89 98 repo: credentialSession.did!, 90 99 rkey: aturi.rkey, 91 100 record, 92 - collection: record.$type, 101 + collection: publicationType, 93 102 validate: false, 94 103 }); 95 104 96 - //optimistically write to our db! 97 - let { data: publication, error } = await supabaseServerClient 105 + // Optimistically write to database 106 + const { data: publication } = await supabaseServerClient 98 107 .from("publications") 99 108 .update({ 100 109 name: record.name, ··· 103 112 .eq("uri", uri) 104 113 .select() 105 114 .single(); 115 + 106 116 return { success: true, publication }; 107 117 } 108 118 119 + /** Fields that can be overridden when building a record */ 120 + interface RecordOverrides { 121 + name?: string; 122 + description?: string; 123 + icon?: any; 124 + theme?: any; 125 + basicTheme?: NormalizedPublication["basicTheme"]; 126 + preferences?: NormalizedPublication["preferences"]; 127 + basePath?: string; 128 + } 129 + 130 + /** Merges override with existing value, respecting explicit undefined */ 131 + function resolveField<T>(override: T | undefined, existing: T | undefined, hasOverride: boolean): T | undefined { 132 + return hasOverride ? override : existing; 133 + } 134 + 135 + /** 136 + * Builds a pub.leaflet.publication record. 137 + * Uses base_path for the URL path component. 138 + */ 139 + function buildLeafletRecord( 140 + normalizedPub: NormalizedPublication | null, 141 + existingBasePath: string | undefined, 142 + overrides: RecordOverrides, 143 + ): PubLeafletPublication.Record { 144 + const preferences = overrides.preferences ?? normalizedPub?.preferences; 145 + 146 + return { 147 + $type: "pub.leaflet.publication", 148 + name: overrides.name ?? normalizedPub?.name ?? "", 149 + description: resolveField(overrides.description, normalizedPub?.description, "description" in overrides), 150 + icon: resolveField(overrides.icon, normalizedPub?.icon, "icon" in overrides), 151 + theme: resolveField(overrides.theme, normalizedPub?.theme, "theme" in overrides), 152 + base_path: overrides.basePath ?? existingBasePath, 153 + preferences: preferences ? { 154 + $type: "pub.leaflet.publication#preferences", 155 + showInDiscover: preferences.showInDiscover, 156 + showComments: preferences.showComments, 157 + showMentions: preferences.showMentions, 158 + showPrevNext: preferences.showPrevNext, 159 + } : undefined, 160 + }; 161 + } 162 + 163 + /** 164 + * Builds a site.standard.publication record. 165 + * Uses url for the full URL. Also supports basicTheme. 166 + */ 167 + function buildStandardRecord( 168 + normalizedPub: NormalizedPublication | null, 169 + existingBasePath: string | undefined, 170 + overrides: RecordOverrides, 171 + ): SiteStandardPublication.Record { 172 + const preferences = overrides.preferences ?? normalizedPub?.preferences; 173 + const basePath = overrides.basePath ?? existingBasePath; 174 + 175 + return { 176 + $type: "site.standard.publication", 177 + name: overrides.name ?? normalizedPub?.name ?? "", 178 + description: resolveField(overrides.description, normalizedPub?.description, "description" in overrides), 179 + icon: resolveField(overrides.icon, normalizedPub?.icon, "icon" in overrides), 180 + theme: resolveField(overrides.theme, normalizedPub?.theme, "theme" in overrides), 181 + basicTheme: resolveField(overrides.basicTheme, normalizedPub?.basicTheme, "basicTheme" in overrides), 182 + url: basePath ? `https://${basePath}` : normalizedPub?.url || "", 183 + preferences: preferences ? { 184 + showInDiscover: preferences.showInDiscover, 185 + showComments: preferences.showComments, 186 + showMentions: preferences.showMentions, 187 + showPrevNext: preferences.showPrevNext, 188 + } : undefined, 189 + }; 190 + } 191 + 192 + /** 193 + * Builds a record for the appropriate publication type. 194 + */ 195 + function buildRecord( 196 + normalizedPub: NormalizedPublication | null, 197 + existingBasePath: string | undefined, 198 + publicationType: PublicationType, 199 + overrides: RecordOverrides, 200 + ): PubLeafletPublication.Record | SiteStandardPublication.Record { 201 + if (publicationType === "pub.leaflet.publication") { 202 + return buildLeafletRecord(normalizedPub, existingBasePath, overrides); 203 + } 204 + return buildStandardRecord(normalizedPub, existingBasePath, overrides); 205 + } 206 + 207 + export async function updatePublication({ 208 + uri, 209 + name, 210 + description, 211 + iconFile, 212 + preferences, 213 + }: { 214 + uri: string; 215 + name: string; 216 + description?: string; 217 + iconFile?: File | null; 218 + preferences?: Omit<PubLeafletPublication.Preferences, "$type">; 219 + }): Promise<UpdatePublicationResult> { 220 + return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType, agent }) => { 221 + // Upload icon if provided 222 + let iconBlob = normalizedPub?.icon; 223 + if (iconFile && iconFile.size > 0) { 224 + const buffer = await iconFile.arrayBuffer(); 225 + const uploadResult = await agent.com.atproto.repo.uploadBlob( 226 + new Uint8Array(buffer), 227 + { encoding: iconFile.type }, 228 + ); 229 + if (uploadResult.data.blob) { 230 + iconBlob = uploadResult.data.blob; 231 + } 232 + } 233 + 234 + return buildRecord(normalizedPub, existingBasePath, publicationType, { 235 + name, 236 + description, 237 + icon: iconBlob, 238 + preferences, 239 + }); 240 + }); 241 + } 242 + 109 243 export async function updatePublicationBasePath({ 110 244 uri, 111 245 base_path, ··· 113 247 uri: string; 114 248 base_path: string; 115 249 }): Promise<UpdatePublicationResult> { 116 - let identity = await getIdentityData(); 117 - if (!identity || !identity.atp_did) { 118 - return { 119 - success: false, 120 - error: { 121 - type: "oauth_session_expired", 122 - message: "Not authenticated", 123 - did: "", 124 - }, 125 - }; 126 - } 127 - 128 - const sessionResult = await restoreOAuthSession(identity.atp_did); 129 - if (!sessionResult.ok) { 130 - return { success: false, error: sessionResult.error }; 131 - } 132 - let credentialSession = sessionResult.value; 133 - let agent = new AtpBaseClient( 134 - credentialSession.fetchHandler.bind(credentialSession), 135 - ); 136 - let { data: existingPub } = await supabaseServerClient 137 - .from("publications") 138 - .select("*") 139 - .eq("uri", uri) 140 - .single(); 141 - if (!existingPub || existingPub.identity_did !== identity.atp_did) { 142 - return { success: false }; 143 - } 144 - let aturi = new AtUri(existingPub.uri); 145 - 146 - let record: PubLeafletPublication.Record = { 147 - ...(existingPub.record as PubLeafletPublication.Record), 148 - base_path, 149 - }; 150 - 151 - let result = await agent.com.atproto.repo.putRecord({ 152 - repo: credentialSession.did!, 153 - rkey: aturi.rkey, 154 - record, 155 - collection: record.$type, 156 - validate: false, 250 + return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType }) => { 251 + return buildRecord(normalizedPub, existingBasePath, publicationType, { 252 + basePath: base_path, 253 + }); 157 254 }); 158 - 159 - //optimistically write to our db! 160 - let { data: publication, error } = await supabaseServerClient 161 - .from("publications") 162 - .update({ 163 - name: record.name, 164 - record: record as Json, 165 - }) 166 - .eq("uri", uri) 167 - .select() 168 - .single(); 169 - return { success: true, publication }; 170 255 } 171 256 172 257 type Color = 173 258 | $Typed<PubLeafletThemeColor.Rgb, "pub.leaflet.theme.color#rgb"> 174 259 | $Typed<PubLeafletThemeColor.Rgba, "pub.leaflet.theme.color#rgba">; 260 + 175 261 export async function updatePublicationTheme({ 176 262 uri, 177 263 theme, ··· 189 275 accentText: Color; 190 276 }; 191 277 }): Promise<UpdatePublicationResult> { 192 - let identity = await getIdentityData(); 193 - if (!identity || !identity.atp_did) { 194 - return { 195 - success: false, 196 - error: { 197 - type: "oauth_session_expired", 198 - message: "Not authenticated", 199 - did: "", 200 - }, 201 - }; 202 - } 203 - 204 - const sessionResult = await restoreOAuthSession(identity.atp_did); 205 - if (!sessionResult.ok) { 206 - return { success: false, error: sessionResult.error }; 207 - } 208 - let credentialSession = sessionResult.value; 209 - let agent = new AtpBaseClient( 210 - credentialSession.fetchHandler.bind(credentialSession), 211 - ); 212 - let { data: existingPub } = await supabaseServerClient 213 - .from("publications") 214 - .select("*") 215 - .eq("uri", uri) 216 - .single(); 217 - if (!existingPub || existingPub.identity_did !== identity.atp_did) { 218 - return { success: false }; 219 - } 220 - let aturi = new AtUri(existingPub.uri); 221 - 222 - let oldRecord = existingPub.record as PubLeafletPublication.Record; 223 - let record: PubLeafletPublication.Record = { 224 - ...oldRecord, 225 - $type: "pub.leaflet.publication", 226 - theme: { 278 + return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType, agent }) => { 279 + // Build theme object 280 + const themeData = { 227 281 backgroundImage: theme.backgroundImage 228 282 ? { 229 283 $type: "pub.leaflet.theme.backgroundImage", ··· 238 292 } 239 293 : theme.backgroundImage === null 240 294 ? undefined 241 - : oldRecord.theme?.backgroundImage, 295 + : normalizedPub?.theme?.backgroundImage, 242 296 backgroundColor: theme.backgroundColor 243 297 ? { 244 298 ...theme.backgroundColor, ··· 258 312 accentText: { 259 313 ...theme.accentText, 260 314 }, 261 - }, 262 - }; 315 + }; 263 316 264 - let result = await agent.com.atproto.repo.putRecord({ 265 - repo: credentialSession.did!, 266 - rkey: aturi.rkey, 267 - record, 268 - collection: record.$type, 269 - validate: false, 270 - }); 317 + // Derive basicTheme from the theme colors for site.standard.publication 318 + const basicTheme: NormalizedPublication["basicTheme"] = { 319 + $type: "site.standard.theme.basic", 320 + background: { $type: "site.standard.theme.color#rgb", r: theme.backgroundColor.r, g: theme.backgroundColor.g, b: theme.backgroundColor.b }, 321 + foreground: { $type: "site.standard.theme.color#rgb", r: theme.primary.r, g: theme.primary.g, b: theme.primary.b }, 322 + accent: { $type: "site.standard.theme.color#rgb", r: theme.accentBackground.r, g: theme.accentBackground.g, b: theme.accentBackground.b }, 323 + accentForeground: { $type: "site.standard.theme.color#rgb", r: theme.accentText.r, g: theme.accentText.g, b: theme.accentText.b }, 324 + }; 271 325 272 - //optimistically write to our db! 273 - let { data: publication, error } = await supabaseServerClient 274 - .from("publications") 275 - .update({ 276 - name: record.name, 277 - record: record as Json, 278 - }) 279 - .eq("uri", uri) 280 - .select() 281 - .single(); 282 - return { success: true, publication }; 326 + return buildRecord(normalizedPub, existingBasePath, publicationType, { 327 + theme: themeData, 328 + basicTheme, 329 + }); 330 + }); 283 331 }
+7 -4
app/lish/feeds/[...path]/route.ts
··· 2 2 import { DidResolver } from "@atproto/identity"; 3 3 import { parseReqNsid, verifyJwt } from "@atproto/xrpc-server"; 4 4 import { supabaseServerClient } from "supabase/serverClient"; 5 - import { PubLeafletDocument } from "lexicons/api"; 5 + import { 6 + normalizeDocumentRecord, 7 + type NormalizedDocument, 8 + } from "src/utils/normalizeRecords"; 6 9 7 10 const serviceDid = "did:web:leaflet.pub:lish:feeds"; 8 11 export async function GET( ··· 34 37 let posts = pub.publications?.documents_in_publications || []; 35 38 return posts.flatMap((p) => { 36 39 if (!p.documents?.data) return []; 37 - let record = p.documents.data as PubLeafletDocument.Record; 38 - if (!record.postRef) return []; 39 - return { post: record.postRef.uri }; 40 + const normalizedDoc = normalizeDocumentRecord(p.documents.data, p.documents.uri); 41 + if (!normalizedDoc?.bskyPostRef) return []; 42 + return { post: normalizedDoc.bskyPostRef.uri }; 40 43 }); 41 44 }), 42 45 ],
+9 -5
app/lish/subscribeToPublication.ts
··· 48 48 let agent = new AtpBaseClient( 49 49 credentialSession.fetchHandler.bind(credentialSession), 50 50 ); 51 - let record = await agent.pub.leaflet.graph.subscription.create( 51 + let record = await agent.site.standard.graph.subscription.create( 52 52 { repo: credentialSession.did!, rkey: TID.nextStr() }, 53 53 { 54 54 publication, ··· 140 140 .eq("publication", publication) 141 141 .single(); 142 142 if (!existingSubscription) return { success: true }; 143 - await agent.pub.leaflet.graph.subscription.delete({ 144 - repo: credentialSession.did!, 145 - rkey: new AtUri(existingSubscription.uri).rkey, 146 - }); 143 + 144 + // Delete from both collections (old and new schema) - one or both may exist 145 + let rkey = new AtUri(existingSubscription.uri).rkey; 146 + await Promise.all([ 147 + agent.pub.leaflet.graph.subscription.delete({ repo: credentialSession.did!, rkey }).catch(() => {}), 148 + agent.site.standard.graph.subscription.delete({ repo: credentialSession.did!, rkey }).catch(() => {}), 149 + ]); 150 + 147 151 await supabaseServerClient 148 152 .from("publication_subscriptions") 149 153 .delete()
+26 -24
app/lish/uri/[uri]/route.ts
··· 1 1 import { NextRequest, NextResponse } from "next/server"; 2 2 import { AtUri } from "@atproto/api"; 3 3 import { supabaseServerClient } from "supabase/serverClient"; 4 - import { PubLeafletPublication } from "lexicons/api"; 4 + import { 5 + normalizePublicationRecord, 6 + type NormalizedPublication, 7 + } from "src/utils/normalizeRecords"; 8 + import { 9 + isDocumentCollection, 10 + isPublicationCollection, 11 + } from "src/utils/collectionHelpers"; 5 12 6 13 /** 7 14 * Redirect route for AT URIs (publications and documents) ··· 16 23 const atUriString = decodeURIComponent(uriParam); 17 24 const uri = new AtUri(atUriString); 18 25 19 - if (uri.collection === "pub.leaflet.publication") { 26 + if (isPublicationCollection(uri.collection)) { 20 27 // Get the publication record to retrieve base_path 21 28 const { data: publication } = await supabaseServerClient 22 29 .from("publications") ··· 28 35 return new NextResponse("Publication not found", { status: 404 }); 29 36 } 30 37 31 - const record = publication.record as PubLeafletPublication.Record; 32 - const basePath = record.base_path; 33 - 34 - if (!basePath) { 35 - return new NextResponse("Publication has no base_path", { 38 + const normalizedPub = normalizePublicationRecord(publication.record); 39 + if (!normalizedPub?.url) { 40 + return new NextResponse("Publication has no url", { 36 41 status: 404, 37 42 }); 38 43 } 39 44 40 - // Redirect to the publication's hosted domain (temporary redirect since base_path can change) 41 - return NextResponse.redirect(basePath, 307); 42 - } else if (uri.collection === "pub.leaflet.document") { 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)) { 43 48 // Document link - need to find the publication it belongs to 44 49 const { data: docInPub } = await supabaseServerClient 45 50 .from("documents_in_publications") ··· 49 54 50 55 if (docInPub?.publication && docInPub.publications) { 51 56 // Document is in a publication - redirect to domain/rkey 52 - const record = docInPub.publications 53 - .record as PubLeafletPublication.Record; 54 - const basePath = record.base_path; 57 + const normalizedPub = normalizePublicationRecord( 58 + docInPub.publications.record, 59 + ); 55 60 56 - if (!basePath) { 57 - return new NextResponse("Publication has no base_path", { 61 + if (!normalizedPub?.url) { 62 + return new NextResponse("Publication has no url", { 58 63 status: 404, 59 64 }); 60 65 } 61 66 62 - // Ensure basePath ends without trailing slash 63 - const cleanBasePath = basePath.endsWith("/") 64 - ? basePath.slice(0, -1) 65 - : basePath; 67 + // Ensure url ends without trailing slash 68 + const cleanUrl = normalizedPub.url.endsWith("/") 69 + ? normalizedPub.url.slice(0, -1) 70 + : normalizedPub.url; 66 71 67 - // Redirect to the document on the publication's domain (temporary redirect since base_path can change) 68 - return NextResponse.redirect( 69 - `https://${cleanBasePath}/${uri.rkey}`, 70 - 307, 71 - ); 72 + // Redirect to the document on the publication's domain (temporary redirect since url can change) 73 + return NextResponse.redirect(`${cleanUrl}/${uri.rkey}`, 307); 72 74 } 73 75 74 76 // If not in a publication, check if it's a standalone document
+9 -8
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 - import { PubLeafletDocument } from "lexicons/api"; 6 3 import { jsonToLex } from "@atproto/lexicon"; 7 4 import { idResolver } from "app/(home-pages)/reader/idResolver"; 8 5 import { fetchAtprotoBlob } from "app/api/atproto_images/route"; 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 - let docRecord = jsonToLex(document.data) as PubLeafletDocument.Record; 39 - if (docRecord.coverImage) { 39 + const docRecord = normalizeDocumentRecord(jsonToLex(document.data)); 40 + if (docRecord?.coverImage) { 40 41 try { 41 42 // Get CID from the blob ref (handle both serialized and hydrated forms) 42 43 let cid =
+11 -15
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 - import { PubLeafletDocument } from "lexicons/api"; 5 2 import { Metadata } from "next"; 6 3 import { idResolver } from "app/(home-pages)/reader/idResolver"; 7 4 import { DocumentPageRenderer } from "app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer"; 8 5 import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 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 - let docRecord = document.data as PubLeafletDocument.Record; 36 - 37 - // For documents in publications, include publication name 38 - let publicationName = 39 - document.documents_in_publications[0]?.publications?.name; 36 + const docRecord = normalizeDocumentRecord(document.data); 37 + if (!docRecord) return { title: "404" }; 40 38 41 39 return { 42 40 icons: { ··· 45 43 url: document.uri, 46 44 }, 47 45 }, 48 - title: publicationName 49 - ? `${docRecord.title} - ${publicationName}` 50 - : docRecord.title, 46 + title: docRecord.title, 51 47 description: docRecord?.description || "", 52 48 }; 53 49 }
+98
appview/index.ts
··· 11 11 PubLeafletComment, 12 12 PubLeafletPollVote, 13 13 PubLeafletPollDefinition, 14 + SiteStandardDocument, 15 + SiteStandardPublication, 16 + SiteStandardGraphSubscription, 14 17 } from "lexicons/api"; 15 18 import { 16 19 AppBskyEmbedExternal, ··· 47 50 ids.PubLeafletPollDefinition, 48 51 // ids.AppBskyActorProfile, 49 52 "app.bsky.feed.post", 53 + ids.SiteStandardDocument, 54 + ids.SiteStandardPublication, 55 + ids.SiteStandardGraphSubscription, 50 56 ], 51 57 handleEvent, 52 58 onError: (err) => { ··· 207 213 if (evt.collection === ids.PubLeafletGraphSubscription) { 208 214 if (evt.event === "create" || evt.event === "update") { 209 215 let record = PubLeafletGraphSubscription.validateRecord(evt.record); 216 + if (!record.success) return; 217 + await supabase 218 + .from("identities") 219 + .upsert({ atp_did: evt.did }, { onConflict: "atp_did" }); 220 + await supabase.from("publication_subscriptions").upsert({ 221 + uri: evt.uri.toString(), 222 + identity: evt.did, 223 + publication: record.value.publication, 224 + record: record.value as Json, 225 + }); 226 + } 227 + if (evt.event === "delete") { 228 + await supabase 229 + .from("publication_subscriptions") 230 + .delete() 231 + .eq("uri", evt.uri.toString()); 232 + } 233 + } 234 + // site.standard.document records go into the main "documents" table 235 + // The normalization layer handles reading both pub.leaflet and site.standard formats 236 + if (evt.collection === ids.SiteStandardDocument) { 237 + if (evt.event === "create" || evt.event === "update") { 238 + let record = SiteStandardDocument.validateRecord(evt.record); 239 + if (!record.success) { 240 + console.log(record.error); 241 + return; 242 + } 243 + let docResult = await supabase.from("documents").upsert({ 244 + uri: evt.uri.toString(), 245 + data: record.value as Json, 246 + }); 247 + if (docResult.error) console.log(docResult.error); 248 + 249 + // site.standard.document uses "site" field to reference the publication 250 + // For documents in publications, site is an AT-URI (at://did:plc:xxx/site.standard.publication/rkey) 251 + // For standalone documents, site is an HTTPS URL (https://leaflet.pub/p/did:plc:xxx) 252 + // Only link to publications table for AT-URI sites 253 + if (record.value.site && record.value.site.startsWith("at://")) { 254 + let siteURI = new AtUri(record.value.site); 255 + 256 + if (siteURI.host !== evt.uri.host) { 257 + console.log("Unauthorized to create document in site!"); 258 + return; 259 + } 260 + let docInPublicationResult = await supabase 261 + .from("documents_in_publications") 262 + .upsert({ 263 + publication: record.value.site, 264 + document: evt.uri.toString(), 265 + }); 266 + await supabase 267 + .from("documents_in_publications") 268 + .delete() 269 + .neq("publication", record.value.site) 270 + .eq("document", evt.uri.toString()); 271 + 272 + if (docInPublicationResult.error) 273 + console.log(docInPublicationResult.error); 274 + } 275 + } 276 + if (evt.event === "delete") { 277 + await supabase.from("documents").delete().eq("uri", evt.uri.toString()); 278 + } 279 + } 280 + 281 + // site.standard.publication records go into the main "publications" table 282 + if (evt.collection === ids.SiteStandardPublication) { 283 + if (evt.event === "create" || evt.event === "update") { 284 + let record = SiteStandardPublication.validateRecord(evt.record); 285 + if (!record.success) return; 286 + await supabase 287 + .from("identities") 288 + .upsert({ atp_did: evt.did }, { onConflict: "atp_did" }); 289 + await supabase.from("publications").upsert({ 290 + uri: evt.uri.toString(), 291 + identity_did: evt.did, 292 + name: record.value.name, 293 + record: record.value as Json, 294 + }); 295 + } 296 + if (evt.event === "delete") { 297 + await supabase 298 + .from("publications") 299 + .delete() 300 + .eq("uri", evt.uri.toString()); 301 + } 302 + } 303 + 304 + // site.standard.graph.subscription records go into the main "publication_subscriptions" table 305 + if (evt.collection === ids.SiteStandardGraphSubscription) { 306 + if (evt.event === "create" || evt.event === "update") { 307 + let record = SiteStandardGraphSubscription.validateRecord(evt.record); 210 308 if (!record.success) return; 211 309 await supabase 212 310 .from("identities")
+7 -4
components/ActionBar/Publications.tsx
··· 5 5 import { theme } from "tailwind.config"; 6 6 import { getBasePublicationURL } from "app/lish/createPub/getPublicationURL"; 7 7 import { Json } from "supabase/database.types"; 8 - import { PubLeafletPublication } from "lexicons/api"; 9 8 import { AtUri } from "@atproto/syntax"; 10 9 import { ActionButton } from "./ActionButton"; 10 + import { 11 + normalizePublicationRecord, 12 + type NormalizedPublication, 13 + } from "src/utils/normalizeRecords"; 11 14 import { SpeedyLink } from "components/SpeedyLink"; 12 15 import { PublishSmall } from "components/Icons/PublishSmall"; 13 16 import { Popover } from "components/Popover"; ··· 85 88 record: Json; 86 89 current?: boolean; 87 90 }) => { 88 - let record = props.record as PubLeafletPublication.Record | null; 91 + let record = normalizePublicationRecord(props.record); 89 92 if (!record) return; 90 93 91 94 return ( ··· 181 184 }; 182 185 183 186 export const PubIcon = (props: { 184 - record: PubLeafletPublication.Record; 187 + record: NormalizedPublication | null; 185 188 uri: string; 186 189 small?: boolean; 187 190 large?: boolean; 188 191 className?: string; 189 192 }) => { 190 - if (!props.record) return; 193 + if (!props.record) return null; 191 194 192 195 let iconSizeClassName = `${props.small ? "w-4 h-4" : props.large ? "w-12 h-12" : "w-6 h-6"} rounded-full`; 193 196
+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 =
+7 -7
components/Blocks/PublicationPollBlock.tsx
··· 11 11 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 12 12 import { 13 13 PubLeafletBlocksPoll, 14 - PubLeafletDocument, 15 14 PubLeafletPagesLinearDocument, 16 15 } from "lexicons/api"; 16 + import { getDocumentPages } from "src/utils/normalizeRecords"; 17 17 import { ids } from "lexicons/api/lexicons"; 18 18 19 19 /** ··· 22 22 * but disables adding new options once the poll record exists (indicated by pollUri). 23 23 */ 24 24 export const PublicationPollBlock = (props: BlockProps) => { 25 - let { data: publicationData } = useLeafletPublicationData(); 25 + let { data: publicationData, normalizedDocument } = useLeafletPublicationData(); 26 26 let isSelected = useUIState((s) => 27 27 s.selectedBlocks.find((b) => b.value === props.entityID), 28 28 ); 29 29 // Check if this poll has been published in a publication document 30 30 const isPublished = useMemo(() => { 31 - if (!publicationData?.documents?.data) return false; 31 + if (!normalizedDocument) return false; 32 32 33 - const docRecord = publicationData.documents 34 - .data as PubLeafletDocument.Record; 33 + const pages = getDocumentPages(normalizedDocument); 34 + if (!pages) return false; 35 35 36 36 // Search through all pages and blocks to find if this poll entity has been published 37 - for (const page of docRecord.pages || []) { 37 + for (const page of pages) { 38 38 if (page.$type === "pub.leaflet.pages.linearDocument") { 39 39 const linearPage = page as PubLeafletPagesLinearDocument.Main; 40 40 for (const blockWrapper of linearPage.blocks || []) { ··· 50 50 } 51 51 } 52 52 return false; 53 - }, [publicationData, props.entityID]); 53 + }, [normalizedDocument, props.entityID]); 54 54 55 55 return ( 56 56 <BlockLayout
+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",
+4 -8
components/Canvas.tsx
··· 21 21 import { QuoteTiny } from "./Icons/QuoteTiny"; 22 22 import { PublicationMetadata } from "./Pages/PublicationMetadata"; 23 23 import { useLeafletPublicationData } from "./PageSWRDataProvider"; 24 - import { 25 - PubLeafletPublication, 26 - PubLeafletPublicationRecord, 27 - } from "lexicons/api"; 28 24 import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop"; 29 25 30 26 export function Canvas(props: { ··· 165 161 } 166 162 167 163 const CanvasMetadata = (props: { isSubpage: boolean | undefined }) => { 168 - let { data: pub } = useLeafletPublicationData(); 164 + let { data: pub, normalizedPublication } = useLeafletPublicationData(); 169 165 if (!pub || !pub.publications) return null; 170 166 171 - let pubRecord = pub.publications.record as PubLeafletPublication.Record; 172 - let showComments = pubRecord.preferences?.showComments !== false; 173 - let showMentions = pubRecord.preferences?.showMentions !== false; 167 + if (!normalizedPublication) return null; 168 + let showComments = normalizedPublication.preferences?.showComments !== false; 169 + let showMentions = normalizedPublication.preferences?.showMentions !== false; 174 170 175 171 return ( 176 172 <div className="flex flex-row gap-3 items-center absolute top-6 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20">
+20 -1
components/PageSWRDataProvider.tsx
··· 6 6 import { callRPC } from "app/api/rpc/client"; 7 7 import { getPollData } from "actions/pollActions"; 8 8 import type { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 9 - import { createContext, useContext } from "react"; 9 + import { createContext, useContext, useMemo } from "react"; 10 10 import { getPublicationMetadataFromLeafletData } from "src/utils/getPublicationMetadataFromLeafletData"; 11 11 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 12 12 import { AtUri } from "@atproto/syntax"; 13 + import { 14 + normalizeDocumentRecord, 15 + normalizePublicationRecord, 16 + type NormalizedDocument, 17 + type NormalizedPublication, 18 + } from "src/utils/normalizeRecords"; 13 19 14 20 export const StaticLeafletDataContext = createContext< 15 21 null | GetLeafletDataReturnType["result"]["data"] ··· 73 79 // First check for leaflets in publications 74 80 let pubData = getPublicationMetadataFromLeafletData(data); 75 81 82 + // Normalize records so consumers don't have to 83 + const normalizedPublication = useMemo( 84 + () => normalizePublicationRecord(pubData?.publications?.record), 85 + [pubData?.publications?.record] 86 + ); 87 + const normalizedDocument = useMemo( 88 + () => normalizeDocumentRecord(pubData?.documents?.data), 89 + [pubData?.documents?.data] 90 + ); 91 + 76 92 return { 77 93 data: pubData || null, 94 + // Pre-normalized data - consumers should use these instead of normalizing themselves 95 + normalizedPublication, 96 + normalizedDocument, 78 97 mutate, 79 98 }; 80 99 }
+11 -18
components/Pages/PublicationMetadata.tsx
··· 5 5 import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; 6 6 import { Separator } from "components/Layout"; 7 7 import { AtUri } from "@atproto/syntax"; 8 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 9 8 import { 10 9 getBasePublicationURL, 11 10 getPublicationURL, ··· 24 23 25 24 export const PublicationMetadata = () => { 26 25 let { rep } = useReplicache(); 27 - let { data: pub } = useLeafletPublicationData(); 26 + let { data: pub, normalizedDocument, normalizedPublication } = useLeafletPublicationData(); 28 27 let { identity } = useIdentityData(); 29 28 let title = useSubscribe(rep, (tx) => tx.get<string>("publication_title")); 30 29 let description = useSubscribe(rep, (tx) => 31 30 tx.get<string>("publication_description"), 32 31 ); 33 - let record = pub?.documents?.data as PubLeafletDocument.Record | null; 34 - let pubRecord = pub?.publications?.record as 35 - | PubLeafletPublication.Record 36 - | undefined; 37 - let publishedAt = record?.publishedAt; 32 + let publishedAt = normalizedDocument?.publishedAt; 38 33 39 34 if (!pub) return null; 40 35 ··· 123 118 {tags && ( 124 119 <> 125 120 <AddTags /> 126 - {pubRecord?.preferences?.showMentions !== false || 127 - pubRecord?.preferences?.showComments !== false ? ( 121 + {normalizedPublication?.preferences?.showMentions !== false || 122 + normalizedPublication?.preferences?.showComments !== false ? ( 128 123 <Separator classname="h-4!" /> 129 124 ) : null} 130 125 </> 131 126 )} 132 - {pubRecord?.preferences?.showMentions !== false && ( 127 + {normalizedPublication?.preferences?.showMentions !== false && ( 133 128 <div className="flex gap-1 items-center"> 134 129 <QuoteTiny />— 135 130 </div> 136 131 )} 137 - {pubRecord?.preferences?.showComments !== false && ( 132 + {normalizedPublication?.preferences?.showComments !== false && ( 138 133 <div className="flex gap-1 items-center"> 139 134 <CommentTiny />— 140 135 </div> ··· 218 213 }; 219 214 220 215 export const PublicationMetadataPreview = () => { 221 - let { data: pub } = useLeafletPublicationData(); 222 - let record = pub?.documents?.data as PubLeafletDocument.Record | null; 223 - let publishedAt = record?.publishedAt; 216 + let { data: pub, normalizedDocument } = useLeafletPublicationData(); 217 + let publishedAt = normalizedDocument?.publishedAt; 224 218 225 219 if (!pub) return null; 226 220 ··· 245 239 }; 246 240 247 241 const AddTags = () => { 248 - let { data: pub } = useLeafletPublicationData(); 242 + let { data: pub, normalizedDocument } = useLeafletPublicationData(); 249 243 let { rep } = useReplicache(); 250 - let record = pub?.documents?.data as PubLeafletDocument.Record | null; 251 244 252 245 // Get tags from Replicache local state or published document 253 246 let replicacheTags = useSubscribe(rep, (tx) => ··· 258 251 let tags: string[] = []; 259 252 if (Array.isArray(replicacheTags)) { 260 253 tags = replicacheTags; 261 - } else if (record?.tags && Array.isArray(record.tags)) { 262 - tags = record.tags as string[]; 254 + } else if (normalizedDocument?.tags && Array.isArray(normalizedDocument.tags)) { 255 + tags = normalizedDocument.tags as string[]; 263 256 } 264 257 265 258 // Update tags in replicache local state
+12 -4
components/PostListing.tsx
··· 7 7 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 8 8 import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; 9 9 import { useSmoker } from "components/Toast"; 10 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 11 10 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 + import type { 12 + NormalizedDocument, 13 + NormalizedPublication, 14 + } from "src/utils/normalizeRecords"; 12 15 import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 13 16 14 17 import Link from "next/link"; ··· 17 20 18 21 export const PostListing = (props: Post) => { 19 22 let pubRecord = props.publication?.pubRecord as 20 - | PubLeafletPublication.Record 23 + | NormalizedPublication 21 24 | undefined; 22 25 23 - let postRecord = props.documents.data as PubLeafletDocument.Record; 26 + let postRecord = props.documents.data as NormalizedDocument | null; 27 + 28 + // Don't render anything for records that can't be normalized (e.g., site.standard records without expected fields) 29 + if (!postRecord) { 30 + return null; 31 + } 24 32 let postUri = new AtUri(props.documents.uri); 25 33 let uri = props.publication ? props.publication?.uri : props.documents.uri; 26 34 ··· 110 118 111 119 const PubInfo = (props: { 112 120 href: string; 113 - pubRecord: PubLeafletPublication.Record; 121 + pubRecord: NormalizedPublication; 114 122 uri: string; 115 123 }) => { 116 124 return (
+8 -8
components/ThemeManager/PubThemeSetter.tsx
··· 1 - import { usePublicationData } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 1 + import { 2 + usePublicationData, 3 + useNormalizedPublicationRecord, 4 + } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 2 5 import { useState } from "react"; 3 6 import { pickers, SectionArrow } from "./ThemeSetter"; 4 7 import { Color } from "react-aria-components"; 5 - import { 6 - PubLeafletPublication, 7 - PubLeafletThemeBackgroundImage, 8 - } from "lexicons/api"; 8 + import { PubLeafletThemeBackgroundImage } from "lexicons/api"; 9 9 import { AtUri } from "@atproto/syntax"; 10 10 import { useLocalPubTheme } from "./PublicationThemeProvider"; 11 11 import { BaseThemeProvider } from "./ThemeProvider"; ··· 35 35 let [openPicker, setOpenPicker] = useState<pickers>("null"); 36 36 let { data, mutate } = usePublicationData(); 37 37 let { publication: pub } = data || {}; 38 - let record = pub?.record as PubLeafletPublication.Record | undefined; 38 + let record = useNormalizedPublicationRecord(); 39 39 let [showPageBackground, setShowPageBackground] = useState( 40 40 !!record?.theme?.showPageBackground, 41 41 ); ··· 246 246 }) => { 247 247 let { data } = usePublicationData(); 248 248 let { publication } = data || {}; 249 - let record = publication?.record as PubLeafletPublication.Record | null; 249 + let record = useNormalizedPublicationRecord(); 250 250 251 251 return ( 252 252 <div ··· 314 314 }) => { 315 315 let { data } = usePublicationData(); 316 316 let { publication } = data || {}; 317 - let record = publication?.record as PubLeafletPublication.Record | null; 317 + let record = useNormalizedPublicationRecord(); 318 318 return ( 319 319 <div 320 320 style={{
+8 -11
components/ThemeManager/PublicationThemeProvider.tsx
··· 6 6 import { useColorAttribute, colorToString } from "./useColorAttribute"; 7 7 import { BaseThemeProvider, CardBorderHiddenContext } from "./ThemeProvider"; 8 8 import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; 9 - import { usePublicationData } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 9 + import { 10 + usePublicationData, 11 + useNormalizedPublicationRecord, 12 + } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 10 13 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 - 12 - const PubThemeDefaults = { 13 - backgroundColor: "#FDFCFA", 14 - pageBackground: "#FDFCFA", 15 - primary: "#272727", 16 - accentText: "#FFFFFF", 17 - accentBackground: "#0000FF", 18 - }; 14 + import { PubThemeDefaults } from "./themeDefaults"; 19 15 20 16 // Default page background for standalone leaflets (matches editor default) 21 17 const StandalonePageBackground = "#FFFFFF"; ··· 53 49 }) { 54 50 let { data } = usePublicationData(); 55 51 let { publication: pub } = data || {}; 52 + const normalizedPub = useNormalizedPublicationRecord(); 56 53 return ( 57 54 <PublicationThemeProvider 58 55 pub_creator={pub?.identity_did || ""} 59 - theme={(pub?.record as PubLeafletPublication.Record)?.theme} 56 + theme={normalizedPub?.theme} 60 57 > 61 58 <PublicationBackgroundProvider 62 - theme={(pub?.record as PubLeafletPublication.Record)?.theme} 59 + theme={normalizedPub?.theme} 63 60 pub_creator={pub?.identity_did || ""} 64 61 > 65 62 {props.children}
+4 -7
components/ThemeManager/ThemeProvider.tsx
··· 21 21 PublicationBackgroundProvider, 22 22 PublicationThemeProvider, 23 23 } from "./PublicationThemeProvider"; 24 - import { PubLeafletPublication } from "lexicons/api"; 25 24 import { getColorDifference } from "./themeUtils"; 26 25 27 26 // define a function to set an Aria Color to a CSS Variable in RGB ··· 40 39 children: React.ReactNode; 41 40 className?: string; 42 41 }) { 43 - let { data: pub } = useLeafletPublicationData(); 42 + let { data: pub, normalizedPublication } = useLeafletPublicationData(); 44 43 if (!pub || !pub.publications) return <LeafletThemeProvider {...props} />; 45 44 return ( 46 45 <PublicationThemeProvider 47 46 {...props} 48 - theme={(pub.publications?.record as PubLeafletPublication.Record)?.theme} 47 + theme={normalizedPublication?.theme} 49 48 pub_creator={pub.publications?.identity_did} 50 49 /> 51 50 ); ··· 328 327 entityID: string; 329 328 children: React.ReactNode; 330 329 }) => { 331 - let { data: pub } = useLeafletPublicationData(); 330 + let { data: pub, normalizedPublication } = useLeafletPublicationData(); 332 331 let backgroundImage = useEntity(props.entityID, "theme/background-image"); 333 332 let backgroundImageRepeat = useEntity( 334 333 props.entityID, ··· 338 337 return ( 339 338 <PublicationBackgroundProvider 340 339 pub_creator={pub?.publications.identity_did || ""} 341 - theme={ 342 - (pub.publications?.record as PubLeafletPublication.Record)?.theme 343 - } 340 + theme={normalizedPublication?.theme} 344 341 > 345 342 {props.children} 346 343 </PublicationBackgroundProvider>
+21
components/ThemeManager/themeDefaults.ts
··· 1 + /** 2 + * Default theme values for publications. 3 + * Shared between client and server code. 4 + */ 5 + 6 + // Hex color defaults 7 + export const PubThemeDefaults = { 8 + backgroundColor: "#FDFCFA", 9 + pageBackground: "#FDFCFA", 10 + primary: "#272727", 11 + accentText: "#FFFFFF", 12 + accentBackground: "#0000FF", 13 + } as const; 14 + 15 + // RGB color defaults (parsed from hex values above) 16 + export const PubThemeDefaultsRGB = { 17 + background: { r: 253, g: 252, b: 250 }, // #FDFCFA 18 + foreground: { r: 39, g: 39, b: 39 }, // #272727 19 + accent: { r: 0, g: 0, b: 255 }, // #0000FF 20 + accentForeground: { r: 255, g: 255, b: 255 }, // #FFFFFF 21 + } as const;
+50
contexts/DocumentContext.tsx
··· 1 + "use client"; 2 + import { createContext, useContext } from "react"; 3 + import type { PostPageData } from "app/lish/[did]/[publication]/[rkey]/getPostPageData"; 4 + 5 + // Derive types from PostPageData 6 + type NonNullPostPageData = NonNullable<PostPageData>; 7 + export type PublicationContext = NonNullPostPageData["publication"]; 8 + export type CommentOnDocument = NonNullPostPageData["comments"][number]; 9 + export type DocumentMention = NonNullPostPageData["mentions"][number]; 10 + export type QuotesAndMentions = NonNullPostPageData["quotesAndMentions"]; 11 + 12 + export type DocumentContextValue = Pick< 13 + NonNullPostPageData, 14 + | "uri" 15 + | "normalizedDocument" 16 + | "normalizedPublication" 17 + | "theme" 18 + | "prevNext" 19 + | "quotesAndMentions" 20 + | "publication" 21 + | "comments" 22 + | "mentions" 23 + | "leafletId" 24 + >; 25 + 26 + const DocumentContext = createContext<DocumentContextValue | null>(null); 27 + 28 + export function useDocument() { 29 + const ctx = useContext(DocumentContext); 30 + if (!ctx) throw new Error("useDocument must be used within DocumentProvider"); 31 + return ctx; 32 + } 33 + 34 + export function useDocumentOptional() { 35 + return useContext(DocumentContext); 36 + } 37 + 38 + export function DocumentProvider({ 39 + children, 40 + value, 41 + }: { 42 + children: React.ReactNode; 43 + value: DocumentContextValue; 44 + }) { 45 + return ( 46 + <DocumentContext.Provider value={value}> 47 + {children} 48 + </DocumentContext.Provider> 49 + ); 50 + }
+35
contexts/LeafletContentContext.tsx
··· 1 + "use client"; 2 + import { createContext, useContext } from "react"; 3 + import type { PubLeafletContent } from "lexicons/api"; 4 + 5 + export type Page = PubLeafletContent.Main["pages"][number]; 6 + 7 + export type LeafletContentContextValue = { 8 + pages: Page[]; 9 + }; 10 + 11 + const LeafletContentContext = createContext<LeafletContentContextValue | null>(null); 12 + 13 + export function useLeafletContent() { 14 + const ctx = useContext(LeafletContentContext); 15 + if (!ctx) throw new Error("useLeafletContent must be used within LeafletContentProvider"); 16 + return ctx; 17 + } 18 + 19 + export function useLeafletContentOptional() { 20 + return useContext(LeafletContentContext); 21 + } 22 + 23 + export function LeafletContentProvider({ 24 + children, 25 + value, 26 + }: { 27 + children: React.ReactNode; 28 + value: LeafletContentContextValue; 29 + }) { 30 + return ( 31 + <LeafletContentContext.Provider value={value}> 32 + {children} 33 + </LeafletContentContext.Provider> 34 + ); 35 + }
+79 -24
drizzle/relations.ts
··· 1 1 import { relations } from "drizzle-orm/relations"; 2 - import { identities, notifications, publications, documents, comments_on_documents, bsky_profiles, entity_sets, entities, facts, email_auth_tokens, poll_votes_on_entity, permission_tokens, phone_rsvps_to_entity, custom_domains, custom_domain_routes, email_subscriptions_to_entity, atp_poll_records, atp_poll_votes, bsky_follows, subscribers_to_publications, permission_token_on_homepage, documents_in_publications, document_mentions_in_bsky, bsky_posts, publication_domains, leaflets_in_publications, publication_subscriptions, permission_token_rights } from "./schema"; 2 + import { identities, notifications, publications, documents, comments_on_documents, bsky_profiles, entity_sets, entities, facts, email_auth_tokens, poll_votes_on_entity, permission_tokens, phone_rsvps_to_entity, site_standard_publications, custom_domains, custom_domain_routes, site_standard_documents, email_subscriptions_to_entity, atp_poll_records, atp_poll_votes, bsky_follows, subscribers_to_publications, site_standard_documents_in_publications, documents_in_publications, document_mentions_in_bsky, bsky_posts, permission_token_on_homepage, publication_domains, publication_subscriptions, site_standard_subscriptions, leaflets_to_documents, permission_token_rights, leaflets_in_publications } from "./schema"; 3 3 4 4 export const notificationsRelations = relations(notifications, ({one}) => ({ 5 5 identity: one(identities, { ··· 17 17 fields: [identities.home_page], 18 18 references: [permission_tokens.id] 19 19 }), 20 + site_standard_publications: many(site_standard_publications), 21 + site_standard_documents: many(site_standard_documents), 20 22 custom_domains_identity: many(custom_domains, { 21 23 relationName: "custom_domains_identity_identities_email" 22 24 }), ··· 33 35 permission_token_on_homepages: many(permission_token_on_homepage), 34 36 publication_domains: many(publication_domains), 35 37 publication_subscriptions: many(publication_subscriptions), 38 + site_standard_subscriptions: many(site_standard_subscriptions), 36 39 })); 37 40 38 41 export const publicationsRelations = relations(publications, ({one, many}) => ({ ··· 43 46 subscribers_to_publications: many(subscribers_to_publications), 44 47 documents_in_publications: many(documents_in_publications), 45 48 publication_domains: many(publication_domains), 46 - leaflets_in_publications: many(leaflets_in_publications), 47 49 publication_subscriptions: many(publication_subscriptions), 50 + leaflets_in_publications: many(leaflets_in_publications), 48 51 })); 49 52 50 53 export const comments_on_documentsRelations = relations(comments_on_documents, ({one}) => ({ ··· 62 65 comments_on_documents: many(comments_on_documents), 63 66 documents_in_publications: many(documents_in_publications), 64 67 document_mentions_in_bskies: many(document_mentions_in_bsky), 68 + leaflets_to_documents: many(leaflets_to_documents), 65 69 leaflets_in_publications: many(leaflets_in_publications), 66 70 })); 67 71 ··· 136 140 }), 137 141 email_subscriptions_to_entities: many(email_subscriptions_to_entity), 138 142 permission_token_on_homepages: many(permission_token_on_homepage), 143 + leaflets_to_documents: many(leaflets_to_documents), 144 + permission_token_rights: many(permission_token_rights), 139 145 leaflets_in_publications: many(leaflets_in_publications), 140 - permission_token_rights: many(permission_token_rights), 141 146 })); 142 147 143 148 export const phone_rsvps_to_entityRelations = relations(phone_rsvps_to_entity, ({one}) => ({ ··· 147 152 }), 148 153 })); 149 154 155 + export const site_standard_publicationsRelations = relations(site_standard_publications, ({one, many}) => ({ 156 + identity: one(identities, { 157 + fields: [site_standard_publications.identity_did], 158 + references: [identities.atp_did] 159 + }), 160 + site_standard_documents_in_publications: many(site_standard_documents_in_publications), 161 + site_standard_subscriptions: many(site_standard_subscriptions), 162 + })); 163 + 150 164 export const custom_domain_routesRelations = relations(custom_domain_routes, ({one}) => ({ 151 165 custom_domain: one(custom_domains, { 152 166 fields: [custom_domain_routes.domain], ··· 179 193 publication_domains: many(publication_domains), 180 194 })); 181 195 196 + export const site_standard_documentsRelations = relations(site_standard_documents, ({one, many}) => ({ 197 + identity: one(identities, { 198 + fields: [site_standard_documents.identity_did], 199 + references: [identities.atp_did] 200 + }), 201 + site_standard_documents_in_publications: many(site_standard_documents_in_publications), 202 + })); 203 + 182 204 export const email_subscriptions_to_entityRelations = relations(email_subscriptions_to_entity, ({one}) => ({ 183 205 entity: one(entities, { 184 206 fields: [email_subscriptions_to_entity.entity], ··· 225 247 }), 226 248 })); 227 249 228 - export const permission_token_on_homepageRelations = relations(permission_token_on_homepage, ({one}) => ({ 229 - identity: one(identities, { 230 - fields: [permission_token_on_homepage.identity], 231 - references: [identities.id] 250 + export const site_standard_documents_in_publicationsRelations = relations(site_standard_documents_in_publications, ({one}) => ({ 251 + site_standard_document: one(site_standard_documents, { 252 + fields: [site_standard_documents_in_publications.document], 253 + references: [site_standard_documents.uri] 232 254 }), 233 - permission_token: one(permission_tokens, { 234 - fields: [permission_token_on_homepage.token], 235 - references: [permission_tokens.id] 255 + site_standard_publication: one(site_standard_publications, { 256 + fields: [site_standard_documents_in_publications.publication], 257 + references: [site_standard_publications.uri] 236 258 }), 237 259 })); 238 260 ··· 262 284 document_mentions_in_bskies: many(document_mentions_in_bsky), 263 285 })); 264 286 287 + export const permission_token_on_homepageRelations = relations(permission_token_on_homepage, ({one}) => ({ 288 + identity: one(identities, { 289 + fields: [permission_token_on_homepage.identity], 290 + references: [identities.id] 291 + }), 292 + permission_token: one(permission_tokens, { 293 + fields: [permission_token_on_homepage.token], 294 + references: [permission_tokens.id] 295 + }), 296 + })); 297 + 265 298 export const publication_domainsRelations = relations(publication_domains, ({one}) => ({ 266 299 custom_domain: one(custom_domains, { 267 300 fields: [publication_domains.domain], ··· 277 310 }), 278 311 })); 279 312 280 - export const leaflets_in_publicationsRelations = relations(leaflets_in_publications, ({one}) => ({ 281 - document: one(documents, { 282 - fields: [leaflets_in_publications.doc], 283 - references: [documents.uri] 284 - }), 285 - permission_token: one(permission_tokens, { 286 - fields: [leaflets_in_publications.leaflet], 287 - references: [permission_tokens.id] 313 + export const publication_subscriptionsRelations = relations(publication_subscriptions, ({one}) => ({ 314 + identity: one(identities, { 315 + fields: [publication_subscriptions.identity], 316 + references: [identities.atp_did] 288 317 }), 289 318 publication: one(publications, { 290 - fields: [leaflets_in_publications.publication], 319 + fields: [publication_subscriptions.publication], 291 320 references: [publications.uri] 292 321 }), 293 322 })); 294 323 295 - export const publication_subscriptionsRelations = relations(publication_subscriptions, ({one}) => ({ 324 + export const site_standard_subscriptionsRelations = relations(site_standard_subscriptions, ({one}) => ({ 296 325 identity: one(identities, { 297 - fields: [publication_subscriptions.identity], 326 + fields: [site_standard_subscriptions.identity], 298 327 references: [identities.atp_did] 299 328 }), 300 - publication: one(publications, { 301 - fields: [publication_subscriptions.publication], 302 - references: [publications.uri] 329 + site_standard_publication: one(site_standard_publications, { 330 + fields: [site_standard_subscriptions.publication], 331 + references: [site_standard_publications.uri] 332 + }), 333 + })); 334 + 335 + export const leaflets_to_documentsRelations = relations(leaflets_to_documents, ({one}) => ({ 336 + document: one(documents, { 337 + fields: [leaflets_to_documents.document], 338 + references: [documents.uri] 339 + }), 340 + permission_token: one(permission_tokens, { 341 + fields: [leaflets_to_documents.leaflet], 342 + references: [permission_tokens.id] 303 343 }), 304 344 })); 305 345 ··· 311 351 permission_token: one(permission_tokens, { 312 352 fields: [permission_token_rights.token], 313 353 references: [permission_tokens.id] 354 + }), 355 + })); 356 + 357 + export const leaflets_in_publicationsRelations = relations(leaflets_in_publications, ({one}) => ({ 358 + document: one(documents, { 359 + fields: [leaflets_in_publications.doc], 360 + references: [documents.uri] 361 + }), 362 + permission_token: one(permission_tokens, { 363 + fields: [leaflets_in_publications.leaflet], 364 + references: [permission_tokens.id] 365 + }), 366 + publication: one(publications, { 367 + fields: [leaflets_in_publications.publication], 368 + references: [publications.uri] 314 369 }), 315 370 }));
+77 -19
drizzle/schema.ts
··· 136 136 export const identities = pgTable("identities", { 137 137 id: uuid("id").defaultRandom().primaryKey().notNull(), 138 138 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 139 - home_page: uuid("home_page").notNull().references(() => permission_tokens.id, { onDelete: "cascade" } ), 139 + home_page: uuid("home_page").default(sql`create_identity_homepage()`).notNull().references(() => permission_tokens.id, { onDelete: "cascade" } ), 140 140 email: text("email"), 141 141 atp_did: text("atp_did"), 142 142 interface_state: jsonb("interface_state"), ··· 173 173 } 174 174 }); 175 175 176 + export const site_standard_publications = pgTable("site_standard_publications", { 177 + uri: text("uri").primaryKey().notNull(), 178 + data: jsonb("data").notNull(), 179 + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 180 + identity_did: text("identity_did").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 181 + }); 182 + 176 183 export const custom_domain_routes = pgTable("custom_domain_routes", { 177 184 id: uuid("id").defaultRandom().primaryKey().notNull(), 178 185 domain: text("domain").notNull().references(() => custom_domains.domain), ··· 186 193 edit_permission_token_idx: index("custom_domain_routes_edit_permission_token_idx").on(table.edit_permission_token), 187 194 custom_domain_routes_domain_route_key: unique("custom_domain_routes_domain_route_key").on(table.domain, table.route), 188 195 } 196 + }); 197 + 198 + export const site_standard_documents = pgTable("site_standard_documents", { 199 + uri: text("uri").primaryKey().notNull(), 200 + data: jsonb("data").notNull(), 201 + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 202 + identity_did: text("identity_did").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 189 203 }); 190 204 191 205 export const custom_domains = pgTable("custom_domains", { ··· 260 274 } 261 275 }); 262 276 263 - export const permission_token_on_homepage = pgTable("permission_token_on_homepage", { 264 - token: uuid("token").notNull().references(() => permission_tokens.id, { onDelete: "cascade" } ), 265 - identity: uuid("identity").notNull().references(() => identities.id, { onDelete: "cascade" } ), 266 - created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 277 + export const site_standard_documents_in_publications = pgTable("site_standard_documents_in_publications", { 278 + publication: text("publication").notNull().references(() => site_standard_publications.uri, { onDelete: "cascade" } ), 279 + document: text("document").notNull().references(() => site_standard_documents.uri, { onDelete: "cascade" } ), 280 + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 267 281 }, 268 282 (table) => { 269 283 return { 270 - permission_token_creator_pkey: primaryKey({ columns: [table.token, table.identity], name: "permission_token_creator_pkey"}), 284 + site_standard_documents_in_publications_pkey: primaryKey({ columns: [table.publication, table.document], name: "site_standard_documents_in_publications_pkey"}), 271 285 } 272 286 }); 273 287 ··· 295 309 } 296 310 }); 297 311 312 + export const permission_token_on_homepage = pgTable("permission_token_on_homepage", { 313 + token: uuid("token").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), 314 + identity: uuid("identity").notNull().references(() => identities.id, { onDelete: "cascade" } ), 315 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 316 + archived: boolean("archived"), 317 + }, 318 + (table) => { 319 + return { 320 + permission_token_creator_pkey: primaryKey({ columns: [table.token, table.identity], name: "permission_token_creator_pkey"}), 321 + } 322 + }); 323 + 298 324 export const publication_domains = pgTable("publication_domains", { 299 325 publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), 300 326 domain: text("domain").notNull().references(() => custom_domains.domain, { onDelete: "cascade" } ), ··· 308 334 } 309 335 }); 310 336 311 - export const leaflets_in_publications = pgTable("leaflets_in_publications", { 337 + export const publication_subscriptions = pgTable("publication_subscriptions", { 312 338 publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), 313 - doc: text("doc").default('').references(() => documents.uri, { onDelete: "set null" } ), 314 - leaflet: uuid("leaflet").notNull().references(() => permission_tokens.id, { onDelete: "cascade" } ), 315 - description: text("description").default('').notNull(), 316 - title: text("title").default('').notNull(), 339 + identity: text("identity").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 340 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 341 + record: jsonb("record").notNull(), 342 + uri: text("uri").notNull(), 317 343 }, 318 344 (table) => { 319 345 return { 320 - leaflet_idx: index("leaflets_in_publications_leaflet_idx").on(table.leaflet), 321 - publication_idx: index("leaflets_in_publications_publication_idx").on(table.publication), 322 - leaflets_in_publications_pkey: primaryKey({ columns: [table.publication, table.leaflet], name: "leaflets_in_publications_pkey"}), 346 + publication_idx: index("publication_subscriptions_publication_idx").on(table.publication), 347 + publication_subscriptions_pkey: primaryKey({ columns: [table.publication, table.identity], name: "publication_subscriptions_pkey"}), 348 + publication_subscriptions_uri_key: unique("publication_subscriptions_uri_key").on(table.uri), 323 349 } 324 350 }); 325 351 326 - export const publication_subscriptions = pgTable("publication_subscriptions", { 327 - publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), 352 + export const site_standard_subscriptions = pgTable("site_standard_subscriptions", { 353 + publication: text("publication").notNull().references(() => site_standard_publications.uri, { onDelete: "cascade" } ), 328 354 identity: text("identity").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 329 355 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 330 356 record: jsonb("record").notNull(), ··· 332 358 }, 333 359 (table) => { 334 360 return { 335 - publication_idx: index("publication_subscriptions_publication_idx").on(table.publication), 336 - publication_subscriptions_pkey: primaryKey({ columns: [table.publication, table.identity], name: "publication_subscriptions_pkey"}), 337 - publication_subscriptions_uri_key: unique("publication_subscriptions_uri_key").on(table.uri), 361 + site_standard_subscriptions_pkey: primaryKey({ columns: [table.publication, table.identity], name: "site_standard_subscriptions_pkey"}), 362 + site_standard_subscriptions_uri_key: unique("site_standard_subscriptions_uri_key").on(table.uri), 363 + } 364 + }); 365 + 366 + export const leaflets_to_documents = pgTable("leaflets_to_documents", { 367 + leaflet: uuid("leaflet").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), 368 + document: text("document").notNull().references(() => documents.uri, { onDelete: "cascade", onUpdate: "cascade" } ), 369 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 370 + title: text("title").default('').notNull(), 371 + description: text("description").default('').notNull(), 372 + tags: text("tags").default('RRAY[').array(), 373 + cover_image: text("cover_image"), 374 + }, 375 + (table) => { 376 + return { 377 + leaflets_to_documents_pkey: primaryKey({ columns: [table.leaflet, table.document], name: "leaflets_to_documents_pkey"}), 338 378 } 339 379 }); 340 380 ··· 352 392 token_idx: index("permission_token_rights_token_idx").on(table.token), 353 393 entity_set_idx: index("permission_token_rights_entity_set_idx").on(table.entity_set), 354 394 permission_token_rights_pkey: primaryKey({ columns: [table.token, table.entity_set], name: "permission_token_rights_pkey"}), 395 + } 396 + }); 397 + 398 + export const leaflets_in_publications = pgTable("leaflets_in_publications", { 399 + publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), 400 + doc: text("doc").default('').references(() => documents.uri, { onDelete: "set null" } ), 401 + leaflet: uuid("leaflet").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), 402 + description: text("description").default('').notNull(), 403 + title: text("title").default('').notNull(), 404 + archived: boolean("archived"), 405 + tags: text("tags").default('RRAY[').array(), 406 + cover_image: text("cover_image"), 407 + }, 408 + (table) => { 409 + return { 410 + leaflet_idx: index("leaflets_in_publications_leaflet_idx").on(table.leaflet), 411 + publication_idx: index("leaflets_in_publications_publication_idx").on(table.publication), 412 + leaflets_in_publications_pkey: primaryKey({ columns: [table.publication, table.leaflet], name: "leaflets_in_publications_pkey"}), 355 413 } 356 414 });
+8 -5
feeds/index.ts
··· 3 3 import { DidResolver } from "@atproto/identity"; 4 4 import { parseReqNsid, verifyJwt } from "@atproto/xrpc-server"; 5 5 import { supabaseServerClient } from "supabase/serverClient"; 6 - import { PubLeafletDocument } from "lexicons/api"; 6 + import { 7 + normalizeDocumentRecord, 8 + type NormalizedDocument, 9 + } from "src/utils/normalizeRecords"; 7 10 import { inngest } from "app/api/inngest/client"; 8 11 import { AtUri } from "@atproto/api"; 9 12 ··· 112 115 ); 113 116 } 114 117 query = query 115 - .not("data -> postRef", "is", null) 118 + .or("data->postRef.not.is.null,data->bskyPostRef.not.is.null") 116 119 .order("indexed_at", { ascending: false }) 117 120 .order("uri", { ascending: false }) 118 121 .limit(25); ··· 133 136 cursor: newCursor || cursor, 134 137 feed: posts.flatMap((p) => { 135 138 if (!p.data) return []; 136 - let record = p.data as PubLeafletDocument.Record; 137 - if (!record.postRef) return []; 138 - return { post: record.postRef.uri }; 139 + const normalizedDoc = normalizeDocumentRecord(p.data, p.uri); 140 + if (!normalizedDoc?.bskyPostRef) return []; 141 + return { post: normalizedDoc.bskyPostRef.uri }; 139 142 }), 140 143 }); 141 144 });
+303
lexicons/api/index.ts
··· 38 38 import * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' 39 39 import * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' 40 40 import * as PubLeafletComment from './types/pub/leaflet/comment' 41 + import * as PubLeafletContent from './types/pub/leaflet/content' 41 42 import * as PubLeafletDocument from './types/pub/leaflet/document' 42 43 import * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' 43 44 import * as PubLeafletPagesCanvas from './types/pub/leaflet/pages/canvas' ··· 48 49 import * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' 49 50 import * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage' 50 51 import * as PubLeafletThemeColor from './types/pub/leaflet/theme/color' 52 + import * as SiteStandardDocument from './types/site/standard/document' 53 + import * as SiteStandardGraphSubscription from './types/site/standard/graph/subscription' 54 + import * as SiteStandardPublication from './types/site/standard/publication' 55 + import * as SiteStandardThemeBasic from './types/site/standard/theme/basic' 56 + import * as SiteStandardThemeColor from './types/site/standard/theme/color' 51 57 52 58 export * as AppBskyActorProfile from './types/app/bsky/actor/profile' 53 59 export * as ComAtprotoLabelDefs from './types/com/atproto/label/defs' ··· 78 84 export * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' 79 85 export * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' 80 86 export * as PubLeafletComment from './types/pub/leaflet/comment' 87 + export * as PubLeafletContent from './types/pub/leaflet/content' 81 88 export * as PubLeafletDocument from './types/pub/leaflet/document' 82 89 export * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' 83 90 export * as PubLeafletPagesCanvas from './types/pub/leaflet/pages/canvas' ··· 88 95 export * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' 89 96 export * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage' 90 97 export * as PubLeafletThemeColor from './types/pub/leaflet/theme/color' 98 + export * as SiteStandardDocument from './types/site/standard/document' 99 + export * as SiteStandardGraphSubscription from './types/site/standard/graph/subscription' 100 + export * as SiteStandardPublication from './types/site/standard/publication' 101 + export * as SiteStandardThemeBasic from './types/site/standard/theme/basic' 102 + export * as SiteStandardThemeColor from './types/site/standard/theme/color' 91 103 92 104 export const PUB_LEAFLET_PAGES = { 93 105 CanvasTextAlignLeft: 'pub.leaflet.pages.canvas#textAlignLeft', ··· 106 118 app: AppNS 107 119 com: ComNS 108 120 pub: PubNS 121 + site: SiteNS 109 122 110 123 constructor(options: FetchHandler | FetchHandlerOptions) { 111 124 super(options, schemas) 112 125 this.app = new AppNS(this) 113 126 this.com = new ComNS(this) 114 127 this.pub = new PubNS(this) 128 + this.site = new SiteNS(this) 115 129 } 116 130 117 131 /** @deprecated use `this` instead */ ··· 952 966 ) 953 967 } 954 968 } 969 + 970 + export class SiteNS { 971 + _client: XrpcClient 972 + standard: SiteStandardNS 973 + 974 + constructor(client: XrpcClient) { 975 + this._client = client 976 + this.standard = new SiteStandardNS(client) 977 + } 978 + } 979 + 980 + export class SiteStandardNS { 981 + _client: XrpcClient 982 + document: SiteStandardDocumentRecord 983 + publication: SiteStandardPublicationRecord 984 + graph: SiteStandardGraphNS 985 + theme: SiteStandardThemeNS 986 + 987 + constructor(client: XrpcClient) { 988 + this._client = client 989 + this.graph = new SiteStandardGraphNS(client) 990 + this.theme = new SiteStandardThemeNS(client) 991 + this.document = new SiteStandardDocumentRecord(client) 992 + this.publication = new SiteStandardPublicationRecord(client) 993 + } 994 + } 995 + 996 + export class SiteStandardGraphNS { 997 + _client: XrpcClient 998 + subscription: SiteStandardGraphSubscriptionRecord 999 + 1000 + constructor(client: XrpcClient) { 1001 + this._client = client 1002 + this.subscription = new SiteStandardGraphSubscriptionRecord(client) 1003 + } 1004 + } 1005 + 1006 + export class SiteStandardGraphSubscriptionRecord { 1007 + _client: XrpcClient 1008 + 1009 + constructor(client: XrpcClient) { 1010 + this._client = client 1011 + } 1012 + 1013 + async list( 1014 + params: OmitKey<ComAtprotoRepoListRecords.QueryParams, 'collection'>, 1015 + ): Promise<{ 1016 + cursor?: string 1017 + records: { uri: string; value: SiteStandardGraphSubscription.Record }[] 1018 + }> { 1019 + const res = await this._client.call('com.atproto.repo.listRecords', { 1020 + collection: 'site.standard.graph.subscription', 1021 + ...params, 1022 + }) 1023 + return res.data 1024 + } 1025 + 1026 + async get( 1027 + params: OmitKey<ComAtprotoRepoGetRecord.QueryParams, 'collection'>, 1028 + ): Promise<{ 1029 + uri: string 1030 + cid: string 1031 + value: SiteStandardGraphSubscription.Record 1032 + }> { 1033 + const res = await this._client.call('com.atproto.repo.getRecord', { 1034 + collection: 'site.standard.graph.subscription', 1035 + ...params, 1036 + }) 1037 + return res.data 1038 + } 1039 + 1040 + async create( 1041 + params: OmitKey< 1042 + ComAtprotoRepoCreateRecord.InputSchema, 1043 + 'collection' | 'record' 1044 + >, 1045 + record: Un$Typed<SiteStandardGraphSubscription.Record>, 1046 + headers?: Record<string, string>, 1047 + ): Promise<{ uri: string; cid: string }> { 1048 + const collection = 'site.standard.graph.subscription' 1049 + const res = await this._client.call( 1050 + 'com.atproto.repo.createRecord', 1051 + undefined, 1052 + { collection, ...params, record: { ...record, $type: collection } }, 1053 + { encoding: 'application/json', headers }, 1054 + ) 1055 + return res.data 1056 + } 1057 + 1058 + async put( 1059 + params: OmitKey< 1060 + ComAtprotoRepoPutRecord.InputSchema, 1061 + 'collection' | 'record' 1062 + >, 1063 + record: Un$Typed<SiteStandardGraphSubscription.Record>, 1064 + headers?: Record<string, string>, 1065 + ): Promise<{ uri: string; cid: string }> { 1066 + const collection = 'site.standard.graph.subscription' 1067 + const res = await this._client.call( 1068 + 'com.atproto.repo.putRecord', 1069 + undefined, 1070 + { collection, ...params, record: { ...record, $type: collection } }, 1071 + { encoding: 'application/json', headers }, 1072 + ) 1073 + return res.data 1074 + } 1075 + 1076 + async delete( 1077 + params: OmitKey<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, 1078 + headers?: Record<string, string>, 1079 + ): Promise<void> { 1080 + await this._client.call( 1081 + 'com.atproto.repo.deleteRecord', 1082 + undefined, 1083 + { collection: 'site.standard.graph.subscription', ...params }, 1084 + { headers }, 1085 + ) 1086 + } 1087 + } 1088 + 1089 + export class SiteStandardThemeNS { 1090 + _client: XrpcClient 1091 + 1092 + constructor(client: XrpcClient) { 1093 + this._client = client 1094 + } 1095 + } 1096 + 1097 + export class SiteStandardDocumentRecord { 1098 + _client: XrpcClient 1099 + 1100 + constructor(client: XrpcClient) { 1101 + this._client = client 1102 + } 1103 + 1104 + async list( 1105 + params: OmitKey<ComAtprotoRepoListRecords.QueryParams, 'collection'>, 1106 + ): Promise<{ 1107 + cursor?: string 1108 + records: { uri: string; value: SiteStandardDocument.Record }[] 1109 + }> { 1110 + const res = await this._client.call('com.atproto.repo.listRecords', { 1111 + collection: 'site.standard.document', 1112 + ...params, 1113 + }) 1114 + return res.data 1115 + } 1116 + 1117 + async get( 1118 + params: OmitKey<ComAtprotoRepoGetRecord.QueryParams, 'collection'>, 1119 + ): Promise<{ uri: string; cid: string; value: SiteStandardDocument.Record }> { 1120 + const res = await this._client.call('com.atproto.repo.getRecord', { 1121 + collection: 'site.standard.document', 1122 + ...params, 1123 + }) 1124 + return res.data 1125 + } 1126 + 1127 + async create( 1128 + params: OmitKey< 1129 + ComAtprotoRepoCreateRecord.InputSchema, 1130 + 'collection' | 'record' 1131 + >, 1132 + record: Un$Typed<SiteStandardDocument.Record>, 1133 + headers?: Record<string, string>, 1134 + ): Promise<{ uri: string; cid: string }> { 1135 + const collection = 'site.standard.document' 1136 + const res = await this._client.call( 1137 + 'com.atproto.repo.createRecord', 1138 + undefined, 1139 + { collection, ...params, record: { ...record, $type: collection } }, 1140 + { encoding: 'application/json', headers }, 1141 + ) 1142 + return res.data 1143 + } 1144 + 1145 + async put( 1146 + params: OmitKey< 1147 + ComAtprotoRepoPutRecord.InputSchema, 1148 + 'collection' | 'record' 1149 + >, 1150 + record: Un$Typed<SiteStandardDocument.Record>, 1151 + headers?: Record<string, string>, 1152 + ): Promise<{ uri: string; cid: string }> { 1153 + const collection = 'site.standard.document' 1154 + const res = await this._client.call( 1155 + 'com.atproto.repo.putRecord', 1156 + undefined, 1157 + { collection, ...params, record: { ...record, $type: collection } }, 1158 + { encoding: 'application/json', headers }, 1159 + ) 1160 + return res.data 1161 + } 1162 + 1163 + async delete( 1164 + params: OmitKey<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, 1165 + headers?: Record<string, string>, 1166 + ): Promise<void> { 1167 + await this._client.call( 1168 + 'com.atproto.repo.deleteRecord', 1169 + undefined, 1170 + { collection: 'site.standard.document', ...params }, 1171 + { headers }, 1172 + ) 1173 + } 1174 + } 1175 + 1176 + export class SiteStandardPublicationRecord { 1177 + _client: XrpcClient 1178 + 1179 + constructor(client: XrpcClient) { 1180 + this._client = client 1181 + } 1182 + 1183 + async list( 1184 + params: OmitKey<ComAtprotoRepoListRecords.QueryParams, 'collection'>, 1185 + ): Promise<{ 1186 + cursor?: string 1187 + records: { uri: string; value: SiteStandardPublication.Record }[] 1188 + }> { 1189 + const res = await this._client.call('com.atproto.repo.listRecords', { 1190 + collection: 'site.standard.publication', 1191 + ...params, 1192 + }) 1193 + return res.data 1194 + } 1195 + 1196 + async get( 1197 + params: OmitKey<ComAtprotoRepoGetRecord.QueryParams, 'collection'>, 1198 + ): Promise<{ 1199 + uri: string 1200 + cid: string 1201 + value: SiteStandardPublication.Record 1202 + }> { 1203 + const res = await this._client.call('com.atproto.repo.getRecord', { 1204 + collection: 'site.standard.publication', 1205 + ...params, 1206 + }) 1207 + return res.data 1208 + } 1209 + 1210 + async create( 1211 + params: OmitKey< 1212 + ComAtprotoRepoCreateRecord.InputSchema, 1213 + 'collection' | 'record' 1214 + >, 1215 + record: Un$Typed<SiteStandardPublication.Record>, 1216 + headers?: Record<string, string>, 1217 + ): Promise<{ uri: string; cid: string }> { 1218 + const collection = 'site.standard.publication' 1219 + const res = await this._client.call( 1220 + 'com.atproto.repo.createRecord', 1221 + undefined, 1222 + { collection, ...params, record: { ...record, $type: collection } }, 1223 + { encoding: 'application/json', headers }, 1224 + ) 1225 + return res.data 1226 + } 1227 + 1228 + async put( 1229 + params: OmitKey< 1230 + ComAtprotoRepoPutRecord.InputSchema, 1231 + 'collection' | 'record' 1232 + >, 1233 + record: Un$Typed<SiteStandardPublication.Record>, 1234 + headers?: Record<string, string>, 1235 + ): Promise<{ uri: string; cid: string }> { 1236 + const collection = 'site.standard.publication' 1237 + const res = await this._client.call( 1238 + 'com.atproto.repo.putRecord', 1239 + undefined, 1240 + { collection, ...params, record: { ...record, $type: collection } }, 1241 + { encoding: 'application/json', headers }, 1242 + ) 1243 + return res.data 1244 + } 1245 + 1246 + async delete( 1247 + params: OmitKey<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, 1248 + headers?: Record<string, string>, 1249 + ): Promise<void> { 1250 + await this._client.call( 1251 + 'com.atproto.repo.deleteRecord', 1252 + undefined, 1253 + { collection: 'site.standard.publication', ...params }, 1254 + { headers }, 1255 + ) 1256 + } 1257 + }
+277
lexicons/api/lexicons.ts
··· 1400 1400 }, 1401 1401 }, 1402 1402 }, 1403 + PubLeafletContent: { 1404 + lexicon: 1, 1405 + id: 'pub.leaflet.content', 1406 + revision: 1, 1407 + description: 'A lexicon for long form rich media documents', 1408 + defs: { 1409 + main: { 1410 + type: 'object', 1411 + description: 'Content format for leaflet documents', 1412 + required: ['pages'], 1413 + properties: { 1414 + pages: { 1415 + type: 'array', 1416 + items: { 1417 + type: 'union', 1418 + refs: [ 1419 + 'lex:pub.leaflet.pages.linearDocument', 1420 + 'lex:pub.leaflet.pages.canvas', 1421 + ], 1422 + }, 1423 + }, 1424 + }, 1425 + }, 1426 + }, 1427 + }, 1403 1428 PubLeafletDocument: { 1404 1429 lexicon: 1, 1405 1430 id: 'pub.leaflet.document', ··· 2082 2107 }, 2083 2108 }, 2084 2109 }, 2110 + SiteStandardDocument: { 2111 + defs: { 2112 + main: { 2113 + key: 'tid', 2114 + record: { 2115 + properties: { 2116 + bskyPostRef: { 2117 + ref: 'lex:com.atproto.repo.strongRef', 2118 + type: 'ref', 2119 + }, 2120 + content: { 2121 + closed: false, 2122 + refs: ['lex:pub.leaflet.content'], 2123 + type: 'union', 2124 + }, 2125 + coverImage: { 2126 + accept: ['image/*'], 2127 + maxSize: 1000000, 2128 + type: 'blob', 2129 + }, 2130 + description: { 2131 + maxGraphemes: 300, 2132 + maxLength: 3000, 2133 + type: 'string', 2134 + }, 2135 + path: { 2136 + description: 2137 + 'combine with the publication url or the document site to construct a full url to the document', 2138 + type: 'string', 2139 + }, 2140 + publishedAt: { 2141 + format: 'datetime', 2142 + type: 'string', 2143 + }, 2144 + site: { 2145 + description: 2146 + '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.', 2147 + format: 'uri', 2148 + type: 'string', 2149 + }, 2150 + tags: { 2151 + items: { 2152 + maxGraphemes: 50, 2153 + maxLength: 100, 2154 + type: 'string', 2155 + }, 2156 + type: 'array', 2157 + }, 2158 + textContent: { 2159 + type: 'string', 2160 + }, 2161 + theme: { 2162 + description: 2163 + 'Theme for standalone documents. For documents in publications, theme is inherited from the publication.', 2164 + ref: 'lex:pub.leaflet.publication#theme', 2165 + type: 'ref', 2166 + }, 2167 + title: { 2168 + maxGraphemes: 128, 2169 + maxLength: 1280, 2170 + type: 'string', 2171 + }, 2172 + updatedAt: { 2173 + format: 'datetime', 2174 + type: 'string', 2175 + }, 2176 + }, 2177 + required: ['site', 'title', 'publishedAt'], 2178 + type: 'object', 2179 + }, 2180 + type: 'record', 2181 + }, 2182 + }, 2183 + id: 'site.standard.document', 2184 + lexicon: 1, 2185 + }, 2186 + SiteStandardGraphSubscription: { 2187 + defs: { 2188 + main: { 2189 + description: 'Record declaring a subscription to a publication', 2190 + key: 'tid', 2191 + record: { 2192 + properties: { 2193 + publication: { 2194 + format: 'at-uri', 2195 + type: 'string', 2196 + }, 2197 + }, 2198 + required: ['publication'], 2199 + type: 'object', 2200 + }, 2201 + type: 'record', 2202 + }, 2203 + }, 2204 + id: 'site.standard.graph.subscription', 2205 + lexicon: 1, 2206 + }, 2207 + SiteStandardPublication: { 2208 + defs: { 2209 + main: { 2210 + key: 'tid', 2211 + record: { 2212 + properties: { 2213 + basicTheme: { 2214 + ref: 'lex:site.standard.theme.basic', 2215 + type: 'ref', 2216 + }, 2217 + theme: { 2218 + type: 'ref', 2219 + ref: 'lex:pub.leaflet.publication#theme', 2220 + }, 2221 + description: { 2222 + maxGraphemes: 300, 2223 + maxLength: 3000, 2224 + type: 'string', 2225 + }, 2226 + icon: { 2227 + accept: ['image/*'], 2228 + maxSize: 1000000, 2229 + type: 'blob', 2230 + }, 2231 + name: { 2232 + maxGraphemes: 128, 2233 + maxLength: 1280, 2234 + type: 'string', 2235 + }, 2236 + preferences: { 2237 + ref: 'lex:site.standard.publication#preferences', 2238 + type: 'ref', 2239 + }, 2240 + url: { 2241 + format: 'uri', 2242 + type: 'string', 2243 + }, 2244 + }, 2245 + required: ['url', 'name'], 2246 + type: 'object', 2247 + }, 2248 + type: 'record', 2249 + }, 2250 + preferences: { 2251 + properties: { 2252 + showInDiscover: { 2253 + default: true, 2254 + type: 'boolean', 2255 + }, 2256 + showComments: { 2257 + default: true, 2258 + type: 'boolean', 2259 + }, 2260 + showMentions: { 2261 + default: true, 2262 + type: 'boolean', 2263 + }, 2264 + showPrevNext: { 2265 + default: false, 2266 + type: 'boolean', 2267 + }, 2268 + }, 2269 + type: 'object', 2270 + }, 2271 + }, 2272 + id: 'site.standard.publication', 2273 + lexicon: 1, 2274 + }, 2275 + SiteStandardThemeBasic: { 2276 + defs: { 2277 + main: { 2278 + properties: { 2279 + accent: { 2280 + refs: ['lex:site.standard.theme.color#rgb'], 2281 + type: 'union', 2282 + }, 2283 + accentForeground: { 2284 + refs: ['lex:site.standard.theme.color#rgb'], 2285 + type: 'union', 2286 + }, 2287 + background: { 2288 + refs: ['lex:site.standard.theme.color#rgb'], 2289 + type: 'union', 2290 + }, 2291 + foreground: { 2292 + refs: ['lex:site.standard.theme.color#rgb'], 2293 + type: 'union', 2294 + }, 2295 + }, 2296 + required: ['background', 'foreground', 'accent', 'accentForeground'], 2297 + type: 'object', 2298 + }, 2299 + }, 2300 + id: 'site.standard.theme.basic', 2301 + lexicon: 1, 2302 + }, 2303 + SiteStandardThemeColor: { 2304 + lexicon: 1, 2305 + id: 'site.standard.theme.color', 2306 + defs: { 2307 + rgb: { 2308 + type: 'object', 2309 + required: ['r', 'g', 'b'], 2310 + properties: { 2311 + r: { 2312 + type: 'integer', 2313 + minimum: 0, 2314 + maximum: 255, 2315 + }, 2316 + g: { 2317 + type: 'integer', 2318 + minimum: 0, 2319 + maximum: 255, 2320 + }, 2321 + b: { 2322 + type: 'integer', 2323 + minimum: 0, 2324 + maximum: 255, 2325 + }, 2326 + }, 2327 + }, 2328 + rgba: { 2329 + type: 'object', 2330 + required: ['r', 'g', 'b', 'a'], 2331 + properties: { 2332 + r: { 2333 + type: 'integer', 2334 + minimum: 0, 2335 + maximum: 255, 2336 + }, 2337 + g: { 2338 + type: 'integer', 2339 + minimum: 0, 2340 + maximum: 255, 2341 + }, 2342 + b: { 2343 + type: 'integer', 2344 + minimum: 0, 2345 + maximum: 255, 2346 + }, 2347 + a: { 2348 + type: 'integer', 2349 + minimum: 0, 2350 + maximum: 100, 2351 + }, 2352 + }, 2353 + }, 2354 + }, 2355 + }, 2085 2356 } as const satisfies Record<string, LexiconDoc> 2086 2357 export const schemas = Object.values(schemaDict) satisfies LexiconDoc[] 2087 2358 export const lexicons: Lexicons = new Lexicons(schemas) ··· 2144 2415 PubLeafletBlocksUnorderedList: 'pub.leaflet.blocks.unorderedList', 2145 2416 PubLeafletBlocksWebsite: 'pub.leaflet.blocks.website', 2146 2417 PubLeafletComment: 'pub.leaflet.comment', 2418 + PubLeafletContent: 'pub.leaflet.content', 2147 2419 PubLeafletDocument: 'pub.leaflet.document', 2148 2420 PubLeafletGraphSubscription: 'pub.leaflet.graph.subscription', 2149 2421 PubLeafletPagesCanvas: 'pub.leaflet.pages.canvas', ··· 2154 2426 PubLeafletRichtextFacet: 'pub.leaflet.richtext.facet', 2155 2427 PubLeafletThemeBackgroundImage: 'pub.leaflet.theme.backgroundImage', 2156 2428 PubLeafletThemeColor: 'pub.leaflet.theme.color', 2429 + SiteStandardDocument: 'site.standard.document', 2430 + SiteStandardGraphSubscription: 'site.standard.graph.subscription', 2431 + SiteStandardPublication: 'site.standard.publication', 2432 + SiteStandardThemeBasic: 'site.standard.theme.basic', 2433 + SiteStandardThemeColor: 'site.standard.theme.color', 2157 2434 } as const
+33
lexicons/api/types/pub/leaflet/content.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../lexicons' 7 + import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 + import type * as PubLeafletPagesLinearDocument from './pages/linearDocument' 9 + import type * as PubLeafletPagesCanvas from './pages/canvas' 10 + 11 + const is$typed = _is$typed, 12 + validate = _validate 13 + const id = 'pub.leaflet.content' 14 + 15 + /** Content format for leaflet documents */ 16 + export interface Main { 17 + $type?: 'pub.leaflet.content' 18 + pages: ( 19 + | $Typed<PubLeafletPagesLinearDocument.Main> 20 + | $Typed<PubLeafletPagesCanvas.Main> 21 + | { $type: string } 22 + )[] 23 + } 24 + 25 + const hashMain = 'main' 26 + 27 + export function isMain<V>(v: V) { 28 + return is$typed(v, id, hashMain) 29 + } 30 + 31 + export function validateMain<V>(v: V) { 32 + return validate<Main & V>(v, id, hashMain) 33 + }
+43
lexicons/api/types/site/standard/document.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../lexicons' 7 + import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 + import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef' 9 + import type * as PubLeafletContent from '../../pub/leaflet/content' 10 + import type * as PubLeafletPublication from '../../pub/leaflet/publication' 11 + 12 + const is$typed = _is$typed, 13 + validate = _validate 14 + const id = 'site.standard.document' 15 + 16 + export interface Record { 17 + $type: 'site.standard.document' 18 + bskyPostRef?: ComAtprotoRepoStrongRef.Main 19 + content?: $Typed<PubLeafletContent.Main> | { $type: string } 20 + coverImage?: BlobRef 21 + description?: string 22 + /** combine with the publication url or the document site to construct a full url to the document */ 23 + path?: string 24 + publishedAt: string 25 + /** 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. */ 26 + site: string 27 + tags?: string[] 28 + textContent?: string 29 + theme?: PubLeafletPublication.Theme 30 + title: string 31 + updatedAt?: string 32 + [k: string]: unknown 33 + } 34 + 35 + const hashRecord = 'main' 36 + 37 + export function isRecord<V>(v: V) { 38 + return is$typed(v, id, hashRecord) 39 + } 40 + 41 + export function validateRecord<V>(v: V) { 42 + return validate<Record & V>(v, id, hashRecord, true) 43 + }
+31
lexicons/api/types/site/standard/graph/subscription.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'site.standard.graph.subscription' 16 + 17 + export interface Record { 18 + $type: 'site.standard.graph.subscription' 19 + publication: string 20 + [k: string]: unknown 21 + } 22 + 23 + const hashRecord = 'main' 24 + 25 + export function isRecord<V>(v: V) { 26 + return is$typed(v, id, hashRecord) 27 + } 28 + 29 + export function validateRecord<V>(v: V) { 30 + return validate<Record & V>(v, id, hashRecord, true) 31 + }
+53
lexicons/api/types/site/standard/publication.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../lexicons' 7 + import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 + import type * as SiteStandardThemeBasic from './theme/basic' 9 + import type * as PubLeafletPublication from '../../pub/leaflet/publication' 10 + 11 + const is$typed = _is$typed, 12 + validate = _validate 13 + const id = 'site.standard.publication' 14 + 15 + export interface Record { 16 + $type: 'site.standard.publication' 17 + basicTheme?: SiteStandardThemeBasic.Main 18 + theme?: PubLeafletPublication.Theme 19 + description?: string 20 + icon?: BlobRef 21 + name: string 22 + preferences?: Preferences 23 + url: string 24 + [k: string]: unknown 25 + } 26 + 27 + const hashRecord = 'main' 28 + 29 + export function isRecord<V>(v: V) { 30 + return is$typed(v, id, hashRecord) 31 + } 32 + 33 + export function validateRecord<V>(v: V) { 34 + return validate<Record & V>(v, id, hashRecord, true) 35 + } 36 + 37 + export interface Preferences { 38 + $type?: 'site.standard.publication#preferences' 39 + showInDiscover: boolean 40 + showComments: boolean 41 + showMentions: boolean 42 + showPrevNext: boolean 43 + } 44 + 45 + const hashPreferences = 'preferences' 46 + 47 + export function isPreferences<V>(v: V) { 48 + return is$typed(v, id, hashPreferences) 49 + } 50 + 51 + export function validatePreferences<V>(v: V) { 52 + return validate<Preferences & V>(v, id, hashPreferences) 53 + }
+34
lexicons/api/types/site/standard/theme/basic.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + import type * as SiteStandardThemeColor from './color' 13 + 14 + const is$typed = _is$typed, 15 + validate = _validate 16 + const id = 'site.standard.theme.basic' 17 + 18 + export interface Main { 19 + $type?: 'site.standard.theme.basic' 20 + accent: $Typed<SiteStandardThemeColor.Rgb> | { $type: string } 21 + accentForeground: $Typed<SiteStandardThemeColor.Rgb> | { $type: string } 22 + background: $Typed<SiteStandardThemeColor.Rgb> | { $type: string } 23 + foreground: $Typed<SiteStandardThemeColor.Rgb> | { $type: string } 24 + } 25 + 26 + const hashMain = 'main' 27 + 28 + export function isMain<V>(v: V) { 29 + return is$typed(v, id, hashMain) 30 + } 31 + 32 + export function validateMain<V>(v: V) { 33 + return validate<Main & V>(v, id, hashMain) 34 + }
+50
lexicons/api/types/site/standard/theme/color.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'site.standard.theme.color' 16 + 17 + export interface Rgb { 18 + $type?: 'site.standard.theme.color#rgb' 19 + r: number 20 + g: number 21 + b: number 22 + } 23 + 24 + const hashRgb = 'rgb' 25 + 26 + export function isRgb<V>(v: V) { 27 + return is$typed(v, id, hashRgb) 28 + } 29 + 30 + export function validateRgb<V>(v: V) { 31 + return validate<Rgb & V>(v, id, hashRgb) 32 + } 33 + 34 + export interface Rgba { 35 + $type?: 'site.standard.theme.color#rgba' 36 + r: number 37 + g: number 38 + b: number 39 + a: number 40 + } 41 + 42 + const hashRgba = 'rgba' 43 + 44 + export function isRgba<V>(v: V) { 45 + return is$typed(v, id, hashRgba) 46 + } 47 + 48 + export function validateRgba<V>(v: V) { 49 + return validate<Rgba & V>(v, id, hashRgba) 50 + }
+2
lexicons/build.ts
··· 10 10 import { PubLeafletRichTextFacet } from "./src/facet"; 11 11 import { PubLeafletComment } from "./src/comment"; 12 12 import { PubLeafletAuthFullPermissions } from "./src/authFullPermissions"; 13 + import { PubLeafletContent } from "./src/content"; 13 14 14 15 const outdir = path.join("lexicons", "pub", "leaflet"); 15 16 ··· 20 21 21 22 const lexicons = [ 22 23 PubLeafletDocument, 24 + PubLeafletContent, 23 25 PubLeafletComment, 24 26 PubLeafletRichTextFacet, 25 27 PubLeafletAuthFullPermissions,
+27
lexicons/pub/leaflet/content.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.content", 4 + "revision": 1, 5 + "description": "A lexicon for long form rich media documents", 6 + "defs": { 7 + "main": { 8 + "type": "object", 9 + "description": "Content format for leaflet documents", 10 + "required": [ 11 + "pages" 12 + ], 13 + "properties": { 14 + "pages": { 15 + "type": "array", 16 + "items": { 17 + "type": "union", 18 + "refs": [ 19 + "pub.leaflet.pages.linearDocument", 20 + "pub.leaflet.pages.canvas" 21 + ] 22 + } 23 + } 24 + } 25 + } 26 + } 27 + }
+73
lexicons/site/standard/document.json
··· 1 + { 2 + "defs": { 3 + "main": { 4 + "key": "tid", 5 + "record": { 6 + "properties": { 7 + "bskyPostRef": { 8 + "ref": "com.atproto.repo.strongRef", 9 + "type": "ref" 10 + }, 11 + "content": { 12 + "closed": false, 13 + "refs": ["pub.leaflet.content"], 14 + "type": "union" 15 + }, 16 + "coverImage": { 17 + "accept": ["image/*"], 18 + "maxSize": 1000000, 19 + "type": "blob" 20 + }, 21 + "description": { 22 + "maxGraphemes": 300, 23 + "maxLength": 3000, 24 + "type": "string" 25 + }, 26 + "path": { 27 + "description": "combine with the publication url or the document site to construct a full url to the document", 28 + "type": "string" 29 + }, 30 + "publishedAt": { 31 + "format": "datetime", 32 + "type": "string" 33 + }, 34 + "site": { 35 + "description": "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.", 36 + "format": "uri", 37 + "type": "string" 38 + }, 39 + "tags": { 40 + "items": { 41 + "maxGraphemes": 50, 42 + "maxLength": 100, 43 + "type": "string" 44 + }, 45 + "type": "array" 46 + }, 47 + "textContent": { 48 + "type": "string" 49 + }, 50 + "theme": { 51 + "description": "Theme for standalone documents. For documents in publications, theme is inherited from the publication.", 52 + "ref": "pub.leaflet.publication#theme", 53 + "type": "ref" 54 + }, 55 + "title": { 56 + "maxGraphemes": 128, 57 + "maxLength": 1280, 58 + "type": "string" 59 + }, 60 + "updatedAt": { 61 + "format": "datetime", 62 + "type": "string" 63 + } 64 + }, 65 + "required": ["site", "title", "publishedAt"], 66 + "type": "object" 67 + }, 68 + "type": "record" 69 + } 70 + }, 71 + "id": "site.standard.document", 72 + "lexicon": 1 73 + }
+23
lexicons/site/standard/graph/subscription.json
··· 1 + { 2 + "defs": { 3 + "main": { 4 + "description": "Record declaring a subscription to a publication", 5 + "key": "tid", 6 + "record": { 7 + "properties": { 8 + "publication": { 9 + "format": "at-uri", 10 + "type": "string" 11 + } 12 + }, 13 + "required": [ 14 + "publication" 15 + ], 16 + "type": "object" 17 + }, 18 + "type": "record" 19 + } 20 + }, 21 + "id": "site.standard.graph.subscription", 22 + "lexicon": 1 23 + }
+68
lexicons/site/standard/publication.json
··· 1 + { 2 + "defs": { 3 + "main": { 4 + "key": "tid", 5 + "record": { 6 + "properties": { 7 + "basicTheme": { 8 + "ref": "site.standard.theme.basic", 9 + "type": "ref" 10 + }, 11 + "theme": { 12 + "type": "ref", 13 + "ref": "pub.leaflet.publication#theme" 14 + }, 15 + "description": { 16 + "maxGraphemes": 300, 17 + "maxLength": 3000, 18 + "type": "string" 19 + }, 20 + "icon": { 21 + "accept": ["image/*"], 22 + "maxSize": 1000000, 23 + "type": "blob" 24 + }, 25 + "name": { 26 + "maxGraphemes": 128, 27 + "maxLength": 1280, 28 + "type": "string" 29 + }, 30 + "preferences": { 31 + "ref": "#preferences", 32 + "type": "ref" 33 + }, 34 + "url": { 35 + "format": "uri", 36 + "type": "string" 37 + } 38 + }, 39 + "required": ["url", "name"], 40 + "type": "object" 41 + }, 42 + "type": "record" 43 + }, 44 + "preferences": { 45 + "properties": { 46 + "showInDiscover": { 47 + "default": true, 48 + "type": "boolean" 49 + }, 50 + "showComments": { 51 + "default": true, 52 + "type": "boolean" 53 + }, 54 + "showMentions": { 55 + "default": true, 56 + "type": "boolean" 57 + }, 58 + "showPrevNext": { 59 + "default": false, 60 + "type": "boolean" 61 + } 62 + }, 63 + "type": "object" 64 + } 65 + }, 66 + "id": "site.standard.publication", 67 + "lexicon": 1 68 + }
+41
lexicons/site/standard/theme/basic.json
··· 1 + { 2 + "defs": { 3 + "main": { 4 + "properties": { 5 + "accent": { 6 + "refs": [ 7 + "site.standard.theme.color#rgb" 8 + ], 9 + "type": "union" 10 + }, 11 + "accentForeground": { 12 + "refs": [ 13 + "site.standard.theme.color#rgb" 14 + ], 15 + "type": "union" 16 + }, 17 + "background": { 18 + "refs": [ 19 + "site.standard.theme.color#rgb" 20 + ], 21 + "type": "union" 22 + }, 23 + "foreground": { 24 + "refs": [ 25 + "site.standard.theme.color#rgb" 26 + ], 27 + "type": "union" 28 + } 29 + }, 30 + "required": [ 31 + "background", 32 + "foreground", 33 + "accent", 34 + "accentForeground" 35 + ], 36 + "type": "object" 37 + } 38 + }, 39 + "id": "site.standard.theme.basic", 40 + "lexicon": 1 41 + }
+53
lexicons/site/standard/theme/color.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "site.standard.theme.color", 4 + "defs": { 5 + "rgb": { 6 + "type": "object", 7 + "required": ["r", "g", "b"], 8 + "properties": { 9 + "r": { 10 + "type": "integer", 11 + "minimum": 0, 12 + "maximum": 255 13 + }, 14 + "g": { 15 + "type": "integer", 16 + "minimum": 0, 17 + "maximum": 255 18 + }, 19 + "b": { 20 + "type": "integer", 21 + "minimum": 0, 22 + "maximum": 255 23 + } 24 + } 25 + }, 26 + "rgba": { 27 + "type": "object", 28 + "required": ["r", "g", "b", "a"], 29 + "properties": { 30 + "r": { 31 + "type": "integer", 32 + "minimum": 0, 33 + "maximum": 255 34 + }, 35 + "g": { 36 + "type": "integer", 37 + "minimum": 0, 38 + "maximum": 255 39 + }, 40 + "b": { 41 + "type": "integer", 42 + "minimum": 0, 43 + "maximum": 255 44 + }, 45 + "a": { 46 + "type": "integer", 47 + "minimum": 0, 48 + "maximum": 100 49 + } 50 + } 51 + } 52 + } 53 + }
+29
lexicons/src/content.ts
··· 1 + import { LexiconDoc } from "@atproto/lexicon"; 2 + import { PubLeafletPagesLinearDocument } from "./pages/LinearDocument"; 3 + import { PubLeafletPagesCanvasDocument } from "./pages"; 4 + 5 + export const PubLeafletContent: LexiconDoc = { 6 + lexicon: 1, 7 + id: "pub.leaflet.content", 8 + revision: 1, 9 + description: "A lexicon for long form rich media documents", 10 + defs: { 11 + main: { 12 + type: "object", 13 + description: "Content format for leaflet documents", 14 + required: ["pages"], 15 + properties: { 16 + pages: { 17 + type: "array", 18 + items: { 19 + type: "union", 20 + refs: [ 21 + PubLeafletPagesLinearDocument.id, 22 + PubLeafletPagesCanvasDocument.id, 23 + ], 24 + }, 25 + }, 26 + }, 27 + }, 28 + }, 29 + };
+282
lexicons/src/normalize.ts
··· 1 + /** 2 + * Normalization utilities for converting between pub.leaflet and site.standard lexicon formats. 3 + * 4 + * The standard format (site.standard.*) is used as the canonical representation for 5 + * reading data from the database, while both formats are accepted for storage. 6 + * 7 + * ## Site Field Format 8 + * 9 + * The `site` field in site.standard.document supports two URI formats: 10 + * - AT-URIs (at://did/collection/rkey) - Used when document belongs to an AT Protocol publication 11 + * - HTTPS URLs (https://example.com) - Used for standalone documents or external sites 12 + * 13 + * Both formats are valid and should be handled by consumers. 14 + */ 15 + 16 + import type * as PubLeafletDocument from "../api/types/pub/leaflet/document"; 17 + import type * as PubLeafletPublication from "../api/types/pub/leaflet/publication"; 18 + import type * as PubLeafletContent from "../api/types/pub/leaflet/content"; 19 + import type * as SiteStandardDocument from "../api/types/site/standard/document"; 20 + import type * as SiteStandardPublication from "../api/types/site/standard/publication"; 21 + import type * as SiteStandardThemeBasic from "../api/types/site/standard/theme/basic"; 22 + import type * as PubLeafletThemeColor from "../api/types/pub/leaflet/theme/color"; 23 + import type { $Typed } from "../api/util"; 24 + import { AtUri } from "@atproto/syntax"; 25 + 26 + // Normalized document type - uses the generated site.standard.document type 27 + // with an additional optional theme field for backwards compatibility 28 + export type NormalizedDocument = SiteStandardDocument.Record & { 29 + // Keep the original theme for components that need leaflet-specific styling 30 + theme?: PubLeafletPublication.Theme; 31 + }; 32 + 33 + // Normalized publication type - uses the generated site.standard.publication type 34 + export type NormalizedPublication = SiteStandardPublication.Record; 35 + 36 + /** 37 + * Checks if the record is a pub.leaflet.document 38 + */ 39 + export function isLeafletDocument( 40 + record: unknown 41 + ): record is PubLeafletDocument.Record { 42 + if (!record || typeof record !== "object") return false; 43 + const r = record as Record<string, unknown>; 44 + return ( 45 + r.$type === "pub.leaflet.document" || 46 + // Legacy records without $type but with pages array 47 + (Array.isArray(r.pages) && typeof r.author === "string") 48 + ); 49 + } 50 + 51 + /** 52 + * Checks if the record is a site.standard.document 53 + */ 54 + export function isStandardDocument( 55 + record: unknown 56 + ): record is SiteStandardDocument.Record { 57 + if (!record || typeof record !== "object") return false; 58 + const r = record as Record<string, unknown>; 59 + return r.$type === "site.standard.document"; 60 + } 61 + 62 + /** 63 + * Checks if the record is a pub.leaflet.publication 64 + */ 65 + export function isLeafletPublication( 66 + record: unknown 67 + ): record is PubLeafletPublication.Record { 68 + if (!record || typeof record !== "object") return false; 69 + const r = record as Record<string, unknown>; 70 + return ( 71 + r.$type === "pub.leaflet.publication" || 72 + // Legacy records without $type but with name and no url 73 + (typeof r.name === "string" && !("url" in r)) 74 + ); 75 + } 76 + 77 + /** 78 + * Checks if the record is a site.standard.publication 79 + */ 80 + export function isStandardPublication( 81 + record: unknown 82 + ): record is SiteStandardPublication.Record { 83 + if (!record || typeof record !== "object") return false; 84 + const r = record as Record<string, unknown>; 85 + return r.$type === "site.standard.publication"; 86 + } 87 + 88 + /** 89 + * Extracts RGB values from a color union type 90 + */ 91 + function extractRgb( 92 + color: 93 + | $Typed<PubLeafletThemeColor.Rgba> 94 + | $Typed<PubLeafletThemeColor.Rgb> 95 + | { $type: string } 96 + | undefined 97 + ): { r: number; g: number; b: number } | undefined { 98 + if (!color || typeof color !== "object") return undefined; 99 + const c = color as Record<string, unknown>; 100 + if ( 101 + typeof c.r === "number" && 102 + typeof c.g === "number" && 103 + typeof c.b === "number" 104 + ) { 105 + return { r: c.r, g: c.g, b: c.b }; 106 + } 107 + return undefined; 108 + } 109 + 110 + /** 111 + * Converts a pub.leaflet theme to a site.standard.theme.basic format 112 + */ 113 + export function leafletThemeToBasicTheme( 114 + theme: PubLeafletPublication.Theme | undefined 115 + ): SiteStandardThemeBasic.Main | undefined { 116 + if (!theme) return undefined; 117 + 118 + const background = extractRgb(theme.backgroundColor); 119 + const accent = extractRgb(theme.accentBackground) || extractRgb(theme.primary); 120 + const accentForeground = extractRgb(theme.accentText); 121 + 122 + // If we don't have the required colors, return undefined 123 + if (!background || !accent) return undefined; 124 + 125 + // Default foreground to dark if not specified 126 + const foreground = { r: 0, g: 0, b: 0 }; 127 + 128 + // Default accent foreground to white if not specified 129 + const finalAccentForeground = accentForeground || { r: 255, g: 255, b: 255 }; 130 + 131 + return { 132 + $type: "site.standard.theme.basic", 133 + background: { $type: "site.standard.theme.color#rgb", ...background }, 134 + foreground: { $type: "site.standard.theme.color#rgb", ...foreground }, 135 + accent: { $type: "site.standard.theme.color#rgb", ...accent }, 136 + accentForeground: { 137 + $type: "site.standard.theme.color#rgb", 138 + ...finalAccentForeground, 139 + }, 140 + }; 141 + } 142 + 143 + /** 144 + * Normalizes a document record from either format to the standard format. 145 + * 146 + * @param record - The document record from the database (either pub.leaflet or site.standard) 147 + * @param uri - Optional document URI, used to extract the rkey for the path field when normalizing pub.leaflet records 148 + * @returns A normalized document in site.standard format, or null if invalid/unrecognized 149 + */ 150 + export function normalizeDocument(record: unknown, uri?: string): NormalizedDocument | null { 151 + if (!record || typeof record !== "object") return null; 152 + 153 + // Pass through site.standard records directly (theme is already in correct format if present) 154 + if (isStandardDocument(record)) { 155 + return { 156 + ...record, 157 + theme: record.theme, 158 + } as NormalizedDocument; 159 + } 160 + 161 + if (isLeafletDocument(record)) { 162 + // Convert from pub.leaflet to site.standard 163 + const publishedAt = record.publishedAt; 164 + 165 + if (!publishedAt) { 166 + return null; 167 + } 168 + 169 + // For standalone documents (no publication), construct a site URL from the author 170 + // This matches the pattern used in publishToPublication.ts for new standalone docs 171 + const site = record.publication || `https://leaflet.pub/p/${record.author}`; 172 + 173 + // Extract path from URI if available 174 + const path = uri ? new AtUri(uri).rkey : undefined; 175 + 176 + // Wrap pages in pub.leaflet.content structure 177 + const content: $Typed<PubLeafletContent.Main> | undefined = record.pages 178 + ? { 179 + $type: "pub.leaflet.content" as const, 180 + pages: record.pages, 181 + } 182 + : undefined; 183 + 184 + return { 185 + $type: "site.standard.document", 186 + title: record.title, 187 + site, 188 + path, 189 + publishedAt, 190 + description: record.description, 191 + tags: record.tags, 192 + coverImage: record.coverImage, 193 + bskyPostRef: record.postRef, 194 + content, 195 + theme: record.theme, 196 + }; 197 + } 198 + 199 + return null; 200 + } 201 + 202 + /** 203 + * Normalizes a publication record from either format to the standard format. 204 + * 205 + * @param record - The publication record from the database (either pub.leaflet or site.standard) 206 + * @returns A normalized publication in site.standard format, or null if invalid/unrecognized 207 + */ 208 + export function normalizePublication( 209 + record: unknown 210 + ): NormalizedPublication | null { 211 + if (!record || typeof record !== "object") return null; 212 + 213 + // Pass through site.standard records directly 214 + if (isStandardPublication(record)) { 215 + return record; 216 + } 217 + 218 + if (isLeafletPublication(record)) { 219 + // Convert from pub.leaflet to site.standard 220 + const url = record.base_path ? `https://${record.base_path}` : undefined; 221 + 222 + if (!url) { 223 + return null; 224 + } 225 + 226 + const basicTheme = leafletThemeToBasicTheme(record.theme); 227 + 228 + // Convert preferences to site.standard format (strip/replace $type) 229 + const preferences: SiteStandardPublication.Preferences | undefined = 230 + record.preferences 231 + ? { 232 + showInDiscover: record.preferences.showInDiscover, 233 + showComments: record.preferences.showComments, 234 + showMentions: record.preferences.showMentions, 235 + showPrevNext: record.preferences.showPrevNext, 236 + } 237 + : undefined; 238 + 239 + return { 240 + $type: "site.standard.publication", 241 + name: record.name, 242 + url, 243 + description: record.description, 244 + icon: record.icon, 245 + basicTheme, 246 + theme: record.theme, 247 + preferences, 248 + }; 249 + } 250 + 251 + return null; 252 + } 253 + 254 + /** 255 + * Type guard to check if a normalized document has leaflet content 256 + */ 257 + export function hasLeafletContent( 258 + doc: NormalizedDocument 259 + ): doc is NormalizedDocument & { 260 + content: $Typed<PubLeafletContent.Main>; 261 + } { 262 + return ( 263 + doc.content !== undefined && 264 + (doc.content as { $type?: string }).$type === "pub.leaflet.content" 265 + ); 266 + } 267 + 268 + /** 269 + * Gets the pages array from a normalized document, handling both formats 270 + */ 271 + export function getDocumentPages( 272 + doc: NormalizedDocument 273 + ): PubLeafletContent.Main["pages"] | undefined { 274 + if (!doc.content) return undefined; 275 + 276 + if (hasLeafletContent(doc)) { 277 + return doc.content.pages; 278 + } 279 + 280 + // Unknown content type 281 + return undefined; 282 + }
+403 -29
package-lock.json
··· 16 16 "@atproto/oauth-client-node": "^0.3.8", 17 17 "@atproto/sync": "^0.1.34", 18 18 "@atproto/syntax": "^0.3.3", 19 + "@atproto/tap": "^0.1.1", 19 20 "@atproto/xrpc": "^0.7.5", 20 21 "@atproto/xrpc-server": "^0.9.5", 21 22 "@hono/node-server": "^1.14.3", ··· 264 265 } 265 266 }, 266 267 "node_modules/@atproto/common-web": { 267 - "version": "0.4.3", 268 - "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.3.tgz", 269 - "integrity": "sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg==", 268 + "version": "0.4.10", 269 + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.10.tgz", 270 + "integrity": "sha512-TLDZSgSKzT8ZgOrBrTGK87J1CXve9TEuY6NVVUBRkOMzRRtQzpFb9/ih5WVS/hnaWVvE30CfuyaetRoma+WKNw==", 270 271 "license": "MIT", 271 272 "dependencies": { 272 - "graphemer": "^1.4.0", 273 - "multiformats": "^9.9.0", 274 - "uint8arrays": "3.0.0", 273 + "@atproto/lex-data": "0.0.6", 274 + "@atproto/lex-json": "0.0.6", 275 275 "zod": "^3.23.8" 276 276 } 277 277 }, 278 - "node_modules/@atproto/common-web/node_modules/multiformats": { 279 - "version": "9.9.0", 280 - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 281 - "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 282 - "license": "(Apache-2.0 AND MIT)" 283 - }, 284 278 "node_modules/@atproto/common/node_modules/multiformats": { 285 279 "version": "9.9.0", 286 280 "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", ··· 288 282 "license": "(Apache-2.0 AND MIT)" 289 283 }, 290 284 "node_modules/@atproto/crypto": { 291 - "version": "0.4.4", 292 - "resolved": "https://registry.npmjs.org/@atproto/crypto/-/crypto-0.4.4.tgz", 293 - "integrity": "sha512-Yq9+crJ7WQl7sxStVpHgie5Z51R05etaK9DLWYG/7bR5T4bhdcIgF6IfklLShtZwLYdVVj+K15s0BqW9a8PSDA==", 285 + "version": "0.4.5", 286 + "resolved": "https://registry.npmjs.org/@atproto/crypto/-/crypto-0.4.5.tgz", 287 + "integrity": "sha512-n40aKkMoCatP0u9Yvhrdk6fXyOHFDDbkdm4h4HCyWW+KlKl8iXfD5iV+ECq+w5BM+QH25aIpt3/j6EUNerhLxw==", 294 288 "license": "MIT", 295 289 "dependencies": { 296 290 "@noble/curves": "^1.7.0", ··· 360 354 "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 361 355 "license": "(Apache-2.0 AND MIT)" 362 356 }, 357 + "node_modules/@atproto/lex": { 358 + "version": "0.0.9", 359 + "resolved": "https://registry.npmjs.org/@atproto/lex/-/lex-0.0.9.tgz", 360 + "integrity": "sha512-o6gauf1lz0iyzJR0rqSj4VHOrO+Nt8+/iPb0KPojw1ieXk13zOSTSxotAoDzO/dP6y8Ey5jxwuCQGuzab/4XnQ==", 361 + "license": "MIT", 362 + "dependencies": { 363 + "@atproto/lex-builder": "0.0.9", 364 + "@atproto/lex-client": "0.0.7", 365 + "@atproto/lex-data": "0.0.6", 366 + "@atproto/lex-installer": "0.0.9", 367 + "@atproto/lex-json": "0.0.6", 368 + "@atproto/lex-schema": "0.0.7", 369 + "tslib": "^2.8.1", 370 + "yargs": "^17.0.0" 371 + }, 372 + "bin": { 373 + "lex": "bin/lex", 374 + "ts-lex": "bin/lex" 375 + } 376 + }, 377 + "node_modules/@atproto/lex-builder": { 378 + "version": "0.0.9", 379 + "resolved": "https://registry.npmjs.org/@atproto/lex-builder/-/lex-builder-0.0.9.tgz", 380 + "integrity": "sha512-buOFk1JpuW3twI7To7f/67zQQ1NulLHf/oasH/kTOPUAd0dNyeAa13t9eRSVGbwi0BcZYxRxBm0QzPmdLKyuyw==", 381 + "license": "MIT", 382 + "dependencies": { 383 + "@atproto/lex-document": "0.0.8", 384 + "@atproto/lex-schema": "0.0.7", 385 + "prettier": "^3.2.5", 386 + "ts-morph": "^27.0.0", 387 + "tslib": "^2.8.1" 388 + } 389 + }, 390 + "node_modules/@atproto/lex-builder/node_modules/@ts-morph/common": { 391 + "version": "0.28.1", 392 + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.28.1.tgz", 393 + "integrity": "sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==", 394 + "license": "MIT", 395 + "dependencies": { 396 + "minimatch": "^10.0.1", 397 + "path-browserify": "^1.0.1", 398 + "tinyglobby": "^0.2.14" 399 + } 400 + }, 401 + "node_modules/@atproto/lex-builder/node_modules/minimatch": { 402 + "version": "10.1.1", 403 + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", 404 + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", 405 + "license": "BlueOak-1.0.0", 406 + "dependencies": { 407 + "@isaacs/brace-expansion": "^5.0.0" 408 + }, 409 + "engines": { 410 + "node": "20 || >=22" 411 + }, 412 + "funding": { 413 + "url": "https://github.com/sponsors/isaacs" 414 + } 415 + }, 416 + "node_modules/@atproto/lex-builder/node_modules/ts-morph": { 417 + "version": "27.0.2", 418 + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-27.0.2.tgz", 419 + "integrity": "sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w==", 420 + "license": "MIT", 421 + "dependencies": { 422 + "@ts-morph/common": "~0.28.1", 423 + "code-block-writer": "^13.0.3" 424 + } 425 + }, 426 + "node_modules/@atproto/lex-cbor": { 427 + "version": "0.0.6", 428 + "resolved": "https://registry.npmjs.org/@atproto/lex-cbor/-/lex-cbor-0.0.6.tgz", 429 + "integrity": "sha512-lee2T00owDy3I1plRHuURT6f98NIpYZZr2wXa5pJZz5JzefZ+nv8gJ2V70C2f+jmSG+5S9NTIy4uJw94vaHf4A==", 430 + "license": "MIT", 431 + "dependencies": { 432 + "@atproto/lex-data": "0.0.6", 433 + "multiformats": "^9.9.0", 434 + "tslib": "^2.8.1" 435 + } 436 + }, 437 + "node_modules/@atproto/lex-cbor/node_modules/multiformats": { 438 + "version": "9.9.0", 439 + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 440 + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 441 + "license": "(Apache-2.0 AND MIT)" 442 + }, 363 443 "node_modules/@atproto/lex-cli": { 364 444 "version": "0.9.5", 365 445 "resolved": "https://registry.npmjs.org/@atproto/lex-cli/-/lex-cli-0.9.5.tgz", ··· 390 470 "dev": true, 391 471 "license": "MIT" 392 472 }, 473 + "node_modules/@atproto/lex-client": { 474 + "version": "0.0.7", 475 + "resolved": "https://registry.npmjs.org/@atproto/lex-client/-/lex-client-0.0.7.tgz", 476 + "integrity": "sha512-ofUz3yXJ0nN/M9aqqF2ZUL/4D1wWT1P4popCfV3OEDsDrtWofMflYPFz1IWuyPa2e83paaEHRhaw3bZEhgXH1w==", 477 + "license": "MIT", 478 + "dependencies": { 479 + "@atproto/lex-data": "0.0.6", 480 + "@atproto/lex-json": "0.0.6", 481 + "@atproto/lex-schema": "0.0.7", 482 + "tslib": "^2.8.1" 483 + } 484 + }, 485 + "node_modules/@atproto/lex-data": { 486 + "version": "0.0.6", 487 + "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.6.tgz", 488 + "integrity": "sha512-MBNB4ghRJQzuXK1zlUPljpPbQcF1LZ5dzxy274KqPt4p3uPuRw0mHjgcCoWzRUNBQC685WMQR4IN9DHtsnG57A==", 489 + "license": "MIT", 490 + "dependencies": { 491 + "@atproto/syntax": "0.4.2", 492 + "multiformats": "^9.9.0", 493 + "tslib": "^2.8.1", 494 + "uint8arrays": "3.0.0", 495 + "unicode-segmenter": "^0.14.0" 496 + } 497 + }, 498 + "node_modules/@atproto/lex-data/node_modules/@atproto/syntax": { 499 + "version": "0.4.2", 500 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", 501 + "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", 502 + "license": "MIT" 503 + }, 504 + "node_modules/@atproto/lex-data/node_modules/multiformats": { 505 + "version": "9.9.0", 506 + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 507 + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 508 + "license": "(Apache-2.0 AND MIT)" 509 + }, 510 + "node_modules/@atproto/lex-document": { 511 + "version": "0.0.8", 512 + "resolved": "https://registry.npmjs.org/@atproto/lex-document/-/lex-document-0.0.8.tgz", 513 + "integrity": "sha512-p3l5h96Hx0vxUwbO/eas6x5h2vU0JVN1a/ktX4k3PlK9YLXfWMFsv+RdVwVZom8o0irHwlcyh1D/cY0PyUojDA==", 514 + "license": "MIT", 515 + "dependencies": { 516 + "@atproto/lex-schema": "0.0.7", 517 + "core-js": "^3", 518 + "tslib": "^2.8.1" 519 + } 520 + }, 521 + "node_modules/@atproto/lex-installer": { 522 + "version": "0.0.9", 523 + "resolved": "https://registry.npmjs.org/@atproto/lex-installer/-/lex-installer-0.0.9.tgz", 524 + "integrity": "sha512-zEeIeSaSCb3j+zNsqqMY7+X5FO6fxy/MafaCEj42KsXQHNcobuygZsnG/0fxMj/kMvhjrNUCp/w9PyOMwx4hQg==", 525 + "license": "MIT", 526 + "dependencies": { 527 + "@atproto/lex-builder": "0.0.9", 528 + "@atproto/lex-cbor": "0.0.6", 529 + "@atproto/lex-data": "0.0.6", 530 + "@atproto/lex-document": "0.0.8", 531 + "@atproto/lex-resolver": "0.0.8", 532 + "@atproto/lex-schema": "0.0.7", 533 + "@atproto/syntax": "0.4.2", 534 + "tslib": "^2.8.1" 535 + } 536 + }, 537 + "node_modules/@atproto/lex-installer/node_modules/@atproto/syntax": { 538 + "version": "0.4.2", 539 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", 540 + "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", 541 + "license": "MIT" 542 + }, 543 + "node_modules/@atproto/lex-json": { 544 + "version": "0.0.6", 545 + "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.6.tgz", 546 + "integrity": "sha512-EILnN5cditPvf+PCNjXt7reMuzjugxAL1fpSzmzJbEMGMUwxOf5pPWxRsaA/M3Boip4NQZ+6DVrPOGUMlnqceg==", 547 + "license": "MIT", 548 + "dependencies": { 549 + "@atproto/lex-data": "0.0.6", 550 + "tslib": "^2.8.1" 551 + } 552 + }, 553 + "node_modules/@atproto/lex-resolver": { 554 + "version": "0.0.8", 555 + "resolved": "https://registry.npmjs.org/@atproto/lex-resolver/-/lex-resolver-0.0.8.tgz", 556 + "integrity": "sha512-4hXT560+k5BIttouuhXOr+UkhAuFvvkJaVdqYb8vx2Ez7eHPiZ+yWkUK6FKpyGsx2whHkJzgleEA6DNWtdDlWA==", 557 + "license": "MIT", 558 + "dependencies": { 559 + "@atproto-labs/did-resolver": "0.2.5", 560 + "@atproto/crypto": "0.4.5", 561 + "@atproto/lex-client": "0.0.7", 562 + "@atproto/lex-data": "0.0.6", 563 + "@atproto/lex-document": "0.0.8", 564 + "@atproto/lex-schema": "0.0.7", 565 + "@atproto/repo": "0.8.12", 566 + "@atproto/syntax": "0.4.2", 567 + "tslib": "^2.8.1" 568 + } 569 + }, 570 + "node_modules/@atproto/lex-resolver/node_modules/@atproto-labs/did-resolver": { 571 + "version": "0.2.5", 572 + "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.2.5.tgz", 573 + "integrity": "sha512-he7EC6OMSifNs01a4RT9mta/yYitoKDzlK9ty2TFV5Uj/+HpB4vYMRdIDFrRW0Hcsehy90E2t/dw0t7361MEKQ==", 574 + "license": "MIT", 575 + "dependencies": { 576 + "@atproto-labs/fetch": "0.2.3", 577 + "@atproto-labs/pipe": "0.1.1", 578 + "@atproto-labs/simple-store": "0.3.0", 579 + "@atproto-labs/simple-store-memory": "0.1.4", 580 + "@atproto/did": "0.2.4", 581 + "zod": "^3.23.8" 582 + } 583 + }, 584 + "node_modules/@atproto/lex-resolver/node_modules/@atproto/did": { 585 + "version": "0.2.4", 586 + "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.2.4.tgz", 587 + "integrity": "sha512-nxNiCgXeo7pfjojq9fpfZxCO0X0xUipNVKW+AHNZwQKiUDt6zYL0VXEfm8HBUwQOCmKvj2pRRSM1Cur+tUWk3g==", 588 + "license": "MIT", 589 + "dependencies": { 590 + "zod": "^3.23.8" 591 + } 592 + }, 593 + "node_modules/@atproto/lex-resolver/node_modules/@atproto/syntax": { 594 + "version": "0.4.2", 595 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", 596 + "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", 597 + "license": "MIT" 598 + }, 599 + "node_modules/@atproto/lex-schema": { 600 + "version": "0.0.7", 601 + "resolved": "https://registry.npmjs.org/@atproto/lex-schema/-/lex-schema-0.0.7.tgz", 602 + "integrity": "sha512-/7HkTUsnP1rlzmVE6nnY0kl/hydL/W8V29V8BhFwdAvdDKpYcdRgzzsMe38LAt+ZOjHknRCZDIKGsbQMSbJErw==", 603 + "license": "MIT", 604 + "dependencies": { 605 + "@atproto/lex-data": "0.0.6", 606 + "@atproto/syntax": "0.4.2", 607 + "tslib": "^2.8.1" 608 + } 609 + }, 610 + "node_modules/@atproto/lex-schema/node_modules/@atproto/syntax": { 611 + "version": "0.4.2", 612 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", 613 + "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", 614 + "license": "MIT" 615 + }, 393 616 "node_modules/@atproto/lexicon": { 394 617 "version": "0.5.1", 395 618 "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.5.1.tgz", ··· 472 695 } 473 696 }, 474 697 "node_modules/@atproto/repo": { 475 - "version": "0.8.9", 476 - "resolved": "https://registry.npmjs.org/@atproto/repo/-/repo-0.8.9.tgz", 477 - "integrity": "sha512-FTePZS2KEv8++pkOB8GGvm46V6uJqd/95bPA1cXTDXyw0cqeVEOItfxkCH1ky/fY71QYr0NkmqMUwuwZ/gwEtQ==", 698 + "version": "0.8.12", 699 + "resolved": "https://registry.npmjs.org/@atproto/repo/-/repo-0.8.12.tgz", 700 + "integrity": "sha512-QpVTVulgfz5PUiCTELlDBiRvnsnwrFWi+6CfY88VwXzrRHd9NE8GItK7sfxQ6U65vD/idH8ddCgFrlrsn1REPQ==", 478 701 "license": "MIT", 479 702 "dependencies": { 480 - "@atproto/common": "^0.4.12", 481 - "@atproto/common-web": "^0.4.3", 482 - "@atproto/crypto": "^0.4.4", 483 - "@atproto/lexicon": "^0.5.1", 703 + "@atproto/common": "^0.5.3", 704 + "@atproto/common-web": "^0.4.7", 705 + "@atproto/crypto": "^0.4.5", 706 + "@atproto/lexicon": "^0.6.0", 484 707 "@ipld/dag-cbor": "^7.0.0", 485 708 "multiformats": "^9.9.0", 486 709 "uint8arrays": "3.0.0", ··· 491 714 "node": ">=18.7.0" 492 715 } 493 716 }, 717 + "node_modules/@atproto/repo/node_modules/@atproto/common": { 718 + "version": "0.5.6", 719 + "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.5.6.tgz", 720 + "integrity": "sha512-rbWoZwHQNP8jcwjCREVecchw8aaoM5A1NCONyb9PVDWOJLRLCzojYMeIS8IbFqXo6NyIByOGddupADkkLeVBGQ==", 721 + "license": "MIT", 722 + "dependencies": { 723 + "@atproto/common-web": "^0.4.10", 724 + "@atproto/lex-cbor": "0.0.6", 725 + "@atproto/lex-data": "0.0.6", 726 + "iso-datestring-validator": "^2.2.2", 727 + "multiformats": "^9.9.0", 728 + "pino": "^8.21.0" 729 + }, 730 + "engines": { 731 + "node": ">=18.7.0" 732 + } 733 + }, 734 + "node_modules/@atproto/repo/node_modules/@atproto/lexicon": { 735 + "version": "0.6.0", 736 + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.0.tgz", 737 + "integrity": "sha512-5veb8aD+J5M0qszLJ+73KSFsFrJBgAY/nM1TSAJvGY7fNc9ZAT+PSUlmIyrdye9YznAZ07yktalls/TwNV7cHQ==", 738 + "license": "MIT", 739 + "dependencies": { 740 + "@atproto/common-web": "^0.4.7", 741 + "@atproto/syntax": "^0.4.2", 742 + "iso-datestring-validator": "^2.2.2", 743 + "multiformats": "^9.9.0", 744 + "zod": "^3.23.8" 745 + } 746 + }, 747 + "node_modules/@atproto/repo/node_modules/@atproto/syntax": { 748 + "version": "0.4.2", 749 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", 750 + "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", 751 + "license": "MIT" 752 + }, 494 753 "node_modules/@atproto/repo/node_modules/multiformats": { 495 754 "version": "9.9.0", 496 755 "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", ··· 535 794 "integrity": "sha512-8CNmi5DipOLaVeSMPggMe7FCksVag0aO6XZy9WflbduTKM4dFZVCs4686UeMLfGRXX+X966XgwECHoLYrovMMg==", 536 795 "license": "MIT" 537 796 }, 797 + "node_modules/@atproto/tap": { 798 + "version": "0.1.1", 799 + "resolved": "https://registry.npmjs.org/@atproto/tap/-/tap-0.1.1.tgz", 800 + "integrity": "sha512-gW4NzLOxj74TzaDOVzzzt5kl2PdC0r75XkIpYpI5xobwCfsc/DmVtwpuSw1fW9gr4Vzk2Q90S9UE4ifAFl2gyA==", 801 + "license": "MIT", 802 + "dependencies": { 803 + "@atproto/common": "^0.5.6", 804 + "@atproto/lex": "^0.0.9", 805 + "@atproto/syntax": "^0.4.2", 806 + "@atproto/ws-client": "^0.0.4", 807 + "ws": "^8.12.0", 808 + "zod": "^3.23.8" 809 + }, 810 + "engines": { 811 + "node": ">=18.7.0" 812 + } 813 + }, 814 + "node_modules/@atproto/tap/node_modules/@atproto/common": { 815 + "version": "0.5.6", 816 + "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.5.6.tgz", 817 + "integrity": "sha512-rbWoZwHQNP8jcwjCREVecchw8aaoM5A1NCONyb9PVDWOJLRLCzojYMeIS8IbFqXo6NyIByOGddupADkkLeVBGQ==", 818 + "license": "MIT", 819 + "dependencies": { 820 + "@atproto/common-web": "^0.4.10", 821 + "@atproto/lex-cbor": "0.0.6", 822 + "@atproto/lex-data": "0.0.6", 823 + "iso-datestring-validator": "^2.2.2", 824 + "multiformats": "^9.9.0", 825 + "pino": "^8.21.0" 826 + }, 827 + "engines": { 828 + "node": ">=18.7.0" 829 + } 830 + }, 831 + "node_modules/@atproto/tap/node_modules/@atproto/syntax": { 832 + "version": "0.4.2", 833 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", 834 + "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", 835 + "license": "MIT" 836 + }, 837 + "node_modules/@atproto/tap/node_modules/multiformats": { 838 + "version": "9.9.0", 839 + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 840 + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 841 + "license": "(Apache-2.0 AND MIT)" 842 + }, 843 + "node_modules/@atproto/ws-client": { 844 + "version": "0.0.4", 845 + "resolved": "https://registry.npmjs.org/@atproto/ws-client/-/ws-client-0.0.4.tgz", 846 + "integrity": "sha512-dox1XIymuC7/ZRhUqKezIGgooZS45C6vHCfu0PnWjfvsLCK2kAlnvX4IBkA/WpcoijDhQ9ejChnFbo/sLmgvAg==", 847 + "license": "MIT", 848 + "dependencies": { 849 + "@atproto/common": "^0.5.3", 850 + "ws": "^8.12.0" 851 + }, 852 + "engines": { 853 + "node": ">=18.7.0" 854 + } 855 + }, 856 + "node_modules/@atproto/ws-client/node_modules/@atproto/common": { 857 + "version": "0.5.6", 858 + "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.5.6.tgz", 859 + "integrity": "sha512-rbWoZwHQNP8jcwjCREVecchw8aaoM5A1NCONyb9PVDWOJLRLCzojYMeIS8IbFqXo6NyIByOGddupADkkLeVBGQ==", 860 + "license": "MIT", 861 + "dependencies": { 862 + "@atproto/common-web": "^0.4.10", 863 + "@atproto/lex-cbor": "0.0.6", 864 + "@atproto/lex-data": "0.0.6", 865 + "iso-datestring-validator": "^2.2.2", 866 + "multiformats": "^9.9.0", 867 + "pino": "^8.21.0" 868 + }, 869 + "engines": { 870 + "node": ">=18.7.0" 871 + } 872 + }, 873 + "node_modules/@atproto/ws-client/node_modules/multiformats": { 874 + "version": "9.9.0", 875 + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 876 + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 877 + "license": "(Apache-2.0 AND MIT)" 878 + }, 538 879 "node_modules/@atproto/xrpc": { 539 880 "version": "0.7.5", 540 881 "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.5.tgz", ··· 2553 2894 "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 2554 2895 "license": "(Apache-2.0 AND MIT)" 2555 2896 }, 2897 + "node_modules/@isaacs/balanced-match": { 2898 + "version": "4.0.1", 2899 + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", 2900 + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", 2901 + "license": "MIT", 2902 + "engines": { 2903 + "node": "20 || >=22" 2904 + } 2905 + }, 2906 + "node_modules/@isaacs/brace-expansion": { 2907 + "version": "5.0.0", 2908 + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", 2909 + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", 2910 + "license": "MIT", 2911 + "dependencies": { 2912 + "@isaacs/balanced-match": "^4.0.1" 2913 + }, 2914 + "engines": { 2915 + "node": "20 || >=22" 2916 + } 2917 + }, 2556 2918 "node_modules/@isaacs/fs-minipass": { 2557 2919 "version": "4.0.1", 2558 2920 "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", ··· 9108 9470 "version": "13.0.3", 9109 9471 "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", 9110 9472 "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", 9111 - "dev": true, 9112 9473 "license": "MIT" 9113 9474 }, 9114 9475 "node_modules/collapse-white-space": { ··· 9216 9577 "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", 9217 9578 "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", 9218 9579 "license": "MIT" 9580 + }, 9581 + "node_modules/core-js": { 9582 + "version": "3.47.0", 9583 + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz", 9584 + "integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==", 9585 + "hasInstallScript": true, 9586 + "license": "MIT", 9587 + "funding": { 9588 + "type": "opencollective", 9589 + "url": "https://opencollective.com/core-js" 9590 + } 9219 9591 }, 9220 9592 "node_modules/crelt": { 9221 9593 "version": "1.0.6", ··· 11884 12256 "node_modules/graphemer": { 11885 12257 "version": "1.4.0", 11886 12258 "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", 11887 - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" 12259 + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", 12260 + "dev": true 11888 12261 }, 11889 12262 "node_modules/gzip-size": { 11890 12263 "version": "6.0.0", ··· 15638 16011 "version": "1.0.1", 15639 16012 "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", 15640 16013 "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", 15641 - "dev": true, 15642 16014 "license": "MIT" 15643 16015 }, 15644 16016 "node_modules/path-exists": { ··· 15920 16292 "version": "3.2.5", 15921 16293 "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", 15922 16294 "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", 15923 - "dev": true, 15924 16295 "bin": { 15925 16296 "prettier": "bin/prettier.cjs" 15926 16297 }, ··· 18024 18395 "version": "0.2.15", 18025 18396 "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", 18026 18397 "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", 18027 - "dev": true, 18028 18398 "license": "MIT", 18029 18399 "dependencies": { 18030 18400 "fdir": "^6.5.0", ··· 18041 18411 "version": "6.5.0", 18042 18412 "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", 18043 18413 "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", 18044 - "dev": true, 18045 18414 "license": "MIT", 18046 18415 "engines": { 18047 18416 "node": ">=12.0.0" ··· 18059 18428 "version": "4.0.3", 18060 18429 "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", 18061 18430 "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", 18062 - "dev": true, 18063 18431 "license": "MIT", 18064 18432 "engines": { 18065 18433 "node": ">=12" ··· 18449 18817 "version": "6.21.0", 18450 18818 "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", 18451 18819 "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", 18820 + "license": "MIT" 18821 + }, 18822 + "node_modules/unicode-segmenter": { 18823 + "version": "0.14.5", 18824 + "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz", 18825 + "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==", 18452 18826 "license": "MIT" 18453 18827 }, 18454 18828 "node_modules/unified": {
+2 -1
package.json
··· 7 7 "dev": "TZ=UTC next dev --turbo", 8 8 "publish-lexicons": "tsx lexicons/publish.ts", 9 9 "generate-db-types": "supabase gen types --local > supabase/database.types.ts && drizzle-kit introspect && rm -rf ./drizzle/*.sql ./drizzle/meta", 10 - "lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/document.json ./lexicons/pub/leaflet/comment.json ./lexicons/pub/leaflet/publication.json ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* ./lexicons/app/bsky/*/* --yes && tsx ./lexicons/fix-extensions.ts ./lexicons/api", 10 + "lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/document.json ./lexicons/pub/leaflet/comment.json ./lexicons/pub/leaflet/publication.json ./lexicons/pub/leaflet/content.json ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* ./lexicons/app/bsky/*/* ./lexicons/site/*/* ./lexicons/site/*/*/* --yes && tsx ./lexicons/fix-extensions.ts ./lexicons/api", 11 11 "wrangler-dev": "wrangler dev", 12 12 "build-appview": "esbuild appview/index.ts --outfile=appview/dist/index.js --bundle --platform=node", 13 13 "build-feed-service": "esbuild feeds/index.ts --outfile=feeds/dist/index.js --bundle --platform=node", ··· 26 26 "@atproto/oauth-client-node": "^0.3.8", 27 27 "@atproto/sync": "^0.1.34", 28 28 "@atproto/syntax": "^0.3.3", 29 + "@atproto/tap": "^0.1.1", 29 30 "@atproto/xrpc": "^0.7.5", 30 31 "@atproto/xrpc-server": "^0.9.5", 31 32 "@hono/node-server": "^1.14.3",
+284
patterns/lexicons.md
··· 1 + # Lexicon System 2 + 3 + ## Overview 4 + 5 + Lexicons define the schema for AT Protocol records. This project has two namespaces: 6 + - **`pub.leaflet.*`** - Leaflet-specific lexicons (documents, publications, blocks, etc.) 7 + - **`site.standard.*`** - Standard site lexicons for interoperability 8 + 9 + The lexicons are defined as TypeScript in `lexicons/src/`, built to JSON in `lexicons/pub/leaflet/` and `lexicons/site/standard/`, and TypeScript types are generated in `lexicons/api/`. 10 + 11 + ## Key Files 12 + 13 + - **`lexicons/src/*.ts`** - Source definitions for `pub.leaflet.*` lexicons 14 + - **`lexicons/site/standard/**/*.json`** - JSON definitions for `site.standard.*` lexicons (manually maintained) 15 + - **`lexicons/build.ts`** - Builds TypeScript sources to JSON 16 + - **`lexicons/api/`** - Generated TypeScript types and client 17 + - **`package.json`** - Contains `lexgen` script 18 + 19 + ## Running Lexicon Generation 20 + 21 + ```bash 22 + npm run lexgen 23 + ``` 24 + 25 + This runs: 26 + 1. `tsx ./lexicons/build.ts` - Builds `pub.leaflet.*` JSON from TypeScript 27 + 2. `lex gen-api` - Generates TypeScript types from all JSON lexicons 28 + 3. `tsx ./lexicons/fix-extensions.ts` - Fixes import extensions 29 + 30 + ## Adding a New pub.leaflet Lexicon 31 + 32 + ### 1. Create the Source Definition 33 + 34 + Create a file in `lexicons/src/` (e.g., `lexicons/src/myLexicon.ts`): 35 + 36 + ```typescript 37 + import { LexiconDoc } from "@atproto/lexicon"; 38 + 39 + export const PubLeafletMyLexicon: LexiconDoc = { 40 + lexicon: 1, 41 + id: "pub.leaflet.myLexicon", 42 + defs: { 43 + main: { 44 + type: "record", // or "object" for non-record types 45 + key: "tid", 46 + record: { 47 + type: "object", 48 + required: ["field1"], 49 + properties: { 50 + field1: { type: "string", maxLength: 1000 }, 51 + field2: { type: "integer", minimum: 0 }, 52 + optionalRef: { type: "ref", ref: "other.lexicon#def" }, 53 + }, 54 + }, 55 + }, 56 + // Additional defs for sub-objects 57 + subType: { 58 + type: "object", 59 + properties: { 60 + nested: { type: "string" }, 61 + }, 62 + }, 63 + }, 64 + }; 65 + ``` 66 + 67 + ### 2. Add to Build 68 + 69 + Update `lexicons/build.ts`: 70 + 71 + ```typescript 72 + import { PubLeafletMyLexicon } from "./src/myLexicon"; 73 + 74 + const lexicons = [ 75 + // ... existing lexicons 76 + PubLeafletMyLexicon, 77 + ]; 78 + ``` 79 + 80 + ### 3. Update lexgen Command (if needed) 81 + 82 + If your lexicon is at the top level of `pub/leaflet/` (not in a subdirectory), add it to the `lexgen` script in `package.json`: 83 + 84 + ```json 85 + "lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/document.json ./lexicons/pub/leaflet/myLexicon.json ./lexicons/pub/leaflet/*/* ..." 86 + ``` 87 + 88 + Note: Files in subdirectories (`pub/leaflet/*/*`) are automatically included. 89 + 90 + ### 4. Regenerate Types 91 + 92 + ```bash 93 + npm run lexgen 94 + ``` 95 + 96 + ### 5. Use the Generated Types 97 + 98 + ```typescript 99 + import { PubLeafletMyLexicon } from "lexicons/api"; 100 + 101 + // Type for the record 102 + type MyRecord = PubLeafletMyLexicon.Record; 103 + 104 + // Validation 105 + const result = PubLeafletMyLexicon.validateRecord(data); 106 + if (result.success) { 107 + // result.value is typed 108 + } 109 + 110 + // Type guard 111 + if (PubLeafletMyLexicon.isRecord(data)) { 112 + // data is typed as Record 113 + } 114 + ``` 115 + 116 + ## Adding a New site.standard Lexicon 117 + 118 + ### 1. Create the JSON Definition 119 + 120 + Create a file in `lexicons/site/standard/` (e.g., `lexicons/site/standard/myType.json`): 121 + 122 + ```json 123 + { 124 + "lexicon": 1, 125 + "id": "site.standard.myType", 126 + "defs": { 127 + "main": { 128 + "type": "record", 129 + "key": "tid", 130 + "record": { 131 + "type": "object", 132 + "required": ["field1"], 133 + "properties": { 134 + "field1": { 135 + "type": "string", 136 + "maxLength": 1000 137 + } 138 + } 139 + } 140 + } 141 + } 142 + } 143 + ``` 144 + 145 + ### 2. Regenerate Types 146 + 147 + ```bash 148 + npm run lexgen 149 + ``` 150 + 151 + The `site/*/* site/*/*/*` globs in the lexgen command automatically pick up new files. 152 + 153 + ## Common Lexicon Patterns 154 + 155 + ### Referencing Other Lexicons 156 + 157 + ```typescript 158 + // Reference another lexicon's main def 159 + { type: "ref", ref: "pub.leaflet.publication" } 160 + 161 + // Reference a specific def within a lexicon 162 + { type: "ref", ref: "pub.leaflet.publication#theme" } 163 + 164 + // Reference within the same lexicon 165 + { type: "ref", ref: "#myDef" } 166 + ``` 167 + 168 + ### Union Types 169 + 170 + ```typescript 171 + { 172 + type: "union", 173 + refs: [ 174 + "pub.leaflet.pages.linearDocument", 175 + "pub.leaflet.pages.canvas", 176 + ], 177 + } 178 + 179 + // Open union (allows unknown types) 180 + { 181 + type: "union", 182 + closed: false, // default is true 183 + refs: ["pub.leaflet.content"], 184 + } 185 + ``` 186 + 187 + ### Blob Types (for images/files) 188 + 189 + ```typescript 190 + { 191 + type: "blob", 192 + accept: ["image/*"], // or specific types like ["image/png", "image/jpeg"] 193 + maxSize: 1000000, // bytes 194 + } 195 + ``` 196 + 197 + ### Color Types 198 + 199 + The project has color types defined: 200 + - `pub.leaflet.theme.color#rgb` / `#rgba` 201 + - `site.standard.theme.color#rgb` / `#rgba` 202 + 203 + ```typescript 204 + // In lexicons/src/theme.ts 205 + export const ColorUnion = { 206 + type: "union", 207 + refs: [ 208 + "pub.leaflet.theme.color#rgba", 209 + "pub.leaflet.theme.color#rgb", 210 + ], 211 + }; 212 + ``` 213 + 214 + ## Normalization Between Formats 215 + 216 + Use `lexicons/src/normalize.ts` to convert between `pub.leaflet` and `site.standard` formats: 217 + 218 + ```typescript 219 + import { 220 + normalizeDocument, 221 + normalizePublication, 222 + isLeafletDocument, 223 + isStandardDocument, 224 + getDocumentPages, 225 + } from "lexicons/src/normalize"; 226 + 227 + // Normalize a document from either format 228 + const normalized = normalizeDocument(record); 229 + if (normalized) { 230 + // normalized is always in site.standard.document format 231 + console.log(normalized.title, normalized.site); 232 + 233 + // Get pages if content is pub.leaflet.content 234 + const pages = getDocumentPages(normalized); 235 + } 236 + 237 + // Normalize a publication 238 + const pub = normalizePublication(record); 239 + if (pub) { 240 + console.log(pub.name, pub.url); 241 + } 242 + ``` 243 + 244 + ## Handling in Appview (Firehose Consumer) 245 + 246 + When processing records from the firehose in `appview/index.ts`: 247 + 248 + ```typescript 249 + import { ids } from "lexicons/api/lexicons"; 250 + import { PubLeafletMyLexicon } from "lexicons/api"; 251 + 252 + // In filterCollections: 253 + filterCollections: [ 254 + ids.PubLeafletMyLexicon, 255 + // ... 256 + ], 257 + 258 + // In handleEvent: 259 + if (evt.collection === ids.PubLeafletMyLexicon) { 260 + if (evt.event === "create" || evt.event === "update") { 261 + let record = PubLeafletMyLexicon.validateRecord(evt.record); 262 + if (!record.success) return; 263 + 264 + // Store in database 265 + await supabase.from("my_table").upsert({ 266 + uri: evt.uri.toString(), 267 + data: record.value as Json, 268 + }); 269 + } 270 + if (evt.event === "delete") { 271 + await supabase.from("my_table").delete().eq("uri", evt.uri.toString()); 272 + } 273 + } 274 + ``` 275 + 276 + ## Publishing Lexicons 277 + 278 + To publish lexicons to an AT Protocol PDS: 279 + 280 + ```bash 281 + npm run publish-lexicons 282 + ``` 283 + 284 + This runs `lexicons/publish.ts` which publishes lexicons to the configured PDS.
+37 -4
src/notifications.ts
··· 4 4 import { Tables, TablesInsert } from "supabase/database.types"; 5 5 import { AtUri } from "@atproto/syntax"; 6 6 import { idResolver } from "app/(home-pages)/reader/idResolver"; 7 + import { 8 + normalizeDocumentRecord, 9 + normalizePublicationRecord, 10 + type NormalizedDocument, 11 + type NormalizedPublication, 12 + } from "src/utils/normalizeRecords"; 7 13 8 14 type NotificationRow = Tables<"notifications">; 9 15 ··· 99 105 ? comments?.find((c) => c.uri === notification.data.parent_uri) 100 106 : undefined, 101 107 commentData, 108 + normalizedDocument: normalizeDocumentRecord(commentData.documents?.data, commentData.documents?.uri), 109 + normalizedPublication: normalizePublicationRecord( 110 + commentData.documents?.documents_in_publications[0]?.publications?.record, 111 + ), 102 112 }; 103 113 }) 104 114 .filter((n) => n !== null); ··· 140 150 type: "subscribe" as const, 141 151 subscription_uri: notification.data.subscription_uri, 142 152 subscriptionData, 153 + normalizedPublication: normalizePublicationRecord(subscriptionData.publications?.record), 143 154 }; 144 155 }) 145 156 .filter((n) => n !== null); ··· 187 198 document_uri: notification.data.document_uri, 188 199 bskyPost, 189 200 document, 201 + normalizedDocument: normalizeDocumentRecord(document.data, document.uri), 202 + normalizedPublication: normalizePublicationRecord( 203 + document.documents_in_publications[0]?.publications?.record, 204 + ), 190 205 }; 191 206 }) 192 207 .filter((n) => n !== null); ··· 269 284 const documentCreatorDid = new AtUri(notification.data.document_uri).host; 270 285 const documentCreatorHandle = didToHandleMap.get(documentCreatorDid) ?? null; 271 286 287 + const mentionedPublication = mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined; 288 + const mentionedDoc = mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined; 289 + 272 290 return { 273 291 id: notification.id, 274 292 recipient: notification.recipient, ··· 279 297 mentioned_uri: mentionedUri, 280 298 document, 281 299 documentCreatorHandle, 282 - mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, 283 - mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, 300 + mentionedPublication, 301 + mentionedDocument: mentionedDoc, 302 + normalizedDocument: normalizeDocumentRecord(document.data, document.uri), 303 + normalizedPublication: normalizePublicationRecord( 304 + document.documents_in_publications[0]?.publications?.record, 305 + ), 306 + normalizedMentionedPublication: normalizePublicationRecord(mentionedPublication?.record), 307 + normalizedMentionedDocument: normalizeDocumentRecord(mentionedDoc?.data, mentionedDoc?.uri), 284 308 }; 285 309 }) 286 310 .filter((n) => n !== null); ··· 365 389 const commenterDid = new AtUri(notification.data.comment_uri).host; 366 390 const commenterHandle = didToHandleMap.get(commenterDid) ?? null; 367 391 392 + const mentionedPublication = mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined; 393 + const mentionedDoc = mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined; 394 + 368 395 return { 369 396 id: notification.id, 370 397 recipient: notification.recipient, ··· 375 402 mentioned_uri: mentionedUri, 376 403 commentData, 377 404 commenterHandle, 378 - mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, 379 - mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, 405 + mentionedPublication, 406 + mentionedDocument: mentionedDoc, 407 + normalizedDocument: normalizeDocumentRecord(commentData.documents?.data, commentData.documents?.uri), 408 + normalizedPublication: normalizePublicationRecord( 409 + commentData.documents?.documents_in_publications[0]?.publications?.record, 410 + ), 411 + normalizedMentionedPublication: normalizePublicationRecord(mentionedPublication?.record), 412 + normalizedMentionedDocument: normalizeDocumentRecord(mentionedDoc?.data, mentionedDoc?.uri), 380 413 }; 381 414 }) 382 415 .filter((n) => n !== null);
+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 + }
+30 -20
src/utils/getPublicationMetadataFromLeafletData.ts
··· 1 1 import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 2 2 import { Json } from "supabase/database.types"; 3 3 4 + /** 5 + * Return type for publication metadata extraction. 6 + * Note: `publications.record` and `documents.data` are raw JSON from the database. 7 + * Consumers should use `normalizePublicationRecord()` and `normalizeDocumentRecord()` 8 + * from `src/utils/normalizeRecords` to get properly typed data. 9 + */ 10 + export type PublicationMetadata = { 11 + description: string; 12 + title: string; 13 + leaflet: string; 14 + doc: string | null; 15 + publications: { 16 + identity_did: string; 17 + name: string; 18 + indexed_at: string; 19 + /** Raw record - use normalizePublicationRecord() to get typed data */ 20 + record: Json | null; 21 + uri: string; 22 + } | null; 23 + documents: { 24 + /** Raw data - use normalizeDocumentRecord() to get typed data */ 25 + data: Json; 26 + indexed_at: string; 27 + uri: string; 28 + } | null; 29 + } | null; 30 + 4 31 export function getPublicationMetadataFromLeafletData( 5 32 data?: GetLeafletDataReturnType["result"]["data"], 6 - ) { 33 + ): PublicationMetadata { 7 34 if (!data) return null; 8 35 9 36 let pubData: 10 - | { 11 - description: string; 12 - title: string; 13 - leaflet: string; 14 - doc: string | null; 15 - publications: { 16 - identity_did: string; 17 - name: string; 18 - indexed_at: string; 19 - record: Json | null; 20 - uri: string; 21 - } | null; 22 - documents: { 23 - data: Json; 24 - indexed_at: string; 25 - uri: string; 26 - } | null; 27 - } 37 + | NonNullable<PublicationMetadata> 28 38 | undefined 29 39 | null = 30 40 data?.leaflets_in_publications?.[0] || ··· 46 56 doc: standaloneDoc.document, 47 57 }; 48 58 } 49 - return pubData; 59 + return pubData || null; 50 60 }
+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)}`;
+134
src/utils/normalizeRecords.ts
··· 1 + /** 2 + * Utilities for normalizing pub.leaflet and site.standard records from database queries. 3 + * 4 + * These helpers apply the normalization functions from lexicons/src/normalize.ts 5 + * to database query results, providing properly typed normalized records. 6 + */ 7 + 8 + import { 9 + normalizeDocument, 10 + normalizePublication, 11 + type NormalizedDocument, 12 + type NormalizedPublication, 13 + } from "lexicons/src/normalize"; 14 + import type { Json } from "supabase/database.types"; 15 + 16 + /** 17 + * Normalizes a document record from a database query result. 18 + * Returns the normalized document or null if the record is invalid/unrecognized. 19 + * 20 + * @param data - The document record data from the database 21 + * @param uri - Optional document URI, used to extract the rkey for the path field when normalizing pub.leaflet records 22 + * 23 + * @example 24 + * const doc = normalizeDocumentRecord(dbResult.data, dbResult.uri); 25 + * if (doc) { 26 + * // doc is NormalizedDocument with proper typing 27 + * console.log(doc.title, doc.site, doc.publishedAt); 28 + * } 29 + */ 30 + export function normalizeDocumentRecord( 31 + data: Json | unknown, 32 + uri?: string 33 + ): NormalizedDocument | null { 34 + return normalizeDocument(data, uri); 35 + } 36 + 37 + /** 38 + * Normalizes a publication record from a database query result. 39 + * Returns the normalized publication or null if the record is invalid/unrecognized. 40 + * 41 + * @example 42 + * const pub = normalizePublicationRecord(dbResult.record); 43 + * if (pub) { 44 + * // pub is NormalizedPublication with proper typing 45 + * console.log(pub.name, pub.url); 46 + * } 47 + */ 48 + export function normalizePublicationRecord( 49 + record: Json | unknown 50 + ): NormalizedPublication | null { 51 + return normalizePublication(record); 52 + } 53 + 54 + /** 55 + * Type helper for a document row from the database with normalized data. 56 + * Use this when you need the full row but with typed data. 57 + */ 58 + export type DocumentRowWithNormalizedData< 59 + T extends { data: Json | unknown } 60 + > = Omit<T, "data"> & { 61 + data: NormalizedDocument | null; 62 + }; 63 + 64 + /** 65 + * Type helper for a publication row from the database with normalized record. 66 + * Use this when you need the full row but with typed record. 67 + */ 68 + export type PublicationRowWithNormalizedRecord< 69 + T extends { record: Json | unknown } 70 + > = Omit<T, "record"> & { 71 + record: NormalizedPublication | null; 72 + }; 73 + 74 + /** 75 + * Normalizes a document row in place, returning a properly typed row. 76 + * If the row has a `uri` field, it will be used to extract the path. 77 + */ 78 + export function normalizeDocumentRow<T extends { data: Json | unknown; uri?: string }>( 79 + row: T 80 + ): DocumentRowWithNormalizedData<T> { 81 + return { 82 + ...row, 83 + data: normalizeDocumentRecord(row.data, row.uri), 84 + }; 85 + } 86 + 87 + /** 88 + * Normalizes a publication row in place, returning a properly typed row. 89 + */ 90 + export function normalizePublicationRow<T extends { record: Json | unknown }>( 91 + row: T 92 + ): PublicationRowWithNormalizedRecord<T> { 93 + return { 94 + ...row, 95 + record: normalizePublicationRecord(row.record), 96 + }; 97 + } 98 + 99 + /** 100 + * Type guard for filtering normalized document rows with non-null data. 101 + * Use with .filter() after .map(normalizeDocumentRow) to narrow the type. 102 + */ 103 + export function hasValidDocument<T extends { data: NormalizedDocument | null }>( 104 + row: T 105 + ): row is T & { data: NormalizedDocument } { 106 + return row.data !== null; 107 + } 108 + 109 + /** 110 + * Type guard for filtering normalized publication rows with non-null record. 111 + * Use with .filter() after .map(normalizePublicationRow) to narrow the type. 112 + */ 113 + export function hasValidPublication< 114 + T extends { record: NormalizedPublication | null } 115 + >(row: T): row is T & { record: NormalizedPublication } { 116 + return row.record !== null; 117 + } 118 + 119 + // Re-export the core types and functions for convenience 120 + export { 121 + normalizeDocument, 122 + normalizePublication, 123 + type NormalizedDocument, 124 + type NormalizedPublication, 125 + } from "lexicons/src/normalize"; 126 + 127 + export { 128 + isLeafletDocument, 129 + isStandardDocument, 130 + isLeafletPublication, 131 + isStandardPublication, 132 + hasLeafletContent, 133 + getDocumentPages, 134 + } from "lexicons/src/normalize";
+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 + }
+140 -1
supabase/database.types.ts
··· 586 586 doc: string | null 587 587 leaflet: string 588 588 publication: string 589 + tags: string[] | null 589 590 title: string 590 591 } 591 592 Insert: { ··· 595 596 doc?: string | null 596 597 leaflet: string 597 598 publication: string 599 + tags?: string[] | null 598 600 title?: string 599 601 } 600 602 Update: { ··· 604 606 doc?: string | null 605 607 leaflet?: string 606 608 publication?: string 609 + tags?: string[] | null 607 610 title?: string 608 611 } 609 612 Relationships: [ ··· 632 635 } 633 636 leaflets_to_documents: { 634 637 Row: { 638 + archived: boolean | null 635 639 cover_image: string | null 636 640 created_at: string 637 641 description: string 638 642 document: string 639 643 leaflet: string 644 + tags: string[] | null 640 645 title: string 641 646 } 642 647 Insert: { 648 + archived?: boolean | null 643 649 cover_image?: string | null 644 650 created_at?: string 645 651 description?: string 646 652 document: string 647 653 leaflet: string 654 + tags?: string[] | null 648 655 title?: string 649 656 } 650 657 Update: { 658 + archived?: boolean | null 651 659 cover_image?: string | null 652 660 created_at?: string 653 661 description?: string 654 662 document?: string 655 663 leaflet?: string 664 + tags?: string[] | null 656 665 title?: string 657 666 } 658 667 Relationships: [ ··· 762 771 referencedColumns: ["id"] 763 772 }, 764 773 { 765 - foreignKeyName: "permission_token_creator_token_fkey" 774 + foreignKeyName: "permission_token_on_homepage_token_fkey" 766 775 columns: ["token"] 767 776 isOneToOne: false 768 777 referencedRelation: "permission_tokens" ··· 1079 1088 last_mutation?: number 1080 1089 } 1081 1090 Relationships: [] 1091 + } 1092 + site_standard_documents: { 1093 + Row: { 1094 + data: Json 1095 + identity_did: string 1096 + indexed_at: string 1097 + uri: string 1098 + } 1099 + Insert: { 1100 + data: Json 1101 + identity_did: string 1102 + indexed_at?: string 1103 + uri: string 1104 + } 1105 + Update: { 1106 + data?: Json 1107 + identity_did?: string 1108 + indexed_at?: string 1109 + uri?: string 1110 + } 1111 + Relationships: [ 1112 + { 1113 + foreignKeyName: "site_standard_documents_identity_did_fkey" 1114 + columns: ["identity_did"] 1115 + isOneToOne: false 1116 + referencedRelation: "identities" 1117 + referencedColumns: ["atp_did"] 1118 + }, 1119 + ] 1120 + } 1121 + site_standard_documents_in_publications: { 1122 + Row: { 1123 + document: string 1124 + indexed_at: string 1125 + publication: string 1126 + } 1127 + Insert: { 1128 + document: string 1129 + indexed_at?: string 1130 + publication: string 1131 + } 1132 + Update: { 1133 + document?: string 1134 + indexed_at?: string 1135 + publication?: string 1136 + } 1137 + Relationships: [ 1138 + { 1139 + foreignKeyName: "site_standard_documents_in_publications_document_fkey" 1140 + columns: ["document"] 1141 + isOneToOne: false 1142 + referencedRelation: "site_standard_documents" 1143 + referencedColumns: ["uri"] 1144 + }, 1145 + { 1146 + foreignKeyName: "site_standard_documents_in_publications_publication_fkey" 1147 + columns: ["publication"] 1148 + isOneToOne: false 1149 + referencedRelation: "site_standard_publications" 1150 + referencedColumns: ["uri"] 1151 + }, 1152 + ] 1153 + } 1154 + site_standard_publications: { 1155 + Row: { 1156 + data: Json 1157 + identity_did: string 1158 + indexed_at: string 1159 + uri: string 1160 + } 1161 + Insert: { 1162 + data: Json 1163 + identity_did: string 1164 + indexed_at?: string 1165 + uri: string 1166 + } 1167 + Update: { 1168 + data?: Json 1169 + identity_did?: string 1170 + indexed_at?: string 1171 + uri?: string 1172 + } 1173 + Relationships: [ 1174 + { 1175 + foreignKeyName: "site_standard_publications_identity_did_fkey" 1176 + columns: ["identity_did"] 1177 + isOneToOne: false 1178 + referencedRelation: "identities" 1179 + referencedColumns: ["atp_did"] 1180 + }, 1181 + ] 1182 + } 1183 + site_standard_subscriptions: { 1184 + Row: { 1185 + created_at: string 1186 + identity: string 1187 + publication: string 1188 + record: Json 1189 + uri: string 1190 + } 1191 + Insert: { 1192 + created_at?: string 1193 + identity: string 1194 + publication: string 1195 + record: Json 1196 + uri: string 1197 + } 1198 + Update: { 1199 + created_at?: string 1200 + identity?: string 1201 + publication?: string 1202 + record?: Json 1203 + uri?: string 1204 + } 1205 + Relationships: [ 1206 + { 1207 + foreignKeyName: "site_standard_subscriptions_identity_fkey" 1208 + columns: ["identity"] 1209 + isOneToOne: false 1210 + referencedRelation: "identities" 1211 + referencedColumns: ["atp_did"] 1212 + }, 1213 + { 1214 + foreignKeyName: "site_standard_subscriptions_publication_fkey" 1215 + columns: ["publication"] 1216 + isOneToOne: false 1217 + referencedRelation: "site_standard_publications" 1218 + referencedColumns: ["uri"] 1219 + }, 1220 + ] 1082 1221 } 1083 1222 subscribers_to_publications: { 1084 1223 Row: {