はじめに
Hugo で作ったブログサイト であったが、しっくり来る Hugo のテーマがなかなか見つからなかった。
色々探していたのだが、hugo-theme-stack というテーマの ドキュメント が見やすかったので、何で作ってあるのか調べたところ VitePress という静的サイトジェネレーターだった。
Vue にはあまり詳しくないし、JavaScript フレームワークが入ることでサイトが重くなる懸念があったが、デザインの良さに惹かれて試してみることにした。
試してみると、それほどサイトも重くならなさそうだし、ブログサイトにカスタマイズするのもなんとかできそうなので Hugo から VitePress に移行することにした。
VitePress のインストール
Getting Started を見ながらインストールした。
ディレクトリを掘って移動して
$ mkdir vitepress
$ cd vitepress
VitePress のインストール
$ npm install -D vitepress
サイトの初期化
$ npx vitepress init
初期化で色々聞かれるので入力していく
ファイルとディレクトリができている
vitepress
├── .vitepress
│ └── config.ts
├── api-examples.md
├── index.md
├── markdown-examples.md
├── node_modules
└── package.json
.vitepress/config.ts
が VitePress の設定ファイル
*.md
はサンプルなので消しておく。
ディレクトリ構成
いろいろ試したが、最終、次のような構成に落ち着いた。
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
をカスタマイズして作ることにした。
トップページはこちら
---
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
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 はこちら
.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 ファイルを読み込む必要がある。
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
でサイドメニューなどを設定しておく。
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
で記事一覧を読み込んで表示するだけ。
---
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 ページの構成はこんな感じ
content
└── tags
├── [tag]
│ ├── index.md ... Tag 詳細のページ
│ └── index.paths.js ... Tag 詳細のページを生成するための JavaScript
└── index.md ... Tag 一覧ページ
Dynamic Routes 機能では、[tag]
のようなプレースホルダを入れたディレクトリがあると、そのディレクトリの下にある index.paths.js
が paths loader file として使われる。
paths loader file は、 [tag]
に入れる値の一覧を返す JavaScript で、VitePress がその値を読み込んでページを自動生成することになる。
index.paths.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
はこちら。
---
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一覧ページはこんな感じ。
---
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一覧ページ
共通ヘッダー・フッター
プログ記事に共通のヘッダーとフッターを差し込んだ。
VitePress の Layout Slots 機能を使うと、VitePress が予め決めた埋め込みポイント(スロット)に、HTMLを差し込むことができる。
.vitepress/theme/index.js
の Layout() { ... }
で、doc-before
と doc-after
にそれぞれヘッダーとフッターを差し込んでいる。
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)
}
}
})
}
}
共通ヘッダーはこちら
<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>
出来上がった共通ヘッダー
共通フッターはこちら
<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="/">
< 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
はこちら
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
はこちら
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);
})
}
出来上がった共通フッター
記事ページ
記事ページの階層はこちら
content
└── posts
└── 2023
├── 0225
│ └── create-blog-from-scratch
│ └── index.md
└── 0318
└── upgrade-aurora-mysql-from-56-to-57
└── index.md
後で説明する SEO 対策のため、日付のディレクトリの下にさらに説明的なディレクトリ階層を作っている。
記事ページの index.md
はこちら
---
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 機能でルーティングしている。
ディレクトリ階層はこちら
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 に必要な部分だけ抜粋
import { defineConfig } from 'vitepress'
export default defineConfig({
...
rewrites: {
'posts/(.*)/(.*)/:name/(.*)': 'posts/:name/index.md'
},
...
})
サイトマップ生成
.vitepress/config.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生成に必要な部分だけ抜粋
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 コード生成に必要な部分だけ抜粋
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 のキャッシュ設定は後でやっておきたい。