wire /news/[slug] and /articles/[slug] to read bb.wp_imported_posts via ISR

- new src/lib/articles/legacy-source.ts: server-side Supabase reader
  (bb schema, anon key, RLS public_read policy) that maps imported rows
  to the existing Article shape, applies rewriteLegacyImageUrl for the
  featured image and rewriteLegacyImageUrlsInHtml across the body.
- both [slug]/page.tsx routes now: try seed sampleArticles first; on
  miss fall back to getLegacyArticleBySlug. Related articles match via
  same-category recent posts when imported.
- generateStaticParams pre-renders seed slugs + 50 most recent imported
  posts. dynamicParams=true means anything else server-renders on first
  request and ISR caches it for 1h (revalidate=3600). Avoids a 4577-page
  build.
- Hardcoded builtwithrocket.new URLs in JSON-LD replaced with
  process.env.NEXT_PUBLIC_SITE_URL.
This commit is contained in:
Ryan Salazar
2026-05-08 06:25:29 +00:00
parent 54154e2d3c
commit 08df1ad4c1
3 changed files with 233 additions and 94 deletions

View File

@@ -1,91 +1,89 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import ArticleDetailPage from './ArticleDetailClient';
import { sampleArticles, getRelatedArticles } from '@/lib/articles/sampleArticles';
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ArticleDetailPage from "./ArticleDetailClient";
import { sampleArticles, getRelatedArticles, type Article } from "@/lib/articles/sampleArticles";
import {
getLegacyArticleBySlug,
getLegacyRecentSlugs,
getLegacyRelatedArticles,
} from "@/lib/articles/legacy-source";
export const revalidate = 3600;
export const dynamicParams = true;
// DO NOT OVERRIDE — article slug generation and metadata
interface PageProps {
params: Promise<{ slug: string }>;
}
const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL || "https://bb-staging.onsethost.com";
async function loadArticle(slug: string): Promise<{
article: Article | null;
related: Article[];
}> {
const seed = sampleArticles.find((a) => a.slug === slug);
if (seed) {
return { article: seed, related: getRelatedArticles(slug, 3) };
}
const imported = await getLegacyArticleBySlug(slug);
if (!imported) return { article: null, related: [] };
const related = await getLegacyRelatedArticles(slug, imported.category, 3);
return { article: imported, related };
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const article = sampleArticles.find((a) => a.slug === slug);
const { article } = await loadArticle(slug);
if (!article) {
return {
title: 'Article Not Found',
description: 'The article you are looking for does not exist.',
};
return { title: "Article Not Found", description: "The article you are looking for does not exist." };
}
return {
title: article.title,
description: article.excerpt,
alternates: {
canonical: `/articles/${article.slug}`,
},
alternates: { canonical: `/articles/${article.slug}` },
openGraph: {
type: 'article',
type: "article",
title: article.title,
description: article.excerpt,
url: `/articles/${article.slug}`,
images: [
{
url: article.image,
width: 1200,
height: 630,
alt: article.alt,
type: 'image/jpeg',
},
],
images: [{ url: article.image, width: 1200, height: 630, alt: article.alt }],
authors: [article.author],
publishedTime: article.date,
tags: article.tags,
},
twitter: {
card: 'summary_large_image',
card: "summary_large_image",
title: article.title,
description: article.excerpt,
images: [article.image],
creator: '@BroadcastBeat',
creator: "@BroadcastBeat",
},
};
}
export async function generateStaticParams() {
return sampleArticles.map((article) => ({
slug: article.slug,
}));
const seedSlugs = sampleArticles.map((a) => a.slug);
const recent = await getLegacyRecentSlugs(50);
return Array.from(new Set([...seedSlugs, ...recent])).map((slug) => ({ slug }));
}
export default async function ArticlePage({ params }: PageProps) {
const { slug } = await params;
const article = sampleArticles.find((a) => a.slug === slug);
if (!article) {
notFound();
}
const related = getRelatedArticles(slug, 3);
const { article, related } = await loadArticle(slug);
if (!article) notFound();
const articleSchema = {
'@context': 'https://schema.org',
'@type': 'Article',
"@context": "https://schema.org",
"@type": "Article",
headline: article.title,
description: article.excerpt,
image: article.image,
author: {
'@type': 'Person',
name: article.author,
},
author: { "@type": "Person", name: article.author },
publisher: {
'@type': 'Organization',
name: 'BroadcastBeat',
logo: {
'@type': 'ImageObject',
url: 'https://broadcastb5322.builtwithrocket.new/assets/images/logo.png',
},
"@type": "Organization",
name: "BroadcastBeat",
logo: { "@type": "ImageObject", url: `${SITE_URL}/legacy/site/broadcastbeat-logo.png` },
},
datePublished: article.date,
dateModified: new Date().toISOString(),
@@ -95,11 +93,9 @@ export default async function ArticlePage({ params }: PageProps) {
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(articleSchema),
}}
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
/>
<ArticleDetailPage article={article} relatedArticles={related} />
</>
);
}
}

