blog: honor canonical_url so guest posts point at their source (#151)

* blog: honor canonical_url so guest posts point at their source

A post syndicated from another blog carries the original URL in
blog_posts.canonical_url (the autoblog webhook already stores it). The post
page ignored it and always self-canonicalized, so a guest post would
compete with its source for the same words. Now the page selects
canonical_url and author, sets rel=canonical to the original when present
(self otherwise), points JSON-LD mainEntityOfPage at it, and shows readers
an "Originally published on <host>" line with the author byline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MxNif5tsYq4LczgG7aE8Jp

* blog: author is jsonb, render a name not [object Object]

The blog_posts.author column is jsonb (e.g. { name, url }), not text.
Extract a display name for the byline and JSON-LD instead of rendering the
object directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MxNif5tsYq4LczgG7aE8Jp

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-09-12 08:25:21 -07:00 • committed by GitHub
parent 9f42ce222a
commit 00622f5882
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -17,8 +17,22 @@ type PostRow = {
featured_image: { url?: string } | null; featured_image: { url?: string } | null;
published_at: string; published_at: string;
updated_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(/\/$/, ""); const SITE_URL = (process.env.PUBLIC_URL ?? "https://logicsrc.com").replace(/\/$/, "");
async function loadPost(slug: string): Promise<PostRow | null> { async function loadPost(slug: string): Promise<PostRow | null> {
@ -26,7 +40,7 @@ async function loadPost(slug: string): Promise<PostRow | null> {
const supabase = publicClient(); const supabase = publicClient();
const { data } = await supabase const { data } = await supabase
.from("blog_posts") .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("slug", slug)
.eq("status", "published") .eq("status", "published")
.maybeSingle(); .maybeSingle();
@ -47,7 +61,9 @@ export async function generateMetadata({
return { return {
title: `${post.title} · LogicSRC`, title: `${post.title} · LogicSRC`,
description: post.excerpt ?? undefined, 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: { openGraph: {
title: post.title, title: post.title,
description: post.excerpt ?? undefined, description: post.excerpt ?? undefined,
@ -74,6 +90,7 @@ export default async function BlogPostPage({
const post = await loadPost(slug); const post = await loadPost(slug);
if (!post) notFound(); if (!post) notFound();
const canonical = post.canonical_url ?? `${SITE_URL}/blog/${post.slug}`;
const jsonLd = { const jsonLd = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "BlogPosting", "@type": "BlogPosting",
@ -83,9 +100,20 @@ export default async function BlogPostPage({
datePublished: post.published_at, datePublished: post.published_at,
dateModified: post.updated_at, dateModified: post.updated_at,
url: `${SITE_URL}/blog/${post.slug}`, 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` }, 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); const html = sanitizeRenderedHtml(post.html);
return ( return (
@ -105,6 +133,15 @@ export default async function BlogPostPage({
</h1> </h1>
<div style={{ color: "#5b6b7a", fontSize: "0.85rem", marginBottom: "2rem" }}> <div style={{ color: "#5b6b7a", fontSize: "0.85rem", marginBottom: "2rem" }}>
{formatDate(post.published_at)} {formatDate(post.published_at)}
{byline ? ` · ${byline}` : ""}
{post.canonical_url ? (
<>
{" · "}
<a href={post.canonical_url} rel="noreferrer" style={{ color: "#5b6b7a" }}>
Originally published{originHost ? ` on ${originHost}` : ""}
</a>
</>
) : null}
</div> </div>
{post.featured_image?.url ? ( {post.featured_image?.url ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element