# Astroの共有ボタンを言語ごとに出し分ける — デスクトップは固定レール、モバイルはラベル付きのピル

> AstroでIntent URLから共有ボタンを組み立てる。ロケールごとに異なる共有先、広い画面での固定アイコンレール、そして途中で見つかったエンドポイントのずれ。

- Source: https://oharu121.com/ja/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でどう形になるのかを整理します。1つのレジストリが2つの見せ方を駆動する構成、ネット上のチュートリアルの半分を間違いにしていたエンドポイントのずれ、そして手元の自動チェックをすべて通過した3つの不具合です。

## SDKではなくIntent URL

Intent URLは、サービス自身の投稿画面をあらかじめ入力済みの状態で開くだけの、ただのリンクです。`https://x.com/intent/tweet?text=…&url=…`がその形になります。読み込むスクリプトはなく、書き込むCookieもなく、**誰かが実際にリンクをたどるまでリクエストは1つも発生しません**。

これは見た目以上に効いてきます。というのも、たどる人はほとんどいないからです。共有ボタンのクリック率は実測で**0.2〜0.5%**、広く引用されている事例では150万人の訪問に対して月に15クリック程度でした。**SDKは、15人が使った操作の代金を150万人全員に請求します。**

*Figure — PageLoadCost: コストが発生するのはページ読み込みの時点で、共有するかどうかを決める前の全員が負担します。*

つまり設計上の制約は目立たせることではなく費用のほうでした。共有機能を無視する読者にとって、無料でなければならないということです。

## 最初の配置は間違っていた

エージェントの最初の提案は、共有アイコンを既存の目次レールの足元に固定するというものでした。すでに固定表示されていて、すでに正しい側の余白にあるので、新しい列もブレークポイントも増えないという理屈です。

私はこれを却下しました。**目次が長いと成立しない**からです。サイドバーは`overflow-y: auto`と`max-height: calc(100vh - var(--nav-height))`で組まれているので、見出しが30個ある記事ではリストが枠を埋め尽くします。その下に固定したものは、手の届かないところへ流れていくか、固定した先のリストを削るかのどちらかになります。

私が望んだのは、Zenn・Qiita・dev.toがそろって採用しているパターンでした。左の余白に固定した、アイコンだけのレールとツールチップです。エージェントは2度反対し、どちらの指摘ももっともでした。あちらのレールが担っているのは*カウンター*、つまり読んでいる間に増える「いいね」やブックマークの数であり、それが恒久的な列を正当化しています。共有レールに状態はありません。そしてアイコンだけのレールはホバーに依存しますが、タッチにホバーは存在しません。

どちらの反対も、エージェントが知らなかった事実の前に消えました。「いいね」とブックマークのボタンは後から追加する予定なので、この列はカウンターを担うことになります。そして**モバイルにはレールを出しません**。ツールチップが存在しないからこそ、モバイルには文字が描画されたラベル付きの行を出します。

*Figure — RailAndRow: 行は両方のパネルにあります。レールは余白が確保できる場合にだけ重ねられ、行にないものを持つことはありません。*

ブレークポイントは推測ではなく計測から出しました。本文ブロックは1000px固定なので、空いている余白は72remで76px、1280pxで140px、1440pxで220pxになります。目次が現れる72remでは、レールは画面の端に張り付き、ツールチップが本文の上に開いてしまいます。**レールは80remまで待たせる**ことにして、そこで本文から44px空きました。

## エンドポイントはどれも移動していた

エージェントは各エンドポイントをチュートリアルではなく公式ドキュメントに当てて確認し、それが**6つのうち5つ**で効きました。

| サービス | 多くの記事の記載 | 実際に動く形 |
| --- | --- | --- |
| X | `x.com/intent/post` | `x.com/intent/tweet`。`post`のパスは投稿画面ではなくアプリ内ログインを開く |
| 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=` | 変更なし |

このうち2つは、リンクが壊れる以上の牙を持っています。

Blueskyは`text`フィールドが1つだけで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/`は**別々の2つのエントリになり、ブックマーク数が恒久的に分かれます**。このサイトは`/blog/foo/`でビルドされますが、`astro dev`は`trailingSlash: 'ignore'`で動き、ブラウザが要求した形をそのまま返すため、開発中はこのバグが見えません。URLはレイアウトで一度だけ正規化し、すべての共有先をその1つのオブジェクトから導出します。

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

## 言語ごとの共有先

ここはZennを真似ただけでは出てこなかった部分です。

