From d8d4565cfc7b063d3f2065ea8b7f74ee6534b16a Mon Sep 17 00:00:00 2001 From: Timofei Iatsenko Date: Thu, 22 Feb 2024 18:14:24 +0100 Subject: [PATCH] feat(macro): add useLingui macro #1852 --- packages/macro/__typetests__/index.test-d.tsx | 13 + packages/macro/index.d.ts | 32 ++ packages/macro/src/index.ts | 27 +- packages/macro/src/macroJs.ts | 99 +++++- packages/macro/test/index.ts | 1 + packages/macro/test/js-useLingui.ts | 282 ++++++++++++++++++ 6 files changed, 445 insertions(+), 9 deletions(-) create mode 100644 packages/macro/test/js-useLingui.ts diff --git a/packages/macro/__typetests__/index.test-d.tsx b/packages/macro/__typetests__/index.test-d.tsx index 771507296..e4bbbcefd 100644 --- a/packages/macro/__typetests__/index.test-d.tsx +++ b/packages/macro/__typetests__/index.test-d.tsx @@ -12,6 +12,7 @@ import { Plural, Select, SelectOrdinal, + useLingui, } from "../index" // eslint-disable-next-line import/no-extraneous-dependencies import React from "react" @@ -338,3 +339,15 @@ m = ( other={...} /> ) + +//////////////////////// +//// React useLingui() +//////////////////////// +function MyComponent() { + const { t } = useLingui() + + expectType(t`Hello world`) + expectType(t({ message: "my message" })) + // @ts-expect-error: you could not pass a custom instance here + expectType(t(i18n)({ message: "my message" })) +} diff --git a/packages/macro/index.d.ts b/packages/macro/index.d.ts index dca81ea01..aa279327f 100644 --- a/packages/macro/index.d.ts +++ b/packages/macro/index.d.ts @@ -316,3 +316,35 @@ export const SelectOrdinal: VFC * ``` */ export const Select: VFC + +export function _t(descriptor: MacroMessageDescriptor): string +export function _t( + literals: TemplateStringsArray, + ...placeholders: any[] +): string + +/** + * + * Return `t` macro function which is bound to i18n passed from React.Context + * + * Returned `t` macro function has all the same signatures as global `t` + * + * @example + * ``` + * const { t } = useLingui(); + * const message = t`Text`; + * ``` + * + * @example + * ``` + * const { t } = useLingui(); + * const message = t({ + * id: "msg.hello", + * comment: "Greetings at the homepage", + * message: `Hello ${name}`, + * }); + * ``` + */ +export const useLingui: () => { + t: typeof _t +} diff --git a/packages/macro/src/index.ts b/packages/macro/src/index.ts index ae0a41ae8..38b8a078d 100644 --- a/packages/macro/src/index.ts +++ b/packages/macro/src/index.ts @@ -10,6 +10,7 @@ import { isImportSpecifier, isIdentifier, JSXIdentifier, + Statement, } from "@babel/types" export type LinguiMacroOpts = { @@ -23,6 +24,7 @@ const jsMacroTags = new Set([ "msg", "arg", "t", + "useLingui", "plural", "select", "selectOrdinal", @@ -45,6 +47,8 @@ function getConfig(_config?: LinguiConfigNormalized) { function macro({ references, state, babel, config }: MacroParams) { const opts: LinguiMacroOpts = config as LinguiMacroOpts + const body = state.file.path.node.body + const { i18nImportModule, i18nImportName, @@ -55,6 +59,7 @@ function macro({ references, state, babel, config }: MacroParams) { const jsxNodes = new Set() const jsNodes = new Set() let needsI18nImport = false + let needsUseLinguiImport = false let nameMap = new Map() Object.keys(references).forEach((tagName) => { @@ -92,7 +97,9 @@ function macro({ references, state, babel, config }: MacroParams) { nameMap, }) try { - if (macro.replacePath(path)) needsI18nImport = true + macro.replacePath(path) + needsI18nImport = needsI18nImport || macro.needsI18nImport + needsUseLinguiImport = needsUseLinguiImport || macro.needsUseLinguiImport } catch (e) { reportUnsupportedSyntax(path, e as Error) } @@ -110,12 +117,16 @@ function macro({ references, state, babel, config }: MacroParams) { } }) + if (needsUseLinguiImport) { + addImport(babel, body, "@lingui/react", "useLingui") + } + if (needsI18nImport) { - addImport(babel, state, i18nImportModule, i18nImportName) + addImport(babel, body, i18nImportModule, i18nImportName) } if (jsxNodes.size) { - addImport(babel, state, TransImportModule, TransImportName) + addImport(babel, body, TransImportModule, TransImportName) } } @@ -130,13 +141,13 @@ function reportUnsupportedSyntax(path: NodePath, e: Error) { function addImport( babel: MacroParams["babel"], - state: MacroParams["state"], + body: Statement[], module: string, importName: string ) { const { types: t } = babel - const linguiImport = state.file.path.node.body.find( + const linguiImport = body.find( (importNode) => t.isImportDeclaration(importNode) && importNode.source.value === module && @@ -148,16 +159,16 @@ function addImport( // Handle adding the import or altering the existing import if (linguiImport) { if ( - linguiImport.specifiers.findIndex( + !linguiImport.specifiers.find( (specifier) => isImportSpecifier(specifier) && isIdentifier(specifier.imported, { name: importName }) - ) === -1 + ) ) { linguiImport.specifiers.push(t.importSpecifier(tIdentifier, tIdentifier)) } } else { - state.file.path.node.body.unshift( + body.unshift( t.importDeclaration( [t.importSpecifier(tIdentifier, tIdentifier)], t.stringLiteral(module) diff --git a/packages/macro/src/macroJs.ts b/packages/macro/src/macroJs.ts index 49bedba1d..7588cf05a 100644 --- a/packages/macro/src/macroJs.ts +++ b/packages/macro/src/macroJs.ts @@ -54,6 +54,9 @@ export default class MacroJs { nameMap: Map nameMapReversed: Map + needsUseLinguiImport = false + needsI18nImport = false + // Positional expressions counter (e.g. for placeholders `Hello {0}, today is {1}`) _expressionIndex = makeCounter() @@ -148,13 +151,105 @@ export default class MacroJs { this.isLinguiIdentifier(path.node.callee, "t") ) { this.replaceTAsFunction(path as NodePath) + this.needsI18nImport = true + return true } + // { t } = useLingui() + if ( + this.types.isCallExpression(path.node) && + this.isLinguiIdentifier(path.node.callee, "useLingui") && + this.types.isVariableDeclarator(path.parentPath.node) + ) { + this.needsUseLinguiImport = true + + const varDec = path.parentPath.node + const _property = this.types.isObjectPattern(varDec.id) + ? varDec.id.properties.find( + (property): property is ObjectProperty & { value: Identifier } => + this.types.isObjectProperty(property) && + this.types.isIdentifier(property.key) && + this.types.isIdentifier(property.value) && + property.key.name == "t" + ) + : null + + // Enforce destructuring `t` from `useLingui` macro to prevent misuse + if (!_property) { + throw new Error( + `Must destruct _ when using useLingui macro, i.e: +const { t } = useLingui() +or +const { t: _ } = useLingui()` + ) + } + + const uniqTIdentifier = path.scope.generateUidIdentifier("t") + + const newUseLinguiExpression = this.types.variableDeclarator( + this.types.objectPattern([ + this.types.objectProperty( + this.types.identifier("_"), + uniqTIdentifier + ), + ]), + this.types.callExpression(this.types.identifier("useLingui"), []) + ) + + path.parentPath.replaceWith(newUseLinguiExpression) + + path.scope + .getBinding(_property.value.name) + ?.referencePaths.forEach((refPath) => { + const currentPath = refPath.parentPath + + // { t } = useLingui() + // t`Hello!` + if (currentPath.isTaggedTemplateExpression()) { + const tokens = this.tokenizeTemplateLiteral(currentPath.node) + + const descriptor = this.createMessageDescriptorFromTokens( + tokens, + currentPath.node.loc + ) + + const callExpr = this.types.callExpression( + this.types.identifier(uniqTIdentifier.name), + [descriptor] + ) + + return currentPath.replaceWith(callExpr) + } + + // { t } = useLingui() + // t(messageDescriptor) + if ( + currentPath.isCallExpression() && + this.types.isExpression(currentPath.node.arguments[0]) + ) { + let descriptor = this.processDescriptor( + currentPath.node.arguments[0] + ) + const callExpr = this.types.callExpression( + this.types.identifier(uniqTIdentifier.name), + [descriptor] + ) + + return currentPath.replaceWith(callExpr) + } + + // for rest of cases just rename identifier for run-time counterpart + refPath.replaceWith(this.types.identifier(uniqTIdentifier.name)) + }) + return false + } + const tokens = this.tokenizeNode(path.node) this.replacePathWithMessage(path, tokens) + this.needsI18nImport = true return true } @@ -467,8 +562,10 @@ export default class MacroJs { return ( this.types.isTaggedTemplateExpression(node) && (this.isLinguiIdentifier(node.tag, "t") || + this.isLinguiIdentifier(node.tag, "_") || (this.types.isCallExpression(node.tag) && - this.isLinguiIdentifier(node.tag.callee, "t"))) + (this.isLinguiIdentifier(node.tag.callee, "t") || + this.isLinguiIdentifier(node.tag.callee, "_")))) ) } diff --git a/packages/macro/test/index.ts b/packages/macro/test/index.ts index e6cc0902b..a1c5cd7c8 100644 --- a/packages/macro/test/index.ts +++ b/packages/macro/test/index.ts @@ -40,6 +40,7 @@ const testCases: Record = { "jsx-plural": require("./jsx-plural").default, "jsx-selectOrdinal": require("./jsx-selectOrdinal").default, "js-defineMessage": require("./js-defineMessage").default, + "js-useLingui": require("./js-useLingui").default, } function stripIdPlugin(): PluginObj { diff --git a/packages/macro/test/js-useLingui.ts b/packages/macro/test/js-useLingui.ts new file mode 100644 index 000000000..9ad7bec2b --- /dev/null +++ b/packages/macro/test/js-useLingui.ts @@ -0,0 +1,282 @@ +import { TestCase } from "./index" + +const cases: TestCase[] = [ + { + name: "tagged template literal style", + input: ` +import { useLingui } from '@lingui/macro'; + +function MyComponent() { + const { t } = useLingui(); + const a = t\`Text\`; +} + `, + expected: ` +import { useLingui } from "@lingui/react"; + +function MyComponent() { + const { _: _t } = useLingui(); + const a = _t( + /*i18n*/ + { + id: \"xeiujy\", + message: \"Text\", + } + ); +}`, + }, + { + name: "support renamed destructuring", + input: ` +import { useLingui } from '@lingui/macro'; + +function MyComponent() { + const { t: _ } = useLingui(); + const a = _\`Text\`; +} + `, + expected: ` +import { useLingui } from "@lingui/react"; + +function MyComponent() { + const { _: _t } = useLingui(); + const a = _t( + /*i18n*/ + { + id: \"xeiujy\", + message: \"Text\", + } + ); +}`, + }, + { + name: "should process macro with matching name in correct scopes", + input: ` +import { useLingui } from '@lingui/macro'; + +function MyComponent() { + const { t } = useLingui(); + const a = t\`Text\`; + + { + // here is child scope with own "t" binding, shouldn't be processed + const t = () => {}; + t\`Text\`; + } + + { + // here is child scope which should be processed, since 't' relates to outer scope + t\`Text\`; + } +} + `, + expected: ` +import { useLingui } from "@lingui/react"; +function MyComponent() { + const { _: _t } = useLingui(); + const a = _t( + /*i18n*/ + { + id: "xeiujy", + message: "Text", + } + ); + { + // here is child scope with own "t" binding, shouldn't be processed + const t = () => {}; + t\`Text\`; + } + { + // here is child scope which should be processed, since 't' relates to outer scope + _t( + /*i18n*/ + { + id: "xeiujy", + message: "Text", + } + ); + } +} + +`, + }, + { + name: "inserted statement should not clash with existing variables", + input: ` +import { useLingui } from '@lingui/macro'; + +function MyComponent() { + const _t = "i'm here"; + const { t: _ } = useLingui(); + const a = _\`Text\`; +} + `, + expected: ` +import { useLingui } from "@lingui/react"; +function MyComponent() { + const _t = "i'm here"; + const { _: _t2 } = useLingui(); + const a = _t2( + /*i18n*/ + { + id: "xeiujy", + message: "Text", + } + ); +} +`, + }, + { + name: "support nested macro", + input: ` +import { useLingui, plural } from '@lingui/macro'; + +function MyComponent() { + const { t } = useLingui(); + const a = t\`Text \${plural(users.length, { + offset: 1, + 0: "No books", + 1: "1 book", + other: "# books" + })}\`; +} + + `, + expected: ` +import { i18n } from "@lingui/core"; +import { useLingui } from "@lingui/react"; +function MyComponent() { + const { _: _t } = useLingui(); + const a = _t( + /*i18n*/ + { + id: "hJRCh6", + message: + "Text {0, plural, offset:1 =0 {No books} =1 {1 book} other {# books}}", + values: { + 0: users.length, + }, + } + ); +} +`, + }, + { + name: "support message descriptor", + input: ` +import { useLingui } from '@lingui/macro'; + +function MyComponent() { + const { t } = useLingui(); + const a = t({ message: "Hello", context: "my custom" }); +} + `, + expected: ` + import { useLingui } from "@lingui/react"; + function MyComponent() { + const { _: _t } = useLingui(); + const a = _t( + /*i18n*/ + { + context: "my custom", + message: "Hello", + id: "BYqAaU", + } + ); +}`, + }, + { + name: "support passing t variable as dependency", + input: ` +import { useLingui } from '@lingui/macro'; + +function MyComponent() { + const { t } = useLingui(); + const a = useMemo(() => t\`Text\`, [t]); +} + `, + expected: ` +import { useLingui } from "@lingui/react"; +function MyComponent() { + const { _: _t } = useLingui(); + const a = useMemo( + () => + _t( + /*i18n*/ + { + id: "xeiujy", + message: "Text", + } + ), + [_t] + ); +} +`, + }, + { + name: "work with existing useLingui statement", + input: ` +import { useLingui as useLinguiMacro } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; + +function MyComponent() { + const { _ } = useLingui(); + + console.log(_); + const { t } = useLinguiMacro(); + const a = t\`Text\`; +} + `, + expected: ` +import { useLingui } from "@lingui/react"; +function MyComponent() { + const { _ } = useLingui(); + console.log(_); + const { _: _t } = useLingui(); + const a = _t( + /*i18n*/ + { + id: "xeiujy", + message: "Text", + } + ); +} +`, + }, + + { + // todo: implement this + skip: true, + name: "work with renamed existing useLingui statement", + input: ` +import { useLingui as useLinguiMacro } from '@lingui/macro'; +import { useLingui as useLinguiRenamed } from '@lingui/react'; + +function MyComponent() { + const { _ } = useLinguiRenamed(); + + console.log(_); + const { t } = useLinguiMacro(); + const a = t\`Text\`; +} + `, + expected: ` +import { useLingui as useLinguiRenamed } from '@lingui/react'; +import { useLingui } from "@lingui/react"; +function MyComponent() { + const { _ } = useLinguiRenamed(); + console.log(_); + const { _: _t } = useLingui(); + const a = _t( + /*i18n*/ + { + id: "xeiujy", + message: "Text", + } + ); +} +`, + }, +] + +export default cases