├── .editorconfig ├── .github ├── release-please │ ├── config.json │ └── manifest.json └── workflows │ ├── codeql-analysis.yml │ ├── compliance.yml │ ├── dependency-review.yml │ ├── lint.yml │ ├── nodejs.yml │ ├── release-please.yml │ └── types.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-push ├── .knip.jsonc ├── .npmrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── declaration.tsconfig.json ├── eslint.config.mjs ├── index.d.mts ├── index.d.ts ├── index.js ├── index.mjs ├── index.test-d.ts ├── lib ├── element-types.d.ts ├── htm.js ├── react-utils.js ├── render-utils.mjs ├── render.js ├── util-types.d.ts └── utils.js ├── package.json ├── renovate.json ├── test ├── fixtures.js ├── html.spec.js ├── render-basic.spec.js └── render-complex.spec.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.github/release-please/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/v16.12.0/schemas/config.json", 3 | "release-type": "node", 4 | "include-component-in-tag": false, 5 | "changelog-sections": [ 6 | { "type": "feat", "section": "🌟 Features", "hidden": false }, 7 | { "type": "fix", "section": "🩹 Fixes", "hidden": false }, 8 | { "type": "docs", "section": "📚 Documentation", "hidden": false }, 9 | 10 | { "type": "chore", "section": "🧹 Chores", "hidden": false }, 11 | { "type": "perf", "section": "🧹 Chores", "hidden": false }, 12 | { "type": "refactor", "section": "🧹 Chores", "hidden": false }, 13 | { "type": "test", "section": "🧹 Chores", "hidden": false }, 14 | 15 | { "type": "build", "section": "🤖 Automation", "hidden": false }, 16 | { "type": "ci", "section": "🤖 Automation", "hidden": true } 17 | ], 18 | "packages": { 19 | ".": {} 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/release-please/manifest.json: -------------------------------------------------------------------------------- 1 | {".":"3.0.2"} 2 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | schedule: 9 | - cron: '24 4 * * 2' 10 | 11 | permissions: 12 | actions: read 13 | contents: read 14 | security-events: write 15 | 16 | jobs: 17 | analyze: 18 | uses: voxpelli/ghatemplates/.github/workflows/codeql-analysis.yml@main 19 | -------------------------------------------------------------------------------- /.github/workflows/compliance.yml: -------------------------------------------------------------------------------- 1 | name: Compliance 2 | 3 | on: 4 | pull_request_target: 5 | types: [opened, edited, reopened] 6 | 7 | permissions: 8 | pull-requests: write 9 | 10 | jobs: 11 | compliance: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: mtfoley/pr-compliance-action@11b664f0fcf2c4ce954f05ccfcaab6e52b529f86 15 | with: 16 | body-auto-close: false 17 | body-regex: '.*' 18 | ignore-authors: | 19 | renovate 20 | renovate[bot] 21 | ignore-team-members: false 22 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | name: 'Dependency Review' 2 | 3 | on: [pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | dependency-review: 10 | uses: voxpelli/ghatemplates/.github/workflows/dependency-review.yml@main 11 | 12 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '*' 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | lint: 18 | uses: voxpelli/ghatemplates/.github/workflows/lint.yml@main 19 | types: 20 | needs: [lint] 21 | uses: ./.github/workflows/types.yml 22 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '*' 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | test: 18 | uses: voxpelli/ghatemplates/.github/workflows/test.yml@main 19 | with: 20 | node-versions: '18,20,22' 21 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | name: Release Please 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | id-token: write 11 | packages: write 12 | pull-requests: write 13 | 14 | jobs: 15 | release-please: 16 | uses: voxpelli/ghatemplates/.github/workflows/release-please-4.yml@main 17 | secrets: inherit 18 | -------------------------------------------------------------------------------- /.github/workflows/types.yml: -------------------------------------------------------------------------------- 1 | name: Type Checks 2 | 3 | on: 4 | workflow_call: 5 | workflow_dispatch: 6 | schedule: 7 | - cron: '14 5 * * 1,3,5' 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | type-check: 14 | uses: voxpelli/ghatemplates/.github/workflows/type-check.yml@main 15 | with: 16 | ts-prebuild-script: 'build' 17 | ts-versions: ${{ github.event.schedule && 'next' || '5.6,next' }} 18 | ts-libs: 'es2022;esnext' 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Basic ones 2 | /coverage 3 | /docs 4 | /node_modules 5 | /.env 6 | /.nyc_output 7 | 8 | # We're a library, so please, no lock files 9 | /package-lock.json 10 | /yarn.lock 11 | 12 | # Generated types 13 | *.d.ts 14 | *.d.ts.map 15 | *.d.mts 16 | *.d.mts.map 17 | !/lib/**/*-types.d.ts 18 | !/index.d.ts 19 | !/index.d.mts 20 | 21 | # Library specific ones 22 | /lib/render.mjs 23 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | 2 | npx --no validate-conventional-commit < .git/COMMIT_EDITMSG 3 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | 2 | npm test 3 | -------------------------------------------------------------------------------- /.knip.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/knip@5/schema.json", 3 | "ignore": ["index.test-d.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [3.0.2](https://github.com/voxpelli/async-htm-to-string/compare/v3.0.1...v3.0.2) (2024-10-02) 4 | 5 | 6 | ### 🩹 Fixes 7 | 8 | * handle nested array values ([a469cdf](https://github.com/voxpelli/async-htm-to-string/commit/a469cdff7db27600cb4534a239ba40fbd3eab79b)) 9 | 10 | 11 | ### 🧹 Chores 12 | 13 | * **deps:** update dependency sinon to v19 ([#100](https://github.com/voxpelli/async-htm-to-string/issues/100)) ([0f5c1b6](https://github.com/voxpelli/async-htm-to-string/commit/0f5c1b6ce661af044e3ff200f5ce48da85dcc6d6)) 14 | * **deps:** update dev dependencies ([6bd5be8](https://github.com/voxpelli/async-htm-to-string/commit/6bd5be8d20d1e3496fe396eef4fb977bd5ce5b13)) 15 | * **deps:** update dev dependencies ([13de112](https://github.com/voxpelli/async-htm-to-string/commit/13de112b2383223d2e900f595cbbd56cd07a8796)) 16 | * **deps:** update dev dependencies ([668f437](https://github.com/voxpelli/async-htm-to-string/commit/668f437ab14907143619c7711300d68f299587cb)) 17 | * fix failing voxpelli/tsconfig integration ([2a3b9f3](https://github.com/voxpelli/async-htm-to-string/commit/2a3b9f3845fe67ed44d27bccce0eb7bd8e0f9415)) 18 | 19 | ## [3.0.1](https://github.com/voxpelli/async-htm-to-string/compare/v3.0.0...v3.0.1) (2024-07-19) 20 | 21 | 22 | ### 🧹 Chores 23 | 24 | * clean up leftover `linemod` stuff ([20cb561](https://github.com/voxpelli/async-htm-to-string/commit/20cb561b220a55a8159b65d1e49877cc5933857f)) 25 | * **deps:** update dependency @types/node to ^18.19.41 ([#99](https://github.com/voxpelli/async-htm-to-string/issues/99)) ([10e2748](https://github.com/voxpelli/async-htm-to-string/commit/10e2748b2ba64541f9a6e73e412d73dbf63eaa56)) 26 | * **deps:** update dependency chai-as-promised to ^7.1.2 ([#96](https://github.com/voxpelli/async-htm-to-string/issues/96)) ([2ca97cf](https://github.com/voxpelli/async-htm-to-string/commit/2ca97cf409876ed8ae65d151cf4d1ac449d69487)) 27 | * improve types ([1e60f45](https://github.com/voxpelli/async-htm-to-string/commit/1e60f45c7f14fdeb240ac9c54bd46b522b35599e)) 28 | * less `any` ([a5940dd](https://github.com/voxpelli/async-htm-to-string/commit/a5940dd15cf5f2927ae5c6201c3972f732e7cf65)) 29 | * make use of `buffered-async-iterable` ([9cdf3a7](https://github.com/voxpelli/async-htm-to-string/commit/9cdf3a71235da8d718f354264383b0c4f47a220c)) 30 | * simplify bufferedAsyncMap call ([37af84e](https://github.com/voxpelli/async-htm-to-string/commit/37af84e6e75da4f128afad6e135b41b4c78e7379)) 31 | * split into different files ([91e2d91](https://github.com/voxpelli/async-htm-to-string/commit/91e2d9125a583447a7f354fca6cd7ab2bca548f0)), closes [#103](https://github.com/voxpelli/async-htm-to-string/issues/103) 32 | 33 | ## [3.0.0](https://github.com/voxpelli/async-htm-to-string/compare/v2.1.1...v3.0.0) (2024-07-18) 34 | 35 | 36 | ### ⚠ BREAKING CHANGES 37 | 38 | * drop Node 16 (as its EOL) 39 | 40 | ### 🧹 Chores 41 | 42 | * **deps:** update dev dependencies ([417aeaa](https://github.com/voxpelli/async-htm-to-string/commit/417aeaa13414689e98319e53ac6b6b924caa4df2)) 43 | * drop Node 16 (as its EOL) ([3aaeb85](https://github.com/voxpelli/async-htm-to-string/commit/3aaeb85548fc6042c2d417b1522c07df50f6e06b)) 44 | * use neostandard based linting ([35f6a0b](https://github.com/voxpelli/async-htm-to-string/commit/35f6a0bf637a0cb807b1fc2cec7fd15d52ecc66a)) 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD Zero Clause License (0BSD) 2 | 3 | Copyright (c) 2020 Pelle Wessman 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 9 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 10 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 11 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 12 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 13 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 14 | PERFORMANCE OF THIS SOFTWARE. 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # async-htm-to-string 2 | 3 | Renders a [`htm`](https://www.npmjs.com/package/htm) tagged template asyncly into a string. 4 | 5 | [![npm version](https://img.shields.io/npm/v/async-htm-to-string.svg?style=flat)](https://www.npmjs.com/package/async-htm-to-string) 6 | [![npm downloads](https://img.shields.io/npm/dm/async-htm-to-string.svg?style=flat)](https://www.npmjs.com/package/async-htm-to-string) 7 | [![Module type: CJS+ESM](https://img.shields.io/badge/module%20type-cjs%2Besm-brightgreen)](https://github.com/voxpelli/badges-cjs-esm) 8 | [![Types in JS](https://img.shields.io/badge/types_in_js-yes-brightgreen)](https://github.com/voxpelli/types-in-js) 9 | [![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-7fffff?style=flat&labelColor=ff80ff)](https://github.com/neostandard/neostandard) 10 | [![Follow @voxpelli@mastodon.social](https://img.shields.io/mastodon/follow/109247025527949675?domain=https%3A%2F%2Fmastodon.social&style=social)](https://mastodon.social/@voxpelli) 11 | 12 | ## Usage 13 | 14 | ### Simple 15 | 16 | ```bash 17 | npm install async-htm-to-string 18 | ``` 19 | 20 | ```javascript 21 | const { html, renderToString } = require('async-htm-to-string'); 22 | 23 | const customTag = ({ prefix }, children) => html`
${prefix}-${children}
`; 24 | const dynamicContent = 'bar'; 25 | // Will equal "
foo-bar
26 | const result = await renderToString(html`<${customTag} prefix="foo">${dynamicContent}`); 27 | ``` 28 | 29 | ## API 30 | 31 | ### `html` 32 | 33 | Is `h()` bound to [`htm`](https://www.npmjs.com/package/htm) (`htm.bind(h)`). Used with template literals, like: 34 | 35 | ```javascript 36 | const renderableElement = html`
${content}
`; 37 | ``` 38 | 39 | ### `rawHtml / rawHtml(rawString)` 40 | 41 | If you need to provide pre-escaped raw HTML content, then you can use `rawHtml` as either a template literal or by calling it with the 42 | 43 | ```javascript 44 | const renderableElement = rawHtml`
&${'"'}
`; 45 | ``` 46 | 47 | ```javascript 48 | const renderableElement = rawHtml('
&
'); 49 | ``` 50 | 51 | You can also use the result of any of those `rawHtml` inside `html`, like: 52 | 53 | ```javascript 54 | const renderableElement = html`
${rawHtml`&`}
`; 55 | ``` 56 | 57 | ### `h(type, props, ...children)` 58 | 59 | The inner method that's `htm` is bound to. 60 | 61 | ### `render(renderableElement)` 62 | 63 | Takes the output from `html` and returns an async iterator that yields the strings as they are rendered 64 | 65 | ### `renderToString(renderableElement)` 66 | 67 | Same as `render()`, but asyncly returns a single string with the fully rendered result, rather than an async iterator. 68 | 69 | ## Helpers 70 | 71 | ### `generatorToString(somethingIterable)` 72 | 73 | Asyncly loops over an iterable (like eg. an async iterable) and concatenates together the result into a single string that it resolves to. The brains behind `renderToString()`. 74 | -------------------------------------------------------------------------------- /declaration.tsconfig.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "extends": "./tsconfig", 4 | "files": [], 5 | "exclude": [ 6 | "test/**/*.js" 7 | ], 8 | "compilerOptions": { 9 | "declaration": true, 10 | "declarationMap": true, 11 | "noEmit": false, 12 | "emitDeclarationOnly": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { voxpelli } from '@voxpelli/eslint-config'; 2 | 3 | export default voxpelli({ cjs: true }); 4 | -------------------------------------------------------------------------------- /index.d.mts: -------------------------------------------------------------------------------- 1 | export type * from './lib/element-types.d.ts'; 2 | 3 | export { h, html, rawHtml } from './lib/htm.js'; 4 | export { render, renderToString } from './lib/render.mjs'; 5 | export { generatorToString } from './lib/utils.js'; 6 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export type * from './lib/element-types.d.ts'; 2 | 3 | export { h, html, rawHtml } from './lib/htm.js'; 4 | export { render, renderToString } from './lib/render.js'; 5 | export { generatorToString } from './lib/utils.js'; 6 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const { 2 | h, 3 | html, 4 | rawHtml, 5 | } = require('./lib/htm'); 6 | 7 | const { 8 | render, 9 | renderToString, 10 | } = require('./lib/render'); 11 | 12 | const { 13 | generatorToString, 14 | } = require('./lib/utils'); 15 | 16 | module.exports = { 17 | generatorToString, 18 | h, 19 | html, 20 | rawHtml, 21 | render, 22 | renderToString, 23 | }; 24 | -------------------------------------------------------------------------------- /index.mjs: -------------------------------------------------------------------------------- 1 | export { h, html, rawHtml } from './lib/htm.js'; 2 | export { render, renderToString } from './lib/render.mjs'; 3 | export { generatorToString } from './lib/utils.js'; 4 | -------------------------------------------------------------------------------- /index.test-d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/no-null */ 2 | 3 | import { 4 | expectAssignable, 5 | expectNotAssignable, 6 | expectError, 7 | expectType, 8 | } from 'tsd'; 9 | 10 | import { 11 | html, 12 | h, 13 | render, 14 | renderToString, 15 | ElementProps, 16 | HtmlMethodResult, 17 | RenderableElement, 18 | RenderableElementFunction, 19 | BasicRenderableElement, 20 | } from '.'; 21 | 22 | type ExtendableRenderableElementFunction = (props: Props, children: RenderableElement[]) => HtmlMethodResult; 23 | 24 | const invalidValues = [ 25 | 123, 26 | null, 27 | undefined, 28 | false, 29 | true, 30 | () => {}, 31 | Symbol.asyncIterator, 32 | ]; 33 | 34 | for (const item of invalidValues) { 35 | expectNotAssignable(item); 36 | expectNotAssignable([item]); 37 | } 38 | 39 | expectAssignable('foo'); 40 | expectAssignable({ type: 'div', props: {}, children: [] }); 41 | expectAssignable(''); 42 | expectAssignable([{ type: 'div', props: {}, children: [] }, 'foo']); 43 | 44 | expectType(html`
`); 45 | 46 | const abc: RenderableElementFunction<{}> = (_props, children) => html`${children}`; 47 | const bar: RenderableElementFunction<{}> = (_props, children) => html`<${abc}>${children}`; 48 | 49 | const customPropsElem: ExtendableRenderableElementFunction<{ foo: number }> = ({ foo }, children) => html`${children}`; 50 | const customPropsElem2: ExtendableRenderableElementFunction<{ foo: Record }> = ({ foo }, children) => html`${children}`; 51 | 52 | expectAssignable>(customPropsElem); 53 | expectAssignable>(customPropsElem2); 54 | 55 | // TODO: Eventually make this be an error 56 | // expectError(html`<${customPropsElem} />Foo`); 57 | expectType(html`<${customPropsElem} foo=${123} />`); 58 | expectType(html`<${customPropsElem2} foo=${{ key: true }} />`); 59 | 60 | // TODO: For some reason no longer an error, should it be? 61 | // expectError(h(abc, { foo: null })); 62 | expectError(h(customPropsElem, { yay: 123 })); 63 | expectError(h(customPropsElem2, { foo: 123 })); 64 | 65 | expectType>(h( 66 | customPropsElem, 67 | { foo: 123 }, 68 | h(customPropsElem2, { foo: { key: true } }) 69 | )); 70 | 71 | const complexElementTree: HtmlMethodResult = { 72 | type: 'div', 73 | props: { 'class': 'prop1 prop2', 'data-foo': '123' }, 74 | children: [ 75 | ' ', 76 | { type: 'img', props: { src: '#' }, children: [] }, 77 | ' ', 78 | { 79 | type: bar, 80 | props: {}, 81 | children: [ 82 | ' ', 83 | { 84 | type: 'woot', 85 | props: {}, 86 | children: ['YEA&H!', '
w0000000000t
'], 87 | }, 88 | ], 89 | }, 90 | ], 91 | }; 92 | 93 | expectAssignable(complexElementTree); 94 | 95 | expectError(render()); 96 | expectError(render(false)); 97 | 98 | expectType>(render('foo')); 99 | expectType>(render(complexElementTree)); 100 | 101 | expectType>(renderToString('foo')); 102 | expectType>(renderToString(complexElementTree)); 103 | -------------------------------------------------------------------------------- /lib/element-types.d.ts: -------------------------------------------------------------------------------- 1 | import type { MaybeArray } from './util-types.d.ts'; 2 | 3 | export type ElementPropsValue = 4 | undefined | 5 | boolean | 6 | string | 7 | number | 8 | // TODO: Can the values below be set to unknown instead? 9 | any[] | 10 | Record | 11 | Set | 12 | Map; 13 | 14 | export type ElementProps = { 15 | [key: string]: ElementPropsValue; 16 | }; 17 | 18 | export type RenderableElement = string | number | BasicRenderableElement; 19 | 20 | // TODO: Where is the asyncness here?! 21 | export type HtmlMethodResult = MaybeArray | string>; 22 | 23 | // TODO: Where is the asyncness here?! 24 | export type RenderableElementFunction = (props: Props, children: RenderableElement[]) => HtmlMethodResult; 25 | export type SimpleRenderableElementFunction = RenderableElementFunction; 26 | 27 | // TODO: Where is the asyncness here?! 28 | export interface BasicRenderableElement { 29 | type: string | RenderableElementFunction; 30 | props: Props; 31 | children: RenderableElement[]; 32 | skipStringEscape?: boolean; 33 | } 34 | 35 | export interface StringRenderableElement extends BasicRenderableElement { 36 | type: string; 37 | skipStringEscape?: never; 38 | } 39 | -------------------------------------------------------------------------------- /lib/htm.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const htm = require('htm'); 4 | 5 | /** @import { BasicRenderableElement, ElementProps, ElementPropsValue, HtmlMethodResult, RenderableElement, RenderableElementFunction } from './element-types.js' */ 6 | 7 | /** 8 | * @template {ElementProps} T 9 | * @param {string|RenderableElementFunction} type 10 | * @param {T} props 11 | * @param {...RenderableElement} children 12 | * @returns {BasicRenderableElement} 13 | */ 14 | const h = (type, props, ...children) => { 15 | return { type, props: props || {}, children }; 16 | }; 17 | 18 | /** @type {(strings: TemplateStringsArray, ...values: Array|RenderableElement|RenderableElement[]>) => unknown} */ 19 | const _internalHtml = 20 | // @ts-ignore 21 | htm.bind(h); 22 | 23 | /** 24 | * @param {unknown} result 25 | * @returns {BasicRenderableElement|string} 26 | */ 27 | const _checkHtmlResult = (result) => { 28 | if (typeof result === 'number') { 29 | return result + ''; 30 | } else if (!result) { 31 | return ''; 32 | } else if (typeof result === 'string') { 33 | return result; 34 | } else if (typeof result === 'object' && result !== null) { 35 | if (Array.isArray(result)) { 36 | throw new TypeError('Unexpected nested array value found'); 37 | } 38 | /** @type {BasicRenderableElement} */ 39 | // @ts-ignore 40 | const element = result; 41 | const { children = [], props = {}, type } = element; 42 | 43 | if (typeof type === 'string' || typeof type === 'function') { 44 | return { type, props, children: children.flat() }; 45 | } 46 | 47 | throw new TypeError(`Resolved to invalid type of object value "type" property: ${typeof type}`); 48 | } else { 49 | throw new TypeError(`Resolved to invalid value type: ${typeof result}`); 50 | } 51 | }; 52 | 53 | /** 54 | * @param {TemplateStringsArray} strings 55 | * @param {...ElementPropsValue|ElementProps|RenderableElementFunction|RenderableElement|RenderableElement[]} values 56 | * @returns {HtmlMethodResult} 57 | */ 58 | const html = (strings, ...values) => { 59 | const result = _internalHtml(strings, ...values); 60 | 61 | if (!Array.isArray(result)) return _checkHtmlResult(result); 62 | 63 | /** @type {unknown[]} */ 64 | const unknownArray = result; 65 | 66 | return unknownArray.flat().map(item => _checkHtmlResult(item)); 67 | }; 68 | 69 | /** 70 | * @param {TemplateStringsArray|string} strings 71 | * @param {...(string|number)} values 72 | * @returns {BasicRenderableElement<{}>} 73 | */ 74 | const rawHtml = (strings, ...values) => { 75 | /** @type {RenderableElementFunction<{}>} */ 76 | const type = () => typeof strings === 'string' ? strings : String.raw(strings, ...values); 77 | return { type, props: {}, children: [], skipStringEscape: true }; 78 | }; 79 | 80 | module.exports = { 81 | html, 82 | h, 83 | rawHtml, 84 | }; 85 | -------------------------------------------------------------------------------- /lib/react-utils.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable regexp/no-useless-range */ 2 | 3 | // *** REACT BORROWED *** 4 | 5 | const ATTRIBUTE_NAME_START_CHAR = 6 | ':A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD'; 7 | const ATTRIBUTE_NAME_CHAR = 8 | // eslint-disable-next-line regexp/prefer-w 9 | ATTRIBUTE_NAME_START_CHAR + '\\-.0-9\\u00B7\\u0300-\\u036F\\u203F-\\u2040'; 10 | 11 | // eslint-disable-next-line no-misleading-character-class 12 | const VALID_ATTRIBUTE_NAME_REGEX = new RegExp('^[' + ATTRIBUTE_NAME_START_CHAR + '][' + ATTRIBUTE_NAME_CHAR + ']*$'); 13 | 14 | const VALID_TAG_REGEX = /^[a-z][\w.:-]*$/i; // Simplified subset 15 | 16 | /** @type {Map} */ 17 | const validatedTagCache = new Map(); 18 | /** 19 | * @param {string} tag 20 | * @returns {boolean} 21 | */ 22 | const isTagValid = (tag) => { 23 | const cached = validatedTagCache.get(tag); 24 | 25 | if (cached !== undefined) return cached; 26 | 27 | const result = VALID_TAG_REGEX.test(tag); 28 | validatedTagCache.set(tag, result); 29 | return result; 30 | }; 31 | 32 | /** @type {Map} */ 33 | const validatedAttributeNameCache = new Map(); 34 | /** 35 | * @param {string} name 36 | * @returns {boolean} 37 | */ 38 | const isAttributeNameValid = (name) => { 39 | const cached = validatedAttributeNameCache.get(name); 40 | 41 | if (cached !== undefined) return cached; 42 | 43 | const result = VALID_ATTRIBUTE_NAME_REGEX.test(name); 44 | validatedAttributeNameCache.set(name, result); 45 | return result; 46 | }; 47 | 48 | const omittedCloseTags = /** @type {const} */ ({ 49 | area: true, 50 | base: true, 51 | br: true, 52 | col: true, 53 | embed: true, 54 | hr: true, 55 | img: true, 56 | input: true, 57 | keygen: true, 58 | link: true, 59 | meta: true, 60 | param: true, 61 | source: true, 62 | track: true, 63 | wbr: true, 64 | }); 65 | 66 | module.exports = { 67 | isTagValid, 68 | isAttributeNameValid, 69 | omittedCloseTags, 70 | }; 71 | -------------------------------------------------------------------------------- /lib/render-utils.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable func-style */ 2 | 3 | import { bufferedAsyncMap } from 'buffered-async-iterable'; 4 | import { stringifyEntities } from 'stringify-entities'; 5 | 6 | import { isAttributeNameValid, isTagValid, omittedCloseTags } from './react-utils.js'; 7 | import { isAsyncIterable, isIterable } from './utils.js'; 8 | 9 | /** @import { BasicRenderableElement, ElementProps, RenderableElement, StringRenderableElement } from './element-types.js' */ 10 | /** @import { IterableIteratorMaybeAsync } from './util-types.js' */ 11 | 12 | /** 13 | * @yields {string} 14 | * @param {ElementProps} props 15 | * @returns {AsyncIterableIterator} 16 | */ 17 | async function * renderProps (props) { 18 | // *** REACT BORROWED https://github.com/facebook/react/blob/779a472b0901b2d28e382f3850b2ad09a555b014/packages/react-dom/src/server/DOMMarkupOperations.js#L48-L72 *** 19 | for (const propKey in props) { 20 | if (!Object.hasOwn(props, propKey)) { 21 | continue; 22 | } 23 | 24 | const propValue = props[propKey]; 25 | 26 | if (propValue === undefined) continue; 27 | if (propValue === null) continue; 28 | if (propValue === false) continue; 29 | 30 | if (!isAttributeNameValid(propKey)) { 31 | throw new Error(`Invalid attribute name: ${propKey}`); 32 | } 33 | 34 | if (propValue === true) { 35 | yield ` ${propKey}`; 36 | } else if (propValue === '') { 37 | yield ` ${propKey}=""`; 38 | } else if (typeof propValue === 'string') { 39 | yield ` ${propKey}="${stringifyEntities(propValue, { escapeOnly: true, useNamedReferences: true })}"`; 40 | } else if (typeof propValue === 'number') { 41 | yield ` ${propKey}="${propValue}"`; 42 | } else { 43 | // eslint-disable-next-line no-console 44 | console.error('Unexpected prop value type:', typeof propValue); 45 | } 46 | } 47 | // *** END REACT BORROWED *** 48 | } 49 | 50 | /** 51 | * @yields {string} 52 | * @template {ElementProps} Props 53 | * @param {StringRenderableElement} item 54 | * @returns {AsyncIterableIterator} 55 | */ 56 | async function * renderStringItem (item) { 57 | const { children, props, type } = item; 58 | 59 | const tag = type.toLowerCase(); 60 | 61 | if (type in omittedCloseTags) { 62 | yield `<${tag}`; 63 | yield * renderProps(props); 64 | yield ' />'; 65 | } else if (!isTagValid(tag)) { 66 | throw new Error(`Invalid tag name: ${tag}`); 67 | } else { 68 | yield `<${tag}`; 69 | yield * renderProps(props); 70 | yield '>'; 71 | yield * renderItem(children); 72 | yield ``; 73 | } 74 | } 75 | 76 | /** 77 | * @yields {string} 78 | * @template {ElementProps} Props 79 | * @param {BasicRenderableElement} item 80 | * @returns {AsyncIterableIterator} 81 | */ 82 | async function * renderElement (item) { 83 | const { children, props, skipStringEscape, type } = item; 84 | 85 | if (type === undefined) { 86 | throw new TypeError('Not an element definition. Missing type in: ' + JSON.stringify(item).slice(0, 50)); 87 | } else if (type === '') { 88 | yield * renderItem(children); 89 | } else if (typeof type === 'function') { 90 | // TODO: Why can't this be async? 91 | const result = type(props, children); 92 | if (!skipStringEscape) { 93 | yield * renderItem(result); 94 | } else if (typeof result !== 'string') { 95 | throw new TypeError('skipStringEscape can only be used with string results'); 96 | } else { 97 | yield result; 98 | } 99 | } else if (typeof type === 'string') { 100 | yield * renderStringItem({ type, props, children }); 101 | } else { 102 | throw new TypeError(`Invalid element type: ${typeof type}`); 103 | } 104 | } 105 | 106 | /** 107 | * @yields {string} 108 | * @param {IterableIteratorMaybeAsync} iterator 109 | * @returns {AsyncIterableIterator} 110 | */ 111 | async function * renderIterable (iterator) { 112 | yield * bufferedAsyncMap(iterator, renderItem, { ordered: true }); 113 | } 114 | 115 | /** 116 | * @yields {string} 117 | * @param {RenderableElement|IterableIteratorMaybeAsync} item 118 | * @returns {AsyncIterableIterator} 119 | */ 120 | export async function * renderItem (item) { 121 | if (item === undefined || item === null) { 122 | yield ''; 123 | } else if (typeof item === 'string') { 124 | yield stringifyEntities(item, { escapeOnly: true, useNamedReferences: true }); 125 | } else if (typeof item === 'number') { 126 | yield item + ''; 127 | } else { 128 | if (Array.isArray(item) || isIterable(item) || isAsyncIterable(item)) { 129 | yield * renderIterable(item); 130 | } else if (typeof item === 'object') { 131 | yield * renderElement(item); 132 | } else { 133 | throw new TypeError(`Invalid render item type: ${typeof item}`); 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /lib/render.js: -------------------------------------------------------------------------------- 1 | 'use strict'; // linemod-remove 2 | // linemod-add: import { renderItem } from './render-utils.mjs'; 3 | const { generatorToString } = require('./utils.js'); // linemod-replace-with: import { generatorToString } from './utils.js'; 4 | 5 | /** @import { HtmlMethodResult } from './element-types.js' */ 6 | 7 | /** 8 | * @yields {string} 9 | * @param {HtmlMethodResult} item 10 | * @returns {AsyncIterableIterator} 11 | */ 12 | const render = async function * (item) { // linemod-prefix-with: export 13 | if (item === undefined) throw new TypeError('Expected an argument'); 14 | if (!item) throw new TypeError(`Expected a non-falsy argument, got: ${item}`); 15 | if (Array.isArray(item)) { 16 | for (const value of item) { 17 | yield * render(value); 18 | } 19 | } else if (typeof item === 'string' || typeof item === 'object') { 20 | const { renderItem } = await import('./render-utils.mjs'); // linemod-remove 21 | yield * renderItem(item); 22 | } else { 23 | throw new TypeError(`Expected a string or an object, got: ${typeof item}`); 24 | } 25 | }; 26 | 27 | /** 28 | * @param {HtmlMethodResult} item 29 | * @returns {Promise} 30 | */ 31 | const renderToString = async (item) => generatorToString(render(item)); // linemod-prefix-with: export 32 | 33 | module.exports = { // linemod-remove 34 | render, // linemod-remove 35 | renderToString, // linemod-remove 36 | }; // linemod-remove 37 | -------------------------------------------------------------------------------- /lib/util-types.d.ts: -------------------------------------------------------------------------------- 1 | export type IterableIteratorMaybeAsync = T[] | IterableIterator | AsyncIterableIterator; 2 | export type MaybeArray = T | T[]; 3 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** @import { IterableIteratorMaybeAsync } from './util-types.d.ts' */ 4 | 5 | /** 6 | * @param {unknown} value 7 | * @returns {value is object} 8 | */ 9 | const isObject = (value) => typeof value === 'object' && value !== null; 10 | 11 | /** 12 | * @param {unknown} value 13 | * @returns {value is AsyncIterable} 14 | */ 15 | const isAsyncIterable = (value) => isObject(value) && Symbol.asyncIterator in value; 16 | 17 | /** 18 | * @param {unknown} value 19 | * @returns {value is Iterable} 20 | */ 21 | const isIterable = (value) => isObject(value) && Symbol.iterator in value; 22 | 23 | /** 24 | * @param {IterableIteratorMaybeAsync} generator 25 | * @returns {Promise} 26 | */ 27 | const generatorToString = async (generator) => { 28 | let result = ''; 29 | for await (const item of generator) { 30 | result += item; 31 | } 32 | return result; 33 | }; 34 | 35 | module.exports = { 36 | isAsyncIterable, 37 | isIterable, 38 | generatorToString, 39 | }; 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "async-htm-to-string", 3 | "version": "3.0.2", 4 | "description": "Renders a htm tagged template asyncly into a string", 5 | "homepage": "http://github.com/voxpelli/async-htm-to-string", 6 | "author": "Pelle Wessman (http://kodfabrik.se/)", 7 | "license": "0BSD", 8 | "repository": { 9 | "type": "git", 10 | "url": "git://github.com/voxpelli/async-htm-to-string.git" 11 | }, 12 | "main": "index.js", 13 | "types": "index.d.ts", 14 | "exports": { 15 | ".": { 16 | "import": { 17 | "types": "./index.d.mts", 18 | "default": "./index.mjs" 19 | }, 20 | "require": { 21 | "types": "./index.d.ts", 22 | "default": "./index.js" 23 | } 24 | } 25 | }, 26 | "engines": { 27 | "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 28 | }, 29 | "keywords": [ 30 | "tagged template", 31 | "template literals", 32 | "htm", 33 | "html", 34 | "async", 35 | "asyncgenerator", 36 | "asynciterator", 37 | "asynciterable", 38 | "promise", 39 | "server-side rendering", 40 | "ssr" 41 | ], 42 | "files": [ 43 | "index.d.mts.map", 44 | "index.d.mts", 45 | "index.d.ts.map", 46 | "index.d.ts", 47 | "index.js", 48 | "index.mjs", 49 | "lib/**/*.d.mts.map", 50 | "lib/**/*.d.mts", 51 | "lib/**/*.d.ts.map", 52 | "lib/**/*.d.ts", 53 | "lib/**/*.js", 54 | "lib/**/*.mjs" 55 | ], 56 | "scripts": { 57 | "build:0-clean": "run-s clean", 58 | "build:1-esm": "linemod -e mjs lib/render.js", 59 | "build:2-declaration": "tsc -p declaration.tsconfig.json", 60 | "build": "run-s build:*", 61 | "build-for-test": "run-s build:1-esm", 62 | "check:0": "run-s clean build:1-esm", 63 | "check:1:installed-check": "installed-check", 64 | "check:1:knip": "knip", 65 | "check:1:lint": "eslint --report-unused-disable-directives .", 66 | "check:1:skypack": "package-check", 67 | "check:1:tsc": "tsc", 68 | "check:1:type-coverage": "type-coverage --detail --strict --at-least 99 --ignore-files 'test/*'", 69 | "check:1": "run-p check:1:*", 70 | "check:2": "run-s build", 71 | "check:3": "tsd", 72 | "check": "run-s check:*", 73 | "clean:declarations-top": "rm -rf $(find . -maxdepth 1 -type f -name '*.d.ts*' ! -name 'index.d.ts')", 74 | "clean:declarations-lib": "rm -rf $(find lib -type f -name '*.d.ts*' ! -name '*-types.d.ts')", 75 | "clean:esm-declarations-top": "rm -rf $(find . -maxdepth 1 -type f -name '*.d.mts*' ! -name 'index.d.mts')", 76 | "clean:esm-declarations-lib": "rm -rf $(find lib -type f -name '*.d.mts*')", 77 | "clean": "run-p clean:*", 78 | "prepare": "husky", 79 | "prepublishOnly": "run-s build", 80 | "test:mocha": "c8 --reporter=lcov --reporter text mocha 'test/**/*.spec.js'", 81 | "test-ci": "run-s test:*", 82 | "test": "run-s check test:*", 83 | "watch:build": "nodemon -x \"run-s build\"", 84 | "watch:tsd": "nodemon -e ts -x \"npx tsd\"", 85 | "watch": "run-p watch:*" 86 | }, 87 | "dependencies": { 88 | "buffered-async-iterable": "^1.0.1", 89 | "htm": "^3.0.4", 90 | "stringify-entities": "^4.0.3" 91 | }, 92 | "devDependencies": { 93 | "@skypack/package-check": "^0.2.2", 94 | "@types/chai": "^4.3.20", 95 | "@types/chai-as-promised": "^7.1.8", 96 | "@types/mocha": "^10.0.9", 97 | "@types/node": "^18.19.69", 98 | "@types/sinon": "^17.0.3", 99 | "@types/sinon-chai": "^3.2.12", 100 | "@voxpelli/eslint-config": "^22.0.0", 101 | "@voxpelli/tsconfig": "^15.1.0", 102 | "c8": "^10.1.2", 103 | "chai": "^4.5.0", 104 | "chai-as-promised": "^7.1.2", 105 | "eslint": "^9.13.0", 106 | "husky": "^9.1.6", 107 | "installed-check": "^9.3.0", 108 | "knip": "^5.34.0", 109 | "linemod": "^2.0.1", 110 | "mocha": "^11.0.1", 111 | "nodemon": "^3.1.7", 112 | "npm-run-all2": "^7.0.2", 113 | "sinon": "^19.0.2", 114 | "sinon-chai": "^3.7.0", 115 | "tsd": "^0.31.2", 116 | "type-coverage": "^2.29.7", 117 | "typescript": "~5.7.2", 118 | "validate-conventional-commit": "^1.0.4" 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "github>voxpelli/renovate-config" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** @type {import('..').HtmlMethodResult} */ 4 | const ELEMENT_FIXTURE = Object.freeze({ type: 'div', props: {}, children: [] }); 5 | 6 | /** @type {import('..').HtmlMethodResult} */ 7 | const ELEMENT_ARRAY_CHILD_FIXTURE = Object.freeze({ 8 | type: 'ul', 9 | props: {}, 10 | children: [ 11 | { type: 'li', props: {}, children: ['One'] }, 12 | { type: 'li', props: {}, children: ['Two'] }, 13 | ], 14 | }); 15 | 16 | const ELEMENT_FIXTURE_INVALID_TYPE = Object.freeze({ type: true, props: {}, children: [] }); 17 | 18 | const PrototypeIncludingClass = function () { 19 | this.a = 1; 20 | this.b = 2; 21 | }; 22 | 23 | // add properties in f function's prototype 24 | PrototypeIncludingClass.prototype.b = 3; 25 | PrototypeIncludingClass.prototype.c = 4; 26 | 27 | /** @type {import('..').HtmlMethodResult} */ 28 | // @ts-ignore 29 | const ELEMENT_FIXTURE_WITH_COMPLEX_PROPS = Object.freeze({ 30 | type: 'div', 31 | props: new PrototypeIncludingClass(), 32 | children: [], 33 | }); 34 | 35 | module.exports = { 36 | ELEMENT_FIXTURE, 37 | ELEMENT_ARRAY_CHILD_FIXTURE, 38 | ELEMENT_FIXTURE_INVALID_TYPE, 39 | ELEMENT_FIXTURE_WITH_COMPLEX_PROPS, 40 | }; 41 | -------------------------------------------------------------------------------- /test/html.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | 'use strict'; 6 | 7 | const chai = require('chai'); 8 | const chaiAsPromised = require('chai-as-promised'); 9 | 10 | chai.use(chaiAsPromised); 11 | const should = chai.should(); 12 | 13 | const { 14 | html, 15 | } = require('../lib/htm'); 16 | 17 | const { 18 | ELEMENT_ARRAY_CHILD_FIXTURE, 19 | ELEMENT_FIXTURE, 20 | } = require('./fixtures'); 21 | 22 | describe('html``', () => { 23 | it('should handle complex example', () => { 24 | /** @type {import('..').SimpleRenderableElementFunction} */ 25 | const abc = (_props, children) => html`${children}`; 26 | /** @type {import('..').SimpleRenderableElementFunction} */ 27 | const bar = (_props, children) => html`<${abc}>${children}`; 28 | 29 | /** @type {import('..').HtmlMethodResult} */ 30 | const fixture = { 31 | type: 'div', 32 | props: { 'class': 'prop1 prop2', 'data-foo': '123' }, 33 | children: [ 34 | ' ', 35 | { type: 'img', props: { src: '#' }, children: [] }, 36 | ' ', 37 | { 38 | type: bar, 39 | props: {}, 40 | children: [ 41 | ' ', 42 | { 43 | type: 'woot', 44 | props: {}, 45 | children: ['YEA&H!', '
w0000000000t
'], 46 | }, 47 | ], 48 | }, 49 | ], 50 | }; 51 | 52 | const foo = 'woot'; 53 | const danger = '
w0000000000t
'; 54 | 55 | const result = html`
<${bar}> <${foo}>YEA&H!${danger}
`; 56 | 57 | should.exist(result); 58 | result.should.deep.equal(fixture); 59 | }); 60 | 61 | it('should handle array content', () => { 62 | const list = [ 63 | html`
  • One
  • `, 64 | html`
  • Two
  • `, 65 | ].flat(); 66 | const result = html`
      ${list}
    `; 67 | should.exist(result); 68 | result.should.deep.equal(ELEMENT_ARRAY_CHILD_FIXTURE); 69 | }); 70 | 71 | it('should handle simple root example', () => { 72 | const result = html`
    `; 73 | should.exist(result); 74 | result.should.deep.equal(ELEMENT_FIXTURE); 75 | }); 76 | 77 | it('should handle multi root example', () => { 78 | /** @type {import('..').HtmlMethodResult} */ 79 | const fixture = [ 80 | { type: 'div', props: {}, children: [] }, 81 | { type: 'div', props: {}, children: [] }, 82 | ]; 83 | 84 | html`
    `.should.deep.equal(fixture); 85 | }); 86 | 87 | it('should handle text root example', () => { 88 | /** @type {import('..').HtmlMethodResult} */ 89 | const fixture = 'foo'; 90 | html`foo`.should.deep.equal(fixture); 91 | }); 92 | 93 | it('should handle combined root example', () => { 94 | /** @type {import('..').HtmlMethodResult} */ 95 | const fixture = [ 96 | { type: 'div', props: {}, children: [] }, 97 | 'foo', 98 | ]; 99 | html`
    foo`.should.deep.equal(fixture); 100 | }); 101 | 102 | it('should handle multi text root example', () => { 103 | /** @type {import('..').HtmlMethodResult} */ 104 | const fixture = ['foo', 'bar']; 105 | html`${'foo'}bar`.should.deep.equal(fixture); 106 | }); 107 | 108 | it('should handle number root value', () => { html`${123}`.should.equal('123'); }); 109 | 110 | it('should handle undefined root example', () => { html`${undefined}`.should.equal(''); }); 111 | 112 | // @ts-ignore 113 | // eslint-disable-next-line unicorn/no-null 114 | it('should handle null root example', () => { html`${null}`.should.equal(''); }); 115 | 116 | it('should handle false root example', () => { html`${false}`.should.equal(''); }); 117 | 118 | it('should handle 0 root example', () => { html`${0}`.should.equal('0'); }); 119 | 120 | it('should handle top level array content', () => { 121 | /** @type {import('..').HtmlMethodResult} */ 122 | const fixture1 = [ 123 | { type: 'div', props: {}, children: [] }, 124 | { type: 'div', props: {}, children: [] }, 125 | ]; 126 | 127 | /** @type {import('..').HtmlMethodResult} */ 128 | const fixture2 = [ 129 | ...fixture1, 130 | { type: 'span', props: {}, children: [] }, 131 | ]; 132 | 133 | html`${fixture1}`.should.deep.equal(fixture2); 134 | }); 135 | 136 | it('should throw on invalid root type', () => { 137 | should.Throw(() => { html`${true}`; }, TypeError, 'Resolved to invalid value type: boolean'); 138 | should.Throw(() => { html`${{}}`; }, TypeError, 'Resolved to invalid type of object value "type" property: undefined'); 139 | // @ts-ignore 140 | should.Throw(() => { html`${Symbol.asyncIterator}`; }, TypeError, 'Resolved to invalid value type: symbol'); 141 | // @ts-ignore 142 | should.Throw(() => { html`${() => {}}`; }, TypeError, 'Resolved to invalid value type: function'); 143 | // @ts-ignore 144 | should.Throw(() => { html`${[Symbol.asyncIterator, 'foo']}`; }, TypeError, 'Resolved to invalid value type: symbol'); 145 | }); 146 | 147 | it('should throw on multi root type', () => { 148 | should.Throw(() => { html`foo${true}`; }, TypeError, 'Resolved to invalid value type: boolean'); 149 | // @ts-ignore 150 | should.Throw(() => { html`foo${Symbol.asyncIterator}`; }, TypeError, 'Resolved to invalid value type: symbol'); 151 | // @ts-ignore 152 | should.Throw(() => { html`foo${() => {}}`; }, TypeError, 'Resolved to invalid value type: function'); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /test/render-basic.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | 'use strict'; 6 | 7 | const chai = require('chai'); 8 | const chaiAsPromised = require('chai-as-promised'); 9 | 10 | chai.use(chaiAsPromised); 11 | const should = chai.should(); 12 | 13 | const { 14 | render, 15 | } = require('../lib/render'); 16 | 17 | const { 18 | ELEMENT_FIXTURE, 19 | } = require('./fixtures'); 20 | 21 | describe('render() basic', () => { 22 | it('should throw on no argument', async () => { 23 | await (async () => { 24 | // @ts-ignore 25 | // eslint-disable-next-line no-unused-vars, no-empty 26 | for await (const _foo of render()) {} 27 | })() 28 | .should.be.rejectedWith(TypeError, 'Expected an argument'); 29 | }); 30 | 31 | it('should throw on null argument', async () => { 32 | await (async () => { 33 | // @ts-ignore 34 | // eslint-disable-next-line no-unused-vars, no-empty, unicorn/no-null 35 | for await (const _foo of render(null)) {} 36 | })() 37 | .should.be.rejectedWith(TypeError, 'Expected a non-falsy argument, got: null'); 38 | }); 39 | 40 | it('should throw on empty string argument', async () => { 41 | await (async () => { 42 | // @ts-ignore 43 | // eslint-disable-next-line no-unused-vars, no-empty 44 | for await (const _foo of render('')) {} 45 | })() 46 | .should.be.rejectedWith(TypeError, 'Expected a non-falsy argument, got: '); 47 | }); 48 | 49 | it('should throw on unsupported argument', async () => { 50 | await (async () => { 51 | // @ts-ignore 52 | // eslint-disable-next-line no-unused-vars, no-empty 53 | for await (const _foo of render(true)) {} 54 | })() 55 | .should.be.rejectedWith(TypeError, 'Expected a string or an object, got: boolean'); 56 | }); 57 | 58 | it('should return an async iterator', async () => { 59 | const result = render(ELEMENT_FIXTURE); 60 | should.exist(result); 61 | (typeof result === 'object').should.be.ok; 62 | should.exist(result[Symbol.asyncIterator]); 63 | }); 64 | 65 | it('should return sensible values', async () => { 66 | const total = []; 67 | 68 | for await (const result of render(ELEMENT_FIXTURE)) { 69 | should.exist(result); 70 | 71 | result.should.be.a('string'); 72 | result.length.should.be.greaterThan(0); 73 | 74 | total.push(result); 75 | } 76 | 77 | total.should.deep.equal(['', '
    ']); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /test/render-complex.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | 'use strict'; 6 | 7 | const chai = require('chai'); 8 | const chaiAsPromised = require('chai-as-promised'); 9 | const sinon = require('sinon'); 10 | const sinonChai = require('sinon-chai'); 11 | 12 | chai.use(chaiAsPromised); 13 | chai.use(sinonChai); 14 | chai.should(); 15 | 16 | process.on('unhandledRejection', reason => { throw reason; }); 17 | 18 | const { 19 | html, 20 | rawHtml, 21 | renderToString, 22 | } = require('..'); 23 | 24 | const { 25 | ELEMENT_FIXTURE_INVALID_TYPE, 26 | ELEMENT_FIXTURE_WITH_COMPLEX_PROPS, 27 | } = require('./fixtures'); 28 | 29 | describe('renderToString()', () => { 30 | afterEach(() => { 31 | sinon.restore(); 32 | }); 33 | 34 | describe('complex', () => { 35 | it('should be able to render a complex example', async () => { 36 | const foo = 'woot'; 37 | /** @type {import('..').SimpleRenderableElementFunction} */ 38 | const abc = (_props, children) => html`${children}`; 39 | /** @type {import('..').SimpleRenderableElementFunction} */ 40 | const bar = (_props, children) => html`<${abc}>${children}`; 41 | const danger = '
    w0000000000t
    '; 42 | const wow = html`
    <${bar}> <${foo}>YEA&H!${danger}
    `; 43 | 44 | return renderToString(wow) 45 | .should.eventually.equal('
    YEA&H!<div>w0000000000t</div>
    '); 46 | }); 47 | 48 | it('should handle multiple roots', async () => { 49 | await renderToString(html`
    `) 50 | .should.eventually.equal('
    '); 51 | }); 52 | 53 | it('should handle plain string', async () => { 54 | await renderToString(html`foo`) 55 | .should.eventually.equal('foo'); 56 | }); 57 | }); 58 | 59 | describe('tags', () => { 60 | it('should throw on invalid tag name', async () => { 61 | await renderToString(html`<-div>`) 62 | .should.be.rejectedWith('Invalid tag name: -div'); 63 | }); 64 | 65 | it('should throw on invalid tag type', async () => { 66 | // @ts-ignore 67 | await renderToString(ELEMENT_FIXTURE_INVALID_TYPE) 68 | .should.be.rejectedWith('Invalid element type: boolean'); 69 | }); 70 | 71 | it('should handle string variable tag type', async () => { 72 | const foo = 'bar'; 73 | await renderToString(html`<${foo}>`) 74 | .should.eventually.equal(''); 75 | }); 76 | 77 | it('should render self-closing tags correctly', async () => { 78 | await renderToString(html``) 79 | .should.eventually.equal(''); 80 | }); 81 | 82 | it('should handle self-closing tag with closing tag correctly', async () => { 83 | await renderToString(html``) 84 | .should.eventually.equal(''); 85 | }); 86 | 87 | it('should be able to render a fragment example', async () => { 88 | const wow = html`<>
    `; 89 | 90 | await renderToString(wow) 91 | .should.eventually.equal('
    '); 92 | }); 93 | 94 | it('should be able to handle a non-named closing tag', async () => { 95 | const wow = html`
    content here`; 96 | 97 | return renderToString(wow) 98 | .should.eventually.equal('
    content here
    '); 99 | }); 100 | }); 101 | 102 | describe('tag function', () => { 103 | it('should handle function tag type', async () => { 104 | /** @type {import('..').SimpleRenderableElementFunction} */ 105 | const foo = sinon.stub().returns(html``); 106 | 107 | await renderToString(html`<${foo} />`) 108 | .should.eventually.equal(''); 109 | 110 | foo.should.have.been.calledOnce.and.calledOnceWithExactly({}, []); 111 | }); 112 | 113 | it('should handle function tag with children', async () => { 114 | const foo = sinon.stub().returnsArg(1); 115 | const wow = html`<${foo}>content here`; 116 | 117 | await renderToString(wow) 118 | .should.eventually.equal('content here'); 119 | 120 | foo.should.have.been.calledOnce.and.calledOnceWithExactly({}, ['content here']); 121 | }); 122 | 123 | it('should handle function tag with props', async () => { 124 | const foo = sinon.stub().returns(html``); 125 | const wow = html`<${foo} foo="bar" />`; 126 | 127 | await renderToString(wow) 128 | .should.eventually.equal(''); 129 | 130 | foo.should.have.been.calledOnce.and.calledOnceWithExactly({ foo: 'bar' }, []); 131 | }); 132 | }); 133 | 134 | describe('children', () => { 135 | it('should throw on invalid child type', async () => { 136 | await renderToString(html`
    ${true}
    `) 137 | .should.be.rejectedWith('Invalid render item type: boolean'); 138 | }); 139 | 140 | it('should throw on invalid child object', async () => { 141 | // @ts-ignore 142 | await renderToString(html`
    ${{ abc: 123 }}
    `) 143 | .should.be.rejectedWith('Not an element definition. Missing type in: {"abc":123}'); 144 | }); 145 | 146 | it('should handle undefined child', async () => { 147 | await renderToString(html`
    ${undefined}
    `) 148 | .should.eventually.equal('
    '); 149 | }); 150 | 151 | it('should handle null child', async () => { 152 | // @ts-ignore 153 | // eslint-disable-next-line unicorn/no-null 154 | await renderToString(html`
    ${null}
    `) 155 | .should.eventually.equal('
    '); 156 | }); 157 | 158 | it('should handle text variable child', async () => { 159 | await renderToString(html`
    ${'foo'}
    `) 160 | .should.eventually.equal('
    foo
    '); 161 | }); 162 | 163 | it('should handle text non-variable child', async () => { 164 | await renderToString(html`
    foo
    `) 165 | .should.eventually.equal('
    foo
    '); 166 | }); 167 | 168 | it('should handle text combined child', async () => { 169 | await renderToString(html`
    ${'foo'} bar
    `) 170 | .should.eventually.equal('
    foo bar
    '); 171 | }); 172 | 173 | it('should handle number child', async () => { 174 | await renderToString(html`
    ${1}
    `) 175 | .should.eventually.equal('
    1
    '); 176 | }); 177 | 178 | it('should handle element child', async () => { 179 | await renderToString(html`
    `) 180 | .should.eventually.equal('
    '); 181 | }); 182 | 183 | it('should custom element child', async () => { 184 | const foo = sinon.stub().returns(html``); 185 | 186 | await renderToString(html`
    <${foo} />
    `) 187 | .should.eventually.equal('
    '); 188 | }); 189 | 190 | it('should handle array child', async () => { 191 | await renderToString(html`
      ${['foo', 'bar'].flatMap(i => html`
    • ${i}
    • `)}
    `) 192 | .should.eventually.equal('
    • foo
    • bar
    '); 193 | }); 194 | 195 | it('should have comments ignored', async () => { 196 | await renderToString(html`
    `) 197 | .should.eventually.equal('
    '); 198 | }); 199 | 200 | it('should handle output as input', async () => { 201 | const foo = html``; 202 | await renderToString(html`
    ${foo}
    `) 203 | .should.eventually.equal('
    '); 204 | }); 205 | }); 206 | 207 | describe('properties', () => { 208 | it('should throw on invalid property name', async () => { 209 | await renderToString(html`
    `) 210 | .should.be.rejectedWith('Invalid attribute name: -cool'); 211 | }); 212 | 213 | it('should be able to render boolean props', async () => { 214 | await renderToString(html`
    `) 215 | .should.eventually.equal('
    '); 216 | }); 217 | 218 | it('should be able to render numeric props', async () => { 219 | await renderToString(html`
    `) 220 | .should.eventually.equal('
    '); 221 | }); 222 | 223 | it('should handle empty string props', async () => { 224 | await renderToString(html`
    `) 225 | .should.eventually.equal('
    '); 226 | }); 227 | 228 | it('should be able to render string props', async () => { 229 | await renderToString(html`
    `) 230 | .should.eventually.equal('
    '); 231 | }); 232 | 233 | it('should escape string props', async () => { 234 | await renderToString(html`
    `) 235 | .should.eventually.equal('
    '); 236 | }); 237 | 238 | it('should escape string content', async () => { 239 | await renderToString(html`
    ${'"bar"'}${'
    '}ab&c
    `) 240 | .should.eventually.equal('
    "bar"<div>ab&c
    '); 241 | }); 242 | 243 | it('should not escape raw html given through template literal rawHtml``', async () => { 244 | await renderToString(rawHtml`
    ${'"bar"'}<div>ab&c
    `) 245 | .should.eventually.equal('
    "bar"<div>ab&c
    '); 246 | }); 247 | 248 | it('should not escape raw html given through direct call to rawHtml()', async () => { 249 | await renderToString(rawHtml('
    "bar"<div>ab&c
    ')) 250 | .should.eventually.equal('
    "bar"<div>ab&c
    '); 251 | }); 252 | 253 | it('should support rawHtml() as child content in ordinary html', async () => { 254 | await renderToString(html`
    ${'
    '}${rawHtml`
    `}${rawHtml('
    ')}
    `) 255 | .should.eventually.equal('
    <div>
    '); 256 | }); 257 | 258 | it('should correctly enumerate a non-plain object', async () => { 259 | await renderToString(ELEMENT_FIXTURE_WITH_COMPLEX_PROPS) 260 | .should.eventually.equal('
    '); 261 | }); 262 | 263 | it('should handle props spread', async () => { 264 | const props = { foo: 'bar', xyz: 'abc' }; 265 | await renderToString(html`
    `) 266 | .should.eventually.equal('
    '); 267 | }); 268 | 269 | it('should handle unquoted props', async () => { 270 | await renderToString(html`
    `) 271 | .should.eventually.equal('
    '); 272 | }); 273 | 274 | it('should ignore unsupported property types', async () => { 275 | const consoleErrorStub = sinon.stub(console, 'error'); 276 | 277 | // @ts-ignore 278 | // eslint-disable-next-line unicorn/no-null 279 | await renderToString(html`
    `) 280 | .should.eventually.equal('
    '); 281 | 282 | consoleErrorStub.should.have.been 283 | .calledTwice 284 | .calledWithExactly('Unexpected prop value type:', 'function').and 285 | .calledWithExactly('Unexpected prop value type:', 'object'); 286 | }); 287 | }); 288 | }); 289 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@voxpelli/tsconfig/node18.json", 3 | "files": [ 4 | "index.js", 5 | "index.mjs" 6 | ], 7 | "include": [ 8 | "lib/**/*", 9 | "test/**/*.js" 10 | ] 11 | } 12 | --------------------------------------------------------------------------------