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