TASKS-1823 WebManager module
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { PageRenderData } from '#web-runtime/types';
|
||||
|
||||
/** Resolves a page's public URL path; injected so this module stays free of routing setup. */
|
||||
export type PublicPathResolver = (locale: string, path: string) => string;
|
||||
|
||||
/**
|
||||
* `x-default` tells search engines which version to serve when no language matches. The
|
||||
* site's default locale is the honest answer; if this page has no version in that locale,
|
||||
* the page itself is the next best thing.
|
||||
*/
|
||||
function xDefaultLocale(data: PageRenderData, defaultLocale: string): string {
|
||||
const hasDefault = data.languageAlternatives.some(
|
||||
(alternative) => alternative.locale === defaultLocale
|
||||
);
|
||||
|
||||
return hasDefault ? defaultLocale : data.page.locale;
|
||||
}
|
||||
|
||||
function languageAlternates(
|
||||
data: PageRenderData,
|
||||
defaultLocale: string,
|
||||
publicPath: PublicPathResolver
|
||||
): Record<string, string> | undefined {
|
||||
const hasTranslations = data.languageAlternatives.some(
|
||||
(alternative) => alternative.locale !== data.page.locale
|
||||
);
|
||||
|
||||
if (!hasTranslations) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const languages = Object.fromEntries(
|
||||
data.languageAlternatives.map((alternative) => [
|
||||
alternative.locale,
|
||||
publicPath(alternative.locale, alternative.path)
|
||||
])
|
||||
);
|
||||
const fallbackLocale = xDefaultLocale(data, defaultLocale);
|
||||
const fallbackPath = languages[fallbackLocale];
|
||||
|
||||
if (fallbackPath !== undefined) {
|
||||
languages['x-default'] = fallbackPath;
|
||||
}
|
||||
|
||||
return languages;
|
||||
}
|
||||
|
||||
/**
|
||||
* The hero image doubles as the share card. Width and height are included when the backend
|
||||
* knows them, because crawlers use them to pick a layout before fetching the file.
|
||||
*/
|
||||
function shareImages(data: PageRenderData): NonNullable<Metadata['openGraph']>['images'] {
|
||||
const media = data.page.hero?.media;
|
||||
|
||||
if (!media) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
url: media.url,
|
||||
alt: media.alt || data.page.title,
|
||||
...(media.width !== undefined ? { width: media.width } : {}),
|
||||
...(media.height !== undefined ? { height: media.height } : {})
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata for a resolved public page. Kept separate from the route so it can be tested
|
||||
* without a Next request: everything it needs arrives as an argument.
|
||||
*/
|
||||
export function pageMetadata(
|
||||
data: PageRenderData,
|
||||
defaultLocale: string,
|
||||
publicPath: PublicPathResolver
|
||||
): Metadata {
|
||||
const title = data.page.seo.title || data.page.title;
|
||||
const description = data.page.seo.description || undefined;
|
||||
const canonical = publicPath(data.page.locale, data.page.path);
|
||||
const languages = languageAlternates(data, defaultLocale, publicPath);
|
||||
const images = shareImages(data);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical,
|
||||
...(languages ? { languages } : {})
|
||||
},
|
||||
openGraph: {
|
||||
// An editorial page is an article to a crawler; everything else is a plain page.
|
||||
type: data.page.layout === 'blog-item' ? 'article' : 'website',
|
||||
title,
|
||||
description,
|
||||
locale: data.page.locale,
|
||||
url: canonical,
|
||||
...(images ? { images } : {})
|
||||
},
|
||||
twitter: {
|
||||
card: images ? 'summary_large_image' : 'summary',
|
||||
title,
|
||||
description,
|
||||
...(images ? { images } : {})
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* The public origin of this site, used as Next's `metadataBase`.
|
||||
*/
|
||||
const DEVELOPMENT_FALLBACK = 'http://localhost:3000';
|
||||
|
||||
export function siteUrl(): URL {
|
||||
const configured = process.env['SITE_URL']?.trim();
|
||||
|
||||
if (configured) {
|
||||
return new URL(configured);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
console.error('SITE_URL is not set — canonical and Open Graph URLs will point at localhost.');
|
||||
}
|
||||
|
||||
return new URL(DEVELOPMENT_FALLBACK);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
import type { SitemapEntry } from '#web-runtime/types';
|
||||
import type { PublicPathResolver } from './page-metadata';
|
||||
|
||||
/**
|
||||
* Turns the backend's page listing into sitemap entries.
|
||||
*
|
||||
* Pages that share a `groupKey` are the same page in different languages, so each entry links
|
||||
* to its siblings through `alternates.languages` — the sitemap equivalent of the `hreflang`
|
||||
* tags on the page itself, and the form Google prefers for multilingual sites.
|
||||
*
|
||||
* Kept free of `next/headers` and routing setup so it can be tested directly: the caller
|
||||
* supplies the path resolver and the absolute origin.
|
||||
*/
|
||||
export function buildSitemap(
|
||||
entries: SitemapEntry[],
|
||||
origin: URL,
|
||||
publicPath: PublicPathResolver
|
||||
): MetadataRoute.Sitemap {
|
||||
const absolute = (locale: string, path: string) => new URL(publicPath(locale, path), origin).href;
|
||||
|
||||
const byGroupKey = new Map<string, SitemapEntry[]>();
|
||||
for (const entry of entries) {
|
||||
if (!entry.groupKey) continue;
|
||||
|
||||
const group = byGroupKey.get(entry.groupKey);
|
||||
if (group) group.push(entry);
|
||||
else byGroupKey.set(entry.groupKey, [entry]);
|
||||
}
|
||||
|
||||
return entries.map((entry) => {
|
||||
const siblings = byGroupKey.get(entry.groupKey) ?? [];
|
||||
const lastModified = entry.updatedAt ?? entry.publishedAt;
|
||||
const item: MetadataRoute.Sitemap[number] = {
|
||||
url: absolute(entry.locale, entry.path)
|
||||
};
|
||||
|
||||
if (lastModified) {
|
||||
item.lastModified = new Date(lastModified);
|
||||
}
|
||||
|
||||
// A single-locale group has nothing to point at but itself; omit the noise.
|
||||
if (siblings.length > 1) {
|
||||
item.alternates = {
|
||||
languages: Object.fromEntries(
|
||||
siblings.map((sibling) => [sibling.locale, absolute(sibling.locale, sibling.path)])
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Breadcrumb, PageDocument } from '#web-runtime/types';
|
||||
import { toPublicHref } from '#i18n/content-href';
|
||||
|
||||
export type JsonLd = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* schema.org `BreadcrumbList` for a resolved breadcrumb trail. Positions are
|
||||
* 1-based. The current page (the last crumb) intentionally omits `item` — per
|
||||
* Google's guidance it is where the user already is, so it carries no URL.
|
||||
*/
|
||||
export function breadcrumbListJsonLd(items: Breadcrumb[]): JsonLd {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
'itemListElement': items.map((item, index) => ({
|
||||
'@type': 'ListItem',
|
||||
'position': index + 1,
|
||||
'name': item.label,
|
||||
...(index < items.length - 1 ? { item: toPublicHref(item.href) } : {})
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* schema.org `Article` for an editorial page. Only the fields the backend actually knows are
|
||||
* emitted — a date or image invented here would be markup that contradicts the page.
|
||||
*
|
||||
* `dateModified` is omitted when it equals `datePublished`, since repeating it tells a crawler
|
||||
* nothing, and the whole object is skipped by the caller when there is no publication date:
|
||||
* an `Article` without one is markup Google will ignore anyway.
|
||||
*/
|
||||
export function articleJsonLd(page: PageDocument): JsonLd | null {
|
||||
if (!page.publishedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const image = page.hero?.media?.url;
|
||||
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
'headline': page.seo.title || page.title,
|
||||
...(page.seo.description ? { description: page.seo.description } : {}),
|
||||
'datePublished': page.publishedAt,
|
||||
...(page.updatedAt && page.updatedAt !== page.publishedAt
|
||||
? { dateModified: page.updatedAt }
|
||||
: {}),
|
||||
...(image ? { image: [image] } : {}),
|
||||
'inLanguage': page.locale
|
||||
};
|
||||
}
|
||||
|
||||
export function JsonLdScript({ data }: { data: JsonLd }) {
|
||||
return (
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }} />
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user