├── .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 | [](https://bundlejs.com/?q=rehtm)
4 | [](https://www.npmjs.com/package/rehtm)
5 | [](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 | alert('CLICKED!')}>
85 | Click ME!
86 |
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` tmpl.hydrate(div)}>HYDRATE `)
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 | Click ME!
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 |
--------------------------------------------------------------------------------