logicsrc/apps/logicsrc-web/src/app/blog/[slug]/page.tsx
Anthony Ettinger 00622f5882
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>
2026-09-12 08:25:21 -07:00

165 lines
5.5 KiB
TypeScript

import Link from "next/link";
import { notFound } from "next/navigation";
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { publicClient } from "@/lib/supabase";
import { SiteShell } from "@/components/site-shell";
import { AdUnit } from "@/components/ad-unit";
import { sanitizeRenderedHtml } from "@/lib/html";
export const dynamic = "force-dynamic";
type PostRow = {
slug: string;
title: string;
excerpt: string | null;
html: string;
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<PostRow | null> {
try {
const supabase = publicClient();
const { data } = await supabase
.from("blog_posts")
.select("slug, title, excerpt, html, featured_image, published_at, updated_at, canonical_url, author")
.eq("slug", slug)
.eq("status", "published")
.maybeSingle();
return (data as PostRow | null) ?? null;
} catch {
return null;
}
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await loadPost(slug);
if (!post) return { title: "Not found · LogicSRC" };
return {
title: `${post.title} · LogicSRC`,
description: post.excerpt ?? undefined,
// 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,
type: "article",
images: post.featured_image?.url ? [post.featured_image.url] : undefined,
},
};
}
function formatDate(value: string): string {
return new Date(value).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<ReactNode> {
const { slug } = await params;
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",
headline: post.title,
description: post.excerpt ?? undefined,
image: post.featured_image?.url ? [post.featured_image.url] : undefined,
datePublished: post.published_at,
dateModified: post.updated_at,
url: `${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 (
<SiteShell active="Blog">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article className="band" style={{ maxWidth: "48rem" }}>
<p style={{ marginBottom: "1.5rem" }}>
<Link href="/blog" style={{ color: "#5b6b7a", textDecoration: "none" }}>
← Blog
</Link>
</p>
<h1 style={{ fontSize: "2.2rem", margin: "0 0 0.5rem", color: "#101418" }}>
{post.title}
</h1>
<div style={{ color: "#5b6b7a", fontSize: "0.85rem", marginBottom: "2rem" }}>
{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>
{post.featured_image?.url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={post.featured_image.url}
alt={post.title}
style={{ width: "100%", borderRadius: "0.75rem", margin: "0 0 2rem" }}
/>
) : null}
<div
className="blog-content"
style={{ lineHeight: 1.7 }}
dangerouslySetInnerHTML={{ __html: html }}
/>
<div style={{ display: "flex", justifyContent: "center", marginTop: "2.5rem" }}>
<AdUnit slot="709f4f1d-667a-4888-9040-90f5f65120f2" format="banner_300x250" />
</div>
</article>
</SiteShell>
);
}