Skip to content

Internationalisation (i18n) ​

The app is translated into five locales using @nuxtjs/i18n with a no_prefix routing strategy and a split catalog model: 18 per-section TypeScript files per locale, registered through a single locales.config.ts source of truth.


Routing Strategy ​

no_prefix keeps the URL unchanged across all locales. Locale state lives in a cookie and in the Accept-Language header β€” not in the URL.

AspectApp (no_prefix)Website (prefix_except_default)
English URL/dashboard/dashboard
Spanish URL/dashboard (same)/es/dashboard
Locale sourceCookie i18n_redirected + Accept-LanguageURL prefix
WhyAuth app β€” no SEO/hreflang need. Avoids rewriting auth.global.ts + 18 Playwright path specsPublic site needs crawlable per-locale URLs

auth.global.ts publicRoutes[] and all Playwright path specs are locale-neutral β€” do not add locale prefixes to them.


Locale Set ​

CodeDisplay nameBCP-47 tagDefault currency
en (default)Englishen-USUSD
esEspaΓ±oles-ESEUR
ptPortuguΓͺspt-PTEUR
frFranΓ§aisfr-FREUR
deDeutschde-DEEUR

APP_LOCALES in i18n/locales.config.ts is the canonical list. The parity test (tests/i18n-catalog-parity) derives its locale set from this array β€” adding a sixth locale here automatically extends the test.


Locale Detection Order ​

On every request @nuxtjs/i18n resolves the active locale in this order:

  1. Cookie i18n_redirected (set by setLocale() on switch)
  2. Accept-Language request header
  3. Fallback to 'en'

fallbackLocale: 'en' in nuxt.config.ts ensures any key absent in a non-EN catalog silently falls back to English β€” no raw key path ever reaches the user.


Locale Switch ​

Use setLocale() from useI18n() β€” never switchLocalePath().

switchLocalePath() only builds the URL; it does not write the i18n_redirected cookie. Under no_prefix, the URL does not change, so the only locale-change side-effect is the cookie write β€” which switchLocalePath() skips. On the next navigation detectBrowserLanguage reads the stale cookie and reverts to the previous locale.

typescript
const { setLocale } = useI18n()

// Correct:
await setLocale('es')

// Wrong β€” do not use:
// const path = switchLocalePath('es')
// await navigateTo(path)

SharedLanguageSwitcher uses this pattern internally. All mount points call through it.


Catalog Structure ​

The catalog is split into 18 section files per locale under i18n/locales/<locale>/. EN is source of truth; the other four locales mirror it exactly.

i18n/
  locales.config.ts        # single SoT β€” section file lists + APP_LOCALES
  i18n.config.ts           # datetimeFormats + numberFormats, keyed by locale code
  locales/
    en/
      common.ts   nav.ts   auth.ts      dashboard.ts  gnosaris.ts   chat.ts
      sessions.ts knowledge.ts traits.ts entities.ts  entityTypes.ts qrCodes.ts
      chatThemes.ts settings.ts subscription.ts widget.ts embed.ts build.ts
    es/   <same 18 files β€” full Castilian translation>
    pt/   <same 18 files>
    fr/   <same 18 files>
    de/   <same 18 files>

locales.config.ts exports EN_SECTION_FILES, ES_SECTION_FILES, … DE_SECTION_FILES (each an array of 18 paths) and APP_LOCALES (the { code, name, language, files } array). This file is imported by both nuxt.config.ts and tests/i18n-catalog-parity.test.ts β€” it is the only place to register a new section or locale.

@nuxtjs/i18n deep-merges all files in the files array under the same locale key, so sections compose transparently.

Key naming convention: section.subsection.key β€” lowercase, dot-separated, no spaces. The section corresponds to the file name (e.g. keys for the chat surface live in chat.ts and are prefixed with chat.).


SharedLanguageSwitcher ​

app/components/shared/LanguageSwitcher.vue (<SharedLanguageSwitcher>) is the locale picker for the app chrome. It is self-contained β€” no props required.

AspectDetail
Component pathapp/components/shared/LanguageSwitcher.vue
Auto-registered as<SharedLanguageSwitcher> (Nuxt pathPrefix: true)
Data sourceuseI18n().locales β€” the 5 configured entries; never a hardcoded list
Switch mechanismuseI18n().setLocale(code) (see Locale Switch section above)
PropsNone required. Self-contained via useI18n()
EmitsNone. Side-effect is the global locale change
Mount pointsSidebar user area (SidebarUser.vue) and settings header

See app/components/shared/README.md for the catalog entry and recorded deviation on the loading prop omission.


Accept-Language Wiring ​

The active locale is forwarded to the backend as an Accept-Language header on a bounded set of routes.

In scope ​

