├── .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 | [](https://www.npmjs.com/package/react-from-dom) [](https://github.com/gilbarbara/react-from-dom/actions/workflows/main.yml) [](https://sonarcloud.io/summary/new_code?id=gilbarbara_react-from-dom) [](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 |
29 |
30 |
31 | line 1
32 | line 2
33 |
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 | 'VIDEO ';
32 |
33 | export const links = 'link 1 link 2 ';
34 |
35 | export const panel = `
36 |
37 |
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 |
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 |
339 | Your browser does not support the audio element.
340 |
341 |
342 | `;
343 |
344 | exports[`react-from-dom > should convert an iframe from a string 1`] = `
345 |
348 | VIDEO
356 |
357 | `;
358 |
359 | exports[`react-from-dom > should convert markup with spaces 1`] = `
360 |
371 | `;
372 |
373 | exports[`react-from-dom > should convert multiple nodes > nodeList 1`] = `
374 | NodeList [
375 | 1,
376 |
377 | 2
378 | ,
379 | 3,
380 | ]
381 | `;
382 |
383 | exports[`react-from-dom > should convert multiple nodes > reactNode 1`] = `
384 |
387 | 1
388 |
389 | 2
390 |
391 | 3
392 |
393 | `;
394 |
395 | exports[`react-from-dom > should handle UTF8 text 1`] = `
396 |
399 |
400 |
407 | 眼观:仪表盘、车辆
408 |
409 |
410 |
411 | `;
412 |
413 | exports[`react-from-dom > should handle actions 1`] = `
414 |
417 |
420 |
427 |
430 |
431 |
432 | line 1
433 |
434 |
435 | line 2
436 |
437 |
438 |
439 |
440 | This Element has a
441 |
442 | style
443 |
444 | attribute.
445 |
446 |
447 | It also shows how to use the
448 |
449 | parser
450 |
451 | -argument
452 |
453 |
454 |
455 |
456 |
457 |
458 |
459 | This is the second paragraph
460 |
461 |
462 |
463 | EMPTY
464 |
465 |
466 |
467 | throw new Error('Fail');
468 |
469 |
470 |
471 |
472 |
473 | `;
474 |
475 | exports[`react-from-dom > should handle broken markup 1`] = `
476 |
479 |
480 |
481 | los
482 |
483 |
484 |
485 | `;
486 |
--------------------------------------------------------------------------------
/test/helpers.spec.ts:
--------------------------------------------------------------------------------
1 | import { randomString, styleToObject } from '../src/helpers';
2 |
3 | describe('randomString', () => {
4 | it('should return a random string', () => {
5 | expect(randomString()).toHaveLength(6);
6 | });
7 | it('should return a 20 characters random string', () => {
8 | expect(randomString(20)).toHaveLength(20);
9 | });
10 | });
11 |
12 | describe('styleToObject', () => {
13 | it('should return an object', () => {
14 | expect(styleToObject('')).toEqual({});
15 | expect(
16 | styleToObject(
17 | 'stroke-width: 0;stroke-dasharray: none;stroke-linecap: butt;stroke-dashoffset: 0;stroke-linejoin: miter;stroke-miterlimit: 4;fill: var(--my-css-var);fill-rule: nonzero;opacity: 1;--my-css-var: yellow;',
18 | ),
19 | ).toEqual({
20 | '--my-css-var': 'yellow',
21 | fill: 'var(--my-css-var)',
22 | fillRule: 'nonzero',
23 | opacity: 1,
24 | strokeDasharray: 'none',
25 | strokeDashoffset: 0,
26 | strokeLinecap: 'butt',
27 | strokeLinejoin: 'miter',
28 | strokeMiterlimit: 4,
29 | strokeWidth: 0,
30 | });
31 | });
32 | });
33 |
--------------------------------------------------------------------------------
/test/index.spec.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 |
4 | import convert, { convertFromNode, convertFromString } from '../src/index';
5 |
6 | import {
7 | audio,
8 | form,
9 | iframe,
10 | links,
11 | panel,
12 | svg,
13 | svgWithCssVariables,
14 | svgWithStyleAndScript,
15 | utf8,
16 | } from './__fixtures__/data';
17 |
18 | vi.mock('../src/helpers', async () => {
19 | const helpers = await vi.importActual('../src/helpers');
20 |
21 | return {
22 | ...helpers,
23 | randomString: () => 'ABCDE',
24 | };
25 | });
26 |
27 | function ReactMarkdown({ children }: React.PropsWithChildren) {
28 | return {children}
;
29 | }
30 |
31 | describe('react-from-dom', () => {
32 | beforeAll(() => {
33 | vi.spyOn(console, 'error').mockImplementation(() => {});
34 | vi.spyOn(console, 'warn').mockImplementation(() => {});
35 | });
36 |
37 | it('should convert an SVG from a string', () => {
38 | const node = convertFromString(svg, { nodeOnly: true });
39 |
40 | expect(node).toMatchSnapshot();
41 |
42 | const element = convert(node as Node) as React.ReactNode;
43 |
44 | render({element}
);
45 |
46 | expect(screen.getByTestId('wrapper')).toMatchSnapshot();
47 | });
48 |
49 | it('should convert an SVG with CSS variable from a string', () => {
50 | const element = convertFromString(svgWithCssVariables) as React.ReactNode;
51 |
52 | render({element}
);
53 |
54 | expect(screen.getByTestId('wrapper')).toMatchSnapshot();
55 | });
56 |
57 | it('should convert an SVG with style and script from a string', () => {
58 | const element = convert(svgWithStyleAndScript, {
59 | selector: 'svg',
60 | }) as React.ReactNode;
61 |
62 | render({element}
);
63 |
64 | expect(screen.getByTestId('wrapper')).toMatchSnapshot();
65 | });
66 |
67 | it('should handle UTF8 text', () => {
68 | const element = convert(utf8) as React.ReactNode;
69 |
70 | render({element}
);
71 |
72 | expect(screen.getByTestId('wrapper')).toMatchSnapshot();
73 | });
74 |
75 | it('should convert a search form from a string', () => {
76 | const element = convert(form) as React.ReactNode;
77 |
78 | render({element}
);
79 |
80 | expect(screen.getByTestId('wrapper')).toMatchSnapshot();
81 | });
82 |
83 | it('should convert an iframe from a string', () => {
84 | const element = convert(iframe) as React.ReactNode;
85 |
86 | render({element}
);
87 |
88 | expect(screen.getByTestId('wrapper')).toMatchSnapshot();
89 | });
90 |
91 | it('should convert an audio from Node', () => {
92 | const element = convert(audio as Node) as React.ReactNode;
93 |
94 | render({element}
);
95 |
96 | expect(screen.getByTestId('wrapper')).toMatchSnapshot();
97 | });
98 |
99 | it('should convert a fragment', () => {
100 | const fragment = document.createDocumentFragment();
101 | const div = document.createElement('div');
102 |
103 | div.appendChild(document.createTextNode("I'm inside a `div`."));
104 | fragment.appendChild(document.createTextNode('Hello, world!'));
105 | fragment.appendChild(div);
106 |
107 | const element = convert(fragment) as React.ReactNode;
108 |
109 | render({element}
);
110 |
111 | expect(screen.getByTestId('wrapper')).toMatchSnapshot();
112 | });
113 |
114 | it('should convert markup with spaces', () => {
115 | const element = convert(links, {
116 | allowWhiteSpaces: true,
117 | includeAllNodes: true,
118 | }) as React.ReactNode;
119 |
120 | render({element}
);
121 |
122 | expect(screen.getByTestId('wrapper')).toMatchSnapshot();
123 | });
124 |
125 | it('should convert a text node', () => {
126 | const element = convert('test') as React.ReactNode;
127 |
128 | render({element}
);
129 |
130 | expect(screen.getByTestId('wrapper')).toMatchSnapshot();
131 | });
132 |
133 | it('should convert multiple nodes', () => {
134 | const reactNode = convert('12 3', { includeAllNodes: true }) as React.ReactNode;
135 |
136 | render({reactNode}
);
137 | expect(screen.getByTestId('wrapper')).toMatchSnapshot('reactNode');
138 |
139 | const nodeList = convert('12 3', { includeAllNodes: true, nodeOnly: true });
140 |
141 | expect(nodeList).toMatchSnapshot('nodeList');
142 | });
143 |
144 | it('should handle actions', () => {
145 | const element = convert(panel, {
146 | actions: [
147 | {
148 | condition: node => node.nodeName.toLowerCase() === 'code',
149 | // @ts-ignore
150 | pre: () => null,
151 | },
152 | {
153 | condition: node => node.nodeName.toLowerCase() === 'pre',
154 | post: (node, key) => (
155 | // @ts-ignore
156 |
157 | ),
158 | },
159 | {
160 | condition: node => node.nodeName.toLowerCase() === 'ul',
161 | pre: node => {
162 | const ol = document.createElement('ol');
163 |
164 | [...node.childNodes].forEach(child => {
165 | ol.appendChild(child);
166 | });
167 |
168 | return ol;
169 | },
170 | },
171 | {
172 | condition: node => node instanceof HTMLElement && node.className === 'panel-footer',
173 | post: () => null,
174 | },
175 | {
176 | condition: node => node instanceof HTMLElement && node.classList.contains('panel'),
177 | pre: node => {
178 | if (node instanceof HTMLElement) {
179 | // eslint-disable-next-line no-param-reassign
180 | node.className += ' panel--fixed';
181 | }
182 |
183 | return node;
184 | },
185 | },
186 | ],
187 | randomKey: true,
188 | selector: 'div',
189 | }) as React.ReactNode;
190 |
191 | render({element}
);
192 |
193 | expect(screen.getByTestId('wrapper')).toMatchSnapshot();
194 | });
195 |
196 | it('should handle broken markup', () => {
197 | const element = convert('los ', {
198 | selector: 'div',
199 | }) as React.ReactNode;
200 |
201 | render(
{element}
);
202 |
203 | expect(screen.getByTestId('wrapper')).toMatchSnapshot();
204 | });
205 |
206 | it('should handle missing or invalid parameters', () => {
207 | // @ts-ignore
208 | expect(convert()).toBeNull();
209 |
210 | // @ts-ignore
211 | expect(convert(() => ({}))).toBeNull();
212 |
213 | // @ts-ignore
214 | expect(convertFromNode()).toBeNull();
215 |
216 | // @ts-ignore
217 | expect(convertFromNode('This is not a test')).toBeNull();
218 |
219 | // @ts-ignore
220 | expect(convertFromString()).toBeNull();
221 |
222 | // @ts-ignore
223 | expect(convertFromString([])).toBeNull();
224 |
225 | expect(convertFromString('This is a test')).toBe('This is a test');
226 |
227 | expect(convert('', { includeAllNodes: true })).toBeNull();
228 | });
229 | });
230 |
--------------------------------------------------------------------------------
/test/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig",
3 | "compilerOptions": {
4 | "noUnusedLocals": false,
5 | "esModuleInterop": true,
6 | "module": "esnext"
7 | },
8 | "include": ["**/*"]
9 | }
10 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@gilbarbara/tsconfig",
3 | "compilerOptions": {
4 | "downlevelIteration": true,
5 | "lib": ["dom", "dom.iterable", "esnext"],
6 | "noEmit": true,
7 | "target": "ES2022"
8 | },
9 | "include": ["src/**/*"]
10 | }
11 |
--------------------------------------------------------------------------------
/vitest.config.mts:
--------------------------------------------------------------------------------
1 | import react from '@vitejs/plugin-react-swc';
2 | import { defineConfig } from 'vitest/config';
3 |
4 | export default defineConfig({
5 | plugins: [react()],
6 | test: {
7 | coverage: {
8 | all: true,
9 | include: ['src/**/*.ts?(x)'],
10 | reporter: ['text', 'lcov'],
11 | thresholds: {
12 | statements: 90,
13 | branches: 90,
14 | functions: 90,
15 | lines: 90,
16 | },
17 | },
18 | environment: 'jsdom',
19 | globals: true,
20 | setupFiles: ['./test/__setup__/vitest.setup.ts'],
21 | },
22 | });
23 |
--------------------------------------------------------------------------------