@jarenjs/locales
v0.91.4
Published
Locale packs (message catalogs) for @jarenjs/validate and @jarenjs/forms error messages
Downloads
241
Maintainers
Readme
@jarenjs/locales
Locale packs (message catalogs) for the error messages of
@jarenjs/validate,
@jarenjs/forms and
@jarenjs/contract, and the calendar language
@jarenjs/core/dates refuses to invent.
Jaren validators and form checks never bake prose into the hot path: every
failure carries a stable message key (msgid) plus raw structured
params, and human text is produced at report/render time through a
catalog - a plain flat object of closures. This package is the home of
the non-English catalogs. The normative contract (message-key registry,
template syntax, resolution precedence) lives in
packages/validate/docs/ERROR-MESSAGES.md.
Usage
Post-hoc localization of a collect-mode validation result:
import { JarenValidator, compileMessageCatalog, localizeErrors } from '@jarenjs/validate';
import { nl } from '@jarenjs/locales';
const catalog = compileMessageCatalog(nl);
const validator = new JarenValidator({ collectErrors: true });
const validate = validator.compile({ type: 'string', minLength: 2 });
const result = validate('x');
localizeErrors(result.errors, catalog);
// result.errors[0].message === 'mag niet minder dan 2 tekens bevatten'Per-keystroke form feedback in Dutch:
import { buildFormModel, validateAllFields, compileMessageCatalog } from '@jarenjs/forms';
import { nl } from '@jarenjs/locales';
const catalog = compileMessageCatalog(nl);
const errors = validateAllFields(model, data, catalog);
// errors['/name'][0].message === 'Dit veld is verplicht'Subpath import when you only want one pack (tree-shaking friendly either
way - the package is sideEffects: false):
import { nl } from '@jarenjs/locales/nl';Calendar language
@jarenjs/core/dates is locale-free by design: its MMMM, EEE and a
pattern tokens raise rather than fall back to English. Every pack carries
the data those tokens need, on the same flat msgid machinery as the error
messages, and compileDateLocale turns a pack into the frozen record a
formatter and a UI read:
import { compileDateFormat, parseRFC3339Parts } from '@jarenjs/core/dates';
import { compileDateLocale, nl } from '@jarenjs/locales';
const dates = compileDateLocale(nl);
const long = compileDateFormat('EEEE d MMMM yyyy', dates.names);
long(parseRFC3339Parts('2026-07-05')); // 'zondag 5 juli 2026'
dates.relative(-3, 'day'); // '3 dagen geleden'
dates.relative(-1, 'day', { numeric: 'auto' }); // 'gisteren'
dates.formatName('date-time'); // 'datum en tijd'The compiled record has three members: names (the five arrays
compileDateFormat takes), relative(amount, unit, options?) and
formatName(format). relative takes an explicit signed amount -
negative is past, positive is future - and reads no clock; deciding how
long ago something was is the caller's job, and phrasing it is this
package's. numeric: 'auto' reaches for yesterday/today/tomorrow
and now only where they are exact (a whole day away, a zero-second
offset); a fractional amount, an unsupported unit and an unknown mode are
each refused. Compilation reads the catalog once, so a formatter built
from names costs no message lookup per date.
The msgids are closed and mechanical, so a pack cannot half-cover them:
| Family | Keys |
|---|---|
| months | date/month/01…12 × /wide, /short |
| weekdays | date/weekday/0…6 × /wide, /short (Sunday = 0) |
| day period | date/meridiem/am, date/meridiem/pm |
| relative | date/relative/{second,minute,hour,day,week,month,year}/{past,future} |
| named days | date/relative/{now,yesterday,today,tomorrow} |
| format names | format/name/{date,time,date-time,iso-date,iso-time,iso-date-time} |
dateMessagesEn is the English set and the canonical key list. Name
entries take no parameters; a relative entry receives the absolute
amount as { value } and owns its language's plural and case grammar -
the direction is already in the key. A pack authors the forty name keys
through dateNameEntries({ months, monthsShort, weekdays, weekdaysShort,
meridiem }), which checks the lengths as it expands them.
Two consequences worth knowing before writing a pack. The month array
feeds a pattern, so a language that inflects months by position ships
the format forms rather than the standalone ones (Russian's
5 июля 2026, not июль). And a language whose abbreviations equal its
full names ships them equal (Arabic, Japanese, Chinese) rather than
inventing shorter ones.
Format display names, and why they exist
A format's name is its wire name - date-time, iso-time - which is
English by construction. Interpolated into a translated sentence it reads
as gibberish: a Dutch user used to be told "Moet een geldige date-time
zijn". @jarenjs/forms now resolves format/name/<format> through the
catalog it is rendering with, so the same failure reads "Moet een
geldige datum en tijd zijn" while the error's own
params.format stays 'date-time' - structural, so re-rendering it in a
second language answers in that language rather than repeating the first
one's noun. A format the catalog cannot name keeps its own, which is the
readable answer for names that are already words (email, hostname)
and the only possible one for a format this repository has never heard of.
The optional Intl provider
createIntlDateLocale(locale, options?) builds the same shape out of the
platform's ICU data, for a host that wants a hundred locales more than it
wants byte stability:
import { createIntlDateLocale } from '@jarenjs/locales/intl-dates';
const dates = createIntlDateLocale('hu-HU'); // same three membersIt is a separate module and an explicit call because it cannot be the
default. ICU output moves between Node versions and between a browser and
a server, and this repository's own site is server-rendered under
byte-comparison tests. Nothing on the default path constructs an Intl
object, and a bundler drops the provider from a bundle that never calls
it. ICU has no date-format display names, so that member carries the
repository's English ones unless options.formatNames overrides them.
The optional Intl zone provider
The same idea one seam over. @jarenjs/core/series reads its calendar
boundaries on a clock, and a clock that is a named zone needs a
provider — a tzdb — that the kernel refuses to bundle, because a
bundled table is megabytes that go stale on a government's timetable.
createIntlZoneProvider(options?) is that provider, over the host's ICU:
import { createIntlZoneProvider } from '@jarenjs/locales/intl-zones';
import { resolveClock, compileBuckets } from '@jarenjs/core/series';
const provider = createIntlZoneProvider();
const clock = resolveClock({ zone: 'Europe/Amsterdam', provider, disambiguation: 'later' });
clock.epochOf({ year: 2026, month: 3, day: 29, hours: 2, minutes: 30, seconds: 0 });
// 2026-03-29T01:30:00Z — 02:30 never happened that morning; 'later' is 03:30 CEST
compileBuckets('P1D', { zone: 'Australia/Adelaide', provider }).floor(Date.UTC(2026, 3, 5, 12));
// 2026-04-04T13:30:00Z — local midnight on the day the clock went back, 25 hours longThe seam is unchanged: @jarenjs/core still bundles no zone data, a
named zone with no provider is still a refusal, and this module is what
a host passes to answer it. Three rules hold it honest. It never reads
the host's own zone — every call names its zone, and there is no
default that asks the process where it is, because that hidden clock is
exactly what the seam exists to forbid. An unknown name is a refusal
naming it, never a quiet UTC that is right for eight months of the
year. And the hard half is answered exactly: on a spring-forward day
02:30 never happens and on a fall-back day it happens twice, so
toEpoch(parts, zone, disambiguation) verifies every candidate instant
by reading its wall clock back. Two that read back are a fold, and
'earlier'/'later' are the smaller and the larger; none is a gap, and
'earlier' is the wall clock read with the offset in force after the
transition (that far before the gap), 'later' the reading with the
offset before it (that far after) — one transition apart either way.
'reject', the kernel's default, answers both with NaN, which the
kernel turns into a refusal naming the local time. Offsets are derived
from the wall clock rather than parsed from a GMT+02:00 string, so a
historical -00:44:30 is exact.
Like createIntlDateLocale, it is opt-in for the reason the header of
that module gives: ICU output moves between Node versions and between a
browser and a server. Here that means the tzdata — a zone whose rules
changed last year answers differently on a host whose ICU predates the
change — so a consumer that needs the same instant on every host pins
the tzdata its hosts run. Nothing runs at module load, and a bundler
drops the provider from a bundle that never calls it. Formatters are
built once per zone and kept in a bounded, least-recently-used cache of
ZONE_CACHE_LIMIT zones (options.zones resizes it), so a generated
stream of zone names cannot grow it without limit.
Authoring a pack
A catalog is a plain flat object; each entry is either a template string or a closure:
export const xx = {
// template string: {name} substitutes the params member 'name';
// {{ escapes a literal brace; unknown names stay literal
pattern: 'must match pattern "{pattern}"',
// closure: full control, receives (params, error)
minLength: (p) => `at least ${p.limit} character${p.limit === 1 ? '' : 's'}`,
};Rules of the road:
- Key parity. Cover every key of validate's
messagesEn, everyform/*key of forms'formsMessagesEn, everycontract/*wire-error key of contract'scontractMessagesEn, everydate/*andformat/name/*key of this package's owndateMessagesEn, plusx-form/assertand theJQ2xxxquery runtime codes. The repo enforces this with tests (test/locales/); missing keys silently fall back to English. - Never depend on a consumer. A pack must stay importable without
dragging in
@jarenjs/validateor@jarenjs/forms, so that either one can serve any pack. The platform and@jarenjs/coreare the only things a pack may reach for - core is where the shared value renderer lives, so a pack cannot drift from the catalog contract by re-implementing it. - Use
Intl, as module-level singletons.Intl.PluralRulesfor plural categories (seenl.js: "1 teken" / "2 tekens"),Intl.NumberFormatfor{limit}-style numbers,Intl.ListFormatfor enum lists. Construct them once at module level - catalog entries run on the failure path, which is cheap, but allocation discipline is the house rule. - Bidi. Packs targeting RTL scripts should isolate interpolated user
values with FSI/PDI (U+2068/U+2069) so a Latin value cannot reorder
the surrounding text. That call belongs to the pack author, not core.
The harder rule, paid for by
ar.js: an ASCII comparison operator placed between RTL text and a number displays flipped - the operator is bidi-neutral, so>=before a Latin-digit limit reorders and reads as=<. Never interpolate{comparison}as a symbol. Arabic therefore renders the four operators validate emits forminimum/maximum/exclusiveMinimum/exclusiveMaximumas whole phrases ("يجب ألا تقل القيمة عن 2" - "the value must not be less than 2"), and falls back for an operator it does not know to the symbol wrapped in a directional isolate (U+2066 … U+2069) so it at least still reads left-to-right inside the RTL sentence. Do the same before shipping a Hebrew, Farsi or Urdu pack, and always eyeball the rendered strings - a flipped operator is silently valid text. - Voice. Validate keys speak about the document ("must have required
property 'x'");
form/*keys speak to the person filling the field ("This field is required"). Keep both voices.
Available packs
| Export | Subpath | Locale |
|--------|---------|--------|
| ar | ./ar | Arabic (العربية) - RTL |
| de | ./de | German (Deutsch) |
| es | ./es | Spanish (Español) |
| fr | ./fr | French (Français) |
| ja | ./ja | Japanese (日本語) |
| ko | ./ko | Korean (한국어) |
| nl | ./nl | Dutch (Nederlands) |
| pt | ./pt | Portuguese (Português) |
| ru | ./ru | Russian (Русский) |
| tr | ./tr | Turkish (Türkçe) |
| zhTW | ./zh-tw | Traditional Chinese, Taiwan (繁體中文) |
The named export is the locale tag camel-cased (zhTW), the subpath keeps
the tag itself (@jarenjs/locales/zh-tw).
Exports
Every subpath a consumer can import, derived from the manifest by
npm run docs:derive (npm run docs:check fails when the two drift):
| Import | Kind | Declarations |
|---|---|---|
| @jarenjs/locales | JavaScript | declared |
| @jarenjs/locales/dates | JavaScript | declared |
| @jarenjs/locales/intl-dates | JavaScript | declared |
| @jarenjs/locales/intl-zones | JavaScript | declared |
| @jarenjs/locales/ar | JavaScript | declared |
| @jarenjs/locales/de | JavaScript | declared |
| @jarenjs/locales/es | JavaScript | declared |
| @jarenjs/locales/fr | JavaScript | declared |
| @jarenjs/locales/ja | JavaScript | declared |
| @jarenjs/locales/ko | JavaScript | declared |
| @jarenjs/locales/nl | JavaScript | declared |
| @jarenjs/locales/pt | JavaScript | declared |
| @jarenjs/locales/ru | JavaScript | declared |
| @jarenjs/locales/tr | JavaScript | declared |
| @jarenjs/locales/zh-tw | JavaScript | declared |
| @jarenjs/locales/package.json | metadata | — |
Author JSON template catalogs and MessageSpec references with the messages pen; existing locale render functions retain their pluralization and formatting behavior.
