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
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:
1 parent
97454f361b
commit
30583b8e2e
7 files changed
+328
-11
No files matched your search
@@ -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<Metadata> {
|
||||
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<string, number>();
|
||||
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 (
|
||||
<section className="page-grid">
|
||||
<JsonLd data={structuredData} />
|
||||
{comment === "posted" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.posted")}
|
||||
|
||||
@@ -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
@@ -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<Metadata> {
|
||||
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 (
|
||||
<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 ── */}
|
||||
<div className="mx-auto w-full max-w-7xl">
|
||||
<AuthTopBar hotelName={hotelName} logo={logo} online={online} />
|
||||
|
||||
Reference in new issue
Block a user