From 30583b8e2e2e213f87ea97ab207b546f3134b818 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 22 Oct 2026 18:22:32 +0200 Subject: [PATCH] feat(seo): add JSON-LD, OG images and RSS feed for news --- src/app/(site)/news/[...slug]/page.tsx | 58 ++++++++++-- src/app/(site)/news/rss.xml/route.ts | 25 ++++++ src/app/(site)/page.tsx | 21 ++++- src/app/layout.tsx | 22 ++++- src/components/public/json-ld.tsx | 21 +++++ src/lib/news-rss.ts | 72 +++++++++++++++ src/lib/seo.ts | 120 +++++++++++++++++++++++++ 7 files changed, 328 insertions(+), 11 deletions(-) create mode 100644 src/app/(site)/news/rss.xml/route.ts create mode 100644 src/components/public/json-ld.tsx create mode 100644 src/lib/news-rss.ts create mode 100644 src/lib/seo.ts diff --git a/src/app/(site)/news/[...slug]/page.tsx b/src/app/(site)/news/[...slug]/page.tsx index f8403941..203ab5f9 100644 --- a/src/app/(site)/news/[...slug]/page.tsx +++ b/src/app/(site)/news/[...slug]/page.tsx @@ -5,15 +5,22 @@ import type { CSSProperties } from "react"; import { postComment } from "@/actions/article-comments"; import { toggleReaction } from "@/actions/article-reactions"; import Link from "@/components/link"; +import { JsonLd } from "@/components/public/json-ld"; import { ContentCard, EmptyState } from "@/components/public/ui"; import { SanitizedHtml } from "@/components/shared/sanitized-html"; import { articleSlug } from "@/lib/article-input"; import { auth } from "@/lib/auth"; import { excerpt } from "@/lib/format"; import { formatDate } from "@/lib/format-date"; +import { resolveHotelName } from "@/lib/hotel-name"; import { logger } from "@/lib/logger"; import { withPublicPagePerformance } from "@/lib/public-page-performance"; import { sanitize } from "@/lib/sanitize"; +import { + absoluteAssetUrl, + appBaseUrl, + buildArticleStructuredData, +} from "@/lib/seo"; import { getArticleComments, getArticleReactions, @@ -48,24 +55,44 @@ export async function generateMetadata({ }: { params: Promise<{ slug: string[] }>; }): Promise { - const appUrl = process.env.APP_URL ?? "http://localhost:3000"; + const appUrl = appBaseUrl(); const slug = articleSlug((await params).slug); const article = await getPublishedArticle(slug).catch(() => { logger.error("Public article metadata failed", { module: "news" }); return null; }); 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 { title: article.title, - description: excerpt(article.shortStory, 160), - alternates: { canonical: `${appUrl}/news/${slug}` }, + description, + alternates: { canonical: url }, openGraph: { title: article.title, - description: excerpt(article.shortStory, 160), + description, 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 = comment === "posted" && !commentsPage ? "last" : Number(commentsPage ?? 1); - const [reactions, commentsResult] = await Promise.all([ + const [reactions, commentsResult, hotelName] = await Promise.all([ getArticleReactions(articleId, sessionUserId).catch(() => { logger.error("Public article reactions failed", { module: "news", @@ -132,11 +159,27 @@ async function ArticlePage({ }); return null; }), + resolveHotelName(), ]); const reactionCounts = reactions?.counts ?? new Map(); const myReaction = reactions?.myReaction ?? null; const comments = commentsResult?.rows ?? []; 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) => `${articleHref}?commentsPage=${page}#comments`; const retryHref = commentsPage @@ -160,6 +203,7 @@ async function ArticlePage({ return (
+ {comment === "posted" ? (
{t("success.posted")} diff --git a/src/app/(site)/news/rss.xml/route.ts b/src/app/(site)/news/rss.xml/route.ts new file mode 100644 index 00000000..9dce44d6 --- /dev/null +++ b/src/app/(site)/news/rss.xml/route.ts @@ -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", + }, + }, + ); +} diff --git a/src/app/(site)/page.tsx b/src/app/(site)/page.tsx index c1fdfd11..55a04237 100644 --- a/src/app/(site)/page.tsx +++ b/src/app/(site)/page.tsx @@ -11,6 +11,7 @@ import { AuthTopBar } from "@/components/auth/auth-top-bar"; import { LoginForm } from "@/components/auth/login-form"; import Link from "@/components/link"; import { Reveal } from "@/components/motion-reveal"; +import { JsonLd } from "@/components/public/json-ld"; import { PublicLoadError } from "@/components/public/load-error"; import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; import { SpotlightCard } from "@/components/spotlight-card"; @@ -21,6 +22,11 @@ import { CameraWeb, db, User } from "@/lib/db"; import { formatDate } from "@/lib/format-date"; import { resolveHotelName } from "@/lib/hotel-name"; import { withPublicPagePerformance } from "@/lib/public-page-performance"; +import { + absoluteAssetUrl, + appBaseUrl, + buildWebsiteStructuredData, +} from "@/lib/seo"; import { captchaConfig } from "@/lib/services/captcha"; import { getNewsList } from "@/lib/services/news-list"; import { loadProfilePrivacyMap } from "@/lib/services/profile-privacy"; @@ -48,10 +54,15 @@ export async function generateMetadata(): Promise { const tp = await getTranslations("pages.home"); const title = tn("home"); const description = tp("welcomeBody"); + const appUrl = appBaseUrl(); return { title, 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 = [ { href: "/community", label: tn("community") }, { href: "/news", label: tn("articles") }, + { href: "/events", label: tn("events") }, { href: "/photos", label: tn("photos") }, { href: "/radio", label: tn("radio") }, { href: "/shop", label: tn("shop") }, @@ -460,8 +472,15 @@ async function Home() { { value: articleCount, label: tp("statsArticles"), icon: ICON_ARTICLE }, ]; + const structuredData = buildWebsiteStructuredData({ + hotelName, + url: appBaseUrl(), + logo: absoluteAssetUrl(logo, appBaseUrl()), + }); + return (
+ {/* ── Top bar: logo + live online + language/clock/theme ── */}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0fc0b936..7d0b6b37 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -38,7 +38,10 @@ const pixelify = Pixelify_Sans({ // export const instant = false; export async function generateMetadata(): Promise { - const appUrl = process.env.APP_URL ?? "http://localhost:3000"; + const appUrl = (process.env.APP_URL ?? "http://localhost:3000").replace( + /\/+$/, + "", + ); const hotel = await resolveHotelName(); const customFavicon = await siteSettings.get("cms_favicon"); const faviconUrl = customFavicon ?? getDefaultFaviconDataUri(); @@ -50,15 +53,28 @@ export async function generateMetadata(): Promise { title: { default: `${hotel} - Hotel`, template: `%s · ${hotel}` }, description: `${hotel} — a Habbo retro hotel. Hang out, collect furni and meet friends.`, metadataBase: new URL(appUrl), - alternates: { canonical: appUrl }, + alternates: { + canonical: appUrl, + types: { "application/rss+xml": `${appUrl}/news/rss.xml` }, + }, icons, openGraph: { title: `${hotel} - Hotel`, description: `${hotel} — a Habbo retro hotel. Hang out, collect furni and meet friends.`, type: "website", 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` }, }; } diff --git a/src/components/public/json-ld.tsx b/src/components/public/json-ld.tsx new file mode 100644 index 00000000..97dba038 --- /dev/null +++ b/src/components/public/json-ld.tsx @@ -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 | Record[]; + nonce?: string; +}) { + return ( +