a post-component library for building user-interfaces on the web.

initial ssr support (#43)

authored by tombl.dev and committed by

GitHub 220a92f9 749ec0fb

+403 -8
+12 -8
build.sh
··· 2 2 3 3 mkdir -p dist 4 4 5 - esbuild src/html.js --bundle --minify --format=esm --define:DHTML_PROD=true --mangle-props=^_ --drop:console --drop-labels=DEV | 6 - terser --mangle --compress --module --output dist/html.min.js 7 - printf "min: %d bytes\n" "$(wc -c <dist/html.min.js)" 5 + build() { 6 + esbuild src/$1.$2 --bundle --minify --format=esm --define:DHTML_PROD=true --mangle-props=^_ --drop:console --drop-labels=DEV | 7 + terser --mangle --compress --module --output dist/$1.min.js 8 + printf "min: %d bytes\n" "$(wc -c <dist/$1.min.js)" 8 9 9 - gzip --best <dist/html.min.js >dist/html.min.js.gz 10 - printf "gzip: %d bytes\n" "$(wc -c <dist/html.min.js.gz)" 10 + gzip --best <dist/$1.min.js >dist/$1.min.js.gz 11 + printf "gzip: %d bytes\n" "$(wc -c <dist/$1.min.js.gz)" 11 12 12 - brotli --best <dist/html.min.js >dist/html.min.js.br 13 - printf "brotli: %d bytes\n" "$(wc -c <dist/html.min.js.br)" 14 - 13 + brotli --best <dist/$1.min.js >dist/$1.min.js.br 14 + printf "brotli: %d bytes\n" "$(wc -c <dist/$1.min.js.br)" 15 + } 16 + 17 + build html js 18 + build html.server ts
+106
package-lock.json
··· 13 13 "@vitest/ui": "^3.0.5", 14 14 "dhtml": ".", 15 15 "esbuild": "^0.24.0", 16 + "htmlparser2": "^10.0.0", 16 17 "playwright": "^1.50.1", 17 18 "prettier": "^3.4.2", 18 19 "terser": "^5.37.0", ··· 1726 1727 "dev": true, 1727 1728 "license": "MIT" 1728 1729 }, 1730 + "node_modules/dom-serializer": { 1731 + "version": "2.0.0", 1732 + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", 1733 + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", 1734 + "dev": true, 1735 + "license": "MIT", 1736 + "dependencies": { 1737 + "domelementtype": "^2.3.0", 1738 + "domhandler": "^5.0.2", 1739 + "entities": "^4.2.0" 1740 + }, 1741 + "funding": { 1742 + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" 1743 + } 1744 + }, 1745 + "node_modules/dom-serializer/node_modules/entities": { 1746 + "version": "4.5.0", 1747 + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", 1748 + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", 1749 + "dev": true, 1750 + "license": "BSD-2-Clause", 1751 + "engines": { 1752 + "node": ">=0.12" 1753 + }, 1754 + "funding": { 1755 + "url": "https://github.com/fb55/entities?sponsor=1" 1756 + } 1757 + }, 1758 + "node_modules/domelementtype": { 1759 + "version": "2.3.0", 1760 + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", 1761 + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", 1762 + "dev": true, 1763 + "funding": [ 1764 + { 1765 + "type": "github", 1766 + "url": "https://github.com/sponsors/fb55" 1767 + } 1768 + ], 1769 + "license": "BSD-2-Clause" 1770 + }, 1771 + "node_modules/domhandler": { 1772 + "version": "5.0.3", 1773 + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", 1774 + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", 1775 + "dev": true, 1776 + "license": "BSD-2-Clause", 1777 + "dependencies": { 1778 + "domelementtype": "^2.3.0" 1779 + }, 1780 + "engines": { 1781 + "node": ">= 4" 1782 + }, 1783 + "funding": { 1784 + "url": "https://github.com/fb55/domhandler?sponsor=1" 1785 + } 1786 + }, 1787 + "node_modules/domutils": { 1788 + "version": "3.2.2", 1789 + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", 1790 + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", 1791 + "dev": true, 1792 + "license": "BSD-2-Clause", 1793 + "dependencies": { 1794 + "dom-serializer": "^2.0.0", 1795 + "domelementtype": "^2.3.0", 1796 + "domhandler": "^5.0.3" 1797 + }, 1798 + "funding": { 1799 + "url": "https://github.com/fb55/domutils?sponsor=1" 1800 + } 1801 + }, 1729 1802 "node_modules/eastasianwidth": { 1730 1803 "version": "0.2.0", 1731 1804 "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", ··· 1739 1812 "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", 1740 1813 "dev": true, 1741 1814 "license": "MIT" 1815 + }, 1816 + "node_modules/entities": { 1817 + "version": "6.0.0", 1818 + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", 1819 + "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==", 1820 + "dev": true, 1821 + "license": "BSD-2-Clause", 1822 + "engines": { 1823 + "node": ">=0.12" 1824 + }, 1825 + "funding": { 1826 + "url": "https://github.com/fb55/entities?sponsor=1" 1827 + } 1742 1828 }, 1743 1829 "node_modules/es-module-lexer": { 1744 1830 "version": "1.6.0", ··· 1959 2045 "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", 1960 2046 "dev": true, 1961 2047 "license": "MIT" 2048 + }, 2049 + "node_modules/htmlparser2": { 2050 + "version": "10.0.0", 2051 + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", 2052 + "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", 2053 + "dev": true, 2054 + "funding": [ 2055 + "https://github.com/fb55/htmlparser2?sponsor=1", 2056 + { 2057 + "type": "github", 2058 + "url": "https://github.com/sponsors/fb55" 2059 + } 2060 + ], 2061 + "license": "MIT", 2062 + "dependencies": { 2063 + "domelementtype": "^2.3.0", 2064 + "domhandler": "^5.0.3", 2065 + "domutils": "^3.2.1", 2066 + "entities": "^6.0.0" 2067 + } 1962 2068 }, 1963 2069 "node_modules/is-fullwidth-code-point": { 1964 2070 "version": "3.0.0",
+11
package.json
··· 2 2 "name": "dhtml", 3 3 "type": "module", 4 4 "main": "src/html.js", 5 + "exports": { 6 + ".": { 7 + "production": "./dist/html.min.js", 8 + "default": "./src/html.js" 9 + }, 10 + "./server": { 11 + "production": "./dist/html.server.min.js", 12 + "default": "./src/html.server.js" 13 + } 14 + }, 5 15 "scripts": { 6 16 "build": "./build.sh", 7 17 "format": "prettier --write . --cache", ··· 16 26 "@vitest/ui": "^3.0.5", 17 27 "dhtml": ".", 18 28 "esbuild": "^0.24.0", 29 + "htmlparser2": "^10.0.0", 19 30 "playwright": "^1.50.1", 20 31 "prettier": "^3.4.2", 21 32 "terser": "^5.37.0",
+274
src/html.server.ts
··· 1 + import type { Displayable, Renderable } from './types.ts' 2 + import { Tokenizer } from 'htmlparser2' 3 + 4 + function isRenderable(value: unknown): value is Renderable { 5 + return typeof value === 'object' && value !== null && 'render' in value 6 + } 7 + 8 + function isIterable(value: unknown): value is Iterable<unknown> { 9 + return typeof value === 'object' && value !== null && Symbol.iterator in value 10 + } 11 + 12 + export function html(statics: TemplateStringsArray, ...dynamics: unknown[]) { 13 + return new BoundTemplateInstance(statics, dynamics) 14 + } 15 + 16 + const singlePartTemplate = (part: Displayable) => html`${part}` 17 + 18 + /* v8 ignore start */ 19 + function assert(value: unknown, message = 'assertion failed'): asserts value { 20 + if (!value) throw new Error(message) 21 + } 22 + /* v8 ignore stop */ 23 + 24 + type PartRenderer = (values: unknown[]) => string | Generator<string, void, void> 25 + 26 + interface CompiledTemplate { 27 + statics: string[] 28 + parts: PartRenderer[] 29 + } 30 + 31 + class BoundTemplateInstance { 32 + #template: CompiledTemplate | undefined 33 + #statics: TemplateStringsArray 34 + dynamics: unknown[] 35 + 36 + get template() { 37 + return (this.#template ??= compileTemplate(this.#statics)) 38 + } 39 + 40 + constructor(statics: TemplateStringsArray, dynamics: unknown[]) { 41 + this.#statics = statics 42 + this.dynamics = dynamics 43 + } 44 + } 45 + 46 + const WHITESPACE_WHOLE = /^\s+$/ 47 + const DYNAMIC_WHOLE = /^dyn-\$(\d+)\$$/i 48 + const DYNAMIC_GLOBAL = /dyn-\$(\d+)\$/gi 49 + 50 + const templates = new WeakMap<TemplateStringsArray, CompiledTemplate>() 51 + function compileTemplate(statics: TemplateStringsArray): CompiledTemplate { 52 + const cached = templates.get(statics) 53 + if (cached) return cached 54 + 55 + const html = statics.reduce((a, v, i) => a + v + (i === statics.length - 1 ? '' : `dyn-$${i}$`), '') 56 + const parts: { 57 + start: number 58 + end: number 59 + render: PartRenderer 60 + }[] = [] 61 + let attribname: [start: number, end: number] | null = null 62 + function noop() {} 63 + 64 + const tokenizer = new Tokenizer( 65 + {}, 66 + { 67 + onattribname(start, end) { 68 + const name = html.slice(start, end) 69 + const match = name.match(DYNAMIC_WHOLE) 70 + if (match) { 71 + const idx = parseInt(match[1]) 72 + parts.push({ start, end, render: values => renderDirective(values[idx]) }) 73 + return 74 + } 75 + 76 + assert(!DYNAMIC_GLOBAL.test(name), `expected a whole dynamic value for ${name}, got a partial one`) 77 + 78 + attribname = [start, end] 79 + }, 80 + onattribdata(start, end) { 81 + assert(attribname) 82 + 83 + const [nameStart, nameEnd] = attribname 84 + const name = html.slice(nameStart, nameEnd) 85 + const value = html.slice(start, end) 86 + 87 + const match = value.match(DYNAMIC_WHOLE) 88 + if (match) { 89 + const idx = parseInt(match[1]) 90 + parts.push({ start: nameStart, end, render: values => renderAttribute(name, values[idx]) }) 91 + return 92 + } 93 + 94 + assert(!DYNAMIC_GLOBAL.test(value), `expected a whole dynamic value for ${name}, got a partial one`) 95 + }, 96 + onattribentity: noop, 97 + onattribend() { 98 + attribname = null 99 + }, 100 + 101 + onopentagname(start, end) {}, 102 + onopentagend() {}, 103 + onclosetag(start, end) {}, 104 + onselfclosingtag: noop, 105 + 106 + ontext(start, end) { 107 + const value = html.slice(start, end) 108 + 109 + for (const match of [...value.matchAll(DYNAMIC_GLOBAL)]) { 110 + const idx = parseInt(match[1]) 111 + parts.push({ 112 + start: start + match.index, 113 + end: start + match.index + match[0].length, 114 + render: values => renderChild(values[idx]), 115 + }) 116 + } 117 + 118 + if (WHITESPACE_WHOLE.test(value)) { 119 + parts.push({ start, end, render: () => ' ' }) 120 + return 121 + } 122 + }, 123 + ontextentity: noop, 124 + 125 + oncomment(start, end) { 126 + const value = html.slice(start, end) 127 + 128 + for (const match of [...value.matchAll(DYNAMIC_GLOBAL)]) { 129 + const idx = parseInt(match[1]) 130 + parts.push({ 131 + start: start + match.index, 132 + end: start + match.index + match[0].length, 133 + render: values => escape(values[idx]), 134 + }) 135 + } 136 + }, 137 + 138 + oncdata(start, end) {}, 139 + ondeclaration(start, end) {}, 140 + onprocessinginstruction(start, end) {}, 141 + 142 + onend: noop, 143 + }, 144 + ) 145 + 146 + tokenizer.write(html) 147 + tokenizer.end() 148 + 149 + const compiled: CompiledTemplate = { 150 + statics: [], 151 + parts: [], 152 + } 153 + 154 + compiled.statics.push(html.slice(0, parts[0]?.start)) 155 + 156 + for (let i = 0; i < parts.length; i++) { 157 + const part = parts[i] 158 + const nextPart = parts[i + 1] 159 + compiled.parts.push(part.render) 160 + compiled.statics.push(html.slice(part.end, nextPart?.start)) 161 + } 162 + 163 + templates.set(statics, compiled) 164 + return compiled 165 + } 166 + 167 + function renderDirective(value: unknown) { 168 + if (value === null) return '' 169 + 170 + assert(typeof value === 'function') 171 + console.log('directive returned:', value()) 172 + 173 + return '' 174 + } 175 + 176 + function renderAttribute(name: string, value: unknown) { 177 + if (value === false || value === null || typeof value === 'function') { 178 + return '' 179 + } 180 + if (value === true) return name 181 + return `${name}="${escape(value)}"` 182 + } 183 + 184 + function* renderChild(value: unknown) { 185 + const seen = new Set() 186 + 187 + while (isRenderable(value)) 188 + try { 189 + if (seen.has(value)) throw new Error('circular render') 190 + seen.add(value) 191 + value = value.render() 192 + } catch (thrown) { 193 + if (thrown instanceof BoundTemplateInstance) { 194 + value = thrown 195 + } else { 196 + throw thrown 197 + } 198 + } 199 + 200 + if (isIterable(value)) { 201 + for (const item of value) yield* renderToIterable(item as Displayable) 202 + } else if (value instanceof BoundTemplateInstance) { 203 + yield* renderToIterable(value) 204 + } else if (value !== null) { 205 + yield escape(value) 206 + } 207 + } 208 + 209 + const ESCAPE_RE = /[&<>"']/g 210 + const ESCAPE_SUBSTITUTIONS = { 211 + '&': '&amp;', 212 + '<': '&lt;', 213 + '>': '&gt;', 214 + '"': '&quot;', 215 + "'": '&#39;', 216 + } 217 + function escape(str: unknown) { 218 + return String(str).replace(ESCAPE_RE, c => ESCAPE_SUBSTITUTIONS[c]) 219 + } 220 + 221 + function* renderToIterable(value: Displayable) { 222 + const { template, dynamics } = value instanceof BoundTemplateInstance ? value : singlePartTemplate(value) 223 + 224 + for (let i = 0; i < template.statics.length - 1; i++) { 225 + yield template.statics[i] 226 + yield* template.parts[i](dynamics) 227 + } 228 + yield template.statics[template.statics.length - 1] 229 + } 230 + 231 + export function renderToString(value: Displayable) { 232 + let str = '' 233 + for (const part of renderToIterable(value)) str += part 234 + return str 235 + } 236 + 237 + export function renderToReadableStream(value: Displayable) { 238 + const iter = renderToIterable(value)[Symbol.iterator]() 239 + return new ReadableStream({ 240 + pull(controller) { 241 + const { done, value } = iter.next() 242 + if (done) { 243 + controller.close() 244 + return 245 + } 246 + controller.enqueue(value) 247 + }, 248 + }) 249 + } 250 + 251 + // { 252 + // const displayable = html` 253 + // <!-- ${'z'} --> 254 + // <p>a${'text'}b</p> 255 + // <a href=${'attr'} onclick=${() => {}}></a> 256 + // <button ${() => 'directive'}>but</button> 257 + // <script> 258 + // ;<span>z</span> 259 + // </script> 260 + // ${{ 261 + // render() { 262 + // return html`<div>${[1, 2, 3]}</div>` 263 + // }, 264 + // }} 265 + // ${html`[${'A'}|${'B'}]`} 266 + // ` 267 + 268 + // const stream = renderToReadableStream(displayable).pipeThrough(new TextEncoderStream()) 269 + 270 + // new Response(stream).text().then(rendered => { 271 + // console.log(rendered) 272 + // console.log(rendered === renderToString(displayable)) 273 + // }) 274 + // }