mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-23 02:06:29 -05:00
feat: import html-to-react from https://github.com/hedgedoc/html-to-react
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
5dc6526278
commit
84527f065c
37 changed files with 1388 additions and 0 deletions
36
html-to-react/.eslintrc.json
Normal file
36
html-to-react/.eslintrc.json
Normal file
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
{
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"project": [
|
||||
"./tsconfig.test.json"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
"@typescript-eslint",
|
||||
"jest",
|
||||
"prettier"
|
||||
],
|
||||
"env": {
|
||||
"jest": true,
|
||||
"jest/globals": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"rules": {
|
||||
"prettier/prettier": "error",
|
||||
"jest/no-disabled-tests": "warn",
|
||||
"jest/no-focused-tests": "error",
|
||||
"jest/no-identical-title": "error",
|
||||
"jest/prefer-to-have-length": "warn",
|
||||
"jest/valid-expect": "error"
|
||||
}
|
||||
}
|
9
html-to-react/.npmignore
Normal file
9
html-to-react/.npmignore
Normal file
|
@ -0,0 +1,9 @@
|
|||
.idea
|
||||
.babelrc
|
||||
.eslintrc
|
||||
.travis.yml
|
||||
karma.conf.js
|
||||
tests.webpack.js
|
||||
webpack.config.*.js
|
||||
coverage/
|
||||
test/
|
3
html-to-react/.npmignore.license
Normal file
3
html-to-react/.npmignore.license
Normal file
|
@ -0,0 +1,3 @@
|
|||
SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
|
||||
SPDX-License-Identifier: CC0-1.0
|
1
html-to-react/.prettierignore
Normal file
1
html-to-react/.prettierignore
Normal file
|
@ -0,0 +1 @@
|
|||
node_modules/
|
4
html-to-react/.prettierignore.license
Normal file
4
html-to-react/.prettierignore.license
Normal file
|
@ -0,0 +1,4 @@
|
|||
SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
|
||||
SPDX-License-Identifier: CC0-1.0
|
||||
|
11
html-to-react/.prettierrc.json
Normal file
11
html-to-react/.prettierrc.json
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"parser": "typescript",
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"semi": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": true,
|
||||
"arrowParens": "always"
|
||||
}
|
3
html-to-react/.prettierrc.json.license
Normal file
3
html-to-react/.prettierrc.json.license
Normal file
|
@ -0,0 +1,3 @@
|
|||
SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
|
||||
SPDX-License-Identifier: CC0-1.0
|
143
html-to-react/README.md
Normal file
143
html-to-react/README.md
Normal file
|
@ -0,0 +1,143 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
|
||||
SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
-->
|
||||
|
||||
# HTML to React
|
||||
|
||||
This is a library that renders HTML strings into [React](https://facebook.github.io/react/) components without using `dangerouslySetInnerHTML`. Converts standard HTML elements, attributes and inline styles into their React equivalents and provides a simple way to modify and replace the content.
|
||||
|
||||
This library is a hard fork of https://github.com/peternewnham/react-html-parser. It has some improvements and is converted to typescript.
|
||||
|
||||
[![npm](https://img.shields.io/npm/v/@hedgedoc/html-to-react.svg)](https://www.npmjs.com/package/@hedgedoc/html-to-react)
|
||||
[![Downloads](https://img.shields.io/npm/dw/@hedgedoc/html-to-react.svg)](https://www.npmjs.com/package/@hedgedoc/html-to-react)
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @hedgedoc/html-to-react
|
||||
# or
|
||||
yarn add @hedgedoc/html-to-react
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import React from 'react';
|
||||
import { convertHtmlToReact } from '@hedgedoc/html-to-react';
|
||||
|
||||
class HtmlComponent extends React.Component {
|
||||
render() {
|
||||
const html = '<div>Example HTML string</div>';
|
||||
return <div>{ convertHtmlToReact(html) }</div>;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
It is important to understand that this library should not be used as a direct replacement for using properly sanitized HTML and that it only provides the same level of protection that React does which does not provide 100% protection. All HTML should be properly sanitized using a dedicated sanitisation library (such as [dompurify](https://www.npmjs.com/package/dompurify) for node/js) before being passed to this library to ensure that you are fully protected from [malicious injections](https://en.wikipedia.org/wiki/Cross-site_scripting).
|
||||
|
||||
### What doesn't React protect me from?
|
||||
|
||||
Whilst React has a [certain level of protection to injection attacks](https://reactjs.org/docs/introducing-jsx.html#jsx-prevents-injection-attacks) built into it, it doesn't cover everything, for example:
|
||||
* xss via iframe src: `<iframe src="javascript:alert('xss')" />`
|
||||
* xss via link href: `<a href="javascript:alert('xss')">click me</a>`
|
||||
|
||||
[Click here](https://codesandbox.io/s/reacthtmlparser-xss-examples-ijgiu?file=/src/App.js) to see these in action and how to protect yourself using [dompurify](https://www.npmjs.com/package/dompurify) in the browser.
|
||||
|
||||
### Why doesn't `HTML to React` protect me automatically?
|
||||
|
||||
Including a sanitizer as part of the library means it is making decisions for you that may not be correct. It is up to you to decide what level of sanitization you need and to act accordingly. Some users may already be sanitizing on the server or others may have specialized requirements that cannot be covered by a generic implementation.
|
||||
|
||||
Additionally, HTML sanitization is a hard thing to get right and even the most popular and actively developed sanitizers have [vulnerabilities discovered](https://snyk.io/vuln/npm:dompurify) from time to time. By leaving the sanitization outside of this library it gives users the ability to patch and deploy any fixes needed immediately instead of having to wait for a new version of this library to be released with the fix.
|
||||
|
||||
## API
|
||||
|
||||
### `function convertHtmlToReact(html, [options])`
|
||||
Takes an HTML string and returns equivalent React elements
|
||||
|
||||
#### Usage
|
||||
```js
|
||||
import { convertHtmlToReact } from '@hedgedoc/html-to-react';
|
||||
```
|
||||
#### Arguments
|
||||
- `html`: The HTML string to parse
|
||||
- `options`: Options object
|
||||
- decodeEntities=true *(boolean)*: Whether to decode html entities (defaults to true)
|
||||
- transform *(function)*: Transform function that is applied to every node
|
||||
- preprocessNodes *(function)*: Pre-process the nodes generated by `htmlparser2`
|
||||
|
||||
#### Transform Function
|
||||
The transform function will be called for every node that is parsed by the library.
|
||||
|
||||
`function transform(node, index)`
|
||||
##### Arguments
|
||||
- `node`: The node being parsed. This is the [htmlparser2](https://github.com/fb55/htmlparser2) node object. Full details can be found on their project page but important properties are:
|
||||
- `type` (string): The type of node *(tag, text, style etc)*
|
||||
- `name` (string): The name of the node
|
||||
- `children` (array): Array of children nodes
|
||||
- `next` (node): The node's next sibling
|
||||
- `prev` (node): The node's previous sibling
|
||||
- `parent` (node): The node's parent
|
||||
- `data` (string): The text content, if the `type` is text
|
||||
- `index` (number): The index of the node in relation to it's parent
|
||||
|
||||
#### Return Types
|
||||
`return null`
|
||||
Returning null will prevent the node and all of it's children from being rendered.
|
||||
```js
|
||||
function transform(node) {
|
||||
// do not render any <span> tags
|
||||
if (node.type === 'tag' && node.name === 'span') {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
`return undefined`
|
||||
If the function does not return anything, or returns undefined, then the default behaviour will occur and the parser will continue was usual.
|
||||
|
||||
`return React element`
|
||||
React elements can be returned directly
|
||||
```js
|
||||
import React from 'react';
|
||||
function transform(node) {
|
||||
if (node.type === 'tag' && node.name === 'b') {
|
||||
return <div>This was a bold tag</div>;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### preprocessNodes Function
|
||||
Allows pre-processing the nodes generated from the html by `htmlparser2` before being passed to the library and converted to React elements.
|
||||
|
||||
`function preprocessNodes(nodes)`
|
||||
##### Arguments
|
||||
- `nodes`: The entire node tree generated by `htmlparser2`.
|
||||
|
||||
##### Return type
|
||||
The `preprocessNodes` function should return a valid `htmlparser2` node tree.
|
||||
|
||||
### `function convertNodeToReactElement(node, index, transform)`
|
||||
Processes a node and returns the React element to be rendered. This function can be used in conjunction with the previously described `transform` function to continue to process a node after modifying it.
|
||||
|
||||
#### Usage
|
||||
```typescript
|
||||
import { convertNodeToReactElement } from '@hedgedoc/html-to-react';
|
||||
```
|
||||
#### Arguments
|
||||
- `node`: The node to process
|
||||
- `index` (number): The index of the node in relation to it's parent
|
||||
- `transform`: The transform function as described above
|
||||
|
||||
```typescript
|
||||
import { convertNodeToReactElement } from '@hedgedoc/html-to-react';
|
||||
function transform(node, index) {
|
||||
// convert <ul> to <ol>
|
||||
if (node.type === 'tag' && node.name === 'ul') {
|
||||
node.name = 'ol';
|
||||
return convertNodeToReactElement(node, index, transform);
|
||||
}
|
||||
}
|
||||
```
|
33
html-to-react/build.sh
Executable file
33
html-to-react/build.sh
Executable file
|
@ -0,0 +1,33 @@
|
|||
#!/bin/bash
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
#
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
echo "Clear dist directory.."
|
||||
rm -rf dist
|
||||
|
||||
echo "Compile to CJS.."
|
||||
tsc --project tsconfig.cjs.json
|
||||
|
||||
echo "Compile to ESM.."
|
||||
tsc --project tsconfig.esm.json
|
||||
|
||||
echo "Fix CJS package.json.."
|
||||
cat > dist/cjs/package.json <<!EOF
|
||||
{
|
||||
"type": "commonjs"
|
||||
}
|
||||
!EOF
|
||||
|
||||
echo "Fix ESM package.json.."
|
||||
cat > dist/esm/package.json <<!EOF
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
!EOF
|
||||
|
||||
echo "Done!"
|
26
html-to-react/jest.config.json
Normal file
26
html-to-react/jest.config.json
Normal file
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"testRegex" : "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$",
|
||||
"testPathIgnorePatterns" : [
|
||||
"/dist/"
|
||||
],
|
||||
"moduleFileExtensions" : [
|
||||
"ts",
|
||||
"tsx",
|
||||
"js"
|
||||
],
|
||||
"extensionsToTreatAsEsm" : [
|
||||
".ts"
|
||||
],
|
||||
"moduleNameMapper" : {
|
||||
"^(\\.{1,2}/.*)\\.js$" : "$1"
|
||||
},
|
||||
"transform" : {
|
||||
"^.+\\.tsx?$" : [
|
||||
"ts-jest",
|
||||
{
|
||||
"tsconfig" : "tsconfig.test.json",
|
||||
"useESM" : true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
3
html-to-react/jest.config.json.license
Normal file
3
html-to-react/jest.config.json.license
Normal file
|
@ -0,0 +1,3 @@
|
|||
SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
|
||||
SPDX-License-Identifier: CC0-1.0
|
86
html-to-react/package.json
Normal file
86
html-to-react/package.json
Normal file
|
@ -0,0 +1,86 @@
|
|||
{
|
||||
"name": "@hedgedoc/html-to-react",
|
||||
"version": "2.1.0",
|
||||
"description": "Parse HTML into React components",
|
||||
"source": "src/index.ts",
|
||||
"main": "dist/cjs/index.js",
|
||||
"types": "dist/cjs/index.d.ts",
|
||||
"module": "./dist/esm/index.js",
|
||||
"exports": {
|
||||
"import": {
|
||||
"types": "./dist/esm/index.d.ts",
|
||||
"default": "./dist/esm/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/cjs/index.d.ts",
|
||||
"default": "./dist/cjs/index.js"
|
||||
}
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"build": "./build.sh",
|
||||
"prepublish": "yarn lint && yarn build && yarn test",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"lint:fix": "eslint --fix --ext .ts src"
|
||||
},
|
||||
"files": [
|
||||
"LICENSES/*",
|
||||
"package.json",
|
||||
"README.md",
|
||||
"dist/**"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hedgedoc/html-to-react.git"
|
||||
},
|
||||
"keywords": [
|
||||
"react",
|
||||
"html",
|
||||
"htmlparser",
|
||||
"htmlparser2",
|
||||
"inner html",
|
||||
"dangerouslySetInnerHTML"
|
||||
],
|
||||
"author": "The HedgeDoc Authors",
|
||||
"license": "AGPL-3.0",
|
||||
"devDependencies": {
|
||||
"@jest/globals": "29.6.4",
|
||||
"@jest/types": "29.6.3",
|
||||
"@types/react": "18.2.21",
|
||||
"@types/react-dom": "18.2.7",
|
||||
"@typescript-eslint/eslint-plugin": "6.4.1",
|
||||
"@typescript-eslint/parser": "6.4.1",
|
||||
"eslint": "8.48.0",
|
||||
"eslint-config-prettier": "9.0.0",
|
||||
"eslint-plugin-jest": "27.2.3",
|
||||
"eslint-plugin-prettier": "5.0.0",
|
||||
"jest": "29.6.4",
|
||||
"prettier": "3.0.2",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"ts-jest": "29.1.1",
|
||||
"typescript": "5.1.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"htmlparser2": "^9.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.0"
|
||||
},
|
||||
"directories": {
|
||||
"test": "test"
|
||||
},
|
||||
"browserslist": [
|
||||
"node> 12"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "18.2.21"
|
||||
},
|
||||
"packageManager": "yarn@3.6.3"
|
||||
}
|
3
html-to-react/package.json.license
Normal file
3
html-to-react/package.json.license
Normal file
|
@ -0,0 +1,3 @@
|
|||
SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
15
html-to-react/src/NodeToReactElementTransformer.ts
Normal file
15
html-to-react/src/NodeToReactElementTransformer.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Node } from 'domhandler'
|
||||
import { ReactElement } from 'react'
|
||||
|
||||
export interface NodeToReactElementTransformer {
|
||||
(
|
||||
node: Node,
|
||||
index: number | string,
|
||||
transform?: NodeToReactElementTransformer
|
||||
): ReactElement | void | null | string
|
||||
}
|
38
html-to-react/src/convertHtmlToReact.ts
Normal file
38
html-to-react/src/convertHtmlToReact.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { parseDocument } from 'htmlparser2'
|
||||
import { processNodes } from './processNodes.js'
|
||||
import { ReactElement } from 'react'
|
||||
import { Document } from 'domhandler'
|
||||
import { NodeToReactElementTransformer } from './NodeToReactElementTransformer.js'
|
||||
|
||||
export interface ParserOptions {
|
||||
decodeEntities?: boolean
|
||||
transform?: NodeToReactElementTransformer
|
||||
preprocessNodes?: (nodes: Document) => Document
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an HTML string and returns a list of React components generated from it
|
||||
*
|
||||
* @param {String} html The HTML to convert into React component
|
||||
* @param {Object} options Options to pass
|
||||
* @returns {Array} List of top level React elements
|
||||
*/
|
||||
export function convertHtmlToReact(
|
||||
html: string,
|
||||
options?: ParserOptions
|
||||
): (ReactElement | string | null)[] {
|
||||
const parsedDocument = parseDocument(html, {
|
||||
decodeEntities: options?.decodeEntities ?? true
|
||||
})
|
||||
|
||||
const processedDocument =
|
||||
options?.preprocessNodes?.(parsedDocument) ?? parsedDocument
|
||||
|
||||
return processNodes(processedDocument.childNodes, options?.transform)
|
||||
}
|
38
html-to-react/src/convertNodeToReactElement.ts
Normal file
38
html-to-react/src/convertNodeToReactElement.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { ReactElement } from 'react'
|
||||
import { Node } from 'domhandler'
|
||||
import { ElementType } from 'domelementtype'
|
||||
import { processTextNode } from './elementTypes/ProcessTextNode.js'
|
||||
import { processTagNode } from './elementTypes/ProcessTagNode.js'
|
||||
import { processStyleNode } from './elementTypes/ProcessStyleNode.js'
|
||||
import { NodeToReactElementTransformer } from './NodeToReactElementTransformer.js'
|
||||
|
||||
/**
|
||||
* Converts a htmlparser2 node to a React element
|
||||
*
|
||||
* @param {Object} node The htmlparser2 node to convert
|
||||
* @param {Number} index The index of the current node
|
||||
* @param {Function} transform Transform function to apply to children of the node
|
||||
* @returns {React.Element}
|
||||
*/
|
||||
export function convertNodeToReactElement(
|
||||
node: Node,
|
||||
index: string | number,
|
||||
transform?: NodeToReactElementTransformer
|
||||
): ReactElement | string | null {
|
||||
switch (node.type) {
|
||||
case ElementType.Text:
|
||||
return processTextNode(node)
|
||||
case ElementType.Tag:
|
||||
return processTagNode(node, index, transform)
|
||||
case ElementType.Style:
|
||||
return processStyleNode(node, index)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
44
html-to-react/src/dom/attributes/booleanAttributes.ts
Normal file
44
html-to-react/src/dom/attributes/booleanAttributes.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* List of boolean attributes
|
||||
* These attributes should have their React attribute value set to be the same as their name
|
||||
* E.g. <input disabled> = <input disabled>
|
||||
* <input disabled=""> = <input disabled>
|
||||
* <input disabled="disabled"> = <input disabled>
|
||||
* @type {Array}
|
||||
*/
|
||||
const booleanAttributes: ReadonlySet<string> = new Set([
|
||||
'allowfullscreen',
|
||||
'async',
|
||||
'autofocus',
|
||||
'autoplay',
|
||||
'checked',
|
||||
'controls',
|
||||
'default',
|
||||
'defer',
|
||||
'disabled',
|
||||
'disablepictureinpicture',
|
||||
'disableremoteplayback',
|
||||
'formnovalidate',
|
||||
'hidden',
|
||||
'itemscope',
|
||||
'loop',
|
||||
'multiple',
|
||||
'muted',
|
||||
'nomodule',
|
||||
'novalidate',
|
||||
'open',
|
||||
'playsinline',
|
||||
'readonly',
|
||||
'required',
|
||||
'reversed',
|
||||
'scoped',
|
||||
'seamless',
|
||||
'selected'
|
||||
])
|
||||
export default booleanAttributes
|
315
html-to-react/src/dom/attributes/reactAttributes.ts
Normal file
315
html-to-react/src/dom/attributes/reactAttributes.ts
Normal file
|
@ -0,0 +1,315 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mapping of standard HTML attributes to their React counterparts
|
||||
* List taken and reversed from
|
||||
* https://github.com/facebook/react/blob/7a5b8227c7d67aefe62f015cf0e961e28075d897/packages/react-dom/src/shared/possibleStandardNames.js
|
||||
* with identity-mapped elements removed
|
||||
*/
|
||||
const reactAttributes: Record<string, string> = {
|
||||
acceptcharset: 'acceptCharset',
|
||||
'accept-charset': 'acceptCharset',
|
||||
accesskey: 'accessKey',
|
||||
allowfullscreen: 'allowFullScreen',
|
||||
autocapitalize: 'autoCapitalize',
|
||||
autocomplete: 'autoComplete',
|
||||
autocorrect: 'autoCorrect',
|
||||
autofocus: 'autoFocus',
|
||||
autoplay: 'autoPlay',
|
||||
autosave: 'autoSave',
|
||||
cellpadding: 'cellPadding',
|
||||
cellspacing: 'cellSpacing',
|
||||
charset: 'charSet',
|
||||
class: 'className',
|
||||
classid: 'classID',
|
||||
classname: 'className',
|
||||
colspan: 'colSpan',
|
||||
contenteditable: 'contentEditable',
|
||||
contextmenu: 'contextMenu',
|
||||
controlslist: 'controlsList',
|
||||
crossorigin: 'crossOrigin',
|
||||
dangerouslysetinnerhtml: 'dangerouslySetInnerHTML',
|
||||
datetime: 'dateTime',
|
||||
defaultchecked: 'defaultChecked',
|
||||
defaultvalue: 'defaultValue',
|
||||
disablepictureinpicture: 'disablePictureInPicture',
|
||||
disableremoteplayback: 'disableRemotePlayback',
|
||||
enctype: 'encType',
|
||||
enterkeyhint: 'enterKeyHint',
|
||||
for: 'htmlFor',
|
||||
formmethod: 'formMethod',
|
||||
formaction: 'formAction',
|
||||
formenctype: 'formEncType',
|
||||
formnovalidate: 'formNoValidate',
|
||||
formtarget: 'formTarget',
|
||||
frameborder: 'frameBorder',
|
||||
hreflang: 'hrefLang',
|
||||
htmlfor: 'htmlFor',
|
||||
httpequiv: 'httpEquiv',
|
||||
'http-equiv': 'httpEquiv',
|
||||
imagesizes: 'imageSizes',
|
||||
imagesrcset: 'imageSrcSet',
|
||||
innerhtml: 'innerHTML',
|
||||
inputmode: 'inputMode',
|
||||
itemid: 'itemID',
|
||||
itemprop: 'itemProp',
|
||||
itemref: 'itemRef',
|
||||
itemscope: 'itemScope',
|
||||
itemtype: 'itemType',
|
||||
keyparams: 'keyParams',
|
||||
keytype: 'keyType',
|
||||
marginwidth: 'marginWidth',
|
||||
marginheight: 'marginHeight',
|
||||
maxlength: 'maxLength',
|
||||
mediagroup: 'mediaGroup',
|
||||
minlength: 'minLength',
|
||||
nomodule: 'noModule',
|
||||
novalidate: 'noValidate',
|
||||
playsinline: 'playsInline',
|
||||
radiogroup: 'radioGroup',
|
||||
readonly: 'readOnly',
|
||||
referrerpolicy: 'referrerPolicy',
|
||||
rowspan: 'rowSpan',
|
||||
spellcheck: 'spellCheck',
|
||||
srcdoc: 'srcDoc',
|
||||
srclang: 'srcLang',
|
||||
srcset: 'srcSet',
|
||||
tabindex: 'tabIndex',
|
||||
usemap: 'useMap',
|
||||
accentheight: 'accentHeight',
|
||||
'accent-height': 'accentHeight',
|
||||
alignmentbaseline: 'alignmentBaseline',
|
||||
'alignment-baseline': 'alignmentBaseline',
|
||||
allowreorder: 'allowReorder',
|
||||
arabicform: 'arabicForm',
|
||||
'arabic-form': 'arabicForm',
|
||||
attributename: 'attributeName',
|
||||
attributetype: 'attributeType',
|
||||
autoreverse: 'autoReverse',
|
||||
basefrequency: 'baseFrequency',
|
||||
baselineshift: 'baselineShift',
|
||||
'baseline-shift': 'baselineShift',
|
||||
baseprofile: 'baseProfile',
|
||||
calcmode: 'calcMode',
|
||||
capheight: 'capHeight',
|
||||
'cap-height': 'capHeight',
|
||||
clippath: 'clipPath',
|
||||
'clip-path': 'clipPath',
|
||||
clippathunits: 'clipPathUnits',
|
||||
cliprule: 'clipRule',
|
||||
'clip-rule': 'clipRule',
|
||||
colorinterpolation: 'colorInterpolation',
|
||||
'color-interpolation': 'colorInterpolation',
|
||||
colorinterpolationfilters: 'colorInterpolationFilters',
|
||||
'color-interpolation-filters': 'colorInterpolationFilters',
|
||||
colorprofile: 'colorProfile',
|
||||
'color-profile': 'colorProfile',
|
||||
colorrendering: 'colorRendering',
|
||||
'color-rendering': 'colorRendering',
|
||||
contentscripttype: 'contentScriptType',
|
||||
contentstyletype: 'contentStyleType',
|
||||
diffuseconstant: 'diffuseConstant',
|
||||
dominantbaseline: 'dominantBaseline',
|
||||
'dominant-baseline': 'dominantBaseline',
|
||||
edgemode: 'edgeMode',
|
||||
enablebackground: 'enableBackground',
|
||||
'enable-background': 'enableBackground',
|
||||
externalresourcesrequired: 'externalResourcesRequired',
|
||||
fillopacity: 'fillOpacity',
|
||||
'fill-opacity': 'fillOpacity',
|
||||
fillrule: 'fillRule',
|
||||
'fill-rule': 'fillRule',
|
||||
filterres: 'filterRes',
|
||||
filterunits: 'filterUnits',
|
||||
floodopacity: 'floodOpacity',
|
||||
'flood-opacity': 'floodOpacity',
|
||||
floodcolor: 'floodColor',
|
||||
'flood-color': 'floodColor',
|
||||
fontfamily: 'fontFamily',
|
||||
'font-family': 'fontFamily',
|
||||
fontsize: 'fontSize',
|
||||
'font-size': 'fontSize',
|
||||
fontsizeadjust: 'fontSizeAdjust',
|
||||
'font-size-adjust': 'fontSizeAdjust',
|
||||
fontstretch: 'fontStretch',
|
||||
'font-stretch': 'fontStretch',
|
||||
fontstyle: 'fontStyle',
|
||||
'font-style': 'fontStyle',
|
||||
fontvariant: 'fontVariant',
|
||||
'font-variant': 'fontVariant',
|
||||
fontweight: 'fontWeight',
|
||||
'font-weight': 'fontWeight',
|
||||
glyphname: 'glyphName',
|
||||
'glyph-name': 'glyphName',
|
||||
glyphorientationhorizontal: 'glyphOrientationHorizontal',
|
||||
'glyph-orientation-horizontal': 'glyphOrientationHorizontal',
|
||||
glyphorientationvertical: 'glyphOrientationVertical',
|
||||
'glyph-orientation-vertical': 'glyphOrientationVertical',
|
||||
glyphref: 'glyphRef',
|
||||
gradienttransform: 'gradientTransform',
|
||||
gradientunits: 'gradientUnits',
|
||||
horizadvx: 'horizAdvX',
|
||||
'horiz-adv-x': 'horizAdvX',
|
||||
horizoriginx: 'horizOriginX',
|
||||
'horiz-origin-x': 'horizOriginX',
|
||||
imagerendering: 'imageRendering',
|
||||
'image-rendering': 'imageRendering',
|
||||
kernelmatrix: 'kernelMatrix',
|
||||
kernelunitlength: 'kernelUnitLength',
|
||||
keypoints: 'keyPoints',
|
||||
keysplines: 'keySplines',
|
||||
keytimes: 'keyTimes',
|
||||
lengthadjust: 'lengthAdjust',
|
||||
letterspacing: 'letterSpacing',
|
||||
'letter-spacing': 'letterSpacing',
|
||||
lightingcolor: 'lightingColor',
|
||||
'lighting-color': 'lightingColor',
|
||||
limitingconeangle: 'limitingConeAngle',
|
||||
markerend: 'markerEnd',
|
||||
'marker-end': 'markerEnd',
|
||||
markerheight: 'markerHeight',
|
||||
markermid: 'markerMid',
|
||||
'marker-mid': 'markerMid',
|
||||
markerstart: 'markerStart',
|
||||
'marker-start': 'markerStart',
|
||||
markerunits: 'markerUnits',
|
||||
markerwidth: 'markerWidth',
|
||||
maskcontentunits: 'maskContentUnits',
|
||||
maskunits: 'maskUnits',
|
||||
numoctaves: 'numOctaves',
|
||||
overlineposition: 'overlinePosition',
|
||||
'overline-position': 'overlinePosition',
|
||||
overlinethickness: 'overlineThickness',
|
||||
'overline-thickness': 'overlineThickness',
|
||||
paintorder: 'paintOrder',
|
||||
'paint-order': 'paintOrder',
|
||||
'panose-1': 'panose1',
|
||||
pathlength: 'pathLength',
|
||||
patterncontentunits: 'patternContentUnits',
|
||||
patterntransform: 'patternTransform',
|
||||
patternunits: 'patternUnits',
|
||||
pointerevents: 'pointerEvents',
|
||||
'pointer-events': 'pointerEvents',
|
||||
pointsatx: 'pointsAtX',
|
||||
pointsaty: 'pointsAtY',
|
||||
pointsatz: 'pointsAtZ',
|
||||
preservealpha: 'preserveAlpha',
|
||||
preserveaspectratio: 'preserveAspectRatio',
|
||||
primitiveunits: 'primitiveUnits',
|
||||
refx: 'refX',
|
||||
refy: 'refY',
|
||||
renderingintent: 'renderingIntent',
|
||||
'rendering-intent': 'renderingIntent',
|
||||
repeatcount: 'repeatCount',
|
||||
repeatdur: 'repeatDur',
|
||||
requiredextensions: 'requiredExtensions',
|
||||
requiredfeatures: 'requiredFeatures',
|
||||
shaperendering: 'shapeRendering',
|
||||
'shape-rendering': 'shapeRendering',
|
||||
specularconstant: 'specularConstant',
|
||||
specularexponent: 'specularExponent',
|
||||
spreadmethod: 'spreadMethod',
|
||||
startoffset: 'startOffset',
|
||||
stddeviation: 'stdDeviation',
|
||||
stitchtiles: 'stitchTiles',
|
||||
stopcolor: 'stopColor',
|
||||
'stop-color': 'stopColor',
|
||||
stopopacity: 'stopOpacity',
|
||||
'stop-opacity': 'stopOpacity',
|
||||
strikethroughposition: 'strikethroughPosition',
|
||||
'strikethrough-position': 'strikethroughPosition',
|
||||
strikethroughthickness: 'strikethroughThickness',
|
||||
'strikethrough-thickness': 'strikethroughThickness',
|
||||
strokedasharray: 'strokeDasharray',
|
||||
'stroke-dasharray': 'strokeDasharray',
|
||||
strokedashoffset: 'strokeDashoffset',
|
||||
'stroke-dashoffset': 'strokeDashoffset',
|
||||
strokelinecap: 'strokeLinecap',
|
||||
'stroke-linecap': 'strokeLinecap',
|
||||
strokelinejoin: 'strokeLinejoin',
|
||||
'stroke-linejoin': 'strokeLinejoin',
|
||||
strokemiterlimit: 'strokeMiterlimit',
|
||||
'stroke-miterlimit': 'strokeMiterlimit',
|
||||
strokewidth: 'strokeWidth',
|
||||
'stroke-width': 'strokeWidth',
|
||||
strokeopacity: 'strokeOpacity',
|
||||
'stroke-opacity': 'strokeOpacity',
|
||||
suppresscontenteditablewarning: 'suppressContentEditableWarning',
|
||||
suppresshydrationwarning: 'suppressHydrationWarning',
|
||||
surfacescale: 'surfaceScale',
|
||||
systemlanguage: 'systemLanguage',
|
||||
tablevalues: 'tableValues',
|
||||
targetx: 'targetX',
|
||||
targety: 'targetY',
|
||||
textanchor: 'textAnchor',
|
||||
'text-anchor': 'textAnchor',
|
||||
textdecoration: 'textDecoration',
|
||||
'text-decoration': 'textDecoration',
|
||||
textlength: 'textLength',
|
||||
textrendering: 'textRendering',
|
||||
'text-rendering': 'textRendering',
|
||||
underlineposition: 'underlinePosition',
|
||||
'underline-position': 'underlinePosition',
|
||||
underlinethickness: 'underlineThickness',
|
||||
'underline-thickness': 'underlineThickness',
|
||||
unicodebidi: 'unicodeBidi',
|
||||
'unicode-bidi': 'unicodeBidi',
|
||||
unicoderange: 'unicodeRange',
|
||||
'unicode-range': 'unicodeRange',
|
||||
unitsperem: 'unitsPerEm',
|
||||
'units-per-em': 'unitsPerEm',
|
||||
valphabetic: 'vAlphabetic',
|
||||
'v-alphabetic': 'vAlphabetic',
|
||||
vectoreffect: 'vectorEffect',
|
||||
'vector-effect': 'vectorEffect',
|
||||
vertadvy: 'vertAdvY',
|
||||
'vert-adv-y': 'vertAdvY',
|
||||
vertoriginx: 'vertOriginX',
|
||||
'vert-origin-x': 'vertOriginX',
|
||||
vertoriginy: 'vertOriginY',
|
||||
'vert-origin-y': 'vertOriginY',
|
||||
vhanging: 'vHanging',
|
||||
'v-hanging': 'vHanging',
|
||||
videographic: 'vIdeographic',
|
||||
'v-ideographic': 'vIdeographic',
|
||||
viewbox: 'viewBox',
|
||||
viewtarget: 'viewTarget',
|
||||
vmathematical: 'vMathematical',
|
||||
'v-mathematical': 'vMathematical',
|
||||
wordspacing: 'wordSpacing',
|
||||
'word-spacing': 'wordSpacing',
|
||||
writingmode: 'writingMode',
|
||||
'writing-mode': 'writingMode',
|
||||
xchannelselector: 'xChannelSelector',
|
||||
xheight: 'xHeight',
|
||||
'x-height': 'xHeight',
|
||||
xlinkactuate: 'xlinkActuate',
|
||||
'xlink:actuate': 'xlinkActuate',
|
||||
xlinkarcrole: 'xlinkArcrole',
|
||||
'xlink:arcrole': 'xlinkArcrole',
|
||||
xlinkhref: 'xlinkHref',
|
||||
'xlink:href': 'xlinkHref',
|
||||
xlinkrole: 'xlinkRole',
|
||||
'xlink:role': 'xlinkRole',
|
||||
xlinkshow: 'xlinkShow',
|
||||
'xlink:show': 'xlinkShow',
|
||||
xlinktitle: 'xlinkTitle',
|
||||
'xlink:title': 'xlinkTitle',
|
||||
xlinktype: 'xlinkType',
|
||||
'xlink:type': 'xlinkType',
|
||||
xmlbase: 'xmlBase',
|
||||
'xml:base': 'xmlBase',
|
||||
xmllang: 'xmlLang',
|
||||
'xml:lang': 'xmlLang',
|
||||
'xml:space': 'xmlSpace',
|
||||
xmlnsxlink: 'xmlnsXlink',
|
||||
'xmlns:xlink': 'xmlnsXlink',
|
||||
xmlspace: 'xmlSpace',
|
||||
ychannelselector: 'yChannelSelector',
|
||||
zoomandpan: 'zoomAndPan'
|
||||
}
|
||||
export default reactAttributes
|
29
html-to-react/src/dom/elements/VoidElements.ts
Normal file
29
html-to-react/src/dom/elements/VoidElements.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* List of void elements
|
||||
* These elements are not allowed to have children
|
||||
* @type {Array}
|
||||
*/
|
||||
export const VOID_ELEMENTS = [
|
||||
'area',
|
||||
'base',
|
||||
'br',
|
||||
'col',
|
||||
'command',
|
||||
'embed',
|
||||
'hr',
|
||||
'img',
|
||||
'input',
|
||||
'keygen',
|
||||
'link',
|
||||
'meta',
|
||||
'param',
|
||||
'source',
|
||||
'track',
|
||||
'wbr'
|
||||
]
|
41
html-to-react/src/elementTypes/ProcessStyleNode.ts
Normal file
41
html-to-react/src/elementTypes/ProcessStyleNode.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { ReactElement } from 'react'
|
||||
import { generatePropsFromAttributes } from '../utils/generatePropsFromAttributes.js'
|
||||
import { isText } from 'domhandler'
|
||||
import { isTag, Node } from 'domhandler'
|
||||
|
||||
/**
|
||||
* Converts a <style> element to a React element
|
||||
*
|
||||
* @param {Object} node The style node
|
||||
* @param {String} index The index of the React element relative to it's parent
|
||||
* @returns {React.Element} The React style element
|
||||
*/
|
||||
export function processStyleNode(
|
||||
node: Node,
|
||||
index: number | string
|
||||
): ReactElement | null {
|
||||
if (!isTag(node)) {
|
||||
return null
|
||||
}
|
||||
// The style element only ever has a single child which is the styles so try and find this to add as
|
||||
// a child to the style element that will be created
|
||||
let styles
|
||||
if (node.children.length > 0) {
|
||||
const subNode = node.children[0]
|
||||
if (isText(subNode)) {
|
||||
styles = subNode.data
|
||||
}
|
||||
}
|
||||
|
||||
// generate props
|
||||
const props = generatePropsFromAttributes(node.attribs, index)
|
||||
|
||||
// create and return the element
|
||||
return React.createElement('style', props, styles)
|
||||
}
|
49
html-to-react/src/elementTypes/ProcessTagNode.ts
Normal file
49
html-to-react/src/elementTypes/ProcessTagNode.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { ReactElement } from 'react'
|
||||
import { processNodes } from '../processNodes.js'
|
||||
import { generatePropsFromAttributes } from '../utils/generatePropsFromAttributes.js'
|
||||
import { isValidTagOrAttributeName } from '../utils/isValidTagOrAttributeName.js'
|
||||
import { isTag, Node } from 'domhandler'
|
||||
import { VOID_ELEMENTS } from '../dom/elements/VoidElements.js'
|
||||
import { NodeToReactElementTransformer } from '../NodeToReactElementTransformer.js'
|
||||
|
||||
/**
|
||||
* Converts any element (excluding style - see StyleElementType - and script) to a react element.
|
||||
*
|
||||
* @param {Object} node The tag node
|
||||
* @param {String} index The index of the React element relative to it's parent
|
||||
* @param {Function} transform The transform function to apply to all children
|
||||
* @returns {React.Element} The React tag element
|
||||
*/
|
||||
export function processTagNode(
|
||||
node: Node,
|
||||
index: number | string,
|
||||
transform?: NodeToReactElementTransformer
|
||||
): ReactElement | null {
|
||||
if (!isTag(node)) {
|
||||
return null
|
||||
}
|
||||
const tagName = node.tagName
|
||||
|
||||
// validate tag name
|
||||
if (!isValidTagOrAttributeName(tagName)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// generate props
|
||||
const props = generatePropsFromAttributes(node.attribs, index)
|
||||
|
||||
// If the node is not a void element and has children then process them
|
||||
let children = null
|
||||
if (VOID_ELEMENTS.indexOf(tagName) === -1) {
|
||||
children = processNodes(node.children, transform)
|
||||
}
|
||||
|
||||
// create and return the element
|
||||
return React.createElement(tagName, props, children)
|
||||
}
|
19
html-to-react/src/elementTypes/ProcessTextNode.ts
Normal file
19
html-to-react/src/elementTypes/ProcessTextNode.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Node } from 'domhandler'
|
||||
import { isText } from 'domhandler'
|
||||
|
||||
/**
|
||||
* Converts a text node to a React text element
|
||||
*
|
||||
* @param {Object} node The text node
|
||||
* @returns {String} The text
|
||||
*/
|
||||
export function processTextNode(node: Node): string | null {
|
||||
// React will accept plain text for rendering so just return the node data
|
||||
return isText(node) ? node.data : null
|
||||
}
|
174
html-to-react/src/index.spec.tsx
Normal file
174
html-to-react/src/index.spec.tsx
Normal file
|
@ -0,0 +1,174 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { convertHtmlToReact, ParserOptions } from './convertHtmlToReact.js'
|
||||
import { convertNodeToReactElement } from './convertNodeToReactElement.js'
|
||||
import { Document, isTag, isText } from 'domhandler'
|
||||
import { NodeToReactElementTransformer } from './NodeToReactElementTransformer.js'
|
||||
import React, { ReactElement } from 'react'
|
||||
import { describe, expect, it } from '@jest/globals'
|
||||
|
||||
const expectSameHtml = function (html: string, options: ParserOptions = {}) {
|
||||
const actual = renderToStaticMarkup(<div>{convertHtmlToReact(html, options)}</div>)
|
||||
const expected = `<div>${html}</div>`
|
||||
expect(actual).toBe(expected)
|
||||
}
|
||||
|
||||
const expectOtherHtml = function (html: string, override: string, options: ParserOptions = {}) {
|
||||
const actual = renderToStaticMarkup(<div>{convertHtmlToReact(html, options)}</div>)
|
||||
const expected = `<div>${override}</div>`
|
||||
expect(actual).toBe(expected)
|
||||
}
|
||||
|
||||
describe('Integration tests: ', () => {
|
||||
it('should render a simple element', () => {
|
||||
expectSameHtml('<div>test</div>')
|
||||
})
|
||||
|
||||
it('should render multiple sibling elements', () => {
|
||||
expectSameHtml('<div>test1</div><span>test2</span><footer>test3</footer>')
|
||||
})
|
||||
|
||||
it('should render nested elements', () => {
|
||||
expectSameHtml('<div><span>test1</span><div><ul><li>test2</li><li>test3</li></ul></div></div>')
|
||||
})
|
||||
|
||||
it('should handle bad html', () => {
|
||||
expectOtherHtml(
|
||||
'<div class=test>test<ul><li>test1<li>test2</ul><span>test</span></div>',
|
||||
'<div class="test">test<ul><li>test1</li><li>test2</li></ul><span>test</span></div>'
|
||||
)
|
||||
})
|
||||
|
||||
it('should ignore doctypes', () => {
|
||||
expectOtherHtml('<!doctype html><div>test</div>', '<div>test</div>')
|
||||
})
|
||||
|
||||
it('should ignore comments', () => {
|
||||
expectOtherHtml('<div>test1</div><!-- comment --><div>test2</div>', '<div>test1</div><div>test2</div>')
|
||||
})
|
||||
|
||||
it('should ignore script tags', () => {
|
||||
expectOtherHtml('<script>alert(1)</script>', '')
|
||||
})
|
||||
|
||||
it('should ignore event handlers', () => {
|
||||
expectOtherHtml('<a href="#" onclick="alert(1)">test</a>', '<a href="#">test</a>')
|
||||
})
|
||||
|
||||
it('should handle attributes', () => {
|
||||
expectSameHtml('<div class="test" id="test" aria-valuetext="test" data-test="test">test</div>')
|
||||
})
|
||||
|
||||
it('should handle inline styles', () => {
|
||||
expectSameHtml('<div style="border-radius:1px;background:red">test</div>')
|
||||
})
|
||||
|
||||
it('should ignore inline styles that are empty strings', () => {
|
||||
expectOtherHtml('<div style="">test</div>', '<div>test</div>')
|
||||
})
|
||||
|
||||
it('should not allow nesting of void elements', () => {
|
||||
expectOtherHtml('<input><p>test</p></input>', '<input/><p>test</p>')
|
||||
})
|
||||
|
||||
it('should convert boolean attribute values', () => {
|
||||
expectOtherHtml('<input disabled>', '<input disabled=""/>')
|
||||
expectOtherHtml('<input disabled="">', '<input disabled=""/>')
|
||||
expectOtherHtml('<input disabled="disabled">', '<input disabled=""/>')
|
||||
})
|
||||
;[
|
||||
['CONTENTEDITABLE', 'contentEditable'],
|
||||
['LABEL', 'label'],
|
||||
['iTemREF', 'itemRef']
|
||||
].forEach(([attr, prop]) => {
|
||||
it(`should convert attribute ${attr} to prop ${prop}`, () => {
|
||||
const nodes = convertHtmlToReact(`<div ${attr}/>`, {})
|
||||
expect(nodes).toHaveLength(1)
|
||||
expect((nodes[0] as ReactElement).props).toHaveProperty(prop)
|
||||
})
|
||||
})
|
||||
|
||||
it('should decode html entities by default', () => {
|
||||
expectOtherHtml('<span>!</span>', '<span>!</span>')
|
||||
})
|
||||
|
||||
it('should not decode html entities when the option is disabled', () => {
|
||||
expectOtherHtml('<span>!</span>', '<span>&excl;</span>', {
|
||||
decodeEntities: false
|
||||
})
|
||||
})
|
||||
|
||||
describe('transform function', () => {
|
||||
it('should use the response when it is not undefined', () => {
|
||||
expectOtherHtml('<span>test</span><div>another</div>', '<p>transformed</p><p>transformed</p>', {
|
||||
transform(node, index) {
|
||||
return <p key={index}>transformed</p>
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should not render elements and children when returning null', () => {
|
||||
expectOtherHtml('<p>test<span>inner test<b>bold child</b></span></p>', '<p>test</p>', {
|
||||
transform(node) {
|
||||
if (isTag(node) && node.type === 'tag' && node.name === 'span') {
|
||||
return null
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow modifying nodes', () => {
|
||||
expectOtherHtml('<a href="/test">test link</a>', '<a href="/changed">test link</a>', {
|
||||
transform(node, index) {
|
||||
if (isTag(node)) {
|
||||
node.attribs.href = '/changed'
|
||||
}
|
||||
return convertNodeToReactElement(node, index)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow passing the transform function down to children', () => {
|
||||
const transform: NodeToReactElementTransformer = (node, index) => {
|
||||
if (isTag(node)) {
|
||||
if (node.name === 'ul') {
|
||||
node.attribs.class = 'test'
|
||||
return convertNodeToReactElement(node, index, transform)
|
||||
}
|
||||
} else if (isText(node)) {
|
||||
return node.data.replace(/list/, 'changed')
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
expectOtherHtml(
|
||||
'<ul><li>list 1</li><li>list 2</li></ul>',
|
||||
'<ul class="test"><li>changed 1</li><li>changed 2</li></ul>',
|
||||
{
|
||||
transform
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not render invalid tags', () => {
|
||||
expectOtherHtml('<div>test<test</div>', '<div>test</div>')
|
||||
})
|
||||
|
||||
it('should not render invalid attributes', () => {
|
||||
expectOtherHtml('<div data-test<="test" class="test">content</div>', '<div class="test">content</div>')
|
||||
})
|
||||
|
||||
it('should preprocess nodes correctly', () => {
|
||||
expectOtherHtml('<div>preprocess test</div>', '<div>preprocess test</div><div>preprocess test</div>', {
|
||||
preprocessNodes(document) {
|
||||
return new Document([...document.childNodes, ...document.childNodes])
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
9
html-to-react/src/index.ts
Normal file
9
html-to-react/src/index.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
export { convertHtmlToReact, ParserOptions } from './convertHtmlToReact.js'
|
||||
export { convertNodeToReactElement } from './convertNodeToReactElement.js'
|
||||
export type { NodeToReactElementTransformer } from './NodeToReactElementTransformer.js'
|
||||
export { processNodes } from './processNodes.js'
|
32
html-to-react/src/processNodes.ts
Normal file
32
html-to-react/src/processNodes.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { convertNodeToReactElement } from './convertNodeToReactElement.js'
|
||||
import { Node } from 'domhandler'
|
||||
import { ReactElement } from 'react'
|
||||
import { NodeToReactElementTransformer } from './NodeToReactElementTransformer.js'
|
||||
|
||||
/**
|
||||
* Processes the nodes generated by htmlparser2 and convert them all into React elements
|
||||
*
|
||||
* @param {Object[]} nodes List of nodes to process
|
||||
* @param {Function} transform Transform function to optionally apply to nodes
|
||||
* @returns {React.Element[]} The list of processed React elements
|
||||
*/
|
||||
export function processNodes(
|
||||
nodes: Node[],
|
||||
transform?: NodeToReactElementTransformer
|
||||
): (ReactElement | string | null)[] {
|
||||
return nodes.map((node, index) => {
|
||||
if (transform) {
|
||||
const transformed = transform(node, index)
|
||||
if (transformed === null || !!transformed) {
|
||||
return transformed
|
||||
}
|
||||
}
|
||||
return convertNodeToReactElement(node, index, transform)
|
||||
})
|
||||
}
|
50
html-to-react/src/utils/convertInlineStyleToMap.ts
Normal file
50
html-to-react/src/utils/convertInlineStyleToMap.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* Converts an inline style string into an object of React style properties
|
||||
*
|
||||
* @param {String} inlineStyle='' The inline style to convert
|
||||
* @returns {Object} The converted style
|
||||
*/
|
||||
export function convertInlineStyleToMap(
|
||||
inlineStyle = ''
|
||||
): Record<string, string> {
|
||||
if (inlineStyle === '') {
|
||||
return {}
|
||||
}
|
||||
|
||||
return inlineStyle.split(';').reduce(
|
||||
(styleObject, stylePropertyValue) => {
|
||||
// extract the style property name and value
|
||||
const [property, value] = stylePropertyValue
|
||||
.split(/^([^:]+):/)
|
||||
.filter((val, i) => i > 0)
|
||||
.map((item) => item.trim())
|
||||
|
||||
// if there is no value (i.e. no : in the style) then ignore it
|
||||
if (value === undefined) {
|
||||
return styleObject
|
||||
}
|
||||
|
||||
// convert the property name into the correct React format
|
||||
// remove all hyphens and convert the letter immediately after each hyphen to upper case
|
||||
// additionally don't uppercase any -ms- prefix
|
||||
// e.g. -ms-style-property = msStyleProperty
|
||||
// -webkit-style-property = WebkitStyleProperty
|
||||
const replacedProperty = property
|
||||
.toLowerCase()
|
||||
.replace(/^-ms-/, 'ms-')
|
||||
.replace(/-(.)/g, (_, character) => character.toUpperCase())
|
||||
|
||||
// add the new style property and value to the style object
|
||||
styleObject[replacedProperty] = value
|
||||
|
||||
return styleObject
|
||||
},
|
||||
{} as Record<string, string>
|
||||
)
|
||||
}
|
34
html-to-react/src/utils/generatePropsFromAttributes.ts
Normal file
34
html-to-react/src/utils/generatePropsFromAttributes.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { mapHtmlAttributesToReactElementAttributes } from './mapHtmlAttributesToReactElementAttributes.js'
|
||||
import { convertInlineStyleToMap } from './convertInlineStyleToMap.js'
|
||||
|
||||
/**
|
||||
* Generates props for a React element from an object of HTML attributes
|
||||
*
|
||||
* @param {Object} attributes The HTML attributes
|
||||
* @param {String} key The key to give the react element
|
||||
*/
|
||||
export function generatePropsFromAttributes(
|
||||
attributes: Record<string, string>,
|
||||
key: string | number
|
||||
): Record<string, string | Record<string, string>> {
|
||||
const props = Object.assign(
|
||||
{ key },
|
||||
mapHtmlAttributesToReactElementAttributes(attributes)
|
||||
) as Record<string, string | Record<string, string>>
|
||||
|
||||
if (props.style) {
|
||||
if (typeof props.style === 'string') {
|
||||
props.style = convertInlineStyleToMap(props.style)
|
||||
}
|
||||
} else {
|
||||
delete props.style
|
||||
}
|
||||
|
||||
return props
|
||||
}
|
15
html-to-react/src/utils/isValidTagOrAttributeName.ts
Normal file
15
html-to-react/src/utils/isValidTagOrAttributeName.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
const VALID_TAG_REGEX = /^[a-zA-Z][a-zA-Z:_.\-\d]*$/
|
||||
const nameCache: Record<string, boolean> = {}
|
||||
|
||||
export function isValidTagOrAttributeName(tagName: string): boolean {
|
||||
if (!(tagName in nameCache)) {
|
||||
nameCache[tagName] = VALID_TAG_REGEX.test(tagName)
|
||||
}
|
||||
return nameCache[tagName]
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import booleanAttributes from '../dom/attributes/booleanAttributes.js'
|
||||
import reactAttributes from '../dom/attributes/reactAttributes.js'
|
||||
import { isValidTagOrAttributeName } from './isValidTagOrAttributeName.js'
|
||||
|
||||
/**
|
||||
* Returns the parsed attribute value taking into account things like boolean attributes
|
||||
*
|
||||
* @param {string} attribute The name of the attribute
|
||||
* @param {string} value The value of the attribute from the HTML
|
||||
* @returns {string} The parsed attribute value
|
||||
*/
|
||||
function getParsedAttributeValue(attribute: string, value: string): string {
|
||||
if (booleanAttributes.has(attribute.toLowerCase())) {
|
||||
value = attribute
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Don't pass through event handler attributes at all (on...)
|
||||
* This is the same heuristic used by React:
|
||||
* https://github.com/facebook/react/blob/7a5b8227c7/packages/react-dom/src/shared/ReactDOMUnknownPropertyHook.js#L23
|
||||
* @param {string} attribute The attribute name to check
|
||||
*/
|
||||
function isEventHandlerAttribute(attribute: string): boolean {
|
||||
return attribute.startsWith('on')
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes an object of standard HTML property names and converts them to their React counterpart. If the react
|
||||
* version does not exist for an attribute then just use it as it is
|
||||
*
|
||||
* @param {Object} attributes The HTML attributes to convert
|
||||
* @returns {Object} The React attributes
|
||||
*/
|
||||
export function mapHtmlAttributesToReactElementAttributes(
|
||||
attributes: Record<string, string>
|
||||
): Record<string, string> {
|
||||
return Object.keys(attributes)
|
||||
.filter(
|
||||
(attribute) =>
|
||||
!isEventHandlerAttribute(attribute) &&
|
||||
isValidTagOrAttributeName(attribute)
|
||||
)
|
||||
.reduce(
|
||||
(mappedAttributes, attribute) => {
|
||||
// lowercase the attribute name and find it in the react attribute map
|
||||
const lowerCaseAttribute = attribute.toLowerCase()
|
||||
|
||||
// format the attribute name
|
||||
const name = reactAttributes[lowerCaseAttribute] || attribute
|
||||
|
||||
// add the parsed attribute value to the mapped attributes
|
||||
mappedAttributes[name] = getParsedAttributeValue(
|
||||
name,
|
||||
attributes[attribute]
|
||||
)
|
||||
|
||||
return mappedAttributes
|
||||
},
|
||||
{} as Record<string, string>
|
||||
)
|
||||
}
|
20
html-to-react/tsconfig.base.json
Normal file
20
html-to-react/tsconfig.base.json
Normal file
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"removeComments": true,
|
||||
"preserveConstEnums": true,
|
||||
"lib": [
|
||||
"es2022",
|
||||
"dom"
|
||||
],
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"esModuleInterop": true,
|
||||
"allowJs": true,
|
||||
"sourceMap": true,
|
||||
"jsx": "react"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["dist", "**/*.test.ts"]
|
||||
}
|
3
html-to-react/tsconfig.base.json.license
Normal file
3
html-to-react/tsconfig.base.json.license
Normal file
|
@ -0,0 +1,3 @@
|
|||
SPDX-FileCopyrightText: 2023 Tilman Vatteroth
|
||||
|
||||
SPDX-License-Identifier: CC0-1.0
|
10
html-to-react/tsconfig.cjs.json
Normal file
10
html-to-react/tsconfig.cjs.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends" : "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"target": "ES2015",
|
||||
"outDir": "dist/cjs",
|
||||
"declarationDir": "dist/cjs",
|
||||
"moduleResolution": "node"
|
||||
}
|
||||
}
|
3
html-to-react/tsconfig.cjs.json.license
Normal file
3
html-to-react/tsconfig.cjs.json.license
Normal file
|
@ -0,0 +1,3 @@
|
|||
SPDX-FileCopyrightText: 2023 Tilman Vatteroth
|
||||
|
||||
SPDX-License-Identifier: CC0-1.0
|
10
html-to-react/tsconfig.esm.json
Normal file
10
html-to-react/tsconfig.esm.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends" : "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"target" : "esnext",
|
||||
"outDir": "dist/esm",
|
||||
"moduleResolution": "NodeNext",
|
||||
"declarationDir": "dist/esm"
|
||||
}
|
||||
}
|
3
html-to-react/tsconfig.esm.json.license
Normal file
3
html-to-react/tsconfig.esm.json.license
Normal file
|
@ -0,0 +1,3 @@
|
|||
SPDX-FileCopyrightText: 2023 Tilman Vatteroth
|
||||
|
||||
SPDX-License-Identifier: CC0-1.0
|
4
html-to-react/tsconfig.test.json
Normal file
4
html-to-react/tsconfig.test.json
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends" : "./tsconfig.esm.json",
|
||||
"exclude": ["dist"]
|
||||
}
|
3
html-to-react/tsconfig.test.json.license
Normal file
3
html-to-react/tsconfig.test.json.license
Normal file
|
@ -0,0 +1,3 @@
|
|||
SPDX-FileCopyrightText: 2023 Tilman Vatteroth
|
||||
|
||||
SPDX-License-Identifier: CC0-1.0
|
Loading…
Reference in a new issue