Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions assets/js/welcome.js

Large diffs are not rendered by default.

20 changes: 15 additions & 5 deletions src/shared/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import ChainedBackend from 'i18next-chained-backend';
import LocalStorageBackend from 'i18next-localstorage-backend';
import HttpBackend from 'i18next-http-backend';
import { getLandingData } from './landing-data';
import { resolveLocaleDir } from './locale-resolve.mjs';
import sharedEn from '../translations/en/wp-admin-landing-shared.json';

export const SHARED_NS = 'wp-admin-landing-shared';
Expand All @@ -19,6 +20,12 @@ export function initI18n(): typeof i18next {
.init({
lng: locale,
fallbackLng: 'en_US',
// Request only the exact locale — not i18next's default base-language
// companion (es_ES would otherwise also fetch a nonexistent `es`, logging
// a 404 each load). Locale files are key-complete (lint-translations), so
// no region→base fallback layer is needed; missing keys fall back to the
// bundled en_US.
load: 'currentOnly',
ns: [SHARED_NS],
defaultNS: SHARED_NS,
keySeparator: false,
Expand All @@ -32,11 +39,14 @@ export function initI18n(): typeof i18next {
backendOptions: [
{ prefix: 'wcpos_i18n_', expirationTime: 7 * 24 * 60 * 60 * 1000 },
// Self-contained: locale files live in this repo beside src/translations/en/
// and ship with the same release tag as the bundles. Locale dirs use WP locale
// codes (fr_FR, de_DE, es_ES, it_IT, nl_NL, pt_BR, ja, zh_CN, ko_KR, ar, hi_IN).
// Missing locale files 404 → chained backend falls back to bundled English
// (expected until translations are generated).
{ loadPath: 'https://cdn.jsdelivr.net/gh/wcpos/wp-admin-landing@v2/src/translations/{{lng}}/{{ns}}.json' },
// and ship with the same release tag as the bundles. The loadPath is a
// function so a requested code is resolved to an available directory
// (short es → es_ES, etc.); unsupported locales 404 → bundled-English
// fallback (the documented behaviour).
{
loadPath: (lngs: string[], namespaces: string[]) =>
`https://cdn.jsdelivr.net/gh/wcpos/wp-admin-landing@v2/src/translations/${resolveLocaleDir(lngs[0])}/${namespaces[0]}.json`,
},
],
},
});
Expand Down
38 changes: 38 additions & 0 deletions src/shared/locale-resolve.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// src/shared/locale-resolve.mjs
// Pure locale→directory resolution for the i18n HTTP backend. Kept as a plain
// .mjs (no DOM/i18next imports) so node --test can exercise the mapping.

/** Translation directories that exist in src/translations/ (keep in sync; the
* lint-translations CI gate validates each has the full namespace set). */
export const LOCALE_DIRS = [
'ar', 'cs_CZ', 'da_DK', 'de_DE', 'el', 'en', 'es_ES', 'es_MX', 'fi', 'fr_FR',
'he_IL', 'hi_IN', 'hu_HU', 'id_ID', 'it_IT', 'ja', 'ko_KR', 'nb_NO', 'nl_NL',
'pl_PL', 'pt_BR', 'pt_PT', 'ro_RO', 'ru_RU', 'sv_SE', 'th', 'tr_TR', 'uk', 'vi',
'zh_CN', 'zh_TW',
];

/**
* Maps an i18next-requested language code to an available translation directory.
* Files use full WP locale codes (fr_FR, es_ES, …). This resolves:
* - exact region matches, including hyphenated BCP-47 forms i18next/browsers
* may pass (zh-TW → zh_TW, es-MX → es_MX, pt-PT → pt_PT), case-insensitively;
* - short codes from sites whose WP locale is language-only (es → es_ES,
* pt → pt_BR, zh → zh_CN) so those users get their language, not English.
* Unknown codes pass through unchanged → the file 404s → bundled-English
* fallback (the documented behaviour for unsupported locales).
*
* @param {string} lng
* @returns {string}
*/
export function resolveLocaleDir(lng) {
if (!lng) return lng;
const norm = lng.replace(/-/g, '_'); // BCP-47 hyphen → WP underscore (zh-TW → zh_TW)
// Exact region/language match first, so es-MX stays es_MX and never collapses
// to the base language's first region (es_ES). Case-insensitive to tolerate
// codes like `zh_tw` from cleanCode/lowercasing.
const exact = LOCALE_DIRS.find((dir) => dir.toLowerCase() === norm.toLowerCase());
if (exact) return exact;
const base = norm.split('_')[0].toLowerCase();
if (LOCALE_DIRS.includes(base)) return base; // language-only dir (ja, ar, en, …)
return LOCALE_DIRS.find((dir) => dir.toLowerCase().split('_')[0] === base) ?? lng;
}
48 changes: 48 additions & 0 deletions tests/locale-resolve.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// tests/locale-resolve.test.mjs
import test from 'node:test';
import assert from 'node:assert/strict';
import { resolveLocaleDir, LOCALE_DIRS } from '../src/shared/locale-resolve.mjs';

test('exact full-locale matches pass through', () => {
assert.equal(resolveLocaleDir('es_ES'), 'es_ES');
assert.equal(resolveLocaleDir('fr_FR'), 'fr_FR');
assert.equal(resolveLocaleDir('zh_TW'), 'zh_TW');
assert.equal(resolveLocaleDir('ja'), 'ja'); // language-only dir
});

test('short codes map to a region directory (so they are not served English)', () => {
assert.equal(resolveLocaleDir('es'), 'es_ES');
assert.equal(resolveLocaleDir('fr'), 'fr_FR');
assert.equal(resolveLocaleDir('de'), 'de_DE');
assert.equal(resolveLocaleDir('pt'), 'pt_BR'); // first region variant in the list
assert.equal(resolveLocaleDir('zh'), 'zh_CN');
});

test('hyphenated and base codes resolve too', () => {
assert.equal(resolveLocaleDir('fr-FR'), 'fr_FR'); // i18next/browser hyphen form
assert.equal(resolveLocaleDir('en_US'), 'en'); // base en dir exists (bundled source)
});

test('region variants keep their own directory', () => {
assert.equal(resolveLocaleDir('es_MX'), 'es_MX');
assert.equal(resolveLocaleDir('pt_PT'), 'pt_PT');
});

test('hyphenated codes keep their exact region (not the base language first region)', () => {
// Regression (Codex #31): 'zh-TW' must not collapse to zh_CN, 'es-MX' to es_ES.
assert.equal(resolveLocaleDir('zh-TW'), 'zh_TW');
assert.equal(resolveLocaleDir('es-MX'), 'es_MX');
assert.equal(resolveLocaleDir('pt-PT'), 'pt_PT');
assert.equal(resolveLocaleDir('zh_tw'), 'zh_TW'); // case-insensitive
});

test('unknown locales pass through unchanged → 404 → English fallback', () => {
assert.equal(resolveLocaleDir('xx_XX'), 'xx_XX');
assert.equal(resolveLocaleDir('zz'), 'zz');
});

test('LOCALE_DIRS has no short duplicates that would shadow region codes', () => {
// 'es' must not be a dir (only es_ES/es_MX), else short→region mapping breaks.
assert.ok(!LOCALE_DIRS.includes('es'));
assert.ok(!LOCALE_DIRS.includes('fr'));
});
Loading