Files

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 } : {})
}
};
}