From 5cad96fc68a4219178d6caf6ad5c02e2f68f68fa Mon Sep 17 00:00:00 2001 From: Jeroen Visser Date: Tue, 28 Sep 2021 17:51:39 +0200 Subject: [PATCH] feat(macro): Add support for passing custom i18n instance (#1139) --- docs/ref/macro.rst | 30 +++++- packages/macro/global.d.ts | 160 +++++++++++++++++++++++++++-- packages/macro/index.d.ts | 154 +++++++++++++++++++++++++-- packages/macro/src/index.ts | 5 +- packages/macro/src/macroJs.test.ts | 38 ++++--- packages/macro/src/macroJs.ts | 50 +++++++-- packages/macro/test/js-t.ts | 14 +++ 7 files changed, 410 insertions(+), 41 deletions(-) diff --git a/docs/ref/macro.rst b/docs/ref/macro.rst index 3d955dc47..0b0b8d39a 100644 --- a/docs/ref/macro.rst +++ b/docs/ref/macro.rst @@ -77,7 +77,7 @@ transformed into ``i18n._`` call. By default, the ``i18n`` object is imported from ``@lingui/core``. If you use a custom instance of ``i18n`` object, you need to set - :conf:`runtimeConfigModule` + :conf:`runtimeConfigModule` or pass a custom instance to :jsmacro:`t`. The only exception is :jsmacro:`defineMessage` which is transformed into message descriptor. In other words, the message isn't translated directly @@ -122,6 +122,16 @@ Examples of JS macros +-------------------------------------------------------------+--------------------------------------------------------------------+ | .. code-block:: js | .. code-block:: js | | | | +| t(customI18n)`Refresh inbox` | /*i18n*/ | +| | customI18n._("Refresh inbox") | ++-------------------------------------------------------------+--------------------------------------------------------------------+ +| .. code-block:: js | .. code-block:: js | +| | | +| t(customI18n)`Attachment ${name} saved` | /*i18n*/ | +| | customI18n._("Attachment {name} saved", { name }) | ++-------------------------------------------------------------+--------------------------------------------------------------------+ +| .. code-block:: js | .. code-block:: js | +| | | | plural(count, { | /*i18n*/ | | one: "Message", | i18n._("{count, plural, one {Message} other {Messages}}", { | | other: "Messages" | count | @@ -184,7 +194,7 @@ into a *Message Descriptor* wrapped inside of ``i18n._`` call. By default, the ``i18n`` object is imported from ``@lingui/core``. If you use a custom instance of ``i18n`` object, you need to set - :conf:`runtimeConfigModule` + :conf:`runtimeConfigModule` or pass a custom instance to :jsmacro:`t`. *Message Descriptor* is an object with message ID, default message and other parameters. ``i18n._`` accepts message descriptors and performs translation and formatting: @@ -265,6 +275,22 @@ other expressions are referenced by numeric index: 0: new Date() }); +Optionally, a custom ``i18n`` instance can be passed that can be used +instead of the global instance: + +.. code-block:: jsx + + import { t } from "@lingui/macro" + import { i18n } from "./lingui" + const message = t(i18n)`Hello World` + + // ↓ ↓ ↓ ↓ ↓ ↓ + + import { i18n } from "./lingui" + const message = + /*i18n*/ + i18n._("Hello World") + It's also possible to pass custom ``id`` and ``comment`` for translators by calling ``t`` macro with a message descriptor: diff --git a/packages/macro/global.d.ts b/packages/macro/global.d.ts index 674266eb1..25a92329d 100644 --- a/packages/macro/global.d.ts +++ b/packages/macro/global.d.ts @@ -1,13 +1,68 @@ -declare module '@lingui/macro' { - import type { MessageDescriptor } from "@lingui/core" +declare module "@lingui/macro" { + import type { MessageDescriptor, I18n } from "@lingui/core" export type BasicType = { id?: string comment?: string } + /** + * Translates a message descriptor + * + * @example + * ``` + * import { t } from "@lingui/macro"; + * const message = t({ + * id: "msg.hello", + * comment: "Greetings at the homepage", + * message: `Hello ${name}`, + * }); + * ``` + * + * @example + * ``` + * import { t } from "@lingui/macro"; + * const message = t({ + * id: "msg.plural", + * message: plural(value, { one: "...", other: "..." }), + * }); + * ``` + * + * @param messageDescriptior The descriptor to translate + */ + export function t(messageDescriptior: MessageDescriptor): string + + /** + * Translates a template string using the global I18n instance + * + * @example + * ``` + * import { t } from "@lingui/macro"; + * const message = t`Hello ${name}`; + * ``` + */ + export function t( + literals: TemplateStringsArray, + ...placeholders: any[] + ): string + + /** + * Translates a template string using a given I18n instance + * + * @example + * ``` + * import { t } from "@lingui/macro"; + * import { I18n } from "@lingui/core"; + * const i18n = new I18n({ + * locale: "nl", + * messages: { "Hello {0}": "Hallo {0}" }, + * }); + * const message = t(i18n)`Hello ${name}`; + * ``` + */ export function t( - literals: TemplateStringsArray | MessageDescriptor, + i18n: I18n, + literals: TemplateStringsArray, ...placeholders: any[] ): string @@ -21,20 +76,107 @@ declare module '@lingui/macro' { other?: T } & UnderscoreDigit - export function plural(arg: number | string, options: ChoiceOptions & BasicType): string + /** + * Pluralize a message + * + * @example + * ``` + * import { plural } from "@lingui/macro"; + * const message = plural(count, { + * one: "# Book", + * other: "# Books", + * }); + * ``` + * + * @param value Determines the plural form + * @param options Object with available plural forms + */ + export function plural( + value: number | string, + options: ChoiceOptions & BasicType + ): string + + /** + * Pluralize a message using ordinal forms + * + * Similar to `plural` but instead of using cardinal plural forms, + * it uses ordinal forms. + * + * @example + * ``` + * import { selectOrdinal } from "@lingui/macro"; + * const message = selectOrdinal(count, { + * one: "1st", + * two: "2nd", + * few: "3rd", + * other: "#th", + * }); + * ``` + * + * @param value Determines the plural form + * @param options Object with available plural forms + */ export function selectOrdinal( - arg: number | string, + value: number | string, options: ChoiceOptions & BasicType ): string - export function select(arg: string, choices: Record & BasicType): string + + /** + * Selects a translation based on a value + * + * Select works like a switch statement. It will + * select one of the forms in `options` object which + * key matches exactly `value`. + * + * @example + * ``` + * import { select } from "@lingui/macro"; + * const message = select(gender, { + * male: "he", + * female: "she", + * other: "they", + * }); + * ``` + * + * @param value The key of choices to use + * @param choices + */ + export function select( + value: string, + choices: Record & BasicType + ): string + + /** + * Defines multiple messages for extraction + */ export function defineMessages>( messages: M ): M - export function defineMessage(descriptor: MessageDescriptor): MessageDescriptor + + /** + * Define a message for later use + * + * `defineMessage` can be used to add comments for translators, + * or to override the message ID. + * + * @example + * ``` + * import { defineMessage } from "@lingui/macro"; + * const message = defineMessage({ + * comment: "Greetings on the welcome page", + * message: `Welcome, ${name}!`, + * }); + * ``` + * + * @param descriptor The message descriptor + */ + export function defineMessage( + descriptor: MessageDescriptor + ): MessageDescriptor export type ChoiceProps = { value?: string | number - } & ChoiceOptions + } & ChoiceOptions /** * The types should be changed after this PR is merged @@ -60,4 +202,4 @@ declare module '@lingui/macro' { export const Plural: any export const Select: any export const SelectOrdinal: any -} \ No newline at end of file +} diff --git a/packages/macro/index.d.ts b/packages/macro/index.d.ts index 46e10a36c..0a0ffe3e3 100644 --- a/packages/macro/index.d.ts +++ b/packages/macro/index.d.ts @@ -1,12 +1,7 @@ import type { ReactElement, ComponentType, ReactNode } from "react" -import type { MessageDescriptor } from "@lingui/core" +import type { MessageDescriptor, I18n } from "@lingui/core" import type { TransRenderProps } from "@lingui/react" -export function t( - literals: TemplateStringsArray | MessageDescriptor, - ...placeholders: any[] -): string - export type UnderscoreDigit = { [digit: string]: T } export type ChoiceOptions = { offset?: number @@ -17,15 +12,156 @@ export type ChoiceOptions = { other?: T } & UnderscoreDigit -export function plural(arg: number | string, options: ChoiceOptions): string +/** + * Translates a message descriptor + * + * @example + * ``` + * import { t } from "@lingui/macro"; + * const message = t({ + * id: "msg.hello", + * comment: "Greetings at the homepage", + * message: `Hello ${name}`, + * }); + * ``` + * + * @example + * ``` + * import { t } from "@lingui/macro"; + * const message = t({ + * id: "msg.plural", + * message: plural(value, { one: "...", other: "..." }), + * }); + * ``` + * + * @param messageDescriptior The descriptor to translate + */ +export function t(messageDescriptior: MessageDescriptor): string + +/** + * Translates a template string using the global I18n instance + * + * @example + * ``` + * import { t } from "@lingui/macro"; + * const message = t`Hello ${name}`; + * ``` + */ +export function t( + literals: TemplateStringsArray, + ...placeholders: any[] +): string + +/** + * Translates a template string using a given I18n instance + * + * @example + * ``` + * import { t } from "@lingui/macro"; + * import { I18n } from "@lingui/core"; + * const i18n = new I18n({ + * locale: "nl", + * messages: { "Hello {0}": "Hallo {0}" }, + * }); + * const message = t(i18n)`Hello ${name}`; + * ``` + */ +export function t( + i18n: I18n, + literals: TemplateStringsArray, + ...placeholders: any[] +): string + +/** + * Pluralize a message + * + * @example + * ``` + * import { plural } from "@lingui/macro"; + * const message = plural(count, { + * one: "# Book", + * other: "# Books", + * }); + * ``` + * + * @param value Determines the plural form + * @param options Object with available plural forms + */ +export function plural(value: number | string, options: ChoiceOptions): string + +/** + * Pluralize a message using ordinal forms + * + * Similar to `plural` but instead of using cardinal plural forms, + * it uses ordinal forms. + * + * @example + * ``` + * import { selectOrdinal } from "@lingui/macro"; + * const message = selectOrdinal(count, { + * one: "1st", + * two: "2nd", + * few: "3rd", + * other: "#th", + * }); + * ``` + * + * @param value Determines the plural form + * @param options Object with available plural forms + */ export function selectOrdinal( - arg: number | string, + value: number | string, options: ChoiceOptions ): string -export function select(arg: string, choices: Record): string + +/** + * Selects a translation based on a value + * + * Select works like a switch statement. It will + * select one of the forms in `options` object which + * key matches exactly `value`. + * + * @example + * ``` + * import { select } from "@lingui/macro"; + * const message = select(gender, { + * male: "he", + * female: "she", + * other: "they", + * }); + * ``` + * + * @param value The key of choices to use + * @param choices + */ +export function select(value: string, choices: ChoiceOptions): string + +/** + * Defines multiple messages for extraction + * + * @see {@link defineMessage} for more details + */ export function defineMessages>( messages: M ): M + +/** + * Define a message for later use + * + * `defineMessage` can be used to add comments for translators, + * or to override the message ID. + * + * @example + * ``` + * import { defineMessage } from "@lingui/macro"; + * const message = defineMessage({ + * comment: "Greetings on the welcome page", + * message: `Welcome, ${name}!`, + * }); + * ``` + * + * @param descriptor The message descriptor + */ export function defineMessage(descriptor: MessageDescriptor): MessageDescriptor export type TransProps = { diff --git a/packages/macro/src/index.ts b/packages/macro/src/index.ts index 5163c7f66..e22e4de22 100644 --- a/packages/macro/src/index.ts +++ b/packages/macro/src/index.ts @@ -30,6 +30,7 @@ const [TransImportModule, TransImportName = "Trans"] = getSymbolSource("Trans") function macro({ references, state, babel }) { const jsxNodes = [] const jsNodes = [] + let needsI18nImport = false Object.keys(references).forEach((tagName) => { const nodes = references[tagName] @@ -53,7 +54,7 @@ function macro({ references, state, babel }) { jsNodes.filter(isRootPath(jsNodes)).forEach((path) => { if (alreadyVisited(path)) return const macro = new MacroJS(babel, { i18nImportName }) - macro.replacePath(path) + if (macro.replacePath(path)) needsI18nImport = true }) jsxNodes.filter(isRootPath(jsxNodes)).forEach((path) => { @@ -62,7 +63,7 @@ function macro({ references, state, babel }) { macro.replacePath(path) }) - if (jsNodes.length) { + if (needsI18nImport) { addImport(babel, state, i18nImportModule, i18nImportName) } diff --git a/packages/macro/src/macroJs.test.ts b/packages/macro/src/macroJs.test.ts index 558c8519a..6284e7db6 100644 --- a/packages/macro/src/macroJs.test.ts +++ b/packages/macro/src/macroJs.test.ts @@ -20,6 +20,18 @@ describe("js macro", () => { ]) }) + it("with custom lingui instance", () => { + const macro = createMacro() + const exp = parseExpression("t(i18n)`Message`") + const tokens = macro.tokenizeTemplateLiteral(exp) + expect(tokens).toEqual([ + { + type: "text", + value: "Message", + }, + ]) + }) + it("message with named argument", () => { const macro = createMacro() const exp = parseExpression("t`Message ${name}`") @@ -82,50 +94,52 @@ describe("js macro", () => { it("message with unicode \\u chars is interpreted by babel", () => { const macro = createMacro() - const exp = parseExpression('t`Message \\u0020`') + const exp = parseExpression("t`Message \\u0020`") const tokens = macro.tokenizeTemplateLiteral(exp) expect(tokens).toEqual([ { type: "text", - value: 'Message ', + value: "Message ", }, ]) }) it("message with unicode \\x chars is interpreted by babel", () => { const macro = createMacro() - const exp = parseExpression('t`Bienvenue\\xA0!`') + const exp = parseExpression("t`Bienvenue\\xA0!`") const tokens = macro.tokenizeTemplateLiteral(exp) expect(tokens).toEqual([ { type: "text", - // Looks like an empty space, but it isn't - value: 'Bienvenue !', + // Looks like an empty space, but it isn't + value: "Bienvenue !", }, ]) }) it("message with double scaped literals it's stripped", () => { const macro = createMacro() - const exp = parseExpression('t\`Passing \\`${argSet}\\` is not supported.\`') + const exp = parseExpression( + "t`Passing \\`${argSet}\\` is not supported.`" + ) const tokens = macro.tokenizeTemplateLiteral(exp) expect(tokens).toEqual([ { type: "text", - value: 'Passing `', + value: "Passing `", }, { name: "argSet", type: "arg", value: { end: 20, - loc: { - end: { + loc: { + end: { column: 20, line: 1, }, identifierName: "argSet", - start: { + start: { column: 14, line: 1, }, @@ -298,7 +312,7 @@ describe("js macro", () => { female: "she", male: "he", offset: undefined, - other: "they" + other: "they", }), type: "arg", value: { @@ -317,7 +331,7 @@ describe("js macro", () => { name: "gender", start: 7, type: "Identifier", - } + }, }) }) }) diff --git a/packages/macro/src/macroJs.ts b/packages/macro/src/macroJs.ts index 3c5de09d0..d840f4e46 100644 --- a/packages/macro/src/macroJs.ts +++ b/packages/macro/src/macroJs.ts @@ -31,7 +31,8 @@ export default class MacroJs { replacePathWithMessage = ( path: NodePath, - { id, message, values, comment } + { id, message, values, comment }, + linguiInstance?: babelTypes.Identifier ) => { const args = [] const options = [] @@ -75,7 +76,7 @@ export default class MacroJs { const newNode = this.types.callExpression( this.types.memberExpression( - this.types.identifier(this.i18nImportName), + linguiInstance ?? this.types.identifier(this.i18nImportName), this.types.identifier("_") ), args @@ -89,13 +90,42 @@ export default class MacroJs { path.replaceWith(newNode) } - replacePath = (path: NodePath) => { + // Returns a boolean indicating if the replacement requires i18n import + replacePath = (path: NodePath): boolean => { // reset the expression counter this._expressionIndex = makeCounter() if (this.isDefineMessage(path.node)) { this.replaceDefineMessage(path) - return + return true + } + + // t(i18nInstance)`Message` -> i18nInstance._('Message') + if ( + this.types.isCallExpression(path.node) && + this.types.isTaggedTemplateExpression(path.parentPath.node) && + this.types.isIdentifier(path.node.arguments[0]) && + this.isIdentifier(path.node.callee, "t") + ) { + // Use the first argument as i18n instance instead of the default i18n instance + const i18nInstance = path.node.arguments[0] + const tokens = this.tokenizeNode(path.parentPath.node) + + const messageFormat = new ICUMessageFormat() + const { + message: messageRaw, + values, + id, + comment, + } = messageFormat.fromTokens(tokens) + const message = normalizeWhitespace(messageRaw) + + this.replacePathWithMessage( + path.parentPath, + { id, message, values, comment }, + i18nInstance + ) + return false } if ( @@ -103,7 +133,7 @@ export default class MacroJs { this.isIdentifier(path.node.callee, "t") ) { this.replaceTAsFunction(path) - return + return true } const tokens = this.tokenizeNode(path.node) @@ -118,6 +148,8 @@ export default class MacroJs { const message = normalizeWhitespace(messageRaw) this.replacePathWithMessage(path, { id, message, values, comment }) + + return true } /** @@ -267,7 +299,11 @@ export default class MacroJs { // 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 + 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 { @@ -361,7 +397,7 @@ export default class MacroJs { /** * We clean '//\` ' to just '`' */ - clearBackslashes(value: string) { + clearBackslashes(value: string) { // if not we replace the extra scaped literals return value.replace(/\\`/g, "`") } diff --git a/packages/macro/test/js-t.ts b/packages/macro/test/js-t.ts index da0b7ee24..6da766848 100644 --- a/packages/macro/test/js-t.ts +++ b/packages/macro/test/js-t.ts @@ -12,6 +12,20 @@ export default [ i18n._("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\`; + `, + expected: ` + import { i18n } from './lingui'; + const a = + /*i18n*/ + i18n._("Expression assignment") + `, + }, { name: "Variables are replaced with named arguments", input: `