Last active
November 14, 2020 21:43
-
-
Save JohnDDuncanIII/495c10cd461f87c03e292846f6d87078 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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