import React from 'react'; import PropTypes from 'prop-types'; import matchLinks from '../utils/matchLinks'; import memoize from 'lodash/memoize'; import cn from 'classnames'; import styles from './AdminCommentContent.css'; function escapeHTML(unsafe) { return unsafe .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string } // generate a regulare expression that catches the `phrases`. function generateRegExp(phrases) { const inner = phrases .map(phrase => phrase .split(/\s+/) .map(word => escapeRegExp(word)) .join('[\\s"?!.]+') ) .join('|'); const pattern = `(^|[^\\w])(${inner})(?=[^\\w]|$)`; try { return new RegExp(pattern, 'iu'); } catch (_err) { // IE does not support unicode support, so we'll create one without. return new RegExp(pattern, 'i'); } } // Generate a regular expression detecting `suspectWords` and `bannedWords` phrases. function getPhrasesRegexp(suspectWords, bannedWords) { return generateRegExp([...suspectWords, ...bannedWords]); } // Memoized version as arguments rarely change. const getPhrasesRegexpMemoized = memoize(getPhrasesRegexp); function nl2br(body, keyPrefix) { const tokens = body.split('\n').reduce((tokens, t, i) => { if (i !== 0) { tokens.push(
); } tokens.push(t); return tokens; }, []); return tokens; } // markPhrases looks for `supsectWords` and `bannedWords` inside `body` and highlights them by returning // an array of React Elements. function markPhrases(body, suspectWords, bannedWords, keyPrefix) { const regexp = getPhrasesRegexpMemoized(suspectWords, bannedWords); const tokens = body.split(regexp); return tokens.map( (token, i) => i % 3 === 2 ? {token} : token ); } // markLinks looks for links inside `body` and highlights them by returning // an array of React Elements. function markLinks(body, keyPrefix) { const matches = matchLinks(body); const content = []; let index = 0; if (matches) { matches.forEach((match, i) => { content.push(body.substring(index, match.index)); content.push( {match.text} ); index = match.lastIndex; }); } content.push(body.substring(index)); return content; } // markPhrasesHTML looks for `supsectWords` and `bannedWords` inside `text` and highlights them by returning // a HTML string. function markPhrasesHTML(text, suspectWords, bannedWords) { const regexp = getPhrasesRegexpMemoized(suspectWords, bannedWords); const tokens = text.split(regexp); if (tokens.length === 1) { return text; } return tokens .map( (token, i) => i % 3 === 2 ? `${escapeHTML(token)}` : escapeHTML(token) ) .join(''); } // markHTMLNode manipulates the node by looking for #text nodes and adding markers // for `supsectWords` and `bannedWords`. function markHTMLNode(parentNode, suspectWords, bannedWords) { parentNode.childNodes.forEach(node => { if (node.nodeName === '#text') { const newContent = markPhrasesHTML( node.textContent, suspectWords, bannedWords ); if (newContent !== node.textContent) { const newNode = document.createElement('span'); newNode.innerHTML = newContent; parentNode.replaceChild(newNode, node); } } else { markHTMLNode(node, suspectWords, bannedWords); } }); } // renderText performs all the marking of a text body and returns an array of React Elements. function renderText(body, suspectWords, bannedWords) { return nl2br(body).map((element, index) => { // Skip br tags. if (typeof element !== 'string') { return element; } return markLinks(element, index).map((element, index) => { // Keep highlighted links. if (typeof element !== 'string') { return element; } // Highlight suspect and banned phrase inside this part of text. return markPhrases(element, suspectWords, bannedWords, index); }); }); } const commonPropTypes = { className: PropTypes.string, bannedWords: PropTypes.array.isRequired, suspectWords: PropTypes.array.isRequired, body: PropTypes.string.isRequired, }; const AdminCommentContentText = ({ body, className, suspectWords, bannedWords, }) => { return (
{renderText(body, suspectWords, bannedWords)}
); }; AdminCommentContentText.propTypes = commonPropTypes; const AdminCommentContentHTML = ({ body, className, suspectWords, bannedWords, }) => { // We create a Shadow DOM Tree with the HTML body content and // use it as a parser. const node = document.createElement('div'); node.innerHTML = body; // Then we traverse it recursively and manipulate it to highlight suspect words // and banned words. markHTMLNode(node, suspectWords, bannedWords); // Finally we render the content of the Shadow DOM Tree return (
); }; AdminCommentContentHTML.propTypes = commonPropTypes; const AdminCommentContent = ({ className, body, suspectWords, bannedWords, html, }) => { const Component = html ? AdminCommentContentHTML : AdminCommentContentText; return ( ); }; AdminCommentContent.propTypes = { ...commonPropTypes, html: PropTypes.bool, }; export default AdminCommentContent;