# 在 Astro 文章的連結前加上該網站的 logo — 一個 rehype 外掛與三個陷阱

> 這個 Astro rehype 外掛會在建置時，把 simple-icons 的 logo 以內嵌 SVG sprite 的形式放在 GitHub、MDN、Vercel 等網站的連結之前，即使在嚴格的 CSP 下也能運作。

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

---
## 引言

GitHub 會把指向 pull request 的連結，顯示成 pull request 圖示加上標題與編號；Confluence 也會把貼上的連結，變成一張指向該頁面的小卡片。

這個部落格的連結，原本全都是單調的藍字藍色底線。參考連結清單裡混著 GitHub issue、MDN 頁面等說明文件，看起來卻一模一樣。所以我開始想，能不能像 GitHub 那樣在連結的前面加上圖示？

於是**我製作了一個 rehype 外掛，會在建置時替連結加上該網站的 logo**，對象是這個部落格經常引用的網站：GitHub、pnpm、Vercel、Claude 等。logo 取自 simple-icons，以內嵌 SVG 的形式直接放進頁面，所以**不需要額外下載這些 logo 而拖累頁面的載入速度**。文章原始檔也仍然只是一般的 Markdown 連結。

*Image: 一份參考連結清單：pnpm 的連結前有橘色的 pnpm logo，GitHub issue 的連結前有 GitHub 的 Octocat，Vercel 的連結前有 Vercel 的三角形*

*套用外掛後，參考連結清單裡的每個連結前面，都會加上該網站的 logo。*

## 使用 rehype 外掛，替每個頁面套用 SVG sprite

SVG sprite 是一個隱藏的 `<svg>`，裡面存放著這個頁面會用到的所有 logo。套用了 rehype 外掛之後，在頁面上的任何地方只要用 `<use href="#id">`，就能畫出那個 id 相對應的 logo，所以不管同一個頁面用了幾次 logo，每個 logo 的 path 都只需要寫一次就好。

> **話說**
>
> **為什麼叫 sprite**
>
> sprite 原本是電玩裡的小圖，例如角色或道具。把許多 sprite 放進同一張圖、需要時再取出其中一塊，這種做法叫 sprite sheet。CSS sprite 和 SVG sprite 都沿用了這個概念：SVG sprite 把許多 `<symbol>` 放進同一個 `<svg>`，再用 `<use>` 取出需要的那一個。

*Image: 像素風格的 sprite sheet：草地、樹木、屋頂、圍籬、門窗和各種道具的 16×16 小圖，緊密排列在同一張圖片裡*

