Skip to content

I18N #311

Description

@froozeify

Plan — Frontend i18n (French + English) for narvik-front

Context

The user wants Narvik to support translation. Today the app is French-only with zero i18n infrastructure on either side:

  • Frontend (narvik-front, Nuxt 4 SPA, ssr: false): no i18n library, no locale files, no $t. ~103 of ~140 .vue files contain hardcoded French (~650 lines), in both templates and script logic (toasts, validation messages, step labels). Enum labels are hand-mapped to French in scattered places.
  • Backend (narvik-back, Symfony/API Platform): no symfony/translation, no locale source. Returns hardcoded English validation/exception prose and machine-value enums; emails are hardcoded French Twig.

Decision (confirmed with user)

Best practice for this stack is frontend-owned UI translation. The API should return stable machine codes/enums (it already does for enums), and the frontend maps them to localized text — the backend should not translate response prose. The only genuine backend-i18n case is emails, which is explicitly out of scope for v1.

v1 scope: Full frontend sweep. Add @nuxtjs/i18n, extract all hardcoded French into fr + en locale catalogs, add a language switcher, and localize date/currency formatting. Locale is browser-detected + manually switchable + cookie-persisted (no backend/account changes, no user locale field).

Explicitly out of scope for v1: backend symfony/translation, localized emails, a User.locale field, and turning backend English error prose into codes (tracked as follow-up below).

Status: plan only — do not implement yet. This document is saved for later execution.


Translation file format — decision

Use JSON catalogs as the source of truth. Not .po.

  • @nuxtjs/i18n is built on vue-i18n, which reads JSON/JS natively. .po (gettext) requires a .po → JSON build step every load — a conversion layer for no real benefit here.
  • Modern translation platforms (Crowdin, Weblate, Lokalise) edit JSON directly; Poedit can too. .po is not required for a good translator UX.
  • Escape hatch: if an external agency insists on gettext, the extraction script (below) can additionally emit a .pot template and we add a .po → JSON converter (e.g. i18next-conv-style). But JSON stays canonical; .po would be an export artifact only.

Catalogs live at narvik-front/i18n/locales/{fr,en}.json, namespaced by domain (login.*, member.*, loan.*, sale.*, presence.*, common.*, validation.*, enum.*).


Extraction tooling — scripts

Two distinct scripts, both committed under narvik-front/scripts/i18n/:

A. One-time migration extractor (extract-hardcoded.mjs)

