今天要解的問題
讀者看完一篇想轉給朋友,或是我想要分享,只能自己去網址列複製網址,實在有夠麻煩的。
最後做出來長這樣,位置在 tags 之下、上一頁/下一頁之上,上下各一條細線:

想法與取捨
為什麼不直接開主題的分享列
PaperMod 本來就有分享列,hugo.yml 的 ShowShareButtons 打開就有。但它內建的平台是 X、LinkedIn、Reddit、Facebook、WhatsApp、Telegram、Hacker News,沒有 LINE,也沒有 Threads。全部打開就是一排七顆,正好是「一排 icon 看起來很亂」;只挑幾個開,也挑不到 LINE 和 Threads。
所以開關沿用主題的(ShowShareButtons: true,單篇用 disableShare: true 關掉,關於頁本來就有設),裡面的標記整個自己寫。
桌機和手機要的東西不一樣
定案(2026-10-19)的規格是:
- 桌機:小字「分享」,後面 LINE、Threads、Facebook、複製連結四顆圓形 icon。
- 手機(≤ 768px):只有兩顆文字按鈕,「分享這篇」和「複製連結」。需求單上寫「分享這篇」是呼叫系統分享選單,「裡面本來就有所有 app」。
- 不要:X、LinkedIn、Telegram,以及主題分享列的其他平台。
768px 這個斷點跟網站的選單一樣:選單在 ≤ 768px 收成漢堡按鈕,分享列也在同一個寬度切換。
寬度交給 CSS,能不能分享交給 JS
手機版要用的 navigator.share 不是每個瀏覽器都有。規格寫的是:手機瀏覽器不支援時,改顯示桌機那排 icon。
判斷可以全寫在 JS 裡(「寬度 ≤ 768 且有 navigator.share」才切),但我把兩件事拆開:JS 只檢查 navigator.share 存不存在,有的話在分享列加一個 class;「≤ 768px 才換成兩顆按鈕」寫在 CSS 的 media query。效果一樣,但視窗縮放、手機轉向時會即時跟著切,不用在 JS 裡監聽寬度。
拆開還有一個理由:桌機的瀏覽器也可能有 navigator.share。實測時 1280px 的 Chromium 一樣拿到了那個 class,只是因為寬度不到 media query 的條件,畫面上照樣是四顆 icon。
沒有 JS 時
三個社群連結是普通的 <a href>,沒有 JS 也能點。「複製連結」和「分享這篇」要 JS 才能動,所以兩顆按鈕在 HTML 裡預設 hidden,由 JS 打開。沒有 JS 的讀者看到的是三顆 icon,不會看到一顆按了沒反應的按鈕。

