Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

6 Commits

Folders and files

Repository files navigation

arb_spans

flutter gen-l10n compiles every ARB message into a Dart method returning a String, with the concatenation baked into the generated source:

String termsNotice(String terms) {
  return 'By continuing you accept our $terms.';
}

That leaves no seam for rich text. To put a tappable link inside a translated sentence you would have to split the message into fragments — which breaks translation, because word order differs per language.

arb_spans reads the same ARB files and generates a second, rich-text API beside the first:

AppLocalizationSpans.of(context).termsNotice(terms: …)

It does not fork, patch, or replace flutter gen-l10n. Both generators run over the same files, from the same l10n.yaml.

A runnable demo is in example/.

Usage

Mark the placeholders that should carry rich text with "isSpan": true in your template ARB file:

{
  "termsNotice": "By continuing you accept our {terms}.",
  "@termsNotice": {
    "placeholders": {
      "terms": { "type": "String", "isSpan": true }
    }
  }
}

Translations need no changes — they just use {terms} wherever their grammar puts it.

Run the two generators in order:

flutter gen-l10n
dart run arb_spans

Then reach the rich-text messages through their own class, parallel to AppLocalizations.of(context):

import 'l10n/app_localizations_spans.dart';

Text.rich(
  AppLocalizationSpans.of(context).termsNotice(
    terms: TextSpan(
      text: 'the terms',
      style: TextStyle(decoration: TextDecoration.underline),
      recognizer: TapGestureRecognizer()..onTap = openTerms,
    ),
  ),
)

Method names are the ARB keys verbatim — termsNotice, not termsNoticeSpans — because they live on their own class rather than alongside the String methods. Only messages with a span placeholder appear on it, so typing AppLocalizationSpans.of(context). lists exactly the rich-text messages.

Important

A TapGestureRecognizer must be disposed. Own it from a State and dispose it in dispose(), rather than constructing one inside build.

Because the parameter is an InlineSpan, a WidgetSpan works too:

Text.rich(AppLocalizationSpans.of(context).errorServer(
  details: const WidgetSpan(child: Icon(Icons.error, size: 14)),
))

How it works

The generated class holds nothing but the resolved locale, and each method dispatches on it:

@immutable
class AppLocalizationSpans {
  const AppLocalizationSpans(this.localeName);

  static AppLocalizationSpans of(BuildContext context) =>
      AppLocalizationSpans(AppLocalizations.of(context)!.localeName);

  final String localeName;

  InlineSpan cancelAnytime({required InlineSpan settings}) {
    switch (localeName) {
      case 'de':
        return TextSpan(children: <InlineSpan>[
          const TextSpan(text: 'Du kannst dein Abo jederzeit in '),
          settings,
          const TextSpan(text: ' kündigen.'),
        ]);
    }
    return TextSpan(children: <InlineSpan>[
      const TextSpan(text: 'You can cancel your subscription at any time in '),
      settings,
      const TextSpan(text: '.'),
    ]);
  }
}

Reading the locale from gen-l10n is what keeps this small. The class hierarchy, the LocalizationsDelegate, the lookup function and the locale fallback are all gen-l10n's, and none of it is reproduced here. localeName is the locale of the ARB file gen-l10n resolved to, so switching on it needs no fallback logic of its own — and the template locale is the default branch, which also covers any locale whose ARB omits the message.

The whole coupling to gen-l10n is that one field.

Plurals and selects

These work, including when a branch contains a span. Intl.pluralLogic and Intl.selectLogic are already generic over their return type, so the branch is chosen as a List<InlineSpan> and spread:

return TextSpan(children: <InlineSpan>[
  ...intl.Intl.pluralLogic<List<InlineSpan>>(
    count,
    locale: localeName,
    one: <InlineSpan>[const TextSpan(text: 'One unread message in '), folder],
    other: <InlineSpan>[
      TextSpan(text: '$count'),
      const TextSpan(text: ' unread messages in '),
      folder,
    ],
  ),
]);

No changes to package:intl are required.

Formatting

Placeholders that are not marked isSpan keep their declared type and their formatting. A NumberFormat or DateFormat placeholder is formatted once, above the locale switch, exactly as gen-l10n formats it:

final amountString =
    intl.NumberFormat.currency(locale: localeName, symbol: '€').format(amount);

The conformance test

Two independent generators parse the same ICU messages, so in principle they could disagree about where literal text ends and a placeholder begins. Set span-conformance-test and arb_spans writes a test that pins one to the other:

# l10n.yaml
span-conformance-test: test/spans_conformance_test.dart

For every message, every locale, and every plural and select branch, it asserts that flattening the span tree yields exactly the string the gen-l10n method returns. If a Flutter upgrade ever changes gen-l10n's output, this test fails instead of your app quietly rendering the wrong text.

Configuration

arb_spans reads the same l10n.yaml as flutter gen-l10n, so arb-dir, template-arb-file, output-dir, output-localization-file, output-class and use-named-parameters are picked up automatically. Three keys are its own:

Key Default Meaning
output-spans-file gen-l10n's output name with a _spans suffix Name of the generated file.
spans-class output-class de-pluralised, plus Spans Name of the generated class. AppLocalizations gives AppLocalizationSpans.
span-conformance-test (none) Where to write the conformance test. Skipped if unset.

All are also available as command-line flags, which override l10n.yaml. Run dart run arb_spans --help for the full list.

Limitations

  • Placeholder metadata is read from the template ARB only. Per-locale placeholder overrides are ignored; translations are treated as plain ARB.
  • The inline {date, date, yMMMd} argument syntax is not supported. Declare the format on the placeholder instead — { "type": "DateTime", "format": "yMMMd" } — which is the form gen-l10n's own documentation recommends. Using the inline syntax produces an error naming the message.
  • use-deferred-loading is not honoured for span methods. The generated class is a single file covering every locale, so those messages are not deferred.
  • A placeholder used as a plural count, a select choice, or a formatted date/number cannot be a span; the generator rejects it with an error naming the ARB message.
  • Because message methods keep the ARB key verbatim, a key that collides with a member of the generated class (of, localeName, toString, hashCode, runtimeType, noSuchMethod) is rejected. Rename the message.

Provenance

lib/src/icu_parser.dart is vendored verbatim from dart-lang/i18n (pkgs/messages_builder), which is BSD licensed by the Dart project authors — see LICENSE and AUTHORS. It is excluded from analysis so it can be re-synced by copying the upstream file over it.

Everything else in lib/ is specific to span generation and is original to this project.

Note that this is not a fork of flutter gen-l10n: no part of the Flutter SDK is vendored here, and gen-l10n is neither patched nor replaced. The only thing the generated code assumes about gen-l10n's output is the localeName field on the generated localizations class.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages