@samitouri / QOS-React-2 / commits / eab1f4d366

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.

Daniel Lo Nigro committed Dec 14, 2013 at 00:39 UTC eab1f4d36624107661a94d058e06277e51edaf49
6 files changed +603 -14
.gitignore
+1 -7
@@ -13,13 +13,7 @@ docs/code
13 docs/_site
14 docs/.sass-cache
15 docs/css/react.css
16 -docs/js/.module-cache
17 -docs/js/JSXTransformer.js
18 -docs/js/react.min.js
19 -docs/js/docs.js
20 -docs/js/jsx-compiler.js
21 -docs/js/live_editor.js
22 -docs/js/examples
16 +docs/js/*
17 docs/downloads
18 examples/shared/*.js
19 test/the-files-to-test.generated.js
docs/_js/html-jsx-lib.js new
+482
@@ -0,0 +1,482 @@
1 +/**
2 + * Copyright 2013 Facebook, Inc.
3 + *
4 + * Licensed under the Apache License, Version 2.0 (the "License");
5 + * you may not use this file except in compliance with the License.
6 + * You may obtain a copy of the License at
7 + *
8 + * http://www.apache.org/licenses/LICENSE-2.0
9 + *
10 + * Unless required by applicable law or agreed to in writing, software
11 + * distributed under the License is distributed on an "AS IS" BASIS,
12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 + * See the License for the specific language governing permissions and
14 + * limitations under the License.
15 + */
16 +
17 +/**
18 + * This is a very simple HTML to JSX converter. It turns out that browsers
19 + * have good HTML parsers (who would have thought?) so we utilise this by
20 + * inserting the HTML into a temporary DOM node, and then do a breadth-first
21 + * traversal of the resulting DOM tree.
22 + */
23 +;(function(global) {
24 + 'use strict';
25 +
26 + // https://developer.mozilla.org/en-US/docs/Web/API/Node.nodeType
27 + var NODE_TYPE = {
28 + ELEMENT: 1,
29 + TEXT: 3,
30 + COMMENT: 8
31 + };
32 + var ATTRIBUTE_MAPPING = {
33 + 'for': 'htmlFor',
34 + 'class': 'className'
35 + };
36 +
37 + /**
38 + * Repeats a string a certain number of times.
39 + * Also: the future is bright and consists of native string repetition:
40 + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/repeat
41 + *
42 + * @param {string} string String to repeat
43 + * @param {number} times Number of times to repeat string. Integer.
44 + * @see http://jsperf.com/string-repeater/2
45 + */
46 + function repeatString(string, times) {
47 + if (times === 1) {
48 + return string;
49 + }
50 + if (times < 0) { throw new Error(); }
51 + var repeated = '';
52 + while (times) {
53 + if (times & 1) {
54 + repeated += string;
55 + }
56 + if (times >>= 1) {
57 + string += string;
58 + }
59 + }
60 + return repeated;
61 + }
62 +
63 + /**
64 + * Determine if the string ends with the specified substring.
65 + *
66 + * @param {string} haystack String to search in
67 + * @param {string} needle String to search for
68 + * @return {boolean}
69 + */
70 + function endsWith(haystack, needle) {
71 + return haystack.slice(-needle.length) === needle;
72 + }
73 +
74 + /**
75 + * Trim the specified substring off the string. If the string does not end
76 + * with the specified substring, this is a no-op.
77 + *
78 + * @param {string} haystack String to search in
79 + * @param {string} needle String to search for
80 + * @return {string}
81 + */
82 + function trimEnd(haystack, needle) {
83 + return endsWith(haystack, needle)
84 + ? haystack.slice(0, -needle.length)
85 + : haystack;
86 + }
87 +
88 + /**
89 + * Convert a hyphenated string to camelCase.
90 + */
91 + function hyphenToCamelCase(string) {
92 + return string.replace(/-(.)/g, function(match, chr) {
93 + return chr.toUpperCase();
94 + });
95 + }
96 +
97 + /**
98 + * Determines if the specified string consists entirely of whitespace.
99 + */
100 + function isEmpty(string) {
101 + return !/[^\s]/.test(string);
102 + }
103 +
104 + /**
105 + * Determines if the specified string consists entirely of numeric characters.
106 + */
107 + function isNumeric(input) {
108 + return input !== undefined
109 + && input !== null
110 + && (typeof input === 'number' || parseInt(input, 10) == input);
111 + }
112 +
113 + var HTMLtoJSX = function(config) {
114 + this.config = config || {};
115 +
116 + if (this.config.createClass === undefined) {
117 + this.config.createClass = true;
118 + }
119 + if (!this.config.indent) {
120 + this.config.indent = ' ';
121 + }
122 + if (!this.config.outputClassName) {
123 + this.config.outputClassName = 'NewComponent';
124 + }
125 + };
126 + HTMLtoJSX.prototype = {
127 + /**
128 + * Reset the internal state of the converter
129 + */
130 + reset: function() {
131 + this.output = '';
132 + this.level = 0;
133 + },
134 + /**
135 + * Main entry point to the converter. Given the specified HTML, returns a
136 + * JSX object representing it.
137 + * @param {string} html HTML to convert
138 + * @return {string} JSX
139 + */
140 + convert: function(html) {
141 + this.reset();
142 +
143 + // It turns out browsers have good HTML parsers (imagine that).
144 + // Let's take advantage of it.
145 + var containerEl = document.createElement('div');
146 + containerEl.innerHTML = '\n' + this._cleanInput(html) + '\n';
147 +
148 + if (this.config.createClass) {
149 + if (this.config.outputClassName) {
150 + this.output = 'var ' + this.config.outputClassName + ' = React.createClass({\n';
151 + } else {
152 + this.output = 'React.createClass({\n';
153 + }
154 + this.output += this.config.indent + 'render: function() {' + "\n";
155 + this.output += this.config.indent + this.config.indent + 'return (\n';
156 + }
157 +
158 + if (this._onlyOneTopLevel(containerEl)) {
159 + // Only one top-level element, the component can return it directly
160 + // No need to actually visit the container element
161 + this._traverse(containerEl);
162 + } else {
163 + // More than one top-level element, need to wrap the whole thing in a
164 + // container.
165 + this.output += this.config.indent + this.config.indent + this.config.indent;
166 + this.level++;
167 + this._visit(containerEl);
168 + }
169 + this.output = this.output.trim() + '\n';
170 + if (this.config.createClass) {
171 + this.output += this.config.indent + this.config.indent + ');\n';
172 + this.output += this.config.indent + '}\n';
173 + this.output += '});';
174 + }
175 + return this.output;
176 + },
177 +
178 + /**
179 + * Cleans up the specified HTML so it's in a format acceptable for
180 + * converting.
181 + *
182 + * @param {string} html HTML to clean
183 + * @return {string} Cleaned HTML
184 + */
185 + _cleanInput: function(html) {
186 + // Remove unnecessary whitespace
187 + html = html.trim();
188 + // Ugly method to strip script tags. They can wreak havoc on the DOM nodes
189 + // so let's not even put them in the DOM.
190 + html = html.replace(/<script(.*?)<\/script>/g, '');
191 + return html;
192 + },
193 +
194 + /**
195 + * Determines if there's only one top-level node in the DOM tree. That is,
196 + * all the HTML is wrapped by a single HTML tag.
197 + *
198 + * @param {DOMElement} containerEl Container element
199 + * @return {boolean}
200 + */
201 + _onlyOneTopLevel: function(containerEl) {
202 + // Only a single child element
203 + if (
204 + containerEl.childNodes.length === 1
205 + && containerEl.childNodes[0].nodeType === NODE_TYPE.ELEMENT
206 + ) {
207 + return true;
208 + }
209 + // Only one element, and all other children are whitespace
210 + var foundElement = false;
211 + for (var i = 0, count = containerEl.childNodes.length; i < count; i++) {
212 + var child = containerEl.childNodes[i];
213 + if (child.nodeType === NODE_TYPE.ELEMENT) {
214 + if (foundElement) {
215 + // Encountered an element after already encountering another one
216 + // Therefore, more than one element at root level
217 + return false;
218 + } else {
219 + foundElement = true;
220 + }
221 + } else if (child.nodeType === NODE_TYPE.TEXT && !isEmpty(child.textContent)) {
222 + // Contains text content
223 + return false;
224 + }
225 + }
226 + return true;
227 + },
228 +
229 + /**
230 + * Gets a newline followed by the correct indentation for the current
231 + * nesting level
232 + *
233 + * @return {string}
234 + */
235 + _getIndentedNewline: function() {
236 + return '\n' + repeatString(this.config.indent, this.level + 2);
237 + },
238 +
239 + /**
240 + * Handles processing the specified node
241 + *
242 + * @param {Node} node
243 + */
244 + _visit: function(node) {
245 + this._beginVisit(node);
246 + this._traverse(node);
247 + this._endVisit(node);
248 + },
249 +
250 + /**
251 + * Traverses all the children of the specified node
252 + *
253 + * @param {Node} node
254 + */
255 + _traverse: function(node) {
256 + this.level++;
257 + for (var i = 0, count = node.childNodes.length; i < count; i++) {
258 + this._visit(node.childNodes[i]);
259 + }
260 + this.level--;
261 + },
262 +
263 + /**
264 + * Handle pre-visit behaviour for the specified node.
265 + *
266 + * @param {Node} node
267 + */
268 + _beginVisit: function(node) {
269 + switch (node.nodeType) {
270 + case NODE_TYPE.ELEMENT:
271 + this._beginVisitElement(node);
272 + break;
273 +
274 + case NODE_TYPE.TEXT:
275 + this._visitText(node);
276 + break;
277 +
278 + case NODE_TYPE.COMMENT:
279 + this._visitComment(node);
280 + break;
281 +
282 + default:
283 + console.warn('Unrecognised node type: ' + node.nodeType);
284 + }
285 + },
286 +
287 + /**
288 + * Handles post-visit behaviour for the specified node.
289 + *
290 + * @param {Node} node
291 + */
292 + _endVisit: function(node) {
293 + switch (node.nodeType) {
294 + case NODE_TYPE.ELEMENT:
295 + this._endVisitElement(node);
296 + break;
297 + // No ending tags required for these types
298 + case NODE_TYPE.TEXT:
299 + case NODE_TYPE.COMMENT:
300 + break;
301 + }
302 + },
303 +
304 + /**
305 + * Handles pre-visit behaviour for the specified element node
306 + *
307 + * @param {DOMElement} node
308 + */
309 + _beginVisitElement: function(node) {
310 + var tagName = node.tagName.toLowerCase();
311 + var attributes = [];
312 + for (var i = 0, count = node.attributes.length; i < count; i++) {
313 + attributes.push(this._getElementAttribute(node, node.attributes[i]));
314 + }
315 +
316 + this.output += '<' + tagName;
317 + if (attributes.length > 0) {
318 + this.output += ' ' + attributes.join(' ');
319 + }
320 + if (node.firstChild) {
321 + this.output += '>';
322 + }
323 + },
324 +
325 + /**
326 + * Handles post-visit behaviour for the specified element node
327 + *
328 + * @param {Node} node
329 + */
330 + _endVisitElement: function(node) {
331 + // De-indent a bit
332 + // TODO: It's inefficient to do it this way :/
333 + this.output = trimEnd(this.output, this.config.indent);
334 + if (node.firstChild) {
335 + this.output += '</' + node.tagName.toLowerCase() + '>';
336 + } else {
337 + this.output += ' />';
338 + }
339 + },
340 +
341 + /**
342 + * Handles processing of the specified text node
343 + *
344 + * @param {TextNode} node
345 + */
346 + _visitText: function(node) {
347 + var text = node.textContent;
348 + // If there's a newline in the text, adjust the indent level
349 + if (text.indexOf('\n') > -1) {
350 + text = node.textContent.replace(/\n\s*/g, this._getIndentedNewline());
351 + }
352 + this.output += text;
353 + },
354 +
355 + /**
356 + * Handles processing of the specified text node
357 + *
358 + * @param {Text} node
359 + */
360 + _visitComment: function(node) {
361 + // Do not render the comment
362 + // Since we remove comments, we also need to remove the next line break so we
363 + // don't end up with extra whitespace after every comment
364 + //if (node.nextSibling && node.nextSibling.nodeType === NODE_TYPE.TEXT) {
365 + // node.nextSibling.textContent = node.nextSibling.textContent.replace(/\n\s*/, '');
366 + //}
367 + this.output += '{/*' + node.textContent.replace('*/', '* /') + '*/}';
368 + },
369 +
370 + /**
371 + * Gets a JSX formatted version of the specified attribute from the node
372 + *
373 + * @param {DOMElement} node
374 + * @param {object} attribute
375 + * @return {string}
376 + */
377 + _getElementAttribute: function(node, attribute) {
378 + switch (attribute.name) {
379 + case 'style':
380 + return this._getStyleAttribute(attribute.value);
381 + default:
382 + var name = ATTRIBUTE_MAPPING[attribute.name] || attribute.name;
383 + var result = name + '=';
384 + // Numeric values should be output as {123} not "123"
385 + if (isNumeric(attribute.value)) {
386 + result += '{' + attribute.value + '}';
387 + } else {
388 + result += '"' + attribute.value.replace('"', '&quot;') + '"';
389 + }
390 + return result;
391 + }
392 + },
393 +
394 + /**
395 + * Gets a JSX formatted version of the specified element styles
396 + *
397 + * @param {string} styles
398 + * @return {string}
399 + */
400 + _getStyleAttribute: function(styles) {
401 + var jsxStyles = new StyleParser(styles).toJSXString();
402 + return 'style={{' + jsxStyles + '}}';
403 + }
404 + };
405 +
406 + /**
407 + * Handles parsing of inline styles
408 + *
409 + * @param {string} rawStyle Raw style attribute
410 + * @constructor
411 + */
412 + var StyleParser = function(rawStyle) {
413 + this.parse(rawStyle);
414 + };
415 + StyleParser.prototype = {
416 + /**
417 + * Parse the specified inline style attribute value
418 + * @param {string} rawStyle Raw style attribute
419 + */
420 + parse: function(rawStyle) {
421 + this.styles = {};
422 + rawStyle.split(';').forEach(function(style) {
423 + style = style.trim();
424 + var firstColon = style.indexOf(':');
425 + var key = style.substr(0, firstColon);
426 + var value = style.substr(firstColon + 1).trim();
427 + if (key !== '') {
428 + this.styles[key] = value;
429 + }
430 + }, this);
431 + },
432 +
433 + /**
434 + * Convert the style information represented by this parser into a JSX
435 + * string
436 + *
437 + * @return {string}
438 + */
439 + toJSXString: function() {
440 + var output = [];
441 + for (var key in this.styles) {
442 + if (!this.styles.hasOwnProperty(key)) {
443 + continue;
444 + }
445 + output.push(this.toJSXKey(key) + ': ' + this.toJSXValue(this.styles[key]));
446 + }
447 + return output.join(', ');
448 + },
449 +
450 + /**
451 + * Convert the CSS style key to a JSX style key
452 + *
453 + * @param {string} key CSS style key
454 + * @return {string} JSX style key
455 + */
456 + toJSXKey: function(key) {
457 + return hyphenToCamelCase(key);
458 + },
459 +
460 + /**
461 + * Convert the CSS style value to a JSX style value
462 + *
463 + * @param {string} value CSS style value
464 + * @return {string} JSX style value
465 + */
466 + toJSXValue: function(value) {
467 + if (isNumeric(value)) {
468 + // If numeric, no quotes
469 + return value;
470 + } else if (endsWith(value, 'px')) {
471 + // "500px" -> 500
472 + return trimEnd(value, 'px');
473 + } else {
474 + // Proably a string, wrap it in quotes
475 + return '\'' + value.replace(/'/g, '"') + '\'';
476 + }
477 + }
478 + };
479 +
480 + // Expose public API
481 + global.HTMLtoJSX = HTMLtoJSX;
482 +}(window));
\ No newline at end of file
docs/_js/html-jsx.js new
+89
@@ -0,0 +1,89 @@
1 +/**
2 + * Copyright 2013 Facebook, Inc.
3 + *
4 + * Licensed under the Apache License, Version 2.0 (the "License");
5 + * you may not use this file except in compliance with the License.
6 + * You may obtain a copy of the License at
7 + *
8 + * http://www.apache.org/licenses/LICENSE-2.0
9 + *
10 + * Unless required by applicable law or agreed to in writing, software
11 + * distributed under the License is distributed on an "AS IS" BASIS,
12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 + * See the License for the specific language governing permissions and
14 + * limitations under the License.
15 + *
16 + * @jsx React.DOM
17 + */
18 +
19 +/**
20 + * This is a web interface for the HTML to JSX converter contained in
21 + * `html-jsx-lib.js`.
22 + */
23 +;(function() {
24 +
25 +var HELLO_COMPONENT = "\
26 +<!-- Hello world -->\n\
27 +<div class=\"awesome\" style=\"border: 1px solid red\">\n\
28 + <label for=\"name\">Enter your name: </label>\n\
29 + <input type=\"text\" id=\"name\" />\n\
30 +</div>\n\
31 +<p>Enter your HTML here</p>\
32 +";
33 +
34 + var HTMLtoJSXComponent = React.createClass({
35 + getInitialState: function() {
36 + return {
37 + outputClassName: 'NewComponent',
38 + createClass: true
39 + };
40 + },
41 + onReactClassNameChange: function(evt) {
42 + this.setState({ outputClassName: evt.target.value });
43 + },
44 + onCreateClassChange: function(evt) {
45 + this.setState({ createClass: evt.target.checked });
46 + },
47 + setInput: function(input) {
48 + this.setState({ input: input });
49 + this.convertToJsx();
50 + },
51 + convertToJSX: function(input) {
52 + var converter = new HTMLtoJSX({
53 + outputClassName: this.state.outputClassName,
54 + createClass: this.state.createClass
55 + });
56 + return converter.convert(input);
57 + },
58 + render: function() {
59 + return (
60 + <div>
61 + <div id="options">
62 + <label>
63 + <input
64 + type="checkbox"
65 + checked={this.state.createClass}
66 + onChange={this.onCreateClassChange} />
67 + Create class
68 + </label>
69 + <label style={{display: this.state.createClass ? '' : 'none'}}>
70 + ·
71 + Class name:
72 + <input
73 + type="text"
74 + value={this.state.outputClassName}
75 + onChange={this.onReactClassNameChange} />
76 + </label>
77 + </div>
78 + <ReactPlayground
79 + codeText={HELLO_COMPONENT}
80 + renderCode={true}
81 + transformer={this.convertToJSX}
82 + />
83 + </div>
84 + );
85 + }
86 + });
87 +
88 + React.renderComponent(<HTMLtoJSXComponent />, document.getElementById('jsxCompiler'));
89 +}());
\ No newline at end of file
docs/_js/jsx-compiler.js
+8 -1
@@ -13,7 +13,14 @@ var HelloMessage = React.createClass({\n\
13 React.renderComponent(<HelloMessage name=\"John\" />, mountNode);\
14 ";
15
16 +var transformer = function(code) {
17 + return JSXTransformer.transform(code).code;
18 +}
19 React.renderComponent(
17 - <ReactPlayground codeText={HELLO_COMPONENT} renderCode={true} />,
20 + <ReactPlayground
21 + codeText={HELLO_COMPONENT}
22 + renderCode={true}
23 + transformer={transformer}
24 + />,
25 document.getElementById('jsxCompiler')
26 );
docs/_js/live_editor.js
+12 -6
@@ -55,6 +55,12 @@ var CodeMirrorEditor = React.createClass({
55 var ReactPlayground = React.createClass({
56 MODES: {XJS: 'XJS', JS: 'JS'}, //keyMirror({XJS: true, JS: true}),
57
58 + propTypes: {
59 + codeText: React.PropTypes.string.isRequired,
60 + transformer: React.PropTypes.func.isRequired,
61 + renderCode: React.PropTypes.bool,
62 + },
63 +
64 getInitialState: function() {
65 return {mode: this.MODES.XJS, code: this.props.codeText};
66 },
@@ -67,8 +73,8 @@ var ReactPlayground = React.createClass({
73 }.bind(this);
74 },
75
70 - getDesugaredCode: function() {
71 - return JSXTransformer.transform(this.state.code).code;
76 + compileCode: function() {
77 + return this.props.transformer(this.state.code);
78 },
79
80 render: function() {
@@ -83,7 +89,7 @@ var ReactPlayground = React.createClass({
89 } else if (this.state.mode === this.MODES.JS) {
90 content =
91 <div className="playgroundJS playgroundStage">
86 - {this.getDesugaredCode()}
92 + {this.compileCode()}
93 </div>;
94 }
95
@@ -112,14 +118,14 @@ var ReactPlayground = React.createClass({
118 } catch (e) { }
119
120 try {
115 - var desugaredCode = this.getDesugaredCode();
121 + var compiledCode = this.compileCode();
122 if (this.props.renderCode) {
123 React.renderComponent(
118 - <CodeMirrorEditor codeText={desugaredCode} readOnly={true} />,
124 + <CodeMirrorEditor codeText={compiledCode} readOnly={true} />,
125 mountNode
126 );
127 } else {
122 - eval(desugaredCode);
128 + eval(compiledCode);
129 }
130 } catch (e) {
131 React.renderComponent(
docs/html-jsx.md new
+11
@@ -0,0 +1,11 @@
1 +---
2 +layout: default
3 +title: HTML to JSX
4 +id: html-jsx
5 +---
6 +<div class="jsxCompiler">
7 + <h1>HTML to JSX Compiler</h1>
8 + <div id="jsxCompiler"></div>
9 + <script src="js/html-jsx-lib.js"></script>
10 + <script src="js/html-jsx.js"></script>
11 +</div>