diff --git a/packages/cli/package.json b/packages/cli/package.json index 66c7a14af..e563709de 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -41,6 +41,7 @@ "@babel/types": "^7.11.5", "@lingui/babel-plugin-extract-messages": "^3.15.0", "@lingui/conf": "^3.15.0", + "@lingui/core": "^3.15.0", "babel-plugin-macros": "^3.0.1", "bcp-47": "^1.0.7", "chalk": "^4.1.0", @@ -53,7 +54,6 @@ "glob": "^7.1.4", "inquirer": "^7.3.3", "make-plural": "^6.2.2", - "messageformat-parser": "^4.1.3", "micromatch": "4.0.2", "mkdirp": "^1.0.4", "node-gettext": "^3.0.0", diff --git a/packages/cli/src/api/__snapshots__/compile.test.ts.snap b/packages/cli/src/api/__snapshots__/compile.test.ts.snap index b60c649ca..eccc39549 100644 --- a/packages/cli/src/api/__snapshots__/compile.test.ts.snap +++ b/packages/cli/src/api/__snapshots__/compile.test.ts.snap @@ -1,39 +1,25 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`compile should report failed message on error 1`] = ` -Can't parse message. Please check correct syntax: "{value, plural, one {Book} other {Books" - - Messageformat-parser trace: Expected "#", "{", "}", doubled apostrophe, escaped string, or plain char but end of input found. -`; +exports[`createCompiledCatalog nested message 1`] = `/*eslint-disable*/module.exports={messages:JSON.parse("{\\"nested\\":{\\"one\\":\\"Uno\\",\\"two\\":\\"Dos\\",\\"three\\":\\"Tres\\",\\"hello\\":[\\"Hola \\",[\\"name\\"]]}}")};`; -exports[`createCompiledCatalog nested message 1`] = `/*eslint-disable*/module.exports={messages:{"nested":{"one":"Uno","two":"Dos","three":"Tres","hello":["Hola ",["name"]]}}};`; +exports[`createCompiledCatalog options.compilerBabelOptions by default should return catalog without ASCII chars 1`] = `/*eslint-disable*/module.exports={messages:JSON.parse("{\\"Hello\\":\\"Alohà\\"}")};`; -exports[`createCompiledCatalog options.compilerBabelOptions by default should return catalog without ASCII chars 1`] = `/*eslint-disable*/module.exports={messages:{"Hello":"Alohà"}};`; +exports[`createCompiledCatalog options.compilerBabelOptions should return catalog without ASCII chars 1`] = `/*eslint-disable*/module.exports={messages:JSON.parse("{\\"Hello\\":\\"Aloh\\xE0\\"}")};`; -exports[`createCompiledCatalog options.compilerBabelOptions should return catalog without ASCII chars 1`] = `/*eslint-disable*/module.exports={messages:{"Hello":"Aloh\\xE0"}};`; +exports[`createCompiledCatalog options.namespace should compile with es 1`] = `/*eslint-disable*/export const messages=JSON.parse("{}");`; -exports[`createCompiledCatalog options.namespace should compile with es 1`] = `/*eslint-disable*/export const messages={};`; +exports[`createCompiledCatalog options.namespace should compile with global 1`] = `/*eslint-disable*/global.test={messages:JSON.parse("{}")};`; -exports[`createCompiledCatalog options.namespace should compile with global 1`] = `/*eslint-disable*/global.test={messages:{}};`; +exports[`createCompiledCatalog options.namespace should compile with ts 1`] = `/*eslint-disable*/export const messages=JSON.parse("{}");`; -exports[`createCompiledCatalog options.namespace should compile with ts 1`] = `/*eslint-disable*/export const messages={};`; - -exports[`createCompiledCatalog options.namespace should compile with window 1`] = `/*eslint-disable*/window.test={messages:{}};`; +exports[`createCompiledCatalog options.namespace should compile with window 1`] = `/*eslint-disable*/window.test={messages:JSON.parse("{}")};`; exports[`createCompiledCatalog options.namespace should error with invalid value 1`] = `Invalid namespace param: "global"`; -exports[`createCompiledCatalog options.pseudoLocale should return catalog with pseudolocalized messages 1`] = `/*eslint-disable*/module.exports={messages:{"Hello":"ÀĥōĴ"}};`; - -exports[`createCompiledCatalog options.pseudoLocale should return compiled catalog when pseudoLocale doesn't match current locale 1`] = `/*eslint-disable*/module.exports={messages:{"Hello":"Ahoj"}};`; - -exports[`createCompiledCatalog options.pure should return code catalog 1`] = `/*eslint-disable*/module.exports={messages:{"Hello":"Ahoj"}};`; +exports[`createCompiledCatalog options.pseudoLocale should return catalog with pseudolocalized messages 1`] = `/*eslint-disable*/module.exports={messages:JSON.parse("{\\"Hello\\":\\"ÀĥōĴ\\"}")};`; -exports[`createCompiledCatalog options.pure should return pure catalog 1`] = ` -Object { - Hello: Ahoj, -} -`; +exports[`createCompiledCatalog options.pseudoLocale should return compiled catalog when pseudoLocale doesn't match current locale 1`] = `/*eslint-disable*/module.exports={messages:JSON.parse("{\\"Hello\\":\\"Ahoj\\"}")};`; -exports[`createCompiledCatalog options.strict should return message key as a fallback translation 1`] = `/*eslint-disable*/module.exports={messages:{"Hello":"Ahoj","Missing":"Missing","Select":[["id","select",{Gen:"Genesis","1John":"1 John",other:"____"}]]}};`; +exports[`createCompiledCatalog options.strict should return message key as a fallback translation 1`] = `/*eslint-disable*/module.exports={messages:JSON.parse("{\\"Hello\\":\\"Ahoj\\",\\"Missing\\":\\"Missing\\",\\"Select\\":[[\\"id\\",\\"select\\",{\\"Gen\\":\\"Genesis\\",\\"1John\\":\\"1 John\\",\\"other\\":\\"____\\"}]]}")};`; -exports[`createCompiledCatalog options.strict should't return message key as a fallback in strict mode 1`] = `/*eslint-disable*/module.exports={messages:{"Hello":"Ahoj","Missing":"","Select":[["id","select",{Gen:"Genesis","1John":"1 John",other:"____"}]]}};`; +exports[`createCompiledCatalog options.strict should't return message key as a fallback in strict mode 1`] = `/*eslint-disable*/module.exports={messages:JSON.parse("{\\"Hello\\":\\"Ahoj\\",\\"Missing\\":\\"\\",\\"Select\\":[[\\"id\\",\\"select\\",{\\"Gen\\":\\"Genesis\\",\\"1John\\":\\"1 John\\",\\"other\\":\\"____\\"}]]}")};`; diff --git a/packages/cli/src/api/catalog.test.ts b/packages/cli/src/api/catalog.test.ts index 1ad7e38fb..23de94357 100644 --- a/packages/cli/src/api/catalog.test.ts +++ b/packages/cli/src/api/catalog.test.ts @@ -1023,76 +1023,29 @@ describe("order", function () { }) describe("writeCompiled", function () { - it("saves ES modules to .mjs files", function () { - const localeDir = copyFixture(fixture("locales", "initial/")) - const catalog = new Catalog( - { - name: "messages", - path: path.join(localeDir, "{locale}", "messages"), - include: [], - exclude: [], - }, - mockConfig() - ) - - const namespace = "es" - const compiledCatalog = createCompiledCatalog("en", {}, { namespace }) + const localeDir = copyFixture(fixture("locales", "initial/")) + const catalog = new Catalog( + { + name: "messages", + path: path.join(localeDir, "{locale}", "messages"), + include: [], + exclude: [] + }, + mockConfig() + ) + + it.each([ + {namespace: "es", extension: /\.mjs$/}, + {namespace: "ts", extension: /\.ts$/}, + {namespace: undefined, extension: /\.js$/}, + {namespace: 'cjs', extension: /\.js$/}, + {namespace: 'window.test', extension: /\.js$/}, + {namespace: 'global.test', extension: /\.js$/} + ])('Should save namespace $namespace in $extension extension', ({namespace, extension}) => { + const compiledCatalog = createCompiledCatalog("en", {}, {namespace}) // Test that the file extension of the compiled catalog is `.mjs` expect(catalog.writeCompiled("en", compiledCatalog, namespace)).toMatch( - /\.mjs$/ - ) - }) - - it("saves TS modules to .ts files", function () { - const localeDir = copyFixture(fixture("locales", "initial/")) - const catalog = new Catalog( - { - name: "messages", - path: path.join(localeDir, "{locale}", "messages"), - include: [], - exclude: [], - }, - mockConfig() - ) - - const namespace = "ts" - const compiledCatalog = createCompiledCatalog("en", {}, { namespace }) - expect(catalog.writeCompiled("en", compiledCatalog, namespace)).toMatch( - /\.ts$/ - ) - }) - - it("saves anything else than ES modules to .js files", function () { - const localeDir = copyFixture(fixture("locales", "initial/")) - const catalog = new Catalog( - { - name: "messages", - path: path.join(localeDir, "{locale}", "messages"), - include: [], - exclude: [], - }, - mockConfig() - ) - - let compiledCatalog = createCompiledCatalog("en", {}, {}) - // Test that the file extension of the compiled catalog is `.js` - expect(catalog.writeCompiled("en", compiledCatalog)).toMatch(/\.js$/) - - compiledCatalog = createCompiledCatalog("en", {}, { namespace: "cjs" }) - expect(catalog.writeCompiled("en", compiledCatalog)).toMatch(/\.js$/) - - compiledCatalog = createCompiledCatalog( - "en", - {}, - { namespace: "window.test" } - ) - expect(catalog.writeCompiled("en", compiledCatalog)).toMatch(/\.js$/) - - compiledCatalog = createCompiledCatalog( - "en", - {}, - { namespace: "global.test" } + extension ) - expect(catalog.writeCompiled("en", compiledCatalog)).toMatch(/\.js$/) }) }) diff --git a/packages/cli/src/api/compile.test.ts b/packages/cli/src/api/compile.test.ts index 380f28dd4..adf3fff7f 100644 --- a/packages/cli/src/api/compile.test.ts +++ b/packages/cli/src/api/compile.test.ts @@ -1,133 +1,45 @@ -import generate from "@babel/generator" import { compile, createCompiledCatalog } from "./compile" describe("compile", () => { - const getSource = (message: string) => - generate(compile(message) as any, { - compact: true, - minified: true, - jsescOption: { minimal: true }, - }).code - - it("should optimize string only messages", () => { - expect(getSource("Hello World")).toEqual('"Hello World"') - }) - - it("should allow escaping syntax characters", () => { - expect(getSource("'{name}'")).toEqual('"{name}"') - expect(getSource("''")).toEqual('"\'"') - }) - - it("should compile arguments", () => { - expect(getSource("{name}")).toEqual('[["name"]]') - - expect(getSource("B4 {name} A4")).toEqual('["B4 ",["name"]," A4"]') - }) - - it("should compile arguments with formats", () => { - expect(getSource("{name, number}")).toEqual('[["name","number"]]') - - expect(getSource("{name, number, percent}")).toEqual( - '[["name","number","percent"]]' - ) - }) - - it("should compile plural", () => { - expect(getSource("{name, plural, one {Book} other {Books}}")).toEqual( - '[["name","plural",{one:"Book",other:"Books"}]]' - ) - - expect( - getSource("{name, plural, one {Book} other {{name} Books}}") - ).toEqual('[["name","plural",{one:"Book",other:[["name"]," Books"]}]]') - - expect(getSource("{name, plural, one {Book} other {# Books}}")).toEqual( - '[["name","plural",{one:"Book",other:["#"," Books"]}]]' - ) - - expect( - getSource( - "{name, plural, offset:1 =0 {No Books} one {Book} other {# Books}}" - ) - ).toEqual( - '[["name","plural",{offset:1,0:"No Books",one:"Book",other:["#"," Books"]}]]' - ) - }) - - it("should compile select", () => { - expect(getSource("{name, select, male {He} female {She}}")).toEqual( - '[["name","select",{male:"He",female:"She"}]]' - ) - - expect(getSource("{name, select, male {He} female {{name} She}}")).toEqual( - '[["name","select",{male:"He",female:[["name"]," She"]}]]' - ) - - expect(getSource("{name, select, male {He} female {# She}}")).toEqual( - '[["name","select",{male:"He",female:"# She"}]]' - ) - }) - - it("should compile multiple plurals", () => { - expect( - getSource( - "{bcount, plural, one {boy} other {# boys}} {gcount, plural, one {girl} other {# girls}}" - ) - ).toEqual( - '[["bcount","plural",{one:"boy",other:["#"," boys"]}]," ",["gcount","plural",{one:"girl",other:["#"," girls"]}]]' - ) - }) - - it("should report failed message on error", () => { - expect(() => - getSource("{value, plural, one {Book} other {Books") - ).toThrowErrorMatchingSnapshot() - }) - describe("with pseudo-localization", () => { - const getPSource = (message: string) => - generate(compile(message, true) as any, { - compact: true, - minified: true, - jsescOption: { minimal: true }, - }).code + const getPSource = (message: string) => compile(message, true) it("should pseudolocalize strings", () => { - expect(getPSource("Martin Černý")).toEqual('"Màŕţĩń Čēŕńý"') + expect(getPSource("Martin Černý")).toEqual("Màŕţĩń Čēŕńý") }) it("should pseudolocalize escaping syntax characters", () => { // TODO: should this turn into pseudoLocale string? - expect(getPSource("'{name}'")).toEqual('"{name}"') + expect(getPSource("'{name}'")).toEqual("{name}") // expect(getPSource("'{name}'")).toEqual('"{ńàmē}"') }) it("should not pseudolocalize arguments", () => { - expect(getPSource("{name}")).toEqual('[["name"]]') - expect(getPSource("B4 {name} A4")).toEqual('["ß4 ",["name"]," À4"]') + expect(getPSource("{name}")).toEqual([["name"]]) + expect(getPSource("B4 {name} A4")).toEqual(["ß4 ", ["name"], " À4"]) }) it("should not pseudolocalize arguments nor formats", () => { - expect(getPSource("{name, number}")).toEqual('[["name","number"]]') + expect(getPSource("{name, number}")).toEqual([["name", "number"]]) expect(getPSource("{name, number, percent}")).toEqual( - '[["name","number","percent"]]' + [["name", "number", "percent"]] ) }) it("should not pseudolocalize HTML tags", () => { expect(getPSource('Martin Černý')).toEqual( - JSON.stringify('Màŕţĩń Čēŕńý') + 'Màŕţĩń Čēŕńý' ) expect( getPSource("Martin Cerny 123aČerný") ).toEqual( - JSON.stringify("Màŕţĩń Ćēŕńŷ 123àČēŕńý") + "Màŕţĩń Ćēŕńŷ 123àČēŕńý" ) expect(getPSource("Martin a")).toEqual( - JSON.stringify("Màŕţĩń à") + "Màŕţĩń à" ) expect(getPSource("text")).toEqual( - JSON.stringify("ţēxţ") + "ţēxţ" ) }) @@ -135,7 +47,7 @@ describe("compile", () => { it("with value", () => { expect( getPSource("{value, plural, one {# book} other {# books}}") - ).toEqual('[["value","plural",{one:["#"," ƀōōķ"],other:["#"," ƀōōķś"]}]]') + ).toEqual([["value","plural",{one:["#"," ƀōōķ"],other:["#"," ƀōōķś"]}]]) }) it("with variable placeholder", () => { @@ -144,7 +56,7 @@ describe("compile", () => { "{count, plural, one {{countString} book} other {{countString} books}}" ) ).toEqual( - '[["count","plural",{one:[["countString"]," ƀōōķ"],other:[["countString"]," ƀōōķś"]}]]' + [["count","plural",{one:[["countString"]," ƀōōķ"],other:[["countString"]," ƀōōķś"]}]] ) }) @@ -154,7 +66,7 @@ describe("compile", () => { "{count, plural, offset:1 zero {There are no messages} other {There are # messages in your inbox}}" ) ).toEqual( - '[["count","plural",{offset:1,zero:"Ţĥēŕē àŕē ńō mēśśàĝēś",other:["Ţĥēŕē àŕē ","#"," mēśśàĝēś ĩń ŷōũŕ ĩńƀōx"]}]]' + [["count","plural",{offset:1,zero:"Ţĥēŕē àŕē ńō mēśśàĝēś",other:["Ţĥēŕē àŕē ","#"," mēśśàĝēś ĩń ŷōũŕ ĩńƀōx"]}]] ) }) @@ -164,7 +76,7 @@ describe("compile", () => { "{count, plural, zero {There's # message} other {There are # messages}}" ) ).toEqual( - '[["count","plural",{zero:["Ţĥēŕē\'ś ","#"," mēśśàĝē"],other:["Ţĥēŕē àŕē ","#"," mēśśàĝēś"]}]]' + [["count","plural",{zero:["Ţĥēŕē\'ś ","#"," mēśśàĝē"],other:["Ţĥēŕē àŕē ","#"," mēśśàĝēś"]}]] ) }) @@ -174,7 +86,7 @@ describe("compile", () => { "{count, plural, =0 {There's # message} other {There are # messages}}" ) ).toEqual( - '[["count","plural",{0:["Ţĥēŕē\'ś ","#"," mēśśàĝē"],other:["Ţĥēŕē àŕē ","#"," mēśśàĝēś"]}]]' + [["count","plural",{0:["Ţĥēŕē\'ś ","#"," mēśśàĝē"],other:["Ţĥēŕē àŕē ","#"," mēśśàĝēś"]}]] ) }) }) @@ -185,7 +97,7 @@ describe("compile", () => { "{count, selectordinal, offset:1 one {#st} two {#nd} few {#rd} =4 {4th} many {testMany} other {#th}}" ) ).toEqual( - '[["count","selectordinal",{offset:1,one:["#","śţ"],two:["#","ńď"],few:["#","ŕď"],4:"4ţĥ",many:"ţēśţMàńŷ",other:["#","ţĥ"]}]]' + [["count","selectordinal",{offset:1,one:["#","śţ"],two:["#","ńď"],few:["#","ŕď"],4:"4ţĥ",many:"ţēśţMàńŷ",other:["#","ţĥ"]}]] ) }) @@ -195,13 +107,13 @@ describe("compile", () => { "{gender, select, male {He} female {She} other {Other}}" ) ).toEqual( - '[["gender","select",{male:"Ĥē",female:"Śĥē",other:"Ōţĥēŕ"}]]' + [["gender","select",{male:"Ĥē",female:"Śĥē",other:"Ōţĥēŕ"}]] ) }) it("should not pseudolocalize variables", () => { - expect(getPSource("replace {count}")).toEqual('["ŕēƥĺàćē ",["count"]]') - expect(getPSource("replace { count }")).toEqual('["ŕēƥĺàćē ",["count"]]') + expect(getPSource("replace {count}")).toEqual(["ŕēƥĺàćē ",["count"]]) + expect(getPSource("replace { count }")).toEqual(["ŕēƥĺàćē ",["count"]]) }) it("Multiple Plurals", () => { @@ -210,7 +122,7 @@ describe("compile", () => { "{bcount, plural, one {boy} other {# boys}} {gcount, plural, one {girl} other {# girls}}" ) ).toEqual( - '[["bcount","plural",{one:"ƀōŷ",other:["#"," ƀōŷś"]}]," ",["gcount","plural",{one:"ĝĩŕĺ",other:["#"," ĝĩŕĺś"]}]]' + [["bcount","plural",{one:"ƀōŷ",other:["#"," ƀōŷś"]}]," ",["gcount","plural",{one:"ĝĩŕĺ",other:["#"," ĝĩŕĺś"]}]] ) }) }) @@ -309,27 +221,6 @@ describe("createCompiledCatalog", () => { }) }) - describe("options.pure", () => { - const getCompiledCatalog = (pure) => - createCompiledCatalog( - "ps", - { - Hello: "Ahoj", - }, - { - pure, - } - ) - - it("should return pure catalog", () => { - expect(getCompiledCatalog(true)).toMatchSnapshot() - }) - - it("should return code catalog", () => { - expect(getCompiledCatalog(false)).toMatchSnapshot() - }) - }) - describe("options.compilerBabelOptions", () => { const getCompiledCatalog = (opts = {}) => createCompiledCatalog( diff --git a/packages/cli/src/api/compile.ts b/packages/cli/src/api/compile.ts index 42e0882c0..5b4daee2c 100644 --- a/packages/cli/src/api/compile.ts +++ b/packages/cli/src/api/compile.ts @@ -1,11 +1,8 @@ import * as t from "@babel/types" -import generate, { GeneratorOptions } from "@babel/generator" -import { parse } from "messageformat-parser" -import * as R from "ramda" - +import generate, {GeneratorOptions} from "@babel/generator" +import {compileMessage} from "@lingui/core" import pseudoLocalize from "./pseudoLocalize" -const INVALID_OBJECT_KEY_REGEX = /^(\d+[a-zA-Z]|[a-zA-Z]+\d)(\d|[a-zA-Z])*/ export type CompiledCatalogNamespace = "cjs" | "es" | "ts" | string type CompiledCatalogType = { @@ -17,57 +14,57 @@ export type CreateCompileCatalogOptions = { namespace?: CompiledCatalogNamespace pseudoLocale?: string compilerBabelOptions?: GeneratorOptions - pure?: boolean -} - -/** - * Transform a single key/value translation into a Babel expression, - * applying pseudolocalization where necessary. - */ -function compileSingleKey(key: string, translation: string, shouldPseudolocalize: boolean): t.ObjectProperty { - return t.objectProperty(t.stringLiteral(key), compile(translation, shouldPseudolocalize)) } export function createCompiledCatalog( locale: string, messages: CompiledCatalogType, options: CreateCompileCatalogOptions -) { - const { strict = false, namespace = "cjs", pseudoLocale, compilerBabelOptions = {}, pure = false } = options +): string { + const {strict = false, namespace = "cjs", pseudoLocale, compilerBabelOptions = {}} = options const shouldPseudolocalize = locale === pseudoLocale - const compiledMessages = R.keys(messages).map((key: string) => { - const value = messages[key]; + const compiledMessages = Object.keys(messages).reduce((obj, key: string) => { + const value = messages[key] - // If the current ID's value is a context object, create a nested + // If the current ID's value is a context object, create a nested // expression, and assign the current ID to that expression if (typeof value === "object") { - const contextExpression = t.objectExpression(Object.keys(value).map((contextKey) => { - const contextTranslation = value[contextKey]; - return compileSingleKey(contextKey, contextTranslation, shouldPseudolocalize) - })) - return t.objectProperty(t.stringLiteral(key), contextExpression); + obj[key] = Object.keys(value).reduce((obj, contextKey) => { + obj[contextKey] = compile(value[contextKey], shouldPseudolocalize) + return obj + }, {}) + + return obj } // Don't use `key` as a fallback translation in strict mode. - let translation = (messages[key] || (!strict ? key : "")) as string - return compileSingleKey(key, translation, shouldPseudolocalize) - }) - - const ast = pure ? t.objectExpression(compiledMessages) : buildExportStatement( - t.objectExpression(compiledMessages), + const translation = (messages[key] || (!strict ? key : "")) as string + + obj[key] = compile(translation, shouldPseudolocalize) + return obj + }, {}) + + const ast = buildExportStatement( + //build JSON.parse() statement + t.callExpression( + t.memberExpression( + t.identifier('JSON'), t.identifier('parse') + ), + [t.stringLiteral(JSON.stringify(compiledMessages))] + ), namespace ) const code = generate(ast, { minified: true, jsescOption: { - minimal: true, + minimal: true }, - ...compilerBabelOptions, + ...compilerBabelOptions }).code - return pure ? JSON.parse(code) : ("/*eslint-disable*/" + code) + return "/*eslint-disable*/" + code; } function buildExportStatement(expression, namespace: CompiledCatalogNamespace) { @@ -114,93 +111,7 @@ function buildExportStatement(expression, namespace: CompiledCatalogNamespace) { * JS arrays, which are handled in client. */ export function compile(message: string, shouldPseudolocalize: boolean = false) { - let tokens - - try { - tokens = parse(message) - } catch (e) { - throw new Error( - `Can't parse message. Please check correct syntax: "${message}" \n \n Messageformat-parser trace: ${e.message}` - ) - } - const ast = processTokens(tokens, shouldPseudolocalize) - - if (isString(ast)) return t.stringLiteral(ast) - - return ast -} - -function processTokens(tokens, shouldPseudolocalize: boolean) { - // Shortcut - if the message doesn't include any formatting, - // simply join all string chunks into one message - if (!tokens.filter((token) => !isString(token)).length) { - if (shouldPseudolocalize) { - return tokens.map((token) => pseudoLocalize(token)).join("") - } else { - return tokens.join("") - } - } - - return t.arrayExpression( - tokens.map((token) => { - if (isString(token)) { - return t.stringLiteral( - shouldPseudolocalize ? pseudoLocalize(token) : token - ) - - // # in plural case - } else if (token.type === "octothorpe") { - return t.stringLiteral("#") - - // simple argument - } else if (token.type === "argument") { - return t.arrayExpression([t.stringLiteral(token.arg)]) - - // argument with custom format (date, number) - } else if (token.type === "function") { - const params = [t.stringLiteral(token.arg), t.stringLiteral(token.key)] - - const format = token.param && token.param.tokens[0] - if (format) { - params.push(t.stringLiteral(format.trim())) - } - return t.arrayExpression(params) - } - - // complex argument with cases - const formatProps = [] - - if (token.offset) { - formatProps.push( - t.objectProperty( - t.identifier("offset"), - t.numericLiteral(parseInt(token.offset)) - ) - ) - } - - token.cases.forEach((item) => { - const inlineTokens = processTokens(item.tokens, shouldPseudolocalize) - formatProps.push( - t.objectProperty( - // if starts with number must be wrapped with quotes - INVALID_OBJECT_KEY_REGEX.test(item.key) ? t.stringLiteral(item.key) : t.identifier(item.key), - isString(inlineTokens) - ? t.stringLiteral(inlineTokens) - : inlineTokens - ) - ) - }) - - const params = [ - t.stringLiteral(token.arg), - t.stringLiteral(token.type), - t.objectExpression(formatProps), - ] - - return t.arrayExpression(params) - }) + return compileMessage(message, (value) => + shouldPseudolocalize ? pseudoLocalize(value) : value ) } - -const isString = (s) => typeof s === "string" diff --git a/packages/cli/src/api/formats/index.ts b/packages/cli/src/api/formats/index.ts index 36ecd9a94..9af84a2a9 100644 --- a/packages/cli/src/api/formats/index.ts +++ b/packages/cli/src/api/formats/index.ts @@ -15,7 +15,10 @@ const formats: Record = { "po-gettext": poGettext, } -type CatalogFormatOptionsInternal = { +/** + * @internal + */ +export type CatalogFormatOptionsInternal = { locale: string } & CatalogFormatOptions diff --git a/packages/cli/src/api/formats/po-gettext.ts b/packages/cli/src/api/formats/po-gettext.ts index 4e4a9c08f..1f7e9594e 100644 --- a/packages/cli/src/api/formats/po-gettext.ts +++ b/packages/cli/src/api/formats/po-gettext.ts @@ -1,6 +1,6 @@ import { format as formatDate } from "date-fns" import fs from "fs" -import ICUParser from "messageformat-parser" +import {parse as parseIcu, Select, SelectCase, Token} from "@messageformat/parser" import pluralsCldr from "plurals-cldr" import PO from "pofile" import * as R from "ramda" @@ -8,7 +8,7 @@ import gettextPlurals from "node-gettext/lib/plurals" import { CatalogType, MessageType } from "../catalog" import { joinOrigin, splitOrigin, writeFileIfChanged } from "../utils" -import { CatalogFormatter } from "." +import type {CatalogFormatOptionsInternal, CatalogFormatter} from "./" // Workaround because pofile doesn't support es6 modules, see https://github.com/rubenv/pofile/pull/38#issuecomment-623119284 type POItemType = InstanceType @@ -25,11 +25,11 @@ function getCreateHeaders(language = "no") { } // Attempts to turn a single tokenized ICU plural case back into a string. -function stringifyICUCase(icuCase) { +function stringifyICUCase(icuCase: SelectCase): string { return icuCase.tokens .map((token) => { - if (typeof token === "string") { - return token + if (token.type === "content") { + return token.value } else if (token.type === "octothorpe") { return "#" } else if (token.type === "argument") { @@ -51,7 +51,7 @@ const LINE_ENDINGS = /\r?\n/g // Prefix that is used to identitify context information used by this module in PO's "extracted comments". const CTX_PREFIX = "js-lingui:" -const serialize = (items: CatalogType, options) => +const serialize = (items: CatalogType, options: CatalogFormatOptionsInternal & {disableSelectWarning: boolean}) => R.compose( R.values, R.mapObjIndexed((message: MessageType, key) => { @@ -62,7 +62,7 @@ const serialize = (items: CatalogType, options) => // The extractedComments array may be modified in this method, so create a new array with the message's elements. // Destructuring `undefined` is forbidden, so fallback to `[]` if the message has no extracted comments. item.extractedComments = [...(message.extractedComments ?? [])] - + if (message.context) { item.msgctxt = message.context } @@ -89,7 +89,7 @@ const serialize = (items: CatalogType, options) => // Quick check to see if original message is a plural localization. if (ICU_PLURAL_REGEX.test(_simplifiedMessage)) { try { - const [messageAst] = ICUParser.parse(icuMessage) + const messageAst = parseIcu(icuMessage)[0] as Select // Check if any of the plural cases contain plurals themselves. if ( @@ -133,7 +133,7 @@ const serialize = (items: CatalogType, options) => // plural (above) already causes `pofile` to automatically generate `msgstr[0]` and `msgstr[1]`. if (message.translation?.length > 0) { try { - const [ast] = ICUParser.parse(message.translation) + const ast = parseIcu(message.translation)[0] as Select if (ast.cases == null) { console.warn( `Found translation without plural cases for key "${key}". ` + diff --git a/packages/core/package.json b/packages/core/package.json index 266cc65b2..ad2ee1992 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -46,6 +46,6 @@ "dependencies": { "@babel/runtime": "^7.11.2", "make-plural": "^6.2.2", - "messageformat-parser": "^4.1.3" + "@messageformat/parser": "^5.0.0" } } diff --git a/packages/core/src/compileMessage.test.ts b/packages/core/src/compileMessage.test.ts new file mode 100644 index 000000000..d2de5bc70 --- /dev/null +++ b/packages/core/src/compileMessage.test.ts @@ -0,0 +1,154 @@ +import {compileMessage as compile} from "./compileMessage" +import { mockEnv, mockConsole } from "@lingui/jest-mocks" +import { interpolate } from "./context" +import {Locale, Locales} from "./i18n" + +describe("compile", () => { + const englishPlurals = { + plurals(value: number, ordinal: boolean) { + if (ordinal) { + return { "1": "one", "2": "two", "3": "few" }[value] || "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) + } + + it("should handle an error if message has syntax errors", () => { + mockConsole((console) => { + expect(compile("Invalid {message")).toEqual("Invalid {message") + expect(console.error).toBeCalledWith(expect.stringMatching('Unexpected message end at line')) + }) + }) + + it("should process string chunks with provided map fn", () => { + const tokens = compile("Message {value, plural, one {{value} Book} other {# Books}}", (text) => `<${text}>`) + expect(tokens).toEqual([ + "", + [ + "value", + "plural", + { + "one": [ + [ + "value" + ], + "< Book>" + ], + "other": [ + "#", + "< Books>" + ] + } + ] + ]) + }) + + + it("should compile static message", () => { + const cache = compile("Static message") + expect(cache).toEqual("Static message") + + mockEnv("production", () => { + const cache = compile("Static message") + expect(cache).toEqual("Static message") + }) + }) + + it("should compile message with variable", () => { + const cache = compile("Hey {name}!") + expect(interpolate(cache, "en", [], {})({ name: "Joe" })).toEqual( + "Hey Joe!" + ) + }) + + it("should not interpolate escaped placeholder", () => { + const msg = prepare("Hey '{name}'!") + + expect(msg({})).toEqual( + "Hey {name}!" + ) + }) + + it("should compile plurals", () => { + const plural = prepare( + "{value, plural, one {{value} Book} other {# Books}}" + ) + expect(plural({ value: 1 })).toEqual("1 Book") + expect(plural({ value: 2 })).toEqual("2 Books") + + const offset = prepare( + "{value, plural, offset:1 =0 {No Books} one {# Book} other {# Books}}" + ) + expect(offset({ value: 0 })).toEqual("No Books") + expect(offset({ value: 2 })).toEqual("1 Book") + expect(offset({ value: 3 })).toEqual("2 Books") + }) + + it("should compile selectordinal", () => { + const cache = prepare( + "{value, selectordinal, one {#st Book} two {#nd Book}}" + ) + expect(cache({ value: 1 })).toEqual("1st Book") + expect(cache({ value: 2 })).toEqual("2nd Book") + }) + + it("should compile select", () => { + const cache = prepare("{value, select, female {She} other {They}}") + expect(cache({ value: "female" })).toEqual("She") + expect(cache({ value: "n/a" })).toEqual("They") + }) + + describe("Custom format", () => { + const testVector = [ + ["en", null, "0.1", "10%", "20%", "3/4/2017", "€0.10", "€1.00"], + ["fr", null, "0,1", "10 %", "20 %", "04/03/2017", "0,10 €", "1,00 €"], + ["fr", "fr-CH", "0,1", "10%", "20%", "04.03.2017", "0.10 €", "1.00 €"] + ] + testVector.forEach((tc) => { + const [ + locale, + locales, + expectedNumber, + expectedPercent1, + expectedPercent2, + expectedDate, + expectedCurrency1, + expectedCurrency2, + ] = tc + + it( + `should compile custom format for locale=${locale} and locales=${locales}`, + () => { + const number = prepare("{value, number}", locale, locales) + expect(number({ value: 0.1 })).toEqual(expectedNumber) + + const percent = prepare("{value, number, percent}", locale, locales) + expect(percent({ value: 0.1 })).toEqual(expectedPercent1) + expect(percent({ value: 0.2 })).toEqual(expectedPercent2) + + const now = new Date("3/4/2017") + const date = prepare("{value, date}", locale, locales) + expect(date({ value: now })).toEqual(expectedDate) + + const formats = { + currency: { + style: "currency", + currency: "EUR", + minimumFractionDigits: 2 + } as Intl.NumberFormatOptions + } + const currency = prepare("{value, number, currency}", locale, locales) + expect(currency({value: 0.1}, formats)).toEqual(expectedCurrency1) + expect(currency({value: 1}, formats)).toEqual(expectedCurrency2) + } + ) + + }) + }) +}) diff --git a/packages/core/src/compileMessage.ts b/packages/core/src/compileMessage.ts new file mode 100644 index 000000000..d2809f962 --- /dev/null +++ b/packages/core/src/compileMessage.ts @@ -0,0 +1,65 @@ +import {Content, parse, Token} from "@messageformat/parser" +import {CompiledMessage, CompiledMessageToken} from "./i18n" + +type MapTextFn = (value: string) => string; + +// [Tokens] -> (CTX -> String) +function processTokens(tokens: Array, mapText?: MapTextFn): CompiledMessage { + if (!tokens.filter((token) => token.type !== "content").length) { + return tokens.map(token => mapText((token as Content).value)).join("") + } + + return tokens.map((token) => { + if (token.type === 'content') { + return mapText(token.value) + + // # in plural case + } else if (token.type === "octothorpe") { + return "#" + + // simple argument + } else if (token.type === "argument") { + return [token.arg] + + // argument with custom format (date, number) + } else if (token.type === "function") { + const _param = token?.param?.[0] as Content + + if (_param) { + return [token.arg, token.key, _param.value.trim()] + } else { + return [token.arg, token.key] + } + } + + const offset = token.pluralOffset + + // complex argument with cases + const formatProps = {} + token.cases.forEach((item) => { + formatProps[item.key.replace(/^=(.)+/, "$1")] = processTokens(item.tokens, mapText) + }) + + return [ + token.arg, + token.type, + { + offset, + ...formatProps, + } as any, + ] as CompiledMessageToken + }) +} + +// Message -> (Params -> String) +export function compileMessage( + message: string, + mapText: MapTextFn = (v) => v, +): CompiledMessage { + try { + return processTokens(parse(message), mapText) + } catch (e) { + console.error(`${e.message} \n\nMessage: ${message}`) + return message + } +} diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts index 602c77d75..e14152a2b 100644 --- a/packages/core/src/context.ts +++ b/packages/core/src/context.ts @@ -1,20 +1,22 @@ -import { CompiledMessage, Locales } from "./i18n" +import {CompiledMessage, Formats, LocaleData, Locales, Values} from "./i18n" import { date, number } from "./formats" import { isString, isFunction } from "./essentials" export const UNICODE_REGEX = /\\u[a-fA-F0-9]{4}|\\x[a-fA-F0-9]{2}/g; const defaultFormats = ( - locale, - locales, - localeData = { plurals: undefined }, - formats = {} + locale: string, + locales: Locales, + localeData: LocaleData = { plurals: undefined }, + formats: Formats = {} ) => { locales = locales || locale const { plurals } = localeData - const style = (format) => - isString(format) ? formats[format] || { style: format } : format - const replaceOctothorpe = (value, message) => { + 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] @@ -29,26 +31,28 @@ const defaultFormats = ( } return { - plural: (value, { offset = 0, ...rules }) => { - const message = rules[value] || rules[plurals?.(value - offset)] || rules.other + plural: (value: number, { offset = 0, ...rules }) => { + const message = rules[value] || rules[plurals?.(value - offset)] || rules.other + return replaceOctothorpe(value - offset, message) }, - selectordinal: (value, { offset = 0, ...rules }) => { - const message = rules[value] || rules[plurals?.(value - offset, true)] || rules.other + selectordinal: (value: number, { offset = 0, ...rules }) => { + const message = rules[value] || rules[plurals?.(value - offset, true)] || rules.other return replaceOctothorpe(value - offset, message) }, - select: (value, rules) => rules[value] || rules.other, + select: (value: string, rules) => rules[value] || rules.other, - number: (value, format) => number(locales, style(format))(value), + number: (value: number, format: string | Intl.NumberFormatOptions) => number(locales, style(format))(value), - date: (value, format) => date(locales, style(format))(value), + date: (value: string, format: string | Intl.DateTimeFormatOptions) => date(locales, style(format))(value), - undefined: (value) => value, + undefined: (value: unknown) => value, } } + // Params -> CTX /** * Creates a context object, which formats ICU MessageFormat arguments based on @@ -61,7 +65,13 @@ const defaultFormats = ( * @param formats - Custom format styles * @returns {function(string, string, any)} */ -function context({ locale, locales, values, formats, localeData }) { +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 => { @@ -78,21 +88,21 @@ export function interpolate( translation: CompiledMessage, locale: string, locales: Locales, - localeData: Object + localeData: LocaleData ) { - return (values: Object, formats: Object = {}): string => { - const ctx = context({ + return (values: Values, formats: Formats = {}): string => { + const ctx = context( locale, locales, - localeData, - formats, values, - }) + formats, + localeData, + ) - const formatMessage = (message) => { + const formatMessage = (message: CompiledMessage): string => { if (!Array.isArray(message)) return message - return message.reduce((message, token) => { + return message.reduce((message, token) => { if (isString(token)) return message + token const [name, type, format] = token diff --git a/packages/core/src/dev/compile.test.ts b/packages/core/src/dev/compile.test.ts deleted file mode 100644 index c232ad762..000000000 --- a/packages/core/src/dev/compile.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import compile from "./compile" -import { mockEnv, mockConsole } from "@lingui/jest-mocks" -import { interpolate } from "../context" - -describe("compile", function () { - const englishPlurals = { - plurals(value, ordinal) { - if (ordinal) { - return { "1": "one", "2": "two", "3": "few" }[value] || "other" - } else { - return value === 1 ? "one" : "other" - } - }, - } - - const prepare = (translation, locale?, locales?) => - interpolate(compile(translation), locale || "en", locales, englishPlurals) - - it("should handle an error if message has syntax errors", function () { - mockConsole((console) => { - expect(compile("Invalid {{message}}")).toEqual("Invalid {{message}}") - expect(console.error).toBeCalledWith( - "Message cannot be parsed due to syntax errors: Invalid {{message}}" - ) - }) - }) - - it("should compile static message", function () { - const cache = compile("Static message") - expect(cache).toEqual("Static message") - - mockEnv("production", () => { - const cache = compile("Static message") - expect(cache).toEqual("Static message") - }) - }) - - it("should compile message with variable", function () { - const cache = compile("Hey {name}!") - expect(interpolate(cache, "en", [], {})({ name: "Joe" })).toEqual( - "Hey Joe!" - ) - }) - - it("should compile plurals", function () { - const plural = prepare( - "{value, plural, one {{value} Book} other {# Books}}" - ) - expect(plural({ value: 1 })).toEqual("1 Book") - expect(plural({ value: 2 })).toEqual("2 Books") - - const offset = prepare( - "{value, plural, offset:1 =0 {No Books} one {# Book} other {# Books}}" - ) - expect(offset({ value: 0 })).toEqual("No Books") - expect(offset({ value: 2 })).toEqual("1 Book") - expect(offset({ value: 3 })).toEqual("2 Books") - }) - - it("should compile selectordinal", function () { - const cache = prepare( - "{value, selectordinal, one {#st Book} two {#nd Book}}" - ) - expect(cache({ value: 1 })).toEqual("1st Book") - expect(cache({ value: 2 })).toEqual("2nd Book") - }) - - it("should compile select", function () { - const cache = prepare("{value, select, female {She} other {They}}") - expect(cache({ value: "female" })).toEqual("She") - expect(cache({ value: "n/a" })).toEqual("They") - }) - - const testVector = [ - ["en", null, "0.1", "10%", "20%", "3/4/2017", "€0.10", "€1.00"], - ["fr", null, "0,1", "10 %", "20 %", "04/03/2017", "0,10 €", "1,00 €"], - ["fr", "fr-CH", "0,1", "10%", "20%", "04.03.2017", "0.10 €", "1.00 €"], - ] - testVector.forEach((tc) => { - const [ - locale, - locales, - expectedNumber, - expectedPercent1, - expectedPercent2, - expectedDate, - expectedCurrency1, - expectedCurrency2, - ] = tc - - it( - "should compile custom format for locale=" + - locale + - " and locales=" + - locales, - function () { - const number = prepare("{value, number}", locale, locales) - expect(number({ value: 0.1 })).toEqual(expectedNumber) - - const percent = prepare("{value, number, percent}", locale, locales) - expect(percent({ value: 0.1 })).toEqual(expectedPercent1) - expect(percent({ value: 0.2 })).toEqual(expectedPercent2) - - const now = new Date("3/4/2017") - const date = prepare("{value, date}", locale, locales) - expect(date({ value: now })).toEqual(expectedDate) - - const formats = { - currency: { - style: "currency", - currency: "EUR", - minimumFractionDigits: 2, - }, - } - const currency = prepare("{value, number, currency}", locale, locales) - expect(currency({ value: 0.1 }, formats)).toEqual(expectedCurrency1) - expect(currency({ value: 1 }, formats)).toEqual(expectedCurrency2) - } - ) - }) -}) diff --git a/packages/core/src/dev/compile.ts b/packages/core/src/dev/compile.ts deleted file mode 100644 index 93d045a44..000000000 --- a/packages/core/src/dev/compile.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { parse } from "messageformat-parser" -import { isString } from "../essentials" -import { CompiledMessage } from "../i18n" - -// [Tokens] -> (CTX -> String) -function processTokens(tokens) { - if (!tokens.filter((token) => !isString(token)).length) { - return tokens.join("") - } - - return tokens.map((token) => { - if (isString(token)) { - return token - - // # in plural case - } else if (token.type === "octothorpe") { - return "#" - - // simple argument - } else if (token.type === "argument") { - return [token.arg] - - // argument with custom format (date, number) - } else if (token.type === "function") { - const _param = token.param && token.param.tokens[0] - const param = typeof _param === "string" ? _param.trim() : _param - return [token.arg, token.key, param].filter(Boolean) - } - - const offset = token.offset ? parseInt(token.offset) : undefined - - // complex argument with cases - const formatProps = {} - token.cases.forEach((item) => { - formatProps[item.key] = processTokens(item.tokens) - }) - - return [ - token.arg, - token.type, - { - offset, - ...formatProps, - }, - ] - }) -} - -// Message -> (Params -> String) -export default function compile( - message: string -): CompiledMessage { - try { - return processTokens(parse(message)) - } catch (e) { - console.error(`Message cannot be parsed due to syntax errors: ${message}`) - return message - } -} diff --git a/packages/core/src/dev/index.ts b/packages/core/src/dev/index.ts index e3b6d5b01..32ce04ffa 100644 --- a/packages/core/src/dev/index.ts +++ b/packages/core/src/dev/index.ts @@ -1,4 +1,4 @@ -import compile from "./compile" +import {compileMessage as compile} from "../compileMessage" import loadLocaleData from "./loadLocaleData" export { compile, loadLocaleData } diff --git a/packages/core/src/formats.ts b/packages/core/src/formats.ts index 7be2099a1..e4707c947 100644 --- a/packages/core/src/formats.ts +++ b/packages/core/src/formats.ts @@ -59,4 +59,4 @@ function cacheKey( ) { const localeKey = Array.isArray(locales) ? locales.sort().join('-') : locales return `${localeKey}-${JSON.stringify(options)}` -} \ No newline at end of file +} diff --git a/packages/core/src/i18n.ts b/packages/core/src/i18n.ts index 49538b62c..f0359b8a4 100644 --- a/packages/core/src/i18n.ts +++ b/packages/core/src/i18n.ts @@ -3,27 +3,30 @@ import { isString, isFunction } from "./essentials" import { date, number } from "./formats" import * as icu from "./dev" import { EventEmitter } from "./eventEmitter" +import type {PluralCategory} from "make-plural" export type MessageOptions = { message?: string context?: string - formats?: Object + formats?: Formats } export type Locale = string export type Locales = Locale | Locale[] +export type Formats = Record + +export type Values = Record; export type LocaleData = { - plurals?: Function + plurals?: (n: number, ordinal?: boolean) => PluralCategory } export type AllLocaleData = Record -export type CompiledMessage = - | string - | Array< - string | Array> - > +export type CompiledIcuChoices = Record & {offset: number}; +export type CompiledMessageToken = string | [name: string, type?: string, format?: null | string | CompiledIcuChoices]; + +export type CompiledMessage = string | CompiledMessageToken[] export type Messages = Record @@ -48,7 +51,7 @@ type setupI18nProps = { locales?: Locales messages?: AllMessages localeData?: AllLocaleData - missing?: string | ((message, id, context) => string) + missing?: string | ((message: string, id: string, context: string) => string) } type Events = { @@ -57,11 +60,11 @@ type Events = { } export class I18n extends EventEmitter { - _locale: Locale - _locales: Locales - _localeData: AllLocaleData - _messages: AllMessages - _missing: string | ((message, id, context) => string) + private _locale: Locale + private _locales: Locales + private _localeData: AllLocaleData + private _messages: AllMessages + private _missing: string | ((message, id, context) => string) constructor(params: setupI18nProps) { super() @@ -93,7 +96,7 @@ export class I18n extends EventEmitter { return this._localeData[this._locale] ?? {} } - _loadLocaleData(locale: Locale, localeData: LocaleData) { + private _loadLocaleData(locale: Locale, localeData: LocaleData) { if (this._localeData[locale] == null) { this._localeData[locale] = localeData } else { @@ -120,7 +123,7 @@ export class I18n extends EventEmitter { this.emit("change") } - _load(locale: Locale, messages: Messages) { + private _load(locale: Locale, messages: Messages) { if (this._messages[locale] == null) { this._messages[locale] = messages } else { @@ -168,7 +171,7 @@ export class I18n extends EventEmitter { // method for translation and formatting _( id: MessageDescriptor | string, - values: Object | undefined = {}, + values: Values | undefined = {}, { message, formats, context }: MessageOptions | undefined = {} ) { if (!isString(id)) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1e1db5314..d29ca6e6a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,6 +10,10 @@ export { Locales, } from "./i18n" +export { + compileMessage +} from './compileMessage' + // Default i18n object import { setupI18n } from "./i18n" export const i18n = setupI18n() diff --git a/packages/jest-mocks/index.ts b/packages/jest-mocks/index.ts index 321c1f2cc..895fe89d8 100644 --- a/packages/jest-mocks/index.ts +++ b/packages/jest-mocks/index.ts @@ -12,7 +12,7 @@ export function getConsoleMockCalls({ mock }) { return mock.calls.map((call) => call[0]).join("\n") } -export function mockConsole(testCase, mock = {}) { +export function mockConsole(testCase: (console: jest.Mocked) => any, mock = {}) { function restoreConsole() { global.console = originalConsole } @@ -25,15 +25,14 @@ export function mockConsole(testCase, mock = {}) { error: jest.fn(), } - // @ts-ignore: Lot of console methods are missing global.console = { ...defaults, ...mock, - } + } as any let result try { - result = testCase(global.console) + result = testCase(global.console as jest.Mocked) } catch (e) { restoreConsole() throw e diff --git a/packages/react/src/Trans.tsx b/packages/react/src/Trans.tsx index 339d0950d..c84a76584 100644 --- a/packages/react/src/Trans.tsx +++ b/packages/react/src/Trans.tsx @@ -14,10 +14,10 @@ export type TransRenderProps = { export type TransProps = { id: string message?: string - values: Object + values: Record context?: string components: { [key: string]: React.ElementType | any } - formats?: Object + formats?: Record children?: React.ReactNode component?: React.ComponentType render?: (props: TransRenderProps) => React.ReactElement | null diff --git a/packages/remote-loader/package.json b/packages/remote-loader/package.json index 4f7e77972..e3dbcf30c 100644 --- a/packages/remote-loader/package.json +++ b/packages/remote-loader/package.json @@ -40,10 +40,7 @@ "esm/" ], "dependencies": { - "@babel/generator": "^7.14.5", - "@babel/types": "^7.14.5", - "json5": "^2.2.0", - "messageformat-parser": "^4.1.3", + "@lingui/core": "^3.15.0", "ramda": "^0.27.1" } } diff --git a/packages/remote-loader/src/browserCompiler.ts b/packages/remote-loader/src/browserCompiler.ts index e9393e8ca..af0544262 100644 --- a/packages/remote-loader/src/browserCompiler.ts +++ b/packages/remote-loader/src/browserCompiler.ts @@ -1,112 +1,23 @@ -import * as R from "ramda" -import * as t from "@babel/types" -import JSON5 from "json5" -import generate from "@babel/generator" -import { parse } from "messageformat-parser" +import { compileMessage } from "@lingui/core" export function createBrowserCompiledCatalog(messages: Record) { - const compiledMessages = R.keys(messages).map((key) => { - let translation = messages[key] || key - - return t.objectProperty(t.stringLiteral(key.toString()), compile(translation)) - }) - - const ast = t.objectExpression(compiledMessages) - const code = generate(ast as any, { - minified: true, - jsescOption: { - minimal: true, + return Object.keys(messages).reduce((obj, key: string) => { + const value = messages[key] + + // If the current ID's value is a context object, create a nested + // expression, and assign the current ID to that expression + if (typeof value === "object") { + obj[key] = Object.keys(value).reduce((obj, contextKey) => { + obj[contextKey] = compileMessage(value[contextKey]) + return obj + }, {}) + + return obj } - }).code - - return JSON5.parse(code) -} - -/** - * Compile string message into AST tree. Message format is parsed/compiled into - * JS arrays, which are handled in client. - */ -export function compile(message: string) { - let tokens: any - try { - tokens = parse(message) - } catch (e) { - throw new Error( - `Can't parse message. Please check correct syntax: "${message}" \n \n Messageformat-parser trace: ${e.message}`, - ) - } - const ast = processTokens(tokens) + const translation = value || key - if (isString(ast)) return t.stringLiteral(ast) - - return ast + obj[key] = compileMessage(translation) + return obj + }, {}) } - -function processTokens(tokens: any) { - // Shortcut - if the message doesn't include any formatting, - // simply join all string chunks into one message - if (!tokens.filter((token: any) => !isString(token)).length) { - return tokens.join("") - } - - return t.arrayExpression( - tokens.map((token: any) => { - if (isString(token)) { - return t.stringLiteral(token) - - // # in plural case - } else if (token.type === "octothorpe") { - return t.stringLiteral("#") - - // simple argument - } else if (token.type === "argument") { - return t.arrayExpression([t.stringLiteral(token.arg)]) - - // argument with custom format (date, number) - } else if (token.type === "function") { - const params = [t.stringLiteral(token.arg), t.stringLiteral(token.key)] - - const format = token.param && token.param.tokens[0] - if (format) { - params.push(t.stringLiteral(format.trim())) - } - return t.arrayExpression(params) - } - - // complex argument with cases - const formatProps = [] - - if (token.offset) { - formatProps.push( - t.objectProperty( - t.identifier("offset"), - t.numericLiteral(parseInt(token.offset)) - ) - ) - } - - token.cases.forEach((item: any) => { - const inlineTokens = processTokens(item.tokens) - formatProps.push( - t.objectProperty( - t.identifier(item.key), - isString(inlineTokens) - ? t.stringLiteral(inlineTokens) - : inlineTokens - ) - ) - }) - - const params = [ - t.stringLiteral(token.arg), - t.stringLiteral(token.type), - t.objectExpression(formatProps), - ] - - return t.arrayExpression(params) - }) - ) -} - -const isString = (s: any) => typeof s === "string" diff --git a/packages/remote-loader/src/index.ts b/packages/remote-loader/src/index.ts index e3b8b7f9d..32416c5a5 100644 --- a/packages/remote-loader/src/index.ts +++ b/packages/remote-loader/src/index.ts @@ -4,8 +4,8 @@ import PARSERS from "./minimalParser" type RemoteLoaderOpts = { format?: "minimal" - fallbackMessages?: string | Record | T - messages: string | Record | T + fallbackMessages?: string | Record | T + messages: string | Record | T } function remoteLoader({ format = "minimal", fallbackMessages, messages}: RemoteLoaderOpts) { @@ -28,6 +28,7 @@ function remoteLoader({ format = "minimal", fallbackMessages, messages}: Remo `) } + // todo: that will not work with context const mapTranslationsToInterporlatedString = R.mapObjIndexed( (_, key) => { // if there's fallback and translation is empty, return the fallback diff --git a/packages/remote-loader/test/index.test.ts b/packages/remote-loader/test/index.test.ts index df5ad4941..068782206 100644 --- a/packages/remote-loader/test/index.test.ts +++ b/packages/remote-loader/test/index.test.ts @@ -13,6 +13,7 @@ describe("remote-loader", () => { someVariable, select, Object { + offset: undefined, other: SomeOtherText, someVarValue: SomeTextHere, }, @@ -51,6 +52,7 @@ describe("remote-loader", () => { someVariable, select, Object { + offset: undefined, other: SomeOtherText, someVarValue: SomeTextHere, }, diff --git a/packages/snowpack-plugin/test/__snapshots__/index.ts.snap b/packages/snowpack-plugin/test/__snapshots__/index.ts.snap index 98036548b..72f3587f8 100644 --- a/packages/snowpack-plugin/test/__snapshots__/index.ts.snap +++ b/packages/snowpack-plugin/test/__snapshots__/index.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`snowpack-plugin should return compiled catalog 1`] = `/*eslint-disable*/module.exports={messages:{"Hello World":"Hello World","My name is {name}":["My name is ",["name"]]}};`; +exports[`snowpack-plugin should return compiled catalog 1`] = `/*eslint-disable*/module.exports={messages:JSON.parse("{\\"Hello World\\":\\"Hello World\\",\\"My name is {name}\\":[\\"My name is \\",[\\"name\\"]]}")};`; exports[`snowpack-plugin should return error if import doesn't contain extension 1`] = `@lingui/snowpack-plugin: File extension is mandatory, for ex: import('./locales/en/messages.po')`; diff --git a/scripts/build/modules.js b/scripts/build/modules.js index f5269b95a..0d3baf6c8 100644 --- a/scripts/build/modules.js +++ b/scripts/build/modules.js @@ -12,7 +12,7 @@ const importSideEffects = Object.freeze({ "@babel/runtime-corejs2/helpers/typeof": HAS_NO_SIDE_EFFECTS_ON_IMPORT, "babel-runtime/core-js/object/get-own-property-names": HAS_NO_SIDE_EFFECTS_ON_IMPORT, "babel-runtime/helpers/slicedToArray": HAS_NO_SIDE_EFFECTS_ON_IMPORT, - "messageformat-parser": HAS_NO_SIDE_EFFECTS_ON_IMPORT, + "@messageformat/parser": HAS_NO_SIDE_EFFECTS_ON_IMPORT, "make-plural/umd/plurals": HAS_NO_SIDE_EFFECTS_ON_IMPORT, "@lingui/core": HAS_NO_SIDE_EFFECTS_ON_IMPORT, deepFreezeAndThrowOnMutationInDev: HAS_NO_SIDE_EFFECTS_ON_IMPORT diff --git a/yarn.lock b/yarn.lock index 5014d966a..03139f1ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2004,6 +2004,13 @@ npmlog "^4.1.2" write-file-atomic "^2.3.0" +"@messageformat/parser@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@messageformat/parser/-/parser-5.0.0.tgz#5737e69d7d4a469998b527710f1891174fc1b262" + integrity sha512-WiDKhi8F0zQaFU8cXgqq69eYFarCnTVxKcvhAONufKf0oUxbqLMW6JX6rV4Hqh+BEQWGyKKKHY4g1XA6bCLylA== + dependencies: + moo "^0.5.1" + "@mrmlnc/readdir-enhanced@^2.2.1": version "2.2.1" resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde" @@ -8888,11 +8895,6 @@ merge2@^1.2.3, merge2@^1.3.0: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== -messageformat-parser@^4.1.3: - version "4.1.3" - resolved "https://registry.yarnpkg.com/messageformat-parser/-/messageformat-parser-4.1.3.tgz#b824787f57fcda7d50769f5b63e8d4fda68f5b9e" - integrity sha512-2fU3XDCanRqeOCkn7R5zW5VQHWf+T3hH65SzuqRvjatBK7r4uyFa5mEX+k6F9Bd04LVM5G4/BHBTUJsOdW7uyg== - micromatch@4.0.2, micromatch@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259" @@ -9082,6 +9084,11 @@ modify-values@^1.0.0: resolved "https://registry.yarnpkg.com/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022" integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw== +moo@^0.5.1: + version "0.5.2" + resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.2.tgz#f9fe82473bc7c184b0d32e2215d3f6e67278733c" + integrity sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q== + move-concurrently@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"