feat(session-ui): show GitHub mark on Markdown links (#51000)

This commit is contained in:
opencode-agent[bot]
2026-09-24 14:08:32 +08:00
committed by GitHub
Unverified
parent dca73ba9e3
commit 0bc8b8dbeb
7 changed files with 231 additions and 1 deletions
@@ -163,6 +163,89 @@ story("mounts cached completed Markdown with sanitized HTML and decorations", as
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
})
story("shows a stable GitHub mark without changing link text or other sites", async ({ page }) => {
await page.evaluate(async (fixture) => {
const { mountMarkdown } = await import(fixture)
await mountMarkdown({
text: [
"[#540](https://github.com/anomalyco/opencode/pull/540)",
"[GitHub](https://github.com)",
"[other site](https://example.com/docs)",
"[lookalike](https://github.com.evil.example/pull/540)",
].join(" · "),
cached: true,
})
}, fixture)
const markdown = page.getByTestId("markdown-fixture").locator('[data-component="markdown"]')
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
const github = markdown.getByRole("link", { name: "#540" })
await expect(github).toHaveAttribute("href", "https://github.com/anomalyco/opencode/pull/540")
await expect(github).toHaveText("#540")
expect(await github.evaluate((link) => getComputedStyle(link, "::before").width)).toBe("14px")
expect(await github.evaluate((link) => getComputedStyle(link, "::before").maskImage)).toContain("data:image/svg+xml")
expect(
await markdown.getByRole("link", { name: "GitHub" }).evaluate((link) => getComputedStyle(link, "::before").content),
).toBe('""')
for (const name of ["other site", "lookalike"]) {
expect(
await markdown.getByRole("link", { name }).evaluate((link) => getComputedStyle(link, "::before").content),
).toBe("none")
}
})
story("keeps favicon space stable across loading and failure without fetching private hosts", async ({ page }) => {
const requested: string[] = []
let release: () => void = () => undefined
const loading = new Promise<void>((resolve) => (release = resolve))
await page.route("https://www.google.com/s2/favicons?**", async (route) => {
requested.push(route.request().url())
if (route.request().url().includes("developer.mozilla.org")) await loading
if (route.request().url().includes("broken.example.org")) return route.abort()
await route.fulfill({ status: 200, contentType: "image/png", body: png })
})
await page.evaluate(async (fixture) => {
const { mountMarkdown } = await import(fixture)
await mountMarkdown({
text: [
"[docs](https://developer.mozilla.org/docs)",
"[missing](https://broken.example.org/docs)",
"[private](http://localhost:8080/docs)",
"[GitHub](https://github.com/anomalyco/opencode)",
].join(" · "),
cached: true,
})
}, fixture)
const markdown = page.getByTestId("markdown-fixture").locator('[data-component="markdown"]')
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
const docs = markdown.getByRole("link", { name: "docs" })
const image = docs.locator(".markdown-link-favicon img")
await expect.poll(() => requested.some((url) => url.includes("developer.mozilla.org"))).toBe(true)
await expect(image).toHaveCSS("opacity", "0")
await expect(docs.locator(".markdown-link-favicon")).toHaveCSS("width", "14px")
const width = await docs.evaluate((link) => link.getBoundingClientRect().width)
release()
await expect(image).toHaveAttribute("data-loaded", "")
await expect(image).toHaveCSS("opacity", "1")
await expect(image).not.toHaveAttribute("role", "button")
expect(await image.evaluate((favicon) => favicon.onclick)).toBeNull()
expect(await docs.evaluate((link) => link.getBoundingClientRect().width)).toBe(width)
expect(
await image.evaluate((favicon) => favicon.getBoundingClientRect().top - favicon.parentElement!.getBoundingClientRect().top),
).toBe(0)
await expect(markdown.getByRole("link", { name: "missing" }).locator(".markdown-link-favicon img")).not.toHaveAttribute(
"data-loaded",
"",
)
await expect(markdown.getByRole("link", { name: "private" }).locator(".markdown-link-favicon img")).toHaveCount(0)
await expect(markdown.getByRole("link", { name: "GitHub" }).locator(".markdown-link-favicon")).toHaveCount(0)
expect(requested).toHaveLength(2)
expect(requested.every((url) => !url.includes("localhost") && !url.includes("github.com"))).toBe(true)
})
async function resolvedColor(page: Page, token: string) {
return page.evaluate((token) => {
const probe = document.createElement("span")
@@ -16,7 +16,7 @@ export function createImagePreview() {
}
return (root: HTMLElement) => {
root.querySelectorAll<HTMLImageElement>("img").forEach((image) => {
root.querySelectorAll<HTMLImageElement>("img:not([data-markdown-favicon])").forEach((image) => {
image.setAttribute("role", "button")
image.setAttribute("aria-haspopup", "dialog")
image.setAttribute("aria-label", image.alt || i18n.t("ui.imagePreview.alt"))
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.23.49-2.69-.94-2.69-.94-.36-.92-.89-1.17-.89-1.17-.73-.5.06-.49.06-.49.8.06 1.22.82 1.22.82.71 1.21 1.87.86 2.33.66.07-.51.28-.86.51-1.06-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.19 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>
</svg>

After

Width:  |  Height:  |  Size: 645 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="black">
<circle cx="8" cy="8" r="6"/>
<ellipse cx="8" cy="8" rx="2.5" ry="6"/>
<path d="M2 8h12"/>
</svg>

After

Width:  |  Height:  |  Size: 192 B

@@ -101,6 +101,55 @@
text-underline-offset: 2px;
}
a.external-link:is(
[href="https://github.com" i],
[href^="https://github.com/" i],
[href="http://github.com" i],
[href^="http://github.com/" i]
):not(:has(img))::before {
content: "";
display: inline-block;
width: 14px;
height: 14px;
margin-inline-end: 4px;
vertical-align: -0.125em;
background: currentColor;
mask: url("./markdown-github.svg") center / contain no-repeat;
}
.markdown-link-favicon {
position: relative;
display: inline-block;
width: 14px;
height: 14px;
margin-inline-end: 4px;
vertical-align: -0.125em;
}
.markdown-link-favicon::before {
content: "";
position: absolute;
inset: 0;
background: currentColor;
mask: url("./markdown-globe.svg") center / contain no-repeat;
}
.markdown-link-favicon img {
position: absolute;
inset: 0;
display: block;
width: 14px;
height: 14px;
margin: 0;
border-radius: 2px;
cursor: inherit;
opacity: 0;
}
.markdown-link-favicon img[data-loaded] {
opacity: 1;
}
a[data-local-link] {
cursor: pointer;
}
@@ -85,3 +85,42 @@ export const StreamingInlineCode = {
</div>
),
}
const externalLinkChunks = [
"1. ",
"[#540](https://github.com/anomalyco/opencode/pull/540)",
": Batch access-policy reads.\n2. ",
"[Stack Overflow](https://stackoverflow.com/questions/123)",
": Favicon from a public site.\n3. ",
"[MDN docs](https://developer.mozilla.org/en-US/docs/Web)",
": Globe while loading.\n4. ",
"[Unavailable favicon](https://absent-site.example.org/docs)",
": Globe fallback.\n5. ",
"[Local host](http://localhost:8080/docs)",
": No third-party request.",
]
function StreamingExternalLinks() {
const [count, setCount] = createSignal(1)
const timer = setInterval(() => {
setCount((value) => {
if (value === externalLinkChunks.length) {
clearInterval(timer)
return value
}
return value + 1
})
}, 380)
onCleanup(() => clearInterval(timer))
return (
<Markdown text={externalLinkChunks.slice(0, count()).join("")} streaming={count() < externalLinkChunks.length} />
)
}
export const ExternalLinksStreaming = {
render: () => (
<div class="mx-auto max-w-[680px] rounded-lg border border-border-weak-base bg-background-base px-5 py-4">
<StreamingExternalLinks />
</div>
),
}
@@ -289,6 +289,50 @@ function markCodeLinks(root: HTMLDivElement) {
}
}
const publicFaviconHost = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/
const privateFaviconSuffix = /\.(?:alt|example|internal|invalid|local|localhost|onion|test|ts\.net)$/
function markExternalLinkFavicons(root: HTMLDivElement) {
root.querySelectorAll<HTMLAnchorElement>("a.external-link[href]").forEach((link) => {
if (!link.textContent?.trim() || link.querySelector("img")) return
if (!URL.canParse(link.href)) return
const url = new URL(link.href)
if (url.protocol !== "http:" && url.protocol !== "https:") return
if (url.hostname.toLowerCase() === "github.com") return
const favicon = document.createElement("span")
favicon.className = "markdown-link-favicon"
favicon.setAttribute("aria-hidden", "true")
const host = url.hostname.toLowerCase()
if (
publicFaviconHost.test(host) &&
!privateFaviconSuffix.test(host) &&
host !== "home.arpa" &&
!host.endsWith(".home.arpa")
) {
const image = document.createElement("img")
image.src = `https://www.google.com/s2/favicons?domain=${encodeURIComponent(url.host)}&sz=32`
image.alt = ""
image.setAttribute("data-markdown-favicon", "")
image.width = 14
image.height = 14
image.decoding = "async"
favicon.appendChild(image)
}
link.insertBefore(favicon, link.firstChild)
})
}
function setupExternalLinkFavicons(root: HTMLDivElement) {
const loaded = (event: Event) => {
const image = event.target
if (!(image instanceof HTMLImageElement) || !image.hasAttribute("data-markdown-favicon")) return
if (image.naturalWidth > 0) image.dataset.loaded = ""
}
root.addEventListener("load", loaded, true)
return () => root.removeEventListener("load", loaded, true)
}
function markInlineCode(root: HTMLDivElement) {
const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code"))
for (const code of codeNodes) {
@@ -554,6 +598,7 @@ export function Markdown(
let copyCleanup: (() => void) | undefined
let linkCleanup: (() => void) | undefined
let faviconCleanup: (() => void) | undefined
let readImage: ReadMarkdownImage | undefined
let images: ReturnType<typeof createMarkdownImages> | undefined
@@ -599,6 +644,9 @@ export function Markdown(
}
images?.update(container)
previewImages(container)
container.querySelectorAll<HTMLImageElement>("img[data-markdown-favicon]").forEach((image) => {
if (image.complete && image.naturalWidth > 0) image.dataset.loaded = ""
})
container
.querySelectorAll<HTMLElement>('[data-slot="markdown-copy-button"]')
.forEach((button) => setCopyState(button, labels, button.dataset.copied === "true"))
@@ -608,6 +656,7 @@ export function Markdown(
copied: i18n.t("ui.message.copied"),
}))
if (!linkCleanup) linkCleanup = setupLocalLinks(container, () => markdown?.openLocalFile)
if (!faviconCleanup) faviconCleanup = setupExternalLinkFavicons(container)
container.toggleAttribute("data-local-links", !!markdown?.openLocalFile)
if (result?.ready && result.text === local.text) container.dataset.markdownReady = ""
})
@@ -617,6 +666,7 @@ export function Markdown(
images?.dispose()
if (copyCleanup) copyCleanup()
if (linkCleanup) linkCleanup()
if (faviconCleanup) faviconCleanup()
const container = root()
if (container) disposeRenderedMarkdown(container)
if (streamed) disposeMarkdownProjection(owner)
@@ -697,6 +747,7 @@ function updateBlock(container: HTMLDivElement, index: number, block: RenderedBl
source.innerHTML = block.html
markInlineCode(source)
markCodeLinks(source)
markExternalLinkFavicons(source)
if (rendered) {
rendered.renderer.update(source.innerHTML, block.mode === "live", rendered.raw !== block.raw)