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