# 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.

- Source: https://oharu121.com/blog/astro-rehype-plugin-link-brand-icons-svg-sprite-csp/
- Published: 2026-10-16T22:57:40+09:00
- Tags: Astro, CSS, SVG, Sprite, CSP

---
## 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.

*Image: A References list where pnpm documentation links start with the orange pnpm mark, GitHub issue links with the GitHub octocat, and a Vercel link with the Vercel triangle*

*With the plugin on, each link in a References list starts with its site's logo.*

## 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.

> **HEADS UP**
>
> **Why it is called a sprite**
>
> In video games a sprite is one small image, a character or an item. Packing many into a single image and cutting out the one you need is a sprite sheet. CSS and SVG sprites borrow the idea: an SVG sprite packs many `<symbol>`s into one `<svg>`, and `<use>` pulls out the one it needs.

*Image: A pixel-art sprite sheet: 16×16 tiles of grass, trees, roofs, fences, doors and small items packed edge to edge in one image*

*[Kenney's Tiny Town](https://kenney.nl/assets/tiny-town) (CC0). 132 tiles of 16×16 share one image, and a game cuts out whichever tile it needs to draw.*

**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:

```js title="astro.config.mjs" ins={2,5}
import { rehypeTableScroll } from "./src/lib/rehype-table-scroll.ts";
import { rehypeLinkIcons } from "./src/lib/rehype-link-icons.ts";
// …
  markdown: {
    processor: unified({ rehypePlugins: [rehypeTableScroll, rehypeLinkIcons] }),
  },
```

*Figure — PluginFlow: The plugin changes only the built HTML; the MDX source is never touched.*

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>`:

```html title="At build time, the plugin outputs the link with a logo in front"
<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.

*Figure — SpriteReference: Each link draws its logo with `<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](https://github.com/) | `github.com`, `docs.github.com`, `github.blog` |
| [MDN](https://developer.mozilla.org/) | `developer.mozilla.org` |
| [Vercel](https://vercel.com/) | `vercel.com` |
| [Claude](https://code.claude.com/) | `code.claude.com`, `platform.claude.com`, `docs.claude.com`, `support.claude.com` |
| [pnpm](https://pnpm.io/) | `pnpm.io` |
| [Git](https://git-scm.com/) | `git-scm.com` |
| [Astro](https://docs.astro.build/) | `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.

*Figure — LinkDecision: Every check runs at build time. The plugin changes link text only on GitHub links: a bare URL becomes its short form, and an issue, pull request or commit link gains its reference.*

### 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](https://github.com/anthropics/claude-code/issues/36123) in this sentence shows it.

A label that already contains the number or SHA **gets nothing appended**, as with [Issue #36123](https://github.com/anthropics/claude-code/issues/36123) here. Neither does a repository link such as [simple-icons](https://github.com/simple-icons/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 https://github.com/remarkjs/remark-github in this sentence renders that way.

```ts title="src/lib/rehype-link-icons.ts"
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

> **HEADS UP**
>
> **What a CSP is**
>
> A Content Security Policy is a response header in which a site tells the browser where a page may load images, scripts and styles from. **The browser blocks any request outside it.**

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:

```ts title="src/lib/brand-mark.ts"
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:

```html title="The GitHub symbol in the sprite"
<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>
```

```css title="src/styles/global.css"
.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**.

*Image: The same References list in dark mode: the GitHub octocat and the Vercel triangle are drawn in light grey beside light-blue links*

*In dark mode the GitHub and Vercel marks take the text colour.*

## 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.

*Figure — IconWrap: Three ways to place the logo. Only binding it to the first character keeps it with its label and still lets a label with no spaces wrap.*

**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:

```ts title="src/lib/rehype-link-icons.ts"
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:

```css title="src/styles/global.css"
.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**.

*Figure — HastWalk: The same tree walked two ways. Only the walk that steps into the MDX node reaches the link inside `<Callout>`.*

Walking through the MDX nodes as well fixes it:

```ts title="src/lib/rehype-link-icons.ts" ins={3-6}
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 the `color` attribute.
- 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 through `mdxJsxFlowElement` and `mdxJsxTextElement`, or links in components go undecorated.

## References

- [simple-icons](https://github.com/simple-icons/simple-icons)
- [simple-icons 15.0.0 release notes, including the removal of the Amazon and AWS marks](https://github.com/simple-icons/simple-icons/releases/tag/15.0.0)
- [GitHub Docs: autolinked references and URLs](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/autolinked-references-and-urls)
- [remark-github, which rewrites only links whose text is their URL](https://github.com/remarkjs/remark-github)
- [HTML Standard: rendering of hidden elements, under the XHTML namespace](https://html.spec.whatwg.org/multipage/rendering.html#hidden-elements)
- [MDN: light-dark()](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/light-dark)
- [MDN: CSP img-src](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/img-src)
- [WCAG 2.2 Understanding 1.4.11: Non-text Contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html)
