├── .gitignore ├── .npmignore ├── .travis.yml ├── package.json ├── README.md ├── .eslintrc.json ├── test.js └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .* 2 | test.js 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - stable 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "babel-plugin-transform-jsx-lit-html", 3 | "version": "0.1.0", 4 | "description": "Transform JSX to lit-html template literal", 5 | "repository": "phaux/babel-jsx-lit-html", 6 | "keywords": [ 7 | "babel-plugin", 8 | "jsx", 9 | "lit-html" 10 | ], 11 | "author": "phaux ", 12 | "license": "ISC", 13 | "scripts": { 14 | "prepublishOnly": "npm test", 15 | "test": "eslint index.js && node test" 16 | }, 17 | "dependencies": { 18 | "@babel/plugin-syntax-jsx": "^7.0.0" 19 | }, 20 | "devDependencies": { 21 | "@babel/core": "^7.1.0", 22 | "eslint": "^5.6.0" 23 | }, 24 | "peerDependencies": { 25 | "@babel/core": "^7.0.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # babel-plugin-transform-jsx-lit-html 2 | 3 | [![npm](https://img.shields.io/npm/v/babel-plugin-transform-jsx-lit-html.svg)](https://www.npmjs.com/package/babel-plugin-transform-jsx-lit-html) 4 | [![travis](https://travis-ci.org/phaux/babel-jsx-lit-html.svg?branch=master)](https://travis-ci.org/phaux/babel-jsx-lit-html) 5 | 6 | ## Example 7 | 8 | **In** 9 | 10 | ```jsx 11 | const renderProfile = user => <> 12 | 13 |

{user.firstName} {user.lastName}

14 | 15 | ; 16 | ``` 17 | 18 | **Out** 19 | 20 | ```js 21 | const renderProfile = user => html` 22 | 23 |

${user.firstName} ${user.lastName}

24 | 25 | `; 26 | ``` 27 | 28 | ## Props syntax 29 | 30 | Regular JSX props map to lit-html property setters. 31 | To set an attribute use `attr$` syntax. 32 | To set an event handler use `on-event` syntax. 33 | 34 | ```jsx 35 | // In 36 | const input = ; 37 | // Out 38 | const input = html``; 39 | ``` 40 | 41 | ## Installation 42 | 43 | ```sh 44 | npm install --save-dev babel-plugin-transform-jsx-lit-html 45 | ``` 46 | 47 | ## Usage 48 | 49 | ### Via `.babelrc` 50 | 51 | ```json 52 | { 53 | "plugins": ["transform-jsx-lit-html"] 54 | } 55 | ``` 56 | 57 | ### Via CLI 58 | 59 | ```sh 60 | babel --plugins transform-jsx-lit-html script.js 61 | ``` 62 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "env": {"es6": true, "node": true}, 4 | "parserOptions": {"ecmaVersion": 2018, "ecmaFeatures": {"jsx": true}}, 5 | "rules": { 6 | "array-bracket-spacing": ["error", "never"], 7 | "arrow-body-style": ["error", "as-needed"], 8 | "arrow-parens": ["error", "as-needed"], 9 | "arrow-spacing": "error", 10 | "block-spacing": ["error", "always"], 11 | "brace-style": ["error", "stroustrup", {"allowSingleLine": true}], 12 | "comma-dangle": ["error", "always-multiline"], 13 | "comma-spacing": "error", 14 | "comma-style": "error", 15 | "complexity": ["warn", 18], 16 | "computed-property-spacing": "error", 17 | "dot-location": ["error", "property"], 18 | "dot-notation": "error", 19 | "eol-last": "error", 20 | "generator-star-spacing": ["error", "before"], 21 | "indent": ["error", 2], 22 | "key-spacing": ["error", {"mode": "minimum"}], 23 | "keyword-spacing": "error", 24 | "linebreak-style": "error", 25 | "max-depth": ["warn", 5], 26 | "max-len": ["error", 120, 4], 27 | "max-statements": ["warn", 20], 28 | "no-caller": "error", 29 | "no-console": "warn", 30 | "no-constant-condition": "off", 31 | "no-empty": "off", 32 | "no-invalid-this": "error", 33 | "no-labels": "error", 34 | "no-lone-blocks": "error", 35 | "no-multiple-empty-lines": "error", 36 | "no-restricted-syntax": ["error", "SwitchStatement", "ForStatement", "ForInStatement"], 37 | "no-self-compare": "error", 38 | "no-sequences": "error", 39 | "no-spaced-func": "error", 40 | "no-throw-literal": "error", 41 | "no-underscore-dangle": "error", 42 | "no-unmodified-loop-condition": "error", 43 | "no-unused-expressions": ["error", {"allowShortCircuit": true, "allowTernary": true}], 44 | "no-useless-concat": "error", 45 | "no-useless-escape": "error", 46 | "no-var": "error", 47 | "no-void": "error", 48 | "no-warning-comments": "warn", 49 | "no-whitespace-before-property": "error", 50 | "no-with": "error", 51 | "object-curly-spacing": ["error", "never"], 52 | "object-shorthand": "error", 53 | "operator-assignment": ["error", "always"], 54 | "operator-linebreak": ["error", "before"], 55 | "prefer-arrow-callback": "error", 56 | "prefer-const": "error", 57 | "require-jsdoc": "warn", 58 | "rest-spread-spacing": "error", 59 | "semi": ["error", "never"], 60 | "semi-spacing": "error", 61 | "space-before-blocks": "error", 62 | "space-before-function-paren": ["error", {"anonymous": "always", "named": "never"}], 63 | "space-infix-ops": ["error", {"int32Hint": true}], 64 | "space-in-parens": "error", 65 | "space-unary-ops": "error", 66 | "template-curly-spacing": "error", 67 | "valid-jsdoc": ["error", {"requireParamDescription": false, "requireReturnDescription": false}], 68 | "yield-star-spacing": "error", 69 | "yoda": ["error", "never"] 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console: off */ 2 | 3 | const babel = require('@babel/core') 4 | const assert = require('assert') 5 | 6 | const options = {plugins: ['module:./index.js']} 7 | 8 | const test = (desc, input, output) => { 9 | console.log(`${desc}...`) 10 | const {code} = babel.transformSync(input, options) 11 | assert.equal(code, output) 12 | } 13 | 14 | console.log('### Elements') 15 | 16 | test( 17 | "Transforms element", 18 | '
;', 19 | 'html`
`;' 20 | ) 21 | 22 | test( 23 | "Transforms void element", 24 | '
;', 25 | 'html`
`;' 26 | ) 27 | 28 | test( 29 | "Discards content of void element", 30 | '
test
;', 31 | 'html`
`;' 32 | ) 33 | 34 | test( 35 | "Transforms fragment", 36 | '<>foo;', 37 | 'html`foo`;' 38 | ) 39 | 40 | console.log('### Tag names') 41 | 42 | test( 43 | "Transforms custom element tag name", 44 | ';', 45 | 'html``;' 46 | ) 47 | 48 | test( 49 | "Transforms component class", 50 | ';', 51 | 'html`<${Component.is}>`;' 52 | ) 53 | 54 | 55 | test( 56 | "Transforms member expression", 57 | ';', 58 | 'html`<${foo.bar.is}>`;' 59 | ) 60 | 61 | console.log('### Attributes') 62 | 63 | test( 64 | "Transforms string attributes", 65 | `;`, 66 | 'html``;' 67 | ) 68 | 69 | test( 70 | "Transforms expression attributes", 71 | `;`, 72 | 'html``;' 73 | ) 74 | 75 | test( 76 | "Doesn't transform entities in string attributes", 77 | `;`, 78 | 'html``;' 79 | ) 80 | 81 | test( 82 | "Escapes string attributes", 83 | `;`, 84 | `html\`\`;` 85 | ) 86 | 87 | test( 88 | "Transforms boolean attributes", 89 | `;`, 90 | 'html``;' 91 | ) 92 | 93 | test( 94 | "Transforms string props", 95 | `;`, 96 | 'html``;' 97 | ) 98 | 99 | test( 100 | "Transform entities in string props", 101 | `;`, 102 | 'html``;' 103 | ) 104 | 105 | test( 106 | "Transforms expression props", 107 | `;`, 108 | 'html``;' 109 | ) 110 | 111 | test( 112 | "Transforms boolean props", 113 | `;`, 114 | 'html``;' 115 | ) 116 | 117 | test( 118 | "Transforms event handlers", 119 | `;`, 120 | 'html``;' 121 | ) 122 | 123 | console.log('### Children') 124 | 125 | test( 126 | "Transforms text children", 127 | `

foo bar

;`, 128 | 'html`

foo bar

`;' 129 | ) 130 | 131 | test( 132 | "Escapes text children", 133 | '

`\\`

;', 134 | 'html`

\\`\\\\\\`

`;' 135 | ) 136 | 137 | test( 138 | "Doesn't transform entities in text children", 139 | '

"

;', 140 | 'html`

"

`;' 141 | ) 142 | 143 | test( 144 | "Transforms expression children", 145 | `

foo: {val}

;`, 146 | 'html`

foo: ${val}

`;' 147 | ) 148 | 149 | test( 150 | "Skips empty expression children", 151 | `

{ } { /* comment */ }

;`, 152 | 'html`

`;' 153 | ) 154 | 155 | test( 156 | "Transforms element children", 157 | `

foo: {val}

;`, 158 | 'html`

foo: ${val}

`;' 159 | ) 160 | 161 | test( 162 | "Transforms fragment children", 163 | `

foo: <>{val}

;`, 164 | 'html`

foo: ${val}

`;' 165 | ) 166 | 167 | console.log("All tests passed successfully! 🎉") 168 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const {default: BabelPluginSyntaxJsx} = require('@babel/plugin-syntax-jsx') 2 | 3 | module.exports = api => { 4 | 5 | api.assertVersion(7) 6 | const t = api.types 7 | 8 | return { 9 | inherits: BabelPluginSyntaxJsx, 10 | visitor: { 11 | JSXElement(path) { 12 | path.replaceWith(transformElement(renderElement(path.node))) 13 | }, 14 | JSXFragment(path) { 15 | path.replaceWith(transformElement(renderElement(path.node))) 16 | }, 17 | }, 18 | } 19 | 20 | /** 21 | * take array of quasis (strings) + expressions (AST nodes) and produce TemplateLiteral node 22 | * @param {Array<*>} parts 23 | * @return {object} 24 | **/ 25 | function transformElement(parts) { 26 | 27 | // we have one mixed array and we need to split nodes by type 28 | const quasis = [], exprs = [] 29 | 30 | let i = 0 31 | // do one iteration more to make sure we produce an empty string quasi at the end 32 | while (i < parts.length + 1) { 33 | 34 | let quasi = '' 35 | // join adjacent strings into one 36 | while (typeof parts[i] == 'string') { 37 | // we need to escape backticks and backslashes manually 38 | quasi += parts[i].replace(/[\\`]/g, s => `\\${s}`) 39 | i += 1 40 | } 41 | quasis.push(t.templateElement({raw: quasi})) 42 | 43 | // add a single expr node 44 | if (parts[i] != null) exprs.push(parts[i]) 45 | 46 | i += 1 // repeat 47 | 48 | } 49 | 50 | return t.taggedTemplateExpression( 51 | t.identifier('html'), 52 | t.templateLiteral(quasis, exprs), 53 | ) 54 | 55 | } 56 | 57 | /** 58 | * take JSXElement and return array of template strings and parts 59 | * @param {*} elem 60 | * @return {Array<*>} 61 | */ 62 | function renderElement(elem) { 63 | if (elem.type == 'JSXFragment') { 64 | const children = elem.children.map(renderChild) 65 | return [...flatten(children)] 66 | } 67 | if (elem.type == 'JSXElement') { 68 | const {tag, isVoid} = renderTag(elem.openingElement.name) 69 | const attrs = elem.openingElement.attributes.map(renderProp) 70 | const children = elem.children.map(renderChild) 71 | return [ 72 | '<', tag, ...flatten(attrs), '>', 73 | ...isVoid ? [] : flatten(children), 74 | ...isVoid ? [] : [''], 75 | ] 76 | } 77 | throw new Error(`Unknown element type: ${elem.type}`) 78 | } 79 | 80 | /** 81 | * Take JSXElement name (Identifier or MemberExpression) and return JS counterpart 82 | * @param {*} name 83 | * @param {boolean} root Whether it's the root of expression tree 84 | * @return {{tag: *, isVoid: boolean}} 85 | */ 86 | function renderTag(name, root = true) { 87 | 88 | // name is an identifier 89 | if (name.type == 'JSXIdentifier') { 90 | 91 | const tag = name.name 92 | 93 | // it's a single lowercase identifier (e.g. `foo`) 94 | if (root && t.react.isCompatTag(tag)) { 95 | const isVoid = voidElements.includes(tag.toLowerCase()) 96 | // return it as part of the template (``) 97 | return {tag, isVoid} 98 | } 99 | 100 | // it's a single uppercase identifier (e.g. `Foo`) 101 | else if (root) { 102 | const object = t.identifier(tag) 103 | const property = t.identifier('is') 104 | // imitate React and try to use the class (`<${Foo.is}>`) 105 | return {tag: t.memberExpression(object, property)} 106 | } 107 | 108 | // it's not the only identifier, it's a part of a member expression 109 | // return it as identifier 110 | else return {tag: t.identifier(tag)} 111 | 112 | } 113 | 114 | // tag names can also be member expressions (`Foo.Bar`) 115 | if (name.type == 'JSXMemberExpression') { 116 | const expr = name // transform recursively 117 | const {tag: object} = renderTag(expr.object, false) 118 | const property = t.identifier(expr.property.name) 119 | const tag = root // stick `.is` to the root member expr 120 | ? t.memberExpression(t.memberExpression(object, property), t.identifier('is')) 121 | : t.memberExpression(object, property) 122 | return {tag} // return as member expr 123 | } 124 | 125 | throw new Error(`Unknown element tag type: ${name.type}`) 126 | 127 | } 128 | 129 | /** 130 | * Take JSXAttribute and return array of template strings and parts 131 | * @param {*} prop 132 | * @return {Array<*>} 133 | */ 134 | function renderProp(prop) { 135 | 136 | const [jsxName, attributeName, eventName, propertyName] 137 | = prop.name.name.match(/^(?:(.*)\$|on-(.*)|(.*))$/) 138 | 139 | if (prop.value) { // prop has a value 140 | 141 | if (prop.value.type == 'StringLiteral') { // value is a string literal 142 | 143 | // we are setting an attribute, no lit-html involved, produce template strings 144 | if (attributeName) return [' ', `${attributeName}`, '=', prop.value.extra.raw] 145 | 146 | // setting property must involve lit-html, let's create a template expression here 147 | if (propertyName) return [' ', `.${propertyName}`, '=', t.stringLiteral(prop.value.extra.rawValue)] 148 | 149 | // setting event handler to a string doesn't make sense 150 | if (eventName) throw Error(`Event prop can't be a string literal`) 151 | 152 | } 153 | if (prop.value.type == 'JSXExpressionContainer') { // value is an expression 154 | 155 | // modify the name and produce a template expression in all cases 156 | if (attributeName) return [' ', `${attributeName}`, '=', prop.value.expression] 157 | if (propertyName) return [' ', `.${propertyName}`, '=', prop.value.expression] 158 | if (eventName) return [' ', `@${eventName}`, '=', prop.value.expression] 159 | 160 | } 161 | } 162 | else { // prop has no value 163 | 164 | // we are setting a boolean attribute, no lit-html involved, just remove the `$` 165 | if (attributeName) return [' ', `${attributeName}`] 166 | 167 | // valueless event handler doesn't make sense 168 | if (eventName) throw Error(`Event prop must have a value`) 169 | 170 | // Valueless property default to `true` (imitate React) 171 | if (propertyName) return [' ', `.${propertyName}`, '=', t.booleanLiteral(true)] 172 | 173 | } 174 | throw new Error(`Couldn't transform attribute ${JSON.stringify(jsxName)}`) 175 | 176 | } 177 | 178 | /** 179 | * Take JSX child node and return array of template strings and parts 180 | * @param {*} child 181 | * @return {Array<*>} 182 | */ 183 | function renderChild(child) { 184 | 185 | if (child.type == 'JSXText') return [child.extra.raw] // text becomes part of template 186 | 187 | if (child.type == 'JSXExpressionContainer') { 188 | if (child.expression.type == 'JSXEmptyExpression') return [] 189 | else return [child.expression] // expression renders as part 190 | } 191 | 192 | if (child.type == 'JSXElement' || child.type == 'JSXFragment') 193 | return renderElement(child) // recurse on element 194 | 195 | throw new Error(`Unknown child type: ${child.type}`) 196 | 197 | } 198 | 199 | } 200 | 201 | const flatten = arrs => arrs.reduce((xs, x) => [...xs, ...x], []) 202 | 203 | const voidElements = [ 204 | 'area', 'base', 'basefont', 'bgsound', 'br', 'col', 'command', 205 | 'embed', 'frame', 'hr', 'image', 'img', 'input', 'isindex', 'keygen', 206 | 'link', 'menuitem', 'meta', 'nextid', 'param', 'source', 'track', 'wbr', 207 | ] 208 | --------------------------------------------------------------------------------