├── test ├── bench.html ├── index.html ├── perf.js ├── bench.js ├── html.js ├── index.js └── htm.js ├── .travis.yml ├── .gitignore ├── index.d.ts ├── terser.config.json ├── .github └── workflows │ └── tests.js.yml ├── LICENSE ├── package.json ├── index.js ├── index.min.js ├── README.md └── htm.js /test/bench.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '12.0' 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | /preact 4 | /react 5 | dist 6 | mini 7 | yarn.lock 8 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | bind( 3 | h: (type: any, props: Record, ...children: any[]) => HResult 4 | ): (strings: TemplateStringsArray, ...values: any[]) => HResult | HResult[]; 5 | }; 6 | -------------------------------------------------------------------------------- /terser.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "compress": { 3 | "arguments": true, 4 | "pure_getters": true, 5 | "unsafe": true, 6 | "passes": 10, 7 | "module": true 8 | }, 9 | "output": { 10 | "wrap_func_args": false 11 | }, 12 | "warnings": true, 13 | "ecma": 9 14 | } 15 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 13 | 14 | -------------------------------------------------------------------------------- /.github/workflows/tests.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Tests 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [16.x, 18.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'npm' 29 | - run: npm ci 30 | - run: npm test 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Dmitry Yv. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xhtm", 3 | "version": "1.6.3", 4 | "description": "Extensible HTM", 5 | "main": "index.js", 6 | "module": "index.js", 7 | "source": "index.js", 8 | "type": "module", 9 | "scripts": { 10 | "test": "node test/index.js", 11 | "build": "microbundle build --no-sourcemap -f modern --target web && shx mv index.modern.js index.min.js" 12 | }, 13 | "files": [ 14 | "index.js", 15 | "htm.js", 16 | "index.min.js", 17 | "index.d.ts" 18 | ], 19 | "repository": "dy/xhtm", 20 | "keywords": [ 21 | "Hyperscript Tagged Markup", 22 | "tagged template", 23 | "template literals", 24 | "html", 25 | "htm", 26 | "jsx", 27 | "virtual dom", 28 | "hyperscript" 29 | ], 30 | "browserslist": [ 31 | "last 1 Chrome versions" 32 | ], 33 | "author": "Dmitry Yv ", 34 | "license": "MIT", 35 | "homepage": "https://github.com/dy/xhtm", 36 | "devDependencies": { 37 | "htm": "^2.2.1", 38 | "microbundle": "^0.15.1", 39 | "performance-now": "^2.1.0", 40 | "shx": "^0.3.2", 41 | "tst": "^5.3.3", 42 | "vhtml": "^2.2.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import htm from './htm.js' 2 | 3 | // https://github.com/wooorm/html-void-elements/blob/main/index.js 4 | 'area base basefont bgsound br col command embed frame hr image img input keygen link meta param source track wbr ! !doctype ? ?xml'.split(' ').map(v => htm.empty[v] = true) 5 | 6 | // https://html.spec.whatwg.org/multipage/syntax.html#optional-tags 7 | // closed by the corresponding tag or end of parent content 8 | let close = { 9 | 'li': '', 10 | 'dt': 'dd', 11 | 'dd': 'dt', 12 | 'p': 'address article aside blockquote details div dl fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6 header hgroup hr main menu nav ol pre section table', 13 | 'rt': 'rp', 14 | 'rp': 'rt', 15 | 'optgroup': '', 16 | 'option': 'optgroup', 17 | 'caption': 'tbody thead tfoot tr colgroup', 18 | 'colgroup': 'thead tbody tfoot tr caption', 19 | 'thead': 'tbody tfoot caption', 20 | 'tbody': 'tfoot caption', 21 | 'tfoot': 'caption', 22 | 'tr': 'tbody tfoot', 23 | 'td': 'th tr', 24 | 'th': 'td tr tbody', 25 | } 26 | for (let tag in close) { 27 | for (let closer of [...close[tag].split(' '), tag]) 28 | htm.close[tag] = htm.close[tag + closer] = true 29 | } 30 | 31 | 'pre textarea'.split(' ').map(v => htm.pre[v] = true) 32 | 33 | export default htm 34 | -------------------------------------------------------------------------------- /index.min.js: -------------------------------------------------------------------------------- 1 | const t="",e="";function o(o){let a,s,c,n,d=this,h=0,f=[null],g=0,u=[],m=0,b=0,y=!1;const $=(t,o=[],r)=>{let l=0;return(t=r||t!==e?t.replace(/\ue001/g,t=>u[m++]):u[m++].slice(1,-1))?(t.replace(/\ue000/g,(e,r)=>(r&&o.push(t.slice(l,r)),l=r+1,o.push(arguments[++g]))),l1?o:o[0]):t},k=()=>{[f,n,...a]=f,f.push(d(n,...a)),y===b--&&(y=!1)};return o.join(t).replace(//g,"").replace(//g,"").replace(/('|")[^\1]*?\1/g,t=>(u.push(t),e)).replace(/(?:^|>)((?:[^<]|<[^\w\ue000\/?!>])*)(?:$|<)/g,(t,e,o,a)=>{let d,u;if(o&&a.slice(h,o).replace(/(\S)\/$/,"$1 /").split(/\s+/).map((t,e)=>{if("/"===t[0]){if(t=t.slice(1),l[t])return;u=d||t||1}else if(e){if(t){let e=f[2]||(f[2]={});"..."===t.slice(0,3)?Object.assign(e,arguments[++g]):([s,c]=t.split("="),Array.isArray(c=e[$(s)]=!c||$(c))&&(c.toString=c.join.bind(c,"")))}}else{if(d=$(t),"string"==typeof d)for(d=d.toLowerCase();p[f[1]+d];)k();f=[f,d,null],b++,!y&&i[d]&&(y=b),l[d]&&(u=d)}}),u)for(f[0]||r(`Wrong close tag \`${u}\``),k();n!==u&&p[n];)k();h=o+t.length,y||(e=e.replace(/\s*\n\s*/g,"").replace(/\s+/g," ")),e&&$((n=0,e),f,!0)}),f[0]&&p[f[1]]&&k(),b&&r(`Unclosed \`${f[1]}\`.`),f.length<3?f[1]:(f.shift(),f)}const r=t=>{throw SyntaxError(t)},l=o.empty={},p=o.close={},i=o.pre={};"area base br col command embed hr img input keygen link meta param source track wbr ! !doctype ? ?xml".split(" ").map(t=>o.empty[t]=!0);let a={li:"",dt:"dd",dd:"dt",p:"address article aside blockquote details div dl fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6 header hgroup hr main menu nav ol pre section table",rt:"rp",rp:"rt",optgroup:"",option:"optgroup",caption:"tbody thead tfoot tr colgroup",colgroup:"thead tbody tfoot tr caption",thead:"tbody tfoot caption",tbody:"tfoot caption",tfoot:"caption",tr:"tbody tfoot",td:"th tr",th:"td tr tbody"};for(let t in a)for(let e of[...a[t].split(" "),t])o.close[t]=o.close[t+e]=!0;"pre textarea".split(" ").map(t=>o.pre[t]=!0);export{o as default}; 2 | -------------------------------------------------------------------------------- /test/perf.js: -------------------------------------------------------------------------------- 1 | import {html} from './index.js'; 2 | // import htm from '../dist/xhtm.modern.js'; 3 | // import htm from 'htm/mini'; 4 | // import htm from 'htm'; 5 | import t from 'tst' 6 | 7 | t('creation', (t) => { 8 | const results = []; 9 | const Foo = ({ name }) => html`
${name}
`; 10 | let count = 0; 11 | function go(count) { 12 | const statics = [ 13 | '\n
\n\t

Hello World

\n\t
    \n\t', 14 | '\n\t
\n\t\n\t<', ' name="foo" />\n\t<', ' name="other">content\n\n
' 15 | ]; 16 | return html( 17 | statics, 18 | `id${count}`, 19 | html`
  • ${'some text #' + count}
  • `, 20 | Foo, Foo 21 | ); 22 | } 23 | let now = performance.now(); 24 | const start = now; 25 | while ((now = performance.now()) < start+1000) { 26 | count++; 27 | if (results.push(String(go(count)))===10) results.length = 0; 28 | } 29 | const elapsed = now - start; 30 | const hz = count / elapsed * 1000; 31 | // eslint-disable-next-line no-console 32 | console.log(`Creation: ${(hz|0).toLocaleString()}/s, average: ${elapsed/count*1000|0}µs`); 33 | t.ok(elapsed > 999); 34 | t.ok(hz > 1000); 35 | 36 | t.end() 37 | }); 38 | 39 | t.skip('usage', (t) => { 40 | const results = []; 41 | const Foo = ({ name }) => html`
    ${name}
    `; 42 | let count = 0; 43 | function go(count) { 44 | return html` 45 |
    46 |

    Hello World

    47 |
      48 | ${html`
    • ${'some text #' + count}
    • `} 49 |
    50 | <${Foo} name="foo" /> 51 | <${Foo} name="other">content 52 |
    53 | `; 54 | } 55 | let now = performance.now(); 56 | const start = now; 57 | while ((now = performance.now()) < start+1000) { 58 | count++; 59 | if (results.push(String(go(count)))===10) results.length = 0; 60 | } 61 | const elapsed = now - start; 62 | const hz = count / elapsed * 1000; 63 | // eslint-disable-next-line no-console 64 | console.log(`Usage: ${(hz|0).toLocaleString()}/s, average: ${elapsed/count*1000|0}µs`); 65 | t.ok(elapsed > 999); 66 | t.ok(hz > 100000); 67 | 68 | t.end() 69 | }); 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # XHTM − eXtended HTM Tagged Markup 2 | 3 |

    4 | 5 | size 6 | version 7 | stability 8 |

    9 | 10 | _XHTM_ is alternative implementation of [HTM](https://ghub.io/htm) without HTM-specific limitations. 11 | Low-level machinery is rejected in favor of readability and better HTML support. 12 | 13 | ## Differences from HTM 14 | 15 | * Self-closing tags support `` htm`
    ` `` → `[h('input'), h('br')]`. 16 | * HTML directives ``, `` etc. support [#91](https://github.com/developit/htm/issues/91). 17 | * Optionally closed tags support `

    foo

    bar` → `

    foo

    bar

    ` [#91](https://github.com/developit/htm/issues/91). 18 | * Interpolated props are exposed as arrays `` html`` `` → `h('a', { class: ['a ', b, ' c'] })`. 19 | * Calculated tag names [#109](https://github.com/developit/htm/issues/109) support. 20 | * Ignoring null-like arguments (customizable) [#129](https://github.com/developit/htm/issues/129). 21 | * Spaces formatting closer to HTML; elements with preserved formatting ([#23](https://github.com/dy/xhtm/issues/23)). 22 | * No integrations exported, no babel compilers. 23 | * No cache. 24 | 25 | 30 | 31 | ## Installation & Usage 32 | 33 | 34 | [![NPM](https://nodei.co/npm/xhtm.png?mini=true)](https://nodei.co/npm/xhtm/) 35 | 36 | `xhtm` is by default fully compatible with `htm` and can be used as drop-in replacement. 37 | 38 | ```js 39 | import htm from 'xhtm' 40 | import { render, h } from 'preact' 41 | 42 | html = htm.bind(h) 43 | 44 | render(html` 45 |

    Hello World!

    46 |

    Some paragraph
    47 |

    Another paragraph 48 | `, document.getElementById('app')) 49 | ``` 50 | 51 | For `htm` manual, refer to [htm docs](https://ghub.io/htm). 52 | 53 | ## Justification 54 | 55 | Generally HTM is a better choice, since it has better performance and enables caching.
    56 | But if your app doesn't render components frequently or you need HTML support, then XHTM can be a good choice. 57 | Originally that was just minimal HTML parser implementation (~60LOC), best from 10 variants in R&D branches.
    58 | 59 | 72 | 73 |

    🕉️

    74 | -------------------------------------------------------------------------------- /htm.js: -------------------------------------------------------------------------------- 1 | const FIELD = '\ue000', QUOTES = '\ue001' 2 | 3 | export default function htm (statics) { 4 | let h = this, prev = 0, current = [null], field = 0, args, name, value, quotes = [], quote = 0, last, level = 0, pre = false 5 | 6 | const evaluate = (str, parts = [], raw) => { 7 | let i = 0 8 | str = (!raw && str === QUOTES ? 9 | quotes[quote++].slice(1,-1) : 10 | str.replace(/\ue001/g, m => quotes[quote++])) 11 | 12 | if (!str) return str 13 | str.replace(/\ue000/g, (match, idx) => { 14 | if (idx) parts.push(str.slice(i, idx)) 15 | i = idx + 1 16 | return parts.push(arguments[++field]) 17 | }) 18 | if (i < str.length) parts.push(str.slice(i)) 19 | return parts.length > 1 ? parts : parts[0] 20 | } 21 | 22 | // close level 23 | const up = () => { 24 | // console.log('-level', current); 25 | [current, last, ...args] = current 26 | current.push(h(last, ...args)) 27 | if (pre === level--) pre = false // reset
     28 |   }
     29 | 
     30 |   let str = statics
     31 |     .join(FIELD)
     32 |     .replace(//g, '')
     33 |     .replace(//g, '')
     34 |     .replace(/('|")[^\1]*?\1/g, match => (quotes.push(match), QUOTES))
     35 | 
     36 |     // ...>text<... sequence
     37 |   str.replace(/(?:^|>)((?:[^<]|<[^\w\ue000\/?!>])*)(?:$|<)/g, (match, text, idx, str) => {
     38 |     let tag, close
     39 | 
     40 |     if (idx) {
     41 |       str.slice(prev, idx)
     42 |         // 
     43 |         .replace(/(\S)\/$/, '$1 /')
     44 |         .split(/\s+/)
     45 |         .map((part, i) => {
     46 |           // ,  .../>
     47 |           if (part[0] === '/') {
     48 |             part = part.slice(1)
     49 |             // ignore duplicate empty closers 
     50 |             if (EMPTY[part]) return
     51 |             // ignore pairing self-closing tags
     52 |             close = tag || part || 1
     53 |             // skip 
     54 |           }
     55 |           // abc

    def, x 59 | if (typeof tag === 'string') { tag = tag.toLowerCase(); while (CLOSE[current[1]+tag]) up() } 60 | current = [current, tag, null] 61 | level++ 62 | if (!pre && PRE[tag]) pre = level 63 | // console.log('+level', tag) 64 | if (EMPTY[tag]) close = tag 65 | } 66 | // attr=... 67 | else if (part) { 68 | let props = current[2] || (current[2] = {}) 69 | if (part.slice(0, 3) === '...') { 70 | Object.assign(props, arguments[++field]) 71 | } 72 | else { 73 | [name, value] = part.split('='); 74 | Array.isArray(value = props[evaluate(name)] = value ? evaluate(value) : true) && 75 | // if prop value is array - make sure it serializes as string without csv 76 | (value.toString = value.join.bind(value, '')) 77 | } 78 | } 79 | }) 80 | } 81 | 82 | if (close) { 83 | if (!current[0]) err(`Wrong close tag \`${close}\``) 84 | up() 85 | // if last child is optionally closable - close it too 86 | while (last !== close && CLOSE[last]) up() 87 | } 88 | prev = idx + match.length 89 | 90 | // fix text indentation 91 | if (!pre) text = text.replace(/\s*\n\s*/g,'').replace(/\s+/g, ' ') 92 | 93 | if (text) evaluate((last = 0, text), current, true) 94 | }) 95 | 96 | if (current[0] && CLOSE[current[1]]) up() 97 | 98 | if (level) err(`Unclosed \`${current[1]}\`.`) 99 | 100 | return current.length < 3 ? current[1] : (current.shift(), current) 101 | } 102 | 103 | const err = (msg) => { throw SyntaxError(msg) } 104 | 105 | // self-closing elements 106 | const EMPTY = htm.empty = {} 107 | 108 | // optional closing elements 109 | const CLOSE = htm.close = {} 110 | 111 | // preformatted text elements 112 | const PRE = htm.pre = {} 113 | -------------------------------------------------------------------------------- /test/bench.js: -------------------------------------------------------------------------------- 1 | import t from 'tape' 2 | 3 | t('htm', async t => { 4 | const htm = (await import('htm')).default 5 | 6 | const h = (tag, props, ...children) => ({ tag, props, children }); 7 | const html = htm.bind(h); 8 | 9 | creation(html, t) 10 | usage(html, t) 11 | 12 | t.end() 13 | }) 14 | 15 | t('xhtm', async t => { 16 | const htm = (await import('../src/index')).default 17 | 18 | const h = (tag, props, ...children) => ({ tag, props, children }); 19 | const html = htm.bind(h); 20 | 21 | creation(html, t) 22 | usage(html, t) 23 | 24 | t.end() 25 | }) 26 | 27 | t('domtagger', async t => { 28 | const domtagger = (await import('domtagger')).default 29 | 30 | const options = { 31 | type: 'html', 32 | attribute(node, name) { 33 | return value => { 34 | node[name] = value; 35 | } 36 | }, 37 | any(node) { 38 | if (node.nodeType === 8) 39 | node = node.parentNode; 40 | return html => { 41 | node.innerHTML = html; 42 | }; 43 | }, 44 | text(node) { 45 | return textContent => { 46 | node.textContent = textContent; 47 | }; 48 | } 49 | }; 50 | const html = domtagger(options) 51 | 52 | creation(html, t) 53 | usage(html, t) 54 | 55 | t.end() 56 | }) 57 | 58 | t('hyperx', async t => { 59 | const hyperx = (await import('hyperx')) 60 | const h = (tag, props, ...children) => ({ tag, props, children }); 61 | const html = hyperx(h) 62 | 63 | creation(html, t) 64 | usage(html, t) 65 | 66 | t.end() 67 | }) 68 | 69 | t.skip('lit-html', async t => { 70 | const { html } = (await import('lit-html')) 71 | creation(html, t) 72 | usage(html, t) 73 | 74 | t.end() 75 | }) 76 | 77 | t('common-tags', async t => { 78 | const { html } = (await import('common-tags')) 79 | creation(html, t) 80 | usage(html, t) 81 | 82 | t.end() 83 | }) 84 | 85 | t('innerHTML', async t => { 86 | let div = document.createElement('div') 87 | const html = (...args) => { 88 | let real = String.raw(...args) 89 | div.innerHTML = real 90 | return div.innerHTML 91 | } 92 | 93 | creation(html, t) 94 | usage(html, t) 95 | 96 | t.end() 97 | }) 98 | 99 | t('String.raw', async t => { 100 | const html = String.raw 101 | 102 | creation(html, t) 103 | usage(html, t) 104 | 105 | t.end() 106 | }) 107 | 108 | t.skip('html-template-string', async t => { 109 | const { default: html} = await import('html-template-string') 110 | html` 111 |

    112 | Click me! 113 | Element2 114 | Element3 115 |
    ` 116 | creation(html, t) 117 | usage(html, t) 118 | 119 | t.end() 120 | }) 121 | 122 | 123 | function creation (html, t) { 124 | const results = []; 125 | const Foo = ({ name }) => html`
    ${name}
    `; 126 | let count = 0; 127 | function go(count) { 128 | const statics = [ 129 | '\n
    \n\t

    Hello World

    \n\t
      \n\t', 130 | '\n\t
    \n\t\n\t<', ' name="foo" />\n\t<', ' name="other">content\n\n
    ' 131 | ]; 132 | statics.raw = statics 133 | return html( 134 | statics, 135 | `id${count}`, 136 | html`
  • ${'some text #' + count}
  • `, 137 | Foo, Foo 138 | ); 139 | } 140 | let now = performance.now(); 141 | const start = now; 142 | while ((now = performance.now()) < start + 1000) { 143 | count++; 144 | if (results.push(String(go(count))) === 10) results.length = 0; 145 | } 146 | const elapsed = now - start; 147 | const hz = count / elapsed * 1000; 148 | // eslint-disable-next-line no-console 149 | console.log(`Creation: ${(hz | 0).toLocaleString()}/s, average: ${elapsed / count * 1000 | 0}µs`); 150 | // t.ok(elapsed > 999); 151 | // t.ok(hz > 1000); 152 | } 153 | 154 | function usage (html, t) { 155 | const results = []; 156 | const Foo = ({ name }) => html`
    ${name}
    `; 157 | let count = 0; 158 | function go(count) { 159 | return html` 160 |
    161 |

    Hello World

    162 |
      163 | ${html`
    • ${'some text #' + count}
    • `} 164 |
    165 | <${Foo} name="foo" /> 166 | <${Foo} name="other">content 167 |
    168 | `; 169 | } 170 | let now = performance.now(); 171 | const start = now; 172 | while ((now = performance.now()) < start + 1000) { 173 | count++; 174 | if (results.push(String(go(count))) === 10) results.length = 0; 175 | } 176 | const elapsed = now - start; 177 | const hz = count / elapsed * 1000; 178 | // eslint-disable-next-line no-console 179 | console.log(`Usage: ${(hz | 0).toLocaleString()}/s, average: ${elapsed / count * 1000 | 0}µs`); 180 | // t.ok(elapsed > 999); 181 | // t.ok(hz > 100000); 182 | } 183 | -------------------------------------------------------------------------------- /test/html.js: -------------------------------------------------------------------------------- 1 | import t from 'tst' 2 | import { h, html } from './index.js' 3 | 4 | t('html: self-closing tags', t => { 5 | t.is(html``, { tag: 'input', props: null, children: [] }) 6 | t.is(html`


    `, [{ tag: 'input', props: null, children: [] }, { tag: 'p', props: null, children: [{ tag: 'br', props: null, children: [] }]}]) 7 | }) 8 | 9 | t('optional closing tags', t => { 10 | t.is( 11 | html`
    1
    2
    `, 12 | {tag: 'table', props: null, children: [ 13 | {tag: 'tr', props: null, children: [{ tag: 'td', props: null, children: ['1']}]}, 14 | {tag: 'tr', props: null, children: [{ tag: 'td', props: null, children: ['2']}]} 15 | ]}, 16 | 'double close') 17 | t.is( 18 | html`

    a

    b`, 19 | [{tag: 'p', props: null, children: ['a']}, {tag: 'p', props: null, children: ['b']}], 20 | 'Closed by next same tag' 21 | ) 22 | t.is( 23 | html`Hello

    Welcome to this example.`, 24 | [{tag:'title', props: null, children: ['Hello']}, {tag: 'p', props: null, children: ['Welcome to this example.']}], 25 | 'Closed by end of content' 26 | ) 27 | }) 28 | 29 | t.skip('optional closing tags 2', t => { 30 | t.is(html` 31 | 32 | 34 | 35 | 37 |
    37547 TEE Electric Powered Rail Car Train Functions (Abbreviated) 33 |
    Function Control Unit Central Station 36 |
    Headlights ✔ 38 |
    Interior Lights ✔ 39 |
    Electric locomotive operating sounds ✔ 40 |
    Engineer's cab lighting ✔ 41 |
    Station Announcements - Swiss ✔ 42 |
    43 | `, { tag: 'table', props: null, children: [ 44 | { tag: 'caption', props: null, children: ['37547 TEE Electric Powered Rail Car Train Functions (Abbreviated)']}, 45 | { tag: 'colgroup', props: null, children: [ 46 | { tag: 'col', props: null, children: []}, { tag: 'col', props: null, children: []}, { tag: 'col', props: null, children: []} 47 | ]}, 48 | { tag: 'thead', props: null, children: [ 49 | { tag: 'tr', props: null, children: [ 50 | ' ', 51 | { tag: 'th', props: null, children: ['Function '] }, 52 | { tag: 'th', props: null, children: ['Control Unit '] }, 53 | { tag: 'th', props: null, children: ['Central Station '] } 54 | ]} 55 | ]}, 56 | { tag: 'tbody', props: null, children: [ 57 | { tag: 'tr', props: null, children: [ 58 | ' ', 59 | { tag: 'td', props: null, children: ['Headlights '] }, 60 | { tag: 'td', props: null, children: ['✔ '] }, { tag: 'td', props: null, children: ['✔ '] } 61 | ]}, 62 | { tag: 'tr', props: null, children: [ 63 | ' ', 64 | { tag: 'td', props: null, children: ['Interior Lights '] }, 65 | { tag: 'td', props: null, children: ['✔ '] }, { tag: 'td', props: null, children: ['✔ '] } 66 | ]}, 67 | { tag: 'tr', props: null, children: [ 68 | ' ', 69 | { tag: 'td', props: null, children: ['Electric locomotive operating sounds '] }, 70 | { tag: 'td', props: null, children: ['✔ '] }, { tag: 'td', props: null, children: ['✔ '] } 71 | ]}, 72 | { tag: 'tr', props: null, children: [ 73 | ' ', 74 | { tag: 'td', props: null, children: ['Engineer\'s cab lighting '] }, 75 | { tag: 'td', props: null, children: [] }, { tag: 'td', props: null, children: ['✔ '] } 76 | ]}, 77 | { tag: 'tr', props: null, children: [ 78 | ' ', 79 | { tag: 'td', props: null, children: ['Station Announcements - Swiss '] }, 80 | { tag: 'td', props: null, children: [] }, { tag: 'td', props: null, children: ['✔ '] } 81 | ]} 82 | ]} 83 | ]}) 84 | }) 85 | 86 | t('optional closing tags normal', t => { 87 | t.is(html` 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 |
    37547 TEE Electric Powered Rail Car Train Functions (Abbreviated)
    Function Control Unit Central Station
    Headlights
    Interior Lights
    Electric locomotive operating sounds
    Engineer's cab lighting
    Station Announcements - Swiss
    102 | `, { tag: 'table', props: null, children: [ 103 | { tag: 'caption', props: null, children: ['37547 TEE Electric Powered Rail Car Train Functions (Abbreviated) ']}, 104 | { tag: 'colgroup', props: null, children: [ 105 | { tag: 'col', props: null, children: []}, { tag: 'col', props: null, children: []}, { tag: 'col', props: null, children: []} 106 | ]}, 107 | { tag: 'thead', props: null, children: [ 108 | { tag: 'tr', props: null, children: [ 109 | { tag: 'th', props: null, children: ['Function '] }, 110 | { tag: 'th', props: null, children: ['Control Unit '] }, 111 | { tag: 'th', props: null, children: ['Central Station '] } 112 | ]} 113 | ]}, 114 | { tag: 'tbody', props: null, children: [ 115 | { tag: 'tr', props: null, children: [ 116 | { tag: 'td', props: null, children: ['Headlights '] }, 117 | { tag: 'td', props: null, children: ['✔ '] }, { tag: 'td', props: null, children: ['✔ '] } 118 | ]}, 119 | { tag: 'tr', props: null, children: [ 120 | { tag: 'td', props: null, children: ['Interior Lights '] }, 121 | { tag: 'td', props: null, children: ['✔ '] }, { tag: 'td', props: null, children: ['✔ '] } 122 | ]}, 123 | { tag: 'tr', props: null, children: [ 124 | { tag: 'td', props: null, children: ['Electric locomotive operating sounds '] }, 125 | { tag: 'td', props: null, children: ['✔ '] }, { tag: 'td', props: null, children: ['✔ '] } 126 | ]}, 127 | { tag: 'tr', props: null, children: [ 128 | { tag: 'td', props: null, children: ['Engineer\'s cab lighting '] }, 129 | { tag: 'td', props: null, children: [' '] }, { tag: 'td', props: null, children: ['✔ '] } 130 | ]}, 131 | { tag: 'tr', props: null, children: [ 132 | { tag: 'td', props: null, children: ['Station Announcements - Swiss '] }, 133 | { tag: 'td', props: null, children: [' '] }, { tag: 'td', props: null, children: ['✔ '] } 134 | ]} 135 | ]} 136 | ]}) 137 | }) 138 | 139 | t('optional/self-closing readme', t => { 140 | t.is(html` 141 |

    Hello World!

    142 |

    Some paragraph
    143 |

    Another paragraph 144 | `, [ 145 | { tag: 'h1', props: null, children: ['Hello World!']}, 146 | { tag: 'p', props: null, children: ['Some paragraph', { tag: 'br', props: null, children: []}]}, 147 | { tag: 'p', props: null, children: ['Another paragraph']} 148 | ]) 149 | }) 150 | 151 | t('html: tr case', t => { 152 | t.is(html``, {tag: 'tr', props: {colspan: '2'}, children: []}) 153 | }) 154 | 155 | t('html: directives', t => { 156 | t.is(html``, {tag:'?xml', props:{version:'1.0', encoding:'UTF-8', '?': true}, children:[]}) 157 | t.is(html``, {tag: '!doctype', props:{html: true}, children:[]}) 158 | t.is(html``, {tag: '!doctype', props:{html: true}, children:[]}) 159 | // t.is(html``, {tag:, props:{}, children:[]}) 160 | t.is(html``, {tag: '?', props:{'header("Content-Type: text/html; charset= UTF-8");': true, '?': true}, children: []}) 161 | t.is(html`]]>`, undefined) 162 | t.is(html``, undefined) 163 | t.is(html``, undefined) 164 | }) 165 | 166 | t.skip('safer fields', t => { 167 | t.is(html` 168 | 169 | 170 | ${''} 171 |

    ${'

    And it auto-escapes code snippets.

    '}
    172 | `, 173 | [ 174 | { tag: 'script', props: null, children: [ "alert('This is fine')" ] }, 175 | { 176 | tag: 'style', 177 | props: null, 178 | children: [ '.soIsThis { font-size: 3em; }' ] 179 | }, 180 | '<script>alert("But it avoids this injection attempt")</script>', 181 | { 182 | tag: 'pre', 183 | props: null, 184 | children: [ 185 | {tag: 'code', props: null, children: ['<p>And it auto-escapes code snippets.</p>']} 186 | ] 187 | } 188 | ]) 189 | }) 190 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import './htm.js' 2 | import './perf.js' 3 | import './html.js' 4 | 5 | import t from 'tst' 6 | import xhtm from '../index.js' 7 | import htm from 'htm' 8 | 9 | export const h = (tag, props, ...children) => { 10 | if (Array.isArray(tag)) tag = tag.join('') 11 | for (let p in props) Array.isArray(props[p]) && (props[p] = props[p] + '') 12 | return { tag, props, children } 13 | } 14 | // export const html = htm.bind(h) 15 | export const html = xhtm.bind(h) 16 | 17 | t('base case', t => { 18 | t.deepEqual(html` foo
    c${'d'}h `, [ 19 | ' foo ', { tag: 'a', props: { b: true }, children: ['c', 'd', { tag: 'e', props: { f: 'g' }, children: [] }, 'h '] } 20 | ]) 21 | t.end() 22 | }) 23 | 24 | t('plain text', t => { 25 | t.deepEqual(html`a`, `a`) 26 | t.deepEqual(html`a${'b'}c`, ['a', 'b', 'c']) 27 | t.deepEqual(html`a${1}b${2}c`, ['a', 1, 'b', 2, 'c']) 28 | t.deepEqual(html`foo${''}bar${''}`, ['foo', '', 'bar', '']) 29 | t.deepEqual(html`${'foo'}${'bar'}`, ['foo', '', 'bar']) 30 | t.deepEqual(html`${''}${''}`, ['', '', '']) 31 | t.end() 32 | }) 33 | 34 | t('tag cases', t => { 35 | // special case: both self-closing empty tag and ending tag 36 | // t.deepEqual(html``, { tag: '', props: null, children: []}) 37 | // t.deepEqual(html`< />`, { tag: '', props: null, children: [] }) 38 | 39 | t.deepEqual(html`<>`, { tag: '', props: null, children: [] }) 40 | t.deepEqual(html``, { tag: 'a', props: null, children: [] }) 41 | t.deepEqual(html``, { tag: 'a', props: null, children: [] }) 42 | t.deepEqual(html``, { tag: 'abc', props: null, children: [] }) 43 | t.deepEqual(html``, { tag: 'abc', props: null, children: [] }) 44 | t.deepEqual(html``, { tag: 'abc', props: null, children: [] }) 45 | t.deepEqual(html``, { tag: 'abc', props: null, children: [] }) 46 | t.deepEqual(html``, { tag: 'abc', props: null, children: [] }) 47 | t.deepEqual(html`<${'abc'} />`, { tag: 'abc', props: null, children: [] }) 48 | t.deepEqual(html``, { tag: 'abc', props: null, children: [] }) 49 | t.deepEqual(html`<${'ab'}c />`, { tag: 'abc', props: null, children: [] }) 50 | t.deepEqual(html`<${'a'}${'b'}${'c'} />`, { tag: 'abc', props: null, children: [] }) 51 | t.deepEqual(html``, { tag: 'abc', props: { d: true }, children: [] }) 52 | t.deepEqual(html``, { tag: 'abc', props: { d: true }, children: [] }) 53 | t.deepEqual(html``, { tag: 'abc', props: { d: true }, children: [] }) 54 | t.deepEqual(html``, { tag: 'abc', props: { d: true }, children: [] }) 55 | t.deepEqual(html``, { tag: 'abc', props: { d: true }, children: [] }) 56 | t.deepEqual(html``, { tag: 'abc', props: { d: true }, children: [] }) 57 | t.deepEqual(html``, { tag: 'abc', props: { d: true }, children: [] }) 58 | t.deepEqual(html``, { tag: 'abc', props: { d: 'e' }, children: [] }) 59 | t.deepEqual(html``, { tag: 'abc', props: { d: 'e' }, children: [] }) 60 | t.deepEqual(html``, { tag: 'abc', props: { d: 'e' }, children: [] }) 61 | t.deepEqual(html``, { tag: 'abc', props: { d: 'e' }, children: [] }) 62 | t.deepEqual(html``, { tag: 'abc', props: { d: 'e' }, children: [] }) 63 | t.deepEqual(html``, { tag: 'abc', props: { d: 'e' }, children: [] }) 64 | t.deepEqual(html``, { tag: 'abc', props: { d: 'e' }, children: [] }) 65 | t.deepEqual(html``, { tag: 'abc', props: { d: 'e' }, children: [] }) 66 | t.deepEqual(html``, { tag: 'abc', props: { d: 'e' }, children: [] }) 67 | t.deepEqual(html``, { tag: 'abc', props: { d: 'e f' }, children: [] }) 68 | t.deepEqual(html``, { tag: 'abc', props: { d: 'e f' }, children: [] }) 69 | t.end() 70 | }) 71 | 72 | t('quoted cases', t => { 73 | t.deepEqual(html``, { tag: 'abc', props: { d: 'e f', g: ' h ', i: ' > j /> k ' }, children: [] }) 74 | t.deepEqual(html`"def"`, { tag: 'abc', props: null, children: ["\"def\""] }) 75 | t.end() 76 | }) 77 | 78 | t.skip('malformed html', t => { 79 | t.throws(() => html` html`<`) 81 | t.end() 82 | }) 83 | 84 | t('ignore null values', t => { 85 | t.deepEqual( 86 | html`
    `, 87 | { tag: 'div', props: { str: "false " }, children: [] } 88 | ); 89 | 90 | t.end() 91 | }) 92 | 93 | t('after tags', t => { 94 | t.is(html` 1`, [{tag:'x', props: null, children: []}, ' 1']) 95 | t.is(html`${1}`, [{tag:'x', props: null, children: []}, 1]) 96 | t.is(html`1`, ['1', {tag:'x', props: null, children: []}]) 97 | t.is(html`${1}`, [1, {tag:'x', props: null, children: []}]) 98 | t.is(html`${1}${1}`, [1, {tag:'x', props: null, children: []}, 1]) 99 | }) 100 | 101 | 102 | t('indentation & spaces', t => { 103 | t.deepEqual(html` 104 | 105 | before 106 | ${'foo'} 107 | 108 | ${'bar'} 109 | after 110 | 111 | `, h('a', null, 'before', 'foo', h('b', null), 'bar', 'after')); 112 | t.end() 113 | }) 114 | 115 | t('indentation 2', t => { 116 | t.is(html` 117 |

    Hello World!

    118 |

    Some paragraph

    119 |

    Another paragraph

    120 | `, [ 121 | { tag: 'h1', props: null, children: ['Hello World!']}, 122 | { tag: 'p', props: null, children: ['Some paragraph']}, 123 | { tag: 'p', props: null, children: ['Another paragraph']} 124 | ]) 125 | }) 126 | 127 | t('indentation 3', t => { 128 | t.is(html` Headlights `, h('tr', null, ' ', h('td', null, 'Headlights '))) 129 | }) 130 | 131 | 132 | t('#9: Additional comma', t => { 133 | t.equal(html`
  • `.props.src, "https://my.site/section/2") 134 | }) 135 | 136 | t.skip('#8: Zero value', t => { 137 | //NOTE: we don't do it for htm complacency. Hiding zero values is vhtml's issue. 138 | t.equal(html`

    Count:${0}

    `, `

    Count:0

    `) 139 | t.equal(html`

    Count:${null}

    `, `

    Count:

    `) 140 | }) 141 | 142 | t('#10: value.join is not a function', t => { 143 | let applicationIndex = 1 144 | 145 | let el = html` 146 |
    147 | 148 |
    149 | ` 150 | }) 151 | 152 | t('#11: proper spacing', t => { 153 | // t.deepEqual( 154 | // html`

    Hello, world!

    `, 155 | // h('p', null, h('strong', null, 'Hello,'), ' ', h('em', null, 'world!')) 156 | // ) 157 | t.deepEqual( 158 | html`

    Hello, world!

    `, 159 | h('p', null, h('strong', null, 'Hello,'), ' ', h('em', null, 'world!')) 160 | ) 161 | }) 162 | 163 | t('#13: newline tags', t => { 164 | t.deepEqual(html`xx`, h('span', {id: 'status'}, 'xx')) 166 | }) 167 | 168 | t('#14: closing input', t => { 169 | t.deepEqual(html``, h('input', null)) 170 | }) 171 | 172 | t('#15: initial comment', t => { 173 | const markup = html` 174 | 175 |

    This is not rendered

    176 |

    Neither is this.

    177 | 178 |

    However, this is rendered correctly.

    179 | ` 180 | t.deepEqual(markup, [h('h1', null, 'This is not rendered'), h('p', null, 'Neither is this.'), h('h2', null, 'However, this is rendered correctly.')]) 181 | }) 182 | 183 | t('#16: optional closing tags', t => { 184 | const markup = html`
    185 |
    186 | 187 |
    ` 188 | t.throws(() => { 189 | const markup1 = html`
    190 |
    191 | 192 |
    193 |
    ` 194 | }) 195 | t.throws(() => { 196 | const markup2 = html`
    197 |
    198 | 199 |
    200 |
    ` 201 | console.log(markup2) 202 | }) 203 | //TODO 204 | // t.throws(() => { 205 | // const markup3 = html`
    ` 206 | // console.log(markup3) 207 | // }) 208 | }) 209 | 210 | t('#17: sequence of empties', t => { 211 | let markup = html` 212 |
    213 | 214 | 215 |
    ` 216 | }) 217 | 218 | t('#18: unescaped block', t => { 219 | t.throws(() => html`

    123

    456
    789

    `, /close/) 220 | }) 221 | 222 | t('#20: unescaped chars', t => { 223 | t.is(html`

    this < that

    `, {tag:'p',props:null,children:['this < that']}) 224 | }) 225 | 226 | t('#23: pre elements', t => { 227 | t.is( 228 | html` 1 a
     b \n  c   d\n\ne  
    2
    `, 229 | { 230 | tag: 'x', props: null, children: [ 231 | ' 1 ', 232 | {tag:'code',props:null, children:[ 233 | ' a ', 234 | {tag:'pre',props:null, children:[' b \n c ', {tag:'y', props:null, children: [' d\n\ne ']}]}, 235 | ' ' 236 | ]}, 237 | ' 2 ' 238 | ] 239 | } 240 | ) 241 | }) -------------------------------------------------------------------------------- /test/htm.js: -------------------------------------------------------------------------------- 1 | import t from 'tst' 2 | import {h, html} from './index.js' 3 | 4 | 5 | t('empty', (t) => { 6 | t.deepEqual(html``, undefined); 7 | t.end() 8 | }); 9 | 10 | t('single named elements', (t) => { 11 | t.deepEqual(html`
    `, { tag: 'div', props: null, children: [] }); 12 | t.deepEqual(html`
    `, { tag: 'div', props: null, children: [] }); 13 | t.deepEqual(html``, { tag: 'span', props: null, children: [] }); 14 | t.end() 15 | }); 16 | 17 | t('multiple root elements', (t) => { 18 | t.deepEqual(html``, [ 19 | { tag: 'a', props: null, children: [] }, 20 | { tag: 'b', props: null, children: [] }, 21 | { tag: 'c', props: null, children: [] } 22 | ]); 23 | t.end() 24 | }); 25 | 26 | t('single dynamic tag name', (t) => { 27 | t.deepEqual(html`<${'foo'} />`, { tag: 'foo', props: null, children: [] }); 28 | function Foo() { } 29 | t.deepEqual(html`<${Foo} />`, { tag: Foo, props: null, children: [] }); 30 | t.end() 31 | }); 32 | 33 | t('single boolean prop', (t) => { 34 | t.deepEqual(html``, { tag: 'a', props: { disabled: true }, children: [] }); 35 | t.end() 36 | }); 37 | 38 | t('two boolean props', (t) => { 39 | t.deepEqual(html``, { tag: 'a', props: { invisible: true, disabled: true }, children: [] }); 40 | t.end() 41 | }); 42 | 43 | t('single prop with empty value', (t) => { 44 | t.deepEqual(html``, { tag: 'a', props: { href: '' }, children: [] }); 45 | t.end() 46 | }); 47 | 48 | t('two props with empty values', (t) => { 49 | t.deepEqual(html``, { tag: 'a', props: { href: '', foo: '' }, children: [] }); 50 | t.end() 51 | }); 52 | 53 | t.skip('single prop with empty name', (t) => { 54 | t.deepEqual(html``, { tag: 'a', props: { '': 'foo' }, children: [] }); 55 | t.end() 56 | }); 57 | 58 | t('single prop with static value', (t) => { 59 | t.deepEqual(html``, { tag: 'a', props: { href: '/hello' }, children: [] }); 60 | t.end() 61 | }); 62 | 63 | t('single prop with static value followed by a single boolean prop', (t) => { 64 | t.deepEqual(html``, { tag: 'a', props: { href: '/hello', b: true }, children: [] }); 65 | t.end() 66 | }); 67 | 68 | t('two props with static values', (t) => { 69 | t.deepEqual(html``, { tag: 'a', props: { href: '/hello', target: '_blank' }, children: [] }); 70 | t.end() 71 | }); 72 | 73 | t('single prop with dynamic value', (t) => { 74 | t.deepEqual(html``, { tag: 'a', props: { href: 'foo' }, children: [] }); 75 | t.end() 76 | }); 77 | 78 | t('slash in the middle of tag name or property name self-closes the element', (t) => { 79 | // t.deepEqual(html``, { tag: 'ab', props: null, children: [] }); 80 | // t.deepEqual(html``, { tag: 'abba', props: { pr: true }, children: [] }); 81 | t.deepEqual(html``, { tag: 'ab/ba', props: { prop: 'value' }, children: [] }); 82 | t.deepEqual(html``, { tag: 'abba', props: { 'pr/op': 'value' }, children: [] }); 83 | t.end() 84 | }); 85 | 86 | t('slash in a property value does not self-closes the element, unless followed by >', (t) => { 87 | t.deepEqual(html``, { tag: 'abba', props: { prop: 'val/ue' }, children: [] }); 88 | t.deepEqual(html``, { tag: 'abba', props: { prop: 'value' }, children: [] }); 89 | t.deepEqual(html``, { tag: 'abba', props: { prop: 'value/' }, children: [] }); 90 | t.end() 91 | }); 92 | 93 | t('two props with dynamic values', (t) => { 94 | function onClick(e) { } 95 | t.deepEqual(html``, { tag: 'a', props: { href: 'foo', onClick }, children: [] }); 96 | t.end() 97 | }); 98 | 99 | t('prop with multiple static and dynamic values get concatenated as strings', (t) => { 100 | t.deepEqual(html``, { tag: 'a', props: { href: 'beforefooafter' }, children: [] }); 101 | t.deepEqual(html``, { tag: 'a', props: { href: '11' }, children: [] }); 102 | t.deepEqual(html``, { tag: 'a', props: { href: '1between1' }, children: [] }); 103 | t.deepEqual(html``, { tag: 'a', props: { href: '/before/foo/after' }, children: [] }); 104 | t.deepEqual(html``, { tag: 'a', props: { href: '/before/foo' }, children: [] }); 105 | t.end() 106 | }); 107 | 108 | t('spread props', (t) => { 109 | t.deepEqual(html``, { tag: 'a', props: null, children: [] }); 110 | t.deepEqual(html``, { tag: 'a', props: { foo: 'bar' }, children: [] }); 111 | t.deepEqual(html``, { tag: 'a', props: { b: true, foo: 'bar' }, children: [] }); 112 | t.deepEqual(html``, { tag: 'a', props: { b: true, c: true, foo: 'bar' }, children: [] }); 113 | t.deepEqual(html``, { tag: 'a', props: { b: true, foo: 'bar' }, children: [] }); 114 | t.deepEqual(html``, { tag: 'a', props: { b: '1', foo: 'bar' }, children: [] }); 115 | t.deepEqual(html``, h('a', { x: '1' }, h('b', { y: '2', c: 'bar' }))); 116 | t.deepEqual(html`d: ${4}`, h('a', { b: 2, c: 3 }, 'd: ', 4)); 117 | t.deepEqual(html``, h('a', { c: 'bar' }, h('b', { d: 'baz' }))); 118 | t.end() 119 | }); 120 | 121 | t('multiple spread props in one element', (t) => { 122 | t.deepEqual(html``, { tag: 'a', props: { foo: 'bar', quux: 'baz' }, children: [] }); 123 | t.end() 124 | }); 125 | 126 | t('mixed spread + static props', (t) => { 127 | t.deepEqual(html``, { tag: 'a', props: { b: true, foo: 'bar' }, children: [] }); 128 | t.deepEqual(html``, { tag: 'a', props: { b: true, c: true, foo: 'bar' }, children: [] }); 129 | t.deepEqual(html``, { tag: 'a', props: { b: true, foo: 'bar' }, children: [] }); 130 | t.deepEqual(html``, { tag: 'a', props: { b: true, c: true, foo: 'bar' }, children: [] }); 131 | t.end() 132 | }); 133 | 134 | t('closing tag', (t) => { 135 | t.deepEqual(html``, { tag: 'a', props: null, children: [] }); 136 | t.deepEqual(html``, { tag: 'a', props: { b: true }, children: [] }); 137 | t.end() 138 | }); 139 | 140 | t('auto-closing tag', (t) => { 141 | t.deepEqual(html``, { tag: 'a', props: null, children: [] }); 142 | t.end() 143 | }); 144 | 145 | t('non-element roots', (t) => { 146 | t.deepEqual(html`foo`, 'foo'); 147 | t.deepEqual(html`${1}`, 1); 148 | t.deepEqual(html`foo${1}`, ['foo', 1]); 149 | t.deepEqual(html`${1}foo`, [1, 'foo']); 150 | t.deepEqual(html`foo${1}bar`, ['foo', 1, 'bar']); 151 | t.end() 152 | }); 153 | 154 | t('text child', (t) => { 155 | t.deepEqual(html`foo`, { tag: 'a', props: null, children: ['foo'] }); 156 | t.deepEqual(html`foo bar`, { tag: 'a', props: null, children: ['foo bar'] }); 157 | t.deepEqual(html`foo "`, { tag: 'a', props: null, children: ['foo "', { tag: 'b', props: null, children: [] }] }); 158 | t.end() 159 | }); 160 | 161 | t('dynamic child', (t) => { 162 | t.deepEqual(html`${'foo'}`, { tag: 'a', props: null, children: ['foo'] }); 163 | t.end() 164 | }); 165 | 166 | t('mixed text + dynamic children', (t) => { 167 | t.deepEqual(html`${'foo'}bar`, { tag: 'a', props: null, children: ['foo', 'bar'] }); 168 | t.deepEqual(html`before${'foo'}after`, { tag: 'a', props: null, children: ['before', 'foo', 'after'] }); 169 | t.deepEqual(html`foo${null}`, { tag: 'a', props: null, children: ['foo', null] }); 170 | t.deepEqual(html`foo${0}`, { tag: 'a', props: null, children: ['foo', 0] }); 171 | t.end() 172 | }); 173 | 174 | t('element child', (t) => { 175 | t.deepEqual(html``, h('a', null, h('b', null))); 176 | t.end() 177 | }); 178 | 179 | t('multiple element children', (t) => { 180 | t.deepEqual(html``, h('a', null, h('b', null), h('c', null))); 181 | t.deepEqual(html``, h('a', { x: true }, h('b', { y: true }), h('c', { z: true }))); 182 | t.deepEqual(html``, h('a', { x: '1' }, h('b', { y: '2' }), h('c', { z: '3' }))); 183 | t.deepEqual(html``, h('a', { x: 1 }, h('b', { y: 2 }), h('c', { z: 3 }))); 184 | t.end() 185 | }); 186 | 187 | t('mixed typed children', (t) => { 188 | t.deepEqual(html`foo`, h('a', null, 'foo', h('b', null))); 189 | t.deepEqual(html`bar`, h('a', null, h('b', null), 'bar')); 190 | t.deepEqual(html`beforeafter`, h('a', null, 'before', h('b', null), 'after')); 191 | t.deepEqual(html`beforeafter`, h('a', null, 'before', h('b', { x: '1' }), 'after')); 192 | t.end() 193 | }); 194 | 195 | t('hyphens (-) are allowed in attribute names', (t) => { 196 | t.deepEqual(html``, h('a', { 'b-c': true })); 197 | t.end() 198 | }); 199 | 200 | t('NUL characters are allowed in attribute values', (t) => { 201 | t.deepEqual(html``, h('a', { b: '\0' })); 202 | t.deepEqual(html``, h('a', { b: '\0', c: 'foo' })); 203 | t.end() 204 | }); 205 | 206 | t('NUL characters are allowed in text', (t) => { 207 | t.deepEqual(html`\0`, h('a', null, '\0')); 208 | t.deepEqual(html`\0${'foo'}`, h('a', null, '\0', 'foo')); 209 | t.end() 210 | }); 211 | 212 | t('cache key should be unique', (t) => { 213 | html``; 214 | t.deepEqual(html``, h('a', { b: '\0' })); 215 | t.notDeepEqual(html`${''}9aaaaaaaaa${''}`, html`${''}0${''}aaaaaaaaa${''}`); 216 | t.notDeepEqual(html`${''}0${''}aaaaaaaa${''}`, html`${''}.8aaaaaaaa${''}`); 217 | t.end() 218 | }); 219 | 220 | t('do not mutate spread variables', (t) => { 221 | const obj = {}; 222 | html``; 223 | t.deepEqual(obj, {}); 224 | t.end() 225 | }); 226 | 227 | t('ignore HTML comments', (t) => { 228 | t.deepEqual(html``, h('a', null)); 229 | t.deepEqual(html``, h('a', null)); 230 | t.deepEqual(html``, h('a', null)); 231 | t.deepEqual(html` Hello, world `, h('a', null)); 232 | t.end() 233 | }); 234 | --------------------------------------------------------------------------------