mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-13 22:54:42 -04: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
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>
|
||||
)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue