# Astroの記事リンクにリンク先のブランドアイコンを付ける — rehypeプラグインと3つの落とし穴

> GitHub・MDN・Vercelなどへのリンクに、simple-iconsのブランドマークをインラインSVGのスプライトとして付けるAstroのビルド時rehypeプラグイン。厳しいCSPのもとでも動作します。

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

---
## はじめに

GitHubはプルリクエストへのリンクを、プルリクエストのアイコンとタイトル、番号で表示します。Confluenceも、貼り付けたリンクをリンク先ページの小さなカードに変えてくれます。

このブログでは、どのリンクも同じ青い下線でした。GitHubのIssue、MDNのページ、各サービスのドキュメントが混ざった参考リンクの一覧も、見た目の同じテキストが縦に並ぶだけです。GitHubのように表示できないものかと考えました。

**そこで、このブログがよく引用するサイトへのリンクの前に、リンク先のブランドマークを付けるビルド時のrehypeプラグインを追加しました**。対象はGitHub、MDN、Vercel、Claudeのドキュメント、pnpm、git-scm、Astroのドキュメントです。

マークはsimple-iconsから取り出してSVGとしてインライン化しているので、**ページがこれらのサイトにリクエストを送ることはありません**。記事のソースも、Markdownのふつうのリンクのままです。

*Image: 参考リンクの一覧。pnpmのドキュメントへのリンクにはオレンジのpnpmマーク、GitHubのIssueへのリンクにはGitHubのOctocat、Vercelへのリンクには三角形のVercelマークが付いている*

*プラグインを有効にすると、参考リンクの各リンクの前にそのサイトのマークが付きます。*

## rehypeプラグイン1つ、ページごとにスプライト1つ

SVGスプライトとは、各アイコンを `id` 付きの `<symbol>` として1回ずつ収めた、非表示の `<svg>` のことです。ページ内のどこでも `<use href="#id">` でそのシンボルを描けるので、アイコンが何回出てきてもパスは1回しか書きません。

> **ちなみに**
>
> **なぜスプライトと呼ぶのか**
>
> スプライトはもともと、キャラクターやアイテムといったゲームの小さな画像のことです。多くのスプライトを1枚の画像にまとめ、必要な部分だけを切り出して使う方法をスプライトシートと呼びます。CSSスプライトやSVGスプライトもこの考え方を借りたもので、SVGスプライトは多くの `<symbol>` を1つの `<svg>` にまとめ、`<use>` で必要なものを取り出します。

*Image: ピクセルアートのスプライトシート。草地、木、屋根、柵、扉、小物などの16×16のタイルが1枚の画像に隙間なく並んでいる*

