From feeb1e35d5600be02d53b56482bc53618e417c12 Mon Sep 17 00:00:00 2001 From: Guido X Jansen Date: Wed, 2 Sep 2026 14:11:11 +0300 Subject: [PATCH 1/3] fix(i18n): drive every link, switcher and hreflang from the scope Base.astro built each nav href as `lang === 'en' ? '/x' : '/nl/x'`. A two-way switch has no third branch, so every non-English visitor was sent to Dutch: /fr/ on .be was a dead end where each link left French and nothing returned to it, and .eu -- a build with no Dutch pages at all -- still rendered an EN/NL switcher whose NL link 404'd and hreflang advertising a Dutch alternate that does not exist. Every path now goes through localeHref(lang, path), and the switcher and hreflang are generated from scope.locales, so a build cannot advertise a language it does not serve. .eu renders no switcher at all, having one locale. The switcher shows endonyms rather than codes: on atproto.be "NL" is simultaneously a language and a country, and no amount of care elsewhere fixes a token that is ambiguous on its own. Header speaks language names, footer speaks domains, and the two now share no vocabulary. Ordered alphabetically by endonym, because in Belgium list order reads as ranking. og:locale, the wordmark aria-label, the footer handle and the JSON-LD name all come from the scope too. The visible wordmark still hardcodes .nl -- that is a component of its own and needs the same treatment. --- src/components/Auth.astro | 3 +- src/components/pages/HomePage.astro | 3 +- src/components/pages/MembersPage.astro | 5 +- src/components/pages/PrivacyPage.astro | 3 +- src/components/pages/TermsPage.astro | 3 +- src/i18n/paths.ts | 44 ++++++++++++++++ src/layouts/Base.astro | 69 ++++++++++++++++---------- 7 files changed, 99 insertions(+), 31 deletions(-) diff --git a/src/components/Auth.astro b/src/components/Auth.astro index e0a5ff1..b3c8827 100644 --- a/src/components/Auth.astro +++ b/src/components/Auth.astro @@ -1,4 +1,5 @@ --- +import { localeHref } from '../i18n/paths'; // Client-side sign in / out (AT Protocol OAuth). Restores the session on load (so users // stay signed in), exposes window.atn = { agent, session, did } + window.atnClient for the // /login page and logged-in actions, and fires atn:signedin / atn:signedout. @@ -8,7 +9,7 @@ // on page-to-page navigation). import type { Lang } from '../i18n/ui'; const { lang = 'en' } = Astro.props as { lang?: Lang }; -const loginHref = lang === 'en' ? '/login' : '/nl/login'; +const loginHref = localeHref(lang, '/login'); const signIn = lang === 'nl' ? 'Inloggen' : 'Sign in'; const signOut = lang === 'nl' ? 'Uitloggen' : 'Sign out'; --- diff --git a/src/components/pages/HomePage.astro b/src/components/pages/HomePage.astro index c4abd1c..b05c5aa 100644 --- a/src/components/pages/HomePage.astro +++ b/src/components/pages/HomePage.astro @@ -1,4 +1,5 @@ --- +import { localeHref } from '../../i18n/paths'; import Base from '../../layouts/Base.astro'; import PostCard from '../PostCard.astro'; import LinkedEventCard from '../LinkedEventCard.astro'; @@ -18,7 +19,7 @@ import { nlStats } from '../../data/nl-stats'; import { HOMEPAGE_FEED_URI, HOMEPAGE_FEED_SUBSCRIBE } from '../../data/feed'; const { lang } = Astro.props as { lang: Lang }; -const p = lang === 'nl' ? '/nl' : ''; +const p = localeHref(lang, '/').replace(/\/$/, ''); const t = useTranslations(lang); const { members, feed, stats } = await getCommunity(); const locations = members.map((m) => m.sifa?.location).filter((l): l is string => Boolean(l)); diff --git a/src/components/pages/MembersPage.astro b/src/components/pages/MembersPage.astro index 7b6ed71..24d3224 100644 --- a/src/components/pages/MembersPage.astro +++ b/src/components/pages/MembersPage.astro @@ -1,4 +1,5 @@ --- +import { localeHref } from '../../i18n/paths'; import Base from '../../layouts/Base.astro'; import MemberCard from '../MemberCard.astro'; import { useTranslations, type Lang } from '../../i18n/ui'; @@ -7,8 +8,8 @@ import { getCommunity } from '../../lib/atproto/home'; const { lang } = Astro.props as { lang: Lang }; const t = useTranslations(lang); const { members } = await getCommunity(); -const loginHref = lang === 'nl' ? '/nl/login' : '/login'; -const buildersHref = lang === 'nl' ? '/nl/builders' : '/builders'; +const loginHref = localeHref(lang, '/login'); +const buildersHref = localeHref(lang, '/builders'); --- diff --git a/src/components/pages/PrivacyPage.astro b/src/components/pages/PrivacyPage.astro index 129ce7b..8f51e0b 100644 --- a/src/components/pages/PrivacyPage.astro +++ b/src/components/pages/PrivacyPage.astro @@ -1,11 +1,12 @@ --- +import { localeHref } from '../../i18n/paths'; import Base from '../../layouts/Base.astro'; import { useTranslations, type Lang } from '../../i18n/ui'; const { lang } = Astro.props as { lang: Lang }; const t = useTranslations(lang); const title = 'Privacy'; -const home = lang === 'nl' ? '/nl/' : '/'; +const home = localeHref(lang, '/'); const updated = lang === 'nl' ? 'Laatst bijgewerkt: 27 augustus 2026' : 'Last updated: 27 August 2026'; --- diff --git a/src/components/pages/TermsPage.astro b/src/components/pages/TermsPage.astro index c3bc387..a301ca6 100644 --- a/src/components/pages/TermsPage.astro +++ b/src/components/pages/TermsPage.astro @@ -1,11 +1,12 @@ --- +import { localeHref } from '../../i18n/paths'; import Base from '../../layouts/Base.astro'; import { useTranslations, type Lang } from '../../i18n/ui'; const { lang } = Astro.props as { lang: Lang }; const t = useTranslations(lang); const title = lang === 'nl' ? 'Voorwaarden' : 'Terms'; -const home = lang === 'nl' ? '/nl/' : '/'; +const home = localeHref(lang, '/'); const updated = lang === 'nl' ? 'Laatst bijgewerkt: 26 augustus 2026' : 'Last updated: 26 August 2026'; --- diff --git a/src/i18n/paths.ts b/src/i18n/paths.ts index f7b747f..8769c87 100644 --- a/src/i18n/paths.ts +++ b/src/i18n/paths.ts @@ -42,3 +42,47 @@ export function t(table: Record, lang: Lang, what = 'value'): T { 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', +}; + +/** 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', +}; + +/** 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() })); +} diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 8a77744..71649e3 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -1,5 +1,6 @@ --- import { getScope, siteName } from '../config/scopes'; +import { localeHref, localeAlternates, OG_LOCALE, DEFAULT_LOCALE } from '../i18n/paths'; import '@fontsource-variable/atkinson-hyperlegible-next'; import '@fontsource-variable/jetbrains-mono'; import '../styles/global.css'; @@ -23,26 +24,34 @@ interface Props { const { title, lang = 'en', description: pageDescription } = Astro.props; const t = useTranslations(lang); -const home = lang === 'en' ? '/' : '/nl/'; -const membersHref = lang === 'en' ? '/members' : '/nl/members'; -const eventsHref = lang === 'en' ? '/events' : '/nl/events'; -const toolsHref = lang === 'en' ? '/tools' : '/nl/tools'; -const statsHref = lang === 'en' ? '/community-size' : '/nl/community-size'; -const buildersHref = lang === 'en' ? '/builders' : '/nl/builders'; -const aboutHref = lang === 'en' ? '/about' : '/nl/about'; -const privacyHref = lang === 'en' ? '/privacy' : '/nl/privacy'; -const termsHref = lang === 'en' ? '/terms' : '/nl/terms'; +// Every in-site link goes through localeHref: a two-way `lang === 'en' ? … : '/nl/…'` sent +// every non-English visitor to Dutch, which made /fr/ a dead end on .be and put a Dutch +// switcher on .eu, a build with no Dutch at all. +const home = localeHref(lang, '/'); +const membersHref = localeHref(lang, '/members'); +const eventsHref = localeHref(lang, '/events'); +const toolsHref = localeHref(lang, '/tools'); +const statsHref = localeHref(lang, '/community-size'); +const buildersHref = localeHref(lang, '/builders'); +const aboutHref = localeHref(lang, '/about'); +const privacyHref = localeHref(lang, '/privacy'); +const termsHref = localeHref(lang, '/terms'); const followUrl = 'https://mu.social/profile/atproto.nl'; const site = Astro.site?.toString().replace(/\/$/, '') ?? 'https://atproto.nl'; // Language switch keeps you on the same page in the other language, instead of // always going home. Guides live in English only, so their NL link falls back to /nl/events/. const path = Astro.url.pathname; -const toEn = (p: string) => (p === '/nl' || p === '/nl/' ? '/' : p.replace(/^\/nl\//, '/')); -const toNl = (p: string) => (p === '/' ? '/nl/' : p.startsWith('/nl/') || p === '/nl' ? p : '/nl' + p); -const enHref = lang === 'en' ? path : toEn(path); -let nlHref = lang === 'nl' ? path : toNl(path); -if (lang === 'en' && /^\/events\/[^/]+\/?$/.test(path)) nlHref = '/nl/events/'; +// Built from the scope, so a build can never advertise a language it does not serve. +let alternates = localeAlternates(path); +// Event guides are English-only: point other locales at the events index rather than a 404. +if (/^\/(?:[a-z]{2}\/)?events\/[^/]+\/?$/.test(path)) { + alternates = alternates.map((a) => + a.lang === DEFAULT_LOCALE ? a : { ...a, href: localeHref(a.lang, '/events/') }, + ); +} +const selfHref = alternates.find((a) => a.lang === lang)?.href ?? path; +const defaultHref = alternates.find((a) => a.lang === DEFAULT_LOCALE)?.href ?? '/'; const canonical = `${site}${path}`; // The site name comes from the scope, not the catalogue: it does not change per language, and @@ -53,7 +62,7 @@ const description = pageDescription ?? t('site.tagline'); const orgLd = { '@context': 'https://schema.org', '@type': 'Organization', - name: 'atproto.nl', + name: SITE_NAME, url: `${site}/`, logo: `${site}/brand/atproto-nl-avatar-1000.png`, description: t('site.tagline'), @@ -86,14 +95,15 @@ const orgLd = { {pageTitle} - - - + {alternates.map((a) => ( + + ))} + - + @@ -111,7 +121,7 @@ const orgLd = {