VuePress 1.x から VitePress へポートフォリオサイトを移行した記録

Table of Contents

はじめに

task4233.dev はもともと VuePress 1.x で運用していたポートフォリオ兼テックブログだった。テックブログとしての利用はすでに別ドメインへ移しており、本ドメインは実質的にプロフィールページとしてしか使っていなかった。

そのまま放置していたが、ここ最近の状況がよろしくなかった:

  • VuePress 1 系の最終リリースは 2023-08(執筆時点で 2 年以上前)
  • ビルドに NODE_OPTIONS='--openssl-legacy-provider' が必須(modern Node との非互換)
  • Dependabot 経由の transitive 脆弱性 PR が定期的に降ってくる(webpack 4 世代の依存ツリー)
  • そして決定打、自動デプロイが 2023-01-06 から止まっていたことが調査して判明した

腰を据えて移行することにした。スコープは トップページのみ(プロフィール/Publications/Employments 等を載せたシングルページ)。記事は別ドメインに集約済みなので、本ドメイン側に残っていた旧記事群はこの機会に撤去することとし、移行対象から最初から外した。

同じような VuePress 1 サイトを抱えている人の参考になれば。

なぜ VuePress 2 にしなかったのか

最初に検討するべきは「VuePress 2 へのアップグレード」だ。同じ系統で済むなら、それが一番楽に見える。

しかし、VuePress 2 は npm の next タグで RC が長期間配信されているだけで、執筆時点でも GA に到達していない。さらに調査を進めると、v1 → v2 は アップグレードではなく実質書き直しであることが分かった:

  • Vue 2 → Vue 3 のメジャーアップ(カスタムコンポーネントに手が入っているなら全書き直し)
  • 設定ファイルは CommonJS → ESM 必須
  • バンドラーが暗黙ロードでなくなり、@vuepress/bundler-vite 等を明示的に install
  • プラグイン参照が文字列指定不可、import した関数を渡す形式に

加えて、私のサイトはデフォルトテーマを eject してアクセント色を変えていた。v2 ではこの ejected theme が完全に無効化されるため、カスタマイズ部分は丸ごと書き直しになる。

つまり、v2 への移行コストは:

  • Vue 2 → 3 の書き直し
  • ejected theme 廃棄 → 再構築
  • 設定ファイル / プラグイン参照の作り直し

これを払うのに、得られる結果が「RC 段階のフレームワーク」では報われない。

SSG の選定

「アップグレードではなく移行」と腹を括った段階で、選択肢は一気に開ける。候補は:

候補スタイル維持将来性移行コスト
現状維持(VuePress 1 凍結)0
VuePress 2 RC
VitePress
Astro
Hugo
Docusaurus

選定で重視した軸:

  1. 現在のページスタイル維持の容易さ — デザイン刷新は今回のスコープ外
  2. 将来 5 年のメンテ負担 — エコシステム活発度、メジャーアップグレードの素直さ
  3. 機能パリティ — last-updated / カスタムドメイン HTTPS など必要最小限の機能が無理なく実現できるか
  4. 移行コスト — 半日〜数日で収まるか

逆に 明示的に重視しなかった軸も書いておく(差別化として読み手に有用と思うので):

  • ビルド速度の極限値 — 個人サイトで数秒の差は無意味
  • フレームワーク汎用性 — ポートフォリオ用途なら不要
  • 流行り — 移行の理由にはならない

最終的に VitePress を採用した。理由は:

  • VuePress と同じ作者陣による事実上の後継
  • デフォルトテーマが VuePress デフォルトテーマの直系の見た目 → アクセント色合わせだけでスタイル維持できる
  • Vue 3 / Vite / Node 22 ネイティブで --openssl-legacy-provider 不要
  • 「保守的な選択」であることを正直に書くと、流行りで選んだのではなくリスクの少なさで選んだ

VitePress 公式リリースとして提供されているテーマがデフォルトテーマしかないのは弱点だが、トップページ 1 枚なのでこの制約はそもそも顕在化しない。

移行を 4 フェーズに分割

一気にやると事故るので、段階を切った:

  1. Phase 0: 足場固め (30 分) — package.jsonengines.node>=22.18 <23 で固定し、作業ブランチを切る
  2. Phase 1: VitePress PoC (半日) — 最小構成を組み、トップページの見た目を本番 (VuePress) と並列比較。Go/No-Go 判断ポイント
  3. Phase 2: デプロイ Actions 化 (1〜2 時間) — GitHub Actions による自動デプロイの新設
  4. Phase 3: 切り替えと本番検証 (1 時間) — master マージと本番反映確認

各フェーズは独立した PR として切り出せるサイズにした。Phase 1 完了時点に「Go/No-Go 判断ポイント」を置いておくのが事故防止上重要だった。スタイルが想像以上に崩れる、といった致命傷が出たらここで一度立ち止まれる。

