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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 0 additions & 8 deletions examples/locales/de-DE.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
8 changes: 0 additions & 8 deletions examples/locales/en-US.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
11 changes: 11 additions & 0 deletions examples/locales/nested/de-DE.arb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"nested": "Geschachtelt",
"itemCount": "{count, plural, =0{Keine Elemente} =1{Ein Element} other{{count} Elemente}}",
"@itemCount": {
"placeholders": {
"count": {
"type": "number"
}
}
}
}
11 changes: 11 additions & 0 deletions examples/locales/nested/en-US.arb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"nested": "Nested",
"itemCount": "{count, plural, =0{No items} =1{One item} other{{count} items}}",
"@itemCount": {
"placeholders": {
"count": {
"type": "number"
}
}
}
}
2 changes: 2 additions & 0 deletions examples/locales/nested/fr-FR.arb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{
}
16 changes: 10 additions & 6 deletions examples/translations/translations.ts
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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,
Expand Down Expand Up @@ -47,13 +48,14 @@ export const exampleTranslation: Translation<ExampleTranslationLocales, Partial<
},
'escapeCharacters': `Folgende Zeichen werden mit \\ im resultiernden string ergänzt \`, \\ und \$ \${`,
'escapedExample': `Folgende Zeichen müssen escaped werden: {, }, '`,
'itemCount': ({ count }): string => {
'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, {
Expand Down Expand Up @@ -135,13 +137,14 @@ export const exampleTranslation: Translation<ExampleTranslationLocales, Partial<
})
},
'escapedExample': `The following characters must be escaped: { } '`,
'itemCount': ({ count }): string => {
'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, {
Expand Down Expand Up @@ -214,3 +217,4 @@ export const exampleTranslation: Translation<ExampleTranslationLocales, Partial<
'yes': `Oui`
}
}

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"url": "git+https://github.com/helpwave/internationlization.git"
},
"license": "MPL-2.0",
"version": "0.2.2",
"version": "0.3.0",
"type": "module",
"files": [
"dist"
Expand Down
21 changes: 18 additions & 3 deletions src/combineTranslation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,35 @@ export function combineTranslation<L extends string, T extends TranslationEntrie
values?: T[K] extends (...args: infer P) => unknown ? Exact<P[0], P[0]> : 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
} else if (typeof msg === 'function') {
return msg(values as never)
}
}
console.warn(`Missing key or locale for locale "${locale}" and key "${String(key)}" in all translations`)
if(!foundLocale) {
console.warn(`Did not find locale "${locale}" in all translations`)
} else if(!foundKey) {
console.warn(`Did not find key [${String(key)}] for locale "${locale}" in all translations`)
}

return `{{${locale}:${String(key)}}}`
}

Expand Down
6 changes: 4 additions & 2 deletions src/icu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ function parse(tokens: ICUToken[]): ICUASTNode {

switch (token.type) {
case 'ESCAPE':
if (context.last.type === 'ESCAPE') {
if (context.last?.type === 'ESCAPE') {
pushFunction(escapeCharacter)
}
context.state.pop()
Expand Down Expand Up @@ -245,6 +245,9 @@ function parse(tokens: ICUToken[]): ICUASTNode {
} else if (state.expect === 'optionNameOrReplaceClose') {
context.state.pop()
const prevState = getState()
if(!state.operatorName) {
throw Error(`ICU Parser: Internal Parser Error. Operator name undefined in state.`)
}
const node: ICUASTNode = {
type: 'OptionReplace',
variableName: state.variableName,
Expand Down Expand Up @@ -280,7 +283,6 @@ function parse(tokens: ICUToken[]): ICUASTNode {
} else if (state.expect === 'optionContentOrClose') {
pushText(',', state.subtree)
} else {
console.log(context.state[context.state.length - 1])
throw Error(`ICU Parser: Invalid placement of "," in replacement function.`)
}
break
Expand Down
4 changes: 1 addition & 3 deletions src/scripts/compile-arb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ function parseArgs() {


function printHelp() {
console.log(`
console.info(`
Usage: i18n-compile [options]

Options:
Expand Down Expand Up @@ -126,8 +126,6 @@ if (name.length < 1 || name[0].toUpperCase() === name[0]) {
console.warn(`The name ${parsed.name} cannot start with a number.`)
}

console.log(name)

/* ------------------ prompts ------------------ */

function askQuestion(query: string): Promise<string> {
Expand Down
5 changes: 4 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
export type TranslationEntry = string | ((values: Record<string, unknown>) => 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<V extends object = any> = string | ((values: V) => string)

export type TranslationEntries = Record<string, TranslationEntry>

Expand Down
76 changes: 76 additions & 0 deletions tests/combiner.test.ts
Original file line number Diff line number Diff line change
@@ -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<Languages, TestTranslation>([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<Languages, TestTranslation>([{ de: { hello: 'Hallo' } }], 'en')
expect(t('hello')).toBe('{{en:hello}}')

expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining('Did not find locale')
)
})
})
1 change: 0 additions & 1 deletion tests/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
Expand Down
47 changes: 47 additions & 0 deletions tests/typing.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@
"jsx": "react-jsx",
"esModuleInterop": true,
"skipLibCheck": true,
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
},
}
},
"include": [
"src",
Expand Down