/* * Copyright 2015, Yahoo Inc. * Copyrights licensed under the New BSD License. * See the accompanying LICENSE file for terms. */ import * as p from 'path'; import {writeFileSync} from 'fs'; import {mkdirpSync} from 'fs-extra'; import {parse} from 'intl-messageformat-parser/dist'; const {declare} = require('@babel/helper-plugin-utils') as any; import {types as t, PluginObj} from '@babel/core'; import { ObjectExpression, JSXAttribute, StringLiteral, JSXIdentifier, JSXExpressionContainer, Identifier, ObjectProperty, SourceLocation, Expression, V8IntrinsicIdentifier, isTSAsExpression, isTypeCastExpression, isTSTypeAssertion, } from '@babel/types'; import {NodePath} from '@babel/traverse'; import validate from 'schema-utils'; import OPTIONS_SCHEMA from './options.schema.json'; import {OptionsSchema} from './options.js'; const DEFAULT_COMPONENT_NAMES = ['FormattedMessage', 'FormattedHTMLMessage']; const EXTRACTED = Symbol('ReactIntlExtracted'); const DESCRIPTOR_PROPS = new Set(['id', 'description', 'defaultMessage']); interface MessageDescriptor { id: string; defaultMessage?: string; description?: string; } export type ExtractedMessageDescriptor = MessageDescriptor & Partial & {file?: string}; type MessageDescriptorPath = Record< keyof MessageDescriptor, NodePath | undefined >; // From https://github.com/babel/babel/blob/master/packages/babel-core/src/transformation/plugin-pass.js interface PluginPass { key?: string; file: BabelTransformationFile; opts: O; cwd: string; filename?: string; } interface BabelTransformationFile { opts: { filename: string; babelrc: boolean; configFile: boolean; passPerPreset: boolean; envName: string; cwd: string; root: string; plugins: unknown[]; presets: unknown[]; parserOpts: object; generatorOpts: object; }; declarations: {}; path: NodePath | null; ast: {}; scope: unknown; metadata: {}; code: string; inputMap: object | null; } interface State { ReactIntlMessages: Map; } function getICUMessageValue( messagePath?: NodePath, {isJSXSource = false} = {} ) { if (!messagePath) { return ''; } const message = getMessageDescriptorValue(messagePath); try { parse(message); } catch (parseError) { if ( isJSXSource && messagePath.isLiteral() && message.indexOf('\\\\') >= 0 ) { throw messagePath.buildCodeFrameError( '[React Intl] Message failed to parse. ' + 'It looks like `\\`s were used for escaping, ' + "this won't work with JSX string literals. " + 'Wrap with `{}`. ' + 'See: http://facebook.github.io/react/docs/jsx-gotchas.html' ); } throw messagePath.buildCodeFrameError( '[React Intl] Message failed to parse. ' + 'See: http://formatjs.io/guides/message-syntax/' + `\n${parseError}` ); } return message; } function evaluatePath(path: NodePath): string { const evaluated = path.evaluate(); if (evaluated.confident) { return evaluated.value; } throw path.buildCodeFrameError( '[React Intl] Messages must be statically evaluate-able for extraction.' ); } function getMessageDescriptorKey(path: NodePath) { if (path.isIdentifier() || path.isJSXIdentifier()) { return path.node.name; } return evaluatePath(path); } function getMessageDescriptorValue( path?: NodePath | NodePath ) { if (!path) { return ''; } if (path.isJSXExpressionContainer()) { path = path.get('expression') as NodePath; } // Always trim the Message Descriptor values. const descriptorValue = evaluatePath(path); return descriptorValue; } function createMessageDescriptor( propPaths: [ NodePath | NodePath, NodePath | NodePath ][] ): MessageDescriptorPath { return propPaths.reduce( (hash: MessageDescriptorPath, [keyPath, valuePath]) => { const key = getMessageDescriptorKey(keyPath); if (DESCRIPTOR_PROPS.has(key)) { hash[key as 'id'] = valuePath as NodePath; } return hash; }, { id: undefined, defaultMessage: undefined, description: undefined, } ); } function evaluateMessageDescriptor( descriptorPath: MessageDescriptorPath, isJSXSource = false, overrideIdFn?: OptionsSchema['overrideIdFn'] ) { let id = getMessageDescriptorValue(descriptorPath.id); const defaultMessage = getICUMessageValue(descriptorPath.defaultMessage, { isJSXSource, }); const description = getMessageDescriptorValue(descriptorPath.description); if (overrideIdFn) { id = overrideIdFn(id, defaultMessage, description); } const descriptor: MessageDescriptor = { id, }; if (description) { descriptor.description = description; } if (defaultMessage) { descriptor.defaultMessage = defaultMessage; } return descriptor; } function storeMessage( {id, description, defaultMessage}: MessageDescriptor, path: NodePath, {extractSourceLocation}: OptionsSchema, filename: string, messages: Map ) { if (!id && !defaultMessage) { throw path.buildCodeFrameError( '[React Intl] Message Descriptors require an `id` or `defaultMessage`.' ); } if (messages.has(id)) { const existing = messages.get(id); if ( description !== existing!.description || defaultMessage !== existing!.defaultMessage ) { throw path.buildCodeFrameError( `[React Intl] Duplicate message id: "${id}", ` + 'but the `description` and/or `defaultMessage` are different.' ); } } let loc = {}; if (extractSourceLocation) { loc = { file: p.relative(process.cwd(), filename), ...path.node.loc, }; } messages.set(id, {id, description, defaultMessage, ...loc}); } function referencesImport( path: NodePath, mod: string, importedNames: string[] ) { if (!(path.isIdentifier() || path.isJSXIdentifier())) { return false; } return importedNames.some(name => path.referencesImport(mod, name)); } function isFormatMessageCall( callee: NodePath ) { if (!callee.isMemberExpression()) { return false; } const object = callee.get('object'); const property = callee.get('property') as NodePath; return ( property.isIdentifier() && property.node.name === 'formatMessage' && // things like `intl.formatMessage` ((object.isIdentifier() && object.node.name === 'intl') || // things like `this.props.intl.formatMessage` (object.isMemberExpression() && (object.get('property') as NodePath).node.name === 'intl')) ); } function assertObjectExpression( path: NodePath, callee: NodePath ): path is NodePath { if (!path || !path.isObjectExpression()) { throw path.buildCodeFrameError( `[React Intl] \`${ (callee.get('property') as NodePath).node.name }()\` must be ` + 'called with an object expression with values ' + 'that are React Intl Message Descriptors, also ' + 'defined as object expressions.' ); } return true; } export default declare((api: any, options: OptionsSchema) => { api.assertVersion(7); validate(OPTIONS_SCHEMA as any, options, { name: 'babel-plugin-react-intl', baseDataPath: 'options', }); const {messagesDir} = options; /** * Store this in the node itself so that multiple passes work. Specifically * if we remove `description` in the 1st pass, 2nd pass will fail since * it expect `description` to be there. * HACK: We store this in the node instance since this persists across * multiple plugin runs */ function tagAsExtracted(path: NodePath) { (path.node as any)[EXTRACTED] = true; } function wasExtracted(path: NodePath) { return !!(path.node as any)[EXTRACTED]; } return { pre() { if (!this.ReactIntlMessages) { this.ReactIntlMessages = new Map(); } }, post(state) { const { file: { opts: {filename}, }, } = this; // If no filename is specified, that means this babel plugin is called programmatically // via NodeJS API by other programs (e.g. by feeding us with file content directly). In // this case we will only make extracted messages accessible via Babel result objects. const basename = filename ? p.basename(filename, p.extname(filename)) : null; const {ReactIntlMessages: messages} = this; const descriptors = Array.from(messages.values()); state.metadata['react-intl'] = {messages: descriptors}; if (basename && messagesDir && descriptors.length > 0) { // Make sure the relative path is "absolute" before // joining it with the `messagesDir`. let relativePath = p.join(p.sep, p.relative(process.cwd(), filename)); // Solve when the window user has symlink on the directory, because // process.cwd on windows returns the symlink root, // and filename (from babel) returns the original root if (process.platform === 'win32') { const {name} = p.parse(process.cwd()); if (relativePath.includes(name)) { relativePath = relativePath.slice( relativePath.indexOf(name) + name.length ); } } const messagesFilename = p.join( messagesDir, p.dirname(relativePath), basename + '.json' ); const messagesFile = JSON.stringify(descriptors, null, 2); mkdirpSync(p.dirname(messagesFilename)); writeFileSync(messagesFilename, messagesFile); } }, visitor: { JSXOpeningElement( path, { opts, file: { opts: {filename}, }, } ) { const { moduleSourceName = 'react-intl', additionalComponentNames = [], removeDefaultMessage, overrideIdFn, } = opts; if (wasExtracted(path)) { return; } const name = path.get('name'); if (name.referencesImport(moduleSourceName, 'FormattedPlural')) { if (path.node && path.node.loc) console.warn( `[React Intl] Line ${path.node.loc.start.line}: ` + 'Default messages are not extracted from ' + ', use instead.' ); return; } if ( name.isJSXIdentifier() && (referencesImport(name, moduleSourceName, DEFAULT_COMPONENT_NAMES) || additionalComponentNames.includes(name.node.name)) ) { const attributes = path .get('attributes') .filter((attr): attr is NodePath => attr.isJSXAttribute() ); const descriptorPath = createMessageDescriptor( attributes.map(attr => [ attr.get('name') as NodePath, attr.get('value') as NodePath, ]) ); // In order for a default message to be extracted when // declaring a JSX element, it must be done with standard // `key=value` attributes. But it's completely valid to // write ``, because it will be // skipped here and extracted elsewhere. The descriptor will // be extracted only (storeMessage) if a `defaultMessage` prop. if (descriptorPath.id && descriptorPath.defaultMessage) { // Evaluate the Message Descriptor values in a JSX // context, then store it. const descriptor = evaluateMessageDescriptor( descriptorPath, true, overrideIdFn ); storeMessage( descriptor, path, opts, filename, this.ReactIntlMessages ); attributes.forEach(attr => { const ketPath = attr.get('name'); const msgDescriptorKey = getMessageDescriptorKey(ketPath); if ( // Remove description since it's not used at runtime. msgDescriptorKey === 'description' || // Remove defaultMessage if opts says so. (removeDefaultMessage && msgDescriptorKey === 'defaultMessage') ) { attr.remove(); } else if ( overrideIdFn && getMessageDescriptorKey(ketPath) === 'id' ) { attr.get('value').replaceWith(t.stringLiteral(descriptor.id)); } }); // Tag the AST node so we don't try to extract it twice. tagAsExtracted(path); } } }, CallExpression( path, { opts, file: { opts: {filename}, }, } ) { const {ReactIntlMessages: messages} = this; const { moduleSourceName = 'react-intl', overrideIdFn, removeDefaultMessage, extractFromFormatMessageCall, } = opts; const callee = path.get('callee'); /** * Process MessageDescriptor * @param messageDescriptor Message Descriptor */ function processMessageObject( messageDescriptor: NodePath ) { assertObjectExpression(messageDescriptor, callee); if (wasExtracted(messageDescriptor)) { return; } const properties = messageDescriptor.get('properties') as NodePath< ObjectProperty >[]; const descriptorPath = createMessageDescriptor( properties.map( prop => [prop.get('key'), prop.get('value')] as [ NodePath, NodePath ] ) ); // Evaluate the Message Descriptor values, then store it. const descriptor = evaluateMessageDescriptor( descriptorPath, false, overrideIdFn ); storeMessage(descriptor, messageDescriptor, opts, filename, messages); // Remove description since it's not used at runtime. messageDescriptor.replaceWith( t.objectExpression([ t.objectProperty( t.stringLiteral('id'), t.stringLiteral(descriptor.id) ), ...(!removeDefaultMessage && descriptor.defaultMessage ? [ t.objectProperty( t.stringLiteral('defaultMessage'), t.stringLiteral(descriptor.defaultMessage) ), ] : []), ]) ); // Tag the AST node so we don't try to extract it twice. tagAsExtracted(messageDescriptor); } // Check that this is `defineMessages` call if ( isMultipleMessagesDeclMacro(callee, moduleSourceName) || isSingularMessagesDeclMacro(callee) ) { const firstArgument = path.get('arguments')[0]; const messagesObj = getMessagesObjectFromExpression(firstArgument); if (assertObjectExpression(messagesObj, callee)) { if (isSingularMessagesDeclMacro(callee)) { processMessageObject(messagesObj as NodePath); } else { messagesObj .get('properties') .map(prop => prop.get('value') as NodePath) .forEach(processMessageObject); } } } // Check that this is `intl.formatMessage` call if (extractFromFormatMessageCall && isFormatMessageCall(callee)) { const messageDescriptor = path.get('arguments')[0]; if (messageDescriptor.isObjectExpression()) { processMessageObject(messageDescriptor); } } }, }, } as PluginObj & State>; }); function isMultipleMessagesDeclMacro( callee: NodePath, moduleSourceName: string ) { return ( referencesImport(callee, moduleSourceName, ['defineMessages']) || referencesImport(callee, '@formatjs/macro', ['defineMessages']) ); } function isSingularMessagesDeclMacro(callee: NodePath) { return referencesImport(callee, '@formatjs/macro', ['_']); } function getMessagesObjectFromExpression( nodePath: NodePath ): NodePath { let currentPath = nodePath; while ( isTSAsExpression(currentPath.node) || isTSTypeAssertion(currentPath.node) || isTypeCastExpression(currentPath.node) ) { currentPath = currentPath.get('expression') as NodePath; } return currentPath; }