diff --git a/packages/macro/src/index.ts b/packages/macro/src/index.ts index a774ec9fe..a65142315 100644 --- a/packages/macro/src/index.ts +++ b/packages/macro/src/index.ts @@ -52,8 +52,8 @@ const jsxMacroTags = new Set(["Trans", "Plural", "Select", "SelectOrdinal"]) function macro({ references, state, babel, config }: MacroParams) { const opts: LinguiMacroOpts = config as LinguiMacroOpts - const jsxNodes: NodePath[] = [] - const jsNodes: NodePath[] = [] + const jsxNodes = new Set() + const jsNodes = new Set() let needsI18nImport = false Object.keys(references).forEach((tagName) => { @@ -61,12 +61,15 @@ function macro({ references, state, babel, config }: MacroParams) { if (jsMacroTags.has(tagName)) { nodes.forEach((node) => { - jsNodes.push(node.parentPath) + jsNodes.add(node.parentPath) }) } else if (jsxMacroTags.has(tagName)) { + // babel-plugin-macros return JSXIdentifier nodes. + // Which is for every JSX element would be presented twice (opening / close) + // Here we're taking JSXElement and dedupe it. nodes.forEach((node) => { // identifier.openingElement.jsxElement - jsxNodes.push(node.parentPath.parentPath) + jsxNodes.add(node.parentPath.parentPath) }) } else { throw nodes[0].buildCodeFrameError(`Unknown macro ${tagName}`) @@ -76,14 +79,16 @@ function macro({ references, state, babel, config }: MacroParams) { const stripNonEssentialProps = process.env.NODE_ENV == "production" && !opts.extract - jsNodes.filter(isRootPath(jsNodes)).forEach((path) => { - if (alreadyVisited(path)) return + const jsNodesArray = Array.from(jsNodes) + + jsNodesArray.filter(isRootPath(jsNodesArray)).forEach((path) => { const macro = new MacroJS(babel, { i18nImportName, stripNonEssentialProps }) if (macro.replacePath(path)) needsI18nImport = true }) - jsxNodes.filter(isRootPath(jsxNodes)).forEach((path) => { - if (alreadyVisited(path)) return + const jsxNodesArray = Array.from(jsxNodes) + + jsxNodesArray.filter(isRootPath(jsxNodesArray)).forEach((path) => { const macro = new MacroJSX(babel, { stripNonEssentialProps }) macro.replacePath(path) }) @@ -92,7 +97,7 @@ function macro({ references, state, babel, config }: MacroParams) { addImport(babel, state, i18nImportModule, i18nImportName) } - if (jsxNodes.length) { + if (jsxNodes.size) { addImport(babel, state, TransImportModule, TransImportName) } } @@ -135,6 +140,13 @@ function addImport( } } +/** + * Filtering nested macro calls + * + * + * <-- this would be filtered out + * + */ function isRootPath(allPath: NodePath[]) { return (node: NodePath) => (function traverse(path): boolean { @@ -146,16 +158,6 @@ function isRootPath(allPath: NodePath[]) { })(node) } -const alreadyVisitedCache = new WeakSet() -const alreadyVisited = (path: NodePath) => { - if (alreadyVisitedCache.has(path)) { - return true - } else { - alreadyVisitedCache.add(path) - return false - } -} - ;[...jsMacroTags, ...jsxMacroTags].forEach((name) => { Object.defineProperty(module.exports, name, { get() { diff --git a/packages/macro/src/macroJsx.test.ts b/packages/macro/src/macroJsx.test.ts index f3efb99a8..52c6f3658 100644 --- a/packages/macro/src/macroJsx.test.ts +++ b/packages/macro/src/macroJsx.test.ts @@ -1,12 +1,28 @@ -import { parseExpression as _parseExpression } from "@babel/parser" import * as types from "@babel/types" import MacroJSX, { normalizeWhitespace } from "./macroJsx" -import { JSXElement } from "@babel/types" +import { transformSync } from "@babel/core" +import type { NodePath } from "@babel/traverse" +import type { JSXElement } from "@babel/types" -const parseExpression = (expression: string) => - _parseExpression(expression, { - plugins: ["jsx"], - }) as JSXElement +const parseExpression = (expression: string) => { + let path: NodePath + + transformSync(expression, { + filename: "unit-test.js", + plugins: [ + { + visitor: { + JSXElement: (d) => { + path = d + d.stop() + }, + }, + }, + ], + }) + + return path +} function createMacro() { return new MacroJSX({ types }, { stripNonEssentialProps: false }) @@ -302,20 +318,22 @@ describe("jsx macro", () => { }), format: "plural", options: { - one: { - type: "arg", - name: "gender", - value: expect.objectContaining({ + one: [ + { + type: "arg", name: "gender", - type: "Identifier", - }), - format: "select", - options: { - male: "he", - female: "she", - other: "they", + value: expect.objectContaining({ + name: "gender", + type: "Identifier", + }), + format: "select", + options: { + male: "he", + female: "she", + other: "they", + }, }, - }, + ], }, }) }) @@ -332,7 +350,7 @@ describe("jsx macro", () => { />` ) const tokens = macro.tokenizeNode(exp) - expect(tokens).toMatchObject({ + expect(tokens[0]).toMatchObject({ format: "select", name: "gender", options: { diff --git a/packages/macro/src/macroJsx.ts b/packages/macro/src/macroJsx.ts index 1c944815a..bcd9846e2 100644 --- a/packages/macro/src/macroJsx.ts +++ b/packages/macro/src/macroJsx.ts @@ -1,13 +1,13 @@ -import * as R from "ramda" import * as babelTypes from "@babel/types" import { + ConditionalExpression, Expression, - Identifier, JSXAttribute, JSXElement, JSXExpressionContainer, JSXIdentifier, JSXSpreadAttribute, + Literal, Node, StringLiteral, } from "@babel/types" @@ -18,15 +18,16 @@ import ICUMessageFormat, { ElementToken, TextToken, Token, - Tokens, } from "./icu" -import { makeCounter, zip } from "./utils" +import { makeCounter } from "./utils" import { COMMENT, CONTEXT, ID, MESSAGE } from "./constants" const pluralRuleRe = /(_[\d\w]+|zero|one|two|few|many|other)/ const jsx2icuExactChoice = (value: string) => value.replace(/_(\d+)/, "=$1").replace(/_(\w+)/, "$1") +type JSXChildPath = NodePath + // replace whitespace before/after newline with single space const keepSpaceRe = /\s*(?:\r\n|\r|\n)+\s*/g // remove whitespace before/after tag or expression @@ -82,7 +83,7 @@ export default class MacroJSX { } replacePath = (path: NodePath) => { - const tokens = this.tokenizeNode(path.node) + const tokens = this.tokenizeNode(path) const messageFormat = new ICUMessageFormat() const { @@ -93,7 +94,7 @@ export default class MacroJSX { const message = normalizeWhitespace(messageRaw) const { attributes, id, comment, context } = this.stripMacroAttributes( - path.node as JSXElement + path as NodePath ) if (!id && !message) { @@ -172,11 +173,11 @@ export default class MacroJSX { this.types.jsxOpeningElement( this.types.jsxIdentifier("Trans"), attributes, - /*selfClosing*/ true + true ), - /*closingElement*/ null, - /*children*/ [], - /*selfClosing*/ true + null, + [], + true ) newNode.loc = path.node.loc @@ -191,17 +192,17 @@ export default class MacroJSX { } } - stripMacroAttributes = (node: JSXElement) => { - const { attributes } = node.openingElement + 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] let reserved = [ID, MESSAGE, COMMENT, CONTEXT] - if (this.isI18nComponent(node)) { + if (this.isI18nComponent(path)) { // no reserved prop names - } else if (this.isChoiceComponent(node)) { + } else if (this.isChoiceComponent(path)) { reserved = [ ...reserved, "_\\w+", @@ -226,87 +227,88 @@ export default class MacroJSX { } } - tokenizeNode = (node: Node): Tokens => { - if (this.isI18nComponent(node)) { + tokenizeNode = (path: NodePath): Token[] => { + if (this.isI18nComponent(path)) { // t - return this.tokenizeTrans(node) - } else if (this.isChoiceComponent(node)) { + return this.tokenizeTrans(path) + } else if (this.isChoiceComponent(path)) { // plural, select and selectOrdinal - return this.tokenizeChoiceComponent(node) - } else if (this.types.isJSXElement(node)) { - return this.tokenizeElement(node) + return [this.tokenizeChoiceComponent(path)] + } else if (path.isJSXElement()) { + return [this.tokenizeElement(path)] } else { - return this.tokenizeExpression(node) + return [this.tokenizeExpression(path)] } } - tokenizeTrans = (node: JSXElement): Token[] => { - return R.flatten( - node.children.map((child) => this.tokenizeChildren(child)).filter(Boolean) - ) + tokenizeTrans = (path: NodePath): Token[] => { + return path + .get("children") + .flatMap((child) => this.tokenizeChildren(child)) + .filter(Boolean) } - tokenizeChildren = (node: JSXElement["children"][number]): Tokens => { - if (this.types.isJSXExpressionContainer(node)) { - const exp = node.expression + tokenizeChildren = (path: JSXChildPath): Token[] => { + if (path.isJSXExpressionContainer()) { + const exp = path.get("expression") as NodePath - if (this.types.isStringLiteral(exp)) { + if (exp.isStringLiteral()) { // Escape forced newlines to keep them in message. - return { - type: "text", - value: exp.value.replace(/\n/g, "\\n"), - } - } else if (this.types.isTemplateLiteral(exp)) { - const tokenize = R.pipe( - // Don"t output tokens without text. - R.evolve({ - quasis: R.map((text: babelTypes.TemplateElement) => { - // 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 this.tokenizeText(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) - ) + 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, + ] + }) + } + if (exp.isConditionalExpression()) { + return [this.tokenizeConditionalExpression(exp)] + } - return tokenize(exp) - } else if (this.types.isJSXElement(exp)) { + if (exp.isJSXElement()) { return this.tokenizeNode(exp) - } else { - return this.tokenizeExpression(exp) } - } else if (this.types.isJSXElement(node)) { - return this.tokenizeNode(node) - } else if (this.types.isJSXSpreadChild(node)) { + return [this.tokenizeExpression(exp)] + } else if (path.isJSXElement()) { + return this.tokenizeNode(path) + } else if (path.isJSXSpreadChild()) { // just do nothing - } else if (this.types.isJSXText(node)) { - return this.tokenizeText(node.value) + } else if (path.isJSXText()) { + return [this.tokenizeText(path.node.value)] } else { // impossible path // return this.tokenizeText(node.value) } } - tokenizeChoiceComponent = (node: JSXElement): Token => { - const element = node.openingElement - const format = this.getJsxTagName(node).toLowerCase() - const props = element.attributes.filter( - this.attrName( + tokenizeChoiceComponent = (path: NodePath): Token => { + const element = path.get("openingElement") + const format = this.getJsxTagName(path.node).toLowerCase() + const props = element.get("attributes").filter((attr) => { + return this.attrName( [ ID, COMMENT, @@ -319,8 +321,8 @@ export default class MacroJSX { "components", ], true - ) - ) + )(attr.node) + }) const token: Token = { type: "arg", @@ -332,41 +334,52 @@ export default class MacroJSX { }, } - for (const attr of props) { - if (this.types.isJSXSpreadAttribute(attr)) { + for (const _attr of props) { + if (_attr.isJSXSpreadAttribute()) { continue } - if (this.types.isJSXNamespacedName(attr.name)) { + const attr = _attr as NodePath + + if (this.types.isJSXNamespacedName(attr.node.name)) { continue } - const name = attr.name.name + const name = attr.node.name.name + const value = attr.get("value") as + | NodePath + | NodePath if (name === "value") { - const exp = this.types.isLiteral(attr.value) - ? attr.value - : (attr.value as JSXExpressionContainer).expression + const exp = value.isLiteral() ? value : value.get("expression") + token.name = this.expressionToArgument(exp) - token.value = exp as Expression + token.value = exp.node as Expression } else if (format !== "select" && name === "offset") { // offset is static parameter, so it must be either string or number - token.options.offset = this.types.isStringLiteral(attr.value) - ? attr.value.value - : ((attr.value as JSXExpressionContainer).expression as StringLiteral) - .value + token.options.offset = + value.isStringLiteral() || value.isNumericLiteral() + ? (value.node.value as string) + : ( + (value as NodePath).get( + "expression" + ) as NodePath + ).node.value } else { - let value: ArgToken["options"][number] + let option: ArgToken["options"][number] - if (this.types.isStringLiteral(attr.value)) { - value = (attr.value.extra.raw as string).replace(/(["'])(.*)\1/, "$2") + if (value.isStringLiteral()) { + option = (value.node.extra.raw as string).replace( + /(["'])(.*)\1/, + "$2" + ) } else { - value = this.tokenizeChildren(attr.value) + option = this.tokenizeChildren(value as JSXChildPath) } if (pluralRuleRe.test(name)) { - token.options[jsx2icuExactChoice(name)] = value + token.options[jsx2icuExactChoice(name)] = option } else { - token.options[name] = value + token.options[name] = option } } } @@ -374,30 +387,50 @@ export default class MacroJSX { return token } - tokenizeElement = (node: JSXElement): ElementToken => { + tokenizeElement = (path: NodePath): ElementToken => { // !!! Important: Calculate element index before traversing children. // That way outside elements are numbered before inner elements. (...and it looks pretty). const name = this.elementIndex() - const children = R.flatten( - node.children.map((child) => this.tokenizeChildren(child)).filter(Boolean) - ) - - node.children = [] - node.openingElement.selfClosing = true return { type: "element", name, - value: node, - children, + value: { + ...path.node, + children: [], + openingElement: { + ...path.node.openingElement, + selfClosing: true, + }, + }, + children: this.tokenizeTrans(path), } } - tokenizeExpression = (node: Expression | Node): ArgToken => { + tokenizeExpression = (path: NodePath): ArgToken => { return { type: "arg", - name: this.expressionToArgument(node), - value: node as Expression, + name: this.expressionToArgument(path), + value: path.node as Expression, + } + } + + tokenizeConditionalExpression = ( + exp: NodePath + ): ArgToken => { + exp.traverse({ + JSXElement: (el) => { + if (this.isI18nComponent(el) || this.isChoiceComponent(el)) { + this.replacePath(el) + el.skip() + } + }, + }) + + return { + type: "arg", + name: this.expressionToArgument(exp), + value: exp.node, } } @@ -408,10 +441,8 @@ export default class MacroJSX { } } - expressionToArgument(exp: Expression | Node): string { - return this.types.isIdentifier(exp) - ? exp.name - : String(this.expressionIndex()) + expressionToArgument(path: NodePath): string { + return path.isIdentifier() ? path.node.name : String(this.expressionIndex()) } /** @@ -422,27 +453,23 @@ export default class MacroJSX { return value.replace(/\\`/g, "`") } - /** - * Custom matchers - */ - isIdentifier = (node: Node, name: string): node is Identifier => { - return this.types.isIdentifier(node, { name }) - } - - isI18nComponent = (node: Node, name = "Trans"): node is JSXElement => { + isI18nComponent = ( + path: NodePath, + name = "Trans" + ): path is NodePath => { return ( - this.types.isJSXElement(node) && - this.types.isJSXIdentifier(node.openingElement.name, { + path.isJSXElement() && + this.types.isJSXIdentifier(path.node.openingElement.name, { name, }) ) } - isChoiceComponent = (node: Node): node is JSXElement => { + isChoiceComponent = (path: NodePath): path is NodePath => { return ( - this.isI18nComponent(node, "Plural") || - this.isI18nComponent(node, "Select") || - this.isI18nComponent(node, "SelectOrdinal") + this.isI18nComponent(path, "Plural") || + this.isI18nComponent(path, "Select") || + this.isI18nComponent(path, "SelectOrdinal") ) } diff --git a/packages/macro/test/jsx-trans.ts b/packages/macro/test/jsx-trans.ts index 000b39408..358c39c71 100644 --- a/packages/macro/test/jsx-trans.ts +++ b/packages/macro/test/jsx-trans.ts @@ -196,6 +196,50 @@ const cases: TestCase[] = [ }} />; `, }, + { + name: "JSX Macro inside JSX conditional expressions", + input: ` + import { Trans } from '@lingui/macro' + ;Hello, {props.world ? world : guys} + `, + expected: ` + import { Trans } from '@lingui/react' + + ; : + }} + /> + `, + }, + { + name: "JSX Macro inside JSX multiple nested conditional expressions", + input: ` + import { Trans } from '@lingui/macro' + ;Hello, {props.world ? world : ( + props.b + ? nested + : guys + ) + } + `, + expected: ` + import { Trans } from "@lingui/react"; + + ) : props.b ? ( + + ) : ( + + ), + }} + />; + `, + }, { name: "Elements are replaced with placeholders", input: ` diff --git a/packages/macro/tsconfig.json b/packages/macro/tsconfig.json index 8965e0cbd..3ea412def 100644 --- a/packages/macro/tsconfig.json +++ b/packages/macro/tsconfig.json @@ -1,7 +1,10 @@ { "extends": "../../tsconfig.json", "compilerOptions": { + "lib": [ + "ES2019.Array" + ], "strict": true, - "strictNullChecks": false, + "strictNullChecks": false } }