diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c3159f..e732517 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2025-11-24 + +### Added +- add tests for typing and combining translations + +### Removed +- Fix typing to be compatible with strict typescript + ## [0.2.2] - 2025-11-24 ### Changed diff --git a/examples/locales/de-DE.arb b/examples/locales/de-DE.arb index 6ce5ef4..57e6d96 100644 --- a/examples/locales/de-DE.arb +++ b/examples/locales/de-DE.arb @@ -8,14 +8,6 @@ } } }, - "itemCount": "{count, plural, =0{Keine Elemente} =1{Ein Element} other{{count} Elemente}}", - "@itemCount": { - "placeholders": { - "count": { - "type": "number" - } - } - }, "accountStatus": "{status, select, active{Aktiv} inactive{Inaktiv} other{Unbekannt}}", "@accountStatus": { "placeholders": { diff --git a/examples/locales/en-US.arb b/examples/locales/en-US.arb index 9e5890f..da2af79 100644 --- a/examples/locales/en-US.arb +++ b/examples/locales/en-US.arb @@ -8,14 +8,6 @@ } } }, - "itemCount": "{count, plural, =0{No items} =1{One item} other{{count} items}}", - "@itemCount": { - "placeholders": { - "count": { - "type": "number" - } - } - }, "accountStatus": "{status, select, active{Active} inactive{Inactive} other{Unknown}}", "@accountStatus": { "placeholders": { diff --git a/examples/locales/nested/de-DE.arb b/examples/locales/nested/de-DE.arb new file mode 100644 index 0000000..7996c75 --- /dev/null +++ b/examples/locales/nested/de-DE.arb @@ -0,0 +1,11 @@ +{ + "nested": "Geschachtelt", + "itemCount": "{count, plural, =0{Keine Elemente} =1{Ein Element} other{{count} Elemente}}", + "@itemCount": { + "placeholders": { + "count": { + "type": "number" + } + } + } +} diff --git a/examples/locales/nested/en-US.arb b/examples/locales/nested/en-US.arb new file mode 100644 index 0000000..e09bb07 --- /dev/null +++ b/examples/locales/nested/en-US.arb @@ -0,0 +1,11 @@ +{ + "nested": "Nested", + "itemCount": "{count, plural, =0{No items} =1{One item} other{{count} items}}", + "@itemCount": { + "placeholders": { + "count": { + "type": "number" + } + } + } +} diff --git a/examples/locales/nested/fr-FR.arb b/examples/locales/nested/fr-FR.arb new file mode 100644 index 0000000..2c63c08 --- /dev/null +++ b/examples/locales/nested/fr-FR.arb @@ -0,0 +1,2 @@ +{ +} diff --git a/examples/translations/translations.ts b/examples/translations/translations.ts index 6975fb3..862180b 100644 --- a/examples/translations/translations.ts +++ b/examples/translations/translations.ts @@ -1,9 +1,9 @@ // AUTO-GENERATED. DO NOT EDIT. /* eslint-disable @stylistic/quote-props */ /* eslint-disable no-useless-escape */ - -import type { Translation } from 'src/index' -import { TranslationGen } from 'src/index' +/* eslint-disable @typescript-eslint/no-unused-vars */ +import type { Translation } from '@helpwave/internationalization' +import { TranslationGen } from '@helpwave/internationalization' export const exampleTranslationLocales = ['de-DE', 'en-US', 'fr-FR'] as const @@ -14,7 +14,8 @@ export type ExampleTranslationEntries = { 'ageCategory': (values: { ageGroup: string }) => string, 'escapeCharacters': string, 'escapedExample': string, - 'itemCount': (values: { count: number }) => string, + 'nested.itemCount': (values: { count: number }) => string, + 'nested.nested': string, 'nestedSelectPlural': (values: { gender: string, count: number }) => string, 'passwordStrength': (values: { strength: string }) => string, 'priceInfo': (values: { price: number, currency: string }) => string, @@ -47,13 +48,14 @@ export const exampleTranslation: Translation { + 'nested.itemCount': ({ count }): string => { return TranslationGen.resolveSelect(count, { '=0': `Keine Elemente`, '=1': `Ein Element`, 'other': `${count} Elemente`, }) }, + 'nested.nested': `Geschachtelt`, 'nestedSelectPlural': ({ gender, count }): string => { return TranslationGen.resolveSelect(gender, { 'male': TranslationGen.resolveSelect(count, { @@ -135,13 +137,14 @@ export const exampleTranslation: Translation { + 'nested.itemCount': ({ count }): string => { return TranslationGen.resolveSelect(count, { '=0': `No items`, '=1': `One item`, 'other': `${count} items`, }) }, + 'nested.nested': `Nested`, 'nestedSelectPlural': ({ gender, count }): string => { return TranslationGen.resolveSelect(gender, { 'male': TranslationGen.resolveSelect(count, { @@ -214,3 +217,4 @@ export const exampleTranslation: Translation unknown ? Exact : never ): string { const usedTranslations = Array.isArray(translations) ? translations : [translations] + let foundLocale = false + let foundKey = false for (const translation of usedTranslations) { const localizedTranslation = translation[locale] - if (!localizedTranslation) continue + if (!localizedTranslation) { + continue + } else { + foundLocale = true + } const msg = localizedTranslation[key] - if (!msg) continue + if (!msg) { + continue + } else { + foundKey = true + } if (typeof msg === 'string') { return msg @@ -28,7 +38,12 @@ export function combineTranslation { diff --git a/src/types.ts b/src/types.ts index bbd86e8..a0b9ac5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,7 @@ -export type TranslationEntry = string | ((values: Record) => string) +// The 'any' is required as strict typescript otherwise complains about different function parameters in +// the TranslationEntries object +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type TranslationEntry = string | ((values: V) => string) export type TranslationEntries = Record diff --git a/tests/combiner.test.ts b/tests/combiner.test.ts new file mode 100644 index 0000000..9297538 --- /dev/null +++ b/tests/combiner.test.ts @@ -0,0 +1,76 @@ +import { combineTranslation } from '../src' + +type TestTranslation = { + hello: string, + goodbye: string, + greet: (values: { name: string }) => string, +} + +type Languages = 'en'|'de' + +const enTranslation = { + en: { + hello: 'Hello', + goodbye: 'Goodbye', + greet: ({ name }: { name: string }) => `Hello, ${name}!`, + }, +} + +const deTranslation = { + de: { + hello: 'Hallo', + goodbye: 'Auf Wiedersehen', + greet: ({ name }: { name: string }) => `Hallo, ${name}!`, + }, +} + +describe('combineTranslation', () => { + let originalWarn: typeof console.warn + + beforeAll(() => { + // Save the original console.warn + originalWarn = console.warn + }) + + afterAll(() => { + // Restore it after tests + console.warn = originalWarn + }) + + test('returns string translations correctly', () => { + const t = combineTranslation(enTranslation, 'en') + expect(t('hello')).toBe('Hello') + expect(t('goodbye')).toBe('Goodbye') + }) + + test('returns function translations correctly', () => { + const t = combineTranslation(enTranslation, 'en') + expect(t('greet', { name: 'Alice' })).toBe('Hello, Alice!') + }) + + test('supports multiple translation objects', () => { + const t = combineTranslation([enTranslation, deTranslation], 'de') + expect(t('hello')).toBe('Hallo') + expect(t('greet', { name: 'Bob' })).toBe('Hallo, Bob!') + }) + + test('falls back for missing keys', () => { + console.warn = jest.fn() + const t = combineTranslation([{ en: { hello: 'Hi' } }], 'en') + expect(t('goodbye')).toBe('{{en:goodbye}}') + + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('Did not find key') + ) + }) + + test('falls back for missing locales', () => { + console.warn = jest.fn() + const t = combineTranslation([{ de: { hello: 'Hallo' } }], 'en') + expect(t('hello')).toBe('{{en:hello}}') + + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('Did not find locale') + ) + }) +}) diff --git a/tests/parser.test.ts b/tests/parser.test.ts index 83f7c5c..6f85661 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -452,7 +452,6 @@ describe('ICU Parser', () => { for (const example of examples) { test(`${example.name}:`, () => { const result = ICUUtil.parse(example.input) - console.log(result) expect(result).toEqual(example.result) }) } diff --git a/tests/typing.test.ts b/tests/typing.test.ts new file mode 100644 index 0000000..c8a1f8f --- /dev/null +++ b/tests/typing.test.ts @@ -0,0 +1,47 @@ +import type { Translation } from '../src' + +type TestTranslation = { + entry1: string, + entry2: string, + function1: (values: { name: string, author: string }) => string, + function2: (values: { status: string }) => string, +} + +// The type we want to validate +type T = Translation<'en' | 'de', TestTranslation> + +// Runtime type guard for Jest +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function isValidTranslation(obj: any): obj is T { + const locales = ['en', 'de'] + return locales.every(locale => { + const localeObj = obj[locale] + return ( + localeObj && + typeof localeObj.entry1 === 'string' && + typeof localeObj.entry2 === 'string' && + typeof localeObj.function1 === 'function' && + typeof localeObj.function2 === 'function' + ) + }) +} + +// Example translation object +const translationCandidate: T = { + en: { + entry1: 'Hello', + entry2: 'World', + function1: ({ name, author }) => `${name} by ${author}`, + function2: ({ status }) => `Status: ${status}`, + }, + de: { + entry1: 'Hallo', + entry2: 'Welt', + function1: ({ name, author }) => `${name} von ${author}`, + function2: ({ status }) => `Status: ${status}`, + }, +} + +test('Typing and type shape', () => { + expect(isValidTranslation(translationCandidate)).toBe(true) +}) \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 09f1b7c..bb0a426 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,10 +10,11 @@ "jsx": "react-jsx", "esModuleInterop": true, "skipLibCheck": true, + "strict": true, "baseUrl": ".", "paths": { "@/*": ["./*"] - }, + } }, "include": [ "src",