feat: typed translation generator

This commit is contained in:
shenlong-tanwen
2025-12-19 04:35:09 +05:30
parent e1f5f3b939
commit a3cdc182d8
3 changed files with 335 additions and 40 deletions

View File

@@ -3,7 +3,99 @@
import 'dart:convert';
import 'dart:io';
const _kReservedWords = ['continue'];
const _kReservedWords = [
'abstract',
'as',
'assert',
'async',
'await',
'break',
'case',
'catch',
'class',
'const',
'continue',
'covariant',
'default',
'deferred',
'do',
'dynamic',
'else',
'enum',
'export',
'extends',
'extension',
'external',
'factory',
'false',
'final',
'finally',
'for',
'Function',
'get',
'hide',
'if',
'implements',
'import',
'in',
'interface',
'is',
'late',
'library',
'mixin',
'new',
'null',
'on',
'operator',
'part',
'required',
'rethrow',
'return',
'sealed',
'set',
'show',
'static',
'super',
'switch',
'sync',
'this',
'throw',
'true',
'try',
'typedef',
'var',
'void',
'when',
'while',
'with',
'yield',
];
const _kIntParamNames = [
'count',
'number',
'amount',
'total',
'index',
'size',
'length',
'width',
'height',
'year',
'month',
'day',
'hour',
'minute',
'second',
'page',
'limit',
'offset',
'max',
'min',
'id',
'num',
'quantity',
];
void main() async {
final sourceFile = File('../i18n/en.json');
@@ -15,49 +107,258 @@ void main() async {
final outputDir = Directory('lib/generated');
await outputDir.create(recursive: true);
final outputFile = File('lib/generated/intl_keys.g.dart');
await _generate(sourceFile, outputFile);
final content = await sourceFile.readAsString();
final translations = json.decode(content) as Map<String, dynamic>;
final outputFile = File('lib/generated/translations.g.dart');
await _generateTranslations(translations, outputFile);
print('Generated ${outputFile.path}');
}
Future<void> _generate(File source, File output) async {
final content = await source.readAsString();
final translations = json.decode(content) as Map<String, dynamic>;
class TranslationNode {
final String key;
final String? value;
final Map<String, TranslationNode> children;
final List<TranslationParam> params;
const TranslationNode({
required this.key,
this.value,
Map<String, TranslationNode>? children,
List<TranslationParam>? params,
}) : children = children ?? const {},
params = params ?? const [];
bool get isLeaf => value != null;
bool get hasParams => params.isNotEmpty;
}
class TranslationParam {
final String name;
final String type;
const TranslationParam(this.name, this.type);
}
Future<void> _generateTranslations(Map<String, dynamic> translations, File output) async {
final root = _buildTranslationTree('', translations);
final buffer = StringBuffer('''
// DO NOT EDIT. This is code generated via generate_keys.dart
abstract class IntlKeys {
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/widgets.dart';
import 'package:intl/message_format.dart';
extension TranslationsExtension on BuildContext {
Translations get t => Translations.of(this);
}
class StaticTranslations {
StaticTranslations._();
static final instance = Translations._(null);
}
abstract class _BaseTranslations {
BuildContext? get _context;
String _t(String key, [Map<String, Object>? args]) {
if (key.isEmpty) return '';
try {
final translated = key.tr(context: _context);
return args != null
? MessageFormat(translated, locale: Intl.defaultLocale ?? 'en').format(args)
: translated;
} catch (e) {
return key;
}
}
}
class Translations extends _BaseTranslations {
@override
final BuildContext? _context;
Translations._(this._context);
static Translations of(BuildContext context) {
context.locale;
return Translations._(context);
}
''');
_writeKeys(buffer, translations);
_generateClassMembers(buffer, root, ' ');
buffer.writeln('}');
_generateNestedClasses(buffer, root);
await output.writeAsString(buffer.toString());
}
void _writeKeys(
StringBuffer buffer,
Map<String, dynamic> map, [
String prefix = '',
]) {
for (final entry in map.entries) {
final key = entry.key;
final value = entry.value;
TranslationNode _buildTranslationTree(String key, dynamic value) {
if (value is Map<String, dynamic>) {
_writeKeys(buffer, value, prefix.isEmpty ? key : '${prefix}_$key');
final children = <String, TranslationNode>{};
for (final entry in value.entries) {
children[entry.key] = _buildTranslationTree(entry.key, entry.value);
}
return TranslationNode(key: key, children: children);
} else {
final name = _cleanName(prefix.isEmpty ? key : '${prefix}_$key');
final path = prefix.isEmpty ? key : '$prefix.$key'.replaceAll('_', '.');
buffer.writeln(' static const $name = \'$path\';');
final stringValue = value.toString();
final params = _extractParams(stringValue);
return TranslationNode(key: key, value: stringValue, params: params);
}
}
List<TranslationParam> _extractParams(String value) {
final params = <String, TranslationParam>{};
final icuRegex = RegExp(r'\{(\w+),\s*(plural|select|number|date|time)([^}]*(?:\{[^}]*\}[^}]*)*)\}');
for (final match in icuRegex.allMatches(value)) {
final name = match.group(1)!;
final icuType = match.group(2)!;
final icuContent = match.group(3) ?? '';
if (params.containsKey(name)) continue;
String type;
if (icuType == 'plural' || icuType == 'number') {
type = 'int';
} else if (icuType == 'select') {
final hasTrueFalse = RegExp(r',\s*(true|false)\s*\{').hasMatch(icuContent);
type = hasTrueFalse ? 'bool' : 'String';
} else {
type = 'String';
}
params[name] = TranslationParam(name, type);
}
var cleanedValue = value;
var depth = 0;
var icuStart = -1;
for (var i = 0; i < value.length; i++) {
if (value[i] == '{') {
if (depth == 0) icuStart = i;
depth++;
} else if (value[i] == '}') {
depth--;
if (depth == 0 && icuStart >= 0) {
final block = value.substring(icuStart, i + 1);
if (RegExp(r'^\{\w+,').hasMatch(block)) {
cleanedValue = cleanedValue.replaceFirst(block, '');
}
icuStart = -1;
}
}
}
final simpleRegex = RegExp(r'\{(\w+)\}');
for (final match in simpleRegex.allMatches(cleanedValue)) {
final name = match.group(1)!;
if (params.containsKey(name)) continue;
String type;
if (_kIntParamNames.contains(name.toLowerCase())) {
type = 'int';
} else {
type = 'Object';
}
params[name] = TranslationParam(name, type);
}
return params.values.toList();
}
void _generateClassMembers(StringBuffer buffer, TranslationNode node, String indent, [String keyPrefix = '']) {
final sortedKeys = node.children.keys.toList()..sort();
for (final childKey in sortedKeys) {
final child = node.children[childKey]!;
final dartName = _escapeName(childKey);
final fullKey = keyPrefix.isEmpty ? childKey : '$keyPrefix.$childKey';
if (child.isLeaf) {
if (child.hasParams) {
_generateMethod(buffer, dartName, fullKey, child.params, indent);
} else {
_generateGetter(buffer, dartName, fullKey, indent);
}
} else {
final className = _toNestedClassName(keyPrefix, childKey);
buffer.writeln('${indent}late final $dartName = $className._(_context);');
}
}
}
String _cleanName(String name) {
name = name.replaceAll(RegExp(r'[^a-zA-Z0-9_]'), '_');
if (RegExp(r'^[0-9]').hasMatch(name)) name = 'k_$name';
if (_kReservedWords.contains(name)) name = '${name}_';
void _generateGetter(StringBuffer buffer, String dartName, String translationKey, String indent) {
buffer.writeln('${indent}String get $dartName => _t(\'$translationKey\');');
}
void _generateMethod(
StringBuffer buffer,
String dartName,
String translationKey,
List<TranslationParam> params,
String indent,
) {
final paramList = params.map((p) => 'required ${p.type} ${_escapeName(p.name)}').join(', ');
final argsMap = params.map((p) => '\'${p.name}\': ${_escapeName(p.name)}').join(', ');
buffer.writeln('${indent}String $dartName({$paramList}) => _t(\'$translationKey\', {$argsMap});');
}
void _generateNestedClasses(StringBuffer buffer, TranslationNode node, [String keyPrefix = '']) {
final sortedKeys = node.children.keys.toList()..sort();
for (final childKey in sortedKeys) {
final child = node.children[childKey]!;
final fullKey = keyPrefix.isEmpty ? childKey : '$keyPrefix.$childKey';
if (!child.isLeaf && child.children.isNotEmpty) {
final className = _toNestedClassName(keyPrefix, childKey);
buffer.writeln();
buffer.writeln('class $className extends _BaseTranslations {');
buffer.writeln(' @override');
buffer.writeln(' final BuildContext? _context;');
buffer.writeln(' $className._(this._context);');
_generateClassMembers(buffer, child, ' ', fullKey);
buffer.writeln('}');
_generateNestedClasses(buffer, child, fullKey);
}
}
}
String _toNestedClassName(String prefix, String key) {
final parts = <String>[];
if (prefix.isNotEmpty) {
parts.addAll(prefix.split('.'));
}
parts.add(key);
final result = StringBuffer('_');
for (final part in parts) {
final words = part.split('_');
for (final word in words) {
if (word.isNotEmpty) {
result.write(word[0].toUpperCase());
if (word.length > 1) {
result.write(word.substring(1).toLowerCase());
}
}
}
}
result.write('Translations');
return result.toString();
}
String _escapeName(String name) {
if (_kReservedWords.contains(name)) {
return '$name\$';
}
if (RegExp(r'^[0-9]').hasMatch(name)) {
return 'k$name';
}
return name;
}

View File

@@ -37,7 +37,7 @@ translation:
dart run easy_localization:generate -S ../i18n
dart run bin/generate_keys.dart
dart format lib/generated/codegen_loader.g.dart
dart format lib/generated/intl_keys.g.dart
dart format lib/generated/translations.g.dart
analyze:
dart analyze --fatal-infos

View File

@@ -32,13 +32,7 @@ depends = [
[tasks."codegen:translation"]
alias = "translation"
description = "Generate translations from i18n JSONs"
run = [
{ task = "//i18n:format-fix" },
{ tasks = [
"i18n:loader",
"i18n:keys",
] },
]
run = [{ task = "//i18n:format-fix" }, { tasks = ["i18n:loader", "i18n:keys"] }]
[tasks."codegen:app-icon"]
description = "Generate app icons"
@@ -158,10 +152,10 @@ run = [
description = "Generate i18n keys"
hide = true
sources = ["i18n/en.json"]
outputs = "lib/generated/intl_keys.g.dart"
outputs = "lib/generated/translations.g.dart"
run = [
"dart run bin/generate_keys.dart",
"dart format lib/generated/intl_keys.g.dart",
"dart format lib/generated/translations.g.dart",
]
[tasks."analyze:dart"]