1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 | import * as p from 'path';
|
8 | import { writeFileSync } from 'fs';
|
9 | import { mkdirpSync } from 'fs-extra';
|
10 | import { printAST, parse } from 'intl-messageformat-parser';
|
11 | const { declare } = require('@babel/helper-plugin-utils') as any;
|
12 | import { types as t, PluginObj } from '@babel/core';
|
13 | import {
|
14 | ObjectExpression,
|
15 | JSXAttribute,
|
16 | StringLiteral,
|
17 | JSXIdentifier,
|
18 | JSXExpressionContainer,
|
19 | Identifier,
|
20 | ObjectProperty,
|
21 | SourceLocation,
|
22 | Expression
|
23 | } from '@babel/types';
|
24 | import { NodePath } from '@babel/traverse';
|
25 |
|
26 | const DEFAULT_COMPONENT_NAMES = ['FormattedMessage', 'FormattedHTMLMessage'];
|
27 |
|
28 | const FUNCTION_NAMES = ['defineMessages'];
|
29 | const EXTRACTED = Symbol('ReactIntlExtracted');
|
30 | const DESCRIPTOR_PROPS = new Set(['id', 'description', 'defaultMessage']);
|
31 |
|
32 | interface MessageDescriptor {
|
33 | id: string;
|
34 | defaultMessage?: string;
|
35 | description?: string;
|
36 | }
|
37 |
|
38 | type ExtractedMessageDescriptor = MessageDescriptor &
|
39 | Partial<SourceLocation> & { file?: string };
|
40 |
|
41 | type MessageDescriptorPath = Record<
|
42 | keyof MessageDescriptor,
|
43 | NodePath<StringLiteral> | undefined
|
44 | >;
|
45 |
|
46 | export 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 |
|
59 | interface PluginPass<O> {
|
60 | key?: string;
|
61 | file: BabelTransformationFile;
|
62 | opts: O;
|
63 | cwd: string;
|
64 | filename?: string;
|
65 | }
|
66 |
|
67 | interface 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 |
|
90 | interface State {
|
91 | ReactIntlMessages: Map<string, ExtractedMessageDescriptor>;
|
92 | }
|
93 |
|
94 | function 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 |
|
128 | function 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 |
|
139 | function getMessageDescriptorKey(path: NodePath) {
|
140 | if (path.isIdentifier() || path.isJSXIdentifier()) {
|
141 | return path.node.name;
|
142 | }
|
143 |
|
144 | return evaluatePath(path);
|
145 | }
|
146 |
|
147 | function 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 |
|
158 | const descriptorValue = evaluatePath(path);
|
159 |
|
160 | if (typeof descriptorValue === 'string') {
|
161 | return descriptorValue.trim();
|
162 | }
|
163 |
|
164 | return descriptorValue;
|
165 | }
|
166 |
|
167 | function 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 |
|
191 | function 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 |
|
219 | function 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 |
|
272 | function 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 |
|
284 | function 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 |
|
296 | ((object.isIdentifier() && object.node.name === 'intl') ||
|
297 |
|
298 | (object.isMemberExpression() &&
|
299 | (object.get('property') as NodePath<Identifier>).node.name === 'intl'))
|
300 | );
|
301 | }
|
302 |
|
303 | function 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 |
|
320 | export default declare((api: any) => {
|
321 | api.assertVersion(7);
|
322 | |
323 |
|
324 |
|
325 |
|
326 |
|
327 |
|
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 |
|
357 |
|
358 | let relativePath = p.join(p.sep, p.relative(process.cwd(), filename));
|
359 |
|
360 |
|
361 |
|
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 |
|
437 |
|
438 |
|
439 |
|
440 |
|
441 |
|
442 |
|
443 |
|
444 | if (
|
445 | enforceDefaultMessage === false ||
|
446 | descriptorPath.defaultMessage
|
447 | ) {
|
448 |
|
449 |
|
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 |
|
469 | msgDescriptorKey === 'description' ||
|
470 |
|
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 |
|
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 |
|
508 |
|
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 |
|
534 | const descriptor = evaluateMessageDescriptor(
|
535 | descriptorPath,
|
536 | false,
|
537 | overrideIdFn
|
538 | );
|
539 | storeMessage(descriptor, messageDescriptor, opts, filename, messages);
|
540 |
|
541 |
|
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 |
|
560 | tagAsExtracted(messageDescriptor);
|
561 | }
|
562 |
|
563 |
|
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 |
|
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 | });
|