From 87237f665ffd852f53874d5f7d12c2b924518879 Mon Sep 17 00:00:00 2001 From: Felix Thape <67233923+DasProffi@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:13:46 +0200 Subject: [PATCH 1/5] feat: make translation building config based --- CHANGELOG.md | 18 ++ README.md | 50 ++- examples/intl.config.json | 7 + examples/locales/de-DE.arb | 9 +- examples/locales/en-US.arb | 7 +- examples/locales/fr-FR.arb | 1 + examples/locales/nested/de-DE.arb | 4 +- examples/locales/nested/en-US.arb | 4 +- examples/locales/nested/fr-FR.arb | 1 + examples/translations/translations.ts | 28 +- package-lock.json | 6 +- package.json | 5 +- src/compile-to-code.ts | 11 +- src/scripts/build-intl.ts | 131 ++++++++ src/scripts/cli-args.ts | 122 ++++++++ src/scripts/collect-translations.ts | 379 ++++++++++++++++++++++ src/scripts/compile-arb.ts | 432 -------------------------- src/scripts/compile-typescript.ts | 173 +++++++++++ src/scripts/intl-config.ts | 109 +++++++ src/scripts/output-diff.ts | 116 +++++++ src/scripts/placeholder-type.ts | 65 ++++ src/translationGeneration.ts | 5 + tests/build-intl.test.ts | 398 ++++++++++++++++++++++++ tsconfig.json | 9 +- 24 files changed, 1614 insertions(+), 476 deletions(-) create mode 100644 examples/intl.config.json create mode 100644 src/scripts/build-intl.ts create mode 100644 src/scripts/cli-args.ts create mode 100644 src/scripts/collect-translations.ts delete mode 100644 src/scripts/compile-arb.ts create mode 100644 src/scripts/compile-typescript.ts create mode 100644 src/scripts/intl-config.ts create mode 100644 src/scripts/output-diff.ts create mode 100644 src/scripts/placeholder-type.ts create mode 100644 tests/build-intl.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f7915dc..b99413b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ 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). +## [Unreleased] + +### 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 + +### 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 +- `int` and `float` placeholders are `number` parameters and are rounded before they are inserted into the translation string + ## [0.4.0] - 2025-11-26 ### Added diff --git a/README.md b/README.md index 1bab1d4..3f9f5c4 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,42 @@ The file structure could look like this: -> en-US.arb ``` -Then run: +Describe the project in a JSON config file. Every field is optional: +```json +{ + "supportedLocales": ["en-US", "de-DE"], + "compileTo": "typescript", + "name": "appTranslation", + "outputFile": "./i18n/translations.ts", + "translationFiles": ["./locales", "./shared/locales"] +} +``` + +`supportedLocales`, `compileTo`, `name`, and `outputFile` default to 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. Placeholder types are `string`, `int`, and `float`. 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 those values before they are inserted into the translation string. + +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 --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 --config [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 + -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 +110,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..078fbce 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 => { @@ -93,7 +93,7 @@ export const exampleTranslation: Translation { let _out: string = '' - _out += `Der Preis beträgt ${price}` + _out += `Der Preis beträgt ${TranslationGen.round(price)}` _out += TranslationGen.resolveSelect(currency, { 'usd': `\$USD`, 'eur': `€`, @@ -123,7 +123,7 @@ export const exampleTranslation: Translation { let _out: string = '' - _out += `The price is ${price}` + _out += `The price is ${TranslationGen.round(price)}` _out += TranslationGen.resolveSelect(currency, { 'usd': `\$USD`, 'eur': `€`, @@ -212,7 +212,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' || kind === 'float') 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..5cef31d --- /dev/null +++ b/src/scripts/build-intl.ts @@ -0,0 +1,131 @@ +#!/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 { loadIntlConfig, type IntlConfig } from './intl-config' +import { describeOutputChange } from './output-diff' + +function printHelp(): void { + console.info(` +Usage: build-intl --config [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 + -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": [] + } + +Omitted fields use those defaults. +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"). +Placeholder types other than string, int, and float are errors. The output file is not written when the build reports an error. +int and float are number parameters. Their values are rounded before they are inserted into the string. +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) + } + if (!args.configPath) { + console.error('error: Missing required option --config .') + printHelp() + process.exit(1) + } + + let config: IntlConfig + try { + config = 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..cc162b3 --- /dev/null +++ b/src/scripts/intl-config.ts @@ -0,0 +1,109 @@ +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 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.') + } + + return { + supportedLocales: parsed.supportedLocales === undefined + ? [...DEFAULT_SUPPORTED_LOCALES] + : readLocaleList(parsed.supportedLocales), + compileTo: parsed.compileTo === undefined + ? DEFAULT_COMPILE_TARGET + : readCompileTarget(parsed.compileTo), + name: readStringSetting(parsed.name, 'name', DEFAULT_EXPORT_NAME), + outputFile: readStringSetting(parsed.outputFile, 'outputFile', DEFAULT_OUTPUT_FILE), + 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..da48ab3 --- /dev/null +++ b/src/scripts/placeholder-type.ts @@ -0,0 +1,65 @@ +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] + +const NAME_TYPE_HINTS: { type: SupportedType, names: string[] }[] = [ + { type: 'int', names: ['count', 'amount', 'length', 'number'] }, + { type: 'string', names: ['name'] }, +] + +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) { + const hint = NAME_TYPE_HINTS.find(entry => entry.names.includes(variableName)) + if (hint) { + const article = /^[aeiou]/i.test(hint.type) ? 'an' : 'a' + report('warning', [ + `Variable [${variableName}] of [${context.key}] in file ${context.filePath}`, + `was considered ${article} ${hint.type} because the name is one of ${hint.names.join(', ')}.`, + 'Set the type explicitly or rename it to avoid this warning.', + ].join(' ')) + return toFunctionType(hint.type) + } + 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..be4a459 --- /dev/null +++ b/tests/build-intl.test.ts @@ -0,0 +1,398 @@ +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 { 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({ + supportedLocales: ['en-US', 'de-DE'], + compileTo: 'typescript', + name: 'appTranslation', + outputFile: './i18n/translations.ts', + translationFiles: [], + }) + 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: 'number', numeric: 'int' }], + 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('was considered an int'), + }), + ])) + 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 and float placeholders before inserting them', () => { + 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 `${TranslationGen.round(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" ], } From 3b43017326eb6cd31525e9904ab0464f55950512 Mon Sep 17 00:00:00 2001 From: Felix Thape <67233923+DasProffi@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:18:46 +0200 Subject: [PATCH 2/5] chore: remove placeholder inference --- CHANGELOG.md | 1 + README.md | 2 +- src/scripts/build-intl.ts | 2 +- src/scripts/placeholder-type.ts | 16 +--------------- tests/build-intl.test.ts | 8 ++++++-- 5 files changed, 10 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b99413b..33dc6e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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 diff --git a/README.md b/README.md index 3f9f5c4..5424684 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ Describe the project in a JSON config file. Every field is optional: } ``` -`supportedLocales`, `compileTo`, `name`, and `outputFile` default to 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. Placeholder types are `string`, `int`, and `float`. 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`. +`supportedLocales`, `compileTo`, `name`, and `outputFile` default to 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 those values before they are inserted into the translation string. diff --git a/src/scripts/build-intl.ts b/src/scripts/build-intl.ts index 5cef31d..6899621 100644 --- a/src/scripts/build-intl.ts +++ b/src/scripts/build-intl.ts @@ -36,7 +36,7 @@ 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"). -Placeholder types other than string, int, and float are errors. The output file is not written when the build reports an error. +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. Their values are rounded before they are inserted into the string. Keys or placeholder types that differ between locales are reported as warnings. `) diff --git a/src/scripts/placeholder-type.ts b/src/scripts/placeholder-type.ts index da48ab3..af5b022 100644 --- a/src/scripts/placeholder-type.ts +++ b/src/scripts/placeholder-type.ts @@ -13,11 +13,6 @@ const SUPPORTED_TYPES = ['string', 'int', 'float'] as const type SupportedType = typeof SUPPORTED_TYPES[number] -const NAME_TYPE_HINTS: { type: SupportedType, names: string[] }[] = [ - { type: 'int', names: ['count', 'amount', 'length', 'number'] }, - { type: 'string', names: ['name'] }, -] - function toFunctionType(typeName: SupportedType): ResolvedPlaceholderType { if (typeName === 'int') return { typing: 'number', numeric: 'int' } if (typeName === 'float') return { typing: 'number', numeric: 'float' } @@ -43,16 +38,7 @@ export function resolvePlaceholderType( const typing = rawType?.trim() ?? '' if (!typing) { - const hint = NAME_TYPE_HINTS.find(entry => entry.names.includes(variableName)) - if (hint) { - const article = /^[aeiou]/i.test(hint.type) ? 'an' : 'a' - report('warning', [ - `Variable [${variableName}] of [${context.key}] in file ${context.filePath}`, - `was considered ${article} ${hint.type} because the name is one of ${hint.names.join(', ')}.`, - 'Set the type explicitly or rename it to avoid this warning.', - ].join(' ')) - return toFunctionType(hint.type) - } + report('warning', `Placeholder [${variableName}] of [${context.key}] in file ${context.filePath} has no type. Interpreting it as string.`) return { typing: 'string' } } diff --git a/tests/build-intl.test.ts b/tests/build-intl.test.ts index be4a459..a41a5a8 100644 --- a/tests/build-intl.test.ts +++ b/tests/build-intl.test.ts @@ -254,7 +254,7 @@ describe('collectTranslations', () => { }) expect(collected.catalog['en-US'].items).toEqual({ type: 'func', - params: [{ name: 'count', typing: 'number', numeric: 'int' }], + params: [{ name: 'count', typing: 'string' }], value: '{count}', }) const unsupported = collected.diagnostics.filter(item => item.message.includes('is not supported')) @@ -265,7 +265,11 @@ describe('collectTranslations', () => { expect(collected.diagnostics).toEqual(expect.arrayContaining([ expect.objectContaining({ level: 'warning', - message: expect.stringContaining('was considered an int'), + 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) From 63471976ce471c94eca4601488cf8804c2596ce8 Mon Sep 17 00:00:00 2001 From: Felix Thape <67233923+DasProffi@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:56:43 +0200 Subject: [PATCH 3/5] chore: make config optional again --- CHANGELOG.md | 1 + README.md | 9 +++++---- src/scripts/build-intl.ts | 16 +++++----------- src/scripts/intl-config.ts | 19 +++++++++++++++---- tests/build-intl.test.ts | 14 ++++++-------- 5 files changed, 32 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33dc6e2..763789f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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 and are rounded before they are inserted into the translation string ## [0.4.0] - 2025-11-26 diff --git a/README.md b/README.md index 5424684..c24ffa5 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ The file structure could look like this: -> en-US.arb ``` -Describe the project in a JSON config file. Every field is optional: +A JSON config file is optional. Pass it with `-c` or `--config`. Every field is optional: ```json { "supportedLocales": ["en-US", "de-DE"], @@ -75,23 +75,24 @@ Describe the project in a JSON config file. Every field is optional: } ``` -`supportedLocales`, `compileTo`, `name`, and `outputFile` default to 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`. +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 those values before they are inserted into the translation string. 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 ./locales ./shared/locales npx build-intl --config ./intl.config.json ./locales ./shared/locales ``` An existing output file is overwritten. `--dry-run` prints the diff and does not write the file. ``` -Usage: build-intl --config [options] [location ...] +Usage: build-intl [options] [location ...] Options: - -c, --config JSON configuration file + -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 diff --git a/src/scripts/build-intl.ts b/src/scripts/build-intl.ts index 6899621..aa00df3 100644 --- a/src/scripts/build-intl.ts +++ b/src/scripts/build-intl.ts @@ -4,19 +4,19 @@ import path from 'path' import { parseBuildIntlArgs, resolveExportName } from './cli-args' import { collectTranslations, type IntlDiagnostic } from './collect-translations' import { compileTranslations } from './compile-typescript' -import { loadIntlConfig, type IntlConfig } from './intl-config' +import { defaultIntlConfig, loadIntlConfig, type IntlConfig } from './intl-config' import { describeOutputChange } from './output-diff' function printHelp(): void { console.info(` -Usage: build-intl --config [options] [location ...] +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 + -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 @@ -31,7 +31,7 @@ Configuration file: "translationFiles": [] } -Omitted fields use those defaults. +--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. @@ -65,15 +65,9 @@ function main(): void { for (const message of args.errors) console.error(`error: ${message}`) process.exit(1) } - if (!args.configPath) { - console.error('error: Missing required option --config .') - printHelp() - process.exit(1) - } - let config: IntlConfig try { - config = loadIntlConfig(args.configPath) + config = args.configPath === undefined ? defaultIntlConfig() : loadIntlConfig(args.configPath) } catch (error) { const reason = error instanceof Error ? error.message : String(error) console.error(`error: ${reason}`) diff --git a/src/scripts/intl-config.ts b/src/scripts/intl-config.ts index cc162b3..2c2e391 100644 --- a/src/scripts/intl-config.ts +++ b/src/scripts/intl-config.ts @@ -77,6 +77,16 @@ function readTranslationFiles(value: unknown): string[] { 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)) { @@ -95,15 +105,16 @@ export function loadIntlConfig(configPath: string): IntlConfig { throw new Error('Configuration file must contain a JSON object.') } + const defaults = defaultIntlConfig() return { supportedLocales: parsed.supportedLocales === undefined - ? [...DEFAULT_SUPPORTED_LOCALES] + ? defaults.supportedLocales : readLocaleList(parsed.supportedLocales), compileTo: parsed.compileTo === undefined - ? DEFAULT_COMPILE_TARGET + ? defaults.compileTo : readCompileTarget(parsed.compileTo), - name: readStringSetting(parsed.name, 'name', DEFAULT_EXPORT_NAME), - outputFile: readStringSetting(parsed.outputFile, 'outputFile', DEFAULT_OUTPUT_FILE), + name: readStringSetting(parsed.name, 'name', defaults.name), + outputFile: readStringSetting(parsed.outputFile, 'outputFile', defaults.outputFile), translationFiles: readTranslationFiles(parsed.translationFiles), } } diff --git a/tests/build-intl.test.ts b/tests/build-intl.test.ts index a41a5a8..610a94a 100644 --- a/tests/build-intl.test.ts +++ b/tests/build-intl.test.ts @@ -5,7 +5,7 @@ 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 { loadIntlConfig, type IntlConfig } from '../src/scripts/intl-config' +import { defaultIntlConfig, loadIntlConfig, type IntlConfig } from '../src/scripts/intl-config' import { describeOutputChange } from '../src/scripts/output-diff' const directories: string[] = [] @@ -99,13 +99,11 @@ describe('loadIntlConfig', () => { writeJson(configPath, { supportedLocales: ['fr-FR'] }) writeJson(path.join(directory, 'empty.json'), {}) - expect(loadIntlConfig(path.join(directory, 'empty.json'))).toEqual({ - supportedLocales: ['en-US', 'de-DE'], - compileTo: 'typescript', - name: 'appTranslation', - outputFile: './i18n/translations.ts', - translationFiles: [], - }) + 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', From f4ad40423938b4d8daefcdac015acd1f853e583b Mon Sep 17 00:00:00 2001 From: Felix Thape <67233923+DasProffi@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:03:28 +0200 Subject: [PATCH 4/5] chore: bump version --- CHANGELOG.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 763789f..e69bcbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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). -## [Unreleased] +## 0.5.0 - 2026-10-13 ### Added - `build-intl` reads a JSON config via `-c` / `--config` with `supportedLocales` and `compileTo` diff --git a/package.json b/package.json index c4c580f..9e3c7b5 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.4.0", + "version": "0.5.0", "type": "module", "files": [ "dist" From 8d1a3303ca3e4b0ddd4491ea6b11f805365d1cf2 Mon Sep 17 00:00:00 2001 From: Felix Thape <67233923+DasProffi@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:22:22 +0200 Subject: [PATCH 5/5] chore: do not round float values --- CHANGELOG.md | 2 +- README.md | 2 +- examples/translations/translations.ts | 4 ++-- src/compile-to-code.ts | 2 +- src/scripts/build-intl.ts | 2 +- tests/build-intl.test.ts | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e69bcbf..ccb9ab0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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 and are rounded before they are inserted into the translation string +- `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 diff --git a/README.md b/README.md index c24ffa5..2151db7 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ A JSON config file is optional. Pass it with `-c` or `--config`. Every field is 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 those values before they are inserted into the translation string. +`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. diff --git a/examples/translations/translations.ts b/examples/translations/translations.ts index 078fbce..0ac6847 100644 --- a/examples/translations/translations.ts +++ b/examples/translations/translations.ts @@ -93,7 +93,7 @@ export const exampleTranslation: Translation { let _out: string = '' - _out += `Der Preis beträgt ${TranslationGen.round(price)}` + _out += `Der Preis beträgt ${price}` _out += TranslationGen.resolveSelect(currency, { 'usd': `\$USD`, 'eur': `€`, @@ -183,7 +183,7 @@ export const exampleTranslation: Translation { let _out: string = '' - _out += `The price is ${TranslationGen.round(price)}` + _out += `The price is ${price}` _out += TranslationGen.resolveSelect(currency, { 'usd': `\$USD`, 'eur': `€`, diff --git a/src/compile-to-code.ts b/src/compile-to-code.ts index 2393c86..6183bc8 100644 --- a/src/compile-to-code.ts +++ b/src/compile-to-code.ts @@ -37,7 +37,7 @@ export function compileToCode( function formatValue(name: string): string { const kind = context.numericFormats?.[name] - if (kind === 'int' || kind === 'float') return `TranslationGen.round(${name})` + if (kind === 'int') return `TranslationGen.round(${name})` return name } diff --git a/src/scripts/build-intl.ts b/src/scripts/build-intl.ts index aa00df3..d6e70b4 100644 --- a/src/scripts/build-intl.ts +++ b/src/scripts/build-intl.ts @@ -37,7 +37,7 @@ 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. Their values are rounded before they are inserted into the string. +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. `) } diff --git a/tests/build-intl.test.ts b/tests/build-intl.test.ts index 610a94a..a838642 100644 --- a/tests/build-intl.test.ts +++ b/tests/build-intl.test.ts @@ -300,7 +300,7 @@ describe('TranslationGen.round', () => { }) describe('compileTranslations', () => { - test('rounds int and float placeholders before inserting them', () => { + test('rounds int placeholders and inserts float placeholders as given', () => { const directory = createWorkspace() writeJson(path.join(directory, 'en-US.arb'), { '@@locale': 'en-US', @@ -336,7 +336,7 @@ describe('compileTranslations', () => { 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 `${TranslationGen.round(amount)}`') + expect(compiled.source).toContain('return `${amount}`') expect(compiled.source).toContain('TranslationGen.resolvePlural(count, {') expect(compiled.source).toContain('${TranslationGen.round(count)} item') })