├── .github └── workflows │ ├── compressed-size.yml │ └── test.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── babel.config.js ├── package.json ├── packages ├── babel-plugin-htm │ ├── README.md │ ├── index.mjs │ └── package.json └── babel-plugin-transform-jsx-to-htm │ ├── README.md │ ├── index.mjs │ └── package.json ├── src ├── build.mjs ├── cjs.mjs ├── constants-mini.mjs ├── constants.mjs ├── index.d.ts ├── index.mjs └── integrations │ ├── preact │ ├── index.d.ts │ ├── index.mjs │ ├── package.json │ └── standalone.mjs │ └── react │ ├── index.d.ts │ ├── index.mjs │ └── package.json └── test ├── __d8.mjs ├── __perftest.mjs ├── babel-transform-jsx.test.mjs ├── babel.test.mjs ├── fixtures └── esm │ ├── index.js │ └── package.json ├── index.test.mjs ├── perf.test.mjs ├── preact.test.mjs └── statics-caching.test.mjs /.github/workflows/compressed-size.yml: -------------------------------------------------------------------------------- 1 | name: Compressed Size 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: preactjs/compressed-size-action@v2 12 | with: 13 | pattern: "./{dist,mini,react,preact}/{index.js,index.mjs,htm.js,htm.mjs,standalone.js,standalone.mjs}" 14 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: [pull_request, push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | # Change the condition for ESM Dist Test below when changing this. 11 | node-version: [12.x, 14.x] 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - name: Install 20 | run: npm install 21 | - name: Build and Test 22 | run: npm test 23 | - if: matrix.node-version == '14.x' 24 | name: ESM Dist Test 25 | run: npm run test:dist 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | /preact 4 | /react 5 | dist 6 | mini 7 | yarn.lock 8 | htm.tgz 9 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at jason@developit.ca. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement. You (or your employer) retain the copyright to your contribution, 10 | this simply gives us permission to use and redistribute your contributions as 11 | part of the project. Head over to to see 12 | your current agreements on file or to sign a new one. 13 | 14 | You generally only need to submit a CLA once, so if you've already submitted one 15 | (even if it was for a different project), you probably don't need to do it 16 | again. 17 | 18 | ## Code reviews 19 | 20 | All submissions, including submissions by project members, require review. We 21 | use GitHub pull requests for this purpose. Consult 22 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 23 | information on using pull requests. 24 | 25 | ## Community Guidelines 26 | 27 | This project follows [Google's Open Source Community 28 | Guidelines](https://opensource.google.com/conduct/). -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2018 Google Inc. 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | HTM (Hyperscript Tagged Markup) 4 | npm 5 |

6 |

7 | hyperscript tagged markup demo 8 |

9 | 10 | `htm` is **JSX-like syntax in plain JavaScript** - no transpiler necessary. 11 | 12 | Develop with React/Preact directly in the browser, then compile `htm` away for production. 13 | 14 | It uses standard JavaScript [Tagged Templates] and works in [all modern browsers]. 15 | 16 | ## `htm` by the numbers: 17 | 18 | 🐣 **< 600 bytes** when used directly in the browser 19 | 20 | ⚛️ **< 500 bytes** when used with Preact _(thanks gzip 🌈)_ 21 | 22 | 🥚 **< 450 byte** `htm/mini` version 23 | 24 | 🏅 **0 bytes** if compiled using [babel-plugin-htm] 25 | 26 | 27 | ## Syntax: like JSX but also lit 28 | 29 | The syntax you write when using HTM is as close as possible to JSX: 30 | 31 | - Spread props: `
` instead of `
` 32 | - Self-closing tags: `
` 33 | - Components: `<${Foo}>` instead of `` _(where `Foo` is a component reference)_ 34 | - Boolean attributes: `
` 35 | 36 | 37 | ## Improvements over JSX 38 | 39 | `htm` actually takes the JSX-style syntax a couple steps further! 40 | 41 | Here's some ergonomic features you get for free that aren't present in JSX: 42 | 43 | - **No transpiler necessary** 44 | - HTML's optional quotes: `
` 45 | - Component end-tags: `<${Footer}>footer content` 46 | - Syntax highlighting and language support via the [lit-html VSCode extension] and [vim-jsx-pretty plugin]. 47 | - Multiple root element (fragments): `
` 48 | - Support for HTML-style comments: `
` 49 | 50 | ## Installation 51 | 52 | `htm` is published to npm, and accessible via the unpkg.com CDN: 53 | 54 | **via npm:** 55 | 56 | ```js 57 | npm i htm 58 | ``` 59 | 60 | **hotlinking from unpkg:** _(no build tool needed!)_ 61 | 62 | ```js 63 | import htm from 'https://unpkg.com/htm?module' 64 | const html = htm.bind(React.createElement); 65 | ``` 66 | 67 | ```js 68 | // just want htm + preact in a single file? there's a highly-optimized version of that: 69 | import { html, render } from 'https://unpkg.com/htm/preact/standalone.module.js' 70 | ``` 71 | 72 | ## Usage 73 | 74 | If you're using Preact or React, we've included off-the-shelf bindings to make your life easier. 75 | They also have the added benefit of sharing a template cache across all modules. 76 | 77 | ```js 78 | import { render } from 'preact'; 79 | import { html } from 'htm/preact'; 80 | render(html`Hello!`, document.body); 81 | ``` 82 | 83 | Similarly, for React: 84 | 85 | ```js 86 | import ReactDOM from 'react-dom'; 87 | import { html } from 'htm/react'; 88 | ReactDOM.render(html`Hello!`, document.body); 89 | ``` 90 | 91 | ### Advanced Usage 92 | 93 | Since `htm` is a generic library, we need to tell it what to "compile" our templates to. 94 | You can bind `htm` to any function of the form `h(type, props, ...children)` _([hyperscript])_. 95 | This function can return anything - `htm` never looks at the return value. 96 | 97 | Here's an example `h()` function that returns tree nodes: 98 | 99 | ```js 100 | function h(type, props, ...children) { 101 | return { type, props, children }; 102 | } 103 | ``` 104 | 105 | To use our custom `h()` function, we need to create our own `html` tag function by binding `htm` to our `h()` function: 106 | 107 | ```js 108 | import htm from 'htm'; 109 | 110 | const html = htm.bind(h); 111 | ``` 112 | 113 | Now we have an `html()` template tag that can be used to produce objects in the format we created above. 114 | 115 | Here's the whole thing for clarity: 116 | 117 | ```js 118 | import htm from 'htm'; 119 | 120 | function h(type, props, ...children) { 121 | return { type, props, children }; 122 | } 123 | 124 | const html = htm.bind(h); 125 | 126 | console.log( html`

Hello world!

` ); 127 | // { 128 | // type: 'h1', 129 | // props: { id: 'hello' }, 130 | // children: ['Hello world!'] 131 | // } 132 | ``` 133 | 134 | If the template has multiple element at the root level 135 | the output is an array of `h` results: 136 | 137 | ```js 138 | console.log(html` 139 |

Hello

140 |
World!
141 | `); 142 | // [ 143 | // { 144 | // type: 'h1', 145 | // props: { id: 'hello' }, 146 | // children: ['Hello'] 147 | // }, 148 | // { 149 | // type: 'div', 150 | // props: { class: 'world' }, 151 | // children: ['world!'] 152 | // } 153 | // ] 154 | ``` 155 | 156 | ### Caching 157 | 158 | The default build of `htm` caches template strings, which means that it can return the same Javascript object at multiple points in the tree. If you don't want this behaviour, you have three options: 159 | 160 | * Change your `h` function to copy nodes when needed. 161 | * Add the code `this[0] = 3;` at the beginning of your `h` function, which disables caching of created elements. 162 | * Use `htm/mini`, which disables caching by default. 163 | 164 | ## Example 165 | 166 | Curious to see what it all looks like? Here's a working app! 167 | 168 | It's a single HTML file, and there's no build or tooling. You can edit it with nano. 169 | 170 | ```html 171 | 172 | 173 | htm Demo 174 | 204 | 205 | ``` 206 | 207 | [⚡️ **See live version** ▶](https://htm-demo-preact.glitch.me/) 208 | 209 | [⚡️ **Try this on CodeSandbox** ▶](https://codesandbox.io/s/x7pmq32j6q) 210 | 211 | How nifty is that? 212 | 213 | Notice there's only one import - here we're using the prebuilt Preact integration since it's easier to import and a bit smaller. 214 | 215 | The same example works fine without the prebuilt version, just using two imports: 216 | 217 | ```js 218 | import { h, Component, render } from 'preact'; 219 | import htm from 'htm'; 220 | 221 | const html = htm.bind(h); 222 | 223 | render(html`<${App} page="All" />`, document.body); 224 | ``` 225 | 226 | ## Other Uses 227 | 228 | Since `htm` is designed to meet the same need as JSX, you can use it anywhere you'd use JSX. 229 | 230 | **Generate HTML using [vhtml]:** 231 | 232 | ```js 233 | import htm from 'htm'; 234 | import vhtml from 'vhtml'; 235 | 236 | const html = htm.bind(vhtml); 237 | 238 | console.log( html`

Hello world!

` ); 239 | // '

Hello world!

' 240 | ``` 241 | 242 | **Webpack configuration via [jsxobj]:** ([details here](https://webpack.js.org/configuration/configuration-languages/#babel-and-jsx)) _(never do this)_ 243 | 244 | ```js 245 | import htm from 'htm'; 246 | import jsxobj from 'jsxobj'; 247 | 248 | const html = htm.bind(jsxobj); 249 | 250 | console.log(html` 251 | 252 | 253 | 254 | `); 255 | // { 256 | // watch: true, 257 | // mode: 'production', 258 | // entry: { 259 | // path: 'src/index.js' 260 | // } 261 | // } 262 | ``` 263 | 264 | ## Demos & Examples 265 | 266 | - [Canadian Holidays](https://github.com/pcraig3/hols): A full app using HTM and Server-Side Rendering 267 | - [HTM SSR Example](https://github.com/timarney/htm-ssr-demo): Shows how to do SSR with HTM 268 | - [HTM + Preact SSR Demo](https://gist.github.com/developit/699c8d8f180a1e4eed58167f9c6711be) 269 | - [HTM + vhtml SSR Demo](https://gist.github.com/developit/ff925c3995b4a129b6b977bf7cd12ebd) 270 | 271 | ## Project Status 272 | 273 | The original goal for `htm` was to create a wrapper around Preact that felt natural for use untranspiled in the browser. I wanted to use Virtual DOM, but I wanted to eschew build tooling and use ES Modules directly. 274 | 275 | This meant giving up JSX, and the closest alternative was [Tagged Templates]. So, I wrote this library to patch up the differences between the two as much as possible. The technique turns out to be framework-agnostic, so it should work great with any library or renderer that works with JSX. 276 | 277 | `htm` is stable, fast, well-tested and ready for production use. 278 | 279 | [Tagged Templates]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#Tagged_templates 280 | [lit-html]: https://github.com/Polymer/lit-html 281 | [babel-plugin-htm]: https://github.com/developit/htm/tree/master/packages/babel-plugin-htm 282 | [lit-html VSCode extension]: https://marketplace.visualstudio.com/items?itemName=bierner.lit-html 283 | [vim-jsx-pretty plugin]: https://github.com/MaxMEllon/vim-jsx-pretty 284 | [vhtml]: https://github.com/developit/vhtml 285 | [jsxobj]: https://github.com/developit/jsxobj 286 | [hyperscript]: https://github.com/hyperhype/hyperscript 287 | [all modern browsers]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#Browser_compatibility 288 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | node: 'current' 8 | } 9 | } 10 | ] 11 | ] 12 | }; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "htm", 3 | "version": "3.1.1", 4 | "description": "The Tagged Template syntax for Virtual DOM. Only browser-compatible syntax.", 5 | "main": "dist/htm.js", 6 | "umd:main": "dist/htm.umd.js", 7 | "module": "dist/htm.module.js", 8 | "types": "dist/htm.d.ts", 9 | "exports": { 10 | ".": { 11 | "types": "./dist/htm.d.ts", 12 | "browser": "./dist/htm.module.js", 13 | "umd": "./dist/htm.umd.js", 14 | "import": "./dist/htm.mjs", 15 | "require": "./dist/htm.js" 16 | }, 17 | "./": "./", 18 | "./preact": { 19 | "types": "./preact/index.d.ts", 20 | "browser": "./preact/index.module.js", 21 | "umd": "./preact/index.umd.js", 22 | "import": "./preact/index.mjs", 23 | "require": "./preact/index.js" 24 | }, 25 | "./preact/standalone": { 26 | "types": "./preact/index.d.ts", 27 | "browser": "./preact/standalone.module.js", 28 | "umd": "./preact/standalone.umd.js", 29 | "import": "./preact/standalone.mjs", 30 | "require": "./preact/standalone.js" 31 | }, 32 | "./react": { 33 | "types": "./react/index.d.ts", 34 | "browser": "./react/index.module.js", 35 | "umd": "./react/index.umd.js", 36 | "import": "./react/index.mjs", 37 | "require": "./react/index.js" 38 | }, 39 | "./mini": { 40 | "types": "./mini/index.d.ts", 41 | "browser": "./mini/index.module.js", 42 | "umd": "./mini/index.umd.js", 43 | "import": "./mini/index.mjs", 44 | "require": "./mini/index.js" 45 | } 46 | }, 47 | "scripts": { 48 | "build": "npm run -s build:main && npm run -s build:mini && npm run -s build:preact && npm run -s build:react && npm run -s build:babel && npm run -s build:babel-transform-jsx && npm run -s build:mjsalias", 49 | "build:main": "microbundle src/index.mjs -f es,umd --no-sourcemap --target web && microbundle src/cjs.mjs -f iife --no-sourcemap --target web && cp src/index.d.ts dist/htm.d.ts", 50 | "build:mini": "microbundle src/index.mjs -o ./mini/index.js -f es,umd --no-sourcemap --target web --alias ./constants.mjs=./constants-mini.mjs && microbundle src/cjs.mjs -o ./mini/index.js -f iife --no-sourcemap --target web --alias ./constants.mjs=./constants-mini.mjs && cp src/index.d.ts mini", 51 | "build:preact": "cd src/integrations/preact && npm run build", 52 | "build:react": "cd src/integrations/react && npm run build", 53 | "build:babel": "cd packages/babel-plugin-htm && npm run build", 54 | "build:babel-transform-jsx": "cd packages/babel-plugin-transform-jsx-to-htm && npm run build", 55 | "build:mjsalias": "cp dist/htm.module.js dist/htm.mjs && cp mini/index.module.js mini/index.mjs && cp preact/index.module.js preact/index.mjs && cp preact/standalone.module.js preact/standalone.mjs && cp react/index.module.js react/index.mjs", 56 | "test": "eslint src/**/*.mjs test/**/*.mjs --ignore-path .gitignore && npm run build && jest test", 57 | "test:perf": "v8 test/__perftest.mjs", 58 | "test:dist": "npm pack && mv htm*.tgz test/fixtures/esm/htm.tgz && cd test/fixtures/esm && npm install && node index.js", 59 | "release": "npm t && git commit -am \"$npm_package_version\" && git tag $npm_package_version && git push && git push --tags && npm publish" 60 | }, 61 | "files": [ 62 | "dist", 63 | "mini", 64 | "preact", 65 | "react", 66 | "src" 67 | ], 68 | "eslintConfig": { 69 | "extends": "developit", 70 | "rules": { 71 | "prefer-const": 0, 72 | "prefer-spread": 0, 73 | "prefer-rest-params": 0, 74 | "func-style": 0 75 | } 76 | }, 77 | "jest": { 78 | "testURL": "http://localhost", 79 | "testMatch": [ 80 | "**/__tests__/**/*.?(m)js?(x)", 81 | "**/?(*.)(spec|test).?(m)js?(x)" 82 | ], 83 | "transform": { 84 | "\\.m?js$": "babel-jest" 85 | }, 86 | "moduleFileExtensions": [ 87 | "mjs", 88 | "js" 89 | ], 90 | "moduleNameMapper": { 91 | "^babel-plugin-transform-jsx-to-htm$": "/packages/babel-plugin-transform-jsx-to-htm/index.mjs", 92 | "^babel-plugin-htm$": "/packages/babel-plugin-htm/index.mjs", 93 | "^htm$": "/src/index.mjs", 94 | "^htm/preact$": "/src/integrations/preact/index.mjs" 95 | } 96 | }, 97 | "repository": "developit/htm", 98 | "keywords": [ 99 | "Hyperscript Tagged Markup", 100 | "tagged template", 101 | "template literals", 102 | "html", 103 | "htm", 104 | "jsx", 105 | "virtual dom", 106 | "hyperscript" 107 | ], 108 | "author": "Jason Miller ", 109 | "license": "Apache-2.0", 110 | "homepage": "https://github.com/developit/htm", 111 | "devDependencies": { 112 | "@babel/core": "^7.2.2", 113 | "@babel/preset-env": "^7.1.6", 114 | "@types/jest": "^26.0.24", 115 | "babel-jest": "^24.1.0", 116 | "babel-preset-env": "^1.7.0", 117 | "eslint": "^5.2.0", 118 | "eslint-config-developit": "^1.1.1", 119 | "jest": "^24.1.0", 120 | "microbundle": "^0.10.1", 121 | "preact": "^10.2.0", 122 | "react": "^16.8.3" 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /packages/babel-plugin-htm/README.md: -------------------------------------------------------------------------------- 1 | # `babel-plugin-htm` 2 | 3 | A Babel plugin that compiles [htm] syntax to hyperscript, React.createElement, or just plain objects. 4 | 5 | ## Usage 6 | 7 | In your Babel configuration (`.babelrc`, `babel.config.js`, `"babel"` field in package.json, etc), add the plugin: 8 | 9 | ```js 10 | { 11 | "plugins": [ 12 | ["htm", { 13 | "pragma": "React.createElement" 14 | }] 15 | ] 16 | } 17 | ``` 18 | 19 | ```js 20 | // input: 21 | html`
hello ${you}
` 22 | 23 | // output: 24 | React.createElement("div", { id: "foo" }, "hello ", you) 25 | ``` 26 | 27 | ## options 28 | 29 | ### `pragma` 30 | 31 | The target "hyperscript" function to compile elements to (see [Babel docs]). 32 | Defaults to: `"h"`. 33 | 34 | ### `tag=html` 35 | 36 | By default, `babel-plugin-htm` will process all Tagged Templates with a tag function named `html`. To use a different name, use the `tag` option in your Babel configuration: 37 | 38 | ```js 39 | {"plugins":[ 40 | ["babel-plugin-htm", { 41 | "tag": "myCustomHtmlFunction" 42 | }] 43 | ]} 44 | ``` 45 | 46 | ### `import=false` _(experimental)_ 47 | 48 | Auto-import the pragma function, off by default. 49 | 50 | #### `false` (default) 51 | 52 | Don't auto-import anything. 53 | 54 | #### `String` 55 | 56 | Import the `pragma` like `import {} from ''`. 57 | 58 | With Babel config: 59 | ```js 60 | "plugins": [ 61 | ["babel-plugin-htm", { 62 | "tag": "$$html", 63 | "import": "htm/preact" 64 | }] 65 | ] 66 | ``` 67 | 68 | ```js 69 | import { html as $$html } from 'htm/preact'; 70 | 71 | export default $$html`
hello ${you}
` 72 | ``` 73 | 74 | The above will produce files that look like: 75 | 76 | ```js 77 | import { h } from 'preact'; 78 | import { html as $$html } from 'htm/preact'; 79 | 80 | export default h("div", { id: "foo" }, "hello ", you) 81 | ``` 82 | 83 | #### `{module: String, export: String}` 84 | 85 | Import the `pragma` like `import { as } from ''`. 86 | 87 | With Babel config: 88 | ```js 89 | "plugins": [ 90 | ["babel-plugin-htm", { 91 | "pragma": "React.createElement", 92 | "tag": "$$html", 93 | "import": { 94 | // the module to import: 95 | "module": "react", 96 | // a named export to use from that module: 97 | "export": "default" 98 | } 99 | }] 100 | ] 101 | ``` 102 | 103 | ```js 104 | import { html as $$html } from 'htm/react'; 105 | 106 | export default $$html`
hello ${you}
` 107 | ``` 108 | 109 | The above will produce files that look like: 110 | 111 | ```js 112 | import React from 'react'; 113 | import { html as $$html } from 'htm/react'; 114 | 115 | export default React.createElement("div", { id: "foo" }, "hello ", you) 116 | ``` 117 | 118 | ### `useBuiltIns=false` 119 | 120 | `babel-plugin-htm` transforms prop spreads (``) into `Object.assign()` calls. For browser support reasons, Babel's standard `_extends` helper is used by default. To use native `Object.assign` directly, pass `{useBuiltIns:true}`. 121 | 122 | ### `useNativeSpread=false` 123 | 124 | `babel-plugin-htm` transforms prop spreads (``) into `{ ...b, x: 'y' }` object spread syntax. For browser support reasons, Babel's standard `_extends` helper is used by default. To use object spread syntax, pass `{useNativeSpread:true}`. This option takes precedence over the `useBuiltIns` option. 125 | 126 | ### `variableArity=true` 127 | 128 | By default, `babel-plugin-htm` transpiles to the same output as JSX would, which assumes a target function of the form `h(type, props, ...children)`. If, for the purposes of optimization or simplification, you would like all calls to `h()` to be passed exactly 3 arguments, specify `{variableArity:false}` in your Babel config: 129 | 130 | ```js 131 | html`
` // h('div', null, []) 132 | html`
` // h('div', { a: true }, []) 133 | html`
b
` // h('div', null, ['b']) 134 | html`
b
` // h('div', { a: true }, ['b']) 135 | ``` 136 | 137 | ### `pragma=false` _(experimental)_ 138 | 139 | Setting `pragma` to `false` changes the output to be plain objects instead of `h()` function calls: 140 | 141 | ```js 142 | // input: 143 | html`
hello ${you}
` 144 | // output: 145 | { tag:"div", props:{ id: "foo" }, children:["hello ", you] } 146 | ``` 147 | 148 | ### `monomorphic` _(experimental)_ 149 | 150 | Like `pragma=false` but converts all inline text to objects, resulting in the same object shape being used: 151 | 152 | ```js 153 | // input: 154 | html`
hello ${you}
` 155 | // output: 156 | { type: 1, tag:"div", props:{ id: "foo" }, text: null, children:[ 157 | { type: 3, tag: null, props: null, text: "hello ", children: null }, 158 | you 159 | ] } 160 | ``` 161 | 162 | 163 | [htm]: https://github.com/developit/htm 164 | [Babel docs]: https://babeljs.io/docs/en/babel-plugin-transform-react-jsx#pragma 165 | -------------------------------------------------------------------------------- /packages/babel-plugin-htm/index.mjs: -------------------------------------------------------------------------------- 1 | import { build, treeify } from '../../src/build.mjs'; 2 | 3 | /** 4 | * @param {Babel} babel 5 | * @param {object} options 6 | * @param {string} [options.pragma=h] JSX/hyperscript pragma. 7 | * @param {string} [options.tag=html] The tagged template "tag" function name to process. 8 | * @param {string | boolean | object} [options.import=false] Import the tag automatically 9 | * @param {boolean} [options.monomorphic=false] Output monomorphic inline objects instead of using String literals. 10 | * @param {boolean} [options.useBuiltIns=false] Use the native Object.assign instead of trying to polyfill it. 11 | * @param {boolean} [options.useNativeSpread=false] Use the native { ...a, ...b } syntax for prop spreads. 12 | * @param {boolean} [options.variableArity=true] If `false`, always passes exactly 3 arguments to the pragma function. 13 | */ 14 | export default function htmBabelPlugin({ types: t }, options = {}) { 15 | const pragmaString = options.pragma===false ? false : options.pragma || 'h'; 16 | const pragma = pragmaString===false ? false : dottedIdentifier(pragmaString); 17 | const useBuiltIns = options.useBuiltIns; 18 | const useNativeSpread = options.useNativeSpread; 19 | const inlineVNodes = options.monomorphic || pragma===false; 20 | const importDeclaration = pragmaImport(options.import || false); 21 | 22 | function pragmaImport(imp) { 23 | if (pragmaString === false || imp === false) { 24 | return null; 25 | } 26 | const pragmaRoot = t.identifier(pragmaString.split('.')[0]); 27 | const { module, export: export_ } = typeof imp !== 'string' ? imp : { 28 | module: imp, 29 | export: null 30 | }; 31 | 32 | let specifier; 33 | if (export_ === '*') { 34 | specifier = t.importNamespaceSpecifier(pragmaRoot); 35 | } 36 | else if (export_ === 'default') { 37 | specifier = t.importDefaultSpecifier(pragmaRoot); 38 | } 39 | else { 40 | specifier = t.importSpecifier(pragmaRoot, export_ ? t.identifier(export_) : pragmaRoot); 41 | } 42 | return t.importDeclaration([specifier], t.stringLiteral(module)); 43 | } 44 | 45 | function dottedIdentifier(keypath) { 46 | const path = keypath.split('.'); 47 | let out; 48 | for (let i=0; i { 70 | const values = obj[key].map(valueOrNode => 71 | t.isNode(valueOrNode) ? valueOrNode : t.valueToNode(valueOrNode) 72 | ); 73 | 74 | let node = values[0]; 75 | if (values.length > 1 && !t.isStringLiteral(node) && !t.isStringLiteral(values[1])) { 76 | node = t.binaryExpression('+', t.stringLiteral(''), node); 77 | } 78 | values.slice(1).forEach(value => { 79 | node = t.binaryExpression('+', node, value); 80 | }); 81 | 82 | return t.objectProperty(propertyName(key), node); 83 | }); 84 | } 85 | 86 | function stringValue(str) { 87 | if (options.monomorphic) { 88 | return t.objectExpression([ 89 | t.objectProperty(propertyName('type'), t.numericLiteral(3)), 90 | t.objectProperty(propertyName('tag'), t.nullLiteral()), 91 | t.objectProperty(propertyName('props'), t.nullLiteral()), 92 | t.objectProperty(propertyName('children'), t.nullLiteral()), 93 | t.objectProperty(propertyName('text'), t.stringLiteral(str)) 94 | ]); 95 | } 96 | return t.stringLiteral(str); 97 | } 98 | 99 | function createVNode(tag, props, children) { 100 | // Never pass children=[[]]. 101 | if (children.elements.length === 1 && t.isArrayExpression(children.elements[0]) && children.elements[0].elements.length === 0) { 102 | children = children.elements[0]; 103 | } 104 | 105 | if (inlineVNodes) { 106 | return t.objectExpression([ 107 | options.monomorphic && t.objectProperty(propertyName('type'), t.numericLiteral(1)), 108 | t.objectProperty(propertyName('tag'), tag), 109 | t.objectProperty(propertyName('props'), props), 110 | t.objectProperty(propertyName('children'), children), 111 | options.monomorphic && t.objectProperty(propertyName('text'), t.nullLiteral()) 112 | ].filter(Boolean)); 113 | } 114 | 115 | // Passing `{variableArity:false}` always produces `h(tag, props, children)` - where `children` is always an Array. 116 | // Otherwise, the default is `h(tag, props, ...children)`. 117 | if (options.variableArity !== false) { 118 | children = children.elements; 119 | } 120 | 121 | return t.callExpression(pragma, [tag, props].concat(children)); 122 | } 123 | 124 | function spreadNode(args, state) { 125 | if (args.length === 0) { 126 | return t.nullLiteral(); 127 | } 128 | if (args.length > 0 && t.isNode(args[0])) { 129 | args.unshift({}); 130 | } 131 | 132 | // 'Object.assign(x)', can be collapsed to 'x'. 133 | if (args.length === 1) { 134 | return propsNode(args[0]); 135 | } 136 | // 'Object.assign({}, x)', can be collapsed to 'x'. 137 | if (args.length === 2 && !t.isNode(args[0]) && Object.keys(args[0]).length === 0) { 138 | return propsNode(args[1]); 139 | } 140 | 141 | if (useNativeSpread) { 142 | const properties = []; 143 | args.forEach(arg => { 144 | if (t.isNode(arg)) { 145 | properties.push(t.spreadElement(arg)); 146 | } 147 | else { 148 | properties.push(...objectProperties(arg)); 149 | } 150 | }); 151 | return t.objectExpression(properties); 152 | } 153 | 154 | const helper = useBuiltIns ? dottedIdentifier('Object.assign') : state.addHelper('extends'); 155 | return t.callExpression(helper, args.map(propsNode)); 156 | } 157 | 158 | function propsNode(props) { 159 | return t.isNode(props) ? props : t.objectExpression(objectProperties(props)); 160 | } 161 | 162 | function transform(node, state) { 163 | if (t.isNode(node)) return node; 164 | if (typeof node === 'string') return stringValue(node); 165 | if (typeof node === 'undefined') return t.identifier('undefined'); 166 | 167 | const { tag, props, children } = node; 168 | const newTag = typeof tag === 'string' ? t.stringLiteral(tag) : tag; 169 | const newProps = spreadNode(props, state); 170 | const newChildren = t.arrayExpression(children.map(child => transform(child, state))); 171 | return createVNode(newTag, newProps, newChildren); 172 | } 173 | 174 | // The tagged template tag function name we're looking for. 175 | // This is static because it's generally assigned via htm.bind(h), 176 | // which could be imported from elsewhere, making tracking impossible. 177 | const htmlName = options.tag || 'html'; 178 | return { 179 | name: 'htm', 180 | visitor: { 181 | Program: { 182 | exit(path, state) { 183 | if (state.get('hasHtm') && importDeclaration) { 184 | path.unshiftContainer('body', importDeclaration); 185 | } 186 | }, 187 | }, 188 | TaggedTemplateExpression(path, state) { 189 | const tag = path.node.tag.name; 190 | if (htmlName[0]==='/' ? patternStringToRegExp(htmlName).test(tag) : tag === htmlName) { 191 | const statics = path.node.quasi.quasis.map(e => e.value.raw); 192 | const expr = path.node.quasi.expressions; 193 | 194 | const tree = treeify(build(statics), expr); 195 | const node = !Array.isArray(tree) 196 | ? transform(tree, state) 197 | : t.arrayExpression(tree.map(root => transform(root, state))); 198 | path.replaceWith(node); 199 | state.set('hasHtm', true); 200 | } 201 | } 202 | } 203 | }; 204 | } 205 | -------------------------------------------------------------------------------- /packages/babel-plugin-htm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "babel-plugin-htm", 3 | "version": "3.0.0", 4 | "description": "Babel plugin to compile htm's Tagged Template syntax to hyperscript or inline VNodes.", 5 | "main": "dist/babel-plugin-htm.js", 6 | "module": "dist/babel-plugin-htm.mjs", 7 | "scripts": { 8 | "build": "microbundle index.mjs -f es,cjs --target node --no-compress --no-sourcemap", 9 | "prepare": "npm run build" 10 | }, 11 | "files": [ 12 | "dist" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/developit/htm.git", 17 | "directory": "packages/babel-plugin-htm" 18 | }, 19 | "keywords": [ 20 | "Hyperscript Tagged Markup", 21 | "tagged template", 22 | "template literals", 23 | "html", 24 | "htm", 25 | "jsx", 26 | "virtual dom", 27 | "hyperscript", 28 | "babel", 29 | "babel plugin", 30 | "babel-plugin" 31 | ], 32 | "author": "Jason Miller ", 33 | "license": "Apache-2.0", 34 | "homepage": "https://github.com/developit/htm/tree/master/packages/babel-plugin-htm", 35 | "dependencies": { 36 | "htm": "^3.0.0" 37 | }, 38 | "devDependencies": { 39 | "microbundle": "^0.10.1" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/babel-plugin-transform-jsx-to-htm/README.md: -------------------------------------------------------------------------------- 1 | # babel-plugin-transform-jsx-to-htm 2 | 3 | This plugin converts JSX into Tagged Templates that work with [htm]. 4 | 5 | ```js 6 | // INPUT: 7 | const Foo = () =>

Hello

8 | 9 | // OUTPUT: 10 | const Foo = () => html`

Hello

` 11 | ``` 12 | 13 | ## Installation 14 | 15 | Grab it from npm: 16 | 17 | ```sh 18 | npm i -D babel-plugin-transform-jsx-to-htm 19 | ``` 20 | 21 | ... then add it to your Babel config (eg: `.babelrc`): 22 | 23 | ```js 24 | "plugins": [ 25 | "babel-plugin-transform-jsx-to-htm" 26 | ] 27 | ``` 28 | 29 | ## Options 30 | 31 | The following options are available: 32 | 33 | | Option | Type | Default | Description 34 | |--------|---------|----------|------------ 35 | | `tag` | String | `"html"` | The "tag" function to prefix [Tagged Templates] with. 36 | | `import` | `false`\|String\|Object | `false` | Auto-import a tag function, off by default.
_See [Auto-importing a tag function](#auto-importing-the-tag) for an example._ 37 | 38 | Options are passed to a Babel plugin using a nested Array: 39 | 40 | ```js 41 | "plugins": [ 42 | ["babel-plugin-transform-jsx-to-htm", { 43 | "tag": "$$html" 44 | }] 45 | ] 46 | ``` 47 | 48 | ## Auto-importing the tag 49 | 50 | Want to automatically import `html` into any file that uses JSX? 51 | Just use the `import` option: 52 | 53 | ```js 54 | "plugins": [ 55 | ["babel-plugin-transform-jsx-to-htm", { 56 | "tag": "$$html", 57 | "import": { 58 | // the module to import: 59 | "module": "htm/preact", 60 | // a named export to use from that module: 61 | "export": "html" 62 | } 63 | }] 64 | ] 65 | ``` 66 | 67 | The above will produce files that look like: 68 | 69 | ```js 70 | import { html as $$html } from 'htm/preact'; 71 | 72 | export default $$html`

hello

` 73 | ``` 74 | 75 | ### License 76 | 77 | Apache 2 78 | 79 | [htm]: https://github.com/developit/htm 80 | -------------------------------------------------------------------------------- /packages/babel-plugin-transform-jsx-to-htm/index.mjs: -------------------------------------------------------------------------------- 1 | import jsx from '@babel/plugin-syntax-jsx'; 2 | 3 | /** 4 | * @param {Babel} babel 5 | * @param {object} [options] 6 | * @param {string} [options.tag='html'] The tagged template "tag" function name to produce. 7 | * @param {boolean} [options.terse=false] Use `` for closing component tags 8 | * @param {string | boolean | object} [options.import=false] Import the tag automatically 9 | */ 10 | export default function jsxToHtmBabelPlugin({ types: t }, options = {}) { 11 | const tagString = options.tag || 'html'; 12 | const tag = dottedIdentifier(tagString); 13 | const importDeclaration = tagImport(options.import || false); 14 | const terse = options.terse === true; 15 | 16 | function tagImport(imp) { 17 | if (imp === false) { 18 | return null; 19 | } 20 | const tagRoot = t.identifier(tagString.split('.')[0]); 21 | const { module, export: export_ } = typeof imp !== 'string' ? imp : { 22 | module: imp, 23 | export: null 24 | }; 25 | 26 | let specifier; 27 | if (export_ === '*') { 28 | specifier = t.importNamespaceSpecifier(tagRoot); 29 | } 30 | else if (export_ === 'default') { 31 | specifier = t.importDefaultSpecifier(tagRoot); 32 | } 33 | else { 34 | specifier = t.importSpecifier(tagRoot, export_ ? t.identifier(export_) : tagRoot); 35 | } 36 | return t.importDeclaration([specifier], t.stringLiteral(module)); 37 | } 38 | 39 | function dottedIdentifier(keypath) { 40 | const path = keypath.split('.'); 41 | let out; 42 | for (let i = 0; i < path.length; i++) { 43 | const ident = t.identifier(path[i]); 44 | out = i === 0 ? ident : t.memberExpression(out, ident); 45 | } 46 | return out; 47 | } 48 | 49 | let quasis = []; 50 | let expressions = []; 51 | 52 | function expr(value) { 53 | expressions.push(value); 54 | quasis.push(t.templateElement({ raw: '', cooked: '' })); 55 | } 56 | 57 | function raw(str) { 58 | const last = quasis[quasis.length - 1]; 59 | last.value.raw += str; 60 | last.value.cooked += str; 61 | } 62 | 63 | function escapeText(text) { 64 | if (text.indexOf('<') < 0) { 65 | return raw(text); 66 | } 67 | return expr(t.stringLiteral(text)); 68 | } 69 | 70 | function escapePropValue(node) { 71 | const value = node.value; 72 | 73 | if (value.match(/^.*$/u)) { 74 | if (value.indexOf('"') < 0) { 75 | return raw(`"${value}"`); 76 | } 77 | else if (value.indexOf("'") < 0) { 78 | return raw(`'${value}'`); 79 | } 80 | } 81 | 82 | return expr(t.stringLiteral(node.value)); 83 | } 84 | 85 | const FRAGMENT_EXPR = dottedIdentifier('React.Fragment'); 86 | 87 | function isFragmentName(node) { 88 | return t.isNodesEquivalent(FRAGMENT_EXPR, node); 89 | } 90 | 91 | function isComponentName(node) { 92 | if (t.isJSXNamespacedName(node)) return false; 93 | return !t.isIdentifier(node) || node.name.match(/^[$_A-Z]/); 94 | } 95 | 96 | function getNameExpr(node) { 97 | if (t.isJSXNamespacedName(node)) { 98 | return t.identifier(node.namespace.name + ':' + node.name.name); 99 | } 100 | if (!t.isJSXMemberExpression(node)) { 101 | return t.identifier(node.name); 102 | } 103 | return t.memberExpression( 104 | getNameExpr(node.object), 105 | t.identifier(node.property.name) 106 | ); 107 | } 108 | 109 | function processChildren(node, name, isFragment) { 110 | const children = t.react.buildChildren(node); 111 | if (children && children.length !== 0) { 112 | if (!isFragment) { 113 | raw('>'); 114 | } 115 | for (let i = 0; i < children.length; i++) { 116 | let child = children[i]; 117 | if (t.isStringLiteral(child)) { 118 | escapeText(child.value); 119 | } 120 | else if (t.isJSXElement(child)) { 121 | processNode(child); 122 | } 123 | else { 124 | expr(child); 125 | } 126 | } 127 | 128 | if (!isFragment) { 129 | if (isComponentName(name)) { 130 | if (terse) { 131 | raw(''); 132 | } 133 | else { 134 | raw(''); 137 | } 138 | } 139 | else { 140 | raw(''); 143 | } 144 | } 145 | } 146 | else if (!isFragment) { 147 | raw('/>'); 148 | } 149 | } 150 | 151 | function processNode(node, path, isRoot) { 152 | const open = node.openingElement; 153 | const name = getNameExpr(open.name); 154 | const isFragment = isFragmentName(name); 155 | 156 | if (!isFragment) { 157 | if (isComponentName(name)) { 158 | raw('<'); 159 | expr(name); 160 | } 161 | else { 162 | raw('<'); 163 | raw(name.name); 164 | } 165 | 166 | if (open.attributes) { 167 | for (let i = 0; i < open.attributes.length; i++) { 168 | const attr = open.attributes[i]; 169 | raw(' '); 170 | if (t.isJSXSpreadAttribute(attr)) { 171 | raw('...'); 172 | expr(attr.argument); 173 | continue; 174 | } 175 | const { name, value } = attr; 176 | if (t.isJSXNamespacedName(name)) { 177 | raw(name.namespace.name + ':' + name.name.name); 178 | } 179 | else { 180 | raw(name.name); 181 | } 182 | if (value) { 183 | raw('='); 184 | if (value.expression) { 185 | expr(value.expression); 186 | } 187 | else if (t.isStringLiteral(value)) { 188 | escapePropValue(value); 189 | } 190 | else { 191 | expr(value); 192 | } 193 | } 194 | } 195 | } 196 | } 197 | 198 | processChildren(node, name, isFragment); 199 | 200 | if (isRoot) { 201 | const template = t.templateLiteral(quasis, expressions); 202 | const replacement = t.taggedTemplateExpression(tag, template); 203 | path.replaceWith(replacement); 204 | } 205 | } 206 | 207 | function jsxVisitorHandler(path, state, isFragment) { 208 | let quasisBefore = quasis; 209 | let expressionsBefore = expressions; 210 | 211 | quasis = [t.templateElement({ raw: '', cooked: '' })]; 212 | expressions = []; 213 | 214 | if (isFragment) { 215 | processChildren(path.node, null, true); 216 | const template = t.templateLiteral(quasis, expressions); 217 | const replacement = t.taggedTemplateExpression(tag, template); 218 | path.replaceWith(replacement); 219 | } 220 | else { 221 | processNode(path.node, path, true); 222 | } 223 | 224 | quasis = quasisBefore; 225 | expressions = expressionsBefore; 226 | 227 | state.set('jsxElement', true); 228 | } 229 | 230 | return { 231 | name: 'transform-jsx-to-htm', 232 | inherits: jsx, 233 | visitor: { 234 | Program: { 235 | exit(path, state) { 236 | if (state.get('jsxElement') && importDeclaration) { 237 | path.unshiftContainer('body', importDeclaration); 238 | } 239 | } 240 | }, 241 | 242 | JSXElement(path, state) { 243 | jsxVisitorHandler(path, state, false); 244 | }, 245 | 246 | JSXFragment(path, state) { 247 | jsxVisitorHandler(path, state, true); 248 | } 249 | } 250 | }; 251 | } 252 | -------------------------------------------------------------------------------- /packages/babel-plugin-transform-jsx-to-htm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "babel-plugin-transform-jsx-to-htm", 3 | "version": "2.2.0", 4 | "description": "Babel plugin to compile JSX to Tagged Templates.", 5 | "main": "dist/babel-plugin-transform-jsx-to-htm.js", 6 | "scripts": { 7 | "build": "microbundle index.mjs -f cjs --target node --no-compress --no-sourcemap", 8 | "prepare": "npm run build" 9 | }, 10 | "files": [ 11 | "dist" 12 | ], 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/developit/htm.git", 16 | "directory": "packages/babel-plugin-transform-jsx-to-htm" 17 | }, 18 | "keywords": [ 19 | "tagged template", 20 | "template literals", 21 | "html", 22 | "htm", 23 | "jsx", 24 | "virtual dom", 25 | "hyperscript", 26 | "babel", 27 | "babel plugin", 28 | "babel-plugin" 29 | ], 30 | "author": "Jason Miller ", 31 | "license": "Apache-2.0", 32 | "homepage": "https://github.com/developit/htm/tree/master/packages/babel-plugin-transform-jsx-to-htm", 33 | "dependencies": { 34 | "@babel/plugin-syntax-jsx": "^7.2.0", 35 | "htm": "^3.0.0" 36 | }, 37 | "devDependencies": { 38 | "microbundle": "^0.10.1" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/build.mjs: -------------------------------------------------------------------------------- 1 | import { MINI } from './constants.mjs'; 2 | 3 | const MODE_SLASH = 0; 4 | const MODE_TEXT = 1; 5 | const MODE_WHITESPACE = 2; 6 | const MODE_TAGNAME = 3; 7 | const MODE_COMMENT = 4; 8 | const MODE_PROP_SET = 5; 9 | const MODE_PROP_APPEND = 6; 10 | 11 | const CHILD_APPEND = 0; 12 | const CHILD_RECURSE = 2; 13 | const TAG_SET = 3; 14 | const PROPS_ASSIGN = 4; 15 | const PROP_SET = MODE_PROP_SET; 16 | const PROP_APPEND = MODE_PROP_APPEND; 17 | 18 | // Turn a result of a build(...) call into a tree that is more 19 | // convenient to analyze and transform (e.g. Babel plugins). 20 | // For example: 21 | // treeify( 22 | // build`
<${x} />
`, 23 | // [X, Y, Z] 24 | // ) 25 | // returns: 26 | // { 27 | // tag: 'div', 28 | // props: [ { href: ["1", X] }, Y ], 29 | // children: [ { tag: Z, props: [], children: [] } ] 30 | // } 31 | export const treeify = (built, fields) => { 32 | const _treeify = built => { 33 | let tag = ''; 34 | let currentProps = null; 35 | const props = []; 36 | const children = []; 37 | 38 | for (let i = 1; i < built.length; i++) { 39 | const type = built[i++]; 40 | const value = built[i] ? fields[built[i++]-1] : built[++i]; 41 | 42 | if (type === TAG_SET) { 43 | tag = value; 44 | } 45 | else if (type === PROPS_ASSIGN) { 46 | props.push(value); 47 | currentProps = null; 48 | } 49 | else if (type === PROP_SET) { 50 | if (!currentProps) { 51 | currentProps = Object.create(null); 52 | props.push(currentProps); 53 | } 54 | currentProps[built[++i]] = [value]; 55 | } 56 | else if (type === PROP_APPEND) { 57 | currentProps[built[++i]].push(value); 58 | } 59 | else if (type === CHILD_RECURSE) { 60 | children.push(_treeify(value)); 61 | } 62 | else if (type === CHILD_APPEND) { 63 | children.push(value); 64 | } 65 | } 66 | 67 | return { tag, props, children }; 68 | }; 69 | const { children } = _treeify(built); 70 | return children.length > 1 ? children : children[0]; 71 | }; 72 | 73 | export const evaluate = (h, built, fields, args) => { 74 | let tmp; 75 | 76 | // `build()` used the first element of the operation list as 77 | // temporary workspace. Now that `build()` is done we can use 78 | // that space to track whether the current element is "dynamic" 79 | // (i.e. it or any of its descendants depend on dynamic values). 80 | built[0] = 0; 81 | 82 | for (let i = 1; i < built.length; i++) { 83 | const type = built[i++]; 84 | 85 | // Set `built[0]`'s appropriate bits if this element depends on a dynamic value. 86 | const value = built[i] ? ((built[0] |= type ? 1 : 2), fields[built[i++]]) : built[++i]; 87 | 88 | if (type === TAG_SET) { 89 | args[0] = value; 90 | } 91 | else if (type === PROPS_ASSIGN) { 92 | args[1] = Object.assign(args[1] || {}, value); 93 | } 94 | else if (type === PROP_SET) { 95 | (args[1] = args[1] || {})[built[++i]] = value; 96 | } 97 | else if (type === PROP_APPEND) { 98 | args[1][built[++i]] += (value + ''); 99 | } 100 | else if (type) { // type === CHILD_RECURSE 101 | // Set the operation list (including the staticness bits) as 102 | // `this` for the `h` call. 103 | tmp = h.apply(value, evaluate(h, value, fields, ['', null])); 104 | args.push(tmp); 105 | 106 | if (value[0]) { 107 | // Set the 2nd lowest bit it the child element is dynamic. 108 | built[0] |= 2; 109 | } 110 | else { 111 | // Rewrite the operation list in-place if the child element is static. 112 | // The currently evaluated piece `CHILD_RECURSE, 0, [...]` becomes 113 | // `CHILD_APPEND, 0, tmp`. 114 | // Essentially the operation list gets optimized for potential future 115 | // re-evaluations. 116 | built[i-2] = CHILD_APPEND; 117 | built[i] = tmp; 118 | } 119 | } 120 | else { // type === CHILD_APPEND 121 | args.push(value); 122 | } 123 | } 124 | 125 | return args; 126 | }; 127 | 128 | export const build = function(statics) { 129 | const fields = arguments; 130 | const h = this; 131 | 132 | let mode = MODE_TEXT; 133 | let buffer = ''; 134 | let quote = ''; 135 | let current = [0]; 136 | let char, propName; 137 | 138 | const commit = field => { 139 | if (mode === MODE_TEXT && (field || (buffer = buffer.replace(/^\s*\n\s*|\s*\n\s*$/g,'')))) { 140 | if (MINI) { 141 | current.push(field ? fields[field] : buffer); 142 | } 143 | else { 144 | current.push(CHILD_APPEND, field, buffer); 145 | } 146 | } 147 | else if (mode === MODE_TAGNAME && (field || buffer)) { 148 | if (MINI) { 149 | current[1] = field ? fields[field] : buffer; 150 | } 151 | else { 152 | current.push(TAG_SET, field, buffer); 153 | } 154 | mode = MODE_WHITESPACE; 155 | } 156 | else if (mode === MODE_WHITESPACE && buffer === '...' && field) { 157 | if (MINI) { 158 | current[2] = Object.assign(current[2] || {}, fields[field]); 159 | } 160 | else { 161 | current.push(PROPS_ASSIGN, field, 0); 162 | } 163 | } 164 | else if (mode === MODE_WHITESPACE && buffer && !field) { 165 | if (MINI) { 166 | (current[2] = current[2] || {})[buffer] = true; 167 | } 168 | else { 169 | current.push(PROP_SET, 0, true, buffer); 170 | } 171 | } 172 | else if (mode >= MODE_PROP_SET) { 173 | if (MINI) { 174 | if (mode === MODE_PROP_SET) { 175 | (current[2] = current[2] || {})[propName] = field ? buffer ? (buffer + fields[field]) : fields[field] : buffer; 176 | mode = MODE_PROP_APPEND; 177 | } 178 | else if (field || buffer) { 179 | current[2][propName] += field ? buffer + fields[field] : buffer; 180 | } 181 | } 182 | else { 183 | if (buffer || (!field && mode === MODE_PROP_SET)) { 184 | current.push(mode, 0, buffer, propName); 185 | mode = MODE_PROP_APPEND; 186 | } 187 | if (field) { 188 | current.push(mode, field, 0, propName); 189 | mode = MODE_PROP_APPEND; 190 | } 191 | } 192 | } 193 | 194 | buffer = ''; 195 | }; 196 | 197 | for (let i=0; i' 226 | if (buffer === '--' && char === '>') { 227 | mode = MODE_TEXT; 228 | buffer = ''; 229 | } 230 | else { 231 | buffer = char + buffer[0]; 232 | } 233 | } 234 | else if (quote) { 235 | if (char === quote) { 236 | quote = ''; 237 | } 238 | else { 239 | buffer += char; 240 | } 241 | } 242 | else if (char === '"' || char === "'") { 243 | quote = char; 244 | } 245 | else if (char === '>') { 246 | commit(); 247 | mode = MODE_TEXT; 248 | } 249 | else if (!mode) { 250 | // Ignore everything until the tag ends 251 | } 252 | else if (char === '=') { 253 | mode = MODE_PROP_SET; 254 | propName = buffer; 255 | buffer = ''; 256 | } 257 | else if (char === '/' && (mode < MODE_PROP_SET || statics[i][j+1] === '>')) { 258 | commit(); 259 | if (mode === MODE_TAGNAME) { 260 | current = current[0]; 261 | } 262 | mode = current; 263 | if (MINI) { 264 | (current = current[0]).push(h.apply(null, mode.slice(1))); 265 | } 266 | else { 267 | (current = current[0]).push(CHILD_RECURSE, 0, mode); 268 | } 269 | mode = MODE_SLASH; 270 | } 271 | else if (char === ' ' || char === '\t' || char === '\n' || char === '\r') { 272 | //
273 | commit(); 274 | mode = MODE_WHITESPACE; 275 | } 276 | else { 277 | buffer += char; 278 | } 279 | 280 | if (mode === MODE_TAGNAME && buffer === '!--') { 281 | mode = MODE_COMMENT; 282 | current = current[0]; 283 | } 284 | } 285 | } 286 | commit(); 287 | 288 | if (MINI) { 289 | return current.length > 2 ? current.slice(1) : current[1]; 290 | } 291 | return current; 292 | }; 293 | -------------------------------------------------------------------------------- /src/cjs.mjs: -------------------------------------------------------------------------------- 1 | import htm from './index.mjs'; 2 | if (typeof module != 'undefined') module.exports = htm; 3 | else self.htm = htm; 4 | -------------------------------------------------------------------------------- /src/constants-mini.mjs: -------------------------------------------------------------------------------- 1 | export const MINI = true; 2 | -------------------------------------------------------------------------------- /src/constants.mjs: -------------------------------------------------------------------------------- 1 | export const MINI = false; 2 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | declare const htm: { 2 | bind( 3 | h: (type: any, props: Record, ...children: any[]) => HResult 4 | ): (strings: TemplateStringsArray, ...values: any[]) => HResult | HResult[]; 5 | }; 6 | export default htm; 7 | -------------------------------------------------------------------------------- /src/index.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | 14 | import { MINI } from './constants.mjs'; 15 | import { build, evaluate } from './build.mjs'; 16 | 17 | const CACHES = new Map(); 18 | 19 | const regular = function(statics) { 20 | let tmp = CACHES.get(this); 21 | if (!tmp) { 22 | tmp = new Map(); 23 | CACHES.set(this, tmp); 24 | } 25 | tmp = evaluate(this, tmp.get(statics) || (tmp.set(statics, tmp = build(statics)), tmp), arguments, []); 26 | return tmp.length > 1 ? tmp : tmp[0]; 27 | }; 28 | 29 | export default MINI ? build : regular; 30 | -------------------------------------------------------------------------------- /src/integrations/preact/index.d.ts: -------------------------------------------------------------------------------- 1 | import { h, VNode, Component } from 'preact'; 2 | export * from 'preact/hooks'; 3 | declare function render(tree: VNode, parent: HTMLElement): void; 4 | declare const html: (strings: TemplateStringsArray, ...values: any[]) => VNode; 5 | export { h, html, render, Component }; 6 | -------------------------------------------------------------------------------- /src/integrations/preact/index.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | 14 | import { h, Component, render } from 'preact'; 15 | import htm from 'htm'; 16 | 17 | const html = htm.bind(h); 18 | 19 | export { h, html, render, Component }; 20 | -------------------------------------------------------------------------------- /src/integrations/preact/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "htm_preact", 3 | "module": "index.module.js", 4 | "main": "index.js", 5 | "umd:main": "index.umd.js", 6 | "unpkg": "standalone.js", 7 | "scripts": { 8 | "build": "npm run -s build:main && npm run -s build:standalone && npm run -s build:static", 9 | "build:main": "microbundle index.mjs -o ../../../preact/index.js --external preact,htm --no-sourcemap --target web", 10 | "build:static": "cp index.d.ts package.json ../../../preact/", 11 | "build:standalone": "microbundle standalone.mjs -o ../../../preact/standalone.js -f es,umd --no-sourcemap --target web" 12 | } 13 | } -------------------------------------------------------------------------------- /src/integrations/preact/standalone.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | 14 | import { h, Component, createContext, createRef, render } from 'preact'; 15 | import { useState, useReducer, useEffect, useLayoutEffect, useRef, useImperativeHandle, useMemo, useCallback, useContext, useDebugValue, useErrorBoundary } from 'preact/hooks'; 16 | import htm from '../../index.mjs'; 17 | 18 | const html = htm.bind(h); 19 | 20 | export { h, html, render, Component, createContext, createRef, useState, useReducer, useEffect, useLayoutEffect, useRef, useImperativeHandle, useMemo, useCallback, useContext, useDebugValue, useErrorBoundary }; 21 | -------------------------------------------------------------------------------- /src/integrations/react/index.d.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | declare const html: (strings: TemplateStringsArray, ...values: any[]) => React.ReactElement; 3 | -------------------------------------------------------------------------------- /src/integrations/react/index.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | 14 | import { createElement } from 'react'; 15 | import htm from 'htm'; 16 | export const html = htm.bind(createElement); 17 | -------------------------------------------------------------------------------- /src/integrations/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "htm_react", 3 | "module": "index.module.js", 4 | "main": "index.js", 5 | "umd:main": "index.umd.js", 6 | "unpkg": "index.js", 7 | "scripts": { 8 | "build": "npm run -s build:main && npm run -s build:static", 9 | "build:main": "microbundle index.mjs -o ../../../react/index.js --external react,htm --no-sourcemap --target web", 10 | "build:static": "cp index.d.ts package.json ../../../react/" 11 | } 12 | } -------------------------------------------------------------------------------- /test/__d8.mjs: -------------------------------------------------------------------------------- 1 | /*global globalThis*/ 2 | 3 | const queue = []; 4 | let stack = []; 5 | let index = 0; 6 | 7 | async function process() { 8 | const id = index++; 9 | if (id === queue.length) { 10 | queue.length = index = 0; 11 | return; 12 | } 13 | const [op, name, fn, extra] = queue[id]; 14 | queue[id] = undefined; 15 | await processors[op](name, fn, extra); 16 | await process(); 17 | } 18 | 19 | const processors = { 20 | async describe(name, fn, path) { 21 | stack.push(name); 22 | log('INFO', name); 23 | await fn(); 24 | stack.pop(); 25 | }, 26 | async test(name, fn, path) { 27 | let stackBefore = stack; 28 | stack = path.concat(name); 29 | logBuffer = []; 30 | await new Promise(resolve => { 31 | let calls = 0; 32 | const done = () => { 33 | if (calls++) throw Error(`Callback called multiple times\n\t${name}`); 34 | log('INFO', `✅ ${name}`); 35 | resolve(); 36 | }; 37 | Promise.resolve(done) 38 | .then(fn) 39 | .then(() => calls || done()) 40 | .catch(err => { 41 | log('ERROR', `🚨 ${name}`); 42 | log('ERROR', '\t' + String(err.stack || err.message || err)); 43 | resolve(); 44 | }); 45 | }); 46 | for (let i=0; i { 97 | push('describe', name, fn, stack.slice()); 98 | }; 99 | 100 | globalThis.test = (name, fn) => { 101 | push('test', name, fn, stack.slice()); 102 | }; 103 | 104 | globalThis.expect = (subject) => new Expect(subject); 105 | 106 | const SUBJECT = Symbol.for('subject'); 107 | const NEGATED = Symbol.for('negated'); 108 | class Expect { 109 | constructor(subject) { 110 | this[SUBJECT] = subject; 111 | } 112 | get not() { 113 | this[NEGATED] = true; 114 | return this; 115 | } 116 | toBeGreaterThan(value) { 117 | const subject = this[SUBJECT]; 118 | const negated = this[NEGATED]; 119 | 120 | const isOver = subject > value; 121 | let msg = `Expected ${subject}${negated?' not':''} to be greater than ${value}`; 122 | if (logBuffer) { 123 | for (let i=logBuffer.length; i-- > -1; ) { 124 | if (i<0 || logBuffer[i][2] === 1) { 125 | logBuffer.splice(i+1, 0, [isOver !== negated ? 'SUCCESS' : 'ERROR', ' ' + msg, 1]); 126 | break; 127 | } 128 | } 129 | } 130 | else { 131 | log(isOver !== negated ? 'SUCCESS' : 'ERROR', ' ' + msg); 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /test/__perftest.mjs: -------------------------------------------------------------------------------- 1 | import './__d8.mjs'; 2 | import './perf.test.mjs'; 3 | -------------------------------------------------------------------------------- /test/babel-transform-jsx.test.mjs: -------------------------------------------------------------------------------- 1 | import { transform } from '@babel/core'; 2 | import transformJsxToHtmPlugin from 'babel-plugin-transform-jsx-to-htm'; 3 | 4 | function compile(code, { plugins = [], ...options } = {}) { 5 | return transform(code, { 6 | babelrc: false, 7 | configFile: false, 8 | sourceType: 'module', 9 | plugins: [ 10 | ...plugins, 11 | [transformJsxToHtmPlugin, options] 12 | ] 13 | }).code; 14 | } 15 | 16 | describe('babel-plugin-transform-jsx-to-htm', () => { 17 | describe('import', () => { 18 | test('import shortcut', () => { 19 | expect( 20 | compile(`(
);`, { import: 'htm/preact' }) 21 | ).toBe('import { html } from "htm/preact";\nhtml`
`;'); 22 | }); 23 | 24 | test('import shortcut, dotted tag', () => { 25 | expect( 26 | compile(`(
);`, { tag: 'html.bound', import: 'htm/preact' }) 27 | ).toBe('import { html } from "htm/preact";\nhtml.bound`
`;'); 28 | }); 29 | 30 | test('named import', () => { 31 | expect( 32 | compile(`(
);`, { import: { module: 'htm/preact', export: '$html' } }) 33 | ).toBe('import { $html as html } from "htm/preact";\nhtml`
`;'); 34 | }); 35 | 36 | test('named import, dotted tag', () => { 37 | expect( 38 | compile(`(
);`, { tag: 'html.bound', import: { module: 'htm/preact', export: '$html' } }) 39 | ).toBe('import { $html as html } from "htm/preact";\nhtml.bound`
`;'); 40 | }); 41 | 42 | test('default import', () => { 43 | expect( 44 | compile(`(
);`, { import: { module: 'htm/preact', export: 'default' } }) 45 | ).toBe('import html from "htm/preact";\nhtml`
`;'); 46 | }); 47 | 48 | test('namespace import', () => { 49 | expect( 50 | compile(`(
);`, { import: { module: 'htm/preact', export: '*' } }) 51 | ).toBe('import * as html from "htm/preact";\nhtml`
`;'); 52 | }); 53 | 54 | test('no import without JSX', () => { 55 | expect( 56 | compile(`false;`, { import: 'htm/preact' }) 57 | ).toBe('false;'); 58 | }); 59 | }); 60 | 61 | describe('elements and text', () => { 62 | test('single named element', () => { 63 | expect( 64 | compile('(
);') 65 | ).toBe('html`
`;'); 66 | 67 | expect( 68 | compile('(
a
);') 69 | ).toBe('html`
a
`;'); 70 | 71 | expect( 72 | compile('();') 73 | ).toBe('html``;'); 74 | 75 | expect( 76 | compile('(a);') 77 | ).toBe('html`a`;'); 78 | 79 | expect( 80 | compile('();') 81 | ).toBe('html``;'); 82 | 83 | expect( 84 | compile('(a);') 85 | ).toBe('html`a`;'); 86 | }); 87 | 88 | test('single component element', () => { 89 | expect( 90 | compile('();') 91 | ).toBe('html`<${Foo}/>`;'); 92 | 93 | expect( 94 | compile('(a);') 95 | ).toBe('html`<${Foo}>a`;'); 96 | 97 | expect( 98 | compile('(<$ />);') 99 | ).toBe('html`<${$}/>`;'); 100 | 101 | expect( 102 | compile('(<$>a);') 103 | ).toBe('html`<${$}>a`;'); 104 | 105 | expect( 106 | compile('(<_ />);') 107 | ).toBe('html`<${_}/>`;'); 108 | 109 | expect( 110 | compile('(<_>a);') 111 | ).toBe('html`<${_}>a`;'); 112 | 113 | expect( 114 | compile('(<_foo />);') 115 | ).toBe('html`<${_foo}/>`;'); 116 | 117 | expect( 118 | compile('(<_foo>a);') 119 | ).toBe('html`<${_foo}>a`;'); 120 | 121 | expect( 122 | compile('(<$foo />);') 123 | ).toBe('html`<${$foo}/>`;'); 124 | 125 | expect( 126 | compile('(<$foo>a);') 127 | ).toBe('html`<${$foo}>a`;'); 128 | }); 129 | 130 | test('dotted component element', () => { 131 | expect( 132 | compile('();') 133 | ).toBe('html`<${a.b.c}/>`;'); 134 | 135 | expect( 136 | compile('(a);') 137 | ).toBe('html`<${a.b.c}>a`;'); 138 | }); 139 | 140 | test('namespaced element names', () => { 141 | expect( 142 | compile('();') 143 | ).toBe('html``;'); 144 | 145 | expect( 146 | compile('();') 147 | ).toBe('html``;'); 148 | }); 149 | 150 | test('namespaced attributes', () => { 151 | expect( 152 | compile('();') 153 | ).toBe('html``;'); 154 | 155 | expect( 156 | compile('(h);') 157 | ).toBe('html`
h`;'); 158 | }); 159 | 160 | test('static text', () => { 161 | expect( 162 | compile(`(
Hello
);`) 163 | ).toBe('html`
Hello
`;'); 164 | expect( 165 | compile(`(
こんにちわ
);`) 166 | ).toBe('html`
こんにちわ
`;'); 167 | }); 168 | 169 | test('HTML entities get unescaped', () => { 170 | expect( 171 | compile(`(
&
);`) 172 | ).toBe('html`
&
`;'); 173 | }); 174 | 175 | test('< gets wrapped into an expression container', () => { 176 | expect( 177 | compile(`(
a<b<<<c
);`) 178 | ).toBe('html`
${"a`;'); 179 | }); 180 | }); 181 | 182 | describe('fragments', () => { 183 | test('React.Fragment', () => { 184 | expect( 185 | compile(`
Foo
Bar
`) 186 | ).toBe('html`
Foo
Bar
`;'); 187 | }); 188 | 189 | test('short syntax', () => { 190 | expect( 191 | compile(`<>
Foo
Bar
`) 192 | ).toBe('html`
Foo
Bar
`;'); 193 | }); 194 | 195 | test('root expressions', () => { 196 | expect( 197 | compile(`{Foo}{Bar}`) 198 | ).toBe('html`${Foo}${Bar}`;'); 199 | }); 200 | 201 | test('short syntax fragments should not crash due to TemplateLiteral quasi/expression unbalance', () => { 202 | expect( 203 | compile(`<>{Bar}`) 204 | ).toBe('html`<${Foo}/>${Bar}`;'); 205 | }); 206 | }); 207 | 208 | describe('props', () => { 209 | test('static values', () => { 210 | expect( 211 | compile('(
);') 212 | ).toBe('html`
`;'); 213 | expect( 214 | compile('(
);') 215 | ).toBe('html`
`;'); 216 | }); 217 | 218 | test('HTML entities get unescaped', () => { 219 | expect( 220 | compile(`(
);`) 221 | ).toBe('html`
`;'); 222 | }); 223 | 224 | test('double quote values with single quotes', () => { 225 | expect( 226 | compile(`(
);`) 227 | ).toBe(`html\`
\`;`); 228 | }); 229 | 230 | test('single quote values with double quotes', () => { 231 | expect( 232 | compile(`(
);`) 233 | ).toBe(`html\`
\`;`); 234 | }); 235 | 236 | test('escape values with newlines as expressions', () => { 237 | expect( 238 | compile(`(
);`) 239 | ).toBe('html`
`;'); 240 | }); 241 | 242 | test('escape values with both single and double quotes as expressions', () => { 243 | expect( 244 | compile(`(
);`) 245 | ).toBe('html`
`;'); 246 | }); 247 | 248 | test('expression values', () => { 249 | expect( 250 | compile('const Foo = (props, a) =>
;') 251 | ).toBe('const Foo = (props, a) => html`
`;'); 252 | }); 253 | 254 | test('spread', () => { 255 | expect( 256 | compile('const Foo = props =>
;') 257 | ).toBe('const Foo = props => html`
`;'); 258 | 259 | expect( 260 | compile('(
);') 261 | ).toBe('html`
`;'); 262 | 263 | expect( 264 | compile('(
);') 265 | ).toBe('html`
`;'); 266 | }); 267 | }); 268 | 269 | describe('nesting', () => { 270 | test('element children are merged into one template', () => { 271 | expect( 272 | compile('const Foo = () =>
\n

Hello

\n

world.

\n
;') 273 | ).toBe('const Foo = () => html`

Hello

world.

`;'); 274 | }); 275 | 276 | test('inter-element whitespace is collapsed similarly to the JSX plugin', () => { 277 | expect( 278 | compile('const Foo = props =>
a \n b \n B c d e
;') 279 | ).toBe('const Foo = props => html`
a b B c d e
`;'); 280 | }); 281 | 282 | test('nested JSX Expressions produce nested templates', () => { 283 | expect( 284 | compile('const Foo = props =>
    {props.items.map(item =>\n
  • \n {item}\n
  • \n)}
;') 285 | ).toBe('const Foo = props => html`
    ${props.items.map(item => html`
  • ${item}
  • `)}
`;'); 286 | }); 287 | 288 | test('empty expressions are ignored', () => { 289 | expect( 290 | compile(`(
{/* a comment */}
);`) 291 | ).toBe('html`
`;'); 292 | }); 293 | }); 294 | }); 295 | -------------------------------------------------------------------------------- /test/babel.test.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import { transform } from '@babel/core'; 6 | import htmBabelPlugin from 'babel-plugin-htm'; 7 | 8 | const options = { 9 | babelrc: false, 10 | configFile: false, 11 | sourceType: 'script', 12 | compact: true 13 | }; 14 | 15 | describe('htm/babel', () => { 16 | test('basic transformation', () => { 17 | expect( 18 | transform('html`
hello
`;', { 19 | ...options, 20 | plugins: [ 21 | htmBabelPlugin 22 | ] 23 | }).code 24 | ).toBe(`h("div",{id:"hello"},"hello");`); 25 | }); 26 | 27 | test('basic transformation with variable', () => { 28 | expect( 29 | transform('var name="world";html`
hello, ${name}
`;', { 30 | ...options, 31 | plugins: [ 32 | htmBabelPlugin 33 | ] 34 | }).code 35 | ).toBe(`var name="world";h("div",{id:"hello"},"hello, ",name);`); 36 | }); 37 | 38 | test('basic nested transformation', () => { 39 | expect( 40 | transform('html`d: ${4}`;', { 41 | ...options, 42 | plugins: [ 43 | [htmBabelPlugin, { 44 | useBuiltIns: true 45 | }] 46 | ] 47 | }).code 48 | ).toBe(`h("a",Object.assign({b:2},{c:3}),"d: ",4);`); 49 | 50 | expect( 51 | transform('html`d: ${4}`;', { 52 | ...options, 53 | plugins: [ 54 | [htmBabelPlugin, { 55 | useNativeSpread: true 56 | }] 57 | ] 58 | }).code 59 | ).toBe(`h("a",{b:2,...{c:3}},"d: ",4);`); 60 | }); 61 | 62 | test('spread a single variable', () => { 63 | expect( 64 | transform('html``;', { 65 | ...options, 66 | plugins: [ 67 | htmBabelPlugin 68 | ] 69 | }).code 70 | ).toBe(`h("a",foo);`); 71 | 72 | expect( 73 | transform('html``;', { 74 | ...options, 75 | plugins: [ 76 | [htmBabelPlugin, { 77 | useNativeSpread: true 78 | }] 79 | ] 80 | }).code 81 | ).toBe(`h("a",foo);`); 82 | }); 83 | 84 | test('spread two variables', () => { 85 | expect( 86 | transform('html``;', { 87 | ...options, 88 | plugins: [ 89 | [htmBabelPlugin, { 90 | useBuiltIns: true 91 | }] 92 | ] 93 | }).code 94 | ).toBe(`h("a",Object.assign({},foo,bar));`); 95 | 96 | expect( 97 | transform('html``;', { 98 | ...options, 99 | plugins: [ 100 | [htmBabelPlugin, { 101 | useNativeSpread: true 102 | }] 103 | ] 104 | }).code 105 | ).toBe(`h("a",{...foo,...bar});`); 106 | }); 107 | 108 | test('property followed by a spread', () => { 109 | expect( 110 | transform('html``;', { 111 | ...options, 112 | plugins: [ 113 | [htmBabelPlugin, { 114 | useBuiltIns: true 115 | }] 116 | ] 117 | }).code 118 | ).toBe(`h("a",Object.assign({b:"1"},foo));`); 119 | 120 | expect( 121 | transform('html``;', { 122 | ...options, 123 | plugins: [ 124 | [htmBabelPlugin, { 125 | useNativeSpread: true 126 | }] 127 | ] 128 | }).code 129 | ).toBe(`h("a",{b:"1",...foo});`); 130 | }); 131 | 132 | test('spread followed by a property', () => { 133 | expect( 134 | transform('html``;', { 135 | ...options, 136 | plugins: [ 137 | [htmBabelPlugin, { 138 | useBuiltIns: true 139 | }] 140 | ] 141 | }).code 142 | ).toBe(`h("a",Object.assign({},foo,{b:"1"}));`); 143 | 144 | expect( 145 | transform('html``;', { 146 | ...options, 147 | plugins: [ 148 | [htmBabelPlugin, { 149 | useNativeSpread: true 150 | }] 151 | ] 152 | }).code 153 | ).toBe(`h("a",{...foo,b:"1"});`); 154 | }); 155 | 156 | test('mix-and-match spreads', () => { 157 | expect( 158 | transform('html``;', { 159 | ...options, 160 | plugins: [ 161 | [htmBabelPlugin, { 162 | useBuiltIns: true 163 | }] 164 | ] 165 | }).code 166 | ).toBe(`h("a",Object.assign({b:"1"},foo,{c:2},{d:3}));`); 167 | 168 | expect( 169 | transform('html``;', { 170 | ...options, 171 | plugins: [ 172 | [htmBabelPlugin, { 173 | useNativeSpread: true 174 | }] 175 | ] 176 | }).code 177 | ).toBe(`h("a",{b:"1",...foo,c:2,...{d:3}});`); 178 | }); 179 | 180 | test('mix-and-match dynamic and static values', () => { 181 | expect( 182 | transform('html``;', { 183 | ...options, 184 | plugins: [ 185 | [htmBabelPlugin, { 186 | useBuiltIns: true 187 | }] 188 | ] 189 | }).code 190 | ).toBe(`h("a",{b:"1"+2+3});`); 191 | 192 | expect( 193 | transform('html``;', { 194 | ...options, 195 | plugins: [ 196 | [htmBabelPlugin, { 197 | useNativeSpread: true 198 | }] 199 | ] 200 | }).code 201 | ).toBe(`h("a",{b:"1"+2+3});`); 202 | }); 203 | 204 | test('coerces props to strings when needed', () => { 205 | expect( 206 | transform('html``;', { 207 | ...options, 208 | plugins: [ 209 | [htmBabelPlugin, { 210 | useBuiltIns: true 211 | }] 212 | ] 213 | }).code 214 | ).toBe(`h("a",{b:""+1+2+"3"+4});`); 215 | 216 | expect( 217 | transform('html``;', { 218 | ...options, 219 | plugins: [ 220 | [htmBabelPlugin, { 221 | useNativeSpread: true 222 | }] 223 | ] 224 | }).code 225 | ).toBe(`h("a",{b:""+1+2+"3"+4});`); 226 | }); 227 | 228 | test('coerces props to strings only when needed', () => { 229 | expect( 230 | transform('html``;', { 231 | ...options, 232 | plugins: [ 233 | [htmBabelPlugin, { 234 | useBuiltIns: true 235 | }] 236 | ] 237 | }).code 238 | ).toBe(`h("a",{b:"1"+2+"3"+4});`); 239 | 240 | expect( 241 | transform('html``;', { 242 | ...options, 243 | plugins: [ 244 | [htmBabelPlugin, { 245 | useNativeSpread: true 246 | }] 247 | ] 248 | }).code 249 | ).toBe(`h("a",{b:"1"+2+"3"+4});`); 250 | }); 251 | 252 | describe('{variableArity:false}', () => { 253 | test('should pass no children as an empty Array', () => { 254 | expect( 255 | transform('html`
`;', { 256 | ...options, 257 | plugins: [ 258 | [htmBabelPlugin, { 259 | variableArity: false 260 | }] 261 | ] 262 | }).code 263 | ).toBe(`h("div",null,[]);`); 264 | }); 265 | 266 | test('should pass children as an Array', () => { 267 | expect( 268 | transform('html`
hello
`;', { 269 | ...options, 270 | plugins: [ 271 | [htmBabelPlugin, { 272 | variableArity: false 273 | }] 274 | ] 275 | }).code 276 | ).toBe(`h("div",{id:"hello"},["hello"]);`); 277 | }); 278 | }); 279 | 280 | describe('{pragma:false}', () => { 281 | test('should transform to inline vnodes', () => { 282 | expect( 283 | transform('var name="world",vnode=html`
hello, ${name}
`;', { 284 | ...options, 285 | plugins: [ 286 | [htmBabelPlugin, { 287 | pragma: false 288 | }] 289 | ] 290 | }).code 291 | ).toBe(`var name="world",vnode={tag:"div",props:{id:"hello"},children:["hello, ",name]};`); 292 | }); 293 | }); 294 | 295 | describe('{monomorphic:true}', () => { 296 | test('should transform to monomorphic inline vnodes', () => { 297 | expect( 298 | transform('var name="world",vnode=html`
hello, ${name}
`;', { 299 | ...options, 300 | plugins: [ 301 | [htmBabelPlugin, { 302 | monomorphic: true 303 | }] 304 | ] 305 | }).code 306 | ).toBe(`var name="world",vnode={type:1,tag:"div",props:{id:"hello"},children:[{type:3,tag:null,props:null,children:null,text:"hello, "},name],text:null};`); 307 | }); 308 | }); 309 | 310 | describe('{import:"preact"}', () => { 311 | test('should do nothing when pragma=false', () => { 312 | expect( 313 | transform('var name="world",vnode=html`
hello, ${name}
`;', { 314 | ...options, 315 | plugins: [ 316 | [htmBabelPlugin, { 317 | pragma: false, 318 | import: 'preact' 319 | }] 320 | ] 321 | }).code 322 | ).toBe(`var name="world",vnode={tag:"div",props:{id:"hello"},children:["hello, ",name]};`); 323 | }); 324 | test('should do nothing when tag is not used', () => { 325 | expect( 326 | transform('console.log("hi");', { 327 | ...options, 328 | plugins: [ 329 | [htmBabelPlugin, { 330 | import: 'preact' 331 | }] 332 | ] 333 | }).code 334 | ).toBe(`console.log("hi");`); 335 | }); 336 | test('should add import', () => { 337 | expect( 338 | transform('html`
hello
`;', { 339 | ...options, 340 | plugins: [ 341 | [htmBabelPlugin, { 342 | import: 'preact' 343 | }] 344 | ] 345 | }).code 346 | ).toBe(`import{h}from"preact";h("div",{id:"hello"},"hello");`); 347 | }); 348 | test('should add import for pragma', () => { 349 | expect( 350 | transform('html`
hello
`;', { 351 | ...options, 352 | plugins: [ 353 | [htmBabelPlugin, { 354 | pragma: 'createElement', 355 | import: 'react' 356 | }] 357 | ] 358 | }).code 359 | ).toBe(`import{createElement}from"react";createElement("div",{id:"hello"},"hello");`); 360 | }); 361 | }); 362 | 363 | describe('{import:Object}', () => { 364 | test('should add import', () => { 365 | expect( 366 | transform('html`
hello
`;', { 367 | ...options, 368 | plugins: [ 369 | [htmBabelPlugin, { 370 | import: { 371 | module: 'preact', 372 | export: 'h' 373 | } 374 | }] 375 | ] 376 | }).code 377 | ).toBe(`import{h}from"preact";h("div",{id:"hello"},"hello");`); 378 | }); 379 | test('should add import as pragma', () => { 380 | expect( 381 | transform('html`
hello
`;', { 382 | ...options, 383 | plugins: [ 384 | [htmBabelPlugin, { 385 | pragma: 'hh', 386 | import: { 387 | module: 'preact', 388 | export: 'h' 389 | } 390 | }] 391 | ] 392 | }).code 393 | ).toBe(`import{h as hh}from"preact";hh("div",{id:"hello"},"hello");`); 394 | }); 395 | test('should add import default', () => { 396 | expect( 397 | transform('html`
hello
`;', { 398 | ...options, 399 | plugins: [ 400 | [htmBabelPlugin, { 401 | pragma: 'React.createElement', 402 | import: { 403 | module: 'react', 404 | export: 'default' 405 | } 406 | }] 407 | ] 408 | }).code 409 | ).toBe(`import React from"react";React.createElement("div",{id:"hello"},"hello");`); 410 | }); 411 | test('should add import *', () => { 412 | expect( 413 | transform('html`
hello
`;', { 414 | ...options, 415 | plugins: [ 416 | [htmBabelPlugin, { 417 | pragma: 'Preact.h', 418 | import: { 419 | module: 'preact', 420 | export: '*' 421 | } 422 | }] 423 | ] 424 | }).code 425 | ).toBe(`import*as Preact from"preact";Preact.h("div",{id:"hello"},"hello");`); 426 | }); 427 | }); 428 | 429 | describe('main test suite', () => { 430 | // Run all of the main tests against the Babel plugin: 431 | const mod = require('fs').readFileSync(require('path').resolve(__dirname, 'index.test.mjs'), 'utf8').replace(/\\0/g, '\0'); 432 | const { code } = transform(mod.replace(/^\s*import\s*.+?\s*from\s+(['"]).*?\1[\s;]*$/im, 'const htm = function(){};'), { 433 | ...options, 434 | plugins: [htmBabelPlugin] 435 | }); 436 | eval(code); 437 | }); 438 | }); 439 | -------------------------------------------------------------------------------- /test/fixtures/esm/index.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import htm from 'htm'; 3 | import * as preact from 'htm/preact'; 4 | import * as standalone from 'htm/preact/standalone'; 5 | // TODO: Enable once react distro is ESM compatible. 6 | // import * as react 'htm/react'; 7 | 8 | assert(typeof htm === 'function', 'import htm from "htm"'); 9 | 10 | assert(typeof preact.html === 'function', 'import { html } from "preact"'); 11 | 12 | assert(typeof standalone.html === 'function', 'import { html } from "preact/standalone"'); 13 | 14 | console.log('✅ Dist Tests Passed'); 15 | -------------------------------------------------------------------------------- /test/fixtures/esm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "htm_dist_test", 3 | "type": "module", 4 | "private": true, 5 | "description": "A package to test importing htm as ES modules in Node", 6 | "dependencies": { 7 | "htm": "file:htm.tgz", 8 | "preact": "^10.4.1" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/index.test.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | 14 | import htm from '../src/index.mjs'; 15 | 16 | const h = (tag, props, ...children) => ({ tag, props, children }); 17 | const html = htm.bind(h); 18 | 19 | describe('htm', () => { 20 | test('empty', () => { 21 | expect(html``).toEqual(undefined); 22 | }); 23 | 24 | test('single named elements', () => { 25 | expect(html`
`).toEqual({ tag: 'div', props: null, children: [] }); 26 | expect(html`
`).toEqual({ tag: 'div', props: null, children: [] }); 27 | expect(html``).toEqual({ tag: 'span', props: null, children: [] }); 28 | }); 29 | 30 | test('multiple root elements', () => { 31 | expect(html``).toEqual([ 32 | { tag: 'a', props: null, children: [] }, 33 | { tag: 'b', props: null, children: [] }, 34 | { tag: 'c', props: null, children: [] } 35 | ]); 36 | }); 37 | 38 | test('single dynamic tag name', () => { 39 | expect(html`<${'foo'} />`).toEqual({ tag: 'foo', props: null, children: [] }); 40 | function Foo () {} 41 | expect(html`<${Foo} />`).toEqual({ tag: Foo, props: null, children: [] }); 42 | }); 43 | 44 | test('single boolean prop', () => { 45 | expect(html``).toEqual({ tag: 'a', props: { disabled: true }, children: [] }); 46 | }); 47 | 48 | test('two boolean props', () => { 49 | expect(html``).toEqual({ tag: 'a', props: { invisible: true, disabled: true }, children: [] }); 50 | }); 51 | 52 | test('single prop with empty value', () => { 53 | expect(html``).toEqual({ tag: 'a', props: { href: '' }, children: [] }); 54 | }); 55 | 56 | test('two props with empty values', () => { 57 | expect(html``).toEqual({ tag: 'a', props: { href: '', foo: '' }, children: [] }); 58 | }); 59 | 60 | test('single prop with empty name', () => { 61 | expect(html``).toEqual({ tag: 'a', props: { '': 'foo' }, children: [] }); 62 | }); 63 | 64 | test('single prop with static value', () => { 65 | expect(html``).toEqual({ tag: 'a', props: { href: '/hello' }, children: [] }); 66 | }); 67 | 68 | test('single prop with static value followed by a single boolean prop', () => { 69 | expect(html``).toEqual({ tag: 'a', props: { href: '/hello', b: true }, children: [] }); 70 | }); 71 | 72 | test('two props with static values', () => { 73 | expect(html``).toEqual({ tag: 'a', props: { href: '/hello', target: '_blank' }, children: [] }); 74 | }); 75 | 76 | test('single prop with dynamic value', () => { 77 | expect(html``).toEqual({ tag: 'a', props: { href: 'foo' }, children: [] }); 78 | }); 79 | 80 | test('slash in the middle of tag name or property name self-closes the element', () => { 81 | expect(html``).toEqual({ tag: 'ab', props: null, children: [] }); 82 | expect(html``).toEqual({ tag: 'abba', props: { pr: true }, children: [] }); 83 | }); 84 | 85 | test('slash in a property value does not self-closes the element, unless followed by >', () => { 86 | expect(html``).toEqual({ tag: 'abba', props: { prop: 'val/ue' }, children: [] }); 87 | expect(html``).toEqual({ tag: 'abba', props: { prop: 'value' }, children: [] }); 88 | expect(html``).toEqual({ tag: 'abba', props: { prop: 'value/' }, children: [] }); 89 | }); 90 | 91 | test('two props with dynamic values', () => { 92 | function onClick(e) { } 93 | expect(html``).toEqual({ tag: 'a', props: { href: 'foo', onClick }, children: [] }); 94 | }); 95 | 96 | test('prop with multiple static and dynamic values get concatenated as strings', () => { 97 | expect(html``).toEqual({ tag: 'a', props: { href: 'beforefooafter' }, children: [] }); 98 | expect(html``).toEqual({ tag: 'a', props: { href: '11' }, children: [] }); 99 | expect(html``).toEqual({ tag: 'a', props: { href: '1between1' }, children: [] }); 100 | expect(html``).toEqual({ tag: 'a', props: { href: '/before/foo/after' }, children: [] }); 101 | expect(html``).toEqual({ tag: 'a', props: { href: '/before/foo' }, children: [] }); 102 | }); 103 | 104 | test('spread props', () => { 105 | expect(html``).toEqual({ tag: 'a', props: { foo: 'bar' }, children: [] }); 106 | expect(html``).toEqual({ tag: 'a', props: { b: true, foo: 'bar' }, children: [] }); 107 | expect(html``).toEqual({ tag: 'a', props: { b: true, c: true, foo: 'bar' }, children: [] }); 108 | expect(html``).toEqual({ tag: 'a', props: { b: true, foo: 'bar' }, children: [] }); 109 | expect(html``).toEqual({ tag: 'a', props: { b: '1', foo: 'bar' }, children: [] }); 110 | expect(html``).toEqual(h('a', { x: '1' }, h('b', { y: '2', c: 'bar' }) )); 111 | expect(html`d: ${4}`).toEqual(h('a',{ b: 2, c: 3 }, 'd: ', 4)); 112 | expect(html``).toEqual(h('a', { c: 'bar' }, h('b', { d: 'baz' }) )); 113 | }); 114 | 115 | test('multiple spread props in one element', () => { 116 | expect(html``).toEqual({ tag: 'a', props: { foo: 'bar', quux: 'baz' }, children: [] }); 117 | }); 118 | 119 | test('mixed spread + static props', () => { 120 | expect(html``).toEqual({ tag: 'a', props: { b: true, foo: 'bar' }, children: [] }); 121 | expect(html``).toEqual({ tag: 'a', props: { b: true, c: true, foo: 'bar' }, children: [] }); 122 | expect(html``).toEqual({ tag: 'a', props: { b: true, foo: 'bar' }, children: [] }); 123 | expect(html``).toEqual({ tag: 'a', props: { b: true, c: true, foo: 'bar' }, children: [] }); 124 | }); 125 | 126 | test('closing tag', () => { 127 | expect(html``).toEqual({ tag: 'a', props: null, children: [] }); 128 | expect(html``).toEqual({ tag: 'a', props: { b: true }, children: [] }); 129 | }); 130 | 131 | test('auto-closing tag', () => { 132 | expect(html``).toEqual({ tag: 'a', props: null, children: [] }); 133 | }); 134 | 135 | test('non-element roots', () => { 136 | expect(html`foo`).toEqual('foo'); 137 | expect(html`${1}`).toEqual(1); 138 | expect(html`foo${1}`).toEqual(['foo', 1]); 139 | expect(html`foo${1}bar`).toEqual(['foo', 1, 'bar']); 140 | }); 141 | 142 | test('text child', () => { 143 | expect(html`foo`).toEqual({ tag: 'a', props: null, children: ['foo'] }); 144 | expect(html`foo bar`).toEqual({ tag: 'a', props: null, children: ['foo bar'] }); 145 | expect(html`foo "`).toEqual({ tag: 'a', props: null, children: ['foo "', { tag: 'b', props: null, children: [] }] }); 146 | }); 147 | 148 | test('dynamic child', () => { 149 | expect(html`${'foo'}`).toEqual({ tag: 'a', props: null, children: ['foo'] }); 150 | }); 151 | 152 | test('mixed text + dynamic children', () => { 153 | expect(html`${'foo'}bar`).toEqual({ tag: 'a', props: null, children: ['foo', 'bar'] }); 154 | expect(html`before${'foo'}after`).toEqual({ tag: 'a', props: null, children: ['before', 'foo', 'after'] }); 155 | expect(html`foo${null}`).toEqual({ tag: 'a', props: null, children: ['foo', null] }); 156 | }); 157 | 158 | test('element child', () => { 159 | expect(html``).toEqual(h('a', null, h('b', null))); 160 | }); 161 | 162 | test('multiple element children', () => { 163 | expect(html``).toEqual(h('a', null, h('b', null), h('c', null))); 164 | expect(html``).toEqual(h('a', { x: true }, h('b', { y: true }), h('c', { z: true }))); 165 | expect(html``).toEqual(h('a', { x: '1' }, h('b', { y: '2' }), h('c', { z: '3' }))); 166 | expect(html``).toEqual(h('a', { x: 1 }, h('b', { y: 2 }), h('c', { z: 3 }))); 167 | }); 168 | 169 | test('mixed typed children', () => { 170 | expect(html`foo`).toEqual(h('a', null, 'foo', h('b', null))); 171 | expect(html`bar`).toEqual(h('a', null, h('b', null), 'bar')); 172 | expect(html`beforeafter`).toEqual(h('a', null, 'before', h('b', null), 'after')); 173 | expect(html`beforeafter`).toEqual(h('a', null, 'before', h('b', { x: '1' }), 'after')); 174 | expect(html` 175 | 176 | before 177 | ${'foo'} 178 | 179 | ${'bar'} 180 | after 181 | 182 | `).toEqual(h('a', null, 'before', 'foo', h('b', null), 'bar', 'after')); 183 | }); 184 | 185 | test('hyphens (-) are allowed in attribute names', () => { 186 | expect(html``).toEqual(h('a', { 'b-c': true })); 187 | }); 188 | 189 | test('NUL characters are allowed in attribute values', () => { 190 | expect(html``).toEqual(h('a', { b: '\0' })); 191 | expect(html``).toEqual(h('a', { b: '\0', c: 'foo' })); 192 | }); 193 | 194 | test('NUL characters are allowed in text', () => { 195 | expect(html`\0`).toEqual(h('a', null, '\0')); 196 | expect(html`\0${'foo'}`).toEqual(h('a', null, '\0', 'foo')); 197 | }); 198 | 199 | test('cache key should be unique', () => { 200 | html``; 201 | expect(html``).toEqual(h('a', { b: '\0' })); 202 | expect(html`${''}9aaaaaaaaa${''}`).not.toEqual(html`${''}0${''}aaaaaaaaa${''}`); 203 | expect(html`${''}0${''}aaaaaaaa${''}`).not.toEqual(html`${''}.8aaaaaaaa${''}`); 204 | }); 205 | 206 | test('do not mutate spread variables', () => { 207 | const obj = {}; 208 | html``; 209 | expect(obj).toEqual({}); 210 | }); 211 | 212 | test('ignore HTML comments', () => { 213 | expect(html``).toEqual(h('a', null)); 214 | expect(html``).toEqual(h('a', null)); 215 | expect(html``).toEqual(h('a', null)); 216 | expect(html` Hello, world `).toEqual(h('a', null)); 217 | }); 218 | }); 219 | -------------------------------------------------------------------------------- /test/perf.test.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | 14 | import htm from '../src/index.mjs'; 15 | 16 | const h = (tag, props, ...children) => ({ tag, props, children }); 17 | const html = htm.bind(h); 18 | 19 | describe('performance', () => { 20 | test('creation', () => { 21 | const results = []; 22 | const Foo = ({ name }) => html`
${name}
`; 23 | let count = 0; 24 | function go(count) { 25 | const statics = [ 26 | '\n
\n\t

Hello World

\n\t
    \n\t', 27 | '\n\t
\n\t\n\t<', ' name="foo" />\n\t<', ' name="other">content\n\n
' 28 | ]; 29 | return html( 30 | statics, 31 | `id${count}`, 32 | html`
  • ${'some text #' + count}
  • `, 33 | Foo, Foo 34 | ); 35 | } 36 | let now = performance.now(); 37 | const start = now; 38 | while ((now = performance.now()) < start+1000) { 39 | count++; 40 | if (results.push(String(go(count)))===10) results.length = 0; 41 | } 42 | const elapsed = now - start; 43 | const hz = count / elapsed * 1000; 44 | // eslint-disable-next-line no-console 45 | console.log(`Creation: ${(hz|0).toLocaleString()}/s, average: ${elapsed/count*1000|0}µs`); 46 | expect(elapsed).toBeGreaterThan(999); 47 | expect(hz).toBeGreaterThan(1000); 48 | }); 49 | 50 | test('usage', () => { 51 | const results = []; 52 | const Foo = ({ name }) => html`
    ${name}
    `; 53 | let count = 0; 54 | function go(count) { 55 | return html` 56 |
    57 |

    Hello World

    58 |
      59 | ${html`
    • ${'some text #' + count}
    • `} 60 |
    61 | <${Foo} name="foo" /> 62 | <${Foo} name="other">content 63 |
    64 | `; 65 | } 66 | let now = performance.now(); 67 | const start = now; 68 | while ((now = performance.now()) < start+1000) { 69 | count++; 70 | if (results.push(String(go(count)))===10) results.length = 0; 71 | } 72 | const elapsed = now - start; 73 | const hz = count / elapsed * 1000; 74 | // eslint-disable-next-line no-console 75 | console.log(`Usage: ${(hz|0).toLocaleString()}/s, average: ${elapsed/count*1000|0}µs`); 76 | expect(elapsed).toBeGreaterThan(999); 77 | expect(hz).toBeGreaterThan(100000); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /test/preact.test.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | 14 | import { html, Component, render } from 'htm/preact'; 15 | 16 | const sleep = ms => new Promise(r => setTimeout(r, ms)); 17 | 18 | describe('htm/preact', () => { 19 | const scratch = document.createElement('div'); 20 | document.body.appendChild(scratch); 21 | 22 | class Foo extends Component { 23 | render({ name }, { count = 0 }) { 24 | return html` 25 |
    26 |

    Name: ${name}

    27 |

    Hello world!

    28 | 29 |
    Count: ${count}
    30 | xml-style end tags: 31 | <${Bar} hello /> 32 | explicit end tags: 33 | <${Bar} hello>some children (count=${count}) 34 | implicit end tags: (${''}) 35 | <${Bar} hello>some children (count=${count}) 36 | some text at the end 37 |
    38 | `; 39 | } 40 | } 41 | const Bar = ({ hello, children }) => html` 42 |
    43 | Value of hello: ${hello + ''} 44 | ${children} 45 |
    46 | `; 47 | 48 | const Baz = ({ myCaseSensitiveProp }) => html`
    ${myCaseSensitiveProp}
    `; 49 | 50 | const fullHtml = '

    Name: jason

    Hello world!

    Count: 0
    xml-style end tags:
    Value of hello: true
    explicit end tags:
    Value of hello: truesome children (count=0)
    implicit end tags: (<//>)
    Value of hello: truesome children (count=0)
    some text at the end
    '; 51 | 52 | test('initial render', () => { 53 | render(html`<${Foo} name=jason />`, scratch); 54 | expect(scratch.innerHTML).toBe(fullHtml); 55 | }); 56 | 57 | test('rerenders in-place', () => { 58 | render(html`<${Foo} name=tom />`, scratch); 59 | expect(scratch.innerHTML).toBe(fullHtml.replace('jason', 'tom')); 60 | }); 61 | 62 | test('state update re-renders', async () => { 63 | document.querySelector('button').click(); 64 | document.querySelector('button').click(); 65 | await sleep(1); 66 | expect(scratch.innerHTML).toBe(fullHtml.replace('jason', 'tom').replace(/\b0\b/g, '2')); 67 | }); 68 | 69 | test('preserves case', () => { 70 | scratch.textContent = ''; 71 | render(html`<${Baz} myCaseSensitiveProp="Yay!" />`, scratch); 72 | expect(scratch.innerHTML).toBe('
    Yay!
    '); 73 | }); 74 | 75 | test('object spreads', () => { 76 | scratch.textContent = ''; 77 | 78 | const props = { a: 1, b: 2, c: 3 }; 79 | const other = { d: 4, e: 5, f: 6 }; 80 | 81 | render(html`
    `, scratch); 82 | expect(scratch.innerHTML).toBe(`
    `); 83 | scratch.innerHTML = ''; 84 | 85 | render(html`
    `, scratch); 86 | expect(scratch.innerHTML).toBe(`
    `); 87 | scratch.innerHTML = ''; 88 | 89 | render(html`
    `, scratch); 90 | expect(scratch.innerHTML).toBe(`
    `); 91 | expect(JSON.stringify(props)).toBe(`{"a":1,"b":2,"c":3}`); 92 | scratch.innerHTML = ''; 93 | 94 | render(html`
    `, scratch); 95 | expect(scratch.innerHTML).toBe(`
    `); 96 | scratch.innerHTML = ''; 97 | 98 | render(html`
    `, scratch); 99 | expect(scratch.innerHTML).toBe(`
    `); 100 | scratch.innerHTML = ''; 101 | 102 | }); 103 | 104 | // describe('performance', () => { 105 | // test('creation', () => { 106 | // const results = []; 107 | // const Foo = ({ name }) => html`
    ${name}
    `; 108 | // const statics = [ 109 | // '\n
    \n\t

    Hello World

    \n\t
      \n\t', 110 | // '\n\t
    \n\t\n\t<', ' name="foo" />\n\t<', ' name="other">content\n\n
    ' 111 | // ]; 112 | // let count = 0; 113 | // function go(count) { 114 | // return html( 115 | // statics.concat(['count:', count]), 116 | // `id${count}`, 117 | // html(['
  • ', '
  • '], 'i'+count, 'some text #'+count), 118 | // Foo, Foo, Foo 119 | // ); 120 | // } 121 | // let now = performance.now(); 122 | // const start = now; 123 | // while ((now = performance.now()) < start+2000) { 124 | // count++; 125 | // if (results.push(String(go(count)))===10) results.length = 0; 126 | // } 127 | // const elapsed = now - start; 128 | // const hz = count / elapsed * 1000; 129 | // // eslint-disable-next-line no-console 130 | // console.log(`Creation: ${hz|0}/s, average: ${elapsed/count.toFixed(4)}ms`); 131 | // expect(elapsed).toBeGreaterThan(999); 132 | // expect(hz).toBeGreaterThan(10); 133 | // }); 134 | // }); 135 | }); 136 | -------------------------------------------------------------------------------- /test/statics-caching.test.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | 14 | import htm from '../src/index.mjs'; 15 | 16 | const h = (tag, props, ...children) => ({ tag, props, children }); 17 | const html = htm.bind(h); 18 | 19 | describe('htm', () => { 20 | test('should cache static subtrees', () => { 21 | const x = () => html`
    a
    `; 22 | const a = x(); 23 | const b = x(); 24 | expect(a).toEqual({ tag: 'div', props: null, children: ['a'] }); 25 | expect(b).toEqual({ tag: 'div', props: null, children: ['a'] }); 26 | expect(a).toBe(b); 27 | }); 28 | 29 | test('should have a different cache for each h', () => { 30 | let tmp = htm.bind(() => 1); 31 | const x = () => tmp`
    a
    `; 32 | const a = x(); 33 | tmp = htm.bind(() => 2); 34 | const b = x(); 35 | 36 | expect(a).toBe(1); 37 | expect(b).toBe(2); 38 | }); 39 | 40 | describe('`this` in the h function', () => { 41 | const html = htm.bind(function() { 42 | return this; 43 | }); 44 | 45 | test('stays the same for each call site)', () => { 46 | const x = () => html`
    a
    `; 47 | const a = x(); 48 | const b = x(); 49 | expect(a).toBe(b); 50 | }); 51 | 52 | test('is different for each call site', () => { 53 | const a = html`
    a
    `; 54 | const b = html`
    a
    `; 55 | expect(a).not.toBe(b); 56 | }); 57 | 58 | test('is specific to each h function', () => { 59 | let tmp = htm.bind(function() { return this; }); 60 | const x = () => tmp`
    a
    `; 61 | const a = x(); 62 | tmp = htm.bind(function() { return this; }); 63 | const b = x(); 64 | expect(a).not.toBe(b); 65 | }); 66 | }); 67 | 68 | describe('`this[0]` in the h function contains the staticness bits', () => { 69 | const html = htm.bind(function() { 70 | return this[0]; 71 | }); 72 | 73 | test('should be 0 for static subtrees', () => { 74 | expect(html`
    `).toBe(0); 75 | expect(html`
    a
    `).toBe(0); 76 | expect(html`
    `).toBe(0); 77 | }); 78 | 79 | test('should be 2 for static nodes with some dynamic children', () => { 80 | expect(html`
    ${'a'}
    `).toBe(2); 81 | expect(html`
    `).toBe(2); 82 | }); 83 | 84 | test('should be 1 for dynamic nodes with all static children', () => { 85 | expect(html`
    `).toBe(1); 86 | }); 87 | 88 | test('should be 3 for dynamic nodes with some dynamic children', () => { 89 | expect(html`
    `).toBe(3); 90 | }); 91 | }); 92 | 93 | describe('the h function should be able to modify `this[0]`', () => { 94 | function wrapH(h) { 95 | return function(type, props, ...children) { 96 | if (type === '@static') { 97 | this[0] &= ~3; 98 | return children; 99 | } 100 | if (props['@static']) { 101 | this[0] &= ~3; 102 | } 103 | return h(type, props, ...children); 104 | }; 105 | } 106 | 107 | test('should be able to force subtrees to be static via a prop', () => { 108 | const html = htm.bind(wrapH(h)); 109 | const x = () => html`
    ${'a'}
    `; 110 | const a = x(); 111 | const b = x(); 112 | expect(a).toEqual({ tag: 'div', props: { '@static': true }, children: ['a'] }); 113 | expect(b).toEqual({ tag: 'div', props: { '@static': true }, children: ['a'] }); 114 | expect(a).toBe(b); 115 | }); 116 | 117 | test('should be able to force subtrees to be static via a special tag', () => { 118 | const html = htm.bind(wrapH(h)); 119 | const x = () => html`<@static>${'a'}`; 120 | const a = x(); 121 | const b = x(); 122 | expect(a).toEqual(['a']); 123 | expect(b).toEqual(['a']); 124 | expect(a).toBe(b); 125 | }); 126 | }); 127 | }); 128 | --------------------------------------------------------------------------------