diff --git a/.gitignore b/.gitignore index 3d8732d..e5e3859 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,6 @@ node_modules/ package-lock.json yarn.lock /dist/ -jsx-*runtime.* +/jsx-*runtime.* docs/ .vscode/settings.json \ No newline at end of file diff --git a/package.json b/package.json index 06743c6..dfd0fdd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dom-renderer", - "version": "2.3.1", + "version": "2.4.0", "license": "LGPL-3.0-or-later", "author": "shiy2008@gmail.com", "description": "A light-weight DOM Renderer supports Web components standard & TypeScript language", @@ -25,9 +25,9 @@ "main": "dist/index.js", "dependencies": { "declarative-shadow-dom-polyfill": "^0.4.0", - "tslib": "^2.8.0", + "tslib": "^2.8.1", "web-streams-polyfill": "^4.0.0", - "web-utility": "^4.4.1" + "web-utility": "^4.4.2" }, "peerDependencies": { "happy-dom": "^14" @@ -35,7 +35,7 @@ "devDependencies": { "@happy-dom/jest-environment": "^14.12.3", "@types/jest": "^29.5.14", - "@types/node": "^20.17.1", + "@types/node": "^20.17.6", "happy-dom": "^14.12.3", "husky": "^9.1.6", "jest": "^29.7.0", @@ -43,22 +43,31 @@ "open-cli": "^8.0.0", "prettier": "^3.3.3", "ts-jest": "^29.2.5", - "typedoc": "^0.26.10", - "typedoc-plugin-mdn-links": "^3.3.4", + "typedoc": "^0.26.11", + "typedoc-plugin-mdn-links": "^3.3.6", "typescript": "~5.6.3" }, "prettier": { "singleQuote": true, "trailingComma": "none", "arrowParens": "avoid", - "tabWidth": 4 + "tabWidth": 4, + "printWidth": 100 }, "lint-staged": { "*.{md,json,yml,ts}": "prettier --write" }, "jest": { "preset": "ts-jest", - "testEnvironment": "@happy-dom/jest-environment" + "testEnvironment": "@happy-dom/jest-environment", + "transform": { + "\\.tsx?$": [ + "ts-jest", + { + "tsconfig": "test/tsconfig.json" + } + ] + } }, "browserslist": "> 0.5%, last 2 versions, not dead, IE 11", "scripts": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f90117c..0581aea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,14 +12,14 @@ importers: specifier: ^0.4.0 version: 0.4.0(typescript@5.6.3) tslib: - specifier: ^2.8.0 - version: 2.8.0 + specifier: ^2.8.1 + version: 2.8.1 web-streams-polyfill: specifier: ^4.0.0 version: 4.0.0 web-utility: - specifier: ^4.4.1 - version: 4.4.1(typescript@5.6.3) + specifier: ^4.4.2 + version: 4.4.2(typescript@5.6.3) devDependencies: '@happy-dom/jest-environment': specifier: ^14.12.3 @@ -28,8 +28,8 @@ importers: specifier: ^29.5.14 version: 29.5.14 '@types/node': - specifier: ^20.17.1 - version: 20.17.1 + specifier: ^20.17.6 + version: 20.17.6 happy-dom: specifier: ^14.12.3 version: 14.12.3 @@ -38,7 +38,7 @@ importers: version: 9.1.6 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@20.17.1) + version: 29.7.0(@types/node@20.17.6) lint-staged: specifier: ^15.2.10 version: 15.2.10 @@ -50,13 +50,13 @@ importers: version: 3.3.3 ts-jest: specifier: ^29.2.5 - version: 29.2.5(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(jest@29.7.0(@types/node@20.17.1))(typescript@5.6.3) + version: 29.2.5(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(jest@29.7.0(@types/node@20.17.6))(typescript@5.6.3) typedoc: - specifier: ^0.26.10 - version: 0.26.10(typescript@5.6.3) + specifier: ^0.26.11 + version: 0.26.11(typescript@5.6.3) typedoc-plugin-mdn-links: - specifier: ^3.3.4 - version: 3.3.4(typedoc@0.26.10(typescript@5.6.3)) + specifier: ^3.3.6 + version: 3.3.6(typedoc@0.26.11(typescript@5.6.3)) typescript: specifier: ~5.6.3 version: 5.6.3 @@ -387,8 +387,8 @@ packages: '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} - '@types/node@20.17.1': - resolution: {integrity: sha512-j2VlPv1NnwPJbaCNv69FO/1z4lId0QmGvpT41YxitRtWlg96g/j8qcv2RKsLKe2F6OJgyXhupN1Xo17b2m139Q==} + '@types/node@20.17.6': + resolution: {integrity: sha512-VEI7OdvK2wP7XHnsuXbAJnEpEkF6NjSN45QJlL4VGqZSXsnicpesdTWsg9RISeSdYd3yeRj/y3k5KGjUXYnFwQ==} '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -1547,8 +1547,8 @@ packages: esbuild: optional: true - tslib@2.8.0: - resolution: {integrity: sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} type-detect@4.0.8: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} @@ -1566,13 +1566,13 @@ packages: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} engines: {node: '>=12.20'} - typedoc-plugin-mdn-links@3.3.4: - resolution: {integrity: sha512-jF/QpTT43rDeSG6Sh0d5HUsoxz6RlfGNKrP/9KJjTLkQwxbbfEc6cUh6KtbPRVbxAdjIXbgCg+pdn01e27isrg==} + typedoc-plugin-mdn-links@3.3.6: + resolution: {integrity: sha512-iz/+UBDEDqtymjgO6AQA7P4A/kiGmHVKSStGFz7rueZClfbpYaJB5T+xePMF4i0N7PxWMPrWzQd1+B6pn40w1w==} peerDependencies: typedoc: '>= 0.23.14 || 0.24.x || 0.25.x || 0.26.x' - typedoc@0.26.10: - resolution: {integrity: sha512-xLmVKJ8S21t+JeuQLNueebEuTVphx6IrP06CdV7+0WVflUSW3SPmR+h1fnWVdAR/FQePEgsSWCUHXqKKjzuUAw==} + typedoc@0.26.11: + resolution: {integrity: sha512-sFEgRRtrcDl2FxVP58Ze++ZK2UQAEvtvvH8rRlig1Ja3o7dDaMHmaBfvJmdGnNEFaLTpQsN8dpvZaTqJSu/Ugw==} engines: {node: '>= 18'} hasBin: true peerDependencies: @@ -1634,8 +1634,8 @@ packages: resolution: {integrity: sha512-0zJXHRAYEjM2tUfZ2DiSOHAa2aw1tisnnhU3ufD57R8iefL+DcdJyRBRyJpG+NUimDgbTI/lH+gAE1PAvV3Cgw==} engines: {node: '>= 8'} - web-utility@4.4.1: - resolution: {integrity: sha512-MGmMMjSdsKWHDBpiEHa4Rve6gzFz/aB7RHYyJEKk7cwh+M5/dEjIG2kj/299PXLvHOAMxoywTBhY/d0k2lTgUA==} + web-utility@4.4.2: + resolution: {integrity: sha512-mpUh9jQ4SGSRKehfQKsvMMItzeRb5SPvYbbyksvWy5HPVsKh8VOS6ijVD0PiwaFGuv729Dg+oXzWyiibSZVCBw==} peerDependencies: typescript: '>=4.1' @@ -1915,7 +1915,7 @@ snapshots: '@jest/console@29.7.0': dependencies: '@jest/types': 29.6.3 - '@types/node': 20.17.1 + '@types/node': 20.17.6 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -1928,14 +1928,14 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.17.1 + '@types/node': 20.17.6 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.17.1) + jest-config: 29.7.0(@types/node@20.17.6) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -1960,7 +1960,7 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.17.1 + '@types/node': 20.17.6 jest-mock: 29.7.0 '@jest/expect-utils@29.7.0': @@ -1978,7 +1978,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 20.17.1 + '@types/node': 20.17.6 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -2000,7 +2000,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.25 - '@types/node': 20.17.1 + '@types/node': 20.17.6 chalk: 4.1.2 collect-v8-coverage: 1.0.2 exit: 0.1.2 @@ -2070,7 +2070,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 20.17.1 + '@types/node': 20.17.6 '@types/yargs': 17.0.33 chalk: 4.1.2 @@ -2130,7 +2130,7 @@ snapshots: '@swc/helpers@0.5.13': dependencies: - tslib: 2.8.0 + tslib: 2.8.1 '@tokenizer/token@0.3.0': {} @@ -2157,7 +2157,7 @@ snapshots: '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 20.17.1 + '@types/node': 20.17.6 '@types/hast@3.0.4': dependencies: @@ -2182,7 +2182,7 @@ snapshots: dependencies: '@types/unist': 3.0.3 - '@types/node@20.17.1': + '@types/node@20.17.6': dependencies: undici-types: 6.19.8 @@ -2384,13 +2384,13 @@ snapshots: convert-source-map@2.0.0: {} - create-jest@29.7.0(@types/node@20.17.1): + create-jest@29.7.0(@types/node@20.17.6): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.17.1) + jest-config: 29.7.0(@types/node@20.17.6) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -2713,7 +2713,7 @@ snapshots: '@jest/expect': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.17.1 + '@types/node': 20.17.6 chalk: 4.1.2 co: 4.6.0 dedent: 1.5.3 @@ -2733,16 +2733,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@20.17.1): + jest-cli@29.7.0(@types/node@20.17.6): dependencies: '@jest/core': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.17.1) + create-jest: 29.7.0(@types/node@20.17.6) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@20.17.1) + jest-config: 29.7.0(@types/node@20.17.6) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -2752,7 +2752,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@20.17.1): + jest-config@29.7.0(@types/node@20.17.6): dependencies: '@babel/core': 7.26.0 '@jest/test-sequencer': 29.7.0 @@ -2777,7 +2777,7 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 20.17.1 + '@types/node': 20.17.6 transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -2806,7 +2806,7 @@ snapshots: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.17.1 + '@types/node': 20.17.6 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -2816,7 +2816,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 20.17.1 + '@types/node': 20.17.6 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -2855,7 +2855,7 @@ snapshots: jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 20.17.1 + '@types/node': 20.17.6 jest-util: 29.7.0 jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): @@ -2890,7 +2890,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.17.1 + '@types/node': 20.17.6 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -2918,7 +2918,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.17.1 + '@types/node': 20.17.6 chalk: 4.1.2 cjs-module-lexer: 1.4.1 collect-v8-coverage: 1.0.2 @@ -2964,7 +2964,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 20.17.1 + '@types/node': 20.17.6 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -2983,7 +2983,7 @@ snapshots: dependencies: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.17.1 + '@types/node': 20.17.6 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -2992,17 +2992,17 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 20.17.1 + '@types/node': 20.17.6 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@20.17.1): + jest@29.7.0(@types/node@20.17.6): dependencies: '@jest/core': 29.7.0 '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@20.17.1) + jest-cli: 29.7.0(@types/node@20.17.6) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -3454,12 +3454,12 @@ snapshots: trim-lines@3.0.1: {} - ts-jest@29.2.5(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(jest@29.7.0(@types/node@20.17.1))(typescript@5.6.3): + ts-jest@29.2.5(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(jest@29.7.0(@types/node@20.17.6))(typescript@5.6.3): dependencies: bs-logger: 0.2.6 ejs: 3.1.10 fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@20.17.1) + jest: 29.7.0(@types/node@20.17.6) jest-util: 29.7.0 json5: 2.2.3 lodash.memoize: 4.1.2 @@ -3473,7 +3473,7 @@ snapshots: '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.26.0) - tslib@2.8.0: {} + tslib@2.8.1: {} type-detect@4.0.8: {} @@ -3483,11 +3483,11 @@ snapshots: type-fest@2.19.0: {} - typedoc-plugin-mdn-links@3.3.4(typedoc@0.26.10(typescript@5.6.3)): + typedoc-plugin-mdn-links@3.3.6(typedoc@0.26.11(typescript@5.6.3)): dependencies: - typedoc: 0.26.10(typescript@5.6.3) + typedoc: 0.26.11(typescript@5.6.3) - typedoc@0.26.10(typescript@5.6.3): + typedoc@0.26.11(typescript@5.6.3): dependencies: lunr: 2.3.9 markdown-it: 14.1.0 @@ -3559,7 +3559,7 @@ snapshots: web-streams-polyfill@4.0.0: {} - web-utility@4.4.1(typescript@5.6.3): + web-utility@4.4.2(typescript@5.6.3): dependencies: '@swc/helpers': 0.5.13 element-internals-polyfill: 1.3.12 diff --git a/source/dist/DOMRenderer.ts b/source/dist/DOMRenderer.ts index bde83be..6e0a32e 100644 --- a/source/dist/DOMRenderer.ts +++ b/source/dist/DOMRenderer.ts @@ -24,9 +24,7 @@ export class DOMRenderer { key?.toString() || props?.id || (text || selector || '') + index; protected vNodeOf = (list: VNode[], key?: VNode['key']) => - list.find( - (vNode, index) => `${this.keyOf(vNode, index)}` === String(key) - ); + list.find((vNode, index) => `${this.keyOf(vNode, index)}` === String(key)); protected propsKeyOf = (key: string) => key.startsWith('aria-') @@ -42,36 +40,25 @@ export class DOMRenderer { onDelete?: (node: N, key: string) => any, onAdd?: (node: N, key: string, value: any) => any ) { - const { group } = diffKeys( - Object.keys(oldProps), - Object.keys(newProps) - ); + const { group } = diffKeys(Object.keys(oldProps), Object.keys(newProps)); for (const [key] of group[DiffStatus.Old] || []) onDelete?.(node, key); - for (const [key] of [ - ...(group[DiffStatus.Same] || []), - ...(group[DiffStatus.New] || []) - ]) + for (const [key] of [...(group[DiffStatus.Same] || []), ...(group[DiffStatus.New] || [])]) if (oldProps[key] !== newProps[key]) if (onAdd instanceof Function) onAdd(node, key, newProps[key]); else Reflect.set(node, key, newProps[key]); } protected createNode(vNode: VNode, reusedVNodes?: Record) { - if (vNode.text) - return (vNode.node = this.document.createTextNode(vNode.text)); + if (vNode.text) return vNode.createDOM(this.document); - const reusedVNode = - vNode.selector && reusedVNodes?.[vNode.selector]?.shift(); + const reusedVNode = vNode.selector && reusedVNodes?.[vNode.selector]?.shift(); - vNode.node = vNode.tagName - ? reusedVNode?.node || - this.document.createElement(vNode.tagName, { is: vNode.is }) - : this.document.createDocumentFragment(); + vNode.node = reusedVNode?.node || vNode.createDOM(this.document); const { node } = this.patch( - reusedVNode || { tagName: vNode.tagName, node: vNode.node }, + reusedVNode || new VNode({ tagName: vNode.tagName, node: vNode.node }), vNode ); if (node) vNode.ref?.(node); @@ -80,8 +67,7 @@ export class DOMRenderer { } protected deleteNode({ ref, node, children }: VNode) { - if (node instanceof DocumentFragment) - children?.forEach(this.deleteNode); + if (node instanceof DocumentFragment) children?.forEach(this.deleteNode); else if (node) { (node as ChildNode).remove(); @@ -110,28 +96,18 @@ export class DOMRenderer { if (newNodes[0]) root.append(...newNodes); } - protected updateChildren( - node: ParentNode, - oldList: VNode[], - newList: VNode[] - ) { - const { map, group } = diffKeys( - oldList.map(this.keyOf), - newList.map(this.keyOf) - ); + protected updateChildren(node: ParentNode, oldList: VNode[], newList: VNode[]) { + const { map, group } = diffKeys(oldList.map(this.keyOf), newList.map(this.keyOf)); const deletingGroup = group[DiffStatus.Old] && groupBy( - group[DiffStatus.Old].map(([key]) => - this.vNodeOf(oldList, key) - ), + group[DiffStatus.Old].map(([key]) => this.vNodeOf(oldList, key)), ({ selector }) => selector + '' ); const newNodes = newList.map((vNode, index) => { const key = this.keyOf(vNode, index); - if (map[key] !== DiffStatus.Same) - return this.createNode(vNode, deletingGroup); + if (map[key] !== DiffStatus.Same) return this.createNode(vNode, deletingGroup); const oldVNode = this.vNodeOf(oldList, key)!; @@ -163,13 +139,10 @@ export class DOMRenderer { this.eventPattern.test(key) ? (node[key.toLowerCase()] = null) : node.removeAttribute( - this.ariaPattern.test(key) - ? toHyphenCase(key) - : VNode.propsMap[key] || key + this.ariaPattern.test(key) ? toHyphenCase(key) : VNode.propsMap[key] || key ); protected setProperty = (node: Element, key: string, value: string) => { - const isXML = - templateOf(node.tagName) && elementTypeOf(node.tagName) === 'xml'; + const isXML = templateOf(node.tagName) && elementTypeOf(node.tagName) === 'xml'; if (isXML || key.includes('-')) node.setAttribute(key, value); else diff --git a/source/dist/VDOM.ts b/source/dist/VDOM.ts index 2fe58b7..b1b1f0c 100644 --- a/source/dist/VDOM.ts +++ b/source/dist/VDOM.ts @@ -1,4 +1,4 @@ -import { HTMLProps, IndexKey, isEmpty } from 'web-utility'; +import { HTMLProps, IndexKey, isEmpty, MathMLProps, SVGProps, XMLNamespace } from 'web-utility'; export type DataObject = Record; @@ -6,28 +6,30 @@ export type VNodeStyle = HTMLProps['style'] & { [K in `--${string}`]?: string; }; -export class VNode { +export class VNodeMeta { key?: IndexKey; ref?: (node?: Node) => any; text?: string; selector?: string; + namespace?: string; tagName?: string; is?: string; props?: DataObject; style?: VNodeStyle; - children?: VNode[]; + parent?: VNode; + children?: VNode[] = []; node?: Node; +} - constructor({ children, ...meta }: VNode) { +export class VNode extends VNodeMeta { + constructor({ children, ...meta }: VNodeMeta) { + super(); Object.assign(this, meta); for (const vNode of children || []) - if (VNode.isFragment(vNode)) - this.children = [ - ...(this.children || []), - ...(vNode.children || []) - ]; - else this.children = [...(this.children || []), vNode]; + this.children.push(...(VNode.isFragment(vNode) ? vNode.children || [] : [vNode])); + + for (const child of this.children) child.parent = this; const { tagName, is, props } = meta; @@ -35,25 +37,53 @@ export class VNode { this.selector = [ tagName?.toLowerCase(), - props?.className && - `.${props.className.trim().replace(/\s+/, '.')}`, + props?.className && `.${props.className.trim().replace(/\s+/, '.')}`, is && `[is="${is}"]` ] .filter(Boolean) .join(''); } - static propsMap: Partial< - Record, string> - > = { + *walkUp() { + var current: VNode = this; + + while ((current = current.parent)) yield current; + } + + findNamespace() { + for (const { namespace } of this.walkUp()) if (namespace) return namespace; + } + + createDOM(document = globalThis.document) { + const { tagName, is, text } = this; + + return (this.node = text + ? document.createTextNode(text) + : !tagName + ? document.createDocumentFragment() + : document.createElementNS( + (this.namespace ||= XMLNamespace[tagName] || this.findNamespace()), + tagName, + { is } + )); + } + + toJSON(): VNodeMeta { + const { key, text, selector, namespace, tagName, is, props, style, children } = this; + + return JSON.parse( + JSON.stringify({ key, text, selector, namespace, tagName, is, props, style, children }) + ); + } + + static propsMap: Partial, string>> = { className: 'class', htmlFor: 'for' }; - static attrsMap: Record> = - Object.fromEntries( - Object.entries(this.propsMap).map(item => item.reverse()) - ); + static attrsMap: Record> = Object.fromEntries( + Object.entries(this.propsMap).map(item => item.reverse()) + ); static isFragment({ key, node, children, ...rest }: VNode) { for (const key in rest) if (!isEmpty(rest[key])) return false; @@ -61,21 +91,20 @@ export class VNode { } static fromDOM(node: Node) { - if (node instanceof Text) - return new VNode({ node, text: node.nodeValue }); + if (node instanceof Text) return new VNode({ node, text: node.nodeValue }); if (!(node instanceof Element)) return new VNode({ node }); - const { tagName, attributes, style, childNodes } = node as HTMLElement; - const vNode: VNode = { + const { namespaceURI, tagName, attributes, style, childNodes } = node as HTMLElement; + const vNode: VNodeMeta = { node, + namespace: namespaceURI, tagName: tagName.toLowerCase(), is: node.getAttribute('is') }; const props = Array.from( attributes, - ({ name, value }) => - name !== 'style' && [this.attrsMap[name] || name, value] + ({ name, value }) => name !== 'style' && [this.attrsMap[name] || name, value] ).filter(Boolean); if (props[0]) vNode.props = Object.fromEntries(props); @@ -100,6 +129,16 @@ export type JsxProps = DataObject & Omit, 'children'> & { children?: JsxChildren; }; +export type SvgJsxProps = DataObject & + Pick & + Omit, 'children'> & { + children?: JsxChildren; + }; +export type MathMlJsxProps = DataObject & + Pick & + Omit, 'children'> & { + children?: JsxChildren; + }; declare global { /** @@ -109,8 +148,12 @@ declare global { type Element = VNode; type JSXElements = { - [tagName in keyof HTMLElementTagNameMap]: JsxProps< - HTMLElementTagNameMap[tagName] + [tagName in keyof HTMLElementTagNameMap]: JsxProps; + } & { + [tagName in keyof SVGElementTagNameMap]: SvgJsxProps; + } & { + [tagName in keyof MathMLElementTagNameMap]: MathMlJsxProps< + MathMLElementTagNameMap[tagName] >; }; interface IntrinsicElements extends JSXElements {} diff --git a/source/jsx-runtime.ts b/source/jsx-runtime.ts index e43e84d..24a09fa 100644 --- a/source/jsx-runtime.ts +++ b/source/jsx-runtime.ts @@ -1,6 +1,6 @@ import { IndexKey, isEmpty, isHTMLElementClass, tagNameOf } from 'web-utility'; -import { DataObject, VNode } from './dist/VDOM'; +import { DataObject, VNode, VNodeMeta } from './dist/VDOM'; /** * @see {@link https://github.com/reactjs/rfcs/blob/createlement-rfc/text/0000-create-element-changes.md} @@ -25,7 +25,7 @@ export function jsx( ) .filter(Boolean); - const commonProps: VNode = { key, ref, is, style, children }; + const commonProps: VNodeMeta = { key, ref, is, style, children }; return typeof type === 'string' ? new VNode({ ...commonProps, tagName: type, props }) diff --git a/test/DOMRenderer.spec.ts b/test/DOMRenderer.spec.ts index 1f9ea89..d128749 100644 --- a/test/DOMRenderer.spec.ts +++ b/test/DOMRenderer.spec.ts @@ -10,11 +10,11 @@ describe('DOM Renderer', () => { it('should update DOM properties', () => { const newVNode = renderer.patch( - { ...root }, - { + new VNode({ ...root }), + new VNode({ ...root, props: { className: 'container' } - } + }) ); expect(document.body.className).toBe('container'); @@ -25,11 +25,11 @@ describe('DOM Renderer', () => { it('should update DOM styles', () => { const newVNode = renderer.patch( - { ...root }, - { + new VNode({ ...root }), + new VNode({ ...root, style: { margin: '0', '--color': 'red' } - } + }) ); expect(document.body.style.margin).toBe('0px'); expect(document.body.style.getPropertyValue('--color')).toBe('red'); @@ -42,8 +42,8 @@ describe('DOM Renderer', () => { it('should update DOM children', () => { const newNode = renderer.patch( - { ...root }, - { + new VNode({ ...root }), + new VNode({ ...root, children: [ new VNode({ @@ -53,7 +53,7 @@ describe('DOM Renderer', () => { children: [new VNode({ text: 'idea2app' })] }) ] - } + }) ); expect(document.body.innerHTML).toBe( 'idea2app' @@ -65,15 +65,21 @@ describe('DOM Renderer', () => { it('should update DOM children without keys', () => { var newNode = renderer.patch( - { ...root }, - { ...root, children: [{ children: [new VNode({ tagName: 'i' })] }] } + new VNode({ ...root }), + new VNode({ + ...root, + children: [new VNode({ children: [new VNode({ tagName: 'i' })] })] + }) ); expect(document.body.innerHTML).toBe(''); - newNode = renderer.patch(newNode, { - ...root, - children: [{ children: [new VNode({ tagName: 'a' })] }] - }); + newNode = renderer.patch( + newNode, + new VNode({ + ...root, + children: [new VNode({ children: [new VNode({ tagName: 'a' })] })] + }) + ); expect(document.body.innerHTML).toBe(''); renderer.patch(newNode, root); @@ -90,30 +96,28 @@ describe('DOM Renderer', () => { customElements.define('x-test', Test); const newNode = renderer.patch( - { ...root }, - { + new VNode({ ...root }), + new VNode({ ...root, children: [new VNode({ tagName: 'x-test' })] - } + }) ); expect(document.body.innerHTML).toBe(''); - renderer.patch(newNode, { - ...root, - children: [ - new VNode({ text: 'y' }), - new VNode({ tagName: 'x-test' }) - ] - }); + renderer.patch( + newNode, + new VNode({ + ...root, + children: [new VNode({ text: 'y' }), new VNode({ tagName: 'x-test' })] + }) + ); expect(document.body.innerHTML).toBe('y'); expect(connectHook).toHaveBeenCalledTimes(1); }); it('should render a Virtual DOM node to a Shadow Root', () => { - const shadowRoot = document - .createElement('div') - .attachShadow({ mode: 'open' }); + const shadowRoot = document.createElement('div').attachShadow({ mode: 'open' }); const shadowVDOM = renderer.patch( VNode.fromDOM(shadowRoot), @@ -133,18 +137,14 @@ describe('DOM Renderer', () => { customElements.define('shadow-root-tag', ShadowRootTag); it('should render the Shadow Root to HTML strings', () => { - const markup = renderer.renderToStaticMarkup( - new VNode({ tagName: 'shadow-root-tag' }) - ); + const markup = renderer.renderToStaticMarkup(new VNode({ tagName: 'shadow-root-tag' })); expect(markup).toBe( `` ); }); it('should render the Shadow Root to a Readable Stream', async () => { - const stream = renderer.renderToReadableStream( - new VNode({ tagName: 'shadow-root-tag' }) - ), + const stream = renderer.renderToReadableStream(new VNode({ tagName: 'shadow-root-tag' })), markups: string[] = []; for await (const markup of stream) markups.push(markup); diff --git a/test/VDOM.spec.ts b/test/VDOM.spec.ts index c742911..7ca9a11 100644 --- a/test/VDOM.spec.ts +++ b/test/VDOM.spec.ts @@ -15,12 +15,14 @@ describe('VDOM', () => { }); it('should detect a Fragment VNode', () => { - const result = VNode.isFragment({ - key: undefined, - tagName: undefined, - props: {}, - children: [] - }); + const result = VNode.isFragment( + new VNode({ + key: undefined, + tagName: undefined, + props: {}, + children: [] + }) + ); expect(result).toBe(true); }); @@ -47,19 +49,13 @@ describe('VDOM', () => { children: [oneLevelFragment, oneLevelFragment] }); const fragmentVNode = new VNode({ - children: [ - new VNode({ tagName: 'a' }), - oneLevelFragment, - twoLevelFragment - ] - }); - expect(fragmentVNode).toEqual({ - children: [ - { tagName: 'a', selector: 'a' }, - { tagName: 'b', selector: 'b' }, - { tagName: 'b', selector: 'b' }, - { tagName: 'b', selector: 'b' } - ] + children: [new VNode({ tagName: 'a' }), oneLevelFragment, twoLevelFragment] }); + expect(fragmentVNode.children?.map(child => child.toJSON())).toEqual([ + { tagName: 'a', selector: 'a', children: [] }, + { tagName: 'b', selector: 'b', children: [] }, + { tagName: 'b', selector: 'b', children: [] }, + { tagName: 'b', selector: 'b', children: [] } + ]); }); }); diff --git a/test/jsx-runtime.spec.ts b/test/jsx-runtime.spec.tsx similarity index 54% rename from test/jsx-runtime.spec.ts rename to test/jsx-runtime.spec.tsx index 8e47353..64a37ca 100644 --- a/test/jsx-runtime.spec.ts +++ b/test/jsx-runtime.spec.tsx @@ -1,18 +1,25 @@ import 'declarative-shadow-dom-polyfill'; import { DOMRenderer } from '../source/dist'; -import { jsx, Fragment } from '../source/jsx-runtime'; + +class MyTag extends HTMLElement {} + +customElements.define('my-tag', MyTag); + +declare global { + interface HTMLElementTagNameMap { + 'my-tag': MyTag; + } +} describe('JSX runtime', () => { const renderer = new DOMRenderer(); it('should render JSX to DOM', () => { renderer.render( - jsx('a', { - href: 'https://idea2.app/', - style: { color: 'red' }, - children: ['idea2app'] - }) + + idea2app + ); expect(document.body.innerHTML).toBe( 'idea2app' @@ -21,15 +28,11 @@ describe('JSX runtime', () => { it('should render JSX fragment to DOM', () => { renderer.render( - jsx(Fragment, { - children: [ - jsx('a', { - href: 'https://idea2.app/', - style: { color: 'blue' }, - children: ['idea2app'] - }) - ] - }) + <> + + idea2app + + ); expect(document.body.innerHTML).toBe( 'idea2app' @@ -37,11 +40,7 @@ describe('JSX runtime', () => { }); it('should render a Web components class', () => { - class MyTag extends HTMLElement {} - - customElements.define('my-tag', MyTag); - - renderer.render(jsx(MyTag, {})); + renderer.render(); expect(document.body.innerHTML).toBe(''); }); @@ -51,34 +50,43 @@ describe('JSX runtime', () => { customElements.define('my-div', MyDiv, { extends: 'div' }); - renderer.render(jsx('div', { is: 'my-div' })); + renderer.render(
); expect(document.body.innerHTML).toBe('
'); }); it('should ignore Empty values except 0', () => { renderer.render( - jsx(Fragment, { children: [0, false, null, undefined, NaN] }) + <> + {0} + {false} + {null} + {undefined} + {NaN} + ); expect(document.body.innerHTML).toBe('0'); }); it('should render Non-empty Primitive values', () => { - renderer.render(jsx(Fragment, { children: [1, true] })); - + renderer.render( + <> + {1} + {true} + + ); expect(document.body.innerHTML).toBe('1true'); }); it('should render DataSet & ARIA attributes', () => { - renderer.render( - jsx('div', { 'data-id': 'idea2app', 'aria-label': 'idea2app' }) - ); + renderer.render(
); + expect(document.body.innerHTML).toBe( '
' ); // To Do: https://github.com/jsdom/jsdom/issues/3323 - // renderer.render(jsx('div', { ariaLabel: 'fCC' })); + // renderer.render(
); // expect(document.body.innerHTML).toBe('
'); }); @@ -86,17 +94,17 @@ describe('JSX runtime', () => { it('should call Event handlers', () => { const onClick = jest.fn(); - renderer.render(jsx('i', { onClick })); + renderer.render(); document.querySelector('i')?.click(); - expect(onClick).toBeCalledTimes(1); + expect(onClick).toHaveBeenCalledTimes(1); }); it('should toggle a real DOM Node by callbacks', () => { const ref = jest.fn(); - renderer.render(jsx('b', { ref })); + renderer.render(); const { firstChild } = document.body; @@ -104,7 +112,7 @@ describe('JSX runtime', () => { expect(ref).toHaveBeenCalledWith(firstChild); - renderer.render(jsx('a', {})); + renderer.render(); expect(document.body.innerHTML).toBe(''); @@ -114,14 +122,13 @@ describe('JSX runtime', () => { it('should reuse similar DOM nodes', () => { const renderList = (offset = 0) => renderer.render( - jsx('ul', { - children: Array.from(new Array(2), (_, index) => { - const key = String.fromCodePoint( - 'a'.charCodeAt(0) + index + offset - ); - return jsx('li', { children: [key] }, key); - }) - }) +
    + {Array.from({ length: 2 }, (_, index) => { + const key = String.fromCodePoint('a'.charCodeAt(0) + index + offset); + + return
  • {key}
  • ; + })} +
); renderList(); @@ -133,35 +140,51 @@ describe('JSX runtime', () => { expect(document.body.innerHTML).toBe('
  • c
  • d
'); - expect([...document.body.firstElementChild!.children]).toEqual([ - ...children - ]); + expect([...document.body.firstElementChild!.children]).toEqual([...children]); }); it('should not share a real DOM with the same VDOM', () => { - const sameVDOM = jsx('a', {}); + const sameVDOM = ; renderer.render( - jsx(Fragment, { - children: [ - jsx('nav', { children: [sameVDOM] }), - jsx('nav', { children: [sameVDOM] }) - ] - }) - ); - expect(document.body.innerHTML).toBe( - '' + <> + + + ); + expect(document.body.innerHTML).toBe(''); }); it('should handle Nested children arrays', () => { renderer.render( - jsx(Fragment, { children: [jsx('nav', {}), [jsx('nav', {})]] }) + <> +