Skip to content
On this page

📆 2023-03-21

ブログサイトを VitePress へ移行した

#VitePress #PageSpeed Insights

はじめに

Hugo で作ったブログサイト であったが、しっくり来る Hugo のテーマがなかなか見つからなかった。

色々探していたのだが、hugo-theme-stack というテーマの ドキュメント が見やすかったので、何で作ってあるのか調べたところ VitePress という静的サイトジェネレーターだった。

https://vitepress.dev/

Vue にはあまり詳しくないし、JavaScript フレームワークが入ることでサイトが重くなる懸念があったが、デザインの良さに惹かれて試してみることにした。

試してみると、それほどサイトも重くならなさそうだし、ブログサイトにカスタマイズするのもなんとかできそうなので Hugo から VitePress に移行することにした。

VitePress のインストール

Getting Started を見ながらインストールした。

ディレクトリを掘って移動して

shell
$ mkdir vitepress
$ cd vitepress

VitePress のインストール

shell
$ npm install -D vitepress

サイトの初期化

shell
$ npx vitepress init

初期化で色々聞かれるので入力していく

初期化で入力しているところ

ファイルとディレクトリができている

shell
vitepress
├── .vitepress
   └── config.ts
├── api-examples.md
├── index.md
├── markdown-examples.md
├── node_modules
└── package.json

.vitepress/config.ts が VitePress の設定ファイル

*.md はサンプルなので消しておく。

ディレクトリ構成

いろいろ試したが、最終、次のような構成に落ち着いた。

shell
vitepress
├── content      ... ページは content 以下にまとめる
   ├── about    ... 紹介ページ
   ├── index.md ... トップページ
   ├── public   ... この下に favicon などの静的リソースを置く
   ├── tags     ... Tag ページ
   ├── posts/index.md ... 記事一覧ページ
   └── posts/YYYY/MMDD/{page_name}/index.md ... ブログ記事を置く
├── .vitepress
├── node_modules
└── package.json

トップページのカスタマイズ

VitePress のデフォルトテーマのページレイアウトは3種類ある。

  • home がトップページ用
  • doc がドキュメント用(何も指定しない場合はこれ)
  • page がCSSのあたっていないページ用

home をカスタマイズして作ろうとしたが、レイアウトがガチガチに固められているので、doc をカスタマイズして作ることにした。

トップページはこちら

markdown
---
theme: page
title: バックエンドエンジニアのメモ
next: false
prev: false
---

<script setup>
import { data as posts } from '../.vitepress/theme/posts.data.js'
import moment from 'moment';
</script>

# nshmura.com

バックエンドエンジニアのメモ


<article v-for="post of posts" class="home-posts-article">
  <p>
    <a :href="post.url" class="home-posts-article-title">{{ post.frontmatter.title }}</a>
  </p>
  <p>{{ post.frontmatter.description }}</p>
  <p>
    <a :href="post.url">続きを読む</a>
  </p>
</article>

next, prev はフッターのナビゲーションを隠すために false に設定している。

トップページで読み込んでいる posts.data.js は記事の一覧を読み込むための JavaScript

js
import { createContentLoader } from 'vitepress'

