feat(i18n): add arb, json and yaml parsers

This commit is contained in:
Hugo Pointcheval 2023-02-27 16:32:22 +01:00
parent 0a55df8638
commit 8a37aec127
Signed by: hugo
GPG Key ID: 3AAC487E131E00BC
17 changed files with 397 additions and 48 deletions

View File

@ -0,0 +1,13 @@
{
"@@locale": "en",
"youHavePushed": "You have pushed {count} times the bouton !",
"@youHavePushed": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"btnAddFile": "Add file",
"btnAddFileCaption": "Max size: 20MB"
}

View File

@ -0,0 +1,8 @@
"@@locale": en
youHavePushed: You have pushed {count} times the bouton !
"@youHavePushed":
placeholders:
count:
type: int
btnAddFile: Add file
btnAddFileCaption: "Max size: 20MB"

View File

@ -45,8 +45,12 @@ class App extends StatelessWidget {
const I18nRepository repository =
I18RepositoryImpl(dataSource: dataSource);
final test = (await repository.load('en')).ok;
print((await repository.load('fr')).err);
print(test?.locale);
print(test?.data);
print(test?.data['btnAddFile']);
return 'test';
}),

View File

@ -1,18 +1,19 @@
// Copyright (C) 2023 WYATT GROUP
// Please see the AUTHORS file for details.
//
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
export 'enums/format.dart';
export 'exceptions/exceptions.dart';
export 'utils/utils.dart';

View File

