Astro article links that show their site's brand icon — a rehype plugin and three traps
A build-time rehype plugin for Astro that puts simple-icons brand marks on links to GitHub, MDN and Vercel as an inline SVG sprite, safe under a strict CSP.

On this page
Introduction
GitHub renders a link to a pull request as the pull request icon followed by its title and number, and Confluence turns a pasted link into a small card for the page it points at.
On this blog every link was the same blue underline, so a list of references mixing GitHub issues, MDN pages and vendor docs read as one column of identical text, and I wondered why it could not look like GitHub’s.
I added a build-time rehype plugin that puts the destination’s brand mark in front of links to the sites this blog cites most: GitHub, MDN, Vercel, the Claude docs, pnpm, git-scm and the Astro docs.
The marks come from simple-icons and are inlined as SVG, so a page sends no request to any of those sites, and article source stays plain Markdown links.

One rehype plugin, one sprite per page
An SVG sprite is one hidden <svg> that holds each icon once, as a <symbol> with an id. Anywhere on the page, <use href="#id"> draws that symbol, so an icon’s path is written once however many times it appears.

The plugin is one entry in the unified() processor that Astro hands to both Markdown and MDX, so all three locales of every article go through it with no per-article setup:
import { rehypeTableScroll } from "./src/lib/rehype-table-scroll.ts";import { rehypeLinkIcons } from "./src/lib/rehype-link-icons.ts";// … markdown: { processor: unified({ rehypePlugins: [rehypeTableScroll, rehypeLinkIcons] }), },For each link it decorates, the plugin wraps an icon and the first character of the label in a span, and points the icon at a symbol with <use>:
<a href="https://github.com/pnpm/pnpm/issues/12744"> <span class="link-brand"> <svg class="link-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"> <use href="#li-github"></use> </svg>p</span>npm/pnpm#12744</a>The span keeps the logo and the first character on the same line, so the logo never ends a line on its own. Pitfall 1 below shows why the span holds one character rather than one word.
After the walk, it appends one <svg> to the end of the built HTML holding a <symbol> for each mark the page used, and nothing else. An article that links to the Claude docs a dozen times therefore carries the Claude mark’s path once, not a dozen times.
The sprite exists only in the built HTML; the MDX source never shows it.
<use>, pointing at a <symbol> in the one sprite at the end of the page.The icon is aria-hidden, so a screen reader announces the link by its text alone rather than reading the logo out as an image. CSS sizes it at 1em with margin-inline: 0.2em 0.25em, which gives it room on both sides even in Japanese and Chinese, where nothing separates a link from the character before it.
Which links get a mark
A link gets a mark only when its hostname is in a fixed table, matched exactly:
| Logo | Hostnames |
|---|---|
| GitHub | github.com, docs.github.com, github.blog |
| MDN | developer.mozilla.org |
| Vercel | vercel.com |
| Claude | code.claude.com, platform.claude.com, docs.claude.com, support.claude.com |
| pnpm | pnpm.io |
| Git | git-scm.com |
| Astro | docs.astro.build |
I picked these as the sites this blog cites often that simple-icons has a mark for. A mark on every external link would stop telling the reader anything, so a site outside the table renders as a plain link.
The walk also skips links inside headings, because heading text feeds the table of contents, and links inside code or pre. Internal links, footnote references among them, never match the table.
Written labels stay, and GitHub’s short reference follows them
Claude Code first suggested rewriting every GitHub link into GitHub’s own owner/repo#59 form. Going through the blog’s articles before anything was built, it found the issue links already carried hand-written labels, in several styles: pnpm#12744, Issue #36123: …, withastro/astro issue #13996: ….
Replacing those with owner/repo#59 would drop what each issue is about. I chose to keep the label and add the short reference after it, so a link to an issue, pull request or commit renders as label (owner/repo#59). The link to the request for /add-dir in the IDE extensions (anthropics/claude-code#36123) in this sentence shows it.
A label that already contains the number or SHA gets nothing appended, as with Issue #36123 here. Neither does a repository link such as simple-icons, whose short form would only repeat the repository’s name.
A link whose text is its own URL has that text replaced instead, the same rule remark-github applies. GitHub’s autolink docs give the format: an issue or pull request becomes owner/repo#N, a commit becomes the repository plus a seven-character SHA, and a repository root becomes owner/repo. The bare URL remarkjs/remark-github in this sentence renders that way.
const ref = githubRef(url);const only = children.length === 1 ? children[0] : undefined;if (only?.type === "text" && only.value === href) { if (ref) only.value = ref.short;} else if (ref?.named && !ref.named.test(textOf(link))) { children.push( { type: "text", value: " " }, { type: "element", tagName: "span", properties: { className: ["link-ref"] }, children: [{ type: "text", value: `(${ref.short})` }] }, );}GITHUB_RESERVED holds GitHub’s own top-level pages, such as features, orgs, settings and marketplace. Without it, a bare github.com/features/actions would render as features/actions and read as a repository that does not exist. Any other path, a blob/… file link for one, keeps its URL, because GitHub documents no short form for it.
Why the marks are inline SVG
The CSP on this site is img-src 'self' data:, which allows images only from this site and from data: URIs, so a favicon fetched from Google’s favicon service, or from the linked site, is blocked.
Loading one at read time would also hand each reader’s IP address to that service or site. The marks therefore have to be in the page when it is built, rather than fetched by the reader’s browser.
They come from simple-icons, already a dependency here, through a helper that throws when a mark no longer exists:
function lookup(slug: string): SimpleIcon { const icon = (simpleIcons as Record<string, SimpleIcon | undefined>)[slug]; if (!icon) { throw new Error( `simple-icons has no export "${slug}". It may have been renamed, or removed ` + `over a trademark complaint. Check the package and update whichever table ` + `asked for it.`, ); } return icon;}simple-icons does remove marks. Version 15.0.0 removed Amazon Web Services and every Amazon mark in one pull request, so AWS docs links, cited as often here as Vercel’s, get no icon. I left them plain rather than add a generic glyph. W3C and IETF are not in simple-icons either.
Claude Code proposed inlining the marks as an SVG sprite rather than generating a stylesheet that embeds each logo as a data: URI background image. The plugin then emits markup only: each page carries just the marks it uses, and there is no generated CSS file to keep in step with the table.
Brand colours, and the three marks that vanish in dark mode
Each mark keeps the brand colour simple-icons records for it. Claude Code suggested giving every logo the link’s own colour, which stays readable in both light and dark mode, but I wanted each logo in its own brand colour.
Three of those colours are black. Against this site’s dark background, #0b1120, GitHub’s #181717 measures 1.05:1 and MDN’s and Vercel’s #000000 1.12:1, far below the 3:1 that WCAG asks of graphics, so in dark mode those marks disappeared. I asked for a light variant for them.
The plugin measures each mark’s contrast against the dark background at build time. A mark below 3:1 gets a class, plus its brand colour in a color attribute:
<symbol id="li-github" viewBox="0 0 24 24"> <path class="link-mark-ink" color="#181717" fill="#181717" d="M12 .297c-6.63 …" ></path></symbol>.link-mark-ink { fill: light-dark(currentColor, var(--text-1));}currentColor reads the color attribute, so the light theme gets the exact brand colour and the dark theme gets the site’s text colour.
light-dark() uses its first value in a light colour scheme and its second in a dark one, following color-scheme, which the theme toggle already sets, so the mark flips under the OS setting and under the toggle alike.

