Files
sharper.ly/apps/web/app/routes/_shared/alternates.ts
T

194 lines
6.8 KiB
TypeScript
Raw Normal View History

2026-09-11 02:46:56 +02:00
/* ============================================================================
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 the default locale when it exists.
2026-09-11 02:46:56 +02:00
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";
2026-09-11 02:46:56 +02:00
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 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];
2026-09-11 02:46:56 +02:00
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";