diff --git a/app/(app)/(published)/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx index 803c289e..5a296632 100644 --- a/app/(app)/(published)/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx @@ -10,7 +10,7 @@ import { PublicationBackgroundProvider, PublicationThemeProvider, } from "components/ThemeManager/PublicationThemeProvider"; -import { getPostPageData } from "src/utils/getPostPageData"; +import { getPostPageData, type PostPageData } from "src/utils/getPostPageData"; import { PostPages } from "./PostPages"; import { collectAndFetchBlockResources } from "./collectAndFetchBlockResources"; import { LeafletLayout } from "components/LeafletLayout"; @@ -23,8 +23,14 @@ import { getProfiles } from "src/identity/profileCache"; import { getBylineDids, hasExplicitByline, + namedBylineProfiles, toBylineProfiles, + type BylineProfile, } from "src/utils/byline"; +import { JsonLd } from "components/JsonLd"; +import { collectPostImages } from "./collectPostImages"; +import { getPublicationURL } from "src/utils/getPublicationURL"; +import { blobRefToSrc } from "src/utils/blobRefToSrc"; export async function DocumentPageRenderer({ did, @@ -93,6 +99,20 @@ export async function DocumentPageRenderer({ let pub_creator = document.publication?.identity_did || did; let isStandalone = !pubRecord; + const jsonLd = buildPostJsonLd({ + document, + pages: pages as ( + | PubLeafletPagesLinearDocument.Main + | PubLeafletPagesCanvas.Main + )[], + did, + rkey, + publication, + profile, + contributorProfiles, + pub_creator, + }); + return ( + ); } + +function buildPostJsonLd({ + document, + pages, + did, + rkey, + publication, + profile, + contributorProfiles, + pub_creator, +}: { + document: NonNullable; + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; + did: string; + rkey: string; + publication?: string; + profile?: { displayName?: string; handle: string }; + contributorProfiles?: BylineProfile[]; + pub_creator: string; +}) { + const record = document.normalizedDocument; + + // Absolute canonical URL, matching the metadata layer: the record-derived + // URL when it's a real web URL (postUrl can be relative in dev/preview or an + // AT-URI for odd records), else this route on leaflet.pub. + const routeUrl = publication + ? `https://leaflet.pub/lish/${did}/${encodeURIComponent(publication)}/${encodeURIComponent(rkey)}` + : `https://leaflet.pub/p/${did}/${rkey}`; + const url = document.postUrl.startsWith("/") + ? `https://leaflet.pub${document.postUrl}` + : /^https?:\/\//.test(document.postUrl) + ? document.postUrl + : routeUrl; + + const images = pages + .flatMap((page) => collectPostImages(page, did)) + .map((image) => image.fullSrc ?? image.src) + .map((src) => (src.startsWith("/") ? `https://leaflet.pub${src}` : src)) + .slice(0, 5); + + const author = contributorProfiles + ? namedBylineProfiles(contributorProfiles).map((p) => ({ + "@type": "Person" as const, + name: p.displayName || p.handle || "", + url: `https://leaflet.pub/p/${p.did}`, + })) + : profile + ? [ + { + "@type": "Person" as const, + name: profile.displayName || profile.handle, + url: `https://leaflet.pub/p/${did}`, + }, + ] + : []; + + const pub = document.publication; + const pubRecord = document.normalizedPublication; + let publisher; + if (pub) { + const pubUrl = getPublicationURL(pub); + publisher = { + "@type": "Organization" as const, + name: pubRecord?.name ?? pub.name, + url: pubUrl.startsWith("/") ? `https://leaflet.pub${pubUrl}` : pubUrl, + ...(pubRecord?.icon + ? { + logo: blobRefToSrc( + pubRecord.icon.ref, + pub_creator, + "https://leaflet.pub", + ), + } + : {}), + }; + } + + return { + "@context": "https://schema.org", + "@type": "BlogPosting", + headline: record.title, + url, + mainEntityOfPage: url, + ...(record.description ? { description: record.description } : {}), + ...(record.publishedAt ? { datePublished: record.publishedAt } : {}), + ...(images.length > 0 ? { image: images } : {}), + ...(author.length > 0 ? { author } : {}), + ...(publisher ? { publisher } : {}), + ...(document.membersOnly.gated + ? { + isAccessibleForFree: false, + hasPart: { + "@type": "WebPageElement" as const, + isAccessibleForFree: false, + cssSelector: ".membersOnlyPaywall", + }, + } + : {}), + }; +} diff --git a/app/(app)/(published)/lish/[did]/[publication]/page.tsx b/app/(app)/(published)/lish/[did]/[publication]/page.tsx index c074bf73..ab8e3189 100644 --- a/app/(app)/(published)/lish/[did]/[publication]/page.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/page.tsx @@ -17,6 +17,8 @@ import { PublicationThemeProvider, PublicationBackgroundProvider, } from "components/ThemeManager/PublicationThemeProvider"; +import { JsonLd } from "components/JsonLd"; +import { getPublicationURL } from "src/utils/getPublicationURL"; // On-demand ISR: rendered on first request, then served from the CDN and // re-rendered in the background. The empty generateStaticParams is what opts a @@ -60,6 +62,17 @@ export default async function Publication(props: { const publication = await fetchPublicationForPage(did, publication_name); if (!publication) notFound(); + const record = normalizePublicationRecord(publication.record); + // getPublicationURL is relative on dev/preview hosts; JSON-LD needs absolute. + const pubUrl = getPublicationURL(publication); + const blogJsonLd = { + "@context": "https://schema.org", + "@type": "Blog", + name: record?.name ?? publication.name, + url: pubUrl.startsWith("/") ? `https://leaflet.pub${pubUrl}` : pubUrl, + ...(record?.description ? { description: record.description } : {}), + }; + // Render a published "/" page when one exists; otherwise fall back to the // legacy post-listing homepage. const homePageRender = tryRenderPublicationPage({ @@ -67,9 +80,14 @@ export default async function Publication(props: { publication, path: "/", }); - if (homePageRender) return homePageRender; + if (homePageRender) + return ( + <> + + {homePageRender} + + ); - const record = normalizePublicationRecord(publication.record); // Resolve the author profile and post bylines server-side so they're in the // SSR HTML. The profile lookup is independent of the post queries, so it // runs alongside the rows → bylines chain. @@ -89,6 +107,7 @@ export default async function Publication(props: { record={record} pub_creator={publication.identity_did} > + " in a post title) +// from closing the script tag and injecting markup. +export function JsonLd({ data }: { data: object }) { + return ( +