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 } 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, }, } }