export default createContentLoader('content/posts/**/*.md', {
    includeSrc: false,
    transform(rawData) {
        return rawData
            .filter(page => page.url != "/content/posts/index")
            .sort((a, b) => {
                // date DESC
                return +new Date(b.frontmatter.date) - +new Date(a.frontmatter.date)
            })
            .map(page => {
                page.relativePath = page.url.replace(/^\/content\/posts\/[0-9]+\/[0-9]+\//g, 'posts/') + ".md";
                page.url = page.url.replace(/^\/content\/posts\/[0-9]+\/[0-9]+\//g, '/posts/').replace(/index$/g, '');
                return page;
            })
      }
})

トップページに当てている CSS はこちら

css
.home-posts-article {
    border-top: 1px solid var(--vp-c-divider);
    justify-content: space-between;
    padding: 10px 0;
}

.home-posts-article p {
    margin: 10px 0;
}

.home-posts-article .home-posts-article-title {
    color: var(--vp-c-text-1);
    font-size: 20px;
    font-weight: 700;
    line-height: 1.5;
    text-decoration: none !important;
}

.vitepress/theme/index.js を作って、上記の CSS ファイルを読み込む必要がある。

js
import { h } from 'vue'
import DefaultTheme from 'vitepress/theme'
import PostTitle from './PostTitle.vue'
import PostFooter from './PostFooter.vue'
import { useData } from 'vitepress'
import './custom.css'

.vitepress/config.js でサイドメニューなどを設定しておく。

js
import { defineConfig } from 'vitepress'

export default defineConfig({
  title: "nshmura.com",
  description: "バックエンドエンジニアのメモ",
  lang: "ja",
  cleanUrls: true,
  srcDir: './content',
  themeConfig: {
    nav: [
      { text: 'Home', link: '/' },
      { text: 'About', link: '/about/' }
    ],

    sidebar: [
      {
        items: [
          { text: 'Home', link: '/' },
          { text: 'About', link: '/about/' },
          { text: 'Posts', link: '/posts/' },
          { text: 'Tags', link: '/tags/' }
        ]
      }
    ],

    socialLinks: [
      { icon: 'github', link: 'https://github.com/nshmura' }
    ],

    footer: {
      message: 'Released under the MIT License.',
      copyright: 'Copyright © present nshmura'
    }
  }
})

出来上がったトップページ トップページ

記事一覧ページの作成

記事一覧ページは簡単で、トップページでも使った posts.data.js で記事一覧を読み込んで表示するだけ。

markdown
---
theme: page
title: Posts
next: false
prev: false
---

<script setup>
import { data as posts } from '../../.vitepress/theme/posts.data.js'
</script>

# Posts

<ul>
  <li v-for="post of posts">
    <a :href="post.url">{{ post.frontmatter.title }}</a>
  </li>
</ul>

出来上がった記事一覧ページ 記事一覧ページ

Tag ページの作成

VitePress の Dynamic Routes 機能を使ってビルド時にタグごとのページを生成した。

Tag ページの構成はこんな感じ

shell
content
└── tags
    ├── [tag]
       ├── index.md         ... Tag 詳細のページ
       └── index.paths.js   ... Tag 詳細のページを生成するための JavaScript
    └── index.md             ... Tag 一覧ページ

Dynamic Routes 機能では、[tag] のようなプレースホルダを入れたディレクトリがあると、そのディレクトリの下にある index.paths.jspaths loader file として使われる。

paths loader file は、 [tag] に入れる値の一覧を返す JavaScript で、VitePress がその値を読み込んでページを自動生成することになる。

index.paths.js は下のようにした。ページのタグ一覧をうまく取ることができなくて、無理やりタグ一覧を取得している。ここはキレイに書き直したい。

js
import fs from 'fs'
import { globSync } from 'glob'

var tags = {}

var files = globSync('content/posts/**/*.md');

files.forEach(file => {
  var data = fs.readFileSync(file, 'utf8');
  var found = data.match(/^tags:\s*\[(.+)\]\s*$/m)
  if (found) {
    found[1].split(",")
      .map(tag => { return tag.replaceAll('"', '') })
      .forEach(tag => {
      tags[tag.replaceAll(' ', '')] = tag
    })
  }
});

export default {
  paths: () => {
      return Object.keys(tags).map((key) => {
        return { params: { tag: key }, content: `# ${tags[key]}`}
      })
  }
}

index.md はこちら。

markdown
---
title: 
date: 2023-03-10T21:57:03+09:00
next: false
prev: false
---

<script setup>
import { useData } from 'vitepress'
import { data as posts } from '../../../.vitepress/theme/posts.data.js'

const { params } = useData()
const current_tag = params.value.tag

var pages = []
posts.forEach(post => {
    if (post.frontmatter.tags){
        var tags = post.frontmatter.tags.map(tag => { return tag.replaceAll(" ", "") })
        if (tags.includes(current_tag)) {
            pages.push(post)
        }
    }
})

</script>

<!-- @content -->

<ul>
  <li v-for="page of pages">
    <a :href="page.url">{{ page.frontmatter.title }}</a>
  </li>
</ul>

出来上がったTag詳細ページ Tag詳細ページ

Tag一覧ページはこんな感じ。

markdown
---
title: "Tags"
date: 2023-03-10T21:57:03+09:00
next: false
prev: false
---

<script setup>
import { data as posts } from '../../.vitepress/theme/posts.data.js'

var tags = {}
posts.forEach(post => {
    if (post.frontmatter.tags) {
        post.frontmatter.tags.forEach(tag => {
            if (tags[tag] === undefined) {
                tags[tag] = 1
            } else {
                tags[tag] += 1
            }
        })
    }
})

var tag_list = Object.keys(tags)
</script>

<h1>Tags</h1>
<ul>
  <li v-for="tag of tag_list">
    <a :href="'/tags/' + encodeURIComponent(tag.replaceAll(' ', '')) + '/'">{{ tag }} ({{ tags[tag] }})</a>
  </li>
</ul>

出来上がったTag一覧ページ Tag一覧ページ

共通ヘッダー・フッター

プログ記事に共通のヘッダーとフッターを差し込んだ。

VitePress の Layout Slots 機能を使うと、VitePress が予め決めた埋め込みポイント(スロット)に、HTMLを差し込むことができる。

.vitepress/theme/index.jsLayout() { ... } で、doc-beforedoc-after にそれぞれヘッダーとフッターを差し込んでいる。

js
import { h } from 'vue'
import DefaultTheme from 'vitepress/theme'
import PostTitle from './PostTitle.vue'
import PostFooter from './PostFooter.vue'
import { useData } from 'vitepress'
import './custom.css'

export default {
  extends: DefaultTheme,
  Layout() {
    return h(DefaultTheme.Layout, null, {
      'doc-before': () => {
        const { page } = useData()
        if (page.value.relativePath.match(/^posts\/(?!index.md)/)) {
          return h(PostTitle)
        }
      },
      'doc-after': () => {
        const { page } = useData()
        if (page.value.relativePath.match(/^posts\/(?!index.md)/)) {
          return h(PostFooter)
        }
      }      
    })
  }
}

共通ヘッダーはこちら

html
<script setup>
import { useData } from 'vitepress'
import moment from 'moment';

const { frontmatter } = useData()
const date = moment(frontmatter.value.date).format('YYYY-MM-DD');
</script>

<template>
<div class="vp-doc">
    <p>
    <span>📆 {{ date }}</span>
    </p>
    <h1>{{ frontmatter.title }}</h1>
    <p>
        <a v-for="tag in frontmatter.tags" :href="'/tags/' + encodeURIComponent(tag.replaceAll(' ', '')) + '/'"> #{{ tag }} </a>
    </p>
</div>
</template>

出来上がった共通ヘッダー 共通ヘッダー

共通フッターはこちら

html
<script setup>
import { usePrevNext } from './prev-next.js'
import { usePickup } from './pickup.js'

const pickup = usePickup()
const control = usePrevNext()
</script>

<template>
<div class="post-footer-container">

  <div class="edit-info">
    <div class="edit-link">
      <a class="edit-link-button" href="/">
        &lt; Back to Home
      </a>
    </div>
  </div>

  <div class="prev-next">
    <div class="pager">
      <a v-if="control.prev?.link" class="pager-link prev" :href="control.prev?.link">
        <span class="desc">Previous page</span>
        <span class="title" v-html="control.prev?.text"></span>
      </a>
    </div>

    <div class="pager" :class="{ 'has-prev' : control.prev }">
      <a v-if="control.next?.link" class="pager-link next" :href="control.next?.link">
        <span class="desc">Next page</span>
        <span class="title" v-html="control.next?.text"></span>
      </a>
    </div>
  </div>

  <div class="post-footer-article-container">
    <h2>おすすめ記事</h2>
    <ul>
        <li v-for="post of pickup">
            <a :href="post.url">{{ post.frontmatter.title }}</a>
        </li>    
    </ul>
  </div>
</div>
</template>

<style scoped>
.post-footer-article-container {
    margin-top: 150px;
}

.post-footer-article-container h2 {
    margin: 48px 0 16px;
    border-bottom: 1px solid var(--vp-c-divider);
    padding-bottom: 24px;
    letter-spacing: -0.02em;
    line-height: 32px;
    font-size: 24px;
    position: relative;
    font-weight: 600;
    outline: none;
}

.post-footer-article-container ul {
    list-style: disc;
}
.post-footer-article-container ul, .post-footer-article-container ol {
    padding-left: 1.25rem;
    margin: 16px 0;
}
.post-footer-article-container li + li {
    margin-top: 8px;
}
.post-footer-container {
  margin-top: 64px;
}
.post-footer-container .edit-info {
  padding-bottom: 18px;
}
@media (min-width: 640px) {
  .post-footer-container .edit-info {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding-bottom: 14px;
  }
}
.post-footer-container .edit-link-button {
  display: flex;
  align-items: center;
  border: 0;
  line-height: 32px;
  font-size: 14px;
  font-weight: 500;
  color: var(--vp-c-brand);
  transition: color 0.25s;
}
.post-footer-container .edit-link-button:hover {
  color: var(--vp-c-brand-dark);
}
.post-footer-container .edit-link-icon {
  margin-right: 8px;
  width: 14px;
  height: 14px;
  fill: currentColor;
}
.post-footer-container .prev-next {
  border-top: 1px solid var(--vp-c-divider);
  padding-top: 24px;
}
@media (min-width: 640px) {
  .post-footer-container .prev-next {
    display: flex;
  }
}
.post-footer-container .pager.has-prev {
  padding-top: 8px;
}
@media (min-width: 640px) {
  .post-footer-container .pager {
    display: flex;
    flex-direction: column;
    flex-shrink: 0;
    width: 50%;
  }
  .post-footer-container .pager.has-prev {
    padding-top: 0;
    padding-left: 16px;
  }
}
.post-footer-container .pager-link {
  display: block;
  border: 1px solid var(--vp-c-divider);
  border-radius: 8px;
  padding: 11px 16px 13px;
  width: 100%;
  height: 100%;
  transition: border-color 0.25s;
}
.post-footer-container .pager-link:hover {
  border-color: var(--vp-c-brand);
}
.post-footer-container .pager-link.next {
  margin-left: auto;
}
.post-footer-container .desc {
  display: block;
  line-height: 20px;
  font-size: 12px;
  font-weight: 500;
  color: var(--vp-c-text-2);
}
.post-footer-container .title {
  display: block;
  line-height: 20px;
  font-size: 14px;
  font-weight: 500;
  color: var(--vp-c-brand);
  transition: color 0.25s;
}
</style>

共通フッターから読み込んでいる ./prev-next.js はこちら

js
import { computed } from 'vue'
import { data as posts } from './posts.data.js'
import { useData } from 'vitepress'

export function usePrevNext() {

  return computed(() => {

    const { page } = useData()

    var prev = null
    var next = null

    for (let i = 0; i < posts.length; ++i) {
      if (posts[i].relativePath == page.value.relativePath) {
        if (i >= 1) {
          next = posts[i - 1];
        }
        if (i <= posts.length - 2) {
          prev = posts[i + 1];
        }
        break;
      }
    }

    return {
      prev: {
        text: prev?.frontmatter.title,
        link: prev?.url
      },
      next: {
        text: next?.frontmatter.title,
        link: next?.url
      }
    }
  })
}

共通フッターから読み込んでいる pickup.js はこちら

js
import { computed } from 'vue'
import { data as posts } from './posts.data.js'
import { useData } from 'vitepress'

export function usePickup() {

  return computed(() => {

    const { page } = useData()

    return posts
      .map(value => ({ value, sort: Math.random() }))
      .sort((a, b) => a.sort - b.sort)
      .map(({ value }) => value)
      .filter(post => post.relativePath != page.value.relativePath)
      .slice(0, 5);
  })
}

出来上がった共通フッター フッター

記事ページ

記事ページの階層はこちら

shell
content
└── posts
    └── 2023
        ├── 0225
           └── create-blog-from-scratch
               └── index.md
        └── 0318
            └── upgrade-aurora-mysql-from-56-to-57
                └── index.md

後で説明する SEO 対策のため、日付のディレクトリの下にさらに説明的なディレクトリ階層を作っている。

記事ページの index.md はこちら

markdown
---
title: "Aurora MySQL 5.6 を 5.7 へアップグレードした"
description: "Amazon Aurora MySQL を 5.6 から 5.7 へアップグレードした作業をメモする。"
date: 2023-03-10T00:00:00+09:00
tags: ["AWS", "Aurora", "MySQL"]
outline: deep
next: false
prev: false
---

## はじめに
....

SEO対策

URL

あまり効果がないかもしれないが、

  • URL は単なる記号ではなく、読者がわかりやすい説明的なものに
  • 階層は深くなりすぎないように

という対策をしてみた。

例: https://nshmura.com/posts/upgrade-aurora-mysql-from-56-to-57/

ディレクトリは、記事を書きやすいように日付ごとに作っているが、VitePress の Route Rewrites 機能でルーティングしている。

ディレクトリ階層はこちら

shell
content
└── posts
    └── 2023
        ├── 0225
           └── create-blog-from-scratch
               └── index.md
        └── 0318
            └── upgrade-aurora-mysql-from-56-to-57
                └── index.md

.vitepress/config.js で次のようにルーティングする。 ※ Route Rewrites に必要な部分だけ抜粋

js
import { defineConfig } from 'vitepress'

export default defineConfig({
  ...
  rewrites: {
    'posts/(.*)/(.*)/:name/(.*)': 'posts/:name/index.md'
  },
  ...
})

サイトマップ生成

.vitepress/config.js に次のように設定する。 ※ サイトマップ生成に必要な部分だけ抜粋

js
import { defineConfig } from 'vitepress'

import { createWriteStream } from 'node:fs'
import { resolve } from 'node:path'
import { SitemapStream } from 'sitemap'

const links = []

export default defineConfig({
  ... 
  transformHtml: (_, id, { pageData }) => {
    // for sitemap
    if (!/[\\/]404\.html$/.test(id)) {
      links.push({
        url: pageData.relativePath.replace(/((^|\/)index)?\.md$/, '$2'),
        lastmod: pageData.frontmatter.date
      })
    }
    }
  },
  buildEnd: ({ outDir }) => {
    // sitemap
    const sitemap = new SitemapStream({ hostname: 'https://nshmura.com/' })
    const sitemapStream = createWriteStream(resolve(outDir, 'sitemap.xml'))
    sitemap.pipe(sitemapStream)
    links.forEach((link) => sitemap.write(link))
    sitemap.end()
  }
})

RSS生成

.vitepress/config.js に次のように設定する。 ※ RSS生成に必要な部分だけ抜粋

js
import { defineConfig } from 'vitepress'

import { createWriteStream } from 'node:fs'
import { resolve } from 'node:path'
import { Feed } from "feed"

const links = []
const posts = []

export default defineConfig({
  ... 
  transformHtml: (_, id, { pageData }) => {
    // for rss
    if (/posts/.test(id)) {
      var url = 'https://nshmura.com/' + pageData.relativePath.replace(/((^|\/)index)?\.md$/, '$2');
      posts.push({
        title: pageData.title,
        id: url,
        link: url,
        date: new Date(pageData.frontmatter.date),
        description: pageData.description,
        content: pageData.description,
      })
    }
  },
  buildEnd: ({ outDir }) => {
    // rss
    const feed = new Feed({
      title: "nshmura.com",
      description: "バックエンドエンジニアのメモ",
      id: "https://nshmura.com/",
      link: "https://nshmura.com/",
      language: "ja",
      favicon: "https://nshmura.com/favicon.ico",
    });
    posts.forEach((post) => { feed.addItem(post) });
    feed.addCategory("Technologie");
    const rssStream = createWriteStream(resolve(outDir, 'index.xml'))
    rssStream.write(feed.atom1());
  }
})

Google Analytics の設定

.vitepress/config.js に次のように設定する。 ※ Google Analytics コード生成に必要な部分だけ抜粋

js
import { defineConfig } from 'vitepress']

