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 { 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")}
|
||||||
|
|||||||
@@ -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 { 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
@@ -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` },
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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) =>
|
||||||
|
({
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
"&": "&",
|
||||||
|
"'": "'",
|
||||||
|
'"': """,
|
||||||
|
})[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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user