Skip to content

Instantly share code, notes, and snippets.

@JohnDDuncanIII
Last active November 14, 2020 21:43
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save JohnDDuncanIII/495c10cd461f87c03e292846f6d87078 to your computer and use it in GitHub Desktop.
Save JohnDDuncanIII/495c10cd461f87c03e292846f6d87078 to your computer and use it in GitHub Desktop.
/**
* recursively converts DOM nodes into React elements
* @param {(DOM) Node} element the DOM Node
* @param {number} index the unique key
* @param {number} id the ID of the parent Component
*
* @returns {JSX} the new JSX element
*/
const reactify = (element, index, id, disableAnchors) => {
// get attributes of DOM nodes and add them to a map to pass to the React element
const domAttrs = element.attributes
// map to hold attributes of vanilla DOM nodes
const attributes = {}
// map to hold the inline style values of vanilla DOM nodes
const style = {}
// populate map so we can pass it into our custom React component
if (domAttrs) {
// NamedNodeMap does not have a forEach
for (let i = 0; i < domAttrs.length; i += 1) {
// React-specific camelCase syntax rules
// https://reactjs.org/blog/2017/09/08/dom-attributes-in-react-16.html
// https://github.com/facebook/react/pull/14268
// https://github.com/facebook/react/blob/master/packages/react-dom/src/shared/possibleStandardNames.js
switch (domAttrs[i].name) {
case "bgcolor":
style.backgroundColor = domAttrs[i].value
break
case "contenteditable":
attributes.contentEditable = domAttrs[i].value
// https://github.com/facebook/draft-js/issues/81
attributes.suppressContentEditableWarning = true
break
case "style":
// This is required since the element.style CSSStyleDeclaration object contains every single possible css key
// (including browser-prefixed elements that will cause React to throw warnings)
// The CSSStyleDeclaration object contains a weird mix of array indices and key/value pairs, so we are using a
// vanilla for(;;) loop to be safe
for (let j = 0; j < element.style.length; j += 1) {
// required since the CSSStyleDeclaration declares its style array values (keys) in snake_case
// but stores its actual key/val pairs in camelCase
const camelCaseKey = element.style[j].replace(/-([a-z])/g, g => g[1].toUpperCase())
// pass the camelCased key to React, since React does not recognize snake_case inline css rules
style[camelCaseKey] = element.style.getPropertyValue(element.style[j])
}
break
// some press release and dear colleague documents contain <p> and <table> elements with the HTML5 incompatible align attribute
// some tweet content fields contain <img> elements with the HTML5 incompatible border attribute
// some constituent email fields contain elements with HTML5 incompatible attributes
case "align":
case "border":
case "face":
case "vspace":
case "hspace":
case "valign":
break
default:
// Despite what the html5 spec claims,
// html5 element attribute names that contain any unicode character (for example, 'xuofi') cause a
// "DOMException: Failed to execute 'setAttribute' on 'Element': '...' is not a valid attribute name."
// on all major browsers.
// Valid html5 attribute characters, as of this comment,
// include A-Z characters (case insensitive) and '-' for custom data attributes
// https://html.spec.whatwg.org/multipage/dom.html#attributes
// https://html.spec.whatwg.org/multipage/syntax.html#attributes-0
// https://html.spec.whatwg.org/multipage/dom.html#custom-data-attribute
// https://html.spec.whatwg.org/multipage/custom-elements.html#custom-elements-core-concepts
// https://www.w3.org/TR/2011/WD-html5-20110525/elements.html#attributes
// https://www.w3.org/TR/2011/WD-html5-20110525/syntax.html#attributes-0
// https://www.w3.org/TR/2011/WD-html5-20110525/elements.html#custom-data-attribute
// https://www.w3.org/TR/2016/WD-custom-elements-20160830/#custom-elements-core-concepts
// https://www.w3.org/TR/html4/index/attributes.html
// To fix this, we strip out all invalid characters.
// https://stackoverflow.com/questions/925994/926136#comment33673269_926136
const attributeNameRegex = /[^A-Za-z-]/
const attributeName = (
attributeNameRegex.test(domAttrs[i].name)
? domAttrs[i].name.replace(attributeNameRegex, "")
: domAttrs[i].name
)
attributes[attributeName] = domAttrs[i].value
break
}
}
}
// prevent warning about modifying a function parameter
let uniqueKey = index
// reactify all child nodes of a given parent
const childHelper = (parent) => {
const children = []
parent.childNodes.forEach((childNode) => {
children.push(reactify(childNode, uniqueKey += 1, id, disableAnchors))
})
return children
}
// get the element type
// variable name must be PascalCase in order for React to correctly parse it into a built-in element
// https://stackoverflow.com/a/33471928
const TagName = element.tagName
const Tag = TagName && TagName.toLowerCase()
// use unique keys when rendering dynamic React elements
const key = `${id}${uniqueKey}`
// handle internal linking with our custom Segue middleware
if (TagName === "A") {
if (!disableAnchors) {
// internal quorum Segue links are structured as relative URLs, but the parsed DOM node replaces the
// relative href (i.e., /project_profile/190/) with the
// absolute URL (i.e., http://localhost:8000/project_profile/190/)
// element.href is any external absolute URL
let href = element.getAttribute("href")
let link = {}
let quorumSegue = false
// we check to see if the attribute exists as some anchor elements may not declare an href value
// (which happens in some Document Inlines)
if (href) {
if (
// the only semantic difference between internal quorum segues and external URLs is the leading '/'
href.charAt(0) === '/' &&
// this href syntax is deprecated and breaks when invoking any element properties in Edge 17+
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#Access_using_credentials_in_the_URL
!isLoginUrl.test(href)
) {
// we do not want to segue to downloads since they will 404
if (!href.includes("/api/")) {
quorumSegue = true
}
// pathname returns only the content that follows the anchor host (only the Segue link)
if (!href.includes('?')) {
href = element.pathname
}
}
}
const Tag = quorumSegue ? SegueLink : 'a'
link = (
<Tag
{...attributes}
onClick={(e) => { e.stopPropagation() }}
// if we are dealing with a Quorum segue, set middleware attribute appropriately
to={quorumSegue ? href : undefined}
// if we are dealing with a plain URL, do not create a Segue object (instead set the href attr)
href={href}
style={style}
// if we are dealing with a plain (external) URL, make sure it opens in a new tab onClick
target={!quorumSegue ? "_blank" : undefined}
rel={!quorumSegue ? "noopener noreferrer" : undefined}
>
{ childHelper(element) }
</Tag>
)
return <object key={key}>{link}</object>
} else {
return element.textContent
}
}
// all html elements have some base text content (or nothing)
// DOM Core level 2 properties, so they should work in IE6+, Firefox 2+, Chrome 1+ etc
// https://caniuse.com/#search=nodeName
else if (element.nodeType === Node.TEXT_NODE) {
// only return the textContent if the string is non-empty
// https://stackoverflow.com/a/10262019
if (element.textContent.replace(/\s/g, '').length) {
return element.textContent
}
return undefined
}
// void elements are not allowed to have any content nor children
// https://html.spec.whatwg.org/multipage/syntax.html#void-elements
else if ([
"AREA",
"BASE",
"BR",
"COL",
"EMBED",
"HR",
"IMG",
"INPUT",
"LINK",
"META",
"PARAM",
"SOURCE",
"TRACK",
"WBR"
].includes(TagName)) {
return (
<Tag
{...attributes}
key={key}
style={style}
/>
)
}
// generalize creation of built-in html elements that allow nesting
// https://stackoverflow.com/a/26287085
else if (element.nodeType === Node.ELEMENT_NODE) {
// the DOMParser() can return elements with a malformed TagName if the html is incorrect...
if (!isValidTagName(TagName)) {
return childHelper(element)
}
return (
<Tag
{...attributes}
key={key}
style={style}
>
{ childHelper(element) }
</Tag>
)
}
}
// convert backend-generated html to React JSX (hrefs to our SegueLink Component, etc.)
// we use this for both the aforementoned reason and instead of:
// React's dangerouslySetInnerHTML because it is dangerous to pass raw HTML (XSS) and we want to whitelist specific elements
// our old querySelectorAll hack which grabbed and modified DOM nodes after we had already rendered them to the page because
// it is a React anti-pattern to modify DOM nodes in a stateful React component (since React keeps track of the VDOM)
// this performs around the same amount of work as rendering React elements to the page and grabbing the DOM nodes, but instead
// of waiting until React renders and populates the VDOM and then grabbing the vanilla DOM nodes with a querySelector,
// we are instead creating the vanilla JS DOM and parsing the elements into React nodes with their Quorum-specific
// functionality (before React renders them to the page). This avoids directly modifying the document DOM
// (instead of React's VDOM), avoiding what is generally considered to be a React anti-pattern
// this is currently being used to convert:
// the Note Inline's firstLineData and thirdLineData
// the Document Inline's thirdLinedata (in certain cases)
const parseHtmlToReact = (html, inlineId, disableAnchors) => {
// parse a raw html string into DOM nodes
const doc = new DOMParser().parseFromString(html, 'text/html')
// grab the nodes from the DOM tree
const elements = doc.body.childNodes
const react = []
// IE-compatible NodeList iteration
// https://developer.mozilla.org/en-US/docs/Web/API/NodeList
// https://gist.github.com/bendc/4090e383865d81b4b684
Array.prototype.forEach.call(elements, (element, index) => {
// convert each DOM node to a react element
react.push(reactify(element, index, inlineId, disableAnchors))
})
// return an array of React elements that we can pass to a React component
return react
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment