タグ
黒いカードにピンクの炎を伴う白のAstroロケットロゴとロゴタイプ

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

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

目次

はじめに

私が読む技術ブログは、どれも記事の末尾に共有ボタンの行を置いています。このブログには何もなく、個人サイトに部品が足りていない状態ではなく、完成した製品として読まれてほしいと思っていました。素直な解決策は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万人全員に請求します。

共有 SDK は全読者に、Intent URL はクリックした読者だけに課金するクリック前のページ読み込み時点で 2 つの列を比較する。公式の共有 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: automax-height: calc(100vh - var(--nav-height))で組まれているので、見出しが30個ある記事ではリストが枠を埋め尽くします。その下に固定したものは、手の届かないところへ流れていくか、固定した先のリストを削るかのどちらかになります。

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

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

ラベル付きの共有行はすべての幅で出る。アイコンレールは 80rem 以上でだけ加わる2 つのパネルが並ぶ。80rem 未満の狭いパネルには本文の 1 カラムと、その下にラベル付きの共有行があり、ポインタなし・JavaScript なしでも動くと注記されている。80rem 以上の広いパネルには同じ本文カラムと、左の余白に本文から 44px 離れた細いアイコンレールが加わり、ラベル付きの共有行は変わらず本文の下にある。レールは近道、行は常に存在する80rem 未満本文ポインタなし・JavaScript なしでも動く80rem 以上本文本文から 44px 離す記事末尾のラベル付きの行アイコンのレール、ホバーとフォーカスでツールチップ
行は両方のパネルにあります。レールは余白が確保できる場合にだけ重ねられ、行にないものを持つことはありません。

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

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

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

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

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

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

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

言語ごとの共有先

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

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

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の書き方をそのまま持ち込みました。

<!-- 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に勝ちます。レールにも正の値が必要で、しかも固定ヘッダーより下に収まる値でなければなりませんでした。

共有レールはコードブロックより上、固定ヘッダーより下に置くz-index の値で 5 つの層が積み上がり、上ほど手前にある。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)を使っています。

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

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

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

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

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がマークを削除したという事実が、出せないという合図です。

参考リンク

この記事をシェア