Skip to content

Commit 20a5f7c

Browse files
committed
Merge branch 'feat/add-linked-files' into develop
2 parents 255057d + 02aac23 commit 20a5f7c

15 files changed

+304
-325
lines changed

README.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -411,8 +411,6 @@ print('example.emptyNameError'.tr()); //Output: Please fill in your full name
411411

412412
### 🔥 Linked files:
413413

414-
> ⚠ This is only available for the default asset loader (on Json Files).
415-
416414
You can split translations for a single locale into multiple files by using linked files. This helps keep your JSON clean and maintainable.
417415

418416
To link an external file, set the key’s value to a path prefixed with `:/`, relative to your translations directory. For example, with default path `assets/translations` and locale `en-US`:
@@ -435,7 +433,7 @@ assets
435433
└── notifications.json
436434
```
437435

438-
Each linked file must contain a valid JSON object of translation keys.
436+
Each linked file must contain a valid object of translation keys (of the file type you are using [Other file types](#-loading-translations-from-other-resources)).
439437

440438
Don't forget to add your linked files (or linked files folder, here assets/translations/en-US/), to your pubspec.yaml : [See installation](#-installation).
441439

bin/audit/audit_command.dart

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import 'dart:convert';
22
import 'dart:io';
3-
3+
import 'package:easy_localization/src/linked_file_resolver.dart';
44
import 'package:path/path.dart';
5+
import 'package:easy_localization/src/file_loaders/io_file_loader.dart';
56

67
class AuditCommand {
7-
void run({required String transDir, required String srcDir}) {
8+
void run({required String transDir, required String srcDir}) async {
89
try {
910
final translationDir = Directory(transDir);
1011
final sourceDir = Directory(srcDir);
@@ -19,7 +20,7 @@ class AuditCommand {
1920
return;
2021
}
2122

22-
final allTranslations = _loadTranslations(translationDir);
23+
final allTranslations = await _loadTranslations(translationDir);
2324
final usedKeys = _scanSourceForKeys(sourceDir);
2425

2526
_report(allTranslations, usedKeys);
@@ -31,15 +32,30 @@ class AuditCommand {
3132
/// Walks [translationsDir], reads every `.json`, flattens nested maps
3233
/// into dot‑separated keys, and returns a map:
3334
/// { 'en': {'home.title', 'home.subtitle', …}, 'fr': { … } }
34-
Map<String, Set<String>> _loadTranslations(Directory translationsDir) {
35+
/// Also handles linked translation files (those containing ':/file.json' references)
36+
Future<Map<String, Set<String>>> _loadTranslations(Directory translationsDir) async {
3537
final result = <String, Set<String>>{};
38+
const IOFileLoader fileLoader = IOFileLoader();
39+
const LinkedFileResolver linkedFileResolver = JsonLinkedFileResolver(fileLoader: fileLoader);
40+
3641
for (var file in translationsDir.listSync().whereType<File>()) {
3742
if (!file.path.endsWith('.json')) continue;
3843

3944
try {
40-
final langCode = basenameWithoutExtension(file.path);
45+
final local = basenameWithoutExtension(file.path);
46+
final langCode = local.split('-').first;
47+
final hasCountryCode = local.split('-').length > 1;
48+
final countryCode = hasCountryCode ? local.split('-').last : null;
4149
final jsonMap = json.decode(file.readAsStringSync()) as Map<String, dynamic>;
42-
result[langCode] = _flatten(jsonMap);
50+
51+
// Process linked files if present using the shared resolver
52+
final resolvedJson = await linkedFileResolver.resolveLinkedFiles(
53+
basePath: translationsDir.path,
54+
languageCode: langCode,
55+
baseJson: jsonMap,
56+
countryCode: countryCode,
57+
);
58+
result[local] = _flatten(resolvedJson);
4359
} catch (e) {
4460
stderr.writeln('Error reading ${file.path}: $e');
4561
}

debug_linked.dart

Whitespace-only changes.

example/lib/generated/codegen_loader.g.dart

Lines changed: 13 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44

55
import 'dart:ui';
66

7-
import 'package:easy_localization/easy_localization.dart' show AssetLoader;
7+
import 'package:easy_localization/easy_localization.dart'
8+
show AssetLoader, JsonLinkedFileResolver, RootBundleFileLoader;
89

910
class CodegenLoader extends AssetLoader {
10-
const CodegenLoader();
11+
const CodegenLoader() : super(linkedFileResolver: const JsonLinkedFileResolver(fileLoader: RootBundleFileLoader()));
1112

1213
@override
1314
Future<Map<String, dynamic>> load(String fullPath, Locale locale) {
@@ -20,11 +21,7 @@ class CodegenLoader extends AssetLoader {
2021
"msg_named": "{} مكتوبة باللغة {lang}",
2122
"clickMe": "إضغط هنا",
2223
"profile": {
23-
"reset_password": {
24-
"label": "اعادة تعين كلمة السر",
25-
"username": "المستخدم",
26-
"password": "كلمة السر"
27-
}
24+
"reset_password": {"label": "اعادة تعين كلمة السر", "username": "المستخدم", "password": "كلمة السر"}
2825
},
2926
"clicked": {
3027
"zero": "لم تنقر بعد!",
@@ -55,11 +52,7 @@ class CodegenLoader extends AssetLoader {
5552
"msg_named": "{} مكتوبة باللغة {lang}",
5653
"clickMe": "إضغط هنا",
5754
"profile": {
58-
"reset_password": {
59-
"label": "اعادة تعين كلمة السر",
60-
"username": "المستخدم",
61-
"password": "كلمة السر"
62-
}
55+
"reset_password": {"label": "اعادة تعين كلمة السر", "username": "المستخدم", "password": "كلمة السر"}
6356
},
6457
"clicked": {
6558
"zero": "لم تنقر بعد!",
@@ -90,11 +83,7 @@ class CodegenLoader extends AssetLoader {
9083
"msg_named": "{} ist in {lang} geschrieben",
9184
"clickMe": "Click mich",
9285
"profile": {
93-
"reset_password": {
94-
"label": "Password zurücksetzten",
95-
"username": "Name",
96-
"password": "Password"
97-
}
86+
"reset_password": {"label": "Password zurücksetzten", "username": "Name", "password": "Password"}
9887
},
9988
"clicked": {
10089
"zero": "Du hast {} mal geklickt",
@@ -125,11 +114,7 @@ class CodegenLoader extends AssetLoader {
125114
"msg_named": "{} ist in {lang} geschrieben",
126115
"clickMe": "Click mich",
127116
"profile": {
128-
"reset_password": {
129-
"label": "Password zurücksetzten",
130-
"username": "Name",
131-
"password": "Password"
132-
}
117+
"reset_password": {"label": "Password zurücksetzten", "username": "Name", "password": "Password"}
133118
},
134119
"clicked": {
135120
"zero": "Du hast {} mal geklickt",
@@ -160,11 +145,7 @@ class CodegenLoader extends AssetLoader {
160145
"msg_named": "{} are written in the {lang} language",
161146
"clickMe": "Click me",
162147
"profile": {
163-
"reset_password": {
164-
"label": "Reset Password",
165-
"username": "Username",
166-
"password": "password"
167-
}
148+
"reset_password": {"label": "Reset Password", "username": "Username", "password": "password"}
168149
},
169150
"clicked": {
170151
"zero": "You clicked {} times!",
@@ -195,11 +176,7 @@ class CodegenLoader extends AssetLoader {
195176
"msg_named": "{} are written in the {lang} language",
196177
"clickMe": "Click me",
197178
"profile": {
198-
"reset_password": {
199-
"label": "Reset Password",
200-
"username": "Username",
201-
"password": "password"
202-
}
179+
"reset_password": {"label": "Reset Password", "username": "Username", "password": "password"}
203180
},
204181
"clicked": {
205182
"zero": "You clicked {} times!",
@@ -230,11 +207,7 @@ class CodegenLoader extends AssetLoader {
230207
"msg_named": "{} написан на языке {lang}",
231208
"clickMe": "Нажми на меня",
232209
"profile": {
233-
"reset_password": {
234-
"label": "Сбросить пароль",
235-
"username": "Логин",
236-
"password": "Пароль"
237-
}
210+
"reset_password": {"label": "Сбросить пароль", "username": "Логин", "password": "Пароль"}
238211
},
239212
"clicked": {
240213
"zero": "Ты кликнул {} раз!",
@@ -255,10 +228,7 @@ class CodegenLoader extends AssetLoader {
255228
"gender": {
256229
"male": "Привет мужык ;) ",
257230
"female": "Привет девчуля :)",
258-
"with_arg": {
259-
"male": "Привет мужык ;) {}",
260-
"female": "Привет девчуля :) {}"
261-
}
231+
"with_arg": {"male": "Привет мужык ;) {}", "female": "Привет девчуля :) {}"}
262232
},
263233
"reset_locale": "Сбросить язык"
264234
};
@@ -268,11 +238,7 @@ class CodegenLoader extends AssetLoader {
268238
"msg_named": "{} написан на языке {lang}",
269239
"clickMe": "Нажми на меня",
270240
"profile": {
271-
"reset_password": {
272-
"label": "Сбросить пароль",
273-
"username": "Логин",
274-
"password": "Пароль"
275-
}
241+
"reset_password": {"label": "Сбросить пароль", "username": "Логин", "password": "Пароль"}
276242
},
277243
"clicked": {
278244
"zero": "Ты кликнул {} раз!",
@@ -293,10 +259,7 @@ class CodegenLoader extends AssetLoader {
293259
"gender": {
294260
"male": "Привет мужык ;) ",
295261
"female": "Привет девчуля :)",
296-
"with_arg": {
297-
"male": "Привет мужык ;) {}",
298-
"female": "Привет девчуля :) {}"
299-
}
262+
"with_arg": {"male": "Привет мужык ;) {}", "female": "Привет девчуля :) {}"}
300263
},
301264
"reset_locale": "Сбросить язык"
302265
};

lib/easy_localization.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,6 @@ export 'package:easy_localization/src/easy_localization_app.dart';
44
export 'package:easy_localization/src/asset_loader.dart';
55
export 'package:easy_localization/src/public.dart';
66
export 'package:easy_localization/src/public_ext.dart';
7+
export 'package:easy_localization/src/linked_file_resolver.dart';
8+
export 'package:easy_localization/src/file_loaders/root_bundle_file_loader.dart';
79
export 'package:intl/intl.dart';

lib/src/asset_loader.dart

Lines changed: 20 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import 'dart:convert';
22
import 'dart:ui';
3-
43
import 'package:easy_localization/easy_localization.dart';
4+
import 'package:easy_localization/src/file_loaders/io_file_loader.dart';
55
import 'package:flutter/services.dart';
66

77
/// abstract class used to building your Custom AssetLoader
@@ -16,97 +16,43 @@ import 'package:flutter/services.dart';
1616
///}
1717
/// ```
1818
abstract class AssetLoader {
19-
const AssetLoader();
19+
// Place inside class RootBundleAssetLoader
20+
final LinkedFileResolver linkedFileResolver;
21+
22+
const AssetLoader({required this.linkedFileResolver});
23+
2024
Future<Map<String, dynamic>?> load(String path, Locale locale);
2125
}
2226

