mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
feat(session-ui): show GitHub mark on Markdown links (#51000)
This commit is contained in:
committed by
GitHub
Unverified
parent
dca73ba9e3
commit
0bc8b8dbeb
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user