WordPressのテーマを更新しました

10年ほど前に自作したWordPressテーマをずっと使っていたのですが、メンテナンスできないまま時代遅れになっていたので、今風に作り直しました。
Claude Code (Opus 5.5)に手伝ってもらいながら進めた(というかほぼClaude作)ので、設計の考え方と実装の細かい話をまとめておきます。

旧テーマの問題点

2015年ごろに作ったテーマは、当時としては普通の作りでしたが、今見るとかなり厳しい状態でした。

  • 横幅999px固定で、スマホでは縮小表示されて読めない
  • レイアウトが float ベースで、ちょっと直すだけでも崩れる
  • 画像の拡大表示、引用のホバー、サイドバーの追従をすべて jQuery で実装していた
  • Google+ ボタンや、計測が終了した旧Google Analytics(UA)のタグが残っていた
  • 広告の枠が固定サイズでテンプレートに直書きされていた

設計方針

私は、「今風にリメイクして」とだけ指示しましたが、Opus 5.5は作り直すにあたって次の4つを決めていい感じに作り直してくれました。

  1. 雰囲気は残す:ダーク基調、ティール系のリンク色、ヘッダーのドラゴン画像はこのブログの顔なので引き継ぐ
  2. 過去記事を1本も直さない:280本以上ある記事の中で使っている独自クラスは、新テーマでもそのまま使えるようにする
  3. 依存を減らす:jQuery・Webフォント・ビルドツールを使わず、素のCSSとJSだけで書く
  4. スマホを基準にする:1カラムを基本にして、画面が広いときだけ2カラムにする

ブロックテーマではなくクラシックテーマを選んだ理由

今のWordPressの主流は、theme.json とHTMLテンプレートで作る「ブロックテーマ」です。それでも今回は、PHPテンプレートで作る従来型の「クラシックテーマ」にしました。

  • 過去記事はほぼすべてクラシックエディターで書いたHTMLで、ブロックエディターの恩恵が少ない
  • 旧テーマのテンプレート構成(header.php / single.php / sidebar.php …)をそのまま置き換えられる
  • 関連記事や目次のような「ちょっとしたロジック」をPHPで素直に書ける

ファイル構成はこれだけです。

between/
├── style.css          … テーマ情報 + 全スタイル
├── functions.php      … 機能の登録とヘルパー関数
├── header.php / footer.php / sidebar.php
├── index.php          … トップ・カテゴリー・タグ・検索の一覧
├── single.php / page.php / 404.php / comments.php / searchform.php
├── template-parts/card.php … 記事カード
└── assets/
    ├── theme.js       … メニュー・目次・画像拡大など
    ├── editor.css     … ブロックエディター用
    └── head.png       … ヘッダー画像

色と余白はCSS変数で管理する

色・角丸・幅などはすべて :root にCSS変数として定義し、各所ではそれを参照するだけにしました。後から色味を変えたくなっても、ここを書き換えれば全体に反映されます。

:root {
	--bg: #0d0f11;
	--surface: #15181b;
	--border: #262c31;
	--text: #d9dde0;
	--muted: #8d979f;
	--accent: #6fb6c4;
	--radius: 12px;
	--content-w: 1240px;
	--gutter: 16px;
	color-scheme: dark;
}

color-scheme: dark を指定しておくと、フォームの入力欄やスクロールバーもブラウザ標準のダーク表示になります。

フォントはWebフォントを読み込まず、OS標準の日本語フォント(ヒラギノ、Noto Sans JP、游ゴシック、メイリオ)を並べています。日本語のWebフォントはファイルが重いので、表示速度を優先しました。

レイアウトはCSS Gridの2カラム

本文とサイドバーの2カラムは、CSS Gridで書いています。

.site-body {
	display: grid;
	grid-template-columns: minmax(0, 1fr) 320px;
	gap: 48px;
	align-items: start;
}
@media (max-width: 960px) {
	.site-body { grid-template-columns: minmax(0, 1fr); }
}

ポイントは 1fr ではなく minmax(0, 1fr) にしていることです。1fr だけだと、長いURLや横に広い表があったときに列がそれ以上縮まず、スマホで横スクロールが発生します。最小幅を0にしておくと、中身があふれても列の幅は守られます。

960px以下では1カラムになり、サイドバーは本文の下に回ります。

記事一覧はカード型に

記事一覧は、サムネイル・日付・カテゴリー・タイトル・抜粋を1枚のカードにまとめました。PCではサムネイルが左、スマホでは上に来ます。

サムネイルはアイキャッチがなければ本文の最初の画像

過去記事にはアイキャッチ画像を設定していないものが多いので、アイキャッチがなければ本文中の最初の画像を使うようにしています。

function between_thumbnail_url( $post = null ) {
	$post = get_post( $post );
	if ( has_post_thumbnail( $post ) ) {
		return get_the_post_thumbnail_url( $post, 'medium_large' );
	}
	if ( preg_match( '/<img[^>]+src=["\']([^"\']+)["\']/i', $post->post_content, $m ) ) {
		// 外部の埋め込み画像は使わない
		if ( false !== strpos( $m[1], '/wp-content/uploads/' ) ) {
			return $m[1];
		}
	}
	return '';
}

画像がない記事は、サムネイル無しのカードになります。

日本語の抜粋は文字数で切る

WordPress標準の抜粋は「単語数」で切るため、スペースで区切られない日本語だとうまく切れません。そこで、<!--more--> より前の本文からタグを除き、mb_substr で文字数ベースで切る関数を用意しました。

$text = get_extended( $post->post_content )['main'];
$text = wp_strip_all_tags( strip_shortcodes( $text ), true );
if ( mb_strlen( $text ) > 110 ) {
	$text = mb_substr( $text, 0, 110 ) . '…';
}