実装で詰まったポイント

ここから先は具体の話。VitePress 公式ドキュメントだけでは見えにくかった躓きどころを並べる。

1. "type": "module" が必須

VitePress 本体は ESM-only。package.json"type": "module" を入れていないと、設定ファイル config.ts のロードで以下のエラーが出る:

"vitepress" resolved to an ESM file. ESM file cannot be loaded by `require`.

最初の vitepress dev 実行で必ずぶつかるので、package.json のテンプレートに最初から入れておくとよい。

2. README.md ではなく index.md がホーム

VuePress は src/README.md をホームページとして扱うが、VitePress は src/index.md をホームに使う。リネーム必須:

git mv src/README.md src/index.md

これに気付かないと、ホーム URL を叩いたときに 404 になり、ビルトインの NotFound レイアウトが表示される。一見動いているように見えてホームだけ壊れるので最も気付きにくい。

3. 本文幅が .content-container の親縛りで広がらない

VitePress のデフォルトテーマは .VPDoc:not(.has-sidebar) .content を 752px 固定 max-width にしている。padding を引いた実本文幅は 688px。VuePress 1 のデフォルト $contentWidth = 740px より狭く見える。

ここで素直に .content-container { max-width: 740px } を当ててもダメだった。原因は:

  • VitePress は本体スタイルを Vue scoped style として出している
  • 生成される data-v-XXX 属性セレクタが specificity を 1 段押し上げる
  • 通常の .content 系セレクタでは負ける

具体的に見ると:

セレクタspecificity
.VPDoc:not(.has-sidebar) .content[data-v-10119189] (本体)(0, 4, 0)
.VPDoc:not(.has-aside) .content (上書き試行)(0, 3, 0)

私の上書きは負けて効かなかった。!important でねじ伏せるしかない:

.VPDoc:not(.has-aside) .content {
  max-width: 80% !important;
}

VitePress 公式テーマ自身も !important を多用しているので、慣用パターンとしては許容される。

4. lang: 'ja-JP' だけではローカライズが終わらない

<html lang="ja-JP">lang: 'ja-JP' で出るが、これだけでは:

  • 最終更新日が 3/10/21, 10:25 PM(en-US 形式)のまま
  • TOC が On this page
  • 404 ページが英語のまま
  • スキップリンクが Skip to content

VitePress は lang 設定で UI 文字列を自動翻訳しない。それぞれ themeConfig で明示する必要がある:

themeConfig: {
  lastUpdated: {
    text: '最終更新',
    formatOptions: {
      dateStyle: 'medium',
      timeStyle: 'short',
      forceLocale: true,  // <html lang> に従う
    },
  },
  outline: { label: '目次' },
  sidebarMenuLabel: 'メニュー',
  returnToTopLabel: 'トップへ戻る',
  skipToContentLabel: 'コンテンツへスキップ',
  notFound: {
    title: 'ページが見つかりません',
    linkText: 'トップへ戻る',
    linkLabel: 'トップに戻る',
  },
}

forceLocale: true がないと、Intl.DateTimeFormat がブラウザロケールで動いて日付がガタガタになる。

5. 見た目の差分(H2 の区切り線位置)

VuePress 1 のデフォルトは H2 { border-bottom: 1px solid } で見出しの「下」に区切り線。VitePress は H2 { border-top: 1px solid; padding-top: 24px } で見出しの「上」に区切り線。微妙だが視覚的にだいぶ印象が違う。

.vp-doc h2 {
  margin: 48px 0 16px;
  border-top: none;
  padding-top: 0;
  border-bottom: 1px solid var(--vp-c-divider);
  padding-bottom: 0.3rem;
}

/* padding-top を消したのでアンカーリンクの位置も合わせる
   default は top: 24px で padding 内に配置 */
.vp-doc h2 .header-anchor {
  top: 0;
}

.header-anchortop: 24px は親 H2 の padding-top: 24px 内に配置するためのものなので、padding を消すならこれもセットで top: 0 に戻す必要がある。気付かないと、ホバー時の # が見出しの下にズレて出る不気味な挙動になる。

デプロイ機構の再構築

ここで大きな発見があった。コードレビュー段階で、実は本番が 2023-01-06 を最後に更新されていなかったことに気付いた。

調べると:

  • gh-pages ブランチの最終コミットは Auto build and deploy [ci skip] で 2023-01-06
  • .github/workflows/ にデプロイワークフロー無し
  • README には CircleCI と書かれているが .circleci/ 設定ファイル無し

CircleCI 連携が当時切れて、自動デプロイが完全に死んでいた。master には dependabot PR がマージされ続けていたが、本番には何も反映されていなかった。

このまま master を更新しても本番に届かないので、Actions ベースの新デプロイフローを立てるのが必須だった:

name: Deploy to GitHub Pages

