diff --git a/.kiro/specs/translation-nested-keys-fix/.config.kiro b/.kiro/specs/translation-nested-keys-fix/.config.kiro new file mode 100644 index 00000000..770dabf9 --- /dev/null +++ b/.kiro/specs/translation-nested-keys-fix/.config.kiro @@ -0,0 +1 @@ +{"specId": "16317c2a-e85f-4e5f-a27e-7b7f9e0609b1", "workflowType": "requirements-first", "specType": "bugfix"} \ No newline at end of file diff --git a/.kiro/specs/translation-nested-keys-fix/bugfix.md b/.kiro/specs/translation-nested-keys-fix/bugfix.md new file mode 100644 index 00000000..5045d341 --- /dev/null +++ b/.kiro/specs/translation-nested-keys-fix/bugfix.md @@ -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 } + 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 +``` diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1 @@ +{} diff --git a/src/hooks/useInternationalization.tsx b/src/hooks/useInternationalization.tsx index d3e167e2..79605fe0 100644 --- a/src/hooks/useInternationalization.tsx +++ b/src/hooks/useInternationalization.tsx @@ -149,7 +149,7 @@ export function I18nProvider({ // Translation function const t = useCallback( - (key: string, params?: Record) => { + (key: string, params?: Record) => { return getTranslation(translations, key, params); }, [translations], @@ -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) => + const fallbackT = (key: string, params?: Record) => getTranslation({}, key, params); return { diff --git a/src/locales/translationManager.test.ts b/src/locales/translationManager.test.ts new file mode 100644 index 00000000..ea0316be --- /dev/null +++ b/src/locales/translationManager.test.ts @@ -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; + +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'); + }); +}); diff --git a/src/locales/translationManager.ts b/src/locales/translationManager.ts index da19ffdf..262ce9f2 100644 --- a/src/locales/translationManager.ts +++ b/src/locales/translationManager.ts @@ -65,7 +65,7 @@ export async function preloadTranslations(languages: LanguageCode[]): Promise, + params?: Record, ): string { const keys = key.split('.'); let value: any = translations; @@ -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)[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); }); }