← ブログに戻る

YAMLを1枚書くと企業サイト風の年表ページになるOSS「historymap」を公開した

小さなアプリ・ツール・ゲームを週次で公開する「weekly ship」シリーズを始めました。第1号は historymap。YAMLを1枚書くと、企業サイト風の製品ヒストリー年表ページを生成するOSSです。

historymapで生成した年表。中央の破線軸の左右に年号と書影が交互に並ぶ

デモの中身は私の技術書12冊の出版履歴です。産業機器メーカーのサイトによくある、中央に縦軸が通って年号が左右交互に並ぶあの形式を、そのまま自分のデータで再現できます。

表は積み重ねを語らない

私は本を出すたびにサイトの書籍一覧を更新していますが、一覧はあくまで「表」です。表は検索には向いていても、「この本の3ヶ月後にこの本が出た」という時間の流れは見えません。積み重ねは、年表の形をしていないと伝わりません。それが作った動機です。

使い方は3ステップにしました。

  1. リポジトリをfork(またはUse this template)
  2. data.yaml を自分のデータに書き換える
  3. GitHub Pagesを有効化(Source: GitHub Actions)して push
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/

datetitle の2項目だけでも動きます。出版履歴のほか、OSSのリリース史、キャリア年表、チームのプロジェクト史あたりは書き換えるだけで入ります。

生成物は self-containedなHTML 1ファイルです。CSSもJSもインラインで、外部CDN参照はゼロ。ビルドはNode 20+、依存パッケージは js-yaml の1個だけにしました。1ファイルに畳んであれば、GitHub Pagesでもレンタルサーバーでも置くだけで動きます。

左右交互レイアウトはflexboxで足りる

「ジグザグ配置」と聞くと面倒そうですが、芯は flex-direction の切り替えだけです。

.item--left  { flex-direction: row; }
.item--right { flex-direction: row-reverse; }

奇数番目と偶数番目でこのクラスを振り分ければ、テキストと画像の位置が入れ替わります。中央の縦軸も .timeline::before に破線ボーダーを1本引くだけで、画像は使っていません。

細部で考えたのは3点です。書影の丸抜きは定石どおり border-radius: 50% ですが、縦長の表紙を object-fit: cover に通すと上下が切れるので、白背景の円に contain で収める方式にしました。年ラベルは最初、年だけの表示にしていたら「2026」が11連続する画面になったので(毎月本を出すとこうなります)、月精度の日付は 2026.03 形式に変えました。モバイルは640px以下で軸を左端に寄せて単列に畳みます。ジグザグは幅があってこそなので、狭い画面では維持しません。

iframeの高さはResizeObserver + postMessage

このツールの本命はiframe埋め込みです。生成した年表を、自分のブログやポートフォリオに1行で足せるのがゴールです。ところがiframeには古典的な問題があります。中身の高さが親から見えないことです。年表は縦に伸びるコンテンツなので、height="600" のような固定値では必ずスクロールバーか余白が出ます。

解決は昔から変わらず、子から親への高さ通知です。生成ページ側に ResizeObserver を仕込み、高さが変わるたびに postMessage で親へ送ります。画像の遅延読み込みで後から高さが伸びても追従できます。親側は同梱の embed.js がメッセージを受けて該当iframeの高さを更新します。

<iframe data-historymap src="https://your-name.github.io/historymap/" style="width:100%;border:0"></iframe>
<script src="embed.js"></script>

実装のポイントは event.source でiframeを特定することです。URLで探す実装だと、同じページを2箇所に埋め込んだときに壊れます。contentWindowevent.source の一致で見れば、複数埋め込みでも正しいiframeだけが伸びます。受信した高さも Number.isFinite チェックと上限クランプを通してから適用しています。

入力はテンプレ配布物の攻撃面なので、入口で落とす

data.yamlはテンプレートとして配る以上、他人が書く入力です。href・<style>・ファイルパスに流れ込む値をそのまま信用はできません。そこで出力時のエスケープに頼らず、バリデーション段階でallowlistに合わない値をビルドエラーにする方針にしました。

  • link: http / https / mailto / tel 以外のスキーム(javascript: 等)はエラー
  • theme の色: hex形式以外はエラー。fontは英数と , . ' " - だけの文字allowlist
  • image: 絶対パスを拒否し、解決結果がdata.yamlのあるディレクトリの外に出たらエラー

静的サイトジェネレータには、ビルドを失敗させるという一番単純な安全弁があります。おかしな入力は画面に出さず、その場で止める。この方針だと「不正な値をどう安全に出力するか」を考えずに済むので、設計が小さいまま保てます。

配信は3パターン

生成物が1ファイルなので、置き方は好みで選べます。

  1. GitHub Pagesをそのまま使う: forkしてpushすれば https://<you>.github.io/historymap/ が生えます
  2. iframeで自分のサイトに埋め込む: 先ほどの data-historymap 付きiframe + embed.js
  3. 自分のドメインのパス配下で配信する: 冒頭のライブデモがこの方式です。このサイトはCloudflare Workers配信なので、小さなWorkerを立てて kenimoto.dev/products/historymap/* にRouteを1本張りました

レイアウトはv1では zigzag の1種類ですが、レンダラーはレジストリ方式で分離してあります。系譜図や路線図のような別レイアウトを、同じdata.yamlに足せる構造です。

weekly shipのリリースはプロダクト一覧に、静的化・独立ドメイン昇格・アーカイブの遍歴ごと積んでいきます。まずは手元の時系列データを3件ほど data.yaml に入れて眺めてみてください。