191 lines
6.6 KiB
TypeScript
191 lines
6.6 KiB
TypeScript
/* ============================================================================
|
|
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 <link rel="canonical"> plus one
|
|
<link rel="alternate" hreflang> per locale that actually has a slug, and
|
|
x-default pointing at English when English 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 { 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<Partial<Record<Locale, string>>>;
|
|
|
|
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<Record<Locale, string>> = {};
|
|
|
|
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/<arabic-slug>` 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;
|
|
}
|
|
|
|
/**
|
|
* `<link rel="canonical">` 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 English URL, because bare "/" negotiates towards English.
|
|
const fallback = paths.find((entry) => entry.locale === "en");
|
|
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";
|