UNPKG

@formatjs/ts-transformer

Version:

TS Compiler transformer for formatjs

469 lines (468 loc) 20.5 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 "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