*[Kenney 的 Tiny Town](https://kenney.nl/assets/tiny-town)（CC0）。132 個 16×16 的小圖放在同一張圖片裡，遊戲要畫哪一個，就取出哪一塊。*

只要把 rehype 外掛放進 Astro 讓 Markdown 和 MDX 共用的 `unified()` 處理器就好。這樣每篇文章的三種語言都會套用這個處理器，所以不需要逐篇設定：

```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 原始檔不會被改動。*

外掛會對每一個要加上 logo 的連結，取連結文字的第一個字元，把它和圖示一起放進一個 span 裡，並讓圖示用 `<use>` 去參照一個 symbol：

```html title="建置時，外掛會生成前面帶有 logo 的連結"
<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>
```

把 logo 和第一個字元放進同一個 span，是為了讓它們待在同一行，logo 才不會單獨被留在行尾。至於為什麼只放一個字元而不是一個單字，我會在之後的陷阱 1 中說明。

外掛會檢視這個頁面中的所有連結，並在建置出來的 HTML 最後加上 SVG sprite，裡面只放這個頁面用到的所有 logo。這樣一來，即使一篇文章大量引用了 Claude 的連結，也只需要繪製一次再讓各個連結參照，不會讓載入的檔案變得很大。這個 sprite 只存在於建置出來的 HTML 裡，在 MDX 原始檔中是看不到的。

*Figure — SpriteReference: 每個連結都用 `<use>` 畫出 logo，參照的是頁面最後那個 sprite 裡的 `<symbol>`。*

我把 logo 設為 `aria-hidden`，確保**螢幕閱讀器只會唸出連結文字**，而不會把 logo 當成圖片唸出來。我把 logo 的大小設為 `1em`，並用 `margin-inline: 0.2em 0.25em` 在左右各留一點空間。這是因為日文和中文的連結和前一個字之間不會有空格，`margin-inline` 能讓 logo 和前一個字之間多一點空間。

## 哪些連結會加上 logo

只有主機名稱完全吻合固定對照表的連結，才會加上 logo：

| logo | 主機名稱 |
| --- | --- |
| [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 裡有 logo 的網站。如果每個外部連結都加上 logo，logo 就不再傳達任何資訊，所以不在表裡的網站會維持一般連結。

rehype 外掛走訪時也會略過標題裡的連結，因為標題內的文字會用在目錄上；`code` 和 `pre` 裡的連結同樣略過。至於站內連結（包括註腳的連結），本來就不會對上對照表。

*Figure — LinkDecision: 所有判斷都在建置時完成。外掛只會改動指向 GitHub 的連結文字：直接寫成 URL 的換成短格式，指向 issue、pull request 或提交的則在後面加上短格式。*

### 保留文章內使用的連結文字，再加上 GitHub 的短格式

Claude Code 一開始建議，把所有 GitHub 連結都改寫成 GitHub 自己的 `owner/repo#59` 格式。但它在動手前先翻過了這個部落格的文章，發現**issue 連結早就有手寫的連結文字**，寫法還各不相同：`pnpm#12744`、`Issue #36123: …`、`withastro/astro issue #13996: …`。

如果把這些文字換成 `owner/repo#59`，就看不出每個 issue 在講什麼了。**所以我決定保留連結文字，並在後面加上短格式**：指向 issue、pull request 或提交的連結，會顯示成 `連結文字 (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)。指向 repo 首頁的連結也一樣，例如 [simple-icons](https://github.com/simple-icons/simple-icons)，因為它的短格式只會重複 repo 名稱。

如果連結文字就是 URL 本身，外掛會直接把文字換成短格式，這和 `remark-github` 採用的規則相同。格式依照 GitHub 自動連結的說明文件：issue 或 pull request 會變成 `owner/repo#N`，提交會變成 repo 名稱加上 7 個字元的 SHA，repo 首頁則是 `owner/repo`，例如：[https://github.com/remarkjs/remark-github](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` 收錄的是 GitHub 自己的頂層頁面，例如 `features`、`orgs`、`settings` 和 `marketplace`；少了它，直接寫成 URL 的 `github.com/features/actions` 會顯示成 `features/actions`，看起來像一個不存在的 repo。至於其他路徑（例如指向 `blob/…` 檔案的連結），則會保留原本的 URL，因為 GitHub 沒有替它們定義短格式。

## 為什麼 logo 要用內嵌 SVG？

> **話說**
>
> **什麼是 CSP**
>
> CSP（Content Security Policy）是網站透過回應 header 告訴瀏覽器的規則，規定頁面可以從哪些來源載入圖片、指令碼和樣式。不符合規則的請求，瀏覽器會直接擋下。

這個網站的 CSP 是 `img-src 'self' data:`，只允許載入這個網站本身和 `data:` 的圖片，所以**不論是從 Google 的 favicon 服務，還是從連結目標網站抓取 favicon，都會被擋下**。如果在閱讀時才載入，還會把每位讀者的 IP 位址交給那個服務或目標網站，因此 logo 必須在建置時就放進頁面裡，而不是在客戶端的瀏覽器中抓取。

logo 取自 simple-icons，這個套件本來就是這個網站的相依套件。取得 logo 時會經過一個輔助函式，如果 logo 不存在，它就會拋出例外：

```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 確實會移除 logo。**15.0.0 版在同一個 pull request 裡，移除了 Amazon Web Services 和所有 Amazon 的 logo**，所以 AWS 說明文件的連結雖然在這個部落格裡和 Vercel 一樣常被引用，也不會加上 logo。我選擇讓它們維持一般連結，而不是隨便補上一個通用圖示。W3C 和 IETF 在 simple-icons 裡也沒有。

用 SVG sprite 內嵌 logo，是 Claude Code 提出的做法；另一個選項是產生一份樣式表，把每個 logo 寫成 `data:` URI 的背景圖。採用 sprite 的話，外掛只需要輸出 HTML 標記：每一頁只帶用到的 logo，也不必另外產生一份 CSS 檔、再費心讓它和對照表保持同步。

## 品牌色，以及在深色模式下消失的三個 logo

雖然 Claude Code 建議我讓每個 logo 都統一使用連結的顏色，這樣一來不管在亮色還是深色模式下都能看得清楚，但我仍想用原本 logo 各自的配色。

其中有三個 logo 是黑色的。在這個網站深色模式的背景 `#0b1120` 上，**GitHub 的 `#181717` 對比度只有 1.05:1，MDN 和 Vercel 的 `#000000` 也只有 1.12:1**，遠低於 WCAG 對圖形要求的 3:1，所以在深色模式下這三個 logo 都幾乎看不見。於是我替這 3 個 logo 做了另一個淺色版本。

rehype 外掛會在建置時，計算每個 logo 和深色背景的對比度。低於 3:1 的 logo 會加上一個淺色版本的 class，並把品牌色放進 `color` 屬性：

```html title="sprite 中的 GitHub symbol"
<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` 選色：淺色時用第一個值，深色時用第二個值。主題切換按鈕本來就會設定 `color-scheme`，所以**不論是依照作業系統設定，還是用切換按鈕，logo 都會跟著換色**。

*Image: 深色模式下的同一份參考連結清單：GitHub 的 Octocat 和 Vercel 的三角形，在淺藍色連結旁以淺灰色呈現*

*在深色模式下，GitHub 和 Vercel 的 logo 改用文字色。*

## 三個陷阱：CJK 連結文字、`<svg hidden>` 與 MDX 元件

`pnpm check` 和建置都沒有抓到這三個問題，只有在算繪出來的頁面上才看得到。

### 陷阱 1：CJK 連結文字沒有空格，包進 `nowrap` 後就無法換行，還會把頁面撐寬

把 logo 設成 `inline-block` 之後，它的前後都有可能換行。因此瀏覽器可能會把 logo 留在前一行的最後面，讓連結文字從下一行開始，而且**就算加上不換行空格也沒有用**。

常見的解決方法是用 `white-space: nowrap` 的 span，把 logo 和連結文字的第一個單字包在一起。`nowrap` 不允許在 span 裡面出現換行，所以 logo 和那個單字一定會留在同一行；要換行，只能在 logo 之前或那個單字之後，不會出現 logo 後面馬上換行的情況。

這個方法在英文文章中管用，但由於**日文和中文的連結文字中沒有空格，外掛找不到可以切開的地方，因此會把整段文字當成第一個單字**。如果連結文字很長，就有可能超出手機 390px 寬的可視區域。實測後發現有 15 個頁面比螢幕還寬，最嚴重的甚至溢出了 652px。

*Figure — IconWrap: logo 的三種放法。只有綁在第一個字元上，才能讓 logo 和連結文字留在同一行，同時讓沒有空格的連結文字也能換行。*

**解決方法是 span 裡只放 logo 和第一個字元**，`nowrap` 照樣保留。這樣 logo 一定會和連結文字的第一個字留在同一行，而其餘的文字在 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` 會以字元為單位把連結文字切開，即使是 emoji 這種由兩個 UTF-16 單位組成的字元，也不會被切成兩半。像 ``[`simple-icons`](…)`` 這種以行內程式碼寫成的連結文字，會整個放進 span，再用 `.link-brand > :not(svg) { white-space: normal; }` 讓它在 span 裡換行。改好之後，同一個檢查在 238 個頁面上都沒有監測到任何溢出。

### 陷阱 2：`<svg hidden>` 還是會畫出一個 300×150 的框

為了讓 sprite 不要出現在頁面上，我原以為加上 `hidden` 屬性就可以了。但加在 `<svg>` 上之後卻完全沒有作用。

瀏覽器內建的樣式表裡確實有 `[hidden] { display: none }` 這條規則，但它只能套用在 HTML 元素上（規範裡寫在 XHTML 命名空間之下），而無法套用到 SVG 元素上。所以 sprite 還是以 `display: inline` 和替換元素的預設大小 300×150 算繪出來，結果**每一篇有 logo 的文章最後，都多了一個空白的框**。

`hidden` 屬性本身不會隱藏任何東西，瀏覽器會根據內建的規則隱藏該元素，但該規則只套用在 HTML 元素上。class 選擇器則沒有這個限制：用 `.link-icon-sprite` 就能鎖定 SVG sprite，設定 `display: none` 之後就能將其隱藏起來：

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

即使 symbol 所在的 `<svg>` 是 `display: none`，`<use>` 也照樣能從裡面繪製，所以每個 logo 都仍然會顯示。

### 陷阱 3：MDX 元件不是 `element` 節點，裡面的連結會被略過

原本 rehype 外掛在走訪 HTML 樹時，只會處理 `element` 節點。但在 MDX 裡，像 `<Callout>` 這樣的元件，在 hast 樹中是 `mdxJsxFlowElement`，因此整個被跳過，導致只有 **`<Callout>` 裡的連結沒有 logo**。

*Figure — HastWalk: 同一棵樹的兩種走訪方式。只有走進 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 外掛，只要連結的主機名稱在固定對照表裡，每種語言的文章都會在連結前加上 simple-icons 的 logo，文章原始檔裡維持一樣寫 Markdown 形式的連結就好，不需要額外的變更。
- 因為 CSP 會擋下其他網站的 favicon，所以 logo 全部採用內嵌 SVG。每個頁面只放一個 sprite，裡面只有該頁面用到的所有 logo，連結再用 `<use>` 參照後描繪。
- 文章裡使用的連結文字一律保留。指向 GitHub issue、pull request 或提交的連結，只要連結文字裡沒有編號，就會在後面加上短格式；直接寫成 URL 的連結則換成短格式，GitHub 自己的頂層頁面除外。
- 在深色背景上對比度低於 3:1 的 logo，套用 `fill: light-dark(currentColor, var(--text-1))`，並把品牌色放進 `color` 屬性裡。
- span 裡只放 logo 和第一個字元，而不是第一個單字，否則 CJK 的連結文字會因為沒有空白而無法換行。`[hidden]` 不會作用在 SVG 上，所以 sprite 要用 class 隱藏。`mdxJsxFlowElement` 和 `mdxJsxTextElement` 的內部也要走訪，否則 `<Callout>` 這類元件裡的連結不會被加上 logo。

## 參考連結

- [simple-icons](https://github.com/simple-icons/simple-icons)
- [simple-icons 15.0.0 版本說明，包含移除 Amazon 與 AWS logo](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)
