diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index 68ae93bda4ae..27c8f238d519 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -209,3 +209,8 @@ display: block; } } + +[data-component="markdown"] a.external-link:hover > code { + text-decoration: underline; + text-underline-offset: 2px; +} diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 608db818f549..4c3d5628418b 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -49,6 +49,19 @@ type CopyLabels = { copied: string } +const urlPattern = /^https?:\/\/[^\s<>()`"']+$/ + +function codeUrl(text: string) { + const href = text.trim().replace(/[),.;!?]+$/, "") + if (!urlPattern.test(href)) return + try { + const url = new URL(href) + return url.toString() + } catch { + return + } +} + function createIcon(path: string, slot: string) { const icon = document.createElement("div") icon.setAttribute("data-component", "icon") @@ -110,9 +123,39 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) { wrapper.appendChild(createCopyButton(labels)) } + const markCodeLinks = () => { + const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code")) + for (const code of codeNodes) { + const href = codeUrl(code.textContent ?? "") + const parentLink = + code.parentElement instanceof HTMLAnchorElement && code.parentElement.classList.contains("external-link") + ? code.parentElement + : null + + if (!href) { + if (parentLink) parentLink.replaceWith(code) + continue + } + + if (parentLink) { + parentLink.href = href + continue + } + + const link = document.createElement("a") + link.href = href + link.className = "external-link" + link.target = "_blank" + link.rel = "noopener noreferrer" + code.parentNode?.replaceChild(link, code) + link.appendChild(code) + } + } + const handleClick = async (event: MouseEvent) => { const target = event.target if (!(target instanceof Element)) return + const button = target.closest('[data-slot="markdown-copy-button"]') if (!(button instanceof HTMLButtonElement)) return const code = button.closest('[data-component="markdown-code"]')?.querySelector("code") @@ -132,6 +175,7 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) { for (const block of blocks) { ensureWrapper(block) } + markCodeLinks() const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]')) for (const button of buttons) {