'use strict'; var cheerio = require('cheerio'); var _ = require('lodash'); var esprima = require('esprima'); var escodegen = require('escodegen'); var reactDOMSupport = require('./reactDOMSupport'); var reactNativeSupport = require('./reactNativeSupport'); var reactPropTemplates = require('./reactPropTemplates'); var stringUtils = require('./stringUtils'); var rtError = require('./RTCodeError'); var reactSupport = require('./reactSupport'); var templates = reactSupport.templates; var utils = require('./utils'); var util = require('util'); var validateJS = utils.validateJS; var RTCodeError = rtError.RTCodeError; var repeatTemplate = _.template('_.map(<%= collection %>,<%= repeatFunction %>.bind(<%= repeatBinds %>))'); var ifTemplate = _.template('((<%= condition %>)?(<%= body %>):null)'); var propsTemplateSimple = _.template('_.assign({}, <%= generatedProps %>, <%= rtProps %>)'); var propsTemplate = _.template('mergeProps( <%= generatedProps %>, <%= rtProps %>)'); const propsMergeFunction = `function mergeProps(inline,external) { var res = _.assign({},inline,external) if (inline.hasOwnProperty('style')) { res.style = _.defaults(res.style, inline.style); } if (inline.hasOwnProperty('className') && external.hasOwnProperty('className')) { res.className = external.className + ' ' + inline.className; } return res; } ` var classSetTemplate = _.template('_.keys(_.pick(<%= classSet %>, _.identity)).join(" ")'); function getTagTemplateString(simpleTagTemplate, shouldCreateElement) { if (simpleTagTemplate) { return shouldCreateElement ? 'React.createElement(<%= name %>,<%= props %><%= children %>)' : '<%= name %>(<%= props %><%= children %>)'; } return shouldCreateElement ? 'React.createElement.apply(this, [<%= name %>,<%= props %><%= children %>])' : '<%= name %>.apply(this, [<%= props %><%= children %>])'; } var commentTemplate = _.template(' /* <%= data %> */ '); var repeatAttr = 'rt-repeat'; var ifAttr = 'rt-if'; var classSetAttr = 'rt-class'; var classAttr = 'class'; var scopeAttr = 'rt-scope'; var propsAttr = 'rt-props'; var templateNode = 'rt-template'; var virtualNode = 'rt-virtual'; var includeNode = 'rt-include'; var includeSrcAttr = 'src'; /** * @param {Options} options * @return {Options} */ function getOptions(options) { options = options || {}; var defaultOptions = { modules: options.native ? 'commonjs' : 'amd', version: false, force: false, format: 'stylish', targetVersion: reactDOMSupport.default, reactImportPath: reactImport(options), lodashImportPath: 'lodash', native: false, nativeTargetVersion: reactNativeSupport.default, flow: options.flow }; var finalOptions = _.defaults({}, options, defaultOptions); var defaultPropTemplates = finalOptions.native ? reactPropTemplates.native[finalOptions.nativeTargetVersion] : reactPropTemplates.dom[finalOptions.targetVersion]; finalOptions.propTemplates = _.defaults({}, options.propTemplates, defaultPropTemplates); return finalOptions; } function reactImport(options) { if (options.native) { return 'react-native'; } if (options.targetVersion === '0.14.0') { return 'react'; } return 'react/addons'; } /** * @const */ const curlyMap = {'{': 1, '}': -1}; /** * @typedef {{boundParams: Array.<string>, injectedFunctions: Array.<string>, html: string, options: *}} Context */ /** * @typedef {{fileName:string,force:boolean,modules:string,defines:*,reactImportPath:string=,lodashImportPath:string=,flow:boolean,name:string,native:boolean,propTemplates:*,format:string,_:*,version:boolean,help:boolean,listTargetVersion:boolean,modules:string, dryRun:boolean}} Options */ /** * @param node * @param {Context} context * @param {string} txt * @return {string} */ function convertText(node, context, txt) { var res = ''; var first = true; var concatChar = node.type === 'text' ? ',' : '+'; while (txt.indexOf('{') !== -1) { var start = txt.indexOf('{'); var pre = txt.substr(0, start); if (pre) { res += (first ? '' : concatChar) + JSON.stringify(pre); first = false; } var curlyCounter = 1; var end; for (end = start + 1; end < txt.length && curlyCounter > 0; end++) { //eslint-disable-line no-restricted-syntax curlyCounter += curlyMap[txt.charAt(end)] || 0; } if (curlyCounter === 0) { var needsParens = start !== 0 || end !== txt.length - 1; res += (first ? '' : concatChar) + (needsParens ? '(' : '') + txt.substr(start + 1, end - start - 2) + (needsParens ? ')' : ''); first = false; txt = txt.substr(end); } else { throw RTCodeError.build(context, node, `Failed to parse text '${txt}'`); } } if (txt) { res += (first ? '' : concatChar) + JSON.stringify(txt); } if (res === '') { res = 'true'; } return res; } /** * @param {Context} context * @param {string} namePrefix * @param {string} body * @param {*?} params * @return {string} */ function generateInjectedFunc(context, namePrefix, body, params) { params = params || context.boundParams; var funcName = namePrefix.replace(',', '') + (context.injectedFunctions.length + 1); var funcText = `function ${funcName}(${params.join(',')}) { ${body} } `; context.injectedFunctions.push(funcText); return funcName; } function generateTemplateProps(node, context) { var propTemplateDefinition = context.options.propTemplates[node.name]; var propertiesTemplates = _(node.children) .map(function (child, index) { var templateProp = null; if (child.name === templateNode) { // Generic explicit template tag if (!_.has(child.attribs, 'prop')) { throw RTCodeError.build(context, child, 'rt-template must have a prop attribute'); } var childTemplate = _.find(context.options.propTemplates, {prop: child.attribs.prop}) || {arguments: []}; templateProp = { prop: child.attribs.prop, arguments: (child.attribs.arguments ? child.attribs.arguments.split(',') : childTemplate.arguments) || [] }; } else if (propTemplateDefinition && propTemplateDefinition[child.name]) { // Implicit child template from configuration templateProp = { prop: propTemplateDefinition[child.name].prop, arguments: child.attribs.arguments ? child.attribs.arguments.split(',') : propTemplateDefinition[child.name].arguments }; } if (templateProp) { _.assign(templateProp, {childIndex: index, content: _.find(child.children, {type: 'tag'})}); } return templateProp; }) .compact() .value(); return _.transform(propertiesTemplates, function (props, templateProp) { var functionParams = _.values(context.boundParams).concat(templateProp.arguments); var oldBoundParams = context.boundParams; context.boundParams = context.boundParams.concat(templateProp.arguments); var functionBody = 'return ' + convertHtmlToReact(templateProp.content, context); context.boundParams = oldBoundParams; var generatedFuncName = generateInjectedFunc(context, templateProp.prop, functionBody, functionParams); props[templateProp.prop] = genBind(generatedFuncName, _.values(context.boundParams)); // Remove the template child from the children definition. node.children.splice(templateProp.childIndex, 1); }, {}); } /** * @param node * @param {Context} context * @return {string} */ function generateProps(node, context) { var props = {}; _.forOwn(node.attribs, function (val, key) { var propKey = reactSupport.attributesMapping[key.toLowerCase()] || key; if (props.hasOwnProperty(propKey) && propKey !== reactSupport.classNameProp) { throw RTCodeError.build(context, node, `duplicate definition of ${propKey} ${JSON.stringify(node.attribs)}`); } if (key.indexOf('on') === 0 && !utils.isStringOnlyCode(val)) { props[propKey] = handleEventHandler(val, context, node, key); } else if (key === 'style' && !utils.isStringOnlyCode(val)) { props[propKey] = handleStyleProp(val, node, context); } else if (propKey === reactSupport.classNameProp) { // Processing for both class and rt-class conveniently return strings that // represent JS expressions, each evaluating to a space-separated set of class names. // We can just join them with another space here. var existing = props[propKey] ? `${props[propKey]} + " " + ` : ''; if (key === classSetAttr) { props[propKey] = existing + classSetTemplate({classSet: val}); } else if (key === classAttr || key === reactSupport.classNameProp) { props[propKey] = existing + convertText(node, context, val.trim()); } } else if (key.indexOf('rt-') !== 0) { props[propKey] = convertText(node, context, val.trim()); } }); _.assign(props, generateTemplateProps(node, context)); const propStr = _.map(props, (v, k) => `${JSON.stringify(k)} : ${v}`).join(','); return `{${propStr}}`; } function handleEventHandler(val, context, node, key) { var funcParts = val.split('=>'); if (funcParts.length !== 2) { 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}']`); } var evtParams = funcParts[0].replace('(', '').replace(')', '').trim(); var funcBody = funcParts[1].trim(); var params = context.boundParams; if (evtParams.trim() !== '') { params = params.concat([evtParams.trim()]); } var generatedFuncName = generateInjectedFunc(context, key, funcBody, params); return genBind(generatedFuncName, context.boundParams); } function genBind(func, args) { return util.format('%s.bind(%s)', func, (['this'].concat(args)).join(',')); } function handleStyleProp(val, node, context) { const styleStr = _(val) .split(';') .map(_.trim) .filter(i => _.includes(i, ':')) .map(i => { const pair = i.split(':'); //const val = pair[1]; const val = pair.slice(1).join(':').trim(); return _.camelCase(pair[0].trim()) + ' : ' + convertText(node, context, val.trim()) //return stringUtils.convertToCamelCase(pair[0].trim()) + ' : ' + convertText(node, context, val.trim()) }) .join(','); return `{${styleStr}}`; } /** * @param {string} tagName * @param context * @return {string} */ function convertTagNameToConstructor(tagName, context) { if (context.options.native) { return _.includes(reactNativeSupport[context.options.nativeTargetVersion], tagName) ? 'React.' + tagName : tagName; } var isHtmlTag = _.includes(reactDOMSupport[context.options.targetVersion], tagName); if (reactSupport.shouldUseCreateElement(context)) { isHtmlTag = isHtmlTag || tagName.match(/^\w+(-\w+)$/); return isHtmlTag ? `'${tagName}'` : tagName; } return isHtmlTag ? 'React.DOM.' + tagName : tagName; } /** * @param {string} html * @param options * @return {Context} */ function defaultContext(html, options, reportContext) { var defaultDefines = {}; defaultDefines[options.reactImportPath] = 'React'; defaultDefines[options.lodashImportPath] = '_'; return { boundParams: [], injectedFunctions: [], html: html, options: options, defines: options.defines ? _.clone(options.defines) : defaultDefines, reportContext: reportContext }; } /** * @param node * @return {boolean} */ function hasNonSimpleChildren(node) { return _.some(node.children, child => child.type === 'tag' && child.attribs[repeatAttr]); } /** * @param node * @param {Context} context * @return {string} */ function convertHtmlToReact(node, context) { if (node.type === 'tag' || node.type === 'style') { context = _.defaults({ boundParams: _.clone(context.boundParams) }, context); if (node.type === 'tag' && node.name === includeNode) { var srcFile = node.attribs[includeSrcAttr]; if (!srcFile) { throw RTCodeError.build(context, node, 'rt-include must supply a source attribute'); } if (!context.options.readFileSync) { throw RTCodeError.build(context, node, 'rt-include needs a readFileSync polyfill on options'); } try { var newHtml = context.options.readFileSync(srcFile); } catch (e) { console.error(e); throw RTCodeError.build(context, node, `rt-include failed to read file '${srcFile}'`); } context.html = newHtml; return parseAndConvertHtmlToReact(newHtml, context); } var data = {name: convertTagNameToConstructor(node.name, context)}; // Order matters. We need to add the item and itemIndex to context.boundParams before // the rt-scope directive is processed, lest they are not passed to the child scopes if (node.attribs[repeatAttr]) { var arr = node.attribs[repeatAttr].split(' in '); if (arr.length !== 2) { throw RTCodeError.build(context, node, `rt-repeat invalid 'in' expression '${node.attribs[repeatAttr]}'`); } data.item = arr[0].trim(); data.collection = arr[1].trim(); validateJS(data.item, node, context); validateJS("(" + data.collection + ")", node, context); stringUtils.addIfMissing(context.boundParams, data.item); stringUtils.addIfMissing(context.boundParams, `${data.item}Index`); } if (node.attribs[scopeAttr]) { handleScopeAttribute(node, context, data); } if (node.attribs[ifAttr]) { validateIfAttribute(node, context, data); data.condition = node.attribs[ifAttr].trim(); } data.props = generateProps(node, context); if (node.attribs[propsAttr]) { if (data.props === '{}') { data.props = node.attribs[propsAttr]; } else if (!node.attribs.style && !node.attribs.class) { data.props = propsTemplateSimple({generatedProps: data.props, rtProps: node.attribs[propsAttr]}); } else { data.props = propsTemplate({generatedProps: data.props, rtProps: node.attribs[propsAttr]}); if (!_.includes(context.injectedFunctions, propsMergeFunction)) { context.injectedFunctions.push(propsMergeFunction); } } } var children = _.map(node.children, function (child) { var code = convertHtmlToReact(child, context); validateJS(code, child, context); return code; }); data.children = utils.concatChildren(children); if (node.name === virtualNode) { //eslint-disable-line wix-editor/prefer-ternary data.body = "[" + _.compact(children).join(',') + "]" } else { data.body = _.template(getTagTemplateString(!hasNonSimpleChildren(node), reactSupport.shouldUseCreateElement(context)))(data); } if (node.attribs[scopeAttr]) { var functionBody = _.values(data.innerScope.innerMapping).join('\n') + `return ${data.body}`; var generatedFuncName = generateInjectedFunc(context, 'scope' + data.innerScope.scopeName, functionBody, _.keys(data.innerScope.outerMapping)); data.body = `${generatedFuncName}.apply(this, [${_.values(data.innerScope.outerMapping).join(',')}])`; } // Order matters here. Each rt-repeat iteration wraps over the rt-scope, so // the scope variables are evaluated in context of the current iteration. if (node.attribs[repeatAttr]) { data.repeatFunction = generateInjectedFunc(context, 'repeat' + _.capitalize(data.item), 'return ' + data.body); data.repeatBinds = ['this'].concat(_.reject(context.boundParams, p => p === data.item || p === data.item + 'Index' || data.innerScope && p in data.innerScope.innerMapping)); data.body = repeatTemplate(data); } if (node.attribs[ifAttr]) { data.body = ifTemplate(data); } return data.body; } else if (node.type === 'comment') { return commentTemplate(node); } else if (node.type === 'text') { return node.data.trim() ? convertText(node, context, node.data) : ''; } } function handleScopeAttribute(node, context, data) { data.innerScope = { scopeName: '', innerMapping: {}, outerMapping: {} }; data.innerScope.outerMapping = _.zipObject(context.boundParams, context.boundParams); _(node.attribs[scopeAttr]).split(';').invoke('trim').compact().forEach(scopePart => { //eslint-disable-line lodash3/collection-return var scopeSubParts = _(scopePart).split(' as ').invoke('trim').value(); if (scopeSubParts.length < 2) { throw RTCodeError.build(context, node, `invalid scope part '${scopePart}'`); } var alias = scopeSubParts[1]; var value = scopeSubParts[0]; validateJS(alias, node, context); // this adds both parameters to the list of parameters passed further down // the scope chain, as well as variables that are locally bound before any // function call, as with the ones we generate for rt-scope. stringUtils.addIfMissing(context.boundParams, alias); data.innerScope.scopeName += _.capitalize(alias); data.innerScope.innerMapping[alias] = `var ${alias} = ${value};`; validateJS(data.innerScope.innerMapping[alias], node, context); }).value(); } function validateIfAttribute(node, context, data) { var innerMappingKeys = _.keys(data.innerScope && data.innerScope.innerMapping || {}); var ifAttributeTree = null; try { ifAttributeTree = esprima.parse(node.attribs[ifAttr]); } catch (e) { throw new RTCodeError(e.message, e.index, -1); } if (ifAttributeTree && ifAttributeTree.body && ifAttributeTree.body.length === 1 && ifAttributeTree.body[0].type === 'ExpressionStatement') { // make sure that rt-if does not use an inner mapping if (ifAttributeTree.body[0].expression && utils.usesScopeName(innerMappingKeys, ifAttributeTree.body[0].expression)) { throw RTCodeError.buildFormat(context, node, "invalid scope mapping used in if part '%s'", node.attribs[ifAttr]); } } else { throw RTCodeError.buildFormat(context, node, "invalid if part '%s'", node.attribs[ifAttr]); } } /** * @param node * @return {boolean} */ function isTag(node) { return node.type === 'tag'; } function handleSelfClosingHtmlTags(nodes) { return _(nodes) .map(function (node) { var externalNodes = []; node.children = handleSelfClosingHtmlTags(node.children); if (node.type === 'tag' && _.includes(reactSupport.htmlSelfClosingTags, node.name)) { externalNodes = _.filter(node.children, isTag); _.forEach(externalNodes, i => i.parent = node); node.children = _.reject(node.children, isTag); } return [node].concat(externalNodes); }) .flatten() .value(); } function convertTemplateToReact(html, options) { var context = require('./context'); return convertRT(html, context, options); } function parseAndConvertHtmlToReact(html, context) { var rootNode = cheerio.load(html, {lowerCaseTags: false, lowerCaseAttributeNames: false, xmlMode: true, withStartIndices: true}); utils.validate(context.options, context, context.reportContext, rootNode.root()[0]); var rootTags = _.filter(rootNode.root()[0].children, isTag); rootTags = handleSelfClosingHtmlTags(rootTags); if (!rootTags || rootTags.length === 0) { throw new RTCodeError('Document should have a root element'); } var firstTag = null; _.forEach(rootTags, function (tag) { //eslint-disable-line lodash3/collection-return if (tag.name === 'rt-require') { if (!tag.attribs.dependency || !tag.attribs.as) { throw RTCodeError.build(context, tag, "rt-require needs 'dependency' and 'as' attributes"); } else if (tag.children.length) { throw RTCodeError.build(context, tag, 'rt-require may have no children'); } context.defines[tag.attribs.dependency] = tag.attribs.as; } else if (firstTag === null) { firstTag = tag; } else { throw RTCodeError.build(context, tag, 'Document should have no more than a single root element'); } }); if (firstTag === null) { throw RTCodeError.build(context, rootNode.root()[0], 'Document should have a single root element'); } else if (firstTag.name === virtualNode) { throw RTCodeError.build(context, firstTag, `Document should not have <${virtualNode}> as root element`); } return convertHtmlToReact(firstTag, context); } /** * @param {string} html * @param {CONTEXT} reportContext * @param {Options?} options * @return {string} */ function convertRT(html, reportContext, options) { options = getOptions(options); var context = defaultContext(html, options, reportContext); var body = parseAndConvertHtmlToReact(html, context); var requirePaths = _(context.defines) .keys() .map(def => `"${def}"`) .join(','); var buildImport; if (options.modules === 'typescript') { buildImport = (v, p) => `import ${v} = require('${p}');`; } else if (options.modules === 'es6') { // eslint-disable-line buildImport = (v, p) => `import ${v} from '${p}';` } else { buildImport = (v, p) => `var ${v} = require('${p}');` } const header = options.flow ? '/* @flow */\n' : ''; const vars = header + _(context.defines).map(buildImport).join('\n'); var data = {body, injectedFunctions: context.injectedFunctions.join('\n'), requireNames: _.values(context.defines).join(','), requirePaths, vars, name: options.name}; var code = generate(data, options); if (options.modules !== 'typescript' && options.modules !== 'jsrt') { code = parseJS(code); } return code; } function parseJS(code) { try { var tree = esprima.parse(code, {range: true, tokens: true, comment: true, sourceType: 'module'}); tree = escodegen.attachComments(tree, tree.comments, tree.tokens); return escodegen.generate(tree, {comment: true}); } catch (e) { throw new RTCodeError(e.message, e.index, -1); } } function convertJSRTToJS(text, reportContext, options) { options = getOptions(options); options.modules = 'jsrt'; var templateMatcherJSRT = /<template>([^]*?)<\/template>/gm; var code = text.replace(templateMatcherJSRT, (template, html) => convertRT(html, reportContext, options).replace(/;$/, '')); code = parseJS(code); return code; } function generate(data, options) { var template = templates[options.modules]; return template(data); } module.exports = { convertTemplateToReact: convertTemplateToReact, convertRT: convertRT, convertJSRTToJS: convertJSRTToJS, RTCodeError: RTCodeError, normalizeName: utils.normalizeName, _test: { convertText: convertText } };