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('"', '"') + '"';
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>