Purpose: turn the ~650 existing hardcoded French literals into a starting catalog + a per-file worklist, so the Phase-4 sweep is mechanical rather than hunt-and-peck. It does not auto-rewrite code (too risky for template vs. script context) — it produces drafts a human wires up.

  • Scan app/**/*.vue, app/utils/*.ts, app/types/api/**/*.ts for string literals containing French accented chars ([àâäéèêëîïôöùûüçœ]) or known French tokens, in both <template> text/attribute bindings and <script> literals.
  • Emit:
    1. i18n/locales/fr.draft.json — proposed namespaced keys (namespace derived from file path, e.g. components/Loan/*loan.*) mapped to the extracted French string.
    2. en.draft.json — same keys, values empty (to be filled by translator).
    3. scripts/i18n/worklist.md — per-file table: line number, original string, proposed key, context (template/script). This is the checklist that drives Phase 4.
    4. Optional --pot flag → also write messages.pot for a gettext export.
  • De-duplicate identical strings into shared common.* keys; flag near-duplicates for manual merge.
  • Node ESM script (repo already uses "type": "module"); parse .vue with a lightweight SFC split + regex, no heavy AST dependency required for a one-shot tool.

B. Ongoing key linter (vue-i18n-extract)

Purpose: after migration, keep catalogs honest. Add the vue-i18n-extract dev dependency (purpose-built for vue-i18n) and a pnpm i18n:check script that reports missing keys (used in code, absent from catalog) and unused keys (in catalog, never referenced). Wire it into CI / the lint step so drift is caught on every PR.


Approach

Phase 1 — Infrastructure (~1 day)

  1. Install & configure @nuxtjs/i18n (v9+/v10, Nuxt 4 compatible). Add to modules in narvik-front/nuxt.config.ts:21.
    • defaultLocale: 'fr', locales fr + en.
    • strategy: 'no_prefix' — this is an authenticated SPA, not a marketing site; URLs must not carry a locale prefix (keeps existing routes/robots/sitemap in nuxt.config.ts:118-124 untouched).
    • detectBrowserLanguage: { useCookie: true, cookieKey: 'narvik_locale', fallbackLocale: 'fr', redirectOn: 'no prefix' }.
    • lazy: true, langDir pointing at the locale catalogs below.
  2. Locale catalogs — new narvik-front/i18n/locales/fr.json and en.json. Namespace keys by domain to mirror the component tree (e.g. login.*, member.*, loan.*, sale.*, presence.*, common.*, validation.*, enum.*). Do not dump everything flat.
  3. Nuxt UI v4 component locale — Nuxt UI ships its own i18n for built-in component strings; wire <UApp :locale> (or the @nuxt/ui locale option) to the active @nuxtjs/i18n locale so date-pickers/pagination/etc. follow the switch.
  4. Language switcher component — new narvik-front/app/components/LocaleSwitcher.vue, modeled exactly on the existing app/components/ThemeSwitcher.vue UFieldGroup/UButton pattern. Place it wherever ThemeSwitcher already renders (header/nav).
  5. Build the migration extractor (scripts/i18n/extract-hardcoded.mjs, script A above) and run it once to generate fr.draft.json, en.draft.json, and worklist.md — the input to Phase 4. Also add vue-i18n-extract + the pnpm i18n:check script (script B).

Phase 2 — Localize formatting utilities (~0.5 day)

These centralize locale-dependent output and must consume the active locale instead of the hardcoded 'fr-FR':

  • app/utils/string.tsformatMonetary() uses toLocaleString('fr-FR', …) and fallback 'Non défini'. Drive the locale from the current i18n locale; move 'Non défini' to a common.* key.
  • app/utils/date.tsformatDateReadable/formatTimeReadable/formatDateTimeReadable use native Intl 'fr-FR' and a hardcoded " à " separator. Parameterize the Intl locale; move " à " to a key. Numeric DD/MM/YYYY formats (dayjs) can stay, or become locale-driven if EN users should see MM/DD/YYYY (confirm during impl — default: keep numeric format stable).
  • dayjs currently has no locale configured and is only used for numeric formats, so no dayjs locale plugin is strictly required unless readable dayjs output is later introduced.

Phase 3 — Extract enum/label maps (~1 day)

Convert the three existing French-label patterns into enum.* catalog keys, keeping the same public helper signatures so callers don't change:

  • app/types/api/item/club.tsgetAvailableClubRoles() / getAvailableClubRole() ({text,value} builders) and clubPlugins. Return t('enum.clubRole.*') instead of literals. Fix the missing CLUB_BADGER label while here.
  • app/utils/loan.tsLOAN_ITEM_STATUS_LABELS record → catalog keys. De-duplicate: app/components/Loan/LoanItemForm.vue:66-71 re-declares this map inline (and drops loaned) — make it consume the shared helper.
  • app/types/api/permissions.tspermissionSections inline label/name French strings → enum.permission.* keys, rendered by app/components/Member/MemberPermissions.vue.
  • Note: helpers that return translated strings must call useI18n()/t — where these run outside component setup (plain util modules), use the global nuxtApp.$i18n.t accessor so they work in non-setup contexts.

Phase 4 — Full component/page sweep (~1–2 weeks, the bulk)

Extract all remaining hardcoded French across ~103 .vue files into the namespaced catalogs. Two mechanical patterns repeat everywhere:

  • Templates: Champ requis{{ $t('validation.required') }} / :label="$t('member.form.securityCode')".
  • Script logic (toasts, pushed validation errors, step labels): useI18n()'s t(), e.g. message: t('login.badCredentials'). Representative hotspots found: app/pages/login/password-reset.vue (validation + toast titles), app/pages/login/index.vue:40 (error branches), plus every domain folder under app/components/ (Member/, Loan/, Sale/, Presence/, Email/, …).

Driven by worklist.md from the extractor: promote reviewed keys from fr.draft.json into fr.json and wire each source line to $t()/t(). Sweep domain-by-domain (login → member → loan → sale → presence → …) so each slice is independently reviewable and testable, even though all land in v1. Fill en.json with real English per slice (untranslated keys fall back to FR via fallbackLocale). Run pnpm i18n:check after each slice to confirm no missing/unused keys.

Representative files to modify (pattern repeats — not exhaustive):

  • app/components/ThemeSwitcher.vue (Clair/Sombre) and new LocaleSwitcher.vue
  • app/pages/login/*.vue, app/pages/index.vue
  • One file per domain under app/components/*/
  • nuxt.config.ts, app/utils/{string,date}.ts, app/types/api/{item/club,permissions}.ts, app/utils/loan.ts

Follow-up (NOT in v1 — backend, when emails/account-level locale are wanted)

Deferred by decision. When picked up later: add symfony/translation + a locale source (Accept-Language listener and/or a User.locale field + migration), localize the templates/email/*.twig templates and hardcoded subjects, and convert the ~15 hardcoded English UniqueEntity/exception messages (e.g. Member.php:52 'Licence already registered') into stable codes the frontend maps under validation.*.


Verification

  1. rtk pnpm install then rtk pnpm dev — app boots with the i18n module, no console errors.
  2. Switch test: toggle the LocaleSwitcher FR↔EN and confirm live re-render of: a form with validation, a toast (trigger a login error), enum labels (club role dropdown, loan item status badge, permissions screen), and a date/currency display.
  3. Persistence: reload after switching → cookie narvik_locale keeps the choice. Fresh browser with Accept-Language: en → app starts in English.
  4. Fallback: temporarily remove a key from en.json → confirm it falls back to French rather than showing the raw key.
  5. No-regression: rtk pnpm lint clean; rtk pnpm test:e2e (Playwright) passes — update any specs asserting French literals to assert via keys or the FR catalog.
  6. Coverage check: grep for remaining accented French literals in app/**/*.vue to catch anything the sweep missed.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions