initial commit
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
/* ============================================================================
|
||||
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";
|
||||
Reference in New Issue
Block a user