2327
///
2428
/// default used is RootBundleAssetLoader which uses flutter's assetloader
2529
///
2630
class RootBundleAssetLoader extends AssetLoader {
27-
// Place inside class RootBundleAssetLoader
28-
static const int _maxLinkedDepth = 32;
31+
const RootBundleAssetLoader({LinkedFileResolver? linkedFileResolver})
32+
: super(
33+
linkedFileResolver: linkedFileResolver ?? const JsonLinkedFileResolver(fileLoader: RootBundleFileLoader()));
2934

30-
const RootBundleAssetLoader();
35+
factory RootBundleAssetLoader.fromIOFile() {
36+
return const RootBundleAssetLoader(
37+
linkedFileResolver: JsonLinkedFileResolver(fileLoader: IOFileLoader()),
38+
);
39+
}
3140

3241
String getLocalePath(String basePath, Locale locale) {
3342
return '$basePath/${locale.toStringWithSeparator(separator: "-")}.json';
3443
}
3544

36-
String _getLinkedLocalePath(String basePath, String filePath, Locale locale) {
37-
return '$basePath/${locale.toStringWithSeparator(separator: "-")}/$filePath';
38-
}
39-
40-
Future<Map<String, dynamic>> _getLinkedTranslationFileDataFromBaseJson(
41-
String basePath,
42-
Locale locale,
43-
Map<String, dynamic> baseJson, {
44-
required Set<String> visited,
45-
int depth = 0,
46-
}) async {
47-
if (depth > _maxLinkedDepth) {
48-
throw StateError('Maximum linked files depth ($_maxLinkedDepth) exceeded for $locale at $basePath.');
49-
}
50-
51-
final Map<String, dynamic> fullJson = Map<String, dynamic>.from(baseJson);
52-
53-
for (final entry in baseJson.entries) {
54-
final key = entry.key;
55-
var value = entry.value;
56-
57-
if (value is String && value.startsWith(':/')) {
58-
final rawPath = value.substring(2).trim();
59-
final linkedAssetPath = _getLinkedLocalePath(basePath, rawPath, locale);
60-
61-
if (visited.contains(linkedAssetPath)) {
62-
throw StateError('Cyclic linked files detected at "$linkedAssetPath" (key: "$key").');
63-
}
64-
65-
final Map<String, dynamic> linkedJson =
66-
json.decode(await rootBundle.loadString(linkedAssetPath)) as Map<String, dynamic>;
67-
68-
visited.add(linkedAssetPath);
69-
try {
70-
final resolved = await _getLinkedTranslationFileDataFromBaseJson(
71-
basePath,
72-
locale,
73-
linkedJson,
74-
visited: visited,
75-
depth: depth + 1,
76-
);
77-
fullJson[key] = resolved;
78-
} catch (e) {
79-
throw StateError(
80-
'Error resolving linked file "$linkedAssetPath" for key "$key": $e',
81-
);
82-
}
83-
}
84-
85-
if (value is Map<String, dynamic>) {
86-
fullJson[key] = await _getLinkedTranslationFileDataFromBaseJson(
87-
basePath,
88-
locale,
89-
value,
90-
visited: visited,
91-
depth: depth + 1,
92-
);
93-
}
94-
}
95-
96-
return fullJson;
97-
}
98-
9945
@override
10046
Future<Map<String, dynamic>?> load(String path, Locale locale) async {
10147
var localePath = getLocalePath(path, locale);
10248
EasyLocalization.logger.debug('Load asset from $path');
10349

104-
Map<String, dynamic> baseJson = json.decode(await rootBundle.loadString(localePath));
105-
return await _getLinkedTranslationFileDataFromBaseJson(
106-
path,
107-
locale,
108-
baseJson,
109-
visited: <String>{},
50+
Map<String, dynamic> baseJson = json.decode(await linkedFileResolver.fileLoader.loadString(localePath));
51+
return await linkedFileResolver.resolveLinkedFiles(
52+
basePath: path,
53+
languageCode: locale.languageCode,
54+
countryCode: locale.countryCode,
55+
baseJson: baseJson,
11056
);
11157
}
11258
}

0 commit comments

Comments
 (0)