- Add new `/projects/{slug}/files` endpoint group for per-project filesystem operations including directory and file CRUD, upload, and inline editing with public read and owner write access
- Extend attachment system to support video formats (webm, ogv, mov, m4v) with proper file icons and MIME types
- Implement configurable allowed file types via `allowed_file_types` site setting, replacing hardcoded `ALLOWED_UPLOAD_TYPES` with dynamic `allowed_extensions()` and `is_extension_allowed()` functions
- Add `delete_all_project_files()` call in `delete_content_item()` to clean up project files when a project is deleted
- Create database indexes on `project_files` table for `(project_uid, path)` and `(project_uid, parent_path)` to optimize file lookups
- Introduce `docs_prose.py` module with `render_prose()` function that renders Markdown content inside `data-render` divs using mistune, enabling dynamic prose rendering in documentation pages
- Enhance docs search with Markdown-aware text stripping (`_demarkdown()`) and improved HTML/script/style sanitization for better search indexing
- Update documentation API samples to reflect new attachment response fields (`is_image`, `is_video`, `mime_type`) and note video format support
- Update README to document the new project files endpoint and clarify AI gateway attribution for guest Devii sessions
214 lines
8.3 KiB
JavaScript
214 lines
8.3 KiB
JavaScript
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 = "<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);
|
|
|
|
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 = (
|
|
'<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 = "";
|
|
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;
|