How the package is wired. For usage examples see ../README.md. For capability status see CAPABILITY_ROADMAP.md; for maintenance recipes see UPDATING.md.
fluent_bundle is the CORE of the fluent family: the Fluent-spec runtime plus the ECMA-402 option contract, with zero locale data. The satellites — fluent_icu (ICU4X via icu_kit) and fluent_intl (package:intl) — implement rendering; each has its own docs/ tree.
- The core is backend-free, not options-free. It never imports a rendering library and never renders a locale-aware string. It DOES own the whole ECMA-402 option contract: the option bags, FTL parsing + validation, merge semantics, plural-operand digit math, the error shapes, and the conformance harness. The line: does this code need icu_kit or package:intl to run? No → core. Yes → satellite.
- Inert resolution.
formatMessagenever throws. Every error lands on the optionalerrorsout-list; the rendered string is always a string. One bad reference in a translation never breaks the UI. - Two-stage AST. A rich, span-tracked syntax AST for tools; a compact runtime AST for fast resolution. The compiler lowers one to the other; the resolver consumes only the runtime form.
- fluent-rs semantics. Where fluent-rs (Firefox's production implementation) and fluent.js deliberately diverge — bidi isolation of Message/Term references and StringLiterals — this package follows fluent-rs, and the vendored fluent-rs corpus proves it.
- The degrade contract. A backend that cannot honor an option still
renders the nearest supported form AND records a
FluentTypeError— never a throw, never a silent drop. The error shape is a core factory (FluentTypeError.unsupportedOption) so every satellite degrades with the same sentence, and the conformance harness asserts the contract in both directions on every backend. - Plural selection agrees with rendering.
FluentNumber.resolveDigitscomputes plural operands withIntl.PluralRulessemantics — exact digit-string + BigInt arithmetic honoring all nine rounding modes,roundingIncrement, andtrailingZeroDisplay— so the selected variant matches the rendered digits on any backend that supports the option, and selection stays identical ACROSS backends when one degrades the render.
lib/
fluent_bundle.dart — RUNTIME barrel: FluentBundle, FluentBackend,
values, errors, builtins surface
markup.dart — OPT-IN barrel: FluentSpan tree + parseFluentMarkup
syntax.dart — PARSE-TIME barrel: parser + syntax AST (for tools)
testing.dart — CONFORMANCE barrel: BackendExpectations +
fluentBackendConformanceChecks (for satellites)
src/
backend/ — FluentBackend interface + FluentFormatContext +
PluralCategory; the spec-fallback default backend
builtins/ — NUMBER() + DATETIME(): FTL named-arg parsing +
full ECMA-402 value-set validation
bundle/ — FluentBundle + FluentBundleChain (locale
fallback across bundles), resolver, scope,
FluentFunction
compiled/ — compiler + runtime AST (CompiledMessage /
CompiledTerm / CompiledPattern / expressions)
errors/ — sealed FluentError + FluentParseError hierarchies,
the degrade-contract factories, isValidCurrencyCode
locale/ — negotiateLocaleChain / negotiateLocale: the one
tag-negotiation ladder (fluent_gen's emitted enum
and fluent_flutter both delegate here)
markup/ — FluentSpan tree + the package:html-backed parser
syntax/
ast/ — one Dart library; part-files per node family
parser/ — FluentParser + ParserStream
unescape.dart
testing/ — the conformance harness: expectations, harness
(ConformanceHarness + check assembler), and the
check groups (core / number / datetime / degrade)
values/ — FluentValue sealed family; digit_resolution.dart
holds resolveDigits' exact-arithmetic machinery
bin/
watch.dart — dev-time .ftl watcher (JSON change events; apps
apply via addResource(allowOverrides: true))
tool/
example/
main.dart — the pub.dev showcase: every core capability in
one runnable file (output pinned by the test)
test/
_corpus/syntax/ — vendored fluent-rs PARSER fixtures + PROVENANCE.md
(the resolver corpus needs a CLDR backend, so it
lives with each satellite — see their docs/)
example/example_test.dart — runs the showcase, pins every output line
... — mirrors lib/src folder-for-folder
Makefile — the gate: `make check` = analyze + floor +
VM suite + chrome suite + example showcase
Each barrel pulls only what it needs; consumers pay for nothing they don't import.
| Barrel | Surface | Who imports it |
|---|---|---|
fluent_bundle.dart |
FluentBundle, FluentBackend, FluentValue family + options, errors, FluentFunction |
Apps (usually via a satellite's re-export) |
syntax.dart |
FluentParser, the span-tracked syntax AST, unescapeFluentString, parse errors |
Tools: linters, codegen, editors |
markup.dart |
FluentSpan sealed tree + parseFluentMarkup |
Apps rendering inline markup (<bold>…</bold>) |
testing.dart |
BackendExpectations, ConformanceCheck, fluentBackendConformanceChecks |
Satellite test suites |
Each satellite's own barrel re-exports fluent_bundle.dart, so a
consumer adds ONE dependency (fluent_icu or fluent_intl) and one
import gives them FluentBundle plus that backend.
Syntax AST (src/syntax/ast/, exported via syntax.dart): rich,
span-tracked, loses nothing. Each node carries its source span so tools
point at exact column ranges. One Dart library split across part-files —
the node types are mutually recursive and the shared mixins compose
freely only within one library.
Runtime AST (src/compiled/): spans stripped, keys interned,
variants pre-indexed. Compiled once per addResource and cached. The
resolver consumes only this form.
The compiler (src/compiled/compiler.dart) walks the syntax AST
top-down and emits the runtime form.
bundle.formatMessage(id, args: …, errors: …):
1. Look up CompiledMessage by id
├─ missing → record FluentReferenceError, return the id
└─ found → resolve its pattern with a fresh Scope
2. Walk the CompiledPattern element by element:
├─ text → append
└─ placeable → resolve the expression:
literals → FluentString / FluentNumber
$variable → coerce the caller's arg to a FluentValue
message/term → recurse (cycle-guarded; Scope.dirty)
FUNCTION(...) → look up in bundle.functions, call, format result
select { … } → match variant by (a) exact key, (b) plural
category from backend.pluralCategory, (c) default
3. Format leaf values through the backend
(backend.formatNumber / formatDateTime / plural selection)
4. Apply the bundle transform if set; return the string
Guard rails: MAX_PLACEABLES halts expansion with
FluentResolutionLimitError (Billion-Laughs guard); cyclic references
resolve to the default variant with FluentCyclicReferenceError —
including cycles through term-attribute selectors.
With useIsolating: true (default), interpolations in multi-element
patterns are wrapped in FSI (U+2068) / PDI (U+2069). Deliberately
matching fluent-rs, Message references, Term references, and
StringLiterals are NOT isolated (fluent.js isolates all placeables).
The vendored fluent-rs corpus's "(Rust)" fixtures prove this choice;
the fluent.js-behavior fixtures stay upstream-skipped. Do not "fix"
this toward fluent.js — the divergence is documented at
src/bundle/resolver.dart _writePlaceable.
The core owns everything about what NUMBER/DATETIME options mean; satellites own turning them into strings.
FluentNumberOptions/FluentDateTimeOptions(src/values/) — every ECMA-402 option as a nullable field;mergecomposes partial overrides (named args win). One FTLuseGroupingkey feeds two typed fields: booleans →useGrouping, the v3 strategy strings (auto/always/min2,"true"normalizing toalways) →groupingStrategy.- The builtins (
src/builtins/) — parse FTL named args and validate every enum-shaped option against its ECMA-402 value set, the roundingIncrement cross-constraints, and the Unicode-extension subtag shapes. Out-of-set values record aFluentFormatErrorand drop (Fluent never throws). FluentNumber.resolveDigits(src/values/fluent_number.dart+digit_resolution.dart) —Intl.PluralRulesdigit resolution: significant-digit rules when present, else min/max fraction rules with the PluralRules defaults (min 0, maxmax(min, 3)), honoringroundingMode/roundingIncrement/trailingZeroDisplay. Exact digit-string + BigInt arithmetic on the value's shortest decimal representation — the same source both ICU4X and package:intl round from.notationnever affects selection (PluralRules takes rounding options but not notation). The result feeds every backend's plural selection.- The degrade-contract shapes (
src/errors/fluent_error.dart) —FluentTypeError.unsupportedOption(the one sentence every satellite degrade records) andFluentTypeError.invalidCurrencyCode+isValidCurrencyCode(strict 3-alpha ECMA well-formedness; both backends degrade invalid codes to decimal instead of letting the formatter guess).
abstract class FluentBackend {
PluralCategory pluralCategory(
FluentNumber value, PluralRuleType type, FluentFormatContext context);
String formatNumber(FluentNumber value, FluentFormatContext context);
String formatDateTime(FluentDateTime value, FluentFormatContext context);
}The default (spec-fallback) backend classifies every number as other
and renders digits / ISO-8601 — correct per spec, zero locale awareness,
zero dependencies. The satellites subclass with real CLDR behavior. The
bundle stores nothing about CLDR; the backend is everything.
The machine-readable version of the capability matrix. A satellite
declares its truth as BackendExpectations flags;
fluentBackendConformanceChecks(createBackend, expectations) returns
named checks that test every flag in BOTH directions:
- flag
true→ a positive structural rendering check (real output asserted, digit/marker-shaped so CLDR versions can differ); - flag
false(underrecordsUnsupportedOptionErrors) → a degrade check: renders a usable string AND records an error — formatted TWICE on ONE bundle, so an error recorded only inside a cached formatter builder (silent from the second call) fails the suite.
Layout: src/testing/expectations.dart (the flags),
harness.dart (ConformanceHarness + the assembler), and the check
groups (checks_core / checks_number / checks_datetime /
checks_degrade). The harness deliberately does not depend on
package:test; satellites wire each returned check into their runner.
A satellite cannot lie: declaring ✓ without the ability fails the positive check; declaring ✗ but degrading silently fails the degrade check. The declaration IS the proof.
Two satellite species exist. This template is for BACKEND satellites
(FluentBackend implementations: fluent_icu, fluent_intl).
INTEGRATION satellites (fluent_flutter — locale lifecycle, loading,
widget access) implement no backend and carry their own shape,
documented in their own docs/ARCHITECTURE.md.
Every backend satellite has the IDENTICAL skeleton. Backend-specific implementation only ever lives INSIDE the fixed slots — never as new top-level files or folders. A future satellite is stamped from this template; deviation is drift.
fluent_<x>/
lib/fluent_<x>.dart barrel: re-exports fluent_bundle + the backend
lib/src/
backend.dart class <X>Backend extends FluentBackend
common/ backend infra shared across concerns
datetime/datetime_map.dart create<X>DateTimeFormatter — the folder's entry
datetime/… free: backend-specific datetime helpers
number/number_map.dart create<X>NumberFormatter — router + degrades
number/… free: backend-specific number helpers
plural/plural_map.dart <x>PluralRules — the folder's entry
plural/… free: helpers
test/
_corpus/bundle/*.yaml vendored fluent-rs resolver fixtures
(byte-identical across satellites; each
PROVENANCE.md pins the same commit)
_corpus/bundle_corpus_test.dart runner wired to THIS backend
conformance_test.dart REQUIRED: the shared harness with this
backend's declared BackendExpectations
common/… datetime/… number/… plural/… mirror lib/src file-for-file
docs/ ARCHITECTURE / CAPABILITY_ROADMAP / UPDATING
Locked: the folder set, the entry-file names, the corpus + conformance presence, the barrel shape, the docs triple. Free: everything else inside the folders (the icu satellite has shaping/styles/field_mapping/ zoned; the intl one has builder/currency_name — guts differ, skeleton never does).
Cross-satellite duplication is pushed DOWN into this core, never tolerated sideways. If two satellites re-author the same sentence or rule, extract it here.
Add-a-satellite recipe: the full step list lives in
UPDATING.md §4.
Translators write HTML5-shaped tags inside messages; parseFluentMarkup
turns the resolved string into a List<FluentSpan> tree callers walk to
build UI. Backed by package:html (the Dart team's html5lib port) —
same entity decoding, case folding, and malformed-tag recovery every
browser uses. Bidi isolation marks survive inside text spans but are
stripped from attribute values (URLs and keys are machine-consumed).
The core stays Flutter-free; span → InlineSpan mapping lives in the
fluent_flutter satellite (fluentSpansToInline + FluentText).
addResource(source, allowOverrides: true) replaces existing messages/
terms in place (conflicts without the flag record FluentOverrideError
and keep the existing definition). bin/watch.dart is the dev-loop
half: it watches .ftl files and emits stable JSON change events on
stdout; the app-side wiring (vm_service, dev endpoint) is deliberately
the consumer's — different IDE setups expose the running app
differently.
The test tree mirrors lib/src folder-for-folder. Special categories:
| Where | What |
|---|---|
test/_corpus/syntax/ |
Vendored fluent-rs parser fixtures (.ftl + AST .json pairs), run by syntax_corpus_test.dart. Provenance: ../test/_corpus/PROVENANCE.md. |
test/builtins/ |
The option parsers: every ECMA value set, cross-constraints, polymorphic useGrouping, merge semantics. |
test/values/resolve_digits_test.dart |
The exact-rounding machinery: all nine modes, increments, stripIfInteger, exponent decomposition, PluralRules defaults. |
test/bundle/ |
Resolver behavior: isolation, cycles, transform, backend seam. |
test/barrel/ |
Barrel-surface guards. |
The resolver corpus (fluent-rs bundle/*.yaml) cannot run here — it
needs a real CLDR backend — so each satellite vendors the same pristine
fixture set and runs it against its own backend. The parser corpus needs
nothing, so it lives here.
| Symptom | First place to look |
|---|---|
| A NUMBER option "does nothing" | Is it validated in src/builtins/number_builtin.dart (out-of-set values drop with a recorded error — check the errors out-list first)? Then the satellite's number/number_map.dart routing. |
| Plural category disagrees with the rendered digits | resolveDigits (core) computes the operand; the satellite renders. If a rounding option is involved and the backend degrades it, selection follows the REQUESTED options by design — the degrade error flags the render. |
| A fixture in the satellite corpus fails but core tests pass | The satellite's mapping, not the resolver — resolution is corpus-proven per satellite. |
| Isolation marks appear/disappear unexpectedly | fluent-rs semantics (§3): Message/Term refs and StringLiterals are never isolated. Check _writePlaceable before assuming a bug. |
The core owns the Fluent spec AND the ECMA-402 option contract — parsing, validation, merge, exact plural-operand math, error shapes, and the conformance harness — and renders nothing. Satellites are whole-package adapters stamped from one locked template, each corpus-proven and harness-proven against its declared capability flags. Inert resolution, fluent-rs isolation semantics, degrade loud or render right — never silent.