diff --git a/packages/babel-plugin-extract-messages/package.json b/packages/babel-plugin-extract-messages/package.json index bffa75dd7..bb96bd66a 100644 --- a/packages/babel-plugin-extract-messages/package.json +++ b/packages/babel-plugin-extract-messages/package.json @@ -43,7 +43,8 @@ "@babel/core": "^7.21.0", "@babel/traverse": "7.20.12", "@babel/types": "^7.20.7", - "@lingui/jest-mocks": "*", + "@lingui/jest-mocks": "workspace:*", + "@lingui/macro": "workspace:*", "unbuild": "2.0.0" } } diff --git a/packages/babel-plugin-extract-messages/src/index.ts b/packages/babel-plugin-extract-messages/src/index.ts index 0b937cb65..0fcc54dd4 100644 --- a/packages/babel-plugin-extract-messages/src/index.ts +++ b/packages/babel-plugin-extract-messages/src/index.ts @@ -2,14 +2,12 @@ import type * as BabelTypesNamespace from "@babel/types" import { Expression, Identifier, - ImportSpecifier, JSXAttribute, Node, ObjectExpression, ObjectProperty, } from "@babel/types" -import type { PluginObj, PluginPass } from "@babel/core" -import type { NodePath } from "@babel/core" +import type { PluginObj, PluginPass, NodePath } from "@babel/core" import type { Hub } from "@babel/traverse" type BabelTypes = typeof BabelTypesNamespace @@ -148,14 +146,13 @@ function hasI18nComment(node: Node): boolean { } export default function ({ types: t }: { types: BabelTypes }): PluginObj { - let localTransComponentName: string - - function isTransComponent(node: Node) { + function isTransComponent(path: NodePath) { return ( - t.isJSXElement(node) && - t.isJSXIdentifier(node.openingElement.name, { - name: localTransComponentName, - }) + path.isJSXElement() && + path + .get("openingElement") + .get("name") + .referencesImport("@lingui/react", "Trans") ) } @@ -190,33 +187,10 @@ export default function ({ types: t }: { types: BabelTypes }): PluginObj { return { visitor: { - // Get the local name of Trans component. Usually it's just `Trans`, but - // it might be different when the import is aliased: - // import { Trans as T } from '@lingui/react'; - ImportDeclaration(path) { - const { node } = path - - const moduleName = node.source.value - if (!["@lingui/react", "@lingui/core"].includes(moduleName)) return - - const importDeclarations: Record = {} - if (moduleName === "@lingui/react") { - node.specifiers.forEach((specifier) => { - specifier = specifier as ImportSpecifier - importDeclarations[(specifier.imported as Identifier).name] = - specifier.local.name - }) - - // Trans import might be missing if there's just Plural or similar macro. - // If there's no alias, consider it was imported as Trans. - localTransComponentName = importDeclarations["Trans"] || "Trans" - } - }, - // Extract translation from component. JSXElement(path, ctx) { const { node } = path - if (!localTransComponentName || !isTransComponent(node)) return + if (!isTransComponent(path)) return const attrs = (node.openingElement.attributes as JSXAttribute[]) || [] diff --git a/packages/babel-plugin-extract-messages/test/fixtures/js-with-macros.js b/packages/babel-plugin-extract-messages/test/fixtures/js-with-macros.js index d9dcf8741..d2c2e10ea 100644 --- a/packages/babel-plugin-extract-messages/test/fixtures/js-with-macros.js +++ b/packages/babel-plugin-extract-messages/test/fixtures/js-with-macros.js @@ -1,4 +1,4 @@ -import { t, defineMessage, msg, useLingui } from "@lingui/macro" +import { t, defineMessage, msg, useLingui, plural } from "@lingui/macro" t`Message` diff --git a/packages/babel-plugin-extract-messages/test/index.ts b/packages/babel-plugin-extract-messages/test/index.ts index 72832cd00..796548a89 100644 --- a/packages/babel-plugin-extract-messages/test/index.ts +++ b/packages/babel-plugin-extract-messages/test/index.ts @@ -3,6 +3,7 @@ import fs from "fs" import { transform as babelTransform } from "@babel/core" import plugin, { ExtractedMessage, ExtractPluginOpts } from "../src/index" import { mockConsole } from "@lingui/jest-mocks" +import linguiMacroPlugin, { type LinguiPluginOpts } from "@lingui/macro/plugin" const transform = (filename: string) => { const rootDir = path.join(__dirname, "fixtures") @@ -42,15 +43,10 @@ const transformCode = ( plugins: [ "@babel/plugin-syntax-jsx", [ - "macros", + linguiMacroPlugin, { - lingui: { extract: true }, - // macro plugin uses package `resolve` to find a path of macro file - // this will not follow jest pathMapping and will resolve path from ./build - // instead of ./src which makes testing & developing hard. - // here we override resolve and provide correct path for testing - resolvePath: (source: string) => require.resolve(source), - }, + extract: true, + } satisfies LinguiPluginOpts, ], [plugin, pluginOpts], ], diff --git a/packages/cli/package.json b/packages/cli/package.json index 720eb72dd..0d7b2fe34 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -57,6 +57,7 @@ "@lingui/conf": "4.8.0-next.0", "@lingui/core": "4.8.0-next.0", "@lingui/format-po": "4.8.0-next.0", + "@lingui/macro": "4.8.0-next.0", "@lingui/message-utils": "4.8.0-next.0", "babel-plugin-macros": "^3.0.1", "chalk": "^4.1.0", @@ -80,7 +81,6 @@ }, "devDependencies": { "@lingui/jest-mocks": "*", - "@lingui/macro": "4.8.0-next.0", "@types/convert-source-map": "^2.0.0", "@types/glob": "^8.1.0", "@types/micromatch": "^4.0.1", diff --git a/packages/cli/src/api/extractors/babel.ts b/packages/cli/src/api/extractors/babel.ts index b4d68efef..a2fe6c673 100644 --- a/packages/cli/src/api/extractors/babel.ts +++ b/packages/cli/src/api/extractors/babel.ts @@ -9,7 +9,8 @@ import linguiExtractMessages from "@lingui/babel-plugin-extract-messages" import type { ExtractorType } from "@lingui/conf" import { ParserPlugin } from "@babel/parser" -import { LinguiMacroOpts } from "@lingui/macro/node" +import { type LinguiPluginOpts } from "@lingui/macro/plugin" +import linguiMacroPlugin from "@lingui/macro/plugin" import { ExtractedMessage, ExtractorCtx } from "@lingui/conf" const babelRe = new RegExp( @@ -120,18 +121,11 @@ export async function extractFromFileWithBabel( plugins: [ [ - "macros", + linguiMacroPlugin, { - // macro plugin uses package `resolve` to find a path of macro file - // this will not follow jest pathMapping and will resolve path from ./build - // instead of ./src which makes testing & developing hard. - // here we override resolve and provide correct path for testing - resolvePath: (source: string) => require.resolve(source), - lingui: { - extract: true, - linguiConfig: ctx.linguiConfig, - } satisfies LinguiMacroOpts, - }, + extract: true, + linguiConfig: ctx.linguiConfig, + } satisfies LinguiPluginOpts, ], [ linguiExtractMessages, diff --git a/packages/conf/src/__snapshots__/index.test.ts.snap b/packages/conf/src/__snapshots__/index.test.ts.snap index be831a033..a09709428 100644 --- a/packages/conf/src/__snapshots__/index.test.ts.snap +++ b/packages/conf/src/__snapshots__/index.test.ts.snap @@ -53,12 +53,18 @@ exports[`@lingui/conf should return default config 1`] = ` pseudoLocale: , rootDir: ., runtimeConfigModule: { - TransImportModule: @lingui/react, - TransImportName: Trans, - i18nImportModule: @lingui/core, - i18nImportName: i18n, - useLinguiImportModule: @lingui/react, - useLinguiImportName: useLingui, + Trans: [ + @lingui/react, + Trans, + ], + i18n: [ + @lingui/core, + i18n, + ], + useLingui: [ + @lingui/react, + useLingui, + ], }, service: { apiKey: , diff --git a/packages/conf/src/migrations/normalizeRuntimeConfigModule.test.ts b/packages/conf/src/migrations/normalizeRuntimeConfigModule.test.ts index 3842691f8..6daff5130 100644 --- a/packages/conf/src/migrations/normalizeRuntimeConfigModule.test.ts +++ b/packages/conf/src/migrations/normalizeRuntimeConfigModule.test.ts @@ -6,12 +6,18 @@ describe("normalizeRuntimeConfigModule", () => { expect(actual.runtimeConfigModule).toMatchInlineSnapshot(` { - TransImportModule: @lingui/react, - TransImportName: Trans, - i18nImportModule: @lingui/core, - i18nImportName: i18n, - useLinguiImportModule: @lingui/react, - useLinguiImportName: useLingui, + Trans: [ + @lingui/react, + Trans, + ], + i18n: [ + @lingui/core, + i18n, + ], + useLingui: [ + @lingui/react, + useLingui, + ], } `) }) @@ -23,12 +29,18 @@ describe("normalizeRuntimeConfigModule", () => { expect(actual.runtimeConfigModule).toMatchInlineSnapshot(` { - TransImportModule: @lingui/react, - TransImportName: Trans, - i18nImportModule: ../my-i18n, - i18nImportName: myI18n, - useLinguiImportModule: @lingui/react, - useLinguiImportName: useLingui, + Trans: [ + @lingui/react, + Trans, + ], + i18n: [ + ../my-i18n, + myI18n, + ], + useLingui: [ + @lingui/react, + useLingui, + ], } `) }) @@ -44,12 +56,18 @@ describe("normalizeRuntimeConfigModule", () => { expect(actual.runtimeConfigModule).toMatchInlineSnapshot(` { - TransImportModule: ./custom-trans, - TransImportName: myTrans, - i18nImportModule: ./custom-i18n, - i18nImportName: myI18n, - useLinguiImportModule: ./custom-use-lingui, - useLinguiImportName: myLingui, + Trans: [ + ./custom-trans, + myTrans, + ], + i18n: [ + ./custom-i18n, + myI18n, + ], + useLingui: [ + ./custom-use-lingui, + myLingui, + ], } `) }) @@ -65,12 +83,18 @@ describe("normalizeRuntimeConfigModule", () => { expect(actual.runtimeConfigModule).toMatchInlineSnapshot(` { - TransImportModule: ./custom-trans, - TransImportName: Trans, - i18nImportModule: ./custom-i18n, - i18nImportName: i18n, - useLinguiImportModule: ./custom-use-lingui, - useLinguiImportName: useLingui, + Trans: [ + ./custom-trans, + Trans, + ], + i18n: [ + ./custom-i18n, + i18n, + ], + useLingui: [ + ./custom-use-lingui, + useLingui, + ], } `) }) diff --git a/packages/conf/src/migrations/normalizeRuntimeConfigModule.ts b/packages/conf/src/migrations/normalizeRuntimeConfigModule.ts index f075483eb..ffb92a34b 100644 --- a/packages/conf/src/migrations/normalizeRuntimeConfigModule.ts +++ b/packages/conf/src/migrations/normalizeRuntimeConfigModule.ts @@ -1,6 +1,6 @@ import { LinguiConfig, LinguiConfigNormalized } from "../types" -type ModuleSrc = [source: string, identifier?: string] +type ModuleSrc = [source: string, identifier: string] const getSymbolSource = ( defaults: ModuleSrc, @@ -26,28 +26,21 @@ const getSymbolSource = ( export function normalizeRuntimeConfigModule( config: Pick ) { - const [i18nImportModule, i18nImportName] = getSymbolSource( - ["@lingui/core", "i18n"], - config.runtimeConfigModule - ) - const [TransImportModule, TransImportName] = getSymbolSource( - ["@lingui/react", "Trans"], - config.runtimeConfigModule - ) - const [useLinguiImportModule, useLinguiImportName] = getSymbolSource( - ["@lingui/react", "useLingui"], - config.runtimeConfigModule - ) - return { ...config, runtimeConfigModule: { - i18nImportModule, - i18nImportName, - TransImportModule, - TransImportName, - useLinguiImportModule, - useLinguiImportName, + i18n: getSymbolSource( + ["@lingui/core", "i18n"], + config.runtimeConfigModule + ), + useLingui: getSymbolSource( + ["@lingui/react", "useLingui"], + config.runtimeConfigModule + ), + Trans: getSymbolSource( + ["@lingui/react", "Trans"], + config.runtimeConfigModule + ), } satisfies LinguiConfigNormalized["runtimeConfigModule"], } } diff --git a/packages/conf/src/types.ts b/packages/conf/src/types.ts index ea21f44e0..0754406fc 100644 --- a/packages/conf/src/types.ts +++ b/packages/conf/src/types.ts @@ -217,17 +217,16 @@ export type LinguiConfig = { } } +type ModuleSourceNormalized = readonly [module: string, specifier: string] + export type LinguiConfigNormalized = Omit< LinguiConfig, "runtimeConfigModule" > & { fallbackLocales?: FallbackLocales runtimeConfigModule: { - i18nImportModule: string - i18nImportName: string - TransImportModule: string - TransImportName: string - useLinguiImportModule: string - useLinguiImportName: string + i18n: ModuleSourceNormalized + useLingui: ModuleSourceNormalized + Trans: ModuleSourceNormalized } } diff --git a/packages/macro/package.json b/packages/macro/package.json index 3b818f1c4..520f408f5 100644 --- a/packages/macro/package.json +++ b/packages/macro/package.json @@ -54,6 +54,17 @@ "import": { "types": "./dist/index.d.ts" } + }, + "./plugin": { + "require": { + "types": "./dist/plugin.d.ts", + "default": "./dist/plugin.cjs" + }, + "import": { + "types": "./dist/plugin.d.ts", + "default": "./dist/index.mjs" + + } } }, "files": [ @@ -63,6 +74,7 @@ "index.d.ts" ], "dependencies": { + "@babel/core": "7.20.12", "@babel/runtime": "^7.20.13", "@babel/types": "^7.20.7", "@lingui/conf": "4.8.0-next.0", @@ -73,8 +85,12 @@ "@lingui/react": "^4.0.0", "babel-plugin-macros": "2 || 3" }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + }, "devDependencies": { - "@babel/core": "7.20.12", "@babel/parser": "7.20.15", "@babel/traverse": "7.20.12", "@types/babel-plugin-macros": "^2.8.5", diff --git a/packages/macro/src/constants.ts b/packages/macro/src/constants.ts index 81ba04b63..00def66ca 100644 --- a/packages/macro/src/constants.ts +++ b/packages/macro/src/constants.ts @@ -3,3 +3,21 @@ export const MESSAGE = "message" export const COMMENT = "comment" export const EXTRACT_MARK = "i18n" export const CONTEXT = "context" +export const MACRO_PACKAGE = "@lingui/macro" + +export enum JsMacroName { + t = "t", + plural = "plural", + select = "select", + selectOrdinal = "selectOrdinal", + msg = "msg", + defineMessage = "defineMessage", + useLingui = "useLingui", +} + +export enum JsxMacroName { + Trans = "Trans", + Plural = "Plural", + Select = "Select", + SelectOrdinal = "SelectOrdinal", +} diff --git a/packages/macro/src/index.ts b/packages/macro/src/index.ts index ceaa7af5a..f8051e334 100644 --- a/packages/macro/src/index.ts +++ b/packages/macro/src/index.ts @@ -1,282 +1,45 @@ import { createMacro, MacroParams } from "babel-plugin-macros" -import { getConfig as loadConfig, LinguiConfigNormalized } from "@lingui/conf" -import MacroJS from "./macroJs" -import MacroJSX from "./macroJsx" -import { NodePath } from "@babel/traverse" -import * as t from "@babel/types" -export type LinguiMacroOpts = { - // explicitly set by CLI when running extraction process - extract?: boolean - linguiConfig?: LinguiConfigNormalized -} - -const jsMacroTags = new Set([ - "defineMessage", - "msg", - "arg", - "t", - "useLingui", - "plural", - "select", - "selectOrdinal", -]) - -const jsxMacroTags = new Set(["Trans", "Plural", "Select", "SelectOrdinal"]) - -let config: LinguiConfigNormalized +import { VisitNodeObject } from "@babel/traverse" +import { Program } from "@babel/types" -function getConfig(_config?: LinguiConfigNormalized) { - if (_config) { - config = _config - } - if (!config) { - config = loadConfig() - } - return config -} +import linguiPlugin from "../src/plugin" +import { JsMacroName, JsxMacroName } from "./constants" -function macro({ references, state, babel, config }: MacroParams) { - const opts: LinguiMacroOpts = config as LinguiMacroOpts +function macro({ state, babel, config }: MacroParams) { + if (!state.get("linguiProcessed")) { + state.opts = config + const plugin = linguiPlugin(babel) - const body = state.file.path.node.body - const { - i18nImportModule, - i18nImportName, - TransImportModule, - TransImportName, - useLinguiImportModule, - useLinguiImportName, - } = getConfig(opts.linguiConfig).runtimeConfigModule - - const jsxNodes = new Set() - const jsNodes = new Set() - let needsI18nImport = false - let needsUseLinguiImport = false - - // create unique name for all _t, must be outside the loop - const uniq_tIdentifier = state.file.scope.generateUidIdentifier("_t") - - let nameMap = new Map() - Object.keys(references).forEach((tagName) => { - const nodes = references[tagName] - - if (jsMacroTags.has(tagName)) { - nodes.forEach((path) => { - if (tagName !== "useLingui") { - nameMap.set(tagName, (path.node as t.Identifier).name) - jsNodes.add(path.parentPath) - } else { - needsUseLinguiImport = true - nameMap.set("_t", uniq_tIdentifier.name) - processUseLingui( - path, - useLinguiImportName, - uniq_tIdentifier.name - )?.forEach((n) => jsNodes.add(n)) - } - }) - } 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((path) => { - nameMap.set(tagName, (path.node as t.JSXIdentifier).name) - - // identifier.openingElement.jsxElement - jsxNodes.add(path.parentPath.parentPath) - }) - } else { - throw nodes[0].buildCodeFrameError(`Unknown macro ${tagName}`) - } - }) + const { enter, exit } = plugin.visitor.Program as VisitNodeObject< + any, + Program + > - const stripNonEssentialProps = - process.env.NODE_ENV == "production" && !opts.extract + enter(state.file.path, state) + state.file.path.traverse(plugin.visitor, state) + exit(state.file.path, state) - const jsNodesArray = Array.from(jsNodes) - - jsNodesArray.filter(isRootPath(jsNodesArray)).forEach((path) => { - const macro = new MacroJS(babel, { - i18nImportName, - stripNonEssentialProps, - nameMap, - }) - try { - macro.replacePath(path) - needsI18nImport = needsI18nImport || macro.needsI18nImport - needsUseLinguiImport = needsUseLinguiImport || macro.needsUseLinguiImport - } catch (e) { - reportUnsupportedSyntax(path, e as Error) - } - }) - - const jsxNodesArray = Array.from(jsxNodes) - - jsxNodesArray.filter(isRootPath(jsxNodesArray)).forEach((path) => { - const macro = new MacroJSX(babel, { stripNonEssentialProps, nameMap }) - - try { - macro.replacePath(path) - } catch (e) { - reportUnsupportedSyntax(path, e as Error) - } - }) - - if (needsUseLinguiImport) { - addImport(babel, body, useLinguiImportModule, useLinguiImportName) - } - - if (needsI18nImport) { - addImport(babel, body, i18nImportModule, i18nImportName) + state.set("linguiProcessed", true) } - if (jsxNodes.size) { - addImport(babel, body, TransImportModule, TransImportName) - } -} - -function reportUnsupportedSyntax(path: NodePath, e: Error) { - throw path.buildCodeFrameError( - `Unsupported macro usage. Please check the examples at https://lingui.dev/ref/macro#examples-of-js-macros. - If you think this is a bug, fill in an issue at https://github.com/lingui/js-lingui/issues - - Error: ${e.message}` - ) -} - -/** - * Pre-process useLingui macro - * 1. Get references to destructured t function for macro processing - * 2. Transform usage to non-macro useLingui - * - * @returns Array of paths to useLingui's t macro - */ -function processUseLingui( - path: NodePath, - useLinguiName: string, - newIdentifier: string -): NodePath[] | null { - if (!path.parentPath.parentPath.isVariableDeclarator()) { - reportUnsupportedSyntax( - path, - new Error( - `\`useLingui\` macro must be used in variable declaration. - - Example: - - const { t } = useLingui() -` - ) - ) - return null - } - - const varDec = path.parentPath.parentPath.node - const _property = t.isObjectPattern(varDec.id) - ? varDec.id.properties.find( - ( - property - ): property is t.ObjectProperty & { - key: t.Identifier - value: t.Identifier - } => - t.isObjectProperty(property) && - t.isIdentifier(property.key) && - t.isIdentifier(property.value) && - property.key.name == "t" - ) - : null - - if (!_property) { - reportUnsupportedSyntax( - path.parentPath.parentPath, - new Error( - `You have to destructure \`t\` when using the \`useLingui\` macro, i.e: - const { t } = useLingui() - or - const { t: _ } = useLingui() - ` - ) - ) - return null - } - - if (t.isIdentifier(path.node)) { - // rename to standard useLingui - path.scope.rename(path.node.name, useLinguiName) - } - - // rename to standard useLingui _ - _property.key.name = "_" - path.scope.rename(_property.value.name, newIdentifier) - - return path.scope - .getBinding(_property.value.name) - ?.referencePaths.filter( - // dont process array expression to allow use in dependency arrays - (path) => !path.parentPath.isArrayExpression() - ) - .map((path) => path.parentPath) -} - -function addImport( - babel: MacroParams["babel"], - body: t.Statement[], - module: string, - importName: string -) { - const { types: t } = babel - - const linguiImport = body.find( - (importNode) => - t.isImportDeclaration(importNode) && - importNode.source.value === module && - // https://github.com/lingui/js-lingui/issues/777 - importNode.importKind !== "type" - ) as t.ImportDeclaration - - const tIdentifier = t.identifier(importName) - // Handle adding the import or altering the existing import - if (linguiImport) { - if ( - !linguiImport.specifiers.find( - (specifier) => - t.isImportSpecifier(specifier) && - t.isIdentifier(specifier.imported, { name: importName }) - ) - ) { - linguiImport.specifiers.push(t.importSpecifier(tIdentifier, tIdentifier)) - } - } else { - body.unshift( - t.importDeclaration( - [t.importSpecifier(tIdentifier, tIdentifier)], - t.stringLiteral(module) - ) - ) - } -} - -/** - * Filtering nested macro calls - * - * - * <-- this would be filtered out - * - */ -function isRootPath(allPath: NodePath[]) { - return (node: NodePath) => - (function traverse(path): boolean { - if (!path.parentPath) { - return true - } else { - return !allPath.includes(path.parentPath) && traverse(path.parentPath) - } - })(node) + return { keepImports: true } } -;[...jsMacroTags, ...jsxMacroTags].forEach((name) => { +;[ + JsMacroName.defineMessage, + JsMacroName.msg, + JsMacroName.t, + JsMacroName.useLingui, + JsMacroName.plural, + JsMacroName.select, + JsMacroName.selectOrdinal, + + JsxMacroName.Trans, + JsxMacroName.Plural, + JsxMacroName.Select, + JsxMacroName.SelectOrdinal, +].forEach((name) => { Object.defineProperty(module.exports, name, { get() { throw new Error( diff --git a/packages/macro/src/macroJs.test.ts b/packages/macro/src/macroJs.test.ts index 3f746f123..6284540fa 100644 --- a/packages/macro/src/macroJs.test.ts +++ b/packages/macro/src/macroJs.test.ts @@ -1,7 +1,35 @@ -import { parseExpression } from "@babel/parser" import * as types from "@babel/types" +import { type CallExpression, type Expression } from "@babel/types" import MacroJs from "./macroJs" -import { CallExpression } from "@babel/types" +import type { NodePath } from "@babel/traverse" +import { transformSync } from "@babel/core" +import { JsMacroName } from "./constants" + +const parseExpression = (expression: string) => { + let path: NodePath + + const importExp = `import {t, plural, select, selectOrdinal} from "@lingui/macro"; \n` + transformSync(importExp + expression, { + filename: "unit-test.js", + configFile: false, + presets: [], + plugins: [ + "@babel/plugin-syntax-jsx", + { + visitor: { + "CallExpression|TaggedTemplateExpression": ( + d: NodePath + ) => { + path = d + d.stop() + }, + }, + }, + ], + }) + + return path +} function createMacro() { return new MacroJs( @@ -9,7 +37,7 @@ function createMacro() { { i18nImportName: "i18n", stripNonEssentialProps: false, - nameMap: new Map(), + useLinguiImportName: "useLingui", } ) } @@ -125,7 +153,7 @@ describe("js macro", () => { ]) }) - it("message with double scaped literals it's stripped", () => { + it("message with double escaped literals it's stripped", () => { const macro = createMacro() const exp = parseExpression( "t`Passing \\`${argSet}\\` is not supported.`" @@ -140,20 +168,10 @@ describe("js macro", () => { name: "argSet", type: "arg", value: { - end: 20, loc: { - end: { - column: 20, - line: 1, - }, identifierName: "argSet", - start: { - column: 14, - line: 1, - }, }, name: "argSet", - start: 14, type: "Identifier", }, }, @@ -171,7 +189,10 @@ describe("js macro", () => { const exp = parseExpression( "plural(count, { one: '# book', other: '# books'})" ) - const tokens = macro.tokenizeChoiceComponent(exp as CallExpression) + const tokens = macro.tokenizeChoiceComponent( + exp as NodePath, + JsMacroName.plural + ) expect(tokens).toEqual({ type: "arg", name: "count", @@ -197,7 +218,10 @@ describe("js macro", () => { other: '# books' })` ) - const tokens = macro.tokenizeChoiceComponent(exp as CallExpression) + const tokens = macro.tokenizeChoiceComponent( + exp as NodePath, + JsMacroName.plural + ) expect(tokens).toEqual({ type: "arg", name: "count", @@ -220,7 +244,10 @@ describe("js macro", () => { const exp = parseExpression( "plural(count, { one: `# glass of ${drink}`, other: `# glasses of ${drink}`})" ) - const tokens = macro.tokenizeChoiceComponent(exp as CallExpression) + const tokens = macro.tokenizeChoiceComponent( + exp as NodePath, + JsMacroName.plural + ) expect(tokens).toEqual({ type: "arg", name: "count", @@ -274,7 +301,10 @@ describe("js macro", () => { other: otherText })` ) - const tokens = macro.tokenizeChoiceComponent(exp as CallExpression) + const tokens = macro.tokenizeChoiceComponent( + exp as NodePath, + JsMacroName.plural + ) expect(tokens).toEqual({ type: "arg", name: "count", @@ -328,8 +358,11 @@ describe("js macro", () => { other: "they" })` ) - const tokens = macro.tokenizeChoiceComponent(exp as CallExpression) - expect(tokens).toEqual({ + const tokens = macro.tokenizeChoiceComponent( + exp as NodePath, + JsMacroName.select + ) + expect(tokens).toMatchObject({ format: "select", name: "gender", options: expect.objectContaining({ @@ -340,20 +373,7 @@ describe("js macro", () => { }), type: "arg", value: { - end: 13, - loc: { - end: expect.objectContaining({ - column: 13, - line: 1, - }), - identifierName: "gender", - start: expect.objectContaining({ - column: 7, - line: 1, - }), - }, name: "gender", - start: 7, type: "Identifier", }, }) diff --git a/packages/macro/src/macroJs.ts b/packages/macro/src/macroJs.ts index 35650035e..7b1e8d1ef 100644 --- a/packages/macro/src/macroJs.ts +++ b/packages/macro/src/macroJs.ts @@ -3,7 +3,6 @@ import { CallExpression, Expression, Identifier, - isObjectProperty, Node, ObjectExpression, ObjectProperty, @@ -21,7 +20,15 @@ import ICUMessageFormat, { Tokens, } from "./icu" import { makeCounter } from "./utils" -import { COMMENT, CONTEXT, EXTRACT_MARK, ID, MESSAGE } from "./constants" +import { + COMMENT, + CONTEXT, + EXTRACT_MARK, + ID, + MESSAGE, + MACRO_PACKAGE, + JsMacroName, +} from "./constants" import { generateMessageId } from "@lingui/message-utils/generateMessageId" const keepSpaceRe = /(?:\\(?:\r\n|\r|\n))+\s+/g @@ -40,8 +47,9 @@ function buildICUFromTokens(tokens: Tokens) { export type MacroJsOpts = { i18nImportName: string + useLinguiImportName: string + stripNonEssentialProps: boolean - nameMap: Map } export default class MacroJs { @@ -50,9 +58,8 @@ export default class MacroJs { // Identifier of i18n object i18nImportName: string + useLinguiImportName: string stripNonEssentialProps: boolean - nameMap: Map - nameMapReversed: Map needsUseLinguiImport = false needsI18nImport = false @@ -63,12 +70,9 @@ export default class MacroJs { constructor({ types }: { types: typeof babelTypes }, opts: MacroJsOpts) { this.types = types this.i18nImportName = opts.i18nImportName + this.useLinguiImportName = opts.useLinguiImportName + this.stripNonEssentialProps = opts.stripNonEssentialProps - this.nameMap = opts.nameMap - this.nameMapReversed = Array.from(opts.nameMap.entries()).reduce( - (map, [key, value]) => map.set(value, key), - new Map() - ) } replacePathWithMessage = ( @@ -76,138 +80,96 @@ export default class MacroJs { tokens: Tokens, linguiInstance?: babelTypes.Expression ) => { - const newNode = this.createI18nCall( + return this.createI18nCall( this.createMessageDescriptorFromTokens(tokens, path.node.loc), linguiInstance ) - - path.replaceWith(newNode) } - // Returns a boolean indicating if the replacement requires i18n import - replacePath = (path: NodePath): boolean => { + replacePath = (path: NodePath): false | babelTypes.Expression => { // reset the expression counter this._expressionIndex = makeCounter() // defineMessage({ message: "Message", context: "My" }) -> {id: , message: "Message"} if ( - this.types.isCallExpression(path.node) && - this.isDefineMessage(path.node.callee) + // + path.isCallExpression() && + this.isDefineMessage(path.get("callee")) ) { - let descriptor = this.processDescriptor(path.node.arguments[0]) - path.replaceWith(descriptor) - return false + return this.processDescriptor( + path.get("arguments")[0] as NodePath + ) } // defineMessage`Message` -> {id: , message: "Message"} if ( - this.types.isTaggedTemplateExpression(path.node) && - this.isDefineMessage(path.node.tag) + path.isTaggedTemplateExpression() && + this.isDefineMessage(path.get("tag")) ) { - const tokens = this.tokenizeTemplateLiteral(path.node.quasi) - const descriptor = this.createMessageDescriptorFromTokens( - tokens, - path.node.loc - ) - - path.replaceWith(descriptor) - return false + const tokens = this.tokenizeTemplateLiteral(path.get("quasi")) + return this.createMessageDescriptorFromTokens(tokens, path.node.loc) } - // t(i18nInstance)`Message` -> i18nInstance._(messageDescriptor) - if ( - this.types.isCallExpression(path.node) && - this.types.isTaggedTemplateExpression(path.parentPath.node) && - this.types.isExpression(path.node.arguments[0]) && - this.isLinguiIdentifier(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) + if (path.isTaggedTemplateExpression()) { + const tag = path.get("tag") + + // t(i18nInstance)`Message` -> i18nInstance._(messageDescriptor) + if ( + tag.isCallExpression() && + tag.get("arguments")[0].isExpression() && + this.isLinguiIdentifier(tag.get("callee"), JsMacroName.t) + ) { + // 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) - this.replacePathWithMessage(path.parentPath, tokens, i18nInstance) - return false + return this.replacePathWithMessage(path, tokens, i18nInstance) + } } // t(i18nInstance)(messageDescriptor) -> i18nInstance._(messageDescriptor) - if ( - this.types.isCallExpression(path.node) && - this.types.isCallExpression(path.parentPath.node) && - this.types.isExpression(path.node.arguments[0]) && - path.parentPath.node.callee === path.node && - this.isLinguiIdentifier(path.node.callee, "t") - ) { - const i18nInstance = path.node.arguments[0] - this.replaceTAsFunction( - path.parentPath as NodePath, - i18nInstance - ) - return false + if (path.isCallExpression()) { + const callee = path.get("callee") + + if ( + callee.isCallExpression() && + callee.get("arguments")[0].isExpression() && + this.isLinguiIdentifier(callee.get("callee"), JsMacroName.t) + ) { + const i18nInstance = callee.node.arguments[0] as Expression + return this.replaceTAsFunction( + path as NodePath, + i18nInstance + ) + } } // t({...}) if ( - this.types.isCallExpression(path.node) && - this.isLinguiIdentifier(path.node.callee, "t") + path.isCallExpression() && + this.isLinguiIdentifier(path.get("callee"), JsMacroName.t) ) { - this.replaceTAsFunction(path as NodePath) this.needsI18nImport = true - - return true + return this.replaceTAsFunction(path) } // { t } = useLingui() - // t`Hello!` - if ( - path.isTaggedTemplateExpression() && - this.isLinguiIdentifier(path.node.tag, "_t") - ) { - this.needsUseLinguiImport = true - const tokens = this.tokenizeTemplateLiteral(path.node) - - const descriptor = this.createMessageDescriptorFromTokens( - tokens, - path.node.loc - ) - - const callExpr = this.types.callExpression( - this.types.isIdentifier(path.node.tag) && path.node.tag, - [descriptor] - ) - - path.replaceWith(callExpr) - - return false - } - - // { t } = useLingui() - // t(messageDescriptor) if ( path.isCallExpression() && - this.isLinguiIdentifier(path.node.callee, "_t") && - this.types.isExpression(path.node.arguments[0]) + this.isLinguiIdentifier(path.get("callee"), JsMacroName.useLingui) ) { this.needsUseLinguiImport = true - let descriptor = this.processDescriptor(path.node.arguments[0]) - path.node.arguments = [descriptor] - return false + return this.processUseLingui(path) } - if ( - this.types.isCallExpression(path.node) && - this.isLinguiIdentifier(path.node.callee, "useLingui") && - this.types.isVariableDeclarator(path.parentPath.node) - ) { - this.needsUseLinguiImport = true - return false - } + const tokens = this.tokenizeNode(path, true) - const tokens = this.tokenizeNode(path.node) - - this.replacePathWithMessage(path, tokens) + if (tokens) { + this.needsI18nImport = true + return this.replacePathWithMessage(path, tokens) + } - this.needsI18nImport = true - return true + return false } /** @@ -217,9 +179,133 @@ export default class MacroJs { replaceTAsFunction = ( path: NodePath, linguiInstance?: babelTypes.Expression - ) => { - const descriptor = this.processDescriptor(path.node.arguments[0]) - path.replaceWith(this.createI18nCall(descriptor, linguiInstance)) + ): babelTypes.CallExpression => { + const descriptor = this.processDescriptor( + path.get("arguments")[0] as NodePath + ) + + return this.createI18nCall(descriptor, linguiInstance) + } + + /** + * Receives reference to `useLingui()` call + * + * Finds every usage of { t } destructured from the call + * and process each reference as usual `t` macro. + * + * const { t } = useLingui() + * t`Message` + * + * ↓ ↓ ↓ ↓ ↓ ↓ + * + * const { _: _t } = useLingui() + * _t({id: , message: "Message"}) + */ + processUseLingui(path: NodePath) { + /* + * path is CallExpression eq: + * useLingui() + * + * path.parentPath should be a VariableDeclarator eq: + * const { t } = useLingui() + */ + if (!path.parentPath.isVariableDeclarator()) { + throw new Error( + `\`useLingui\` macro must be used in variable declaration. + + Example: + + const { t } = useLingui() + ` + ) + } + + // 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) + ? varDec.id.properties.find( + ( + property + ): property is ObjectProperty & { + value: Identifier + key: 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( + `You have to destructure \`t\` when using the \`useLingui\` macro, i.e: + const { t } = useLingui() + or + const { t: _ } = useLingui() + ` + ) + } + + const uniqTIdentifier = path.scope.generateUidIdentifier("t") + + path.scope + .getBinding(_property.value.name) + ?.referencePaths.forEach((refPath) => { + // reference usually points to Identifier, + // parent would be an Expression with this identifier which we are interesting in + const currentPath = refPath.parentPath + + // { t } = useLingui() + // t`Hello!` + if (currentPath.isTaggedTemplateExpression()) { + const tokens = this.tokenizeTemplateLiteral(currentPath) + + 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() && + currentPath.get("arguments")[0].isObjectExpression() + ) { + let descriptor = this.processDescriptor( + currentPath.get("arguments")[0] as NodePath + ) + 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)) + }) + + // assign uniq identifier for runtime `_` + // { t } = useLingui() -> { _ : _t } = useLingui() + _property.key.name = "_" + _property.value.name = uniqTIdentifier.name + + return this.types.callExpression( + this.types.identifier(this.useLinguiImportName), + [] + ) } /** @@ -240,28 +326,33 @@ export default class MacroJs { * } * */ - processDescriptor = (descriptor_: Node) => { - const descriptor = descriptor_ as ObjectExpression - + processDescriptor = (descriptor: NodePath) => { const messageProperty = this.getObjectPropertyByKey(descriptor, MESSAGE) const idProperty = this.getObjectPropertyByKey(descriptor, ID) const contextProperty = this.getObjectPropertyByKey(descriptor, CONTEXT) + const commentProperty = this.getObjectPropertyByKey(descriptor, COMMENT) + + const properties: ObjectProperty[] = [] - const properties: ObjectProperty[] = [idProperty] + if (idProperty) { + properties.push(idProperty.node) + } - if (!this.stripNonEssentialProps) { - properties.push(contextProperty) + if (!this.stripNonEssentialProps && contextProperty) { + properties.push(contextProperty.node) } // 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 tokens = this.types.isTemplateLiteral(messageProperty.value) - ? this.tokenizeTemplateLiteral(messageProperty.value) - : this.tokenizeNode(messageProperty.value, true) + const messageValue = messageProperty.get("value") - let messageNode = messageProperty.value as StringLiteral + const tokens = messageValue.isTemplateLiteral() + ? this.tokenizeTemplateLiteral(messageValue) + : this.tokenizeNode(messageValue, true) + + let messageNode = messageValue.node as StringLiteral if (tokens) { const { message, values } = buildICUFromTokens(tokens) @@ -279,17 +370,19 @@ export default class MacroJs { if (!idProperty && this.types.isStringLiteral(messageNode)) { const context = contextProperty && - this.getTextFromExpression(contextProperty.value as Expression) + this.getTextFromExpression( + contextProperty.get("value").node as Expression + ) properties.push(this.createIdProperty(messageNode.value, context)) } } - if (!this.stripNonEssentialProps) { - properties.push(this.getObjectPropertyByKey(descriptor, COMMENT)) + if (!this.stripNonEssentialProps && commentProperty) { + properties.push(commentProperty.node) } - return this.createMessageDescriptor(properties, descriptor.loc) + return this.createMessageDescriptor(properties, descriptor.node.loc) } createIdProperty(message: string, context?: string) { @@ -312,17 +405,29 @@ export default class MacroJs { ) } - tokenizeNode(node: Node, ignoreExpression = false): Token[] { - if (this.isI18nMethod(node)) { + tokenizeNode(path: NodePath, ignoreExpression = false): Token[] { + const node = path.node + + if (this.isI18nMethod(path)) { // t - return this.tokenizeTemplateLiteral(node as Expression) - } else if (this.isChoiceMethod(node)) { - // plural, select and selectOrdinal - return [this.tokenizeChoiceComponent(node as CallExpression)] - // } else if (isFormatMethod(node.callee)) { - // // date, number - // return transformFormatMethod(node, file, props, root) - } else if (!ignoreExpression) { + 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 (isFormatMethod(node.callee)) { + // // date, number + // return transformFormatMethod(node, file, props, root) + + if (!ignoreExpression) { return [this.tokenizeExpression(node)] } } @@ -332,28 +437,30 @@ export default class MacroJs { * text chunks and node.expressions contains expressions. * Both arrays must be zipped together to get the final list of tokens. */ - tokenizeTemplateLiteral(node: babelTypes.Expression): Token[] { - const tpl = this.types.isTaggedTemplateExpression(node) - ? node.quasi - : (node as TemplateLiteral) + tokenizeTemplateLiteral(path: NodePath): Token[] { + const tpl = path.isTaggedTemplateExpression() + ? path.get("quasi") + : (path as NodePath) - const expressions = tpl.expressions as Expression[] + const expressions = tpl.get("expressions") as NodePath[] - return tpl.quasis.flatMap((text, i) => { + return tpl.get("quasis").flatMap((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 + const value = /\\u[a-fA-F0-9]{4}|\\x[a-fA-F0-9]{2}/g.test( + text.node.value.raw + ) + ? text.node.value.cooked + : text.node.value.raw let argTokens: Token[] = [] const currExp = expressions[i] if (currExp) { - argTokens = this.types.isCallExpression(currExp) + argTokens = currExp.isCallExpression() ? this.tokenizeNode(currExp) - : [this.tokenizeExpression(currExp)] + : [this.tokenizeExpression(currExp.node)] } const textToken: TextToken = { type: "text", @@ -363,44 +470,53 @@ export default class MacroJs { }) } - tokenizeChoiceComponent(node: CallExpression): ArgToken { - const name = (node.callee as Identifier).name - const format = (this.nameMapReversed.get(name) || name).toLowerCase() + tokenizeChoiceComponent( + path: NodePath, + componentName: string + ): ArgToken { + const format = componentName.toLowerCase() const token: ArgToken = { - ...this.tokenizeExpression(node.arguments[0]), - format, + ...this.tokenizeExpression(path.node.arguments[0]), + format: format, options: { offset: undefined, }, } - const props = (node.arguments[1] as ObjectExpression).properties + const props = (path.get("arguments")[1] as NodePath).get( + "properties" + ) for (const attr of props) { - const { key, value: attrValue } = attr as ObjectProperty + 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 = this.types.isNumericLiteral(key) - ? `=${key.value}` - : (key as Identifier).name || (key as StringLiteral).value + 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 as StringLiteral).value + token.options.offset = (attrValue.node as StringLiteral).value } else { let value: ArgToken["options"][string] - if (this.types.isTemplateLiteral(attrValue)) { + if (attrValue.isTemplateLiteral()) { value = this.tokenizeTemplateLiteral(attrValue) - } else if (this.types.isCallExpression(attrValue)) { + } else if (attrValue.isCallExpression()) { value = this.tokenizeNode(attrValue) - } else if (this.types.isStringLiteral(attrValue)) { - value = attrValue.value - } else if (this.types.isExpression(attrValue)) { - value = this.tokenizeExpression(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 } @@ -490,47 +606,64 @@ export default class MacroJs { } getObjectPropertyByKey( - objectExp: ObjectExpression, + objectExp: NodePath, key: string - ): ObjectProperty { - return objectExp.properties.find( + ): NodePath { + return objectExp.get("properties").find( (property) => - isObjectProperty(property) && this.isLinguiIdentifier(property.key, key) - ) as ObjectProperty + property.isObjectProperty() && + (property.get("key") as NodePath).isIdentifier({ + name: key, + }) + ) as NodePath } /** * Custom matchers */ - isLinguiIdentifier(node: Node | Expression, name: string) { - return this.types.isIdentifier(node, { - name: this.nameMap.get(name) || name, - }) + isLinguiIdentifier(path: NodePath, name: JsMacroName) { + if (path.isIdentifier() && path.referencesImport(MACRO_PACKAGE, name)) { + return true + } } - isDefineMessage(node: Node | Expression): boolean { + isDefineMessage(path: NodePath): boolean { return ( - this.isLinguiIdentifier(node, "defineMessage") || - this.isLinguiIdentifier(node, "msg") + this.isLinguiIdentifier(path, JsMacroName.defineMessage) || + this.isLinguiIdentifier(path, JsMacroName.msg) ) } - isI18nMethod(node: Node) { + isI18nMethod(path: NodePath) { + if (!path.isTaggedTemplateExpression()) { + return + } + + const tag = path.get("tag") + return ( - this.types.isTaggedTemplateExpression(node) && - (this.isLinguiIdentifier(node.tag, "t") || - (this.types.isCallExpression(node.tag) && - this.isLinguiIdentifier(node.tag.callee, "t"))) + this.isLinguiIdentifier(tag, JsMacroName.t) || + (tag.isCallExpression() && + this.isLinguiIdentifier(tag.get("callee"), JsMacroName.t)) ) } - isChoiceMethod(node: Node) { - return ( - this.types.isCallExpression(node) && - (this.isLinguiIdentifier(node.callee, "plural") || - this.isLinguiIdentifier(node.callee, "select") || - this.isLinguiIdentifier(node.callee, "selectOrdinal")) - ) + 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 + } } getTextFromExpression(exp: Expression): string { diff --git a/packages/macro/src/macroJsx.test.ts b/packages/macro/src/macroJsx.test.ts index 71bb6a464..2bb7b81a4 100644 --- a/packages/macro/src/macroJsx.test.ts +++ b/packages/macro/src/macroJsx.test.ts @@ -1,15 +1,21 @@ +import type { JSXElement } from "@babel/types" import * as types from "@babel/types" import MacroJSX, { normalizeWhitespace } from "./macroJsx" import { transformSync } from "@babel/core" import type { NodePath } from "@babel/traverse" -import type { JSXElement } from "@babel/types" +import { JsxMacroName } from "./constants" const parseExpression = (expression: string) => { let path: NodePath - transformSync(expression, { + const importExp = `import {Trans, Plural, Select, SelectOrdinal} from "@lingui/macro";\n` + + transformSync(importExp + expression, { filename: "unit-test.js", + configFile: false, + presets: [], plugins: [ + "@babel/plugin-syntax-jsx", { visitor: { JSXElement: (d) => { @@ -27,7 +33,7 @@ const parseExpression = (expression: string) => { function createMacro() { return new MacroJSX( { types }, - { stripNonEssentialProps: false, nameMap: new Map() } + { stripNonEssentialProps: false, transImportName: "Trans" } ) } @@ -195,7 +201,7 @@ describe("jsx macro", () => { const exp = parseExpression( "" ) - const tokens = macro.tokenizeChoiceComponent(exp) + const tokens = macro.tokenizeChoiceComponent(exp, JsxMacroName.Plural) expect(tokens).toEqual({ type: "arg", name: "count", @@ -222,7 +228,7 @@ describe("jsx macro", () => { other='# books' />` ) - const tokens = macro.tokenizeChoiceComponent(exp) + const tokens = macro.tokenizeChoiceComponent(exp, JsxMacroName.Plural) expect(tokens).toEqual({ type: "arg", name: "count", @@ -251,7 +257,7 @@ describe("jsx macro", () => { other='# books' />` ) - const tokens = macro.tokenizeChoiceComponent(exp) + const tokens = macro.tokenizeChoiceComponent(exp, JsxMacroName.Plural) expect(tokens).toEqual({ type: "arg", name: "count", @@ -273,7 +279,7 @@ describe("jsx macro", () => { const exp = parseExpression( "" ) - const tokens = macro.tokenizeChoiceComponent(exp) + const tokens = macro.tokenizeChoiceComponent(exp, JsxMacroName.Plural) expect(tokens).toEqual({ type: "arg", name: "count", @@ -330,7 +336,7 @@ describe("jsx macro", () => { } />` ) - const tokens = macro.tokenizeChoiceComponent(exp) + const tokens = macro.tokenizeChoiceComponent(exp, JsxMacroName.Plural) expect(tokens).toEqual({ type: "arg", name: "count", @@ -383,18 +389,6 @@ describe("jsx macro", () => { }, type: "arg", value: { - end: 31, - loc: { - end: { - column: 23, - line: 2, - }, - identifierName: "gender", - start: { - column: 17, - line: 2, - }, - }, name: "gender", type: "Identifier", }, diff --git a/packages/macro/src/macroJsx.ts b/packages/macro/src/macroJsx.ts index 66046967a..936a13655 100644 --- a/packages/macro/src/macroJsx.ts +++ b/packages/macro/src/macroJsx.ts @@ -21,7 +21,14 @@ import ICUMessageFormat, { Token, } from "./icu" import { makeCounter } from "./utils" -import { COMMENT, CONTEXT, ID, MESSAGE } from "./constants" +import { + COMMENT, + CONTEXT, + ID, + MESSAGE, + MACRO_PACKAGE, + JsxMacroName, +} from "./constants" import { generateMessageId } from "@lingui/message-utils/generateMessageId" const pluralRuleRe = /(_[\d\w]+|zero|one|two|few|many|other)/ @@ -65,7 +72,7 @@ export function normalizeWhitespace(text: string): string { export type MacroJsxOpts = { stripNonEssentialProps: boolean - nameMap: Map + transImportName: string } export default class MacroJSX { @@ -73,17 +80,12 @@ export default class MacroJSX { expressionIndex = makeCounter() elementIndex = makeCounter() stripNonEssentialProps: boolean - nameMap: Map - nameMapReversed: Map + transImportName: string constructor({ types }: { types: typeof babelTypes }, opts: MacroJsxOpts) { this.types = types this.stripNonEssentialProps = opts.stripNonEssentialProps - this.nameMap = opts.nameMap - this.nameMapReversed = Array.from(opts.nameMap.entries()).reduce( - (map, [key, value]) => map.set(value, key), - new Map() - ) + this.transImportName = opts.transImportName } createStringJsxAttribute = (name: string, value: string) => { @@ -94,12 +96,16 @@ export default class MacroJSX { ) } - replacePath = (path: NodePath) => { + replacePath = (path: NodePath): false | Node => { if (!path.isJSXElement()) { - return path + return false } - const tokens = this.tokenizeNode(path) + const tokens = this.tokenizeNode(path, true, true) + + if (!tokens) { + return false + } const messageFormat = new ICUMessageFormat() const { @@ -114,7 +120,7 @@ export default class MacroJSX { ) if (!id && !message) { - return + throw new Error("Incorrect usage of Trans") } if (id) { @@ -191,7 +197,7 @@ export default class MacroJSX { const newNode = this.types.jsxElement( this.types.jsxOpeningElement( - this.types.jsxIdentifier("Trans"), + this.types.jsxIdentifier(this.transImportName), attributes, true ), @@ -201,7 +207,7 @@ export default class MacroJSX { ) newNode.loc = path.node.loc - path.replaceWith(newNode) + return newNode } attrName = (names: string[], exclude = false) => { @@ -246,16 +252,33 @@ export default class MacroJSX { } } - tokenizeNode = (path: NodePath): Token[] => { + tokenizeNode = ( + path: NodePath, + ignoreExpression = false, + ignoreElement = false + ): Token[] => { if (this.isTransComponent(path)) { // t return this.tokenizeTrans(path) - } else if (this.isChoiceComponent(path)) { + } + + const componentName = this.isChoiceComponent(path) + + if (componentName) { // plural, select and selectOrdinal - return [this.tokenizeChoiceComponent(path)] - } else if (path.isJSXElement()) { + return [ + this.tokenizeChoiceComponent( + path as NodePath, + componentName + ), + ] + } + + if (path.isJSXElement() && !ignoreElement) { return [this.tokenizeElement(path)] - } else { + } + + if (!ignoreExpression) { return [this.tokenizeExpression(path)] } } @@ -325,11 +348,13 @@ export default class MacroJSX { }) } - tokenizeChoiceComponent = (path: NodePath): Token => { + tokenizeChoiceComponent = ( + path: NodePath, + componentName: JsxMacroName + ): Token => { const element = path.get("openingElement") - const name = this.getJsxTagName(path.node) - const format = (this.nameMapReversed.get(name) || name).toLowerCase() + const format = componentName.toLowerCase() const props = element.get("attributes").filter((attr) => { return this.attrName( [ @@ -478,26 +503,31 @@ export default class MacroJSX { isLinguiComponent = ( path: NodePath, - name: string + name: JsxMacroName ): path is NodePath => { return ( path.isJSXElement() && - this.types.isJSXIdentifier(path.node.openingElement.name, { - name: this.nameMap.get(name) || name, - }) + path + .get("openingElement") + .get("name") + .referencesImport(MACRO_PACKAGE, name) ) } isTransComponent = (path: NodePath): path is NodePath => { - return this.isLinguiComponent(path, "Trans") + return this.isLinguiComponent(path, JsxMacroName.Trans) } - isChoiceComponent = (path: NodePath): path is NodePath => { - return ( - this.isLinguiComponent(path, "Plural") || - this.isLinguiComponent(path, "Select") || - this.isLinguiComponent(path, "SelectOrdinal") - ) + isChoiceComponent = (path: NodePath): JsxMacroName => { + if (this.isLinguiComponent(path, JsxMacroName.Plural)) { + return JsxMacroName.Plural + } + if (this.isLinguiComponent(path, JsxMacroName.Select)) { + return JsxMacroName.Select + } + if (this.isLinguiComponent(path, JsxMacroName.SelectOrdinal)) { + return JsxMacroName.SelectOrdinal + } } getJsxTagName = (node: JSXElement): string => { diff --git a/packages/macro/src/plugin.ts b/packages/macro/src/plugin.ts new file mode 100644 index 000000000..04a91c6be --- /dev/null +++ b/packages/macro/src/plugin.ts @@ -0,0 +1,194 @@ +import type { PluginObj, Visitor, PluginPass } from "@babel/core" +import type * as babelTypes from "@babel/types" +import MacroJSX from "./macroJsx" +import { NodePath } from "@babel/traverse" +import MacroJs from "./macroJs" +import { MACRO_PACKAGE } from "./constants" +import { + type LinguiConfigNormalized, + getConfig as loadConfig, +} from "@lingui/conf" +import { Program, Identifier } from "@babel/types" + +let config: LinguiConfigNormalized + +export type LinguiPluginOpts = { + // explicitly set by CLI when running extraction process + extract?: boolean + linguiConfig?: LinguiConfigNormalized +} + +function getConfig(_config?: LinguiConfigNormalized) { + if (_config) { + config = _config + } + if (!config) { + config = loadConfig() + } + return config +} + +function reportUnsupportedSyntax(path: NodePath, e: Error) { + throw path.buildCodeFrameError( + `Unsupported macro usage. Please check the examples at https://lingui.dev/ref/macro#examples-of-js-macros. + If you think this is a bug, fill in an issue at https://github.com/lingui/js-lingui/issues + + Error: ${e.message}` + ) +} + +type LinguiSymbol = "Trans" | "useLingui" | "i18n" + +export default function ({ + types: t, +}: { + types: typeof babelTypes +}): PluginObj { + function addImport(state: PluginPass, name: LinguiSymbol) { + const path = state.get( + "macroImport" + ) as NodePath + + const config = state.get("linguiConfig") as LinguiConfigNormalized + + if (!state.get("has_import_" + name)) { + state.set("has_import_" + name, true) + const [moduleSource, importName] = config.runtimeConfigModule[name] + + const [newPath] = path.insertAfter( + t.importDeclaration( + [ + t.importSpecifier( + getSymbolIdentifier(state, name), + t.identifier(importName) + ), + ], + t.stringLiteral(moduleSource) + ) + ) + + path.parentPath.scope.registerDeclaration(newPath) + } + + return path.parentPath.scope.getBinding( + getSymbolIdentifier(state, name).name + ) + } + + function getMacroImports(path: NodePath) { + return path.get("body").filter((statement) => { + return ( + statement.isImportDeclaration() && + statement.get("source").node.value === MACRO_PACKAGE + ) + }) + } + + function getSymbolIdentifier( + state: PluginPass, + name: LinguiSymbol + ): Identifier { + return state.get("linguiIdentifiers")[name] + } + + return { + name: "lingui-macro-plugin", + visitor: { + Program: { + enter(path, state) { + const macroImports = getMacroImports(path) + + if (!macroImports.length) { + return + } + + state.set("macroImport", macroImports[0]) + + state.set( + "linguiConfig", + getConfig((state.opts as LinguiPluginOpts).linguiConfig) + ) + + state.set("linguiIdentifiers", { + i18n: path.scope.generateUidIdentifier("i18n"), + Trans: path.scope.generateUidIdentifier("Trans"), + useLingui: path.scope.generateUidIdentifier("useLingui"), + }) + + path.traverse( + { + JSXElement(path, state) { + const macro = new MacroJSX( + { types: t }, + { + transImportName: getSymbolIdentifier(state, "Trans").name, + stripNonEssentialProps: + process.env.NODE_ENV == "production" && + !(state.opts as LinguiPluginOpts).extract, + } + ) + + let newNode: false | babelTypes.Node + + try { + newNode = macro.replacePath(path) + } catch (e) { + reportUnsupportedSyntax(path, e as Error) + } + + if (newNode) { + const [newPath] = path.replaceWith(newNode) + addImport(state, "Trans").reference(newPath) + } + }, + + "CallExpression|TaggedTemplateExpression"( + path: NodePath< + | babelTypes.CallExpression + | babelTypes.TaggedTemplateExpression + >, + 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, + } + ) + let newNode: false | babelTypes.Node + + try { + newNode = macro.replacePath(path) + } catch (e) { + reportUnsupportedSyntax(path, e as Error) + } + + if (newNode) { + const [newPath] = path.replaceWith(newNode) + + if (macro.needsUseLinguiImport) { + addImport(state, "useLingui").reference(newPath) + } + + if (macro.needsI18nImport) { + addImport(state, "i18n").reference(newPath) + } + } + }, + } as Visitor, + state + ) + }, + exit(path, state) { + const macroImports = getMacroImports(path) + macroImports.forEach((path) => path.remove()) + }, + }, + } as Visitor, + } +} diff --git a/packages/macro/test/fixtures/js-t-continuation-character.expected.js b/packages/macro/test/fixtures/js-t-continuation-character.expected.js index bb22822cd..5958cd9e9 100644 --- a/packages/macro/test/fixtures/js-t-continuation-character.expected.js +++ b/packages/macro/test/fixtures/js-t-continuation-character.expected.js @@ -1,5 +1,5 @@ -import { i18n } from "@lingui/core" -i18n._( +import { i18n as _i18n } from "@lingui/core" +_i18n._( /*i18n*/ { id: "LBYoFK", diff --git a/packages/macro/test/fixtures/js-t-var/.babelrc b/packages/macro/test/fixtures/js-t-var/.babelrc deleted file mode 100644 index c1237979d..000000000 --- a/packages/macro/test/fixtures/js-t-var/.babelrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "presets": ["@babel/preset-env"], - "plugins": ["@babel/plugin-syntax-jsx", "macros"] -} diff --git a/packages/macro/test/fixtures/js-t-var/js-t-var.expected.js b/packages/macro/test/fixtures/js-t-var/js-t-var.expected.js index 17a544413..abe4115a0 100644 --- a/packages/macro/test/fixtures/js-t-var/js-t-var.expected.js +++ b/packages/macro/test/fixtures/js-t-var/js-t-var.expected.js @@ -1,10 +1,8 @@ -"use strict" - -var _core = require("@lingui/core") +import { i18n as _i18n } from "@lingui/core" function scoped(foo) { if (foo) { - var bar = 50 - _core.i18n._( + const bar = 50 + _i18n._( /*i18n*/ { id: "EvVtyn", @@ -15,14 +13,14 @@ function scoped(foo) { } ) } else { - var _bar = 10 - _core.i18n._( + const bar = 10 + _i18n._( /*i18n*/ { id: "e6QGtZ", message: "This is a different bar {bar}", values: { - bar: _bar, + bar: bar, }, } ) diff --git a/packages/macro/test/fixtures/jsx-plural-select-nested.expected.js b/packages/macro/test/fixtures/jsx-plural-select-nested.expected.js index 4e3c00bc1..dedcebb3d 100644 --- a/packages/macro/test/fixtures/jsx-plural-select-nested.expected.js +++ b/packages/macro/test/fixtures/jsx-plural-select-nested.expected.js @@ -1,5 +1,5 @@ -import { Trans } from "@lingui/react" -; { return { filename: "" + (isTs ? ".tsx" : "jsx"), configFile: false, + babelrc: false, presets: [], plugins: [ "@babel/plugin-syntax-jsx", - [ - "macros", - { - lingui: macroOpts, - // macro plugin uses package `resolve` to find a path of macro file - // this will not follow jest pathMapping and will resolve path from ./build - // instead of ./src which makes testing & developing hard. - // here we override resolve and provide correct path for testing - resolvePath: (source: string) => require.resolve(source), - }, - ], + transformType === "plugin" + ? [linguiMacroPlugin, macroOpts] + : [ + "macros", + { + lingui: macroOpts, + // macro plugin uses package `resolve` to find a path of macro file + // this will not follow jest pathMapping and will resolve path from ./build + // instead of ./src which makes testing & developing hard. + // here we override resolve and provide correct path for testing + resolvePath: (source: string) => require.resolve(source), + }, + ], + ...(stripId ? [stripIdPlugin] : []), ], } } + const transformTypes = ["plugin", "macro"] as const + + function forTransforms( + run: (_transformCode: (code: string) => () => string) => any + ) { + return () => + transformTypes.forEach((transformType) => { + test(transformType, () => { + return run((code) => transformCode(code, transformType)) + }) + }) + } + // return function, so we can test exceptions - const transformCode = (code: string) => () => { - try { - return transformSync(code, getDefaultBabelOptions()).code.trim() - } catch (e) { - ;(e as Error).message = (e as Error).message.replace(/([^:]*:){2}/, "") - throw e + const transformCode = + (code: string, transformType: "plugin" | "macro" = "plugin") => + () => { + try { + return transformSync( + code, + getDefaultBabelOptions(transformType) + ).code.trim() + } catch (e) { + ;(e as Error).message = (e as Error).message.replace(/([^:]*:){2}/, "") + throw e + } } - } Object.keys(testCases).forEach((suiteName) => { - describe(suiteName, () => { + describe(`${suiteName}`, () => { const cases = testCases[suiteName] const clean = (value: string) => @@ -128,57 +153,68 @@ describe("macro", function () { }, index ) => { - let run = it - if (only) run = it.only - if (skip) run = it.skip - run(name != null ? name : `${suiteName} #${index + 1}`, () => { - const babelOptions = getDefaultBabelOptions( - macroOpts, - useTypescriptPreset, - stripId - ) - expect(input || filename).toBeDefined() - - const originalEnv = process.env.NODE_ENV - - if (production) { - process.env.NODE_ENV = "production" - } - - if (useTypescriptPreset) { - babelOptions.presets.push("@babel/preset-typescript") - } - - try { - if (filename) { - const inputPath = path.relative( - process.cwd(), - path.join(__dirname, "fixtures", filename) + let group = describe + if (only) group = describe.only + if (skip) group = describe.skip + group(name != null ? name : `${suiteName} #${index + 1}`, () => { + transformTypes.forEach((transformType) => { + it(transformType, () => { + const babelOptions = getDefaultBabelOptions( + transformType, + macroOpts, + useTypescriptPreset, + stripId ) - const expectedPath = inputPath.replace(/\.js$/, ".expected.js") - const expected = fs - .readFileSync(expectedPath, "utf8") - .replace(/\r/g, "") - .trim() - - const _babelOptions = { - ...babelOptions, - cwd: path.dirname(inputPath), + expect(input || filename).toBeDefined() + + const originalEnv = process.env.NODE_ENV + + if (production) { + process.env.NODE_ENV = "production" + } + + if (useTypescriptPreset) { + babelOptions.presets.push("@babel/preset-typescript") } - const actual = transformFileSync(inputPath, _babelOptions) - .code.replace(/\r/g, "") - .trim() - expect(clean(actual)).toEqual(clean(expected)) - } else { - const actual = transformSync(input, babelOptions).code.trim() - - expect(clean(actual)).toEqual(clean(expected)) - } - } finally { - process.env.LINGUI_CONFIG = "" - process.env.NODE_ENV = originalEnv - } + try { + if (filename) { + const inputPath = path.relative( + process.cwd(), + path.join(__dirname, "fixtures", filename) + ) + const expectedPath = inputPath.replace( + /\.js$/, + ".expected.js" + ) + const expected = fs + .readFileSync(expectedPath, "utf8") + .replace(/\r/g, "") + .trim() + + const _babelOptions = { + ...babelOptions, + cwd: path.dirname(inputPath), + } + + const actual = transformFileSync(inputPath, _babelOptions) + .code.replace(/\r/g, "") + .trim() + expect(clean(actual)).toEqual(clean(expected)) + } else { + const actual = transformSync( + input, + babelOptions + ).code.trim() + + expect(clean(actual)).toEqual(clean(expected)) + } + } finally { + process.env.LINGUI_CONFIG = "" + process.env.NODE_ENV = originalEnv + } + }) + }) }) } ) @@ -324,29 +360,59 @@ describe("macro", function () { }) }) - describe("useLingui", () => { - it("Should throw if used not in the variable declaration", () => { - const code = ` + describe("useLingui validation", () => { + describe( + "Should throw if used not in the variable declaration", + forTransforms((transformCode) => { + const code = ` import {useLingui} from "@lingui/macro"; useLingui() ` - expect(transformCode(code)).toThrowError( - "Error: `useLingui` macro must be used in variable declaration." - ) - }) + expect(transformCode(code)).toThrowError( + "Error: `useLingui` macro must be used in variable declaration." + ) + }) + ) - it("Should throw if not used with destructuring", () => { - const code = ` + describe( + "Should throw if not used with destructuring", + forTransforms((transformCode) => { + const code = ` import {useLingui} from "@lingui/macro"; const lingui = useLingui() ` - expect(transformCode(code)).toThrowError( - "You have to destructure `t` when using the `useLingui` macro" - ) - }) + expect(transformCode(code)).toThrowError( + "You have to destructure `t` when using the `useLingui` macro" + ) + }) + ) + }) + + describe("Trans validation", () => { + describe( + "Should throw if spread used in children", + forTransforms((transformCode) => { + const code = ` + import { Trans } from '@lingui/macro'; + {...spread} + ` + expect(transformCode(code)).toThrowError("Incorrect usage of Trans") + }) + ) + + describe( + "Should throw if used without children", + forTransforms((transformCode) => { + const code = ` + import { Trans } from '@lingui/macro'; + ; + ` + expect(transformCode(code)).toThrowError("Incorrect usage of Trans") + }) + ) }) }) diff --git a/packages/macro/test/js-defineMessage.ts b/packages/macro/test/js-defineMessage.ts index 7ff700613..ef030cbcb 100644 --- a/packages/macro/test/js-defineMessage.ts +++ b/packages/macro/test/js-defineMessage.ts @@ -185,7 +185,7 @@ const cases: TestCase[] = [ { name: "should preserve values", input: ` - import { defineMessage } from '@lingui/macro'; + import { defineMessage, t } from '@lingui/macro'; const message = defineMessage({ message: t\`Hello $\{name\}\` }) diff --git a/packages/macro/test/js-plural.ts b/packages/macro/test/js-plural.ts index bf2f3db6c..a7b7780af 100644 --- a/packages/macro/test/js-plural.ts +++ b/packages/macro/test/js-plural.ts @@ -11,8 +11,8 @@ const cases: TestCase[] = [ }); `, expected: ` - import { i18n } from "@lingui/core"; - const a = i18n._( + import { i18n as _i18n } from "@lingui/core"; + const a = _i18n._( /*i18n*/ { id: "esnaQO", @@ -35,8 +35,8 @@ const cases: TestCase[] = [ }); `, expected: ` - import { i18n } from "@lingui/core"; - const a = i18n._( + import { i18n as _i18n } from "@lingui/core"; + const a = _i18n._( /*i18n*/ { id: "esnaQO", @@ -61,8 +61,8 @@ const cases: TestCase[] = [ }); `, expected: ` - import { i18n } from "@lingui/core"; - i18n._( + import { i18n as _i18n } from "@lingui/core"; + _i18n._( /*i18n*/ { id: "CF5t+7", @@ -86,8 +86,8 @@ const cases: TestCase[] = [ }); `, expected: ` - import { i18n } from "@lingui/core"; - i18n._( + import { i18n as _i18n } from "@lingui/core"; + _i18n._( /*i18n*/ { id: "0mcXIe", diff --git a/packages/macro/test/js-select.ts b/packages/macro/test/js-select.ts index 0e5477754..db4ecbd64 100644 --- a/packages/macro/test/js-select.ts +++ b/packages/macro/test/js-select.ts @@ -15,8 +15,8 @@ const cases: TestCase[] = [ }); `, expected: ` - import { i18n } from "@lingui/core"; - i18n._( + import { i18n as _i18n } from "@lingui/core"; + _i18n._( /*i18n*/ { id: "G8xqGf", @@ -43,8 +43,8 @@ const cases: TestCase[] = [ }); `, expected: ` - import { i18n } from "@lingui/core"; - i18n._( + import { i18n as _i18n } from "@lingui/core"; + _i18n._( /*i18n*/ { id: "j9PNNm", diff --git a/packages/macro/test/js-selectOrdinal.ts b/packages/macro/test/js-selectOrdinal.ts index 12e5469fa..07e35688f 100644 --- a/packages/macro/test/js-selectOrdinal.ts +++ b/packages/macro/test/js-selectOrdinal.ts @@ -11,8 +11,8 @@ const cases: TestCase[] = [ })} cat\` `, expected: ` - import { i18n } from "@lingui/core"; - i18n._( + import { i18n as _i18n } from "@lingui/core"; + _i18n._( /*i18n*/ { id: "dJXd3T", diff --git a/packages/macro/test/js-t.ts b/packages/macro/test/js-t.ts index e48684c81..fb035fbfb 100644 --- a/packages/macro/test/js-t.ts +++ b/packages/macro/test/js-t.ts @@ -8,8 +8,8 @@ const cases: TestCase[] = [ const a = t\`Expression assignment\`; `, expected: ` - import { i18n } from "@lingui/core"; - const a = i18n._( + import { i18n as _i18n } from "@lingui/core"; + const a = _i18n._( /*i18n*/ { id: "mjnlP0", @@ -25,9 +25,9 @@ const cases: TestCase[] = [ const msg = message.error(t({message: "dasd"})) `, expected: ` - import { i18n } from "@lingui/core"; + import { i18n as _i18n } from "@lingui/core"; const msg = message.error( - i18n._( + _i18n._( /*i18n*/ { message: "dasd", @@ -44,8 +44,8 @@ const cases: TestCase[] = [ const a = t2\`Expression assignment\`; `, expected: ` - import { i18n } from "@lingui/core"; - const a = i18n._( + import { i18n as _i18n } from "@lingui/core"; + const a = _i18n._( /*i18n*/ { id: "mjnlP0", @@ -79,8 +79,8 @@ const cases: TestCase[] = [ t\`Variable \${name}\`; `, expected: ` - import { i18n } from "@lingui/core"; - i18n._( + import { i18n as _i18n } from "@lingui/core"; + _i18n._( /*i18n*/ { id: "xRRkAE", @@ -99,8 +99,8 @@ const cases: TestCase[] = [ t\`Variable \\\`\${name}\\\`\`; `, expected: ` - import { i18n } from "@lingui/core"; - i18n._( + import { i18n as _i18n } from "@lingui/core"; + _i18n._( /*i18n*/ { id: "ICBco+", @@ -119,8 +119,8 @@ const cases: TestCase[] = [ t\`Variable \"name\" \`; `, expected: ` - import { i18n } from "@lingui/core"; - i18n._( + import { i18n as _i18n } from "@lingui/core"; + _i18n._( /*i18n*/ { id: "CcPIZW", @@ -136,8 +136,8 @@ const cases: TestCase[] = [ t\`\${duplicate} variable \${duplicate}\`; `, expected: ` - import { i18n } from "@lingui/core"; - i18n._( + import { i18n as _i18n } from "@lingui/core"; + _i18n._( /*i18n*/ { id: "+nhkwg", @@ -163,8 +163,8 @@ const cases: TestCase[] = [ \` `, expected: ` - import { i18n } from "@lingui/core"; - i18n._( + import { i18n as _i18n } from "@lingui/core"; + _i18n._( /*i18n*/ { id: "X1jIKa", @@ -189,8 +189,8 @@ const cases: TestCase[] = [ string\` `, expected: ` - import { i18n } from "@lingui/core"; - i18n._( + import { i18n as _i18n } from "@lingui/core"; + _i18n._( /*i18n*/ { id: "EfogM+", @@ -206,8 +206,8 @@ const cases: TestCase[] = [ const msg = t({ message: \`Hello \${name}\` }) `, expected: ` - import { i18n } from "@lingui/core"; - const msg = i18n._( + import { i18n as _i18n } from "@lingui/core"; + const msg = _i18n._( /*i18n*/ { values: { @@ -266,15 +266,15 @@ const cases: TestCase[] = [ t({ message: "Hello", context: "my custom" }) `, expected: ` - import { i18n } from "@lingui/core"; - i18n._( + import { i18n as _i18n } from "@lingui/core"; + _i18n._( /*i18n*/ { message: "Hello", id: "uzTaYi", } ); - i18n._( + _i18n._( /*i18n*/ { context: "my custom", @@ -292,8 +292,8 @@ const cases: TestCase[] = [ t({ message: "Hello", context: \`my custom\` }) `, expected: ` - import { i18n } from "@lingui/core"; - i18n._( + import { i18n as _i18n } from "@lingui/core"; + _i18n._( /*i18n*/ { context: "my custom", @@ -301,7 +301,7 @@ const cases: TestCase[] = [ id: "BYqAaU", } ); - i18n._( + _i18n._( /*i18n*/ { context: \`my custom\`, @@ -314,12 +314,12 @@ const cases: TestCase[] = [ { name: "Support id and comment in t macro as callExpression", input: ` - import { t } from '@lingui/macro' + import { t, plural } from '@lingui/macro' const msg = t({ id: 'msgId', comment: 'description for translators', message: plural(val, { one: '...', other: '...' }) }) `, expected: ` - import { i18n } from "@lingui/core"; - const msg = i18n._( + import { i18n as _i18n } from "@lingui/core"; + const msg = _i18n._( /*i18n*/ { id: "msgId", @@ -339,8 +339,8 @@ const cases: TestCase[] = [ const msg = t({ id: 'msgId', message: \`Some \${value}\` }) `, expected: ` - import { i18n } from "@lingui/core"; - const msg = i18n._( + import { i18n as _i18n } from "@lingui/core"; + const msg = _i18n._( /*i18n*/ { id: "msgId", @@ -359,9 +359,9 @@ const cases: TestCase[] = [ const msg = t({ id: \`msgId\` }) `, expected: ` - import { i18n } from "@lingui/core"; + import { i18n as _i18n } from "@lingui/core"; const msg = - i18n._(/*i18n*/ + _i18n._(/*i18n*/ { id: \`msgId\` }); @@ -375,8 +375,8 @@ const cases: TestCase[] = [ const msg = t\`Message\` `, expected: ` - import { i18n } from "@lingui/core"; - const msg = i18n._(/*i18n*/ + import { i18n as _i18n } from "@lingui/core"; + const msg = _i18n._(/*i18n*/ { id: "xDAtGP", }); @@ -386,7 +386,7 @@ const cases: TestCase[] = [ name: "Production - only essential props are kept, with plural, with custom i18n instance", production: true, input: ` - import { t } from '@lingui/macro'; + import { t, plural } from '@lingui/macro'; const msg = t({ id: 'msgId', comment: 'description for translators', @@ -395,9 +395,9 @@ const cases: TestCase[] = [ }) `, expected: ` - import { i18n } from "@lingui/core"; + import { i18n as _i18n } from "@lingui/core"; const msg = - i18n._(/*i18n*/ + _i18n._(/*i18n*/ { id: "msgId", values: { @@ -444,9 +444,9 @@ const cases: TestCase[] = [ }) `, expected: ` - import { i18n } from "@lingui/core"; + import { i18n as _i18n } from "@lingui/core"; const msg = - i18n._(/*i18n*/ + _i18n._(/*i18n*/ { id: 'msgId', values: { @@ -471,9 +471,9 @@ const cases: TestCase[] = [ }) `, expected: ` - import { i18n } from "@lingui/core"; + import { i18n as _i18n } from "@lingui/core"; const msg = - i18n._(/*i18n*/ + _i18n._(/*i18n*/ { id: 'msgId', context: 'My Context', @@ -492,6 +492,86 @@ const cases: TestCase[] = [ { filename: "js-t-var/js-t-var.js", }, + { + name: "Support t in t", + input: ` + import { t } from '@lingui/macro' + t\`Field \${t\`First Name\`} is required\` + `, + expected: ` + import { i18n as _i18n } from "@lingui/core"; +_i18n._( + /*i18n*/ + { + id: "O8dJMg", + message: "Field {0} is required", + values: { + 0: _i18n._( + /*i18n*/ + { + id: "kODvZJ", + message: "First Name", + } + ), + }, + } +); + + `, + }, + { + name: "should correctly process nested macro when referenced from different imports", + input: ` + import { t } from '@lingui/macro' + import { plural } from '@lingui/macro' + t\`Ola! \${plural(count, {one: "1 user", many: "# users"})} is required\` + `, + expected: ` +import { i18n as _i18n } from "@lingui/core"; +_i18n._( + /*i18n*/ + { + id: "EUO+Gb", + message: "Ola! {count, plural, one {1 user} many {# users}} is required", + values: { + count: count, + }, + } +); + `, + }, + { + name: "should correctly process nested macro when referenced from different imports 2", + input: ` + import { t as t1, plural as plural1 } from '@lingui/macro' + import { plural as plural2, t as t2 } from '@lingui/macro' + t1\`Ola! \${plural2(count, {one: "1 user", many: "# users"})} Ola!\` + t2\`Ola! \${plural1(count, {one: "1 user", many: "# users"})} Ola!\` + `, + expected: ` + import { i18n as _i18n } from "@lingui/core"; +_i18n._( + /*i18n*/ + { + id: "aui5Gr", + message: "Ola! {count, plural, one {1 user} many {# users}} Ola!", + values: { + count: count, + }, + } +); +_i18n._( + /*i18n*/ + { + id: "wJ7AD9", + message: "Ola! {count, plural, one {1 user} many {# users}} Ola!", + values: { + count: count, + }, + } +); +`, + }, ] export default cases diff --git a/packages/macro/test/js-useLingui.ts b/packages/macro/test/js-useLingui.ts index 12fdf9972..08044fab7 100644 --- a/packages/macro/test/js-useLingui.ts +++ b/packages/macro/test/js-useLingui.ts @@ -13,10 +13,10 @@ function MyComponent() { } `, expected: ` -import { useLingui } from "@lingui/react"; +import { useLingui as _useLingui } from "@lingui/react"; function MyComponent() { - const { _: _t } = useLingui(); + const { _: _t } = _useLingui(); const a = _t( /*i18n*/ { @@ -37,10 +37,10 @@ function MyComponent() { } `, expected: ` -import { useLingui } from "@lingui/react"; +import { useLingui as _useLingui } from "@lingui/react"; function MyComponent() { - const { _: _t } = useLingui(); + const { _: _t } = _useLingui(); const a = _t( /*i18n*/ { @@ -71,9 +71,9 @@ function MyComponent() { } `, expected: ` -import { useLingui } from "@lingui/react"; +import { useLingui as _useLingui } from "@lingui/react"; function MyComponent() { - const { _: _t } = useLingui(); + const { _: _t } = _useLingui(); const a = _t( /*i18n*/ { @@ -112,10 +112,10 @@ function MyComponent() { } `, expected: ` -import { useLingui } from "@lingui/react"; +import { useLingui as _useLingui } from "@lingui/react"; function MyComponent() { const _t = "i'm here"; - const { _: _t2 } = useLingui(); + const { _: _t2 } = _useLingui(); const a = _t2( /*i18n*/ { @@ -143,9 +143,9 @@ function MyComponent() { `, expected: ` -import { useLingui } from "@lingui/react"; +import { useLingui as _useLingui } from "@lingui/react"; function MyComponent() { - const { _: _t } = useLingui(); + const { _: _t } = _useLingui(); const a = _t( /*i18n*/ { @@ -171,9 +171,9 @@ function MyComponent() { } `, expected: ` - import { useLingui } from "@lingui/react"; + import { useLingui as _useLingui } from "@lingui/react"; function MyComponent() { - const { _: _t } = useLingui(); + const { _: _t } = _useLingui(); const a = _t( /*i18n*/ { @@ -195,9 +195,9 @@ function MyComponent() { } `, expected: ` -import { useLingui } from "@lingui/react"; +import { useLingui as _useLingui } from "@lingui/react"; function MyComponent() { - const { _: _t } = useLingui(); + const { _: _t } = _useLingui(); const a = useMemo( () => _t( @@ -225,9 +225,9 @@ function MyComponent() { } `, expected: ` -import { useLingui } from "@lingui/react"; +import { useLingui as _useLingui } from "@lingui/react"; function MyComponent() { - const { i18n, _: _t } = useLingui(); + const { i18n, _: _t } = _useLingui(); console.log(i18n); const a = _t( /*i18n*/ @@ -254,11 +254,12 @@ function MyComponent() { } `, expected: ` +import { useLingui as _useLingui } from "@lingui/react"; import { useLingui } from "@lingui/react"; function MyComponent() { const { _ } = useLingui(); console.log(_); - const { _: _t } = useLingui(); + const { _: _t } = _useLingui(); const a = _t( /*i18n*/ { @@ -271,12 +272,10 @@ function MyComponent() { }, { - // 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'; +import { useLingui as useLinguiMacro } from '@lingui/macro'; function MyComponent() { const { _ } = useLinguiRenamed(); @@ -288,11 +287,11 @@ function MyComponent() { `, expected: ` import { useLingui as useLinguiRenamed } from '@lingui/react'; -import { useLingui } from "@lingui/react"; +import { useLingui as _useLingui } from "@lingui/react"; function MyComponent() { const { _ } = useLinguiRenamed(); console.log(_); - const { _: _t } = useLingui(); + const { _: _t } = _useLingui(); const a = _t( /*i18n*/ { @@ -318,9 +317,9 @@ function MyComponent2() { const b = t\`Text\`; }`, expected: ` -import { useLingui } from "@lingui/react"; +import { useLingui as _useLingui } from "@lingui/react"; function MyComponent() { - const { _: _t } = useLingui(); + const { _: _t } = _useLingui(); const a = _t( /*i18n*/ { @@ -330,8 +329,8 @@ function MyComponent() { ); } function MyComponent2() { - const { _: _t } = useLingui(); - const b = _t( + const { _: _t2 } = _useLingui(); + const b = _t2( /*i18n*/ { id: "xeiujy", @@ -362,9 +361,9 @@ function MyComponent() { } `, expected: ` -import { myUselingui } from "@my/lingui-react"; +import { myUselingui as _useLingui } from "@my/lingui-react"; function MyComponent() { - const { _: _t } = myUselingui(); + const { _: _t } = _useLingui(); const a = _t( /*i18n*/ { diff --git a/packages/macro/test/jsx-plural.ts b/packages/macro/test/jsx-plural.ts index 17a035894..51709caf3 100644 --- a/packages/macro/test/jsx-plural.ts +++ b/packages/macro/test/jsx-plural.ts @@ -13,8 +13,8 @@ const cases: TestCase[] = [ />; `, expected: ` - import { Trans } from "@lingui/react"; - A lot of them}}" @@ -40,8 +40,8 @@ const cases: TestCase[] = [ />; `, expected: ` - import { Trans } from "@lingui/react"; - "} message={"{count, plural, one {...} other {...}}"} values={{ @@ -67,8 +67,8 @@ const cases: TestCase[] = [ />; `, expected: ` - import { Trans } from "@lingui/react"; - {}} id="custom.id" message={ @@ -104,8 +104,8 @@ const cases: TestCase[] = [ />; `, expected: ` - import { Trans } from "@lingui/react"; - "} message={ "{count, plural, one {<0># slot added} other {<1># slots added}}" @@ -140,8 +140,8 @@ const cases: TestCase[] = [ />; `, expected: ` - import { Trans } from "@lingui/react"; - "} message={ "{count, plural, one {One hello} other {Other hello}}" @@ -166,8 +166,8 @@ const cases: TestCase[] = [ />; `, expected: ` - import { Trans } from "@lingui/react"; - A lot of them}}"} values={{ + import { Trans as _Trans } from "@lingui/react"; + <_Trans render="strong" id="msg.plural" message={"{count, plural, offset:1 =0 {Zero items} few {{count} items} other {<0>A lot of them}}"} values={{ count: count }} components={{ 0: @@ -189,8 +189,8 @@ const cases: TestCase[] = [ `, expected: ` - import { Trans } from "@lingui/react"; - a lot of them}}" } values={{ @@ -213,8 +213,8 @@ const cases: TestCase[] = [ />; `, expected: ` - import { Trans } from "@lingui/react"; - A lot of them}}" diff --git a/packages/macro/test/jsx-select.ts b/packages/macro/test/jsx-select.ts index 5fa938b9a..d07a6dbac 100644 --- a/packages/macro/test/jsx-select.ts +++ b/packages/macro/test/jsx-select.ts @@ -13,8 +13,8 @@ const cases: TestCase[] = [ />; `, expected: ` - import { Trans } from "@lingui/react"; - "} message={"{count, select, male {He} female {She} other {<0>Other}}"} values={{ count: count @@ -38,8 +38,8 @@ const cases: TestCase[] = [ />; `, expected: ` - import { Trans } from "@lingui/react"; - Other}}"} values={{ + import { Trans as _Trans } from "@lingui/react"; + <_Trans render="strong" id="msg.select" message={"{0, select, male {He} female {She} other {<0>Other}}"} values={{ 0: user.gender }} components={{ 0: @@ -50,7 +50,7 @@ const cases: TestCase[] = [ stripId: true, name: "Select should support JSX elements in cases", input: ` - import { Select } from '@lingui/macro'; + import { Select, Trans } from '@lingui/macro';