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 | [](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 |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 | // abcdef,
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\tHello 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 | 37547 TEE Electric Powered Rail Car Train Functions (Abbreviated)
33 |
34 |
35 | Function Control Unit Central Station
36 |
37 | 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 | 37547 TEE Electric Powered Rail Car Train Functions (Abbreviated)
90 |
91 |
92 | Function Control Unit Central Station
93 |
94 |
95 | Headlights ✔ ✔
96 | Interior Lights ✔ ✔
97 | Electric locomotive operating sounds ✔ ✔
98 | Engineer's cab lighting ✔
99 | Station Announcements - Swiss ✔
100 |
101 |
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` header("Content-Type: text/html; charset= UTF-8"); ?>`, {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 |
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 | `
216 | })
217 |
218 | t('#18: unescaped block', t => {
219 | t.throws(() => html`123
456789`, /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 |
--------------------------------------------------------------------------------