# 在 Astro 中依語言決定分享按鈕的目標 — 桌機是固定側欄，行動裝置是具名按鈕

> 在 Astro 中用 Intent URL 組出分享按鈕：每個語系有各自的分享目標、寬螢幕上的固定圖示側欄，以及過程中發現的端點變動。

- Source: https://oharu121.com/zh-tw/blog/astro-share-buttons-intent-urls-per-locale-targets/
- Published: 2026-08-13T20:20:42+09:00
- Tags: Astro, 國際化, CSS

---
## 引言

我讀的每個技術部落格，文章結尾都有一列分享按鈕。這個網站什麼都沒有，而我希望它讀起來是個完成的產品，不是一個少了零件的個人網站。最直覺的做法是 Facebook、X 與 LinkedIn 的官方按鈕，但那同時也是會送出 200 至 500 KB 第三方 JavaScript、並對從來不點它的讀者寫入追蹤 Cookie 的做法。**答案是 Intent URL，而且每個語言配上不同的分享目標。**

本文整理這在 Astro 裡是什麼樣子：一份登錄表驅動兩種呈現方式、讓網路上半數教學失效的端點變動，以及三個通過了我手上所有自動檢查的缺陷。

## 用 Intent URL，而不是 SDK

Intent URL 就是一條普通連結，它會開啟服務自己的發文畫面，欄位已經預先填好：`https://x.com/intent/tweet?text=…&url=…`。沒有要載入的指令碼，沒有要寫入的 Cookie，而且**在有人真的點下去之前，不會發出任何請求**。

這件事比表面上更重要，因為幾乎沒有人會點。分享按鈕的實測點擊率落在 **0.2% 到 0.5%**，一個被廣泛引用的案例是 150 萬次造訪換來每月大約 15 次點擊。**SDK 把那 15 個人用到的功能，向全部 150 萬人收費。**

*Figure — PageLoadCost: 成本在頁面載入當下就產生，由所有人負擔，而且發生在決定要不要分享之前。*

所以設計上的限制不是顯眼程度，而是成本：對於那些完全忽略它的讀者，分享功能必須是免費的。

## 第一個位置放錯了

智能體最初的提議，是把分享圖示釘在既有目錄軌線的底部。理由是那裡本來就固定顯示、本來就在正確的那側留白，所以不必新增欄位，也不必新增斷點。

我否決了它，因為**目錄一長就會毀掉這個做法**。側邊欄是用 `overflow-y: auto` 搭配 `max-height: calc(100vh - var(--nav-height))` 組出來的，所以一篇有三十個標題的文章，清單會把整個容器填滿。釘在下面的東西不是被推到搆不著的地方，就是吃掉它原本要依附的清單。

我想要的是 Zenn、Qiita 與 dev.to 都採用的那個樣式：左側留白裡一條固定的、只有圖示的側欄，配上工具提示。智能體反對了兩次，兩個理由都很合理。它們的側欄真正承載的是*計數*，也就是閱讀過程中會增加的按讚或收藏數量，那才撐得起一個永久欄位；分享側欄沒有狀態。而且只有圖示的側欄依賴滑鼠滑過，觸控裝置上根本沒有滑過這件事。

兩個反對都敗給了它不知道的事。按讚與收藏按鈕之後就會加上去，所以這一欄將來會有計數要承載。而**行動裝置根本不會出現側欄**：它拿到的是文字實際算繪出來的具名橫列，正因為那裡沒有工具提示。

*Figure — RailAndRow: 橫列在兩個面板裡都在。側欄只在留白足夠時才疊上去，而且不會承載橫列沒有的東西。*

斷點是量出來的，不是猜出來的。內文區塊固定為 1000px，所以空出來的留白在 72rem 是 76px、在 1280px 是 140px、在 1440px 是 220px。在目錄出現的 72rem，側欄會貼到可視區域邊緣，工具提示則會開在內文上方。**側欄要等到 80rem**，在那裡它與內文相距 44px。

## 每個端點都搬過家

智能體拿官方文件而不是教學文章去核對每個端點，而這在**六個裡有五個**產生了差別。

| 服務 | 多數教學的寫法 | 實際可用的寫法 |
| --- | --- | --- |
| X | `x.com/intent/post` | `x.com/intent/tweet`。`post` 這條路徑會開啟 App 內登入畫面，而不是發文畫面 |
| LinkedIn | 帶 `title` 與 `summary` 的 `shareArticle` | `sharing/share-offsite/?url=`。舊路徑已淘汰，文字類參數會被忽略 |
| Threads | `threads.net/intent/post` | `threads.com/intent/post`。Meta 換了網域 |
| Bluesky | `text` 與 `url` 兩個參數 | 只有 `text`，上限是 300 個字素叢集 |
| Hatena | 查詢字串 | `b.hatena.ne.jp/entry/s/<host><path>`，協定被折進 `/s/` 這個前綴裡 |
| Facebook | `sharer/sharer.php?u=` | 沒有變動 |

