diff --git a/CHANGELOG.md b/CHANGELOG.md index 65b75b7..02fb029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 8a9b1c6..c30c47a 100644 --- a/README.md +++ b/README.md @@ -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": {} + } } } ``` @@ -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). \ No newline at end of file +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" +``` \ No newline at end of file diff --git a/examples/locales/de-DE.arb b/examples/locales/de-DE.arb index 2a73a5a..6ce5ef4 100644 --- a/examples/locales/de-DE.arb +++ b/examples/locales/de-DE.arb @@ -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": { @@ -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": { @@ -72,5 +72,6 @@ "type": "number" } } - } + }, + "escapeCharacters": "Folgende Zeichen werden mit '\\' im resultiernden string ergänzt '`', '\\' und '$' $'{'" } diff --git a/examples/locales/en-US.arb b/examples/locales/en-US.arb index 73f1b72..9e5890f 100644 --- a/examples/locales/en-US.arb +++ b/examples/locales/en-US.arb @@ -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": { diff --git a/examples/translations/translation.ts b/examples/translations/translations.ts similarity index 89% rename from examples/translations/translation.ts rename to examples/translations/translations.ts index 5194406..9c76fce 100644 --- a/examples/translations/translation.ts +++ b/examples/translations/translations.ts @@ -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, @@ -25,7 +28,7 @@ export type GeneratedTranslationEntries = { 'yes': string, } -export const generatedTranslations: Translation> = { +export const exampleTranslation: Translation> = { 'de-DE': { 'accountStatus': ({ status }): string => { return TranslationGen.resolveSelect(status, { @@ -42,7 +45,8 @@ export const generatedTranslations: Translation { return TranslationGen.resolveSelect(count, { '=0': `Keine Elemente`, @@ -79,10 +83,10 @@ export const generatedTranslations: Translation { 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 @@ -130,7 +134,7 @@ export const generatedTranslations: Translation { return TranslationGen.resolveSelect(count, { '=0': `No items`, @@ -167,10 +171,10 @@ export const generatedTranslations: Translation { 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 diff --git a/package.json b/package.json index 8a5660a..576624c 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/scripts/compile-arb.ts b/src/scripts/compile-arb.ts index 0d2b4cb..61b966d 100644 --- a/src/scripts/compile-arb.ts +++ b/src/scripts/compile-arb.ts @@ -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++) { @@ -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 @@ -84,6 +91,7 @@ Options: -i, --in Input directory containing .arb files -o, --out Output file (e.g. ./i18n/translations.ts) -f, --force Overwrite output without prompt + -n, --name The name for exported translation within the code -h, --help Show this help message `) } @@ -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 { @@ -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() /* ------------------ ARB reader ------------------ */ @@ -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 = '' @@ -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) } } @@ -239,7 +287,7 @@ function compile( switch (node.type) { case 'Text': - currentLine += node.value + currentLine += escapeForTemplateJS(node.value) break case 'NumberField': if (context.numberParam) { @@ -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}, {`) @@ -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] += ',' } @@ -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` @@ -387,30 +442,34 @@ async function main(): Promise { 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 = {} for (const locale of locales) { value[locale] = { type: 'nested', value: translationData[locale] } } - const generatedTranslation = generateCode(value) - output += `export const generatedTranslations: Translation> = {\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( diff --git a/src/types.ts b/src/types.ts index 9da1f22..e6cc92b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,4 +4,18 @@ export type TranslationEntries = Record export type Translation = Record -export type PartialTranslation = Partial>> \ No newline at end of file +export type PartialTranslation = Partial>> + +export type TranslationExtension< + L1 extends string, + L2 extends string, + T1 extends TranslationEntries, + T2 extends TranslationEntries +> = Translation + +export type PartialTranslationExtension< + L1 extends string, + L2 extends string, + T1 extends TranslationEntries, + T2 extends TranslationEntries +> = PartialTranslation \ No newline at end of file