Skip to content

Commit

Permalink
feat: l10n
Browse files Browse the repository at this point in the history
  • Loading branch information
Tienisto committed Oct 20, 2024
1 parent 8ada4d3 commit d275c9d
Show file tree
Hide file tree
Showing 28 changed files with 403 additions and 75 deletions.
53 changes: 53 additions & 0 deletions slang/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ dart run slang migrate arb src.arb dest.json # migrate arb to json
- [Pluralization](#-pluralization)
- [Custom Contexts / Enums](#-custom-contexts--enums)
- [Typed Parameters](#-typed-parameters)
- [L10n](#-l10n)
- [Interfaces](#-interfaces)
- [Modifiers](#-modifiers)
- [Locale Enum](#-locale-enum)
Expand Down Expand Up @@ -902,6 +903,58 @@ You can specify the type using the `name: type` syntax to increase type safety.
}
```

### ➤ L10n

To properly display numbers and dates,
Slang extends the [Typed Parameters](#-typed-parameters) feature
to support additional types like `currency`, `decimalPattern`, or `jm`.
Internally, it uses the `NumberFormat` and `DateFormat` classes from [intl](https://pub.dev/packages/intl).

```json
{
"greet": "Hello {name: String}, you have {amount: currency} in your account",
"today": "Today is {date: yMd}"
}
```

There are several built-in types:

| Long | Short | Example |
|--------------------------------------|-------------------------|-------------|
| `NumberFormat.compact` | `compact` | 1.2M |
| `NumberFormat.compactCurrency` | `compactCurrency` | $1.2M |
| `NumberFormat.compactSimpleCurrency` | `compactSimpleCurrency` | $1.2M |
| `NumberFormat.compactLong` | `compactLong` | 1.2 million |
| `NumberFormat.currency` | `currency` | $1.23 |
| `NumberFormat.decimalPattern` | `decimalPattern` | 1,234.56 |
| `NumberFormat.decimalPatternDigits` | `decimalPatternDigits` | 1,234.56 |
| `NumberFormat.decimalPercentPattern` | `decimalPercentPattern` | 12.34% |
| `NumberFormat.percentPattern` | `percentPattern` | 12.34% |
| `NumberFormat.scientificPattern` | `scientificPattern` | 1.23E6 |
| `NumberFormat.simpleCurrency` | `simpleCurrency` | $1.23 |
| `DateFormat.yM` | `yM` | 2023-12 |
| `DateFormat.yMd` | `yMd` | 2023-12-31 |
| `DateFormat.Hm` | `Hm` | 14:30 |
| `DateFormat.Hms` | `Hms` | 14:30:15 |
| `DateFormat.jm` | `jm` | 2:30 PM |
| `DateFormat.jms` | `jms` | 2:30:15 PM |

You can also provide custom formats:

```json
{
"today": "Today is {date: DateFormat('yyyy-MM-dd')}"
}
```

Or adjust built-in formats:

```json
{
"today": "Today is {date: currency(currency: 'EUR')}"
}
```

### ➤ Interfaces

Often, multiple objects have the same attributes. You can create a common super class for that.
Expand Down
7 changes: 6 additions & 1 deletion slang/lib/src/api/singleton.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'package:slang/src/builder/builder/translation_model_builder.dart';
import 'package:slang/src/builder/decoder/base_decoder.dart';
import 'package:slang/src/builder/model/build_model_config.dart';
import 'package:slang/src/builder/model/enums.dart';
import 'package:slang/src/builder/model/i18n_locale.dart';
import 'package:slang/src/builder/utils/map_utils.dart';
import 'package:slang/src/builder/utils/node_utils.dart';
import 'package:slang/src/builder/utils/regex_utils.dart';
Expand Down Expand Up @@ -227,7 +228,11 @@ extension AppLocaleUtilsExt<E extends BaseAppLocale<E, T>,
map: digestedMap,
handleLinks: false,
shouldEscapeText: false,
localeDebug: locale.languageTag,
locale: I18nLocale(
language: locale.languageCode,
script: locale.scriptCode,
country: locale.countryCode,
),
);
}
}
Expand Down
134 changes: 134 additions & 0 deletions slang/lib/src/builder/builder/text/l10n_parser.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import 'package:slang/src/builder/model/i18n_locale.dart';

class ParseL10nResult {
/// The actual parameter type.
/// Is [num] for [NumberFormat] and [DateTime] for [DateFormat].
final String paramType;

/// The format string that will be rendered as is.
final String format;

ParseL10nResult({
required this.paramType,
required this.format,
});
}

const _numberFormats = {
'compact',
'compactCurrency',
'compactSimpleCurrency',
'compactLong',
'currency',
'decimalPattern',
'decimalPatternDigits',
'decimalPercentPattern',
'percentPattern',
'scientificPattern',
'simpleCurrency',
};

const _numberFormatsWithNamedParameters = {
'NumberFormat.compactCurrency',
'NumberFormat.compactSimpleCurrency',
'NumberFormat.currency',
'NumberFormat.decimalPatternDigits',
'NumberFormat.decimalPercentPattern',
'NumberFormat.simpleCurrency',
};

final _numberFormatsWithClass = {
for (final format in _numberFormats) 'NumberFormat.$format',
'NumberFormat',
};

const _dateFormats = {
'yM',
'yMd',
'Hm',
'Hms',
'jm',
'jms',
};

final _dateFormatsWithClass = {
for (final format in _dateFormats) 'DateFormat.$format',
'DateFormat',
};

// Parses "currency(symbol: '€')"
// -> paramType: num, format: NumberFormat.currency(symbol: '€', locale: locale).format(value)
ParseL10nResult? parseL10n({
required I18nLocale locale,
required String paramName,
required String type,
}) {
final bracketStart = type.indexOf('(');

// The type without parameters.
// E.g. currency(symbol: '€') -> currency
final digestedType =
bracketStart == -1 ? type : type.substring(0, bracketStart);

final String paramType;
if (_numberFormats.contains(digestedType) ||
_numberFormatsWithClass.contains(digestedType)) {
paramType = 'num';
} else if (_dateFormats.contains(digestedType) ||
_dateFormatsWithClass.contains(digestedType)) {
paramType = 'DateTime';
} else {
return null;
}

// Extract method name and arguments
final bracketEnd = type.endsWith(')') ? type.lastIndexOf(')') : -1;

String methodName;
String arguments;

if (bracketStart != -1 && bracketEnd != -1 && bracketEnd > bracketStart) {
methodName = type.substring(0, bracketStart);
arguments = type.substring(bracketStart + 1, bracketEnd);
} else {
methodName = type;
arguments = '';
}

// Prepend class if necessary
if (!type.startsWith('NumberFormat(') &&
!type.startsWith('DateFormat(') &&
!methodName.startsWith('NumberFormat.') &&
!methodName.startsWith('DateFormat.')) {
if (paramType == 'num') {
methodName = 'NumberFormat.$methodName';
} else if (paramType == 'DateTime') {
methodName = 'DateFormat.$methodName';
}
}

// Add locale
if (paramType == 'num' &&
_numberFormatsWithNamedParameters.contains(methodName)) {
// add locale as named parameter
if (arguments.isEmpty) {
arguments = "locale: '${locale.underscoreTag}'";
} else {
arguments = "$arguments, locale: '${locale.underscoreTag}'";
}
} else {
// add locale as positional parameter
if (!arguments.contains('locale:')) {
if (arguments.isEmpty) {
arguments = "'${locale.underscoreTag}'";
} else {
arguments = "$arguments, '${locale.underscoreTag}'";
}
}
}

return ParseL10nResult(
paramType: paramType,
format: '$methodName($arguments).format($paramName)',
);
}
19 changes: 11 additions & 8 deletions slang/lib/src/builder/builder/translation_model_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:collection/collection.dart';
import 'package:slang/src/builder/model/build_model_config.dart';
import 'package:slang/src/builder/model/context_type.dart';
import 'package:slang/src/builder/model/enums.dart';
import 'package:slang/src/builder/model/i18n_locale.dart';
import 'package:slang/src/builder/model/interface.dart';
import 'package:slang/src/builder/model/node.dart';
import 'package:slang/src/builder/model/pluralization.dart';
Expand Down Expand Up @@ -41,12 +42,12 @@ class TranslationModelBuilder {
/// e.g. "Let's go" will be "Let's go" instead of "Let\'s go".
/// Similar to [handleLinks], this is used for "Translation Overrides".
static BuildModelResult build({
required I18nLocale locale,
required BuildModelConfig buildConfig,
required Map<String, dynamic> map,
BuildModelResult? baseData,
bool handleLinks = true,
bool shouldEscapeText = true,
required String localeDebug,
}) {
// flat map for leaves (TextNode, PluralNode, ContextNode)
final Map<String, LeafNode> leavesMap = {};
Expand Down Expand Up @@ -77,7 +78,7 @@ class TranslationModelBuilder {
// Assumption: They are basic linked translations without parameters
// Reason: Not all TextNodes are built, so final parameters are unknown
final resultNodeTree = _parseMapNode(
localeDebug: localeDebug,
locale: locale,
parentPath: '',
parentRawPath: '',
curr: map,
Expand Down Expand Up @@ -112,7 +113,7 @@ class TranslationModelBuilder {
final currLink = pathQueue.removeFirst();
final linkedNode = leavesMap[currLink];
if (linkedNode == null) {
throw '"$key" in <$localeDebug> is linked to "$currLink" but "$currLink" is undefined.';
throw '"$key" in <${locale.languageTag}> is linked to "$currLink" but "$currLink" is undefined.';
}

visitedLinks.add(currLink);
Expand Down Expand Up @@ -220,7 +221,7 @@ class TranslationModelBuilder {
/// Takes the [curr] map which is (a part of) the raw tree from json / yaml
/// and returns the node model.
Map<String, Node> _parseMapNode({
required String localeDebug,
required I18nLocale locale,
required String parentPath,
required String parentRawPath,
required Map<String, dynamic> curr,
Expand Down Expand Up @@ -265,6 +266,7 @@ Map<String, Node> _parseMapNode({
path: currPath,
rawPath: currRawPath,
modifiers: modifiers,
locale: locale,
raw: value.toString(),
comment: comment,
shouldEscape: shouldEscapeText,
Expand All @@ -275,6 +277,7 @@ Map<String, Node> _parseMapNode({
path: currPath,
rawPath: currRawPath,
modifiers: modifiers,
locale: locale,
raw: value.toString(),
comment: comment,
shouldEscape: shouldEscapeText,
Expand All @@ -293,7 +296,7 @@ Map<String, Node> _parseMapNode({
for (int i = 0; i < value.length; i++) i.toString(): value[i],
};
children = _parseMapNode(
localeDebug: localeDebug,
locale: locale,
parentPath: currPath,
parentRawPath: currRawPath,
curr: listAsMap,
Expand All @@ -319,7 +322,7 @@ Map<String, Node> _parseMapNode({
} else {
// key: { ...value }
children = _parseMapNode(
localeDebug: localeDebug,
locale: locale,
parentPath: currPath,
parentRawPath: currRawPath,
curr: value,
Expand Down Expand Up @@ -347,7 +350,7 @@ Map<String, Node> _parseMapNode({
if (children.isEmpty) {
switch (config.fallbackStrategy) {
case FallbackStrategy.none:
throw '"$currPath" in <$localeDebug> is empty but it is marked for pluralization / context. Define "fallback_strategy: base_locale" to ignore this node.';
throw '"$currPath" in <${locale.languageTag}> is empty but it is marked for pluralization / context. Define "fallback_strategy: base_locale" to ignore this node.';
case FallbackStrategy.baseLocale:
case FallbackStrategy.baseLocaleEmptyString:
return;
Expand Down Expand Up @@ -375,7 +378,7 @@ Map<String, Node> _parseMapNode({
if (rich) {
// rebuild children as RichText
digestedMap = _parseMapNode(
localeDebug: localeDebug,
locale: locale,
parentPath: currPath,
parentRawPath: currRawPath,
curr: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class TranslationModelListBuilder {
final baseResult = TranslationModelBuilder.build(
buildConfig: buildConfig,
map: rawConfig.namespaces ? namespaces : namespaces.values.first,
localeDebug: baseEntry.key.languageTag,
locale: baseEntry.key,
);

return translationMap.getInternalMap().entries.map((localeEntry) {
Expand All @@ -48,7 +48,7 @@ class TranslationModelListBuilder {
buildConfig: buildConfig,
map: rawConfig.namespaces ? namespaces : namespaces.values.first,
baseData: baseResult,
localeDebug: locale.languageTag,
locale: locale,
);

return I18nData(
Expand Down
1 change: 1 addition & 0 deletions slang/lib/src/builder/generator/generate_header.dart
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ void _generateImports(GenerateConfig config, StringBuffer buffer) {
buffer.writeln();
final imports = [
...config.imports,
'package:intl/intl.dart',
'package:slang/node.dart',
if (config.obfuscation.enabled) 'package:slang/secret.dart',
if (config.translationOverrides) 'package:slang/overrides.dart',
Expand Down
21 changes: 16 additions & 5 deletions slang/lib/src/builder/generator/generate_translations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ String generateTranslations(GenerateConfig config, I18nData localeData) {
final imports = [
config.outputFileName,
...config.imports,
'package:intl/intl.dart',
'package:slang/node.dart',
if (config.obfuscation.enabled) 'package:slang/secret.dart',
if (config.translationOverrides) 'package:slang/overrides.dart',
Expand Down Expand Up @@ -220,7 +221,7 @@ void _generateClass(

if (callSuperConstructor) {
buffer.write(
',\n\t\t super.build(cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver)');
',\n\t\t super(cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver)');
}

if (config.renderFlatMap) {
Expand Down Expand Up @@ -263,9 +264,13 @@ void _generateClass(
} else {
if (callSuperConstructor) {
buffer.writeln(
'\t$finalClassName._($rootClassName root) : this._root = root, super._(root);');
'\t$finalClassName._($rootClassName root) : this._root = root, super.internal(root);');
} else {
buffer.writeln('\t$finalClassName._(this._root);');
if (config.fallbackStrategy == GenerateFallbackStrategy.baseLocale) {
buffer.writeln('\t$finalClassName.internal(this._root);');
} else {
buffer.writeln('\t$finalClassName._(this._root);');
}
}
}

Expand Down Expand Up @@ -389,8 +394,14 @@ void _generateClass(
childName: key,
locale: localeData.locale,
);
buffer.writeln(
'late final $childClassWithLocale$optional $key = $childClassWithLocale._(_root);');

buffer.write('late final $childClassWithLocale$optional $key = ');
if (localeData.base &&
config.fallbackStrategy == GenerateFallbackStrategy.baseLocale) {
buffer.writeln('$childClassWithLocale.internal(_root);');
} else {
buffer.writeln('$childClassWithLocale._(_root);');
}
}
} else if (value is PluralNode) {
final returnType = value.rich ? 'TextSpan' : 'String';
Expand Down
Loading

0 comments on commit d275c9d

Please sign in to comment.