Three traps: CJK labels, <svg hidden>, and MDX components
All three passed pnpm check and a clean build. They show up only on a rendered page.
Pitfall 1: a CJK label has no spaces, so inside a nowrap span it cannot wrap and widens the page
An icon set inline-block is a line-break opportunity of its own, so without help the browser can leave the icon at the end of one line and start its label on the next. A non-breaking space does not prevent it.
The usual fix is to wrap the icon and the first word of the label in a span with white-space: nowrap. nowrap forbids a line break inside the span, so the logo and that word always share a line; the line can only break before the logo or after the word.
That works in English, but a Japanese or Chinese label has no spaces, so the plugin finds nowhere to split it and treats the whole text as the first word. A long label can then run past a 390px phone screen. Measured, 15 pages were wider than the screen, the worst by 652px.
The fix keeps nowrap but puts only the first character in the span with the logo. The logo still shares a line with the label’s first character, and the rest of the label sits outside the span, free to wrap:
if (first.type === "text") { const [head = "", ...tail] = Array.from(first.value ?? ""); lead = { type: "text", value: head }; if (tail.length > 0) rest = [{ type: "text", value: tail.join("") }, ...rest];}Array.from splits by character, so a character stored as two UTF-16 units, such as an emoji, is never cut in half.
A label that is inline code, such as [`simple-icons`](…), goes into the span whole, and .link-brand > :not(svg) { white-space: normal; } lets it wrap inside. With the first-character span and the inline-code rule in place, the same check measured nothing overflowing across 238 pages.
Pitfall 2: <svg hidden> still draws a 300×150 box
The hidden attribute looks like the way to keep the sprite off the page. On an <svg> it does nothing.
The browser’s built-in stylesheet does carry [hidden] { display: none }, but it applies only to HTML elements (the spec declares it under the XHTML namespace), never to SVG ones. The sprite rendered as display: inline at the default replaced-element size of 300×150, a blank box at the end of every article that had a mark.
The hidden attribute hides nothing by itself; it relies on that built-in rule, which only looks at HTML elements. A class selector in the site’s own stylesheet has no such limit: .link-icon-sprite matches any element carrying the class, SVG included, so display: none reaches the sprite:
.link-icon-sprite { display: none;}<use> keeps drawing from symbols inside an <svg> with display: none, so every mark still renders.
Pitfall 3: an MDX component is not an element node, so the links inside it are skipped
A walk written for HTML trees steps over anything that is not an element. In MDX, a component such as <Callout> arrives in the hast tree as an mdxJsxFlowElement, so the walk skipped it whole, and only the links inside <Callout> went without a logo.
<Callout>.Walking through the MDX nodes as well fixes it:
function walk(node: HastNode, used: Set<string>): void { for (const child of node.children ?? []) { if (child.type === "mdxJsxFlowElement" || child.type === "mdxJsxTextElement") { walk(child, used); continue; } if (child.type !== "element") continue; if (SKIP_INSIDE.has(child.tagName ?? "")) continue; // …Summary
- One rehype plugin in Astro’s shared
unified()processor gives links to a fixed table of hostnames a simple-icons brand mark, in every locale, with article source left as plain links. - Marks are inline SVG because the CSP blocks remote favicons. Each page carries one sprite with only the marks it uses, and
<use>references it. - Written labels are kept. A GitHub issue, pull request or commit link gets GitHub’s short reference after its label unless the label already has the number, and a bare GitHub URL becomes the short form, except under GitHub’s own top-level pages.
- Marks below 3:1 on the dark background get
fill: light-dark(currentColor, var(--text-1)), with the brand colour in thecolorattribute. - Bind the icon to the first character, not the first word, or CJK labels cannot wrap. Hide the sprite with a class, because
[hidden]does not apply to SVG. Walk throughmdxJsxFlowElementandmdxJsxTextElement, or links in components go undecorated.
References
- simple-icons
- simple-icons 15.0.0 release notes, including the removal of the Amazon and AWS marks
- GitHub Docs: autolinked references and URLs
- remark-github, which rewrites only links whose text is their URL
- HTML Standard: rendering of hidden elements, under the XHTML namespace
- MDN: light-dark()
- MDN: CSP img-src
- WCAG 2.2 Understanding 1.4.11: Non-text Contrast


