Add i18n vendor overlays and dynamic bundling
- Add opt-in vendor overlays under src/env/locales/<env>
(and optional variant), merged on top of base locales at runtime.
- Auto-discover and bundle all base locale JSON files
in src/locales/.
- Example: move dump type labels under pageDumps.dumpTypes;
read vendor-only dump labels from overlays.
- Docs: update i18n guidelines and env README (formatting fixes).
- Tests: add focused unit tests for overlays and locale aliases.
Tested:
- Unit: i18n.locale-alias.spec.js, i18n.vendor.spec.js (passing)
- Manual: Verified dynamic locale discovery and overlay merge in UI
Change-Id: I8eae2bfec0e9622bafdafac3168dbf96650e8ae8
Signed-off-by: jason westover <jwestover@nvidia.com>
diff --git a/docs/guide/guidelines/internationalization.md b/docs/guide/guidelines/internationalization.md
index f9ee473..52c75b5 100644
--- a/docs/guide/guidelines/internationalization.md
+++ b/docs/guide/guidelines/internationalization.md
@@ -75,3 +75,61 @@
autoFocusButton: 'ok',
})
```
+
+## Vendor overlays (environment-specific translations)
+
+To keep the base translation files vendor-neutral, vendor-specific strings live
+under `src/env/locales/<envName>/`.
+
+- Place shared vendor strings in the vendor root folder (e.g.,
+ `src/env/locales/vendor/`)
+- Place project-specific overrides in the variant folder when needed (e.g.,
+ `src/env/locales/vendor-variant/`)
+- Merge order at runtime:
+ 1. Base locales from `src/locales/` (auto-discovered)
+ 2. Vendor root overlays (e.g., `src/env/locales/vendor/`)
+ 3. Variant overlays (e.g., `src/env/locales/vendor-variant/`)
+ - Variant keys overwrite vendor root keys on conflict
+
+Notes:
+
+- All JSON files under `src/locales/` are bundled automatically.
+- All JSON files under `src/env/locales/` that match the active environment are
+ also bundled.
+- If multiple vendor projects share strings, prefer the vendor root folder so
+ variants don’t duplicate content.
+
+Example: moving vendor-only dump type labels
+
+```json
+// src/locales/en-US.json (base)
+{
+ "pageDumps": {
+ "dumpTypes": {}
+ }
+}
+
+// src/env/locales/nvidia/en-US.json (overlay)
+{
+ "pageDumps": {
+ "dumpTypes": {
+ "hmcDump": "HMC dump",
+ "bmcDump": "BMC dump",
+ "systemBmcDump": "System [BMC] dump (disruptive)",
+ "systemHgxDump": "System [HGX] dump (disruptive)"
+ }
+ }
+}
+```
+
+### Locale codes
+
+We support aliasing short codes to our canonical locales:
+
+- `en` → `en-US`
+- `ru` → `ru-RU`
+- `zh` → `zh-CN`
+- `ka` → `ka-GE`
+
+If a short code is stored (e.g., in localStorage), it will be normalized at app
+startup.
diff --git a/package.json b/package.json
index 0ca4307..cae7b7a 100644
--- a/package.json
+++ b/package.json
@@ -60,6 +60,7 @@
"eslint-plugin-prettier": "5.1.3",
"eslint-plugin-vue": "9.2.0",
"eslint-scope": "7.1.1",
+ "file-loader": "6.2.0",
"lint-staged": "13.0.3",
"postcss-loader": "8.1.1",
"prettier": "3.2.5",
diff --git a/src/env/locales/README.md b/src/env/locales/README.md
new file mode 100644
index 0000000..90fae61
--- /dev/null
+++ b/src/env/locales/README.md
@@ -0,0 +1,64 @@
+# Vendor locale overlays
+
+This directory contains environment/vendor-specific translation bundles that are
+merged on top of the base, vendor-neutral locales in `src/locales/`.
+
+## Structure
+
+```text
+src/env/locales/
+ <vendor>/
+ en-US.json
+ ka-GE.json
+ ru-RU.json
+ zh-CN.json
+ <vendor-variant>/
+ en-US.json # optional, only when variant overrides vendor root
+ ka-GE.json # optional
+ ru-RU.json # optional
+ zh-CN.json # optional
+```
+
+Examples:
+
+- Shared vendor folder: `src/env/locales/nvidia/`
+- Variant folder: `src/env/locales/nvidia-gb/`
+
+## Merge order at runtime
+
+1. Base locales from `src/locales/` (auto-discovered)
+2. Vendor root overlays (e.g., `src/env/locales/nvidia/`)
+3. Variant overlays (e.g., `src/env/locales/nvidia-gb/`)
+
+Variant keys overwrite vendor root keys on conflict.
+
+## Guidelines
+
+- Keep `src/locales/` vendor‑neutral. Put vendor‑specific strings here.
+- Prefer the vendor root folder when multiple projects share strings to avoid
+ duplication.
+- Only add a variant folder if it truly needs to override vendor root strings.
+- File names must match locale codes (e.g., `en-US.json`, `ru-RU.json`,
+ `zh-CN.json`, `ka-GE.json`).
+- Use 4‑space indentation; alphabetize object keys for readability.
+- JSON must be valid (no trailing commas or comments).
+
+## Environment selection
+
+The active environment is selected by `VUE_APP_ENV_NAME` (e.g., `nvidia`,
+`nvidia-gb`). See the `.env.*` files at the repo root (e.g., `.env.nvidia-gb`).
+
+## Bundling
+
+- All JSON files under `src/locales/` are bundled automatically.
+- Matching overlays under `src/env/locales/<env>` are also bundled and merged at
+ app start.
+
+## Testing
+
+Focused unit tests exist for overlays and fallback:
+
+- `npm run test:unit -- i18n.vendor.spec.js`
+- `npm run test:unit -- i18n.locale-alias.spec.js`
+
+These verify vendor root → variant merge behavior and locale alias handling.
diff --git a/src/env/locales/nvidia/en-US.json b/src/env/locales/nvidia/en-US.json
new file mode 100644
index 0000000..23d8057
--- /dev/null
+++ b/src/env/locales/nvidia/en-US.json
@@ -0,0 +1,11 @@
+{
+ "pageDumps": {
+ "dumpTypes": {
+ "bmcDump": "BMC dump",
+ "hmcDump": "HMC dump",
+ "systemBmcDump": "System [BMC] dump (disruptive)",
+ "systemHgxDump": "System [HGX] dump (disruptive)",
+ "systemDump": "System [BMC] dump (disruptive)"
+ }
+ }
+}
diff --git a/src/env/locales/nvidia/ka-GE.json b/src/env/locales/nvidia/ka-GE.json
new file mode 100644
index 0000000..a6e53a9
--- /dev/null
+++ b/src/env/locales/nvidia/ka-GE.json
@@ -0,0 +1,11 @@
+{
+ "pageDumps": {
+ "dumpTypes": {
+ "bmcDump": "BMC-ის დამპი",
+ "hmcDump": "HMC დამპი",
+ "systemBmcDump": "სისტემის [BMC] დამპი (დროებით წყვეტს სისტემის მუშაობას)",
+ "systemHgxDump": "სისტემის [HGX] დამპი (დროებით წყვეტს სისტემის მუშაობას)",
+ "systemDump": "სისტემის [BMC] დამპი (დროებით წყვეტს სისტემის მუშაობას)"
+ }
+ }
+}
diff --git a/src/env/locales/nvidia/ru-RU.json b/src/env/locales/nvidia/ru-RU.json
new file mode 100644
index 0000000..b6c67b0
--- /dev/null
+++ b/src/env/locales/nvidia/ru-RU.json
@@ -0,0 +1,11 @@
+{
+ "pageDumps": {
+ "dumpTypes": {
+ "bmcDump": "BMC дамп",
+ "hmcDump": "HMC дамп",
+ "systemBmcDump": "Системный [BMC] дамп (разрушительный)",
+ "systemHgxDump": "Системный [HGX] дамп (разрушительный)",
+ "systemDump": "Системный [BMC] дамп (разрушительный)"
+ }
+ }
+}
diff --git a/src/env/locales/nvidia/zh-CN.json b/src/env/locales/nvidia/zh-CN.json
new file mode 100644
index 0000000..1071123
--- /dev/null
+++ b/src/env/locales/nvidia/zh-CN.json
@@ -0,0 +1,11 @@
+{
+ "pageDumps": {
+ "dumpTypes": {
+ "bmcDump": "BMC 转储",
+ "hmcDump": "HMC 转储",
+ "systemBmcDump": "系统 [BMC] 转储(破坏性)",
+ "systemHgxDump": "系统 [HGX] 转储(破坏性)",
+ "systemDump": "系统 [BMC] 转储(破坏性)"
+ }
+ }
+}
diff --git a/src/i18n.js b/src/i18n.js
index 2580784..65c7d1f 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -1,29 +1,94 @@
import { createI18n } from 'vue-i18n';
+import { deepMerge } from './utilities/objectUtils';
-import en_us from './locales/en-US.json';
-import ru_ru from './locales/ru-RU.json';
-import ka_ge from './locales/ka-GE.json';
-
-function loadLocaleMessages() {
- const messages = {
- 'en-US': en_us,
- 'ka-GE': ka_ge,
- 'ru-RU': ru_ru,
- };
+export function loadBaseLocaleMessages() {
+ const context = require.context(
+ './locales',
+ true,
+ /[A-Za-z0-9-_,\s]+\.json$/i,
+ );
+ const messages = {};
+ context.keys().forEach((key) => {
+ const match = key.match(/([A-Za-z0-9-_]+)\.json$/i);
+ if (!match) return;
+ const locale = match[1];
+ const mod = context(key);
+ messages[locale] = mod && mod.default ? mod.default : mod;
+ });
return messages;
}
-const i18n = createI18n({
- // Get default locale from local storage
- locale: window.localStorage.getItem('storedLanguage'),
- // Locales that don't exist will fallback to English
- fallbackLocale: 'en-US',
- // Falling back to fallbackLocale generates two console warnings
- // Silent fallback suppresses console warnings when using fallback
- silentFallbackWarn: true,
- messages: loadLocaleMessages(),
- globalInjection: false,
- legacy: false,
-});
+export function loadEnvLocaleMessages(envName) {
+ if (!envName) return {};
+ const envMessages = {};
+ const envLocales = require.context(
+ './env/locales',
+ true,
+ /[A-Za-z0-9-_,\s]+\.json$/i,
+ );
+ const vendorRoot = String(envName).split('-')[0];
+ const candidates =
+ vendorRoot && vendorRoot !== envName ? [vendorRoot, envName] : [envName];
+ candidates.forEach((candidate) => {
+ envLocales.keys().forEach((key) => {
+ if (!key.includes(`/${candidate}/`)) return;
+ const localeMatch = key.match(/([A-Za-z0-9-_]+)\.json$/i);
+ if (!localeMatch) return;
+ const locale = localeMatch[1];
+ const mod = envLocales(key);
+ const bundle = mod && mod.default ? mod.default : mod;
+ envMessages[locale] = deepMerge(envMessages[locale] || {}, bundle);
+ });
+ });
+ return envMessages;
+}
-export default i18n;
+export function createI18nInstance(
+ envName,
+ locale,
+ loadEnv = loadEnvLocaleMessages,
+ loadBase = loadBaseLocaleMessages,
+) {
+ const base = loadBase();
+ const env = loadEnv(envName);
+ const messages = { ...base };
+ Object.keys(env).forEach((loc) => {
+ messages[loc] = deepMerge(base[loc] || {}, env[loc]);
+ });
+
+ const addAlias = (alias, target) => {
+ if (!messages[alias] && messages[target])
+ messages[alias] = messages[target];
+ };
+ addAlias('en', 'en-US');
+ addAlias('ru', 'ru-RU');
+ addAlias('zh', 'zh-CN');
+ addAlias('ka', 'ka-GE');
+
+ const normalize = (val) => {
+ if (!val) return undefined;
+ const s = String(val);
+ if (s === 'en') return 'en-US';
+ if (s === 'ru') return 'ru-RU';
+ if (s === 'zh') return 'zh-CN';
+ if (s === 'ka') return 'ka-GE';
+ return s;
+ };
+
+ return createI18n({
+ locale: normalize(locale),
+ // Locales that don't exist will fallback to English
+ fallbackLocale: 'en-US',
+ // Falling back to fallbackLocale generates two console warnings
+ // Silent fallback suppresses console warnings when using fallback
+ silentFallbackWarn: true,
+ messages,
+ globalInjection: false,
+ legacy: false,
+ });
+}
+
+const envName = process.env.VUE_APP_ENV_NAME;
+// Get default locale from local storage
+const stored = window.localStorage.getItem('storedLanguage');
+export default createI18nInstance(envName, stored);
diff --git a/src/locales/en-US.json b/src/locales/en-US.json
index e64287d..091d7d9 100644
--- a/src/locales/en-US.json
+++ b/src/locales/en-US.json
@@ -220,11 +220,13 @@
"pageDumps": {
"dumpsAvailableOnBmc": "Dumps available on BMC",
"initiateDump": "Initiate dump",
- "form": {
+ "dumpTypes": {
"bmcDump": "BMC dump",
+ "systemDump": "System dump (disruptive)"
+ },
+ "form": {
"initiateDump": "Initiate dump",
"selectDumpType": "Select dump type",
- "systemDump": "System dump (disruptive)",
"systemDumpInfo": "System dumps will be offloaded to the operating system and will not appear in the table below."
},
"modal": {
diff --git a/src/locales/ka-GE.json b/src/locales/ka-GE.json
index 7427d4f..93425af 100644
--- a/src/locales/ka-GE.json
+++ b/src/locales/ka-GE.json
@@ -220,11 +220,13 @@
"pageDumps": {
"dumpsAvailableOnBmc": "BMC-ზე ხელმისაწვდომი დამპები",
"initiateDump": "დამპის შექმნა",
- "form": {
+ "dumpTypes": {
"bmcDump": "BMC-ის დამპი",
+ "systemDump": "სისტემის დამპი (დროებით წყვეტს სისტემის მუშაობას)"
+ },
+ "form": {
"initiateDump": "დამპის შექმნა",
"selectDumpType": "აირჩით დამპის ტიპი",
- "systemDump": "სისტემის დამპი (დროებით წყვეტს სისტემის მუშაობას)",
"systemDumpInfo": "სისტემის დამპები გადაეცემა ოპერაციულ სისტემას და ქვემოთ ცხრილში არ გამოჩნდება"
},
"modal": {
diff --git a/src/locales/ru-RU.json b/src/locales/ru-RU.json
index 2d7779f..4df30b3 100644
--- a/src/locales/ru-RU.json
+++ b/src/locales/ru-RU.json
@@ -220,11 +220,13 @@
"pageDumps": {
"dumpsAvailableOnBmc": "Дампы доступные на BMC",
"initiateDump": "Создать дамп",
- "form": {
+ "dumpTypes": {
"bmcDump": "Дамп BMC",
+ "systemDump": "Системный дамп (прерывающий работу)"
+ },
+ "form": {
"initiateDump": "Создать дамп",
"selectDumpType": "Выбрать тип дампа",
- "systemDump": "Системный дамп (прерывающий работу)",
"systemDumpInfo": "Системные дампы будут выгружены в операционную систему и не появятся в таблице ниже."
},
"modal": {
diff --git a/src/utilities/objectUtils.js b/src/utilities/objectUtils.js
new file mode 100644
index 0000000..8d4586b
--- /dev/null
+++ b/src/utilities/objectUtils.js
@@ -0,0 +1,31 @@
+/**
+ * Deeply merge two plain objects (or arrays) without mutating the inputs.
+ *
+ * Rules:
+ * - Arrays from the source replace arrays on the target.
+ * - Plain objects are merged recursively.
+ * - Primitive values from the source overwrite the target.
+ */
+export function deepMerge(target, source) {
+ if (typeof target !== 'object' || target === null) return source;
+ if (typeof source !== 'object' || source === null) return target;
+ const output = Array.isArray(target) ? target.slice() : { ...target };
+ Object.keys(source).forEach((key) => {
+ const sourceValue = source[key];
+ const targetValue = output[key];
+ if (Array.isArray(sourceValue)) {
+ output[key] = sourceValue.slice();
+ } else if (
+ typeof sourceValue === 'object' &&
+ sourceValue !== null &&
+ typeof targetValue === 'object' &&
+ targetValue !== null &&
+ !Array.isArray(targetValue)
+ ) {
+ output[key] = deepMerge(targetValue, sourceValue);
+ } else {
+ output[key] = sourceValue;
+ }
+ });
+ return output;
+}
diff --git a/src/views/Logs/Dumps/DumpsForm.vue b/src/views/Logs/Dumps/DumpsForm.vue
index 7e61b96..17257d1 100644
--- a/src/views/Logs/Dumps/DumpsForm.vue
+++ b/src/views/Logs/Dumps/DumpsForm.vue
@@ -55,8 +55,11 @@
$t: useI18n().t,
selectedDumpType: null,
dumpTypeOptions: [
- { value: 'bmc', text: i18n.global.t('pageDumps.form.bmcDump') },
- { value: 'system', text: i18n.global.t('pageDumps.form.systemDump') },
+ { value: 'bmc', text: i18n.global.t('pageDumps.dumpTypes.bmcDump') },
+ {
+ value: 'system',
+ text: i18n.global.t('pageDumps.dumpTypes.systemDump'),
+ },
],
};
},
diff --git a/tests/unit/i18n.locale-alias.spec.js b/tests/unit/i18n.locale-alias.spec.js
new file mode 100644
index 0000000..bfcacbf
--- /dev/null
+++ b/tests/unit/i18n.locale-alias.spec.js
@@ -0,0 +1,36 @@
+// How to run this test in isolation:
+// npm run test:unit -- i18n.locale-alias.spec.js
+
+describe('i18n locale aliases', () => {
+ test('resolves pageLogin.language for en (alias to en-US)', async () => {
+ const { createI18nInstance } = await import('@/i18n');
+ const base = require('@/locales/en-US.json');
+ const loadBase = () => ({ 'en-US': base.default || base });
+ const i18n = createI18nInstance(undefined, 'en', undefined, loadBase);
+ expect(i18n.global.t('pageLogin.language')).toBe('Language');
+ });
+
+ test('resolves pageLogin.language for en-US', async () => {
+ const { createI18nInstance } = await import('@/i18n');
+ const base = require('@/locales/en-US.json');
+ const loadBase = () => ({ 'en-US': base.default || base });
+ const i18n = createI18nInstance(undefined, 'en-US', undefined, loadBase);
+ expect(i18n.global.t('pageLogin.language')).toBe('Language');
+ });
+
+ test('resolves pageLogin.language for ka (alias to ka-GE)', async () => {
+ const { createI18nInstance } = await import('@/i18n');
+ const base = require('@/locales/ka-GE.json');
+ const loadBase = () => ({ 'ka-GE': base.default || base });
+ const i18n = createI18nInstance(undefined, 'ka', undefined, loadBase);
+ expect(i18n.global.t('pageLogin.language')).toBe('ენა');
+ });
+
+ test('resolves pageLogin.language for ka-GE', async () => {
+ const { createI18nInstance } = await import('@/i18n');
+ const base = require('@/locales/ka-GE.json');
+ const loadBase = () => ({ 'ka-GE': base.default || base });
+ const i18n = createI18nInstance(undefined, 'ka-GE', undefined, loadBase);
+ expect(i18n.global.t('pageLogin.language')).toBe('ენა');
+ });
+});
diff --git a/tests/unit/i18n.vendor.spec.js b/tests/unit/i18n.vendor.spec.js
new file mode 100644
index 0000000..93049b9
--- /dev/null
+++ b/tests/unit/i18n.vendor.spec.js
@@ -0,0 +1,44 @@
+// How to run this test in isolation:
+// npm run test:unit -- i18n.vendor.spec.js
+// This verifies vendor overlays (e.g., nvidia shared folder) and vendor-root fallback
+// without requiring component mounts or full app boot.
+describe('i18n vendor overlays', () => {
+ const ORIGINAL_ENV = process.env;
+ beforeEach(() => {
+ jest.resetModules();
+ process.env = { ...ORIGINAL_ENV };
+ // Ensure default locale is deterministic for the test
+ window.localStorage.setItem('storedLanguage', 'en-US');
+ });
+
+ afterEach(() => {
+ process.env = ORIGINAL_ENV;
+ });
+
+ test('falls back to vendor root overlays when env has hyphenated suffix', async () => {
+ // Simulate running in nvidia-gb but having overlays only in src/env/locales/nvidia
+ process.env.VUE_APP_ENV_NAME = 'nvidia-gb';
+
+ const { createI18nInstance } = await import('@/i18n');
+ const vendorEn = require('@/env/locales/nvidia/en-US.json');
+ const stubLoader = () => ({ 'en-US': vendorEn.default || vendorEn });
+ const i18nInstance = createI18nInstance('nvidia-gb', 'en-US', stubLoader);
+
+ // System HGX dump is NVIDIA-specific and defined in src/env/locales/nvidia/en-US.json
+ const translated = i18nInstance.global.t(
+ 'pageDumps.dumpTypes.systemHgxDump',
+ );
+ expect(translated).toBe('System [HGX] dump (disruptive)');
+ });
+
+ test('base locales do not contain vendor-only keys', async () => {
+ process.env.VUE_APP_ENV_NAME = undefined;
+ const { createI18nInstance } = await import('@/i18n');
+ const i18nInstance = createI18nInstance(undefined, 'en-US');
+ const translated = i18nInstance.global.t(
+ 'pageDumps.dumpTypes.systemHgxDump',
+ );
+ // When no env overlays are loaded, accessing vendor-only keys should return the key path
+ expect(translated).toBe('pageDumps.dumpTypes.systemHgxDump');
+ });
+});