import emojifyText from './emojify-text';
const fauxDiv = document.createElement('div');
function enhanceContent(content, opts = {}) {
const { emojis, postEnhanceDOM = () => {} } = opts;
let enhancedContent = content;
const dom = document.createElement('div');
dom.innerHTML = enhancedContent;
const hasLink = / {
link.setAttribute('target', '_blank');
});
}
// Spanify un-spanned mentions
if (hasLink) {
const notMentionLinks = Array.from(dom.querySelectorAll('a[href]'));
notMentionLinks.forEach((link) => {
const text = link.innerText.trim();
const hasChildren = link.querySelector('*');
// If text looks like @username@domain, then it's a mention
if (/^@[^@]+(@[^@]+)?$/g.test(text)) {
// Only show @username
const username = text.split('@')[1];
if (!hasChildren) link.innerHTML = `@${username}`;
link.classList.add('mention');
}
// If text looks like #hashtag, then it's a hashtag
if (/^#[^#]+$/g.test(text)) {
if (!hasChildren) link.innerHTML = `#${text.slice(1)}`;
link.classList.add('mention', 'hashtag');
}
});
}
// EMOJIS
// ======
// Convert :shortcode: to
let textNodes;
if (enhancedContent.indexOf(':') !== -1) {
textNodes = extractTextNodes(dom);
textNodes.forEach((node) => {
let html = node.nodeValue
.replace(/&/g, '&')
.replace(//g, '>');
if (emojis) {
html = emojifyText(html, emojis);
}
fauxDiv.innerHTML = html;
const nodes = Array.from(fauxDiv.childNodes);
node.replaceWith(...nodes);
});
}
// CODE BLOCKS
// ===========
// Convert ```code``` to
if (hasCodeBlock) {
const blocks = Array.from(dom.querySelectorAll('p')).filter((p) =>
/^```[^]+```$/g.test(p.innerText.trim()),
);
blocks.forEach((block) => {
const pre = document.createElement('pre');
// Replace code
with newlines
block.querySelectorAll('br').forEach((br) => br.replaceWith('\n'));
pre.innerHTML = `${block.innerHTML.trim()}
`;
block.replaceWith(pre);
});
}
// Convert multi-paragraph code blocks to
if (hasCodeBlock) {
const paragraphs = Array.from(dom.querySelectorAll('p'));
// Filter out paragraphs with ``` in beginning only
const codeBlocks = paragraphs.filter((p) => /^```/g.test(p.innerText));
// For each codeBlocks, get all paragraphs until the last paragraph with ``` at the end only
codeBlocks.forEach((block) => {
const nextParagraphs = [block];
let hasCodeBlock = false;
let currentBlock = block;
while (currentBlock.nextElementSibling) {
const next = currentBlock.nextElementSibling;
if (next && next.tagName === 'P') {
if (/```$/g.test(next.innerText)) {
nextParagraphs.push(next);
hasCodeBlock = true;
break;
} else {
nextParagraphs.push(next);
}
} else {
break;
}
currentBlock = next;
}
if (hasCodeBlock) {
const pre = document.createElement('pre');
nextParagraphs.forEach((p) => {
// Replace code
with newlines
p.querySelectorAll('br').forEach((br) => br.replaceWith('\n'));
});
const codeText = nextParagraphs.map((p) => p.innerHTML).join('\n\n');
pre.innerHTML = `${codeText}
`;
block.replaceWith(pre);
nextParagraphs.forEach((p) => p.remove());
}
});
}
// INLINE CODE
// ===========
// Convert `code` to code
if (enhancedContent.indexOf('`') !== -1) {
textNodes = extractTextNodes(dom);
textNodes.forEach((node) => {
let html = node.nodeValue
.replace(/&/g, '&')
.replace(//g, '>');
if (/`[^`]+`/g.test(html)) {
html = html.replaceAll(/(`[^]+?`)/g, '$1
');
}
fauxDiv.innerHTML = html;
const nodes = Array.from(fauxDiv.childNodes);
node.replaceWith(...nodes);
});
}
// TWITTER USERNAMES
// =================
// Convert @username@twitter.com to @username@twitter.com
if (/twitter\.com/i.test(enhancedContent)) {
textNodes = extractTextNodes(dom, {
rejectFilter: ['A'],
});
textNodes.forEach((node) => {
let html = node.nodeValue
.replace(/&/g, '&')
.replace(//g, '>');
if (/@[a-zA-Z0-9_]+@twitter\.com/g.test(html)) {
html = html.replaceAll(
/(@([a-zA-Z0-9_]+)@twitter\.com)/g,
'$1',
);
}
fauxDiv.innerHTML = html;
const nodes = Array.from(fauxDiv.childNodes);
node.replaceWith(...nodes);
});
}
// HASHTAG STUFFING
// ================
// Get the
that contains a lot of hashtags, add a class to it if (enhancedContent.indexOf('#') !== -1) { const hashtagStuffedParagraph = Array.from(dom.querySelectorAll('p')).find( (p) => { let hashtagCount = 0; for (let i = 0; i < p.childNodes.length; i++) { const node = p.childNodes[i]; if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent.trim(); if (text !== '') { return false; } } else if (node.tagName === 'A') { const linkText = node.textContent.trim(); if (!linkText || !linkText.startsWith('#')) { return false; } else { hashtagCount++; } } else { return false; } } // Only consider "stuffing" if there are more than 3 hashtags return hashtagCount > 3; }, ); if (hashtagStuffedParagraph) { hashtagStuffedParagraph.classList.add('hashtag-stuffing'); hashtagStuffedParagraph.title = hashtagStuffedParagraph.innerText; } } if (postEnhanceDOM) { postEnhanceDOM(dom); // mutate dom } enhancedContent = dom.innerHTML; return enhancedContent; } const defaultRejectFilter = [ // Document metadata 'STYLE', // Image and multimedia 'IMG', 'VIDEO', 'AUDIO', 'AREA', 'MAP', 'TRACK', // Embedded content 'EMBED', 'IFRAME', 'OBJECT', 'PICTURE', 'PORTAL', 'SOURCE', // SVG and MathML 'SVG', 'MATH', // Scripting 'CANVAS', 'NOSCRIPT', 'SCRIPT', // Forms 'INPUT', 'OPTION', 'TEXTAREA', // Web Components 'SLOT', 'TEMPLATE', ]; const defaultRejectFilterMap = Object.fromEntries( defaultRejectFilter.map((nodeName) => [nodeName, true]), ); function extractTextNodes(dom, opts = {}) { const textNodes = []; const walk = document.createTreeWalker( dom, NodeFilter.SHOW_TEXT, { acceptNode(node) { if (defaultRejectFilterMap[node.parentNode.nodeName]) { return NodeFilter.FILTER_REJECT; } if ( opts.rejectFilter && opts.rejectFilter.includes(node.parentNode.nodeName) ) { return NodeFilter.FILTER_REJECT; } return NodeFilter.FILTER_ACCEPT; }, }, false, ); let node; while ((node = walk.nextNode())) { textNodes.push(node); } return textNodes; } export default enhanceContent;