A Go implementation of Project Fluent — a
localization system for natural-sounding translations. You write .ftl files,
load them into a per-locale Bundle, and format messages whose plurals,
numbers, and dates follow the rules of each language. gofluent ports the
reference JavaScript implementation
(@fluent/syntax and
@fluent/bundle); the locale-aware formatting is CLDR-backed by the
github.com/hakastein/gocldr
module (validated against Node's Intl.*) and wired into every bundle by
default.
Status: pre-1.0. The library is feature-complete and tested against the upstream conformance and
Intl.*suites, but the public API may still change between minor versions until 1.0.
go get github.com/hakastein/gofluentRequires Go 1.23 or newer.
This example renders one Russian message across the plural categories Russian
actually uses — one (1, 21), few (2), many (5) — plus a grouped
number and a localized date. It is verified by a runnable example
(ExampleBundle_pluralRussian in example_test.go), so the
output below is exactly what go test asserts.
package main
import (
"fmt"
"time"
_ "github.com/hakastein/gocldr/locales/ru" // Russian number + date data (see "Locale data")
fluent "github.com/hakastein/gofluent"
)
const src = `
apples =
{ $n ->
[one] { $n } яблоко
[few] { $n } яблока
*[many] { $n } яблок
}
total = Итого: { NUMBER($total) }
updated = Обновлено { DATETIME($at, dateStyle: "long") }
`
func main() {
// NewBundle wires the CLDR plural rules, number, and date formatters by
// default. useIsolating is disabled here so the output is plain text;
// the default (true) wraps placeables in Unicode bidi isolation marks.
b := fluent.NewBundle("ru", fluent.WithUseIsolating(false))
b.AddResource(fluent.NewResource(src))
apples, _ := b.Message("apples")
for _, n := range []int{1, 2, 5, 21} {
out, _ := b.FormatPattern(apples.Value, map[string]any{"n": n})
fmt.Println(out)
}
total, _ := b.Message("total")
out, _ := b.FormatPattern(total.Value, map[string]any{"total": 1234567})
fmt.Println(out)
updated, _ := b.Message("updated")
at := time.Date(2023, 1, 5, 14, 9, 7, 0, time.UTC)
out, _ = b.FormatPattern(updated.Value, map[string]any{"at": at})
fmt.Println(out)
}Output:
1 яблоко
2 яблока
5 яблок
21 яблоко
Итого: 1 234 567
Обновлено 5 января 2023 г.
The number 1 234 567 is grouped with no-break spaces and the date reads
5 января 2023 г. — both Russian conventions, matching Intl.*. The plural
select picks [one] for 1 and 21, [few] for 2, and the default *[many] for
5, following CLDR's Russian cardinal rules.
The same shape works for English — fluent.NewBundle("en")
with an FTL whose select uses English's [one]/*[other] categories.
- A
Bundleholds the translations for one locale (fluent.NewBundle("ru")). The locale string drives every locale-aware decision below. - FTL source is parsed once with
fluent.NewResourceand added withb.AddResource. A resource is a set of messages; each message has a value (aPattern) and optional.attributes. - A
{ $n -> [one] … [few] … *[many] … }select expression asks the bundle'sPluralRulesfor the CLDR plural category of$nin this locale, then renders the matching variant (falling back to the*default). NUMBER($n)andDATETIME($d)format their argument through the bundle'sNumberFormatter/DateTimeFormatter, honoring options such asdateStyle: "long"oruseGrouping.fluent.NewBundleinstalls those three formatters — the CLDR-backed plural rules, number, and date implementations — by default, matching ECMA-402Intl.*(and therefore fluent.js). No opt-in step is needed. To override the default for a bundle, pass your own implementation throughfluent.WithPluralRules/WithNumberFormatter/WithDateTimeFormatter.
CLDR-backed formatting is the default, so a bare fluent.NewBundle("en")
already formats numbers, dates, and plurals locale-aware. The locale data that
drives real number/date rendering is still opt-in — see "Locale data" below.
The resolver is fault-tolerant: FormatPattern never panics. It returns a
best-effort string together with the errors it encountered — missing references
and other problems render as fluent.js-style placeholders (for example
{$name}) and come back classified by the ErrReference / ErrRange /
ErrType sentinels (errors.Is). A Bundle is also safe for concurrent use
across all of its read and Add* methods.
Plural-category selection (the [one]/[few]/[many] choice) uses CLDR rules
that are always linked — Russian plurals are correct with no extra import.
Number and date formatting data is opt-in: a program links only the locales it blank-imports. For each locale you format, import its data:
import _ "github.com/hakastein/gocldr/locales/ru" // numbers + dates for ru
import _ "github.com/hakastein/gocldr/locales/en" // numbers + dates for enEach locales/<lang> package registers both the number and the date data for
that language. (If you only ever format numbers, gocldr/number/locales/ru
alone is enough; for dates, gocldr/datetime/locales/ru.) With no locale
data imported, formatting degrades gracefully: dates render as RFC3339 and
numbers use the ASCII root (e.g. 1,234,567), while plural selection still
works.
The gocldr formatters are also usable on their own, independent of Fluent
(gocldr/number, gocldr/plural, gocldr/datetime); see that module's docs.
In a real app the FTL lives in files, one directory per locale, loaded through
the localization package. localization.FSLoader accepts any fs.FS —
typically an embed.FS (translations compiled into the binary) or
os.DirFS("./locales") (read from disk at runtime).
Directory layout (the path template tells the loader where to look):
locales/
en/
main.ftl # apples = { $n -> [one] … *[other] … }
ru/
main.ftl # apples = { $n -> [one] … [few] … *[many] … }
import (
"embed"
_ "github.com/hakastein/gocldr/locales/en"
_ "github.com/hakastein/gocldr/locales/ru"
"github.com/hakastein/gofluent/localization"
)
//go:embed locales
var localesFS embed.FS
// "{locale}" and "{resource}" are substituted per (locale, resource) pair:
// e.g. "locales/ru/main.ftl".
loader := localization.FSLoader(localesFS, "locales/{locale}/{resource}.ftl")
l10n, _ := localization.NewFromLocales(localization.Config{
Requested: []string{"ru-RU"}, // e.g. from Accept-Language
Available: []string{"ru", "en"}, // locales you ship
Default: "en", // ultimate fallback
Resources: []string{"main"}, // resource ids (file basenames)
Loader: loader,
})
// Walks the negotiated chain (ru, then en) and returns the first match.
val, _ := l10n.FormatValue("apples", map[string]any{"n": 5}) // "5 яблок"NewFromLocales negotiates the requested locales against the ones you ship,
builds one Bundle per negotiated locale (each with the default CLDR-backed
formatters), and resolves a message from the first bundle in the chain that
defines it. Missing files and parse errors are non-fatal: the failing resource
is skipped and the rest of the chain still works.
| Package | Purpose |
|---|---|
github.com/hakastein/gofluent |
Runtime: fast FTL parser, fault-tolerant resolver, Bundle (one locale). |
.../syntax (+ .../syntax/ast) |
Full AST, recursive-descent parser, serializer, visitor — for tooling. |
.../langneg |
Language negotiation (port of @fluent/langneg). |
.../localization |
High-level fallback layer that loads .ftl files and formats across an ordered chain of locale bundles. |
gofluent is generated code — it was ported from fluent.js with the assistance of
large language models — and that is stated plainly, because the project's
credibility rests on verification rather than authorship. Correctness is pinned
to executable references, all run under go test ./...:
- The syntax parser and serializer are checked against the upstream Project Fluent conformance fixtures (62/62 structure, 35/36 reference — the single skip matches fluent.js).
- The CLDR formatters live in
github.com/hakastein/gocldrand are checked there against Node'sIntl.*(Intl.PluralRules,Intl.NumberFormat, andIntl.DateTimeFormatgolden fixtures).
Read the code and the tests, not just the prose — ARCHITECTURE.md explains the design and where each guarantee is enforced.
Contributions are welcome. See CONTRIBUTING.md for build, test, and linting mechanics, and ARCHITECTURE.md for how the codebase is organized and why. By participating you agree to the Code of Conduct.
Licensed under the Apache License, Version 2.0. See NOTICE for attribution of the fluent.js port lineage and the CLDR data.