Paul O’Shannessy
10 years ago
2 changed files with 8 additions and 483 deletions
@ -1,482 +1,7 @@ |
|||
/** |
|||
* Copyright 2013-2014 Facebook, Inc. |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
|
|||
/** |
|||
* This is a very simple HTML to JSX converter. It turns out that browsers |
|||
* have good HTML parsers (who would have thought?) so we utilise this by |
|||
* inserting the HTML into a temporary DOM node, and then do a breadth-first |
|||
* traversal of the resulting DOM tree. |
|||
*/ |
|||
;(function(global) { |
|||
'use strict'; |
|||
|
|||
// https://developer.mozilla.org/en-US/docs/Web/API/Node.nodeType
|
|||
var NODE_TYPE = { |
|||
ELEMENT: 1, |
|||
TEXT: 3, |
|||
COMMENT: 8 |
|||
}; |
|||
var ATTRIBUTE_MAPPING = { |
|||
'for': 'htmlFor', |
|||
'class': 'className' |
|||
}; |
|||
|
|||
/** |
|||
* Repeats a string a certain number of times. |
|||
* Also: the future is bright and consists of native string repetition: |
|||
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/repeat
|
|||
* |
|||
* @param {string} string String to repeat |
|||
* @param {number} times Number of times to repeat string. Integer. |
|||
* @see http://jsperf.com/string-repeater/2
|
|||
*/ |
|||
function repeatString(string, times) { |
|||
if (times === 1) { |
|||
return string; |
|||
} |
|||
if (times < 0) { throw new Error(); } |
|||
var repeated = ''; |
|||
while (times) { |
|||
if (times & 1) { |
|||
repeated += string; |
|||
} |
|||
if (times >>= 1) { |
|||
string += string; |
|||
} |
|||
} |
|||
return repeated; |
|||
} |
|||
|
|||
/** |
|||
* Determine if the string ends with the specified substring. |
|||
* |
|||
* @param {string} haystack String to search in |
|||
* @param {string} needle String to search for |
|||
* @return {boolean} |
|||
*/ |
|||
function endsWith(haystack, needle) { |
|||
return haystack.slice(-needle.length) === needle; |
|||
} |
|||
|
|||
/** |
|||
* Trim the specified substring off the string. If the string does not end |
|||
* with the specified substring, this is a no-op. |
|||
* |
|||
* @param {string} haystack String to search in |
|||
* @param {string} needle String to search for |
|||
* @return {string} |
|||
*/ |
|||
function trimEnd(haystack, needle) { |
|||
return endsWith(haystack, needle) |
|||
? haystack.slice(0, -needle.length) |
|||
: haystack; |
|||
} |
|||
|
|||
/** |
|||
* Convert a hyphenated string to camelCase. |
|||
*/ |
|||
function hyphenToCamelCase(string) { |
|||
return string.replace(/-(.)/g, function(match, chr) { |
|||
return chr.toUpperCase(); |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* Determines if the specified string consists entirely of whitespace. |
|||
*/ |
|||
function isEmpty(string) { |
|||
return !/[^\s]/.test(string); |
|||
} |
|||
|
|||
/** |
|||
* Determines if the specified string consists entirely of numeric characters. |
|||
*/ |
|||
function isNumeric(input) { |
|||
return input !== undefined |
|||
&& input !== null |
|||
&& (typeof input === 'number' || parseInt(input, 10) == input); |
|||
} |
|||
|
|||
var HTMLtoJSX = function(config) { |
|||
this.config = config || {}; |
|||
|
|||
if (this.config.createClass === undefined) { |
|||
this.config.createClass = true; |
|||
} |
|||
if (!this.config.indent) { |
|||
this.config.indent = ' '; |
|||
} |
|||
if (!this.config.outputClassName) { |
|||
this.config.outputClassName = 'NewComponent'; |
|||
} |
|||
}; |
|||
HTMLtoJSX.prototype = { |
|||
/** |
|||
* Reset the internal state of the converter |
|||
*/ |
|||
reset: function() { |
|||
this.output = ''; |
|||
this.level = 0; |
|||
}, |
|||
/** |
|||
* Main entry point to the converter. Given the specified HTML, returns a |
|||
* JSX object representing it. |
|||
* @param {string} html HTML to convert |
|||
* @return {string} JSX |
|||
*/ |
|||
convert: function(html) { |
|||
this.reset(); |
|||
|
|||
// It turns out browsers have good HTML parsers (imagine that).
|
|||
// Let's take advantage of it.
|
|||
var containerEl = document.createElement('div'); |
|||
containerEl.innerHTML = '\n' + this._cleanInput(html) + '\n'; |
|||
|
|||
if (this.config.createClass) { |
|||
if (this.config.outputClassName) { |
|||
this.output = 'var ' + this.config.outputClassName + ' = React.createClass({\n'; |
|||
} else { |
|||
this.output = 'React.createClass({\n'; |
|||
} |
|||
this.output += this.config.indent + 'render: function() {' + "\n"; |
|||
this.output += this.config.indent + this.config.indent + 'return (\n'; |
|||
} |
|||
|
|||
if (this._onlyOneTopLevel(containerEl)) { |
|||
// Only one top-level element, the component can return it directly
|
|||
// No need to actually visit the container element
|
|||
this._traverse(containerEl); |
|||
} else { |
|||
// More than one top-level element, need to wrap the whole thing in a
|
|||
// container.
|
|||
this.output += this.config.indent + this.config.indent + this.config.indent; |
|||
this.level++; |
|||
this._visit(containerEl); |
|||
} |
|||
this.output = this.output.trim() + '\n'; |
|||
if (this.config.createClass) { |
|||
this.output += this.config.indent + this.config.indent + ');\n'; |
|||
this.output += this.config.indent + '}\n'; |
|||
this.output += '});'; |
|||
} |
|||
return this.output; |
|||
}, |
|||
|
|||
/** |
|||
* Cleans up the specified HTML so it's in a format acceptable for |
|||
* converting. |
|||
* |
|||
* @param {string} html HTML to clean |
|||
* @return {string} Cleaned HTML |
|||
*/ |
|||
_cleanInput: function(html) { |
|||
// Remove unnecessary whitespace
|
|||
html = html.trim(); |
|||
// Ugly method to strip script tags. They can wreak havoc on the DOM nodes
|
|||
// so let's not even put them in the DOM.
|
|||
html = html.replace(/<script(.*?)<\/script>/g, ''); |
|||
return html; |
|||
}, |
|||
|
|||
/** |
|||
* Determines if there's only one top-level node in the DOM tree. That is, |
|||
* all the HTML is wrapped by a single HTML tag. |
|||
* |
|||
* @param {DOMElement} containerEl Container element |
|||
* @return {boolean} |
|||
*/ |
|||
_onlyOneTopLevel: function(containerEl) { |
|||
// Only a single child element
|
|||
if ( |
|||
containerEl.childNodes.length === 1 |
|||
&& containerEl.childNodes[0].nodeType === NODE_TYPE.ELEMENT |
|||
) { |
|||
return true; |
|||
} |
|||
// Only one element, and all other children are whitespace
|
|||
var foundElement = false; |
|||
for (var i = 0, count = containerEl.childNodes.length; i < count; i++) { |
|||
var child = containerEl.childNodes[i]; |
|||
if (child.nodeType === NODE_TYPE.ELEMENT) { |
|||
if (foundElement) { |
|||
// Encountered an element after already encountering another one
|
|||
// Therefore, more than one element at root level
|
|||
return false; |
|||
} else { |
|||
foundElement = true; |
|||
} |
|||
} else if (child.nodeType === NODE_TYPE.TEXT && !isEmpty(child.textContent)) { |
|||
// Contains text content
|
|||
return false; |
|||
} |
|||
} |
|||
return true; |
|||
}, |
|||
|
|||
/** |
|||
* Gets a newline followed by the correct indentation for the current |
|||
* nesting level |
|||
* |
|||
* @return {string} |
|||
*/ |
|||
_getIndentedNewline: function() { |
|||
return '\n' + repeatString(this.config.indent, this.level + 2); |
|||
}, |
|||
|
|||
/** |
|||
* Handles processing the specified node |
|||
* |
|||
* @param {Node} node |
|||
*/ |
|||
_visit: function(node) { |
|||
this._beginVisit(node); |
|||
this._traverse(node); |
|||
this._endVisit(node); |
|||
}, |
|||
|
|||
/** |
|||
* Traverses all the children of the specified node |
|||
* |
|||
* @param {Node} node |
|||
*/ |
|||
_traverse: function(node) { |
|||
this.level++; |
|||
for (var i = 0, count = node.childNodes.length; i < count; i++) { |
|||
this._visit(node.childNodes[i]); |
|||
} |
|||
this.level--; |
|||
}, |
|||
|
|||
/** |
|||
* Handle pre-visit behaviour for the specified node. |
|||
* |
|||
* @param {Node} node |
|||
*/ |
|||
_beginVisit: function(node) { |
|||
switch (node.nodeType) { |
|||
case NODE_TYPE.ELEMENT: |
|||
this._beginVisitElement(node); |
|||
break; |
|||
|
|||
case NODE_TYPE.TEXT: |
|||
this._visitText(node); |
|||
break; |
|||
|
|||
case NODE_TYPE.COMMENT: |
|||
this._visitComment(node); |
|||
break; |
|||
|
|||
default: |
|||
console.warn('Unrecognised node type: ' + node.nodeType); |
|||
} |
|||
}, |
|||
|
|||
/** |
|||
* Handles post-visit behaviour for the specified node. |
|||
* |
|||
* @param {Node} node |
|||
*/ |
|||
_endVisit: function(node) { |
|||
switch (node.nodeType) { |
|||
case NODE_TYPE.ELEMENT: |
|||
this._endVisitElement(node); |
|||
break; |
|||
// No ending tags required for these types
|
|||
case NODE_TYPE.TEXT: |
|||
case NODE_TYPE.COMMENT: |
|||
break; |
|||
} |
|||
}, |
|||
|
|||
/** |
|||
* Handles pre-visit behaviour for the specified element node |
|||
* |
|||
* @param {DOMElement} node |
|||
*/ |
|||
_beginVisitElement: function(node) { |
|||
var tagName = node.tagName.toLowerCase(); |
|||
var attributes = []; |
|||
for (var i = 0, count = node.attributes.length; i < count; i++) { |
|||
attributes.push(this._getElementAttribute(node, node.attributes[i])); |
|||
} |
|||
|
|||
this.output += '<' + tagName; |
|||
if (attributes.length > 0) { |
|||
this.output += ' ' + attributes.join(' '); |
|||
} |
|||
if (node.firstChild) { |
|||
this.output += '>'; |
|||
} |
|||
}, |
|||
|
|||
/** |
|||
* Handles post-visit behaviour for the specified element node |
|||
* |
|||
* @param {Node} node |
|||
*/ |
|||
_endVisitElement: function(node) { |
|||
// De-indent a bit
|
|||
// TODO: It's inefficient to do it this way :/
|
|||
this.output = trimEnd(this.output, this.config.indent); |
|||
if (node.firstChild) { |
|||
this.output += '</' + node.tagName.toLowerCase() + '>'; |
|||
} else { |
|||
this.output += ' />'; |
|||
} |
|||
}, |
|||
|
|||
/** |
|||
* Handles processing of the specified text node |
|||
* |
|||
* @param {TextNode} node |
|||
*/ |
|||
_visitText: function(node) { |
|||
var text = node.textContent; |
|||
// If there's a newline in the text, adjust the indent level
|
|||
if (text.indexOf('\n') > -1) { |
|||
text = node.textContent.replace(/\n\s*/g, this._getIndentedNewline()); |
|||
} |
|||
this.output += text; |
|||
}, |
|||
|
|||
/** |
|||
* Handles processing of the specified text node |
|||
* |
|||
* @param {Text} node |
|||
*/ |
|||
_visitComment: function(node) { |
|||
// Do not render the comment
|
|||
// Since we remove comments, we also need to remove the next line break so we
|
|||
// don't end up with extra whitespace after every comment
|
|||
//if (node.nextSibling && node.nextSibling.nodeType === NODE_TYPE.TEXT) {
|
|||
// node.nextSibling.textContent = node.nextSibling.textContent.replace(/\n\s*/, '');
|
|||
//}
|
|||
this.output += '{/*' + node.textContent.replace('*/', '* /') + '*/}'; |
|||
}, |
|||
|
|||
/** |
|||
* Gets a JSX formatted version of the specified attribute from the node |
|||
* |
|||
* @param {DOMElement} node |
|||
* @param {object} attribute |
|||
* @return {string} |
|||
*/ |
|||
_getElementAttribute: function(node, attribute) { |
|||
switch (attribute.name) { |
|||
case 'style': |
|||
return this._getStyleAttribute(attribute.value); |
|||
default: |
|||
var name = ATTRIBUTE_MAPPING[attribute.name] || attribute.name; |
|||
var result = name + '='; |
|||
// Numeric values should be output as {123} not "123"
|
|||
if (isNumeric(attribute.value)) { |
|||
result += '{' + attribute.value + '}'; |
|||
} else { |
|||
result += '"' + attribute.value.replace('"', '"') + '"'; |
|||
} |
|||
return result; |
|||
} |
|||
}, |
|||
|
|||
/** |
|||
* Gets a JSX formatted version of the specified element styles |
|||
* |
|||
* @param {string} styles |
|||
* @return {string} |
|||
*/ |
|||
_getStyleAttribute: function(styles) { |
|||
var jsxStyles = new StyleParser(styles).toJSXString(); |
|||
return 'style={{' + jsxStyles + '}}'; |
|||
} |
|||
}; |
|||
|
|||
/** |
|||
* Handles parsing of inline styles |
|||
* |
|||
* @param {string} rawStyle Raw style attribute |
|||
* @constructor |
|||
*/ |
|||
var StyleParser = function(rawStyle) { |
|||
this.parse(rawStyle); |
|||
}; |
|||
StyleParser.prototype = { |
|||
/** |
|||
* Parse the specified inline style attribute value |
|||
* @param {string} rawStyle Raw style attribute |
|||
*/ |
|||
parse: function(rawStyle) { |
|||
this.styles = {}; |
|||
rawStyle.split(';').forEach(function(style) { |
|||
style = style.trim(); |
|||
var firstColon = style.indexOf(':'); |
|||
var key = style.substr(0, firstColon); |
|||
var value = style.substr(firstColon + 1).trim(); |
|||
if (key !== '') { |
|||
this.styles[key] = value; |
|||
} |
|||
}, this); |
|||
}, |
|||
|
|||
/** |
|||
* Convert the style information represented by this parser into a JSX |
|||
* string |
|||
* |
|||
* @return {string} |
|||
*/ |
|||
toJSXString: function() { |
|||
var output = []; |
|||
for (var key in this.styles) { |
|||
if (!this.styles.hasOwnProperty(key)) { |
|||
continue; |
|||
} |
|||
output.push(this.toJSXKey(key) + ': ' + this.toJSXValue(this.styles[key])); |
|||
} |
|||
return output.join(', '); |
|||
}, |
|||
|
|||
/** |
|||
* Convert the CSS style key to a JSX style key |
|||
* |
|||
* @param {string} key CSS style key |
|||
* @return {string} JSX style key |
|||
*/ |
|||
toJSXKey: function(key) { |
|||
return hyphenToCamelCase(key); |
|||
}, |
|||
|
|||
/** |
|||
* Convert the CSS style value to a JSX style value |
|||
* |
|||
* @param {string} value CSS style value |
|||
* @return {string} JSX style value |
|||
*/ |
|||
toJSXValue: function(value) { |
|||
if (isNumeric(value)) { |
|||
// If numeric, no quotes
|
|||
return value; |
|||
} else if (endsWith(value, 'px')) { |
|||
// "500px" -> 500
|
|||
return trimEnd(value, 'px'); |
|||
} else { |
|||
// Proably a string, wrap it in quotes
|
|||
return '\'' + value.replace(/'/g, '"') + '\''; |
|||
} |
|||
} |
|||
}; |
|||
|
|||
// Expose public API
|
|||
global.HTMLtoJSX = HTMLtoJSX; |
|||
}(window)); |
|||
// Ideally it would be nice to just redirect, but Github Pages is very basic and
|
|||
// lacks that functionality.
|
|||
console.warn( |
|||
'html-jsx-lib.js has moved to http://reactjs.github.io/react-magic/' + |
|||
'htmltojsx.min.js. If using React-Magic, you are no longer required to ' + |
|||
'link to this file. Please delete its <script> tag.' |
|||
); |
|||
|
Loading…
Reference in new issue