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