diff --git a/slang/CHANGELOG.md b/slang/CHANGELOG.md index 5331fe3..9686a18 100644 --- a/slang/CHANGELOG.md +++ b/slang/CHANGELOG.md @@ -1,3 +1,7 @@ +## 4.4.0 + +- feat: add `(fallback)` modifier to fallback entries within a map (#268) + ## 4.3.0 - feat: simplify file names without namespaces (#267) diff --git a/slang/README.md b/slang/README.md index 223a053..83c1b6d 100644 --- a/slang/README.md +++ b/slang/README.md @@ -1088,6 +1088,7 @@ Available Modifiers: |----------------------------|-----------------------------------------------|---------------------------------| | `(rich)` | This is a rich text. | Leaves, Maps (Plural / Context) | | `(map)` | This is a map / dictionary (and not a class). | Maps | +| `(fallback)` | Should fallback. `(map)` required. | Maps | | `(plural)` | This is a plural (type: cardinal) | Maps | | `(cardinal)` | This is a plural (type: cardinal) | Maps | | `(ordinal)` | This is a plural (type: ordinal) | Maps | @@ -1300,6 +1301,14 @@ By default, you must provide all translations for all locales. Otherwise, you ca In case of rapid development, you can turn off this feature. Missing translations will fall back to base locale. +The following configurations are available: + +| Fallback Strategy | Description | +|----------------------------|-------------------------------------------------------------------| +| `none` | Don't fallback (default). | +| `base_locale` | Fallback to the base locale. | +| `base_locale_empty_string` | Fallback to the base locale. Also treat empty strings as missing. | + ```yaml # Config base_locale: en @@ -1322,7 +1331,18 @@ fallback_strategy: base_locale # add this } ``` -To also treat empty strings as missing translations, set `fallback_strategy: base_locale_empty_string`. +By default, entries inside `(map)` are not affected by the fallback strategy. +This allows you to provide different map entries for each locale. +To still apply the fallback strategy to maps, add the `(fallback)` modifier. + +```json5 +{ + "myMap(map, fallback)": { + "someKey": "Some value", + // missing keys will fallback to the base locale + } +} +``` ### ➤ Lazy Loading diff --git a/slang/lib/src/builder/builder/translation_model_builder.dart b/slang/lib/src/builder/builder/translation_model_builder.dart index 40aa8d2..83ce095 100644 --- a/slang/lib/src/builder/builder/translation_model_builder.dart +++ b/slang/lib/src/builder/builder/translation_model_builder.dart @@ -28,6 +28,8 @@ class BuildModelResult { } class TranslationModelBuilder { + TranslationModelBuilder._(); + /// Builds the i18n model for ONE locale /// /// The [map] must be of type Map and all children may of type @@ -381,7 +383,7 @@ Map _parseMapNode({ : null; // key: { ...value } - children = _parseMapNode( + final tempChildren = _parseMapNode( locale: locale, types: types, parentPath: currPath, @@ -402,6 +404,19 @@ Map _parseMapNode({ sanitizeKey: detectedType == null, ); + if (detectedType?.nodeType == _DetectionType.map && + baseData != null && + modifiers.containsKey(NodeModifiers.fallback)) { + children = _digestMapEntries( + locale: locale, + baseTranslation: baseData.root, + path: currPath, + entries: tempChildren, + ); + } else { + children = tempChildren; + } + detectedType ??= _determineNodeType(config, currPath, modifiers, children); @@ -902,34 +917,54 @@ Map _digestContextEntries({ }) { // Using "late" keyword because we are optimistic that all values are present late ContextNode baseContextNode = - _findContextNode(baseTranslation, path.split('.')); + _findNode(baseTranslation, path.split('.')); return { for (final value in baseContext.enumValues) value: entries[value] ?? - baseContextNode.entries[value] ?? + baseContextNode.entries[value] + ?.clone(keepParent: false, locale: locale) ?? _throwError( 'In <${locale.languageTag}>, the value for $value in $path is missing (required by ${baseContext.enumName})', ), }; } +/// Makes sure that every map entry in [baseTranslation] is also present in [entries]. +/// If a value is missing, the base translation is used. +Map _digestMapEntries({ + required I18nLocale locale, + required ObjectNode baseTranslation, + required String path, + required Map entries, +}) { + // Using "late" keyword because we are optimistic that all values are present + late ObjectNode baseMapNode = + _findNode(baseTranslation, path.split('.')); + return { + for (final entry in baseMapNode.entries.entries) + entry.key: entries[entry.key] ?? + baseMapNode.entries[entry.key]! + .clone(keepParent: false, locale: locale), + }; +} + Never _throwError(String message) { throw message; } -/// Recursively find the [ContextNode] using the given [path]. -ContextNode _findContextNode(ObjectNode node, List path) { +/// Recursively find the [Node] using the given [path]. +T _findNode(ObjectNode node, List path) { final child = node.entries[path[0]]; if (path.length == 1) { - if (child is ContextNode) { + if (child is T) { return child; } else { - throw 'Parent node is not a ContextNode but a ${node.runtimeType} at path $path'; + throw 'Parent node is not a $T but a ${node.runtimeType} at path $path'; } } else if (child is ObjectNode) { - return _findContextNode(child, path.sublist(1)); + return _findNode(child, path.sublist(1)); } else { - throw 'Cannot find base ContextNode'; + throw 'Cannot find base $T'; } } diff --git a/slang/lib/src/builder/model/node.dart b/slang/lib/src/builder/model/node.dart index dd9329b..38903af 100644 --- a/slang/lib/src/builder/model/node.dart +++ b/slang/lib/src/builder/model/node.dart @@ -10,17 +10,44 @@ import 'package:slang/src/builder/utils/regex_utils.dart'; import 'package:slang/src/builder/utils/string_interpolation_extensions.dart'; class NodeModifiers { + /// Flag. Mark a node as rich text static const rich = 'rich'; + + /// Flag. Mark a node as a map static const map = 'map'; + + /// Flag. Missing keys should fallback to base map. + /// Only relevant when fallback_strategy is configured. + static const fallback = 'fallback'; + + /// Flag. Mark a plural as plural static const plural = 'plural'; + + /// Flag. Mark a plural as cardinal static const cardinal = 'cardinal'; + + /// Flag. Mark a plural as ordinal static const ordinal = 'ordinal'; + + /// Parameter. Set context name. static const context = 'context'; + + /// Parameter. Set a parameter name for a plural or context static const param = 'param'; + + /// Flag. Mark all children of an interface as descendants of an interface static const interface = 'interface'; + + /// Flag. Mark a single JSON object as a descendant of an interface static const singleInterface = 'singleInterface'; + + /// Analysis flag. Ignore during missing translation analysis static const ignoreMissing = 'ignoreMissing'; + + /// Analysis flag. Ignore during unused translation analysis static const ignoreUnused = 'ignoreUnused'; + + /// Analysis flag. Translation is outdated static const outdated = 'OUTDATED'; } @@ -47,13 +74,19 @@ abstract class Node { assert(_parent == null); _parent = parent; } + + /// Deep clones the node. + Node clone({ + required bool keepParent, + I18nLocale? locale, + }); } /// Flag for leaves -/// Leaves are: TextNode, PluralNode and ContextNode -abstract class LeafNode {} +/// Leaves are: [TextNode], [PluralNode] and [ContextNode] +abstract interface class LeafNode {} -/// the super class for list and object nodes +/// The super class for [ListNode] and [ObjectNode] abstract class IterableNode extends Node { /// The generic type of the container, i.e. Map or List String _genericType; @@ -104,6 +137,31 @@ class ObjectNode extends IterableNode { @override String toString() => entries.toString(); + + @override + ObjectNode clone({required bool keepParent, I18nLocale? locale}) { + final node = ObjectNode( + path: path, + rawPath: rawPath, + modifiers: modifiers, + comment: comment, + entries: entries.map( + (key, value) => MapEntry( + key, + value.clone(keepParent: keepParent, locale: locale), + ), + ), + isMap: isMap, + ); + if (keepParent && parent != null) { + node.setParent(parent!); + } + node.setGenericType(genericType); + if (interface != null) { + node.setInterface(interface!); + } + return node; + } } class ListNode extends IterableNode { @@ -122,6 +180,27 @@ class ListNode extends IterableNode { @override String toString() => entries.toString(); + + @override + ListNode clone({required bool keepParent, I18nLocale? locale}) { + final node = ListNode( + path: path, + rawPath: rawPath, + modifiers: modifiers, + comment: comment, + entries: entries + .map((e) => e.clone(keepParent: keepParent, locale: locale)) + .toList(), + ); + + if (keepParent && parent != null) { + node.setParent(parent!); + } + + node.setGenericType(genericType); + + return node; + } } enum PluralType { @@ -172,6 +251,32 @@ class PluralNode extends Node implements LeafNode { @override String toString() => quantities.toString(); + + @override + PluralNode clone({required bool keepParent, I18nLocale? locale}) { + final node = PluralNode( + path: path, + rawPath: rawPath, + modifiers: modifiers, + comment: comment, + pluralType: pluralType, + quantities: quantities.map( + (key, value) => MapEntry( + key, + value.clone(keepParent: keepParent, locale: locale), + ), + ), + paramName: paramName, + paramType: paramType, + rich: rich, + ); + + if (keepParent && parent != null) { + node.setParent(parent!); + } + + return node; + } } class ContextNode extends Node implements LeafNode { @@ -213,6 +318,31 @@ class ContextNode extends Node implements LeafNode { @override String toString() => entries.toString(); + + @override + ContextNode clone({required bool keepParent, I18nLocale? locale}) { + final node = ContextNode( + path: path, + rawPath: rawPath, + modifiers: modifiers, + comment: comment, + context: context, + entries: entries.map( + (key, value) => MapEntry( + key, + value.clone(keepParent: keepParent, locale: locale), + ), + ), + paramName: paramName, + rich: rich, + ); + + if (keepParent && parent != null) { + node.setParent(parent!); + } + + return node; + } } abstract class TextNode extends Node implements LeafNode { @@ -267,6 +397,12 @@ abstract class TextNode extends Node implements LeafNode { required Map> linkParamMap, required Map paramTypeMap, }); + + @override + TextNode clone({ + required bool keepParent, + I18nLocale? locale, + }); } class StringTextNode extends TextNode { @@ -365,6 +501,29 @@ class StringTextNode extends TextNode { return '$params => $content'; } } + + @override + StringTextNode clone({required bool keepParent, I18nLocale? locale}) { + final node = StringTextNode( + path: path, + rawPath: rawPath, + modifiers: modifiers, + locale: locale ?? this.locale, + types: types, + raw: raw, + comment: comment, + shouldEscape: shouldEscape, + handleTypes: handleTypes, + interpolation: interpolation, + paramCase: paramCase, + ); + + if (keepParent && parent != null) { + node.setParent(parent!); + } + + return node; + } } class RichTextNode extends TextNode { @@ -408,7 +567,8 @@ class RichTextNode extends TextNode { interpolation: interpolation, defaultType: 'ignored', // types are ignored - paramCase: null, // param case will be applied later + paramCase: null, + // param case will be applied later digestParameter: false, ); @@ -493,6 +653,29 @@ class RichTextNode extends TextNode { _spans = temp.spans; } + + @override + RichTextNode clone({required bool keepParent, I18nLocale? locale}) { + final node = RichTextNode( + path: path, + rawPath: rawPath, + modifiers: modifiers, + locale: locale ?? this.locale, + types: types, + raw: raw, + comment: comment, + shouldEscape: shouldEscape, + handleTypes: handleTypes, + interpolation: interpolation, + paramCase: paramCase, + ); + + if (keepParent && parent != null) { + node.setParent(parent!); + } + + return node; + } } String _escapeContent(String raw, StringInterpolation interpolation) { diff --git a/slang/test/unit/builder/translation_model_builder_test.dart b/slang/test/unit/builder/translation_model_builder_test.dart index afe8970..95d9d5d 100644 --- a/slang/test/unit/builder/translation_model_builder_test.dart +++ b/slang/test/unit/builder/translation_model_builder_test.dart @@ -11,19 +11,19 @@ import 'package:test/test.dart'; final _locale = I18nLocale(language: 'en'); void main() { - group('TranslationModelBuilder.build', () { - test('1 StringTextNode', () { - final result = TranslationModelBuilder.build( - buildConfig: RawConfig.defaultConfig.toBuildModelConfig(), - locale: _locale, - map: { - 'test': 'a', - }, - ); - final map = result.root.entries; - expect((map['test'] as StringTextNode).content, 'a'); - }); + test('1 StringTextNode', () { + final result = TranslationModelBuilder.build( + buildConfig: RawConfig.defaultConfig.toBuildModelConfig(), + locale: _locale, + map: { + 'test': 'a', + }, + ); + final map = result.root.entries; + expect((map['test'] as StringTextNode).content, 'a'); + }); + group('Recasing', () { test('keyCase=snake and keyMapCase=camel', () { final result = TranslationModelBuilder.build( buildConfig: RawConfig.defaultConfig.copyWith( @@ -54,36 +54,9 @@ void main() { final mapNode = result.root.entries['my_map'] as ObjectNode; expect((mapNode.entries['my_value 3'] as StringTextNode).content, 'cool'); }); + }); - test('Should sanitize reserved keyword', () { - final result = TranslationModelBuilder.build( - buildConfig: RawConfig.defaultConfig.toBuildModelConfig(), - locale: _locale, - map: { - 'continue': 'Continue', - }, - ); - - expect(result.root.entries['continue'], isNull); - expect(result.root.entries['kContinue'], isA()); - }); - - test('Should not sanitize keys in maps', () { - final result = TranslationModelBuilder.build( - buildConfig: RawConfig.defaultConfig.toBuildModelConfig(), - locale: _locale, - map: { - 'a(map)': { - 'continue': 'Continue', - }, - }, - ); - - final mapNode = result.root.entries['a'] as ObjectNode; - expect(mapNode.entries['continue'], isA()); - expect(mapNode.entries['kContinue'], isNull); - }); - + group('Linked Translations', () { test('one link no parameters', () { final result = TranslationModelBuilder.build( buildConfig: RawConfig.defaultConfig.toBuildModelConfig(), @@ -187,7 +160,29 @@ void main() { expect(textNode.paramTypeMap, {'p1': 'Object', 'gender': 'GenderCon'}); expect(textNode.content, r'Hello ${_root.a(p1: p1, gender: gender)}'); }); + }); + + group('Context Type', () { + test('Should not include context type if values are unspecified', () { + final result = TranslationModelBuilder.build( + buildConfig: RawConfig.defaultConfig.copyWith(contexts: [ + ContextType( + enumName: 'GenderCon', + defaultParameter: 'gender', + generateEnum: true, + ), + ]).toBuildModelConfig(), + locale: _locale, + map: { + 'a': 'b', + }, + ); + + expect(result.contexts, []); + }); + }); + group('Interface', () { test('empty lists should take generic type of interface', () { final result = TranslationModelBuilder.build( buildConfig: RawConfig.defaultConfig.copyWith(interfaces: [ @@ -238,24 +233,6 @@ void main() { (objectNode2.entries['myList'] as ListNode).genericType, 'MyType2'); }); - test('Should not include context type if values are unspecified', () { - final result = TranslationModelBuilder.build( - buildConfig: RawConfig.defaultConfig.copyWith(contexts: [ - ContextType( - enumName: 'GenderCon', - defaultParameter: 'gender', - generateEnum: true, - ), - ]).toBuildModelConfig(), - locale: _locale, - map: { - 'a': 'b', - }, - ); - - expect(result.contexts, []); - }); - test('Should handle nested interfaces specified via modifier', () { final resultUsingModifiers = TranslationModelBuilder.build( buildConfig: RawConfig.defaultConfig.toBuildModelConfig(), @@ -333,6 +310,202 @@ void main() { _checkInterfaceResult(resultUsingConfig); }); }); + + group('Sanitization', () { + test('Should sanitize reserved keyword', () { + final result = TranslationModelBuilder.build( + buildConfig: RawConfig.defaultConfig.toBuildModelConfig(), + locale: _locale, + map: { + 'continue': 'Continue', + }, + ); + + expect(result.root.entries['continue'], isNull); + expect(result.root.entries['kContinue'], isA()); + }); + + test('Should not sanitize keys in maps', () { + final result = TranslationModelBuilder.build( + buildConfig: RawConfig.defaultConfig.toBuildModelConfig(), + locale: _locale, + map: { + 'a(map)': { + 'continue': 'Continue', + }, + }, + ); + + final mapNode = result.root.entries['a'] as ObjectNode; + expect(mapNode.entries['continue'], isA()); + expect(mapNode.entries['kContinue'], isNull); + }); + }); + + group('Fallback', () { + test('Should fallback context type cases', () { + final result = TranslationModelBuilder.build( + buildConfig: RawConfig.defaultConfig + .copyWith( + fallbackStrategy: FallbackStrategy.baseLocale, + ) + .toBuildModelConfig(), + locale: _locale, + map: { + 'hello(context=GenderCon)': { + 'a': 'A', + 'c': 'C', + } + }, + baseData: TranslationModelBuilder.build( + buildConfig: RawConfig.defaultConfig.copyWith( + contexts: [ + ContextType( + enumName: 'GenderCon', + defaultParameter: 'default', + generateEnum: true, + ), + ], + ).toBuildModelConfig(), + locale: _locale, + map: { + 'hello(context=GenderCon)': { + 'a': 'Base A', + 'b': 'Base B', + 'c': 'Base C', + }, + }, + ), + ); + + final textNode = result.root.entries['hello'] as ContextNode; + expect(textNode.entries.length, 3); + expect( + textNode.entries.values.map((e) => (e as StringTextNode).content), + [ + 'A', + 'Base B', + 'C', + ], + ); + }); + + test('Should not fallback map entry by default', () { + final result = TranslationModelBuilder.build( + buildConfig: RawConfig.defaultConfig + .copyWith( + fallbackStrategy: FallbackStrategy.baseLocale, + ) + .toBuildModelConfig(), + locale: _locale, + map: { + 'myMap(map)': { + 'a': 'A', + 'c': 'C', + } + }, + baseData: TranslationModelBuilder.build( + buildConfig: RawConfig.defaultConfig.copyWith().toBuildModelConfig(), + locale: _locale, + map: { + 'myMap(map)': { + 'a': 'Base A', + 'b': 'Base B', + 'c': 'Base C', + }, + }, + ), + ); + + final textNode = result.root.entries['myMap'] as ObjectNode; + expect(textNode.entries.length, 2); + expect( + textNode.entries.values.map((e) => (e as StringTextNode).content), + [ + 'A', + 'C', + ], + ); + }); + + test('Should fallback map entry when fallback modifier is added', () { + final result = TranslationModelBuilder.build( + buildConfig: RawConfig.defaultConfig + .copyWith( + fallbackStrategy: FallbackStrategy.baseLocale, + ) + .toBuildModelConfig(), + locale: _locale, + map: { + 'myMap(map, fallback)': { + 'a': 'A', + 'c': 'C', + } + }, + baseData: TranslationModelBuilder.build( + buildConfig: RawConfig.defaultConfig.copyWith().toBuildModelConfig(), + locale: _locale, + map: { + 'myMap(map, fallback)': { + 'a': 'Base A', + 'b': 'Base B', + 'c': 'Base C', + }, + }, + ), + ); + + final textNode = result.root.entries['myMap'] as ObjectNode; + expect(textNode.entries.length, 3); + expect( + textNode.entries.values.map((e) => (e as StringTextNode).content), + [ + 'A', + 'Base B', + 'C', + ], + ); + }); + + test('Should respect base_locale_empty_string in fallback map', () { + final result = TranslationModelBuilder.build( + buildConfig: RawConfig.defaultConfig + .copyWith( + fallbackStrategy: FallbackStrategy.baseLocaleEmptyString, + ) + .toBuildModelConfig(), + locale: _locale, + map: { + 'myMap(map, fallback)': { + 'a': 'A', + 'c': '', + } + }, + baseData: TranslationModelBuilder.build( + buildConfig: RawConfig.defaultConfig.copyWith().toBuildModelConfig(), + locale: _locale, + map: { + 'myMap(map, fallback)': { + 'a': 'Base A', + 'b': 'Base B', + 'c': 'Base C', + }, + }, + ), + ); + + final textNode = result.root.entries['myMap'] as ObjectNode; + expect(textNode.entries.length, 3); + expect( + textNode.entries.values.map((e) => (e as StringTextNode).content), + [ + 'A', + 'Base B', + 'Base C', + ], + ); + }); + }); } void _checkInterfaceResult(BuildModelResult result) {