├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src └── litJSX.js ├── test └── tests.js └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Component Kitchen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # litJSX 2 | 3 | This library provides HTML tagged template literals to process JSX at runtime and then render a final string result. This is suitable for server-side HTML rendering, allowing decomposition of pages into functional components. 4 | 5 | This feels like using JSX in React/Preact, but without a compile step. Parsing of JSX is done at runtime, but is only performed once per tagged template literal, and parsing is quite efficient, so runtime performance should be acceptable. 6 | 7 | 8 | ## litJSX template literal 9 | 10 | litJSX exports a function `jsxToText` that parses JSX and returns a string representation. 11 | 12 | Example: 13 | 14 | ```js 15 | const jsxToText = require('litjsx'); 16 | 17 | const name = 'world'; 18 | jsxToText`Hello, ${name}.` // "Hello, world." 19 | ``` 20 | 21 | The JSX can contain a single top-level item, or multiple top-level items. 22 | 23 | 24 | ## Components 25 | 26 | Components are stateless functional components that take a `props` object as their sole parameter and return a string: 27 | 28 | ```js 29 | const jsxToText = require('litjsx'); 30 | 31 | export default function Header(props) { 32 | return jsxToText` 33 |

${props.children}

34 | `; 35 | } 36 | 37 | const title = new Text('Hello'); 38 | Header({ children: title }) //

Hello

39 | ``` 40 | 41 | 42 | ## Design-time syntax highlighting 43 | 44 | Various editor extensions exist to apply HTML syntax highlighting to tagged template literals. Some of these require that the name of the template literal be `html`. By importing the template literal function as `html`, you can convince your editor extension to apply syntax highlighting to these litJSX template strings. 45 | 46 | ```js 47 | const { jsxToText: html } = require('litjsx'); 48 | 49 | export default function Header(props) { 50 | return html` 51 |

${props.children}

52 | `; 53 | } 54 | ``` 55 | 56 | 57 | ## Binding to components 58 | 59 | Components often include subcomponents. 60 | 61 | By default, the litJSX template parser looks in the `global` scope for functions with the indicated component names. E.g., `` will look for a global function called `Foo` and incorporate the result of calling that function into the DOM or string result. 62 | 63 | For control over which components are included in the parser's scope, you can use bindable litJSX parser `jsxToTextWith`. This accepts a map of function names to functions, and returns a template literal that will use that map in resolving component names to functions. 64 | 65 | ```js 66 | const jsxToTextWith = require('litjsx'); 67 | const html = jsxToTextWith({ Bold, Greet }); // Create custom template literal. 68 | 69 | function Bold(props) { 70 | return html`${props.children}`; 71 | } 72 | 73 | function Greet(props) { 74 | return html` 75 | 76 | Hello, 77 | ${props.name}. 78 | 79 | `; 80 | } 81 | 82 | html`` // Hello, world. 83 | ``` 84 | 85 | This allows each JavaScript module to work strictly with the functions it has imported, without fear of name collisions. 86 | 87 | 88 | ## Quoting attributes 89 | 90 | Unlike standard JSX, litJSX requires you to quote all attributes. That said, you can pass an object via an attribute. Even thought it's quoted, it won't be coerced to a string. 91 | 92 | ```js 93 | const html = jsxToTextWith({ GreetFirst }); 94 | 95 | function GreetFirst(props) { 96 | return html`Hello, ${props.name.first}.`; 97 | } 98 | 99 | const name = { 100 | first: 'Jane', 101 | last: 'Doe' 102 | }; 103 | html`` // Hello, Jane. 104 | ``` 105 | 106 | 107 | ## Asynchronous components 108 | 109 | The litJSX functions support both synchronous and asynchronous components. If any component in the JSX is asynchronous, the entire tagged template literal returns a `Promise` for the complete result. This lets you create `async` components and `await` the final template result. 110 | 111 | ```js 112 | async function GreetUser(props) { 113 | const user = await getUser(props.id); // Some async function to get data 114 | return html`

Hello, ${user.name}.

`; 115 | } 116 | 117 | const html = jsxToTextWith({ GreetUser }); 118 | const userId = 1001; // Jane's user id 119 | const text = await html``; // Hello, Jane. 120 | ``` 121 | 122 | 123 | ## Server-side rendering 124 | 125 | litJSX is designed for use in server-side rendering of HTML. You can create litJSX components that accept an HTTP request and return a suitable block of HTML that can be sent as a response. E.g., writing a web server in [Express](http://expressjs.com/): 126 | 127 | ```js 128 | const html = jsxToTextWith({ Greet }); 129 | 130 | function Greet(props) { 131 | return html`

