Skip to content
This repository was archived by the owner on Jun 8, 2019. It is now read-only.

Commit c75b461

Browse files
committed
Merge pull request #4 from yahoo/mutation
Stabilize message, new option, switch to defineMessages
2 parents c83352a + d63bfc8 commit c75b461

File tree

4 files changed

+177
-53
lines changed

4 files changed

+177
-53
lines changed

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,18 @@ $ npm install babel-plugin-react-intl
2121
"plugins": ["react-intl"],
2222
"extra": {
2323
"react-intl": {
24-
"messagesDir": "./build/messages/"
24+
"messagesDir": "./build/messages/",
25+
"enforceDescriptions": true
2526
}
2627
}
2728
}
2829
```
2930

30-
The `messagesDir` option is the target location where the plugin will output a `.json` file corresponding to each component from which React Intl messages were extracted.
31+
#### Options
32+
33+
- **`messagesDir`**: The target location where the plugin will output a `.json` file corresponding to each component from which React Intl messages were extracted.
34+
35+
- **`enforceDescriptions`**: Whether or not message declarations _must_ contain a `description` to provide context to translators.
3136

3237
### Via CLI
3338

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"author": "Eric Ferraiuolo <[email protected]>",
1212
"dependencies": {
1313
"babel-runtime": "^5.8.20",
14+
"intl-messageformat-parser": "^1.1.0",
1415
"mkdirp": "^0.5.1"
1516
},
1617
"devDependencies": {

src/index.js

Lines changed: 106 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -7,47 +7,27 @@
77
import * as p from 'path';
88
import {writeFileSync} from 'fs';
99
import {sync as mkdirpSync} from 'mkdirp';
10+
import printICUMessage from './print-icu-message';
1011

1112
const COMPONENT_NAMES = [
1213
'FormattedMessage',
1314
'FormattedHTMLMessage',
1415
];
1516

1617
const FUNCTION_NAMES = [
17-
'defineMessage',
18+
'defineMessages',
1819
];
1920

2021
const IMPORTED_NAMES = new Set([...COMPONENT_NAMES, ...FUNCTION_NAMES]);
2122
const DESCRIPTOR_PROPS = new Set(['id', 'description', 'defaultMessage']);
2223

23-
export default function ({Plugin}) {
24-
function getModuleSourceName(options) {
25-
const reactIntlOptions = options.extra['react-intl'] || {};
26-
return reactIntlOptions.moduleSourceName || 'react-intl';
27-
}
28-
29-
function getMessagesDir(options) {
30-
const reactIntlOptions = options.extra['react-intl'] || {};
31-
return reactIntlOptions.messagesDir;
24+
export default function ({Plugin, types: t}) {
25+
function getReactIntlOptions(options) {
26+
return options.extra['react-intl'] || {};
3227
}
3328

34-
function getMessageDescriptor(propertiesMap) {
35-
// Force property order on descriptors.
36-
let descriptor = [...DESCRIPTOR_PROPS].reduce((descriptor, key) => {
37-
descriptor[key] = undefined;
38-
return descriptor;
39-
}, {});
40-
41-
for (let [key, value] of propertiesMap) {
42-
key = getMessageDescriptorKey(key);
43-
44-
if (DESCRIPTOR_PROPS.has(key)) {
45-
// TODO: Should this be trimming values?
46-
descriptor[key] = getMessageDescriptorValue(value).trim();
47-
}
48-
}
49-
50-
return descriptor;
29+
function getModuleSourceName(options) {
30+
return getReactIntlOptions(options).moduleSourceName || 'react-intl';
5131
}
5232

5333
function getMessageDescriptorKey(path) {
@@ -84,9 +64,38 @@ export default function ({Plugin}) {
8464
);
8565
}
8666

87-
function storeMessage(descriptor, node, file) {
88-
const {id} = descriptor;
89-
const {messages} = file.get('react-intl');
67+
function createMessageDescriptor(propPaths) {
68+
return propPaths.reduce((hash, [keyPath, valuePath]) => {
69+
let key = getMessageDescriptorKey(keyPath);
70+
71+
if (DESCRIPTOR_PROPS.has(key)) {
72+
let value = getMessageDescriptorValue(valuePath).trim();
73+
74+
if (key === 'defaultMessage') {
75+
try {
76+
hash[key] = printICUMessage(value);
77+
} catch (e) {
78+
throw valuePath.errorWithNode(
79+
`[React Intl] Message failed to parse: ${e} ` +
80+
'See: http://formatjs.io/guides/message-syntax/'
81+
);
82+
}
83+
} else {
84+
hash[key] = value;
85+
}
86+
}
87+
88+
return hash;
89+
}, {});
90+
}
91+
92+
function createPropNode(key, value) {
93+
return t.property('init', t.literal(key), t.literal(value));
94+
}
95+
96+
function storeMessage({id, description, defaultMessage}, node, file) {
97+
const {enforceDescriptions} = getReactIntlOptions(file.opts);
98+
const {messages} = file.get('react-intl');
9099

91100
if (!id) {
92101
throw file.errorWithNode(node,
@@ -95,22 +104,35 @@ export default function ({Plugin}) {
95104
}
96105

97106
if (messages.has(id)) {
98-
throw file.errorWithNode(node,
99-
`[React Intl] Duplicate message id: "${id}"`
100-
);
107+
let existing = messages.get(id);
108+
109+
if (description !== existing.description ||
110+
defaultMessage !== existing.defaultMessage) {
111+
112+
throw file.errorWithNode(node,
113+
`[React Intl] Duplicate message id: "${id}", ` +
114+
'but the `description` and/or `defaultMessage` are different.'
115+
);
116+
}
101117
}
102118

103-
if (!descriptor.defaultMessage) {
119+
if (!defaultMessage) {
104120
let {loc} = node;
105121
file.log.warn(
106122
`[React Intl] Line ${loc.start.line}: ` +
107-
`Message "${id}" is missing a \`defaultMessage\` ` +
108-
`and will not be extracted.`
123+
'Message is missing a `defaultMessage` and will not be extracted.'
109124
);
125+
110126
return;
111127
}
112128

