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 = {