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

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

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

本頁目錄

引言

我讀的每個技術部落格,文章結尾都有一列分享按鈕。這個網站什麼都沒有,而我希望它讀起來是個完成的產品,不是一個少了零件的個人網站。最直覺的做法是 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 萬人收費。

分享 SDK 向所有讀者收費,Intent URL 只向點擊的人收費在尚未點擊的頁面載入當下比較兩欄。官方分享 SDK 欄列出 200 至 500 KB 的 JavaScript、連線至第三方主機、寫入追蹤 Cookie,以及由所有讀者共同負擔。Intent URL 欄列出 0 KB、不發出任何請求、不寫入 Cookie,以及只在點擊時才產生成本。結論指出 SDK 讓從未分享的讀者也要付出代價,而 Intent URL 只由真正分享的讀者負擔。頁面載入當下,還沒有人點擊官方分享 SDK200〜500 KB 的 JavaScript連線至第三方主機寫入追蹤 Cookie由所有讀者共同負擔從未分享的讀者也要付出代價Intent URL0 KB不發出任何請求不寫入 Cookie只在點擊時才產生成本只有真正分享的讀者才負擔
成本在頁面載入當下就產生,由所有人負擔,而且發生在決定要不要分享之前。

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

第一個位置放錯了

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

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

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

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

具名分享橫列在所有寬度都會出現,圖示側欄只在 80rem 以上加入兩個面板並列。小於 80rem 的窄面板顯示單欄內文,下方是具名分享橫列,並註明沒有指標裝置與 JavaScript 也能運作。80rem 以上的寬面板顯示相同的內文欄,左側留白處多出一條與內文相距 44px 的細長圖示側欄,具名分享橫列同樣仍在內文下方。側欄是捷徑,橫列則始終存在小於 80rem內文沒有指標裝置與 JavaScript 也能運作80rem 以上內文與內文相距 44px文章結尾的具名橫列圖示側欄,滑過或聚焦時顯示工具提示
橫列在兩個面板裡都在。側欄只在留白足夠時才疊上去,而且不會承載橫列沒有的東西。

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

每個端點都搬過家

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

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

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

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

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 只在版面配置裡正規化一次,所有分享目標都從那一個物件推導出來:

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

每個語言各自的分享目標

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

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

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 的寫法照搬了過來:

<!-- 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 hintstsc 是乾淨的,建置成功,標記語言也合法:含有文字節點的 <svg> 是合規的,只是什麼都不會畫。這個錯誤是在把算繪後的頁面截圖下來、實際看了一眼之後才浮現的。

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

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

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

分享側欄位於程式碼區塊之上、固定頁首之下五個圖層依 z-index 由上而下堆疊。Expressive Code 的複製完成提示為 99,跳至內容連結為 20,固定頁首與閱讀進度條為 10,本次新增的分享側欄為 2,Expressive Code 的程式碼區塊標頭為 1。側欄標示為本次新增,程式碼區塊標頭則標示為先前工具提示被蓋住的原因。文章頁面的堆疊順序99Expressive Code 的複製完成提示20跳至內容連結10固定頁首與閱讀進度條2分享側欄1Expressive Code 的程式碼區塊標頭本次新增工具提示被蓋住的原因
決定這個值的,是從建置後的 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 字典註冊進去,這樣兩顆複製按鈕就不會各說各話:

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 移除某個標誌,就是你不能送的信號。

參考連結

分享這篇文章