2025-09-07 12:22:02 +05:30
|
|
|
import 'dart:convert';
|
|
|
|
|
import 'dart:io';
|
|
|
|
|
|
|
|
|
|
/// Validates ARB locale files against the English template (app_en.arb).
|
|
|
|
|
/// - Ensures no duplicate keys within any ARB file.
|
|
|
|
|
/// - Ensures each non-meta key in EN exists in other locales.
|
|
|
|
|
/// - Ensures placeholder names match between EN and other locales.
|
|
|
|
|
/// - Reports unused keys (best-effort) by scanning lib/ for usages of
|
2025-09-07 13:17:34 +05:30
|
|
|
/// AppLocalizations.of(context)!.someKey. Unused keys are WARNINGS by default.
|
2025-09-07 12:22:02 +05:30
|
|
|
///
|
|
|
|
|
/// Exit codes:
|
|
|
|
|
/// 0 = success (no hard errors; warnings may be printed)
|
|
|
|
|
/// 1 = validation errors (duplicates, missing keys, placeholder mismatches)
|
|
|
|
|
Future<void> main(List<String> args) async {
|
|
|
|
|
final basePath = 'lib/l10n/app_en.arb';
|
|
|
|
|
final dir = Directory('lib/l10n');
|
|
|
|
|
if (!await File(basePath).exists()) {
|
|
|
|
|
stderr.writeln('Base ARB not found: $basePath');
|
|
|
|
|
exit(1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final arbFiles = await dir
|
|
|
|
|
.list()
|
|
|
|
|
.where((e) => e.path.endsWith('.arb'))
|
|
|
|
|
.map((e) => File(e.path))
|
|
|
|
|
.toList();
|
|
|
|
|
|
|
|
|
|
final baseFile = File(basePath);
|
|
|
|
|
final base = _readJson(baseFile);
|
|
|
|
|
final baseKeys = _nonMetaKeys(base);
|
|
|
|
|
final basePlaceholders = _placeholdersMap(base);
|
|
|
|
|
|
|
|
|
|
final errors = <String>[];
|
|
|
|
|
final warnings = <String>[];
|
|
|
|
|
|
|
|
|
|
// NOTE: Duplicate keys at the top-level are invalid JSON and unlikely.
|
|
|
|
|
// We skip duplicate detection to avoid false positives from nested meta keys.
|
|
|
|
|
|
|
|
|
|
// Validate translations against base
|
|
|
|
|
for (final f in arbFiles) {
|
|
|
|
|
if (f.path.endsWith('_en.arb')) continue;
|
|
|
|
|
final data = _readJson(f);
|
|
|
|
|
final keys = _nonMetaKeys(data);
|
|
|
|
|
|
|
|
|
|
// Missing keys
|
|
|
|
|
final missing = baseKeys.difference(keys);
|
|
|
|
|
if (missing.isNotEmpty) {
|
|
|
|
|
errors.add('[${f.path}] Missing keys: ${missing.toList()..sort()}');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Placeholder parity checks
|
|
|
|
|
final transPlaceholders = _placeholdersMap(data);
|
|
|
|
|
for (final k in basePlaceholders.keys) {
|
|
|
|
|
final basePh = basePlaceholders[k] ?? const <String>{};
|
|
|
|
|
final trPh = transPlaceholders[k];
|
|
|
|
|
if (trPh == null) {
|
|
|
|
|
// If string exists but no meta placeholders, warn only.
|
|
|
|
|
if (keys.contains(k) && basePh.isNotEmpty) {
|
2025-09-24 12:00:49 +05:30
|
|
|
warnings.add(
|
|
|
|
|
'[${f.path}] Key "$k" missing @meta placeholders; base has ${basePh.toList()..sort()}',
|
|
|
|
|
);
|
2025-09-07 12:22:02 +05:30
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (basePh.length != trPh.length || !basePh.containsAll(trPh)) {
|
2025-09-24 12:00:49 +05:30
|
|
|
warnings.add(
|
|
|
|
|
'[${f.path}] Placeholder mismatch for "$k": expected ${basePh.toList()..sort()}, got ${trPh.toList()..sort()}',
|
|
|
|
|
);
|
2025-09-07 12:22:02 +05:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Unused keys (best-effort) — WARNINGS only
|
2025-11-02 17:44:23 +05:30
|
|
|
final usedKeys = await _scanUsedLocalizationKeys(baseKeys);
|
2025-09-07 12:22:02 +05:30
|
|
|
final unused = baseKeys.difference(usedKeys);
|
|
|
|
|
if (unused.isNotEmpty) {
|
|
|
|
|
warnings.add('Unused keys in EN (best-effort): ${unused.toList()..sort()}');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Print results
|
|
|
|
|
if (errors.isNotEmpty) {
|
|
|
|
|
stderr.writeln('ARB validation errors:');
|
|
|
|
|
for (final e in errors) {
|
|
|
|
|
stderr.writeln(' - $e');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (warnings.isNotEmpty) {
|
|
|
|
|
stdout.writeln('ARB validation warnings:');
|
|
|
|
|
for (final w in warnings) {
|
|
|
|
|
stdout.writeln(' - $w');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
exit(errors.isEmpty ? 0 : 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Map<String, dynamic> _readJson(File f) {
|
|
|
|
|
final content = f.readAsStringSync();
|
|
|
|
|
return json.decode(content) as Map<String, dynamic>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Set<String> _nonMetaKeys(Map<String, dynamic> m) {
|
2025-09-24 12:00:49 +05:30
|
|
|
return m.keys.where((k) => !k.startsWith('@') && k != '@@locale').toSet();
|
2025-09-07 12:22:02 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Map<String, Set<String>> _placeholdersMap(Map<String, dynamic> m) {
|
|
|
|
|
final map = <String, Set<String>>{};
|
|
|
|
|
for (final entry in m.entries) {
|
|
|
|
|
final key = entry.key;
|
|
|
|
|
if (!key.startsWith('@')) continue;
|
|
|
|
|
final value = entry.value;
|
|
|
|
|
if (value is! Map<String, dynamic>) continue;
|
|
|
|
|
final placeholders = value['placeholders'];
|
|
|
|
|
if (placeholders is Map<String, dynamic>) {
|
|
|
|
|
map[key.substring(1)] = placeholders.keys.toSet();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return map;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Duplicate detection intentionally omitted (see note above).
|
|
|
|
|
|
2025-11-02 17:44:23 +05:30
|
|
|
Future<Set<String>> _scanUsedLocalizationKeys(Set<String> baseKeys) async {
|
2025-09-07 12:22:02 +05:30
|
|
|
final used = <String>{};
|
|
|
|
|
|
2025-11-02 17:44:23 +05:30
|
|
|
Future<bool> keyIsUsed(String key) async {
|
|
|
|
|
final result = await Process.run('rg', [
|
|
|
|
|
'--fixed-strings',
|
|
|
|
|
'--quiet',
|
|
|
|
|
'--glob=*.dart',
|
|
|
|
|
'--glob=!lib/l10n/app_localizations*.dart',
|
|
|
|
|
key,
|
|
|
|
|
'lib',
|
|
|
|
|
]);
|
|
|
|
|
if (result.exitCode == 0) {
|
|
|
|
|
return true;
|
2025-09-07 12:22:02 +05:30
|
|
|
}
|
2025-11-02 17:44:23 +05:30
|
|
|
if (result.exitCode > 1) {
|
|
|
|
|
stderr.writeln(
|
|
|
|
|
'warning: failed to search for key "$key": ${result.stderr}'.trim(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return false;
|
2025-09-07 12:22:02 +05:30
|
|
|
}
|
2025-11-02 17:44:23 +05:30
|
|
|
|
|
|
|
|
for (final key in baseKeys) {
|
|
|
|
|
if (await keyIsUsed(key)) {
|
|
|
|
|
used.add(key);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-07 12:22:02 +05:30
|
|
|
return used;
|
|
|
|
|
}
|