feat(seo): add JSON-LD, OG images and RSS feed for news
CI / check (push) Successful in 29s
CI / tests-unit (push) Successful in 2m17s
CI / tests-integration (push) Successful in 2m29s
CI / tests-imaging-browsers (push) Successful in 2m32s
CI / tests-quality (push) Failing after 2m18s
CI / tests-ui (push) Successful in 5m14s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m41s

This commit is contained in:
openhands committed 2026-10-22 18:22:32 +02:00
1 parent 97454f361b
commit 30583b8e2e
7 files changed
+328 -11

No files matched your search

+51 -7
View File
@@ -5,15 +5,22 @@ import type { CSSProperties } from "react";
import { postComment } from "@/actions/article-comments"; import { postComment } from "@/actions/article-comments";
import { toggleReaction } from "@/actions/article-reactions"; import { toggleReaction } from "@/actions/article-reactions";
import Link from "@/components/link"; import Link from "@/components/link";
import { JsonLd } from "@/components/public/json-ld";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { SanitizedHtml } from "@/components/shared/sanitized-html"; import { SanitizedHtml } from "@/components/shared/sanitized-html";
import { articleSlug } from "@/lib/article-input"; import { articleSlug } from "@/lib/article-input";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { excerpt } from "@/lib/format"; import { excerpt } from "@/lib/format";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
import { resolveHotelName } from "@/lib/hotel-name";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { withPublicPagePerformance } from "@/lib/public-page-performance"; import { withPublicPagePerformance } from "@/lib/public-page-performance";
import { sanitize } from "@/lib/sanitize"; import { sanitize } from "@/lib/sanitize";
import {
absoluteAssetUrl,
appBaseUrl,
buildArticleStructuredData,
} from "@/lib/seo";
import { import {
getArticleComments, getArticleComments,
getArticleReactions, getArticleReactions,
@@ -48,24 +55,44 @@ export async function generateMetadata({
}: { }: {
params: Promise<{ slug: string[] }>; params: Promise<{ slug: string[] }>;
}): Promise<Metadata> { }): Promise<Metadata> {
const appUrl = process.env.APP_URL ?? "http://localhost:3000"; const appUrl = appBaseUrl();
const slug = articleSlug((await params).slug); const slug = articleSlug((await params).slug);
const article = await getPublishedArticle(slug).catch(() => { const article = await getPublishedArticle(slug).catch(() => {
logger.error("Public article metadata failed", { module: "news" }); logger.error("Public article metadata failed", { module: "news" });
return null; return null;
}); });
if (!article) return { title: "Article" }; if (!article) return { title: "Article" };
const description = excerpt(article.shortStory, 160);
const url = `${appUrl}/news/${encodeURIComponent(slug)}`;
const image =
absoluteAssetUrl(article.image, appUrl) ??
`${appUrl}/assets/images/next_header.jpg`;
const publishedAt = (article.publishedAt ?? article.publishAt)?.toISOString();
const modifiedAt = (
article.updatedAt ??
article.publishedAt ??
article.publishAt
)?.toISOString();
return { return {
title: article.title, title: article.title,
description: excerpt(article.shortStory, 160), description,
alternates: { canonical: `${appUrl}/news/${slug}` }, alternates: { canonical: url },
openGraph: { openGraph: {
title: article.title, title: article.title,
description: excerpt(article.shortStory, 160), description,
type: "article", type: "article",
url: `${appUrl}/news/${slug}`, url,
images: [{ url: image, alt: article.title }],
...(publishedAt ? { publishedTime: publishedAt } : {}),
...(modifiedAt ? { modifiedTime: modifiedAt } : {}),
section: "News",
},
twitter: {
card: "summary_large_image",
title: article.title,
description,
images: [image],
}, },
twitter: { card: "summary", title: article.title },
}; };
} }
@@ -117,7 +144,7 @@ async function ArticlePage({
const requestedPage = const requestedPage =
comment === "posted" && !commentsPage ? "last" : Number(commentsPage ?? 1); comment === "posted" && !commentsPage ? "last" : Number(commentsPage ?? 1);
const [reactions, commentsResult] = await Promise.all([ const [reactions, commentsResult, hotelName] = await Promise.all([
getArticleReactions(articleId, sessionUserId).catch(() => { getArticleReactions(articleId, sessionUserId).catch(() => {
logger.error("Public article reactions failed", { logger.error("Public article reactions failed", {
module: "news", module: "news",
@@ -132,11 +159,27 @@ async function ArticlePage({
}); });
return null; return null;
}), }),
resolveHotelName(),
]); ]);
const reactionCounts = reactions?.counts ?? new Map<string, number>(); const reactionCounts = reactions?.counts ?? new Map<string, number>();
const myReaction = reactions?.myReaction ?? null; const myReaction = reactions?.myReaction ?? null;
const comments = commentsResult?.rows ?? []; const comments = commentsResult?.rows ?? [];
const articleHref = `/news/${encodeURIComponent(slug)}`; const articleHref = `/news/${encodeURIComponent(slug)}`;
const structuredDataUrl = `${appBaseUrl()}/news/${encodeURIComponent(slug)}`;
const structuredData = buildArticleStructuredData({
hotelName,
url: structuredDataUrl,
title: article.title,
description: excerpt(article.shortStory, 160),
image: absoluteAssetUrl(article.image, appBaseUrl()),
logo: absoluteAssetUrl(article.image, appBaseUrl()),
publishedAt: (article.publishedAt ?? article.publishAt)?.toISOString(),
modifiedAt: (
article.updatedAt ??
article.publishedAt ??
article.publishAt
)?.toISOString(),
});
const commentsHref = (page: number) => const commentsHref = (page: number) =>
`${articleHref}?commentsPage=${page}#comments`; `${articleHref}?commentsPage=${page}#comments`;
const retryHref = commentsPage const retryHref = commentsPage
@@ -160,6 +203,7 @@ async function ArticlePage({
return ( return (
<section className="page-grid"> <section className="page-grid">
<JsonLd data={structuredData} />
{comment === "posted" ? ( {comment === "posted" ? (
<div role="status" style={feedbackStyle("success")}> <div role="status" style={feedbackStyle("success")}>
{t("success.posted")} {t("success.posted")}
+25
View File
@@ -0,0 +1,25 @@
import { resolveHotelName } from "@/lib/hotel-name";
import { buildNewsRss } from "@/lib/news-rss";
import { appBaseUrl } from "@/lib/seo";
import { getNewsList } from "@/lib/services/news-list";
// Built per request so the feed never needs a database connection during
// `next build`, matching the sitemap and robots routes.
export const dynamic = "force-dynamic";
export async function GET() {
const [hotelName, items] = await Promise.all([
resolveHotelName(),
getNewsList(30),
]);
return new Response(
buildNewsRss(items, { hotelName, appUrl: appBaseUrl() }),
{
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=600",
},
},
);
}
+20 -1
View File
@@ -11,6 +11,7 @@ import { AuthTopBar } from "@/components/auth/auth-top-bar";
import { LoginForm } from "@/components/auth/login-form"; import { LoginForm } from "@/components/auth/login-form";
import Link from "@/components/link"; import Link from "@/components/link";
import { Reveal } from "@/components/motion-reveal"; import { Reveal } from "@/components/motion-reveal";
import { JsonLd } from "@/components/public/json-ld";
import { PublicLoadError } from "@/components/public/load-error"; import { PublicLoadError } from "@/components/public/load-error";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { SpotlightCard } from "@/components/spotlight-card"; import { SpotlightCard } from "@/components/spotlight-card";
@@ -21,6 +22,11 @@ import { CameraWeb, db, User } from "@/lib/db";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
import { resolveHotelName } from "@/lib/hotel-name"; import { resolveHotelName } from "@/lib/hotel-name";
import { withPublicPagePerformance } from "@/lib/public-page-performance"; import { withPublicPagePerformance } from "@/lib/public-page-performance";
import {
absoluteAssetUrl,
appBaseUrl,
buildWebsiteStructuredData,
} from "@/lib/seo";
import { captchaConfig } from "@/lib/services/captcha"; import { captchaConfig } from "@/lib/services/captcha";
import { getNewsList } from "@/lib/services/news-list"; import { getNewsList } from "@/lib/services/news-list";
import { loadProfilePrivacyMap } from "@/lib/services/profile-privacy"; import { loadProfilePrivacyMap } from "@/lib/services/profile-privacy";
@@ -48,10 +54,15 @@ export async function generateMetadata(): Promise<Metadata> {
const tp = await getTranslations("pages.home"); const tp = await getTranslations("pages.home");
const title = tn("home"); const title = tn("home");
const description = tp("welcomeBody"); const description = tp("welcomeBody");
const appUrl = appBaseUrl();
return { return {
title, title,
description, description,
openGraph: { title, description, type: "website" }, alternates: {
canonical: appUrl,
types: { "application/rss+xml": `${appUrl}/news/rss.xml` },
},
openGraph: { title, description, type: "website", url: appUrl },
}; };
} }
@@ -442,6 +453,7 @@ async function Home() {
const exploreLinks = [ const exploreLinks = [
{ href: "/community", label: tn("community") }, { href: "/community", label: tn("community") },
{ href: "/news", label: tn("articles") }, { href: "/news", label: tn("articles") },
{ href: "/events", label: tn("events") },
{ href: "/photos", label: tn("photos") }, { href: "/photos", label: tn("photos") },
{ href: "/radio", label: tn("radio") }, { href: "/radio", label: tn("radio") },
{ href: "/shop", label: tn("shop") }, { href: "/shop", label: tn("shop") },
@@ -460,8 +472,15 @@ async function Home() {
{ value: articleCount, label: tp("statsArticles"), icon: ICON_ARTICLE }, { value: articleCount, label: tp("statsArticles"), icon: ICON_ARTICLE },
]; ];
const structuredData = buildWebsiteStructuredData({
hotelName,
url: appBaseUrl(),
logo: absoluteAssetUrl(logo, appBaseUrl()),
});
return ( return (
<div className="flex w-full flex-col gap-14 pb-16 pt-2 md:gap-20 md:pb-24"> <div className="flex w-full flex-col gap-14 pb-16 pt-2 md:gap-20 md:pb-24">
<JsonLd data={structuredData} />
{/* ── Top bar: logo + live online + language/clock/theme ── */} {/* ── Top bar: logo + live online + language/clock/theme ── */}
<div className="mx-auto w-full max-w-7xl"> <div className="mx-auto w-full max-w-7xl">
<AuthTopBar hotelName={hotelName} logo={logo} online={online} /> <AuthTopBar hotelName={hotelName} logo={logo} online={online} />
+19 -3
View File
@@ -38,7 +38,10 @@ const pixelify = Pixelify_Sans({
// export const instant = false; // export const instant = false;
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const appUrl = process.env.APP_URL ?? "http://localhost:3000"; const appUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(
/\/+$/,
"",
);
const hotel = await resolveHotelName(); const hotel = await resolveHotelName();
const customFavicon = await siteSettings.get("cms_favicon"); const customFavicon = await siteSettings.get("cms_favicon");
const faviconUrl = customFavicon ?? getDefaultFaviconDataUri(); const faviconUrl = customFavicon ?? getDefaultFaviconDataUri();
@@ -50,15 +53,28 @@ export async function generateMetadata(): Promise<Metadata> {
title: { default: `${hotel} - Hotel`, template: `%s · ${hotel}` }, title: { default: `${hotel} - Hotel`, template: `%s · ${hotel}` },
description: `${hotel} — a Habbo retro hotel. Hang out, collect furni and meet friends.`, description: `${hotel} — a Habbo retro hotel. Hang out, collect furni and meet friends.`,
metadataBase: new URL(appUrl), metadataBase: new URL(appUrl),
alternates: { canonical: appUrl }, alternates: {
canonical: appUrl,
types: { "application/rss+xml": `${appUrl}/news/rss.xml` },
},
icons, icons,
openGraph: { openGraph: {
title: `${hotel} - Hotel`, title: `${hotel} - Hotel`,
description: `${hotel} — a Habbo retro hotel. Hang out, collect furni and meet friends.`, description: `${hotel} — a Habbo retro hotel. Hang out, collect furni and meet friends.`,
type: "website", type: "website",
siteName: hotel, siteName: hotel,
images: [
{
url: `${appUrl}/assets/images/next_header.jpg`,
alt: `${hotel} - Hotel`,
},
],
},
twitter: {
card: "summary_large_image",
title: `${hotel} - Hotel`,
images: [`${appUrl}/assets/images/next_header.jpg`],
}, },
twitter: { card: "summary_large_image", title: `${hotel} - Hotel` },
}; };
} }
+21
View File
@@ -0,0 +1,21 @@
/**
* Renders schema.org JSON-LD. The payload is data, not executable script, but
* `<` is escaped so an article title can never terminate the tag early.
*/
export function JsonLd({
data,
nonce,
}: {
data: Record<string, unknown> | Record<string, unknown>[];
nonce?: string;
}) {
return (
<script
type="application/ld+json"
nonce={nonce}
dangerouslySetInnerHTML={{
__html: JSON.stringify(data).replace(/</g, "\\u003c"),
}}
/>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { absoluteAssetUrl, escapeXml } from "@/lib/seo";
export interface NewsRssItem {
slug: string;
title: string;
shortStory: string;
image: string;
createdAt: Date | null;
}
function imageMime(url: string): string {
const extension = url.split("?")[0]?.split(".").pop()?.toLowerCase();
switch (extension) {
case "png":
return "image/png";
case "gif":
return "image/gif";
case "webp":
return "image/webp";
case "avif":
return "image/avif";
default:
return "image/jpeg";
}
}
/** Build a standards-compliant RSS 2.0 feed for the public news archive. */
export function buildNewsRss(
items: NewsRssItem[],
options: { hotelName: string; appUrl: string; now?: Date },
): string {
const base = options.appUrl.replace(/\/+$/, "");
const now = options.now ?? new Date();
const entries = items
.map((item) => {
const url = `${base}/news/${encodeURIComponent(item.slug)}`;
const image = absoluteAssetUrl(item.image, base);
return [
"<item>",
`<title>${escapeXml(item.title)}</title>`,
`<link>${escapeXml(url)}</link>`,
`<guid isPermaLink="true">${escapeXml(url)}</guid>`,
item.createdAt
? `<pubDate>${item.createdAt.toUTCString()}</pubDate>`
: "",
`<description>${escapeXml(item.shortStory)}</description>`,
image
? `<enclosure url="${escapeXml(image)}" type="${imageMime(image)}" length="0" />`
: "",
"</item>",
]
.filter(Boolean)
.join("");
})
.join("");
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">',
"<channel>",
`<title>${escapeXml(`${options.hotelName} — News`)}</title>`,
`<link>${escapeXml(`${base}/news`)}</link>`,
`<description>${escapeXml(`Latest news from ${options.hotelName}`)}</description>`,
"<language>en</language>",
`<atom:link href="${escapeXml(`${base}/news/rss.xml`)}" rel="self" type="application/rss+xml" />`,
`<lastBuildDate>${now.toUTCString()}</lastBuildDate>`,
entries,
"</channel>",
"</rss>",
].join("");
}
+120
View File
@@ -0,0 +1,120 @@
const FALLBACK_APP_URL = "http://localhost:3000";
/** Public origin of this installation, without a trailing slash. */
export function appBaseUrl(): string {
return (process.env.APP_URL ?? FALLBACK_APP_URL).replace(/\/+$/, "");
}
/**
* Turn a stored image path into an absolute URL for metadata / JSON-LD.
* Returns null for values we cannot safely make absolute, so callers can fall
* back to a default image instead of emitting an invalid URL.
*/
export function absoluteAssetUrl(
value: string | null | undefined,
base: string = appBaseUrl(),
): string | null {
const raw = value?.trim();
if (!raw) return null;
if (raw.startsWith("//")) return `https:${raw}`;
if (/^https?:\/\//i.test(raw)) return raw;
if (!raw.startsWith("/")) return null;
try {
return new URL(raw, base).toString();
} catch {
return null;
}
}
/** Escape text for use in XML text nodes and attribute values. */
export function escapeXml(value: string): string {
return value.replace(
/[<>&'"]/g,
(char) =>
({
"<": "&lt;",
">": "&gt;",
"&": "&amp;",
"'": "&apos;",
'"': "&quot;",
})[char] ?? char,
);
}
export interface WebsiteStructuredDataOptions {
hotelName: string;
url: string;
logo?: string | null;
}
/** WebSite + Organization JSON-LD for the landing page. */
export function buildWebsiteStructuredData({
hotelName,
url,
logo,
}: WebsiteStructuredDataOptions): Record<string, unknown>[] {
return [
{
"@context": "https://schema.org",
"@type": "WebSite",
name: hotelName,
url,
potentialAction: {
"@type": "SearchAction",
target: {
"@type": "EntryPoint",
urlTemplate: `${url}/search?q={search_term_string}`,
},
"query-input": "required name=search_term_string",
},
},
{
"@context": "https://schema.org",
"@type": "Organization",
name: hotelName,
url,
...(logo ? { logo } : {}),
},
];
}
export interface ArticleStructuredDataOptions {
hotelName: string;
url: string;
title: string;
description: string;
image?: string | null;
logo?: string | null;
publishedAt?: string | null;
modifiedAt?: string | null;
}
/** NewsArticle JSON-LD for a public article. */
export function buildArticleStructuredData({
hotelName,
url,
title,
description,
image,
logo,
publishedAt,
modifiedAt,
}: ArticleStructuredDataOptions): Record<string, unknown> {
return {
"@context": "https://schema.org",
"@type": "NewsArticle",
headline: title,
description,
url,
...(image ? { image: [image] } : {}),
...(publishedAt ? { datePublished: publishedAt } : {}),
...(modifiedAt ? { dateModified: modifiedAt } : {}),
author: { "@type": "Organization", name: hotelName },
publisher: {
"@type": "Organization",
name: hotelName,
...(logo ? { logo: { "@type": "ImageObject", url: logo } } : {}),
},
mainEntityOfPage: { "@type": "WebPage", "@id": url },
};
}