├── .editorconfig ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .husky ├── post-merge └── pre-commit ├── .prettierignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── package.json ├── pnpm-lock.yaml ├── sonar-project.properties ├── src ├── helpers.ts └── index.ts ├── test ├── __fixtures__ │ └── data.ts ├── __setup__ │ ├── global.d.ts │ └── vitest.setup.ts ├── __snapshots__ │ └── index.spec.tsx.snap ├── helpers.spec.ts ├── index.spec.tsx └── tsconfig.json ├── tsconfig.json └── vitest.config.mts /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | indent_style = space 11 | indent_size = 2 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ['main'] 6 | tags: ['v*'] 7 | pull_request: 8 | branches: ['*'] 9 | 10 | workflow_dispatch: 11 | 12 | concurrency: 13 | group: ${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | main: 18 | name: Validate and Deploy 19 | runs-on: ubuntu-latest 20 | 21 | env: 22 | CI: true 23 | 24 | steps: 25 | - name: Setup timezone 26 | uses: zcong1993/setup-timezone@master 27 | with: 28 | timezone: America/Sao_Paulo 29 | 30 | - name: Setup repo 31 | uses: actions/checkout@v4 32 | 33 | - name: Setup Node 34 | uses: actions/setup-node@v4 35 | with: 36 | node-version: 22 37 | registry-url: 'https://registry.npmjs.org' 38 | 39 | - name: Install pnpm 40 | uses: pnpm/action-setup@v3 41 | with: 42 | version: 9 43 | run_install: false 44 | 45 | - name: Get pnpm store directory 46 | shell: bash 47 | run: | 48 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 49 | 50 | - name: Setup pnpm cache 51 | uses: actions/cache@v4 52 | with: 53 | path: ${{ env.STORE_PATH }} 54 | key: "${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}" 55 | restore-keys: | 56 | ${{ runner.os }}-pnpm-store- 57 | 58 | - name: Install Packages 59 | run: pnpm install 60 | timeout-minutes: 3 61 | 62 | - name: Validate and Build 63 | if: "!startsWith(github.ref, 'refs/tags/')" 64 | run: pnpm run validate 65 | timeout-minutes: 3 66 | 67 | - name: SonarCloud Scan 68 | if: "!startsWith(github.ref, 'refs/tags/')" 69 | uses: SonarSource/sonarqube-scan-action@master 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 73 | 74 | - name: Publish Package 75 | if: startsWith(github.ref, 'refs/tags/') 76 | run: npm publish 77 | env: 78 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Editor Ignores # 2 | ################ 3 | *.iml 4 | *.ipr 5 | *.iws 6 | .idea/ 7 | .vscode 8 | .cache 9 | local.properties 10 | 11 | # Modules and Caches 12 | ########################### 13 | .tmp/ 14 | coverage/ 15 | dist/ 16 | node_modules/ 17 | stats.html 18 | .awcache 19 | 20 | # OS generated files # 21 | ###################### 22 | .DS_Store 23 | .DS_Store? 24 | thumbs.db 25 | Desktop.ini 26 | -------------------------------------------------------------------------------- /.husky/post-merge: -------------------------------------------------------------------------------- 1 | ./node_modules/.bin/repo-tools install-packages 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | ./node_modules/.bin/repo-tools check-remote && npm run validate 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage 2 | esm 3 | lib 4 | node_modules 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to react-from-dom 2 | 3 | :+1::tada: First off, thanks for taking the time to contribute! :tada::+1: 4 | 5 | **Reporting Bugs** 6 | Before creating bug reports, please check this [list](https://github.com/gilbarbara/react-from-dom/issues) as you might find out that you don't need to create one. When you are creating a bug report, please include as many details as possible. 7 | 8 | **Pull Requests** 9 | Before submitting a new pull request, open a new issue to discuss it. It may already been implemented but not published or we might have found the same situation before and decide against it. 10 | 11 | In any case: 12 | 13 | - Format files using these rules [EditorConfig](https://github.com/gilbarbara/react-from-dom/blob/master/.editorconfig) 14 | - Follow the [Typescript](https://github.com/gilbarbara/react-from-dom/blob/master/.eslintrc) (ESLint) styleguide. 15 | 16 | Thank you! 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019, Gil Barbara 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 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-from-dom 2 | 3 | [![NPM version](https://badge.fury.io/js/react-from-dom.svg)](https://www.npmjs.com/package/react-from-dom) [![CI](https://github.com/gilbarbara/react-from-dom/actions/workflows/main.yml/badge.svg)](https://github.com/gilbarbara/react-from-dom/actions/workflows/main.yml) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=gilbarbara_react-from-dom&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=gilbarbara_react-from-dom) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=gilbarbara_react-from-dom&metric=coverage)](https://sonarcloud.io/summary/new_code?id=gilbarbara_react-from-dom) 4 | 5 | Convert HTML/XML source code or a DOM node to a React element. 6 | The perfect replacement for React's `dangerouslySetInnerHTML` 7 | 8 | 9 | ## Setup 10 | 11 | Install it 12 | ```shell-script 13 | npm install react-from-dom 14 | ``` 15 | 16 | ## Getting Started 17 | 18 | Set a string with HTML/XML source code OR a DOM Node, which will be used to create React elements recursively. 19 | 20 | ```jsx 21 | import React from 'react'; 22 | import convert from 'react-from-dom'; 23 | 24 | const panel = convert(` 25 |
26 |
27 |

Title

28 |
29 |
30 | 34 |
35 | 38 |
39 | `); 40 | 41 | const audio = document.createElement('audio'); 42 | audio.setAttribute('controls', 'true'); 43 | audio.setAttribute( 44 | 'src', 45 | 'https://interactive-examples.mdn.mozilla.net/media/examples/t-rex-roar.mp3', 46 | ); 47 | const audioContent = document.createTextNode('Your browser does not support the audio element.'); 48 | audio.appendChild(audioContent); 49 | 50 | const audioElement = convert(audio); 51 | 52 | const App = () => ( 53 |
54 | {panel} 55 | {audioElement} 56 |
57 | ); 58 | ``` 59 | ## API 60 | 61 | The function accepts two parameters: 62 | 63 | **input** `string|Node` - *required* 64 | An HTML/XML source code string or a DOM node. 65 | 66 | **options** `Options` 67 | 68 | - **actions** `Action[]` 69 | An array of actions to modify the nodes before converting them to ReactNodes. 70 | *Read about them below.* 71 | - **allowWhiteSpaces** `boolean` ▶︎ **false** 72 | Don't remove white spaces in the output. 73 | - **includeAllNodes** `boolean` ▶︎ **false** 74 | Parse all nodes instead of just a single parent node. 75 | This will return a ReactNode array (or a NodeList if `nodeOnly` is true) 76 | - **Index** `number` ▶︎ **0** 77 | The index to start the React key identification. 78 | - **level** `number` ▶︎ **0** 79 | The level to start the React key identification. 80 | - **nodeOnly** `boolean` ▶︎ **false** 81 | Return the node (or NodeList) without converting it to a ReactNode. 82 | *Only used for string inputs.* 83 | - **randomKey** `boolean` ▶︎ **false** 84 | Add a random key to the root element. 85 | - **selector** `string` ▶︎ **body > *** 86 | The selector to use in the `document.querySelector` method. 87 | *Only used for string inputs.* 88 | - **type** `DOMParserSupportedType` ▶︎ **text/html** 89 | The mimeType to use in the DOMParser's parseFromString. 90 | *Only used for string inputs.* 91 | 92 | ### Actions 93 | 94 | You can mutate/update a Node before the conversion or replace it with a ReactNode. 95 | 96 | ```tsx 97 | { 98 | // If this returns true, the two following functions are called if they are defined 99 | condition: (node: Node, key: string, level: number) => boolean; 100 | 101 | // Use this to update or replace the node 102 | // e.g. for removing or adding attributes, changing the node type 103 | pre?: (node: Node, key: string, level: number) => Node; 104 | 105 | // Use this to inject a component or remove the node 106 | // It must return something that can be rendered by React 107 | post?: (node: Node, key: string, level: number) => React.ReactNode; 108 | } 109 | ``` 110 | 111 | #### Examples 112 | 113 | ##### Add a class to all elements that match. 114 | 115 | ```javascript 116 | { 117 | condition: node => node.nodeName.toLowerCase() === 'div', 118 | pre: node => { 119 | node.className += ' a-class-added'; 120 | return node; 121 | }, 122 | } 123 | ``` 124 | 125 | ##### Remove all elements with a specific class. 126 | ```javascript 127 | { 128 | condition: node => node.className.indexOf('delete-me') >= 0, 129 | post: () => null, 130 | } 131 | ``` 132 | 133 | ##### Return a react component for some node types. 134 | ```javascript 135 | { 136 | condition: node => node.nodeName.toLowerCase() === 'pre', 137 | post: (node, key) => ( 138 | 139 | ), 140 | }, 141 | ``` 142 | 143 | ##### Transform one node into another and preserve the child nodes. 144 | ```javascript 145 | { 146 | condition: node => node.nodeName.toLowerCase() === 'ul', 147 | pre: (node) => { 148 | const ol = document.createElement('ol'); 149 | 150 | [...node.childNodes].forEach(child => { 151 | ol.appendChild(child); 152 | }); 153 | 154 | return ol; 155 | } 156 | } 157 | ``` 158 | 159 | ## Browser Support 160 | 161 | If you need to support legacy browsers, you'll need to include a polyfiil for `Number.isNaN` in your app. 162 | 163 | ## Credits 164 | 165 | This is a fork from the [dom-to-react](https://github.com/diva-e/dom-to-react) package. Thanks! ❤️ 166 | 167 | ## License 168 | 169 | MIT 170 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-from-dom", 3 | "version": "0.7.5", 4 | "description": "Convert HTML/XML source code or DOM nodes to React elements", 5 | "author": "Gil Barbara ", 6 | "keywords": [ 7 | "string", 8 | "DOM", 9 | "converter", 10 | "react", 11 | "component", 12 | "dangerouslySetInnerHTML" 13 | ], 14 | "license": "MIT", 15 | "repository": { 16 | "type": "git", 17 | "url": "git://github.com/gilbarbara/react-from-dom.git" 18 | }, 19 | "bugs": { 20 | "url": "https://github.com/gilbarbara/react-from-dom/issues" 21 | }, 22 | "homepage": "https://github.com/gilbarbara/react-from-dom#readme", 23 | "main": "./dist/index.js", 24 | "module": "./dist/index.mjs", 25 | "exports": { 26 | "import": "./dist/index.mjs", 27 | "require": "./dist/index.js" 28 | }, 29 | "files": [ 30 | "dist", 31 | "src" 32 | ], 33 | "types": "dist/index.d.ts", 34 | "sideEffects": false, 35 | "peerDependencies": { 36 | "react": "16.8 - 19" 37 | }, 38 | "devDependencies": { 39 | "@arethetypeswrong/cli": "^0.17.3", 40 | "@gilbarbara/eslint-config": "^0.8.4", 41 | "@gilbarbara/prettier-config": "^1.0.0", 42 | "@gilbarbara/tsconfig": "^0.2.3", 43 | "@size-limit/preset-small-lib": "^11.1.6", 44 | "@swc/core": "^1.10.7", 45 | "@testing-library/jest-dom": "^6.6.3", 46 | "@testing-library/react": "^16.1.0", 47 | "@types/node": "^22.10.6", 48 | "@types/react": "^19.0.7", 49 | "@types/react-dom": "^19.0.3", 50 | "@vitejs/plugin-react-swc": "^3.7.2", 51 | "@vitest/coverage-v8": "^2.1.8", 52 | "del-cli": "^6.0.0", 53 | "husky": "^9.1.7", 54 | "is-ci-cli": "^2.2.0", 55 | "jest-extended": "^4.0.2", 56 | "jsdom": "^26.0.0", 57 | "react": "^19.0.0", 58 | "react-dom": "^19.0.0", 59 | "repo-tools": "^0.3.1", 60 | "size-limit": "^11.1.6", 61 | "ts-node": "^10.9.2", 62 | "tsup": "^8.3.5", 63 | "typescript": "^5.7.3", 64 | "vitest": "^2.1.8" 65 | }, 66 | "scripts": { 67 | "build": "npm run clean && tsup", 68 | "watch": "tsup --watch", 69 | "clean": "del dist/*", 70 | "lint": "eslint --fix src test", 71 | "test": "is-ci \"test:coverage\" \"test:watch\"", 72 | "test:coverage": "vitest run --coverage", 73 | "test:watch": "vitest watch", 74 | "typecheck": "tsc", 75 | "typevalidation": "attw -P", 76 | "format": "prettier \"**/*.{js,jsx,json,yml,yaml,css,less,scss,ts,tsx,md,graphql,mdx}\" --write", 77 | "validate": "npm run lint && npm run typecheck && npm run test:coverage && npm run build && npm run size && npm run typevalidation", 78 | "size": "size-limit", 79 | "prepublishOnly": "npm run validate", 80 | "prepare": "husky" 81 | }, 82 | "tsup": { 83 | "dts": true, 84 | "entry": [ 85 | "src/index.ts" 86 | ], 87 | "format": [ 88 | "cjs", 89 | "esm" 90 | ], 91 | "sourcemap": true, 92 | "splitting": false 93 | }, 94 | "eslintConfig": { 95 | "extends": [ 96 | "@gilbarbara/eslint-config" 97 | ] 98 | }, 99 | "prettier": "@gilbarbara/prettier-config", 100 | "size-limit": [ 101 | { 102 | "name": "commonjs", 103 | "path": "./dist/index.js", 104 | "limit": "5 kB" 105 | }, 106 | { 107 | "name": "esm", 108 | "path": "./dist/index.mjs", 109 | "limit": "5 kB" 110 | } 111 | ] 112 | } 113 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=gilbarbara_react-from-dom 2 | sonar.organization=gilbarbara-github 3 | sonar.source=./src 4 | sonar.javascript.lcov.reportPaths=./coverage/lcov.info 5 | sonar.coverage.exclusions=**/test/**/*.* 6 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | export const styleToObject = (input: string): Record => { 2 | /* c8 ignore next 3 */ 3 | if (typeof input !== 'string') { 4 | return {}; 5 | } 6 | 7 | return input.split(/ ?; ?/).reduce>((acc, item: string) => { 8 | const [key, value] = item 9 | .split(/ ?: ?/) 10 | .map((d, index) => (index === 0 ? d.replace(/\s+/g, '') : d.trim())); 11 | 12 | if (key && value) { 13 | const nextKey = key.replace(/(\w)-(\w)/g, (_$0, $1, $2) => `${$1}${$2.toUpperCase()}`); 14 | let nextValue: string | number = value.trim(); 15 | 16 | if (!Number.isNaN(Number(value))) { 17 | nextValue = Number(value); 18 | } 19 | 20 | acc[key.startsWith('-') ? key : nextKey] = nextValue; 21 | } 22 | 23 | return acc; 24 | }, {}); 25 | }; 26 | 27 | export function randomString(length = 6): string { 28 | const characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; 29 | let result = ''; 30 | 31 | for (let index = length; index > 0; --index) { 32 | result += characters[Math.round(Math.random() * (characters.length - 1))]; 33 | } 34 | 35 | return result; 36 | } 37 | 38 | export const noTextChildNodes = [ 39 | 'br', 40 | 'col', 41 | 'colgroup', 42 | 'dl', 43 | 'hr', 44 | 'iframe', 45 | 'img', 46 | 'input', 47 | 'link', 48 | 'menuitem', 49 | 'meta', 50 | 'ol', 51 | 'param', 52 | 'select', 53 | 'table', 54 | 'tbody', 55 | 'tfoot', 56 | 'thead', 57 | 'tr', 58 | 'ul', 59 | 'wbr', 60 | ]; 61 | 62 | /** 63 | * Copyright (c) 2013-present, Facebook, Inc. 64 | * 65 | * This source code is licensed under the MIT license found in the 66 | * LICENSE file in the root directory of this source tree. 67 | */ 68 | 69 | // Taken from https://raw.githubusercontent.com/facebook/react/baff5cc2f69d30589a5dc65b089e47765437294b/packages/react-dom/src/shared/possibleStandardNames.js 70 | // tslint:disable:object-literal-sort-keys 71 | export const possibleStandardNames: Record = { 72 | // HTML 73 | 'accept-charset': 'acceptCharset', 74 | acceptcharset: 'acceptCharset', 75 | accesskey: 'accessKey', 76 | allowfullscreen: 'allowFullScreen', 77 | autocapitalize: 'autoCapitalize', 78 | autocomplete: 'autoComplete', 79 | autocorrect: 'autoCorrect', 80 | autofocus: 'autoFocus', 81 | autoplay: 'autoPlay', 82 | autosave: 'autoSave', 83 | cellpadding: 'cellPadding', 84 | cellspacing: 'cellSpacing', 85 | charset: 'charSet', 86 | class: 'className', 87 | classid: 'classID', 88 | classname: 'className', 89 | colspan: 'colSpan', 90 | contenteditable: 'contentEditable', 91 | contextmenu: 'contextMenu', 92 | controlslist: 'controlsList', 93 | crossorigin: 'crossOrigin', 94 | dangerouslysetinnerhtml: 'dangerouslySetInnerHTML', 95 | datetime: 'dateTime', 96 | defaultchecked: 'defaultChecked', 97 | defaultvalue: 'defaultValue', 98 | enctype: 'encType', 99 | for: 'htmlFor', 100 | formmethod: 'formMethod', 101 | formaction: 'formAction', 102 | formenctype: 'formEncType', 103 | formnovalidate: 'formNoValidate', 104 | formtarget: 'formTarget', 105 | frameborder: 'frameBorder', 106 | hreflang: 'hrefLang', 107 | htmlfor: 'htmlFor', 108 | httpequiv: 'httpEquiv', 109 | 'http-equiv': 'httpEquiv', 110 | icon: 'icon', 111 | innerhtml: 'innerHTML', 112 | inputmode: 'inputMode', 113 | itemid: 'itemID', 114 | itemprop: 'itemProp', 115 | itemref: 'itemRef', 116 | itemscope: 'itemScope', 117 | itemtype: 'itemType', 118 | keyparams: 'keyParams', 119 | keytype: 'keyType', 120 | marginwidth: 'marginWidth', 121 | marginheight: 'marginHeight', 122 | maxlength: 'maxLength', 123 | mediagroup: 'mediaGroup', 124 | minlength: 'minLength', 125 | nomodule: 'noModule', 126 | novalidate: 'noValidate', 127 | playsinline: 'playsInline', 128 | radiogroup: 'radioGroup', 129 | readonly: 'readOnly', 130 | referrerpolicy: 'referrerPolicy', 131 | rowspan: 'rowSpan', 132 | spellcheck: 'spellCheck', 133 | srcdoc: 'srcDoc', 134 | srclang: 'srcLang', 135 | srcset: 'srcSet', 136 | tabindex: 'tabIndex', 137 | typemustmatch: 'typeMustMatch', 138 | usemap: 'useMap', 139 | 140 | // SVG 141 | accentheight: 'accentHeight', 142 | 'accent-height': 'accentHeight', 143 | alignmentbaseline: 'alignmentBaseline', 144 | 'alignment-baseline': 'alignmentBaseline', 145 | allowreorder: 'allowReorder', 146 | arabicform: 'arabicForm', 147 | 'arabic-form': 'arabicForm', 148 | attributename: 'attributeName', 149 | attributetype: 'attributeType', 150 | autoreverse: 'autoReverse', 151 | basefrequency: 'baseFrequency', 152 | baselineshift: 'baselineShift', 153 | 'baseline-shift': 'baselineShift', 154 | baseprofile: 'baseProfile', 155 | calcmode: 'calcMode', 156 | capheight: 'capHeight', 157 | 'cap-height': 'capHeight', 158 | clippath: 'clipPath', 159 | 'clip-path': 'clipPath', 160 | clippathunits: 'clipPathUnits', 161 | cliprule: 'clipRule', 162 | 'clip-rule': 'clipRule', 163 | colorinterpolation: 'colorInterpolation', 164 | 'color-interpolation': 'colorInterpolation', 165 | colorinterpolationfilters: 'colorInterpolationFilters', 166 | 'color-interpolation-filters': 'colorInterpolationFilters', 167 | colorprofile: 'colorProfile', 168 | 'color-profile': 'colorProfile', 169 | colorrendering: 'colorRendering', 170 | 'color-rendering': 'colorRendering', 171 | contentscripttype: 'contentScriptType', 172 | contentstyletype: 'contentStyleType', 173 | diffuseconstant: 'diffuseConstant', 174 | dominantbaseline: 'dominantBaseline', 175 | 'dominant-baseline': 'dominantBaseline', 176 | edgemode: 'edgeMode', 177 | enablebackground: 'enableBackground', 178 | 'enable-background': 'enableBackground', 179 | externalresourcesrequired: 'externalResourcesRequired', 180 | fillopacity: 'fillOpacity', 181 | 'fill-opacity': 'fillOpacity', 182 | fillrule: 'fillRule', 183 | 'fill-rule': 'fillRule', 184 | filterres: 'filterRes', 185 | filterunits: 'filterUnits', 186 | floodopacity: 'floodOpacity', 187 | 'flood-opacity': 'floodOpacity', 188 | floodcolor: 'floodColor', 189 | 'flood-color': 'floodColor', 190 | fontfamily: 'fontFamily', 191 | 'font-family': 'fontFamily', 192 | fontsize: 'fontSize', 193 | 'font-size': 'fontSize', 194 | fontsizeadjust: 'fontSizeAdjust', 195 | 'font-size-adjust': 'fontSizeAdjust', 196 | fontstretch: 'fontStretch', 197 | 'font-stretch': 'fontStretch', 198 | fontstyle: 'fontStyle', 199 | 'font-style': 'fontStyle', 200 | fontvariant: 'fontVariant', 201 | 'font-variant': 'fontVariant', 202 | fontweight: 'fontWeight', 203 | 'font-weight': 'fontWeight', 204 | glyphname: 'glyphName', 205 | 'glyph-name': 'glyphName', 206 | glyphorientationhorizontal: 'glyphOrientationHorizontal', 207 | 'glyph-orientation-horizontal': 'glyphOrientationHorizontal', 208 | glyphorientationvertical: 'glyphOrientationVertical', 209 | 'glyph-orientation-vertical': 'glyphOrientationVertical', 210 | glyphref: 'glyphRef', 211 | gradienttransform: 'gradientTransform', 212 | gradientunits: 'gradientUnits', 213 | horizadvx: 'horizAdvX', 214 | 'horiz-adv-x': 'horizAdvX', 215 | horizoriginx: 'horizOriginX', 216 | 'horiz-origin-x': 'horizOriginX', 217 | imagerendering: 'imageRendering', 218 | 'image-rendering': 'imageRendering', 219 | kernelmatrix: 'kernelMatrix', 220 | kernelunitlength: 'kernelUnitLength', 221 | keypoints: 'keyPoints', 222 | keysplines: 'keySplines', 223 | keytimes: 'keyTimes', 224 | lengthadjust: 'lengthAdjust', 225 | letterspacing: 'letterSpacing', 226 | 'letter-spacing': 'letterSpacing', 227 | lightingcolor: 'lightingColor', 228 | 'lighting-color': 'lightingColor', 229 | limitingconeangle: 'limitingConeAngle', 230 | markerend: 'markerEnd', 231 | 'marker-end': 'markerEnd', 232 | markerheight: 'markerHeight', 233 | markermid: 'markerMid', 234 | 'marker-mid': 'markerMid', 235 | markerstart: 'markerStart', 236 | 'marker-start': 'markerStart', 237 | markerunits: 'markerUnits', 238 | markerwidth: 'markerWidth', 239 | maskcontentunits: 'maskContentUnits', 240 | maskunits: 'maskUnits', 241 | numoctaves: 'numOctaves', 242 | overlineposition: 'overlinePosition', 243 | 'overline-position': 'overlinePosition', 244 | overlinethickness: 'overlineThickness', 245 | 'overline-thickness': 'overlineThickness', 246 | paintorder: 'paintOrder', 247 | 'paint-order': 'paintOrder', 248 | 'panose-1': 'panose1', 249 | pathlength: 'pathLength', 250 | patterncontentunits: 'patternContentUnits', 251 | patterntransform: 'patternTransform', 252 | patternunits: 'patternUnits', 253 | pointerevents: 'pointerEvents', 254 | 'pointer-events': 'pointerEvents', 255 | pointsatx: 'pointsAtX', 256 | pointsaty: 'pointsAtY', 257 | pointsatz: 'pointsAtZ', 258 | preservealpha: 'preserveAlpha', 259 | preserveaspectratio: 'preserveAspectRatio', 260 | primitiveunits: 'primitiveUnits', 261 | refx: 'refX', 262 | refy: 'refY', 263 | renderingintent: 'renderingIntent', 264 | 'rendering-intent': 'renderingIntent', 265 | repeatcount: 'repeatCount', 266 | repeatdur: 'repeatDur', 267 | requiredextensions: 'requiredExtensions', 268 | requiredfeatures: 'requiredFeatures', 269 | shaperendering: 'shapeRendering', 270 | 'shape-rendering': 'shapeRendering', 271 | specularconstant: 'specularConstant', 272 | specularexponent: 'specularExponent', 273 | spreadmethod: 'spreadMethod', 274 | startoffset: 'startOffset', 275 | stddeviation: 'stdDeviation', 276 | stitchtiles: 'stitchTiles', 277 | stopcolor: 'stopColor', 278 | 'stop-color': 'stopColor', 279 | stopopacity: 'stopOpacity', 280 | 'stop-opacity': 'stopOpacity', 281 | strikethroughposition: 'strikethroughPosition', 282 | 'strikethrough-position': 'strikethroughPosition', 283 | strikethroughthickness: 'strikethroughThickness', 284 | 'strikethrough-thickness': 'strikethroughThickness', 285 | strokedasharray: 'strokeDasharray', 286 | 'stroke-dasharray': 'strokeDasharray', 287 | strokedashoffset: 'strokeDashoffset', 288 | 'stroke-dashoffset': 'strokeDashoffset', 289 | strokelinecap: 'strokeLinecap', 290 | 'stroke-linecap': 'strokeLinecap', 291 | strokelinejoin: 'strokeLinejoin', 292 | 'stroke-linejoin': 'strokeLinejoin', 293 | strokemiterlimit: 'strokeMiterlimit', 294 | 'stroke-miterlimit': 'strokeMiterlimit', 295 | strokewidth: 'strokeWidth', 296 | 'stroke-width': 'strokeWidth', 297 | strokeopacity: 'strokeOpacity', 298 | 'stroke-opacity': 'strokeOpacity', 299 | suppresscontenteditablewarning: 'suppressContentEditableWarning', 300 | suppresshydrationwarning: 'suppressHydrationWarning', 301 | surfacescale: 'surfaceScale', 302 | systemlanguage: 'systemLanguage', 303 | tablevalues: 'tableValues', 304 | targetx: 'targetX', 305 | targety: 'targetY', 306 | textanchor: 'textAnchor', 307 | 'text-anchor': 'textAnchor', 308 | textdecoration: 'textDecoration', 309 | 'text-decoration': 'textDecoration', 310 | textlength: 'textLength', 311 | textrendering: 'textRendering', 312 | 'text-rendering': 'textRendering', 313 | underlineposition: 'underlinePosition', 314 | 'underline-position': 'underlinePosition', 315 | underlinethickness: 'underlineThickness', 316 | 'underline-thickness': 'underlineThickness', 317 | unicodebidi: 'unicodeBidi', 318 | 'unicode-bidi': 'unicodeBidi', 319 | unicoderange: 'unicodeRange', 320 | 'unicode-range': 'unicodeRange', 321 | unitsperem: 'unitsPerEm', 322 | 'units-per-em': 'unitsPerEm', 323 | unselectable: 'unselectable', 324 | valphabetic: 'vAlphabetic', 325 | 'v-alphabetic': 'vAlphabetic', 326 | vectoreffect: 'vectorEffect', 327 | 'vector-effect': 'vectorEffect', 328 | vertadvy: 'vertAdvY', 329 | 'vert-adv-y': 'vertAdvY', 330 | vertoriginx: 'vertOriginX', 331 | 'vert-origin-x': 'vertOriginX', 332 | vertoriginy: 'vertOriginY', 333 | 'vert-origin-y': 'vertOriginY', 334 | vhanging: 'vHanging', 335 | 'v-hanging': 'vHanging', 336 | videographic: 'vIdeographic', 337 | 'v-ideographic': 'vIdeographic', 338 | viewbox: 'viewBox', 339 | viewtarget: 'viewTarget', 340 | vmathematical: 'vMathematical', 341 | 'v-mathematical': 'vMathematical', 342 | wordspacing: 'wordSpacing', 343 | 'word-spacing': 'wordSpacing', 344 | writingmode: 'writingMode', 345 | 'writing-mode': 'writingMode', 346 | xchannelselector: 'xChannelSelector', 347 | xheight: 'xHeight', 348 | 'x-height': 'xHeight', 349 | xlinkactuate: 'xlinkActuate', 350 | 'xlink:actuate': 'xlinkActuate', 351 | xlinkarcrole: 'xlinkArcrole', 352 | 'xlink:arcrole': 'xlinkArcrole', 353 | xlinkhref: 'xlinkHref', 354 | 'xlink:href': 'xlinkHref', 355 | xlinkrole: 'xlinkRole', 356 | 'xlink:role': 'xlinkRole', 357 | xlinkshow: 'xlinkShow', 358 | 'xlink:show': 'xlinkShow', 359 | xlinktitle: 'xlinkTitle', 360 | 'xlink:title': 'xlinkTitle', 361 | xlinktype: 'xlinkType', 362 | 'xlink:type': 'xlinkType', 363 | xmlbase: 'xmlBase', 364 | 'xml:base': 'xmlBase', 365 | xmllang: 'xmlLang', 366 | 'xml:lang': 'xmlLang', 367 | 'xml:space': 'xmlSpace', 368 | xmlnsxlink: 'xmlnsXlink', 369 | 'xmlns:xlink': 'xmlnsXlink', 370 | xmlspace: 'xmlSpace', 371 | ychannelselector: 'yChannelSelector', 372 | zoomandpan: 'zoomAndPan', 373 | 374 | // event handlers 375 | onblur: 'onBlur', 376 | onchange: 'onChange', 377 | onclick: 'onClick', 378 | oncontextmenu: 'onContextMenu', 379 | ondoubleclick: 'onDoubleClick', 380 | ondrag: 'onDrag', 381 | ondragend: 'onDragEnd', 382 | ondragenter: 'onDragEnter', 383 | ondragexit: 'onDragExit', 384 | ondragleave: 'onDragLeave', 385 | ondragover: 'onDragOver', 386 | ondragstart: 'onDragStart', 387 | ondrop: 'onDrop', 388 | onerror: 'onError', 389 | onfocus: 'onFocus', 390 | oninput: 'onInput', 391 | oninvalid: 'onInvalid', 392 | onkeydown: 'onKeyDown', 393 | onkeypress: 'onKeyPress', 394 | onkeyup: 'onKeyUp', 395 | onload: 'onLoad', 396 | onmousedown: 'onMouseDown', 397 | onmouseenter: 'onMouseEnter', 398 | onmouseleave: 'onMouseLeave', 399 | onmousemove: 'onMouseMove', 400 | onmouseout: 'onMouseOut', 401 | onmouseover: 'onMouseOver', 402 | onmouseup: 'onMouseUp', 403 | onscroll: 'onScroll', 404 | onsubmit: 'onSubmit', 405 | ontouchcancel: 'onTouchCancel', 406 | ontouchend: 'onTouchEnd', 407 | ontouchmove: 'onTouchMove', 408 | ontouchstart: 'onTouchStart', 409 | onwheel: 'onWheel', 410 | }; 411 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { noTextChildNodes, possibleStandardNames, randomString, styleToObject } from './helpers'; 4 | 5 | interface Attributes { 6 | [index: string]: any; 7 | 8 | key: string; 9 | } 10 | 11 | interface GetReactNodeOptions extends Options { 12 | key: string; 13 | level: number; 14 | } 15 | 16 | export type Output = React.ReactNode | Node | NodeList; 17 | 18 | export interface Action { 19 | // If this returns true, the two following functions are called if they are defined 20 | condition: (node: Node, key: string, level: number) => boolean; 21 | 22 | // Use this to inject a component or remove the node 23 | // It must return something that can be rendered by React 24 | post?: (node: Node, key: string, level: number) => React.ReactNode; 25 | 26 | // Use this to update or replace the node 27 | // e.g. for removing or adding attributes, changing the node type 28 | pre?: (node: Node, key: string, level: number) => Node; 29 | } 30 | 31 | export interface Options { 32 | /** 33 | * An array of actions to modify the nodes before converting them to ReactNodes. 34 | */ 35 | actions?: Action[]; 36 | /** 37 | * Don't remove white spaces in the output. 38 | */ 39 | allowWhiteSpaces?: boolean; 40 | /** 41 | * Parse all nodes instead of just a single parent node. 42 | * This will return a ReactNode array (or a NodeList if `nodeOnly` is true). 43 | */ 44 | includeAllNodes?: boolean; 45 | /** 46 | * The index to start the React key identification. 47 | * @default 0 48 | */ 49 | index?: number; 50 | /** 51 | * The level to start the React key identification. 52 | * @default 0 53 | */ 54 | level?: number; 55 | /** 56 | * Only return the node (or NodeList) without converting it to a ReactNode. 57 | */ 58 | nodeOnly?: boolean; 59 | /** 60 | * Add a random key to the root element. 61 | * @default false 62 | */ 63 | randomKey?: boolean; 64 | /** 65 | * The selector to use in the `document.querySelector` method. 66 | * @default 'body > *' 67 | */ 68 | selector?: string; 69 | /** 70 | * The mimeType to use in the DOMParser's parseFromString. 71 | * @default 'text/html' 72 | */ 73 | type?: DOMParserSupportedType; 74 | } 75 | 76 | function getReactNode(node: Node, options: GetReactNodeOptions): React.ReactNode { 77 | const { key, level, ...rest } = options; 78 | 79 | switch (node.nodeType) { 80 | case 1: { 81 | // regular dom-node 82 | return React.createElement( 83 | parseName(node.nodeName), 84 | parseAttributes(node, key), 85 | parseChildren(node.childNodes, level, rest), 86 | ); 87 | } 88 | case 3: { 89 | // textnode 90 | const nodeText = node.nodeValue?.toString() ?? ''; 91 | 92 | if (!rest.allowWhiteSpaces && /^\s+$/.test(nodeText) && !/[\u00A0\u202F]/.test(nodeText)) { 93 | return null; 94 | } 95 | 96 | /* c8 ignore next 3 */ 97 | if (!node.parentNode) { 98 | return nodeText; 99 | } 100 | 101 | const parentNodeName = node.parentNode.nodeName.toLowerCase(); 102 | 103 | if (noTextChildNodes.includes(parentNodeName)) { 104 | if (/\S/.test(nodeText)) { 105 | // eslint-disable-next-line no-console 106 | console.warn( 107 | `A textNode is not allowed inside '${parentNodeName}'. Your text "${nodeText}" will be ignored`, 108 | ); 109 | } 110 | 111 | return null; 112 | } 113 | 114 | return nodeText; 115 | } 116 | case 8: { 117 | // html-comment 118 | return null; 119 | } 120 | case 11: { 121 | // fragment 122 | 123 | return parseChildren(node.childNodes, level, options); 124 | } 125 | /* c8 ignore next 3 */ 126 | default: { 127 | return null; 128 | } 129 | } 130 | } 131 | 132 | function parseAttributes(node: Node, reactKey: string): Attributes { 133 | const attributes: Attributes = { 134 | key: reactKey, 135 | }; 136 | 137 | if (node instanceof Element) { 138 | const nodeClassNames = node.getAttribute('class'); 139 | 140 | if (nodeClassNames) { 141 | attributes.className = nodeClassNames; 142 | } 143 | 144 | [...node.attributes].forEach(d => { 145 | switch (d.name) { 146 | // this is manually handled above, so break; 147 | case 'class': 148 | break; 149 | case 'style': 150 | attributes[d.name] = styleToObject(d.value); 151 | break; 152 | case 'allowfullscreen': 153 | case 'allowpaymentrequest': 154 | case 'async': 155 | case 'autofocus': 156 | case 'autoplay': 157 | case 'checked': 158 | case 'controls': 159 | case 'default': 160 | case 'defer': 161 | case 'disabled': 162 | case 'formnovalidate': 163 | case 'hidden': 164 | case 'ismap': 165 | case 'itemscope': 166 | case 'loop': 167 | case 'multiple': 168 | case 'muted': 169 | case 'nomodule': 170 | case 'novalidate': 171 | case 'open': 172 | case 'readonly': 173 | case 'required': 174 | case 'reversed': 175 | case 'selected': 176 | case 'typemustmatch': 177 | attributes[possibleStandardNames[d.name] || d.name] = true; 178 | break; 179 | default: 180 | attributes[possibleStandardNames[d.name] || d.name] = d.value; 181 | } 182 | }); 183 | } 184 | 185 | return attributes; 186 | } 187 | 188 | function parseChildren(childNodeList: NodeList, level: number, options: Options) { 189 | const children: React.ReactNode[] = [...childNodeList] 190 | .map((node, index) => 191 | convertFromNode(node, { 192 | ...options, 193 | index, 194 | level: level + 1, 195 | }), 196 | ) 197 | .filter(Boolean); 198 | 199 | if (!children.length) { 200 | return null; 201 | } 202 | 203 | return children; 204 | } 205 | 206 | function parseName(nodeName: string) { 207 | if (/[a-z]+[A-Z]+[a-z]+/.test(nodeName)) { 208 | return nodeName; 209 | } 210 | 211 | return nodeName.toLowerCase(); 212 | } 213 | 214 | export default function convert(input: Node | string, options: Options = {}): Output { 215 | if (typeof input === 'string') { 216 | return convertFromString(input, options); 217 | } 218 | 219 | if (input instanceof Node) { 220 | return convertFromNode(input, options); 221 | } 222 | 223 | return null; 224 | } 225 | 226 | export function convertFromNode(input: Node, options: Options = {}): React.ReactNode { 227 | if (!input || !(input instanceof Node)) { 228 | return null; 229 | } 230 | 231 | const { actions = [], index = 0, level = 0, randomKey } = options; 232 | 233 | let node = input; 234 | let key = `${level}-${index}`; 235 | const result: React.ReactNode[] = []; 236 | 237 | if (randomKey && level === 0) { 238 | key = `${randomString()}-${key}`; 239 | } 240 | 241 | if (Array.isArray(actions)) { 242 | actions.forEach((action: Action) => { 243 | if (action.condition(node, key, level)) { 244 | if (typeof action.pre === 'function') { 245 | node = action.pre(node, key, level); 246 | 247 | if (!(node instanceof Node)) { 248 | node = input; 249 | 250 | if (process.env.NODE_ENV !== 'production') { 251 | // eslint-disable-next-line no-console 252 | console.warn( 253 | 'The `pre` method always must return a valid DomNode (instanceof Node) - your modification will be ignored (Hint: if you want to render a React-component, use the `post` method instead)', 254 | ); 255 | } 256 | } 257 | } 258 | 259 | if (typeof action.post === 'function') { 260 | result.push(action.post(node, key, level)); 261 | } 262 | } 263 | }); 264 | } 265 | 266 | if (result.length) { 267 | return result; 268 | } 269 | 270 | return getReactNode(node, { key, level, ...options }); 271 | } 272 | 273 | export function convertFromString(input: string, options: Options = {}): Output { 274 | if (!input || typeof input !== 'string') { 275 | return null; 276 | } 277 | 278 | const { 279 | includeAllNodes = false, 280 | nodeOnly = false, 281 | selector = 'body > *', 282 | type = 'text/html', 283 | } = options; 284 | 285 | try { 286 | const parser = new DOMParser(); 287 | const document = parser.parseFromString(input, type); 288 | 289 | if (includeAllNodes) { 290 | const { childNodes } = document.body; 291 | 292 | if (nodeOnly) { 293 | return childNodes; 294 | } 295 | 296 | return [...childNodes].map(node => convertFromNode(node, options)); 297 | } 298 | 299 | const node = document.querySelector(selector) || document.body.childNodes[0]; 300 | 301 | /* c8 ignore next 3 */ 302 | if (!(node instanceof Node)) { 303 | throw new TypeError('Error parsing input'); 304 | } 305 | 306 | if (nodeOnly) { 307 | return node; 308 | } 309 | 310 | return convertFromNode(node, options); 311 | /* c8 ignore start */ 312 | } catch (error) { 313 | if (process.env.NODE_ENV !== 'production') { 314 | // eslint-disable-next-line no-console 315 | console.error(error); 316 | } 317 | } 318 | 319 | return null; 320 | /* c8 ignore stop */ 321 | } 322 | -------------------------------------------------------------------------------- /test/__fixtures__/data.ts: -------------------------------------------------------------------------------- 1 | export const audio = document.createElement('audio'); 2 | audio.setAttribute('controls', 'true'); 3 | audio.setAttribute( 4 | 'src', 5 | 'https://interactive-examples.mdn.mozilla.net/media/examples/t-rex-roar.mp3', 6 | ); 7 | const audioContent = document.createTextNode('Your browser does not support the audio element.'); 8 | 9 | audio.appendChild(audioContent); 10 | 11 | export const form = ` 12 | 28 | `; 29 | 30 | export const iframe = 31 | ''; 32 | 33 | export const links = 'link 1 link 2'; 34 | 35 | export const panel = ` 36 |
37 |
38 |

Title

39 |
40 |
41 |
    42 |
  • line 1
  • 43 |
  • line 2
  • 44 |
45 |

46 | This Element has a style attribute.
47 | It also shows how to use the parser-argument 48 |

49 |

 

50 |

51 | This is the second paragraph 52 |

53 | EMPTY 54 |
This is a test
55 | throw new Error('Fail'); 56 |
Text
57 |
58 | 61 |
62 | `; 63 | 64 | export const svg = ` 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | `; 81 | 82 | export const svgWithCssVariables = ` 83 | 84 | 85 | 102 | 103 | 104 | 122 | 123 | 124 | 125 | `; 126 | 127 | export const svgWithStyleAndScript = ` 128 | 129 | 132 | 137 | 153 | 154 | 155 | 156 | 157 | 158 | `; 159 | 160 | export const utf8 = ` 161 | 162 | 眼观:仪表盘、车辆 163 | 164 | `; 165 | -------------------------------------------------------------------------------- /test/__setup__/global.d.ts: -------------------------------------------------------------------------------- 1 | import 'jest-extended'; 2 | import 'vitest/globals'; 3 | -------------------------------------------------------------------------------- /test/__setup__/vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | 3 | import * as matchers from 'jest-extended'; 4 | 5 | expect.extend(matchers); 6 | -------------------------------------------------------------------------------- /test/__snapshots__/index.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`react-from-dom > should convert a fragment 1`] = ` 4 |
7 | Hello, world! 8 |
9 | I'm inside a \`div\`. 10 |
11 |
12 | `; 13 | 14 | exports[`react-from-dom > should convert a search form from a string 1`] = ` 15 |
18 | 87 |
88 | `; 89 | 90 | exports[`react-from-dom > should convert a text node 1`] = ` 91 |
94 | test 95 |
96 | `; 97 | 98 | exports[`react-from-dom > should convert an SVG from a string 1`] = ` 99 | 108 | 109 | 110 | 111 | 112 | 113 | 117 | 118 | 119 | 128 | 129 | 130 | 134 | 135 | 136 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 155 | 156 | 157 | 160 | 161 | 162 | 163 | 164 | 165 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | `; 179 | 180 | exports[`react-from-dom > should convert an SVG from a string 2`] = ` 181 |
184 | 193 | 194 | 198 | 207 | 211 | 215 | 216 | 217 | 218 | 222 | 225 | 226 | 233 | 234 | 235 |
236 | `; 237 | 238 | exports[`react-from-dom > should convert an SVG with CSS variable from a string 1`] = ` 239 |
242 | 250 | 253 | 256 | 262 | 263 | 264 | 265 |
266 | `; 267 | 268 | exports[`react-from-dom > should convert an SVG with style and script from a string 1`] = ` 269 |
272 | 280 | 285 | 292 | 312 | 313 | 319 | 326 | 327 | 328 |
329 | `; 330 | 331 | exports[`react-from-dom > should convert an audio from Node 1`] = ` 332 |
335 | 341 |
342 | `; 343 | 344 | exports[`react-from-dom > should convert an iframe from a string 1`] = ` 345 |
348 |