← Back to Blog

historymap: one YAML file becomes a corporate-style product-history timeline

I’m starting a “weekly ship” series: small apps, tools, and games, released once a week. Ship number one is historymap, an OSS tool that turns a single YAML file into a corporate-style product-history timeline page.

A timeline generated by historymap: years and book covers alternate left and right around a central dashed axis

The demo shows the publication history of my twelve tech books. It reproduces the format you see on industrial manufacturers’ websites: a vertical axis down the middle, years alternating left and right, product photos cropped into circles.

Tables don’t show accumulation

Every time I publish a book I update the books page on this site, but that page is a table. Tables are good for lookup and useless for showing momentum. “This book came out three months after that one” only becomes visible when the data takes the shape of a timeline. That’s the whole motivation.

Setup is three steps:

  1. Fork the repository (or hit Use this template)
  2. Rewrite data.yaml with your own data
  3. Enable GitHub Pages (Source: GitHub Actions) and push
title: "Ken Imoto — Tech Books History"
lang: en
layout: zigzag
theme:
  preset: navy-mono
items:
  - id: claude-code-mastery
    date: 2025-09-01
    title: "Claude Code in Practice"
    description: "A year of Claude Code in production."
    image: https://example.com/images/cover.png
    link: https://example.com/books/claude-code-mastery/

Two fields, date and title, are enough to render. Publication histories work, and so do OSS release histories, career timelines, and team project chronicles.

The output is a self-contained single HTML file. CSS and JS are inlined, with zero external CDN references. The build needs Node 20+ and exactly one dependency, js-yaml. Because everything folds into one file, it runs anywhere you can drop a file: GitHub Pages, a cheap shared host, wherever.

The zigzag layout is just flexbox

“Alternating left and right” sounds fiddly, but the core is a flex-direction switch:

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

Assign the classes to odd and even items and the text and image swap sides. The central axis is a single dashed border on .timeline::before, no images involved.

Three details took actual thought. Circular crops are the usual border-radius: 50%, but book covers are tall, and object-fit: cover chops off the top and bottom, so I switched to contain inside a white circle. Year labels started out as years only, which gave me a screen with “2026” printed eleven times in a row (publish a book every month and this is what you get), so dates with month precision now render as 2026.03. And below 640px the axis moves to the left edge and everything collapses to a single column. Zigzag only earns its keep when there’s width to zigzag in.

iframe height: ResizeObserver + postMessage

The real point of this tool is iframe embedding. I wanted the generated timeline to drop into a blog or portfolio with one line. But iframes have a classic problem: the parent can’t see the height of the content. A timeline grows vertically, so a fixed height="600" guarantees either a scrollbar or dead space.

The fix hasn’t changed in years: the child reports its height to the parent. The generated page carries a ResizeObserver and sends postMessage every time its height changes, so it keeps up even when lazy-loaded images stretch the page later. On the parent side, the bundled embed.js receives the message and updates the matching iframe.

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

The detail that matters: identify the iframe by event.source. Implementations that match by URL break as soon as the same page is embedded twice. Comparing contentWindow against event.source means only the right iframe grows, no matter how many embeds share the page. Received heights also pass a Number.isFinite check and an upper clamp before being applied.

Input is the attack surface of a template, so reject it at the door

Since data.yaml ships as a template, it’s input written by strangers. Values that flow into href attributes, <style> blocks, and file paths don’t deserve trust. Rather than leaning on output escaping, the validator fails the build for anything outside an allowlist:

  • link: any scheme other than http / https / mailto / tel (javascript: included) is an error
  • theme colors: anything that isn’t hex format is an error; fonts pass a character allowlist of alphanumerics plus , . ' " -
  • image: absolute paths are rejected, and if the resolved path escapes the directory containing data.yaml, that’s an error too

Static site generators have the simplest safety valve there is: fail the build. Bad input never reaches a screen; it stops right there. This spares me the whole question of “how do we render malicious values safely,” and the design stays small.

Three ways to serve it

Since the output is one file, pick whichever suits you:

  1. Plain GitHub Pages: fork, push, and https://<you>.github.io/historymap/ appears
  2. Embed via iframe: the data-historymap iframe plus embed.js from above
  3. Serve under your own domain: the live demo linked at the top works this way. This site is served by Cloudflare Workers, so I stood up a tiny Worker and added one Route for kenimoto.dev/products/historymap/*

v1 ships with a single layout, zigzag, but the renderer sits behind a registry. Family trees, transit-map styles, and other layouts can be added against the same data.yaml.

Every weekly ship release lands on the products page, along with its whole life story: static freezes, promotions to standalone domains, and retirements to the archive. Try putting three entries from your own history into data.yaml and see how it reads.