export default defineConfig({
  ...
  head: [
    [
      'script',
      {
        async: true,
        src: 'https://www.googletagmanager.com/gtag/js?id=G-{Google Analytics のトラッキングID}',
      },
    ],
    [
      'script',
      {},
      "window.dataLayer = window.dataLayer || [];\nfunction gtag(){dataLayer.push(arguments);}\ngtag('js', new Date());\ngtag('config', 'G-{Google Analytics のトラッキングID}');",
    ],
  ],
  ...
})

PageSpeed Insights の結果

作成したブログサイトを評価するために PageSpeed Insights でチェックしてみた。

診断結果

診断結果

  • 改善できる項目
    • 画像のサイズを落とす。
    • 画像を次世代フォーマットフォーマットにする。
    • gtag.js が指摘されている。 改善できる項目

診断

  • レスポンシブデザインだからか、画像の with, height が指定されていない。
  • CloudFront + S3 のキャッシュヘッダーを設定してないので指摘されている。

診断

合格した審査

  • コントラスト比が十分出ないらしい。確かにリンクの色は見ずらい気がする。 合格した審査

振り返り

良いところ

  • シンプルで見やすいデザインになった気がする。
  • Hugo ではテーマに任せっきりだったが、VitePress では必要なところをカスタマイズできるようになった。
  • 思ったより軽量なサイトになった。PageSpeed Insights の結果も良い。

良くないところ

  • VitePress でブログサイトを作ったという記事がなかなか見当たらなかったので、カスタマイズに苦労した。 Vue の動きにけっこうはまったので、知識があればもう少し楽だったかなと思う。

  • VitePress がまだ alpha バージョンなので、大きな変更があったら面倒そう。

  • Hugo と比較とすると

    • ローカルで開発しているときの変更反映時間が多少遅い気がする。
    • ビルド時間も Hugo のほうが早かったかも?

    どちらも許容範囲なので大丈夫。

おわりに

VitePress はデフォルトでいい感じのテーマが用意されているので、CSS が得意じゃなくても始めやすくて、結果いいサイトができたかなと思う。

Vue にはほぼ触れたことがなかったので、触る機会ができたのもよかった。

PageSpeed Insights のキャッシュ設定は後でやっておきたい。

Released under the MIT License.