i18n(ar): register Arabic as the first right-to-left language

- Add ar to LOCALES (picker name العربية) and RTL_LOCALES, so <html dir>
  turns rtl; any ar-* tag maps to it.
- Intl formatters get ar-u-nu-latn: numbers and dates keep Western digits
  like the catalog, whatever the browser's ICU picks for Arabic regions.
- HUD quantities keep a space before an Arabic unit (2069.0 كم), as in
  Chinese, instead of running digits into the word.
- Codes and product names that stay Latin go to same-as-english.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-28 12:55:20 +04:00
1 parent 94dc2736e1
commit 0b928b6810
8 files changed
+239 -8

No files matched your search

+43
View File
@@ -439,6 +439,7 @@
"src/ui/slider.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -479,6 +480,7 @@
"src/localRequestGate.mjs",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -509,6 +511,7 @@
"src/data/layerSnapshot.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -531,6 +534,7 @@
"src/app/stateChannel.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -548,6 +552,7 @@
"src/ui/radioTunerModel.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -568,6 +573,7 @@
"src/ui/cctvPresentation.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -595,6 +601,7 @@
"src/ui/panelRailGeometry.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -630,6 +637,7 @@
"src/ui/panelRailGeometry.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -647,6 +655,7 @@
"src/ui/actionButton.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -806,6 +815,7 @@
"src/data/cameraArrival.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -928,6 +938,7 @@
"src/data/cameraArrival.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -996,6 +1007,7 @@
"src/data/modelEligibility.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -1045,6 +1057,7 @@
"src/data/modelEligibility.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -1108,6 +1121,7 @@
"src/data/inputOwnership.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -1138,6 +1152,7 @@
"src/maps/imageryHost.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -1169,6 +1184,7 @@
"src/data/inputOwnership.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -1200,6 +1216,7 @@
"src/data/inputOwnership.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -1234,6 +1251,7 @@
"src/data/inputOwnership.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -1277,6 +1295,7 @@
"src/data/infrastructureOverlayEntry.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -1314,6 +1333,7 @@
"src/ui/panelRailGeometry.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -1365,6 +1385,7 @@
"src/layers/traffic/roadModes.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -1395,6 +1416,7 @@
"src/data/cameraSensitivity.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -1444,6 +1466,7 @@
"src/layers/cctv/sourcePolicy.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -1508,6 +1531,7 @@
"scripts/qa-street-level.mjs",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -1538,6 +1562,7 @@
"src/layers/alpr/floors.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -1579,6 +1604,7 @@
"src/data/inputOwnership.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -1602,6 +1628,7 @@
"src/data/inputOwnership.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -1659,6 +1686,7 @@
"src/maps/imageryHost.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -1722,6 +1750,7 @@
"src/voice/pointerReticle.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -2605,6 +2634,7 @@
"src/app/embed.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -3190,6 +3220,7 @@
"src/sources/infrastructureData.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -3301,6 +3332,7 @@
"src/app/stateChannel.js",
"src/data/cameraArrival.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -3325,6 +3357,7 @@
"src/ui/uiLifetime.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -3362,6 +3395,7 @@
"src/i18n/marker.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -3389,6 +3423,7 @@
"src/ui/uiLifetime.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -3481,6 +3516,7 @@
"src/data/routeSteps.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -3537,6 +3573,7 @@
"src/voice/pointerReticle.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -3792,6 +3829,7 @@
"src/sources/capability.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -3836,6 +3874,7 @@
"src/sources/transitHistory.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -3890,6 +3929,7 @@
"src/layers/perimeters/inciweb.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -3907,6 +3947,7 @@
"src/layers/gnss/records.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -3967,6 +4008,7 @@
"src/i18n/marker.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
@@ -4164,6 +4206,7 @@
"src/i18n/marker.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
+7 -1
View File
@@ -8,6 +8,12 @@ import { formatNumber, getLocale, t } from './i18n/index.js';
import { unitLabel } from './ui/unitLabels.js';
import { localizePlaceName } from './ui/placeNames.js';
/**
* Scripts without letter case keep a space before the unit: `13 km`, `13 كم`.
* Arabic would otherwise run the digits into the word.
*/
const UNCASED_UNITS = /^(?:zh-|ar$)/;
/** An upper-case HUD quantity such as `13KM` / `13КМ`, in the active language. */
export function hudQuantity(value, unit, digits = 0) {
const number = formatNumber(value, {
@@ -16,7 +22,7 @@ export function hudQuantity(value, unit, digits = 0) {
maximumFractionDigits: digits,
});
const label = unitLabel(unit);
if (getLocale().startsWith('zh-'))
if (UNCASED_UNITS.test(getLocale()))
return label ? `${number} ${label}` : number;
return `${number}${label.toUpperCase()}`;
}
+101
View File
@@ -0,0 +1,101 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { formatDate, formatNumber, getLocale, setLocale, t, tn } from './index.js';
import {
applyDocumentLanguage,
intlLocale,
LOCALE_STORAGE_KEY,
resolveLocale,
} from './locale.js';
import { hudQuantity, LOCALIZED_LOCALITY } from '../hudLocalityFormat.js';
import { formatCockpitDistance } from '../ui/cockpitPresentation.js';
import { READOUT_WORDS } from '../layers/recentImagery/display.js';
test('Arabic resolves from URL, saved preference and browser language', () => {
assert.equal(
resolveLocale({
location: { search: '?lang=ar-EG' },
storage: { getItem: () => 'en' },
languages: ['en'],
}),
'ar',
);
assert.equal(
resolveLocale({
location: { search: '' },
storage: {
getItem: (key) => (key === LOCALE_STORAGE_KEY ? 'ar' : null),
},
languages: ['en'],
}),
'ar',
);
assert.equal(
resolveLocale({
location: { search: '' },
storage: { getItem: () => null },
languages: ['ar-SA', 'en'],
}),
'ar',
);
});
test('Arabic turns the page right-to-left; other languages stay left-to-right', () => {
const doc = { documentElement: {} };
applyDocumentLanguage(doc, 'ar');
assert.deepEqual(doc.documentElement, { lang: 'ar', dir: 'rtl' });
applyDocumentLanguage(doc, 'zh-CN');
assert.deepEqual(doc.documentElement, { lang: 'zh-CN', dir: 'ltr' });
});
test('Arabic formats with Western digits whatever ICU defaults to', () => {
assert.equal(intlLocale('ar'), 'ar-u-nu-latn');
assert.equal(intlLocale('ru'), 'ru');
const previous = getLocale();
try {
setLocale('ar');
assert.equal(formatNumber(1234.5), '1,234.5');
assert.equal(
formatDate(Date.UTC(2026, 7, 26), {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC',
}),
'26 أغسطس 2026',
);
assert.doesNotMatch(formatNumber(9876543210), /[٠-٩]/);
} finally {
setLocale(previous);
}
});
test('Arabic uses all six plural forms and spaced HUD units', () => {
const previous = getLocale();
try {
setLocale('ar');
assert.deepEqual(
[0, 1, 2, 3, 11, 100].map((n) =>
tn(n, '{count} SOURCE', '{count} SOURCES'),
),
[
'0 مصدر',
'مصدر واحد (1)',
'مصدران (2)',
'3 مصادر',
'11 مصدرًا',
'100 مصدر',
],
);
assert.equal(hudQuantity(2069, 'km', 1), '2069.0 كم');
assert.equal(formatCockpitDistance(14000), '14 كم');
assert.equal(
LOCALIZED_LOCALITY.sector({ latDeg: 21.33, lonDeg: -77.5 }),
'القطاع 21.33ش 77.50غ',
);
assert.equal(READOUT_WORDS.daysAgo(9), 'قبل 9 أيام');
assert.equal(t('SAVE KEYS'), 'حفظ المفاتيح');
} finally {
setLocale(previous);
}
});
+7
View File
@@ -47,6 +47,9 @@ test('normalizeLocale folds regional variants and rejects unshipped tags', () =>
assert.equal(normalizeLocale('zh-Hant-CN'), 'zh-Hant');
assert.equal(normalizeLocale('zh_HK'), 'zh-Hant');
assert.equal(normalizeLocale('zh-MO'), 'zh-Hant');
assert.equal(normalizeLocale('ar'), 'ar');
assert.equal(normalizeLocale('ar-EG'), 'ar');
assert.equal(normalizeLocale(' AR_sa '), 'ar');
assert.equal(normalizeLocale(undefined), null);
});
@@ -102,6 +105,10 @@ test('applyDocumentLanguage sets lang and dir', () => {
const doc = { documentElement: {} };
applyDocumentLanguage(doc, 'ru-RU');
assert.deepEqual(doc.documentElement, { lang: 'ru', dir: 'ltr' });
applyDocumentLanguage(doc, 'ar-EG');
assert.deepEqual(doc.documentElement, { lang: 'ar', dir: 'rtl' });
applyDocumentLanguage(doc, 'en');
assert.deepEqual(doc.documentElement, { lang: 'en', dir: 'ltr' });
});
test('t() returns English text in English and translates in Russian', () => {
+10 -2
View File
@@ -13,15 +13,23 @@
// `npm run i18n` keeps the catalogs in step with the code. Guide: docs/I18N.md.
import {
DEFAULT_LOCALE,
intlLocale,
normalizeLocale,
writeStoredLocale,
} from './locale.js';
import ar from './locales/ar.json' with { type: 'json' };
import es from './locales/es.json' with { type: 'json' };
import ru from './locales/ru.json' with { type: 'json' };
import zhCN from './locales/zh-CN.json' with { type: 'json' };
import zhHant from './locales/zh-Hant.json' with { type: 'json' };
const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN, 'zh-Hant': zhHant });
const CATALOGS = Object.freeze({
ar,
es,
ru,
'zh-CN': zhCN,
'zh-Hant': zhHant,
});
let currentLocale = DEFAULT_LOCALE;
@@ -157,7 +165,7 @@ function cachedFormatter(kind, Constructor, options) {
const cacheKey = `${kind}|${currentLocale}|${options ? JSON.stringify(options) : ''}`;
let formatter = formatterCache.get(cacheKey);
if (!formatter) {
formatter = new Constructor(currentLocale, options);
formatter = new Constructor(intlLocale(currentLocale), options);
formatterCache.set(cacheKey, formatter);
}
return formatter;
+24 -2
View File
@@ -8,11 +8,19 @@ export const LOCALE_STORAGE_KEY = 'gev:locale:v1';
export const DEFAULT_LOCALE = 'en';
/** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */
export const LOCALES = Object.freeze(['en', 'es', 'ru', 'zh-CN', 'zh-Hant']);
export const LOCALES = Object.freeze([
'en',
'ar',
'es',
'ru',
'zh-CN',
'zh-Hant',
]);
/** Each locale named in its own language, for the selector. */
export const LOCALE_NAMES = Object.freeze({
en: 'English',
ar: 'العربية',
es: 'Español',
ru: 'Русский',
'zh-CN': '中文(简体)',
@@ -20,10 +28,24 @@ export const LOCALE_NAMES = Object.freeze({
});
/** Right-to-left locales. */
const RTL_LOCALES = Object.freeze([]);
const RTL_LOCALES = Object.freeze(['ar']);
/**
* Tags for Intl formatters where the locale code alone is not enough. Arabic
* keeps Western digits (0–9) like its catalog: ICU picks Arabic-Indic digits
* (٠–٩) for some Arabic regions and browsers, and a HUD that mixes both
* misreads.
*/
const INTL_TAGS = Object.freeze({ ar: 'ar-u-nu-latn' });
/** The tag to hand Intl.NumberFormat, DateTimeFormat and the like. */
export function intlLocale(locale) {
return INTL_TAGS[locale] ?? locale;
}
/**
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru',
* 'ar-EG' and 'ar-SA' → 'ar',
* 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN';
* 'zh-Hant', 'zh-TW', 'zh-HK' and 'zh-MO' → 'zh-Hant'.
* @returns {string|null}
+34
View File
@@ -1,4 +1,38 @@
{
"ar": [
"1090 MHz",
"978 MHz UAT",
"AIS: --",
"AIS: {name}",
"BRG {value}°",
"BRG —",
"CAL {value}",
"CAL · {badge}",
"CCTV",
"CRS {value}°",
"CRT",
"FLIR",
"FOV",
"FOV {value}°",
"GEO",
"GOOGLE STREET VIEW",
"Google Street View",
"HDG",
"HDG {value}°",
"Ironbow",
"Landsat 8/9",
"NVG",
"REC",
"S2 GhostMaps",
"SQUAWK {code} · {status}",
"SRC {value}",
"Sentinel-2",
"WHOT/BHOT",
"dB",
"hPa",
"{source} HTTP {status}",
"▲ {frp} MW"
],
"es": [
"1090 MHz",
"978 MHz UAT",
+13 -3
View File
@@ -40,7 +40,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['ru', 'en', 'es', 'zh-CN', 'zh-Hant'],
['ru', 'en', 'es', 'ar', 'zh-CN', 'zh-Hant'],
);
assert.deepEqual(
codes(
@@ -50,7 +50,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['es', 'en', 'ru', 'zh-CN', 'zh-Hant'],
['es', 'en', 'ru', 'ar', 'zh-CN', 'zh-Hant'],
);
assert.deepEqual(
codes(
@@ -60,7 +60,17 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['en', 'es', 'ru', 'zh-CN', 'zh-Hant'],
['en', 'es', 'ru', 'ar', 'zh-CN', 'zh-Hant'],
);
assert.deepEqual(
codes(
buildLocaleOptions({
locales: LOCALES,
current: 'ar',
nativeNames: LOCALE_NAMES,
}),
),
['ar', 'en', 'es', 'ru', 'zh-CN', 'zh-Hant'],
);
const rows = buildLocaleOptions({
locales: MANY,