このサイトのVitePressカスタマイズを読み解く
このマニュアルはVitePressの既定テーマを拡張しています。検索・記事の見せ方・配信方法を別々の場所でカスタマイズすることで、標準の画面や記事の書き方を活かしながら、技術情報を探し、実装を読み、更新を追いやすくしています。
この記事は、このサイトのVitePress 1.6.4と、記事追加時点の設定・テーマ実装を確認した解説です。プリザンター本体の機能とは別の、マニュアルサイト側の実装です。本サイトの独自コードは設定例・処理の抜粋として記事内に示し、VitePress側のコードは公開ソースをコミット固定で引用します。
どの層で何を変えているか
| 利用者に見える改善 | 実装する場所 | 主なファイル |
|---|---|---|
| 日本語・表記ゆれを含めて記事を探す | ローカル検索の設定 | config.mts、search-normalization.ts |
| トップから検索を始める | 既定テーマのスロット | theme/HomeSearch.vue、theme/index.ts |
| 番号を気にせずURLを共有する | URLの書き換え・サイドバー生成 | config.mts、page-path.ts |
| 確認版・改訂版・変更差分を読む | ビルド時のGit走査とVue表示 | articles.ts、history.ts、theme/PageMeta.vue |
| 図と実装コードを記事の中で読む | Markdownレンダラー | config.mts、github-snippets.ts、theme/Mermaid.vue |
| 検索や差分を必要なときに取得する | Viteプラグインと遅延表示 | lazy-data.ts、theme/HistoryDiff.vue |
| 共有・印刷・表示言語を切り替える | ブラウザで動くVueコンポーネント | theme/PageActions.vue、theme/style.scss |
| 更新を購読し、Markdownでも読む | ビルド完了後の出力 | feed.ts、llms.ts、seo.ts |
表のファイルは、いずれもdocs/.vitepress/からの相対パスです。Markdown記事からサイトの設定・テーマを呼び出す構成を、次の図で整理できます。
図を読み込み中…
1. あいまい検索と日本語対応は別の処理
あいまい検索はVitePressの標準機能
このサイトはthemeConfig.search.provider: 'local'を使います。記事から作ったインデックスをブラウザへ配信し、MiniSearchで検索する方式です(VitePress 1.6.4のローカル検索)。
検索画面に組み込まれている標準設定は次のとおりです。このサイトはsearchOptionsを上書きしていないため、これを使います。
fields: ['title', 'titles', 'text'],
storeFields: ['title', 'titles'],
searchOptions: {
fuzzy: 0.2,
prefix: true,
boost: { title: 4, text: 2, titles: 1 },
...(theme.value.search?.provider === 'local' &&
theme.value.search.options?.miniSearch?.searchOptions)
},
...(theme.value.search?.provider === 'local' &&
theme.value.search.options?.miniSearch?.options)出典: src/client/theme-default/components/VPLocalSearchBox.vue(75–85行・1fc537b)
fuzzy: 0.2は、検索語の長さに応じた近い文字列の検索を許可する設定です。prefix: trueは前方一致を許可します。boostは記事タイトル・本文・見出し階層の重み付けで、順に4・2・1です。数値は「一致したら必ずこの順位になる」という指定ではありません。
たとえば検証用の小さなインデックスにJavaScriptを入れると、JavaScripは前方一致、Javascritpはあいまい一致で見つかります。サイト全体の順位は、登録された記事と検索語によって変わります。
独自に足しているのは分かち書きと正規化
このサイトの追加設定は、次のtokenizeとprocessTermです。記事をインデックスへ入れる側と、利用者の検索語を読む側の両方で、同じ関数を使います。
import { normalizeSearchTerm, tokenizeSearchText } from './search-normalization'
// defineConfigのthemeConfig内
search: {
provider: 'local',
options: {
miniSearch: {
options: {
tokenize: tokenizeSearchText,
processTerm: normalizeSearchTerm
}
}
}
}役割は、tokenizeが検索対象の文字列を単語へ分け、processTermが単語を比較しやすい形へ揃えることです。検索する側だけ正規化しても、インデックス側の単語と揃わなければ一致しません。
tokenizeで日本語と記号を扱う
実装は次のとおりです。
export function tokenizeSearchText(text: string): string[] {
const chunks = text.normalize('NFKC').split(/[\p{Z}\p{P}\p{S}]+/u).filter(Boolean)
const segmenter = 'Segmenter' in Intl ? new Intl.Segmenter('ja', { granularity: 'word' }) : undefined
return chunks.flatMap(chunk => {
const word = /^[\u3041-\u3096ー]+$/u.test(chunk)
? chunk.replace(/[\u3041-\u3096]/g, char => String.fromCharCode(char.charCodeAt(0) + 0x60))
: chunk
if (!segmenter) return [word]
return Array.from(segmenter.segment(word))
.filter(part => part.isWordLike)
.map(part => part.segment)
})
}処理の順序には意味があります。
NFKCで全角英数字や半角カタカナなどを正規化します。- Unicodeの区切り・句読点・記号で文字列を分けます。空白だけに依存しません。
- ひらがなと長音だけの塊は、分かち書きの前にカタカナへ寄せます。
Intl.Segmenterで日本語の単語へ分け、isWordLikeの要素を残します。
たとえばNode.js 24の検証では、次のように分かれました。Intl.Segmenterの分割結果は実行環境の辞書に依存するため、ブラウザでも確認します。
| 入力 | tokenizeの結果 | processTerm後 |
|---|---|---|
サーバースクリプト | サーバー、スクリプト | サーバ、スクリプト |
さーばーすくりぷと | サーバー、スクリプト | サーバ、スクリプト |
サーバースクリプト | サーバー、スクリプト | サーバ、スクリプト |
context.AddResponse | context、AddResponse | context、addresponse |
$p.getValue | p、getValue | p、getvalue |
AddResponseだけでも検索対象の単語になります。一方、ドットやドル記号を含むコードの文字列を、そのまま厳密一致させる検索ではありません。また、CamelCaseを任意の境界で分割する処理は入れていません。
Intl.Segmenterがない環境では、記号で分けた塊をそのまま返します。正規化は続きますが、日本語を単語単位へ分ける精度は変わります。
processTermで表記ゆれを揃える
export function normalizeSearchTerm(term: string): string {
return term.normalize('NFKC').toLowerCase()
.replace(/[\u3041-\u3096]/g, char => String.fromCharCode(char.charCodeAt(0) + 0x60))
.replace(/ユーザー/g, 'ユーザ')
.replace(/サーバー/g, 'サーバ')
.replace(/ブラウザー/g, 'ブラウザ')
.replace(/フォルダー/g, 'フォルダ')
.replace(/パラメーター/g, 'パラメータ')
}英字の大小・文字の幅・ひらがな/カタカナに加えて、技術記事でよく出る長音の表記を揃えます。ユーザーとユーザ、サーバーとサーバは同じ単語になります。元の本文や検索結果の表示文をこの文字列へ書き換える処理ではありません。
この置換は列挙した語だけに適用します。全ての長音を削除したり、漢字と読みを相互変換したり、同義語を辞書で展開したりする機能はありません。「利用者」と「ユーザ」が同じ意味だから一致する、といった意味検索でもありません。
あいまい一致は文字列の近さ、正規化は既知の表記ゆれ、分かち書きは検索単位の整理を担当します。検索が見つからないときは、どの段階で違いが生じているかを分けて調べます。
関数を自己完結させる理由
VitePressは検索用の関数を設定からブラウザへ渡す際に、関数の内容を文字列化して復元します。このため、正規化関数から外側の変数や別のヘルパー関数を参照すると、ブラウザ側でその参照を解決できません。
この実装では、Intl.Segmenterの生成も置換処理も、それぞれの関数内で完結させています。共通の置換表を外へ移すリファクタリングは、ビルド時に動いても検索画面で失敗する可能性があります。変更後は、ブラウザへ渡った関数でも実行できることを確認します。
リリースの機能検索は別の検索方式
バージョン間機能比較で使うFeatureReleaseSearch.vueも、同じnormalizeSearchTermを使います。ただし、こちらは空白で分けた各語が、変更概要と分類の文字列に全て含まれるかをevery()とincludes()で判定します。
MiniSearchのあいまい検索・前方一致・日本語の分かち書きは使いません。正規化を共有していても、全文検索と同じ結果になるわけではありません。製品系列で絞り、該当件数を表示し、結果は先頭50件まで表示する実装です。
2. 検索の入口を増やしても検索画面は共用する
トップページの大きな検索ボタンは、HomeSearch.vueのクリック処理から標準の検索ボタンを押しています。
function openSearch() {
document.querySelector<HTMLButtonElement>('#local-search button')?.click()
}独自の検索フォームや結果一覧を追加していないので、検索インデックス、ダイアログ、キーボード操作を標準の検索と共用できます。ボタンにはaria-label・aria-haspopup="dialog"、CSSにはfocus-visibleの表示を設定しています。
ただし、#local-search buttonは既定テーマのDOMに依存する接続点です。VitePress更新時には、検索ボタンのセレクタと実際の起動を確認します。
3. 検索データと履歴差分を必要なときに読み込む
大きな検索インデックスをJSONへ分離する
lazy-data.tsのViteプラグインは、ビルド時の/@localSearchIndex仮想モジュールを変換します。検索インデックスをJSONアセットとして出力し、検索ローダーを「アセットURLを取得し、fetch()で本文を読む」処理へ差し替えます。
図を読み込み中…
標準ローダーが返す{ default: JSON文字列 }の形を維持するのがポイントです。検索画面を作り直さず、データの配信方法だけを変えています。JSONへ分けてもインデックスを取得・復元する負荷自体は残るので、記事数が増えたときは転送量と検索開始時の動作を確認します。
この変換は、開発サーバーとSSR用ビルドでは行いません。対象モジュールの形式が想定と違うときは、ビルドをエラーにします。VitePress内部のモジュール名と生成コードに依存するため、更新時に優先して確認する部分です。
履歴は見出しと差分を分ける
記事には、改訂版・日時・変更内容の見出しと、差分JSONのURLだけを埋め込みます。PageHistory.vueで履歴を開いたときに初めてHistoryDiff.vueを作り、そのコンポーネントが差分を取得します。
差分の内容からSHA-256を計算し、その先頭20桁をファイル名に使います。同じ内容は同じURLになり、内容が変わればURLも変わります。取得失敗時には再読み込みボタンを出します。
これは「履歴を消して軽くする」のではなく、読者が差分を開くまで本文の取得を遅らせる仕組みです。差分はVueのテキスト補間で表示し、追加行・削除行・差分見出しをクラスで色分けします。差分に含まれるHTMLをそのまま実行しません。
4. 既定テーマへ部品を挿し込む
theme/index.tsはDefaultThemeを継承し、レイアウトのスロットへ部品を入れます。現在の配置は次のとおりです。
Layout: () =>
h(DefaultTheme.Layout, null, {
'home-hero-info-after': () => [h(HomeSearch), h(PageActions)],
'doc-before': () => [h(Breadcrumb), h(PageActions)],
'doc-after': () => h(PageHistory)
})トップのヒーロー領域の後に検索とページ操作、記事の前にパンくずとページ操作、記事の後に履歴を置きます。記事ごとに同じ部品を書き込む必要がありません。パンくずはサイドバーのセクション情報から表示名を解決し、トップ・セクション・現在の記事を示します。
一方、PageMetaはスロットではなく、Markdownの最初のH1が閉じた直後へレンダラーが挿入します。これで作成日時・最終更新日時・改訂版・確認版を、記事タイトルのすぐ下に揃えられます。
VueコンポーネントをMarkdownの変換結果から使う場合は、enhanceAppで登録します。このサイトではMermaid・VersionCompare・SwaggerSpec・PageMetaを登録しています。テーマの拡張とスロットは既定テーマの拡張方法が入口です。
5. 番号順の管理と共有しやすいURLを両立する
記事ファイルは3桁の番号-スラッグ.mdで並べます。サイドバーはフォルダを再帰的に走査し、番号順に並べ、frontmatterのtitleを表示名に使います。
rewritesには、元のファイル名と公開先の対応を生成します。たとえば、operation/130-vitepress-customization.mdの公開URLは/operation/vitepress-customizationです。番号付き記事では途中の分類フォルダも除き、セクション直下のURLへ揃えます。
同じ規則をpage-path.tsへ集めて、サイドバー、記事リンク、履歴、SEOで使います。ここが別々の規則になると、画面では開けてもフィードやcanonical URLが違う、といった問題が起きます。なお、番号変更ではURLが変わりませんが、スラッグ変更では変わります。
開発中の記事追加・削除時には、ファイル監視から設定ファイルの更新日時を変え、VitePressの再起動を促します。起動時に生成するサイドバーとrewritesを、記事ファイルの増減へ追従させるためです。
6. 記事の版と対応する製品の版を区別する
冒頭の「第何版」は記事の改訂版です。Git履歴から初回追加を第1版とし、記事を変更したコミットごとに版を進めます。内容を変えない改名だけのコミットは数えません。
frontmatterのpleasanterは確認したプリザンターの版、since・untilは内容の対応範囲です。記事の改訂版と製品のバージョンは、別の情報として表示します。
日時は全記事のGit履歴を一括で走査して集め、記事冒頭・新着記事・フィードで共用します。記事末尾の個別差分は改名を追跡し、直近30件、各差分は400行までで表示します。日時の表示は日本時間です。
履歴がない浅いcloneでは、同じ結果を再現できません。このサイトの公開ワークフローではfetch-depth: 0でGit履歴を取得します。未コミットの記事はGitから日時を取得できないため、公開ビルドの前にコミットする必要があります。
7. 図とGitHubのコードを記事の中で読む
Mermaidは表示領域へ近づいてから描画する
MarkdownレンダラーはmermaidコードブロックをMermaidコンポーネントへ変換します。図の文字列はURIエンコードして渡し、ブラウザ側で戻して描画します。
Mermaid.vueはIntersectionObserverで図が画面付近へ来るのを検出し、import('mermaid')でライブラリを読み込みます。判定範囲は表示領域の周囲200pxです。IntersectionObserverがない環境では、マウント時に開始します。
日本語フォントの読み込みが終わるまでdocument.fonts.readyで待ち、その後に図のラベルを計測します。先に別のフォントで計測すると、文字の幅と箱の寸法が合わなくなるためです。ダークモード変更時には配色を変えて再描画し、エラー時は本文の代わりに理由を表示します。
GitHubコードはビルド時に取得する
記事のgithubコードブロックでは、公開リポジトリの固定コミットと行範囲を指定します。たとえば、この記事の検索設定の引用も、この機能で表示しています。
処理はURLの検証、ファイルの取得とキャッシュ、行範囲の切り出し、言語の決定、既定のコードレンダラーへの受け渡しです。表示にはファイル名と出典リンクを付け、共通の空白インデントだけを取り除きます。コードグループ内の表示にも対応します。
取得は開発・ビルド時に行い、閲覧者のブラウザからGitHubへ取得しに行く仕組みではありません。キャッシュは取得URLのSHA-256をキーにします。取得失敗・不正なURL・範囲外の行はエラーにするため、引用が欠けたまま公開しません。
40桁のコミットを固定するので、上流の変更で記事内のコードが勝手に差し替わることもありません。新しい版を解説するときは、URLと説明の両方を更新します。
8. 共有・印刷・翻訳をブラウザ側で補助する
共有はブラウザ機能から順に試す
共有ボタンは、現在のURLとタイトルをnavigator.share()へ渡します。利用できない場合や共有に失敗した場合は、クリップボードへのURLコピー、それも失敗した場合はコピー用のプロンプトへ進みます。利用者が共有をキャンセルした場合は、そのまま終了します。
現在のURLを使うため、見出しのアンカーや表示言語のクエリも共有先へ渡ります。コピー成功時はボタンの説明を一定時間変更して、操作結果を伝えます。
印刷ボタンと印刷CSSを組み合わせる
ボタンはwindow.print()を呼びます。印刷CSSではナビゲーション、サイドバー、ページ操作、変更履歴を隠し、本文の幅を広げます。バージョン比較の横スクロール領域も解除して、表を印刷に載せます。
現在の@pageはA4横・余白12mmです。ブラウザの印刷ダイアログでPDF保存できますが、PDF生成サーバーを設けている実装ではありません。
ページ内翻訳は原文とコードを保持する
PageActions.vueはブラウザのTranslator APIが使えるかを確認し、日本語から選択言語へテキストノードを翻訳します。APIの対応環境・モデルの準備条件はChromeのTranslator APIで確認できます。非対応時やモデル取得の失敗時は、状態と開始方法を表示します。
コード・入力欄・SVG・非表示要素・translate="no"の領域などを除外します。翻訳は画面のテキストに対して行い、記事のMarkdownは更新しません。原文と翻訳を保持して、日本語へ戻したときに原文を復元します。
MutationObserverで後から追加されるテキストも処理し、ページ遷移時は古い処理を止めます。非同期翻訳の世代番号を比較し、以前のページへの結果が新しいページへ適用されないようにします。選択言語はURLのlangと保存した設定から決定し、翻訳モデルはタブ内で再利用します。翻訳中は機械翻訳であることを表示します。
9. 日本語の見やすさと配信量を調整する
本文のフォントはBIZ UDPGothicの400・700を読み込み、--vp-font-family-baseを変更しています。配信用CSSはunicode-rangeで分割されているため、ページで使う文字に対応するフォントを読み込みます。使わない既定のInterフォントの先読みタグは、生成HTMLから除きます。
ブランド色はCSS変数で変更し、ダークモードでは明るい同系色に切り替えます。PCのサイドバーはヘッダー下をスクロール領域にする調整も入れています。
Font Awesomeは、使うアイコン名だけをSCSSに列挙し、SVGをCSSマスクで表示します。全アイコン入りのフォントやスタイルシートを読み込まず、currentcolorで色を揃えます。
100行を超えるコード例では、ハイライト用のトークンを行単位へまとめます。コード本文は保持しながら大量のspanを抑える設定で、長い配布スクリプトの記事でも使っています。細かなトークンごとの色分けは、この範囲では減ります。
10. 更新情報とMarkdownも静的に配信する
buildEndで、更新日時順の直近50記事からRSS・Atom・JSON Feedを生成します。日時とURLは記事情報と共用するので、記事冒頭とフィードを別々に更新する必要がありません。
記事ごとのMarkdown、全記事をまとめたllms-full.txt、索引のllms.txtも生成します。GitHubのコード引用と、配布ファイルのスニペットは中身へ展開します。Vueの画面を操作せずに本文とコードを取得できる、もう一つの読み方です。
SEOではcanonical URL、OGP、記事の日時、パンくずなどの構造化データを生成します。404ページはnoindexにします。サイドバーやフィードと同じURL規則を使い、記事を移動・整理しても参照先を揃えます。
カスタマイズを他のサイトへ移すときの確認順
この実装を参考にする場合は、効果が欲しい部分から移します。検索の正規化なら関数と検索設定、テーマ部品ならVueコンポーネントとスロット、遅延配信ならViteプラグインと受け取り側を揃えます。
| 変更する部分 | 確認すること |
|---|---|
| 検索の正規化 | インデックスと検索語の双方へ設定したか。ブラウザへ渡した関数が外部変数へ依存しないか |
| 日本語の分割 | 対応ブラウザで単語の分割結果と検索結果を確認したか |
| 検索の入口 | 標準ボタンのDOMが変わっていないか。クリック・キーボードで検索を開けるか |
| JSONへの分離 | 本番ビルドの検索ローダー形式、公開先のURL、取得失敗時の動作を確認したか |
| URLの書き換え | サイドバー・本文リンク・canonical・フィードが同じURLになっているか |
| 記事情報と履歴 | 全履歴を取得しているか。改名・改訂版・日時・差分の遅延取得を確認したか |
| 図とコード引用 | ダークモード、日本語ラベル、取得失敗、コードグループ、Markdown配信でも内容が揃うか |
| ページ操作 | ブラウザAPIの非対応・拒否・キャンセル・ページ遷移を扱えるか |
特に検索の仮想モジュール変換と、既定テーマのDOMへ接続する処理は、VitePress更新時に動作確認が必要です。設定値だけで拡張できる場所と、内部の生成形式へ依存する場所を把握しておくと、更新時の調査範囲を絞れます。