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
1 change: 1 addition & 0 deletions .kiro/specs/translation-nested-keys-fix/.config.kiro
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"specId": "16317c2a-e85f-4e5f-a27e-7b7f9e0609b1", "workflowType": "requirements-first", "specType": "bugfix"}
73 changes: 73 additions & 0 deletions .kiro/specs/translation-nested-keys-fix/bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Bugfix Requirements Document

## Introduction

The `getTranslation` function in `src/locales/translationManager.ts` replaces `{{key}}` placeholders using the regex `/\{\{(\w+)\}\}/g`. The `\w+` pattern only matches word characters (`[a-zA-Z0-9_]`), which excludes the dot character. As a result, nested interpolation keys like `{{user.name}}` are never matched and their placeholders are left verbatim in the output string. Additionally, when a param key referenced in a template is absent from the provided params object, the placeholder is silently left in the output with no developer notification, making debugging difficult.

## Bug Analysis

### Current Behavior (Defect)

1.1 WHEN a translation template contains a dot-separated placeholder such as `{{user.name}}` AND params contains a nested object `{ user: { name: 'Alice' } }` THEN the system leaves `{{user.name}}` unreplaced in the returned string

1.2 WHEN a translation template contains a placeholder whose key is not present in the provided params object THEN the system silently leaves the placeholder in the returned string without any warning

### Expected Behavior (Correct)

2.1 WHEN a translation template contains a dot-separated placeholder such as `{{user.name}}` AND params contains a matching nested object `{ user: { name: 'Alice' } }` THEN the system SHALL resolve the value by traversing the nested object and return the string with `{{user.name}}` replaced by `Alice`

2.2 WHEN a translation template contains a placeholder whose key is not present in the provided params object THEN the system SHALL emit a `console.warn` (or equivalent logger warning) identifying the missing key AND leave the original placeholder visible in the returned string

### Unchanged Behavior (Regression Prevention)

3.1 WHEN a translation template contains a flat (non-nested) placeholder such as `{{name}}` AND params contains the matching key `{ name: 'Alice' }` THEN the system SHALL CONTINUE TO replace the placeholder and return the correct interpolated string

3.2 WHEN params is undefined or not provided THEN the system SHALL CONTINUE TO return the raw translation string without modification

3.3 WHEN a translation template contains multiple placeholders of any kind THEN the system SHALL CONTINUE TO replace all of them in a single pass

3.4 WHEN the translation key path does not exist in the translations object THEN the system SHALL CONTINUE TO return the original key string unchanged

---

## Bug Condition Derivation

### Bug Condition Function

```pascal
FUNCTION isBugCondition(X)
INPUT: X of type { template: string, params: Record<string, any> }
OUTPUT: boolean

// Returns true when the placeholder contains a dot (nested key)
RETURN template CONTAINS pattern /\{\{[\w]+\.[\w.]+\}\}/
OR (template CONTAINS pattern /\{\{[\w.]+\}\}/ AND referenced key NOT IN params)
END FUNCTION
```

### Fix Checking Property

```pascal
// Property: Fix Checking — Nested Key Resolution
FOR ALL X WHERE isBugCondition(X) AND X is nested key case DO
result ← getTranslation'(translations, key, X.params)
ASSERT result does NOT contain the original placeholder
ASSERT result contains the resolved nested value
END FOR

// Property: Fix Checking — Missing Key Warning
FOR ALL X WHERE isBugCondition(X) AND X is missing key case DO
result ← getTranslation'(translations, key, X.params)
ASSERT console.warn was called with a message referencing the missing key
ASSERT result contains the original placeholder (visible to developer)
END FOR
```

### Preservation Checking Property

