【VitePress】ADR（Architecture Decision Record）をMarkdownで管理する

# やること

- VitePressを使ってADR管理サイトを構築する
- カスタムテーマを使って見やすいドキュメントサイトを作る
  - メタデータの表示機能を実装する
  - キーワードタグによる検索機能を実装する

# はじめに

最近、プロジェクトのアーキテクチャ決定を記録するためにADR（Architecture Decision Record）を導入することにしました。でも、ただのMarkdownファイルをGitHubで管理するだけだと、

- GitHubにいない人（他チーム、自チームの企画担当や営業担当など）へ共有しづらい
- **あんまり楽しくない**（！？）

と思って、VitePressを使ってドキュメントサイトを構築してみることにしました。

# 前提

## ADRとは

https://docs.aws.amazon.com/prescriptive-guidance/latest/architectural-decision-records/adr-process.html

ADRは、アーキテクチャに関する重要な決定事項を記録するためのドキュメントです。以下のような情報を含みます。

- ステータス（提案済み・承諾済み・拒否済み・新しいADRによって置き換えられた状態）
- 決定事項
- コンテキスト
- 理由

プロジェクト初期は、この後関わる全員が揃っているわけではありません。後から参画するプロジェクトメンバーにとっては、初期の決定になればなるほどその背景がわからなくなってしまいます。ADRはある意味、そういった「決定の経緯」を理解するための助けになると考えています。

## VitePressとは

https://vitepress.dev/

VitePressは、Vue.jsベースの静的サイトジェネレーター（SSG）です。MarkdownファイルをHTMLに変換し、かっちょいいドキュメントサイトを生成します。

- 高速なビルド（Viteベース）
- Markdownの拡張機能
- Vueコンポーネントによってカスタマイズ可能
- 検索機能のサポート
- TypeScriptのサポート

実は、前述したVitepressの公式サイト自体もVitepressベースで作られています。要は、そういう雰囲気のサイトを作れるんだってことですね。

# 作ってみる

https://github.com/Enokisan/eno-adr

## ディレクトリ構成

今回作ったプロジェクトのディレクトリツリーは以下の通りです。

```
eno-adr
├── docs/ # ドキュメントのルートディレクトリ
│ ├── .vitepress/ # VitePressの設定ファイル
│ │ ├── config.mjs # メインの設定ファイル
│ │ └── theme/ # カスタムテーマのVueファイル置き場
│ ├── pages/ # 各ページのMarkdownファイル置き場
│ │ ├── tags.md # タグ検索ページ
│ │ └── adr/ # adr置き場
│ ├── scripts/ # スクリプト
│ └── index.md # トップページ
├── package.json
├── .gitignore
└── README.md
```

