apps/readest-app/docs/i18n.md
Readest uses a key-as-content approach — English strings are the translation keys. The English locale (en/translation.json) is empty because keys serve as content. Other locales contain actual translations.
import { useTranslation } from '@/hooks/useTranslation';
const _ = useTranslation();
_('Progress synced');
Two-step process:
1. Declaration — Use stubTranslation to mark strings for scanner extraction (returns key as-is, does NOT translate):
import { stubTranslation as _ } from '@/utils/misc';
// These calls only register keys for extraction
_('Reveal in Finder');
_('Reveal in Explorer');
2. Usage — In the React component that consumes the value, apply the real _() from useTranslation:
const _ = useTranslation();
const label = _(getRevealLabel()); // translates at runtime
pnpm i18n:extract # Scans codebase, adds new keys with __STRING_NOT_TRANSLATED__
public/locales/<locale>/translation.json_('KEY') and _('KEY', options) patterns are recognized by i18next-scannerThe supported language set has a single ground truth: i18n-langs.json. Both the i18next runtime (src/i18n/i18n.ts) and the extractor (i18next-scanner.config.cjs) read from it, so adding a locale is a two-file change plus a translation pass.
Add the locale code to i18n-langs.json. Use the exact code i18next will emit (e.g. hu, zh-CN). Do not add en — it's the source language and lives outside this list.
Add a display label to TRANSLATED_LANGS in src/services/constants.ts. The key is the locale code, the value is the language's native name (e.g. hu: 'Magyar'). This is what users see in the language picker.
Generate the translation file:
pnpm i18n:extract
This creates public/locales/<code>/translation.json with every key set to __STRING_NOT_TRANSLATED__.
Translate every __STRING_NOT_TRANSLATED__ placeholder in the new file. The /i18n skill automates this; the singular en/translation.json only holds plural variants and proper nouns, so use the JSON keys themselves as the English source.
Verify with grep -r "__STRING_NOT_TRANSLATED__" public/locales/<code>/ — the result should be empty.
Translate the KOReader companion plugin (apps/readest.koplugin). It pulls the locale set from the same i18n-langs.json via the scanner config, but the catalog format is gettext .po, not JSON. Steps:
LANG_META entry (label + Plural-Forms) for the new code in apps/readest.koplugin/scripts/extract-i18n.js. Without it the extractor prints <code> skipped (no metadata in extract-i18n.js) and the catalog is never created./i18n-koplugin skill to run extraction and fill every empty msgstr "" in apps/readest.koplugin/locales/<code>/translation.po.stubTranslation is for extraction only — always apply _() from useTranslation in the component for runtime translation.stubTranslation in utility modules (e.g. src/services/errors.ts), return the English key from helpers, wrap with _() in the component.