├── .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}${tag}>`;
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 |
--------------------------------------------------------------------------------