Skip to main content
astilba ships a vendored copy of the CLDR plural rules in @astilba/core, rather than reading from the host’s Intl.PluralRules. This page explains why, what is covered, and what happens for languages not in the table.

Why vendor instead of Intl.PluralRules

Native i18next resolves plurals against the end user’s ambient browser or host ICU. That ICU version drifts across CLDR releases and across runtimes (Node, Bun, Deno, browsers, Workers). If astilba’s exporter emitted a suffix set that disagreed with what the client’s ICU asks for at runtime, the user would see a silent miss — the wrong form, or no form at all. Vendoring one table, used by both the exporter (which suffix keys to emit) and the round-trip resolver (which suffix a given count selects), makes emission host-independent and the fidelity harness fully deterministic. Every host agrees on what _one means.

What’s in the table

The vendored table covers the corpus-plan languages plus a few common ones, keyed by BCP-47 primary subtag:
import { SUPPORTED_LANGUAGES } from "@astilba/core";

SUPPORTED_LANGUAGES;
// → ["ar", "de", "en", "fr", "ja", "ko", "pl", "ru", "zh"]
Each entry carries three things:
  • categories — the ordered set of CLDR categories that apply to the language.
  • select(n) — the CLDR rule, mapping a count to a category.
  • representative — one count per category, used to build round-trip test vectors.
import { getPlurals } from "@astilba/core";

const en = getPlurals("en");
en.cardinal.categories;          // → ["one", "other"]
en.cardinal.select(1);           // → "one"
en.cardinal.representative;      // → { one: 1, other: 2 }

getPlurals("ru").cardinal.categories;   // → ["one", "few", "many", "other"]
getPlurals("ar").cardinal.categories;   // → ["zero","one","two","few","many","other"]
getPlurals("ja").cardinal.categories;   // → ["other"]   (no plural distinction)

Selecting a category and a representative count

Two helpers cover the common operations:
import { selectCategory, representativeCount } from "@astilba/core";

selectCategory(language, count, ordinal);
// selectCategory("en", 1, false)  → "one"
// selectCategory("en", 21, true)  → "one"   (21st — ordinal)

representativeCount(language, kind, category);
// representativeCount("ru", "cardinal", "few")  → 2
// representativeCount("ar", "cardinal", "zero") → 0
representativeCount has one i18next-specific quirk worth knowing: for cardinal zero, it returns 0 for every language, because i18next’s _zero special-case is reached at count 0 even for languages whose CLDR set has no zero category (like English).

Fallback for unsupported languages

Languages with no vendored rules fall back to other-only — a graceful single-category rule. This is safe (nothing crashes), but it would mis-model a real language that has more categories. So the i18next adapter, when parsing, rejects an unsupported language loudly (UNSUPPORTED_LANGUAGE) rather than silently approximating — unless you explicitly opt in with allowUnsupportedLanguage (useful for opaque variant tags like Weblate’s @pirate). Check ahead of time when you need to:
import { isSupportedLanguage } from "@astilba/core";

isSupportedLanguage("en");     // → true
isSupportedLanguage("en-US");  // → true   (region is collapsed to the primary subtag)
isSupportedLanguage("xh");     // → false  (falls back to "other"-only)

Known limitations

  • Region collapse. primarySubtag reduces pt-BR and pt-PT both to pt. A few languages have region-specific plural rules (most notably pt-PT differs from Brazilian Portuguese). This is harmless while the vendored table is small — those languages aren’t in it, so both fall back to other-only — but when the full make-plural table is vendored, region-specific entries must be looked up before the primary subtag. That’s a road-to-1.0 item.