Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2023-08-26 14:52:15 +02:00
parent 5dc6526278
commit 84527f065c
37 changed files with 1388 additions and 0 deletions

View 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
View file

@ -0,0 +1,9 @@
.idea
.babelrc
.eslintrc
.travis.yml
karma.conf.js
tests.webpack.js
webpack.config.*.js
coverage/
test/

View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: CC0-1.0

View file

@ -0,0 +1 @@
node_modules/

View file

@ -0,0 +1,4 @@
SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: CC0-1.0

View file

@ -0,0 +1,11 @@
{
"parser": "typescript",
"singleQuote": true,
"jsxSingleQuote": true,
"semi": false,
"tabWidth": 2,
"trailingComma": "none",
"bracketSpacing": true,
"bracketSameLine": true,
"arrowParens": "always"
}

View 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
View 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
View 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!"

View 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
}
]
}
}

View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: CC0-1.0

View 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"
}

View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: AGPL-3.0-only

View 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
}

View 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)
}

View 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
}
}

View 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

View 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

View 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'
]

View 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)
}

View 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)
}

View 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
}

View 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>&excl;</span>', '<span>!</span>')
})
it('should not decode html entities when the option is disabled', () => {
expectOtherHtml('<span>&excl;</span>', '<span>&amp;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])
}
})
})
})

View 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'

View 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)
})
}

View 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>
)
}

View 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
}

View 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]
}

View file

@ -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>
)
}

View 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"]
}

View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2023 Tilman Vatteroth
SPDX-License-Identifier: CC0-1.0

View file

@ -0,0 +1,10 @@
{
"extends" : "./tsconfig.base.json",
"compilerOptions": {
"module": "CommonJS",
"target": "ES2015",
"outDir": "dist/cjs",
"declarationDir": "dist/cjs",
"moduleResolution": "node"
}
}

View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2023 Tilman Vatteroth
SPDX-License-Identifier: CC0-1.0

View file

@ -0,0 +1,10 @@
{
"extends" : "./tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"target" : "esnext",
"outDir": "dist/esm",
"moduleResolution": "NodeNext",
"declarationDir": "dist/esm"
}
}

View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2023 Tilman Vatteroth
SPDX-License-Identifier: CC0-1.0

View file

@ -0,0 +1,4 @@
{
"extends" : "./tsconfig.esm.json",
"exclude": ["dist"]
}

View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2023 Tilman Vatteroth
SPDX-License-Identifier: CC0-1.0