web-locale-kit
v0.0.7
Published
TypeScript locale detector — resolves the user's language(s), timezone, UTC offset, and text direction (RTL/LTR) from Intl and navigator, with normalization and graceful fallbacks.
Maintainers
Readme
English · 한국어
web-locale-kit
A tiny TypeScript locale detector — resolves the user's language(s),
timezone, UTC offset, and text direction (RTL/LTR) from navigator and
Intl, with BCP-47 parsing and graceful fallbacks.
npm install web-locale-kitAPI at a glance
LocaleKit is a singleton. Fields are resolved at import and are read-only, except
languages, which can be set to override detection.
| Member | Type | Description |
| --- | --- | --- |
| LocaleKit.version | string | The installed package version |
| LocaleKit.language | string \| null | Primary normalized BCP-47 tag (e.g. "ko-KR"), or null if undetectable |
| LocaleKit.languages | readonly string[] | All detected tags, de-duplicated and normalized, in preference order |
| LocaleKit.subtags | LocaleSubtags \| null | language split into its parts |
| LocaleKit.fallbacks | readonly string[] | Fallback chain for language, most specific first |
| LocaleKit.timezone | string \| null | IANA timezone (e.g. "Asia/Seoul"), or null |
| LocaleKit.offset | number | Minutes ahead of UTC (e.g. Seoul → 540; opposite sign of getTimezoneOffset()) |
| LocaleKit.rtl | boolean | Whether the primary language is right-to-left |
| LocaleKit.parse(tag) | LocaleSubtags \| null | Parses any BCP-47 tag; null when malformed |
| LocaleKit.expand(tag) | readonly string[] | Fallback chain for any tag |
| LocaleKit.maximize(tag) | LocaleSubtags \| null | Fills in the likely script and region ("ko" → "ko-Kore-KR") |
| LocaleKit.lookup(supported, fallback?) | string \| null | Best match among the locales you ship |
| LocaleKit.reset() | void | Drops a languages override and re-reads the environment |
Where the values come from
Tags are read in the user's own order of preference and the first one wins:
navigator.languages → navigator.language → legacy IE fields → Intl.DateTimeFormat
Intl reports the locale the host negotiated for formatting, which is not always what
the user asked for, so it fills in behind navigator rather than ahead of it. Outside a
browser it is the only source, which is what makes LocaleKit work under Node.
Malformed entries are discarded, and extension sequences are stripped — en-US-u-ca-buddhist
is recorded as en-US, because a calendar preference is not a language. Use parse() when
you need the extensions back.
ESM
import LocaleKit from 'web-locale-kit'
console.log(LocaleKit.language) // "ko-KR"
console.log(LocaleKit.languages) // ["ko-KR", "en-US"]
console.log(LocaleKit.timezone) // "Asia/Seoul"
console.log(LocaleKit.offset) // 540 (UTC+9, in minutes)
console.log(LocaleKit.rtl) // false
document.documentElement.lang = LocaleKit.language || 'en'
document.documentElement.dir = LocaleKit.rtl ? 'rtl' : 'ltr'CommonJS
The bundle is built with exports: "named", so the singleton lives under .default:
const { default: LocaleKit } = require('web-locale-kit')
console.log(LocaleKit.language, LocaleKit.timezone)UMD (browser <script>)
The global LocaleKit is a namespace object; the singleton is LocaleKit.default.
<script src="https://unpkg.com/web-locale-kit/dist/locale-kit.umd.min.js"></script>
<script>
var locale = window.LocaleKit.default
document.documentElement.lang = locale.language || 'en'
document.documentElement.dir = locale.rtl ? 'rtl' : 'ltr'
</script>TypeScript
The singleton shape is exported as LocaleKitInstance, and the parsed-tag shape as
LocaleSubtags.
import LocaleKit, {
type LocaleKitInstance,
type LocaleSubtags,
} from 'web-locale-kit'
const lang: string | null = LocaleKit.language
const isRTL: boolean = LocaleKit.rtl
const chain: readonly string[] = LocaleKit.fallbacks
const locale: string | null = LocaleKit.lookup(['en', 'ko'], 'en')
function regionOf(tag: string): string | null {
const parsed: LocaleSubtags | null = LocaleKit.parse(tag)
return parsed === null ? null : parsed.region
}Subtags
language is the whole tag. When you need a part of it, subtags has it already parsed,
and parse() does the same for any tag you hand it. The field names follow Intl.Locale.
// LocaleKit.language === 'zh-Hant-TW'
LocaleKit.subtags.language // 'zh'
LocaleKit.subtags.script // 'Hant'
LocaleKit.subtags.region // 'TW'
LocaleKit.parse('ca-ES-valencia')
// { tag: 'ca-ES-valencia', baseName: 'ca-ES-valencia', language: 'ca',
// script: null, region: 'ES', variants: ['valencia'], extensions: [] }
LocaleKit.parse('en-US-u-ca-gregory')
// { tag: 'en-US-u-ca-gregory', baseName: 'en-US', language: 'en',
// script: null, region: 'US', variants: [], extensions: ['u-ca-gregory'] }
LocaleKit.parse('ko_KR.UTF-8@euro') // POSIX forms are accepted → { tag: 'ko-KR', … }
LocaleKit.parse('not a locale') // nullServing a bare en and a region-qualified en-US from one code path is the usual reason
to reach for this — check subtags.region instead of splitting the string yourself.
Choosing a locale
Most apps do not want to branch on region themselves; they want to know which of the
locales they actually ship to serve. That is lookup().
LocaleKit.lookup(['en', 'ko', 'ja']) // 'ko' for a Korean user, null for a French one
LocaleKit.lookup(['en', 'ko', 'ja'], 'en') // 'en' instead of nullIt walks your languages in preference order and, for each, its fallback chain from most to
least specific. An exact match wins; failing that a supported tag that merely refines the
request is taken, so asking for en accepts en-US rather than skipping to a language you
ranked lower. The returned string is the entry from your list, exactly as you wrote it.
fallbacks and expand() expose the same chain if you would rather match it yourself:
// language === 'zh-Hant-TW'
LocaleKit.fallbacks // ['zh-Hant-TW', 'zh-Hant', 'zh']
LocaleKit.expand('ca-ES-valencia') // ['ca-ES-valencia', 'ca-ES', 'ca']Overriding detection
languages is settable. Everything derived from it — language, subtags, fallbacks,
rtl — is recomputed, and reset() puts the detected environment back.
LocaleKit.languages = ['ar-EG', 'en'] // or a single string
LocaleKit.language // 'ar-EG'
LocaleKit.rtl // true
LocaleKit.reset()This is what makes the kit usable on a server, where the process locale is not the user's:
LocaleKit.languages = request.headers['accept-language'].split(',')
const locale = LocaleKit.lookup(SUPPORTED_LOCALES, 'en')
LocaleKit.reset()Note that this is shared singleton state — the override applies to every reader, so on a concurrent server resolve the value and reset within the same synchronous block.
Notes
- Resolved once at import. Values are computed when the module loads; they do not
update on their own if the user changes language or timezone mid-session. Assign to
languageswhen your app switches locale, or callreset()to re-read the environment. offsetis inverted vsgetTimezoneOffset(). Standard JS returns minutes behind UTC (Seoul →-540); this field flips the sign so positive means ahead of UTC (Seoul →540), which reads more intuitively.language/timezonemay benullin environments withoutIntlornavigator(e.g. some server/Node contexts). Guard before use.- RTL detection prefers
Intl.Locale.prototype.getTextInfo()where available. Otherwise the script subtag decides when there is one —ku-Arabis right-to-left whereku-Latnis not — and the language subtag decides when there is not. A language the built-in list calls right-to-left keeps that reading even where CLDR's likely script is Latin, so the answer does not change with the engine underneath. maximize()works withoutIntl. The likely script and region for 186 two-letter languages are built in (1.5 KB), so"ko"still becomes"ko-Kore-KR"on IE and on webviews too old forIntl.Locale. The engine's own answer always wins where there is one. Three-letter languages are not in the table and keep the parsed tag.- Server-side rendering. Detection runs once per process, so left alone every
request would see the server's own locale. Assign the request's
Accept-Languagetolanguages, read what you need, thenreset(). - The singleton is frozen. Fields are accessors, so assigning to anything other than
languagesis a no-op (and throws in strict mode).
Browser support
Runs down to IE 9.
