Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions packages/ui/src/components/markdown-code-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
export type CopyLabels = {
copy: string
copied: string
}

const iconPaths = {
copy: '<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>',
check: '<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>',
}

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")
icon.setAttribute("data-slot", slot)
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
svg.setAttribute("data-slot", "icon-svg")
svg.setAttribute("fill", "none")
svg.setAttribute("viewBox", "0 0 20 20")
svg.setAttribute("aria-hidden", "true")
svg.innerHTML = path
icon.appendChild(svg)
return icon
}

function createCopyButton(labels: CopyLabels) {
const button = document.createElement("button")
button.type = "button"
button.setAttribute("data-component", "icon-button")
button.setAttribute("data-variant", "secondary")
button.setAttribute("data-slot", "markdown-copy-button")
button.setAttribute("aria-label", labels.copy)
button.setAttribute("data-tooltip", labels.copy)
button.appendChild(createIcon(iconPaths.copy, "copy-icon"))
button.appendChild(createIcon(iconPaths.check, "check-icon"))
return button
}

export function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boolean) {
if (copied) {
button.setAttribute("data-copied", "true")
button.setAttribute("aria-label", labels.copied)
button.setAttribute("data-tooltip", labels.copied)
return
}
button.removeAttribute("data-copied")
button.setAttribute("aria-label", labels.copy)
button.setAttribute("data-tooltip", labels.copy)
}

export function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
const parent = block.parentElement
if (!parent) return
const wrapped = parent.getAttribute("data-component") === "markdown-code"
if (!wrapped) {
const wrapper = document.createElement("div")
wrapper.setAttribute("data-component", "markdown-code")
parent.replaceChild(wrapper, block)
wrapper.appendChild(block)
wrapper.appendChild(createCopyButton(labels))
return
}

const buttons = Array.from(parent.querySelectorAll('[data-slot="markdown-copy-button"]')).filter(
(el): el is HTMLButtonElement => el instanceof HTMLButtonElement,
)

if (buttons.length === 0) {
parent.appendChild(createCopyButton(labels))
return
}

for (const button of buttons.slice(1)) {
button.remove()
}
}

export function markCodeLinks(root: HTMLDivElement) {
const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code"))
for (const code of codeNodes) {
const href = codeUrl(code.textContent ?? "")
const parent = code.parentElement
if (parent instanceof HTMLAnchorElement && !parent.classList.contains("external-link")) continue
const parentLink = parent instanceof HTMLAnchorElement ? parent : 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)
}
}

export function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
const timeouts = new Map<HTMLButtonElement, ReturnType<typeof setTimeout>>()

const updateLabel = (button: HTMLButtonElement) => {
const labels = getLabels()
const copied = button.getAttribute("data-copied") === "true"
setCopyState(button, labels, copied)
}

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")
const content = code?.textContent ?? ""
if (!content) return
const clipboard = navigator?.clipboard
if (!clipboard) return
try {
await clipboard.writeText(content)
setCopyState(button, getLabels(), true)
const existing = timeouts.get(button)
if (existing) clearTimeout(existing)
const timeout = setTimeout(() => {
setCopyState(button, getLabels(), false)
timeouts.delete(button)
}, 2000)
timeouts.set(button, timeout)
} catch (err) {
console.error("Clipboard copy failed", err)
}
}
Comment thread
Astro-Han marked this conversation as resolved.

const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]'))
for (const button of buttons) {
if (button instanceof HTMLButtonElement) updateLabel(button)
}

root.addEventListener("click", handleClick)

return () => {
root.removeEventListener("click", handleClick)
for (const timeout of timeouts.values()) {
clearTimeout(timeout)
}
}
}
47 changes: 47 additions & 0 deletions packages/ui/src/components/markdown-details.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Force every <details> element open by default.
*
* Why: in chat output, an LLM emitting `<details>` is grouping related
* content (extended explanation, code example, expanded discussion), not
* hiding low-value reasoning. Auto-collapsing on stream completion would
* make the user re-click to see what they just saw stream in. Default-open
* also sidesteps marked's HTML-block parser instability on partial input
* (without `</details>` closed, code fences can briefly parse outside the
* subtree, making content "jump out" of a closed block).
*
* Pairs with `preserveDetailsOpenState` in the morphdom diff: this helper
* makes the initial state open; that one preserves the user's manual
* collapse across subsequent diffs.
*/
export function forceOpenAllDetails(root: ParentNode): void {
for (const d of root.querySelectorAll<HTMLDetailsElement>("details")) {
d.setAttribute("open", "")
}
}

/**
* Preserve the user's <details> open state across morphdom diffs.
* After the user manually collapses a default-open details block, every
* subsequent re-render must keep it collapsed (otherwise i18n changes or
* other re-render triggers would flip it back to open via `forceOpenAllDetails`).
*/
export function preserveDetailsOpenState(fromEl: Element, toEl: Element): void {
if (fromEl instanceof HTMLDetailsElement && toEl instanceof HTMLDetailsElement) {
if (fromEl.hasAttribute("open")) toEl.setAttribute("open", "")
else toEl.removeAttribute("open")
}
}

const chevSvg =
'<svg class="chev" viewBox="0 0 16 16" aria-hidden="true"><path d="M6 4l4 4-4 4" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/></svg>'

export function ensureDetailsChev(root: ParentNode) {
const summaries = Array.from(root.querySelectorAll<HTMLElement>("details > summary"))
for (const summary of summaries) {
if (summary.querySelector("svg.chev")) continue
const wrap = document.createElement("template")
wrap.innerHTML = chevSvg
const svg = wrap.content.firstElementChild
if (svg) summary.insertBefore(svg, summary.firstChild)
}
}
97 changes: 97 additions & 0 deletions packages/ui/src/components/markdown-link-routing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
export type LinkAction =
| { kind: "external"; url: string }
| { kind: "reveal"; path: string }
| { kind: "anchor"; url: string }
| { kind: "block" }