FileHow
app/composables/useApi.tsInjected inside the composable from useI18n().locale captured at factory time. NOT via a caller-passed header β€” the composable's allow-list drops caller headers other than Content-Type/Accept.
server/api/gnosari.tsReads the header from the request and forwards it upstream. The catch-all server/api/[...path].ts re-exports this implementation.

Explicitly out of scope ​

FileWhy
server/api/auth.post.tsStandalone login proxy β€” no backend response localisation today
server/api/auth/google.*, auth/user.get.tsGoogle auth handlers
server/api/oauth/*, verify-urls.post.tsOAuth handlers
Raw $fetch in signup.vue, Step1Auth.vue, useGoogleAuth, useOAuthFlow, useUrlVerification, useAuth.tsThe composable allow-list silently drops any caller-passed Accept-Language; injecting it into raw $fetch is deferred until backend response localisation ships

Locale-Reactive Shared Content ​

Some content is sourced from a shared package rather than the local catalog and needs its own locale-reactivity pattern.

SymbolWhereShape
useWizardPurposes()app/composables/features/useAgentWizard.tsuseI18n()-wrapping composable β€” returns ComputedRef<AgentPurpose[]>
matchBusiness(input, locale)app/composables/features/useBuildV2Matcher.tsPlain util β€” takes locale as an explicit parameter, no useI18n() inside

useWizardPurposes() computes over useI18n().locale and resolves each purpose from the shared @neomanex/gnosari-agent-purposes package per id via getPurposeById(id, locale.value) β€” never getAllPurposes(). Resolving per id (rather than taking the package's own list) is deliberate: it preserves the raw declaration order the /build grid already renders, instead of inheriting whatever order the package exports. Because it wraps useI18n(), it MUST be constructed in synchronous setup scope β€” see the Gotcha in the project CLAUDE.md.

matchBusiness(input, locale = 'en') takes the opposite shape: it's a plain utility, not a composable, so it has no useI18n() call to protect and takes locale as an explicit parameter instead. Keyword matching runs on locale-invariant purpose ids; only the returned display objects (resolved via getPurposeById) are localized.

See documentation/standards/ui/11-i18n.md Β§ Shared Content Packages for the underlying private-npm-content-package pattern both symbols consume.


Dates, Numbers, and Currency ​

In templates ​

Use the $d() and $n() helpers β€” they read datetimeFormats and numberFormats from i18n/i18n.config.ts.

HelperFormat keysExample
$d(date, 'short')short, full, long, timeJun 29, 2026
$n(value, 'currency')currency, decimal, percent$12.00 / €12,00

Currency defaults: USD for en, EUR for es/pt/fr/de. At runtime plan.currency from the API always overrides the format default β€” pass it explicitly: $n(amount, 'currency', { currency: plan.currency }).

Outside templates (composables / utilities) ​

Use app/utils/formatters/locale-intl.ts β€” a pure helper that maps the active vue-i18n locale code to a BCP-47 tag for Intl APIs.

vue-i18n codeBCP-47 tag
enen-US
eses-ES
ptpt-PT
frfr-FR
dede-DE

Adding a New Locale ​

All registration flows through i18n/locales.config.ts.

  1. Add {CODE}_SECTION_FILES constant β€” array of 18 paths '{code}/common.ts', '{code}/nav.ts', … mirroring EN_SECTION_FILES with the new locale prefix.
  2. Add an entry to APP_LOCALES: { code: 'it', name: 'Italiano', language: 'it-IT', files: IT_SECTION_FILES }.
  3. Create i18n/locales/{code}/<section>.ts for each of the 18 sections β€” full key mirror of the corresponding EN file.
  4. Add datetimeFormats and numberFormats entries for the new code in i18n/i18n.config.ts.
  5. Add the code to LOCALE_TO_INTL in app/utils/formatters/locale-intl.ts.
  6. Run tests/i18n-catalog-parity β€” it auto-detects the new locale from APP_LOCALES and must pass.
  7. Run the translation-quality-judge agent before enabling the locale in production.

Adding or Translating Strings ​

  1. Add the key to the correct section file in i18n/locales/en/<section>.ts. Key naming: section.subsection.key (lowercase, dot-separated).
  2. Add the same key to the matching section file in all four non-EN locales (es/<section>.ts, pt/<section>.ts, fr/<section>.ts, de/<section>.ts). Use the EN value as a placeholder until translated β€” fallbackLocale: 'en' prevents a raw-key flash, but the catalog-parity test enforces identical key sets across all locales.
  3. In templates, use $t('section.subsection.key'). For aria attributes: :aria-label="$t('section.subsection.key')". For strings that contain markup: <i18n-t tag="span" keypath="section.subsection.key">.
  4. Run tests/i18n-catalog-parity β€” this is a blocking CI gate. A missing key in any non-EN locale fails the build.