// Locale routing for the scope-driven builds.
//
// Locale pages used to exist as real files (src/pages/about.astro + src/pages/nl/about.astro).
// That made `i18n.locales` decorative: a .eu build declaring English-only still emitted every
// Dutch page, because Astro cannot skip a route that exists on disk. Only a DYNAMIC route can
// opt out, which is what this enables — one `src/pages/[...lang]/about.astro` whose
// getStaticPaths asks the scope which locales to build.
//
// The rest parameter is what keeps the default locale at the root: `lang: undefined` produces
// /about, `lang: 'nl'` produces /nl/about. That matches `prefixDefaultLocale: false`.
import { getScope } from '../config/scopes';
const scope = getScope(import.meta.env.PUBLIC_SITE_SCOPE);
export const LOCALES = scope.locales;
export const DEFAULT_LOCALE = scope.locales[0];
export type Lang = string;
/** getStaticPaths() for every localized page. */
export function localePaths() {
return LOCALES.map((locale, i) => ({
// The default locale takes the bare path; the rest are prefixed.
params: { lang: i === 0 ? undefined : locale },
props: { lang: locale },
}));
}
/** Resolve the locale for a request, tolerating a missing rest param. */
export function langOf(params: { lang?: string }): Lang {
return params.lang ?? DEFAULT_LOCALE;
}
/** Pick a value for the current locale, falling back to the default locale's value.
*
* A scope can declare a locale before its copy exists (.be declares fr today). Falling back is
* better than an empty
, but it is a stopgap: untranslated pages should be visible, so
* missing keys warn at build time rather than passing silently. */
export function t(table: Record, lang: Lang, what = 'value'): T {
if (lang in table) return table[lang];
console.warn(`[i18n] missing ${what} for locale "${lang}"; falling back to "${DEFAULT_LOCALE}"`);
return table[DEFAULT_LOCALE];
}
/** Endonyms for the language switcher. Codes are ambiguous once a third locale exists — on
* atproto.be "NL" is both a language and a country — so the header speaks language names and
* the footer speaks domains, and the two share no vocabulary. */
export const ENDONYM: Record = {
en: 'English',
nl: 'Nederlands',
fr: 'Français',
de: 'Deutsch',
hu: 'Magyar',
pl: 'Polski',
es: 'Español',
pt: 'Português',
ca: 'Català',
cs: 'Čeština',
};
/** BCP-47-ish locale for og:locale. Region is the scope's country where we have one. */
export const OG_LOCALE: Record = {
en: 'en_GB',
nl: 'nl_NL',
fr: 'fr_BE',
de: 'de_BE',
hu: 'hu_HU',
pl: 'pl_PL',
es: 'es_ES',
pt: 'pt_PT',
ca: 'ca_ES',
cs: 'cs_CZ',
};
/** Path for `path` in `lang`. The default locale lives at the root, others under //. */
export function localeHref(lang: Lang, path: string): string {
const p = path.startsWith('/') ? path : `/${path}`;
return lang === DEFAULT_LOCALE ? p : `/${lang}${p === '/' ? '/' : p}`;
}
/** Strip any known locale prefix, giving the path as the default locale would serve it. */
export function stripLocale(path: string): string {
for (const l of LOCALES) {
if (l === DEFAULT_LOCALE) continue;
if (path === `/${l}` || path === `/${l}/`) return '/';
if (path.startsWith(`/${l}/`)) return path.slice(l.length + 1);
}
return path;
}
/** The same page in every locale this build serves — for the switcher and for hreflang.
* Only locales this scope actually builds appear, so a build can never advertise a language
* it does not serve. */
export function localeAlternates(path: string): { lang: Lang; href: string; label: string }[] {
const base = stripLocale(path);
return [...LOCALES]
.sort((a, b) => (ENDONYM[a] ?? a).localeCompare(ENDONYM[b] ?? b))
.map((l) => ({ lang: l, href: localeHref(l, base), label: ENDONYM[l] ?? l.toUpperCase() }));
}