fix(i18n): add the two keys the client named without en having them

BudgetPanel titled its add button with budget.addCategory (since April)
and the trip form labelled its cover remove button with common.remove
(since August). Neither key was ever in en, and t() returns an unknown
key unchanged, so both rendered as the raw key in every language. Both
are added to all 27 locales.

Locale parity compares the locales with en, never en with the code, so
nothing could catch this. client/scripts/i18n-keys.mjs (npm run
lint:i18n-keys) now resolves every literal key src/ names against en:
the first argument of t(), tHtml() and tr() (both sides of a conditional
included), <TransHtml html>, the fallback of translateApiError and any
*Key property or prop. A template key, a prefix with the rest appended
and the help registry's key builders pass when their fixed parts match
an en key; the one template with no fixed part is on an allow-list that
names the keys it resolves to, and an entry nothing uses fails. With
--unused it lists the en keys nothing reaches, as information only.

en is read with shared/scripts/i18n-catalog.mjs, a reader for the
locale tables as text that the parity tooling will share. It refuses a
value that is not one string literal instead of skipping the key.
This commit is contained in:
Maurice
2026-10-08 11:52:44 +02:00
parent 4062b314cf
commit f00c354d0d
63 changed files with 596 additions and 1 deletions
+1
View File
@@ -11,6 +11,7 @@ const budget: TranslationStrings = {
'budget.createCategory': 'إنشاء فئة',
'budget.category': 'الفئة',
'budget.categoryName': 'اسم الفئة',
'budget.addCategory': 'إضافة فئة',
'budget.table.name': 'الاسم',
'budget.table.total': 'الإجمالي',
'budget.table.persons': 'الأشخاص',
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'إلغاء',
'common.clear': 'مسح',
'common.delete': 'حذف',
'common.remove': 'إزالة',
'common.preview': 'معاينة',
'common.edit': 'تعديل',
'common.add': 'إضافة',
+1
View File
@@ -12,6 +12,7 @@ const budget: TranslationStrings = {
'budget.createCategory': 'Kateqoriya yarat',
'budget.category': 'Kateqoriya',
'budget.categoryName': 'Kateqoriyanın adı',
'budget.addCategory': 'Kateqoriya əlavə et',
'budget.table.name': 'Ad',
'budget.table.total': 'Ümumi',
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Ləğv et',
'common.clear': 'Təmizlə',
'common.delete': 'Sil',
'common.remove': 'Sil',
'common.preview': 'Önizləmə',
'common.edit': 'Redaktə et',
'common.add': 'Əlavə et',
+1
View File
@@ -11,6 +11,7 @@ const budget: TranslationStrings = {
'budget.createCategory': 'Criar categoria',
'budget.category': 'Categoria',
'budget.categoryName': 'Nome da categoria',
'budget.addCategory': 'Adicionar categoria',
'budget.table.name': 'Nome',
'budget.table.total': 'Total',
'budget.table.persons': 'Pessoas',
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Cancelar',
'common.clear': 'Limpar',
'common.delete': 'Excluir',
'common.remove': 'Remover',
'common.preview': 'Prévia',
'common.edit': 'Editar',
'common.add': 'Adicionar',
+1
View File
@@ -9,6 +9,7 @@ const budget: TranslationStrings = {
'budget.createCategory': 'Crea una categoria',
'budget.category': 'Categoria',
'budget.categoryName': 'Nom de la categoria',
'budget.addCategory': 'Afegeix una categoria',
'budget.table.name': 'Nom',
'budget.table.total': 'Total',
'budget.table.persons': 'Persones',
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Cancel·lar',
'common.clear': 'Esborrar',
'common.delete': 'Eliminar',
'common.remove': 'Elimina',
'common.preview': 'Vista prèvia',
'common.edit': 'Editar',
'common.add': 'Afegir',
+1
View File
@@ -11,6 +11,7 @@ const budget: TranslationStrings = {
'budget.createCategory': 'Vytvořit kategorii',
'budget.category': 'Kategorie',
'budget.categoryName': 'Název kategorie',
'budget.addCategory': 'Přidat kategorii',
'budget.table.name': 'Název',
'budget.table.total': 'Celkem',
'budget.table.persons': 'Osoby',
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Zrušit',
'common.clear': 'Vymazat',
'common.delete': 'Smazat',
'common.remove': 'Odstranit',
'common.preview': 'Náhled',
'common.edit': 'Upravit',
'common.add': 'Přidat',
+1
View File
@@ -11,6 +11,7 @@ const budget: TranslationStrings = {
'budget.createCategory': 'Kategorie erstellen',
'budget.category': 'Kategorie',
'budget.categoryName': 'Kategoriename',
'budget.addCategory': 'Kategorie hinzufügen',
'budget.table.name': 'Name',
'budget.table.total': 'Gesamt',
'budget.table.persons': 'Personen',
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Abbrechen',
'common.clear': 'Löschen',
'common.delete': 'Löschen',
'common.remove': 'Entfernen',
'common.preview': 'Vorschau',
'common.edit': 'Bearbeiten',
'common.add': 'Hinzufügen',
+1
View File
@@ -11,6 +11,7 @@ const budget: TranslationStrings = {
'budget.createCategory': 'Create Category',
'budget.category': 'Category',
'budget.categoryName': 'Category Name',
'budget.addCategory': 'Add category',
'budget.table.name': 'Name',
'budget.table.total': 'Total',
'budget.table.persons': 'Persons',
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Cancel',
'common.clear': 'Clear',
'common.delete': 'Delete',
'common.remove': 'Remove',
'common.preview': 'Preview',
'common.edit': 'Edit',
'common.add': 'Add',
+1
View File
@@ -11,6 +11,7 @@ const budget: TranslationStrings = {
'budget.createCategory': 'Crear categoría',
'budget.category': 'Categoría',
'budget.categoryName': 'Nombre de la categoría',
'budget.addCategory': 'Añadir categoría',
'budget.table.name': 'Nombre',
'budget.table.total': 'Total',
'budget.table.persons': 'Personas',
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Cancelar',
'common.clear': 'Borrar',
'common.delete': 'Eliminar',
'common.remove': 'Eliminar',
'common.preview': 'Vista previa',
'common.edit': 'Editar',
'common.add': 'Añadir',
+1
View File
@@ -11,6 +11,7 @@ const budget: TranslationStrings = {
'budget.createCategory': 'Loo kategooria',
'budget.category': 'Kategooria',
'budget.categoryName': 'Kategooria nimi',
'budget.addCategory': 'Lisa kategooria',
'budget.table.name': 'Nimi',
'budget.table.total': 'Kokku',
'budget.table.persons': 'Inimesi',
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Tühista',
'common.clear': 'Tühjenda',
'common.delete': 'Kustuta',
'common.remove': 'Eemalda',
'common.preview': 'Eelvaade',
'common.edit': 'Muuda',
'common.add': 'Lisa',
+1
View File
@@ -11,6 +11,7 @@ const budget: TranslationStrings = {
'budget.createCategory': 'Créer une catégorie',
'budget.category': 'Catégorie',
'budget.categoryName': 'Nom de la catégorie',
'budget.addCategory': 'Ajouter une catégorie',
'budget.table.name': 'Nom',
'budget.table.total': 'Total',
'budget.table.persons': 'Personnes',
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Annuler',
'common.clear': 'Effacer',
'common.delete': 'Supprimer',
'common.remove': 'Supprimer',
'common.preview': 'Aperçu',
'common.edit': 'Modifier',
'common.add': 'Ajouter',
+1
View File
@@ -11,6 +11,7 @@ const budget: TranslationStrings = {
'budget.createCategory': 'Δημιουργία Κατηγορίας',
'budget.category': 'Κατηγορία',
'budget.categoryName': 'Όνομα Κατηγορίας',
'budget.addCategory': 'Προσθήκη κατηγορίας',
'budget.table.name': 'Όνομα',
'budget.table.total': 'Σύνολο',
'budget.table.persons': 'Άτομα',
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Ακύρωση',
'common.clear': 'Εκκαθάριση',
'common.delete': 'Διαγραφή',
'common.remove': 'Αφαίρεση',
'common.preview': 'Προεπισκόπηση',
'common.edit': 'Επεξεργασία',
'common.add': 'Προσθήκη',
+1
View File
@@ -11,6 +11,7 @@ const budget: TranslationStrings = {
'budget.createCategory': 'Kategória létrehozása',
'budget.category': 'Kategória',
'budget.categoryName': 'Kategória neve',
'budget.addCategory': 'Kategória hozzáadása',
'budget.table.name': 'Név',
'budget.table.total': 'Összesen',
'budget.table.persons': 'Személyek',
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Mégse',
'common.clear': 'Törlés',
'common.delete': 'Törlés',
'common.remove': 'Eltávolítás',
'common.preview': 'Előnézet',
'common.edit': 'Szerkesztés',
'common.add': 'Hozzáadás',
+58
View File
@@ -0,0 +1,58 @@
// @ts-expect-error: plain .mjs script with no .d.ts; import as JS module.
import { listDomainFiles, listLocales, parseCatalog, readCatalog } from '../../scripts/i18n-catalog.mjs';
import { describe, it, expect } from 'vitest';
/**
* The reader the i18n scripts (parity, the untranslated ratchet, the client's
* key check) share. It reads the tables as text, so it sees the
* `// en-fallback` markers, and it refuses what it cannot read rather than
* skipping it.
*/
const ROOT = new URL('../../scripts/fixtures/i18n/', import.meta.url);
type Entry = { key: string; value: string; marked: boolean; line: number };
describe('i18n catalogue reader', () => {
it('reads single-line, wrapped, double-quoted and escaped values with their markers', () => {
const entries = parseCatalog(
[
" 'a.one': 'One',",
" 'a.two':",
" 'Two, wrapped', // en-fallback",
` 'a.three': "It's",`,
" 'a.four': 'Don\\'t \\u00e9',",
].join('\r\n'),
);
expect(entries.map((e: Entry) => [e.key, e.value, e.marked])).toEqual([
['a.one', 'One', false],
['a.two', 'Two, wrapped', true],
['a.three', "It's", false],
['a.four', "Don't é", false],
]);
});
it('refuses a value that is not one string literal instead of skipping it', () => {
expect(() => parseCatalog(" 'a.one': 'One' + 'Two',", 'x.ts')).toThrow(
/x\.ts:1: 'a\.one' is not a single string literal/,
);
expect(() => parseCatalog(" 'a.one': `One`,", 'x.ts')).toThrow(/not a single string literal/);
expect(() => parseCatalog(" 'a.one': 'unterminated,", 'x.ts')).toThrow(/not a single string literal/);
});
it('lists locales and domain files and reads a table from disk', () => {
expect(listLocales(ROOT).sort()).toEqual(['de', 'en']);
expect(listDomainFiles('en', ROOT)).toEqual(['a.ts']);
const de: Entry[] = readCatalog('de', 'a.ts', ROOT);
expect(de.find((e) => e.key === 'a.lone')).toMatchObject({ value: 'Remove {count} items', marked: true });
expect(de.find((e) => e.key === 'a.multi')).toMatchObject({
value: 'A long sentence that wraps onto the next line',
});
});
it('reads every real locale table', () => {
for (const locale of listLocales()) {
for (const file of listDomainFiles(locale)) expect(() => readCatalog(locale, file)).not.toThrow();
}
});
});
+1
View File
@@ -11,6 +11,7 @@ const budget: TranslationStrings = {
'budget.createCategory': 'Buat Kategori',
'budget.category': 'Kategori',
'budget.categoryName': 'Nama Kategori',
'budget.addCategory': 'Tambah kategori',
'budget.table.name': 'Nama',
'budget.table.total': 'Total',
'budget.table.persons': 'Orang',
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Batal',
'common.clear': 'Hapus',
'common.delete': 'Hapus',
'common.remove': 'Hapus',
'common.preview': 'Pratinjau',
'common.edit': 'Sunting',
'common.add': 'Tambah',
+1
View File
@@ -11,6 +11,7 @@ const budget: TranslationStrings = {
'budget.createCategory': 'Crea categoria',
'budget.category': 'Categoria',
'budget.categoryName': 'Nome categoria',
'budget.addCategory': 'Aggiungi categoria',
'budget.table.name': 'Nome',
'budget.table.total': 'Totale',
'budget.table.persons': 'Persone',
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Annulla',
'common.clear': 'Cancella',
'common.delete': 'Elimina',
'common.remove': 'Rimuovi',
'common.preview': 'Anteprima',
'common.edit': 'Modifica',
'common.add': 'Aggiungi',
+1
View File
@@ -11,6 +11,7 @@ const budget: TranslationStrings = {
'budget.createCategory': 'カテゴリを作成',
'budget.category': 'カテゴリ',
'budget.categoryName': 'カテゴリ名',
'budget.addCategory': 'カテゴリを追加',
'budget.table.name': '名前',
'budget.table.total': '合計',
'budget.table.persons': '人数',
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'キャンセル',
'common.clear': 'クリア',
'common.delete': '削除',
'common.remove': '削除',
'common.preview': 'プレビュー',
'common.edit': '編集',
'common.add': '追加',
+1
View File
@@ -11,6 +11,7 @@ const budget: TranslationStrings = {
'budget.createCategory': '카테고리 만들기',
'budget.category': '카테고리',
'budget.categoryName': '카테고리 이름',
'budget.addCategory': '카테고리 추가',
'budget.table.name': '이름',
'budget.table.total': '합계',
'budget.table.persons': '인원',
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': '취소',
'common.clear': '지우기',
'common.delete': '삭제',
'common.remove': '제거',
'common.preview': '미리보기',
'common.edit': '편집',
'common.add': '추가',
+1
View File
@@ -11,6 +11,7 @@ const budget: TranslationStrings = {
'budget.createCategory': 'Categorie aanmaken',
'budget.category': 'Categorie',
'budget.categoryName': 'Categorienaam',
'budget.addCategory': 'Categorie toevoegen',
'budget.table.name': 'Naam',
'budget.table.total': 'Totaal',
'budget.table.persons': 'Personen',
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Annuleren',
'common.clear': 'Wissen',
'common.delete': 'Verwijderen',
'common.remove': 'Verwijderen',
'common.preview': 'Voorbeeld',
'common.edit': 'Bewerken',
'common.add': 'Toevoegen',
+1
View File
@@ -10,6 +10,7 @@ const budget: TranslationStrings = {
'budget.createCategory': 'Utwórz kategorię',
'budget.category': 'Kategoria',
'budget.categoryName': 'Nazwa kategorii',
'budget.addCategory': 'Dodaj kategorię',
'budget.table.name': 'Nazwa',
'budget.table.total': 'Łącznie',
'budget.table.persons': 'Osoby',
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Anuluj',
'common.clear': 'Wyczyść',
'common.delete': 'Usuń',
'common.remove': 'Usuń',
'common.preview': 'Podgląd',
'common.edit': 'Edytuj',
'common.add': 'Dodaj',
+1
View File
@@ -11,6 +11,7 @@ const budget: TranslationStrings = {
'budget.createCategory': 'Создать категорию',
'budget.category': 'Категория',
'budget.categoryName': 'Название категории',
'budget.addCategory': 'Добавить категорию',
'budget.table.name': 'Название',
'budget.table.total': 'Итого',
'budget.table.persons': 'Человек',
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Отмена',
'common.clear': 'Очистить',
'common.delete': 'Удалить',
'common.remove': 'Удалить',
'common.preview': 'Предпросмотр',
'common.edit': 'Редактировать',
'common.add': 'Добавить',
+1
View File
@@ -11,6 +11,7 @@ const budget: TranslationStrings = {
'budget.createCategory': 'Vytvoriť kategóriu',
'budget.category': 'Kategória',
'budget.categoryName': 'Názov kategórie',
'budget.addCategory': 'Pridať kategóriu',
'budget.table.name': 'Názov',
'budget.table.total': 'Spolu',
'budget.table.persons': 'Osoby',
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Zrušiť',
'common.clear': 'Vymazať',
'common.delete': 'Odstrániť',
'common.remove': 'Odstrániť',
'common.preview': 'Náhľad',
'common.edit': 'Upraviť',
'common.add': 'Pridať',
+1
View File
@@ -11,6 +11,7 @@ const budget: TranslationStrings = {
'budget.createCategory': 'Skapa kategori',
'budget.category': 'Kategori',
'budget.categoryName': 'Kategorinamn',
'budget.addCategory': 'Lägg till kategori',
'budget.table.name': 'Namn',
'budget.table.total': 'Totalt',
'budget.table.persons': 'Personer',
+1
View File
@@ -19,6 +19,7 @@ const common: TranslationStrings = {
'common.cancel': 'Avbryt',
'common.clear': 'Rensa',
'common.delete': 'Ta bort',
'common.remove': 'Ta bort',
'common.preview': 'Förhandsvisning',
'common.edit': 'Redigera',
'common.add': 'Lägg till',
+1
View File
@@ -11,6 +11,7 @@ const budget: TranslationStrings = {
'budget.createCategory': 'สร้างหมวดหมู่',
'budget.category': 'หมวดหมู่',
'budget.categoryName': 'ชื่อหมวดหมู่',
'budget.addCategory': 'เพิ่มหมวดหมู่',
'budget.table.name': 'ชื่อ',
'budget.table.total': 'รวม',
'budget.table.persons': 'บุคคล',
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'ยกเลิก',
'common.clear': 'ล้าง',
'common.delete': 'ลบ',
'common.remove': 'ลบ',
'common.preview': 'ดูตัวอย่าง',
'common.edit': 'แก้ไข',
'common.add': 'เพิ่ม',
+1
View File
@@ -11,6 +11,7 @@ const budget: TranslationStrings = {
'budget.createCategory': 'Kategori Oluştur',
'budget.category': 'Kategori',
'budget.categoryName': 'Kategori Adı',
'budget.addCategory': 'Kategori ekle',
'budget.table.name': 'İsim',
'budget.table.total': 'Toplam',
'budget.table.persons': 'Kişiler',
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'İptal',
'common.clear': 'Temizle',
'common.delete': 'Sil',
'common.remove': 'Kaldır',
'common.preview': 'Önizleme',
'common.edit': 'Düzenle',
'common.add': 'Ekle',
+1
View File
@@ -11,6 +11,7 @@ const budget: TranslationStrings = {
'budget.createCategory': 'Створити категорію',
'budget.category': 'Категорія',
'budget.categoryName': 'Назва категорії',
'budget.addCategory': 'Додати категорію',
'budget.table.name': 'Назва',
'budget.table.total': 'Всього',
'budget.table.persons': 'Осіб',
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Скасувати',
'common.clear': 'Очистити',
'common.delete': 'Видалити',
'common.remove': 'Видалити',
'common.preview': 'Перегляд',
'common.edit': 'Редагувати',
'common.add': 'Додати',
+1
View File
@@ -11,6 +11,7 @@ const budget: TranslationStrings = {
'budget.createCategory': 'Tạo danh mục',
'budget.category': 'Loại',
'budget.categoryName': 'Tên danh mục',
'budget.addCategory': 'Thêm danh mục',
'budget.table.name': 'Tên',
'budget.table.total': 'Tổng cộng',
'budget.table.persons': 'Người',
+1
View File
@@ -19,6 +19,7 @@ const common: TranslationStrings = {
'common.cancel': 'Hủy',
'common.clear': 'Xóa',
'common.delete': 'Xóa bỏ',
'common.remove': 'Gỡ bỏ',
'common.preview': 'Xem trước',
'common.edit': 'Chỉnh sửa',
'common.add': 'Thêm',
+1
View File
@@ -11,6 +11,7 @@ const budget: TranslationStrings = {
'budget.createCategory': '建立分類',
'budget.category': '分類',
'budget.categoryName': '分類名稱',
'budget.addCategory': '新增分類',
'budget.table.name': '名稱',
'budget.table.total': '合計',
'budget.table.persons': '人數',
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': '取消',
'common.clear': '清除',
'common.delete': '刪除',
'common.remove': '刪除',
'common.preview': '預覽',
'common.edit': '編輯',
'common.add': '新增',
+1
View File
@@ -11,6 +11,7 @@ const budget: TranslationStrings = {
'budget.createCategory': '创建分类',
'budget.category': '分类',
'budget.categoryName': '分类名称',
'budget.addCategory': '添加分类',
'budget.table.name': '名称',
'budget.table.total': '合计',
'budget.table.persons': '人数',
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': '取消',
'common.clear': '清除',
'common.delete': '删除',
'common.remove': '删除',
'common.preview': '预览',
'common.edit': '编辑',
'common.add': '添加',