1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 | import {
|
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';
|
22 | import {assertInput, assertUnique} from './errors';
|
23 | import generate from './generation';
|
24 | import {options} from './options';
|
25 | const whitelisting = require('./whitelisting');
|
26 |
|
27 |
|
28 | function 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 |
|
37 | export function sanitizedAttributesOf(element) {
|
38 | return validateMessage(element).componentsToSanitizedAttributes;
|
39 | }
|
40 |
|
41 | export 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 |
|
64 | export 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 |
|
80 | function 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 |
|
92 | function 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 |
|
100 | function validateJSXElement(element, context) {
|
101 | if (isElementMarker(element)) {
|
102 |
|
103 |
|
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 |
|
130 | function 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 |
|
138 | function 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 |
|
158 | import {whitelist} from './options';
|
159 |
|
160 | export 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 |
|
175 | function incrementKey(map, key) {
|
176 | map[key] = (map[key] || 0) + 1;
|
177 | }
|
178 |
|
179 |
|
180 | const ExtractionValidationVisitor = {
|
181 | JSXElement(path) {
|
182 |
|
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 |
|
192 | incrementKey(this.validationContext.componentNamesAndIds, elementName);
|
193 | } else {
|
194 |
|
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 |
|
206 |
|
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 |
|
227 | export function hasUnsafeAttributes(jsxElement) {
|
228 | return elementAttributes(jsxElement).some(a => attributeIsSanitized(jsxElement, a));
|
229 | }
|
230 |
|
231 | function 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 |
|
244 | function validateElementContext(validationContext) {
|
245 | assertUnique(
|
246 | validationContext.componentNamesAndIds,
|
247 | 'Found the following duplicate elements/components',
|
248 | validationContext.root
|
249 | );
|
250 | }
|
251 |
|
252 | export function validateAndSanitizeElement(jsxElementPath) {
|
253 |
|
254 | const validationContext = {
|
255 | root: jsxElementPath.node,
|
256 | componentNamesAndIds: {}
|
257 | };
|
258 | jsxElementPath.traverse(ExtractionValidationVisitor, {validationContext});
|
259 | validateElementContext(validationContext);
|
260 | }
|