Parse the header once. alx tells you which language to serve.
alx is an Accept-Language toolkit for Go (RFC 9110 / RFC 4647), with zero third-party
dependencies. It gives you two layers of usage:
- Direct functions β parse, sort, normalize 8000 and match a header in one call.
- Resolver β decide the request's language across URL, query, cookie, header and default, and tell you which one won.
On top of either layer you get quality-value ordering, wildcards, fallback chains, canonical tag casing, and framework adapters that stay out of your dependency graph.
go get github.com/bakhod1r/alxContent negotiation is usually a hand-rolled strings.Split that quietly ignores
q= values and serves the wrong language. It is one call:
func handler(w http.ResponseWriter, r *http.Request) {
tag, ok := alx.Best(r.Header.Get("Accept-Language"), []string{"en", "uz", "ru"})
if !ok {
tag = "en"
}
alx.SetContentLanguage(w, tag)
alx.SetVary(w) // so caches key on the header you just read
}Best sorts by quality, walks the fallback chain (zh-Hant-TW β zh-Hant β zh),
honours *, and returns your supported tag β spelled exactly as you wrote it, not as
the client sent it.
Real apps read the language from a URL prefix, a ?lang= link, a cookie the user set
last week, and only then the browser. The Resolver does that in one place, in the
order you declare:
resolver := alx.NewResolver(
alx.WithSupported("en", "uz", "ru"),
alx.WithURL(), // /uz/products
alx.WithQuery("lang"), // ?lang=uz
alx.WithCookie("lang"),
alx.WithHeader(), // Accept-Language, or the headers you name
alx.WithFallback("uz-Cyrl", "ru"),
alx.WithDefault("en"),
)
res := resolver.ResolveRequest(r)A Resolution reports the decision and its provenance:
res.Tag // "uz"
res.Language // "uz"
res.Script // "" β e.g. "Hant"
res.Region // "" β e.g. "UZ"
res.Quality // 1.0
res.Matched // the supported tag that matched
res.Source // SourceURL | SourceQuery | SourceCookie | SourceHeader | SourceDefaultres.Source is the field that makes this debuggable: when a user insists the site is in
the wrong language, you can log whether it came from their cookie or their browser.
For a bare header string, use resolver.Resolve(header).
langs := alx.ParseSorted("uz-UZ,uz;q=0.9,en;q=0.8,*;q=0.1")
langs[0].Tag // "uz-UZ"
langs[0].Language // "uz"
langs[0].Region // "UZ"
langs[0].Script // ""
langs[0].Quality // 1.0
langs[0].String() // "uz-UZ" β round-trips as "uz;q=0.9" when q != 1| Function | Behaviour |
|---|---|
Parse / ParseRaw |
Lenient. Header order preserved, malformed segments dropped. |
ParseSorted |
Lenient, then sorted by descending quality. |
ParseStrict |
Returns error on any malformed segment. |
Validate |
ParseStrict with the result discarded β error only. |
PrimaryLanguage |
Allocation-free fast path: first tag, no q= handling. |
Sort |
Stable sort of []Language by descending quality. |
Strict mode returns a *ParseError that says where it broke:
_, err := alx.ParseStrict("en;q=banana")
var pe *alx.ParseError
if errors.As(err, &pe) {
pe.Position // byte offset in the header
pe.Token // the offending token
pe.Reason // why it was rejected
}Equal quality values keep header order, so a client's own preference ordering survives β that is what makes the sort stable rather than merely correct.
alx.Best(header, supported) // Lookup β the sensible default
alx.Match(header, supported) // alias for Best
alx.MatchWith(header, supported, alx.Exact) // tag must match exactly
alx.MatchWith(header, supported, alx.Basic) // base language only: en-US matches en
alx.MatchWith(header, supported, alx.Lookup) // fallback chain: zh-Hant-TW β zh-Hant β zh| Strategy | en-GB against ["en"] |
zh-Hant-TW against ["zh"] |
|---|---|---|
Exact |
no match | no match |
Basic |
en |
zh |
Lookup |
en |
zh |
Fallback is an alias for Lookup; PreferRegion currently behaves as Lookup.
The chain itself is exported, so you can drive your own lookup β translation files, a CDN path, whatever:
alx.Fallbacks("zh-Hant-TW") // ["zh-Hant-TW", "zh-Hant", "zh"]Clients send EN-us, config files hold en_US, your file names use en-US. One
function each way:
alx.Normalize("ZH-hant-tw") // "zh-Hant-TW" β language lower, script Title, region UPPER
alx.Canonical("en_US") // "en-US" β underscores to dashes, then NormalizeEach adapter is its own Go module, so importing alx never pulls a web framework
into your build. Install only the one you use:
| Framework | Import |
|---|---|
| Gin | go get github.com/bakhod1r/alx/gin |
| Fiber | go get github.com/bakhod1r/alx/fiber |
| Echo | go get github.com/bakhod1r/alx/echo |
| Iris | go get github.com/bakhod1r/alx/iris |
| Beego | go get github.com/bakhod1r/alx/beego |
| fasthttp | go get github.com/bakhod1r/alx/fasthttp |
Every adapter exposes the same two functions, typed for that framework's context:
import alxgin "github.com/bakhod1r/alx/gin"
func handler(c *gin.Context) {
lang := alxgin.FromRequest(c) // best Language
all := alxgin.Languages(c) // all languages, sorted by quality
}import alxfiber "github.com/bakhod1r/alx/fiber"
func handler(c *fiber.Ctx) error {
lang := alxfiber.FromRequest(c)
return c.SendString(lang.Tag)
}alx.FromRequest(r) // highest-quality Language
alx.Languages(r) // all languages, sorted
alx.SetContentLanguage(w, "uz")
alx.SetVary(w)FromRequest and Languages check Accept-Language, then Language, then Lang β
some clients and proxies use the shorter spellings. Both return zero values on a nil
request rather than panicking, so they are safe in middleware that runs before routing.
The repository is a Go workspace, so the adapter modules build against the local core:
make test # go test -cover across every module
make race # go test -race
make cover # coverage report for the core
make all # fmt + vet + testContributions welcome β see CONTRIBUTING.md. Releases are documented in RELEASING.md, changes in CHANGELOG.md.
MIT Β© bakhod1r