/* ============================================================================ LOCALISED DETAIL URLS - canonical and hreflang ---------------------------------------------------------------------------- PURE and client-safe: this runs in a `meta` function, which also executes in the browser on a client-side navigation, so nothing here may touch the server environment. WHY THIS EXISTS Strapi 5 gives every localisation of an entry its OWN `slug` - the field is a `uid` with `i18n.localized: true`, so the Arabic translation of "civil-society-law-session" is a different string, not the same one behind another prefix. Swapping only the "/en/" segment for "/ar/" therefore points the hreflang alternate at a URL that does not exist, and a crawler follows it into a 404. That is defect 18. THE CONVENTION 1. A detail loader populates the counterpart: populate: { localizations: LOCALIZATION_POPULATE } and returns `alternates: collectLocalizedSlugs(...)` in its payload. 2. The route's `meta` appends `detailAlternateDescriptors(...)` to whatever `buildMeta` produced, which emits plus one per locale that actually has a slug, and x-default pointing at the default locale when it exists. 3. The route declares `export const handle = SELF_ALTERNATES_HANDLE`, which tells the app shell that this page emits its own pair and that the shell's prefix-swapped set must be suppressed for it. The shell reads it with `readSelfAlternates`, exactly the way it already reads `readCanonicalPath` from ~/lib/route-handle. A locale with no localisation in the CMS gets NO alternate at all, which is the honest answer: the page has not been translated yet. ========================================================================= */ import type { MetaDescriptor } from "react-router"; import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type Locale } from "~/i18n/config"; import { sectionPath, type RouteSlugKey } from "~/i18n/paths"; import { canonicalUrl } from "~/lib/seo"; /* --------------------------------------------------------------------------- THE STRAPI SIDE ------------------------------------------------------------------------ */ /** * The `populate` fragment every detail loader passes for `localizations`. * Only the two fields the alternate needs are asked for, so the counterpart * entry's whole body is not dragged across the wire. */ export const LOCALIZATION_POPULATE = { fields: ["slug", "locale"], } as const; /** One entry of Strapi 5's `localizations` array, with everything optional. */ export interface CmsLocalizationRef { readonly slug?: string | null; readonly locale?: string | null; } /** An entity that may carry its counterpart translations. */ export interface CmsLocalizable { readonly localizations?: readonly CmsLocalizationRef[] | null; } /** Slug per locale. A locale is absent when the CMS has no translation. */ export type LocalizedSlugs = Readonly>>; function isLocaleCode(value: unknown): value is Locale { return ( typeof value === "string" && (SUPPORTED_LOCALES as readonly string[]).includes(value) ); } /** * The slug of this entry in every language the CMS actually holds it in: * the locale being rendered plus each entry of `localizations`. * * Serialisable, so a loader returns it straight to the route. */ export function collectLocalizedSlugs( locale: Locale, slug: string, localizations: readonly CmsLocalizationRef[] | null | undefined, ): LocalizedSlugs { const slugs: Partial> = {}; if (slug) slugs[locale] = slug; for (const entry of localizations ?? []) { const code = entry?.locale; const value = entry?.slug; if (isLocaleCode(code) && typeof value === "string" && value.length > 0) { slugs[code] = value; } } return slugs; } /* --------------------------------------------------------------------------- THE URL SIDE ------------------------------------------------------------------------ */ export interface LocalizedDetailPath { readonly locale: Locale; readonly path: string; } /** `/ar/news-and-updates/` for every locale that has a slug. */ export function localizedDetailPaths( section: RouteSlugKey, slugs: LocalizedSlugs, ): LocalizedDetailPath[] { return SUPPORTED_LOCALES.flatMap((locale) => { const slug = slugs[locale]; if (!slug) return []; return [ { locale, path: `${sectionPath(section, locale)}/${encodeURIComponent(slug)}`, }, ]; }); } export interface DetailAlternatesInput { readonly siteUrl: string; readonly section: RouteSlugKey; /** The locale being rendered. */ readonly locale: Locale; readonly slugs: LocalizedSlugs; } /** * `` for this page plus the hreflang set built from the * REAL slugs. Returns [] when the current locale has no slug at all, so an * error render never claims a canonical it cannot build. */ export function detailAlternateDescriptors({ siteUrl, section, locale, slugs, }: DetailAlternatesInput): MetaDescriptor[] { const paths = localizedDetailPaths(section, slugs); const own = paths.find((entry) => entry.locale === locale); if (!own) return []; const descriptors: MetaDescriptor[] = [ { tagName: "link", rel: "canonical", href: canonicalUrl(siteUrl, own.path) }, ]; for (const entry of paths) { descriptors.push({ tagName: "link", rel: "alternate", hrefLang: entry.locale, href: canonicalUrl(siteUrl, entry.path), }); } // x-default is the default-locale URL, because bare "/" negotiates towards // the default locale. It must point at an existing page, so a detail page // with no translation yet falls back to whichever locale does exist. const fallback = paths.find((entry) => entry.locale === DEFAULT_LOCALE) ?? paths[0]; if (fallback) { descriptors.push({ tagName: "link", rel: "alternate", hrefLang: "x-default", href: canonicalUrl(siteUrl, fallback.path), }); } return descriptors; } /* --------------------------------------------------------------------------- THE SHELL SIDE ------------------------------------------------------------------------ */ /** * A route handle saying "this page emits its own canonical and hreflang - do * not add the prefix-swapped pair". Read by the shell with `readSelfAlternates`. */ export const SELF_ALTERNATES_HANDLE = { selfAlternates: true } as const; /* The reader lives with the other handle readers in ~/lib/route-handle, so the shell does not have to reach into a route folder. Re-exported here because this module is where the convention is documented. */ export { readSelfAlternates } from "~/lib/route-handle";