├── .prettierignore ├── .npmrc ├── lib ├── jsx-classic.js ├── jsx-automatic.js ├── automatic-runtime-svg.js ├── automatic-runtime-html.js ├── svg-case-sensitive-tag-names.js ├── automatic-runtime-svg.d.ts ├── automatic-runtime-html.d.ts ├── jsx-automatic.d.ts ├── jsx-classic.d.ts ├── index.js ├── create-automatic-runtime.js └── create-h.js ├── test-d ├── files.ts ├── classic-h.tsx ├── classic-s.tsx ├── automatic-s.tsx ├── automatic-h.tsx └── index.ts ├── .editorconfig ├── index.js ├── test ├── index.js ├── jsx.jsx └── core.js ├── .gitignore ├── .github └── workflows │ ├── bb.yml │ └── main.yml ├── script ├── build.js └── generate-jsx.js ├── tsconfig.json ├── license ├── package.json └── readme.md /.prettierignore: -------------------------------------------------------------------------------- 1 | *.md 2 | coverage/ 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-scripts=true 2 | package-lock=false 3 | -------------------------------------------------------------------------------- /lib/jsx-classic.js: -------------------------------------------------------------------------------- 1 | // Empty (only used for TypeScript). 2 | export {} 3 | -------------------------------------------------------------------------------- /lib/jsx-automatic.js: -------------------------------------------------------------------------------- 1 | // Empty (only used for TypeScript). 2 | export {} 3 | -------------------------------------------------------------------------------- /test-d/files.ts: -------------------------------------------------------------------------------- 1 | import {h, s} from 'hastscript' 2 | import type {Root} from 'hast' 3 | import {expectType} from 'tsd' 4 | 5 | expectType(h()) 6 | expectType(s()) 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('./lib/create-h.js').Child} Child 3 | * @typedef {import('./lib/create-h.js').Properties} Properties 4 | * @typedef {import('./lib/create-h.js').Result} Result 5 | */ 6 | 7 | export {h, s} from './lib/index.js' 8 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unassigned-import */ 2 | import './core.js' 3 | import './jsx-build-jsx-classic.js' 4 | import './jsx-build-jsx-automatic.js' 5 | import './jsx-build-jsx-automatic-development.js' 6 | /* eslint-enable import/no-unassigned-import */ 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.d.ts 2 | *.log 3 | *.map 4 | *.tsbuildinfo 5 | .DS_Store 6 | coverage/ 7 | node_modules/ 8 | test/jsx-*.js 9 | yarn.lock 10 | !/lib/automatic-runtime-html.d.ts 11 | !/lib/automatic-runtime-svg.d.ts 12 | !/lib/jsx-automatic.d.ts 13 | !/lib/jsx-classic.d.ts 14 | -------------------------------------------------------------------------------- /lib/automatic-runtime-svg.js: -------------------------------------------------------------------------------- 1 | import {createAutomaticRuntime} from './create-automatic-runtime.js' 2 | import {s} from './index.js' 3 | 4 | // Export `JSX` as a global for TypeScript. 5 | export * from './jsx-automatic.js' 6 | 7 | export const {Fragment, jsxDEV, jsxs, jsx} = createAutomaticRuntime(s) 8 | -------------------------------------------------------------------------------- /.github/workflows/bb.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | main: 3 | runs-on: ubuntu-latest 4 | steps: 5 | - uses: unifiedjs/beep-boop-beta@main 6 | with: 7 | repo-token: ${{secrets.GITHUB_TOKEN}} 8 | name: bb 9 | on: 10 | issues: 11 | types: [closed, edited, labeled, opened, reopened, unlabeled] 12 | pull_request_target: 13 | types: [closed, edited, labeled, opened, reopened, unlabeled] 14 | -------------------------------------------------------------------------------- /lib/automatic-runtime-html.js: -------------------------------------------------------------------------------- 1 | // Note: types exposed from `automatic-runtime-html.d.ts` because TS has bugs 2 | // when generating types. 3 | import {createAutomaticRuntime} from './create-automatic-runtime.js' 4 | import {h} from './index.js' 5 | 6 | // Export `JSX` as a global for TypeScript. 7 | export * from './jsx-automatic.js' 8 | 9 | export const {Fragment, jsxDEV, jsxs, jsx} = createAutomaticRuntime(h) 10 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | main: 3 | name: ${{matrix.node}} 4 | runs-on: ubuntu-latest 5 | steps: 6 | - uses: actions/checkout@v4 7 | - uses: actions/setup-node@v4 8 | with: 9 | node-version: ${{matrix.node}} 10 | - run: npm install 11 | - run: npm test 12 | - uses: codecov/codecov-action@v5 13 | strategy: 14 | matrix: 15 | node: 16 | - lts/hydrogen 17 | - node 18 | name: main 19 | on: 20 | - pull_request 21 | - push 22 | -------------------------------------------------------------------------------- /script/build.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises' 2 | import {svgTagNames} from 'svg-tag-names' 3 | 4 | /** @type {Array} */ 5 | const irregular = [] 6 | 7 | for (const name of svgTagNames) { 8 | if (name !== name.toLowerCase()) irregular.push(name) 9 | } 10 | 11 | await fs.writeFile( 12 | new URL('../lib/svg-case-sensitive-tag-names.js', import.meta.url), 13 | [ 14 | '/**', 15 | ' * List of case-sensitive SVG tag names.', 16 | ' *', 17 | ' * @type {ReadonlyArray}', 18 | ' */', 19 | 'export const svgCaseSensitiveTagNames = ' + 20 | JSON.stringify(irregular, undefined, 2), 21 | '' 22 | ].join('\n') 23 | ) 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "checkJs": true, 4 | "customConditions": ["development"], 5 | "declaration": true, 6 | "declarationMap": true, 7 | "emitDeclarationOnly": true, 8 | "exactOptionalPropertyTypes": true, 9 | "jsx": "preserve", 10 | "lib": ["es2022"], 11 | "module": "node16", 12 | "strict": true, 13 | "target": "es2022" 14 | }, 15 | "exclude": ["coverage/", "node_modules/"], 16 | "include": [ 17 | "**/**.js", 18 | "**/**.jsx", 19 | "lib/automatic-runtime-html.d.ts", 20 | "lib/automatic-runtime-svg.d.ts", 21 | "lib/jsx-automatic.d.ts", 22 | "lib/jsx-classic.d.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /lib/svg-case-sensitive-tag-names.js: -------------------------------------------------------------------------------- 1 | /** 2 | * List of case-sensitive SVG tag names. 3 | * 4 | * @type {ReadonlyArray} 5 | */ 6 | export const svgCaseSensitiveTagNames = [ 7 | 'altGlyph', 8 | 'altGlyphDef', 9 | 'altGlyphItem', 10 | 'animateColor', 11 | 'animateMotion', 12 | 'animateTransform', 13 | 'clipPath', 14 | 'feBlend', 15 | 'feColorMatrix', 16 | 'feComponentTransfer', 17 | 'feComposite', 18 | 'feConvolveMatrix', 19 | 'feDiffuseLighting', 20 | 'feDisplacementMap', 21 | 'feDistantLight', 22 | 'feDropShadow', 23 | 'feFlood', 24 | 'feFuncA', 25 | 'feFuncB', 26 | 'feFuncG', 27 | 'feFuncR', 28 | 'feGaussianBlur', 29 | 'feImage', 30 | 'feMerge', 31 | 'feMergeNode', 32 | 'feMorphology', 33 | 'feOffset', 34 | 'fePointLight', 35 | 'feSpecularLighting', 36 | 'feSpotLight', 37 | 'feTile', 38 | 'feTurbulence', 39 | 'foreignObject', 40 | 'glyphRef', 41 | 'linearGradient', 42 | 'radialGradient', 43 | 'solidColor', 44 | 'textArea', 45 | 'textPath' 46 | ] 47 | -------------------------------------------------------------------------------- /lib/automatic-runtime-svg.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | 3 | import type {Element, Root} from 'hast' 4 | import type {Child} from './create-h.js' 5 | import type {JSXProps} from './create-automatic-runtime.js' 6 | 7 | export * from './jsx-automatic.js' 8 | 9 | export const Fragment: null 10 | 11 | export const jsxDEV: { 12 | ( 13 | type: null, 14 | properties: {children?: Child}, 15 | key?: string | null | undefined 16 | ): Root 17 | (type: string, properties: JSXProps, key?: string | null | undefined): Element 18 | } 19 | 20 | export const jsxs: { 21 | ( 22 | type: null, 23 | properties: {children?: Child}, 24 | key?: string | null | undefined 25 | ): Root 26 | (type: string, properties: JSXProps, key?: string | null | undefined): Element 27 | } 28 | 29 | export const jsx: { 30 | ( 31 | type: null, 32 | properties: {children?: Child}, 33 | key?: string | null | undefined 34 | ): Root 35 | (type: string, properties: JSXProps, key?: string | null | undefined): Element 36 | } 37 | -------------------------------------------------------------------------------- /lib/automatic-runtime-html.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | 3 | import type {Element, Root} from 'hast' 4 | import type {Child} from './create-h.js' 5 | import type {JSXProps} from './create-automatic-runtime.js' 6 | 7 | export * from './jsx-automatic.js' 8 | 9 | export const Fragment: null 10 | 11 | export const jsxDEV: { 12 | ( 13 | type: null, 14 | properties: {children?: Child}, 15 | key?: string | null | undefined 16 | ): Root 17 | (type: string, properties: JSXProps, key?: string | null | undefined): Element 18 | } 19 | 20 | export const jsxs: { 21 | ( 22 | type: null, 23 | properties: {children?: Child}, 24 | key?: string | null | undefined 25 | ): Root 26 | (type: string, properties: JSXProps, key?: string | null | undefined): Element 27 | } 28 | 29 | export const jsx: { 30 | ( 31 | type: null, 32 | properties: {children?: Child}, 33 | key?: string | null | undefined 34 | ): Root 35 | (type: string, properties: JSXProps, key?: string | null | undefined): Element 36 | } 37 | -------------------------------------------------------------------------------- /lib/jsx-automatic.d.ts: -------------------------------------------------------------------------------- 1 | import type {Child, Properties, Result} from './create-h.js' 2 | 3 | export namespace JSX { 4 | /** 5 | * Define the return value of JSX syntax. 6 | */ 7 | type Element = Result 8 | 9 | /** 10 | * Key of this interface defines as what prop children are passed. 11 | */ 12 | interface ElementChildrenAttribute { 13 | /** 14 | * Only the key matters, not the value. 15 | */ 16 | children?: never 17 | } 18 | 19 | /** 20 | * Disallow the use of functional components. 21 | */ 22 | type IntrinsicAttributes = never 23 | 24 | /** 25 | * Define the prop types for known elements. 26 | * 27 | * For `hastscript` this defines any string may be used in combination with 28 | * `hast` `Properties`. 29 | * 30 | * This **must** be an interface. 31 | */ 32 | type IntrinsicElements = Record< 33 | string, 34 | | Properties 35 | | { 36 | /** 37 | * The prop that matches `ElementChildrenAttribute` key defines the 38 | * type of JSX children, defines the children type. 39 | */ 40 | children?: Child 41 | } 42 | > 43 | } 44 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) Titus Wormer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /lib/jsx-classic.d.ts: -------------------------------------------------------------------------------- 1 | import type {Child, Properties, Result} from './create-h.js' 2 | 3 | /** 4 | * This unique symbol is declared to specify the key on which JSX children are 5 | * passed, without conflicting with the `Attributes` type. 6 | */ 7 | declare const children: unique symbol 8 | 9 | /** 10 | * Define the return value of JSX syntax. 11 | */ 12 | export type Element = Result 13 | 14 | /** 15 | * Key of this interface defines as what prop children are passed. 16 | */ 17 | export interface ElementChildrenAttribute { 18 | /** 19 | * Only the key matters, not the value. 20 | */ 21 | [children]?: never 22 | } 23 | 24 | /** 25 | * Disallow the use of functional components. 26 | */ 27 | export type IntrinsicAttributes = never 28 | 29 | /** 30 | * Define the prop types for known elements. 31 | * 32 | * For `hastscript` this defines any string may be used in combination with 33 | * `hast` `Properties`. 34 | * 35 | * This **must** be an interface. 36 | */ 37 | export type IntrinsicElements = Record< 38 | string, 39 | | Properties 40 | | { 41 | /** 42 | * The prop that matches `ElementChildrenAttribute` key defines the 43 | * type of JSX children, defines the children type. 44 | */ 45 | [children]?: Child 46 | } 47 | > 48 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | // Register the JSX namespace on `h`. 2 | /** 3 | * @typedef {import('./jsx-classic.js').Element} h.JSX.Element 4 | * @typedef {import('./jsx-classic.js').ElementChildrenAttribute} h.JSX.ElementChildrenAttribute 5 | * @typedef {import('./jsx-classic.js').IntrinsicAttributes} h.JSX.IntrinsicAttributes 6 | * @typedef {import('./jsx-classic.js').IntrinsicElements} h.JSX.IntrinsicElements 7 | */ 8 | 9 | // Register the JSX namespace on `s`. 10 | /** 11 | * @typedef {import('./jsx-classic.js').Element} s.JSX.Element 12 | * @typedef {import('./jsx-classic.js').ElementChildrenAttribute} s.JSX.ElementChildrenAttribute 13 | * @typedef {import('./jsx-classic.js').IntrinsicAttributes} s.JSX.IntrinsicAttributes 14 | * @typedef {import('./jsx-classic.js').IntrinsicElements} s.JSX.IntrinsicElements 15 | */ 16 | 17 | import {html, svg} from 'property-information' 18 | import {createH} from './create-h.js' 19 | import {svgCaseSensitiveTagNames} from './svg-case-sensitive-tag-names.js' 20 | 21 | // Note: this explicit type is needed, otherwise TS creates broken types. 22 | /** @type {ReturnType} */ 23 | export const h = createH(html, 'div') 24 | 25 | // Note: this explicit type is needed, otherwise TS creates broken types. 26 | /** @type {ReturnType} */ 27 | export const s = createH(svg, 'g', svgCaseSensitiveTagNames) 28 | -------------------------------------------------------------------------------- /test-d/classic-h.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxFrag null */ 2 | /* @jsx h */ 3 | 4 | import {h} from 'hastscript' 5 | import type {Element, Root} from 'hast' 6 | import {expectType} from 'tsd' 7 | 8 | type Result = Element | Root 9 | 10 | expectType(<>) 11 | expectType() 12 | expectType() 13 | expectType() 14 | expectType(string) 15 | expectType({['string', 'string']}) 16 | expectType( 17 | 18 | <> 19 | 20 | ) 21 | expectType({h()}) 22 | expectType({h('b')}) 23 | expectType( 24 | 25 | c 26 | 27 | ) 28 | expectType( 29 | 30 | 31 | 32 | 33 | ) 34 | expectType({[, ]}) 35 | expectType({[, ]}) 36 | expectType({[]}) 37 | 38 | // @ts-expect-error: not a valid property value. 39 | const a = 40 | 41 | // @ts-expect-error: This is where the classic runtime differs from the 42 | // automatic runtime. 43 | // The automatic runtime the children prop to define JSX children, whereas 44 | // it’s used as an attribute in the classic runtime. 45 | const b = } /> 46 | 47 | declare function Bar(properties?: Record): Element 48 | 49 | // @ts-expect-error: components are not supported. 50 | const c = 51 | -------------------------------------------------------------------------------- /test-d/classic-s.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxFrag null */ 2 | /* @jsx s */ 3 | 4 | import {s} from 'hastscript' 5 | import type {Element, Root} from 'hast' 6 | import {expectType} from 'tsd' 7 | 8 | type Result = Element | Root 9 | 10 | expectType(<>) 11 | expectType() 12 | expectType() 13 | expectType() 14 | expectType(string) 15 | expectType({['string', 'string']}) 16 | expectType( 17 | 18 | <> 19 | 20 | ) 21 | expectType({s()}) 22 | expectType({s('b')}) 23 | expectType( 24 | 25 | c 26 | 27 | ) 28 | expectType( 29 | 30 | 31 | 32 | 33 | ) 34 | expectType({[, ]}) 35 | expectType({[, ]}) 36 | expectType({[]}) 37 | 38 | // @ts-expect-error: not a valid property value. 39 | const a = 40 | 41 | // @ts-expect-error: This is where the classic runtime differs from the 42 | // automatic runtime. 43 | // The automatic runtime the children prop to define JSX children, whereas 44 | // it’s used as an attribute in the classic runtime. 45 | const b = } /> 46 | 47 | declare function Bar(properties?: Record): Element 48 | 49 | // @ts-expect-error: components are not supported. 50 | const c = 51 | -------------------------------------------------------------------------------- /test-d/automatic-s.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxRuntime automatic */ 2 | /* @jsxImportSource hastscript/svg */ 3 | 4 | import {s} from 'hastscript' 5 | import type {Element, Root} from 'hast' 6 | import {expectType} from 'tsd' 7 | 8 | type Result = Element | Root 9 | 10 | expectType(<>) 11 | expectType() 12 | expectType() 13 | expectType() 14 | expectType(string) 15 | expectType({['string', 'string']}) 16 | expectType( 17 | 18 | <> 19 | 20 | ) 21 | expectType({s()}) 22 | expectType({s('b')}) 23 | expectType( 24 | 25 | c 26 | 27 | ) 28 | expectType( 29 | 30 | 31 | 32 | 33 | ) 34 | expectType({[, ]}) 35 | expectType({[, ]}) 36 | expectType({[]}) 37 | 38 | // @ts-expect-error: not a valid property value. 39 | const a = 40 | 41 | // This is where the automatic runtime differs from the classic runtime. 42 | // The automatic runtime the children prop to define JSX children, whereas it’s used as an attribute in the classic runtime. 43 | 44 | expectType(} />) 45 | 46 | declare function Bar(properties?: Record): Element 47 | 48 | // @ts-expect-error: components are not supported. 49 | const b = 50 | -------------------------------------------------------------------------------- /test-d/automatic-h.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxRuntime automatic */ 2 | /* @jsxImportSource hastscript */ 3 | 4 | import {h} from 'hastscript' 5 | import type {Element, Root} from 'hast' 6 | import {expectType} from 'tsd' 7 | 8 | type Result = Element | Root 9 | 10 | // JSX automatic runtime. 11 | expectType(<>) 12 | expectType() 13 | expectType() 14 | expectType() 15 | expectType(string) 16 | expectType({['string', 'string']}) 17 | expectType( 18 | 19 | <> 20 | 21 | ) 22 | expectType({h()}) 23 | expectType({h('b')}) 24 | expectType( 25 | 26 | c 27 | 28 | ) 29 | expectType( 30 | 31 | 32 | 33 | 34 | ) 35 | expectType({[, ]}) 36 | expectType({[, ]}) 37 | expectType({[]}) 38 | 39 | // @ts-expect-error: not a valid property value. 40 | const a = 41 | 42 | // This is where the automatic runtime differs from the classic runtime. 43 | // The automatic runtime the children prop to define JSX children, whereas it’s used as an attribute in the classic runtime. 44 | 45 | expectType(} />) 46 | 47 | declare function Bar(properties?: Record): Element 48 | 49 | // @ts-expect-error: components are not supported. 50 | const b = 51 | -------------------------------------------------------------------------------- /lib/create-automatic-runtime.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @import {Element, Root} from 'hast' 3 | * @import {Child, Properties, PropertyValue, Result, Style, createH as CreateH} from './create-h.js' 4 | */ 5 | 6 | /** 7 | * @typedef {Record} JSXProps 8 | */ 9 | 10 | // Make VS code see references to above symbols. 11 | '' 12 | 13 | /** 14 | * Create an automatic runtime. 15 | * 16 | * @param {ReturnType} f 17 | * `h` function. 18 | * @returns 19 | * Automatic JSX runtime. 20 | */ 21 | export function createAutomaticRuntime(f) { 22 | /** 23 | * @overload 24 | * @param {null} type 25 | * @param {{children?: Child}} properties 26 | * @param {string | null | undefined} [key] 27 | * @returns {Root} 28 | * 29 | * @overload 30 | * @param {string} type 31 | * @param {JSXProps} properties 32 | * @param {string | null | undefined} [key] 33 | * @returns {Element} 34 | * 35 | * @param {string | null} type 36 | * Element name or `null` to get a root. 37 | * @param {Properties & {children?: Child}} properties 38 | * Properties. 39 | * @returns {Result} 40 | * Result. 41 | */ 42 | function jsx(type, properties) { 43 | const {children, ...properties_} = properties 44 | const result = 45 | // @ts-ignore: `children` is fine: TS has a recursion problem which 46 | // sometimes generates broken types. 47 | type === null ? f(null, children) : f(type, properties_, children) 48 | return result 49 | } 50 | 51 | return {Fragment: null, jsxDEV: jsx, jsxs: jsx, jsx} 52 | } 53 | -------------------------------------------------------------------------------- /script/generate-jsx.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises' 2 | import acornJsx from 'acorn-jsx' 3 | import {buildJsx} from 'estree-util-build-jsx' 4 | import {fromJs} from 'esast-util-from-js' 5 | import {toJs} from 'estree-util-to-js' 6 | 7 | const document = await fs.readFile( 8 | new URL('../test/jsx.jsx', import.meta.url), 9 | 'utf8' 10 | ) 11 | 12 | const treeAutomatic = fromJs( 13 | document.replace(/'name'/, "'jsx (estree-util-build-jsx, automatic)'"), 14 | {module: true, plugins: [acornJsx()]} 15 | ) 16 | 17 | const treeAutomaticDevelopment = fromJs( 18 | document.replace( 19 | /'name'/, 20 | "'jsx (estree-util-build-jsx, automatic, development)'" 21 | ), 22 | {module: true, plugins: [acornJsx()]} 23 | ) 24 | 25 | const treeClassic = fromJs( 26 | document.replace(/'name'/, "'jsx (estree-util-build-jsx, classic)'"), 27 | {module: true, plugins: [acornJsx()]} 28 | ) 29 | 30 | buildJsx(treeAutomatic, {importSource: 'hastscript', runtime: 'automatic'}) 31 | buildJsx(treeAutomaticDevelopment, { 32 | development: true, 33 | importSource: 'hastscript', 34 | runtime: 'automatic' 35 | }) 36 | buildJsx(treeClassic, {pragmaFrag: 'null', pragma: 'h'}) 37 | 38 | await fs.writeFile( 39 | new URL('../test/jsx-build-jsx-automatic.js', import.meta.url), 40 | toJs(treeAutomatic).value 41 | ) 42 | 43 | await fs.writeFile( 44 | new URL('../test/jsx-build-jsx-automatic-development.js', import.meta.url), 45 | // There’s a problem with `this` that TS doesn’t like. 46 | '// @ts-nocheck\n\n' + toJs(treeAutomaticDevelopment).value 47 | ) 48 | 49 | await fs.writeFile( 50 | new URL('../test/jsx-build-jsx-classic.js', import.meta.url), 51 | toJs(treeClassic).value 52 | ) 53 | -------------------------------------------------------------------------------- /test-d/index.ts: -------------------------------------------------------------------------------- 1 | import {Fragment, jsxs, jsx} from 'hastscript/jsx-runtime' 2 | import {h, s} from 'hastscript' 3 | import type {Element, Root} from 'hast' 4 | import {expectType} from 'tsd' 5 | 6 | expectType(jsx(Fragment, {})) 7 | expectType(jsx(Fragment, {children: h('h')})) 8 | expectType(jsx('a', {})) 9 | expectType(jsx('a', {children: 'a'})) 10 | expectType(jsx('a', {children: h('h')})) 11 | expectType(jsxs('a', {children: ['a', 'b']})) 12 | expectType(jsxs('a', {children: [h('x'), h('y')]})) 13 | 14 | expectType(h()) 15 | expectType(s()) 16 | // @ts-expect-error: not a tag name. 17 | h(true) 18 | expectType(h(null)) 19 | expectType(h(undefined)) 20 | expectType(h('')) 21 | expectType(s('')) 22 | expectType(h('', null)) 23 | expectType(h('', undefined)) 24 | expectType(h('', 1)) 25 | expectType(h('', 'a')) 26 | // @ts-expect-error: not a child. 27 | h('', true) 28 | expectType(h('', [1, 'a', null])) 29 | // @ts-expect-error: not a child. 30 | h('', [true]) 31 | 32 | expectType(h('', {})) 33 | expectType(h('', {}, [1, 'a', null])) 34 | expectType(h('', {p: 1})) 35 | expectType(h('', {p: null})) 36 | expectType(h('', {p: undefined})) 37 | expectType(h('', {p: true})) 38 | expectType(h('', {p: false})) 39 | expectType(h('', {p: 'a'})) 40 | expectType(h('', {p: [1]})) 41 | // @ts-expect-error: not a property value. 42 | h('', {p: [true]}) 43 | expectType(h('', {p: ['a']})) 44 | expectType(h('', {p: {x: 1}})) // Style 45 | // @ts-expect-error: not a property value. 46 | h('', {p: {x: true}}) 47 | 48 | expectType( 49 | s('svg', {viewbox: '0 0 500 500', xmlns: 'http://www.w3.org/2000/svg'}, [ 50 | s('title', 'SVG ` (https://wooorm.com)", 3 | "bugs": "https://github.com/syntax-tree/hastscript/issues", 4 | "contributors": [ 5 | "Titus Wormer (https://wooorm.com)" 6 | ], 7 | "dependencies": { 8 | "@types/hast": "^3.0.0", 9 | "comma-separated-tokens": "^2.0.0", 10 | "hast-util-parse-selector": "^4.0.0", 11 | "property-information": "^7.0.0", 12 | "space-separated-tokens": "^2.0.0" 13 | }, 14 | "description": "hast utility to create trees", 15 | "devDependencies#": "note: some bug with `typescript` 5.5 being broken", 16 | "devDependencies": { 17 | "@types/node": "^22.0.0", 18 | "acorn-jsx": "^5.0.0", 19 | "c8": "^10.0.0", 20 | "esast-util-from-js": "^2.0.0", 21 | "estree-util-build-jsx": "^3.0.0", 22 | "estree-util-to-js": "^2.0.0", 23 | "prettier": "^3.0.0", 24 | "remark-cli": "^12.0.0", 25 | "remark-preset-wooorm": "^11.0.0", 26 | "svg-tag-names": "^3.0.0", 27 | "tsd": "^0.31.0", 28 | "type-coverage": "^2.0.0", 29 | "typescript": "^5.0.0", 30 | "xo": "^0.60.0" 31 | }, 32 | "exports": { 33 | "./jsx-dev-runtime": "./lib/automatic-runtime-html.js", 34 | "./jsx-runtime": "./lib/automatic-runtime-html.js", 35 | "./svg/jsx-dev-runtime": "./lib/automatic-runtime-svg.js", 36 | "./svg/jsx-runtime": "./lib/automatic-runtime-svg.js", 37 | ".": "./index.js" 38 | }, 39 | "files": [ 40 | "index.d.ts.map", 41 | "index.d.ts", 42 | "index.js", 43 | "lib/" 44 | ], 45 | "funding": { 46 | "type": "opencollective", 47 | "url": "https://opencollective.com/unified" 48 | }, 49 | "keywords": [ 50 | "dom", 51 | "dsl", 52 | "hast-util", 53 | "hast", 54 | "html", 55 | "hyperscript", 56 | "rehype", 57 | "unist", 58 | "utility", 59 | "util", 60 | "vdom", 61 | "virtual" 62 | ], 63 | "license": "MIT", 64 | "name": "hastscript", 65 | "prettier": { 66 | "bracketSpacing": false, 67 | "semi": false, 68 | "singleQuote": true, 69 | "tabWidth": 2, 70 | "trailingComma": "none", 71 | "useTabs": false 72 | }, 73 | "remarkConfig": { 74 | "plugins": [ 75 | "remark-preset-wooorm" 76 | ] 77 | }, 78 | "repository": "syntax-tree/hastscript", 79 | "scripts": { 80 | "build": "tsc --build --clean && tsc --build && tsd && type-coverage", 81 | "format": "remark --frail --output --quiet -- . && prettier --log-level warn --write -- . && xo --fix", 82 | "generate": "node --conditions development script/generate-jsx.js && node --conditions development script/build.js", 83 | "test-api": "node --conditions development test/index.js", 84 | "test-coverage": "c8 --100 --reporter lcov -- npm run test-api", 85 | "test": "npm run generate && npm run build && npm run format && npm run test-coverage" 86 | }, 87 | "sideEffects": false, 88 | "typeCoverage": { 89 | "atLeast": 100, 90 | "ignoreFiles#": "needed `any`s :'(", 91 | "ignoreFiles": [ 92 | "test/jsx-build-jsx-automatic-development.js" 93 | ], 94 | "strict": true 95 | }, 96 | "type": "module", 97 | "version": "9.0.1", 98 | "xo": { 99 | "overrides": [ 100 | { 101 | "files": [ 102 | "**/*.ts" 103 | ], 104 | "rules": { 105 | "@typescript-eslint/array-type": [ 106 | "error", 107 | { 108 | "default": "generic" 109 | } 110 | ], 111 | "@typescript-eslint/ban-types": [ 112 | "error", 113 | { 114 | "extendDefaults": true 115 | } 116 | ], 117 | "@typescript-eslint/consistent-type-definitions": [ 118 | "error", 119 | "interface" 120 | ] 121 | } 122 | } 123 | ], 124 | "prettier": true 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /test/jsx.jsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource hastscript */ 2 | 3 | import assert from 'node:assert/strict' 4 | import test from 'node:test' 5 | import {h} from 'hastscript' 6 | 7 | test('name', async function (t) { 8 | await t.test('should support a self-closing element', async function () { 9 | assert.deepEqual(, h('a')) 10 | }) 11 | 12 | await t.test('should support a value as a child', async function () { 13 | assert.deepEqual(b, h('a', 'b')) 14 | }) 15 | 16 | await t.test('should support an uppercase tag name', async function () { 17 | const A = 'a' 18 | 19 | // Note: this file is a template, generated with different runtimes. 20 | // @ts-ignore: TS (depending on this build) sometimes doesn’t understand. 21 | assert.deepEqual(, h(A)) 22 | }) 23 | 24 | await t.test('should support expressions as children', async function () { 25 | assert.deepEqual({1 + 1}, h('a', '2')) 26 | }) 27 | 28 | await t.test('should support a fragment', async function () { 29 | assert.deepEqual(<>, {type: 'root', children: []}) 30 | }) 31 | 32 | await t.test('should support a fragment with text', async function () { 33 | assert.deepEqual(<>a, { 34 | type: 'root', 35 | children: [{type: 'text', value: 'a'}] 36 | }) 37 | }) 38 | 39 | await t.test('should support a fragment with an element', async function () { 40 | assert.deepEqual( 41 | <> 42 | 43 | , 44 | {type: 'root', children: [h('a')]} 45 | ) 46 | }) 47 | 48 | await t.test( 49 | 'should support a fragment with an expression', 50 | async function () { 51 | assert.deepEqual(<>{-1}, { 52 | type: 'root', 53 | children: [{type: 'text', value: '-1'}] 54 | }) 55 | } 56 | ) 57 | 58 | await t.test('should support members as names (`a.b`)', async function () { 59 | const com = {acme: {a: 'A', b: 'B'}} 60 | 61 | assert.deepEqual( 62 | // Note: this file is a template, generated with different runtimes. 63 | // @ts-ignore: TS (depending on this build) sometimes doesn’t understand. 64 | , 65 | h(com.acme.a) 66 | ) 67 | }) 68 | 69 | await t.test('should support a boolean attribute', async function () { 70 | assert.deepEqual(, h('a', {b: true})) 71 | }) 72 | 73 | await t.test('should support a double quoted attribute', async function () { 74 | assert.deepEqual(, h('a', {b: ''})) 75 | }) 76 | 77 | await t.test('should support a single quoted attribute', async function () { 78 | assert.deepEqual(, h('a', {b: '"'})) 79 | }) 80 | 81 | await t.test('should support expression value attributes', async function () { 82 | assert.deepEqual(, h('a', {b: 2})) 83 | }) 84 | 85 | await t.test( 86 | 'should support expression spread attributes', 87 | async function () { 88 | const properties = {a: 1, b: 2} 89 | 90 | assert.deepEqual(, h('a', properties)) 91 | } 92 | ) 93 | 94 | await t.test( 95 | 'should support text, elements, and expressions in jsx', 96 | async function () { 97 | assert.deepEqual( 98 | 99 | ce 100 | {1 + 1} 101 | , 102 | h('a', [h('b'), 'c', h('d', 'e'), '2']) 103 | ) 104 | } 105 | ) 106 | 107 | await t.test( 108 | 'should support a fragment in an element (#1)', 109 | async function () { 110 | assert.deepEqual( 111 | 112 | <>{1} 113 | , 114 | h('a', '1') 115 | ) 116 | } 117 | ) 118 | 119 | await t.test( 120 | 'should support a fragment in an element (#2)', 121 | async function () { 122 | const dl = [ 123 | ['Firefox', 'A red panda.'], 124 | ['Chrome', 'A chemical element.'] 125 | ] 126 | 127 | assert.deepEqual( 128 |
129 | {dl.map(function ([title, definition]) { 130 | return ( 131 | <> 132 |
{title}
133 |
{definition}
134 | 135 | ) 136 | })} 137 |
, 138 | h('dl', [ 139 | h('dt', dl[0][0]), 140 | h('dd', dl[0][1]), 141 | h('dt', dl[1][0]), 142 | h('dd', dl[1][1]) 143 | ]) 144 | ) 145 | } 146 | ) 147 | }) 148 | -------------------------------------------------------------------------------- /lib/create-h.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @import {Element, Nodes, RootContent, Root} from 'hast' 3 | * @import {Info, Schema} from 'property-information' 4 | */ 5 | 6 | /** 7 | * @typedef {Array} ArrayChildNested 8 | * List of children (deep). 9 | */ 10 | 11 | /** 12 | * @typedef {Array} ArrayChild 13 | * List of children. 14 | */ 15 | 16 | /** 17 | * @typedef {Array} ArrayValue 18 | * List of property values for space- or comma separated values (such as `className`). 19 | */ 20 | 21 | /** 22 | * @typedef {ArrayChild | Nodes | PrimitiveChild} Child 23 | * Acceptable child value. 24 | */ 25 | 26 | /** 27 | * @typedef {number | string | null | undefined} PrimitiveChild 28 | * Primitive children, either ignored (nullish), or turned into text nodes. 29 | */ 30 | 31 | /** 32 | * @typedef {boolean | number | string | null | undefined} PrimitiveValue 33 | * Primitive property value. 34 | */ 35 | 36 | /** 37 | * @typedef {Record} Properties 38 | * Acceptable value for element properties. 39 | */ 40 | 41 | /** 42 | * @typedef {ArrayValue | PrimitiveValue} PropertyValue 43 | * Primitive value or list value. 44 | */ 45 | 46 | /** 47 | * @typedef {Element | Root} Result 48 | * Result from a `h` (or `s`) call. 49 | */ 50 | 51 | /** 52 | * @typedef {number | string} StyleValue 53 | * Value for a CSS style field. 54 | */ 55 | 56 | /** 57 | * @typedef {Record} Style 58 | * Supported value of a `style` prop. 59 | */ 60 | 61 | import {parse as parseCommas} from 'comma-separated-tokens' 62 | import {parseSelector} from 'hast-util-parse-selector' 63 | import {find, normalize} from 'property-information' 64 | import {parse as parseSpaces} from 'space-separated-tokens' 65 | 66 | /** 67 | * @param {Schema} schema 68 | * Schema to use. 69 | * @param {string} defaultTagName 70 | * Default tag name. 71 | * @param {ReadonlyArray | undefined} [caseSensitive] 72 | * Case-sensitive tag names (default: `undefined`). 73 | * @returns 74 | * `h`. 75 | */ 76 | export function createH(schema, defaultTagName, caseSensitive) { 77 | const adjust = caseSensitive ? createAdjustMap(caseSensitive) : undefined 78 | 79 | /** 80 | * Hyperscript compatible DSL for creating virtual hast trees. 81 | * 82 | * @overload 83 | * @param {null | undefined} [selector] 84 | * @param {...Child} children 85 | * @returns {Root} 86 | * 87 | * @overload 88 | * @param {string} selector 89 | * @param {Properties} properties 90 | * @param {...Child} children 91 | * @returns {Element} 92 | * 93 | * @overload 94 | * @param {string} selector 95 | * @param {...Child} children 96 | * @returns {Element} 97 | * 98 | * @param {string | null | undefined} [selector] 99 | * Selector. 100 | * @param {Child | Properties | null | undefined} [properties] 101 | * Properties (or first child) (default: `undefined`). 102 | * @param {...Child} children 103 | * Children. 104 | * @returns {Result} 105 | * Result. 106 | */ 107 | function h(selector, properties, ...children) { 108 | /** @type {Result} */ 109 | let node 110 | 111 | if (selector === null || selector === undefined) { 112 | node = {type: 'root', children: []} 113 | // Properties are not supported for roots. 114 | const child = /** @type {Child} */ (properties) 115 | children.unshift(child) 116 | } else { 117 | node = parseSelector(selector, defaultTagName) 118 | // Normalize the name. 119 | const lower = node.tagName.toLowerCase() 120 | const adjusted = adjust ? adjust.get(lower) : undefined 121 | node.tagName = adjusted || lower 122 | 123 | // Handle properties. 124 | if (isChild(properties)) { 125 | children.unshift(properties) 126 | } else { 127 | for (const [key, value] of Object.entries(properties)) { 128 | addProperty(schema, node.properties, key, value) 129 | } 130 | } 131 | } 132 | 133 | // Handle children. 134 | for (const child of children) { 135 | addChild(node.children, child) 136 | } 137 | 138 | if (node.type === 'element' && node.tagName === 'template') { 139 | node.content = {type: 'root', children: node.children} 140 | node.children = [] 141 | } 142 | 143 | return node 144 | } 145 | 146 | return h 147 | } 148 | 149 | /** 150 | * Check if something is properties or a child. 151 | * 152 | * @param {Child | Properties} value 153 | * Value to check. 154 | * @returns {value is Child} 155 | * Whether `value` is definitely a child. 156 | */ 157 | function isChild(value) { 158 | // Never properties if not an object. 159 | if (value === null || typeof value !== 'object' || Array.isArray(value)) { 160 | return true 161 | } 162 | 163 | // Never node without `type`; that’s the main discriminator. 164 | if (typeof value.type !== 'string') return false 165 | 166 | // Slower check: never property value if object or array with 167 | // non-number/strings. 168 | const record = /** @type {Record} */ (value) 169 | const keys = Object.keys(value) 170 | 171 | for (const key of keys) { 172 | const value = record[key] 173 | 174 | if (value && typeof value === 'object') { 175 | if (!Array.isArray(value)) return true 176 | 177 | const list = /** @type {ReadonlyArray} */ (value) 178 | 179 | for (const item of list) { 180 | if (typeof item !== 'number' && typeof item !== 'string') { 181 | return true 182 | } 183 | } 184 | } 185 | } 186 | 187 | // Also see empty `children` as a node. 188 | if ('children' in value && Array.isArray(value.children)) { 189 | return true 190 | } 191 | 192 | // Default to properties, someone can always pass an empty object, 193 | // put `data: {}` in a node, 194 | // or wrap it in an array. 195 | return false 196 | } 197 | 198 | /** 199 | * @param {Schema} schema 200 | * Schema. 201 | * @param {Properties} properties 202 | * Properties object. 203 | * @param {string} key 204 | * Property name. 205 | * @param {PropertyValue | Style} value 206 | * Property value. 207 | * @returns {undefined} 208 | * Nothing. 209 | */ 210 | function addProperty(schema, properties, key, value) { 211 | const info = find(schema, key) 212 | /** @type {PropertyValue} */ 213 | let result 214 | 215 | // Ignore nullish and NaN values. 216 | if (value === null || value === undefined) return 217 | 218 | if (typeof value === 'number') { 219 | // Ignore NaN. 220 | if (Number.isNaN(value)) return 221 | 222 | result = value 223 | } 224 | // Booleans. 225 | else if (typeof value === 'boolean') { 226 | result = value 227 | } 228 | // Handle list values. 229 | else if (typeof value === 'string') { 230 | if (info.spaceSeparated) { 231 | result = parseSpaces(value) 232 | } else if (info.commaSeparated) { 233 | result = parseCommas(value) 234 | } else if (info.commaOrSpaceSeparated) { 235 | result = parseSpaces(parseCommas(value).join(' ')) 236 | } else { 237 | result = parsePrimitive(info, info.property, value) 238 | } 239 | } else if (Array.isArray(value)) { 240 | result = [...value] 241 | } else { 242 | result = info.property === 'style' ? style(value) : String(value) 243 | } 244 | 245 | if (Array.isArray(result)) { 246 | /** @type {Array} */ 247 | const finalResult = [] 248 | 249 | for (const item of result) { 250 | // Assume no booleans in array. 251 | finalResult.push( 252 | /** @type {number | string} */ ( 253 | parsePrimitive(info, info.property, item) 254 | ) 255 | ) 256 | } 257 | 258 | result = finalResult 259 | } 260 | 261 | // Class names (which can be added both on the `selector` and here). 262 | if (info.property === 'className' && Array.isArray(properties.className)) { 263 | // Assume no booleans in `className`. 264 | result = properties.className.concat( 265 | /** @type {Array | number | string} */ (result) 266 | ) 267 | } 268 | 269 | properties[info.property] = result 270 | } 271 | 272 | /** 273 | * @param {Array} nodes 274 | * Children. 275 | * @param {Child} value 276 | * Child. 277 | * @returns {undefined} 278 | * Nothing. 279 | */ 280 | function addChild(nodes, value) { 281 | if (value === null || value === undefined) { 282 | // Empty. 283 | } else if (typeof value === 'number' || typeof value === 'string') { 284 | nodes.push({type: 'text', value: String(value)}) 285 | } else if (Array.isArray(value)) { 286 | for (const child of value) { 287 | addChild(nodes, child) 288 | } 289 | } else if (typeof value === 'object' && 'type' in value) { 290 | if (value.type === 'root') { 291 | addChild(nodes, value.children) 292 | } else { 293 | nodes.push(value) 294 | } 295 | } else { 296 | throw new Error('Expected node, nodes, or string, got `' + value + '`') 297 | } 298 | } 299 | 300 | /** 301 | * Parse a single primitives. 302 | * 303 | * @param {Info} info 304 | * Property information. 305 | * @param {string} name 306 | * Property name. 307 | * @param {PrimitiveValue} value 308 | * Property value. 309 | * @returns {PrimitiveValue} 310 | * Property value. 311 | */ 312 | function parsePrimitive(info, name, value) { 313 | if (typeof value === 'string') { 314 | if (info.number && value && !Number.isNaN(Number(value))) { 315 | return Number(value) 316 | } 317 | 318 | if ( 319 | (info.boolean || info.overloadedBoolean) && 320 | (value === '' || normalize(value) === normalize(name)) 321 | ) { 322 | return true 323 | } 324 | } 325 | 326 | return value 327 | } 328 | 329 | /** 330 | * Serialize a `style` object as a string. 331 | * 332 | * @param {Style} styles 333 | * Style object. 334 | * @returns {string} 335 | * CSS string. 336 | */ 337 | function style(styles) { 338 | /** @type {Array} */ 339 | const result = [] 340 | 341 | for (const [key, value] of Object.entries(styles)) { 342 | result.push([key, value].join(': ')) 343 | } 344 | 345 | return result.join('; ') 346 | } 347 | 348 | /** 349 | * Create a map to adjust casing. 350 | * 351 | * @param {ReadonlyArray} values 352 | * List of properly cased keys. 353 | * @returns {Map} 354 | * Map of lowercase keys to uppercase keys. 355 | */ 356 | function createAdjustMap(values) { 357 | /** @type {Map} */ 358 | const result = new Map() 359 | 360 | for (const value of values) { 361 | result.set(value.toLowerCase(), value) 362 | } 363 | 364 | return result 365 | } 366 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # hastscript 2 | 3 | [![Build][badge-build-image]][badge-build-url] 4 | [![Coverage][badge-coverage-image]][badge-coverage-url] 5 | [![Downloads][badge-downloads-image]][badge-downloads-url] 6 | [![Size][badge-size-image]][badge-size-url] 7 | 8 | [hast][github-hast] utility to create trees with ease. 9 | 10 | ## Contents 11 | 12 | * [What is this?](#what-is-this) 13 | * [When should I use this?](#when-should-i-use-this) 14 | * [Install](#install) 15 | * [Use](#use) 16 | * [API](#api) 17 | * [`h(selector?[, properties][, …children])`](#hselector-properties-children) 18 | * [`s(selector?[, properties][, …children])`](#sselector-properties-children) 19 | * [`Child`](#child) 20 | * [`Properties`](#properties-1) 21 | * [`Result`](#result) 22 | * [Syntax tree](#syntax-tree) 23 | * [JSX](#jsx) 24 | * [Compatibility](#compatibility) 25 | * [Security](#security) 26 | * [Related](#related) 27 | * [Contribute](#contribute) 28 | * [License](#license) 29 | 30 | ## What is this? 31 | 32 | This package is a hyperscript interface (like `createElement` from React and 33 | `h` from Vue and such) to help with creating hast trees. 34 | 35 | ## When should I use this? 36 | 37 | You can use this utility in your project when you generate hast syntax trees 38 | with code. 39 | It helps because it replaces most of the repetition otherwise needed in a syntax 40 | tree with function calls. 41 | It also helps as it improves the attributes you pass by turning them into the 42 | form that is required by hast. 43 | 44 | You can instead use [`unist-builder`][github-unist-builder] 45 | when creating any unist nodes and 46 | [`xastscript`][github-xastscript] when creating xast (XML) nodes. 47 | 48 | ## Install 49 | 50 | This package is [ESM only][github-gist-esm]. 51 | In Node.js (version 16+), 52 | install with [npm][npmjs-install]: 53 | 54 | ```sh 55 | npm install hastscript 56 | ``` 57 | 58 | In Deno with [`esm.sh`][esmsh]: 59 | 60 | ```js 61 | import {h} from 'https://esm.sh/hastscript@9' 62 | ``` 63 | 64 | In browsers with [`esm.sh`][esmsh]: 65 | 66 | ```html 67 | 70 | ``` 71 | 72 | ## Use 73 | 74 | ```js 75 | import {h, s} from 'hastscript' 76 | 77 | console.log( 78 | h('.foo#some-id', [ 79 | h('span', 'some text'), 80 | h('input', {type: 'text', value: 'foo'}), 81 | h('a.alpha', {class: 'bravo charlie', download: 'download'}, [ 82 | 'delta', 83 | 'echo' 84 | ]) 85 | ]) 86 | ) 87 | 88 | console.log( 89 | s('svg', {viewbox: '0 0 500 500', xmlns: 'http://www.w3.org/2000/svg'}, [ 90 | s('title', 'SVG `` element'), 91 | s('circle', {cx: 120, cy: 120, r: 100}) 92 | ]) 93 | ) 94 | ``` 95 | 96 | Yields: 97 | 98 | ```js 99 | { 100 | type: 'element', 101 | tagName: 'div', 102 | properties: {className: ['foo'], id: 'some-id'}, 103 | children: [ 104 | { 105 | type: 'element', 106 | tagName: 'span', 107 | properties: {}, 108 | children: [{type: 'text', value: 'some text'}] 109 | }, 110 | { 111 | type: 'element', 112 | tagName: 'input', 113 | properties: {type: 'text', value: 'foo'}, 114 | children: [] 115 | }, 116 | { 117 | type: 'element', 118 | tagName: 'a', 119 | properties: {className: ['alpha', 'bravo', 'charlie'], download: true}, 120 | children: [{type: 'text', value: 'delta'}, {type: 'text', value: 'echo'}] 121 | } 122 | ] 123 | } 124 | { 125 | type: 'element', 126 | tagName: 'svg', 127 | properties: {viewBox: '0 0 500 500', xmlns: 'http://www.w3.org/2000/svg'}, 128 | children: [ 129 | { 130 | type: 'element', 131 | tagName: 'title', 132 | properties: {}, 133 | children: [{type: 'text', value: 'SVG `` element'}] 134 | }, 135 | { 136 | type: 'element', 137 | tagName: 'circle', 138 | properties: {cx: 120, cy: 120, r: 100}, 139 | children: [] 140 | } 141 | ] 142 | } 143 | ``` 144 | 145 | ## API 146 | 147 | This package exports the identifiers [`h`][api-h] and [`s`][api-s]. 148 | There is no default export. 149 | It exports the additional [TypeScript][] types 150 | [`Child`][api-child], 151 | [`Properties`][api-properties], 152 | and 153 | [`Result`][api-result]. 154 | 155 | The export map supports the automatic JSX runtime. 156 | You can pass `hastscript` or `hastscript/svg` to your build tool 157 | (TypeScript, Babel, SWC) 158 | with an `importSource` option or similar. 159 | 160 | ### `h(selector?[, properties][, …children])` 161 | 162 | Create virtual **[hast][github-hast]** trees for HTML. 163 | 164 | ##### Signatures 165 | 166 | * `h(): root` 167 | * `h(null[, …children]): root` 168 | * `h(selector[, properties][, …children]): element` 169 | 170 | ##### Parameters 171 | 172 | ###### `selector` 173 | 174 | Simple CSS selector 175 | (`string`, optional). 176 | When string, builds an [`Element`][github-hast-element]. 177 | When nullish, builds a [`Root`][github-hast-root] instead. 178 | The selector can contain a tag name (`foo`), 179 | IDs (`#bar`), 180 | and classes (`.baz`). 181 | If the selector is a string but there is no tag name in it then `h` defaults to 182 | build a `div` element and `s` to a `g` element. 183 | `selector` is parsed by 184 | [`hast-util-parse-selector`][github-hast-util-parse-selector]. 185 | 186 | ###### `properties` 187 | 188 | Properties of the element 189 | ([`Properties`][api-properties], optional). 190 | 191 | ###### `children` 192 | 193 | Children of the node ([`Child`][api-child] or `Array`, optional). 194 | 195 | ##### Returns 196 | 197 | Created tree ([`Result`][api-result]). 198 | 199 | [`Element`][github-hast-element] when a `selector` is passed, 200 | otherwise [`Root`][github-hast-root]. 201 | 202 | ### `s(selector?[, properties][, …children])` 203 | 204 | Create virtual **[hast][github-hast]** trees for SVG. 205 | 206 | Signatures, parameters, and return value are the same as `h` above. 207 | Importantly, 208 | the `selector` and `properties` parameters are interpreted as SVG. 209 | 210 | ### `Child` 211 | 212 | (Lists of) children (TypeScript type). 213 | 214 | When strings or numbers are encountered, 215 | they are turned into [`Text`][github-hast-text] 216 | nodes. 217 | [`Root`][github-hast-root] nodes are treated as “fragments”, 218 | meaning that their children are used instead. 219 | 220 | ###### Type 221 | 222 | ```ts 223 | type Child = 224 | | Array 225 | | Node 226 | | number 227 | | string 228 | | null 229 | | undefined 230 | ``` 231 | 232 | ### `Properties` 233 | 234 | Map of properties (TypeScript type). 235 | Keys should match either the HTML attribute name or the DOM property name, 236 | but are case-insensitive. 237 | 238 | ###### Type 239 | 240 | ```ts 241 | type Properties = Record< 242 | string, 243 | | boolean 244 | | number 245 | | string 246 | | null 247 | | undefined 248 | // For comma- and space-separated values such as `className`: 249 | | Array 250 | // Accepts value for `style` prop as object. 251 | | Record 252 | > 253 | ``` 254 | 255 | ### `Result` 256 | 257 | Result from a `h` (or `s`) call (TypeScript type). 258 | 259 | ###### Type 260 | 261 | ```ts 262 | type Result = Element | Root 263 | ``` 264 | 265 | ## Syntax tree 266 | 267 | The syntax tree is [hast][github-hast]. 268 | 269 | ## JSX 270 | 271 | This package can be used with JSX. 272 | You should use the automatic JSX runtime set to `hastscript` or 273 | `hastscript/svg`. 274 | 275 | > 👉 **Note** 276 | > while `h` supports dots (`.`) for classes or number signs (`#`) 277 | > for IDs in `selector`, 278 | > those are not supported in JSX. 279 | 280 | > 🪦 **Legacy**: 281 | > you can also use the classic JSX runtime, 282 | > but this is not recommended. 283 | > To do so, 284 | > import `h` (or `s`) yourself and define it as the pragma 285 | > (plus set the fragment to `null`). 286 | 287 | The Use example above can then be written like so, 288 | using inline pragmas, 289 | so that SVG can be used too: 290 | 291 | `example-html.jsx`: 292 | 293 | ```js 294 | /** @jsxImportSource hastscript */ 295 | console.log( 296 |
297 | some text 298 | 299 | 300 | deltaecho 301 | 302 |
303 | ) 304 | ``` 305 | 306 | `example-svg.jsx`: 307 | 308 | ```js 309 | /** @jsxImportSource hastscript/svg */ 310 | console.log( 311 | 312 | SVG `<circle>` element 313 | 314 | 315 | ) 316 | ``` 317 | 318 | ## Compatibility 319 | 320 | Projects maintained by the unified collective are compatible with maintained 321 | versions of Node.js. 322 | 323 | When we cut a new major release, 324 | we drop support for unmaintained versions of Node. 325 | This means we try to keep the current release line, 326 | `hastscript@9`, 327 | compatible with Node.js 16. 328 | 329 | ## Security 330 | 331 | Use of `hastscript` can open you up to a 332 | [cross-site scripting (XSS)][wikipedia-xss] 333 | when you pass user-provided input to it because values are injected into the 334 | syntax tree. 335 | 336 | The following example shows how an image is injected that fails loading and 337 | therefore runs code in a browser. 338 | 339 | ```js 340 | const tree = h() 341 | 342 | // Somehow someone injected these properties instead of an expected `src` and 343 | // `alt`: 344 | const otherProps = {onError: 'alert(1)', src: 'x'} 345 | 346 | tree.children.push(h('img', {src: 'default.png', ...otherProps})) 347 | ``` 348 | 349 | Yields: 350 | 351 | ```html 352 | 353 | ``` 354 | 355 | The following example shows how code can run in a browser because someone stored 356 | an object in a database instead of the expected string. 357 | 358 | ```js 359 | const tree = h() 360 | 361 | // Somehow this isn’t the expected `'wooorm'`. 362 | const username = { 363 | type: 'element', 364 | tagName: 'script', 365 | children: [{type: 'text', value: 'alert(2)'}] 366 | } 367 | 368 | tree.children.push(h('span.handle', username)) 369 | ``` 370 | 371 | Yields: 372 | 373 | ```html 374 | 375 | ``` 376 | 377 | Either do not use user-provided input in `hastscript` or use 378 | [`hast-util-santize`][github-hast-util-sanitize]. 379 | 380 | ## Related 381 | 382 | * [`unist-builder`][github-unist-builder] 383 | — create unist trees 384 | * [`xastscript`][github-xastscript] 385 | — create xast trees 386 | * [`hast-to-hyperscript`](https://github.com/syntax-tree/hast-to-hyperscript) 387 | — turn hast into React, Preact, Vue, etc 388 | * [`hast-util-to-html`](https://github.com/syntax-tree/hast-util-to-html) 389 | — turn hast into HTML 390 | * [`hast-util-to-dom`](https://github.com/syntax-tree/hast-util-to-dom) 391 | — turn hast into DOM trees 392 | * [`estree-util-build-jsx`](https://github.com/syntax-tree/estree-util-build-jsx) 393 | — compile JSX away 394 | 395 | ## Contribute 396 | 397 | See 398 | [`contributing.md`][health-contributing] 399 | in 400 | [`syntax-tree/.github`][health] 401 | for ways to get started. 402 | See [`support.md`][health-support] for ways to get help. 403 | 404 | This project has a [code of conduct][health-coc]. 405 | By interacting with this repository, 406 | organization, 407 | or community you agree to abide by its terms. 408 | 409 | ## License 410 | 411 | [MIT][file-license] © [Titus Wormer][wooorm] 412 | 413 | 414 | 415 | [api-child]: #child 416 | 417 | [api-h]: #hselector-properties-children 418 | 419 | [api-properties]: #properties-1 420 | 421 | [api-result]: #result 422 | 423 | [api-s]: #sselector-properties-children 424 | 425 | [badge-build-image]: https://github.com/syntax-tree/hastscript/workflows/main/badge.svg 426 | 427 | [badge-build-url]: https://github.com/syntax-tree/hastscript/actions 428 | 429 | [badge-coverage-image]: https://img.shields.io/codecov/c/github/syntax-tree/hastscript.svg 430 | 431 | [badge-coverage-url]: https://codecov.io/github/syntax-tree/hastscript 432 | 433 | [badge-downloads-image]: https://img.shields.io/npm/dm/hastscript.svg 434 | 435 | [badge-downloads-url]: https://www.npmjs.com/package/hastscript 436 | 437 | [badge-size-image]: https://img.shields.io/bundlejs/size/hastscript 438 | 439 | [badge-size-url]: https://bundlejs.com/?q=hastscript 440 | 441 | [esmsh]: https://esm.sh 442 | 443 | [file-license]: license 444 | 445 | [github-gist-esm]: https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c 446 | 447 | [github-hast]: https://github.com/syntax-tree/hast 448 | 449 | [github-hast-element]: https://github.com/syntax-tree/hast#element 450 | 451 | [github-hast-root]: https://github.com/syntax-tree/hast#root 452 | 453 | [github-hast-text]: https://github.com/syntax-tree/hast#text 454 | 455 | [github-hast-util-parse-selector]: https://github.com/syntax-tree/hast-util-parse-selector 456 | 457 | [github-hast-util-sanitize]: https://github.com/syntax-tree/hast-util-sanitize 458 | 459 | [github-unist-builder]: https://github.com/syntax-tree/unist-builder 460 | 461 | [github-xastscript]: https://github.com/syntax-tree/xastscript 462 | 463 | [health]: https://github.com/syntax-tree/.github 464 | 465 | [health-coc]: https://github.com/syntax-tree/.github/blob/main/code-of-conduct.md 466 | 467 | [health-contributing]: https://github.com/syntax-tree/.github/blob/main/contributing.md 468 | 469 | [health-support]: https://github.com/syntax-tree/.github/blob/main/support.md 470 | 471 | [npmjs-install]: https://docs.npmjs.com/cli/install 472 | 473 | [typescript]: https://www.typescriptlang.org 474 | 475 | [wikipedia-xss]: https://en.wikipedia.org/wiki/Cross-site_scripting 476 | 477 | [wooorm]: https://wooorm.com 478 | -------------------------------------------------------------------------------- /test/core.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert/strict' 2 | import test from 'node:test' 3 | import {h, s} from 'hastscript' 4 | 5 | test('core', async function (t) { 6 | await t.test('should expose the public api (`/`)', async function () { 7 | assert.deepEqual(Object.keys(await import('hastscript')).sort(), ['h', 's']) 8 | }) 9 | 10 | await t.test( 11 | 'should expose the public api (`/jsx-runtime`)', 12 | async function () { 13 | assert.deepEqual( 14 | Object.keys(await import('hastscript/jsx-runtime')).sort(), 15 | ['Fragment', 'jsx', 'jsxDEV', 'jsxs'] 16 | ) 17 | } 18 | ) 19 | 20 | await t.test( 21 | 'should expose the public api (`/jsx-dev-runtime`)', 22 | async function () { 23 | assert.deepEqual( 24 | Object.keys(await import('hastscript/jsx-dev-runtime')).sort(), 25 | ['Fragment', 'jsx', 'jsxDEV', 'jsxs'] 26 | ) 27 | } 28 | ) 29 | 30 | await t.test( 31 | 'should expose the public api (`/svg/jsx-runtime`)', 32 | async function () { 33 | assert.deepEqual( 34 | Object.keys(await import('hastscript/svg/jsx-runtime')).sort(), 35 | ['Fragment', 'jsx', 'jsxDEV', 'jsxs'] 36 | ) 37 | } 38 | ) 39 | 40 | await t.test( 41 | 'should expose the public api (`/svg/jsx-dev-runtime`)', 42 | async function () { 43 | assert.deepEqual( 44 | Object.keys(await import('hastscript/svg/jsx-dev-runtime')).sort(), 45 | ['Fragment', 'jsx', 'jsxDEV', 'jsxs'] 46 | ) 47 | } 48 | ) 49 | }) 50 | 51 | test('selector', async function (t) { 52 | await t.test( 53 | 'should create a `root` node without arguments', 54 | async function () { 55 | assert.deepEqual(h(), {type: 'root', children: []}) 56 | } 57 | ) 58 | 59 | await t.test( 60 | 'should create a `div` element w/ an empty string name', 61 | async function () { 62 | assert.deepEqual(h(''), { 63 | type: 'element', 64 | tagName: 'div', 65 | properties: {}, 66 | children: [] 67 | }) 68 | } 69 | ) 70 | 71 | await t.test('should append to the selector’s classes', async function () { 72 | assert.deepEqual(h('.bar', {class: 'baz'}), { 73 | type: 'element', 74 | tagName: 'div', 75 | properties: {className: ['bar', 'baz']}, 76 | children: [] 77 | }) 78 | }) 79 | 80 | await t.test( 81 | 'should create a `div` element when given an id selector', 82 | async function () { 83 | assert.deepEqual(h('#id'), { 84 | type: 'element', 85 | tagName: 'div', 86 | properties: {id: 'id'}, 87 | children: [] 88 | }) 89 | } 90 | ) 91 | 92 | await t.test( 93 | 'should create an element with the last ID when given multiple in a selector', 94 | async function () { 95 | assert.deepEqual(h('#a#b'), { 96 | type: 'element', 97 | tagName: 'div', 98 | properties: {id: 'b'}, 99 | children: [] 100 | }) 101 | } 102 | ) 103 | 104 | await t.test( 105 | 'should create a `div` element when given a class selector', 106 | async function () { 107 | assert.deepEqual(h('.foo'), { 108 | type: 'element', 109 | tagName: 'div', 110 | properties: {className: ['foo']}, 111 | children: [] 112 | }) 113 | } 114 | ) 115 | 116 | await t.test( 117 | 'should create a `foo` element when given a tag selector', 118 | async function () { 119 | assert.deepEqual(h('foo'), { 120 | type: 'element', 121 | tagName: 'foo', 122 | properties: {}, 123 | children: [] 124 | }) 125 | } 126 | ) 127 | 128 | await t.test( 129 | 'should create a `foo` element with an ID when given a both as a selector', 130 | async function () { 131 | assert.deepEqual(h('foo#bar'), { 132 | type: 'element', 133 | tagName: 'foo', 134 | properties: {id: 'bar'}, 135 | children: [] 136 | }) 137 | } 138 | ) 139 | 140 | await t.test( 141 | 'should create a `foo` element with a class when given a both as a selector', 142 | async function () { 143 | assert.deepEqual(h('foo.bar'), { 144 | type: 'element', 145 | tagName: 'foo', 146 | properties: {className: ['bar']}, 147 | children: [] 148 | }) 149 | } 150 | ) 151 | 152 | await t.test('should support multiple classes', async function () { 153 | assert.deepEqual(h('.foo.bar'), { 154 | type: 'element', 155 | tagName: 'div', 156 | properties: {className: ['foo', 'bar']}, 157 | children: [] 158 | }) 159 | }) 160 | }) 161 | 162 | test('property names', async function (t) { 163 | await t.test( 164 | 'should support correctly cased property names', 165 | async function () { 166 | assert.deepEqual(h('', {className: 'foo'}), { 167 | type: 'element', 168 | tagName: 'div', 169 | properties: {className: ['foo']}, 170 | children: [] 171 | }) 172 | } 173 | ) 174 | 175 | await t.test('should map attributes to property names', async function () { 176 | assert.deepEqual(h('', {class: 'foo'}), { 177 | type: 'element', 178 | tagName: 'div', 179 | properties: {className: ['foo']}, 180 | children: [] 181 | }) 182 | }) 183 | 184 | await t.test( 185 | 'should map attribute-like values to property names', 186 | async function () { 187 | assert.deepEqual(h('', {CLASS: 'foo'}), { 188 | type: 'element', 189 | tagName: 'div', 190 | properties: {className: ['foo']}, 191 | children: [] 192 | }) 193 | } 194 | ) 195 | 196 | await t.test( 197 | 'should *not* map property-like values to property names', 198 | async function () { 199 | assert.deepEqual(h('', {'class-name': 'foo'}), { 200 | type: 'element', 201 | tagName: 'div', 202 | properties: {'class-name': 'foo'}, 203 | children: [] 204 | }) 205 | } 206 | ) 207 | }) 208 | 209 | test('property names (unknown)', async function (t) { 210 | await t.test('should keep lower-cased unknown names', async function () { 211 | assert.deepEqual(h('', {allowbigscreen: true}), { 212 | type: 'element', 213 | tagName: 'div', 214 | properties: {allowbigscreen: true}, 215 | children: [] 216 | }) 217 | }) 218 | 219 | await t.test('should keep camel-cased unknown names', async function () { 220 | assert.deepEqual(h('', {allowBigScreen: true}), { 221 | type: 'element', 222 | tagName: 'div', 223 | properties: {allowBigScreen: true}, 224 | children: [] 225 | }) 226 | }) 227 | 228 | await t.test('should keep weirdly cased unknown names', async function () { 229 | assert.deepEqual(h('', {'allow_big-screen': true}), { 230 | type: 'element', 231 | tagName: 'div', 232 | properties: {'allow_big-screen': true}, 233 | children: [] 234 | }) 235 | }) 236 | }) 237 | 238 | test('property names (other)', async function (t) { 239 | await t.test('should support aria attribute names', async function () { 240 | assert.deepEqual(h('', {'aria-valuenow': 1}), { 241 | type: 'element', 242 | tagName: 'div', 243 | properties: {ariaValueNow: 1}, 244 | children: [] 245 | }) 246 | }) 247 | 248 | await t.test('should support aria property names', async function () { 249 | assert.deepEqual(h('', {ariaValueNow: 1}), { 250 | type: 'element', 251 | tagName: 'div', 252 | properties: {ariaValueNow: 1}, 253 | children: [] 254 | }) 255 | }) 256 | 257 | await t.test('should support svg attribute names', async function () { 258 | assert.deepEqual(s('', {'color-interpolation-filters': 'sRGB'}), { 259 | type: 'element', 260 | tagName: 'g', 261 | properties: {colorInterpolationFilters: 'sRGB'}, 262 | children: [] 263 | }) 264 | }) 265 | 266 | await t.test('should support svg property names', async function () { 267 | assert.deepEqual(s('', {colorInterpolationFilters: 'sRGB'}), { 268 | type: 'element', 269 | tagName: 'g', 270 | properties: {colorInterpolationFilters: 'sRGB'}, 271 | children: [] 272 | }) 273 | }) 274 | 275 | await t.test('should support xml attribute names', async function () { 276 | assert.deepEqual(s('', {'xml:space': 'preserve'}), { 277 | type: 'element', 278 | tagName: 'g', 279 | properties: {xmlSpace: 'preserve'}, 280 | children: [] 281 | }) 282 | }) 283 | 284 | await t.test('should support xml property names', async function () { 285 | assert.deepEqual(s('', {xmlSpace: 'preserve'}), { 286 | type: 'element', 287 | tagName: 'g', 288 | properties: {xmlSpace: 'preserve'}, 289 | children: [] 290 | }) 291 | }) 292 | 293 | await t.test('should support xmlns attribute names', async function () { 294 | assert.deepEqual(s('', {'xmlns:xlink': 'http://www.w3.org/1999/xlink'}), { 295 | type: 'element', 296 | tagName: 'g', 297 | properties: {xmlnsXLink: 'http://www.w3.org/1999/xlink'}, 298 | children: [] 299 | }) 300 | }) 301 | 302 | await t.test('should support xmlns property names', async function () { 303 | assert.deepEqual(s('', {xmlnsXLink: 'http://www.w3.org/1999/xlink'}), { 304 | type: 'element', 305 | tagName: 'g', 306 | properties: {xmlnsXLink: 'http://www.w3.org/1999/xlink'}, 307 | children: [] 308 | }) 309 | }) 310 | 311 | await t.test('should support xlink attribute names', async function () { 312 | assert.deepEqual(s('', {'xlink:arcrole': 'http://www.example.com'}), { 313 | type: 'element', 314 | tagName: 'g', 315 | properties: {xLinkArcRole: 'http://www.example.com'}, 316 | children: [] 317 | }) 318 | }) 319 | 320 | await t.test('should support xlink property names', async function () { 321 | assert.deepEqual(s('', {xLinkArcRole: 'http://www.example.com'}), { 322 | type: 'element', 323 | tagName: 'g', 324 | properties: {xLinkArcRole: 'http://www.example.com'}, 325 | children: [] 326 | }) 327 | }) 328 | }) 329 | 330 | test('data property names', async function (t) { 331 | await t.test('should support data attribute names', async function () { 332 | assert.deepEqual(h('', {'data-foo': true}), { 333 | type: 'element', 334 | tagName: 'div', 335 | properties: {dataFoo: true}, 336 | children: [] 337 | }) 338 | }) 339 | 340 | await t.test( 341 | 'should support numeric-first data attribute names', 342 | async function () { 343 | assert.deepEqual(h('', {'data-123': true}), { 344 | type: 'element', 345 | tagName: 'div', 346 | properties: {data123: true}, 347 | children: [] 348 | }) 349 | } 350 | ) 351 | 352 | await t.test('should support data property names', async function () { 353 | assert.deepEqual(h('', {dataFooBar: true}), { 354 | type: 'element', 355 | tagName: 'div', 356 | properties: {dataFooBar: true}, 357 | children: [] 358 | }) 359 | }) 360 | 361 | await t.test( 362 | 'should support numeric-first data property names', 363 | async function () { 364 | assert.deepEqual(h('', {data123: true}), { 365 | type: 'element', 366 | tagName: 'div', 367 | properties: {data123: true}, 368 | children: [] 369 | }) 370 | } 371 | ) 372 | 373 | await t.test( 374 | 'should support data attribute names with uncommon characters', 375 | async function () { 376 | assert.deepEqual(h('', {'data-foo.bar': true}), { 377 | type: 'element', 378 | tagName: 'div', 379 | properties: {'dataFoo.bar': true}, 380 | children: [] 381 | }) 382 | } 383 | ) 384 | 385 | await t.test( 386 | 'should support data property names with uncommon characters', 387 | async function () { 388 | assert.deepEqual(h('', {'dataFoo.bar': true}), { 389 | type: 'element', 390 | tagName: 'div', 391 | properties: {'dataFoo.bar': true}, 392 | children: [] 393 | }) 394 | } 395 | ) 396 | 397 | await t.test('should keep invalid data attribute names', async function () { 398 | assert.deepEqual(h('', {'data-foo!bar': true}), { 399 | type: 'element', 400 | tagName: 'div', 401 | properties: {'data-foo!bar': true}, 402 | children: [] 403 | }) 404 | }) 405 | 406 | await t.test('should keep invalid data property names', async function () { 407 | assert.deepEqual(h('', {'dataFoo!bar': true}), { 408 | type: 'element', 409 | tagName: 'div', 410 | properties: {'dataFoo!bar': true}, 411 | children: [] 412 | }) 413 | }) 414 | }) 415 | 416 | test('property values (unknown)', async function (t) { 417 | await t.test('should support unknown `string` values', async function () { 418 | assert.deepEqual(h('', {foo: 'bar'}), { 419 | type: 'element', 420 | tagName: 'div', 421 | properties: {foo: 'bar'}, 422 | children: [] 423 | }) 424 | }) 425 | 426 | await t.test('should support unknown `number` values', async function () { 427 | assert.deepEqual(h('', {foo: 3}), { 428 | type: 'element', 429 | tagName: 'div', 430 | properties: {foo: 3}, 431 | children: [] 432 | }) 433 | }) 434 | 435 | await t.test('should support unknown `boolean` values', async function () { 436 | assert.deepEqual(h('', {foo: true}), { 437 | type: 'element', 438 | tagName: 'div', 439 | properties: {foo: true}, 440 | children: [] 441 | }) 442 | }) 443 | 444 | await t.test('should support unknown `Array` values', async function () { 445 | assert.deepEqual(h('', {list: ['bar', 'baz']}), { 446 | type: 'element', 447 | tagName: 'div', 448 | properties: {list: ['bar', 'baz']}, 449 | children: [] 450 | }) 451 | }) 452 | 453 | await t.test( 454 | 'should ignore properties with a value of `null`', 455 | async function () { 456 | assert.deepEqual(h('', {foo: null}), { 457 | type: 'element', 458 | tagName: 'div', 459 | properties: {}, 460 | children: [] 461 | }) 462 | } 463 | ) 464 | 465 | await t.test( 466 | 'should ignore properties with a value of `undefined`', 467 | async function () { 468 | assert.deepEqual(h('', {foo: undefined}), { 469 | type: 'element', 470 | tagName: 'div', 471 | properties: {}, 472 | children: [] 473 | }) 474 | } 475 | ) 476 | 477 | await t.test( 478 | 'should ignore properties with a value of `NaN`', 479 | async function () { 480 | assert.deepEqual(h('', {foo: Number.NaN}), { 481 | type: 'element', 482 | tagName: 'div', 483 | properties: {}, 484 | children: [] 485 | }) 486 | } 487 | ) 488 | }) 489 | 490 | test('boolean properties', async function (t) { 491 | await t.test('should cast valid known `boolean` values', async function () { 492 | assert.deepEqual(h('', {allowFullScreen: ''}), { 493 | type: 'element', 494 | tagName: 'div', 495 | properties: {allowFullScreen: true}, 496 | children: [] 497 | }) 498 | }) 499 | 500 | await t.test( 501 | 'should not cast invalid known `boolean` values', 502 | async function () { 503 | assert.deepEqual(h('', {allowFullScreen: 'yup'}), { 504 | type: 'element', 505 | tagName: 'div', 506 | properties: {allowFullScreen: 'yup'}, 507 | children: [] 508 | }) 509 | } 510 | ) 511 | 512 | await t.test( 513 | 'should not cast unknown boolean-like values', 514 | async function () { 515 | assert.deepEqual(h('img', {title: 'title'}), { 516 | type: 'element', 517 | tagName: 'img', 518 | properties: {title: 'title'}, 519 | children: [] 520 | }) 521 | } 522 | ) 523 | }) 524 | 525 | test('overloaded boolean properties', async function (t) { 526 | await t.test( 527 | 'should cast known empty overloaded `boolean` values', 528 | async function () { 529 | assert.deepEqual(h('', {download: ''}), { 530 | type: 'element', 531 | tagName: 'div', 532 | properties: {download: true}, 533 | children: [] 534 | }) 535 | } 536 | ) 537 | 538 | await t.test( 539 | 'should cast known named overloaded `boolean` values', 540 | async function () { 541 | assert.deepEqual(h('', {download: 'downLOAD'}), { 542 | type: 'element', 543 | tagName: 'div', 544 | properties: {download: true}, 545 | children: [] 546 | }) 547 | } 548 | ) 549 | 550 | await t.test( 551 | 'should not cast overloaded `boolean` values for different values', 552 | async function () { 553 | assert.deepEqual(h('', {download: 'example.ogg'}), { 554 | type: 'element', 555 | tagName: 'div', 556 | properties: {download: 'example.ogg'}, 557 | children: [] 558 | }) 559 | } 560 | ) 561 | }) 562 | 563 | test('number properties', async function (t) { 564 | await t.test('should cast valid known `numeric` values', async function () { 565 | assert.deepEqual(h('textarea', {cols: '3'}), { 566 | type: 'element', 567 | tagName: 'textarea', 568 | properties: {cols: 3}, 569 | children: [] 570 | }) 571 | }) 572 | 573 | await t.test( 574 | 'should not cast invalid known `numeric` values', 575 | async function () { 576 | assert.deepEqual(h('textarea', {cols: 'one'}), { 577 | type: 'element', 578 | tagName: 'textarea', 579 | properties: {cols: 'one'}, 580 | children: [] 581 | }) 582 | } 583 | ) 584 | 585 | await t.test('should cast known `numeric` values', async function () { 586 | assert.deepEqual(h('meter', {low: '40', high: '90'}), { 587 | type: 'element', 588 | tagName: 'meter', 589 | properties: {low: 40, high: 90}, 590 | children: [] 591 | }) 592 | }) 593 | }) 594 | 595 | test('list properties', async function (t) { 596 | await t.test( 597 | 'should cast know space-separated `array` values', 598 | async function () { 599 | assert.deepEqual(h('', {class: 'foo bar baz'}), { 600 | type: 'element', 601 | tagName: 'div', 602 | properties: {className: ['foo', 'bar', 'baz']}, 603 | children: [] 604 | }) 605 | } 606 | ) 607 | 608 | await t.test( 609 | 'should cast know comma-separated `array` values', 610 | async function () { 611 | assert.deepEqual(h('input', {type: 'file', accept: 'video/*, image/*'}), { 612 | type: 'element', 613 | tagName: 'input', 614 | properties: {type: 'file', accept: ['video/*', 'image/*']}, 615 | children: [] 616 | }) 617 | } 618 | ) 619 | 620 | await t.test( 621 | 'should cast a list of known `numeric` values', 622 | async function () { 623 | assert.deepEqual(h('a', {coords: ['0', '0', '82', '126']}), { 624 | type: 'element', 625 | tagName: 'a', 626 | properties: {coords: [0, 0, 82, 126]}, 627 | children: [] 628 | }) 629 | } 630 | ) 631 | }) 632 | 633 | test('style property', async function (t) { 634 | await t.test('should support `style` as an object', async function () { 635 | assert.deepEqual( 636 | h('', {style: {color: 'red', '-webkit-border-radius': '3px'}}), 637 | { 638 | type: 'element', 639 | tagName: 'div', 640 | properties: {style: 'color: red; -webkit-border-radius: 3px'}, 641 | children: [] 642 | } 643 | ) 644 | }) 645 | 646 | await t.test('should support `style` as a string', async function () { 647 | assert.deepEqual( 648 | h('', {style: 'color:/*red*/purple; -webkit-border-radius: 3px'}), 649 | { 650 | type: 'element', 651 | tagName: 'div', 652 | properties: {style: 'color:/*red*/purple; -webkit-border-radius: 3px'}, 653 | children: [] 654 | } 655 | ) 656 | }) 657 | }) 658 | 659 | test('children', async function (t) { 660 | await t.test('should ignore no children', async function () { 661 | assert.deepEqual(h('div', {}, []), { 662 | type: 'element', 663 | tagName: 'div', 664 | properties: {}, 665 | children: [] 666 | }) 667 | }) 668 | 669 | await t.test('should support `string` for a `Text`', async function () { 670 | assert.deepEqual(h('div', {}, 'foo'), { 671 | type: 'element', 672 | tagName: 'div', 673 | properties: {}, 674 | children: [{type: 'text', value: 'foo'}] 675 | }) 676 | }) 677 | 678 | await t.test('should support a node', async function () { 679 | assert.deepEqual(h('div', {}, {type: 'text', value: 'foo'}), { 680 | type: 'element', 681 | tagName: 'div', 682 | properties: {}, 683 | children: [{type: 'text', value: 'foo'}] 684 | }) 685 | }) 686 | 687 | await t.test('should support a node created by `h`', async function () { 688 | assert.deepEqual(h('div', {}, h('span', {}, 'foo')), { 689 | type: 'element', 690 | tagName: 'div', 691 | properties: {}, 692 | children: [ 693 | { 694 | type: 'element', 695 | tagName: 'span', 696 | properties: {}, 697 | children: [{type: 'text', value: 'foo'}] 698 | } 699 | ] 700 | }) 701 | }) 702 | 703 | await t.test('should support nodes', async function () { 704 | assert.deepEqual( 705 | h('div', {}, [ 706 | {type: 'text', value: 'foo'}, 707 | {type: 'text', value: 'bar'} 708 | ]), 709 | { 710 | type: 'element', 711 | tagName: 'div', 712 | properties: {}, 713 | children: [ 714 | {type: 'text', value: 'foo'}, 715 | {type: 'text', value: 'bar'} 716 | ] 717 | } 718 | ) 719 | }) 720 | 721 | await t.test('should support nodes created by `h`', async function () { 722 | assert.deepEqual( 723 | h('div', {}, [h('span', {}, 'foo'), h('strong', {}, 'bar')]), 724 | { 725 | type: 'element', 726 | tagName: 'div', 727 | properties: {}, 728 | children: [ 729 | { 730 | type: 'element', 731 | tagName: 'span', 732 | properties: {}, 733 | children: [{type: 'text', value: 'foo'}] 734 | }, 735 | { 736 | type: 'element', 737 | tagName: 'strong', 738 | properties: {}, 739 | children: [{type: 'text', value: 'bar'}] 740 | } 741 | ] 742 | } 743 | ) 744 | }) 745 | 746 | await t.test( 747 | 'should support `Array` for a `Text`s', 748 | async function () { 749 | assert.deepEqual(h('div', {}, ['foo', 'bar']), { 750 | type: 'element', 751 | tagName: 'div', 752 | properties: {}, 753 | children: [ 754 | {type: 'text', value: 'foo'}, 755 | {type: 'text', value: 'bar'} 756 | ] 757 | }) 758 | } 759 | ) 760 | 761 | await t.test('should disambiguate non-object as a child', async function () { 762 | assert.deepEqual(h('x', 'y'), { 763 | type: 'element', 764 | tagName: 'x', 765 | properties: {}, 766 | children: [{type: 'text', value: 'y'}] 767 | }) 768 | }) 769 | 770 | await t.test('should disambiguate `array` as a child', async function () { 771 | assert.deepEqual(h('x', ['y']), { 772 | type: 'element', 773 | tagName: 'x', 774 | properties: {}, 775 | children: [{type: 'text', value: 'y'}] 776 | }) 777 | }) 778 | 779 | await t.test( 780 | 'should not disambiguate an object w/o `type` as a child', 781 | async function () { 782 | assert.deepEqual( 783 | // @ts-expect-error: incorrect properties. 784 | h('x', { 785 | a: 'y', 786 | b: 1, 787 | c: true, 788 | d: ['z'], 789 | e: {f: true} 790 | }), 791 | { 792 | type: 'element', 793 | tagName: 'x', 794 | properties: { 795 | a: 'y', 796 | b: 1, 797 | c: true, 798 | d: ['z'], 799 | e: '[object Object]' 800 | }, 801 | children: [] 802 | } 803 | ) 804 | } 805 | ) 806 | 807 | await t.test( 808 | 'should disambiguate an object w/ a `type` and an array of non-primitives as a child', 809 | async function () { 810 | assert.deepEqual( 811 | // @ts-expect-error: unknown node. 812 | h('x', {type: 'y', key: [{value: 1}]}), 813 | { 814 | type: 'element', 815 | tagName: 'x', 816 | properties: {}, 817 | children: [{type: 'y', key: [{value: 1}]}] 818 | } 819 | ) 820 | } 821 | ) 822 | 823 | await t.test( 824 | 'should not disambiguate an object w/ a `type` and an array of primitives as a child', 825 | async function () { 826 | assert.deepEqual(h('x', {type: 'y', key: [1]}), { 827 | type: 'element', 828 | tagName: 'x', 829 | properties: {type: 'y', key: [1]}, 830 | children: [] 831 | }) 832 | } 833 | ) 834 | 835 | await t.test( 836 | 'should disambiguate an object w/ a `type` and an `object` as a child', 837 | async function () { 838 | assert.deepEqual(h('x', {type: 'y', data: {bar: 'baz'}}), { 839 | type: 'element', 840 | tagName: 'x', 841 | properties: {}, 842 | children: [{type: 'y', data: {bar: 'baz'}}] 843 | }) 844 | } 845 | ) 846 | 847 | await t.test( 848 | 'should disambiguate an object w/ a `type` and an empty `children` array is a child', 849 | async function () { 850 | assert.deepEqual(h('x', {type: 'y', children: []}), { 851 | type: 'element', 852 | tagName: 'x', 853 | properties: {}, 854 | children: [{type: 'y', children: []}] 855 | }) 856 | } 857 | ) 858 | 859 | await t.test( 860 | 'should allow passing multiple child nodes as arguments', 861 | async function () { 862 | assert.deepEqual( 863 | h('section', {id: 'test'}, h('p', 'first'), h('p', 'second')), 864 | { 865 | type: 'element', 866 | tagName: 'section', 867 | properties: {id: 'test'}, 868 | children: [ 869 | { 870 | type: 'element', 871 | tagName: 'p', 872 | properties: {}, 873 | children: [{type: 'text', value: 'first'}] 874 | }, 875 | { 876 | type: 'element', 877 | tagName: 'p', 878 | properties: {}, 879 | children: [{type: 'text', value: 'second'}] 880 | } 881 | ] 882 | } 883 | ) 884 | } 885 | ) 886 | 887 | await t.test( 888 | 'should allow passing multiple child nodes as arguments when there is no properties argument present', 889 | async function () { 890 | assert.deepEqual(h('section', h('p', 'first'), h('p', 'second')), { 891 | type: 'element', 892 | tagName: 'section', 893 | properties: {}, 894 | children: [ 895 | { 896 | type: 'element', 897 | tagName: 'p', 898 | properties: {}, 899 | children: [{type: 'text', value: 'first'}] 900 | }, 901 | { 902 | type: 'element', 903 | tagName: 'p', 904 | properties: {}, 905 | children: [{type: 'text', value: 'second'}] 906 | } 907 | ] 908 | }) 909 | } 910 | ) 911 | 912 | await t.test('should throw when given an invalid value', async function () { 913 | assert.throws(function () { 914 | // @ts-expect-error: check how the runtime handles a boolean instead of a child. 915 | h('foo', {}, true) 916 | }, /Expected node, nodes, or string, got `true`/) 917 | }) 918 | }) 919 | 920 | test('