├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── analysis_options.yaml
├── bin
├── generate.dart
├── localizely_download.dart
└── localizely_upload_main.dart
├── doc
└── getting_started.md
├── example
└── README.md
├── lib
├── intl_utils.dart
└── src
│ ├── config
│ ├── config_exception.dart
│ ├── credentials_config.dart
│ └── pubspec_config.dart
│ ├── constants
│ └── constants.dart
│ ├── generator
│ ├── generator.dart
│ ├── generator_exception.dart
│ ├── intl_translation_helper.dart
│ ├── label.dart
│ └── templates.dart
│ ├── intl_translation
│ ├── extract_messages.dart
│ ├── generate_localized.dart
│ └── src
│ │ ├── icu_parser.dart
│ │ └── intl_message.dart
│ ├── localizely
│ ├── api
│ │ ├── api.dart
│ │ └── api_exception.dart
│ ├── model
│ │ ├── download_response.dart
│ │ └── file_data.dart
│ └── service
│ │ ├── service.dart
│ │ └── service_exception.dart
│ ├── parser
│ ├── icu_parser.dart
│ └── message_format.dart
│ └── utils
│ ├── file_utils.dart
│ └── utils.dart
├── pubspec.yaml
└── test
├── download_response_test.dart
├── download_response_test.mocks.dart
├── label_test.dart
├── parser_test.dart
└── utils_test.dart
/.gitignore:
--------------------------------------------------------------------------------
1 | # Miscellaneous
2 | *.class
3 | *.log
4 | *.pyc
5 | *.swp
6 | .DS_Store
7 | .atom/
8 | .buildlog/
9 | .history
10 | .svn/
11 | migrate_working_dir/
12 |
13 | # IntelliJ related
14 | *.iml
15 | *.ipr
16 | *.iws
17 | .idea/
18 |
19 | # The .vscode folder contains launch configuration and tasks you configure in
20 | # VS Code which you may wish to be included in version control, so this line
21 | # is commented out by default.
22 | #.vscode/
23 |
24 | # Flutter/Dart/Pub related
25 | # Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
26 | /pubspec.lock
27 | **/doc/api/
28 | .dart_tool/
29 | .packages
30 | build/
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 |
3 | All notable changes to the "flutter-intl" extension will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## 2.8.10 - 2025-02-11
9 |
10 | - Update `analyzer` dependency
11 |
12 | ## 2.8.9 - 2025-01-23
13 |
14 | - Update docs
15 |
16 | - Update `dart_style` dependency
17 | Note: This update introduces 'tall style' formatting for the generated code. This new formatting style is also set to become the [default in Dart 3.7 and later versions](https://pub.dev/packages/dart_style/changelog#300).
18 |
19 | ## 2.8.8 - 2025-01-08
20 |
21 | - Update `intl` dependency
22 |
23 | - Update `archive` dependency
24 |
25 | - Fix lint warnings
26 |
27 | ## 2.8.7 - 2023-12-25
28 |
29 | - Update `intl` dependency
30 |
31 | - Fix deprecation and lint warnings
32 |
33 | ## 2.8.6 - 2023-11-22
34 |
35 | - Update `analyzer` dependency
36 |
37 | - Update `mockito` dependency
38 |
39 | ## 2.8.5 - 2023-10-13
40 |
41 | - Update `petitparser` dependency
42 |
43 | ## 2.8.4 - 2023-08-04
44 |
45 | - Add support for locale formats based on ISO 639-2/3 languages and UN-M49 regions
46 |
47 | ## 2.8.3 - 2023-06-01
48 |
49 | - Update `http` dependency
50 |
51 | - Update `.gitignore` file
52 |
53 | ## 2.8.2 - 2023-02-22
54 |
55 | - Update `intl` dependency
56 |
57 | ## 2.8.1 - 2022-11-17
58 |
59 | - Fix black frames caused by async initialization of localization messages when deferred loading is not enabled
60 |
61 | ## 2.8.0 - 2022-11-15
62 |
63 | - Update `analyzer` and `lints` dependencies
64 |
65 | ## 2.7.0 - 2022-07-07
66 |
67 | - Update `analyzer` and `petitparser` dependencies
68 |
69 | ## 2.6.1 - 2022-01-14
70 |
71 | - Improve error handling for invalid config files
72 |
73 | - Update `analyzer` dependency
74 |
75 | ## 2.6.0 - 2021-12-24
76 |
77 | - Add custom date-time format option
78 |
79 | ## 2.5.1 - 2021-11-08
80 |
81 | - Fix optional parameters string issue
82 |
83 | ## 2.5.0 - 2021-11-05
84 |
85 | - Add support for json strings
86 |
87 | - Add number and date-time format options
88 |
89 | - Move from pedantic to lints package
90 |
91 | ## 2.4.1 - 2021-10-01
92 |
93 | - Update `analyzer` dependency
94 |
95 | ## 2.4.0 - 2021-07-13
96 |
97 | - Add support for tagging uploaded string keys to Localizely
98 |
99 | - Add support for download tagged string keys from Localizely
100 |
101 | - Fix issue with translations that contain tags
102 |
103 | ## 2.3.0 - 2021-05-18
104 |
105 | - Add missing upload and download command line arg options
106 |
107 | ## 2.2.0 - 2021-04-27
108 |
109 | - Add support for compound messages
110 |
111 | - Format generated files
112 |
113 | - Add missing return types in generated files
114 |
115 | - Ignore avoid_escaping_inner_quotes lint rule in generated files
116 |
117 | - Fix escaping special chars
118 |
119 | ## 2.1.0 - 2021-03-09
120 |
121 | - Make `of(context)` non-null
122 |
123 | ## 2.0.0 - 2021-03-05
124 |
125 | - Migrate to null-safety
126 |
127 | ## 1.9.0 - 2020-10-19
128 |
129 | - Make generated directory path configurable
130 |
131 | - Extend configuration with deferred loading parameter
132 |
133 | - Ignore common lint warnings for the `l10n.dart` file
134 |
135 | ## 1.8.0 - 2020-10-09
136 |
137 | - Extend Localizely configuration with the download_empty_as parameter used for setting a fallback for empty translations during download
138 |
139 | ## 1.7.0 - 2020-09-29
140 |
141 | - Make ARB directory path configurable
142 |
143 | ## 1.6.5 - 2020-09-18
144 |
145 | - Fix unzipping issues during download
146 |
147 | ## 1.6.4 - 2020-09-03
148 |
149 | - Extend Localizely configuration with the branch parameter
150 |
151 | ## 1.6.3 - 2020-08-06
152 |
153 | - Update `petitparser` dependency
154 |
155 | ## 1.6.2 - 2020-06-22
156 |
157 | - Update file logic
158 |
159 | - Code cleanup
160 |
161 | ## 1.6.1 - 2020-06-17
162 |
163 | - Add useful error message for invalid ARB files
164 |
165 | ## 1.6.0 - 2020-06-03
166 |
167 | - Reference the key without passing the context
168 |
169 | - Provide default value of term as Dart doc on getters in `l10n.dart` file
170 |
171 | - Suppress lint warnings for getters that do not follow the lowerCamelCase style within `l10n.dart` file
172 |
173 | ## 1.5.0 - 2020-05-11
174 |
175 | - Add support for the Localizely SDK
176 |
177 | - Fix lint warnings for the `l10n.dart` file
178 |
179 | ## 1.4.0 - 2020-05-04
180 |
181 | - Add integration with Localizely
182 |
183 | ## 1.3.0 - 2020-04-21
184 |
185 | - Support select messages
186 |
187 | - Make order of supported locales consistent
188 |
189 | ## 1.2.2 - 2020-04-13
190 |
191 | - Make generated files consistent
192 |
193 | ## 1.2.1 - 2020-03-30
194 |
195 | - Update order of supported locales
196 |
197 | - Replace `dynamic` with concrete type for generated Dart methods
198 |
199 | - Handle empty plural and gender forms
200 |
201 | - Update `l10n.dart` file template (remove `localeName`)
202 |
203 | ## 1.2.0 - 2020-03-16
204 |
205 | - Add support for locales with script code
206 |
207 | - Fix locale loading issue when country code is not provided
208 |
209 | ## 1.1.0 - 2020-02-04
210 |
211 | - Make main locale configurable
212 |
213 | ## 1.0.2 - 2020-01-21
214 |
215 | - Add curly-braces around placeholders when they are followed by alphanumeric or underscore character
216 |
217 | ## 1.0.1 - 2020-01-15
218 |
219 | - Fix trailing comma issue (l10n.dart)
220 |
221 | - Remove unused dependencies
222 |
223 | ## 1.0.0 - 2020-01-12
224 |
225 | - Initial release
226 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2020 The Localizely Authors. All rights reserved.
2 |
3 | Redistribution and use in source and binary forms, with or without modification,
4 | are permitted provided that the following conditions are met:
5 |
6 | * Redistributions of source code must retain the above copyright
7 | notice, this list of conditions and the following disclaimer.
8 | * Redistributions in binary form must reproduce the above
9 | copyright notice, this list of conditions and the following
10 | disclaimer in the documentation and/or other materials provided
11 | with the distribution.
12 | * Neither the name of Localizely Inc. nor the names of its
13 | contributors may be used to endorse or promote products derived
14 | from this software without specific prior written permission.
15 |
16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
17 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
18 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
20 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
21 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
22 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
23 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
24 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
25 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Intl Utils
2 |
3 | [](https://pub.dev/packages/intl_utils)
4 | [](https://twitter.com/intent/follow?screen_name=localizely)
5 |
6 | Dart package that creates a binding between your translations from .arb files and your Flutter app. It generates boilerplate code for official Dart Intl library and adds auto-complete for keys in Dart code.
7 |
8 | ## Usage
9 |
10 | You can use this package directly (i.e. for Continuous Integration tools or via CLI) or leave it to [Visual Studio Code](https://marketplace.visualstudio.com/items?itemName=localizely.flutter-intl) or [IntelliJ/Android Studio](https://plugins.jetbrains.com/plugin/13666-flutter-intl) plugins to run it automatically whenever you modify ARB files.
11 |
12 | Follow these steps to get started:
13 |
14 | ### Configure package
15 |
16 | Add package configuration to your `pubspec.yaml` file. Here is a full configuration for the package:
17 |
18 |
19 | flutter_intl:
20 | enabled: true # Required. Must be set to true to activate the package. Default: false
21 | class_name: S # Optional. Sets the name for the generated localization class. Default: S
22 | main_locale: en # Optional. Sets the main locale used for generating localization files. Provided value should consist of language code and optional script and country codes separated with underscore (e.g. 'en', 'en_GB', 'zh_Hans', 'zh_Hans_CN'). Default: en
23 | arb_dir: lib/l10n # Optional. Sets the directory of your ARB resource files. Provided value should be a valid path on your system. Default: lib/l10n
24 | output_dir: lib/generated # Optional. Sets the directory of generated localization files. Provided value should be a valid path on your system. Default: lib/generated
25 | use_deferred_loading: false # Optional. Must be set to true to generate localization code that is loaded with deferred loading. Default: false
26 | localizely: # Optional settings if you use Localizely platform. Read more: https://localizely.com/blog/flutter-localization-step-by-step/?tab=automated-using-flutter-intl
27 | project_id: # Get it from the https://app.localizely.com/projects page.
28 | branch: # Get it from the “Branches” page on the Localizely platform, in case branching is enabled and you want to use a non-main branch.
29 | upload_overwrite: # Set to true if you want to overwrite translations with upload. Default: false
30 | upload_as_reviewed: # Set to true if you want to mark uploaded translations as reviewed. Default: false
31 | upload_tag_added: # Optional list of tags to add to new translations with upload (e.g. ['new', 'New translation']). Default: []
32 | upload_tag_updated: # Optional list of tags to add to updated translations with upload (e.g. ['updated', 'Updated translation']). Default: []
33 | upload_tag_removed: # Optional list of tags to add to removed translations with upload (e.g. ['removed', 'Removed translation']). Default: []
34 | download_empty_as: # Set to empty|main|skip, to configure how empty translations should be exported from the Localizely platform. Default: empty
35 | download_include_tags: # Optional list of tags to be downloaded (e.g. ['include', 'Include key']). If not set, all string keys will be considered for download. Default: []
36 | download_exclude_tags: # Optional list of tags to be excluded from download (e.g. ['exclude', 'Exclude key']). If not set, all string keys will be considered for download. Default: []
37 | ota_enabled: # Set to true if you want to use Localizely Over-the-air functionality. Default: false
38 |
39 |
40 | ### Add ARB files
41 |
42 | Add one ARB file for each locale you need to support in your Flutter app.
43 | Add them to `lib/l10n` folder inside your project, and name them in a following way: `intl_.arb`.
44 | For example: `intl_en.arb` or `intl_en_GB.arb`.
45 | You can also change the ARB folder from `lib/l10n` to a custom directory by adding the `arb_dir` line in your `pubspec.yaml` file.
46 |
47 | If you wonder how to format key-values content inside ARB files, [here](https://localizely.com/flutter-arb/) is detailed explanation.
48 |
49 | ### Run command
50 |
51 | To generate boilerplate code for localization, run the `generate` program inside directory where your `pubspec.yaml` file is located:
52 |
53 | dart run intl_utils:generate
54 |
55 | This will produce files inside `lib/generated` directory.
56 | You can also change the output folder from `lib/generated` to a custom directory by adding the `output_dir` line in your `pubspec.yaml` file.
57 |
58 | ### Integration with Localizely
59 |
60 | #### Upload main ARB file
61 |
62 | dart run intl_utils:localizely_upload_main [--project-id --api-token --arb-dir --main-locale --branch --[no-]upload-overwrite --[no-]upload-as-reviewed] --upload-tag-added --upload-tag-updated --upload-tag-removed
63 |
64 | This will upload your main ARB file to Localizely. All args are optional. If not provided, the `intl_utils` will use configuration from the `pubspec.yaml` file or default values (check the [Configure package](#configure-package) section for more details).
65 |
66 | #### Download ARB files
67 |
68 | dart run intl_utils:localizely_download [--project-id --api-token --arb-dir --download-empty-as --download-include-tags --download-exclude-tags --branch ]
69 |
70 | This will download all available ARB files from the Localizely platform and put them under `arb-dir` folder. All args are optional. If not provided, the `intl_utils` will use configuration from the `pubspec.yaml` file or default values (check the [Configure package](#configure-package) section for more details).
71 |
72 | Notes:
73 | Argument `project-id` can be omitted if `pubspec.yaml` file contains `project_id` configuration under `flutter_intl/localizely` section.
74 | Argument `api-token` can be omitted if `~/.localizely/credentials.yaml` file contains `api_token` configuration (e.g. `api_token: xxxxxx`).
75 | Optional argument `arb-dir` has the value `lib/l10n` as default and needs only to be set, if you want to place your ARB files in a custom directory.
76 |
--------------------------------------------------------------------------------
/analysis_options.yaml:
--------------------------------------------------------------------------------
1 | include: package:lints/recommended.yaml
2 |
3 | analyzer:
4 | exclude:
5 | - test/*.mocks.dart
6 |
--------------------------------------------------------------------------------
/bin/generate.dart:
--------------------------------------------------------------------------------
1 | library intl_utils;
2 |
3 | import 'package:intl_utils/intl_utils.dart';
4 | import 'package:intl_utils/src/generator/generator_exception.dart';
5 | import 'package:intl_utils/src/utils/utils.dart';
6 |
7 | Future main(List args) async {
8 | try {
9 | var generator = Generator();
10 | await generator.generateAsync();
11 | } on GeneratorException catch (e) {
12 | exitWithError(e.message);
13 | } catch (e) {
14 | exitWithError('Failed to generate localization files.\n$e');
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/bin/localizely_download.dart:
--------------------------------------------------------------------------------
1 | library intl_utils;
2 |
3 | import 'dart:io';
4 |
5 | import 'package:args/args.dart' as args;
6 | import 'package:intl_utils/src/config/config_exception.dart';
7 | import 'package:intl_utils/src/config/credentials_config.dart';
8 | import 'package:intl_utils/src/config/pubspec_config.dart';
9 | import 'package:intl_utils/src/constants/constants.dart';
10 | import 'package:intl_utils/src/localizely/api/api_exception.dart';
11 | import 'package:intl_utils/src/localizely/service/service.dart';
12 | import 'package:intl_utils/src/localizely/service/service_exception.dart';
13 | import 'package:intl_utils/src/utils/file_utils.dart';
14 | import 'package:intl_utils/src/utils/utils.dart';
15 |
16 | Future main(List arguments) async {
17 | late String? projectId;
18 | late String? apiToken;
19 | late String arbDir;
20 | late String downloadEmptyAs;
21 | late List? downloadIncludeTags;
22 | late List? downloadExcludeTags;
23 | late String? branch;
24 |
25 | final argParser = args.ArgParser();
26 |
27 | try {
28 | final pubspecConfig = PubspecConfig();
29 | final credentialsConfig = CredentialsConfig();
30 |
31 | argParser
32 | ..addFlag(
33 | 'help',
34 | abbr: 'h',
35 | help: 'Print this usage information.',
36 | negatable: false,
37 | defaultsTo: false,
38 | )
39 | ..addOption(
40 | 'project-id',
41 | help: 'Localizely project ID.',
42 | callback: ((x) => projectId = x),
43 | defaultsTo: pubspecConfig.localizelyConfig?.projectId,
44 | )
45 | ..addOption(
46 | 'api-token',
47 | help: 'Localizely API token.',
48 | callback: ((x) => apiToken = x),
49 | )
50 | ..addOption(
51 | 'arb-dir',
52 | help: 'Directory of the arb files.',
53 | callback: ((x) => arbDir = x!),
54 | defaultsTo: pubspecConfig.arbDir ?? defaultArbDir,
55 | )
56 | ..addOption(
57 | 'branch',
58 | help:
59 | 'Get it from the “Branches” page on the Localizely platform, in case branching is enabled and you want to use a non-main branch.',
60 | callback: ((x) => branch = x),
61 | defaultsTo: pubspecConfig.localizelyConfig?.branch,
62 | )
63 | ..addOption(
64 | 'download-empty-as',
65 | help:
66 | "Config parameter 'download_empty_as' expects one of the following values: 'empty', 'main' or 'skip'.",
67 | callback: ((x) => downloadEmptyAs = x!),
68 | defaultsTo: pubspecConfig.localizelyConfig?.downloadEmptyAs ??
69 | defaultDownloadEmptyAs,
70 | )
71 | ..addMultiOption(
72 | 'download-include-tags',
73 | help: 'Optional list of tags to be downloaded.',
74 | callback: ((x) => downloadIncludeTags = x),
75 | defaultsTo: pubspecConfig.localizelyConfig?.downloadIncludeTags,
76 | )
77 | ..addMultiOption(
78 | 'download-exclude-tags',
79 | help: 'Optional list of tags to be excluded from download.',
80 | callback: ((x) => downloadExcludeTags = x),
81 | defaultsTo: pubspecConfig.localizelyConfig?.downloadExcludeTags,
82 | );
83 |
84 | final argResults = argParser.parse(arguments);
85 | if (argResults['help'] == true) {
86 | stdout.writeln(argParser.usage);
87 | exit(0);
88 | }
89 |
90 | if (projectId == null) {
91 | throw ConfigException(
92 | "Argument 'project-id' was not provided, nor 'project_id' config was set within the 'flutter_intl/localizely' section of the 'pubspec.yaml' file.");
93 | }
94 |
95 | apiToken ??= credentialsConfig.apiToken;
96 | if (apiToken == null) {
97 | throw ConfigException(
98 | "Argument 'api-token' was not provided, nor 'api_token' config was set within the '${getLocalizelyCredentialsFilePath()}' file.");
99 | }
100 |
101 | if (!isValidDownloadEmptyAsParam(downloadEmptyAs)) {
102 | throw ConfigException(
103 | "Config parameter 'download_empty_as' expects one of the following values: 'empty', 'main' or 'skip'.",
104 | );
105 | }
106 |
107 | await LocalizelyService.download(projectId!, apiToken!, arbDir,
108 | downloadEmptyAs, branch, downloadIncludeTags, downloadExcludeTags);
109 | } on args.ArgParserException catch (e) {
110 | exitWithError('${e.message}\n\n${argParser.usage}');
111 | } on ConfigException catch (e) {
112 | exitWithError(e.message);
113 | } on ServiceException catch (e) {
114 | exitWithError(e.message);
115 | } on ApiException catch (e) {
116 | exitWithError(e.getFormattedMessage());
117 | } catch (e) {
118 | exitWithError('Failed to download ARB files from Localizely.\n$e');
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/bin/localizely_upload_main.dart:
--------------------------------------------------------------------------------
1 | library intl_utils;
2 |
3 | import 'dart:io';
4 |
5 | import 'package:args/args.dart' as args;
6 | import 'package:intl_utils/src/config/config_exception.dart';
7 | import 'package:intl_utils/src/config/credentials_config.dart';
8 | import 'package:intl_utils/src/config/pubspec_config.dart';
9 | import 'package:intl_utils/src/constants/constants.dart';
10 | import 'package:intl_utils/src/localizely/api/api_exception.dart';
11 | import 'package:intl_utils/src/localizely/service/service.dart';
12 | import 'package:intl_utils/src/localizely/service/service_exception.dart';
13 | import 'package:intl_utils/src/utils/file_utils.dart';
14 | import 'package:intl_utils/src/utils/utils.dart';
15 |
16 | Future main(List arguments) async {
17 | late String? projectId;
18 | late String? apiToken;
19 | late String arbDir;
20 | late String mainLocale;
21 | late String? branch;
22 | late bool uploadOverwrite;
23 | late bool uploadAsReviewed;
24 | late List? uploadTagAdded;
25 | late List? uploadTagUpdated;
26 | late List? uploadTagRemoved;
27 |
28 | final argParser = args.ArgParser();
29 |
30 | try {
31 | final pubspecConfig = PubspecConfig();
32 | final credentialsConfig = CredentialsConfig();
33 |
34 | argParser
35 | ..addFlag(
36 | 'help',
37 | abbr: 'h',
38 | help: 'Print this usage information.',
39 | negatable: false,
40 | defaultsTo: false,
41 | )
42 | ..addOption(
43 | 'project-id',
44 | help: 'Localizely project ID.',
45 | callback: ((x) => projectId = x),
46 | defaultsTo: pubspecConfig.localizelyConfig?.projectId,
47 | )
48 | ..addOption(
49 | 'api-token',
50 | help: 'Localizely API token.',
51 | callback: ((x) => apiToken = x),
52 | )
53 | ..addOption(
54 | 'arb-dir',
55 | help: 'Directory of the arb files.',
56 | callback: ((x) => arbDir = x!),
57 | defaultsTo: pubspecConfig.arbDir ?? defaultArbDir,
58 | )
59 | ..addOption(
60 | 'main-locale',
61 | help:
62 | "Optional. Sets the main locale used for generating localization files. Provided value should consist of language code and optional script and country codes separated with underscore (e.g. 'en', 'en_GB', 'zh_Hans', 'zh_Hans_CN')",
63 | callback: ((x) => mainLocale = x!),
64 | defaultsTo: pubspecConfig.mainLocale ?? defaultMainLocale,
65 | )
66 | ..addOption(
67 | 'branch',
68 | help:
69 | 'Get it from the “Branches” page on the Localizely platform, in case branching is enabled and you want to use a non-main branch.',
70 | callback: ((x) => branch = x),
71 | defaultsTo: pubspecConfig.localizelyConfig?.branch,
72 | )
73 | ..addFlag(
74 | 'upload-overwrite',
75 | help: 'Set to true if you want to overwrite translations with upload.',
76 | callback: ((x) => uploadOverwrite = x),
77 | defaultsTo: pubspecConfig.localizelyConfig?.uploadOverwrite ??
78 | defaultUploadOverwrite,
79 | )
80 | ..addFlag(
81 | 'upload-as-reviewed',
82 | help:
83 | 'Set to true if you want to mark uploaded translations as reviewed.',
84 | callback: ((x) => uploadAsReviewed = x),
85 | defaultsTo: pubspecConfig.localizelyConfig?.uploadAsReviewed ??
86 | defaultUploadAsReviewed,
87 | )
88 | ..addMultiOption(
89 | 'upload-tag-added',
90 | help: 'Optional list of tags to add to new translations with upload.',
91 | callback: ((x) => uploadTagAdded = x),
92 | defaultsTo: pubspecConfig.localizelyConfig?.uploadTagAdded,
93 | )
94 | ..addMultiOption(
95 | 'upload-tag-updated',
96 | help:
97 | 'Optional list of tags to add to updated translations with upload.',
98 | callback: ((x) => uploadTagUpdated = x),
99 | defaultsTo: pubspecConfig.localizelyConfig?.uploadTagUpdated,
100 | )
101 | ..addMultiOption(
102 | 'upload-tag-removed',
103 | help:
104 | 'Optional list of tags to add to removed translations with upload.',
105 | callback: ((x) => uploadTagRemoved = x),
106 | defaultsTo: pubspecConfig.localizelyConfig?.uploadTagRemoved,
107 | );
108 |
109 | final argResults = argParser.parse(arguments);
110 | if (argResults['help'] == true) {
111 | stdout.writeln(argParser.usage);
112 | exit(0);
113 | }
114 |
115 | if (projectId == null) {
116 | throw ConfigException(
117 | "Argument 'project-id' was not provided, nor 'project_id' config was set within the 'flutter_intl/localizely' section of the 'pubspec.yaml' file.");
118 | }
119 |
120 | apiToken ??= credentialsConfig.apiToken;
121 | if (apiToken == null) {
122 | throw ConfigException(
123 | "Argument 'api-token' was not provided, nor 'api_token' config was set within the '${getLocalizelyCredentialsFilePath()}' file.");
124 | }
125 |
126 | if (!isValidLocale(mainLocale)) {
127 | throw ConfigException(
128 | "Config parameter 'main_locale' requires value consisted of language code and optional script and country codes separated with underscore (e.g. 'en', 'en_GB', 'zh_Hans', 'zh_Hans_CN').",
129 | );
130 | }
131 |
132 | await LocalizelyService.uploadMainArbFile(
133 | projectId!,
134 | apiToken!,
135 | arbDir,
136 | mainLocale,
137 | branch,
138 | uploadOverwrite,
139 | uploadAsReviewed,
140 | uploadTagAdded,
141 | uploadTagUpdated,
142 | uploadTagRemoved);
143 | } on args.ArgParserException catch (e) {
144 | exitWithError('${e.message}\n\n${argParser.usage}');
145 | } on ConfigException catch (e) {
146 | exitWithError(e.message);
147 | } on ServiceException catch (e) {
148 | exitWithError(e.message);
149 | } on ApiException catch (e) {
150 | exitWithError(e.getFormattedMessage());
151 | } catch (e) {
152 | exitWithError('Failed to upload the main ARB file on Localizely.\n$e');
153 | }
154 | }
155 |
--------------------------------------------------------------------------------
/doc/getting_started.md:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/localizely/intl_utils/36bad3ec481de41a9a2b44e4a2134c82e2226d88/doc/getting_started.md
--------------------------------------------------------------------------------
/example/README.md:
--------------------------------------------------------------------------------
1 | Please see [Usage](https://pub.dev/packages/intl_utils#usage).
2 |
--------------------------------------------------------------------------------
/lib/intl_utils.dart:
--------------------------------------------------------------------------------
1 | library intl_utils;
2 |
3 | export 'src/generator/generator.dart';
4 |
--------------------------------------------------------------------------------
/lib/src/config/config_exception.dart:
--------------------------------------------------------------------------------
1 | class ConfigException implements Exception {
2 | final String message;
3 |
4 | ConfigException(this.message);
5 |
6 | @override
7 | String toString() => 'ConfigException: $message';
8 | }
9 |
--------------------------------------------------------------------------------
/lib/src/config/credentials_config.dart:
--------------------------------------------------------------------------------
1 | import 'package:yaml/yaml.dart' as yaml;
2 |
3 | import './config_exception.dart';
4 | import '../utils/file_utils.dart';
5 |
6 | class CredentialsConfig {
7 | String? _apiToken;
8 |
9 | CredentialsConfig() {
10 | var credentialsFile = getLocalizelyCredentialsFile();
11 | if (credentialsFile == null) {
12 | return;
13 | }
14 |
15 | var credentialsFileContent = credentialsFile.readAsStringSync();
16 | var credentialsYaml = yaml.loadYaml(credentialsFileContent);
17 | if (credentialsYaml is! yaml.YamlMap) {
18 | throw ConfigException(
19 | "Failed to extract 'api_token' from the '${getLocalizelyCredentialsFilePath()}' file.\nExpected YAML map (e.g. api_token: xxxxxx) but got ${credentialsYaml.runtimeType}.");
20 | }
21 |
22 | _apiToken = credentialsYaml['api_token'] is String
23 | ? credentialsYaml['api_token']
24 | : null;
25 | }
26 |
27 | String? get apiToken => _apiToken;
28 | }
29 |
--------------------------------------------------------------------------------
/lib/src/config/pubspec_config.dart:
--------------------------------------------------------------------------------
1 | import 'package:yaml/yaml.dart' as yaml;
2 |
3 | import '../utils/file_utils.dart';
4 | import 'config_exception.dart';
5 |
6 | class PubspecConfig {
7 | bool? _enabled;
8 | String? _className;
9 | String? _mainLocale;
10 | String? _arbDir;
11 | String? _outputDir;
12 | bool? _useDeferredLoading;
13 | LocalizelyConfig? _localizelyConfig;
14 |
15 | PubspecConfig() {
16 | var pubspecFile = getPubspecFile();
17 | if (pubspecFile == null) {
18 | throw ConfigException("Can't find 'pubspec.yaml' file.");
19 | }
20 |
21 | var pubspecFileContent = pubspecFile.readAsStringSync();
22 | var pubspecYaml = yaml.loadYaml(pubspecFileContent);
23 |
24 | if (pubspecYaml is! yaml.YamlMap) {
25 | throw ConfigException(
26 | "Failed to extract config from the 'pubspec.yaml' file.\nExpected YAML map but got ${pubspecYaml.runtimeType}.");
27 | }
28 |
29 | var flutterIntlConfig = pubspecYaml['flutter_intl'];
30 | if (flutterIntlConfig == null) {
31 | return;
32 | }
33 |
34 | _enabled = flutterIntlConfig['enabled'] is bool
35 | ? flutterIntlConfig['enabled']
36 | : null;
37 | _className = flutterIntlConfig['class_name'] is String
38 | ? flutterIntlConfig['class_name']
39 | : null;
40 | _mainLocale = flutterIntlConfig['main_locale'] is String
41 | ? flutterIntlConfig['main_locale']
42 | : null;
43 | _arbDir = flutterIntlConfig['arb_dir'] is String
44 | ? flutterIntlConfig['arb_dir']
45 | : null;
46 | _outputDir = flutterIntlConfig['output_dir'] is String
47 | ? flutterIntlConfig['output_dir']
48 | : null;
49 | _useDeferredLoading = flutterIntlConfig['use_deferred_loading'] is bool
50 | ? flutterIntlConfig['use_deferred_loading']
51 | : null;
52 | _localizelyConfig =
53 | LocalizelyConfig.fromConfig(flutterIntlConfig['localizely']);
54 | }
55 |
56 | bool? get enabled => _enabled;
57 |
58 | String? get className => _className;
59 |
60 | String? get mainLocale => _mainLocale;
61 |
62 | String? get arbDir => _arbDir;
63 |
64 | String? get outputDir => _outputDir;
65 |
66 | bool? get useDeferredLoading => _useDeferredLoading;
67 |
68 | LocalizelyConfig? get localizelyConfig => _localizelyConfig;
69 | }
70 |
71 | class LocalizelyConfig {
72 | String? _projectId;
73 | String? _branch;
74 | bool? _uploadAsReviewed;
75 | bool? _uploadOverwrite;
76 | List? _uploadTagAdded;
77 | List? _uploadTagUpdated;
78 | List? _uploadTagRemoved;
79 | String? _downloadEmptyAs;
80 | List? _downloadIncludeTags;
81 | List? _downloadExcludeTags;
82 | bool? _otaEnabled;
83 |
84 | LocalizelyConfig.fromConfig(yaml.YamlMap? localizelyConfig) {
85 | if (localizelyConfig == null) {
86 | return;
87 | }
88 |
89 | _projectId = localizelyConfig['project_id'] is String
90 | ? localizelyConfig['project_id']
91 | : null;
92 | _branch = localizelyConfig['branch'] is String
93 | ? localizelyConfig['branch']
94 | : null;
95 | _uploadAsReviewed = localizelyConfig['upload_as_reviewed'] is bool
96 | ? localizelyConfig['upload_as_reviewed']
97 | : null;
98 | _uploadOverwrite = localizelyConfig['upload_overwrite'] is bool
99 | ? localizelyConfig['upload_overwrite']
100 | : null;
101 | _uploadTagAdded = localizelyConfig['upload_tag_added'] is yaml.YamlList
102 | ? List.from(localizelyConfig['upload_tag_added'])
103 | : null;
104 | _uploadTagUpdated = localizelyConfig['upload_tag_updated'] is yaml.YamlList
105 | ? List.from(localizelyConfig['upload_tag_updated'])
106 | : null;
107 | _uploadTagRemoved = localizelyConfig['upload_tag_removed'] is yaml.YamlList
108 | ? List.from(localizelyConfig['upload_tag_removed'])
109 | : null;
110 | _downloadEmptyAs = localizelyConfig['download_empty_as'] is String
111 | ? localizelyConfig['download_empty_as']
112 | : null;
113 | _downloadIncludeTags =
114 | localizelyConfig['download_include_tags'] is yaml.YamlList
115 | ? List.from(localizelyConfig['download_include_tags'])
116 | : null;
117 | _downloadExcludeTags =
118 | localizelyConfig['download_exclude_tags'] is yaml.YamlList
119 | ? List.from(localizelyConfig['download_exclude_tags'])
120 | : null;
121 | _otaEnabled = localizelyConfig['ota_enabled'] is bool
122 | ? localizelyConfig['ota_enabled']
123 | : null;
124 | }
125 |
126 | String? get projectId => _projectId;
127 |
128 | String? get branch => _branch;
129 |
130 | bool? get uploadAsReviewed => _uploadAsReviewed;
131 |
132 | bool? get uploadOverwrite => _uploadOverwrite;
133 |
134 | List? get uploadTagAdded => _uploadTagAdded;
135 |
136 | List? get uploadTagUpdated => _uploadTagUpdated;
137 |
138 | List? get uploadTagRemoved => _uploadTagRemoved;
139 |
140 | String? get downloadEmptyAs => _downloadEmptyAs;
141 |
142 | List? get downloadIncludeTags => _downloadIncludeTags;
143 |
144 | List? get downloadExcludeTags => _downloadExcludeTags;
145 |
146 | bool? get otaEnabled => _otaEnabled;
147 | }
148 |
--------------------------------------------------------------------------------
/lib/src/constants/constants.dart:
--------------------------------------------------------------------------------
1 | import 'package:path/path.dart';
2 |
3 | const defaultClassName = 'S';
4 | const defaultMainLocale = 'en';
5 | final defaultArbDir = join('lib', 'l10n');
6 | final defaultOutputDir = join('lib', 'generated');
7 | const defaultUseDeferredLoading = false;
8 | const defaultUploadOverwrite = false;
9 | const defaultUploadAsReviewed = false;
10 | const defaultDownloadEmptyAs = 'empty';
11 | const defaultOtaEnabled = false;
12 |
--------------------------------------------------------------------------------
/lib/src/generator/generator.dart:
--------------------------------------------------------------------------------
1 | import 'dart:convert';
2 |
3 | import '../config/pubspec_config.dart';
4 | import '../constants/constants.dart';
5 | import '../utils/file_utils.dart';
6 | import '../utils/utils.dart';
7 | import 'generator_exception.dart';
8 | import 'intl_translation_helper.dart';
9 | import 'label.dart';
10 | import 'templates.dart';
11 |
12 | /// The generator of localization files.
13 | class Generator {
14 | late String _className;
15 | late String _mainLocale;
16 | late String _arbDir;
17 | late String _outputDir;
18 | late bool _useDeferredLoading;
19 | late bool _otaEnabled;
20 |
21 | /// Creates a new generator with configuration from the 'pubspec.yaml' file.
22 | Generator() {
23 | var pubspecConfig = PubspecConfig();
24 |
25 | _className = defaultClassName;
26 | if (pubspecConfig.className != null) {
27 | if (isValidClassName(pubspecConfig.className!)) {
28 | _className = pubspecConfig.className!;
29 | } else {
30 | warning(
31 | "Config parameter 'class_name' requires valid 'UpperCamelCase' value.");
32 | }
33 | }
34 |
35 | _mainLocale = defaultMainLocale;
36 | if (pubspecConfig.mainLocale != null) {
37 | if (isValidLocale(pubspecConfig.mainLocale!)) {
38 | _mainLocale = pubspecConfig.mainLocale!;
39 | } else {
40 | warning(
41 | "Config parameter 'main_locale' requires value consisted of language code and optional script and country codes separated with underscore (e.g. 'en', 'en_GB', 'zh_Hans', 'zh_Hans_CN').");
42 | }
43 | }
44 |
45 | _arbDir = defaultArbDir;
46 | if (pubspecConfig.arbDir != null) {
47 | if (isValidPath(pubspecConfig.arbDir!)) {
48 | _arbDir = pubspecConfig.arbDir!;
49 | } else {
50 | warning(
51 | "Config parameter 'arb_dir' requires valid path value (e.g. 'lib', 'res/', 'lib\\l10n').");
52 | }
53 | }
54 |
55 | _outputDir = defaultOutputDir;
56 | if (pubspecConfig.outputDir != null) {
57 | if (isValidPath(pubspecConfig.outputDir!)) {
58 | _outputDir = pubspecConfig.outputDir!;
59 | } else {
60 | warning(
61 | "Config parameter 'output_dir' requires valid path value (e.g. 'lib', 'lib\\generated').");
62 | }
63 | }
64 |
65 | _useDeferredLoading =
66 | pubspecConfig.useDeferredLoading ?? defaultUseDeferredLoading;
67 |
68 | _otaEnabled =
69 | pubspecConfig.localizelyConfig?.otaEnabled ?? defaultOtaEnabled;
70 | }
71 |
72 | /// Generates localization files.
73 | Future generateAsync() async {
74 | await _updateL10nDir();
75 | await _updateGeneratedDir();
76 | await _generateDartFiles();
77 | }
78 |
79 | Future _updateL10nDir() async {
80 | var mainArbFile = getArbFileForLocale(_mainLocale, _arbDir);
81 | if (mainArbFile == null) {
82 | await createArbFileForLocale(_mainLocale, _arbDir);
83 | }
84 | }
85 |
86 | Future _updateGeneratedDir() async {
87 | var labels = _getLabelsFromMainArbFile();
88 | var locales = _orderLocales(getLocales(_arbDir));
89 | var content =
90 | generateL10nDartFileContent(_className, labels, locales, _otaEnabled);
91 | var formattedContent = formatDartContent(content, 'l10n.dart');
92 |
93 | await updateL10nDartFile(formattedContent, _outputDir);
94 |
95 | var intlDir = getIntlDirectory(_outputDir);
96 | if (intlDir == null) {
97 | await createIntlDirectory(_outputDir);
98 | }
99 |
100 | await removeUnusedGeneratedDartFiles(locales, _outputDir);
101 | }
102 |
103 | List _getLabelsFromMainArbFile() {
104 | var mainArbFile = getArbFileForLocale(_mainLocale, _arbDir);
105 | if (mainArbFile == null) {
106 | throw GeneratorException(
107 | "Can't find ARB file for the '$_mainLocale' locale.");
108 | }
109 |
110 | var content = mainArbFile.readAsStringSync();
111 | var decodedContent = json.decode(content) as Map;
112 |
113 | var labels =
114 | decodedContent.keys.where((key) => !key.startsWith('@')).map((key) {
115 | var name = key;
116 | var content = decodedContent[key];
117 |
118 | var meta = decodedContent['@$key'] ?? {};
119 | var type = meta['type'];
120 | var description = meta['description'];
121 | var placeholders = meta['placeholders'] != null
122 | ? (meta['placeholders'] as Map)
123 | .keys
124 | .map((placeholder) => Placeholder(
125 | key, placeholder, meta['placeholders'][placeholder]))
126 | .toList()
127 | : null;
128 |
129 | return Label(name, content,
130 | type: type, description: description, placeholders: placeholders);
131 | }).toList();
132 |
133 | return labels;
134 | }
135 |
136 | List _orderLocales(List locales) {
137 | var index = locales.indexOf(_mainLocale);
138 | return index != -1
139 | ? [
140 | locales.elementAt(index),
141 | ...locales.sublist(0, index),
142 | ...locales.sublist(index + 1)
143 | ]
144 | : locales;
145 | }
146 |
147 | Future _generateDartFiles() async {
148 | var outputDir = getIntlDirectoryPath(_outputDir);
149 | var dartFiles = [getL10nDartFilePath(_outputDir)];
150 | var arbFiles = getArbFiles(_arbDir).map((file) => file.path).toList();
151 |
152 | var helper = IntlTranslationHelper(_useDeferredLoading);
153 | helper.generateFromArb(outputDir, dartFiles, arbFiles);
154 | }
155 | }
156 |
--------------------------------------------------------------------------------
/lib/src/generator/generator_exception.dart:
--------------------------------------------------------------------------------
1 | class GeneratorException implements Exception {
2 | final String message;
3 |
4 | GeneratorException(this.message);
5 |
6 | @override
7 | String toString() => 'GeneratorException: $message';
8 | }
9 |
--------------------------------------------------------------------------------
/lib/src/generator/intl_translation_helper.dart:
--------------------------------------------------------------------------------
1 | // This file incorporates work covered by the following copyright and
2 | // permission notice:
3 | //
4 | // Copyright 2013, the Dart project authors. All rights reserved.
5 | // Redistribution and use in source and binary forms, with or without
6 | // modification, are permitted provided that the following conditions are
7 | // met:
8 | //
9 | // * Redistributions of source code must retain the above copyright
10 | // notice, this list of conditions and the following disclaimer.
11 | // * Redistributions in binary form must reproduce the above
12 | // copyright notice, this list of conditions and the following
13 | // disclaimer in the documentation and/or other materials provided
14 | // with the distribution.
15 | // * Neither the name of Google Inc. nor the names of its
16 | // contributors may be used to endorse or promote products derived
17 | // from this software without specific prior written permission.
18 | //
19 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
20 | // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
21 | // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
22 | // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
23 | // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
24 | // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
25 | // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
26 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
27 | // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
28 | // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29 | // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30 |
31 | import 'dart:convert';
32 | import 'dart:io';
33 |
34 | import 'package:path/path.dart' as path;
35 |
36 | // Due to a delay in the maintenance of the 'intl_translation' package,
37 | // we are using a partial copy of it with added support for the null-safety
38 | import '../intl_translation/extract_messages.dart';
39 | import '../intl_translation/generate_localized.dart';
40 | import '../intl_translation/src/icu_parser.dart';
41 | import '../intl_translation/src/intl_message.dart';
42 |
43 | import '../utils/utils.dart';
44 |
45 | class IntlTranslationHelper {
46 | final pluralAndGenderParser = IcuParser().message;
47 | final plainParser = IcuParser().nonIcuMessage;
48 | final JsonCodec jsonDecoder = JsonCodec();
49 |
50 | final MessageExtraction extraction = MessageExtraction();
51 | final MessageGeneration generation = MessageGeneration();
52 | final Map> messages =
53 | {}; // Track of all processed messages, keyed by message name
54 |
55 | IntlTranslationHelper([bool useDeferredLoading = false]) {
56 | extraction.suppressWarnings = true;
57 | generation.useDeferredLoading = useDeferredLoading;
58 | generation.generatedFilePrefix = '';
59 | }
60 |
61 | void generateFromArb(
62 | String outputDir, List dartFiles, List arbFiles) {
63 | var allMessages = dartFiles.map((file) => extraction.parseFile(File(file)));
64 | for (var messageMap in allMessages) {
65 | messageMap.forEach(
66 | (key, value) => messages.putIfAbsent(key, () => []).add(value));
67 | }
68 |
69 | var messagesByLocale = >{};
70 | // Note: To group messages by locale, we eagerly read all data, which might cause a memory issue for large projects
71 | for (var arbFile in arbFiles) {
72 | _loadData(arbFile, messagesByLocale);
73 | }
74 | messagesByLocale.forEach((locale, data) {
75 | _generateLocaleFile(locale, data, outputDir);
76 | });
77 |
78 | var fileName = '${generation.generatedFilePrefix}messages_all.dart';
79 | var mainImportFile = File(path.join(outputDir, fileName));
80 |
81 | var content = generation.generateMainImportFile();
82 | var formattedContent = formatDartContent(content, fileName);
83 |
84 | mainImportFile.writeAsStringSync(formattedContent);
85 | }
86 |
87 | void _loadData(String filename, Map> messagesByLocale) {
88 | var file = File(filename);
89 | var src = file.readAsStringSync();
90 | var data = jsonDecoder.decode(src);
91 | var locale = data['@@locale'] ?? data['_locale'];
92 | if (locale == null) {
93 | // Get the locale from the end of the file name. This assumes that the file
94 | // name doesn't contain any underscores except to begin the language tag
95 | // and to separate language from country. Otherwise we can't tell if
96 | // my_file_fr.arb is locale "fr" or "file_fr".
97 | var name = path.basenameWithoutExtension(file.path);
98 | locale = name.split('_').skip(1).join('_');
99 | info(
100 | "No @@locale or _locale field found in $name, assuming '$locale' based on the file name.");
101 | }
102 | messagesByLocale.putIfAbsent(locale, () => []).add(data);
103 | generation.allLocales.add(locale);
104 | }
105 |
106 | void _generateLocaleFile(
107 | String locale, List localeData, String targetDir) {
108 | var translations = [];
109 | for (var jsonTranslations in localeData) {
110 | jsonTranslations.forEach((id, messageData) {
111 | TranslatedMessage? message = _recreateIntlObjects(id, messageData);
112 | if (message != null) {
113 | translations.add(message);
114 | }
115 | });
116 | }
117 | generation.generateIndividualMessageFile(locale, translations, targetDir);
118 | }
119 |
120 | /// Regenerate the original IntlMessage objects from the given [data]. For
121 | /// things that are messages, we expect [id] not to start with "@" and
122 | /// [data] to be a String. For metadata we expect [id] to start with "@"
123 | /// and [data] to be a Map or null. For metadata we return null.
124 | BasicTranslatedMessage? _recreateIntlObjects(String id, data) {
125 | if (id.startsWith('@')) return null;
126 | if (data == null) return null;
127 | var parsed = pluralAndGenderParser.parse(data).value;
128 | if (parsed is LiteralString && parsed.string.isEmpty) {
129 | parsed = plainParser.parse(data).value;
130 | }
131 | return BasicTranslatedMessage(id, parsed, messages);
132 | }
133 | }
134 |
135 | /// A TranslatedMessage that just uses the name as the id and knows how to look up its original messages in our [messages].
136 | class BasicTranslatedMessage extends TranslatedMessage {
137 | Map> messages;
138 |
139 | BasicTranslatedMessage(super.name, super.translated, this.messages);
140 |
141 | @override
142 | List? get originalMessages => (super.originalMessages == null)
143 | ? _findOriginals()
144 | : super.originalMessages;
145 |
146 | // We know that our [id] is the name of the message, which is used as the key in [messages].
147 | List? _findOriginals() => originalMessages = messages[id];
148 | }
149 |
--------------------------------------------------------------------------------
/lib/src/generator/templates.dart:
--------------------------------------------------------------------------------
1 | import '../utils/utils.dart';
2 | import 'label.dart';
3 |
4 | String generateL10nDartFileContent(
5 | String className, List labels, List locales,
6 | [bool otaEnabled = false]) {
7 | return """
8 | // GENERATED CODE - DO NOT MODIFY BY HAND
9 | import 'package:flutter/material.dart';
10 | import 'package:intl/intl.dart';${otaEnabled ? '\n${_generateLocalizelySdkImport()}' : ''}
11 | import 'intl/messages_all.dart';
12 |
13 | // **************************************************************************
14 | // Generator: Flutter Intl IDE plugin
15 | // Made by Localizely
16 | // **************************************************************************
17 |
18 | // ignore_for_file: non_constant_identifier_names, lines_longer_than_80_chars
19 | // ignore_for_file: join_return_with_assignment, prefer_final_in_for_each
20 | // ignore_for_file: avoid_redundant_argument_values, avoid_escaping_inner_quotes
21 |
22 | class $className {
23 | $className();
24 |
25 | static $className? _current;
26 |
27 | static $className get current {
28 | assert(_current != null, 'No instance of $className was loaded. Try to initialize the $className delegate before accessing $className.current.');
29 | return _current!;
30 | }
31 |
32 | static const AppLocalizationDelegate delegate =
33 | AppLocalizationDelegate();
34 |
35 | static Future<$className> load(Locale locale) {
36 | final name = (locale.countryCode?.isEmpty ?? false) ? locale.languageCode : locale.toString();
37 | final localeName = Intl.canonicalizedLocale(name);${otaEnabled ? '\n${_generateMetadataSetter()}' : ''}
38 | return initializeMessages(localeName).then((_) {
39 | Intl.defaultLocale = localeName;
40 | final instance = $className();
41 | $className._current = instance;
42 |
43 | return instance;
44 | });
45 | }
46 |
47 | static $className of(BuildContext context) {
48 | final instance = $className.maybeOf(context);
49 | assert(instance != null, 'No instance of $className present in the widget tree. Did you add $className.delegate in localizationsDelegates?');
50 | return instance!;
51 | }
52 |
53 | static $className? maybeOf(BuildContext context) {
54 | return Localizations.of<$className>(context, $className);
55 | }
56 | ${otaEnabled ? '\n${_generateMetadata(labels)}\n' : ''}
57 | ${labels.map((label) => label.generateDartGetter()).join("\n\n")}
58 | }
59 |
60 | class AppLocalizationDelegate extends LocalizationsDelegate<$className> {
61 | const AppLocalizationDelegate();
62 |
63 | List get supportedLocales {
64 | return const [
65 | ${locales.map((locale) => _generateLocale(locale)).join("\n")}
66 | ];
67 | }
68 |
69 | @override
70 | bool isSupported(Locale locale) => _isSupported(locale);
71 | @override
72 | Future<$className> load(Locale locale) => $className.load(locale);
73 | @override
74 | bool shouldReload(AppLocalizationDelegate old) => false;
75 |
76 | bool _isSupported(Locale locale) {
77 | for (var supportedLocale in supportedLocales) {
78 | if (supportedLocale.languageCode == locale.languageCode) {
79 | return true;
80 | }
81 | }
82 | return false;
83 | }
84 | }
85 | """
86 | .trim();
87 | }
88 |
89 | String _generateLocale(String locale) {
90 | var parts = locale.split('_');
91 |
92 | if (isLangScriptCountryLocale(locale)) {
93 | return ' Locale.fromSubtags(languageCode: \'${parts[0]}\', scriptCode: \'${parts[1]}\', countryCode: \'${parts[2]}\'),';
94 | } else if (isLangScriptLocale(locale)) {
95 | return ' Locale.fromSubtags(languageCode: \'${parts[0]}\', scriptCode: \'${parts[1]}\'),';
96 | } else if (isLangCountryLocale(locale)) {
97 | return ' Locale.fromSubtags(languageCode: \'${parts[0]}\', countryCode: \'${parts[1]}\'),';
98 | } else {
99 | return ' Locale.fromSubtags(languageCode: \'${parts[0]}\'),';
100 | }
101 | }
102 |
103 | String _generateLocalizelySdkImport() {
104 | return "import 'package:localizely_sdk/localizely_sdk.dart';";
105 | }
106 |
107 | String _generateMetadataSetter() {
108 | return [
109 | ' if (!Localizely.hasMetadata()) {',
110 | ' Localizely.setMetadata(_metadata);',
111 | ' }'
112 | ].join('\n');
113 | }
114 |
115 | String _generateMetadata(List labels) {
116 | return [
117 | ' static final Map> _metadata = {',
118 | labels.map((label) => label.generateMetadata()).join(',\n'),
119 | ' };'
120 | ].join('\n');
121 | }
122 |
--------------------------------------------------------------------------------
/lib/src/intl_translation/extract_messages.dart:
--------------------------------------------------------------------------------
1 | // This file incorporates work covered by the following copyright and
2 | // permission notice:
3 | //
4 | // Copyright 2013, the Dart project authors. All rights reserved.
5 | // Redistribution and use in source and binary forms, with or without
6 | // modification, are permitted provided that the following conditions are
7 | // met:
8 | //
9 | // * Redistributions of source code must retain the above copyright
10 | // notice, this list of conditions and the following disclaimer.
11 | // * Redistributions in binary form must reproduce the above
12 | // copyright notice, this list of conditions and the following
13 | // disclaimer in the documentation and/or other materials provided
14 | // with the distribution.
15 | // * Neither the name of Google Inc. nor the names of its
16 | // contributors may be used to endorse or promote products derived
17 | // from this software without specific prior written permission.
18 | //
19 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
20 | // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
21 | // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
22 | // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
23 | // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
24 | // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
25 | // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
26 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
27 | // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
28 | // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29 | // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30 | //
31 | //
32 | // Due to a delay in the maintenance of the 'intl_translation' package,
33 | // we are using a partial copy of it with added support for the null-safety.
34 |
35 | // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file
36 | // for details. All rights reserved. Use of this source code is governed by a
37 | // BSD-style license that can be found in the LICENSE file.
38 |
39 | /// This is for use in extracting messages from a Dart program
40 | /// using the Intl.message() mechanism and writing them to a file for
41 | /// translation. This provides only the stub of a mechanism, because it
42 | /// doesn't define how the file should be written. It provides an
43 | /// [IntlMessage] class that holds the extracted data and [parseString]
44 | /// and [parseFile] methods which
45 | /// can extract messages that conform to the expected pattern:
46 | /// (parameters) => Intl.message("Message $parameters", desc: ...);
47 | /// It uses the analyzer package to do the parsing, so may
48 | /// break if there are changes to the API that it provides.
49 | /// An example can be found in test/message_extraction/extract_to_json.dart
50 | ///
51 | /// Note that this does not understand how to follow part directives, so it
52 | /// has to explicitly be given all the files that it needs. A typical use case
53 | /// is to run it on all .dart files in a directory.
54 | library extract_messages;
55 |
56 | // ignore_for_file: implementation_imports
57 |
58 | import 'dart:io';
59 |
60 | import 'package:analyzer/dart/analysis/features.dart';
61 | import 'package:analyzer/dart/analysis/utilities.dart';
62 | import 'package:analyzer/dart/ast/ast.dart';
63 | import 'package:analyzer/dart/ast/visitor.dart';
64 | import 'package:analyzer/src/dart/ast/constant_evaluator.dart';
65 |
66 | import './src/intl_message.dart';
67 |
68 | /// A function that takes a message and does something useful with it.
69 | typedef OnMessage = void Function(String message);
70 |
71 | final _featureSet = FeatureSet.latestLanguageVersion();
72 |
73 | /// A particular message extraction run.
74 | ///
75 | /// This encapsulates all the state required for message extraction so that
76 | /// it can be run inside a persistent process.
77 | class MessageExtraction {
78 | /// What to do when a message is encountered, defaults to [print].
79 | OnMessage onMessage = print;
80 |
81 | /// If this is true, the @@last_modified entry is not output.
82 | bool suppressLastModified = false;
83 |
84 | /// If this is true, print warnings for skipped messages. Otherwise, warnings
85 | /// are suppressed.
86 | bool suppressWarnings = false;
87 |
88 | /// If this is true, no translation meta data is written
89 | bool suppressMetaData = false;
90 |
91 | /// If this is true, then treat all warnings as errors.
92 | bool warningsAreErrors = false;
93 |
94 | /// This accumulates a list of all warnings/errors we have found. These are
95 | /// saved as strings right now, so all that can really be done is print and
96 | /// count them.
97 | List warnings = [];
98 |
99 | /// Were there any warnings or errors in extracting messages.
100 | bool get hasWarnings => warnings.isNotEmpty;
101 |
102 | /// Are plural and gender expressions required to be at the top level
103 | /// of an expression, or are they allowed to be embedded in string literals.
104 | ///
105 | /// For example, the following expression
106 | /// 'There are ${Intl.plural(...)} items'.
107 | /// is legal if [allowEmbeddedPluralsAndGenders] is true, but illegal
108 | /// if [allowEmbeddedPluralsAndGenders] is false.
109 | bool allowEmbeddedPluralsAndGenders = true;
110 |
111 | /// Are examples required on all messages.
112 | bool examplesRequired = false;
113 |
114 | bool descriptionRequired = false;
115 |
116 | /// Whether to include source_text in messages
117 | bool includeSourceText = false;
118 |
119 | /// How messages with the same name are resolved.
120 | ///
121 | /// This function is allowed to mutate its arguments.
122 | MainMessage Function(MainMessage, MainMessage)? mergeMessages;
123 |
124 | /// Parse the source of the Dart program file [file] and return a Map from
125 | /// message names to [IntlMessage] instances.
126 | ///
127 | /// If [transformer] is true, assume the transformer will supply any "name"
128 | /// and "args" parameters required in Intl.message calls.
129 | Map parseFile(File file, [bool transformer = false]) {
130 | var contents = file.readAsStringSync();
131 | return parseContent(contents, file.path, transformer);
132 | }
133 |
134 | /// Parse the source of the Dart program from a file with content
135 | /// [fileContent] and path [path] and return a Map from message
136 | /// names to [IntlMessage] instances.
137 | ///
138 | /// If [transformer] is true, assume the transformer will supply any "name"
139 | /// and "args" parameters required in Intl.message calls.
140 | Map parseContent(String fileContent, String filepath,
141 | [bool transformer = false]) {
142 | var contents = fileContent;
143 | origin = filepath;
144 | // Optimization to avoid parsing files we're sure don't contain any messages.
145 | if (contents.contains('Intl.')) {
146 | root = _parseCompilationUnit(contents, origin!);
147 | } else {
148 | return {};
149 | }
150 | var visitor = MessageFindingVisitor(this);
151 | visitor.generateNameAndArgs = transformer;
152 | root!.accept(visitor);
153 | return visitor.messages;
154 | }
155 |
156 | CompilationUnit _parseCompilationUnit(String contents, String origin) {
157 | var result = parseString(
158 | content: contents, featureSet: _featureSet, throwIfDiagnostics: false);
159 |
160 | if (result.errors.isNotEmpty) {
161 | print('Error in parsing $origin, no messages extracted.');
162 | throw ArgumentError('Parsing errors in $origin');
163 | }
164 |
165 | return result.unit;
166 | }
167 |
168 | /// The root of the compilation unit, and the first node we visit. We hold
169 | /// on to this for error reporting, as it can give us line numbers of other
170 | /// nodes.
171 | CompilationUnit? root;
172 |
173 | /// An arbitrary string describing where the source code came from. Most
174 | /// obviously, this could be a file path. We use this when reporting
175 | /// invalid messages.
176 | String? origin;
177 |
178 | String _reportErrorLocation(AstNode node) {
179 | var result = StringBuffer();
180 | if (origin != null) result.write(' from $origin');
181 | var info = root?.lineInfo;
182 | if (info != null) {
183 | var line = info.getLocation(node.offset);
184 | result
185 | .write(' line: ${line.lineNumber}, column: ${line.columnNumber}');
186 | }
187 | return result.toString();
188 | }
189 | }
190 |
191 | /// This visits the program source nodes looking for Intl.message uses
192 | /// that conform to its pattern and then creating the corresponding
193 | /// IntlMessage objects. We have to find both the enclosing function, and
194 | /// the Intl.message invocation.
195 | class MessageFindingVisitor extends GeneralizingAstVisitor {
196 | MessageFindingVisitor(this.extraction);
197 |
198 | /// The message extraction in which we are running.
199 | final MessageExtraction extraction;
200 |
201 | /// Accumulates the messages we have found, keyed by name.
202 | final Map messages = {};
203 |
204 | /// Should we generate the name and arguments from the function definition,
205 | /// meaning we're running in the transformer.
206 | bool generateNameAndArgs = false;
207 |
208 | // We keep track of the data from the last MethodDeclaration,
209 | // FunctionDeclaration or FunctionExpression that we saw on the way down,
210 | // as that will be the nearest parent of the Intl.message invocation.
211 | /// Parameters of the currently visited method.
212 | List? parameters;
213 |
214 | /// Name of the currently visited method.
215 | String? name;
216 |
217 | /// Dartdoc of the currently visited method.
218 | Comment? documentation;
219 |
220 | final List _emptyParameterList = const [];
221 |
222 | /// Return true if [node] matches the pattern we expect for Intl.message()
223 | bool looksLikeIntlMessage(MethodInvocation node) {
224 | const validNames = ['message', 'plural', 'gender', 'select'];
225 | if (!validNames.contains(node.methodName.name)) return false;
226 | final target = node.target;
227 | if (target is SimpleIdentifier) {
228 | return target.token.toString() == 'Intl';
229 | } else if (target is PrefixedIdentifier) {
230 | return target.identifier.token.toString() == 'Intl';
231 | }
232 | return false;
233 | }
234 |
235 | Message? _expectedInstance(String type) {
236 | switch (type) {
237 | case 'message':
238 | return MainMessage();
239 | case 'plural':
240 | return Plural();
241 | case 'gender':
242 | return Gender();
243 | case 'select':
244 | return Select();
245 | default:
246 | return null;
247 | }
248 | }
249 |
250 | /// Returns a String describing why the node is invalid, or null if no
251 | /// reason is found, so it's presumed valid.
252 | String? checkValidity(MethodInvocation node) {
253 | if (parameters == null) {
254 | return 'Calls to Intl must be inside a method, field declaration or '
255 | 'top level declaration.';
256 | }
257 | // The containing function cannot have named parameters.
258 | if (parameters!.any((each) => each.isNamed)) {
259 | return 'Named parameters on message functions are not supported.';
260 | }
261 | var arguments = node.argumentList.arguments;
262 | var instance = _expectedInstance(node.methodName.name);
263 | if (instance == null) {
264 | return "Invalid message type '${node.methodName.name}'.";
265 | }
266 | return instance.checkValidity(node, arguments, name, parameters!,
267 | nameAndArgsGenerated: generateNameAndArgs,
268 | examplesRequired: extraction.examplesRequired);
269 | }
270 |
271 | /// Record the parameters of the function or method declaration we last
272 | /// encountered before seeing the Intl.message call.
273 | @override
274 | void visitMethodDeclaration(MethodDeclaration node) {
275 | name = node.name.lexeme;
276 | parameters = node.parameters?.parameters ?? _emptyParameterList;
277 | documentation = node.documentationComment;
278 | super.visitMethodDeclaration(node);
279 | name = null;
280 | parameters = null;
281 | documentation = null;
282 | }
283 |
284 | /// Record the parameters of the function or method declaration we last
285 | /// encountered before seeing the Intl.message call.
286 | @override
287 | void visitFunctionDeclaration(FunctionDeclaration node) {
288 | name = node.name.lexeme;
289 | parameters =
290 | node.functionExpression.parameters?.parameters ?? _emptyParameterList;
291 | documentation = node.documentationComment;
292 | super.visitFunctionDeclaration(node);
293 | name = null;
294 | parameters = null;
295 | documentation = null;
296 | }
297 |
298 | /// Record the name of field declaration we last
299 | /// encountered before seeing the Intl.message call.
300 | @override
301 | void visitFieldDeclaration(FieldDeclaration node) {
302 | // We don't support names in list declarations,
303 | // e.g. String first, second = Intl.message(...);
304 | if (node.fields.variables.length == 1) {
305 | name = node.fields.variables.first.name.lexeme;
306 | } else {
307 | name = null;
308 | }
309 | parameters = _emptyParameterList;
310 | documentation = node.documentationComment;
311 | super.visitFieldDeclaration(node);
312 | name = null;
313 | parameters = null;
314 | documentation = null;
315 | }
316 |
317 | /// Record the name of the top level variable declaration we last
318 | /// encountered before seeing the Intl.message call.
319 | @override
320 | void visitTopLevelVariableDeclaration(TopLevelVariableDeclaration node) {
321 | // We don't support names in list declarations,
322 | // e.g. String first, second = Intl.message(...);
323 | if (node.variables.variables.length == 1) {
324 | name = node.variables.variables.first.name.lexeme;
325 | } else {
326 | name = null;
327 | }
328 | parameters = _emptyParameterList;
329 | documentation = node.documentationComment;
330 | super.visitTopLevelVariableDeclaration(node);
331 | name = null;
332 | parameters = null;
333 | documentation = null;
334 | }
335 |
336 | /// Examine method invocations to see if they look like calls to Intl.message.
337 | /// If we've found one, stop recursing. This is important because we can have
338 | /// Intl.message(...Intl.plural...) and we don't want to treat the inner
339 | /// plural as if it was an outermost message.
340 | @override
341 | void visitMethodInvocation(MethodInvocation node) {
342 | if (!addIntlMessage(node)) {
343 | super.visitMethodInvocation(node);
344 | }
345 | }
346 |
347 | /// Check that the node looks like an Intl.message invocation, and create
348 | /// the [IntlMessage] object from it and store it in [messages]. Return true
349 | /// if we successfully extracted a message and should stop looking. Return
350 | /// false if we didn't, so should continue recursing.
351 | bool addIntlMessage(MethodInvocation node) {
352 | if (!looksLikeIntlMessage(node)) return false;
353 | var reason = checkValidity(node) ?? _extractMessage(node);
354 |
355 | if (reason != null) {
356 | if (!extraction.suppressWarnings) {
357 | var err = StringBuffer()
358 | ..write('Skipping invalid Intl.message invocation\n <$node>\n')
359 | ..writeAll(
360 | [' reason: $reason\n', extraction._reportErrorLocation(node)]);
361 | var errString = err.toString();
362 | extraction.warnings.add(errString);
363 | extraction.onMessage(errString);
364 | }
365 | }
366 |
367 | // We found a message, valid or not. Stop recursing.
368 | return true;
369 | }
370 |
371 | /// Try to extract a message. On failure, return a String error message.
372 | String? _extractMessage(MethodInvocation node) {
373 | MainMessage? message;
374 | try {
375 | if (node.methodName.name == 'message') {
376 | message = messageFromIntlMessageCall(node);
377 | } else {
378 | message = messageFromDirectPluralOrGenderCall(node);
379 | }
380 | } catch (e, s) {
381 | return 'Unexpected exception: $e, $s';
382 | }
383 | return message == null ? null : _validateMessage(message);
384 | }
385 |
386 | /// Perform any post-construction validations on the message and
387 | /// ensure that it's not a duplicate.
388 | // TODO(alanknight): This is still ugly and may lead to duplicate reporting
389 | // of the same error. Refactor to consistently throw
390 | // IntlMessageExtractionException instead of returning strings and centralize
391 | // the reporting.
392 | String? _validateMessage(MainMessage message) {
393 | try {
394 | message.validate();
395 | if (extraction.descriptionRequired) {
396 | message.validateDescription();
397 | }
398 | } on IntlMessageExtractionException catch (e) {
399 | return e.message;
400 | }
401 | var existing = messages[message.name];
402 | if (existing != null) {
403 | if (!message.skip && extraction.mergeMessages != null) {
404 | messages[message.name] = extraction.mergeMessages!(existing, message);
405 | }
406 | // TODO(alanknight): We may want to require the descriptions to match.
407 | var existingCode =
408 | existing.toOriginalCode(includeDesc: false, includeExamples: false);
409 | var messageCode =
410 | message.toOriginalCode(includeDesc: false, includeExamples: false);
411 | if (existingCode != messageCode) {
412 | return 'WARNING: Duplicate message name:\n'
413 | "'${message.name}' occurs more than once in ${extraction.origin}";
414 | }
415 | } else {
416 | if (!message.skip) {
417 | messages[message.name] = message;
418 | }
419 | }
420 | return null;
421 | }
422 |
423 | /// Create a MainMessage from [node] using the name and
424 | /// parameters of the last function/method declaration we encountered,
425 | /// and the values we get by calling [extract]. We set those values
426 | /// by calling [setAttribute]. This is the common parts between
427 | /// [messageFromIntlMessageCall] and [messageFromDirectPluralOrGenderCall].
428 | MainMessage? _messageFromNode(
429 | MethodInvocation node,
430 | MainMessage? Function(MainMessage message, List arguments)
431 | extract,
432 | void Function(MainMessage message, String fieldName, Object? fieldValue)
433 | setAttribute) {
434 | var message = MainMessage();
435 | message.sourcePosition = node.offset;
436 | message.endPosition = node.end;
437 | message.arguments = parameters
438 | ?.map((x) => x.name?.lexeme)
439 | .where((x) => x != null)
440 | .cast()
441 | .toList();
442 | if (documentation != null) {
443 | message.documentation
444 | .addAll(documentation!.tokens.map((token) => token.toString()));
445 | }
446 | var arguments = node.argumentList.arguments;
447 | var extractionResult = extract(message, arguments);
448 | if (extractionResult == null) return null;
449 |
450 | for (var namedArgument in arguments.whereType()) {
451 | var name = namedArgument.name.label.name;
452 | var exp = namedArgument.expression;
453 | var evaluator = ConstantEvaluator();
454 | var basicValue = exp.accept(evaluator);
455 | var value = basicValue == ConstantEvaluator.NOT_A_CONSTANT
456 | ? exp.toString()
457 | : basicValue;
458 | setAttribute(message, name, value);
459 | }
460 | // We only rewrite messages with parameters, otherwise we use the literal
461 | // string as the name and no arguments are necessary.
462 | if (!message.hasName) {
463 | if (generateNameAndArgs &&
464 | message.arguments != null &&
465 | message.arguments!.isNotEmpty) {
466 | // Always try for class_method if this is a class method and
467 | // generating names/args.
468 | message.name = (Message.classPlusMethodName(node, name) ?? name)!;
469 | } else if (arguments.first is SimpleStringLiteral ||
470 | arguments.first is AdjacentStrings) {
471 | // If there's no name, and the message text is a simple string, compute
472 | // a name based on that plus meaning, if present.
473 | var simpleName = (arguments.first as StringLiteral).stringValue;
474 | message.name =
475 | computeMessageName(message.name, simpleName, message.meaning)!;
476 | }
477 | }
478 | return message;
479 | }
480 |
481 | /// Find the message pieces from a Dart interpolated string.
482 | List _extractFromIntlCallWithInterpolation(
483 | MainMessage message, List arguments) {
484 | var interpolation = InterpolationVisitor(message, extraction);
485 | arguments.first.accept(interpolation);
486 | if (interpolation.pieces.any((x) => x is Plural || x is Gender) &&
487 | !extraction.allowEmbeddedPluralsAndGenders) {
488 | if (interpolation.pieces.any((x) => x is String && x.isNotEmpty)) {
489 | throw IntlMessageExtractionException(
490 | 'Plural and gender expressions must be at the top level, '
491 | 'they cannot be embedded in larger string literals.\n');
492 | }
493 | }
494 | return interpolation.pieces;
495 | }
496 |
497 | /// Create a MainMessage from [node] using the name and
498 | /// parameters of the last function/method declaration we encountered
499 | /// and the parameters to the Intl.message call.
500 | MainMessage? messageFromIntlMessageCall(MethodInvocation node) {
501 | MainMessage? extractFromIntlCall(
502 | MainMessage? message, List arguments) {
503 | try {
504 | // The pieces of the message, either literal strings, or integers
505 | // representing the index of the argument to be substituted.
506 | List extracted;
507 | extracted = _extractFromIntlCallWithInterpolation(message!, arguments);
508 | message.addPieces(List.from(extracted));
509 | } on IntlMessageExtractionException catch (e) {
510 | message = null;
511 | var err = StringBuffer()
512 | ..writeAll(['Error ', e, '\nProcessing <', node, '>\n'])
513 | ..write(extraction._reportErrorLocation(node));
514 | var errString = err.toString();
515 | extraction.onMessage(errString);
516 | extraction.warnings.add(errString);
517 | }
518 | return message;
519 | }
520 |
521 | void setValue(MainMessage message, String fieldName, Object? fieldValue) {
522 | message[fieldName] = fieldValue;
523 | }
524 |
525 | return _messageFromNode(node, extractFromIntlCall, setValue);
526 | }
527 |
528 | /// Create a MainMessage from [node] using the name and
529 | /// parameters of the last function/method declaration we encountered
530 | /// and the parameters to the Intl.plural or Intl.gender call.
531 | MainMessage? messageFromDirectPluralOrGenderCall(MethodInvocation node) {
532 | MainMessage extractFromPluralOrGender(MainMessage message, _) {
533 | var visitor =
534 | PluralAndGenderVisitor(message.messagePieces, message, extraction);
535 | node.accept(visitor);
536 | return message;
537 | }
538 |
539 | void setAttribute(MainMessage msg, String fieldName, Object? fieldValue) {
540 | if (msg.attributeNames.contains(fieldName)) {
541 | msg[fieldName] = fieldValue;
542 | }
543 | }
544 |
545 | return _messageFromNode(node, extractFromPluralOrGender, setAttribute);
546 | }
547 | }
548 |
549 | /// Given an interpolation, find all of its chunks, validate that they are only
550 | /// simple variable substitutions or else Intl.plural/gender calls,
551 | /// and keep track of the pieces of text so that other parts
552 | /// of the program can deal with the simple string sections and the generated
553 | /// parts separately. Note that this is a SimpleAstVisitor, so it only
554 | /// traverses one level of children rather than automatically recursing. If we
555 | /// find a plural or gender, which requires recursion, we do it with a separate
556 | /// special-purpose visitor.
557 | class InterpolationVisitor extends SimpleAstVisitor {
558 | final Message message;
559 |
560 | /// The message extraction in which we are running.
561 | final MessageExtraction extraction;
562 |
563 | InterpolationVisitor(this.message, this.extraction);
564 |
565 | List pieces = [];
566 | String get extractedMessage => pieces.join();
567 |
568 | @override
569 | void visitAdjacentStrings(AdjacentStrings node) {
570 | node.visitChildren(this);
571 | super.visitAdjacentStrings(node);
572 | }
573 |
574 | @override
575 | void visitStringInterpolation(StringInterpolation node) {
576 | node.visitChildren(this);
577 | super.visitStringInterpolation(node);
578 | }
579 |
580 | @override
581 | void visitSimpleStringLiteral(SimpleStringLiteral node) {
582 | pieces.add(node.value);
583 | super.visitSimpleStringLiteral(node);
584 | }
585 |
586 | @override
587 | void visitInterpolationString(InterpolationString node) {
588 | pieces.add(node.value);
589 | super.visitInterpolationString(node);
590 | }
591 |
592 | @override
593 | void visitInterpolationExpression(InterpolationExpression node) {
594 | if (node.expression is SimpleIdentifier) {
595 | handleSimpleInterpolation(node);
596 | } else {
597 | lookForPluralOrGender(node);
598 | }
599 | // Note that we never end up calling super.
600 | }
601 |
602 | void lookForPluralOrGender(InterpolationExpression node) {
603 | var visitor =
604 | PluralAndGenderVisitor(pieces, message as ComplexMessage, extraction);
605 | node.accept(visitor);
606 | if (!visitor.foundPluralOrGender) {
607 | throw IntlMessageExtractionException(
608 | 'Only simple identifiers and Intl.plural/gender/select expressions '
609 | 'are allowed in message '
610 | 'interpolation expressions.\nError at $node');
611 | }
612 | }
613 |
614 | void handleSimpleInterpolation(InterpolationExpression node) {
615 | // Method parameters can be formatted before passing to the 'args' argument.
616 | // Thus, args argument should have the same name as the method parameter or with the suffix 'String'.
617 | var regularIndex = arguments.indexOf(node.expression.toString());
618 | var formattedIndex = arguments
619 | .indexWhere((arg) => '${arg}String' == node.expression.toString());
620 |
621 | var index = regularIndex != -1 ? regularIndex : formattedIndex;
622 |
623 | if (index == -1) {
624 | throw IntlMessageExtractionException(
625 | 'Cannot find argument ${node.expression}');
626 | }
627 | pieces.add(index);
628 | }
629 |
630 | List get arguments => message.arguments;
631 | }
632 |
633 | /// A visitor to extract information from Intl.plural/gender sends. Note that
634 | /// this is a SimpleAstVisitor, so it doesn't automatically recurse. So this
635 | /// needs to be called where we expect a plural or gender immediately below.
636 | class PluralAndGenderVisitor extends SimpleAstVisitor {
637 | /// The message extraction in which we are running.
638 | final MessageExtraction extraction;
639 |
640 | /// A plural or gender always exists in the context of a parent message,
641 | /// which could in turn also be a plural or gender.
642 | final ComplexMessage parent;
643 |
644 | /// The pieces of the message. We are given an initial version of this
645 | /// from our parent and we add to it as we find additional information.
646 | List pieces;
647 |
648 | /// This will be set to true if we find a plural or gender.
649 | bool foundPluralOrGender = false;
650 |
651 | PluralAndGenderVisitor(this.pieces, this.parent, this.extraction) : super();
652 |
653 | @override
654 | void visitInterpolationExpression(InterpolationExpression node) {
655 | // TODO(alanknight): Provide better errors for malformed expressions.
656 | if (!looksLikePluralOrGender(node.expression)) return;
657 | var reason = checkValidity(node.expression as MethodInvocation);
658 | if (reason != null) throw reason;
659 | var message =
660 | messageFromMethodInvocation(node.expression as MethodInvocation);
661 | foundPluralOrGender = true;
662 | pieces.add(message);
663 | super.visitInterpolationExpression(node);
664 | }
665 |
666 | @override
667 | visitMethodInvocation(MethodInvocation node) {
668 | pieces.add(messageFromMethodInvocation(node));
669 | super.visitMethodInvocation(node);
670 | }
671 |
672 | /// Return true if [node] matches the pattern for plural or gender message.
673 | bool looksLikePluralOrGender(Expression expression) {
674 | if (expression is! MethodInvocation) return false;
675 | final node = expression;
676 | if (!['plural', 'gender', 'select'].contains(node.methodName.name)) {
677 | return false;
678 | }
679 | if (node.target is! SimpleIdentifier) return false;
680 | var target = node.target as SimpleIdentifier;
681 | return target.token.toString() == 'Intl';
682 | }
683 |
684 | /// Returns a String describing why the node is invalid, or null if no
685 | /// reason is found, so it's presumed valid.
686 | String? checkValidity(MethodInvocation node) {
687 | // TODO(alanknight): Add reasonable validity checks.
688 | return null;
689 | }
690 |
691 | /// Create a MainMessage from [node] using the name and
692 | /// parameters of the last function/method declaration we encountered
693 | /// and the parameters to the Intl.message call.
694 | Message messageFromMethodInvocation(MethodInvocation node) {
695 | var message; // ignore: prefer_typing_uninitialized_variables
696 | switch (node.methodName.name) {
697 | case 'gender':
698 | message = Gender();
699 | break;
700 | case 'plural':
701 | message = Plural();
702 | break;
703 | case 'select':
704 | message = Select();
705 | break;
706 | default:
707 | throw IntlMessageExtractionException(
708 | 'Invalid plural/gender/select message ${node.methodName.name} '
709 | 'in $node');
710 | }
711 | message.parent = parent;
712 |
713 | var arguments = message.argumentsOfInterestFor(node);
714 | arguments.forEach((key, value) {
715 | try {
716 | var interpolation = InterpolationVisitor(message, extraction);
717 | value.accept(interpolation);
718 | // Might be null due to previous errors.
719 | // Continue collecting errors, but don't build message.
720 | if (message != null) {
721 | message[key] = interpolation.pieces;
722 | }
723 | } on IntlMessageExtractionException catch (e) {
724 | message = null;
725 | var err = StringBuffer()
726 | ..writeAll(['Error ', e, '\nProcessing <', node, '>'])
727 | ..write(extraction._reportErrorLocation(node));
728 | var errString = err.toString();
729 | extraction.onMessage(errString);
730 | extraction.warnings.add(errString);
731 | }
732 | });
733 | var mainArg = node.argumentList.arguments
734 | .firstWhere((each) => each is! NamedExpression);
735 | if (mainArg is SimpleStringLiteral) {
736 | message.mainArgument = mainArg.toString();
737 | } else if (mainArg is SimpleIdentifier) {
738 | message.mainArgument = mainArg.name;
739 | } else {
740 | var err = StringBuffer()
741 | ..write('Error (Invalid argument to plural/gender/select, '
742 | 'must be simple variable reference) '
743 | '\nProcessing <$node>')
744 | ..write(extraction._reportErrorLocation(node));
745 | var errString = err.toString();
746 | extraction.onMessage(errString);
747 | extraction.warnings.add(errString);
748 | }
749 | return message;
750 | }
751 | }
752 |
753 | /// If a message is a string literal without interpolation, compute
754 | /// a name based on that and the meaning, if present.
755 | // NOTE: THIS LOGIC IS DUPLICATED IN intl AND THE TWO MUST MATCH.
756 | String? computeMessageName(String? name, String? text, String? meaning) {
757 | if (name != null && name != '') return name;
758 | return meaning == null ? text : '${text}_$meaning';
759 | }
760 |
--------------------------------------------------------------------------------
/lib/src/intl_translation/generate_localized.dart:
--------------------------------------------------------------------------------
1 | // This file incorporates work covered by the following copyright and
2 | // permission notice:
3 | //
4 | // Copyright 2013, the Dart project authors. All rights reserved.
5 | // Redistribution and use in source and binary forms, with or without
6 | // modification, are permitted provided that the following conditions are
7 | // met:
8 | //
9 | // * Redistributions of source code must retain the above copyright
10 | // notice, this list of conditions and the following disclaimer.
11 | // * Redistributions in binary form must reproduce the above
12 | // copyright notice, this list of conditions and the following
13 | // disclaimer in the documentation and/or other materials provided
14 | // with the distribution.
15 | // * Neither the name of Google Inc. nor the names of its
16 | // contributors may be used to endorse or promote products derived
17 | // from this software without specific prior written permission.
18 | //
19 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
20 | // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
21 | // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
22 | // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
23 | // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
24 | // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
25 | // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
26 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
27 | // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
28 | // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29 | // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30 | //
31 | //
32 | // Due to a delay in the maintenance of the 'intl_translation' package,
33 | // we are using a partial copy of it with added support for the null-safety.
34 |
35 | // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file
36 | // for details. All rights reserved. Use of this source code is governed by a
37 | // BSD-style license that can be found in the LICENSE file.
38 |
39 | /// This provides utilities for generating localized versions of
40 | /// messages. It does not stand alone, but expects to be given
41 | /// TranslatedMessage objects and generate code for a particular locale
42 | /// based on them.
43 | ///
44 | /// An example of usage can be found
45 | /// in test/message_extract/generate_from_json.dart
46 | library generate_localized;
47 |
48 | import 'package:intl/intl.dart';
49 | import 'dart:convert';
50 | import 'dart:io';
51 | import 'package:path/path.dart' as path;
52 |
53 | import './src/intl_message.dart';
54 | import '../utils/utils.dart';
55 |
56 | class MessageGeneration {
57 | /// If the import path following package: is something else, modify the
58 | /// [intlImportPath] variable to change the import directives in the generated
59 | /// code.
60 | var intlImportPath = 'intl';
61 |
62 | /// If the path to the generated files is something other than the current
63 | /// directory, update the [generatedImportPath] variable to change the import
64 | /// directives in the generated code.
65 | var generatedImportPath = '';
66 |
67 | /// Given a base file, return the file prefixed with the path to import it.
68 | /// By default, that is in the current directory, but if [generatedImportPath]
69 | /// has been set, then use that as a prefix.
70 | String importForGeneratedFile(String file) =>
71 | generatedImportPath.isEmpty ? file : '$generatedImportPath/$file';
72 |
73 | /// A list of all the locales for which we have translations. Code that does
74 | /// the reading of translations should add to this.
75 | Set allLocales = {};
76 |
77 | /// If we have more than one set of messages to generate in a particular
78 | /// directory we may want to prefix some to distinguish them.
79 | String generatedFilePrefix = '';
80 |
81 | /// Should we use deferred loading for the generated libraries.
82 | bool useDeferredLoading = true;
83 |
84 | /// The mode to generate in - either 'release' or 'debug'.
85 | ///
86 | /// In release mode, a missing translation is an error. In debug mode, it
87 | /// falls back to the original string.
88 | String? codegenMode;
89 |
90 | /// What is the path to the package for which we are generating.
91 | ///
92 | /// The exact format of this string depends on the generation mechanism,
93 | /// so it's left undefined.
94 | String? package;
95 |
96 | bool get releaseMode => codegenMode == 'release';
97 |
98 | bool get jsonMode => false;
99 |
100 | /// Holds the generated translations.
101 | StringBuffer output = StringBuffer();
102 |
103 | void clearOutput() {
104 | output = StringBuffer();
105 | }
106 |
107 | /// Generate a file <[generated_file_prefix]>_messages_<[locale]>.dart
108 | /// for the [translations] in [locale] and put it in [targetDir].
109 | void generateIndividualMessageFile(String basicLocale,
110 | Iterable translations, String targetDir) {
111 | final fileName = '${generatedFilePrefix}messages_$basicLocale.dart';
112 | final content = contentForLocale(basicLocale, translations);
113 | final formattedContent = formatDartContent(content, fileName);
114 |
115 | // To preserve compatibility, we don't use the canonical version of the
116 | // locale in the file name.
117 | final filePath = path.join(targetDir, fileName);
118 | File(filePath).writeAsStringSync(formattedContent);
119 | }
120 |
121 | /// Generate a string that contains the dart code
122 | /// with the [translations] in [locale].
123 | String contentForLocale(
124 | String basicLocale, Iterable translations) {
125 | clearOutput();
126 | var locale = MainMessage()
127 | .escapeAndValidateString(Intl.canonicalizedLocale(basicLocale));
128 | output.write(prologue(locale));
129 | // Exclude messages with no translation and translations with no matching
130 | // original message (e.g. if we're using some messages from a larger
131 | // catalog)
132 | var usableTranslations =
133 | translations.where((each) => each.originalMessages != null).toList();
134 | for (var each in usableTranslations) {
135 | for (var original in each.originalMessages!) {
136 | original.addTranslation(locale, each.message);
137 | }
138 | }
139 | usableTranslations.sort((a, b) => a.originalMessages!.first.name
140 | .compareTo(b.originalMessages!.first.name));
141 |
142 | writeTranslations(usableTranslations, locale);
143 |
144 | return '$output';
145 | }
146 |
147 | /// Write out the translated forms.
148 | void writeTranslations(
149 | Iterable usableTranslations, String locale) {
150 | for (var translation in usableTranslations) {
151 | // Some messages we generate as methods in this class. Simpler ones
152 | // we inline in the map from names to messages.
153 | var messagesThatNeedMethods =
154 | translation.originalMessages!.where(_hasArguments).toSet().toList();
155 | for (var original in messagesThatNeedMethods) {
156 | output
157 | ..write(' ')
158 | ..write(
159 | original.toCodeForLocale(locale, _methodNameFor(original.name)))
160 | ..write('\n\n');
161 | }
162 | }
163 | output.write(messagesDeclaration);
164 |
165 | // Now write the map of names to either the direct translation or to a
166 | // method.
167 | var entries = (usableTranslations
168 | .expand((translation) => translation.originalMessages!)
169 | .toSet()
170 | .toList()
171 | ..sort((a, b) => a.name.compareTo(b.name)))
172 | .map((original) =>
173 | ' "${original.escapeAndValidateString(original.name)}" '
174 | ': ${_mapReference(original, locale)}');
175 | output
176 | ..write(entries.join(',\n'))
177 | ..write('\n };\n}\n');
178 | }
179 |
180 | /// Any additional imports the individual message files need.
181 | String get extraImports => '';
182 |
183 | String get messagesDeclaration =>
184 | // Includes some gyrations to prevent parts of the deferred libraries from
185 | // being inlined into the main one, defeating the space savings. Issue
186 | // 24356
187 | '''
188 | final messages = _notInlinedMessages(_notInlinedMessages);
189 | static Map _notInlinedMessages(_) => {
190 | ''';
191 |
192 | /// [generateIndividualMessageFile] for the beginning of the file,
193 | /// parameterized by [locale].
194 | String prologue(String locale) => """
195 | // DO NOT EDIT. This is code generated via package:intl/generate_localized.dart
196 | // This is a library that provides messages for a $locale locale. All the
197 | // messages from the main program should be duplicated here with the same
198 | // function name.
199 |
200 | // Ignore issues from commonly used lints in this file.
201 | // ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new
202 | // ignore_for_file:prefer_single_quotes,comment_references, directives_ordering
203 | // ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases
204 | // ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes
205 | // ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes
206 |
207 | import 'package:$intlImportPath/intl.dart';
208 | import 'package:$intlImportPath/message_lookup_by_library.dart';
209 | $extraImports
210 | final messages = new MessageLookup();
211 |
212 | typedef String MessageIfAbsent(String messageStr, List args);
213 |
214 | class MessageLookup extends MessageLookupByLibrary {
215 | String get localeName => '$locale';
216 |
217 | ${releaseMode ? overrideLookup : ''}""";
218 |
219 | String overrideLookup = """
220 | String lookupMessage(
221 | String message_str,
222 | String locale,
223 | String name,
224 | List args,
225 | String meaning,
226 | {MessageIfAbsent ifAbsent}) {
227 | String failedLookup(String message_str, List args) {
228 | // If there's no message_str, then we are an internal lookup, e.g. an
229 | // embedded plural, and shouldn't fail.
230 | if (message_str == null) return null;
231 | throw new UnsupportedError(
232 | "No translation found for message '\$name',\\n"
233 | " original text '\$message_str'");
234 | }
235 | return super.lookupMessage(message_str, locale, name, args, meaning,
236 | ifAbsent: ifAbsent ?? failedLookup);
237 | }
238 |
239 | """;
240 |
241 | /// This section generates the messages_all.dart file based on the list of
242 | /// [allLocales].
243 | String generateMainImportFile() {
244 | clearOutput();
245 | output.write(mainPrologue);
246 | for (var locale in allLocales) {
247 | var baseFile = '${generatedFilePrefix}messages_$locale.dart';
248 | var file = importForGeneratedFile(baseFile);
249 | output.write("import '$file' ");
250 | if (useDeferredLoading) output.write('deferred ');
251 | output.write('as ${libraryName(locale)};\n');
252 | }
253 | output.write('\n');
254 | output.write('typedef Future LibraryLoader();\n');
255 | output.write('Map _deferredLibraries = {\n');
256 | for (var rawLocale in allLocales) {
257 | var locale = Intl.canonicalizedLocale(rawLocale);
258 | var loadOperation = (useDeferredLoading)
259 | ? " '$locale': ${libraryName(locale)}.loadLibrary,\n"
260 | : " '$locale': () => new SynchronousFuture(null),\n";
261 | output.write(loadOperation);
262 | }
263 | output.write('};\n');
264 | output.write('\nMessageLookupByLibrary? _findExact(String localeName) {\n'
265 | ' switch (localeName) {\n');
266 | for (var rawLocale in allLocales) {
267 | var locale = Intl.canonicalizedLocale(rawLocale);
268 | output.write(
269 | " case '$locale':\n return ${libraryName(locale)}.messages;\n");
270 | }
271 | output.write(closing);
272 | return output.toString();
273 | }
274 |
275 | /// Constant string used in [generateMainImportFile] for the beginning of the
276 | /// file.
277 | String get mainPrologue => """
278 | // DO NOT EDIT. This is code generated via package:intl/generate_localized.dart
279 | // This is a library that looks up messages for specific locales by
280 | // delegating to the appropriate library.
281 |
282 | // Ignore issues from commonly used lints in this file.
283 | // ignore_for_file:implementation_imports, file_names, unnecessary_new
284 | // ignore_for_file:unnecessary_brace_in_string_interps, directives_ordering
285 | // ignore_for_file:argument_type_not_assignable, invalid_assignment
286 | // ignore_for_file:prefer_single_quotes, prefer_generic_function_type_aliases
287 | // ignore_for_file:comment_references
288 |
289 | import 'dart:async';
290 | ${useDeferredLoading ? '' : "\nimport 'package:flutter/foundation.dart';"}
291 | import 'package:$intlImportPath/intl.dart';
292 | import 'package:$intlImportPath/message_lookup_by_library.dart';
293 | import 'package:$intlImportPath/src/intl_helpers.dart';
294 |
295 | """;
296 |
297 | /// Constant string used in [generateMainImportFile] as the end of the file.
298 | String get closing => '''
299 | default:\n return null;
300 | }
301 | }
302 |
303 | /// User programs should call this before using [localeName] for messages.
304 | Future initializeMessages(String localeName) ${useDeferredLoading ? 'async ' : ''}{
305 | var availableLocale = Intl.verifiedLocale(
306 | localeName,
307 | (locale) => _deferredLibraries[locale] != null,
308 | onFailure: (_) => null);
309 | if (availableLocale == null) {
310 | return ${useDeferredLoading ? 'new Future.value(false)' : 'new SynchronousFuture(false)'};
311 | }
312 | var lib = _deferredLibraries[availableLocale];
313 | ${useDeferredLoading ? 'await (lib == null ? new Future.value(false) : lib());' : 'lib == null ? new SynchronousFuture(false) : lib();'}
314 | initializeInternalMessageLookup(() => new CompositeMessageLookup());
315 | messageLookup.addLocale(availableLocale, _findGeneratedMessagesFor);
316 | return ${useDeferredLoading ? 'new Future.value(true)' : 'new SynchronousFuture(true)'};
317 | }
318 |
319 | bool _messagesExistFor(String locale) {
320 | try {
321 | return _findExact(locale) != null;
322 | } catch (e) {
323 | return false;
324 | }
325 | }
326 |
327 | MessageLookupByLibrary? _findGeneratedMessagesFor(String locale) {
328 | var actualLocale = Intl.verifiedLocale(locale, _messagesExistFor,
329 | onFailure: (_) => null);
330 | if (actualLocale == null) return null;
331 | return _findExact(actualLocale);
332 | }
333 | ''';
334 | }
335 |
336 | class JsonMessageGeneration extends MessageGeneration {
337 | /// We import the main file so as to get the shared code to evaluate
338 | /// the JSON data.
339 | @override
340 | String get extraImports => '''
341 | import 'dart:convert';
342 | import '${generatedFilePrefix}messages_all.dart' show evaluateJsonTemplate;
343 | ''';
344 |
345 | @override
346 | String prologue(locale) =>
347 | '''${super.prologue(locale)} String evaluateMessage(translation, List args) {
348 | return evaluateJsonTemplate(translation, args);
349 | }
350 | ''';
351 |
352 | /// Embed the JSON string in a Dart raw string literal.
353 | ///
354 | /// In simple cases this just wraps it in a Dart raw triple-quoted
355 | /// literal. However, a translated message may contain a triple quote,
356 | /// which would end the Dart literal. So when we encounter this, we turn
357 | /// it into three adjacent strings, one of which is just the
358 | /// triple-quote.
359 | String _embedInLiteral(String jsonMessages) {
360 | var triple = "'''";
361 | var result = jsonMessages;
362 | if (jsonMessages.contains(triple)) {
363 | var doubleQuote = '"';
364 | var asAdjacentStrings =
365 | '$triple r$doubleQuote$triple$doubleQuote r$triple';
366 | result = jsonMessages.replaceAll(triple, asAdjacentStrings);
367 | }
368 | return "r'''\n$result''';\n}";
369 | }
370 |
371 | @override
372 | void writeTranslations(
373 | Iterable usableTranslations, String locale) {
374 | output.write(r'''
375 | Map _messages;
376 | Map get messages => _messages ??=
377 | const JsonDecoder().convert(messageText) as Map;
378 | ''');
379 |
380 | output.write(' static final messageText = ');
381 | var entries = usableTranslations
382 | .expand((translation) => translation.originalMessages!);
383 | var map = {};
384 | for (var original in entries) {
385 | map[original.name] = original.toJsonForLocale(locale);
386 | }
387 | var jsonEncoded = JsonEncoder().convert(map);
388 | output.write(_embedInLiteral(jsonEncoded));
389 | }
390 |
391 | @override
392 | String get closing => '''${super.closing}
393 | /// Turn the JSON template into a string.
394 | ///
395 | /// We expect one of the following forms for the template.
396 | /// * null -> null
397 | /// * String s -> s
398 | /// * int n -> '\${args[n]}'
399 | /// * List list, one of
400 | /// * ['Intl.plural', int howMany, (templates for zero, one, ...)]
401 | /// * ['Intl.gender', String gender, (templates for female, male, other)]
402 | /// * ['Intl.select', String choice, { 'case' : template, ...}]
403 | /// * ['text alternating with ', 0 , ' indexes in the argument list']
404 | String evaluateJsonTemplate(dynamic input, List args) {
405 | if (input == null) return null;
406 | if (input is String) return input;
407 | if (input is int) {
408 | return "\${args[input]}";
409 | }
410 |
411 | var template = input as List;
412 | var messageName = template.first;
413 | if (messageName == "Intl.plural") {
414 | var howMany = args[template[1] as int] as num;
415 | return evaluateJsonTemplate(
416 | Intl.pluralLogic(
417 | howMany,
418 | zero: template[2],
419 | one: template[3],
420 | two: template[4],
421 | few: template[5],
422 | many: template[6],
423 | other: template[7]),
424 | args);
425 | }
426 | if (messageName == "Intl.gender") {
427 | var gender = args[template[1] as int] as String;
428 | return evaluateJsonTemplate(
429 | Intl.genderLogic(
430 | gender,
431 | female: template[2],
432 | male: template[3],
433 | other: template[4]),
434 | args);
435 | }
436 | if (messageName == "Intl.select") {
437 | var select = args[template[1] as int];
438 | var choices = template[2] as Map;
439 | return evaluateJsonTemplate(Intl.selectLogic(select, choices), args);
440 | }
441 |
442 | // If we get this far, then we are a basic interpolation, just strings and
443 | // ints.
444 | var output = new StringBuffer();
445 | for (var entry in template) {
446 | if (entry is int) {
447 | output.write("\${args[entry]}");
448 | } else {
449 | output.write("\$entry");
450 | }
451 | }
452 | return output.toString();
453 | }
454 |
455 | ''';
456 | }
457 |
458 | /// This represents a message and its translation. We assume that the
459 | /// translation has some identifier that allows us to figure out the original
460 | /// message it corresponds to, and that it may want to transform the translated
461 | /// text in some way, e.g. to turn whatever format the translation uses for
462 | /// variables into a Dart string interpolation. Specific translation mechanisms
463 | /// are expected to subclass this.
464 | abstract class TranslatedMessage {
465 | /// The identifier for this message. In the simplest case, this is the name
466 | /// parameter from the Intl.message call,
467 | /// but it can be any identifier that this program and the output of the
468 | /// translation can agree on as identifying a message.
469 | final String id;
470 |
471 | /// Our translated version of all the [originalMessages].
472 | final Message translated;
473 |
474 | /// The original messages that we are a translation of. There can
475 | /// be more than one original message for the same translation.
476 | List? originalMessages;
477 |
478 | /// For backward compatibility, we still have the originalMessage API.
479 | MainMessage? get originalMessage => originalMessages?.first;
480 | set originalMessage(MainMessage? m) {
481 | if (m != null) {
482 | originalMessages = [m];
483 | }
484 | }
485 |
486 | TranslatedMessage(this.id, this.translated);
487 |
488 | Message get message => translated;
489 |
490 | @override
491 | String toString() => id.toString();
492 |
493 | @override
494 | bool operator ==(Object other) =>
495 | other is TranslatedMessage && other.id == id;
496 |
497 | @override
498 | int get hashCode => id.hashCode;
499 | }
500 |
501 | /// We can't use a hyphen in a Dart library name, so convert the locale
502 | /// separator to an underscore.
503 | String libraryName(String x) =>
504 | 'messages_${x.replaceAll('-', '_').toLowerCase()}';
505 |
506 | bool _hasArguments(MainMessage message) =>
507 | message.arguments != null && message.arguments!.isNotEmpty;
508 |
509 | /// Simple messages are printed directly in the map of message names to
510 | /// functions as a call that returns a lambda. e.g.
511 | ///
512 | /// "foo" : simpleMessage("This is foo"),
513 | ///
514 | /// This is helpful for the compiler.
515 | /// */
516 | String _mapReference(MainMessage original, String locale) {
517 | if (!_hasArguments(original)) {
518 | // No parameters, can be printed simply.
519 | return 'MessageLookupByLibrary.simpleMessage("'
520 | '${original.translations[locale]}")';
521 | } else {
522 | return _methodNameFor(original.name);
523 | }
524 | }
525 |
526 | /// Generated method counter for use in [_methodNameFor].
527 | int _methodNameCounter = 0;
528 |
529 | /// A map from Intl message names to the generated method names
530 | /// for their translated versions.
531 | Map _internalMethodNames = {};
532 |
533 | /// Generate a Dart method name of the form "m<number>".
534 | String _methodNameFor(String name) {
535 | return _internalMethodNames.putIfAbsent(
536 | name, () => 'm${_methodNameCounter++}');
537 | }
538 |
--------------------------------------------------------------------------------
/lib/src/intl_translation/src/icu_parser.dart:
--------------------------------------------------------------------------------
1 | // This file incorporates work covered by the following copyright and
2 | // permission notice:
3 | //
4 | // Copyright 2013, the Dart project authors. All rights reserved.
5 | // Redistribution and use in source and binary forms, with or without
6 | // modification, are permitted provided that the following conditions are
7 | // met:
8 | //
9 | // * Redistributions of source code must retain the above copyright
10 | // notice, this list of conditions and the following disclaimer.
11 | // * Redistributions in binary form must reproduce the above
12 | // copyright notice, this list of conditions and the following
13 | // disclaimer in the documentation and/or other materials provided
14 | // with the distribution.
15 | // * Neither the name of Google Inc. nor the names of its
16 | // contributors may be used to endorse or promote products derived
17 | // from this software without specific prior written permission.
18 | //
19 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
20 | // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
21 | // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
22 | // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
23 | // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
24 | // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
25 | // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
26 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
27 | // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
28 | // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29 | // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30 | //
31 | //
32 | // Due to a delay in the maintenance of the 'intl_translation' package,
33 | // we are using a partial copy of it with added support for the null-safety.
34 |
35 | // Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file
36 | // for details. All rights reserved. Use of this source code is governed by a
37 | // BSD-style license that can be found in the LICENSE file.
38 |
39 | /// Contains a parser for ICU format plural/gender/select format for localized
40 | /// messages. See extract_to_arb.dart and make_hardcoded_translation.dart.
41 | library icu_parser;
42 |
43 | import 'package:petitparser/petitparser.dart';
44 |
45 | import './intl_message.dart';
46 |
47 | /// This defines a grammar for ICU MessageFormat syntax. Usage is
48 | /// new IcuParser.message.parse(<string>).value;
49 | /// The "parse" method will return a Success or Failure object which responds
50 | /// to "value".
51 | class IcuParser {
52 | Parser get openCurly => char('{');
53 |
54 | Parser get closeCurly => char('}');
55 | Parser get quotedCurly => (string("'{'") | string("'}'")).map((x) => x[1]);
56 |
57 | Parser get icuEscapedText => quotedCurly | twoSingleQuotes;
58 | Parser get curly => (openCurly | closeCurly);
59 | Parser get notAllowedInIcuText => curly | char('<');
60 | Parser get icuText => notAllowedInIcuText.neg();
61 | Parser get notAllowedInNormalText => char('{');
62 | Parser get normalText => notAllowedInNormalText.neg();
63 | Parser get messageText =>
64 | (icuEscapedText | icuText).plus().map((x) => x.join());
65 | Parser get nonIcuMessageText => normalText.plus().map((x) => x.join());
66 | Parser get twoSingleQuotes => string("''").map((x) => "'");
67 | Parser get number => digit().plus().flatten().trim().map(int.parse);
68 | Parser get id => (letter() & (word() | char('_')).star()).flatten().trim();
69 | Parser get comma => char(',').trim();
70 |
71 | /// Given a list of possible keywords, return a rule that accepts any of them.
72 | /// e.g., given ["male", "female", "other"], accept any of them.
73 | Parser asKeywords(List list) =>
74 | list.map(string).cast().reduce((a, b) => a | b).flatten().trim();
75 |
76 | Parser get pluralKeyword => asKeywords(
77 | ['=0', '=1', '=2', 'zero', 'one', 'two', 'few', 'many', 'other']);
78 | Parser get genderKeyword => asKeywords(['female', 'male', 'other']);
79 |
80 | var interiorText = undefined();
81 |
82 | Parser get preface => (openCurly & id & comma).map((values) => values[1]);
83 |
84 | Parser get pluralLiteral => string('plural');
85 | Parser get pluralClause =>
86 | (pluralKeyword & openCurly & interiorText & closeCurly)
87 | .trim()
88 | .map((result) => [result[0], result[2]]);
89 | Parser get plural =>
90 | preface & pluralLiteral & comma & pluralClause.plus() & closeCurly;
91 | Parser get intlPlural =>
92 | plural.map((values) => Plural.from(values.first, values[3], null));
93 |
94 | Parser get selectLiteral => string('select');
95 | Parser get genderClause =>
96 | (genderKeyword & openCurly & interiorText & closeCurly)
97 | .trim()
98 | .map((result) => [result[0], result[2]]);
99 | Parser get gender =>
100 | preface & selectLiteral & comma & genderClause.plus() & closeCurly;
101 | Parser get intlGender =>
102 | gender.map((values) => Gender.from(values.first, values[3], null));
103 | Parser get selectClause =>
104 | (id & openCurly & interiorText & closeCurly).map((x) => [x.first, x[2]]);
105 | Parser get generalSelect =>
106 | preface & selectLiteral & comma & selectClause.plus() & closeCurly;
107 | Parser get intlSelect =>
108 | generalSelect.map((values) => Select.from(values.first, values[3], null));
109 |
110 | Parser get compound => (((parameter | nonIcuMessageText).plus() &
111 | pluralOrGenderOrSelect &
112 | (pluralOrGenderOrSelect | parameter | nonIcuMessageText).star()) |
113 | (pluralOrGenderOrSelect &
114 | (pluralOrGenderOrSelect | parameter | nonIcuMessageText).plus()))
115 | .map((result) => result.expand((x) => x is List ? x : [x]).toList());
116 |
117 | Parser get pluralOrGenderOrSelect => intlPlural | intlGender | intlSelect;
118 |
119 | Parser get contents => pluralOrGenderOrSelect | parameter | messageText;
120 | Parser get simpleText => (nonIcuMessageText | parameter | openCurly).plus();
121 | Parser get empty => epsilon().map((_) => '');
122 |
123 | Parser get parameter => (openCurly & id & closeCurly)
124 | .map((param) => VariableSubstitution.named(param[1], null));
125 |
126 | /// The primary entry point for parsing. Accepts a string and produces
127 | /// a parsed representation of it as a Message.
128 | Parser get message => (compound | pluralOrGenderOrSelect | empty)
129 | .map((chunk) => Message.from(chunk, null));
130 |
131 | /// Represents an ordinary message, i.e. not a plural/gender/select, although
132 | /// it may have parameters.
133 | Parser get nonIcuMessage =>
134 | (simpleText | empty).map((chunk) => Message.from(chunk, null));
135 |
136 | Parser get stuff => (pluralOrGenderOrSelect | empty)
137 | .map((chunk) => Message.from(chunk, null));
138 |
139 | IcuParser() {
140 | // There is a cycle here, so we need the explicit set to avoid
141 | // infinite recursion.
142 | interiorText.set(contents.plus() | empty);
143 | }
144 | }
145 |
--------------------------------------------------------------------------------
/lib/src/intl_translation/src/intl_message.dart:
--------------------------------------------------------------------------------
1 | // This file incorporates work covered by the following copyright and
2 | // permission notice:
3 | //
4 | // Copyright 2013, the Dart project authors. All rights reserved.
5 | // Redistribution and use in source and binary forms, with or without
6 | // modification, are permitted provided that the following conditions are
7 | // met:
8 | //
9 | // * Redistributions of source code must retain the above copyright
10 | // notice, this list of conditions and the following disclaimer.
11 | // * Redistributions in binary form must reproduce the above
12 | // copyright notice, this list of conditions and the following
13 | // disclaimer in the documentation and/or other materials provided
14 | // with the distribution.
15 | // * Neither the name of Google Inc. nor the names of its
16 | // contributors may be used to endorse or promote products derived
17 | // from this software without specific prior written permission.
18 | //
19 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
20 | // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
21 | // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
22 | // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
23 | // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
24 | // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
25 | // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
26 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
27 | // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
28 | // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29 | // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30 | //
31 | //
32 | // Due to a delay in the maintenance of the 'intl_translation' package,
33 | // we are using a partial copy of it with added support for the null-safety.
34 |
35 | // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file
36 | // for details. All rights reserved. Use of this source code is governed by a
37 | // BSD-style license that can be found in the LICENSE file.
38 |
39 | /// This provides classes to represent the internal structure of the
40 | /// arguments to `Intl.message`. It is used when parsing sources to extract
41 | /// messages or to generate code for message substitution. Normal programs
42 | /// using Intl would not import this library.
43 | ///
44 | /// While it's written
45 | /// in a somewhat abstract way, it has some assumptions about ICU-style
46 | /// message syntax for parameter substitutions, choices, selects, etc.
47 | ///
48 | /// For example, if we have the message
49 | /// plurals(num) => Intl.message("""${Intl.plural(num,
50 | /// zero : 'Is zero plural?',
51 | /// one : 'This is singular.',
52 | /// other : 'This is plural ($num).')
53 | /// }""",
54 | /// name: "plurals", args: [num], desc: "Basic plurals");
55 | /// That is represented as a MainMessage which has only one message component, a
56 | /// Plural, but also has a name, list of arguments, and a description.
57 | /// The Plural has three different clauses. The `zero` clause is
58 | /// a LiteralString containing 'Is zero plural?'. The `other` clause is a
59 | /// CompositeMessage containing three pieces, a LiteralString for
60 | /// 'This is plural (', a VariableSubstitution for `num`. amd a LiteralString
61 | /// for '.)'.
62 | ///
63 | /// This representation isn't used at runtime. Rather, we read some format
64 | /// from a translation file, parse it into these objects, and they are then
65 | /// used to generate the code representation above.
66 | library intl_message;
67 |
68 | // ignore_for_file: implementation_imports
69 |
70 | import 'dart:convert';
71 |
72 | import 'package:analyzer/dart/ast/ast.dart';
73 | import 'package:analyzer/src/dart/ast/constant_evaluator.dart';
74 |
75 | /// A default function for the [Message.expanded] method.
76 | dynamic _nullTransform(msg, chunk) => chunk;
77 |
78 | const jsonEncoder = JsonCodec();
79 |
80 | /// An abstract superclass for Intl.message/plural/gender calls in the
81 | /// program's source text. We
82 | /// assemble these into objects that can be used to write out some translation
83 | /// format and can also print themselves into code.
84 | abstract class Message {
85 | /// All [Message]s except a [MainMessage] are contained inside some parent,
86 | /// terminating at an Intl.message call which supplies the arguments we
87 | /// use for variable substitutions.
88 | Message? parent;
89 |
90 | Message(this.parent);
91 |
92 | /// We find the arguments from the top-level [MainMessage] and use those to
93 | /// do variable substitutions. [MainMessage] overrides this to return
94 | /// the actual arguments.
95 | get arguments => parent == null ? const [] : parent!.arguments;
96 |
97 | /// We find the examples from the top-level [MainMessage] and use those
98 | /// when writing out variables. [MainMessage] overrides this to return
99 | /// the actual examples.
100 | get examples => parent == null ? const [] : parent!.examples;
101 |
102 | /// The name of the top-level [MainMessage].
103 | String get name => parent == null ? '' : parent!.name;
104 |
105 | static final _evaluator = ConstantEvaluator();
106 |
107 | String? _evaluateAsString(expression) {
108 | var result = expression.accept(_evaluator);
109 | if (result == ConstantEvaluator.NOT_A_CONSTANT || result is! String) {
110 | return null;
111 | } else {
112 | return result;
113 | }
114 | }
115 |
116 | Map? _evaluateAsMap(expression) {
117 | var result = expression.accept(_evaluator);
118 | if (result == ConstantEvaluator.NOT_A_CONSTANT || result is! Map) {
119 | return null;
120 | } else {
121 | return result;
122 | }
123 | }
124 |
125 | /// Verify that the args argument matches the method parameters and
126 | /// isn't, e.g. passing string names instead of the argument values.
127 | bool checkArgs(NamedExpression? args, List parameterNames) {
128 | if (args == null) return true;
129 | // Detect cases where args passes invalid names, either literal strings
130 | // instead of identifiers, or in the wrong order, missing values, etc.
131 | var identifiers = args.childEntities.last as ListLiteral;
132 | if (!identifiers.elements.every((each) => each is SimpleIdentifier)) {
133 | return false;
134 | }
135 | var names = identifiers.elements
136 | .map((each) => (each as SimpleIdentifier).name)
137 | .toList();
138 | Map both;
139 | try {
140 | both = Map.fromIterables(names, parameterNames);
141 | } catch (e) {
142 | // Most likely because sizes don't match.
143 | return false;
144 | }
145 | var everythingMatches = true;
146 | both.forEach((name, parameterName) {
147 | // Method parameters can be formatted before passing to the 'args' argument.
148 | // Thus, args argument should have the same name as the method parameter or with the suffix 'String'.
149 | if (!(name == parameterName || name == '${parameterName}String')) {
150 | everythingMatches = false;
151 | }
152 | });
153 | return everythingMatches;
154 | }
155 |
156 | /// Verify that this looks like a correct
157 | /// Intl.message/plural/gender/... invocation.
158 | ///
159 | /// We expect an invocation like
160 | ///
161 | /// outerName(x) => Intl.message("foo \$x", ...)
162 | ///
163 | /// The [node] parameter is the Intl.message invocation node in the AST,
164 | /// [arguments] is the list of arguments to that node (also reachable as
165 | /// node.argumentList.arguments), [outerName] is the name of the containing
166 | /// function, e.g. "outerName" in this case and [outerArgs] is the list of
167 | /// arguments to that function. Of the optional parameters
168 | /// [nameAndArgsGenerated] indicates if we are generating names and arguments
169 | /// while rewriting the code in the transformer or a development-time rewrite,
170 | /// so we should not expect them to be present. The [examplesRequired]
171 | /// parameter indicates if we will fail if parameter examples are not provided
172 | /// for messages with parameters.
173 | String? checkValidity(MethodInvocation node, List arguments,
174 | String? outerName, List outerArgs,
175 | {bool nameAndArgsGenerated = false, bool examplesRequired = false}) {
176 | // If we have parameters, we must specify args and name.
177 | var namedExpArgs = arguments
178 | .where(
179 | (each) => each is NamedExpression && each.name.label.name == 'args')
180 | .toList();
181 | NamedExpression? args = namedExpArgs.isNotEmpty ? namedExpArgs.first : null;
182 |
183 | var parameterNames = outerArgs.map((x) => x.name?.lexeme).toList();
184 | var hasArgs = args != null;
185 | var hasParameters = outerArgs.isNotEmpty;
186 | if (!nameAndArgsGenerated && !hasArgs && hasParameters) {
187 | return "The 'args' argument for Intl.message must be specified for "
188 | 'messages with parameters. Consider using rewrite_intl_messages.dart';
189 | }
190 | if (!checkArgs(args, parameterNames)) {
191 | return "The 'args' argument must match the message arguments,"
192 | ' e.g. args: $parameterNames';
193 | }
194 | var namedExpNames = arguments
195 | .where((eachArg) =>
196 | eachArg is NamedExpression && eachArg.name.label.name == 'name')
197 | .toList();
198 | var messageNameArgument =
199 | namedExpNames.isNotEmpty ? namedExpNames.first : null;
200 |
201 | var nameExpression = messageNameArgument?.expression;
202 | String? messageName;
203 | String? givenName;
204 |
205 | //TODO(alanknight): If we generalize this to messages with parameters
206 | // this check will need to change.
207 | if (nameExpression == null) {
208 | if (!hasParameters) {
209 | // No name supplied, no parameters. Use the message as the name.
210 | messageName = _evaluateAsString(arguments[0]);
211 | outerName = messageName;
212 | } else {
213 | // We have no name and parameters, but the transformer generates the
214 | // name.
215 | if (nameAndArgsGenerated) {
216 | givenName = outerName;
217 | messageName = givenName;
218 | } else {
219 | return "The 'name' argument for Intl.message must be supplied for "
220 | 'messages with parameters. Consider using '
221 | 'rewrite_intl_messages.dart';
222 | }
223 | }
224 | } else {
225 | // Name argument is supplied, use it.
226 | givenName = _evaluateAsString(nameExpression);
227 | messageName = givenName;
228 | }
229 |
230 | if (messageName == null) {
231 | return "The 'name' argument for Intl.message must be a string literal";
232 | }
233 |
234 | var hasOuterName = outerName != null;
235 | var simpleMatch = outerName == givenName || givenName == null;
236 |
237 | var classPlusMethod = Message.classPlusMethodName(node, outerName);
238 | var classMatch = classPlusMethod != null && (givenName == classPlusMethod);
239 | if (!(hasOuterName && (simpleMatch || classMatch))) {
240 | return "The 'name' argument for Intl.message must match either "
241 | 'the name of the containing function or _ ('
242 | "was '$givenName' but must be '$outerName' or '$classPlusMethod')";
243 | }
244 |
245 | var simpleArguments = arguments.where((each) =>
246 | each is NamedExpression &&
247 | ['desc', 'name'].contains(each.name.label.name));
248 | var values = simpleArguments.map((each) => each.expression).toList();
249 | for (var arg in values) {
250 | if (_evaluateAsString(arg) == null) {
251 | return ('Intl.message arguments must be string literals: $arg');
252 | }
253 | }
254 |
255 | if (hasParameters) {
256 | var exampleArg = arguments.where((each) =>
257 | each is NamedExpression && each.name.label.name == 'examples');
258 | var examples = exampleArg.map((each) => each.expression).toList();
259 | if (examples.isEmpty && examplesRequired) {
260 | return 'Examples must be provided for messages with parameters';
261 | }
262 | if (examples.isNotEmpty) {
263 | var example = examples.first;
264 | var map = _evaluateAsMap(example);
265 | if (map == null) {
266 | return 'Examples must be a const Map literal.';
267 | }
268 | if (example.constKeyword == null) {
269 | return 'Examples must be const.';
270 | }
271 | }
272 | }
273 |
274 | return null;
275 | }
276 |
277 | /// Verify that a constructed message is valid.
278 | ///
279 | /// This is called after the message has already been built, as opposed
280 | /// to checkValidity which is called before creation. It can be used to
281 | /// validate conditions that can just be checked against the result,
282 | /// and/or are simpler to check there than on the AST nodes. For example,
283 | /// is a required clause like "other" included, or are there examples
284 | /// for all of the parameters. It should throw an
285 | /// IntlMessageExtractionException for errors.
286 | void validate() {}
287 |
288 | /// Return the name of the enclosing class (if any) plus method name, or null
289 | /// if there's no enclosing class.
290 | ///
291 | /// For a method foo in class Bar we allow either "foo" or "Bar_Foo" as the
292 | /// name.
293 | static String? classPlusMethodName(MethodInvocation node, String? outerName) {
294 | ClassDeclaration? classNode(n) {
295 | if (n == null) return null;
296 | if (n is ClassDeclaration) return n;
297 | return classNode(n.parent);
298 | }
299 |
300 | var classDeclaration = classNode(node);
301 | return classDeclaration == null
302 | ? null
303 | : '${classDeclaration.name}_$outerName';
304 | }
305 |
306 | /// Turn a value, typically read from a translation file or created out of an
307 | /// AST for a source program, into the appropriate
308 | /// subclass. We expect to get literal Strings, variable substitutions
309 | /// represented by integers, things that are already MessageChunks and
310 | /// lists of the same.
311 | static Message from(Object value, Message? parent) {
312 | if (value is String) return LiteralString(value, parent);
313 | if (value is int) return VariableSubstitution(value, parent);
314 | if (value is List) {
315 | if (value.length == 1) return Message.from(value[0], parent);
316 | var result = CompositeMessage([], parent);
317 | var items = value.map((x) => from(x, result)).toList();
318 | result.pieces.addAll(items);
319 | return result;
320 | }
321 | // We assume this is already a Message.
322 | var mustBeAMessage = value as Message;
323 | mustBeAMessage.parent = parent;
324 | return mustBeAMessage;
325 | }
326 |
327 | /// Return a string representation of this message for use in generated Dart
328 | /// code.
329 | String toCode();
330 |
331 | /// Return a JSON-storable representation of this message which can be
332 | /// interpolated at runtime.
333 | Object? toJson();
334 |
335 | /// Escape the string for use in generated Dart code.
336 | String escapeAndValidateString(String value) {
337 | const escapes = {
338 | r'\': r'\\',
339 | '"': r'\"',
340 | '\b': r'\b',
341 | '\f': r'\f',
342 | '\n': r'\n',
343 | '\r': r'\r',
344 | '\t': r'\t',
345 | '\v': r'\v',
346 | "'": r"\'",
347 | r'$': r'\$'
348 | };
349 |
350 | String escape(String s) => escapes[s] ?? s;
351 |
352 | var escaped = value.splitMapJoin('', onNonMatch: escape);
353 | return escaped;
354 | }
355 |
356 | /// Expand this string out into a printed form. The function [f] will be
357 | /// applied to any sub-messages, allowing this to be used to generate a form
358 | /// suitable for a wide variety of translation file formats.
359 | String expanded([Function f]);
360 | }
361 |
362 | /// Abstract class for messages with internal structure, representing the
363 | /// main Intl.message call, plurals, and genders.
364 | abstract class ComplexMessage extends Message {
365 | ComplexMessage(super.parent);
366 |
367 | /// When we create these from strings or from AST nodes, we want to look up
368 | /// and set their attributes by string names, so we override the indexing
369 | /// operators so that they behave like maps with respect to those attribute
370 | /// names.
371 | operator [](String x);
372 |
373 | /// When we create these from strings or from AST nodes, we want to look up
374 | /// and set their attributes by string names, so we override the indexing
375 | /// operators so that they behave like maps with respect to those attribute
376 | /// names.
377 | operator []=(String x, y);
378 |
379 | List get attributeNames;
380 |
381 | /// Return the name of the message type, as it will be generated into an
382 | /// ICU-type format. e.g. choice, select
383 | String get icuMessageName;
384 |
385 | /// Return the message name we would use for this when doing Dart code
386 | /// generation, e.g. "Intl.plural".
387 | String get dartMessageName;
388 | }
389 |
390 | /// This represents a message chunk that is a list of multiple sub-pieces,
391 | /// each of which is in turn a [Message].
392 | class CompositeMessage extends Message {
393 | late List pieces;
394 |
395 | CompositeMessage.withParent(super.parent);
396 | CompositeMessage(this.pieces, Message? parent) : super(parent) {
397 | for (var x in pieces) {
398 | x.parent = this;
399 | }
400 | }
401 |
402 | @override
403 | String toCode() => pieces.map((each) => each.toCode()).join('');
404 |
405 | @override
406 | List toJson() => pieces.map((each) => each.toJson()).toList();
407 |
408 | @override
409 | String toString() => 'CompositeMessage($pieces)';
410 |
411 | @override
412 | String expanded([Function f = _nullTransform]) =>
413 | pieces.map((chunk) => f(this, chunk)).join('');
414 | }
415 |
416 | /// Represents a simple constant string with no dynamic elements.
417 | class LiteralString extends Message {
418 | String string;
419 |
420 | LiteralString(this.string, Message? parent) : super(parent);
421 |
422 | @override
423 | String toCode() => escapeAndValidateString(string);
424 |
425 | @override
426 | String toJson() => string;
427 |
428 | @override
429 | String toString() => 'Literal($string)';
430 |
431 | @override
432 | String expanded([Function f = _nullTransform]) => f(this, string);
433 | }
434 |
435 | /// Represents an interpolation of a variable value in a message. We expect
436 | /// this to be specified as an [index] into the list of variables, or else
437 | /// as the name of a variable that exists in [arguments] and we will
438 | /// compute the variable name or the index based on the value of the other.
439 | class VariableSubstitution extends Message {
440 | VariableSubstitution(this._index, Message? parent) : super(parent);
441 |
442 | /// Create a substitution based on the name rather than the index. The name
443 | /// may have been used as all upper-case in the translation tool, so we
444 | /// save it separately and look it up case-insensitively once the parent
445 | /// (and its arguments) are definitely available.
446 | VariableSubstitution.named(String name, Message? parent) : super(parent) {
447 | _variableNameUpper = name.toUpperCase();
448 | }
449 |
450 | /// The index in the list of parameters of the containing function.
451 | int? _index;
452 | int? get index {
453 | if (_index != null) return _index;
454 | if (arguments.isEmpty) return null;
455 | // We may have been given an all-uppercase version of the name, so compare
456 | // case-insensitive.
457 | _index = arguments
458 | .map((x) => x.toUpperCase())
459 | .toList()
460 | .indexOf(_variableNameUpper);
461 | if (_index == -1) {
462 | throw ArgumentError(
463 | "Cannot find parameter named '$_variableNameUpper' in "
464 | "message named '$name'. Available "
465 | 'parameters are $arguments');
466 | }
467 | return _index;
468 | }
469 |
470 | /// The variable name we get from parsing. This may be an all uppercase
471 | /// version of the Dart argument name.
472 | String? _variableNameUpper;
473 |
474 | /// The name of the variable in the parameter list of the containing function.
475 | /// Used when generating code for the interpolation
476 | String get variableName => _variableName ??= arguments[index];
477 |
478 | String? _variableName;
479 | // Although we only allow simple variable references, we always enclose them
480 | // in curly braces so that there's no possibility of ambiguity with
481 | // surrounding text.
482 |
483 | @override
484 | String toCode() => '\${$variableName}';
485 |
486 | @override
487 | int? toJson() => index;
488 |
489 | @override
490 | String toString() => 'VariableSubstitution($index)';
491 |
492 | @override
493 | String expanded([Function f = _nullTransform]) => f(this, index);
494 | }
495 |
496 | class MainMessage extends ComplexMessage {
497 | MainMessage() : super(null);
498 |
499 | /// All the pieces of the message. When we go to print, these will
500 | /// all be expanded appropriately. The exact form depends on what we're
501 | /// printing it for See [expanded], [toCode].
502 | List messagePieces = [];
503 |
504 | /// The position in the source at which this message starts.
505 | int? sourcePosition;
506 |
507 | /// The position in the source at which this message ends.
508 | int? endPosition;
509 |
510 | /// Optional documentation of the member that wraps the message definition.
511 | List documentation = [];
512 |
513 | /// Verify that this looks like a correct Intl.message invocation.
514 | @override
515 | String? checkValidity(MethodInvocation node, List arguments,
516 | String? outerName, List outerArgs,
517 | {bool nameAndArgsGenerated = false, bool examplesRequired = false}) {
518 | if (arguments.first is! StringLiteral) {
519 | return 'Intl.message messages must be string literals';
520 | }
521 |
522 | return super.checkValidity(node, arguments, outerName, outerArgs,
523 | nameAndArgsGenerated: nameAndArgsGenerated,
524 | examplesRequired: examplesRequired);
525 | }
526 |
527 | void addPieces(List messages) {
528 | for (var each in messages) {
529 | messagePieces.add(Message.from(each, this));
530 | }
531 | }
532 |
533 | void validateDescription() {
534 | if (description == null || description == '') {
535 | throw IntlMessageExtractionException(
536 | 'Missing description for message $this');
537 | }
538 | }
539 |
540 | /// The description provided in the Intl.message call.
541 | String? description;
542 |
543 | /// The examples from the Intl.message call
544 | @override
545 | Map? examples;
546 |
547 | /// A field to disambiguate two messages that might have exactly the
548 | /// same text. The two messages will also need different names, but
549 | /// this can be used by machine translation tools to distinguish them.
550 | String? meaning;
551 |
552 | /// The name, which may come from the function name, from the arguments
553 | /// to Intl.message, or we may just re-use the message.
554 | String? _name;
555 |
556 | /// A placeholder for any other identifier that the translation format
557 | /// may want to use.
558 | String? id;
559 |
560 | /// The arguments list from the Intl.message call.
561 | @override
562 | List? arguments;
563 |
564 | /// The locale argument from the Intl.message call
565 | String? locale;
566 |
567 | /// Whether extraction skip outputting this message.
568 | ///
569 | /// For example, this could be used to define messages whose purpose is known,
570 | /// but whose text isn't final yet and shouldn't be sent for translation.
571 | bool skip = false;
572 |
573 | /// When generating code, we store translations for each locale
574 | /// associated with the original message.
575 | Map translations = {};
576 | Map jsonTranslations = {};
577 |
578 | /// If the message was not given a name, we use the entire message string as
579 | /// the name.
580 | @override
581 | String get name => _name ?? '';
582 | set name(String newName) {
583 | _name = newName;
584 | }
585 |
586 | /// Does this message have an assigned name.
587 | bool get hasName => _name != null;
588 |
589 | /// Return the full message, with any interpolation expressions transformed
590 | /// by [f] and all the results concatenated. The chunk argument to [f] may be
591 | /// either a String, an int or an object representing a more complex
592 | /// message entity.
593 | /// See [messagePieces].
594 | @override
595 | String expanded([Function f = _nullTransform]) =>
596 | messagePieces.map((chunk) => f(this, chunk)).join('');
597 |
598 | /// Record the translation for this message in the given locale, after
599 | /// suitably escaping it.
600 | void addTranslation(String locale, Message translated) {
601 | translated.parent = this;
602 | translations[locale] = translated.toCode();
603 | jsonTranslations[locale] = translated.toJson();
604 | }
605 |
606 | @override
607 | Never toCode() =>
608 | throw UnsupportedError('MainMessage.toCode requires a locale');
609 |
610 | @override
611 | Never toJson() =>
612 | throw UnsupportedError('MainMessage.toJson requires a locale');
613 |
614 | /// Generate code for this message, expecting it to be part of a map
615 | /// keyed by name with values the function that calls Intl.message.
616 | String toCodeForLocale(String locale, String name) {
617 | var out = StringBuffer()
618 | ..write('static String $name(')
619 | ..write((arguments ?? []).join(', '))
620 | ..write(') => "')
621 | ..write(translations[locale])
622 | ..write('";');
623 | return out.toString();
624 | }
625 |
626 | /// Return a JSON string representation of this message.
627 | Object? toJsonForLocale(String locale) {
628 | return jsonTranslations[locale];
629 | }
630 |
631 | String turnInterpolationBackIntoStringForm(Message message, chunk) {
632 | if (chunk is String) return escapeAndValidateString(chunk);
633 | if (chunk is int) return '${r'${' + message.arguments[chunk]}}';
634 | if (chunk is Message) return chunk.toCode();
635 | throw ArgumentError.value(chunk, 'Unexpected value in Intl.message');
636 | }
637 |
638 | /// Create a string that will recreate this message, optionally
639 | /// including the compile-time only information desc and examples.
640 | String toOriginalCode({bool includeDesc = true, includeExamples = true}) {
641 | var out = StringBuffer()..write("Intl.message('");
642 | out.write(expanded(turnInterpolationBackIntoStringForm));
643 | out.write("', ");
644 | out.write("name: '$name', ");
645 | out.write(locale == null ? '' : "locale: '$locale', ");
646 | if (includeDesc) {
647 | out.write(description == null
648 | ? ''
649 | : "desc: '${escapeAndValidateString(description!)}', ");
650 | }
651 | if (includeExamples) {
652 | // json is already mostly-escaped, but we need to handle interpolations.
653 | var json = jsonEncoder.encode(examples).replaceAll(r'$', r'\$');
654 | out.write(examples == null ? '' : 'examples: const $json, ');
655 | }
656 | out.write(meaning == null
657 | ? ''
658 | : "meaning: '${escapeAndValidateString(meaning!)}', ");
659 | out.write("args: [${(arguments ?? []).join(', ')}]");
660 | out.write(')');
661 | return out.toString();
662 | }
663 |
664 | /// The AST node will have the attribute names as strings, so we translate
665 | /// between those and the fields of the class.
666 | @override
667 | void operator []=(String attributeName, value) {
668 | switch (attributeName) {
669 | case 'desc':
670 | description = value;
671 | return;
672 | case 'examples':
673 | examples = value as Map;
674 | return;
675 | case 'name':
676 | name = value;
677 | return;
678 | // We use the actual args from the parser rather than what's given in the
679 | // arguments to Intl.message.
680 | case 'args':
681 | return;
682 | case 'meaning':
683 | meaning = value;
684 | return;
685 | case 'locale':
686 | locale = value;
687 | return;
688 | case 'skip':
689 | skip = value as bool;
690 | return;
691 | default:
692 | return;
693 | }
694 | }
695 |
696 | /// The AST node will have the attribute names as strings, so we translate
697 | /// between those and the fields of the class.
698 | @override
699 | Object? operator [](String attributeName) {
700 | switch (attributeName) {
701 | case 'desc':
702 | return description;
703 | case 'examples':
704 | return examples;
705 | case 'name':
706 | return name;
707 | // We use the actual args from the parser rather than what's given in the
708 | // arguments to Intl.message.
709 | case 'args':
710 | return [];
711 | case 'meaning':
712 | return meaning;
713 | case 'skip':
714 | return skip;
715 | default:
716 | return null;
717 | }
718 | }
719 |
720 | // This is the top-level construct, so there's no meaningful ICU name.
721 | @override
722 | String get icuMessageName => '';
723 |
724 | @override
725 | String get dartMessageName => 'message';
726 |
727 | /// The parameters that the Intl.message call may provide.
728 | @override
729 | List get attributeNames =>
730 | const ['name', 'desc', 'examples', 'args', 'meaning', 'skip'];
731 |
732 | @override
733 | String toString() =>
734 | 'Intl.message(${expanded()}, $name, $description, $examples, $arguments)';
735 | }
736 |
737 | /// An abstract class to represent sub-sections of a message, primarily
738 | /// plurals and genders.
739 | abstract class SubMessage extends ComplexMessage {
740 | SubMessage() : super(null);
741 |
742 | /// Creates the sub-message, given a list of [clauses] in the sort of form
743 | /// that we're likely to get them from parsing a translation file format,
744 | /// as a list of [key, value] where value may in turn be a list.
745 | SubMessage.from(this.mainArgument, List clauses, parent) : super(parent) {
746 | for (var clause in clauses) {
747 | this[clause.first] = (clause.last is List) ? clause.last : [clause.last];
748 | }
749 | }
750 |
751 | @override
752 | String toString() => expanded();
753 |
754 | /// The name of the main argument, which is expected to have the value which
755 | /// is one of [attributeNames] and is used to decide which clause to use.
756 | String? mainArgument;
757 |
758 | /// Return the arguments that affect this SubMessage as a map of
759 | /// argument names and values.
760 | Map argumentsOfInterestFor(MethodInvocation node) {
761 | var basicArguments = node.argumentList.arguments;
762 | var others = basicArguments.whereType();
763 | return {
764 | for (var node in others) node.name.label.token.value(): node.expression
765 | };
766 | }
767 |
768 | /// Return the list of attribute names to use when generating code. This
769 | /// may be different from [attributeNames] if there are multiple aliases
770 | /// that map to the same clause.
771 | List get codeAttributeNames;
772 |
773 | @override
774 | String expanded([Function f = _nullTransform]) {
775 | String fullMessageForClause(String key) => '$key{${f(parent, this[key])}}';
776 | var clauses = attributeNames
777 | .where((key) => this[key] != null)
778 | .map(fullMessageForClause)
779 | .toList();
780 | return "{$mainArgument,$icuMessageName, ${clauses.join("")}}";
781 | }
782 |
783 | @override
784 | String toCode() {
785 | var out = StringBuffer();
786 | out.write('\${');
787 | out.write(dartMessageName);
788 | out.write('(');
789 | out.write(mainArgument);
790 | var args = codeAttributeNames.where((attribute) => this[attribute] != null);
791 | args.fold(
792 | out,
793 | (StringBuffer buffer, arg) =>
794 | buffer..write(", $arg: '${this[arg].toCode()}'"));
795 | out.write(')}');
796 | return out.toString();
797 | }
798 |
799 | /// We represent this in JSON as a list with [dartMessageName], the index in
800 | /// the arguments list at which we will find the main argument (e.g. howMany
801 | /// for a plural), and then the values of all the possible arguments, in the
802 | /// order that they appear in codeAttributeNames. Any missing arguments are
803 | /// saved as an explicit null.
804 | @override
805 | List toJson() {
806 | var json = [];
807 | json.add(dartMessageName);
808 | json.add(arguments.indexOf(mainArgument));
809 | for (var arg in codeAttributeNames) {
810 | json.add(this[arg]?.toJson());
811 | }
812 | return json;
813 | }
814 | }
815 |
816 | /// Represents a message send of [Intl.gender] inside a message that is to
817 | /// be internationalized. This corresponds to an ICU message syntax "select"
818 | /// with "male", "female", and "other" as the possible options.
819 | class Gender extends SubMessage {
820 | Gender();
821 |
822 | /// Create a new Gender providing [mainArgument] and the list of possible
823 | /// clauses. Each clause is expected to be a list whose first element is a
824 | /// variable name and whose second element is either a [String] or
825 | /// a list of strings and [Message] or [VariableSubstitution].
826 | Gender.from(super.mainArgument, super.clauses, super.parent) : super.from();
827 |
828 | Message? female;
829 |
830 | Message? male;
831 |
832 | Message? other;
833 |
834 | @override
835 | String get icuMessageName => 'select';
836 |
837 | @override
838 | String get dartMessageName => 'Intl.gender';
839 |
840 | @override
841 | List get attributeNames => ['female', 'male', 'other'];
842 |
843 | @override
844 | List get codeAttributeNames => attributeNames;
845 |
846 | /// The node will have the attribute names as strings, so we translate
847 | /// between those and the fields of the class.
848 | @override
849 | void operator []=(String attributeName, rawValue) {
850 | var value = Message.from(rawValue, this);
851 | switch (attributeName) {
852 | case 'female':
853 | female = value;
854 | return;
855 | case 'male':
856 | male = value;
857 | return;
858 | case 'other':
859 | other = value;
860 | return;
861 | default:
862 | return;
863 | }
864 | }
865 |
866 | @override
867 | Message? operator [](String x) {
868 | switch (x) {
869 | case 'female':
870 | return female;
871 | case 'male':
872 | return male;
873 | case 'other':
874 | return other;
875 | default:
876 | return other;
877 | }
878 | }
879 | }
880 |
881 | class Plural extends SubMessage {
882 | Plural();
883 | Plural.from(super.mainArgument, super.clauses, super.parent) : super.from();
884 |
885 | Message? zero;
886 |
887 | Message? one;
888 |
889 | Message? two;
890 |
891 | Message? few;
892 |
893 | Message? many;
894 |
895 | Message? other;
896 |
897 | @override
898 | String get icuMessageName => 'plural';
899 |
900 | @override
901 | String get dartMessageName => 'Intl.plural';
902 |
903 | @override
904 | List get attributeNames => ['=0', '=1', '=2', 'few', 'many', 'other'];
905 |
906 | @override
907 | List get codeAttributeNames =>
908 | ['zero', 'one', 'two', 'few', 'many', 'other'];
909 |
910 | /// The node will have the attribute names as strings, so we translate
911 | /// between those and the fields of the class.
912 | @override
913 | void operator []=(String attributeName, rawValue) {
914 | var value = Message.from(rawValue, this);
915 | switch (attributeName) {
916 | case 'zero':
917 | // We prefer an explicit "=0" clause to a "ZERO"
918 | // if both are present.
919 | zero ??= value;
920 | return;
921 | case '=0':
922 | zero = value;
923 | return;
924 | case 'one':
925 | // We prefer an explicit "=1" clause to a "ONE"
926 | // if both are present.
927 | one ??= value;
928 | return;
929 | case '=1':
930 | one = value;
931 | return;
932 | case 'two':
933 | // We prefer an explicit "=2" clause to a "TWO"
934 | // if both are present.
935 | two ??= value;
936 | return;
937 | case '=2':
938 | two = value;
939 | return;
940 | case 'few':
941 | few = value;
942 | return;
943 | case 'many':
944 | many = value;
945 | return;
946 | case 'other':
947 | other = value;
948 | return;
949 | default:
950 | return;
951 | }
952 | }
953 |
954 | @override
955 | Message? operator [](String x) {
956 | switch (x) {
957 | case 'zero':
958 | return zero;
959 | case '=0':
960 | return zero;
961 | case 'one':
962 | return one;
963 | case '=1':
964 | return one;
965 | case 'two':
966 | return two;
967 | case '=2':
968 | return two;
969 | case 'few':
970 | return few;
971 | case 'many':
972 | return many;
973 | case 'other':
974 | return other;
975 | default:
976 | return other;
977 | }
978 | }
979 | }
980 |
981 | /// Represents a message send of [Intl.select] inside a message that is to
982 | /// be internationalized. This corresponds to an ICU message syntax "select"
983 | /// with arbitrary options.
984 | class Select extends SubMessage {
985 | Select();
986 |
987 | /// Create a new [Select] providing [mainArgument] and the list of possible
988 | /// clauses. Each clause is expected to be a list whose first element is a
989 | /// variable name and whose second element is either a String or
990 | /// a list of strings and [Message]s or [VariableSubstitution]s.
991 | Select.from(super.mainArgument, super.clauses, super.parent) : super.from();
992 |
993 | Map cases = {};
994 |
995 | @override
996 | String get icuMessageName => 'select';
997 |
998 | @override
999 | String get dartMessageName => 'Intl.select';
1000 |
1001 | @override
1002 | List get attributeNames => cases.keys.toList();
1003 |
1004 | @override
1005 | List get codeAttributeNames => attributeNames;
1006 |
1007 | // Check for valid select keys.
1008 | // See http://site.icu-project.org/design/formatting/select
1009 | static const selectPattern = '[a-zA-Z][a-zA-Z0-9_-]*';
1010 | static final validSelectKey = RegExp(selectPattern);
1011 |
1012 | @override
1013 | void operator []=(String x, y) {
1014 | var value = Message.from(y, this);
1015 | if (validSelectKey.stringMatch(x) == x) {
1016 | cases[x] = value;
1017 | } else {
1018 | throw IntlMessageExtractionException("Invalid select keyword: '$x', must "
1019 | "match '$selectPattern'");
1020 | }
1021 | }
1022 |
1023 | @override
1024 | Message? operator [](String x) {
1025 | var exact = cases[x];
1026 | return exact ?? cases['other'];
1027 | }
1028 |
1029 | /// Return the arguments that we care about for the select. In this
1030 | /// case they will all be passed in as a Map rather than as the named
1031 | /// arguments used in Plural/Gender.
1032 | @override
1033 | Map argumentsOfInterestFor(MethodInvocation node) {
1034 | var casesArgument = node.argumentList.arguments[1] as SetOrMapLiteral;
1035 | return {
1036 | for (var node in casesArgument.elements)
1037 | _keyForm((node as MapLiteralEntry).key): node.value
1038 | };
1039 | }
1040 |
1041 | // The key might already be a simple string, or it might be
1042 | // something else, in which case we convert it to a string
1043 | // and take the portion after the period, if present.
1044 | // This is to handle enums as select keys.
1045 | String _keyForm(key) {
1046 | return (key is SimpleStringLiteral) ? key.value : '$key'.split('.').last;
1047 | }
1048 |
1049 | @override
1050 | void validate() {
1051 | if (this['other'] == null) {
1052 | throw IntlMessageExtractionException(
1053 | 'Missing keyword other for Intl.select $this');
1054 | }
1055 | }
1056 |
1057 | /// Write out the generated representation of this message. This differs
1058 | /// from Plural/Gender in that it prints a literal map rather than
1059 | /// named arguments.
1060 | @override
1061 | String toCode() {
1062 | var out = StringBuffer();
1063 | out.write('\${');
1064 | out.write(dartMessageName);
1065 | out.write('(');
1066 | out.write(mainArgument);
1067 | var args = codeAttributeNames;
1068 | out.write(', {');
1069 | args.fold(
1070 | out,
1071 | (StringBuffer buffer, arg) =>
1072 | buffer..write("'$arg': '${this[arg]?.toCode()}', "));
1073 | out.write('})}');
1074 | return out.toString();
1075 | }
1076 |
1077 | /// We represent this in JSON as a List with the name of the message
1078 | /// (e.g. Intl.select), the index in the arguments list of the main argument,
1079 | /// and then a Map from the cases to the List of strings or sub-messages.
1080 | @override
1081 | List toJson() {
1082 | var json = [];
1083 | json.add(dartMessageName);
1084 | json.add(arguments.indexOf(mainArgument));
1085 | var attributes = {};
1086 | for (var arg in codeAttributeNames) {
1087 | attributes[arg] = this[arg]?.toJson();
1088 | }
1089 | json.add(attributes);
1090 | return json;
1091 | }
1092 | }
1093 |
1094 | /// Exception thrown when we cannot process a message properly.
1095 | class IntlMessageExtractionException implements Exception {
1096 | /// A message describing the error.
1097 | final String message;
1098 |
1099 | /// Creates a new exception with an optional error [message].
1100 | const IntlMessageExtractionException([this.message = '']);
1101 |
1102 | @override
1103 | String toString() => 'IntlMessageExtractionException: $message';
1104 | }
1105 |
--------------------------------------------------------------------------------
/lib/src/localizely/api/api.dart:
--------------------------------------------------------------------------------
1 | import 'dart:io';
2 |
3 | import 'package:http/http.dart' as http;
4 | import 'package:path/path.dart' as path;
5 |
6 | import '../../utils/utils.dart';
7 | import '../api/api_exception.dart';
8 | import '../model/download_response.dart';
9 |
10 | class LocalizelyApi {
11 | static final String _baseUrl = 'https://api.localizely.com';
12 |
13 | LocalizelyApi._();
14 |
15 | static Future upload(
16 | String projectId, String apiToken, String langCode, File file,
17 | [String? branch,
18 | bool overwrite = false,
19 | bool reviewed = false,
20 | List? tagAdded,
21 | List? tagUpdated,
22 | List? tagRemoved]) async {
23 | var queryParams = [
24 | '?lang_code=$langCode',
25 | '&overwrite=$overwrite',
26 | '&reviewed=$reviewed',
27 | branch != null ? '&branch=$branch' : '',
28 | tagAdded != null
29 | ? tagAdded.map((tag) => '&tag_added=$tag').toList().join()
30 | : '',
31 | tagUpdated != null
32 | ? tagUpdated.map((tag) => '&tag_updated=$tag').toList().join()
33 | : '',
34 | tagRemoved != null
35 | ? tagRemoved.map((tag) => '&tag_removed=$tag').toList().join()
36 | : ''
37 | ].join();
38 |
39 | var uri =
40 | Uri.parse('$_baseUrl/v1/projects/$projectId/files/upload$queryParams');
41 | var headers = {'X-Api-Token': apiToken};
42 |
43 | var request = http.MultipartRequest('POST', uri)
44 | ..headers.addAll(headers)
45 | ..files.add(http.MultipartFile.fromBytes('file', file.readAsBytesSync(),
46 | filename: path.basename(file.path)));
47 |
48 | var response = await request.send();
49 |
50 | if (response.statusCode != 200) {
51 | var formattedResponse =
52 | formatJsonMessage(await response.stream.bytesToString());
53 | throw ApiException('Failed to upload data on Localizely.',
54 | response.statusCode, formattedResponse);
55 | }
56 | }
57 |
58 | static Future download(String projectId, String apiToken,
59 | [String? branch,
60 | String? exportEmptyAs,
61 | List? includeTags,
62 | List? excludeTags]) async {
63 | var queryParams = [
64 | '?type=flutter_arb',
65 | branch != null ? '&branch=$branch' : '',
66 | exportEmptyAs != null ? '&export_empty_as=$exportEmptyAs' : '',
67 | includeTags != null
68 | ? includeTags.map((tag) => '&include_tags=$tag').toList().join()
69 | : '',
70 | excludeTags != null
71 | ? excludeTags.map((tag) => '&exclude_tags=$tag').toList().join()
72 | : ''
73 | ].join();
74 |
75 | var uri = Uri.parse(
76 | '$_baseUrl/v1/projects/$projectId/files/download$queryParams');
77 | var headers = {'X-Api-Token': apiToken};
78 |
79 | var response = await http.get(uri, headers: headers);
80 |
81 | if (response.statusCode != 200) {
82 | var formattedResponse = formatJsonMessage(response.body);
83 | throw ApiException('Failed to download data from Localizely.',
84 | response.statusCode, formattedResponse);
85 | }
86 |
87 | return DownloadResponse.fromResponse(response);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/lib/src/localizely/api/api_exception.dart:
--------------------------------------------------------------------------------
1 | class ApiException implements Exception {
2 | final String message;
3 | final int statusCode;
4 | final String body;
5 |
6 | ApiException(this.message, this.statusCode, this.body);
7 |
8 | String getFormattedMessage() {
9 | return '$message\n$body';
10 | }
11 |
12 | @override
13 | String toString() =>
14 | 'ApiException: $message. Status code: $statusCode. Body: $body';
15 | }
16 |
--------------------------------------------------------------------------------
/lib/src/localizely/model/download_response.dart:
--------------------------------------------------------------------------------
1 | import 'package:archive/archive.dart';
2 | import 'package:http/http.dart';
3 |
4 | import 'file_data.dart';
5 |
6 | class DownloadResponse {
7 | late List files;
8 |
9 | DownloadResponse.fromResponse(Response response) {
10 | files = [];
11 |
12 | var headers = response.headers;
13 |
14 | var contentDisposition = headers['content-disposition'];
15 | if (contentDisposition == null) {
16 | throw Exception("Missing 'Content-Disposition' header.");
17 | }
18 |
19 | var fileName = _getFileName(contentDisposition);
20 | if (fileName == null) {
21 | throw Exception(
22 | "Can't extract file name from 'Content-Disposition' header.");
23 | }
24 |
25 | var bytes = response.bodyBytes;
26 |
27 | var isArchive = _checkIsArchive(fileName);
28 | if (isArchive) {
29 | var archive = ZipDecoder().decodeBytes(bytes);
30 | for (final file in archive) {
31 | files.add(FileData(file.name, file.content));
32 | }
33 | } else {
34 | files.add(FileData(fileName, bytes));
35 | }
36 | }
37 |
38 | String? _getFileName(String contentDisposition) {
39 | var patterns = [
40 | RegExp('filename\\*=[^\']+\'\\w*\'"([^"]+)";?', caseSensitive: false),
41 | RegExp('filename\\*=[^\']+\'\\w*\'([^;]+);?', caseSensitive: false),
42 | RegExp('filename="([^;]*);?"', caseSensitive: false),
43 | RegExp('filename=([^;]*);?', caseSensitive: false)
44 | ];
45 |
46 | String? fileName;
47 | for (var i = 0; i < patterns.length; i++) {
48 | var allMatches = patterns[i].allMatches(contentDisposition);
49 | if (allMatches.isNotEmpty && allMatches.elementAt(0).groupCount == 1) {
50 | fileName = allMatches.elementAt(0).group(1);
51 | break;
52 | }
53 | }
54 |
55 | return fileName;
56 | }
57 |
58 | bool _checkIsArchive(String fileName) => fileName.endsWith('.zip');
59 | }
60 |
--------------------------------------------------------------------------------
/lib/src/localizely/model/file_data.dart:
--------------------------------------------------------------------------------
1 | import 'dart:typed_data';
2 |
3 | class FileData {
4 | String name;
5 | Uint8List bytes;
6 |
7 | FileData(this.name, this.bytes);
8 | }
9 |
--------------------------------------------------------------------------------
/lib/src/localizely/service/service.dart:
--------------------------------------------------------------------------------
1 | import '../../utils/file_utils.dart';
2 | import '../api/api.dart';
3 | import 'service_exception.dart';
4 |
5 | class LocalizelyService {
6 | LocalizelyService._();
7 |
8 | /// Uploads main ARB file on Localizely.
9 | static Future uploadMainArbFile(
10 | String projectId,
11 | String apiToken,
12 | String arbDir,
13 | String mainLocale,
14 | String? branch,
15 | bool overwrite,
16 | bool reviewed,
17 | List? tagAdded,
18 | List? tagUpdated,
19 | List? tagRemoved) async {
20 | final mainArbFile = getArbFileForLocale(mainLocale, arbDir);
21 | if (mainArbFile == null) {
22 | throw ServiceException("Can't find ARB file for the main locale.");
23 | }
24 |
25 | await LocalizelyApi.upload(projectId, apiToken, mainLocale, mainArbFile,
26 | branch, overwrite, reviewed, tagAdded, tagUpdated, tagRemoved);
27 | }
28 |
29 | /// Downloads all ARB files from Localizely.
30 | static Future download(
31 | String projectId,
32 | String apiToken,
33 | String arbDir,
34 | String exportEmptyAs,
35 | String? branch,
36 | List? includeTags,
37 | List? excludeTags,
38 | ) async {
39 | final response = await LocalizelyApi.download(
40 | projectId, apiToken, branch, exportEmptyAs, includeTags, excludeTags);
41 |
42 | for (var fileData in response.files) {
43 | await updateArbFile(
44 | fileData.name,
45 | fileData.bytes,
46 | arbDir,
47 | );
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/lib/src/localizely/service/service_exception.dart:
--------------------------------------------------------------------------------
1 | class ServiceException implements Exception {
2 | final String message;
3 |
4 | ServiceException(this.message);
5 |
6 | @override
7 | String toString() => 'ServiceException: $message';
8 | }
9 |
--------------------------------------------------------------------------------
/lib/src/parser/icu_parser.dart:
--------------------------------------------------------------------------------
1 | // This file incorporates work covered by the following copyright and
2 | // permission notice:
3 | //
4 | // Copyright 2013, the Dart project authors. All rights reserved.
5 | // Redistribution and use in source and binary forms, with or without
6 | // modification, are permitted provided that the following conditions are
7 | // met:
8 | //
9 | // * Redistributions of source code must retain the above copyright
10 | // notice, this list of conditions and the following disclaimer.
11 | // * Redistributions in binary form must reproduce the above
12 | // copyright notice, this list of conditions and the following
13 | // disclaimer in the documentation and/or other materials provided
14 | // with the distribution.
15 | // * Neither the name of Google Inc. nor the names of its
16 | // contributors may be used to endorse or promote products derived
17 | // from this software without specific prior written permission.
18 | //
19 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
20 | // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
21 | // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
22 | // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
23 | // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
24 | // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
25 | // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
26 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
27 | // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
28 | // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29 | // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30 |
31 | import 'package:petitparser/petitparser.dart';
32 |
33 | import 'message_format.dart';
34 |
35 | class IcuParser {
36 | Parser get openCurly => char('{');
37 |
38 | Parser get closeCurly => char('}');
39 |
40 | Parser get quotedCurly => (string("'{'") | string("'}'")).map((x) => x[1]);
41 |
42 | Parser get icuEscapedText => quotedCurly | twoSingleQuotes;
43 |
44 | Parser get curly => (openCurly | closeCurly);
45 |
46 | Parser get notAllowedInIcuText => curly | char('<');
47 |
48 | Parser get icuText => notAllowedInIcuText.neg();
49 |
50 | Parser get notAllowedInNormalText => char('{');
51 |
52 | Parser get normalText => notAllowedInNormalText.neg();
53 |
54 | Parser get messageText => (icuEscapedText | icuText)
55 | .plus()
56 | .flatten()
57 | .map((result) => LiteralElement(result));
58 |
59 | Parser get nonIcuMessageText =>
60 | normalText.plus().flatten().map((result) => LiteralElement(result));
61 |
62 | Parser get twoSingleQuotes => string("''").map((x) => "'");
63 |
64 | Parser get number => digit().plus().flatten().trim().map(int.parse);
65 |
66 | Parser get id => (letter() & (word() | char('_')).star()).flatten().trim();
67 |
68 | Parser get comma => char(',').trim();
69 |
70 | /// Given a list of possible keywords, return a rule that accepts any of them.
71 | /// e.g., given ["male", "female", "other"], accept any of them.
72 | Parser asKeywords(List list) =>
73 | list.map(string).cast().reduce((a, b) => a | b).flatten().trim();
74 |
75 | Parser get pluralKeyword => asKeywords(
76 | ['=0', '=1', '=2', 'zero', 'one', 'two', 'few', 'many', 'other']);
77 |
78 | Parser get genderKeyword => asKeywords(['female', 'male', 'other']);
79 |
80 | var interiorText = undefined();
81 |
82 | Parser get preface => (openCurly & id & comma).map((values) => values[1]);
83 |
84 | Parser get pluralLiteral => string('plural');
85 |
86 | Parser get pluralClause => (pluralKeyword &
87 | openCurly &
88 | interiorText &
89 | closeCurly)
90 | .trim()
91 | .map((result) => Option(result[0],
92 | List.from(result[2] is List ? result[2] : [result[2]])));
93 |
94 | Parser get plural =>
95 | preface & pluralLiteral & comma & pluralClause.plus() & closeCurly;
96 |
97 | Parser get intlPlural => plural
98 | .map((result) => PluralElement(result[0], List.from(result[3])));
99 |
100 | Parser get selectLiteral => string('select');
101 |
102 | Parser get genderClause => (genderKeyword &
103 | openCurly &
104 | interiorText &
105 | closeCurly)
106 | .trim()
107 | .map((result) => Option(result[0],
108 | List.from(result[2] is List ? result[2] : [result[2]])));
109 |
110 | Parser get gender =>
111 | preface & selectLiteral & comma & genderClause.plus() & closeCurly;
112 |
113 | Parser get intlGender => gender
114 | .map((result) => GenderElement(result[0], List.from(result[3])));
115 |
116 | Parser get selectClause => (id & openCurly & interiorText & closeCurly)
117 | .trim()
118 | .map((result) => Option(result[0],
119 | List.from(result[2] is List ? result[2] : [result[2]])));
120 |
121 | Parser get generalSelect =>
122 | preface & selectLiteral & comma & selectClause.plus() & closeCurly;
123 |
124 | Parser get intlSelect => generalSelect
125 | .map((result) => SelectElement(result[0], List.from(result[3])));
126 |
127 | Parser get compound => (((parameter | nonIcuMessageText).plus() &
128 | pluralOrGenderOrSelect &
129 | (pluralOrGenderOrSelect | parameter | nonIcuMessageText).star()) |
130 | (pluralOrGenderOrSelect &
131 | (pluralOrGenderOrSelect | parameter | nonIcuMessageText).plus()))
132 | .map((result) => result.expand((x) => x is List ? x : [x]).toList());
133 |
134 | Parser get pluralOrGenderOrSelect => (intlPlural | intlGender | intlSelect);
135 |
136 | Parser get contents => pluralOrGenderOrSelect | parameter | messageText;
137 |
138 | Parser get simpleText =>
139 | (nonIcuMessageText | parameter | openCurly).plus().map((result) => result
140 | .map((item) => item is String ? LiteralElement(item) : item)
141 | .toList());
142 |
143 | Parser get empty => epsilon().map((_) => LiteralElement(''));
144 |
145 | Parser get parameter =>
146 | (openCurly & id & closeCurly).map((result) => ArgumentElement(result[1]));
147 |
148 | List? parse(String message) {
149 | var parsed = (compound | pluralOrGenderOrSelect | simpleText | empty)
150 | .map((result) =>
151 | List.from(result is List ? result : [result]))
152 | .parse(message);
153 | return parsed is Success ? parsed.value : null;
154 | }
155 |
156 | IcuParser() {
157 | // There is a cycle here, so we need the explicit set to avoid infinite recursion.
158 | interiorText.set(contents.plus() | empty);
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/lib/src/parser/message_format.dart:
--------------------------------------------------------------------------------
1 | enum ElementType { literal, argument, plural, gender, select }
2 |
3 | class BaseElement {
4 | ElementType type;
5 | String value;
6 |
7 | BaseElement(this.type, this.value);
8 | }
9 |
10 | class Option {
11 | String name;
12 | List value;
13 |
14 | Option(this.name, this.value);
15 | }
16 |
17 | class LiteralElement extends BaseElement {
18 | LiteralElement(String value) : super(ElementType.literal, value);
19 | }
20 |
21 | class ArgumentElement extends BaseElement {
22 | ArgumentElement(String value) : super(ElementType.argument, value);
23 | }
24 |
25 | class GenderElement extends BaseElement {
26 | List options;
27 |
28 | GenderElement(String value, this.options) : super(ElementType.gender, value);
29 | }
30 |
31 | class PluralElement extends BaseElement {
32 | List options;
33 |
34 | PluralElement(String value, this.options) : super(ElementType.plural, value);
35 | }
36 |
37 | class SelectElement extends BaseElement {
38 | List options;
39 |
40 | SelectElement(String value, this.options) : super(ElementType.select, value);
41 | }
42 |
--------------------------------------------------------------------------------
/lib/src/utils/file_utils.dart:
--------------------------------------------------------------------------------
1 | import 'dart:io';
2 | import 'dart:typed_data';
3 |
4 | import 'package:path/path.dart' as path;
5 |
6 | /// Gets the root directory path.
7 | String getRootDirectoryPath() => getRootDirectory().path;
8 |
9 | /// Gets the root directory.
10 | ///
11 | /// Note: The current working directory is assumed to be the root of a project.
12 | Directory getRootDirectory() => Directory.current;
13 |
14 | /// Gets the pubspec file.
15 | File? getPubspecFile() {
16 | var rootDirPath = getRootDirectoryPath();
17 | var pubspecFilePath = path.join(rootDirPath, 'pubspec.yaml');
18 | var pubspecFile = File(pubspecFilePath);
19 |
20 | return pubspecFile.existsSync() ? pubspecFile : null;
21 | }
22 |
23 | /// Gets arb file for the given locale.
24 | File? getArbFileForLocale(String locale, String arbDir) {
25 | var rootDirPath = getRootDirectoryPath();
26 | var arbFilePath = path.join(rootDirPath, arbDir, 'intl_$locale.arb');
27 | var arbFile = File(arbFilePath);
28 |
29 | return arbFile.existsSync() ? arbFile : null;
30 | }
31 |
32 | /// Creates arb file for the given locale.
33 | Future createArbFileForLocale(String locale, String arbDir) async {
34 | var rootDirPath = getRootDirectoryPath();
35 | var arbFilePath = path.join(rootDirPath, arbDir, 'intl_$locale.arb');
36 | var arbFile = File(arbFilePath);
37 |
38 | await arbFile.create(recursive: true);
39 | await arbFile.writeAsString('{}');
40 |
41 | return arbFile;
42 | }
43 |
44 | /// Gets all arb files in the project.
45 | List getArbFiles(String arbDir) {
46 | var l10nDirPath = path.join(getRootDirectoryPath(), arbDir);
47 | var arbFiles = Directory(l10nDirPath)
48 | .listSync()
49 | .where((file) =>
50 | path.basename(file.path).startsWith('intl_') &&
51 | path.basename(file.path).endsWith('.arb'))
52 | .toList();
53 |
54 | // arb files order is not the same on all operating systems (e.g. win, mac)
55 | arbFiles.sort((a, b) => a.path.compareTo(b.path));
56 |
57 | return arbFiles;
58 | }
59 |
60 | /// Gets all locales in the project.
61 | List getLocales(String arbDir) {
62 | var locales = getArbFiles(arbDir)
63 | .map((file) => path.basename(file.path))
64 | .map((fileName) =>
65 | fileName.substring('intl_'.length, fileName.length - '.arb'.length))
66 | .toList();
67 |
68 | return locales;
69 | }
70 |
71 | /// Updates arb file content.
72 | Future updateArbFile(
73 | String fileName, Uint8List bytes, String arbDir) async {
74 | var rootDirPath = getRootDirectoryPath();
75 | var arbFilePath = path.join(rootDirPath, arbDir, fileName);
76 | var arbFile = File(arbFilePath);
77 |
78 | if (!arbFile.existsSync()) {
79 | await arbFile.create();
80 | }
81 |
82 | await arbFile.writeAsBytes(bytes);
83 | }
84 |
85 | /// Gets l10n Dart file path.
86 | String getL10nDartFilePath(String outputDir) =>
87 | path.join(getRootDirectoryPath(), outputDir, 'l10n.dart');
88 |
89 | /// Updates l10n Dart file.
90 | Future updateL10nDartFile(String content, String outputDir) async {
91 | var l10nDartFilePath = getL10nDartFilePath(outputDir);
92 | var l10nDartFile = File(l10nDartFilePath);
93 |
94 | if (!l10nDartFile.existsSync()) {
95 | await l10nDartFile.create(recursive: true);
96 | }
97 |
98 | await l10nDartFile.writeAsString(content);
99 | }
100 |
101 | /// Gets intl directory path.
102 | String getIntlDirectoryPath(String outputDir) =>
103 | path.join(getRootDirectoryPath(), outputDir, 'intl');
104 |
105 | /// Gets intl directory.
106 | Directory? getIntlDirectory(String outputDir) {
107 | var intlDirPath = getIntlDirectoryPath(outputDir);
108 | var intlDir = Directory(intlDirPath);
109 |
110 | return intlDir.existsSync() ? intlDir : null;
111 | }
112 |
113 | /// Creates intl directory.
114 | Future createIntlDirectory(String outputDir) async {
115 | var intlDirPath = getIntlDirectoryPath(outputDir);
116 | var intlDir = Directory(intlDirPath);
117 |
118 | if (!intlDir.existsSync()) {
119 | await intlDir.create(recursive: true);
120 | }
121 |
122 | return intlDir;
123 | }
124 |
125 | /// Removes unused generated Dart files.
126 | Future removeUnusedGeneratedDartFiles(
127 | List locales, String outputDir) async {
128 | var intlDir = getIntlDirectory(outputDir);
129 | if (intlDir == null) {
130 | return;
131 | }
132 |
133 | var files = intlDir.listSync();
134 | for (var file in files) {
135 | var basename = path.basename(file.path);
136 | var substring = basename.substring(
137 | 'messages_'.length, basename.length - '.dart'.length);
138 |
139 | if (basename.startsWith('messages_') &&
140 | basename.endsWith('.dart') &&
141 | !['all', ...locales].contains(substring)) {
142 | await file.delete(recursive: true);
143 | }
144 | }
145 | }
146 |
147 | /// Gets Localizely credentials file path.
148 | String? getLocalizelyCredentialsFilePath() {
149 | var userHome = getUserHome();
150 | if (userHome == null) {
151 | return null;
152 | }
153 |
154 | return path.join(userHome, '.localizely', 'credentials.yaml');
155 | }
156 |
157 | /// Gets Localizely credentials file.
158 | File? getLocalizelyCredentialsFile() {
159 | var credentialsFilePath = getLocalizelyCredentialsFilePath();
160 | if (credentialsFilePath == null) {
161 | return null;
162 | }
163 |
164 | var credentialsFile = File(credentialsFilePath);
165 |
166 | return credentialsFile.existsSync() ? credentialsFile : null;
167 | }
168 |
169 | /// Gets the user home directory path.
170 | String? getUserHome() {
171 | if (Platform.isMacOS || Platform.isLinux) {
172 | return Platform.environment['HOME'];
173 | } else if (Platform.isWindows) {
174 | return Platform.environment['USERPROFILE'];
175 | } else {
176 | return null;
177 | }
178 | }
179 |
--------------------------------------------------------------------------------
/lib/src/utils/utils.dart:
--------------------------------------------------------------------------------
1 | import 'dart:convert' as convert;
2 | import 'dart:io';
3 |
4 | import 'package:dart_style/dart_style.dart' show DartFormatter;
5 |
6 | bool isValidClassName(String value) =>
7 | RegExp(r'^[A-Z][a-zA-Z0-9]*$').hasMatch(value);
8 |
9 | bool isValidLocale(String value) =>
10 | RegExp(r'^[a-z]{2,3}(_[A-Z][a-z]{3})?(_([A-Z]{2}|[0-9]{3}))?$')
11 | .hasMatch(value);
12 |
13 | bool isValidPath(String value) =>
14 | RegExp(r'^(?:[A-Za-z]:)?([\/\\]{0,2}\w*)+$').hasMatch(value);
15 |
16 | bool isValidDownloadEmptyAsParam(String value) =>
17 | RegExp(r'^(empty|main|skip)$').hasMatch(value);
18 |
19 | bool isLangScriptCountryLocale(String locale) =>
20 | RegExp(r'^[a-z]{2,3}_[A-Z][a-z]{3}_([A-Z]{2}|[0-9]{3})$').hasMatch(locale);
21 |
22 | bool isLangScriptLocale(String locale) =>
23 | RegExp(r'^[a-z]{2,3}_[A-Z][a-z]{3}$').hasMatch(locale);
24 |
25 | bool isLangCountryLocale(String locale) =>
26 | RegExp(r'^[a-z]{2,3}_([A-Z]{2}|[0-9]{3})$').hasMatch(locale);
27 |
28 | void info(String message) => stdout.writeln('INFO: $message');
29 |
30 | void warning(String message) => stdout.writeln('WARNING: $message');
31 |
32 | void error(String message) => stderr.writeln('ERROR: $message');
33 |
34 | void exitWithError(String message) {
35 | error(message);
36 | exit(2);
37 | }
38 |
39 | /// Convert to inline json message.
40 | String formatJsonMessage(String jsonMessage) {
41 | var decoded = convert.jsonDecode(jsonMessage);
42 | return convert.jsonEncode(decoded);
43 | }
44 |
45 | /// Formats Dart file content.
46 | String formatDartContent(String content, String fileName) {
47 | try {
48 | var formatter =
49 | DartFormatter(languageVersion: DartFormatter.latestLanguageVersion);
50 | return formatter.format(content);
51 | } catch (e) {
52 | info('Failed to format \'$fileName\' file.');
53 | return content;
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/pubspec.yaml:
--------------------------------------------------------------------------------
1 | name: intl_utils
2 | description: intl_utils is a dart library that generates Dart localization code from ARB file. Generated code relies on Intl library.
3 | version: 2.8.10
4 | homepage: https://github.com/localizely/intl_utils
5 |
6 | environment:
7 | sdk: ">=3.4.0 <4.0.0"
8 |
9 | dependencies:
10 | analyzer: ">=6.0.0 <8.0.0"
11 | archive: ">=3.0.0 <5.0.0"
12 | args: ^2.0.0
13 | dart_style: ^3.0.0
14 | http: ">=0.13.0 <2.0.0"
15 | intl: ">=0.17.0 <0.21.0"
16 | path: ^1.8.0
17 | petitparser: ">=4.0.0 <7.0.0"
18 | yaml: ^3.0.0
19 |
20 | dev_dependencies:
21 | build_runner: ^2.0.6
22 | lints: ^4.0.0
23 | mockito: ^5.4.3
24 | test: ^1.16.0
25 |
--------------------------------------------------------------------------------
/test/download_response_test.dart:
--------------------------------------------------------------------------------
1 | import 'dart:typed_data';
2 |
3 | import 'package:test/test.dart';
4 | import 'package:mockito/annotations.dart';
5 | import 'package:mockito/mockito.dart';
6 | import 'package:http/http.dart';
7 |
8 | import 'package:intl_utils/src/localizely/model/download_response.dart';
9 |
10 | import 'download_response_test.mocks.dart';
11 |
12 | Uint8List _getEmptyArbFileBytes() => Uint8List.fromList([123, 125]);
13 |
14 | Uint8List _getZippedEmptyArbFilesBytes() => Uint8List.fromList([
15 | 80,
16 | 75,
17 | 3,
18 | 4,
19 | 20,
20 | 0,
21 | 8,
22 | 8,
23 | 8,
24 | 0,
25 | 154,
26 | 90,
27 | 50,
28 | 81,
29 | 0,
30 | 0,
31 | 0,
32 | 0,
33 | 0,
34 | 0,
35 | 0,
36 | 0,
37 | 0,
38 | 0,
39 | 0,
40 | 0,
41 | 11,
42 | 0,
43 | 0,
44 | 0,
45 | 105,
46 | 110,
47 | 116,
48 | 108,
49 | 95,
50 | 100,
51 | 101,
52 | 46,
53 | 97,
54 | 114,
55 | 98,
56 | 171,
57 | 174,
58 | 5,
59 | 0,
60 | 80,
61 | 75,
62 | 7,
63 | 8,
64 | 67,
65 | 191,
66 | 166,
67 | 163,
68 | 4,
69 | 0,
70 | 0,
71 | 0,
72 | 2,
73 | 0,
74 | 0,
75 | 0,
76 | 80,
77 | 75,
78 | 3,
79 | 4,
80 | 20,
81 | 0,
82 | 8,
83 | 8,
84 | 8,
85 | 0,
86 | 154,
87 | 90,
88 | 50,
89 | 81,
90 | 0,
91 | 0,
92 | 0,
93 | 0,
94 | 0,
95 | 0,
96 | 0,
97 | 0,
98 | 0,
99 | 0,
100 | 0,
101 | 0,
102 | 11,
103 | 0,
104 | 0,
105 | 0,
106 | 105,
107 | 110,
108 | 116,
109 | 108,
110 | 95,
111 | 101,
112 | 110,
113 | 46,
114 | 97,
115 | 114,
116 | 98,
117 | 171,
118 | 174,
119 | 5,
120 | 0,
121 | 80,
122 | 75,
123 | 7,
124 | 8,
125 | 67,
126 | 191,
127 | 166,
128 | 163,
129 | 4,
130 | 0,
131 | 0,
132 | 0,
133 | 2,
134 | 0,
135 | 0,
136 | 0,
137 | 80,
138 | 75,
139 | 1,
140 | 2,
141 | 20,
142 | 0,
143 | 20,
144 | 0,
145 | 8,
146 | 8,
147 | 8,
148 | 0,
149 | 154,
150 | 90,
151 | 50,
152 | 81,
153 | 67,
154 | 191,
155 | 166,
156 | 163,
157 | 4,
158 | 0,
159 | 0,
160 | 0,
161 | 2,
162 | 0,
163 | 0,
164 | 0,
165 | 11,
166 | 0,
167 | 0,
168 | 0,
169 | 0,
170 | 0,
171 | 0,
172 | 0,
173 | 0,
174 | 0,
175 | 0,
176 | 0,
177 | 0,
178 | 0,
179 | 0,
180 | 0,
181 | 0,
182 | 0,
183 | 105,
184 | 110,
185 | 116,
186 | 108,
187 | 95,
188 | 100,
189 | 101,
190 | 46,
191 | 97,
192 | 114,
193 | 98,
194 | 80,
195 | 75,
196 | 1,
197 | 2,
198 | 20,
199 | 0,
200 | 20,
201 | 0,
202 | 8,
203 | 8,
204 | 8,
205 | 0,
206 | 154,
207 | 90,
208 | 50,
209 | 81,
210 | 67,
211 | 191,
212 | 166,
213 | 163,
214 | 4,
215 | 0,
216 | 0,
217 | 0,
218 | 2,
219 | 0,
220 | 0,
221 | 0,
222 | 11,
223 | 0,
224 | 0,
225 | 0,
226 | 0,
227 | 0,
228 | 0,
229 | 0,
230 | 0,
231 | 0,
232 | 0,
233 | 0,
234 | 0,
235 | 0,
236 | 61,
237 | 0,
238 | 0,
239 | 0,
240 | 105,
241 | 110,
242 | 116,
243 | 108,
244 | 95,
245 | 101,
246 | 110,
247 | 46,
248 | 97,
249 | 114,
250 | 98,
251 | 80,
252 | 75,
253 | 5,
254 | 6,
255 | 0,
256 | 0,
257 | 0,
258 | 0,
259 | 2,
260 | 0,
261 | 2,
262 | 0,
263 | 114,
264 | 0,
265 | 0,
266 | 0,
267 | 122,
268 | 0,
269 | 0,
270 | 0,
271 | 0,
272 | 0
273 | ]);
274 |
275 | @GenerateMocks([Response])
276 | void main() {
277 | group('Create an instance from a response with an arb file', () {
278 | test(
279 | 'Test instantiation with the http response, which contains an arb file for the en locale code',
280 | () {
281 | var mockedResponse = MockResponse();
282 | when(mockedResponse.headers).thenReturn(
283 | {'content-disposition': 'attachment; filename="intl_en.arb"'});
284 | when(mockedResponse.bodyBytes).thenReturn(_getEmptyArbFileBytes());
285 |
286 | var downloadResponse = DownloadResponse.fromResponse(mockedResponse);
287 |
288 | expect(downloadResponse.files.length, equals(1));
289 | expect(downloadResponse.files[0].name, equals('intl_en.arb'));
290 | });
291 |
292 | test(
293 | 'Test instantiation with the http response, which contains an arb file for the fr_FR locale code',
294 | () {
295 | var mockedResponse = MockResponse();
296 | when(mockedResponse.headers).thenReturn(
297 | {'content-disposition': 'attachment; filename="intl_fr_FR.arb"'});
298 | when(mockedResponse.bodyBytes).thenReturn(_getEmptyArbFileBytes());
299 |
300 | var downloadResponse = DownloadResponse.fromResponse(mockedResponse);
301 |
302 | expect(downloadResponse.files.length, equals(1));
303 | expect(downloadResponse.files[0].name, equals('intl_fr_FR.arb'));
304 | });
305 |
306 | test(
307 | 'Test instantiation with the http response, which contains an arb file for the zh_Hans locale code',
308 | () {
309 | var mockedResponse = MockResponse();
310 | when(mockedResponse.headers).thenReturn(
311 | {'content-disposition': 'attachment; filename="intl_zh_Hans.arb"'});
312 | when(mockedResponse.bodyBytes).thenReturn(_getEmptyArbFileBytes());
313 |
314 | var downloadResponse = DownloadResponse.fromResponse(mockedResponse);
315 |
316 | expect(downloadResponse.files.length, equals(1));
317 | expect(downloadResponse.files[0].name, equals('intl_zh_Hans.arb'));
318 | });
319 |
320 | test(
321 | 'Test instantiation with the http response, which contains an arb file for the zh_Hans_CN locale code',
322 | () {
323 | var mockedResponse = MockResponse();
324 | when(mockedResponse.headers).thenReturn({
325 | 'content-disposition': 'attachment; filename="intl_zh_Hans_CN.arb"'
326 | });
327 | when(mockedResponse.bodyBytes).thenReturn(_getEmptyArbFileBytes());
328 |
329 | var downloadResponse = DownloadResponse.fromResponse(mockedResponse);
330 |
331 | expect(downloadResponse.files.length, equals(1));
332 | expect(downloadResponse.files[0].name, equals('intl_zh_Hans_CN.arb'));
333 | });
334 | });
335 |
336 | group('Create an instance from a response with a zip file', () {
337 | test(
338 | 'Test instantiation with the http response, which contains a zip file with the simple name',
339 | () {
340 | var mockedResponse = MockResponse();
341 | when(mockedResponse.headers).thenReturn(
342 | {'content-disposition': 'attachment; filename="Project name.zip"'});
343 | when(mockedResponse.bodyBytes).thenReturn(_getZippedEmptyArbFilesBytes());
344 |
345 | var downloadResponse = DownloadResponse.fromResponse(mockedResponse);
346 |
347 | expect(downloadResponse.files.length, equals(2));
348 | expect(downloadResponse.files[0].name, equals('intl_de.arb'));
349 | expect(downloadResponse.files[1].name, equals('intl_en.arb'));
350 | });
351 |
352 | test(
353 | 'Test instantiation with the http response, which contains a zip file with the name with dashes',
354 | () {
355 | var mockedResponse = MockResponse();
356 | when(mockedResponse.headers).thenReturn({
357 | 'content-disposition':
358 | 'attachment; filename="Project-name-with-dashes.zip"'
359 | });
360 | when(mockedResponse.bodyBytes).thenReturn(_getZippedEmptyArbFilesBytes());
361 |
362 | var downloadResponse = DownloadResponse.fromResponse(mockedResponse);
363 |
364 | expect(downloadResponse.files.length, equals(2));
365 | expect(downloadResponse.files[0].name, equals('intl_de.arb'));
366 | expect(downloadResponse.files[1].name, equals('intl_en.arb'));
367 | });
368 |
369 | test(
370 | 'Test instantiation with the http response, which contains a zip file with the name with underscores',
371 | () {
372 | var mockedResponse = MockResponse();
373 | when(mockedResponse.headers).thenReturn({
374 | 'content-disposition':
375 | 'attachment; filename="Project_name_with_underscores.zip"'
376 | });
377 | when(mockedResponse.bodyBytes).thenReturn(_getZippedEmptyArbFilesBytes());
378 |
379 | var downloadResponse = DownloadResponse.fromResponse(mockedResponse);
380 |
381 | expect(downloadResponse.files.length, equals(2));
382 | expect(downloadResponse.files[0].name, equals('intl_de.arb'));
383 | expect(downloadResponse.files[1].name, equals('intl_en.arb'));
384 | });
385 |
386 | test(
387 | 'Test instantiation with the http response, which contains a zip file with the name with numbers',
388 | () {
389 | var mockedResponse = MockResponse();
390 | when(mockedResponse.headers).thenReturn(
391 | {'content-disposition': 'attachment; filename="0123456789.zip"'});
392 | when(mockedResponse.bodyBytes).thenReturn(_getZippedEmptyArbFilesBytes());
393 |
394 | var downloadResponse = DownloadResponse.fromResponse(mockedResponse);
395 |
396 | expect(downloadResponse.files.length, equals(2));
397 | expect(downloadResponse.files[0].name, equals('intl_de.arb'));
398 | expect(downloadResponse.files[1].name, equals('intl_en.arb'));
399 | });
400 |
401 | test(
402 | 'Test instantiation with the http response, which contains a zip file with the name with double quote signs',
403 | () {
404 | var mockedResponse = MockResponse();
405 | when(mockedResponse.headers).thenReturn(
406 | {'content-disposition': 'attachment; filename="Project "name".zip"'});
407 | when(mockedResponse.bodyBytes).thenReturn(_getZippedEmptyArbFilesBytes());
408 |
409 | var downloadResponse = DownloadResponse.fromResponse(mockedResponse);
410 |
411 | expect(downloadResponse.files.length, equals(2));
412 | expect(downloadResponse.files[0].name, equals('intl_de.arb'));
413 | expect(downloadResponse.files[1].name, equals('intl_en.arb'));
414 | });
415 |
416 | test(
417 | 'Test instantiation with the http response, which contains a zip file with the name with uncommon letters',
418 | () {
419 | var mockedResponse = MockResponse();
420 | when(mockedResponse.headers).thenReturn({
421 | 'content-disposition':
422 | 'attachment; filename="Nom du projet avec caractères spéciaux.zip"'
423 | });
424 | when(mockedResponse.bodyBytes).thenReturn(_getZippedEmptyArbFilesBytes());
425 |
426 | var downloadResponse = DownloadResponse.fromResponse(mockedResponse);
427 |
428 | expect(downloadResponse.files.length, equals(2));
429 | expect(downloadResponse.files[0].name, equals('intl_de.arb'));
430 | expect(downloadResponse.files[1].name, equals('intl_en.arb'));
431 | });
432 |
433 | test(
434 | 'Test instantiation with the http response, which contains a zip file with the name with special characters',
435 | () {
436 | var mockedResponse = MockResponse();
437 | when(mockedResponse.headers).thenReturn({
438 | 'content-disposition':
439 | 'attachment; filename="Project `~!@#\$%^&*()_+-=[]{}\'\\:"|,./<>?name.zip"'
440 | });
441 | when(mockedResponse.bodyBytes).thenReturn(_getZippedEmptyArbFilesBytes());
442 |
443 | var downloadResponse = DownloadResponse.fromResponse(mockedResponse);
444 |
445 | expect(downloadResponse.files.length, equals(2));
446 | expect(downloadResponse.files[0].name, equals('intl_de.arb'));
447 | expect(downloadResponse.files[1].name, equals('intl_en.arb'));
448 | });
449 | });
450 | }
451 |
--------------------------------------------------------------------------------
/test/download_response_test.mocks.dart:
--------------------------------------------------------------------------------
1 | // Mocks generated by Mockito 5.4.3 from annotations
2 | // in intl_utils/test/download_response_test.dart.
3 | // Do not manually edit this file.
4 |
5 | // ignore_for_file: no_leading_underscores_for_library_prefixes
6 | import 'dart:typed_data' as _i3;
7 |
8 | import 'package:http/src/response.dart' as _i2;
9 | import 'package:mockito/mockito.dart' as _i1;
10 | import 'package:mockito/src/dummies.dart' as _i4;
11 |
12 | // ignore_for_file: type=lint
13 | // ignore_for_file: avoid_redundant_argument_values
14 | // ignore_for_file: avoid_setters_without_getters
15 | // ignore_for_file: comment_references
16 | // ignore_for_file: deprecated_member_use
17 | // ignore_for_file: deprecated_member_use_from_same_package
18 | // ignore_for_file: implementation_imports
19 | // ignore_for_file: invalid_use_of_visible_for_testing_member
20 | // ignore_for_file: prefer_const_constructors
21 | // ignore_for_file: unnecessary_parenthesis
22 | // ignore_for_file: camel_case_types
23 | // ignore_for_file: subtype_of_sealed_class
24 |
25 | /// A class which mocks [Response].
26 | ///
27 | /// See the documentation for Mockito's code generation for more information.
28 | class MockResponse extends _i1.Mock implements _i2.Response {
29 | MockResponse() {
30 | _i1.throwOnMissingStub(this);
31 | }
32 |
33 | @override
34 | _i3.Uint8List get bodyBytes => (super.noSuchMethod(
35 | Invocation.getter(#bodyBytes),
36 | returnValue: _i3.Uint8List(0),
37 | ) as _i3.Uint8List);
38 | @override
39 | String get body => (super.noSuchMethod(
40 | Invocation.getter(#body),
41 | returnValue: _i4.dummyValue(
42 | this,
43 | Invocation.getter(#body),
44 | ),
45 | ) as String);
46 | @override
47 | int get statusCode => (super.noSuchMethod(
48 | Invocation.getter(#statusCode),
49 | returnValue: 0,
50 | ) as int);
51 | @override
52 | Map get headers => (super.noSuchMethod(
53 | Invocation.getter(#headers),
54 | returnValue: {},
55 | ) as Map);
56 | @override
57 | bool get isRedirect => (super.noSuchMethod(
58 | Invocation.getter(#isRedirect),
59 | returnValue: false,
60 | ) as bool);
61 | @override
62 | bool get persistentConnection => (super.noSuchMethod(
63 | Invocation.getter(#persistentConnection),
64 | returnValue: false,
65 | ) as bool);
66 | }
67 |
--------------------------------------------------------------------------------
/test/utils_test.dart:
--------------------------------------------------------------------------------
1 | import 'package:intl_utils/src/utils/utils.dart';
2 | import 'package:test/test.dart';
3 |
4 | void main() {
5 | group('Path validation', () {
6 | test('Test path validation with blank string',
7 | () => expect(isValidPath(' '), isFalse));
8 |
9 | test('Test path validation with forbidden path character *',
10 | () => expect(isValidPath('te*/lib'), isFalse));
11 |
12 | test('Test path validation with forbidden path character "',
13 | () => expect(isValidPath('te"/lib'), isFalse));
14 |
15 | test('Test path validation with forbidden path character ?',
16 | () => expect(isValidPath('te?/lib'), isFalse));
17 |
18 | test('Test path validation with empty string',
19 | () => expect(isValidPath(''), isTrue));
20 |
21 | test('Test path validation with Windows path',
22 | () => expect(isValidPath(r'lib\l10n'), isTrue));
23 |
24 | test('Test path validation with UNIX path',
25 | () => expect(isValidPath('lib/l10n'), isTrue));
26 |
27 | test('Test path validation with escaped path separators',
28 | () => expect(isValidPath('lib\\l10n'), isTrue));
29 |
30 | test('Test path validation with dual path separators',
31 | () => expect(isValidPath('lib//l10n'), isTrue));
32 |
33 | test('Test path validation with Windows absolute path',
34 | () => expect(isValidPath('C:\\dart\\l10n'), isTrue));
35 | });
36 |
37 | group('Download empty as param validation', () {
38 | test('Test download empty as param with empty string',
39 | () => expect(isValidDownloadEmptyAsParam(''), isFalse));
40 |
41 | test('Test download empty as param with blank string',
42 | () => expect(isValidDownloadEmptyAsParam(' '), isFalse));
43 |
44 | test('Test download empty as param with unsupported value',
45 | () => expect(isValidDownloadEmptyAsParam('unsupported'), isFalse));
46 |
47 | test('Test download empty as param with invalid empty value',
48 | () => expect(isValidDownloadEmptyAsParam('emty'), isFalse));
49 |
50 | test('Test download empty as param with empty value',
51 | () => expect(isValidDownloadEmptyAsParam('empty'), isTrue));
52 |
53 | test('Test download empty as param with invalid main value',
54 | () => expect(isValidDownloadEmptyAsParam('mmain'), isFalse));
55 |
56 | test('Test download empty as param with main value',
57 | () => expect(isValidDownloadEmptyAsParam('main'), isTrue));
58 |
59 | test('Test download empty as param with invalid skip value',
60 | () => expect(isValidDownloadEmptyAsParam('skipp'), isFalse));
61 |
62 | test('Test download empty as param with skip value',
63 | () => expect(isValidDownloadEmptyAsParam('skip'), isTrue));
64 | });
65 |
66 | group('Locale validation', () {
67 | test('Test locale validation with empty string',
68 | () => expect(isValidLocale(''), isFalse));
69 |
70 | test('Test locale validation with blank string',
71 | () => expect(isValidLocale(' '), isFalse));
72 |
73 | test('Test locale validation with plain text',
74 | () => expect(isValidLocale('some text'), isFalse));
75 |
76 | test(
77 | 'Test locale validation with invalid locale where language code is uppercased',
78 | () => expect(isValidLocale('FR'), isFalse));
79 |
80 | test(
81 | 'Test locale validation with invalid locale where language code is mixcased',
82 | () => expect(isValidLocale('eS'), isFalse));
83 |
84 | test(
85 | 'Test locale validation with invalid locale where language code contains special character',
86 | () => expect(isValidLocale('d#'), isFalse));
87 |
88 | test(
89 | 'Test locale validation with invalid locale where language code contains white space',
90 | () => expect(isValidLocale('d '), isFalse));
91 |
92 | test(
93 | 'Test locale validation with valid locale consisted only of language code',
94 | () => expect(isValidLocale('en'), isTrue));
95 |
96 | test(
97 | 'Test locale validation with valid locale where language code is consisted of three letters',
98 | () => expect(isValidLocale('fil'), isTrue));
99 |
100 | test(
101 | 'Test locale validation with invalid locale where language code is consisted of four letters',
102 | () => expect(isValidLocale('engb'), isFalse));
103 |
104 | test(
105 | 'Test locale validation with invalid locale where language and country codes are separated with dash',
106 | () => expect(isValidLocale('en-gb'), isFalse));
107 |
108 | test(
109 | 'Test locale validation with invalid locale where country code is lowercased',
110 | () => expect(isValidLocale('en_gb'), isFalse));
111 |
112 | test(
113 | 'Test locale validation with valid locale consisted of language and country codes',
114 | () => expect(isValidLocale('en_GB'), isTrue));
115 |
116 | test(
117 | 'Test locale validation with valid locale consisted of language and country codes where language code is consisted of three letters',
118 | () => expect(isValidLocale('fil_PH'), isTrue));
119 |
120 | test(
121 | 'Test locale validation with invalid locale where country code is consisted of three letters',
122 | () => expect(isValidLocale('en_GBR'), isFalse));
123 |
124 | test(
125 | 'Test locale validation with valid locale where country code is consisted of three digits (UN-M49)',
126 | () => expect(isValidLocale('es_419'), isTrue));
127 |
128 | test(
129 | 'Test locale validation with invalid locale where country code is consisted of four letters',
130 | () => expect(isValidLocale('en_UKGB'), isFalse));
131 |
132 | test(
133 | 'Test locale validation with invalid locale where script code is lowercased',
134 | () => expect(isValidLocale('zh_hans'), isFalse));
135 |
136 | test(
137 | 'Test locale validation with valid locale consisted of language and script codes',
138 | () => expect(isValidLocale('zh_Hans'), isTrue));
139 |
140 | test(
141 | 'Test locale validation with invalid locale where script code is lowercased and country code is provided',
142 | () => expect(isValidLocale('zh_hans_CN'), isFalse));
143 |
144 | test(
145 | 'Test locale validation with valid locale consisted of language, script and country codes',
146 | () => expect(isValidLocale('zh_Hans_CN'), isTrue));
147 | });
148 | }
149 |
--------------------------------------------------------------------------------