Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog


### [3.0.9]

- added .yaml support for transaltion files in the :generate script

### [3.0.8]

- code audit and maintenance updates
Expand Down
187 changes: 109 additions & 78 deletions bin/generate.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'dart:io';

import 'package:args/args.dart';
import 'package:path/path.dart' as path;
import 'package:yaml/yaml.dart';

const _preservedKeywords = [
'few',
Expand Down Expand Up @@ -43,35 +44,45 @@ GenerateOptions _generateOption(List<String> args) {
ArgParser _generateArgParser(GenerateOptions? generateOptions) {
var parser = ArgParser();

parser.addOption('source-dir',
abbr: 'S',
defaultsTo: 'resources/langs',
callback: (String? x) => generateOptions!.sourceDir = x,
help: 'Folder containing localization files');

parser.addOption('source-file',
abbr: 's',
callback: (String? x) => generateOptions!.sourceFile = x,
help: 'File to use for localization');

parser.addOption('output-dir',
abbr: 'O',
defaultsTo: 'lib/generated',
callback: (String? x) => generateOptions!.outputDir = x,
help: 'Output folder stores for the generated file');

parser.addOption('output-file',
abbr: 'o',
defaultsTo: 'codegen_loader.g.dart',
callback: (String? x) => generateOptions!.outputFile = x,
help: 'Output file name');

parser.addOption('format',
abbr: 'f',
defaultsTo: 'json',
callback: (String? x) => generateOptions!.format = x,
help: 'Support json or keys formats',
allowed: ['json', 'keys']);
parser.addOption(
'source-dir',
abbr: 'S',
defaultsTo: 'resources/langs',
callback: (String? x) => generateOptions!.sourceDir = x,
help: 'Folder containing localization files',
);

parser.addOption(
'source-file',
abbr: 's',
callback: (String? x) => generateOptions!.sourceFile = x,
help: 'File to use for localization',
);

parser.addOption(
'output-dir',
abbr: 'O',
defaultsTo: 'lib/generated',
callback: (String? x) => generateOptions!.outputDir = x,
help: 'Output folder stores for the generated file',
);

parser.addOption(
'output-file',
abbr: 'o',
defaultsTo: 'codegen_loader.g.dart',
callback: (String? x) => generateOptions!.outputFile = x,
help: 'Output file name',
);

parser.addOption(
'format',
abbr: 'f',
defaultsTo: 'json',
callback: (String? x) => generateOptions!.format = x,
help: 'Support json or keys formats',
allowed: ['json', 'keys'],
);

parser.addFlag(
'skip-unnecessary-keys',
Expand Down Expand Up @@ -104,8 +115,9 @@ void handleLangFiles(GenerateOptions options) async {
final source = Directory.fromUri(Uri.parse(options.sourceDir!));
final output = Directory.fromUri(Uri.parse(options.outputDir!));
final sourcePath = Directory(path.join(current.path, source.path));
final outputPath =
Directory(path.join(current.path, output.path, options.outputFile));
final outputPath = Directory(
path.join(current.path, output.path, options.outputFile),
);

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Bug: Treating a file path as a Directory.

outputPath holds a file path (includes options.outputFile) but is typed/constructed as Directory. Use File to avoid semantic confusion and potential path issues.

-  final outputPath = Directory(
-    path.join(current.path, output.path, options.outputFile),
-  );
+  final outputFile = File(
+    path.join(current.path, output.path, options.outputFile),
+  );

Also update the call site at Line 143 and the generateFile signature accordingly (see separate diffs).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
final outputPath = Directory(
path.join(current.path, output.path, options.outputFile),
);
final outputFile = File(
path.join(current.path, output.path, options.outputFile),
);
🤖 Prompt for AI Agents
In bin/generate.dart around lines 118 to 121, outputPath is constructed as a
Directory but it actually contains a file path (includes options.outputFile);
change it to a File (use File(path.join(...))) to reflect a file target, update
the call site at line 143 to pass a File instead of Directory, and adjust the
generateFile function signature to accept a File (and update any
Directory-specific APIs inside generateFile to File APIs).

if (!await sourcePath.exists()) {
stderr.writeln('Source path does not exist');
Expand All @@ -121,8 +133,10 @@ void handleLangFiles(GenerateOptions options) async {
}
files = [sourceFile];
} else {
//filtering format
files = files.where((f) => f.path.contains('.json')).toList();
// Filter for .yml and .yaml files
files = files
.where((f) => f.path.endsWith('.yml') || f.path.endsWith('.yaml'))
.toList();
}
Comment on lines 125 to 127
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Filter by selected format and ensure only files are processed.
Currently mixes JSON with YAML when format='yaml' and may include directories. Make filtering deterministic and case-insensitive.

-    //filtering format
-    files = files.where((f) => f.path.contains(RegExp(r'\.(json|yaml|yml)$'))).toList();
+    // Filter files based on requested format; keep only regular files.
+    files = files
+        .whereType<File>()
+        .where((f) {
+          final p = f.path.toLowerCase();
+          switch (options.format) {
+            case 'json':
+              return p.endsWith('.json');
+            case 'yaml':
+              return p.endsWith('.yaml') || p.endsWith('.yml');
+            case 'keys':
+              return p.endsWith('.json') || p.endsWith('.yaml') || p.endsWith('.yml');
+            default:
+              return false;
+          }
+        })
+        .toList();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
//filtering format
files = files.where((f) => f.path.contains('.json')).toList();
files = files.where((f) => f.path.contains(RegExp(r'\.(json|yaml|yml)$'))).toList();
}
// Filter files based on requested format; keep only regular files.
files = files
.whereType<File>()
.where((f) {
final p = f.path.toLowerCase();
switch (options.format) {
case 'json':
return p.endsWith('.json');
case 'yaml':
return p.endsWith('.yaml') || p.endsWith('.yml');
case 'keys':
return p.endsWith('.json') || p.endsWith('.yaml') || p.endsWith('.yml');
default:
return false;
}
})
.toList();
}
🤖 Prompt for AI Agents
In bin/generate.dart around lines 125-127, the current filter mixes JSON and
YAML when format='yaml' and can include directories; update the filtering to
first exclude non-files using FileSystemEntity.isFileSync(path) and then match
extensions deterministically: build the allowed extensions based on the selected
format (e.g., ['json'] for 'json', ['yaml','yml'] for 'yaml'), compile a RegExp
that matches the extension at the end of the path with caseSensitive: false, and
filter files by both being a real file and matching that regex.


if (files.isNotEmpty) {
Expand All @@ -136,13 +150,18 @@ Future<List<FileSystemEntity>> dirContents(Directory dir) {
var files = <FileSystemEntity>[];
var completer = Completer<List<FileSystemEntity>>();
var lister = dir.list(recursive: false);
lister.listen((file) => files.add(file),
onDone: () => completer.complete(files));
lister.listen(
(file) => files.add(file),
onDone: () => completer.complete(files),
);
return completer.future;
}

void generateFile(List<FileSystemEntity> files, Directory outputPath,
GenerateOptions options) async {
void generateFile(
List<FileSystemEntity> files,
Directory outputPath,
GenerateOptions options,
) async {
var generatedFile = File(outputPath.path);
if (!generatedFile.existsSync()) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Align generateFile API with a File destination.

Update signature and usage to accept File instead of Directory.

-void generateFile(
-  List<FileSystemEntity> files,
-  Directory outputPath,
-  GenerateOptions options,
-) async {
-  var generatedFile = File(outputPath.path);
+void generateFile(
+  List<FileSystemEntity> files,
+  File outputFile,
+  GenerateOptions options,
+) async {
+  var generatedFile = outputFile;
   if (!generatedFile.existsSync()) {
     generatedFile.createSync(recursive: true);
   }

And at Line 143:

-    generateFile(files, outputPath, options);
+    generateFile(files, outputFile, options);

And at Line 186:

-  stdout.writeln('All done! File generated in ${outputPath.path}');
+  stdout.writeln('All done! File generated in ${outputFile.path}');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
void generateFile(
List<FileSystemEntity> files,
Directory outputPath,
GenerateOptions options,
) async {
var generatedFile = File(outputPath.path);
if (!generatedFile.existsSync()) {
// Updated signature to take a File instead of Directory
void generateFile(
List<FileSystemEntity> files,
File outputFile,
GenerateOptions options,
) async {
var generatedFile = outputFile;
if (!generatedFile.existsSync()) {
generatedFile.createSync(recursive: true);
}
// …rest of the implementation…
}
Suggested change
void generateFile(
List<FileSystemEntity> files,
Directory outputPath,
GenerateOptions options,
) async {
var generatedFile = File(outputPath.path);
if (!generatedFile.existsSync()) {
// At Line 143: pass the File instead of a Directory
generateFile(files, outputFile, options);
Suggested change
void generateFile(
List<FileSystemEntity> files,
Directory outputPath,
GenerateOptions options,
) async {
var generatedFile = File(outputPath.path);
if (!generatedFile.existsSync()) {
// At Line 186: reference outputFile.path
stdout.writeln('All done! File generated in ${outputFile.path}');
🤖 Prompt for AI Agents
In bin/generate.dart around lines 160-166 (and also update references at lines
143 and 186), the generateFile function currently accepts a Directory but should
accept a File destination; change the function signature parameter type from
Directory outputPath to File outputFile (and rename usages accordingly), replace
any calls to outputPath.path with outputFile.path, update existence and
parent-directory handling to use outputFile.existsSync() and
outputFile.parent.createSync(recursive: true) as needed, and update the two call
sites at lines 143 and 186 to pass a File object instead of a Directory.

generatedFile.createSync(recursive: true);
Expand All @@ -157,9 +176,6 @@ void generateFile(List<FileSystemEntity> files, Directory outputPath,
case 'keys':
await _writeKeys(classBuilder, files, options.skipUnnecessaryKeys);
break;
// case 'csv':
// await _writeCsv(classBuilder, files);
// break;
default:
stderr.writeln('Format not supported');
}
Expand All @@ -170,28 +186,36 @@ void generateFile(List<FileSystemEntity> files, Directory outputPath,
stdout.writeln('All done! File generated in ${outputPath.path}');
}

Future _writeKeys(StringBuffer classBuilder, List<FileSystemEntity> files,
bool? skipUnnecessaryKeys) async {
Future _writeKeys(
StringBuffer classBuilder,
List<FileSystemEntity> files,
bool? skipUnnecessaryKeys,
) async {
var file = '''
// DO NOT EDIT. This is code generated via package:easy_localization/generate.dart

// ignore_for_file: constant_identifier_names

abstract class LocaleKeys {
abstract class LocaleKeys {
''';

final fileData = File(files.first.path);

Map<String, dynamic> translations =
json.decode(await fileData.readAsString());
// Parse YAML file
final yamlString = await fileData.readAsString();
final yamlData = loadYaml(yamlString);
Map<String, dynamic> translations = _yamlToMap(yamlData);

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

Keys generation reads only YAML; restore JSON support and validate top-level structure.

Use a shared parser that detects by extension and ensures a top-level map.

-  // Parse YAML file
-  final yamlString = await fileData.readAsString();
-  final yamlData = loadYaml(yamlString);
-  Map<String, dynamic> translations = _yamlToMap(yamlData);
+  // Parse file (.json, .yml, .yaml)
+  final Map<String, dynamic> translations = await _parseTranslations(fileData);

Additional helper to add (see snippet below in this comment thread).

Add this helper outside the function (near _yamlToMap):

// Parses .json, .yml, .yaml into a Map<String, dynamic>.
Future<Map<String, dynamic>> _parseTranslations(File file) async {
  final ext = path.extension(file.path).toLowerCase();
  final content = await file.readAsString();
  if (ext == '.json') {
    final decoded = json.decode(content);
    if (decoded is! Map) {
      throw FormatException('Top-level JSON must be an object');
    }
    return Map<String, dynamic>.from(decoded as Map);
  }
  final yamlRoot = loadYaml(content);
  final map = _yamlToMap(yamlRoot);
  if (map.isEmpty && yamlRoot is! YamlMap) {
    throw FormatException('Top-level YAML must be a mapping');
  }
  return map;
}
🤖 Prompt for AI Agents
In bin/generate.dart around lines 189 to 208, the _writeKeys function currently
reads only YAML files and directly calls loadYaml, so JSON input and top-level
structure validation are missing; replace the inline YAML-only parsing with a
shared async parser that checks the file extension ('.json' vs '.yml'/' .yaml'),
decodes JSON into a Map and throws a FormatException if the decoded top-level is
not a Map, or for YAML uses loadYaml then converts via _yamlToMap and throws if
the YAML root is not a mapping; add this helper function outside _writeKeys
(near _yamlToMap) and call it from _writeKeys instead of the current YAML-only
code so both JSON and YAML are supported and validated.

Comment on lines 187 to 191
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Keys generation reads YAML only — add shared parser to support JSON and validate top-level.

Without this, -f keys fails for .json sources and lacks structure checks.

-  Map<String, dynamic> translations =
-      json.decode(json.encode(loadYaml(await fileData.readAsString())));
+  final Map<String, dynamic> translations = await _parseTranslations(fileData);

Add this helper (place near _resolve):

// Parses .json, .yaml, .yml into Map<String, dynamic> and validates top-level map.
Future<Map<String, dynamic>> _parseTranslations(File file) async {
  final ext = path.extension(file.path).toLowerCase();
  final content = await file.readAsString();
  if (ext == '.json') {
    final decoded = json.decode(content);
    if (decoded is! Map) {
      throw const FormatException('Top-level JSON must be an object');
    }
    return Map<String, dynamic>.from(decoded as Map);
  }
  // YAML path
  final yamlRoot = loadYaml(content);
  // Normalize YamlMap/YamlList into JSON-friendly Map/List
  final normalized = json.decode(json.encode(yamlRoot));
  if (normalized is! Map) {
    throw const FormatException('Top-level YAML must be a mapping');
  }
  return Map<String, dynamic>.from(normalized as Map);
}
🤖 Prompt for AI Agents
In bin/generate.dart around lines 187 to 191, the current code reads YAML only
and directly decodes a YAML structure into a Map which breaks when input is JSON
and does not validate top-level structure; add a helper method named
_parseTranslations placed near _resolve that: reads the file bytes, inspects
path.extension(file.path).toLowerCase(), for '.json' uses json.decode and throws
a FormatException if the decoded value is not a Map, for YAML ('.yaml' or
'.yml') uses loadYaml then normalizes the YamlMap/YamlList to JSON-friendly
structures via json.encode/json.decode, validates the normalized value is a Map,
converts and returns Map<String, dynamic>; then replace the current YAML-only
logic at lines 187–191 to call await _parseTranslations(fileData) and use its
returned Map.

file += _resolve(translations, skipUnnecessaryKeys);

classBuilder.writeln(file);
}

String _resolve(Map<String, dynamic> translations, bool? skipUnnecessaryKeys,
[String? accKey]) {
String _resolve(
Map<String, dynamic> translations,
bool? skipUnnecessaryKeys, [
String? accKey,
]) {
var fileContent = '';

final sortedKeys = translations.keys.toList();
Expand All @@ -205,34 +229,41 @@ String _resolve(Map<String, dynamic> translations, bool? skipUnnecessaryKeys,
var ignoreKey = false;
if (translations[key] is Map) {
// If key does not contain keys for plural(), gender() etc. and option is enabled -> ignore it
ignoreKey = !containsPreservedKeywords(
translations[key] as Map<String, dynamic>) &&
ignoreKey =
!containsPreservedKeywords(
translations[key] as Map<String, dynamic>,
) &&
canIgnoreKeys;

var nextAccKey = key;
if (accKey != null) {
nextAccKey = '$accKey.$key';
}

fileContent +=
_resolve(translations[key], skipUnnecessaryKeys, nextAccKey);
fileContent += _resolve(
translations[key],
skipUnnecessaryKeys,
nextAccKey,
);
}

if (!_preservedKeywords.contains(key)) {
accKey != null && !ignoreKey
? fileContent +=
' static const ${accKey.replaceAll('.', '_')}_$key = \'$accKey.$key\';\n'
' static const ${accKey.replaceAll('.', '_')}_$key = \'$accKey.$key\';\n'
: !ignoreKey
? fileContent += ' static const $key = \'$key\';\n'
: null;
? fileContent += ' static const $key = \'$key\';\n'
: null;
}
}

return fileContent;
}

Future _writeJson(
StringBuffer classBuilder, List<FileSystemEntity> files) async {
StringBuffer classBuilder,
List<FileSystemEntity> files,
) async {
var gFile = '''
// DO NOT EDIT. This is code generated via package:easy_localization/generate.dart

Expand All @@ -242,7 +273,7 @@ import 'dart:ui';

import 'package:easy_localization/easy_localization.dart' show AssetLoader;

class CodegenLoader extends AssetLoader{
class CodegenLoader extends AssetLoader {
const CodegenLoader();

@override
Expand All @@ -255,12 +286,17 @@ class CodegenLoader extends AssetLoader{
final listLocales = [];

for (var file in files) {
final localeName =
path.basename(file.path).replaceFirst('.json', '').replaceAll('-', '_');
final localeName = path
.basename(file.path)
.replaceFirst(RegExp(r'\.ya?ml$'), '')
.replaceAll('-', '_');
listLocales.add('"$localeName": _$localeName');
final fileData = File(file.path);

Map<String, dynamic>? data = json.decode(await fileData.readAsString());
// Parse YAML file
final yamlString = await fileData.readAsString();
final yamlData = loadYaml(yamlString);
Map<String, dynamic> data = _yamlToMap(yamlData);

final mapString = const JsonEncoder.withIndent(' ').convert(data);
gFile += 'static const Map<String,dynamic> _$localeName = $mapString;\n';
Expand All @@ -271,22 +307,17 @@ class CodegenLoader extends AssetLoader{
classBuilder.writeln(gFile);
}

// _writeCsv(StringBuffer classBuilder, List<FileSystemEntity> files) async {
// List<String> listLocales = List();
// final fileData = File(files.first.path);

// // CSVParser csvParser = CSVParser(await fileData.readAsString());

// // List listLangs = csvParser.getLanguages();
// for(String localeName in listLangs){
// listLocales.add('"$localeName": $localeName');
// String mapString = JsonEncoder.withIndent(" ").convert(csvParser.getLanguageMap(localeName)) ;

// classBuilder.writeln(
// ' static const Map<String,dynamic> $localeName = ${mapString};\n');
// }

// classBuilder.writeln(
// ' static const Map<String, Map<String,dynamic>> mapLocales = \{${listLocales.join(', ')}\};');

// }
// Helper function to convert YamlMap to Map<String, dynamic>
Map<String, dynamic> _yamlToMap(dynamic yaml) {
if (yaml is YamlMap) {
return yaml.map((key, value) {
if (value is YamlMap || value is YamlList) {
return MapEntry(key.toString(), _yamlToMap(value));
}
return MapEntry(key.toString(), value);
});
} else if (yaml is YamlList) {
return {'': yaml.map((e) => _yamlToMap(e)).toList()};
}
return {};
}
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ dependencies:
intl: '>=0.17.0-0 <0.21.0'
args: ^2.3.1
path: ^1.8.1
yaml: ^3.1.2
easy_logger: ^0.0.2
flutter_localizations:
sdk: flutter
Expand Down