From e383dba9f2f512bbfdf70dca93556b9682f77c72 Mon Sep 17 00:00:00 2001 From: Timofei Iatsenko Date: Wed, 4 Jan 2023 12:16:38 +0100 Subject: [PATCH] refactor(macro): make code strongly typed --- packages/macro/package.json | 5 +- packages/macro/src/icu.test.ts | 17 +- packages/macro/src/icu.ts | 230 +++++++++++++---------- packages/macro/src/index.ts | 26 +-- packages/macro/src/macroJs.test.ts | 13 +- packages/macro/src/macroJs.ts | 148 ++++++--------- packages/macro/src/macroJsx.test.ts | 64 ++++++- packages/macro/src/macroJsx.ts | 118 +++++++----- packages/macro/src/utils.ts | 2 +- packages/macro/test/index.ts | 17 +- packages/macro/test/js-arg.ts | 6 +- packages/macro/test/js-defineMessage.ts | 6 +- packages/macro/test/js-plural.ts | 5 +- packages/macro/test/js-select.ts | 5 +- packages/macro/test/js-selectOrdinal.ts | 5 +- packages/macro/test/js-t.ts | 5 +- packages/macro/test/jsx-plural.ts | 40 +++- packages/macro/test/jsx-select.ts | 5 +- packages/macro/test/jsx-selectOrdinal.ts | 31 ++- packages/macro/test/jsx-trans.ts | 33 +++- packages/macro/tsconfig.json | 7 + yarn.lock | 18 ++ 22 files changed, 528 insertions(+), 278 deletions(-) create mode 100644 packages/macro/tsconfig.json diff --git a/packages/macro/package.json b/packages/macro/package.json index 68a4e5c5f..c332c9f36 100644 --- a/packages/macro/package.json +++ b/packages/macro/package.json @@ -36,6 +36,9 @@ "peerDependencies": { "@lingui/core": "^3.13.0", "@lingui/react": "^3.13.0", - "babel-plugin-macros": "2 || 3" + "babel-plugin-macros": "2 || 3" + }, + "devDependencies": { + "@types/babel-plugin-macros": "^2.8.5" } } diff --git a/packages/macro/src/icu.test.ts b/packages/macro/src/icu.test.ts index 9e38112d1..f526730cb 100644 --- a/packages/macro/src/icu.test.ts +++ b/packages/macro/src/icu.test.ts @@ -1,9 +1,10 @@ -import ICUMessageFormat from "./icu" +import ICUMessageFormat, {Token} from "./icu" +import {Identifier} from "@babel/types" describe("ICU MessageFormat", function () { it("should collect text message", function () { const messageFormat = new ICUMessageFormat() - const tokens = [ + const tokens: Token[] = [ { type: "text", value: "Hello World", @@ -19,7 +20,7 @@ describe("ICU MessageFormat", function () { it("should collect text message with arguments", function () { const messageFormat = new ICUMessageFormat() - const tokens = [ + const tokens: Token[] = [ { type: "text", value: "Hello ", @@ -27,14 +28,20 @@ describe("ICU MessageFormat", function () { { type: "arg", name: "name", - value: "Joe", + value: { + type: "Identifier", + name: "Joe", + } as Identifier, }, ] expect(messageFormat.fromTokens(tokens)).toEqual( expect.objectContaining({ message: "Hello {name}", values: { - name: "Joe", + name: { + type: "Identifier", + name: "Joe", + }, }, }) ) diff --git a/packages/macro/src/icu.ts b/packages/macro/src/icu.ts index 04f478d3b..5ab1e065f 100644 --- a/packages/macro/src/icu.ts +++ b/packages/macro/src/icu.ts @@ -1,112 +1,146 @@ +import {Expression, isJSXEmptyExpression, JSXElement, Node} from "@babel/types" + const metaOptions = ["id", "comment", "props"] const escapedMetaOptionsRe = new RegExp(`^_(${metaOptions.join("|")})$`) -export default function ICUMessageFormat() {} - -ICUMessageFormat.prototype.fromTokens = function (tokens) { - return (Array.isArray(tokens) ? tokens : [tokens]) - .map((token) => this.processToken(token)) - .filter(Boolean) - .reduce( - (props, message) => ({ - ...message, - message: props.message + message.message, - values: { ...props.values, ...message.values }, - jsxElements: { ...props.jsxElements, ...message.jsxElements }, - }), - { - message: "", - values: {}, - jsxElements: {}, - } - ) +export type ParsedResult = { + message: string, + values?: Record, + jsxElements?: Record, } -ICUMessageFormat.prototype.processToken = function (token) { - const jsxElements = {} +export type TextToken = { + type: "text", + value: string; +} +export type ArgToken = { + type: "arg", + value: Expression; + name?: string; - if (token.type === "text") { - return { - message: token.value, - } - } else if (token.type === "arg") { - if (token.value !== undefined && token.value.type === 'JSXEmptyExpression') { - return null; - } - const values = - token.value !== undefined - ? { - [token.name]: token.value, - } - : {} + /** + * plural + * select + * selectordinal + */ + format?: string, + options?: { + offset: string, + [icuChoice: string]: string | Tokens, + }, +} +export type ElementToken = { + type: "element", + value: JSXElement; + name?: string | number; + children?: Token[], +} +export type Tokens = Token | Token[]; +export type Token = TextToken | ArgToken | ElementToken - switch (token.format) { - case "plural": - case "select": - case "selectordinal": - const formatOptions = Object.keys(token.options) - .filter((key) => token.options[key] != null) - .map((key) => { - let value = token.options[key] - key = key.replace(escapedMetaOptionsRe, "$1") - - if (key === "offset") { - // offset has special syntax `offset:number` - return `offset:${value}` +export default class ICUMessageFormat { + public fromTokens(tokens: Tokens): ParsedResult { + return (Array.isArray(tokens) ? tokens : [tokens]) + .map((token) => this.processToken(token)) + .filter(Boolean) + .reduce( + (props, message) => ({ + ...message, + message: props.message + message.message, + values: { ...props.values, ...message.values }, + jsxElements: { ...props.jsxElements, ...message.jsxElements }, + }), + { + message: "", + values: {}, + jsxElements: {}, } + ) + } - if (typeof value !== "string") { - // process tokens from nested formatters - const { - message, - values: childValues, - jsxElements: childJsxElements, - } = this.fromTokens(value) - - Object.assign(values, childValues) - Object.assign(jsxElements, childJsxElements) - value = message - } + public processToken(token: Token): ParsedResult { + const jsxElements: ParsedResult['jsxElements'] = {} - return `${key} {${value}}` - }) - .join(" ") - - return { - message: `{${token.name}, ${token.format}, ${formatOptions}}`, - values, - jsxElements, - } - default: - return { - message: `{${token.name}}`, - values, - } - } - } else if (token.type === "element") { - let message = "" - let elementValues = {} - Object.assign(jsxElements, { [token.name]: token.value }) - token.children.forEach((child) => { - const { - message: childMessage, - values: childValues, - jsxElements: childJsxElements, - } = this.fromTokens(child) - - message += childMessage - Object.assign(elementValues, childValues) - Object.assign(jsxElements, childJsxElements) - }) - return { - message: token.children.length - ? `<${token.name}>${message}` - : `<${token.name}/>`, - values: elementValues, - jsxElements, + if (token.type === "text") { + return { + message: token.value as string, + } + } else if (token.type === "arg") { + if (token.value !== undefined && isJSXEmptyExpression(token.value as Node)) { + return null; + } + const values = token.value !== undefined + ? { [token.name]: token.value } + : {} + + switch (token.format) { + case "plural": + case "select": + case "selectordinal": + const formatOptions = Object.keys(token.options) + .filter((key) => token.options[key] != null) + .map((key) => { + let value = token.options[key] + key = key.replace(escapedMetaOptionsRe, "$1") + + if (key === "offset") { + // offset has special syntax `offset:number` + return `offset:${value}` + } + + if (typeof value !== "string") { + // process tokens from nested formatters + const { + message, + values: childValues, + jsxElements: childJsxElements, + } = this.fromTokens(value) + + Object.assign(values, childValues) + Object.assign(jsxElements, childJsxElements) + value = message + } + + return `${key} {${value}}` + }) + .join(" ") + + return { + message: `{${token.name}, ${token.format}, ${formatOptions}}`, + values, + jsxElements, + } + default: + return { + message: `{${token.name}}`, + values, + } + } + } else if (token.type === "element") { + let message = "" + let elementValues: ParsedResult['values'] = {} + Object.assign(jsxElements, { [token.name]: token.value }) + token.children.forEach((child) => { + const { + message: childMessage, + values: childValues, + jsxElements: childJsxElements, + } = this.fromTokens(child) + + message += childMessage + Object.assign(elementValues, childValues) + Object.assign(jsxElements, childJsxElements) + }) + return { + message: token.children.length + ? `<${token.name}>${message}` + : `<${token.name}/>`, + values: elementValues, + jsxElements, + } } - } - throw new Error(`Unknown token type ${token.type}`) + throw new Error(`Unknown token type ${(token as any).type}`) + } } diff --git a/packages/macro/src/index.ts b/packages/macro/src/index.ts index 55acefbde..e9726fbd5 100644 --- a/packages/macro/src/index.ts +++ b/packages/macro/src/index.ts @@ -1,8 +1,10 @@ -import { createMacro } from "babel-plugin-macros" +import {createMacro, MacroParams} from "babel-plugin-macros" import { getConfig } from "@lingui/conf" import MacroJS from "./macroJs" import MacroJSX from "./macroJsx" +import {NodePath} from "@babel/traverse" +import {ImportDeclaration, isImportSpecifier, isIdentifier} from "@babel/types" const config = getConfig({ configPath: process.env.LINGUI_CONFIG }) @@ -25,13 +27,13 @@ const getSymbolSource = (name: 'i18n' | 'Trans'): [source: string, identifier?: const [i18nImportModule, i18nImportName = "i18n"] = getSymbolSource("i18n") const [TransImportModule, TransImportName = "Trans"] = getSymbolSource("Trans") -function macro({ references, state, babel }) { - const jsxNodes = [] - const jsNodes = [] +function macro({ references, state, babel }: MacroParams) { + const jsxNodes: NodePath[] = [] + const jsNodes: NodePath[] = [] let needsI18nImport = false const alreadyVisitedCache = new WeakSet() - const alreadyVisited = (path) => { + const alreadyVisited = (path: NodePath) => { if (alreadyVisitedCache.has(path)) { return true } else { @@ -86,7 +88,7 @@ function macro({ references, state, babel }) { } } -function addImport(babel, state, module, importName) { +function addImport(babel: MacroParams["babel"], state: MacroParams["state"], module: string, importName: string) { const { types: t } = babel const linguiImport = state.file.path.node.body.find( @@ -95,7 +97,7 @@ function addImport(babel, state, module, importName) { importNode.source.value === module && // https://github.com/lingui/js-lingui/issues/777 importNode.importKind !== "type" - ) + ) as ImportDeclaration const tIdentifier = t.identifier(importName) // Handle adding the import or altering the existing import @@ -103,7 +105,7 @@ function addImport(babel, state, module, importName) { if ( linguiImport.specifiers.findIndex( (specifier) => - specifier.imported && specifier.imported.name === importName + isImportSpecifier(specifier) && isIdentifier(specifier.imported, {name: importName}) ) === -1 ) { linguiImport.specifiers.push(t.importSpecifier(tIdentifier, tIdentifier)) @@ -118,9 +120,9 @@ function addImport(babel, state, module, importName) { } } -function isRootPath(allPath) { - return (node) => - (function traverse(path) { +function isRootPath(allPath: NodePath[]) { + return (node: NodePath) => + (function traverse(path): boolean { if (!path.parentPath) { return true } else { @@ -129,7 +131,7 @@ function isRootPath(allPath) { })(node) } -function getMacroType(tagName) { +function getMacroType(tagName: string): string { switch (tagName) { case "defineMessage": case "arg": diff --git a/packages/macro/src/macroJs.test.ts b/packages/macro/src/macroJs.test.ts index 6284e7db6..a63a35c2c 100644 --- a/packages/macro/src/macroJs.test.ts +++ b/packages/macro/src/macroJs.test.ts @@ -1,6 +1,7 @@ import { parseExpression } from "@babel/parser" import * as types from "@babel/types" import MacroJs from "./macroJs" +import {CallExpression} from "@babel/types" function createMacro() { return new MacroJs({ types }, { i18nImportName: "i18n" }) @@ -63,7 +64,7 @@ describe("js macro", () => { }, { type: "arg", - name: 0, + name: "0", value: expect.objectContaining({ type: "MemberExpression", }), @@ -163,7 +164,7 @@ describe("js macro", () => { const exp = parseExpression( "plural(count, { one: '# book', other: '# books'})" ) - const tokens = macro.tokenizeChoiceComponent(exp) + const tokens = macro.tokenizeChoiceComponent(exp as CallExpression) expect(tokens).toEqual({ type: "arg", name: "count", @@ -189,7 +190,7 @@ describe("js macro", () => { other: '# books' })` ) - const tokens = macro.tokenizeChoiceComponent(exp) + const tokens = macro.tokenizeChoiceComponent(exp as CallExpression) expect(tokens).toEqual({ type: "arg", name: "count", @@ -212,7 +213,7 @@ describe("js macro", () => { const exp = parseExpression( "plural(count, { one: `# glass of ${drink}`, other: `# glasses of ${drink}`})" ) - const tokens = macro.tokenizeChoiceComponent(exp) + const tokens = macro.tokenizeChoiceComponent(exp as CallExpression) expect(tokens).toEqual({ type: "arg", name: "count", @@ -265,7 +266,7 @@ describe("js macro", () => { }) })` ) - const tokens = macro.tokenizeChoiceComponent(exp) + const tokens = macro.tokenizeChoiceComponent(exp as CallExpression) expect(tokens).toEqual({ type: "arg", name: "count", @@ -304,7 +305,7 @@ describe("js macro", () => { other: "they" })` ) - const tokens = macro.tokenizeChoiceComponent(exp) + const tokens = macro.tokenizeChoiceComponent(exp as CallExpression) expect(tokens).toEqual({ format: "select", name: "gender", diff --git a/packages/macro/src/macroJs.ts b/packages/macro/src/macroJs.ts index 189bdbd0a..18cd9b12c 100644 --- a/packages/macro/src/macroJs.ts +++ b/packages/macro/src/macroJs.ts @@ -1,15 +1,16 @@ import * as R from "ramda" import * as babelTypes from "@babel/types" +import {Expression, Node, CallExpression, ObjectExpression, isObjectProperty, ObjectProperty, Identifier, StringLiteral} from "@babel/types" import { NodePath } from "@babel/traverse" -import ICUMessageFormat from "./icu" +import ICUMessageFormat, {ArgToken, ParsedResult, TextToken, Tokens} from "./icu" import { zip, makeCounter } from "./utils" import { COMMENT, ID, MESSAGE, EXTRACT_MARK } from "./constants" const keepSpaceRe = /(?:\\(?:\r\n|\r|\n))+\s+/g const keepNewLineRe = /(?:\r\n|\r|\n)+\s+/g -function normalizeWhitespace(text) { +function normalizeWhitespace(text: string): string { return text.replace(keepSpaceRe, " ").replace(keepNewLineRe, "\n").trim() } @@ -21,48 +22,26 @@ export default class MacroJs { i18nImportName: string // Positional expressions counter (e.g. for placeholders `Hello {0}, today is {1}`) - _expressionIndex: () => Number + _expressionIndex = makeCounter() - constructor({ types }, { i18nImportName }) { + constructor({ types }: {types: typeof babelTypes}, { i18nImportName }: { i18nImportName: string }) { this.types = types this.i18nImportName = i18nImportName - this._expressionIndex = makeCounter() } replacePathWithMessage = ( path: NodePath, - { id, message, values, comment }, + {message, values}: {message: ParsedResult['message'], values: ParsedResult['values']}, linguiInstance?: babelTypes.Identifier ) => { const args = [] - const options = [] - const messageNode = isString(message) + args.push(isString(message) ? this.types.stringLiteral(message) - : message - - if (id) { - args.push(this.types.stringLiteral(id)) - - if (process.env.NODE_ENV !== "production") { - options.push( - this.types.objectProperty(this.types.identifier(MESSAGE), messageNode) - ) - } - } else { - args.push(messageNode) - } + : message) - if (comment) { - options.push( - this.types.objectProperty( - this.types.identifier(COMMENT), - this.types.stringLiteral(comment) - ) - ) - } - if (Object.keys(values).length || options.length) { + if (Object.keys(values).length) { const valuesObject = Object.keys(values).map((key) => this.types.objectProperty(this.types.identifier(key), values[key]) ) @@ -70,10 +49,6 @@ export default class MacroJs { args.push(this.types.objectExpression(valuesObject)) } - if (options.length) { - args.push(this.types.objectExpression(options)) - } - const newNode = this.types.callExpression( this.types.memberExpression( linguiInstance ?? this.types.identifier(this.i18nImportName), @@ -86,7 +61,6 @@ export default class MacroJs { newNode.loc = path.node.loc path.addComment("leading", EXTRACT_MARK) - // @ts-ignore path.replaceWith(newNode) } @@ -96,7 +70,7 @@ export default class MacroJs { this._expressionIndex = makeCounter() if (this.isDefineMessage(path.node)) { - this.replaceDefineMessage(path) + this.replaceDefineMessage(path as NodePath) return true } @@ -115,14 +89,12 @@ export default class MacroJs { const { message: messageRaw, values, - id, - comment, } = messageFormat.fromTokens(tokens) const message = normalizeWhitespace(messageRaw) this.replacePathWithMessage( path.parentPath, - { id, message, values, comment }, + { message, values }, i18nInstance ) return false @@ -136,7 +108,7 @@ export default class MacroJs { this.isIdentifier(path.node.callee, "t") ) { const i18nInstance = path.node.arguments[0] - this.replaceTAsFunction(path.parentPath, i18nInstance) + this.replaceTAsFunction(path.parentPath as NodePath, i18nInstance) return false } @@ -144,7 +116,7 @@ export default class MacroJs { this.types.isCallExpression(path.node) && this.isIdentifier(path.node.callee, "t") ) { - this.replaceTAsFunction(path) + this.replaceTAsFunction(path as NodePath) return true } @@ -154,12 +126,10 @@ export default class MacroJs { const { message: messageRaw, values, - id, - comment, } = messageFormat.fromTokens(tokens) const message = normalizeWhitespace(messageRaw) - this.replacePathWithMessage(path, { id, message, values, comment }) + this.replacePathWithMessage(path, { message, values }) return true } @@ -185,7 +155,7 @@ export default class MacroJs { * } * */ - replaceDefineMessage = (path) => { + replaceDefineMessage = (path: NodePath) => { // reset the expression counter this._expressionIndex = makeCounter() @@ -197,7 +167,7 @@ export default class MacroJs { * macro `t` is called with MessageDescriptor, after that * we create a new node to append it to i18n._ */ - replaceTAsFunction = (path, linguiInstance?: babelTypes.Identifier) => { + replaceTAsFunction = (path: NodePath, linguiInstance?: babelTypes.Identifier) => { const descriptor = this.processDescriptor(path.node.arguments[0]) const newNode = this.types.callExpression( this.types.memberExpression( @@ -210,7 +180,7 @@ export default class MacroJs { } /** - * `processDescriptor` expand macros inside messsage descriptor. + * `processDescriptor` expand macros inside message descriptor. * Message descriptor is used in `defineMessage`. * * { @@ -226,17 +196,19 @@ export default class MacroJs { * } * */ - processDescriptor = (descriptor) => { + processDescriptor = (descriptor_: Node) => { + const descriptor = descriptor_ as ObjectExpression; + this.types.addComment(descriptor, "leading", EXTRACT_MARK) const messageIndex = descriptor.properties.findIndex( - (property) => property.key.name === MESSAGE + (property) => isObjectProperty(property) && this.isIdentifier(property.key, MESSAGE) ) if (messageIndex === -1) { return descriptor } // if there's `message` property, replace macros with formatted message - const node = descriptor.properties[messageIndex] + const node = (descriptor.properties[messageIndex]) as ObjectProperty; // Inside message descriptor the `t` macro in `message` prop is optional. // Template strings are always processed as if they were wrapped by `t`. @@ -257,7 +229,7 @@ export default class MacroJs { // Don't override custom ID const hasId = descriptor.properties.findIndex( - (property) => property.key.name === ID + (property) => isObjectProperty(property) && this.isIdentifier(property.key, ID) ) !== -1 descriptor.properties[messageIndex] = this.types.objectProperty( @@ -268,7 +240,7 @@ export default class MacroJs { return descriptor } - addValues = (obj, values) => { + addValues = (obj: ObjectExpression['properties'], values: ParsedResult["values"]) => { const valuesObject = Object.keys(values).map((key) => this.types.objectProperty(this.types.identifier(key), values[key]) ) @@ -283,13 +255,13 @@ export default class MacroJs { ) } - tokenizeNode = (node, ignoreExpression = false) => { + tokenizeNode = (node: Node, ignoreExpression = false) => { if (this.isI18nMethod(node)) { // t - return this.tokenizeTemplateLiteral(node) + return this.tokenizeTemplateLiteral(node as Expression) } else if (this.isChoiceMethod(node)) { // plural, select and selectOrdinal - return [this.tokenizeChoiceComponent(node)] + return [this.tokenizeChoiceComponent(node as CallExpression)] // } else if (isFormatMethod(node.callee)) { // // date, number // return transformFormatMethod(node, file, props, root) @@ -303,10 +275,10 @@ 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) => { + tokenizeTemplateLiteral = (node: babelTypes.Expression): Tokens => { const tokenize = R.pipe( R.evolve({ - quasis: R.map((text: babelTypes.TemplateElement) => { + quasis: R.map((text: babelTypes.TemplateElement): TextToken => { // Don't output tokens without text. // 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 @@ -339,10 +311,10 @@ export default class MacroJs { ) } - tokenizeChoiceComponent = (node) => { - const format = node.callee.name.toLowerCase() + tokenizeChoiceComponent = (node: CallExpression): ArgToken => { + const format = (node.callee as Identifier).name.toLowerCase() - const token = { + const token: ArgToken = { ...this.tokenizeExpression(node.arguments[0]), format, options: { @@ -350,30 +322,30 @@ export default class MacroJs { }, } - const props = node.arguments[1].properties + const props = (node.arguments[1] as ObjectExpression).properties for (const attr of props) { - const { key } = attr + const { key, value: attrValue } = attr as ObjectProperty // name is either: // NumericLiteral => convert to `={number}` // StringLiteral => key.value - // Literal => key.name + // Identifier => key.name const name = this.types.isNumericLiteral(key) ? `=${key.value}` - : key.name || key.value + : (key as Identifier).name || (key as StringLiteral).value if (format !== "select" && name === "offset") { - token.options.offset = attr.value.value + token.options.offset = (attrValue as StringLiteral).value } else { - let value + let value: ArgToken['options'][string] - if (this.types.isTemplateLiteral(attr.value)) { - value = this.tokenizeTemplateLiteral(attr.value) - } else if (this.types.isCallExpression(attr.value)) { - value = this.tokenizeNode(attr.value) + if (this.types.isTemplateLiteral(attrValue)) { + value = this.tokenizeTemplateLiteral(attrValue) + } else if (this.types.isCallExpression(attrValue)) { + value = this.tokenizeNode(attrValue) } else { - value = attr.value.value + value = (attrValue as StringLiteral).value } token.options[name] = value } @@ -382,27 +354,28 @@ export default class MacroJs { return token } - tokenizeExpression = (node) => { - if (this.isArg(node)) { + tokenizeExpression = (node: Node | Expression): ArgToken => { + if (this.isArg(node) && this.types.isCallExpression(node)) { return { type: "arg", - name: node.arguments[0].value, + name: (node.arguments[0] as StringLiteral).value, + value: undefined } } return { type: "arg", - name: this.expressionToArgument(node), - value: node, + name: this.expressionToArgument(node as Expression), + value: node as Expression, } } - expressionToArgument = (exp) => { + expressionToArgument = (exp: Expression): string => { if (this.types.isIdentifier(exp)) { return exp.name } else if (this.types.isStringLiteral(exp)) { return exp.value } else { - return this._expressionIndex() + return String(this._expressionIndex()) } } @@ -417,33 +390,32 @@ export default class MacroJs { /** * Custom matchers */ - - isIdentifier = (node, name) => { + isIdentifier = (node: Node | Expression, name: string) => { return this.types.isIdentifier(node, { name }) } - isDefineMessage = (node) => { + isDefineMessage = (node: Node): boolean => { return ( this.types.isCallExpression(node) && this.isIdentifier(node.callee, "defineMessage") ) } - isArg = (node) => { + isArg = (node: Node) => { return ( this.types.isCallExpression(node) && this.isIdentifier(node.callee, "arg") ) } - isI18nMethod = (node) => { + isI18nMethod = (node: Node) => { return ( - this.isIdentifier(node.tag, "t") || - (this.types.isCallExpression(node.tag) && - this.isIdentifier(node.tag.callee, "t")) + this.types.isTaggedTemplateExpression(node) && + (this.isIdentifier(node.tag, "t") + || (this.types.isCallExpression(node.tag) && this.isIdentifier(node.tag.callee, "t"))) ) } - isChoiceMethod = (node) => { + isChoiceMethod = (node: Node) => { return ( this.types.isCallExpression(node) && (this.isIdentifier(node.callee, "plural") || @@ -453,4 +425,4 @@ export default class MacroJs { } } -const isString = (s): s is string => typeof s === "string" +const isString = (s: unknown): s is string => typeof s === "string" diff --git a/packages/macro/src/macroJsx.test.ts b/packages/macro/src/macroJsx.test.ts index 8c6e4bf16..7a3782beb 100644 --- a/packages/macro/src/macroJsx.test.ts +++ b/packages/macro/src/macroJsx.test.ts @@ -1,17 +1,69 @@ import { parseExpression as _parseExpression } from "@babel/parser" import * as types from "@babel/types" -import MacroJSX from "./macroJsx" +import MacroJSX, {normalizeWhitespace} from "./macroJsx" +import {JSXElement} from "@babel/types" -const parseExpression = (expression) => +const parseExpression = (expression: string) => _parseExpression(expression, { plugins: ["jsx"], - }) + }) as JSXElement function createMacro() { return new MacroJSX({ types }) } describe("jsx macro", () => { + describe('normalizeWhitespace', () => { + it('should remove whitespace before/after expression', () => { + const actual = normalizeWhitespace( + `You have + + {count, plural, one {Message} other {Messages}}`) + + expect(actual).toBe(`You have{count, plural, one {Message} other {Messages}}`) + }) + + it('should remove whitespace before/after tag', () => { + const actual = normalizeWhitespace( + ` Hello World!
+

+ My name is {{" "}} + {{name}} +

`) + + expect(actual).toBe(`Hello World!

My name is {{" "}}{{name}}

`) + }) + + it('should remove whitespace before/after tag', () => { + const actual = normalizeWhitespace( + `Property {0}, + function {1}, + array {2}, + constant {3}, + object {4}, + everything {5}`) + + expect(actual).toBe(`Property {0}, function {1}, array {2}, constant {3}, object {4}, everything {5}`) + }) + + it('should remove trailing whitespaces in icu expressions', () => { + const actual = normalizeWhitespace( + `{count, plural, one { + + <0># slot added + + } other { + + <1># slots added + + }} +`) + + expect(actual).toBe(`{count, plural, one {<0># slot added} other {<1># slots added}}`) + }) + + }) + describe("tokenizeTrans", () => { it("simple message without arguments", () => { const macro = createMacro() @@ -20,8 +72,8 @@ describe("jsx macro", () => { expect(tokens).toEqual([ { type: "text", - value: "Message", - }, + value: "Message" + } ]) }) @@ -56,7 +108,7 @@ describe("jsx macro", () => { }, { type: "arg", - name: 0, + name: "0", value: expect.objectContaining({ type: "MemberExpression", }), diff --git a/packages/macro/src/macroJsx.ts b/packages/macro/src/macroJsx.ts index 4d60d95af..2c87c0b74 100644 --- a/packages/macro/src/macroJsx.ts +++ b/packages/macro/src/macroJsx.ts @@ -1,13 +1,24 @@ import * as R from "ramda" import * as babelTypes from "@babel/types" -import { NodePath } from "@babel/traverse" - -import ICUMessageFormat from "./icu" -import { zip, makeCounter } from "./utils" -import { ID, COMMENT, MESSAGE, CONTEXT } from "./constants" +import { + Expression, + Identifier, + JSXAttribute, + JSXElement, + JSXExpressionContainer, + JSXIdentifier, + JSXSpreadAttribute, + Node, + StringLiteral +} from "@babel/types" +import {NodePath} from "@babel/traverse" + +import ICUMessageFormat, {ArgToken, ElementToken, TextToken, Token, Tokens} from "./icu" +import {makeCounter, zip} from "./utils" +import {COMMENT, CONTEXT, ID, MESSAGE} from "./constants" const pluralRuleRe = /(_[\d\w]+|zero|one|two|few|many|other)/ -const jsx2icuExactChoice = (value) => +const jsx2icuExactChoice = (value: string) => value.replace(/_(\d+)/, "=$1").replace(/_(\w+)/, "$1") // replace whitespace before/after newline with single space @@ -15,7 +26,7 @@ const keepSpaceRe = /\s*(?:\r\n|\r|\n)+\s*/g // remove whitespace before/after tag or expression const stripAroundTagsRe = /(?:([>}])(?:\r\n|\r|\n)+\s*|(?:\r\n|\r|\n)+\s*(?=[<{]))/g -function maybeNodeValue(node) { +function maybeNodeValue(node: Node): string { if (!node) return null if (node.type === "StringLiteral") return node.value if (node.type === "JSXAttribute") return maybeNodeValue(node.value) @@ -26,7 +37,7 @@ function maybeNodeValue(node) { return null } -function normalizeWhitespace(text) { +export function normalizeWhitespace(text: string): string { return ( text .replace(stripAroundTagsRe, "$1") @@ -42,13 +53,11 @@ function normalizeWhitespace(text) { export default class MacroJSX { types: typeof babelTypes - expressionIndex: () => Number - elementIndex: () => Number + expressionIndex = makeCounter() + elementIndex = makeCounter() - constructor({ types }) { + constructor({ types }: {types: typeof babelTypes}) { this.types = types - this.expressionIndex = makeCounter() - this.elementIndex = makeCounter() } safeJsxAttribute = (name: string, value: string) => { @@ -70,7 +79,7 @@ export default class MacroJSX { } = messageFormat.fromTokens(tokens) const message = normalizeWhitespace(messageRaw) - const { attributes, id, comment, context } = this.stripMacroAttributes(path.node) + const { attributes, id, comment, context } = this.stripMacroAttributes(path.node as JSXElement) if (!id && !message) { return @@ -159,17 +168,20 @@ export default class MacroJSX { /*selfClosing*/ true ) newNode.loc = path.node.loc - // @ts-ignore + path.replaceWith(newNode) } - attrName = (names, exclude = false) => { + attrName = (names: string[], exclude = false) => { const namesRe = new RegExp("^(" + names.join("|") + ")$") - return (attr) => - exclude ? !namesRe.test(attr.name.name) : namesRe.test(attr.name.name) + return (attr: JSXAttribute | JSXSpreadAttribute) => { + const name = (((attr as JSXAttribute).name) as JSXIdentifier).name + return exclude ? !namesRe.test(name) : namesRe.test(name) + } + } - stripMacroAttributes = (node) => { + stripMacroAttributes = (node: JSXElement) => { const { attributes } = node.openingElement const id = attributes.filter(this.attrName([ID]))[0] const message = attributes.filter(this.attrName([MESSAGE]))[0] @@ -204,7 +216,7 @@ export default class MacroJSX { } } - tokenizeNode = (node) => { + tokenizeNode = (node: Node): Tokens => { if (this.isI18nComponent(node)) { // t return this.tokenizeTrans(node) @@ -218,13 +230,13 @@ export default class MacroJSX { } } - tokenizeTrans = (node) => { + tokenizeTrans = (node: JSXElement): Token[] => { return R.flatten( node.children.map((child) => this.tokenizeChildren(child)).filter(Boolean) ) } - tokenizeChildren = (node) => { + tokenizeChildren = (node: JSXElement['children'][number]): Tokens => { if (this.types.isJSXExpressionContainer(node)) { const exp = node.expression @@ -254,7 +266,6 @@ export default class MacroJSX { ), }), (exp) => zip(exp.quasis, exp.expressions), - // @ts-ignore R.flatten, R.filter(Boolean) ) @@ -272,13 +283,14 @@ export default class MacroJSX { } else if (this.types.isJSXText(node)) { return this.tokenizeText(node.value) } else { - return this.tokenizeText(node.value) + // impossible path + // return this.tokenizeText(node.value) } } - tokenizeChoiceComponent = (node) => { + tokenizeChoiceComponent = (node: JSXElement): Token => { const element = node.openingElement - const format = element.name.name.toLowerCase() + const format = this.getJsxTagName(node).toLowerCase() const props = element.attributes.filter(this.attrName([ ID, COMMENT, @@ -291,7 +303,7 @@ export default class MacroJSX { "components" ], true)) - const token = { + const token: Token = { type: "arg", format, name: null, @@ -302,23 +314,32 @@ export default class MacroJSX { } for (const attr of props) { + if (this.types.isJSXSpreadAttribute(attr)) { + continue; + } + + if (this.types.isJSXNamespacedName(attr.name)) { + continue; + } + const name = attr.name.name if (name === "value") { const exp = this.types.isLiteral(attr.value) ? attr.value - : attr.value.expression + : (attr.value as JSXExpressionContainer).expression token.name = this.expressionToArgument(exp) - token.value = exp + token.value = exp as Expression } else if (format !== "select" && name === "offset") { // offset is static parameter, so it must be either string or number token.options.offset = this.types.isStringLiteral(attr.value) ? attr.value.value - : attr.value.expression.value + : ((attr.value as JSXExpressionContainer).expression as StringLiteral).value } else { - let value + let value: ArgToken['options'][number] + if (this.types.isStringLiteral(attr.value)) { - value = attr.value.extra.raw.replace(/(["'])(.*)\1/, "$2") + value = (attr.value.extra.raw as string).replace(/(["'])(.*)\1/, "$2") } else { value = this.tokenizeChildren(attr.value) } @@ -333,13 +354,13 @@ export default class MacroJSX { return token } - tokenizeElement = (node) => { + tokenizeElement = (node: JSXElement): ElementToken => { // !!! Important: Calculate element index before traversing children. // That way outside elements are numbered before inner elements. (...and it looks pretty). const name = this.elementIndex() - const children = node.children + const children = R.flatten(node.children .map((child) => this.tokenizeChildren(child)) - .filter(Boolean) + .filter(Boolean)) node.children = [] node.openingElement.selfClosing = true @@ -352,29 +373,29 @@ export default class MacroJSX { } } - tokenizeExpression = (node) => { + tokenizeExpression = (node: Expression | Node): ArgToken => { return { type: "arg", name: this.expressionToArgument(node), - value: node, + value: node as Expression, } } - tokenizeText = (value) => { + tokenizeText = (value: string): TextToken => { return { type: "text", value, } } - expressionToArgument = (exp) => { - return this.types.isIdentifier(exp) ? exp.name : this.expressionIndex() + expressionToArgument(exp: Expression | Node): string { + return this.types.isIdentifier(exp) ? exp.name : String(this.expressionIndex()) } /** * We clean '//\` ' to just '`' - * */ - clearBackslashes(value: string) { + **/ + clearBackslashes(value: string): string { // if not we replace the extra scaped literals return value.replace(/\\`/g, "`") } @@ -382,12 +403,11 @@ export default class MacroJSX { /** * Custom matchers */ - - isIdentifier = (node, name) => { + isIdentifier = (node: Node, name: string): node is Identifier => { return this.types.isIdentifier(node, { name }) } - isI18nComponent = (node, name = "Trans") => { + isI18nComponent = (node: Node, name = "Trans"): node is JSXElement => { return ( this.types.isJSXElement(node) && this.types.isJSXIdentifier(node.openingElement.name, { @@ -396,11 +416,17 @@ export default class MacroJSX { ) } - isChoiceComponent = (node) => { + isChoiceComponent = (node: Node): node is JSXElement => { return ( this.isI18nComponent(node, "Plural") || this.isI18nComponent(node, "Select") || this.isI18nComponent(node, "SelectOrdinal") ) } + + getJsxTagName = (node: JSXElement): string => { + if ( this.types.isJSXIdentifier(node.openingElement.name)) { + return node.openingElement.name.name; + } + } } diff --git a/packages/macro/src/utils.ts b/packages/macro/src/utils.ts index 276c8a05f..e98738825 100644 --- a/packages/macro/src/utils.ts +++ b/packages/macro/src/utils.ts @@ -4,7 +4,7 @@ import * as R from "ramda" * Custom zip method which takes length of the larger array * (usually zip functions use the `smaller` length, discarding values in larger array) */ -export function zip(a, b) { +export function zip(a: A[], b: B[]): [A, B][] { return R.range(0, Math.max(a.length, b.length)).map((index) => [ a[index], b[index], diff --git a/packages/macro/test/index.ts b/packages/macro/test/index.ts index 266e31258..7698d6bd9 100644 --- a/packages/macro/test/index.ts +++ b/packages/macro/test/index.ts @@ -3,7 +3,18 @@ import path from "path" import { transformFileSync, TransformOptions, transformSync } from "@babel/core" import prettier from "prettier" -const testCases = { +export type TestCase = { + name?: string + input?: string + expected?: string + filename?: string + production?: boolean + useTypescriptPreset?: boolean + only?: boolean + skip?: boolean +} + +const testCases: Record = { "js-arg": require("./js-arg").default, "js-t": require("./js-t").default, "js-plural": require("./js-plural").default, @@ -25,7 +36,7 @@ describe("macro", function () { } // return function, so we can test exceptions - const transformCode = (code) => () => { + const transformCode = (code: string) => () => { try { return transformSync(code, babelOptions).code.trim() } catch (e) { @@ -38,7 +49,7 @@ describe("macro", function () { describe(suiteName, () => { const cases = testCases[suiteName] - const clean = (value) => + const clean = (value: string) => prettier.format(value, { parser: "babel" }).replace(/\n+/, "\n") cases.forEach( diff --git a/packages/macro/test/js-arg.ts b/packages/macro/test/js-arg.ts index 2d5be6411..6936d0aaa 100644 --- a/packages/macro/test/js-arg.ts +++ b/packages/macro/test/js-arg.ts @@ -1,4 +1,6 @@ -export default [ +import { TestCase } from "./index" + +const cases: TestCase[] = [ { name: "Arg macro should be exluded from values", input: ` @@ -13,3 +15,5 @@ export default [ `, }, ] + +export default cases; diff --git a/packages/macro/test/js-defineMessage.ts b/packages/macro/test/js-defineMessage.ts index bf4efa47c..ecbf19108 100644 --- a/packages/macro/test/js-defineMessage.ts +++ b/packages/macro/test/js-defineMessage.ts @@ -1,4 +1,6 @@ -export default [ +import {TestCase} from "./index" + +const cases: TestCase[] = [ { name: "should expand macros in message property", input: ` @@ -95,3 +97,5 @@ export default [ `, }, ] + +export default cases; diff --git a/packages/macro/test/js-plural.ts b/packages/macro/test/js-plural.ts index 90dfeccc7..03a4b509f 100644 --- a/packages/macro/test/js-plural.ts +++ b/packages/macro/test/js-plural.ts @@ -1,4 +1,6 @@ -export default [ +import {TestCase} from "./index" + +const cases: TestCase[] = [ { name: "Macro is used in expression assignment", input: ` @@ -37,3 +39,4 @@ export default [ `, }, ] +export default cases; diff --git a/packages/macro/test/js-select.ts b/packages/macro/test/js-select.ts index d9b61a292..ce4a908c0 100644 --- a/packages/macro/test/js-select.ts +++ b/packages/macro/test/js-select.ts @@ -1,4 +1,6 @@ -export default [ +import {TestCase} from "./index" + +const cases: TestCase[] = [ { name: "Nested macros", input: ` @@ -39,3 +41,4 @@ export default [ `, }, ] +export default cases; diff --git a/packages/macro/test/js-selectOrdinal.ts b/packages/macro/test/js-selectOrdinal.ts index 0257c573e..fd69a6f45 100644 --- a/packages/macro/test/js-selectOrdinal.ts +++ b/packages/macro/test/js-selectOrdinal.ts @@ -1,4 +1,6 @@ -export default [ +import {TestCase} from "./index" + +const cases: TestCase[] = [ { input: ` import { t, selectOrdinal } from '@lingui/macro' @@ -17,3 +19,4 @@ export default [ `, }, ] +export default cases; diff --git a/packages/macro/test/js-t.ts b/packages/macro/test/js-t.ts index 60884191e..9e0745af5 100644 --- a/packages/macro/test/js-t.ts +++ b/packages/macro/test/js-t.ts @@ -1,4 +1,6 @@ -export default [ +import {TestCase} from "./index" + +const cases: TestCase[] = [ { name: "Macro is used in expression assignment", input: ` @@ -217,3 +219,4 @@ export default [ filename: "js-t-var/js-t-var.js", }, ] +export default cases; diff --git a/packages/macro/test/jsx-plural.ts b/packages/macro/test/jsx-plural.ts index 714a0dbf9..d2ef8140d 100644 --- a/packages/macro/test/jsx-plural.ts +++ b/packages/macro/test/jsx-plural.ts @@ -1,4 +1,6 @@ -export default [ +import {TestCase} from "./index" + +const cases: TestCase[] = [ { input: ` import { Plural } from '@lingui/macro'; @@ -22,6 +24,41 @@ export default [ }} />; `, }, + { + name: 'Should preserve reserved props: `comment`, `context`, `render`, `id`', + input: ` + import { Plural } from '@lingui/macro'; + {}} + value={count} + offset="1" + _0="Zero items" + few={\`\${count} items\`} + other={A lot of them} + />; + `, + expected: ` + import { Trans } from "@lingui/react"; + {}} + id="custom.id" + message={ + "{count, plural, offset:1 =0 {Zero items} few {{count} items} other {<0>A lot of them}}" + } + comment="Comment for translator" + context="translation context" + values={{ + count: count + }} + components={{ + 0: + }} + />; + `, + }, { input: ` import { Trans, Plural } from '@lingui/macro'; @@ -106,3 +143,4 @@ export default [ filename: `jsx-plural-select-nested.js`, }, ] +export default cases; diff --git a/packages/macro/test/jsx-select.ts b/packages/macro/test/jsx-select.ts index 07f114c9a..1d1ce8c7b 100644 --- a/packages/macro/test/jsx-select.ts +++ b/packages/macro/test/jsx-select.ts @@ -1,4 +1,6 @@ -export default [ +import {TestCase} from "./index" + +const cases: TestCase[] = [ { input: ` import { Select } from '@lingui/macro'; @@ -40,3 +42,4 @@ export default [ `, }, ] +export default cases; diff --git a/packages/macro/test/jsx-selectOrdinal.ts b/packages/macro/test/jsx-selectOrdinal.ts index e3f846069..6e47cf3e8 100644 --- a/packages/macro/test/jsx-selectOrdinal.ts +++ b/packages/macro/test/jsx-selectOrdinal.ts @@ -1,4 +1,6 @@ -export default [ +import {TestCase} from "./index" + +const cases: TestCase[] = [ { input: ` import { Trans, SelectOrdinal } from '@lingui/macro'; @@ -23,6 +25,32 @@ export default [ }} />; `, }, + { + // without trailing whitespace ICU expression on the next line will not have a space + input: ` + import { Trans, SelectOrdinal } from '@lingui/macro'; + + This is my + #rd} + /> cat. + ; + `, + expected: ` + import { Trans } from "@lingui/react"; + #rd}} cat." + } + values={{ + count: count + }} components={{ + 0: + }} />; + `, + }, { input: ` import { Trans, SelectOrdinal } from '@lingui/macro'; @@ -48,3 +76,4 @@ export default [ `, }, ] +export default cases; diff --git a/packages/macro/test/jsx-trans.ts b/packages/macro/test/jsx-trans.ts index 97e70493a..d5ced3ab4 100644 --- a/packages/macro/test/jsx-trans.ts +++ b/packages/macro/test/jsx-trans.ts @@ -1,4 +1,6 @@ -export default [ +import {TestCase} from "./index" + +const cases: TestCase[] = [ { name: "Generate ID from children", input: ` @@ -54,6 +56,28 @@ export default [ ; `, }, + { + name: 'Should preserve reserved props: `comment`, `context`, `render`, `id`', + input: ` + import { Trans } from '@lingui/macro'; + {}} + >Hello World; + `, + expected: ` + import { Trans } from "@lingui/react"; + {}} + id="custom.id" + message={"Hello World"} + comment="Comment for translator" + context="translation context" + />; + `, + }, { name: "Macro without children is noop", input: ` @@ -324,8 +348,9 @@ export default [ `, }, { + name: 'Use a js macro inside a JSX Attribute of a component handled by JSX macro', input: ` - import { t, plural, Trans } from '@lingui/macro' + import { t, Trans } from '@lingui/macro'; Read more `, expected: ` @@ -342,8 +367,9 @@ export default [ `, }, { + name: 'Use a js macro inside a JSX Attribute of a non macro JSX component', input: ` - import { plural } from '@lingui/macro' + import { plural } from '@lingui/macro';