fix(i18n): require each locale's plural forms in i18n:check

Lithuanian lacked _few forms, so counts like 2 fell back to English.
Add them, fix _other wording for 10+, and update stale docs and comments.
This commit is contained in:
Tautvydas Derzinskas committed 2026-10-20 07:57:22 +02:00
1 parent 10451a9f11
commit 96300510b4
5 files changed
+45 -15

No files matched your search

+6 -4
View File
@@ -31,16 +31,18 @@ To improve an existing language, edit its file the same way; the commit type is
## Plurals
Text that depends on a number has one key per plural form, ending in `_one`, `_few`, `_many` or `_other`:
Text that depends on a number has one key per plural form, ending in `_zero`, `_one`, `_two`, `_few`, `_many`
or `_other`:
```json
"minutesAgo_one": "{{count}} minute ago",
"minutesAgo_other": "{{count}} minutes ago"
```
Use the forms your language needs, which aren't always English's: Lithuanian, for example, adds `_few`. Check your
language in the [Unicode plural rules](https://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html)
and add or drop forms to match.
Use the forms your language needs, which aren't always English's: Lithuanian, for example, adds `_few`, and Arabic
and Welsh also use `_zero` and `_two`. Check your language in the
[Unicode plural rules](https://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html) and add or
drop forms to match; `i18n:check` below lists any form your language is missing.
## Check your translation
+21 -5
View File
@@ -1,5 +1,5 @@
// Lists each locale's keys missing from, or no longer in, en.json: `npm run i18n:check [code]`.
// Plural forms (key_one, key_few, ...) count as one key, since languages need different ones.
// A plural key (key_one, key_other, ...) needs every form the locale uses for whole-number counts.
import { readdirSync, readFileSync } from "node:fs";
const dir = new URL("../src/i18n/locales/", import.meta.url);
@@ -7,12 +7,25 @@ const PLURAL = /_(zero|one|two|few|many|other)$/;
function keys(node, prefix = "") {
return Object.entries(node).flatMap(([key, value]) =>
value && typeof value === "object" ? keys(value, `${prefix}${key}.`) : [`${prefix}${key}`.replace(PLURAL, "")],
value && typeof value === "object" ? keys(value, `${prefix}${key}.`) : [`${prefix}${key}`],
);
}
const read = (code) => new Set(keys(JSON.parse(readFileSync(new URL(`${code}.json`, dir), "utf8"))));
const en = read("en");
const read = (code) => keys(JSON.parse(readFileSync(new URL(`${code}.json`, dir), "utf8")));
// Fractional-only forms (Lithuanian `many`) are left out: counts here are whole numbers.
function pluralForms(code) {
const rules = new Intl.PluralRules(code);
return new Set(Array.from({ length: 1000 }, (_, n) => rules.select(n)));
}
const plurals = new Set();
const plain = [];
for (const key of read("en")) {
if (PLURAL.test(key)) plurals.add(key.replace(PLURAL, ""));
else plain.push(key);
}
const only = process.argv[2];
const codes = readdirSync(dir)
.filter((file) => file.endsWith(".json") && file !== "en.json")
@@ -25,7 +38,10 @@ if (only && codes.length === 0) {
}
for (const code of codes) {
const locale = read(code);
const forms = [...pluralForms(code)];
const en = new Set([...plain, ...[...plurals].flatMap((base) => forms.map((form) => `${base}_${form}`))]);
// Extra plural forms a translator added (such as `_many`) aren't stale, just unused for whole numbers.
const locale = new Set(read(code).filter((key) => en.has(key) || !plurals.has(key.replace(PLURAL, ""))));
const missing = [...en].filter((key) => !locale.has(key));
const stale = [...locale].filter((key) => !en.has(key));
const done = Math.floor(((en.size - missing.length) / en.size) * 100);
+16 -4
View File
@@ -287,10 +287,13 @@
"viewSource": "Žiūrėti šaltinį",
"justNow": "Ką tik",
"minutesAgo_one": "prieš {{count}} minutę",
"minutesAgo_few": "prieš {{count}} minutes",
"minutesAgo_other": "prieš {{count}} minučių",
"hoursAgo_one": "prieš {{count}} valandą",
"hoursAgo_few": "prieš {{count}} valandas",
"hoursAgo_other": "prieš {{count}} valandų",
"daysAgo_one": "prieš {{count}} dieną",
"daysAgo_few": "prieš {{count}} dienas",
"daysAgo_other": "prieš {{count}} dienų",
"clearAll": "Išvalyti viską",
"clearAllConfirm": "Ištrinti visus pranešimus? To nebus galima atšaukti.",
@@ -320,7 +323,8 @@
"sectionCollections": "Kolekcijos",
"sectionTags": "Žymos",
"modelCount_one": "{{count}} modelis",
"modelCount_other": "{{count}} modeliai",
"modelCount_few": "{{count}} modeliai",
"modelCount_other": "{{count}} modelių",
"sectionRecent": "Neseniai",
"sectionCommands": "Komandos",
"open": "Atidaryti",
@@ -1085,14 +1089,19 @@
"approx": "≈ {{value}}",
"units": {
"minutes_one": "{{count}} minutė",
"minutes_few": "{{count}} minutės",
"minutes_other": "{{count}} minučių",
"hours_one": "{{count}} valanda",
"hours_few": "{{count}} valandos",
"hours_other": "{{count}} valandų",
"days_one": "{{count}} diena",
"days_few": "{{count}} dienos",
"days_other": "{{count}} dienų",
"weeks_one": "{{count}} savaitė",
"weeks_few": "{{count}} savaitės",
"weeks_other": "{{count}} savaičių",
"months_one": "{{count}} mėnuo",
"months_few": "{{count}} mėnesiai",
"months_other": "{{count}} mėnesių"
},
"save": "Išsaugoti",
@@ -2822,7 +2831,8 @@
"card": {
"empty": "Kol kas nėra modelių",
"itemCount_one": "{{count}} modelis",
"itemCount_other": "{{count}} modeliai"
"itemCount_few": "{{count}} modeliai",
"itemCount_other": "{{count}} modelių"
},
"form": {
"createTitle": "Nauja kolekcija",
@@ -2998,9 +3008,11 @@
"downloadZip": {
"loading": "Skaičiuojama…",
"summary_one": "{{count}} modelis (~{{size}})",
"summary_other": "{{count}} modeliai (~{{size}})",
"summary_few": "{{count}} modeliai (~{{size}})",
"summary_other": "{{count}} modelių (~{{size}})",
"summaryNoSize_one": "{{count}} modelis",
"summaryNoSize_other": "{{count}} modeliai",
"summaryNoSize_few": "{{count}} modeliai",
"summaryNoSize_other": "{{count}} modelių",
"summaryFailed": "Nepavyko apskaičiuoti atsisiuntimo dydžio. Bandykite dar kartą.",
"empty": "Nerasta modelių failų atsisiuntimui.",
"downloading": "Atsisiunčiama…",
@@ -32,7 +32,7 @@ type Props = {
/** GitHub's contribution graph: a column per week, a square per day, darker the busier the day. */
export default function Heatmap({ range, days, selectedDay, today, onSelectDay }: Props) {
const { t, i18n } = useTranslation("app");
// Monday-first in Lithuanian, as its calendars are; Sunday-first otherwise, as GitHub's is.
// Follows the locale's calendar: Sunday-first where it starts the week then (as GitHub does), Monday otherwise.
const weekStart = weekStartFor(i18n.language);
const weeks = React.useMemo(() => buildWeeks(range, weekStart), [range, weekStart]);
const months = React.useMemo(() => monthStarts(weeks), [weeks]);
+1 -1
View File
@@ -291,7 +291,7 @@ export const DOCS = [
group: "Features",
title: "Themes and languages",
nav: "Themes and languages",
description: "Light, dark or system theme, in English or Lithuanian.",
description: "Light, dark or system theme, in any language Thingport is translated into.",
},
{
slug: "guides/system-health",