storybook で競プロの解説画像をまとめて GitHub Pages で公開してみた

# はじめに

AtCoder コンテストの問題を解いていると、ふと解説画像を描きたくなることがあります。2023-11-04 に ABC327-F の解説画像を描いて、X (Twitter) に投稿していました。 

![abc327-f-640.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/118172/8555cd3e-1d79-f4cf-a548-d5d97872207f.png)

画像作成後に、解説やコード例を付けて Qiita の記事を仕上げることもあります。その手間をかけず、絵を作るところまでで満足することも多かったです。そうするともう画像が目に留まることがなくなります。

記事を書かなくても、画像を簡単にまとめられる方法がないかということで、UI のカタログ集として人気の Storybook を使ってみようと思いました。

- [Storybook: Frontend workshop for UI development](https://storybook.js.org/)

これがけっこう良い感じでした。ツリーで画像を切り替えられる、MDX で問題文と解答例へのリンクを気軽に追加できるなど。

![abc327-all-640.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/118172/f1caee60-6249-fd7a-1e6b-6e3be9f7d1d5.png)

storybook を UI カタログやテストフレームワークとしては使っていません。やりたいことに対して大仰な道具を振り回している感じもしますが、試してみたという記事にしてみます。

## デモ

https://hossy3.github.io/atcoder-slides/

- ツリーで、問題に対応するページを選べます
- 各ページを開くと、以下を見られます
  - 解説画像: 1600 * 900px, 1枚以上
  - AtCoder 問題文へのリンク
  - AtCoder 解答例へのリンク、または Qiita 過去に投稿した解説記事へのリンク
- 解説画像をクリックすると拡大表示できます

## 本記事で扱うこと

- Storybook 7 + React 18 + TypeScript + Vite でプロジェクトを作成する
- Storybook でスライド画像を表示する
  - 個々のスライドに対応する Docs (MDX) を作成する
  - スライドを共通で扱う UI コンポーネントを作成し、使用する
- Storybook でその他調整
  - ツリー表示順
  - Open Graph protocol 設定
- GitHub Pages に GitHub Actions でデプロイする

ソースコードはこちらです:

- [hossy3/atcoder\-slides: 競技プログラミングの解法を図示したスライド集](https://github.com/hossy3/atcoder-slides/)

## 想定読者

- React 18 + TypeScript が分かる方
- Storybook のプロジェクト作成・公開方法を知りたい方

# プロジェクトを作成する

## 開発環境

事前に用意します。

- [Node\.js](https://nodejs.org/en) 18
- [Visual Studio Code \- Code Editing\. Redefined](https://code.visualstudio.com/)
- [GitHub Desktop \| Simple collaboration from your desktop](https://desktop.github.com/)

## Storybook インストール方法確認

Storybook 公式ドキュメントとにらめっこしながら進めていきます。多くのライブラリーなら `npm install (package)` で入りそうです。

- [Install Storybook](https://storybook.js.org/docs/react/get-started/install/)

> Use the Storybook CLI to install it in a single command. Run this inside your **existing project’s** root directory:

Storybook はそうではなく、すでに作られているプロジェクトに追加インストールの形で組み込みます。ふつうは UI コンポーネントなどを先に開発しているためでしょう。たとえば React プロジェクトと認識して、React 向けの Storybook おすすめ設定でインストールしてくれる、というのはありがたいです。

というわけで、Storybook をインストールするための仮プロジェクトを作成します。適当な UI コンポーネントを React で作成します。

このページ内の "Create React App" リンク先へ移動します。

## React インストール方法確認

- [React プロジェクトを始める – React](https://ja.react.dev/learn/start-a-new-react-project)

React 公式ページに移りました。

React のインストール方法が Next.js フレームワークなどいろいろ紹介されています。昔は create-react-app 一択でした。時代が変わっています。

さて、どのフレームワークに乗って React をインストールするか、ですが……。 今回行いたいのは、Storybook フレームワークをインストールするための準備です。「フレームワークいらない、いちばんコンパクトな構成にしたい」 というのが正直なところです。

インストール方法の下の方に「フレームワークなしで React を使うことはできますか？」 という質問があります。

> npm から `react` と `react-dom` を入手し、[Vite](https://vitejs.dev/) や [Parcel](https://parceljs.org/) のようなバンドラを使ってカスタムビルドプロセスをセットアップし、

あまりおすすめではないそうですが、Vite ではできるとのこと。リンク先へ移動します。

## Vite + React インストール

- [Vite \| Next Generation Frontend Tooling](https://vitejs.dev/)
- [Getting Started \| Vite](https://vitejs.dev/guide/)

Storybook 公式から React 公式を経て、Vite 公式サイトにたどり着きました。インストールコマンドが書かれています。

```shell
npm create vite@latest my-app -- --template react-ts
```

これこそ欲しかったものです。 :tada:

コマンドを実行すると、`react` と `react-dom` ともに 18.2 系のインストールが行われました。

テンプレートとして `react-ts` 、慣れている React + TypeScript 構成を使用しました。他にも複数のテンプレートが用意されています。

### Vite + React 動作確認

```shell
npm run dev
```

動作確認できました。

このプロジェクトはあくまで Storybook の準備用です。以降は使いません。それでも進んでいる感があります。

## Storybook インストール

```shell
npx storybook@latest init
```

今度はインストールできました。祝。

### Storybook 動作確認

```shell
npm run storybook
```
 
[Install Storybook](https://storybook.js.org/docs/react/get-started/install/) で紹介されているようなサイトが表示されました。 

# Storybook でスライド画像を表示する

## プロジェクト構成と進め方

storybook インストールまでで次のようなファイルが作られました。

```
.storybook/
  main.ts
  preview.ts
src/
  stories/
    Button.stories.ts
    Button.tsx
    Configure.mdx
    Header.stories.ts
    Header.tsx
    Page.stories.ts
    Page.tsx
    assets/
      *.png
```

これから解説画像表示用に差し替えていきます。

```
.storybook/
  main.ts
  preview.ts
src/
  stories/
    Readme.mdx
    Slide.tsx
    SlideAbc327d.mdx
    SlideAbc327e.mdx
    SlideAbc327f.mdx
    assets/
      abc327-d.png
      abc327-e.png
      abc327-f.png
```

## 各ページの MDX を作る

- [MDX](https://storybook.js.org/docs/react/writing-docs/mdx)

MDX は、Markdown を React コンポーネントを貼り付けられるよう拡張したものです。

ページ作成を MDX 形式で行います。画像を React コンポーネントで表示しつつ、リンクなどの追加情報を気軽に追加できます。

```md:SlideAbc327d.mdx
import { Meta } from "@storybook/blocks";
import { Slide } from "./Slide";
import Png from "./assets/abc327-d.png";

<Meta title="ABC327/ABC327-D" />
<Slide src={Png} layout="fullscreen" label="ABC327 D - Good Tuple Problem" />

- [D \- Good Tuple Problem](https://atcoder.jp/contests/abc327/tasks/abc327_d)
- [提出 \#47233850 \- HHKBプログラミングコンテスト2023\(AtCoder Beginner Contest 327\)](https://atcoder.jp/contests/abc327/submissions/47233850) (Rust)
```

`<Meta />` 要素がページタイトルに対応します。

`<Slide>` コンポーネントをこれから作ります。

## 画像表示用のコンポーネントを作成する

### コンポーネントで画像表示を行う

`<img>` を表示します。表示まではできます。

```tsx:Slide.tsx
interface SlideProps {
  src: string;
  label?: string;
}

export const Slide = ({ src, label }: SlideProps) => {
  return <img src={src} aria-label={label} />;
};
```

しかし残念ながら、Storybook の MDX 表では、最大横幅が 1000px 程度に制限されました。 1600 * 900 で作っている画像は小さくつぶれます。横 100% 表示や、等倍表示もしたくなります。

横 1000px というのは Storybook 自体のスタイルによるもののようです。このスタイルを外から指定することで、画像を大きく表示できるはずです。しかし、 Storybook のバージョンが上がるとスタイル指定方法が変わってしまうかもしれません。あまり依存したくありません。

### コンポーネントで画像サイズ切り替えも行う

そこで、React コンポーネント側でサイズを切り替えられるようにしました。

* 画像クリックで「初期サイズ (mode: 0)」「横幅100％ (1)」「等倍 (2)」を切り替えられる
  * 画像のスタイルを `position: fixed` にすれば、Storybook 右の `<iframe>` 範囲内については全画面表示できそう
    * 左のツリー部分は `<iframe>` の外側ですので表示できません
  * クリックできそうに見せるために、画像スタイルに `cursor: zoom-in` を付ける
* `[Enter]` `[Esc]` でも拡大縮小できる
  * 画像に `tabIndex` 属性を付けて、`onKeyDown` イベントを処理
  * Storybook のグローバルなキー処理に流れないように `ev.stopPropagation();` も

今回書いた React は、この 1つだけです。記事の本題ではないですから折りたたみます。興味のある方はどうぞ。

<details>
<summary>Slide.tsx</summary>

```tsx:Slide.tsx
import { KeyboardEventHandler, useCallback, useState } from "react";
import "./Slide.css";

interface SlideProps {
  src: string;
  label?: string;
}

export const Slide = ({ src, label }: SlideProps) => {
  const [zoom, setZoom] = useState<0 | 1 | 2>(0);
  const onKeyDown = useCallback<KeyboardEventHandler>(
    (ev) => {
      if (ev?.key === "Enter") {
        setZoom(((zoom + 1) % 3) as 0 | 1 | 2);
        ev.stopPropagation();
      } else if (ev?.key === "Escape" && zoom > 0) {
        setZoom(0);
        ev.stopPropagation();
      }
    },
    [zoom]
  );
  return (
    <>
      <img
        onClick={() => setZoom(1)}
        onKeyDown={onKeyDown}
        src={src}
        aria-label={label}
        tabIndex={0}
      />
      {zoom > 0 && (
        <div onClick={() => setZoom(0)} className="modal-overlay">
          <div className="modal-content">
            {zoom == 1 ? (
              <img
                src={src}
                aria-label={label}
                onClick={(ev) => {
                  setZoom(2);
                  ev.stopPropagation();
                }}
                onKeyDown={onKeyDown}
                tabIndex={0}
              />
            ) : (
              <img
                src={src}
                aria-label={label}
                className="zoom100"
                onKeyDown={onKeyDown}
                tabIndex={0}
              />
            )}
          </div>
        </div>
      )}
    </>
  );
};
```
</details>

<details>
<summary>Slide.css</summary>

```tsx:Slide.tsx
.modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
  z-index: 1;
  cursor: zoom-out;
}

.modal-content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  overflow: auto;
}

.modal-content img.zoom100 {
  max-width: unset;
  cursor: zoom-out;
}

img {
  cursor: zoom-in;
}
```
</details>

# Storybook でその他調整

画像が表示できましたから、ここまでで目標達成です。

ここからは短く対応できる範囲で、もう少し調整してみよう、というものです。

## ソースコード表示 (対応しない)

画像の隣にソースコードを折り畳み表示できるようにすると、見る人にとって絵のイメージと実装がより結びつき、良いのではと思いました。

Markdown のコード埋め込みで "```rust" のように言語指定できることを期待しました。しかし、試してみたところ、書式が反映されませんでした。プレーンテキスト同様の表示でした。

Storybook 7 がサポートしているのは JavaScript など一部の言語だけのようです。

- [Frequently Asked Questions](https://storybook.js.org/docs/react/faq#why-arent-my-code-blocks-highlighted-with-storybook-mdx)

> Why aren't my code blocks highlighted with Storybook MDX
> Out of the box, Storybook provides syntax highlighting for a set of languages (e.g., Javascript, Markdown, CSS, HTML, Typescript, GraphQL) you can use with your code blocks. Currently, there's a known limitation when you try and register a custom language to get syntax highlighting. We're working on a fix for this And will update this section once it's available.

今回はソースコード表示は対応せず、AtCoder 提出コードへのリンクとしました。

```md
- [提出 \#47233850 \- HHKBプログラミングコンテスト2023\(AtCoder Beginner Contest 327\)](https://atcoder.jp/contests/abc327/submissions/47233850) (Rust)
```

もし対応するなら、Storybook 6 系を使う、Gist などにコードを置いて埋め込む、といった方法があるようです。

## ツリーを並び替える

ツリーの並べ方を指定しない場合、ページ名が単純に昇順に並びます。古いコンテストの方が番号が小さく、上の方に現れます。

直近のコンテストのページが目立つよう、並び替えたいです。

|並び替え指定しない|コンテスト名で並び替え|ソート関数を書いて並び替え|
|---|---|---|
|はじめに<br/>ABC308<br/>:page_facing_up:ABC308-A<br/>:page_facing_up:ABC308-B<br/>:page_facing_up:ABC308-C<br/>:page_facing_up:ABC308-D<br/>:page_facing_up:ABC308-E<br/>:page_facing_up:ABC308-Ex<br/>:page_facing_up:ABC308-F<br/>:page_facing_up:ABC308-G<br/>ABC312<br/>:page_facing_up:ABC312-E<br/>ABC327<br/>:page_facing_up:ABC327-D<br/>:page_facing_up:ABC327-E<br/>:page_facing_up:ABC327-F|はじめに<br/>ABC327<br/>:page_facing_up:ABC327-D<br/>:page_facing_up:ABC327-E<br/>:page_facing_up:ABC327-F<br/>ABC312<br/>:page_facing_up:ABC312-E<br/>ABC308<br/>:page_facing_up:ABC308-A<br/>:page_facing_up:ABC308-B<br/>:page_facing_up:ABC308-C<br/>:page_facing_up:ABC308-D<br/>:page_facing_up:ABC308-E<br/>:page_facing_up:ABC308-Ex<br/>:page_facing_up:ABC308-F<br/>:page_facing_up:ABC308-G|はじめに<br/>ABC327<br/>:page_facing_up:ABC327-D<br/>:page_facing_up:ABC327-E<br/>:page_facing_up:ABC327-F<br/>ABC312<br/>:page_facing_up:ABC312-E<br/>ABC308<br/>:page_facing_up:ABC308-A<br/>:page_facing_up:ABC308-B<br/>:page_facing_up:ABC308-C<br/>:page_facing_up:ABC308-D<br/>:page_facing_up:ABC308-E<br/>:page_facing_up:ABC308-F<br/>:page_facing_up:ABC308-G<br/>:page_facing_up:ABC308-Ex|

### コンテスト名で並び替え

[Naming components and hierarchy](https://storybook.js.org/docs/react/writing-stories/naming-components-and-hierarchy#sorting-stories)

並び替え方法が公式に載っています。 preview.ts を次のように書きます。ルールが少なければ簡単です。

```typescript:.storybook/preview.ts
const preview: Preview = {
  parameters: {
    options: {
      storySort: {
        method: "alphabetical",
        order: [
          "はじめに",
          "ABC327",
          "ABC312",
          "ABC308",
        ],
      },
```

しかし、コンテストを追加するたびに preview.ts を更新するのは手間がかかります。もっと簡単にしたいです。

### ソート関数を書いて並び替え

`storySort` には関数を指定することもできます。"abc" で始まるストーリー名を特別扱いするような関数を次のように書けば、毎回のメンテナンスは不要になります。

```typescript:.storybook/preview.ts
storySort: (a, b) => {
  if (a.id === b.id) {
    return 0;
  }
  if (!a.id.startsWith("abc") && !b.id.startsWith("abc")) {
    return a.id.localeCompare(b.id);
  }
  if (!a.id.startsWith("abc") && b.id.startsWith("abc")) {
    return -1;
  }
  if (a.id.startsWith("abc") && !b.id.startsWith("abc")) {
    return 1;
  }
  if (a.id.substring(0, 6) === b.id.substring(0, 6)) {
    if (!a.id.includes("-ex-") && b.id.includes("-ex-")) {
      return -1;
    }
    if (a.id.includes("-ex-") && !b.id.includes("-ex-")) {
      return 1;
    }
    return a.id.localeCompare(b.id);
  }
  return -a.id.localeCompare(b.id);
},
```

ついでに Ex 問題を難易度順ということで一番最後に表示するようにしました。 [^1]

[^1]: ABC318 までで Ex はいったん終了しました。あまり頑張るところではないです。

## タイトル差し替え (たぶん行えない)

サイトを公開するなら、タイトル `<title>` を指定したいと思いました。

ブラウザのウィンドウやタブに 「ABC327 / ABC327-D - Docs ⋅ Storybook」 と表示されます。この「Storybook」 は、 Storybook を使って作られていることは分かるけれど、何をまとめた Storybook か分からないのではと。

しかし残念ながら、調べた限りではタイトルの変更方法が分かりませんでした。

- [Fluent UI React Components](https://react.fluentui.dev/?path=/docs/concepts-introduction--page)

Storybook 作成例として参考にしている Fluent UI v9 ページも タイトルは 「Concepts / Introduction - Page ⋅ Storybook」 のように Fluent UI という言葉が入っていませんでした。こういうものなのかもしれません。

## Open Graph protocol 設定

タイトルは差し替えられなくても、Open Graph protocol (OGP) は設定できます。これで十分そうです。

- SNS などに URL を貼り付けると、タイトルと画像を表示するサービスもある [^2]
- 検索エンジンが、何に対する Storybook かということを理解できる

[^2]: X (Twitter) では以前は `og:title` などがリンク先情報として表示されていましたが、2023年10月の仕様変更で除かれました。情報を設定したとしても、それをどのように表示する・しないかは、あくまで各サービス側に委ねられます

たとえば Qiita に https://hossy3.github.io/atcoder-slides/ と書くと、このように表示されます。

https://hossy3.github.io/atcoder-slides/

この OGP の設定は、`<head></head>` 内に OGP 用のタグを埋め込む形で行えます。

### Storybook での設定方法

- [Write a preset addon](https://storybook.js.org/docs/6.5/react/addons/writing-presets)

> It's also possible to programmatically modify the preview head/body HTML using a preset, similar to the way preview-head.html/preview-body.html can be used to configure story rendering. 
> (中略)
> Similarly, the managerHead can be used to modify the surrounding "manager" UI, analogous to manager-head.html.

全体のヘッダーに好きな情報を追加するには manager-head.html を使いましょう、とのことです。

- [fluentui/apps/public\-docsite\-v9/\.storybook/manager\-head\.html at d82568581b73996acbfdf78966e55572e0f869f6 · microsoft/fluentui](https://github.com/microsoft/fluentui/blob/d82568581b73996acbfdf78966e55572e0f869f6/apps/public-docsite-v9/.storybook/manager-head.html)

Fluent UI v9 の設定例を参考に、次のように書きました。

```html:.storybook/manager-head.html
<meta name="title" content="競技プログラミング お絵描き広場" />
<meta
  name="description"
  content="競技プログラミングの解法を、感覚的にイメージでとらえられるようなものを描いています。数式は控えめです。"
/>
<meta property="og:type" content="website" />
<meta property="og:url" content="https://hossy3.github.io/atcoder-slides/" />
<meta property="og:title" content="競技プログラミング お絵描き広場" />
<meta
  property="og:description"
  content="競技プログラミングの解法を、感覚的にイメージでとらえられるようなものを描いています。数式は控えめです。"
/>
<meta property="og:locale" content="ja_JP" />
<meta
  property="og:image"
  content="https://hossy3.github.io/atcoder-slides/banner-meta.png"
/>
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="670" />
<meta property="og:image:type" content="image/png" />
<meta
  property="og:image:alt"
  content="競技プログラミング お絵描き広場 バナー"
/>

<meta property="twitter:card" content="summary_large_image" />
```

Storybook では、ページごとに異なる OGP を表示するのは難しそうです。各スライド画像をそのまま `og:image` に入れたかったのですけれど。

ここまでで調整系は一区切りとします。お疲れさまでした。

# GitHub Pages に GitHub Actions でデプロイする

作成したサイトを公開します。

## ビルド

```shell
npm run build-storybook
```

Storybook インストール時に、ビルドコマンドも追加されています。

実行すると、storybook_static フォルダーにビルド成果物が置かれました。

```
.storybook/
src/
storybook_static/
  assets/
    abc*.png
    Slide*.js
  index.html
```

storybook_static の中身を、どこかの WEB サーバーに置けば、公開完了です。

## gh-pages で手動デプロイ (今回は行わない)

- [Paint\.NET の選択範囲テキストを編集する Fluent UI React v9 アプリを作ってみた \#React \- Qiita](https://qiita.com/hossie/items/918ca7043e1a6a4acb91)

以前サービスを公開したときは、更新頻度が少ないだろうと思い、gh-pages ブランチを経由して手動でデプロイしていました。同じようにも公開できるはずです。

今回はより更新しやすいように、GitHub Actions でデプロイします。

## GitHub Actions でデプロイ

[Publish Storybook](https://storybook.js.org/docs/react/sharing/publish-storybook#github-pages)

公式ページにおすすめの設定方法が書いていました。 main ブランチに変更があったときに、storybook-static/ 以下を公開するというように、と 2か所だけ書き換えました。

```yaml:.github\workflows\deploy-github-pages.yaml
# Workflow name
name: Build and Publish Storybook to GitHub Pages

on:
  # Event for the workflow to run on
  push:
    branches:
      - "main"

permissions:
  contents: read
  pages: write
  id-token: write

# List of jobs
jobs:
  deploy:
    runs-on: ubuntu-latest
    # Job steps
    steps:
      # Manual Checkout
      - uses: actions/checkout@v3

      # Set up Node
      - uses: actions/setup-node@v3
        with:
          node-version: "16.x"

      #👇 Add Storybook build and deploy to GitHub Pages as a step in the workflow
      - uses: bitovi/github-actions-storybook-to-github-pages@v1.0.1
        with:
          path: storybook-static
```

そして GitHub リポジトリ設定の GitHub Pages から、Source を GitHub Actions (Beta) にすれば、設定終了。今後の公開作業は GitHub Actions にお任せできます。

# 最後に

Storybook を使って画像をまとめ、GitHub Pages で公開するところまで行いました。お疲れ様でした。

システムも本記事が書けるくらいには一区切りしたところで、また不定期にスライドを描いていきます。今後ともよろしくお願いします。