diff --git a/index.js b/index.js index ec3fa91..7cbad93 100644 --- a/index.js +++ b/index.js @@ -9,6 +9,5 @@ module.exports = { Parser: parser, ProcessingInstructions: processingInstructions, IsValidNodeDefinitions: isValidNodeDefinitions, - ProcessNodeDefinitions: processNodeDefinitions + ProcessNodeDefinitions: processNodeDefinitions, }; - diff --git a/lib/camel-case-attribute-names.js b/lib/camel-case-attribute-names.js new file mode 100644 index 0000000..f158e83 --- /dev/null +++ b/lib/camel-case-attribute-names.js @@ -0,0 +1,81 @@ +// These are all sourced from https://facebook.github.io/react/docs/tags-and-attributes.html - +// all attributes regardless of whether they have a different case to their HTML equivalents are +// listed to reduce the chance of human error and make it easier to just copy-paste the new list if +// it changes. +'use strict'; +var HTML_ATTRIBUTES = [ + 'accept', 'acceptCharset', 'accessKey', 'action', 'allowFullScreen', 'allowTransparency', + 'alt', 'async', 'autoComplete', 'autoFocus', 'autoPlay', 'capture', 'cellPadding', + 'cellSpacing', 'challenge', 'charSet', 'checked', 'cite', 'classID', 'className', + 'colSpan', 'cols', 'content', 'contentEditable', 'contextMenu', 'controls', 'coords', + 'crossOrigin', 'data', 'dateTime', 'default', 'defer', 'dir', 'disabled', 'download', + 'draggable', 'encType', 'form', 'formAction', 'formEncType', 'formMethod', 'formNoValidate', + 'formTarget', 'frameBorder', 'headers', 'height', 'hidden', 'high', 'href', 'hrefLang', + 'htmlFor', 'httpEquiv', 'icon', 'id', 'inputMode', 'integrity', 'is', 'keyParams', 'keyType', + 'kind', 'label', 'lang', 'list', 'loop', 'low', 'manifest', 'marginHeight', 'marginWidth', + 'max', 'maxLength', 'media', 'mediaGroup', 'method', 'min', 'minLength', 'multiple', 'muted', + 'name', 'noValidate', 'nonce', 'open', 'optimum', 'pattern', 'placeholder', 'poster', + 'preload', 'profile', 'radioGroup', 'readOnly', 'rel', 'required', 'reversed', 'role', + 'rowSpan', 'rows', 'sandbox', 'scope', 'scoped', 'scrolling', 'seamless', 'selected', + 'shape', 'size', 'sizes', 'span', 'spellCheck', 'src', 'srcDoc', 'srcLang', 'srcSet', 'start', + 'step', 'style', 'summary', 'tabIndex', 'target', 'title', 'type', 'useMap', 'value', 'width', + 'wmode', 'wrap', +]; + +var NON_STANDARD_ATTRIBUTES = [ + 'autoCapitalize', 'autoCorrect', 'color', 'itemProp', 'itemScope', 'itemType', 'itemRef', + 'itemID', 'security', 'unselectable', 'results', 'autoSave', +]; + +var SVG_ATTRIBUTES = [ + 'accentHeight', 'accumulate', 'additive', 'alignmentBaseline', 'allowReorder', 'alphabetic', + 'amplitude', 'arabicForm', 'ascent', 'attributeName', 'attributeType', 'autoReverse', + 'azimuth', 'baseFrequency', 'baseProfile', 'baselineShift', 'bbox', 'begin', 'bias', 'by', + 'calcMode', 'capHeight', 'clip', 'clipPath', 'clipPathUnits', 'clipRule', 'colorInterpolation', + 'colorInterpolationFilters', 'colorProfile', 'colorRendering', 'contentScriptType', + 'contentStyleType', 'cursor', 'cx', 'cy', 'd', 'decelerate', 'descent', 'diffuseConstant', + 'direction', 'display', 'divisor', 'dominantBaseline', 'dur', 'dx', 'dy', 'edgeMode', + 'elevation', 'enableBackground', 'end', 'exponent', 'externalResourcesRequired', 'fill', + 'fillOpacity', 'fillRule', 'filter', 'filterRes', 'filterUnits', 'floodColor', 'floodOpacity', + 'focusable', 'fontFamily', 'fontSize', 'fontSizeAdjust', 'fontStretch', 'fontStyle', + 'fontVariant', 'fontWeight', 'format', 'from', 'fx', 'fy', 'g1', 'g2', 'glyphName', + 'glyphOrientationHorizontal', 'glyphOrientationVertical', 'glyphRef', 'gradientTransform', + 'gradientUnits', 'hanging', 'horizAdvX', 'horizOriginX', 'ideographic', 'imageRendering', + 'in', 'in2', 'intercept', 'k', 'k1', 'k2', 'k3', 'k4', 'kernelMatrix', 'kernelUnitLength', + 'kerning', 'keyPoints', 'keySplines', 'keyTimes', 'lengthAdjust', 'letterSpacing', + 'lightingColor', 'limitingConeAngle', 'local', 'markerEnd', 'markerHeight', 'markerMid', + 'markerStart', 'markerUnits', 'markerWidth', 'mask', 'maskContentUnits', 'maskUnits', + 'mathematical', 'mode', 'numOctaves', 'offset', 'opacity', 'operator', 'order', + 'orient', 'orientation', 'origin', 'overflow', 'overlinePosition', 'overlineThickness', + 'paintOrder', 'panose1', 'pathLength', 'patternContentUnits', 'patternTransform', + 'patternUnits', 'pointerEvents', 'points', 'pointsAtX', 'pointsAtY', 'pointsAtZ', + 'preserveAlpha', 'preserveAspectRatio', 'primitiveUnits', 'r', 'radius', 'refX', 'refY', + 'renderingIntent', 'repeatCount', 'repeatDur', 'requiredExtensions', 'requiredFeatures', + 'restart', 'result', 'rotate', 'rx', 'ry', 'scale', 'seed', 'shapeRendering', 'slope', + 'spacing', 'specularConstant', 'specularExponent', 'speed', 'spreadMethod', 'startOffset', + 'stdDeviation', 'stemh', 'stemv', 'stitchTiles', 'stopColor', 'stopOpacity', + 'strikethroughPosition', 'strikethroughThickness', 'string', 'stroke', 'strokeDasharray', + 'strokeDashoffset', 'strokeLinecap', 'strokeLinejoin', 'strokeMiterlimit', + 'strokeOpacity', 'strokeWidth', 'surfaceScale', 'systemLanguage', 'tableValues', 'targetX', + 'targetY', 'textAnchor', 'textDecoration', 'textLength', 'textRendering', 'to', 'transform', + 'u1', 'u2', 'underlinePosition', 'underlineThickness', 'unicode', 'unicodeBidi', + 'unicodeRange', 'unitsPerEm', 'vAlphabetic', 'vHanging', 'vIdeographic', 'vMathematical', + 'values', 'vectorEffect', 'version', 'vertAdvY', 'vertOriginX', 'vertOriginY', 'viewBox', + 'viewTarget', 'visibility', 'widths', 'wordSpacing', 'writingMode', 'x', 'x1', 'x2', + 'xChannelSelector', 'xHeight', 'xlinkActuate', 'xlinkArcrole', 'xlinkHref', 'xlinkRole', + 'xlinkShow', 'xlinkTitle', 'xlinkType', 'xmlBase', 'xmlLang', 'xmlSpace', 'y', 'y1', 'y2', + 'yChannelSelector', 'z', 'zoomAndPan', +]; + +var camelCaseMap = HTML_ATTRIBUTES + .concat(NON_STANDARD_ATTRIBUTES) + .concat(SVG_ATTRIBUTES) + .reduce(function (soFar, attr) { + var lower = attr.toLowerCase(); + if (lower !== attr) { + soFar[lower] = attr; + } + return soFar; + }, {}); + +module.exports = camelCaseMap; diff --git a/lib/parser.js b/lib/parser.js index bed57a7..2f87714 100644 --- a/lib/parser.js +++ b/lib/parser.js @@ -1,6 +1,4 @@ 'use strict'; - -var compact = require('lodash.compact'); var find = require('lodash.find'); var map = require('lodash.map'); var htmlParser = require('htmlparser2'); @@ -18,19 +16,21 @@ var Html2React = function(React, options) { var traverseDom = function(node, isValidNode, processingInstructions, index) { if (isValidNode(node)) { var processingInstruction = find(processingInstructions || [], - function (processingInstruction) { - return processingInstruction.shouldProcessNode(node); - }); + function (processingInstruction) { + return processingInstruction.shouldProcessNode(node); + }); if (processingInstruction != null) { - var children = compact(map(node.children, function (child, i) { + var children = map(node.children || [], function (child, i) { return traverseDom(child, isValidNode, processingInstructions, i); - })); + }).filter(function (x) { + return x != null; + }); return processingInstruction.processNode(node, children, index); } else { return false; } } else { - return false; + return false; } }; @@ -38,9 +38,11 @@ var Html2React = function(React, options) { var domTree = parseHtmlToTree(html); // TODO: Deal with HTML that contains more than one root level node if (domTree && domTree.length !== 1) { - throw new Error('html-to-react currently only supports HTML with one single root element. ' + - 'The HTML provided contains ' + domTree.length + ' root elements. You can fix that by simply wrapping your HTML ' + - 'in a
element.'); + throw new Error( + 'html-to-react currently only supports HTML with one single root element. ' + + 'The HTML provided contains ' + domTree.length + + ' root elements. You can fix that by simply wrapping your HTML ' + + 'in a
element.'); } return traverseDom(domTree[0], isValidNode, processingInstructions, 0); }; diff --git a/lib/process-node-definitions.js b/lib/process-node-definitions.js index f5a69f6..af94c77 100644 --- a/lib/process-node-definitions.js +++ b/lib/process-node-definitions.js @@ -1,14 +1,18 @@ 'use strict'; +var isEmpty = require('lodash.isempty'); +var map = require('lodash.map'); +var fromPairs = require('lodash.frompairs'); var camelCase = require('lodash.camelcase'); -var forEach = require('lodash.foreach'); var includes = require('lodash.includes'); +var merge = require('lodash.merge'); var ent = require('ent'); +var camelCaseAttrMap = require('./camel-case-attribute-names'); -// https://github.com/facebook/react/blob/0.14-stable/src/renderers/dom/shared/ReactDOMComponent.js#L457 +// https://github.com/facebook/react/blob/15.0-stable/src/renderers/dom/shared/ReactDOMComponent.js#L457 var voidElementTags = [ - 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', - 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr', 'textarea', + 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', + 'source', 'track', 'wbr', 'menuitem', 'textarea', ]; function createStyleJsonFromString(styleString) { @@ -42,20 +46,18 @@ var ProcessNodeDefinitions = function(React) { key: index, }; // Process attributes - if (node.attribs) { - forEach(node.attribs, function(value, key) { - switch (key || '') { - case 'style': - elementProps.style = createStyleJsonFromString(node.attribs.style); - break; - case 'class': - elementProps.className = value; - break; - default: - elementProps[key] = value || key; - break; + if (!isEmpty(node.attribs)) { + elementProps = merge(elementProps, fromPairs(map(node.attribs, function (value, key) { + if (key === 'style') { + value = createStyleJsonFromString(node.attribs.style); + } else if (key === 'class') { + key = 'className'; + } else if (camelCaseAttrMap[key]) { + key = camelCaseAttrMap[key]; } - }); + + return [key, value || key,]; + }))); } if (includes(voidElementTags, node.name)) { diff --git a/lib/processing-instructions.js b/lib/processing-instructions.js index 8f3f97a..9617b6e 100644 --- a/lib/processing-instructions.js +++ b/lib/processing-instructions.js @@ -9,10 +9,9 @@ var ProcessingInstructions = function(React) { return { defaultProcessingInstructions: [{ shouldProcessNode: ShouldProcessNodeDefinitions.shouldProcessEveryNode, - processNode: processNodeDefinitions.processDefaultNode - }] + processNode: processNodeDefinitions.processDefaultNode, + },], }; }; module.exports = ProcessingInstructions; - diff --git a/package.json b/package.json index d1ccfdd..664793b 100644 --- a/package.json +++ b/package.json @@ -36,20 +36,21 @@ "dependencies": { "ent": "^2.2.0", "htmlparser2": "^3.8.3", - "lodash.camelcase": "^4.1.0", - "lodash.compact": "^3.0.1", - "lodash.find": "^4.3.0", - "lodash.foreach": "^4.2.0", - "lodash.includes": "^4.1.2", - "lodash.map": "^4.3.0" + "lodash.camelcase": "^4.3.0", + "lodash.find": "^4.6.0", + "lodash.frompairs": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isempty": "^4.4.0", + "lodash.map": "^4.6.0", + "lodash.merge": "^4.6.0" }, "devDependencies": { "coveralls": "2.11.9", "istanbul": "0.4.3", - "lodash": "^4.11.1", + "lodash": "^4.16.1", "mocha": "2.4.5", "mocha-lcov-reporter": "1.2.0", - "react": "^0.14.7", - "react-dom": "^0.14.7" + "react": "^15.0", + "react-dom": "^15.0" } } diff --git a/test/html-to-react-tests.js b/test/html-to-react-tests.js index 989d214..43e7e8e 100644 --- a/test/html-to-react-tests.js +++ b/test/html-to-react-tests.js @@ -76,6 +76,15 @@ describe('Html2React', function() { assert.equal(reactHtml, htmlInput); }); + it('should return a valid HTML string with a react camelCase attribute', function() { + var htmlInput = '
'; + + var reactComponent = parser.parse(htmlInput); + var reactHtml = ReactDOMServer.renderToStaticMarkup(reactComponent); + + assert.equal(reactHtml, htmlInput); + }); + // FIXME: See lib/process-node-definitions.js -> processDefaultNode() it.skip('should return a valid HTML string with comments', function() { var htmlInput = '
'; @@ -203,8 +212,8 @@ describe('Html2React', function() { shouldProcessNode: function(node) { return node.name && node.name !== 'p'; }, - processNode: processNodeDefinitions.processDefaultNode - }]; + processNode: processNodeDefinitions.processDefaultNode, + },]; var reactComponent = parser.parseWithInstructions(htmlInput, isValidNode, processingInstructions); // With only 1

element, nothing is rendered @@ -223,8 +232,8 @@ describe('Html2React', function() { shouldProcessNode: function(node) { return node.type === 'text' || node.name !== 'p'; }, - processNode: processNodeDefinitions.processDefaultNode - }]; + processNode: processNodeDefinitions.processDefaultNode, + },]; var reactComponent = parser.parseWithInstructions(htmlInput, isValidNode, processingInstructions); var reactHtml = ReactDOMServer.renderToStaticMarkup(reactComponent); assert.equal(reactHtml, htmlExpected); @@ -246,14 +255,14 @@ describe('Html2React', function() { }, processNode: function(node, children) { return node.data.toUpperCase(); - } + }, }, { // Anything else shouldProcessNode: function(node) { return true; }, - processNode: processNodeDefinitions.processDefaultNode - }]; + processNode: processNodeDefinitions.processDefaultNode, + },]; var reactComponent = parser.parseWithInstructions(htmlInput, isValidNode, processingInstructions); var reactHtml = ReactDOMServer.renderToStaticMarkup(reactComponent); assert.equal(reactHtml, htmlExpected);