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

目次
はじめに
GitHubはプルリクエストへのリンクを、プルリクエストのアイコンとタイトル、番号で表示します。Confluenceも、貼り付けたリンクをリンク先ページの小さなカードに変えてくれます。
このブログでは、どのリンクも同じ青い下線でした。GitHubのIssue、MDNのページ、各サービスのドキュメントが混ざった参考リンクの一覧も、見た目の同じテキストが縦に並ぶだけです。GitHubのように表示できないものかと考えました。
そこで、このブログがよく引用するサイトへのリンクの前に、リンク先のブランドマークを付けるビルド時のrehypeプラグインを追加しました。対象はGitHub、MDN、Vercel、Claudeのドキュメント、pnpm、git-scm、Astroのドキュメントです。
マークはsimple-iconsから取り出してSVGとしてインライン化しているので、ページがこれらのサイトにリクエストを送ることはありません。記事のソースも、Markdownのふつうのリンクのままです。

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

プラグインは、AstroがMarkdownとMDXの両方に渡す unified() プロセッサーに1項目追加するだけです。記事ごとの設定は不要で、どの記事も3言語ともこのプラグインを通ります。
import { rehypeTableScroll } from "./src/lib/rehype-table-scroll.ts";import { rehypeLinkIcons } from "./src/lib/rehype-link-icons.ts";// … markdown: { processor: unified({ rehypePlugins: [rehypeTableScroll, rehypeLinkIcons] }), },プラグインは装飾するリンクごとに、アイコンとラベルの先頭1文字をspanで包み、アイコンから <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>spanに入れるのは、マークと先頭の1文字を同じ行に保ち、マークだけが行末に取り残されないようにするためです。1単語ではなく1文字にした理由は、後述の落とし穴1で説明します。
走査が終わると、そのページで使ったマークだけを <symbol> として持つ <svg> を1つ、ビルド後のHTMLの末尾に追加します。そのため、Claudeのドキュメントへのリンクが十数個ある記事でも、Claudeのマークのパスは1回分しか入りません。このスプライトはビルド後のHTMLにしかなく、MDXのソースには現れません。
<use> でマークを描き、ページ末尾にある1つのスプライト内の <symbol> を参照します。アイコンは aria-hidden なので、スクリーンリーダーが読み上げるのはリンクのテキストだけで、マークを画像として読み上げることはありません。CSSではアイコンを 1em の大きさにし、margin-inline: 0.2em 0.25em で左右に余白を取っています。日本語や中国語ではリンクの直前の文字との間に何も入らないので、この余白が効きます。
どのリンクにマークが付くか
マークが付くのは、ホスト名が固定の対応表に完全一致したリンクだけです。
| マーク | ホスト名 |
|---|---|
| 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 |
このブログがよく引用し、かつsimple-iconsにマークがあるサイトを私が選びました。外部リンクすべてにマークを付けると、マークが何も伝えなくなります。表にないサイトへのリンクは、ふつうのリンクとして表示されます。
見出しのテキストは目次にも使われるので、見出しの中のリンクは対象外です。code や pre の中のリンクも飛ばします。脚注の参照を含む内部リンクは、そもそも対応表に一致しません。
書かれたラベルは残し、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 を使えるようにする要望 (anthropics/claude-code#36123)へのリンクが、その例です。
番号やSHAをすでに含むラベルには、何も付け足しません。この文の Issue #36123 がその例です。simple-icons のようなリポジトリへのリンクも同様です。短縮形はリポジトリ名を繰り返すだけになるからです。
テキストがURLそのものであるリンクは、代わりにテキストを短縮形に置き換えます。remark-github も同じルールを採っています。形式はGitHubの自動リンクのドキュメントに従い、Issueやプルリクエストは owner/repo#N、コミットはリポジトリ名と7文字のSHA、リポジトリのトップは owner/repo になります。この文中のURLそのままのリンク remarkjs/remark-github も、そのように表示されます。
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は img-src 'self' data: で、このサイト自身と data: の画像しか読み込めないため、Googleのファビコンサービスやリンク先のサイトから取得するファビコンはブロックされます。閲覧時に読み込めば、読者のIPアドレスをそのサービスやサイトに渡すことにもなります。そのため、マークは閲覧時にブラウザーで取得するのではなく、ビルドの時点でページに入っている必要があります。
マークは、すでに依存関係にあるsimple-iconsから取り出します。取り出すときは、マークが存在しなければ例外を投げるヘルパーを通します。
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 属性を付けます。
<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 は color 属性の値を読むので、ライトテーマではブランドカラーそのもの、ダークテーマではサイトの文字色になります。light-dark() は color-scheme に従い、ライトなら1つ目、ダークなら2つ目の値を使います。テーマの切り替えボタンもすでに color-scheme を設定しているので、OSの設定でも切り替えボタンでも、同じようにマークの色が切り替わります。

3つの落とし穴:CJKのラベル、<svg hidden>、MDXコンポーネント
3つとも、pnpm check とビルドは通っていました。問題が見えるのは、レンダリングしたページの上だけです。
落とし穴1:空白のないCJKのラベルは、nowrapのspanに入れると折り返せず、ページが横に広がる
アイコンを inline-block にすると、その前後で改行が起こりえます。何もしなければ、ブラウザーはアイコンを行末に残し、ラベルを次の行から始めてしまいます。ノーブレークスペースを入れても防げません。
よく使われる対策は、アイコンとラベルの最初の単語を white-space: nowrap のspanで包むことです。nowrap はspanの中での改行を禁止するので、マークとその単語は必ず同じ行に並びます。改行できるのは、マークの前か単語の後だけです。
この方法は英語なら機能しますが、日本語や中国語のラベルには空白がないため、プラグインは区切る場所を見つけられず、テキスト全体を最初の単語として扱います。ラベルが長いと、幅390pxのスマートフォンの画面からはみ出すことがあります。実際に計測すると15ページが画面より広く、最も大きいもので652pxはみ出していました。
nowrap はそのままに、spanに入れるのをマークと先頭の1文字だけにしました。マークはラベルの先頭の文字と同じ行に残り、残りのテキストはspanの外にあるので自由に折り返せます。
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 がスプライトに適用されます。
.link-icon-sprite { display: none;}<use> は display: none の <svg> の中にあるシンボルからも描画できるので、マークはすべて表示されます。
落とし穴3:MDXコンポーネントはelementノードではないため、中のリンクが飛ばされる
HTMLのツリー向けに書いた走査は、element 以外のノードを飛ばします。MDXでは、<Callout> のようなコンポーネントはhastのツリーに mdxJsxFlowElement として現れるため、まるごと飛ばされ、<Callout> の中のリンクだけにマークが付きませんでした。
<Callout> の中のリンクに届きます。MDXのノードの中も走査すれば直ります。
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の中も走査しないと、コンポーネント内のリンクにマークが付きません。


