もともとHugoというブログシステムを使っていて、その上でCoderというテーマを使っています。以前のブログでDisqusというコメントシステムを導入しましたが、利用しなくなりました。Blueskyをコメントサービスとして導入しようと思ったのは、「Your data is yours」という思想に共感したからです。記録として実装内容を残します。
ずっと二の足を踏んでいた理由は、フロントエンドが全くわからないからです。また、Hugoのブログシステムの仕組みも全く理解していませんでした。正直、フロントエンドの知識はjQueryやGulpで止まっています。HTMLとCSSもお手上げです。また、Blueskyの仕組みもよくわかっていませんでした。
Blueskyのコメントシステムを参考にできるブログが2つあることと、HugoにJavaScriptを組み込むGitHubリポジトリがあったので、それを参考にしました。Claude Codeの力を借りてこのコメントサービスを作りました。Claude Codeは非常に強力です。たまに失敗もしますが、大きな恩恵を受けています。
Blueskyコメントシステムの仕組み 見出しへのリンク
参考にしたのは、次の2つのブログでした。Powering your Hugo blog with Bluesky Comments と Building Bluesky Comments for My Blog です。app.bsky.feed.getPostThread を見ればわかるのですが、このエンドポイントにリクエストを投げて、再帰的にコメントを読み込むだけです。認証とかは特に必要ないです。前者はhugoとライブラリも何も使わずに自前でやっていて、後者の方は Next とReact と mary-ext/atcute という Typescript のライブラリを使っています。
Hugo の js buildの仕組み 見出しへのリンク
Hugoには、evanw/esbuildを使ったjs.Buildを使ってビルドできます。
gohugoio/hugoTestProjectJSModImports というサイトがあり、簡単な like を react で実装されていました。 ただ、古くて5年前の実装でした。module が利用できていませんでした。
<head>
<script
src="https://unpkg.com/react@18/umd/react.production.min.js"
crossorigin></script>
<script
src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"
crossorigin></script>
</head>
<body>
<div id="like_button_container"></div>
{{ $shims := dict }}
{{ $defines := dict }}
{{ $shims = dict "react" "js/shims/react.js" "react-dom/client" "js/shims/react-dom.js" }}
{{ $js := resources.Get "js/like.jsx" | js.Build (dict "shims" $shims "defines" $defines ) }}
<script src="{{ $js.RelPermalink }}"></script>
</body>
方針 見出しへのリンク
基本的にはライブラリは使いたくありませんでした。ただし、テンプレートエンジンと型システムは欲しかったです。looptype というサイトを別件で作っているのですが、Claudeに任せて生のJavaScriptと生のHTMLを使って大変だったので、この選択にしました。以下のような構成になりました。
- ImportMapを使ってbundleをしないこと
- TypeScriptをJavascriptにtranspileすること
- Reactを使うこと
実装 見出しへのリンク
前提条件 見出しへのリンク
- Hugoブログが既に動作していること
- Coderテーマを使用していること
- Blueskyアカウントを持っていること
ステップ1: Bluesky投稿の準備 見出しへのリンク
ブログ記事に対応するBluesky投稿を作成し、DIDとCIDを取得します。
ステップ2: TypeScript開発環境のセットアップ 見出しへのリンク
tsconfig.jsonの設定例:
{
"compilerOptions": {
"jsx": "react-jsx",
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["react", "react-dom"] /* Include React type definitions */
},
"include": ["tsx/**/*"],
"exclude": ["node_modules"]
}
ステップ3: Hugoパーシャルテンプレートの作成 見出しへのリンク
layouts/partials/posts/bluesky.html を作成します。
{{- if and (isset .Params "bluesky_did") (isset .Params "bluesky_cid") -}}
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18",
"react-dom/client": "https://esm.sh/react-dom@18/client",
"react/jsx-runtime": "https://esm.sh/react@18/jsx-runtime",
"@atproto/api": "https://esm.sh/@atproto/api@0.18"
}
}
</script>
<div id="bluesky_comment_container"
data-did="{{ .Params.bluesky_did }}"
data-cid="{{ .Params.bluesky_cid }}">
</div>
<script type="module" src="/js/blueskycomment.js"></script>
{{- end -}}
ステップ4: TypeScriptファイルの作成 見出しへのリンク
assets/ts/blueskycomment.tsx を作成します。
主な構成要素:
- AtpAgentの初期化
- スレッド取得関数
- Reactコンポーネント(BlueskyComments)
- renderReply関数(再帰的なコメント表示)
AtpAgentの初期化 見出しへのリンク
mary-ext/atcute は利用せずに、official の @atproto/api を利用した。
import { AtpAgent } from "@atproto/api";
const agent = new AtpAgent({ service: "https://public.api.bsky.app" });
スレッド取得の設定 見出しへのリンク
const uri = `at://${did}/app.bsky.feed.post/${postCid}`;
await agent.getPostThread({
uri: uri,
depth: 5, // コメントの深さ (最大10)
parentHeight: 0, // 親投稿の取得数
});
Reactコンポーネントの初期化 見出しへのリンク
const container = document.querySelector("#bluesky_comment_container");
if (container && did && postCid) {
const root = ReactDOM.createRoot(container);
root.render(
<BlueskyComments did={did} postCid={postCid} skipFirst={true} />
);
}
Reply 見出しへのリンク
const renderReply = (reply: AppBskyFeedDefs.ThreadViewPost, depth: number = 0) => {
const { post } = reply;
const record = post.record as { text: string; createdAt: string };
return (
<div key={post.cid}>
{/* ユーザー情報 */}
<div>
<img
src={post.author.avatar}
alt={post.author.handle}
/>
<div>
<strong>{post.author.displayName || post.author.handle}</strong>
<div>
@{post.author.handle} · {new Date(record.createdAt).toLocaleDateString()}
</div>
</div>
</div>
{/* コメント本文 */}
<div>
{record.text}
</div>
{/* ネストした返信 */}
{reply.replies?.map((r) => {
if (r.$type === "app.bsky.feed.defs#threadViewPost") {
return renderReply(r as AppBskyFeedDefs.ThreadViewPost, depth + 1);
}
return null;
})}
</div>
);
};
ステップ5: TypeScriptのコンパイル 見出しへのリンク
# TypeScriptをJavaScriptにコンパイル
npx tsc
ステップ6: テーマテンプレートへの組み込み 見出しへのリンク
Coderテーマの投稿テンプレートを編集します。
``layouts/posts/single.html` に追加:
<article>
{{ .Content }}
<!-- Blueskyコメントの表示 -->
{{ partial "posts/bluesky.html" . }}
</article>
ステップ7: 記事のフロントマターに設定を追加 見出しへのリンク
コメントを表示したい記事の先頭に以下を追加:
---
title: "記事タイトル"
date: 2024-12-14
bluesky_did: "did:plc:あなたのDID"
bluesky_cid: "投稿のCID"
---
ハマったポイント・トラブルシューティング 見出しへのリンク
typescript の build 見出しへのリンク
TypeScriptのビルドではまりました。これは自分の理解不足が原因でもあります。Hugoのjs.Buildを使おうとしましたが、うまくいかなかったので、結局TypeScriptコンパイラ(tsc)でビルドすることにして、それを静的ファイルとして配置しました。TypeScriptコンパイラとesbuildでは正常に動作しましたが、なぜかHugoのjs.Buildから呼び出すとエラーになってしまったため、tscでのビルドに切り替えました。結局ローカルでビルドすることになったので、バンドルした方が良かったかもしれませんが、最初の設計のままにしました。
Tsxをトランスパイルしないやつ
esm.sh/tsxで、トランスパイルしないものもありましたが、HTMLに直接書かないと動かないようなので諦めました。下の実装は、公式サイトのままのやつです。
<!DOCTYPE html>
<html>
<head>
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@19.2.0",
"react-dom/client": "https://esm.sh/react-dom@19.2.0/client"
}
}
</script>
<script type="module" src="https://esm.sh/tsx"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
import { createRoot } from "react-dom/client"
createRoot(root).render(<h1>Hello, World!</h1>)
</script>
</body>
</html>
html と CSS の実装 見出しへのリンク
HTMLとCSSは全くわからなかったので、Claudeに任せました。
感想 見出しへのリンク
今回の実装ではReactを採用しましたが、実装後に Useful patterns for building HTML tools や 「FastAPI + htmxが最強説」- AIエンジニアがモック作るならReactは不要、Streamlitも捨てよう といった記事を読み、考えさせられました。小さなプロジェクトは、シンプルさを保つことが大切だと改めて感じました。 Claude code もガンガン活用したほうがいい良いです。