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

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

黒いカードにピンクの炎を伴う白のAstroロケットロゴとロゴタイプ
目次

はじめに

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

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

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

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

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

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

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

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

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

KenneyのTiny Town(CC0)。16×16のタイル132枚が1枚の画像に収められ、ゲームは描きたいタイルを切り出して使います。

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

astro.config.mjs
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ページに対して行うことビルド時と書かれた帯の中を上から下へ流れる図。ふつうのMarkdownリンクを含むMDXのソースがhastに変換される。手順1ですべてのノードを走査し、見出しとコードは飛ばし、MDXコンポーネントの中へ入る。手順2でホストが対応表にあるリンクを装飾する。マークと先頭の1文字をspanに入れ、URLそのままのGitHubリンクを短縮し、使ったマークを記録する。手順3で、記録したマークごとにsymbolを持つスプライトを1つHTMLの末尾に追加する。結果はビルド後のHTMLで、リンクの前にマークが付き、末尾に非表示のスプライトがある。どの手順もサイトのビルド時に実行され、読者のブラウザーでは実行されないMDXのソースふつうのMarkdownリンク。Astroがhast(HTMLの構文木)に変換する1すべてのノードを走査見出しとコードは飛ばし、<Callout> などのMDXコンポーネントの中へ入る2ホストが対応表にあるリンクを装飾マークと先頭の1文字をspanに入れ、GitHubのリンクに短縮形を付け、マークを記録3スプライトを1つ追加記録したマークごとに <symbol> を作り、HTMLの末尾に置くビルド後のHTMLリンクの前にマーク、末尾に非表示のスプライト
プラグインが変えるのはビルド後のHTMLだけで、MDXのソースには手を加えません。

プラグインは装飾するリンクごとに、アイコンとラベルの先頭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のソースには現れません。

リンクは1つのスプライト内のシンボルを参照する左側に記事内の3つのリンクがある。2つはGitHub、1つはClaude Codeのドキュメントへのリンクで、それぞれuse要素を含む。右側はページ末尾の非表示のスプライトで、GitHubとClaudeのシンボルを1つずつ持ち、どちらもパスは1回だけ書かれている。GitHubへの2つのリンクは同じGitHubのシンボルを、Claudeへのリンクは Claude のシンボルを参照する。記事内のリンクページ末尾のスプライト非表示。それ自体は描画されないpnpm/pnpm#12744<use href="#li-github">withastro/astro#13996<use href="#li-github">Claude Code のドキュメント<use href="#li-claude"><symbol id="li-github">パスは1回だけ<symbol id="li-claude">パスは1回だけ
各リンクは <use> でマークを描き、ページ末尾にある1つのスプライト内の <symbol> を参照します。

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

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

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

マークホスト名
GitHubgithub.com, docs.github.com, github.blog
MDNdeveloper.mozilla.org
Vercelvercel.com
Claudecode.claude.com, platform.claude.com, docs.claude.com, support.claude.com
pnpmpnpm.io
Gitgit-scm.com
Astrodocs.astro.build

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

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

どのリンクにブランドマークが付くかノードごとに上から 4 つの質問を順に確認する。MDX コンポーネントは中へ入って走査し、子要素は最初の質問から判定し直す。見出し・code・pre の中にあるノードはスキップする。ホストが表にないリンクは通常のリンクのまま。GitHubへのリンクは、ラベルがURLそのものなら短縮形に置き換え、Issue・プルリクエスト・コミットへのリンクならラベルの後ろに (owner/repo#N) を付ける。残ったリンクには、アイコンとラベルの先頭 1 文字を nowrap の span に入れて付け、そのマークをページのスプライトに記録する。MDX コンポーネント(<Callout> など)?はい中へ入って走査し、子要素ごとに最初から判定いいえ見出し・code・pre の中?はいスキップして書かれたままにいいえホストが表にあるリンク?いいえ通常のリンク(マークなし)はいGitHubへのリンク?はいURLそのままなら短縮形にIssue・PR・コミットは参照を追加いいえマークを付ける:アイコンと先頭の 1 文字を nowrap の span にページのスプライトにマークを記録
判定はすべてビルド時に行われます。プラグインがテキストに手を加えるのは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 を使えるようにする要望 (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 も、そのように表示されます。

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

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

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 属性を付けます。

スプライト内の 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>
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の設定でも切り替えボタンでも、同じようにマークの色が切り替わります。

ダークモードの同じ参考リンク一覧。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はみ出していました。

マークをリンクのラベルと同じ行に保つ3通りの方法3つの行があり、それぞれ狭いカラムの中のリンクを示す。spanがない場合、マークだけが行末に残り、ラベルは次の行から始まる。先頭の単語をnowrapのspanに入れると、空白のない日本語のラベルが折り返せない1つの塊になり、カラムの右端をはみ出す。先頭の1文字だけをspanに入れると、マークはラベルの先頭と同じ行に残り、ラベルの残りはカラムの中で次の行へ折り返す。画面の右端✗spanなしマークだけが行末に残る✗先頭の単語をnowrapのspanに空白のないラベルが折り返せない✓先頭の1文字をspanにマークは残り、ラベルは折り返すgit-resetのドキュメント。git-resetのドキュメント。HEAD・インデックス画面からはみ出し<span>git-resetのドキュメント。<span>HEAD・インデックス
マークの置き方3通り。ラベルと同じ行に保ちつつ、空白のないラベルも折り返せるのは、先頭の1文字に結び付ける方法だけです。

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

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 がスプライトに適用されます。

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

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

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

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

<Callout> の中のリンクに走査が届くまで同じhastツリーを2つ並べた図。ルートの下には、リンクを含む段落と、Callout にあたる mdxJsxFlowElement があり、その中にも段落とリンクがある。左はelementノードだけを走査する場合で、最初のリンクにはマークが付くが、mdxJsxFlowElement は飛ばされ、Callout の中のリンクにはマークが付かない。右はmdxJsxFlowElementの中も走査する場合で、両方のリンクにマークが付く。elementノードだけを走査root<p><a>✓ マークありmdxJsxFlowElement<Callout>飛ばされる<p><a>✗ マークなしMDXのノードの中も走査root<p><a>✓ マークありmdxJsxFlowElement<Callout><p><a>✓ マークあり
同じツリーを2通りに走査した図。MDXのノードの中へ入る走査だけが、<Callout> の中のリンクに届きます。

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

src/lib/rehype-link-icons.ts
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 の中も走査しないと、コンポーネント内のリンクにマークが付きません。

参考リンク

この記事をシェア