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