diff --git a/CHANGELOG.md b/CHANGELOG.md index f98d84f..96f27ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## [0.1.0] - * Set Dart 3.8 as minimum constraint +* Ability to set optional placeholder metadata ## [0.0.4] - 20/10/2024 diff --git a/README.md b/README.md index 7a9a712..1332c6f 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,62 @@ # arb_generator -A dart tool which generates ARB files from CSV files. +A dart tool which generates ARB localization files from CSV files. + +## Features + +- CSV → ARB conversion +- Flutter intl compatible +- ICU placeholder, plural and select support +- Placeholder metadata support +- Multi-language generation ## Getting Started +### Add dependency + +Add the package as a dev dependency: + +```yaml +dev_dependencies: + arb_generator: +``` + +### Define Settings + +Next define arb_generator package settings in `pubspec.yaml`: + +```yaml +arb_generator: + input_filepath: "assets_dev/test.csv" + output_directory: "lib/l10n" + filename_prepend: "intl_" + csv_settings: + delimiter: ";" + description_index: 1 + base_index: 2 +``` + +| Setting | Description | +| ------------------------------- | ------------------------------------------------------------------------------| +| input_filepath | Required. A path to the input CSV file. | +| output_directory | A directory to generate the output ARB file(s). Defaults to `lib/l10n` | +| filename_prepend | Text to prepend to filename of generated files. Defaults to empty string. | +| csv_settings: delimiter | A delimiter to separate columns in the input CSV file. Defaults to `,`. | +| csv_settings: description_index | The description column index. Defaults to `null` (i.e. no description given) | +| csv_settings: base_index | The column index of the base language in the input CSV file. Defaults to `1`. | + +### Run package + +Run the following command from your project root: + +```sh +dart run arb_generator +``` + +ARB files are then generated in `output_directory`. + +## Generated Output + A CSV file of the form |keys|description|en|de| @@ -11,9 +64,11 @@ A CSV file of the form |myKey|The conventional newborn programmer greeting|Hello world!|Hallo Welt!| |welcome|A welcome message|Welcome {firstName}!|Willkommen {firstName}!| |numberMessages|An info message about new messages count|{count, plural, zero{You have no new messages} one{You have 1 new message} other{You have {count} new messages}}|{count, plural, zero{Du hast keine neue Nachrichten} one{Du hast eine neue Nachricht} other{Du hast {count} neue Nachrichten}}| -|whoseBook|A message determine whose book it is|{sex, select, male{His book} female{Her book} other{Their book}}|{sex, select, male{Sein Buch} female{Ihr Buch} other{Ihr Buch}}| +|whoseBook|A message determining whose book it is|{sex, select, male{His book} female{Her book} other{Their book}}|{sex, select, male{Sein Buch} female{Ihr Buch} other{Ihr Buch}}| + +generates the following ARB files: -is generated into the following ARB file +**en.arb** ```json { @@ -32,55 +87,98 @@ is generated into the following ARB file }, "whoseBook": "{sex, select, male{His book} female{Her book} other{Their book}}", "@whoseBook": { - "description": "A message determine whose book it is" + "description": "A message determining whose book it is" } } ``` -This ARB file can then be converted into localization delegates using [intl](https://docs.flutter.dev/development/accessibility-and-localization/internationalization) or [intl_utils](https://pub.dev/packages/intl_utils). +**de.arb** -### Add dependency +```json +{ + "@@locale": "de", + "myKey": "Hallo Welt!", + "@myKey": { + "description": "The conventional newborn programmer greeting" + }, + "welcome": "Willkommen {firstName}!", + "@welcome": { + "description": "A welcome message" + }, + "numberMessages": "{count, plural, zero{Du hast keine neue Nachrichten} one{Du hast eine neue Nachricht} other{Du hast {count} neue Nachrichten}}", + "@numberMessages": { + "description": "An info message about new messages count" + }, + "whoseBook": "{sex, select, male{Sein Buch} female{Ihr Buch} other{Ihr Buch}}", + "@whoseBook": { + "description": "A message determining whose book it is" + } +} +``` -Firstly, add the package as a dev dependency: +## CSV Format -```yaml -dev_dependencies: - arb_generator: -``` +- The first column must contain translation keys +- Locale columns should use valid locale codes such as `en`, `de` or `en_US` +- The first row is treated as the header row +- ICU message syntax is supported in translation values -### Define Settings +## Flutter Integration -Next define arb_generator package settings in `pubspec.yaml`. Note that `input_filepath` is the only required parameter. +See [example/l10n.yml](example/l10n.yml) and follow the [official documentation](https://docs.flutter.dev/ui/internationalization) to generate localization delegates from the ARB files. -```yaml -arb_generator: - input_filepath: "assets_dev/test.csv" - output_directory: "lib/l10n" - filename_prepend: "intl_" - csv_settings: - delimiter: ";" - description_index: 1 - base_index: 2 +## Optional Metadata + +By default, if a description is given, it is added as metadata in the ARB file: + +```json +"welcome": "Welcome {firstName}!", +"@welcome": { + "description": "A welcome message", +} ``` -| Setting | Description | -| ------------------------------- | ------------------------------------------------------------------------------| -| input_filepath | Required. A path to the input CSV file. | -| output_directory | A directory to generate the output ARB file(s). Defaults to `lib/l10n` | -| filename_prepend | Text to prepend to filename of generated files. Defaults to empty string. | -| csv_settings: delimiter | A delimiter to separate columns in the input CSV file. Defaults to `,`. | -| csv_settings: description_index | The description column index. Defaults to `null`. | -| csv_settings: base_index | The column index of the base language in the input CSV file. Defaults to `1`. | +Optional placeholder metadata can be supplied by adding a `json` file with the same filename as `input_filepath` input CSV file: -### Run package +```json +{ + "@welcome": { + "placeholders": { + "firstName": { + "type": "String", + "example": "Dash" + } + } + } +} +``` -Ensure that your current working directory is the project root and run the following command: +This metadata will then be copied into the generated file: -```sh -dart run arb_generator +```json +"welcome": "Welcome {firstName}!", +"@welcome": { + "description": "A welcome message", + "placeholders": { + "firstName": { + "type": "String", + "example": "Dash" + } + } +} ``` -ARB files are then generated in `output_directory`. +Now the generated delegates will expect `firstName` to be of type `String` not `Object`. + +```dart +/// A welcome message +/// +/// In en, this message translates to: +/// **'Welcome {firstName}!'** +String welcome(String firstName); +``` + +See [example/assets_dev/test.json](example/assets_dev/test.json) for more info. ## Collaboration diff --git a/example/assets_dev/test.csv b/example/assets_dev/test.csv index f08967a..33a33f2 100644 --- a/example/assets_dev/test.csv +++ b/example/assets_dev/test.csv @@ -2,6 +2,6 @@ keys;description;en;de myKey;The conventional newborn programmer greeting;Hello world!;Hallo Welt! welcome;A welcome message;Welcome {firstName}!;Willkommen {firstName}! numberMessages;An info message about new messages count;{count, plural, zero{You have no new messages} one{You have 1 new message} other{You have {count} new messages}};{count, plural, zero{Du hast keine neue Nachrichten} one{Du hast eine neue Nachricht} other{Du hast {count} neue Nachrichten}} -whoseBook;A message determine whose book it is;{sex, select, male{His book} female{Her book} other{Their book}};{sex, select, male{Sein Buch} female{Ihr Buch} other{Ihr Buch}} +whoseBook;A message determining whose book it is;{sex, select, male{His book} female{Her book} other{Their book}};{sex, select, male{Sein Buch} female{Ihr Buch} other{Ihr Buch}} unreadEmails;How many unread emails for user;{howMany, plural, zero{There are no unread emails for {userName}} one{There is 1 unread email for {userName}} other{There are {howMany} unread emails for {userName}}};{howMany, plural, zero{Es gibt keine ungelesenen Emails für {userName}} one{Es gibt eine ungelesene für {userName}} other{Es gibt {howMany} ungelesenen Emails für {userName}}} weatherReaction;Reaction to types of weather;{weatherType, select, sunny{Woohoo} cloudy{Meh} rainy{Weeh} other{Other}};{weatherType, select, sunny{Prima} cloudy{In Ordnung} rainy{Mist} other{Other}} diff --git a/example/assets_dev/test.json b/example/assets_dev/test.json new file mode 100644 index 0000000..014ab24 --- /dev/null +++ b/example/assets_dev/test.json @@ -0,0 +1,30 @@ +{ + "@welcome": { + "placeholders": { + "firstName": { + "type": "String", + "example": "Dash" + } + } + }, + "@numberMessages": { + "placeholders": { + "count": { + "type": "int", + "example": "1" + } + } + }, + "unreadEmails": { + "placeholders": { + "howMany": { + "type": "int", + "example": "1" + }, + "userName": { + "type": "String", + "example": "dash" + } + } + } +} \ No newline at end of file diff --git a/example/lib/l10n/app_de.arb b/example/lib/l10n/app_de.arb index 2b60c71..cf13b2e 100644 --- a/example/lib/l10n/app_de.arb +++ b/example/lib/l10n/app_de.arb @@ -6,19 +6,41 @@ }, "welcome": "Willkommen {firstName}!", "@welcome": { - "description": "A welcome message" + "description": "A welcome message", + "placeholders": { + "firstName": { + "type": "String", + "example": "Dash" + } + } }, "numberMessages": "{count, plural, zero{Du hast keine neue Nachrichten} one{Du hast eine neue Nachricht} other{Du hast {count} neue Nachrichten}}", "@numberMessages": { - "description": "An info message about new messages count" + "description": "An info message about new messages count", + "placeholders": { + "count": { + "type": "int", + "example": "1" + } + } }, "whoseBook": "{sex, select, male{Sein Buch} female{Ihr Buch} other{Ihr Buch}}", "@whoseBook": { - "description": "A message determine whose book it is" + "description": "A message determining whose book it is" }, "unreadEmails": "{howMany, plural, zero{Es gibt keine ungelesenen Emails für {userName}} one{Es gibt eine ungelesene für {userName}} other{Es gibt {howMany} ungelesenen Emails für {userName}}}", "@unreadEmails": { - "description": "How many unread emails for user" + "description": "How many unread emails for user", + "placeholders": { + "howMany": { + "type": "int", + "example": "1" + }, + "userName": { + "type": "String", + "example": "dash" + } + } }, "weatherReaction": "{weatherType, select, sunny{Prima} cloudy{In Ordnung} rainy{Mist} other{Other}}", "@weatherReaction": { diff --git a/example/lib/l10n/app_en.arb b/example/lib/l10n/app_en.arb index f417729..b55e72c 100644 --- a/example/lib/l10n/app_en.arb +++ b/example/lib/l10n/app_en.arb @@ -6,19 +6,41 @@ }, "welcome": "Welcome {firstName}!", "@welcome": { - "description": "A welcome message" + "description": "A welcome message", + "placeholders": { + "firstName": { + "type": "String", + "example": "Dash" + } + } }, "numberMessages": "{count, plural, zero{You have no new messages} one{You have 1 new message} other{You have {count} new messages}}", "@numberMessages": { - "description": "An info message about new messages count" + "description": "An info message about new messages count", + "placeholders": { + "count": { + "type": "int", + "example": "1" + } + } }, "whoseBook": "{sex, select, male{His book} female{Her book} other{Their book}}", "@whoseBook": { - "description": "A message determine whose book it is" + "description": "A message determining whose book it is" }, "unreadEmails": "{howMany, plural, zero{There are no unread emails for {userName}} one{There is 1 unread email for {userName}} other{There are {howMany} unread emails for {userName}}}", "@unreadEmails": { - "description": "How many unread emails for user" + "description": "How many unread emails for user", + "placeholders": { + "howMany": { + "type": "int", + "example": "1" + }, + "userName": { + "type": "String", + "example": "dash" + } + } }, "weatherReaction": "{weatherType, select, sunny{Woohoo} cloudy{Meh} rainy{Weeh} other{Other}}", "@weatherReaction": { diff --git a/example/lib/l10n/app_localizations.dart b/example/lib/l10n/app_localizations.dart index 8d708f0..34953c7 100644 --- a/example/lib/l10n/app_localizations.dart +++ b/example/lib/l10n/app_localizations.dart @@ -108,15 +108,15 @@ abstract class AppLocalizations { /// /// In en, this message translates to: /// **'Welcome {firstName}!'** - String welcome(Object firstName); + String welcome(String firstName); /// An info message about new messages count /// /// In en, this message translates to: /// **'{count, plural, zero{You have no new messages} one{You have 1 new message} other{You have {count} new messages}}'** - String numberMessages(num count); + String numberMessages(int count); - /// A message determine whose book it is + /// A message determining whose book it is /// /// In en, this message translates to: /// **'{sex, select, male{His book} female{Her book} other{Their book}}'** @@ -126,7 +126,7 @@ abstract class AppLocalizations { /// /// In en, this message translates to: /// **'{howMany, plural, zero{There are no unread emails for {userName}} one{There is 1 unread email for {userName}} other{There are {howMany} unread emails for {userName}}}'** - String unreadEmails(num howMany, Object userName); + String unreadEmails(int howMany, String userName); /// Reaction to types of weather /// diff --git a/example/lib/l10n/app_localizations_de.dart b/example/lib/l10n/app_localizations_de.dart index c87b853..d0ea340 100644 --- a/example/lib/l10n/app_localizations_de.dart +++ b/example/lib/l10n/app_localizations_de.dart @@ -12,12 +12,12 @@ class AppLocalizationsDe extends AppLocalizations { String get myKey => 'Hallo Welt!'; @override - String welcome(Object firstName) { + String welcome(String firstName) { return 'Willkommen $firstName!'; } @override - String numberMessages(num count) { + String numberMessages(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, @@ -42,7 +42,7 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String unreadEmails(num howMany, Object userName) { + String unreadEmails(int howMany, String userName) { String _temp0 = intl.Intl.pluralLogic( howMany, locale: localeName, diff --git a/example/lib/l10n/app_localizations_en.dart b/example/lib/l10n/app_localizations_en.dart index 27b8127..bae5385 100644 --- a/example/lib/l10n/app_localizations_en.dart +++ b/example/lib/l10n/app_localizations_en.dart @@ -12,12 +12,12 @@ class AppLocalizationsEn extends AppLocalizations { String get myKey => 'Hello world!'; @override - String welcome(Object firstName) { + String welcome(String firstName) { return 'Welcome $firstName!'; } @override - String numberMessages(num count) { + String numberMessages(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, @@ -42,7 +42,7 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String unreadEmails(num howMany, Object userName) { + String unreadEmails(int howMany, String userName) { String _temp0 = intl.Intl.pluralLogic( howMany, locale: localeName, diff --git a/lib/src/models/arb/arb_file.dart b/lib/src/models/arb/arb_file.dart index 9195f17..b110c70 100644 --- a/lib/src/models/arb/arb_file.dart +++ b/lib/src/models/arb/arb_file.dart @@ -18,16 +18,19 @@ class Message { required this.key, required this.value, this.description, + this.metadata, }); final String key; final String value; final String? description; + final Map? metadata; Map toJson() => { key: value, '@$key': { - if (description != null) 'description': description, + 'description': ?description, + ...?metadata, }, }; } diff --git a/lib/src/services/arb_generator.dart b/lib/src/services/arb_generator.dart index 03f00a9..7db4281 100644 --- a/lib/src/services/arb_generator.dart +++ b/lib/src/services/arb_generator.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'dart:io'; +import 'package:path/path.dart' as p; + import '../models/arb/arb_file.dart'; import '../models/settings/package_settings.dart'; import 'file_writer/file_writer.dart'; @@ -24,6 +26,11 @@ abstract class ARBGenerator { // File is valid, state progress print('Loading file ${packageSettings.inputFilepath}...'); + // Try to load optional metadata + final optionalMetadata = _loadOptionalMetadata( + packageSettings.inputFilepath, + ); + final parser = CSVParser( file: file, startIndex: packageSettings.csvSettings.baseIndex, @@ -56,6 +63,7 @@ abstract class ARBGenerator { descriptions: packageSettings.csvSettings.descriptionIndex != null ? parser.getColumn(packageSettings.csvSettings.descriptionIndex!) : null, + optionalMetadata: optionalMetadata, ); var prettyContent = encoder.convert(content.toJson()); // convert turns \n into \\n @@ -76,12 +84,31 @@ abstract class ARBGenerator { } } +Map? _loadOptionalMetadata( + String inputFilepath, +) { + final optionalMetadataPath = p.setExtension( + inputFilepath, + '.json', + ); + final optionalMetadataFile = File(optionalMetadataPath); + final contents = optionalMetadataFile.existsSync() + ? json.decode(optionalMetadataFile.readAsStringSync()) + : null; + if (contents != null) { + print('Loading optional metadata $optionalMetadataPath...'); + } + + return contents; +} + ARBFile _generateARBFile({ required String language, required List keys, required List values, required List defaultValues, List? descriptions, + Map? optionalMetadata, }) { if (keys.length != values.length && keys.length != defaultValues.length) { print('Error! Mismatch number of keys and values'); @@ -89,15 +116,20 @@ ARBFile _generateARBFile({ } final messages = []; - for (var i = 0; i < keys.length; i++) { + for (final (i, key) in keys.indexed) { final value = i < values.length && values[i].isNotEmpty ? values[i] : defaultValues[i]; + + // safety check in case user forgot @ + final metadata = optionalMetadata?['@$key'] ?? optionalMetadata?[key]; + messages.add( Message( - key: keys[i], + key: key, value: value, description: descriptions?[i], + metadata: metadata, ), ); } diff --git a/pubspec.yaml b/pubspec.yaml index ae84fef..807086d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: arb_generator -description: A dart tool which generates ARB files from CSV files. +description: A dart tool which generates ARB localization files from CSV files. version: 0.0.4 homepage: https://github.com/defuncart/arb_generator repository: https://github.com/defuncart/arb_generator @@ -12,6 +12,7 @@ dependencies: meta: ^1.16.0 yaml: ^3.1.2 csv: ^6.0.0 + path: ^1.9.0 dev_dependencies: test: ^1.25.5 diff --git a/test/services/parsing/csv_parser_test.dart b/test/services/parsing/csv_parser_test.dart index 857447b..099ad47 100644 --- a/test/services/parsing/csv_parser_test.dart +++ b/test/services/parsing/csv_parser_test.dart @@ -32,7 +32,7 @@ void main() { ], [ 'whoseBook', - 'A message determine whose book it is', + 'A message determining whose book it is', '{sex, select, male{His book} female{Her book} other{Their book}}', '{sex, select, male{Sein Buch} female{Ihr Buch} other{Ihr Buch}}', ],