Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -420,17 +420,22 @@ and `specs/roadmap.md` tracks milestone progress.

## Translating

The interface text lives in `src/frontend/src/locales/`, one flat JSON file per
The interface text lives in `src/frontend/src/locales/`, one JSON file per
language. `en.json` is the source: every other language is translated from it,
and any string a language has not translated yet shows in English.

Keys are nested objects grouped by component (`settings` → `button` → `label`)
and looked up by their dotted path, `t('settings.button.label')`. Don't write a
flat `"settings.button.label"` key: Weblate writes the keys it adds nested, so a
file would end up mixing both shapes. `keys.test.js` fails on dotted keys.

Translations are done on [Hosted Weblate](https://hosted.weblate.org/). You don't
need to open a pull request to translate. Weblate commits the changes and opens
the pull request for you. To change the English wording or add a string, edit
`en.json` in a normal pull request. Weblate picks the change up once it merges.

A string that depends on a number gets one key per plural form, named the i18next
v4 way: `"sentence.symbolCount_one"` and `"sentence.symbolCount_other"` in
v4 way: `symbolCount_one` and `symbolCount_other` inside `sentence` in
`en.json`. Render it with `tPlural(key, count)` from `src/frontend/src/i18n.js`,
not `t`. The helper picks the form the active language needs, so on Weblate each
language gets its own plural forms, such as `_few` and `_many` for Russian.
Expand All @@ -443,16 +448,24 @@ Only maintainers need these, when creating or repairing the component:
|---|---|
| Version control system | GitHub pull request |
| Source code repository | `https://github.com/Akay7/TypeLearn.git` |
| Repository push URL | `git@github.com:Akay7/TypeLearn.git` |
| Repository push URL | `git@github.com:Akay7/TypeLearn.git` (not empty, see below) |
| Repository branch | `main` |
| Push branch | `weblate` |
| File mask | `src/frontend/src/locales/*.json` |
| Monolingual base language file | `src/frontend/src/locales/en.json` |
| Edit base file | off (English changes go through code review) |
| File format | i18next JSON file v4 (keeps the flat keys; shows `key_one`, `key_few`, `key_other`… as one plural string) |
| File format | i18next JSON file v4 (nested keys; shows `key_one`, `key_few`, `key_other`… as one plural string) |
| File format parameters | `json_indent: 2`, `json_sort_keys` left unset (keeps `en.json`'s order) |
| Translation flags | `vue-format` (checks `{placeholder}` names against the source) |
| Adding new translation | Contact maintainers (see below) |

Weblate pushes its commits to a `weblate` branch in this repository, not to a
fork of it. CI pushes its images to ghcr.io before testing them, and a pull
request opened from a fork gets a read-only token that cannot push, so CI would
never test a translation. For the push to work, add Hosted Weblate's public SSH
key (shown on its "SSH keys" page) to the repository as a deploy key with write
access.

### Adding a language

A new JSON file alone does nothing. The app only loads the languages it lists. To
Expand Down
5 changes: 2 additions & 3 deletions src/frontend/src/__tests__/i18n.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
// the '_one' form of a plural whose '_other' form is translated.
vi.mock('../locales/fr.json', () => ({
default: {
'answer.check': 'Vérifier',
'sentence.loading': '',
'sentence.symbolCount_other': '{count} caractères',
answer: { check: 'Vérifier' },
sentence: { loading: '', symbolCount_other: '{count} caractères' },
},
}))

Expand Down
22 changes: 15 additions & 7 deletions src/frontend/src/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ import th from './locales/th.json'

// The catalogs are plain JSON so Weblate (see "Translating" in the README) can
// read and write them. en.json is the reference: it holds every key, and every
// other locale is translated from it and falls back to it. Keys are flat,
// dot-prefixed strings grouped by the component that owns the text, rather than
// nested objects: six files are easier to diff side by side this way, and
// vue-i18n resolves a flat 'a.b' key exactly like a nested one.
// other locale is translated from it and falls back to it. Keys are nested
// objects grouped by the component that owns the text, and components look
// them up by dotted path: t('settings.button.label'). Never a flat
// 'settings.button.label' key: Weblate's i18next v4 format reads a dot as
// nesting, so while it re-saves an existing flat key as it found it, any key
// it adds to a translation is written nested — leaving a partly translated
// file half one shape and half the other.
//
// A translation may be incomplete — a volunteer translates some strings of a
// language and not the rest — and src/locales/__tests__/keys.test.js allows
Expand All @@ -24,10 +27,15 @@ import th from './locales/th.json'
export const SUPPORTED_LANGUAGES = ['en', 'fr', 'de', 'th', 'ru', 'hu']

// Weblate can write a string nobody has translated yet as "" instead of leaving
// the key out, and vue-i18n renders "" as it is: a blank button. Dropping blanks
// makes those keys fall back to English, the same as keys that are missing.
// the key out, and vue-i18n renders "" as it is: a blank button. Dropping blanks,
// at every level of nesting, makes those keys fall back to English, the same as
// keys that are missing.
function withoutBlanks(catalog) {
return Object.fromEntries(Object.entries(catalog).filter(([, text]) => text !== ''))
return Object.fromEntries(
Object.entries(catalog)
.filter(([, value]) => value !== '')
.map(([key, value]) => [key, typeof value === 'object' ? withoutBlanks(value) : value]),
)
}

export const i18n = createI18n({
Expand Down
50 changes: 43 additions & 7 deletions src/frontend/src/locales/__tests__/keys.test.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,38 @@
import { describe, expect, it } from 'vitest'

import de from '../de.json'
import en from '../en.json'
import fr from '../fr.json'
import hu from '../hu.json'
import ru from '../ru.json'
import th from '../th.json'
import deCatalog from '../de.json'
import enCatalog from '../en.json'
import frCatalog from '../fr.json'
import huCatalog from '../hu.json'
import ruCatalog from '../ru.json'
import thCatalog from '../th.json'

// The catalogs are nested objects (see the comment in i18n.js); every check
// below is about the dotted paths components look strings up by, so each
// catalog is flattened to those paths first.
function flatten(catalog, prefix = '') {
return Object.fromEntries(
Object.entries(catalog).flatMap(([key, value]) =>
value !== null && typeof value === 'object' && !Array.isArray(value)
? Object.entries(flatten(value, `${prefix}${key}.`))
: [[`${prefix}${key}`, value]],
),
)
}

const CATALOGS = { en: enCatalog, fr: frCatalog, de: deCatalog, th: thCatalog, ru: ruCatalog, hu: huCatalog }

// en.json is the reference catalog (see the comment in i18n.js). The other
// locales are edited on Weblate, and a language is often only partly translated
// there, so a key that is missing or blank passes: it falls back to English.
// What does not pass is a translation that would break at runtime — a key en.json
// no longer has, or a placeholder that does not match the English one.
const OTHERS = { fr, de, th, ru, hu }
const en = flatten(enCatalog)
const OTHERS = Object.fromEntries(
Object.entries(CATALOGS)
.filter(([locale]) => locale !== 'en')
.map(([locale, catalog]) => [locale, flatten(catalog)]),
)

// Plural forms (see tPlural in i18n.js) vary by language: English has '_one'
// and '_other', Russian adds '_few' and '_many'. Any CLDR form counts as
Expand All @@ -30,6 +50,14 @@ function placeholders(text) {
return [...text.matchAll(/\{(\w+)\}/g)].map(([, name]) => name).sort()
}

/** Every key, at every level, that has a dot in it. */
function dottedKeys(catalog, path = '') {
return Object.entries(catalog).flatMap(([key, value]) => [
...(key.includes('.') ? [`${path}${key}`] : []),
...(value !== null && typeof value === 'object' ? dottedKeys(value, `${path}${key} → `) : []),
])
}

describe('locale catalogs', () => {
it('en.json is a complete catalog of non-empty strings', () => {
expect(Object.keys(en).length).toBeGreaterThan(0)
Expand All @@ -39,6 +67,14 @@ describe('locale catalogs', () => {
}
})

// A flat 'a.b' key would still resolve, but Weblate writes the keys it adds
// nested — so a catalog that mixed both shapes would not stay consistent.
for (const [locale, catalog] of Object.entries(CATALOGS)) {
it(`${locale}.json nests its keys instead of joining them with dots`, () => {
expect(dottedKeys(catalog)).toEqual([])
})
}

for (const [locale, catalog] of Object.entries(OTHERS)) {
it(`${locale}.json has no key that en.json lacks`, () => {
expect(Object.keys(catalog).filter((key) => sourceOf(key) === undefined)).toEqual([])
Expand Down
122 changes: 78 additions & 44 deletions src/frontend/src/locales/de.json
Original file line number Diff line number Diff line change
@@ -1,46 +1,80 @@
{
"settings.button.label": "Einstellungen",
"settings.keyboardOverride.title": "Virtuelle Tastatur",
"settings.keyboardOverride.auto.label": "Automatisch",
"settings.keyboardOverride.auto.description": "An dieses Gerät anpassen",
"settings.keyboardOverride.on.label": "Ein",
"settings.keyboardOverride.on.description": "Immer anzeigen",
"settings.keyboardOverride.off.label": "Aus",
"settings.keyboardOverride.off.description": "Nie anzeigen",
"settings.onScreenKeyboard.title": "Bildschirmtastatur",
"settings.onScreenKeyboard.show.label": "Anzeigen",
"settings.onScreenKeyboard.show.description": "Das Kedmanee-Layout, mit Fingerfarben",
"settings.onScreenKeyboard.hide.label": "Ausblenden",
"settings.onScreenKeyboard.hide.description": "Nur mit der eigenen Tastatur tippen",
"settings.completionStats.label": "Zusammenfassung am Ende",
"settings.completionStats.description": "Statistik für heute und die letzten 7 Tage",
"settings.language.title": "Sprache",
"sentence.loading": "Übung wird geladen…",
"sentence.error": "Übung konnte nicht geladen werden. Läuft das Backend?",
"sentence.empty": "Es sind noch keine Übungen verfügbar.",
"sentence.symbolCount_one": "{count} Zeichen",
"sentence.symbolCount_other": "{count} Zeichen",
"answer.placeholder": "Tippe, was du hörst",
"answer.check": "Prüfen",
"answer.correct": "✓ Richtig",
"answer.incorrect": "✗ Falsch — versuch es erneut",
"audio.play": "▶ Abspielen",
"audio.pause": "⏸ Pause",
"audio.autoplayBlocked": "Drücke Abspielen, um es zu hören",
"keyboard.finger.little": "kleiner Finger",
"keyboard.finger.ring": "Ringfinger",
"keyboard.finger.middle": "Mittelfinger",
"keyboard.finger.leftIndex": "linker Zeigefinger",
"keyboard.finger.rightIndex": "rechter Zeigefinger",
"keyboard.finger.thumb": "Daumen",
"keyboard.restPosition": "Ruheposition",
"keyboard.restPositionSuffix": ", Ruheposition",
"keyboard.layoutSwitch": "Tastaturlayout: {name} — wechseln",
"stats.caption": "Übungssummen, heute und in den letzten 7 Tagen",
"stats.symbolsCorrect": "Richtige Zeichen",
"stats.keysPressed": "Tastendrücke",
"stats.exercisesCompleted": "Erledigte Übungen",
"stats.today": "Heute",
"stats.last7Days": "Letzte 7 Tage",
"stats.next": "Nächste Übung →"
"settings": {
"button": {
"label": "Einstellungen"
},
"keyboardOverride": {
"title": "Virtuelle Tastatur",
"auto": {
"label": "Automatisch",
"description": "An dieses Gerät anpassen"
},
"on": {
"label": "Ein",
"description": "Immer anzeigen"
},
"off": {
"label": "Aus",
"description": "Nie anzeigen"
}
},
"onScreenKeyboard": {
"title": "Bildschirmtastatur",
"show": {
"label": "Anzeigen",
"description": "Das Kedmanee-Layout, mit Fingerfarben"
},
"hide": {
"label": "Ausblenden",
"description": "Nur mit der eigenen Tastatur tippen"
}
},
"completionStats": {
"label": "Zusammenfassung am Ende",
"description": "Statistik für heute und die letzten 7 Tage"
},
"language": {
"title": "Sprache"
}
},
"sentence": {
"loading": "Übung wird geladen…",
"error": "Übung konnte nicht geladen werden. Läuft das Backend?",
"empty": "Es sind noch keine Übungen verfügbar.",
"symbolCount_one": "{count} Zeichen",
"symbolCount_other": "{count} Zeichen"
},
"answer": {
"placeholder": "Tippe, was du hörst",
"check": "Prüfen",
"correct": "✓ Richtig",
"incorrect": "✗ Falsch — versuch es erneut"
},
"audio": {
"play": "▶ Abspielen",
"pause": "⏸ Pause",
"autoplayBlocked": "Drücke Abspielen, um es zu hören"
},
"keyboard": {
"finger": {
"little": "kleiner Finger",
"ring": "Ringfinger",
"middle": "Mittelfinger",
"leftIndex": "linker Zeigefinger",
"rightIndex": "rechter Zeigefinger",
"thumb": "Daumen"
},
"restPosition": "Ruheposition",
"restPositionSuffix": ", Ruheposition",
"layoutSwitch": "Tastaturlayout: {name} — wechseln"
},
"stats": {
"caption": "Übungssummen, heute und in den letzten 7 Tagen",
"symbolsCorrect": "Richtige Zeichen",
"keysPressed": "Tastendrücke",
"exercisesCompleted": "Erledigte Übungen",
"today": "Heute",
"last7Days": "Letzte 7 Tage",
"next": "Nächste Übung →"
}
}
Loading