UNPKG

7.81 kBJavaScriptView Raw
1/*
2 *
3 * Message Validation
4 *
5 */
6
7
8import {
9 attributeName,
10 convertToNamespacedName,
11 elementAttributes,
12 elementName,
13 elementNamespaceOrName,
14 i18nId,
15 idOrComponentName,
16 hasI18nId,
17 isComponent,
18 isElementMarker,
19 isSimpleExpression,
20 isTag
21} from './ast';
22import {assertInput, assertUnique} from './errors';
23import generate from './generation';
24import {options} from './options';
25const whitelisting = require('./whitelisting');
26
27
28function assertSameCounts(original, translated, msg) {
29 Object.keys(original).concat(Object.keys(translated)).forEach(key => {
30 if (translated[key] !== original[key]) {
31 throw new Error(msg + ` (original[${key}]=${original[key]} translated[${key}]=${translated[key]})`);
32 }
33 });
34}
35
36
37export function sanitizedAttributesOf(element) {
38 return validateMessage(element).componentsToSanitizedAttributes;
39}
40
41export function validateTranslation(original, translation) {
42 const ogContext = validateMessage(original);
43 const trContext = validateMessage(translation);
44
45 assertSameCounts(
46 ogContext.componentCounts,
47 trContext.componentCounts,
48 "Found a differing number of components in translation from original"
49 );
50
51 assertSameCounts(
52 ogContext.i18nIds,
53 trContext.i18nIds,
54 "Found a differing number of components with same i18n-id in translation from original"
55 );
56
57 assertSameCounts(
58 ogContext.namedExpressionDefinitions,
59 trContext.namedExpressionDefinitions,
60 "Found a differing number of named expressions in translation from original"
61 );
62}
63
64export function validateMessage(element) {
65 let context = {
66 root: element,
67 componentsWithoutIds: {},
68 componentsToSanitizedAttributes: {},
69 i18nIds: {},
70 componentCounts: {},
71 namedExpressionDefinitions: {},
72 };
73
74 validateChildren(element.children, context);
75 assertUniqueComponents(context);
76
77 return context;
78}
79
80function assertUniqueComponents(context) {
81 let duplicated = [];
82 Object.keys(context.componentsWithoutIds).forEach(name => {
83 if (context.componentsWithoutIds[name] > 1) {
84 duplicated.push(name);
85 }
86 });
87 if (duplicated.length) {
88 throw new Error("Missing required i18n-id on duplicated component(s) " + duplicated.join(', '));
89 }
90}
91
92function assertI18nId(element) {
93 let openingElement = element.openingElement;
94 if (openingElement.name.type !== 'JSXNamespacedName'
95 && !i18nId(element)) {
96 throw new Error('Element missing required i18n-id: ' + generate(openingElement));
97 }
98}
99
100function validateJSXElement(element, context) {
101 if (isElementMarker(element)) {
102 // TODO: unified error handling showing source of exception
103 // and context, including line/character positions.
104 throw new Error("Found a nested element marker in " + generate(context.root));
105 }
106 if (whitelisting.hasUnsafeAttributes(element)) {
107 if (isTag(element)) {
108 assertI18nId(element);
109 } else if (isComponent(element)) {
110 let componentId = i18nId(element);
111 if (!componentId) {
112 componentId = elementName(element);
113 incrementKey(context.componentsWithoutIds, componentId);
114 }
115 }
116 context.componentsToSanitizedAttributes[idOrComponentName(element)] = whitelisting.sanitizedAttributes(element);
117 }
118
119 if (isComponent(element) || i18nId(element)) {
120 incrementKey(context.i18nIds, idOrComponentName(element));
121 }
122
123 if (isComponent(element)) {
124 incrementKey(context.componentCounts, elementNamespaceOrName(element));
125 }
126
127 validateChildren(element.children, context);
128}
129
130function validateJSXExpressionContainer(container, context) {
131 return container.type === 'Identifier'
132 || container.type === 'ThisExpression'
133 || (container.type === 'MemberExpression'
134 && container.computed === false
135 && validateJSXExpressionContainer(container.object, context));
136}
137
138function validateChildren(children, context) {
139 children.forEach(child => {
140 switch(child.type) {
141 case 'JSXElement':
142 validateJSXElement(child, context);
143 break;
144
145 case 'JSXExpressionContainer':
146 validateJSXExpressionContainer(child, context);
147 incrementKey(context.namedExpressionDefinitions, generate(child));
148 break;
149 }
150 });
151}
152
153
154
155
156
157
158import {whitelist} from './options';
159
160export function validateFunctionMessage(callExpression) {
161 assertInput(
162 callExpression.arguments.length === 1,
163 `Expected exactly 1 argument to ${options.functionMarker}(), but got ${callExpression.arguments.length}`,
164 callExpression
165 );
166
167 assertInput(
168 callExpression.arguments[0].type === 'StringLiteral',
169 `Expected a StringLiteral argument to ${options.functionMarker}(), but got ${callExpression.arguments[0].type}`,
170 callExpression
171 );
172}
173
174
175function incrementKey(map, key) {
176 map[key] = (map[key] || 0) + 1;
177}
178
179
180const ExtractionValidationVisitor = {
181 JSXElement(path) {
182 // prevent nested markers
183 assertInput(!isElementMarker(path.node),
184 "Found a nested message marker",
185 path.node
186 );
187
188 const elementName = convertToNamespacedName(path.node);
189 if (hasUnsafeAttributes(path.node)) {
190 if (isComponent(path.node)) {
191 // keep track of custom components to ensure there are no duplicates
192 incrementKey(this.validationContext.componentNamesAndIds, elementName);
193 } else {
194 // tags with sanitized attributes must have an i18n-id or namespace
195 assertInput(
196 hasI18nId(path.node),
197 "Found a tag with sanitized attributes with no i18n-id",
198 path.node
199 );
200 }
201 }
202 },
203
204 JSXAttribute(path) {
205 // technically part of sanitization, but visitors are merged
206 // for performance
207 if (attributeIsSanitized(path.parentPath.parent, path.node)) {
208 path.remove();
209 }
210 },
211
212 JSXSpreadAttribute(path) {
213 path.remove();
214 },
215
216 JSXExpressionContainer(path) {
217 if (path.parent.type === 'JSXElement') {
218 assertInput(isSimpleExpression(path.node.expression),
219 "Only identifiers and simple member expressions (foo.bar, " +
220 "this.that.other) are allowed in <I18N> tags.",
221 path.node
222 );
223 }
224 },
225};
226
227export function hasUnsafeAttributes(jsxElement) {
228 return elementAttributes(jsxElement).some(a => attributeIsSanitized(jsxElement, a));
229}
230
231function attributeIsSanitized(element, attribute) {
232 if (attribute.type === 'JSXSpreadAttribute') {
233 return true;
234 }
235
236 const name = elementNamespaceOrName(element);
237 const whitelistedAttributes = whitelist[name] || whitelist['*'];
238 return (
239 !(whitelistedAttributes.indexOf(attributeName(attribute)) !== -1) ||
240 attribute.value.type !== 'StringLiteral'
241 );
242}
243
244function validateElementContext(validationContext) {
245 assertUnique(
246 validationContext.componentNamesAndIds,
247 'Found the following duplicate elements/components',
248 validationContext.root
249 );
250}
251
252export function validateAndSanitizeElement(jsxElementPath) {
253 // Traverse with state of validationContext
254 const validationContext = {
255 root: jsxElementPath.node,
256 componentNamesAndIds: {}
257 };
258 jsxElementPath.traverse(ExtractionValidationVisitor, {validationContext});
259 validateElementContext(validationContext);
260}