├── .eslintrc ├── .github └── workflows │ ├── coverage.yml │ ├── lint.yml │ ├── publish.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── babel.config.js ├── conf └── typescript │ ├── base.json │ ├── commonjs.json │ └── es.json ├── jest.config.ts ├── logo-dark.svg ├── logo-light.svg ├── package-lock.json ├── package.json ├── sample ├── index.html └── index.js ├── src ├── build.ts ├── cache.ts ├── extensions │ ├── functional-events.ext.ts │ ├── index.ts │ ├── object-props.ext.ts │ ├── ref.ext.ts │ └── test │ │ ├── functional-events.ext.test.ts │ │ ├── object-props.ext.test.ts │ │ └── ref.ext.test.ts ├── factory │ ├── dom.ts │ ├── extension.ts │ ├── index.ts │ ├── null.ts │ ├── test │ │ ├── factory.test.ts │ │ └── null.test.ts │ └── types.ts ├── index.ts ├── re.ts ├── template │ ├── build.ts │ ├── errors.ts │ ├── extension.ts │ ├── index.ts │ ├── recipe.ts │ ├── slot.ts │ └── test │ │ └── template.test.ts ├── test │ ├── build.test.ts │ ├── exports.test.ts │ ├── no-window.ssr-test.ts │ └── re.test.ts └── types.ts └── tsconfig.json /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ "@typescript-eslint"], 5 | "ignorePatterns": ["dist/**/*"], 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | "env": { 11 | "browser": true, 12 | "node": true, 13 | "es6": true 14 | }, 15 | "rules": { 16 | "quotes": ["warn", "single", {"avoidEscape": true}], 17 | "curly": "warn", 18 | "no-unused-vars": "off", 19 | "no-unused-expressions": [ 20 | "error", { 21 | "allowShortCircuit": true, 22 | "allowTernary": true 23 | } 24 | ], 25 | "no-shadow": "warn", 26 | "prefer-const": "warn", 27 | "eqeqeq": "warn", 28 | "prefer-spread": "warn", 29 | "prefer-object-spread": "warn", 30 | "indent": ["warn", 2], 31 | "newline-before-return": "warn", 32 | "eol-last": "warn", 33 | "semi": ["warn", "never"], 34 | "no-trailing-spaces": "warn", 35 | "@typescript-eslint/no-explicit-any": "off", 36 | "@typescript-eslint/adjacent-overload-signatures": "warn", 37 | "@typescript-eslint/no-empty-function": "off", 38 | "@typescript-eslint/no-non-null-assertion": "off", 39 | "@typescript-eslint/no-empty-interface": "warn", 40 | "@typescript-eslint/explicit-module-boundary-types": "off", 41 | "@typescript-eslint/no-inferrable-types": "warn", 42 | "@typescript-eslint/restrict-plus-operands": "off", 43 | "@typescript-eslint/restrict-template-expressions": "off", 44 | "@typescript-eslint/no-this-alias": "off", 45 | "@typescript-eslint/no-unused-vars": ["warn", { 46 | "argsIgnorePattern": "^_|^renderer$", 47 | "varsIgnorePattern": "^_" 48 | }], 49 | "@typescript-eslint/ban-types": "off", 50 | "@typescript-eslint/no-var-requires": "off" 51 | }, 52 | "overrides": [ 53 | { 54 | "files": ["src/**/*.test.ts", "src/**/*.test.tsx"], 55 | "rules": { 56 | "no-unused-expressions": "off" 57 | } 58 | } 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: coverage 2 | on: 3 | push: 4 | branches: ['*'] 5 | pull_request: 6 | branches: ['*'] 7 | jobs: 8 | run: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | 14 | - uses: actions/setup-node@v2 15 | with: 16 | node-version: '16' 17 | 18 | - name: Install Dependencies 19 | run: npm ci 20 | 21 | - name: Check Coverage 22 | run: npm run coverage 23 | 24 | 25 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | on: 3 | push: 4 | branches: ['*'] 5 | pull_request: 6 | branches: ['*'] 7 | jobs: 8 | run: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | 14 | - uses: actions/setup-node@v2 15 | with: 16 | node-version: '16' 17 | 18 | - name: Install Dependencies 19 | run: npm ci 20 | 21 | - name: Check Linting 22 | run: npm run lint 23 | 24 | - name: Check Typings 25 | run: npm run typecheck 26 | 27 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | run: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v2 12 | 13 | - uses: actions/setup-node@v2 14 | with: 15 | node-version: '16' 16 | 17 | - name: Install Dependencies 18 | run: npm ci 19 | 20 | - name: Check Coverage 21 | run: npm run coverage 22 | 23 | - name: Check Linting 24 | run: npm run lint 25 | 26 | - name: Fix README for NPM 27 | run: sed -i.tmp -e '9d' README.md && rm README.md.tmp 28 | 29 | - name: Publish 30 | uses: JS-DevTools/npm-publish@v1 31 | with: 32 | token: ${{ secrets.NPM_AUTH_TOKEN }} 33 | 34 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | on: 3 | push: 4 | branches: ['*'] 5 | pull_request: 6 | branches: ['*'] 7 | jobs: 8 | run: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | 14 | - uses: actions/setup-node@v2 15 | with: 16 | node-version: '16' 17 | 18 | - name: Install Dependencies 19 | run: npm ci 20 | 21 | - name: Run Tests 22 | run: npm test 23 | 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Eugene Ghanizadeh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | [![npm package minimized gzipped size](https://img.shields.io/bundlejs/size/rehtm?style=flat-square&label=%20&color=black)](https://bundlejs.com/?q=rehtm) 4 | [![npm](https://img.shields.io/npm/v/rehtm?color=black&label=&style=flat-square)](https://www.npmjs.com/package/rehtm) 5 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/loreanvictor/rehtm/coverage.yml?label=&style=flat-square)](https://github.com/loreanvictor/rehtm/actions/workflows/coverage.yml) 6 | 7 |
8 | 9 | 10 | 11 | 12 | Create and hydrate [HTML](https://en.wikipedia.org/wiki/HTML) using [HTM](https://github.com/developit/htm): 13 | 14 | ```js 15 | import { html, ref } from 'rehtm' 16 | 17 | let count = 0 18 | const span = ref() 19 | 20 | document.body.append(html` 21 |
span.current.innerHTML = ++count}> 22 | Clicked ${count} times! 23 |
24 | `) 25 | ``` 26 |
27 | 28 | [**▷ TRY IT**](https://codepen.io/lorean_victor/pen/wvxKJyq?editors=0010) 29 | 30 |
31 | 32 | - 🧬 [Hydration](https://en.wikipedia.org/wiki/Hydration_(web_development)) for pre-rendered content (e.g. SSR) 33 | - 🪝 Functions as Event Listeners 34 | - 🔗 Element references (instead of element IDs) 35 | - 📦 Object properties for [custom elements](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements) 36 | - 🚀 Cached [HTML templates](https://www.w3schools.com/tags/tag_template.asp) for performance 37 | - 🧩 Extensions for custom attribute and node types 38 | 39 |
40 | 41 | # Contents 42 | 43 | - [Contents](#contents) 44 | - [Installation](#installation) 45 | - [Usage](#usage) 46 | - [Hydration](#hydration) 47 | - [Global Document Object](#global-document-object) 48 | - [Extension](#extension) 49 | - [Contribution](#contribution) 50 | 51 |
52 | 53 | # Installation 54 | 55 | [Node](https://nodejs.org/en/): 56 | 57 | ```bash 58 | npm i rehtm 59 | ``` 60 | 61 | Browser / [Deno](https://deno.land): 62 | 63 | ```js 64 | import { html } from 'https://esm.sh/rehtm' 65 | ``` 66 | 67 |
68 | 69 | # Usage 70 | 71 | 👉 Render DOM: 72 | 73 | ```js 74 | import { html } from 'rehtm' 75 | 76 | const name = 'World' 77 | document.body.append(html`
Hellow ${name}!
`) 78 | ``` 79 |
80 | 👉 Add event listeners: 81 | 82 | ```js 83 | document.body.append(html` 84 | 87 | `) 88 | ``` 89 |
90 | 91 | 👉 Use `ref()` to get references to created elements: 92 | 93 | ```js 94 | import { ref, html } from 'rehtm' 95 | 96 | const el = ref() 97 | document.body.append(html`
Hellow World!
`) 98 | 99 | console.log(el.current) 100 | //
Hellow World!
101 | ``` 102 |
103 | 104 | 👉 Set object properties: 105 | ```js 106 | const div = ref() 107 | html`
Some content
` 108 | 109 | console.log(div.current.prop) 110 | // > { x: 2 } 111 | ``` 112 | If the object properties are set on a custom element with a `.setProperty()` method, then that method will be called instead: 113 | ```js 114 | import { define, onProperty } from 'minicomp' 115 | import { html, ref } from 'rehtm' 116 | 117 | 118 | define('say-hi', () => { 119 | const target = ref() 120 | onProperty('to', person => target.current.textContent = person.name) 121 | 122 | return html`
Hellow
` 123 | }) 124 | 125 | 126 | const jack = { name: 'Jack', ... } 127 | const jill = { name: 'Jill', ... } 128 | 129 | document.body.append(html` 130 | 131 | 132 | `) 133 | ``` 134 | 135 |
136 | 137 | > **NOTE** 138 | > 139 | > [**re**htm](.) creates [HTML templates](https://www.w3schools.com/tags/tag_template.asp) for any string literal and reuses them 140 | > when possible. The following elements are all constructed from the same template: 141 | > 142 | > ```js 143 | > html`
Hellow ${'World'}
` 144 | > html`
Hellow ${'Welt'}
` 145 | > html`
Hellow ${'Universe'}
` 146 | > ``` 147 | 148 |
149 | 150 | ## Hydration 151 | 152 | 👉 Use `template`s to breath life into content already rendered (for example, when it is rendered on the server): 153 | 154 | ```js 155 | import { template, ref } from 'rehtm' 156 | 157 | const span = ref() 158 | let count = 0 159 | 160 | // 👇 create a template to hydrate existing DOM: 161 | const tmpl = template` 162 |
span.current.textContent = ++count}> 163 | Clicked ${count} times! 164 |
165 | ` 166 | 167 | // 👇 hydrate existing DOM: 168 | tmpl.hydrate(document.querySelector('div')) 169 | ``` 170 | ```html 171 |
172 | Clicked 0 times. 173 |
174 | ``` 175 | 176 |
177 | 178 | [**▷ TRY IT**](https://codepen.io/lorean_victor/pen/vYaNqPw?editors=1010) 179 | 180 |
181 | 182 | 👉 Use `.hydateRoot()` to hydrate children of an element instead of the element itself: 183 | 184 | ```js 185 | const tmpl = template` 186 | ${'some stuff'} 187 | ${'other stuff'} 188 | ` 189 | 190 | tmpl.hydrateRoot(document.querySelector('#root')) 191 | ``` 192 | ```html 193 |
194 | This will be reset. 195 | This also. 196 |
197 | ``` 198 | 199 |
200 | 201 | > **IMPORTANT** 202 | > 203 | > [**re**htm](.) can hydrate DOM that is minorly different (for example, elements have different attributes). However it requires the same tree-structure to be able to hydrate pre-rendered DOM. This can be particularly tricky with the presence of text nodes that contain whitespace (e.g. newlines, tabs, etc.). To avoid this, make sure you are hydrating DOM that was created using the same template string with [**rehtm**](.). 204 | 205 |
206 | 207 | 👉 Use `.create()` for using a template to create elements with different values: 208 | ```js 209 | const tmpl = template`
Hellow ${'World'}
` 210 | 211 | tmpl.create('Jack') 212 | // >
Hellow Jack
213 | ``` 214 | 215 |
216 | 217 | > **NOTE** 218 | > 219 | > `html` template tag also creates templates and uses `.create()` method to generate elements. It caches each template based on its string parts, and reloads the same template the next time it comes upon the same string bits. 220 | 221 |
222 | 223 | ## Global Document Object 224 | 225 | You might want to use [**rehtm**](.) in an environment where there is no global `document` object present (for example, during server side rendering). For such situations, use the `re()` helper function to create `html` and `template` tags for a specific document object: 226 | 227 | ```js 228 | import { re } from 'rehtm' 229 | 230 | const { html, template } = re(document) 231 | document.body.append(html`
Hellow World!
`) 232 | ``` 233 | 234 |
235 | 236 | > `re()` reuses same build for same document object, all templates remain cached. 237 | 238 |
239 | 240 | ## Extension 241 | 242 | You can create your own `html` and `template` tags with extended behavior. For that, you need a baseline DOM factory, extended with some extensions, passed to the `build()` function. For example, this is ([roughly](https://github.com/loreanvictor/rehtm/blob/412b87ad36f204491e56429291a339443dd978f5/src/index.ts#L12-L15)) how the default `html` and `template` tags are generated: 243 | 244 | ```js 245 | import { 246 | build, // 👉 builds template tags from given DOM factory, with caching enabled. 247 | extend, // 👉 extends given DOM factory with given extensions. 248 | domFactory, // 👉 this creates a baseline DOM factory. 249 | 250 | // 👉 this extension allows using functions as event listeners 251 | functionalEventListenersExt, 252 | 253 | // 👉 this extension allows setting object properties 254 | objectPropsExt, 255 | 256 | // 👉 this extension enables references 257 | refExt, 258 | } from 'rehtm' 259 | 260 | 261 | const { html, template } = build( 262 | extend( 263 | domFactory(), 264 | functionalEventListenersExt, 265 | objectPropsExt, 266 | refExt, 267 | ) 268 | ) 269 | ``` 270 | 271 |
272 | 273 | An extension can extend any of these four core functionalities: 274 | 275 | - `create(type, props, children, fallback, self)` \ 276 | Is used when creating a new element. `children` is an already processed array of values. Should return the created node. 277 |
278 | 279 | - `attribute(node, name, value, fallback, self)` \ 280 | Is used when setting an attribute on an element (a `create()` extension might bypass this). 281 |
282 | 283 | - `append(node, value, fallback, self)` \ 284 | Is used when appending a child to a node (a `create()` extension might bypass this). Should return the appended node (or its analouge). 285 |
286 | 287 | - `fill(node, value, fallback, self)` \ 288 | Is used when hydrating a node with some content. 289 |
290 | 291 |
292 | 293 | 👉 Each method is given a `fallback()`, which it can invoke to invoke prior extensions (or the original factory): 294 | 295 | ```js 296 | const myExt = { 297 | attribute(node, name, value, fallback) { 298 | if (name === 'some-attr') { 299 | // do the magic 300 | } else { 301 | // not our thing, let others set this 302 | // particular attribute 303 | fallback() 304 | } 305 | } 306 | } 307 | ``` 308 | 309 | You can also call `fallback()` with modified arguments: 310 | 311 | ```js 312 | fallback(node, modify(name), value.prop) 313 | ``` 314 | 315 |
316 | 317 | 👉 Each method is also given a `self` object, which represents the final DOM factory. It can be used when you need to 318 | invoke other methods of the host factory: 319 | 320 | ```js 321 | const myExt = { 322 | create(tag, props, children, fallback, self) { 323 | if (tag === 'my-custom-thing') { 324 | const node = fallback('div') 325 | self.attribute(node, 'class', 'my-custom-thingy', self) 326 | 327 | if (props) { 328 | for (const name in props) { 329 | self.attribute(node, name, props[name], self) 330 | } 331 | } 332 | 333 | // ... 334 | } else { 335 | return fallback() 336 | } 337 | } 338 | } 339 | ``` 340 | 341 |
342 | 343 | You can see some extension examples [here](https://github.com/loreanvictor/rehtm/tree/main/src/extensions). For examle, this is how the functional event listeners extension works: 344 | 345 | ```js 346 | export const functionalEventListenerExt = { 347 | attribute(node, name, value, fallback) { 348 | if (name.startsWith('on') && typeof value === 'function') { 349 | const eventName = name.slice(2).toLowerCase() 350 | node.addEventListener(eventName, value) 351 | } else { 352 | fallback() 353 | } 354 | } 355 | } 356 | ``` 357 | 358 |
359 | 360 | # Contribution 361 | 362 | You need [node](https://nodejs.org/en/), [NPM](https://www.npmjs.com) to start and [git](https://git-scm.com) to start. 363 | 364 | ```bash 365 | # clone the code 366 | git clone git@github.com:loreanvictor/minicomp.git 367 | ``` 368 | ```bash 369 | # install stuff 370 | npm i 371 | ``` 372 | 373 | Make sure all checks are successful on your PRs. This includes all tests passing, high code coverage, correct typings and abiding all [the linting rules](https://github.com/loreanvictor/quel/blob/main/.eslintrc). The code is typed with [TypeScript](https://www.typescriptlang.org), [Jest](https://jestjs.io) is used for testing and coverage reports, [ESLint](https://eslint.org) and [TypeScript ESLint](https://typescript-eslint.io) are used for linting. Subsequently, IDE integrations for TypeScript and ESLint would make your life much easier (for example, [VSCode](https://code.visualstudio.com) supports TypeScript out of the box and has [this nice ESLint plugin](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint)), but you could also use the following commands: 374 | 375 | ```bash 376 | # run tests 377 | npm test 378 | ``` 379 | ```bash 380 | # check code coverage 381 | npm run coverage 382 | ``` 383 | ```bash 384 | # run linter 385 | npm run lint 386 | ``` 387 | ```bash 388 | # run type checker 389 | npm run typecheck 390 | ``` 391 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', {targets: {node: 'current'}}], 4 | ], 5 | } 6 | -------------------------------------------------------------------------------- /conf/typescript/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sindresorhus/tsconfig", 3 | "compilerOptions": { 4 | "moduleResolution": "node", 5 | "module": "esnext", 6 | "jsx": "react", 7 | "esModuleInterop": true, 8 | "noImplicitAny": false 9 | }, 10 | "include": ["../../src"], 11 | "exclude": ["../../**/test/**/*"] 12 | } -------------------------------------------------------------------------------- /conf/typescript/commonjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./base.json", 3 | "compilerOptions": { 4 | "target": "es6", 5 | "module": "commonjs", 6 | "outDir": "../../dist/commonjs/" 7 | } 8 | } -------------------------------------------------------------------------------- /conf/typescript/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./base.json", 3 | "compilerOptions": { 4 | "target": "es2020", 5 | "module": "es2020", 6 | "outDir": "../../dist/es/" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | preset: 'ts-jest', 3 | verbose: true, 4 | clearMocks: true, 5 | projects: [ 6 | { 7 | displayName: 'browser', 8 | preset: 'ts-jest', 9 | testEnvironment: 'jsdom', 10 | testEnvironmentOptions: { 11 | url: 'http://localhost', 12 | }, 13 | testMatch: ['**/test/**/*.test.[jt]s?(x)'], 14 | }, 15 | { 16 | displayName: 'node', 17 | preset: 'ts-jest', 18 | testEnvironment: 'node', 19 | testMatch: ['**/test/**/*.ssr-test.[jt]s?(x)'], 20 | } 21 | ], 22 | collectCoverageFrom: [ 23 | 'src/**/*.{ts,tsx}', 24 | '!src/**/*.test.{ts,tsx}', 25 | '!src/**/*.ssr-test.{ts,tsx}', 26 | ], 27 | coverageThreshold: { 28 | global: { 29 | branches: 100, 30 | functions: 90, 31 | lines: 100, 32 | statements: 100, 33 | }, 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /logo-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | logo-dark 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /logo-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | logo-light 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rehtm", 3 | "version": "0.5.1", 4 | "description": "create HTML using HTM", 5 | "main": "dist/commonjs/index.js", 6 | "module": "dist/es/index.js", 7 | "types": "dist/es/index.d.ts", 8 | "scripts": { 9 | "sample": "vite sample", 10 | "test": "jest", 11 | "lint": "eslint .", 12 | "typecheck": "tsc -p conf/typescript/es.json --noEmit", 13 | "coverage": "jest --coverage", 14 | "build-commonjs": "tsc -p conf/typescript/commonjs.json", 15 | "build-es": "tsc -p conf/typescript/es.json", 16 | "build": "npm run build-commonjs && npm run build-es", 17 | "prepack": "npm run build" 18 | }, 19 | "files": [ 20 | "dist/es", 21 | "dist/commonjs" 22 | ], 23 | "sideEffects": false, 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/loreanvictor/rehtm.git" 27 | }, 28 | "keywords": [ 29 | "HTML", 30 | "DOM", 31 | "JSX" 32 | ], 33 | "author": "Eugene Ghanizadeh Khoub", 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/loreanvictor/rehtm/issues" 37 | }, 38 | "homepage": "https://github.com/loreanvictor/rehtm#readme", 39 | "devDependencies": { 40 | "@babel/core": "^7.20.7", 41 | "@babel/preset-env": "^7.20.2", 42 | "@sindresorhus/tsconfig": "^3.0.1", 43 | "@types/jest": "^29.2.4", 44 | "@types/node": "^18.11.17", 45 | "@typescript-eslint/eslint-plugin": "^5.47.0", 46 | "@typescript-eslint/parser": "^5.47.0", 47 | "babel-jest": "^29.3.1", 48 | "eslint": "^8.30.0", 49 | "jest": "^29.3.1", 50 | "jest-environment-jsdom": "^29.3.1", 51 | "minicomp": "^0.4.0", 52 | "ts-inference-check": "^0.2.1", 53 | "ts-jest": "^29.0.3", 54 | "ts-node": "^10.9.1", 55 | "tslib": "^2.4.1", 56 | "typescript": "^4.9.4", 57 | "vite": "^4.0.3" 58 | }, 59 | "dependencies": { 60 | "htm": "^3.1.1" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /sample/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /sample/index.js: -------------------------------------------------------------------------------- 1 | import { define, onProperty, onAttribute } from 'minicomp' 2 | import { html, template, ref } from '../src' 3 | 4 | // --- SIMPLE COUNTER --- \\ 5 | 6 | // let count = 0 7 | // const span = ref() 8 | 9 | // document.body.append(html` 10 | //
span.current.innerHTML = ++count}> 11 | // Clicked ${count} times! 12 | //
13 | // `) 14 | 15 | 16 | // --- HYDRATION TEST --- \\ 17 | 18 | // let count = 0 19 | // const span = ref() 20 | 21 | // const tmpl = template` 22 | //
span.current.innerHTML = ++count}> 23 | // Clicked ${count} times! 24 | //
25 | // ` 26 | 27 | // const div = document.querySelector('div') 28 | // document.body.appendChild(html``) 29 | 30 | // --- COMPONENT TEST --- \\ 31 | 32 | // define('say-hi', () => { 33 | // const _span = ref() 34 | // const counter = ref() 35 | // let _count = 0 36 | 37 | // onProperty('to', person => _span.current.innerHTML = person.name) 38 | // onAttribute('to', name => _span.current.innerHTML = name) 39 | 40 | // return html` 41 | //
counter.current.innerHTML = ++_count}> 42 | // Hellow ! 43 | // (Clicked ${_count} times) 44 | //
45 | // ` 46 | // }) 47 | 48 | // document.body.appendChild(html` 49 | // 50 | // 51 | // `) 52 | 53 | // --- CUSTOM EVENTS --- \\ 54 | 55 | document.body.append(html` 56 |
console.log('STUFF!')}> 57 | Hellow World! 58 | 59 |
60 | `) 61 | 62 | const div = document.querySelector('div') 63 | const button = document.querySelector('button') 64 | 65 | button.addEventListener('click', () => { 66 | div.dispatchEvent(new CustomEvent('stuff')) 67 | }) 68 | -------------------------------------------------------------------------------- /src/build.ts: -------------------------------------------------------------------------------- 1 | import { DOMFactory } from './factory' 2 | import { cache } from './cache' 3 | 4 | 5 | export function build(factory: DOMFactory) { 6 | const cached = cache(factory) 7 | 8 | return { 9 | html: (strings: TemplateStringsArray, ...values: unknown[]) => cached.get(strings, ...values).create(...values), 10 | template: (strings: TemplateStringsArray, ...values: unknown[]) => { 11 | const recipe = cached.get(strings, ...values) 12 | 13 | return { 14 | hydrate: (node: Node) => recipe.apply([node], ...values), 15 | hydrateRoot: (root: Node) => recipe.apply(root.childNodes, ...values), 16 | create: () => recipe.create(...values), 17 | } 18 | }, 19 | recipe: cached.get, 20 | cached, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- 1 | import { DOMFactory } from './factory' 2 | import { build, Recipe } from './template' 3 | 4 | 5 | export function cache(factory: DOMFactory) { 6 | const create = build(factory) 7 | const store = new Map() 8 | 9 | const get = (strings: TemplateStringsArray, ...values: unknown[]) => { 10 | const key = strings.join('&') 11 | if (store.has(key)) { 12 | return store.get(key)! 13 | } else { 14 | const recipe = create(strings, ...values) 15 | store.set(key, recipe) 16 | 17 | return recipe 18 | } 19 | } 20 | 21 | const clear = () => store.clear() 22 | 23 | return { get, clear } 24 | } 25 | -------------------------------------------------------------------------------- /src/extensions/functional-events.ext.ts: -------------------------------------------------------------------------------- 1 | import { DOMFactoryExt } from '../factory' 2 | 3 | 4 | export const functionalEventListenerExt: DOMFactoryExt = { 5 | attribute(node, name, value, fallback) { 6 | if (name.startsWith('on') && typeof value === 'function') { 7 | const eventName = name.slice(2).toLowerCase() 8 | node.addEventListener(eventName as keyof ElementEventMap, value as EventListener) 9 | node.removeAttribute(name) 10 | } else { 11 | fallback() 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/extensions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './functional-events.ext' 2 | export * from './object-props.ext' 3 | export * from './ref.ext' 4 | -------------------------------------------------------------------------------- /src/extensions/object-props.ext.ts: -------------------------------------------------------------------------------- 1 | import { DOMFactoryExt } from '../factory' 2 | 3 | 4 | export const objectPropsExt: DOMFactoryExt = { 5 | attribute(node, name, value, fallback) { 6 | if (typeof value !== 'number' && typeof value !== 'string' && typeof value !== 'boolean') { 7 | if ((node as any).setProperty) { 8 | (node as any).setProperty(name, value) 9 | } else { 10 | node[name] = value 11 | } 12 | } else { 13 | fallback() 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/extensions/ref.ext.ts: -------------------------------------------------------------------------------- 1 | import { DOMFactoryExt } from '../factory' 2 | 3 | 4 | const refSymbol = Symbol() 5 | export type Ref = { 6 | current?: T 7 | [refSymbol]: typeof refSymbol 8 | } 9 | 10 | export const ref = (): Ref => ({ [refSymbol]: refSymbol }) 11 | 12 | export function isRef(param: unknown): param is Ref { 13 | return typeof param === 'object' && (param as Ref)[refSymbol] === refSymbol 14 | } 15 | 16 | export const refExt: DOMFactoryExt = { 17 | attribute(el, name, value, fallback) { 18 | if (name === 'ref' && isRef(value)) { 19 | value.current = el as any 20 | } else { 21 | fallback() 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/extensions/test/functional-events.ext.test.ts: -------------------------------------------------------------------------------- 1 | import { functionalEventListenerExt } from '../functional-events.ext' 2 | import { extend, domFactory } from '../../factory' 3 | 4 | 5 | describe('functional event listeners', () => { 6 | afterEach(() => document.body.innerHTML = '') 7 | 8 | test('adds event listener.', () => { 9 | const cb = jest.fn() 10 | 11 | const fact = extend(domFactory(), functionalEventListenerExt) 12 | const el = fact.create('div', { onclick: cb, 'aria-label': 'test' }, [], fact) as HTMLElement 13 | document.body.append(el) 14 | 15 | el.click() 16 | expect(cb).toBeCalledTimes(1) 17 | expect(el.getAttribute('aria-label')).toBe('test') 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /src/extensions/test/object-props.ext.test.ts: -------------------------------------------------------------------------------- 1 | import { define, onProperty } from 'minicomp' 2 | 3 | import { objectPropsExt } from '../object-props.ext' 4 | import { extend, domFactory } from '../../factory' 5 | 6 | 7 | describe('object properties', () => { 8 | afterEach(() => document.body.innerHTML = '') 9 | 10 | test('adds object properties to custom elements.', () => { 11 | const cb = jest.fn() 12 | const cb2 = jest.fn() 13 | 14 | define('op-ext-test', ({test: t}) => { 15 | cb2(t) 16 | onProperty('test', cb) 17 | 18 | return '
' 19 | }) 20 | 21 | const fact = extend(domFactory(), objectPropsExt) 22 | const obj = { test: 'test' } 23 | const el = fact.create('op-ext-test', { test: obj, id: 'foo' }, [], fact) as HTMLElement 24 | document.body.append(el) 25 | 26 | expect(cb2).toBeCalledTimes(1) 27 | expect(cb2).toBeCalledWith(obj) 28 | expect(cb).toBeCalledTimes(1) 29 | expect(cb).toBeCalledWith(obj) 30 | expect(el.id).toBe('foo') 31 | }) 32 | 33 | test('adds properties to native elements too.', () => { 34 | const fact = extend(domFactory(), objectPropsExt) 35 | const obj = { test: 'test' } 36 | const el = fact.create('div', { test: obj, id: 'foo' }, [], fact) as HTMLElement 37 | 38 | expect(el['test']).toBe(obj) 39 | expect(el.id).toBe('foo') 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /src/extensions/test/ref.ext.test.ts: -------------------------------------------------------------------------------- 1 | import { refExt, ref } from '../ref.ext' 2 | import { extend, domFactory } from '../../factory' 3 | 4 | 5 | describe('element references', () => { 6 | afterEach(() => document.body.innerHTML = '') 7 | 8 | test('adds element references.', () => { 9 | const fact = extend(domFactory(), refExt) 10 | const r = ref() 11 | 12 | const el = fact.create('div', { ref: r, x: 'y' }, [], fact) as HTMLElement 13 | document.body.append(el) 14 | 15 | expect(r.current).toBe(el) 16 | expect(el.getAttribute('x')).toBe('y') 17 | }) 18 | 19 | test('ignores ref attribute if not a ref is passed.', () => { 20 | const fact = extend(domFactory(), refExt) 21 | const el = fact.create('div', { ref: 'foo' }, [], fact) as HTMLElement 22 | document.body.append(el) 23 | 24 | expect(el.getAttribute('ref')).toBe('foo') 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /src/factory/dom.ts: -------------------------------------------------------------------------------- 1 | import { extend, DOMFactoryExt } from './extension' 2 | import { nullFactory } from './null' 3 | 4 | 5 | export const domFactoryExt: (document: Document) => DOMFactoryExt = (document) => ({ 6 | create: (type, props, children, fallback, self) => { 7 | if (typeof type === 'string') { 8 | const node = document.createElement(type) 9 | if (props) { 10 | for (const name in props) { 11 | self.attribute(node, name, props[name], self) 12 | } 13 | } 14 | if (children) { 15 | for (const child of children) { 16 | self.append(node, child, self) 17 | } 18 | } 19 | 20 | return node 21 | } else { 22 | return fallback() 23 | } 24 | }, 25 | 26 | attribute: (node, name, value, fallback) => { 27 | if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { 28 | node.setAttribute(name, value.toString()) 29 | } else { 30 | fallback() 31 | } 32 | }, 33 | 34 | append: (node, value, fallback) => { 35 | if (value instanceof document.defaultView!.Node) { 36 | node.appendChild(value) 37 | 38 | return value 39 | } else if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { 40 | const child = document.createTextNode(value.toString()) 41 | node.appendChild(child) 42 | 43 | return child 44 | } else { 45 | return fallback() 46 | } 47 | }, 48 | 49 | fill: (node, value, fallback, self) => { 50 | if (value instanceof document.defaultView!.Node) { 51 | node.childNodes.forEach((child) => node.removeChild(child)) 52 | node.appendChild(value) 53 | } else if (Array.isArray(value)) { 54 | node.childNodes.forEach((child) => node.removeChild(child)) 55 | for (const child of value) { 56 | self.append(node, child, self) 57 | } 58 | }else if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { 59 | node.textContent = value.toString() 60 | } else { 61 | fallback() 62 | } 63 | } 64 | }) 65 | 66 | 67 | export const domFactory = (document = window.document) => extend(nullFactory(document), domFactoryExt(document)) 68 | -------------------------------------------------------------------------------- /src/factory/extension.ts: -------------------------------------------------------------------------------- 1 | import { DOMFactory } from './types' 2 | 3 | 4 | export type CreateFnExt = ( 5 | type: unknown, 6 | props: Record | undefined, 7 | children: unknown[] | undefined, 8 | fallback: (t?: unknown, p?: Record, c?: unknown[], s?: DOMFactory) => Node, 9 | self: DOMFactory, 10 | ) => Node 11 | 12 | export type AttributeFnExt = ( 13 | node: Element, 14 | name: string, 15 | value: unknown, 16 | fallback: (n?: Element, a?: string, v?: unknown, s?: DOMFactory) => void, 17 | self: DOMFactory, 18 | ) => void 19 | 20 | export type AppendFnExt = ( 21 | node: Node, 22 | value: unknown, 23 | fallback: (n?: Node, v?: unknown, s?: DOMFactory) => Node, 24 | self: DOMFactory, 25 | ) => Node 26 | 27 | export type FillFnExt = ( 28 | node: Node, 29 | value: unknown, 30 | fallback: (n?: Node, v?: unknown, s?: DOMFactory) => void, 31 | self: DOMFactory, 32 | ) => void 33 | 34 | export interface DOMFactoryExt { 35 | create?: CreateFnExt 36 | attribute?: AttributeFnExt 37 | append?: AppendFnExt 38 | fill?: FillFnExt 39 | } 40 | 41 | export function extend(factory: DOMFactory, ...exts: DOMFactoryExt[]): DOMFactory { 42 | if (exts.length > 1) { 43 | return exts.reduce((f, ext) => extend(f, ext), factory) 44 | } else if (exts.length === 0) { 45 | return factory 46 | } 47 | 48 | const ext = exts[0]! 49 | 50 | return { 51 | document: factory.document, 52 | create: (type, props, children, self) => ext.create ? 53 | ext.create(type, props, children, 54 | (t, p, c, s) => factory.create(t ?? type, p ?? props, c ?? children, s ?? self), 55 | self) 56 | : factory.create(type, props, children, self), 57 | attribute: (node, name, value, self) => ext.attribute ? 58 | ext.attribute(node, name, value, 59 | (n, a, v, s) => factory.attribute(n ?? node, a ?? name, v ?? value, s ?? self) 60 | , self) 61 | : factory.attribute(node, name, value, self), 62 | append: (node, value, self) => ext.append ? 63 | ext.append(node, value, 64 | (n, v, s) => factory.append(n ?? node, v ?? value, s ?? self) 65 | , self) 66 | : factory.append(node, value, self), 67 | fill: (node, value, self) => ext.fill ? 68 | ext.fill(node, value, 69 | (n, v, s) => factory.fill(n ?? node, v ?? value, s ?? self) 70 | , self) 71 | : factory.fill(node, value, self), 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/factory/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types' 2 | export * from './extension' 3 | export * from './null' 4 | export * from './dom' 5 | -------------------------------------------------------------------------------- /src/factory/null.ts: -------------------------------------------------------------------------------- 1 | import { DOMFactory } from './types' 2 | 3 | 4 | export class UnsupportedElementTypeError extends Error { 5 | constructor(type: unknown) { 6 | super(`Can't create element with type ${type}`) 7 | } 8 | } 9 | 10 | export class UnsupportedAttributeError extends Error { 11 | constructor(node: Node, name: string, value: unknown) { 12 | super(`Can't set attribute ${name} to ${value} on ${node}`) 13 | } 14 | } 15 | 16 | export class UnsupportedAppendError extends Error { 17 | constructor(node: Node, value: unknown) { 18 | super(`Can't append ${value} to ${node}`) 19 | } 20 | } 21 | 22 | export class UnsupportedFillError extends Error { 23 | constructor(node: Node, value: unknown) { 24 | super(`Can't fill ${node} with ${value}`) 25 | } 26 | } 27 | 28 | 29 | export const nullFactory: (document?: Document) => DOMFactory = (document = window.document) => ({ 30 | document, 31 | create: (type) => { throw new UnsupportedElementTypeError(type) }, 32 | attribute: (node, name, value) => { throw new UnsupportedAttributeError(node, name, value) }, 33 | append: (node, value) => { throw new UnsupportedAppendError(node, value) }, 34 | fill: (node, value) => { throw new UnsupportedFillError(node, value) }, 35 | }) 36 | 37 | -------------------------------------------------------------------------------- /src/factory/test/factory.test.ts: -------------------------------------------------------------------------------- 1 | import { domFactory, extend } from '../index' 2 | 3 | 4 | describe('factory', () => { 5 | afterEach(() => document.body.innerHTML = '') 6 | 7 | test('creates DOM elements.', () => { 8 | const factory = domFactory() 9 | document.body.appendChild(factory.create('div', { id: 'test' }, ['hellow world'], factory)) 10 | expect(document.getElementById('test')).not.toBeNull() 11 | expect(document.getElementById('test')!.textContent).toBe('hellow world') 12 | }) 13 | 14 | test('can fill DOM elements with other elements.', () => { 15 | const factory = domFactory() 16 | const b = document.createElement('b') 17 | factory.fill(b, 'hellow world', factory) 18 | expect(b.textContent).toBe('hellow world') 19 | 20 | const span = document.createElement('span') 21 | factory.fill(span, 'Hi Hi!', factory) 22 | factory.fill(b, span, factory) 23 | expect(b.textContent).toBe('Hi Hi!') 24 | }) 25 | 26 | test('can fill DOM elements with arrays of stuff.', () => { 27 | const factory = domFactory() 28 | const b = document.createElement('b') 29 | factory.fill(b, 'Hellow Hellow', factory) 30 | factory.fill(b, ['hellow', 'world'], factory) 31 | expect(b.textContent).toBe('hellowworld') 32 | }) 33 | 34 | test('throws error for unsupported tags.', () => { 35 | const factory = domFactory() 36 | expect(() => factory.create(42, {}, [], factory)).toThrowError() 37 | }) 38 | 39 | test('throws errors when setting unsupported attributes', () => { 40 | const factory = domFactory() 41 | expect(() => factory.create('div', {x: [42]}, [], factory)).toThrowError() 42 | }) 43 | 44 | test('throws error for unsupported childs.', () => { 45 | const factory = domFactory() 46 | expect(() => factory.create('div', {}, [[42]], factory)).toThrowError() 47 | }) 48 | 49 | test('throws error for unsupported content to fill.', () => { 50 | const factory = domFactory() 51 | const b = document.createElement('b') 52 | expect(() => factory.fill(b, () => {}, factory)).toThrowError() 53 | }) 54 | 55 | test('can be extended to support new tag types.', () => { 56 | const cb = jest.fn() 57 | 58 | const fact = extend(domFactory(), { 59 | create(type, _, __, fallback) { 60 | if (type === 42) { 61 | cb() 62 | 63 | return fallback('b') 64 | } else { 65 | return fallback() 66 | } 67 | } 68 | }) 69 | 70 | document.body.append(fact.create(42, {}, [], fact)) 71 | document.body.append(fact.create('div', {}, ['Halo'], fact)) 72 | 73 | expect(cb).toBeCalledTimes(1) 74 | expect(document.body.innerHTML).toBe('
Halo
') 75 | }) 76 | 77 | test('extended factories fallback properly on other methods.', () => { 78 | const fact = extend(domFactory(), { 79 | create(type, _, __, fallback) { 80 | if (type === 42) { 81 | return fallback('b') 82 | } else { 83 | return fallback() 84 | } 85 | } 86 | }) 87 | 88 | const el = fact.create(42, {}, [], fact) as HTMLElement 89 | fact.attribute(el, 'x', 43, fact) 90 | 91 | expect(el.outerHTML).toBe('') 92 | }) 93 | 94 | test('can be extended to support new attribute types.', () => { 95 | const cb = jest.fn() 96 | 97 | const fact = extend(domFactory(), { 98 | attribute(el, name, __, fallback) { 99 | if (name === 'x') { 100 | cb() 101 | fallback(el, 'y') 102 | } else { 103 | fallback() 104 | } 105 | } 106 | }) 107 | 108 | const div = fact.create('div', {}, [], fact) as HTMLElement 109 | fact.attribute(div, 'x', 42, fact) 110 | document.body.append(div) 111 | expect(cb).toBeCalledTimes(1) 112 | expect(document.body.innerHTML).toBe('
') 113 | }) 114 | 115 | test('can be extended to support new child types.', () => { 116 | const cb = jest.fn() 117 | 118 | const fact = extend(domFactory(), { 119 | append(el, child, fallback) { 120 | if (Array.isArray(child)) { 121 | cb() 122 | 123 | return fallback(el, '42') 124 | } else { 125 | return fallback() 126 | } 127 | } 128 | }) 129 | 130 | document.body.append(fact.create('div', {}, [[41]], fact)) 131 | expect(cb).toBeCalledTimes(1) 132 | expect(document.body.innerHTML).toBe('
42
') 133 | }) 134 | 135 | test('can be extended to support new fill types.', () => { 136 | const cb = jest.fn() 137 | 138 | const fact = extend(domFactory(), { 139 | fill(el, child, fallback) { 140 | if (typeof child === 'function') { 141 | cb() 142 | 143 | return fallback(el, '42') 144 | } else { 145 | return fallback() 146 | } 147 | } 148 | }) 149 | 150 | const b = document.createElement('b') 151 | fact.fill(b, () => {}, fact) 152 | expect(cb).toBeCalledTimes(1) 153 | expect(b.textContent).toBe('42') 154 | }) 155 | 156 | test('extension of factory without any extensions should be itself.', () => { 157 | const factory = domFactory() 158 | expect(extend(factory)).toBe(factory) 159 | }) 160 | }) 161 | -------------------------------------------------------------------------------- /src/factory/test/null.test.ts: -------------------------------------------------------------------------------- 1 | import { nullFactory, UnsupportedAppendError, UnsupportedAttributeError, UnsupportedElementTypeError, UnsupportedFillError } from '../null' 2 | 3 | 4 | describe(nullFactory, () => { 5 | test('throws error for unsupported tags.', () => { 6 | const factory = nullFactory() 7 | expect(() => factory.create(42, {}, undefined, factory)).toThrowError(UnsupportedElementTypeError) 8 | }) 9 | 10 | test('throws errors for unsupported attributes.', () => { 11 | const factory = nullFactory() 12 | expect( 13 | () => factory.attribute(document.createElement('div'), 'x', 42, factory) 14 | ).toThrowError(UnsupportedAttributeError) 15 | }) 16 | 17 | test('throws error for unsupported childs.', () => { 18 | const factory = nullFactory() 19 | expect(() => factory.append(document.createElement('div'), 42, factory)).toThrowError( 20 | UnsupportedAppendError 21 | ) 22 | }) 23 | 24 | test('throws error for unsupported element fillings.', () => { 25 | const factory = nullFactory() 26 | expect(() => factory.fill(document.createElement('div'), 42, factory)).toThrowError( 27 | UnsupportedFillError 28 | ) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /src/factory/types.ts: -------------------------------------------------------------------------------- 1 | export type CreateFn = ( 2 | type: unknown, 3 | props: Record | undefined, 4 | children: unknown[] | undefined, 5 | self: DOMFactory, 6 | ) => Node 7 | 8 | export type AttributeFn = ( 9 | node: Element, 10 | name: string, 11 | value: unknown, 12 | self: DOMFactory, 13 | ) => void 14 | 15 | export type AppendFn = ( 16 | node: Node, 17 | value: unknown, 18 | self: DOMFactory, 19 | ) => Node 20 | 21 | export type FillFn = ( 22 | node: Node, 23 | value: unknown, 24 | self: DOMFactory, 25 | ) => void 26 | 27 | export interface DOMFactory { 28 | create: CreateFn 29 | attribute: AttributeFn 30 | append: AppendFn 31 | fill: FillFn 32 | document: Document 33 | } 34 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { build as buildRecipe, Recipe } from './template' 2 | export * from './factory' 3 | export * from './extensions' 4 | export * from './cache' 5 | export * from './build' 6 | export * from './re' 7 | 8 | import { re } from './re' 9 | import { BuildSuite } from './types' 10 | 11 | 12 | const wrongFn = name => (..._: any[]) => { 13 | throw new Error( 14 | `No global window object found, which means you cannot call ${name}. \n` 15 | + 'Create a custom `document` object and use the following syntax instead:\n\n' 16 | + "import { re } from 'rehtm'\n" 17 | + `const { ${name} } = re(document)\n` 18 | ) 19 | } 20 | 21 | const result: BuildSuite = globalThis.window ? re(globalThis.window.document) : { 22 | html: wrongFn('html'), 23 | template: wrongFn('template'), 24 | recipe: wrongFn('recipe'), 25 | cached: { 26 | get: wrongFn('cached.get'), 27 | clear: wrongFn('cached.clear'), 28 | }, 29 | } as any 30 | 31 | export const { 32 | html, template, 33 | recipe, cached 34 | } = result 35 | -------------------------------------------------------------------------------- /src/re.ts: -------------------------------------------------------------------------------- 1 | import { build } from './build' 2 | import { extend, domFactory } from './factory' 3 | import { functionalEventListenerExt, objectPropsExt, refExt } from './extensions' 4 | import { BuildSuite } from './types' 5 | 6 | 7 | export const re: (document: Document) => BuildSuite = (document: Document) => { 8 | return (document as any).__rehtm_tags__ ??= build( 9 | extend(domFactory(document), 10 | objectPropsExt, 11 | functionalEventListenerExt, 12 | refExt 13 | ) 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/template/build.ts: -------------------------------------------------------------------------------- 1 | import htm from 'htm/mini' 2 | 3 | import { extend, DOMFactory } from '../factory' 4 | import { Recipe } from './recipe' 5 | import { RecipeExt } from './extension' 6 | import { makeSlottedParam } from './slot' 7 | 8 | 9 | export function build(factory: DOMFactory) { 10 | const ctx: Recipe[] = [] 11 | const fact = extend(factory, new RecipeExt(() => ctx[ctx.length - 1]!)) 12 | 13 | const template = htm.bind( 14 | (type: unknown, props?: Record, ...children: unknown[]) => 15 | fact.create(type, props, children, fact) 16 | ) 17 | 18 | return (strings: TemplateStringsArray, ...values: unknown[]) => { 19 | const recipe = new Recipe(factory) 20 | ctx.push(recipe) 21 | const result = template(strings, ...values.map((_, index) => makeSlottedParam(index))) 22 | ctx.pop() 23 | 24 | if (Array.isArray(result)) { 25 | result.forEach((node) => recipe.template.content.appendChild(node)) 26 | } else { 27 | recipe.template.content.appendChild(result) 28 | } 29 | 30 | recipe.finalize() 31 | 32 | return recipe 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/template/errors.ts: -------------------------------------------------------------------------------- 1 | export class WrongAddressError extends Error { 2 | constructor( 3 | public readonly address: number[], 4 | public readonly matched: number[], 5 | public readonly host: Node | Node[] | NodeList 6 | ) { 7 | super(`Address ${address.join('->')} not found on ${host}. Best match: ${matched.join('->')}.`) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/template/extension.ts: -------------------------------------------------------------------------------- 1 | import { DOMFactoryExt } from '../factory' 2 | import { Recipe } from './recipe' 3 | import { isSlottedParam } from './slot' 4 | 5 | 6 | export class RecipeExt implements DOMFactoryExt { 7 | constructor(private readonly recipe: () => Recipe) { } 8 | 9 | attribute(el, name, value, fallback) { 10 | if (isSlottedParam(value)) { 11 | const recipe = this.recipe() 12 | recipe.slot(value.index, el, name) 13 | fallback(el, name, '?') 14 | } else { 15 | fallback() 16 | } 17 | } 18 | 19 | append(parent, value, fallback) { 20 | if (isSlottedParam(value)) { 21 | const recipe = this.recipe() 22 | const node = fallback(parent, '?') 23 | recipe.slot(value.index, node) 24 | 25 | return node 26 | } else { 27 | return fallback(parent, value) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/template/index.ts: -------------------------------------------------------------------------------- 1 | export * from './recipe' 2 | export * from './build' 3 | -------------------------------------------------------------------------------- /src/template/recipe.ts: -------------------------------------------------------------------------------- 1 | import { DOMFactory } from '../factory' 2 | import { WrongAddressError } from './errors' 3 | import { elementify, locate, address, Slot } from './slot' 4 | 5 | 6 | export class Recipe { 7 | slots: { [key: number]: Slot } = {} 8 | #closed = false 9 | readonly template: HTMLTemplateElement 10 | 11 | constructor(readonly factory: DOMFactory) { 12 | this.template = factory.document.createElement('template') 13 | } 14 | 15 | slot(index: number, node: Node, attribute?: string) { 16 | if (!this.#closed) { 17 | this.slots[index] = { node, attribute } 18 | } 19 | } 20 | 21 | finalize() { 22 | this.#closed = true 23 | Object.values(this.slots).map(elementify).map(address) 24 | } 25 | 26 | apply(target: Node | Node[] | NodeList, ...values: unknown[]) { 27 | Object.entries(this.slots).forEach(([index, slot]) => { 28 | const { node, matched } = locate(slot, target, this.factory.document) 29 | if (matched.length !== slot.address!.length) { 30 | if (!slot.attribute && matched.length === slot.address!.length - 1 && node.childNodes.length === 0) { 31 | this.factory.append(node, values[index], this.factory) 32 | } else { 33 | throw new WrongAddressError(slot.address!, matched, target) 34 | } 35 | } else { 36 | if (slot.attribute) { 37 | this.factory.attribute(node as Element, slot.attribute, values[index], this.factory) 38 | } else { 39 | this.factory.fill(node, values[index], this.factory) 40 | } 41 | } 42 | }) 43 | } 44 | 45 | create(...values: unknown[]) { 46 | const target = this.factory.document.importNode(this.template.content, true) 47 | this.apply(target, ...values) 48 | 49 | return target 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/template/slot.ts: -------------------------------------------------------------------------------- 1 | export type Slot = { 2 | node: Node 3 | attribute?: string 4 | address?: number[] 5 | } 6 | 7 | 8 | export function elementify(slot: Slot) { 9 | if (slot.node.nodeType === slot.node.TEXT_NODE) { 10 | const element = slot.node.ownerDocument!.createElement('_') 11 | slot.node.parentNode?.replaceChild(element, slot.node) 12 | slot.node = element 13 | } 14 | 15 | return slot 16 | } 17 | 18 | 19 | export function address(slot: Slot) { 20 | if (!slot.address) { 21 | slot.address = [] 22 | let node = slot.node 23 | 24 | while (node.parentNode) { 25 | const index = Array.from(node.parentNode.childNodes).indexOf(node as ChildNode) 26 | slot.address.unshift(index) 27 | node = node.parentNode 28 | } 29 | } 30 | 31 | return slot 32 | } 33 | 34 | export function locate(slot: Slot, host: Node | Node[] | NodeList, document: Document) { 35 | const addr = slot.address! 36 | const first = ( 37 | (host instanceof document.defaultView!.NodeList || Array.isArray(host)) ? 38 | host[addr[0]!] 39 | : host.childNodes[addr[0]!] 40 | )! 41 | let node = first 42 | const matched: number[] = [addr[0]!] 43 | 44 | for (let i = 1; i < addr.length; i++) { 45 | const step = addr[i]! 46 | const candidate = node.childNodes[step] 47 | 48 | if (candidate) { 49 | node = candidate 50 | matched.push(step) 51 | } else { 52 | break 53 | } 54 | } 55 | 56 | return { node, matched } 57 | } 58 | 59 | 60 | const slottedParamSymbol = Symbol() 61 | export type SlottedParam = { [slottedParamSymbol]: typeof slottedParamSymbol, index: number } 62 | 63 | export function makeSlottedParam(index: number): SlottedParam { 64 | return { [slottedParamSymbol]: slottedParamSymbol, index } 65 | } 66 | 67 | export function isSlottedParam(value: unknown): value is SlottedParam { 68 | return typeof value === 'object' && (value as SlottedParam)[slottedParamSymbol] === slottedParamSymbol 69 | } 70 | -------------------------------------------------------------------------------- /src/template/test/template.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock('htm/mini', () => require('htm/mini/index.umd.js')) 2 | 3 | import { domFactory } from '../../factory' 4 | import { build } from '../index' 5 | import { WrongAddressError } from '../errors' 6 | 7 | 8 | describe('template', () => { 9 | afterEach(() => document.body.innerHTML = '') 10 | 11 | test('creates a template that can be re-used.', () => { 12 | const template = build(domFactory()) 13 | const tmpl = template`
hello
` 14 | 15 | document.body.appendChild(tmpl.create()) 16 | 17 | const div$ = document.querySelector('div') 18 | expect(div$).not.toBeNull() 19 | expect(div$!.textContent).toBe('hello') 20 | }) 21 | 22 | test('creates a template with slots and stuff.', () => { 23 | const template = build(domFactory()) 24 | const tmpl = template`
hello ${1}
` 25 | 26 | document.body.appendChild(tmpl.create('foo', 'world')) 27 | 28 | const div$ = document.querySelector('div') 29 | expect(div$).not.toBeNull() 30 | expect(div$!.className).toBe('foo') 31 | expect(div$!.getAttribute('aria-role')).toBe('button') 32 | expect(div$!.textContent).toBe('hello world') 33 | }) 34 | 35 | test('also works for lists of elements.', () => { 36 | const template = build(domFactory()) 37 | const tmpl = template`${0}${1}` 38 | 39 | document.body.appendChild(tmpl.create('foo', 'bar')) 40 | 41 | const b$ = document.querySelector('b') 42 | expect(b$).not.toBeNull() 43 | expect(b$!.textContent).toBe('foo') 44 | 45 | const i$ = document.querySelector('i') 46 | expect(i$).not.toBeNull() 47 | expect(i$!.textContent).toBe('bar') 48 | }) 49 | 50 | test('can hydrate a list of nodes.', () => { 51 | const template = build(domFactory()) 52 | const tmpl = template`${0}${1}` 53 | 54 | const b = document.createElement('b') 55 | const i = document.createElement('i') 56 | 57 | tmpl.apply([b, i], 'foo', 'bar') 58 | expect(b.textContent).toBe('foo') 59 | expect(i.textContent).toBe('bar') 60 | }) 61 | 62 | test('can hydrate a node list.', () => { 63 | const template = build(domFactory()) 64 | const tmpl = template`${0}${1}` 65 | 66 | document.body.innerHTML = '' 67 | tmpl.apply(document.body.childNodes, 'foo', 'bar') 68 | 69 | const b$ = document.querySelector('b') 70 | expect(b$).not.toBeNull() 71 | expect(b$!.textContent).toBe('foo') 72 | 73 | const i$ = document.querySelector('i') 74 | expect(i$).not.toBeNull() 75 | expect(i$!.textContent).toBe('bar') 76 | }) 77 | 78 | test('throws proper error when cant hydrate.', () => { 79 | const template = build(domFactory()) 80 | const tmpl = template`
hello ${1}
` 81 | 82 | document.body.innerHTML = '
Hi
' 83 | expect(() => tmpl.apply(document.body, 'world')).toThrowError(WrongAddressError) 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /src/test/build.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock('htm/mini', () => require('htm/mini/index.umd.js')) 2 | 3 | import { build, domFactory, extend, functionalEventListenerExt } from '../index' 4 | 5 | 6 | describe(build, () => { 7 | afterEach(() => document.body.innerHTML = '') 8 | 9 | test('creates an element with given stuff.', () => { 10 | const { html } = build(domFactory()) 11 | const comp = (name: string) => html`
hello ${name}
` 12 | document.body.appendChild(comp('world')) 13 | 14 | const div$ = document.querySelector('div') 15 | expect(div$).not.toBeNull() 16 | expect(div$!.textContent).toBe('hello world') 17 | }) 18 | 19 | test('caches templates and re-uses them.', () => { 20 | const ogCreateElement = document.createElement 21 | const cb = jest.fn() 22 | document.createElement = (tag, options) => { 23 | if (tag === 'template') { 24 | cb() 25 | } 26 | 27 | return ogCreateElement.call(document, tag, options) 28 | } 29 | 30 | const { html } = build(domFactory()) 31 | 32 | const comp = (name: string) => html`
hello ${name}
` 33 | 34 | document.body.appendChild(comp('world')) 35 | document.body.appendChild(comp('jack')) 36 | 37 | expect(cb).toHaveBeenCalledTimes(1) 38 | const div$ = document.querySelectorAll('div').item(0) 39 | const div2$ = document.querySelectorAll('div').item(1) 40 | 41 | expect(div$).not.toBeNull() 42 | expect(div2$).not.toBeNull() 43 | expect(div$!.textContent).toBe('hello world') 44 | expect(div2$!.textContent).toBe('hello jack') 45 | 46 | document.createElement = ogCreateElement 47 | }) 48 | 49 | test('the cache can be cleaned.', () => { 50 | const ogCreateElement = document.createElement 51 | const cb = jest.fn() 52 | document.createElement = (tag, options) => { 53 | if (tag === 'template') { 54 | cb() 55 | } 56 | 57 | return ogCreateElement.call(document, tag, options) 58 | } 59 | 60 | const { html, cached } = build(domFactory()) 61 | 62 | document.body.append(html`
Hellow!
`) 63 | document.body.append(html`
Hellow!
`) 64 | 65 | expect(cb).toHaveBeenCalledTimes(1) 66 | 67 | cached.clear() 68 | 69 | document.body.append(html`
Hellow!
`) 70 | 71 | expect(cb).toHaveBeenCalledTimes(2) 72 | }) 73 | 74 | test('creates a template that can create elements.', () => { 75 | const { template } = build(domFactory()) 76 | 77 | const tmpl = template`
hello ${'world'}
` 78 | document.body.append(tmpl.create()) 79 | 80 | const div$ = document.querySelector('div') 81 | expect(div$).not.toBeNull() 82 | expect(div$!.textContent).toBe('hello world') 83 | }) 84 | 85 | test('can hydrate other elements.', () => { 86 | const cb = jest.fn() 87 | 88 | const { template } = build(extend(domFactory(), functionalEventListenerExt)) 89 | 90 | const tmpl = template`
hello ${'world'}
` 91 | document.body.innerHTML = '
hi
' 92 | const div$ = document.querySelector('div')! 93 | tmpl.hydrate(div$) 94 | 95 | expect(div$.textContent).toBe('hi world') 96 | expect(div$.querySelector('i')!.textContent).toBe('world') 97 | div$.click() 98 | expect(cb).toHaveBeenCalledTimes(1) 99 | }) 100 | 101 | test('can hydrate other elements from their root too', () => { 102 | const cb = jest.fn() 103 | 104 | const { template } = build(extend(domFactory(), functionalEventListenerExt)) 105 | 106 | const tmpl = template`CLICK ME!${'World'}` 107 | 108 | document.body.innerHTML = '
CLICK ME!jack
' 109 | const div$ = document.querySelector('div')! 110 | tmpl.hydrateRoot(div$) 111 | 112 | expect(div$.textContent).toBe('CLICK ME!World') 113 | expect(div$.querySelector('i')!.textContent).toBe('World') 114 | div$.querySelector('b')!.click() 115 | expect(cb).toHaveBeenCalledTimes(1) 116 | }) 117 | 118 | test('can embed elements in each other.', () => { 119 | const { html } = build(domFactory()) 120 | 121 | const c1 = html`
World!
` 122 | const c2 = html`
halo ${c1}
` 123 | 124 | document.body.appendChild(c2) 125 | 126 | const div$ = document.querySelector('div') 127 | expect(div$).not.toBeNull() 128 | expect(div$!.textContent).toBe('halo World!') 129 | }) 130 | 131 | test('can embed arrays in elements.', () => { 132 | const { html } = build(domFactory()) 133 | 134 | const c1 = html`World` 135 | const c2 = html`
halo ${[c1, '!']}
` 136 | 137 | document.body.appendChild(c2) 138 | 139 | const div$ = document.querySelector('div') 140 | expect(div$).not.toBeNull() 141 | expect(div$!.textContent).toBe('halo World!') 142 | }) 143 | }) 144 | -------------------------------------------------------------------------------- /src/test/exports.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock('htm/mini', () => require('htm/mini/index.umd.js')) 2 | 3 | import { 4 | buildRecipe, Recipe, 5 | 6 | CreateFn, AttributeFn, AppendFn, FillFn, DOMFactory, 7 | CreateFnExt, AttributeFnExt, AppendFnExt, FillFnExt, DOMFactoryExt, 8 | extend, nullFactory, domFactory, 9 | 10 | functionalEventListenerExt, objectPropsExt, refExt, 11 | 12 | cache, build, re, 13 | 14 | } from '../index' 15 | 16 | 17 | test('everything is exported properly.', () => { 18 | expect(buildRecipe).not.toBe(undefined) 19 | expect(Recipe).not.toBe(undefined) 20 | 21 | expect({}).not.toBe(undefined) 22 | expect({}).not.toBe(undefined) 23 | expect({}).not.toBe(undefined) 24 | expect({}).not.toBe(undefined) 25 | expect({}).not.toBe(undefined) 26 | expect({}).not.toBe(undefined) 27 | expect({}).not.toBe(undefined) 28 | expect({}).not.toBe(undefined) 29 | expect({}).not.toBe(undefined) 30 | expect({}).not.toBe(undefined) 31 | expect(extend).not.toBe(undefined) 32 | expect(nullFactory).not.toBe(undefined) 33 | expect(domFactory).not.toBe(undefined) 34 | 35 | expect(functionalEventListenerExt).not.toBe(undefined) 36 | expect(objectPropsExt).not.toBe(undefined) 37 | expect(refExt).not.toBe(undefined) 38 | 39 | expect(cache).not.toBe(undefined) 40 | expect(build).not.toBe(undefined) 41 | expect(re).not.toBe(undefined) 42 | }) 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/test/no-window.ssr-test.ts: -------------------------------------------------------------------------------- 1 | jest.mock('htm/mini', () => require('htm/mini/index.umd.js')) 2 | 3 | import { JSDOM } from 'jsdom' 4 | 5 | import { html, template, recipe, cached, re } from '../index' 6 | 7 | 8 | describe('no window', () => { 9 | test('default functions should throw error.', () => { 10 | expect(() => html`
hello
`).toThrow() 11 | expect(() => template`
hello
`).toThrow() 12 | expect(() => recipe`
hello
`).toThrow() 13 | expect(() => cached.get`
hello
`).toThrow() 14 | expect(() => cached.clear()).toThrow() 15 | }) 16 | 17 | test('re should return functions that do not throw.', () => { 18 | const document = new JSDOM().window.document 19 | const { html: h, template: t, recipe: r, cached: c } = re(document) 20 | 21 | expect(() => h`
hello
`).not.toThrow() 22 | expect(() => t`
hello
`).not.toThrow() 23 | expect(() => r`
hello
`).not.toThrow() 24 | expect(() => c.get`
hello
`).not.toThrow() 25 | expect(() => c.clear()).not.toThrow() 26 | }) 27 | 28 | test('creates an element with given stuff.', () => { 29 | const document = new JSDOM().window.document 30 | const { html: h } = re(document) 31 | const comp = (name: string) => h`
hello ${name}
` 32 | document.body.appendChild(comp('world')) 33 | 34 | const div$ = document.querySelector('div') 35 | expect(div$).not.toBeNull() 36 | expect(div$!.textContent).toBe('hello world') 37 | }) 38 | 39 | test('can hydrate other elements.', () => { 40 | const cb = jest.fn() 41 | const document = new JSDOM().window.document 42 | 43 | const { template: t } = re(document) 44 | 45 | const tmpl = t`
hello ${'world'}
` 46 | document.body.innerHTML = '
hi
' 47 | const div$ = document.querySelector('div')! 48 | tmpl.hydrate(div$) 49 | 50 | expect(div$.textContent).toBe('hi world') 51 | expect(div$.querySelector('i')!.textContent).toBe('world') 52 | div$.click() 53 | expect(cb).toHaveBeenCalledTimes(1) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /src/test/re.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock('htm/mini', () => require('htm/mini/index.umd.js')) 2 | 3 | import { type } from 'ts-inference-check' 4 | 5 | import { HTMLBuilderFn, TemplateBuilderFn, RecipeBuilderFn, CachedBuilder } from '../types' 6 | import { re, ref } from '../index' 7 | 8 | describe(re, () => { 9 | test('uses the same cache for the same document object.', () => { 10 | const { cached } = re(document) 11 | const { cached: cached2 } = re(document) 12 | 13 | expect(cached.get`
hello
`).toBe(cached2.get`
hello
`) 14 | }) 15 | 16 | test('re has correct typing.', () => { 17 | const { cached, template, html, recipe } = re(document) 18 | 19 | expect(type(html).is(true)).toBe(true) 20 | expect(type(template).is(true)).toBe(true) 21 | expect(type(recipe).is(true)).toBe(true) 22 | expect(type(cached).is(true)).toBe(true) 23 | 24 | expect(type(html).is(false)).toBe(false) 25 | expect(type(template).is(false)).toBe(false) 26 | expect(type(recipe).is(false)).toBe(false) 27 | expect(type(cached).is(false)).toBe(false) 28 | }) 29 | 30 | test('applies default plugins in correct order.', () => { 31 | const cb = jest.fn() 32 | 33 | const { html } = re(document) 34 | const D = ref() 35 | 36 | document.body.appendChild(html`
`) 37 | const div = document.body.querySelector('div')! 38 | div.dispatchEvent(new CustomEvent('customevent')) 39 | 40 | expect(D.current).toBe(div) 41 | expect(cb).toHaveBeenCalled() 42 | expect((div as any).prop.x).toBe(42) 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Recipe } from './template' 2 | 3 | 4 | export type HTMLBuilderFn = (strings: TemplateStringsArray, ...values: unknown[]) => DocumentFragment 5 | export type TemplateBuilderFn = (strings: TemplateStringsArray, ...values: unknown[]) => { 6 | hydrate: (node: Node) => void 7 | hydrateRoot: (root: Node) => void 8 | create: () => DocumentFragment 9 | } 10 | export type RecipeBuilderFn = (strings: TemplateStringsArray, ...values: unknown[]) => Recipe 11 | export type CachedBuilder = { 12 | get: RecipeBuilderFn, 13 | clear: () => void, 14 | } 15 | 16 | export type BuildSuite = { 17 | html: HTMLBuilderFn, 18 | template: TemplateBuilderFn, 19 | recipe: RecipeBuilderFn, 20 | cached: CachedBuilder, 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./conf/typescript/base.json", 3 | "ts-node": { 4 | "compilerOptions": { 5 | "module": "commonjs" 6 | } 7 | } 8 | } 9 | --------------------------------------------------------------------------------