stringlocale 0.4.0
stringlocale: ^0.4.0 copied to clipboard
Declare strings with typed params, compile to offline locale bundles with an LLM, and resolve by locale at runtime. Axis-model i18n for Dart and Flutter.
stringlocale #
Take a single app string: "Welcome back, {name}! You have {count} new messages."
Shipping it correctly across five languages means dealing with all of this:
| Language | What your translators must maintain |
|---|---|
| Spanish | Gender agreement: Bienvenido (male) vs Bienvenida (female); mensaje vs mensajes |
| French | Gender inflection in the greeting; message vs messages |
| Hindi | Subject gender changes verb endings throughout the sentence |
| Arabic | Six plural categories for count (zero, one, two, few, many, other) plus Eastern Arabic digits |
| Japanese | No plurals, but formality register shapes every word choice |
For one string across five languages with gender × plural, that is roughly 50 hand-maintained translation keys — all of which break silently when you rename {count} to {unread}.
stringlocale replaces all of that. Declare the string once in Dart, tag each {placeholder} with what it is, and run the compiler:
final welcome = StringLocale(
'Welcome back, {name}! You have {count} new messages.',
id: 'welcome',
params: {
'name': Param.literal(),
'count': Param.plural(),
},
gendered: true,
);
dart run stringlocale compile \
--locales es-ES,fr-FR,hi-IN,ja-JP,ar-SA \
--source-locale en-US \
--out assets/i18n
The compiler uses an LLM to generate every gender × plural variant per locale into static JSON. At runtime, resolving is a pure offline lookup — no API call, no network:
welcome.resolve(args: {'name': 'Sofia', 'count': 3, 'gender': 'female'})
// Spanish → "¡Bienvenida, Sofia! Tienes 3 mensajes nuevos."
// Arabic → "مرحباً بعودتك، Sofia! لديك ٣ رسائل جديدة."
// Hindi → "वापसी पर स्वागत है, Sofia! आपके 3 नए संदेश हैं।"
This package is the Dart/Flutter runtime plus a CLI: compile, check, and prune.
Quick Start #
1. Add the dependency
dependencies:
stringlocale: ^0.3.0
2. Import
import 'package:stringlocale/stringlocale.dart'; // runtime and declarations
import 'package:stringlocale/flutter.dart'; // Flutter widgets (optional)
import 'package:stringlocale/compile.dart'; // compiler API and build scripts
3. Declare a string
// lib/strings.dart
import 'package:stringlocale/stringlocale.dart';
final inbox = StringLocale(
'You have {count} messages',
id: 'inbox',
params: {'count': Param.plural()},
);
// Force lazy top-level finals to initialize; return value is the string count.
int registerAll() => [inbox].length;
4. Compile to your target locales
Set your LLM API key, or use --drafter offline to skip the LLM and emit placeholder bundles. stringlocale works with any OpenAI-compatible /chat/completions endpoint (OpenRouter, OpenAI, Groq, Together, a local Ollama/LM Studio server, etc.):
# Default endpoint is OpenRouter; point STRINGLOCALE_BASE_URL elsewhere for any
# other OpenAI-compatible provider.
export STRINGLOCALE_API_KEY=sk-...
# export STRINGLOCALE_BASE_URL=https://api.openai.com/v1/chat/completions
# export STRINGLOCALE_MODEL=gpt-4o-mini
dart run stringlocale compile \
--locales en-US,es-ES,fr-FR,ar-SA \
--source-locale en-US \
--out assets/i18n
This writes assets/i18n/manifest.json plus one bundle.<locale>.json per locale. Re-running only redrafts cells whose source text or locale changed.
5. Resolve at runtime
import 'package:stringlocale/stringlocale.dart';
import 'strings.dart';
void main() {
registerAll();
final bundle = Bundle.fromDir('assets/i18n', ioFileReader);
useBundle(bundle, locale: 'es-ES');
print(inbox.resolve(args: {'count': 5}));
// → "Tienes 5 mensajes."
}
In Flutter, wrap the app in StringLocaleScope and use Tr or tr(context, ...). See Resolving.
6. Keep bundles in sync as code changes
dart run stringlocale check --out assets/i18n # CI gate — exits non-zero on drift
dart run stringlocale prune --out assets/i18n --dry-run
dart run stringlocale prune --out assets/i18n
Declaring Strings #
Each StringLocale has a stable id, the source text, and a Param per {placeholder}. Placeholders are validated against params at construction time — mismatches throw at startup, not silently at runtime.
Dart top-level final values are lazy, so nothing is constructed until first access. Keep all declarations in a list and call a registration function before compiling or resolving to force construction:
// lib/strings.dart
import 'package:stringlocale/stringlocale.dart';
final followers = StringLocale(
'{n} followers',
id: 'followers',
params: {'n': Param.number()},
);
final inbox = StringLocale(
'You have {count} messages',
id: 'inbox',
params: {'count': Param.plural()},
);
final fee = StringLocale(
'{creator} charges {amount} per post',
id: 'fee',
params: {
'creator': Param.literal(),
'amount': Param.currency('NPR'),
},
);
final greeting = StringLocale(
'{name}, your account is ready',
id: 'greeting',
params: {'name': Param.literal()},
gendered: true,
);
final allStrings = [followers, inbox, fee, greeting];
int registerAll() => allStrings.length;
You do not write translation keys, per-locale JSON, plural tables, or formatting glue. The params carry enough structure for the compiler to draft the right cells and for the runtime to format values correctly.
CLI Reference: Compile, Check, Prune #
compile #
Imports your Dart strings file, calls your registration function, drafts a translation for each cell across the target locales, and writes the bundles.
dart run stringlocale compile \
--input lib/strings.dart \
--register registerAll \
--source-locale en-US \
--locales ne-NP,nl-NL,ar-SA \
--out assets/i18n
| Flag | Default | Meaning |
|---|---|---|
--input <path> |
lib/strings.dart |
Dart file that declares strings |
--register <name> |
registerAll |
Function called after import to construct lazy declarations |
--no-register |
off | Import the input file but skip a register function |
--locales <codes> |
required | Comma-separated locale list, preferably full tags like ne-NP |
--out <dir> |
dist |
Output directory for split bundles |
--combined <path> |
off | Emit one combined bundle file instead of split per-locale files |
--source-locale <code> |
en |
Locale of the source strings |
--drafter <mode> |
auto |
auto, offline, llm, or openrouter |
--model <id> |
google/gemini-2.5-flash |
LLM model id |
--base-url <url> |
OpenRouter | OpenAI-compatible /chat/completions endpoint |
--force |
off | Re-draft every cell even when incremental hashes match |
--quiet |
off | Hide progress messages except final output |
Translator. With STRINGLOCALE_API_KEY (or OPENROUTER_API_KEY) set, --drafter auto uses the configured LLM via LlmDrafter. Without a key, it uses OfflineDrafter, which emits deterministic placeholders such as ne:You have {count} messages. You can force either behavior with --drafter llm (or --drafter openrouter) or --drafter offline. Point at any OpenAI-compatible API with --base-url or STRINGLOCALE_BASE_URL.
Model tracking. Generated bundles record the drafter and model that produced them:
{
"translation": {
"drafter": "llm",
"model": "google/gemini-2.5-flash"
}
}
The model is also part of each cell's incremental hash, so the same model reuses unchanged translations, while choosing a different model drafts those cells again.
Output. Split output writes a manifest plus one file per locale:
assets/i18n/
manifest.json
bundle.ne-NP.json
bundle.nl-NL.json
bundle.ar-SA.json
Combined output writes one file:
dart run stringlocale compile \
--locales en-US,ne-NP,nl-NL \
--source-locale en-US \
--combined assets/i18n/bundle.json
During compilation the CLI prints the string count, target locales, drafter mode, model when supplied, and each locale as it starts.
check #
Imports the current strings, reads the compiled bundle, and reports drift. It exits non-zero on problems, so it fits CI.
dart run stringlocale check \
--input lib/strings.dart \
--register registerAll \
--out assets/i18n
It reports:
- missing: id declared in code but absent from the bundle
- orphaned: id in the bundle but no longer declared in code
- stale: recorded source text differs from current source text
- placeholder drift: translated templates dropped or invented placeholders
- untranslated warnings: non-source cells identical to the source text
prune #
Removes orphaned entries, meaning ids in the bundle that no longer exist in code, without redrafting anything.
dart run stringlocale prune --out assets/i18n --dry-run
dart run stringlocale prune --out assets/i18n
Use --dry-run first to preview removals.
Resolving #
Load bundles once at startup, then resolve strings anywhere. No LLM or network call happens at resolve time. It is a pure lookup with numbers, dates, currency, relative values, enum labels, plural categories, and digit conversion handled offline.
Flutter #
The Flutter widget this package provides is Tr — it is unrelated to Flutter's own Text widget and the StringLocale model class does not conflict with any Flutter type.
Declare your compiled bundles as assets in your app's pubspec.yaml:
flutter:
assets:
- assets/i18n/
Load the generated split bundles from assets:
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:stringlocale/flutter.dart';
Future<Bundle> loadBundleFromAssets(String assetDir) async {
final manifest = jsonDecode(
await rootBundle.loadString('$assetDir/manifest.json'),
) as Map<String, dynamic>;
final files = manifest['files'] as Map;
Bundle? bundle;
for (final entry in files.values.cast<Map>()) {
final data = jsonDecode(
await rootBundle.loadString('$assetDir/${entry['path']}'),
) as Map<String, dynamic>;
if (bundle == null) {
bundle = Bundle(data);
} else {
bundle.merge(data);
}
}
if (bundle == null) throw StateError('No stringlocale bundles found');
return bundle;
}
Wrap your app and render strings with Tr:
import 'package:flutter/material.dart';
import 'package:stringlocale/flutter.dart';
import 'strings.dart';
class App extends StatelessWidget {
const App({super.key, required this.bundle});
final Bundle bundle;
@override
Widget build(BuildContext context) {
return StringLocaleScope(
locale: 'ne-NP',
bundle: bundle,
child: Builder(
builder: (context) => Column(
children: [
Tr(greeting, args: {'name': 'Anisha', 'gender': 'female'}),
Tr(inbox, args: {'count': 5}),
FilledButton(
onPressed: () {
final label = tr(context, fee, args: {
'creator': 'Anisha',
'amount': 2500,
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(label)),
);
},
child: const Text('Show'),
),
],
),
),
);
}
}
Switch locale with the scope controller:
StringLocaleScope.of(context).setLocale('fr-FR');
The package includes a runnable Flutter sample with locale chips, Tr, imperative tr(context, ...), compiled asset bundles, and a macOS runner:
cd example
flutter run -d macos
Plain Dart #
import 'package:stringlocale/stringlocale.dart';
import 'strings.dart';
void main() {
registerAll();
final bundle = Bundle.fromDir('assets/i18n', ioFileReader);
useBundle(bundle, locale: 'ne-NP');
final text = fee.resolve(args: {
'creator': 'Jane Doe',
'amount': 2500,
});
print(text);
}
You can also pass locale: per call:
final text = inbox.resolve(
locale: 'ja-JP',
args: {'count': 5},
);
Before a bundle is loaded, .resolve() falls back to the source string and local value formatting.
Parameter Types #
| Helper | Use for | Runtime behavior |
|---|---|---|
Param.literal() |
Brand names, usernames, URLs, proper nouns | Passed through verbatim |
Param.number() |
Numeric values | Locale digits and separators |
Param.plural() |
Counts that affect wording | Locale plural category plus number formatting |
Param.translatable([...]) |
Fixed enum-like values | Translated at compile time and substituted at runtime |
Param.translatable([...], inline: true) |
Enum values that affect grammar | Folded into template variants |
Param.date(fmt) |
Dates | package:intl date formatting |
Param.currency('NPR') |
Money | package:intl currency formatting |
Param.relative() |
Relative time, such as 3 days ago |
Offline relative formatting |
Param.user() |
Free user text | Passed through untouched |
Param.userAdapted(context: ...) |
Free prose needing custom adaptation | Calls your bundle adapter only when supplied |
Param.date and Param.currency use package:intl with the Gregorian calendar. For non-Gregorian calendars, pre-format the value and pass it as Param.literal().
Runtime Adaptation #
userAdapted is a runtime adaptation hook for user-provided prose. It is opt-in and only applies to params you explicitly mark.
You need to provide three things:
- Mark params that need adaptation:
final profileBio = StringLocale(
'Creator bio: {bio}',
id: 'profile_bio',
params: {
'bio': Param.userAdapted(
context: 'Short creator profile shown to shoppers',
),
},
);
- Load the bundle. If
STRINGLOCALE_API_KEY(orOPENROUTER_API_KEY) is set,userAdapteduses the configured LLM by default on async runtime APIs (resolveAsync,AsyncTr,trAsync). Point at any OpenAI-compatible endpoint withSTRINGLOCALE_BASE_URL. You can still pass your own adapter to override it:
import 'package:stringlocale/stringlocale.dart';
final bundle = load(
'assets/i18n',
locale: 'ne-NP',
userAdaptedMode: UserAdaptedMode.realtime,
adapter: (value, locale, context) {
// Implement your own service call here.
return '$locale: $value';
},
);
- Resolve normally with user text in args:
final out = profileBio.resolve(args: {
'bio': '1200 followers, vintage camera collector, ships on Fridays',
});
Notes:
- If the current locale shares the source language, adaptation is skipped automatically.
- Adapter signature is
(String value, String locale, String? context). UserAdaptedMode.cachedmemoizes results by(value, locale, context)to avoid repeated calls.UserAdaptedMode.realtimecalls the adapter on every resolve.
Runnable examples:
- Offline adapter demo:
dart run example/main.dart - LLM runtime adapter demo:
dart run example/user_adapted_openrouter.dart
Axes and Variants #
Use gendered: true for a built-in gender axis:
final message = StringLocale(
'{name} has {count} saved campaigns',
id: 'saved_campaigns',
params: {
'name': Param.literal(),
'count': Param.plural(),
},
gendered: true,
);
Use custom axes for any other variant:
final cta = StringLocale(
'{name}, your workspace is ready',
id: 'workspace_ready',
params: {'name': Param.literal()},
axes: {
'audience': ['buyer', 'seller'],
},
required: ['audience'],
);
Enum translations are additive by default. For example, gender(2) + status(3) + category(5) means 10 drafted cells, not 30. Use inline: true only when grammar needs the enum value inside the template variant.
Runtime Behavior #
The runtime is offline by default. It does not call an LLM, translation API, or remote service while your app runs; it only resolves already-compiled JSON. A few behaviors worth knowing:
- Locale fallback —
ne-NPfalls back tone, then to the source locale. Custom chains are supported:'nl-BE': ['nl-NL', 'fr-FR']. - Native digits — Arabic, Devanagari, and other numeral systems are applied automatically where the locale expects them.
userAdaptedis opt-in — without an adapter or an LLM API key, it is passthrough. It never fires unless you explicitly mark a param withParam.userAdapted(...).
LLM-drafted translations are plain JSON, so you can review, diff, and version them like any other build artifact before shipping.
Running the Sample Apps #
Minimal console sample (no API key needed, runs in seconds):
dart run example/lib/main.dart
Full console sample with all param types:
dart run example/main.dart
Flutter macOS sample:
cd example
flutter run -d macos
Regenerate the Flutter sample assets:
dart run stringlocale compile \
--input example/lib/flutter_strings.dart \
--register registerFlutterSampleStrings \
--locales en-US,hi-IN,ne-NP,nl-NL,fr-FR,ru-RU \
--source-locale en-US \
--out example/assets/i18n \
--drafter offline
CLI help:
dart run stringlocale --help
Develop #
Package #
dart format .
flutter analyze
flutter test
dart pub publish --dry-run
Example app #
cd example
flutter analyze
flutter test
flutter build macos --debug
License #
MIT