在 Astro 文章的連結前加上該網站的 logo — 一個 rehype 外掛與三個陷阱
這個 Astro rehype 外掛會在建置時,把 simple-icons 的 logo 以內嵌 SVG sprite 的形式放在 GitHub、MDN、Vercel 等網站的連結之前,即使在嚴格的 CSP 下也能運作。

本頁目錄
引言
GitHub 會把指向 pull request 的連結,顯示成 pull request 圖示加上標題與編號;Confluence 也會把貼上的連結,變成一張指向該頁面的小卡片。
這個部落格的連結,原本全都是單調的藍字藍色底線。參考連結清單裡混著 GitHub issue、MDN 頁面等說明文件,看起來卻一模一樣。所以我開始想,能不能像 GitHub 那樣在連結的前面加上圖示?
於是我製作了一個 rehype 外掛,會在建置時替連結加上該網站的 logo,對象是這個部落格經常引用的網站:GitHub、pnpm、Vercel、Claude 等。logo 取自 simple-icons,以內嵌 SVG 的形式直接放進頁面,所以不需要額外下載這些 logo 而拖累頁面的載入速度。文章原始檔也仍然只是一般的 Markdown 連結。

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

只要把 rehype 外掛放進 Astro 讓 Markdown 和 MDX 共用的 unified() 處理器就好。這樣每篇文章的三種語言都會套用這個處理器,所以不需要逐篇設定:
import { rehypeTableScroll } from "./src/lib/rehype-table-scroll.ts";import { rehypeLinkIcons } from "./src/lib/rehype-link-icons.ts";// … markdown: { processor: unified({ rehypePlugins: [rehypeTableScroll, rehypeLinkIcons] }), },外掛會對每一個要加上 logo 的連結,取連結文字的第一個字元,把它和圖示一起放進一個 span 裡,並讓圖示用 <use> 去參照一個 symbol:
<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 原始檔中是看不到的。
<use> 畫出 logo,參照的是頁面最後那個 sprite 裡的 <symbol>。我把 logo 設為 aria-hidden,確保螢幕閱讀器只會唸出連結文字,而不會把 logo 當成圖片唸出來。我把 logo 的大小設為 1em,並用 margin-inline: 0.2em 0.25em 在左右各留一點空間。這是因為日文和中文的連結和前一個字之間不會有空格,margin-inline 能讓 logo 和前一個字之間多一點空間。
哪些連結會加上 logo
只有主機名稱完全吻合固定對照表的連結,才會加上 logo:
| logo | 主機名稱 |
|---|---|
| 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 裡有 logo 的網站。如果每個外部連結都加上 logo,logo 就不再傳達任何資訊,所以不在表裡的網站會維持一般連結。
rehype 外掛走訪時也會略過標題裡的連結,因為標題內的文字會用在目錄上;code 和 pre 裡的連結同樣略過。至於站內連結(包括註腳的連結),本來就不會對上對照表。
保留文章內使用的連結文字,再加上 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 的請求 (anthropics/claude-code#36123)就是一例。
如果連結文字已經含有編號或 SHA,就不會再加上任何東西,例如這裡的 Issue #36123。指向 repo 首頁的連結也一樣,例如 simple-icons,因為它的短格式只會重複 repo 名稱。
如果連結文字就是 URL 本身,外掛會直接把文字換成短格式,這和 remark-github 採用的規則相同。格式依照 GitHub 自動連結的說明文件:issue 或 pull request 會變成 owner/repo#N,提交會變成 repo 名稱加上 7 個字元的 SHA,repo 首頁則是 owner/repo,例如: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 收錄的是 GitHub 自己的頂層頁面,例如 features、orgs、settings 和 marketplace;少了它,直接寫成 URL 的 github.com/features/actions 會顯示成 features/actions,看起來像一個不存在的 repo。至於其他路徑(例如指向 blob/… 檔案的連結),則會保留原本的 URL,因為 GitHub 沒有替它們定義短格式。
為什麼 logo 要用內嵌 SVG?
這個網站的 CSP 是 img-src 'self' data:,只允許載入這個網站本身和 data: 的圖片,所以不論是從 Google 的 favicon 服務,還是從連結目標網站抓取 favicon,都會被擋下。如果在閱讀時才載入,還會把每位讀者的 IP 位址交給那個服務或目標網站,因此 logo 必須在建置時就放進頁面裡,而不是在客戶端的瀏覽器中抓取。
logo 取自 simple-icons,這個套件本來就是這個網站的相依套件。取得 logo 時會經過一個輔助函式,如果 logo 不存在,它就會拋出例外:
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 屬性:
<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 選色:淺色時用第一個值,深色時用第二個值。主題切換按鈕本來就會設定 color-scheme,所以不論是依照作業系統設定,還是用切換按鈕,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。
解決方法是 span 裡只放 logo 和第一個字元,nowrap 照樣保留。這樣 logo 一定會和連結文字的第一個字留在同一行,而其餘的文字在 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 會以字元為單位把連結文字切開,即使是 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 之後就能將其隱藏起來:
.link-icon-sprite { display: none;}即使 symbol 所在的 <svg> 是 display: none,<use> 也照樣能從裡面繪製,所以每個 logo 都仍然會顯示。
陷阱 3:MDX 元件不是 element 節點,裡面的連結會被略過
原本 rehype 外掛在走訪 HTML 樹時,只會處理 element 節點。但在 MDX 裡,像 <Callout> 這樣的元件,在 hast 樹中是 mdxJsxFlowElement,因此整個被跳過,導致只有 <Callout> 裡的連結沒有 logo。
<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 外掛,只要連結的主機名稱在固定對照表裡,每種語言的文章都會在連結前加上 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。


