From fbcff6aa30d4bbe3e5499f160cde3c2c6f15ce73 Mon Sep 17 00:00:00 2001 From: Timofei Iatsenko Date: Wed, 22 May 2024 16:47:17 +0200 Subject: [PATCH] refactor(macro) split and expose internals to be used in Vue macro --- .../babel-plugin-lingui-macro/package.json | 10 + packages/babel-plugin-lingui-macro/src/ast.ts | 19 + .../src/constants.ts | 1 + .../babel-plugin-lingui-macro/src/icu.test.ts | 2 +- packages/babel-plugin-lingui-macro/src/icu.ts | 32 +- .../babel-plugin-lingui-macro/src/index.ts | 75 +++- .../babel-plugin-lingui-macro/src/macroJs.ts | 415 +++--------------- .../{macroJs.test.ts => macroJsAst.test.ts} | 86 ++-- .../src/macroJsAst.ts | 328 ++++++++++++++ .../babel-plugin-lingui-macro/src/macroJsx.ts | 1 + .../src/messageDescriptorUtils.ts | 46 +- .../test/js-useLingui.test.ts | 2 + 12 files changed, 584 insertions(+), 433 deletions(-) create mode 100644 packages/babel-plugin-lingui-macro/src/ast.ts rename packages/babel-plugin-lingui-macro/src/{macroJs.test.ts => macroJsAst.test.ts} (81%) create mode 100644 packages/babel-plugin-lingui-macro/src/macroJsAst.ts diff --git a/packages/babel-plugin-lingui-macro/package.json b/packages/babel-plugin-lingui-macro/package.json index 0e5050834..e032949e5 100644 --- a/packages/babel-plugin-lingui-macro/package.json +++ b/packages/babel-plugin-lingui-macro/package.json @@ -53,6 +53,16 @@ "types": "./dist/macro.d.mts", "default": "./dist/macro.mjs" } + }, + "./ast": { + "require": { + "types": "./dist/ast.d.cts", + "default": "./dist/ast.cjs" + }, + "import": { + "types": "./dist/ast.d.mts", + "default": "./dist/ast.mjs" + } } }, "files": [ diff --git a/packages/babel-plugin-lingui-macro/src/ast.ts b/packages/babel-plugin-lingui-macro/src/ast.ts new file mode 100644 index 000000000..35bdb76fc --- /dev/null +++ b/packages/babel-plugin-lingui-macro/src/ast.ts @@ -0,0 +1,19 @@ +export * from "./icu" +export * from "./messageDescriptorUtils" +export { JsMacroName } from "./constants" + +export { + isChoiceMethod, + isLinguiIdentifier, + isI18nMethod, + isDefineMessage, + tokenizeExpression, + tokenizeChoiceComponent, + tokenizeTemplateLiteral, + tokenizeNode, + processDescriptor, + createMacroJsContext, + type MacroJsContext, + tokenizeArg, + isArgDecorator, +} from "./macroJsAst" diff --git a/packages/babel-plugin-lingui-macro/src/constants.ts b/packages/babel-plugin-lingui-macro/src/constants.ts index 91b528aa7..037150cf3 100644 --- a/packages/babel-plugin-lingui-macro/src/constants.ts +++ b/packages/babel-plugin-lingui-macro/src/constants.ts @@ -19,6 +19,7 @@ export enum JsMacroName { selectOrdinal = "selectOrdinal", msg = "msg", defineMessage = "defineMessage", + arg = "arg", useLingui = "useLingui", } diff --git a/packages/babel-plugin-lingui-macro/src/icu.test.ts b/packages/babel-plugin-lingui-macro/src/icu.test.ts index 897803955..36be857ac 100644 --- a/packages/babel-plugin-lingui-macro/src/icu.test.ts +++ b/packages/babel-plugin-lingui-macro/src/icu.test.ts @@ -1,4 +1,4 @@ -import ICUMessageFormat, { Token } from "./icu" +import { ICUMessageFormat, Token } from "./icu" import { Identifier } from "@babel/types" describe("ICU MessageFormat", function () { diff --git a/packages/babel-plugin-lingui-macro/src/icu.ts b/packages/babel-plugin-lingui-macro/src/icu.ts index 02be076f1..ca3c425db 100644 --- a/packages/babel-plugin-lingui-macro/src/icu.ts +++ b/packages/babel-plugin-lingui-macro/src/icu.ts @@ -1,9 +1,4 @@ -import { - Expression, - isJSXEmptyExpression, - JSXElement, - Node, -} from "@babel/types" +import { Expression, isJSXEmptyExpression, Node } from "@babel/types" const metaOptions = ["id", "comment", "props"] @@ -12,18 +7,20 @@ const escapedMetaOptionsRe = new RegExp(`^_(${metaOptions.join("|")})$`) export type ParsedResult = { message: string values?: Record - jsxElements?: Record + elements?: Record // JSXElement or ElementNode in Vue } export type TextToken = { type: "text" value: string } + export type ArgToken = { type: "arg" value: Expression name?: string + raw?: boolean /** * plural * select @@ -35,16 +32,17 @@ export type ArgToken = { [icuChoice: string]: string | Tokens } } + export type ElementToken = { type: "element" - value: JSXElement + value: any // JSXElement or ElementNode in Vue name?: string | number children?: Token[] } export type Tokens = Token | Token[] export type Token = TextToken | ArgToken | ElementToken -export default class ICUMessageFormat { +export class ICUMessageFormat { public fromTokens(tokens: Tokens): ParsedResult { return (Array.isArray(tokens) ? tokens : [tokens]) .map((token) => this.processToken(token)) @@ -54,18 +52,18 @@ export default class ICUMessageFormat { ...message, message: props.message + message.message, values: { ...props.values, ...message.values }, - jsxElements: { ...props.jsxElements, ...message.jsxElements }, + elements: { ...props.elements, ...message.elements }, }), { message: "", values: {}, - jsxElements: {}, + elements: {}, } ) } public processToken(token: Token): ParsedResult { - const jsxElements: ParsedResult["jsxElements"] = {} + const jsxElements: ParsedResult["elements"] = {} if (token.type === "text") { return { @@ -101,7 +99,7 @@ export default class ICUMessageFormat { const { message, values: childValues, - jsxElements: childJsxElements, + elements: childJsxElements, } = this.fromTokens(value) Object.assign(values, childValues) @@ -116,11 +114,11 @@ export default class ICUMessageFormat { return { message: `{${token.name}, ${token.format}, ${formatOptions}}`, values, - jsxElements, + elements: jsxElements, } default: return { - message: `{${token.name}}`, + message: token.raw ? `${token.name}` : `{${token.name}}`, values, } } @@ -132,7 +130,7 @@ export default class ICUMessageFormat { const { message: childMessage, values: childValues, - jsxElements: childJsxElements, + elements: childJsxElements, } = this.fromTokens(child) message += childMessage @@ -144,7 +142,7 @@ export default class ICUMessageFormat { ? `<${token.name}>${message}` : `<${token.name}/>`, values: elementValues, - jsxElements, + elements: jsxElements, } } diff --git a/packages/babel-plugin-lingui-macro/src/index.ts b/packages/babel-plugin-lingui-macro/src/index.ts index faa2f84d0..aca1d14f7 100644 --- a/packages/babel-plugin-lingui-macro/src/index.ts +++ b/packages/babel-plugin-lingui-macro/src/index.ts @@ -1,5 +1,6 @@ -import type { PluginObj, Visitor, PluginPass } from "@babel/core" +import type { PluginObj, Visitor, PluginPass, BabelFile } from "@babel/core" import type * as babelTypes from "@babel/types" +import { Program, Identifier } from "@babel/types" import { MacroJSX } from "./macroJsx" import { NodePath } from "@babel/traverse" import { MacroJs } from "./macroJs" @@ -7,12 +8,12 @@ import { MACRO_CORE_PACKAGE, MACRO_REACT_PACKAGE, MACRO_LEGACY_PACKAGE, + JsMacroName, } from "./constants" import { type LinguiConfigNormalized, getConfig as loadConfig, } from "@lingui/conf" -import { Program, Identifier } from "@babel/types" let config: LinguiConfigNormalized @@ -47,6 +48,21 @@ function reportUnsupportedSyntax(path: NodePath, e: Error) { type LinguiSymbol = "Trans" | "useLingui" | "i18n" +const getIdentifierPath = ((path: NodePath, node: Identifier) => { + let foundPath: NodePath + + path.traverse({ + Identifier: (path) => { + if (path.node === node) { + foundPath = path + path.stop() + } + }, + }) + + return foundPath +}) as any + export default function ({ types: t, }: { @@ -103,8 +119,41 @@ export default function ({ return state.get("linguiIdentifiers")[name] } + function isLinguiIdentifier( + path: NodePath, + node: Identifier, + macro: JsMacroName + ) { + let identPath = getIdentifierPath(path, node) + + if (macro === JsMacroName.useLingui) { + if ( + identPath.referencesImport( + MACRO_REACT_PACKAGE, + JsMacroName.useLingui + ) || + identPath.referencesImport(MACRO_LEGACY_PACKAGE, JsMacroName.useLingui) + ) { + return true + } + } else { + // useLingui might ask for identifiers which are not direct child of macro + identPath = identPath || getIdentifierPath(path.getFunctionParent(), node) + + if ( + identPath.referencesImport(MACRO_CORE_PACKAGE, macro) || + identPath.referencesImport(MACRO_LEGACY_PACKAGE, macro) + ) { + return true + } + } + return false + } return { name: "lingui-macro-plugin", + pre(file: BabelFile) { + file.hub + }, visitor: { Program: { enter(path, state) { @@ -161,17 +210,17 @@ export default function ({ >, state: PluginPass ) { - const macro = new MacroJs( - { types: t }, - { - stripNonEssentialProps: - process.env.NODE_ENV == "production" && - !(state.opts as LinguiPluginOpts).extract, - i18nImportName: getSymbolIdentifier(state, "i18n").name, - useLinguiImportName: getSymbolIdentifier(state, "useLingui") - .name, - } - ) + const macro = new MacroJs({ + stripNonEssentialProps: + process.env.NODE_ENV == "production" && + !(state.opts as LinguiPluginOpts).extract, + i18nImportName: getSymbolIdentifier(state, "i18n").name, + useLinguiImportName: getSymbolIdentifier(state, "useLingui") + .name, + + isLinguiIdentifier: (node: Identifier, macro) => + isLinguiIdentifier(path, node, macro), + }) let newNode: false | babelTypes.Node try { diff --git a/packages/babel-plugin-lingui-macro/src/macroJs.ts b/packages/babel-plugin-lingui-macro/src/macroJs.ts index 62521cc67..b9b5aabbd 100644 --- a/packages/babel-plugin-lingui-macro/src/macroJs.ts +++ b/packages/babel-plugin-lingui-macro/src/macroJs.ts @@ -1,58 +1,56 @@ import * as babelTypes from "@babel/types" +import * as t from "@babel/types" import { CallExpression, Expression, Identifier, - Node, ObjectExpression, ObjectProperty, - StringLiteral, - TemplateLiteral, } from "@babel/types" import { NodePath } from "@babel/traverse" -import { ArgToken, TextToken, Token, Tokens } from "./icu" -import { makeCounter } from "./utils" -import { - MACRO_CORE_PACKAGE, - JsMacroName, - MACRO_REACT_PACKAGE, - MACRO_LEGACY_PACKAGE, - MsgDescriptorPropKey, -} from "./constants" +import { Tokens } from "./icu" +import { JsMacroName } from "./constants" import { createMessageDescriptorFromTokens } from "./messageDescriptorUtils" +import { + isLinguiIdentifier, + isDefineMessage, + tokenizeTemplateLiteral, + tokenizeNode, + processDescriptor, + createMacroJsContext, + MacroJsContext, +} from "./macroJsAst" export type MacroJsOpts = { i18nImportName: string useLinguiImportName: string stripNonEssentialProps: boolean + isLinguiIdentifier: (node: Identifier, macro: JsMacroName) => boolean } export class MacroJs { - // Babel Types - types: typeof babelTypes - // Identifier of i18n object i18nImportName: string useLinguiImportName: string - stripNonEssentialProps: boolean needsUseLinguiImport = false needsI18nImport = false - // Positional expressions counter (e.g. for placeholders `Hello {0}, today is {1}`) - _expressionIndex = makeCounter() + _ctx: MacroJsContext - constructor({ types }: { types: typeof babelTypes }, opts: MacroJsOpts) { - this.types = types + constructor(opts: MacroJsOpts) { this.i18nImportName = opts.i18nImportName this.useLinguiImportName = opts.useLinguiImportName - this.stripNonEssentialProps = opts.stripNonEssentialProps + this._ctx = createMacroJsContext( + opts.isLinguiIdentifier, + opts.stripNonEssentialProps + ) } - replacePathWithMessage = ( + private replacePathWithMessage = ( path: NodePath, tokens: Tokens, linguiInstance?: babelTypes.Expression @@ -61,37 +59,37 @@ export class MacroJs { createMessageDescriptorFromTokens( tokens, path.node.loc, - this.stripNonEssentialProps + this._ctx.stripNonEssentialProps ), linguiInstance ) } replacePath = (path: NodePath): false | babelTypes.Expression => { - // reset the expression counter - this._expressionIndex = makeCounter() + const ctx = this._ctx // defineMessage({ message: "Message", context: "My" }) -> {id: , message: "Message"} if ( // path.isCallExpression() && - this.isDefineMessage(path.get("callee")) + isDefineMessage(path.get("callee").node, ctx) ) { - return this.processDescriptor( - path.get("arguments")[0] as NodePath + return processDescriptor( + path.get("arguments")[0].node as ObjectExpression, + ctx ) } // defineMessage`Message` -> {id: , message: "Message"} if ( path.isTaggedTemplateExpression() && - this.isDefineMessage(path.get("tag")) + isDefineMessage(path.get("tag").node, ctx) ) { - const tokens = this.tokenizeTemplateLiteral(path.get("quasi")) + const tokens = tokenizeTemplateLiteral(path.get("quasi").node, ctx) return createMessageDescriptorFromTokens( tokens, path.node.loc, - this.stripNonEssentialProps + ctx.stripNonEssentialProps ) } @@ -102,11 +100,11 @@ export class MacroJs { if ( tag.isCallExpression() && tag.get("arguments")[0]?.isExpression() && - this.isLinguiIdentifier(tag.get("callee"), JsMacroName.t) + isLinguiIdentifier(tag.get("callee").node, JsMacroName.t, ctx) ) { // Use the first argument as i18n instance instead of the default i18n instance const i18nInstance = tag.get("arguments")[0].node as Expression - const tokens = this.tokenizeNode(path) + const tokens = tokenizeNode(path.node, false, ctx) return this.replacePathWithMessage(path, tokens, i18nInstance) } @@ -119,11 +117,12 @@ export class MacroJs { if ( callee.isCallExpression() && callee.get("arguments")[0]?.isExpression() && - this.isLinguiIdentifier(callee.get("callee"), JsMacroName.t) + isLinguiIdentifier(callee.get("callee").node, JsMacroName.t, ctx) ) { const i18nInstance = callee.node.arguments[0] as Expression return this.replaceTAsFunction( - path as NodePath, + path.node as CallExpression, + ctx, i18nInstance ) } @@ -132,19 +131,22 @@ export class MacroJs { // t({...}) if ( path.isCallExpression() && - this.isLinguiIdentifier(path.get("callee"), JsMacroName.t) + isLinguiIdentifier(path.get("callee").node, JsMacroName.t, ctx) ) { this.needsI18nImport = true - return this.replaceTAsFunction(path) + return this.replaceTAsFunction(path.node, ctx) } // { t } = useLingui() - if (path.isCallExpression() && this.isUseLinguiHook(path.get("callee"))) { + if ( + path.isCallExpression() && + isLinguiIdentifier(path.get("callee").node, JsMacroName.useLingui, ctx) + ) { this.needsUseLinguiImport = true - return this.processUseLingui(path) + return this.processUseLingui(path, ctx) } - const tokens = this.tokenizeNode(path, true) + const tokens = tokenizeNode(path.node, true, ctx) if (tokens) { this.needsI18nImport = true @@ -158,12 +160,14 @@ export class MacroJs { * macro `t` is called with MessageDescriptor, after that * we create a new node to append it to i18n._ */ - replaceTAsFunction = ( - path: NodePath, + private replaceTAsFunction = ( + node: CallExpression, + ctx: MacroJsContext, linguiInstance?: babelTypes.Expression ): babelTypes.CallExpression => { - const descriptor = this.processDescriptor( - path.get("arguments")[0] as NodePath + const descriptor = processDescriptor( + node.arguments[0] as ObjectExpression, + ctx ) return this.createI18nCall(descriptor, linguiInstance) @@ -183,7 +187,7 @@ export class MacroJs { * const { _: _t } = useLingui() * _t({id: , message: "Message"}) */ - processUseLingui(path: NodePath) { + processUseLingui(path: NodePath, ctx: MacroJsContext) { /* * path is CallExpression eq: * useLingui() @@ -205,7 +209,7 @@ export class MacroJs { // looking for `t` property in left side assigment // in the declarator `const { t } = useLingui()` const varDec = path.parentPath.node - const _property = this.types.isObjectPattern(varDec.id) + const _property = t.isObjectPattern(varDec.id) ? varDec.id.properties.find( ( property @@ -213,9 +217,9 @@ export class MacroJs { value: Identifier key: Identifier } => - this.types.isObjectProperty(property) && - this.types.isIdentifier(property.key) && - this.types.isIdentifier(property.value) && + t.isObjectProperty(property) && + t.isIdentifier(property.key) && + t.isIdentifier(property.value) && property.key.name == "t" ) : null @@ -243,16 +247,16 @@ export class MacroJs { // { t } = useLingui() // t`Hello!` if (currentPath.isTaggedTemplateExpression()) { - const tokens = this.tokenizeTemplateLiteral(currentPath) + const tokens = tokenizeTemplateLiteral(currentPath.node, ctx) const descriptor = createMessageDescriptorFromTokens( tokens, currentPath.node.loc, - this.stripNonEssentialProps + ctx.stripNonEssentialProps ) - const callExpr = this.types.callExpression( - this.types.identifier(uniqTIdentifier.name), + const callExpr = t.callExpression( + t.identifier(uniqTIdentifier.name), [descriptor] ) @@ -265,11 +269,13 @@ export class MacroJs { currentPath.isCallExpression() && currentPath.get("arguments")[0].isObjectExpression() ) { - let descriptor = this.processDescriptor( - currentPath.get("arguments")[0] as NodePath + let descriptor = processDescriptor( + (currentPath.get("arguments")[0] as NodePath) + .node, + ctx ) - const callExpr = this.types.callExpression( - this.types.identifier(uniqTIdentifier.name), + const callExpr = t.callExpression( + t.identifier(uniqTIdentifier.name), [descriptor] ) @@ -277,7 +283,7 @@ export class MacroJs { } // for rest of cases just rename identifier for run-time counterpart - refPath.replaceWith(this.types.identifier(uniqTIdentifier.name)) + refPath.replaceWith(t.identifier(uniqTIdentifier.name)) }) // assign uniq identifier for runtime `_` @@ -285,300 +291,19 @@ export class MacroJs { _property.key.name = "_" _property.value.name = uniqTIdentifier.name - return this.types.callExpression( - this.types.identifier(this.useLinguiImportName), - [] - ) - } - - /** - * `processDescriptor` expand macros inside message descriptor. - * Message descriptor is used in `defineMessage`. - * - * { - * comment: "Description", - * message: plural("value", { one: "book", other: "books" }) - * } - * - * ↓ ↓ ↓ ↓ ↓ ↓ - * - * { - * comment: "Description", - * id: - * message: "{value, plural, one {book} other {books}}" - * } - * - */ - processDescriptor = (descriptor: NodePath) => { - const messageProperty = this.getObjectPropertyByKey( - descriptor, - MsgDescriptorPropKey.message - ) - const idProperty = this.getObjectPropertyByKey( - descriptor, - MsgDescriptorPropKey.id - ) - const contextProperty = this.getObjectPropertyByKey( - descriptor, - MsgDescriptorPropKey.context - ) - const commentProperty = this.getObjectPropertyByKey( - descriptor, - MsgDescriptorPropKey.comment - ) - - let tokens: Token[] = [] - - // if there's `message` property, replace macros with formatted message - if (messageProperty) { - // Inside message descriptor the `t` macro in `message` prop is optional. - // Template strings are always processed as if they were wrapped by `t`. - const messageValue = messageProperty.get("value") - - tokens = messageValue.isTemplateLiteral() - ? this.tokenizeTemplateLiteral(messageValue) - : this.tokenizeNode(messageValue, true) - } - - return createMessageDescriptorFromTokens( - tokens, - descriptor.node.loc, - this.stripNonEssentialProps, - { - id: idProperty?.node, - context: contextProperty?.node, - comment: commentProperty?.node, - } - ) - } - - tokenizeNode(path: NodePath, ignoreExpression = false): Token[] { - const node = path.node - - if (this.isI18nMethod(path)) { - // t - return this.tokenizeTemplateLiteral(path as NodePath) - } - - const choiceMethod = this.isChoiceMethod(path) - // plural, select and selectOrdinal - if (choiceMethod) { - return [ - this.tokenizeChoiceComponent( - path as NodePath, - choiceMethod - ), - ] - } - - if (path.isStringLiteral()) { - return [ - { - type: "text", - value: path.node.value, - } satisfies TextToken, - ] - } - // if (isFormatMethod(node.callee)) { - // // date, number - // return transformFormatMethod(node, file, props, root) - - if (!ignoreExpression) { - return [this.tokenizeExpression(node)] - } - } - - /** - * `node` is a TemplateLiteral. node.quasi contains - * text chunks and node.expressions contains expressions. - * Both arrays must be zipped together to get the final list of tokens. - */ - tokenizeTemplateLiteral(path: NodePath): Token[] { - const tpl = path.isTaggedTemplateExpression() - ? path.get("quasi") - : (path as NodePath) - - const expressions = tpl.get("expressions") as NodePath[] - - return tpl.get("quasis").flatMap((text, i) => { - const value = text.node.value.cooked - - let argTokens: Token[] = [] - const currExp = expressions[i] - - if (currExp) { - argTokens = currExp.isCallExpression() - ? this.tokenizeNode(currExp) - : [this.tokenizeExpression(currExp.node)] - } - const textToken: TextToken = { - type: "text", - value, - } - return [...(value ? [textToken] : []), ...argTokens] - }) - } - - tokenizeChoiceComponent( - path: NodePath, - componentName: string - ): ArgToken { - const format = componentName.toLowerCase() - - const token: ArgToken = { - ...this.tokenizeExpression(path.node.arguments[0]), - format: format, - options: { - offset: undefined, - }, - } - - const props = (path.get("arguments")[1] as NodePath).get( - "properties" - ) - - for (const attr of props) { - if (!attr.isObjectProperty()) { - throw new Error("Expected an ObjectProperty") - } - - const key = attr.get("key") - const attrValue = attr.get("value") as NodePath - - // name is either: - // NumericLiteral => convert to `={number}` - // StringLiteral => key.value - // Identifier => key.name - const name = key.isNumericLiteral() - ? `=${key.node.value}` - : (key.node as Identifier).name || (key.node as StringLiteral).value - - if (format !== "select" && name === "offset") { - token.options.offset = (attrValue.node as StringLiteral).value - } else { - let value: ArgToken["options"][string] - - if (attrValue.isTemplateLiteral()) { - value = this.tokenizeTemplateLiteral(attrValue) - } else if (attrValue.isCallExpression()) { - value = this.tokenizeNode(attrValue) - } else if (attrValue.isStringLiteral()) { - value = attrValue.node.value - } else if (attrValue.isExpression()) { - value = this.tokenizeExpression(attrValue.node) - } else { - value = (attrValue as unknown as StringLiteral).value - } - token.options[name] = value - } - } - - return token + return t.callExpression(t.identifier(this.useLinguiImportName), []) } - tokenizeExpression(node: Node | Expression): ArgToken { - return { - type: "arg", - name: this.expressionToArgument(node as Expression), - value: node as Expression, - } - } - - expressionToArgument(exp: Expression): string { - if (this.types.isIdentifier(exp)) { - return exp.name - } else if (this.types.isStringLiteral(exp)) { - return exp.value - } else { - return String(this._expressionIndex()) - } - } - - createI18nCall( + private createI18nCall( messageDescriptor: ObjectExpression, linguiInstance?: Expression ) { - return this.types.callExpression( - this.types.memberExpression( - linguiInstance ?? this.types.identifier(this.i18nImportName), - this.types.identifier("_") + return t.callExpression( + t.memberExpression( + linguiInstance ?? t.identifier(this.i18nImportName), + t.identifier("_") ), [messageDescriptor] ) } - - getObjectPropertyByKey( - objectExp: NodePath, - key: string - ): NodePath { - return objectExp.get("properties").find( - (property) => - property.isObjectProperty() && - (property.get("key") as NodePath).isIdentifier({ - name: key, - }) - ) as NodePath - } - - /** - * Custom matchers - */ - isLinguiIdentifier(path: NodePath, name: JsMacroName) { - if ( - path.isIdentifier() && - (path.referencesImport(MACRO_CORE_PACKAGE, name) || - path.referencesImport(MACRO_LEGACY_PACKAGE, name)) - ) { - return true - } - } - - isUseLinguiHook(path: NodePath) { - if ( - path.isIdentifier() && - (path.referencesImport(MACRO_REACT_PACKAGE, JsMacroName.useLingui) || - path.referencesImport(MACRO_LEGACY_PACKAGE, JsMacroName.useLingui)) - ) { - return true - } - } - - isDefineMessage(path: NodePath): boolean { - return ( - this.isLinguiIdentifier(path, JsMacroName.defineMessage) || - this.isLinguiIdentifier(path, JsMacroName.msg) - ) - } - - isI18nMethod(path: NodePath) { - if (!path.isTaggedTemplateExpression()) { - return - } - - const tag = path.get("tag") - - return ( - this.isLinguiIdentifier(tag, JsMacroName.t) || - (tag.isCallExpression() && - this.isLinguiIdentifier(tag.get("callee"), JsMacroName.t)) - ) - } - - isChoiceMethod(path: NodePath) { - if (!path.isCallExpression()) { - return - } - - const callee = path.get("callee") - - if (this.isLinguiIdentifier(callee, JsMacroName.plural)) { - return JsMacroName.plural - } - if (this.isLinguiIdentifier(callee, JsMacroName.select)) { - return JsMacroName.select - } - if (this.isLinguiIdentifier(callee, JsMacroName.selectOrdinal)) { - return JsMacroName.selectOrdinal - } - } } diff --git a/packages/babel-plugin-lingui-macro/src/macroJs.test.ts b/packages/babel-plugin-lingui-macro/src/macroJsAst.test.ts similarity index 81% rename from packages/babel-plugin-lingui-macro/src/macroJs.test.ts rename to packages/babel-plugin-lingui-macro/src/macroJsAst.test.ts index 1eb5c50a0..4e00d5962 100644 --- a/packages/babel-plugin-lingui-macro/src/macroJs.test.ts +++ b/packages/babel-plugin-lingui-macro/src/macroJsAst.test.ts @@ -1,6 +1,9 @@ -import * as types from "@babel/types" import { type CallExpression, type Expression } from "@babel/types" -import { MacroJs } from "./macroJs" +import { + tokenizeTemplateLiteral, + tokenizeChoiceComponent, + createMacroJsContext, +} from "./macroJsAst" import type { NodePath } from "@babel/traverse" import { transformSync } from "@babel/core" import { JsMacroName } from "./constants" @@ -31,23 +34,17 @@ const parseExpression = (expression: string) => { return path } -function createMacro() { - return new MacroJs( - { types }, - { - i18nImportName: "i18n", - stripNonEssentialProps: false, - useLinguiImportName: "useLingui", - } - ) +function createMacroCtx() { + return createMacroJsContext((identifier, macro) => { + return identifier.name === macro + }, false) } describe("js macro", () => { describe("tokenizeTemplateLiteral", () => { it("simple message without arguments", () => { - const macro = createMacro() const exp = parseExpression("t`Message`") - const tokens = macro.tokenizeTemplateLiteral(exp) + const tokens = tokenizeTemplateLiteral(exp.node, createMacroCtx()) expect(tokens).toEqual([ { type: "text", @@ -57,9 +54,8 @@ describe("js macro", () => { }) it("with custom lingui instance", () => { - const macro = createMacro() const exp = parseExpression("t(i18n)`Message`") - const tokens = macro.tokenizeTemplateLiteral(exp) + const tokens = tokenizeTemplateLiteral(exp.node, createMacroCtx()) expect(tokens).toEqual([ { type: "text", @@ -69,9 +65,8 @@ describe("js macro", () => { }) it("message with named argument", () => { - const macro = createMacro() const exp = parseExpression("t`Message ${name}`") - const tokens = macro.tokenizeTemplateLiteral(exp) + const tokens = tokenizeTemplateLiteral(exp.node, createMacroCtx()) expect(tokens).toEqual([ { type: "text", @@ -89,9 +84,8 @@ describe("js macro", () => { }) it("message with positional argument", () => { - const macro = createMacro() const exp = parseExpression("t`Message ${obj.name}`") - const tokens = macro.tokenizeTemplateLiteral(exp) + const tokens = tokenizeTemplateLiteral(exp.node, createMacroCtx()) expect(tokens).toEqual([ { type: "text", @@ -108,9 +102,8 @@ describe("js macro", () => { }) it("message with plural", () => { - const macro = createMacro() const exp = parseExpression("t`Message ${plural(count, {})}`") - const tokens = macro.tokenizeTemplateLiteral(exp) + const tokens = tokenizeTemplateLiteral(exp.node, createMacroCtx()) expect(tokens).toEqual([ { type: "text", @@ -129,9 +122,8 @@ describe("js macro", () => { }) it("message with unicode \\u chars is interpreted by babel", () => { - const macro = createMacro() const exp = parseExpression("t`Message \\u0020`") - const tokens = macro.tokenizeTemplateLiteral(exp) + const tokens = tokenizeTemplateLiteral(exp.node, createMacroCtx()) expect(tokens).toEqual([ { type: "text", @@ -141,9 +133,8 @@ describe("js macro", () => { }) it("message with unicode \\x chars is interpreted by babel", () => { - const macro = createMacro() const exp = parseExpression("t`Bienvenue\\xA0!`") - const tokens = macro.tokenizeTemplateLiteral(exp) + const tokens = tokenizeTemplateLiteral(exp.node, createMacroCtx()) expect(tokens).toEqual([ { type: "text", @@ -154,11 +145,10 @@ describe("js macro", () => { }) it("message with double escaped literals it's stripped", () => { - const macro = createMacro() const exp = parseExpression( "t`Passing \\`${argSet}\\` is not supported.`" ) - const tokens = macro.tokenizeTemplateLiteral(exp) + const tokens = tokenizeTemplateLiteral(exp.node, createMacroCtx()) expect(tokens).toMatchObject([ { type: "text", @@ -183,15 +173,15 @@ describe("js macro", () => { }) }) - describe("tokenizeChoiceMethod", () => { + describe("tokenizeChoiceComponent", () => { it("plural", () => { - const macro = createMacro() const exp = parseExpression( "plural(count, { one: '# book', other: '# books'})" ) - const tokens = macro.tokenizeChoiceComponent( - exp as NodePath, - JsMacroName.plural + const tokens = tokenizeChoiceComponent( + (exp as NodePath).node, + JsMacroName.plural, + createMacroCtx() ) expect(tokens).toEqual({ type: "arg", @@ -209,7 +199,6 @@ describe("js macro", () => { }) it("plural with offset", () => { - const macro = createMacro() const exp = parseExpression( `plural(count, { offset: 1, @@ -218,9 +207,10 @@ describe("js macro", () => { other: '# books' })` ) - const tokens = macro.tokenizeChoiceComponent( - exp as NodePath, - JsMacroName.plural + const tokens = tokenizeChoiceComponent( + (exp as NodePath).node, + JsMacroName.plural, + createMacroCtx() ) expect(tokens).toEqual({ type: "arg", @@ -240,13 +230,13 @@ describe("js macro", () => { }) it("plural with template literal", () => { - const macro = createMacro() const exp = parseExpression( "plural(count, { one: `# glass of ${drink}`, other: `# glasses of ${drink}`})" ) - const tokens = macro.tokenizeChoiceComponent( - exp as NodePath, - JsMacroName.plural + const tokens = tokenizeChoiceComponent( + (exp as NodePath).node, + JsMacroName.plural, + createMacroCtx() ) expect(tokens).toEqual({ type: "arg", @@ -290,7 +280,6 @@ describe("js macro", () => { }) it("plural with select", () => { - const macro = createMacro() const exp = parseExpression( `plural(count, { one: select(gender, { @@ -301,9 +290,10 @@ describe("js macro", () => { other: otherText })` ) - const tokens = macro.tokenizeChoiceComponent( - exp as NodePath, - JsMacroName.plural + const tokens = tokenizeChoiceComponent( + (exp as NodePath).node, + JsMacroName.plural, + createMacroCtx() ) expect(tokens).toEqual({ type: "arg", @@ -350,7 +340,6 @@ describe("js macro", () => { }) it("select", () => { - const macro = createMacro() const exp = parseExpression( `select(gender, { male: "he", @@ -358,9 +347,10 @@ describe("js macro", () => { other: "they" })` ) - const tokens = macro.tokenizeChoiceComponent( - exp as NodePath, - JsMacroName.select + const tokens = tokenizeChoiceComponent( + (exp as NodePath).node, + JsMacroName.select, + createMacroCtx() ) expect(tokens).toMatchObject({ format: "select", diff --git a/packages/babel-plugin-lingui-macro/src/macroJsAst.ts b/packages/babel-plugin-lingui-macro/src/macroJsAst.ts new file mode 100644 index 000000000..51ca1bfad --- /dev/null +++ b/packages/babel-plugin-lingui-macro/src/macroJsAst.ts @@ -0,0 +1,328 @@ +import * as t from "@babel/types" +import { + ObjectExpression, + Expression, + TemplateLiteral, + Identifier, + Node, + CallExpression, + StringLiteral, + ObjectProperty, +} from "@babel/types" +import { MsgDescriptorPropKey, JsMacroName } from "./constants" +import { Token, TextToken, ArgToken } from "./icu" +import { createMessageDescriptorFromTokens } from "./messageDescriptorUtils" +import { makeCounter } from "./utils" + +export type MacroJsContext = { + // Positional expressions counter (e.g. for placeholders `Hello {0}, today is {1}`) + getExpressionIndex: () => number + stripNonEssentialProps: boolean + isLinguiIdentifier: (node: Identifier, macro: JsMacroName) => boolean +} + +export function createMacroJsContext( + isLinguiIdentifier: MacroJsContext["isLinguiIdentifier"], + stripNonEssentialProps: boolean +): MacroJsContext { + return { + getExpressionIndex: makeCounter(), + isLinguiIdentifier, + stripNonEssentialProps, + } +} + +/** + * `processDescriptor` expand macros inside message descriptor. + * Message descriptor is used in `defineMessage`. + * + * { + * comment: "Description", + * message: plural("value", { one: "book", other: "books" }) + * } + * + * ↓ ↓ ↓ ↓ ↓ ↓ + * + * { + * comment: "Description", + * id: + * message: "{value, plural, one {book} other {books}}" + * } + * + */ +export function processDescriptor( + descriptor: ObjectExpression, + ctx: MacroJsContext +) { + const messageProperty = getObjectPropertyByKey( + descriptor, + MsgDescriptorPropKey.message + ) + const idProperty = getObjectPropertyByKey(descriptor, MsgDescriptorPropKey.id) + const contextProperty = getObjectPropertyByKey( + descriptor, + MsgDescriptorPropKey.context + ) + const commentProperty = getObjectPropertyByKey( + descriptor, + MsgDescriptorPropKey.comment + ) + + let tokens: Token[] = [] + + // if there's `message` property, replace macros with formatted message + if (messageProperty) { + // Inside message descriptor the `t` macro in `message` prop is optional. + // Template strings are always processed as if they were wrapped by `t`. + const messageValue = messageProperty.value + + tokens = t.isTemplateLiteral(messageValue) + ? tokenizeTemplateLiteral(messageValue, ctx) + : tokenizeNode(messageValue, true, ctx) + } + + return createMessageDescriptorFromTokens( + tokens, + descriptor.loc, + ctx.stripNonEssentialProps, + { + id: idProperty, + context: contextProperty, + comment: commentProperty, + } + ) +} + +export function tokenizeNode( + node: Node, + ignoreExpression = false, + ctx: MacroJsContext +): Token[] { + if (isI18nMethod(node, ctx)) { + // t + return tokenizeTemplateLiteral(node as Expression, ctx) + } + + if (t.isCallExpression(node) && isArgDecorator(node, ctx)) { + return [tokenizeArg(node, ctx)] + } + + const choiceMethod = isChoiceMethod(node, ctx) + // plural, select and selectOrdinal + if (choiceMethod) { + return [tokenizeChoiceComponent(node as CallExpression, choiceMethod, ctx)] + } + + if (t.isStringLiteral(node)) { + return [ + { + type: "text", + value: node.value, + } satisfies TextToken, + ] + } + // if (isFormatMethod(node.callee)) { + // // date, number + // return transformFormatMethod(node, file, props, root) + + if (!ignoreExpression) { + return [tokenizeExpression(node, ctx)] + } +} + +/** + * `node` is a TemplateLiteral. node.quasi contains + * text chunks and node.expressions contains expressions. + * Both arrays must be zipped together to get the final list of tokens. + */ +export function tokenizeTemplateLiteral( + node: Expression, + ctx: MacroJsContext +): Token[] { + const tpl = t.isTaggedTemplateExpression(node) + ? node.quasi + : (node as TemplateLiteral) + + const expressions = tpl.expressions as Expression[] + + return tpl.quasis.flatMap((text, i) => { + const value = text.value.cooked + + let argTokens: Token[] = [] + const currExp = expressions[i] + + if (currExp) { + argTokens = t.isCallExpression(currExp) + ? tokenizeNode(currExp, false, ctx) + : [tokenizeExpression(currExp, ctx)] + } + const textToken: TextToken = { + type: "text", + value, + } + return [...(value ? [textToken] : []), ...argTokens] + }) +} + +export function tokenizeChoiceComponent( + node: CallExpression, + componentName: string, + ctx: MacroJsContext +): ArgToken { + const format = componentName.toLowerCase() + + const token: ArgToken = { + ...tokenizeExpression(node.arguments[0], ctx), + format: format, + options: { + offset: undefined, + }, + } + + const props = (node.arguments[1] as ObjectExpression).properties + + for (const attr of props) { + if (!t.isObjectProperty(attr)) { + throw new Error("Expected an ObjectProperty") + } + + const key = attr.key + const attrValue = attr.value as Expression + + // name is either: + // NumericLiteral => convert to `={number}` + // StringLiteral => key.value + // Identifier => key.name + const name = t.isNumericLiteral(key) + ? `=${key.value}` + : (key as Identifier).name || (key as StringLiteral).value + + if (format !== "select" && name === "offset") { + token.options.offset = (attrValue as StringLiteral).value + } else { + let value: ArgToken["options"][string] + + if (t.isTemplateLiteral(attrValue)) { + value = tokenizeTemplateLiteral(attrValue, ctx) + } else if (t.isCallExpression(attrValue)) { + value = tokenizeNode(attrValue, false, ctx) + } else if (t.isStringLiteral(attrValue)) { + value = attrValue.value + } else if (t.isExpression(attrValue)) { + value = tokenizeExpression(attrValue, ctx) + } else { + value = (attrValue as unknown as StringLiteral).value + } + token.options[name] = value + } + } + + return token +} + +export function tokenizeExpression( + node: Node | Expression, + ctx: MacroJsContext +): ArgToken { + return { + type: "arg", + name: expressionToArgument(node as Expression, ctx), + value: node as Expression, + } +} + +export function tokenizeArg( + node: CallExpression, + ctx: MacroJsContext +): ArgToken { + const arg = node.arguments[0] as Expression + + return { + type: "arg", + name: expressionToArgument(arg, ctx), + raw: true, + value: arg, + } +} + +export function expressionToArgument( + exp: Expression, + ctx: MacroJsContext +): string { + if (t.isIdentifier(exp)) { + return exp.name + } else if (t.isStringLiteral(exp)) { + return exp.value + } else { + return String(ctx.getExpressionIndex()) + } +} + +export function isArgDecorator(node: Node, ctx: MacroJsContext): boolean { + return ( + t.isCallExpression(node) && + isLinguiIdentifier(node.callee, JsMacroName.arg, ctx) + ) +} + +export function isDefineMessage(node: Node, ctx: MacroJsContext): boolean { + return ( + isLinguiIdentifier(node, JsMacroName.defineMessage, ctx) || + isLinguiIdentifier(node, JsMacroName.msg, ctx) + ) +} + +export function isI18nMethod(node: Node, ctx: MacroJsContext) { + if (!t.isTaggedTemplateExpression(node)) { + return + } + + const tag = node.tag + + return ( + isLinguiIdentifier(tag, JsMacroName.t, ctx) || + (t.isCallExpression(tag) && + isLinguiIdentifier(tag.callee, JsMacroName.t, ctx)) + ) +} + +export function isLinguiIdentifier( + node: Node, + name: JsMacroName, + ctx: MacroJsContext +) { + if (!t.isIdentifier(node)) { + return false + } + + return ctx.isLinguiIdentifier(node, name) +} + +export function isChoiceMethod(node: Node, ctx: MacroJsContext) { + if (!t.isCallExpression(node)) { + return + } + + if (isLinguiIdentifier(node.callee, JsMacroName.plural, ctx)) { + return JsMacroName.plural + } + if (isLinguiIdentifier(node.callee, JsMacroName.select, ctx)) { + return JsMacroName.select + } + if (isLinguiIdentifier(node.callee, JsMacroName.selectOrdinal, ctx)) { + return JsMacroName.selectOrdinal + } +} + +function getObjectPropertyByKey( + objectExp: ObjectExpression, + key: string +): ObjectProperty { + return objectExp.properties.find( + (property) => + t.isObjectProperty(property) && + t.isIdentifier(property.key as Expression, { + name: key, + }) + ) as ObjectProperty +} diff --git a/packages/babel-plugin-lingui-macro/src/macroJsx.ts b/packages/babel-plugin-lingui-macro/src/macroJsx.ts index 75c098113..cc8ae2e1f 100644 --- a/packages/babel-plugin-lingui-macro/src/macroJsx.ts +++ b/packages/babel-plugin-lingui-macro/src/macroJsx.ts @@ -326,6 +326,7 @@ export class MacroJSX { } else { option = this.tokenizeChildren(value as JSXChildPath) } + if (pluralRuleRe.test(name)) { token.options[jsx2icuExactChoice(name)] = option } else { diff --git a/packages/babel-plugin-lingui-macro/src/messageDescriptorUtils.ts b/packages/babel-plugin-lingui-macro/src/messageDescriptorUtils.ts index 735ec45f9..c85936fb2 100644 --- a/packages/babel-plugin-lingui-macro/src/messageDescriptorUtils.ts +++ b/packages/babel-plugin-lingui-macro/src/messageDescriptorUtils.ts @@ -1,9 +1,8 @@ -import ICUMessageFormat, { Tokens } from "./icu" +import { ICUMessageFormat, Tokens, ParsedResult } from "./icu" import { SourceLocation, ObjectProperty, ObjectExpression, - isObjectProperty, Expression, } from "@babel/types" import { EXTRACT_MARK, MsgDescriptorPropKey } from "./constants" @@ -20,6 +19,12 @@ type TextWithLoc = { loc?: SourceLocation } +function isObjectProperty( + node: TextWithLoc | ObjectProperty +): node is ObjectProperty { + return "type" in node +} + export function createMessageDescriptorFromTokens( tokens: Tokens, oldLoc: SourceLocation, @@ -30,7 +35,25 @@ export function createMessageDescriptorFromTokens( comment?: TextWithLoc | ObjectProperty } = {} ) { - const { message, values, jsxElements } = buildICUFromTokens(tokens) + return createMessageDescriptor( + buildICUFromTokens(tokens), + oldLoc, + stripNonEssentialProps, + defaults + ) +} + +export function createMessageDescriptor( + result: Partial, + oldLoc: SourceLocation, + stripNonEssentialProps: boolean, + defaults: { + id?: TextWithLoc | ObjectProperty + context?: TextWithLoc | ObjectProperty + comment?: TextWithLoc | ObjectProperty + } = {} +) { + const { message, values, elements } = result const properties: ObjectProperty[] = [] @@ -85,12 +108,17 @@ export function createMessageDescriptorFromTokens( } } - properties.push(createValuesProperty(MsgDescriptorPropKey.values, values)) - properties.push( - createValuesProperty(MsgDescriptorPropKey.components, jsxElements) - ) + if (values) { + properties.push(createValuesProperty(MsgDescriptorPropKey.values, values)) + } - return createMessageDescriptor( + if (elements) { + properties.push( + createValuesProperty(MsgDescriptorPropKey.components, elements) + ) + } + + return createMessageDescriptorObjectExpression( properties, // preserve line numbers for extractor oldLoc @@ -145,7 +173,7 @@ function getTextFromExpression(exp: Expression): string { } } -function createMessageDescriptor( +function createMessageDescriptorObjectExpression( properties: ObjectProperty[], oldLoc?: SourceLocation ): ObjectExpression { diff --git a/packages/babel-plugin-lingui-macro/test/js-useLingui.test.ts b/packages/babel-plugin-lingui-macro/test/js-useLingui.test.ts index 83ecec067..d24cf8f65 100644 --- a/packages/babel-plugin-lingui-macro/test/js-useLingui.test.ts +++ b/packages/babel-plugin-lingui-macro/test/js-useLingui.test.ts @@ -1,6 +1,8 @@ import { makeConfig } from "@lingui/conf" import { macroTester } from "./macroTester" +describe.skip("", () => {}) + macroTester({ cases: [ {