UNPKG

24.4 kBJavaScriptView Raw
1'use strict';
2const cheerio = require('cheerio');
3const _ = require('lodash');
4const esprima = require('esprima');
5const escodegen = require('escodegen');
6const reactDOMSupport = require('./reactDOMSupport');
7const reactNativeSupport = require('./reactNativeSupport');
8const reactPropTemplates = require('./reactPropTemplates');
9const rtError = require('./RTCodeError');
10const reactSupport = require('./reactSupport');
11const templates = reactSupport.templates;
12const utils = require('./utils');
13const validateJS = utils.validateJS;
14const RTCodeError = rtError.RTCodeError;
15
16const repeatTemplate = _.template('_.map(<%= collection %>,<%= repeatFunction %>.bind(<%= repeatBinds %>))');
17const ifTemplate = _.template('((<%= condition %>)?(<%= body %>):null)');
18const propsTemplateSimple = _.template('_.assign({}, <%= generatedProps %>, <%= rtProps %>)');
19const propsTemplate = _.template('mergeProps( <%= generatedProps %>, <%= rtProps %>)');
20
21const propsMergeFunction = `function mergeProps(inline,external) {
22 var res = _.assign({},inline,external)
23 if (inline.hasOwnProperty('style')) {
24 res.style = _.defaults(res.style, inline.style);
25 }
26 if (inline.hasOwnProperty('className') && external.hasOwnProperty('className')) {
27 res.className = external.className + ' ' + inline.className;
28 }
29 return res;
30}
31`;
32
33const classSetTemplate = _.template('_(<%= classSet %>).transform(function(res, value, key){ if(value){ res.push(key); } }, []).join(" ")');
34
35function getTagTemplateString(simpleTagTemplate, shouldCreateElement) {
36 if (simpleTagTemplate) {
37 return shouldCreateElement ? 'React.createElement(<%= name %>,<%= props %><%= children %>)' : '<%= name %>(<%= props %><%= children %>)';
38 }
39 return shouldCreateElement ? 'React.createElement.apply(this, [<%= name %>,<%= props %><%= children %>])' : '<%= name %>.apply(this, [<%= props %><%= children %>])';
40}
41
42
43const commentTemplate = _.template(' /* <%= data %> */ ');
44
45const repeatAttr = 'rt-repeat';
46const ifAttr = 'rt-if';
47const classSetAttr = 'rt-class';
48const classAttr = 'class';
49const scopeAttr = 'rt-scope';
50const propsAttr = 'rt-props';
51const templateNode = 'rt-template';
52const virtualNode = 'rt-virtual';
53const includeNode = 'rt-include';
54const includeSrcAttr = 'src';
55const requireAttr = 'rt-require';
56const importAttr = 'rt-import';
57const statelessAttr = 'rt-stateless';
58
59const reactTemplatesSelfClosingTags = [includeNode];
60
61/**
62 * @param {Options} options
63 * @return {Options}
64 */
65function getOptions(options) {
66 options = options || {};
67 const defaultOptions = {
68 version: false,
69 force: false,
70 format: 'stylish',
71 targetVersion: reactDOMSupport.default,
72 lodashImportPath: 'lodash',
73 native: false,
74 nativeTargetVersion: reactNativeSupport.default
75 };
76
77 const finalOptions = _.defaults({}, options, defaultOptions);
78 finalOptions.reactImportPath = finalOptions.reactImportPath || reactImport(finalOptions);
79 finalOptions.modules = finalOptions.modules || (finalOptions.native ? 'commonjs' : 'amd');
80
81 const defaultPropTemplates = finalOptions.native ?
82 reactPropTemplates.native[finalOptions.nativeTargetVersion] :
83 reactPropTemplates.dom[finalOptions.targetVersion];
84
85 finalOptions.propTemplates = _.defaults({}, options.propTemplates, defaultPropTemplates);
86 return finalOptions;
87}
88
89function reactImport(options) {
90 if (options.native) {
91 return 'react-native';
92 }
93 if (options.targetVersion === '0.14.0' || options.targetVersion === '0.15.0' || options.targetVersion === '15.0.0' || options.targetVersion === '15.0.1') {
94 return 'react';
95 }
96 return 'react/addons';
97}
98
99/**
100 * @param {Context} context
101 * @param {string} namePrefix
102 * @param {string} body
103 * @param {*?} params
104 * @return {string}
105 */
106function generateInjectedFunc(context, namePrefix, body, params) {
107 params = params || context.boundParams;
108 const funcName = namePrefix.replace(',', '') + (context.injectedFunctions.length + 1);
109 const funcText = `function ${funcName}(${params.join(',')}) {
110 ${body}
111 }
112 `;
113 context.injectedFunctions.push(funcText);
114 return funcName;
115}
116
117function generateTemplateProps(node, context) {
118 const propTemplateDefinition = context.options.propTemplates[node.name];
119 const propertiesTemplates = _(node.children)
120 .map((child, index) => {
121 let templateProp = null;
122 if (child.name === templateNode) { // Generic explicit template tag
123 if (!_.has(child.attribs, 'prop')) {
124 throw RTCodeError.build(context, child, 'rt-template must have a prop attribute');
125 }
126
127 const childTemplate = _.find(context.options.propTemplates, {prop: child.attribs.prop}) || {arguments: []};
128 templateProp = {
129 prop: child.attribs.prop,
130 arguments: (child.attribs.arguments ? child.attribs.arguments.split(',') : childTemplate.arguments) || []
131 };
132 } else if (propTemplateDefinition && propTemplateDefinition[child.name]) { // Implicit child template from configuration
133 templateProp = {
134 prop: propTemplateDefinition[child.name].prop,
135 arguments: child.attribs.arguments ? child.attribs.arguments.split(',') : propTemplateDefinition[child.name].arguments
136 };
137 }
138
139 if (templateProp) {
140 _.assign(templateProp, {childIndex: index, content: _.find(child.children, {type: 'tag'})});
141 }
142
143 return templateProp;
144 })
145 .compact()
146 .value();
147
148 return _.transform(propertiesTemplates, (props, templateProp) => {
149 const functionParams = _.values(context.boundParams).concat(templateProp.arguments);
150
151 const oldBoundParams = context.boundParams;
152 context.boundParams = context.boundParams.concat(templateProp.arguments);
153
154 const functionBody = 'return ' + convertHtmlToReact(templateProp.content, context);
155 context.boundParams = oldBoundParams;
156
157 const generatedFuncName = generateInjectedFunc(context, templateProp.prop, functionBody, functionParams);
158 props[templateProp.prop] = genBind(generatedFuncName, _.values(context.boundParams));
159
160 // Remove the template child from the children definition.
161 node.children.splice(templateProp.childIndex, 1);
162 }, {});
163}
164
165/**
166 * @param node
167 * @param {Context} context
168 * @return {string}
169 */
170function generateProps(node, context) {
171 const props = {};
172 _.forOwn(node.attribs, (val, key) => {
173 const propKey = reactSupport.attributesMapping[key.toLowerCase()] || key;
174 if (props.hasOwnProperty(propKey) && propKey !== reactSupport.classNameProp) {
175 throw RTCodeError.build(context, node, `duplicate definition of ${propKey} ${JSON.stringify(node.attribs)}`);
176 }
177 if (_.startsWith(key, 'on') && !utils.isStringOnlyCode(val)) {
178 props[propKey] = handleEventHandler(val, context, node, key);
179 } else if (key === 'style' && !utils.isStringOnlyCode(val)) {
180 props[propKey] = handleStyleProp(val, node, context);
181 } else if (propKey === reactSupport.classNameProp) {
182 // Processing for both class and rt-class conveniently return strings that
183 // represent JS expressions, each evaluating to a space-separated set of class names.
184 // We can just join them with another space here.
185 const existing = props[propKey] ? `${props[propKey]} + " " + ` : '';
186 if (key === classSetAttr) {
187 props[propKey] = existing + classSetTemplate({classSet: val});
188 } else if (key === classAttr || key === reactSupport.classNameProp) {
189 props[propKey] = existing + utils.convertText(node, context, val.trim());
190 }
191 } else if (!_.startsWith(key, 'rt-')) {
192 props[propKey] = utils.convertText(node, context, val.trim());
193 }
194 });
195 _.assign(props, generateTemplateProps(node, context));
196
197 const propStr = _.map(props, (v, k) => `${JSON.stringify(k)} : ${v}`).join(',');
198 return `{${propStr}}`;
199}
200
201function handleEventHandler(val, context, node, key) {
202 const funcParts = val.split('=>');
203 if (funcParts.length !== 2) {
204 throw RTCodeError.build(context, node, `when using 'on' events, use lambda '(p1,p2)=>body' notation or use {} to return a callback function. error: [${key}='${val}']`);
205 }
206 const evtParams = funcParts[0].replace('(', '').replace(')', '').trim();
207 const funcBody = funcParts[1].trim();
208 let params = context.boundParams;
209 if (evtParams.trim() !== '') {
210 params = params.concat([evtParams.trim()]);
211 }
212 const generatedFuncName = generateInjectedFunc(context, key, funcBody, params);
213 return genBind(generatedFuncName, context.boundParams);
214}
215
216function genBind(func, args) {
217 const bindArgs = ['this'].concat(args);
218 return `${func}.bind(${bindArgs.join(',')})`;
219}
220
221function handleStyleProp(val, node, context) {
222 const styleStr = _(val)
223 .split(';')
224 .map(_.trim)
225 .filter(i => _.includes(i, ':'))
226 .map(i => {
227 const pair = i.split(':');
228
229 const value = pair.slice(1).join(':').trim();
230 return _.camelCase(pair[0].trim()) + ' : ' + utils.convertText(node, context, value.trim());
231 })
232 .join(',');
233 return `{${styleStr}}`;
234}
235
236/**
237 * @param {string} tagName
238 * @param context
239 * @return {string}
240 */
241function convertTagNameToConstructor(tagName, context) {
242 if (context.options.native) {
243 return _.includes(reactNativeSupport[context.options.nativeTargetVersion], tagName) ? 'React.' + tagName : tagName;
244 }
245 let isHtmlTag = _.includes(reactDOMSupport[context.options.targetVersion], tagName);
246 if (reactSupport.shouldUseCreateElement(context)) {
247 isHtmlTag = isHtmlTag || tagName.match(/^\w+(-\w+)$/);
248 return isHtmlTag ? `'${tagName}'` : tagName;
249 }
250 return isHtmlTag ? 'React.DOM.' + tagName : tagName;
251}
252
253/**
254 * @param {string} html
255 * @param options
256 * @param reportContext
257 * @return {Context}
258 */
259function defaultContext(html, options, reportContext) {
260 const defaultDefines = [
261 {moduleName: options.reactImportPath, alias: 'React', member: '*'},
262 {moduleName: options.lodashImportPath, alias: '_', member: '*'}
263 ];
264 return {
265 boundParams: [],
266 injectedFunctions: [],
267 html,
268 options,
269 defines: options.defines ? _.clone(options.defines) : defaultDefines,
270 reportContext
271 };
272}
273
274/**
275 * @param node
276 * @return {boolean}
277 */
278function hasNonSimpleChildren(node) {
279 return _.some(node.children, child => child.type === 'tag' && child.attribs[repeatAttr]);
280}
281
282/**
283 * @param node
284 * @param {Context} context
285 * @return {string}
286 */
287function convertHtmlToReact(node, context) {
288 if (node.type === 'tag' || node.type === 'style') {
289 context = _.defaults({
290 boundParams: _.clone(context.boundParams)
291 }, context);
292
293 if (node.type === 'tag' && node.name === includeNode) {
294 const srcFile = node.attribs[includeSrcAttr];
295 if (!srcFile) {
296 throw RTCodeError.build(context, node, 'rt-include must supply a source attribute');
297 }
298 if (!context.options.readFileSync) {
299 throw RTCodeError.build(context, node, 'rt-include needs a readFileSync polyfill on options');
300 }
301 try {
302 context.html = context.options.readFileSync(srcFile);
303 } catch (e) {
304 console.error(e);
305 throw RTCodeError.build(context, node, `rt-include failed to read file '${srcFile}'`);
306 }
307 return parseAndConvertHtmlToReact(context.html, context);
308 }
309
310 const data = {name: convertTagNameToConstructor(node.name, context)};
311
312 // Order matters. We need to add the item and itemIndex to context.boundParams before
313 // the rt-scope directive is processed, lest they are not passed to the child scopes
314 if (node.attribs[repeatAttr]) {
315 const arr = node.attribs[repeatAttr].split(' in ');
316 if (arr.length !== 2) {
317 throw RTCodeError.build(context, node, `rt-repeat invalid 'in' expression '${node.attribs[repeatAttr]}'`);
318 }
319 const repeaterParams = arr[0].split(',').map(s => s.trim());
320 data.item = repeaterParams[0];
321 data.index = repeaterParams[1] || `${data.item}Index`;
322 data.collection = arr[1].trim();
323 const bindParams = [data.item, data.index];
324 _.forEach(bindParams, param => {
325 validateJS(param, node, context);
326 });
327 validateJS(`(${data.collection})`, node, context);
328 _.forEach(bindParams, param => {
329 if (!_.includes(context.boundParams, param)) {
330 context.boundParams.push(param);
331 }
332 });
333 }
334
335 if (node.attribs[scopeAttr]) {
336 handleScopeAttribute(node, context, data);
337 }
338
339 if (node.attribs[ifAttr]) {
340 validateIfAttribute(node, context, data);
341 data.condition = node.attribs[ifAttr].trim();
342 if (!node.attribs.key) {
343 _.set(node, ['attribs', 'key'], `${node.startIndex}`);
344 }
345 }
346
347 data.props = generateProps(node, context);
348 if (node.attribs[propsAttr]) {
349 if (data.props === '{}') {
350 data.props = node.attribs[propsAttr];
351 } else if (!node.attribs.style && !node.attribs.class) {
352 data.props = propsTemplateSimple({generatedProps: data.props, rtProps: node.attribs[propsAttr]});
353 } else {
354 data.props = propsTemplate({generatedProps: data.props, rtProps: node.attribs[propsAttr]});
355 if (!_.includes(context.injectedFunctions, propsMergeFunction)) {
356 context.injectedFunctions.push(propsMergeFunction);
357 }
358 }
359 }
360
361 // provide a key to virtual node children if missing
362 if (node.name === virtualNode && node.children.length > 1) {
363 _(node.children)
364 .reject('attribs.key')
365 .forEach((child, i) => {
366 _.set(child, ['attribs', 'key'], `${node.startIndex}${i}`);
367 });
368 }
369
370 const children = _.map(node.children, child => {
371 const code = convertHtmlToReact(child, context);
372 validateJS(code, child, context);
373 return code;
374 });
375
376 data.children = utils.concatChildren(children);
377
378 if (node.name === virtualNode) { //eslint-disable-line wix-editor/prefer-ternary
379 data.body = `[${_.compact(children).join(',')}]`;
380 } else {
381 data.body = _.template(getTagTemplateString(!hasNonSimpleChildren(node), reactSupport.shouldUseCreateElement(context)))(data);
382 }
383
384 if (node.attribs[scopeAttr]) {
385 const functionBody = _.values(data.innerScope.innerMapping).join('\n') + `return ${data.body}`;
386 const generatedFuncName = generateInjectedFunc(context, 'scope' + data.innerScope.scopeName, functionBody, _.keys(data.innerScope.outerMapping));
387 data.body = `${generatedFuncName}.apply(this, [${_.values(data.innerScope.outerMapping).join(',')}])`;
388 }
389
390 // Order matters here. Each rt-repeat iteration wraps over the rt-scope, so
391 // the scope variables are evaluated in context of the current iteration.
392 if (node.attribs[repeatAttr]) {
393 data.repeatFunction = generateInjectedFunc(context, 'repeat' + _.upperFirst(data.item), 'return ' + data.body);
394 data.repeatBinds = ['this'].concat(_.reject(context.boundParams, p => p === data.item || p === data.item + 'Index' || data.innerScope && p in data.innerScope.innerMapping));
395 data.body = repeatTemplate(data);
396 }
397 if (node.attribs[ifAttr]) {
398 data.body = ifTemplate(data);
399 }
400 return data.body;
401 } else if (node.type === 'comment') {
402 return commentTemplate(node);
403 } else if (node.type === 'text') {
404 return node.data.trim() ? utils.convertText(node, context, node.data) : '';
405 }
406}
407
408function handleScopeAttribute(node, context, data) {
409 data.innerScope = {
410 scopeName: '',
411 innerMapping: {},
412 outerMapping: {}
413 };
414
415 data.innerScope.outerMapping = _.zipObject(context.boundParams, context.boundParams);
416
417 _(node.attribs[scopeAttr]).split(';').invokeMap('trim').compact().forEach(scopePart => {
418 const scopeSubParts = _(scopePart).split(' as ').invokeMap('trim').value();
419 if (scopeSubParts.length < 2) {
420 throw RTCodeError.build(context, node, `invalid scope part '${scopePart}'`);
421 }
422 const alias = scopeSubParts[1];
423 const value = scopeSubParts[0];
424 validateJS(alias, node, context);
425
426 // this adds both parameters to the list of parameters passed further down
427 // the scope chain, as well as variables that are locally bound before any
428 // function call, as with the ones we generate for rt-scope.
429 if (!_.includes(context.boundParams, alias)) {
430 context.boundParams.push(alias);
431 }
432
433 data.innerScope.scopeName += _.upperFirst(alias);
434 data.innerScope.innerMapping[alias] = `var ${alias} = ${value};`;
435 validateJS(data.innerScope.innerMapping[alias], node, context);
436 });
437}
438
439function validateIfAttribute(node, context, data) {
440 const innerMappingKeys = _.keys(data.innerScope && data.innerScope.innerMapping || {});
441 let ifAttributeTree = null;
442 try {
443 ifAttributeTree = esprima.parse(node.attribs[ifAttr]);
444 } catch (e) {
445 throw new RTCodeError(e.message, e.index, -1);
446 }
447 if (ifAttributeTree && ifAttributeTree.body && ifAttributeTree.body.length === 1 && ifAttributeTree.body[0].type === 'ExpressionStatement') {
448 // make sure that rt-if does not use an inner mapping
449 if (ifAttributeTree.body[0].expression && utils.usesScopeName(innerMappingKeys, ifAttributeTree.body[0].expression)) {
450 throw RTCodeError.buildFormat(context, node, "invalid scope mapping used in if part '%s'", node.attribs[ifAttr]);
451 }
452 } else {
453 throw RTCodeError.buildFormat(context, node, "invalid if part '%s'", node.attribs[ifAttr]);
454 }
455}
456
457function handleSelfClosingHtmlTags(nodes) {
458 return _.flatMap(nodes, node => {
459 let externalNodes = [];
460 node.children = handleSelfClosingHtmlTags(node.children);
461 if (node.type === 'tag' && (_.includes(reactSupport.htmlSelfClosingTags, node.name) ||
462 _.includes(reactTemplatesSelfClosingTags, node.name))) {
463 externalNodes = _.filter(node.children, {type: 'tag'});
464 _.forEach(externalNodes, i => {i.parent = node;});
465 node.children = _.reject(node.children, {type: 'tag'});
466 }
467 return [node].concat(externalNodes);
468 });
469}
470
471function handleRequire(tag, context) {
472 let moduleName;
473 let alias;
474 let member;
475 if (tag.children.length) {
476 throw RTCodeError.build(context, tag, `'${requireAttr}' may have no children`);
477 } else if (tag.attribs.dependency && tag.attribs.as) {
478 moduleName = tag.attribs.dependency;
479 member = '*';
480 alias = tag.attribs.as;
481 }
482 if (!moduleName) {
483 throw RTCodeError.build(context, tag, `'${requireAttr}' needs 'dependency' and 'as' attributes`);
484 }
485 context.defines.push({moduleName, member, alias});
486}
487
488function handleImport(tag, context) {
489 let moduleName;
490 let alias;
491 let member;
492 if (tag.children.length) {
493 throw RTCodeError.build(context, tag, `'${importAttr}' may have no children`);
494 } else if (tag.attribs.name && tag.attribs.from) {
495 moduleName = tag.attribs.from;
496 member = tag.attribs.name;
497 alias = tag.attribs.as;
498 if (!alias) {
499 if (member === '*') {
500 throw RTCodeError.build(context, tag, "'*' imports must have an 'as' attribute");
501 } else if (member === 'default') {
502 throw RTCodeError.build(context, tag, "default imports must have an 'as' attribute");
503 }
504 alias = member;
505 }
506 }
507 if (!moduleName) {
508 throw RTCodeError.build(context, tag, `'${importAttr}' needs 'name' and 'from' attributes`);
509 }
510 context.defines.push({moduleName, member, alias});
511}
512
513function convertTemplateToReact(html, options) {
514 const context = require('./context');
515 return convertRT(html, context, options);
516}
517
518function parseAndConvertHtmlToReact(html, context) {
519 const rootNode = cheerio.load(html, {
520 lowerCaseTags: false,
521 lowerCaseAttributeNames: false,
522 xmlMode: true,
523 withStartIndices: true
524 });
525 utils.validate(context.options, context, context.reportContext, rootNode.root()[0]);
526 let rootTags = _.filter(rootNode.root()[0].children, {type: 'tag'});
527 rootTags = handleSelfClosingHtmlTags(rootTags);
528 if (!rootTags || rootTags.length === 0) {
529 throw new RTCodeError('Document should have a root element');
530 }
531 let firstTag = null;
532 _.forEach(rootTags, tag => {
533 if (tag.name === requireAttr) {
534 handleRequire(tag, context);
535 } else if (tag.name === importAttr) {
536 handleImport(tag, context);
537 } else if (firstTag === null) {
538 firstTag = tag;
539 if (_.hasIn(tag, ['attribs', statelessAttr])) {
540 context.stateless = true;
541 }
542 } else {
543 throw RTCodeError.build(context, tag, 'Document should have no more than a single root element');
544 }
545 });
546 if (firstTag === null) {
547 throw RTCodeError.build(context, rootNode.root()[0], 'Document should have a single root element');
548 } else if (firstTag.name === virtualNode) {
549 throw RTCodeError.build(context, firstTag, `Document should not have <${virtualNode}> as root element`);
550 }
551 return convertHtmlToReact(firstTag, context);
552}
553
554/**
555 * @param {string} html
556 * @param {CONTEXT} reportContext
557 * @param {Options?} options
558 * @return {string}
559 */
560function convertRT(html, reportContext, options) {
561 options = getOptions(options);
562
563 const context = defaultContext(html, options, reportContext);
564 const body = parseAndConvertHtmlToReact(html, context);
565
566 const requirePaths = _.map(context.defines, d => `"${d.moduleName}"`).join(',');
567 const requireNames = _.map(context.defines, d => `${d.alias}`).join(',');
568 const buildImport = reactSupport.buildImport[options.modules] || reactSupport.buildImport.commonjs;
569 const requires = _.map(context.defines, buildImport).join('\n');
570 const header = options.flow ? '/* @flow */\n' : '';
571 const vars = header + requires;
572 const data = {
573 body,
574 injectedFunctions: context.injectedFunctions.join('\n'),
575 requireNames,
576 requirePaths,
577 vars,
578 name: options.name,
579 statelessProps: context.stateless ? 'props' : ''
580 };
581 let code = templates[options.modules](data);
582 if (options.modules !== 'typescript' && options.modules !== 'jsrt') {
583 code = parseJS(code);
584 }
585 return code;
586}
587
588function parseJS(code) {
589 try {
590 let tree = esprima.parse(code, {range: true, tokens: true, comment: true, sourceType: 'module'});
591 tree = escodegen.attachComments(tree, tree.comments, tree.tokens);
592 return escodegen.generate(tree, {comment: true});
593 } catch (e) {
594 throw new RTCodeError(e.message, e.index, -1);
595 }
596}
597
598function convertJSRTToJS(text, reportContext, options) {
599 options = getOptions(options);
600 options.modules = 'jsrt';
601 const templateMatcherJSRT = /<template>([^]*?)<\/template>/gm;
602 const code = text.replace(templateMatcherJSRT, (template, html) => convertRT(html, reportContext, options).replace(/;$/, ''));
603
604 return parseJS(code);
605}
606
607module.exports = {
608 convertTemplateToReact,
609 convertRT,
610 convertJSRTToJS,
611 RTCodeError,
612 normalizeName: utils.normalizeName
613};