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

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

黑色卡片上的白色 Astro 火箭標誌與字樣,火焰為粉紅色
本頁目錄

引言

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

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

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

一份參考連結清單: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 sheet:草地、樹木、屋頂、圍籬、門窗和各種道具的 16×16 小圖,緊密排列在同一張圖片裡

Kenney 的 Tiny Town(CC0)。132 個 16×16 的小圖放在同一張圖片裡,遊戲要畫哪一個,就取出哪一塊。

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

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] }),
},
外掛對一個頁面做的事在標示為建置時的區塊裡,由上而下的流程。含有一般 Markdown 連結的 MDX 原始檔會先轉成 hast。步驟 1 走訪每個節點,略過標題和程式碼,並走進 MDX 元件裡。步驟 2 替主機在對照表中的連結加上 logo:把 logo 和第一個字元放進 span,縮短直接寫成 URL 的 GitHub 連結,並記下用到的 logo。步驟 3 在 HTML 最後加上一個 sprite,每個記下的 logo 一個 symbol。結果是建置出來的 HTML,連結前面有 logo,最後是隱藏的 sprite。這些步驟都在建置網站時執行,不會在讀者的瀏覽器裡執行MDX 原始檔一般的 Markdown 連結,Astro 會把它轉成 hast(HTML 語法樹)1走訪每個節點略過標題和程式碼,走進 <Callout> 這類 MDX 元件裡2替主機在對照表中的連結加上 logo把 logo 和第一個字元放進 span,替 GitHub 連結加上短格式,並記下用到的 logo3加上一個 sprite替每個記下的 logo 建立一個 <symbol>,放在 HTML 最後建置出來的 HTML連結前面有 logo,最後是隱藏的 sprite
外掛只改動建置出來的 HTML,MDX 原始檔不會被改動。

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

建置時,外掛會生成前面帶有 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 原始檔中是看不到的。

連結參照同一個 sprite 裡的 symbol左邊是文章裡的三個連結:兩個連到 GitHub,一個連到 Claude Code 說明文件,每個連結都包含一個 use 元素。右邊是頁面最後隱藏的 sprite,裡面有 GitHub 和 Claude 各一個 symbol,path 都只寫一次。兩個 GitHub 連結都參照同一個 GitHub symbol,Claude 的連結則參照 Claude 的 symbol。文章裡的連結頁面最後的 sprite隱藏,本身不會畫出來pnpm/pnpm#12744<use href="#li-github">withastro/astro#13996<use href="#li-github">Claude Code 說明文件<use href="#li-claude"><symbol id="li-github">path 只寫一次<symbol id="li-claude">path 只寫一次
每個連結都用 <use> 畫出 logo,參照的是頁面最後那個 sprite 裡的 <symbol>。

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

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

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

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

哪些連結會加上 logo每個節點由上而下依序回答四個問題。遇到 MDX 元件會走進內部,子節點再從第一個問題開始判斷。位於標題、code 或 pre 內的節點會被略過。主機名稱不在對照表中的連結維持一般連結。指向 GitHub 的連結,如果連結文字就是 URL 本身,會換成短格式;指向 issue、pull request 或提交的連結,則會在連結文字後面加上 (owner/repo#N)。其餘的連結會把 logo 與連結文字的第一個字元包進 nowrap 的 span,並把該 logo 記錄到頁面的 sprite。MDX 元件(例如 <Callout>)?是走進元件內部,每個子節點從頭判斷否位於標題、code 或 pre 內?是略過,保持原樣否主機名稱在對照表中的連結?否一般連結,不加 logo是指向 GitHub 的連結?是直接寫成 URL 就縮短,issue、PR、提交則加上短格式否加上 logo:把 logo 與第一個字元包進 nowrap 的 span把 logo 記錄到頁面的 sprite
所有判斷都在建置時完成。外掛只會改動指向 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 的請求 (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。

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

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

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 檔、再費心讓它和對照表保持同步。

雖然 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 屬性:

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>
src/styles/global.css
.link-mark-ink {
fill: light-dark(currentColor, var(--text-1));
}

currentColor 會讀取 color 屬性,所以淺色主題用的是原本的品牌色,深色主題用的則是網站的文字色。light-dark() 會依照 color-scheme 選色:淺色時用第一個值,深色時用第二個值。主題切換按鈕本來就會設定 color-scheme,所以不論是依照作業系統設定,還是用切換按鈕,logo 都會跟著換色。

深色模式下的同一份參考連結清單: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。

讓 logo 和連結文字留在同一行的三種做法三列,每一列都是窄欄位裡的一個連結。沒有 span 時,logo 單獨留在一行的結尾,連結文字從下一行開始。把第一個單字放進 nowrap 的 span 時,沒有空格的日文連結文字會變成一整段無法斷開的文字,超出欄位右緣。只把第一個字元放進 span 時,logo 和連結文字的開頭留在同一行,其餘的文字在欄位內換到下一行。畫面右緣✗沒有 spanlogo 被留在行尾✗第一個單字放進 nowrap 的 span沒有空格的連結文字無法換行✓第一個字元放進 spanlogo 跟著連結文字的開頭,其餘照常換行git-resetのドキュメント。git-resetのドキュメント。HEAD・インデックス超出畫面<span>git-resetのドキュメント。<span>HEAD・インデックス
logo 的三種放法。只有綁在第一個字元上,才能讓 logo 和連結文字留在同一行,同時讓沒有空格的連結文字也能換行。

解決方法是 span 裡只放 logo 和第一個字元,nowrap 照樣保留。這樣 logo 一定會和連結文字的第一個字留在同一行,而其餘的文字在 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 會以字元為單位把連結文字切開,即使是 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 之後就能將其隱藏起來:

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。

走訪如何走到 <Callout> 裡的連結並排的兩棵相同的 hast 樹。根節點底下有一個含連結的段落,以及代表 Callout 的 mdxJsxFlowElement,裡面也有一個段落和連結。左邊的走訪只進入 element 節點:第一個連結加上了 logo,但 mdxJsxFlowElement 被跳過,Callout 裡的連結沒有 logo。右邊的走訪也會走進 mdxJsxFlowElement,兩個連結都加上了 logo。只走訪 element 節點root<p><a>✓ 加上 logomdxJsxFlowElement<Callout>被跳過<p><a>✗ 沒有 logo也走進 MDX 節點root<p><a>✓ 加上 logomdxJsxFlowElement<Callout><p><a>✓ 加上 logo
同一棵樹的兩種走訪方式。只有走進 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 外掛,只要連結的主機名稱在固定對照表裡,每種語言的文章都會在連結前加上 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。

參考連結

分享這篇文章