109 lines
3.2 KiB
TypeScript
109 lines
3.2 KiB
TypeScript
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 } : {})
|
|
}
|
|
};
|
|
}
|