この記事についてClaude(Anthropic)との共同編集により作成されました。
要約
- 昨日の告知記事の続き。Astro + Fuwari に X・Bluesky・はてブの共有ボタンを実装した全手順
- ボタンは
<a>タグのみで構成。クライアントJSは一切不要- Hatena Bookmark のアイコンは
fa6-brandsにないため、@iconify-json/simple-iconsを追加して対処- swup(SPA遷移)との相性も問題なし。再バインド処理が不要な理由まで記録
昨日(2026-05-05)、ゆるディープに SNS 共有ボタンをリリースしたことを告知した。今回はその実装の詳細を記録する。
採用した intent URL とパラメータ
X(Twitter)
https://x.com/intent/tweet?text={タイトル}&url={記事URL}text と url は独立したパラメータで、Xの投稿文は {タイトル} {記事URL} の形になる^1。投稿画面が新規タブで開き、ユーザーが内容を確認してから投稿できる。
Bluesky
https://bsky.app/intent/compose?text={タイトル\n記事URL}Bluesky の intent は url パラメータを持たない^2。回避策として text にタイトルと改行+URLをまとめて渡す。
const bskyUrl = `https://bsky.app/intent/compose?text=${encodeURIComponent(`${title}\n${absoluteUrl}`)}`Bluesky 側でURLをリンクカードとして展開してくれる。
はてなブックマーク
https://b.hatena.ne.jp/entry/panel/?url={記事URL}&title={タイトル}/entry/panel/ エンドポイントを使う^3。/entry/ だけだとブックマーク一覧ページになる。/add?mode=confirm&... という旧形式もあるが、現在は /entry/panel/ が推奨されている。
なぜ Astro 純正・クライアント JS なしにしたか
3ボタンとも「intent URLを新規タブで開く」<a> タグで完結する。コピー後のトースト表示のようなDOM操作がないため、クライアントサイドJSは一切不要だ。
比較として、Giscus を組み込んだ Comments.astro はインライン <script> でスクリプト要素を動的生成している(2026-05-04 の実装記事を参照)。あのパターンが必要だったのは、Giscusが <script> タグ自身の data-* 属性を読む仕組みだったからだ。
今回はURL生成をすべてビルド時に確定できるため、SSG(静的生成)との相性が最も良い実装を取れた。Svelteコンポーネントにする理由もない。
Hatena アイコンが Font Awesome にない問題と対処
fa6-brands(Font Awesome 6 Brands)には hatenabookmark が含まれていない。
対処として @iconify-json/simple-icons パッケージを追加した。これで simple-icons:hatenabookmark が利用できる^4。
astro.config.mjs の icon({ include: { ... } }) ブロックに1行追加するだけでよい。
"simple-icons": ["hatenabookmark"],simple-icons は 3000+ アイコンのセットだが、include 指定により必要なアイコンだけがビルドに含まれる。全部バンドルされるわけではない。
実装コード
ShareButtons.astro(全体)
---import I18nKey from "@i18n/i18nKey";import { i18n } from "@i18n/translation";import { getPostUrlBySlug } from "@utils/url-utils";import { Icon } from "astro-icon/components";
interface Props { title: string; slug: string; class?: string;}
const { title, slug } = Astro.props;const className = Astro.props.class;
const absoluteUrl = new URL(getPostUrlBySlug(slug), Astro.site).toString();
const xUrl = `https://x.com/intent/tweet?text=${encodeURIComponent(title)}&url=${encodeURIComponent(absoluteUrl)}`;const bskyUrl = `https://bsky.app/intent/compose?text=${encodeURIComponent(`${title}\n${absoluteUrl}`)}`;const hatenaUrl = `https://b.hatena.ne.jp/entry/panel/?url=${encodeURIComponent(absoluteUrl)}&title=${encodeURIComponent(title)}`;---
<div class={`flex flex-wrap items-center gap-2 ${className ?? ""}`}> <span class="transition text-sm text-black/30 dark:text-white/30 mr-1"> {i18n(I18nKey.share)}: </span> <a href={xUrl} target="_blank" rel="noopener noreferrer" aria-label="Share on X" class="btn-regular rounded-lg px-3 py-1.5 flex items-center gap-1.5 text-sm transition active:scale-95"> <Icon name="fa6-brands:x-twitter" class="text-base" /> <span>X</span> </a> <a href={bskyUrl} target="_blank" rel="noopener noreferrer" aria-label="Share on Bluesky" class="btn-regular rounded-lg px-3 py-1.5 flex items-center gap-1.5 text-sm transition active:scale-95"> <Icon name="fa6-brands:bluesky" class="text-base" /> <span>Bluesky</span> </a> <a href={hatenaUrl} target="_blank" rel="noopener noreferrer" aria-label="Share on Hatena Bookmark" class="btn-regular rounded-lg px-3 py-1.5 flex items-center gap-1.5 text-sm transition active:scale-95"> <Icon name="simple-icons:hatenabookmark" class="text-base" /> <span>はてブ</span> </a></div>getPostUrlBySlug(slug) で相対パスを取得し、Astro.site を使って絶対URLに変換している。Astro.site は astro.config.mjs の site フィールドから注入される値だ。
[…slug].astro への組み込み
<ShareButtons title={entry.data.title} slug={entry.slug} class="mb-6 onload-animation" />Markdown 本文の直後、licenseConfig.enable による License 表示の直前に挿入している。
配置場所の検討と決定
| 候補 | メリット | デメリット |
|---|---|---|
| 本文末・License前(採用) | 読了直後に目に入る、post-container内で統一感がある | — |
| License後・Comments前 | License→シェア→コメントの自然な順序 | Licenseがオフのときに少し遠くなる |
| Comments前(カード外) | 独立カードで視認性が高い | post-containerと分離してデザイン調整が必要 |
本文末・License前を採用。読了直後が心理的ハードルの最も低いタイミングと判断した。
swup 遷移後に再バインドが不要な理由
swup が何をしているか
swup^5 は、ページ遷移時にブラウザのフルリロードを行わず、指定したDOM要素だけを差し替えるJavaScriptライブラリだ(DOM〔Document Object Model〕とはブラウザがHTMLを読み込んだあとにメモリ上に構築するツリー構造で、<div> や <a> といったHTMLタグひとつひとつに対応するオブジェクトのことを指す)。SPA(Single Page Application)とはこうした手法の総称で、画面全体を再描画せずに必要な部分だけを書き換えることでネイティブアプリのような滑らかな遷移を実現する。
このブログも swup でSPA的な遷移を実装している。通常のリンククリックでは、ブラウザは次のページを丸ごとロードしてDOMを再構築する。swup はリンククリックをインターセプトし、fetch で次のページのHTMLを取得してから containers に指定したDOM要素だけを差し替える。
// astro.config.mjs(抜粋)containers: ["main", "#toc", "#sidebar"],<main>・#toc・#sidebar が毎回新しい内容に置き換わる。ブラウザのフルリロードは発生しないため、CSSや共通JSは再実行されない。見た目はSPAと同じ滑らかな遷移だ。
JS 依存コンポーネントが再バインドを必要とする理由
再バインドとは、DOMが差し替えられた後にJavaScriptのイベントリスナーや初期化処理を新しい要素へ再度適用する操作のことだ。
JSでイベントリスナーを登録した要素は、swup によるDOM差し替えで古い要素ごと破棄される。新しいDOMが挿入されても、リスナーは自動では付き直さない。
Giscus がその典型だ。Comments.astro に書かれたインライン <script> は Astro がモジュールとしてバンドルする。このスクリプトは初回ページロード時に [data-load-giscus="true"] 要素を探して Giscus の client.js を動的挿入する。しかし swup 遷移後に <main> が差し替わると、新しい <div data-load-giscus> はDOMに現れるが、バンドルされたスクリプトは再実行されない——Giscus が起動しないことになる。この問題を避けるためには、swup のライフサイクルイベントにフックしてスクリプトを再実行する処理が必要になる。
ShareButtons が問題にならない理由
ShareButtons の <a> タグにはイベントリスナーが存在しない。ブラウザが href を読んで新規タブを開く動作は、DOMに要素が存在すれば自動的に有効だ。swup が <main> を差し替えるたびに、新しいページの正しい intent URL を持つ <a> タグが描画される。追加のフック処理は不要。
「動的JSなし」という設計選択が、swupとの相性問題をそもそも発生させない構造になっている。
ファイル構成(変更一覧)
| ファイル | 変更内容 |
|---|---|
src/components/misc/ShareButtons.astro | 新規作成(共有ボタンコンポーネント) |
src/pages/posts/[...slug].astro | ShareButtonsのimportと挿入 |
src/i18n/i18nKey.ts | share キー追加 |
src/i18n/languages/*.ts(10ファイル) | 各言語で「シェア」相当の訳語追加 |
astro.config.mjs | "simple-icons": ["hatenabookmark"] を追加 |
package.json / pnpm-lock.yaml | @iconify-json/simple-icons 追加 |
所感
クライアントJSを一切書かずに完結した。
Giscus実装のとき「Astroのテンプレート変数を <script> の data-* に直接埋める方式はGiscusと噛み合わない」という落とし穴に嵌った。今回はその問題が構造的に発生しない。URLをビルド時に静的展開するだけで、Astroが得意とするSSGがそのまま機能する。
「動的scriptなし・intent URL方式・アイコンセット拡張で対処」——この3点セットは他のSNSボタン追加でも同じパターンを流用できる。
参考文献
- X intent tweet https://developer.x.com/en/docs/twitter-for-websites/tweet-button/guides/web-intent
- Bluesky intent compose https://docs.bsky.app/docs/advanced-guides/intent-links
- Hatena Bookmark add panel https://b.hatena.ne.jp/entry/panel/
- simple-icons (iconify) https://icon-sets.iconify.design/simple-icons/
- swup https://swup.js.org/