128 lines
4.0 KiB
TypeScript
128 lines
4.0 KiB
TypeScript
import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'
|
|
import type { TitleOrder } from './composeTitle.js'
|
|
import type { SeoMeta } from './types.js'
|
|
|
|
import { buildLocalizedPath } from '../i18n/index.js'
|
|
import { composeTitle } from './composeTitle.js'
|
|
import { buildHreflangAlternates } from './hreflang.js'
|
|
|
|
/**
|
|
* Subset of Next.js `Metadata` this helper produces. Kept local so the plugin
|
|
* doesn't depend on `next` types; the shape is assignable to Next's Metadata.
|
|
*/
|
|
export type PageMetadata = {
|
|
alternates?: {
|
|
canonical?: string
|
|
languages?: Record<string, string>
|
|
}
|
|
description?: string
|
|
openGraph?: {
|
|
description?: string
|
|
images?: { url: string }[]
|
|
locale?: string
|
|
title: string
|
|
}
|
|
robots?: {
|
|
follow: boolean
|
|
index: boolean
|
|
}
|
|
title: string
|
|
}
|
|
|
|
type BuildMetadataArgs = {
|
|
/** Absolute site origin, e.g. 'https://example.com'. */
|
|
baseUrl?: string
|
|
config: I18nConfig
|
|
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
|
homeSlug?: string
|
|
/** Resolved OG image URL (page image or site defaultShareImage). */
|
|
imageUrl?: null | string
|
|
/** Current locale being rendered. */
|
|
locale: string
|
|
/** SEO meta from the document (plugin-seo group). */
|
|
meta?: null | SeoMeta
|
|
/** Page title or site name first. Defaults to 'page-first'. */
|
|
order?: TitleOrder
|
|
/**
|
|
* Localized segment the document lives under (an archive page's slugs).
|
|
* Feeds both canonical and hreflang, so /pl/artykuly/moj-post and
|
|
* /en/articles/my-post point at each other correctly.
|
|
*/
|
|
prefix?: LocalizedSlugs
|
|
/**
|
|
* Query string appended to canonical and every hreflang, e.g. '?page=2'.
|
|
*
|
|
* A paginated listing must be canonical to itself — pointing page 2 at page 1
|
|
* tells Google the entries on it don't exist. Alternates carry the same page,
|
|
* since /pl/artykuly?page=2 corresponds to /en/articles?page=2.
|
|
*/
|
|
query?: string
|
|
/** Separator between page title and site name. Defaults to ' | '. */
|
|
separator?: string
|
|
/** Site name for title composition and OG. */
|
|
siteName?: null | string
|
|
/** slug per locale for this document — drives canonical + hreflang. */
|
|
slugs: LocalizedSlugs
|
|
}
|
|
|
|
/**
|
|
* Assembles a Next.js-compatible Metadata object from document SEO fields and
|
|
* site-level data. Locale-aware: canonical points at the current locale's
|
|
* path, and hreflang alternates cover every locale the document exists in.
|
|
*
|
|
* Designed for use inside Next.js `generateMetadata`. The caller resolves the
|
|
* pieces (meta group, site name, image URL, localized slugs) and passes them
|
|
* in — the plugin composes, it doesn't fetch.
|
|
*/
|
|
export function buildMetadata({
|
|
baseUrl,
|
|
config,
|
|
homeSlug = 'home',
|
|
imageUrl,
|
|
locale,
|
|
meta,
|
|
order,
|
|
prefix,
|
|
query,
|
|
separator,
|
|
siteName,
|
|
slugs,
|
|
}: BuildMetadataArgs): PageMetadata {
|
|
// titleOverride wins outright: an editor who filled it in wants that exact
|
|
// string in the tab, not a composition.
|
|
const override = meta?.titleOverride?.trim()
|
|
const title = override || composeTitle({ order, pageTitle: meta?.title, separator, siteName })
|
|
const description = meta?.description?.trim() || undefined
|
|
const origin = baseUrl?.replace(/\/$/, '') ?? ''
|
|
|
|
const suffix = query ?? ''
|
|
|
|
const currentPath = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })
|
|
const canonical = currentPath ? `${origin}${currentPath}${suffix}` : undefined
|
|
|
|
const languages = buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs })
|
|
if (suffix) {
|
|
for (const code of Object.keys(languages)) {
|
|
languages[code] = `${languages[code]}${suffix}`
|
|
}
|
|
}
|
|
|
|
const images = imageUrl ? [{ url: imageUrl }] : undefined
|
|
|
|
return {
|
|
title,
|
|
...(description && { description }),
|
|
alternates: {
|
|
...(canonical && { canonical }),
|
|
...(Object.keys(languages).length > 0 && { languages }),
|
|
},
|
|
...(meta?.noindex ? { robots: { follow: true, index: false } } : {}),
|
|
openGraph: {
|
|
title,
|
|
...(description && { description }),
|
|
...(images && { images }),
|
|
locale,
|
|
},
|
|
}
|
|
}
|