// retoor <retoor@molodetz.nl>
export class ContentRenderer {
constructor() {
this.emojiMap = {};
this.emojiLoaded = false;
this.imageExtRe = /\.(jpg|jpeg|png|gif|webp|svg|bmp|webp)(\?.*)?$/i;
this.videoExtRe = /\.(mp4|webm|ogg|ogv|mov|m4v)(\?.*)?$/i;
this.audioExtRe = /\.(mp3|wav|flac|ogg|aac|m4a|wma|opus)(\?.*)?$/i;
this.youtubeRe = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
}
replaceShortcodes(text) {
return text.replace(/:([a-zA-Z0-9_+\-]+):/g, (match, name) => {
return this.emojiMap[name.toLowerCase()] || match;
});
}
normalizeDashes(text) {
return text.replace(/\u2014/g, "-");
}
render(text, authorAdmin = false) {
if (!text) return "";
let widgets = [];
const widgetRe = /<dp-widget>([\s\S]*?)<\/dp-widget>/gi;
if (authorAdmin && widgetRe.test(text)) {
widgetRe.lastIndex = 0;
let m;
let idx = 0;
let parts = [];
let last = 0;
while ((m = widgetRe.exec(text)) !== null) {
if (m.index > last) {
parts.push(text.substring(last, m.index));
}
parts.push("\x00WIDGET_" + idx + "\x00");
widgets.push(m[1]);
idx++;
last = m.index + m[0].length;
}
if (last < text.length) {
parts.push(text.substring(last));
}
text = parts.join("");
}
text = this.normalizeDashes(text);
text = this.replaceShortcodes(text);
let html;
if (typeof marked !== "undefined") {
html = marked.parse(text, { breaks: true, gfm: true });
} else {
html = "<p>" + text.replace(/\n/g, "<br>") + "</p>";
}
if (typeof DOMPurify === "undefined") {
throw new Error("DOMPurify not loaded; refusing to render untrusted HTML");
}
html = DOMPurify.sanitize(html);
for (let i = 0; i < widgets.length; i++) {
html = html.replace("\x00WIDGET_" + i + "\x00", widgets[i]);
}
html = this.processMedia(html);
return html;
}
plainText(text) {
if (!text) return "";
let html;
try {
html = this.render(text);
} catch (err) {
return String(text).replace(/\s+/g, " ").trim();
}
const div = document.createElement("div");
div.innerHTML = html;
return (div.textContent || "").replace(/\s+/g, " ").trim();
}
preview(text, length = 60) {
const plain = this.plainText(text);
if (plain.length <= length) return plain;
return plain.slice(0, length).replace(/\s+$/, "") + "...";
}
escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
renderInline(text, authorAdmin = false) {
if (!text) return "";
let widgets = [];
const widgetRe = /<dp-widget>([\s\S]*?)<\/dp-widget>/gi;
if (authorAdmin && widgetRe.test(text)) {
widgetRe.lastIndex = 0;
let m;
let idx = 0;
let parts = [];
let last = 0;
while ((m = widgetRe.exec(text)) !== null) {
if (m.index > last) {
parts.push(text.substring(last, m.index));
}
parts.push("\x00WIDGET_" + idx + "\x00");
widgets.push(m[1]);
idx++;
last = m.index + m[0].length;
}
if (last < text.length) {
parts.push(text.substring(last));
}
text = parts.join("");
}
text = this.normalizeDashes(text);
text = this.replaceShortcodes(text);
let html;
if (typeof marked !== "undefined" && typeof marked.parseInline === "function") {
html = marked.parseInline(text, { gfm: true });
} else {
html = this.escapeHtml(text);
}
if (typeof DOMPurify === "undefined") {
throw new Error("DOMPurify not loaded; refusing to render untrusted HTML");
}
html = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ["b", "strong", "i", "em", "code", "del", "s", "mark", "sub", "sup", "span", "br"],
ALLOWED_ATTR: [],
});
for (let i = 0; i < widgets.length; i++) {
html = html.replace("\x00WIDGET_" + i + "\x00", widgets[i]);
}
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) });
}
}
altFromUrl(url) {
try {
const path = new URL(url, window.location.origin).pathname;
const file = decodeURIComponent(path.split("/").pop() || "");
const name = file.replace(/\.[^.]+$/, "").replace(/[-_]/g, " ").trim();
return name || "Embedded image";
} catch {
return "Embedded image";
}
}
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 = (
'<iframe src="https://www.youtube.com/embed/' + videoId + '" ' +
'frameborder="0" allowfullscreen ' +
'allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture">' +
'</iframe>'
);
return wrap;
}
if (this.imageExtRe.test(url)) {
const img = document.createElement("img");
img.src = url;
img.alt = this.altFromUrl(url);
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;
}
if (this.audioExtRe.test(url)) {
const audio = document.createElement("audio");
audio.src = url;
audio.controls = true;
audio.preload = "metadata";
return audio;
}
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 authorAdmin = element.hasAttribute("data-author-admin");
const text = element.textContent || element.innerText || "";
const rendered = this.render(text, authorAdmin);
element.innerHTML = rendered;
}
highlightAll() {
if (typeof hljs !== "undefined") {
document.querySelectorAll("pre code").forEach((el) => {
hljs.highlightElement(el);
});
}
}
}
export const contentRenderer = new ContentRenderer();
contentRenderer.ready = import("./emoji-shortcodes.js")
.then((mod) => {
contentRenderer.emojiMap = mod.EMOJI_SHORTCODES;
})
.catch(() => {})
.finally(() => {
contentRenderer.emojiLoaded = true;
});
window.contentRenderer = contentRenderer;