1 | // Copyright 2014 The Flutter Authors. All rights reserved. |
---|---|
2 | // Use of this source code is governed by a BSD-style license that can be |
3 | // found in the LICENSE file. |
4 | |
5 | import 'package:intl/locale.dart'; |
6 | |
7 | import '../base/common.dart'; |
8 | import '../base/file_system.dart'; |
9 | import '../base/logger.dart'; |
10 | import '../convert.dart'; |
11 | import 'localizations_utils.dart'; |
12 | import 'message_parser.dart'; |
13 | |
14 | // The set of date formats that can be automatically localized. |
15 | // |
16 | // The localizations generation tool makes use of the intl library's |
17 | // DateFormat class to properly format dates based on the locale, the |
18 | // desired format, as well as the passed in [DateTime]. For example, using |
19 | // DateFormat.yMMMMd("en_US").format(DateTime.utc(1996, 7, 10)) results |
20 | // in the string "July 10, 1996". |
21 | // |
22 | // Since the tool generates code that uses DateFormat's constructor and its |
23 | // add_* methods, it is necessary to verify that the constructor/method exists, |
24 | // or the tool will generate code that may cause a compile-time error. |
25 | // |
26 | // See also: |
27 | // |
28 | // * |
29 | // * |
30 | // * |
31 | const Set<String> validDateFormats = <String>{ |
32 | 'd', |
33 | 'E', |
34 | 'EEEE', |
35 | 'LLL', |
36 | 'LLLL', |
37 | 'M', |
38 | 'Md', |
39 | 'MEd', |
40 | 'MMM', |
41 | 'MMMd', |
42 | 'MMMEd', |
43 | 'MMMM', |
44 | 'MMMMd', |
45 | 'MMMMEEEEd', |
46 | 'QQQ', |
47 | 'QQQQ', |
48 | 'y', |
49 | 'yM', |
50 | 'yMd', |
51 | 'yMEd', |
52 | 'yMMM', |
53 | 'yMMMd', |
54 | 'yMMMEd', |
55 | 'yMMMM', |
56 | 'yMMMMd', |
57 | 'yMMMMEEEEd', |
58 | 'yQQQ', |
59 | 'yQQQQ', |
60 | 'H', |
61 | 'Hm', |
62 | 'Hms', |
63 | 'j', |
64 | 'jm', |
65 | 'jms', |
66 | 'jmv', |
67 | 'jmz', |
68 | 'jv', |
69 | 'jz', |
70 | 'm', |
71 | 'ms', |
72 | 's', |
73 | }; |
74 | |
75 | const String _dateFormatPartsDelimiter = '+'; |
76 | |
77 | // The set of number formats that can be automatically localized. |
78 | // |
79 | // The localizations generation tool makes use of the intl library's |
80 | // NumberFormat class to properly format numbers based on the locale and |
81 | // the desired format. For example, using |
82 | // NumberFormat.compactLong("en_US").format(1200000) results |
83 | // in the string "1.2 million". |
84 | // |
85 | // Since the tool generates code that uses NumberFormat's constructor, it is |
86 | // necessary to verify that the constructor exists, or the |
87 | // tool will generate code that may cause a compile-time error. |
88 | // |
89 | // See also: |
90 | // |
91 | // * |
92 | // * |
93 | const Set<String> _validNumberFormats = <String>{ |
94 | 'compact', |
95 | 'compactCurrency', |
96 | 'compactSimpleCurrency', |
97 | 'compactLong', |
98 | 'currency', |
99 | 'decimalPattern', |
100 | 'decimalPatternDigits', |
101 | 'decimalPercentPattern', |
102 | 'percentPattern', |
103 | 'scientificPattern', |
104 | 'simpleCurrency', |
105 | }; |
106 | |
107 | // The names of the NumberFormat factory constructors which have named |
108 | // parameters rather than positional parameters. |
109 | // |
110 | // This helps the tool correctly generate number formatting code correctly. |
111 | // |
112 | // Example of code that uses named parameters: |
113 | // final NumberFormat format = NumberFormat.compact( |
114 | // locale: localeName, |
115 | // ); |
116 | // |
117 | // Example of code that uses positional parameters: |
118 | // final NumberFormat format = NumberFormat.scientificPattern(localeName); |
119 | const Set<String> _numberFormatsWithNamedParameters = <String>{ |
120 | 'compact', |
121 | 'compactCurrency', |
122 | 'compactSimpleCurrency', |
123 | 'compactLong', |
124 | 'currency', |
125 | 'decimalPatternDigits', |
126 | 'decimalPercentPattern', |
127 | 'simpleCurrency', |
128 | }; |
129 | |
130 | class L10nException implements Exception { |
131 | L10nException(this.message); |
132 | |
133 | final String message; |
134 | |
135 | @override |
136 | String toString() => message; |
137 | } |
138 | |
139 | class L10nParserException extends L10nException { |
140 | L10nParserException( |
141 | this.error, |
142 | this.fileName, |
143 | this.messageId, |
144 | this.messageString, |
145 | this.charNumber, |
146 | ) : super(''' |
147 | [$fileName :$messageId ]$error |
148 | $messageString |
149 | ${List<String>.filled(charNumber, ' ').join()} ^'''); |
150 | |
151 | final String error; |
152 | final String fileName; |
153 | final String messageId; |
154 | final String messageString; |
155 | // Position of character within the "messageString" where the error is. |
156 | final int charNumber; |
157 | } |
158 | |
159 | class L10nMissingPlaceholderException extends L10nParserException { |
160 | L10nMissingPlaceholderException( |
161 | super.error, |
162 | super.fileName, |
163 | super.messageId, |
164 | super.messageString, |
165 | super.charNumber, |
166 | this.placeholderName, |
167 | ); |
168 | |
169 | final String placeholderName; |
170 | } |
171 | |
172 | // One optional named parameter to be used by a NumberFormat. |
173 | // |
174 | // Some of the NumberFormat factory constructors have optional named parameters. |
175 | // For example NumberFormat.compactCurrency has a decimalDigits parameter that |
176 | // specifies the number of decimal places to use when formatting. |
177 | // |
178 | // Optional parameters for NumberFormat placeholders are specified as a |
179 | // JSON map value for optionalParameters in a resource's "@" ARB file entry: |
180 | // |
181 | // "@myResourceId": { |
182 | // "placeholders": { |
183 | // "myNumberPlaceholder": { |
184 | // "type": "double", |
185 | // "format": "compactCurrency", |
186 | // "optionalParameters": { |
187 | // "decimalDigits": 2 |
188 | // } |
189 | // } |
190 | // } |
191 | // } |
192 | class OptionalParameter { |
193 | const OptionalParameter(this.name, this.value); |
194 | |
195 | final String name; |
196 | final Object value; |
197 | } |
198 | |
199 | // One message parameter: one placeholder from an @foo entry in the template ARB file. |
200 | // |
201 | // Placeholders are specified as a JSON map with one entry for each placeholder. |
202 | // One placeholder must be specified for each message "{parameter}". |
203 | // Each placeholder entry is also a JSON map. If the map is empty, the placeholder |
204 | // is assumed to be an Object value whose toString() value will be displayed. |
205 | // For example: |
206 | // |
207 | // "greeting": "{hello} {world}", |
208 | // "@greeting": { |
209 | // "description": "A message with a two parameters", |
210 | // "placeholders": { |
211 | // "hello": {}, |
212 | // "world": {} |
213 | // } |
214 | // } |
215 | // |
216 | // Each placeholder can optionally specify a valid Dart type. If the type |
217 | // is NumberFormat or DateFormat then a format which matches one of the |
218 | // type's factory constructors can also be specified. In this example the |
219 | // date placeholder is to be formatted with DateFormat.yMMMMd: |
220 | // |
221 | // "helloWorldOn": "Hello World on {date}", |
222 | // "@helloWorldOn": { |
223 | // "description": "A message with a date parameter", |
224 | // "placeholders": { |
225 | // "date": { |
226 | // "type": "DateTime", |
227 | // "format": "yMMMMd" |
228 | // } |
229 | // } |
230 | // } |
231 | // |
232 | class Placeholder { |
233 | Placeholder(this.resourceId, this.name, Map<String, Object?> attributes) |
234 | : example = _stringAttribute(resourceId, name, attributes, 'example'), |
235 | type = _stringAttribute(resourceId, name, attributes, 'type'), |
236 | format = _stringAttribute(resourceId, name, attributes, 'format'), |
237 | optionalParameters = _optionalParameters(resourceId, name, attributes), |
238 | isCustomDateFormat = _boolAttribute(resourceId, name, attributes, 'isCustomDateFormat'); |
239 | |
240 | final String resourceId; |
241 | final String name; |
242 | final String? example; |
243 | final String? format; |
244 | final List<OptionalParameter> optionalParameters; |
245 | final bool? isCustomDateFormat; |
246 | // The following will be initialized after all messages are parsed in the Message constructor. |
247 | String? type; |
248 | bool isPlural = false; |
249 | bool isSelect = false; |
250 | bool isDateTime = false; |
251 | bool requiresDateFormatting = false; |
252 | |
253 | bool get requiresFormatting => requiresDateFormatting || requiresNumFormatting; |
254 | bool get requiresNumFormatting => |
255 | <String>['int', 'num', 'double'].contains(type) && format != null; |
256 | bool get hasValidNumberFormat => _validNumberFormats.contains(format); |
257 | bool get hasNumberFormatWithParameters => _numberFormatsWithNamedParameters.contains(format); |
258 | // 'format' can contain a number of date time formats separated by `dateFormatPartsDelimiter`. |
259 | List<String> get dateFormatParts => format?.split(_dateFormatPartsDelimiter) ?? <String>[]; |
260 | bool get hasValidDateFormat => dateFormatParts.every(validDateFormats.contains); |
261 | |
262 | static String? _stringAttribute( |
263 | String resourceId, |
264 | String name, |
265 | Map<String, Object?> attributes, |
266 | String attributeName, |
267 | ) { |
268 | final Object? value = attributes[attributeName]; |
269 | if (value == null) { |
270 | return null; |
271 | } |
272 | if (value is! String || value.isEmpty) { |
273 | throw L10nException( |
274 | 'The "$attributeName " value of the "$name " placeholder in message$resourceId ' |
275 | 'must be a non-empty string.', |
276 | ); |
277 | } |
278 | return value; |
279 | } |
280 | |
281 | static bool? _boolAttribute( |
282 | String resourceId, |
283 | String name, |
284 | Map<String, Object?> attributes, |
285 | String attributeName, |
286 | ) { |
287 | final Object? value = attributes[attributeName]; |
288 | if (value == null) { |
289 | return null; |
290 | } |
291 | if (value is bool) { |
292 | return value; |
293 | } |
294 | if (value != 'true'&& value != 'false') { |
295 | throw L10nException( |
296 | 'The "$attributeName " value of the "$name " placeholder in message$resourceId ' |
297 | 'must be a boolean value.', |
298 | ); |
299 | } |
300 | return value == 'true'; |
301 | } |
302 | |
303 | static List<OptionalParameter> _optionalParameters( |
304 | String resourceId, |
305 | String name, |
306 | Map<String, Object?> attributes, |
307 | ) { |
308 | final Object? value = attributes['optionalParameters']; |
309 | if (value == null) { |
310 | return <OptionalParameter>[]; |
311 | } |
312 | if (value is! Map<String, Object?>) { |
313 | throw L10nException( |
314 | 'The "optionalParameters" value of the "$name " placeholder in message ' |
315 | '$resourceId is not a properly formatted Map. Ensure that it is a map ' |
316 | 'with keys that are strings.', |
317 | ); |
318 | } |
319 | final Map<String, Object?> optionalParameterMap = value; |
320 | return optionalParameterMap.keys.map<OptionalParameter>((String parameterName) { |
321 | return OptionalParameter(parameterName, optionalParameterMap[parameterName]!); |
322 | }).toList(); |
323 | } |
324 | } |
325 | |
326 | // All translations for a given message specified by a resource id. |
327 | // |
328 | // The template ARB file must contain an entry called @myResourceId for each |
329 | // message named myResourceId. The @ entry describes message parameters |
330 | // called "placeholders" and can include an optional description. |
331 | // Here's a simple example message with no parameters: |
332 | // |
333 | // "helloWorld": "Hello World", |
334 | // "@helloWorld": { |
335 | // "description": "The conventional newborn programmer greeting" |
336 | // } |
337 | // |
338 | // The value of this Message is "Hello World". The Message's value is the |
339 | // localized string to be shown for the template ARB file's locale. |
340 | // The docs for the Placeholder explain how placeholder entries are defined. |
341 | class Message { |
342 | Message( |
343 | AppResourceBundle templateBundle, |
344 | AppResourceBundleCollection allBundles, |
345 | this.resourceId, |
346 | bool isResourceAttributeRequired, { |
347 | this.useRelaxedSyntax = false, |
348 | this.useEscaping = false, |
349 | this.logger, |
350 | }) : assert(resourceId.isNotEmpty), |
351 | value = _value(templateBundle.resources, resourceId), |
352 | description = _description( |
353 | templateBundle.resources, |
354 | resourceId, |
355 | isResourceAttributeRequired, |
356 | ), |
357 | templatePlaceholders = _placeholders( |
358 | templateBundle.resources, |
359 | resourceId, |
360 | isResourceAttributeRequired, |
361 | ), |
362 | localePlaceholders = <LocaleInfo, Map<String, Placeholder>>{}, |
363 | messages = <LocaleInfo, String?>{}, |
364 | parsedMessages = <LocaleInfo, Node?>{} { |
365 | // Filenames for error handling. |
366 | final Map<LocaleInfo, String> filenames = <LocaleInfo, String>{}; |
367 | // Collect all translations from allBundles and parse them. |
368 | for (final AppResourceBundle bundle in allBundles.bundles) { |
369 | filenames[bundle.locale] = bundle.file.basename; |
370 | final String? translation = bundle.translationFor(resourceId); |
371 | messages[bundle.locale] = translation; |
372 | |
373 | localePlaceholders[bundle.locale] = |
374 | templateBundle.locale == bundle.locale |
375 | ? templatePlaceholders |
376 | : _placeholders(bundle.resources, resourceId, false); |
377 | |
378 | List<String>? validPlaceholders; |
379 | if (useRelaxedSyntax) { |
380 | validPlaceholders = |
381 | templatePlaceholders.entries.map((MapEntry<String, Placeholder> e) => e.key).toList(); |
382 | } |
383 | try { |
384 | parsedMessages[bundle.locale] = |
385 | translation == null |
386 | ? null |
387 | : Parser( |
388 | resourceId, |
389 | bundle.file.basename, |
390 | translation, |
391 | useEscaping: useEscaping, |
392 | placeholders: validPlaceholders, |
393 | logger: logger, |
394 | ).parse(); |
395 | } on L10nParserException catch (error) { |
396 | logger?.printError(error.toString()); |
397 | // Treat it as an untranslated message in case we can't parse. |
398 | parsedMessages[bundle.locale] = null; |
399 | hadErrors = true; |
400 | } |
401 | } |
402 | // Infer the placeholders |
403 | _inferPlaceholders(); |
404 | } |
405 | |
406 | final String resourceId; |
407 | final String value; |
408 | final String? description; |
409 | late final Map<LocaleInfo, String?> messages; |
410 | final Map<LocaleInfo, Node?> parsedMessages; |
411 | final Map<LocaleInfo, Map<String, Placeholder>> localePlaceholders; |
412 | final Map<String, Placeholder> templatePlaceholders; |
413 | final bool useEscaping; |
414 | final bool useRelaxedSyntax; |
415 | final Logger? logger; |
416 | bool hadErrors = false; |
417 | |
418 | Iterable<Placeholder> getPlaceholders(LocaleInfo locale) { |
419 | final Map<String, Placeholder>? placeholders = localePlaceholders[locale]; |
420 | if (placeholders == null) { |
421 | return templatePlaceholders.values; |
422 | } |
423 | return templatePlaceholders.values.map( |
424 | (Placeholder templatePlaceholder) => |
425 | placeholders[templatePlaceholder.name] ?? templatePlaceholder, |
426 | ); |
427 | } |
428 | |
429 | static String _value(Map<String, Object?> bundle, String resourceId) { |
430 | final Object? value = bundle[resourceId]; |
431 | if (value == null) { |
432 | throw L10nException('A value for resource "$resourceId " was not found.'); |
433 | } |
434 | if (value is! String) { |
435 | throw L10nException('The value of "$resourceId " is not a string.'); |
436 | } |
437 | return value; |
438 | } |
439 | |
440 | static Map<String, Object?>? _attributes( |
441 | Map<String, Object?> bundle, |
442 | String resourceId, |
443 | bool isResourceAttributeRequired, |
444 | ) { |
445 | final Object? attributes = bundle['@$resourceId ']; |
446 | if (isResourceAttributeRequired) { |
447 | if (attributes == null) { |
448 | throw L10nException( |
449 | 'Resource attribute "@$resourceId " was not found. Please ' |
450 | 'ensure that each resource has a corresponding @resource.', |
451 | ); |
452 | } |
453 | } |
454 | |
455 | if (attributes != null && attributes is! Map<String, Object?>) { |
456 | throw L10nException( |
457 | 'The resource attribute "@$resourceId " is not a properly formatted Map. ' |
458 | 'Ensure that it is a map with keys that are strings.', |
459 | ); |
460 | } |
461 | |
462 | return attributes as Map<String, Object?>?; |
463 | } |
464 | |
465 | static String? _description( |
466 | Map<String, Object?> bundle, |
467 | String resourceId, |
468 | bool isResourceAttributeRequired, |
469 | ) { |
470 | final Map<String, Object?>? resourceAttributes = _attributes( |
471 | bundle, |
472 | resourceId, |
473 | isResourceAttributeRequired, |
474 | ); |
475 | if (resourceAttributes == null) { |
476 | return null; |
477 | } |
478 | |
479 | final Object? value = resourceAttributes['description']; |
480 | if (value == null) { |
481 | return null; |
482 | } |
483 | if (value is! String) { |
484 | throw L10nException('The description for "@$resourceId " is not a properly formatted String.'); |
485 | } |
486 | return value; |
487 | } |
488 | |
489 | static Map<String, Placeholder> _placeholders( |
490 | Map<String, Object?> bundle, |
491 | String resourceId, |
492 | bool isResourceAttributeRequired, |
493 | ) { |
494 | final Map<String, Object?>? resourceAttributes = _attributes( |
495 | bundle, |
496 | resourceId, |
497 | isResourceAttributeRequired, |
498 | ); |
499 | if (resourceAttributes == null) { |
500 | return <String, Placeholder>{}; |
501 | } |
502 | final Object? allPlaceholdersMap = resourceAttributes['placeholders']; |
503 | if (allPlaceholdersMap == null) { |
504 | return <String, Placeholder>{}; |
505 | } |
506 | if (allPlaceholdersMap is! Map<String, Object?>) { |
507 | throw L10nException( |
508 | 'The "placeholders" attribute for message "$resourceId ", is not ' |
509 | 'properly formatted. Ensure that it is a map with string valued keys.', |
510 | ); |
511 | } |
512 | return Map<String, Placeholder>.fromEntries( |
513 | allPlaceholdersMap.keys.map((String placeholderName) { |
514 | final Object? value = allPlaceholdersMap[placeholderName]; |
515 | if (value is! Map<String, Object?>) { |
516 | throw L10nException( |
517 | 'The value of the "$placeholderName " placeholder attribute for message ' |
518 | '"$resourceId ", is not properly formatted. Ensure that it is a map ' |
519 | 'with string valued keys.', |
520 | ); |
521 | } |
522 | return MapEntry<String, Placeholder>( |
523 | placeholderName, |
524 | Placeholder(resourceId, placeholderName, value), |
525 | ); |
526 | }), |
527 | ); |
528 | } |
529 | |
530 | // Using parsed translations, attempt to infer types of placeholders used by plurals and selects. |
531 | // For undeclared placeholders, create a new placeholder. |
532 | void _inferPlaceholders() { |
533 | // We keep the undeclared placeholders separate so that we can sort them alphabetically afterwards. |
534 | final Map<String, Placeholder> undeclaredPlaceholders = <String, Placeholder>{}; |
535 | // Helper for getting placeholder by name. |
536 | for (final LocaleInfo locale in parsedMessages.keys) { |
537 | Placeholder? getPlaceholder(String name) => |
538 | localePlaceholders[locale]?[name] ?? |
539 | templatePlaceholders[name] ?? |
540 | undeclaredPlaceholders[name]; |
541 | if (parsedMessages[locale] == null) { |
542 | continue; |
543 | } |
544 | final List<Node> traversalStack = <Node>[parsedMessages[locale]!]; |
545 | while (traversalStack.isNotEmpty) { |
546 | final Node node = traversalStack.removeLast(); |
547 | if (<ST>[ |
548 | ST.placeholderExpr, |
549 | ST.pluralExpr, |
550 | ST.selectExpr, |
551 | ST.argumentExpr, |
552 | ].contains(node.type)) { |
553 | final String identifier = node.children[1].value!; |
554 | Placeholder? placeholder = getPlaceholder(identifier); |
555 | if (placeholder == null) { |
556 | placeholder = Placeholder(resourceId, identifier, <String, Object?>{}); |
557 | undeclaredPlaceholders[identifier] = placeholder; |
558 | } |
559 | if (node.type == ST.pluralExpr) { |
560 | placeholder.isPlural = true; |
561 | } else if (node.type == ST.selectExpr) { |
562 | placeholder.isSelect = true; |
563 | } else if (node.type == ST.argumentExpr) { |
564 | placeholder.isDateTime = true; |
565 | } else { |
566 | // Here the node type must be ST.placeholderExpr. |
567 | // A DateTime placeholder must require date formatting. |
568 | if (placeholder.type == 'DateTime') { |
569 | placeholder.requiresDateFormatting = true; |
570 | } |
571 | } |
572 | } |
573 | traversalStack.addAll(node.children); |
574 | } |
575 | } |
576 | templatePlaceholders.addEntries( |
577 | undeclaredPlaceholders.entries.toList()..sort( |
578 | (MapEntry<String, Placeholder> p1, MapEntry<String, Placeholder> p2) => |
579 | p1.key.compareTo(p2.key), |
580 | ), |
581 | ); |
582 | |
583 | bool atMostOneOf(bool x, bool y, bool z) { |
584 | return x && !y && !z || !x && y && !z || !x && !y && z || !x && !y && !z; |
585 | } |
586 | |
587 | for (final Placeholder placeholder in templatePlaceholders.values.followedBy( |
588 | localePlaceholders.values.expand((Map<String, Placeholder> e) => e.values), |
589 | )) { |
590 | if (!atMostOneOf(placeholder.isPlural, placeholder.isDateTime, placeholder.isSelect)) { |
591 | throw L10nException('Placeholder is used as plural/select/datetime in certain languages.'); |
592 | } else if (placeholder.isPlural) { |
593 | if (placeholder.type == null) { |
594 | placeholder.type = 'num'; |
595 | } else if (!<String>['num', 'int'].contains(placeholder.type)) { |
596 | throw L10nException("Placeholders used in plurals must be of type 'num' or 'int'"); |
597 | } |
598 | } else if (placeholder.isSelect) { |
599 | if (placeholder.type == null) { |
600 | placeholder.type = 'String'; |
601 | } else if (placeholder.type != 'String') { |
602 | throw L10nException("Placeholders used in selects must be of type 'String'"); |
603 | } |
604 | } else if (placeholder.isDateTime) { |
605 | if (placeholder.type == null) { |
606 | placeholder.type = 'DateTime'; |
607 | } else if (placeholder.type != 'DateTime') { |
608 | throw L10nException( |
609 | "Placeholders used in datetime expressions much be of type 'DateTime'", |
610 | ); |
611 | } |
612 | } |
613 | placeholder.type ??= 'Object'; |
614 | } |
615 | } |
616 | } |
617 | |
618 | /// Represents the contents of one ARB file. |
619 | class AppResourceBundle { |
620 | /// Assuming that the caller has verified that the file exists and is readable. |
621 | factory AppResourceBundle(File file) { |
622 | final Map<String, Object?> resources; |
623 | try { |
624 | final String content = file.readAsStringSync().trim(); |
625 | if (content.isEmpty) { |
626 | resources = <String, Object?>{}; |
627 | } else { |
628 | resources = json.decode(content) as Map<String, Object?>; |
629 | } |
630 | } on FormatException catch (e) { |
631 | throw L10nException( |
632 | 'The arb file${file.path} has the following formatting issue: \n' |
633 | '$e ', |
634 | ); |
635 | } |
636 | |
637 | String? localeString = resources['@@locale'] as String?; |
638 | |
639 | // Look for the first instance of an ISO 639-1 language code, matching exactly. |
640 | final String fileName = file.fileSystem.path.basenameWithoutExtension(file.path); |
641 | |
642 | for (int index = 0; index < fileName.length; index += 1) { |
643 | // If an underscore was found, check if locale string follows. |
644 | if (fileName[index] == '_') { |
645 | // If Locale.tryParse fails, it returns null. |
646 | final Locale? parserResult = Locale.tryParse(fileName.substring(index + 1)); |
647 | // If the parserResult is not an actual locale identifier, end the loop. |
648 | if (parserResult != null && _iso639Languages.contains(parserResult.languageCode)) { |
649 | // The parsed result uses dashes ('-'), but we want underscores ('_'). |
650 | final String parserLocaleString = parserResult.toString().replaceAll('-', '_'); |
651 | |
652 | if (localeString == null) { |
653 | // If @@locale was not defined, use the filename locale suffix. |
654 | localeString = parserLocaleString; |
655 | } else { |
656 | // If the localeString was defined in @@locale and in the filename, verify to |
657 | // see if the parsed locale matches, throw an error if it does not. This |
658 | // prevents developers from confusing issues when both @@locale and |
659 | // "_{locale}" is specified in the filename. |
660 | if (localeString != parserLocaleString) { |
661 | throw L10nException( |
662 | 'The locale specified in @@locale and the arb filename do not match. \n' |
663 | 'Please make sure that they match, since this prevents any confusion \n' |
664 | 'with which locale to use. Otherwise, specify the locale in either the \n' |
665 | 'filename or the @@locale key only.\n' |
666 | 'Current @@locale value:$localeString \n' |
667 | 'Current filename extension:$parserLocaleString ', |
668 | ); |
669 | } |
670 | } |
671 | break; |
672 | } |
673 | } |
674 | } |
675 | |
676 | if (localeString == null) { |
677 | throw L10nException( |
678 | "The following .arb file's locale could not be determined: \n" |
679 | '${file.path} \n' |
680 | "Make sure that the locale is specified in the file's '@@locale' " |
681 | 'property or as part of the filename (e.g. file_en.arb)', |
682 | ); |
683 | } |
684 | |
685 | final Iterable<String> ids = resources.keys.where((String key) => !key.startsWith('@')); |
686 | return AppResourceBundle._(file, LocaleInfo.fromString(localeString), resources, ids); |
687 | } |
688 | |
689 | const AppResourceBundle._(this.file, this.locale, this.resources, this.resourceIds); |
690 | |
691 | final File file; |
692 | final LocaleInfo locale; |
693 | |
694 | /// JSON representation of the contents of the ARB file. |
695 | final Map<String, Object?> resources; |
696 | final Iterable<String> resourceIds; |
697 | |
698 | String? translationFor(String resourceId) { |
699 | final Object? result = resources[resourceId]; |
700 | if (result is! String?) { |
701 | throwToolExit( |
702 | 'Localized message for key "$resourceId " in "${file.path} " ' |
703 | 'is not a string.', |
704 | ); |
705 | } |
706 | return result; |
707 | } |
708 | |
709 | @override |
710 | String toString() { |
711 | return 'AppResourceBundle($locale ,${file.path} )'; |
712 | } |
713 | } |
714 | |
715 | // Represents all of the ARB files in [directory] as [AppResourceBundle]s. |
716 | class AppResourceBundleCollection { |
717 | factory AppResourceBundleCollection(Directory directory) { |
718 | // Assuming that the caller has verified that the directory is readable. |
719 | |
720 | final RegExp filenameRE = RegExp(r'(\w+)\.arb$'); |
721 | final Map<LocaleInfo, AppResourceBundle> localeToBundle = <LocaleInfo, AppResourceBundle>{}; |
722 | final Map<String, List<LocaleInfo>> languageToLocales = <String, List<LocaleInfo>>{}; |
723 | // We require the list of files to be sorted so that |
724 | // "languageToLocales[bundle.locale.languageCode]" is not null |
725 | // by the time we handle locales with country codes. |
726 | final List<File> files = |
727 | directory |
728 | .listSync() |
729 | .whereType<File>() |
730 | .where((File e) => filenameRE.hasMatch(e.path)) |
731 | .toList() |
732 | ..sort(sortFilesByPath); |
733 | for (final File file in files) { |
734 | final AppResourceBundle bundle = AppResourceBundle(file); |
735 | if (localeToBundle[bundle.locale] != null) { |
736 | throw L10nException( |
737 | "Multiple arb files with the same '${bundle.locale} ' locale detected. \n" |
738 | 'Ensure that there is exactly one arb file for each locale.', |
739 | ); |
740 | } |
741 | localeToBundle[bundle.locale] = bundle; |
742 | languageToLocales[bundle.locale.languageCode] ??= <LocaleInfo>[]; |
743 | languageToLocales[bundle.locale.languageCode]!.add(bundle.locale); |
744 | } |
745 | |
746 | languageToLocales.forEach((String language, List<LocaleInfo> listOfCorrespondingLocales) { |
747 | final List<String> localeStrings = |
748 | listOfCorrespondingLocales.map((LocaleInfo locale) { |
749 | return locale.toString(); |
750 | }).toList(); |
751 | if (!localeStrings.contains(language)) { |
752 | throw L10nException( |
753 | 'Arb file for a fallback,$language , does not exist, even though \n' |
754 | 'the following locale(s) exist:$listOfCorrespondingLocales . \n' |
755 | 'When locales specify a script code or country code, a \n' |
756 | 'base locale (without the script code or country code) should \n' |
757 | 'exist as the fallback. Please create a {fileName}_$language .arb \n' |
758 | 'file.', |
759 | ); |
760 | } |
761 | }); |
762 | |
763 | return AppResourceBundleCollection._(directory, localeToBundle, languageToLocales); |
764 | } |
765 | |
766 | const AppResourceBundleCollection._( |
767 | this._directory, |
768 | this._localeToBundle, |
769 | this._languageToLocales, |
770 | ); |
771 | |
772 | final Directory _directory; |
773 | final Map<LocaleInfo, AppResourceBundle> _localeToBundle; |
774 | final Map<String, List<LocaleInfo>> _languageToLocales; |
775 | |
776 | Iterable<LocaleInfo> get locales => _localeToBundle.keys; |
777 | Iterable<AppResourceBundle> get bundles => _localeToBundle.values; |
778 | AppResourceBundle? bundleFor(LocaleInfo locale) => _localeToBundle[locale]; |
779 | |
780 | Iterable<String> get languages => _languageToLocales.keys; |
781 | Iterable<LocaleInfo> localesForLanguage(String language) => |
782 | _languageToLocales[language] ?? <LocaleInfo>[]; |
783 | |
784 | @override |
785 | String toString() { |
786 | return 'AppResourceBundleCollection(${_directory.path} ,${locales.length} locales)'; |
787 | } |
788 | } |
789 | |
790 | // A set containing all the ISO630-1 languages. This list was pulled from https://datahub.io/core/language-codes. |
791 | final Set<String> _iso639Languages = <String>{ |
792 | 'aa', |
793 | 'ab', |
794 | 'ae', |
795 | 'af', |
796 | 'ak', |
797 | 'am', |
798 | 'an', |
799 | 'ar', |
800 | 'as', |
801 | 'av', |
802 | 'ay', |
803 | 'az', |
804 | 'ba', |
805 | 'be', |
806 | 'bg', |
807 | 'bh', |
808 | 'bi', |
809 | 'bm', |
810 | 'bn', |
811 | 'bo', |
812 | 'br', |
813 | 'bs', |
814 | 'ca', |
815 | 'ce', |
816 | 'ch', |
817 | 'co', |
818 | 'cr', |
819 | 'cs', |
820 | 'cu', |
821 | 'cv', |
822 | 'cy', |
823 | 'da', |
824 | 'de', |
825 | 'dv', |
826 | 'dz', |
827 | 'ee', |
828 | 'el', |
829 | 'en', |
830 | 'eo', |
831 | 'es', |
832 | 'et', |
833 | 'eu', |
834 | 'fa', |
835 | 'ff', |
836 | 'fi', |
837 | 'fil', |
838 | 'fj', |
839 | 'fo', |
840 | 'fr', |
841 | 'fy', |
842 | 'ga', |
843 | 'gd', |
844 | 'gl', |
845 | 'gn', |
846 | 'gsw', |
847 | 'gu', |
848 | 'gv', |
849 | 'ha', |
850 | 'he', |
851 | 'hi', |
852 | 'ho', |
853 | 'hr', |
854 | 'ht', |
855 | 'hu', |
856 | 'hy', |
857 | 'hz', |
858 | 'ia', |
859 | 'id', |
860 | 'ie', |
861 | 'ig', |
862 | 'ii', |
863 | 'ik', |
864 | 'io', |
865 | 'is', |
866 | 'it', |
867 | 'iu', |
868 | 'ja', |
869 | 'jv', |
870 | 'ka', |
871 | 'kg', |
872 | 'ki', |
873 | 'kj', |
874 | 'kk', |
875 | 'kl', |
876 | 'km', |
877 | 'kn', |
878 | 'ko', |
879 | 'kr', |
880 | 'ks', |
881 | 'ku', |
882 | 'kv', |
883 | 'kw', |
884 | 'ky', |
885 | 'la', |
886 | 'lb', |
887 | 'lg', |
888 | 'li', |
889 | 'ln', |
890 | 'lo', |
891 | 'lt', |
892 | 'lu', |
893 | 'lv', |
894 | 'mg', |
895 | 'mh', |
896 | 'mi', |
897 | 'mk', |
898 | 'ml', |
899 | 'mn', |
900 | 'mr', |
901 | 'ms', |
902 | 'mt', |
903 | 'my', |
904 | 'na', |
905 | 'nb', |
906 | 'nd', |
907 | 'ne', |
908 | 'ng', |
909 | 'nl', |
910 | 'nn', |
911 | 'no', |
912 | 'nr', |
913 | 'nv', |
914 | 'ny', |
915 | 'oc', |
916 | 'oj', |
917 | 'om', |
918 | 'or', |
919 | 'os', |
920 | 'pa', |
921 | 'pi', |
922 | 'pl', |
923 | 'ps', |
924 | 'pt', |
925 | 'qu', |
926 | 'rm', |
927 | 'rn', |
928 | 'ro', |
929 | 'ru', |
930 | 'rw', |
931 | 'sa', |
932 | 'sc', |
933 | 'sd', |
934 | 'se', |
935 | 'sg', |
936 | 'si', |
937 | 'sk', |
938 | 'sl', |
939 | 'sm', |
940 | 'sn', |
941 | 'so', |
942 | 'sq', |
943 | 'sr', |
944 | 'ss', |
945 | 'st', |
946 | 'su', |
947 | 'sv', |
948 | 'sw', |
949 | 'ta', |
950 | 'te', |
951 | 'tg', |
952 | 'th', |
953 | 'ti', |
954 | 'tk', |
955 | 'tl', |
956 | 'tn', |
957 | 'to', |
958 | 'tr', |
959 | 'ts', |
960 | 'tt', |
961 | 'tw', |
962 | 'ty', |
963 | 'ug', |
964 | 'uk', |
965 | 'ur', |
966 | 'uz', |
967 | 've', |
968 | 'vi', |
969 | 'vo', |
970 | 'wa', |
971 | 'wo', |
972 | 'xh', |
973 | 'yi', |
974 | 'yo', |
975 | 'za', |
976 | 'zh', |
977 | 'zu', |
978 | }; |
979 |
Definitions
- validDateFormats
- _dateFormatPartsDelimiter
- _validNumberFormats
- _numberFormatsWithNamedParameters
- L10nException
- L10nException
- toString
- L10nParserException
- L10nParserException
- L10nMissingPlaceholderException
- L10nMissingPlaceholderException
- OptionalParameter
- OptionalParameter
- Placeholder
- Placeholder
- requiresFormatting
- requiresNumFormatting
- hasValidNumberFormat
- hasNumberFormatWithParameters
- dateFormatParts
- hasValidDateFormat
- _stringAttribute
- _boolAttribute
- _optionalParameters
- Message
- Message
- getPlaceholders
- _value
- _attributes
- _description
- _placeholders
- _inferPlaceholders
- getPlaceholder
- atMostOneOf
- AppResourceBundle
- AppResourceBundle
- _
- translationFor
- toString
- AppResourceBundleCollection
- AppResourceBundleCollection
- _
- locales
- bundles
- bundleFor
- languages
- localesForLanguage
- toString
Learn more about Flutter for embedded and desktop on industrialflutter.com