View File

@@ -1,43 +1,57 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { sampleArticles, getRelatedArticles } from "@/lib/articles/sampleArticles";
import { sampleArticles, getRelatedArticles, type Article } from "@/lib/articles/sampleArticles";
import {
getLegacyArticleBySlug,
getLegacyRecentSlugs,
getLegacyRelatedArticles,
} from "@/lib/articles/legacy-source";
import NewsArticleDetailClient from "./NewsArticleDetailClient";
// ISR: serve cached pages but revalidate hourly. New imported posts surface
// without a redeploy.
export const revalidate = 3600;
export const dynamicParams = true;
interface PageProps {
params: Promise<{ slug: string }>;
}
const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL || "https://bb-staging.onsethost.com";
async function loadArticle(slug: string): Promise<{
article: Article | null;
related: Article[];
source: "seed" | "imported";
}> {
// Seed (sampleArticles) wins for routes already curated.
const seed = sampleArticles.find((a) => a.slug === slug && a.section === "news");
if (seed) {
return { article: seed, related: getRelatedArticles(slug, 6), source: "seed" };
}
const imported = await getLegacyArticleBySlug(slug);
if (!imported) return { article: null, related: [], source: "imported" };
const related = await getLegacyRelatedArticles(slug, imported.category, 6);
return { article: imported, related, source: "imported" };
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const article = sampleArticles.find((a) => a.slug === slug && a.section === "news");
const { article } = await loadArticle(slug);
if (!article) {
return {
title: "Article Not Found",
description: "The article you are looking for does not exist.",
};
return { title: "Article Not Found", description: "The article you are looking for does not exist." };
}
return {
title: `${article.title} — BroadcastBeat`,
description: article.excerpt,
alternates: {
canonical: `/news/${article.slug}`,
},
alternates: { canonical: `/news/${article.slug}` },
openGraph: {
type: "article",
title: article.title,
description: article.excerpt,
url: `/news/${article.slug}`,
images: [
{
url: article.image,
width: 1200,
height: 630,
alt: article.alt,
type: "image/jpeg",
},
],
images: [{ url: article.image, width: 1200, height: 630, alt: article.alt }],
authors: [article.author],
publishedTime: article.date,
tags: article.tags,
@@ -53,20 +67,19 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
}
export async function generateStaticParams() {
return sampleArticles
// Build-time: only seed articles + the most-recent imported slugs (cap to keep
// build fast). Anything else is on-demand ISR via dynamicParams=true.
const seedSlugs = sampleArticles
.filter((a) => a.section === "news")
.map((article) => ({ slug: article.slug }));
.map((a) => a.slug);
const recent = await getLegacyRecentSlugs(50);
return Array.from(new Set([...seedSlugs, ...recent])).map((slug) => ({ slug }));
}
export default async function NewsArticlePage({ params }: PageProps) {
const { slug } = await params;
const article = sampleArticles.find((a) => a.slug === slug && a.section === "news");
if (!article) {
notFound();
}
const related = getRelatedArticles(slug, 6);
const { article, related } = await loadArticle(slug);
if (!article) notFound();
const articleSchema = {
"@context": "https://schema.org",
@@ -74,24 +87,15 @@ export default async function NewsArticlePage({ params }: PageProps) {
headline: article.title,
description: article.excerpt,
image: article.image,
author: {
"@type": "Person",
name: article.author,
},
author: { "@type": "Person", name: article.author },
publisher: {
"@type": "Organization",
name: "BroadcastBeat",
logo: {
"@type": "ImageObject",
url: "https://broadcastb5322.builtwithrocket.new/assets/images/logo.png",
},
logo: { "@type": "ImageObject", url: `${SITE_URL}/legacy/site/broadcastbeat-logo.png` },
},
datePublished: article.date,
dateModified: new Date().toISOString(),
mainEntityOfPage: {
"@type": "WebPage",
"@id": `https://broadcastb5322.builtwithrocket.new/news/${article.slug}`,
},
mainEntityOfPage: { "@type": "WebPage", "@id": `${SITE_URL}/news/${article.slug}` },
};
return (

View File

@@ -0,0 +1,139 @@
import { createClient } from "@supabase/supabase-js";
import type { Article } from "./sampleArticles";
import { rewriteLegacyImageUrl, rewriteLegacyImageUrlsInHtml } from "@/lib/legacy-image";
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const SUPABASE_ANON = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const SCHEMA = process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "bb";
function client() {
return createClient(SUPABASE_URL, SUPABASE_ANON, {
db: { schema: SCHEMA as "public" },
auth: { persistSession: false },
});
}
interface ImportedPostRow {
wp_id: number;
wp_slug: string;
title: string;
excerpt: string | null;
content: string | null;
author_name: string | null;
category: string | null;
tags: string[] | null;
featured_image: string | null;
featured_image_alt: string | null;
status: string;
wp_published_at: string | null;
}
const FALLBACK_IMAGE = "/legacy/site/no_image.png";
function authorSlug(name: string | null): string {
if (!name) return "staff-reporter";
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60) || "staff-reporter";
}
function formatDate(iso: string | null): string {
if (!iso) return "";
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
return d.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
});
}
function readTime(content: string | null): string {
if (!content) return "1 min read";
const text = content.replace(/<[^>]+>/g, " ");
const words = text.trim().split(/\s+/).filter(Boolean).length;
const minutes = Math.max(1, Math.round(words / 200));
return `${minutes} min read`;
}
function rowToArticle(row: ImportedPostRow): Article {
const image = row.featured_image
? rewriteLegacyImageUrl(row.featured_image)
: FALLBACK_IMAGE;
const content = rewriteLegacyImageUrlsInHtml(row.content || "");
const author = row.author_name || "Staff Reporter";
return {
slug: row.wp_slug,
section: "news",
title: row.title,
excerpt: row.excerpt || "",
category: (row.category || "NEWS").toUpperCase(),
date: formatDate(row.wp_published_at),
author,
authorSlug: authorSlug(author),
authorTitle: "Contributor",
authorAvatar: FALLBACK_IMAGE,
image,
alt: row.featured_image_alt || row.title,
readTime: readTime(row.content),
tags: row.tags || [],
content,
};
}
export async function getLegacyArticleBySlug(slug: string): Promise<Article | null> {
try {
const { data, error } = await client()
.from("wp_imported_posts")
.select(
"wp_id,wp_slug,title,excerpt,content,author_name,category,tags,featured_image,featured_image_alt,status,wp_published_at",
)
.eq("wp_slug", slug)
.eq("status", "published")
.maybeSingle();
if (error || !data) return null;
return rowToArticle(data as ImportedPostRow);
} catch {
return null;
}
}
export async function getLegacyRelatedArticles(
excludeSlug: string,
category: string | null,
count: number,
): Promise<Article[]> {
try {
let q = client()
.from("wp_imported_posts")
.select(
"wp_id,wp_slug,title,excerpt,content,author_name,category,tags,featured_image,featured_image_alt,status,wp_published_at",
)
.eq("status", "published")
.neq("wp_slug", excludeSlug)
.order("wp_published_at", { ascending: false })
.limit(count);
if (category) q = q.eq("category", category);
const { data, error } = await q;
if (error || !data) return [];
return (data as ImportedPostRow[]).map(rowToArticle);
} catch {
return [];
}
}
export async function getLegacyRecentSlugs(limit = 200): Promise<string[]> {
try {
const { data } = await client()
.from("wp_imported_posts")
.select("wp_slug")
.eq("status", "published")
.order("wp_published_at", { ascending: false })
.limit(limit);
return (data || []).map((r: { wp_slug: string }) => r.wp_slug);
} catch {
return [];
}
}