@formatjs/ts-transformer
Version:
TS Compiler transformer for formatjs
485 lines (484 loc) • 18 kB
JavaScript
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);
}