*[KenneyのTiny Town](https://kenney.nl/assets/tiny-town)（CC0）。16×16のタイル132枚が1枚の画像に収められ、ゲームは描きたいタイルを切り出して使います。*

**プラグインは、AstroがMarkdownとMDXの両方に渡す `unified()` プロセッサーに1項目追加するだけです**。記事ごとの設定は不要で、どの記事も3言語ともこのプラグインを通ります。

```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: プラグインが変えるのはビルド後のHTMLだけで、MDXのソースには手を加えません。*

プラグインは装飾するリンクごとに、アイコンとラベルの先頭1文字をspanで包み、アイコンから `<use>` でシンボルを参照します。

```html title="ビルド時にプラグインが生成する、マーク付きのリンク"
<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>
```

spanに入れるのは、マークと先頭の1文字を同じ行に保ち、マークだけが行末に取り残されないようにするためです。1単語ではなく1文字にした理由は、後述の落とし穴1で説明します。

走査が終わると、そのページで使ったマークだけを `<symbol>` として持つ `<svg>` を1つ、ビルド後のHTMLの末尾に追加します。そのため、Claudeのドキュメントへのリンクが十数個ある記事でも、Claudeのマークのパスは1回分しか入りません。このスプライトはビルド後のHTMLにしかなく、MDXのソースには現れません。

*Figure — SpriteReference: 各リンクは `<use>` でマークを描き、ページ末尾にある1つのスプライト内の `<symbol>` を参照します。*

アイコンは `aria-hidden` なので、**スクリーンリーダーが読み上げるのはリンクのテキストだけで**、マークを画像として読み上げることはありません。CSSではアイコンを `1em` の大きさにし、`margin-inline: 0.2em 0.25em` で左右に余白を取っています。日本語や中国語ではリンクの直前の文字との間に何も入らないので、この余白が効きます。

## どのリンクにマークが付くか

マークが付くのは、ホスト名が固定の対応表に完全一致したリンクだけです。

| マーク | ホスト名 |
| --- | --- |
| [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` |

このブログがよく引用し、かつsimple-iconsにマークがあるサイトを私が選びました。外部リンクすべてにマークを付けると、マークが何も伝えなくなります。表にないサイトへのリンクは、ふつうのリンクとして表示されます。

見出しのテキストは目次にも使われるので、見出しの中のリンクは対象外です。`code` や `pre` の中のリンクも飛ばします。脚注の参照を含む内部リンクは、そもそも対応表に一致しません。

*Figure — LinkDecision: 判定はすべてビルド時に行われます。プラグインがテキストに手を加えるのはGitHubへのリンクだけで、URLそのままのものは短縮形にし、Issue・プルリクエスト・コミットへのリンクには参照を付けます。*

### 書かれたラベルは残し、GitHubの短縮形を後ろに付ける

Claude Codeは最初、GitHubへのリンクをすべてGitHub流の `owner/repo#59` 形式に書き換えることを提案しました。しかしClaude Codeが実装の前にこのブログの記事を調べたところ、**Issueへのリンクにはすでに手書きのラベルが付いていました**。書き方も `pnpm#12744`、`Issue #36123: …`、`withastro/astro issue #13996: …` とまちまちです。

これを `owner/repo#59` に置き換えると、そのIssueが何についてのものかが失われます。**そこで私は、ラベルを残したまま、その後ろに短縮形を付けることにしました**。Issue、プルリクエスト、コミットへのリンクは `ラベル (owner/repo#59)` と表示されます。この文にある[IDE拡張機能で `/add-dir` を使えるようにする要望](https://github.com/anthropics/claude-code/issues/36123)へのリンクが、その例です。

番号やSHAをすでに含むラベルには、何も付け足しません。この文の [Issue #36123](https://github.com/anthropics/claude-code/issues/36123) がその例です。[simple-icons](https://github.com/simple-icons/simple-icons) のようなリポジトリへのリンクも同様です。短縮形はリポジトリ名を繰り返すだけになるからです。

テキストがURLそのものであるリンクは、代わりにテキストを短縮形に置き換えます。`remark-github` も同じルールを採っています。形式はGitHubの自動リンクのドキュメントに従い、Issueやプルリクエストは `owner/repo#N`、コミットはリポジトリ名と7文字のSHA、リポジトリのトップは `owner/repo` になります。この文中のURLそのままのリンク https://github.com/remarkjs/remark-github も、そのように表示されます。

```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` には、`features`、`orgs`、`settings`、`marketplace` といったGitHub自身のトップレベルのページを入れています。これがないと、URLそのままの `github.com/features/actions` が `features/actions` と表示され、存在しないリポジトリのように読めてしまいます。それ以外のパス、たとえば `blob/…` のファイルへのリンクはURLのまま残します。GitHubがその短縮形を定めていないからです。

## マークをインラインSVGにした理由

> **ちなみに**
>
> **CSPとは**
>
> CSP（Content Security Policy）は、ページが画像やスクリプト、スタイルをどこから読み込んでよいかを、サイトがレスポンスヘッダーでブラウザーに伝える仕組みです。ルールに合わないリクエストは、ブラウザーがブロックします。

このサイトのCSPは `img-src 'self' data:` で、このサイト自身と `data:` の画像しか読み込めないため、**Googleのファビコンサービスやリンク先のサイトから取得するファビコンはブロックされます**。閲覧時に読み込めば、読者のIPアドレスをそのサービスやサイトに渡すことにもなります。そのため、マークは閲覧時にブラウザーで取得するのではなく、ビルドの時点でページに入っている必要があります。

マークは、すでに依存関係にあるsimple-iconsから取り出します。取り出すときは、マークが存在しなければ例外を投げるヘルパーを通します。

```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は実際にマークを削除します。**バージョン15.0.0では、1つのプルリクエストでAmazon Web ServicesとAmazonのマークがすべて削除されました**。そのため、このブログでVercelと同じくらい引用しているAWSのドキュメントへのリンクには、アイコンが付きません。汎用のアイコンで穴埋めはせず、ふつうのリンクのままにしました。W3CとIETFも、simple-iconsにはありません。

インライン化をSVGスプライトで行う案は、Claude Codeが出したものです。各マークを `data:` URIの背景画像として埋め込むスタイルシートを生成する方法もありましたが、スプライトならプラグインが出力するのはマークアップだけです。各ページには使ったマークだけが入り、対応表と同期させる生成済みのCSSファイルもありません。

## ブランドカラーと、ダークモードで消える3つのマーク

Claude Codeは、ライトモードでもダークモードでも見やすいように、すべてのマークをリンクと同じ色にすることを提案しましたが、私はそれぞれのマーク本来の色を使いたいと考えました。各マークには、simple-iconsが記録しているブランドカラーをそのまま使っています。

そのうち3色は黒です。このサイトのダークモードの背景 `#0b1120` に対して、**GitHubの `#181717` は1.05:1、MDNとVercelの `#000000` は1.12:1** しかありません。WCAGがグラフィックに求める3:1を大きく下回り、ダークモードではこの3つのマークが見えなくなっていました。そこで、これらに明るい色のバリエーションを用意するよう依頼しました。

プラグインはビルド時に、各マークとダークモードの背景とのコントラストを計算します。3:1を下回るマークには、クラスと、ブランドカラーを入れた `color` 属性を付けます。

```html title="スプライト内の GitHub のシンボル"
<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` は `color` 属性の値を読むので、ライトテーマではブランドカラーそのもの、ダークテーマではサイトの文字色になります。`light-dark()` は `color-scheme` に従い、ライトなら1つ目、ダークなら2つ目の値を使います。テーマの切り替えボタンもすでに `color-scheme` を設定しているので、**OSの設定でも切り替えボタンでも、同じようにマークの色が切り替わります**。

*Image: ダークモードの同じ参考リンク一覧。GitHubのOctocatとVercelの三角形が、水色のリンクの横に明るいグレーで描かれている*

*ダークモードでは、GitHubとVercelのマークが文字色になります。*

## 3つの落とし穴：CJKのラベル、`<svg hidden>`、MDXコンポーネント

3つとも、`pnpm check` とビルドは通っていました。問題が見えるのは、レンダリングしたページの上だけです。

### 落とし穴1：空白のないCJKのラベルは、`nowrap`のspanに入れると折り返せず、ページが横に広がる

アイコンを `inline-block` にすると、その前後で改行が起こりえます。何もしなければ、ブラウザーはアイコンを行末に残し、ラベルを次の行から始めてしまいます。**ノーブレークスペースを入れても防げません**。

よく使われる対策は、アイコンとラベルの最初の単語を `white-space: nowrap` のspanで包むことです。`nowrap` はspanの中での改行を禁止するので、マークとその単語は必ず同じ行に並びます。改行できるのは、マークの前か単語の後だけです。

この方法は英語なら機能しますが、**日本語や中国語のラベルには空白がないため、プラグインは区切る場所を見つけられず、テキスト全体を最初の単語として扱います**。ラベルが長いと、幅390pxのスマートフォンの画面からはみ出すことがあります。実際に計測すると15ページが画面より広く、最も大きいもので652pxはみ出していました。

*Figure — IconWrap: マークの置き方3通り。ラベルと同じ行に保ちつつ、空白のないラベルも折り返せるのは、先頭の1文字に結び付ける方法だけです。*

**`nowrap` はそのままに、spanに入れるのをマークと先頭の1文字だけにしました**。マークはラベルの先頭の文字と同じ行に残り、残りのテキストはspanの外にあるので自由に折り返せます。

```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` は文字単位で分割するので、絵文字のようにUTF-16の2単位で表される文字も途中で切れません。``[`simple-icons`](…)`` のようにラベルがインラインコードの場合は要素ごとspanに入れ、`.link-brand > :not(svg) { white-space: normal; }` でその中での折り返しを許しています。先頭1文字だけのspanと、インラインコードの折り返しを入れた後は、同じチェックで238ページすべてにはみ出しがありませんでした。

### 落とし穴2：`<svg hidden>`は300×150の箱を描く

スプライトをページから隠すには、`hidden` 属性を付ければよさそうに見えます。しかし `<svg>` に付けても何も起きません。

ブラウザーに組み込まれたスタイルシートには `[hidden] { display: none }` がありますが、これはHTML要素だけに適用され（仕様ではXHTMLの名前空間のもとで宣言）、SVG要素には適用されません。スプライトは `display: inline` のまま、置換要素の既定サイズである300×150で描画され、**マークを含むすべての記事の末尾に空白の箱ができていました**。

`hidden` 属性はそれ自体では何も隠さず、組み込みのルールに頼っています。そのルールはHTML要素しか見ません。サイト自身のスタイルシートに書いたクラスセレクタにはその制限がなく、`.link-icon-sprite` はSVGを含め、このクラスを持つどの要素にも一致するので、`display: none` がスプライトに適用されます。

```css title="src/styles/global.css"
.link-icon-sprite {
  display: none;
}
```

`<use>` は `display: none` の `<svg>` の中にあるシンボルからも描画できるので、マークはすべて表示されます。

### 落とし穴3：MDXコンポーネントは`element`ノードではないため、中のリンクが飛ばされる

HTMLのツリー向けに書いた走査は、`element` 以外のノードを飛ばします。MDXでは、`<Callout>` のようなコンポーネントはhastのツリーに `mdxJsxFlowElement` として現れるため、まるごと飛ばされ、**`<Callout>` の中のリンクだけにマークが付きませんでした**。

*Figure — HastWalk: 同じツリーを2通りに走査した図。MDXのノードの中へ入る走査だけが、`<Callout>` の中のリンクに届きます。*

MDXのノードの中も走査すれば直ります。

```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;
    // …
```

## まとめ

- Astroが共有する `unified()` プロセッサーにrehypeプラグインを1つ入れるだけで、固定の対応表にあるホスト名へのリンクに、simple-iconsのブランドマークが全言語で付きます。記事のソースはふつうのリンクのままです。
- CSPがリモートのファビコンをブロックするので、マークはインラインSVGにしています。各ページは使ったマークだけを入れたスプライトを1つ持ち、`<use>` でそれを参照します。
- 書かれたラベルは残します。GitHubのIssue・プルリクエスト・コミットへのリンクは、ラベルに番号がなければ後ろに短縮形を付け、URLそのままのリンクは短縮形に置き換えます。GitHub自身のトップレベルのページは除きます。
- ダークモードの背景に対して3:1を下回るマークには `fill: light-dark(currentColor, var(--text-1))` を当て、ブランドカラーは `color` 属性に入れます。
- アイコンは最初の単語ではなく先頭の1文字に結び付けないと、CJKのラベルが折り返せません。`[hidden]` はSVGに効かないので、スプライトはクラスで隠します。`mdxJsxFlowElement` と `mdxJsxTextElement` の中も走査しないと、コンポーネント内のリンクにマークが付きません。

## 参考リンク

- [simple-icons](https://github.com/simple-icons/simple-icons)
- [simple-icons 15.0.0のリリースノート（AmazonとAWSのマークの削除を含む）](https://github.com/simple-icons/simple-icons/releases/tag/15.0.0)
- [GitHub Docs：自動リンクされた参照とURL](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/autolinked-references-and-urls)
- [remark-github（テキストがURLそのもののリンクだけを書き換える）](https://github.com/remarkjs/remark-github)
- [HTML Standard：hidden要素のレンダリング（XHTMLの名前空間のもとで宣言）](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 達成基準1.4.11の解説：非テキストのコントラスト](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html)
