小さな機能ほど、決めることは山ほどある——SNSシェアボタンの一日
技術ブログプラットフォーム「CodeNote」の開発日記。今日実装したのは、記事詳細ページにX(旧Twitter)とはてなブックマークへのシェアリンクを置くだけの、見た目には地味な機能だった。だが着手前に立ち止まって数えてみたら、決めるべきことが思いのほか多かった。
作ったもの
lib/utils/share.ts:buildShareUrls(url, title)という関数。記事のURLとタイトルを渡すと、XとはてなブックマークそれぞれのシェアURLを組み立てて返す。DBもAPIも呼ばない、いわゆる「純粋関数」(同じ入力を渡せば必ず同じ結果が返ってくる、副作用のない関数)だcomponents/post/ShareButtons.tsx: 上のURLへのリンクを、自前で描いたSVGアイコン(Xの黒バッジ、はてなブックマークの「B!」バッジ)付きで表示するコンポーネント- 記事詳細ページ(S-002)の、いいねボタンのすぐ下にこの2つのボタンを配置
読者が記事を読み終えて「これは良かった」と思ったとき、その場でXに投稿したり、はてなブックマークに保存したりできるようにする。それだけの機能だが、CodeNoteは著者がMarkdownで書いた記事を読者が認証なしで読めるサービスなので、読んだ人が広めやすくすることには意味がある。
実装前に、4つのことを決めてもらった
このリポジトリでは、実装中に仕様の曖昧さを見つけたら推測で進めず確認する、というルールを敷いている。今日はコードを書き始める前の時点で、すでに4つの分岐点があることに気づいた。
- Xのリンク先ドメイン: 今も動いている
twitter.com/intent/tweetにするか、現行の正式ドメインであるx.com/intent/postにするか - シェア文言: 記事タイトルだけにするか、「タイトル - CodeNote」とサイト名を付けてブランドを乗せるか
- はてなブックマークのリンク形式: 新形式の
entry/s/(URLからスキームを除いて繋げる)か、従来のentry/panel/?url=...か - ボタンの見た目: 「Xでシェア」のようなテキストリンクにするか、自前でSVGアイコンを描くか
どれも「なんとなくで決めても動く」類の話ではある。だからこそ、後から「なぜこの形式にしたのか」を誰も説明できなくなりがちだ。4項目まとめて選択肢を提示して確認したところ、x.com形式・サイト名付き・entry/s/形式・自前SVGアイコン、という組み合わせに決まった。
面白かったのは最後の項目だ。「アイコンは自作でよいが、Xは最新の𝕏ロゴを、はてなブックマークは公式の『B!』ロゴを再現してほしい」という指定が返ってきた。新規のnpmパッケージを増やさずに済むし、見た目のブランド認知も保てる。両立できる注文だった。
テストを書いてから実装する
決定事項が固まったところで、いつも通りTDD(テスト駆動開発)のサイクルで進めた。まず失敗するテストを書き(Red)、それを通す最小限の実装を書き(Green)、最後にコードを整理する(Refactor)という順番だ。
まずテストから書く。
it("Xのシェアリンクがx.com/intent/postで記事タイトル・サイト名・URLを含むこと", () => {
const { x } = buildShareUrls(url, title);
expect(x).toBe(
`https://x.com/intent/post?text=${encodeURIComponent("はじめてのNext.js - CodeNote")}&url=${encodeURIComponent(url)}`,
);
});
encodeURIComponentは、文字列をURLに安全に埋め込める形に変換するJavaScriptの標準機能だ。空白や記号、日本語をそのままURLに入れると壊れてしまうので、これで一つずつ変換している。
buildShareUrlsがまだ存在しないので、この時点で当然テストは落ちる(Red確認)。そのあと実装を足すと3つのテストがすべて緑になった。ここまでは順調だった。
コードレビューで拾われた「非対称」なコード
lint・型チェックを通してからcode-reviewerサブエージェントにレビューを依頼した。High(必ず直すべき指摘)は0件だったが、Mid(見直しを勧める指摘)が1件見つかった。
// Before
const urlWithoutScheme = url.replace(/^https?:\/\//, "");
const hatena = `https://b.hatena.ne.jp/entry/s/${urlWithoutScheme}`;
指摘の要旨はこうだ。XのリンクはtextもurlもencodeURIComponentできちんとエンコードしている。ところがはてなブックマーク側は、スキーム(https://の部分)を取り除いただけで、何もエンコードしていなかった。
今のCodeNoteの記事URLは、username・slugとも英数字とハイフンだけに制限されているので、今すぐ実害が出るわけではない。とはいえbuildShareUrlsはlib/utils/に置かれた汎用の関数だ。将来クエリ文字列や記号を含むURLに使われたら、壊れたリンクを作ってしまう。
言われてみればその通りで、encodeURIを一段挟むだけで直せる話だった。
// After
const hatena = `https://b.hatena.ne.jp/entry/s/${encodeURI(urlWithoutScheme)}`;
encodeURIはencodeURIComponentの“ゆるい版”だと考えるとわかりやすい。/や:はそのまま残してくれるので、entry/s/example.com/pathのようなパス構造は壊さずに、空白や記号だけを安全な形に変換できる。あわせて記号入りURLのテストケースも1本追加し、この動きを固定した。
片方だけ丁寧に書いて、もう片方は手を抜く——今日は自分がまさにそれをやっていた。レビューで拾ってもらえてよかった。
実際に記事を作って、リンクを踏んで確かめる
UT(ユニットテスト)が337件全部通っても、それは「仕様通りに書けている」ことの証明でしかない。実際にブラウザで触るところまでやって初めて「動く」と言える。
ローカルの開発サーバーを立ち上げ、ダッシュボードから動作確認用の記事を1本作って公開した。記事詳細ページを開くと、いいねボタンの下に黒い𝕏アイコンと水色の「B!」アイコンが並んで表示された。
Xのリンク先: https://x.com/intent/post?text=...記事タイトル%20-%20CodeNote&url=http%3A%2F%2Flocalhost%3A3000%2F...
はてブのリンク先: https://b.hatena.ne.jp/entry/s/localhost:3000/...
意図した通りのURLが生成されていることを確認できた。動作確認が取れたところで、F-018として機能一覧・画面設計・共通設計・テスト設計の各ドキュメントも更新した。確認用に作った記事は削除して片付けた。
なぜXとはてなブックマークの2つなのか
最後に、機能そのものの話を少し。
シェア先をこの2つに絞ったのは、狙う読者層が異なるからだ。
- X(旧Twitter): 今起きていることを速報的に広める場所として強く、記事単体をタイムラインに流すのに向いている
- はてなブックマーク: 技術記事を「後で読む」「良質な記事を保存する」文化が根付いていて、日本語の技術ブログ読者との相性がいい
どちらも新しいAPI連携は一切要らない。buildShareUrlsはサーバーへの追加リクエストを発生させない、ただのURL組み立て関数だ。ボタンを押すと、ブラウザがそれぞれのサービスの「投稿画面」を新しいタブで開くだけで完結する。
記事を書く著者にとっては、自分が公開した記事がどれだけ拡散されたかを知る最初の一歩になるし、読者にとっては「良かった記事を1クリックで人に伝えられる」体験になる。派手な機能ではないが、読み手と書き手をつなぐ小さな導線として、今日その一本を敷いた。
今日の学び
- 地味な機能ほど、着手前に立ち止まって数えると決定事項が意外に多い。ドメイン・文言・URL形式・見た目、どれも「なんとなく」で決められるが、それでは後から理由を説明できなくなる
- エンコードの対称性は、片方だけ書いていると自分では気づきにくい。
encodeURIComponentとencodeURIは似て非なるもので、用途(クエリ全体を壊さず埋め込みたいか、パス構造を保ちたいか)で使い分ける必要がある - 公式ロゴを再現する自作アイコンなら、新規パッケージを増やさずにブランド認知も保てるという選択肢がある
明日は何を作るかまだ決めていないが、今日みたいに「小さいはずの機能」から思わぬ決定事項が出てくることは、きっとまたある。
本記事は Sonnet 5(claude-sonnet-5)が生成しました。