産業機器メーカーのサイトには、たまに「製品ヒストリー」というページがあります。中央に縦の軸が通り、年号が左右交互に並び、丸く切り抜かれた製品写真が歴代モデルを飾る。1975年のシーケンスコントローラから最新のIoT対応機まで、50年が1本の軸に載っている。あれです。
先日あの形式の年表を眺めていて、ふと思いました。私は技術書を出すたびにサイトの一覧ページを更新していますが、一覧は「表」です。表は検索には向いていても、積み重ねは語らない。「この本の3ヶ月後にこの本が出た」という時間の流れは、年表の形をしていないと見えないんです。
というわけで作りました。YAMLを1枚書くと、あの企業サイト風の年表ページが生成されるOSSです。デモは私の出版履歴ですが、中身は製品史でもキャリアでも、時系列なら何でも入ります。
作ったもの: historymap
デモは私の技術書12冊の出版履歴です。data.yaml にこう書くと、
title: "Ken Imoto — Tech Books History"
lang: ja
layout: zigzag
theme:
preset: navy-mono
items:
- id: claude-code-mastery
date: 2025-09-01
title: "実践Claude Code"
description: "Claude Code を1年以上、実務で使い込んだ。"
image: https://example.com/images/cover.png
link: https://example.com/books/claude-code-mastery/
中央軸+左右交互+丸抜き画像の年表になって出てきます。使い方は3ステップです。
- リポジトリをfork(またはUse this template)
-
data.yamlを自分のデータに書き換える - GitHub Pagesを有効化(Source: GitHub Actions)→ pushで自動デプロイ
生成物は self-containedなHTML 1ファイル に全部入れました。CSSもJSもインラインで、外部CDNへの参照はゼロです。1ファイルに畳んであれば、GitHub Pagesでもレンタルサーバーでも置くだけで動きます。ビルドはNode 20+で、依存パッケージは js-yaml の1個だけです。なお image はURLとローカル相対パスの両方を取れます。ローカルパスの場合はビルド時に dist/ へコピーされます。
テーマは preset: navy-mono のほかに、hex色指定で個別上書きができます。レイアウトはv1では zigzag の1種類です。ただしレンダラーはレジストリ方式で分離してあります。系譜図や路線図のような別レイアウトを、同じdata.yamlに足せる構造です。
zigzagレイアウトはflexboxで意外と簡単
「左右交互」と聞くと面倒そうですが、芯は flex-direction の切り替えだけです。
.item--left { flex-direction: row; }
.item--right { flex-direction: row-reverse; }
奇数番目と偶数番目でこのクラスを振り分ければ、テキストと画像の位置が交互に入れ替わります。中央の縦軸は .timeline::before に破線ボーダーを1本引くだけで、画像は使っていません。
こだわったのは3点です。
書影を切らない。 丸抜きは border-radius: 50% + overflow: hidden が定石です。ただ、本の表紙は縦長なので object-fit: cover だと上下が切れます。object-fit: contain にして、白背景の円の中に収める方式にしました。
年ラベルに月を入れる。 最初は年だけを大きく表示していたら、「2026」が11連続する間抜けな画面になりました。毎月本を出しているとこうなります。月精度がある日付は 2026.03 形式で表示するよう変えました。
モバイルは単列に畳む。 640px以下では軸を左端に寄せて、全アイテムを右側に並べ直します。ジグザグは幅があってこそなので、狭い画面で無理に維持しません。
iframeの高さ問題はResizeObserver + postMessageで解決
このツールの本命はiframe埋め込みです。生成したページを自分のブログやポートフォリオサイトに1行で埋め込めるようにしたい。ところがiframeには古典的な問題があります。中身の高さが親から見えないことです。
年表は縦に伸びるコンテンツです。height="600" のような固定値では、必ずスクロールバーが出るか下に空白が余ります。解決策は昔から変わらず、子から親への高さ通知です。iframeの高さ問題が話題になるたびに同じ答えを書いている気がしますが、変わらないものは変わりません。
生成ページ側には ResizeObserver を仕込みます。高さが変わるたびに postMessage で親へ通知する仕掛けです。画像の遅延読み込みで後から高さが伸びても追従できます。
親ページ側は同梱の embed.js がメッセージを受け取り、該当するiframeの高さを更新します。ポイントは event.source でiframeを特定することです。URLで探す実装だと、同じページを2箇所に埋め込んだときに壊れます。contentWindow と event.source の一致で見れば、複数埋め込みでも正しいiframeだけが伸びます。
<iframe data-historymap src="https://your-name.github.io/historymap/" style="width:100%;border:0"></iframe>
<script src="embed.js"></script>
埋め込む側に書くのはこれだけです。embed.js はリポジトリに同梱してあるので、埋め込む側のサイトにコピーして置いてください。
生成したページをサイトに載せる3パターン
配信の形は3つ用意しました。どれも生成物が1ファイルなので追加のビルド設定はありません。
-
GitHub Pagesをそのまま使う: forkしてpushすれば
https://<you>.github.io/historymap/が生えます。ポートフォリオのリンク先としてはこれで完成です -
iframeで自分のサイトに埋め込む: 先ほどの
data-historymap付きiframe + embed.js。ブログの自己紹介ページなどに1行で足せます -
サイトのパス配下で直接配信する: 冒頭のライブデモがこの方式です。kenimoto.devはCloudflare Workers配信なので、
historymapという小さなWorkerを立ててkenimoto.dev/products/historymap/*のRouteを1本張りました。自分のドメインの下に置きたい場合はこの形が一番きれいです
入力はテンプレ配布物の攻撃面なので、入口で落とす
data.yamlはテンプレートとして配る以上、他人が書く入力です。href・<style>・ファイルパスに流れ込む値をそのまま信用はできません。そこで出力時のエスケープに頼らず、バリデーション段階でallowlistに合わない値をビルドエラーにする方針にしました。
-
link: http / https / mailto / tel 以外のスキーム(javascript:等)はエラー -
themeの色: hex形式(#rrggbb等)以外はエラー。fontは英数と, . ' " -だけの文字allowlist -
image: 絶対パスを拒否し、path.resolveの結果がdata.yamlのあるディレクトリの外に出たらエラー
静的サイトジェネレータには、ビルドを失敗させるという一番単純な安全弁があります。おかしな入力は画面に出さず、その場で止める。この方針だと「不正な値をどう安全に出力するか」を考えずに済むので、設計が小さいままで保てます。embed.js側でも、受信した高さは Number.isFinite チェックと上限クランプを通してから適用しています。
まとめ
- 企業サイト風の年表は、flexboxの
row-reverse交互 + 中央破線1本で再現できる - iframe埋め込みの高さ問題は、ResizeObserver + postMessage +
event.source照合が今も一番素直 - テンプレとして配るツールの入力検証は、エスケープよりも「入口のallowlist + ビルド失敗」が単純
出版履歴に限らず、OSSのリリース史・キャリア年表・チームのプロジェクト史あたりにそのまま使えます。date と title の2項目だけでも動くので、まずは手元の時系列データを3件ほど入れて眺めてみてください。皆さんなら何の年表にしますか。
📘 デモの年表に並んでいる技術書12冊は、kenimoto.dev の書籍一覧から読めます。『実践Claude Code』からRTX 4070のローカルLLM本まで、全冊このヒストリーの実データです。
