From 22c6ae330ae790f737424eef1d10d5d7df289c33 Mon Sep 17 00:00:00 2001 From: Timofei Iatsenko Date: Thu, 6 Apr 2023 17:13:22 +0200 Subject: [PATCH] feat: support extracting from all forms of i18n._ / i18n.t calls --- .../src/index.ts | 173 +++++++++--------- .../test/__snapshots__/index.ts.snap | 45 +++-- .../test/fixtures/js-call-expression.js | 26 ++- .../fixtures/node-call-expression-aliased.js | 3 - .../fixtures/two-imports-renaming-esbuild.js | 11 -- .../test/index.ts | 21 ++- packages/core/__typetests__/index.test-d.tsx | 57 ++++++ packages/core/__typetests__/tsconfig.json | 8 + packages/core/src/i18n.test.ts | 90 +-------- packages/core/src/i18n.ts | 12 +- website/docs/ref/core.md | 24 ++- 11 files changed, 247 insertions(+), 223 deletions(-) delete mode 100644 packages/babel-plugin-extract-messages/test/fixtures/node-call-expression-aliased.js delete mode 100644 packages/babel-plugin-extract-messages/test/fixtures/two-imports-renaming-esbuild.js create mode 100644 packages/core/__typetests__/index.test-d.tsx create mode 100644 packages/core/__typetests__/tsconfig.json diff --git a/packages/babel-plugin-extract-messages/src/index.ts b/packages/babel-plugin-extract-messages/src/index.ts index c850b51bd..cb95b8e02 100644 --- a/packages/babel-plugin-extract-messages/src/index.ts +++ b/packages/babel-plugin-extract-messages/src/index.ts @@ -130,9 +130,25 @@ function extractFromObjectExpression( return props } +const I18N_OBJECT = "i18n" + +function hasComment(node: Node, comment: string): boolean { + return ( + node.leadingComments && + node.leadingComments.some((comm) => comm.value.trim() === comment) + ) +} + +function hasIgnoreComment(node: Node): boolean { + return hasComment(node, "lingui-extract-ignore") +} + +function hasI18nComment(node: Node): boolean { + return hasComment(node, "i18n") +} + export default function ({ types: t }: { types: BabelTypes }): PluginObj { let localTransComponentName: string - let localCoreI18nName: string function isTransComponent(node: Node) { return ( @@ -145,20 +161,33 @@ export default function ({ types: t }: { types: BabelTypes }): PluginObj { const isI18nMethod = (node: Node) => t.isMemberExpression(node) && - t.isIdentifier(node.object, { name: "i18n" }) && - t.isIdentifier(node.property, { name: "_" }) - - const isI18nTMethod = (node: Node) => - t.isMemberExpression(node) && - t.isIdentifier(node.object, { name: localCoreI18nName }) && - t.isIdentifier(node.property, { name: "t" }) + (t.isIdentifier(node.object, { name: I18N_OBJECT }) || + (t.isMemberExpression(node.object) && + t.isIdentifier(node.object.property, { name: I18N_OBJECT }) && + (t.isIdentifier(node.property, { name: "_" }) || + t.isIdentifier(node.property, { name: "t" })))) + + const extractFromMessageDescriptor = ( + path: NodePath, + ctx: PluginPass + ) => { + const props = extractFromObjectExpression(t, path.node, ctx.file.hub, [ + "id", + "message", + "comment", + "context", + ]) + + if (!props.id) { + console.warn( + path.buildCodeFrameError("Missing message ID, skipping.").message + ) + return + } - function hasI18nComment(node: Node): boolean { - return ( - node.leadingComments && - node.leadingComments.some((comm) => comm.value.match(/^\s*i18n/)) - ) + collectMessage(path, props, ctx) } + return { visitor: { // Get the local name of Trans component. Usually it's just `Trans`, but @@ -182,17 +211,6 @@ export default function ({ types: t }: { types: BabelTypes }): PluginObj { // If there's no alias, consider it was imported as Trans. localTransComponentName = importDeclarations["Trans"] || "Trans" } - - const coreImportDeclarations: Record = {} - if (moduleName === "@lingui/core") { - node.specifiers.forEach((specifier) => { - specifier = specifier as ImportSpecifier - coreImportDeclarations[(specifier.imported as Identifier).name] = - specifier.local.name - }) - - localCoreI18nName = coreImportDeclarations["i18n"] || "i18n" - } }, // Extract translation from component. @@ -237,66 +255,59 @@ export default function ({ types: t }: { types: BabelTypes }): PluginObj { }, CallExpression(path, ctx) { - const hasComment = [path.node, path.parent].some((node) => - hasI18nComment(node) - ) - - const firstArgument = path.node.arguments[0] - - let props: Record = {} - - if ( - isI18nTMethod(path.node.callee) && - t.isObjectExpression(firstArgument) - ) { - props = { - ...extractFromObjectExpression(t, firstArgument, ctx.file.hub, [ - "id", - "message", - "comment", - "context", - ]), - } - - collectMessage(path, props, ctx) + if ([path.node, path.parent].some((node) => hasIgnoreComment(node))) { return } - // support `i18n._` calls written by users in form i18n._(id, variables, descriptor) - // without explicit annotation with comment - // calls generated by macro has a form i18n._(/*i18n*/ {descriptor}) and - // processed by ObjectExpression visitor - const isNonMacroI18n = - isI18nMethod(path.node.callee) && !firstArgument?.leadingComments - if (!hasComment && !isNonMacroI18n) return - - props = { - id: getTextFromExpression( - t, - firstArgument as Expression, - ctx.file.hub, - false - ), + const firstArgument = path.get("arguments")[0] + + // i18n._(...) + if (!isI18nMethod(path.node.callee)) { + return } - if (!props.id) { + // call with explicit annotation + // i18n._(/*i18n*/ {descriptor}) + // skipping this as it is processed + // by ObjectExpression visitor + if (hasI18nComment(firstArgument.node)) { return } - const msgDescArg = path.node.arguments[2] + if (firstArgument.isObjectExpression()) { + // i8n._({message, id, context}) + extractFromMessageDescriptor(firstArgument, ctx) + return + } else { + // i18n._(id, variables, descriptor) + let props = { + id: getTextFromExpression( + t, + firstArgument.node as Expression, + ctx.file.hub, + false + ), + } - if (t.isObjectExpression(msgDescArg)) { - props = { - ...props, - ...extractFromObjectExpression(t, msgDescArg, ctx.file.hub, [ - "message", - "comment", - "context", - ]), + if (!props.id) { + return } - } - collectMessage(path, props, ctx) + const msgDescArg = path.node.arguments[2] + + if (t.isObjectExpression(msgDescArg)) { + props = { + ...props, + ...extractFromObjectExpression(t, msgDescArg, ctx.file.hub, [ + "message", + "comment", + "context", + ]), + } + } + + collectMessage(path, props, ctx) + } }, StringLiteral(path, ctx) { @@ -322,21 +333,7 @@ export default function ({ types: t }: { types: BabelTypes }): PluginObj { ObjectExpression(path, ctx) { if (!hasI18nComment(path.node)) return - const props = extractFromObjectExpression(t, path.node, ctx.file.hub, [ - "id", - "message", - "comment", - "context", - ]) - - if (!props.id) { - console.warn( - path.buildCodeFrameError("Missing message ID, skipping.").message - ) - return - } - - collectMessage(path, props, ctx) + extractFromMessageDescriptor(path, ctx) }, }, } 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 9f96c1883..1e5fda52f 100644 --- a/packages/babel-plugin-extract-messages/test/__snapshots__/index.ts.snap +++ b/packages/babel-plugin-extract-messages/test/__snapshots__/index.ts.snap @@ -9,7 +9,7 @@ exports[`@lingui/babel-plugin-extract-messages CallExpression i18n._() should ex message: undefined, origin: [ js-call-expression.js, - 2, + 1, ], }, { @@ -19,7 +19,7 @@ exports[`@lingui/babel-plugin-extract-messages CallExpression i18n._() should ex message: undefined, origin: [ js-call-expression.js, - 4, + 3, ], }, { @@ -29,7 +29,7 @@ exports[`@lingui/babel-plugin-extract-messages CallExpression i18n._() should ex message: Message with id, origin: [ js-call-expression.js, - 6, + 5, ], }, { @@ -39,7 +39,7 @@ exports[`@lingui/babel-plugin-extract-messages CallExpression i18n._() should ex message: undefined, origin: [ js-call-expression.js, - 8, + 7, ], }, { @@ -49,22 +49,37 @@ exports[`@lingui/babel-plugin-extract-messages CallExpression i18n._() should ex message: undefined, origin: [ js-call-expression.js, - 10, + 9, ], }, -] -`; - -exports[`@lingui/babel-plugin-extract-messages CallExpression i18n._() should extract messages from i18n.t aliased expression 1`] = ` -[ { - comment: Your comment, + comment: My comment, context: undefined, - id: your.id, - message: Your Id Message, + id: my.id, + message: My Id Message, origin: [ - node-call-expression-aliased.js, - 3, + js-call-expression.js, + 12, + ], + }, + { + comment: undefined, + context: undefined, + id: Aliased Message, + message: undefined, + origin: [ + js-call-expression.js, + 19, + ], + }, + { + comment: My comment, + context: undefined, + id: my.id, + message: My Id Message, + origin: [ + js-call-expression.js, + 22, ], }, ] diff --git a/packages/babel-plugin-extract-messages/test/fixtures/js-call-expression.js b/packages/babel-plugin-extract-messages/test/fixtures/js-call-expression.js index cda677d9f..19bc69707 100644 --- a/packages/babel-plugin-extract-messages/test/fixtures/js-call-expression.js +++ b/packages/babel-plugin-extract-messages/test/fixtures/js-call-expression.js @@ -1,12 +1,26 @@ +const msg = i18n._("Message") -const msg = i18n._('Message') +const withDescription = i18n._("Description", {}, { comment: "description" }) -const withDescription = i18n._('Description', {}, { comment: "description"}); +const withId = i18n._("ID", {}, { message: "Message with id" }) -const withId = i18n._('ID', {}, { message: 'Message with id' }); +const withValues = i18n._("Values {param}", { param: param }) -const withValues = i18n._('Values {param}', { param: param }); +const withContext = i18n._("Some id", {}, { context: "Context1" }) -const withContext = i18n._('Some id', {},{ context: 'Context1'}); +// from message descriptor +i18n._({ + id: "my.id", + message: "My Id Message", + comment: "My comment", +}) -const withTMessageDescriptor = i18n.t({ id: 'my.id', message: 'My Id Message', comment: 'My comment'}); \ No newline at end of file +// support alias +i18n.t("Aliased Message") + +// from message descriptor +i18n.t({ + id: "my.id", + message: "My Id Message", + comment: "My comment", +}) diff --git a/packages/babel-plugin-extract-messages/test/fixtures/node-call-expression-aliased.js b/packages/babel-plugin-extract-messages/test/fixtures/node-call-expression-aliased.js deleted file mode 100644 index 915a468e3..000000000 --- a/packages/babel-plugin-extract-messages/test/fixtures/node-call-expression-aliased.js +++ /dev/null @@ -1,3 +0,0 @@ -import { i18n as lingui } from '@lingui/core'; - -const withAliasedTMessageDescriptor = lingui.t({ id: 'your.id', message: 'Your Id Message', comment: 'Your comment'}); \ No newline at end of file diff --git a/packages/babel-plugin-extract-messages/test/fixtures/two-imports-renaming-esbuild.js b/packages/babel-plugin-extract-messages/test/fixtures/two-imports-renaming-esbuild.js deleted file mode 100644 index ff4f71e8a..000000000 --- a/packages/babel-plugin-extract-messages/test/fixtures/two-imports-renaming-esbuild.js +++ /dev/null @@ -1,11 +0,0 @@ -// packages/cli/test/extractor-experimental/fixtures/pages/about.page.ts -import { t as t2 } from "@lingui/macro"; - -// packages/cli/test/extractor-experimental/fixtures/components/header.ts -import { t } from "@lingui/macro"; -var msg = t`header message`; - -// packages/cli/test/extractor-experimental/fixtures/pages/about.page.ts -var msg2 = t2`about page message`; -console.log(msg2, msg); -//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiLi4vLi4vLi4vLi4vLi4vLi4vLi4vLi4vVXNlcnMvdGltL3Byb2plY3RzL2pzLWxpbmd1aS9wYWNrYWdlcy9jbGkvdGVzdC9leHRyYWN0b3ItZXhwZXJpbWVudGFsL2ZpeHR1cmVzL3BhZ2VzL2Fib3V0LnBhZ2UudHMiLCAiLi4vLi4vLi4vLi4vLi4vLi4vLi4vLi4vVXNlcnMvdGltL3Byb2plY3RzL2pzLWxpbmd1aS9wYWNrYWdlcy9jbGkvdGVzdC9leHRyYWN0b3ItZXhwZXJpbWVudGFsL2ZpeHR1cmVzL2NvbXBvbmVudHMvaGVhZGVyLnRzIl0sCiAgInNvdXJjZXNDb250ZW50IjogWyJpbXBvcnQgeyB0IH0gZnJvbSBcIkBsaW5ndWkvbWFjcm9cIlxuaW1wb3J0IHsgbXNnIGFzIGhlYWRlck1zZyB9IGZyb20gXCIuLi9jb21wb25lbnRzL2hlYWRlclwiXG5cbmNvbnN0IG1zZyA9IHRgYWJvdXQgcGFnZSBtZXNzYWdlYFxuXG5jb25zb2xlLmxvZyhtc2csIGhlYWRlck1zZylcbiIsICJpbXBvcnQgeyB0IH0gZnJvbSBcIkBsaW5ndWkvbWFjcm9cIlxuXG5leHBvcnQgY29uc3QgbXNnID0gdGBoZWFkZXIgbWVzc2FnZWBcbiJdLAogICJtYXBwaW5ncyI6ICI7QUFBQSxTQUFTLEtBQUFBLFVBQVM7OztBQ0FsQixTQUFTLFNBQVM7QUFFWCxJQUFNLE1BQU07OztBRENuQixJQUFNQyxPQUFNQztBQUVaLFFBQVEsSUFBSUQsTUFBSyxHQUFTOyIsCiAgIm5hbWVzIjogWyJ0IiwgIm1zZyIsICJ0Il0KfQo= diff --git a/packages/babel-plugin-extract-messages/test/index.ts b/packages/babel-plugin-extract-messages/test/index.ts index f7ef9eaec..c0aa13182 100644 --- a/packages/babel-plugin-extract-messages/test/index.ts +++ b/packages/babel-plugin-extract-messages/test/index.ts @@ -127,10 +127,25 @@ import { Trans } from "@lingui/react"; }) }) - it("should extract messages from i18n.t aliased expression", () => { + it("should extract from member access expressions", () => { + const code = ` + // member access + ctx.i18n._("Message") + + // member access any depth + ctx.req.i18n._("Message") + + // should not extract if disabled via annotation + + /* lingui-extract-ignore */ + ctx.i18n._("Message") + + /* lingui-extract-ignore */ + ctx.req.i18n._("Message") + ` expectNoConsole(() => { - const messages = transform("node-call-expression-aliased.js") - expect(messages).toMatchSnapshot() + const messages = transformCode(code) + expect(messages.length).toBe(2) }) }) diff --git a/packages/core/__typetests__/index.test-d.tsx b/packages/core/__typetests__/index.test-d.tsx new file mode 100644 index 000000000..f637de362 --- /dev/null +++ b/packages/core/__typetests__/index.test-d.tsx @@ -0,0 +1,57 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { expectType } from "tsd" +import { i18n } from "@lingui/core" + +expectType(i18n._("message.id")) +expectType( + i18n._({ + id: "message.id", + message: "Message", + }) +) +expectType( + i18n._( + "message.id", + { name: "Tim" }, + { message: "Hello {name}", comment: "", formats: {} } + ) +) +expectType( + i18n._( + // @ts-expect-error you could not use message descriptor together with rest of params + { + id: "message.id", + message: "Message", + }, + { name: "Tim" }, + { message: "Hello {name}", comment: "", formats: {} } + ) +) + +expectType(i18n.t("message.id")) +expectType( + i18n.t({ + id: "message.id", + message: "Message", + }) +) + +expectType( + i18n.t( + "message.id", + { name: "Tim" }, + { message: "Hello {name}", comment: "", formats: {} } + ) +) + +expectType( + i18n.t( + // @ts-expect-error you could not use message descriptor together with rest of params + { + id: "message.id", + message: "Message", + }, + { name: "Tim" }, + { message: "Hello {name}", comment: "", formats: {} } + ) +) diff --git a/packages/core/__typetests__/tsconfig.json b/packages/core/__typetests__/tsconfig.json new file mode 100644 index 000000000..bb01d834e --- /dev/null +++ b/packages/core/__typetests__/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "jsx": "react", + "esModuleInterop": true, + "skipLibCheck": true + }, + "paths": {} +} diff --git a/packages/core/src/i18n.test.ts b/packages/core/src/i18n.test.ts index 33af886be..709894add 100644 --- a/packages/core/src/i18n.test.ts +++ b/packages/core/src/i18n.test.ts @@ -206,6 +206,9 @@ describe("I18n", () => { "Je m'appelle Fred" ) + // alias + expect(i18n.t("Hello")).toEqual("Salut") + // missing { name } expect(i18n._("My name is {name}")).toEqual("Je m'appelle") @@ -284,91 +287,8 @@ describe("I18n", () => { id: "missing", locale: "en", }) - }) - - it(".t should format message from catalog", () => { - const messages = { - Hello: "Salut", - "My name is {name}": "Je m'appelle {name}", - } - - const i18n = setupI18n({ - locale: "fr", - messages: { fr: messages }, - }) - - expect(i18n.t({ id: "Hello" })).toEqual("Salut") - expect( - i18n.t({ id: "My name is {name}", values: { name: "Fred" } }) - ).toEqual("Je m'appelle Fred") - - // missing { name } - expect(i18n.t({ id: "My name is {name}" })).toEqual("Je m'appelle") - - // Untranslated message - expect(i18n.t({ id: "Missing message" })).toEqual("Missing message") - expect(i18n.t({ id: "Missing {name}", values: { name: "Fred" } })).toEqual( - "Missing Fred" - ) - expect( - i18n.t({ - id: "Missing with default", - message: "Missing {name}", - values: { name: "Fred" }, - }) - ).toEqual("Missing Fred") - }) - - it(".t allow escaping syntax characters", () => { - const messages = { - "My ''name'' is '{name}'": "Mi ''nombre'' es '{name}'", - } - - const i18n = setupI18n({ - locale: "es", - messages: { es: messages }, - }) - - expect(i18n.t({ id: "My ''name'' is '{name}'" })).toEqual( - "Mi 'nombre' es {name}" - ) - }) - - it(".t shouldn't compile messages in production", () => { - const messages = { - Hello: "Salut", - "My name is {name}": "Je m'appelle {name}", - } - - mockEnv("production", () => { - const { setupI18n } = require("@lingui/core") - const i18n = setupI18n({ - locale: "fr", - messages: { fr: messages }, - }) - - expect(i18n.t({ id: "My name is {name}" }, { name: "Fred" })).toEqual( - "Je m'appelle {name}" - ) - }) - }) - - it(".t should emit missing event for missing translation", () => { - const i18n = setupI18n({ - locale: "en", - messages: { en: { exists: "exists" } }, - }) - - const handler = jest.fn() - i18n.on("missing", handler) - i18n.t({ id: "exists" }) - expect(handler).toHaveBeenCalledTimes(0) - i18n.t({ id: "missing" }) - expect(handler).toHaveBeenCalledTimes(1) - expect(handler).toHaveBeenCalledWith({ - id: "missing", - locale: "en", - }) + i18n.t("missing") + expect(handler).toHaveBeenCalledTimes(2) }) describe("params.missing - handling missing translations", () => { diff --git a/packages/core/src/i18n.ts b/packages/core/src/i18n.ts index 0cc81051e..89038c2c3 100644 --- a/packages/core/src/i18n.ts +++ b/packages/core/src/i18n.ts @@ -8,6 +8,7 @@ import type { CompiledMessage } from "@lingui/message-utils/compileMessage" export type MessageOptions = { message?: string formats?: Formats + comment?: string } export type { CompiledMessage } @@ -212,6 +213,8 @@ export class I18n extends EventEmitter { } // method for translation and formatting + _(descriptor: MessageDescriptor): string + _(id: string, values?: Values, options?: MessageOptions): string _( id: MessageDescriptor | string, values: Values | undefined = {}, @@ -255,11 +258,10 @@ export class I18n extends EventEmitter { )(values, formats) } - // Alternative to _. Can be used in node/js without macros - // uses message descriptor only - t(descriptor: MessageDescriptor) { - return this._(descriptor) - } + /** + * Alias for {@see I18n._} + */ + t: I18n["_"] = this._.bind(this) date(value: string | Date, format?: Intl.DateTimeFormatOptions): string { return date(this._locales || this._locale, value, format) diff --git a/website/docs/ref/core.md b/website/docs/ref/core.md index 3b6ccab67..96f2795d1 100644 --- a/website/docs/ref/core.md +++ b/website/docs/ref/core.md @@ -153,9 +153,7 @@ i18n._("My name is {name}", { name: "Tom" }) i18n._("msg.id", { name: "Tom" }, { message: "My name is {name}" }) ``` -### `i18n.t(messageDescriptor) (experimental)` {#i18n.t} - -A small wrapper on the core translation meant for NodeJS/JS usage without macros. It uses the core `_` method, but currently only accepts message descriptor. This API is prone to breaking changes. +### `i18n._(messageDescriptor)` `messageDescriptor` is an object of message parameters. @@ -163,16 +161,28 @@ A small wrapper on the core translation meant for NodeJS/JS usage without macros import { i18n } from "@lingui/core" // Simple message -i18n.t({ id: "Hello" }) +i18n._({ id: "Hello" }) // Simple message using custom ID -i18n.t({ id: "msg.hello", message: "Hello"}) +i18n._({ id: "msg.hello", message: "Hello"}) // Message with variable -i18n.t({ id: "My name is {name}", values: { name: "Tom" } }); +i18n._({ id: "My name is {name}", values: { name: "Tom" } }); // Message with comment, custom ID and variable +i18n._({ id: "msg.name", message: "My name is {name}", comment: "Message showing the passed in name", values: { name: "Tom" } }); +``` + +### `i18n.t(...)` {#i18n.t} + +Alias for [`i18n._`](#i18n._) + +```ts +import { i18n } from "@lingui/core" + i18n.t({ id: "msg.name", message: "My name is {name}", comment: "Message showing the passed in name", values: { name: "Tom" } }); + +i18n.t("msg.id", { name: "Tom" }, { message: "My name is {name}" }) ``` ### `i18n.date(value: string | Date[, format: Intl.DateTimeFormatOptions])` {#i18n.date} @@ -299,7 +309,7 @@ Initial [`Messages`](#messages). ```tsx import { setupI18n } from "@lingui/core" -const messages: { +const messages = { en: require("./locale/en/messages").messages, // your path to compiled messages here cs: require("./locale/cs/messages").messages // your path to compiled messages here }