Resolve ordered browser languages when no profile language is saved

Match all browser preferences using existing locale identifiers and explicit standard-to-legacy aliases, including Veps, Venetian, Latin American Spanish and Uzbek scripts. Prefer supported saved profile choices and fall back to English. Respond to browser languagechange without writing a profile choice. Checks cover all 245 locales and alias/error cases. Browser regression added and syntax-checked; execution pending.

Thanks to xet7 !
This commit is contained in:
Lauri Ojansivu committed 2026-09-13 18:35:42 +03:00
1 parent 0c0adb48b9
commit 1666a2012b
4 files changed
+85 -25

No files matched your search

+11 -25
View File
@@ -2,6 +2,7 @@ import { Meteor } from 'meteor/meteor';
import { Tracker } from 'meteor/tracker';
import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
const { preferredLanguage } = require('/imports/i18n/browserLanguage');
// We save the user language preference in the user profile, and use that to set
// the language reactively. If the user is not connected we use the language
@@ -37,30 +38,15 @@ Meteor.startup(() => {
// A stored login token can begin resuming after startup has run. Keep this
// reactive so the persisted profile language wins when that user arrives,
// rather than leaving a resumed session in the browser's language.
Tracker.autorun(() => {
const applyPreferredLanguage = () => {
const currentUser = ReactiveCache.getCurrentUser();
const [language] = [
currentUser?.profile?.language,
navigator.languages?.at(0),
navigator.language,
navigator.userLanguage,
].filter(Boolean);
if (!language) return;
// Match the browser tag, then progressively strip trailing subtags until a supported
// language is found: e.g. 'zh-Hans-CN' -> 'zh-Hans', 'zh-Hant-TW' -> 'zh-Hant',
// 'ja-JP' -> 'ja-JP', bare 'zh' -> 'zh-Hans' (via alias). isLanguageSupported is
// case-insensitive, so 'zh-hant' / 'JA-JP' etc. also match.
let candidate = language;
while (candidate) {
if (TAPi18n.isLanguageSupported(candidate)) {
Promise.resolve(TAPi18n.setLanguage(candidate)).catch(error => {
console.error(`Could not load language ${candidate}:`, error);
});
break;
}
const cut = candidate.lastIndexOf('-');
candidate = cut > 0 ? candidate.slice(0, cut) : '';
}
});
const language = preferredLanguage(currentUser?.profile?.language, navigator,
candidate => TAPi18n.resolveTag(candidate));
Promise.resolve(TAPi18n.setLanguage(language)).catch(error => {
console.error(`Could not load language ${language}:`, error);
});
};
Tracker.autorun(applyPreferredLanguage);
// Browsers can change their ordered preferences without a page reload.
window.addEventListener('languagechange', applyPreferredLanguage);
});
+33
View File
@@ -0,0 +1,33 @@
// Browser BCP 47 tags mapped to WeKan's historical locale identifiers.
const aliases = {
vep: 've-PP', vec: 've-CC', vls: 'vl-SS', war: 'wa-RR',
'es-419': 'es-LA', 'be-by': 'be-BE', 'ca-valencia': 'ca-valencia',
'uz-arab': 'uz-AR', 'uz-latn': 'uz-LA', iw: 'he', ji: 'yi', in: 'id',
no: 'nb',
};
function matchBrowserLanguage(value, resolveTag) {
if (typeof value !== 'string' || !value.trim()) return undefined;
const original = resolveTag(value.trim());
if (original) return original;
let candidate = value.trim().replace(/_/g, '-');
while (candidate) {
const exact = resolveTag(candidate);
if (exact) return exact;
const mapped = aliases[candidate.toLowerCase()];
if (mapped && resolveTag(mapped)) return resolveTag(mapped);
const cut = candidate.lastIndexOf('-');
candidate = cut > 0 ? candidate.slice(0, cut) : '';
}
return undefined;
}
function preferredLanguage(profileLanguage, navigatorInfo, resolveTag) {
const saved = matchBrowserLanguage(profileLanguage, resolveTag);
if (saved) return saved;
const browserLanguages = Array.isArray(navigatorInfo.languages) ? navigatorInfo.languages : [];
for (const language of [...browserLanguages, navigatorInfo.language, navigatorInfo.userLanguage]) {
const supported = matchBrowserLanguage(language, resolveTag);
if (supported) return supported;
}
return resolveTag('en') || 'en';
}
module.exports = { matchBrowserLanguage, preferredLanguage };
+26
View File
@@ -0,0 +1,26 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { parseLanguageMetadata } = require('./lib/languageRegistrySource.cjs');
const { matchBrowserLanguage, preferredLanguage } = require('../imports/i18n/browserLanguage');
const rows = parseLanguageMetadata(fs.readFileSync(path.join(__dirname, '../imports/i18n/languages.js'), 'utf8'));
const normalize = tag => tag.toLowerCase().replace(/_/g, '-');
const resolve = value => rows.find(row => row[2] === value)?.[2] || rows.find(row => normalize(row[2]) === normalize(value))?.[2];
for (const row of rows) {
assert.equal(matchBrowserLanguage(row[2], resolve), row[2]);
const browserTag = row[2].replace(/_/g, '-');
assert.equal(normalize(matchBrowserLanguage(browserTag.toLowerCase(), resolve)), normalize(row[2]));
}
for (const [browser, expected] of Object.entries({vep:'ve-PP', vec:'ve-CC', vls:'vl-SS', war:'wa-RR', 'es-419':'es-LA', 'be-BY':'be-BE', 'uz-Arab-UZ':'uz-AR', 'uz-Latn-UZ':'uz-LA', 'zh-Hant-TW':'zh-Hant', 'fr-CA-u-ca-gregory':'fr-CA', 'en-AU':'en_AU', iw:'he', in:'id', no:'nb'})) {
assert.equal(matchBrowserLanguage(browser, resolve), expected, browser);
}
assert.equal(preferredLanguage(undefined, {languages:['zz-ZZ','fr-CA','de']}, resolve), 'fr-CA');
assert.equal(preferredLanguage('', {language:'es-CO'}, resolve), 'es-CO');
assert.equal(preferredLanguage('de', {languages:['fr-CA']}, resolve), 'de');
assert.equal(preferredLanguage('unsupported', {languages:['fr-CA']}, resolve), 'fr-CA');
assert.equal(preferredLanguage(undefined, {languages:['unsupported']}, resolve), 'en');
assert.equal(preferredLanguage(undefined, {userLanguage:'fi-FI'}, resolve), 'fi');
assert.equal(matchBrowserLanguage(null, resolve), undefined);
assert.notEqual(matchBrowserLanguage('vep', resolve), 've');
console.log(`browserLanguageFallback: ${rows.length} registry locales, standard aliases, ordered preferences and saved-profile precedence passed`);
@@ -20,3 +20,18 @@ test('language popup lists every locale and orders country before language flags
await expect(entry.locator('.language-flags')).toHaveAttribute('dir','ltr');
}
});
test('missing profile language follows browser preferences; saved choice wins', async ({ boardPage, user }) => {
const db = require('../helpers/db');
db.updateOne('users', {_id:user._id}, {$unset:{'profile.language':''}});
await boardPage.addInitScript(() => {
Object.defineProperty(navigator, 'languages', {configurable:true, get:() => ['zz-ZZ', 'vep']});
Object.defineProperty(navigator, 'language', {configurable:true, get:() => 'zz-ZZ'});
});
await boardPage.reload();
await expect(boardPage.locator('html')).toHaveAttribute('lang','ve-PP');
expect(db.findOne('users',{_id:user._id}).profile?.language).toBeUndefined();
db.updateOne('users', {_id:user._id}, {$set:{'profile.language':'de'}});
await boardPage.reload();
await expect(boardPage.locator('html')).toHaveAttribute('lang','de');
});