diff --git a/examples/create-react-app/src/App.test.tsx b/examples/create-react-app/src/App.test.tsx
index 58ba5dea9..9b22b362e 100644
--- a/examples/create-react-app/src/App.test.tsx
+++ b/examples/create-react-app/src/App.test.tsx
@@ -1,42 +1,39 @@
-import React from 'react'
-import { getByText, render, act } from '@testing-library/react'
-import { i18n } from '@lingui/core'
-import { I18nProvider } from '@lingui/react'
-import { en, cs } from 'make-plural/plurals'
+import React from "react"
+import { getByText, render, act } from "@testing-library/react"
+import { i18n } from "@lingui/core"
+import { I18nProvider } from "@lingui/react"
-import { messages } from './locales/en/messages'
-import { messages as csMessages } from './locales/cs/messages'
-import App from './App'
+import { messages } from "./locales/en/messages"
+import { messages as csMessages } from "./locales/cs/messages"
+import App from "./App"
i18n.load({
en: messages,
- cs: csMessages
+ cs: csMessages,
})
-i18n.loadLocaleData({
- en: { plurals: en },
- cs: { plurals: cs }
-});
const TestingProvider = ({ children }: any) => (
-
- {children}
-
+ {children}
)
-test('Test that lang is translated correctly in English' , () => {
+test("Test that lang is translated correctly in English", () => {
act(() => {
- i18n.activate('en')
+ i18n.activate("en")
})
- const { getByTestId, container } = render(, { wrapper: TestingProvider });
- expect(getByTestId('h3-title')).toBeInTheDocument()
+ const { getByTestId, container } = render(, {
+ wrapper: TestingProvider,
+ })
+ expect(getByTestId("h3-title")).toBeInTheDocument()
expect(getByText(container, "Language switcher example:")).toBeDefined()
-});
+})
-test('Test that lang is translated correctly in Czech', () => {
+test("Test that lang is translated correctly in Czech", () => {
act(() => {
- i18n.activate('cs')
+ i18n.activate("cs")
})
- const { getByTestId, container } = render(, { wrapper: TestingProvider });
- expect(getByTestId('h3-title')).toBeInTheDocument()
+ const { getByTestId, container } = render(, {
+ wrapper: TestingProvider,
+ })
+ expect(getByTestId("h3-title")).toBeInTheDocument()
expect(getByText(container, "Příklad přepínače jazyků:")).toBeDefined()
-});
\ No newline at end of file
+})
diff --git a/examples/create-react-app/src/i18n.ts b/examples/create-react-app/src/i18n.ts
index 1b43a162a..ce317c71b 100644
--- a/examples/create-react-app/src/i18n.ts
+++ b/examples/create-react-app/src/i18n.ts
@@ -1,23 +1,19 @@
-import { i18n } from "@lingui/core";
-import { en, cs } from 'make-plural/plurals'
+import { i18n } from "@lingui/core"
export const locales = {
en: "English",
cs: "Česky",
-};
-export const defaultLocale = "en";
-
-i18n.loadLocaleData({
- en: { plurals: en },
- cs: { plurals: cs },
-})
+}
+export const defaultLocale = "en"
/**
* We do a dynamic import of just the catalog that we need
* @param locale any locale string
*/
export async function dynamicActivate(locale: string) {
- const { messages } = await import(`@lingui/loader!./locales/${locale}/messages.po`)
+ const { messages } = await import(
+ `@lingui/loader!./locales/${locale}/messages.po`
+ )
i18n.load(locale, messages)
i18n.activate(locale)
}
diff --git a/examples/js/src/ids.js b/examples/js/src/ids.js
index fa17cd866..42bb3159c 100644
--- a/examples/js/src/ids.js
+++ b/examples/js/src/ids.js
@@ -1,9 +1,5 @@
import { i18n } from "@lingui/core"
import { t, plural, defineMessage } from "@lingui/macro"
-import { en, cs } from "make-plural/plurals"
-
-i18n.loadLocaleData("en", { plurals: en })
-i18n.loadLocaleData("cs", { plurals: cs })
i18n.load({
en: require("./locale/en/messages").messages,
diff --git a/examples/js/src/messages.js b/examples/js/src/messages.js
index 16e4b07f1..378e17b13 100644
--- a/examples/js/src/messages.js
+++ b/examples/js/src/messages.js
@@ -1,9 +1,5 @@
import { i18n } from "@lingui/core"
import { t, plural, defineMessage } from "@lingui/macro"
-import { en, cs } from "make-plural/plurals"
-
-i18n.loadLocaleData("en", { plurals: en })
-i18n.loadLocaleData("cs", { plurals: cs })
i18n.load({
en: require("./locale/en/messages").messages,
diff --git a/examples/next-js/lingui-example/i18n.ts b/examples/next-js/lingui-example/i18n.ts
index 6602d1f46..adc1efded 100644
--- a/examples/next-js/lingui-example/i18n.ts
+++ b/examples/next-js/lingui-example/i18n.ts
@@ -1,8 +1,4 @@
import { i18n } from "@lingui/core"
-import { en, cs } from "make-plural/plurals"
-
-i18n.loadLocaleData("en", { plurals: en })
-i18n.loadLocaleData("cs", { plurals: cs })
/**
* Load messages for requested locale and activate it.
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 58ab51a4e..161be25b5 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -56,7 +56,6 @@
"@lingui/core": "4.0.0-next.0",
"@messageformat/parser": "^5.0.0",
"babel-plugin-macros": "^3.0.1",
- "bcp-47": "^1.0.7",
"chalk": "^4.1.0",
"chokidar": "3.5.1",
"cli-table": "0.3.6",
@@ -65,7 +64,6 @@
"date-fns": "^2.16.1",
"glob": "^7.1.4",
"inquirer": "^7.3.3",
- "make-plural": "^6.2.2",
"micromatch": "4.0.2",
"mkdirp": "^1.0.4",
"node-gettext": "^3.0.0",
diff --git a/packages/cli/src/api/locales.test.ts b/packages/cli/src/api/locales.test.ts
deleted file mode 100644
index d17bb61a6..000000000
--- a/packages/cli/src/api/locales.test.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import * as locales from "./locales"
-
-describe("Catalog formats utilities - locales", function () {
- it("isValid - should validate locale format", function () {
- expect(locales.isValid("en")).toBeTruthy()
- expect(locales.isValid("en_US")).toBeTruthy()
- expect(locales.isValid("en-US")).toBeTruthy()
- expect(locales.isValid("zh-Hans-TW")).toBeTruthy()
- expect(locales.isValid("xyz")).toBeFalsy()
- })
-
- it("parse - should parse language and country from locale", function () {
- expect(locales.parse("en")).toEqual({ locale: "en", language: "en" })
- expect(locales.parse("en_US")).toEqual({ locale: "en-US", language: "en" })
- expect(locales.parse("en-US")).toEqual({ locale: "en-US", language: "en" })
- })
-})
diff --git a/packages/cli/src/api/locales.ts b/packages/cli/src/api/locales.ts
deleted file mode 100644
index 2568e9cdb..000000000
--- a/packages/cli/src/api/locales.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import * as plurals from "make-plural/plurals"
-import bcp47 from "bcp-47"
-
-export type LocaleInfo = {
- locale: string
- language: string
-}
-
-/**
- * Check that locale is valid according to BCP47 and we have plurals for it
- * @param locale: string - Locale in BCP47 format
- * @return {boolean}
- */
-export function isValid(locale: string): boolean {
- const localeData = parse(locale)
- return (
- localeData !== null &&
- localeData !== undefined &&
- localeData.language in plurals
- )
-}
-
-/**
- * Parse locale in BCP47 format and
- * @param locale - Locale in BCP47 format
- * @return {LocaleInfo}
- */
-export function parse(locale: string): LocaleInfo | null {
- if (typeof locale !== "string") return null
-
- const schema = bcp47.parse(locale.replace("_", "-"))
- if (!schema.language) return null
-
- return {
- locale: bcp47.stringify(schema),
- language: schema.language,
- }
-}
diff --git a/packages/cli/src/lingui-compile.ts b/packages/cli/src/lingui-compile.ts
index 21286ab83..48ff22f24 100644
--- a/packages/cli/src/lingui-compile.ts
+++ b/packages/cli/src/lingui-compile.ts
@@ -2,7 +2,6 @@ import chalk from "chalk"
import chokidar from "chokidar"
import fs from "fs"
import { program } from "commander"
-import * as plurals from "make-plural"
import { getConfig, LinguiConfigNormalized } from "@lingui/conf"
@@ -33,18 +32,6 @@ export function command(
console.log("Compiling message catalogs…")
for (const locale of config.locales) {
- const [language] = locale.split(/[_-]/)
- // todo: this validation should be in @lingui/conf
- // todo: validate locales according bcp47, instead of plurals
- if (locale !== config.pseudoLocale && !(plurals as any)[language]) {
- console.error(
- chalk.red(
- `Error: Invalid locale ${chalk.bold(locale)} (missing plural rules)!`
- )
- )
- console.error()
- }
-
for (const catalog of catalogs) {
const missingMessages: TranslationMissingEvent[] = []
diff --git a/packages/cli/src/test/__snapshots__/compile.test.ts.snap b/packages/cli/src/test/__snapshots__/compile.test.ts.snap
index 123c76f9c..b4f0b0134 100644
--- a/packages/cli/src/test/__snapshots__/compile.test.ts.snap
+++ b/packages/cli/src/test/__snapshots__/compile.test.ts.snap
@@ -1,10 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`CLI Command: Compile Locales Validation Should throw error for invalid locale 1`] = `
-Error: Invalid locale abra (missing plural rules)!
-
-`;
-
exports[`CLI Command: Compile allowEmpty = false Should show error and stop compilation of catalog if message doesnt have a translation (no template) 1`] = `
Error: Failed to compile catalog for locale pl!
Missing 1 translation(s)
diff --git a/packages/cli/src/test/compile.test.ts b/packages/cli/src/test/compile.test.ts
index a0fee9062..53f4713ca 100644
--- a/packages/cli/src/test/compile.test.ts
+++ b/packages/cli/src/test/compile.test.ts
@@ -9,58 +9,6 @@ describe("CLI Command: Compile", () => {
// todo
})
- describe("Locales Validation", () => {
- // todo: should be moved to @lingui/conf
- it("Should throw error for invalid locale", () => {
- const config = makeConfig({
- locales: ["abra"],
- rootDir: "/test",
- catalogs: [
- {
- path: "/{locale}",
- include: [""],
- exclude: [],
- },
- ],
- })
-
- mockFs()
-
- mockConsole((console) => {
- const result = command(config, {})
- mockFs.restore()
- const log = getConsoleMockCalls(console.error)
- expect(log).toMatchSnapshot()
-
- expect(result).toBeTruthy()
- })
- })
-
- it("Should not throw error for pseudolocale", () => {
- const config = makeConfig({
- locales: ["abracadabra"],
- rootDir: "/test",
- pseudoLocale: "abracadabra",
- catalogs: [
- {
- path: "/{locale}",
- include: [""],
- exclude: [],
- },
- ],
- })
-
- mockFs()
-
- mockConsole((console) => {
- const result = command(config, {})
- mockFs.restore()
- expect(console.error).not.toBeCalled()
- expect(result).toBeTruthy()
- })
- })
- })
-
describe("allowEmpty = false", () => {
const config = makeConfig({
locales: ["en", "pl"],
diff --git a/packages/core/package.json b/packages/core/package.json
index fc8b3b9f6..8f1254c3b 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -60,8 +60,7 @@
],
"dependencies": {
"@babel/runtime": "^7.20.13",
- "@messageformat/parser": "^5.0.0",
- "make-plural": "^6.2.2"
+ "@messageformat/parser": "^5.0.0"
},
"devDependencies": {
"@lingui/jest-mocks": "*"
diff --git a/packages/core/src/compile/compileMessage.test.ts b/packages/core/src/compile/compileMessage.test.ts
index 7e2770816..6b14a0727 100644
--- a/packages/core/src/compile/compileMessage.test.ts
+++ b/packages/core/src/compile/compileMessage.test.ts
@@ -2,25 +2,11 @@ import { compileMessage as compile } from "./compileMessage"
import { mockEnv, mockConsole } from "@lingui/jest-mocks"
import { interpolate } from "../context"
import { Locale, Locales } from "../i18n"
-import { PluralCategory } from "make-plural"
describe("compile", () => {
- const englishPlurals = {
- plurals(value: number, ordinal: boolean) {
- if (ordinal) {
- return (
- ({ "1": "one", "2": "two", "3": "few" }[value] as PluralCategory) ||
- "other"
- )
- } else {
- return value === 1 ? "one" : "other"
- }
- },
- }
-
const prepare = (translation: string, locale?: Locale, locales?: Locales) => {
const tokens = compile(translation)
- return interpolate(tokens, locale || "en", locales, englishPlurals)
+ return interpolate(tokens, locale || "en", locales)
}
it("should handle an error if message has syntax errors", () => {
@@ -62,9 +48,7 @@ describe("compile", () => {
it("should compile message with variable", () => {
const cache = compile("Hey {name}!")
- expect(interpolate(cache, "en", [], {})({ name: "Joe" })).toEqual(
- "Hey Joe!"
- )
+ expect(interpolate(cache, "en", [])({ name: "Joe" })).toEqual("Hey Joe!")
})
it("should not interpolate escaped placeholder", () => {
@@ -96,6 +80,32 @@ describe("compile", () => {
expect(cache({ value: 2 })).toEqual("2nd Book")
})
+ it("should support nested choice components", () => {
+ const cache = prepare(
+ `{
+ gender, select,
+ male {{numOfGuests, plural, one {He invites one guest} other {He invites # guests}}}
+ female {{numOfGuests, plural, one {She invites one guest} other {She invites # guests}}}
+ other {They is {gender}}}`
+ )
+
+ expect(cache({ numOfGuests: 1, gender: "male" })).toEqual(
+ "He invites one guest"
+ )
+ expect(cache({ numOfGuests: 3, gender: "male" })).toEqual(
+ "He invites 3 guests"
+ )
+ expect(cache({ numOfGuests: 1, gender: "female" })).toEqual(
+ "She invites one guest"
+ )
+ expect(cache({ numOfGuests: 3, gender: "female" })).toEqual(
+ "She invites 3 guests"
+ )
+ expect(cache({ numOfGuests: 3, gender: "unknown" })).toEqual(
+ "They is unknown"
+ )
+ })
+
it("should compile select", () => {
const cache = prepare("{value, select, female {She} other {They}}")
expect(cache({ value: "female" })).toEqual("She")
@@ -137,7 +147,7 @@ describe("compile", () => {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
- } as Intl.NumberFormatOptions,
+ } satisfies Intl.NumberFormatOptions,
}
const currency = prepare("{value, number, currency}", locale, locales)
expect(currency({ value: 0.1 }, formats)).toEqual(expectedCurrency1)
diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts
index fe03c3675..f42feed61 100644
--- a/packages/core/src/context.ts
+++ b/packages/core/src/context.ts
@@ -1,100 +1,71 @@
-import { CompiledMessage, Formats, LocaleData, Locales, Values } from "./i18n"
-import { date, number } from "./formats"
-import { isString, isFunction } from "./essentials"
+import { CompiledMessage, Formats, Locales, Values } from "./i18n"
+import { date, number, plural } from "./formats"
+import { isString } from "./essentials"
export const UNICODE_REGEX = /\\u[a-fA-F0-9]{4}|\\x[a-fA-F0-9]{2}/g
-const defaultFormats = (
+const getDefaultFormats = (
locale: string,
locales: Locales,
- localeData: LocaleData = { plurals: undefined },
formats: Formats = {}
) => {
locales = locales || locale
- const { plurals } = localeData
const style = (format: string | T): T =>
isString(format) ? formats[format] || { style: format } : (format as any)
- const replaceOctothorpe = (value: number, message) => {
- return (ctx) => {
- const msg = isFunction(message) ? message(ctx) : message
- const norm = Array.isArray(msg) ? msg : [msg]
- const numberFormat = Object.keys(formats).length ? style("number") : {}
- const valueStr = number(locales, numberFormat)(value)
- return norm.map((m) => (isString(m) ? m.replace("#", valueStr) : m))
- }
- }
- if (!plurals) {
- console.error(
- `Plurals for locale ${locale} aren't loaded. Use i18n.loadLocaleData method to load plurals for specific locale. Using other plural rule as a fallback.`
- )
+ const replaceOctothorpe = (value: number, message: string): string => {
+ const numberFormat = Object.keys(formats).length ? style("number") : {}
+ const valueStr = number(locales, value, numberFormat)
+ return message.replace("#", valueStr)
}
return {
- plural: (value: number, { offset = 0, ...rules }) => {
- const message =
- rules[value] || rules[plurals?.(value - offset)] || rules.other
+ plural: (value: number, cases) => {
+ const { offset = 0 } = cases
+ const message = plural(locales, false, value, cases)
return replaceOctothorpe(value - offset, message)
},
- selectordinal: (value: number, { offset = 0, ...rules }) => {
- const message =
- rules[value] || rules[plurals?.(value - offset, true)] || rules.other
+ selectordinal: (value: number, cases) => {
+ const { offset = 0 } = cases
+ const message = plural(locales, true, value, cases)
+
return replaceOctothorpe(value - offset, message)
},
select: (value: string, rules) => rules[value] || rules.other,
- number: (value: number, format: string | Intl.NumberFormatOptions) =>
- number(locales, style(format))(value),
+ number: (
+ value: number,
+ format: string | Intl.NumberFormatOptions
+ ): string => number(locales, value, style(format)),
- date: (value: string, format: string | Intl.DateTimeFormatOptions) =>
- date(locales, style(format))(value),
+ date: (
+ value: string,
+ format: string | Intl.DateTimeFormatOptions
+ ): string => date(locales, value, style(format)),
undefined: (value: unknown) => value,
}
}
-// Params -> CTX
/**
- * Creates a context object, which formats ICU MessageFormat arguments based on
- * argument type.
- *
- * @param locale - Locale of message
- * @param locales - Locales to be used when formatting the numbers or dates
- * @param values - Parameters for variable interpolation
- * @param localeData - Locale data (e.g: plurals)
- * @param formats - Custom format styles
- * @returns {function(string, string, any)}
+ * @param translation compiled message
+ * @param locale Locale of message
+ * @param locales Locales to be used when formatting the numbers or dates
*/
-function context(
- locale: string,
- locales: Locales,
- values: Values,
- formats: Formats,
- localeData: LocaleData
-) {
- const formatters = defaultFormats(locale, locales, localeData, formats)
-
- const ctx = (name: string, type: string, format: any): string => {
- const value = values[name]
- const formatted = formatters[type](value, format)
- const message = isFunction(formatted) ? formatted(ctx) : formatted
- return Array.isArray(message) ? message.join("") : message
- }
-
- return ctx
-}
-
export function interpolate(
translation: CompiledMessage,
locale: string,
- locales: Locales,
- localeData: LocaleData
+ locales: Locales
) {
+ /**
+ * @param values - Parameters for variable interpolation
+ * @param formats - Custom format styles
+ */
return (values: Values, formats: Formats = {}): string => {
- const ctx = context(locale, locales, values, formats, localeData)
+ const formatters = getDefaultFormats(locale, locales, formats)
const formatMessage = (message: CompiledMessage): string => {
if (!Array.isArray(message)) return message
@@ -113,7 +84,7 @@ export function interpolate(
interpolatedFormat = format
}
- const value = ctx(name, type, interpolatedFormat)
+ const value = formatters[type](values[name], interpolatedFormat)
if (value == null) return message
return message + value
diff --git a/packages/core/src/formats.test.ts b/packages/core/src/formats.test.ts
index 0fbe9b955..e4a32e845 100644
--- a/packages/core/src/formats.test.ts
+++ b/packages/core/src/formats.test.ts
@@ -1,30 +1,52 @@
import { date, number } from "./formats"
describe("@lingui/core/formats", () => {
- it("number formatter is memoized", async () => {
- const firstRunt0 = performance.now()
- number("es", {})(10000)
- const firstRunt1 = performance.now()
- const firstRunResult = firstRunt1 - firstRunt0
+ describe("date", () => {
+ it("should support Date as input", () => {
+ expect(date(["en"], new Date(2023, 2, 5))).toMatchInlineSnapshot(
+ `"3/5/2023"`
+ )
+ })
+ it("should support iso string as input", () => {
+ expect(
+ date(["en"], new Date(2023, 2, 5).toISOString())
+ ).toMatchInlineSnapshot(`"3/5/2023"`)
+ })
- const seconddRunt0 = performance.now()
- number("es", {}, false)(10000)
- const seconddRunt1 = performance.now()
- const secondRunResult = seconddRunt1 - seconddRunt0
+ it("should pass format options", () => {
+ expect(
+ date(["en"], new Date(2023, 2, 5).toISOString(), { dateStyle: "full" })
+ ).toMatchInlineSnapshot(`"Sunday, March 5, 2023"`)
- expect(secondRunResult).toBeLessThan(firstRunResult)
+ expect(
+ date(["en"], new Date(2023, 2, 5).toISOString(), {
+ dateStyle: "medium",
+ })
+ ).toMatchInlineSnapshot(`"Mar 5, 2023"`)
+ })
+
+ it("should respect passed locale", () => {
+ expect(
+ date(["pl"], new Date(2023, 2, 5).toISOString(), { dateStyle: "full" })
+ ).toMatchInlineSnapshot(`"niedziela, 5 marca 2023"`)
+ })
})
- it("date formatter is memoized", async () => {
- const firstRunt0 = performance.now()
- date("es", {})(new Date())
- const firstRunt1 = performance.now()
- const firstRunResult = firstRunt1 - firstRunt0
- const seconddRunt0 = performance.now()
- date("es", {}, false)(new Date())
- const seconddRunt1 = performance.now()
- const secondRunResult = seconddRunt1 - seconddRunt0
+ describe("number", () => {
+ it("should pass format options", () => {
+ expect(
+ number(["en"], 1000, { style: "currency", currency: "EUR" })
+ ).toMatchInlineSnapshot(`"€1,000.00"`)
+
+ expect(
+ number(["en"], 1000, { maximumSignificantDigits: 3 })
+ ).toMatchInlineSnapshot(`"1,000"`)
+ })
- expect(secondRunResult).toBeLessThan(firstRunResult)
+ it("should respect passed locale", () => {
+ expect(
+ number(["pl"], 1000, { style: "currency", currency: "EUR" })
+ ).toMatchInlineSnapshot(`"1000,00 €"`)
+ })
})
})
diff --git a/packages/core/src/formats.ts b/packages/core/src/formats.ts
index 1fe35c1ab..f62d575ca 100644
--- a/packages/core/src/formats.ts
+++ b/packages/core/src/formats.ts
@@ -2,58 +2,82 @@ import { isString } from "./essentials"
import { Locales } from "./i18n"
/** Memoized cache */
-const numberFormats = new Map()
-const dateFormats = new Map()
+const cache = new Map()
+
+function normalizeLocales(locales: Locales): string[] {
+ const out = Array.isArray(locales) ? locales : [locales]
+ return [...out, "en"]
+}
export function date(
locales: Locales,
- format: Intl.DateTimeFormatOptions = {},
- memoize: boolean = true
-): (value: string | Date) => string {
- return (value) => {
- if (isString(value)) value = new Date(value)
- if (memoize) {
- const key = cacheKey(locales, format)
- const cachedFormatter = dateFormats.get(key)
- if (cachedFormatter) {
- return cachedFormatter.format(value)
- }
-
- const formatter = new Intl.DateTimeFormat(locales, format)
- dateFormats.set(key, formatter)
- return formatter.format(value)
- }
-
- const formatter = new Intl.DateTimeFormat(locales, format)
- return formatter.format(value)
- }
+ value: string | Date,
+ format: Intl.DateTimeFormatOptions = {}
+): string {
+ const _locales = normalizeLocales(locales)
+
+ const formatter = getMemoized(
+ () => cacheKey("date", _locales, format),
+ () => new Intl.DateTimeFormat(_locales, format)
+ )
+
+ return formatter.format(isString(value) ? new Date(value) : value)
}
export function number(
locales: Locales,
- format: Intl.NumberFormatOptions = {},
- memoize: boolean = true
-): (value: number) => string {
- return (value) => {
- if (memoize) {
- const key = cacheKey(locales, format)
- const cachedFormatter = numberFormats.get(key)
- if (cachedFormatter) {
- return cachedFormatter.format(value)
- }
-
- const formatter = new Intl.NumberFormat(locales, format)
- numberFormats.set(key, formatter)
- return formatter.format(value)
- }
-
- const formatter = new Intl.NumberFormat(locales, format)
- return formatter.format(value)
+ value: number,
+ format: Intl.NumberFormatOptions = {}
+): string {
+ const _locales = normalizeLocales(locales)
+
+ const formatter = getMemoized(
+ () => cacheKey("number", _locales, format),
+ () => new Intl.NumberFormat(_locales, format)
+ )
+
+ return formatter.format(value)
+}
+
+export function plural(
+ locales: Locales,
+ ordinal: boolean,
+ value: number,
+ { offset = 0, ...rules }
+): string {
+ const _locales = normalizeLocales(locales)
+
+ const plurals = ordinal
+ ? getMemoized(
+ () => cacheKey("plural-ordinal", _locales, {}),
+ () => new Intl.PluralRules(_locales, { type: "ordinal" })
+ )
+ : getMemoized(
+ () => cacheKey("plural-cardinal", _locales, {}),
+ () => new Intl.PluralRules(_locales, { type: "cardinal" })
+ )
+
+ return rules[value] || rules[plurals.select(value - offset)] || rules.other
+}
+
+function getMemoized(getKey: () => string, construct: () => T) {
+ const key = getKey()
+
+ let formatter = cache.get(key) as T
+
+ if (!formatter) {
+ formatter = construct()
+ cache.set(key, formatter)
}
+
+ return formatter
}
-/** Memoize helpers */
-function cacheKey(locales?: string | string[], options: T = {} as T) {
- const localeKey = Array.isArray(locales) ? locales.sort().join("-") : locales
- return `${localeKey}-${JSON.stringify(options)}`
+function cacheKey(
+ type: string,
+ locales?: readonly string[],
+ options: unknown = {}
+) {
+ const localeKey = [...locales].sort().join("-")
+ return `${type}-${localeKey}-${JSON.stringify(options)}`
}
diff --git a/packages/core/src/i18n.test.ts b/packages/core/src/i18n.test.ts
index 37404729f..9493e0ac9 100644
--- a/packages/core/src/i18n.test.ts
+++ b/packages/core/src/i18n.test.ts
@@ -1,7 +1,7 @@
import { setupI18n } from "@lingui/core"
import { mockConsole, mockEnv } from "@lingui/jest-mocks"
-describe("I18n", function () {
+describe("I18n", () => {
describe("I18n.load", () => {
it("should emit event", () => {
const i18n = setupI18n()
@@ -56,9 +56,6 @@ describe("I18n", function () {
messages: {
en: {},
},
- localeData: {
- en: {},
- },
})
const cbChange = jest.fn()
@@ -69,7 +66,6 @@ describe("I18n", function () {
it("should activate instantly", () => {
const i18n = setupI18n({
- locales: ["en", "es"],
messages: {
en: {
Hello: "Hello",
@@ -78,10 +74,6 @@ describe("I18n", function () {
Hello: "Hola",
},
},
- localeData: {
- en: {},
- es: {},
- },
})
i18n.activate("en")
@@ -119,9 +111,6 @@ describe("I18n", function () {
expect(console.warn).toBeCalledWith(
'Messages for locale "xyz" not loaded.'
)
- expect(console.warn).toBeCalledWith(
- 'Locale data for locale "xyz" not loaded. Plurals won\'t work correctly.'
- )
})
mockEnv("production", () => {
@@ -136,7 +125,7 @@ describe("I18n", function () {
})
})
- it("._ should format message from catalog", function () {
+ it("._ should format message from catalog", () => {
const messages = {
Hello: "Salut",
"My name is {name}": "Je m'appelle {name}",
@@ -169,7 +158,7 @@ describe("I18n", function () {
).toEqual("Missing Fred")
})
- it("._ should translate message from variable", function () {
+ it("._ should translate message from variable", () => {
const messages = {
Hello: "Salut",
}
@@ -195,7 +184,7 @@ describe("I18n", function () {
expect(i18n._("My ''name'' is '{name}'")).toEqual("Mi 'nombre' es {name}")
})
- it("._ shouldn't compile messages in production", function () {
+ it("._ shouldn't compile messages in production", () => {
const messages = {
Hello: "Salut",
"My name is {name}": "Je m'appelle {name}",
@@ -232,8 +221,8 @@ describe("I18n", function () {
})
})
- describe("params.missing - handling missing translations", function () {
- it("._ should return custom string for missing translations", function () {
+ describe("params.missing - handling missing translations", () => {
+ it("._ should return custom string for missing translations", () => {
const i18n = setupI18n({
missing: "xxx",
locale: "en",
@@ -243,10 +232,13 @@ describe("I18n", function () {
expect(i18n._("missing")).toEqual("xxx")
})
- it("._ should call a function with message ID of missing translation", function () {
+ it("._ should call a function with message ID of missing translation", () => {
const missing = jest.fn((locale, id) => id.split("").reverse().join(""))
const i18n = setupI18n({
locale: "en",
+ messages: {
+ en: {},
+ },
missing,
})
expect(i18n._("missing")).toEqual("gnissim")
diff --git a/packages/core/src/i18n.ts b/packages/core/src/i18n.ts
index 482fb7c82..db6f355c8 100644
--- a/packages/core/src/i18n.ts
+++ b/packages/core/src/i18n.ts
@@ -3,7 +3,6 @@ import { isString, isFunction } from "./essentials"
import { date, number } from "./formats"
import { compileMessage } from "@lingui/core/compile"
import { EventEmitter } from "./eventEmitter"
-import type { PluralCategory } from "make-plural"
export type MessageOptions = {
message?: string
@@ -19,10 +18,19 @@ export type Formats = Record<
export type Values = Record
+/**
+ * @deprecated Plurals automatically used from Intl.PluralRules you can safely remove this call. Introduced in v4
+ */
export type LocaleData = {
- plurals?: (n: number, ordinal?: boolean) => PluralCategory
+ plurals?: (
+ n: number,
+ ordinal?: boolean
+ ) => ReturnType
}
+/**
+ * @deprecated Plurals automatically used from Intl.PluralRules you can safely remove this call. Introduced in v4
+ */
export type AllLocaleData = Record
export type CompiledIcuChoices = Record & {
@@ -56,6 +64,9 @@ type setupI18nProps = {
locale?: Locale
locales?: Locales
messages?: AllMessages
+ /**
+ * @deprecated Plurals automatically used from Intl.PluralRules you can safely remove this call. Introduced in v4
+ */
localeData?: AllLocaleData
missing?: MissingHandler
}
@@ -98,6 +109,9 @@ export class I18n extends EventEmitter {
return this._messages[this._locale] ?? {}
}
+ /**
+ * @deprecated this has no effect. Please remove this from the code. Introduced in v4
+ */
get localeData(): LocaleData {
return this._localeData[this._locale] ?? {}
}
@@ -110,9 +124,17 @@ export class I18n extends EventEmitter {
}
}
+ /**
+ * @deprecated Plurals automatically used from Intl.PluralRules you can safely remove this call. Introduced in v4
+ */
public loadLocaleData(allLocaleData: AllLocaleData): void
+ /**
+ * @deprecated Plurals automatically used from Intl.PluralRules you can safely remove this call. Introduced in v4
+ */
public loadLocaleData(locale: Locale, localeData: LocaleData): void
-
+ /**
+ * @deprecated Plurals automatically used from Intl.PluralRules you can safely remove this call. Introduced in v4
+ */
loadLocaleData(localeOrAllData, localeData?) {
if (localeData != null) {
// loadLocaleData('en', enLocaleData)
@@ -161,12 +183,6 @@ export class I18n extends EventEmitter {
if (!this._messages[locale]) {
console.warn(`Messages for locale "${locale}" not loaded.`)
}
-
- if (!this._localeData[locale]) {
- console.warn(
- `Locale data for locale "${locale}" not loaded. Plurals won't work correctly.`
- )
- }
}
this._locale = locale
@@ -214,17 +230,16 @@ export class I18n extends EventEmitter {
return interpolate(
translation,
this._locale,
- this._locales,
- this.localeData
+ this._locales
)(values, formats)
}
date(value: string | Date, format?: Intl.DateTimeFormatOptions): string {
- return date(this._locales || this._locale, format)(value)
+ return date(this._locales || this._locale, value, format)
}
number(value: number, format?: Intl.NumberFormatOptions): string {
- return number(this._locales || this._locale, format)(value)
+ return number(this._locales || this._locale, value, format)
}
}
diff --git a/packages/react/src/I18nProvider.test.tsx b/packages/react/src/I18nProvider.test.tsx
index 766d8cdfa..b63d85c9c 100644
--- a/packages/react/src/I18nProvider.test.tsx
+++ b/packages/react/src/I18nProvider.test.tsx
@@ -3,11 +3,16 @@ import { act, render } from "@testing-library/react"
import { withI18n, I18nProvider } from "./I18nProvider"
import { setupI18n } from "@lingui/core"
+// eslint-disable-next-line import/no-extraneous-dependencies
+import { mockConsole } from "@lingui/jest-mocks"
describe("I18nProvider", () => {
it("should pass i18n context to wrapped component", () => {
const i18n = setupI18n({
locale: "cs",
+ messages: {
+ cs: {},
+ },
})
const WithoutHoc = (props) => {
@@ -33,7 +38,12 @@ describe("I18nProvider", () => {
})
it("should subscribe for locale changes", () => {
- const i18n = setupI18n()
+ const i18n = setupI18n({
+ locale: "cs",
+ messages: {
+ cs: {},
+ },
+ })
i18n.on = jest.fn(() => jest.fn())
expect(i18n.on).not.toBeCalled()
@@ -47,7 +57,12 @@ describe("I18nProvider", () => {
it("should unsubscribe for locale changes on unmount", () => {
const unsubscribe = jest.fn()
- const i18n = setupI18n()
+ const i18n = setupI18n({
+ locale: "cs",
+ messages: {
+ cs: {},
+ },
+ })
i18n.on = jest.fn(() => unsubscribe)
const { unmount } = render(
@@ -61,19 +76,31 @@ describe("I18nProvider", () => {
})
it("should re-render on locale changes", async () => {
- expect.assertions(3)
+ expect.assertions(4)
- const i18n = setupI18n()
+ const i18n = setupI18n({
+ messages: { en: {} },
+ })
const CurrentLocale = () => {
return {i18n.locale}
}
- const { container } = render(
-
-
-
- )
+ let container: HTMLElement
+
+ mockConsole((console) => {
+ const res = render(
+
+
+
+ )
+
+ container = res.container
+ expect(console.log.mock.calls[0][0]).toMatchInlineSnapshot(
+ `"I18nProvider did not render. A call to i18n.activate still needs to happen or forceRenderOnLocaleChange must be set to false."`
+ )
+ })
+
// First render — no output, because locale isn't activated
expect(container.textContent).toEqual("")
@@ -95,6 +122,7 @@ describe("I18nProvider", () => {
it("should render children", () => {
const i18n = setupI18n({
locale: "en",
+ messages: { en: {} },
})
const child =
diff --git a/packages/react/src/format.test.tsx b/packages/react/src/format.test.tsx
index 016ccf506..8c984db9b 100644
--- a/packages/react/src/format.test.tsx
+++ b/packages/react/src/format.test.tsx
@@ -1,6 +1,8 @@
import * as React from "react"
import { render } from "@testing-library/react"
import { formatElements } from "./format"
+// eslint-disable-next-line import/no-extraneous-dependencies
+import { mockConsole } from "@lingui/jest-mocks"
describe("formatElements", function () {
const html = (elements) => render(elements).container.innerHTML
@@ -72,29 +74,49 @@ describe("formatElements", function () {
})
it("should ignore non existing element", function () {
- expect(html(formatElements("<0>First0>"))).toEqual("First")
- expect(html(formatElements("<0>First0>Second"))).toEqual("FirstSecond")
- expect(html(formatElements("First<0>Second0>Third"))).toEqual(
- "FirstSecondThird"
- )
- expect(html(formatElements("Fir<0/>st"))).toEqual("First")
- expect(html(formatElements("text"))).toEqual("text")
- expect(html(formatElements("text
"))).toEqual("text ")
+ mockConsole((console) => {
+ expect(html(formatElements("<0>First0>"))).toEqual("First")
+ expect(html(formatElements("<0>First0>Second"))).toEqual("FirstSecond")
+ expect(html(formatElements("First<0>Second0>Third"))).toEqual(
+ "FirstSecondThird"
+ )
+ expect(html(formatElements("Fir<0/>st"))).toEqual("First")
+ expect(html(formatElements("text"))).toEqual("text")
+ expect(html(formatElements("text
"))).toEqual("text ")
+
+ expect(console.warn).not.toBeCalled()
+ expect(console.error).toBeCalledTimes(6)
+ })
})
it("should ignore incorrect tags and print them as a text", function () {
- expect(html(formatElements("text0>"))).toEqual("text</0>")
- expect(html(formatElements("text<0 />"))).toEqual("text<0 />")
+ mockConsole((console) => {
+ expect(html(formatElements("text0>"))).toEqual("text</0>")
+ expect(html(formatElements("text<0 />"))).toEqual("text<0 />")
+
+ expect(console.warn).not.toBeCalled()
+ expect(console.error).not.toBeCalled()
+ })
})
it("should ignore unpaired element used as paired", function () {
- expect(html(formatElements("<0>text0>", { 0:
}))).toEqual("text")
+ mockConsole((console) => {
+ expect(html(formatElements("<0>text0>", { 0:
}))).toEqual("text")
+
+ expect(console.warn).not.toBeCalled()
+ expect(console.error).toBeCalled()
+ })
})
it("should ignore unpaired named element used as paired", function () {
- expect(
- html(formatElements("text", { named:
}))
- ).toEqual("text")
+ mockConsole((console) => {
+ expect(
+ html(formatElements("text", { named:
}))
+ ).toEqual("text")
+
+ expect(console.warn).not.toBeCalled()
+ expect(console.error).toBeCalledTimes(1)
+ })
})
it("should ignore paired element used as unpaired", function () {
@@ -113,6 +135,7 @@ describe("formatElements", function () {
const cleanPrefix = (str: string): number =>
Number.parseInt(str.replace("$lingui$_", ""), 10)
const elements = formatElements("<0/><0/>
", {
+ div: ,
0: hi,
}) as Array
diff --git a/packages/react/src/format.ts b/packages/react/src/format.ts
index a5df71677..47cabe219 100644
--- a/packages/react/src/format.ts
+++ b/packages/react/src/format.ts
@@ -53,7 +53,7 @@ function formatElements(
if (!element || (voidElementTags[element.type as string] && children)) {
if (!element) {
console.error(
- `Can use element at index '${index}' as it is not declared in the original translation`
+ `Can't use element at index '${index}' as it is not declared in the original translation`
)
} else {
console.error(
diff --git a/website/docs/guides/dynamic-loading-catalogs.md b/website/docs/guides/dynamic-loading-catalogs.md
index 5b004a2d6..6f6d389a0 100644
--- a/website/docs/guides/dynamic-loading-catalogs.md
+++ b/website/docs/guides/dynamic-loading-catalogs.md
@@ -4,48 +4,12 @@
Here's an example of a basic setup with a dynamic load of catalogs.
-## Setup
-
-:::caution
-You don't have to install following Babel plugins if you're using *Create React App* or similar framework which already has it.
-:::
-
-We are using the [Dynamic Import() Proposal](https://github.com/tc39/proposal-dynamic-import) to ECMAScript. We need to install `@babel/plugin-syntax-dynamic-import` and `babel-plugin-dynamic-import-node` to make it work. Also, the code examples given here make use of `@babel/plugin-proposal-class-properties`.
-
-```bash npm2yarn
-npm install --save-dev \
- @babel/plugin-syntax-dynamic-import \
- babel-plugin-dynamic-import-node \
- @babel/plugin-proposal-class-properties
-```
-
-:::caution
-`babel-plugin-dynamic-import-node` is required when running tests in Jest.
-:::
-
-``` js title=".babelrc"
-{
- "plugins": [
- "@babel/plugin-syntax-dynamic-import",
- "@babel/plugin-proposal-class-properties"
- ],
- "env": {
- "test": {
- "plugins": [
- "dynamic-import-node"
- ]
- }
- }
-}
-```
-
## Final I18n loader helper
Here's the full source of `i18n.ts` logic:
-``` jsx title="i18n.ts"
+```tsx title="i18n.ts"
import { i18n } from '@lingui/core';
-import { en, cs } from 'make-plural/plurals'
export const locales = {
en: "English",
@@ -53,11 +17,6 @@ export const locales = {
};
export const defaultLocale = "en";
-i18n.loadLocaleData({
- en: { plurals: en },
- cs: { plurals: cs },
-})
-
/**
* We do a dynamic import of just the catalog that we need
* @param locale any locale string
@@ -71,7 +30,7 @@ export async function dynamicActivate(locale: string) {
**How should I use the dynamicActivate in our application?**
-``` jsx
+```jsx
import React, { useEffect } from 'react';
import App from './App';
diff --git a/website/docs/guides/testing.md b/website/docs/guides/testing.md
index 6b7c641fb..47696ba66 100644
--- a/website/docs/guides/testing.md
+++ b/website/docs/guides/testing.md
@@ -4,12 +4,11 @@ Components using [`Trans`](/docs/ref/react.md#trans), [`withI18n`](/docs/ref/rea
Here is a working example with [react-testing-library](https://testing-library.com/docs/react-testing-library/intro/), using the [wrapper-property](https://testing-library.com/docs/react-testing-library/api#wrapper):
-``` jsx title="index.js"
+```tsx title="index.js"
import React from 'react'
import { getByText, render, act } from '@testing-library/react'
import { i18n } from '@lingui/core'
import { I18nProvider } from '@lingui/react'
- import { en, cs } from 'make-plural/plurals'
import { messages } from './locales/en/messages'
import { messages as csMessages } from './locales/cs/messages'
@@ -19,10 +18,6 @@ Here is a working example with [react-testing-library](https://testing-library.c
en: messages,
cs: csMessages
})
- i18n.loadLocaleData({
- en: { plurals: en },
- cs: { plurals: cs }
- });
const TestingProvider = ({ children }: any) => (
diff --git a/website/docs/ref/conf.md b/website/docs/ref/conf.md
index cbc0f9e8b..0b3fd125c 100644
--- a/website/docs/ref/conf.md
+++ b/website/docs/ref/conf.md
@@ -359,9 +359,7 @@ Object for configuring message catalog output. See individual formats for option
Default: `[]`
-Locale tags which are used in the project. [`extract`](/docs/ref/cli.md#extract) and [`compile`](/docs/ref/cli.md#compile) writes one catalog for each locale. Each locale should be a valid [BCP-47 code](http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html). If you use a string that is not a BCP-47, make sure to use a BCP-47 when defining plurals in 18n.loadLocaleData.
-
-For example for `pt-br`: `i18n.loadLocaleData('pt-br', { plurals: pt })`
+Locale tags which are used in the project. [`extract`](/docs/ref/cli.md#extract) and [`compile`](/docs/ref/cli.md#compile) writes one catalog for each locale. Each locale should be a valid [BCP-47 code](http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html).
## orderBy
diff --git a/website/docs/ref/core.md b/website/docs/ref/core.md
index 835fb898f..6533f2e7c 100644
--- a/website/docs/ref/core.md
+++ b/website/docs/ref/core.md
@@ -12,15 +12,9 @@ npm install --save @lingui/core
`@lingui/core` package exports the global instance of `i18n` object. Simply import it and use it:
-``` jsx
+```tsx
import { i18n } from "@lingui/core"
-// import plural rules for all locales
-import { en, cs } from "make-plural/plurals"
-
-i18n.loadLocaleData("en", { plurals: en })
-i18n.loadLocaleData("cs", { plurals: cs })
-
/**
* Load messages for requested locale and activate it.
* This function isn't part of the LinguiJS library because there are
diff --git a/website/docs/releases/migration-4.md b/website/docs/releases/migration-4.md
index 33bcc2be4..2ef9af204 100644
--- a/website/docs/releases/migration-4.md
+++ b/website/docs/releases/migration-4.md
@@ -152,3 +152,16 @@ Migration for the following older options:
- `fallbackLocale`
were deleted from the source code. This should affect only users who are migrating from `v2` to `v4` directly.
+
+### Plural Rules works out of the box without manual configuration
+
+You can safely remove `i18n.loadLocaleData` calls because since v4 Lingui uses `Intl.PluralRules` internally.
+
+```diff
+- import { en, cs } from "make-plural/plurals"
+
+- i18n.loadLocaleData("en", { plurals: en })
+- i18n.loadLocaleData("cs", { plurals: cs })
+```
+
+Don't forget to delete `make-plural` from your `package.json`.
diff --git a/website/docs/tutorials/react-native.md b/website/docs/tutorials/react-native.md
index 3a87a3f33..1588959bb 100644
--- a/website/docs/tutorials/react-native.md
+++ b/website/docs/tutorials/react-native.md
@@ -78,15 +78,13 @@ Let's use the [`Trans`](/docs/ref/macro.md#trans) macro first. Don't forget that
Let's translate the screen heading:
-``` jsx
+```jsx
import { I18nProvider } from '@lingui/react'
import { Trans } from '@lingui/macro'
import { i18n } from "@lingui/core"
-import { en } from 'make-plural/plurals'
-i18n.loadLocaleData('en', { plurals: en })
-i18n.load('en', messages)
-i18n.activate('en')
+i18n.load('en', messages);
+i18n.activate('en');
diff --git a/website/docs/tutorials/react.md b/website/docs/tutorials/react.md
index feacdc3db..3d6f45b08 100644
--- a/website/docs/tutorials/react.md
+++ b/website/docs/tutorials/react.md
@@ -445,43 +445,9 @@ This message is a bit special, because it depends on the value of the `messagesC
What's tricky is that different languages use different number of plural forms. For example, English has only two forms - singular and plural - as we can see in the example above. However, Czech language has three plural forms. Some languages have up to 6 plural forms and some don't have plurals at all!
:::tip
-Plural forms for all languages can be found in the [CLDR repository](http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html).
+Lingui uses `Intl.PluralRules` which is supported in [every modern browser](https://caniuse.com/intl-pluralrules) and can be polyfilled for older. So you don't need to setup anything special.
:::
-Let's load plural data into our app:
-
-```jsx title="src/index.js" {6,11-14}
-import React from 'react'
-import { render } from 'react-dom'
-
-import { i18n } from '@lingui/core'
-import { I18nProvider } from '@lingui/react'
-import { en, cs } from 'make-plural/plurals'
-import { messages as enMessages } from './locales/en/messages'
-import { messages as csMessages } from './locales/cs/messages'
-import Inbox from './Inbox'
-
-i18n.loadLocaleData({
- en: { plurals: en },
- cs: { plurals: cs },
-})
-
-i18n.load({
- en: enMessages,
- cs: csMessages,
-})
-
-i18n.activate('cs')
-
-const App = () => (
-
-
-
-)
-
-render(, document.getElementById('root'))
-```
-
### English plural rules
How do we know which plural form we should use? It's very simple: we, as developers, only need to know plural forms of the language we use in our source. Our component is written in English, so looking at [English plural rules](http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html#en) we'll need just two forms:
@@ -496,8 +462,8 @@ How do we know which plural form we should use? It's very simple: we, as develop
We don't need to select these forms manually. We'll use [`Plural`](/docs/ref/macro.md#plural-1) component, which takes a `value` prop and based on the active language, selects the right plural form:
-``` jsx
-import { Trans, Plural } from '@lingui/macro'
+```jsx
+import { Trans, Plural } from '@lingui/macro';