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 thelocalekey inlocalStorage, orfrwhen nothing is saved.fallbackLocale: 'fr': a key missing fromen.jsonshows the French text.legacy: false(Composition API) andglobalInjection: true, so templates can use$t(...)directly.setLocale(code)switches the language, persists it inlocalStorageand updates the<html lang>attribute. The flag button in the navigation bar calls it for every entry ofSUPPORTED_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:
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¶
- Pick the namespace matching the screen or feature (the top-level keys:
nav,common,auth,recipes,planning,shopping,account,admin,allergens, ...). Usecommononly for truly generic words ("Save", "Cancel"). - Add the key to both
fr.jsonanden.json, at the same position, so the two files can be compared line by line. - Use it in the component with
$t('namespace.key'). - Don't hard-code text in templates, including
aria-label,titleandplaceholderattributes, 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 (
detailfields), 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 fromapps/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):
- Create the messages file: copy
frontend/src/i18n/locales/en.jsontoes.jsonand translate every value. Keep the keys, placeholders ({count}) and|plural separators intact. -
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
labelin its own language: it is what speakers look for in the menu. 3. Add a flag infrontend/src/components/LocaleFlag.vue, which draws flags as inline SVG (emoji flags don't render on Windows). Today it renders the French flag forfrand the UK flag for anything else, so add av-else-if="code === 'es'"branch. 4. Check plural rules: if the language's plural forms don't fit vue-i18n's default rule, add apluralRulesentry for it increateI18n(). Check the unit pluralization inutils/format.tstoo. 5. Update the tests:tests/unit/i18n.test.tsasserts the exact list of supported locales. 6. Check the result: runnpm run typecheckandnpm 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 underAdded.
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.