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>First"))).toEqual("First") - expect(html(formatElements("<0>FirstSecond"))).toEqual("FirstSecond") - expect(html(formatElements("First<0>SecondThird"))).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>First"))).toEqual("First") + expect(html(formatElements("<0>FirstSecond"))).toEqual("FirstSecond") + expect(html(formatElements("First<0>SecondThird"))).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("text"))).toEqual("text</0>") - expect(html(formatElements("text<0 />"))).toEqual("text<0 />") + mockConsole((console) => { + expect(html(formatElements("text"))).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>text", { 0:
}))).toEqual("text") + mockConsole((console) => { + expect(html(formatElements("<0>text", { 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';