stringlocale 0.2.0
stringlocale: ^0.2.0 copied to clipboard
Compile-time LLM localization for Dart and Flutter. Define strings once with typed params, generate locale JSON at build time, translate dynamic values at runtime via OpenRouter.
stringlocale (Dart / Flutter) #
Compile-time LLM localization for Dart and Flutter. You define your English source strings once, attach type information to each placeholder, generate locale JSON at build time, and render localized UI with lookup + interpolation at runtime.
Companion to stringlocale for Python and stringlocale-react for TypeScript. All three use the same locale JSON shape, which makes it practical to share translations across backend, web, and Flutter clients.
What problem this solves #
Most localization systems are strong at static strings but weak at mixed templates like:
Website status: {status}wherestatusshould be translated dynamically{owner}'s website plan costs {amount} per monthwhere only some values should be localizedYou have {count} localized page(s)where plural rules vary by locale- user-authored prose that should keep its words but adapt dates, numbers, and currency formatting
stringlocale makes those cases explicit by forcing every placeholder to declare what it is.
Where to use it #
Use stringlocale when your Dart or Flutter app has localized UI that combines:
- static app copy
- runtime values like names, statuses, counts, dates, and prices
- plural rules that differ by locale
- generated locale JSON you want to review, commit, and override
- optional LLM help for translating controlled runtime labels
It is not a replacement for product/content strategy, human review, or every calendar system. It is a practical layer for typed UI strings where placeholders need explicit localization behavior.
Installation #
Add the package:
dart pub add stringlocale
For Flutter apps:
flutter pub add stringlocale
Or add it manually:
dependencies:
stringlocale: ^0.2.0
If you use Flutter widgets like StringLocaleScope and Tr, import:
import 'package:stringlocale/stringlocale_flutter.dart';
If you only need the pure Dart API, import:
import 'package:stringlocale/stringlocale.dart';
Mental model #
texts.dart
-> compile step with OpenRouter
-> locales/ne-NP.json, locales/ja-JP.json, ...
-> runtime Renderer lookup + interpolation
-> LLM only for translatable / userAdapted param values
At compile time:
- static strings are translated once per locale
- dynamic template shells are translated once per locale
- plural strings generate singular form, plural form, and a safe boolean rule
- output is JSON that you can commit to source control
At runtime:
- most rendering is just lookup + placeholder substitution
- only
translatableanduserAdaptedparams call the LLM - runtime-translated values are cached in memory by locale and context
Quick start #
1. Define messages #
Create a file such as texts.dart:
import 'package:stringlocale/stringlocale.dart';
final welcome = staticText('welcome', 'Welcome to stringlocale');
final websitePlanPrice = dynamicText(
'website_plan_price',
'{owner}\'s website plan costs {amount} per month',
{
'owner': ParamKind.literal,
'amount': const Param(
'amount',
kind: ParamKind.currency,
currency: 'NPR',
),
},
);
final websiteStatus = dynamicText(
'website_status',
'Website status: {status}',
{
'status': const Param(
'status',
kind: ParamKind.translatable,
context: 'website publishing status',
),
},
);
final pageCount = pluralText(
'page_count',
'You have {count} localized page',
'You have {count} localized pages',
);
final texts = [
welcome,
websitePlanPrice,
websiteStatus,
pageCount,
];
2. Create a compile script #
Scaffold the project-local compile script:
dart run stringlocale:compile --init
This creates tool/compile_locales.dart:
import 'package:stringlocale/stringlocale.dart';
import '../lib/texts.dart';
Future<void> main() async {
await compileLocales(
texts: texts,
locales: [
'ne-NP:Nepali',
'ja-JP:Japanese',
'ar-SA:Arabic',
],
localeDir: 'locales',
);
}
3. Set your OpenRouter key #
export OPENROUTER_API_KEY=sk-or-...
For Flutter runtime translation, pass the key into the running app with
--dart-define:
flutter run --dart-define=OPENROUTER_API_KEY=$OPENROUTER_API_KEY
If the app is already running, stop it and run flutter run again. Hot reload
or hot restart will not apply a new --dart-define value.
You need this key for:
- compile-time locale generation
- runtime
translatableparams - runtime
userAdaptedparams
You do not need it for pure lookup, number formatting, date formatting, currency formatting, or plural selection.
4. Generate locale JSON #
dart run tool/compile_locales.dart
The package also exposes a small help command after install:
dart run stringlocale:compile --help
--init creates the script for you, but the compile step is still explicit.
Dart packages cannot create files during flutter pub add, and they cannot
dynamically import your app's texts.dart by path at runtime. The generated
script keeps the setup testable and compatible with normal Dart imports.
If tool/compile_locales.dart already exists, --init will not overwrite it.
To recreate it, run:
dart run stringlocale:compile --init --force
Typical output:
{
"welcome": {
"text": "stringlocale मा स्वागत छ",
"src_hash": "a3f1b2c4d5e6f7a8"
},
"page_count": {
"singular": "तपाईंसँग {count} अभियान छ",
"plural": "तपाईंसँग {count} अभियानहरू छन्",
"rule": "count < 2",
"rule_explanation": "Use singular when count is less than 2.",
"src_hash": "b4c5d6e7f8a9b0c1"
}
}
5. Render in your app #
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:stringlocale/stringlocale_flutter.dart';
import 'texts.dart';
void main() {
final renderer = Renderer(
localeData: {
'ne-NP': jsonDecode(neNpJson) as Map<String, dynamic>,
'ja-JP': jsonDecode(jaJpJson) as Map<String, dynamic>,
},
);
runApp(
StringLocaleScope(
localeCode: 'ne-NP',
languageName: 'Nepali',
renderer: renderer,
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: Tr(welcome)),
body: Column(
children: [
Tr(pageCount, args: {'count': 5}),
Tr(websiteStatus, args: {'status': 'approved'}),
Tr(websitePlanPrice, args: {'owner': 'Jane Doe', 'amount': 2500}),
],
),
),
);
}
}
Message types and parameter kinds #
Static messages #
Use staticText() or its alias t() when a string has no placeholders.
final welcome = staticText('welcome', 'Welcome to stringlocale');
final shortAlias = t('sign_in', 'Sign in');
Dynamic messages #
Use dynamicText() when the message contains placeholders and each placeholder has a declared type.
final launchDate = dynamicText(
'launch_date',
'Launch by {date}',
{
'date': const Param('date', kind: ParamKind.date, fmt: 'medium'),
},
);
message() and dynamic_() are still available as backward-compatible aliases. dynamic_() exists because dynamic is a Dart keyword, but new code should prefer dynamicText().
Plural messages #
Use pluralText() when a string changes by count. The compile step translates both forms and generates a locale-specific rule.
final pageCount = pluralText(
'page_count',
'You have {count} localized page',
'You have {count} localized pages',
);
The count placeholder defaults to count, but you can change it:
final cartItems = pluralText(
'cart_items',
'{n} item in cart',
'{n} items in cart',
countParam: 'n',
);
Plural messages can also have additional typed placeholders. The countParam
is the one value used to choose singular vs plural; every other placeholder is
formatted normally.
final localizedPageCount = pluralText(
'localized_page_count',
'{owner}\'s website has {pages} localized page ready for {amount}',
'{owner}\'s website has {pages} localized pages ready for {amount}',
countParam: 'pages',
params: {
'owner': ParamKind.literal,
'amount': const Param('amount', kind: ParamKind.currency, currency: 'NPR'),
},
);
Render it with all placeholders, including the plural decider:
Tr(
localizedPageCount,
args: {'owner': 'Jane Doe', 'pages': 2, 'amount': 2500},
)
Parameter kinds #
| Kind | Use when | Runtime behavior |
|---|---|---|
literal |
Brands, URLs, usernames, IDs | Passed through unchanged |
number |
Plain numeric values | Locale-aware digit conversion |
numberPlural |
Count value for plural messages | Drives singular/plural selection and formatting |
date |
Dates you want formatted | Uses intl date formatting |
currency |
Money amounts | Uses locale-aware currency formatting |
relative |
Relative times like "3 days ago" | Uses locale-aware relative formatting |
translatable |
Enum-like values such as status/category | LLM translation at runtime, cached |
user |
User-authored text that must stay untouched | Passed through unchanged |
userAdapted |
Free text whose words stay the same but numbers/dates should adapt | LLM adaptation at runtime, cached |
When to choose translatable vs user #
Use translatable for a limited vocabulary you control, such as:
approvedfashioninstagram
Use user for content you must not rewrite, such as:
- comments
- support notes entered by users
- imported copy from another system
Use userAdapted for content where prose should remain untouched but locale formatting should change, such as:
Offer ends Feb 15, 2026 and costs NPR 2,500
Placeholder rules #
stringlocale validates your templates up front.
- every
{placeholder}in the source must be declared in params - every declared param must exist in the source
- compile-time translations must preserve placeholders
- plural rules are validated before being written to JSON
This catches common localization failures early instead of silently rendering broken templates.
Compiling locales in practice #
compileLocales() accepts:
texts: your list ofMessageslocales: locale entries as eithercodeorcode:LanguagelocaleDir: output directory, defaultlocalesmodel: OpenRouter model nameapiKey: optional explicit API key overrideforce: retranslate even if source hashes are unchangedclient: custom HTTP client, useful in tests
Example:
await compileLocales(
texts: texts,
locales: ['ne-NP:Nepali', 'ja-JP:Japanese'],
localeDir: 'locales',
force: false,
);
Recompile behavior #
The compiler stores a stable src_hash for each source string.
- unchanged source: skipped on the next compile
- changed source: retranslated
- override exists: compile warns if the source changed so you can review stale manual translations
What gets translated at compile time #
- static messages
- translated template shells for dynamic messages
- both sides of plural messages
- plural rules
What does not get translated at compile time #
translatableparam values such asapproveduserAdaptedruntime free text- actual runtime data passed by the caller
Using the renderer #
The Renderer is the runtime engine. It can be used with or without Flutter.
final renderer = Renderer(
localeDir: 'locales',
useDigitConversion: true,
localeData: {
'ne-NP': neNpMap,
},
);
Useful methods:
render(...)renders aMessagesetLocale(...)registers compiled locale data in memoryloadLocale(...)loadslocales/<code>.jsonand merges overridessetOverrides(...)injects manual overrides in memoryclearValueCache()resets cached runtime value translationsclearLocaleCache()clears cached locale JSON
Pure Dart usage #
final renderer = Renderer(localeData: {'ne-NP': neNpMap});
final text = await renderer.render(
welcome,
'ne-NP',
languageName: 'Nepali',
);
Flutter widget usage #
Wrap your widget tree in StringLocaleScope:
StringLocaleScope(
localeCode: 'ja-JP',
languageName: 'Japanese',
renderer: renderer,
child: MyHomePage(),
)
Render text with Tr:
Tr(welcome)
Tr(pageCount, args: {'count': 5})
Tr(websiteStatus, args: {'status': 'approved'})
Use a custom builder if you do not want the default Text widget:
Tr(
welcome,
builder: (value) => Text(
value,
style: Theme.of(context).textTheme.titleLarge,
),
)
Switching locale in Flutter #
StringLocaleScope.of(context).setLocale('ja-JP', 'Japanese');
You can also rebuild the parent StringLocaleScope with a new localeCode and languageName.
Imperative rendering #
Use trAsync() outside build methods, for example inside callbacks:
final label = await trAsync(
context,
websitePlanPrice,
args: {'owner': 'Jane Doe', 'amount': 2500},
);
Loading locale JSON #
There are two common ways to provide compiled data.
Option 1: parse JSON yourself and pass localeData #
Good for demos, tests, or when you already have the JSON in memory.
final renderer = Renderer(
localeData: {
'ne-NP': jsonDecode(neNpJson) as Map<String, dynamic>,
},
);
Option 2: use loadLocale() #
Good for Dart VM and non-web Flutter when locale files live on disk.
final renderer = Renderer(localeDir: 'locales');
await renderer.loadLocale('ne-NP');
loadLocale() automatically merges locales/overrides/<code>.json on top if present.
Plural rules and safety #
Dart has no eval, so stringlocale uses a tiny built-in expression evaluator for plural rules. Rules are limited to safe syntax:
- one count variable such as
count - integer literals
+ - * / %== != < > <= >=&& || !- parentheses
- boolean literals
trueandfalse
Examples:
count < 2
count == 1
count % 10 == 1 && count % 100 != 11
true
If a rule is invalid at runtime, the renderer falls back to count < 2.
Digit conversion and formatting #
number, numberPlural, date, currency, and relative can convert digits for locales that use non-Latin numerals.
| Locale | 12 becomes |
|---|---|
ne, hi |
१२ |
ar |
١٢ |
fa |
۱۲ |
bn |
১২ |
th |
๑๒ |
Disable this if you want raw Western digits everywhere:
final renderer = Renderer(useDigitConversion: false);
Date formatting uses intl and stays Gregorian. If your app needs Bikram Sambat or another non-Gregorian calendar, format the date yourself and pass it as literal or user.
Registry helper #
If you prefer collecting messages in one place with duplicate-key protection, use Registry:
final registry = Registry();
final welcome = registry.staticText('welcome', 'Welcome');
final status = registry.dynamicText('status', 'Status: {status}', {
'status': ParamKind.translatable,
});
final texts = registry.texts;
Registry throws immediately if the same key is registered twice.
Manual overrides #
Overrides are human-reviewed corrections layered on top of generated locale JSON. You do not need them to get started. They become useful when an LLM translation is mostly right but needs a product, legal, brand, or native-speaker adjustment.
Keep generated files in locales/<code>.json, then put human edits for that
same locale in:
locales/overrides/<locale>.json
For example, Nepali corrections go in locales/overrides/ne-NP.json, while
Japanese corrections go in locales/overrides/ja-JP.json.
Example:
{
"welcome": {
"text": "मानव-सम्पादित अनुवाद",
"src_hash": "a3f1b2c4d5e6f7a8"
}
}
Behavior:
- overrides always win at runtime
- the compiler does not overwrite override files
- if the English source changes, compile warns that the override may be stale
- deleting an override falls back to the generated translation
You can also inject overrides directly:
renderer.setOverrides('ne-NP', overrideMap);
Most small apps can ignore overrides at first. For production apps, they give
you a clean review workflow: generate translations quickly, review them in
dashboard.html, export only the corrections, and keep those corrections stable
across future compiles.
Runtime fallback behavior #
If a locale entry does not exist:
- static and dynamic messages fall back to the English source template
- plural messages fall back to the English plural form when localized data is missing
If a runtime translation call fails inside Tr, the widget shows its fallback text, which defaults to the English source.
Caching behavior #
Runtime value translation is cached in memory:
translatablevalues cache by value + locale + contextuserAdaptedvalues cache by content hash + locale + context
This keeps repeated enum values like approved from hitting the LLM on every render.
Demo app #
This repository includes:
example/for a package-style examplestringlocale_demo/for a runnable Flutter appdashboard.htmlfor reviewing and exporting overrides
To compile locales for the demo:
cd stringlocale_demo
dart run lib/compile.dart
To run the Flutter demo:
cd stringlocale_demo
flutter run -d macos --dart-define=OPENROUTER_API_KEY=$OPENROUTER_API_KEY
The demo includes a runtime translation section where you can type a visitor region and see the ParamKind.translatable value translated for the selected non-English locale. English passes the value through because the source value is already English.
Translation dashboard #
Open dashboard.html in a browser. It is a standalone static page with no backend. You can:
- load compiled locale JSON
- inspect keys across locales
- edit translations inline
- export one override file per edited locale, compatible with
locales/overrides/<code>.json
Troubleshooting #
My UI stays in English #
Check these first:
- the active
localeCodeis noten StringLocaleScopeis wrapping the subtree that rendersTr- the locale JSON was loaded into
Renderer - the message key exists in the compiled JSON
OPENROUTER_API_KEY is not set #
Set the environment variable before compile or runtime calls that need LLM access:
export OPENROUTER_API_KEY=sk-or-...
For Flutter apps, especially when launching from tooling that may not pass shell
environment variables through to the app process, use --dart-define:
flutter run -d macos --dart-define=OPENROUTER_API_KEY=$OPENROUTER_API_KEY
Compile keeps skipping my key #
That means the source string hash has not changed. If you need a full regeneration, run with force: true in your compile script.
My placeholder disappeared in a translation #
The compiler validates placeholders and throws if a translated string drops one unexpectedly. Keep placeholders exactly as {name} in your English source.
languageName is required #
That only applies when rendering a non-English message with translatable or userAdapted params. Pass languageName to render(...), or use StringLocaleScope in Flutter so the widget layer supplies it.
I need custom calendars or domain-specific formatting #
Preformat those values yourself and pass them as literal or user. stringlocale is intentionally strict about what it formats automatically.
API overview #
Main exports from package:stringlocale/stringlocale.dart:
Message,Param,ParamKindstaticText(),t(),dynamicText(),message(),dynamic_(),pluralText(),plural()RegistryRenderercompileLocales()formatNumber(),formatDateValue(),formatCurrencyValue(),formatRelativeValue()PluralRuleEvaluator
Additional Flutter exports from package:stringlocale/stringlocale_flutter.dart:
StringLocaleScopeTrtrAsync()
License #
MIT