實作
改動落在 5 個檔案:hugo.yml、文章模板、新的分享列 partial、新的 share.js、custom.css。
分享網址在 build 時組好
{{- $url := .Permalink }}
{{- $title := .Title | plainify }}
{{- $u := urlquery $url }}
{{- $t := replace (urlquery $title) "+" "%20" }}
<div class="share-buttons" data-url="{{ $url }}" data-title="{{ $title }}">
<span class="share-label">分享</span>
<ul class="share-icons">
<li>
<a class="share-icon" href="https://social-plugins.line.me/lineit/share?url={{ $u | safeURL }}" target="_blank" rel="noopener noreferrer" aria-label="分享到 LINE" title="LINE">
<!-- ... SVG ... -->
這是 layouts/partials/share_icons.html 的開頭,覆寫主題的同名 partial。三個平台的網址都在 Hugo build 時組好:LINE 和 Facebook 只帶網址,Threads 帶「標題 + 空格 + 網址」。
urlquery 會把空格編成 +,規格要的是 %20,所以標題編碼後再把 + 換成 %20。標題裡原本就有的 + 會先被編成 %2B,不會被誤換。實際輸出的 Threads 連結長這樣:
threads.net/intent/post?text=2026Q3%20SEO%20%E5%81%A5%E6%AA%A2%20https%3A%2F%2Fkiwi-walk.com%2Fblogs%2Fengineer%2Fblog-seo-audit%2F
data-url、data-title 是給 JS 用的:網站的 CSP 不允許 inline script(安全標頭那篇把全站 inline script 搬成了檔案),每頁不同的值只能用 data- 屬性傳。
手機按鈕預設藏起來
<div class="share-native" hidden>
<button class="share-pill share-native-btn" type="button">
<!-- ... SVG ... -->
<span>分享這篇</span>
</button>
<button class="share-pill share-copy" type="button">
<!-- ... SVG ... -->
<span class="share-copy-text">複製連結</span>
</button>
</div>
<span class="share-status" role="status" aria-live="polite"></span>
</div>
{{- $share := resources.Get "js/share.js" | minify | fingerprint }}
<script src="{{ $share.RelPermalink }}" integrity="{{ $share.Data.Integrity }}" crossorigin="anonymous" defer></script>
手機那兩顆按鈕帶著 hidden,桌機那顆複製 icon(share-copy-item)也是。最後的 share-status 是桌機複製後顯示「已複製」的地方,role="status" 讓螢幕閱讀器也會唸出來。script 用 fingerprint 加 integrity 載入,跟網站其他 script 一樣。
JS:打開按鈕、呼叫 navigator.share
copyItem.hidden = false;
if (typeof navigator.share === 'function') {
bar.classList.add('has-native-share');
native.hidden = false;
bar.querySelector('.share-native-btn').addEventListener('click', function () {
navigator.share({ title: title, url: url }).catch(function () {
/* AbortError when the user closes the sheet: nothing to do. */
});
});
}
assets/js/share.js 一開始就把複製 icon 打開(有 JS 就能複製,見下一段)。有 navigator.share 才加 has-native-share、打開手機按鈕。使用者打開系統選單又關掉時,navigator.share 會以 AbortError reject,這裡接住不做事,console 不會冒錯誤。
CSS:只有手機寬度才換
/* Desktop never shows the pills, even where navigator.share exists. */
.share-native {
display: none;
}
/* ... */
@media (max-width: 768px) {
.share-buttons.has-native-share .share-label,
.share-buttons.has-native-share .share-icons {
display: none;
}
.share-buttons.has-native-share .share-native:not([hidden]) {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
桌機一律不顯示兩顆膠囊按鈕,即使有 has-native-share。只有在 ≤ 768px 而且有那個 class 時,才把「分享」字樣和 icon 列藏起來、換成按鈕。按鈕列用 flex-wrap,窄到放不下就換行,不會橫向捲動。
分享列的位置
{{- if (and site.Params.ShowShareButtons (ne .Params.disableShare true)) }}
{{- partial "share_icons.html" . -}}
{{- end }}
{{- if (.Param "ShowPostNavLinks") }}
{{- partial "post_nav_links.html" . }}
{{- end }}
主題的 single.html 把分享列放在上一頁/下一頁的後面。規格要的是 tags 之下、上一頁/下一頁之上,所以在網站自己的 layouts/_default/single.html 把這兩段對調。
把通用圖示換成官方 logo
第一版的社群 icon,我原本以為只是示意圖,結果就是真的畫面。
實作做完之後,才發現原來是 LINE。所以三個都換成官方 logo,圖形取自 Simple Icons(CC0 授權)。Facebook 本來的 f 認得出來,但另外兩個換成實心 logo 之後,只剩它是線條圖會不一致,就一起換了。

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20" fill="currentColor" aria-hidden="true"><path d="M19.365 9.863c.349 0 .63.285.63.631 ..."/></svg>
logo 用 fill="currentColor",顏色跟著 CSS 的文字色走,跟「複製連結」那顆一樣是單色,不放品牌原色,四顆 icon 看起來是同一組。
踩到的坑
內嵌瀏覽器裡複製失敗
第一版的複製只用 navigator.clipboard.writeText,失敗就顯示「複製失敗」。在 Claude 桌面版的內建瀏覽器裡點複製 icon,出來的就是「複製失敗」。
我沒有再細查是權限還是別的原因,而是補了一個退路:writeText 不存在或被拒絕時,改用一個看不見的 <textarea> 加 document.execCommand('copy'),兩個都失敗才顯示「複製失敗」。
/* For browsers without the async clipboard API or that deny it. */
function legacyCopy(text) {
var ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.className = 'share-copy-buffer';
document.body.appendChild(ta);
ta.select();
var ok = false;
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
document.body.removeChild(ta);
return ok;
}
var done = function () { flash('已複製'); };
var fallback = function () { flash(legacyCopy(url) ? '已複製' : '複製失敗'); };
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(done, fallback);
} else {
fallback();
}
這也是為什麼「複製連結」一律顯示:有 JS 就有退路可試。
補完之後在同一個內建瀏覽器重測,結果還是「複製失敗」。但那次是用 script 呼叫按鈕的 .click(),不算使用者真的按,這個測試本身就說明不了什麼。後來改用 Playwright,給頁面剪貼簿權限再點一次:桌機顯示「已複製」,剪貼簿裡就是那篇文章的網址;375px 的「複製連結」按鈕也一樣。
主題的分享列樣式會橫向捲動
主題給 .share-buttons 的樣式是一個有底色、有框的盒子,內容置中,還有 overflow-x: auto。這個網站的規則是手機寬度下任何東西都不能左右捲,所以 custom.css 把它整個蓋掉:拿掉底色和框,改成上下兩條細線、靠左排、flex-wrap: wrap、overflow: visible。
在 375px 實測,有 navigator.share、沒有 navigator.share、停用 JS 三種情況,document.documentElement.scrollWidth 都沒有超過 375,分享列裡沒有可以橫向捲動的元素。
小結
文章頁現在有分享列:桌機四顆圓形 icon,手機兩顆按鈕把分享交給系統選單。不支援 navigator.share 的瀏覽器和沒有 JS 的讀者,各自退回還能用的版本。分享網址在 build 時就組好,是普通連結,不載入任何第三方 script,CSP 一個字都不用改。