Skip to content

[Feature] Linked Files #770

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ flutter:
- assets/translations/
```



### 🔌 Loading translations from other resources

You can use JSON,CSV,HTTP,XML,Yaml files, etc.
Expand Down Expand Up @@ -407,6 +409,34 @@ Output:
print('example.emptyNameError'.tr()); //Output: Please fill in your full name
```

### 🔥 Linked files:

You can split translations for a single locale into multiple files by using linked files. This helps keep your JSON clean and maintainable.

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`:

```json
{
"errors": ":/errors.json",
"validation": ":/validation.json",
"notifications": ":/notifications.json"
}
```

At runtime, Easy Localization will load:
```
assets
└── translations
└── en-US
├── errors.json
├── validation.json
└── notifications.json
```

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)).

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

### 🔥 Reset locale `resetLocale()`

Reset locale to device locale
Expand Down
28 changes: 22 additions & 6 deletions bin/audit/audit_command.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import 'dart:convert';
import 'dart:io';

import 'package:easy_localization/src/linked_file_resolver.dart';
import 'package:path/path.dart';
import 'package:easy_localization/src/file_loaders/io_file_loader.dart';

class AuditCommand {
void run({required String transDir, required String srcDir}) {
Future<void> run({required String transDir, required String srcDir}) async {
try {
final translationDir = Directory(transDir);
final sourceDir = Directory(srcDir);
Expand All @@ -19,7 +20,7 @@ class AuditCommand {
return;
}

final allTranslations = _loadTranslations(translationDir);
final allTranslations = await _loadTranslations(translationDir);
final usedKeys = _scanSourceForKeys(sourceDir);

_report(allTranslations, usedKeys);
Expand All @@ -31,15 +32,30 @@ class AuditCommand {
/// Walks [translationsDir], reads every `.json`, flattens nested maps
/// into dot‑separated keys, and returns a map:
/// { 'en': {'home.title', 'home.subtitle', …}, 'fr': { … } }
Map<String, Set<String>> _loadTranslations(Directory translationsDir) {
/// Also handles linked translation files (those containing ':/file.json' references)
Future<Map<String, Set<String>>> _loadTranslations(Directory translationsDir) async {
final result = <String, Set<String>>{};
const IOFileLoader fileLoader = IOFileLoader();
const LinkedFileResolver linkedFileResolver = JsonLinkedFileResolver(fileLoader: fileLoader);

for (var file in translationsDir.listSync().whereType<File>()) {
if (!file.path.endsWith('.json')) continue;

try {
final langCode = basenameWithoutExtension(file.path);
final local = basenameWithoutExtension(file.path);
final langCode = local.split('-').first;
final hasCountryCode = local.split('-').length > 1;
final countryCode = hasCountryCode ? local.split('-').last : null;
final jsonMap = json.decode(file.readAsStringSync()) as Map<String, dynamic>;
result[langCode] = _flatten(jsonMap);

// Process linked files if present using the shared resolver
final resolvedJson = await linkedFileResolver.resolveLinkedFiles(
basePath: translationsDir.path,
languageCode: langCode,
baseJson: jsonMap,
countryCode: countryCode,
);
result[local] = _flatten(resolvedJson);
} catch (e) {
stderr.writeln('Error reading ${file.path}: $e');
}
Expand Down
Empty file added debug_linked.dart
Empty file.
66 changes: 16 additions & 50 deletions example/lib/generated/codegen_loader.g.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@

import 'dart:ui';

import 'package:easy_localization/easy_localization.dart' show AssetLoader;
import 'package:easy_localization/easy_localization.dart'
show AssetLoader, JsonLinkedFileResolver, RootBundleFileLoader;

class CodegenLoader extends AssetLoader {
const CodegenLoader();
const CodegenLoader()
: super(
linkedFileResolver: const JsonLinkedFileResolver(fileLoader: RootBundleFileLoader()),
fileLoader: const RootBundleFileLoader());

@override
Future<Map<String, dynamic>> load(String fullPath, Locale locale) {
Expand All @@ -20,11 +24,7 @@ class CodegenLoader extends AssetLoader {
"msg_named": "{} مكتوبة باللغة {lang}",
"clickMe": "إضغط هنا",
"profile": {
"reset_password": {
"label": "اعادة تعين كلمة السر",
"username": "المستخدم",
"password": "كلمة السر"
}
"reset_password": {"label": "اعادة تعين كلمة السر", "username": "المستخدم", "password": "كلمة السر"}
},
"clicked": {
"zero": "لم تنقر بعد!",
Expand Down Expand Up @@ -55,11 +55,7 @@ class CodegenLoader extends AssetLoader {
"msg_named": "{} مكتوبة باللغة {lang}",
"clickMe": "إضغط هنا",
"profile": {
"reset_password": {
"label": "اعادة تعين كلمة السر",
"username": "المستخدم",
"password": "كلمة السر"
}
"reset_password": {"label": "اعادة تعين كلمة السر", "username": "المستخدم", "password": "كلمة السر"}
},
"clicked": {
"zero": "لم تنقر بعد!",
Expand Down Expand Up @@ -90,11 +86,7 @@ class CodegenLoader extends AssetLoader {
"msg_named": "{} ist in {lang} geschrieben",
"clickMe": "Click mich",
"profile": {
"reset_password": {
"label": "Password zurücksetzten",
"username": "Name",
"password": "Password"
}
"reset_password": {"label": "Password zurücksetzten", "username": "Name", "password": "Password"}
},
"clicked": {
"zero": "Du hast {} mal geklickt",
Expand Down Expand Up @@ -125,11 +117,7 @@ class CodegenLoader extends AssetLoader {
"msg_named": "{} ist in {lang} geschrieben",
"clickMe": "Click mich",
"profile": {
"reset_password": {
"label": "Password zurücksetzten",
"username": "Name",
"password": "Password"
}
"reset_password": {"label": "Password zurücksetzten", "username": "Name", "password": "Password"}
},
"clicked": {
"zero": "Du hast {} mal geklickt",
Expand Down Expand Up @@ -160,11 +148,7 @@ class CodegenLoader extends AssetLoader {
"msg_named": "{} are written in the {lang} language",
"clickMe": "Click me",
"profile": {
"reset_password": {
"label": "Reset Password",
"username": "Username",
"password": "password"
}
"reset_password": {"label": "Reset Password", "username": "Username", "password": "password"}
},
"clicked": {
"zero": "You clicked {} times!",
Expand Down Expand Up @@ -195,11 +179,7 @@ class CodegenLoader extends AssetLoader {
"msg_named": "{} are written in the {lang} language",
"clickMe": "Click me",
"profile": {
"reset_password": {
"label": "Reset Password",
"username": "Username",
"password": "password"
}
"reset_password": {"label": "Reset Password", "username": "Username", "password": "password"}
},
"clicked": {
"zero": "You clicked {} times!",
Expand Down Expand Up @@ -230,11 +210,7 @@ class CodegenLoader extends AssetLoader {
"msg_named": "{} написан на языке {lang}",
"clickMe": "Нажми на меня",
"profile": {
"reset_password": {
"label": "Сбросить пароль",
"username": "Логин",
"password": "Пароль"
}
"reset_password": {"label": "Сбросить пароль", "username": "Логин", "password": "Пароль"}
},
"clicked": {
"zero": "Ты кликнул {} раз!",
Expand All @@ -255,10 +231,7 @@ class CodegenLoader extends AssetLoader {
"gender": {
"male": "Привет мужык ;) ",
"female": "Привет девчуля :)",
"with_arg": {
"male": "Привет мужык ;) {}",
"female": "Привет девчуля :) {}"
}
"with_arg": {"male": "Привет мужык ;) {}", "female": "Привет девчуля :) {}"}
},
"reset_locale": "Сбросить язык"
};
Expand All @@ -268,11 +241,7 @@ class CodegenLoader extends AssetLoader {
"msg_named": "{} написан на языке {lang}",
"clickMe": "Нажми на меня",
"profile": {
"reset_password": {
"label": "Сбросить пароль",
"username": "Логин",
"password": "Пароль"
}
"reset_password": {"label": "Сбросить пароль", "username": "Логин", "password": "Пароль"}
},
"clicked": {
"zero": "Ты кликнул {} раз!",
Expand All @@ -293,10 +262,7 @@ class CodegenLoader extends AssetLoader {
"gender": {
"male": "Привет мужык ;) ",
"female": "Привет девчуля :)",
"with_arg": {
"male": "Привет мужык ;) {}",
"female": "Привет девчуля :) {}"
}
"with_arg": {"male": "Привет мужык ;) {}", "female": "Привет девчуля :) {}"}
},
"reset_locale": "Сбросить язык"
};
Expand Down
4 changes: 4 additions & 0 deletions i18n/en-cyclic.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"test": "cyclic_test",
"cycle_start": ":/cycle_file1.json"
}
4 changes: 4 additions & 0 deletions i18n/en-cyclic/cycle_file1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"file1_value": "This is file 1",
"link_to_file2": ":/cycle_file2.json"
}
4 changes: 4 additions & 0 deletions i18n/en-cyclic/cycle_file2.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"file2_value": "This is file 2",
"link_back_to_file1": ":/cycle_file1.json"
}
19 changes: 19 additions & 0 deletions i18n/en-linked.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"test": "test_linked_en",
"hello": "Hello",
"app": {
"name": "Test App",
"errors": ":/errors.json"
},
"validation": ":/validation.json",
"nested": {
"module": {
"messages": ":/nested/messages.json"
}
},
"multiple": {
"errors": ":/multi_errors.json",
"validation": ":/multi_validation.json"
},
"deep_nested": ":/deep/level1.json"
}
4 changes: 4 additions & 0 deletions i18n/en-linked/deep/level1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"level1_value": "This is level 1",
"level2": ":/deep/level2.json"
}
4 changes: 4 additions & 0 deletions i18n/en-linked/deep/level2.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"level2_value": "This is level 2",
"final_message": "Deep nesting works!"
}
5 changes: 5 additions & 0 deletions i18n/en-linked/errors.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"not_found": "Resource not found",
"server_error": "Internal server error",
"invalid_input": "Invalid input provided"
}
5 changes: 5 additions & 0 deletions i18n/en-linked/multi_errors.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"not_found": "Multiple resource not found",
"server_error": "Multiple internal server error",
"invalid_input": "Multiple invalid input provided"
}
6 changes: 6 additions & 0 deletions i18n/en-linked/multi_validation.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"required": "Multiple field is required",
"email": "Multiple valid email address required",
"min_length": "Multiple minimum length is {min} characters",
"max_length": "Multiple maximum length is {max} characters"
}
5 changes: 5 additions & 0 deletions i18n/en-linked/nested/messages.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"welcome": "Welcome to our app",
"goodbye": "Thank you for using our app",
"info": "This is a nested message file"
}
6 changes: 6 additions & 0 deletions i18n/en-linked/validation.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"required": "This field is required",
"email": "Please enter a valid email address",
"min_length": "Minimum length is {min} characters",
"max_length": "Maximum length is {max} characters"
}
4 changes: 4 additions & 0 deletions i18n/en-missing.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"test": "missing_file_test",
"missing": ":/nonexistent.json"
}
9 changes: 1 addition & 8 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,7 @@
"many": "{} many days",
"other": "{} other days"
},
"hat": {
"zero": "no hats",
"one": "one hat",
"two": "two hats",
"few": "few hats",
"many": "many hats",
"other": "other hats"
},
"hats": ":/hats.json",
"hat_other": {
"other": "other hats"
}
Expand Down
9 changes: 9 additions & 0 deletions i18n/en/hats.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"zero": "no hats",
"one": "one hat",
"two": "two hats",
"few": "few hats",
"many": "many hats",
"other": "other hats"

}
2 changes: 2 additions & 0 deletions lib/easy_localization.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ export 'package:easy_localization/src/easy_localization_app.dart';
export 'package:easy_localization/src/asset_loader.dart';
export 'package:easy_localization/src/public.dart';
export 'package:easy_localization/src/public_ext.dart';
export 'package:easy_localization/src/linked_file_resolver.dart';
export 'package:easy_localization/src/file_loaders/root_bundle_file_loader.dart';
export 'package:intl/intl.dart';
Loading