Locales
defineLocales declares the application's locale set. Everything that depends on the list — membership, negotiation, the URL segment, the [locale] schema, the static paths and the locale of the render in progress — is a member of what it returns.
defineLocales(all, options?)
It takes an array of locale tags and an optional default. The tags are inferred as literal types, so no as const is needed.
// src/i18n.ts
import { defineLocales } from '@k8ordo/i18n';
import type { LocaleOf } from '@k8ordo/i18n';
export const locales = defineLocales(['en-US', 'en-GB', 'ja'], {
default: 'ja',
});
export type Locale = LocaleOf<typeof locales>;
declare module '@k8ordo/i18n' {
interface Register {
locale: Locale;
}
}Without default, the first tag is the default. The type of default is the union of the list, so a tag outside it fails to compile.
A wrong list throws a TypeError where it is defined, not later where it is used. A repeated tag and a tag that is not BCP 47 pass the type checker, so this check is the only thing that catches them. A default outside the list already fails to compile, and is checked again at run time for callers in JavaScript and values forced through as.
| Call | Error message |
|---|---|
defineLocales(['ja', 'en'], { default: 'fr' }) | defineLocales: the default "fr" is not in ["ja","en"] |
defineLocales(['ja', 'ja']) | defineLocales: a locale is listed twice in ["ja","ja"] |
defineLocales(['ja', 'not a tag']) | defineLocales: "not a tag" is not a BCP 47 language tag |
One set per application
defineLocales does more than return a value: it registers the default and the membership check that message() reads. message() does not take the set as an argument because that would make each declaration look like a side effect to the bundler, which could then no longer drop unused messages.
The last set defined wins, so that when a dev server re-evaluates the module after a locale is added, the next message called reads the new set. The flip side: defining another set with defineLocales inside a test, a demo or a helper makes every message read that set from then on. Define the set in one module, export it, and import it everywhere else.
What the set returns
The value is a Locales<L, D>, where L is the union of the tags and D is the default tag.
| Member | What it is |
|---|---|
all | The tags, in the order written. |
default | The default locale, used when negotiation finds nothing and when nothing names a locale. |
is(value) | Membership as a type guard. The comparison is exact and case-sensitive (is('EN') is false). |
negotiate(requested) | The best supported locale for a preference list (see Negotiation below). |
localize(pathname, locale) | Puts a locale segment in front of a pathname: '/ui' becomes '/en/ui', and '/' becomes '/en'. |
delocalize(pathname) | The inverse: '/en/ui' gives { locale: 'en', pathname: '/ui' }, and a pathname that starts with no locale gives locale: null. |
paths(patterns) | @k8ordo/static's paths option: every pattern with a /:locale segment, once per locale. |
paramsSchema | The [locale] segment's schema (Standard Schema). The locale it accepts becomes the locale of the render of the page that accepted it. |
getLocale() | The locale of the render in progress. Not a hook: call it anywhere. |
run(locale, fn) | Server only: runs fn, and everything it starts, under locale. |
How the members that deal with the URL — localize, delocalize, paths, paramsSchema, getLocale and run — are used is covered on a page of its own.
Types
| Type | What it is |
|---|---|
Locales<L, D> | The type defineLocales returns. |
LocaleOf<typeof locales> | The union of a set's tags. It is also what Register's locale is set to. |
LocalesOptions<D> | The type of the second argument ({ default?: D }). |
Delocalized<L> | What delocalize returns ({ locale: L | null; pathname: string }). pathname is never empty; it is / at least. |
LocaleParamsSchema<L> | The type of paramsSchema: the Standard Schema shape, declared by this package itself so that it depends on no schema library. |
BCP 47 tags
A tag is checked by whether Intl.Locale accepts it. A wrong separator such as en_US, or ***, is not BCP 47.
The tag as you spell it in the set is the URL segment: en-US gives /en-US/…. Spell tags the way you want them in URLs.
Negotiation
negotiate(requested) returns the one supported locale that best fits a preference list — navigator.languages, or a parsed Accept-Language header.
- Take the requested tags one at a time, in order.
- If a supported locale matches the tag exactly (ignoring case), return it.
- Otherwise, return the first supported locale that speaks the same language.
- Skip a tag that is not BCP 47 instead of throwing on it: the list is user input.
- If nothing matched by the end of the list, return the default.
- Negotiation's exact match ignores case (
en-gbmatchesen-GBand comes back spelled as the set spells it).isanddelocalizedo not ignore case. - Matching by language compares only the language subtag (
languageofIntl.Locale). Script and region are not weighed, so with['zh-Hans', 'zh-Hant']a request forzh-Hant-TWgetszh-Hans, the one written first. When several locales share a language, the order you write them in is the order a language match picks them in.
Each tag is tried down to its language before the next one is looked at, rather than searching the whole list for exact matches first, so that the visitor's first choice wins. Someone who sends ['en-US', 'ja'] wants English most; if the set has en, they get en, even though ja further down is an exact match.
The argument is an Iterable<string>, so navigator.languages, an array and a Set all pass as they are.
// src/preferred-locale.ts
import { parseAcceptLanguage } from '@k8ordo/i18n';
import { locales } from './i18n';
import type { Locale } from './i18n';
export const fromBrowser = (): Locale => locales.negotiate(navigator.languages);
export const fromHeaders = (headers: Headers): Locale =>
locales.negotiate(parseAcceptLanguage(headers.get('accept-language')));Worked examples
Results against defineLocales(['ja', 'en', 'en-GB']).
| Requested | Result | Why |
|---|---|---|
['en-GB'] | en-GB | Exact match |
['EN-gb'] | en-GB | Exact match, ignoring case |
['en-US', 'en-GB'] | en | The language of en-US already finds en |
['en-US', 'ja'] | en | The first choice's language comes before the second choice's exact match |
['ja-JP', 'en'] | ja | The language of ja-JP matches |
['***', 'en'] | en | *** is not BCP 47 and is skipped |
['fr', 'de'] | ja | Nothing matches; the default |
[] | ja | An empty list; the default |
parseAcceptLanguage(header)
Turns an Accept-Language header into a preference list negotiate accepts. It is how a server negotiates from a request header.
- Tags are ordered by their
qweight, highest first; a tag without one weighs 1. - Tags of equal weight keep the order the header gives them.
- Tags weighted 0 or less (an empty
q=reads as0), and the*wildcard, are dropped. The wildcard means "anything", which is what the default already means. - A missing header (
null) or a blank one is an empty list. - Spacing, an upper-case parameter name such as
Q=, and unknown parameters are tolerated. Aqthat is not a number, such asq=abc, is ignored, and the tag keeps a weight of 1. - Tags themselves are not validated;
negotiateskips the ones that are not BCP 47.
| Header | Result |
|---|---|
parseAcceptLanguage('en-US;q=0.8, ja, en;q=0.9, fr;q=0.8') | ['ja', 'en', 'en-US', 'fr'] |
parseAcceptLanguage('*;q=0.5, en;q=0, ja') | ['ja'] |
parseAcceptLanguage(' en ; foo=bar ; Q=0.5 ,ja') | ['ja', 'en'] |
parseAcceptLanguage(null) | [] |
Try negotiation
Edit the header and watch the list parseAcceptLanguage makes, which locale of the set each tag matched, and the answer negotiate gives. The set is this site's own locales (ja and en, default ja). There is no separate sample set because, as described above, a second defineLocales would replace the set every message on this page reads.
parseAcceptLanguage returnsfr-CHno matchfrno matchen-USsame language → enjanot consulted (already decided)
negotiate returnsen