自分のnote記事が885本たまった。積み上げた手応えはある。けれど読み返す手段がnoteのタイムライン1本しかない。スクロールで遡り、下に下に落ちていく感覚は、書き手から見ても重い。 そこで、885本を1冊の本として開けるサイトを作りました。PCで開くと見開き2ページ。クリックで紙がめくれる。スマホは1ページずつ縦にスクロールする。全部Astro + Cloudflare Pagesで動いており、Claude Codeに設計から実装まで走らせています。
きっかけは単純で、自分のアーカイブを自分で読み返したくなったからです。noteの一覧画面は新着順で流れていき、2年前の記事は物理的に遠い。検索は効くけれど、偶然の出会いが起きない。 本棚に並んだ背表紙を眺めて、気分でぱらぱら開く。あの体験を自分の書き物に対してやりたいと思いました。 もう1つの理由は、noteのアルゴリズムに依存しない読者導線を1本持っておきたかったことです。SNSのタイムラインもnoteの新着も、過去記事を拾いに行く設計ではない。自分のドメイン配下に、885本を横断できるインターフェースを置く意味は大きい。 作り始める前に決めたのは3つ。
構成はできるだけ薄く作っています。
note/{年}/{日付_タイトル}/index.md をそのまま置く/ が目次、/y/{年} が年別、/a/{guid} が記事詳細./publish.shを叩くだけで反映される。
記事のURLはタイトル文字列ではなく、フォルダ名から生成したハッシュ(nlocal + MD5先頭12桁)にしています。タイトルを後から直しても外部URLが壊れないようにするためです。一番悩んだのは、Markdown本文を「見開き2ページ + ページめくり可能」な形に落とす方法です。
最初に試したのは、本文をセクションごとに分割してページ単位のDOMを作る方法。これはMarkdownのパース結果に手を入れる必要があり、崩れやすいと判断して捨てました。
最終的に採用したのはCSS columnsで本文を縦長の帯(テープ)にして、その帯をtransform: translate3dで横にスライドさせる設計です。
.tape {
position: absolute;
column-width: var(--col-width);
column-gap: var(--col-gap);
column-fill: auto;
transform: translate3d(var(--tx, 0), 0, 0);
}
ページ幅と列幅をJSで計算し、「今何ページ目を見せるか」を--txの値だけで切り替える。DOMは1セットで、中身を動かすのではなく「覗く窓」を動かす発想です。
左ページ用と右ページ用に同じ本文のコピーを用意し、それぞれ別のオフセットを与えれば見開きになる。ブラウザがcolumnsのレイアウトをやってくれるので、こちらは座標計算だけ気にすればよい。
総ページ数の計測は、いったん帯を全部描画してから、末尾要素の位置がどの列に属するかを測ります。
probe.querySelectorAll<HTMLElement>('*').forEach((el) => {
const r = el.getBoundingClientRect();
const rightInTape = r.right - tapeRect.left;
const col = Math.floor((rightInTape - 1) / pageWidth);
if (col > maxCol) maxCol = col;
});
pageCount = Math.max(1, maxCol + 1);
この方法の利点は、本文構造を一切壊さないこと。Markdownから生成したHTMLをそのまま流し込めば、columnsが勝手に複数ページに割ってくれる。コードブロックや画像にはbreak-inside: avoidを付けて、途中で切れないようにしています。
「めくれる本」と言ったからには、クリックした瞬間に紙がひらりと回転してほしい。Canvas製の高級ライブラリ(Turn.jsなど)もありますが、885ページ級の静的サイトに載せるには重すぎる。
採用したのは、「絶対配置のシート1枚をrotateYでパタンと倒すだけ」の実装です。
.flip-sheet.side-right {
left: 50%;
transform-origin: left center;
transform: rotateY(0deg);
}
.flip-sheet.side-right.turning {
transform: rotateY(-178deg);
}
仕組みはシンプル。
rotateYで-178度倒す。裏面はbackface-visibility: hiddenで見えたり消えたりするスマホで見開きをエミュレートしても、文字が小さくて読めないだけです。デザイン4原則より先に、物理的な横幅が足りない。 そこでモバイル(820px以下)では、本の構造を全部捨てて普通の縦スクロールに切り替えます。
@media (max-width: 820px) {
.page { position: static; width: auto; }
.page.right, .flip-sheet, .click-zone { display: none; }
.tape {
column-count: 1 !important;
transform: none;
padding: var(--page-pad-y) var(--page-pad-x) 4rem;
}
}
CSS的にはcolumnsを1カラムに戻し、transformを消すだけ。JS側もisMobile()で分岐してクリックゾーンを殺しています。
同じコードベースで、PC = 見開きブック、スマホ = 連続スクロールという2つの読書体験を両立できます。
AstroにはdefineCollectionという仕組みがあり、独自のローダーを書けます。これを使って「{年}/{フォルダ}/index.mdを全部読み込む」だけのシンプルなローダーを書きました。
function articlesLoader(): Loader {
return {
name: 'local-articles',
async load({ store, parseData, renderMarkdown }) {
const years = ['2022', '2023', '2024', '2025', '2026'];
for (const year of years) {
const dirs = await readdir(path.join(root, year), { withFileTypes: true });
for (const d of dirs) {
const raw = await readFile(path.join(yearDir, d.name, 'index.md'), 'utf-8');
const { data, content } = matter(raw);
const rendered = await renderMarkdown(content);
store.set({ id: `${year}/${d.name}`, data, rendered });
}
}
},
};
}
記事ごとに別々のfrontmatterを書き、本文はMarkdownで。新しい記事フォルダを置いて./publish.shを叩けば、それだけで目次・年別・個別ページが全部生成されます。
画像パスはビルド時に書き換えていて、assets/xxx.webpという相対参照を/notes/{年}/{フォルダ}/assets/xxx.webpという絶対パスに差し替えてから配信しています。記事フォルダはそのままディストリビューションにもコピーされるので、画像リンクが切れない。
885本あると、目次を眺めるだけでは目的の記事に辿り着けません。かといって検索サーバーを立てるのは過剰。
やったのは、全記事のタイトル+日付+guidだけを<script type="application/json">に埋め込んで、JSで絞り込むだけのクライアント側検索です。
const hits = data.filter((a) => norm(a.t).includes(nq));
タイトルベースで十分ヒットします。全文検索はCloudflare Workersを足せば実現できますが、885本規模ではオーバースペック。JSONサイズも数十KBで済むので、初回ロードにも響きません。
既存の画像は大半がPNGでした。そのままだとCloudflare Pagesの転送量を無駄に食うので、Python側でバッチ変換しています。
subprocess.run(["cwebp", "-q", "85", str(src), "-o", str(dst)])
変換後、オリジナルは~/.Trash/note-originals-{timestamp}/に退避。index.md内の画像参照も同時に.png → .webpに書き換え。
WebP統一後、画像総容量は体感で1/3〜1/4になりました。885記事のサイト全体が、Cloudflareの無料枠で余裕を持って回る。
ここまで書いた実装を、自分でゴリゴリ書いたかというと、ほぼ書いていません。Claude Codeに「noteの過去記事を本のようにめくって読めるサイトを作る」とだけ渡し、Astroの採用・ディレクトリ構成・CSS columnsの採用・3Dめくりの実装・Cloudflare Pagesへのデプロイまで、全部相談しながら詰めました。 特に効いたのは、CLAUDE.md(プロジェクト固有ルール)に「Markdown構造を壊さない」「記事追加時は自動インデックス」「スマホは縦スクロール」という制約を最初に書いておいたことです。Claude Codeはこの制約を守る前提でコードを出してくるので、手戻りが極端に少なかった。 人間がやったのは以下だけです。
公開したばかりの状態ですが、改善余地は見えています。