From aaffa24f3712c8a3d1d9a09bcace824e4f1fff0e Mon Sep 17 00:00:00 2001 From: Guido X Jansen Date: Wed, 2 Sep 2026 07:45:45 +0300 Subject: [PATCH] refactor(i18n): serve locales from a [...lang] route so scopes can drop them Locale pages existed as real files (src/pages/about.astro plus src/pages/nl/about.astro), which made i18n.locales decorative: Astro cannot skip a route that exists on disk, so a .eu build declaring English-only still emitted all 10 Dutch pages. Only a dynamic route can opt out. Ten pages collapse into src/pages/[...lang]/, each calling localePaths() from src/i18n/paths.ts, which asks the scope which locales to build. The rest parameter keeps the default locale at the root: lang undefined -> /about, lang 'nl' -> /nl/about, matching prefixDefaultLocale: false. community-size was the only page carrying per-locale copy inline; its titles and descriptions move into a table beside the route rather than into a global catalogue, so the copy stays where whoever edits the page will look. 404 and offline stay unlocalised -- they had no Dutch counterpart. Result: .eu builds 15 pages with no Dutch, .nl and .be build 25 with it. The .nl output is unchanged -- same titles, same lang attributes, same canonicals. Drops 'fr' from the .be scope. The UI string catalogue has no French entries, so building it crashed on t('nav.about'); adding an English fallback instead would have shipped French URLs full of English, which is worse than not offering French yet. Add 'fr' back in the same change that adds the strings. Removes the two build guards from the previous commit. They existed only to stop this exact mismatch shipping silently, and checked for the src/pages// directories this commit deletes. --- astro.config.mjs | 33 +----------------- src/config/scopes.ts | 5 ++- src/i18n/paths.ts | 44 ++++++++++++++++++++++++ src/pages/[...lang]/about.astro | 11 ++++++ src/pages/[...lang]/builders.astro | 11 ++++++ src/pages/[...lang]/community-size.astro | 30 ++++++++++++++++ src/pages/[...lang]/events.astro | 11 ++++++ src/pages/[...lang]/index.astro | 11 ++++++ src/pages/[...lang]/login.astro | 11 ++++++ src/pages/[...lang]/members.astro | 11 ++++++ src/pages/[...lang]/privacy.astro | 11 ++++++ src/pages/[...lang]/terms.astro | 11 ++++++ src/pages/[...lang]/tools.astro | 11 ++++++ src/pages/about.astro | 5 --- src/pages/builders.astro | 5 --- src/pages/community-size.astro | 17 --------- src/pages/events.astro | 5 --- src/pages/index.astro | 5 --- src/pages/login.astro | 5 --- src/pages/members.astro | 5 --- src/pages/nl/about.astro | 5 --- src/pages/nl/builders.astro | 5 --- src/pages/nl/community-size.astro | 15 -------- src/pages/nl/events.astro | 5 --- src/pages/nl/index.astro | 5 --- src/pages/nl/login.astro | 5 --- src/pages/nl/members.astro | 5 --- src/pages/nl/privacy.astro | 5 --- src/pages/nl/terms.astro | 5 --- src/pages/nl/tools.astro | 5 --- src/pages/privacy.astro | 5 --- src/pages/terms.astro | 5 --- src/pages/tools.astro | 5 --- 33 files changed, 178 insertions(+), 155 deletions(-) create mode 100644 src/i18n/paths.ts create mode 100644 src/pages/[...lang]/about.astro create mode 100644 src/pages/[...lang]/builders.astro create mode 100644 src/pages/[...lang]/community-size.astro create mode 100644 src/pages/[...lang]/events.astro create mode 100644 src/pages/[...lang]/index.astro create mode 100644 src/pages/[...lang]/login.astro create mode 100644 src/pages/[...lang]/members.astro create mode 100644 src/pages/[...lang]/privacy.astro create mode 100644 src/pages/[...lang]/terms.astro create mode 100644 src/pages/[...lang]/tools.astro delete mode 100644 src/pages/about.astro delete mode 100644 src/pages/builders.astro delete mode 100644 src/pages/community-size.astro delete mode 100644 src/pages/events.astro delete mode 100644 src/pages/index.astro delete mode 100644 src/pages/login.astro delete mode 100644 src/pages/members.astro delete mode 100644 src/pages/nl/about.astro delete mode 100644 src/pages/nl/builders.astro delete mode 100644 src/pages/nl/community-size.astro delete mode 100644 src/pages/nl/events.astro delete mode 100644 src/pages/nl/index.astro delete mode 100644 src/pages/nl/login.astro delete mode 100644 src/pages/nl/members.astro delete mode 100644 src/pages/nl/privacy.astro delete mode 100644 src/pages/nl/terms.astro delete mode 100644 src/pages/nl/tools.astro delete mode 100644 src/pages/privacy.astro delete mode 100644 src/pages/terms.astro delete mode 100644 src/pages/tools.astro diff --git a/astro.config.mjs b/astro.config.mjs index cae0a10..4a6f308 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -2,8 +2,7 @@ import { defineConfig } from 'astro/config'; import tailwindcss from '@tailwindcss/vite'; import sitemap from '@astrojs/sitemap'; -import { readdirSync, existsSync } from 'node:fs'; -import { getScope, resolveScopeId, SCOPES } from './src/config/scopes.ts'; +import { getScope, resolveScopeId } from './src/config/scopes.ts'; // One codebase, three real domains. SITE_SCOPE=nl|be|eu picks which one this build is; see // src/config/scopes.ts. Astro resolves `site` and `i18n.locales` at build time rather than per @@ -12,36 +11,6 @@ const scopeId = resolveScopeId(process.env.SITE_SCOPE); const scope = getScope(scopeId); console.log(`[scope] building ${scopeId} -> ${scope.site} [${scope.locales.join(', ')}]`); -// Locale routes exist as real directories under src/pages//, so i18n.locales does NOT -// gate them: a build whose scope drops a locale would still emit that locale's pages. Astro can -// only skip a route that is dynamic, so until src/pages// becomes a [locale] route this -// mismatch has to fail loudly rather than ship a .eu site full of Dutch pages. -const ALL_LOCALES = new Set(Object.values(SCOPES).flatMap((s) => s.locales)); -const routeLocales = existsSync('./src/pages') - ? readdirSync('./src/pages', { withFileTypes: true }) - .filter((e) => e.isDirectory() && ALL_LOCALES.has(e.name)) - .map((e) => e.name) - : []; -const orphans = routeLocales.filter((l) => !scope.locales.includes(l)); -if (orphans.length) { - throw new Error( - `Scope "${scopeId}" declares locales [${scope.locales.join(', ')}], but src/pages/ still ` + - `contains route directories for [${orphans.join(', ')}], which this build would emit ` + - `anyway. Convert src/pages// to a [locale] dynamic route whose getStaticPaths ` + - `reads the scope before building this scope.`, - ); -} -// The mirror case: a scope declaring a locale that has no pages yet. A warning, not an error -- -// a scope may legitimately be defined before its translations exist -- but silence here would -// mean shipping .be with its French half quietly missing. -const missing = scope.locales.slice(1).filter((l) => !routeLocales.includes(l)); -if (missing.length) { - console.warn( - `[scope] WARNING: scope "${scopeId}" declares locale(s) [${missing.join(', ')}] with no ` + - `src/pages// directory. Those languages will be advertised but not served.`, - ); -} - // https://astro.build/config export default defineConfig({ site: scope.site, diff --git a/src/config/scopes.ts b/src/config/scopes.ts index 3b0fcaa..0c5ccaa 100644 --- a/src/config/scopes.ts +++ b/src/config/scopes.ts @@ -46,7 +46,10 @@ export const SCOPES: Record = { be: { site: 'https://atproto.be', countries: ['be'], - locales: ['en', 'nl', 'fr'], + // French is the goal for .be and half its audience, but the UI string catalogue has no `fr` + // entries, so building it produces French URLs containing English -- worse than not offering + // it. Add 'fr' here in the same change that adds the French strings, not before. + locales: ['en', 'nl'], aggregate: false, relatedLangs: ['nl', 'fr'], joinCountries: ['be'], diff --git a/src/i18n/paths.ts b/src/i18n/paths.ts new file mode 100644 index 0000000..f7b747f --- /dev/null +++ b/src/i18n/paths.ts @@ -0,0 +1,44 @@ +// 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<T>(table: Record<string, T>, 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]; +} diff --git a/src/pages/[...lang]/about.astro b/src/pages/[...lang]/about.astro new file mode 100644 index 0000000..a731ec7 --- /dev/null +++ b/src/pages/[...lang]/about.astro @@ -0,0 +1,11 @@ +--- +import AboutPage from '../../components/pages/AboutPage.astro'; +import { localePaths } from '../../i18n/paths'; + +// One route per locale the scope declares; a scope that drops a locale simply does not build it. +export const getStaticPaths = localePaths; + +const { lang } = Astro.props; +--- + +<AboutPage lang={lang} /> diff --git a/src/pages/[...lang]/builders.astro b/src/pages/[...lang]/builders.astro new file mode 100644 index 0000000..534e536 --- /dev/null +++ b/src/pages/[...lang]/builders.astro @@ -0,0 +1,11 @@ +--- +import BuildersPage from '../../components/pages/BuildersPage.astro'; +import { localePaths } from '../../i18n/paths'; + +// One route per locale the scope declares; a scope that drops a locale simply does not build it. +export const getStaticPaths = localePaths; + +const { lang } = Astro.props; +--- + +<BuildersPage lang={lang} /> diff --git a/src/pages/[...lang]/community-size.astro b/src/pages/[...lang]/community-size.astro new file mode 100644 index 0000000..3b255e2 --- /dev/null +++ b/src/pages/[...lang]/community-size.astro @@ -0,0 +1,30 @@ +--- +import Base from '../../layouts/Base.astro'; +import CommunitySizePage from '../../components/pages/CommunitySizePage.astro'; +import { nlStats } from '../../data/nl-stats'; +import { localePaths, t } from '../../i18n/paths'; + +export const getStaticPaths = localePaths; + +const { lang } = Astro.props; + +// The modelled figure stays in the metadata even though the page leads with the counted +// one: it is the number that gets quoted, and it should travel with its ~ attached. +const est = nlStats.estimate.modelledNl.toLocaleString(lang === 'nl' ? 'nl-NL' : 'en-GB'); + +// The only page whose <title>/<description> differ per locale beyond what the component +// handles. Kept next to the route rather than in a global message catalogue so the copy stays +// visible to whoever edits the page. +const TITLES: Record<string, string> = { + en: 'How many Dutch accounts are on AT Protocol?', + nl: 'Hoeveel Nederlandse accounts zitten er op AT Protocol?', +}; +const DESCRIPTIONS: Record<string, string> = { + en: `AT Protocol has no country field, so we publish two numbers: the Dutch accounts we can point to, and a modelled total of around ${est}.`, + nl: `AT Protocol heeft geen landveld, dus publiceren we twee getallen: de Nederlandse accounts die we kunnen aanwijzen, en een gemodelleerd totaal van ongeveer ${est}.`, +}; +--- + +<Base lang={lang} title={t(TITLES, lang, 'title')} description={t(DESCRIPTIONS, lang, 'description')}> + <CommunitySizePage lang={lang} /> +</Base> diff --git a/src/pages/[...lang]/events.astro b/src/pages/[...lang]/events.astro new file mode 100644 index 0000000..3e887ef --- /dev/null +++ b/src/pages/[...lang]/events.astro @@ -0,0 +1,11 @@ +--- +import EventsPage from '../../components/pages/EventsPage.astro'; +import { localePaths } from '../../i18n/paths'; + +// One route per locale the scope declares; a scope that drops a locale simply does not build it. +export const getStaticPaths = localePaths; + +const { lang } = Astro.props; +--- + +<EventsPage lang={lang} /> diff --git a/src/pages/[...lang]/index.astro b/src/pages/[...lang]/index.astro new file mode 100644 index 0000000..93f17b2 --- /dev/null +++ b/src/pages/[...lang]/index.astro @@ -0,0 +1,11 @@ +--- +import HomePage from '../../components/pages/HomePage.astro'; +import { localePaths } from '../../i18n/paths'; + +// One route per locale the scope declares; a scope that drops a locale simply does not build it. +export const getStaticPaths = localePaths; + +const { lang } = Astro.props; +--- + +<HomePage lang={lang} /> diff --git a/src/pages/[...lang]/login.astro b/src/pages/[...lang]/login.astro new file mode 100644 index 0000000..ba390e7 --- /dev/null +++ b/src/pages/[...lang]/login.astro @@ -0,0 +1,11 @@ +--- +import LoginPage from '../../components/pages/LoginPage.astro'; +import { localePaths } from '../../i18n/paths'; + +// One route per locale the scope declares; a scope that drops a locale simply does not build it. +export const getStaticPaths = localePaths; + +const { lang } = Astro.props; +--- + +<LoginPage lang={lang} /> diff --git a/src/pages/[...lang]/members.astro b/src/pages/[...lang]/members.astro new file mode 100644 index 0000000..e13b354 --- /dev/null +++ b/src/pages/[...lang]/members.astro @@ -0,0 +1,11 @@ +--- +import MembersPage from '../../components/pages/MembersPage.astro'; +import { localePaths } from '../../i18n/paths'; + +// One route per locale the scope declares; a scope that drops a locale simply does not build it. +export const getStaticPaths = localePaths; + +const { lang } = Astro.props; +--- + +<MembersPage lang={lang} /> diff --git a/src/pages/[...lang]/privacy.astro b/src/pages/[...lang]/privacy.astro new file mode 100644 index 0000000..cb0101f --- /dev/null +++ b/src/pages/[...lang]/privacy.astro @@ -0,0 +1,11 @@ +--- +import PrivacyPage from '../../components/pages/PrivacyPage.astro'; +import { localePaths } from '../../i18n/paths'; + +// One route per locale the scope declares; a scope that drops a locale simply does not build it. +export const getStaticPaths = localePaths; + +const { lang } = Astro.props; +--- + +<PrivacyPage lang={lang} /> diff --git a/src/pages/[...lang]/terms.astro b/src/pages/[...lang]/terms.astro new file mode 100644 index 0000000..a7f68c5 --- /dev/null +++ b/src/pages/[...lang]/terms.astro @@ -0,0 +1,11 @@ +--- +import TermsPage from '../../components/pages/TermsPage.astro'; +import { localePaths } from '../../i18n/paths'; + +// One route per locale the scope declares; a scope that drops a locale simply does not build it. +export const getStaticPaths = localePaths; + +const { lang } = Astro.props; +--- + +<TermsPage lang={lang} /> diff --git a/src/pages/[...lang]/tools.astro b/src/pages/[...lang]/tools.astro new file mode 100644 index 0000000..acff618 --- /dev/null +++ b/src/pages/[...lang]/tools.astro @@ -0,0 +1,11 @@ +--- +import ToolsPage from '../../components/pages/ToolsPage.astro'; +import { localePaths } from '../../i18n/paths'; + +// One route per locale the scope declares; a scope that drops a locale simply does not build it. +export const getStaticPaths = localePaths; + +const { lang } = Astro.props; +--- + +<ToolsPage lang={lang} /> diff --git a/src/pages/about.astro b/src/pages/about.astro deleted file mode 100644 index d7238cc..0000000 --- a/src/pages/about.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -import AboutPage from '../components/pages/AboutPage.astro'; ---- - -<AboutPage lang="en" /> diff --git a/src/pages/builders.astro b/src/pages/builders.astro deleted file mode 100644 index 488c854..0000000 --- a/src/pages/builders.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -import BuildersPage from '../components/pages/BuildersPage.astro'; ---- - -<BuildersPage lang="en" /> diff --git a/src/pages/community-size.astro b/src/pages/community-size.astro deleted file mode 100644 index 207256e..0000000 --- a/src/pages/community-size.astro +++ /dev/null @@ -1,17 +0,0 @@ ---- -import Base from '../layouts/Base.astro'; -import CommunitySizePage from '../components/pages/CommunitySizePage.astro'; -import { nlStats } from '../data/nl-stats'; - -// The modelled figure stays in the metadata even though the page leads with the counted -// one: it is the number that gets quoted, and it should travel with its ~ attached. -const est = nlStats.estimate.modelledNl.toLocaleString('nl-NL'); ---- - -<Base - lang="en" - title="How many Dutch accounts are on AT Protocol?" - description={`AT Protocol has no country field, so we publish two numbers: the Dutch accounts we can point to, and a modelled total of around ${est}.`} -> - <CommunitySizePage lang="en" /> -</Base> diff --git a/src/pages/events.astro b/src/pages/events.astro deleted file mode 100644 index 56caf17..0000000 --- a/src/pages/events.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -import EventsPage from '../components/pages/EventsPage.astro'; ---- - -<EventsPage lang="en" /> diff --git a/src/pages/index.astro b/src/pages/index.astro deleted file mode 100644 index fca9c45..0000000 --- a/src/pages/index.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -import HomePage from '../components/pages/HomePage.astro'; ---- - -<HomePage lang="en" /> diff --git a/src/pages/login.astro b/src/pages/login.astro deleted file mode 100644 index 0e22b0b..0000000 --- a/src/pages/login.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -import LoginPage from '../components/pages/LoginPage.astro'; ---- - -<LoginPage lang="en" /> diff --git a/src/pages/members.astro b/src/pages/members.astro deleted file mode 100644 index 0a1563c..0000000 --- a/src/pages/members.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -import MembersPage from '../components/pages/MembersPage.astro'; ---- - -<MembersPage lang="en" /> diff --git a/src/pages/nl/about.astro b/src/pages/nl/about.astro deleted file mode 100644 index e41add6..0000000 --- a/src/pages/nl/about.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -import AboutPage from '../../components/pages/AboutPage.astro'; ---- - -<AboutPage lang="nl" /> diff --git a/src/pages/nl/builders.astro b/src/pages/nl/builders.astro deleted file mode 100644 index abfd6df..0000000 --- a/src/pages/nl/builders.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -import BuildersPage from '../../components/pages/BuildersPage.astro'; ---- - -<BuildersPage lang="nl" /> diff --git a/src/pages/nl/community-size.astro b/src/pages/nl/community-size.astro deleted file mode 100644 index a390351..0000000 --- a/src/pages/nl/community-size.astro +++ /dev/null @@ -1,15 +0,0 @@ ---- -import Base from '../../layouts/Base.astro'; -import CommunitySizePage from '../../components/pages/CommunitySizePage.astro'; -import { nlStats } from '../../data/nl-stats'; - -const est = nlStats.estimate.modelledNl.toLocaleString('nl-NL'); ---- - -<Base - lang="nl" - title="Hoeveel Nederlandse accounts zitten er op AT Protocol?" - description={`AT Protocol heeft geen landveld, dus publiceren we twee getallen: de Nederlandse accounts die we kunnen aanwijzen, en een gemodelleerd totaal van ongeveer ${est}.`} -> - <CommunitySizePage lang="nl" /> -</Base> diff --git a/src/pages/nl/events.astro b/src/pages/nl/events.astro deleted file mode 100644 index c741f9b..0000000 --- a/src/pages/nl/events.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -import EventsPage from '../../components/pages/EventsPage.astro'; ---- - -<EventsPage lang="nl" /> diff --git a/src/pages/nl/index.astro b/src/pages/nl/index.astro deleted file mode 100644 index 8e3daf9..0000000 --- a/src/pages/nl/index.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -import HomePage from '../../components/pages/HomePage.astro'; ---- - -<HomePage lang="nl" /> diff --git a/src/pages/nl/login.astro b/src/pages/nl/login.astro deleted file mode 100644 index ee1e80b..0000000 --- a/src/pages/nl/login.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -import LoginPage from '../../components/pages/LoginPage.astro'; ---- - -<LoginPage lang="nl" /> diff --git a/src/pages/nl/members.astro b/src/pages/nl/members.astro deleted file mode 100644 index af2472d..0000000 --- a/src/pages/nl/members.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -import MembersPage from '../../components/pages/MembersPage.astro'; ---- - -<MembersPage lang="nl" /> diff --git a/src/pages/nl/privacy.astro b/src/pages/nl/privacy.astro deleted file mode 100644 index db9ed19..0000000 --- a/src/pages/nl/privacy.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -import PrivacyPage from '../../components/pages/PrivacyPage.astro'; ---- - -<PrivacyPage lang="nl" /> diff --git a/src/pages/nl/terms.astro b/src/pages/nl/terms.astro deleted file mode 100644 index 029705c..0000000 --- a/src/pages/nl/terms.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -import TermsPage from '../../components/pages/TermsPage.astro'; ---- - -<TermsPage lang="nl" /> diff --git a/src/pages/nl/tools.astro b/src/pages/nl/tools.astro deleted file mode 100644 index 3c097cc..0000000 --- a/src/pages/nl/tools.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -import ToolsPage from '../../components/pages/ToolsPage.astro'; ---- - -<ToolsPage lang="nl" /> diff --git a/src/pages/privacy.astro b/src/pages/privacy.astro deleted file mode 100644 index d36fb2d..0000000 --- a/src/pages/privacy.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -import PrivacyPage from '../components/pages/PrivacyPage.astro'; ---- - -<PrivacyPage lang="en" /> diff --git a/src/pages/terms.astro b/src/pages/terms.astro deleted file mode 100644 index 476ded6..0000000 --- a/src/pages/terms.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -import TermsPage from '../components/pages/TermsPage.astro'; ---- - -<TermsPage lang="en" /> diff --git a/src/pages/tools.astro b/src/pages/tools.astro deleted file mode 100644 index 8893445..0000000 --- a/src/pages/tools.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -import ToolsPage from '../components/pages/ToolsPage.astro'; ---- - -<ToolsPage lang="en" /> -- 2.54.0