Last active
December 10, 2020 07:59
-
-
Save ConradSollitt/f88166e079da3d00cc7a3e40847392a8 to your computer and use it in GitHub Desktop.
Convert JSX to JS - JSX Transformer / Compiler / Transpiler
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* This file is based on the DataFormsJS jsxLoader which is a | |
* small browser based compiler for JSX / React. | |
* | |
* The main file is intended for browser use with a <script> tag | |
* so needed portions of the code have copied here so that it can | |
* be optimized with webpack and node using a simple API. | |
* | |
* @link https://www.dataformsjs.com | |
* @link https://github.com/dataformsjs/dataformsjs/blob/master/js/react/jsxLoader.js | |
* @link https://github.com/dataformsjs/dataformsjs/blob/master/docs/jsx-loader.md | |
* @author Conrad Sollitt (https://conradsollitt.com) | |
* @license MIT | |
*/ | |
/* Validates with both [eslint] and [jshint] */ | |
/* eslint-env browser */ | |
/* eslint quotes: ["error", "single", { "avoidEscape": true }] */ | |
/* eslint strict: ["error", "function"] */ | |
/* eslint spaced-comment: ["error", "always"] */ | |
/* eslint no-console: ["error", { allow: ["log", "warn", "error"] }] */ | |
/* eslint no-prototype-builtins: "off" */ | |
/* jshint esversion: 6 */ | |
// Enums "JavaScript Objects" for Tokens and AST. | |
// This makes typing and searching for related code easier. | |
const tokenTypes = { | |
js: 0, | |
e_start: 1, | |
e_end: 2, | |
e_prop: 3, | |
e_value: 4, | |
e_child_text: 5, | |
e_child_js: 6, | |
e_child_whitespace: 7, | |
e_child_js_start: 8, | |
e_child_js_end: 9, | |
}; | |
const astTypes = { | |
program: 0, | |
js: 1, | |
createElement: 2, | |
}; | |
// Convert enum props to strings so they can be viewed easily from DevTools | |
const enums = [tokenTypes, astTypes]; | |
for (let n = 0, m = enums.length; n < m; n++) { | |
const obj = enums[n]; | |
for (let prop in obj) { | |
if (obj.hasOwnProperty(prop)) { | |
obj[prop] = prop; | |
} | |
} | |
} | |
/** | |
* Compiler for converting JSX Code to JavaScript. | |
* See comments in the original file for details. | |
*/ | |
const jsxLoader = { | |
compiler: { | |
/** | |
* Compiler Options | |
*/ | |
pragma: 'React.createElement', | |
pragmaFrag: 'React.Fragment', | |
maxRecursiveCalls: 1000, | |
addUseStrict: true, | |
/** | |
* Compile JSX to JS | |
* | |
* @param {string} input | |
* @return {string} | |
*/ | |
compile: function(input) { | |
// Compiler Step 1 - Remove Comments from the Code | |
var newInput = this.removeComments(input); | |
// Compiler Step 2 (Lexical Analysis) - Convert JSX Code to an array of tokens | |
var tokens = this.tokenizer(newInput); | |
if (jsxLoader.logCompileDetails) { | |
console.log(tokens); | |
} | |
// Compiler Step 3 (Syntactic Analysis) - Convert Tokens to an Abstract Syntax Tree (AST) | |
var ast = this.parser(tokens, input); | |
if (jsxLoader.logCompileDetails) { | |
console.log(ast); | |
} | |
// Compiler Step 4 (Code Generation) - Convert AST to Code | |
var output = this.codeGenerator(ast, input); | |
return output; | |
}, | |
/** | |
* Helper function to return line/column numbers when an error occurs | |
* | |
* @param {string} input | |
* @param {int} pos | |
* @return {string} | |
*/ | |
getTextPosition: function(input, pos) { | |
var lines = input.substr(0, pos).split('\n'); | |
var lineCount = lines.length; | |
var line = lines[lineCount - 1]; | |
return ' at Line #: ' + lineCount + ', Column #: ' + (line.length - 1) + ', Line: ' + line.trim(); | |
}, | |
/** | |
* Compiler Step 1 - Remove Comments from the Code | |
* | |
* All Code Comments are simply replaced with whitespace. This keeps the | |
* original structure of the code and allows for error messages to report on | |
* the correct line/column position of the error. Additionally it simplifies | |
* lexical analysis because there is no need to tokenize the comments. | |
* | |
* Note - this function should handle most but may not handle all comments. | |
* If new issues parsing are discovered this function needs to be updated to | |
* better handle them. | |
* | |
* @param {string} input | |
* @return {string} | |
*/ | |
removeComments: function(input) { | |
var length = input.length, | |
newInput = new Array(length), | |
state = { | |
inCommentReact: false, | |
inCommentSingleLine: false, | |
inCommentMultiLine: false, | |
inStringSingleQuote: false, | |
inStringDoubleQuote: false, | |
inStringMultiLine: false, | |
elementCount: 0, | |
jsCount: 0, | |
}, | |
current = 0, | |
char, | |
charNext; | |
function peekNext() { | |
return (current < length-1 ? input[current+1] : null); | |
} | |
function peekNext2() { | |
return (current < length-2 ? input[current+1] + input[current+2] : null); | |
} | |
while (current < length) { | |
char = input[current]; | |
if (state.inCommentReact) { | |
if (char === '*' && peekNext2() === '/}') { | |
newInput[current] = ' '; | |
newInput[current + 1] = ' '; | |
current += 2; | |
char = ' '; | |
state.inCommentReact = false; | |
} else if (char !== '\n') { | |
char = ' '; | |
} | |
} else if (state.inCommentSingleLine) { | |
if (char === '\n') { | |
state.inCommentSingleLine = false; | |
} else { | |
char = ' '; | |
} | |
} else if (state.inCommentMultiLine) { | |
if (char == '*' && peekNext() === '/') { | |
newInput[current] = ' '; | |
current++; | |
char = ' '; | |
state.inCommentMultiLine = false; | |
} else if (char !== '\n') { | |
char = ' '; | |
} | |
} else if (state.inStringDoubleQuote) { | |
if (char === '"' && input[current-1] !== '\\') { | |
state.inStringDoubleQuote = false; | |
} | |
} else if (state.inStringSingleQuote) { | |
if (char === "'" && input[current-1] !== '\\') { | |
state.inStringSingleQuote = false; | |
} | |
} else if (state.inStringMultiLine) { | |
if (char === '`') { | |
state.inStringMultiLine = false; | |
} | |
} else { | |
switch (char) { | |
case '{': | |
if (peekNext2() === '/*') { | |
newInput[current] = ' '; | |
newInput[current + 1] = ' '; | |
current += 2; | |
char = ' '; | |
state.inCommentReact = true; | |
} else if (state.elementCount > 0) { | |
state.jsCount++; | |
} | |
break; | |
case '}': | |
if (state.elementCount > 0 && state.jsCount > 0) { | |
state.jsCount--; | |
} | |
break; | |
case '/': | |
if (state.elementCount === 0 || state.jsCount > 0) { | |
var next = peekNext(); | |
state.inCommentSingleLine = (next === '/'); | |
if (!state.inCommentSingleLine) { | |
state.inCommentMultiLine = (next === '*'); | |
} | |
if (state.inCommentSingleLine || state.inCommentMultiLine) { | |
newInput[current] = ' '; | |
current++; | |
char = ' '; | |
} | |
} | |
break; | |
case '"': | |
state.inStringDoubleQuote = true; | |
break; | |
case "'": | |
state.inStringSingleQuote = true; | |
break; | |
case '`': | |
state.inStringMultiLine = true; | |
break; | |
case '<': | |
charNext = peekNext(); | |
if (/[a-zA-Z>]/.test(charNext)) { | |
state.elementCount++; | |
} else if (charNext === '/') { | |
state.elementCount--; | |
} | |
break; | |
case '>': | |
if (input[current-1] === '/' && state.elementCount > 0) { | |
state.elementCount--; | |
} | |
break; | |
} | |
} | |
newInput[current] = char; | |
current++; | |
} | |
return newInput.join(''); | |
}, | |
/** | |
* Compiler Step 2 (Lexical Analysis) - Convert JSX Code to an array of tokens. | |
* | |
* Warning, this function is large, contains recursive private functions, and is | |
* built for speed and features over readability. Using breakpoints with DevTools | |
* is recommended when making changes and to better understand how the code works. | |
* | |
* @param {string} input | |
* @return {array} | |
*/ | |
tokenizer: function(input) { | |
var length = input.length, | |
current = 0, | |
tokens = [], | |
char, | |
pos, | |
loopCount = 0, | |
callCount = 0, | |
nextChar, | |
maxRecursiveCalls = this.maxRecursiveCalls; | |
// Private function to return the next React/JSX Element | |
function nextElementPos() { | |
var c = current, | |
char, | |
state = { | |
inStringSingleQuote: false, | |
inStringDoubleQuote: false, | |
inStringMultiLine: false, | |
}; | |
while (c < length - 1) { | |
char = input[c]; | |
if (state.inStringDoubleQuote) { | |
if (char === '"' && input[c-1] !== '\\') { | |
state.inStringDoubleQuote = false; | |
} | |
} else if (state.inStringSingleQuote) { | |
if (char === "'" && input[c-1] !== '\\') { | |
state.inStringSingleQuote = false; | |
} | |
} else if (state.inStringMultiLine) { | |
if (char === '`') { | |
state.inStringMultiLine = false; | |
} | |
} else { | |
switch (char) { | |
case '"': | |
state.inStringDoubleQuote = true; | |
break; | |
case "'": | |
state.inStringSingleQuote = true; | |
break; | |
case '`': | |
state.inStringMultiLine = true; | |
break; | |
case '<': | |
if (/[a-zA-Z>]/.test(input[c + 1])) { | |
return c; // Start of Element found | |
} | |
break; | |
} | |
} | |
c++; | |
} | |
return null; | |
} | |
// Private functions to return the current or next characters without | |
// incrementing the counter for the current position. | |
function peekCurrent() { | |
return (current < length ? input[current] : null); | |
} | |
function peekNext() { | |
return (current < length ? input[current+1] : null); | |
} | |
// Safety check to prevent endless loops on unexpected errors. | |
// The number of loops should always be less that then string length. | |
function loopCheck() { | |
loopCount++; | |
if (loopCount > length) { | |
throw new Error('Endless loop encountered in tokenizer'); | |
} | |
} | |
function tokenizeElement(startPosition, firstNode) { | |
// Safety check | |
callCount++; | |
if (callCount > maxRecursiveCalls) { | |
throw new Error('Call count exceeded in tokenizer. If you have a large JSX file that is valid you can increase them limit using the property `jsxLoader.compiler.maxRecursiveCalls`.'); | |
} | |
// Current state of the processed text | |
var state = { | |
value: input[pos], | |
elementStack: 0, | |
elementState: [], | |
currentElementState: null, | |
inElement: true, | |
hasElementName: false, | |
elementClosed: false, | |
closeElement: false, | |
closingElement: false, | |
fatalError: false, | |
errorMessage: null, | |
addElementEnd: false, | |
breakLoop: false, | |
addChild: false, | |
addChar: true, | |
hasProp: false, | |
inValue: false, | |
inPropString: false, | |
propStringChar: null, | |
jsWithElement: false, | |
}; | |
// Add text up to the matched position as JavaScript | |
if (current < startPosition && firstNode) { | |
tokens.push({ | |
type: tokenTypes.js, | |
value: input.substring(current, startPosition), | |
pos: current, | |
}); | |
} | |
// Tokenize the current React element. This loop inside the recursive function | |
// provides core logic to process characters one at a time. The loop from the main | |
// calling function is used to find JSX elements. | |
current = startPosition + 1; | |
while (current < length) { | |
loopCheck(); | |
// Get the character at the current position in the input string | |
char = input[current]; | |
// Handle character differently depending on if the current | |
// position is still inside the <element> `state.inElement` | |
// or if it is in a child/code section <element>{code}</element> | |
if (state.inElement) { | |
if (state.hasElementName) { | |
state.value += char; | |
current++; | |
state.breakLoop = false; | |
state.hasProp = false; | |
state.inValue = false; | |
while (current < length) { | |
loopCheck(); | |
char = input[current]; | |
current++; | |
if (state.inPropString && char !== state.propStringChar) { | |
state.value += char; | |
continue; | |
} | |
switch (char) { | |
case '=': | |
if (state.currentElementState.inPropJs) { | |
break; | |
} | |
if (state.value.trim() !== '') { | |
tokens.push({ | |
type: tokenTypes.e_prop, | |
value: state.value, | |
pos: current, | |
}); | |
state.hasProp = true; | |
state.inValue = true; | |
} | |
state.value = ''; | |
nextChar = peekCurrent(); | |
if (nextChar === '"' || nextChar === "'") { | |
state.inPropString = true; | |
state.propStringChar = nextChar; | |
current++; | |
} else if (nextChar === '{') { | |
state.currentElementState.inPropJs = true; | |
state.currentElementState.jsPropBracketCount = 0; | |
current++; | |
} | |
continue; | |
case state.propStringChar: | |
if (state.inPropString) { | |
state.inPropString = false; | |
tokens.push({ | |
type: (state.hasProp ? tokenTypes.e_value : tokenTypes.e_prop), | |
value: JSON.stringify(state.value), | |
pos: current, | |
}); | |
state.inValue = false; | |
state.hasProp = false; | |
state.value = ''; | |
continue; | |
} | |
break; | |
case '}': | |
if (state.currentElementState.inPropJs) { | |
if (state.currentElementState.jsPropBracketCount === 0) { | |
state.currentElementState.inPropJs = false; | |
if (state.value.trim() !== '') { | |
if (state.jsWithElement) { | |
tokens.push({ | |
type: tokenTypes.e_child_js_end, | |
value: state.value, | |
pos: current, | |
}); | |
state.jsWithElement = false; | |
} else { | |
if (state.value.trim() !== '>') { | |
tokens.push({ | |
type: tokenTypes.e_value, | |
value: state.value, | |
pos: current, | |
}); | |
state.hasProp = false; | |
} | |
} | |
} | |
state.inValue = false; | |
state.value = ''; | |
continue; | |
} else { | |
state.currentElementState.jsPropBracketCount--; | |
} | |
} | |
break; | |
case ' ': | |
case '\t': | |
case '\r': | |
case '\n': | |
if (!state.currentElementState.inPropJs) { | |
if (state.value.trim() !== '') { | |
tokens.push({ | |
type: (state.hasProp ? tokenTypes.e_value : tokenTypes.e_prop), | |
value: state.value, | |
pos: current, | |
}); | |
} | |
state.inValue = false; | |
state.value = ''; | |
} | |
break; | |
case '/': | |
if (peekCurrent() === '>') { | |
current--; | |
state.breakLoop = true; | |
state.hasElementName = false; | |
} | |
break; | |
case '<': | |
if (state.currentElementState.inPropJs && peekCurrent() !== ' ') { | |
if (state.value.trim() !== '') { | |
tokens.push({ | |
type: tokenTypes.e_child_js_start, | |
value: state.value, | |
pos: current, | |
}); | |
state.value = ''; | |
state.jsWithElement = true; | |
} | |
current--; | |
tokenizeElement(current, false); | |
char = ''; | |
if (state.jsWithElement) { | |
current++; | |
} | |
} | |
break; | |
case '{': | |
if (state.currentElementState.inPropJs) { | |
state.currentElementState.jsPropBracketCount++; | |
} | |
break; | |
case '>': | |
if (!state.currentElementState.inPropJs) { | |
state.breakLoop = true; | |
state.hasElementName = false; | |
} | |
break; | |
} | |
if (state.breakLoop) { | |
var trimValue = state.value.trim(); | |
var lastToken = tokens[tokens.length-1]; | |
if (state.value === '/' && char === '>' && lastToken.type === tokenTypes.e_start) { | |
tokens.push({ | |
type: tokenTypes.e_end, | |
value: state.value + char, | |
pos: current, | |
}); | |
if (state.elementStack <= 1) { | |
if (peekCurrent() !== '}') { | |
current--; | |
} | |
return; | |
} else { | |
state.elementStack--; | |
state.elementState.pop(); | |
state.currentElementState = (state.elementStack === 0 ? null : state.elementState[state.elementStack - 1]); | |
} | |
} else if ( | |
char === '>' && | |
trimValue !== '' && | |
(/^[a-zA-Z0-9-_]*$/.test(trimValue) || /{\.\.\.(.+)}/.test(trimValue)) && | |
(lastToken.type === tokenTypes.e_start || lastToken.type === tokenTypes.e_value) | |
) { | |
tokens.push({ | |
type: tokenTypes.e_prop, | |
value: trimValue, | |
pos: current, | |
}); | |
} else if (trimValue !== '') { | |
console.log(tokens); | |
throw new Error('Unhandled character in element properties: `' + state.value + '`' + jsxLoader.compiler.getTextPosition(input, current)); | |
} | |
state.value = ''; | |
state.breakLoop = false; | |
break; | |
} | |
state.value += char; | |
} | |
} | |
switch (char) { | |
case '/': | |
if (state.value === '' || state.value === '<') { | |
state.closingElement = true; | |
} else if (peekCurrent() === '>') { | |
state.closeElement = true; | |
state.inElement = false; | |
state.addElementEnd = true; | |
} else { | |
state.fatalError = true; | |
state.errorMessage = 'Error found a "/" character in element [' + state.value + '] but not closing "/>"' + jsxLoader.compiler.getTextPosition(input, current); | |
} | |
break; | |
case '>': | |
state.closeElement = true; | |
state.inElement = false; | |
break; | |
case ' ': | |
case '\t': | |
case '\n': | |
case '\r': | |
state.hasElementName = true; | |
state.closeElement = true; | |
break; | |
} | |
} else { | |
switch (char) { | |
case '}': | |
if (state.currentElementState.inJs) { | |
if (state.currentElementState.jsBracketCount === 0) { | |
state.currentElementState.inJs = false; | |
state.currentElementState.closeJs = true; | |
state.addChild = true; | |
state.addChar = false; | |
} else { | |
state.currentElementState.jsBracketCount--; | |
} | |
} | |
break; | |
case '{': | |
if (state.currentElementState.inJs) { | |
state.currentElementState.jsBracketCount++; | |
} else { | |
state.currentElementState.inJs = true; | |
state.currentElementState.jsBracketCount = 0; | |
state.addChild = true; | |
state.addChar = false; | |
} | |
break; | |
case '<': | |
if (/[a-zA-Z>/]/.test(peekNext())) { | |
state.addChild = true; | |
state.inElement = true; | |
} | |
break; | |
} | |
if (state.addChild) { | |
if (state.value.trim() === '') { | |
if (state.value !== '') { | |
tokens.push({ | |
type: tokenTypes.e_child_whitespace, | |
value: state.value, | |
pos: current, | |
}); | |
} | |
} else { | |
var isJS = (state.currentElementState.closeJs || (state.currentElementState.inJs && state.currentElementState.jsBracketCount > 0) || (state.currentElementState.inJs && state.inElement)); | |
tokens.push({ | |
type: (isJS ? tokenTypes.e_child_js : tokenTypes.e_child_text), | |
value: state.value, | |
pos: current, | |
}); | |
} | |
state.addChild = false; | |
state.value = ''; | |
if (state.currentElementState.closeJs) { | |
state.currentElementState.closeJs = false; | |
state.currentElementState.inJs = false; | |
} | |
} | |
} | |
// Should the current element be closed? | |
if (state.closeElement) { | |
if (char !== ' ' && char !== '\t' && char !== '\n' && char !== '\r') { | |
state.value += char; | |
} | |
if (state.closingElement) { | |
tokens.push({ | |
type: tokenTypes.e_end, | |
value: state.value, | |
pos: current, | |
}); | |
state.hasElementName = false; | |
state.elementStack--; | |
state.elementState.pop(); | |
state.currentElementState = (state.elementStack === 0 ? null : state.elementState[state.elementStack - 1]); | |
if (state.elementStack === 0) { | |
state.elementClosed = true; | |
} | |
} else { | |
if (state.value === '>') { | |
current--; | |
} else { | |
tokens.push({ | |
type: tokenTypes.e_start, | |
value: state.value, | |
pos: current, | |
}); | |
state.elementStack++; | |
state.currentElementState = { | |
inJs: false, | |
jsBracketCount: 0, | |
closeJs: false, | |
inPropJs: false, | |
jsPropBracketCount: 0, | |
}; | |
state.elementState.push(state.currentElementState); | |
} | |
} | |
state.value = ''; | |
state.closeElement = false; | |
state.closingElement = false; | |
if (state.addElementEnd) { | |
tokens.push({ | |
type: tokenTypes.e_end, | |
value: state.value, | |
pos: null, | |
}); | |
state.addElementEnd = false; | |
} | |
} else { | |
if (state.addChar) { | |
state.value += char; | |
} | |
state.addChar = true; | |
} | |
// Exit nested element loop once the element has been closed | |
if (state.elementClosed) { | |
break; | |
} | |
// Next character | |
current++; | |
} // End of `while (current < length)` loop in the recursive `tokenizeElement()` function | |
// Was there a fatal error in the loop? | |
if (state.fatalError) { | |
console.log(tokens); | |
throw new Error(state.errorMessage); | |
} | |
} | |
// Main loop to find and process JSX elements inside of plain JS | |
while (current < length) { | |
loopCheck(); | |
// Find the next React Element and add remaining js once all elements are found | |
pos = nextElementPos(); | |
if (pos === null) { | |
tokens.push({ | |
type: tokenTypes.js, | |
value: input.substring(current, length), | |
pos: current, | |
}); | |
break; | |
} | |
tokenizeElement(pos, true); | |
current++; | |
} | |
return tokens; | |
}, | |
/** | |
* Compiler Step 3 (Syntactic Analysis) - Convert Tokens to an Abstract Syntax Tree (AST) | |
* | |
* @param {array} tokens | |
* @param {string} input Original input is passed to allow for helpful error messages | |
* @return {object} | |
*/ | |
parser: function(tokens, input) { | |
var current = 0, | |
ast = { | |
type: astTypes.program, | |
body: [], | |
pos: null, | |
}, | |
callCount = 0, | |
tokenCount = tokens.length, | |
maxRecursiveCalls = this.maxRecursiveCalls, | |
pragmaFrag = this.pragmaFrag, | |
e_start_count = 0, | |
e_end_count = 0; | |
// Default to use `React.Fragment`, however if a code hint for | |
// Babel is found such as `// @jsxFrag Vue.Fragment` then use | |
// the `Fragment` component from the code hint. | |
var regex = /(\/\/|\/\*|\/\*\*)\s+@jsxFrag\s+([a-zA-Z.]+)/gm; | |
var match = regex.exec(input); | |
if (match) { | |
pragmaFrag = match[2]; | |
} | |
function nextTokenType() { | |
if (current < tokenCount) { | |
return tokens[current].type; | |
} | |
return null; | |
} | |
function walk(stackCount) { | |
callCount++; | |
if (callCount > maxRecursiveCalls) { | |
throw new Error('Call count exceeded in parser. If you have a large JSX file that is valid you can increase them limit using the property `jsxLoader.compiler.maxRecursiveCalls`.'); | |
} | |
var token = tokens[current]; | |
current++; | |
if (token.type === tokenTypes.js) { | |
return { | |
type: astTypes.js, | |
value: token.value, | |
pos: token.pos, | |
stackCount: stackCount, | |
}; | |
} | |
if (token.type === tokenTypes.e_start) { | |
e_start_count++; | |
var elName = token.value.replace('<', '').replace('/', '').replace('>', ''); | |
if (elName === '') { | |
elName = pragmaFrag; | |
} | |
var firstChar = elName[0]; | |
var node = { | |
type: astTypes.createElement, | |
name: elName, | |
isClass: ((firstChar >= 'A' && firstChar <= 'Z') || elName.indexOf('.') !== -1), | |
props: [], | |
children: [], | |
pos: token.pos, | |
stackCount: stackCount, | |
}; | |
var breakLoop = false; | |
var value; | |
while (current < tokenCount) { | |
token = tokens[current]; | |
current++; | |
switch (token.type) { | |
case tokenTypes.e_prop: | |
var prop = { | |
name: token.value, | |
value: null, | |
pos: token.pos, | |
}; | |
var nextNodeType = nextTokenType(); | |
switch (nextNodeType) { | |
case tokenTypes.e_value: | |
case tokenTypes.js: | |
prop.value = tokens[current].value; | |
current++; | |
break; | |
case tokenTypes.e_start: | |
prop.value = walk(stackCount + 1); | |
break; | |
} | |
if (prop.name.trim() !== '') { | |
node.props.push(prop); | |
} | |
break; | |
case tokenTypes.e_child_js: | |
if (token.value.trim() !== '') { | |
value = token.value.trim(); | |
if (value.indexOf('{') === 0) { | |
value = value.substr(1); | |
} | |
if (value.substr(value.length - 1, 1) === '}') { | |
value = value.substr(0, value.length-1); | |
} | |
node.children.push({ | |
type: token.type, | |
value: value, | |
pos: token.pos, | |
}); | |
} | |
break; | |
case tokenTypes.e_child_js_start: | |
case tokenTypes.e_child_js_end: | |
node.children.push({ | |
type: token.type, | |
value: value, | |
pos: token.pos, | |
}); | |
break; | |
case tokenTypes.e_child_text: | |
if (token.value.trim() !== '') { | |
node.children.push({ | |
type: token.type, | |
value: token.value, | |
pos: token.pos, | |
}); | |
} | |
break; | |
case tokenTypes.e_child_whitespace: | |
node.children.push({ | |
type: token.type, | |
value: token.value, | |
pos: token.pos, | |
}); | |
break; | |
case tokenTypes.e_end: | |
var endName = token.value.replace('<', '').replace('/', '').replace('>', ''); | |
if (endName !== node.name && endName !== '') { | |
throw new Error('Found closing element [' + endName + '] that does not match opening element [' + node.name + '] from Token # ' + token.index + jsxLoader.compiler.getTextPosition(input, token.pos)); | |
} | |
breakLoop = true; | |
e_end_count++; | |
break; | |
case tokenTypes.e_start: | |
// Handle nested elements here with a recursive call to walk() | |
current--; | |
node.children.push({ | |
type: tokenTypes.e_start, | |
value: walk(stackCount + 1), | |
pos: token.pos, | |
}); | |
break; | |
default: | |
console.log(tokens); | |
console.log(ast); | |
throw new Error('Tokens are out of order 1: [' + token.type + '], Token #: ' + token.index + jsxLoader.compiler.getTextPosition(input, token.pos)); | |
} | |
if (breakLoop) { | |
break; | |
} | |
} | |
return node; | |
} | |
throw new Error('Tokens are out of order 2: [' + token.type + '], Token #: ' + token.index + jsxLoader.compiler.getTextPosition(input, token.pos)); | |
} // walk() | |
for (var n = 0; n < tokenCount; n++) { | |
tokens[n].index = n; | |
} | |
while (current < tokenCount) { | |
ast.body.push(walk(0)); | |
} | |
// Checking opening and closing tag count. | |
// Because jsxLoader is a minimal JSX compiler and not a full JS compiler | |
// it is unable to determine the error location in code for this type of error. | |
// To avoid this develop using a IDE such as VS Code that highlights errors in code. | |
if (e_start_count !== e_end_count) { | |
throw new Error('The number of opening elements (for example: "<div>") does not match the number closing elements ("</div>").'); | |
} | |
return ast; | |
}, | |
/** | |
* Compiler Step 4 (Code Generation) - Convert AST to Code. | |
* | |
* Often compilers will include additional steps in-between the original AST | |
* and Code Generation such as converting to a different AST format or optimizing. | |
* For example the [The Super Tiny Compiler] which this script used as a starting | |
* point includes extra functions `traverser()` and `transformer()`. This function | |
* combines the steps because most AST nodes are kept (only some whitespace is | |
* dropped and the logic is relatively simple). By combining transformation and | |
* code generation only a single iteration is needed over the original AST is | |
* performed and only one copy of the AST is made. | |
* | |
* @param {object} ast | |
* @param {string} input | |
* @return {string} | |
*/ | |
codeGenerator: function(ast, input) { | |
var addUseStrict = this.addUseStrict; | |
// Default to use `React.createElement`, however if a code hint for | |
// Babel is found such as `// @jsx preact.createElement` then use | |
// the `createElement()` function from the code hint. | |
var createElement = this.pragma; | |
var regex = /(\/\/|\/\*|\/\*\*)\s+@jsx\s+([a-zA-Z.]+)/gm; | |
var match = regex.exec(input); | |
if (match) { | |
createElement = match[2]; | |
} | |
return generateCode(ast); | |
// Recursive private function for generating code | |
function generateCode(node, skipIndent) { | |
switch (node.type) { | |
case astTypes.program: | |
var generatedJs = node.body.map(generateCode).join(''); | |
// By default if 'use strict' is not found then add it to the start of the generated code. | |
// This can be turned off by setting `jsxLoader.compiler.addUseStrict = false`; | |
if (addUseStrict && generatedJs.indexOf('"use strict"') === -1 && generatedJs.indexOf("'use strict'") === -1) { | |
return '"use strict";\n' + generatedJs; | |
} | |
return generatedJs; | |
case astTypes.js: | |
return node.value; | |
case astTypes.createElement: | |
// Start of Element | |
var js = createElement + '(' + (node.isClass ? node.name : JSON.stringify(node.name)) + ', '; | |
if (node.stackCount > 0) { | |
if (skipIndent !== true) { | |
js = '\n' + ' '.repeat(8) + ' '.repeat(node.stackCount * 4) + js; | |
} else { | |
js = ' ' + js; | |
} | |
} | |
// Add Element Props | |
var propCount = node.props.length; | |
var propName; | |
var propJs = []; | |
if (propCount === 0) { | |
js += 'null'; | |
} else { | |
js += '{'; | |
for (var n = 0; n < propCount; n++) { | |
var propValue = node.props[n].value; | |
if (propValue === null) { | |
propValue = 'true'; | |
} else if (typeof propValue !== 'string') { | |
propValue = generateCode(propValue, true); | |
} | |
propName = node.props[n].name.trim(); | |
if (propName.indexOf('-') !== -1) { | |
propName = JSON.stringify(propName); | |
} | |
if (propValue === 'true' && /{\.\.\.(.+)}/.test(propName)) { | |
// Handle spread operators: `{...props}` | |
propJs.push(propName.substr(0, propName.length - 1).substr(1) + (n === propCount - 1 ? '' : ', ')); | |
} else { | |
propJs.push(propName + ': ' + propValue + (n === propCount - 1 ? '' : ', ')); | |
} | |
} | |
var propTextLen = propJs.reduce(function(total, item) { | |
return total += item.length; | |
}, 0); | |
if (propTextLen > 80) { | |
var propIndent = '\n'; | |
if (skipIndent !== true) { | |
propIndent += ' '.repeat(12) + ' '.repeat(node.stackCount * 4); | |
} | |
js += propIndent + propJs.join(propIndent); | |
} else { | |
js += propJs.join(''); | |
} | |
js += '}'; | |
} | |
// Add Element Children | |
var childJs = []; | |
var hasChildText = false; | |
var hasChildJs = false; | |
var hasChildEl = false; | |
var startsWithJs = false; | |
var childCount = node.children.length; | |
var lastIndex = null; | |
var childElCount = 0; | |
var lastElAddedAsJs = false; | |
var nodeValue; | |
var allChildJsContainsExpressions = true; | |
var m; | |
// First see what types of child nodes exist | |
for (m = 0; m < childCount; m++) { | |
switch (node.children[m].type) { | |
case tokenTypes.e_child_js: | |
case tokenTypes.e_child_js_start: | |
hasChildJs = true; | |
nodeValue = node.children[m].value; | |
// This is not an exact match based on JS syntax but rather | |
// a quick check that will work for most JSX code. | |
if (typeof nodeValue === 'string' && | |
nodeValue.indexOf('(') === -1 && | |
nodeValue.indexOf(')') === -1 | |
) { | |
allChildJsContainsExpressions = false; | |
} | |
break; | |
case tokenTypes.e_child_text: | |
hasChildText = true; | |
break; | |
case tokenTypes.e_start: | |
hasChildEl = true; | |
childElCount++; | |
break; | |
case tokenTypes.e_child_whitespace: | |
case tokenTypes.e_child_js_end: | |
break; // Ignore, no need to count or track | |
default: | |
throw new Error('Unhandled child type codeGenerator(): ' + node.children[m].type); | |
} | |
} | |
for (m = 0; m < childCount; m++) { | |
switch (node.children[m].type) { | |
case tokenTypes.e_child_js: | |
case tokenTypes.e_child_js_start: | |
if (lastElAddedAsJs) { | |
childJs[childJs.length-1] += node.children[m].value; | |
} else { | |
childJs.push(node.children[m].value); | |
if (!startsWithJs && childJs.length === 1) { | |
startsWithJs = true; | |
} | |
} | |
break; | |
case tokenTypes.e_child_text: | |
nodeValue = node.children[m].value; | |
if (nodeValue.indexOf('&') !== -1) { | |
// Use the browser DOM to convert from HTML to Text if the node might contain | |
// HTML encoded characters. Example: [&] or ['] | |
var tmp = document.createElement('div'); | |
tmp.innerHTML = nodeValue; | |
nodeValue = tmp.textContent; | |
} | |
if (childCount === 1) { | |
childJs.push(JSON.stringify(nodeValue)); | |
} else if (m === childCount - 1) { | |
childJs.push(JSON.stringify(nodeValue.replace(/\s+$/, ''))); // trimEnd(); | |
} else if (m === 0) { | |
childJs.push(JSON.stringify(nodeValue.replace(/^\s+/, ''))); // trimStart() | |
} else { | |
childJs.push(JSON.stringify(nodeValue)); | |
} | |
break; | |
case tokenTypes.e_child_js_end: | |
childJs.push(node.children[m].value); | |
break; | |
case tokenTypes.e_start: | |
var skipElIndent = false; | |
var addedAsJs = false; | |
if (lastIndex !== null) { | |
skipElIndent = (node.children[lastIndex].type === tokenTypes.e_child_js); | |
} | |
if (skipElIndent) { | |
var lastValue = node.children[lastIndex].value.trim(); | |
if (lastValue.endsWith('&&') || lastValue.endsWith('?') || lastValue.endsWith('(') || lastValue.endsWith(':') || lastValue.endsWith(' return')) { | |
childJs[childJs.length-1] += generateCode(node.children[m].value, skipElIndent); | |
addedAsJs = true; | |
childElCount--; | |
lastElAddedAsJs = true; | |
} | |
} | |
if (!addedAsJs) { | |
childJs.push(generateCode(node.children[m].value, skipElIndent)); | |
lastElAddedAsJs = false; | |
} | |
break; | |
case tokenTypes.e_child_whitespace: | |
if ((hasChildJs || hasChildText) && !(m === 0 || m === (childCount-1))) { | |
childJs.push(JSON.stringify(node.children[m].value)); | |
lastElAddedAsJs = false; | |
} | |
continue; | |
default: | |
throw new Error('Unhandled child type codeGenerator(): ' + node.children[m].type); | |
} | |
lastIndex = m; // Skipped when [e_child_whitespace] | |
} | |
if (childJs.length > 0) { | |
if (!hasChildText && hasChildJs && hasChildEl && startsWithJs && childElCount === 1 && allChildJsContainsExpressions) { | |
js += ', ' + childJs.join(''); | |
} else { | |
js += ', ' + childJs.join(', '); | |
} | |
} | |
js += ')'; | |
return js; | |
default: | |
throw new TypeError('Unhandled AST type in codeGenerator: ' + node.type); | |
} | |
} | |
}, | |
}, | |
}; | |
/** | |
* Transform (compile) a JSX String into a modern JavaScript String. | |
* | |
* @param {string} jsx | |
* @param {object|undefined} options | |
* @returns {string} | |
*/ | |
export function transform(jsx, options) { | |
// By default jsxLoader uses `React.createElement` and `React.Fragment` for | |
// Virtual DOM and it also supports code hints just like Babel Standalone | |
// `// @jsx Vue.h` and `@jsxFrag Vue.Fragment`. | |
if (options !== undefined) { | |
if (typeof options.pragma === 'string' && /^[a-zA-Z.]+$/.test(options.pragma)) { | |
jsxLoader.compiler.pragma = options.pragma; | |
} | |
if (typeof options.pragmaFrag === 'string' && /^[a-zA-Z.]+$/.test(options.pragmaFrag)) { | |
jsxLoader.compiler.pragmaFrag = options.pragma; | |
} | |
} | |
return jsxLoader.compiler.compile(jsx); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This file was created based on issue: dataformsjs/dataformsjs#16
Example usage from webpack: