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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ 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.4.0] - 2025-11-26

### Added
- Added tests for code compiler
- Added more examples
- Added type parsing corrections based on variable names and translate types from other languages like int, or float

### Changed
- Split `lex`, `parse`, and `compile` into different files
- Moved compile function out of compile-arb-script
- Limit exports in index file
- Updated [README.md](README.md)

### Security
- Prevent code injections through typing variables

## [0.3.0] - 2025-11-24

### Added
Expand Down
41 changes: 37 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,21 @@ Create a `.arb` file with your translations:
And get a translation:

```typescript
import {combineTranslation} from "./combineTranslation";
import {combineTranslation} from "@helpwave/internationalization";
import {Translation} from "@helpwave/internationalization";

translations["en-US"].priceInfo(price, currency)
translations["en-US"].priceInfo?.({price, currency})

const t = combineTranslation([translation1, translation2], "en-US")
// v still typesafe on both function parameters
type ExtensionType = { name: string }
const extension: Translation<"fr-FR", ExtensionType> = {
"fr-FR": {
name: "Charlemagne"
}
}

const t = combineTranslation([translations, extension], "en-US")
// typesafe on both function parameters
// and handles errors automatically -> return = {{${locale}:${String(key)}}}
t("priceInfo", { price, currency })
```

Expand Down Expand Up @@ -72,4 +81,28 @@ Rebuild the examples:
```bash
npm run build
node dist/scripts/compile-arb.js --force -i ./examples/locales -o ./examples/translations/translations.ts -n "exampleTranslation"
```