表示側では -webkit-line-clamp: 3 で3行に揃えています。

カード全体をクリックできるようにする

カードのどこをクリックしても記事に飛べるよう、タイトルのリンクに疑似要素をかぶせています。

.card { position: relative; }
.card__title a::after {
	content: "";
	position: absolute;
	inset: 0;
}
/* カテゴリーのリンクだけは個別に押せるよう前面に出す */
.card .post-meta { position: relative; z-index: 1; }

カード全体を <a> で囲む方法だと、中にカテゴリーのリンクを置けません(リンクの入れ子になる)。この方法ならHTMLの構造を崩さずに済みます。

ヘッダーはスクロールしても上に残す

ヘッダーは position: sticky で画面上部に固定し、背景を半透明+ぼかしにしています。

.site-header {
	position: sticky;
	top: 0;
	background: rgba(13, 15, 17, .82);
	backdrop-filter: saturate(160%) blur(12px);
}

スマホではメニューと検索をハンバーガーボタンの中にまとめています。メニューを設定していない場合は、記事数の多いカテゴリーを自動で並べます。

記事本文の読みやすさ

  • 行間は 1.95 と広めにしました。日本語は漢字とかなが詰まって見えやすいので、欧文より広い行間が合います
  • YouTubeの埋め込みは aspect-ratio: 16 / 9; width: 100% にして、画面幅に合わせて伸縮させています
  • 横に長い表は、JSで <div class="table-scroll"> に包んで、表だけを横スクロールできるようにしています。ページ全体が横に揺れるのを防げます
  • 日付の横に「約○分で読めます」を表示しています。本文の文字数を500で割っただけの簡単な計算です

過去記事の独自クラスを引き継ぐ

過去記事では、見出しに h3.mid、手書きの目次に .mokuji、数値の比較に table.note といった独自クラスを使っています。これらのスタイルは新テーマにも用意しつつ、色は新しいCSS変数に置き換えました。記事側は1文字も変えていません。

.entry__content h3.mid {
	padding: .5em .8em;
	background: var(--surface-2);
	border-left: 3px solid var(--accent);
	border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}

目次を自動で生成する

記事中の見出し(h2〜h4)から目次を自動で作り、PCではサイドバーに追従表示、スマホでは本文の前に折りたたみで表示します。

見出しから目次を作る

見出しが3つ以上ある記事だけを対象にしています。見出しに id が無ければ振り、リンクを作ってリストに追加します。一番大きい見出しのレベルを基準にして、下位の見出しを字下げしています。

const headings = [...content.querySelectorAll('h2, h3, h4')];
if (headings.length >= 3) {
	const top = Math.min(...headings.map((h) => Number(h.tagName[1])));
	headings.forEach((h, i) => {
		if (!h.id) h.id = `section-${i + 1}`;
		const li = document.createElement('li');
		li.className = `toc__item toc__item--${Number(h.tagName[1]) - top + 1}`;
		// …リンクを作って追加
	});
}

すでに手書きの目次(.mokuji)がある記事では、スマホで本文前に出す目次は省略して、目次が二重にならないようにしています。

サイドバーの目次を追従させる

目次を position: sticky にするだけでは追従しませんでした。原因は、2カラムのGridに指定していた align-items: start です。これによってサイドバーの高さが中身の分しかなくなり、sticky要素が動ける範囲が残っていませんでした。

目次があるときだけ、サイドバーを本文と同じ高さまで伸ばすようにして解決しました。

.sidebar.has-toc { align-self: stretch; }
.toc--side {
	position: sticky;
	top: 84px; /* ヘッダーの高さ分 */
	max-height: calc(100vh - 110px);
	overflow-y: auto;
}

目次が長い記事でも画面からはみ出さないよう、max-height と overflow-y: auto で目次の中だけスクロールできるようにしています。

今読んでいる見出しをハイライト

スクロールするたびに、画面の上から30%の位置を超えた最後の見出しを「今読んでいる見出し」として、目次側の項目を光らせています。スクロールイベントは大量に発生するので、requestAnimationFrame で1フレームに1回だけ処理するようにしています。

let ticking = false;
window.addEventListener('scroll', () => {
	if (!ticking) {
		ticking = true;
		requestAnimationFrame(update);
	}
}, { passive: true });

画像の拡大表示は <dialog> 要素で

旧テーマではjQueryで拡大表示用のレイヤーを自作していましたが、今はHTML標準の <dialog> 要素でほぼ同じことができます。

<dialog class="lightbox">
	<button class="lightbox__close" type="button" aria-label="閉じる">×</button>
	<a class="lightbox__link" href="#" target="_blank"><img alt=""></a>
</dialog>

showModal() で開くと、背景の暗幕(::backdrop)、Escキーで閉じる、フォーカスの閉じ込めをブラウザが全部やってくれます。

.lightbox::backdrop {
	background: rgba(0, 0, 0, .85);
	backdrop-filter: blur(4px);
}

まとめ

10年前は、2カラムにするにも float と clearfix、画像の拡大表示にもjQueryのプラグインが必要でした。それが今では、CSS Grid・position: sticky・<dialog>・aspect-ratio など、ブラウザの標準機能でほとんど済みます。JSも依存ライブラリなしで100行ちょっとに収まりました。

文章のAIっぽさが抜けていないのでわかると思いますが、設計思想や改善点を明文化したかったこともありこの記事もほぼClaudeに書かせました。
元のテーマは一週間くらいかけて自作しましたが、今回のリメイクに当たって一言指示だけで元のデザインを取り入れつつそのまま使えるレベルの成果物を出してくれて、なかなかいい時代になったなという気持ちです。