UNPKG

@formatjs/ts-transformer

Version:

TS Compiler transformer for formatjs

485 lines (484 loc) 18 kB
import { parse } from "@formatjs/icu-messageformat-parser"; import { hoistSelectors } from "@formatjs/icu-messageformat-parser/manipulator.js"; import { printAST } from "@formatjs/icu-messageformat-parser/printer.js"; import * as stringifyNs from "json-stable-stringify"; import * as typescript from "typescript"; import { debug } from "./console_utils.js"; import { interpolateName } from "./interpolate-name.js"; import "./types.js"; const stringify = stringifyNs.default || stringifyNs; const MESSAGE_DESC_KEYS = [ "id", "defaultMessage", "description" ]; function primitiveToTSNode(factory, v) { return typeof v === "string" ? factory.createStringLiteral(v) : typeof v === "number" ? factory.createNumericLiteral(v + "") : typeof v === "boolean" ? v ? factory.createTrue() : factory.createFalse() : undefined; } function isValidIdentifier(k) { try { new Function(`return {${k}:1}`); return true; } catch { return false; } } function objToTSNode(factory, obj) { if (typeof obj === "object" && !obj) { return factory.createNull(); } const props = Object.entries(obj).filter(([_, v]) => typeof v !== "undefined").map(([k, v]) => factory.createPropertyAssignment(isValidIdentifier(k) ? k : factory.createStringLiteral(k), primitiveToTSNode(factory, v) || (Array.isArray(v) ? factory.createArrayLiteralExpression(v.filter((n) => typeof n !== "undefined").map((n) => objToTSNode(factory, n))) : objToTSNode(factory, v)))); return factory.createObjectLiteralExpression(props); } function messageASTToTSNode(factory, ast) { return factory.createArrayLiteralExpression(ast.map((el) => objToTSNode(factory, el))); } function literalToObj(ts, n) { if (ts.isNumericLiteral(n)) { return +n.text; } if (ts.isStringLiteral(n)) { return n.text; } if (n.kind === ts.SyntaxKind.TrueKeyword) { return true; } if (n.kind === ts.SyntaxKind.FalseKeyword) { return false; } } function objectLiteralExpressionToObj(ts, obj) { return obj.properties.reduce((all, prop) => { if (ts.isPropertyAssignment(prop) && prop.name) { if (ts.isIdentifier(prop.name)) { all[prop.name.escapedText.toString()] = literalToObj(ts, prop.initializer); } else if (ts.isStringLiteral(prop.name)) { all[prop.name.text] = literalToObj(ts, prop.initializer); } } return all; }, {}); } const DEFAULT_OPTS = { onMsgExtracted: () => undefined, onMetaExtracted: () => undefined }; function isMultipleMessageDecl(ts, node) { return ts.isIdentifier(node.expression) && node.expression.text === "defineMessages"; } function isSingularMessageDecl(ts, node, additionalComponentNames) { const compNames = new Set([ "FormattedMessage", "defineMessage", "formatMessage", "$formatMessage", "$t", ...additionalComponentNames ]); let fnName = ""; if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { fnName = node.expression.text; } else if (ts.isJsxOpeningElement(node) && ts.isIdentifier(node.tagName)) { fnName = node.tagName.text; } else if (ts.isJsxSelfClosingElement(node) && ts.isIdentifier(node.tagName)) { fnName = node.tagName.text; } return compNames.has(fnName); } function evaluateStringConcat(ts, node) { const { right, left } = node; if (!ts.isStringLiteral(right)) { return ["", false]; } if (ts.isStringLiteral(left)) { return [left.text + right.text, true]; } if (ts.isBinaryExpression(left)) { const [result, isStatic] = evaluateStringConcat(ts, left); return [result + right.text, isStatic]; } return ["", false]; } function extractMessageDescriptor(ts, node, { overrideIdFn, extractSourceLocation, preserveWhitespace, flatten, throws = true, onMsgError }, sf) { let extractionError = null; // Helper to handle errors based on throws option function handleError(errorMsg) { const error = new Error(errorMsg); if (throws) { throw error; } extractionError = error; if (onMsgError) { onMsgError(sf.fileName, error); } } let properties = undefined; if (ts.isObjectLiteralExpression(node)) { properties = node.properties; } else if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { properties = node.attributes.properties; } const msg = { id: "" }; if (!properties) { return; } properties.forEach((prop) => { const { name } = prop; const initializer = ts.isPropertyAssignment(prop) || ts.isJsxAttribute(prop) ? prop.initializer : undefined; if (name && ts.isIdentifier(name) && initializer) { // {id: 'id'} if (ts.isStringLiteral(initializer)) { switch (name.text) { case "id": msg.id = initializer.text; break; case "defaultMessage": msg.defaultMessage = initializer.text; break; case "description": msg.description = initializer.text; break; } } else if (ts.isNoSubstitutionTemplateLiteral(initializer)) { switch (name.text) { case "id": msg.id = initializer.text; break; case "defaultMessage": msg.defaultMessage = initializer.text; break; case "description": msg.description = initializer.text; break; } } else if (ts.isTaggedTemplateExpression(initializer)) { const isMessageProp = name.text === "id" || name.text === "defaultMessage" || name.text === "description"; if (!isMessageProp) { // Skip non-message props (like tagName, values, etc.) return; } const { template } = initializer; if (!ts.isNoSubstitutionTemplateLiteral(template)) { handleError("[FormatJS] Tagged template expression must be no substitution"); return; } switch (name.text) { case "id": msg.id = template.text; break; case "defaultMessage": msg.defaultMessage = template.text; break; case "description": msg.description = template.text; break; } } else if (ts.isJsxExpression(initializer) && initializer.expression) { // <FormattedMessage foo={'barbaz'} /> if (ts.isStringLiteral(initializer.expression)) { switch (name.text) { case "id": msg.id = initializer.expression.text; break; case "defaultMessage": msg.defaultMessage = initializer.expression.text; break; case "description": msg.description = initializer.expression.text; break; } } else if (ts.isObjectLiteralExpression(initializer.expression) && name.text === "description") { msg.description = objectLiteralExpressionToObj(ts, initializer.expression); } else if (ts.isNoSubstitutionTemplateLiteral(initializer.expression)) { const { expression } = initializer; switch (name.text) { case "id": msg.id = expression.text; break; case "defaultMessage": msg.defaultMessage = expression.text; break; case "description": msg.description = expression.text; break; } } else if (ts.isTaggedTemplateExpression(initializer.expression)) { const isMessageProp = name.text === "id" || name.text === "defaultMessage" || name.text === "description"; if (!isMessageProp) { // Skip non-message props (like tagName, values, etc.) return; } const { expression: { template } } = initializer; if (!ts.isNoSubstitutionTemplateLiteral(template)) { handleError("[FormatJS] Tagged template expression must be no substitution"); return; } switch (name.text) { case "id": msg.id = template.text; break; case "defaultMessage": msg.defaultMessage = template.text; break; case "description": msg.description = template.text; break; } } else if (ts.isBinaryExpression(initializer.expression)) { const { expression } = initializer; const [result, isStatic] = evaluateStringConcat(ts, expression); if (isStatic) { switch (name.text) { case "id": msg.id = result; break; case "defaultMessage": msg.defaultMessage = result; break; case "description": msg.description = result; break; } } else if (MESSAGE_DESC_KEYS.includes(name.text) && name.text !== "description") { // Non-static expression for defaultMessage or id handleError(`[FormatJS] \`${name.text}\` must be a string literal or statically evaluable expression to be extracted.`); return; } } else if (MESSAGE_DESC_KEYS.includes(name.text) && name.text !== "description") { handleError(`[FormatJS] \`${name.text}\` must be a string literal to be extracted.`); return; } } else if (ts.isBinaryExpression(initializer)) { const [result, isStatic] = evaluateStringConcat(ts, initializer); if (isStatic) { switch (name.text) { case "id": msg.id = result; break; case "defaultMessage": msg.defaultMessage = result; break; case "description": msg.description = result; break; } } else if (MESSAGE_DESC_KEYS.includes(name.text) && name.text !== "description") { // Non-static expression for defaultMessage or id handleError(`[FormatJS] \`${name.text}\` must be a string literal or statically evaluable expression to be extracted.`); return; } } else if (ts.isObjectLiteralExpression(initializer) && name.text === "description") { msg.description = objectLiteralExpressionToObj(ts, initializer); } else if (MESSAGE_DESC_KEYS.includes(name.text) && name.text !== "description") { handleError(`[FormatJS] \`${name.text}\` must be a string literal to be extracted.`); return; } } }); // If we had an extraction error (and throws is false), skip this message if (extractionError) { return; } // We extracted nothing if (!msg.defaultMessage && !msg.id) { return; } if (msg.defaultMessage && !preserveWhitespace) { msg.defaultMessage = msg.defaultMessage.trim().replace(/\s+/gm, " "); } // GH #3537: Apply flatten transformation before calling overrideIdFn // so that the ID generation sees the same message format as the final output if (flatten && msg.defaultMessage) { try { msg.defaultMessage = printAST(hoistSelectors(parse(msg.defaultMessage))); } catch (e) { const { line, character } = sf.getLineAndCharacterOfPosition(node.pos); throw new Error(`[formatjs] Cannot flatten message in file "${sf.fileName}" at line ${line + 1}, column ${character + 1}${msg.id ? ` with id "${msg.id}"` : ""}: ${e.message}\nMessage: ${msg.defaultMessage}`); } } if (msg.defaultMessage && overrideIdFn) { switch (typeof overrideIdFn) { case "string": if (!msg.id) { msg.id = interpolateName({ resourcePath: sf.fileName }, overrideIdFn, { content: msg.description ? `${msg.defaultMessage}#${typeof msg.description === "string" ? msg.description : stringify(msg.description)}` : msg.defaultMessage }); } break; case "function": msg.id = overrideIdFn(msg.id, msg.defaultMessage, msg.description, sf.fileName); break; } } if (extractSourceLocation) { return { ...msg, file: sf.fileName, start: node.pos, end: node.end }; } return msg; } /** * Check if node is `foo.bar.formatMessage` node * @param node * @param sf */ function isMemberMethodFormatMessageCall(ts, node, additionalFunctionNames) { const fnNames = new Set([ "formatMessage", "$formatMessage", ...additionalFunctionNames ]); const method = node.expression; // Handle foo.formatMessage() if (ts.isPropertyAccessExpression(method)) { return fnNames.has(method.name.text); } // GH #4471: Handle foo.formatMessage<T>?.() - when both generics and optional chaining are used // TypeScript represents this as ExpressionWithTypeArguments containing a PropertyAccessExpression if (ts.isExpressionWithTypeArguments && ts.isExpressionWithTypeArguments(method)) { const expr = method.expression; if (ts.isPropertyAccessExpression(expr)) { return fnNames.has(expr.name.text); } } // Handle formatMessage() return ts.isIdentifier(method) && fnNames.has(method.text); } function extractMessageFromJsxComponent(ts, factory, node, opts, sf) { const { onMsgExtracted } = opts; if (!isSingularMessageDecl(ts, node, opts.additionalComponentNames || [])) { return node; } const msg = extractMessageDescriptor(ts, node, opts, sf); if (!msg) { return node; } if (typeof onMsgExtracted === "function") { onMsgExtracted(sf.fileName, [msg]); } const newProps = generateNewProperties(ts, factory, node.attributes, { defaultMessage: opts.removeDefaultMessage ? undefined : msg.defaultMessage, id: msg.id }, opts.ast); if (ts.isJsxOpeningElement(node)) { return factory.updateJsxOpeningElement(node, node.tagName, node.typeArguments, factory.createJsxAttributes(newProps)); } return factory.updateJsxSelfClosingElement(node, node.tagName, node.typeArguments, factory.createJsxAttributes(newProps)); } function setAttributesInObject(ts, factory, node, msg, ast) { const newProps = [factory.createPropertyAssignment("id", factory.createStringLiteral(msg.id)), ...msg.defaultMessage ? [factory.createPropertyAssignment("defaultMessage", ast ? messageASTToTSNode(factory, parse(msg.defaultMessage)) : factory.createStringLiteral(msg.defaultMessage))] : []]; for (const prop of node.properties) { if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name) && MESSAGE_DESC_KEYS.includes(prop.name.text)) { continue; } if (ts.isPropertyAssignment(prop)) { newProps.push(prop); } } return factory.createObjectLiteralExpression(factory.createNodeArray(newProps)); } function generateNewProperties(ts, factory, node, msg, ast) { const newProps = [factory.createJsxAttribute(factory.createIdentifier("id"), factory.createStringLiteral(msg.id)), ...msg.defaultMessage ? [factory.createJsxAttribute(factory.createIdentifier("defaultMessage"), ast ? factory.createJsxExpression(undefined, messageASTToTSNode(factory, parse(msg.defaultMessage))) : factory.createStringLiteral(msg.defaultMessage))] : []]; for (const prop of node.properties) { if (ts.isJsxAttribute(prop) && ts.isIdentifier(prop.name) && MESSAGE_DESC_KEYS.includes(prop.name.text)) { continue; } if (ts.isJsxAttribute(prop)) { newProps.push(prop); } } return newProps; } function extractMessagesFromCallExpression(ts, factory, node, opts, sf) { const { onMsgExtracted, additionalFunctionNames } = opts; if (isMultipleMessageDecl(ts, node)) { const [arg, ...restArgs] = node.arguments; let descriptorsObj; if (ts.isObjectLiteralExpression(arg)) { descriptorsObj = arg; } else if (ts.isAsExpression(arg) && ts.isObjectLiteralExpression(arg.expression)) { descriptorsObj = arg.expression; } if (descriptorsObj) { const properties = descriptorsObj.properties; const msgs = properties.filter((prop) => ts.isPropertyAssignment(prop)).map((prop) => ts.isObjectLiteralExpression(prop.initializer) && extractMessageDescriptor(ts, prop.initializer, opts, sf)).filter((msg) => !!msg); if (!msgs.length) { return node; } debug("Multiple messages extracted from \"%s\": %s", sf.fileName, msgs); if (typeof onMsgExtracted === "function") { onMsgExtracted(sf.fileName, msgs); } const clonedProperties = factory.createNodeArray(properties.map((prop, i) => { if (!ts.isPropertyAssignment(prop) || !ts.isObjectLiteralExpression(prop.initializer)) { return prop; } return factory.createPropertyAssignment(prop.name, setAttributesInObject(ts, factory, prop.initializer, { defaultMessage: opts.removeDefaultMessage ? undefined : msgs[i].defaultMessage, id: msgs[i] ? msgs[i].id : "" }, opts.ast)); })); const clonedDescriptorsObj = factory.createObjectLiteralExpression(clonedProperties); return factory.updateCallExpression(node, node.expression, node.typeArguments, [clonedDescriptorsObj, ...restArgs]); } } else if (isSingularMessageDecl(ts, node, opts.additionalComponentNames || []) || isMemberMethodFormatMessageCall(ts, node, additionalFunctionNames || [])) { const [descriptorsObj, ...restArgs] = node.arguments; if (ts.isObjectLiteralExpression(descriptorsObj)) { const msg = extractMessageDescriptor(ts, descriptorsObj, opts, sf); if (!msg) { return node; } debug("Message extracted from \"%s\": %s", sf.fileName, msg); if (typeof onMsgExtracted === "function") { onMsgExtracted(sf.fileName, [msg]); } return factory.updateCallExpression(node, node.expression, node.typeArguments, [setAttributesInObject(ts, factory, descriptorsObj, { defaultMessage: opts.removeDefaultMessage ? undefined : msg.defaultMessage, id: msg.id }, opts.ast), ...restArgs]); } } return node; } const PRAGMA_REGEX = /^\/\/ @([^\s]*) (.*)$/m; function getVisitor(ts, ctx, sf, opts) { const visitor = (node) => { const newNode = ts.isCallExpression(node) ? extractMessagesFromCallExpression(ts, ctx.factory, node, opts, sf) : ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node) ? extractMessageFromJsxComponent(ts, ctx.factory, node, opts, sf) : node; return ts.visitEachChild(newNode, visitor, ctx); }; return visitor; } export function transformWithTs(ts, opts) { opts = { ...DEFAULT_OPTS, ...opts }; debug("Transforming options", opts); const transformFn = (ctx) => { return (sf) => { const pragmaResult = PRAGMA_REGEX.exec(sf.text); if (pragmaResult) { debug("Pragma found", pragmaResult); const [, pragma, kvString] = pragmaResult; if (pragma === opts.pragma) { const kvs = kvString.split(" "); const result = {}; for (const kv of kvs) { const [k, v] = kv.split(":"); result[k] = v; } debug("Pragma extracted", result); if (typeof opts.onMetaExtracted === "function") { opts.onMetaExtracted(sf.fileName, result); } } } return ts.visitEachChild(sf, getVisitor(ts, ctx, sf, opts), ctx); }; }; return transformFn; } export function transform(opts) { return transformWithTs(typescript, opts); }