fix(seo): strip HTML from meta description + OG/Twitter/Schema.org

Article excerpts sometimes contain raw <p> tags; Next.js HTML-escapes
them into the page <meta>, surfacing as literal '&lt;p&gt;' in view-source
and breaking SEO previews. cleanExcerpt() strips tags + entities for all
plain-text meta surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ops-bot
2026-06-04 15:21:26 +00:00
parent e84f69f241
commit aa5bee95e2
2 changed files with 15 additions and 8 deletions

View File

@@ -1,4 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { cleanExcerpt } from "@/lib/articles/excerpt";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import ArticleDetailPage from "./ArticleDetailClient"; import ArticleDetailPage from "./ArticleDetailClient";
import type { Article } from "@/lib/articles/types"; import type { Article } from "@/lib/articles/types";
@@ -53,12 +54,12 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
} }
return { return {
title: article.title, title: article.title,
description: article.excerpt, description: cleanExcerpt(article.excerpt),
alternates: { canonical: `/articles/${article.slug}` }, alternates: { canonical: `/articles/${article.slug}` },
openGraph: { openGraph: {
type: "article", type: "article",
title: article.title, title: article.title,
description: article.excerpt, description: cleanExcerpt(article.excerpt),
url: `/articles/${article.slug}`, url: `/articles/${article.slug}`,
images: [{ url: article.image, width: 1200, height: 630, alt: article.alt }], images: [{ url: article.image, width: 1200, height: 630, alt: article.alt }],
authors: [article.author], authors: [article.author],
@@ -68,7 +69,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: article.title, title: article.title,
description: article.excerpt, description: cleanExcerpt(article.excerpt),
images: [article.image], images: [article.image],
creator: "@AV Beat", creator: "@AV Beat",
}, },
@@ -93,7 +94,7 @@ export default async function ArticlePage({ params }: PageProps) {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Article", "@type": "Article",
headline: article.title, headline: article.title,
description: article.excerpt, description: cleanExcerpt(article.excerpt),
image: [absoluteImage(article.image)], image: [absoluteImage(article.image)],
author: { "@type": "Person", name: article.author }, author: { "@type": "Person", name: article.author },
publisher: { publisher: {

View File

@@ -8,6 +8,7 @@ import {
getRelatedForumThreads, type RelatedForumThread, getRelatedForumThreads, type RelatedForumThread,
} from "@/lib/articles/legacy-source"; } from "@/lib/articles/legacy-source";
import { linkifyAndExtractMentions } from "@/lib/company-mentions"; import { linkifyAndExtractMentions } from "@/lib/company-mentions";
import { cleanExcerpt } from "@/lib/articles/excerpt";
import CompanyMentionsHover from "@/components/CompanyMentionsHover"; import CompanyMentionsHover from "@/components/CompanyMentionsHover";
import CompaniesInThisStory from "@/components/CompaniesInThisStory"; import CompaniesInThisStory from "@/components/CompaniesInThisStory";
import NewsArticleDetailClient from "./NewsArticleDetailClient"; import NewsArticleDetailClient from "./NewsArticleDetailClient";
@@ -58,14 +59,19 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
if (!article) { 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." };
} }
// Strip HTML from excerpt before stuffing into <meta description> /
// openGraph / twitter — those MUST be plain text. Raw excerpts sometimes
// contain <p>...</p>, which Next.js then HTML-escapes, surfacing as
// literal `&lt;p&gt;` in page source.
const cleanDesc = cleanExcerpt(article.excerpt);
return { return {
title: `${article.title} — AV Beat`, title: `${article.title} — AV Beat`,
description: article.excerpt, description: cleanDesc,
alternates: { canonical: `/news/${article.slug}` }, alternates: { canonical: `/news/${article.slug}` },
openGraph: { openGraph: {
type: "article", type: "article",
title: article.title, title: article.title,
description: article.excerpt, description: cleanDesc,
url: `/news/${article.slug}`, url: `/news/${article.slug}`,
images: [{ url: article.image, width: 1200, height: 630, alt: article.alt }], images: [{ url: article.image, width: 1200, height: 630, alt: article.alt }],
authors: [article.author], authors: [article.author],
@@ -75,7 +81,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: article.title, title: article.title,
description: article.excerpt, description: cleanDesc,
images: [article.image], images: [article.image],
creator: "@AV Beat", creator: "@AV Beat",
}, },
@@ -106,7 +112,7 @@ export default async function NewsArticlePage({ params }: PageProps) {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "NewsArticle", "@type": "NewsArticle",
headline: article.title, headline: article.title,
description: article.excerpt, description: cleanExcerpt(article.excerpt),
image: [absoluteImage(article.image)], image: [absoluteImage(article.image)],
author: { "@type": "Person", name: article.author }, author: { "@type": "Person", name: article.author },
publisher: { publisher: {