UNPKG

16.6 kBPlain TextView Raw
1/*
2 * Copyright 2015, Yahoo Inc.
3 * Copyrights licensed under the New BSD License.
4 * See the accompanying LICENSE file for terms.
5 */
6
7import * as p from 'path';
8import { writeFileSync } from 'fs';
9import { mkdirpSync } from 'fs-extra';
10import { printAST, parse } from 'intl-messageformat-parser';
11const { declare } = require('@babel/helper-plugin-utils') as any;
12import { types as t, PluginObj } from '@babel/core';
13import {
14 ObjectExpression,
15 JSXAttribute,
16 StringLiteral,
17 JSXIdentifier,
18 JSXExpressionContainer,
19 Identifier,
20 ObjectProperty,
21 SourceLocation,
22 Expression
23} from '@babel/types';
24import { NodePath } from '@babel/traverse';
25
26const DEFAULT_COMPONENT_NAMES = ['FormattedMessage', 'FormattedHTMLMessage'];
27
28const FUNCTION_NAMES = ['defineMessages'];
29const EXTRACTED = Symbol('ReactIntlExtracted');
30const DESCRIPTOR_PROPS = new Set(['id', 'description', 'defaultMessage']);
31
32interface MessageDescriptor {
33 id: string;
34 defaultMessage?: string;
35 description?: string;
36}
37
38type ExtractedMessageDescriptor = MessageDescriptor &
39 Partial<SourceLocation> & { file?: string };
40
41type MessageDescriptorPath = Record<
42 keyof MessageDescriptor,
43 NodePath<StringLiteral> | undefined
44>;
45
46export interface Opts {
47 moduleSourceName?: string;
48 enforceDefaultMessage?: boolean;
49 enforceDescriptions?: boolean;
50 extractSourceLocation?: boolean;
51 messagesDir: string;
52 overrideIdFn?(id: string, defaultMessage: string, descriptor: string): string;
53 removeDefaultMessage?: boolean;
54 extractFromFormatMessageCall?: boolean;
55 additionalComponentNames?: string[];
56}
57
58// From https://github.com/babel/babel/blob/master/packages/babel-core/src/transformation/plugin-pass.js
59interface PluginPass<O> {
60 key?: string;
61 file: BabelTransformationFile;
62 opts: O;
63 cwd: string;
64 filename?: string;
65}
66
67interface BabelTransformationFile {
68 opts: {
69 filename: string;
70 babelrc: boolean;
71 configFile: boolean;
72 passPerPreset: boolean;
73 envName: string;
74 cwd: string;
75 root: string;
76 plugins: unknown[];
77 presets: unknown[];
78 parserOpts: object;
79 generatorOpts: object;
80 };
81 declarations: {};
82 path: NodePath | null;
83 ast: {};
84 scope: unknown;
85 metadata: {};
86 code: string;
87 inputMap: object | null;
88}
89
90interface State {
91 ReactIntlMessages: Map<string, ExtractedMessageDescriptor>;
92}
93
94function getICUMessageValue(
95 messagePath?: NodePath<StringLiteral>,
96 { isJSXSource = false } = {}
97) {
98 if (!messagePath) {
99 return '';
100 }
101 const message = getMessageDescriptorValue(messagePath);
102
103 try {
104 return printAST(parse(message));
105 } catch (parseError) {
106 if (
107 isJSXSource &&
108 messagePath.isLiteral() &&
109 message.indexOf('\\\\') >= 0
110 ) {
111 throw messagePath.buildCodeFrameError(
112 '[React Intl] Message failed to parse. ' +
113 'It looks like `\\`s were used for escaping, ' +
114 "this won't work with JSX string literals. " +
115 'Wrap with `{}`. ' +
116 'See: http://facebook.github.io/react/docs/jsx-gotchas.html'
117 );
118 }
119
120 throw messagePath.buildCodeFrameError(
121 '[React Intl] Message failed to parse. ' +
122 'See: http://formatjs.io/guides/message-syntax/' +
123 `\n${parseError}`
124 );
125 }
126}
127
128function evaluatePath(path: NodePath): string {
129 const evaluated = path.evaluate();
130 if (evaluated.confident) {
131 return evaluated.value;
132 }
133
134 throw path.buildCodeFrameError(
135 '[React Intl] Messages must be statically evaluate-able for extraction.'
136 );
137}
138
139function getMessageDescriptorKey(path: NodePath) {
140 if (path.isIdentifier() || path.isJSXIdentifier()) {
141 return path.node.name;
142 }
143
144 return evaluatePath(path);
145}
146
147function getMessageDescriptorValue(
148 path?: NodePath<StringLiteral> | NodePath<JSXExpressionContainer>
149) {
150 if (!path) {
151 return '';
152 }
153 if (path.isJSXExpressionContainer()) {
154 path = path.get('expression') as NodePath<StringLiteral>;
155 }
156
157 // Always trim the Message Descriptor values.
158 const descriptorValue = evaluatePath(path);
159
160 if (typeof descriptorValue === 'string') {
161 return descriptorValue.trim();
162 }
163
164 return descriptorValue;
165}
166
167function createMessageDescriptor(
168 propPaths: [
169 NodePath<JSXIdentifier> | NodePath<Identifier>,
170 NodePath<StringLiteral> | NodePath<JSXExpressionContainer>
171 ][]
172): MessageDescriptorPath {
173 return propPaths.reduce(
174 (hash: MessageDescriptorPath, [keyPath, valuePath]) => {
175 const key = getMessageDescriptorKey(keyPath);
176
177 if (DESCRIPTOR_PROPS.has(key)) {
178 hash[key as 'id'] = valuePath as NodePath<StringLiteral>;
179 }
180
181 return hash;
182 },
183 {
184 id: undefined,
185 defaultMessage: undefined,
186 description: undefined
187 }
188 );
189}
190
191function evaluateMessageDescriptor(
192 descriptorPath: MessageDescriptorPath,
193 isJSXSource = false,
194 overrideIdFn?: Opts['overrideIdFn']
195) {
196 let id = getMessageDescriptorValue(descriptorPath.id);
197 const defaultMessage = getICUMessageValue(descriptorPath.defaultMessage, {
198 isJSXSource
199 });
200 const description = getMessageDescriptorValue(descriptorPath.description);
201
202 if (overrideIdFn) {
203 id = overrideIdFn(id, defaultMessage, description);
204 }
205 const descriptor: MessageDescriptor = {
206 id
207 };
208
209 if (description) {
210 descriptor.description = description;
211 }
212 if (defaultMessage) {
213 descriptor.defaultMessage = defaultMessage;
214 }
215
216 return descriptor;
217}
218
219function storeMessage(
220 { id, description, defaultMessage }: MessageDescriptor,
221 path: NodePath,
222 {
223 enforceDescriptions,
224 enforceDefaultMessage = true,
225 extractSourceLocation
226 }: Opts,
227 filename: string,
228 messages: Map<string, ExtractedMessageDescriptor>
229) {
230 if (!id || (enforceDefaultMessage && !defaultMessage)) {
231 throw path.buildCodeFrameError(
232 '[React Intl] Message Descriptors require an `id` and `defaultMessage`.'
233 );
234 }
235
236 if (messages.has(id)) {
237 const existing = messages.get(id);
238
239 if (
240 description !== existing!.description ||
241 defaultMessage !== existing!.defaultMessage
242 ) {
243 throw path.buildCodeFrameError(
244 `[React Intl] Duplicate message id: "${id}", ` +
245 'but the `description` and/or `defaultMessage` are different.'
246 );
247 }
248 }
249
250 if (enforceDescriptions) {
251 if (
252 !description ||
253 (typeof description === 'object' && Object.keys(description).length < 1)
254 ) {
255 throw path.buildCodeFrameError(
256 '[React Intl] Message must have a `description`.'
257 );
258 }
259 }
260
261 let loc = {};
262 if (extractSourceLocation) {
263 loc = {
264 file: p.relative(process.cwd(), filename),
265 ...path.node.loc
266 };
267 }
268
269 messages.set(id, { id, description, defaultMessage, ...loc });
270}
271
272function referencesImport(
273 path: NodePath,
274 mod: string,
275 importedNames: string[]
276) {
277 if (!(path.isIdentifier() || path.isJSXIdentifier())) {
278 return false;
279 }
280
281 return importedNames.some(name => path.referencesImport(mod, name));
282}
283
284function isFormatMessageCall(callee: NodePath<Expression>) {
285 if (!callee.isMemberExpression()) {
286 return false;
287 }
288
289 const object = callee.get('object');
290 const property = callee.get('property') as NodePath<Identifier>;
291
292 return (
293 property.isIdentifier() &&
294 property.node.name === 'formatMessage' &&
295 // things like `intl.formatMessage`
296 ((object.isIdentifier() && object.node.name === 'intl') ||
297 // things like `this.props.intl.formatMessage`
298 (object.isMemberExpression() &&
299 (object.get('property') as NodePath<Identifier>).node.name === 'intl'))
300 );
301}
302
303function assertObjectExpression(
304 path: NodePath,
305 callee: NodePath<Expression>
306): path is NodePath<ObjectExpression> {
307 if (!path || !path.isObjectExpression()) {
308 throw path.buildCodeFrameError(
309 `[React Intl] \`${
310 (callee.get('property') as NodePath<Identifier>).node.name
311 }()\` must be ` +
312 'called with an object expression with values ' +
313 'that are React Intl Message Descriptors, also ' +
314 'defined as object expressions.'
315 );
316 }
317 return true;
318}
319
320export default declare((api: any) => {
321 api.assertVersion(7);
322 /**
323 * Store this in the node itself so that multiple passes work. Specifically
324 * if we remove `description` in the 1st pass, 2nd pass will fail since
325 * it expect `description` to be there.
326 * HACK: We store this in the node instance since this persists across
327 * multiple plugin runs
328 */
329 function tagAsExtracted(path: NodePath) {
330 (path.node as any)[EXTRACTED] = true;
331 }
332
333 function wasExtracted(path: NodePath) {
334 return !!(path.node as any)[EXTRACTED];
335 }
336 return {
337 pre() {
338 if (!this.ReactIntlMessages) {
339 this.ReactIntlMessages = new Map();
340 }
341 },
342
343 post(state) {
344 const {
345 file: {
346 opts: { filename }
347 },
348 opts: { messagesDir }
349 } = this;
350 const basename = p.basename(filename, p.extname(filename));
351 const { ReactIntlMessages: messages } = this;
352 const descriptors = Array.from(messages.values());
353 state.metadata['react-intl'] = { messages: descriptors };
354
355 if (messagesDir && descriptors.length > 0) {
356 // Make sure the relative path is "absolute" before
357 // joining it with the `messagesDir`.
358 let relativePath = p.join(p.sep, p.relative(process.cwd(), filename));
359 // Solve when the window user has symlink on the directory, because
360 // process.cwd on windows returns the symlink root,
361 // and filename (from babel) returns the original root
362 if (process.platform === 'win32') {
363 const { name } = p.parse(process.cwd());
364 if (relativePath.includes(name)) {
365 relativePath = relativePath.slice(
366 relativePath.indexOf(name) + name.length
367 );
368 }
369 }
370
371 const messagesFilename = p.join(
372 messagesDir,
373 p.dirname(relativePath),
374 basename + '.json'
375 );
376
377 const messagesFile = JSON.stringify(descriptors, null, 2);
378
379 mkdirpSync(p.dirname(messagesFilename));
380 writeFileSync(messagesFilename, messagesFile);
381 }
382 },
383
384 visitor: {
385 JSXOpeningElement(
386 path,
387 {
388 opts,
389 file: {
390 opts: { filename }
391 }
392 }
393 ) {
394 const {
395 moduleSourceName = 'react-intl',
396 additionalComponentNames = [],
397 enforceDefaultMessage,
398 removeDefaultMessage,
399 overrideIdFn
400 } = opts;
401 if (wasExtracted(path)) {
402 return;
403 }
404
405 const name = path.get('name');
406
407 if (name.referencesImport(moduleSourceName, 'FormattedPlural')) {
408 if (path.node && path.node.loc)
409 console.warn(
410 `[React Intl] Line ${path.node.loc.start.line}: ` +
411 'Default messages are not extracted from ' +
412 '<FormattedPlural>, use <FormattedMessage> instead.'
413 );
414
415 return;
416 }
417
418 if (
419 name.isJSXIdentifier() &&
420 (referencesImport(name, moduleSourceName, DEFAULT_COMPONENT_NAMES) ||
421 additionalComponentNames.includes(name.node.name))
422 ) {
423 const attributes = path
424 .get('attributes')
425 .filter((attr): attr is NodePath<JSXAttribute> =>
426 attr.isJSXAttribute()
427 );
428
429 let descriptorPath = createMessageDescriptor(
430 attributes.map(attr => [
431 attr.get('name') as NodePath<JSXIdentifier>,
432 attr.get('value') as NodePath<StringLiteral>
433 ])
434 );
435
436 // In order for a default message to be extracted when
437 // declaring a JSX element, it must be done with standard
438 // `key=value` attributes. But it's completely valid to
439 // write `<FormattedMessage {...descriptor} />` or
440 // `<FormattedMessage id={dynamicId} />`, because it will be
441 // skipped here and extracted elsewhere. The descriptor will
442 // be extracted only if a `defaultMessage` prop exists and
443 // `enforceDefaultMessage` is `true`.
444 if (
445 enforceDefaultMessage === false ||
446 descriptorPath.defaultMessage
447 ) {
448 // Evaluate the Message Descriptor values in a JSX
449 // context, then store it.
450 const descriptor = evaluateMessageDescriptor(
451 descriptorPath,
452 true,
453 overrideIdFn
454 );
455
456 storeMessage(
457 descriptor,
458 path,
459 opts,
460 filename,
461 this.ReactIntlMessages
462 );
463
464 attributes.forEach(attr => {
465 const ketPath = attr.get('name');
466 const msgDescriptorKey = getMessageDescriptorKey(ketPath);
467 if (
468 // Remove description since it's not used at runtime.
469 msgDescriptorKey === 'description' ||
470 // Remove defaultMessage if opts says so.
471 (removeDefaultMessage && msgDescriptorKey === 'defaultMessage')
472 ) {
473 attr.remove();
474 } else if (
475 overrideIdFn &&
476 getMessageDescriptorKey(ketPath) === 'id'
477 ) {
478 attr.get('value').replaceWith(t.stringLiteral(descriptor.id));
479 }
480 });
481
482 // Tag the AST node so we don't try to extract it twice.
483 tagAsExtracted(path);
484 }
485 }
486 },
487
488 CallExpression(
489 path,
490 {
491 opts,
492 file: {
493 opts: { filename }
494 }
495 }
496 ) {
497 const { ReactIntlMessages: messages } = this;
498 const {
499 moduleSourceName = 'react-intl',
500 overrideIdFn,
501 removeDefaultMessage,
502 extractFromFormatMessageCall
503 } = opts;
504 const callee = path.get('callee');
505
506 /**
507 * Process MessageDescriptor
508 * @param messageDescriptor Message Descriptor
509 */
510 function processMessageObject(
511 messageDescriptor: NodePath<ObjectExpression>
512 ) {
513 assertObjectExpression(messageDescriptor, callee);
514
515 if (wasExtracted(messageDescriptor)) {
516 return;
517 }
518
519 const properties = messageDescriptor.get('properties') as NodePath<
520 ObjectProperty
521 >[];
522
523 const descriptorPath = createMessageDescriptor(
524 properties.map(
525 prop =>
526 [prop.get('key'), prop.get('value')] as [
527 NodePath<Identifier>,
528 NodePath<StringLiteral>
529 ]
530 )
531 );
532
533 // Evaluate the Message Descriptor values, then store it.
534 const descriptor = evaluateMessageDescriptor(
535 descriptorPath,
536 false,
537 overrideIdFn
538 );
539 storeMessage(descriptor, messageDescriptor, opts, filename, messages);
540
541 // Remove description since it's not used at runtime.
542 messageDescriptor.replaceWith(
543 t.objectExpression([
544 t.objectProperty(
545 t.stringLiteral('id'),
546 t.stringLiteral(descriptor.id)
547 ),
548 ...(!removeDefaultMessage && descriptor.defaultMessage
549 ? [
550 t.objectProperty(
551 t.stringLiteral('defaultMessage'),
552 t.stringLiteral(descriptor.defaultMessage)
553 )
554 ]
555 : [])
556 ])
557 );
558
559 // Tag the AST node so we don't try to extract it twice.
560 tagAsExtracted(messageDescriptor);
561 }
562
563 // Check that this is `defineMessages` call
564 if (referencesImport(callee, moduleSourceName, FUNCTION_NAMES)) {
565 const messagesObj = path.get('arguments')[0];
566
567 if (assertObjectExpression(messagesObj, callee)) {
568 messagesObj
569 .get('properties')
570 .map(prop => prop.get('value') as NodePath<ObjectExpression>)
571 .forEach(processMessageObject);
572 }
573 }
574
575 // Check that this is `intl.formatMessage` call
576 if (extractFromFormatMessageCall && isFormatMessageCall(callee)) {
577 const messageDescriptor = path.get('arguments')[0];
578 if (messageDescriptor.isObjectExpression()) {
579 processMessageObject(messageDescriptor);
580 }
581 }
582 }
583 }
584 } as PluginObj<PluginPass<Opts> & State>;
585});