agent-zero/webui/js/safe-markdown.js
Alessandro 89901b64f0
Some checks are pending
Build And Publish Docker Images / plan (push) Waiting to run
Build And Publish Docker Images / build (push) Blocked by required conditions
Polish native Markdown editor experience
Expand the dedicated Editor surface with safe rendered preview mode, ACE-backed source editing, browser-style tabs, toolbar/file actions, preview search, and richer Markdown rendering for code blocks, task lists, images, tables, math, local links, and footnotes.

Keep open Markdown files synchronized with the active context and saved tool edits, including live refresh for document_artifact and text_editor results without routing Markdown through Desktop/Office.

Add inline preview-page editing, clickable preview task-list checkboxes, source editor rehydration after preview-mode refreshes, and regression coverage for the new editor wiring and sync behavior.
2026-05-15 04:47:24 +02:00

200 lines
5.4 KiB
JavaScript

import DOMPurify from "/vendor/dompurify/purify.es.mjs";
import { marked } from "/vendor/marked/marked.esm.js";
import { addBlankTargetsToLinks } from "/js/html-links.js";
const GITHUB_REPO_ROUTE_PREFIXES = new Set([
"actions",
"blob",
"branches",
"commit",
"commits",
"compare",
"discussions",
"issues",
"labels",
"milestones",
"packages",
"projects",
"pulls",
"raw",
"releases",
"security",
"tags",
"tree",
"wiki",
]);
const DOMPURIFY_CONFIG = Object.freeze({
USE_PROFILES: { html: true },
FORBID_TAGS: ["script", "iframe", "object", "embed", "svg", "math"],
});
const DATA_IMAGE_URL_PATTERN =
/^data:image\/(?:png|jpe?g|gif|webp|bmp);base64,[a-z0-9+/=\s]+$/i;
function getDompurifyConfig(options = {}) {
const config = { ...DOMPURIFY_CONFIG };
if (options.allowLatex) {
config.ADD_TAGS = ["latex"];
}
return config;
}
function parseGithubRepoContext(githubUrl) {
if (!githubUrl || typeof githubUrl !== "string") return null;
let repoUrl;
try {
repoUrl = new URL(githubUrl.trim().replace(/\.git$/i, ""));
} catch {
return null;
}
if (repoUrl.hostname !== "github.com") return null;
const [owner, repo] = repoUrl.pathname
.replace(/^\/+|\/+$/g, "")
.split("/");
if (!owner || !repo) return null;
return { owner, repo };
}
function shouldSkipRebase(value) {
return (
!value ||
value.startsWith("#") ||
value.startsWith("//") ||
/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value)
);
}
function resolveRepoPath(value) {
if (shouldSkipRebase(value)) return null;
try {
const resolved = new URL(value, "https://repo-root.invalid/");
return `${resolved.pathname.replace(/^\/+/, "")}${resolved.search}${resolved.hash}`;
} catch {
return null;
}
}
function isGithubRepoRoutePath(repoPath) {
const pathOnly = repoPath
.split(/[?#]/, 1)[0]
.replace(/^\/+|\/+$/g, "");
if (!pathOnly) return false;
const firstSegment = pathOnly.split("/")[0].toLowerCase();
return GITHUB_REPO_ROUTE_PREFIXES.has(firstSegment);
}
function isSafeUrlValue(value, attributeName, options = {}) {
const normalized = String(value || "").trim();
if (!normalized) return true;
if (
options.allowDataImages &&
attributeName === "src" &&
DATA_IMAGE_URL_PATTERN.test(normalized)
) {
return true;
}
if (
normalized.startsWith("#") ||
normalized.startsWith("/") ||
normalized.startsWith("./") ||
normalized.startsWith("../") ||
normalized.startsWith("?")
) {
return true;
}
try {
const url = new URL(normalized, "https://sanitizer.invalid/");
if (url.origin === "https://sanitizer.invalid") {
return true;
}
const protocol = url.protocol.toLowerCase();
if (protocol === "http:" || protocol === "https:") return true;
if (attributeName === "href" && (protocol === "mailto:" || protocol === "tel:")) {
return true;
}
} catch {
return false;
}
return false;
}
function stripUnsafeUrlAttributes(html, options = {}) {
const doc = new DOMParser().parseFromString(html, "text/html");
doc.querySelectorAll("[href], [src]").forEach((element) => {
for (const attributeName of ["href", "src"]) {
if (!element.hasAttribute(attributeName)) continue;
const value = element.getAttribute(attributeName) || "";
if (!isSafeUrlValue(value, attributeName, options)) {
element.removeAttribute(attributeName);
}
}
});
return doc.body.innerHTML;
}
export function sanitizeHtml(html, options = {}) {
if (!html || typeof html !== "string") return "";
const sanitized = DOMPurify.sanitize(html, getDompurifyConfig(options));
return stripUnsafeUrlAttributes(sanitized, options);
}
export function rebaseGithubReadmeHtml(html, githubUrl, branch) {
if (!html || typeof html !== "string" || !branch) return html;
const repoContext = parseGithubRepoContext(githubUrl);
if (!repoContext) return html;
const { owner, repo } = repoContext;
const repoWebBase = `https://github.com/${owner}/${repo}`;
const repoBlobBase = `${repoWebBase}/blob/${branch}`;
const repoRawBase = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}`;
const doc = new DOMParser().parseFromString(html, "text/html");
// Single-segment links like "releases" are ambiguous, so README rebasing
// needs an explicit GitHub repo-route allowlist instead of a single base URL.
doc.querySelectorAll("a[href]").forEach((anchor) => {
const href = (anchor.getAttribute("href") || "").trim();
const repoPath = resolveRepoPath(href);
if (!repoPath) return;
const base = isGithubRepoRoutePath(repoPath) ? repoWebBase : repoBlobBase;
anchor.setAttribute("href", `${base}/${repoPath}`);
});
doc.querySelectorAll("img[src]").forEach((image) => {
const src = (image.getAttribute("src") || "").trim();
const repoPath = resolveRepoPath(src);
if (!repoPath) return;
image.setAttribute("src", `${repoRawBase}/${repoPath}`);
});
return doc.body.innerHTML;
}
export function renderSafeMarkdown(markdown, options = {}) {
if (!markdown) return "";
const { githubUrl = "", branch = "", openExternalLinksInNewTab = true } = options;
let html = marked.parse(markdown, { breaks: true });
if (githubUrl && branch) {
html = rebaseGithubReadmeHtml(html, githubUrl, branch);
}
html = sanitizeHtml(html, options);
if (openExternalLinksInNewTab) {
html = addBlankTargetsToLinks(html);
}
return html;
}