on:
  push:
    branches: [master]
  workflow_dispatch:

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

concurrency:
  group: pages
  cancel-in-progress: false

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: corepack enable
      - uses: actions/setup-node@v4
        with:
          node-version-file: package.json
          cache: yarn
      - run: yarn install --immutable
      - run: yarn build
      - uses: actions/configure-pages@v5
      - uses: actions/upload-pages-artifact@v3
        with:
          path: docs

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - uses: actions/deploy-pages@v4
        id: deployment

ポイント:

  • corepack enable で yarn 4 を提供
  • node-version-file: package.jsonengines.node を読み取り、Node 22.x の最新を自動選択
  • cache: yarn で yarn.lock ベースのキャッシュ
  • yarn install --immutable (yarn 4 の --frozen-lockfile 相当)
  • ビルド成果物 docs/ を Pages アーティファクト化
  • Settings → Pages → Source を 「GitHub Actions」 に変更する手動操作が前提

gh-pages ブランチは触らずに残置している。新フローが安定動作するのを確認してから削除を判断する想定。

Cloudflare Proxy + GitHub Pages の罠

カスタムドメイン設定でも一悶着あった。task4233.dev は Cloudflare で DNS 管理している。新フロー反映後の本番確認で:

$ curl -sI https://task4233.dev/
HTTP/2 404
server: cloudflare

Cloudflare 経由のリクエストが 404 を返す。一方:

$ curl -sI -H "Host: task4233.dev" https://task4233.github.io
HTTP/2 200
server: GitHub.com

GitHub Pages 直撃は 200 を返す。つまり Cloudflare が GitHub Pages へ正しく中継できていない状態だった。

原因はおそらく Cloudflare 側の DNS レコードが旧 CircleCI 時代の何かを向いたまま放置されていたことか、Page Rules 残骸。Cloudflare の Proxy ON 状態は CDN/WAF が間に挟まる分、設定不整合に弱い

対処は単純で、Cloudflare Proxy を OFF(DNS only / 灰色雲)にして、A レコードを GitHub Pages の 4 IP に直接向けた:

Type   Name   Content              Proxy
A      @      185.199.108.153      OFF
A      @      185.199.109.153      OFF
A      @      185.199.110.153      OFF
A      @      185.199.111.153      OFF
CNAME  www    task4233.github.io   OFF

これで Cloudflare をバイパスして直接 GitHub Pages に到達するようになり、HTTPS 証明書も Let’s Encrypt が自動発行された:

{
  "state": "approved",
  "domains": ["task4233.dev", "www.task4233.dev"],
  "expires_at": "2026-08-09"
}

.dev TLD は HSTS preload 対象なのでブラウザが強制 HTTPS にする。証明書発行までの間は文字通り「ブラウザでアクセスできない(curl は HTTP で通る)」状態になり、ここで焦ると残念な気持ちになる。待つのが正解で、通常 5〜30 分で発行される。

CDN/WAF が必要ないなら Proxy OFF が最もシンプル。必要になったら戻せばよい。

学びと他の技術者への示唆

長々と書いたが、要点を凝集すると:

  1. 「最新の RC を待つ」戦略は、RC 期間が長期化した時点でリスク。VuePress 2 の RC が長すぎて、もう待つ意味がない
  2. ejected theme は便利だが、メジャーアップグレードを実質的に塞ぐ負債。私の場合、ejected default theme を持っていたことが v2 移行コストを跳ね上げていた
  3. 個人サイトでも engines.node を切っておくと CI / 他環境の再現性が安定する
  4. 「自動デプロイが動いているはず」を検証していないことが普通にあるgh-pages ブランチの最終コミット日付を見るだけで、自分のサイトが何年動いてないかが分かったりする
  5. Cloudflare Proxy + GitHub Pages はオリジン側の DNS が壊れていると気付きにくい。SSL 証明書のチェーンや期限ではなく、Cloudflare 経由のレスポンス内容で判定する
  6. 移行のスコープはなるべく絞る。「同じ機能を再現しよう」と無条件に思ってしまうが、本当に使っているか問い直すと作業量が一桁減ることがある

結果

最終的に:

  • VuePress 1.9.10 → VitePress 1.6.4 (stable)
  • yarn 1 → yarn 4 (corepack)
  • ejected default theme 廃棄、CSS 変数オーバーライドのみで見た目を再現
  • CircleCI 死亡 → GitHub Actions による自動デプロイ
  • Cloudflare Proxy OFF + GitHub Pages 直接配信、Let’s Encrypt 証明書自動発行
  • 全 UI 文字列を ja-JP 化

総作業時間は半日〜1 日のオーダー。シンプルなトップページ 1 枚への移行に絞ったことで、見た目調整とデプロイ機構の再構築に集中できた。

リポジトリは task4233/note、本番は task4233.dev。同じ立場の人の参考になれば。

Posted on 11, 2026