其中兩個的殺傷力不只是連結壞掉而已。

Bluesky 只有一個 `text` 欄位，沒有獨立的 URL 欄位，所以標題一長，連結本身就會被擠出 300 個字素叢集的上限，產出一則沒有連結的貼文。值得發出去的是 URL，所以讓步的是標題：

```ts title="src/data/share-targets.ts"
function blueskyText(url: URL, title: string) {
	const suffix = `\n${url.href}`;
	const budget = BLUESKY_MAX_GRAPHEMES - graphemes(suffix).length;
	const titleGraphemes = graphemes(title);
	if (titleGraphemes.length <= budget) return `${title}${suffix}`;
	return `${titleGraphemes.slice(0, budget - 1).join('')}…${suffix}`;
}
```

Hatena 更麻煩，因為它是安靜地失敗。它用 URL 字串本身當作書籤項目的鍵，所以 `/blog/foo` 和 `/blog/foo/` 會變成**兩個不同的項目，書籤數就此永久分家**。這個網站建置出來是 `/blog/foo/`，但 `astro dev` 是以 `trailingSlash: 'ignore'` 運作，瀏覽器要哪一種形式它就回哪一種，所以這個問題在開發階段看不見。URL 只在版面配置裡正規化一次，所有分享目標都從那一個物件推導出來：

```ts title="src/layouts/ArticleLayout.astro"
const shareUrl = new URL(
	Astro.url.pathname.endsWith('/') ? Astro.url.pathname : `${Astro.url.pathname}/`,
	Astro.site,
);
```

## 每個語言各自的分享目標

這是照抄 Zenn 得不到的部分。

はてなブックマーク 是日文技術文章真正流通的地方，其他語言圈沒有對等的東西。而在台灣，Facebook 至今仍以英語圈早已不再有的方式承載著連結分享。把六個網路服務一起端給所有人，等於在每位讀者面前擺上四個與他無關的標誌，所以登錄表為每個語系各自對應一份清單：

```ts title="src/data/share-targets.ts"
export const SHARE_TARGETS_BY_LOCALE = {
	'en': ['x', 'bluesky', 'hackernews'],
	'ja': ['x', 'hatena', 'bluesky'],
	'zh-tw': ['x', 'facebook', 'threads'],
} as const satisfies Record<Locale, readonly ShareTargetId[]>;
```

`satisfies` 在這裡是關鍵。少了它，**在 `LOCALES` 加了第四個語系卻忘了在這裡補上時，畫面會算繪出一列空的，而 `pnpm check` 仍然是綠的**。

英文那一組裡是 Hacker News 而不是 LinkedIn，這是被一個意料之外的限制逼出來的。**simple-icons 在 v14.0.0 因為商標異議移除了 LinkedIn 的標誌**，現在還會自動關閉要求恢復的請求，因為 LinkedIn 的品牌政策不允許第三方重製其標誌。沒有圖示的按鈕在具名橫列裡還撐得住，那裡由文字承擔辨識；側欄只有圖示，放一個通用的公事包在那裡什麼也認不出來。比起送出不該送出的標誌，我選擇換掉分享目標，而對一個談 Astro 與 TypeScript 的部落格來說，Hacker News 本來就是更合適的去處。

## 什麼都沒算繪出來的圖示

標誌來自 `simple-icons`，它早就因為程式碼區塊的檔案類型圖示而是這個專案的 devDependency。智能體第一次是把路徑資料寫死，之後才找到既有的登錄表，改成從套件匯入。

那次改寫帶進了一個沒有任何機制攔得住的錯誤。這個網站自己的 `Icon.astro` 存的是完整的 `<path>` 元素，所以用 `set:html` 算繪；而 `simple-icons` 輸出的只有 `d` 屬性的內容。智能體把 `set:html` 的寫法照搬了過來：

```astro
<!-- injects the `d` string as text, and draws nothing -->
<svg viewBox="0 0 24 24" set:html={target.path} />

<!-- what it needed to be -->
<svg viewBox="0 0 24 24"><path d={target.path} /></svg>
```

面對一列圖示全部看不見的分享橫列，`astro check` 回報的是 **0 errors、0 warnings、0 hints**。`tsc` 是乾淨的，建置成功，標記語言也合法：**含有文字節點的 `<svg>` 是合規的，只是什麼都不會畫**。這個錯誤是在把算繪後的頁面截圖下來、實際看了一眼之後才浮現的。

