2022-12-10 12:14:48 +03:00
|
|
|
import emojifyText from './emojify-text';
|
2023-10-14 20:19:21 +03:00
|
|
|
import mem from './mem';
|
2022-12-10 12:14:48 +03:00
|
|
|
|
2022-12-13 15:15:02 +03:00
|
|
|
const fauxDiv = document.createElement('div');
|
2023-09-16 10:45:09 +03:00
|
|
|
const whitelistLinkClasses = ['u-url', 'mention', 'hashtag'];
|
2022-12-13 15:15:02 +03:00
|
|
|
|
2023-10-14 20:19:21 +03:00
|
|
|
function _enhanceContent(content, opts = {}) {
|
2022-12-11 04:28:02 +03:00
|
|
|
const { emojis, postEnhanceDOM = () => {} } = opts;
|
2022-12-10 12:14:48 +03:00
|
|
|
let enhancedContent = content;
|
2022-12-10 14:16:11 +03:00
|
|
|
const dom = document.createElement('div');
|
|
|
|
dom.innerHTML = enhancedContent;
|
2023-06-11 18:28:12 +03:00
|
|
|
const hasLink = /<a/i.test(enhancedContent);
|
|
|
|
const hasCodeBlock = enhancedContent.indexOf('```') !== -1;
|
2022-12-10 12:14:48 +03:00
|
|
|
|
2023-06-11 18:28:12 +03:00
|
|
|
if (hasLink) {
|
2023-09-16 09:47:35 +03:00
|
|
|
// Add target="_blank" to all links with no target="_blank"
|
|
|
|
// E.g. `note` in `account`
|
2023-06-11 18:28:12 +03:00
|
|
|
const noTargetBlankLinks = Array.from(
|
|
|
|
dom.querySelectorAll('a:not([target="_blank"])'),
|
|
|
|
);
|
|
|
|
noTargetBlankLinks.forEach((link) => {
|
|
|
|
link.setAttribute('target', '_blank');
|
|
|
|
});
|
2023-09-16 09:47:35 +03:00
|
|
|
|
|
|
|
// Remove all classes except `u-url`, `mention`, `hashtag`
|
|
|
|
const links = Array.from(dom.querySelectorAll('a[class]'));
|
|
|
|
links.forEach((link) => {
|
2023-09-16 10:45:09 +03:00
|
|
|
Array.from(link.classList).forEach((c) => {
|
|
|
|
if (!whitelistLinkClasses.includes(c)) {
|
2023-09-16 09:47:35 +03:00
|
|
|
link.classList.remove(c);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
2023-06-11 18:28:12 +03:00
|
|
|
}
|
2022-12-10 14:16:11 +03:00
|
|
|
|
2023-08-18 08:48:45 +03:00
|
|
|
// Add 'has-url-text' to all links that contains a url
|
|
|
|
if (hasLink) {
|
|
|
|
const links = Array.from(dom.querySelectorAll('a[href]'));
|
|
|
|
links.forEach((link) => {
|
|
|
|
if (/^https?:\/\//i.test(link.textContent.trim())) {
|
|
|
|
link.classList.add('has-url-text');
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2023-04-14 16:02:29 +03:00
|
|
|
// Spanify un-spanned mentions
|
2023-06-11 18:28:12 +03:00
|
|
|
if (hasLink) {
|
2023-09-03 05:07:06 +03:00
|
|
|
const links = Array.from(dom.querySelectorAll('a[href]'));
|
|
|
|
const usernames = [];
|
|
|
|
links.forEach((link) => {
|
2023-06-11 18:28:12 +03:00
|
|
|
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
|
2023-09-03 05:07:06 +03:00
|
|
|
const [_, username, domain] = text.split('@');
|
|
|
|
if (!hasChildren) {
|
|
|
|
if (
|
|
|
|
!usernames.find(([u]) => u === username) ||
|
|
|
|
usernames.find(([u, d]) => u === username && d === domain)
|
|
|
|
) {
|
|
|
|
link.innerHTML = `@<span>${username}</span>`;
|
|
|
|
usernames.push([username, domain]);
|
|
|
|
} else {
|
|
|
|
link.innerHTML = `@<span>${username}@${domain}</span>`;
|
|
|
|
}
|
|
|
|
}
|
2023-06-11 18:28:12 +03:00
|
|
|
link.classList.add('mention');
|
|
|
|
}
|
|
|
|
// If text looks like #hashtag, then it's a hashtag
|
|
|
|
if (/^#[^#]+$/g.test(text)) {
|
|
|
|
if (!hasChildren) link.innerHTML = `#<span>${text.slice(1)}</span>`;
|
|
|
|
link.classList.add('mention', 'hashtag');
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
2023-04-14 16:02:29 +03:00
|
|
|
|
2022-12-13 15:15:02 +03:00
|
|
|
// EMOJIS
|
|
|
|
// ======
|
|
|
|
// Convert :shortcode: to <img />
|
2023-06-11 18:28:12 +03:00
|
|
|
let textNodes;
|
|
|
|
if (enhancedContent.indexOf(':') !== -1) {
|
|
|
|
textNodes = extractTextNodes(dom);
|
|
|
|
textNodes.forEach((node) => {
|
|
|
|
let html = node.nodeValue
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
.replace(/</g, '<')
|
|
|
|
.replace(/>/g, '>');
|
|
|
|
if (emojis) {
|
|
|
|
html = emojifyText(html, emojis);
|
|
|
|
}
|
|
|
|
fauxDiv.innerHTML = html;
|
|
|
|
const nodes = Array.from(fauxDiv.childNodes);
|
|
|
|
node.replaceWith(...nodes);
|
|
|
|
});
|
|
|
|
}
|
2022-12-13 15:15:02 +03:00
|
|
|
|
|
|
|
// CODE BLOCKS
|
|
|
|
// ===========
|
|
|
|
// Convert ```code``` to <pre><code>code</code></pre>
|
2023-06-11 18:28:12 +03:00
|
|
|
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 <br /> with newlines
|
|
|
|
block.querySelectorAll('br').forEach((br) => br.replaceWith('\n'));
|
|
|
|
pre.innerHTML = `<code>${block.innerHTML.trim()}</code>`;
|
|
|
|
block.replaceWith(pre);
|
|
|
|
});
|
|
|
|
}
|
2022-12-10 12:14:48 +03:00
|
|
|
|
2023-02-16 16:51:22 +03:00
|
|
|
// Convert multi-paragraph code blocks to <pre><code>code</code></pre>
|
2023-06-11 18:28:12 +03:00
|
|
|
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);
|
|
|
|
}
|
2023-02-16 16:51:22 +03:00
|
|
|
} else {
|
2023-06-11 18:28:12 +03:00
|
|
|
break;
|
2023-02-16 16:51:22 +03:00
|
|
|
}
|
2023-06-11 18:28:12 +03:00
|
|
|
currentBlock = next;
|
2023-02-16 16:51:22 +03:00
|
|
|
}
|
2023-06-11 18:28:12 +03:00
|
|
|
if (hasCodeBlock) {
|
|
|
|
const pre = document.createElement('pre');
|
|
|
|
nextParagraphs.forEach((p) => {
|
|
|
|
// Replace <br /> with newlines
|
|
|
|
p.querySelectorAll('br').forEach((br) => br.replaceWith('\n'));
|
|
|
|
});
|
|
|
|
const codeText = nextParagraphs.map((p) => p.innerHTML).join('\n\n');
|
2023-09-02 15:49:25 +03:00
|
|
|
pre.innerHTML = `<code tabindex="0">${codeText}</code>`;
|
2023-06-11 18:28:12 +03:00
|
|
|
block.replaceWith(pre);
|
|
|
|
nextParagraphs.forEach((p) => p.remove());
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
2023-02-16 16:51:22 +03:00
|
|
|
|
2022-12-23 04:20:34 +03:00
|
|
|
// INLINE CODE
|
|
|
|
// ===========
|
|
|
|
// Convert `code` to <code>code</code>
|
2023-06-11 18:28:12 +03:00
|
|
|
if (enhancedContent.indexOf('`') !== -1) {
|
|
|
|
textNodes = extractTextNodes(dom);
|
|
|
|
textNodes.forEach((node) => {
|
|
|
|
let html = node.nodeValue
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
.replace(/</g, '<')
|
|
|
|
.replace(/>/g, '>');
|
|
|
|
if (/`[^`]+`/g.test(html)) {
|
|
|
|
html = html.replaceAll(/(`[^]+?`)/g, '<code>$1</code>');
|
|
|
|
}
|
|
|
|
fauxDiv.innerHTML = html;
|
|
|
|
const nodes = Array.from(fauxDiv.childNodes);
|
|
|
|
node.replaceWith(...nodes);
|
|
|
|
});
|
|
|
|
}
|
2022-12-23 04:20:34 +03:00
|
|
|
|
2022-12-22 19:40:25 +03:00
|
|
|
// TWITTER USERNAMES
|
|
|
|
// =================
|
|
|
|
// Convert @username@twitter.com to <a href="https://twitter.com/username">@username@twitter.com</a>
|
2023-06-11 18:28:12 +03:00
|
|
|
if (/twitter\.com/i.test(enhancedContent)) {
|
|
|
|
textNodes = extractTextNodes(dom, {
|
|
|
|
rejectFilter: ['A'],
|
|
|
|
});
|
|
|
|
textNodes.forEach((node) => {
|
|
|
|
let html = node.nodeValue
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
.replace(/</g, '<')
|
|
|
|
.replace(/>/g, '>');
|
|
|
|
if (/@[a-zA-Z0-9_]+@twitter\.com/g.test(html)) {
|
|
|
|
html = html.replaceAll(
|
|
|
|
/(@([a-zA-Z0-9_]+)@twitter\.com)/g,
|
|
|
|
'<a href="https://twitter.com/$2" rel="nofollow noopener noreferrer" target="_blank">$1</a>',
|
|
|
|
);
|
|
|
|
}
|
|
|
|
fauxDiv.innerHTML = html;
|
|
|
|
const nodes = Array.from(fauxDiv.childNodes);
|
|
|
|
node.replaceWith(...nodes);
|
|
|
|
});
|
|
|
|
}
|
2022-12-22 19:40:25 +03:00
|
|
|
|
2023-04-20 13:56:22 +03:00
|
|
|
// HASHTAG STUFFING
|
|
|
|
// ================
|
|
|
|
// Get the <p> that contains a lot of hashtags, add a class to it
|
2023-06-11 18:28:12 +03:00
|
|
|
if (enhancedContent.indexOf('#') !== -1) {
|
2023-08-20 05:17:56 +03:00
|
|
|
let prevIndex = null;
|
|
|
|
const hashtagStuffedParagraphs = Array.from(
|
|
|
|
dom.querySelectorAll('p'),
|
|
|
|
).filter((p, index) => {
|
|
|
|
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 !== '') {
|
2023-06-11 18:28:12 +03:00
|
|
|
return false;
|
2023-04-20 13:56:22 +03:00
|
|
|
}
|
2023-08-20 05:17:56 +03:00
|
|
|
} else if (node.tagName === 'BR') {
|
|
|
|
// Ignore <br />
|
|
|
|
} else if (node.tagName === 'A') {
|
|
|
|
const linkText = node.textContent.trim();
|
|
|
|
if (!linkText || !linkText.startsWith('#')) {
|
|
|
|
return false;
|
|
|
|
} else {
|
|
|
|
hashtagCount++;
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
return false;
|
2023-04-20 13:56:22 +03:00
|
|
|
}
|
2023-08-20 05:17:56 +03:00
|
|
|
}
|
|
|
|
// Only consider "stuffing" if:
|
|
|
|
// - there are more than 3 hashtags
|
|
|
|
// - there are more than 1 hashtag in adjacent paragraphs
|
|
|
|
if (hashtagCount > 3) {
|
|
|
|
prevIndex = index;
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
if (hashtagCount > 1 && prevIndex && index === prevIndex + 1) {
|
|
|
|
prevIndex = index;
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
if (hashtagStuffedParagraphs?.length) {
|
|
|
|
hashtagStuffedParagraphs.forEach((p) => {
|
|
|
|
p.classList.add('hashtag-stuffing');
|
|
|
|
p.title = p.innerText;
|
|
|
|
});
|
2023-06-11 18:28:12 +03:00
|
|
|
}
|
2023-04-20 13:56:22 +03:00
|
|
|
}
|
|
|
|
|
2022-12-11 04:28:02 +03:00
|
|
|
if (postEnhanceDOM) {
|
|
|
|
postEnhanceDOM(dom); // mutate dom
|
|
|
|
}
|
|
|
|
|
2022-12-10 14:16:11 +03:00
|
|
|
enhancedContent = dom.innerHTML;
|
2022-12-11 04:28:02 +03:00
|
|
|
|
2022-12-10 12:14:48 +03:00
|
|
|
return enhancedContent;
|
2022-12-16 08:27:04 +03:00
|
|
|
}
|
2023-10-14 20:19:21 +03:00
|
|
|
const enhanceContent = mem(_enhanceContent);
|
2022-12-13 15:15:02 +03:00
|
|
|
|
2022-12-23 04:20:34 +03:00
|
|
|
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 = {}) {
|
2022-12-13 15:15:02 +03:00
|
|
|
const textNodes = [];
|
|
|
|
const walk = document.createTreeWalker(
|
|
|
|
dom,
|
|
|
|
NodeFilter.SHOW_TEXT,
|
2022-12-23 04:20:34 +03:00
|
|
|
{
|
|
|
|
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;
|
|
|
|
},
|
|
|
|
},
|
2022-12-13 15:15:02 +03:00
|
|
|
false,
|
|
|
|
);
|
|
|
|
let node;
|
|
|
|
while ((node = walk.nextNode())) {
|
|
|
|
textNodes.push(node);
|
|
|
|
}
|
|
|
|
return textNodes;
|
|
|
|
}
|
2022-12-16 08:27:04 +03:00
|
|
|
|
|
|
|
export default enhanceContent;
|