Menu
Public repository / 3af23b0e937520000e1aa43c7e2eb9de2675abd1
AkurAI-Mail / src/framework/vendor/rich-text.js
Latest change 3af23b0e937520000e1aa43c7e2eb9de2675abd1 - OLI-248: webmail list and reader as framework islands on a signals store by Ólafur Búi Ólafsson
// @ts-nocheck
// Vendored from AkurAI-Framework site/frontend/rich-text.js @ d7fe19b (see docs/framework-migration.md, gap G2). Do not edit here.
// rich-text.js — minimal rich-text editor island plus a DOM-free HTML sanitizer.
//
// <div data-island="rich-text" data-name="body">
// <div data-rt-toolbar>
// <button type="button" data-cmd="bold">B</button> … data-cmd="italic|underline|
// insertUnorderedList|insertOrderedList|link"
// </div>
// <div data-rt-editor contenteditable="true"></div>
// <input type="hidden" name="body">
// </div>
//
// The hidden input always holds sanitized HTML; an "rt-change" event (detail: html)
// bubbles from the root. Initial HTML comes from island state {"html": "..."}.
import { registerIsland } from "./islands.js";
const ALLOWED_TAGS = new Set([
"p", "div", "br", "b", "strong", "i", "em", "u", "s", "ul", "ol", "li",
"blockquote", "code", "pre", "a", "h1", "h2", "h3",
]);
const VOID_TAGS = new Set(["br"]);
const SAFE_URL = /^(https?:|mailto:)/i;
const TAG_RE = /<(\/?)([a-zA-Z][a-zA-Z0-9]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
const DROP_WITH_CONTENT_RE = /<(script|style)\b[\s\S]*?<\/\1\s*>/gi;
const HREF_RE = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i;
const escapeText = (s) => s.replace(/&(?!(?:[a-z]+|#\d+|#x[0-9a-f]+);)/gi, "&").replace(/</g, "<").replace(/>/g, ">");
const escapeAttr = (s) => s.replace(/&(?!(?:[a-z]+|#\d+|#x[0-9a-f]+);)/gi, "&").replace(/"/g, """).replace(/</g, "<");
function safeHref(attrs) {
const m = HREF_RE.exec(attrs);
if (!m) return null;
const url = (m[1] ?? m[2] ?? m[3] ?? "").trim();
return SAFE_URL.test(url) ? url : null;
}
export function sanitizeHtml(html) {
const src = String(html).replace(DROP_WITH_CONTENT_RE, "");
let out = "";
let last = 0;
for (const m of src.matchAll(TAG_RE)) {
out += escapeText(src.slice(last, m.index));
last = m.index + m[0].length;
const [, closing, rawName, attrs] = m;
const name = rawName.toLowerCase();
if (!ALLOWED_TAGS.has(name)) continue;
if (closing) {
if (!VOID_TAGS.has(name)) out += `</${name}>`;
} else if (name === "a") {
const href = safeHref(attrs);
out += href ? `<a href="${escapeAttr(href)}" rel="noopener noreferrer">` : "<a>";
} else {
out += `<${name}>`;
}
}
return out + escapeText(src.slice(last));
}
registerIsland("rich-text", (el, state) => {
const editor = el.querySelector("[data-rt-editor]");
const input = el.querySelector("input[type=hidden]");
if (!editor) return;
if (input && el.dataset.name) input.name = el.dataset.name;
editor.innerHTML = sanitizeHtml(state.html || "");
const sync = () => {
const html = sanitizeHtml(editor.innerHTML);
if (input) input.value = html;
el.dispatchEvent(new CustomEvent("rt-change", { bubbles: true, detail: html }));
};
editor.addEventListener("input", sync);
editor.addEventListener("paste", (e) => {
e.preventDefault();
const data = e.clipboardData;
const html = data.getData("text/html");
const clean = html ? sanitizeHtml(html) : escapeText(data.getData("text/plain"));
document.execCommand("insertHTML", false, clean);
});
el.querySelectorAll("[data-cmd]").forEach((btn) => {
btn.addEventListener("mousedown", (e) => e.preventDefault());
btn.addEventListener("click", () => {
const cmd = btn.dataset.cmd;
if (cmd === "link") {
const url = window.prompt("Link URL");
if (url && SAFE_URL.test(url.trim())) document.execCommand("createLink", false, url.trim());
} else {
document.execCommand(cmd, false, null);
}
sync();
});
});
sync();
});