@luwiostack/locale
v0.1.0
Published
Simple, predictable locale management for React — locale, country & language data with a typed domain model.
Readme
@luwiostack/locale
Simple, predictable locale management for React. Ties a language and a country together into
an active locale, exposed through a provider + hook. It composes @luwiostack/country
and @luwiostack/language (installed automatically), so a locale is valid whenever its
language and country are each known.
Part of Luwio — standalone, but pairs well with the other @luwiostack/* packages.
Ported and restructured from
@tacky-org/locale.
Install
npm install @luwiostack/localePulls in @luwiostack/country and @luwiostack/language automatically. React 18+ is a peer dependency (the
domain layer works without React too).
React usage
import { Locale, useLocale } from '@luwiostack/locale/react'
function App() {
return (
<Locale locale={Locale.new({ languageOrLocale: 'nl-BE' })}>
<Info />
</Locale>
)
}
function Info() {
const { locale } = useLocale()
// locale is the active Locale — the same object Locale.new() returns.
return (
<p>
{locale.language().machine_name} in {locale.country().code} ({locale.code})
— dial {locale.country().dialing_code}
</p>
)
}Give <Locale> a locale you control. For untrusted values — from the URL, storage or an API —
resolve them first with Locale.resolve: it maps the value onto your supported
set (falling back via the required * catch-all) so an unsupported locale can't crash the app. A
locale that still can't be resolved throws while rendering; useLocale throws if used outside a
provider.
<Locale> mirrors lang/dir onto <html> through the nested <Language> provider, but that's a
client-side effect — server-rendered HTML is still missing them on the first byte. Render the root
with htmlAttrs(locale) in your server entry to ship them from the start instead:
import { htmlAttrs, Locale } from '@luwiostack/locale'
const html = renderToString(
<html {...htmlAttrs(Locale.new({ languageOrLocale: 'ar-EG' }))}>
{/* → <html lang="ar" dir="rtl"> */}
<body>{app}</body>
</html>,
)Domain model (no React required)
A Locale resolves its language(), country() and continent() as domain objects, so you
rarely need anything else. Country, Language and Continent come from
@luwiostack/country and @luwiostack/language — @luwiostack/locale
composes them as dependencies but does not re-export them, so import those from their own
packages when you want to build one directly.
import { Currency } from '@luwiostack/currency'
import { Locale } from '@luwiostack/locale'
const locale = Locale.new({ languageOrLocale: 'nl-BE' })
locale.language().machine_name // 'dutch'
locale.country().alpha3 // 'BEL'
locale.country().borders() // Countries → FR, DE, LU, NL
Currency.of(locale.country()).symbol // '€' — throws for a currency-less country (e.g. Antarctica)
locale.continent().machine_name // 'europe'
locale.languages().toArray().map((l) => l.machine_name) // ['dutch', 'french', 'german'] — spoken in BE
// A locale is valid when its language and country are each known — no "listed pair"
// requirement. Unknown parts throw:
Locale.new({ languageOrLocale: 'en', country: 'BE' }) // 'en-BE' (both parts exist)
Locale.new({ languageOrLocale: 'zz', country: 'BE' }) // throws — 'zz' isn't a languageLocale-aware sorting
Plain Array.prototype.sort() is case-sensitive binary order ('Zebra' sorts before 'apple'),
which is rarely what you want for user-facing lists. LocaleCollator.new(locale) binds an
Intl.Collator to a locale once — a shortcut for new Intl.Collator(locale.code), memoized for the
collator's lifetime — then sort() as many lists as you like without repeating the locale. It's a
separate formatter, not a method on Locale (this repo keeps behavior off domain objects and in
dedicated formatters):
import { Locale, LocaleCollator } from '@luwiostack/locale'
const locale = Locale.new({ languageOrLocale: 'nl-BE' })
const collator = LocaleCollator.new(locale)
collator.sort(['Zebra', 'apple', 'Mango']) // ['apple', 'Mango', 'Zebra']
// Sort by a string derived from each item — e.g. translated names, not English machine_names:
collator.sort(countries.toArray(), (c) => t(c.machine_name))
// Any Intl.CollatorOptions works too, e.g. natural ordering for numbered strings:
collator.sort(['item2', 'item10', 'item1'], { numeric: true }) // ['item1', 'item2', 'item10']In a component, skip the LocaleCollator.new(locale) step — useLocale() already bootstraps one from
the active locale and hands it back as sort, memoized so its identity is stable across renders:
function CountryList() {
const { sort } = useLocale()
return (
<ul>
{sort(Countries.all().toArray(), (c) => t(c.machine_name)).map((c) => (
<li key={c.code}>{t(c.machine_name)}</li>
))}
</ul>
)
}Locale resolution
Locale.resolve maps a detected locale onto the ones your app supports, with exact →
override → wildcard → same-language → catch-all fallback:
import { Locale } from '@luwiostack/locale'
Locale.resolve({
detected: Locale.system, // the runtime's detected locale
supported: ['nl-BE', 'fr-FR', 'en-US'], // optional — omit to accept any valid locale
overrides: { 'en-*': 'en-US', '*': 'nl-BE' }, // '*' catch-all is required
})
// No `supported` list → the whole dataset: any valid locale is returned as-is,
// only an unknown/missing one falls through to the overrides + '*' catch-all.
Locale.resolve({ detected: Locale.system, overrides: { '*': 'nl-BE' } })Locale routing
With @luwiostack/router, useRouteLocale() always returns a locale — the one
in the URL, or the router's configured default when the URL has none — so you never handle null.
Resolve it onto what you support, then hand the result to <Locale>:
import { Locale } from '@luwiostack/locale/react'
import { useRouteLocale } from '@luwiostack/router'
const SUPPORTED = ['nl-BE', 'fr-FR', 'en-US']
const OVERRIDES = { 'en-*': 'en-US', '*': 'nl-BE' } // '*' catch-all → default
function App() {
const { locale } = useRouteLocale() // always a locale (route's, or the router default)
const active = Locale.resolve({ detected: locale, supported: SUPPORTED, overrides: OVERRIDES })
return (
<Locale locale={active}>
<Site />
</Locale>
)
}An unsupported /pt-PT falls to the * catch-all (default); everything else resolves through the
usual rules (same-language → per-pattern → catch-all). No crash, no manual null handling.
Structure
src/
├── domain/ Locale, system-locale (→ Locale.system)
├── utils/ resolve-locale (→ Locale.resolve), normalizeLocale, matchLocalePattern,
│ create-locale (→ Locale.new), from-intl-locale (→ Locale.fromIntlLocale), htmlAttrs
├── formatters/ LocaleCollator (→ .new(locale).sort())
├── react/ LocaleContext, Locale, useLocale
└── types.ts ILocale, LocaleOverridesCountry, language and continent data live in @luwiostack/country / @luwiostack/language.
API surface
- React:
Locale,useLocale(returns{ locale, sort };localeis the activeLocale, same asLocale.new()→.code,.language(),.languages(),.country(),.continent(),.toIntlLocale();sortis aLocaleCollatorbound tolocale, memoized) - Factory:
Locale.new,Locale.tryNew,Locale.resolve,Locale.tryResolve,Locale.system,Locale.fromIntlLocale(build from a nativeIntl.Localeinstead of a string) - Formatters:
LocaleCollator.new(locale)→{ sort(items, by?, options?) }— locale-aware sort via a memoizedIntl.Collator - Utils:
normalizeLocale,matchLocalePattern,htmlAttrs(<html lang dir>attributes for SSR) - Types:
ILocale,LocaleOverrides
Country / Language / Continent and their types are not re-exported — they're internal
dependencies. Import them from @luwiostack/country / @luwiostack/language when you need them directly.
