// Control UI module implements markdown behavior. import DOMPurify from "dompurify"; import hljs from "highlight.js/lib/core"; import bash from "highlight.js/lib/languages/bash"; import cpp from "highlight.js/lib/languages/cpp"; import css from "highlight.js/lib/languages/css"; import diff from "highlight.js/lib/languages/diff"; import go from "highlight.js/lib/languages/go"; import java from "highlight.js/lib/languages/java"; import javascript from "highlight.js/lib/languages/javascript"; import json from "highlight.js/lib/languages/json"; import markdown from "highlight.js/lib/languages/markdown"; import python from "highlight.js/lib/languages/python"; import rust from "highlight.js/lib/languages/rust"; import typescript from "highlight.js/lib/languages/typescript"; import xml from "highlight.js/lib/languages/xml"; import yaml from "highlight.js/lib/languages/yaml"; import MarkdownIt from "markdown-it"; import markdownItTaskLists from "markdown-it-task-lists"; import { stripUnsupportedCitationControlMarkers } from "../../../src/shared/text/citation-control-markers.js"; import { inferBasePathFromPathname, normalizeBasePath, routeIdFromPath, } from "../app-route-paths.ts"; import { i18n, t } from "../i18n/index.ts"; import { copyToClipboard } from "../lib/clipboard.ts"; import { truncateText } from "../lib/format.ts"; import { normalizeLowercaseStringOrEmpty } from "../lib/string-coerce.ts"; const allowedTags = [ "a", "b", "blockquote", "br", "button", "code", "del", "details", "div", "em", "h1", "h2", "h3", "h4", "hr", "i", "input", "li", "ol", "p", "pre", "s", "span", "strong", "summary", "table", "tbody", "td", "th", "thead", "tr", "ul", "img", ]; const allowedAttrs = [ "checked", "class", "disabled", "href", "rel", "target", "title", "start", "src", "alt", "data-code", "data-code-encoding", "type", "aria-label", ]; const sanitizeOptions = { ALLOWED_TAGS: allowedTags, ALLOWED_ATTR: allowedAttrs, ADD_DATA_URI_TAGS: ["img"], }; let hooksInstalled = false; const MARKDOWN_CHAR_LIMIT = 140_000; const MARKDOWN_PARSE_LIMIT = 40_000; const MARKDOWN_CACHE_LIMIT = 200; const MARKDOWN_CACHE_MAX_CHARS = 50_000; const INLINE_DATA_IMAGE_RE = /^data:image\/[a-z0-9.+-]+;base64,/i; const BLOCK_ART_LINE_RE = /^[\t \u00a0▀▄█]+$/u; const BLOCK_ART_GLYPH_RE = /[▀▄█]/u; const blockArtCopyPayloadPrefix = "openclaw:block-art-code:"; export const blockArtCodeBlockCopyPayloadEncoding = "block-art-json"; const HOST_LOCAL_FILE_HREF_RE = /^(?:~\/|\/(?:Users|home|tmp|private\/tmp|var\/folders|private\/var\/folders)\/|\/[A-Za-z]:\/|[A-Za-z]:[\\/])/; const DOCS_ORIGIN = "https://docs.openclaw.ai"; const DOCS_ROOT_SEGMENTS = new Set([ "agent-runtime-architecture", "announcements", "auth-credential-semantics", "automation", "brave-search", "channels", "ci", "clawhub", "cli", "concepts", "date-time", "debug", "diagnostics", "gateway", "help", "index", "install", "logging", "maturity-scorecard", "network", "nodes", "openclaw-agent-runtime", "perplexity", "plan", "platforms", "plugins", "prose", "providers", "refactor", "reference", "security", "specs", "start", "tools", "tts", "vps", "web", ]); const DOCS_SHORTLINK_PATHS = new Set([ "/AGENTS.default", "/RELEASING", "/agent", "/agent-loop", "/agent-send", "/agent-workspace", "/android", "/anthropic", "/architecture", "/audio", "/auth-monitoring", "/azure", "/background-process", "/bash", "/bonjour", "/browser", "/browser-linux-troubleshooting", "/bun", "/camera", "/clawd", "/clawdhub", "/compaction", "/configuration", "/context", "/context-engine", "/control-ui", "/cron", "/cron-jobs", "/cron-vs-heartbeat", "/dashboard", "/device-models", "/discord", "/discovery", "/docker", "/doctor", "/duckduckgo-search", "/elevated", "/exa-search", "/experiments/plans/cron-add-hardening", "/experiments/plans/group-policy-hardening", "/faq", "/gateway-lock", "/gcp", "/gemini-search", "/getting-started", "/glm", "/gmail-pubsub", "/grammy", "/grok-search", "/group-messages", "/groups", "/health", "/heartbeat", "/hubs", "/images", "/imessage", "/ios", "/kimi-search", "/line", "/linux", "/location", "/location-command", "/lore", "/mac/bun", "/mac/canvas", "/mac/child-process", "/mac/dev-setup", "/mac/health", "/mac/icon", "/mac/logging", "/mac/menu-bar", "/mac/peekaboo", "/mac/permissions", "/mac/release", "/mac/remote", "/mac/signing", "/mac/skills", "/mac/voice-overlay", "/mac/voicewake", "/mac/webchat", "/mac/xpc", "/macos", "/mattermost", "/mcp", "/message", "/messages", "/minimax", "/mistral", "/model", "/model-failover", "/models", "/moonshot", "/multi-agent", "/nix", "/northflank", "/oauth", "/onboarding", "/openai", "/opencode", "/opencode-go", "/openrouter", "/pairing", "/pi", "/pi-dev", "/plugin", "/podman", "/poll", "/presence", "/provider-routing", "/qianfan", "/queue", "/quickstart", "/railway", "/remote", "/remote-gateway-readme", "/render", "/rpc", "/sandbox", "/sandboxing", "/session", "/session-tool", "/sessions", "/setup", "/showcase", "/signal", "/skill-workshop", "/skills", "/skills-config", "/slack", "/slash-commands", "/subagents", "/tailscale", "/talk", "/telegram", "/templates/AGENTS", "/templates/BOOT", "/templates/BOOTSTRAP", "/templates/HEARTBEAT", "/templates/IDENTITY", "/templates/SOUL", "/templates/TOOLS", "/templates/USER", "/test", "/thinking", "/timezone", "/troubleshooting", "/tui", "/typebox", "/updating", "/voicewake", "/web-fetch", "/webchat", "/webhook", "/whatsapp", "/windows", "/wizard", "/xiaomi", "/zai", ]); const APP_RESOURCE_ROOT_SEGMENTS = new Set([ "__openclaw", "__openclaw__", "_next", "api", "apple-touch-icon.png", "assets", "avatar", "favicon-32.png", "favicon.ico", "favicon.svg", "manifest.json", "manifest.webmanifest", "media", "res", "socket.io", "sw.js", "static", "ws", ]); const APP_RESOURCE_PATH_PREFIXES = [ ["plugins", "diffs"], ["plugins", "diffs-language-pack"], ]; type WindowWithControlUiBasePath = Window & typeof globalThis & { [key: string]: unknown; }; const markdownCache = new Map(); const TAIL_LINK_BLUR_CLASS = "chat-link-tail-blur"; const FENCE_OPEN_RE = /^[ \t]{0,3}(`{3,}|~{3,})/; const FENCE_CONTAINER_PREFIX_RE = /^[ \t]{0,3}(?:(?:>\s?)|(?:(?:[-+*]|\d{1,9}[.)])[ \t]+))/; export type MarkdownCodeBlockChrome = "copy" | "none"; export type MarkdownRenderOptions = { codeBlockChrome?: MarkdownCodeBlockChrome; }; type MarkdownRenderEnv = { codeBlockChrome: MarkdownCodeBlockChrome; }; // CJK character ranges for URL boundary detection (RFC 3986: CJK is not valid in raw URLs). // CJK Unified Ideographs, CJK Symbols/Punctuation, Fullwidth Forms, Hiragana, Katakana, // Hangul Syllables, and CJK Compatibility Ideographs. const CJK_RE = new RegExp( "[\\u2E80-\\u2FFF\\u3000-\\u303F\\u3040-\\u309F\\u30A0-\\u30FF\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uAC00-\\uD7AF\\uF900-\\uFAFF\\uFF01-\\uFF60]", ); function getCachedMarkdown(key: string): string | null { const cached = markdownCache.get(key); if (cached === undefined) { return null; } markdownCache.delete(key); markdownCache.set(key, cached); return cached; } function setCachedMarkdown(key: string, value: string) { markdownCache.set(key, value); if (markdownCache.size <= MARKDOWN_CACHE_LIMIT) { return; } const oldest = markdownCache.keys().next().value; if (oldest) { markdownCache.delete(oldest); } } function normalizeMarkdownRenderOptions(options: MarkdownRenderOptions = {}): MarkdownRenderEnv { return { codeBlockChrome: options.codeBlockChrome ?? "copy", }; } function shouldRenderCodeBlockCopy(env: unknown): boolean { return (env as Partial | undefined)?.codeBlockChrome !== "none"; } export function encodeBlockArtCodeBlockCopyPayload(value: string): string { return `${blockArtCopyPayloadPrefix}${JSON.stringify(value)}`; } export function decodeCodeBlockCopyPayload(value: string, encoding?: string): string { if ( encoding !== blockArtCodeBlockCopyPayloadEncoding || !value.startsWith(blockArtCopyPayloadPrefix) ) { return value; } try { const decoded = JSON.parse(value.slice(blockArtCopyPayloadPrefix.length)); return typeof decoded === "string" ? decoded : value; } catch { return value; } } export function handleMarkdownCodeBlockCopy(event: Event): void { const target = event.target; if (!(target instanceof Element)) { return; } const button = target.closest(".code-block-copy"); if (!button) { return; } const code = decodeCodeBlockCopyPayload(button.dataset.code ?? "", button.dataset.codeEncoding); void copyToClipboard(code).then((copied) => { if (!copied) { return; } button.classList.add("copied"); setTimeout(() => button.classList.remove("copied"), 1500); }); } function isHostLocalFileHref(href: string): boolean { return HOST_LOCAL_FILE_HREF_RE.test(href.trim()); } function isControlUiRoutePath(pathname: string): boolean { if (routeIdFromPath(pathname) !== null) { return true; } const basePath = currentControlUiBasePath(); if (!basePath) { return false; } if (pathname !== basePath && !pathname.startsWith(`${basePath}/`)) { return false; } return routeIdFromPath(pathname, basePath) !== null; } function currentControlUiBasePath(): string { if (typeof window === "undefined") { return ""; } const configured = (window as WindowWithControlUiBasePath)["__OPENCLAW_CONTROL_UI_BASE_PATH__"]; if (typeof configured === "string") { return normalizeBasePath(configured); } return inferBasePathFromPathname(window.location.pathname); } function pathSegments(pathname: string): string[] { return pathname.split("/").filter(Boolean); } function stripCurrentControlUiBasePath(pathname: string): string[] { const segments = pathSegments(pathname); const baseSegments = pathSegments(currentControlUiBasePath()); if ( baseSegments.length === 0 || baseSegments.some((segment, index) => segments[index] !== segment) ) { return segments; } return segments.slice(baseSegments.length); } function segmentsStartWith(segments: string[], prefix: string[]): boolean { return prefix.every((segment, index) => segments[index] === segment); } function isControlUiResourcePath(segments: string[]): boolean { if (segments.includes("__openclaw__") || segments.includes("__openclaw")) { return true; } const segment = segments[0]; if (!segment || APP_RESOURCE_ROOT_SEGMENTS.has(segment)) { return true; } return APP_RESOURCE_PATH_PREFIXES.some((prefix) => segmentsStartWith(segments, prefix)); } function isDocsRootPath(normalizedPath: string, segments: string[]): boolean { if (DOCS_SHORTLINK_PATHS.has(normalizedPath)) { return true; } const segment = segments[0]; return segment ? DOCS_ROOT_SEGMENTS.has(segment) : false; } function normalizeDocsRootHref(href: string): string { const trimmed = href.trim(); if (!trimmed.startsWith("/") || trimmed.startsWith("//")) { return href; } try { const url = new URL(trimmed, DOCS_ORIGIN); if (url.origin !== DOCS_ORIGIN) { return href; } const normalizedPath = url.pathname.replace(/\/+$/, "") || "/"; if (isControlUiRoutePath(normalizedPath)) { return href; } const segments = pathSegments(normalizedPath); const resourceSegments = stripCurrentControlUiBasePath(normalizedPath); if (isControlUiResourcePath(resourceSegments)) { return href; } if (isDocsRootPath(normalizedPath, segments)) { return url.href; } return href; } catch { return href; } } function installHooks() { if (hooksInstalled) { return; } hooksInstalled = true; DOMPurify.addHook("afterSanitizeAttributes", (node) => { if (!(node instanceof HTMLAnchorElement)) { return; } const href = node.getAttribute("href"); if (!href) { return; } if (isHostLocalFileHref(href)) { node.removeAttribute("href"); return; } const normalizedHref = normalizeDocsRootHref(href); if (normalizedHref !== href) { node.setAttribute("href", normalizedHref); } // Block dangerous URL schemes (javascript:, data:, vbscript:, etc.) try { const url = new URL(normalizedHref, window.location.href); if (url.protocol !== "http:" && url.protocol !== "https:" && url.protocol !== "mailto:") { node.removeAttribute("href"); return; } } catch { // Relative URLs are fine; malformed absolute URLs with dangerous schemes // will fail to parse and keep their href — but DOMPurify already strips // javascript: by default. This is defense-in-depth. } node.setAttribute("rel", "noreferrer noopener"); node.setAttribute("target", "_blank"); if (normalizeLowercaseStringOrEmpty(href).includes("tail")) { node.classList.add(TAIL_LINK_BLUR_CLASS); } }); } // ── markdown-it instance with custom renderers ── function escapeHtml(value: string): string { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function normalizeMarkdownImageLabel(text?: string | null): string { const trimmed = text?.trim(); return trimmed ? trimmed : "image"; } function normalizeMarkdownInput(markdownLocal: string): string { const input = stripUnsupportedCitationControlMarkers(markdownLocal).trim(); if (!input) { return ""; } return formatTruncatedMarkdownInput(input); } function formatTruncatedMarkdownInput(input: string): string { const truncated = truncateText(input, MARKDOWN_CHAR_LIMIT); return appendMarkdownTruncationNotice(truncated).replace(/\r\n?/g, "\n"); } function appendMarkdownTruncationNotice(truncated: { text: string; truncated: boolean; total: number; }): string { const notice = truncated.truncated ? `\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).` : ""; return `${truncated.text}${notice}`; } export function isMarkdownBlockArtText(value: string): boolean { const lines = value.replace(/\r\n?/g, "\n").split("\n"); const artLines = lines.filter((line) => line.trim().length > 0); if (artLines.length < 2) { return false; } // QR generators commonly use spaces plus upper/lower/full block glyphs. // Require multiple glyph-only lines so ordinary prose with a stray block character stays markdown. let glyphCount = 0; for (const line of artLines) { if (!BLOCK_ART_LINE_RE.test(line) || !BLOCK_ART_GLYPH_RE.test(line)) { return false; } glyphCount += Array.from(line).filter((char) => BLOCK_ART_GLYPH_RE.test(char)).length; } return glyphCount >= 8; } function getFenceMarker(line: string): { marker: "`" | "~"; length: number } | null { const match = FENCE_OPEN_RE.exec(stripFenceContainerPrefixes(line)); if (!match) { return null; } const fence = match[1]; const marker = fence[0] as "`" | "~"; return { marker, length: fence.length }; } function stripFenceContainerPrefixes(line: string): string { let current = line; for (let index = 0; index < 8; index += 1) { const next = current.replace(FENCE_CONTAINER_PREFIX_RE, ""); if (next === current) { return current; } current = next; } return current; } function isFenceClose(line: string, fence: { marker: "`" | "~"; length: number }): boolean { const trimmed = stripFenceContainerPrefixes(line).trimEnd(); const match = FENCE_OPEN_RE.exec(trimmed); if (!match) { return false; } const marker = match[1][0]; if (marker !== fence.marker || match[1].length < fence.length) { return false; } return trimmed.slice(match[0].length).trim() === ""; } function findStableStreamingMarkdownBoundary(markdownLocal: string): number { let boundary = 0; let index = 0; let openFence: { marker: "`" | "~"; length: number } | null = null; while (index < markdownLocal.length) { const nextLineBreak = markdownLocal.indexOf("\n", index); const lineEnd = nextLineBreak === -1 ? markdownLocal.length : nextLineBreak + 1; const line = markdownLocal.slice(index, nextLineBreak === -1 ? lineEnd : nextLineBreak); if (openFence) { if (isFenceClose(line, openFence)) { openFence = null; boundary = lineEnd; } index = lineEnd; continue; } const openingFence = getFenceMarker(line); if (openingFence) { openFence = openingFence; index = lineEnd; continue; } if (line.trim() === "") { boundary = lineEnd; } index = lineEnd; } return boundary; } for (const [language, definition, aliases] of [ ["bash", bash, ["sh", "shell"]], ["cpp", cpp, ["c++", "cxx"]], ["css", css, []], ["diff", diff, ["patch"]], ["go", go, ["golang"]], ["java", java, []], ["javascript", javascript, ["js", "jsx"]], ["json", json, []], ["markdown", markdown, ["md"]], ["python", python, ["py"]], ["rust", rust, ["rs"]], ["typescript", typescript, ["ts", "tsx"]], ["xml", xml, ["html", "svg"]], ["yaml", yaml, ["yml"]], ] as const) { hljs.registerLanguage(language, definition); if (aliases.length > 0) { hljs.registerAliases([...aliases], { languageName: language }); } } function normalizeHighlightLanguage(lang: string): string { const normalized = lang.trim().toLowerCase(); if (!normalized) { return ""; } const aliases: Record = { "c++": "cpp", cxx: "cpp", js: "javascript", jsx: "javascript", md: "markdown", sh: "bash", shell: "bash", ts: "typescript", tsx: "typescript", }; return aliases[normalized] ?? normalized; } const autoHighlightLanguages = [ "bash", "cpp", "css", "diff", "go", "java", "javascript", "json", "markdown", "python", "rust", "typescript", "xml", "yaml", ]; function highlightCode(text: string, lang: string): string { const language = normalizeHighlightLanguage(lang); try { if (language && hljs.getLanguage(language)) { return hljs.highlight(text, { language, ignoreIllegals: true }).value; } if (!language && text.trim()) { const result = hljs.highlightAuto(text, autoHighlightLanguages); if (result.relevance >= 2) { return result.value; } } } catch { // Fall back to escaped plaintext; malformed input should not break chat rendering. } return escapeHtml(text); } function codeClassAttribute(lang: string, highlighted: string): string { const classes = [ highlighted.includes("hljs-") ? "hljs" : "", lang ? `language-${lang}` : "", ].filter(Boolean); return classes.length > 0 ? ` class="${escapeHtml(classes.join(" "))}"` : ""; } function renderCodeElement( text: string, lang: string, options: { blockArt?: boolean } = {}, ): string { if (options.blockArt || isMarkdownBlockArtText(text)) { return `
${escapeHtml(text)}
`; } const highlighted = highlightCode(text, lang); const classAttr = codeClassAttribute(lang, highlighted); return `
${highlighted}
`; } function renderCodeBlock( text: string, lang: string, env: unknown, options: { blockArt?: boolean; copyText?: string } = {}, ): string { const blockArt = options.blockArt || isMarkdownBlockArtText(text); const codeBlock = renderCodeElement(text, lang, { blockArt }); if (!shouldRenderCodeBlockCopy(env)) { return codeBlock; } const langLabel = lang ? `${escapeHtml(lang)}` : ""; const copyText = options.copyText ?? text; const copyPayload = blockArt ? encodeBlockArtCodeBlockCopyPayload(copyText) : copyText; const attrSafe = escapeHtml(copyPayload); const encodingAttr = blockArt ? ` data-code-encoding="${blockArtCodeBlockCopyPayloadEncoding}"` : ""; const copyBtn = ``; const header = `
${langLabel}${copyBtn}
`; const trimmed = text.trim(); const isJson = lang === "json" || (!lang && ((trimmed.startsWith("{") && trimmed.endsWith("}")) || (trimmed.startsWith("[") && trimmed.endsWith("]")))); if (isJson) { const lineCount = text.split("\n").length; const label = lineCount > 1 ? `JSON · ${lineCount} lines` : "JSON"; return `
${label}
${header}${codeBlock}
`; } return `
${header}${codeBlock}
`; } function codeBlockCopyTextFromMarkdownToken(content: string): string { return content.endsWith("\n") ? content.slice(0, -1) : content; } export const md = new MarkdownIt({ html: true, // Enable HTML recognition so html_block/html_inline overrides can escape it breaks: true, linkify: true, }); // Enable GFM strikethrough (~~text~~) to match original marked.js behavior. // markdown-it uses tags; we added "s" to allowedTags for DOMPurify. md.enable("strikethrough"); // Disable fuzzy link detection to prevent bare filenames like "README.md" // from being auto-linked as "http://README.md". URLs with explicit protocol // (https://...) and emails are still linkified. // // Alternative considered: extensions/matrix/src/matrix/format.ts uses fuzzyLink // with a file-extension blocklist to filter false positives at render time. // We chose the www-only approach instead because: // 1. Matches original marked.js GFM behavior exactly (bare domains were never linked) // 2. No blocklist to maintain — new TLDs like .ai, .io, .dev would need constant updates // 3. Predictable behavior — users can always use explicit https:// for any URL md.linkify.set({ fuzzyLink: false }); // Re-enable www. prefix detection per GFM spec: bare URLs without protocol // must start with "www." to be auto-linked. This avoids false positives on // filenames while preserving expected behavior for "www.example.com". // GFM spec: valid domain = alphanumeric/underscore/hyphen segments separated // by periods, at least one period, no underscores in last two segments. md.linkify.add("www", { validate(text, pos) { const tail = text.slice(pos); // Match: . followed by domain and optional path, matching marked.js behavior. // Stops at whitespace, < (HTML tag boundary), or CJK characters (RFC 3986: // raw CJK is not valid in URLs; percent-encoded CJK like %E4%BD%A0 is fine). const match = tail.match( /^\.(?:[a-zA-Z0-9-]+\.?)+[^\s<\u2E80-\u2FFF\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF01-\uFF60]*/, ); if (!match) { return 0; } let len = match[0].length; // Strip trailing punctuation per GFM extended autolink spec. // GFM says: ?, !, ., ,, :, *, _, ~ are not part of the autolink if trailing. // Balance checking config: closeChar -> openChar mapping. // Strip trailing close chars only when unbalanced (more closes than opens). // For self-matching pairs like "", open === close (strip if odd count). const balancePairs: Record = { ")": "(", "]": "[", "}": "{", '"': '"', "'": "'", }; // Pre-count balanced pairs to avoid O(n²) rescans. // balance[closeChar] = count(open) - count(close), negative means unbalanced const balance: Record = {}; for (const [close, open] of Object.entries(balancePairs)) { balance[close] = 0; for (let i = 0; i < len; i++) { const c = tail[i]; if (open === close) { // Self-matching pair (e.g., "") — toggle between 0 and 1 if (c === open) { balance[close] = balance[close] === 0 ? 1 : 0; } } else if (c === open) { // Distinct open/close (e.g., ()) balance[close]++; } else if (c === close) { balance[close]--; } } } while (len > 0) { const ch = tail[len - 1]; // GFM trailing punctuation: ?, !, ., ,, :, *, _, ~ stripped unconditionally. // Semicolon is handled specially below (entity reference rule). if (/[?!.,:*_~]/.test(ch)) { len--; continue; } // GFM entity reference rule: strip trailing &entity; sequences. // Only strip ; when preceded by &+ (e.g., & < &hl;). if (ch === ";") { // Backward scan to find & (O(n) total, avoids string allocation) let j = len - 2; while (j >= 0 && /[a-zA-Z0-9]/.test(tail[j])) { j--; } // j < len - 2 ensures at least one alphanumeric between & and ; if (j >= 0 && tail[j] === "&" && j < len - 2) { len = j; continue; } // Not an entity reference, stop stripping break; } // Handle balanced pairs — only strip close char if unbalanced. const open = balancePairs[ch]; if (open !== undefined) { if (open === ch) { // Self-matching: strip if odd count (unbalanced) if (balance[ch] !== 0) { balance[ch] = 0; len--; continue; } } else if (balance[ch] < 0) { // Distinct pair: strip if more closes than opens balance[ch]++; len--; continue; } } break; } return len; }, normalize(match) { match.url = "http://" + match.url; }, }); // Override default link validator to allow all URLs through to renderers. // marked.js does not validate URLs at all — it generates / tags for // everything and relies on DOMPurify to strip dangerous schemes. // // We match this behavior exactly: // - All URLs pass validation, including javascript:, vbscript:, file:, data: // - Images: renderer.rules.image shows alt text for non-data-image URLs // - Links: DOMPurify strips dangerous href schemes, leaving safe anchor text // - Blocking at validateLink would skip token generation entirely, causing raw // markdown source to appear instead of graceful fallbacks. md.validateLink = () => true; // Trim trailing CJK characters from auto-linked URLs (RFC 3986: raw CJK is // not valid in URLs). markdown-it's built-in linkify for https:// URLs may // swallow adjacent CJK text into the URL. This core rule runs after linkify // and splits the CJK suffix back into a plain text token. md.core.ruler.after("linkify", "linkify-cjk-trim", (state) => { for (const blockToken of state.tokens) { if (blockToken.type !== "inline" || !blockToken.children) { continue; } const children = blockToken.children; for (let i = children.length - 1; i >= 0; i--) { const token = children[i]; if (token.type !== "link_open") { continue; } // Only trim linkify-generated autolinks, not explicit markdown links // like [OpenClaw中文](https://docs.openclaw.ai) where CJK in display // text is intentional and href must not be rewritten. if (token.markup !== "linkify") { continue; } // Use the display text to find CJK boundary (href may be percent-encoded) const textToken = children[i + 1]; if (!textToken || textToken.type !== "text") { continue; } const displayText = textToken.content; // Scan backward to find trailing CJK suffix only. // Middle CJK must be preserved (e.g. https://example.com/你/test stays intact); // only strip a contiguous CJK tail adjacent to non-URL text. let cjkIdx = displayText.length; while (cjkIdx > 0 && CJK_RE.test(displayText[cjkIdx - 1])) { cjkIdx--; } if (cjkIdx <= 0 || cjkIdx === displayText.length) { continue; } // Split: URL part and CJK tail from display text const trimmedDisplay = displayText.slice(0, cjkIdx); const cjkTail = displayText.slice(cjkIdx); // Rebuild href by preserving the scheme prefix that linkify added but // display text omits (e.g. "mailto:" for emails, "http://" for www links). const href = token.attrGet("href") ?? ""; const prefixLen = href.indexOf(displayText); const hrefPrefix = prefixLen > 0 ? href.slice(0, prefixLen) : ""; token.attrSet("href", hrefPrefix + trimmedDisplay); textToken.content = trimmedDisplay; // Find link_close and insert CJK text after it for (let j = i + 1; j < children.length; j++) { if (children[j].type === "link_close") { const tailToken = new state.Token("text", "", 0); tailToken.content = cjkTail; children.splice(j + 1, 0, tailToken); break; } } } } }); // Enable GFM task list checkboxes (- [x] / - [ ]). // enabled: false keeps checkboxes read-only (disabled="") — task lists in // chat messages are display-only, not interactive forms. // label: false avoids wrapping item text in