```pascal
// Property: Preservation Checking
FOR ALL X WHERE NOT isBugCondition(X) DO
ASSERT getTranslation(translations, key, X.params) = getTranslation'(translations, key, X.params)
END FOR
```
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
4 changes: 2 additions & 2 deletions src/hooks/useInternationalization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export function I18nProvider({

// Translation function
const t = useCallback(
(key: string, params?: Record<string, string | number>) => {
(key: string, params?: Record<string, unknown>) => {
return getTranslation(translations, key, params);
},
[translations],
Expand Down Expand Up @@ -247,7 +247,7 @@ export function useInternationalization(): I18nContextValue {
if (!context) {
const fallbackLanguage: LanguageCode = DEFAULT_LANGUAGE;
const fallbackPreferences = getCulturalPreferences(fallbackLanguage);
const fallbackT = (key: string, params?: Record<string, string | number>) =>
const fallbackT = (key: string, params?: Record<string, unknown>) =>
getTranslation({}, key, params);

return {
Expand Down
161 changes: 161 additions & 0 deletions src/locales/translationManager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/**
* Tests for getTranslation parameter interpolation in translationManager.ts
*
* Covers:
* - Flat key replacement (regression)
* - Nested dot-separated key replacement (bug fix)
* - Missing param key warning (bug fix)
* - No-params passthrough (regression)
* - Multiple placeholders in one template (regression)
* - Unknown translation key passthrough (regression)
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { getTranslation } from './translationManager';
import type { Translations } from './types';

// ---------------------------------------------------------------------------
// Shared fixture
// ---------------------------------------------------------------------------

const translations: Translations = {
greetings: {
hello: 'Hello {{name}}',
nested: 'Hello {{user.name}}',
deepNested: 'Hello {{user.profile.displayName}}',
multi: 'Hello {{user.name}}, you have {{count}} messages',
plain: 'No placeholders here',
},
};

// ---------------------------------------------------------------------------
// Helper to spy on console.warn
// ---------------------------------------------------------------------------

let warnSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});

afterEach(() => {
warnSpy.mockRestore();
});

// ---------------------------------------------------------------------------
// Regression: flat key replacement still works
// ---------------------------------------------------------------------------

describe('flat key replacement (regression)', () => {
it('replaces a simple flat placeholder', () => {
const result = getTranslation(translations, 'greetings.hello', { name: 'Alice' });
expect(result).toBe('Hello Alice');
});

it('does not warn when flat key is present', () => {
getTranslation(translations, 'greetings.hello', { name: 'Alice' });
expect(warnSpy).not.toHaveBeenCalled();
});
});

// ---------------------------------------------------------------------------
// Bug fix: nested dot-separated key replacement
// ---------------------------------------------------------------------------

describe('nested dot-separated key replacement (bug fix)', () => {
it('resolves a two-level nested key', () => {
const result = getTranslation(translations, 'greetings.nested', {
user: { name: 'Alice' },
});
expect(result).toBe('Hello Alice');
});

it('resolves a three-level nested key', () => {
const result = getTranslation(translations, 'greetings.deepNested', {
user: { profile: { displayName: 'Alice Wonder' } },
});
expect(result).toBe('Hello Alice Wonder');
});

it('does not warn when nested key resolves successfully', () => {
getTranslation(translations, 'greetings.nested', { user: { name: 'Alice' } });
expect(warnSpy).not.toHaveBeenCalled();
});
});

// ---------------------------------------------------------------------------
// Bug fix: missing interpolation key warns and leaves placeholder visible
// ---------------------------------------------------------------------------

describe('missing interpolation key (bug fix)', () => {
it('leaves the placeholder in the output when nested key is missing', () => {
const result = getTranslation(translations, 'greetings.nested', {
user: {},
});
expect(result).toBe('Hello {{user.name}}');
});

it('emits a console.warn mentioning the missing key path', () => {
getTranslation(translations, 'greetings.nested', { user: {} });
expect(warnSpy).toHaveBeenCalledOnce();
expect(warnSpy.mock.calls[0][0]).toContain('user.name');
});

it('leaves the placeholder when the top-level param object is missing the key', () => {
const result = getTranslation(translations, 'greetings.hello', {});
expect(result).toBe('Hello {{name}}');
});

it('emits a console.warn for a missing flat key', () => {
getTranslation(translations, 'greetings.hello', {});
expect(warnSpy).toHaveBeenCalledOnce();
expect(warnSpy.mock.calls[0][0]).toContain('name');
});
});

// ---------------------------------------------------------------------------
// Regression: no params → raw string returned unchanged
// ---------------------------------------------------------------------------

describe('no params passthrough (regression)', () => {
it('returns the template string unchanged when params is undefined', () => {
const result = getTranslation(translations, 'greetings.nested');
expect(result).toBe('Hello {{user.name}}');
expect(warnSpy).not.toHaveBeenCalled();
});

it('returns a plain string unchanged when there are no placeholders', () => {
const result = getTranslation(translations, 'greetings.plain');
expect(result).toBe('No placeholders here');
});
});

// ---------------------------------------------------------------------------
// Regression: multiple placeholders resolved in a single pass
// ---------------------------------------------------------------------------

describe('multiple placeholders in one template (regression)', () => {
it('replaces all placeholders including nested and flat in one pass', () => {
const result = getTranslation(translations, 'greetings.multi', {
user: { name: 'Alice' },
count: 5,
});
expect(result).toBe('Hello Alice, you have 5 messages');
});

it('warns once per missing placeholder when multiple are absent', () => {
getTranslation(translations, 'greetings.multi', {});
expect(warnSpy).toHaveBeenCalledTimes(2);
});
});

// ---------------------------------------------------------------------------
// Regression: unknown translation key returns key unchanged
// ---------------------------------------------------------------------------

describe('unknown translation key passthrough (regression)', () => {
it('returns the key string when the translation path does not exist', () => {
const result = getTranslation(translations, 'greetings.nonExistent', { name: 'Alice' });
expect(result).toBe('greetings.nonExistent');
});
});
21 changes: 18 additions & 3 deletions src/locales/translationManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export async function preloadTranslations(languages: LanguageCode[]): Promise<vo
export function getTranslation(
translations: Translations,
key: string,
params?: Record<string, string | number>,
params?: Record<string, unknown>,
): string {
const keys = key.split('.');
let value: any = translations;
Expand All @@ -83,9 +83,24 @@ export function getTranslation(
}

// Replace parameters in translation string
// Regex supports dot-separated nested keys, e.g. {{user.name}}
if (params) {
return value.replace(/\{\{(\w+)\}\}/g, (match, paramKey) => {
return params[paramKey]?.toString() || match;
return value.replace(/\{\{([\w][\w.]*)\}\}/g, (match, paramPath: string) => {
const resolved = paramPath.split('.').reduce((obj: unknown, key: string) => {
if (obj !== null && obj !== undefined && typeof obj === 'object') {
return (obj as Record<string, unknown>)[key];
}
return undefined;
}, params as unknown);

if (resolved === undefined || resolved === null) {
console.warn(
`[translationManager] Missing interpolation key "${paramPath}" in translation template.`,
);
return match; // leave placeholder visible
}

return String(resolved);
});
}

Expand Down
Loading