Skip to content

feat(i18n): runtime-configurable fallback locale (DEFAULT_LOCALE) - #1080

Draft
forain wants to merge 2 commits into
bulwarkmail:mainfrom
forain:feat/runtime-default-locale
Draft

forain wants to merge 2 commits into
bulwarkmail:mainfrom
forain:feat/runtime-default-locale

Conversation

@forain

@forain forain commented Sep 21, 2026 •

Copy link
Copy Markdown

What

Makes the fallback UI locale (used when neither the visitor's NEXT_LOCALE cookie nor Accept-Language matches a shipped catalogue) configurable at runtime: DEFAULT_LOCALE env var or Admin → Settings → Default Language.

Today this is only NEXT_PUBLIC_DEFAULT_LOCALE, baked in at build time, so anyone on the published Docker image who wants e.g. Portuguese as the fallback has to build their own image. Per-visitor precedence is unchanged: the user's own choice, then browser language, then this fallback.

How

  • lib/admin/types.ts – CONFIG_ENV_MAP.defaultLocale (DEFAULT_LOCALE, enum over the shipped locales, '' = build default). Being an enum, PATCH /api/admin/config already rejects anything else.
  • lib/admin/default-locale.ts – resolveDefaultLocale(): admin override → env → build-time routing.defaultLocale. Values that aren't a shipped catalogue are ignored (regional tags like pt-BR map onto pt at match time, not here).
  • i18n/request.ts – next-intl's fallback uses it.
  • proxy.ts – next-intl bakes defaultLocale into createIntlMiddleware, so the proxy now builds one middleware per default-locale value seen (cached in a Map). Lets the admin change it without a restart.
  • /api/config exposes defaultLocale; i18n/runtime-default-locale.ts mirrors it on the client (filled from fetchConfig()) and lib/deep-links.ts uses it so as-needed URL prefixing agrees with the server.
  • i18n/locale-names.ts – autonyms extracted from LanguageSwitcher and shared with the new admin select.
  • Docs: README, .env.example, FEATURES.md. Also brought the supported-locale lists up to date (mn, nb, zh-TW were missing).

The static Lite build is untouched: it has no server, so it keeps using NEXT_PUBLIC_DEFAULT_LOCALE.

Tests

  • lib/admin/__tests__/default-locale.test.ts – configured locale wins; empty falls back to build default; unknown / regional values ignored.
  • i18n/__tests__/runtime-default-locale.test.ts – client mirror starts at build default, follows the server value, drops invalid values.

npm run typecheck, npm run lint, npx vitest run all green (the one failure locally is the pre-existing timezone-dependent birthday-calendar test, unrelated).

Notes for review

  • Rebased on top of 77ef60c (feat: resolve deep links to the local instance); its isConnectorRoute exclusion in proxy.ts is preserved.
  • Admin UI strings are English-only like the rest of the admin dashboard.

The fallback UI locale (used when neither the visitor's cookie nor
Accept-Language matches a shipped catalogue) was only settable at build
time via NEXT_PUBLIC_DEFAULT_LOCALE, so changing it meant a custom image.

Add a DEFAULT_LOCALE config key (env var or admin dashboard, Settings →
Default Language) resolved at request time:

- lib/admin/default-locale.ts resolves admin/env > build default and is
  used by the next-intl request config, the proxy (one intl middleware per
  default locale seen, since next-intl bakes it in) and /api/config.
- The client mirrors the resolved value (i18n/runtime-default-locale.ts)
  so 'as-needed' URL prefixing in deep links agrees with the server.
- Locale autonyms move to i18n/locale-names.ts, shared by the language
  switcher and the new admin select.

Regional tags still map onto catalogues at match time (pt-BR → pt), so
DEFAULT_LOCALE=pt is the setting for a Brazilian deployment.
Copilot AI lite review requested due to automatic review settings September 21, 2026 15:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unresolved critical and moderate findings remain in locale resolution, client propagation, deep-link handling, and admin selection.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity · 1 Medium severity · 1 Low severity

Open (3)
What changed in this PR

Adds runtime-configurable fallback locales via environment or admin settings, while preserving build-time behavior for Lite builds.

Changes:

  • Resolves fallback locales server-side and in middleware.
  • Synchronizes API, client, and deep-link behavior.
  • Adds admin UI, locale metadata, tests, and documentation.
File Description
README.md Documents runtime locale configuration.
proxy.ts Caches locale-specific middleware instances.
lib/​deep-links.ts Uses the runtime fallback locale.
lib/​admin/​types.ts Adds DEFAULT_LOCALE configuration.
lib/​admin/​default-locale.ts Resolves configured fallback locales.
lib/​admin/​__tests__/​default-locale.test.ts Tests server locale resolution.
i18n/​runtime-default-locale.ts Provides the client-side locale mirror.
i18n/​request.ts Applies runtime fallback during requests.
i18n/​locale-names.ts Centralizes locale display names.
i18n/​__tests__/​runtime-default-locale.test.ts Tests client locale handling.
hooks/​use-config.ts Loads the runtime locale into the client.
FEATURES.md Updates locale configuration documentation.
components/​ui/​language-switcher.tsx Uses shared locale names.
app/​api/​config/​route.ts Exposes the effective default locale.
app/​(main)/​admin/​_tabs/​settings.tsx Adds the Default Language setting.
.env.example Documents locale environment variables.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread lib/admin/default-locale.ts Outdated
* been loaded (every request path calls ensureLoaded() first).
*/
export function resolveDefaultLocale(): Locale {
const configured = configManager.get<string>('defaultLocale', '').trim();
Comment thread lib/deep-links.ts
if (routing.localePrefix === 'always') {
withLocale = `/${resolved}${normalized === '/' ? '' : normalized}`;
} else if (routing.localePrefix === 'as-needed' && resolved !== routing.defaultLocale) {
} else if (routing.localePrefix === 'as-needed' && resolved !== getDefaultLocale()) {
Comment thread .env.example Outdated
Comment on lines +451 to +453
# DEFAULT_LOCALE is read at runtime (also settable in the admin dashboard);
# NEXT_PUBLIC_DEFAULT_LOCALE is the build-time equivalent and is what the
# static Lite build uses.
Review follow-ups:

- resolveDefaultLocale reads the admin value as unknown and only trims a
  string, so a malformed config.json (null, number) is ignored instead of
  throwing in the proxy on every request.
- getLocaleFromPath falls back to the deployment default locale rather
  than a hard-coded 'en'. With 'as-needed' prefixing and a runtime default
  of e.g. pt, links generated from an unprefixed URL like /mail stayed
  unprefixed instead of becoming /en/... .
- .env.example: say up front which i18n variables are runtime
  (DEFAULT_LOCALE) and which are build-time (NEXT_PUBLIC_*).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants