diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index b5164848..0e65d822 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -11,6 +11,8 @@ import { PubLeafletBlocksText, PubLeafletBlocksUnorderedList, PubLeafletDocument, + SiteStandardDocument, + PubLeafletContent, PubLeafletPagesLinearDocument, PubLeafletPagesCanvas, PubLeafletRichtextFacet, @@ -42,6 +44,10 @@ import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks"; import { Lock } from "src/utils/lock"; import type { PubLeafletPublication } from "lexicons/api"; +import { + normalizeDocumentRecord, + type NormalizedDocument, +} from "src/utils/normalizeRecords"; import { ColorToRGB, ColorToRGBA, @@ -52,6 +58,11 @@ import { pingIdentityToUpdateNotification, } from "src/notifications"; import { v7 } from "uuid"; +import { + isDocumentCollection, + isPublicationCollection, + getDocumentType, +} from "src/utils/collectionHelpers"; type PublishResult = | { success: true; rkey: string; record: PubLeafletDocument.Record } @@ -149,9 +160,20 @@ export async function publishToPublication({ credentialSession.did!, ); - let existingRecord = draft?.documents?.data as - | PubLeafletDocument.Record - | undefined; + let existingRecord: Partial = {}; + const normalizedDoc = normalizeDocumentRecord(draft?.documents?.data); + if (normalizedDoc) { + // When reading existing data, use normalized format to extract fields + // The theme is preserved in NormalizedDocument for backward compatibility + existingRecord = { + publishedAt: normalizedDoc.publishedAt, + title: normalizedDoc.title, + description: normalizedDoc.description, + tags: normalizedDoc.tags, + coverImage: normalizedDoc.coverImage, + theme: normalizedDoc.theme, + }; + } // Extract theme for standalone documents (not for publications) let theme: PubLeafletPublication.Theme | undefined; @@ -176,36 +198,72 @@ export async function publishToPublication({ } } - let record: PubLeafletDocument.Record = { - $type: "pub.leaflet.document", - author: credentialSession.did!, - ...(publication_uri && { publication: publication_uri }), - ...(theme && { theme }), - title: title || "Untitled", - description: description || "", - ...(tags !== undefined && { tags }), // Include tags if provided (even if empty array to clear tags) - ...(coverImageBlob && { coverImage: coverImageBlob }), // Include cover image if uploaded - pages: pages.map((p) => { - if (p.type === "canvas") { - return { - $type: "pub.leaflet.pages.canvas" as const, - id: p.id, - blocks: p.blocks as PubLeafletPagesCanvas.Block[], - }; - } else { - return { - $type: "pub.leaflet.pages.linearDocument" as const, - id: p.id, - blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], - }; - } - }), - publishedAt: - publishedAt || existingRecord?.publishedAt || new Date().toISOString(), - }; + // Determine the collection to use - preserve existing schema if updating + const existingCollection = existingDocUri ? new AtUri(existingDocUri).collection : undefined; + const documentType = getDocumentType(existingCollection); + + // Build the pages array (used by both formats) + const pagesArray = pages.map((p) => { + if (p.type === "canvas") { + return { + $type: "pub.leaflet.pages.canvas" as const, + id: p.id, + blocks: p.blocks as PubLeafletPagesCanvas.Block[], + }; + } else { + return { + $type: "pub.leaflet.pages.linearDocument" as const, + id: p.id, + blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], + }; + } + }); + + // Determine the rkey early since we need it for the path field + const rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr(); + + // Create record based on the document type + let record: PubLeafletDocument.Record | SiteStandardDocument.Record; + + if (documentType === "site.standard.document") { + // site.standard.document format + // For standalone docs, use HTTPS URL; for publication docs, use the publication AT-URI + const siteUri = publication_uri || `https://leaflet.pub/p/${credentialSession.did}`; + + record = { + $type: "site.standard.document", + title: title || "Untitled", + site: siteUri, + path: rkey, + publishedAt: + publishedAt || existingRecord.publishedAt || new Date().toISOString(), + ...(description && { description }), + ...(tags !== undefined && { tags }), + ...(coverImageBlob && { coverImage: coverImageBlob }), + // Include theme for standalone documents (not for publication documents) + ...(!publication_uri && theme && { theme }), + content: { + $type: "pub.leaflet.content" as const, + pages: pagesArray, + }, + } satisfies SiteStandardDocument.Record; + } else { + // pub.leaflet.document format (legacy) + record = { + $type: "pub.leaflet.document", + author: credentialSession.did!, + ...(publication_uri && { publication: publication_uri }), + ...(theme && { theme }), + title: title || "Untitled", + description: description || "", + ...(tags !== undefined && { tags }), + ...(coverImageBlob && { coverImage: coverImageBlob }), + pages: pagesArray, + publishedAt: + publishedAt || existingRecord.publishedAt || new Date().toISOString(), + } satisfies PubLeafletDocument.Record; + } - // Keep the same rkey if updating an existing document - let rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr(); let { data: result } = await agent.com.atproto.repo.putRecord({ rkey, repo: credentialSession.did!, @@ -217,7 +275,7 @@ export async function publishToPublication({ // Optimistically create database entries await supabaseServerClient.from("documents").upsert({ uri: result.uri, - data: record as Json, + data: record as unknown as Json, }); if (publication_uri) { @@ -839,15 +897,28 @@ async function extractThemeFromFacts( */ async function createMentionNotifications( documentUri: string, - record: PubLeafletDocument.Record, + record: PubLeafletDocument.Record | SiteStandardDocument.Record, authorDid: string, ) { const mentionedDids = new Set(); const mentionedPublications = new Map(); // Map of DID -> publication URI const mentionedDocuments = new Map(); // Map of DID -> document URI + // Extract pages from either format + let pages: PubLeafletContent.Main["pages"] | undefined; + if (record.$type === "site.standard.document") { + const content = record.content; + if (content && PubLeafletContent.isMain(content)) { + pages = content.pages; + } + } else { + pages = record.pages; + } + + if (!pages) return; + // Extract mentions from all text blocks in all pages - for (const page of record.pages) { + for (const page of pages) { if (page.$type === "pub.leaflet.pages.linearDocument") { const linearPage = page as PubLeafletPagesLinearDocument.Main; for (const blockWrapper of linearPage.blocks) { @@ -867,7 +938,7 @@ async function createMentionNotifications( if (PubLeafletRichtextFacet.isAtMention(feature)) { const uri = new AtUri(feature.atURI); - if (uri.collection === "pub.leaflet.publication") { + if (isPublicationCollection(uri.collection)) { // Get the publication owner's DID const { data: publication } = await supabaseServerClient .from("publications") @@ -881,7 +952,7 @@ async function createMentionNotifications( feature.atURI, ); } - } else if (uri.collection === "pub.leaflet.document") { + } else if (isDocumentCollection(uri.collection)) { // Get the document owner's DID const { data: document } = await supabaseServerClient .from("documents") @@ -890,10 +961,14 @@ async function createMentionNotifications( .single(); if (document) { - const docRecord = - document.data as PubLeafletDocument.Record; - if (docRecord.author !== authorDid) { - mentionedDocuments.set(docRecord.author, feature.atURI); + const normalizedMentionedDoc = normalizeDocumentRecord( + document.data, + ); + // Get the author from the document URI (the DID is the host part) + const mentionedUri = new AtUri(feature.atURI); + const docAuthor = mentionedUri.host; + if (normalizedMentionedDoc && docAuthor !== authorDid) { + mentionedDocuments.set(docAuthor, feature.atURI); } } } diff --git a/app/(home-pages)/discover/PubListing.tsx b/app/(home-pages)/discover/PubListing.tsx index 54dab387..eff5df20 100644 --- a/app/(home-pages)/discover/PubListing.tsx +++ b/app/(home-pages)/discover/PubListing.tsx @@ -6,18 +6,16 @@ import { PubIcon } from "components/ActionBar/Publications"; import { Separator } from "components/Layout"; import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; -import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; import { blobRefToSrc } from "src/utils/blobRefToSrc"; import { timeAgo } from "src/utils/timeAgo"; -import { Json } from "supabase/database.types"; export const PubListing = ( props: PublicationSubscription & { resizeHeight?: boolean; }, ) => { - let record = props.record as PubLeafletPublication.Record; - let theme = usePubTheme(record.theme); + let record = props.record; + let theme = usePubTheme(record?.theme); let backgroundImage = record?.theme?.backgroundImage?.image?.ref ? blobRefToSrc( record?.theme?.backgroundImage?.image?.ref, @@ -31,7 +29,7 @@ export const PubListing = ( return ( 0 && startIndex + limit < allPubs.length ? order === "recentlyUpdated" ? { - indexed_at: page[page.length - 1].documents_in_publications[0]?.indexed_at, - uri: page[page.length - 1].uri, + indexed_at: lastItem.documents_in_publications[0]?.indexed_at, + uri: lastItem.uri, } : { - count: page[page.length - 1].publication_subscriptions[0]?.count || 0, - uri: page[page.length - 1].uri, + count: lastItem.publication_subscriptions[0]?.count || 0, + uri: lastItem.uri, } : null; return { - publications: page, + publications: normalizedPage, nextCursor, }; } diff --git a/app/(home-pages)/notifications/CommentMentionNotification.tsx b/app/(home-pages)/notifications/CommentMentionNotification.tsx index 0e9520cb..10917d61 100644 --- a/app/(home-pages)/notifications/CommentMentionNotification.tsx +++ b/app/(home-pages)/notifications/CommentMentionNotification.tsx @@ -1,9 +1,4 @@ -import { - AppBskyActorProfile, - PubLeafletComment, - PubLeafletDocument, - PubLeafletPublication, -} from "lexicons/api"; +import { AppBskyActorProfile, PubLeafletComment } from "lexicons/api"; import { HydratedCommentMentionNotification } from "src/notifications"; import { blobRefToSrc } from "src/utils/blobRefToSrc"; import { MentionTiny } from "components/Icons/MentionTiny"; @@ -17,19 +12,19 @@ import { AtUri } from "@atproto/api"; export const CommentMentionNotification = ( props: HydratedCommentMentionNotification, ) => { - const docRecord = props.commentData.documents - ?.data as PubLeafletDocument.Record; + const docRecord = props.normalizedDocument; + if (!docRecord) return null; + const commentRecord = props.commentData.record as PubLeafletComment.Record; const profileRecord = props.commentData.bsky_profiles ?.record as AppBskyActorProfile.Record; - const pubRecord = props.commentData.documents?.documents_in_publications[0] - ?.publications?.record as PubLeafletPublication.Record | undefined; + const pubRecord = props.normalizedPublication; const docUri = new AtUri(props.commentData.documents?.uri!); const rkey = docUri.rkey; const did = docUri.host; const href = pubRecord - ? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments` + ? `${pubRecord.url}/${rkey}?interactionDrawer=comments` : `/p/${did}/${rkey}?interactionDrawer=comments`; const commenter = props.commenterHandle @@ -37,8 +32,7 @@ export const CommentMentionNotification = ( : "Someone"; let actionText: React.ReactNode; - let mentionedDocRecord = props.mentionedDocument - ?.data as PubLeafletDocument.Record; + const mentionedDocRecord = props.normalizedMentionedDocument; if (props.mention_type === "did") { actionText = <>{commenter} mentioned you in a comment; @@ -46,15 +40,14 @@ export const CommentMentionNotification = ( props.mention_type === "publication" && props.mentionedPublication ) { - const mentionedPubRecord = props.mentionedPublication - .record as PubLeafletPublication.Record; + const mentionedPubRecord = props.normalizedMentionedPublication; actionText = ( <> {commenter} mentioned your publication{" "} - {mentionedPubRecord.name} in a comment + {mentionedPubRecord?.name} in a comment ); - } else if (props.mention_type === "document" && props.mentionedDocument) { + } else if (props.mention_type === "document" && mentionedDocRecord) { actionText = ( <> {commenter} mentioned your post{" "} @@ -72,7 +65,7 @@ export const CommentMentionNotification = ( icon={} actionText={actionText} content={ - + { - let docRecord = props.commentData.documents - ?.data as PubLeafletDocument.Record; - let commentRecord = props.commentData.record as PubLeafletComment.Record; - let profileRecord = props.commentData.bsky_profiles + const docRecord = props.normalizedDocument; + const commentRecord = props.commentData.record as PubLeafletComment.Record; + const profileRecord = props.commentData.bsky_profiles ?.record as AppBskyActorProfile.Record; + + if (!docRecord) return null; + const displayName = - profileRecord.displayName || + profileRecord?.displayName || props.commentData.bsky_profiles?.handle || "Someone"; - const pubRecord = props.commentData.documents?.documents_in_publications[0] - ?.publications?.record as PubLeafletPublication.Record | undefined; - let docUri = new AtUri(props.commentData.documents?.uri!); - let rkey = docUri.rkey; - let did = docUri.host; + const pubRecord = props.normalizedPublication; + const docUri = new AtUri(props.commentData.documents?.uri!); + const rkey = docUri.rkey; + const did = docUri.host; const href = pubRecord - ? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments` + ? `${pubRecord.url}/${rkey}?interactionDrawer=comments` : `/p/${did}/${rkey}?interactionDrawer=comments`; return ( diff --git a/app/(home-pages)/notifications/FollowNotification.tsx b/app/(home-pages)/notifications/FollowNotification.tsx index dd8b2b74..dbdd5efd 100644 --- a/app/(home-pages)/notifications/FollowNotification.tsx +++ b/app/(home-pages)/notifications/FollowNotification.tsx @@ -2,7 +2,7 @@ import { Avatar } from "components/Avatar"; import { Notification } from "./Notification"; import { HydratedSubscribeNotification } from "src/notifications"; import { blobRefToSrc } from "src/utils/blobRefToSrc"; -import { AppBskyActorProfile, PubLeafletPublication } from "lexicons/api"; +import { AppBskyActorProfile } from "lexicons/api"; export const FollowNotification = (props: HydratedSubscribeNotification) => { const profileRecord = props.subscriptionData?.identities?.bsky_profiles @@ -11,8 +11,7 @@ export const FollowNotification = (props: HydratedSubscribeNotification) => { profileRecord?.displayName || props.subscriptionData?.identities?.bsky_profiles?.handle || "Someone"; - const pubRecord = props.subscriptionData?.publications - ?.record as PubLeafletPublication.Record; + const pubRecord = props.normalizedPublication; const avatarSrc = profileRecord?.avatar?.ref && blobRefToSrc( @@ -23,7 +22,7 @@ export const FollowNotification = (props: HydratedSubscribeNotification) => { return ( } actionText={ <> diff --git a/app/(home-pages)/notifications/MentionNotification.tsx b/app/(home-pages)/notifications/MentionNotification.tsx index 917b2cdf..21c1bab3 100644 --- a/app/(home-pages)/notifications/MentionNotification.tsx +++ b/app/(home-pages)/notifications/MentionNotification.tsx @@ -1,25 +1,25 @@ import { MentionTiny } from "components/Icons/MentionTiny"; import { ContentLayout, Notification } from "./Notification"; import { HydratedMentionNotification } from "src/notifications"; -import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; -import { Agent, AtUri } from "@atproto/api"; +import { AtUri } from "@atproto/api"; export const MentionNotification = (props: HydratedMentionNotification) => { - const docRecord = props.document.data as PubLeafletDocument.Record; - const pubRecord = props.document.documents_in_publications?.[0]?.publications - ?.record as PubLeafletPublication.Record | undefined; + const docRecord = props.normalizedDocument; + const pubRecord = props.normalizedPublication; + + if (!docRecord) return null; + const docUri = new AtUri(props.document.uri); const rkey = docUri.rkey; const did = docUri.host; const href = pubRecord - ? `https://${pubRecord.base_path}/${rkey}` + ? `${pubRecord.url}/${rkey}` : `/p/${did}/${rkey}`; let actionText: React.ReactNode; let mentionedItemName: string | undefined; - let mentionedDocRecord = props.mentionedDocument - ?.data as PubLeafletDocument.Record; + const mentionedDocRecord = props.normalizedMentionedDocument; const mentioner = props.documentCreatorHandle ? `@${props.documentCreatorHandle}` @@ -31,16 +31,15 @@ export const MentionNotification = (props: HydratedMentionNotification) => { props.mention_type === "publication" && props.mentionedPublication ) { - const mentionedPubRecord = props.mentionedPublication - .record as PubLeafletPublication.Record; - mentionedItemName = mentionedPubRecord.name; + const mentionedPubRecord = props.normalizedMentionedPublication; + mentionedItemName = mentionedPubRecord?.name; actionText = ( <> {mentioner} mentioned your publication{" "} {mentionedItemName} ); - } else if (props.mention_type === "document" && props.mentionedDocument) { + } else if (props.mention_type === "document" && mentionedDocRecord) { mentionedItemName = mentionedDocRecord.title; actionText = ( <> diff --git a/app/(home-pages)/notifications/Notification.tsx b/app/(home-pages)/notifications/Notification.tsx index f70f7af2..205d15a8 100644 --- a/app/(home-pages)/notifications/Notification.tsx +++ b/app/(home-pages)/notifications/Notification.tsx @@ -1,9 +1,10 @@ "use client"; import { Avatar } from "components/Avatar"; import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; -import { PubLeafletPublication, PubLeafletRichtextFacet } from "lexicons/api"; +import { PubLeafletRichtextFacet } from "lexicons/api"; import { timeAgo } from "src/utils/timeAgo"; import { useReplicache, useEntity } from "src/replicache"; +import type { NormalizedPublication } from "src/utils/normalizeRecords"; export const Notification = (props: { icon: React.ReactNode; @@ -58,8 +59,8 @@ export const Notification = (props: { export const ContentLayout = (props: { children: React.ReactNode; - postTitle: string; - pubRecord?: PubLeafletPublication.Record; + postTitle: string | undefined; + pubRecord?: NormalizedPublication | null; }) => { let { rootEntity } = useReplicache(); let cardBorderHidden = useEntity(rootEntity, "theme/card-border-hidden")?.data @@ -77,7 +78,7 @@ export const ContentLayout = (props: { <>
{props.pubRecord.name} diff --git a/app/(home-pages)/notifications/QuoteNotification.tsx b/app/(home-pages)/notifications/QuoteNotification.tsx index 94c283b6..d6849205 100644 --- a/app/(home-pages)/notifications/QuoteNotification.tsx +++ b/app/(home-pages)/notifications/QuoteNotification.tsx @@ -1,7 +1,6 @@ import { QuoteTiny } from "components/Icons/QuoteTiny"; import { ContentLayout, Notification } from "./Notification"; import { HydratedQuoteNotification } from "src/notifications"; -import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; import { AtUri } from "@atproto/api"; import { Avatar } from "components/Avatar"; @@ -9,16 +8,18 @@ export const QuoteNotification = (props: HydratedQuoteNotification) => { const postView = props.bskyPost.post_view as any; const author = postView.author; const displayName = author.displayName || author.handle || "Someone"; - const docRecord = props.document.data as PubLeafletDocument.Record; - const pubRecord = props.document.documents_in_publications[0]?.publications - ?.record as PubLeafletPublication.Record | undefined; + const docRecord = props.normalizedDocument; + const pubRecord = props.normalizedPublication; + + if (!docRecord) return null; + const docUri = new AtUri(props.document.uri); const rkey = docUri.rkey; const did = docUri.host; const postText = postView.record?.text || ""; const href = pubRecord - ? `https://${pubRecord.base_path}/${rkey}` + ? `${pubRecord.url}/${rkey}` : `/p/${did}/${rkey}`; return ( diff --git a/app/(home-pages)/notifications/ReplyNotification.tsx b/app/(home-pages)/notifications/ReplyNotification.tsx index 699b7174..00e377b6 100644 --- a/app/(home-pages)/notifications/ReplyNotification.tsx +++ b/app/(home-pages)/notifications/ReplyNotification.tsx @@ -7,41 +7,38 @@ import { Notification, } from "./Notification"; import { HydratedCommentNotification } from "src/notifications"; -import { - PubLeafletComment, - PubLeafletDocument, - PubLeafletPublication, -} from "lexicons/api"; +import { PubLeafletComment } from "lexicons/api"; import { AppBskyActorProfile, AtUri } from "@atproto/api"; import { blobRefToSrc } from "src/utils/blobRefToSrc"; export const ReplyNotification = (props: HydratedCommentNotification) => { - let docRecord = props.commentData.documents - ?.data as PubLeafletDocument.Record; - let commentRecord = props.commentData.record as PubLeafletComment.Record; - let profileRecord = props.commentData.bsky_profiles + const docRecord = props.normalizedDocument; + const commentRecord = props.commentData.record as PubLeafletComment.Record; + const profileRecord = props.commentData.bsky_profiles ?.record as AppBskyActorProfile.Record; + + if (!docRecord) return null; + const displayName = - profileRecord.displayName || + profileRecord?.displayName || props.commentData.bsky_profiles?.handle || "Someone"; - let parentRecord = props.parentData?.record as PubLeafletComment.Record; - let parentProfile = props.parentData?.bsky_profiles + const parentRecord = props.parentData?.record as PubLeafletComment.Record; + const parentProfile = props.parentData?.bsky_profiles ?.record as AppBskyActorProfile.Record; const parentDisplayName = - parentProfile.displayName || + parentProfile?.displayName || props.parentData?.bsky_profiles?.handle || "Someone"; - let docUri = new AtUri(props.commentData.documents?.uri!); - let rkey = docUri.rkey; - let did = docUri.host; - const pubRecord = props.commentData.documents?.documents_in_publications[0] - ?.publications?.record as PubLeafletPublication.Record | undefined; + const docUri = new AtUri(props.commentData.documents?.uri!); + const rkey = docUri.rkey; + const did = docUri.host; + const pubRecord = props.normalizedPublication; const href = pubRecord - ? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments` + ? `${pubRecord.url}/${rkey}?interactionDrawer=comments` : `/p/${did}/${rkey}?interactionDrawer=comments`; return ( diff --git a/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx b/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx index a0fd68e4..044e01fc 100644 --- a/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx +++ b/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx @@ -1,10 +1,9 @@ "use client"; import { Avatar } from "components/Avatar"; -import { PubLeafletPublication } from "lexicons/api"; import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; import { colorToString } from "components/ThemeManager/useColorAttribute"; import { PubIcon } from "components/ActionBar/Publications"; -import { Json } from "supabase/database.types"; +import { type NormalizedPublication } from "src/utils/normalizeRecords"; import { BlueskyTiny } from "components/Icons/BlueskyTiny"; import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; import { SpeedyLink } from "components/SpeedyLink"; @@ -13,7 +12,7 @@ import * as linkify from "linkifyjs"; export const ProfileHeader = (props: { profile: ProfileViewDetailed; - publications: { record: Json; uri: string }[]; + publications: { record: NormalizedPublication; uri: string }[]; popover?: boolean; }) => { let profileRecord = props.profile; @@ -80,11 +79,7 @@ export const ProfileHeader = (props: { 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]"}`} > {props.publications.map((p) => ( - + ))} @@ -105,17 +100,14 @@ const ProfileLinks = (props: { handle: string }) => { ); }; -const PublicationCard = (props: { - record: PubLeafletPublication.Record; - uri: string; -}) => { +const PublicationCard = (props: { record: NormalizedPublication; uri: string }) => { const { record, uri } = props; const { bgLeaflet, bgPage, primary } = usePubTheme(record.theme); return (
{ - let pub = post.documents_in_publications[0].publications!; - let uri = new AtUri(post.uri); - let handle = await idResolver.did.resolve(uri.host); - let p: Post = { - publication: { - href: getPublicationURL(pub), - pubRecord: pub?.record || null, - uri: pub?.uri || "", - }, - author: handle?.alsoKnownAs?.[0] - ? `@${handle.alsoKnownAs[0].slice(5)}` - : null, - documents: { - comments_on_documents: post.comments_on_documents, - document_mentions_in_bsky: post.document_mentions_in_bsky, - data: post.data, - uri: post.uri, - indexed_at: post.indexed_at, - }, - }; - return p; - }) || [], - ); + let posts = ( + await Promise.all( + feed?.map(async (post) => { + let pub = post.documents_in_publications[0].publications!; + let uri = new AtUri(post.uri); + let handle = await idResolver.did.resolve(uri.host); + + // Normalize records - filter out unrecognized formats + const normalizedData = normalizeDocumentRecord(post.data, post.uri); + if (!normalizedData) return null; + + const normalizedPubRecord = normalizePublicationRecord(pub?.record); + + let p: Post = { + publication: { + href: getPublicationURL(pub), + pubRecord: normalizedPubRecord, + uri: pub?.uri || "", + }, + author: handle?.alsoKnownAs?.[0] + ? `@${handle.alsoKnownAs[0].slice(5)}` + : null, + documents: { + comments_on_documents: post.comments_on_documents, + document_mentions_in_bsky: post.document_mentions_in_bsky, + data: normalizedData, + uri: post.uri, + indexed_at: post.indexed_at, + }, + }; + return p; + }) || [], + ) + ).filter((post): post is Post => post !== null); const nextCursor = posts.length > 0 ? { @@ -85,22 +99,14 @@ export type Post = { author: string | null; publication?: { href: string; - pubRecord: Json; + pubRecord: NormalizedPublication | null; uri: string; }; documents: { - data: Json; + data: NormalizedDocument | null; uri: string; indexed_at: string; - comments_on_documents: - | { - count: number; - }[] - | undefined; - document_mentions_in_bsky: - | { - count: number; - }[] - | undefined; + comments_on_documents: { count: number }[] | undefined; + document_mentions_in_bsky: { count: number }[] | undefined; }; }; diff --git a/app/(home-pages)/reader/getSubscriptions.ts b/app/(home-pages)/reader/getSubscriptions.ts index bff1cd6d..3d2d7765 100644 --- a/app/(home-pages)/reader/getSubscriptions.ts +++ b/app/(home-pages)/reader/getSubscriptions.ts @@ -7,6 +7,10 @@ import { Json } from "supabase/database.types"; import { supabaseServerClient } from "supabase/serverClient"; import { idResolver } from "./idResolver"; import { Cursor } from "./getReaderFeed"; +import { + normalizePublicationRecord, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; export async function getSubscriptions( did?: string | null, @@ -43,17 +47,24 @@ export async function getSubscriptions( } let { data: pubs, error } = await query; - const hydratedSubscriptions: PublicationSubscription[] = await Promise.all( - pubs?.map(async (pub) => { - let id = await idResolver.did.resolve(pub.publications?.identity_did!); - return { - ...pub.publications!, - authorProfile: id?.alsoKnownAs?.[0] - ? { handle: `@${id.alsoKnownAs[0].slice(5)}` } - : undefined, - }; - }) || [], - ); + const hydratedSubscriptions = ( + await Promise.all( + pubs?.map(async (pub) => { + const normalizedRecord = normalizePublicationRecord( + pub.publications?.record + ); + if (!normalizedRecord) return null; + let id = await idResolver.did.resolve(pub.publications?.identity_did!); + return { + ...pub.publications!, + record: normalizedRecord, + authorProfile: id?.alsoKnownAs?.[0] + ? { handle: `@${id.alsoKnownAs[0].slice(5)}` } + : undefined, + } as PublicationSubscription; + }) || [] + ) + ).filter((sub): sub is PublicationSubscription => sub !== null); const nextCursor = pubs && pubs.length > 0 @@ -71,7 +82,7 @@ export async function getSubscriptions( export type PublicationSubscription = { authorProfile?: { handle: string }; - record: Json; + record: NormalizedPublication; uri: string; documents_in_publications: { documents: { data?: Json; indexed_at: string } | null; diff --git a/app/(home-pages)/tag/[tag]/getDocumentsByTag.ts b/app/(home-pages)/tag/[tag]/getDocumentsByTag.ts index 56ac8663..b22417e8 100644 --- a/app/(home-pages)/tag/[tag]/getDocumentsByTag.ts +++ b/app/(home-pages)/tag/[tag]/getDocumentsByTag.ts @@ -3,9 +3,12 @@ import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; import { supabaseServerClient } from "supabase/serverClient"; import { AtUri } from "@atproto/api"; -import { Json } from "supabase/database.types"; import { idResolver } from "app/(home-pages)/reader/idResolver"; import type { Post } from "app/(home-pages)/reader/getReaderFeed"; +import { + normalizeDocumentRecord, + normalizePublicationRecord, +} from "src/utils/normalizeRecords"; export async function getDocumentsByTag( tag: string, @@ -37,13 +40,21 @@ export async function getDocumentsByTag( return null; } + // Normalize the document data - skip unrecognized formats + const normalizedData = normalizeDocumentRecord(doc.data, doc.uri); + if (!normalizedData) { + return null; + } + + const normalizedPubRecord = normalizePublicationRecord(pub?.record); + const uri = new AtUri(doc.uri); const handle = await idResolver.did.resolve(uri.host); const post: Post = { publication: { href: getPublicationURL(pub), - pubRecord: pub?.record || null, + pubRecord: normalizedPubRecord, uri: pub?.uri || "", }, author: handle?.alsoKnownAs?.[0] @@ -52,7 +63,7 @@ export async function getDocumentsByTag( documents: { comments_on_documents: doc.comments_on_documents, document_mentions_in_bsky: doc.document_mentions_in_bsky, - data: doc.data, + data: normalizedData, uri: doc.uri, indexed_at: doc.indexed_at, }, diff --git a/app/[leaflet_id]/actions/PublishButton.tsx b/app/[leaflet_id]/actions/PublishButton.tsx index 4be7c75b..0e6c4bc2 100644 --- a/app/[leaflet_id]/actions/PublishButton.tsx +++ b/app/[leaflet_id]/actions/PublishButton.tsx @@ -22,7 +22,7 @@ import { Popover } from "components/Popover"; import { SpeedyLink } from "components/SpeedyLink"; import { useToaster } from "components/Toast"; import { DotLoader } from "components/utils/DotLoader"; -import { PubLeafletPublication } from "lexicons/api"; +import { normalizePublicationRecord } from "src/utils/normalizeRecords"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useState, useMemo, useEffect } from "react"; import { useIsMobile } from "src/hooks/isMobile"; @@ -377,7 +377,7 @@ const PubSelector = (props: {
{props.publications.map((p) => { - let pubRecord = p.record as PubLeafletPublication.Record; + let pubRecord = normalizePublicationRecord(p.record); return ( { let { permission_token } = useReplicache(); - let { data: pub } = useLeafletPublicationData(); - - let record = pub?.documents?.data as PubLeafletDocument.Record | null; + let { data: pub, normalizedDocument } = useLeafletPublicationData(); let docURI = pub?.documents ? new AtUri(pub?.documents.uri) : null; let postLink = !docURI diff --git a/app/[leaflet_id]/publish/PublishPost.tsx b/app/[leaflet_id]/publish/PublishPost.tsx index 8b0aa3cf..adf48c04 100644 --- a/app/[leaflet_id]/publish/PublishPost.tsx +++ b/app/[leaflet_id]/publish/PublishPost.tsx @@ -7,7 +7,7 @@ import { Radio } from "components/Checkbox"; import { useParams } from "next/navigation"; import Link from "next/link"; -import { PubLeafletPublication } from "lexicons/api"; +import type { NormalizedPublication } from "src/utils/normalizeRecords"; import { publishPostToBsky } from "./publishBskyPost"; import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; import { AtUri } from "@atproto/syntax"; @@ -36,7 +36,7 @@ type Props = { profile: ProfileViewDetailed; description: string; publication_uri?: string; - record?: PubLeafletPublication.Record; + record?: NormalizedPublication | null; posts_in_pub?: number; entitiesToDelete?: string[]; hasDraft: boolean; @@ -135,8 +135,8 @@ const PublishPostForm = ( } // Generate post URL based on whether it's in a publication or standalone - let post_url = props.record?.base_path - ? `https://${props.record.base_path}/${result.rkey}` + let post_url = props.record?.url + ? `${props.record.url}/${result.rkey}` : `https://leaflet.pub/p/${props.profile.did}/${result.rkey}`; let [text, facets] = editorStateRef.current @@ -331,7 +331,7 @@ const ShareOptions = (props: { title: string; profile: ProfileViewDetailed; description: string; - record?: PubLeafletPublication.Record; + record?: NormalizedPublication | null; }) => { return (
@@ -398,7 +398,7 @@ const ShareOptions = (props: {
{props.description}

- {props.record?.base_path} + {props.record?.url?.replace(/^https?:\/\//, "")}

@@ -415,7 +415,7 @@ const ShareOptions = (props: { const PublishingTo = (props: { publication_uri?: string; - record?: PubLeafletPublication.Record; + record?: NormalizedPublication | null; }) => { if (props.publication_uri && props.record) { return ( diff --git a/app/[leaflet_id]/publish/page.tsx b/app/[leaflet_id]/publish/page.tsx index 42690741..fa0f2f6f 100644 --- a/app/[leaflet_id]/publish/page.tsx +++ b/app/[leaflet_id]/publish/page.tsx @@ -1,6 +1,6 @@ import { supabaseServerClient } from "supabase/serverClient"; import { PublishPost } from "./PublishPost"; -import { PubLeafletPublication } from "lexicons/api"; +import { normalizePublicationRecord } from "src/utils/normalizeRecords"; import { getIdentityData } from "actions/getIdentityData"; import { AtpAgent } from "@atproto/api"; @@ -118,7 +118,7 @@ export default async function PublishLeafletPage(props: Props) { title={title} description={description} publication_uri={publication?.uri} - record={publication?.record as PubLeafletPublication.Record | undefined} + record={normalizePublicationRecord(publication?.record)} posts_in_pub={publication?.documents_in_publications[0]?.count} entitiesToDelete={entitiesToDelete} hasDraft={hasDraft} diff --git a/app/api/inngest/client.ts b/app/api/inngest/client.ts index 3866ba9f..77a2266a 100644 --- a/app/api/inngest/client.ts +++ b/app/api/inngest/client.ts @@ -21,6 +21,11 @@ export type Events = { }; }; "appview/come-online": { data: {} }; + "user/migrate-to-standard": { + data: { + did: string; + }; + }; }; // Create a client to send and receive events diff --git a/app/api/inngest/functions/index_post_mention.ts b/app/api/inngest/functions/index_post_mention.ts index 726c4c3a..98ebef6d 100644 --- a/app/api/inngest/functions/index_post_mention.ts +++ b/app/api/inngest/functions/index_post_mention.ts @@ -6,6 +6,7 @@ import { ids } from "lexicons/api/lexicons"; import { Notification, pingIdentityToUpdateNotification } from "src/notifications"; import { v7 } from "uuid"; import { idResolver } from "app/(home-pages)/reader/idResolver"; +import { documentUriFilter } from "src/utils/uriHelpers"; export const index_post_mention = inngest.createFunction( { id: "index_post_mention" }, @@ -37,14 +38,29 @@ export const index_post_mention = inngest.createFunction( did = resolved; } - documentUri = AtUri.make(did, ids.PubLeafletDocument, rkey).toString(); + // Query the database to find the actual document URI (could be either namespace) + const { data: docDataArr } = await supabaseServerClient + .from("documents") + .select("uri") + .or(documentUriFilter(did, rkey)) + .order("uri", { ascending: false }) + .limit(1); + const docData = docDataArr?.[0]; + + if (!docData) { + return { message: `No document found for did:${did} rkey:${rkey}` }; + } + + documentUri = docData.uri; authorDid = did; } else { // Publication post: look up by custom domain + // Support both old format (pub.leaflet.publication with base_path) and + // new format (site.standard.publication with url as https://domain) let { data: pub, error } = await supabaseServerClient .from("publications") .select("*") - .eq("record->>base_path", url.host) + .or(`record->>base_path.eq.${url.host},record->>url.eq.https://${url.host}`) .single(); if (!pub) { @@ -54,11 +70,20 @@ export const index_post_mention = inngest.createFunction( }; } - documentUri = AtUri.make( - pub.identity_did, - ids.PubLeafletDocument, - path[0], - ).toString(); + // Query the database to find the actual document URI (could be either namespace) + const { data: docDataArr } = await supabaseServerClient + .from("documents") + .select("uri") + .or(documentUriFilter(pub.identity_did, path[0])) + .order("uri", { ascending: false }) + .limit(1); + const docData = docDataArr?.[0]; + + if (!docData) { + return { message: `No document found for publication ${url.host}/${path[0]}` }; + } + + documentUri = docData.uri; authorDid = pub.identity_did; } diff --git a/app/api/inngest/functions/migrate_user_to_standard.ts b/app/api/inngest/functions/migrate_user_to_standard.ts new file mode 100644 index 00000000..c27b14ec --- /dev/null +++ b/app/api/inngest/functions/migrate_user_to_standard.ts @@ -0,0 +1,428 @@ +import { supabaseServerClient } from "supabase/serverClient"; +import { inngest } from "../client"; +import { restoreOAuthSession } from "src/atproto-oauth"; +import { AtpBaseClient, SiteStandardPublication, SiteStandardDocument, SiteStandardGraphSubscription } from "lexicons/api"; +import { AtUri } from "@atproto/syntax"; +import { Json } from "supabase/database.types"; +import { normalizePublicationRecord, normalizeDocumentRecord } from "src/utils/normalizeRecords"; + +type MigrationResult = + | { success: true; oldUri: string; newUri: string; skipped?: boolean } + | { success: false; error: string }; + +async function createAuthenticatedAgent(did: string): Promise { + const result = await restoreOAuthSession(did); + if (!result.ok) { + throw new Error(`Failed to restore OAuth session: ${result.error.message}`); + } + const credentialSession = result.value; + return new AtpBaseClient( + credentialSession.fetchHandler.bind(credentialSession) + ); +} + +export const migrate_user_to_standard = inngest.createFunction( + { id: "migrate_user_to_standard" }, + { event: "user/migrate-to-standard" }, + async ({ event, step }) => { + const { did } = event.data; + + const stats = { + publicationsMigrated: 0, + documentsMigrated: 0, + userSubscriptionsMigrated: 0, + referencesUpdated: 0, + errors: [] as string[], + }; + + // Step 1: Verify OAuth session is valid + await step.run("verify-oauth-session", async () => { + const result = await restoreOAuthSession(did); + if (!result.ok) { + throw new Error(`Failed to restore OAuth session: ${result.error.message}`); + } + return { success: true }; + }); + + // Step 2: Get user's pub.leaflet.publication records + const oldPublications = await step.run("fetch-old-publications", async () => { + const { data, error } = await supabaseServerClient + .from("publications") + .select("*") + .eq("identity_did", did) + .like("uri", `at://${did}/pub.leaflet.publication/%`); + + if (error) throw new Error(`Failed to fetch publications: ${error.message}`); + return data || []; + }); + + // Step 3: Migrate each publication + const publicationUriMap: Record = {}; // old URI -> new URI + + for (const pub of oldPublications) { + const aturi = new AtUri(pub.uri); + + // Skip if already a site.standard.publication + if (aturi.collection === "site.standard.publication") { + publicationUriMap[pub.uri] = pub.uri; + continue; + } + + const rkey = aturi.rkey; + const normalized = normalizePublicationRecord(pub.record); + + if (!normalized) { + stats.errors.push(`Publication ${pub.uri}: Failed to normalize publication record`); + continue; + } + + // Build site.standard.publication record + const newRecord: SiteStandardPublication.Record = { + $type: "site.standard.publication", + name: normalized.name, + url: normalized.url, + description: normalized.description, + icon: normalized.icon, + theme: normalized.theme, + basicTheme: normalized.basicTheme, + preferences: normalized.preferences, + }; + + // Step: Write to PDS + const pdsResult = await step.run(`pds-write-publication-${pub.uri}`, async () => { + const agent = await createAuthenticatedAgent(did); + const putResult = await agent.com.atproto.repo.putRecord({ + repo: did, + collection: "site.standard.publication", + rkey, + record: newRecord, + validate: false, + }); + return { newUri: putResult.data.uri }; + }); + + const newUri = pdsResult.newUri; + + // Step: Write to database + const dbResult = await step.run(`db-write-publication-${pub.uri}`, async () => { + const { error: dbError } = await supabaseServerClient + .from("publications") + .upsert({ + uri: newUri, + identity_did: did, + name: normalized.name, + record: newRecord as Json, + }); + + if (dbError) { + return { success: false as const, error: dbError.message }; + } + return { success: true as const }; + }); + + if (dbResult.success) { + publicationUriMap[pub.uri] = newUri; + stats.publicationsMigrated++; + } else { + stats.errors.push(`Publication ${pub.uri}: Database error: ${dbResult.error}`); + } + } + + // Step 4: Get ALL user's pub.leaflet.document records (both in publications and standalone) + const oldDocuments = await step.run("fetch-old-documents", async () => { + const { data, error } = await supabaseServerClient + .from("documents") + .select("uri, data") + .like("uri", `at://${did}/pub.leaflet.document/%`); + + if (error) throw new Error(`Failed to fetch documents: ${error.message}`); + return data || []; + }); + + // Also fetch publication associations for documents + const documentPublicationMap = await step.run("fetch-document-publications", async () => { + const docUris = oldDocuments.map(d => d.uri); + if (docUris.length === 0) return {}; + + const { data, error } = await supabaseServerClient + .from("documents_in_publications") + .select("document, publication") + .in("document", docUris); + + if (error) throw new Error(`Failed to fetch document publications: ${error.message}`); + + // Create a map of document URI -> publication URI + const map: Record = {}; + for (const row of data || []) { + map[row.document] = row.publication; + } + return map; + }); + + const documentUriMap: Record = {}; // old URI -> new URI + + for (const doc of oldDocuments) { + const aturi = new AtUri(doc.uri); + + // Skip if already a site.standard.document + if (aturi.collection === "site.standard.document") { + documentUriMap[doc.uri] = doc.uri; + continue; + } + + const rkey = aturi.rkey; + const normalized = normalizeDocumentRecord(doc.data, doc.uri); + + if (!normalized) { + stats.errors.push(`Document ${doc.uri}: Failed to normalize document record`); + continue; + } + + // Determine the site field: + // - If document is in a publication, use the new publication URI (if migrated) or old URI + // - If standalone, use the HTTPS URL format + const oldPubUri = documentPublicationMap[doc.uri]; + let siteValue: string; + + if (oldPubUri) { + // Document is in a publication - use new URI if migrated, otherwise keep old + siteValue = publicationUriMap[oldPubUri] || oldPubUri; + } else { + // Standalone document - use HTTPS URL format + siteValue = `https://leaflet.pub/p/${did}`; + } + + // Build site.standard.document record + const newRecord: SiteStandardDocument.Record = { + $type: "site.standard.document", + title: normalized.title || "Untitled", + site: siteValue, + path: rkey, + publishedAt: normalized.publishedAt || new Date().toISOString(), + description: normalized.description, + content: normalized.content, + tags: normalized.tags, + coverImage: normalized.coverImage, + bskyPostRef: normalized.bskyPostRef, + }; + + // Step: Write to PDS + const pdsResult = await step.run(`pds-write-document-${doc.uri}`, async () => { + const agent = await createAuthenticatedAgent(did); + const putResult = await agent.com.atproto.repo.putRecord({ + repo: did, + collection: "site.standard.document", + rkey, + record: newRecord, + validate: false, + }); + return { newUri: putResult.data.uri }; + }); + + const newUri = pdsResult.newUri; + + // Step: Write to database + const dbResult = await step.run(`db-write-document-${doc.uri}`, async () => { + const { error: dbError } = await supabaseServerClient + .from("documents") + .upsert({ + uri: newUri, + data: newRecord as Json, + }); + + if (dbError) { + return { success: false as const, error: dbError.message }; + } + + // If document was in a publication, add to documents_in_publications with new URIs + if (oldPubUri) { + const newPubUri = publicationUriMap[oldPubUri] || oldPubUri; + await supabaseServerClient + .from("documents_in_publications") + .upsert({ + publication: newPubUri, + document: newUri, + }); + } + + return { success: true as const }; + }); + + if (dbResult.success) { + documentUriMap[doc.uri] = newUri; + stats.documentsMigrated++; + } else { + stats.errors.push(`Document ${doc.uri}: Database error: ${dbResult.error}`); + } + } + + // Step 5: Update references in database tables + await step.run("update-references", async () => { + // Update leaflets_in_publications - update publication and doc references + for (const [oldUri, newUri] of Object.entries(publicationUriMap)) { + const { error } = await supabaseServerClient + .from("leaflets_in_publications") + .update({ publication: newUri }) + .eq("publication", oldUri); + + if (!error) stats.referencesUpdated++; + } + + for (const [oldUri, newUri] of Object.entries(documentUriMap)) { + const { error } = await supabaseServerClient + .from("leaflets_in_publications") + .update({ doc: newUri }) + .eq("doc", oldUri); + + if (!error) stats.referencesUpdated++; + } + + // Update leaflets_to_documents - update document references + for (const [oldUri, newUri] of Object.entries(documentUriMap)) { + const { error } = await supabaseServerClient + .from("leaflets_to_documents") + .update({ document: newUri }) + .eq("document", oldUri); + + if (!error) stats.referencesUpdated++; + } + + // Update publication_domains - update publication references + for (const [oldUri, newUri] of Object.entries(publicationUriMap)) { + const { error } = await supabaseServerClient + .from("publication_domains") + .update({ publication: newUri }) + .eq("publication", oldUri); + + if (!error) stats.referencesUpdated++; + } + + // Update comments_on_documents - update document references + for (const [oldUri, newUri] of Object.entries(documentUriMap)) { + const { error } = await supabaseServerClient + .from("comments_on_documents") + .update({ document: newUri }) + .eq("document", oldUri); + + if (!error) stats.referencesUpdated++; + } + + // Update document_mentions_in_bsky - update document references + for (const [oldUri, newUri] of Object.entries(documentUriMap)) { + const { error } = await supabaseServerClient + .from("document_mentions_in_bsky") + .update({ document: newUri }) + .eq("document", oldUri); + + if (!error) stats.referencesUpdated++; + } + + // Update subscribers_to_publications - update publication references + for (const [oldUri, newUri] of Object.entries(publicationUriMap)) { + const { error } = await supabaseServerClient + .from("subscribers_to_publications") + .update({ publication: newUri }) + .eq("publication", oldUri); + + if (!error) stats.referencesUpdated++; + } + + // Update publication_subscriptions - update publication references for incoming subscriptions + for (const [oldUri, newUri] of Object.entries(publicationUriMap)) { + const { error } = await supabaseServerClient + .from("publication_subscriptions") + .update({ publication: newUri }) + .eq("publication", oldUri); + + if (!error) stats.referencesUpdated++; + } + + return stats.referencesUpdated; + }); + + // Step 6: Migrate user's own subscriptions - subscriptions BY this user to other publications + const userSubscriptions = await step.run("fetch-user-subscriptions", async () => { + const { data, error } = await supabaseServerClient + .from("publication_subscriptions") + .select("*") + .eq("identity", did) + .like("uri", `at://${did}/pub.leaflet.graph.subscription/%`); + + if (error) throw new Error(`Failed to fetch user subscriptions: ${error.message}`); + return data || []; + }); + + const userSubscriptionUriMap: Record = {}; // old URI -> new URI + + for (const sub of userSubscriptions) { + const aturi = new AtUri(sub.uri); + + // Skip if already a site.standard.graph.subscription + if (aturi.collection === "site.standard.graph.subscription") { + userSubscriptionUriMap[sub.uri] = sub.uri; + continue; + } + + const rkey = aturi.rkey; + + // Build site.standard.graph.subscription record + const newRecord: SiteStandardGraphSubscription.Record = { + $type: "site.standard.graph.subscription", + publication: sub.publication, + }; + + // Step: Write to PDS + const pdsResult = await step.run(`pds-write-subscription-${sub.uri}`, async () => { + const agent = await createAuthenticatedAgent(did); + const putResult = await agent.com.atproto.repo.putRecord({ + repo: did, + collection: "site.standard.graph.subscription", + rkey, + record: newRecord, + validate: false, + }); + return { newUri: putResult.data.uri }; + }); + + const newUri = pdsResult.newUri; + + // Step: Write to database + const dbResult = await step.run(`db-write-subscription-${sub.uri}`, async () => { + const { error: dbError } = await supabaseServerClient + .from("publication_subscriptions") + .update({ + uri: newUri, + record: newRecord as Json, + }) + .eq("uri", sub.uri); + + if (dbError) { + return { success: false as const, error: dbError.message }; + } + return { success: true as const }; + }); + + if (dbResult.success) { + userSubscriptionUriMap[sub.uri] = newUri; + stats.userSubscriptionsMigrated++; + } else { + stats.errors.push(`User subscription ${sub.uri}: Database error: ${dbResult.error}`); + } + } + + // NOTE: We intentionally keep old documents, publications, and documents_in_publications entries. + // New entries are created with the new URIs, but the old entries remain so that: + // 1. Notifications referencing old document/publication URIs can still resolve + // 2. External references (e.g., from other AT Proto apps) to old URIs continue to work + // 3. The normalization layer handles both schemas transparently for reads + // Old records are also kept on the user's PDS so existing AT-URI references remain valid. + + return { + success: stats.errors.length === 0, + stats, + publicationUriMap, + documentUriMap, + userSubscriptionUriMap, + }; + } +); diff --git a/app/api/inngest/route.tsx b/app/api/inngest/route.tsx index 0f2375cd..f8886099 100644 --- a/app/api/inngest/route.tsx +++ b/app/api/inngest/route.tsx @@ -4,6 +4,7 @@ import { index_post_mention } from "./functions/index_post_mention"; import { come_online } from "./functions/come_online"; import { batched_update_profiles } from "./functions/batched_update_profiles"; import { index_follows } from "./functions/index_follows"; +import { migrate_user_to_standard } from "./functions/migrate_user_to_standard"; export const { GET, POST, PUT } = serve({ client: inngest, @@ -12,5 +13,6 @@ export const { GET, POST, PUT } = serve({ come_online, batched_update_profiles, index_follows, + migrate_user_to_standard, ], }); diff --git a/app/api/pub_icon/route.ts b/app/api/pub_icon/route.ts index 16ecfb7d..0d58d57b 100644 --- a/app/api/pub_icon/route.ts +++ b/app/api/pub_icon/route.ts @@ -1,8 +1,16 @@ import { AtUri } from "@atproto/syntax"; import { IdResolver } from "@atproto/identity"; import { NextRequest, NextResponse } from "next/server"; -import { PubLeafletPublication } from "lexicons/api"; import { supabaseServerClient } from "supabase/serverClient"; +import { + normalizePublicationRecord, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; +import { + isDocumentCollection, + isPublicationCollection, +} from "src/utils/collectionHelpers"; +import { publicationUriFilter } from "src/utils/uriHelpers"; import sharp from "sharp"; const idResolver = new IdResolver(); @@ -29,11 +37,11 @@ export async function GET(req: NextRequest) { return new NextResponse(null, { status: 400 }); } - let publicationRecord: PubLeafletPublication.Record | null = null; + let normalizedPub: NormalizedPublication | null = null; let publicationUri: string; // Check if it's a document or publication - if (uri.collection === "pub.leaflet.document") { + if (isDocumentCollection(uri.collection)) { // Query the documents_in_publications table to get the publication const { data: docInPub } = await supabaseServerClient .from("documents_in_publications") @@ -46,31 +54,32 @@ export async function GET(req: NextRequest) { } publicationUri = docInPub.publication; - publicationRecord = docInPub.publications - .record as PubLeafletPublication.Record; - } else if (uri.collection === "pub.leaflet.publication") { + normalizedPub = normalizePublicationRecord(docInPub.publications.record); + } else if (isPublicationCollection(uri.collection)) { // Query the publications table directly - const { data: publication } = await supabaseServerClient + const { data: publications } = await supabaseServerClient .from("publications") .select("record, uri") - .eq("uri", at_uri) - .single(); + .or(publicationUriFilter(uri.host, uri.rkey)) + .order("uri", { ascending: false }) + .limit(1); + const publication = publications?.[0]; if (!publication || !publication.record) { return new NextResponse(null, { status: 404 }); } publicationUri = publication.uri; - publicationRecord = publication.record as PubLeafletPublication.Record; + normalizedPub = normalizePublicationRecord(publication.record); } else { // Not a supported collection return new NextResponse(null, { status: 404 }); } // Check if the publication has an icon - if (!publicationRecord?.icon) { + if (!normalizedPub?.icon) { // Generate a placeholder with the first letter of the publication name - const firstLetter = (publicationRecord?.name || "?") + const firstLetter = (normalizedPub?.name || "?") .slice(0, 1) .toUpperCase(); @@ -94,7 +103,7 @@ export async function GET(req: NextRequest) { const pubUri = new AtUri(publicationUri); // Get the CID from the icon blob - const cid = (publicationRecord.icon.ref as unknown as { $link: string })[ + const cid = (normalizedPub.icon.ref as unknown as { $link: string })[ "$link" ]; diff --git a/app/api/rpc/[command]/get_profile_data.ts b/app/api/rpc/[command]/get_profile_data.ts index 2c0e4f8e..16e81fa3 100644 --- a/app/api/rpc/[command]/get_profile_data.ts +++ b/app/api/rpc/[command]/get_profile_data.ts @@ -6,6 +6,10 @@ import { supabaseServerClient } from "supabase/serverClient"; import { Agent } from "@atproto/api"; import { getIdentityData } from "actions/getIdentityData"; import { createOauthClient } from "src/atproto-oauth"; +import { + normalizePublicationRow, + hasValidPublication, +} from "src/utils/normalizeRecords"; export type GetProfileDataReturnType = Awaited< ReturnType<(typeof get_profile_data)["handler"]> @@ -59,10 +63,15 @@ export const get_profile_data = makeRoute({ publicationsReq, ]); + // Normalize publication records before returning + const normalizedPublications = (publications || []) + .map(normalizePublicationRow) + .filter(hasValidPublication); + return { result: { profile, - publications: publications || [], + publications: normalizedPublications, }, }; }, diff --git a/app/api/rpc/[command]/get_publication_data.ts b/app/api/rpc/[command]/get_publication_data.ts index 8af7dc5b..bde8c685 100644 --- a/app/api/rpc/[command]/get_publication_data.ts +++ b/app/api/rpc/[command]/get_publication_data.ts @@ -3,6 +3,8 @@ import { makeRoute } from "../lib"; import type { Env } from "./route"; import { AtUri } from "@atproto/syntax"; import { getFactsFromHomeLeaflets } from "./getFactsFromHomeLeaflets"; +import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; +import { ids } from "lexicons/api/lexicons"; export type GetPublicationDataReturnType = Awaited< ReturnType<(typeof get_publication_data)["handler"]> @@ -17,11 +19,17 @@ export const get_publication_data = makeRoute({ { did, publication_name }, { supabase }: Pick, ) => { - let uri; + let pubLeafletUri; + let siteStandardUri; if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(publication_name)) { - uri = AtUri.make( + pubLeafletUri = AtUri.make( did, - "pub.leaflet.publication", + ids.PubLeafletPublication, + publication_name, + ).toString(); + siteStandardUri = AtUri.make( + did, + ids.SiteStandardPublication, publication_name, ).toString(); } @@ -44,7 +52,7 @@ export const get_publication_data = makeRoute({ ) )`, ) - .or(`name.eq."${publication_name}", uri.eq."${uri}"`) + .or(`name.eq."${publication_name}", uri.eq."${pubLeafletUri}", uri.eq."${siteStandardUri}"`) .eq("identity_did", did) .single(); @@ -58,6 +66,42 @@ export const get_publication_data = makeRoute({ { supabase }, ); - return { result: { publication, leaflet_data: leaflet_data.result } }; + // Pre-normalize documents from documents_in_publications + const documents = (publication?.documents_in_publications || []) + .map((dip) => { + if (!dip.documents) return null; + const normalized = normalizeDocumentRecord(dip.documents.data, dip.documents.uri); + if (!normalized) return null; + return { + uri: dip.documents.uri, + record: normalized, + indexed_at: dip.documents.indexed_at, + data: dip.documents.data, + commentsCount: dip.documents.comments_on_documents[0]?.count || 0, + mentionsCount: dip.documents.document_mentions_in_bsky[0]?.count || 0, + }; + }) + .filter((d): d is NonNullable => d !== null); + + // Pre-filter drafts (leaflets without published documents, not archived) + const drafts = (publication?.leaflets_in_publications || []) + .filter((l) => !l.documents) + .filter((l) => !(l as { archived?: boolean }).archived) + .map((l) => ({ + leaflet: l.leaflet, + title: l.title, + permission_tokens: l.permission_tokens, + // Keep the full leaflet data for LeafletList compatibility + _raw: l, + })); + + return { + result: { + publication, + documents, + drafts, + leaflet_data: leaflet_data.result, + }, + }; }, }); diff --git a/app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx b/app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx index 39664a84..938ec530 100644 --- a/app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx @@ -2,7 +2,7 @@ import { useEntity, useReplicache } from "src/replicache"; import { useUIState } from "src/useUIState"; -import { CSSProperties, useContext, useRef } from "react"; +import { CSSProperties, useRef } from "react"; import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; import { PostContent, Block } from "../PostContent"; import { @@ -15,7 +15,7 @@ import { } from "lexicons/api"; import { AppBskyFeedDefs } from "@atproto/api"; import { TextBlock } from "./TextBlock"; -import { PostPageContext } from "../PostPageContext"; +import { useDocument } from "contexts/DocumentContext"; import { openPage, useOpenPages } from "../PostPages"; import { openInteractionDrawer, @@ -155,8 +155,7 @@ export function PagePreview(props: { }) { let previewRef = useRef(null); let { rootEntity } = useReplicache(); - let data = useContext(PostPageContext); - let theme = data?.theme; + const { theme } = useDocument(); let pageWidth = `var(--page-width-unitless)`; let cardBorderHidden = !theme?.showPageBackground; return ( @@ -195,14 +194,11 @@ export function PagePreview(props: { } const Interactions = (props: { pageId: string; parentPageId?: string }) => { - const data = useContext(PostPageContext); - const document_uri = data?.uri; - if (!document_uri) - throw new Error("document_uri not available in PostPageContext"); - let comments = data.comments_on_documents.filter( + const { uri: document_uri, comments: allComments, mentions } = useDocument(); + let comments = allComments.filter( (c) => (c.record as PubLeafletComment.Record)?.onPage === props.pageId, ).length; - let quotes = data.document_mentions_in_bsky.filter((q) => + let quotes = mentions.filter((q) => q.link.includes(props.pageId), ).length; diff --git a/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx b/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx index 4d28dcc3..9b3a1058 100644 --- a/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx +++ b/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx @@ -69,8 +69,8 @@ export function CanvasPage({ data={document} profile={profile} preferences={preferences} - commentsCount={getCommentCount(document, pageId)} - quotesCount={getQuoteCount(document, pageId)} + commentsCount={getCommentCount(document.comments_on_documents, pageId)} + quotesCount={getQuoteCount(document.quotesAndMentions, pageId)} />
@@ -55,10 +57,8 @@ export async function DocumentPageRenderer({
); - - let record = document.data as PubLeafletDocument.Record; let bskyPosts = - record.pages.flatMap((p) => { + pages.flatMap((p) => { let page = p as PubLeafletPagesLinearDocument.Main; return page.blocks?.filter( (b) => b.block.$type === ids.PubLeafletBlocksBskyPost, @@ -91,7 +91,7 @@ export async function DocumentPageRenderer({ : []; // Extract poll blocks and fetch vote data - let pollBlocks = record.pages.flatMap((p) => { + let pollBlocks = pages.flatMap((p) => { let page = p as PubLeafletPagesLinearDocument.Main; return ( page.blocks?.filter((b) => b.block.$type === ids.PubLeafletBlocksPoll) || @@ -102,16 +102,11 @@ export async function DocumentPageRenderer({ pollBlocks.map((b) => (b.block as any).pollRef.uri), ); - // Get theme from publication or document (for standalone docs) - let pubRecord = document.documents_in_publications[0]?.publications - ?.record as PubLeafletPublication.Record | undefined; - let theme = pubRecord?.theme || record.theme || null; - let pub_creator = - document.documents_in_publications[0]?.publications?.identity_did || did; + const pubRecord = document.normalizedPublication; + let pub_creator = document.publication?.identity_did || did; let isStandalone = !pubRecord; - let firstPage = record.pages[0]; - + let firstPage = pages[0]; let firstPageBlocks = ( firstPage as @@ -121,26 +116,28 @@ export async function DocumentPageRenderer({ let prerenderedCodeBlocks = await extractCodeBlocks(firstPageBlocks); return ( - - - - - - + + + + + + + - - - - + + + + + ); } diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts index d0509b6c..14c083a4 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts @@ -17,6 +17,10 @@ import { pingIdentityToUpdateNotification, } from "src/notifications"; import { v7 } from "uuid"; +import { + isDocumentCollection, + isPublicationCollection, +} from "src/utils/collectionHelpers"; type PublishCommentResult = | { success: true; record: Json; profile: any; uri: string } @@ -180,7 +184,7 @@ function createCommentMentionNotifications( if (notifiedRecipients.has(dedupeKey)) continue; notifiedRecipients.add(dedupeKey); - if (mentionedUri.collection === "pub.leaflet.publication") { + if (isPublicationCollection(mentionedUri.collection)) { notifications.push({ id: v7(), recipient: recipientDid, @@ -191,7 +195,7 @@ function createCommentMentionNotifications( mentioned_uri: feature.atURI, }, }); - } else if (mentionedUri.collection === "pub.leaflet.document") { + } else if (isDocumentCollection(mentionedUri.collection)) { notifications.push({ id: v7(), recipient: recipientDid, diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx index 6492c0f2..9dcc4421 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx @@ -6,14 +6,13 @@ import type { Json } from "supabase/database.types"; import { create } from "zustand"; import type { Comment } from "./Comments"; import { decodeQuotePosition, QuotePosition } from "../quotePosition"; -import { useContext } from "react"; -import { PostPageContext } from "../PostPageContext"; +import { useDocument } from "contexts/DocumentContext"; import { scrollIntoView } from "src/utils/scrollIntoView"; import { TagTiny } from "components/Icons/TagTiny"; import { Tag } from "components/Tags"; import { Popover } from "components/Popover"; -import { PostPageData } from "../getPostPageData"; -import { PubLeafletComment, PubLeafletPublication } from "lexicons/api"; +import { PubLeafletComment } from "lexicons/api"; +import { type CommentOnDocument } from "contexts/DocumentContext"; import { prefetchQuotesData } from "./Quotes"; import { useIdentityData } from "components/IdentityProvider"; import { ManageSubscription, SubscribeWithBluesky } from "app/lish/Subscribe"; @@ -111,21 +110,18 @@ export const Interactions = (props: { showMentions: boolean; pageId?: string; }) => { - const data = useContext(PostPageContext); - const document_uri = data?.uri; + const { uri: document_uri, quotesAndMentions, normalizedDocument } = useDocument(); let { identity } = useIdentityData(); - if (!document_uri) - throw new Error("document_uri not available in PostPageContext"); let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); const handleQuotePrefetch = () => { - if (data?.quotesAndMentions) { - prefetchQuotesData(data.quotesAndMentions); + if (quotesAndMentions) { + prefetchQuotesData(quotesAndMentions); } }; - const tags = (data?.data as any)?.tags as string[] | undefined; + const tags = normalizedDocument.tags; const tagCount = tags?.length || 0; return ( @@ -172,23 +168,18 @@ export const ExpandedInteractions = (props: { showMentions: boolean; pageId?: string; }) => { - const data = useContext(PostPageContext); + const { uri: document_uri, quotesAndMentions, normalizedDocument, publication, leafletId } = useDocument(); let { identity } = useIdentityData(); - const document_uri = data?.uri; - if (!document_uri) - throw new Error("document_uri not available in PostPageContext"); - let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); const handleQuotePrefetch = () => { - if (data?.quotesAndMentions) { - prefetchQuotesData(data.quotesAndMentions); + if (quotesAndMentions) { + prefetchQuotesData(quotesAndMentions); } }; - let publication = data?.documents_in_publications[0]?.publications; - const tags = (data?.data as any)?.tags as string[] | undefined; + const tags = normalizedDocument.tags; const tagCount = tags?.length || 0; let noInteractions = !props.showComments && !props.showMentions; @@ -202,9 +193,8 @@ export const ExpandedInteractions = (props: { let isAuthor = identity && - identity.atp_did === - data.documents_in_publications[0]?.publications?.identity_did && - data.leaflets_in_publications[0]; + identity.atp_did === publication?.identity_did && + leafletId; return (
)} - + {subscribed && publication && ( {
); }; -export function getQuoteCount(document: PostPageData, pageId?: string) { - if (!document) return; - return getQuoteCountFromArray(document.quotesAndMentions, pageId); +export function getQuoteCount(quotesAndMentions: { uri: string; link?: string }[], pageId?: string) { + return getQuoteCountFromArray(quotesAndMentions, pageId); } export function getQuoteCountFromArray( @@ -349,34 +338,34 @@ export function getQuoteCountFromArray( } } -export function getCommentCount(document: PostPageData, pageId?: string) { - if (!document) return; +export function getCommentCount(comments: CommentOnDocument[], pageId?: string) { if (pageId) - return document.comments_on_documents.filter( + return comments.filter( (c) => (c.record as PubLeafletComment.Record)?.onPage === pageId, ).length; else - return document.comments_on_documents.filter( + return comments.filter( (c) => !(c.record as PubLeafletComment.Record)?.onPage, ).length; } -const EditButton = (props: { document: PostPageData }) => { +const EditButton = (props: { + publication: { identity_did: string } | null; + leafletId: string | null; +}) => { let { identity } = useIdentityData(); - if (!props.document) return; if ( identity && - identity.atp_did === - props.document.documents_in_publications[0]?.publications?.identity_did && - props.document.leaflets_in_publications[0] + identity.atp_did === props.publication?.identity_did && + props.leafletId ) return (
Edit Post ); - return; + return null; }; diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx index 9f945a84..9f54f84d 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx @@ -1,19 +1,18 @@ "use client"; import { CloseTiny } from "components/Icons/CloseTiny"; -import { useContext } from "react"; import { useIsMobile } from "src/hooks/isMobile"; import { setInteractionState } from "./Interactions"; import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; import { AtUri, AppBskyFeedPost } from "@atproto/api"; -import { PostPageContext } from "../PostPageContext"; import { PubLeafletBlocksText, PubLeafletBlocksUnorderedList, PubLeafletBlocksHeader, - PubLeafletDocument, PubLeafletPagesLinearDocument, PubLeafletBlocksCode, } from "lexicons/api"; +import { useDocument } from "contexts/DocumentContext"; +import { useLeafletContent } from "contexts/LeafletContentContext"; import { decodeQuotePosition, QuotePosition } from "../quotePosition"; import { useActiveHighlightState } from "../useHighlight"; import { PostContent } from "../PostContent"; @@ -66,10 +65,7 @@ export const Quotes = (props: { quotesAndMentions: { uri: string; link?: string }[]; did: string; }) => { - let data = useContext(PostPageContext); - const document_uri = data?.uri; - if (!document_uri) - throw new Error("document_uri not available in PostPageContext"); + const { uri: document_uri } = useDocument(); // Fetch Bluesky post data for all URIs const uris = props.quotesAndMentions.map((q) => q.uri); @@ -182,18 +178,17 @@ export const QuoteContent = (props: { did: string; }) => { let isMobile = useIsMobile(); - const data = useContext(PostPageContext); - const document_uri = data?.uri; + const { uri: document_uri } = useDocument(); + const { pages } = useLeafletContent(); - let record = data?.data as PubLeafletDocument.Record; let page: PubLeafletPagesLinearDocument.Main | undefined = ( props.position.pageId - ? record.pages.find( + ? pages.find( (p) => (p as PubLeafletPagesLinearDocument.Main).id === props.position.pageId, ) - : record.pages[0] + : pages[0] ) as PubLeafletPagesLinearDocument.Main; // Extract blocks within the quote range const content = extractQuotedBlocks(page.blocks || [], props.position, []); diff --git a/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx index dc57a35b..1046025a 100644 --- a/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx +++ b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx @@ -1,10 +1,6 @@ "use client"; -import { - PubLeafletComment, - PubLeafletDocument, - PubLeafletPagesLinearDocument, - PubLeafletPublication, -} from "lexicons/api"; +import { PubLeafletPagesLinearDocument } from "lexicons/api"; +import { useLeafletContent } from "contexts/LeafletContentContext"; import { PostPageData } from "./getPostPageData"; import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; @@ -50,11 +46,10 @@ export function LinearDocumentPage({ hasPageBackground, } = props; let drawer = useDrawerOpen(document_uri); + const { pages } = useLeafletContent(); if (!document) return null; - let record = document.data as PubLeafletDocument.Record; - const isSubpage = !!pageId; return ( @@ -77,7 +72,7 @@ export function LinearDocumentPage({ )} {!hasPageBackground &&
} diff --git a/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx b/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx index f4b77f0e..86216159 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx @@ -1,9 +1,4 @@ "use client"; -import { - PubLeafletComment, - PubLeafletDocument, - PubLeafletPublication, -} from "lexicons/api"; import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; import { Interactions, @@ -28,12 +23,12 @@ export function PostHeader(props: { let { identity } = useIdentityData(); let document = props.data; - let record = document?.data as PubLeafletDocument.Record; + const record = document?.normalizedDocument; let profile = props.profile; let pub = props.data?.documents_in_publications[0]?.publications; const formattedDate = useLocalizedDate( - record.publishedAt || new Date().toISOString(), + record?.publishedAt || new Date().toISOString(), { year: "numeric", month: "long", @@ -41,7 +36,7 @@ export function PostHeader(props: { }, ); - if (!document?.data) return; + if (!document?.data || !record) return null; return ( } diff --git a/app/lish/[did]/[publication]/[rkey]/PostPageContext.tsx b/app/lish/[did]/[publication]/[rkey]/PostPageContext.tsx deleted file mode 100644 index 0ccfade2..00000000 --- a/app/lish/[did]/[publication]/[rkey]/PostPageContext.tsx +++ /dev/null @@ -1,19 +0,0 @@ -"use client"; -import { createContext } from "react"; -import { PostPageData } from "./getPostPageData"; - -export const PostPageContext = createContext(null); - -export const PostPageContextProvider = ({ - children, - value, -}: { - children: React.ReactNode; - value: PostPageData; -}) => { - return ( - - {children} - - ); -}; diff --git a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx index d60b23c9..c2305d27 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx @@ -1,10 +1,12 @@ "use client"; import { - PubLeafletDocument, PubLeafletPagesLinearDocument, PubLeafletPagesCanvas, PubLeafletPublication, } from "lexicons/api"; +import { type NormalizedPublication } from "src/utils/normalizeRecords"; +import { useLeafletContent } from "contexts/LeafletContentContext"; +import { useDocument } from "contexts/DocumentContext"; import { PostPageData } from "./getPostPageData"; import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; import { AppBskyFeedDefs } from "@atproto/api"; @@ -152,7 +154,7 @@ export type SharedPageProps = { showMentions?: boolean; showPrevNext?: boolean; }; - pubRecord?: PubLeafletPublication.Record; + pubRecord?: NormalizedPublication | null; theme?: PubLeafletPublication.Theme | null; prerenderedCodeBlocks?: Map; bskyPostData: AppBskyFeedDefs.PostView[]; @@ -206,7 +208,7 @@ export function PostPages({ document_uri: string; document: PostPageData; profile: ProfileViewDetailed; - pubRecord?: PubLeafletPublication.Record; + pubRecord?: NormalizedPublication | null; did: string; prerenderedCodeBlocks?: Map; bskyPostData: AppBskyFeedDefs.PostView[]; @@ -220,17 +222,18 @@ export function PostPages({ let drawer = useDrawerOpen(document_uri); useInitializeOpenPages(); let openPageIds = useOpenPages(); - if (!document) return null; + const { pages } = useLeafletContent(); + const { quotesAndMentions } = useDocument(); + const record = document?.normalizedDocument; + if (!document || !record) return null; - let record = document.data as PubLeafletDocument.Record; let theme = pubRecord?.theme || record.theme || null; // For publication posts, respect the publication's showPageBackground setting // For standalone documents, default to showing page background let isInPublication = !!pubRecord; let hasPageBackground = isInPublication ? !!theme?.showPageBackground : true; - let quotesAndMentions = document.quotesAndMentions; - let firstPage = record.pages[0] as + let firstPage = pages[0] as | PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main; @@ -250,7 +253,7 @@ export function PostPages({ pollData, document_uri, hasPageBackground, - allPages: record.pages as ( + allPages: pages as ( | PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main )[], @@ -329,7 +332,7 @@ export function PostPages({ } // Handle document pages - let page = record.pages.find( + let page = pages.find( (p) => ( p as diff --git a/app/lish/[did]/[publication]/[rkey]/PostPrevNextButtons.tsx b/app/lish/[did]/[publication]/[rkey]/PostPrevNextButtons.tsx index 403019b7..3127c575 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostPrevNextButtons.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostPrevNextButtons.tsx @@ -1,28 +1,22 @@ "use client"; -import { PubLeafletDocument } from "lexicons/api"; -import { usePublicationData } from "../dashboard/PublicationSWRProvider"; import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; import { AtUri } from "@atproto/api"; -import { useParams } from "next/navigation"; -import { getPostPageData } from "./getPostPageData"; -import { PostPageContext } from "./PostPageContext"; -import { useContext } from "react"; +import { useDocument } from "contexts/DocumentContext"; import { SpeedyLink } from "components/SpeedyLink"; import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; export const PostPrevNextButtons = (props: { showPrevNext: boolean }) => { - let postData = useContext(PostPageContext); - let pub = postData?.documents_in_publications[0]?.publications; + const { prevNext, publication } = useDocument(); - if (!props.showPrevNext || !pub || !postData) return; + if (!props.showPrevNext || !publication) return null; function getPostLink(uri: string) { - return pub && uri - ? `${getPublicationURL(pub)}/${new AtUri(uri).rkey}` + return publication && uri + ? `${getPublicationURL(publication)}/${new AtUri(uri).rkey}` : "leaflet.pub/not-found"; } - let prevPost = postData?.prevNext?.prev; - let nextPost = postData?.prevNext?.next; + let prevPost = prevNext?.prev; + let nextPost = prevNext?.next; return (
diff --git a/app/lish/[did]/[publication]/[rkey]/PostSubscribe.tsx b/app/lish/[did]/[publication]/[rkey]/PostSubscribe.tsx index 90b3a8ac..6ffcd0be 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostSubscribe.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostSubscribe.tsx @@ -1,15 +1,15 @@ "use client"; -import { useContext } from "react"; -import { PostPageContext } from "./PostPageContext"; +import { useDocumentOptional } from "contexts/DocumentContext"; import { useIdentityData } from "components/IdentityProvider"; import { SubscribeWithBluesky } from "app/lish/Subscribe"; import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; export const PostSubscribe = () => { - const data = useContext(PostPageContext); + const data = useDocumentOptional(); let { identity } = useIdentityData(); - let publication = data?.documents_in_publications[0]?.publications; + let publication = data?.publication; + let normalizedPublication = data?.normalizedPublication; let subscribed = identity?.atp_did && @@ -20,11 +20,10 @@ export const PostSubscribe = () => { let isAuthor = identity && - identity.atp_did === - data?.documents_in_publications[0]?.publications?.identity_did && - data?.leaflets_in_publications[0]; + identity.atp_did === publication?.identity_did && + data?.leafletId; - if (!subscribed && !isAuthor && publication && publication.record) + if (!subscribed && !isAuthor && publication && normalizedPublication) return (
@@ -41,5 +40,5 @@ export const PostSubscribe = () => {
); - else return; + else return null; }; diff --git a/app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx b/app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx index beb835e3..7918ab2d 100644 --- a/app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx +++ b/app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx @@ -3,7 +3,7 @@ import { BlueskyLinkTiny } from "components/Icons/BlueskyLinkTiny"; import { CopyTiny } from "components/Icons/CopyTiny"; import { Separator } from "components/Layout"; import { useSmoker } from "components/Toast"; -import { useEffect, useMemo, useState, useContext } from "react"; +import { useEffect, useMemo, useState } from "react"; import { encodeQuotePosition, decodeQuotePosition, @@ -12,8 +12,7 @@ import { import { useIdentityData } from "components/IdentityProvider"; import { CommentTiny } from "components/Icons/CommentTiny"; import { setInteractionState } from "./Interactions/Interactions"; -import { PostPageContext } from "./PostPageContext"; -import { PubLeafletPublication } from "lexicons/api"; +import { useDocument } from "contexts/DocumentContext"; import { flushSync } from "react-dom"; import { scrollIntoView } from "src/utils/scrollIntoView"; @@ -148,10 +147,7 @@ export function QuoteHandler() { export const QuoteOptionButtons = (props: { position: string }) => { let smoker = useSmoker(); let { identity } = useIdentityData(); - const data = useContext(PostPageContext); - const document_uri = data?.uri; - if (!document_uri) - throw new Error("document_uri not available in PostPageContext"); + const { uri: document_uri, publication } = useDocument(); let [url, position] = useMemo(() => { let currentUrl = new URL(window.location.href); let pos = decodeQuotePosition(props.position); @@ -169,9 +165,7 @@ export const QuoteOptionButtons = (props: { position: string }) => { currentUrl.hash = `#${fragmentId}`; return [currentUrl.toString(), pos]; }, [props.position]); - let pubRecord = data.documents_in_publications[0]?.publications?.record as - | PubLeafletPublication.Record - | undefined; + let pubRecord = publication?.record; return ( <> diff --git a/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts b/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts index aa0cc515..9f7f44a2 100644 --- a/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts +++ b/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts @@ -1,9 +1,16 @@ import { supabaseServerClient } from "supabase/serverClient"; import { AtUri } from "@atproto/syntax"; -import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; - -export async function getPostPageData(uri: string) { - let { data: document } = await supabaseServerClient +import { + normalizeDocumentRecord, + normalizePublicationRecord, + type NormalizedDocument, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; +import { PubLeafletPublication, SiteStandardPublication } from "lexicons/api"; +import { documentUriFilter } from "src/utils/uriHelpers"; + +export async function getPostPageData(did: string, rkey: string) { + let { data: documents } = await supabaseServerClient .from("documents") .select( ` @@ -18,17 +25,26 @@ export async function getPostPageData(uri: string) { leaflets_in_publications(*) `, ) - .eq("uri", uri) - .single(); + .or(documentUriFilter(did, rkey)) + .order("uri", { ascending: false }) + .limit(1); + let document = documents?.[0]; if (!document) return null; + // Normalize the document record - this is the primary way consumers should access document data + const normalizedDocument = normalizeDocumentRecord(document.data, document.uri); + if (!normalizedDocument) return null; + + // Normalize the publication record - this is the primary way consumers should access publication data + const normalizedPublication = normalizePublicationRecord( + document.documents_in_publications[0]?.publications?.record + ); + // Fetch constellation backlinks for mentions - const pubRecord = document.documents_in_publications[0]?.publications - ?.record as PubLeafletPublication.Record; - let aturi = new AtUri(uri); - const postUrl = pubRecord - ? `https://${pubRecord?.base_path}/${aturi.rkey}` + let aturi = new AtUri(document.uri); + const postUrl = normalizedPublication + ? `${normalizedPublication.url}/${aturi.rkey}` : `https://leaflet.pub/p/${aturi.host}/${aturi.rkey}`; const constellationBacklinks = await getConstellationBacklinks(postUrl); @@ -48,11 +64,7 @@ export async function getPostPageData(uri: string) { ...uniqueBacklinks, ]; - let theme = - ( - document?.documents_in_publications[0]?.publications - ?.record as PubLeafletPublication.Record - )?.theme || (document?.data as PubLeafletDocument.Record)?.theme; + let theme = normalizedPublication?.theme || normalizedDocument?.theme; // Calculate prev/next documents from the fetched publication documents let prevNext: @@ -62,8 +74,7 @@ export async function getPostPageData(uri: string) { } | undefined; - const currentPublishedAt = (document.data as PubLeafletDocument.Record) - ?.publishedAt; + const currentPublishedAt = normalizedDocument.publishedAt; const allDocs = document.documents_in_publications[0]?.publications ?.documents_in_publications; @@ -71,13 +82,15 @@ export async function getPostPageData(uri: string) { if (currentPublishedAt && allDocs) { // Filter and sort documents by publishedAt const sortedDocs = allDocs - .map((dip) => ({ - uri: dip?.documents?.uri, - title: (dip?.documents?.data as PubLeafletDocument.Record).title, - publishedAt: (dip?.documents?.data as PubLeafletDocument.Record) - .publishedAt, - })) - .filter((doc) => doc.publishedAt) // Only include docs with publishedAt + .map((dip) => { + const normalizedData = normalizeDocumentRecord(dip?.documents?.data, dip?.documents?.uri); + return { + uri: dip?.documents?.uri, + title: normalizedData?.title, + publishedAt: normalizedData?.publishedAt, + }; + }) + .filter((doc) => doc.publishedAt && doc.title) // Only include docs with publishedAt and valid data .sort( (a, b) => new Date(a.publishedAt!).getTime() - @@ -85,7 +98,7 @@ export async function getPostPageData(uri: string) { ); // Find current document index - const currentIndex = sortedDocs.findIndex((doc) => doc.uri === uri); + const currentIndex = sortedDocs.findIndex((doc) => doc.uri === document.uri); if (currentIndex !== -1) { prevNext = { @@ -93,25 +106,43 @@ export async function getPostPageData(uri: string) { currentIndex > 0 ? { uri: sortedDocs[currentIndex - 1].uri || "", - title: sortedDocs[currentIndex - 1].title, + title: sortedDocs[currentIndex - 1].title || "", } : undefined, next: currentIndex < sortedDocs.length - 1 ? { uri: sortedDocs[currentIndex + 1].uri || "", - title: sortedDocs[currentIndex + 1].title, + title: sortedDocs[currentIndex + 1].title || "", } : undefined, }; } } + // Build explicit publication context for consumers + const rawPub = document.documents_in_publications[0]?.publications; + const publication = rawPub ? { + uri: rawPub.uri, + name: rawPub.name, + identity_did: rawPub.identity_did, + record: rawPub.record as PubLeafletPublication.Record | SiteStandardPublication.Record | null, + publication_subscriptions: rawPub.publication_subscriptions || [], + } : null; + return { ...document, + // Pre-normalized data - consumers should use these instead of normalizing themselves + normalizedDocument, + normalizedPublication, quotesAndMentions, theme, prevNext, + // Explicit relational data for DocumentContext + publication, + comments: document.comments_on_documents, + mentions: document.document_mentions_in_bsky, + leafletId: document.leaflets_in_publications[0]?.leaflet || null, }; } diff --git a/app/lish/[did]/[publication]/[rkey]/opengraph-image.ts b/app/lish/[did]/[publication]/[rkey]/opengraph-image.ts index d58a9993..2ba3e011 100644 --- a/app/lish/[did]/[publication]/[rkey]/opengraph-image.ts +++ b/app/lish/[did]/[publication]/[rkey]/opengraph-image.ts @@ -1,10 +1,9 @@ import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage"; import { supabaseServerClient } from "supabase/serverClient"; -import { AtUri } from "@atproto/syntax"; -import { ids } from "lexicons/api/lexicons"; -import { PubLeafletDocument } from "lexicons/api"; import { jsonToLex } from "@atproto/lexicon"; import { fetchAtprotoBlob } from "app/api/atproto_images/route"; +import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; +import { documentUriFilter } from "src/utils/uriHelpers"; export const revalidate = 60; @@ -15,15 +14,17 @@ export default async function OpenGraphImage(props: { let did = decodeURIComponent(params.did); // Try to get the document's cover image - let { data: document } = await supabaseServerClient + let { data: documents } = await supabaseServerClient .from("documents") .select("data") - .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey).toString()) - .single(); + .or(documentUriFilter(did, params.rkey)) + .order("uri", { ascending: false }) + .limit(1); + let document = documents?.[0]; if (document) { - let docRecord = jsonToLex(document.data) as PubLeafletDocument.Record; - if (docRecord.coverImage) { + const docRecord = normalizeDocumentRecord(jsonToLex(document.data)); + if (docRecord?.coverImage) { try { // Get CID from the blob ref (handle both serialized and hydrated forms) let cid = diff --git a/app/lish/[did]/[publication]/[rkey]/page.tsx b/app/lish/[did]/[publication]/[rkey]/page.tsx index bf1ea7e8..6bab19df 100644 --- a/app/lish/[did]/[publication]/[rkey]/page.tsx +++ b/app/lish/[did]/[publication]/[rkey]/page.tsx @@ -1,9 +1,8 @@ import { supabaseServerClient } from "supabase/serverClient"; -import { AtUri } from "@atproto/syntax"; -import { ids } from "lexicons/api/lexicons"; -import { PubLeafletDocument } from "lexicons/api"; import { Metadata } from "next"; import { DocumentPageRenderer } from "./DocumentPageRenderer"; +import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; +import { documentUriFilter } from "src/utils/uriHelpers"; export async function generateMetadata(props: { params: Promise<{ publication: string; did: string; rkey: string }>; @@ -12,16 +11,19 @@ export async function generateMetadata(props: { let did = decodeURIComponent(params.did); if (!did) return { title: "Publication 404" }; - let [{ data: document }] = await Promise.all([ + let [{ data: documents }] = await Promise.all([ supabaseServerClient .from("documents") .select("*, documents_in_publications(publications(*))") - .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey)) - .single(), + .or(documentUriFilter(did, params.rkey)) + .order("uri", { ascending: false }) + .limit(1), ]); + let document = documents?.[0]; if (!document) return { title: "404" }; - let docRecord = document.data as PubLeafletDocument.Record; + const docRecord = normalizeDocumentRecord(document.data); + if (!docRecord) return { title: "404" }; return { icons: { diff --git a/app/lish/[did]/[publication]/[rkey]/useHighlight.tsx b/app/lish/[did]/[publication]/[rkey]/useHighlight.tsx index ee045aa6..7ccee1ea 100644 --- a/app/lish/[did]/[publication]/[rkey]/useHighlight.tsx +++ b/app/lish/[did]/[publication]/[rkey]/useHighlight.tsx @@ -2,8 +2,6 @@ "use client"; import { useParams } from "next/navigation"; -import { useContext } from "react"; -import { PostPageContext } from "./PostPageContext"; import { create } from "zustand"; import { decodeQuotePosition, QuotePosition } from "./quotePosition"; @@ -12,7 +10,6 @@ export const useActiveHighlightState = create(() => ({ })); export const useHighlight = (pos: number[], pageId?: string) => { - let doc = useContext(PostPageContext); let { quote } = useParams(); let activeHighlight = useActiveHighlightState( (state) => state.activeHighlight, diff --git a/app/lish/[did]/[publication]/dashboard/DraftList.tsx b/app/lish/[did]/[publication]/dashboard/DraftList.tsx index 998efcbd..5ef24543 100644 --- a/app/lish/[did]/[publication]/dashboard/DraftList.tsx +++ b/app/lish/[did]/[publication]/dashboard/DraftList.tsx @@ -2,7 +2,10 @@ import { NewDraftSecondaryButton } from "./NewDraftButton"; import React from "react"; -import { usePublicationData } from "./PublicationSWRProvider"; +import { + usePublicationData, + useNormalizedPublicationRecord, +} from "./PublicationSWRProvider"; import { LeafletList } from "app/(home-pages)/home/HomeLayout"; export function DraftList(props: { @@ -10,8 +13,14 @@ export function DraftList(props: { showPageBackground: boolean; }) { let { data: pub_data } = usePublicationData(); + // Normalize the publication record - skip rendering if unrecognized format + const normalizedPubRecord = useNormalizedPublicationRecord(); if (!pub_data?.publication) return null; - let { leaflets_in_publications, ...publication } = pub_data.publication; + const { drafts, leaflet_data } = pub_data; + const { leaflets_in_publications, ...publication } = pub_data.publication; + + if (!normalizedPubRecord) return null; + return (
!l.documents) - .filter((l) => !l.archived) - .map((l) => { - return { - archived: l.archived, - added_at: "", - token: { - ...l.permission_tokens!, - leaflets_in_publications: [ - { - ...l, - publications: { - ...publication, - }, - }, - ], - }, - }; - })} - initialFacts={pub_data.leaflet_data.facts || {}} + leaflets={drafts + .filter((d) => d.permission_tokens) + .map((d) => ({ + archived: (d._raw as { archived?: boolean }).archived, + added_at: "", + token: { + ...d.permission_tokens!, + leaflets_in_publications: [ + { + ...d._raw, + publications: publication, + }, + ], + }, + }))} + initialFacts={leaflet_data.facts || {}} titles={{ - ...leaflets_in_publications.reduce( - (acc, leaflet) => { - if (leaflet.permission_tokens) - acc[leaflet.permission_tokens.root_entity] = - leaflet.title || "Untitled"; + ...drafts.reduce( + (acc, draft) => { + if (draft.permission_tokens) + acc[draft.permission_tokens.root_entity] = + draft.title || "Untitled"; return acc; }, {} as { [l: string]: string }, diff --git a/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx b/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx index 28aecd87..184f4e25 100644 --- a/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx +++ b/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx @@ -5,21 +5,19 @@ import { GetPublicationDataReturnType } from "app/api/rpc/[command]/get_publicat import { Actions } from "./Actions"; import React, { useState } from "react"; import { PublishedPostsList } from "./PublishedPostsLists"; -import { PubLeafletPublication } from "lexicons/api"; import { PublicationSubscribers } from "./PublicationSubscribers"; -import { AtUri } from "@atproto/syntax"; import { - HomeDashboardControls, DashboardLayout, PublicationDashboardControls, } from "components/PageLayouts/DashboardLayout"; import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; +import { type NormalizedPublication } from "src/utils/normalizeRecords"; export default function PublicationDashboard({ publication, record, }: { - record: PubLeafletPublication.Record; + record: NormalizedPublication; publication: Exclude< GetPublicationDataReturnType["result"]["publication"], null diff --git a/app/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx b/app/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx index 96f51b9c..4ba0fd87 100644 --- a/app/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx +++ b/app/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx @@ -2,11 +2,18 @@ import type { GetPublicationDataReturnType } from "app/api/rpc/[command]/get_publication_data"; import { callRPC } from "app/api/rpc/client"; -import { createContext, useContext, useEffect } from "react"; +import { createContext, useContext, useEffect, useMemo } from "react"; import useSWR, { SWRConfig, KeyedMutator, mutate } from "swr"; -import { produce, Draft } from "immer"; +import { produce, Draft as ImmerDraft } from "immer"; +import { + normalizePublicationRecord, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; +// Derive all types from the RPC return type export type PublicationData = GetPublicationDataReturnType["result"]; +export type PublishedDocument = NonNullable["documents"][number]; +export type PublicationDraft = NonNullable["drafts"][number]; const PublicationContext = createContext({ name: "", did: "" }); export function PublicationSWRDataProvider(props: { @@ -49,9 +56,21 @@ export function usePublicationData() { return { data, mutate }; } +/** + * Returns the normalized publication record from the publication data. + * Use this instead of manually calling normalizePublicationRecord on data.publication.record + */ +export function useNormalizedPublicationRecord(): NormalizedPublication | null { + const { data } = usePublicationData(); + return useMemo( + () => normalizePublicationRecord(data?.publication?.record), + [data?.publication?.record] + ); +} + export function mutatePublicationData( mutate: KeyedMutator, - recipe: (draft: Draft>) => void, + recipe: (draft: ImmerDraft>) => void, ) { mutate( (data) => { diff --git a/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx b/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx index 29929a5b..69f9735f 100644 --- a/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx +++ b/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx @@ -1,22 +1,16 @@ "use client"; import { AtUri } from "@atproto/syntax"; -import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; import { EditTiny } from "components/Icons/EditTiny"; -import { usePublicationData } from "./PublicationSWRProvider"; -import { Fragment, useState } from "react"; +import { + usePublicationData, + useNormalizedPublicationRecord, + type PublishedDocument, +} from "./PublicationSWRProvider"; +import { Fragment } from "react"; import { useParams } from "next/navigation"; import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; -import { Menu, MenuItem } from "components/Menu"; -import { deletePost } from "./deletePost"; -import { ButtonPrimary } from "components/Buttons"; -import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; -import { DeleteSmall } from "components/Icons/DeleteSmall"; -import { ShareSmall } from "components/Icons/ShareSmall"; -import { ShareButton } from "app/[leaflet_id]/actions/ShareOptions"; import { SpeedyLink } from "components/SpeedyLink"; -import { QuoteTiny } from "components/Icons/QuoteTiny"; -import { CommentTiny } from "components/Icons/CommentTiny"; import { InteractionPreview } from "components/InteractionsPreview"; import { useLocalizedDate } from "src/hooks/useLocalizedDate"; import { LeafletOptions } from "app/(home-pages)/home/LeafletList/LeafletOptions"; @@ -27,136 +21,136 @@ export function PublishedPostsList(props: { showPageBackground: boolean; }) { let { data } = usePublicationData(); - let params = useParams(); - let { publication } = data!; - let pubRecord = publication?.record as PubLeafletPublication.Record; + let { publication, documents } = data || {}; + const pubRecord = useNormalizedPublicationRecord(); if (!publication) return null; - if (publication.documents_in_publications.length === 0) + if (!documents || documents.length === 0) return (
Nothing's been published yet...
); + + // Sort by publishedAt (most recent first) + const sortedDocuments = [...documents].sort((a, b) => { + const aDate = a.record.publishedAt + ? new Date(a.record.publishedAt) + : new Date(0); + const bDate = b.record.publishedAt + ? new Date(b.record.publishedAt) + : new Date(0); + return bDate.getTime() - aDate.getTime(); + }); + return (
- {publication.documents_in_publications - .sort((a, b) => { - let aRecord = a.documents?.data! as PubLeafletDocument.Record; - let bRecord = b.documents?.data! as PubLeafletDocument.Record; - const aDate = aRecord.publishedAt - ? new Date(aRecord.publishedAt) - : new Date(0); - const bDate = bRecord.publishedAt - ? new Date(bRecord.publishedAt) - : new Date(0); - return bDate.getTime() - aDate.getTime(); // Sort by most recent first - }) - .map((doc) => { - if (!doc.documents) return null; - let leaflet = publication.leaflets_in_publications.find( - (l) => doc.documents && l.doc === doc.documents.uri, - ); - let uri = new AtUri(doc.documents.uri); - let postRecord = doc.documents.data as PubLeafletDocument.Record; - let quotes = doc.documents.document_mentions_in_bsky[0]?.count || 0; - let comments = doc.documents.comments_on_documents[0]?.count || 0; - let tags = (postRecord?.tags as string[] | undefined) || []; - - let postLink = data?.publication - ? `${getPublicationURL(data?.publication)}/${new AtUri(doc.documents.uri).rkey}` - : ""; + {sortedDocuments.map((doc) => ( + + ))} +
+ ); +} - return ( - -
-
-
- -

- {postRecord.title} -

-
-
- {leaflet && leaflet.permission_tokens && ( - <> - - - +function PublishedPostItem(props: { + doc: PublishedDocument; + publication: NonNullable["data"]>["publication"]>; + pubRecord: ReturnType; + showPageBackground: boolean; +}) { + const { doc, publication, pubRecord, showPageBackground } = props; + const uri = new AtUri(doc.uri); + const leaflet = publication.leaflets_in_publications.find( + (l) => l.doc === doc.uri, + ); - - - - - )} -
-
+ return ( + +
+
+
+ +

+ {doc.record.title} +

+
+
+ {leaflet && leaflet.permission_tokens && ( + <> + + + - {postRecord.description ? ( -

- {postRecord.description} -

- ) : null} -
- {postRecord.publishedAt ? ( - - ) : null} - -
-
-
- {!props.showPageBackground && ( -
+ + + + )} - - ); - })} -
+
+
+ + {doc.record.description ? ( +

+ {doc.record.description} +

+ ) : null} +
+ {doc.record.publishedAt ? ( + + ) : null} + +
+
+
+ {!showPageBackground && ( +
+ )} + ); } diff --git a/app/lish/[did]/[publication]/dashboard/deletePost.ts b/app/lish/[did]/[publication]/dashboard/deletePost.ts index 32b10eba..a976f12f 100644 --- a/app/lish/[did]/[publication]/dashboard/deletePost.ts +++ b/app/lish/[did]/[publication]/dashboard/deletePost.ts @@ -39,10 +39,15 @@ export async function deletePost( } await Promise.all([ + // Delete from both PDS collections (document exists in one or the other) agent.pub.leaflet.document.delete({ repo: credentialSession.did, rkey: uri.rkey, - }), + }).catch(() => {}), + agent.site.standard.document.delete({ + repo: credentialSession.did, + rkey: uri.rkey, + }).catch(() => {}), supabaseServerClient.from("documents").delete().eq("uri", document_uri), supabaseServerClient .from("leaflets_in_publications") @@ -83,10 +88,15 @@ export async function unpublishPost( } await Promise.all([ + // Delete from both PDS collections (document exists in one or the other) agent.pub.leaflet.document.delete({ repo: credentialSession.did, rkey: uri.rkey, - }), + }).catch(() => {}), + agent.site.standard.document.delete({ + repo: credentialSession.did, + rkey: uri.rkey, + }).catch(() => {}), supabaseServerClient.from("documents").delete().eq("uri", document_uri), ]); revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); diff --git a/app/lish/[did]/[publication]/dashboard/page.tsx b/app/lish/[did]/[publication]/dashboard/page.tsx index d124fe8e..6dfd25bb 100644 --- a/app/lish/[did]/[publication]/dashboard/page.tsx +++ b/app/lish/[did]/[publication]/dashboard/page.tsx @@ -3,12 +3,11 @@ import { Metadata } from "next"; import { getIdentityData } from "actions/getIdentityData"; import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; import { PublicationSWRDataProvider } from "./PublicationSWRProvider"; -import { PubLeafletPublication } from "lexicons/api"; import { PublicationThemeProviderDashboard } from "components/ThemeManager/PublicationThemeProvider"; import { AtUri } from "@atproto/syntax"; import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; import PublicationDashboard from "./PublicationDashboard"; -import Link from "next/link"; +import { normalizePublicationRecord } from "src/utils/normalizeRecords"; export async function generateMetadata(props: { params: Promise<{ publication: string; did: string }>; @@ -24,8 +23,7 @@ export async function generateMetadata(props: { { supabase: supabaseServerClient }, ); let { publication } = publication_data; - let record = - (publication?.record as PubLeafletPublication.Record) || undefined; + const record = normalizePublicationRecord(publication?.record); if (!publication) return { title: "404 Publication" }; return { title: record?.name || "Untitled Publication" }; } @@ -56,7 +54,7 @@ export default async function Publication(props: { { supabase: supabaseServerClient }, ); let { publication, leaflet_data } = publication_data; - let record = publication?.record as PubLeafletPublication.Record | null; + const record = normalizePublicationRecord(publication?.record); if (!publication || identity.atp_did !== publication.identity_did || !record) return ; diff --git a/app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx b/app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx index d3991fc7..076568ef 100644 --- a/app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx +++ b/app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx @@ -1,5 +1,7 @@ -import { PubLeafletPublication } from "lexicons/api"; -import { usePublicationData } from "../PublicationSWRProvider"; +import { + usePublicationData, + useNormalizedPublicationRecord, +} from "../PublicationSWRProvider"; import { PubSettingsHeader } from "./PublicationSettings"; import { useState } from "react"; import { Toggle } from "components/Toggle"; @@ -15,7 +17,7 @@ export const PostOptions = (props: { let { data } = usePublicationData(); let { publication: pubData } = data || {}; - let record = pubData?.record as PubLeafletPublication.Record; + const record = useNormalizedPublicationRecord(); let [showComments, setShowComments] = useState( record?.preferences?.showComments === undefined @@ -37,7 +39,7 @@ export const PostOptions = (props: { return (
{ - if (!pubData) return; + if (!pubData || !record) return; e.preventDefault(); props.setLoading(true); let data = await updatePublication({ diff --git a/app/lish/[did]/[publication]/generateFeed.ts b/app/lish/[did]/[publication]/generateFeed.ts index 42096c05..59fbed31 100644 --- a/app/lish/[did]/[publication]/generateFeed.ts +++ b/app/lish/[did]/[publication]/generateFeed.ts @@ -1,14 +1,16 @@ import { AtUri } from "@atproto/syntax"; import { Feed } from "feed"; -import { - PubLeafletDocument, - PubLeafletPagesLinearDocument, - PubLeafletPublication, -} from "lexicons/api"; +import { PubLeafletPagesLinearDocument } from "lexicons/api"; import { createElement } from "react"; import { StaticPostContent } from "./[rkey]/StaticPostContent"; import { supabaseServerClient } from "supabase/serverClient"; import { NextResponse } from "next/server"; +import { + normalizePublicationRecord, + normalizeDocumentRecord, + hasLeafletContent, +} from "src/utils/normalizeRecords"; +import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; export async function generateFeed( did: string, @@ -17,15 +19,7 @@ export async function generateFeed( let renderToReadableStream = await import("react-dom/server").then( (module) => module.renderToReadableStream, ); - let uri; - if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(publication_name)) { - uri = AtUri.make( - did, - "pub.leaflet.publication", - publication_name, - ).toString(); - } - let { data: publication } = await supabaseServerClient + let { data: publications } = await supabaseServerClient .from("publications") .select( `*, @@ -34,40 +28,45 @@ export async function generateFeed( `, ) .eq("identity_did", did) - .or(`name.eq."${publication_name}", uri.eq."${uri}"`) - .single(); + .or(publicationNameOrUriFilter(did, publication_name)) + .order("uri", { ascending: false }) + .limit(1); + let publication = publications?.[0]; - let pubRecord = publication?.record as PubLeafletPublication.Record; + const pubRecord = normalizePublicationRecord(publication?.record); if (!publication || !pubRecord) return new NextResponse(null, { status: 404 }); const feed = new Feed({ title: pubRecord.name, description: pubRecord.description, - id: `https://${pubRecord.base_path}`, - link: `https://${pubRecord.base_path}`, + id: pubRecord.url, + link: pubRecord.url, language: "en", // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes copyright: "", feedLinks: { - rss: `https://${pubRecord.base_path}/rss`, - atom: `https://${pubRecord.base_path}/atom`, - json: `https://${pubRecord.base_path}/json`, + rss: `${pubRecord.url}/rss`, + atom: `${pubRecord.url}/atom`, + json: `${pubRecord.url}/json`, }, }); await Promise.all( publication.documents_in_publications.map(async (doc) => { if (!doc.documents) return; - let record = doc.documents?.data as PubLeafletDocument.Record; - let uri = new AtUri(doc.documents?.uri); - let rkey = uri.rkey; + const record = normalizeDocumentRecord(doc.documents?.data, doc.documents?.uri); + const uri = new AtUri(doc.documents?.uri); + const rkey = uri.rkey; if (!record) return; - let firstPage = record.pages[0]; + let blocks: PubLeafletPagesLinearDocument.Block[] = []; - if (PubLeafletPagesLinearDocument.isMain(firstPage)) { - blocks = firstPage.blocks || []; + if (hasLeafletContent(record) && record.content.pages[0]) { + const firstPage = record.content.pages[0]; + if (PubLeafletPagesLinearDocument.isMain(firstPage)) { + blocks = firstPage.blocks || []; + } } - let stream = await renderToReadableStream( + const stream = await renderToReadableStream( createElement(StaticPostContent, { blocks, did: uri.host }), ); const reader = stream.getReader(); @@ -85,8 +84,8 @@ export async function generateFeed( title: record.title, description: record.description, date: record.publishedAt ? new Date(record.publishedAt) : new Date(), - id: `https://${pubRecord.base_path}/${rkey}`, - link: `https://${pubRecord.base_path}/${rkey}`, + id: `${pubRecord.url}/${rkey}`, + link: `${pubRecord.url}/${rkey}`, content: chunks.join(""), }); }), diff --git a/app/lish/[did]/[publication]/icon/route.ts b/app/lish/[did]/[publication]/icon/route.ts index 80d8921e..ebfdbe82 100644 --- a/app/lish/[did]/[publication]/icon/route.ts +++ b/app/lish/[did]/[publication]/icon/route.ts @@ -1,10 +1,10 @@ import { NextRequest } from "next/server"; import { IdResolver } from "@atproto/identity"; -import { AtUri } from "@atproto/syntax"; -import { PubLeafletPublication } from "lexicons/api"; import { supabaseServerClient } from "supabase/serverClient"; import sharp from "sharp"; import { redirect } from "next/navigation"; +import { normalizePublicationRecord } from "src/utils/normalizeRecords"; +import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; let idResolver = new IdResolver(); @@ -18,15 +18,8 @@ export async function GET( const params = await props.params; try { let did = decodeURIComponent(params.did); - let uri; - if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(params.publication)) { - uri = AtUri.make( - did, - "pub.leaflet.publication", - params.publication, - ).toString(); - } - let { data: publication } = await supabaseServerClient + let publication_name = decodeURIComponent(params.publication); + let { data: publications } = await supabaseServerClient .from("publications") .select( `*, @@ -35,10 +28,12 @@ export async function GET( `, ) .eq("identity_did", did) - .or(`name.eq."${params.publication}", uri.eq."${uri}"`) - .single(); + .or(publicationNameOrUriFilter(did, publication_name)) + .order("uri", { ascending: false }) + .limit(1); + let publication = publications?.[0]; - let record = publication?.record as PubLeafletPublication.Record | null; + const record = normalizePublicationRecord(publication?.record); if (!record?.icon) return redirect("/icon.png"); let identity = await idResolver.did.resolve(did); diff --git a/app/lish/[did]/[publication]/layout.tsx b/app/lish/[did]/[publication]/layout.tsx index 1518b718..70e94991 100644 --- a/app/lish/[did]/[publication]/layout.tsx +++ b/app/lish/[did]/[publication]/layout.tsx @@ -1,7 +1,7 @@ -import { PubLeafletPublication } from "lexicons/api"; import { supabaseServerClient } from "supabase/serverClient"; import { Metadata } from "next"; -import { AtUri } from "@atproto/syntax"; +import { normalizePublicationRecord } from "src/utils/normalizeRecords"; +import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; export default async function PublicationLayout(props: { children: React.ReactNode; @@ -19,16 +19,8 @@ export async function generateMetadata(props: { let did = decodeURIComponent(params.did); if (!params.did || !params.publication) return { title: "Publication 404" }; - let uri; let publication_name = decodeURIComponent(params.publication); - if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(publication_name)) { - uri = AtUri.make( - did, - "pub.leaflet.publication", - publication_name, - ).toString(); - } - let { data: publication } = await supabaseServerClient + let { data: publications } = await supabaseServerClient .from("publications") .select( `*, @@ -37,11 +29,13 @@ export async function generateMetadata(props: { `, ) .eq("identity_did", did) - .or(`name.eq."${publication_name}", uri.eq."${uri}"`) - .single(); + .or(publicationNameOrUriFilter(did, publication_name)) + .order("uri", { ascending: false }) + .limit(1); + let publication = publications?.[0]; if (!publication) return { title: "Publication 404" }; - let pubRecord = publication?.record as PubLeafletPublication.Record; + const pubRecord = normalizePublicationRecord(publication?.record); return { title: pubRecord?.name || "Untitled Publication", @@ -60,12 +54,12 @@ export async function generateMetadata(props: { url: publication.uri, }, }, - alternates: pubRecord?.base_path + alternates: pubRecord?.url ? { types: { - "application/rss+xml": `https://${pubRecord?.base_path}/rss`, - "application/atom+xml": `https://${pubRecord?.base_path}/atom`, - "application/json": `https://${pubRecord?.base_path}/json`, + "application/rss+xml": `${pubRecord.url}/rss`, + "application/atom+xml": `${pubRecord.url}/atom`, + "application/json": `${pubRecord.url}/json`, }, } : undefined, diff --git a/app/lish/[did]/[publication]/page.tsx b/app/lish/[did]/[publication]/page.tsx index 7e579840..0a51e1b4 100644 --- a/app/lish/[did]/[publication]/page.tsx +++ b/app/lish/[did]/[publication]/page.tsx @@ -1,9 +1,8 @@ import { supabaseServerClient } from "supabase/serverClient"; import { AtUri } from "@atproto/syntax"; -import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; -import Link from "next/link"; import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; import { BskyAgent } from "@atproto/api"; +import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; import { SubscribeWithBluesky } from "app/lish/Subscribe"; import React from "react"; import { @@ -12,13 +11,15 @@ import { } from "components/ThemeManager/PublicationThemeProvider"; import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; import { SpeedyLink } from "components/SpeedyLink"; -import { QuoteTiny } from "components/Icons/QuoteTiny"; -import { CommentTiny } from "components/Icons/CommentTiny"; import { InteractionPreview } from "components/InteractionsPreview"; import { LocalizedDate } from "./LocalizedDate"; import { PublicationHomeLayout } from "./PublicationHomeLayout"; import { PublicationAuthor } from "./PublicationAuthor"; import { Separator } from "components/Layout"; +import { + normalizePublicationRecord, + normalizeDocumentRecord, +} from "src/utils/normalizeRecords"; export default async function Publication(props: { params: Promise<{ publication: string; did: string }>; @@ -27,16 +28,8 @@ export default async function Publication(props: { let did = decodeURIComponent(params.did); if (!did) return ; let agent = new BskyAgent({ service: "https://public.api.bsky.app" }); - let uri; let publication_name = decodeURIComponent(params.publication); - if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(publication_name)) { - uri = AtUri.make( - did, - "pub.leaflet.publication", - publication_name, - ).toString(); - } - let [{ data: publication }, { data: profile }] = await Promise.all([ + let [{ data: publications }, { data: profile }] = await Promise.all([ supabaseServerClient .from("publications") .select( @@ -50,12 +43,14 @@ export default async function Publication(props: { `, ) .eq("identity_did", did) - .or(`name.eq."${publication_name}", uri.eq."${uri}"`) - .single(), + .or(publicationNameOrUriFilter(did, publication_name)) + .order("uri", { ascending: false }) + .limit(1), agent.getProfile({ actor: did }), ]); + let publication = publications?.[0]; - let record = publication?.record as PubLeafletPublication.Record | null; + const record = normalizePublicationRecord(publication?.record); let showPageBackground = record?.theme?.showPageBackground; @@ -112,28 +107,28 @@ export default async function Publication(props: { {publication.documents_in_publications .filter((d) => !!d?.documents) .sort((a, b) => { - let aRecord = a.documents?.data! as PubLeafletDocument.Record; - let bRecord = b.documents?.data! as PubLeafletDocument.Record; - const aDate = aRecord.publishedAt + const aRecord = normalizeDocumentRecord(a.documents?.data); + const bRecord = normalizeDocumentRecord(b.documents?.data); + const aDate = aRecord?.publishedAt ? new Date(aRecord.publishedAt) : new Date(0); - const bDate = bRecord.publishedAt + const bDate = bRecord?.publishedAt ? new Date(bRecord.publishedAt) : new Date(0); return bDate.getTime() - aDate.getTime(); // Sort by most recent first }) .map((doc) => { if (!doc.documents) return null; + const doc_record = normalizeDocumentRecord(doc.documents.data); + if (!doc_record) return null; let uri = new AtUri(doc.documents.uri); - let doc_record = doc.documents - .data as PubLeafletDocument.Record; let quotes = doc.documents.document_mentions_in_bsky[0].count || 0; let comments = record?.preferences?.showComments === false ? 0 : doc.documents.comments_on_documents[0].count || 0; - let tags = (doc_record?.tags as string[] | undefined) || []; + let tags = doc_record.tags || []; return ( diff --git a/app/lish/createPub/UpdatePubForm.tsx b/app/lish/createPub/UpdatePubForm.tsx index 90843f90..3672c5f8 100644 --- a/app/lish/createPub/UpdatePubForm.tsx +++ b/app/lish/createPub/UpdatePubForm.tsx @@ -7,8 +7,10 @@ import { updatePublication, updatePublicationBasePath, } from "./updatePublication"; -import { usePublicationData } from "../[did]/[publication]/dashboard/PublicationSWRProvider"; -import { PubLeafletPublication } from "lexicons/api"; +import { + usePublicationData, + useNormalizedPublicationRecord, +} from "../[did]/[publication]/dashboard/PublicationSWRProvider"; import useSWR, { mutate } from "swr"; import { AddTiny } from "components/Icons/AddTiny"; import { DotLoader } from "components/utils/DotLoader"; @@ -30,7 +32,7 @@ export const EditPubForm = (props: { }) => { let { data } = usePublicationData(); let { publication: pubData } = data || {}; - let record = pubData?.record as PubLeafletPublication.Record; + let record = useNormalizedPublicationRecord(); let [formState, setFormState] = useState<"normal" | "loading">("normal"); let [nameValue, setNameValue] = useState(record?.name || ""); @@ -60,14 +62,14 @@ export const EditPubForm = (props: { let [iconPreview, setIconPreview] = useState(null); let fileInputRef = useRef(null); useEffect(() => { - if (!pubData || !pubData.record) return; + if (!pubData || !pubData.record || !record) return; setNameValue(record.name); setDescriptionValue(record.description || ""); if (record.icon) setIconPreview( `/api/atproto_images?did=${pubData.identity_did}&cid=${(record.icon.ref as unknown as { $link: string })["$link"]}`, ); - }, [pubData]); + }, [pubData, record]); let toast = useToaster(); return ( @@ -202,8 +204,9 @@ export const EditPubForm = (props: { export function CustomDomainForm() { let { data } = usePublicationData(); let { publication: pubData } = data || {}; + let record = useNormalizedPublicationRecord(); if (!pubData) return null; - let record = pubData?.record as PubLeafletPublication.Record; + if (!record) return null; let [state, setState] = useState< | { type: "default" } | { type: "addDomain" } @@ -243,7 +246,7 @@ export function CustomDomainForm() { { setState({ type: "domainSettings", diff --git a/app/lish/createPub/createPublication.ts b/app/lish/createPub/createPublication.ts index d41dd7f0..a705a0e6 100644 --- a/app/lish/createPub/createPublication.ts +++ b/app/lish/createPub/createPublication.ts @@ -1,17 +1,22 @@ "use server"; import { TID } from "@atproto/common"; -import { AtpBaseClient, PubLeafletPublication } from "lexicons/api"; +import { + AtpBaseClient, + PubLeafletPublication, + SiteStandardPublication, +} from "lexicons/api"; import { restoreOAuthSession, OAuthSessionError, } from "src/atproto-oauth"; import { getIdentityData } from "actions/getIdentityData"; import { supabaseServerClient } from "supabase/serverClient"; -import { Un$Typed } from "@atproto/api"; import { Json } from "supabase/database.types"; import { Vercel } from "@vercel/sdk"; import { isProductionDomain } from "src/utils/isProductionDeployment"; import { string } from "zod"; +import { getPublicationType } from "src/utils/collectionHelpers"; +import { PubThemeDefaultsRGB } from "components/ThemeManager/themeDefaults"; const VERCEL_TOKEN = process.env.VERCEL_TOKEN; const vercel = new Vercel({ @@ -64,15 +69,14 @@ export async function createPublication({ let agent = new AtpBaseClient( credentialSession.fetchHandler.bind(credentialSession), ); - let record: Un$Typed = { - name, - base_path: domain, - preferences, - }; - if (description) { - record.description = description; - } + // Use site.standard.publication for new publications + const publicationType = getPublicationType(); + const url = `https://${domain}`; + + // Build record based on publication type + let record: SiteStandardPublication.Record | PubLeafletPublication.Record; + let iconBlob: Awaited>["data"]["blob"] | undefined; // Upload the icon if provided if (iconFile && iconFile.size > 0) { @@ -81,16 +85,48 @@ export async function createPublication({ new Uint8Array(buffer), { encoding: iconFile.type }, ); + iconBlob = uploadResult.data.blob; + } - if (uploadResult.data.blob) { - record.icon = uploadResult.data.blob; - } + if (publicationType === "site.standard.publication") { + record = { + $type: "site.standard.publication", + name, + url, + ...(description && { description }), + ...(iconBlob && { icon: iconBlob }), + basicTheme: { + $type: "site.standard.theme.basic", + background: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.background }, + foreground: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.foreground }, + accent: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.accent }, + accentForeground: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.accentForeground }, + }, + preferences: { + showInDiscover: preferences.showInDiscover, + showComments: preferences.showComments, + showMentions: preferences.showMentions, + showPrevNext: preferences.showPrevNext, + }, + } satisfies SiteStandardPublication.Record; + } else { + record = { + $type: "pub.leaflet.publication", + name, + base_path: domain, + ...(description && { description }), + ...(iconBlob && { icon: iconBlob }), + preferences, + } satisfies PubLeafletPublication.Record; } - let result = await agent.pub.leaflet.publication.create( - { repo: credentialSession.did!, rkey: TID.nextStr(), validate: false }, + let { data: result } = await agent.com.atproto.repo.putRecord({ + repo: credentialSession.did!, + rkey: TID.nextStr(), + collection: publicationType, record, - ); + validate: false, + }); //optimistically write to our db! let { data: publication } = await supabaseServerClient @@ -98,11 +134,8 @@ export async function createPublication({ .upsert({ uri: result.uri, identity_did: credentialSession.did!, - name: record.name, - record: { - ...record, - $type: "pub.leaflet.publication", - } as unknown as Json, + name, + record: record as unknown as Json, }) .select() .single(); diff --git a/app/lish/createPub/getPublicationURL.ts b/app/lish/createPub/getPublicationURL.ts index 357af329..da8a77b6 100644 --- a/app/lish/createPub/getPublicationURL.ts +++ b/app/lish/createPub/getPublicationURL.ts @@ -2,16 +2,41 @@ import { AtUri } from "@atproto/syntax"; import { PubLeafletPublication } from "lexicons/api"; import { isProductionDomain } from "src/utils/isProductionDeployment"; import { Json } from "supabase/database.types"; +import { + normalizePublicationRecord, + isLeafletPublication, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; -export function getPublicationURL(pub: { uri: string; record: Json }) { - let record = pub.record as PubLeafletPublication.Record; - if (isProductionDomain() && record?.base_path) - return `https://${record.base_path}`; - else return getBasePublicationURL(pub); +type PublicationInput = + | { uri: string; record: Json | NormalizedPublication | null } + | { uri: string; record: unknown }; + +/** + * Gets the public URL for a publication. + * Works with both pub.leaflet.publication and site.standard.publication records. + */ +export function getPublicationURL(pub: PublicationInput): string { + const normalized = normalizePublicationRecord(pub.record); + + // If we have a normalized record with a URL (site.standard format), use it + if (normalized?.url && isProductionDomain()) { + return normalized.url; + } + + // Fall back to checking raw record for legacy base_path + if (isLeafletPublication(pub.record) && pub.record.base_path && isProductionDomain()) { + return `https://${pub.record.base_path}`; + } + + return getBasePublicationURL(pub); } -export function getBasePublicationURL(pub: { uri: string; record: Json }) { - let record = pub.record as PubLeafletPublication.Record; - let aturi = new AtUri(pub.uri); - return `/lish/${aturi.host}/${encodeURIComponent(aturi.rkey || record?.name)}`; +export function getBasePublicationURL(pub: PublicationInput): string { + const normalized = normalizePublicationRecord(pub.record); + const aturi = new AtUri(pub.uri); + + // Use normalized name if available, fall back to rkey + const name = normalized?.name || aturi.rkey; + return `/lish/${aturi.host}/${encodeURIComponent(name || "")}`; } diff --git a/app/lish/createPub/updatePublication.ts b/app/lish/createPub/updatePublication.ts index 6881ac03..81a87aa2 100644 --- a/app/lish/createPub/updatePublication.ts +++ b/app/lish/createPub/updatePublication.ts @@ -1,9 +1,9 @@ "use server"; -import { TID } from "@atproto/common"; import { AtpBaseClient, PubLeafletPublication, PubLeafletThemeColor, + SiteStandardPublication, } from "lexicons/api"; import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; import { getIdentityData } from "actions/getIdentityData"; @@ -11,25 +11,40 @@ import { supabaseServerClient } from "supabase/serverClient"; import { Json } from "supabase/database.types"; import { AtUri } from "@atproto/syntax"; import { $Typed } from "@atproto/api"; +import { + normalizePublicationRecord, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; +import { getPublicationType } from "src/utils/collectionHelpers"; type UpdatePublicationResult = | { success: true; publication: any } | { success: false; error?: OAuthSessionError }; -export async function updatePublication({ - uri, - name, - description, - iconFile, - preferences, -}: { - uri: string; - name: string; - description?: string; - iconFile?: File | null; - preferences?: Omit; -}): Promise { - let identity = await getIdentityData(); +type PublicationType = "pub.leaflet.publication" | "site.standard.publication"; + +type RecordBuilder = (args: { + normalizedPub: NormalizedPublication | null; + existingBasePath: string | undefined; + publicationType: PublicationType; + agent: AtpBaseClient; +}) => Promise; + +/** + * Shared helper for publication updates. Handles: + * - Authentication and session restoration + * - Fetching existing publication from database + * - Normalizing the existing record + * - Calling the record builder to create the updated record + * - Writing to PDS via putRecord + * - Writing to database + */ +async function withPublicationUpdate( + uri: string, + recordBuilder: RecordBuilder, +): Promise { + // Get identity and validate authentication + const identity = await getIdentityData(); if (!identity || !identity.atp_did) { return { success: false, @@ -41,15 +56,18 @@ export async function updatePublication({ }; } + // Restore OAuth session const sessionResult = await restoreOAuthSession(identity.atp_did); if (!sessionResult.ok) { return { success: false, error: sessionResult.error }; } - let credentialSession = sessionResult.value; - let agent = new AtpBaseClient( + const credentialSession = sessionResult.value; + const agent = new AtpBaseClient( credentialSession.fetchHandler.bind(credentialSession), ); - let { data: existingPub } = await supabaseServerClient + + // Fetch existing publication from database + const { data: existingPub } = await supabaseServerClient .from("publications") .select("*") .eq("uri", uri) @@ -57,44 +75,35 @@ export async function updatePublication({ if (!existingPub || existingPub.identity_did !== identity.atp_did) { return { success: false }; } - let aturi = new AtUri(existingPub.uri); - let record: PubLeafletPublication.Record = { - $type: "pub.leaflet.publication", - ...(existingPub.record as object), - name, - }; - if (preferences) { - record.preferences = preferences; - } - - if (description !== undefined) { - record.description = description; - } + const aturi = new AtUri(existingPub.uri); + const publicationType = getPublicationType(aturi.collection) as PublicationType; - // Upload the icon if provided How do I tell if there isn't a new one? - if (iconFile && iconFile.size > 0) { - const buffer = await iconFile.arrayBuffer(); - const uploadResult = await agent.com.atproto.repo.uploadBlob( - new Uint8Array(buffer), - { encoding: iconFile.type }, - ); + // Normalize existing record + const normalizedPub = normalizePublicationRecord(existingPub.record); + const existingBasePath = normalizedPub?.url + ? normalizedPub.url.replace(/^https?:\/\//, "") + : undefined; - if (uploadResult.data.blob) { - record.icon = uploadResult.data.blob; - } - } + // Build the updated record + const record = await recordBuilder({ + normalizedPub, + existingBasePath, + publicationType, + agent, + }); - let result = await agent.com.atproto.repo.putRecord({ + // Write to PDS + await agent.com.atproto.repo.putRecord({ repo: credentialSession.did!, rkey: aturi.rkey, record, - collection: record.$type, + collection: publicationType, validate: false, }); - //optimistically write to our db! - let { data: publication, error } = await supabaseServerClient + // Optimistically write to database + const { data: publication } = await supabaseServerClient .from("publications") .update({ name: record.name, @@ -103,9 +112,134 @@ export async function updatePublication({ .eq("uri", uri) .select() .single(); + return { success: true, publication }; } +/** Fields that can be overridden when building a record */ +interface RecordOverrides { + name?: string; + description?: string; + icon?: any; + theme?: any; + basicTheme?: NormalizedPublication["basicTheme"]; + preferences?: NormalizedPublication["preferences"]; + basePath?: string; +} + +/** Merges override with existing value, respecting explicit undefined */ +function resolveField(override: T | undefined, existing: T | undefined, hasOverride: boolean): T | undefined { + return hasOverride ? override : existing; +} + +/** + * Builds a pub.leaflet.publication record. + * Uses base_path for the URL path component. + */ +function buildLeafletRecord( + normalizedPub: NormalizedPublication | null, + existingBasePath: string | undefined, + overrides: RecordOverrides, +): PubLeafletPublication.Record { + const preferences = overrides.preferences ?? normalizedPub?.preferences; + + return { + $type: "pub.leaflet.publication", + name: overrides.name ?? normalizedPub?.name ?? "", + description: resolveField(overrides.description, normalizedPub?.description, "description" in overrides), + icon: resolveField(overrides.icon, normalizedPub?.icon, "icon" in overrides), + theme: resolveField(overrides.theme, normalizedPub?.theme, "theme" in overrides), + base_path: overrides.basePath ?? existingBasePath, + preferences: preferences ? { + $type: "pub.leaflet.publication#preferences", + showInDiscover: preferences.showInDiscover, + showComments: preferences.showComments, + showMentions: preferences.showMentions, + showPrevNext: preferences.showPrevNext, + } : undefined, + }; +} + +/** + * Builds a site.standard.publication record. + * Uses url for the full URL. Also supports basicTheme. + */ +function buildStandardRecord( + normalizedPub: NormalizedPublication | null, + existingBasePath: string | undefined, + overrides: RecordOverrides, +): SiteStandardPublication.Record { + const preferences = overrides.preferences ?? normalizedPub?.preferences; + const basePath = overrides.basePath ?? existingBasePath; + + return { + $type: "site.standard.publication", + name: overrides.name ?? normalizedPub?.name ?? "", + description: resolveField(overrides.description, normalizedPub?.description, "description" in overrides), + icon: resolveField(overrides.icon, normalizedPub?.icon, "icon" in overrides), + theme: resolveField(overrides.theme, normalizedPub?.theme, "theme" in overrides), + basicTheme: resolveField(overrides.basicTheme, normalizedPub?.basicTheme, "basicTheme" in overrides), + url: basePath ? `https://${basePath}` : normalizedPub?.url || "", + preferences: preferences ? { + showInDiscover: preferences.showInDiscover, + showComments: preferences.showComments, + showMentions: preferences.showMentions, + showPrevNext: preferences.showPrevNext, + } : undefined, + }; +} + +/** + * Builds a record for the appropriate publication type. + */ +function buildRecord( + normalizedPub: NormalizedPublication | null, + existingBasePath: string | undefined, + publicationType: PublicationType, + overrides: RecordOverrides, +): PubLeafletPublication.Record | SiteStandardPublication.Record { + if (publicationType === "pub.leaflet.publication") { + return buildLeafletRecord(normalizedPub, existingBasePath, overrides); + } + return buildStandardRecord(normalizedPub, existingBasePath, overrides); +} + +export async function updatePublication({ + uri, + name, + description, + iconFile, + preferences, +}: { + uri: string; + name: string; + description?: string; + iconFile?: File | null; + preferences?: Omit; +}): Promise { + return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType, agent }) => { + // Upload icon if provided + let iconBlob = normalizedPub?.icon; + if (iconFile && iconFile.size > 0) { + const buffer = await iconFile.arrayBuffer(); + const uploadResult = await agent.com.atproto.repo.uploadBlob( + new Uint8Array(buffer), + { encoding: iconFile.type }, + ); + if (uploadResult.data.blob) { + iconBlob = uploadResult.data.blob; + } + } + + return buildRecord(normalizedPub, existingBasePath, publicationType, { + name, + description, + icon: iconBlob, + preferences, + }); + }); +} + export async function updatePublicationBasePath({ uri, base_path, @@ -113,65 +247,17 @@ export async function updatePublicationBasePath({ uri: string; base_path: string; }): Promise { - let identity = await getIdentityData(); - if (!identity || !identity.atp_did) { - return { - success: false, - error: { - type: "oauth_session_expired", - message: "Not authenticated", - did: "", - }, - }; - } - - const sessionResult = await restoreOAuthSession(identity.atp_did); - if (!sessionResult.ok) { - return { success: false, error: sessionResult.error }; - } - let credentialSession = sessionResult.value; - let agent = new AtpBaseClient( - credentialSession.fetchHandler.bind(credentialSession), - ); - let { data: existingPub } = await supabaseServerClient - .from("publications") - .select("*") - .eq("uri", uri) - .single(); - if (!existingPub || existingPub.identity_did !== identity.atp_did) { - return { success: false }; - } - let aturi = new AtUri(existingPub.uri); - - let record: PubLeafletPublication.Record = { - ...(existingPub.record as PubLeafletPublication.Record), - base_path, - }; - - let result = await agent.com.atproto.repo.putRecord({ - repo: credentialSession.did!, - rkey: aturi.rkey, - record, - collection: record.$type, - validate: false, + return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType }) => { + return buildRecord(normalizedPub, existingBasePath, publicationType, { + basePath: base_path, + }); }); - - //optimistically write to our db! - let { data: publication, error } = await supabaseServerClient - .from("publications") - .update({ - name: record.name, - record: record as Json, - }) - .eq("uri", uri) - .select() - .single(); - return { success: true, publication }; } type Color = | $Typed | $Typed; + export async function updatePublicationTheme({ uri, theme, @@ -189,41 +275,9 @@ export async function updatePublicationTheme({ accentText: Color; }; }): Promise { - let identity = await getIdentityData(); - if (!identity || !identity.atp_did) { - return { - success: false, - error: { - type: "oauth_session_expired", - message: "Not authenticated", - did: "", - }, - }; - } - - const sessionResult = await restoreOAuthSession(identity.atp_did); - if (!sessionResult.ok) { - return { success: false, error: sessionResult.error }; - } - let credentialSession = sessionResult.value; - let agent = new AtpBaseClient( - credentialSession.fetchHandler.bind(credentialSession), - ); - let { data: existingPub } = await supabaseServerClient - .from("publications") - .select("*") - .eq("uri", uri) - .single(); - if (!existingPub || existingPub.identity_did !== identity.atp_did) { - return { success: false }; - } - let aturi = new AtUri(existingPub.uri); - - let oldRecord = existingPub.record as PubLeafletPublication.Record; - let record: PubLeafletPublication.Record = { - ...oldRecord, - $type: "pub.leaflet.publication", - theme: { + return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType, agent }) => { + // Build theme object + const themeData = { backgroundImage: theme.backgroundImage ? { $type: "pub.leaflet.theme.backgroundImage", @@ -238,7 +292,7 @@ export async function updatePublicationTheme({ } : theme.backgroundImage === null ? undefined - : oldRecord.theme?.backgroundImage, + : normalizedPub?.theme?.backgroundImage, backgroundColor: theme.backgroundColor ? { ...theme.backgroundColor, @@ -258,26 +312,20 @@ export async function updatePublicationTheme({ accentText: { ...theme.accentText, }, - }, - }; + }; - let result = await agent.com.atproto.repo.putRecord({ - repo: credentialSession.did!, - rkey: aturi.rkey, - record, - collection: record.$type, - validate: false, - }); + // Derive basicTheme from the theme colors for site.standard.publication + const basicTheme: NormalizedPublication["basicTheme"] = { + $type: "site.standard.theme.basic", + background: { $type: "site.standard.theme.color#rgb", r: theme.backgroundColor.r, g: theme.backgroundColor.g, b: theme.backgroundColor.b }, + foreground: { $type: "site.standard.theme.color#rgb", r: theme.primary.r, g: theme.primary.g, b: theme.primary.b }, + accent: { $type: "site.standard.theme.color#rgb", r: theme.accentBackground.r, g: theme.accentBackground.g, b: theme.accentBackground.b }, + accentForeground: { $type: "site.standard.theme.color#rgb", r: theme.accentText.r, g: theme.accentText.g, b: theme.accentText.b }, + }; - //optimistically write to our db! - let { data: publication, error } = await supabaseServerClient - .from("publications") - .update({ - name: record.name, - record: record as Json, - }) - .eq("uri", uri) - .select() - .single(); - return { success: true, publication }; + return buildRecord(normalizedPub, existingBasePath, publicationType, { + theme: themeData, + basicTheme, + }); + }); } diff --git a/app/lish/feeds/[...path]/route.ts b/app/lish/feeds/[...path]/route.ts index ff98264c..02743e63 100644 --- a/app/lish/feeds/[...path]/route.ts +++ b/app/lish/feeds/[...path]/route.ts @@ -2,7 +2,10 @@ import { NextResponse } from "next/server"; import { DidResolver } from "@atproto/identity"; import { parseReqNsid, verifyJwt } from "@atproto/xrpc-server"; import { supabaseServerClient } from "supabase/serverClient"; -import { PubLeafletDocument } from "lexicons/api"; +import { + normalizeDocumentRecord, + type NormalizedDocument, +} from "src/utils/normalizeRecords"; const serviceDid = "did:web:leaflet.pub:lish:feeds"; export async function GET( @@ -34,9 +37,9 @@ export async function GET( let posts = pub.publications?.documents_in_publications || []; return posts.flatMap((p) => { if (!p.documents?.data) return []; - let record = p.documents.data as PubLeafletDocument.Record; - if (!record.postRef) return []; - return { post: record.postRef.uri }; + const normalizedDoc = normalizeDocumentRecord(p.documents.data, p.documents.uri); + if (!normalizedDoc?.bskyPostRef) return []; + return { post: normalizedDoc.bskyPostRef.uri }; }); }), ], diff --git a/app/lish/subscribeToPublication.ts b/app/lish/subscribeToPublication.ts index e18d30fe..e321361d 100644 --- a/app/lish/subscribeToPublication.ts +++ b/app/lish/subscribeToPublication.ts @@ -48,7 +48,7 @@ export async function subscribeToPublication( let agent = new AtpBaseClient( credentialSession.fetchHandler.bind(credentialSession), ); - let record = await agent.pub.leaflet.graph.subscription.create( + let record = await agent.site.standard.graph.subscription.create( { repo: credentialSession.did!, rkey: TID.nextStr() }, { publication, @@ -140,10 +140,14 @@ export async function unsubscribeToPublication( .eq("publication", publication) .single(); if (!existingSubscription) return { success: true }; - await agent.pub.leaflet.graph.subscription.delete({ - repo: credentialSession.did!, - rkey: new AtUri(existingSubscription.uri).rkey, - }); + + // Delete from both collections (old and new schema) - one or both may exist + let rkey = new AtUri(existingSubscription.uri).rkey; + await Promise.all([ + agent.pub.leaflet.graph.subscription.delete({ repo: credentialSession.did!, rkey }).catch(() => {}), + agent.site.standard.graph.subscription.delete({ repo: credentialSession.did!, rkey }).catch(() => {}), + ]); + await supabaseServerClient .from("publication_subscriptions") .delete() diff --git a/app/lish/uri/[uri]/route.ts b/app/lish/uri/[uri]/route.ts index f4e0d0ed..c3c907d6 100644 --- a/app/lish/uri/[uri]/route.ts +++ b/app/lish/uri/[uri]/route.ts @@ -1,7 +1,14 @@ import { NextRequest, NextResponse } from "next/server"; import { AtUri } from "@atproto/api"; import { supabaseServerClient } from "supabase/serverClient"; -import { PubLeafletPublication } from "lexicons/api"; +import { + normalizePublicationRecord, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; +import { + isDocumentCollection, + isPublicationCollection, +} from "src/utils/collectionHelpers"; /** * Redirect route for AT URIs (publications and documents) @@ -16,7 +23,7 @@ export async function GET( const atUriString = decodeURIComponent(uriParam); const uri = new AtUri(atUriString); - if (uri.collection === "pub.leaflet.publication") { + if (isPublicationCollection(uri.collection)) { // Get the publication record to retrieve base_path const { data: publication } = await supabaseServerClient .from("publications") @@ -28,18 +35,16 @@ export async function GET( return new NextResponse("Publication not found", { status: 404 }); } - const record = publication.record as PubLeafletPublication.Record; - const basePath = record.base_path; - - if (!basePath) { - return new NextResponse("Publication has no base_path", { + const normalizedPub = normalizePublicationRecord(publication.record); + if (!normalizedPub?.url) { + return new NextResponse("Publication has no url", { status: 404, }); } - // Redirect to the publication's hosted domain (temporary redirect since base_path can change) - return NextResponse.redirect(basePath, 307); - } else if (uri.collection === "pub.leaflet.document") { + // Redirect to the publication's hosted domain (temporary redirect since url can change) + return NextResponse.redirect(normalizedPub.url, 307); + } else if (isDocumentCollection(uri.collection)) { // Document link - need to find the publication it belongs to const { data: docInPub } = await supabaseServerClient .from("documents_in_publications") @@ -49,26 +54,23 @@ export async function GET( if (docInPub?.publication && docInPub.publications) { // Document is in a publication - redirect to domain/rkey - const record = docInPub.publications - .record as PubLeafletPublication.Record; - const basePath = record.base_path; + const normalizedPub = normalizePublicationRecord( + docInPub.publications.record, + ); - if (!basePath) { - return new NextResponse("Publication has no base_path", { + if (!normalizedPub?.url) { + return new NextResponse("Publication has no url", { status: 404, }); } - // Ensure basePath ends without trailing slash - const cleanBasePath = basePath.endsWith("/") - ? basePath.slice(0, -1) - : basePath; + // Ensure url ends without trailing slash + const cleanUrl = normalizedPub.url.endsWith("/") + ? normalizedPub.url.slice(0, -1) + : normalizedPub.url; - // Redirect to the document on the publication's domain (temporary redirect since base_path can change) - return NextResponse.redirect( - `https://${cleanBasePath}/${uri.rkey}`, - 307, - ); + // Redirect to the document on the publication's domain (temporary redirect since url can change) + return NextResponse.redirect(`${cleanUrl}/${uri.rkey}`, 307); } // If not in a publication, check if it's a standalone document diff --git a/app/p/[didOrHandle]/[rkey]/opengraph-image.ts b/app/p/[didOrHandle]/[rkey]/opengraph-image.ts index dbf93f01..2cd0a78e 100644 --- a/app/p/[didOrHandle]/[rkey]/opengraph-image.ts +++ b/app/p/[didOrHandle]/[rkey]/opengraph-image.ts @@ -1,11 +1,10 @@ import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage"; import { supabaseServerClient } from "supabase/serverClient"; -import { AtUri } from "@atproto/syntax"; -import { ids } from "lexicons/api/lexicons"; -import { PubLeafletDocument } from "lexicons/api"; import { jsonToLex } from "@atproto/lexicon"; import { idResolver } from "app/(home-pages)/reader/idResolver"; import { fetchAtprotoBlob } from "app/api/atproto_images/route"; +import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; +import { documentUriFilter } from "src/utils/uriHelpers"; export const revalidate = 60; @@ -28,15 +27,17 @@ export default async function OpenGraphImage(props: { if (did) { // Try to get the document's cover image - let { data: document } = await supabaseServerClient + let { data: documents } = await supabaseServerClient .from("documents") .select("data") - .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey).toString()) - .single(); + .or(documentUriFilter(did, params.rkey)) + .order("uri", { ascending: false }) + .limit(1); + let document = documents?.[0]; if (document) { - let docRecord = jsonToLex(document.data) as PubLeafletDocument.Record; - if (docRecord.coverImage) { + const docRecord = normalizeDocumentRecord(jsonToLex(document.data)); + if (docRecord?.coverImage) { try { // Get CID from the blob ref (handle both serialized and hydrated forms) let cid = diff --git a/app/p/[didOrHandle]/[rkey]/page.tsx b/app/p/[didOrHandle]/[rkey]/page.tsx index ed34577c..ea7cf8fc 100644 --- a/app/p/[didOrHandle]/[rkey]/page.tsx +++ b/app/p/[didOrHandle]/[rkey]/page.tsx @@ -1,11 +1,10 @@ import { supabaseServerClient } from "supabase/serverClient"; -import { AtUri } from "@atproto/syntax"; -import { ids } from "lexicons/api/lexicons"; -import { PubLeafletDocument } from "lexicons/api"; import { Metadata } from "next"; import { idResolver } from "app/(home-pages)/reader/idResolver"; import { DocumentPageRenderer } from "app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer"; import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; +import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; +import { documentUriFilter } from "src/utils/uriHelpers"; export async function generateMetadata(props: { params: Promise<{ didOrHandle: string; rkey: string }>; @@ -24,19 +23,18 @@ export async function generateMetadata(props: { } } - let { data: document } = await supabaseServerClient + let { data: documents } = await supabaseServerClient .from("documents") - .select("*, documents_in_publications(publications(*))") - .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey)) - .single(); + .select("*") + .or(documentUriFilter(did, params.rkey)) + .order("uri", { ascending: false }) + .limit(1); + let document = documents?.[0]; if (!document) return { title: "404" }; - let docRecord = document.data as PubLeafletDocument.Record; - - // For documents in publications, include publication name - let publicationName = - document.documents_in_publications[0]?.publications?.name; + const docRecord = normalizeDocumentRecord(document.data); + if (!docRecord) return { title: "404" }; return { icons: { @@ -45,9 +43,7 @@ export async function generateMetadata(props: { url: document.uri, }, }, - title: publicationName - ? `${docRecord.title} - ${publicationName}` - : docRecord.title, + title: docRecord.title, description: docRecord?.description || "", }; } diff --git a/appview/index.ts b/appview/index.ts index 25c38037..b9f5a2de 100644 --- a/appview/index.ts +++ b/appview/index.ts @@ -11,6 +11,9 @@ import { PubLeafletComment, PubLeafletPollVote, PubLeafletPollDefinition, + SiteStandardDocument, + SiteStandardPublication, + SiteStandardGraphSubscription, } from "lexicons/api"; import { AppBskyEmbedExternal, @@ -47,6 +50,9 @@ async function main() { ids.PubLeafletPollDefinition, // ids.AppBskyActorProfile, "app.bsky.feed.post", + ids.SiteStandardDocument, + ids.SiteStandardPublication, + ids.SiteStandardGraphSubscription, ], handleEvent, onError: (err) => { @@ -225,6 +231,98 @@ async function handleEvent(evt: Event) { .eq("uri", evt.uri.toString()); } } + // site.standard.document records go into the main "documents" table + // The normalization layer handles reading both pub.leaflet and site.standard formats + if (evt.collection === ids.SiteStandardDocument) { + if (evt.event === "create" || evt.event === "update") { + let record = SiteStandardDocument.validateRecord(evt.record); + if (!record.success) { + console.log(record.error); + return; + } + let docResult = await supabase.from("documents").upsert({ + uri: evt.uri.toString(), + data: record.value as Json, + }); + if (docResult.error) console.log(docResult.error); + + // site.standard.document uses "site" field to reference the publication + // For documents in publications, site is an AT-URI (at://did:plc:xxx/site.standard.publication/rkey) + // For standalone documents, site is an HTTPS URL (https://leaflet.pub/p/did:plc:xxx) + // Only link to publications table for AT-URI sites + if (record.value.site && record.value.site.startsWith("at://")) { + let siteURI = new AtUri(record.value.site); + + if (siteURI.host !== evt.uri.host) { + console.log("Unauthorized to create document in site!"); + return; + } + let docInPublicationResult = await supabase + .from("documents_in_publications") + .upsert({ + publication: record.value.site, + document: evt.uri.toString(), + }); + await supabase + .from("documents_in_publications") + .delete() + .neq("publication", record.value.site) + .eq("document", evt.uri.toString()); + + if (docInPublicationResult.error) + console.log(docInPublicationResult.error); + } + } + if (evt.event === "delete") { + await supabase.from("documents").delete().eq("uri", evt.uri.toString()); + } + } + + // site.standard.publication records go into the main "publications" table + if (evt.collection === ids.SiteStandardPublication) { + if (evt.event === "create" || evt.event === "update") { + let record = SiteStandardPublication.validateRecord(evt.record); + if (!record.success) return; + await supabase + .from("identities") + .upsert({ atp_did: evt.did }, { onConflict: "atp_did" }); + await supabase.from("publications").upsert({ + uri: evt.uri.toString(), + identity_did: evt.did, + name: record.value.name, + record: record.value as Json, + }); + } + if (evt.event === "delete") { + await supabase + .from("publications") + .delete() + .eq("uri", evt.uri.toString()); + } + } + + // site.standard.graph.subscription records go into the main "publication_subscriptions" table + if (evt.collection === ids.SiteStandardGraphSubscription) { + if (evt.event === "create" || evt.event === "update") { + let record = SiteStandardGraphSubscription.validateRecord(evt.record); + if (!record.success) return; + await supabase + .from("identities") + .upsert({ atp_did: evt.did }, { onConflict: "atp_did" }); + await supabase.from("publication_subscriptions").upsert({ + uri: evt.uri.toString(), + identity: evt.did, + publication: record.value.publication, + record: record.value as Json, + }); + } + if (evt.event === "delete") { + await supabase + .from("publication_subscriptions") + .delete() + .eq("uri", evt.uri.toString()); + } + } // if (evt.collection === ids.AppBskyActorProfile) { // //only listen to updates because we should fetch it for the first time when they subscribe! // if (evt.event === "update") { diff --git a/components/ActionBar/Publications.tsx b/components/ActionBar/Publications.tsx index 964d3b5e..305db983 100644 --- a/components/ActionBar/Publications.tsx +++ b/components/ActionBar/Publications.tsx @@ -5,9 +5,12 @@ import { useIdentityData } from "components/IdentityProvider"; import { theme } from "tailwind.config"; import { getBasePublicationURL } from "app/lish/createPub/getPublicationURL"; import { Json } from "supabase/database.types"; -import { PubLeafletPublication } from "lexicons/api"; import { AtUri } from "@atproto/syntax"; import { ActionButton } from "./ActionButton"; +import { + normalizePublicationRecord, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; import { SpeedyLink } from "components/SpeedyLink"; import { PublishSmall } from "components/Icons/PublishSmall"; import { Popover } from "components/Popover"; @@ -85,7 +88,7 @@ export const PublicationOption = (props: { record: Json; current?: boolean; }) => { - let record = props.record as PubLeafletPublication.Record | null; + let record = normalizePublicationRecord(props.record); if (!record) return; return ( @@ -181,13 +184,13 @@ export const PubListEmptyContent = (props: { compact?: boolean }) => { }; export const PubIcon = (props: { - record: PubLeafletPublication.Record; + record: NormalizedPublication | null; uri: string; small?: boolean; large?: boolean; className?: string; }) => { - if (!props.record) return; + if (!props.record) return null; let iconSizeClassName = `${props.small ? "w-4 h-4" : props.large ? "w-12 h-12" : "w-6 h-6"} rounded-full`; diff --git a/components/AtMentionLink.tsx b/components/AtMentionLink.tsx index ba1cc629..29580785 100644 --- a/components/AtMentionLink.tsx +++ b/components/AtMentionLink.tsx @@ -1,5 +1,9 @@ import { AtUri } from "@atproto/api"; import { atUriToUrl } from "src/utils/mentionUtils"; +import { + isDocumentCollection, + isPublicationCollection, +} from "src/utils/collectionHelpers"; /** * Component for rendering at-uri mentions (publications and documents) as clickable links. @@ -16,8 +20,8 @@ export function AtMentionLink({ className?: string; }) { const aturi = new AtUri(atURI); - const isPublication = aturi.collection === "pub.leaflet.publication"; - const isDocument = aturi.collection === "pub.leaflet.document"; + const isPublication = isPublicationCollection(aturi.collection); + const isDocument = isDocumentCollection(aturi.collection); // Show publication icon if available const icon = diff --git a/components/Blocks/PublicationPollBlock.tsx b/components/Blocks/PublicationPollBlock.tsx index 002095c5..f776b566 100644 --- a/components/Blocks/PublicationPollBlock.tsx +++ b/components/Blocks/PublicationPollBlock.tsx @@ -11,9 +11,9 @@ import { CloseTiny } from "components/Icons/CloseTiny"; import { useLeafletPublicationData } from "components/PageSWRDataProvider"; import { PubLeafletBlocksPoll, - PubLeafletDocument, PubLeafletPagesLinearDocument, } from "lexicons/api"; +import { getDocumentPages } from "src/utils/normalizeRecords"; import { ids } from "lexicons/api/lexicons"; /** @@ -22,19 +22,19 @@ import { ids } from "lexicons/api/lexicons"; * but disables adding new options once the poll record exists (indicated by pollUri). */ export const PublicationPollBlock = (props: BlockProps) => { - let { data: publicationData } = useLeafletPublicationData(); + let { data: publicationData, normalizedDocument } = useLeafletPublicationData(); let isSelected = useUIState((s) => s.selectedBlocks.find((b) => b.value === props.entityID), ); // Check if this poll has been published in a publication document const isPublished = useMemo(() => { - if (!publicationData?.documents?.data) return false; + if (!normalizedDocument) return false; - const docRecord = publicationData.documents - .data as PubLeafletDocument.Record; + const pages = getDocumentPages(normalizedDocument); + if (!pages) return false; // Search through all pages and blocks to find if this poll entity has been published - for (const page of docRecord.pages || []) { + for (const page of pages) { if (page.$type === "pub.leaflet.pages.linearDocument") { const linearPage = page as PubLeafletPagesLinearDocument.Main; for (const blockWrapper of linearPage.blocks || []) { @@ -50,7 +50,7 @@ export const PublicationPollBlock = (props: BlockProps) => { } } return false; - }, [publicationData, props.entityID]); + }, [normalizedDocument, props.entityID]); return ( { - let { data: pub } = useLeafletPublicationData(); + let { data: pub, normalizedPublication } = useLeafletPublicationData(); if (!pub || !pub.publications) return null; - let pubRecord = pub.publications.record as PubLeafletPublication.Record; - let showComments = pubRecord.preferences?.showComments !== false; - let showMentions = pubRecord.preferences?.showMentions !== false; + if (!normalizedPublication) return null; + let showComments = normalizedPublication.preferences?.showComments !== false; + let showMentions = normalizedPublication.preferences?.showMentions !== false; return (
diff --git a/components/PageSWRDataProvider.tsx b/components/PageSWRDataProvider.tsx index ab866443..227c17e9 100644 --- a/components/PageSWRDataProvider.tsx +++ b/components/PageSWRDataProvider.tsx @@ -6,10 +6,16 @@ import useSWR from "swr"; import { callRPC } from "app/api/rpc/client"; import { getPollData } from "actions/pollActions"; import type { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; -import { createContext, useContext } from "react"; +import { createContext, useContext, useMemo } from "react"; import { getPublicationMetadataFromLeafletData } from "src/utils/getPublicationMetadataFromLeafletData"; import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; import { AtUri } from "@atproto/syntax"; +import { + normalizeDocumentRecord, + normalizePublicationRecord, + type NormalizedDocument, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; export const StaticLeafletDataContext = createContext< null | GetLeafletDataReturnType["result"]["data"] @@ -73,8 +79,21 @@ export function useLeafletPublicationData() { // First check for leaflets in publications let pubData = getPublicationMetadataFromLeafletData(data); + // Normalize records so consumers don't have to + const normalizedPublication = useMemo( + () => normalizePublicationRecord(pubData?.publications?.record), + [pubData?.publications?.record] + ); + const normalizedDocument = useMemo( + () => normalizeDocumentRecord(pubData?.documents?.data), + [pubData?.documents?.data] + ); + return { data: pubData || null, + // Pre-normalized data - consumers should use these instead of normalizing themselves + normalizedPublication, + normalizedDocument, mutate, }; } diff --git a/components/Pages/PublicationMetadata.tsx b/components/Pages/PublicationMetadata.tsx index 32c458ab..3d2ef959 100644 --- a/components/Pages/PublicationMetadata.tsx +++ b/components/Pages/PublicationMetadata.tsx @@ -5,7 +5,6 @@ import { useReplicache } from "src/replicache"; import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; import { Separator } from "components/Layout"; import { AtUri } from "@atproto/syntax"; -import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; import { getBasePublicationURL, getPublicationURL, @@ -24,17 +23,13 @@ import { Backdater } from "./Backdater"; export const PublicationMetadata = () => { let { rep } = useReplicache(); - let { data: pub } = useLeafletPublicationData(); + let { data: pub, normalizedDocument, normalizedPublication } = useLeafletPublicationData(); let { identity } = useIdentityData(); let title = useSubscribe(rep, (tx) => tx.get("publication_title")); let description = useSubscribe(rep, (tx) => tx.get("publication_description"), ); - let record = pub?.documents?.data as PubLeafletDocument.Record | null; - let pubRecord = pub?.publications?.record as - | PubLeafletPublication.Record - | undefined; - let publishedAt = record?.publishedAt; + let publishedAt = normalizedDocument?.publishedAt; if (!pub) return null; @@ -123,18 +118,18 @@ export const PublicationMetadata = () => { {tags && ( <> - {pubRecord?.preferences?.showMentions !== false || - pubRecord?.preferences?.showComments !== false ? ( + {normalizedPublication?.preferences?.showMentions !== false || + normalizedPublication?.preferences?.showComments !== false ? ( ) : null} )} - {pubRecord?.preferences?.showMentions !== false && ( + {normalizedPublication?.preferences?.showMentions !== false && (
—
)} - {pubRecord?.preferences?.showComments !== false && ( + {normalizedPublication?.preferences?.showComments !== false && (
—
@@ -218,9 +213,8 @@ export const TextField = ({ }; export const PublicationMetadataPreview = () => { - let { data: pub } = useLeafletPublicationData(); - let record = pub?.documents?.data as PubLeafletDocument.Record | null; - let publishedAt = record?.publishedAt; + let { data: pub, normalizedDocument } = useLeafletPublicationData(); + let publishedAt = normalizedDocument?.publishedAt; if (!pub) return null; @@ -245,9 +239,8 @@ export const PublicationMetadataPreview = () => { }; const AddTags = () => { - let { data: pub } = useLeafletPublicationData(); + let { data: pub, normalizedDocument } = useLeafletPublicationData(); let { rep } = useReplicache(); - let record = pub?.documents?.data as PubLeafletDocument.Record | null; // Get tags from Replicache local state or published document let replicacheTags = useSubscribe(rep, (tx) => @@ -258,8 +251,8 @@ const AddTags = () => { let tags: string[] = []; if (Array.isArray(replicacheTags)) { tags = replicacheTags; - } else if (record?.tags && Array.isArray(record.tags)) { - tags = record.tags as string[]; + } else if (normalizedDocument?.tags && Array.isArray(normalizedDocument.tags)) { + tags = normalizedDocument.tags as string[]; } // Update tags in replicache local state diff --git a/components/PostListing.tsx b/components/PostListing.tsx index a9c6c542..cf25dd8e 100644 --- a/components/PostListing.tsx +++ b/components/PostListing.tsx @@ -7,8 +7,11 @@ import { Separator } from "components/Layout"; import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; import { useSmoker } from "components/Toast"; -import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; import { blobRefToSrc } from "src/utils/blobRefToSrc"; +import type { + NormalizedDocument, + NormalizedPublication, +} from "src/utils/normalizeRecords"; import type { Post } from "app/(home-pages)/reader/getReaderFeed"; import Link from "next/link"; @@ -17,10 +20,15 @@ import { useLocalizedDate } from "src/hooks/useLocalizedDate"; export const PostListing = (props: Post) => { let pubRecord = props.publication?.pubRecord as - | PubLeafletPublication.Record + | NormalizedPublication | undefined; - let postRecord = props.documents.data as PubLeafletDocument.Record; + let postRecord = props.documents.data as NormalizedDocument | null; + + // Don't render anything for records that can't be normalized (e.g., site.standard records without expected fields) + if (!postRecord) { + return null; + } let postUri = new AtUri(props.documents.uri); let uri = props.publication ? props.publication?.uri : props.documents.uri; @@ -110,7 +118,7 @@ export const PostListing = (props: Post) => { const PubInfo = (props: { href: string; - pubRecord: PubLeafletPublication.Record; + pubRecord: NormalizedPublication; uri: string; }) => { return ( diff --git a/components/ThemeManager/PubThemeSetter.tsx b/components/ThemeManager/PubThemeSetter.tsx index 51ee0c45..e49ffbe1 100644 --- a/components/ThemeManager/PubThemeSetter.tsx +++ b/components/ThemeManager/PubThemeSetter.tsx @@ -1,11 +1,11 @@ -import { usePublicationData } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; +import { + usePublicationData, + useNormalizedPublicationRecord, +} from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; import { useState } from "react"; import { pickers, SectionArrow } from "./ThemeSetter"; import { Color } from "react-aria-components"; -import { - PubLeafletPublication, - PubLeafletThemeBackgroundImage, -} from "lexicons/api"; +import { PubLeafletThemeBackgroundImage } from "lexicons/api"; import { AtUri } from "@atproto/syntax"; import { useLocalPubTheme } from "./PublicationThemeProvider"; import { BaseThemeProvider } from "./ThemeProvider"; @@ -35,7 +35,7 @@ export const PubThemeSetter = (props: { let [openPicker, setOpenPicker] = useState("null"); let { data, mutate } = usePublicationData(); let { publication: pub } = data || {}; - let record = pub?.record as PubLeafletPublication.Record | undefined; + let record = useNormalizedPublicationRecord(); let [showPageBackground, setShowPageBackground] = useState( !!record?.theme?.showPageBackground, ); @@ -246,7 +246,7 @@ const SamplePub = (props: { }) => { let { data } = usePublicationData(); let { publication } = data || {}; - let record = publication?.record as PubLeafletPublication.Record | null; + let record = useNormalizedPublicationRecord(); return (
{ let { data } = usePublicationData(); let { publication } = data || {}; - let record = publication?.record as PubLeafletPublication.Record | null; + let record = useNormalizedPublicationRecord(); return (
{props.children} diff --git a/components/ThemeManager/ThemeProvider.tsx b/components/ThemeManager/ThemeProvider.tsx index 5d00768c..574dc018 100644 --- a/components/ThemeManager/ThemeProvider.tsx +++ b/components/ThemeManager/ThemeProvider.tsx @@ -21,7 +21,6 @@ import { PublicationBackgroundProvider, PublicationThemeProvider, } from "./PublicationThemeProvider"; -import { PubLeafletPublication } from "lexicons/api"; import { getColorDifference } from "./themeUtils"; // define a function to set an Aria Color to a CSS Variable in RGB @@ -40,12 +39,12 @@ export function ThemeProvider(props: { children: React.ReactNode; className?: string; }) { - let { data: pub } = useLeafletPublicationData(); + let { data: pub, normalizedPublication } = useLeafletPublicationData(); if (!pub || !pub.publications) return ; return ( ); @@ -328,7 +327,7 @@ export const ThemeBackgroundProvider = (props: { entityID: string; children: React.ReactNode; }) => { - let { data: pub } = useLeafletPublicationData(); + let { data: pub, normalizedPublication } = useLeafletPublicationData(); let backgroundImage = useEntity(props.entityID, "theme/background-image"); let backgroundImageRepeat = useEntity( props.entityID, @@ -338,9 +337,7 @@ export const ThemeBackgroundProvider = (props: { return ( {props.children} diff --git a/components/ThemeManager/themeDefaults.ts b/components/ThemeManager/themeDefaults.ts new file mode 100644 index 00000000..df775809 --- /dev/null +++ b/components/ThemeManager/themeDefaults.ts @@ -0,0 +1,21 @@ +/** + * Default theme values for publications. + * Shared between client and server code. + */ + +// Hex color defaults +export const PubThemeDefaults = { + backgroundColor: "#FDFCFA", + pageBackground: "#FDFCFA", + primary: "#272727", + accentText: "#FFFFFF", + accentBackground: "#0000FF", +} as const; + +// RGB color defaults (parsed from hex values above) +export const PubThemeDefaultsRGB = { + background: { r: 253, g: 252, b: 250 }, // #FDFCFA + foreground: { r: 39, g: 39, b: 39 }, // #272727 + accent: { r: 0, g: 0, b: 255 }, // #0000FF + accentForeground: { r: 255, g: 255, b: 255 }, // #FFFFFF +} as const; diff --git a/contexts/DocumentContext.tsx b/contexts/DocumentContext.tsx new file mode 100644 index 00000000..467d9ad9 --- /dev/null +++ b/contexts/DocumentContext.tsx @@ -0,0 +1,50 @@ +"use client"; +import { createContext, useContext } from "react"; +import type { PostPageData } from "app/lish/[did]/[publication]/[rkey]/getPostPageData"; + +// Derive types from PostPageData +type NonNullPostPageData = NonNullable; +export type PublicationContext = NonNullPostPageData["publication"]; +export type CommentOnDocument = NonNullPostPageData["comments"][number]; +export type DocumentMention = NonNullPostPageData["mentions"][number]; +export type QuotesAndMentions = NonNullPostPageData["quotesAndMentions"]; + +export type DocumentContextValue = Pick< + NonNullPostPageData, + | "uri" + | "normalizedDocument" + | "normalizedPublication" + | "theme" + | "prevNext" + | "quotesAndMentions" + | "publication" + | "comments" + | "mentions" + | "leafletId" +>; + +const DocumentContext = createContext(null); + +export function useDocument() { + const ctx = useContext(DocumentContext); + if (!ctx) throw new Error("useDocument must be used within DocumentProvider"); + return ctx; +} + +export function useDocumentOptional() { + return useContext(DocumentContext); +} + +export function DocumentProvider({ + children, + value, +}: { + children: React.ReactNode; + value: DocumentContextValue; +}) { + return ( + + {children} + + ); +} diff --git a/contexts/LeafletContentContext.tsx b/contexts/LeafletContentContext.tsx new file mode 100644 index 00000000..ebbe463c --- /dev/null +++ b/contexts/LeafletContentContext.tsx @@ -0,0 +1,35 @@ +"use client"; +import { createContext, useContext } from "react"; +import type { PubLeafletContent } from "lexicons/api"; + +export type Page = PubLeafletContent.Main["pages"][number]; + +export type LeafletContentContextValue = { + pages: Page[]; +}; + +const LeafletContentContext = createContext(null); + +export function useLeafletContent() { + const ctx = useContext(LeafletContentContext); + if (!ctx) throw new Error("useLeafletContent must be used within LeafletContentProvider"); + return ctx; +} + +export function useLeafletContentOptional() { + return useContext(LeafletContentContext); +} + +export function LeafletContentProvider({ + children, + value, +}: { + children: React.ReactNode; + value: LeafletContentContextValue; +}) { + return ( + + {children} + + ); +} diff --git a/drizzle/relations.ts b/drizzle/relations.ts index 1e08d4fe..c6db789a 100644 --- a/drizzle/relations.ts +++ b/drizzle/relations.ts @@ -1,5 +1,5 @@ import { relations } from "drizzle-orm/relations"; -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"; +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"; export const notificationsRelations = relations(notifications, ({one}) => ({ identity: one(identities, { @@ -17,6 +17,8 @@ export const identitiesRelations = relations(identities, ({one, many}) => ({ fields: [identities.home_page], references: [permission_tokens.id] }), + site_standard_publications: many(site_standard_publications), + site_standard_documents: many(site_standard_documents), custom_domains_identity: many(custom_domains, { relationName: "custom_domains_identity_identities_email" }), @@ -33,6 +35,7 @@ export const identitiesRelations = relations(identities, ({one, many}) => ({ permission_token_on_homepages: many(permission_token_on_homepage), publication_domains: many(publication_domains), publication_subscriptions: many(publication_subscriptions), + site_standard_subscriptions: many(site_standard_subscriptions), })); export const publicationsRelations = relations(publications, ({one, many}) => ({ @@ -43,8 +46,8 @@ export const publicationsRelations = relations(publications, ({one, many}) => ({ subscribers_to_publications: many(subscribers_to_publications), documents_in_publications: many(documents_in_publications), publication_domains: many(publication_domains), - leaflets_in_publications: many(leaflets_in_publications), publication_subscriptions: many(publication_subscriptions), + leaflets_in_publications: many(leaflets_in_publications), })); export const comments_on_documentsRelations = relations(comments_on_documents, ({one}) => ({ @@ -62,6 +65,7 @@ export const documentsRelations = relations(documents, ({many}) => ({ comments_on_documents: many(comments_on_documents), documents_in_publications: many(documents_in_publications), document_mentions_in_bskies: many(document_mentions_in_bsky), + leaflets_to_documents: many(leaflets_to_documents), leaflets_in_publications: many(leaflets_in_publications), })); @@ -136,8 +140,9 @@ export const permission_tokensRelations = relations(permission_tokens, ({one, ma }), email_subscriptions_to_entities: many(email_subscriptions_to_entity), permission_token_on_homepages: many(permission_token_on_homepage), - leaflets_in_publications: many(leaflets_in_publications), + leaflets_to_documents: many(leaflets_to_documents), permission_token_rights: many(permission_token_rights), + leaflets_in_publications: many(leaflets_in_publications), })); export const phone_rsvps_to_entityRelations = relations(phone_rsvps_to_entity, ({one}) => ({ @@ -147,6 +152,15 @@ export const phone_rsvps_to_entityRelations = relations(phone_rsvps_to_entity, ( }), })); +export const site_standard_publicationsRelations = relations(site_standard_publications, ({one, many}) => ({ + identity: one(identities, { + fields: [site_standard_publications.identity_did], + references: [identities.atp_did] + }), + site_standard_documents_in_publications: many(site_standard_documents_in_publications), + site_standard_subscriptions: many(site_standard_subscriptions), +})); + export const custom_domain_routesRelations = relations(custom_domain_routes, ({one}) => ({ custom_domain: one(custom_domains, { fields: [custom_domain_routes.domain], @@ -179,6 +193,14 @@ export const custom_domainsRelations = relations(custom_domains, ({one, many}) = publication_domains: many(publication_domains), })); +export const site_standard_documentsRelations = relations(site_standard_documents, ({one, many}) => ({ + identity: one(identities, { + fields: [site_standard_documents.identity_did], + references: [identities.atp_did] + }), + site_standard_documents_in_publications: many(site_standard_documents_in_publications), +})); + export const email_subscriptions_to_entityRelations = relations(email_subscriptions_to_entity, ({one}) => ({ entity: one(entities, { fields: [email_subscriptions_to_entity.entity], @@ -225,14 +247,14 @@ export const subscribers_to_publicationsRelations = relations(subscribers_to_pub }), })); -export const permission_token_on_homepageRelations = relations(permission_token_on_homepage, ({one}) => ({ - identity: one(identities, { - fields: [permission_token_on_homepage.identity], - references: [identities.id] +export const site_standard_documents_in_publicationsRelations = relations(site_standard_documents_in_publications, ({one}) => ({ + site_standard_document: one(site_standard_documents, { + fields: [site_standard_documents_in_publications.document], + references: [site_standard_documents.uri] }), - permission_token: one(permission_tokens, { - fields: [permission_token_on_homepage.token], - references: [permission_tokens.id] + site_standard_publication: one(site_standard_publications, { + fields: [site_standard_documents_in_publications.publication], + references: [site_standard_publications.uri] }), })); @@ -262,6 +284,17 @@ export const bsky_postsRelations = relations(bsky_posts, ({many}) => ({ document_mentions_in_bskies: many(document_mentions_in_bsky), })); +export const permission_token_on_homepageRelations = relations(permission_token_on_homepage, ({one}) => ({ + identity: one(identities, { + fields: [permission_token_on_homepage.identity], + references: [identities.id] + }), + permission_token: one(permission_tokens, { + fields: [permission_token_on_homepage.token], + references: [permission_tokens.id] + }), +})); + export const publication_domainsRelations = relations(publication_domains, ({one}) => ({ custom_domain: one(custom_domains, { fields: [publication_domains.domain], @@ -277,29 +310,36 @@ export const publication_domainsRelations = relations(publication_domains, ({one }), })); -export const leaflets_in_publicationsRelations = relations(leaflets_in_publications, ({one}) => ({ - document: one(documents, { - fields: [leaflets_in_publications.doc], - references: [documents.uri] - }), - permission_token: one(permission_tokens, { - fields: [leaflets_in_publications.leaflet], - references: [permission_tokens.id] +export const publication_subscriptionsRelations = relations(publication_subscriptions, ({one}) => ({ + identity: one(identities, { + fields: [publication_subscriptions.identity], + references: [identities.atp_did] }), publication: one(publications, { - fields: [leaflets_in_publications.publication], + fields: [publication_subscriptions.publication], references: [publications.uri] }), })); -export const publication_subscriptionsRelations = relations(publication_subscriptions, ({one}) => ({ +export const site_standard_subscriptionsRelations = relations(site_standard_subscriptions, ({one}) => ({ identity: one(identities, { - fields: [publication_subscriptions.identity], + fields: [site_standard_subscriptions.identity], references: [identities.atp_did] }), - publication: one(publications, { - fields: [publication_subscriptions.publication], - references: [publications.uri] + site_standard_publication: one(site_standard_publications, { + fields: [site_standard_subscriptions.publication], + references: [site_standard_publications.uri] + }), +})); + +export const leaflets_to_documentsRelations = relations(leaflets_to_documents, ({one}) => ({ + document: one(documents, { + fields: [leaflets_to_documents.document], + references: [documents.uri] + }), + permission_token: one(permission_tokens, { + fields: [leaflets_to_documents.leaflet], + references: [permission_tokens.id] }), })); @@ -312,4 +352,19 @@ export const permission_token_rightsRelations = relations(permission_token_right fields: [permission_token_rights.token], references: [permission_tokens.id] }), +})); + +export const leaflets_in_publicationsRelations = relations(leaflets_in_publications, ({one}) => ({ + document: one(documents, { + fields: [leaflets_in_publications.doc], + references: [documents.uri] + }), + permission_token: one(permission_tokens, { + fields: [leaflets_in_publications.leaflet], + references: [permission_tokens.id] + }), + publication: one(publications, { + fields: [leaflets_in_publications.publication], + references: [publications.uri] + }), })); \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 153b82e6..6e1f7f71 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -136,7 +136,7 @@ export const permission_tokens = pgTable("permission_tokens", { export const identities = pgTable("identities", { id: uuid("id").defaultRandom().primaryKey().notNull(), created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), - home_page: uuid("home_page").notNull().references(() => permission_tokens.id, { onDelete: "cascade" } ), + home_page: uuid("home_page").default(sql`create_identity_homepage()`).notNull().references(() => permission_tokens.id, { onDelete: "cascade" } ), email: text("email"), atp_did: text("atp_did"), interface_state: jsonb("interface_state"), @@ -173,6 +173,13 @@ export const phone_rsvps_to_entity = pgTable("phone_rsvps_to_entity", { } }); +export const site_standard_publications = pgTable("site_standard_publications", { + uri: text("uri").primaryKey().notNull(), + data: jsonb("data").notNull(), + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + identity_did: text("identity_did").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), +}); + export const custom_domain_routes = pgTable("custom_domain_routes", { id: uuid("id").defaultRandom().primaryKey().notNull(), domain: text("domain").notNull().references(() => custom_domains.domain), @@ -188,6 +195,13 @@ export const custom_domain_routes = pgTable("custom_domain_routes", { } }); +export const site_standard_documents = pgTable("site_standard_documents", { + uri: text("uri").primaryKey().notNull(), + data: jsonb("data").notNull(), + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + identity_did: text("identity_did").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), +}); + export const custom_domains = pgTable("custom_domains", { domain: text("domain").primaryKey().notNull(), identity: text("identity").default('').references(() => identities.email, { onDelete: "cascade", onUpdate: "cascade" } ), @@ -260,14 +274,14 @@ export const subscribers_to_publications = pgTable("subscribers_to_publications" } }); -export const permission_token_on_homepage = pgTable("permission_token_on_homepage", { - token: uuid("token").notNull().references(() => permission_tokens.id, { onDelete: "cascade" } ), - identity: uuid("identity").notNull().references(() => identities.id, { onDelete: "cascade" } ), - created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), +export const site_standard_documents_in_publications = pgTable("site_standard_documents_in_publications", { + publication: text("publication").notNull().references(() => site_standard_publications.uri, { onDelete: "cascade" } ), + document: text("document").notNull().references(() => site_standard_documents.uri, { onDelete: "cascade" } ), + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), }, (table) => { return { - permission_token_creator_pkey: primaryKey({ columns: [table.token, table.identity], name: "permission_token_creator_pkey"}), + site_standard_documents_in_publications_pkey: primaryKey({ columns: [table.publication, table.document], name: "site_standard_documents_in_publications_pkey"}), } }); @@ -295,6 +309,18 @@ export const document_mentions_in_bsky = pgTable("document_mentions_in_bsky", { } }); +export const permission_token_on_homepage = pgTable("permission_token_on_homepage", { + token: uuid("token").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), + identity: uuid("identity").notNull().references(() => identities.id, { onDelete: "cascade" } ), + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + archived: boolean("archived"), +}, +(table) => { + return { + permission_token_creator_pkey: primaryKey({ columns: [table.token, table.identity], name: "permission_token_creator_pkey"}), + } +}); + export const publication_domains = pgTable("publication_domains", { publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), domain: text("domain").notNull().references(() => custom_domains.domain, { onDelete: "cascade" } ), @@ -308,23 +334,23 @@ export const publication_domains = pgTable("publication_domains", { } }); -export const leaflets_in_publications = pgTable("leaflets_in_publications", { +export const publication_subscriptions = pgTable("publication_subscriptions", { publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), - doc: text("doc").default('').references(() => documents.uri, { onDelete: "set null" } ), - leaflet: uuid("leaflet").notNull().references(() => permission_tokens.id, { onDelete: "cascade" } ), - description: text("description").default('').notNull(), - title: text("title").default('').notNull(), + identity: text("identity").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + record: jsonb("record").notNull(), + uri: text("uri").notNull(), }, (table) => { return { - leaflet_idx: index("leaflets_in_publications_leaflet_idx").on(table.leaflet), - publication_idx: index("leaflets_in_publications_publication_idx").on(table.publication), - leaflets_in_publications_pkey: primaryKey({ columns: [table.publication, table.leaflet], name: "leaflets_in_publications_pkey"}), + publication_idx: index("publication_subscriptions_publication_idx").on(table.publication), + publication_subscriptions_pkey: primaryKey({ columns: [table.publication, table.identity], name: "publication_subscriptions_pkey"}), + publication_subscriptions_uri_key: unique("publication_subscriptions_uri_key").on(table.uri), } }); -export const publication_subscriptions = pgTable("publication_subscriptions", { - publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), +export const site_standard_subscriptions = pgTable("site_standard_subscriptions", { + publication: text("publication").notNull().references(() => site_standard_publications.uri, { onDelete: "cascade" } ), identity: text("identity").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), record: jsonb("record").notNull(), @@ -332,9 +358,23 @@ export const publication_subscriptions = pgTable("publication_subscriptions", { }, (table) => { return { - publication_idx: index("publication_subscriptions_publication_idx").on(table.publication), - publication_subscriptions_pkey: primaryKey({ columns: [table.publication, table.identity], name: "publication_subscriptions_pkey"}), - publication_subscriptions_uri_key: unique("publication_subscriptions_uri_key").on(table.uri), + site_standard_subscriptions_pkey: primaryKey({ columns: [table.publication, table.identity], name: "site_standard_subscriptions_pkey"}), + site_standard_subscriptions_uri_key: unique("site_standard_subscriptions_uri_key").on(table.uri), + } +}); + +export const leaflets_to_documents = pgTable("leaflets_to_documents", { + leaflet: uuid("leaflet").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), + document: text("document").notNull().references(() => documents.uri, { onDelete: "cascade", onUpdate: "cascade" } ), + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + title: text("title").default('').notNull(), + description: text("description").default('').notNull(), + tags: text("tags").default('RRAY[').array(), + cover_image: text("cover_image"), +}, +(table) => { + return { + leaflets_to_documents_pkey: primaryKey({ columns: [table.leaflet, table.document], name: "leaflets_to_documents_pkey"}), } }); @@ -353,4 +393,22 @@ export const permission_token_rights = pgTable("permission_token_rights", { entity_set_idx: index("permission_token_rights_entity_set_idx").on(table.entity_set), permission_token_rights_pkey: primaryKey({ columns: [table.token, table.entity_set], name: "permission_token_rights_pkey"}), } +}); + +export const leaflets_in_publications = pgTable("leaflets_in_publications", { + publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), + doc: text("doc").default('').references(() => documents.uri, { onDelete: "set null" } ), + leaflet: uuid("leaflet").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), + description: text("description").default('').notNull(), + title: text("title").default('').notNull(), + archived: boolean("archived"), + tags: text("tags").default('RRAY[').array(), + cover_image: text("cover_image"), +}, +(table) => { + return { + leaflet_idx: index("leaflets_in_publications_leaflet_idx").on(table.leaflet), + publication_idx: index("leaflets_in_publications_publication_idx").on(table.publication), + leaflets_in_publications_pkey: primaryKey({ columns: [table.publication, table.leaflet], name: "leaflets_in_publications_pkey"}), + } }); \ No newline at end of file diff --git a/feeds/index.ts b/feeds/index.ts index fe4c58a2..564dd576 100644 --- a/feeds/index.ts +++ b/feeds/index.ts @@ -3,7 +3,10 @@ import { serve } from "@hono/node-server"; import { DidResolver } from "@atproto/identity"; import { parseReqNsid, verifyJwt } from "@atproto/xrpc-server"; import { supabaseServerClient } from "supabase/serverClient"; -import { PubLeafletDocument } from "lexicons/api"; +import { + normalizeDocumentRecord, + type NormalizedDocument, +} from "src/utils/normalizeRecords"; import { inngest } from "app/api/inngest/client"; import { AtUri } from "@atproto/api"; @@ -112,7 +115,7 @@ app.get("/xrpc/app.bsky.feed.getFeedSkeleton", async (c) => { ); } query = query - .not("data -> postRef", "is", null) + .or("data->postRef.not.is.null,data->bskyPostRef.not.is.null") .order("indexed_at", { ascending: false }) .order("uri", { ascending: false }) .limit(25); @@ -133,9 +136,9 @@ app.get("/xrpc/app.bsky.feed.getFeedSkeleton", async (c) => { cursor: newCursor || cursor, feed: posts.flatMap((p) => { if (!p.data) return []; - let record = p.data as PubLeafletDocument.Record; - if (!record.postRef) return []; - return { post: record.postRef.uri }; + const normalizedDoc = normalizeDocumentRecord(p.data, p.uri); + if (!normalizedDoc?.bskyPostRef) return []; + return { post: normalizedDoc.bskyPostRef.uri }; }), }); }); diff --git a/lexicons/api/index.ts b/lexicons/api/index.ts index b225cb7d..fd28a572 100644 --- a/lexicons/api/index.ts +++ b/lexicons/api/index.ts @@ -38,6 +38,7 @@ import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' import * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' import * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' import * as PubLeafletComment from './types/pub/leaflet/comment' +import * as PubLeafletContent from './types/pub/leaflet/content' import * as PubLeafletDocument from './types/pub/leaflet/document' import * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' import * as PubLeafletPagesCanvas from './types/pub/leaflet/pages/canvas' @@ -48,6 +49,11 @@ import * as PubLeafletPublication from './types/pub/leaflet/publication' import * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' import * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage' import * as PubLeafletThemeColor from './types/pub/leaflet/theme/color' +import * as SiteStandardDocument from './types/site/standard/document' +import * as SiteStandardGraphSubscription from './types/site/standard/graph/subscription' +import * as SiteStandardPublication from './types/site/standard/publication' +import * as SiteStandardThemeBasic from './types/site/standard/theme/basic' +import * as SiteStandardThemeColor from './types/site/standard/theme/color' export * as AppBskyActorProfile from './types/app/bsky/actor/profile' export * as ComAtprotoLabelDefs from './types/com/atproto/label/defs' @@ -78,6 +84,7 @@ export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' export * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' export * as PubLeafletBlocksWebsite from './types/pub/leaflet/blocks/website' export * as PubLeafletComment from './types/pub/leaflet/comment' +export * as PubLeafletContent from './types/pub/leaflet/content' export * as PubLeafletDocument from './types/pub/leaflet/document' export * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' export * as PubLeafletPagesCanvas from './types/pub/leaflet/pages/canvas' @@ -88,6 +95,11 @@ export * as PubLeafletPublication from './types/pub/leaflet/publication' export * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' export * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage' export * as PubLeafletThemeColor from './types/pub/leaflet/theme/color' +export * as SiteStandardDocument from './types/site/standard/document' +export * as SiteStandardGraphSubscription from './types/site/standard/graph/subscription' +export * as SiteStandardPublication from './types/site/standard/publication' +export * as SiteStandardThemeBasic from './types/site/standard/theme/basic' +export * as SiteStandardThemeColor from './types/site/standard/theme/color' export const PUB_LEAFLET_PAGES = { CanvasTextAlignLeft: 'pub.leaflet.pages.canvas#textAlignLeft', @@ -106,12 +118,14 @@ export class AtpBaseClient extends XrpcClient { app: AppNS com: ComNS pub: PubNS + site: SiteNS constructor(options: FetchHandler | FetchHandlerOptions) { super(options, schemas) this.app = new AppNS(this) this.com = new ComNS(this) this.pub = new PubNS(this) + this.site = new SiteNS(this) } /** @deprecated use `this` instead */ @@ -952,3 +966,292 @@ export class PubLeafletPublicationRecord { ) } } + +export class SiteNS { + _client: XrpcClient + standard: SiteStandardNS + + constructor(client: XrpcClient) { + this._client = client + this.standard = new SiteStandardNS(client) + } +} + +export class SiteStandardNS { + _client: XrpcClient + document: SiteStandardDocumentRecord + publication: SiteStandardPublicationRecord + graph: SiteStandardGraphNS + theme: SiteStandardThemeNS + + constructor(client: XrpcClient) { + this._client = client + this.graph = new SiteStandardGraphNS(client) + this.theme = new SiteStandardThemeNS(client) + this.document = new SiteStandardDocumentRecord(client) + this.publication = new SiteStandardPublicationRecord(client) + } +} + +export class SiteStandardGraphNS { + _client: XrpcClient + subscription: SiteStandardGraphSubscriptionRecord + + constructor(client: XrpcClient) { + this._client = client + this.subscription = new SiteStandardGraphSubscriptionRecord(client) + } +} + +export class SiteStandardGraphSubscriptionRecord { + _client: XrpcClient + + constructor(client: XrpcClient) { + this._client = client + } + + async list( + params: OmitKey, + ): Promise<{ + cursor?: string + records: { uri: string; value: SiteStandardGraphSubscription.Record }[] + }> { + const res = await this._client.call('com.atproto.repo.listRecords', { + collection: 'site.standard.graph.subscription', + ...params, + }) + return res.data + } + + async get( + params: OmitKey, + ): Promise<{ + uri: string + cid: string + value: SiteStandardGraphSubscription.Record + }> { + const res = await this._client.call('com.atproto.repo.getRecord', { + collection: 'site.standard.graph.subscription', + ...params, + }) + return res.data + } + + async create( + params: OmitKey< + ComAtprotoRepoCreateRecord.InputSchema, + 'collection' | 'record' + >, + record: Un$Typed, + headers?: Record, + ): Promise<{ uri: string; cid: string }> { + const collection = 'site.standard.graph.subscription' + const res = await this._client.call( + 'com.atproto.repo.createRecord', + undefined, + { collection, ...params, record: { ...record, $type: collection } }, + { encoding: 'application/json', headers }, + ) + return res.data + } + + async put( + params: OmitKey< + ComAtprotoRepoPutRecord.InputSchema, + 'collection' | 'record' + >, + record: Un$Typed, + headers?: Record, + ): Promise<{ uri: string; cid: string }> { + const collection = 'site.standard.graph.subscription' + const res = await this._client.call( + 'com.atproto.repo.putRecord', + undefined, + { collection, ...params, record: { ...record, $type: collection } }, + { encoding: 'application/json', headers }, + ) + return res.data + } + + async delete( + params: OmitKey, + headers?: Record, + ): Promise { + await this._client.call( + 'com.atproto.repo.deleteRecord', + undefined, + { collection: 'site.standard.graph.subscription', ...params }, + { headers }, + ) + } +} + +export class SiteStandardThemeNS { + _client: XrpcClient + + constructor(client: XrpcClient) { + this._client = client + } +} + +export class SiteStandardDocumentRecord { + _client: XrpcClient + + constructor(client: XrpcClient) { + this._client = client + } + + async list( + params: OmitKey, + ): Promise<{ + cursor?: string + records: { uri: string; value: SiteStandardDocument.Record }[] + }> { + const res = await this._client.call('com.atproto.repo.listRecords', { + collection: 'site.standard.document', + ...params, + }) + return res.data + } + + async get( + params: OmitKey, + ): Promise<{ uri: string; cid: string; value: SiteStandardDocument.Record }> { + const res = await this._client.call('com.atproto.repo.getRecord', { + collection: 'site.standard.document', + ...params, + }) + return res.data + } + + async create( + params: OmitKey< + ComAtprotoRepoCreateRecord.InputSchema, + 'collection' | 'record' + >, + record: Un$Typed, + headers?: Record, + ): Promise<{ uri: string; cid: string }> { + const collection = 'site.standard.document' + const res = await this._client.call( + 'com.atproto.repo.createRecord', + undefined, + { collection, ...params, record: { ...record, $type: collection } }, + { encoding: 'application/json', headers }, + ) + return res.data + } + + async put( + params: OmitKey< + ComAtprotoRepoPutRecord.InputSchema, + 'collection' | 'record' + >, + record: Un$Typed, + headers?: Record, + ): Promise<{ uri: string; cid: string }> { + const collection = 'site.standard.document' + const res = await this._client.call( + 'com.atproto.repo.putRecord', + undefined, + { collection, ...params, record: { ...record, $type: collection } }, + { encoding: 'application/json', headers }, + ) + return res.data + } + + async delete( + params: OmitKey, + headers?: Record, + ): Promise { + await this._client.call( + 'com.atproto.repo.deleteRecord', + undefined, + { collection: 'site.standard.document', ...params }, + { headers }, + ) + } +} + +export class SiteStandardPublicationRecord { + _client: XrpcClient + + constructor(client: XrpcClient) { + this._client = client + } + + async list( + params: OmitKey, + ): Promise<{ + cursor?: string + records: { uri: string; value: SiteStandardPublication.Record }[] + }> { + const res = await this._client.call('com.atproto.repo.listRecords', { + collection: 'site.standard.publication', + ...params, + }) + return res.data + } + + async get( + params: OmitKey, + ): Promise<{ + uri: string + cid: string + value: SiteStandardPublication.Record + }> { + const res = await this._client.call('com.atproto.repo.getRecord', { + collection: 'site.standard.publication', + ...params, + }) + return res.data + } + + async create( + params: OmitKey< + ComAtprotoRepoCreateRecord.InputSchema, + 'collection' | 'record' + >, + record: Un$Typed, + headers?: Record, + ): Promise<{ uri: string; cid: string }> { + const collection = 'site.standard.publication' + const res = await this._client.call( + 'com.atproto.repo.createRecord', + undefined, + { collection, ...params, record: { ...record, $type: collection } }, + { encoding: 'application/json', headers }, + ) + return res.data + } + + async put( + params: OmitKey< + ComAtprotoRepoPutRecord.InputSchema, + 'collection' | 'record' + >, + record: Un$Typed, + headers?: Record, + ): Promise<{ uri: string; cid: string }> { + const collection = 'site.standard.publication' + const res = await this._client.call( + 'com.atproto.repo.putRecord', + undefined, + { collection, ...params, record: { ...record, $type: collection } }, + { encoding: 'application/json', headers }, + ) + return res.data + } + + async delete( + params: OmitKey, + headers?: Record, + ): Promise { + await this._client.call( + 'com.atproto.repo.deleteRecord', + undefined, + { collection: 'site.standard.publication', ...params }, + { headers }, + ) + } +} diff --git a/lexicons/api/lexicons.ts b/lexicons/api/lexicons.ts index 7c46a663..07822609 100644 --- a/lexicons/api/lexicons.ts +++ b/lexicons/api/lexicons.ts @@ -1400,6 +1400,31 @@ export const schemaDict = { }, }, }, + PubLeafletContent: { + lexicon: 1, + id: 'pub.leaflet.content', + revision: 1, + description: 'A lexicon for long form rich media documents', + defs: { + main: { + type: 'object', + description: 'Content format for leaflet documents', + required: ['pages'], + properties: { + pages: { + type: 'array', + items: { + type: 'union', + refs: [ + 'lex:pub.leaflet.pages.linearDocument', + 'lex:pub.leaflet.pages.canvas', + ], + }, + }, + }, + }, + }, + }, PubLeafletDocument: { lexicon: 1, id: 'pub.leaflet.document', @@ -2082,6 +2107,252 @@ export const schemaDict = { }, }, }, + SiteStandardDocument: { + defs: { + main: { + key: 'tid', + record: { + properties: { + bskyPostRef: { + ref: 'lex:com.atproto.repo.strongRef', + type: 'ref', + }, + content: { + closed: false, + refs: ['lex:pub.leaflet.content'], + type: 'union', + }, + coverImage: { + accept: ['image/*'], + maxSize: 1000000, + type: 'blob', + }, + description: { + maxGraphemes: 300, + maxLength: 3000, + type: 'string', + }, + path: { + description: + 'combine with the publication url or the document site to construct a full url to the document', + type: 'string', + }, + publishedAt: { + format: 'datetime', + type: 'string', + }, + site: { + 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.', + format: 'uri', + type: 'string', + }, + tags: { + items: { + maxGraphemes: 50, + maxLength: 100, + type: 'string', + }, + type: 'array', + }, + textContent: { + type: 'string', + }, + theme: { + description: + 'Theme for standalone documents. For documents in publications, theme is inherited from the publication.', + ref: 'lex:pub.leaflet.publication#theme', + type: 'ref', + }, + title: { + maxGraphemes: 128, + maxLength: 1280, + type: 'string', + }, + updatedAt: { + format: 'datetime', + type: 'string', + }, + }, + required: ['site', 'title', 'publishedAt'], + type: 'object', + }, + type: 'record', + }, + }, + id: 'site.standard.document', + lexicon: 1, + }, + SiteStandardGraphSubscription: { + defs: { + main: { + description: 'Record declaring a subscription to a publication', + key: 'tid', + record: { + properties: { + publication: { + format: 'at-uri', + type: 'string', + }, + }, + required: ['publication'], + type: 'object', + }, + type: 'record', + }, + }, + id: 'site.standard.graph.subscription', + lexicon: 1, + }, + SiteStandardPublication: { + defs: { + main: { + key: 'tid', + record: { + properties: { + basicTheme: { + ref: 'lex:site.standard.theme.basic', + type: 'ref', + }, + theme: { + type: 'ref', + ref: 'lex:pub.leaflet.publication#theme', + }, + description: { + maxGraphemes: 300, + maxLength: 3000, + type: 'string', + }, + icon: { + accept: ['image/*'], + maxSize: 1000000, + type: 'blob', + }, + name: { + maxGraphemes: 128, + maxLength: 1280, + type: 'string', + }, + preferences: { + ref: 'lex:site.standard.publication#preferences', + type: 'ref', + }, + url: { + format: 'uri', + type: 'string', + }, + }, + required: ['url', 'name'], + type: 'object', + }, + type: 'record', + }, + preferences: { + properties: { + showInDiscover: { + default: true, + type: 'boolean', + }, + showComments: { + default: true, + type: 'boolean', + }, + showMentions: { + default: true, + type: 'boolean', + }, + showPrevNext: { + default: false, + type: 'boolean', + }, + }, + type: 'object', + }, + }, + id: 'site.standard.publication', + lexicon: 1, + }, + SiteStandardThemeBasic: { + defs: { + main: { + properties: { + accent: { + refs: ['lex:site.standard.theme.color#rgb'], + type: 'union', + }, + accentForeground: { + refs: ['lex:site.standard.theme.color#rgb'], + type: 'union', + }, + background: { + refs: ['lex:site.standard.theme.color#rgb'], + type: 'union', + }, + foreground: { + refs: ['lex:site.standard.theme.color#rgb'], + type: 'union', + }, + }, + required: ['background', 'foreground', 'accent', 'accentForeground'], + type: 'object', + }, + }, + id: 'site.standard.theme.basic', + lexicon: 1, + }, + SiteStandardThemeColor: { + lexicon: 1, + id: 'site.standard.theme.color', + defs: { + rgb: { + type: 'object', + required: ['r', 'g', 'b'], + properties: { + r: { + type: 'integer', + minimum: 0, + maximum: 255, + }, + g: { + type: 'integer', + minimum: 0, + maximum: 255, + }, + b: { + type: 'integer', + minimum: 0, + maximum: 255, + }, + }, + }, + rgba: { + type: 'object', + required: ['r', 'g', 'b', 'a'], + properties: { + r: { + type: 'integer', + minimum: 0, + maximum: 255, + }, + g: { + type: 'integer', + minimum: 0, + maximum: 255, + }, + b: { + type: 'integer', + minimum: 0, + maximum: 255, + }, + a: { + type: 'integer', + minimum: 0, + maximum: 100, + }, + }, + }, + }, + }, } as const satisfies Record export const schemas = Object.values(schemaDict) satisfies LexiconDoc[] export const lexicons: Lexicons = new Lexicons(schemas) @@ -2144,6 +2415,7 @@ export const ids = { PubLeafletBlocksUnorderedList: 'pub.leaflet.blocks.unorderedList', PubLeafletBlocksWebsite: 'pub.leaflet.blocks.website', PubLeafletComment: 'pub.leaflet.comment', + PubLeafletContent: 'pub.leaflet.content', PubLeafletDocument: 'pub.leaflet.document', PubLeafletGraphSubscription: 'pub.leaflet.graph.subscription', PubLeafletPagesCanvas: 'pub.leaflet.pages.canvas', @@ -2154,4 +2426,9 @@ export const ids = { PubLeafletRichtextFacet: 'pub.leaflet.richtext.facet', PubLeafletThemeBackgroundImage: 'pub.leaflet.theme.backgroundImage', PubLeafletThemeColor: 'pub.leaflet.theme.color', + SiteStandardDocument: 'site.standard.document', + SiteStandardGraphSubscription: 'site.standard.graph.subscription', + SiteStandardPublication: 'site.standard.publication', + SiteStandardThemeBasic: 'site.standard.theme.basic', + SiteStandardThemeColor: 'site.standard.theme.color', } as const diff --git a/lexicons/api/types/pub/leaflet/content.ts b/lexicons/api/types/pub/leaflet/content.ts new file mode 100644 index 00000000..1dba8ce6 --- /dev/null +++ b/lexicons/api/types/pub/leaflet/content.ts @@ -0,0 +1,33 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../lexicons' +import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' +import type * as PubLeafletPagesLinearDocument from './pages/linearDocument' +import type * as PubLeafletPagesCanvas from './pages/canvas' + +const is$typed = _is$typed, + validate = _validate +const id = 'pub.leaflet.content' + +/** Content format for leaflet documents */ +export interface Main { + $type?: 'pub.leaflet.content' + pages: ( + | $Typed + | $Typed + | { $type: string } + )[] +} + +const hashMain = 'main' + +export function isMain(v: V) { + return is$typed(v, id, hashMain) +} + +export function validateMain(v: V) { + return validate
(v, id, hashMain) +} diff --git a/lexicons/api/types/site/standard/document.ts b/lexicons/api/types/site/standard/document.ts new file mode 100644 index 00000000..dccb1dfe --- /dev/null +++ b/lexicons/api/types/site/standard/document.ts @@ -0,0 +1,43 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../lexicons' +import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' +import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef' +import type * as PubLeafletContent from '../../pub/leaflet/content' +import type * as PubLeafletPublication from '../../pub/leaflet/publication' + +const is$typed = _is$typed, + validate = _validate +const id = 'site.standard.document' + +export interface Record { + $type: 'site.standard.document' + bskyPostRef?: ComAtprotoRepoStrongRef.Main + content?: $Typed | { $type: string } + coverImage?: BlobRef + description?: string + /** combine with the publication url or the document site to construct a full url to the document */ + path?: string + publishedAt: string + /** 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. */ + site: string + tags?: string[] + textContent?: string + theme?: PubLeafletPublication.Theme + title: string + updatedAt?: string + [k: string]: unknown +} + +const hashRecord = 'main' + +export function isRecord(v: V) { + return is$typed(v, id, hashRecord) +} + +export function validateRecord(v: V) { + return validate(v, id, hashRecord, true) +} diff --git a/lexicons/api/types/site/standard/graph/subscription.ts b/lexicons/api/types/site/standard/graph/subscription.ts new file mode 100644 index 00000000..56a065a2 --- /dev/null +++ b/lexicons/api/types/site/standard/graph/subscription.ts @@ -0,0 +1,31 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../../lexicons' +import { + type $Typed, + is$typed as _is$typed, + type OmitKey, +} from '../../../../util' + +const is$typed = _is$typed, + validate = _validate +const id = 'site.standard.graph.subscription' + +export interface Record { + $type: 'site.standard.graph.subscription' + publication: string + [k: string]: unknown +} + +const hashRecord = 'main' + +export function isRecord(v: V) { + return is$typed(v, id, hashRecord) +} + +export function validateRecord(v: V) { + return validate(v, id, hashRecord, true) +} diff --git a/lexicons/api/types/site/standard/publication.ts b/lexicons/api/types/site/standard/publication.ts new file mode 100644 index 00000000..88f3b671 --- /dev/null +++ b/lexicons/api/types/site/standard/publication.ts @@ -0,0 +1,53 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../lexicons' +import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' +import type * as SiteStandardThemeBasic from './theme/basic' +import type * as PubLeafletPublication from '../../pub/leaflet/publication' + +const is$typed = _is$typed, + validate = _validate +const id = 'site.standard.publication' + +export interface Record { + $type: 'site.standard.publication' + basicTheme?: SiteStandardThemeBasic.Main + theme?: PubLeafletPublication.Theme + description?: string + icon?: BlobRef + name: string + preferences?: Preferences + url: string + [k: string]: unknown +} + +const hashRecord = 'main' + +export function isRecord(v: V) { + return is$typed(v, id, hashRecord) +} + +export function validateRecord(v: V) { + return validate(v, id, hashRecord, true) +} + +export interface Preferences { + $type?: 'site.standard.publication#preferences' + showInDiscover: boolean + showComments: boolean + showMentions: boolean + showPrevNext: boolean +} + +const hashPreferences = 'preferences' + +export function isPreferences(v: V) { + return is$typed(v, id, hashPreferences) +} + +export function validatePreferences(v: V) { + return validate(v, id, hashPreferences) +} diff --git a/lexicons/api/types/site/standard/theme/basic.ts b/lexicons/api/types/site/standard/theme/basic.ts new file mode 100644 index 00000000..72e6cd68 --- /dev/null +++ b/lexicons/api/types/site/standard/theme/basic.ts @@ -0,0 +1,34 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../../lexicons' +import { + type $Typed, + is$typed as _is$typed, + type OmitKey, +} from '../../../../util' +import type * as SiteStandardThemeColor from './color' + +const is$typed = _is$typed, + validate = _validate +const id = 'site.standard.theme.basic' + +export interface Main { + $type?: 'site.standard.theme.basic' + accent: $Typed | { $type: string } + accentForeground: $Typed | { $type: string } + background: $Typed | { $type: string } + foreground: $Typed | { $type: string } +} + +const hashMain = 'main' + +export function isMain(v: V) { + return is$typed(v, id, hashMain) +} + +export function validateMain(v: V) { + return validate
(v, id, hashMain) +} diff --git a/lexicons/api/types/site/standard/theme/color.ts b/lexicons/api/types/site/standard/theme/color.ts new file mode 100644 index 00000000..baac4487 --- /dev/null +++ b/lexicons/api/types/site/standard/theme/color.ts @@ -0,0 +1,50 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../../lexicons' +import { + type $Typed, + is$typed as _is$typed, + type OmitKey, +} from '../../../../util' + +const is$typed = _is$typed, + validate = _validate +const id = 'site.standard.theme.color' + +export interface Rgb { + $type?: 'site.standard.theme.color#rgb' + r: number + g: number + b: number +} + +const hashRgb = 'rgb' + +export function isRgb(v: V) { + return is$typed(v, id, hashRgb) +} + +export function validateRgb(v: V) { + return validate(v, id, hashRgb) +} + +export interface Rgba { + $type?: 'site.standard.theme.color#rgba' + r: number + g: number + b: number + a: number +} + +const hashRgba = 'rgba' + +export function isRgba(v: V) { + return is$typed(v, id, hashRgba) +} + +export function validateRgba(v: V) { + return validate(v, id, hashRgba) +} diff --git a/lexicons/build.ts b/lexicons/build.ts index d9fc5faa..1b5fcc29 100644 --- a/lexicons/build.ts +++ b/lexicons/build.ts @@ -10,6 +10,7 @@ import * as path from "path"; import { PubLeafletRichTextFacet } from "./src/facet"; import { PubLeafletComment } from "./src/comment"; import { PubLeafletAuthFullPermissions } from "./src/authFullPermissions"; +import { PubLeafletContent } from "./src/content"; const outdir = path.join("lexicons", "pub", "leaflet"); @@ -20,6 +21,7 @@ fs.mkdirSync(outdir, { recursive: true }); const lexicons = [ PubLeafletDocument, + PubLeafletContent, PubLeafletComment, PubLeafletRichTextFacet, PubLeafletAuthFullPermissions, diff --git a/lexicons/pub/leaflet/content.json b/lexicons/pub/leaflet/content.json new file mode 100644 index 00000000..7978c068 --- /dev/null +++ b/lexicons/pub/leaflet/content.json @@ -0,0 +1,27 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.content", + "revision": 1, + "description": "A lexicon for long form rich media documents", + "defs": { + "main": { + "type": "object", + "description": "Content format for leaflet documents", + "required": [ + "pages" + ], + "properties": { + "pages": { + "type": "array", + "items": { + "type": "union", + "refs": [ + "pub.leaflet.pages.linearDocument", + "pub.leaflet.pages.canvas" + ] + } + } + } + } + } +} \ No newline at end of file diff --git a/lexicons/site/standard/document.json b/lexicons/site/standard/document.json new file mode 100644 index 00000000..d0035ecd --- /dev/null +++ b/lexicons/site/standard/document.json @@ -0,0 +1,73 @@ +{ + "defs": { + "main": { + "key": "tid", + "record": { + "properties": { + "bskyPostRef": { + "ref": "com.atproto.repo.strongRef", + "type": "ref" + }, + "content": { + "closed": false, + "refs": ["pub.leaflet.content"], + "type": "union" + }, + "coverImage": { + "accept": ["image/*"], + "maxSize": 1000000, + "type": "blob" + }, + "description": { + "maxGraphemes": 300, + "maxLength": 3000, + "type": "string" + }, + "path": { + "description": "combine with the publication url or the document site to construct a full url to the document", + "type": "string" + }, + "publishedAt": { + "format": "datetime", + "type": "string" + }, + "site": { + "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.", + "format": "uri", + "type": "string" + }, + "tags": { + "items": { + "maxGraphemes": 50, + "maxLength": 100, + "type": "string" + }, + "type": "array" + }, + "textContent": { + "type": "string" + }, + "theme": { + "description": "Theme for standalone documents. For documents in publications, theme is inherited from the publication.", + "ref": "pub.leaflet.publication#theme", + "type": "ref" + }, + "title": { + "maxGraphemes": 128, + "maxLength": 1280, + "type": "string" + }, + "updatedAt": { + "format": "datetime", + "type": "string" + } + }, + "required": ["site", "title", "publishedAt"], + "type": "object" + }, + "type": "record" + } + }, + "id": "site.standard.document", + "lexicon": 1 +} diff --git a/lexicons/site/standard/graph/subscription.json b/lexicons/site/standard/graph/subscription.json new file mode 100644 index 00000000..79f1302c --- /dev/null +++ b/lexicons/site/standard/graph/subscription.json @@ -0,0 +1,23 @@ +{ + "defs": { + "main": { + "description": "Record declaring a subscription to a publication", + "key": "tid", + "record": { + "properties": { + "publication": { + "format": "at-uri", + "type": "string" + } + }, + "required": [ + "publication" + ], + "type": "object" + }, + "type": "record" + } + }, + "id": "site.standard.graph.subscription", + "lexicon": 1 +} diff --git a/lexicons/site/standard/publication.json b/lexicons/site/standard/publication.json new file mode 100644 index 00000000..e1c3aea5 --- /dev/null +++ b/lexicons/site/standard/publication.json @@ -0,0 +1,68 @@ +{ + "defs": { + "main": { + "key": "tid", + "record": { + "properties": { + "basicTheme": { + "ref": "site.standard.theme.basic", + "type": "ref" + }, + "theme": { + "type": "ref", + "ref": "pub.leaflet.publication#theme" + }, + "description": { + "maxGraphemes": 300, + "maxLength": 3000, + "type": "string" + }, + "icon": { + "accept": ["image/*"], + "maxSize": 1000000, + "type": "blob" + }, + "name": { + "maxGraphemes": 128, + "maxLength": 1280, + "type": "string" + }, + "preferences": { + "ref": "#preferences", + "type": "ref" + }, + "url": { + "format": "uri", + "type": "string" + } + }, + "required": ["url", "name"], + "type": "object" + }, + "type": "record" + }, + "preferences": { + "properties": { + "showInDiscover": { + "default": true, + "type": "boolean" + }, + "showComments": { + "default": true, + "type": "boolean" + }, + "showMentions": { + "default": true, + "type": "boolean" + }, + "showPrevNext": { + "default": false, + "type": "boolean" + } + }, + "type": "object" + } + }, + "id": "site.standard.publication", + "lexicon": 1 +} diff --git a/lexicons/site/standard/theme/basic.json b/lexicons/site/standard/theme/basic.json new file mode 100644 index 00000000..4e79ae33 --- /dev/null +++ b/lexicons/site/standard/theme/basic.json @@ -0,0 +1,41 @@ +{ + "defs": { + "main": { + "properties": { + "accent": { + "refs": [ + "site.standard.theme.color#rgb" + ], + "type": "union" + }, + "accentForeground": { + "refs": [ + "site.standard.theme.color#rgb" + ], + "type": "union" + }, + "background": { + "refs": [ + "site.standard.theme.color#rgb" + ], + "type": "union" + }, + "foreground": { + "refs": [ + "site.standard.theme.color#rgb" + ], + "type": "union" + } + }, + "required": [ + "background", + "foreground", + "accent", + "accentForeground" + ], + "type": "object" + } + }, + "id": "site.standard.theme.basic", + "lexicon": 1 +} diff --git a/lexicons/site/standard/theme/color.json b/lexicons/site/standard/theme/color.json new file mode 100644 index 00000000..9fdba770 --- /dev/null +++ b/lexicons/site/standard/theme/color.json @@ -0,0 +1,53 @@ +{ + "lexicon": 1, + "id": "site.standard.theme.color", + "defs": { + "rgb": { + "type": "object", + "required": ["r", "g", "b"], + "properties": { + "r": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "g": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "b": { + "type": "integer", + "minimum": 0, + "maximum": 255 + } + } + }, + "rgba": { + "type": "object", + "required": ["r", "g", "b", "a"], + "properties": { + "r": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "g": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "b": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "a": { + "type": "integer", + "minimum": 0, + "maximum": 100 + } + } + } + } +} diff --git a/lexicons/src/content.ts b/lexicons/src/content.ts new file mode 100644 index 00000000..4c8c7895 --- /dev/null +++ b/lexicons/src/content.ts @@ -0,0 +1,29 @@ +import { LexiconDoc } from "@atproto/lexicon"; +import { PubLeafletPagesLinearDocument } from "./pages/LinearDocument"; +import { PubLeafletPagesCanvasDocument } from "./pages"; + +export const PubLeafletContent: LexiconDoc = { + lexicon: 1, + id: "pub.leaflet.content", + revision: 1, + description: "A lexicon for long form rich media documents", + defs: { + main: { + type: "object", + description: "Content format for leaflet documents", + required: ["pages"], + properties: { + pages: { + type: "array", + items: { + type: "union", + refs: [ + PubLeafletPagesLinearDocument.id, + PubLeafletPagesCanvasDocument.id, + ], + }, + }, + }, + }, + }, +}; diff --git a/lexicons/src/normalize.ts b/lexicons/src/normalize.ts new file mode 100644 index 00000000..9b642c3f --- /dev/null +++ b/lexicons/src/normalize.ts @@ -0,0 +1,282 @@ +/** + * Normalization utilities for converting between pub.leaflet and site.standard lexicon formats. + * + * The standard format (site.standard.*) is used as the canonical representation for + * reading data from the database, while both formats are accepted for storage. + * + * ## Site Field Format + * + * The `site` field in site.standard.document supports two URI formats: + * - AT-URIs (at://did/collection/rkey) - Used when document belongs to an AT Protocol publication + * - HTTPS URLs (https://example.com) - Used for standalone documents or external sites + * + * Both formats are valid and should be handled by consumers. + */ + +import type * as PubLeafletDocument from "../api/types/pub/leaflet/document"; +import type * as PubLeafletPublication from "../api/types/pub/leaflet/publication"; +import type * as PubLeafletContent from "../api/types/pub/leaflet/content"; +import type * as SiteStandardDocument from "../api/types/site/standard/document"; +import type * as SiteStandardPublication from "../api/types/site/standard/publication"; +import type * as SiteStandardThemeBasic from "../api/types/site/standard/theme/basic"; +import type * as PubLeafletThemeColor from "../api/types/pub/leaflet/theme/color"; +import type { $Typed } from "../api/util"; +import { AtUri } from "@atproto/syntax"; + +// Normalized document type - uses the generated site.standard.document type +// with an additional optional theme field for backwards compatibility +export type NormalizedDocument = SiteStandardDocument.Record & { + // Keep the original theme for components that need leaflet-specific styling + theme?: PubLeafletPublication.Theme; +}; + +// Normalized publication type - uses the generated site.standard.publication type +export type NormalizedPublication = SiteStandardPublication.Record; + +/** + * Checks if the record is a pub.leaflet.document + */ +export function isLeafletDocument( + record: unknown +): record is PubLeafletDocument.Record { + if (!record || typeof record !== "object") return false; + const r = record as Record; + return ( + r.$type === "pub.leaflet.document" || + // Legacy records without $type but with pages array + (Array.isArray(r.pages) && typeof r.author === "string") + ); +} + +/** + * Checks if the record is a site.standard.document + */ +export function isStandardDocument( + record: unknown +): record is SiteStandardDocument.Record { + if (!record || typeof record !== "object") return false; + const r = record as Record; + return r.$type === "site.standard.document"; +} + +/** + * Checks if the record is a pub.leaflet.publication + */ +export function isLeafletPublication( + record: unknown +): record is PubLeafletPublication.Record { + if (!record || typeof record !== "object") return false; + const r = record as Record; + return ( + r.$type === "pub.leaflet.publication" || + // Legacy records without $type but with name and no url + (typeof r.name === "string" && !("url" in r)) + ); +} + +/** + * Checks if the record is a site.standard.publication + */ +export function isStandardPublication( + record: unknown +): record is SiteStandardPublication.Record { + if (!record || typeof record !== "object") return false; + const r = record as Record; + return r.$type === "site.standard.publication"; +} + +/** + * Extracts RGB values from a color union type + */ +function extractRgb( + color: + | $Typed + | $Typed + | { $type: string } + | undefined +): { r: number; g: number; b: number } | undefined { + if (!color || typeof color !== "object") return undefined; + const c = color as Record; + if ( + typeof c.r === "number" && + typeof c.g === "number" && + typeof c.b === "number" + ) { + return { r: c.r, g: c.g, b: c.b }; + } + return undefined; +} + +/** + * Converts a pub.leaflet theme to a site.standard.theme.basic format + */ +export function leafletThemeToBasicTheme( + theme: PubLeafletPublication.Theme | undefined +): SiteStandardThemeBasic.Main | undefined { + if (!theme) return undefined; + + const background = extractRgb(theme.backgroundColor); + const accent = extractRgb(theme.accentBackground) || extractRgb(theme.primary); + const accentForeground = extractRgb(theme.accentText); + + // If we don't have the required colors, return undefined + if (!background || !accent) return undefined; + + // Default foreground to dark if not specified + const foreground = { r: 0, g: 0, b: 0 }; + + // Default accent foreground to white if not specified + const finalAccentForeground = accentForeground || { r: 255, g: 255, b: 255 }; + + return { + $type: "site.standard.theme.basic", + background: { $type: "site.standard.theme.color#rgb", ...background }, + foreground: { $type: "site.standard.theme.color#rgb", ...foreground }, + accent: { $type: "site.standard.theme.color#rgb", ...accent }, + accentForeground: { + $type: "site.standard.theme.color#rgb", + ...finalAccentForeground, + }, + }; +} + +/** + * Normalizes a document record from either format to the standard format. + * + * @param record - The document record from the database (either pub.leaflet or site.standard) + * @param uri - Optional document URI, used to extract the rkey for the path field when normalizing pub.leaflet records + * @returns A normalized document in site.standard format, or null if invalid/unrecognized + */ +export function normalizeDocument(record: unknown, uri?: string): NormalizedDocument | null { + if (!record || typeof record !== "object") return null; + + // Pass through site.standard records directly (theme is already in correct format if present) + if (isStandardDocument(record)) { + return { + ...record, + theme: record.theme, + } as NormalizedDocument; + } + + if (isLeafletDocument(record)) { + // Convert from pub.leaflet to site.standard + const publishedAt = record.publishedAt; + + if (!publishedAt) { + return null; + } + + // For standalone documents (no publication), construct a site URL from the author + // This matches the pattern used in publishToPublication.ts for new standalone docs + const site = record.publication || `https://leaflet.pub/p/${record.author}`; + + // Extract path from URI if available + const path = uri ? new AtUri(uri).rkey : undefined; + + // Wrap pages in pub.leaflet.content structure + const content: $Typed | undefined = record.pages + ? { + $type: "pub.leaflet.content" as const, + pages: record.pages, + } + : undefined; + + return { + $type: "site.standard.document", + title: record.title, + site, + path, + publishedAt, + description: record.description, + tags: record.tags, + coverImage: record.coverImage, + bskyPostRef: record.postRef, + content, + theme: record.theme, + }; + } + + return null; +} + +/** + * Normalizes a publication record from either format to the standard format. + * + * @param record - The publication record from the database (either pub.leaflet or site.standard) + * @returns A normalized publication in site.standard format, or null if invalid/unrecognized + */ +export function normalizePublication( + record: unknown +): NormalizedPublication | null { + if (!record || typeof record !== "object") return null; + + // Pass through site.standard records directly + if (isStandardPublication(record)) { + return record; + } + + if (isLeafletPublication(record)) { + // Convert from pub.leaflet to site.standard + const url = record.base_path ? `https://${record.base_path}` : undefined; + + if (!url) { + return null; + } + + const basicTheme = leafletThemeToBasicTheme(record.theme); + + // Convert preferences to site.standard format (strip/replace $type) + const preferences: SiteStandardPublication.Preferences | undefined = + record.preferences + ? { + showInDiscover: record.preferences.showInDiscover, + showComments: record.preferences.showComments, + showMentions: record.preferences.showMentions, + showPrevNext: record.preferences.showPrevNext, + } + : undefined; + + return { + $type: "site.standard.publication", + name: record.name, + url, + description: record.description, + icon: record.icon, + basicTheme, + theme: record.theme, + preferences, + }; + } + + return null; +} + +/** + * Type guard to check if a normalized document has leaflet content + */ +export function hasLeafletContent( + doc: NormalizedDocument +): doc is NormalizedDocument & { + content: $Typed; +} { + return ( + doc.content !== undefined && + (doc.content as { $type?: string }).$type === "pub.leaflet.content" + ); +} + +/** + * Gets the pages array from a normalized document, handling both formats + */ +export function getDocumentPages( + doc: NormalizedDocument +): PubLeafletContent.Main["pages"] | undefined { + if (!doc.content) return undefined; + + if (hasLeafletContent(doc)) { + return doc.content.pages; + } + + // Unknown content type + return undefined; +} diff --git a/package-lock.json b/package-lock.json index bbf3a8cd..65a85d2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@atproto/oauth-client-node": "^0.3.8", "@atproto/sync": "^0.1.34", "@atproto/syntax": "^0.3.3", + "@atproto/tap": "^0.1.1", "@atproto/xrpc": "^0.7.5", "@atproto/xrpc-server": "^0.9.5", "@hono/node-server": "^1.14.3", @@ -264,23 +265,16 @@ } }, "node_modules/@atproto/common-web": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.3.tgz", - "integrity": "sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg==", + "version": "0.4.10", + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.10.tgz", + "integrity": "sha512-TLDZSgSKzT8ZgOrBrTGK87J1CXve9TEuY6NVVUBRkOMzRRtQzpFb9/ih5WVS/hnaWVvE30CfuyaetRoma+WKNw==", "license": "MIT", "dependencies": { - "graphemer": "^1.4.0", - "multiformats": "^9.9.0", - "uint8arrays": "3.0.0", + "@atproto/lex-data": "0.0.6", + "@atproto/lex-json": "0.0.6", "zod": "^3.23.8" } }, - "node_modules/@atproto/common-web/node_modules/multiformats": { - "version": "9.9.0", - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", - "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", - "license": "(Apache-2.0 AND MIT)" - }, "node_modules/@atproto/common/node_modules/multiformats": { "version": "9.9.0", "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", @@ -288,9 +282,9 @@ "license": "(Apache-2.0 AND MIT)" }, "node_modules/@atproto/crypto": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@atproto/crypto/-/crypto-0.4.4.tgz", - "integrity": "sha512-Yq9+crJ7WQl7sxStVpHgie5Z51R05etaK9DLWYG/7bR5T4bhdcIgF6IfklLShtZwLYdVVj+K15s0BqW9a8PSDA==", + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@atproto/crypto/-/crypto-0.4.5.tgz", + "integrity": "sha512-n40aKkMoCatP0u9Yvhrdk6fXyOHFDDbkdm4h4HCyWW+KlKl8iXfD5iV+ECq+w5BM+QH25aIpt3/j6EUNerhLxw==", "license": "MIT", "dependencies": { "@noble/curves": "^1.7.0", @@ -360,6 +354,92 @@ "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", "license": "(Apache-2.0 AND MIT)" }, + "node_modules/@atproto/lex": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@atproto/lex/-/lex-0.0.9.tgz", + "integrity": "sha512-o6gauf1lz0iyzJR0rqSj4VHOrO+Nt8+/iPb0KPojw1ieXk13zOSTSxotAoDzO/dP6y8Ey5jxwuCQGuzab/4XnQ==", + "license": "MIT", + "dependencies": { + "@atproto/lex-builder": "0.0.9", + "@atproto/lex-client": "0.0.7", + "@atproto/lex-data": "0.0.6", + "@atproto/lex-installer": "0.0.9", + "@atproto/lex-json": "0.0.6", + "@atproto/lex-schema": "0.0.7", + "tslib": "^2.8.1", + "yargs": "^17.0.0" + }, + "bin": { + "lex": "bin/lex", + "ts-lex": "bin/lex" + } + }, + "node_modules/@atproto/lex-builder": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@atproto/lex-builder/-/lex-builder-0.0.9.tgz", + "integrity": "sha512-buOFk1JpuW3twI7To7f/67zQQ1NulLHf/oasH/kTOPUAd0dNyeAa13t9eRSVGbwi0BcZYxRxBm0QzPmdLKyuyw==", + "license": "MIT", + "dependencies": { + "@atproto/lex-document": "0.0.8", + "@atproto/lex-schema": "0.0.7", + "prettier": "^3.2.5", + "ts-morph": "^27.0.0", + "tslib": "^2.8.1" + } + }, + "node_modules/@atproto/lex-builder/node_modules/@ts-morph/common": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.28.1.tgz", + "integrity": "sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==", + "license": "MIT", + "dependencies": { + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1", + "tinyglobby": "^0.2.14" + } + }, + "node_modules/@atproto/lex-builder/node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@atproto/lex-builder/node_modules/ts-morph": { + "version": "27.0.2", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-27.0.2.tgz", + "integrity": "sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w==", + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.28.1", + "code-block-writer": "^13.0.3" + } + }, + "node_modules/@atproto/lex-cbor": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@atproto/lex-cbor/-/lex-cbor-0.0.6.tgz", + "integrity": "sha512-lee2T00owDy3I1plRHuURT6f98NIpYZZr2wXa5pJZz5JzefZ+nv8gJ2V70C2f+jmSG+5S9NTIy4uJw94vaHf4A==", + "license": "MIT", + "dependencies": { + "@atproto/lex-data": "0.0.6", + "multiformats": "^9.9.0", + "tslib": "^2.8.1" + } + }, + "node_modules/@atproto/lex-cbor/node_modules/multiformats": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", + "license": "(Apache-2.0 AND MIT)" + }, "node_modules/@atproto/lex-cli": { "version": "0.9.5", "resolved": "https://registry.npmjs.org/@atproto/lex-cli/-/lex-cli-0.9.5.tgz", @@ -390,6 +470,149 @@ "dev": true, "license": "MIT" }, + "node_modules/@atproto/lex-client": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@atproto/lex-client/-/lex-client-0.0.7.tgz", + "integrity": "sha512-ofUz3yXJ0nN/M9aqqF2ZUL/4D1wWT1P4popCfV3OEDsDrtWofMflYPFz1IWuyPa2e83paaEHRhaw3bZEhgXH1w==", + "license": "MIT", + "dependencies": { + "@atproto/lex-data": "0.0.6", + "@atproto/lex-json": "0.0.6", + "@atproto/lex-schema": "0.0.7", + "tslib": "^2.8.1" + } + }, + "node_modules/@atproto/lex-data": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.6.tgz", + "integrity": "sha512-MBNB4ghRJQzuXK1zlUPljpPbQcF1LZ5dzxy274KqPt4p3uPuRw0mHjgcCoWzRUNBQC685WMQR4IN9DHtsnG57A==", + "license": "MIT", + "dependencies": { + "@atproto/syntax": "0.4.2", + "multiformats": "^9.9.0", + "tslib": "^2.8.1", + "uint8arrays": "3.0.0", + "unicode-segmenter": "^0.14.0" + } + }, + "node_modules/@atproto/lex-data/node_modules/@atproto/syntax": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", + "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", + "license": "MIT" + }, + "node_modules/@atproto/lex-data/node_modules/multiformats": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", + "license": "(Apache-2.0 AND MIT)" + }, + "node_modules/@atproto/lex-document": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@atproto/lex-document/-/lex-document-0.0.8.tgz", + "integrity": "sha512-p3l5h96Hx0vxUwbO/eas6x5h2vU0JVN1a/ktX4k3PlK9YLXfWMFsv+RdVwVZom8o0irHwlcyh1D/cY0PyUojDA==", + "license": "MIT", + "dependencies": { + "@atproto/lex-schema": "0.0.7", + "core-js": "^3", + "tslib": "^2.8.1" + } + }, + "node_modules/@atproto/lex-installer": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@atproto/lex-installer/-/lex-installer-0.0.9.tgz", + "integrity": "sha512-zEeIeSaSCb3j+zNsqqMY7+X5FO6fxy/MafaCEj42KsXQHNcobuygZsnG/0fxMj/kMvhjrNUCp/w9PyOMwx4hQg==", + "license": "MIT", + "dependencies": { + "@atproto/lex-builder": "0.0.9", + "@atproto/lex-cbor": "0.0.6", + "@atproto/lex-data": "0.0.6", + "@atproto/lex-document": "0.0.8", + "@atproto/lex-resolver": "0.0.8", + "@atproto/lex-schema": "0.0.7", + "@atproto/syntax": "0.4.2", + "tslib": "^2.8.1" + } + }, + "node_modules/@atproto/lex-installer/node_modules/@atproto/syntax": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", + "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", + "license": "MIT" + }, + "node_modules/@atproto/lex-json": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.6.tgz", + "integrity": "sha512-EILnN5cditPvf+PCNjXt7reMuzjugxAL1fpSzmzJbEMGMUwxOf5pPWxRsaA/M3Boip4NQZ+6DVrPOGUMlnqceg==", + "license": "MIT", + "dependencies": { + "@atproto/lex-data": "0.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@atproto/lex-resolver": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@atproto/lex-resolver/-/lex-resolver-0.0.8.tgz", + "integrity": "sha512-4hXT560+k5BIttouuhXOr+UkhAuFvvkJaVdqYb8vx2Ez7eHPiZ+yWkUK6FKpyGsx2whHkJzgleEA6DNWtdDlWA==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "0.2.5", + "@atproto/crypto": "0.4.5", + "@atproto/lex-client": "0.0.7", + "@atproto/lex-data": "0.0.6", + "@atproto/lex-document": "0.0.8", + "@atproto/lex-schema": "0.0.7", + "@atproto/repo": "0.8.12", + "@atproto/syntax": "0.4.2", + "tslib": "^2.8.1" + } + }, + "node_modules/@atproto/lex-resolver/node_modules/@atproto-labs/did-resolver": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.2.5.tgz", + "integrity": "sha512-he7EC6OMSifNs01a4RT9mta/yYitoKDzlK9ty2TFV5Uj/+HpB4vYMRdIDFrRW0Hcsehy90E2t/dw0t7361MEKQ==", + "license": "MIT", + "dependencies": { + "@atproto-labs/fetch": "0.2.3", + "@atproto-labs/pipe": "0.1.1", + "@atproto-labs/simple-store": "0.3.0", + "@atproto-labs/simple-store-memory": "0.1.4", + "@atproto/did": "0.2.4", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/lex-resolver/node_modules/@atproto/did": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.2.4.tgz", + "integrity": "sha512-nxNiCgXeo7pfjojq9fpfZxCO0X0xUipNVKW+AHNZwQKiUDt6zYL0VXEfm8HBUwQOCmKvj2pRRSM1Cur+tUWk3g==", + "license": "MIT", + "dependencies": { + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/lex-resolver/node_modules/@atproto/syntax": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", + "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", + "license": "MIT" + }, + "node_modules/@atproto/lex-schema": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@atproto/lex-schema/-/lex-schema-0.0.7.tgz", + "integrity": "sha512-/7HkTUsnP1rlzmVE6nnY0kl/hydL/W8V29V8BhFwdAvdDKpYcdRgzzsMe38LAt+ZOjHknRCZDIKGsbQMSbJErw==", + "license": "MIT", + "dependencies": { + "@atproto/lex-data": "0.0.6", + "@atproto/syntax": "0.4.2", + "tslib": "^2.8.1" + } + }, + "node_modules/@atproto/lex-schema/node_modules/@atproto/syntax": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", + "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", + "license": "MIT" + }, "node_modules/@atproto/lexicon": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.5.1.tgz", @@ -472,15 +695,15 @@ } }, "node_modules/@atproto/repo": { - "version": "0.8.9", - "resolved": "https://registry.npmjs.org/@atproto/repo/-/repo-0.8.9.tgz", - "integrity": "sha512-FTePZS2KEv8++pkOB8GGvm46V6uJqd/95bPA1cXTDXyw0cqeVEOItfxkCH1ky/fY71QYr0NkmqMUwuwZ/gwEtQ==", + "version": "0.8.12", + "resolved": "https://registry.npmjs.org/@atproto/repo/-/repo-0.8.12.tgz", + "integrity": "sha512-QpVTVulgfz5PUiCTELlDBiRvnsnwrFWi+6CfY88VwXzrRHd9NE8GItK7sfxQ6U65vD/idH8ddCgFrlrsn1REPQ==", "license": "MIT", "dependencies": { - "@atproto/common": "^0.4.12", - "@atproto/common-web": "^0.4.3", - "@atproto/crypto": "^0.4.4", - "@atproto/lexicon": "^0.5.1", + "@atproto/common": "^0.5.3", + "@atproto/common-web": "^0.4.7", + "@atproto/crypto": "^0.4.5", + "@atproto/lexicon": "^0.6.0", "@ipld/dag-cbor": "^7.0.0", "multiformats": "^9.9.0", "uint8arrays": "3.0.0", @@ -491,6 +714,42 @@ "node": ">=18.7.0" } }, + "node_modules/@atproto/repo/node_modules/@atproto/common": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.5.6.tgz", + "integrity": "sha512-rbWoZwHQNP8jcwjCREVecchw8aaoM5A1NCONyb9PVDWOJLRLCzojYMeIS8IbFqXo6NyIByOGddupADkkLeVBGQ==", + "license": "MIT", + "dependencies": { + "@atproto/common-web": "^0.4.10", + "@atproto/lex-cbor": "0.0.6", + "@atproto/lex-data": "0.0.6", + "iso-datestring-validator": "^2.2.2", + "multiformats": "^9.9.0", + "pino": "^8.21.0" + }, + "engines": { + "node": ">=18.7.0" + } + }, + "node_modules/@atproto/repo/node_modules/@atproto/lexicon": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.0.tgz", + "integrity": "sha512-5veb8aD+J5M0qszLJ+73KSFsFrJBgAY/nM1TSAJvGY7fNc9ZAT+PSUlmIyrdye9YznAZ07yktalls/TwNV7cHQ==", + "license": "MIT", + "dependencies": { + "@atproto/common-web": "^0.4.7", + "@atproto/syntax": "^0.4.2", + "iso-datestring-validator": "^2.2.2", + "multiformats": "^9.9.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/repo/node_modules/@atproto/syntax": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", + "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", + "license": "MIT" + }, "node_modules/@atproto/repo/node_modules/multiformats": { "version": "9.9.0", "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", @@ -535,6 +794,88 @@ "integrity": "sha512-8CNmi5DipOLaVeSMPggMe7FCksVag0aO6XZy9WflbduTKM4dFZVCs4686UeMLfGRXX+X966XgwECHoLYrovMMg==", "license": "MIT" }, + "node_modules/@atproto/tap": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@atproto/tap/-/tap-0.1.1.tgz", + "integrity": "sha512-gW4NzLOxj74TzaDOVzzzt5kl2PdC0r75XkIpYpI5xobwCfsc/DmVtwpuSw1fW9gr4Vzk2Q90S9UE4ifAFl2gyA==", + "license": "MIT", + "dependencies": { + "@atproto/common": "^0.5.6", + "@atproto/lex": "^0.0.9", + "@atproto/syntax": "^0.4.2", + "@atproto/ws-client": "^0.0.4", + "ws": "^8.12.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18.7.0" + } + }, + "node_modules/@atproto/tap/node_modules/@atproto/common": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.5.6.tgz", + "integrity": "sha512-rbWoZwHQNP8jcwjCREVecchw8aaoM5A1NCONyb9PVDWOJLRLCzojYMeIS8IbFqXo6NyIByOGddupADkkLeVBGQ==", + "license": "MIT", + "dependencies": { + "@atproto/common-web": "^0.4.10", + "@atproto/lex-cbor": "0.0.6", + "@atproto/lex-data": "0.0.6", + "iso-datestring-validator": "^2.2.2", + "multiformats": "^9.9.0", + "pino": "^8.21.0" + }, + "engines": { + "node": ">=18.7.0" + } + }, + "node_modules/@atproto/tap/node_modules/@atproto/syntax": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", + "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", + "license": "MIT" + }, + "node_modules/@atproto/tap/node_modules/multiformats": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", + "license": "(Apache-2.0 AND MIT)" + }, + "node_modules/@atproto/ws-client": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@atproto/ws-client/-/ws-client-0.0.4.tgz", + "integrity": "sha512-dox1XIymuC7/ZRhUqKezIGgooZS45C6vHCfu0PnWjfvsLCK2kAlnvX4IBkA/WpcoijDhQ9ejChnFbo/sLmgvAg==", + "license": "MIT", + "dependencies": { + "@atproto/common": "^0.5.3", + "ws": "^8.12.0" + }, + "engines": { + "node": ">=18.7.0" + } + }, + "node_modules/@atproto/ws-client/node_modules/@atproto/common": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.5.6.tgz", + "integrity": "sha512-rbWoZwHQNP8jcwjCREVecchw8aaoM5A1NCONyb9PVDWOJLRLCzojYMeIS8IbFqXo6NyIByOGddupADkkLeVBGQ==", + "license": "MIT", + "dependencies": { + "@atproto/common-web": "^0.4.10", + "@atproto/lex-cbor": "0.0.6", + "@atproto/lex-data": "0.0.6", + "iso-datestring-validator": "^2.2.2", + "multiformats": "^9.9.0", + "pino": "^8.21.0" + }, + "engines": { + "node": ">=18.7.0" + } + }, + "node_modules/@atproto/ws-client/node_modules/multiformats": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", + "license": "(Apache-2.0 AND MIT)" + }, "node_modules/@atproto/xrpc": { "version": "0.7.5", "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.5.tgz", @@ -2553,6 +2894,27 @@ "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", "license": "(Apache-2.0 AND MIT)" }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -9108,7 +9470,6 @@ "version": "13.0.3", "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", - "dev": true, "license": "MIT" }, "node_modules/collapse-white-space": { @@ -9217,6 +9578,17 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/core-js": { + "version": "3.47.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz", + "integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", @@ -11884,7 +12256,8 @@ "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true }, "node_modules/gzip-size": { "version": "6.0.0", @@ -15638,7 +16011,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", - "dev": true, "license": "MIT" }, "node_modules/path-exists": { @@ -15920,7 +16292,6 @@ "version": "3.2.5", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", - "dev": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -18024,7 +18395,6 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -18041,7 +18411,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -18059,7 +18428,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -18451,6 +18819,12 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/unicode-segmenter": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz", + "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==", + "license": "MIT" + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", diff --git a/package.json b/package.json index 01dba955..13be275d 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "TZ=UTC next dev --turbo", "publish-lexicons": "tsx lexicons/publish.ts", "generate-db-types": "supabase gen types --local > supabase/database.types.ts && drizzle-kit introspect && rm -rf ./drizzle/*.sql ./drizzle/meta", - "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", + "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", "wrangler-dev": "wrangler dev", "build-appview": "esbuild appview/index.ts --outfile=appview/dist/index.js --bundle --platform=node", "build-feed-service": "esbuild feeds/index.ts --outfile=feeds/dist/index.js --bundle --platform=node", @@ -26,6 +26,7 @@ "@atproto/oauth-client-node": "^0.3.8", "@atproto/sync": "^0.1.34", "@atproto/syntax": "^0.3.3", + "@atproto/tap": "^0.1.1", "@atproto/xrpc": "^0.7.5", "@atproto/xrpc-server": "^0.9.5", "@hono/node-server": "^1.14.3", diff --git a/patterns/lexicons.md b/patterns/lexicons.md new file mode 100644 index 00000000..0332a953 --- /dev/null +++ b/patterns/lexicons.md @@ -0,0 +1,284 @@ +# Lexicon System + +## Overview + +Lexicons define the schema for AT Protocol records. This project has two namespaces: +- **`pub.leaflet.*`** - Leaflet-specific lexicons (documents, publications, blocks, etc.) +- **`site.standard.*`** - Standard site lexicons for interoperability + +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/`. + +## Key Files + +- **`lexicons/src/*.ts`** - Source definitions for `pub.leaflet.*` lexicons +- **`lexicons/site/standard/**/*.json`** - JSON definitions for `site.standard.*` lexicons (manually maintained) +- **`lexicons/build.ts`** - Builds TypeScript sources to JSON +- **`lexicons/api/`** - Generated TypeScript types and client +- **`package.json`** - Contains `lexgen` script + +## Running Lexicon Generation + +```bash +npm run lexgen +``` + +This runs: +1. `tsx ./lexicons/build.ts` - Builds `pub.leaflet.*` JSON from TypeScript +2. `lex gen-api` - Generates TypeScript types from all JSON lexicons +3. `tsx ./lexicons/fix-extensions.ts` - Fixes import extensions + +## Adding a New pub.leaflet Lexicon + +### 1. Create the Source Definition + +Create a file in `lexicons/src/` (e.g., `lexicons/src/myLexicon.ts`): + +```typescript +import { LexiconDoc } from "@atproto/lexicon"; + +export const PubLeafletMyLexicon: LexiconDoc = { + lexicon: 1, + id: "pub.leaflet.myLexicon", + defs: { + main: { + type: "record", // or "object" for non-record types + key: "tid", + record: { + type: "object", + required: ["field1"], + properties: { + field1: { type: "string", maxLength: 1000 }, + field2: { type: "integer", minimum: 0 }, + optionalRef: { type: "ref", ref: "other.lexicon#def" }, + }, + }, + }, + // Additional defs for sub-objects + subType: { + type: "object", + properties: { + nested: { type: "string" }, + }, + }, + }, +}; +``` + +### 2. Add to Build + +Update `lexicons/build.ts`: + +```typescript +import { PubLeafletMyLexicon } from "./src/myLexicon"; + +const lexicons = [ + // ... existing lexicons + PubLeafletMyLexicon, +]; +``` + +### 3. Update lexgen Command (if needed) + +If your lexicon is at the top level of `pub/leaflet/` (not in a subdirectory), add it to the `lexgen` script in `package.json`: + +```json +"lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/document.json ./lexicons/pub/leaflet/myLexicon.json ./lexicons/pub/leaflet/*/* ..." +``` + +Note: Files in subdirectories (`pub/leaflet/*/*`) are automatically included. + +### 4. Regenerate Types + +```bash +npm run lexgen +``` + +### 5. Use the Generated Types + +```typescript +import { PubLeafletMyLexicon } from "lexicons/api"; + +// Type for the record +type MyRecord = PubLeafletMyLexicon.Record; + +// Validation +const result = PubLeafletMyLexicon.validateRecord(data); +if (result.success) { + // result.value is typed +} + +// Type guard +if (PubLeafletMyLexicon.isRecord(data)) { + // data is typed as Record +} +``` + +## Adding a New site.standard Lexicon + +### 1. Create the JSON Definition + +Create a file in `lexicons/site/standard/` (e.g., `lexicons/site/standard/myType.json`): + +```json +{ + "lexicon": 1, + "id": "site.standard.myType", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": ["field1"], + "properties": { + "field1": { + "type": "string", + "maxLength": 1000 + } + } + } + } + } +} +``` + +### 2. Regenerate Types + +```bash +npm run lexgen +``` + +The `site/*/* site/*/*/*` globs in the lexgen command automatically pick up new files. + +## Common Lexicon Patterns + +### Referencing Other Lexicons + +```typescript +// Reference another lexicon's main def +{ type: "ref", ref: "pub.leaflet.publication" } + +// Reference a specific def within a lexicon +{ type: "ref", ref: "pub.leaflet.publication#theme" } + +// Reference within the same lexicon +{ type: "ref", ref: "#myDef" } +``` + +### Union Types + +```typescript +{ + type: "union", + refs: [ + "pub.leaflet.pages.linearDocument", + "pub.leaflet.pages.canvas", + ], +} + +// Open union (allows unknown types) +{ + type: "union", + closed: false, // default is true + refs: ["pub.leaflet.content"], +} +``` + +### Blob Types (for images/files) + +```typescript +{ + type: "blob", + accept: ["image/*"], // or specific types like ["image/png", "image/jpeg"] + maxSize: 1000000, // bytes +} +``` + +### Color Types + +The project has color types defined: +- `pub.leaflet.theme.color#rgb` / `#rgba` +- `site.standard.theme.color#rgb` / `#rgba` + +```typescript +// In lexicons/src/theme.ts +export const ColorUnion = { + type: "union", + refs: [ + "pub.leaflet.theme.color#rgba", + "pub.leaflet.theme.color#rgb", + ], +}; +``` + +## Normalization Between Formats + +Use `lexicons/src/normalize.ts` to convert between `pub.leaflet` and `site.standard` formats: + +```typescript +import { + normalizeDocument, + normalizePublication, + isLeafletDocument, + isStandardDocument, + getDocumentPages, +} from "lexicons/src/normalize"; + +// Normalize a document from either format +const normalized = normalizeDocument(record); +if (normalized) { + // normalized is always in site.standard.document format + console.log(normalized.title, normalized.site); + + // Get pages if content is pub.leaflet.content + const pages = getDocumentPages(normalized); +} + +// Normalize a publication +const pub = normalizePublication(record); +if (pub) { + console.log(pub.name, pub.url); +} +``` + +## Handling in Appview (Firehose Consumer) + +When processing records from the firehose in `appview/index.ts`: + +```typescript +import { ids } from "lexicons/api/lexicons"; +import { PubLeafletMyLexicon } from "lexicons/api"; + +// In filterCollections: +filterCollections: [ + ids.PubLeafletMyLexicon, + // ... +], + +// In handleEvent: +if (evt.collection === ids.PubLeafletMyLexicon) { + if (evt.event === "create" || evt.event === "update") { + let record = PubLeafletMyLexicon.validateRecord(evt.record); + if (!record.success) return; + + // Store in database + await supabase.from("my_table").upsert({ + uri: evt.uri.toString(), + data: record.value as Json, + }); + } + if (evt.event === "delete") { + await supabase.from("my_table").delete().eq("uri", evt.uri.toString()); + } +} +``` + +## Publishing Lexicons + +To publish lexicons to an AT Protocol PDS: + +```bash +npm run publish-lexicons +``` + +This runs `lexicons/publish.ts` which publishes lexicons to the configured PDS. diff --git a/src/notifications.ts b/src/notifications.ts index 5c4acbd7..5e0711fd 100644 --- a/src/notifications.ts +++ b/src/notifications.ts @@ -4,6 +4,12 @@ import { supabaseServerClient } from "supabase/serverClient"; import { Tables, TablesInsert } from "supabase/database.types"; import { AtUri } from "@atproto/syntax"; import { idResolver } from "app/(home-pages)/reader/idResolver"; +import { + normalizeDocumentRecord, + normalizePublicationRecord, + type NormalizedDocument, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; type NotificationRow = Tables<"notifications">; @@ -99,6 +105,10 @@ async function hydrateCommentNotifications(notifications: NotificationRow[]) { ? comments?.find((c) => c.uri === notification.data.parent_uri) : undefined, commentData, + normalizedDocument: normalizeDocumentRecord(commentData.documents?.data, commentData.documents?.uri), + normalizedPublication: normalizePublicationRecord( + commentData.documents?.documents_in_publications[0]?.publications?.record, + ), }; }) .filter((n) => n !== null); @@ -140,6 +150,7 @@ async function hydrateSubscribeNotifications(notifications: NotificationRow[]) { type: "subscribe" as const, subscription_uri: notification.data.subscription_uri, subscriptionData, + normalizedPublication: normalizePublicationRecord(subscriptionData.publications?.record), }; }) .filter((n) => n !== null); @@ -187,6 +198,10 @@ async function hydrateQuoteNotifications(notifications: NotificationRow[]) { document_uri: notification.data.document_uri, bskyPost, document, + normalizedDocument: normalizeDocumentRecord(document.data, document.uri), + normalizedPublication: normalizePublicationRecord( + document.documents_in_publications[0]?.publications?.record, + ), }; }) .filter((n) => n !== null); @@ -269,6 +284,9 @@ async function hydrateMentionNotifications(notifications: NotificationRow[]) { const documentCreatorDid = new AtUri(notification.data.document_uri).host; const documentCreatorHandle = didToHandleMap.get(documentCreatorDid) ?? null; + const mentionedPublication = mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined; + const mentionedDoc = mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined; + return { id: notification.id, recipient: notification.recipient, @@ -279,8 +297,14 @@ async function hydrateMentionNotifications(notifications: NotificationRow[]) { mentioned_uri: mentionedUri, document, documentCreatorHandle, - mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, - mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, + mentionedPublication, + mentionedDocument: mentionedDoc, + normalizedDocument: normalizeDocumentRecord(document.data, document.uri), + normalizedPublication: normalizePublicationRecord( + document.documents_in_publications[0]?.publications?.record, + ), + normalizedMentionedPublication: normalizePublicationRecord(mentionedPublication?.record), + normalizedMentionedDocument: normalizeDocumentRecord(mentionedDoc?.data, mentionedDoc?.uri), }; }) .filter((n) => n !== null); @@ -365,6 +389,9 @@ async function hydrateCommentMentionNotifications(notifications: NotificationRow const commenterDid = new AtUri(notification.data.comment_uri).host; const commenterHandle = didToHandleMap.get(commenterDid) ?? null; + const mentionedPublication = mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined; + const mentionedDoc = mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined; + return { id: notification.id, recipient: notification.recipient, @@ -375,8 +402,14 @@ async function hydrateCommentMentionNotifications(notifications: NotificationRow mentioned_uri: mentionedUri, commentData, commenterHandle, - mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined, - mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined, + mentionedPublication, + mentionedDocument: mentionedDoc, + normalizedDocument: normalizeDocumentRecord(commentData.documents?.data, commentData.documents?.uri), + normalizedPublication: normalizePublicationRecord( + commentData.documents?.documents_in_publications[0]?.publications?.record, + ), + normalizedMentionedPublication: normalizePublicationRecord(mentionedPublication?.record), + normalizedMentionedDocument: normalizeDocumentRecord(mentionedDoc?.data, mentionedDoc?.uri), }; }) .filter((n) => n !== null); diff --git a/src/utils/collectionHelpers.ts b/src/utils/collectionHelpers.ts new file mode 100644 index 00000000..881b1366 --- /dev/null +++ b/src/utils/collectionHelpers.ts @@ -0,0 +1,57 @@ +import { ids } from "lexicons/api/lexicons"; + +/** + * Check if a collection is a document collection (either namespace). + */ +export function isDocumentCollection(collection: string): boolean { + return ( + collection === ids.PubLeafletDocument || + collection === ids.SiteStandardDocument + ); +} + +/** + * Check if a collection is a publication collection (either namespace). + */ +export function isPublicationCollection(collection: string): boolean { + return ( + collection === ids.PubLeafletPublication || + collection === ids.SiteStandardPublication + ); +} + +/** + * Check if a collection belongs to the site.standard namespace. + */ +export function isSiteStandardCollection(collection: string): boolean { + return collection.startsWith("site.standard."); +} + +/** + * Check if a collection belongs to the pub.leaflet namespace. + */ +export function isPubLeafletCollection(collection: string): boolean { + return collection.startsWith("pub.leaflet."); +} + +/** + * Get the document $type to use based on an existing URI's collection. + * If no existing URI or collection isn't a document, defaults to site.standard.document. + */ +export function getDocumentType(existingCollection?: string): "pub.leaflet.document" | "site.standard.document" { + if (existingCollection === ids.PubLeafletDocument) { + return ids.PubLeafletDocument as "pub.leaflet.document"; + } + return ids.SiteStandardDocument as "site.standard.document"; +} + +/** + * Get the publication $type to use based on an existing URI's collection. + * If no existing URI or collection isn't a publication, defaults to site.standard.publication. + */ +export function getPublicationType(existingCollection?: string): "pub.leaflet.publication" | "site.standard.publication" { + if (existingCollection === ids.PubLeafletPublication) { + return ids.PubLeafletPublication as "pub.leaflet.publication"; + } + return ids.SiteStandardPublication as "site.standard.publication"; +} diff --git a/src/utils/getPublicationMetadataFromLeafletData.ts b/src/utils/getPublicationMetadataFromLeafletData.ts index 32483bd4..e0220520 100644 --- a/src/utils/getPublicationMetadataFromLeafletData.ts +++ b/src/utils/getPublicationMetadataFromLeafletData.ts @@ -1,30 +1,40 @@ import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; import { Json } from "supabase/database.types"; +/** + * Return type for publication metadata extraction. + * Note: `publications.record` and `documents.data` are raw JSON from the database. + * Consumers should use `normalizePublicationRecord()` and `normalizeDocumentRecord()` + * from `src/utils/normalizeRecords` to get properly typed data. + */ +export type PublicationMetadata = { + description: string; + title: string; + leaflet: string; + doc: string | null; + publications: { + identity_did: string; + name: string; + indexed_at: string; + /** Raw record - use normalizePublicationRecord() to get typed data */ + record: Json | null; + uri: string; + } | null; + documents: { + /** Raw data - use normalizeDocumentRecord() to get typed data */ + data: Json; + indexed_at: string; + uri: string; + } | null; +} | null; + export function getPublicationMetadataFromLeafletData( data?: GetLeafletDataReturnType["result"]["data"], -) { +): PublicationMetadata { if (!data) return null; let pubData: - | { - description: string; - title: string; - leaflet: string; - doc: string | null; - publications: { - identity_did: string; - name: string; - indexed_at: string; - record: Json | null; - uri: string; - } | null; - documents: { - data: Json; - indexed_at: string; - uri: string; - } | null; - } + | NonNullable | undefined | null = data?.leaflets_in_publications?.[0] || @@ -46,5 +56,5 @@ export function getPublicationMetadataFromLeafletData( doc: standaloneDoc.document, }; } - return pubData; + return pubData || null; } diff --git a/src/utils/mentionUtils.ts b/src/utils/mentionUtils.ts index 0202ecc6..706928e0 100644 --- a/src/utils/mentionUtils.ts +++ b/src/utils/mentionUtils.ts @@ -1,4 +1,8 @@ import { AtUri } from "@atproto/api"; +import { + isDocumentCollection, + isPublicationCollection, +} from "src/utils/collectionHelpers"; /** * Converts a DID to a Bluesky profile URL @@ -14,10 +18,10 @@ export function atUriToUrl(atUri: string): string { try { const uri = new AtUri(atUri); - if (uri.collection === "pub.leaflet.publication") { + if (isPublicationCollection(uri.collection)) { // Publication URL: /lish/{did}/{rkey} return `/lish/${uri.host}/${uri.rkey}`; - } else if (uri.collection === "pub.leaflet.document") { + } else if (isDocumentCollection(uri.collection)) { // Document URL - we need to resolve this via the API // For now, create a redirect route that will handle it return `/lish/uri/${encodeURIComponent(atUri)}`; diff --git a/src/utils/normalizeRecords.ts b/src/utils/normalizeRecords.ts new file mode 100644 index 00000000..cc4df11d --- /dev/null +++ b/src/utils/normalizeRecords.ts @@ -0,0 +1,134 @@ +/** + * Utilities for normalizing pub.leaflet and site.standard records from database queries. + * + * These helpers apply the normalization functions from lexicons/src/normalize.ts + * to database query results, providing properly typed normalized records. + */ + +import { + normalizeDocument, + normalizePublication, + type NormalizedDocument, + type NormalizedPublication, +} from "lexicons/src/normalize"; +import type { Json } from "supabase/database.types"; + +/** + * Normalizes a document record from a database query result. + * Returns the normalized document or null if the record is invalid/unrecognized. + * + * @param data - The document record data from the database + * @param uri - Optional document URI, used to extract the rkey for the path field when normalizing pub.leaflet records + * + * @example + * const doc = normalizeDocumentRecord(dbResult.data, dbResult.uri); + * if (doc) { + * // doc is NormalizedDocument with proper typing + * console.log(doc.title, doc.site, doc.publishedAt); + * } + */ +export function normalizeDocumentRecord( + data: Json | unknown, + uri?: string +): NormalizedDocument | null { + return normalizeDocument(data, uri); +} + +/** + * Normalizes a publication record from a database query result. + * Returns the normalized publication or null if the record is invalid/unrecognized. + * + * @example + * const pub = normalizePublicationRecord(dbResult.record); + * if (pub) { + * // pub is NormalizedPublication with proper typing + * console.log(pub.name, pub.url); + * } + */ +export function normalizePublicationRecord( + record: Json | unknown +): NormalizedPublication | null { + return normalizePublication(record); +} + +/** + * Type helper for a document row from the database with normalized data. + * Use this when you need the full row but with typed data. + */ +export type DocumentRowWithNormalizedData< + T extends { data: Json | unknown } +> = Omit & { + data: NormalizedDocument | null; +}; + +/** + * Type helper for a publication row from the database with normalized record. + * Use this when you need the full row but with typed record. + */ +export type PublicationRowWithNormalizedRecord< + T extends { record: Json | unknown } +> = Omit & { + record: NormalizedPublication | null; +}; + +/** + * Normalizes a document row in place, returning a properly typed row. + * If the row has a `uri` field, it will be used to extract the path. + */ +export function normalizeDocumentRow( + row: T +): DocumentRowWithNormalizedData { + return { + ...row, + data: normalizeDocumentRecord(row.data, row.uri), + }; +} + +/** + * Normalizes a publication row in place, returning a properly typed row. + */ +export function normalizePublicationRow( + row: T +): PublicationRowWithNormalizedRecord { + return { + ...row, + record: normalizePublicationRecord(row.record), + }; +} + +/** + * Type guard for filtering normalized document rows with non-null data. + * Use with .filter() after .map(normalizeDocumentRow) to narrow the type. + */ +export function hasValidDocument( + row: T +): row is T & { data: NormalizedDocument } { + return row.data !== null; +} + +/** + * Type guard for filtering normalized publication rows with non-null record. + * Use with .filter() after .map(normalizePublicationRow) to narrow the type. + */ +export function hasValidPublication< + T extends { record: NormalizedPublication | null } +>(row: T): row is T & { record: NormalizedPublication } { + return row.record !== null; +} + +// Re-export the core types and functions for convenience +export { + normalizeDocument, + normalizePublication, + type NormalizedDocument, + type NormalizedPublication, +} from "lexicons/src/normalize"; + +export { + isLeafletDocument, + isStandardDocument, + isLeafletPublication, + isStandardPublication, + hasLeafletContent, + getDocumentPages, +} from "lexicons/src/normalize"; diff --git a/src/utils/uriHelpers.ts b/src/utils/uriHelpers.ts new file mode 100644 index 00000000..45786e3a --- /dev/null +++ b/src/utils/uriHelpers.ts @@ -0,0 +1,34 @@ +import { AtUri } from "@atproto/syntax"; +import { ids } from "lexicons/api/lexicons"; + +/** + * Returns an OR filter string for Supabase queries to match either namespace URI. + * Used for querying documents that may be stored under either pub.leaflet.document + * or site.standard.document namespaces. + */ +export function documentUriFilter(did: string, rkey: string): string { + const standard = AtUri.make(did, ids.SiteStandardDocument, rkey).toString(); + const legacy = AtUri.make(did, ids.PubLeafletDocument, rkey).toString(); + return `uri.eq.${standard},uri.eq.${legacy}`; +} + +/** + * Returns an OR filter string for Supabase queries to match either namespace URI. + * Used for querying publications that may be stored under either pub.leaflet.publication + * or site.standard.publication namespaces. + */ +export function publicationUriFilter(did: string, rkey: string): string { + const standard = AtUri.make(did, ids.SiteStandardPublication, rkey).toString(); + const legacy = AtUri.make(did, ids.PubLeafletPublication, rkey).toString(); + return `uri.eq.${standard},uri.eq.${legacy}`; +} + +/** + * Returns an OR filter string for Supabase queries to match a publication by name + * or by either namespace URI. Used when the rkey might be the publication name. + */ +export function publicationNameOrUriFilter(did: string, nameOrRkey: string): string { + const standard = AtUri.make(did, ids.SiteStandardPublication, nameOrRkey).toString(); + const legacy = AtUri.make(did, ids.PubLeafletPublication, nameOrRkey).toString(); + return `name.eq.${nameOrRkey},uri.eq.${standard},uri.eq.${legacy}`; +} diff --git a/supabase/database.types.ts b/supabase/database.types.ts index e9197185..757213b4 100644 --- a/supabase/database.types.ts +++ b/supabase/database.types.ts @@ -586,6 +586,7 @@ export type Database = { doc: string | null leaflet: string publication: string + tags: string[] | null title: string } Insert: { @@ -595,6 +596,7 @@ export type Database = { doc?: string | null leaflet: string publication: string + tags?: string[] | null title?: string } Update: { @@ -604,6 +606,7 @@ export type Database = { doc?: string | null leaflet?: string publication?: string + tags?: string[] | null title?: string } Relationships: [ @@ -632,27 +635,33 @@ export type Database = { } leaflets_to_documents: { Row: { + archived: boolean | null cover_image: string | null created_at: string description: string document: string leaflet: string + tags: string[] | null title: string } Insert: { + archived?: boolean | null cover_image?: string | null created_at?: string description?: string document: string leaflet: string + tags?: string[] | null title?: string } Update: { + archived?: boolean | null cover_image?: string | null created_at?: string description?: string document?: string leaflet?: string + tags?: string[] | null title?: string } Relationships: [ @@ -762,7 +771,7 @@ export type Database = { referencedColumns: ["id"] }, { - foreignKeyName: "permission_token_creator_token_fkey" + foreignKeyName: "permission_token_on_homepage_token_fkey" columns: ["token"] isOneToOne: false referencedRelation: "permission_tokens" @@ -1080,6 +1089,136 @@ export type Database = { } Relationships: [] } + site_standard_documents: { + Row: { + data: Json + identity_did: string + indexed_at: string + uri: string + } + Insert: { + data: Json + identity_did: string + indexed_at?: string + uri: string + } + Update: { + data?: Json + identity_did?: string + indexed_at?: string + uri?: string + } + Relationships: [ + { + foreignKeyName: "site_standard_documents_identity_did_fkey" + columns: ["identity_did"] + isOneToOne: false + referencedRelation: "identities" + referencedColumns: ["atp_did"] + }, + ] + } + site_standard_documents_in_publications: { + Row: { + document: string + indexed_at: string + publication: string + } + Insert: { + document: string + indexed_at?: string + publication: string + } + Update: { + document?: string + indexed_at?: string + publication?: string + } + Relationships: [ + { + foreignKeyName: "site_standard_documents_in_publications_document_fkey" + columns: ["document"] + isOneToOne: false + referencedRelation: "site_standard_documents" + referencedColumns: ["uri"] + }, + { + foreignKeyName: "site_standard_documents_in_publications_publication_fkey" + columns: ["publication"] + isOneToOne: false + referencedRelation: "site_standard_publications" + referencedColumns: ["uri"] + }, + ] + } + site_standard_publications: { + Row: { + data: Json + identity_did: string + indexed_at: string + uri: string + } + Insert: { + data: Json + identity_did: string + indexed_at?: string + uri: string + } + Update: { + data?: Json + identity_did?: string + indexed_at?: string + uri?: string + } + Relationships: [ + { + foreignKeyName: "site_standard_publications_identity_did_fkey" + columns: ["identity_did"] + isOneToOne: false + referencedRelation: "identities" + referencedColumns: ["atp_did"] + }, + ] + } + site_standard_subscriptions: { + Row: { + created_at: string + identity: string + publication: string + record: Json + uri: string + } + Insert: { + created_at?: string + identity: string + publication: string + record: Json + uri: string + } + Update: { + created_at?: string + identity?: string + publication?: string + record?: Json + uri?: string + } + Relationships: [ + { + foreignKeyName: "site_standard_subscriptions_identity_fkey" + columns: ["identity"] + isOneToOne: false + referencedRelation: "identities" + referencedColumns: ["atp_did"] + }, + { + foreignKeyName: "site_standard_subscriptions_publication_fkey" + columns: ["publication"] + isOneToOne: false + referencedRelation: "site_standard_publications" + referencedColumns: ["uri"] + }, + ] + } subscribers_to_publications: { Row: { created_at: string