diff --git a/packages/babel-plugin-extract-messages/src/index.ts b/packages/babel-plugin-extract-messages/src/index.ts index a70522d4f..c850b51bd 100644 --- a/packages/babel-plugin-extract-messages/src/index.ts +++ b/packages/babel-plugin-extract-messages/src/index.ts @@ -132,6 +132,7 @@ function extractFromObjectExpression( export default function ({ types: t }: { types: BabelTypes }): PluginObj { let localTransComponentName: string + let localCoreI18nName: string function isTransComponent(node: Node) { return ( @@ -147,6 +148,11 @@ export default function ({ types: t }: { types: BabelTypes }): PluginObj { 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" }) + function hasI18nComment(node: Node): boolean { return ( node.leadingComments && @@ -176,6 +182,17 @@ 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. @@ -226,6 +243,25 @@ export default function ({ types: t }: { types: BabelTypes }): PluginObj { 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) + 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 @@ -234,7 +270,7 @@ export default function ({ types: t }: { types: BabelTypes }): PluginObj { isI18nMethod(path.node.callee) && !firstArgument?.leadingComments if (!hasComment && !isNonMacroI18n) return - let props: Record = { + props = { id: getTextFromExpression( t, firstArgument as Expression, @@ -284,9 +320,7 @@ export default function ({ types: t }: { types: BabelTypes }): PluginObj { // Extract message descriptors ObjectExpression(path, ctx) { - if (!hasI18nComment(path.node)) { - return - } + if (!hasI18nComment(path.node)) return const props = extractFromObjectExpression(t, path.node, ctx.file.hub, [ "id", 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 51760bd42..9f96c1883 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, - 1, + 2, ], }, { @@ -19,7 +19,7 @@ exports[`@lingui/babel-plugin-extract-messages CallExpression i18n._() should ex message: undefined, origin: [ js-call-expression.js, - 3, + 4, ], }, { @@ -29,7 +29,7 @@ exports[`@lingui/babel-plugin-extract-messages CallExpression i18n._() should ex message: Message with id, origin: [ js-call-expression.js, - 5, + 6, ], }, { @@ -39,7 +39,7 @@ exports[`@lingui/babel-plugin-extract-messages CallExpression i18n._() should ex message: undefined, origin: [ js-call-expression.js, - 7, + 8, ], }, { @@ -49,7 +49,22 @@ exports[`@lingui/babel-plugin-extract-messages CallExpression i18n._() should ex message: undefined, origin: [ js-call-expression.js, - 9, + 10, + ], + }, +] +`; + +exports[`@lingui/babel-plugin-extract-messages CallExpression i18n._() should extract messages from i18n.t aliased expression 1`] = ` +[ + { + comment: Your comment, + context: undefined, + id: your.id, + message: Your Id Message, + origin: [ + node-call-expression-aliased.js, + 3, ], }, ] 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 3ecdc0078..cda677d9f 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,3 +1,4 @@ + const msg = i18n._('Message') const withDescription = i18n._('Description', {}, { comment: "description"}); @@ -8,3 +9,4 @@ const withValues = i18n._('Values {param}', { param: param }); const withContext = i18n._('Some id', {},{ context: 'Context1'}); +const withTMessageDescriptor = i18n.t({ id: 'my.id', message: 'My Id Message', comment: 'My comment'}); \ No newline at end of file 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 new file mode 100644 index 000000000..915a468e3 --- /dev/null +++ b/packages/babel-plugin-extract-messages/test/fixtures/node-call-expression-aliased.js @@ -0,0 +1,3 @@ +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/index.ts b/packages/babel-plugin-extract-messages/test/index.ts index 2cbf6ca46..f7ef9eaec 100644 --- a/packages/babel-plugin-extract-messages/test/index.ts +++ b/packages/babel-plugin-extract-messages/test/index.ts @@ -127,6 +127,13 @@ import { Trans } from "@lingui/react"; }) }) + it("should extract messages from i18n.t aliased expression", () => { + expectNoConsole(() => { + const messages = transform("node-call-expression-aliased.js") + expect(messages).toMatchSnapshot() + }) + }) + it("Should not rise warning when translation from variable", () => { const code = ` i18n._(message); diff --git a/packages/core/src/i18n.test.ts b/packages/core/src/i18n.test.ts index 074842397..33af886be 100644 --- a/packages/core/src/i18n.test.ts +++ b/packages/core/src/i18n.test.ts @@ -286,6 +286,91 @@ describe("I18n", () => { }) }) + 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", + }) + }) + describe("params.missing - handling missing translations", () => { it("._ should return custom string for missing translations", () => { const i18n = setupI18n({ diff --git a/packages/core/src/i18n.ts b/packages/core/src/i18n.ts index 59a706111..49ad7444c 100644 --- a/packages/core/src/i18n.ts +++ b/packages/core/src/i18n.ts @@ -262,6 +262,12 @@ 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) + } + 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 f4f909b72..2e3935df7 100644 --- a/website/docs/ref/core.md +++ b/website/docs/ref/core.md @@ -153,6 +153,28 @@ 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 + +`messageDescriptor` is an object of message parameters. + +```ts +import { i18n } from "@lingui/core" + +// Simple message +i18n.t({ id: "Hello" }) + +// Simple message using custom ID +i18n.t({ id: "msg.hello", message: "Hello"}) + +// Message with variable +i18n.t({ id: "My name is {name}", values: { name: "Tom" } }); + +// Message with comment, custom ID and variable +i18n.t({ id: "msg.name", message: "My name is {name}", comment: "Message showing the passed in name", values: { name: "Tom" } }); +``` + ### `i18n.date(value: string | Date[, format: Intl.DateTimeFormatOptions])` {#i18n.date} > **Returns**: Formatted date string Format a date using the conventional format for the active language.