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
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,19 @@ 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.1.0] - 2025-11-2025
## [0.2.0] - 2025-11-23

### Added
- Add argument to change the generated translation name `-n | --name`
- Add types `TranslationExtension` and `PartialTranslationExtension` to simplify extending translations

### Changed
- Updated README.md example

### Fixed
- Fixed the proper escaping of \, ` and $

## [0.1.0] - 2025-11-21

### Added
- ICU lexer, parser and compiler
Expand Down
24 changes: 16 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ helpwaves package for internationalization that creates localized and typesafe t
## Usage
Create a `.arb` file with your translations:
```json
"priceInfo": "The price is {price} €{currency, select, usd{USD} eur{EUR} other{}}.",
"@priceInfo": {
"placeholders": {
"price": {
"type": "number"
},
"currency": {}
{
"priceInfo": "The price is {price}{currency, select, usd{$USD} eur{€} other{}}.",
"@priceInfo": {
"placeholders": {
"price": {
"type": "number"
},
"currency": {}
}
}
}
```
Expand Down Expand Up @@ -64,4 +66,10 @@ Options:
The lexer, parser and compiler are all tested with jest, see [our tests](/tests)

## Examples
Example translation files and the resulting translation can be found in the [examples folder](/examples).
Example translation files and the resulting translation can be found in the [examples folder](/examples).

Rebuild the examples:
```bash
npm run build
node dist/scripts/compile-arb.js --force -i ./examples/locales -o ./examples/translations/translations.ts -n "exampleTranslation"
```
7 changes: 4 additions & 3 deletions examples/locales/de-DE.arb
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
}
}
},
"priceInfo": "Der Preis beträgt {price}{currency, select, usd{USD} eur{EUR} other{}}.",
"priceInfo": "Der Preis beträgt {price}{currency, select, usd{$USD} eur{} other{}}.",
"@priceInfo": {
"placeholders": {
"price": {
Expand All @@ -63,7 +63,7 @@
}
}
},
"escapedExample": "Folgende Zeichen müssen escaped werden: '{' '}' ''",
"escapedExample": "Folgende Zeichen müssen escaped werden: '{', '}', ''",
"nestedSelectPlural": "{gender, select, male{{count, plural, =0{Keine Nachrichten} =1{Eine Nachricht} other{{count} Nachrichten}}} female{{count, plural, =0{Keine Nachrichten} =1{Eine Nachricht} other{{count} Nachrichten}}} other{{count, plural, =0{Keine Nachrichten} =1{Eine Nachricht} other{{count} Nachrichten}}}}",
"@nestedSelectPlural": {
"placeholders": {
Expand All @@ -72,5 +72,6 @@
"type": "number"
}
}
}
},
"escapeCharacters": "Folgende Zeichen werden mit '\\' im resultiernden string ergänzt '`', '\\' und '$' $'{'"
}
2 changes: 1 addition & 1 deletion examples/locales/en-US.arb
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
}
}
},
"priceInfo": "The price is {price}{currency, select, usd{USD} eur{EUR} other{}}.",
"priceInfo": "The price is {price}{currency, select, usd{$USD} eur{} other{}}.",
"@priceInfo": {
"placeholders": {
"price": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
// AUTO-GENERATED. DO NOT EDIT.
/* eslint-disable @stylistic/quote-props */
/* eslint-disable no-useless-escape */
/* eslint-disable @typescript-eslint/no-unused-vars */
import type { Translation } from '@helpwave/internationalization'
import { TranslationGen } from '@helpwave/internationalization'

/* eslint-disable @stylistic/quote-props */
export const supportedLocales = ['de-DE', 'en-US', 'fr-FR'] as const
export const exampleTranslationLocales = ['de-DE', 'en-US', 'fr-FR'] as const

export type SupportedLocale = typeof supportedLocales[number]
export type ExampleTranslationLocales = typeof exampleTranslationLocales[number]

export type GeneratedTranslationEntries = {
export type ExampleTranslationEntries = {
'accountStatus': (values: { status: string }) => string,
'ageCategory': (values: { ageGroup: string }) => string,
'escapeCharacters': string,
'escapedExample': string,
'itemCount': (values: { count: number }) => string,
'nestedSelectPlural': (values: { gender: string, count: number }) => string,
Expand All @@ -25,7 +28,7 @@ export type GeneratedTranslationEntries = {
'yes': string,
}

export const generatedTranslations: Translation<SupportedLocale, Partial<GeneratedTranslationEntries>> = {
export const exampleTranslation: Translation<ExampleTranslationLocales, Partial<ExampleTranslationEntries>> = {
'de-DE': {
'accountStatus': ({ status }): string => {
return TranslationGen.resolveSelect(status, {
Expand All @@ -42,7 +45,8 @@ export const generatedTranslations: Translation<SupportedLocale, Partial<Generat
'other': `Person`,
})
},
'escapedExample': `Folgende Zeichen müssen escaped werden: '{' '}' ''`,
'escapeCharacters': `Folgende Zeichen werden mit \\ im resultiernden string ergänzt \`, \\ und \$ \${`,
'escapedExample': `Folgende Zeichen müssen escaped werden: {, }, '`,
'itemCount': ({ count }): string => {
return TranslationGen.resolveSelect(count, {
'=0': `Keine Elemente`,
Expand Down Expand Up @@ -79,10 +83,10 @@ export const generatedTranslations: Translation<SupportedLocale, Partial<Generat
},
'priceInfo': ({ price, currency }): string => {
let _out: string = ''
_out += `Der Preis beträgt ${price}`
_out += `Der Preis beträgt ${price}`
_out += TranslationGen.resolveSelect(currency, {
'usd': `USD`,
'eur': `EUR`,
'usd': `\$USD`,
'eur': ``,
})
_out += `.`
return _out
Expand Down Expand Up @@ -130,7 +134,7 @@ export const generatedTranslations: Translation<SupportedLocale, Partial<Generat
'other': `Person`,
})
},
'escapedExample': `The following characters must be escaped: '{' '}' ''`,
'escapedExample': `The following characters must be escaped: { } '`,
'itemCount': ({ count }): string => {
return TranslationGen.resolveSelect(count, {
'=0': `No items`,
Expand Down Expand Up @@ -167,10 +171,10 @@ export const generatedTranslations: Translation<SupportedLocale, Partial<Generat
},
'priceInfo': ({ price, currency }): string => {
let _out: string = ''
_out += `The price is ${price}`
_out += `The price is ${price}`
_out += TranslationGen.resolveSelect(currency, {
'usd': `USD`,
'eur': `EUR`,
'usd': `\$USD`,
'eur': ``,
})
_out += `.`
return _out
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.1.0",
"version": "0.2.0",
"type": "module",
"files": [
"dist"
Expand Down
93 changes: 76 additions & 17 deletions src/scripts/compile-arb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@ function parseArgs() {
outputFile?: string,
force: boolean,
help: boolean,
name: string,
} = {
force: false,
help: false
help: false,
name: 'generatedTranslation'
}

for (let i = 0; i < args.length; i++) {
Expand All @@ -60,6 +62,11 @@ function parseArgs() {
result.outputFile = args[++i]
break

case '--name':
case '-n':
result.name = args[++i]
break

case '--force':
case '-f':
result.force = true
Expand All @@ -84,6 +91,7 @@ Options:
-i, --in <dir> Input directory containing .arb files
-o, --out <file> Output file (e.g. ./i18n/translations.ts)
-f, --force Overwrite output without prompt
-n, --name <name> The name for exported translation within the code
-h, --help Show this help message
`)
}
Expand All @@ -107,6 +115,19 @@ const force = parsed.force

const outputDir = path.dirname(OUTPUT_FILE)

const name = parsed.name
.replace(/[^a-zA-Z0-9]/g, '_')
.replace(/^[0-9]/, '')

if(name.length < 1 || name[0].toUpperCase() === name[0]) {
console.error(`The name ${parsed.name} is invalid. Use [a-z][a-zA-Z0-9_]+`)
process.exit(0)
} else if(name.length !== parsed.name.length) {
console.warn(`The name ${parsed.name} cannot start with a number.`)
}

console.log(name)

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

function askQuestion(query: string): Promise<string> {
Expand All @@ -127,6 +148,14 @@ if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
}

const capitalize = (s: string): string => {
if(s.length > 0) {
return s.charAt(0).toUpperCase() + s.slice(1)
}
return s
}


const locales = new Set<string>()

/* ------------------ ARB reader ------------------ */
Expand Down Expand Up @@ -209,12 +238,28 @@ function readARBDir(
/* ------------------ code generator: values ------------------ */

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

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

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

function compile(
node: ICUASTNode,
context: { numberParam?: string, inNode: boolean, indentLevel: number } = { indentLevel: 0, inNode: false }
context: CompileContext = defaultCompileContext
): string[] {
const lines: string[] = []
let currentLine = ''
Expand All @@ -225,12 +270,15 @@ function compile(
}

function flushCurrent() {
if(currentLine) {
if(context.inNode) {
if (currentLine) {
if (context.inNode) {
lines.push(currentLine)
} else {
const prefix = !isTopLevel ? indent() : '_out += '
const nextLine = `${prefix}\`${escapeForTemplateJS(currentLine)}\``
const prefix =
context.isOnlyText ? '' :
!isTopLevel ? indent()
: '_out += '
const nextLine = `${prefix}\`${currentLine}\``
lines.push(nextLine)
}
}
Expand All @@ -239,7 +287,7 @@ function compile(

switch (node.type) {
case 'Text':
currentLine += node.value
currentLine += escapeForTemplateJS(node.value)
break
case 'NumberField':
if (context.numberParam) {
Expand All @@ -264,6 +312,10 @@ function compile(
break
}
case 'OptionReplace': {
if (context.isOnlyText) {
currentLine += `{${node.variableName}, ${node.operatorName}, {options}}`
break
}
flushCurrent()
lines.push(`${isTopLevel ? '_out += ' : ''}TranslationGen.resolveSelect(${node.variableName}, {`)

Expand All @@ -277,7 +329,7 @@ function compile(
indentLevel: context.indentLevel + 1,
inNode: false,
})
if(expr.length === 0 ) continue
if (expr.length === 0) continue
lines.push(indent(context.indentLevel + 1) + `'${key}': ${expr[0].trimStart()}`, ...expr.slice(1))
lines[lines.length - 1] += ','
}
Expand Down Expand Up @@ -326,7 +378,10 @@ function generateCode(
]
str += `${indent}${quotedKey}: ${functionLines.join(`\n${indent}`)}${comma}\n`
} else if (entry.type === 'text') {
str += `${indent}${quotedKey}: \`${escapeForTemplateJS(entry.value)}\`${comma}\n`
const ast = ICUUtil.parse(ICUUtil.lex(entry.value))
const compiled = compile(ast, { ...defaultCompileContext, isOnlyText: true })
const text = compiled.length === 1 ? compiled[0] : `\`${escapeForTemplateJS(entry.value)}\``
str += `${indent}${quotedKey}: ${text}${comma}\n`
} else {
// nested object
str += `${indent}${quotedKey}: {\n`
Expand Down Expand Up @@ -387,30 +442,34 @@ async function main(): Promise<void> {
const translationData = readARBDir(inputDir)

let output = `// AUTO-GENERATED. DO NOT EDIT.\n`
output += '/* eslint-disable @stylistic/quote-props */\n'
output += '/* eslint-disable no-useless-escape */\n'
output += '/* eslint-disable @typescript-eslint/no-unused-vars */\n'

output += `import type { Translation } from '@helpwave/internationalization'\n`
output += `import { TranslationGen } from '@helpwave/internationalization'\n\n`

output += '/* eslint-disable @stylistic/quote-props */\n'

output += `export const supportedLocales = [${[
const localesVarName = `${name}Locales`
const localesTypeName = `${capitalize(name)}Locales`
output += `export const ${localesVarName} = [${[
...locales.values()
]
.map(v => `'${v}'`)
.join(', ')}] as const\n\n`

output += `export type SupportedLocale = typeof supportedLocales[number]\n\n`
output += `export type ${localesTypeName} = typeof ${localesVarName}[number]\n\n`

const typeName = `${capitalize(name)}Entries`
const generatedTyping = generateType(translationData)
output += `export type GeneratedTranslationEntries = {\n${generatedTyping}}\n\n`
output += `export type ${typeName} = {\n${generatedTyping}}\n\n`

const value: Record<string, TranslationEntry> = {}
for (const locale of locales) {
value[locale] = { type: 'nested', value: translationData[locale] }
}


const generatedTranslation = generateCode(value)
output += `export const generatedTranslations: Translation<SupportedLocale, Partial<GeneratedTranslationEntries>> = {\n${generatedTranslation}}\n\n`
output += `export const ${name}: Translation<${localesTypeName}, Partial<${typeName}>> = {\n${generatedTranslation}}\n\n`

if (fs.existsSync(OUTPUT_FILE) && !force) {
const answer = await askQuestion(
Expand Down
Loading