Browse Source
See /react/html-jsx.html. Not directly linked from the site yet as there may still be some minor issues with it.main
Daniel Lo Nigro
11 years ago
5 changed files with 602 additions and 7 deletions
@ -0,0 +1,482 @@ |
|||||
|
/** |
||||
|
* Copyright 2013 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)); |
@ -0,0 +1,89 @@ |
|||||
|
/** |
||||
|
* Copyright 2013 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. |
||||
|
* |
||||
|
* @jsx React.DOM |
||||
|
*/ |
||||
|
|
||||
|
/** |
||||
|
* This is a web interface for the HTML to JSX converter contained in |
||||
|
* `html-jsx-lib.js`. |
||||
|
*/ |
||||
|
;(function() { |
||||
|
|
||||
|
var HELLO_COMPONENT = "\ |
||||
|
<!-- Hello world -->\n\ |
||||
|
<div class=\"awesome\" style=\"border: 1px solid red\">\n\ |
||||
|
<label for=\"name\">Enter your name: </label>\n\ |
||||
|
<input type=\"text\" id=\"name\" />\n\ |
||||
|
</div>\n\ |
||||
|
<p>Enter your HTML here</p>\ |
||||
|
"; |
||||
|
|
||||
|
var HTMLtoJSXComponent = React.createClass({ |
||||
|
getInitialState: function() { |
||||
|
return { |
||||
|
outputClassName: 'NewComponent', |
||||
|
createClass: true |
||||
|
}; |
||||
|
}, |
||||
|
onReactClassNameChange: function(evt) { |
||||
|
this.setState({ outputClassName: evt.target.value }); |
||||
|
}, |
||||
|
onCreateClassChange: function(evt) { |
||||
|
this.setState({ createClass: evt.target.checked }); |
||||
|
}, |
||||
|
setInput: function(input) { |
||||
|
this.setState({ input: input }); |
||||
|
this.convertToJsx(); |
||||
|
}, |
||||
|
convertToJSX: function(input) { |
||||
|
var converter = new HTMLtoJSX({ |
||||
|
outputClassName: this.state.outputClassName, |
||||
|
createClass: this.state.createClass |
||||
|
}); |
||||
|
return converter.convert(input); |
||||
|
}, |
||||
|
render: function() { |
||||
|
return ( |
||||
|
<div> |
||||
|
<div id="options"> |
||||
|
<label> |
||||
|
<input |
||||
|
type="checkbox" |
||||
|
checked={this.state.createClass} |
||||
|
onChange={this.onCreateClassChange} /> |
||||
|
Create class |
||||
|
</label> |
||||
|
<label style={{display: this.state.createClass ? '' : 'none'}}> |
||||
|
· |
||||
|
Class name: |
||||
|
<input |
||||
|
type="text" |
||||
|
value={this.state.outputClassName} |
||||
|
onChange={this.onReactClassNameChange} /> |
||||
|
</label> |
||||
|
</div> |
||||
|
<ReactPlayground |
||||
|
codeText={HELLO_COMPONENT} |
||||
|
renderCode={true} |
||||
|
transformer={this.convertToJSX} |
||||
|
/> |
||||
|
</div> |
||||
|
); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
React.renderComponent(<HTMLtoJSXComponent />, document.getElementById('jsxCompiler')); |
||||
|
}()); |
@ -0,0 +1,11 @@ |
|||||
|
--- |
||||
|
layout: default |
||||
|
title: HTML to JSX |
||||
|
id: html-jsx |
||||
|
--- |
||||
|
<div class="jsxCompiler"> |
||||
|
<h1>HTML to JSX Compiler</h1> |
||||
|
<div id="jsxCompiler"></div> |
||||
|
<script src="js/html-jsx-lib.js"></script> |
||||
|
<script src="js/html-jsx.js"></script> |
||||
|
</div> |
Loading…
Reference in new issue