はてなブックマークは日本語の技術記事が実際に流通する場所で、他の言語圏に同等のものはありません。台湾では、英語圏ではもう見られない形でFacebookが今もリンク共有を担っています。6つのネットワークを全員に見せれば、どの読者の前にも関係のないマークが4つ並ぶことになるので、レジストリはロケールごとに一覧を持ちます。

```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`に4つ目のロケールを足してここに書き忘れたとき、`pnpm check`は緑のまま空の行が描画されます**。

英語の組み合わせにLinkedInではなくHacker Newsが入っているのは、予期しない制約に押された結果です。**simple-iconsは商標上の申し立てを受けてv14.0.0でLinkedInのマークを削除し**、復活の要望は自動でクローズされるようになりました。LinkedInのブランドポリシーが第三者によるロゴの複製を認めていないからです。マークのないピルは、単語が意味を担うラベル付きの行でなら成立します。レールはアイコンだけなので、そこに汎用のブリーフケースを置いても何も伝わりません。出してはいけないマークを出すより共有先を差し替えることを選びましたし、AstroとTypeScriptのブログなら、そもそもHacker Newsのほうが行き先として適切でした。

## 何も描画しなかったアイコン

マークは`simple-icons`から取っています。コードブロックのファイル種別アイコンのために、すでにこのリポジトリのdevDependencyに入っていたものです。エージェントは最初のパスでパスデータをハードコードし、そのあと既存のレジストリを見つけてパッケージからのimportに書き直しました。

その書き直しが、誰にも捕まらないバグを持ち込みました。このサイトの`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`は、アイコンが1つも見えていない共有行に対して**0 errors, 0 warnings, 0 hints**と報告しました。`tsc`も通り、ビルドも成功し、マークアップとしても妥当でした。**テキストノードを含む`<svg>`は正当で、ただ何も描かないだけ**です。このバグは、描画されたページをスクリーンショットに撮って眺めたときに表に出ました。

## コードブロックの背後に隠れたツールチップ

次の1つは、公開済みの記事を読んでいて自分で見つけました。ツールチップが、隣のコードブロックの*下*に開いていたのです。

原因はツリー順ではありませんでした。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)`を使っています。

## 隣のコピーボタンは英語のままだった

**2つ目のコピー操作を足したことが、1つ目が壊れていたことを表に出しました。**

これを見つけたのはエージェントで、共有機能の作業に入る前にリポジトリを確認していたときでした。Expressive Codeはすべてのコードブロックにコピーボタンを描画します。そのframesプラグインが持つ翻訳は**英語とドイツ語だけ**で、設定のどこにもページがどの言語なのかを伝えるものがありませんでした。日本語と繁体字中国語の記事は、Expressive Codeを導入した日から、そのボタンに`Copy to clipboard`と表示し、`Copied!`と答え続けていたことになります。

修正は、ファイル名からロケールを導き、サイト自身のUI辞書から文言を登録することです。2つのコピーボタンが別々の言い方に離れていかないようにするためでもあります。

```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,
```

このコメントが3つ目の不具合の記録です。エージェントの最初の版は、2つのボタンをそろえるつもりで`copyLink`の文字列を使い回しました。その結果、日本語の読者はURLではなくスニペットをコピーするボタンに「リンクをコピー」と書かれているのを見ることになります。そろえるという判断は完了表示については正しく、ツールチップについては誤りで、マージ前のコードレビューがこれを捕まえました。`addLocale`はそのロケールの文言をまるごと置き換えるので、3つのキーをすべて渡す必要もあります。ターミナル枠のタイトルを省くと、その文字列だけがすべてのターミナル枠で英語のまま残ります。

## まとめ

- **Intent URLはクリックされるまで何のコストも発生させません。** 読者の0.2〜0.5%しか触らない操作にとって、これがSDKを使わない理由になります。
- **エンドポイントは必ず公式ドキュメントで確認します。** 6つのうち5つがずれていて、広く引用されているXの`intent/post`はログイン画面を開きます。
- **適切な共有先は言語ごとに違います。** はてなブックマークに相当するものは他の言語圏になく、共通の行にすればどの読者にも使わないマークが4つ並びます。
- **型チェックが緑でも、描画されたページを見たことにはなりません。** `astro check`はアイコンが1つも見えない行に対して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のアクションインテントリンク。単一の`text`パラメータと書記素クラスタ300の上限について](https://docs.bsky.app/docs/advanced-guides/intent-links)
- [MetaのThreads Web Intentsリファレンス。`threads.net`ではなく`threads.com`](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でLinkedInのマークをv14.0.0で削除したissue](https://github.com/simple-icons/simple-icons/issues/11372)
- [共有ボタンのクリック率0.2〜0.5%の根拠となった計測](https://freshjuice.dev/blog/social-share-buttons-are-dead/)
