技術書を紹介するサイトをNext.jsの静的書き出しで作った。エディタ風の画面と本のデータの持ち方
本記事にはアフィリエイト広告(PR)を含みます。詳しくは広告・アフィリエイトについてをご確認ください。
このサイトは、読んだ本や使っている道具を「依存しているもの」として並べる、というアイデアから始まりました。サイト名の deps は dependencies(依存関係)の略です。
package.json に依存パッケージを書くように本を並べるなら、画面もエディタに寄せたほうが筋が通ります。そう考えて、ファイルツリー、タブ、ステータスバーのある今の形になりました。この記事では、その作り方と、途中でつまずいたところを書いておきます。
構成
サーバーもデータベースも持たない、静的なサイトにしています。
- Next.js 15(App Router)を
output: "export"で静的HTMLに書き出す - 書き出したファイルを Cloudflare Pages で配信する
- 記事は MDX で書き、一覧やタイトルなどの情報は TypeScript の配列で持つ
- 見た目は CSS Modules と BEM の命名で書く
記事を1本足すときは、MDXのファイルを置いて、配列に1件足すだけで済みます。
本のデータは自前で持つ
本の書名や出版社は、Amazonの商品情報APIからは取っていません。このAPIは、直近30日で一定の件数が売れていないと使えない条件があります。始めたばかりのサイトでは満たせないので、書名・著者・出版社・版・発売年月を TypeScript の配列に自分で書いています。
書誌は国立国会図書館サーチで確かめてから書きます(使い方と利用条件は「国立国会図書館サーチのAPIで本の書誌を確かめる」に書きました)。国立国会図書館の書誌では発売日が年月までしか分からない本が多いので、サイトでも発売は「2022年4月」のように年月までにそろえました。出版社の発表で発売日が分かる本は、そちらを優先しています。国立国会図書館の出版年月は奥付の日付に近いようで、店頭に並ぶ発売日より1か月ほど後になっている本がありました。
価格は持っていません。すぐ変わるので、書いた瞬間から古くなるからです。
紙の本の ASIN は ISBN-10
Amazonの商品ページのURLには ASIN(Amazonの商品番号)が要ります。紙の本の ASIN は、その本の ISBN-10 と同じ番号になっています。なので、本ごとに ASIN を調べて書かなくても、ISBN-13 から計算で出せます。
/**
* ISBN-13(978始まり)からISBN-10を求める。紙の本のAmazonのASINはISBN-10と同じ。
* 979始まりのISBN-13にはISBN-10がないため null を返す。
*/
export const toIsbn10 = (isbn13: string) => {
if (!/^978\d{10}$/.test(isbn13)) return null;
const body = isbn13.slice(3, 12);
const sum = [...body].reduce((acc, digit, index) => acc + Number(digit) * (10 - index), 0);
const check = (11 - (sum % 11)) % 11;
return `${body}${check === 10 ? "X" : check}`;
};
ISBN-13 の先頭の「978」と最後の1桁を外した9桁に、10から2までの重みを掛けて足します。11で割った余りからチェックディジット(検査用の最後の1桁)を出し、10になったときは「X」にします。11分の1で出る計算なのに、登録している13冊のうち4冊は末尾が X になりました。X を数字として扱う処理を書いていたら、この4冊のリンクが壊れていたことになります。
979 で始まる ISBN-13 には、対応する ISBN-10 がありません。その本では、Amazon の紙の本へのリンクを出さないようにしています。逆向きの変換やチェックディジットの検証は、「ISBN-13とISBN-10を相互に変換する」にまとめました。
古い版で読んだ本には、新しい版を案内する
技術書は改訂版がよく出ます。自分が読んだのが古い版でも、記事では読んだ版のことを書きたいです。一方で、これから買う人には新しい版を案内したいです。
そこで、本のデータに「読んだ版より新しい版」の情報を持たせました。
newerEdition: { isbn13: "9784297146221", edition: "改訂新版", publishedAt: "2024-12" },
これがある本は、本のカードの下に新しい版があることを書き、新しい版へのリンクを並べます。
エディタ風の画面
タブは本当に開いて、閉じられる
見た目だけのタブだと、押しても何も起きなくてがっかりします。なので、ページを開くとタブが増え、× で閉じられるようにしました。
開いているタブの一覧は、ブラウザの sessionStorage に保存しています。ページを移動してもタブが残り、ブラウザのタブを閉じればリセットされます。エディタの作業状態に近い持ち方です。
保存した一覧はブラウザでしか読めません。そのため、静的HTMLには今のページのタブ1枚だけを書き出し、ブラウザで読み込んでから残りのタブを足しています。
表示中のタブを閉じると、右隣のタブ(なければ左隣)に移ります。最後の1枚を閉じたときは、VS Code の何も開いていない画面のような専用のページに移します。ここは検索結果に出す意味がないので、noindex にしました。コードを含めた詳しい作り方は、「静的サイトにエディタのようなタブを作る」に書いています。
ファイルツリーは details 要素で開閉する
フォルダの開閉は、HTML標準の details と summary に任せました。JavaScript で開閉を作らなくても、クリックでもキーボードでも開け閉めできます。
JavaScript でやっているのは、開閉の状態を保存して、ページを移動したあとに戻すことだけです。閉じたフォルダの中の記事を開いたときは、そのフォルダだけ開いて見せます。ただし、一番上の階層は対象から外しました。どのページも一番上の階層の中にあるので、対象にすると閉じても毎回開き直してしまうからです。詳しくは「details要素でファイルツリーを作る」に書きました。
スマホではファイルツリーを引き出す
スマホの幅では、左にファイルツリーを常に出しておく余裕がありません。そこで、ヘッダー右上の Explorer ボタンで、右から引き出す形にしました。
ボタンと引き出しは画面の離れた場所にあるので、開いているかどうかの状態は React の外に小さなストアとして持ち、useSyncExternalStore で両方から読んでいます。引き出しは、ページを移動したとき、暗くなった背景をタップしたとき、Esc キーを押したときに閉じます。開いている間は、後ろの本文がスクロールしないようにしています。
最初はボタンを画面下のステータスバーに置いていましたが、見つけにくかったのでヘッダー右上に移しました。
package.json はサイトマップにする
/deps のページは、テキストエディタで package.json を開いたように、行番号を付けて表示しています。中身はこのサイトのサイトマップです。
package.json には、パッケージの外から使える入口を並べる exports という項目があります。これをサイトの入口の一覧に見立てました。記事は1本ずつ並べず、"./books/*": "./books/*.mdx" のようにジャンルごとにワイルドカードでまとめています。Node.js の package.json でも実際に使える書き方です。version には、最後に記事を更新した日を入れています。
dependencies には、サイト名の由来になった、運営者が頼っているものを並べています。npm のスコープ付きパッケージのように、本は "@book/リーダブルコード": "^2012.06" の形にしました。道具を紹介するようになったら、別のスコープを足して並べる予定です。
VS Code と同じように、{ で始まるブロックは行番号の横の矢印で折りたためます。折りたたんだブロックは {…} の1行にまとまり、行番号は元の番号のまま飛びます。
つまずいたところ
開発サーバーを動かしたまま next build を実行したら、開発サーバーのほうが壊れました。どちらも .next フォルダを使うので、ビルドが中身を上書きしてしまいます。ビルドは開発サーバーを止めてから実行するようにしました。
等幅フォントは、最初は next/font/google でビルド時に取得していました。ところが、ビルドする環境のネットワーク制限でフォントの取得が止まり、ビルドが終わらなくなりました。フォントファイル(JetBrains Mono、OFLライセンス)をリポジトリに入れて next/font/local で読み込む形にし、ビルドがネットワークに頼らないようにしました。手順は「JetBrains Monoを同梱してnext/font/localで読み込んだ」にあります。
package.json のページは、最初は /package.json というURLにしようとしました。ただ、拡張子の付いたURLだと、配信側が静的HTMLとして返してくれない恐れがあります。URLは /deps にして、タブとファイルツリーに出す名前だけを package.json にしています。