[VitePress公式のクイックスタートガイド](https://vitepress.dev/guide/getting-started)からわかる通り、`docs`ディレクトリがVitePressで生成されるサイトのプロジェクトルートとして機能します。各mdファイルは、htmlファイルへコンパイルされるようになっており、

- docs/index.md → /（ルートパス）
- docs/pages/adr/0001.md → /pages/adr/0001（/pages/adr/0001.html）

のようにルーティングされます。

## VitePressの設定

VitePressの設定ファイル（`.vitepress/config.mjs`）で、以下のような機能を実装しています。

### サイトの基本情報

```javascript
// docs/.vitepress/config.mjs
import { defineConfig } from 'vitepress'

export default defineConfig({
  title: "eno-adr",
  description: "A sample site for adr",
  // ...
})
```

タイトルと概要を記述できます

### ナビゲーションバー&サイドバー

```javascript
// docs/.vitepress/config.mjs
import { defineConfig } from 'vitepress'
import getAdrList from '../scripts/getAdrList.js'

export default defineConfig({
  // ..
  themeConfig: {
    nav: [
      { text: 'Home', link: '/' },
      { text: 'ADR List', link: '/pages/adr/' },
      { text: 'Tag Search', link: '/pages/tag/' }
    ],

    sidebar: [
      {
        text: 'ADRを探す',
        items: [
          { text: 'すべてのADR', link: '/pages/adr/' },
          { text: 'タグで探す', link: '/pages/tag/' }
        ],
      },
      {
        text: 'ADR一覧',
        items: [
          ...getAdrList()
        ]
      }
    ],

    socialLinks: [
      { icon: 'github', link: 'https://github.com/Enokisan/eno-adr' }
    ]
  },
```

ナビゲーションバーやサイドバーの情報はここで記載できます。`getAdrList()`は自作の関数で、ADRについて書かれているファイルだけを抽出して一覧化します。

```javascript
// docs/scripts/getAdrList.js
import path from 'path'
import fs from 'fs'

export default function getAdrList() {
    const adrDir = path.join(__dirname, '../pages/adr')
    const files = fs.readdirSync(adrDir)

    const adrList = files
        .filter(file => file.endsWith('.md') && file != 'index.md')
        .sort()
        .map(file => {
            const content = fs.readFileSync(path.join(adrDir, file), 'utf-8')
            const adrIdMatch = content.match(/adr-id:\s*["']?(\w+)["']?/)
            const titleMatch = content.match(/title:\s*["'](.+?)["']/)
            const statusMatch = content.match(/status:\s*["']?(\w+)["']?/)
            const tagsMatch = content.match(/tags:\s*\[(.*?)\]/)

            const adrId = adrIdMatch ? adrIdMatch[1] : null
            const title = titleMatch ? titleMatch[1] : null

            return {
                text: adrId ? adrId + "_" + title: title,
                link: `/pages/adr/${file.replace('.md', '')}`,
                adrId: adrId,
                title: title,
                status: statusMatch ? statusMatch[1] : null,
                tags: tagsMatch ? tagsMatch[1].split(',').map(tag => tag.trim().replace(/['"]/g, '')) : []
            }
        })

    return adrList
}
```

ここでナビゲーションとサイドバーを記載することで、以下のような表示になります。

![スクリーンショット 2025-04-26 13.09.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2518330/9456684f-cda7-4394-9050-5385bee2d4bb.png)

### transformPageData

https://vitepress.dev/reference/site-config#transformpagedata

各ページのデータを`transformPageData`を使うことで変更することができます。
今回は、各markdownのfrontmatterにデータを突っ込んで、ADR一覧やタグ一覧などの情報を扱うために使っています。

```javascript
// docs/.vitepress/config.mjs
import { defineConfig } from 'vitepress'
import transformPageData from '../scripts/transformPageData.js'

export default defineConfig({
  // ...

  transformPageData
})

```

```javascript
// docs/scripts/getAdrList.js
import getAdrList from "./getAdrList"
import getAllTags from "./getTags"

export default async function transformPageData(pageData) {

    if (pageData.relativePath === 'pages/adr/index.md') {
        // すべてのADRページの場合
        pageData.frontmatter.adrs = getAdrList()
    } else if (pageData.relativePath === 'pages/tag.md') {
        // タグでさがすページの場合
        pageData.frontmatter.adrs = getAdrList()
        pageData.frontmatter.tags = getAllTags()
    }

    return pageData
}
```

## ADRを書く

### ADR Markdownの記載方法

ADRは以下のような形式で記述するルールにしました。

```markdown
---
adr-id: 001
title: "Qiitaの扱い"
layout: doc
status: accepted
date: 2025-04-26
author: "Enokisan"
tags: ["社外発信", "ブログ", "勉強会", "ナレッジ", "コーディング"]
---

<!-- docs/.vitepress/pages/adr/001-qiita.md -->

# ADR-001: Qiitaの扱い

<AdrMetadata />

## 決定事項

- 勉強会の内容は積極的に社外発信すること

## コンテキスト

- 勉強会がやりっぱなしで終わっちゃっていた

## 理由

- その方が良くない？
```

内容は適当ですが、上記の通り、

- 決定事項
- コンテキスト
- 理由

の3点をMarkdownで記載します。大事なのは、決まったことはここにすぐ全部書いていくことです。Markdownなので、とっても書きやすいですよね。

### メタ情報表示コンポーネント

Markdownの中に書いたfrontmatter（メタ情報）は、特に何もしなければ生成されたサイト上には表示されません。それではなんか勿体無いですよね。

Vitepressの強みである「Vueによるコンポーネント挿入」の機能を使って、このお悩みを解決しちゃいましょう。

```vuejs
<!-- docs/.vitepress/theme/component/AdrMetadata.vue -->
<template>
    <div class="adr-metadata">
        <div class="metadata-item" v-if="frontmatter.status">
            <span class="label">ステータス:</span>
            <span class="value">{{ frontmatter.status }} ({{ adrCreatedDate }})</span>
        </div>
        <div class="metadata-item" v-if="frontmatter.author">
            <span class="label">作成者:</span>
            <span class="value">{{ frontmatter.author }}</span>
        </div>
        <div class="metadata-item" v-if="frontmatter.tags && frontmatter.tags.length">
            <span class="label">タグ:</span>
            <span class="value">
                <span class="tag" v-for="tag in frontmatter.tags" :key="tag">{{ tag }}</span>
            </span>
        </div>
    </div>
</template>

<script setup>
import { useData } from 'vitepress';
import { computed } from 'vue';

const { frontmatter } = useData()
const adrCreatedDate = computed(() => {
    if (!frontmatter.value.date) return ''
    return new Date(frontmatter.value.date).toLocaleDateString('ja-JP', {
        year: 'numeric',
        month: 'long',
        day: 'numeric'
    })
})
</script>
```

https://vitepress.dev/guide/extending-default-theme#registering-global-components

コンポーネントを作ることができたら、そのコンポーネントを利用可能になるように登録しましょう。

```javascript
// docs/.vitepress/theme/index.js
import DefaultTheme from 'vitepress/theme'
import AdrMetadata from './component/AdrMetadata.vue'

export default {
    extends: DefaultTheme,
    enhanceApp({ app }) {
        app.component('AdrMetadata', AdrMetadata)
    }
}
```

ここまでやることで、各ADRのページにはイケてるメタ情報コンポーネントが表示されるようになります。（CSSは自分で書いてくださいね）

![スクリーンショット 2025-04-26 13.24.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2518330/98250a90-bf95-48c2-9bc7-ee8190ebd74f.png)

## 検索機能の実装

https://vitepress.dev/reference/default-theme-search

検索機能についてはVitepressに標準搭載されているのもあります。が、せっかくなので、ADRにそれぞれつけたタグで検索できるようにしたいのでカスタマイズ実装しました。

### ADR一覧をとってくる

```javascript
// docs/scripts/getAdrList.js
import path from 'path'
import fs from 'fs'

export default function getAdrList() {
    const adrDir = path.join(__dirname, '../pages/adr')
    const files = fs.readdirSync(adrDir)

    const adrList = files
        .filter(file => file.endsWith('.md') && file != 'index.md')
        .sort()
        .map(file => {
            const content = fs.readFileSync(path.join(adrDir, file), 'utf-8')
            const adrIdMatch = content.match(/adr-id:\s*["']?(\w+)["']?/)
            const titleMatch = content.match(/title:\s*["'](.+?)["']/)
            const statusMatch = content.match(/status:\s*["']?(\w+)["']?/)
            const tagsMatch = content.match(/tags:\s*\[(.*?)\]/)

            const adrId = adrIdMatch ? adrIdMatch[1] : null
            const title = titleMatch ? titleMatch[1] : null

            return {
                text: adrId ? adrId + "_" + title: title,
                link: `/pages/adr/${file.replace('.md', '')}`,
                adrId: adrId,
                title: title,
                status: statusMatch ? statusMatch[1] : null,
                tags: tagsMatch ? tagsMatch[1].split(',').map(tag => tag.trim().replace(/['"]/g, '')) : []
            }
        })

    return adrList
}
```

後ほど反省するのですが、ADRが書かれたファイルを探しながら、frontmatterの要素をゴリ押しでとります。

### 検索候補になるタグあつめ

```javascript
// docs/scripts/getTags.js
import path from 'path'
import fs from 'fs'
import matter from 'gray-matter'

export default function getAllTags() {
    const pageDir = path.join(__dirname, '../pages')
    const tags = new Set()

    const processDirectory = (dir) => {
        fs.readdirSync(dir).forEach(file => {
            const filePath = path.join(dir, file)
            const stat = fs.statSync(filePath)

            if (stat.isDirectory()) {
                processDirectory(filePath)
            } else if (file.endsWith('.md')) {
                const content = fs.readFileSync(filePath, 'utf-8')
                const { data } = matter(content)

                if (data.tags && Array.isArray(data.tags)) {
                    data.tags.forEach(tag => tags.add(tag))
                }
            }
        })
    }

    processDirectory(pageDir)
    return Array.from(tags).sort()
}
```

これを実装している時に`gray-matter`というfrontmatter解析用のライブラリを見つけました。これ使えば、ほかのfrontmatter属性の情報も取り回しやすそうやな。（一個前のセクションの実装について反省）

### ADR一覧からタグで絞る

```vuejs
<!-- docs/.vitepress/theme/component/Tag.vue -->
<template>
    <div class="tags-page">
        <div class="tags-list" v-if="tags.length">
            <button
                v-for="tag in tags"
                :key="tag"
                :class="{ active: selectedTag === tag }"
                @click="changeSelectedTag(tag)">{{ tag }}
            </button>
        </div>
        <div v-else class="loading">
            タグを読み込み中
        </div>
        <div class="filtered-adrs">
            <h2 v-if="selectedTag">{{ selectedTag }}に関連するADR</h2>
            <h2 v-else>すべてのADR</h2>
            <ul v-if="adrList.length">
                <li v-for="adr in filteredAdrs" :key="adr.link">
                    <a :href="adr.link">{{ adr.text }}</a>
                </li>
            </ul>
            <p v-else-if="tags.length && selectedTag">
                選択したタグに一致するADRが見つかりませんでした
            </p>
        </div>
    </div>
</template>

<script setup>
import { useData } from 'vitepress'
import { computed, ref } from 'vue';

const { frontmatter } = useData()
const selectedTag = ref('')

const adrList = computed(() => frontmatter.value.adrs || [])
const tags = computed(() => frontmatter.value.tags || [])

const filteredAdrs = computed(() => {
    if (!selectedTag.value) return adrList.value
    return adrList.value.filter(adr => {
        return adr.tags?.includes(selectedTag.value)
    })
})

function changeSelectedTag(tag) {
    selectedTag.value = tag === selectedTag.value ? '' : tag
}

</script>
```

タグ検索ページに埋め込むコンポーネントを上記のように作ってあげます。

そうすると・・・

![スクリーンショット 2025-04-26 13.34.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2518330/01d6b1fe-79e9-48ae-b518-f7649e74e93e.png)

![スクリーンショット 2025-04-26 13.35.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2518330/2c555440-48ef-4d8d-ab20-e9c1719f20ee.png)

できた！

# まとめ

わーい！VitePressを使うことで、見やすいADR管理サイトが構築できました！

# 参考

https://vitepress.dev/

https://github.com/Enokisan/eno-adr