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; // Add target="_blank" to all links with no target="_blank" // E.g. `note` in `account` const links = Array.from(dom.querySelectorAll('a:not([target="_blank"])')); links.forEach((link) => { link.setAttribute('target', '_blank'); }); // Spanify un-spanned mentions const mentionLinks = Array.from(dom.querySelectorAll('a[href].mention')); mentionLinks.forEach((link) => { if (link.querySelector('*')) { return; } const text = link.innerText; // If text looks like @username@domain, then it's a mention if (/^@[^@]+@[^@]+$/g.test(text)) { // Only show @username const username = text.split('@')[1]; link.innerHTML = `@${username}`; } }); // EMOJIS // ====== // Convert :shortcode: to let textNodes = extractTextNodes(dom); textNodes.forEach((node) => { let html = node.nodeValue.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
code
const blocks = Array.from(dom.querySelectorAll('p')).filter((p) => /^```[^]+```$/g.test(p.innerText.trim()), ); blocks.forEach((block) => { const pre = document.createElement('pre'); // Replace
with newlines block.querySelectorAll('br').forEach((br) => br.replaceWith('\n')); pre.innerHTML = `${block.innerHTML.trim()}`; block.replaceWith(pre); }); // Convert multi-paragraph code blocks to
code
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
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 textNodes = extractTextNodes(dom); textNodes.forEach((node) => { let html = node.nodeValue.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 textNodes = extractTextNodes(dom, { rejectFilter: ['A'], }); textNodes.forEach((node) => { let html = node.nodeValue.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); }); 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;