From 03b64bef0c026c9d7f850d078cc07f75f1248152 Mon Sep 17 00:00:00 2001 From: Daniel Lo Nigro Date: Sat, 14 Dec 2013 00:39:27 -0800 Subject: [PATCH] Simple HTML to JSX converter, built during Hackathon 40 at Facebook. See /react/html-jsx.html. Not directly linked from the site yet as there may still be some minor issues with it. --- _js/html-jsx-lib.js | 482 ++++++++++++++++++++++++++++++++++++++++++++ _js/html-jsx.js | 89 ++++++++ _js/jsx-compiler.js | 9 +- _js/live_editor.js | 18 +- html-jsx.md | 11 + 5 files changed, 602 insertions(+), 7 deletions(-) create mode 100644 _js/html-jsx-lib.js create mode 100644 _js/html-jsx.js create mode 100644 html-jsx.md diff --git a/_js/html-jsx-lib.js b/_js/html-jsx-lib.js new file mode 100644 index 00000000..9029c53a --- /dev/null +++ b/_js/html-jsx-lib.js @@ -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(//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 += ''; + } 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)); \ No newline at end of file diff --git a/_js/html-jsx.js b/_js/html-jsx.js new file mode 100644 index 00000000..b12ea5fd --- /dev/null +++ b/_js/html-jsx.js @@ -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 = "\ +\n\ +
\n\ + \n\ + \n\ +
\n\ +

Enter your HTML here

\ +"; + + 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 ( +
+
+ + +
+ +
+ ); + } + }); + + React.renderComponent(, document.getElementById('jsxCompiler')); +}()); \ No newline at end of file diff --git a/_js/jsx-compiler.js b/_js/jsx-compiler.js index 02734646..c98ee5b5 100644 --- a/_js/jsx-compiler.js +++ b/_js/jsx-compiler.js @@ -13,7 +13,14 @@ var HelloMessage = React.createClass({\n\ React.renderComponent(, mountNode);\ "; +var transformer = function(code) { + return JSXTransformer.transform(code).code; +} React.renderComponent( - , + , document.getElementById('jsxCompiler') ); diff --git a/_js/live_editor.js b/_js/live_editor.js index e03bde05..e099ad41 100644 --- a/_js/live_editor.js +++ b/_js/live_editor.js @@ -55,6 +55,12 @@ var CodeMirrorEditor = React.createClass({ var ReactPlayground = React.createClass({ MODES: {XJS: 'XJS', JS: 'JS'}, //keyMirror({XJS: true, JS: true}), + propTypes: { + codeText: React.PropTypes.string.isRequired, + transformer: React.PropTypes.func.isRequired, + renderCode: React.PropTypes.bool, + }, + getInitialState: function() { return {mode: this.MODES.XJS, code: this.props.codeText}; }, @@ -67,8 +73,8 @@ var ReactPlayground = React.createClass({ }.bind(this); }, - getDesugaredCode: function() { - return JSXTransformer.transform(this.state.code).code; + compileCode: function() { + return this.props.transformer(this.state.code); }, render: function() { @@ -83,7 +89,7 @@ var ReactPlayground = React.createClass({ } else if (this.state.mode === this.MODES.JS) { content =
- {this.getDesugaredCode()} + {this.compileCode()}
; } @@ -112,14 +118,14 @@ var ReactPlayground = React.createClass({ } catch (e) { } try { - var desugaredCode = this.getDesugaredCode(); + var compiledCode = this.compileCode(); if (this.props.renderCode) { React.renderComponent( - , + , mountNode ); } else { - eval(desugaredCode); + eval(compiledCode); } } catch (e) { React.renderComponent( diff --git a/html-jsx.md b/html-jsx.md new file mode 100644 index 00000000..b013b545 --- /dev/null +++ b/html-jsx.md @@ -0,0 +1,11 @@ +--- +layout: default +title: HTML to JSX +id: html-jsx +--- +
+

HTML to JSX Compiler

+
+ + +