/* * 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 { printAST, parse } from 'intl-messageformat-parser'; 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 } from '@babel/types'; import { NodePath } from '@babel/traverse'; const DEFAULT_COMPONENT_NAMES = ['FormattedMessage', 'FormattedHTMLMessage']; const FUNCTION_NAMES = ['defineMessages']; const EXTRACTED = Symbol('ReactIntlExtracted'); const DESCRIPTOR_PROPS = new Set(['id', 'description', 'defaultMessage']); interface MessageDescriptor { id: string; defaultMessage?: string; description?: string; } type ExtractedMessageDescriptor = MessageDescriptor & Partial & { file?: string }; type MessageDescriptorPath = Record< keyof MessageDescriptor, NodePath | undefined >; export interface Opts { moduleSourceName?: string; enforceDefaultMessage?: boolean; enforceDescriptions?: boolean; extractSourceLocation?: boolean; messagesDir: string; overrideIdFn?(id: string, defaultMessage: string, descriptor: string): string; removeDefaultMessage?: boolean; extractFromFormatMessageCall?: boolean; additionalComponentNames?: string[]; } // 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 { return printAST(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}` ); } } 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); if (typeof descriptorValue === 'string') { return descriptorValue.trim(); } 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?: Opts['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, { enforceDescriptions, enforceDefaultMessage = true, extractSourceLocation }: Opts, filename: string, messages: Map ) { if (!id || (enforceDefaultMessage && !defaultMessage)) { throw path.buildCodeFrameError( '[React Intl] Message Descriptors require an `id` and `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.' ); } } if (enforceDescriptions) { if ( !description || (typeof description === 'object' && Object.keys(description).length < 1) ) { throw path.buildCodeFrameError( '[React Intl] Message must have a `description`.' ); } } 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) => { api.assertVersion(7); /** * 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 } }, opts: { messagesDir } } = this; const basename = p.basename(filename, p.extname(filename)); const { ReactIntlMessages: messages } = this; const descriptors = Array.from(messages.values()); state.metadata['react-intl'] = { messages: descriptors }; if (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 = [], enforceDefaultMessage, 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() ); let 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 `` or // ``, because it will be // skipped here and extracted elsewhere. The descriptor will // be extracted only if a `defaultMessage` prop exists and // `enforceDefaultMessage` is `true`. if ( enforceDefaultMessage === false || 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 (referencesImport(callee, moduleSourceName, FUNCTION_NAMES)) { const messagesObj = path.get('arguments')[0]; if (assertObjectExpression(messagesObj, callee)) { 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>; });