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

port to typescript (#47)

* noImplicitAny
* typescript
* split into many files
* change case to snake_case
* switch build to rolldown
* drop assert statements from prod build
* generate dist package json
* generate isolated bundled declarations
* share the index code between client/server

authored by tombl.dev and committed by

GitHub 9e4ca480 a828c834

+1380 -802
+115
build.js
··· 1 + import terser from '@rollup/plugin-terser' 2 + import { createBundle } from 'dts-buddy' 3 + import MagicString from 'magic-string' 4 + import { readFile, rm, writeFile } from 'node:fs/promises' 5 + import { brotliCompressSync, gzipSync } from 'node:zlib' 6 + import { build } from 'rolldown' 7 + import { walk } from 'zimmerframe' 8 + 9 + try { 10 + await rm('dist', { recursive: true }) 11 + } catch {} 12 + 13 + /** @type {import('rolldown').Plugin} */ 14 + const strip_asserts_plugin = { 15 + name: 'strip-asserts', 16 + transform(code, id, { moduleType }) { 17 + if (id.includes('node_modules')) return 18 + 19 + const ast = this.parse(code, { lang: moduleType }) 20 + const source = new MagicString(code, { filename: id }) 21 + 22 + walk(/** @type {import('@oxc-project/types').Node} */ (ast), null, { 23 + CallExpression(node, { next }) { 24 + if (node.callee.type === 'Identifier' && node.callee.name === 'assert') { 25 + source.update(node.start, node.end, ';') 26 + return 27 + } 28 + 29 + next() 30 + }, 31 + }) 32 + 33 + return { code: source.toString(), map: source.generateMap() } 34 + }, 35 + } 36 + 37 + /** @returns {import('rolldown').BuildOptions} */ 38 + function define_bundle(env) { 39 + const is_dev = env === 'dev' 40 + 41 + return { 42 + input: { 43 + client: './src/client.ts', 44 + server: './src/server.ts', 45 + index: './src/index.ts', 46 + }, 47 + plugins: [!is_dev && strip_asserts_plugin], 48 + output: { 49 + dir: 'dist', 50 + entryFileNames: is_dev ? '[name].js' : '[name].min.js', 51 + chunkFileNames: is_dev ? '[name].js' : '[name].min.js', 52 + banner: is_dev ? '// @ts-nocheck' : undefined, 53 + minify: !is_dev, 54 + plugins: [ 55 + !is_dev && 56 + terser({ 57 + mangle: { properties: { regex: /^_/ } }, 58 + }), 59 + ], 60 + }, 61 + define: { 62 + __DEV__: JSON.stringify(is_dev), 63 + }, 64 + } 65 + } 66 + 67 + const bundles = await build([define_bundle('dev'), define_bundle('prod')]) 68 + 69 + await createBundle({ 70 + project: 'tsconfig.json', 71 + output: 'dist/types.d.ts', 72 + modules: { 73 + dhtml: './src/index.ts', 74 + 'dhtml/client': './src/client.ts', 75 + 'dhtml/server': './src/server.ts', 76 + }, 77 + }) 78 + 79 + const pkg = JSON.parse(await readFile('package.json', 'utf8')) 80 + 81 + delete pkg.scripts 82 + delete pkg.devDependencies 83 + delete pkg.prettier 84 + ;(function walk(exports) { 85 + if (typeof exports === 'string') { 86 + if (exports.startsWith('./src/')) exports = exports.slice('./src/'.length) 87 + exports = exports.replace(/\.ts$/, '') 88 + return { 89 + types: './types.d.ts', 90 + production: `./${exports}.min.js`, 91 + default: `./${exports}.js`, 92 + } 93 + } 94 + for (const key in exports) { 95 + exports[key] = walk(exports[key]) 96 + } 97 + return exports 98 + })(pkg.exports) 99 + 100 + await writeFile('dist/package.json', JSON.stringify(pkg, null, 2)) 101 + 102 + console.table( 103 + Object.fromEntries( 104 + bundles.flatMap(bundle => 105 + bundle.output.map(file => [ 106 + file.fileName, 107 + { 108 + normal: file.code.length, 109 + gzip: gzipSync(file.code).length, 110 + brotli: brotliCompressSync(file.code).length, 111 + }, 112 + ]), 113 + ), 114 + ), 115 + )
-18
build.sh
··· 1 - #!/bin/sh 2 - 3 - mkdir -p dist 4 - 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)" 9 - 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)" 12 - 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
+11 -3
examples/kanban/package-lock.json
··· 18 18 }, 19 19 "../..": { 20 20 "devDependencies": { 21 + "@rollup/plugin-terser": "^0.4.4", 21 22 "@vitest/browser": "^3.0.6", 22 23 "@vitest/coverage-v8": "^3.0.6", 23 24 "@vitest/ui": "^3.0.5", 24 25 "dhtml": ".", 25 - "esbuild": "^0.24.0", 26 + "dts-buddy": "^0.5.5", 27 + "htmlparser2": "^10.0.0", 28 + "magic-string": "^0.30.17", 26 29 "playwright": "^1.50.1", 27 30 "prettier": "^3.4.2", 28 - "terser": "^5.37.0", 31 + "rolldown": "^1.0.0-beta.6", 29 32 "typescript": "^5.7.2", 30 - "vitest": "^3.0.6" 33 + "vitest": "^3.0.6", 34 + "zimmerframe": "^1.1.2" 31 35 } 36 + }, 37 + "../../dist": { 38 + "name": "dhtml", 39 + "extraneous": true 32 40 }, 33 41 "node_modules/@esbuild/aix-ppc64": { 34 42 "version": "0.25.0",
+2 -1
examples/kanban/src/app.ts
··· 1 1 import './styles.css' 2 2 3 - import { html, onUnmount } from 'dhtml' 3 + import { html } from 'dhtml' 4 + import { onUnmount } from 'dhtml/client' 4 5 import { Database, type ID } from './db' 5 6 import { Bus } from './util/bus' 6 7 import { Router } from './util/router'
+1 -1
examples/kanban/src/main.ts
··· 1 - import { createRoot } from 'dhtml' 1 + import { createRoot } from 'dhtml/client' 2 2 import { App } from './app' 3 3 4 4 const root = createRoot(document.body)
+2 -1
examples/kanban/src/pages/board/text.ts
··· 1 - import { html, attr } from 'dhtml' 1 + import { html } from 'dhtml' 2 + import { attr } from 'dhtml/client' 2 3 3 4 export function text({ value, onSubmit }: { value: string; onSubmit: (value: string) => void }) { 4 5 return html`
+2 -1
examples/kanban/src/util/decorators.ts
··· 1 - import { invalidate, type Renderable } from 'dhtml' 1 + import type { Renderable } from 'dhtml' 2 + import { invalidate } from 'dhtml/client' 2 3 3 4 export function state<This extends Renderable, Value>( 4 5 target: ClassAccessorDecoratorTarget<This, Value>,
+2 -1
examples/kanban/src/util/query.ts
··· 1 - import { invalidate, onMount, type Renderable } from 'dhtml' 1 + import type { Renderable } from 'dhtml' 2 + import { invalidate, onMount } from 'dhtml/client' 2 3 import type { Bus } from './bus' 3 4 import { suspend } from './suspense' 4 5
+2 -1
examples/kanban/src/util/router.ts
··· 4 4 type RouterConfig as BrowserRouterConfig, 5 5 } from '@tombl/router/browser' 6 6 import type { Params } from '@tombl/router/matcher' 7 - import { html, onMount, type Displayable } from 'dhtml' 7 + import { html, type Displayable } from 'dhtml' 8 + import { onMount } from 'dhtml/client' 8 9 import { state } from './decorators' 9 10 10 11 type PageClass<Path extends string, Context> = new (ctx: Context, params: Params<Path>) => Displayable
+2 -1
examples/kanban/src/util/suspense.ts
··· 1 - import { html, invalidate, type Renderable } from 'dhtml' 1 + import { html, type Renderable } from 'dhtml' 2 + import { invalidate } from 'dhtml/client' 2 3 3 4 const results = new WeakMap< 4 5 Promise<unknown>,
+2 -1
examples/todomvc/index.html
··· 8 8 <script type="importmap"> 9 9 { 10 10 "imports": { 11 - "dhtml": "../../src/html.js" 11 + "dhtml": "../../dist/index.client.js", 12 + "dhtml/client": "../../dist/client.js" 12 13 } 13 14 } 14 15 </script>
+4 -1
examples/todomvc/main.js
··· 1 - import { createRoot, html, invalidate } from 'dhtml' 1 + // @ts-check 2 + 3 + import { html } from 'dhtml' 4 + import { createRoot, invalidate } from 'dhtml/client' 2 5 3 6 function classes(...args) { 4 7 const classes = args.flatMap(a => (a ? a.split(' ') : []))
+1 -1
index.html
··· 7 7 <link rel="stylesheet" href="reset.css" /> 8 8 <title>dhtml</title> 9 9 <script type="module"> 10 - globalThis.DHTML_PROD = !new URLSearchParams(location.search).has('dev') 10 + globalThis.__DEV__ = new URLSearchParams(location.search).has('dev') 11 11 const { createRoot, html, invalidate } = await import('./src/html.js') 12 12 const root = createRoot(document.body) 13 13 Object.assign(globalThis, { createRoot, html, root })
+469 -4
package-lock.json
··· 6 6 "packages": { 7 7 "": { 8 8 "name": "dhtml", 9 - "dev": true, 10 9 "devDependencies": { 10 + "@rollup/plugin-terser": "^0.4.4", 11 11 "@vitest/browser": "^3.0.6", 12 12 "@vitest/coverage-v8": "^3.0.6", 13 13 "@vitest/ui": "^3.0.5", 14 14 "dhtml": ".", 15 - "esbuild": "^0.24.0", 15 + "dts-buddy": "^0.5.5", 16 16 "htmlparser2": "^10.0.0", 17 + "magic-string": "^0.30.17", 17 18 "playwright": "^1.50.1", 18 19 "prettier": "^3.4.2", 19 - "terser": "^5.37.0", 20 + "rolldown": "^1.0.0-beta.6", 20 21 "typescript": "^5.7.2", 21 - "vitest": "^3.0.6" 22 + "vitest": "^3.0.6", 23 + "zimmerframe": "^1.1.2" 22 24 } 23 25 }, 24 26 "node_modules/@ampproject/remapping": { ··· 152 154 "dependencies": { 153 155 "@types/tough-cookie": "^4.0.5", 154 156 "tough-cookie": "^4.1.4" 157 + } 158 + }, 159 + "node_modules/@emnapi/core": { 160 + "version": "1.3.1", 161 + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.3.1.tgz", 162 + "integrity": "sha512-pVGjBIt1Y6gg3EJN8jTcfpP/+uuRksIo055oE/OBkDNcjZqVbfkWCksG1Jp4yZnj3iKWyWX8fdG/j6UDYPbFog==", 163 + "dev": true, 164 + "license": "MIT", 165 + "optional": true, 166 + "dependencies": { 167 + "@emnapi/wasi-threads": "1.0.1", 168 + "tslib": "^2.4.0" 169 + } 170 + }, 171 + "node_modules/@emnapi/runtime": { 172 + "version": "1.3.1", 173 + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", 174 + "integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==", 175 + "dev": true, 176 + "license": "MIT", 177 + "optional": true, 178 + "dependencies": { 179 + "tslib": "^2.4.0" 180 + } 181 + }, 182 + "node_modules/@emnapi/wasi-threads": { 183 + "version": "1.0.1", 184 + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.1.tgz", 185 + "integrity": "sha512-iIBu7mwkq4UQGeMEM8bLwNK962nXdhodeScX4slfQnRhEMMzvYivHhutCIk8uojvmASXXPC2WNEjwxFWk72Oqw==", 186 + "dev": true, 187 + "license": "MIT", 188 + "optional": true, 189 + "dependencies": { 190 + "tslib": "^2.4.0" 155 191 } 156 192 }, 157 193 "node_modules/@esbuild/aix-ppc64": { ··· 840 876 "node": ">=18" 841 877 } 842 878 }, 879 + "node_modules/@napi-rs/wasm-runtime": { 880 + "version": "0.2.7", 881 + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.7.tgz", 882 + "integrity": "sha512-5yximcFK5FNompXfJFoWanu5l8v1hNGqNHh9du1xETp9HWk/B/PzvchX55WYOPaIeNglG8++68AAiauBAtbnzw==", 883 + "dev": true, 884 + "license": "MIT", 885 + "optional": true, 886 + "dependencies": { 887 + "@emnapi/core": "^1.3.1", 888 + "@emnapi/runtime": "^1.3.1", 889 + "@tybys/wasm-util": "^0.9.0" 890 + } 891 + }, 843 892 "node_modules/@open-draft/deferred-promise": { 844 893 "version": "2.2.0", 845 894 "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", ··· 865 914 "dev": true, 866 915 "license": "MIT" 867 916 }, 917 + "node_modules/@oxc-project/types": { 918 + "version": "0.58.1", 919 + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.58.1.tgz", 920 + "integrity": "sha512-/412rL5TIAsZJ428FvFsZCKYsnnKsABv9Z7xZmdtUylGT+qiN240wHU++HdHwYj2j1A5SeScB4O4t8EjjcPlUw==", 921 + "dev": true, 922 + "license": "MIT", 923 + "funding": { 924 + "url": "https://github.com/sponsors/Boshen" 925 + } 926 + }, 868 927 "node_modules/@pkgjs/parseargs": { 869 928 "version": "0.11.0", 870 929 "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", ··· 883 942 "dev": true, 884 943 "license": "MIT" 885 944 }, 945 + "node_modules/@rolldown/binding-darwin-arm64": { 946 + "version": "1.0.0-beta.6", 947 + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.6.tgz", 948 + "integrity": "sha512-Dzayzv3wH3q+mLu+ZTNIqykV502amJnMsyVEGQHZ4Nq4GQ5w0nrMFH0zs+imIb1C+NYPUXMcIj/UF/PDWXvVUA==", 949 + "cpu": [ 950 + "arm64" 951 + ], 952 + "dev": true, 953 + "license": "MIT", 954 + "optional": true, 955 + "os": [ 956 + "darwin" 957 + ] 958 + }, 959 + "node_modules/@rolldown/binding-darwin-x64": { 960 + "version": "1.0.0-beta.6", 961 + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.6.tgz", 962 + "integrity": "sha512-Qd+Dyus1TbFTqDcUh4QQ1rEtEoP69IMB6bQdLtzijvYzhV1P2isnCDPsgjebqz+3Jb850UQMWSQf0ygaLdsD7g==", 963 + "cpu": [ 964 + "x64" 965 + ], 966 + "dev": true, 967 + "license": "MIT", 968 + "optional": true, 969 + "os": [ 970 + "darwin" 971 + ] 972 + }, 973 + "node_modules/@rolldown/binding-freebsd-x64": { 974 + "version": "1.0.0-beta.6", 975 + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.6.tgz", 976 + "integrity": "sha512-nPCph8jSXMUXIUgiIEZM32jb+XsOU63vVkk6SEIMQh8HBxpNw1xISj4WFpywMI97hVjiQxEZOzPiqSeOPJoJZA==", 977 + "cpu": [ 978 + "x64" 979 + ], 980 + "dev": true, 981 + "license": "MIT", 982 + "optional": true, 983 + "os": [ 984 + "freebsd" 985 + ] 986 + }, 987 + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { 988 + "version": "1.0.0-beta.6", 989 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.6.tgz", 990 + "integrity": "sha512-V/0LsI5O6rQVE84HZllvTWlwX2AwnbU9NP50oJn7TrnWFwaVyV/x+FwCl6DykV3GhBc2t6Pp4X35L+Q5v9Kjtg==", 991 + "cpu": [ 992 + "arm" 993 + ], 994 + "dev": true, 995 + "license": "MIT", 996 + "optional": true, 997 + "os": [ 998 + "linux" 999 + ] 1000 + }, 1001 + "node_modules/@rolldown/binding-linux-arm64-gnu": { 1002 + "version": "1.0.0-beta.6", 1003 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.6.tgz", 1004 + "integrity": "sha512-uk4bBCq2IS586gjo6BwzBePrXij/zzU0rwIAOzg7XnIGrgnhZ8iUwX1tUHwOTLATeFfvdAF3dN3eLdObt7Q6XQ==", 1005 + "cpu": [ 1006 + "arm64" 1007 + ], 1008 + "dev": true, 1009 + "license": "MIT", 1010 + "optional": true, 1011 + "os": [ 1012 + "linux" 1013 + ] 1014 + }, 1015 + "node_modules/@rolldown/binding-linux-arm64-musl": { 1016 + "version": "1.0.0-beta.6", 1017 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.6.tgz", 1018 + "integrity": "sha512-+DEjh4orr5vGASIjFuEtjIfKpSF9wPbUG5QCJ+zdXJ+e/SPb5GxqzhAAGAQE9upWzVYU4Gca4WxzP51JEVU40w==", 1019 + "cpu": [ 1020 + "arm64" 1021 + ], 1022 + "dev": true, 1023 + "license": "MIT", 1024 + "optional": true, 1025 + "os": [ 1026 + "linux" 1027 + ] 1028 + }, 1029 + "node_modules/@rolldown/binding-linux-x64-gnu": { 1030 + "version": "1.0.0-beta.6", 1031 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.6.tgz", 1032 + "integrity": "sha512-/ruUtvsP5iXFqOqrhymjz2oH+KLsDyyJaD0YSwuM0Sf3kCtQ4D2cpnMzjzTWtdPrP2NKFduDCFazYKiGyPtIfQ==", 1033 + "cpu": [ 1034 + "x64" 1035 + ], 1036 + "dev": true, 1037 + "license": "MIT", 1038 + "optional": true, 1039 + "os": [ 1040 + "linux" 1041 + ] 1042 + }, 1043 + "node_modules/@rolldown/binding-linux-x64-musl": { 1044 + "version": "1.0.0-beta.6", 1045 + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.6.tgz", 1046 + "integrity": "sha512-1RCIuyk0kDzPLwopcsfFXjWqUVuzn54nQNk+97O9auREIshEOEf/c3+xpjKNKYpLXpV9ZetLlgv60E1yE3conw==", 1047 + "cpu": [ 1048 + "x64" 1049 + ], 1050 + "dev": true, 1051 + "license": "MIT", 1052 + "optional": true, 1053 + "os": [ 1054 + "linux" 1055 + ] 1056 + }, 1057 + "node_modules/@rolldown/binding-wasm32-wasi": { 1058 + "version": "1.0.0-beta.6", 1059 + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.6.tgz", 1060 + "integrity": "sha512-da3ACjk70tIT6QNQCaLjDZ0uFBAO8H3UT+tfm1rclE6SAACCwiXZV5qC8yprNguE0wx8QvhyWnt1h9R50UovGg==", 1061 + "cpu": [ 1062 + "wasm32" 1063 + ], 1064 + "dev": true, 1065 + "license": "MIT", 1066 + "optional": true, 1067 + "dependencies": { 1068 + "@napi-rs/wasm-runtime": "^0.2.4" 1069 + }, 1070 + "engines": { 1071 + "node": ">=14.21.3" 1072 + } 1073 + }, 1074 + "node_modules/@rolldown/binding-win32-arm64-msvc": { 1075 + "version": "1.0.0-beta.6", 1076 + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.6.tgz", 1077 + "integrity": "sha512-sh0YWjjQrNGzv3BFSQnySKP1+0RboVX4TMY4oyoqmhe1pDbUVFmIbdSWPAH9ppIX1DVYoR/g/gcGIi/XgZZlEw==", 1078 + "cpu": [ 1079 + "arm64" 1080 + ], 1081 + "dev": true, 1082 + "license": "MIT", 1083 + "optional": true, 1084 + "os": [ 1085 + "win32" 1086 + ] 1087 + }, 1088 + "node_modules/@rolldown/binding-win32-ia32-msvc": { 1089 + "version": "1.0.0-beta.6", 1090 + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.6.tgz", 1091 + "integrity": "sha512-IqoZ4+vVVdO0YcKm2NdzhCdGIQm63JSaI5dK/BS+AmwfjB+7ThTnmYw9qhoNIOrQ4f/Dyjlmp+E23N+JzcmxwQ==", 1092 + "cpu": [ 1093 + "ia32" 1094 + ], 1095 + "dev": true, 1096 + "license": "MIT", 1097 + "optional": true, 1098 + "os": [ 1099 + "win32" 1100 + ] 1101 + }, 1102 + "node_modules/@rolldown/binding-win32-x64-msvc": { 1103 + "version": "1.0.0-beta.6", 1104 + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.6.tgz", 1105 + "integrity": "sha512-ZxH7/+Wa88KiBa4TMCz8gj6TNewVeZr1J93TwU4AA6U2TFTgtkrx/9DbYUhoQ/m9L5+iHaPT++z2la5nVr9t+A==", 1106 + "cpu": [ 1107 + "x64" 1108 + ], 1109 + "dev": true, 1110 + "license": "MIT", 1111 + "optional": true, 1112 + "os": [ 1113 + "win32" 1114 + ] 1115 + }, 1116 + "node_modules/@rollup/plugin-terser": { 1117 + "version": "0.4.4", 1118 + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", 1119 + "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", 1120 + "dev": true, 1121 + "license": "MIT", 1122 + "dependencies": { 1123 + "serialize-javascript": "^6.0.1", 1124 + "smob": "^1.0.0", 1125 + "terser": "^5.17.4" 1126 + }, 1127 + "engines": { 1128 + "node": ">=14.0.0" 1129 + }, 1130 + "peerDependencies": { 1131 + "rollup": "^2.0.0||^3.0.0||^4.0.0" 1132 + }, 1133 + "peerDependenciesMeta": { 1134 + "rollup": { 1135 + "optional": true 1136 + } 1137 + } 1138 + }, 886 1139 "node_modules/@rollup/rollup-android-arm-eabi": { 887 1140 "version": "4.34.8", 888 1141 "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.8.tgz", ··· 1183 1436 "@testing-library/dom": ">=7.21.4" 1184 1437 } 1185 1438 }, 1439 + "node_modules/@tybys/wasm-util": { 1440 + "version": "0.9.0", 1441 + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", 1442 + "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", 1443 + "dev": true, 1444 + "license": "MIT", 1445 + "optional": true, 1446 + "dependencies": { 1447 + "tslib": "^2.4.0" 1448 + } 1449 + }, 1186 1450 "node_modules/@types/aria-query": { 1187 1451 "version": "5.0.4", 1188 1452 "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", ··· 1217 1481 "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", 1218 1482 "dev": true, 1219 1483 "license": "MIT" 1484 + }, 1485 + "node_modules/@valibot/to-json-schema": { 1486 + "version": "1.0.0-rc.0", 1487 + "resolved": "https://registry.npmjs.org/@valibot/to-json-schema/-/to-json-schema-1.0.0-rc.0.tgz", 1488 + "integrity": "sha512-F3WDgnPzcDs9Y8qZwU9qfPnEJBQ6lCMCFjI7VsMjAza6yAixGr4cZ50gOy6zniSCk49GkFvq2a6cBKfZjTpyOw==", 1489 + "dev": true, 1490 + "license": "MIT", 1491 + "peerDependencies": { 1492 + "valibot": "^1.0.0 || ^1.0.0-beta.5 || ^1.0.0-rc" 1493 + } 1220 1494 }, 1221 1495 "node_modules/@vitest/browser": { 1222 1496 "version": "3.0.6", ··· 1799 2073 "url": "https://github.com/fb55/domutils?sponsor=1" 1800 2074 } 1801 2075 }, 2076 + "node_modules/dts-buddy": { 2077 + "version": "0.5.5", 2078 + "resolved": "https://registry.npmjs.org/dts-buddy/-/dts-buddy-0.5.5.tgz", 2079 + "integrity": "sha512-Mu5PJuP7C+EqZIwDtW/bG1tVli1UFhRIyW/dERBVBYk28OviTkribu9S2LpDQ0HF2MbkqnjQIkbbE6HnepdNTQ==", 2080 + "dev": true, 2081 + "license": "MIT", 2082 + "dependencies": { 2083 + "@jridgewell/source-map": "^0.3.5", 2084 + "@jridgewell/sourcemap-codec": "^1.4.15", 2085 + "kleur": "^4.1.5", 2086 + "locate-character": "^3.0.0", 2087 + "magic-string": "^0.30.4", 2088 + "sade": "^1.8.1", 2089 + "tinyglobby": "^0.2.10", 2090 + "ts-api-utils": "^1.0.3" 2091 + }, 2092 + "bin": { 2093 + "dts-buddy": "src/cli.js" 2094 + }, 2095 + "peerDependencies": { 2096 + "typescript": ">=5.0.4 <5.8" 2097 + } 2098 + }, 1802 2099 "node_modules/eastasianwidth": { 1803 2100 "version": "0.2.0", 1804 2101 "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", ··· 2167 2464 "dev": true, 2168 2465 "license": "MIT" 2169 2466 }, 2467 + "node_modules/kleur": { 2468 + "version": "4.1.5", 2469 + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", 2470 + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", 2471 + "dev": true, 2472 + "license": "MIT", 2473 + "engines": { 2474 + "node": ">=6" 2475 + } 2476 + }, 2477 + "node_modules/locate-character": { 2478 + "version": "3.0.0", 2479 + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", 2480 + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", 2481 + "dev": true, 2482 + "license": "MIT" 2483 + }, 2170 2484 "node_modules/loupe": { 2171 2485 "version": "3.1.3", 2172 2486 "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", ··· 2255 2569 "node": ">=16 || 14 >=14.17" 2256 2570 } 2257 2571 }, 2572 + "node_modules/mri": { 2573 + "version": "1.2.0", 2574 + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", 2575 + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", 2576 + "dev": true, 2577 + "license": "MIT", 2578 + "engines": { 2579 + "node": ">=4" 2580 + } 2581 + }, 2258 2582 "node_modules/mrmime": { 2259 2583 "version": "2.0.1", 2260 2584 "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", ··· 2579 2903 "dev": true, 2580 2904 "license": "MIT" 2581 2905 }, 2906 + "node_modules/randombytes": { 2907 + "version": "2.1.0", 2908 + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", 2909 + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", 2910 + "dev": true, 2911 + "license": "MIT", 2912 + "dependencies": { 2913 + "safe-buffer": "^5.1.0" 2914 + } 2915 + }, 2582 2916 "node_modules/react-is": { 2583 2917 "version": "17.0.2", 2584 2918 "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", ··· 2610 2944 "dev": true, 2611 2945 "license": "MIT" 2612 2946 }, 2947 + "node_modules/rolldown": { 2948 + "version": "1.0.0-beta.6", 2949 + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.6.tgz", 2950 + "integrity": "sha512-0FOZc1kJtHoCR4Se72yFISk3X1kjMtWHQ/567fRw1PMxtQY0cZ0h32pq85tQhMVJkyp5HZ9Mlz9sCx0BUFVeIw==", 2951 + "dev": true, 2952 + "license": "MIT", 2953 + "dependencies": { 2954 + "@oxc-project/types": "0.58.1", 2955 + "@valibot/to-json-schema": "1.0.0-rc.0", 2956 + "valibot": "1.0.0-rc.4" 2957 + }, 2958 + "bin": { 2959 + "rolldown": "bin/cli.js" 2960 + }, 2961 + "optionalDependencies": { 2962 + "@rolldown/binding-darwin-arm64": "1.0.0-beta.6", 2963 + "@rolldown/binding-darwin-x64": "1.0.0-beta.6", 2964 + "@rolldown/binding-freebsd-x64": "1.0.0-beta.6", 2965 + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.6", 2966 + "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.6", 2967 + "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.6", 2968 + "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.6", 2969 + "@rolldown/binding-linux-x64-musl": "1.0.0-beta.6", 2970 + "@rolldown/binding-wasm32-wasi": "1.0.0-beta.6", 2971 + "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.6", 2972 + "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.6", 2973 + "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.6" 2974 + }, 2975 + "peerDependencies": { 2976 + "@oxc-project/runtime": "0.58.1" 2977 + }, 2978 + "peerDependenciesMeta": { 2979 + "@oxc-project/runtime": { 2980 + "optional": true 2981 + } 2982 + } 2983 + }, 2613 2984 "node_modules/rollup": { 2614 2985 "version": "4.34.8", 2615 2986 "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.8.tgz", ··· 2649 3020 "fsevents": "~2.3.2" 2650 3021 } 2651 3022 }, 3023 + "node_modules/sade": { 3024 + "version": "1.8.1", 3025 + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", 3026 + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", 3027 + "dev": true, 3028 + "license": "MIT", 3029 + "dependencies": { 3030 + "mri": "^1.1.0" 3031 + }, 3032 + "engines": { 3033 + "node": ">=6" 3034 + } 3035 + }, 3036 + "node_modules/safe-buffer": { 3037 + "version": "5.2.1", 3038 + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", 3039 + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", 3040 + "dev": true, 3041 + "funding": [ 3042 + { 3043 + "type": "github", 3044 + "url": "https://github.com/sponsors/feross" 3045 + }, 3046 + { 3047 + "type": "patreon", 3048 + "url": "https://www.patreon.com/feross" 3049 + }, 3050 + { 3051 + "type": "consulting", 3052 + "url": "https://feross.org/support" 3053 + } 3054 + ], 3055 + "license": "MIT" 3056 + }, 2652 3057 "node_modules/semver": { 2653 3058 "version": "7.7.1", 2654 3059 "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", ··· 2662 3067 "node": ">=10" 2663 3068 } 2664 3069 }, 3070 + "node_modules/serialize-javascript": { 3071 + "version": "6.0.2", 3072 + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", 3073 + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", 3074 + "dev": true, 3075 + "license": "BSD-3-Clause", 3076 + "dependencies": { 3077 + "randombytes": "^2.1.0" 3078 + } 3079 + }, 2665 3080 "node_modules/shebang-command": { 2666 3081 "version": "2.0.0", 2667 3082 "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", ··· 2719 3134 "engines": { 2720 3135 "node": ">=18" 2721 3136 } 3137 + }, 3138 + "node_modules/smob": { 3139 + "version": "1.5.0", 3140 + "resolved": "https://registry.npmjs.org/smob/-/smob-1.5.0.tgz", 3141 + "integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==", 3142 + "dev": true, 3143 + "license": "MIT" 2722 3144 }, 2723 3145 "node_modules/source-map": { 2724 3146 "version": "0.6.1", ··· 2968 3390 "node": ">=6" 2969 3391 } 2970 3392 }, 3393 + "node_modules/ts-api-utils": { 3394 + "version": "1.4.3", 3395 + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", 3396 + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", 3397 + "dev": true, 3398 + "license": "MIT", 3399 + "engines": { 3400 + "node": ">=16" 3401 + }, 3402 + "peerDependencies": { 3403 + "typescript": ">=4.2.0" 3404 + } 3405 + }, 3406 + "node_modules/tslib": { 3407 + "version": "2.8.1", 3408 + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", 3409 + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", 3410 + "dev": true, 3411 + "license": "0BSD", 3412 + "optional": true 3413 + }, 2971 3414 "node_modules/type-fest": { 2972 3415 "version": "4.33.0", 2973 3416 "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.33.0.tgz", ··· 3012 3455 "dependencies": { 3013 3456 "querystringify": "^2.1.1", 3014 3457 "requires-port": "^1.0.0" 3458 + } 3459 + }, 3460 + "node_modules/valibot": { 3461 + "version": "1.0.0-rc.4", 3462 + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.0.0-rc.4.tgz", 3463 + "integrity": "sha512-VRaChgFv7Ab0P54AMLu7+GqoexdTPQ54Plj59X9qV0AFozI3j9CGH43skg+TqgMpXnrW8jxlJ2TTHAtAD3t4qA==", 3464 + "dev": true, 3465 + "license": "MIT", 3466 + "peerDependencies": { 3467 + "typescript": ">=5" 3468 + }, 3469 + "peerDependenciesMeta": { 3470 + "typescript": { 3471 + "optional": true 3472 + } 3015 3473 } 3016 3474 }, 3017 3475 "node_modules/vite": { ··· 3343 3801 "funding": { 3344 3802 "url": "https://github.com/sponsors/sindresorhus" 3345 3803 } 3804 + }, 3805 + "node_modules/zimmerframe": { 3806 + "version": "1.1.2", 3807 + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz", 3808 + "integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==", 3809 + "dev": true, 3810 + "license": "MIT" 3346 3811 } 3347 3812 } 3348 3813 }
+10 -13
package.json
··· 1 1 { 2 2 "name": "dhtml", 3 3 "type": "module", 4 - "main": "src/html.js", 5 4 "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 - } 5 + ".": "./src/index.ts", 6 + "./client": "./src/client.ts", 7 + "./server": "./src/server.ts" 14 8 }, 15 9 "scripts": { 16 - "build": "./build.sh", 10 + "build": "node build.js", 17 11 "format": "prettier --write . --cache", 18 12 "check": "tsc", 19 13 "test": "vitest run", ··· 21 15 "test:prod": "npm run build && NODE_ENV=production vitest run" 22 16 }, 23 17 "devDependencies": { 18 + "@rollup/plugin-terser": "^0.4.4", 24 19 "@vitest/browser": "^3.0.6", 25 20 "@vitest/coverage-v8": "^3.0.6", 26 21 "@vitest/ui": "^3.0.5", 27 22 "dhtml": ".", 28 - "esbuild": "^0.24.0", 23 + "dts-buddy": "^0.5.5", 29 24 "htmlparser2": "^10.0.0", 25 + "magic-string": "^0.30.17", 30 26 "playwright": "^1.50.1", 31 27 "prettier": "^3.4.2", 32 - "terser": "^5.37.0", 28 + "rolldown": "^1.0.0-beta.6", 33 29 "typescript": "^5.7.2", 34 - "vitest": "^3.0.6" 30 + "vitest": "^3.0.6", 31 + "zimmerframe": "^1.1.2" 35 32 }, 36 33 "prettier": { 37 34 "arrowParens": "avoid",
+9
src/client.ts
··· 1 + import { create_root_into, type RootPublic as Root } from './client/root.ts' 2 + 3 + export function createRoot(parent: Node): Root { 4 + return create_root_into(parent) 5 + } 6 + export type { Root } 7 + 8 + export { getParentNode, invalidate, keyed, onMount, onUnmount } from './client/controller.ts' 9 + export { attr_directive as attr, type Directive } from './client/parts.ts'
+150
src/client/compiler.ts
··· 1 + import { assert } from '../shared.ts' 2 + import { 3 + create_attribute_part, 4 + create_child_part, 5 + create_directive_part, 6 + createPropertyPart, 7 + type Part, 8 + } from './parts.ts' 9 + import type { Span } from './span.ts' 10 + import { is_comment, is_document_fragment, is_element, is_text } from './util.ts' 11 + 12 + const NODE_FILTER_ELEMENT: typeof NodeFilter.SHOW_ELEMENT = 1 13 + const NODE_FILTER_TEXT: typeof NodeFilter.SHOW_TEXT = 4 14 + const NODE_FILTER_COMMENT: typeof NodeFilter.SHOW_COMMENT = 128 15 + 16 + export interface CompiledTemplate { 17 + _content: DocumentFragment 18 + _parts: [idx: number, createPart: (node: Node | Span, span: Span) => Part][] 19 + _root_parts: number[] 20 + } 21 + 22 + const DYNAMIC_WHOLE = /^dyn-\$(\d+)\$$/i 23 + const DYNAMIC_GLOBAL = /dyn-\$(\d+)\$/gi 24 + const FORCE_ATTRIBUTES = /-|^class$|^for$/i 25 + 26 + const templates: Map<TemplateStringsArray, CompiledTemplate> = new Map() 27 + export function compile_template(statics: TemplateStringsArray): CompiledTemplate { 28 + const cached = templates.get(statics) 29 + if (cached) return cached 30 + 31 + const template_element = document.createElement('template') 32 + template_element.innerHTML = statics.reduce((a, v, i) => a + v + (i === statics.length - 1 ? '' : `dyn-$${i}$`), '') 33 + 34 + let next_part = 0 35 + 36 + const compiled: CompiledTemplate = { 37 + _content: template_element.content, 38 + _parts: Array(statics.length - 1), 39 + _root_parts: [], 40 + } 41 + 42 + function patch( 43 + node: DocumentFragment | HTMLElement | SVGElement, 44 + idx: number, 45 + createPart: (node: Node | Span, span: Span) => Part, 46 + ) { 47 + assert(next_part < compiled._parts.length, 'got more parts than expected') 48 + if (is_document_fragment(node)) compiled._root_parts.push(next_part) 49 + else if ('dynparts' in node.dataset) node.dataset.dynparts += ' ' + next_part 50 + // @ts-expect-error -- this assigment will cast nextPart to a string 51 + else node.dataset.dynparts = next_part 52 + compiled._parts[next_part++] = [idx, createPart] 53 + } 54 + 55 + const walker = document.createTreeWalker( 56 + template_element.content, 57 + NODE_FILTER_TEXT | NODE_FILTER_ELEMENT | (__DEV__ ? NODE_FILTER_COMMENT : 0), 58 + ) 59 + // stop iterating once we've hit the last part, but if we're in dev mode, keep going to check for mistakes. 60 + while ((next_part < compiled._parts.length || __DEV__) && walker.nextNode()) { 61 + const node = walker.currentNode 62 + if (is_text(node)) { 63 + // reverse the order because we'll be supplying ChildPart with its index in the parent node. 64 + // and if we apply the parts forwards, indicies will be wrong if some prior part renders more than one node. 65 + // also reverse it because that's the correct order for splitting. 66 + const nodes = [...node.data.matchAll(DYNAMIC_GLOBAL)].reverse().map(match => { 67 + node.splitText(match.index + match[0].length) 68 + const dyn = new Comment() 69 + node.splitText(match.index).replaceWith(dyn) 70 + return [dyn, parseInt(match[1])] as const 71 + }) 72 + 73 + if (nodes.length) { 74 + const parent_node = node.parentNode 75 + assert(parent_node !== null, 'all text nodes should have a parent node') 76 + assert( 77 + parent_node instanceof DocumentFragment || 78 + parent_node instanceof HTMLElement || 79 + parent_node instanceof SVGElement, 80 + ) 81 + let siblings = [...parent_node.childNodes] 82 + for (const [node, idx] of nodes) { 83 + const child = siblings.indexOf(node) 84 + patch(parent_node, idx, (node, span) => create_child_part(node, span, child)) 85 + } 86 + } 87 + } else if (__DEV__ && is_comment(node)) { 88 + // just in dev, stub out a fake part for every interpolation in a comment. 89 + // this means you can comment out code inside a template and not run into 90 + // issues with incorrect part counts. 91 + // in production the check is skipped, so we can also skip this. 92 + for (const _match of node.data.matchAll(DYNAMIC_GLOBAL)) { 93 + compiled._parts[next_part++] = [parseInt(_match[1]), () => ({ update() {}, detach() {} })] 94 + } 95 + } else { 96 + assert(is_element(node)) 97 + assert(node instanceof HTMLElement || node instanceof SVGElement) 98 + 99 + const to_remove = [] 100 + for (let name of node.getAttributeNames()) { 101 + const value = node.getAttribute(name) 102 + assert(value !== null) 103 + 104 + let match = DYNAMIC_WHOLE.exec(name) 105 + if (match !== null) { 106 + // directive: 107 + to_remove.push(name) 108 + assert(value === '', `directives must not have values`) 109 + patch(node, parseInt(match[1]), node => { 110 + assert(node instanceof Node) 111 + return create_directive_part(node) 112 + }) 113 + } else { 114 + // properties: 115 + match = DYNAMIC_WHOLE.exec(value) 116 + if (match !== null) { 117 + to_remove.push(name) 118 + if (FORCE_ATTRIBUTES.test(name)) { 119 + patch(node, parseInt(match[1]), node => { 120 + assert(node instanceof Element) 121 + return create_attribute_part(node, name) 122 + }) 123 + } else { 124 + if (!(name in node)) { 125 + for (const property in node) { 126 + if (property.toLowerCase() === name) { 127 + name = property 128 + break 129 + } 130 + } 131 + } 132 + patch(node, parseInt(match[1]), node => { 133 + assert(node instanceof Node) 134 + return createPropertyPart(node, name) 135 + }) 136 + } 137 + } else if (__DEV__) { 138 + assert(!DYNAMIC_GLOBAL.test(value), `expected a whole dynamic value for ${name}, got a partial one`) 139 + } 140 + } 141 + } 142 + for (const name of to_remove) node.removeAttribute(name) 143 + } 144 + } 145 + 146 + compiled._parts.length = next_part 147 + 148 + templates.set(statics, compiled) 149 + return compiled 150 + }
+57
src/client/controller.ts
··· 1 + import { assert, is_renderable, type Displayable, type Renderable } from '../shared.ts' 2 + import { type Cleanup } from './util.ts' 3 + 4 + export type Key = string | number | bigint | boolean | symbol | object | null 5 + 6 + export const controllers: WeakMap< 7 + object, 8 + { 9 + _mounted: boolean 10 + _invalidate_queued: Promise<void> | null 11 + _invalidate: () => void 12 + _unmount_callbacks: Set<Cleanup> | null 13 + _parent_node: Node 14 + } 15 + > = new WeakMap() 16 + 17 + export const keys: WeakMap<Displayable & object, Key> = new WeakMap() 18 + export const mount_callbacks: WeakMap<Renderable, Set<() => Cleanup>> = new WeakMap() 19 + 20 + export function invalidate(renderable: Renderable): Promise<void> { 21 + const controller = controllers.get(renderable) 22 + assert(controller, 'the renderable has not been rendered') 23 + return (controller._invalidate_queued ??= Promise.resolve().then(() => { 24 + controller._invalidate_queued = null 25 + controller._invalidate() 26 + })) 27 + } 28 + 29 + export function onMount(renderable: Renderable, callback: () => Cleanup): void { 30 + assert(is_renderable(renderable), 'expected a renderable') 31 + 32 + const controller = controllers.get(renderable) 33 + if (controller?._mounted) { 34 + ;(controller._unmount_callbacks ??= new Set()).add(callback()) 35 + return 36 + } 37 + 38 + let cb = mount_callbacks.get(renderable) 39 + if (!cb) mount_callbacks.set(renderable, (cb = new Set())) 40 + cb.add(callback) 41 + } 42 + 43 + export function onUnmount(renderable: Renderable, callback: () => void): void { 44 + onMount(renderable, () => callback) 45 + } 46 + 47 + export function getParentNode(renderable: Renderable): Node { 48 + const controller = controllers.get(renderable) 49 + assert(controller, 'the renderable has not been rendered') 50 + return controller._parent_node 51 + } 52 + 53 + export function keyed<T extends Displayable & object>(renderable: T, key: Key): T { 54 + if (__DEV__ && keys.has(renderable)) throw new Error('renderable already has a key') 55 + keys.set(renderable, key) 56 + return renderable 57 + }
+280
src/client/parts.ts
··· 1 + import { 2 + assert, 3 + is_html, 4 + is_iterable, 5 + is_renderable, 6 + single_part_template, 7 + type Displayable, 8 + type Renderable, 9 + } from '../shared.ts' 10 + import { controllers, keys, mount_callbacks } from './controller.ts' 11 + import { create_root, create_root_after, type Root } from './root.ts' 12 + import { create_span, span_delete_contents, span_extract_contents, span_insert_node, type Span } from './span.ts' 13 + import { type Cleanup } from './util.ts' 14 + 15 + export interface Part { 16 + update(value: unknown): void 17 + detach(): void 18 + } 19 + 20 + export function create_child_part(parent_node: Node | Span, parentSpan: Span, childIndex: number): Part { 21 + let span: Span 22 + 23 + // for when we're rendering a renderable: 24 + let renderable: Renderable | null = null 25 + 26 + // for when we're rendering a template: 27 + let root: Root | undefined 28 + 29 + // for when we're rendering multiple values: 30 + let roots: Root[] | undefined 31 + 32 + // for when we're rendering a string/single dom node: 33 + // undefined means no previous value, because a user-specified undefined is remapped to null 34 + 35 + let old_value: Displayable | null | undefined 36 + 37 + function switch_renderable(next: Renderable | null) { 38 + if (renderable && renderable !== next) { 39 + const controller = controllers.get(renderable) 40 + if (controller?._unmount_callbacks) for (const callback of controller._unmount_callbacks) callback?.() 41 + controllers.delete(renderable) 42 + } 43 + renderable = next 44 + } 45 + 46 + function disconnect_root() { 47 + // root.detach and part.detach are mutually recursive, so this detaches children too. 48 + root?.detach() 49 + root = undefined 50 + } 51 + 52 + if (parent_node instanceof Node) { 53 + const child = parent_node.childNodes[childIndex] 54 + span = create_span(child) 55 + } else { 56 + let child = parent_node._start 57 + for (let i = 0; i < childIndex; i++) { 58 + { 59 + assert(child.nextSibling !== null, 'expected more siblings') 60 + assert(child.nextSibling !== parent_node._end, 'ran out of siblings before the end') 61 + } 62 + child = child.nextSibling 63 + } 64 + span = create_span(child) 65 + } 66 + 67 + return { 68 + update: function update(value: Displayable) { 69 + assert(span) 70 + const endsWereEqual = span._parent === parentSpan._parent && span._end === parentSpan._end 71 + 72 + if (is_renderable(value)) { 73 + switch_renderable(value) 74 + 75 + const renderable = value 76 + 77 + if (!controllers.has(renderable)) 78 + controllers.set(renderable, { 79 + _mounted: false, 80 + _invalidate_queued: null, 81 + _invalidate: () => { 82 + assert(renderable === renderable, 'could not invalidate an outdated renderable') 83 + update(renderable) 84 + }, 85 + _unmount_callbacks: null, // will be upgraded to a Set if needed. 86 + _parent_node: span._parent, 87 + }) 88 + 89 + try { 90 + value = renderable.render() 91 + } catch (thrown) { 92 + if (is_html(thrown)) { 93 + value = thrown 94 + } else { 95 + throw thrown 96 + } 97 + } 98 + 99 + // if render returned another renderable, we want to track/cache both renderables individually. 100 + // wrap it in a nested ChildPart so that each can be tracked without ChildPart having to handle multiple renderables. 101 + if (is_renderable(value)) value = single_part_template(value) 102 + } else switch_renderable(null) 103 + 104 + // if it's undefined, swap the value for null. 105 + // this means if the initial value is undefined, 106 + // it won't conflict with prevValue's default of undefined, 107 + // so it'll still render. 108 + if (value === undefined) value = null 109 + 110 + // NOTE: we're explicitly not caching/diffing the value when it's an iterable, 111 + // given it can yield different values but have the same identity. (e.g. arrays) 112 + if (is_iterable(value)) { 113 + if (!roots) { 114 + // we previously rendered a single value, so we need to clear it. 115 + disconnect_root() 116 + span_delete_contents(span) 117 + 118 + roots = [] 119 + } 120 + 121 + // create or update a root for every item. 122 + let i = 0 123 + let end = span._start 124 + for (const item of value) { 125 + // @ts-expect-error -- WeakMap lookups of non-objects always return undefined, which is fine 126 + const key = keys.get(item) ?? item 127 + let root = (roots[i] ??= create_root_after(end)) 128 + 129 + if (key !== undefined && root._key !== key) { 130 + const j = roots.findIndex(r => r._key === key) 131 + root._key = key 132 + if (j !== -1) { 133 + const root1 = root 134 + const root2 = roots[j] 135 + 136 + // swap the contents of the spans 137 + const tmp_content = span_extract_contents(root1._span) 138 + span_insert_node(root1._span, span_extract_contents(root2._span)) 139 + span_insert_node(root2._span, tmp_content) 140 + 141 + // swap the spans back 142 + const tmp_span = root1._span 143 + root1._span = root2._span 144 + root2._span = tmp_span 145 + 146 + // swap the roots 147 + roots[j] = root1 148 + root = roots[i] = root2 149 + } 150 + } 151 + 152 + root.render(item as Displayable) 153 + end = root._span._end 154 + i++ 155 + } 156 + 157 + // and now remove excess roots if the iterable has shrunk. 158 + while (roots.length > i) { 159 + const root = roots.pop() 160 + assert(root) 161 + root.detach() 162 + span_delete_contents(root._span) 163 + } 164 + 165 + span._end = end 166 + 167 + // @ts-expect-error -- a null controllable will always return a null controller 168 + const controller = controllers.get(renderable) 169 + if (controller) { 170 + controller._mounted = true 171 + // @ts-expect-error -- WeakMap lookups of null always return undefined, which is fine 172 + for (const callback of mount_callbacks.get(renderable) ?? []) { 173 + ;(controller._unmount_callbacks ??= new Set()).add(callback()) 174 + } 175 + // @ts-expect-error -- WeakMap lookups of null always return undefined, which is fine 176 + mount_callbacks.delete(renderable) 177 + } 178 + 179 + if (endsWereEqual) parentSpan._end = span._end 180 + 181 + return 182 + } else if (roots) { 183 + for (const root of roots) root.detach() 184 + roots = undefined 185 + } 186 + 187 + // now early return if the value hasn't changed. 188 + if (Object.is(old_value, value)) return 189 + 190 + if (is_html(value)) { 191 + root ??= create_root(span) 192 + root.render(value) // root.render will detach the previous tree if the template has changed. 193 + } else { 194 + // if we previously rendered a tree that might contain renderables, 195 + // and the template has changed (or we're not even rendering a template anymore), 196 + // we need to clear the old renderables. 197 + disconnect_root() 198 + 199 + if (old_value != null && value !== null && !(old_value instanceof Node) && !(value instanceof Node)) { 200 + // we previously rendered a string, and we're rendering a string again. 201 + assert(span._start === span._end && span._start instanceof Text) 202 + span._start.data = '' + value 203 + } else { 204 + span_delete_contents(span) 205 + if (value !== null) span_insert_node(span, value instanceof Node ? value : new Text('' + value)) 206 + } 207 + } 208 + 209 + old_value = value 210 + 211 + // @ts-expect-error -- a null controllable will always return a null controller 212 + const controller = controllers.get(renderable) 213 + if (controller) { 214 + controller._mounted = true 215 + // @ts-expect-error -- WeakMap lookups of null always return undefined, which is fine 216 + for (const callback of mount_callbacks.get(renderable) ?? []) { 217 + ;(controller._unmount_callbacks ??= new Set()).add(callback()) 218 + } 219 + // @ts-expect-error -- WeakMap lookups of null always return undefined, which is fine 220 + mount_callbacks.delete(renderable) 221 + } 222 + 223 + if (endsWereEqual) parentSpan._end = span._end 224 + }, 225 + detach: () => { 226 + switch_renderable(null) 227 + disconnect_root() 228 + }, 229 + } 230 + } 231 + 232 + export function createPropertyPart(node: Node, name: string): Part { 233 + return { 234 + update: value => { 235 + // @ts-expect-error 236 + node[name] = value 237 + }, 238 + detach: () => { 239 + // @ts-expect-error 240 + delete node[name] 241 + }, 242 + } 243 + } 244 + 245 + export function create_attribute_part(node: Element, name: string): Part { 246 + return { 247 + // @ts-expect-error -- setAttribute implicitly casts the value to a string 248 + update: value => node.setAttribute(name, value), 249 + detach: () => node.removeAttribute(name), 250 + } 251 + } 252 + 253 + export type Directive = (node: Element) => Cleanup 254 + 255 + export function create_directive_part(node: Node): Part { 256 + let cleanup: Cleanup 257 + return { 258 + update: fn => { 259 + assert(typeof fn === 'function' || fn == null) 260 + cleanup?.() 261 + cleanup = fn?.(node) 262 + }, 263 + 264 + detach: () => { 265 + cleanup?.() 266 + cleanup = null 267 + }, 268 + } 269 + } 270 + 271 + export function attr_directive(name: string, value: string | boolean | null | undefined): Directive { 272 + return node => { 273 + if (typeof value === 'boolean') node.toggleAttribute(name, value) 274 + else if (value == null) node.removeAttribute(name) 275 + else node.setAttribute(name, value) 276 + return () => { 277 + node.removeAttribute(name) 278 + } 279 + } 280 + }
+91
src/client/root.ts
··· 1 + import { assert, is_html, single_part_template, type Displayable } from '../shared.ts' 2 + import { compile_template, type CompiledTemplate } from './compiler.ts' 3 + import type { Key } from './controller.ts' 4 + import type { Part } from './parts.ts' 5 + import { create_span, span_delete_contents, span_insert_node, type Span } from './span.ts' 6 + 7 + export interface RootPublic { 8 + render(value: Displayable): void 9 + detach(): void 10 + } 11 + export interface Root extends RootPublic { 12 + _span: Span 13 + _key: Key | undefined 14 + } 15 + 16 + export function create_root_into(parent: Node): Root { 17 + const marker = new Text() 18 + parent.appendChild(marker) 19 + return create_root(create_span(marker)) 20 + } 21 + 22 + export function create_root_after(node: Node): Root { 23 + assert(node.parentNode, 'expected a parent node') 24 + const marker = new Text() 25 + node.parentNode.insertBefore(marker, node.nextSibling) 26 + return create_root(create_span(marker)) 27 + } 28 + 29 + export function create_root(span: Span): Root { 30 + let old_template: CompiledTemplate 31 + let parts: [number, Part][] | undefined 32 + 33 + function detach() { 34 + if (!parts) return 35 + // scan through all the parts of the previous tree, and clear any renderables. 36 + for (const [_idx, part] of parts) part.detach() 37 + parts = undefined 38 + } 39 + 40 + return { 41 + _span: span, 42 + _key: undefined, 43 + 44 + render: (value: Displayable) => { 45 + const { _dynamics: dynamics, _statics: statics } = is_html(value) ? value : single_part_template(value) 46 + const template = compile_template(statics) 47 + 48 + assert( 49 + template._parts.length === dynamics.length, 50 + 'expected the same number of dynamics as parts. do you have a ${...} in an unsupported place?', 51 + ) 52 + 53 + if (old_template !== template) { 54 + detach() 55 + 56 + old_template = template 57 + 58 + const doc = old_template._content.cloneNode(true) as DocumentFragment 59 + 60 + const node_by_part: Array<Node | Span> = [] 61 + 62 + for (const node of doc.querySelectorAll('[data-dynparts]')) { 63 + const parts = node.getAttribute('data-dynparts') 64 + assert(parts) 65 + node.removeAttribute('data-dynparts') 66 + // @ts-expect-error -- is part a number, is part a string, who cares? 67 + for (const part of parts.split(' ')) node_by_part[part] = node 68 + } 69 + 70 + for (const part of old_template._root_parts) node_by_part[part] = span 71 + 72 + // the fragment must be inserted before the parts are constructed, 73 + // because they need to know their final location. 74 + // this also ensures that custom elements are upgraded before we do things 75 + // to them, like setting properties or attributes. 76 + span_delete_contents(span) 77 + span_insert_node(span, doc) 78 + 79 + parts = template._parts.map(([dynamic_index, createPart], element_index) => [ 80 + dynamic_index, 81 + createPart(node_by_part[element_index], span), 82 + ]) 83 + } 84 + 85 + assert(parts) 86 + for (const [idx, part] of parts) part.update(dynamics[idx]) 87 + }, 88 + 89 + detach, 90 + } 91 + }
+65
src/client/span.ts
··· 1 + import { assert } from '../shared.ts' 2 + import { is_document_fragment } from './util.ts' 3 + 4 + export interface Span { 5 + _parent: Node 6 + _start: Node 7 + _end: Node 8 + _marker: Node | null 9 + } 10 + 11 + export function create_span(node: Node): Span { 12 + assert(node.parentNode !== null) 13 + return { 14 + _parent: node.parentNode, 15 + _start: node, 16 + _end: node, 17 + _marker: null, 18 + } 19 + } 20 + 21 + export function span_insert_node(span: Span, node: Node): void { 22 + const end = is_document_fragment(node) ? node.lastChild : node 23 + if (end === null) return // empty fragment 24 + span._parent.insertBefore(node, span._end.nextSibling) 25 + span._end = end 26 + 27 + if (span._start === span._marker) { 28 + assert(span._start.nextSibling) 29 + span._start = span._start.nextSibling 30 + 31 + span._parent.removeChild(span._marker) 32 + span._marker = null 33 + } 34 + } 35 + 36 + export function* span_iterator(span: Span): Generator<Node, void, unknown> { 37 + let node = span._start 38 + for (;;) { 39 + const next = node.nextSibling 40 + yield node 41 + if (node === span._end) return 42 + assert(next, 'expected more siblings') 43 + node = next 44 + } 45 + } 46 + 47 + export function span_extract_contents(span: Span): DocumentFragment { 48 + span._marker = new Text() 49 + span._parent.insertBefore(span._marker, span._start) 50 + 51 + const fragment = document.createDocumentFragment() 52 + for (const node of span_iterator(span)) fragment.appendChild(node) 53 + 54 + span._start = span._end = span._marker 55 + return fragment 56 + } 57 + 58 + export function span_delete_contents(span: Span): void { 59 + span._marker = new Text() 60 + span._parent.insertBefore(span._marker, span._start) 61 + 62 + for (const node of span_iterator(span)) span._parent.removeChild(node) 63 + 64 + span._start = span._end = span._marker 65 + }
+12
src/client/util.ts
··· 1 + import type { Renderable } from '../shared.ts' 2 + 3 + export type Cleanup = (() => void) | void | undefined | null 4 + 5 + export const is_element = (node: Node): node is Element => node.nodeType === (1 satisfies typeof Node.ELEMENT_NODE) 6 + 7 + export const is_text = (node: Node): node is Text => node.nodeType === (3 satisfies typeof Node.TEXT_NODE) 8 + 9 + export const is_comment = (node: Node): node is Comment => node.nodeType === (8 satisfies typeof Node.COMMENT_NODE) 10 + 11 + export const is_document_fragment = (node: Node): node is DocumentFragment => 12 + node.nodeType === (11 satisfies typeof Node.DOCUMENT_FRAGMENT_NODE)
-21
src/html.d.ts
··· 1 - import type { BoundTemplateInstance, Cleanup, Directive, Displayable, Key, Renderable, Span } from './types.ts' 2 - 3 - export { Directive, Displayable, Renderable } 4 - 5 - export function html(statics: TemplateStringsArray, ...dynamics: unknown[]): BoundTemplateInstance 6 - export function keyed<T extends Displayable & object>(value: T, key: Key): T 7 - export function invalidate(renderable: Renderable): Promise<void> 8 - export function onMount(renderable: Renderable, callback: () => Cleanup): void 9 - export function onUnmount(renderable: Renderable, callback: () => void): void 10 - export function getParentNode(renderable: Renderable): Node 11 - 12 - export interface Root { 13 - /* @internal */ _span: Span 14 - /* @internal */ _key: unknown 15 - render(value: Displayable): void 16 - detach(): void 17 - } 18 - 19 - export function createRoot(node: Node): Root 20 - 21 - export function attr(name: string, value: string | boolean | null | undefined): Directive
-609
src/html.js
··· 1 - /** @import { 2 - Cleanup, 3 - CompiledTemplate, 4 - Directive, 5 - Displayable, 6 - Key, 7 - Renderable, 8 - Span, 9 - } from './types' */ 10 - 11 - const DEV = typeof DHTML_PROD === 'undefined' || !DHTML_PROD 12 - 13 - /** @type {typeof NodeFilter.SHOW_ELEMENT} */ const NODE_FILTER_ELEMENT = 1 14 - /** @type {typeof NodeFilter.SHOW_TEXT} */ const NODE_FILTER_TEXT = 4 15 - /** @type {typeof NodeFilter.SHOW_COMMENT} */ const NODE_FILTER_COMMENT = 128 16 - 17 - /** @return {node is Element} */ 18 - const isElement = node => node.nodeType === /** @satisfies {typeof Node.ELEMENT_NODE} */ (1) 19 - 20 - /** @return {node is Text} */ 21 - const isText = node => node.nodeType === /** @satisfies {typeof Node.TEXT_NODE} */ (3) 22 - 23 - /** @return {node is Comment} */ 24 - const isComment = node => node.nodeType === /** @satisfies {typeof Node.COMMENT_NODE} */ (8) 25 - 26 - /** @return {node is DocumentFragment} */ 27 - const isDocumentFragment = node => node.nodeType === /** @satisfies {typeof Node.DOCUMENT_FRAGMENT_NODE} */ (11) 28 - 29 - /** @return {value is Renderable} */ 30 - const isRenderable = value => typeof value === 'object' && value !== null && 'render' in value 31 - 32 - /** @return {value is Iterable<unknown>} */ 33 - const isIterable = value => typeof value === 'object' && value !== null && Symbol.iterator in value 34 - 35 - /** @return {value is ReturnType<typeof html>} */ 36 - const isHtml = value => value?.$ === html 37 - 38 - export function html(/** @type {TemplateStringsArray} */ statics, /** @type {unknown[]} */ ...dynamics) { 39 - /** @type {CompiledTemplate} */ let template 40 - 41 - if (DEV) { 42 - assert( 43 - compileTemplate(statics)._parts.length === dynamics.length, 44 - 'expected the same number of dynamics as parts. do you have a ${...} in an unsupported place?', 45 - ) 46 - } 47 - 48 - return { 49 - $: html, 50 - _dynamics: dynamics, 51 - get _template() { 52 - return (template ??= compileTemplate(statics)) 53 - }, 54 - } 55 - } 56 - 57 - const singlePartTemplate = part => html`${part}` 58 - 59 - /* v8 ignore start */ 60 - /** @return {asserts value} */ 61 - function assert(value, message) { 62 - if (!DEV) return 63 - if (!value) throw new Error(message ?? 'assertion failed') 64 - } 65 - /* v8 ignore stop */ 66 - 67 - /** @returns {Span} */ 68 - function createSpan(node) { 69 - DEV: assert(node.parentNode !== null) 70 - return { 71 - _parentNode: node.parentNode, 72 - _start: node, 73 - _end: node, 74 - _marker: null, 75 - } 76 - } 77 - 78 - function spanInsertNode(/** @type {Span} */ span, /** @type {Node} */ node) { 79 - const end = isDocumentFragment(node) ? node.lastChild : node 80 - if (end === null) return // empty fragment 81 - span._parentNode.insertBefore(node, span._end.nextSibling) 82 - span._end = end 83 - 84 - if (span._start === span._marker) { 85 - DEV: assert(span._start.nextSibling) 86 - span._start = span._start.nextSibling 87 - 88 - span._parentNode.removeChild(span._marker) 89 - span._marker = null 90 - } 91 - } 92 - 93 - function* spanIterator(/** @type {Span} */ span) { 94 - let node = span._start 95 - for (;;) { 96 - const next = node.nextSibling 97 - yield node 98 - if (node === span._end) return 99 - assert(next, 'expected more siblings') 100 - node = next 101 - } 102 - } 103 - 104 - function spanExtractContents(/** @type {Span} */ span) { 105 - span._marker = new Text() 106 - span._parentNode.insertBefore(span._marker, span._start) 107 - 108 - const fragment = document.createDocumentFragment() 109 - for (const node of spanIterator(span)) fragment.appendChild(node) 110 - 111 - span._start = span._end = span._marker 112 - return fragment 113 - } 114 - 115 - function spanDeleteContents(/** @type {Span} */ span) { 116 - span._marker = new Text() 117 - span._parentNode.insertBefore(span._marker, span._start) 118 - 119 - for (const node of spanIterator(span)) span._parentNode.removeChild(node) 120 - 121 - span._start = span._end = span._marker 122 - } 123 - 124 - function createRootInto(/** @type {ParentNode} */ parent) { 125 - const marker = new Text() 126 - parent.appendChild(marker) 127 - return createRoot(createSpan(marker)) 128 - } 129 - export { createRootInto as createRoot } 130 - 131 - function createRootAfter(/** @type {Node} */ node) { 132 - DEV: assert(node.parentNode, 'expected a parent node') 133 - const marker = new Text() 134 - node.parentNode.insertBefore(marker, node.nextSibling) 135 - return createRoot(createSpan(marker)) 136 - } 137 - 138 - function createRoot(/** @type {Span} */ span) { 139 - let template, parts 140 - 141 - function detach() { 142 - if (!parts) return 143 - // scan through all the parts of the previous tree, and clear any renderables. 144 - for (const [_idx, part] of parts) part.detach() 145 - parts = undefined 146 - } 147 - 148 - return { 149 - _span: span, 150 - /** @type {Key | undefined} */ _key: undefined, 151 - 152 - render: value => { 153 - const html = isHtml(value) ? value : singlePartTemplate(value) 154 - 155 - if (template !== html._template) { 156 - detach() 157 - 158 - template = html._template 159 - 160 - const doc = /** @type {DocumentFragment} */ (template._content.cloneNode(true)) 161 - 162 - const nodeByPart = [] 163 - for (const node of doc.querySelectorAll('[data-dynparts]')) { 164 - const parts = node.getAttribute('data-dynparts') 165 - assert(parts) 166 - node.removeAttribute('data-dynparts') 167 - for (const part of parts.split(' ')) nodeByPart[part] = node 168 - } 169 - 170 - for (const part of template._rootParts) nodeByPart[part] = span 171 - 172 - // the fragment must be inserted before the parts are constructed, 173 - // because they need to know their final location. 174 - // this also ensures that custom elements are upgraded before we do things 175 - // to them, like setting properties or attributes. 176 - spanDeleteContents(span) 177 - spanInsertNode(span, doc) 178 - 179 - parts = html._template._parts.map(([dynamicIdx, createPart], elementIdx) => [ 180 - dynamicIdx, 181 - createPart(nodeByPart[elementIdx], span), 182 - ]) 183 - } 184 - 185 - for (const [idx, part] of parts) part.update(html._dynamics[idx]) 186 - }, 187 - 188 - detach, 189 - } 190 - } 191 - 192 - const DYNAMIC_WHOLE = /^dyn-\$(\d+)\$$/i 193 - const DYNAMIC_GLOBAL = /dyn-\$(\d+)\$/gi 194 - const FORCE_ATTRIBUTES = /-|^class$|^for$/i 195 - 196 - /** @type {Map<TemplateStringsArray, CompiledTemplate>} */ 197 - const templates = new Map() 198 - function compileTemplate(/** @type {TemplateStringsArray} */ statics) { 199 - const cached = templates.get(statics) 200 - if (cached) return cached 201 - 202 - const templateElement = document.createElement('template') 203 - templateElement.innerHTML = statics.reduce((a, v, i) => a + v + (i === statics.length - 1 ? '' : `dyn-$${i}$`), '') 204 - 205 - let nextPart = 0 206 - /** @type {CompiledTemplate} */ 207 - const compiled = { 208 - _content: templateElement.content, 209 - _parts: Array(statics.length - 1), 210 - _rootParts: [], 211 - } 212 - 213 - function patch(node, idx, createPart) { 214 - DEV: assert(nextPart < compiled._parts.length, 'got more parts than expected') 215 - if (isDocumentFragment(node)) compiled._rootParts.push(nextPart) 216 - else if ('dynparts' in node.dataset) node.dataset.dynparts += ' ' + nextPart 217 - else node.dataset.dynparts = nextPart 218 - compiled._parts[nextPart++] = [idx, createPart] 219 - } 220 - 221 - const walker = document.createTreeWalker( 222 - templateElement.content, 223 - NODE_FILTER_TEXT | NODE_FILTER_ELEMENT | (DEV ? NODE_FILTER_COMMENT : 0), 224 - ) 225 - // stop iterating once we've hit the last part, but if we're in dev mode, keep going to check for mistakes. 226 - while ((nextPart < compiled._parts.length || DEV) && walker.nextNode()) { 227 - const node = /** @type {Text | Element | Comment} */ (walker.currentNode) 228 - if (isText(node)) { 229 - // reverse the order because we'll be supplying ChildPart with its index in the parent node. 230 - // and if we apply the parts forwards, indicies will be wrong if some prior part renders more than one node. 231 - // also reverse it because that's the correct order for splitting. 232 - const nodes = [...node.data.matchAll(DYNAMIC_GLOBAL)].reverse().map(match => { 233 - node.splitText(match.index + match[0].length) 234 - const dyn = new Comment() 235 - node.splitText(match.index).replaceWith(dyn) 236 - return /** @type {const} */ ([dyn, parseInt(match[1])]) 237 - }) 238 - 239 - if (nodes.length) { 240 - DEV: assert(node.parentNode !== null, 'all text nodes should have a parent node') 241 - let siblings = [...node.parentNode.childNodes] 242 - for (const [node, idx] of nodes) { 243 - const child = siblings.indexOf(node) 244 - patch(node.parentNode, idx, (node, span) => createChildPart(node, span, child)) 245 - } 246 - } 247 - } else if (DEV && isComment(node)) { 248 - // just in dev, stub out a fake part for every interpolation in a comment. 249 - // this means you can comment out code inside a template and not run into 250 - // issues with incorrect part counts. 251 - // in production the check is skipped, so we can also skip this. 252 - for (const _match of node.data.matchAll(DYNAMIC_GLOBAL)) { 253 - compiled._parts[nextPart++] = [parseInt(_match[1]), () => ({ update() {}, detach() {} })] 254 - } 255 - } else { 256 - assert(isElement(node)) 257 - const toRemove = [] 258 - for (let name of node.getAttributeNames()) { 259 - const value = node.getAttribute(name) 260 - assert(value !== null) 261 - 262 - let match = DYNAMIC_WHOLE.exec(name) 263 - if (match !== null) { 264 - // directive: 265 - toRemove.push(name) 266 - DEV: assert(value === '', `directives must not have values`) 267 - patch(node, parseInt(match[1]), node => createDirectivePart(node)) 268 - } else { 269 - // properties: 270 - match = DYNAMIC_WHOLE.exec(value) 271 - if (match !== null) { 272 - toRemove.push(name) 273 - if (FORCE_ATTRIBUTES.test(name)) { 274 - patch(node, parseInt(match[1]), node => createAttributePart(node, name)) 275 - } else { 276 - if (!(name in node)) { 277 - for (const property in node) { 278 - if (property.toLowerCase() === name) { 279 - name = property 280 - break 281 - } 282 - } 283 - } 284 - patch(node, parseInt(match[1]), node => createPropertyPart(node, name)) 285 - } 286 - } else if (DEV) { 287 - assert(!DYNAMIC_GLOBAL.test(value), `expected a whole dynamic value for ${name}, got a partial one`) 288 - } 289 - } 290 - } 291 - for (const name of toRemove) node.removeAttribute(name) 292 - } 293 - } 294 - 295 - compiled._parts.length = nextPart 296 - 297 - templates.set(statics, compiled) 298 - return compiled 299 - } 300 - 301 - /** @type {WeakMap<object, { 302 - _mounted: boolean 303 - _invalidateQueued: Promise<void> | null 304 - _invalidate: () => void 305 - _unmountCallbacks: Set<Cleanup> | null 306 - _parentNode: Node 307 - }>} */ 308 - const controllers = new WeakMap() 309 - 310 - export function invalidate(renderable) { 311 - const controller = controllers.get(renderable) 312 - assert(controller, 'the renderable has not been rendered') 313 - return (controller._invalidateQueued ??= Promise.resolve().then(() => { 314 - controller._invalidateQueued = null 315 - controller._invalidate() 316 - })) 317 - } 318 - 319 - /** @type {WeakMap<Renderable, Set<() => Cleanup>>} */ 320 - const mountCallbacks = new WeakMap() 321 - 322 - export function onMount(renderable, callback) { 323 - DEV: assert(isRenderable(renderable), 'expected a renderable') 324 - 325 - const controller = controllers.get(renderable) 326 - if (controller?._mounted) { 327 - ;(controller._unmountCallbacks ??= new Set()).add(callback()) 328 - return 329 - } 330 - 331 - let cb = mountCallbacks.get(renderable) 332 - if (!cb) mountCallbacks.set(renderable, (cb = new Set())) 333 - cb.add(callback) 334 - } 335 - 336 - export function onUnmount(renderable, callback) { 337 - onMount(renderable, () => callback) 338 - } 339 - 340 - export function getParentNode(renderable) { 341 - const controller = controllers.get(renderable) 342 - assert(controller, 'the renderable has not been rendered') 343 - return controller._parentNode 344 - } 345 - 346 - const keys = new WeakMap() 347 - export function keyed(renderable, key) { 348 - if (DEV && keys.has(renderable)) throw new Error('renderable already has a key') 349 - keys.set(renderable, key) 350 - return renderable 351 - } 352 - 353 - function createChildPart( 354 - /** @type {Node | Span} */ parentNode, 355 - /** @type {Span} */ parentSpan, 356 - /** @type {number} */ childIndex, 357 - ) { 358 - let span 359 - 360 - // for when we're rendering a renderable: 361 - /** @type {Renderable | null} */ let renderable = null 362 - 363 - // for when we're rendering a template: 364 - /** @type {ReturnType<typeof createRoot> | undefined} */ let root 365 - 366 - // for when we're rendering multiple values: 367 - /** @type {ReturnType<typeof createRoot>[] | undefined} */ let roots 368 - 369 - // for when we're rendering a string/single dom node: 370 - /** undefined means no previous value, because a user-specified undefined is remapped to null */ 371 - let prevValue 372 - 373 - function switchRenderable(/** @type {Renderable | null} */ next) { 374 - if (renderable && renderable !== next) { 375 - const controller = controllers.get(renderable) 376 - if (controller?._unmountCallbacks) for (const callback of controller._unmountCallbacks) callback?.() 377 - controllers.delete(renderable) 378 - } 379 - renderable = next 380 - } 381 - 382 - function disconnectRoot() { 383 - // root.detach and part.detach are mutually recursive, so this detaches children too. 384 - root?.detach() 385 - root = undefined 386 - } 387 - 388 - if (parentNode instanceof Node) { 389 - const child = parentNode.childNodes[childIndex] 390 - span = createSpan(child) 391 - } else { 392 - let child = parentNode._start 393 - for (let i = 0; i < childIndex; i++) { 394 - DEV: { 395 - assert(child.nextSibling !== null, 'expected more siblings') 396 - assert(child.nextSibling !== parentNode._end, 'ran out of siblings before the end') 397 - } 398 - child = child.nextSibling 399 - } 400 - span = createSpan(child) 401 - } 402 - 403 - return { 404 - update: function update(/** @type {Displayable} */ value) { 405 - DEV: assert(span) 406 - const endsWereEqual = span._parentNode === parentSpan._parentNode && span._end === parentSpan._end 407 - 408 - if (isRenderable(value)) { 409 - switchRenderable(value) 410 - 411 - const renderable = value 412 - 413 - if (!controllers.has(renderable)) 414 - controllers.set(renderable, { 415 - _mounted: false, 416 - _invalidateQueued: null, 417 - _invalidate: () => { 418 - DEV: assert(renderable === renderable, 'could not invalidate an outdated renderable') 419 - update(renderable) 420 - }, 421 - _unmountCallbacks: null, // will be upgraded to a Set if needed. 422 - _parentNode: span._parentNode, 423 - }) 424 - 425 - try { 426 - value = renderable.render() 427 - } catch (thrown) { 428 - if (isHtml(thrown)) { 429 - value = thrown 430 - } else { 431 - throw thrown 432 - } 433 - } 434 - 435 - // if render returned another renderable, we want to track/cache both renderables individually. 436 - // wrap it in a nested ChildPart so that each can be tracked without ChildPart having to handle multiple renderables. 437 - if (isRenderable(value)) value = singlePartTemplate(value) 438 - } else switchRenderable(null) 439 - 440 - // if it's undefined, swap the value for null. 441 - // this means if the initial value is undefined, 442 - // it won't conflict with prevValue's default of undefined, 443 - // so it'll still render. 444 - if (value === undefined) value = null 445 - 446 - // NOTE: we're explicitly not caching/diffing the value when it's an iterable, 447 - // given it can yield different values but have the same identity. (e.g. arrays) 448 - if (isIterable(value)) { 449 - if (!roots) { 450 - // we previously rendered a single value, so we need to clear it. 451 - disconnectRoot() 452 - spanDeleteContents(span) 453 - 454 - roots = [] 455 - } 456 - 457 - // create or update a root for every item. 458 - let i = 0 459 - let end = span._start 460 - for (const item of value) { 461 - // @ts-expect-error -- WeakMap lookups of non-objects always return undefined, which is fine 462 - const key = keys.get(item) ?? item 463 - let root = (roots[i] ??= createRootAfter(end)) 464 - 465 - if (key !== undefined && root._key !== key) { 466 - const j = roots.findIndex(r => r._key === key) 467 - root._key = key 468 - if (j !== -1) { 469 - const root1 = root 470 - const root2 = roots[j] 471 - 472 - // swap the contents of the spans 473 - const tmpContent = spanExtractContents(root1._span) 474 - spanInsertNode(root1._span, spanExtractContents(root2._span)) 475 - spanInsertNode(root2._span, tmpContent) 476 - 477 - // swap the spans back 478 - const tmpSpan = root1._span 479 - root1._span = root2._span 480 - root2._span = tmpSpan 481 - 482 - // swap the roots 483 - roots[j] = root1 484 - root = roots[i] = root2 485 - } 486 - } 487 - 488 - root.render(item) 489 - end = root._span._end 490 - i++ 491 - } 492 - 493 - // and now remove excess roots if the iterable has shrunk. 494 - while (roots.length > i) { 495 - const root = roots.pop() 496 - assert(root) 497 - root.detach() 498 - spanDeleteContents(root._span) 499 - } 500 - 501 - span._end = end 502 - 503 - const controller = controllers.get(renderable) 504 - if (controller) { 505 - controller._mounted = true 506 - // @ts-expect-error -- WeakMap lookups of null always return undefined, which is fine 507 - for (const callback of mountCallbacks.get(renderable) ?? []) { 508 - ;(controller._unmountCallbacks ??= new Set()).add(callback()) 509 - } 510 - // @ts-expect-error -- WeakMap lookups of null always return undefined, which is fine 511 - mountCallbacks.delete(renderable) 512 - } 513 - 514 - if (endsWereEqual) parentSpan._end = span._end 515 - 516 - return 517 - } else if (roots) { 518 - for (const root of roots) root.detach() 519 - roots = undefined 520 - } 521 - 522 - // now early return if the value hasn't changed. 523 - if (Object.is(prevValue, value)) return 524 - 525 - if (isHtml(value)) { 526 - root ??= createRoot(span) 527 - root.render(value) // root.render will detach the previous tree if the template has changed. 528 - } else { 529 - // if we previously rendered a tree that might contain renderables, 530 - // and the template has changed (or we're not even rendering a template anymore), 531 - // we need to clear the old renderables. 532 - disconnectRoot() 533 - 534 - if (prevValue != null && value !== null && !(prevValue instanceof Node) && !(value instanceof Node)) { 535 - // we previously rendered a string, and we're rendering a string again. 536 - DEV: assert(span._start === span._end && span._start instanceof Text) 537 - span._start.data = '' + value 538 - } else { 539 - spanDeleteContents(span) 540 - if (value !== null) spanInsertNode(span, value instanceof Node ? value : new Text('' + value)) 541 - } 542 - } 543 - 544 - prevValue = value 545 - 546 - const controller = controllers.get(renderable) 547 - if (controller) { 548 - controller._mounted = true 549 - // @ts-expect-error -- WeakMap lookups of null always return undefined, which is fine 550 - for (const callback of mountCallbacks.get(renderable) ?? []) { 551 - ;(controller._unmountCallbacks ??= new Set()).add(callback()) 552 - } 553 - // @ts-expect-error -- WeakMap lookups of null always return undefined, which is fine 554 - mountCallbacks.delete(renderable) 555 - } 556 - 557 - if (endsWereEqual) parentSpan._end = span._end 558 - }, 559 - detach: () => { 560 - switchRenderable(null) 561 - disconnectRoot() 562 - }, 563 - } 564 - } 565 - 566 - function createPropertyPart(node, name) { 567 - return { 568 - update: value => { 569 - node[name] = value 570 - }, 571 - detach: () => { 572 - delete node[name] 573 - }, 574 - } 575 - } 576 - 577 - function createAttributePart(node, name) { 578 - return { 579 - update: value => node.setAttribute(name, value), 580 - detach: () => node.removeAttribute(name), 581 - } 582 - } 583 - 584 - function createDirectivePart(node) { 585 - /** @type {Cleanup} */ let cleanup 586 - return { 587 - update: fn => { 588 - cleanup?.() 589 - cleanup = fn?.(node) 590 - }, 591 - 592 - detach: () => { 593 - cleanup?.() 594 - cleanup = null 595 - }, 596 - } 597 - } 598 - 599 - /** @returns {Directive} */ 600 - export function attr(name, value) { 601 - return node => { 602 - if (typeof value === 'boolean') node.toggleAttribute(name, value) 603 - else if (value == null) node.removeAttribute(name) 604 - else node.setAttribute(name, value) 605 - return () => { 606 - node.removeAttribute(name) 607 - } 608 - } 609 - }
+24 -58
src/html.server.ts src/server.ts
··· 1 - import type { Displayable, Renderable } from './types.ts' 2 1 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 */ 2 + import { assert, is_html, is_iterable, is_renderable, single_part_template, type Displayable } from './shared.ts' 23 3 24 4 type PartRenderer = (values: unknown[]) => string | Generator<string, void, void> 25 5 ··· 28 8 parts: PartRenderer[] 29 9 } 30 10 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 11 const WHITESPACE_WHOLE = /^\s+$/ 47 12 const DYNAMIC_WHOLE = /^dyn-\$(\d+)\$$/i 48 13 const DYNAMIC_GLOBAL = /dyn-\$(\d+)\$/gi 49 14 50 15 const templates = new WeakMap<TemplateStringsArray, CompiledTemplate>() 51 - function compileTemplate(statics: TemplateStringsArray): CompiledTemplate { 16 + function compile_template(statics: TemplateStringsArray): CompiledTemplate { 52 17 const cached = templates.get(statics) 53 18 if (cached) return cached 54 19 ··· 69 34 const match = name.match(DYNAMIC_WHOLE) 70 35 if (match) { 71 36 const idx = parseInt(match[1]) 72 - parts.push({ start, end, render: values => renderDirective(values[idx]) }) 37 + parts.push({ start, end, render: values => render_directive(values[idx]) }) 73 38 return 74 39 } 75 40 ··· 87 52 const match = value.match(DYNAMIC_WHOLE) 88 53 if (match) { 89 54 const idx = parseInt(match[1]) 90 - parts.push({ start: nameStart, end, render: values => renderAttribute(name, values[idx]) }) 55 + parts.push({ start: nameStart, end, render: values => render_attribute(name, values[idx]) }) 91 56 return 92 57 } 93 58 ··· 111 76 parts.push({ 112 77 start: start + match.index, 113 78 end: start + match.index + match[0].length, 114 - render: values => renderChild(values[idx]), 79 + render: values => render_child(values[idx]), 115 80 }) 116 81 } 117 82 ··· 155 120 156 121 for (let i = 0; i < parts.length; i++) { 157 122 const part = parts[i] 158 - const nextPart = parts[i + 1] 123 + const next_part = parts[i + 1] 159 124 compiled.parts.push(part.render) 160 - compiled.statics.push(html.slice(part.end, nextPart?.start)) 125 + compiled.statics.push(html.slice(part.end, next_part?.start)) 161 126 } 162 127 163 128 templates.set(statics, compiled) 164 129 return compiled 165 130 } 166 131 167 - function renderDirective(value: unknown) { 132 + function render_directive(value: unknown) { 168 133 if (value === null) return '' 169 134 170 135 assert(typeof value === 'function') ··· 173 138 return '' 174 139 } 175 140 176 - function renderAttribute(name: string, value: unknown) { 141 + function render_attribute(name: string, value: unknown) { 177 142 if (value === false || value === null || typeof value === 'function') { 178 143 return '' 179 144 } ··· 181 146 return `${name}="${escape(value)}"` 182 147 } 183 148 184 - function* renderChild(value: unknown) { 149 + function* render_child(value: unknown) { 185 150 const seen = new Set() 186 151 187 - while (isRenderable(value)) 152 + while (is_renderable(value)) 188 153 try { 189 154 if (seen.has(value)) throw new Error('circular render') 190 155 seen.add(value) 191 156 value = value.render() 192 157 } catch (thrown) { 193 - if (thrown instanceof BoundTemplateInstance) { 158 + if (is_html(thrown)) { 194 159 value = thrown 195 160 } else { 196 161 throw thrown 197 162 } 198 163 } 199 164 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) 165 + if (is_iterable(value)) { 166 + for (const item of value) yield* render_to_iterable(item as Displayable) 167 + } else if (is_html(value)) { 168 + yield* render_to_iterable(value) 204 169 } else if (value !== null) { 205 170 yield escape(value) 206 171 } ··· 215 180 "'": '&#39;', 216 181 } 217 182 function escape(str: unknown) { 218 - return String(str).replace(ESCAPE_RE, c => ESCAPE_SUBSTITUTIONS[c]) 183 + return String(str).replace(ESCAPE_RE, c => ESCAPE_SUBSTITUTIONS[c as keyof typeof ESCAPE_SUBSTITUTIONS]) 219 184 } 220 185 221 - function* renderToIterable(value: Displayable) { 222 - const { template, dynamics } = value instanceof BoundTemplateInstance ? value : singlePartTemplate(value) 186 + function* render_to_iterable(value: Displayable) { 187 + const { _statics: statics, _dynamics: dynamics } = is_html(value) ? value : single_part_template(value) 188 + const template = compile_template(statics) 223 189 224 190 for (let i = 0; i < template.statics.length - 1; i++) { 225 191 yield template.statics[i] ··· 228 194 yield template.statics[template.statics.length - 1] 229 195 } 230 196 231 - export function renderToString(value: Displayable) { 197 + export function renderToString(value: Displayable): string { 232 198 let str = '' 233 - for (const part of renderToIterable(value)) str += part 199 + for (const part of render_to_iterable(value)) str += part 234 200 return str 235 201 } 236 202 237 - export function renderToReadableStream(value: Displayable) { 238 - const iter = renderToIterable(value)[Symbol.iterator]() 203 + export function renderToReadableStream(value: Displayable): ReadableStream { 204 + const iter = render_to_iterable(value)[Symbol.iterator]() 239 205 return new ReadableStream({ 240 206 pull(controller) { 241 207 const { done, value } = iter.next()
+1
src/index.ts
··· 1 + export { html, type Displayable, type Renderable } from './shared.ts'
+45
src/shared.ts
··· 1 + interface ToString { 2 + toString(): string 3 + } 4 + 5 + export type Displayable = null | undefined | ToString | Node | Renderable | Iterable<Displayable> 6 + export interface Renderable { 7 + render(): Displayable 8 + } 9 + 10 + export const is_renderable = (value: unknown): value is Renderable => 11 + typeof value === 'object' && value !== null && 'render' in value 12 + 13 + export const is_iterable = (value: unknown): value is Iterable<unknown> => 14 + typeof value === 'object' && value !== null && Symbol.iterator in value 15 + 16 + declare global { 17 + const __DEV__: boolean 18 + } 19 + 20 + /* v8 ignore start */ 21 + export function assert(value: unknown, message?: string): asserts value { 22 + if (!__DEV__) return 23 + if (!value) throw new Error(message ?? 'assertion failed') 24 + } 25 + /* v8 ignore stop */ 26 + 27 + const tag: unique symbol = Symbol() 28 + 29 + interface HTML { 30 + [tag]: true 31 + /* @internal */ _statics: TemplateStringsArray 32 + /* @internal */ _dynamics: unknown[] 33 + } 34 + 35 + export const is_html = (value: any): value is HTML => typeof value === 'object' && value !== null && tag in value 36 + 37 + export function html(statics: TemplateStringsArray, ...dynamics: unknown[]): HTML { 38 + return { 39 + [tag]: true, 40 + _dynamics: dynamics, 41 + _statics: statics, 42 + } 43 + } 44 + 45 + export const single_part_template = (part: Displayable): HTML => html`${part}`
-39
src/types.ts
··· 1 - declare global { 2 - const DHTML_PROD: unknown 3 - } 4 - 5 - interface ToString { 6 - toString(): string 7 - } 8 - 9 - export type Displayable = null | undefined | ToString | Node | Renderable | Iterable<Displayable> 10 - export interface Renderable { 11 - render(): Displayable 12 - } 13 - 14 - export declare class BoundTemplateInstance { 15 - #private 16 - } 17 - 18 - export type Key = string | number | bigint | boolean | symbol | object | null 19 - 20 - export interface Span { 21 - _parentNode: Node 22 - _start: Node 23 - _end: Node 24 - _marker: Node | null 25 - } 26 - 27 - interface Part { 28 - update(value: unknown): void 29 - detach(): void 30 - } 31 - 32 - export type Cleanup = (() => void) | void | undefined | null 33 - export type Directive = (node: Element) => Cleanup 34 - 35 - export interface CompiledTemplate { 36 - _content: DocumentFragment 37 - _parts: [idx: number, createPart: (node: Node | Span, span: Span) => Part][] 38 - _rootParts: number[] 39 - }
test/attributes.test.ts src/client/tests/attributes.test.ts
+1 -1
test/basic.test.ts src/client/tests/basic.test.ts
··· 1 1 import { html, type Displayable } from 'dhtml' 2 - import { describe, expect, it, vi } from 'vitest' 2 + import { describe, expect, it } from 'vitest' 3 3 import { setup } from './setup' 4 4 5 5 describe('basic', () => {
+2 -1
test/custom-elements.test.ts src/client/tests/custom-elements.test.ts
··· 1 - import { createRoot, html } from 'dhtml' 1 + import { html } from 'dhtml' 2 + import { createRoot } from 'dhtml/client' 2 3 import { describe, expect, it } from 'vitest' 3 4 import { setup } from './setup' 4 5
+2 -1
test/directives.test.ts src/client/tests/directives.test.ts
··· 1 - import { attr, html, type Directive } from 'dhtml' 1 + import { html } from 'dhtml' 2 + import { attr, type Directive } from 'dhtml/client' 2 3 import { describe, expect, it } from 'vitest' 3 4 import { setup } from './setup' 4 5
+2 -1
test/lists.test.ts src/client/tests/lists.test.ts
··· 1 - import { html, keyed, type Displayable } from 'dhtml' 1 + import { html, type Displayable } from 'dhtml' 2 + import { keyed } from 'dhtml/client' 2 3 import { describe, expect, it } from 'vitest' 3 4 import { setup } from './setup' 4 5
test/recursion.test.ts src/client/tests/recursion.test.ts
+2 -1
test/renderable.test.ts src/client/tests/renderable.test.ts
··· 1 - import { getParentNode, html, invalidate, onMount, onUnmount, type Renderable } from 'dhtml' 1 + import { html, type Renderable } from 'dhtml' 2 + import { getParentNode, invalidate, onMount, onUnmount } from 'dhtml/client' 2 3 import { describe, expect, it, vi } from 'vitest' 3 4 import { setup } from './setup' 4 5
+3 -3
test/setup.ts src/client/tests/setup.ts
··· 1 1 /// <reference types='vite/client' /> 2 2 /// <reference types='@vitest/browser/providers/playwright' /> 3 3 4 - import '../reset.css' 4 + import '../../../reset.css' 5 5 6 - import { createRoot, type Root } from 'dhtml' 6 + import { createRoot, type Root } from 'dhtml/client' 7 7 import { afterEach, expect } from 'vitest' 8 8 9 9 const roots: Root[] = [] 10 10 11 - export function setup(initialHtml = '') { 11 + export function setup(initialHtml = ''): { root: Root; el: HTMLDivElement } { 12 12 const state = expect.getState() 13 13 const parentEl = document.createElement('div') 14 14 Object.assign(parentEl.style, {
-8
test/tsconfig.json
··· 1 - { 2 - "extends": "../tsconfig.json", 3 - "compilerOptions": { 4 - "noImplicitAny": true, 5 - "target": "esnext" 6 - }, 7 - "include": ["."] 8 - }
+6 -4
tsconfig.json
··· 1 1 { 2 2 "compilerOptions": { 3 3 "strict": true, 4 - "noImplicitAny": false, 5 - "checkJs": true, 4 + "noImplicitAny": true, 6 5 "skipLibCheck": true, 7 6 "noEmit": true, 8 7 "module": "es2020", 9 8 "target": "es2020", 10 9 "verbatimModuleSyntax": true, 11 - "allowUnusedLabels": true, // esbuild drops DEV labeled code in the prod build 12 - "moduleResolution": "bundler" 10 + "moduleResolution": "bundler", 11 + "allowImportingTsExtensions": true, 12 + "stripInternal": true, 13 + "isolatedDeclarations": true, 14 + "declaration": true 13 15 }, 14 16 "include": ["src"] 15 17 }
+3 -7
vitest.config.js
··· 1 1 import { defineConfig } from 'vitest/config' 2 2 3 - const prod = !!process.env.PROD 4 - 5 3 export default defineConfig({ 6 - resolve: { 7 - alias: { dhtml: new URL(prod ? 'dist/html.min.js' : 'src/html.js', import.meta.url) }, 8 - }, 9 4 define: { 10 - DHTML_PROD: prod, 5 + __DEV__: !process.env.PROD, 11 6 }, 12 7 test: { 13 8 clearMocks: true, ··· 15 10 enabled: true, 16 11 reporter: ['text', 'json-summary', 'json', 'html'], 17 12 reportOnFailure: true, 18 - include: ['src/html.js'], 13 + include: ['src/client/**', 'src/client.ts'], 14 + exclude: ['**/tests'], 19 15 }, 20 16 browser: { 21 17 enabled: true,