React hook example:
```typescript
type UseHidetideTranslationOverwrites = {
locale?: HightideTranslationLocales,
}

type HidetideTranslationExtension<L extends string, T extends TranslationEntries>
= PartialTranslationExtension<L, HightideTranslationLocales, T, HightideTranslationEntries>

export function useHightideTranslation<L extends string, T extends TranslationEntries>(
extensions?: SingleOrArray<HidetideTranslationExtension<L,T>>,
overwrites?: UseHidetideTranslationOverwrites
) {
const { locale: inferredLocale } = useLocale()
const locale = overwrites?.locale ?? inferredLocale
const translationExtensions = ArrayUtil.resolveSingleOrArray(extensions)

return combineTranslation<L | HightideTranslationLocales, T & HightideTranslationEntries>([
...translationExtensions,
hightideTranslation as HidetideTranslationExtension<L,T>
], locale)
}
```
11 changes: 10 additions & 1 deletion examples/locales/de-DE.arb
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,14 @@
}
}
},
"escapeCharacters": "Folgende Zeichen werden mit '\\' im resultiernden string ergänzt '`', '\\' und '$' $'{'"
"escapeCharacters": "Folgende Zeichen werden mit '\\' im resultiernden string ergänzt '`', '\\' und '$' $'{'",
"nWard": "{count, plural, =1{# Station} other{# Stationen}}",
"@nWard": {
"placeholders": {
"count": {
"type": "number"
}
}
},
"templateJSEscape": "` '${}'"
}
29 changes: 19 additions & 10 deletions examples/translations/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ export type ExampleTranslationEntries = {
'nested.itemCount': (values: { count: number }) => string,
'nested.nested': string,
'nestedSelectPlural': (values: { gender: string, count: number }) => string,
'nWard': (values: { count: number }) => string,
'passwordStrength': (values: { strength: string }) => string,
'priceInfo': (values: { price: number, currency: string }) => string,
'taskDeadline': (values: { deadline: string }) => string,
'templateJSEscape': string,
'userGreeting': (values: { gender: string, name: string }) => string,
'welcomeMessage': (values: { gender: string, name: string, count: number }) => string,
'goodbye': string,
Expand Down Expand Up @@ -49,7 +51,7 @@ export const exampleTranslation: Translation<ExampleTranslationLocales, Partial<
'escapeCharacters': `Folgende Zeichen werden mit \\ im resultiernden string ergänzt \`, \\ und \$ \${`,
'escapedExample': `Folgende Zeichen müssen escaped werden: {, }, '`,
'nested.itemCount': ({ count }): string => {
return TranslationGen.resolveSelect(count, {
return TranslationGen.resolvePlural(count, {
'=0': `Keine Elemente`,
'=1': `Ein Element`,
'other': `${count} Elemente`,
Expand All @@ -58,23 +60,29 @@ export const exampleTranslation: Translation<ExampleTranslationLocales, Partial<
'nested.nested': `Geschachtelt`,
'nestedSelectPlural': ({ gender, count }): string => {
return TranslationGen.resolveSelect(gender, {
'male': TranslationGen.resolveSelect(count, {
'male': TranslationGen.resolvePlural(count, {
'=0': `Keine Nachrichten`,
'=1': `Eine Nachricht`,
'other': `${count} Nachrichten`,
}),
'female': TranslationGen.resolveSelect(count, {
'female': TranslationGen.resolvePlural(count, {
'=0': `Keine Nachrichten`,
'=1': `Eine Nachricht`,
'other': `${count} Nachrichten`,
}),
'other': TranslationGen.resolveSelect(count, {
'other': TranslationGen.resolvePlural(count, {
'=0': `Keine Nachrichten`,
'=1': `Eine Nachricht`,
'other': `${count} Nachrichten`,
}),
})
},
'nWard': ({ count }): string => {
return TranslationGen.resolvePlural(count, {
'=1': `${count} Station`,
'other': `${count} Stationen`,
})
},
'passwordStrength': ({ strength }): string => {
return TranslationGen.resolveSelect(strength, {
'weak': `Schwach`,
Expand All @@ -96,6 +104,7 @@ export const exampleTranslation: Translation<ExampleTranslationLocales, Partial<
'taskDeadline': ({ deadline }): string => {
return `Die Aufgabe muss bis ${deadline} erledigt sein.`
},
'templateJSEscape': `\` \${}`,
'userGreeting': ({ gender, name }): string => {
return TranslationGen.resolveSelect(gender, {
'male': `Hallo, ${name}!`,
Expand All @@ -111,7 +120,7 @@ export const exampleTranslation: Translation<ExampleTranslationLocales, Partial<
'other': `Willkommen, Person!`,
})
_out += ` Du hast `
_out += TranslationGen.resolveSelect(count, {
_out += TranslationGen.resolvePlural(count, {
'=0': `keine neuen Nachrichten`,
'=1': `eine neue Nachricht`,
'other': `${count} neue Nachrichten`,
Expand All @@ -138,7 +147,7 @@ export const exampleTranslation: Translation<ExampleTranslationLocales, Partial<
},
'escapedExample': `The following characters must be escaped: { } '`,
'nested.itemCount': ({ count }): string => {
return TranslationGen.resolveSelect(count, {
return TranslationGen.resolvePlural(count, {
'=0': `No items`,
'=1': `One item`,
'other': `${count} items`,
Expand All @@ -147,17 +156,17 @@ export const exampleTranslation: Translation<ExampleTranslationLocales, Partial<
'nested.nested': `Nested`,
'nestedSelectPlural': ({ gender, count }): string => {
return TranslationGen.resolveSelect(gender, {
'male': TranslationGen.resolveSelect(count, {
'male': TranslationGen.resolvePlural(count, {
'=0': `No messages`,
'=1': `One message`,
'other': `${count} messages`,
}),
'female': TranslationGen.resolveSelect(count, {
'female': TranslationGen.resolvePlural(count, {
'=0': `No messages`,
'=1': `One message`,
'other': `${count} messages`,
}),
'other': TranslationGen.resolveSelect(count, {
'other': TranslationGen.resolvePlural(count, {
'=0': `No messages`,
'=1': `One message`,
'other': `${count} messages`,
Expand Down Expand Up @@ -200,7 +209,7 @@ export const exampleTranslation: Translation<ExampleTranslationLocales, Partial<
'other': `Welcome, person!`,
})
_out += ` You have `
_out += TranslationGen.resolveSelect(count, {
_out += TranslationGen.resolvePlural(count, {
'=0': `no new messages`,
'=1': `one new message`,
'other': `${count} new messages`,
Expand Down
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.3.0",
"version": "0.4.0",
"type": "module",
"files": [
"dist"
Expand Down
105 changes: 105 additions & 0 deletions src/compile-to-code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import type { ICUASTNode } from './parse'

function escapeForTemplateJS(s: string): string {
return s
.replace(/\\/g, `\\\\`)
.replace(/`/g, `\\\``)
.replace(/\$/g, `\\$`)
}

type CompileContextResult = {
numberParam?: string,
inNode: boolean,
indentLevel: number,
isOnlyText: boolean,
}

type CompileContext = Partial<CompileContextResult>

const defaultCompileContext: CompileContextResult = {
indentLevel: 0,
inNode: false,
isOnlyText: false,
}

export function compileToCode(
node: ICUASTNode,
initialContext?: CompileContext
): string[] {
const context: CompileContextResult = { ...defaultCompileContext, ...initialContext }
const lines: string[] = []
let currentLine = ''

function indent(level: number = context.indentLevel) {
return ' '.repeat(level * 2)
}

function flushCurrent() {
if (currentLine) {
if (context.inNode) {
lines.push(currentLine)
} else {
const nextLine = `${indent()}\`${currentLine}\``
lines.push(nextLine)
}
}
currentLine = ''
}

switch (node.type) {
case 'Text':
currentLine += escapeForTemplateJS(node.value)
break
case 'NumberField':
if (context.numberParam) {
currentLine += `$\{${context.numberParam}}`
} else {
currentLine += `{${context.numberParam}}`
}
break
case 'SimpleReplace':
currentLine += `$\{${node.variableName}}`
break
case 'Node': {
for (const partNode of node.parts) {
const compiled = compileToCode(partNode, { ...context, inNode: true })
if (partNode.type === 'OptionReplace' || partNode.type === 'Node') {
flushCurrent()
lines.push(...compiled)
} else {
currentLine += compiled[0]
}
}
break
}
case 'OptionReplace': {
if (context.isOnlyText) {
currentLine += `{${node.variableName}, ${node.operatorName}, {options}}`
break
}
flushCurrent()
const resolver = node.operatorName === 'plural' ?
'TranslationGen.resolvePlural': 'TranslationGen.resolveSelect'
lines.push(`${resolver}(${node.variableName}, {`)

const entries = Object.entries(node.options)

for (const [key, entryNode] of entries) {
const expr = compileToCode(entryNode, {
...context,
numberParam: node.operatorName === 'plural' ? node.variableName : context.numberParam,
indentLevel: context.indentLevel + 1,
inNode: false,
})
if (expr.length === 0) continue
lines.push(indent(context.indentLevel + 1) + `'${key}': ${expr[0].trimStart()}`, ...expr.slice(1))
lines[lines.length - 1] += ','
}

lines.push(indent() + `})`)
return lines
}
}
flushCurrent()
return lines
}
Loading