export class ContentRenderer { constructor() { this.emojiMap = this.buildEmojiMap(); this.imageExtRe = /\.(jpg|jpeg|png|gif|webp|svg|bmp|webp)(\?.*)?$/i; this.videoExtRe = /\.(mp4|webm|ogg|ogv|mov|m4v)(\?.*)?$/i; this.youtubeRe = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/; } buildEmojiMap() { return { "grinning": "\u{1F600}", "smiley": "\u{1F603}", "smile": "\u{1F604}", "grin": "\u{1F601}", "laughing": "\u{1F606}", "sweat_smile": "\u{1F605}", "joy": "\u{1F602}", "blush": "\u{1F60A}", "innocent": "\u{1F607}", "wink": "\u{1F609}", "heart_eyes": "\u{1F60D}", "kissing_heart": "\u{1F618}", "stuck_out_tongue": "\u{1F61B}", "stuck_out_tongue_winking_eye": "\u{1F61C}", "sunglasses": "\u{1F60E}", "unamused": "\u{1F612}", "sweat": "\u{1F613}", "sob": "\u{1F62D}", "scream": "\u{1F631}", "sleeping": "\u{1F634}", "relieved": "\u{1F60C}", "heart": "\u{2764}\u{FE0F}", "broken_heart": "\u{1F494}", "two_hearts": "\u{1F495}", "sparkling_heart": "\u{1F496}", "star": "\u{2B50}", "sparkles": "\u{2728}", "zap": "\u{26A1}", "fire": "\u{1F525}", "rocket": "\u{1F680}", "100": "\u{1F4AF}", "clap": "\u{1F44F}", "ok_hand": "\u{1F44C}", "wave": "\u{1F44B}", "thumbsup": "\u{1F44D}", "+1": "\u{1F44D}", "thumbsdown": "\u{1F44E}", "-1": "\u{1F44E}", "pray": "\u{1F64F}", "muscle": "\u{1F4AA}", "tada": "\u{1F389}", "confetti_ball": "\u{1F38A}", "party": "\u{1F973}", "beers": "\u{1F37B}", "coffee": "\u{2615}", "pizza": "\u{1F355}", "computer": "\u{1F4BB}", "bug": "\u{1F41B}", "gear": "\u{2699}\u{FE0F}", "warning": "\u{26A0}\u{FE0F}", "question": "\u{2753}", "exclamation": "\u{2757}", "checkered_flag": "\u{1F3C1}", "rocket": "\u{1F680}", "eyes": "\u{1F440}", "brain": "\u{1F9E0}", "robot": "\u{1F916}", "skull": "\u{1F480}", "point_up": "\u{261D}\u{FE0F}", "point_down": "\u{1F447}", "point_left": "\u{1F448}", "point_right": "\u{1F449}", "pencil2": "\u{270F}\u{FE0F}", "memo": "\u{1F4DD}", "book": "\u{1F4D6}", "tools": "\u{1F6E0}\u{FE0F}", "hammer_and_wrench": "\u{1F6E0}\u{FE0F}", }; } replaceShortcodes(text) { return text.replace(/:([a-zA-Z0-9_+\-]+):/g, (match, name) => { return this.emojiMap[name.toLowerCase()] || match; }); } render(text) { if (!text) return ""; text = this.replaceShortcodes(text); let html; if (typeof marked !== "undefined") { html = marked.parse(text, { breaks: true, gfm: true }); } else { html = "

" + text.replace(/\n/g, "
") + "

"; } if (typeof DOMPurify === "undefined") { throw new Error("DOMPurify not loaded; refusing to render untrusted HTML"); } html = DOMPurify.sanitize(html); html = this.processMedia(html); return html; } processMedia(html) { const temp = document.createElement("div"); temp.innerHTML = html; this.walkNodes(temp, (node) => { if (node.nodeType !== 3) return; const text = node.textContent; if (!text || text.trim().length === 0) return; const parts = []; let lastIndex = 0; const urlRe = /(https?:\/\/[^\s<]+)/g; const mentionRe = /(?:^|\s|\x28)@([a-zA-Z0-9_-]+)/g; let match; while ((match = urlRe.exec(text)) !== null) { if (match.index > lastIndex) { const chunk = text.substring(lastIndex, match.index); this.splitMentions(chunk, parts); } parts.push({ type: "url", value: match[0], start: match.index, end: match.index + match[0].length }); lastIndex = match.index + match[0].length; } if (lastIndex < text.length) { const chunk = text.substring(lastIndex); this.splitMentions(chunk, parts); } if (parts.length <= 1 && parts[0]?.type === "text") return; const fragment = document.createDocumentFragment(); for (const part of parts) { if (part.type === "text") { fragment.appendChild(document.createTextNode(part.value)); } else if (part.type === "mention") { const a = document.createElement("a"); a.href = "/profile/" + part.username; a.className = "mention-link"; a.textContent = "@" + part.username; fragment.appendChild(a); } else { const el = this.urlToEmbed(part.value); if (el) { fragment.appendChild(el); } else { const a = document.createElement("a"); a.href = part.value; a.target = "_blank"; a.rel = "noopener noreferrer"; a.textContent = part.value; fragment.appendChild(a); } } } node.parentNode.replaceChild(fragment, node); }); return temp.innerHTML; } splitMentions(text, parts) { const mentionRe = /(?:^|\s|\x28)@([a-zA-Z0-9_-]+)/g; let m; let last = 0; while ((m = mentionRe.exec(text)) !== null) { if (m.index > last) { parts.push({ type: "text", value: text.substring(last, m.index) }); } const atIdx = m[0].lastIndexOf('@'); const prefix = m[0].slice(0, atIdx); if (prefix) { parts.push({ type: "text", value: prefix }); } parts.push({ type: "mention", username: m[1] }); last = m.index + m[0].length; } if (last < text.length) { parts.push({ type: "text", value: text.substring(last) }); } } urlToEmbed(url) { const ytMatch = url.match(this.youtubeRe); if (ytMatch) { const videoId = ytMatch[1]; const wrap = document.createElement("div"); wrap.className = "embed-youtube"; wrap.innerHTML = ( '' ); return wrap; } if (this.imageExtRe.test(url)) { const img = document.createElement("img"); img.src = url; img.alt = ""; img.loading = "lazy"; return img; } if (this.videoExtRe.test(url)) { const video = document.createElement("video"); video.src = url; video.controls = true; video.preload = "metadata"; return video; } return null; } walkNodes(root, callback) { const skipTags = new Set(["CODE", "PRE", "A", "IFRAME", "IMG", "VIDEO", "SCRIPT", "STYLE"]); const iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, null, false); let node; while ((node = iter.nextNode())) { let parent = node.parentNode; let skip = false; while (parent) { if (skipTags.has(parent.tagName)) { skip = true; break; } parent = parent.parentNode; } if (!skip) callback(node); } } applyTo(element) { if (!element) return; const text = element.textContent || element.innerText || ""; const rendered = this.render(text); element.innerHTML = rendered; } highlightAll() { if (typeof hljs !== "undefined") { document.querySelectorAll("pre code").forEach((el) => { hljs.highlightElement(el); }); } } } export const contentRenderer = new ContentRenderer(); window.contentRenderer = contentRenderer;