Hello, ${props.name}

`; 132 | } 133 | 134 | function GreetPage(request) { 135 | return html` 136 | 137 | 138 | 139 | 140 | 141 | 142 | `; 143 | } 144 | 145 | // The page at /greet/Jane returns HTML saying "Hello, Jane." 146 | app.get('/greet/:name', (request, response) => { 147 | const content = GreetPage(request); 148 | response.set('Content-Type', 'text/html'); 149 | response.send(content); 150 | }); 151 | ``` 152 | 153 | Components to render pages will often be asynchronous components (see above) so that they can incorporate the results of database queries and other async work. 154 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "litjsx", 3 | "version": "0.0.1", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@types/chai": { 8 | "version": "4.1.3", 9 | "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.1.3.tgz", 10 | "integrity": "sha512-f5dXGzOJycyzSMdaXVhiBhauL4dYydXwVpavfQ1mVCaGjR56a9QfklXObUxlIY9bGTmCPHEEZ04I16BZ/8w5ww==", 11 | "dev": true 12 | }, 13 | "@types/mocha": { 14 | "version": "5.2.0", 15 | "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-5.2.0.tgz", 16 | "integrity": "sha512-YeDiSEzznwZwwp766SJ6QlrTyBYUGPSIwmREHVTmktUYiT/WADdWtpt9iH0KuUSf8lZLdI4lP0X6PBzPo5//JQ==", 17 | "dev": true 18 | }, 19 | "@types/node": { 20 | "version": "10.0.0", 21 | "resolved": "https://registry.npmjs.org/@types/node/-/node-10.0.0.tgz", 22 | "integrity": "sha512-kctoM36XiNZT86a7tPsUje+Q/yl+dqELjtYApi0T5eOQ90Elhu0MI10rmYk44yEP4v1jdDvtjQ9DFtpRtHf2Bw==", 23 | "dev": true 24 | }, 25 | "assertion-error": { 26 | "version": "1.1.0", 27 | "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", 28 | "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", 29 | "dev": true 30 | }, 31 | "balanced-match": { 32 | "version": "1.0.0", 33 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", 34 | "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", 35 | "dev": true 36 | }, 37 | "brace-expansion": { 38 | "version": "1.1.11", 39 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", 40 | "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", 41 | "dev": true, 42 | "requires": { 43 | "balanced-match": "1.0.0", 44 | "concat-map": "0.0.1" 45 | } 46 | }, 47 | "browser-stdout": { 48 | "version": "1.3.1", 49 | "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", 50 | "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", 51 | "dev": true 52 | }, 53 | "chai": { 54 | "version": "4.1.2", 55 | "resolved": "https://registry.npmjs.org/chai/-/chai-4.1.2.tgz", 56 | "integrity": "sha1-D2RYS6ZC8PKs4oBiefTwbKI61zw=", 57 | "dev": true, 58 | "requires": { 59 | "assertion-error": "1.1.0", 60 | "check-error": "1.0.2", 61 | "deep-eql": "3.0.1", 62 | "get-func-name": "2.0.0", 63 | "pathval": "1.1.0", 64 | "type-detect": "4.0.8" 65 | } 66 | }, 67 | "check-error": { 68 | "version": "1.0.2", 69 | "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", 70 | "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", 71 | "dev": true 72 | }, 73 | "commander": { 74 | "version": "2.11.0", 75 | "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", 76 | "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==", 77 | "dev": true 78 | }, 79 | "concat-map": { 80 | "version": "0.0.1", 81 | "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", 82 | "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", 83 | "dev": true 84 | }, 85 | "debug": { 86 | "version": "3.1.0", 87 | "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", 88 | "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", 89 | "dev": true, 90 | "requires": { 91 | "ms": "2.0.0" 92 | } 93 | }, 94 | "deep-eql": { 95 | "version": "3.0.1", 96 | "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", 97 | "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", 98 | "dev": true, 99 | "requires": { 100 | "type-detect": "4.0.8" 101 | } 102 | }, 103 | "diff": { 104 | "version": "3.5.0", 105 | "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", 106 | "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", 107 | "dev": true 108 | }, 109 | "escape-string-regexp": { 110 | "version": "1.0.5", 111 | "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", 112 | "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", 113 | "dev": true 114 | }, 115 | "fs.realpath": { 116 | "version": "1.0.0", 117 | "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", 118 | "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", 119 | "dev": true 120 | }, 121 | "get-func-name": { 122 | "version": "2.0.0", 123 | "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", 124 | "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", 125 | "dev": true 126 | }, 127 | "glob": { 128 | "version": "7.1.2", 129 | "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", 130 | "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", 131 | "dev": true, 132 | "requires": { 133 | "fs.realpath": "1.0.0", 134 | "inflight": "1.0.6", 135 | "inherits": "2.0.3", 136 | "minimatch": "3.0.4", 137 | "once": "1.4.0", 138 | "path-is-absolute": "1.0.1" 139 | } 140 | }, 141 | "growl": { 142 | "version": "1.10.3", 143 | "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.3.tgz", 144 | "integrity": "sha512-hKlsbA5Vu3xsh1Cg3J7jSmX/WaW6A5oBeqzM88oNbCRQFz+zUaXm6yxS4RVytp1scBoJzSYl4YAEOQIt6O8V1Q==", 145 | "dev": true 146 | }, 147 | "has-flag": { 148 | "version": "2.0.0", 149 | "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-2.0.0.tgz", 150 | "integrity": "sha1-6CB68cx7MNRGzHC3NLXovhj4jVE=", 151 | "dev": true 152 | }, 153 | "he": { 154 | "version": "1.1.1", 155 | "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", 156 | "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=", 157 | "dev": true 158 | }, 159 | "inflight": { 160 | "version": "1.0.6", 161 | "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", 162 | "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", 163 | "dev": true, 164 | "requires": { 165 | "once": "1.4.0", 166 | "wrappy": "1.0.2" 167 | } 168 | }, 169 | "inherits": { 170 | "version": "2.0.3", 171 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", 172 | "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", 173 | "dev": true 174 | }, 175 | "minimatch": { 176 | "version": "3.0.4", 177 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", 178 | "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", 179 | "dev": true, 180 | "requires": { 181 | "brace-expansion": "1.1.11" 182 | } 183 | }, 184 | "minimist": { 185 | "version": "0.0.8", 186 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", 187 | "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", 188 | "dev": true 189 | }, 190 | "mkdirp": { 191 | "version": "0.5.1", 192 | "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", 193 | "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", 194 | "dev": true, 195 | "requires": { 196 | "minimist": "0.0.8" 197 | } 198 | }, 199 | "mocha": { 200 | "version": "5.1.1", 201 | "resolved": "https://registry.npmjs.org/mocha/-/mocha-5.1.1.tgz", 202 | "integrity": "sha512-kKKs/H1KrMMQIEsWNxGmb4/BGsmj0dkeyotEvbrAuQ01FcWRLssUNXCEUZk6SZtyJBi6EE7SL0zDDtItw1rGhw==", 203 | "dev": true, 204 | "requires": { 205 | "browser-stdout": "1.3.1", 206 | "commander": "2.11.0", 207 | "debug": "3.1.0", 208 | "diff": "3.5.0", 209 | "escape-string-regexp": "1.0.5", 210 | "glob": "7.1.2", 211 | "growl": "1.10.3", 212 | "he": "1.1.1", 213 | "minimatch": "3.0.4", 214 | "mkdirp": "0.5.1", 215 | "supports-color": "4.4.0" 216 | } 217 | }, 218 | "ms": { 219 | "version": "2.0.0", 220 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 221 | "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", 222 | "dev": true 223 | }, 224 | "once": { 225 | "version": "1.4.0", 226 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 227 | "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", 228 | "dev": true, 229 | "requires": { 230 | "wrappy": "1.0.2" 231 | } 232 | }, 233 | "path-is-absolute": { 234 | "version": "1.0.1", 235 | "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", 236 | "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", 237 | "dev": true 238 | }, 239 | "pathval": { 240 | "version": "1.1.0", 241 | "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.0.tgz", 242 | "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=", 243 | "dev": true 244 | }, 245 | "supports-color": { 246 | "version": "4.4.0", 247 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-4.4.0.tgz", 248 | "integrity": "sha512-rKC3+DyXWgK0ZLKwmRsrkyHVZAjNkfzeehuFWdGGcqGDTZFH73+RH6S/RDAAxl9GusSjZSUWYLmT9N5pzXFOXQ==", 249 | "dev": true, 250 | "requires": { 251 | "has-flag": "2.0.0" 252 | } 253 | }, 254 | "type-detect": { 255 | "version": "4.0.8", 256 | "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", 257 | "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", 258 | "dev": true 259 | }, 260 | "wrappy": { 261 | "version": "1.0.2", 262 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 263 | "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", 264 | "dev": true 265 | }, 266 | "xmldom": { 267 | "version": "0.1.27", 268 | "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.27.tgz", 269 | "integrity": "sha1-1QH5ezvbQDr4757MIFcxh6rawOk=" 270 | } 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "litjsx", 3 | "description": "Server-side HTML rendering via a JavaScript template literal for JSX", 4 | "version": "0.0.1", 5 | "license": "MIT", 6 | "main": "src/litJSX.js", 7 | "dependencies": { 8 | "xmldom": "0.1.27" 9 | }, 10 | "devDependencies": { 11 | "@types/chai": "4.1.3", 12 | "@types/mocha": "5.2.0", 13 | "@types/node": "10.0.0", 14 | "chai": "4.1.2", 15 | "mocha": "5.1.1" 16 | }, 17 | "scripts": { 18 | "test": "./node_modules/.bin/mocha --reporter spec" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/litJSX.js: -------------------------------------------------------------------------------- 1 | /* 2 | * litJSX 3 | * 4 | * JSX-like tagged template literals 5 | */ 6 | 7 | 8 | // @ts-ignore 9 | const { DOMParser } = require('xmldom'); 10 | let domParser = new DOMParser(); 11 | 12 | 13 | // Default cache for processed template strings. 14 | const defaultCache = new WeakMap(); 15 | 16 | 17 | const selfClosingTags = { 18 | area: true, 19 | base: true, 20 | br: true, 21 | col: true, 22 | command: true, 23 | embed: true, 24 | hr: true, 25 | img: true, 26 | input: true, 27 | keygen: true, 28 | link: true, 29 | menuitem: true, 30 | meta: true, 31 | param: true, 32 | source: true, 33 | track: true, 34 | wbr: true 35 | }; 36 | 37 | 38 | /* 39 | * Return the given string with any leading or trailing whitespace condensed to 40 | * a single space. This generally ensures the same whitespace handling in HTML, 41 | * while avoiding long blocks of white space before or after strings. 42 | * Example: ' Hello, world ' => ' Hello, world ' 43 | */ 44 | function collapseWhitespace(string) { 45 | const hasLeadingSpace = /^\s/.test(string); 46 | const hasTrailingSpace = /\s$/.test(string); 47 | const trimmed = string.trim(); 48 | if (trimmed.length === 0) { 49 | return ' '; // Whole string was whitespace 50 | } else { 51 | const newLeadingSpace = hasLeadingSpace ? ' ' : ''; 52 | const newTrailingSpace = hasTrailingSpace ? ' ' : ''; 53 | return `${newLeadingSpace}${trimmed}${newTrailingSpace}`; 54 | } 55 | } 56 | 57 | 58 | /* 59 | * A component that render a fragment of a document. 60 | * We use this as a way of representing the result of parsing JSX that contains 61 | * multiple top-level nodes. 62 | */ 63 | function DocumentFragment(props) { 64 | return props.children; 65 | } 66 | 67 | 68 | /* 69 | * Check a node returned from DOMParser to see if it contains an error (the 70 | * parser doesn't throw exceptions). If such a node is found, return the text of 71 | * the error, otherwise null. 72 | */ 73 | function findDOMParserError(node) { 74 | const isErrorNode = node => node && node.nodeName === 'parsererror'; 75 | // Error node might be first child or first grandchild. 76 | const child = node.childNodes && node.childNodes[0]; 77 | const grandchild = child && child.childNodes && child.childNodes[0]; 78 | const errorNode = isErrorNode(child) ? 79 | child : 80 | isErrorNode(grandchild) ? 81 | grandchild : 82 | null; 83 | return errorNode ? errorNode.textContent : null; 84 | } 85 | 86 | 87 | /* 88 | * Template literal function for JSX. 89 | */ 90 | function jsxToText(strings, ...substitutions) { 91 | const data = parseAndCache(strings, {}, defaultCache); 92 | return render(data, substitutions); 93 | } 94 | 95 | 96 | /* 97 | * Return a template literal capable of handling the indicated classes and 98 | * constructing a string representation. 99 | */ 100 | function jsxToTextWith(classMap = {}) { 101 | const cache = new WeakMap(); 102 | return (strings, ...substitutions) => { 103 | const data = parseAndCache(strings, classMap, cache); 104 | return render(data, substitutions); 105 | }; 106 | } 107 | 108 | 109 | /* 110 | * Main parser entry point for parsing template literals. 111 | * 112 | * This accepts the set of strings which were passed to a template literal, and 113 | * returns a data representation that can be combined with an array of 114 | * substitution values (also from the template literal) to reconstruct either a 115 | * complete DOM or string representation. 116 | * 117 | * Example: `
Hello
` -> 118 | * 119 | * [ 120 | * 'div', 121 | * { class: 'foo' }, 122 | * 'Hello' 123 | * ] 124 | * 125 | * The 2nd+ array elements are the children, which can be subarrays. 126 | * For components, instead of an element name ('div'), the first element in 127 | * the array will be the component's function. 128 | */ 129 | function parse(strings, classMap = {}) { 130 | // Concatenate the strings to form JSX (XML). Intersperse text markers that 131 | // contain an index. That index will be used later to obtain substitutions 132 | // from the values passed to the tag template literal. 133 | const jsx = strings.map((string, index) => { 134 | const marker = index < strings.length - 1 ? 135 | `[[[${index}]]]` : 136 | ''; 137 | return `${string}${marker}`; 138 | }).join(''); 139 | // Parse the resulting JSX. 140 | return parseJSX(jsx, classMap); 141 | } 142 | 143 | 144 | function parseAndCache(strings, classMap, cache) { 145 | // Do we already have data for this set of strings? 146 | let data = cache.get(strings); 147 | if (!data) { 148 | data = parse(strings, classMap); 149 | // Remember the data for next time. 150 | cache.set(strings, data); 151 | } 152 | return data; 153 | } 154 | 155 | 156 | /* 157 | * Parse the given text string as JSX. 158 | * 159 | * This invokes the standard DOMParser, then transforms the parsed result into 160 | * our array representation (see `parse`). 161 | */ 162 | function parseJSX(jsx, classMap = {}) { 163 | 164 | // xmldom parser chokes unless leading/trailing whitespace is trimmed. 165 | const trimmed = jsx.trim(); 166 | 167 | // xmldom will do some limited handling of named HTML entites -- 168 | // but we don't want that. We circumvent that by replacing ampersands 169 | // with the corresponding & entity. xmldom will see the latter 170 | // and replace it with an ampersand, restoring the original text. 171 | const escaped = trimmed.replace(/&/g, '&'); 172 | 173 | // The parser expects only a single node, but we want to handle fragments 174 | // with more than one node, so we wrap with a DocumentFragment node. 175 | const wrapped = `${escaped}`; 176 | 177 | // Create an extended class map that supports DocumentFragment. 178 | const extendedClassMap = Object.assign({ DocumentFragment }, classMap); 179 | 180 | const doc = domParser.parseFromString(wrapped, 'text/xml'); 181 | 182 | // Result of parsing should be a single node representing our wrapper. 183 | const node = doc.firstChild; 184 | 185 | const error = findDOMParserError(node); 186 | if (error) { 187 | throw error; 188 | } 189 | 190 | // Transform the XML DOM node into our data format. 191 | const data = transformNode(node, extendedClassMap); 192 | 193 | // Try to simplify the result. 194 | const reduced = reduce(data); 195 | 196 | // We should now have a DocumentFragment object with simplified children. If 197 | // there is only a single child, unwrap and return that child. 198 | const canUnwrap = data[0] === DocumentFragment && data[2].length === 1; 199 | return canUnwrap ? reduced[2][0] : reduced; 200 | } 201 | 202 | 203 | /* 204 | * Reduce the element/component represented by the data if possible. 205 | * 206 | * We can reduce an element (not a component) if all its attributes and children 207 | * are plain strings that don't reference substitutions. We also 208 | * opportunistically concatenate adjacent strings. 209 | * 210 | * Example: ['h1', {}, ['Hello, ', 'world']] => '

Hello, world

' 211 | */ 212 | function reduce(data) { 213 | if (typeof data === 'string' || typeof data === 'number') { 214 | return data; 215 | } 216 | const [nameData, attributesData, childrenData] = data; 217 | const irreducibleComponent = typeof nameData === 'function'; 218 | const reducedChildren = childrenData.map(child => reduce(child)); 219 | 220 | // See if we can merge any consecutive strings. 221 | const mergedChildren = reducedChildren.reduce((acc, current) => { 222 | const previous = acc.length > 0 ? acc[acc.length - 1] : null; 223 | if (typeof previous === 'string' && typeof current === 'string') { 224 | acc[acc.length - 1] = previous + current; 225 | } else { 226 | acc.push(current); 227 | } 228 | return acc; 229 | }, []); 230 | 231 | const irreducibleAttributes = Object.entries(attributesData).some(([name, value]) => 232 | typeof value !== 'string' 233 | ); 234 | const irreducibleChildren = mergedChildren.length > 1 || 235 | mergedChildren.length === 1 && typeof mergedChildren[0] !== 'string'; 236 | if (irreducibleComponent || irreducibleAttributes || irreducibleChildren) { 237 | // We may have been able to reduce some of the children, 238 | // but we can't completely reduce this node. 239 | return [nameData, attributesData, mergedChildren]; 240 | } 241 | // Data represents a plain element that can be completely rendered now. 242 | const renderedChildren = renderChildren(mergedChildren); 243 | return renderElement(nameData, attributesData, renderedChildren); 244 | } 245 | 246 | 247 | /* 248 | * Render an array of children, which may include async results. 249 | */ 250 | function renderChildren(childrenData, substitutions) { 251 | const rendered = childrenData.map(child => 252 | render(child, substitutions, )); 253 | // See if any of the rendered results are promises. 254 | const anyPromises = rendered.find(result => result instanceof Promise); 255 | if (anyPromises) { 256 | // At least one of the rendered results was a promise; wait for them all to 257 | // complete before processing the final set. 258 | return Promise.all(rendered).then(children => 259 | children.join('') 260 | ); 261 | } else { 262 | // All children were synchronous, so process final set right away. 263 | return rendered.join(''); 264 | } 265 | } 266 | 267 | 268 | /* 269 | * Invoke the indicated component to render it. 270 | */ 271 | function renderComponent(component, attributes, children) { 272 | const props = Object.assign( 273 | {}, 274 | attributes, 275 | { children } 276 | ); 277 | return component(props); 278 | } 279 | 280 | 281 | /* 282 | * Render the HTML element with the indicated tag. 283 | */ 284 | function renderElement(tag, attributes, children) { 285 | const attributeText = Object.keys(attributes).map(name => { 286 | return ` ${name}="${attributes[name]}"`; 287 | }).join(''); 288 | const noChildren = children.trim().length === 0; 289 | return selfClosingTags[tag] && noChildren ? 290 | `<${tag}${attributeText}>` : 291 | `<${tag}${attributeText}>${children}`; 292 | } 293 | 294 | 295 | /* 296 | * Given an array representation returned by `parse`, apply the given 297 | * substitutions (values from the template literal) and return the resulting 298 | * text. 299 | */ 300 | function render(data, substitutions) { 301 | if (typeof data === 'string') { 302 | return data; 303 | } else if (typeof data === 'number') { 304 | return renderSubstitution(substitutions[data]); 305 | } 306 | 307 | // A component or element. 308 | const [nameData, attributesData, childrenData] = data; 309 | const isComponent = typeof nameData === 'function'; 310 | const renderedAttributes = renderAttributes(attributesData, substitutions); 311 | const topRenderer = isComponent ? renderComponent : renderElement; 312 | 313 | // Children may a promise for children, or the actual children. 314 | const awaitedChildren = renderChildren(childrenData, substitutions); 315 | if (awaitedChildren instanceof Promise) { 316 | // Wait for children before constructing result. 317 | return awaitedChildren.then(children => 318 | topRenderer(nameData, renderedAttributes, children) 319 | ); 320 | } else { 321 | // Children were synchronous, can construct result right away. 322 | return topRenderer(nameData, renderedAttributes, awaitedChildren); 323 | } 324 | } 325 | 326 | 327 | /* 328 | * Render a set of attributes. 329 | */ 330 | function renderAttributes(attributesData, substitutions) { 331 | const rendered = {}; 332 | for (const [name, value] of Object.entries(attributesData)) { 333 | rendered[name] = value instanceof Array ? 334 | // Mulit-part attribute: resolve each part and concatenate results. 335 | value.map(item => render(item, substitutions)).join('') : 336 | // Single-part attribute 337 | render(value, substitutions); 338 | } 339 | return rendered; 340 | } 341 | 342 | 343 | /* 344 | * Render a substituted value. 345 | * Arrays are concatenated, everything else returned as string. 346 | */ 347 | function renderSubstitution(substitution) { 348 | return substitution instanceof Array ? 349 | substitution.join('') : 350 | substitution; 351 | } 352 | 353 | 354 | /* 355 | * Transform the attributes on a node to our array representation. 356 | */ 357 | function transformAttributes(attributes) { 358 | const attributeData = {}; 359 | Array.from(attributes).forEach(attribute => { 360 | attributeData[attribute.name] = transformText(attribute.value); 361 | }); 362 | return attributeData; 363 | } 364 | 365 | 366 | /* 367 | * Transform a Node returned by DOMParser into our array representation. 368 | */ 369 | function transformNode(node, classMap = {}) { 370 | if (node.nodeType === 3 /* Text node */) { 371 | return transformText(node.textContent); 372 | } else if (node.nodeType !== 1 /* I.e., not an Element */) { 373 | // The xmldom DOMParser provides a `toString` on all nodes 374 | // that seems to return what we want. (The browser DOMParser 375 | // doesn't.) 376 | return node.toString(); 377 | } 378 | // What's left is either an element or a capitalized function name. 379 | const localName = node.localName; 380 | const isClassName = localName[0] === localName[0].toUpperCase(); 381 | const nameData = isClassName ? 382 | classMap[localName] || global[localName] : 383 | localName; 384 | if (!nameData) { 385 | throw `Couldn't find definition for "${localName}".`; 386 | } 387 | const attributeData = transformAttributes(node.attributes); 388 | const childrenData = transformNodes(node.childNodes, classMap); 389 | return [ 390 | nameData, 391 | attributeData, 392 | childrenData 393 | ]; 394 | } 395 | 396 | 397 | /* 398 | * Transform a list of Node objects to our array representation. 399 | */ 400 | function transformNodes(nodes, classMap) { 401 | let result = []; 402 | Array.from(nodes).forEach(node => { 403 | const transformed = transformNode(node, classMap); 404 | if (node.nodeType === 3 /* Text node */) { 405 | // Splice into result. 406 | result = result.concat(transformed); 407 | } else { 408 | result.push(transformed); 409 | } 410 | }); 411 | return result; 412 | } 413 | 414 | 415 | /* 416 | * Transform a text string that may contain placeholders into our array 417 | * representation. 418 | */ 419 | function transformText(text) { 420 | const markerRegex = /\[\[\[(\d+)\]\]\]/; 421 | const trimmed = collapseWhitespace(text); 422 | const parts = trimmed.split(markerRegex); 423 | if (parts.length === 1) { 424 | // No markers. 425 | return trimmed; 426 | } 427 | // There are markers. There should be an odd number of parts. Parts with an 428 | // even index are strings, with an odd index are markers. We translate the 429 | // latter to numbers that will later index into a substitutions array. 430 | const transformed = parts.map((part, index) => 431 | index % 2 === 0 ? 432 | part : 433 | parseInt(part) 434 | ); 435 | // Remove empty strings. 436 | const stripped = transformed.filter(item => typeof item !== 'string' || item.length > 0); 437 | 438 | return (stripped.length === 1 && typeof stripped[0] === 'number') ? 439 | stripped[0] : // Only one item that's an index; return the index itself. 440 | stripped; 441 | } 442 | 443 | 444 | module.exports = { 445 | jsxToText, 446 | jsxToTextWith, 447 | parse, 448 | parseJSX, 449 | render 450 | }; 451 | -------------------------------------------------------------------------------- /test/tests.js: -------------------------------------------------------------------------------- 1 | const assert = require("chai").assert; 2 | const { 3 | jsxToText, 4 | jsxToTextWith, 5 | parse, 6 | parseJSX, 7 | render 8 | } = require('../src/litJSX.js'); 9 | 10 | 11 | function Bold(props) { 12 | return `${props.children}`; 13 | } 14 | // @ts-ignore 15 | global.Bold = Bold; 16 | 17 | 18 | describe("litJSX", () => { 19 | 20 | it("parses sequences with single substitution", () => { 21 | const data = parse([ 22 | `
`, 23 | `
` 24 | ]); 25 | assert.deepEqual(data, [ 26 | 'div', 27 | {}, 28 | [ 29 | 0, 30 | ] 31 | ]); 32 | }); 33 | 34 | it("parses sequences with mutliple substitutions", () => { 35 | const data = parseJSX(`
[[[0]]]foo[[[1]]]
`); 36 | assert.deepEqual(data, [ 37 | 'div', 38 | {}, 39 | [ 40 | 0, 41 | 'foo', 42 | 1 43 | ] 44 | ]); 45 | }); 46 | 47 | it("parses attributes", () => { 48 | const data = parse([ 49 | `
` 51 | ]); 52 | assert.deepEqual(data, [ 53 | 'div', 54 | { 55 | 'class': 0 56 | }, 57 | [] 58 | ]); 59 | }); 60 | 61 | it("parses embedded component", () => { 62 | const data = parse([ 63 | `Hello, `, 64 | `.` 65 | ]); 66 | assert.deepEqual(data, [ 67 | 'span', 68 | {}, 69 | [ 70 | 'Hello, ', 71 | [ 72 | Bold, 73 | {}, 74 | [ 75 | 0 76 | ] 77 | ], 78 | '.' 79 | ] 80 | ]); 81 | }); 82 | 83 | it("flattens nodes with no substitutions", () => { 84 | const data = parse([ 85 | `
Hello, `, 86 | `
` 87 | ]); 88 | assert.deepEqual(data, [ 89 | 'div', 90 | {}, 91 | [ 92 | 'Hello, ', 93 | [ 94 | 'b', 95 | {}, 96 | [ 97 | 0 98 | ] 99 | ] 100 | ] 101 | ]); 102 | }); 103 | 104 | it("can render data + values", () => { 105 | const data = parse([ 106 | `Hello, `, 107 | `.` 108 | ]); 109 | const result = render(data, ['world']); 110 | assert.equal(result, 'Hello, world.'); 111 | }); 112 | 113 | it("provides tag template literal", () => { 114 | const name = 'world'; 115 | const text = jsxToText`Hello, ${name}.`; 116 | assert.equal(text, 'Hello, world.'); 117 | }); 118 | 119 | it("can construct a template literal for text that handles specific classes", () => { 120 | const Italic = (props) => `${props.children}`; 121 | const html = jsxToTextWith({ Italic }); 122 | const text = html`foo`; 123 | assert.equal(text, `foo`); 124 | }); 125 | 126 | it("can render attributes", () => { 127 | const html = jsxToText; 128 | const value = 'foo'; 129 | const text = html`
`; 130 | assert.equal(text, `
`); 131 | }); 132 | 133 | it("can concatenate strings to construct an attribute value", () => { 134 | const html = jsxToText; 135 | const value = 'foo'; 136 | const text = html`
`; 137 | assert.equal(text, `
`); 138 | }); 139 | 140 | it("can pass objects to parameters identified as if they were attributes", () => { 141 | const LastFirst = props => jsxToText`${props.name.last}, ${props.name.first}`; 142 | const name = { 143 | first: 'Jane', 144 | last: 'Doe' 145 | }; 146 | const html = jsxToTextWith({ LastFirst }); 147 | const text = html``; 148 | assert.equal(text, `Doe, Jane`); 149 | }); 150 | 151 | it("can render async components", async () => { 152 | const Async = props => Promise.resolve(`*${props.children}*`); 153 | const text = await jsxToTextWith({ Async })`test`; 154 | assert.equal(text, `*test*`); 155 | }); 156 | 157 | it("waits for async components in parallel", async () => { 158 | const Async = async (props) => { 159 | const delay = parseInt(props.delay); 160 | await new Promise(resolve => setTimeout(resolve, delay)); 161 | return `[${props.children}]`; 162 | }; 163 | const html = jsxToTextWith({ Async }); 164 | const text = await html` 165 | 166 | One 167 | Two 168 | 169 | `; 170 | assert.equal(text, ` [One] [Two] `); 171 | }); 172 | 173 | it("can handle multiple top-level elements", () => { 174 | const text = jsxToText`${'One'}${'Two'}`; 175 | assert.equal(text, 'OneTwo'); 176 | }); 177 | 178 | it("can handle document type nodes", () => { 179 | const text = jsxToText``; 180 | assert.equal(text, ``); 181 | }); 182 | 183 | it("can handle comments", () => { 184 | const text = jsxToText``; 185 | assert.equal(text, ``); 186 | }); 187 | 188 | it("leaves named HTML entities alone", () => { 189 | const text = jsxToText`<`; 190 | assert.equal(text, `<`); 191 | }); 192 | 193 | it("handles JSX that is only substitutions", () => { 194 | const a = 0; 195 | const b = 1; 196 | const text = jsxToText`${a}${b}`; 197 | assert.equal(text, '01'); 198 | }); 199 | 200 | it('renders an array', () => { 201 | const a = [ 202 | 'Hello', 203 | 'world' 204 | ]; 205 | const text = jsxToText`${a}`; 206 | assert.equal(text, 'Helloworld'); 207 | }); 208 | 209 | }); 210 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "checkJs": true, 5 | "noEmit": true, 6 | "noImplicitAny": false, 7 | "noImplicitReturns": true, 8 | "noUnusedLocals": true, 9 | "lib": [ 10 | "es2017" 11 | ], 12 | "strict": true, 13 | "target": "es2017", 14 | "types": [ 15 | "chai", 16 | "mocha", 17 | "node" 18 | ] 19 | }, 20 | "exclude": [ 21 | "node_modules" 22 | ] 23 | } 24 | --------------------------------------------------------------------------------