From fb4b2487b5e268e9ac856cd98517b225aa551abf Mon Sep 17 00:00:00 2001 From: Timofei Iatsenko Date: Tue, 27 Feb 2024 14:45:56 +0100 Subject: [PATCH 01/15] refactor(macro): use babel-plugin style instead of macro --- .../test/fixtures/js-with-macros.js | 2 +- packages/macro/src/constants.ts | 18 + packages/macro/src/index.ts | 299 ++---------- packages/macro/src/macroJs.test.ts | 86 ++-- packages/macro/src/macroJs.ts | 459 +++++++++++------- packages/macro/src/macroJsx.test.ts | 34 +- packages/macro/src/macroJsx.ts | 98 ++-- packages/macro/src/plugin.ts | 221 +++++++++ packages/macro/test/index.ts | 26 +- packages/macro/test/js-defineMessage.ts | 2 +- packages/macro/test/js-t.ts | 31 +- packages/macro/test/js-useLingui.ts | 8 +- packages/macro/test/jsx-select.ts | 2 +- packages/macro/test/jsx-trans.ts | 22 - 14 files changed, 740 insertions(+), 568 deletions(-) create mode 100644 packages/macro/src/plugin.ts 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/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..ed9472e36 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,187 @@ 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") + path.isCallExpression() && + this.isLinguiIdentifier(path.get("callee"), JsMacroName.useLingui) ) { 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] - ) + if (!path.parentPath.isVariableDeclarator()) { + throw new Error( + `\`useLingui\` macro must be used in variable declaration. - path.replaceWith(callExpr) + Example: - return false - } + const { t } = useLingui() + ` + ) + } - // { t } = useLingui() - // t(messageDescriptor) - if ( - path.isCallExpression() && - this.isLinguiIdentifier(path.node.callee, "_t") && - this.types.isExpression(path.node.arguments[0]) - ) { - this.needsUseLinguiImport = true - let descriptor = this.processDescriptor(path.node.arguments[0]) - path.node.arguments = [descriptor] - return false - } + 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() + ` + ) + } - if ( - this.types.isCallExpression(path.node) && - this.isLinguiIdentifier(path.node.callee, "useLingui") && - this.types.isVariableDeclarator(path.parentPath.node) - ) { - this.needsUseLinguiImport = true - return false + const uniqTIdentifier = path.scope.generateUidIdentifier("t") + + path.scope + .getBinding(_property.value.name) + ?.referencePaths.forEach((refPath) => { + const currentPath = refPath.parentPath + + // { t } = useLingui() + // t`Hello!` + if (currentPath.isTaggedTemplateExpression()) { + const tokens = this.tokenizeTemplateLiteral(currentPath) + + 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)) + }) + + _property.key.name = "_" + path.scope.rename(_property.value.name, uniqTIdentifier.name) + + return this.types.callExpression( + this.types.identifier(this.useLinguiImportName), + [] + ) } - const tokens = this.tokenizeNode(path.node) + const tokens = this.tokenizeNode(path, true) - this.replacePathWithMessage(path, tokens) + if (tokens) { + this.needsI18nImport = true + return this.replacePathWithMessage(path, tokens) + } - this.needsI18nImport = true - return true + return false } /** @@ -217,9 +270,12 @@ 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) } /** @@ -240,28 +296,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[] = [idProperty] + const properties: ObjectProperty[] = [] - if (!this.stripNonEssentialProps) { - properties.push(contextProperty) + if (idProperty) { + properties.push(idProperty.node) + } + + 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") + + const tokens = messageValue.isTemplateLiteral() + ? this.tokenizeTemplateLiteral(messageValue) + : this.tokenizeNode(messageValue, true) - let messageNode = messageProperty.value as StringLiteral + let messageNode = messageValue.node as StringLiteral if (tokens) { const { message, values } = buildICUFromTokens(tokens) @@ -279,17 +340,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 +375,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 +407,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 +440,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 +576,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..fc0629131 --- /dev/null +++ b/packages/macro/src/plugin.ts @@ -0,0 +1,221 @@ +import type { PluginObj, Visitor, PluginPass } from "@babel/core" +import * as babelTypes from "@babel/types" +import MacroJSX from "./macroJsx" +import { NodePath } from "@babel/traverse" +import MacroJs from "./macroJs" +import { MACRO_PACKAGE } from "./constants" +import { LinguiConfigNormalized, getConfig as loadConfig } from "@lingui/conf" + +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}` + ) +} + +export default function ({ + types: t, +}: { + types: typeof babelTypes +}): PluginObj { + let uniqI18nName: string + let uniqTransName: string + let uniqUseLinguiName: string + + let needsI18nImport = false + let needsUseLinguiImport = false + let needsTransImport = false + + // todo: check if babel re-execute this function on each file + const processedNodes = new Set() + + function addImport( + path: NodePath, + module: string, + importName: string, + bindingName: string + ) { + path.insertAfter( + t.importDeclaration( + [ + t.importSpecifier( + t.identifier(bindingName), + t.identifier(importName) + ), + ], + t.stringLiteral(module) + ) + ) + } + + return { + pre(state) { + console.log(state.opts as LinguiPluginOpts) + }, + visitor: { + Program: { + enter(path, state) { + const macroImports = path.get("body").filter((statement) => { + return ( + statement.isImportDeclaration() && + statement.get("source").node.value === MACRO_PACKAGE + ) + }) + + if (!macroImports.length) { + return path.stop() + } + + state.set( + "config", + getConfig((state.opts as LinguiPluginOpts).linguiConfig) + ) + + // uniqI18nName = path.scope.generateUid("i18n") + // uniqTransName = path.scope.generateUid("Trans") + // uniqUseLinguiName = path.scope.generateUid("useLingui") + + uniqI18nName = "i18n" + uniqTransName = "Trans" + uniqUseLinguiName = "useLingui" + }, + exit(path, state) { + const macroImports = path.get("body").filter((statement) => { + return ( + statement.isImportDeclaration() && + statement.get("source").node.value === MACRO_PACKAGE + ) + }) + + const config = getConfig( + (state.opts as LinguiPluginOpts).linguiConfig + ) + + const { + i18nImportModule, + i18nImportName, + TransImportModule, + TransImportName, + useLinguiImportModule, + useLinguiImportName, + } = config.runtimeConfigModule + + if (needsI18nImport) { + addImport( + macroImports[0], + i18nImportModule, + i18nImportName, + uniqI18nName + ) + } + + if (needsUseLinguiImport) { + addImport( + macroImports[0], + useLinguiImportModule, + useLinguiImportName, + uniqUseLinguiName + ) + } + + if (needsTransImport) { + addImport( + macroImports[0], + TransImportModule, + TransImportName, + uniqTransName + ) + } + + macroImports.forEach((path) => path.remove()) + }, + }, + JSXElement(path, state) { + if (processedNodes.has(path.node)) { + return + } + + const macro = new MacroJSX( + { types: t }, + { + transImportName: uniqTransName, + 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) { + processedNodes.add(newNode) + path.replaceWith(newNode) + needsTransImport = true + } + }, + + "CallExpression|TaggedTemplateExpression"( + path: NodePath< + babelTypes.CallExpression | babelTypes.TaggedTemplateExpression + >, + state: PluginPass + ) { + if (processedNodes.has(path.node)) { + return + } + const macro = new MacroJs( + { types: t }, + { + stripNonEssentialProps: + process.env.NODE_ENV == "production" && + !(state.opts as LinguiPluginOpts).extract, + i18nImportName: uniqI18nName, + useLinguiImportName: uniqUseLinguiName, + } + ) + let newNode: false | babelTypes.Node + + try { + newNode = macro.replacePath(path) + } catch (e) { + reportUnsupportedSyntax(path, e as Error) + } + + if (newNode) { + processedNodes.add(newNode) + path.replaceWith(newNode) + } + + needsI18nImport = needsI18nImport || macro.needsI18nImport + needsUseLinguiImport = + needsUseLinguiImport || macro.needsUseLinguiImport + }, + } as Visitor, + } +} diff --git a/packages/macro/test/index.ts b/packages/macro/test/index.ts index 4e85eefaf..e7b514d43 100644 --- a/packages/macro/test/index.ts +++ b/packages/macro/test/index.ts @@ -7,7 +7,7 @@ import { transformSync, } from "@babel/core" import prettier from "prettier" -import { LinguiMacroOpts } from "../src/index" +import { LinguiPluginOpts } from "../src/plugin" import { JSXAttribute, jsxExpressionContainer, @@ -23,7 +23,7 @@ export type TestCase = { filename?: string production?: boolean useTypescriptPreset?: boolean - macroOpts?: LinguiMacroOpts + macroOpts?: LinguiPluginOpts /** Remove hash id from snapshot for more stable testing */ stripId?: boolean only?: boolean @@ -69,7 +69,7 @@ describe("macro", function () { process.env.LINGUI_CONFIG = path.join(__dirname, "lingui.config.js") const getDefaultBabelOptions = ( - macroOpts: LinguiMacroOpts = {}, + macroOpts: LinguiPluginOpts = {}, isTs: boolean = false, stripId = false ): TransformOptions => { @@ -324,7 +324,7 @@ describe("macro", function () { }) }) - describe("useLingui", () => { + describe("useLingui validation", () => { it("Should throw if used not in the variable declaration", () => { const code = ` import {useLingui} from "@lingui/macro"; @@ -349,4 +349,22 @@ describe("macro", function () { ) }) }) + + describe("Trans validation", () => { + it("Should throw if spread used in children", () => { + const code = ` + import { Trans } from '@lingui/macro'; + {...spread} + ` + expect(transformCode(code)).toThrowError("Incorrect usage of Trans") + }) + + it("Should throw if used without children", () => { + 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-t.ts b/packages/macro/test/js-t.ts index e48684c81..d0bea2fd2 100644 --- a/packages/macro/test/js-t.ts +++ b/packages/macro/test/js-t.ts @@ -314,7 +314,7 @@ 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: ` @@ -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', @@ -492,6 +492,33 @@ 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 } from "@lingui/core"; +i18n._( + /*i18n*/ + { + id: "O8dJMg", + message: "Field {0} is required", + values: { + 0: i18n._( + /*i18n*/ + { + id: "kODvZJ", + message: "First Name", + } + ), + }, + } +); + + `, + }, ] export default cases diff --git a/packages/macro/test/js-useLingui.ts b/packages/macro/test/js-useLingui.ts index 12fdf9972..da5c64544 100644 --- a/packages/macro/test/js-useLingui.ts +++ b/packages/macro/test/js-useLingui.ts @@ -330,8 +330,8 @@ function MyComponent() { ); } function MyComponent2() { - const { _: _t } = useLingui(); - const b = _t( + const { _: _t2 } = useLingui(); + const b = _t2( /*i18n*/ { id: "xeiujy", @@ -362,9 +362,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-select.ts b/packages/macro/test/jsx-select.ts index 5fa938b9a..d7ed91f47 100644 --- a/packages/macro/test/jsx-select.ts +++ b/packages/macro/test/jsx-select.ts @@ -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'; setSelected(evt.target.value)} + > + + + +

+ +

+ + + ) +} diff --git a/packages/cli/test/extractor-experimental-1797/fixtures/components/Switcher.tsx b/packages/cli/test/extractor-experimental-1797/fixtures/components/Switcher.tsx new file mode 100644 index 000000000..b6813d068 --- /dev/null +++ b/packages/cli/test/extractor-experimental-1797/fixtures/components/Switcher.tsx @@ -0,0 +1,46 @@ +import { useRouter } from 'next/router' +import { useState } from 'react' +import { t, msg } from '@lingui/macro' +import { useLingui } from '@lingui/react' +import { MessageDescriptor } from '@lingui/core' + +type LOCALES = 'en' | 'sr' | 'es' | 'pseudo' + +const languages: { [key: string]: MessageDescriptor } = { + en: msg`English`, + sr: msg`Serbian`, + es: msg`Spanish` +} + +export function Switcher() { + const router = useRouter() + const { i18n } = useLingui() + + const [locale, setLocale] = useState( + router.locale!.split('-')[0] as LOCALES + ) + + // disabled for DEMO - so we can demonstrate the 'pseudo' locale functionality + // if (process.env.NEXT_PUBLIC_NODE_ENV !== 'production') { + // languages['pseudo'] = t`Pseudo` + // } + + function handleChange(event: React.ChangeEvent) { + const locale = event.target.value as LOCALES + + setLocale(locale) + router.push(router.pathname, router.pathname, { locale }) + } + + return ( + + ) +} diff --git a/packages/cli/test/extractor-experimental-1797/fixtures/pages/_app.page.tsx b/packages/cli/test/extractor-experimental-1797/fixtures/pages/_app.page.tsx new file mode 100644 index 000000000..0883e2eb7 --- /dev/null +++ b/packages/cli/test/extractor-experimental-1797/fixtures/pages/_app.page.tsx @@ -0,0 +1,19 @@ +import { i18n } from '@lingui/core' +import { I18nProvider } from '@lingui/react' +import '../styles/globals.css' +import type { AppProps } from 'next/app' +import { useLinguiInit } from '../utils' + +function MyApp({ Component, pageProps }: AppProps) { + useLinguiInit(pageProps.translation) + + return ( + <> + + + + + ) +} + +export default MyApp diff --git a/packages/cli/test/extractor-experimental-1797/fixtures/pages/index.page.tsx b/packages/cli/test/extractor-experimental-1797/fixtures/pages/index.page.tsx new file mode 100644 index 000000000..a49cf25d2 --- /dev/null +++ b/packages/cli/test/extractor-experimental-1797/fixtures/pages/index.page.tsx @@ -0,0 +1,92 @@ +import { Plural, t, Trans } from '@lingui/macro' + +import path from 'path' +import { GetStaticProps, NextPage } from 'next' +import Head from 'next/head' +import { AboutText } from '../components/AboutText' +import Developers from '../components/Developers' +import { Switcher } from '../components/Switcher' +import styles from '../styles/Index.module.css' +import { loadCatalog } from '../utils' +import { useLingui } from '@lingui/react' + +export const getStaticProps: GetStaticProps = async (ctx) => { + const fileName = __filename + const cwd = process.cwd() + const { locale } = ctx + + const pathname = path + .relative(cwd, fileName) + .replace('.next/server/pages/', '') + .replace('.js', '') + + const translation = await loadCatalog(locale || 'en', pathname) + return { + props: { + translation + } + } +} + +const Index: NextPage = () => { + /** + * This hook is needed to subscribe your + * component for changes if you use t`` macro + */ + useLingui() + + return ( +
+ + {/* + The Next Head component is not being rendered in the React + component tree and React Context is not being passed down to the components placed in the . + That means we cannot use the component here and instead have to use `t` macro. + */} + {t`Translation Demo`} + + + +
+ +

+ + Welcome to Next.js! + +

+

+ Plain text +

+

{t`Plain text`}

+

+ + Next.js say hi. + +

+

+ + Wonderful framework Next.js say hi. + +

+

+ + Wonderful framework Next.js say hi. + And Next.js say hi. + +

+
+ +
+ + +
+ +
+ +
+
+
+ ) +} + +export default Index diff --git a/packages/cli/test/extractor-experimental-1797/fixtures/styles/Index.module.css b/packages/cli/test/extractor-experimental-1797/fixtures/styles/Index.module.css new file mode 100644 index 000000000..4abdd3311 --- /dev/null +++ b/packages/cli/test/extractor-experimental-1797/fixtures/styles/Index.module.css @@ -0,0 +1,47 @@ +.container { + min-height: 100vh; + padding: 0 0.5rem; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.main { + padding: 5rem 0; + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.title a { + color: #0070f3; + text-decoration: none; +} + +.title a:hover, +.title a:focus, +.title a:active { + text-decoration: underline; +} + +.title { + margin-bottom: 0; + margin-top: 2rem; + line-height: 1.15; + font-size: 4rem; +} + +.title, +.description { + text-align: center; +} + +.description { + /* line-height: 1.5; */ + /* font-size: 1.5rem; */ + max-width: 600px; + text-align: left; +} diff --git a/packages/cli/test/extractor-experimental-1797/fixtures/styles/globals.css b/packages/cli/test/extractor-experimental-1797/fixtures/styles/globals.css new file mode 100644 index 000000000..e5e2dcc23 --- /dev/null +++ b/packages/cli/test/extractor-experimental-1797/fixtures/styles/globals.css @@ -0,0 +1,16 @@ +html, +body { + padding: 0; + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, + Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; +} + +a { + color: inherit; + text-decoration: none; +} + +* { + box-sizing: border-box; +} diff --git a/packages/cli/test/extractor-experimental-1797/fixtures/utils.ts b/packages/cli/test/extractor-experimental-1797/fixtures/utils.ts new file mode 100644 index 000000000..d70a97b17 --- /dev/null +++ b/packages/cli/test/extractor-experimental-1797/fixtures/utils.ts @@ -0,0 +1,30 @@ +import { i18n, Messages } from '@lingui/core' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' + +export async function loadCatalog(locale: string, pathname: string) { + if (pathname === '_error') { + return {} + } + const catalog = await import( + `@lingui/loader!./locales/src/pages/${pathname}.page/${locale}.po` + ) + return catalog.messages +} + +export function useLinguiInit(messages: Messages) { + const router = useRouter() + const locale = router.locale || router.defaultLocale! + useState(() => { + i18n.loadAndActivate({ locale, messages }) + }) + + useEffect(() => { + const localeDidChange = locale !== i18n.locale + if (localeDidChange) { + i18n.loadAndActivate({ locale, messages }) + } + }, [locale, messages]) + + return i18n +} diff --git a/packages/cli/test/extractor-experimental-1797/package.json b/packages/cli/test/extractor-experimental-1797/package.json new file mode 100644 index 000000000..985e126a1 --- /dev/null +++ b/packages/cli/test/extractor-experimental-1797/package.json @@ -0,0 +1,30 @@ +{ + "name": "nextjs-swc-example", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "debug": "NODE_OPTIONS='--inspect' next dev", + "build": "npm run lingui:extract && next build", + "start": "next start", + "lingui:extract": "lingui extract-experimental", + "test": "npm run build" + }, + "dependencies": { + "@lingui/core": "^4.5.0", + "@lingui/react": "^4.5.0", + "next": "13.4.12", + "react": "18.2.0", + "react-dom": "18.2.0" + }, + "devDependencies": { + "@lingui/cli": "^4.5.0", + "@lingui/loader": "^4.5.0", + "@lingui/macro": "^4.5.0", + "@lingui/swc-plugin": "^4.0.4", + "@types/react": "^18.0.14", + "eslint": "8.35.0", + "eslint-config-next": "12.3.4", + "typescript": "^4.7.4" + } +} diff --git a/packages/cli/test/extractor-experimental-1797/tsconfig.json b/packages/cli/test/extractor-experimental-1797/tsconfig.json new file mode 100644 index 000000000..005baed64 --- /dev/null +++ b/packages/cli/test/extractor-experimental-1797/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "es6", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + // "downlevelIteration": true, + "jsx": "preserve", + "incremental": true + }, + "include": [ + "next-env.d.ts", + "src/**/*.ts", + "src/**/*.tsx" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/packages/cli/test/index.test.ts b/packages/cli/test/index.test.ts index 1bac33ec5..40c62e507 100644 --- a/packages/cli/test/index.test.ts +++ b/packages/cli/test/index.test.ts @@ -287,5 +287,58 @@ describe("E2E Extractor Test", () => { compareFolders(actualPath, expectedPath) }) + + it("Should extract correctly all messages github-issue: 1797", async () => { + const { rootDir, actualPath, expectedPath } = await prepare( + "extractor-experimental-1797" + ) + + await mockConsole(async (console) => { + const result = await extractExperimentalCommand( + makeConfig({ + rootDir: rootDir, + locales: ["en", "pl"], + sourceLocale: "en", + format: "po", + catalogs: [], + experimental: { + extractor: { + entries: ["/fixtures/pages/**/*.page.tsx"], + output: "/actual/locales/{entryName}/{locale}", + }, + }, + }), + { + clean: true, + } + ) + + expect(getConsoleMockCalls(console.error)).toBeFalsy() + expect(result).toBeTruthy() + expect(getConsoleMockCalls(console.log)).toMatchInlineSnapshot(` + You have using an experimental feature + Experimental features are not covered by semver, and may cause unexpected or broken application behavior. Use at your own risk. + + Catalog statistics for fixtures/pages/_app.page.tsx: + ┌─────────────┬─────────────┬─────────┐ + │ Language │ Total count │ Missing │ + ├─────────────┼─────────────┼─────────┤ + │ en (source) │ 0 │ - │ + │ pl │ 0 │ 0 │ + └─────────────┴─────────────┴─────────┘ + + Catalog statistics for fixtures/pages/index.page.tsx: + ┌─────────────┬─────────────┬─────────┐ + │ Language │ Total count │ Missing │ + ├─────────────┼─────────────┼─────────┤ + │ en (source) │ 14 │ - │ + │ pl │ 14 │ 14 │ + └─────────────┴─────────────┴─────────┘ + + `) + }) + + compareFolders(actualPath, expectedPath) + }) }) }) diff --git a/packages/macro/package.json b/packages/macro/package.json index 520f408f5..3184bc1e0 100644 --- a/packages/macro/package.json +++ b/packages/macro/package.json @@ -63,7 +63,6 @@ "import": { "types": "./dist/plugin.d.ts", "default": "./dist/index.mjs" - } } }, From 0658a0650aa63316627869a6b5f4fb37b379e4b3 Mon Sep 17 00:00:00 2001 From: Timofei Iatsenko Date: Tue, 5 Mar 2024 12:09:47 +0100 Subject: [PATCH 14/15] refactor(macro): use workspace schema for dev lingui deps --- .../package.json | 4 +- yarn.lock | 59 ++----------------- 2 files changed, 6 insertions(+), 57 deletions(-) diff --git a/packages/babel-plugin-extract-messages/package.json b/packages/babel-plugin-extract-messages/package.json index d2ea73b54..bb96bd66a 100644 --- a/packages/babel-plugin-extract-messages/package.json +++ b/packages/babel-plugin-extract-messages/package.json @@ -43,8 +43,8 @@ "@babel/core": "^7.21.0", "@babel/traverse": "7.20.12", "@babel/types": "^7.20.7", - "@lingui/jest-mocks": "*", - "@lingui/macro": "*", + "@lingui/jest-mocks": "workspace:*", + "@lingui/macro": "workspace:*", "unbuild": "2.0.0" } } diff --git a/yarn.lock b/yarn.lock index fed02877d..eb397a7da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3225,8 +3225,8 @@ __metadata: "@babel/core": ^7.21.0 "@babel/traverse": 7.20.12 "@babel/types": ^7.20.7 - "@lingui/jest-mocks": "*" - "@lingui/macro": "*" + "@lingui/jest-mocks": "workspace:*" + "@lingui/macro": "workspace:*" unbuild: 2.0.0 languageName: unknown linkType: soft @@ -3293,20 +3293,6 @@ __metadata: languageName: unknown linkType: soft -"@lingui/conf@npm:4.7.1": - version: 4.7.1 - resolution: "@lingui/conf@npm:4.7.1" - dependencies: - "@babel/runtime": ^7.20.13 - chalk: ^4.1.0 - cosmiconfig: ^8.0.0 - jest-validate: ^29.4.3 - jiti: ^1.17.1 - lodash.get: ^4.4.2 - checksum: 4f98115c21508d89471afb0ee602f8b8ba763f59794126006831feb4080d8e878131e5b77807057d2c4b3a3d4119be0f9ea984ea81cf702c50cc2070d98bb529 - languageName: node - linkType: hard - "@lingui/core@4.8.0-next.0, @lingui/core@workspace:*, @lingui/core@workspace:packages/core": version: 0.0.0-use.local resolution: "@lingui/core@workspace:packages/core" @@ -3329,17 +3315,6 @@ __metadata: languageName: node linkType: hard -"@lingui/core@npm:4.7.1": - version: 4.7.1 - resolution: "@lingui/core@npm:4.7.1" - dependencies: - "@babel/runtime": ^7.20.13 - "@lingui/message-utils": 4.7.1 - unraw: ^3.0.0 - checksum: f9dca40a2a2f406cc0e1292066ce98ba74a4614abfbe0358f53cff8d2809329de37248117c06f267d859d08232eb9e8e0b70ce0a4e6d9667673b1042bc6af5b4 - languageName: node - linkType: hard - "@lingui/detect-locale@workspace:packages/detect-locale": version: 0.0.0-use.local resolution: "@lingui/detect-locale@workspace:packages/detect-locale" @@ -3416,7 +3391,7 @@ __metadata: languageName: unknown linkType: soft -"@lingui/jest-mocks@*, @lingui/jest-mocks@workspace:^, @lingui/jest-mocks@workspace:packages/jest-mocks": +"@lingui/jest-mocks@*, @lingui/jest-mocks@workspace:*, @lingui/jest-mocks@workspace:^, @lingui/jest-mocks@workspace:packages/jest-mocks": version: 0.0.0-use.local resolution: "@lingui/jest-mocks@workspace:packages/jest-mocks" languageName: unknown @@ -3437,7 +3412,7 @@ __metadata: languageName: unknown linkType: soft -"@lingui/macro@4.8.0-next.0, @lingui/macro@workspace:^, @lingui/macro@workspace:packages/macro": +"@lingui/macro@4.8.0-next.0, @lingui/macro@workspace:*, @lingui/macro@workspace:^, @lingui/macro@workspace:packages/macro": version: 0.0.0-use.local resolution: "@lingui/macro@workspace:packages/macro" dependencies: @@ -3462,22 +3437,6 @@ __metadata: languageName: unknown linkType: soft -"@lingui/macro@npm:*": - version: 4.7.1 - resolution: "@lingui/macro@npm:4.7.1" - dependencies: - "@babel/runtime": ^7.20.13 - "@babel/types": ^7.20.7 - "@lingui/conf": 4.7.1 - "@lingui/core": 4.7.1 - "@lingui/message-utils": 4.7.1 - peerDependencies: - "@lingui/react": ^4.0.0 - babel-plugin-macros: 2 || 3 - checksum: ced3208095731584a768a828a3b51a85519c69950177f8cd550016a1b312b684e5c9730f201cc5e483110b382751ffa1749c9567a1c7ac836d6dbee7a2036118 - languageName: node - linkType: hard - "@lingui/message-utils@4.8.0-next.0, @lingui/message-utils@workspace:packages/message-utils": version: 0.0.0-use.local resolution: "@lingui/message-utils@workspace:packages/message-utils" @@ -3498,16 +3457,6 @@ __metadata: languageName: node linkType: hard -"@lingui/message-utils@npm:4.7.1": - version: 4.7.1 - resolution: "@lingui/message-utils@npm:4.7.1" - dependencies: - "@messageformat/parser": ^5.0.0 - js-sha256: ^0.10.1 - checksum: e4dbb4f2dc2f6d0eb2e7d1ee6a93957dcefc37ff2b85586d014e9c9f75b920a574ed6cf9eff60eae2ea99478e81aa5a0331377003f0dcef712a8bc5ff150ef3d - languageName: node - linkType: hard - "@lingui/react@workspace:*, @lingui/react@workspace:packages/react": version: 0.0.0-use.local resolution: "@lingui/react@workspace:packages/react" From 30c0a04d16c29d4771605ccd8abacb69001bcbde Mon Sep 17 00:00:00 2001 From: Timofei Iatsenko Date: Tue, 5 Mar 2024 17:22:07 +0100 Subject: [PATCH 15/15] Revert "refactor(macro): add tests for #1797 issue" This reverts commit 797fe4eea007454adeda86dfb162205d8e373e76. --- .../expected/locales/_app.page/en.po | 8 -- .../expected/locales/_app.page/pl.po | 8 -- .../expected/locales/index.page/en.po | 67 -------------- .../expected/locales/index.page/pl.po | 67 -------------- .../fixtures/components/AboutText.tsx | 18 ---- .../fixtures/components/Developers.tsx | 25 ----- .../fixtures/components/Switcher.tsx | 46 ---------- .../fixtures/pages/_app.page.tsx | 19 ---- .../fixtures/pages/index.page.tsx | 92 ------------------- .../fixtures/styles/Index.module.css | 47 ---------- .../fixtures/styles/globals.css | 16 ---- .../fixtures/utils.ts | 30 ------ .../extractor-experimental-1797/package.json | 30 ------ .../extractor-experimental-1797/tsconfig.json | 31 ------- packages/cli/test/index.test.ts | 53 ----------- packages/macro/package.json | 1 + 16 files changed, 1 insertion(+), 557 deletions(-) delete mode 100644 packages/cli/test/extractor-experimental-1797/expected/locales/_app.page/en.po delete mode 100644 packages/cli/test/extractor-experimental-1797/expected/locales/_app.page/pl.po delete mode 100644 packages/cli/test/extractor-experimental-1797/expected/locales/index.page/en.po delete mode 100644 packages/cli/test/extractor-experimental-1797/expected/locales/index.page/pl.po delete mode 100644 packages/cli/test/extractor-experimental-1797/fixtures/components/AboutText.tsx delete mode 100644 packages/cli/test/extractor-experimental-1797/fixtures/components/Developers.tsx delete mode 100644 packages/cli/test/extractor-experimental-1797/fixtures/components/Switcher.tsx delete mode 100644 packages/cli/test/extractor-experimental-1797/fixtures/pages/_app.page.tsx delete mode 100644 packages/cli/test/extractor-experimental-1797/fixtures/pages/index.page.tsx delete mode 100644 packages/cli/test/extractor-experimental-1797/fixtures/styles/Index.module.css delete mode 100644 packages/cli/test/extractor-experimental-1797/fixtures/styles/globals.css delete mode 100644 packages/cli/test/extractor-experimental-1797/fixtures/utils.ts delete mode 100644 packages/cli/test/extractor-experimental-1797/package.json delete mode 100644 packages/cli/test/extractor-experimental-1797/tsconfig.json diff --git a/packages/cli/test/extractor-experimental-1797/expected/locales/_app.page/en.po b/packages/cli/test/extractor-experimental-1797/expected/locales/_app.page/en.po deleted file mode 100644 index e69a4d65f..000000000 --- a/packages/cli/test/extractor-experimental-1797/expected/locales/_app.page/en.po +++ /dev/null @@ -1,8 +0,0 @@ -msgid "" -msgstr "" -"POT-Creation-Date: 2023-03-15 10:00+0000\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"X-Generator: @lingui/cli\n" -"Language: en\n" diff --git a/packages/cli/test/extractor-experimental-1797/expected/locales/_app.page/pl.po b/packages/cli/test/extractor-experimental-1797/expected/locales/_app.page/pl.po deleted file mode 100644 index 4062525fc..000000000 --- a/packages/cli/test/extractor-experimental-1797/expected/locales/_app.page/pl.po +++ /dev/null @@ -1,8 +0,0 @@ -msgid "" -msgstr "" -"POT-Creation-Date: 2023-03-15 10:00+0000\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"X-Generator: @lingui/cli\n" -"Language: pl\n" diff --git a/packages/cli/test/extractor-experimental-1797/expected/locales/index.page/en.po b/packages/cli/test/extractor-experimental-1797/expected/locales/index.page/en.po deleted file mode 100644 index cd75d8dce..000000000 --- a/packages/cli/test/extractor-experimental-1797/expected/locales/index.page/en.po +++ /dev/null @@ -1,67 +0,0 @@ -msgid "" -msgstr "" -"POT-Creation-Date: 2023-03-15 10:00+0000\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"X-Generator: @lingui/cli\n" -"Language: en\n" - -#: fixtures/pages/index.page.tsx:83 -#: fixtures/pages/index.page.tsx:85 -msgid "{0, plural, one {# Person} other {# Persons}}" -msgstr "{0, plural, one {# Person} other {# Persons}}" - -#: fixtures/components/Developers.tsx:20 -msgid "{selected, plural, one {Developer} other {Developers}}" -msgstr "{selected, plural, one {Developer} other {Developers}}" - -#: fixtures/pages/index.page.tsx:62 -msgid "<0>Next.jssay hi." -msgstr "<0>Next.jssay hi." - -#: fixtures/components/Switcher.tsx:10 -msgid "English" -msgstr "English" - -#: fixtures/components/AboutText.tsx:6 -msgid "Hello, world" -msgstr "Hello, world" - -#. js-lingui-explicit-id -#: fixtures/components/AboutText.tsx:8 -msgid "message.next-explanation" -msgstr "Next.js is an open-source React front-end development web framework that enables functionality such as server-side rendering and generating static websites for React based web applications. It is a production-ready framework that allows developers to quickly create static and dynamic JAMstack websites and is used widely by many large companies." - -#: fixtures/pages/index.page.tsx:58 -#: fixtures/pages/index.page.tsx:60 -msgid "Plain text" -msgstr "Plain text" - -#: fixtures/components/Developers.tsx:9 -msgid "Plural Test: How many developers?" -msgstr "Plural Test: How many developers?" - -#: fixtures/components/Switcher.tsx:11 -msgid "Serbian" -msgstr "Serbian" - -#: fixtures/components/Switcher.tsx:12 -msgid "Spanish" -msgstr "Spanish" - -#: fixtures/pages/index.page.tsx:46 -msgid "Translation Demo" -msgstr "Translation Demo" - -#: fixtures/pages/index.page.tsx:53 -msgid "Welcome to <0>Next.js!" -msgstr "Welcome to <0>Next.js!" - -#: fixtures/pages/index.page.tsx:67 -msgid "Wonderful framework <0>Next.jssay hi." -msgstr "Wonderful framework <0>Next.jssay hi." - -#: fixtures/pages/index.page.tsx:72 -msgid "Wonderful framework <0>Next.jssay hi. And <1>Next.jssay hi." -msgstr "Wonderful framework <0>Next.jssay hi. And <1>Next.jssay hi." diff --git a/packages/cli/test/extractor-experimental-1797/expected/locales/index.page/pl.po b/packages/cli/test/extractor-experimental-1797/expected/locales/index.page/pl.po deleted file mode 100644 index a64a5158e..000000000 --- a/packages/cli/test/extractor-experimental-1797/expected/locales/index.page/pl.po +++ /dev/null @@ -1,67 +0,0 @@ -msgid "" -msgstr "" -"POT-Creation-Date: 2023-03-15 10:00+0000\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"X-Generator: @lingui/cli\n" -"Language: pl\n" - -#: fixtures/pages/index.page.tsx:83 -#: fixtures/pages/index.page.tsx:85 -msgid "{0, plural, one {# Person} other {# Persons}}" -msgstr "" - -#: fixtures/components/Developers.tsx:20 -msgid "{selected, plural, one {Developer} other {Developers}}" -msgstr "" - -#: fixtures/pages/index.page.tsx:62 -msgid "<0>Next.jssay hi." -msgstr "" - -#: fixtures/components/Switcher.tsx:10 -msgid "English" -msgstr "" - -#: fixtures/components/AboutText.tsx:6 -msgid "Hello, world" -msgstr "" - -#. js-lingui-explicit-id -#: fixtures/components/AboutText.tsx:8 -msgid "message.next-explanation" -msgstr "" - -#: fixtures/pages/index.page.tsx:58 -#: fixtures/pages/index.page.tsx:60 -msgid "Plain text" -msgstr "" - -#: fixtures/components/Developers.tsx:9 -msgid "Plural Test: How many developers?" -msgstr "" - -#: fixtures/components/Switcher.tsx:11 -msgid "Serbian" -msgstr "" - -#: fixtures/components/Switcher.tsx:12 -msgid "Spanish" -msgstr "" - -#: fixtures/pages/index.page.tsx:46 -msgid "Translation Demo" -msgstr "" - -#: fixtures/pages/index.page.tsx:53 -msgid "Welcome to <0>Next.js!" -msgstr "" - -#: fixtures/pages/index.page.tsx:67 -msgid "Wonderful framework <0>Next.jssay hi." -msgstr "" - -#: fixtures/pages/index.page.tsx:72 -msgid "Wonderful framework <0>Next.jssay hi. And <1>Next.jssay hi." -msgstr "" diff --git a/packages/cli/test/extractor-experimental-1797/fixtures/components/AboutText.tsx b/packages/cli/test/extractor-experimental-1797/fixtures/components/AboutText.tsx deleted file mode 100644 index 5cf066a99..000000000 --- a/packages/cli/test/extractor-experimental-1797/fixtures/components/AboutText.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Trans } from '@lingui/macro' - -export function AboutText() { - return ( -

- Hello, world -
- - Next.js is an open-source React front-end development web framework that - enables functionality such as server-side rendering and generating - static websites for React based web applications. It is a - production-ready framework that allows developers to quickly create - static and dynamic JAMstack websites and is used widely by many large - companies. - -

- ) -} diff --git a/packages/cli/test/extractor-experimental-1797/fixtures/components/Developers.tsx b/packages/cli/test/extractor-experimental-1797/fixtures/components/Developers.tsx deleted file mode 100644 index 027f5bf4d..000000000 --- a/packages/cli/test/extractor-experimental-1797/fixtures/components/Developers.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useState } from 'react' -import { Trans, Plural } from '@lingui/macro' - -export default function Developers() { - const [selected, setSelected] = useState('1') - return ( -
-

- Plural Test: How many developers? -

-
- -

- -

-
-
- ) -} diff --git a/packages/cli/test/extractor-experimental-1797/fixtures/components/Switcher.tsx b/packages/cli/test/extractor-experimental-1797/fixtures/components/Switcher.tsx deleted file mode 100644 index b6813d068..000000000 --- a/packages/cli/test/extractor-experimental-1797/fixtures/components/Switcher.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useRouter } from 'next/router' -import { useState } from 'react' -import { t, msg } from '@lingui/macro' -import { useLingui } from '@lingui/react' -import { MessageDescriptor } from '@lingui/core' - -type LOCALES = 'en' | 'sr' | 'es' | 'pseudo' - -const languages: { [key: string]: MessageDescriptor } = { - en: msg`English`, - sr: msg`Serbian`, - es: msg`Spanish` -} - -export function Switcher() { - const router = useRouter() - const { i18n } = useLingui() - - const [locale, setLocale] = useState( - router.locale!.split('-')[0] as LOCALES - ) - - // disabled for DEMO - so we can demonstrate the 'pseudo' locale functionality - // if (process.env.NEXT_PUBLIC_NODE_ENV !== 'production') { - // languages['pseudo'] = t`Pseudo` - // } - - function handleChange(event: React.ChangeEvent) { - const locale = event.target.value as LOCALES - - setLocale(locale) - router.push(router.pathname, router.pathname, { locale }) - } - - return ( - - ) -} diff --git a/packages/cli/test/extractor-experimental-1797/fixtures/pages/_app.page.tsx b/packages/cli/test/extractor-experimental-1797/fixtures/pages/_app.page.tsx deleted file mode 100644 index 0883e2eb7..000000000 --- a/packages/cli/test/extractor-experimental-1797/fixtures/pages/_app.page.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { i18n } from '@lingui/core' -import { I18nProvider } from '@lingui/react' -import '../styles/globals.css' -import type { AppProps } from 'next/app' -import { useLinguiInit } from '../utils' - -function MyApp({ Component, pageProps }: AppProps) { - useLinguiInit(pageProps.translation) - - return ( - <> - - - - - ) -} - -export default MyApp diff --git a/packages/cli/test/extractor-experimental-1797/fixtures/pages/index.page.tsx b/packages/cli/test/extractor-experimental-1797/fixtures/pages/index.page.tsx deleted file mode 100644 index a49cf25d2..000000000 --- a/packages/cli/test/extractor-experimental-1797/fixtures/pages/index.page.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { Plural, t, Trans } from '@lingui/macro' - -import path from 'path' -import { GetStaticProps, NextPage } from 'next' -import Head from 'next/head' -import { AboutText } from '../components/AboutText' -import Developers from '../components/Developers' -import { Switcher } from '../components/Switcher' -import styles from '../styles/Index.module.css' -import { loadCatalog } from '../utils' -import { useLingui } from '@lingui/react' - -export const getStaticProps: GetStaticProps = async (ctx) => { - const fileName = __filename - const cwd = process.cwd() - const { locale } = ctx - - const pathname = path - .relative(cwd, fileName) - .replace('.next/server/pages/', '') - .replace('.js', '') - - const translation = await loadCatalog(locale || 'en', pathname) - return { - props: { - translation - } - } -} - -const Index: NextPage = () => { - /** - * This hook is needed to subscribe your - * component for changes if you use t`` macro - */ - useLingui() - - return ( -
- - {/* - The Next Head component is not being rendered in the React - component tree and React Context is not being passed down to the components placed in the . - That means we cannot use the component here and instead have to use `t` macro. - */} - {t`Translation Demo`} - - - -
- -

- - Welcome to Next.js! - -

-

- Plain text -

-

{t`Plain text`}

-

- - Next.js say hi. - -

-

- - Wonderful framework Next.js say hi. - -

-

- - Wonderful framework Next.js say hi. - And Next.js say hi. - -

-
- -
- - -
- -
- -
-
-
- ) -} - -export default Index diff --git a/packages/cli/test/extractor-experimental-1797/fixtures/styles/Index.module.css b/packages/cli/test/extractor-experimental-1797/fixtures/styles/Index.module.css deleted file mode 100644 index 4abdd3311..000000000 --- a/packages/cli/test/extractor-experimental-1797/fixtures/styles/Index.module.css +++ /dev/null @@ -1,47 +0,0 @@ -.container { - min-height: 100vh; - padding: 0 0.5rem; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; -} - -.main { - padding: 5rem 0; - flex: 1; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; -} - -.title a { - color: #0070f3; - text-decoration: none; -} - -.title a:hover, -.title a:focus, -.title a:active { - text-decoration: underline; -} - -.title { - margin-bottom: 0; - margin-top: 2rem; - line-height: 1.15; - font-size: 4rem; -} - -.title, -.description { - text-align: center; -} - -.description { - /* line-height: 1.5; */ - /* font-size: 1.5rem; */ - max-width: 600px; - text-align: left; -} diff --git a/packages/cli/test/extractor-experimental-1797/fixtures/styles/globals.css b/packages/cli/test/extractor-experimental-1797/fixtures/styles/globals.css deleted file mode 100644 index e5e2dcc23..000000000 --- a/packages/cli/test/extractor-experimental-1797/fixtures/styles/globals.css +++ /dev/null @@ -1,16 +0,0 @@ -html, -body { - padding: 0; - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, - Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; -} - -a { - color: inherit; - text-decoration: none; -} - -* { - box-sizing: border-box; -} diff --git a/packages/cli/test/extractor-experimental-1797/fixtures/utils.ts b/packages/cli/test/extractor-experimental-1797/fixtures/utils.ts deleted file mode 100644 index d70a97b17..000000000 --- a/packages/cli/test/extractor-experimental-1797/fixtures/utils.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { i18n, Messages } from '@lingui/core' -import { useRouter } from 'next/router' -import { useEffect, useState } from 'react' - -export async function loadCatalog(locale: string, pathname: string) { - if (pathname === '_error') { - return {} - } - const catalog = await import( - `@lingui/loader!./locales/src/pages/${pathname}.page/${locale}.po` - ) - return catalog.messages -} - -export function useLinguiInit(messages: Messages) { - const router = useRouter() - const locale = router.locale || router.defaultLocale! - useState(() => { - i18n.loadAndActivate({ locale, messages }) - }) - - useEffect(() => { - const localeDidChange = locale !== i18n.locale - if (localeDidChange) { - i18n.loadAndActivate({ locale, messages }) - } - }, [locale, messages]) - - return i18n -} diff --git a/packages/cli/test/extractor-experimental-1797/package.json b/packages/cli/test/extractor-experimental-1797/package.json deleted file mode 100644 index 985e126a1..000000000 --- a/packages/cli/test/extractor-experimental-1797/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "nextjs-swc-example", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "next dev", - "debug": "NODE_OPTIONS='--inspect' next dev", - "build": "npm run lingui:extract && next build", - "start": "next start", - "lingui:extract": "lingui extract-experimental", - "test": "npm run build" - }, - "dependencies": { - "@lingui/core": "^4.5.0", - "@lingui/react": "^4.5.0", - "next": "13.4.12", - "react": "18.2.0", - "react-dom": "18.2.0" - }, - "devDependencies": { - "@lingui/cli": "^4.5.0", - "@lingui/loader": "^4.5.0", - "@lingui/macro": "^4.5.0", - "@lingui/swc-plugin": "^4.0.4", - "@types/react": "^18.0.14", - "eslint": "8.35.0", - "eslint-config-next": "12.3.4", - "typescript": "^4.7.4" - } -} diff --git a/packages/cli/test/extractor-experimental-1797/tsconfig.json b/packages/cli/test/extractor-experimental-1797/tsconfig.json deleted file mode 100644 index 005baed64..000000000 --- a/packages/cli/test/extractor-experimental-1797/tsconfig.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "compilerOptions": { - "target": "es6", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "node", - "resolveJsonModule": true, - "isolatedModules": true, - // "downlevelIteration": true, - "jsx": "preserve", - "incremental": true - }, - "include": [ - "next-env.d.ts", - "src/**/*.ts", - "src/**/*.tsx" - ], - "exclude": [ - "node_modules" - ] -} diff --git a/packages/cli/test/index.test.ts b/packages/cli/test/index.test.ts index 40c62e507..1bac33ec5 100644 --- a/packages/cli/test/index.test.ts +++ b/packages/cli/test/index.test.ts @@ -287,58 +287,5 @@ describe("E2E Extractor Test", () => { compareFolders(actualPath, expectedPath) }) - - it("Should extract correctly all messages github-issue: 1797", async () => { - const { rootDir, actualPath, expectedPath } = await prepare( - "extractor-experimental-1797" - ) - - await mockConsole(async (console) => { - const result = await extractExperimentalCommand( - makeConfig({ - rootDir: rootDir, - locales: ["en", "pl"], - sourceLocale: "en", - format: "po", - catalogs: [], - experimental: { - extractor: { - entries: ["/fixtures/pages/**/*.page.tsx"], - output: "/actual/locales/{entryName}/{locale}", - }, - }, - }), - { - clean: true, - } - ) - - expect(getConsoleMockCalls(console.error)).toBeFalsy() - expect(result).toBeTruthy() - expect(getConsoleMockCalls(console.log)).toMatchInlineSnapshot(` - You have using an experimental feature - Experimental features are not covered by semver, and may cause unexpected or broken application behavior. Use at your own risk. - - Catalog statistics for fixtures/pages/_app.page.tsx: - ┌─────────────┬─────────────┬─────────┐ - │ Language │ Total count │ Missing │ - ├─────────────┼─────────────┼─────────┤ - │ en (source) │ 0 │ - │ - │ pl │ 0 │ 0 │ - └─────────────┴─────────────┴─────────┘ - - Catalog statistics for fixtures/pages/index.page.tsx: - ┌─────────────┬─────────────┬─────────┐ - │ Language │ Total count │ Missing │ - ├─────────────┼─────────────┼─────────┤ - │ en (source) │ 14 │ - │ - │ pl │ 14 │ 14 │ - └─────────────┴─────────────┴─────────┘ - - `) - }) - - compareFolders(actualPath, expectedPath) - }) }) }) diff --git a/packages/macro/package.json b/packages/macro/package.json index 3184bc1e0..520f408f5 100644 --- a/packages/macro/package.json +++ b/packages/macro/package.json @@ -63,6 +63,7 @@ "import": { "types": "./dist/plugin.d.ts", "default": "./dist/index.mjs" + } } },