@formatjs/ts-transformer
Version:
TS Compiler transformer for formatjs
469 lines (468 loc) • 20.5 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 "typescript";
import { format, styleText } from "node:util";
import { createHash } from "crypto";
import * as path from "path";
//#region packages/ts-transformer/console_utils.ts
const LEVEL_COLORS = {
debug: "green",
warn: "yellow",
error: "red"
};
function label(level, message) {
return `[@formatjs/ts-transformer] [${styleText(LEVEL_COLORS[level], level.toUpperCase())}] ${message}`;
}
async function debug(message, ...args) {
if (process.env.LOG_LEVEL !== "debug") return;
console.error(format(label("debug", message), ...args));
console.error("\n");
}
//#endregion
//#region packages/ts-transformer/interpolate-name.ts
function getHashDigest(content, hashType = "md5", digestType = "hex", length = 9999) {
const hasher = createHash(hashType);
hasher.update(content);
return hasher.digest(digestType).slice(0, length);
}
function interpolateName(loaderContext, name, options) {
let filename;
const hasQuery = loaderContext.resourceQuery && loaderContext.resourceQuery.length > 1;
if (typeof name === "function") filename = name(loaderContext.resourcePath, hasQuery ? loaderContext.resourceQuery : void 0);
else filename = name || "[hash].[ext]";
const context = options.context;
const content = options.content;
const regExp = options.regExp;
let ext = "bin";
let basename = "file";
let directory = "";
let folder = "";
let query = "";
if (loaderContext.resourcePath) {
const parsed = path.parse(loaderContext.resourcePath);
let resourcePath = loaderContext.resourcePath;
if (parsed.ext) ext = parsed.ext.slice(1);
if (parsed.dir) {
basename = parsed.name;
resourcePath = parsed.dir + path.sep;
}
if (typeof context !== "undefined") {
directory = path.relative(context, resourcePath + "_").replace(/\\/g, "/").replace(/\.\.(\/)?/g, "_$1");
directory = directory.slice(0, -1);
} else directory = resourcePath.replace(/\\/g, "/").replace(/\.\.(\/)?/g, "_$1");
if (directory.length === 1) directory = "";
else if (directory.length > 1) folder = path.basename(directory);
}
if (loaderContext.resourceQuery && loaderContext.resourceQuery.length > 1) {
query = loaderContext.resourceQuery;
const hashIdx = query.indexOf("#");
if (hashIdx >= 0) query = query.slice(0, hashIdx);
}
let url = filename;
if (content) url = url.replace(/\[(?:([^:\]]+):)?(?:hash|contenthash)(?::([a-z]+\d*[a-z]*))?(?::(\d+))?\]/gi, (_, hashType, digestType, maxLength) => getHashDigest(content, hashType, digestType, parseInt(maxLength, 10)));
url = url.replace(/\[ext\]/gi, () => ext).replace(/\[name\]/gi, () => basename).replace(/\[path\]/gi, () => directory).replace(/\[folder\]/gi, () => folder).replace(/\[query\]/gi, () => query);
if (regExp && loaderContext.resourcePath) {
const match = loaderContext.resourcePath.match(new RegExp(regExp));
if (match) match.forEach((matched, i) => {
url = url.replace(new RegExp("\\[" + i + "\\]", "ig"), matched);
});
}
if (typeof loaderContext.options === "object" && typeof loaderContext.options.customInterpolateName === "function") url = loaderContext.options.customInterpolateName.call(loaderContext, url, name, options);
return url;
}
//#endregion
//#region packages/ts-transformer/transform.ts
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() : void 0;
}
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: () => void 0,
onMetaExtracted: () => void 0
};
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 = unwrapTransparentTypeScriptExpression(ts, node.right);
const left = unwrapTransparentTypeScriptExpression(ts, node.left);
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 unwrapTransparentTypeScriptExpression(ts, node) {
let current = node;
while (ts.isAsExpression(current) || ts.isSatisfiesExpression(current) || ts.isNonNullExpression(current) || ts.isTypeAssertionExpression(current)) current = current.expression;
return current;
}
function unwrapObjectLiteralExpression(ts, node) {
const expression = unwrapTransparentTypeScriptExpression(ts, node);
return ts.isObjectLiteralExpression(expression) ? expression : void 0;
}
function extractMessageDescriptor(ts, node, { overrideIdFn, extractSourceLocation, preserveWhitespace, flatten, throws = true, onMsgError }, sf) {
let extractionError = null;
function handleError(errorMsg, errorNode) {
let locationMsg = errorMsg;
if (errorNode) {
const { line, character } = ts.getLineAndCharacterOfPosition(sf, errorNode.getStart(sf));
locationMsg = `${sf.fileName}:${line + 1}:${character + 1} ${errorMsg}`;
}
const error = new Error(locationMsg);
if (throws) throw error;
extractionError = error;
if (onMsgError) onMsgError(sf.fileName, error);
}
let properties = void 0;
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 : void 0;
if (name && ts.isIdentifier(name) && initializer) {
const value = ts.isPropertyAssignment(prop) ? unwrapTransparentTypeScriptExpression(ts, prop.initializer) : initializer;
if (ts.isStringLiteral(value)) switch (name.text) {
case "id":
msg.id = value.text;
break;
case "defaultMessage":
msg.defaultMessage = value.text;
break;
case "description":
msg.description = value.text;
break;
}
else if (ts.isNoSubstitutionTemplateLiteral(value)) switch (name.text) {
case "id":
msg.id = value.text;
break;
case "defaultMessage":
msg.defaultMessage = value.text;
break;
case "description":
msg.description = value.text;
break;
}
else if (ts.isTaggedTemplateExpression(value)) {
if (!(name.text === "id" || name.text === "defaultMessage" || name.text === "description")) return;
const { template } = value;
if (!ts.isNoSubstitutionTemplateLiteral(template)) {
handleError("[FormatJS] Tagged template expression must be no substitution", prop);
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(value) && value.expression) {
const expression = unwrapTransparentTypeScriptExpression(ts, value.expression);
if (ts.isStringLiteral(expression)) 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.isObjectLiteralExpression(expression) && name.text === "description") msg.description = objectLiteralExpressionToObj(ts, expression);
else if (ts.isNoSubstitutionTemplateLiteral(expression)) 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(expression)) {
if (!(name.text === "id" || name.text === "defaultMessage" || name.text === "description")) return;
const { template } = expression;
if (!ts.isNoSubstitutionTemplateLiteral(template)) {
handleError("[FormatJS] Tagged template expression must be no substitution", prop);
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(expression)) {
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") {
handleError(`[FormatJS] \`${name.text}\` must be a string literal or statically evaluable expression to be extracted.`, prop);
return;
}
} else if (MESSAGE_DESC_KEYS.includes(name.text) && name.text !== "description") {
handleError(`[FormatJS] \`${name.text}\` must be a string literal to be extracted.`, prop);
return;
}
} else if (ts.isBinaryExpression(value)) {
const [result, isStatic] = evaluateStringConcat(ts, value);
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") {
handleError(`[FormatJS] \`${name.text}\` must be a string literal or statically evaluable expression to be extracted.`, prop);
return;
}
} else if (ts.isObjectLiteralExpression(value) && name.text === "description") msg.description = objectLiteralExpressionToObj(ts, value);
else if (MESSAGE_DESC_KEYS.includes(name.text) && name.text !== "description") {
handleError(`[FormatJS] \`${name.text}\` must be a string literal to be extracted.`, prop);
return;
}
}
});
if (extractionError) return;
if (!msg.defaultMessage && !msg.id) return;
if (msg.defaultMessage && !preserveWhitespace) msg.defaultMessage = msg.defaultMessage.trim().replace(/\s+/gm, " ");
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;
if (ts.isPropertyAccessExpression(method)) return fnNames.has(method.name.text);
if (ts.isExpressionWithTypeArguments && ts.isExpressionWithTypeArguments(method)) {
const expr = method.expression;
if (ts.isPropertyAccessExpression(expr)) return fnNames.has(expr.name.text);
}
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 ? void 0 : 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(void 0, 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;
const descriptorsObj = unwrapObjectLiteralExpression(ts, arg);
if (descriptorsObj) {
const properties = descriptorsObj.properties;
const msgs = properties.filter((prop) => ts.isPropertyAssignment(prop)).map((prop) => {
const descriptor = unwrapObjectLiteralExpression(ts, prop.initializer);
return descriptor && extractMessageDescriptor(ts, descriptor, 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 ? void 0 : 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;
const descriptor = unwrapObjectLiteralExpression(ts, descriptorsObj);
if (descriptor) {
const msg = extractMessageDescriptor(ts, descriptor, 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, descriptor, {
defaultMessage: opts.removeDefaultMessage ? void 0 : 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;
}
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;
}
//#endregion
//#region packages/ts-transformer/ts-jest-integration.ts
const name = "@formatjs/ts-transformer";
const version = "2.10.1";
function factory(compilerInstance, opts) {
return transformWithTs(compilerInstance.configSet.compilerModule, opts);
}
//#endregion
export { factory, name, version };
//# sourceMappingURL=ts-jest-integration.js.map