From 93ab9f49057a776396af5c45410a7350f74cb980 Mon Sep 17 00:00:00 2001 From: Timofei Iatsenko Date: Wed, 22 Feb 2023 08:32:13 +0100 Subject: [PATCH] feat: Hash based id (#1440) * feature(macro): hash based id for js macro * feature(macro): hash based id for jsx macro * feature(): remove outdated code related to runtime context * support hash based ids in po formatter * test: add e2e test for extractor * support context passed as tpl literal * remove set/tz is outdated and causes test failures on windows * update docs --- jest.config.integration.js | 1 - jest.config.js | 1 - package.json | 1 - .../test/__snapshots__/index.ts.snap | 48 +- .../api/__snapshots__/catalog.test.ts.snap | 229 ++++++--- .../api/__snapshots__/compile.test.ts.snap | 2 - packages/cli/src/api/catalog.ts | 17 +- packages/cli/src/api/compile.test.ts | 29 +- packages/cli/src/api/compile.ts | 25 +- .../__snapshots__/po-gettext.test.ts.snap | 243 ++-------- .../api/formats/__snapshots__/po.test.ts.snap | 72 ++- .../cli/src/api/formats/fixtures/messages.po | 13 +- .../fixtures/messages_plural-4-letter.po | 4 +- .../api/formats/fixtures/messages_plural.po | 4 +- .../cli/src/api/formats/po-gettext.test.ts | 201 +------- packages/cli/src/api/formats/po-gettext.ts | 15 +- packages/cli/src/api/formats/po.test.ts | 59 ++- packages/cli/src/api/formats/po.ts | 69 ++- .../cli/src/api/generateMessageId.test.ts | 32 ++ packages/cli/src/api/generateMessageId.ts | 11 + packages/cli/src/api/help.ts | 10 +- packages/cli/src/api/index.ts | 1 + packages/cli/src/api/utils.ts | 7 + packages/cli/src/lingui-extract-template.ts | 20 +- packages/cli/src/lingui-extract.ts | 11 +- .../test/__snapshots__/compile.test.ts.snap | 4 +- packages/cli/src/test/compile.test.ts | 74 ++- packages/cli/src/tests.ts | 56 ++- packages/cli/test/.gitignore | 1 + .../cli/test/extract-po-format/expected/en.po | 46 ++ .../cli/test/extract-po-format/expected/pl.po | 46 ++ .../test/extract-po-format/fixtures/file-a.ts | 20 + .../extract-po-format/fixtures/file-b.tsx | 16 + .../expected/messages.pot | 45 ++ .../fixtures/file-a.ts | 20 + .../fixtures/file-b.tsx | 16 + packages/cli/test/index.test.ts | 118 +++++ packages/core/src/i18n.test.ts | 2 +- packages/core/src/i18n.ts | 31 +- packages/macro/package.json | 2 +- packages/macro/src/macroJs.ts | 307 ++++++------ packages/macro/src/macroJsx.ts | 129 ++--- packages/macro/src/utils.ts | 13 - .../js-t-continuation-character.expected.js | 10 +- .../fixtures/js-t-var/js-t-var.expected.js | 34 +- .../jsx-plural-select-nested.expected.js | 20 +- packages/macro/test/index.ts | 49 +- packages/macro/test/js-arg.ts | 8 +- packages/macro/test/js-defineMessage.ts | 20 +- packages/macro/test/js-plural.ts | 27 +- packages/macro/test/js-select.ts | 31 +- packages/macro/test/js-selectOrdinal.ts | 15 +- packages/macro/test/js-t.ts | 257 +++++++--- packages/macro/test/jsx-plural.ts | 58 ++- packages/macro/test/jsx-select.ts | 19 +- packages/macro/test/jsx-selectOrdinal.ts | 27 +- packages/macro/test/jsx-trans.ts | 221 ++++++--- packages/react/src/Trans.tsx | 1 - packages/remote-loader/src/browserCompiler.ts | 11 - packages/remote-loader/src/index.ts | 1 - .../test/__snapshots__/index.ts.snap | 2 +- .../test/__snapshots__/index.ts.snap | 2 +- scripts/jest/env.js | 4 + website/docs/guides/plurals.md | 29 +- website/docs/ref/macro.md | 451 ++++++++++++------ website/docs/tutorials/react-patterns.md | 63 ++- yarn.lock | 12 - 67 files changed, 2128 insertions(+), 1315 deletions(-) create mode 100644 packages/cli/src/api/generateMessageId.test.ts create mode 100644 packages/cli/src/api/generateMessageId.ts create mode 100644 packages/cli/test/.gitignore create mode 100644 packages/cli/test/extract-po-format/expected/en.po create mode 100644 packages/cli/test/extract-po-format/expected/pl.po create mode 100644 packages/cli/test/extract-po-format/fixtures/file-a.ts create mode 100644 packages/cli/test/extract-po-format/fixtures/file-b.tsx create mode 100644 packages/cli/test/extract-template-po-format/expected/messages.pot create mode 100644 packages/cli/test/extract-template-po-format/fixtures/file-a.ts create mode 100644 packages/cli/test/extract-template-po-format/fixtures/file-b.tsx create mode 100644 packages/cli/test/index.test.ts diff --git a/jest.config.integration.js b/jest.config.integration.js index a113b9925..ec6131d78 100644 --- a/jest.config.integration.js +++ b/jest.config.integration.js @@ -10,7 +10,6 @@ module.exports = { testPathIgnorePatterns: ["/node_modules/"], // Redirect imports to the compiled bundles moduleNameMapper: {}, - setupFiles: ["set-tz/utc"], // Exclude the build output from transforms transformIgnorePatterns: ["/node_modules/", "/packages/*/build/"], diff --git a/jest.config.js b/jest.config.js index 47b92d751..f194b8ee8 100644 --- a/jest.config.js +++ b/jest.config.js @@ -36,7 +36,6 @@ module.exports = { }), reporters: ["default", "jest-junit"], - setupFiles: ["set-tz/utc"], setupFilesAfterEnv: [require.resolve("./scripts/jest/env.js")], snapshotSerializers: [ "jest-serializer-html", diff --git a/package.json b/package.json index 4b02c0e7a..f6c42c3a3 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,6 @@ "rollup": "^3.10.0", "rollup-plugin-dts": "^5.1.1", "semver": "^7.3.2", - "set-tz": "^0.2.0", "size-limit": "^8.1.1", "strip-ansi": "^6.0.0", "swc-node": "^1.0.0", diff --git a/packages/babel-plugin-extract-messages/test/__snapshots__/index.ts.snap b/packages/babel-plugin-extract-messages/test/__snapshots__/index.ts.snap index 75d690769..842919e37 100644 --- a/packages/babel-plugin-extract-messages/test/__snapshots__/index.ts.snap +++ b/packages/babel-plugin-extract-messages/test/__snapshots__/index.ts.snap @@ -115,8 +115,8 @@ Array [ Object { comment: undefined, context: undefined, - id: {count, plural, one {# book} other {# books}}, - message: undefined, + id: esnaQO, + message: {count, plural, one {# book} other {# books}}, origin: Array [ jsx-without-trans.js, 3, @@ -125,8 +125,8 @@ Array [ Object { comment: undefined, context: Some context, - id: {count, plural, one {# book} other {# books}}, - message: undefined, + id: 8qNz+K, + message: {count, plural, one {# book} other {# books}}, origin: Array [ jsx-without-trans.js, 4, @@ -140,8 +140,8 @@ Array [ Object { comment: undefined, context: undefined, - id: Message, - message: undefined, + id: xDAtGP, + message: Message, origin: Array [ js-with-macros.js, 3, @@ -150,8 +150,8 @@ Array [ Object { comment: undefined, context: undefined, - id: Message, - message: undefined, + id: xDAtGP, + message: Message, origin: Array [ js-with-macros.js, 5, @@ -160,8 +160,8 @@ Array [ Object { comment: description, context: undefined, - id: Description, - message: undefined, + id: Nu4oKW, + message: Description, origin: Array [ js-with-macros.js, 7, @@ -180,8 +180,8 @@ Array [ Object { comment: undefined, context: undefined, - id: Values {param}, - message: undefined, + id: QCVtWw, + message: Values {param}, origin: Array [ js-with-macros.js, 17, @@ -245,8 +245,8 @@ Array [ Object { comment: undefined, context: undefined, - id: Hi, my name is {name}, - message: undefined, + id: d1Kdl3, + message: Hi, my name is {name}, origin: Array [ jsx-with-macros.js, 3, @@ -255,8 +255,8 @@ Array [ Object { comment: undefined, context: Context1, - id: Some message, - message: undefined, + id: YikuIL, + message: Some message, origin: Array [ jsx-with-macros.js, 4, @@ -265,8 +265,8 @@ Array [ Object { comment: undefined, context: Context1, - id: Some other message, - message: undefined, + id: LBCs5C, + message: Some other message, origin: Array [ jsx-with-macros.js, 5, @@ -275,8 +275,8 @@ Array [ Object { comment: undefined, context: Context2, - id: Some message, - message: undefined, + id: ru2rzr, + message: Some message, origin: Array [ jsx-with-macros.js, 6, @@ -285,8 +285,8 @@ Array [ Object { comment: undefined, context: undefined, - id: Title, - message: undefined, + id: MHrjPM, + message: Title, origin: Array [ jsx-with-macros.js, 7, @@ -295,8 +295,8 @@ Array [ Object { comment: undefined, context: undefined, - id: {count, plural, one {# book} other {# books}}, - message: undefined, + id: esnaQO, + message: {count, plural, one {# book} other {# books}}, origin: Array [ jsx-with-macros.js, 9, diff --git a/packages/cli/src/api/__snapshots__/catalog.test.ts.snap b/packages/cli/src/api/__snapshots__/catalog.test.ts.snap index 6f7016263..5d92b6daf 100644 --- a/packages/cli/src/api/__snapshots__/catalog.test.ts.snap +++ b/packages/cli/src/api/__snapshots__/catalog.test.ts.snap @@ -2,14 +2,15 @@ exports[`Catalog POT Flow Should get translations from template if locale file not presented 1`] = ` Object { - Hello World: Hello World, - Test String: Test String, + 2ZeN02: Test String, + mY42CM: Hello World, } `; exports[`Catalog collect should extract messages from source files 1`] = ` Object { Component A: Object { + context: undefined, extractedComments: Array [], message: undefined, origin: Array [ @@ -20,6 +21,7 @@ Object { ], }, Component B: Object { + context: undefined, extractedComments: Array [], message: undefined, origin: Array [ @@ -30,6 +32,7 @@ Object { ], }, Hello World: Object { + context: undefined, extractedComments: Array [ Comment A, Comment A again, @@ -52,6 +55,7 @@ Object { ], }, custom.id: Object { + context: undefined, extractedComments: Array [], message: Message with id, origin: Array [ @@ -67,6 +71,7 @@ Object { exports[`Catalog collect should extract only files passed on options 1`] = ` Object { Component A: Object { + context: undefined, extractedComments: Array [], message: undefined, origin: Array [ @@ -77,6 +82,7 @@ Object { ], }, Hello World: Object { + context: undefined, extractedComments: Array [ Comment A, Comment A again, @@ -99,6 +105,7 @@ Object { ], }, custom.id: Object { + context: undefined, extractedComments: Array [], message: Message with id, origin: Array [ @@ -115,75 +122,82 @@ exports[`Catalog collect should support Flow syntax if enabled 1`] = `Object {}` exports[`Catalog collect should support JSX and Typescript 1`] = ` Object { - Description: Object { - extractedComments: Array [ - description, - ], - message: undefined, + ID Some: Object { + context: undefined, + extractedComments: Array [], + message: Message with id some, origin: Array [ Array [ collect-typescript-jsx/macro.tsx, - 6, + 11, ], ], }, - Hi, my name is {name}: Object { + MHrjPM: Object { + context: undefined, extractedComments: Array [], - message: undefined, + message: Title, origin: Array [ Array [ collect-typescript-jsx/macro.tsx, - 17, + 19, ], ], }, - ID Some: Object { - extractedComments: Array [], - message: Message with id some, + Nu4oKW: Object { + context: undefined, + extractedComments: Array [ + description, + ], + message: Description, origin: Array [ Array [ collect-typescript-jsx/macro.tsx, - 11, + 6, ], ], }, - Message: Object { + YikuIL: Object { + context: Context1, extractedComments: Array [], - message: undefined, + message: Some message, origin: Array [ Array [ collect-typescript-jsx/macro.tsx, - 4, + 18, ], ], }, - Some message: Object { + d1Kdl3: Object { + context: undefined, extractedComments: Array [], - message: undefined, + message: Hi, my name is {name}, origin: Array [ Array [ collect-typescript-jsx/macro.tsx, - 18, + 17, ], ], }, - Title: Object { + esnaQO: Object { + context: undefined, extractedComments: Array [], - message: undefined, + message: {count, plural, one {# book} other {# books}}, origin: Array [ Array [ collect-typescript-jsx/macro.tsx, - 19, + 21, ], ], }, - {count, plural, one {# book} other {# books}}: Object { + xDAtGP: Object { + context: undefined, extractedComments: Array [], - message: undefined, + message: Message, origin: Array [ Array [ collect-typescript-jsx/macro.tsx, - 21, + 4, ], ], }, @@ -204,7 +218,9 @@ Object { comments: Array [], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [ Array [ @@ -222,7 +238,9 @@ Object { Comment A again, Hello comment, ], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [ Array [ @@ -244,7 +262,9 @@ Object { comments: Array [], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [ Array [ @@ -260,7 +280,9 @@ Object { comments: Array [], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [ Array [ @@ -278,7 +300,9 @@ Object { Comment A again, Hello comment, ], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [ Array [ @@ -300,7 +324,9 @@ Object { comments: Array [], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [ Array [ @@ -317,22 +343,24 @@ Object { exports[`Catalog make should merge with existing catalogs 1`] = ` Object { cs: Object { - Hello World: Object { + mY42CM: Object { comments: Array [], context: null, extractedComments: Array [], flags: Array [], + message: Hello World, obsolete: false, origin: Array [], translation: Ahoj Brno, }, }, en: Object { - Hello World: Object { + mY42CM: Object { comments: Array [], context: null, extractedComments: Array [], flags: Array [], + message: Hello World, obsolete: false, origin: Array [], translation: Ahoj Brno, @@ -348,7 +376,9 @@ Object { comments: Array [], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [ Array [ @@ -362,7 +392,9 @@ Object { comments: Array [], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [ Array [ @@ -380,7 +412,9 @@ Object { Comment A again, Hello comment, ], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [ Array [ @@ -396,13 +430,15 @@ Object { 1, ], ], - translation: Ahoj Brno, + translation: , }, custom.id: Object { comments: Array [], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [ Array [ @@ -412,13 +448,27 @@ Object { ], translation: , }, + mY42CM: Object { + comments: Array [], + context: null, + extractedComments: Array [ + js-lingui-id: mY42CM, + ], + flags: Array [], + message: Hello World, + obsolete: true, + origin: Array [], + translation: Ahoj Brno, + }, }, en: Object { Component A: Object { comments: Array [], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [ Array [ @@ -432,7 +482,9 @@ Object { comments: Array [], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [ Array [ @@ -450,7 +502,9 @@ Object { Comment A again, Hello comment, ], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [ Array [ @@ -466,13 +520,15 @@ Object { 1, ], ], - translation: Ahoj Brno, + translation: , }, custom.id: Object { comments: Array [], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [ Array [ @@ -482,6 +538,18 @@ Object { ], translation: , }, + mY42CM: Object { + comments: Array [], + context: null, + extractedComments: Array [ + js-lingui-id: mY42CM, + ], + flags: Array [], + message: Hello World, + obsolete: true, + origin: Array [], + translation: Ahoj Brno, + }, }, } `; @@ -501,7 +569,9 @@ Object { comments: Array [], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [ Array [ @@ -519,7 +589,9 @@ Object { Comment A again, Hello comment, ], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [ Array [ @@ -541,7 +613,9 @@ Object { comments: Array [], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [ Array [ @@ -563,7 +637,9 @@ Object { comments: Array [], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [ Array [ @@ -581,7 +657,9 @@ Object { Comment A again, Hello comment, ], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [ Array [ @@ -603,7 +681,9 @@ Object { comments: Array [], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [ Array [ @@ -622,7 +702,9 @@ Object { comments: Array [], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: true, origin: Array [], translation: Is marked as obsolete, @@ -631,7 +713,9 @@ Object { comments: Array [], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [], translation: Static message, @@ -640,7 +724,9 @@ Object { comments: Array [], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [], translation: One morning, when Gregor Samsa woke from troubled dreams, he found himself transformed in his bed into a horrible vermin. He lay on his armour-like back, and if he lifted his head a little he could see his brown belly, slightly domed and divided by arches into stiff sections. The bedding was hardly able to cover it and seemed ready to slide off any moment. His many legs, pitifully thin compared with the size of the rest of him, waved about helplessly as he looked. "What's happened to me?" he thought. It wasn't a dream. His room, a proper human, @@ -652,7 +738,9 @@ Object { ], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [], translation: Support translator comments separately, @@ -663,7 +751,9 @@ Object { extractedComments: Array [ Description is comment from developers to translators, ], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [], translation: Message with description, @@ -675,6 +765,7 @@ Object { flags: Array [ fuzzy, otherFlag, + explicit-id, ], obsolete: false, origin: Array [], @@ -684,7 +775,9 @@ Object { comments: Array [], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [ Array [ @@ -702,7 +795,9 @@ Object { comments: Array [], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [ Array [ @@ -712,6 +807,18 @@ Object { ], translation: Message with origin, }, + xC8QeX: Object { + comments: Array [], + context: null, + extractedComments: Array [ + js-lingui-id: pXy+hm, + ], + flags: Array [], + message: Message with default hash id, + obsolete: false, + origin: Array [], + translation: Translation for: Message with default-hash id, + }, } `; @@ -720,22 +827,24 @@ exports[`Catalog read should read file in previous format 1`] = `null`; exports[`Catalog readAll should read existing catalogs for all locales 1`] = ` Object { cs: Object { - Hello World: Object { + mY42CM: Object { comments: Array [], context: null, extractedComments: Array [], flags: Array [], + message: Hello World, obsolete: false, origin: Array [], translation: Ahoj Brno, }, }, en: Object { - Hello World: Object { + mY42CM: Object { comments: Array [], context: null, extractedComments: Array [], flags: Array [], + message: Hello World, obsolete: false, origin: Array [], translation: Hello World, diff --git a/packages/cli/src/api/__snapshots__/compile.test.ts.snap b/packages/cli/src/api/__snapshots__/compile.test.ts.snap index eccc39549..a43a4c6d1 100644 --- a/packages/cli/src/api/__snapshots__/compile.test.ts.snap +++ b/packages/cli/src/api/__snapshots__/compile.test.ts.snap @@ -1,7 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`createCompiledCatalog nested message 1`] = `/*eslint-disable*/module.exports={messages:JSON.parse("{\\"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 should return catalog without ASCII chars 1`] = `/*eslint-disable*/module.exports={messages:JSON.parse("{\\"Hello\\":\\"Aloh\\xE0\\"}")};`; diff --git a/packages/cli/src/api/catalog.ts b/packages/cli/src/api/catalog.ts index 74484e93d..78bf4fa34 100644 --- a/packages/cli/src/api/catalog.ts +++ b/packages/cli/src/api/catalog.ts @@ -112,7 +112,7 @@ export class Catalog { this.format = getFormat(config.format) } - async make(options: MakeOptions): Promise { + async make(options: MakeOptions): Promise { const nextCatalog = await this.collect({ files: options.files }) if (!nextCatalog) return false const prevCatalogs = this.readAll() @@ -139,15 +139,19 @@ export class Catalog { } else { this.writeAll(sortedCatalogs) } - return true + + return sortedCatalogs } - async makeTemplate(options: MakeTemplateOptions): Promise { + async makeTemplate( + options: MakeTemplateOptions + ): Promise { const catalog = await this.collect({ files: options.files }) if (!catalog) return false - const sort = order(options.orderBy) - this.writeTemplate(sort(catalog as CatalogType)) - return true + const sorted = order(options.orderBy)(catalog as CatalogType) + + this.writeTemplate(sorted) + return sorted } /** @@ -173,6 +177,7 @@ export class Catalog { if (!messages[next.id]) { messages[next.id] = { message: next.message, + context: next.context, extractedComments: [], origin: [], } diff --git a/packages/cli/src/api/compile.test.ts b/packages/cli/src/api/compile.test.ts index d02f775b4..13c5e83ba 100644 --- a/packages/cli/src/api/compile.test.ts +++ b/packages/cli/src/api/compile.test.ts @@ -1,4 +1,8 @@ -import { compile, createCompiledCatalog } from "./compile" +import { + compile, + CompiledCatalogNamespace, + createCompiledCatalog, +} from "./compile" describe("compile", () => { describe("with pseudo-localization", () => { @@ -176,25 +180,8 @@ describe("compile", () => { }) describe("createCompiledCatalog", () => { - it("nested message", () => { - expect( - createCompiledCatalog( - "cs", - { - nested: { - one: "Uno", - two: "Dos", - three: "Tres", - hello: "Hola {name}", - }, - }, - {} - ) - ).toMatchSnapshot() - }) - describe("options.namespace", () => { - const getCompiledCatalog = (namespace) => + const getCompiledCatalog = (namespace: CompiledCatalogNamespace) => createCompiledCatalog( "fr", {}, @@ -225,7 +212,7 @@ describe("createCompiledCatalog", () => { }) describe("options.strict", () => { - const getCompiledCatalog = (strict) => + const getCompiledCatalog = (strict: boolean) => createCompiledCatalog( "cs", { @@ -248,7 +235,7 @@ describe("createCompiledCatalog", () => { }) describe("options.pseudoLocale", () => { - const getCompiledCatalog = (pseudoLocale) => + const getCompiledCatalog = (pseudoLocale: string) => createCompiledCatalog( "ps", { diff --git a/packages/cli/src/api/compile.ts b/packages/cli/src/api/compile.ts index a0d8b943b..3dacf49f3 100644 --- a/packages/cli/src/api/compile.ts +++ b/packages/cli/src/api/compile.ts @@ -2,11 +2,12 @@ import * as t from "@babel/types" import generate, { GeneratorOptions } from "@babel/generator" import { compileMessage } from "@lingui/core/compile" import pseudoLocalize from "./pseudoLocalize" +import { CompiledMessage } from "@lingui/core/src/i18n" export type CompiledCatalogNamespace = "cjs" | "es" | "ts" | string type CompiledCatalogType = { - [msgId: string]: string | object + [msgId: string]: string } export type CreateCompileCatalogOptions = { @@ -29,20 +30,9 @@ export function createCompiledCatalog( } = options const shouldPseudolocalize = locale === pseudoLocale - 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 - // expression, and assign the current ID to that expression - if (typeof value === "object") { - obj[key] = Object.keys(value).reduce((obj, contextKey) => { - obj[contextKey] = compile(value[contextKey], shouldPseudolocalize) - return obj - }, {}) - - return obj - } - + const compiledMessages = Object.keys(messages).reduce<{ + [msgId: string]: CompiledMessage + }>((obj, key: string) => { // Don't use `key` as a fallback translation in strict mode. const translation = (messages[key] || (!strict ? key : "")) as string @@ -70,7 +60,10 @@ export function createCompiledCatalog( return "/*eslint-disable*/" + code } -function buildExportStatement(expression, namespace: CompiledCatalogNamespace) { +function buildExportStatement( + expression: t.Expression, + namespace: CompiledCatalogNamespace +) { if (namespace === "es" || namespace === "ts") { // export const messages = { message: "Translation" } return t.exportNamedDeclaration( diff --git a/packages/cli/src/api/formats/__snapshots__/po-gettext.test.ts.snap b/packages/cli/src/api/formats/__snapshots__/po-gettext.test.ts.snap index 505f1d63f..1a499a49e 100644 --- a/packages/cli/src/api/formats/__snapshots__/po-gettext.test.ts.snap +++ b/packages/cli/src/api/formats/__snapshots__/po-gettext.test.ts.snap @@ -1,41 +1,47 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` 1`] = ` +exports[`po-gettext format convertPluralsToIco handle correctly locales with 4-letter 1`] = ` Object { - message_with_id: Object { + WGI12K: Object { comments: Array [], context: null, extractedComments: Array [], flags: Array [], + message: {anotherCount, plural, one {Singular case} other {Case number {anotherCount}}}, obsolete: false, origin: Array [], - translation: {someCount, plural, one {Singular case} other {Case number {someCount}}}, + translation: {anotherCount, plural, one {Singular case} other {Case number {anotherCount}}}, }, - message_with_id_but_without_translation: Object { + jO/SBZ: Object { comments: Array [], context: null, - extractedComments: Array [ - Comment made by the developers., - ], + extractedComments: Array [], flags: Array [], + message: {count, plural, one {Singular} other {Plural}}, obsolete: false, origin: Array [], translation: , }, - {anotherCount, plural, one {Singular case} other {Case number {anotherCount}}}: Object { + message_with_id: Object { comments: Array [], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [], - translation: {anotherCount, plural, one {Singular case} other {Case number {anotherCount}}}, + translation: {someCount, plural, one {Singular case} other {Case number {someCount}}}, }, - {count, plural, one {Singular} other {Plural}}: Object { + message_with_id_but_without_translation: Object { comments: Array [], context: null, - extractedComments: Array [], - flags: Array [], + extractedComments: Array [ + Comment made by the developers., + ], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [], translation: , @@ -54,6 +60,7 @@ msgstr "" "Language: en\\n" #. js-lingui:pluralize_on=count +#, explicit-id msgid "message_with_id_and_octothorpe" msgid_plural "message_with_id_and_octothorpe_plural" msgstr[0] "Singular" @@ -61,11 +68,13 @@ msgstr[1] "Number is #" #. This is a comment by the developers about how the content must be localized. #. js-lingui:pluralize_on=someCount +#, explicit-id msgid "message_with_id" msgid_plural "message_with_id_plural" msgstr[0] "Singular case with id" msgstr[1] "Case number {someCount} with id" +#. js-lingui-id: WGI12K #. js-lingui:icu=%7BanotherCount%2C+plural%2C+one+%7BSingular+case%7D+other+%7BCase+number+%7BanotherCount%7D%7D%7D&pluralize_on=anotherCount msgid "Singular case" msgid_plural "Case number {anotherCount}" @@ -73,11 +82,13 @@ msgstr[0] "Singular case" msgstr[1] "Case number {anotherCount}" #. js-lingui:pluralize_on=count +#, explicit-id msgid "message_with_id_but_without_translation" msgid_plural "message_with_id_but_without_translation_plural" msgstr[0] "" msgstr[1] "" +#. js-lingui-id: xZCXAV #. js-lingui:icu=%7Bcount%2C+plural%2C+one+%7BSingular+automatic+id+no+translation%7D+other+%7BPlural+%7Bcount%7D+automatic+id+no+translation%7D%7D&pluralize_on=count msgid "Singular automatic id no translation" msgid_plural "Plural {count} automatic id no translation" @@ -88,231 +99,49 @@ msgstr[1] "" exports[`po-gettext format should convert gettext plurals to ICU plural messages 1`] = ` Object { - message_with_id: Object { - comments: Array [], - context: null, - extractedComments: Array [], - flags: Array [], - obsolete: false, - origin: Array [], - translation: {someCount, plural, one {Singular case} other {Case number {someCount}}}, - }, - message_with_id_but_without_translation: Object { - comments: Array [], - context: null, - extractedComments: Array [ - Comment made by the developers., - ], - flags: Array [], - obsolete: false, - origin: Array [], - translation: , - }, - {anotherCount, plural, one {Singular case} other {Case number {anotherCount}}}: Object { + WGI12K: Object { comments: Array [], context: null, extractedComments: Array [], flags: Array [], + message: {anotherCount, plural, one {Singular case} other {Case number {anotherCount}}}, obsolete: false, origin: Array [], translation: {anotherCount, plural, one {Singular case} other {Case number {anotherCount}}}, }, - {count, plural, one {Singular} other {Plural}}: Object { + jO/SBZ: Object { comments: Array [], context: null, extractedComments: Array [], flags: Array [], + message: {count, plural, one {Singular} other {Plural}}, obsolete: false, origin: Array [], translation: , }, -} -`; - -exports[`po-gettext format should correct badly used comments 1`] = ` -Object { - withDescriptionAndComments: Object { - comments: Array [ - Translator comment, - ], - context: null, - extractedComments: Array [ - Single description only, - Second description?, - ], - flags: Array [], - obsolete: false, - origin: Array [], - translation: Second description joins translator comments, - }, - withMultipleDescriptions: Object { - comments: Array [], - context: null, - extractedComments: Array [ - First description, - Second comment, - Third comment, - ], - flags: Array [], - obsolete: false, - origin: Array [], - translation: Extra comments are separated from the first description line, - }, -} -`; - -exports[`po-gettext format should read catalog in pofile format 1`] = ` -Object { - obsolete: Object { - comments: Array [], - context: null, - extractedComments: Array [], - flags: Array [], - obsolete: true, - origin: Array [], - translation: Is marked as obsolete, - }, - static: Object { - comments: Array [], - context: null, - extractedComments: Array [], - flags: Array [], - obsolete: false, - origin: Array [], - translation: Static message, - }, - veryLongString: Object { + message_with_id: Object { comments: Array [], context: null, extractedComments: Array [], - flags: Array [], - obsolete: false, - origin: Array [], - translation: One morning, when Gregor Samsa woke from troubled dreams, he found himself transformed in his bed into a horrible vermin. He lay on his armour-like back, and if he lifted his head a little he could see his brown belly, slightly domed and divided by arches into stiff sections. The bedding was hardly able to cover it and seemed ready to slide off any moment. His many legs, pitifully thin compared with the size of the rest of him, waved about helplessly as he looked. "What's happened to me?" he thought. It wasn't a dream. His room, a proper human, - }, - withComments: Object { - comments: Array [ - Translator comment, - This one might come from developer, + flags: Array [ + explicit-id, ], - context: null, - extractedComments: Array [], - flags: Array [], obsolete: false, origin: Array [], - translation: Support translator comments separately, + translation: {someCount, plural, one {Singular case} other {Case number {someCount}}}, }, - withDescription: Object { + message_with_id_but_without_translation: Object { comments: Array [], context: null, extractedComments: Array [ - Description is comment from developers to translators, + Comment made by the developers., ], - flags: Array [], - obsolete: false, - origin: Array [], - translation: Message with description, - }, - withFlags: Object { - comments: Array [], - context: null, - extractedComments: Array [], flags: Array [ - fuzzy, - otherFlag, + explicit-id, ], obsolete: false, origin: Array [], - translation: Keeps any flags that are defined, - }, - withMultipleOrigins: Object { - comments: Array [], - context: null, - extractedComments: Array [], - flags: Array [], - obsolete: false, - origin: Array [ - Array [ - src/App.js, - 4, - ], - Array [ - src/Component.js, - 2, - ], - ], - translation: Message with multiple origin, - }, - withOrigin: Object { - comments: Array [], - context: null, - extractedComments: Array [], - flags: Array [], - obsolete: false, - origin: Array [ - Array [ - src/App.js, - 4, - ], - ], - translation: Message with origin, - }, -} -`; - -exports[`po-gettext format should throw away additional msgstr if present 1`] = ` -Object { - withMultipleTranslations: Object { - comments: Array [], - context: null, - extractedComments: Array [], - flags: Array [], - obsolete: false, - origin: Array [], - translation: This is just fine, + translation: , }, } `; - -exports[`po-gettext format should write catalog in pofile format 1`] = ` -msgid "" -msgstr "" -"POT-Creation-Date: 2018-08-27 10:00+0000\\n" -"MIME-Version: 1.0\\n" -"Content-Type: text/plain; charset=utf-8\\n" -"Content-Transfer-Encoding: 8bit\\n" -"X-Generator: @lingui/cli\\n" -"Language: en\\n" - -msgid "static" -msgstr "Static message" - -#: src/App.js:4 -msgid "withOrigin" -msgstr "Message with origin" - -#: src/App.js:4 -#: src/Component.js:2 -msgid "withMultipleOrigins" -msgstr "Message with multiple origin" - -#. Description is comment from developers to translators -msgid "withDescription" -msgstr "Message with description" - -# Translator comment -# This one might come from developer -msgid "withComments" -msgstr "Support translator comments separately" - -#~ msgid "obsolete" -#~ msgstr "Obsolete message" - -#, fuzzy,otherFlag -msgid "withFlags" -msgstr "Keeps any flags that are defined" - -msgid "veryLongString" -msgstr "One morning, when Gregor Samsa woke from troubled dreams, he found himself transformed in his bed into a horrible vermin. He lay on his armour-like back, and if he lifted his head a little he could see his brown belly, slightly domed and divided by arches into stiff sections. The bedding was hardly able to cover it and seemed ready to slide off any moment. His many legs, pitifully thin compared with the size of the rest of him, waved about helplessly as he looked. \\"What's happened to me?\\" he thought. It wasn't a dream. His room, a proper human" - -`; diff --git a/packages/cli/src/api/formats/__snapshots__/po.test.ts.snap b/packages/cli/src/api/formats/__snapshots__/po.test.ts.snap index 5f772447c..92ac66850 100644 --- a/packages/cli/src/api/formats/__snapshots__/po.test.ts.snap +++ b/packages/cli/src/api/formats/__snapshots__/po.test.ts.snap @@ -11,7 +11,9 @@ Object { Single description only, Second description?, ], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [], translation: Second description joins translator comments, @@ -24,7 +26,9 @@ Object { Second comment, Third comment, ], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [], translation: Extra comments are separated from the first description line, @@ -38,7 +42,9 @@ Object { comments: Array [], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: true, origin: Array [], translation: Is marked as obsolete, @@ -47,7 +53,9 @@ Object { comments: Array [], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [], translation: Static message, @@ -56,7 +64,9 @@ Object { comments: Array [], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [], translation: One morning, when Gregor Samsa woke from troubled dreams, he found himself transformed in his bed into a horrible vermin. He lay on his armour-like back, and if he lifted his head a little he could see his brown belly, slightly domed and divided by arches into stiff sections. The bedding was hardly able to cover it and seemed ready to slide off any moment. His many legs, pitifully thin compared with the size of the rest of him, waved about helplessly as he looked. "What's happened to me?" he thought. It wasn't a dream. His room, a proper human, @@ -68,7 +78,9 @@ Object { ], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [], translation: Support translator comments separately, @@ -79,7 +91,9 @@ Object { extractedComments: Array [ Description is comment from developers to translators, ], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [], translation: Message with description, @@ -91,6 +105,7 @@ Object { flags: Array [ fuzzy, otherFlag, + explicit-id, ], obsolete: false, origin: Array [], @@ -100,7 +115,9 @@ Object { comments: Array [], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [ Array [ @@ -118,7 +135,9 @@ Object { comments: Array [], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [ Array [ @@ -128,6 +147,18 @@ Object { ], translation: Message with origin, }, + xC8QeX: Object { + comments: Array [], + context: null, + extractedComments: Array [ + js-lingui-id: pXy+hm, + ], + flags: Array [], + message: Message with default hash id, + obsolete: false, + origin: Array [], + translation: Translation for: Message with default-hash id, + }, } `; @@ -137,7 +168,9 @@ Object { comments: Array [], context: null, extractedComments: Array [], - flags: Array [], + flags: Array [ + explicit-id, + ], obsolete: false, origin: Array [], translation: This is just fine, @@ -155,34 +188,51 @@ msgstr "" "X-Generator: @lingui/cli\\n" "Language: en\\n" +#, explicit-id msgid "static" msgstr "Static message" #: src/App.js:4 +#, explicit-id msgid "withOrigin" msgstr "Message with origin" +#, explicit-id +msgctxt "my context" +msgid "withContext" +msgstr "Message with context" + +#. js-lingui-id: Dgzql1 +msgctxt "my context" +msgid "with generated id" +msgstr "" + #: src/App.js:4 #: src/Component.js:2 +#, explicit-id msgid "withMultipleOrigins" msgstr "Message with multiple origin" #. Description is comment from developers to translators +#, explicit-id msgid "withDescription" msgstr "Message with description" # Translator comment # This one might come from developer +#, explicit-id msgid "withComments" msgstr "Support translator comments separately" +#, explicit-id #~ msgid "obsolete" #~ msgstr "Obsolete message" -#, fuzzy,otherFlag +#, fuzzy,otherFlag,explicit-id msgid "withFlags" msgstr "Keeps any flags that are defined" +#, explicit-id msgid "veryLongString" msgstr "One morning, when Gregor Samsa woke from troubled dreams, he found himself transformed in his bed into a horrible vermin. He lay on his armour-like back, and if he lifted his head a little he could see his brown belly, slightly domed and divided by arches into stiff sections. The bedding was hardly able to cover it and seemed ready to slide off any moment. His many legs, pitifully thin compared with the size of the rest of him, waved about helplessly as he looked. \\"What's happened to me?\\" he thought. It wasn't a dream. His room, a proper human" diff --git a/packages/cli/src/api/formats/fixtures/messages.po b/packages/cli/src/api/formats/fixtures/messages.po index 3bb873caf..2db2d0e95 100644 --- a/packages/cli/src/api/formats/fixtures/messages.po +++ b/packages/cli/src/api/formats/fixtures/messages.po @@ -13,33 +13,44 @@ msgstr "" "X-Generator: @lingui/cli\n" "Plural-Forms: nplurals=0\n" +#, explicit-id msgid "static" msgstr "Static message" #: src/App.js:4 +#, explicit-id msgid "withOrigin" msgstr "Message with origin" #: src/App.js:4 #: src/Component.js:2 +#, explicit-id msgid "withMultipleOrigins" msgstr "Message with multiple origin" #. Description is comment from developers to translators +#, explicit-id msgid "withDescription" msgstr "Message with description" # Translator comment # This one might come from developer +#, explicit-id msgid "withComments" msgstr "Support translator comments separately" +#, explicit-id msgid "veryLongString" msgstr "One morning, when Gregor Samsa woke from troubled dreams, he found himself transformed in his bed into a horrible vermin. He lay on his armour-like back, and if he lifted his head a little he could see his brown belly, slightly domed and divided by arches into stiff sections. The bedding was hardly able to cover it and seemed ready to slide off any moment. His many legs, pitifully thin compared with the size of the rest of him, waved about helplessly as he looked. \"What's happened to me?\" he thought. It wasn't a dream. His room, a proper human" +#, explicit-id #~ msgid "obsolete" #~ msgstr "Is marked as obsolete" -#, fuzzy,otherFlag +#, fuzzy,otherFlag,explicit-id msgid "withFlags" msgstr "Keeps any flags that are defined" + +#. js-lingui-id: pXy+hm +msgid "Message with default hash id" +msgstr "Translation for: Message with default-hash id" diff --git a/packages/cli/src/api/formats/fixtures/messages_plural-4-letter.po b/packages/cli/src/api/formats/fixtures/messages_plural-4-letter.po index c318bb849..265983825 100644 --- a/packages/cli/src/api/formats/fixtures/messages_plural-4-letter.po +++ b/packages/cli/src/api/formats/fixtures/messages_plural-4-letter.po @@ -8,6 +8,7 @@ msgstr "" "Language: en_CA\n" #. js-lingui:pluralize_on=someCount +#, explicit-id msgid "message_with_id" msgid_plural "message_with_id_plural" msgstr[0] "Singular case" @@ -21,6 +22,7 @@ msgstr[1] "Case number {anotherCount}" #. Comment made by the developers. #. js-lingui:pluralize_on=count +#, explicit-id msgid "message_with_id_but_without_translation" msgid_plural "message_with_id_but_without_translation_plural" msgstr[0] "" @@ -30,4 +32,4 @@ msgstr[1] "" msgid "Singular" msgid_plural "Plural" msgstr[0] "" -msgstr[1] "" \ No newline at end of file +msgstr[1] "" diff --git a/packages/cli/src/api/formats/fixtures/messages_plural.po b/packages/cli/src/api/formats/fixtures/messages_plural.po index b78f5d6a7..38a54d219 100644 --- a/packages/cli/src/api/formats/fixtures/messages_plural.po +++ b/packages/cli/src/api/formats/fixtures/messages_plural.po @@ -8,6 +8,7 @@ msgstr "" "Language: en\n" #. js-lingui:pluralize_on=someCount +#, explicit-id msgid "message_with_id" msgid_plural "message_with_id_plural" msgstr[0] "Singular case" @@ -21,6 +22,7 @@ msgstr[1] "Case number {anotherCount}" #. Comment made by the developers. #. js-lingui:pluralize_on=count +#, explicit-id msgid "message_with_id_but_without_translation" msgid_plural "message_with_id_but_without_translation_plural" msgstr[0] "" @@ -30,4 +32,4 @@ msgstr[1] "" msgid "Singular" msgid_plural "Plural" msgstr[0] "" -msgstr[1] "" \ No newline at end of file +msgstr[1] "" diff --git a/packages/cli/src/api/formats/po-gettext.test.ts b/packages/cli/src/api/formats/po-gettext.test.ts index 9f764e060..6580541c3 100644 --- a/packages/cli/src/api/formats/po-gettext.test.ts +++ b/packages/cli/src/api/formats/po-gettext.test.ts @@ -3,189 +3,16 @@ import fs from "fs" import mockFs from "mock-fs" import mockDate from "mockdate" import path from "path" -import PO from "pofile" import { CatalogType } from "../catalog" import format, { serialize } from "./po-gettext" describe("po-gettext format", () => { - const dateHeaders = { - "pot-creation-date": "2018-08-09", - "po-revision-date": "2018-08-09", - } - afterEach(() => { mockFs.restore() mockDate.reset() }) - it("should write catalog in pofile format", () => { - mockFs({ - locale: { - en: mockFs.directory(), - }, - }) - mockDate.set("2018-08-27T10:00Z") - - const filename = path.join("locale", "en", "messages.po") - const catalog: CatalogType = { - static: { - translation: "Static message", - }, - withOrigin: { - translation: "Message with origin", - origin: [["src/App.js", 4]], - }, - withMultipleOrigins: { - translation: "Message with multiple origin", - origin: [ - ["src/App.js", 4], - ["src/Component.js", 2], - ], - }, - withDescription: { - translation: "Message with description", - extractedComments: [ - "Description is comment from developers to translators", - ], - }, - withComments: { - comments: ["Translator comment", "This one might come from developer"], - translation: "Support translator comments separately", - }, - obsolete: { - translation: "Obsolete message", - obsolete: true, - }, - withFlags: { - flags: ["fuzzy", "otherFlag"], - translation: "Keeps any flags that are defined", - }, - veryLongString: { - translation: - "One morning, when Gregor Samsa woke from troubled dreams, he found himself" + - " transformed in his bed into a horrible vermin. He lay on his armour-like" + - " back, and if he lifted his head a little he could see his brown belly," + - " slightly domed and divided by arches into stiff sections. The bedding was" + - " hardly able to cover it and seemed ready to slide off any moment. His many" + - " legs, pitifully thin compared with the size of the rest of him, waved about" + - " helplessly as he looked. \"What's happened to me?\" he thought. It wasn't" + - " a dream. His room, a proper human", - }, - } - - format.write(filename, catalog, { - origins: true, - locale: "en", - ...dateHeaders, - }) - const pofile = fs.readFileSync(filename).toString() - mockFs.restore() - expect(pofile).toMatchSnapshot() - }) - - it("should read catalog in pofile format", () => { - const filename = path.join( - path.resolve(__dirname), - "fixtures", - "messages.po" - ) - const actual = format.read(filename) - expect(actual).toMatchSnapshot() - }) - - it("should correct badly used comments", () => { - const po = PO.parse(` - #. First description - #. Second comment - #. Third comment - msgid "withMultipleDescriptions" - msgstr "Extra comments are separated from the first description line" - - # Translator comment - #. Single description only - #. Second description? - msgid "withDescriptionAndComments" - msgstr "Second description joins translator comments" - `) - - mockFs({ - locale: { - en: { - "messages.po": po.toString(), - }, - }, - }) - - const filename = path.join("locale", "en", "messages.po") - const actual = format.read(filename) - mockFs.restore() - expect(actual).toMatchSnapshot() - }) - - it("should throw away additional msgstr if present", () => { - const po = PO.parse(` - msgid "withMultipleTranslations" - msgstr[0] "This is just fine" - msgstr[1] "Throw away that one" - `) - - mockFs({ - locale: { - en: { - "messages.po": po.toString(), - }, - }, - }) - - const filename = path.join("locale", "en", "messages.po") - mockConsole((console) => { - const file = fs.readFileSync(filename).toString() - const actual = format.parse(file) - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining("Multiple translations"), - "withMultipleTranslations" - ) - mockFs.restore() - expect(actual).toMatchSnapshot() - }) - }) - - it("should write the same catalog as it was read", () => { - const pofile = fs - .readFileSync( - path.join(path.resolve(__dirname), "fixtures", "messages.po") - ) - .toString() - - const filename = path.join( - path.resolve(__dirname), - "fixtures", - "messages.po" - ) - const catalog = format.read(filename) - - mockFs({ - locale: { - en: { - "messages.po": pofile, - }, - }, - }) - - const mock_filename = path.join("locale", "en", "messages.po") - format.write(mock_filename, catalog, { origins: true, locale: "en" }) - const actual = fs.readFileSync(mock_filename).toString() - - mockFs.restore() - - // on windows mockFs adds ··· to multiline string, so this strictly equal comparison can't be done - // we test that the content if the same inlined... - expect(actual.replace(/(\r\n|\n|\r)/gm, "")).toEqual( - pofile.replace(/(\r\n|\n|\r)/gm, "") - ) - }) - it("should convert ICU plural messages to gettext plurals", function () { mockFs({ locale: { @@ -195,6 +22,7 @@ describe("po-gettext format", () => { mockDate.set("2018-08-27T10:00Z") const filename = path.join("locale", "en", "messages.po") + const catalog: CatalogType = { message_with_id_and_octothorpe: { message: "{count, plural, one {Singular} other {Number is #}}", @@ -210,11 +38,12 @@ describe("po-gettext format", () => { "This is a comment by the developers about how the content must be localized.", ], }, - "{anotherCount, plural, one {Singular case} other {Case number {anotherCount}}}": - { - translation: - "{anotherCount, plural, one {Singular case} other {Case number {anotherCount}}}", - }, + WGI12K: { + message: + "{anotherCount, plural, one {Singular case} other {Case number {anotherCount}}}", + translation: + "{anotherCount, plural, one {Singular case} other {Case number {anotherCount}}}", + }, // Entry with developer-defined ID that generates empty msgstr[] lines message_with_id_but_without_translation: { message: @@ -222,10 +51,11 @@ describe("po-gettext format", () => { translation: "", }, // Entry with automatic ID that generates empty msgstr[] lines - "{count, plural, one {Singular automatic id no translation} other {Plural {count} automatic id no translation}}": - { - translation: "", - }, + xZCXAV: { + message: + "{count, plural, one {Singular automatic id no translation} other {Plural {count} automatic id no translation}}", + translation: "", + }, } format.write(filename, catalog, { @@ -296,14 +126,15 @@ msgid_plural "# days" msgstr[0] "# den" msgstr[1] "# dny" msgstr[2] "# dní" -` + ` const parsed = format.parse(po) expect(parsed).toEqual({ - "{#, plural, one {day} other {days}}": { + Y8Xw2Y: { // Note that the last case must be `other` (the 4th CLDR case name) instead of `many` (the 3rd CLDR case name). translation: "{#, plural, one {# den} few {# dny} other {# dní}}", + message: "{#, plural, one {day} other {days}}", extractedComments: [], context: null, comments: [], @@ -370,7 +201,7 @@ msgstr[2] "# dní" }) }) - describe("convertPluralsToIco handle correctly locales with 4-letter", () => { + it("convertPluralsToIco handle correctly locales with 4-letter", () => { const pofile = fs .readFileSync( path.join( diff --git a/packages/cli/src/api/formats/po-gettext.ts b/packages/cli/src/api/formats/po-gettext.ts index 85707a009..34fb7a509 100644 --- a/packages/cli/src/api/formats/po-gettext.ts +++ b/packages/cli/src/api/formats/po-gettext.ts @@ -55,11 +55,16 @@ function serializePlurals( item: POItem, message: MessageType, id: string, + isGeneratedId: boolean, options: CatalogFormatOptionsInternal ): POItem { // Depending on whether custom ids are used by the developer, the (potential plural) "original", untranslated ICU // message can be found in `message.message` or in the item's `key` itself. - const icuMessage = message.message || id + const icuMessage = message.message + + if (!icuMessage) { + return + } const _simplifiedMessage = icuMessage.replace(LINE_ENDINGS, " ") @@ -86,7 +91,7 @@ function serializePlurals( pluralize_on: messageAst.arg, }) - if (message.message == null) { + if (isGeneratedId) { // For messages without developer-set ID, use first case as `msgid` and the last case as `msgid_plural`. // This does not necessarily make sense for development languages with more than two numbers, but gettext // only supports exactly two plural forms. @@ -96,7 +101,7 @@ function serializePlurals( ) // Since the original msgid is overwritten, store ICU message to allow restoring that ID later. - ctx.set("icu", id) + ctx.set("icu", icuMessage) } else { // For messages with developer-set ID, append `_plural` to the key to generate `msgid_plural`. item.msgid_plural = id + "_plural" @@ -146,8 +151,8 @@ export const serialize = ( catalog: CatalogType, options: CatalogFormatOptionsInternal ) => { - return serializePo(catalog, options, (item, message, id) => - serializePlurals(item, message, id, options) + return serializePo(catalog, options, (item, message, id, isGeneratedId) => + serializePlurals(item, message, id, isGeneratedId, options) ) } diff --git a/packages/cli/src/api/formats/po.test.ts b/packages/cli/src/api/formats/po.test.ts index 5f7958d2f..0068ec6c4 100644 --- a/packages/cli/src/api/formats/po.test.ts +++ b/packages/cli/src/api/formats/po.test.ts @@ -4,10 +4,10 @@ import mockFs from "mock-fs" import mockDate from "mockdate" import PO from "pofile" import { mockConsole } from "@lingui/jest-mocks" -import { format as formatDate } from "date-fns" import format from "./po" import { CatalogType } from "../catalog" +import { normalizeLineEndings } from "../../tests" describe("pofile format", () => { afterEach(() => { @@ -32,6 +32,15 @@ describe("pofile format", () => { translation: "Message with origin", origin: [["src/App.js", 4]], }, + withContext: { + translation: "Message with context", + context: "my context", + }, + Dgzql1: { + message: "with generated id", + translation: "", + context: "my context", + }, withMultipleOrigins: { translation: "Message with multiple origin", origin: [ @@ -97,17 +106,43 @@ describe("pofile format", () => { expect(actual).toMatchSnapshot() }) + it("should serialize and deserialize messages with generated id", () => { + mockFs({ + locale: { + en: mockFs.directory(), + }, + }) + + const catalog: CatalogType = { + // with generated id + Dgzql1: { + message: "with generated id", + translation: "", + context: "my context", + }, + } + + const filename = path.join("locale", "en", "messages.po") + format.write(filename, catalog, { origins: true, locale: "en" }) + + const actual = format.read(filename) + mockFs.restore() + expect(actual).toMatchObject(catalog) + }) + it("should correct badly used comments", () => { const po = PO.parse(` #. First description #. Second comment #. Third comment + #, explicit-id msgid "withMultipleDescriptions" msgstr "Extra comments are separated from the first description line" # Translator comment #. Single description only #. Second description? + #, explicit-id msgid "withDescriptionAndComments" msgstr "Second description joins translator comments" `) @@ -128,6 +163,7 @@ describe("pofile format", () => { it("should throw away additional msgstr if present", () => { const po = PO.parse(` + #, explicit-id msgid "withMultipleTranslation" msgstr[0] "This is just fine" msgstr[1] "Throw away that one" @@ -174,11 +210,8 @@ describe("pofile format", () => { format.write(filename, catalog, { origins: true, locale: "en" }) const actual = fs.readFileSync(filename).toString() mockFs.restore() - // on windows mockFs adds ··· to multiline string, so this strictly equal comparison can't be done - // we test that the content if the same inlined... - expect(actual.replace(/(\r\n|\n|\r)/gm, "")).toEqual( - pofile.replace(/(\r\n|\n|\r)/gm, "") - ) + + expect(normalizeLineEndings(actual)).toEqual(normalizeLineEndings(pofile)) }) it("should not include origins if origins option is false", () => { @@ -213,6 +246,8 @@ describe("pofile format", () => { }) it("should not include lineNumbers if lineNumbers option is false", () => { + mockDate.set(new Date(2018, 7, 27, 10, 0, 0).toUTCString()) + mockFs({ locale: { en: mockFs.directory(), @@ -246,22 +281,25 @@ describe("pofile format", () => { expect(pofile).toMatchInlineSnapshot(` msgid "" msgstr "" - "POT-Creation-Date: ${formatDate(new Date(), "yyyy-MM-dd HH:mmxxxx")}\\n" + "POT-Creation-Date: 2018-08-27 10:00+0000\\n" "MIME-Version: 1.0\\n" "Content-Type: text/plain; charset=utf-8\\n" "Content-Transfer-Encoding: 8bit\\n" "X-Generator: @lingui/cli\\n" "Language: en\\n" + #, explicit-id msgid "static" msgstr "Static message" #: src/App.js + #, explicit-id msgid "withOrigin" msgstr "Message with origin" #: src/App.js #: src/Component.js + #, explicit-id msgid "withMultipleOrigins" msgstr "Message with multiple origin" @@ -269,6 +307,8 @@ describe("pofile format", () => { }) it("should not include lineNumbers if lineNumbers option is false and already excluded", () => { + mockDate.set(new Date(2018, 7, 27, 10, 0, 0).toUTCString()) + mockFs({ locale: { en: mockFs.directory(), @@ -299,22 +339,25 @@ describe("pofile format", () => { expect(pofile).toMatchInlineSnapshot(` msgid "" msgstr "" - "POT-Creation-Date: ${formatDate(new Date(), "yyyy-MM-dd HH:mmxxxx")}\\n" + "POT-Creation-Date: 2018-08-27 10:00+0000\\n" "MIME-Version: 1.0\\n" "Content-Type: text/plain; charset=utf-8\\n" "Content-Transfer-Encoding: 8bit\\n" "X-Generator: @lingui/cli\\n" "Language: en\\n" + #, explicit-id msgid "static" msgstr "Static message" #: src/App.js + #, explicit-id msgid "withOrigin" msgstr "Message with origin" #: src/App.js #: src/Component.js + #, explicit-id msgid "withMultipleOrigins" msgstr "Message with multiple origin" diff --git a/packages/cli/src/api/formats/po.ts b/packages/cli/src/api/formats/po.ts index beb3f7433..878dbac2d 100644 --- a/packages/cli/src/api/formats/po.ts +++ b/packages/cli/src/api/formats/po.ts @@ -5,9 +5,13 @@ import PO from "pofile" import { joinOrigin, splitOrigin, writeFileIfChanged } from "../utils" import { CatalogType, MessageType } from "../catalog" import { CatalogFormatOptionsInternal, CatalogFormatter } from "." +import { generateMessageId } from "../generateMessageId" type POItem = InstanceType +function isGeneratedId(id: string, message: MessageType): boolean { + return id === generateMessageId(message.message, message.context) +} function getCreateHeaders(language = "no"): PO["headers"] { return { "POT-Creation-Date": formatDate(new Date(), "yyyy-MM-dd HH:mmxxxx"), @@ -19,26 +23,54 @@ function getCreateHeaders(language = "no"): PO["headers"] { } } +const EXPLICIT_ID_FLAG = "explicit-id" + export const serialize = ( catalog: CatalogType, options: CatalogFormatOptionsInternal, - postProcessItem?: (item: POItem, message: MessageType, id: string) => POItem + postProcessItem?: ( + item: POItem, + message: MessageType, + id: string, + isGeneratedId: boolean + ) => POItem ) => { return Object.keys(catalog).map((id) => { const message = catalog[id] const item = new PO.Item() - item.msgid = id - item.msgstr = [message.translation] - item.comments = message.comments || [] - // 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 ?? [])] + // The extractedComments array may be modified in this method, + // so create a new array with the message's elements. + item.extractedComments = [...(message.extractedComments || [])] + + item.flags = (message.flags || []).reduce>( + (acc, flag) => { + acc[flag] = true + return acc + }, + {} + ) + + const _isGeneratedId = isGeneratedId(id, message) + + if (_isGeneratedId) { + item.msgid = message.message + if (!item.extractedComments.find((c) => c.includes("js-lingui-id"))) { + item.extractedComments.push(`js-lingui-id: ${id}`) + } + } else { + item.flags[EXPLICIT_ID_FLAG] = true + item.msgid = id + } if (message.context) { item.msgctxt = message.context } + + item.msgstr = [message.translation] + item.comments = message.comments || [] + if (options.origins !== false) { if (message.origin && options.lineNumbers === false) { item.references = message.origin.map(([path]) => path) @@ -47,14 +79,10 @@ export const serialize = ( } } item.obsolete = message.obsolete - item.flags = message.flags - ? message.flags.reduce>((acc, flag) => { - acc[flag] = true - return acc - }, {}) - : {} - - return postProcessItem ? postProcessItem(item, message, id) : item + + return postProcessItem + ? postProcessItem(item, message, id, _isGeneratedId) + : item }) } @@ -65,7 +93,7 @@ export function deserialize( return items.reduce((catalog, item) => { onItem(item) - catalog[item.msgid] = { + const message: MessageType = { translation: item.msgstr[0], extractedComments: item.extractedComments || [], comments: item.comments || [], @@ -75,6 +103,15 @@ export function deserialize( flags: Object.keys(item.flags).map((flag) => flag.trim()), } + let id = item.msgid + + // if generated id, recreate it + if (!item.flags[EXPLICIT_ID_FLAG]) { + id = generateMessageId(item.msgid, item.msgctxt) + message.message = item.msgid + } + + catalog[id] = message return catalog }, {}) } diff --git a/packages/cli/src/api/generateMessageId.test.ts b/packages/cli/src/api/generateMessageId.test.ts new file mode 100644 index 000000000..fd0efd2d3 --- /dev/null +++ b/packages/cli/src/api/generateMessageId.test.ts @@ -0,0 +1,32 @@ +import { generateMessageId } from "./generateMessageId" + +describe("generateMessageId", () => { + it("Should generate an id for a message", () => { + expect(generateMessageId("my message")).toMatchInlineSnapshot(`vQhkQx`) + }) + + it("Should generate different id when context is provided", () => { + const withContext = generateMessageId("my message", "custom context") + expect(withContext).toMatchInlineSnapshot(`gGUeZH`) + + expect(withContext != generateMessageId("my message")).toBeTruthy() + }) + + it("Message + context should not clash with message with suffix or prefix", () => { + const context = "custom context" + const withContext = generateMessageId("my message", context) + const withSuffix = generateMessageId("my message" + context) + const withPrefix = generateMessageId(context + "my message") + + expect(withContext != withSuffix).toBeTruthy() + expect(withContext != withPrefix).toBeTruthy() + }) + + it("All kind of falsy context should give the same result", () => { + const expected = `vQhkQx` + expect(generateMessageId("my message")).toBe(expected) + expect(generateMessageId("my message", "")).toBe(expected) + expect(generateMessageId("my message", undefined)).toBe(expected) + expect(generateMessageId("my message", null)).toBe(expected) + }) +}) diff --git a/packages/cli/src/api/generateMessageId.ts b/packages/cli/src/api/generateMessageId.ts new file mode 100644 index 000000000..8f2c82446 --- /dev/null +++ b/packages/cli/src/api/generateMessageId.ts @@ -0,0 +1,11 @@ +import crypto from "crypto" + +const UNIT_SEPARATOR = "\u001F" + +export function generateMessageId(msg: string, context = "") { + return crypto + .createHash("sha256") + .update(msg + UNIT_SEPARATOR + (context || "")) + .digest("base64") + .slice(0, 6) +} diff --git a/packages/cli/src/api/help.ts b/packages/cli/src/api/help.ts index 65935bc3e..fcccb98ca 100644 --- a/packages/cli/src/api/help.ts +++ b/packages/cli/src/api/help.ts @@ -34,10 +34,10 @@ export function helpRun(command: string) { } } + const isYarn = + process.env.npm_config_user_agent && + process.env.npm_config_user_agent.includes("yarn") + const runCommand = isYarn ? "yarn" : "npm run" + return `${runCommand} ${command}` } - -const isYarn = - process.env.npm_config_user_agent && - process.env.npm_config_user_agent.includes("yarn") -const runCommand = isYarn ? "yarn" : "npm run" diff --git a/packages/cli/src/api/index.ts b/packages/cli/src/api/index.ts index 70bcd1ef6..1c9264bc0 100644 --- a/packages/cli/src/api/index.ts +++ b/packages/cli/src/api/index.ts @@ -2,4 +2,5 @@ import getFormat from "./formats" import { getCatalogs, getCatalogForFile } from "./catalog" export { createCompiledCatalog } from "./compile" +export { generateMessageId } from "./generateMessageId" export { getFormat, getCatalogs, getCatalogForFile } diff --git a/packages/cli/src/api/utils.ts b/packages/cli/src/api/utils.ts index 6605387d3..c5892bec4 100644 --- a/packages/cli/src/api/utils.ts +++ b/packages/cli/src/api/utils.ts @@ -40,3 +40,10 @@ export function makeInstall() { ? `yarn add ${dev ? "--dev " : ""}${packageName}` : `npm install ${dev ? "--save-dev" : "--save"} ${packageName}` } + +/** + * Normalize Windows backslashes in path so they look always as posix + */ +export function normalizeSlashes(path: string) { + return path.replace("\\", "/") +} diff --git a/packages/cli/src/lingui-extract-template.ts b/packages/cli/src/lingui-extract-template.ts index 21bebe3b9..36fa104f0 100644 --- a/packages/cli/src/lingui-extract-template.ts +++ b/packages/cli/src/lingui-extract-template.ts @@ -4,6 +4,8 @@ import { program } from "commander" import { getConfig, LinguiConfigNormalized } from "@lingui/conf" import { getCatalogs } from "./api/catalog" +import nodepath from "path" +import { normalizeSlashes } from "./api/utils" export type CliExtractTemplateOptions = { verbose: boolean @@ -18,16 +20,23 @@ export default async function command( const catalogs = getCatalogs(config) const catalogStats: { [path: string]: Number } = {} + let commandSuccess = true + await Promise.all( catalogs.map(async (catalog) => { - await catalog.makeTemplate({ + const result = await catalog.makeTemplate({ ...(options as CliExtractTemplateOptions), orderBy: config.orderBy, }) - const catalogTemplate = catalog.readTemplate() - if (catalogTemplate !== null && catalogTemplate !== undefined) { - catalogStats[catalog.templateFile] = Object.keys(catalogTemplate).length + + if (result) { + catalogStats[ + normalizeSlashes( + nodepath.relative(config.rootDir, catalog.templateFile) + ) + ] = Object.keys(result).length } + commandSuccess &&= Boolean(result) }) ) @@ -39,7 +48,8 @@ export default async function command( ) console.log() }) - return true + + return commandSuccess } type CliOptions = { diff --git a/packages/cli/src/lingui-extract.ts b/packages/cli/src/lingui-extract.ts index 26b8d27cf..d8f719ec2 100644 --- a/packages/cli/src/lingui-extract.ts +++ b/packages/cli/src/lingui-extract.ts @@ -1,6 +1,7 @@ import chalk from "chalk" import chokidar from "chokidar" import { program } from "commander" +import nodepath from "path" import { getConfig, LinguiConfigNormalized } from "@lingui/conf" @@ -8,6 +9,7 @@ import { AllCatalogsType, getCatalogs } from "./api/catalog" import { printStats } from "./api/stats" import { helpRun } from "./api/help" import ora from "ora" +import { normalizeSlashes } from "./api/utils" export type CliExtractOptions = { verbose: boolean @@ -32,13 +34,16 @@ export default async function command( const spinner = ora().start() for (let catalog of catalogs) { - const catalogSuccess = await catalog.make({ + const result = await catalog.make({ ...(options as CliExtractOptions), orderBy: config.orderBy, }) - catalogStats[catalog.path] = catalog.readAll() - commandSuccess &&= catalogSuccess + catalogStats[ + normalizeSlashes(nodepath.relative(config.rootDir, catalog.path)) + ] = result || {} + + commandSuccess &&= Boolean(result) } if (commandSuccess) { diff --git a/packages/cli/src/test/__snapshots__/compile.test.ts.snap b/packages/cli/src/test/__snapshots__/compile.test.ts.snap index 7c22790bf..b229c2639 100644 --- a/packages/cli/src/test/__snapshots__/compile.test.ts.snap +++ b/packages/cli/src/test/__snapshots__/compile.test.ts.snap @@ -28,7 +28,7 @@ exports[`CLI Command: Compile allowEmpty = false Should show missing messages ve en ⇒ /test/en.js Error: Failed to compile catalog for locale pl! Missing translations: -Hello World -Test String +mY42CM: (Hello World) +2ZeN02: (Test String) `; diff --git a/packages/cli/src/test/compile.test.ts b/packages/cli/src/test/compile.test.ts index 5f705eff2..a0fee9062 100644 --- a/packages/cli/src/test/compile.test.ts +++ b/packages/cli/src/test/compile.test.ts @@ -1,32 +1,8 @@ import { command } from "../lingui-compile" import { makeConfig } from "@lingui/conf" -import path from "path" import { getConsoleMockCalls, mockConsole } from "@lingui/jest-mocks" import mockFs from "mock-fs" -import fs from "fs" - -function readFsToJson( - directory: string, - filter?: (filename: string) => boolean -) { - type Listing = { [filename: string]: string | Listing } - const out: Listing = {} - - fs.readdirSync(directory).map((filename) => { - const filepath = path.join(directory, filename) - - if (fs.lstatSync(filepath).isDirectory()) { - out[filename] = readFsToJson(filepath) - return out - } - - if (!filter || filter(filename)) { - out[filename] = fs.readFileSync(filepath).toString() - } - }) - - return out -} +import { readFsToJson } from "../tests" describe("CLI Command: Compile", () => { describe("Merge Catalogs", () => { @@ -136,36 +112,40 @@ msgstr "" } ) - it("Should show error and stop compilation of catalog if message doesnt have a translation (with template)", () => { - mockFs({ - "/test": { - "messages.pot": ` + it( + "Should show error and stop compilation of catalog " + + "if message doesnt have a translation (with template)", + () => { + mockFs({ + "/test": { + "messages.pot": ` msgid "Hello World" msgstr "" `, - "pl.po": ``, - }, - }) - - mockConsole((console) => { - const result = command(config, { - allowEmpty: false, + "pl.po": ``, + }, }) - const actualFiles = readFsToJson("/test") + mockConsole((console) => { + const result = command(config, { + allowEmpty: false, + }) - mockFs.restore() + const actualFiles = readFsToJson("/test") - expect({ - pl: actualFiles["pl.js"], - en: actualFiles["en.js"], - }).toMatchSnapshot() + mockFs.restore() - const log = getConsoleMockCalls(console.error) - expect(log).toMatchSnapshot() - expect(result).toBeFalsy() - }) - }) + expect({ + pl: actualFiles["pl.js"], + en: actualFiles["en.js"], + }).toMatchSnapshot() + + const log = getConsoleMockCalls(console.error) + expect(log).toMatchSnapshot() + expect(result).toBeFalsy() + }) + } + ) it("Should show missing messages verbosely when verbose = true", () => { mockFs({ diff --git a/packages/cli/src/tests.ts b/packages/cli/src/tests.ts index 1e0fbefc4..363b83360 100644 --- a/packages/cli/src/tests.ts +++ b/packages/cli/src/tests.ts @@ -1,6 +1,7 @@ import os from "os" -import fs from "fs-extra" +import fsExtra from "fs-extra" import path from "path" +import fs from "fs" import { Catalog, @@ -13,11 +14,11 @@ import { import { LinguiConfig, makeConfig } from "@lingui/conf" export function copyFixture(fixtureDir: string) { - const tmpDir = fs.mkdtempSync( + const tmpDir = fsExtra.mkdtempSync( path.join(os.tmpdir(), `lingui-test-${process.pid}`) ) - if (fs.existsSync(fixtureDir)) { - fs.copySync(fixtureDir, tmpDir) + if (fsExtra.existsSync(fixtureDir)) { + fsExtra.copySync(fixtureDir, tmpDir) } return tmpDir } @@ -40,6 +41,11 @@ export const defaultMergeOptions: MergeOptions = { overwrite: false, } +// on windows line endings are different, +// so direct comparison to snapshot would file if not normalized +export const normalizeLineEndings = (str: string) => + str.replace(/\r?\n/g, "\r\n") + export const makeCatalog = (config: Partial = {}) => { return new Catalog( { @@ -66,3 +72,45 @@ export function makeNextMessage(message = {}): ExtractedMessageType { ...message, } } + +type Listing = { [filename: string]: string | Listing } + +export function listingToHumanReadable(listing: Listing): string { + const output: string[] = [] + Object.entries(listing).forEach(([filename, value]) => { + if (typeof value === "string") { + output.push("#######################") + output.push(`Filename: ${filename}`) + output.push("#######################") + output.push("") + output.push(normalizeLineEndings(value)) + output.push("") + } else { + output.push(...listingToHumanReadable(value)) + } + }) + + return output.join("\n") +} + +export function readFsToJson( + directory: string, + filter?: (filename: string) => boolean +): Listing { + const out: Listing = {} + + fs.readdirSync(directory).map((filename) => { + const filepath = path.join(directory, filename) + + if (fs.lstatSync(filepath).isDirectory()) { + out[filename] = readFsToJson(filepath) + return out + } + + if (!filter || filter(filename)) { + out[filename] = fs.readFileSync(filepath).toString() + } + }) + + return out +} diff --git a/packages/cli/test/.gitignore b/packages/cli/test/.gitignore new file mode 100644 index 000000000..b36969646 --- /dev/null +++ b/packages/cli/test/.gitignore @@ -0,0 +1 @@ +actual/ diff --git a/packages/cli/test/extract-po-format/expected/en.po b/packages/cli/test/extract-po-format/expected/en.po new file mode 100644 index 000000000..b2d3407ae --- /dev/null +++ b/packages/cli/test/extract-po-format/expected/en.po @@ -0,0 +1,46 @@ +msgid "" +msgstr "" +"POT-Creation-Date: 2023-03-15 10:00+0000\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: @lingui/cli\n" +"Language: en\n" + +#. js-lingui-id: 1nGWAC +#: fixtures/file-a.ts:4 +msgid "Hello world" +msgstr "Hello world" + +#. js-lingui-id: 2Ps/YQ +#: fixtures/file-a.ts:6 +msgctxt "custom context" +msgid "Hello world" +msgstr "Hello world" + +#. js-lingui-id: 6qYmGe +#: fixtures/file-b.tsx:11 +msgctxt "my context" +msgid "Hello this is JSX Translation" +msgstr "Hello this is JSX Translation" + +#. js-lingui-id: BcXPt3 +#: fixtures/file-a.ts:16 +msgid "Message in descriptor" +msgstr "Message in descriptor" + +#: fixtures/file-a.ts:11 +#, explicit-id +msgid "custom.id" +msgstr "This message has custom id" + +#. this is a comment +#. js-lingui-id: f9Atdk +#: fixtures/file-b.tsx:6 +msgid "Hello this is JSX Translation" +msgstr "Hello this is JSX Translation" + +#: fixtures/file-b.tsx:15 +#, explicit-id +msgid "jsx.custom.id" +msgstr "This JSX element has custom id" diff --git a/packages/cli/test/extract-po-format/expected/pl.po b/packages/cli/test/extract-po-format/expected/pl.po new file mode 100644 index 000000000..f518b7500 --- /dev/null +++ b/packages/cli/test/extract-po-format/expected/pl.po @@ -0,0 +1,46 @@ +msgid "" +msgstr "" +"POT-Creation-Date: 2023-03-15 10:00+0000\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: @lingui/cli\n" +"Language: pl\n" + +#. js-lingui-id: 1nGWAC +#: fixtures/file-a.ts:4 +msgid "Hello world" +msgstr "" + +#. js-lingui-id: 2Ps/YQ +#: fixtures/file-a.ts:6 +msgctxt "custom context" +msgid "Hello world" +msgstr "" + +#. js-lingui-id: 6qYmGe +#: fixtures/file-b.tsx:11 +msgctxt "my context" +msgid "Hello this is JSX Translation" +msgstr "" + +#. js-lingui-id: BcXPt3 +#: fixtures/file-a.ts:16 +msgid "Message in descriptor" +msgstr "" + +#: fixtures/file-a.ts:11 +#, explicit-id +msgid "custom.id" +msgstr "" + +#. this is a comment +#. js-lingui-id: f9Atdk +#: fixtures/file-b.tsx:6 +msgid "Hello this is JSX Translation" +msgstr "" + +#: fixtures/file-b.tsx:15 +#, explicit-id +msgid "jsx.custom.id" +msgstr "" diff --git a/packages/cli/test/extract-po-format/fixtures/file-a.ts b/packages/cli/test/extract-po-format/fixtures/file-a.ts new file mode 100644 index 000000000..995dc7284 --- /dev/null +++ b/packages/cli/test/extract-po-format/fixtures/file-a.ts @@ -0,0 +1,20 @@ +import { defineMessage, t } from "@lingui/macro" +import { i18n } from "@lingui/core" + +const msg = t`Hello world` + +const msg2 = t({ + message: "Hello world", + context: "custom context", +}) + +const msg3 = t({ + message: "This message has custom id", + id: "custom.id", +}) + +const msgDescriptor = defineMessage({ + message: "Message in descriptor", +}) + +i18n._(msgDescriptor) diff --git a/packages/cli/test/extract-po-format/fixtures/file-b.tsx b/packages/cli/test/extract-po-format/fixtures/file-b.tsx new file mode 100644 index 000000000..5c771448f --- /dev/null +++ b/packages/cli/test/extract-po-format/fixtures/file-b.tsx @@ -0,0 +1,16 @@ +import { Trans } from "@lingui/macro" +import React from "react" + +export function MyComponent() { + return ( + Hello this is JSX Translation + ) +} + +export function MyComponent2() { + return Hello this is JSX Translation +} + +export function MyComponent3() { + return This JSX element has custom id +} diff --git a/packages/cli/test/extract-template-po-format/expected/messages.pot b/packages/cli/test/extract-template-po-format/expected/messages.pot new file mode 100644 index 000000000..55f214c5a --- /dev/null +++ b/packages/cli/test/extract-template-po-format/expected/messages.pot @@ -0,0 +1,45 @@ +msgid "" +msgstr "" +"POT-Creation-Date: 2023-03-15 10:00+0000\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: @lingui/cli\n" + +#. js-lingui-id: 1nGWAC +#: fixtures/file-a.ts:4 +msgid "Hello world" +msgstr "" + +#. js-lingui-id: 2Ps/YQ +#: fixtures/file-a.ts:6 +msgctxt "custom context" +msgid "Hello world" +msgstr "" + +#. js-lingui-id: 6qYmGe +#: fixtures/file-b.tsx:11 +msgctxt "my context" +msgid "Hello this is JSX Translation" +msgstr "" + +#. js-lingui-id: BcXPt3 +#: fixtures/file-a.ts:16 +msgid "Message in descriptor" +msgstr "" + +#: fixtures/file-a.ts:11 +#, explicit-id +msgid "custom.id" +msgstr "" + +#. this is a comment +#. js-lingui-id: f9Atdk +#: fixtures/file-b.tsx:6 +msgid "Hello this is JSX Translation" +msgstr "" + +#: fixtures/file-b.tsx:15 +#, explicit-id +msgid "jsx.custom.id" +msgstr "" diff --git a/packages/cli/test/extract-template-po-format/fixtures/file-a.ts b/packages/cli/test/extract-template-po-format/fixtures/file-a.ts new file mode 100644 index 000000000..995dc7284 --- /dev/null +++ b/packages/cli/test/extract-template-po-format/fixtures/file-a.ts @@ -0,0 +1,20 @@ +import { defineMessage, t } from "@lingui/macro" +import { i18n } from "@lingui/core" + +const msg = t`Hello world` + +const msg2 = t({ + message: "Hello world", + context: "custom context", +}) + +const msg3 = t({ + message: "This message has custom id", + id: "custom.id", +}) + +const msgDescriptor = defineMessage({ + message: "Message in descriptor", +}) + +i18n._(msgDescriptor) diff --git a/packages/cli/test/extract-template-po-format/fixtures/file-b.tsx b/packages/cli/test/extract-template-po-format/fixtures/file-b.tsx new file mode 100644 index 000000000..5c771448f --- /dev/null +++ b/packages/cli/test/extract-template-po-format/fixtures/file-b.tsx @@ -0,0 +1,16 @@ +import { Trans } from "@lingui/macro" +import React from "react" + +export function MyComponent() { + return ( + Hello this is JSX Translation + ) +} + +export function MyComponent2() { + return Hello this is JSX Translation +} + +export function MyComponent3() { + return This JSX element has custom id +} diff --git a/packages/cli/test/index.test.ts b/packages/cli/test/index.test.ts new file mode 100644 index 000000000..b48199927 --- /dev/null +++ b/packages/cli/test/index.test.ts @@ -0,0 +1,118 @@ +import extractTemplateCommand from "../src/lingui-extract-template" +import extractCommand from "../src/lingui-extract" +import fs from "fs/promises" +import nodepath from "path" +import { makeConfig } from "@lingui/conf" +import { listingToHumanReadable, readFsToJson } from "../src/tests" +import mockDate from "mockdate" +import { getConsoleMockCalls, mockConsole } from "@lingui/jest-mocks" + +export function compareFolders(pathA: string, pathB: string) { + const listingA = listingToHumanReadable(readFsToJson(pathA)) + const listingB = listingToHumanReadable(readFsToJson(pathB)) + + expect(listingA).toBe(listingB) +} + +async function prepare(caseFolderName: string) { + const rootDir = nodepath.join(__dirname, caseFolderName) + + const actualPath = nodepath.join(rootDir, "actual") + const expectedPath = nodepath.join(rootDir, "expected") + + await fs.rm(actualPath, { + recursive: true, + force: true, + }) + + return { rootDir, actualPath, expectedPath } +} + +describe("E2E Extractor Test", () => { + beforeEach(async () => { + mockDate.set(new Date(2023, 2, 15, 10, 0, 0).toUTCString()) + }) + + afterEach(() => { + mockDate.reset() + }) + + it("Should collect messages from files and write catalog in PO format", async () => { + const { rootDir, actualPath, expectedPath } = await prepare( + "extract-po-format" + ) + + await mockConsole(async (console) => { + const result = await extractCommand( + makeConfig({ + rootDir: rootDir, + locales: ["en", "pl"], + sourceLocale: "en", + format: "po", + catalogs: [ + { + path: "/actual/{locale}", + include: ["/fixtures"], + }, + ], + }), + {} + ) + + expect(result).toBeTruthy() + expect(getConsoleMockCalls(console.error)).toBeFalsy() + expect(getConsoleMockCalls(console.log)).toMatchInlineSnapshot(` + Catalog statistics for actual/{locale}: + ┌─────────────┬─────────────┬─────────┐ + │ Language │ Total count │ Missing │ + ├─────────────┼─────────────┼─────────┤ + │ en (source) │ 7 │ - │ + │ pl │ 7 │ 7 │ + └─────────────┴─────────────┴─────────┘ + + (use "yarn extract" to update catalogs with new messages) + (use "yarn compile" to compile catalogs for production) + `) + }) + + compareFolders(actualPath, expectedPath) + }) + + it("extractTemplate should extract into .pot template", async () => { + const { rootDir, actualPath, expectedPath } = await prepare( + "extract-template-po-format" + ) + + await fs.rm(actualPath, { + recursive: true, + force: true, + }) + + await mockConsole(async (console) => { + const result = await extractTemplateCommand( + makeConfig({ + rootDir: rootDir, + locales: ["en", "pl"], + sourceLocale: "en", + format: "po", + catalogs: [ + { + path: "/actual/{locale}", + include: ["/fixtures"], + }, + ], + }), + {} + ) + + expect(result).toBeTruthy() + expect(getConsoleMockCalls(console.error)).toBeFalsy() + expect(getConsoleMockCalls(console.log)).toMatchInlineSnapshot(` + Catalog statistics for actual/messages.pot: 7 messages + + `) + }) + + compareFolders(actualPath, expectedPath) + }) +}) diff --git a/packages/core/src/i18n.test.ts b/packages/core/src/i18n.test.ts index 1ee6f09f8..37404729f 100644 --- a/packages/core/src/i18n.test.ts +++ b/packages/core/src/i18n.test.ts @@ -250,7 +250,7 @@ describe("I18n", function () { missing, }) expect(i18n._("missing")).toEqual("gnissim") - expect(missing).toHaveBeenCalledWith("en", "missing", undefined) + expect(missing).toHaveBeenCalledWith("en", "missing") }) }) }) diff --git a/packages/core/src/i18n.ts b/packages/core/src/i18n.ts index 36debc4d1..482fb7c82 100644 --- a/packages/core/src/i18n.ts +++ b/packages/core/src/i18n.ts @@ -7,7 +7,6 @@ import type { PluralCategory } from "make-plural" export type MessageOptions = { message?: string - context?: string formats?: Formats } @@ -43,19 +42,15 @@ export type MessageDescriptor = { id?: string comment?: string message?: string - context?: string values?: Record } export type MissingMessageEvent = { locale: Locale id: string - context?: string } -type MissingHandler = - | string - | ((locale: string, id: string, context: string) => string) +type MissingHandler = string | ((locale: string, id: string) => string) type setupI18nProps = { locale?: Locale @@ -183,37 +178,27 @@ export class I18n extends EventEmitter { _( id: MessageDescriptor | string, values: Values | undefined = {}, - { message, formats, context }: MessageOptions | undefined = {} + { message, formats }: MessageOptions | undefined = {} ) { if (!isString(id)) { values = id.values || values message = id.message - context = id.context id = id.id } - const messageMissing = !context && !this.messages[id] - const contextualMessageMissing = context && !this.messages[context][id] - const messageUnreachable = contextualMessageMissing || messageMissing + const messageMissing = !this.messages[id] // replace missing messages with custom message for debugging const missing = this._missing - if (missing && messageUnreachable) { - return isFunction(missing) ? missing(this._locale, id, context) : missing + if (missing && messageMissing) { + return isFunction(missing) ? missing(this._locale, id) : missing } - if (messageUnreachable) { - this.emit("missing", { id, context, locale: this._locale }) + if (messageMissing) { + this.emit("missing", { id, locale: this._locale }) } - let translation - - if (context && !contextualMessageMissing) { - // context is like a subdirectory of other keys - translation = this.messages[context][id] || message || id - } else { - translation = this.messages[id] || message || id - } + let translation = this.messages[id] || message || id if (process.env.NODE_ENV !== "production") { translation = isString(translation) diff --git a/packages/macro/package.json b/packages/macro/package.json index a94e70878..f658d6d78 100644 --- a/packages/macro/package.json +++ b/packages/macro/package.json @@ -35,7 +35,7 @@ "@babel/runtime": "^7.20.13", "@babel/types": "^7.20.7", "@lingui/conf": "3.17.1", - "ramda": "^0.27.1" + "@lingui/cli": "3.17.1" }, "peerDependencies": { "@lingui/core": "^3.13.0", diff --git a/packages/macro/src/macroJs.ts b/packages/macro/src/macroJs.ts index 54f964ae1..b8f6a8b42 100644 --- a/packages/macro/src/macroJs.ts +++ b/packages/macro/src/macroJs.ts @@ -1,14 +1,15 @@ -import * as R from "ramda" import * as babelTypes from "@babel/types" import { + CallExpression, Expression, + Identifier, + isObjectProperty, Node, - CallExpression, ObjectExpression, - isObjectProperty, ObjectProperty, - Identifier, + SourceLocation, StringLiteral, + TemplateLiteral, } from "@babel/types" import { NodePath } from "@babel/traverse" @@ -16,10 +17,11 @@ import ICUMessageFormat, { ArgToken, ParsedResult, TextToken, - Tokens, + Token, } from "./icu" -import { zip, makeCounter } from "./utils" -import { COMMENT, ID, MESSAGE, EXTRACT_MARK } from "./constants" +import { makeCounter } from "./utils" +import { COMMENT, CONTEXT, EXTRACT_MARK, ID, MESSAGE } from "./constants" +import { generateMessageId } from "@lingui/cli/api" const keepSpaceRe = /(?:\\(?:\r\n|\r|\n))+\s+/g const keepNewLineRe = /(?:\r\n|\r|\n)+\s+/g @@ -58,30 +60,21 @@ export default class MacroJs { }: { message: ParsedResult["message"]; values: ParsedResult["values"] }, linguiInstance?: babelTypes.Identifier ) => { - const args = [] - - args.push(isString(message) ? this.types.stringLiteral(message) : message) - - if (Object.keys(values).length) { - const valuesObject = Object.keys(values).map((key) => - this.types.objectProperty(this.types.identifier(key), values[key]) - ) - - args.push(this.types.objectExpression(valuesObject)) - } - - const newNode = this.types.callExpression( - this.types.memberExpression( - linguiInstance ?? this.types.identifier(this.i18nImportName), - this.types.identifier("_") + const properties: ObjectProperty[] = [ + this.createIdProperty(message), + this.createObjectProperty(MESSAGE, this.types.stringLiteral(message)), + this.createValuesProperty(values), + ] + + const newNode = this.createI18nCall( + this.createMessageDescriptor( + properties, + // preserve line numbers for extractor + path.node.loc ), - args + linguiInstance ) - // preserve line number - newNode.loc = path.node.loc - - path.addComment("leading", EXTRACT_MARK) path.replaceWith(newNode) } @@ -95,7 +88,7 @@ export default class MacroJs { return true } - // t(i18nInstance)`Message` -> i18nInstance._('Message') + // t(i18nInstance)`Message` -> i18nInstance._(messageDescriptor) if ( this.types.isCallExpression(path.node) && this.types.isTaggedTemplateExpression(path.parentPath.node) && @@ -190,16 +183,8 @@ export default class MacroJs { path: NodePath, linguiInstance?: babelTypes.Identifier ) => { - let descriptor = this.processDescriptor(path.node.arguments[0]) - - const newNode = this.types.callExpression( - this.types.memberExpression( - linguiInstance ?? this.types.identifier(this.i18nImportName), - this.types.identifier("_") - ), - [descriptor] - ) - path.replaceWith(newNode) + const descriptor = this.processDescriptor(path.node.arguments[0]) + path.replaceWith(this.createI18nCall(descriptor, linguiInstance)) } /** @@ -222,78 +207,78 @@ export default class MacroJs { processDescriptor = (descriptor_: Node) => { const descriptor = descriptor_ as ObjectExpression - this.types.addComment(descriptor, "leading", EXTRACT_MARK) - const messageIndex = descriptor.properties.findIndex( - (property) => - isObjectProperty(property) && this.isIdentifier(property.key, MESSAGE) - ) - if (messageIndex === -1) { - return descriptor + const messageProperty = this.getObjectPropertyByKey(descriptor, MESSAGE) + const idProperty = this.getObjectPropertyByKey(descriptor, ID) + const contextProperty = this.getObjectPropertyByKey(descriptor, CONTEXT) + + const properties: ObjectProperty[] = [idProperty] + + if (!this.stripNonEssentialProps) { + properties.push(contextProperty) } // if there's `message` property, replace macros with formatted message - const node = descriptor.properties[messageIndex] as ObjectProperty + if (messageProperty) { + // Inside message descriptor the `t` macro in `message` prop is optional. + // Template strings are always processed as if they were wrapped by `t`. + const tokens = this.types.isTemplateLiteral(messageProperty.value) + ? this.tokenizeTemplateLiteral(messageProperty.value) + : this.tokenizeNode(messageProperty.value, true) + + let messageNode = messageProperty.value as StringLiteral + + if (tokens) { + const messageFormat = new ICUMessageFormat() + const { message: messageRaw, values } = messageFormat.fromTokens(tokens) + const message = normalizeWhitespace(messageRaw) + messageNode = this.types.stringLiteral(message) + + properties.push(this.createValuesProperty(values)) + } - // Inside message descriptor the `t` macro in `message` prop is optional. - // Template strings are always processed as if they were wrapped by `t`. - const tokens = this.types.isTemplateLiteral(node.value) - ? this.tokenizeTemplateLiteral(node.value) - : this.tokenizeNode(node.value, true) + if (!this.stripNonEssentialProps) { + properties.push( + this.createObjectProperty(MESSAGE, messageNode as Expression) + ) + } - let messageNode = node.value - if (tokens != null) { - const messageFormat = new ICUMessageFormat() - const { message: messageRaw, values } = messageFormat.fromTokens(tokens) - const message = normalizeWhitespace(messageRaw) - messageNode = this.types.stringLiteral(message) + if (!idProperty && this.types.isStringLiteral(messageNode)) { + const context = + contextProperty && + this.getTextFromExpression(contextProperty.value as Expression) - this.addValues(descriptor.properties, values) + properties.push(this.createIdProperty(messageNode.value, context)) + } } - // Don't override custom ID - const hasId = - descriptor.properties.findIndex( - (property) => - isObjectProperty(property) && this.isIdentifier(property.key, ID) - ) !== -1 - - descriptor.properties[messageIndex] = this.types.objectProperty( - this.types.identifier(hasId ? MESSAGE : ID), - messageNode - ) - - if (this.stripNonEssentialProps) { - descriptor.properties = descriptor.properties.filter( - (property) => - isObjectProperty(property) && - !this.isIdentifier(property.key, MESSAGE) && - isObjectProperty(property) && - !this.isIdentifier(property.key, COMMENT) - ) + if (!this.stripNonEssentialProps) { + properties.push(this.getObjectPropertyByKey(descriptor, COMMENT)) } - return descriptor + return this.createMessageDescriptor(properties, descriptor.loc) } - addValues = ( - obj: ObjectExpression["properties"], - values: ParsedResult["values"] - ) => { + createIdProperty(message: string, context?: string) { + return this.createObjectProperty( + ID, + this.types.stringLiteral(generateMessageId(message, context)) + ) + } + + createValuesProperty(values: ParsedResult["values"]) { const valuesObject = Object.keys(values).map((key) => this.types.objectProperty(this.types.identifier(key), values[key]) ) if (!valuesObject.length) return - obj.push( - this.types.objectProperty( - this.types.identifier("values"), - this.types.objectExpression(valuesObject) - ) + return this.types.objectProperty( + this.types.identifier("values"), + this.types.objectExpression(valuesObject) ) } - tokenizeNode = (node: Node, ignoreExpression = false) => { + tokenizeNode(node: Node, ignoreExpression = false): Token[] { if (this.isI18nMethod(node)) { // t return this.tokenizeTemplateLiteral(node as Expression) @@ -304,7 +289,7 @@ export default class MacroJs { // // date, number // return transformFormatMethod(node, file, props, root) } else if (!ignoreExpression) { - return this.tokenizeExpression(node) + return [this.tokenizeExpression(node)] } } @@ -313,43 +298,45 @@ export default class MacroJs { * text chunks and node.expressions contains expressions. * Both arrays must be zipped together to get the final list of tokens. */ - tokenizeTemplateLiteral = (node: babelTypes.Expression): Tokens => { - const tokenize = R.pipe( - R.evolve({ - quasis: R.map((text: babelTypes.TemplateElement): TextToken => { - // Don't output tokens without text. - // if it's an unicode we keep the cooked value because it's the parsed value by babel (without unicode chars) - // This regex will detect if a string contains unicode chars, when they're we should interpolate them - // why? because platforms like react native doesn't parse them, just doing a JSON.parse makes them UTF-8 friendly - const value = /\\u[a-fA-F0-9]{4}|\\x[a-fA-F0-9]{2}/g.test( - text.value.raw - ) - ? text.value.cooked - : text.value.raw - if (value === "") return null - - return { - type: "text", - value: this.clearBackslashes(value), - } - }), - expressions: R.map((exp: babelTypes.Expression) => - this.types.isCallExpression(exp) - ? this.tokenizeNode(exp) - : this.tokenizeExpression(exp) - ), - }), - (exp) => zip(exp.quasis, exp.expressions), - R.flatten, - R.filter(Boolean) - ) + tokenizeTemplateLiteral(node: babelTypes.Expression): Token[] { + const tpl = this.types.isTaggedTemplateExpression(node) + ? node.quasi + : (node as TemplateLiteral) + + const expressions = tpl.expressions as Expression[] + + return tpl.quasis.flatMap((text, i) => { + // if it's an unicode we keep the cooked value because it's the parsed value by babel (without unicode chars) + // This regex will detect if a string contains unicode chars, when they're we should interpolate them + // why? because platforms like react native doesn't parse them, just doing a JSON.parse makes them UTF-8 friendly + const value = /\\u[a-fA-F0-9]{4}|\\x[a-fA-F0-9]{2}/g.test(text.value.raw) + ? text.value.cooked + : text.value.raw + + let argTokens: Token[] = [] + const currExp = expressions[i] + + if (currExp) { + argTokens = this.types.isCallExpression(currExp) + ? this.tokenizeNode(currExp) + : [this.tokenizeExpression(currExp)] + } - return tokenize( - this.types.isTaggedTemplateExpression(node) ? node.quasi : node - ) + return [ + ...(value + ? [ + { + type: "text", + value: this.clearBackslashes(value), + } as TextToken, + ] + : []), + ...argTokens, + ] + }) } - tokenizeChoiceComponent = (node: CallExpression): ArgToken => { + tokenizeChoiceComponent(node: CallExpression): ArgToken { const format = (node.callee as Identifier).name.toLowerCase() const token: ArgToken = { @@ -392,7 +379,7 @@ export default class MacroJs { return token } - tokenizeExpression = (node: Node | Expression): ArgToken => { + tokenizeExpression(node: Node | Expression): ArgToken { if (this.isArg(node) && this.types.isCallExpression(node)) { return { type: "arg", @@ -407,7 +394,7 @@ export default class MacroJs { } } - expressionToArgument = (exp: Expression): string => { + expressionToArgument(exp: Expression): string { if (this.types.isIdentifier(exp)) { return exp.name } else if (this.types.isStringLiteral(exp)) { @@ -425,27 +412,69 @@ export default class MacroJs { return value.replace(/\\`/g, "`") } + createI18nCall( + messageDescriptor: ObjectExpression, + linguiInstance?: Identifier + ) { + return this.types.callExpression( + this.types.memberExpression( + linguiInstance ?? this.types.identifier(this.i18nImportName), + this.types.identifier("_") + ), + [messageDescriptor] + ) + } + + createMessageDescriptor( + properties: ObjectProperty[], + oldLoc?: SourceLocation + ): ObjectExpression { + const newDescriptor = this.types.objectExpression( + properties.filter(Boolean) + ) + this.types.addComment(newDescriptor, "leading", EXTRACT_MARK) + if (oldLoc) { + newDescriptor.loc = oldLoc + } + + return newDescriptor + } + + createObjectProperty(key: string, value: Expression) { + return this.types.objectProperty(this.types.identifier(key), value) + } + + getObjectPropertyByKey( + objectExp: ObjectExpression, + key: string + ): ObjectProperty { + return objectExp.properties.find( + (property) => + isObjectProperty(property) && this.isIdentifier(property.key, key) + ) as ObjectProperty + } + /** * Custom matchers */ - isIdentifier = (node: Node | Expression, name: string) => { + isIdentifier(node: Node | Expression, name: string) { return this.types.isIdentifier(node, { name }) } - isDefineMessage = (node: Node): boolean => { + isDefineMessage(node: Node): boolean { return ( this.types.isCallExpression(node) && this.isIdentifier(node.callee, "defineMessage") ) } - isArg = (node: Node) => { + isArg(node: Node) { return ( this.types.isCallExpression(node) && this.isIdentifier(node.callee, "arg") ) } - isI18nMethod = (node: Node) => { + isI18nMethod(node: Node) { return ( this.types.isTaggedTemplateExpression(node) && (this.isIdentifier(node.tag, "t") || @@ -454,7 +483,7 @@ export default class MacroJs { ) } - isChoiceMethod = (node: Node) => { + isChoiceMethod(node: Node) { return ( this.types.isCallExpression(node) && (this.isIdentifier(node.callee, "plural") || @@ -462,6 +491,16 @@ export default class MacroJs { this.isIdentifier(node.callee, "selectOrdinal")) ) } -} -const isString = (s: unknown): s is string => typeof s === "string" + getTextFromExpression(exp: Expression): string { + if (this.types.isStringLiteral(exp)) { + return exp.value + } + + if (this.types.isTemplateLiteral(exp)) { + if (exp?.quasis.length === 1) { + return exp.quasis[0]?.value?.cooked + } + } + } +} diff --git a/packages/macro/src/macroJsx.ts b/packages/macro/src/macroJsx.ts index 4eb280670..f09f92ab1 100644 --- a/packages/macro/src/macroJsx.ts +++ b/packages/macro/src/macroJsx.ts @@ -10,6 +10,7 @@ import { Literal, Node, StringLiteral, + TemplateLiteral, } from "@babel/types" import { NodePath } from "@babel/traverse" @@ -21,6 +22,7 @@ import ICUMessageFormat, { } from "./icu" import { makeCounter } from "./utils" import { COMMENT, CONTEXT, ID, MESSAGE } from "./constants" +import { generateMessageId } from "@lingui/cli/api" const pluralRuleRe = /(_[\d\w]+|zero|one|two|few|many|other)/ const jsx2icuExactChoice = (value: string) => @@ -76,7 +78,7 @@ export default class MacroJSX { this.stripNonEssentialProps = opts.stripNonEssentialProps } - safeJsxAttribute = (name: string, value: string) => { + createStringJsxAttribute = (name: string, value: string) => { // This handles quoted JSX attributes and html entities. return this.types.jsxAttribute( this.types.jsxIdentifier(name), @@ -101,39 +103,43 @@ export default class MacroJSX { if (!id && !message) { return - } else if (id && id !== message) { - // If `id` prop already exists and generated ID is different, - // add it as a `default` prop + } + + if (id) { attributes.push( this.types.jsxAttribute( this.types.jsxIdentifier(ID), this.types.stringLiteral(id) ) ) - - if (!this.stripNonEssentialProps && message) { - attributes.push(this.safeJsxAttribute(MESSAGE, message)) - } } else { - attributes.push(this.safeJsxAttribute(ID, message)) - } - - if (!this.stripNonEssentialProps && comment) { attributes.push( - this.types.jsxAttribute( - this.types.jsxIdentifier(COMMENT), - this.types.stringLiteral(comment) - ) + this.createStringJsxAttribute(ID, generateMessageId(message, context)) ) } - if (context) { - attributes.push( - this.types.jsxAttribute( - this.types.jsxIdentifier(CONTEXT), - this.types.stringLiteral(context) + if (!this.stripNonEssentialProps) { + if (message) { + attributes.push(this.createStringJsxAttribute(MESSAGE, message)) + } + + if (comment) { + attributes.push( + this.types.jsxAttribute( + this.types.jsxIdentifier(COMMENT), + this.types.stringLiteral(comment) + ) ) - ) + } + + if (context) { + attributes.push( + this.types.jsxAttribute( + this.types.jsxIdentifier(CONTEXT), + this.types.stringLiteral(context) + ) + ) + } } // Parameters for variable substitution @@ -196,15 +202,14 @@ export default class MacroJSX { stripMacroAttributes = (path: NodePath) => { const { attributes } = path.node.openingElement - const id = attributes.filter(this.attrName([ID]))[0] - const message = attributes.filter(this.attrName([MESSAGE]))[0] - const comment = attributes.filter(this.attrName([COMMENT]))[0] - const context = attributes.filter(this.attrName([CONTEXT]))[0] + const id = attributes.find(this.attrName([ID])) + const message = attributes.find(this.attrName([MESSAGE])) + const comment = attributes.find(this.attrName([COMMENT])) + const context = attributes.find(this.attrName([CONTEXT])) let reserved = [ID, MESSAGE, COMMENT, CONTEXT] - if (this.isI18nComponent(path)) { - // no reserved prop names - } else if (this.isChoiceComponent(path)) { + + if (this.isChoiceComponent(path)) { reserved = [ ...reserved, "_\\w+", @@ -230,7 +235,7 @@ export default class MacroJSX { } tokenizeNode = (path: NodePath): Token[] => { - if (this.isI18nComponent(path)) { + if (this.isTransComponent(path)) { // t return this.tokenizeTrans(path) } else if (this.isChoiceComponent(path)) { @@ -259,32 +264,7 @@ export default class MacroJSX { return [this.tokenizeText(exp.node.value.replace(/\n/g, "\\n"))] } if (exp.isTemplateLiteral()) { - const expressions = exp.get("expressions") as NodePath[] - - return exp.get("quasis").flatMap(({ node: text }, i) => { - // if it's an unicode we keep the cooked value because it's the parsed value by babel (without unicode chars) - // This regex will detect if a string contains unicode chars, when they're we should interpolate them - // why? because platforms like react native doesn't parse them, just doing a JSON.parse makes them UTF-8 friendly - const value = /\\u[a-fA-F0-9]{4}|\\x[a-fA-F0-9]{2}/g.test( - text.value.raw - ) - ? text.value.cooked - : text.value.raw - - let argTokens: Token[] = [] - const currExp = expressions[i] - - if (currExp) { - argTokens = currExp.isCallExpression() - ? this.tokenizeNode(currExp) - : [this.tokenizeExpression(currExp)] - } - - return [ - ...(value ? [this.tokenizeText(this.clearBackslashes(value))] : []), - ...argTokens, - ] - }) + return this.tokenizeTemplateLiteral(exp) } if (exp.isConditionalExpression()) { return [this.tokenizeConditionalExpression(exp)] @@ -306,6 +286,33 @@ export default class MacroJSX { } } + tokenizeTemplateLiteral(exp: NodePath): Token[] { + const expressions = exp.get("expressions") as NodePath[] + + return exp.get("quasis").flatMap(({ node: text }, i) => { + // if it's an unicode we keep the cooked value because it's the parsed value by babel (without unicode chars) + // This regex will detect if a string contains unicode chars, when they're we should interpolate them + // why? because platforms like react native doesn't parse them, just doing a JSON.parse makes them UTF-8 friendly + const value = /\\u[a-fA-F0-9]{4}|\\x[a-fA-F0-9]{2}/g.test(text.value.raw) + ? text.value.cooked + : text.value.raw + + let argTokens: Token[] = [] + const currExp = expressions[i] + + if (currExp) { + argTokens = currExp.isCallExpression() + ? this.tokenizeNode(currExp) + : [this.tokenizeExpression(currExp)] + } + + return [ + ...(value ? [this.tokenizeText(this.clearBackslashes(value))] : []), + ...argTokens, + ] + }) + } + tokenizeChoiceComponent = (path: NodePath): Token => { const element = path.get("openingElement") const format = this.getJsxTagName(path.node).toLowerCase() @@ -422,7 +429,7 @@ export default class MacroJSX { ): ArgToken => { exp.traverse({ JSXElement: (el) => { - if (this.isI18nComponent(el) || this.isChoiceComponent(el)) { + if (this.isTransComponent(el) || this.isChoiceComponent(el)) { this.replacePath(el) el.skip() } @@ -455,7 +462,7 @@ export default class MacroJSX { return value.replace(/\\`/g, "`") } - isI18nComponent = ( + isTransComponent = ( path: NodePath, name = "Trans" ): path is NodePath => { @@ -469,9 +476,9 @@ export default class MacroJSX { isChoiceComponent = (path: NodePath): path is NodePath => { return ( - this.isI18nComponent(path, "Plural") || - this.isI18nComponent(path, "Select") || - this.isI18nComponent(path, "SelectOrdinal") + this.isTransComponent(path, "Plural") || + this.isTransComponent(path, "Select") || + this.isTransComponent(path, "SelectOrdinal") ) } diff --git a/packages/macro/src/utils.ts b/packages/macro/src/utils.ts index b9cd7899a..17261d099 100644 --- a/packages/macro/src/utils.ts +++ b/packages/macro/src/utils.ts @@ -1,16 +1,3 @@ -import * as R from "ramda" - -/** - * Custom zip method which takes length of the larger array - * (usually zip functions use the `smaller` length, discarding values in larger array) - */ -export function zip(a: A[], b: B[]): [A, B][] { - return R.range(0, Math.max(a.length, b.length)).map((index) => [ - a[index], - b[index], - ]) -} - export const makeCounter = (index = 0) => () => diff --git a/packages/macro/test/fixtures/js-t-continuation-character.expected.js b/packages/macro/test/fixtures/js-t-continuation-character.expected.js index 416d6fde4..bb22822cd 100644 --- a/packages/macro/test/fixtures/js-t-continuation-character.expected.js +++ b/packages/macro/test/fixtures/js-t-continuation-character.expected.js @@ -1,2 +1,8 @@ -import { i18n } from "@lingui/core"; -/*i18n*/i18n._("Multiline with continuation"); +import { i18n } from "@lingui/core" +i18n._( + /*i18n*/ + { + id: "LBYoFK", + message: "Multiline with continuation", + } +) diff --git a/packages/macro/test/fixtures/js-t-var/js-t-var.expected.js b/packages/macro/test/fixtures/js-t-var/js-t-var.expected.js index caa293f5d..17a544413 100644 --- a/packages/macro/test/fixtures/js-t-var/js-t-var.expected.js +++ b/packages/macro/test/fixtures/js-t-var/js-t-var.expected.js @@ -1,16 +1,30 @@ -"use strict"; +"use strict" -var _core = require("@lingui/core"); +var _core = require("@lingui/core") function scoped(foo) { if (foo) { - var bar = 50; - /*i18n*/_core.i18n._("This is bar {bar}", { - bar: bar - }); + var bar = 50 + _core.i18n._( + /*i18n*/ + { + id: "EvVtyn", + message: "This is bar {bar}", + values: { + bar: bar, + }, + } + ) } else { - var _bar = 10; - /*i18n*/_core.i18n._("This is a different bar {bar}", { - bar: _bar - }); + var _bar = 10 + _core.i18n._( + /*i18n*/ + { + id: "e6QGtZ", + message: "This is a different bar {bar}", + values: { + bar: _bar, + }, + } + ) } } diff --git a/packages/macro/test/fixtures/jsx-plural-select-nested.expected.js b/packages/macro/test/fixtures/jsx-plural-select-nested.expected.js index fd19a6c2e..4e3c00bc1 100644 --- a/packages/macro/test/fixtures/jsx-plural-select-nested.expected.js +++ b/packages/macro/test/fixtures/jsx-plural-select-nested.expected.js @@ -1,7 +1,13 @@ -import { Trans } from "@lingui/react"; -; +import { Trans } from "@lingui/react" +; diff --git a/packages/macro/test/index.ts b/packages/macro/test/index.ts index a3d37bdbc..eab967d63 100644 --- a/packages/macro/test/index.ts +++ b/packages/macro/test/index.ts @@ -1,8 +1,20 @@ import fs from "fs" import path from "path" -import { transformFileSync, TransformOptions, transformSync } from "@babel/core" +import { + PluginObj, + transformFileSync, + TransformOptions, + transformSync, +} from "@babel/core" import prettier from "prettier" import { LinguiMacroOpts } from "../src/index" +import { + JSXAttribute, + jsxExpressionContainer, + JSXIdentifier, + stringLiteral, +} from "@babel/types" +import { NodePath } from "@babel/traverse" export type TestCase = { name?: string @@ -12,6 +24,8 @@ export type TestCase = { production?: boolean useTypescriptPreset?: boolean macroOpts?: LinguiMacroOpts + /** Remove hash id from snapshot for more stable testing */ + stripId?: boolean only?: boolean skip?: boolean } @@ -29,11 +43,34 @@ const testCases: Record = { "js-defineMessage": require("./js-defineMessage").default, } +function stripIdPlugin(): PluginObj { + return { + visitor: { + JSXOpeningElement: (path) => { + const idAttr = path + .get("attributes") + .find( + (attr) => + attr.isJSXAttribute() && + (attr.node.name as JSXIdentifier).name === "id" + ) as NodePath + + if (idAttr) { + idAttr + .get("value") + .replaceWith(jsxExpressionContainer(stringLiteral(""))) + } + }, + }, + } +} + describe("macro", function () { process.env.LINGUI_CONFIG = path.join(__dirname, "lingui.config.js") const getDefaultBabelOptions = ( - macroOpts: LinguiMacroOpts = {} + macroOpts: LinguiMacroOpts = {}, + stripId = false ): TransformOptions => { return { filename: "", @@ -52,6 +89,7 @@ describe("macro", function () { resolvePath: (source: string) => require.resolve(source), }, ], + ...(stripId ? [stripIdPlugin] : []), ], } } @@ -61,7 +99,7 @@ describe("macro", function () { try { return transformSync(code, getDefaultBabelOptions()).code.trim() } catch (e) { - e.message = e.message.replace(/([^:]*:){2}/, "") + ;(e as Error).message = (e as Error).message.replace(/([^:]*:){2}/, "") throw e } } @@ -85,6 +123,7 @@ describe("macro", function () { only, skip, macroOpts, + stripId, }, index ) => { @@ -92,7 +131,7 @@ describe("macro", function () { if (only) run = it.only if (skip) run = it.skip run(name != null ? name : `${suiteName} #${index + 1}`, () => { - const babelOptions = getDefaultBabelOptions(macroOpts) + const babelOptions = getDefaultBabelOptions(macroOpts, stripId) expect(input || filename).toBeDefined() const originalEnv = process.env.NODE_ENV @@ -125,7 +164,7 @@ describe("macro", function () { const actual = transformFileSync(inputPath, _babelOptions) .code.replace(/\r/g, "") .trim() - expect(actual).toEqual(expected) + expect(clean(actual)).toEqual(clean(expected)) } else { const actual = transformSync(input, babelOptions).code.trim() diff --git a/packages/macro/test/js-arg.ts b/packages/macro/test/js-arg.ts index 3aeaeaf9a..7cd66be0f 100644 --- a/packages/macro/test/js-arg.ts +++ b/packages/macro/test/js-arg.ts @@ -9,7 +9,13 @@ const cases: TestCase[] = [ `, expected: ` import { i18n } from "@lingui/core"; - const a = /*i18n*/ i18n._("Hello {name}") + const a = i18n._( + /*i18n*/ + { + id: "OVaF9k", + message: "Hello {name}", + } + ); `, }, ] diff --git a/packages/macro/test/js-defineMessage.ts b/packages/macro/test/js-defineMessage.ts index 8d8d23cf6..a913a8283 100644 --- a/packages/macro/test/js-defineMessage.ts +++ b/packages/macro/test/js-defineMessage.ts @@ -15,8 +15,9 @@ const cases: TestCase[] = [ const message = /*i18n*/ { + message: "{value, plural, one {book} other {books}}", + id: "SlmyxX", comment: "Description", - id: "{value, plural, one {book} other {books}}" }; `, }, @@ -33,7 +34,8 @@ const cases: TestCase[] = [ const message = /*i18n*/ { - id: "Message" + message: "Message", + id: "xDAtGP", }; `, }, @@ -50,10 +52,11 @@ const cases: TestCase[] = [ const message = /*i18n*/ { - id: "Message {name}", values: { name: name - } + }, + message: "Message {name}", + id: "A2aVLF", }; `, }, @@ -92,11 +95,10 @@ const cases: TestCase[] = [ const msg = /*i18n*/ { - id: 'Hello {name}', - context: 'My Context', values: { name: name, }, + id: "oT92lS", }; `, }, @@ -118,7 +120,6 @@ const cases: TestCase[] = [ /*i18n*/ { id: 'msgId', - context: 'My Context', values: { name: name, }, @@ -138,10 +139,11 @@ const cases: TestCase[] = [ const message = /*i18n*/ { - id: "Hello {name}", values: { name: name - } + }, + message: "Hello {name}", + id: "OVaF9k", }; `, }, diff --git a/packages/macro/test/js-plural.ts b/packages/macro/test/js-plural.ts index 2c3a3c7dd..034b736f3 100644 --- a/packages/macro/test/js-plural.ts +++ b/packages/macro/test/js-plural.ts @@ -12,9 +12,17 @@ const cases: TestCase[] = [ `, expected: ` import { i18n } from "@lingui/core"; - const a = /*i18n*/ i18n._("{count, plural, one {# book} other {# books}}", { - count: count - }); + const a = i18n._( + /*i18n*/ + { + id: "esnaQO", + message: "{count, plural, one {# book} other {# books}}", + values: { + count: count, + }, + } + ); + `, }, { @@ -30,9 +38,16 @@ const cases: TestCase[] = [ `, expected: ` import { i18n } from "@lingui/core"; - /*i18n*/ i18n._("{0, plural, offset:1 =0 {No books} =1 {1 book} other {# books}}", { - 0: users.length - }); + i18n._( + /*i18n*/ + { + id: "CF5t+7", + message: "{0, plural, offset:1 =0 {No books} =1 {1 book} other {# books}}", + values: { + 0: users.length, + }, + } + ); `, }, ] diff --git a/packages/macro/test/js-select.ts b/packages/macro/test/js-select.ts index 5b15f1c25..cf1b3436e 100644 --- a/packages/macro/test/js-select.ts +++ b/packages/macro/test/js-select.ts @@ -16,26 +16,17 @@ const cases: TestCase[] = [ `, expected: ` import { i18n } from "@lingui/core"; - /*i18n*/ i18n._("{gender, select, male {{numOfGuests, plural, one {He invites one guest} other {He invites # guests}}} female {She is {gender}} other {They is {gender}}}", { - gender: gender, - numOfGuests: numOfGuests - }); - `, - }, - { - name: "Macro with escaped reserved props", - input: ` - import { select } from '@lingui/macro' - select(value, { - id: 'test escaped id', - comment: 'test escaped comment' - }) - `, - expected: ` - import { i18n } from "@lingui/core"; - /*i18n*/ i18n._("{value, select, id {test escaped id} comment {test escaped comment}}", { - value: value - }); + i18n._( + /*i18n*/ + { + id: "G8xqGf", + message: "{gender, select, male {{numOfGuests, plural, one {He invites one guest} other {He invites # guests}}} female {She is {gender}} other {They is {gender}}}", + values: { + gender: gender, + numOfGuests: numOfGuests, + }, + } + ); `, }, ] diff --git a/packages/macro/test/js-selectOrdinal.ts b/packages/macro/test/js-selectOrdinal.ts index 6664f26eb..12e5469fa 100644 --- a/packages/macro/test/js-selectOrdinal.ts +++ b/packages/macro/test/js-selectOrdinal.ts @@ -11,10 +11,17 @@ const cases: TestCase[] = [ })} cat\` `, expected: ` - import { i18n } from "@lingui/core"; - /*i18n*/ i18n._("This is my {count, selectordinal, one {#st} two {#nd} other {#rd}} cat", { - count: count - }); + import { i18n } from "@lingui/core"; + i18n._( + /*i18n*/ + { + id: "dJXd3T", + message: "This is my {count, selectordinal, one {#st} two {#nd} other {#rd}} cat", + values: { + count: count, + }, + } + ); `, }, ] diff --git a/packages/macro/test/js-t.ts b/packages/macro/test/js-t.ts index f31b4189c..fc63b6663 100644 --- a/packages/macro/test/js-t.ts +++ b/packages/macro/test/js-t.ts @@ -9,19 +9,31 @@ const cases: TestCase[] = [ `, expected: ` import { i18n } from "@lingui/core"; - const a = /*i18n*/ i18n._("Expression assignment") + const a = i18n._( + /*i18n*/ + { + id: "mjnlP0", + message: "Expression assignment", + } + ); `, }, { name: "Macro is used in expression assignment, with custom lingui instance", input: ` import { t } from '@lingui/macro'; - import { i18n } from './lingui'; - const a = t(i18n)\`Expression assignment\`; + import { customI18n } from './lingui'; + const a = t(customI18n)\`Expression assignment\`; `, expected: ` - import { i18n } from './lingui'; - const a = /*i18n*/ i18n._("Expression assignment") + import { customI18n } from './lingui'; + const a = customI18n._( + /*i18n*/ + { + id: "mjnlP0", + message: "Expression assignment", + } + ); `, }, { @@ -32,33 +44,53 @@ const cases: TestCase[] = [ `, expected: ` import { i18n } from "@lingui/core"; - /*i18n*/ i18n._("Variable {name}", { - name: name - }) + i18n._( + /*i18n*/ + { + id: "xRRkAE", + message: "Variable {name}", + values: { + name: name, + }, + } + ); `, }, { - name: "Variables with scaped template literals are correctly formatted", + name: "Variables with escaped template literals are correctly formatted", input: ` import { t } from '@lingui/macro'; t\`Variable \\\`\${name}\\\`\`; `, expected: ` import { i18n } from "@lingui/core"; - /*i18n*/ i18n._("Variable \`{name}\`", { - name: name - }) + i18n._( + /*i18n*/ + { + id: "ICBco+", + message: "Variable \`{name}\`", + values: { + name: name, + }, + } + ); `, }, { - name: "Variables with scaped double quotes are correctly formatted", + name: "Variables with escaped double quotes are correctly formatted", input: ` import { t } from '@lingui/macro'; t\`Variable \"name\" \`; `, expected: ` - import { i18n } from "@lingui/core"; - /*i18n*/ i18n._("Variable \\"name\\"") + import { i18n } from "@lingui/core"; + i18n._( + /*i18n*/ + { + id: "CcPIZW", + message: 'Variable "name"', + } + ); `, }, { @@ -68,10 +100,17 @@ const cases: TestCase[] = [ t\`\${duplicate} variable \${duplicate}\`; `, expected: ` - import { i18n } from "@lingui/core"; - /*i18n*/ i18n._("{duplicate} variable {duplicate}", { - duplicate: duplicate - }) + import { i18n } from "@lingui/core"; + i18n._( + /*i18n*/ + { + id: "+nhkwg", + message: "{duplicate} variable {duplicate}", + values: { + duplicate: duplicate, + }, + } + ); `, }, { @@ -89,14 +128,19 @@ const cases: TestCase[] = [ `, expected: ` import { i18n } from "@lingui/core"; - /*i18n*/ i18n._( - "Property {0}, function {1}, array {2}, constant {3}, object {4} anything {5}", { - 0: props.name, - 1: random(), - 2: array[index], - 3: 42, - 4: new Date(), - 5: props.messages[index].value() + i18n._( + /*i18n*/ + { + id: "X1jIKa", + message: "Property {0}, function {1}, array {2}, constant {3}, object {4} anything {5}", + values: { + 0: props.name, + 1: random(), + 2: array[index], + 3: 42, + 4: new Date(), + 5: props.messages[index].value(), + }, } ); `, @@ -110,7 +154,13 @@ const cases: TestCase[] = [ `, expected: ` import { i18n } from "@lingui/core"; - /*i18n*/ i18n._("Multiline\\nstring") + i18n._( + /*i18n*/ + { + id: "EfogM+", + message: "Multiline\\nstring", + } + ); `, }, { @@ -119,15 +169,18 @@ const cases: TestCase[] = [ import { t } from '@lingui/macro' const msg = t({ message: \`Hello \${name}\` }) `, - expected: `import { i18n } from "@lingui/core"; - const msg = - i18n._(/*i18n*/ - { - id: "Hello {name}", - values: { - name: name, - }, - }); + expected: ` + import { i18n } from "@lingui/core"; + const msg = i18n._( + /*i18n*/ + { + values: { + name: name, + }, + message: "Hello {name}", + id: "OVaF9k", + } + ); `, }, { @@ -137,15 +190,71 @@ const cases: TestCase[] = [ import { i18n } from './lingui' const msg = t(i18n)({ message: \`Hello \${name}\` }) `, - expected: `import { i18n } from "./lingui"; - const msg = - i18n._(/*i18n*/ - { - id: "Hello {name}", - values: { - name: name, - }, - }); + expected: ` + import { i18n } from "./lingui"; + const msg = i18n._( + /*i18n*/ + { + values: { + name: name, + }, + message: "Hello {name}", + id: "OVaF9k", + } + ); + `, + }, + { + name: "Should generate different id when context provided", + input: ` + import { t } from '@lingui/macro' + t({ message: "Hello" }) + t({ message: "Hello", context: "my custom" }) + `, + expected: ` + import { i18n } from "@lingui/core"; + i18n._( + /*i18n*/ + { + message: "Hello", + id: "uzTaYi", + } + ); + i18n._( + /*i18n*/ + { + context: "my custom", + message: "Hello", + id: "BYqAaU", + } + ); + `, + }, + { + name: "Context might be passed as template literal", + input: ` + import { t } from '@lingui/macro' + t({ message: "Hello", context: "my custom" }) + t({ message: "Hello", context: \`my custom\` }) + `, + expected: ` + import { i18n } from "@lingui/core"; + i18n._( + /*i18n*/ + { + context: "my custom", + message: "Hello", + id: "BYqAaU", + } + ); + i18n._( + /*i18n*/ + { + context: \`my custom\`, + message: "Hello", + id: "BYqAaU", + } + ); `, }, { @@ -154,17 +263,19 @@ const cases: TestCase[] = [ import { t } from '@lingui/macro' const msg = t({ id: 'msgId', comment: 'description for translators', message: plural(val, { one: '...', other: '...' }) }) `, - expected: `import { i18n } from "@lingui/core"; - const msg = - i18n._(/*i18n*/ - { - id: "msgId", - comment: "description for translators", - message: "{val, plural, one {...} other {...}}", - values: { - val: val, - }, - }); + expected: ` + import { i18n } from "@lingui/core"; + const msg = i18n._( + /*i18n*/ + { + id: "msgId", + values: { + val: val, + }, + message: "{val, plural, one {...} other {...}}", + comment: "description for translators", + } + ); `, }, { @@ -173,16 +284,18 @@ const cases: TestCase[] = [ import { t } from '@lingui/macro' const msg = t({ id: 'msgId', message: \`Some \${value}\` }) `, - expected: `import { i18n } from "@lingui/core"; - const msg = - i18n._(/*i18n*/ - { - id: "msgId", - message: "Some {value}", - values: { - value: value, - }, - }); + expected: ` + import { i18n } from "@lingui/core"; + const msg = i18n._( + /*i18n*/ + { + id: "msgId", + values: { + value: value, + }, + message: "Some {value}", + } + ); `, }, { @@ -191,7 +304,8 @@ const cases: TestCase[] = [ import { t } from '@lingui/macro' const msg = t({ id: \`msgId\` }) `, - expected: `import { i18n } from "@lingui/core"; + expected: ` + import { i18n } from "@lingui/core"; const msg = i18n._(/*i18n*/ { @@ -217,7 +331,6 @@ const cases: TestCase[] = [ i18n._(/*i18n*/ { id: "msgId", - context: "some context", values: { val: val, }, @@ -243,7 +356,6 @@ const cases: TestCase[] = [ i18n._(/*i18n*/ { id: 'msgId', - context: 'My Context', values: { name: name, }, @@ -268,7 +380,6 @@ const cases: TestCase[] = [ i18n._(/*i18n*/ { id: 'msgId', - context: 'My Context', values: { name: name, }, @@ -295,13 +406,13 @@ const cases: TestCase[] = [ const msg = i18n._(/*i18n*/ { - message: "Hello {name}", id: 'msgId', - comment: "description for translators", context: 'My Context', values: { name: name, }, + message: "Hello {name}", + comment: "description for translators", }); `, }, diff --git a/packages/macro/test/jsx-plural.ts b/packages/macro/test/jsx-plural.ts index 8238cf572..cd4581832 100644 --- a/packages/macro/test/jsx-plural.ts +++ b/packages/macro/test/jsx-plural.ts @@ -14,14 +14,18 @@ const cases: TestCase[] = [ `, expected: ` import { Trans } from "@lingui/react"; - A lot of them}}" - } - values={{ - count: count - }} components={{ - 0: - }} />; + A lot of them}}" + } + values={{ + count: count + }} + components={{ + 0: + }} + />; `, }, { @@ -60,6 +64,7 @@ const cases: TestCase[] = [ `, }, { + stripId: true, input: ` import { Trans, Plural } from '@lingui/macro'; # slot added} other {<1># slots added}}" - } - values={{ - count: count - }} components={{ - 0: , - 1: - }} />; + "} + message={ + "{count, plural, one {<0># slot added} other {<1># slots added}}" + } + values={{ + count: count + }} + components={{ + 0: , + 1: + }} + />; `, }, { + stripId: true, name: "Should return cases without leading or trailing spaces for nested Trans inside Plural", input: ` import { Trans, Plural } from '@lingui/macro'; @@ -109,12 +119,14 @@ const cases: TestCase[] = [ `, expected: ` import { Trans } from "@lingui/react"; - "} + message={ + "{count, plural, one {One hello} other {Other hello}}" + } + values={{ + count: count + }} />; `, }, diff --git a/packages/macro/test/jsx-select.ts b/packages/macro/test/jsx-select.ts index 51fed07fa..bfdc29f31 100644 --- a/packages/macro/test/jsx-select.ts +++ b/packages/macro/test/jsx-select.ts @@ -2,6 +2,7 @@ import { TestCase } from "./index" const cases: TestCase[] = [ { + stripId: true, input: ` import { Select } from '@lingui/macro'; ` inside `` macro if you want to provide `id`, `context` or `comment`. + +```jsx + +