From 3e79175d0e5113275948b5ec35627daae785636f Mon Sep 17 00:00:00 2001
From: SandObserver <260779319+SandObserver@users.noreply.github.com>
Date: Sat, 29 Aug 2026 13:17:18 -0400
Subject: [PATCH] Fix: settle one term per concept in the remaining locales
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Persian pass found two failures a machine translation makes: it renders one
concept as a different word in each string, and it leaves a term it does not
recognise in English. The other four locales were audited the same way, against
the German, Spanish, French and Chinese locales of Immich, Jellyfin and Uptime
Kuma.
German named a badge both Badge and Abzeichen; the reference locales use Badge
26 times and Abzeichen never. It also named a label both Bezeichnung and
Beschriftung. Chinese named a badge 标记, a widget 挂件 and the dashboard 仪表盘
in a minority of strings, against 徽章, 小组件 and 仪表板 elsewhere. Spanish and
French were already consistent and are unchanged.
Logging, API key and Collection ID were left in English in four locales, and
Import, Export and Authentication Proxy in Persian, in every case beside a
label that did translate the same term.
The glossary test now covers German and Chinese, and a second test reads the
Persian and Chinese catalogues for English left in the translation, which is
detectable there because the script differs.
---
CHANGELOG.md | 4 ++
docs/i18n.md | 36 ++++++++--
ui/i18n/de.json | 22 +++---
ui/i18n/es.json | 6 +-
ui/i18n/fa.json | 14 ++--
ui/i18n/fr.json | 6 +-
ui/i18n/zh-Hans.json | 32 ++++-----
ui/test/i18n-coverage.test.mjs | 118 +++++++++++++++++++++++++++++++++
8 files changed, 193 insertions(+), 45 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bad519e1..8760ca19 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -61,6 +61,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Settings now translates its accessible names, its remaining field labels and
its page title, which were English in every language.
+- German, Spanish, French, Chinese and Persian now use one agreed word per
+ concept, and no longer leave "Logging", "API key", "Collection ID" or
+ "Authentication Proxy" in English.
+
- Opening and saving an app no longer rewrites a colour that was not changed.
- Admin form toggles now carry accessible names.
diff --git a/docs/i18n.md b/docs/i18n.md
index 00c1e590..a3ee9b3c 100644
--- a/docs/i18n.md
+++ b/docs/i18n.md
@@ -131,12 +131,14 @@ Terms with a fixed meaning here:
| stale | Last reading is old; the service was not reached this time. |
| unavailable | The service answered, and it is not working. |
-### Persian
+### Per-language terms
-One word per concept, taken from Persian that shipped software actually uses
-rather than from the dictionary. Immich, Jellyfin and Uptime Kuma were read for
-these; Immich is the closest to this project and ships
-`وارد کردن پیکربندی` for "Import configuration" and `خروجی` for "Export".
+One word per concept, taken from what shipped software uses rather than from a
+dictionary. The Persian, German, Spanish and French locales of Immich, Jellyfin
+and Uptime Kuma were counted for these. Immich is the closest project to this
+one.
+
+Persian:
| English | Use | Not |
| --- | --- | --- |
@@ -145,10 +147,34 @@ these; Immich is the closest to this project and ships
| Widget | ویجت | ابزارک |
| File | فایل | پرونده |
+German:
+
+| English | Use | Not |
+| --- | --- | --- |
+| Badge | Badge | Abzeichen |
+| Label | Bezeichnung | Beschriftung |
+
+Chinese (Simplified):
+
+| English | Use | Not |
+| --- | --- | --- |
+| Badge | 徽章 | 标记 |
+| Widget | 小组件 | 挂件 |
+| Dashboard | 仪表板 | 仪表盘 |
+
+Spanish and French were checked the same way and were already consistent:
+`insignia` and `badge` for a badge, `etiqueta` and `libellé` for a label.
+
`ui/test/i18n-coverage.test.mjs` fails on any term in the "Not" column, in the
dashboard catalogue and in every widget catalogue. Add a row there and here
together when a term is decided.
+The same file checks the Persian and Chinese catalogues for English words left
+untranslated, which is how a machine translation usually fails: it skips a term
+it does not recognise and leaves it in Latin script. German, Spanish and French
+cannot be checked this way, because an English word there is not distinguishable
+from the language around it.
+
## Development locales
Two locales exist for testing and are never offered in the selector. Reach them
diff --git a/ui/i18n/de.json b/ui/i18n/de.json
index 3195bf2b..9826a884 100644
--- a/ui/i18n/de.json
+++ b/ui/i18n/de.json
@@ -41,9 +41,9 @@
"title": "Titel",
"description": "Beschreibung",
"hostIp": "Host-IP",
- "hostIpTip": "Erlaubt Abzeichen, Daten von der eigenen Adresse Ihres Servers zu holen. Ohne dies werden diese Anfragen blockiert.",
+ "hostIpTip": "Erlaubt Badges, Daten von der eigenen Adresse Ihres Servers zu holen. Ohne dies werden diese Anfragen blockiert.",
"monitoring": "Überwachung",
- "loggingLevel": "Logging-Stufe",
+ "loggingLevel": "Protokollstufe",
"dockerHealth": "Docker-Container-Zustandsprüfungen",
"hideHealthy": "Badge für gesunden Status ausblenden",
"socketUrl": "Socket-URL",
@@ -84,12 +84,12 @@
"displayDark": "Dunkel",
"wallpaper": "Hintergrundbild",
"source": "Quelle",
- "unsplashKey": "API key",
+ "unsplashKey": "API-Schlüssel",
"collectionId": "Sammlungs-ID",
"imageUrl": "Bild-URL",
"color": "Farbe",
"brightness": "Helligkeit",
- "collectionTip": "Collection ID leer lassen für ein zufälliges Bild.",
+ "collectionTip": "Sammlungs-ID leer lassen für ein zufälliges Bild.",
"sourceUnsplash": "Unsplash",
"sourceUrl": "Bild",
"sourceColor": "Volltonfarbe",
@@ -167,8 +167,8 @@
"containerPh": "container-name",
"pingUrlPh": "http://your-server-ip:port",
"test": "Testen",
- "fixedLabel": "Feste Beschriftung",
- "labelText": "Beschriftungstext",
+ "fixedLabel": "Feste Bezeichnung",
+ "labelText": "Bezeichnungstext",
"labelPh": "z. B. Backup",
"liveActivity": "Live-Aktivität",
"apiUrl": "API-URL",
@@ -181,7 +181,7 @@
"unitPh": "z. B. GB",
"badgeMin": "Anzeigen ab",
"badgeMinPh": "z. B. 5",
- "combineValues": "Als einzelnes Abzeichen anzeigen",
+ "combineValues": "Als einzelnes Badge anzeigen",
"combineTip": "Die Werte aller Bezeichnungen werden addiert und als eine Zahl angezeigt. Deaktivieren Sie dies, um jeweils eine Bezeichnung anzuzeigen, beginnend mit der obersten in der Liste, die etwas zu melden hat.",
"labelN": "Bezeichnung {n}",
"moveUp": "Bezeichnung nach oben",
@@ -191,8 +191,8 @@
"removeLabel": "Bezeichnung entfernen",
"labelValueTaken": "Dieser Wert wird bereits von einer anderen Bezeichnung verwendet.",
"noValuesLeft": "Jeder abgerufene Wert hat bereits eine Bezeichnung.",
- "labelMax": "Ein Abzeichen zeigt höchstens {n} Bezeichnungen.",
- "labelOrderTip": "Das Abzeichen zeigt die erste Bezeichnung in dieser Liste, die ihren Schwellenwert erreicht. Zum Umsortieren ziehen.",
+ "labelMax": "Ein Badge zeigt höchstens {n} Bezeichnungen.",
+ "labelOrderTip": "Das Badge zeigt die erste Bezeichnung in dieser Liste, die ihren Schwellenwert erreicht. Zum Umsortieren ziehen.",
"labelsSaved_one": "{count} Bezeichnung gespeichert",
"labelsSaved_other": "{count} Bezeichnungen gespeichert",
"poll": "Abfrage",
@@ -279,8 +279,8 @@
"healthy": "Status: in Ordnung",
"pending": "{count} ausstehend",
"stale": "(möglicherweise veraltet)",
- "moreValues_one": "{count} weiteres Abzeichen",
- "moreValues_other": "{count} weitere Abzeichen",
+ "moreValues_one": "{count} weiteres Badge",
+ "moreValues_other": "{count} weitere Badges",
"containerNotFound": "Container nicht gefunden",
"containerState": "Container {state}",
"pingFailed": "Ping fehlgeschlagen: {error}",
diff --git a/ui/i18n/es.json b/ui/i18n/es.json
index 11abc771..00aabb4b 100644
--- a/ui/i18n/es.json
+++ b/ui/i18n/es.json
@@ -43,7 +43,7 @@
"hostIp": "IP del host",
"hostIpTip": "Permite que las insignias obtengan datos de la propia dirección de tu servidor. Sin esto, esas peticiones se bloquean.",
"monitoring": "Monitorización",
- "loggingLevel": "Nivel de Logging",
+ "loggingLevel": "Nivel de registro",
"dockerHealth": "Comprobaciones de estado de contenedores Docker",
"hideHealthy": "Ocultar insignia de estado saludable",
"socketUrl": "URL del socket",
@@ -84,12 +84,12 @@
"displayDark": "Oscuro",
"wallpaper": "Fondo de pantalla",
"source": "Origen",
- "unsplashKey": "API key",
+ "unsplashKey": "Clave de API",
"collectionId": "ID de colección",
"imageUrl": "URL de la imagen",
"color": "Color",
"brightness": "Brillo",
- "collectionTip": "Deja el Collection ID en blanco para elegir una imagen aleatoria.",
+ "collectionTip": "Deja el ID de colección en blanco para elegir una imagen aleatoria.",
"sourceUnsplash": "Unsplash",
"sourceUrl": "Imagen",
"sourceColor": "Color sólido",
diff --git a/ui/i18n/fa.json b/ui/i18n/fa.json
index 6ef15967..a0aad9ac 100644
--- a/ui/i18n/fa.json
+++ b/ui/i18n/fa.json
@@ -43,7 +43,7 @@
"hostIp": "IP میزبان",
"hostIpTip": "به نشانها اجازه میدهد داده را از نشانی خود سرور بگیرند. بدون آن، این درخواستها مسدود میشوند.",
"monitoring": "نظارت",
- "loggingLevel": "سطح Logging",
+ "loggingLevel": "سطح گزارش",
"dockerHealth": "بررسی سلامت کانتینرهای داکر",
"hideHealthy": "پنهان کردن وضعیت سلامت",
"socketUrl": "آدرس سوکت",
@@ -55,7 +55,7 @@
"signOutEverywhere": "خروج از همه دستگاهها",
"passwordProtection": "محافظت با رمز عبور",
"password": "رمز عبور",
- "passwordTip": "برای حفظ رمز عبور فعلی، این قسمت را خالی بگذارید. حداقل ۸ کاراکتر. برای محافظت قویتر، قرار دادن داشبورد پشت یک Authentication Proxy را در نظر بگیرید. این رمز عبور داخلی یک محافظ سبک است، نه یک لایه کامل احراز هویت.",
+ "passwordTip": "برای حفظ رمز عبور فعلی، این قسمت را خالی بگذارید. حداقل ۸ کاراکتر. برای محافظت قویتر، قرار دادن داشبورد پشت یک پراکسی احراز هویت اختصاصی را در نظر بگیرید. این رمز عبور داخلی یک محافظ سبک است، نه یک لایه کامل احراز هویت.",
"backup": "پشتیبانگیری",
"importExport": "وارد کردن / خروجی گرفتن",
"signOut": "خروج از حساب",
@@ -84,12 +84,12 @@
"displayDark": "تیره",
"wallpaper": "تصویر زمینه",
"source": "منبع",
- "unsplashKey": "API key",
+ "unsplashKey": "کلید API",
"collectionId": "شناسهٔ مجموعه",
"imageUrl": "آدرس تصویر",
"color": "رنگ",
"brightness": "روشنایی",
- "collectionTip": "برای تصاویر تصادفی، Collection ID را خالی بگذارید.",
+ "collectionTip": "برای تصاویر تصادفی، شناسهٔ مجموعه را خالی بگذارید.",
"sourceUnsplash": "Unsplash",
"sourceUrl": "تصویر",
"sourceColor": "رنگ یکدست",
@@ -196,7 +196,7 @@
"labelsSaved_one": "{count} برچسب ذخیره شد",
"labelsSaved_other": "{count} برچسب ذخیره شد",
"poll": "بررسی دورهای",
- "allowSelfSigned": "مجاز بودن گواهی self-signed",
+ "allowSelfSigned": "مجاز بودن گواهی خودامضا",
"selfSignedTip": "صرفنظر کردن از تأیید TLS برای آدرسهای این برنامه. این کار ناامن است و فقط زمانی باید انجام شود که ریسکهای آن را کاملاً درک کردهاید.",
"dockPill": "داک",
"healthPill": "سلامت",
@@ -240,8 +240,8 @@
"saveFailed": "ذخیره ناموفق بود: {err}",
"colorInvalid": "این یک رنگ نیست. از #rrggbb یا نام رنگ CSS استفاده کنید.",
"imported": "وارد شد",
- "importFailed": "Import ناموفق بود: {err}",
- "exportFailed": "Export ناموفق بود: {err}",
+ "importFailed": "وارد کردن ناموفق بود: {err}",
+ "exportFailed": "خروجی گرفتن ناموفق بود: {err}",
"importNoChange": "چیزی برای وارد کردن نیست — فایل با تنظیمات فعلی شما مطابقت دارد.",
"pwWeak": "رمز عبور ضعیف است: {label}",
"sessionsRevoked": "همه دستگاهها خارج شدند.",
diff --git a/ui/i18n/fr.json b/ui/i18n/fr.json
index c9188316..792e842b 100644
--- a/ui/i18n/fr.json
+++ b/ui/i18n/fr.json
@@ -43,7 +43,7 @@
"hostIp": "IP de l'hôte",
"hostIpTip": "Permet aux badges de récupérer des données à l'adresse de votre propre serveur. Sans cela, ces requêtes sont bloquées.",
"monitoring": "Surveillance",
- "loggingLevel": "Niveau de Logging",
+ "loggingLevel": "Niveau de journalisation",
"dockerHealth": "Vérifications de l'état des conteneurs Docker",
"hideHealthy": "Masquer le badge d'état sain",
"socketUrl": "URL du socket",
@@ -84,12 +84,12 @@
"displayDark": "Sombre",
"wallpaper": "Fond d'écran",
"source": "Source",
- "unsplashKey": "API key",
+ "unsplashKey": "Clé d'API",
"collectionId": "ID de collection",
"imageUrl": "URL de l'image",
"color": "Couleur",
"brightness": "Luminosité",
- "collectionTip": "Laissez le Collection ID vide pour un résultat aléatoire.",
+ "collectionTip": "Laissez l'ID de collection vide pour un résultat aléatoire.",
"sourceUnsplash": "Unsplash",
"sourceUrl": "Image",
"sourceColor": "Couleur unie",
diff --git a/ui/i18n/zh-Hans.json b/ui/i18n/zh-Hans.json
index a47b5dac..1e0dd796 100644
--- a/ui/i18n/zh-Hans.json
+++ b/ui/i18n/zh-Hans.json
@@ -6,7 +6,7 @@
"nav": {
"general": "通用",
"appearance": "外观",
- "dashboard": "仪表盘",
+ "dashboard": "仪表板",
"about": "关于",
"sections": "设置分区",
"backToDashboard": "返回仪表板",
@@ -41,9 +41,9 @@
"title": "标题",
"description": "描述",
"hostIp": "主机 IP",
- "hostIpTip": "允许标记从服务器自身地址获取数据。否则这些请求会被拦截。",
+ "hostIpTip": "允许徽章从服务器自身地址获取数据。否则这些请求会被拦截。",
"monitoring": "监控",
- "loggingLevel": "Logging 级别",
+ "loggingLevel": "日志级别",
"dockerHealth": "Docker 容器健康检查",
"hideHealthy": "隐藏健康徽章",
"socketUrl": "Socket 地址",
@@ -55,12 +55,12 @@
"signOutEverywhere": "退出所有设备",
"passwordProtection": "密码保护",
"password": "密码",
- "passwordTip": "留空以保留现有密码。至少 8 个字符。若需更强的保护,建议将仪表盘置于 Authentication Proxy 之后。此内置密码只是一道简单的门槛,并非完整的身份验证层。",
+ "passwordTip": "留空以保留现有密码。至少 8 个字符。若需更强的保护,建议将仪表板置于专用身份验证代理之后。此内置密码只是一道简单的门槛,并非完整的身份验证层。",
"backup": "备份",
"importExport": "导入 / 导出",
"signOut": "退出登录",
"importForeign": "从其他仪表板导入",
- "importForeignTip": "读取 gethomepage 与 Dashy 的 YAML 文件。链接和文件夹会添加到你的仪表板;其他仪表板的挂件会变成普通链接。",
+ "importForeignTip": "读取 gethomepage 与 Dashy 的 YAML 文件。链接和文件夹会添加到你的仪表板;其他仪表板的小组件会变成普通链接。",
"logDebug": "调试",
"logInfo": "信息",
"logError": "错误",
@@ -84,12 +84,12 @@
"displayDark": "深色",
"wallpaper": "壁纸",
"source": "来源",
- "unsplashKey": "API key",
+ "unsplashKey": "API 密钥",
"collectionId": "合集 ID",
"imageUrl": "图片地址",
"color": "颜色",
"brightness": "亮度",
- "collectionTip": "将 Collection ID 留空以随机显示。",
+ "collectionTip": "将合集 ID 留空以随机显示。",
"sourceUnsplash": "Unsplash",
"sourceUrl": "图片",
"sourceColor": "纯色",
@@ -176,12 +176,12 @@
"fetch": "获取",
"value": "值",
"authentication": "身份验证",
- "addToHeader": "添加到 Header",
+ "addToHeader": "添加到标头",
"unit": "单位",
"unitPh": "例如 GB",
"badgeMin": "达到时显示",
"badgeMinPh": "例如 5",
- "combineValues": "显示为单个标记",
+ "combineValues": "显示为单个徽章",
"combineTip": "所有标签的数值相加后显示为一个数字。关闭后每次显示一个标签,从列表中最靠上且有内容可报告的标签开始。",
"labelN": "标签 {n}",
"moveUp": "上移标签",
@@ -191,8 +191,8 @@
"removeLabel": "移除标签",
"labelValueTaken": "该数值已被另一个标签使用。",
"noValuesLeft": "每个获取到的数值都已有标签。",
- "labelMax": "一个标记最多显示 {n} 个标签。",
- "labelOrderTip": "标记显示此列表中第一个达到阈值的标签。拖动可重新排序。",
+ "labelMax": "一个徽章最多显示 {n} 个标签。",
+ "labelOrderTip": "徽章显示此列表中第一个达到阈值的标签。拖动可重新排序。",
"labelsSaved_other": "已保存 {count} 个标签",
"poll": "轮询",
"allowSelfSigned": "允许自签名证书",
@@ -239,8 +239,8 @@
"saveFailed": "保存失败:{err}",
"colorInvalid": "这不是颜色。请使用 #rrggbb 或 CSS 颜色名称。",
"imported": "已导入",
- "importFailed": "Import 失败:{err}",
- "exportFailed": "Export 失败:{err}",
+ "importFailed": "导入失败:{err}",
+ "exportFailed": "导出失败:{err}",
"importNoChange": "没有可导入的内容 — 该文件与您当前的配置一致。",
"pwWeak": "密码强度不足:{label}",
"sessionsRevoked": "已退出所有设备。",
@@ -278,7 +278,7 @@
"healthy": "状态:正常",
"pending": "{count} 项待处理",
"stale": "(可能已过时)",
- "moreValues_other": "另有 {count} 个标记",
+ "moreValues_other": "另有 {count} 个徽章",
"containerNotFound": "未找到容器",
"containerState": "容器 {state}",
"pingFailed": "Ping 失败:{error}",
@@ -350,8 +350,8 @@
"noteRemoteContainer": "已忽略容器,它运行在 {detail}",
"noteFlattened": "嵌套分组已成为独立文件夹",
"noteSubItems": "子项已移入该文件夹",
- "noteWidgetLink": "挂件已作为链接导入",
- "noteWidgetDropped": "已舍弃 {detail} 个分区挂件",
+ "noteWidgetLink": "小组件已作为链接导入",
+ "noteWidgetDropped": "已舍弃 {detail} 个分区小组件",
"noteFields": "已舍弃字段:{detail}",
"notePages": "未跟随其余 {detail} 个页面",
"noteLocalUrl": "已舍弃第二个本地地址:{detail}",
diff --git a/ui/test/i18n-coverage.test.mjs b/ui/test/i18n-coverage.test.mjs
index c9972a34..313d6c2a 100644
--- a/ui/test/i18n-coverage.test.mjs
+++ b/ui/test/i18n-coverage.test.mjs
@@ -126,6 +126,20 @@ const REJECTED = {
['ابزارک', 'ویجت'],
['پرونده', 'فایل'],
],
+ /* Abzeichen is a pinned badge or a medal. The reference locales use Badge 26
+ times and Abzeichen never. Bezeichnung is what the rest of the catalogue
+ calls a label. */
+ 'de.json': [
+ ['Abzeichen', 'Badge'],
+ ['Beschriftung', 'Bezeichnung'],
+ ],
+ /* 标记 is a mark or a verb. 徽章 is the status readout, which is what Uptime
+ Kuma, a monitoring dashboard, uses throughout. 挂件 is an ornament. */
+ 'zh-Hans.json': [
+ ['标记', '徽章'],
+ ['挂件', '小组件'],
+ ['仪表盘', '仪表板'],
+ ],
};
const widgetCatalogues = locale => {
@@ -153,3 +167,107 @@ for (const [file, pairs] of Object.entries(REJECTED)) {
assert.deepEqual(found, [], `A term the glossary rules out. See docs/i18n.md:\n ${found.join('\n ')}`);
});
}
+
+/* ── English left inside a non-Latin catalogue ────────────────────────────── */
+
+/* A machine translation that skips a word leaves it in English, and in a
+ non-Latin script that is visible to a scan even though the value as a whole
+ differs from the source. Persian rendered "چیزی برای Import نیست", and Chinese
+ had "Import 失败", "Logging 级别" and "添加到 Header".
+
+ Only the catalogues written in another script can be checked this way. In
+ German, Spanish and French an English word is not distinguishable from the
+ language around it.
+
+ ALLOWED is the vocabulary that stays in Latin script on purpose: brands,
+ protocols, formats, and identifiers a reader types or sees in a config file.
+ Everything is matched whole-word and case-insensitively. */
+const LATIN_OK = [
+ 'Stackyard',
+ 'Docker',
+ 'Unsplash',
+ 'GitHub',
+ 'Plex',
+ 'Homepage',
+ 'Dashy',
+ 'gethomepage',
+ 'JSON',
+ 'YAML',
+ 'CSS',
+ 'SVG',
+ 'PNG',
+ 'HTML',
+ 'URL',
+ 'URLs',
+ 'IP',
+ 'API',
+ 'ICMP',
+ 'DNS',
+ 'HTTP',
+ 'HTTPS',
+ 'SSL',
+ 'TLS',
+ 'PWA',
+ 'UI',
+ 'ID',
+ 'CPU',
+ 'RAM',
+ 'SMART',
+ 'RSS',
+ 'VPN',
+ 'PAT',
+ 'SSO',
+ 'Ping',
+ 'Dock',
+ 'Iframe',
+ 'Referrer',
+ 'Socket',
+ 'Widget',
+ 'http',
+ 'https',
+ 'repo',
+ 'ltr',
+ 'rtl',
+ 'px',
+ 'ms',
+ 'container',
+ 'name',
+ 'port',
+ 'server',
+ 'your',
+ 'example',
+ 'com',
+ 'app',
+ 'api',
+ 'read',
+ 'user',
+ 'min',
+ 'max',
+ 'chars',
+ 'rrggbb',
+ 'autoplay',
+ 'fullscreen',
+];
+const ALLOWED = new Set(LATIN_OK.map(w => w.toLowerCase()));
+
+for (const file of ['fa.json', 'zh-Hans.json']) {
+ test(`${file} leaves no English word untranslated`, () => {
+ const stray = [];
+ for (const [key, value] of flat(read(file))) {
+ if (key.startsWith('_meta') || /Ph$/.test(key)) continue; /* examples are literal */
+ /* Markup tag names and placeholder names are not prose. */
+ const prose = String(value)
+ .replace(/<\/?[a-zA-Z][a-zA-Z0-9]*\s*\/?>/g, ' ')
+ .replace(/\{\w+\}/g, ' ')
+ .replace(/\b[a-z][a-z0-9+.-]*:\/\/\S+/gi, ' ') /* a URL is typed, not read */;
+ for (const word of prose.match(/[A-Za-z][A-Za-z']+/g) || []) {
+ if (!ALLOWED.has(word.toLowerCase())) stray.push(`${key}: ${word}`);
+ }
+ }
+ assert.deepEqual(
+ stray,
+ [],
+ `English left in the translation. Translate it, or add the token to LATIN_OK:\n ${stray.join('\n ')}`,
+ );
+ });
+}