## 被程式碼區塊蓋住的工具提示

下一個是我自己發現的，當時正在讀一篇已發布的文章：工具提示開在了旁邊那個程式碼區塊的*下面*。

原因不是樹狀順序。Expressive Code 給每個程式碼區塊標頭都設了明確的 `z-index: 1`，而**正值的 z-index 會贏過 `auto`，跟誰先出現無關**。側欄也需要一個正值，而且要低到能留在固定頁首之下。

*Figure — StackingOrder: 決定這個值的，是從建置後的 CSS 裡把既有的堆疊順序讀出來。Expressive Code 自己的複製完成提示位在 99，比跳至內容連結還前面。*

這個專案自己的歷史裡還有一個細節在這裡也起了作用。`--nav-height` 量的是頁首*內部*那條列的高度，而頁首本身還多了 1px 的下框線，閱讀進度條正好佔著那條細線。用未經加工的 `var(--nav-height)` 去固定的元素會鑽到它下面，所以側欄用的是 `calc(var(--nav-height) + 1.5rem)`。

## 隔壁的複製按鈕是英文的

**加上第二個複製操作，才讓第一個壞掉的事實浮出來。**

發現它的是智能體，時間點是在分享功能動工之前檢查專案的時候。Expressive Code 會在每個程式碼區塊上算繪一顆複製按鈕。它的 frames 外掛內建的翻譯**只有英文與德文**，而設定裡沒有任何東西告訴它一個頁面是什麼語言。從導入 Expressive Code 的那天起，所有日文與繁體中文文章都在那顆按鈕上標著 `Copy to clipboard`，並回應 `Copied!`。

修法是從檔名推導語系，再把文案從網站自己的 UI 字典註冊進去，這樣兩顆複製按鈕就不會各說各話：

```js title="astro.config.mjs"
for (const locale of LOCALES) {
	pluginFramesTexts.addLocale(locale, {
		terminalWindowFallbackTitle: UI.terminalWindow[locale],
		// `copyCode`, not `copyLink` — this button copies the snippet, and the
		// share row's is the one that copies a URL.
		copyButtonTooltip: UI.copyCode[locale],
		copyButtonCopied: UI.copied[locale],
	});
}

getBlockLocale: ({ file }) => file.path.match(LOCALE_FILENAME)?.[1] ?? DEFAULT_LOCALE,
```

那段註解記錄的正是第三個缺陷。智能體的第一版為了讓兩顆按鈕一致，把 `copyLink` 這個字串拿來重複使用，結果是日文讀者會在一顆複製程式碼片段、而非複製 URL 的按鈕上看到「リンクをコピー」。一致這個判斷對完成提示是對的，對工具提示是錯的，而程式碼審查在合併前抓到了它。`addLocale` 也是整包取代該語系的文案，所以三個鍵都得給齊；漏掉終端機外框的標題，那個字串就會在每個終端機外框裡維持英文。

## 總結

- **Intent URL 在被點擊之前不產生任何成本。** 對於一個只有 0.2% 到 0.5% 讀者會碰的操作，這就是不採用 SDK 的理由。
- **每個端點都要拿官方文件核對。** 六個裡有五個已經變動，而被大量轉載的 X `intent/post` 會開啟登入畫面。
- **合適的分享目標在每個語言都不一樣。** はてなブックマーク 在其他語言圈沒有對等物，而一份共用的橫列會讓每位讀者都看到四個用不到的標誌。
- **型別檢查是綠的，不等於看過算繪後的頁面。** 面對一整列看不見的圖示，`astro check` 回報的是 0 errors、0 warnings、0 hints。
- 商標政策確實會限制你能送出哪些圖示，而 `simple-icons` 移除某個標誌，就是你不能送的信號。

## 參考連結

- [X 的發文 Web Intent 文件，至今仍以 `x.com/intent/tweet` 為正式路徑](https://docs.x.com/x-for-websites/post-button/guides/web-intent)
- [Bluesky 的 action intent links，含單一 `text` 參數與 300 個字素叢集的上限](https://docs.bsky.app/docs/advanced-guides/intent-links)
- [Meta 的 Threads Web Intents 參考文件，位於 `threads.com` 而非 `threads.net`](https://developers.facebook.com/docs/threads/threads-web-intents/)
- [MDN 對 Web Share API 的說明，以及哪些瀏覽器真的實作了 `navigator.share`](https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API)
- [simple-icons 在 v14.0.0 移除 LinkedIn 標誌的 issue](https://github.com/simple-icons/simple-icons/issues/11372)
- [分享按鈕點擊率 0.2% 到 0.5% 背後的量測](https://freshjuice.dev/blog/social-share-buttons-are-dead/)
