diff --git a/actions/publishPublicationPages.ts b/actions/publishPublicationPages.ts new file mode 100644 index 00000000..102863c4 --- /dev/null +++ b/actions/publishPublicationPages.ts @@ -0,0 +1,143 @@ +"use server"; + +import { AtpBaseClient } from "lexicons/api"; +import { Json } from "supabase/database.types"; +import type { Fact } from "src/replicache"; +import type { Attribute } from "src/replicache/attributes"; +import { Lock } from "src/utils/lock"; +import { OAuthSessionError, restoreOAuthSession } from "src/atproto-oauth"; +import { get_leaflet_data } from "app/api/rpc/[command]/get_leaflet_data"; +import { getIdentityData } from "actions/getIdentityData"; +import { + leafletToPublicationPageRecord, + pathToRkey, +} from "src/utils/leafletToPublicationPageRecord"; +import { supabaseServerClient } from "supabase/serverClient"; + +type PublishPagesResult = + | { success: true; published: { id: number; uri: string }[] } + | { success: false; error: OAuthSessionError | { type: "other"; message: string } }; + +export async function publishPublicationPages({ + publication_uri, +}: { + publication_uri: string; +}): Promise { + const identity = await getIdentityData(); + if (!identity || !identity.atp_did) { + return { + success: false, + error: { + type: "oauth_session_expired", + message: "Not authenticated", + did: "", + }, + }; + } + + const { data: publication } = await supabaseServerClient + .from("publications") + .select("uri, identity_did") + .eq("uri", publication_uri) + .single(); + if (!publication || publication.identity_did !== identity.atp_did) { + return { + success: false, + error: { type: "other", message: "Not the publication owner" }, + }; + } + + const sessionResult = await restoreOAuthSession(identity.atp_did); + if (!sessionResult.ok) { + return { success: false, error: sessionResult.error }; + } + const credentialSession = sessionResult.value; + const agent = new AtpBaseClient( + credentialSession.fetchHandler.bind(credentialSession), + ); + + const { data: pageRows } = await supabaseServerClient + .from("publication_pages") + .select("id, title, path, leaflet_src") + .eq("publication", publication_uri); + if (!pageRows || pageRows.length === 0) { + return { success: true, published: [] }; + } + + const uploadLock = new Lock(); + + const published: { id: number; uri: string }[] = []; + for (const page of pageRows) { + const path = page.path ?? "/"; + + const { result: leafletRes } = await get_leaflet_data.handler( + { token_id: page.leaflet_src }, + { supabase: supabaseServerClient }, + ); + const rootEntity = leafletRes.data?.root_entity; + if (!rootEntity) continue; + + const { data: factData } = await supabaseServerClient.rpc("get_facts", { + root: rootEntity, + }); + const facts = (factData as unknown as Fact[]) || []; + + const record = await leafletToPublicationPageRecord({ + facts, + root_entity: rootEntity, + publication_uri, + path, + title: page.title ?? "", + hooks: { + uploadImage: async (src: string) => { + const data = await fetch(src); + if (data.status !== 200) return; + const binary = await data.blob(); + return uploadLock.withLock(async () => { + const blob = await agent.com.atproto.repo.uploadBlob(binary, { + headers: { "Content-Type": binary.type }, + }); + return blob.data.blob; + }); + }, + uploadPoll: async (entityId, pollRecord) => { + const { data: pollResult } = await agent.com.atproto.repo.putRecord({ + rkey: entityId, + repo: credentialSession.did!, + collection: pollRecord.$type, + record: pollRecord, + validate: false, + }); + await supabaseServerClient.from("atp_poll_records").upsert({ + uri: pollResult.uri, + cid: pollResult.cid, + record: pollRecord as Json, + }); + return { uri: pollResult.uri, cid: pollResult.cid }; + }, + }, + }); + + const rkey = pathToRkey(path); + const { data: putResult } = await agent.com.atproto.repo.putRecord({ + rkey, + repo: credentialSession.did!, + collection: "pub.leaflet.publicationPage", + record, + validate: false, + }); + + await supabaseServerClient + .from("publication_pages") + .update({ + record: record as unknown as Json, + record_uri: putResult.uri, + }) + .eq("id", page.id) + .eq("publication", publication_uri); + + published.push({ id: page.id, uri: putResult.uri }); + } + + return { success: true, published }; +} diff --git a/app/api/rpc/[command]/get_publication_data.ts b/app/api/rpc/[command]/get_publication_data.ts index b3f023b5..c81a9d09 100644 --- a/app/api/rpc/[command]/get_publication_data.ts +++ b/app/api/rpc/[command]/get_publication_data.ts @@ -126,6 +126,8 @@ export const get_publication_data = makeRoute({ path: p.path, document: p.document, metadata: p.metadata, + record: p.record, + record_uri: p.record_uri, leaflet_src: p.leaflet_src, created_at: p.created_at, permission_tokens: p.permission_tokens, diff --git a/app/lish/[did]/[publication]/PublicationContent.tsx b/app/lish/[did]/[publication]/PublicationContent.tsx index b22ea9a0..686070ab 100644 --- a/app/lish/[did]/[publication]/PublicationContent.tsx +++ b/app/lish/[did]/[publication]/PublicationContent.tsx @@ -2,6 +2,7 @@ import React from "react"; import { PublicationHomeLayout } from "./PublicationHomeLayout"; import { PublicationAuthor } from "./PublicationAuthor"; import { PublicationHeader } from "./PublicationHeader"; +import { PublicationNav } from "./PublicationNav"; import { normalizePublicationRecord, normalizeDocumentRecord, @@ -33,6 +34,12 @@ export const PublicationContent = ({ record: unknown; publication_subscriptions: { identity: string }[]; publication_newsletter_settings: { enabled: boolean } | null; + publication_pages?: { + id: number; + path: string | null; + title: string | null; + record_uri: string | null; + }[]; documents_in_publications: { documents: { uri: string; @@ -106,6 +113,14 @@ export const PublicationContent = ({ } /> + p.record_uri, + )} + activePath="/" + /> { + let aHome = a.path === "/" ? 0 : 1; + let bHome = b.path === "/" ? 0 : 1; + if (aHome !== bHome) return aHome - bHome; + return (a.path ?? "").localeCompare(b.path ?? ""); + }); + + let homeActive = props.activePath === null || props.activePath === "/"; + + return ( + + ); +} diff --git a/app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx b/app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx index 69528b90..0338add7 100644 --- a/app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx +++ b/app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx @@ -30,6 +30,7 @@ import { DocumentProvider } from "contexts/DocumentContext"; import { LeafletContentProvider } from "contexts/LeafletContentContext"; import { FontLoader } from "components/FontLoader"; import { mergePreferences } from "src/utils/mergePreferences"; +import { PublicationNav } from "../PublicationNav"; export async function DocumentPageRenderer({ did, @@ -167,6 +168,14 @@ export async function DocumentPageRenderer({ pub_creator={pub_creator} > + {document.publication?.pages?.length ? ( + + ) : null} ; + postsListData?: PostsListData; }) { return (
@@ -117,6 +132,7 @@ export let Block = ({ pages, pollData, footnoteIndexMap, + postsListData, isFirst, isLast, }: { @@ -134,6 +150,7 @@ export let Block = ({ standardSitePostData: StandardSitePostData[]; pollData: PollData[]; footnoteIndexMap?: Map; + postsListData?: PostsListData; isFirst?: boolean; isLast?: boolean; }) => { @@ -244,6 +261,22 @@ export let Block = ({ case PubLeafletBlocksHorizontalRule.isMain(b.block): { return
; } + case PubLeafletBlocksPostsList.isMain(b.block): { + if (!postsListData) return null; + const view: "compact" | "full" = + b.block.view === "compact" ? "compact" : "full"; + return ( +
+ +
+ ); + } case PubLeafletBlocksPoll.isMain(b.block): { let { cid, uri } = b.block.pollRef; const pollVoteData = pollData.find((p) => p.uri === uri && p.cid === cid); diff --git a/app/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx b/app/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx new file mode 100644 index 00000000..da2adbdd --- /dev/null +++ b/app/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx @@ -0,0 +1,313 @@ +import { ids } from "lexicons/api/lexicons"; +import { + PubLeafletBlocksBskyPost, + PubLeafletBlocksPoll, + PubLeafletBlocksPostsList, + PubLeafletBlocksStandardSitePost, + PubLeafletBlocksOrderedList, + PubLeafletBlocksUnorderedList, + PubLeafletPagesLinearDocument, + PubLeafletPublicationPage, +} from "lexicons/api"; +import { type $Typed } from "lexicons/api/util"; +import { AtpAgent } from "@atproto/api"; + +import { supabaseServerClient } from "supabase/serverClient"; +import { + normalizeDocumentRecord, + normalizePublicationRecord, + type NormalizedPublication, +} from "src/utils/normalizeRecords"; +import { get_standard_site_posts } from "app/api/rpc/[command]/get_standard_site_posts"; + +import { + PublicationBackgroundProvider, + PublicationThemeProvider, +} from "components/ThemeManager/PublicationThemeProvider"; +import { FontLoader } from "components/FontLoader"; +import { LeafletContentProvider } from "contexts/LeafletContentContext"; +import { DocumentProvider } from "contexts/DocumentContext"; +import type { DocumentContextValue } from "contexts/DocumentContext"; +import { + type PublicationPostsListPost, +} from "../PublicationPostsList"; +import { PublicationNav } from "../PublicationNav"; +import { PublicationHeader } from "../PublicationHeader"; +import { PublicationHomeLayout } from "../PublicationHomeLayout"; + +import { extractCodeBlocks } from "./extractCodeBlocks"; +import { fetchPollData } from "./fetchPollData"; +import { PostContent } from "./PostContent"; + +export type PublicationPageRecord = PubLeafletPublicationPage.Record; + +type PublicationRow = { + uri: string; + name: string; + identity_did: string; + record: unknown; + publication_pages?: { + id: number; + path: string | null; + title: string; + record_uri: string | null; + }[]; + documents_in_publications?: { + documents: { + uri: string; + data: unknown; + comments_on_documents?: { count: number }[]; + document_mentions_in_bsky?: { count: number }[]; + recommends_on_documents?: { count: number }[]; + } | null; + }[]; +}; + +export async function PublicationPageRenderer({ + did, + page, + publication, +}: { + did: string; + page: { id: number; path: string; title: string | null; record: PublicationPageRecord }; + publication: PublicationRow; +}) { + const normalizedPublication = normalizePublicationRecord(publication.record); + const pages = page.record.content.pages || []; + const firstPage = pages[0]; + + const allBlocks: PubLeafletPagesLinearDocument.Block[] = + firstPage && firstPage.$type === "pub.leaflet.pages.linearDocument" + ? ((firstPage as PubLeafletPagesLinearDocument.Main).blocks ?? []) + : []; + + const agent = new AtpAgent({ + service: "https://public.api.bsky.app", + fetch: (...args) => + fetch(args[0], { + ...args[1], + next: { revalidate: 3600 }, + }), + }); + + const bskyPostBlocks = extractBlocksByType< + $Typed + >(allBlocks, ids.PubLeafletBlocksBskyPost); + const bskyPostBatches: typeof bskyPostBlocks[] = []; + for (let i = 0; i < bskyPostBlocks.length; i += 25) { + bskyPostBatches.push(bskyPostBlocks.slice(i, i + 25)); + } + const bskyPostResponses = await Promise.all( + bskyPostBatches.map((batch) => + agent.getPosts( + { uris: batch.map((p) => p.block.postRef.uri) }, + { headers: {} }, + ), + ), + ); + const bskyPostData = bskyPostResponses.flatMap((r) => r.data.posts); + + const standardSitePostUris = Array.from( + new Set( + extractBlocksByType<$Typed>( + allBlocks, + ids.PubLeafletBlocksStandardSitePost, + ).map((b) => b.block.uri), + ), + ); + const standardSitePostsResult = + standardSitePostUris.length > 0 + ? await get_standard_site_posts.handler( + { uris: standardSitePostUris }, + { supabase: supabaseServerClient }, + ) + : { result: { posts: [] } }; + const standardSitePosts = standardSitePostsResult.result.posts; + + const pollBlocks = extractBlocksByType<$Typed>( + allBlocks, + ids.PubLeafletBlocksPoll, + ); + const pollData = await fetchPollData( + pollBlocks.map((b) => b.block.pollRef.uri), + ); + + const hasPostsList = allBlocks.some((b) => + PubLeafletBlocksPostsList.isMain(b.block), + ); + const postsListPosts: PublicationPostsListPost[] = hasPostsList + ? (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, + commentsCount: dip.documents.comments_on_documents?.[0]?.count || 0, + mentionsCount: + dip.documents.document_mentions_in_bsky?.[0]?.count || 0, + recommendsCount: + dip.documents.recommends_on_documents?.[0]?.count || 0, + }; + }) + .filter((p): p is PublicationPostsListPost => p !== null) + : []; + + const postsListData = hasPostsList + ? { + publication: { uri: publication.uri, record: publication.record }, + publicationRecord: normalizedPublication as NormalizedPublication | null, + posts: postsListPosts, + } + : undefined; + + const prerenderedCodeBlocks = await extractCodeBlocks(allBlocks); + + const theme = normalizedPublication?.theme; + const showPageBackground = !!theme?.showPageBackground; + const profileResp = await agent.getProfile({ actor: did }).then( + (res) => res.data, + () => undefined, + ); + + const documentContextValue: DocumentContextValue = { + uri: page.record.publication, + normalizedDocument: null as unknown as DocumentContextValue["normalizedDocument"], + normalizedPublication, + theme, + prevNext: undefined, + quotesAndMentions: [], + publication: { + uri: publication.uri, + name: publication.name, + identity_did: publication.identity_did, + record: publication.record as NonNullable< + DocumentContextValue["publication"] + >["record"], + publication_subscriptions: [], + newsletterMode: false, + pages: (publication.publication_pages ?? []).filter((p) => p.record_uri), + }, + comments: [], + mentions: [], + leafletId: null, + recommendsCount: 0, + }; + + return ( + + + + + + + + by {profileResp.displayName || profileResp.handle} + + ) : undefined + } + /> + p.record_uri, + )} + activePath={page.path} + /> +
+ +
+
+
+
+
+
+ ); +} + +function extractBlocksByType( + blocks: PubLeafletPagesLinearDocument.Block[], + type: string, +): { block: T }[] { + const results: { block: T }[] = []; + for (const b of blocks) { + if (b.block.$type === type) { + results.push(b as unknown as { block: T }); + } + if ( + b.block.$type === ids.PubLeafletBlocksOrderedList || + b.block.$type === ids.PubLeafletBlocksUnorderedList + ) { + const list = b.block as + | PubLeafletBlocksOrderedList.Main + | PubLeafletBlocksUnorderedList.Main; + extractFromListItems(list.children, type, results); + } + } + return results; +} + +function extractFromListItems( + items: + | PubLeafletBlocksOrderedList.ListItem[] + | PubLeafletBlocksUnorderedList.ListItem[], + type: string, + results: { block: T }[], +) { + for (const item of items) { + if ((item.content as { $type?: string })?.$type === type) { + results.push({ block: item.content as unknown as T }); + } + if (item.children) { + extractFromListItems(item.children, type, results); + } + const orderedChildren = (item as PubLeafletBlocksUnorderedList.ListItem) + .orderedListChildren; + if (orderedChildren) { + extractFromListItems(orderedChildren.children, type, results); + } + const unorderedChildren = (item as PubLeafletBlocksOrderedList.ListItem) + .unorderedListChildren; + if (unorderedChildren) { + extractFromListItems(unorderedChildren.children, type, results); + } + } +} diff --git a/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts b/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts index 63c83829..22da16e1 100644 --- a/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts +++ b/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts @@ -19,7 +19,8 @@ export async function getPostPageData(did: string, rkey: string) { documents_in_publications(publications(*, documents_in_publications(documents(uri, data)), publication_subscriptions(*), - publication_newsletter_settings(enabled)) + publication_newsletter_settings(enabled), + publication_pages(id, path, title, record_uri)) ), document_mentions_in_bsky(*), leaflets_in_publications(*), @@ -143,6 +144,7 @@ export async function getPostPageData(did: string, rkey: string) { | null, publication_subscriptions: rawPub.publication_subscriptions || [], newsletterMode: !!rawPub.publication_newsletter_settings?.enabled, + pages: (rawPub.publication_pages || []).filter((p) => p.record_uri), } : null; const recommendsCount = document.recommends_on_documents?.[0]?.count ?? 0; diff --git a/app/lish/[did]/[publication]/[rkey]/page.tsx b/app/lish/[did]/[publication]/[rkey]/page.tsx index 6bce7d22..05ea0f8a 100644 --- a/app/lish/[did]/[publication]/[rkey]/page.tsx +++ b/app/lish/[did]/[publication]/[rkey]/page.tsx @@ -1,16 +1,39 @@ import { supabaseServerClient } from "supabase/serverClient"; import { Metadata } from "next"; import { DocumentPageRenderer } from "./DocumentPageRenderer"; +import { PublicationPageRenderer } from "./PublicationPageRenderer"; import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; -import { documentUriFilter } from "src/utils/uriHelpers"; +import { + documentUriFilter, + publicationNameOrUriFilter, +} from "src/utils/uriHelpers"; +import { PubLeafletPublicationPage } from "lexicons/api"; export async function generateMetadata(props: { params: Promise<{ publication: string; did: string; rkey: string }>; }): Promise { let params = await props.params; let did = decodeURIComponent(params.did); + let publication_name = decodeURIComponent(params.publication); + let rkey = decodeURIComponent(params.rkey); if (!did) return { title: "Publication 404" }; + let { data: pubs } = await supabaseServerClient + .from("publications") + .select("name, publication_pages(path, title, record_uri)") + .eq("identity_did", did) + .or(publicationNameOrUriFilter(did, publication_name)) + .order("uri", { ascending: false }) + .limit(1); + let matchingPage = pubs?.[0]?.publication_pages?.find( + (p) => p.path === "/" + rkey && p.record_uri, + ); + if (matchingPage) { + return { + title: `${matchingPage.title || matchingPage.path} - ${pubs?.[0]?.name}`, + }; + } + let [{ data: documents }] = await Promise.all([ supabaseServerClient .from("documents") @@ -55,6 +78,8 @@ export default async function Post(props: { }) { let params = await props.params; let did = decodeURIComponent(params.did); + let publication_name = decodeURIComponent(params.publication); + let rkey = decodeURIComponent(params.rkey); if (!did) return ( @@ -67,5 +92,40 @@ export default async function Post(props: {
); - return ; + let { data: publications } = await supabaseServerClient + .from("publications") + .select( + `uri, name, identity_did, record, + publication_pages(id, path, title, record, record_uri), + documents_in_publications(documents(uri, data, + comments_on_documents(count), + document_mentions_in_bsky(count), + recommends_on_documents(count)))`, + ) + .eq("identity_did", did) + .or(publicationNameOrUriFilter(did, publication_name)) + .order("uri", { ascending: false }) + .limit(1); + let publication = publications?.[0]; + + let matchingPage = publication?.publication_pages?.find( + (p) => p.path === "/" + rkey && p.record_uri && p.record, + ); + if (publication && matchingPage && matchingPage.record) { + let pageRecord = matchingPage.record as unknown as PubLeafletPublicationPage.Record; + return ( + + ); + } + + return ; } diff --git a/app/lish/[did]/[publication]/edit/[[...route]]/PublicationCollapsibleHeader.tsx b/app/lish/[did]/[publication]/edit/[[...route]]/PublicationCollapsibleHeader.tsx new file mode 100644 index 00000000..52a89d52 --- /dev/null +++ b/app/lish/[did]/[publication]/edit/[[...route]]/PublicationCollapsibleHeader.tsx @@ -0,0 +1,54 @@ +"use client"; +import { useEffect, useRef } from "react"; +import { usePathname } from "next/navigation"; + +export function PublicationCollapsibleHeader(props: { + children: React.ReactNode; +}) { + let ref = useRef(null); + let pathname = usePathname(); + + useEffect(() => { + let el = ref.current; + let parent = el?.parentElement; + if (!el || !parent) return; + + let rafId: number | null = null; + + let onScroll = (e: Event) => { + let target = e.target; + if (!(target instanceof HTMLElement)) return; + if (!target.classList.contains("pageScrollWrapper")) return; + if (rafId !== null) return; + rafId = requestAnimationFrame(() => { + rafId = null; + if (!el) return; + let height = el.offsetHeight; + let collapsed = Math.max(0, Math.min(target.scrollTop, height)); + el.style.marginTop = `-${collapsed}px`; + }); + }; + + parent.addEventListener("scroll", onScroll, { + passive: true, + capture: true, + }); + return () => { + if (rafId !== null) cancelAnimationFrame(rafId); + parent.removeEventListener("scroll", onScroll, true); + }; + }, []); + + useEffect(() => { + if (ref.current) ref.current.style.marginTop = ""; + }, [pathname]); + + return ( +
+ {props.children} +
+ ); +} diff --git a/app/lish/[did]/[publication]/edit/[[...route]]/PublicationEditHeader.tsx b/app/lish/[did]/[publication]/edit/[[...route]]/PublicationEditHeader.tsx index 615bba95..99d20904 100644 --- a/app/lish/[did]/[publication]/edit/[[...route]]/PublicationEditHeader.tsx +++ b/app/lish/[did]/[publication]/edit/[[...route]]/PublicationEditHeader.tsx @@ -1,13 +1,60 @@ "use client"; +import { useState } from "react"; import { SpeedyLink } from "components/SpeedyLink"; import { GoToArrowLined } from "components/Icons/GoToArrowLined"; +import { publishPublicationPages } from "actions/publishPublicationPages"; +import { useToaster } from "components/Toast"; +import { usePublicationData } from "../../dashboard/PublicationSWRProvider"; + +type Status = "idle" | "publishing" | "success"; export function PublicationEditHeader(props: { did: string; publicationName: string; }) { + let { data } = usePublicationData(); + let publicationUri = data?.publication?.uri; + let [status, setStatus] = useState("idle"); + let toaster = useToaster(); + let dashboardHref = `/lish/${props.did}/${props.publicationName}/dashboard`; + async function handlePublish() { + if (!publicationUri || status === "publishing") return; + setStatus("publishing"); + try { + const result = await publishPublicationPages({ + publication_uri: publicationUri, + }); + if (result.success) { + setStatus("success"); + setTimeout(() => setStatus("idle"), 2000); + } else { + setStatus("idle"); + toaster({ + type: "error", + content: + result.error.type === "oauth_session_expired" + ? "Sign in again to publish" + : result.error.message, + }); + } + } catch (e) { + setStatus("idle"); + toaster({ + type: "error", + content: e instanceof Error ? e.message : "Failed to publish", + }); + } + } + + let label = + status === "publishing" + ? "Publishing..." + : status === "success" + ? "Published!" + : "Update Publication"; + return (
); diff --git a/app/lish/[did]/[publication]/edit/[[...route]]/layout.tsx b/app/lish/[did]/[publication]/edit/[[...route]]/layout.tsx index 2cab1a07..f93779bd 100644 --- a/app/lish/[did]/[publication]/edit/[[...route]]/layout.tsx +++ b/app/lish/[did]/[publication]/edit/[[...route]]/layout.tsx @@ -11,6 +11,7 @@ import { PublicationSWRDataProvider } from "../../dashboard/PublicationSWRProvid import { PublicationPagesNav } from "./PublicationPagesNav"; import { PublicationEditHeader } from "./PublicationEditHeader"; import { PublicationHeaderEditor } from "../../PublicationHeaderEditor"; +import { PublicationCollapsibleHeader } from "./PublicationCollapsibleHeader"; export async function generateMetadata(props: { params: Promise<{ publication: string; did: string }>; @@ -93,13 +94,13 @@ export default async function PublicationEditLayout(props: { publicationName={params.publication} />
-
+ -
+ ; + + let homePage = publication.publication_pages?.find( + (p) => p.path === "/" && p.record_uri && p.record, + ); + try { + if (homePage && homePage.record) { + let pageRecord = homePage.record as unknown as PubLeafletPublicationPage.Record; + return ( + + ); + } return ( { return { diff --git a/lexicons/api/index.ts b/lexicons/api/index.ts index 7cf07858..df865afe 100644 --- a/lexicons/api/index.ts +++ b/lexicons/api/index.ts @@ -39,6 +39,7 @@ import * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' import * as PubLeafletBlocksOrderedList from './types/pub/leaflet/blocks/orderedList' import * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' import * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll' +import * as PubLeafletBlocksPostsList from './types/pub/leaflet/blocks/postsList' import * as PubLeafletBlocksStandardSitePost from './types/pub/leaflet/blocks/standardSitePost' import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' import * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' @@ -53,6 +54,7 @@ import * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linear import * as PubLeafletPollDefinition from './types/pub/leaflet/poll/definition' import * as PubLeafletPollVote from './types/pub/leaflet/poll/vote' import * as PubLeafletPublication from './types/pub/leaflet/publication' +import * as PubLeafletPublicationPage from './types/pub/leaflet/publicationPage' 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' @@ -93,6 +95,7 @@ export * as PubLeafletBlocksMath from './types/pub/leaflet/blocks/math' export * as PubLeafletBlocksOrderedList from './types/pub/leaflet/blocks/orderedList' export * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' export * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll' +export * as PubLeafletBlocksPostsList from './types/pub/leaflet/blocks/postsList' export * as PubLeafletBlocksStandardSitePost from './types/pub/leaflet/blocks/standardSitePost' export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' export * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' @@ -107,6 +110,7 @@ export * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linear export * as PubLeafletPollDefinition from './types/pub/leaflet/poll/definition' export * as PubLeafletPollVote from './types/pub/leaflet/poll/vote' export * as PubLeafletPublication from './types/pub/leaflet/publication' +export * as PubLeafletPublicationPage from './types/pub/leaflet/publicationPage' 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' @@ -639,6 +643,7 @@ export class PubLeafletNS { comment: PubLeafletCommentRecord document: PubLeafletDocumentRecord publication: PubLeafletPublicationRecord + publicationPage: PubLeafletPublicationPageRecord blocks: PubLeafletBlocksNS graph: PubLeafletGraphNS interactions: PubLeafletInteractionsNS @@ -659,6 +664,7 @@ export class PubLeafletNS { this.comment = new PubLeafletCommentRecord(client) this.document = new PubLeafletDocumentRecord(client) this.publication = new PubLeafletPublicationRecord(client) + this.publicationPage = new PubLeafletPublicationPageRecord(client) } } @@ -1295,6 +1301,89 @@ export class PubLeafletPublicationRecord { } } +export class PubLeafletPublicationPageRecord { + _client: XrpcClient + + constructor(client: XrpcClient) { + this._client = client + } + + async list( + params: OmitKey, + ): Promise<{ + cursor?: string + records: { uri: string; value: PubLeafletPublicationPage.Record }[] + }> { + const res = await this._client.call('com.atproto.repo.listRecords', { + collection: 'pub.leaflet.publicationPage', + ...params, + }) + return res.data + } + + async get( + params: OmitKey, + ): Promise<{ + uri: string + cid: string + value: PubLeafletPublicationPage.Record + }> { + const res = await this._client.call('com.atproto.repo.getRecord', { + collection: 'pub.leaflet.publicationPage', + ...params, + }) + return res.data + } + + async create( + params: OmitKey< + ComAtprotoRepoCreateRecord.InputSchema, + 'collection' | 'record' + >, + record: Un$Typed, + headers?: Record, + ): Promise<{ uri: string; cid: string }> { + const collection = 'pub.leaflet.publicationPage' + 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 = 'pub.leaflet.publicationPage' + 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: 'pub.leaflet.publicationPage', ...params }, + { headers }, + ) + } +} + export class SiteNS { _client: XrpcClient standard: SiteStandardNS diff --git a/lexicons/api/lexicons.ts b/lexicons/api/lexicons.ts index 14c63a22..09a390b5 100644 --- a/lexicons/api/lexicons.ts +++ b/lexicons/api/lexicons.ts @@ -1568,6 +1568,25 @@ export const schemaDict = { }, }, }, + PubLeafletBlocksPostsList: { + lexicon: 1, + id: 'pub.leaflet.blocks.postsList', + defs: { + main: { + type: 'object', + required: [], + properties: { + view: { + type: 'string', + knownValues: ['compact', 'full'], + }, + highlightFirstPost: { + type: 'boolean', + }, + }, + }, + }, + }, PubLeafletBlocksStandardSitePost: { lexicon: 1, id: 'pub.leaflet.blocks.standardSitePost', @@ -1954,6 +1973,7 @@ export const schemaDict = { 'lex:pub.leaflet.blocks.page', 'lex:pub.leaflet.blocks.poll', 'lex:pub.leaflet.blocks.button', + 'lex:pub.leaflet.blocks.postsList', ], }, x: { @@ -2057,6 +2077,7 @@ export const schemaDict = { 'lex:pub.leaflet.blocks.page', 'lex:pub.leaflet.blocks.poll', 'lex:pub.leaflet.blocks.button', + 'lex:pub.leaflet.blocks.postsList', ], }, alignment: { @@ -2310,6 +2331,43 @@ export const schemaDict = { }, }, }, + PubLeafletPublicationPage: { + lexicon: 1, + id: 'pub.leaflet.publicationPage', + defs: { + main: { + type: 'record', + key: 'any', + description: + 'A static page belonging to a publication (e.g. about / contact). The rkey is derived from the page path slug.', + record: { + type: 'object', + required: ['publication', 'path', 'content'], + properties: { + publication: { + type: 'string', + format: 'at-uri', + }, + path: { + type: 'string', + }, + title: { + type: 'string', + maxLength: 2000, + }, + publishedAt: { + type: 'string', + format: 'datetime', + }, + content: { + type: 'ref', + ref: 'lex:pub.leaflet.content', + }, + }, + }, + }, + }, + }, PubLeafletRichtextFacet: { lexicon: 1, id: 'pub.leaflet.richtext.facet', @@ -2887,6 +2945,7 @@ export const ids = { PubLeafletBlocksOrderedList: 'pub.leaflet.blocks.orderedList', PubLeafletBlocksPage: 'pub.leaflet.blocks.page', PubLeafletBlocksPoll: 'pub.leaflet.blocks.poll', + PubLeafletBlocksPostsList: 'pub.leaflet.blocks.postsList', PubLeafletBlocksStandardSitePost: 'pub.leaflet.blocks.standardSitePost', PubLeafletBlocksText: 'pub.leaflet.blocks.text', PubLeafletBlocksUnorderedList: 'pub.leaflet.blocks.unorderedList', @@ -2901,6 +2960,7 @@ export const ids = { PubLeafletPollDefinition: 'pub.leaflet.poll.definition', PubLeafletPollVote: 'pub.leaflet.poll.vote', PubLeafletPublication: 'pub.leaflet.publication', + PubLeafletPublicationPage: 'pub.leaflet.publicationPage', PubLeafletRichtextFacet: 'pub.leaflet.richtext.facet', PubLeafletThemeBackgroundImage: 'pub.leaflet.theme.backgroundImage', PubLeafletThemeColor: 'pub.leaflet.theme.color', diff --git a/lexicons/api/types/pub/leaflet/blocks/postsList.ts b/lexicons/api/types/pub/leaflet/blocks/postsList.ts new file mode 100644 index 00000000..ecdcbd00 --- /dev/null +++ b/lexicons/api/types/pub/leaflet/blocks/postsList.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 = 'pub.leaflet.blocks.postsList' + +export interface Main { + $type?: 'pub.leaflet.blocks.postsList' + view?: 'compact' | 'full' | (string & {}) + highlightFirstPost?: boolean +} + +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/pub/leaflet/pages/canvas.ts b/lexicons/api/types/pub/leaflet/pages/canvas.ts index 1d34e809..e18bc2ed 100644 --- a/lexicons/api/types/pub/leaflet/pages/canvas.ts +++ b/lexicons/api/types/pub/leaflet/pages/canvas.ts @@ -25,6 +25,7 @@ import type * as PubLeafletBlocksStandardSitePost from '../blocks/standardSitePo import type * as PubLeafletBlocksPage from '../blocks/page' import type * as PubLeafletBlocksPoll from '../blocks/poll' import type * as PubLeafletBlocksButton from '../blocks/button' +import type * as PubLeafletBlocksPostsList from '../blocks/postsList' const is$typed = _is$typed, validate = _validate @@ -65,6 +66,7 @@ export interface Block { | $Typed | $Typed | $Typed + | $Typed | { $type: string } x: number y: number diff --git a/lexicons/api/types/pub/leaflet/pages/linearDocument.ts b/lexicons/api/types/pub/leaflet/pages/linearDocument.ts index 83d485a2..b35d8f98 100644 --- a/lexicons/api/types/pub/leaflet/pages/linearDocument.ts +++ b/lexicons/api/types/pub/leaflet/pages/linearDocument.ts @@ -25,6 +25,7 @@ import type * as PubLeafletBlocksStandardSitePost from '../blocks/standardSitePo import type * as PubLeafletBlocksPage from '../blocks/page' import type * as PubLeafletBlocksPoll from '../blocks/poll' import type * as PubLeafletBlocksButton from '../blocks/button' +import type * as PubLeafletBlocksPostsList from '../blocks/postsList' const is$typed = _is$typed, validate = _validate @@ -65,6 +66,7 @@ export interface Block { | $Typed | $Typed | $Typed + | $Typed | { $type: string } alignment?: | 'lex:pub.leaflet.pages.linearDocument#textAlignLeft' diff --git a/lexicons/api/types/pub/leaflet/publicationPage.ts b/lexicons/api/types/pub/leaflet/publicationPage.ts new file mode 100644 index 00000000..d98d97dd --- /dev/null +++ b/lexicons/api/types/pub/leaflet/publicationPage.ts @@ -0,0 +1,32 @@ +/** + * 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 PubLeafletContent from './content' + +const is$typed = _is$typed, + validate = _validate +const id = 'pub.leaflet.publicationPage' + +export interface Record { + $type: 'pub.leaflet.publicationPage' + publication: string + path: string + title?: string + publishedAt?: string + content: PubLeafletContent.Main + [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/build.ts b/lexicons/build.ts index b005bfa4..cdd4646b 100644 --- a/lexicons/build.ts +++ b/lexicons/build.ts @@ -12,6 +12,7 @@ import { PubLeafletRichTextFacet } from "./src/facet"; import { PubLeafletComment } from "./src/comment"; import { PubLeafletAuthFullPermissions } from "./src/authFullPermissions"; import { PubLeafletContent } from "./src/content"; +import { PubLeafletPublicationPage } from "./src/publicationPage"; import * as MentionServiceLexicons from "./src/mentionService"; const outdir = path.join("lexicons", "pub", "leaflet"); @@ -24,6 +25,7 @@ fs.mkdirSync(outdir, { recursive: true }); const lexicons = [ PubLeafletDocument, PubLeafletContent, + PubLeafletPublicationPage, PubLeafletComment, PubLeafletRichTextFacet, PubLeafletAuthFullPermissions, diff --git a/lexicons/pub/leaflet/blocks/postsList.json b/lexicons/pub/leaflet/blocks/postsList.json new file mode 100644 index 00000000..cc3fa08c --- /dev/null +++ b/lexicons/pub/leaflet/blocks/postsList.json @@ -0,0 +1,22 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.blocks.postsList", + "defs": { + "main": { + "type": "object", + "required": [], + "properties": { + "view": { + "type": "string", + "knownValues": [ + "compact", + "full" + ] + }, + "highlightFirstPost": { + "type": "boolean" + } + } + } + } +} \ No newline at end of file diff --git a/lexicons/pub/leaflet/pages/canvas.json b/lexicons/pub/leaflet/pages/canvas.json index 3fcd13cf..275a367e 100644 --- a/lexicons/pub/leaflet/pages/canvas.json +++ b/lexicons/pub/leaflet/pages/canvas.json @@ -47,7 +47,8 @@ "pub.leaflet.blocks.standardSitePost", "pub.leaflet.blocks.page", "pub.leaflet.blocks.poll", - "pub.leaflet.blocks.button" + "pub.leaflet.blocks.button", + "pub.leaflet.blocks.postsList" ] }, "x": { diff --git a/lexicons/pub/leaflet/pages/linearDocument.json b/lexicons/pub/leaflet/pages/linearDocument.json index 2c36f1b6..29e5e200 100644 --- a/lexicons/pub/leaflet/pages/linearDocument.json +++ b/lexicons/pub/leaflet/pages/linearDocument.json @@ -44,7 +44,8 @@ "pub.leaflet.blocks.standardSitePost", "pub.leaflet.blocks.page", "pub.leaflet.blocks.poll", - "pub.leaflet.blocks.button" + "pub.leaflet.blocks.button", + "pub.leaflet.blocks.postsList" ] }, "alignment": { diff --git a/lexicons/pub/leaflet/publicationPage.json b/lexicons/pub/leaflet/publicationPage.json new file mode 100644 index 00000000..a0095ec7 --- /dev/null +++ b/lexicons/pub/leaflet/publicationPage.json @@ -0,0 +1,40 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.publicationPage", + "defs": { + "main": { + "type": "record", + "key": "any", + "description": "A static page belonging to a publication (e.g. about / contact). The rkey is derived from the page path slug.", + "record": { + "type": "object", + "required": [ + "publication", + "path", + "content" + ], + "properties": { + "publication": { + "type": "string", + "format": "at-uri" + }, + "path": { + "type": "string" + }, + "title": { + "type": "string", + "maxLength": 2000 + }, + "publishedAt": { + "type": "string", + "format": "datetime" + }, + "content": { + "type": "ref", + "ref": "pub.leaflet.content" + } + } + } + } + } +} \ No newline at end of file diff --git a/lexicons/src/blocks.ts b/lexicons/src/blocks.ts index c352954f..9c92ac99 100644 --- a/lexicons/src/blocks.ts +++ b/lexicons/src/blocks.ts @@ -364,6 +364,21 @@ export const PubLeafletBlocksButton: LexiconDoc = { }, }; +export const PubLeafletBlocksPostsList: LexiconDoc = { + lexicon: 1, + id: "pub.leaflet.blocks.postsList", + defs: { + main: { + type: "object", + required: [], + properties: { + view: { type: "string", knownValues: ["compact", "full"] }, + highlightFirstPost: { type: "boolean" }, + }, + }, + }, +}; + export const BlockLexicons = [ PubLeafletBlocksIFrame, PubLeafletBlocksText, @@ -381,6 +396,7 @@ export const BlockLexicons = [ PubLeafletBlocksPage, PubLeafletBlocksPoll, PubLeafletBlocksButton, + PubLeafletBlocksPostsList, ]; export const BlockUnion: LexRefUnion = { type: "union", diff --git a/lexicons/src/publicationPage.ts b/lexicons/src/publicationPage.ts new file mode 100644 index 00000000..3992c8b7 --- /dev/null +++ b/lexicons/src/publicationPage.ts @@ -0,0 +1,26 @@ +import { LexiconDoc } from "@atproto/lexicon"; +import { PubLeafletContent } from "./content"; + +export const PubLeafletPublicationPage: LexiconDoc = { + lexicon: 1, + id: "pub.leaflet.publicationPage", + defs: { + main: { + type: "record", + key: "any", + description: + "A static page belonging to a publication (e.g. about / contact). The rkey is derived from the page path slug.", + record: { + type: "object", + required: ["publication", "path", "content"], + properties: { + publication: { type: "string", format: "at-uri" }, + path: { type: "string" }, + title: { type: "string", maxLength: 2000 }, + publishedAt: { type: "string", format: "datetime" }, + content: { type: "ref", ref: PubLeafletContent.id }, + }, + }, + }, + }, +}; diff --git a/package.json b/package.json index 676d0de6..ad03f9b3 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,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/content.json ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* ./lexicons/app/bsky/*/* ./lexicons/site/*/* ./lexicons/site/*/*/* ./lexicons/parts/*/* ./lexicons/parts/*/*/* --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/publicationPage.json ./lexicons/pub/leaflet/content.json ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* ./lexicons/app/bsky/*/* ./lexicons/site/*/* ./lexicons/site/*/*/* ./lexicons/parts/*/* ./lexicons/parts/*/*/* --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", diff --git a/src/utils/factsToPagesRecord.ts b/src/utils/factsToPagesRecord.ts index 4e6b0567..7da3f332 100644 --- a/src/utils/factsToPagesRecord.ts +++ b/src/utils/factsToPagesRecord.ts @@ -17,6 +17,7 @@ import { PubLeafletBlocksOrderedList, PubLeafletBlocksPage, PubLeafletBlocksPoll, + PubLeafletBlocksPostsList, PubLeafletBlocksText, PubLeafletBlocksUnorderedList, PubLeafletBlocksWebsite, @@ -504,6 +505,19 @@ export async function processBlocksToPages(opts: { }; return block; } + if (b.type === "posts-list") { + const [viewFact] = scan.eav(b.value, "posts-list/view"); + const [highlightFact] = scan.eav( + b.value, + "posts-list/highlight-first-post", + ); + const block: $Typed = { + $type: "pub.leaflet.blocks.postsList", + ...(viewFact && { view: viewFact.data.value }), + ...(highlightFact && { highlightFirstPost: highlightFact.data.value }), + }; + return block; + } return; } diff --git a/src/utils/leafletToPublicationPageRecord.ts b/src/utils/leafletToPublicationPageRecord.ts new file mode 100644 index 00000000..e854fcf5 --- /dev/null +++ b/src/utils/leafletToPublicationPageRecord.ts @@ -0,0 +1,66 @@ +import { + PubLeafletContent, + PubLeafletPagesCanvas, + PubLeafletPagesLinearDocument, + PubLeafletPublicationPage, +} from "lexicons/api"; + +import type { Fact } from "src/replicache"; +import type { Attribute } from "src/replicache/attributes"; +import { + processBlocksToPages, + type ProcessBlocksToPagesHooks, +} from "src/utils/factsToPagesRecord"; + +export async function leafletToPublicationPageRecord(opts: { + facts: Fact[]; + root_entity: string; + publication_uri: string; + path: string; + title?: string; + publishedAt?: string; + hooks: ProcessBlocksToPagesHooks; +}): Promise { + const { pages } = await processBlocksToPages({ + facts: opts.facts, + root_entity: opts.root_entity, + hooks: opts.hooks, + }); + + const pagesArray: PubLeafletContent.Main["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[], + }; + } + return { + $type: "pub.leaflet.pages.linearDocument" as const, + id: p.id, + blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], + }; + }); + + return { + $type: "pub.leaflet.publicationPage", + publication: opts.publication_uri, + path: opts.path, + ...(opts.title !== undefined && { title: opts.title }), + publishedAt: opts.publishedAt ?? new Date().toISOString(), + content: { + $type: "pub.leaflet.content", + pages: pagesArray, + }, + }; +} + +export function pathToRkey(path: string): string { + let trimmed = path.replace(/^\/+/, "").replace(/\/+$/, ""); + if (trimmed === "") return "home"; + let slug = trimmed + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug || "home"; +} diff --git a/supabase/database.types.ts b/supabase/database.types.ts index 777384af..d8c83731 100644 --- a/supabase/database.types.ts +++ b/supabase/database.types.ts @@ -1209,6 +1209,8 @@ export type Database = { metadata: Json path: string | null publication: string + record: Json | null + record_uri: string | null title: string } Insert: { @@ -1219,6 +1221,8 @@ export type Database = { metadata?: Json path?: string | null publication: string + record?: Json | null + record_uri?: string | null title?: string } Update: { @@ -1229,6 +1233,8 @@ export type Database = { metadata?: Json path?: string | null publication?: string + record?: Json | null + record_uri?: string | null title?: string } Relationships: [