diff --git a/apps/logicsrc-web/src/app/blog/[slug]/page.tsx b/apps/logicsrc-web/src/app/blog/[slug]/page.tsx index fb140c2..16e2858 100644 --- a/apps/logicsrc-web/src/app/blog/[slug]/page.tsx +++ b/apps/logicsrc-web/src/app/blog/[slug]/page.tsx @@ -17,8 +17,22 @@ type PostRow = { featured_image: { url?: string } | null; published_at: string; updated_at: string; + // A guest post syndicated from another blog carries the original URL here. + // When set, it is the canonical for search engines and is shown to readers, + // so a cross-post never competes with its source for the same words. + canonical_url: string | null; + // Stored as jsonb: an object like { name, url }, or occasionally a bare + // string. Never rendered directly, or an object prints as [object Object]. + author: { name?: string; url?: string } | string | null; }; +/** A display name from the jsonb author, whatever shape it took. */ +function authorName(author: PostRow["author"]): string { + if (!author) return ""; + if (typeof author === "string") return author; + return typeof author.name === "string" ? author.name : ""; +} + const SITE_URL = (process.env.PUBLIC_URL ?? "https://logicsrc.com").replace(/\/$/, ""); async function loadPost(slug: string): Promise { @@ -26,7 +40,7 @@ async function loadPost(slug: string): Promise { const supabase = publicClient(); const { data } = await supabase .from("blog_posts") - .select("slug, title, excerpt, html, featured_image, published_at, updated_at") + .select("slug, title, excerpt, html, featured_image, published_at, updated_at, canonical_url, author") .eq("slug", slug) .eq("status", "published") .maybeSingle(); @@ -47,7 +61,9 @@ export async function generateMetadata({ return { title: `${post.title} · LogicSRC`, description: post.excerpt ?? undefined, - alternates: { canonical: `/blog/${post.slug}` }, + // A guest post points its canonical at the original; an original post is + // canonical to itself. + alternates: { canonical: post.canonical_url ?? `/blog/${post.slug}` }, openGraph: { title: post.title, description: post.excerpt ?? undefined, @@ -74,6 +90,7 @@ export default async function BlogPostPage({ const post = await loadPost(slug); if (!post) notFound(); + const canonical = post.canonical_url ?? `${SITE_URL}/blog/${post.slug}`; const jsonLd = { "@context": "https://schema.org", "@type": "BlogPosting", @@ -83,9 +100,20 @@ export default async function BlogPostPage({ datePublished: post.published_at, dateModified: post.updated_at, url: `${SITE_URL}/blog/${post.slug}`, - mainEntityOfPage: `${SITE_URL}/blog/${post.slug}`, + // For a guest post this is the original; search engines follow it. + mainEntityOfPage: canonical, + ...(authorName(post.author) ? { author: { "@type": "Person", name: authorName(post.author) } } : {}), publisher: { "@id": `${SITE_URL}/#organization` }, }; + const byline = authorName(post.author); + let originHost = ""; + if (post.canonical_url) { + try { + originHost = new URL(post.canonical_url).host; + } catch { + originHost = ""; + } + } const html = sanitizeRenderedHtml(post.html); return ( @@ -105,6 +133,15 @@ export default async function BlogPostPage({
{formatDate(post.published_at)} + {byline ? ` · ${byline}` : ""} + {post.canonical_url ? ( + <> + {" · "} + + Originally published{originHost ? ` on ${originHost}` : ""} + + + ) : null}
{post.featured_image?.url ? ( // eslint-disable-next-line @next/next/no-img-element