Skip to content

Translations

Cocotte's interface is available in French (the default) and English. All translation work happens in the frontend, with vue-i18n v11 in Composition API mode. The backend has no translation layer (see Backend strings).

How it is set up

frontend/src/i18n/
├── index.ts          createI18n(), SUPPORTED_LOCALES, setLocale()
└── locales/
    ├── fr.json       French messages (source of truth, fallback)
    └── en.json       English messages, same keys

src/i18n/index.ts configures the instance:

  • locale: the value saved under the locale key in localStorage, or fr when nothing is saved.
  • fallbackLocale: 'fr': a key missing from en.json shows the French text.
  • legacy: false (Composition API) and globalInjection: true, so templates can use $t(...) directly.
  • setLocale(code) switches the language, persists it in localStorage and updates the <html lang> attribute. The flag button in the navigation bar calls it for every entry of SUPPORTED_LOCALES.

The locale is not detected from the browser: first-time visitors see French until they pick another language.

Using translations in code

In templates, use the globally injected $t:

<h1>{{ $t('recipes.newRecipe') }}</h1>
<button :aria-label="$t('planning.previousWeek')">...</button>

In <script setup>, use useI18n():

import { useI18n } from 'vue-i18n'

const { t } = useI18n()
cooklangError.value = t('recipes.cooklangImportError')

Outside components (utilities, the router), import the instance: i18n.global.t('...').

Interpolation

Named placeholders in braces:

"pageOf": "Page {page} / {total}",
"importedBy": "importé par {author}"
{{ $t('common.pageOf', { page, total: totalPages }) }}

Pluralization

Separate the forms with |. With two forms, they are singular and plural; with three, they are zero, one and many. Pass the count as the second argument; it is also available as {n} (or {count} when you pass it explicitly):

"activeFilters": "{count} filtre actif | {count} filtres actifs",
"nutritionAlerts": "aucune alerte | {n} alerte nutritionnelle | {n} alertes nutritionnelles"
{{ $t('home.nutritionAlerts', deficiencyCount) }}
<span :aria-label="$t('recipes.activeFilters', { count: activeCount })">

Note

vue-i18n's default plural rule is the English one (1 is singular, everything else is plural, including 0). When French and English grammar differ, handle it in code: unit labels use a separate unitsPlural group, and utils/format.ts decides when to use it (from 2 in French, anything but 1 in English).

Rich text

When part of a sentence needs markup (bold, a link), don't split the sentence into several keys glued together in the template. Use the <i18n-t> component with slots, so translators still see one sentence:

"restrictedNotice": "This recipe comes from a third-party source and {copyright}: out of respect for those rights, {visibility}, ...",
"restrictedNoticeCopyright": "may be protected by copyright"
<i18n-t keypath="recipes.restrictedNotice" tag="p" scope="global">
  <template #copyright><strong>{{ $t('recipes.restrictedNoticeCopyright') }}</strong></template>
  <template #visibility><strong>{{ $t('recipes.restrictedNoticeVisibility') }}</strong></template>
</i18n-t>

Adding or changing a string

  1. Pick the namespace matching the screen or feature (the top-level keys: nav, common, auth, recipes, planning, shopping, account, admin, allergens, ...). Use common only for truly generic words ("Save", "Cancel").
  2. Add the key to both fr.json and en.json, at the same position, so the two files can be compared line by line.
  3. Use it in the component with $t('namespace.key').
  4. Don't hard-code text in templates, including aria-label, title and placeholder attributes, and don't put emojis or + signs in labels when an icon already carries that meaning.

Important

fr.json and en.json must always contain exactly the same keys. A missing English key silently falls back to French, which is easy to miss in review. Check with:

cd frontend
node -e "
const flat = (o, p = '') => Object.entries(o).flatMap(([k, v]) =>
  v && typeof v === 'object' ? flat(v, p + k + '.') : [p + k]);
const fr = new Set(flat(require('./src/i18n/locales/fr.json')));
const en = new Set(flat(require('./src/i18n/locales/en.json')));
console.log('missing in en:', [...fr].filter((k) => !en.has(k)));
console.log('missing in fr:', [...en].filter((k) => !fr.has(k)));
"

Unit tests render components with the real i18n instance (tests/unit/i18n.test.ts checks the defaults and the switch). When a test asserts on text, it usually expects the French string.

Backend strings

The backend does not use Django's translation machinery (gettext): LANGUAGE_CODE is fr-fr and a few strings are written directly in French:

  • API error messages (detail fields), which the frontend generally replaces with its own translated message;
  • model choice labels (diets, units, categories) and help texts, which appear in the Django admin;
  • the PDF templates (apps/recipes/templates/pdf/recipe.html, apps/planning/templates/pdf/week.html) and the shopping-list text export, with unit labels from apps/ingredients/templatetags/unit_labels.py.

So PDFs and text exports are French-only today. Translating them would mean adopting Django's i18n (gettext, .po files) and passing the user's locale to the export endpoints; open an issue before starting such work.

Ingredient names are data, not interface strings: they are stored in French. The Ingredient.translations field (e.g. {"en": "garlic"}) is only used to match ingredients when importing a recipe written in another language, not for display.

Adding a new language

Say you want to add Spanish (es):

  1. Create the messages file: copy frontend/src/i18n/locales/en.json to es.json and translate every value. Keep the keys, placeholders ({count}) and | plural separators intact.
  2. Register it in frontend/src/i18n/index.ts:

    import es from './locales/es.json'
    
    export type Locale = 'fr' | 'en' | 'es'
    
    export const SUPPORTED_LOCALES: Array<{ code: Locale; label: string }> = [
      { code: 'fr', label: 'Français' },
      { code: 'en', label: 'English' },
      { code: 'es', label: 'Español' },
    ]
    
    // ...
      messages: { fr, en, es },
    

    Write each label in its own language: it is what speakers look for in the menu. 3. Add a flag in frontend/src/components/LocaleFlag.vue, which draws flags as inline SVG (emoji flags don't render on Windows). Today it renders the French flag for fr and the UK flag for anything else, so add a v-else-if="code === 'es'" branch. 4. Check plural rules: if the language's plural forms don't fit vue-i18n's default rule, add a pluralRules entry for it in createI18n(). Check the unit pluralization in utils/format.ts too. 5. Update the tests: tests/unit/i18n.test.ts asserts the exact list of supported locales. 6. Check the result: run npm run typecheck and npm run test:unit, then browse the app in the new language, looking for overflowing labels on a phone-sized window. 7. Document it: add a changelog entry under Added.

Tip

You don't have to translate everything in one pull request: untranslated keys fall back to French. Still, a new language should only be listed in SUPPORTED_LOCALES once the main screens (navigation, recipes, planner, shopping lists, account) are translated.