diff --git a/CHANGELOG.md b/CHANGELOG.md
index f7915dc..ccb9ab0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,26 @@ 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.5.0 - 2026-10-13
+
+### Added
+- `build-intl` reads a JSON config via `-c` / `--config` with `supportedLocales` and `compileTo`
+- `.arb` files declare `@@locale`, and an optional `@@prefix` prefixes every key in that file
+- Translation inputs can be `.arb` files or directories
+- Warnings for inconsistent keys and inconsistent placeholder types
+- Placeholder types other than `string`, `int`, and `float` are errors. The build reports every error and does not write the output file when any error is present
+- Placeholder types are no longer inferred from the variable name. A placeholder without a type warns and is read as `string`
+
+### Changed
+- Locale identity comes from `@@locale` instead of the file name
+- Key prefixes come from `@@prefix` instead of nested directories
+- Omitted config fields default to locales `en-US` and `de-DE`, `compileTo` `typescript`, export name `appTranslation`, and output file `./i18n/translations.ts`
+- Placeholder types are `string`, `int`, and `float`
+- The config accepts `name`, `outputFile`, and `translationFiles`
+- `build-intl` accepts multiple file or directory paths, overwrites the output file, and `--dry-run` prints that diff
+- `-c` / `--config` is optional. Without it, the build uses the same defaults as a config with omitted fields
+- `int` and `float` placeholders are `number` parameters. `int` values are rounded before they are inserted into the translation string. `float` values are inserted as given
+
## [0.4.0] - 2025-11-26
### Added
diff --git a/README.md b/README.md
index 1bab1d4..2151db7 100644
--- a/README.md
+++ b/README.md
@@ -5,11 +5,12 @@ helpwaves package for internationalization that creates localized and typesafe t
Create a `.arb` file with your translations:
```json
{
+ "@@locale": "en-US",
"priceInfo": "The price is {price}{currency, select, usd{$USD} eur{€} other{}}.",
"@priceInfo": {
"placeholders": {
"price": {
- "type": "number"
+ "type": "float"
},
"currency": {}
}
@@ -43,6 +44,16 @@ t("priceInfo", { price, currency })
npm install -D @helpwave/internationalization
```
#### Create local ARB files
+Each file sets its locale with `@@locale`. An optional `@@prefix` prefixes every key in that file, so a prefix of `test` makes `name` available as `test.name`.
+
+```json
+{
+ "@@locale": "en-US",
+ "@@prefix": "test",
+ "name": "Name"
+}
+```
+
The file structure could look like this:
```
/locales
@@ -53,23 +64,43 @@ The file structure could look like this:
-> en-US.arb
```
-Then run:
+A JSON config file is optional. Pass it with `-c` or `--config`. Every field is optional:
+```json
+{
+ "supportedLocales": ["en-US", "de-DE"],
+ "compileTo": "typescript",
+ "name": "appTranslation",
+ "outputFile": "./i18n/translations.ts",
+ "translationFiles": ["./locales", "./shared/locales"]
+}
+```
+
+Without a config file, and for any omitted field, `supportedLocales`, `compileTo`, `name`, and `outputFile` use the values above. `translationFiles` defaults to an empty list; the sample lists two paths to show that more than one is allowed. `supportedLocales` is the set of locales the project expects. Every placeholder must declare a type of `string`, `int`, or `float`. A missing type is a warning and is read as `string`. Any other type is an error. The build reports every error it finds, and it does not write the output file when any error is present. `compileTo` currently accepts `typescript`. `-n` overrides `name`, and `-o` overrides `outputFile`.
+
+`int` and `float` are `number` parameters in the generated functions. `TranslationGen.round` rounds `int` values before they are inserted into the translation string. `float` values are inserted as given.
+
+Pass one or more `.arb` files or directories. Every `.arb` file under a directory is included. Paths on the command replace `translationFiles` when you provide any.
+
```bash
-npx build-intl
+npx build-intl ./locales ./shared/locales
+npx build-intl --config ./intl.config.json ./locales ./shared/locales
```
-By default `./locales` will be translated to `./i18n`, the input directory and output file are configurable with:
+An existing output file is overwritten. `--dry-run` prints the diff and does not write the file.
+
```
-Usage: i18n-compile [options]
+Usage: build-intl [options] [location ...]
Options:
- -i, --in
Input directory containing .arb files
- -o, --out Output file (e.g. ./i18n/translations.ts)
- -f, --force Overwrite output without prompt
+ -c, --config JSON configuration file (optional)
+ -o, --out Output file (overrides config outputFile)
+ -n, --name Exported translation name (overrides config name)
+ --dry-run Print the output diff without writing the file
-h, --help Show this help message
-`)
```
+Keys or placeholder types that differ between locales are reported as warnings.
+
## Tests
The lexer, parser and compiler are all tested with jest, see [our tests](/tests)
@@ -80,7 +111,7 @@ Example translation files and the resulting translation can be found in the [exa
Rebuild the examples:
```bash
npm run build
-node dist/scripts/compile-arb.js --force -i ./examples/locales -o ./examples/translations/translations.ts -n "exampleTranslation"
+node dist/scripts/build-intl.js --config ./examples/intl.config.json
```
React hook example:
diff --git a/examples/intl.config.json b/examples/intl.config.json
new file mode 100644
index 0000000..9a4f2fd
--- /dev/null
+++ b/examples/intl.config.json
@@ -0,0 +1,7 @@
+{
+ "supportedLocales": ["de-DE", "en-US", "fr-FR"],
+ "compileTo": "typescript",
+ "name": "exampleTranslation",
+ "outputFile": "./examples/translations/translations.ts",
+ "translationFiles": ["./examples/locales"]
+}
diff --git a/examples/locales/de-DE.arb b/examples/locales/de-DE.arb
index d42356a..4925e7b 100644
--- a/examples/locales/de-DE.arb
+++ b/examples/locales/de-DE.arb
@@ -1,4 +1,5 @@
{
+ "@@locale": "de-DE",
"userGreeting": "{gender, select, male{Hallo, {name}!} female{Hallo, {name}!} other{Hallo, Person!}}",
"@userGreeting": {
"placeholders": {
@@ -34,7 +35,7 @@
"type": "string"
},
"count": {
- "type": "number"
+ "type": "int"
}
}
},
@@ -42,7 +43,7 @@
"@priceInfo": {
"placeholders": {
"price": {
- "type": "number"
+ "type": "float"
},
"currency": {}
}
@@ -61,7 +62,7 @@
"placeholders": {
"gender": {},
"count": {
- "type": "number"
+ "type": "int"
}
}
},
@@ -70,7 +71,7 @@
"@nWard": {
"placeholders": {
"count": {
- "type": "number"
+ "type": "int"
}
}
},
diff --git a/examples/locales/en-US.arb b/examples/locales/en-US.arb
index da2af79..feafb4e 100644
--- a/examples/locales/en-US.arb
+++ b/examples/locales/en-US.arb
@@ -1,4 +1,5 @@
{
+ "@@locale": "en-US",
"userGreeting": "{gender, select, male{Hello, {name}!} female{Hello, {name}!} other{Hello, person!}}",
"@userGreeting": {
"placeholders": {
@@ -34,7 +35,7 @@
"type": "string"
},
"count": {
- "type": "number"
+ "type": "int"
}
}
},
@@ -42,7 +43,7 @@
"@priceInfo": {
"placeholders": {
"price": {
- "type": "number"
+ "type": "float"
},
"currency": {}
}
@@ -61,7 +62,7 @@
"placeholders": {
"gender": {},
"count": {
- "type": "number"
+ "type": "int"
}
}
}
diff --git a/examples/locales/fr-FR.arb b/examples/locales/fr-FR.arb
index d5aafb1..212b113 100644
--- a/examples/locales/fr-FR.arb
+++ b/examples/locales/fr-FR.arb
@@ -1,4 +1,5 @@
{
+ "@@locale": "fr-FR",
"hello": "Bonjour",
"goodbye": "Au revoir",
"thankYou": "Merci",
diff --git a/examples/locales/nested/de-DE.arb b/examples/locales/nested/de-DE.arb
index 7996c75..3b9defc 100644
--- a/examples/locales/nested/de-DE.arb
+++ b/examples/locales/nested/de-DE.arb
@@ -1,10 +1,12 @@
{
+ "@@locale": "de-DE",
+ "@@prefix": "nested",
"nested": "Geschachtelt",
"itemCount": "{count, plural, =0{Keine Elemente} =1{Ein Element} other{{count} Elemente}}",
"@itemCount": {
"placeholders": {
"count": {
- "type": "number"
+ "type": "int"
}
}
}
diff --git a/examples/locales/nested/en-US.arb b/examples/locales/nested/en-US.arb
index e09bb07..4664daf 100644
--- a/examples/locales/nested/en-US.arb
+++ b/examples/locales/nested/en-US.arb
@@ -1,10 +1,12 @@
{
+ "@@locale": "en-US",
+ "@@prefix": "nested",
"nested": "Nested",
"itemCount": "{count, plural, =0{No items} =1{One item} other{{count} items}}",
"@itemCount": {
"placeholders": {
"count": {
- "type": "number"
+ "type": "int"
}
}
}
diff --git a/examples/locales/nested/fr-FR.arb b/examples/locales/nested/fr-FR.arb
index 2c63c08..488efff 100644
--- a/examples/locales/nested/fr-FR.arb
+++ b/examples/locales/nested/fr-FR.arb
@@ -1,2 +1,3 @@
{
+ "@@locale": "fr-FR"
}
diff --git a/examples/translations/translations.ts b/examples/translations/translations.ts
index 2eecb12..0ac6847 100644
--- a/examples/translations/translations.ts
+++ b/examples/translations/translations.ts
@@ -54,7 +54,7 @@ export const exampleTranslation: Translation {
return TranslationGen.resolvePlural(count, {
- '=1': `${count} Station`,
- 'other': `${count} Stationen`,
+ '=1': `${TranslationGen.round(count)} Station`,
+ 'other': `${TranslationGen.round(count)} Stationen`,
})
},
'passwordStrength': ({ strength }): string => {
@@ -123,7 +123,7 @@ export const exampleTranslation: Translation,
inNode: boolean,
indentLevel: number,
isOnlyText: boolean,
@@ -34,6 +35,12 @@ export function compileToCode(
return ' '.repeat(level * 2)
}
+ function formatValue(name: string): string {
+ const kind = context.numericFormats?.[name]
+ if (kind === 'int') return `TranslationGen.round(${name})`
+ return name
+ }
+
function flushCurrent() {
if (currentLine) {
if (context.inNode) {
@@ -52,13 +59,13 @@ export function compileToCode(
break
case 'NumberField':
if (context.numberParam) {
- currentLine += `$\{${context.numberParam}}`
+ currentLine += `$\{${formatValue(context.numberParam)}}`
} else {
currentLine += `{${context.numberParam}}`
}
break
case 'SimpleReplace':
- currentLine += `$\{${node.variableName}}`
+ currentLine += `$\{${formatValue(node.variableName)}}`
break
case 'Node': {
for (const partNode of node.parts) {
diff --git a/src/scripts/build-intl.ts b/src/scripts/build-intl.ts
new file mode 100644
index 0000000..d6e70b4
--- /dev/null
+++ b/src/scripts/build-intl.ts
@@ -0,0 +1,125 @@
+#!/usr/bin/env node
+import fs from 'fs'
+import path from 'path'
+import { parseBuildIntlArgs, resolveExportName } from './cli-args'
+import { collectTranslations, type IntlDiagnostic } from './collect-translations'
+import { compileTranslations } from './compile-typescript'
+import { defaultIntlConfig, loadIntlConfig, type IntlConfig } from './intl-config'
+import { describeOutputChange } from './output-diff'
+
+function printHelp(): void {
+ console.info(`
+Usage: build-intl [options] [location ...]
+
+Collects .arb files from the given files and directories and compiles them.
+Repeat location to pass more than one file or directory.
+When no locations are given, translationFiles from the config is used.
+
+Options:
+ -c, --config JSON configuration file (optional)
+ -o, --out Output file (overrides config outputFile)
+ -n, --name Exported translation name (overrides config name)
+ --dry-run Print the output diff without writing the file
+ -h, --help Show this help message
+
+Configuration file:
+ {
+ "supportedLocales": ["en-US", "de-DE"],
+ "compileTo": "typescript",
+ "name": "appTranslation",
+ "outputFile": "./i18n/translations.ts",
+ "translationFiles": []
+ }
+
+--config is optional. Without a config file, and for any omitted field, those defaults are used.
+List one or more paths in translationFiles, or pass them after the options.
+Existing output files are overwritten.
+Each .arb file must set @@locale to a supported locale.
+@@prefix optionally prefixes every key in that file (prefix "test" exposes "name" as "test.name").
+Every placeholder must declare a type of string, int, or float. A missing type is a warning and is read as string. Any other type is an error. The output file is not written when the build reports an error.
+int and float are number parameters. int values are rounded before they are inserted into the string. float values are inserted as given.
+Keys or placeholder types that differ between locales are reported as warnings.
+`)
+}
+
+function reportDiagnostics(diagnostics: readonly IntlDiagnostic[]): void {
+ for (const diagnostic of diagnostics) {
+ const line = `${diagnostic.level}: ${diagnostic.message}`
+ if (diagnostic.level === 'error') console.error(line)
+ else console.warn(line)
+ }
+}
+
+function resolveLocations(cliLocations: readonly string[], configLocations: readonly string[]): string[] {
+ if (cliLocations.length > 0) return [...cliLocations]
+ return [...configLocations]
+}
+
+function main(): void {
+ const args = parseBuildIntlArgs(process.argv.slice(2))
+ if (args.help) {
+ printHelp()
+ process.exit(0)
+ }
+ if (args.errors.length > 0) {
+ for (const message of args.errors) console.error(`error: ${message}`)
+ process.exit(1)
+ }
+ let config: IntlConfig
+ try {
+ config = args.configPath === undefined ? defaultIntlConfig() : loadIntlConfig(args.configPath)
+ } catch (error) {
+ const reason = error instanceof Error ? error.message : String(error)
+ console.error(`error: ${reason}`)
+ process.exit(1)
+ }
+
+ const exportName = resolveExportName(args.name ?? config.name)
+ if (exportName.error) {
+ console.error(`error: ${exportName.error}`)
+ process.exit(1)
+ }
+ if (exportName.warning) console.warn(`warning: ${exportName.warning}`)
+
+ const locations = resolveLocations(args.locations, config.translationFiles)
+ if (locations.length === 0) {
+ console.error('error: Provide at least one .arb file or directory, or set translationFiles in the config.')
+ process.exit(1)
+ }
+
+ const collected = collectTranslations(locations, config)
+ const compiled = compileTranslations(collected.catalog, config, exportName.name)
+ reportDiagnostics(collected.diagnostics)
+ for (const message of compiled.errors) console.error(`error: ${message}`)
+
+ const errorCount = collected.diagnostics.filter(diagnostic => diagnostic.level === 'error').length
+ + compiled.errors.length
+ if (errorCount > 0) {
+ console.error(`Build failed with ${errorCount} error(s). The output file was not written.`)
+ process.exit(1)
+ }
+
+ const outputFile = path.resolve(args.outputFile ?? config.outputFile)
+ if (args.dryRun) {
+ const previous = fs.existsSync(outputFile) ? fs.readFileSync(outputFile, 'utf-8') : undefined
+ console.info(describeOutputChange(outputFile, previous, compiled.source))
+ return
+ }
+
+ fs.mkdirSync(path.dirname(outputFile), { recursive: true })
+ fs.writeFileSync(outputFile, compiled.source)
+
+ const warningCount = collected.diagnostics.filter(diagnostic => diagnostic.level === 'warning').length
+ if (warningCount > 0) {
+ console.warn(`Compiled with ${warningCount} warning(s).`)
+ }
+ console.info(`Translations compiled to ${outputFile}`)
+}
+
+try {
+ main()
+} catch (error) {
+ const reason = error instanceof Error ? error.message : String(error)
+ console.error(`error: ${reason}`)
+ process.exit(1)
+}
diff --git a/src/scripts/cli-args.ts b/src/scripts/cli-args.ts
new file mode 100644
index 0000000..9eace60
--- /dev/null
+++ b/src/scripts/cli-args.ts
@@ -0,0 +1,122 @@
+export interface BuildIntlArgs {
+ configPath?: string,
+ outputFile?: string,
+ dryRun: boolean,
+ help: boolean,
+ name?: string,
+ locations: string[],
+ errors: string[],
+}
+
+export interface ExportName {
+ name: string,
+ warning?: string,
+ error?: string,
+}
+
+export function resolveExportName(raw: string): ExportName {
+ const name = raw.replace(/[^a-zA-Z0-9]/g, '_').replace(/^[0-9]/, '')
+ if (!/^[a-z][a-zA-Z0-9_]*$/.test(name)) {
+ return {
+ name,
+ error: `The name "${raw}" is invalid. Use [a-z][a-zA-Z0-9_]+.`,
+ }
+ }
+ if (name !== raw) {
+ return {
+ name,
+ warning: `The name "${raw}" was changed to "${name}".`,
+ }
+ }
+ return { name }
+}
+
+function readOptionValue(
+ args: BuildIntlArgs,
+ argv: readonly string[],
+ index: number,
+ flag: string
+): string | undefined {
+ const value = argv[index + 1]
+ if (value === undefined || value.startsWith('-')) {
+ args.errors.push(`Option ${flag} requires a value.`)
+ return undefined
+ }
+ return value
+}
+
+export function parseBuildIntlArgs(argv: readonly string[]): BuildIntlArgs {
+ const args: BuildIntlArgs = {
+ dryRun: false,
+ help: false,
+ locations: [],
+ errors: [],
+ }
+
+ for (let index = 0; index < argv.length; index++) {
+ const arg = argv[index]
+
+ if (arg === '--') {
+ args.locations.push(...argv.slice(index + 1))
+ break
+ }
+ if (arg === '--help' || arg === '-h') {
+ args.help = true
+ continue
+ }
+ if (arg === '--dry-run') {
+ args.dryRun = true
+ continue
+ }
+ if (arg === '--config' || arg === '-c') {
+ const value = readOptionValue(args, argv, index, arg)
+ if (value !== undefined) {
+ args.configPath = value
+ index++
+ }
+ continue
+ }
+ if (arg.startsWith('--config=')) {
+ const value = arg.slice('--config='.length)
+ if (value.length === 0) args.errors.push('Option --config requires a value.')
+ else args.configPath = value
+ continue
+ }
+ if (arg === '--out' || arg === '-o') {
+ const value = readOptionValue(args, argv, index, arg)
+ if (value !== undefined) {
+ args.outputFile = value
+ index++
+ }
+ continue
+ }
+ if (arg.startsWith('--out=')) {
+ const value = arg.slice('--out='.length)
+ if (value.length === 0) args.errors.push('Option --out requires a value.')
+ else args.outputFile = value
+ continue
+ }
+ if (arg === '--name' || arg === '-n') {
+ const value = readOptionValue(args, argv, index, arg)
+ if (value !== undefined) {
+ args.name = value
+ index++
+ }
+ continue
+ }
+ if (arg.startsWith('--name=')) {
+ const value = arg.slice('--name='.length)
+ if (value.length === 0) args.errors.push('Option --name requires a value.')
+ else args.name = value
+ continue
+ }
+ if (arg.startsWith('-')) {
+ args.errors.push(`Unknown option "${arg}".`)
+ continue
+ }
+
+ args.locations.push(arg)
+ }
+
+ return args
+}
diff --git a/src/scripts/collect-translations.ts b/src/scripts/collect-translations.ts
new file mode 100644
index 0000000..283fe12
--- /dev/null
+++ b/src/scripts/collect-translations.ts
@@ -0,0 +1,379 @@
+import fs from 'fs'
+import path from 'path'
+import type { IntlConfig } from './intl-config'
+import {
+ resolvePlaceholderType,
+ type DiagnosticLevel,
+ type DiagnosticReporter,
+ type NumericKind
+} from './placeholder-type'
+
+export interface IntlDiagnostic {
+ level: DiagnosticLevel,
+ message: string,
+}
+
+export interface FuncParam {
+ name: string,
+ typing: string,
+ numeric?: NumericKind,
+}
+
+export type TranslationEntry =
+ { type: 'text', value: string }
+ | { type: 'func', params: FuncParam[], value: string }
+
+export type LocaleCatalog = Record>
+
+export interface CollectedTranslations {
+ catalog: LocaleCatalog,
+ diagnostics: IntlDiagnostic[],
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error)
+}
+
+function isPrefix(value: string): boolean {
+ return value.split('.').every(part => /^[A-Za-z_][A-Za-z0-9_]*$/.test(part))
+}
+
+export function describeTranslationEntry(entry: TranslationEntry): string {
+ if (entry.type === 'text') return 'string'
+ const params = entry.params.map(param => `${param.name}: ${param.typing}`).join(', ')
+ return `(${params}) => string`
+}
+
+function collectArbPaths(locations: readonly string[], diagnostics: IntlDiagnostic[]): string[] {
+ const files: string[] = []
+ const seenFiles = new Set()
+ const seenDirectories = new Set()
+
+ const addFile = (filePath: string): void => {
+ try {
+ const real = fs.realpathSync(filePath)
+ if (seenFiles.has(real)) return
+ seenFiles.add(real)
+ files.push(real)
+ } catch (error) {
+ diagnostics.push({
+ level: 'error',
+ message: `Failed to read ${filePath}: ${errorMessage(error)}`,
+ })
+ }
+ }
+
+ const walk = (directory: string): void => {
+ let realDirectory = directory
+ try {
+ realDirectory = fs.realpathSync(directory)
+ } catch (error) {
+ diagnostics.push({
+ level: 'error',
+ message: `Failed to read ${directory}: ${errorMessage(error)}`,
+ })
+ return
+ }
+ if (seenDirectories.has(realDirectory)) return
+ seenDirectories.add(realDirectory)
+
+ let entries: fs.Dirent[]
+ try {
+ entries = fs.readdirSync(realDirectory, { withFileTypes: true })
+ } catch (error) {
+ diagnostics.push({
+ level: 'error',
+ message: `Failed to read ${directory}: ${errorMessage(error)}`,
+ })
+ return
+ }
+
+ for (const entry of entries) {
+ const fullPath = path.join(realDirectory, entry.name)
+ let stat: fs.Stats
+ try {
+ stat = fs.statSync(fullPath)
+ } catch (error) {
+ diagnostics.push({
+ level: 'error',
+ message: `Failed to read ${fullPath}: ${errorMessage(error)}`,
+ })
+ continue
+ }
+ if (stat.isDirectory()) {
+ walk(fullPath)
+ } else if (stat.isFile() && entry.name.endsWith('.arb')) {
+ addFile(fullPath)
+ }
+ }
+ }
+
+ for (const location of locations) {
+ const resolved = path.resolve(location)
+ if (!fs.existsSync(resolved)) {
+ diagnostics.push({
+ level: 'error',
+ message: `Path does not exist: ${location}`,
+ })
+ continue
+ }
+
+ const stat = fs.statSync(resolved)
+ if (stat.isDirectory()) {
+ walk(resolved)
+ } else if (stat.isFile()) {
+ if (!resolved.endsWith('.arb')) {
+ diagnostics.push({
+ level: 'error',
+ message: `Expected an .arb file: ${location}`,
+ })
+ continue
+ }
+ addFile(resolved)
+ } else {
+ diagnostics.push({
+ level: 'error',
+ message: `Unsupported path: ${location}`,
+ })
+ }
+ }
+
+ files.sort((left, right) => left.localeCompare(right))
+ return files
+}
+
+function readArbObject(filePath: string, diagnostics: IntlDiagnostic[]): Record | undefined {
+ try {
+ const parsed: unknown = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
+ if (!isRecord(parsed)) {
+ diagnostics.push({
+ level: 'error',
+ message: `File ${filePath} must contain a JSON object.`,
+ })
+ return undefined
+ }
+ return parsed
+ } catch (error) {
+ diagnostics.push({
+ level: 'error',
+ message: `Failed to read ${filePath}: ${errorMessage(error)}`,
+ })
+ return undefined
+ }
+}
+
+function buildEntry(
+ key: string,
+ value: string,
+ meta: unknown,
+ filePath: string,
+ diagnostics: IntlDiagnostic[]
+): TranslationEntry | undefined {
+ const report: DiagnosticReporter = (level, message) => {
+ diagnostics.push({ level, message })
+ }
+
+ if (meta !== undefined && !isRecord(meta)) {
+ diagnostics.push({
+ level: 'error',
+ message: `Metadata for "${key}" in ${filePath} must be an object.`,
+ })
+ return undefined
+ }
+
+ if (meta === undefined || meta.placeholders === undefined) {
+ return { type: 'text', value }
+ }
+
+ if (!isRecord(meta.placeholders)) {
+ diagnostics.push({
+ level: 'error',
+ message: `Placeholders for "${key}" in ${filePath} must be an object.`,
+ })
+ return undefined
+ }
+
+ const params: FuncParam[] = []
+ for (const [name, definition] of Object.entries(meta.placeholders)) {
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
+ diagnostics.push({
+ level: 'error',
+ message: `Placeholder "${name}" of "${key}" in ${filePath} is not a valid identifier.`,
+ })
+ return undefined
+ }
+
+ if (definition !== undefined && !isRecord(definition)) {
+ diagnostics.push({
+ level: 'warning',
+ message: `Placeholder "${name}" of "${key}" in ${filePath} must be an object. Interpreting its type as string.`,
+ })
+ params.push({ name, typing: 'string' })
+ continue
+ }
+
+ if (isRecord(definition) && definition.type !== undefined && typeof definition.type !== 'string') {
+ diagnostics.push({
+ level: 'warning',
+ message: `Type of placeholder "${name}" of "${key}" in ${filePath} must be a string. Interpreting it as string.`,
+ })
+ params.push({ name, typing: 'string' })
+ continue
+ }
+
+ const rawType = isRecord(definition) && typeof definition.type === 'string'
+ ? definition.type
+ : undefined
+
+ params.push({
+ name,
+ ...resolvePlaceholderType(rawType, name, report, { key, filePath }),
+ })
+ }
+
+ return { type: 'func', params, value }
+}
+
+function readTranslationFile(
+ filePath: string,
+ config: Pick,
+ catalog: LocaleCatalog,
+ diagnostics: IntlDiagnostic[]
+): void {
+ const content = readArbObject(filePath, diagnostics)
+ if (!content) return
+
+ const localeValue = content['@@locale']
+ if (typeof localeValue !== 'string' || localeValue.length === 0) {
+ diagnostics.push({
+ level: 'error',
+ message: `File ${filePath} must set @@locale to one of: ${config.supportedLocales.join(', ')}.`,
+ })
+ return
+ }
+ if (!config.supportedLocales.includes(localeValue)) {
+ diagnostics.push({
+ level: 'error',
+ message: `Locale "${localeValue}" in ${filePath} is not listed in supportedLocales.`,
+ })
+ return
+ }
+
+ let prefix = ''
+ if (Object.prototype.hasOwnProperty.call(content, '@@prefix')) {
+ const prefixValue = content['@@prefix']
+ if (typeof prefixValue !== 'string' || !isPrefix(prefixValue)) {
+ diagnostics.push({
+ level: 'error',
+ message: `File ${filePath} has an invalid @@prefix. Use a dotted identifier such as "test" or "time.relative".`,
+ })
+ return
+ }
+ prefix = prefixValue
+ }
+
+ if (!catalog[localeValue]) catalog[localeValue] = {}
+
+ for (const [key, value] of Object.entries(content)) {
+ if (key.startsWith('@')) continue
+ if (typeof value !== 'string') {
+ diagnostics.push({
+ level: 'error',
+ message: `Key "${key}" in ${filePath} must be a string.`,
+ })
+ continue
+ }
+
+ const flatKey = prefix ? `${prefix}.${key}` : key
+ if (catalog[localeValue][flatKey]) {
+ diagnostics.push({
+ level: 'warning',
+ message: `Key "${flatKey}" for locale "${localeValue}" is defined more than once. The definition in ${filePath} was ignored.`,
+ })
+ continue
+ }
+
+ const entry = buildEntry(key, value, content[`@${key}`], filePath, diagnostics)
+ if (entry) catalog[localeValue][flatKey] = entry
+ }
+}
+
+function flagInconsistencies(
+ catalog: LocaleCatalog,
+ supportedLocales: readonly string[],
+ diagnostics: IntlDiagnostic[]
+): void {
+ const presentLocales = supportedLocales.filter(locale => catalog[locale] !== undefined)
+
+ for (const locale of supportedLocales) {
+ if (catalog[locale] === undefined) {
+ diagnostics.push({
+ level: 'warning',
+ message: `Supported locale "${locale}" has no .arb file.`,
+ })
+ }
+ }
+
+ const keys = new Set()
+ for (const locale of presentLocales) {
+ for (const key of Object.keys(catalog[locale])) keys.add(key)
+ }
+
+ for (const key of [...keys].sort((left, right) => left.localeCompare(right))) {
+ const defined = presentLocales.filter(locale => catalog[locale][key] !== undefined)
+ const missing = presentLocales.filter(locale => catalog[locale][key] === undefined)
+ if (missing.length > 0) {
+ diagnostics.push({
+ level: 'warning',
+ message: `Key "${key}" is missing for ${missing.map(locale => `"${locale}"`).join(', ')}. It is defined for ${defined.map(locale => `"${locale}"`).join(', ')}.`,
+ })
+ }
+
+ const baseLocale = defined[0]
+ const baseSignature = describeTranslationEntry(catalog[baseLocale][key])
+ for (const locale of defined.slice(1)) {
+ const signature = describeTranslationEntry(catalog[locale][key])
+ if (signature === baseSignature) continue
+ diagnostics.push({
+ level: 'warning',
+ message: `Key "${key}" has inconsistent typing. "${baseLocale}" is ${baseSignature}, "${locale}" is ${signature}.`,
+ })
+ }
+ }
+}
+
+export function collectTranslations(
+ locations: readonly string[],
+ config: Pick
+): CollectedTranslations {
+ const diagnostics: IntlDiagnostic[] = []
+ const catalog: LocaleCatalog = {}
+
+ if (locations.length === 0) {
+ diagnostics.push({
+ level: 'error',
+ message: 'Provide at least one .arb file or directory.',
+ })
+ return { catalog, diagnostics }
+ }
+
+ const files = collectArbPaths(locations, diagnostics)
+ if (files.length === 0 && !diagnostics.some(item => item.level === 'error')) {
+ diagnostics.push({
+ level: 'error',
+ message: 'No .arb files were found in the given paths.',
+ })
+ }
+
+ for (const filePath of files) {
+ readTranslationFile(filePath, config, catalog, diagnostics)
+ }
+
+ flagInconsistencies(catalog, config.supportedLocales, diagnostics)
+
+ return { catalog, diagnostics }
+}
diff --git a/src/scripts/compile-arb.ts b/src/scripts/compile-arb.ts
deleted file mode 100644
index a056536..0000000
--- a/src/scripts/compile-arb.ts
+++ /dev/null
@@ -1,432 +0,0 @@
-#!/usr/bin/env node
-import fs from 'fs'
-import path from 'path'
-import readline from 'readline'
-import { ICUUtil } from '../'
-import { compileToCode } from '@/src/compile-to-code'
-
-/* ------------------ types ------------------ */
-
-interface PlaceholderMeta {
- type?: string,
-}
-
-interface ARBPlaceholders {
- [key: string]: PlaceholderMeta,
-}
-
-interface ARBMeta {
- placeholders?: ARBPlaceholders,
-}
-
-interface ARBFile {
- [key: string]: string | ARBMeta,
-}
-
-export interface FuncParam {
- name: string,
- typing: string,
-}
-
-type TranslationEntry =
- { type: 'text', value: string }
- | { type: 'func', params: FuncParam[], value: string }
- | { type: 'nested', value: Record }
-
-/* ------------------ CLI args ------------------ */
-function parseArgs() {
- const args = process.argv.slice(2)
- const result: {
- input?: string,
- outputFile?: string,
- force: boolean,
- help: boolean,
- name: string,
- } = {
- force: false,
- help: false,
- name: 'generatedTranslation'
- }
-
- for (let i = 0; i < args.length; i++) {
- const arg = args[i]
-
- switch (arg) {
- case '--in':
- case '-i':
- result.input = args[++i]
- break
-
- case '--out':
- case '-o':
- result.outputFile = args[++i]
- break
-
- case '--name':
- case '-n':
- result.name = args[++i]
- break
-
- case '--force':
- case '-f':
- result.force = true
- break
-
- case '--help':
- case '-h':
- result.help = true
- break
- }
- }
-
- return result
-}
-
-
-function printHelp() {
- console.info(`
-Usage: i18n-compile [options]
-
-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
-`)
-}
-
-const parsed = parseArgs()
-
-if (parsed.help) {
- printHelp()
- process.exit(0)
-}
-
-const inputDir =
- parsed.input || path.resolve(process.cwd(), './locales')
-
-// Default output file if none given
-const OUTPUT_FILE =
- parsed.outputFile ||
- path.resolve(process.cwd(), './i18n/translations.ts')
-
-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.`)
-}
-
-/* ------------------ prompts ------------------ */
-
-function askQuestion(query: string): Promise {
- const rl = readline.createInterface({
- input: process.stdin,
- output: process.stdout
- })
- return new Promise(resolve =>
- rl.question(query, ans => {
- rl.close()
- resolve(ans)
- }))
-}
-
-/* ------------------ I/O helpers ------------------ */
-
-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 ------------------ */
-
-function readARBDir(
- dir: string,
- prefix = ''
-): Record> {
- const result: Record> = {}
-
- const entries = fs.readdirSync(dir, { withFileTypes: true })
-
- for (const entry of entries) {
- const fullPath = path.join(dir, entry.name)
-
- if (entry.isDirectory()) {
- const newPrefix = prefix ? `${prefix}.${entry.name}` : entry.name
- const values = readARBDir(fullPath, newPrefix)
- for (const locale of locales) {
- if (values[locale]) {
- if (!result[locale]) result[locale] = {}
- Object.assign(result[locale], values[locale])
- }
- }
- continue
- }
-
- if (!entry.isFile() || !entry.name.endsWith('.arb')) continue
-
- const locale = path.basename(entry.name, '.arb')
- locales.add(locale)
-
- const raw = fs.readFileSync(fullPath, 'utf-8')
- const content: ARBFile = JSON.parse(raw)
-
- if (!result[locale]) result[locale] = {}
-
- for (const [key, value] of Object.entries(content)) {
- if (key.startsWith('@')) continue
-
- const meta = content[`@${key}`] as ARBMeta | undefined
- const flatKey = prefix ? `${prefix}.${key}` : key
-
- let entryObj: TranslationEntry
-
- const nameOverwrites: { type: string, names: string[] }[] = [
- { type: 'number', names: ['count', 'amount', 'length', 'number'] },
- { type: 'Date', names: ['date', 'dateTime'] },
- { type: 'string', names: ['name'] },
- ]
- const typeOverwrites: { type: string, types: string[] }[] = [
- { type: 'number', types: ['int', 'float', 'double', 'Int', 'Float', 'Double'] },
- { type: 'Date', types: ['date', 'DateTime', 'dateTime'] },
- { type: 'string', types: ['String'] },
- ]
-
- try {
- if (meta?.placeholders) {
- // ICU function
- const params: FuncParam[] = Object.entries(meta.placeholders).map(
- ([name, def]) => {
- let typing = def.type
- if (typing) {
- const legalCharacters = /^[a-zA-Z]+$/.test(typing)
- if(!legalCharacters) {
- typing = 'string'
- console.error(`Type "${typing}" contained illegal characters for variable [${name}] of [${key}] in file ${fullPath}`)
- } else {
- // type matching for .arb of other programming languages
- for (const typeOverwrite of typeOverwrites) {
- if (typeOverwrite.types.includes(name)) {
- typing = typeOverwrite.type
- break
- }
- }
- }
- } else {
- typing = 'string'
- for (const nameOverwrite of nameOverwrites) {
- if (nameOverwrite.names.includes(name)) {
- console.warn([
- `Variable [${name}] of [${key}] in file ${fullPath}`,
- `Was considered a ${nameOverwrite.type} due to the name being in ${nameOverwrite.names}.`,
- `To prevent this warning set the type explicitly or consider renaming it`
- ].join('\n'))
- typing = nameOverwrite.type
- break
- }
- }
- }
- return { name, typing }
- }
- )
-
- entryObj = {
- type: 'func',
- params,
- value: value as string,
- }
- } else {
- entryObj = {
- type: 'text',
- value: value as string
- }
- }
-
- result[locale][flatKey] = entryObj
- } catch (e) {
- console.error(`Failed to load [${key}] in file ${fullPath}`, e)
- }
- }
- }
-
- return result
-}
-
-/* ------------------ code generator: values ------------------ */
-
-export function generateCode(
- obj: Record,
- indentLevel = 1
-): string {
- const indent = ' '.repeat(indentLevel)
- const entries = Object.entries(obj).sort((a, b) =>
- a[0].localeCompare(b[0]))
-
- let str = ''
-
- for (const [key, entry] of entries) {
- const quotedKey = `'${key}'`
- const isLast = entries[entries.length - 1][0] === key
- const comma = isLast ? '' : ','
-
- try {
- if (entry.type === 'func') {
- const ast = ICUUtil.parse(ICUUtil.lex(entry.value))
- let compiled = compileToCode(ast).map((value, index, array) => {
- if (value.startsWith(' ')) {
- return value
- } else {
- if (index - 1 < 0 || !array[index - 1].startsWith(' ')) {
- return `_out += ${value}`
- }
- }
- return value
- })
- if (compiled.filter(value => value.startsWith('_out +=')).length === 1) {
- const first = compiled.findIndex(value => value.startsWith('_out +='))
- compiled[first] = 'return ' + compiled[first].slice(8)
- } else {
- compiled = [
- "let _out: string = ''",
- ...compiled,
- 'return _out',
- ]
- }
- const functionLines: string[] = [
- `({ ${entry.params.map(value => value.name).join(', ')} }): string => {`,
- ...compiled.map(value => ` ${value}`),
- '}',
- ]
- str += `${indent}${quotedKey}: ${functionLines.join(`\n${indent}`)}${comma}\n`
- } else if (entry.type === 'text') {
- const ast = ICUUtil.parse(ICUUtil.lex(entry.value))
- const compiled = compileToCode(ast, { isOnlyText: true })
- const text = compiled.length === 1 ? compiled[0] : `\`${(entry.value)}\``
- str += `${indent}${quotedKey}: ${text}${comma}\n`
- } else {
- // nested object
- str += `${indent}${quotedKey}: {\n`
- str += generateCode(entry.value, indentLevel + 1)
- str += `${indent}}${comma}\n`
- }
- } catch (e) {
- console.error(`Failed to compile key [${key}] with value "${entry.value}"`, e)
- }
- }
-
- return str
-}
-
-/* ------------------ code generator: type ------------------ */
-
-function generateType(
- translationData: Record>
-): string {
- const indent = ' '
- const fullObject: Record = {}
- const completedLocales: string[] = []
-
- for (const locale of locales) {
- const localizedEntries = Object.entries(
- translationData[locale]
- ).sort((a, b) => a[0].localeCompare(b[0]))
-
- for (const [name, entry] of localizedEntries) {
- if (!fullObject[name]) {
- fullObject[name] = entry
- continue
- }
-
- // type consistency is logged but not enforced
- }
- completedLocales.push(locale)
- }
-
- let str = ''
-
- for (const [key, entry] of Object.entries(fullObject)) {
- const quotedKey = `'${key}'`
-
- if (entry.type === 'func') {
- const params = entry.params
- .map(p => `${p.name}: ${p.typing}`)
- .join(', ')
- str += `${indent}${quotedKey}: (values: { ${params} }) => string,\n`
- } else if (entry.type === 'text') {
- str += `${indent}${quotedKey}: string,\n`
- }
- }
-
- return str
-}
-
-/* ------------------ main ------------------ */
-
-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`
-
- 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 ${localesTypeName} = typeof ${localesVarName}[number]\n\n`
-
- const typeName = `${capitalize(name)}Entries`
- const generatedTyping = generateType(translationData)
- 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 ${name}: Translation<${localesTypeName}, Partial<${typeName}>> = {\n${generatedTranslation}}\n\n`
-
- if (fs.existsSync(OUTPUT_FILE) && !force) {
- const answer = await askQuestion(
- `File "${OUTPUT_FILE}" already exists. Overwrite? (y/N): `
- )
- if (!['y', 'yes'].includes(answer.trim().toLowerCase())) {
- console.info('Aborted.')
- return
- }
- }
-
- fs.writeFileSync(OUTPUT_FILE, output)
- console.info(`✓ Translations compiled to ${OUTPUT_FILE}`)
- console.info(`Input folder: ${inputDir}`)
- console.info(`Output folder: ${outputDir}`)
-}
-
-main()
diff --git a/src/scripts/compile-typescript.ts b/src/scripts/compile-typescript.ts
new file mode 100644
index 0000000..6a2a3e5
--- /dev/null
+++ b/src/scripts/compile-typescript.ts
@@ -0,0 +1,173 @@
+import { compileToCode } from '../compile-to-code'
+import { ICUUtil } from '../icu'
+import type { FuncParam, LocaleCatalog, TranslationEntry } from './collect-translations'
+import type { IntlConfig } from './intl-config'
+
+type GeneratedEntry =
+ TranslationEntry
+ | { type: 'nested', value: Record }
+
+export interface CompileResult {
+ source: string,
+ errors: string[],
+}
+
+function capitalize(value: string): string {
+ if (value.length === 0) return value
+ return value.charAt(0).toUpperCase() + value.slice(1)
+}
+
+function quoteKey(key: string): string {
+ const escaped = key
+ .replace(/\\/g, '\\\\')
+ .replace(/'/g, '\\\'')
+ .replace(/\r/g, '\\r')
+ .replace(/\n/g, '\\n')
+ return `'${escaped}'`
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error)
+}
+
+function numericFormats(params: readonly FuncParam[]): Record | undefined {
+ const formats: Record = {}
+ for (const param of params) {
+ if (param.numeric) formats[param.name] = param.numeric
+ }
+ if (Object.keys(formats).length === 0) return undefined
+ return formats
+}
+
+function generateCode(
+ obj: Record,
+ errors: string[],
+ indentLevel = 1
+): string {
+ const indent = ' '.repeat(indentLevel)
+ const entries = Object.entries(obj).sort((left, right) => left[0].localeCompare(right[0]))
+ let source = ''
+
+ for (const [key, entry] of entries) {
+ const quotedKey = quoteKey(key)
+ const isLast = entries[entries.length - 1][0] === key
+ const comma = isLast ? '' : ','
+
+ try {
+ if (entry.type === 'func') {
+ const ast = ICUUtil.parse(ICUUtil.lex(entry.value))
+ let compiled = compileToCode(ast, { numericFormats: numericFormats(entry.params) }).map((value, index, array) => {
+ if (value.startsWith(' ')) return value
+ if (index - 1 < 0 || !array[index - 1].startsWith(' ')) {
+ return `_out += ${value}`
+ }
+ return value
+ })
+ if (compiled.filter(value => value.startsWith('_out +=')).length === 1) {
+ const first = compiled.findIndex(value => value.startsWith('_out +='))
+ compiled[first] = 'return ' + compiled[first].slice(8)
+ } else {
+ compiled = [
+ 'let _out: string = \'\'',
+ ...compiled,
+ 'return _out',
+ ]
+ }
+ const functionLines: string[] = [
+ `({ ${entry.params.map(value => value.name).join(', ')} }): string => {`,
+ ...compiled.map(value => ` ${value}`),
+ '}',
+ ]
+ source += `${indent}${quotedKey}: ${functionLines.join(`\n${indent}`)}${comma}\n`
+ } else if (entry.type === 'text') {
+ const ast = ICUUtil.parse(ICUUtil.lex(entry.value))
+ const compiled = compileToCode(ast, { isOnlyText: true })
+ const text = compiled.length === 1 ? compiled[0] : `\`${entry.value}\``
+ source += `${indent}${quotedKey}: ${text}${comma}\n`
+ } else {
+ source += `${indent}${quotedKey}: {\n`
+ source += generateCode(entry.value, errors, indentLevel + 1)
+ source += `${indent}}${comma}\n`
+ }
+ } catch (error) {
+ const shown = entry.type === 'nested' ? key : entry.value
+ errors.push(`Failed to compile key [${key}] with value "${shown}": ${errorMessage(error)}`)
+ }
+ }
+
+ return source
+}
+
+function generateType(catalog: LocaleCatalog, locales: readonly string[]): string {
+ const indent = ' '
+ const fullObject: Record = {}
+
+ for (const locale of locales) {
+ const localizedEntries = Object.entries(catalog[locale] ?? {}).sort((left, right) => {
+ return left[0].localeCompare(right[0])
+ })
+ for (const [name, entry] of localizedEntries) {
+ if (!fullObject[name]) fullObject[name] = entry
+ }
+ }
+
+ let source = ''
+ for (const [key, entry] of Object.entries(fullObject)) {
+ const quotedKey = quoteKey(key)
+ if (entry.type === 'func') {
+ const params = entry.params.map(param => `${param.name}: ${param.typing}`).join(', ')
+ source += `${indent}${quotedKey}: (values: { ${params} }) => string,\n`
+ } else {
+ source += `${indent}${quotedKey}: string,\n`
+ }
+ }
+
+ return source
+}
+
+export function compileToTypescript(
+ catalog: LocaleCatalog,
+ supportedLocales: readonly string[],
+ exportName: string
+): CompileResult {
+ const errors: string[] = []
+ const localesVarName = `${exportName}Locales`
+ const localesTypeName = `${capitalize(exportName)}Locales`
+ const typeName = `${capitalize(exportName)}Entries`
+ const value: Record = {}
+
+ for (const locale of supportedLocales) {
+ value[locale] = { type: 'nested', value: catalog[locale] ?? {} }
+ }
+
+ let source = '// AUTO-GENERATED. DO NOT EDIT.\n'
+ source += '/* eslint-disable @stylistic/quote-props */\n'
+ source += '/* eslint-disable no-useless-escape */\n'
+ source += '/* eslint-disable @typescript-eslint/no-unused-vars */\n'
+ source += 'import type { Translation } from \'@helpwave/internationalization\'\n'
+ source += 'import { TranslationGen } from \'@helpwave/internationalization\'\n\n'
+ source += `export const ${localesVarName} = [${supportedLocales.map(locale => quoteKey(locale)).join(', ')}] as const\n\n`
+ source += `export type ${localesTypeName} = typeof ${localesVarName}[number]\n\n`
+ source += `export type ${typeName} = {\n${generateType(catalog, supportedLocales)}}\n\n`
+ source += `export const ${exportName}: Translation<${localesTypeName}, Partial<${typeName}>> = {\n${generateCode(value, errors)}}\n\n`
+
+ return { source, errors }
+}
+
+export function compileTranslations(
+ catalog: LocaleCatalog,
+ config: IntlConfig,
+ exportName: string
+): CompileResult {
+ switch (config.compileTo) {
+ case 'typescript':
+ return compileToTypescript(catalog, config.supportedLocales, exportName)
+ default: {
+ const target: never = config.compileTo
+ return {
+ source: '',
+ errors: [`Compile target "${String(target)}" is not supported.`],
+ }
+ }
+ }
+}
diff --git a/src/scripts/intl-config.ts b/src/scripts/intl-config.ts
new file mode 100644
index 0000000..2c2e391
--- /dev/null
+++ b/src/scripts/intl-config.ts
@@ -0,0 +1,120 @@
+import fs from 'fs'
+import path from 'path'
+
+export const COMPILE_TARGETS = ['typescript'] as const
+
+export type CompileTarget = typeof COMPILE_TARGETS[number]
+
+export const DEFAULT_SUPPORTED_LOCALES = ['en-US', 'de-DE']
+
+export const DEFAULT_COMPILE_TARGET: CompileTarget = 'typescript'
+
+export const DEFAULT_EXPORT_NAME = 'appTranslation'
+
+export const DEFAULT_OUTPUT_FILE = './i18n/translations.ts'
+
+export interface IntlConfig {
+ supportedLocales: string[],
+ compileTo: CompileTarget,
+ name: string,
+ outputFile: string,
+ translationFiles: string[],
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+function readLocaleList(value: unknown): string[] {
+ if (!Array.isArray(value)) {
+ throw new Error('Config field "supportedLocales" must be an array of strings.')
+ }
+ if (value.length === 0) {
+ throw new Error('Config field "supportedLocales" must list at least one locale.')
+ }
+
+ const locales: string[] = []
+ for (const entry of value) {
+ if (typeof entry !== 'string' || !/^[A-Za-z0-9_-]+$/.test(entry)) {
+ throw new Error(`Locale ${JSON.stringify(entry)} in supportedLocales may only contain letters, numbers, hyphens, and underscores.`)
+ }
+ locales.push(entry)
+ }
+
+ if (new Set(locales).size !== locales.length) {
+ throw new Error('Config field "supportedLocales" contains duplicates.')
+ }
+
+ return locales
+}
+
+function readCompileTarget(value: unknown): CompileTarget {
+ if (value === 'typescript') return 'typescript'
+ throw new Error(`Config field "compileTo" must be "typescript". Received ${JSON.stringify(value)}.`)
+}
+
+function readStringSetting(value: unknown, field: string, fallback: string): string {
+ if (value === undefined) return fallback
+ if (typeof value !== 'string' || value.trim().length === 0) {
+ throw new Error(`Config field "${field}" must be a non-empty string.`)
+ }
+ return value
+}
+
+function readTranslationFiles(value: unknown): string[] {
+ if (value === undefined) return []
+ if (!Array.isArray(value)) {
+ throw new Error('Config field "translationFiles" must be an array of paths.')
+ }
+
+ const files: string[] = []
+ for (const entry of value) {
+ if (typeof entry !== 'string' || entry.trim().length === 0) {
+ throw new Error('Config field "translationFiles" must contain non-empty path strings.')
+ }
+ files.push(entry)
+ }
+ return files
+}
+
+export function defaultIntlConfig(): IntlConfig {
+ return {
+ supportedLocales: [...DEFAULT_SUPPORTED_LOCALES],
+ compileTo: DEFAULT_COMPILE_TARGET,
+ name: DEFAULT_EXPORT_NAME,
+ outputFile: DEFAULT_OUTPUT_FILE,
+ translationFiles: [],
+ }
+}
+
+export function loadIntlConfig(configPath: string): IntlConfig {
+ const resolved = path.resolve(configPath)
+ if (!fs.existsSync(resolved)) {
+ throw new Error(`Configuration file not found: ${configPath}`)
+ }
+
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(fs.readFileSync(resolved, 'utf-8'))
+ } catch (error) {
+ const reason = error instanceof Error ? error.message : String(error)
+ throw new Error(`Configuration file is not valid JSON: ${configPath} (${reason})`)
+ }
+
+ if (!isRecord(parsed)) {
+ throw new Error('Configuration file must contain a JSON object.')
+ }
+
+ const defaults = defaultIntlConfig()
+ return {
+ supportedLocales: parsed.supportedLocales === undefined
+ ? defaults.supportedLocales
+ : readLocaleList(parsed.supportedLocales),
+ compileTo: parsed.compileTo === undefined
+ ? defaults.compileTo
+ : readCompileTarget(parsed.compileTo),
+ name: readStringSetting(parsed.name, 'name', defaults.name),
+ outputFile: readStringSetting(parsed.outputFile, 'outputFile', defaults.outputFile),
+ translationFiles: readTranslationFiles(parsed.translationFiles),
+ }
+}
diff --git a/src/scripts/output-diff.ts b/src/scripts/output-diff.ts
new file mode 100644
index 0000000..4297a29
--- /dev/null
+++ b/src/scripts/output-diff.ts
@@ -0,0 +1,116 @@
+interface LineChange {
+ kind: 'context' | 'add' | 'remove',
+ text: string,
+ oldLine?: number,
+ newLine?: number,
+}
+
+function splitLines(value: string): string[] {
+ if (value.length === 0) return []
+ const lines = value.split('\n')
+ if (value.endsWith('\n')) lines.pop()
+ return lines
+}
+
+function lineChanges(before: readonly string[], after: readonly string[]): LineChange[] {
+ const beforeCount = before.length
+ const afterCount = after.length
+ const scores: number[][] = Array.from({ length: beforeCount + 1 }, () => Array(afterCount + 1).fill(0))
+
+ for (let beforeIndex = beforeCount - 1; beforeIndex >= 0; beforeIndex--) {
+ for (let afterIndex = afterCount - 1; afterIndex >= 0; afterIndex--) {
+ scores[beforeIndex][afterIndex] = before[beforeIndex] === after[afterIndex]
+ ? scores[beforeIndex + 1][afterIndex + 1] + 1
+ : Math.max(scores[beforeIndex + 1][afterIndex], scores[beforeIndex][afterIndex + 1])
+ }
+ }
+
+ const changes: LineChange[] = []
+ let beforeIndex = 0
+ let afterIndex = 0
+ let oldLine = 1
+ let newLine = 1
+
+ while (beforeIndex < beforeCount && afterIndex < afterCount) {
+ if (before[beforeIndex] === after[afterIndex]) {
+ changes.push({ kind: 'context', text: before[beforeIndex], oldLine, newLine })
+ beforeIndex++
+ afterIndex++
+ oldLine++
+ newLine++
+ continue
+ }
+ if (scores[beforeIndex + 1][afterIndex] >= scores[beforeIndex][afterIndex + 1]) {
+ changes.push({ kind: 'remove', text: before[beforeIndex], oldLine })
+ beforeIndex++
+ oldLine++
+ continue
+ }
+ changes.push({ kind: 'add', text: after[afterIndex], newLine })
+ afterIndex++
+ newLine++
+ }
+
+ while (beforeIndex < beforeCount) {
+ changes.push({ kind: 'remove', text: before[beforeIndex], oldLine })
+ beforeIndex++
+ oldLine++
+ }
+ while (afterIndex < afterCount) {
+ changes.push({ kind: 'add', text: after[afterIndex], newLine })
+ afterIndex++
+ newLine++
+ }
+
+ return changes
+}
+
+function groupChanges(changes: readonly LineChange[], context: number): LineChange[][] {
+ const changedIndexes = changes
+ .map((change, index) => ({ change, index }))
+ .filter(item => item.change.kind !== 'context')
+ .map(item => item.index)
+ if (changedIndexes.length === 0) return []
+
+ const groups: LineChange[][] = []
+ let groupStart = changedIndexes[0]
+ let groupEnd = changedIndexes[0]
+
+ for (const index of changedIndexes.slice(1)) {
+ if (index - groupEnd <= context * 2) {
+ groupEnd = index
+ continue
+ }
+ groups.push(changes.slice(Math.max(0, groupStart - context), Math.min(changes.length, groupEnd + context + 1)))
+ groupStart = index
+ groupEnd = index
+ }
+
+ groups.push(changes.slice(Math.max(0, groupStart - context), Math.min(changes.length, groupEnd + context + 1)))
+ return groups
+}
+
+function formatHunk(lines: readonly LineChange[]): string {
+ const oldStart = lines.find(line => line.oldLine !== undefined)?.oldLine ?? 0
+ const newStart = lines.find(line => line.newLine !== undefined)?.newLine ?? 0
+ const oldCount = lines.filter(line => line.kind !== 'add').length
+ const newCount = lines.filter(line => line.kind !== 'remove').length
+ const body = lines.map(line => {
+ const prefix = line.kind === 'add' ? '+' : line.kind === 'remove' ? '-' : ' '
+ return `${prefix}${line.text}`
+ })
+ return [`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`, ...body].join('\n')
+}
+
+export function formatUnifiedDiff(filePath: string, before: string, after: string): string {
+ const groups = groupChanges(lineChanges(splitLines(before), splitLines(after)), 3)
+ if (groups.length === 0) return ''
+ return [`--- ${filePath}`, `+++ ${filePath}`, ...groups.map(group => formatHunk(group))].join('\n')
+}
+
+export function describeOutputChange(filePath: string, previous: string | undefined, next: string): string {
+ if (previous === next) return `No changes to ${filePath}`
+ const diff = formatUnifiedDiff(filePath, previous ?? '', next)
+ if (previous === undefined) return `Would create ${filePath}\n${diff}`
+ return `Would update ${filePath}\n${diff}`
+}
diff --git a/src/scripts/placeholder-type.ts b/src/scripts/placeholder-type.ts
new file mode 100644
index 0000000..af5b022
--- /dev/null
+++ b/src/scripts/placeholder-type.ts
@@ -0,0 +1,51 @@
+export type DiagnosticLevel = 'warning' | 'error'
+
+export type DiagnosticReporter = (level: DiagnosticLevel, message: string) => void
+
+export type NumericKind = 'int' | 'float'
+
+export interface ResolvedPlaceholderType {
+ typing: string,
+ numeric?: NumericKind,
+}
+
+const SUPPORTED_TYPES = ['string', 'int', 'float'] as const
+
+type SupportedType = typeof SUPPORTED_TYPES[number]
+
+function toFunctionType(typeName: SupportedType): ResolvedPlaceholderType {
+ if (typeName === 'int') return { typing: 'number', numeric: 'int' }
+ if (typeName === 'float') return { typing: 'number', numeric: 'float' }
+ return { typing: 'string' }
+}
+
+function isSupportedType(typeName: string): typeName is SupportedType {
+ return (SUPPORTED_TYPES as readonly string[]).includes(typeName)
+}
+
+export interface PlaceholderTypeContext {
+ key: string,
+ filePath: string,
+}
+
+export function resolvePlaceholderType(
+ rawType: string | undefined,
+ variableName: string,
+ report: DiagnosticReporter,
+ context: PlaceholderTypeContext
+): ResolvedPlaceholderType {
+ const location = `variable [${variableName}] of [${context.key}] in file ${context.filePath}`
+ const typing = rawType?.trim() ?? ''
+
+ if (!typing) {
+ report('warning', `Placeholder [${variableName}] of [${context.key}] in file ${context.filePath} has no type. Interpreting it as string.`)
+ return { typing: 'string' }
+ }
+
+ if (!isSupportedType(typing)) {
+ report('error', `Type "${typing}" for ${location} is not supported. Only string, int, and float are supported.`)
+ return { typing: 'string' }
+ }
+
+ return toFunctionType(typing)
+}
diff --git a/src/translationGeneration.ts b/src/translationGeneration.ts
index 7ecc706..0783363 100644
--- a/src/translationGeneration.ts
+++ b/src/translationGeneration.ts
@@ -21,7 +21,12 @@ function resolvePlural(
return typeof handler === 'function' ? handler() : handler
}
+function round(value: number): string {
+ return String(Math.round(value))
+}
+
export const TranslationGen = {
resolveSelect,
resolvePlural,
+ round,
}
diff --git a/tests/build-intl.test.ts b/tests/build-intl.test.ts
new file mode 100644
index 0000000..a838642
--- /dev/null
+++ b/tests/build-intl.test.ts
@@ -0,0 +1,400 @@
+import fs from 'fs'
+import os from 'os'
+import path from 'path'
+import { TranslationGen } from '../src'
+import { parseBuildIntlArgs, resolveExportName } from '../src/scripts/cli-args'
+import { collectTranslations } from '../src/scripts/collect-translations'
+import { compileTranslations } from '../src/scripts/compile-typescript'
+import { defaultIntlConfig, loadIntlConfig, type IntlConfig } from '../src/scripts/intl-config'
+import { describeOutputChange } from '../src/scripts/output-diff'
+
+const directories: string[] = []
+
+function createWorkspace(): string {
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'build-intl-'))
+ directories.push(directory)
+ return directory
+}
+
+function writeJson(filePath: string, value: unknown): void {
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
+ fs.writeFileSync(filePath, JSON.stringify(value, null, 2))
+}
+
+const baseConfig: IntlConfig = {
+ supportedLocales: ['en-US', 'de-DE'],
+ compileTo: 'typescript',
+ name: 'appTranslation',
+ outputFile: './i18n/translations.ts',
+ translationFiles: [],
+}
+
+afterAll(() => {
+ for (const directory of directories) {
+ fs.rmSync(directory, { recursive: true, force: true })
+ }
+})
+
+describe('parseBuildIntlArgs', () => {
+ test('reads the config flag, options, and location paths', () => {
+ const parsed = parseBuildIntlArgs([
+ '--config',
+ 'intl.json',
+ './locales',
+ 'messages/en-US.arb',
+ '-o',
+ 'out.ts',
+ '-n',
+ 'exampleTranslation',
+ '--dry-run',
+ ])
+
+ expect(parsed).toEqual({
+ configPath: 'intl.json',
+ outputFile: 'out.ts',
+ dryRun: true,
+ help: false,
+ name: 'exampleTranslation',
+ locations: ['./locales', 'messages/en-US.arb'],
+ errors: [],
+ })
+ })
+
+ test('accepts -c, --config=, and paths after --', () => {
+ const short = parseBuildIntlArgs(['-c', 'intl.json', '--', '--locales'])
+ const equals = parseBuildIntlArgs(['--config=intl.json', 'locales'])
+
+ expect(short.configPath).toBe('intl.json')
+ expect(short.locations).toEqual(['--locales'])
+ expect(short.errors).toEqual([])
+ expect(equals).toMatchObject({
+ configPath: 'intl.json',
+ locations: ['locales'],
+ errors: [],
+ })
+ })
+
+ test('reports a missing config value and unknown options', () => {
+ const parsed = parseBuildIntlArgs(['--config', '--out'])
+
+ expect(parsed.errors).toEqual([
+ 'Option --config requires a value.',
+ 'Option --out requires a value.',
+ ])
+ })
+})
+
+describe('loadIntlConfig', () => {
+ test('loads supported locales, datatypes, and the compile target', () => {
+ const directory = createWorkspace()
+ const configPath = path.join(directory, 'intl.json')
+ writeJson(configPath, baseConfig)
+
+ expect(loadIntlConfig(configPath)).toEqual(baseConfig)
+ })
+
+ test('uses defaults when options are omitted', () => {
+ const directory = createWorkspace()
+ const configPath = path.join(directory, 'intl.json')
+ writeJson(configPath, { supportedLocales: ['fr-FR'] })
+ writeJson(path.join(directory, 'empty.json'), {})
+
+ expect(loadIntlConfig(path.join(directory, 'empty.json'))).toEqual(defaultIntlConfig())
+ const parsed = parseBuildIntlArgs(['./locales', './shared/locales'])
+ expect(parsed.configPath).toBeUndefined()
+ expect(parsed.locations).toEqual(['./locales', './shared/locales'])
+ expect(parsed.errors).toEqual([])
+ expect(loadIntlConfig(configPath)).toEqual({
+ supportedLocales: ['fr-FR'],
+ compileTo: 'typescript',
+ name: 'appTranslation',
+ outputFile: './i18n/translations.ts',
+ translationFiles: [],
+ })
+ })
+
+ test('reads name, outputFile, and translationFiles', () => {
+ const directory = createWorkspace()
+ const configPath = path.join(directory, 'intl.json')
+ writeJson(configPath, {
+ name: 'exampleTranslation',
+ outputFile: './examples/translations/translations.ts',
+ translationFiles: ['./examples/locales', './extra/en-US.arb'],
+ })
+
+ const config = loadIntlConfig(configPath)
+ expect(config.name).toBe('exampleTranslation')
+ expect(config.outputFile).toBe('./examples/translations/translations.ts')
+ expect(config.translationFiles).toEqual(['./examples/locales', './extra/en-US.arb'])
+ })
+
+ test('rejects compile targets other than typescript', () => {
+ const directory = createWorkspace()
+ const configPath = path.join(directory, 'intl.json')
+ writeJson(configPath, { ...baseConfig, compileTo: 'python' })
+
+ expect(() => loadIntlConfig(configPath)).toThrow('compileTo')
+ })
+})
+
+describe('collectTranslations', () => {
+ test('collects arb files from directories and file paths and applies @@prefix', () => {
+ const directory = createWorkspace()
+ const locales = path.join(directory, 'locales')
+ const extra = path.join(directory, 'extra', 'de-DE.arb')
+ writeJson(path.join(locales, 'nested', 'en-US.arb'), {
+ '@@locale': 'en-US',
+ '@@prefix': 'test',
+ 'name': 'Ada',
+ })
+ writeJson(path.join(locales, 'notes.txt'), { ignored: true })
+ writeJson(extra, {
+ '@@locale': 'de-DE',
+ '@@prefix': 'test',
+ 'name': 'Ada',
+ })
+
+ const collected = collectTranslations([locales, extra], baseConfig)
+
+ expect(collected.diagnostics.filter(item => item.level === 'error')).toEqual([])
+ expect(collected.catalog['en-US']['test.name']).toEqual({ type: 'text', value: 'Ada' })
+ expect(collected.catalog['de-DE']['test.name']).toEqual({ type: 'text', value: 'Ada' })
+ expect(collected.catalog['en-US'].name).toBeUndefined()
+ })
+
+ test('does not prefix keys from a nested directory unless @@prefix is set', () => {
+ const directory = createWorkspace()
+ writeJson(path.join(directory, 'sub', 'en-US.arb'), {
+ '@@locale': 'en-US',
+ 'name': 'Ada',
+ })
+
+ const collected = collectTranslations([directory], {
+ ...baseConfig,
+ supportedLocales: ['en-US'],
+ })
+
+ expect(collected.catalog['en-US'].name).toEqual({ type: 'text', value: 'Ada' })
+ expect(collected.diagnostics.filter(item => item.level === 'error')).toEqual([])
+ })
+
+ test('requires @@locale and rejects locales outside the config', () => {
+ const directory = createWorkspace()
+ writeJson(path.join(directory, 'missing.arb'), { name: 'Ada' })
+ writeJson(path.join(directory, 'other.arb'), { '@@locale': 'fr-FR', 'name': 'Ada' })
+
+ const collected = collectTranslations([directory], {
+ ...baseConfig,
+ supportedLocales: ['en-US'],
+ })
+ const messages = collected.diagnostics.map(item => item.message).join('\n')
+
+ expect(collected.diagnostics.some(item => item.level === 'error')).toBe(true)
+ expect(messages).toEqual(expect.stringContaining('@@locale'))
+ expect(messages).toEqual(expect.stringContaining('fr-FR'))
+ expect(collected.catalog['fr-FR']).toBeUndefined()
+ })
+
+ test('warns about inconsistent keys and placeholder types', () => {
+ const directory = createWorkspace()
+ writeJson(path.join(directory, 'en-US.arb'), {
+ '@@locale': 'en-US',
+ 'greet': 'Hello {name}',
+ '@greet': { placeholders: { name: { type: 'string' } } },
+ 'onlyEnglish': 'Hi',
+ })
+ writeJson(path.join(directory, 'de-DE.arb'), {
+ '@@locale': 'de-DE',
+ 'greet': 'Hallo',
+ })
+
+ const collected = collectTranslations([directory], baseConfig)
+ const messages = collected.diagnostics.map(item => item.message)
+
+ expect(collected.diagnostics.filter(item => item.level === 'error')).toEqual([])
+ expect(messages).toEqual(expect.arrayContaining([
+ expect.stringContaining('Key "onlyEnglish" is missing'),
+ expect.stringContaining('Key "greet" has inconsistent typing'),
+ ]))
+ expect(messages.find(message => message.includes('inconsistent typing'))).toEqual(
+ expect.stringContaining('"en-US" is (name: string) => string, "de-DE" is string')
+ )
+ })
+
+ test('reports every unsupported placeholder type as an error', () => {
+ const directory = createWorkspace()
+ writeJson(path.join(directory, 'en-US.arb'), {
+ '@@locale': 'en-US',
+ 'status': '{flag}',
+ '@status': { placeholders: { flag: { type: 'bool' } } },
+ 'when': '{date}',
+ '@when': { placeholders: { date: { type: 'Date' } } },
+ 'total': '{count}',
+ '@total': { placeholders: { count: { type: 'int' } } },
+ 'items': '{count}',
+ '@items': { placeholders: { count: {} } },
+ })
+
+ const collected = collectTranslations([directory], {
+ ...baseConfig,
+ supportedLocales: ['en-US'],
+ })
+
+ expect(collected.catalog['en-US'].status).toEqual({
+ type: 'func',
+ params: [{ name: 'flag', typing: 'string' }],
+ value: '{flag}',
+ })
+ expect(collected.catalog['en-US'].total).toEqual({
+ type: 'func',
+ params: [{ name: 'count', typing: 'number', numeric: 'int' }],
+ value: '{count}',
+ })
+ expect(collected.catalog['en-US'].items).toEqual({
+ type: 'func',
+ params: [{ name: 'count', typing: 'string' }],
+ value: '{count}',
+ })
+ const unsupported = collected.diagnostics.filter(item => item.message.includes('is not supported'))
+ expect(unsupported).toEqual([
+ expect.objectContaining({ level: 'error', message: expect.stringContaining('Type "bool"') }),
+ expect.objectContaining({ level: 'error', message: expect.stringContaining('Type "Date"') }),
+ ])
+ expect(collected.diagnostics).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ level: 'warning',
+ message: expect.stringContaining('Placeholder [count] of [items]'),
+ }),
+ expect.objectContaining({
+ level: 'warning',
+ message: expect.stringContaining('Interpreting it as string.'),
+ }),
+ ]))
+ expect(collected.diagnostics.some(item => item.message.includes('Type "int"'))).toBe(false)
+ })
+
+ test('warns when a supported locale has no file', () => {
+ const directory = createWorkspace()
+ writeJson(path.join(directory, 'en-US.arb'), {
+ '@@locale': 'en-US',
+ 'name': 'Ada',
+ })
+
+ const collected = collectTranslations([directory], baseConfig)
+
+ expect(collected.diagnostics).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ level: 'warning',
+ message: expect.stringContaining('Supported locale "de-DE" has no .arb file.'),
+ }),
+ ]))
+ })
+})
+
+describe('TranslationGen.round', () => {
+ test('rounds a number to a string', () => {
+ expect(TranslationGen.round(1.6)).toBe('2')
+ expect(TranslationGen.round(1.2)).toBe('1')
+ expect(TranslationGen.round(-1.2)).toBe('-1')
+ })
+})
+
+describe('compileTranslations', () => {
+ test('rounds int placeholders and inserts float placeholders as given', () => {
+ const directory = createWorkspace()
+ writeJson(path.join(directory, 'en-US.arb'), {
+ '@@locale': 'en-US',
+ 'total': '{count} items',
+ '@total': { placeholders: { count: { type: 'int' } } },
+ 'price': '{amount}',
+ '@price': { placeholders: { amount: { type: 'float' } } },
+ 'items': '{count, plural, =1{# item} other{# items}}',
+ '@items': { placeholders: { count: { type: 'int' } } },
+ })
+ const config: IntlConfig = {
+ supportedLocales: ['en-US'],
+ compileTo: 'typescript',
+ name: 'appTranslation',
+ outputFile: './i18n/translations.ts',
+ translationFiles: [],
+ }
+ const collected = collectTranslations([directory], config)
+ const compiled = compileTranslations(collected.catalog, config, 'app')
+
+ expect(collected.diagnostics.filter(item => item.level === 'error')).toEqual([])
+ expect(collected.catalog['en-US'].total).toEqual({
+ type: 'func',
+ params: [{ name: 'count', typing: 'number', numeric: 'int' }],
+ value: '{count} items',
+ })
+ expect(collected.catalog['en-US'].price).toEqual({
+ type: 'func',
+ params: [{ name: 'amount', typing: 'number', numeric: 'float' }],
+ value: '{amount}',
+ })
+ expect(compiled.errors).toEqual([])
+ expect(compiled.source).toContain('count: number')
+ expect(compiled.source).toContain('amount: number')
+ expect(compiled.source).toContain('${TranslationGen.round(count)} items')
+ expect(compiled.source).toContain('return `${amount}`')
+ expect(compiled.source).toContain('TranslationGen.resolvePlural(count, {')
+ expect(compiled.source).toContain('${TranslationGen.round(count)} item')
+ })
+
+ test('compiles prefixed keys to typescript', () => {
+ const directory = createWorkspace()
+ writeJson(path.join(directory, 'en-US.arb'), {
+ '@@locale': 'en-US',
+ '@@prefix': 'test',
+ 'name': 'Ada',
+ 'total': '{count} items',
+ '@total': { placeholders: { count: { type: 'int' } } },
+ })
+ const config: IntlConfig = {
+ ...baseConfig,
+ supportedLocales: ['en-US'],
+ }
+ const collected = collectTranslations([directory], config)
+ const compiled = compileTranslations(collected.catalog, config, 'exampleTranslation')
+
+ expect(compiled.errors).toEqual([])
+ expect(compiled.source).toContain("export const exampleTranslationLocales = ['en-US'] as const")
+ expect(compiled.source).toContain("'test.name': `Ada`")
+ expect(compiled.source).toContain("'test.total': ({ count }): string => {")
+ expect(compiled.source).toContain('count: number')
+ })
+
+ test('matches the checked-in example translation', () => {
+ const config = loadIntlConfig(path.resolve('examples/intl.config.json'))
+ const collected = collectTranslations([path.resolve('examples/locales')], config)
+ const compiled = compileTranslations(collected.catalog, config, 'exampleTranslation')
+ const expected = fs.readFileSync(path.resolve('examples/translations/translations.ts'), 'utf-8')
+
+ expect(collected.diagnostics.filter(item => item.level === 'error')).toEqual([])
+ expect(compiled.errors).toEqual([])
+ expect(compiled.source).toBe(expected)
+ })
+})
+
+describe('describeOutputChange', () => {
+ test('prints a diff for a new or changed file', () => {
+ expect(describeOutputChange('out.ts', 'alpha\nbeta\n', 'alpha\nbeta\n')).toBe('No changes to out.ts')
+
+ const created = describeOutputChange('out.ts', undefined, 'hello\n')
+ expect(created).toContain('Would create out.ts')
+ expect(created).toContain('+hello')
+
+ const updated = describeOutputChange('out.ts', 'alpha\nbeta\n', 'alpha\ngamma\n')
+ expect(updated).toContain('Would update out.ts')
+ expect(updated).toContain('-beta')
+ expect(updated).toContain('+gamma')
+ })
+})
+
+describe('resolveExportName', () => {
+ test('keeps a valid name and rewrites separators', () => {
+ expect(resolveExportName('exampleTranslation')).toEqual({ name: 'exampleTranslation' })
+ expect(resolveExportName('my-translation').name).toBe('my_translation')
+ expect(resolveExportName('Example').error).toEqual(expect.stringContaining('invalid'))
+ })
+})
diff --git a/tsconfig.json b/tsconfig.json
index bb0a426..88f2148 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,21 +2,22 @@
"compilerOptions": {
"target": "ES2019",
"module": "ESNext",
- "moduleResolution": "Node",
+ "moduleResolution": "bundler",
"outDir": "dist",
- "rootDir": "src",
+ "rootDir": "./",
"declaration": true,
"declarationDir": "dist",
"jsx": "react-jsx",
"esModuleInterop": true,
"skipLibCheck": true,
"strict": true,
- "baseUrl": ".",
"paths": {
"@/*": ["./*"]
- }
+ },
+ "types": ["jest", "node"],
},
"include": [
"src",
+ "tests"
],
}