@ -14,24 +14,30 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:wyatt_i18n/src/core/utils/arb_parser.dart';
import 'package:wyatt_i18n/src/core/utils/json_parser.dart';
import 'package:wyatt_i18n/src/core/utils/parser.dart';
import 'package:wyatt_i18n/src/core/utils/yaml_parser.dart';
/// Enum for i18n file formats and extensions.
///
/// This enum is used to determine the parser to use for a given i18n file.
enum Format {
/// JSON i18 file format.
json(['json']),
json,
/// YAML i18 file format.
yaml(['yaml', 'yml']),
yaml,
yml,
/// ARB i18 file format.
arb(['arb']);
const Format(this.extensions);
final List<String> extensions;
arb;
/// Returns the [Format] that matches the given [ext].
static Format? fromExtension(String ext) {
for (final format in Format.values) {
if (format.extensions.contains(ext)) {
if (format.name == ext) {
return format;
}
}
@ -42,4 +48,16 @@ enum Format {
/// Returns the [Format] that matches the given [path].
static Format? extensionOf(String path) =>
fromExtension(path.split('.').last);
Parser<String, Map<String, dynamic>> get parser {
switch (this) {
case Format.json:
return const JsonParser();
case Format.yaml:
case Format.yml:
return const YamlParser();
case Format.arb:
return const ArbParser();
}
}
}

View File

@ -31,6 +31,12 @@ class NoLocaleException extends ClientException {
: super('No `@@locale` key found in the source nor locale provided.');
}
/// Exception thrown when the i18n locale is not valid.
class InvalidLocaleException extends ClientException {
InvalidLocaleException(String locale, String expected)
: super('Invalid locale `$locale`. Expected `$expected`.');
}
/// Exception thrown when the key is not found in the i18n file.
class KeyNotFoundException extends ClientException {
KeyNotFoundException(String key, [Map<String, dynamic> arguments = const {}])
@ -51,3 +57,8 @@ class MalformedValueException extends ClientException {
MalformedValueException(String key, String value)
: super('Key `$key` references a malformed value. ($value)');
}
class ParserException extends ClientException {
ParserException(String message, StackTrace? stackTrace)
: super('$message\n\n$stackTrace');
}

View File

@ -0,0 +1,26 @@
// Copyright (C) 2023 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:wyatt_i18n/src/core/utils/json_parser.dart';
import 'package:wyatt_i18n/src/core/utils/parser.dart';
class ArbParser extends Parser<String, Map<String, dynamic>> {
const ArbParser() : super();
/// ARB files are JSON files, so we can use the JSON parser.
@override
Map<String, dynamic> parse(String input) => const JsonParser().parse(input);
}

View File

@ -0,0 +1,33 @@
// Copyright (C) 2023 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'dart:convert';
import 'package:wyatt_i18n/src/core/exceptions/exceptions.dart';
import 'package:wyatt_i18n/src/core/utils/parser.dart';
class JsonParser extends Parser<String, Map<String, dynamic>> {
const JsonParser() : super();
@override
Map<String, dynamic> parse(String input) {
try {
return jsonDecode(input) as Map<String, dynamic>;
} catch (e, s) {
throw ParserException(e.toString(), s);
}
}
}

View File

@ -0,0 +1,21 @@
// Copyright (C) 2023 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
abstract class Parser<I, O> {
const Parser();
O parse(I input);
}

View File

@ -0,0 +1,20 @@
// Copyright (C) 2023 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
export 'arb_parser.dart';
export 'json_parser.dart';
export 'parser.dart';
export 'yaml_parser.dart';

View File

@ -0,0 +1,34 @@
// Copyright (C) 2023 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:wyatt_i18n/src/core/exceptions/exceptions.dart';
import 'package:wyatt_i18n/src/core/utils/parser.dart';
import 'package:yaml/yaml.dart';
class YamlParser extends Parser<String, Map<String, dynamic>> {
const YamlParser() : super();
@override
Map<String, dynamic> parse(String input) {
try {
final yaml = loadYaml(input) as YamlMap;
return yaml.map((key, value) => MapEntry(key.toString(), value));
} catch (e, s) {
throw ParserException(e.toString(), s);
}
}
}

View File

@ -21,10 +21,34 @@ import 'package:wyatt_i18n/src/core/enums/format.dart';
import 'package:wyatt_i18n/src/core/exceptions/exceptions.dart';
import 'package:wyatt_i18n/src/domain/data_sources/i18n_data_source.dart';
/// {@template assets_file_data_source_impl}
/// Implementation of [I18nDataSource] that loads i18n files from the assets.
///
/// The [basePath] is the folder where the i18n files are located.
/// The [baseName] is the name of the i18n files without the extension.
/// The [format] is the format of the i18n files.
///
/// For example, if the i18n files are located in the `assets` and are named
/// `i18n.en.arb`, `i18n.fr.arb`, etc., then the [basePath] is `assets` and
/// the [baseName] is `i18n` and the [format] is [Format.arb].
/// {@endtemplate}
class AssetsFileDataSourceImpl extends I18nDataSource {
const AssetsFileDataSourceImpl({super.format = Format.arb}) : super();
/// {@macro assets_file_data_source_impl}
const AssetsFileDataSourceImpl({
this.basePath = 'assets',
this.baseName = 'i18n',
super.format = Format.arb,
}) : super();
/// The folder where the i18n files are located.
final String basePath;
/// The name of the i18n files without the extension.
final String baseName;
/// Checks if the given [assetPath] is a local asset.
///
/// In fact, this method loads the asset and checks if it is null.
Future<bool> assetExists(String assetPath) async {
final encoded = utf8.encoder.convert(
Uri(path: Uri.encodeFull(assetPath)).path,
@ -35,48 +59,65 @@ class AssetsFileDataSourceImpl extends I18nDataSource {
return asset != null;
}
Future<String?> _tryLoad(String basePath) async {
/// Tries to load the i18n file from the given [locale].
Future<String> _tryLoad(String? locale) async {
String? content;
final ext = format.name;
final path = locale == null
? '$basePath/$baseName.$ext'
: '$basePath/$baseName.$locale.$ext';
try {
for (final ext in super.format.extensions) {
if (await assetExists('$basePath.$ext')) {
content = await rootBundle.loadString('$basePath.$ext');
}
if (await assetExists(path)) {
content = await rootBundle.loadString(path);
}
} catch (_) {
throw SourceNotFoundException(basePath, format: format);
content = null;
}
/// If the i18n file is not found, then we try to load the
/// default i18n file.
if (content == null && locale != null) {
try {
content = await rootBundle.loadString('$basePath/$baseName.$ext');
} catch (_) {
throw SourceNotFoundException(path, format: format);
}
}
/// If the default i18n file is not found, then we throw an exception.
/// This case should happen only if the locale is null.
if (content == null) {
throw SourceNotFoundException(basePath, format: format);
throw SourceNotFoundException(path, format: format);
}
return content;
}
Future<String> _load(String locale) async {
/// Tries to load the i18n file from a given [uri].
/// This method is used when the [uri] is not null.
Future<String> _tryLoadUri(Uri uri) async {
String? content;
try {
content = await _tryLoad('assets/i18n.$locale');
content = await rootBundle.loadString(uri.toString());
} catch (_) {
content = await _tryLoad('assets/i18n');
throw SourceNotFoundException(uri.toString(), format: format);
}
/// Content can't be null at this point.
/// Because if it is, previous [_tryLoad] calls would have t
/// hrown an exception.
return content!;
return content;
}
/// Loads the i18n file from Assets folder.
///
/// The i18n file must be named `i18n.<locale>.<extension>` and must
/// be specified in the `pubspec.yaml` file.
/// The i18n file must be in [basePath], named [baseName] +
/// `.<locale>.<extension>` and must be specified in the `pubspec.yaml` file.
@override
Future<String> load(String locale) async {
final content = await _load(locale);
Future<String> load({required String? locale}) async => _tryLoad(locale);
return content;
}
/// Loads the i18n file from Assets folder.
///
/// If the [uri] is not null, then we try to load the i18n file
/// from the given [uri]. In this case, the [basePath] and the
/// [baseName] are ignored. And there is no fallback.
@override
Future<String> loadFrom(Uri uri) async => _tryLoadUri(uri);
}

View File

@ -19,10 +19,16 @@ import 'package:wyatt_i18n/src/domain/data_sources/i18n_data_source.dart';
class NetworkDataSourceImpl extends I18nDataSource {
const NetworkDataSourceImpl({super.format = Format.arb}) : super();
@override
Future<String> load(String locale) {
// TODO(wyatt): implement load from network
Future<String> load({required String? locale}) {
// TODO(wyatt): implement load
throw UnimplementedError();
}
@override
Future<String> loadFrom(Uri uri) {
// TODO(wyatt): implement loadFrom
throw UnimplementedError();
}
}

View File

@ -15,9 +15,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:wyatt_architecture/wyatt_architecture.dart';
import 'package:wyatt_i18n/src/domain/data_sources/i18n_data_source.dart';
import 'package:wyatt_i18n/src/domain/entities/i18n.dart';
import 'package:wyatt_i18n/src/domain/repositories/i18n_repository.dart';
import 'package:wyatt_i18n/wyatt_i18n.dart';
import 'package:wyatt_type_utils/wyatt_type_utils.dart';
class I18RepositoryImpl extends I18nRepository {
@ -27,17 +25,98 @@ class I18RepositoryImpl extends I18nRepository {
final I18nDataSource dataSource;
Future<I18n> _parse(
String content,
Parser<String, Map<String, dynamic>> parser, {
String? locale,
bool strict = false,
}) async {
final parsed = parser.parse(content);
String parsedLocale;
/// Checks if the locale is present in the parsed data.
/// If not, throws an exception.
/// If yes, sets the locale to the parsed locale.
if (parsed.containsKey('@@locale')) {
if (strict) {
/// Checks if the parsed locale is a string.
if (parsed['@@locale'] is! String) {
throw NoLocaleException();
}
/// Checks if the parsed locale is the same as the given locale.
if (locale != null && parsed['@@locale'] as String != locale) {
throw InvalidLocaleException(locale, parsed['@@locale'] as String);
}
}
parsedLocale = parsed['@@locale'] as String;
} else {
if (strict) {
/// Throws an exception if the locale is not present in the parsed data.
throw NoLocaleException();
} else {
/// Sets the locale to the given locale.
/// If the given locale is null, sets the locale to 'null'.
/// This is done to prevent the locale from being null.
/// It should never be null.
parsedLocale = locale ?? 'null';
}
}
return I18n(
locale: parsedLocale,
unparsedData: content,
data: parsed,
);
}
@override
FutureOrResult<I18n> load(String locale) async =>
FutureOrResult<I18n> load({
required String? locale,
bool strict = false,
Parser<String, Map<String, dynamic>>? parser,
}) async =>
await Result.tryCatchAsync<I18n, AppException, AppException>(
() async {
final content = await dataSource.load(locale);
// TODO: Parse the content into a Map<String, dynamic> and return it.
final content = await dataSource.load(locale: locale);
return I18n(locale: locale,
unparsedData: content,
data: {},);
return _parse(
content,
parser ?? dataSource.format.parser,
locale: locale,
strict: strict,
);
},
(error) => error,
);
@override
FutureOrResult<I18n> loadFrom(
Uri uri, {
Parser<String, Map<String, dynamic>>? parser,
}) async =>
await Result.tryCatchAsync<I18n, AppException, AppException>(
() async {
final content = await dataSource.loadFrom(uri);
/// Strict is always true when loading from a uri. Because
/// the locale is not given and can't be inferred.
return _parse(
content,
parser ?? dataSource.format.parser,
strict: true,
);
},
(error) => error,
);
@override
Result<String, AppException> get(
String key, [
Map<String, dynamic> arguments = const {},
]) {
// TODO: implement get
throw UnimplementedError();
}
}

View File

@ -28,5 +28,12 @@ abstract class I18nDataSource extends BaseDataSource {
final Format format;
/// Loads the i18n file from the source.
Future<String> load(String locale);
/// If [locale] is not `null`, it will load the file with the given [locale].
/// Otherwise, it will load the file from the default location.
Future<String> load({required String? locale});
/// Loads the i18n file from the source.
///
/// This method is used to load the i18n file from the given [Uri].
Future<String> loadFrom(Uri uri);
}

View File

@ -15,12 +15,37 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:wyatt_architecture/wyatt_architecture.dart';
import 'package:wyatt_i18n/src/core/utils/parser.dart';
import 'package:wyatt_i18n/src/domain/entities/i18n.dart';
import 'package:wyatt_type_utils/wyatt_type_utils.dart';
/// Base class for i18n repositories.
abstract class I18nRepository extends BaseRepository {
const I18nRepository() : super();
/// Loads the i18n file from the source.
FutureOrResult<I18n> load(String locale);
/// If [strict] is `true`, it will throw an NoLocaleException if the
/// `@@locale` key is not found in the i18n file, otherwise it will
/// set the locale to the given [locale].
/// If [parser] is not `null`, it will use the given parser to parse
/// the i18n file. Otherwise, it will use the default parser for the format.
FutureOrResult<I18n> load({
required String? locale,
bool strict = false,
Parser<String, Map<String, dynamic>>? parser,
});
/// Loads the i18n file from the given [uri].
/// If [parser] is not `null`, it will use the given parser to parse
/// the i18n file. Otherwise, it will use the default parser for the format.
FutureOrResult<I18n> loadFrom(
Uri uri, {
Parser<String, Map<String, dynamic>>? parser,
});
/// Gets the translation for the given [key].
Result<String, AppException> get(
String key, [
Map<String, dynamic> arguments = const {},
]);
}

View File

@ -9,6 +9,7 @@ environment:
dependencies:
flutter: {sdk: flutter}
path: ^1.8.0
petitparser: ^5.1.0
wyatt_architecture:
hosted:
url: https://git.wyatt-studio.fr/api/packages/Wyatt-FOSS/pub/
@ -24,6 +25,7 @@ dependencies:
url: https://git.wyatt-studio.fr/api/packages/Wyatt-FOSS/pub/
name: wyatt_type_utils
version: 0.0.4
yaml: ^3.1.1
dev_dependencies:
flutter_test: {sdk: flutter}