export function resolveLinkAction(href: string): LinkAction {
const trimmed = href.trim()
if (!trimmed) return { kind: "block" }
if (trimmed.startsWith("#")) return { kind: "anchor", url: trimmed }
// Protocol-relative URLs (//host/path) are remote-shaped; never reveal.
if (trimmed.startsWith("//")) return { kind: "block" }
// Block dangerous schemes outright; sanitize already strips most of these
// at the href level, but defense in depth keeps the routing predictable.
if (/^(?:javascript|data|vbscript):/i.test(trimmed)) return { kind: "block" }
// Windows absolute paths (`C:\path` / `D:/path`) shape-match a generic
// single-letter scheme followed by `:`. Catch them before the generic
// scheme regex below so they reveal instead of routing to a browser.
if (/^[a-z]:[\\/]/i.test(trimmed)) return { kind: "reveal", path: trimmed }
// Any other scheme (https, mailto, vscode, tel, sms, git, ssh, ...) routes
// to the external handler. Whether the scheme is actually surfaced to
// users is governed by the DOMPurify ALLOWED_URI_REGEXP allowlist, not
// here, which keeps schemes opt-in at sanitize time without forcing
// every new addition to also touch the click router.
if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return { kind: "external", url: trimmed }
return { kind: "reveal", path: trimmed }
}

export type LinkActionHandlers = {
openExternal?: (url: string) => void
revealPath?: (path: string) => void
}

export function setupLinkClicks(root: HTMLDivElement, handlers: LinkActionHandlers) {
const handler = (event: MouseEvent) => {
if (event.defaultPrevented) return
const target = event.target
if (!(target instanceof Element)) return
const anchor = target.closest("a")
if (!(anchor instanceof HTMLAnchorElement)) return
if (anchor.closest('[data-slot="markdown-copy-button"]')) return
const href = anchor.getAttribute("href") ?? ""
const action = resolveLinkAction(href)
event.preventDefault()
if (action.kind === "block") return
if (action.kind === "anchor") {
const id = action.url.slice(1)
if (!id) return
const inner = root.querySelector(`#${CSS.escape(id)}`)
if (inner instanceof HTMLElement) inner.scrollIntoView({ block: "start" })
return
}
const desktop =
typeof window !== "undefined"
? (window as unknown as {
api?: {
openLink?: (url: string) => void
showItemInFolder?: (path: string) => unknown
}
}).api
: undefined
if (action.kind === "external") {
if (handlers.openExternal) {
handlers.openExternal(action.url)
} else if (desktop?.openLink) {
desktop.openLink(action.url)
} else if (typeof window !== "undefined") {
window.open(action.url, "_blank", "noopener,noreferrer")
}
return
}
if (action.kind === "reveal") {
if (handlers.revealPath) {
handlers.revealPath(action.path)
} else if (desktop?.showItemInFolder) {
void desktop.showItemInFolder(action.path)
}
}
}
// Capture phase so descendant stopPropagation cannot bypass routing.
root.addEventListener("click", handler, true)
return () => root.removeEventListener("click", handler, true)
}

export function setupImageClicks(root: HTMLDivElement, openImage: (src: string) => void) {
const handler = (event: MouseEvent) => {
if (event.defaultPrevented) return
const target = event.target
if (!(target instanceof HTMLImageElement)) return
const src = target.getAttribute("src") ?? ""
if (!src) return
event.preventDefault()
openImage(src)
}
root.addEventListener("click", handler)
return () => root.removeEventListener("click", handler)
}
56 changes: 56 additions & 0 deletions packages/ui/src/components/markdown-sanitize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import DOMPurify from "dompurify"

const config = {
USE_PROFILES: { html: true, mathMl: true },
SANITIZE_NAMED_PROPS: true,
FORBID_TAGS: ["script", "iframe", "style", "form", "object", "embed"],
FORBID_CONTENTS: ["script", "iframe", "style"],
ALLOWED_URI_REGEXP: /^(?!\/\/)(?:(?:https?|mailto|file):|\/|\.{1,2}\/|#|[^:]*$)/i,
}

export const sanitizeConfig = config

if (typeof window !== "undefined" && DOMPurify.isSupported) {
DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => {
if (!(node instanceof HTMLAnchorElement)) return
if (node.target !== "_blank") return

const rel = node.getAttribute("rel") ?? ""
const set = new Set(rel.split(/\s+/).filter(Boolean))
set.add("noopener")
set.add("noreferrer")
node.setAttribute("rel", Array.from(set).join(" "))
})
// Allow only disabled checkbox inputs (GFM task list); strip every other
// input variant that DOMPurify's html profile would otherwise pass through.
DOMPurify.addHook("uponSanitizeElement", (node, data) => {
if (data.tagName !== "input") return
if (!(node instanceof HTMLInputElement)) return
const type = (node.getAttribute("type") ?? "").toLowerCase()
if (type !== "checkbox") {
node.parentNode?.removeChild(node)
return
}
node.setAttribute("disabled", "")
})
}

export function sanitizeMarkdownHtml(html: string) {
if (!DOMPurify.isSupported) return ""
return DOMPurify.sanitize(html, config)
}

export const sanitizeForTest = sanitizeMarkdownHtml

function escapeMarkdownText(text: string) {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#39;")
}

export function fallbackMarkdownHtml(markdown: string) {
return escapeMarkdownText(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>")
}
Loading
Loading