Skip to content

Commit 6f91edf

Browse files
authored
Merge pull request #6 from helpwave/version-0.2.0
publish Version 0.2.0
2 parents 2865d69 + 7a72815 commit 6f91edf

8 files changed

Lines changed: 143 additions & 45 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,19 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
66
and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
## [0.1.0] - 2025-11-2025
8+
## [0.2.0] - 2025-11-23
9+
10+
### Added
11+
- Add argument to change the generated translation name `-n | --name`
12+
- Add types `TranslationExtension` and `PartialTranslationExtension` to simplify extending translations
13+
14+
### Changed
15+
- Updated README.md example
16+
17+
### Fixed
18+
- Fixed the proper escaping of \, ` and $
19+
20+
## [0.1.0] - 2025-11-21
921

1022
### Added
1123
- ICU lexer, parser and compiler

README.md

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ helpwaves package for internationalization that creates localized and typesafe t
44
## Usage
55
Create a `.arb` file with your translations:
66
```json
7-
"priceInfo": "The price is {price} €{currency, select, usd{USD} eur{EUR} other{}}.",
8-
"@priceInfo": {
9-
"placeholders": {
10-
"price": {
11-
"type": "number"
12-
},
13-
"currency": {}
7+
{
8+
"priceInfo": "The price is {price}{currency, select, usd{$USD} eur{€} other{}}.",
9+
"@priceInfo": {
10+
"placeholders": {
11+
"price": {
12+
"type": "number"
13+
},
14+
"currency": {}
15+
}
1416
}
1517
}
1618
```
@@ -64,4 +66,10 @@ Options:
6466
The lexer, parser and compiler are all tested with jest, see [our tests](/tests)
6567

6668
## Examples
67-
Example translation files and the resulting translation can be found in the [examples folder](/examples).
69+
Example translation files and the resulting translation can be found in the [examples folder](/examples).
70+
71+
Rebuild the examples:
72+
```bash
73+
npm run build
74+
node dist/scripts/compile-arb.js --force -i ./examples/locales -o ./examples/translations/translations.ts -n "exampleTranslation"
75+
```

examples/locales/de-DE.arb

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
}
4747
}
4848
},
49-
"priceInfo": "Der Preis beträgt {price}{currency, select, usd{USD} eur{EUR} other{}}.",
49+
"priceInfo": "Der Preis beträgt {price}{currency, select, usd{$USD} eur{} other{}}.",
5050
"@priceInfo": {
5151
"placeholders": {
5252
"price": {
@@ -63,7 +63,7 @@
6363
}
6464
}
6565
},
66-
"escapedExample": "Folgende Zeichen müssen escaped werden: '{' '}' ''",
66+
"escapedExample": "Folgende Zeichen müssen escaped werden: '{', '}', ''",
6767
"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}}}}",
6868
"@nestedSelectPlural": {
6969
"placeholders": {
@@ -72,5 +72,6 @@
7272
"type": "number"
7373
}
7474
}
75-
}
75+
},
76+
"escapeCharacters": "Folgende Zeichen werden mit '\\' im resultiernden string ergänzt '`', '\\' und '$' $'{'"
7677
}

examples/locales/en-US.arb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
}
4747
}
4848
},
49-
"priceInfo": "The price is {price}{currency, select, usd{USD} eur{EUR} other{}}.",
49+
"priceInfo": "The price is {price}{currency, select, usd{$USD} eur{} other{}}.",
5050
"@priceInfo": {
5151
"placeholders": {
5252
"price": {
Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
// AUTO-GENERATED. DO NOT EDIT.
2+
/* eslint-disable @stylistic/quote-props */
3+
/* eslint-disable no-useless-escape */
4+
/* eslint-disable @typescript-eslint/no-unused-vars */
25
import type { Translation } from '@helpwave/internationalization'
36
import { TranslationGen } from '@helpwave/internationalization'
47

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

8-
export type SupportedLocale = typeof supportedLocales[number]
10+
export type ExampleTranslationLocales = typeof exampleTranslationLocales[number]
911

10-
export type GeneratedTranslationEntries = {
12+
export type ExampleTranslationEntries = {
1113
'accountStatus': (values: { status: string }) => string,
1214
'ageCategory': (values: { ageGroup: string }) => string,
15+
'escapeCharacters': string,
1316
'escapedExample': string,
1417
'itemCount': (values: { count: number }) => string,
1518
'nestedSelectPlural': (values: { gender: string, count: number }) => string,
@@ -25,7 +28,7 @@ export type GeneratedTranslationEntries = {
2528
'yes': string,
2629
}
2730

28-
export const generatedTranslations: Translation<SupportedLocale, Partial<GeneratedTranslationEntries>> = {
31+
export const exampleTranslation: Translation<ExampleTranslationLocales, Partial<ExampleTranslationEntries>> = {
2932
'de-DE': {
3033
'accountStatus': ({ status }): string => {
3134
return TranslationGen.resolveSelect(status, {
@@ -42,7 +45,8 @@ export const generatedTranslations: Translation<SupportedLocale, Partial<Generat
4245
'other': `Person`,
4346
})
4447
},
45-
'escapedExample': `Folgende Zeichen müssen escaped werden: '{' '}' ''`,
48+
'escapeCharacters': `Folgende Zeichen werden mit \\ im resultiernden string ergänzt \`, \\ und \$ \${`,
49+
'escapedExample': `Folgende Zeichen müssen escaped werden: {, }, '`,
4650
'itemCount': ({ count }): string => {
4751
return TranslationGen.resolveSelect(count, {
4852
'=0': `Keine Elemente`,
@@ -79,10 +83,10 @@ export const generatedTranslations: Translation<SupportedLocale, Partial<Generat
7983
},
8084
'priceInfo': ({ price, currency }): string => {
8185
let _out: string = ''
82-
_out += `Der Preis beträgt ${price}`
86+
_out += `Der Preis beträgt ${price}`
8387
_out += TranslationGen.resolveSelect(currency, {
84-
'usd': `USD`,
85-
'eur': `EUR`,
88+
'usd': `\$USD`,
89+
'eur': ``,
8690
})
8791
_out += `.`
8892
return _out
@@ -130,7 +134,7 @@ export const generatedTranslations: Translation<SupportedLocale, Partial<Generat
130134
'other': `Person`,
131135
})
132136
},
133-
'escapedExample': `The following characters must be escaped: '{' '}' ''`,
137+
'escapedExample': `The following characters must be escaped: { } '`,
134138
'itemCount': ({ count }): string => {
135139
return TranslationGen.resolveSelect(count, {
136140
'=0': `No items`,
@@ -167,10 +171,10 @@ export const generatedTranslations: Translation<SupportedLocale, Partial<Generat
167171
},
168172
'priceInfo': ({ price, currency }): string => {
169173
let _out: string = ''
170-
_out += `The price is ${price}`
174+
_out += `The price is ${price}`
171175
_out += TranslationGen.resolveSelect(currency, {
172-
'usd': `USD`,
173-
'eur': `EUR`,
176+
'usd': `\$USD`,
177+
'eur': ``,
174178
})
175179
_out += `.`
176180
return _out

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"url": "git+https://github.com/helpwave/internationlization.git"
1111
},
1212
"license": "MPL-2.0",
13-
"version": "0.1.0",
13+
"version": "0.2.0",
1414
"type": "module",
1515
"files": [
1616
"dist"

src/scripts/compile-arb.ts

Lines changed: 76 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,11 @@ function parseArgs() {
4141
outputFile?: string,
4242
force: boolean,
4343
help: boolean,
44+
name: string,
4445
} = {
4546
force: false,
46-
help: false
47+
help: false,
48+
name: 'generatedTranslation'
4749
}
4850

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

65+
case '--name':
66+
case '-n':
67+
result.name = args[++i]
68+
break
69+
6370
case '--force':
6471
case '-f':
6572
result.force = true
@@ -84,6 +91,7 @@ Options:
8491
-i, --in <dir> Input directory containing .arb files
8592
-o, --out <file> Output file (e.g. ./i18n/translations.ts)
8693
-f, --force Overwrite output without prompt
94+
-n, --name <name> The name for exported translation within the code
8795
-h, --help Show this help message
8896
`)
8997
}
@@ -107,6 +115,19 @@ const force = parsed.force
107115

108116
const outputDir = path.dirname(OUTPUT_FILE)
109117

118+
const name = parsed.name
119+
.replace(/[^a-zA-Z0-9]/g, '_')
120+
.replace(/^[0-9]/, '')
121+
122+
if(name.length < 1 || name[0].toUpperCase() === name[0]) {
123+
console.error(`The name ${parsed.name} is invalid. Use [a-z][a-zA-Z0-9_]+`)
124+
process.exit(0)
125+
} else if(name.length !== parsed.name.length) {
126+
console.warn(`The name ${parsed.name} cannot start with a number.`)
127+
}
128+
129+
console.log(name)
130+
110131
/* ------------------ prompts ------------------ */
111132

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

151+
const capitalize = (s: string): string => {
152+
if(s.length > 0) {
153+
return s.charAt(0).toUpperCase() + s.slice(1)
154+
}
155+
return s
156+
}
157+
158+
130159
const locales = new Set<string>()
131160

132161
/* ------------------ ARB reader ------------------ */
@@ -209,12 +238,28 @@ function readARBDir(
209238
/* ------------------ code generator: values ------------------ */
210239

211240
function escapeForTemplateJS(s: string): string {
212-
return s.replace(/`/g, '\\`')
241+
return s
242+
.replace(/\\/g, `\\\\`)
243+
.replace(/`/g, `\\\``)
244+
.replace(/\$/g, `\\$`)
245+
}
246+
247+
type CompileContext = {
248+
numberParam?: string,
249+
inNode: boolean,
250+
indentLevel: number,
251+
isOnlyText: boolean,
252+
}
253+
254+
const defaultCompileContext: CompileContext = {
255+
indentLevel: 0,
256+
inNode: false,
257+
isOnlyText: false,
213258
}
214259

215260
function compile(
216261
node: ICUASTNode,
217-
context: { numberParam?: string, inNode: boolean, indentLevel: number } = { indentLevel: 0, inNode: false }
262+
context: CompileContext = defaultCompileContext
218263
): string[] {
219264
const lines: string[] = []
220265
let currentLine = ''
@@ -225,12 +270,15 @@ function compile(
225270
}
226271

227272
function flushCurrent() {
228-
if(currentLine) {
229-
if(context.inNode) {
273+
if (currentLine) {
274+
if (context.inNode) {
230275
lines.push(currentLine)
231276
} else {
232-
const prefix = !isTopLevel ? indent() : '_out += '
233-
const nextLine = `${prefix}\`${escapeForTemplateJS(currentLine)}\``
277+
const prefix =
278+
context.isOnlyText ? '' :
279+
!isTopLevel ? indent()
280+
: '_out += '
281+
const nextLine = `${prefix}\`${currentLine}\``
234282
lines.push(nextLine)
235283
}
236284
}
@@ -239,7 +287,7 @@ function compile(
239287

240288
switch (node.type) {
241289
case 'Text':
242-
currentLine += node.value
290+
currentLine += escapeForTemplateJS(node.value)
243291
break
244292
case 'NumberField':
245293
if (context.numberParam) {
@@ -264,6 +312,10 @@ function compile(
264312
break
265313
}
266314
case 'OptionReplace': {
315+
if (context.isOnlyText) {
316+
currentLine += `{${node.variableName}, ${node.operatorName}, {options}}`
317+
break
318+
}
267319
flushCurrent()
268320
lines.push(`${isTopLevel ? '_out += ' : ''}TranslationGen.resolveSelect(${node.variableName}, {`)
269321

@@ -277,7 +329,7 @@ function compile(
277329
indentLevel: context.indentLevel + 1,
278330
inNode: false,
279331
})
280-
if(expr.length === 0 ) continue
332+
if (expr.length === 0) continue
281333
lines.push(indent(context.indentLevel + 1) + `'${key}': ${expr[0].trimStart()}`, ...expr.slice(1))
282334
lines[lines.length - 1] += ','
283335
}
@@ -326,7 +378,10 @@ function generateCode(
326378
]
327379
str += `${indent}${quotedKey}: ${functionLines.join(`\n${indent}`)}${comma}\n`
328380
} else if (entry.type === 'text') {
329-
str += `${indent}${quotedKey}: \`${escapeForTemplateJS(entry.value)}\`${comma}\n`
381+
const ast = ICUUtil.parse(ICUUtil.lex(entry.value))
382+
const compiled = compile(ast, { ...defaultCompileContext, isOnlyText: true })
383+
const text = compiled.length === 1 ? compiled[0] : `\`${escapeForTemplateJS(entry.value)}\``
384+
str += `${indent}${quotedKey}: ${text}${comma}\n`
330385
} else {
331386
// nested object
332387
str += `${indent}${quotedKey}: {\n`
@@ -387,30 +442,34 @@ async function main(): Promise<void> {
387442
const translationData = readARBDir(inputDir)
388443

389444
let output = `// AUTO-GENERATED. DO NOT EDIT.\n`
445+
output += '/* eslint-disable @stylistic/quote-props */\n'
446+
output += '/* eslint-disable no-useless-escape */\n'
447+
output += '/* eslint-disable @typescript-eslint/no-unused-vars */\n'
448+
390449
output += `import type { Translation } from '@helpwave/internationalization'\n`
391450
output += `import { TranslationGen } from '@helpwave/internationalization'\n\n`
392451

393-
output += '/* eslint-disable @stylistic/quote-props */\n'
394-
395-
output += `export const supportedLocales = [${[
452+
const localesVarName = `${name}Locales`
453+
const localesTypeName = `${capitalize(name)}Locales`
454+
output += `export const ${localesVarName} = [${[
396455
...locales.values()
397456
]
398457
.map(v => `'${v}'`)
399458
.join(', ')}] as const\n\n`
400459

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

462+
const typeName = `${capitalize(name)}Entries`
403463
const generatedTyping = generateType(translationData)
404-
output += `export type GeneratedTranslationEntries = {\n${generatedTyping}}\n\n`
464+
output += `export type ${typeName} = {\n${generatedTyping}}\n\n`
405465

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

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

415474
if (fs.existsSync(OUTPUT_FILE) && !force) {
416475
const answer = await askQuestion(

0 commit comments

Comments
 (0)