113-
messages.set(id, descriptor);
129+
if (enforceDescriptions && !description) {
130+
throw file.errorWithNode(node,
131+
'[React Intl] Message must have a `description`.'
132+
);
133+
}
134+
135+
messages.set(id, {id, description, defaultMessage});
114136
}
115137

116138
function referencesImport(path, mod, importedNames) {
@@ -147,7 +169,7 @@ export default function ({Plugin}) {
147169

148170
exit(node, parent, scope, file) {
149171
const {messages} = file.get('react-intl');
150-
const messagesDir = getMessagesDir(file.opts);
172+
const {messagesDir} = getReactIntlOptions(file.opts);
151173
const {basename, filename} = file.opts;
152174

153175
let descriptors = [...messages.values()];
@@ -170,46 +192,79 @@ export default function ({Plugin}) {
170192

171193
JSXOpeningElement(node, parent, scope, file) {
172194
const moduleSourceName = getModuleSourceName(file.opts);
195+
173196
let name = this.get('name');
174197

175198
if (referencesImport(name, moduleSourceName, COMPONENT_NAMES)) {
176199
let attributes = this.get('attributes')
177-
.filter((attr) => attr.isJSXAttribute())
178-
.map((attr) => [attr.get('name'), attr.get('value')]);
200+
.filter((attr) => attr.isJSXAttribute());
179201

180-
let descriptor = getMessageDescriptor(new Map(attributes));
202+
let descriptor = createMessageDescriptor(
203+
attributes.map((attr) => [
204+
attr.get('name'),
205+
attr.get('value'),
206+
])
207+
);
181208

182209
// In order for a default message to be extracted when
183210
// declaring a JSX element, it must be done with standard
184211
// `key=value` attributes. But it's completely valid to
185212
// write `<FormattedMessage {...descriptor} />`, because it
186-
// will be skipped here and extracted elsewhere.
187-
if (descriptor.id) {
213+
// will be skipped here and extracted elsewhere. When
214+
// _either_ an `id` or `defaultMessage` prop exists, the
215+
// descriptor will be checked; this way mixing an object
216+
// spread with props will fail.
217+
if (descriptor.id || descriptor.defaultMessage) {
188218
storeMessage(descriptor, node, file);
219+
220+
attributes
221+
.filter((attr) => {
222+
let keyPath = attr.get('name');
223+
let key = getMessageDescriptorKey(keyPath);
224+
return key === 'description';
225+
})
226+
.forEach((attr) => attr.dangerouslyRemove());
189227
}
190228
}
191229
},
192230

193231
CallExpression(node, parent, scope, file) {
194232
const moduleSourceName = getModuleSourceName(file.opts);
195233

196-
let callee = this.get('callee');
197-
198-
if (referencesImport(callee, moduleSourceName, FUNCTION_NAMES)) {
199-
let messageArg = this.get('arguments')[0];
200-
if (!(messageArg && messageArg.isObjectExpression())) {
234+
function processMessageObject(messageObj) {
235+
if (!(messageObj && messageObj.isObjectExpression())) {
201236
throw file.errorWithNode(node,
202237
`[React Intl] \`${callee.node.name}()\` must be ` +
203238
`called with message descriptor defined via an ` +
204239
`object expression.`
205240
);
206241
}
207242

208-
let properties = messageArg.get('properties')
209-
.map((prop) => [prop.get('key'), prop.get('value')]);
243+
let properties = messageObj.get('properties');
244+
245+
let descriptor = createMessageDescriptor(
246+
properties.map((prop) => [
247+
prop.get('key'),
248+
prop.get('value'),
249+
])
250+
);
210251

211-
let descriptor = getMessageDescriptor(new Map(properties));
212252
storeMessage(descriptor, node, file);
253+
254+
messageObj.replaceWith(t.objectExpression([
255+
createPropNode('id', descriptor.id),
256+
createPropNode('defaultMessage', descriptor.defaultMessage),
257+
]));
258+
}
259+
260+
let callee = this.get('callee');
261+
262+
if (referencesImport(callee, moduleSourceName, FUNCTION_NAMES)) {
263+
let firstArg = this.get('arguments')[0];
264+
265+
firstArg.get('properties')
266+
.map((prop) => prop.get('value'))
267+
.forEach(processMessageObject);
213268
}
214269
},
215270
},

src/print-icu-message.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Copyright 2015, Yahoo Inc.
3+
* Copyrights licensed under the New BSD License.
4+
* See the accompanying LICENSE file for terms.
5+
*/
6+
7+
import {parse} from 'intl-messageformat-parser';
8+
9+
export default function (message) {
10+
let ast = parse(message);
11+
return printICUMessage(ast);
12+
}
13+
14+
function printICUMessage(ast) {
15+
let printedNodes = ast.elements.map((node) => {
16+
if (node.type === 'messageTextElement') {
17+
return node.value;
18+
}
19+
20+
if (!node.format) {
21+
return `{${node.id}}`;
22+
}
23+
24+
switch (getArgumentType(node.format.type)) {
25+
case 'number':
26+
case 'date':
27+
case 'time':
28+
return printSimpleFormatASTNode(node);
29+
30+
case 'plural':
31+
case 'selectordinal':
32+
case 'select':
33+
return printOptionalFormatASTNode(node);
34+
}
35+
});
36+
37+
return printedNodes.join('');
38+
}
39+
40+
function getArgumentType(astType) {
41+
return astType.replace(/Format$/, '').toLowerCase();
42+
}
43+
44+
function printSimpleFormatASTNode(node) {
45+
let {id, format} = node;
46+
let argumentType = getArgumentType(format.type);
47+
let style = format.style ? `, ${format.style}` : '';
48+
49+
return `{${id}, ${argumentType}${style}}`;
50+
}
51+
52+
function printOptionalFormatASTNode(node) {
53+
let {id, format} = node;
54+
let argumentType = getArgumentType(format.type);
55+
let offset = format.offset ? `, offset:${format.offset}` : '';
56+
57+
let options = format.options.map((option) => {
58+
let optionValue = printICUMessage(option.value);
59+
return ` ${option.selector} {${optionValue}}`;
60+
});
61+
62+
return `{${id}, ${argumentType}${offset},${options.join('')}}`;
63+
}

0 commit comments

Comments
 (0)