受託開発におけるハイブリッドテーマ開発

はじめに

この記事では、受託開発において WordPress テーマを制作する時に、ハイブリッドテーマで構築するための設計・アプローチを提案するものです。

記事タイトルに「受託開発における」と含めた理由は、WordPress テーマディレクトリに公開したり、不特定多数に配布するようなテーマにおいては、この記事のアプローチが必ずしもベストな方法ではないと考えるからです。あくまでも、特定の WordPress サイト (特定のクライアント・エンドユーザー) 向けに最適化したテーマを制作するというシチュエーションにおいての話となります。

ハイブリッドテーマとは ?

まず、ハイブリッドテーマ含め、どのようなテーマの形態が存在しているかを、WP Tavern の記事 (Block, FSE, Hybrid, Universal? What Do We Call These New WordPress Themes?)をもとにまとめてみたいと思います。

自分の理解では、各テーマのざっくりとした定義は以下の通りとなります。

  1. ブロックテーマ: ブロックのみで構成された HTML テンプレートを持ち、サイトエディター、テンプレートエディター、グローバルスタイル UI などを利用出来る。
  2. クラシックテーマ: PHP ベースのテンプレートを持ち、サイトエディター、テンプレートエディター、グローバルスタイル UI などは利用出来ない (WordPress 6.1 からは、テンプレートパーツエディターは利用出来る)。
  3. ハイブリッドテーマ: クラシックテーマで、テンプレートエディターや theme.json など、ブロックテーマの1つ以上の機能を採用している。
  4. ユニバーサルテーマ: ブロックテーマの機能 (サイトエディター等)とクラシックテーマの機能 (ウィジェット・カスタマイザー等) の両方をサポートしているもの

そしてこの記事では、クラシックテーマに theme.json を取り入れたテーマを「ハイブリッドテーマ」とし、theme.json をベースとして設計していく、というアプローチについて解説していきます。

なぜハイブリッドテーマを採用するのか?

前述の4つのテーマ形態のうち、受託開発でテーマを開発する時、2つ目の「クラシックテーマ」がほぼ間違いなく採用されていると思います。

これ以外のテーマ形態を採用する事を考えた時、1つ目のブロックテーマについては、以下の理由からまだ受託開発に取り入れる事は難しいと考えています。

  • サイトエディター自体がまだ「ベータ」として位置づけられており、このベータ版ラベルを削除するために解決すべきタスクがいくつか残っているため
  • ナビゲーションブロックに関する issue がオープン状態で多数存在し、Tracking Issue でも多くのタスクが残っており、今後仕様が大きく変わっていく可能性があると考えるため
  • 自分の経験上、受託開発において仕様を満たすために、完全にブロックベースの HTML テンプレートで構成する事は難しいケースが多いと思われるため

4つ目のユニバーサルテーマについては、あくまでも「既存のクラシックテーマからブロックテーマに移行するために一時的に存在するためのテーマ」であり、新規テーマ開発においては、クラシック / ブロックテーマ双方の機能をサポートしなければいけないケースはほぼ存在しないと考えます。

クラシックテーマを除いて、残る選択肢がハイブリッドテーマとなりますが、これを採用する事でどのようなメリットがあるかは、ここでは具体的に触れません。以降の具体的な設計手法を見ていただき、採用する事のメリット・デメリットのどちらが大きいかを判断していただければと思います。

注意事項

この記事では、あくまでも「サイトのコンテンツ部分を theme.json でどのように設計するか」についてフォーカスしているため、一般的なクラシックテーマ開発 (主にテーマテンプレート開発) については触れていません。また、theme.json 、基本的なフック、CSS についての前提知識があるものとして話を進めています。

theme.json についてそもそも分からないという場合は、過去に書いたこちらの記事を参考にしていただければと思います。

また今回提案するアプローチは、WordPress6.1時点での仕様をもとに、受託開発の多くのケースにおいて有用であろうと自分が考えるものの一例です。プロジェクト個別の仕様・予算・人員等によって最適なアプロ―チは変わっていくでしょうし、WordPress のアップデートによってより便利な機能・サポートも追加されていくと思います。そもそもオリジナルテーマを作るべきかどうかを考える必要もあります。最終的にどのようなアプロ―チを取るかは、皆様自身で判断いただければと思います。

事前準備

実際に作業を進めていく前に、ベースとなるテーマを作成します。テンプレート、ヘッダー・フッターなどのコンテンツ外の事はこの記事では扱いませんので、以下のように index.php のみをテンプレートとして作成し、ヘッダー・フッター・ページタイトルには仮のスタイルを当てています。

/*
Theme Name: My Theme
*/
​
.site-header,
.site-footer {
    background-color: #ddd;
    text-align: center;
    padding: 1rem;
}
​
.site-main {
    padding-top: 3rem;
    padding-bottom: 3rem;
}
​
.page-title {
    text-align: center;
    font-size: 2rem;
    margin-top: 0;
    margin-bottom: 3rem;
}
<!doctype html>
<html <?php language_attributes(); ?>>
<head>
    <meta charset="<?php bloginfo( 'charset' ); ?>" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <?php wp_head(); ?>
</head>
​
<body <?php body_class(); ?>>
​
<header class="site-header">
    Site Header
</header>
​
<main class="site-main">
    <h1 class="page-title"><?php the_title(); ?></h1>
    <?php
    if ( have_posts() ) :
        while ( have_posts() ) :
            the_post();
            ?>
            <div class="entry-content">
                <?php the_content(); ?>
            </div>
        <?php endwhile; ?>
    <?php endif; ?>
</main>
​
<footer class="site-footer">
    Site Footer
</footer>
​
<?php wp_footer(); ?>
​
</body>
</html>
<?php
function my_theme_scripts() {
    wp_enqueue_style(
        'my-theme-style',
        get_template_directory_uri() . '/style.css',
        array(),
        wp_get_theme()->get( 'Version' )
    );
}
add_action( 'wp_enqueue_scripts', 'my_theme_scripts' );
​
function my_theme_setup() {
    add_theme_support( 'editor-styles' );
    add_editor_style( 'editor-style.css' );
}
add_action( 'after_setup_theme', 'my_theme_setup' );
/* 現時点では空 */
初期表示

コンテンツエリア全体の設計

theme.json の導入

まずは、ハイブリッドテーマですので theme.json をテーマに追加します。以下のように、JSON スキーマとAPI バージョンのみ指定します。

{
    "$schema": "https://schemas.wp.org/trunk/theme.json",
    "version": 2
}

コンテンツ幅またはブロック幅を指定していないフロントエンドはもちろんですが、エディター側でブロックが幅一杯に広がるようになった事が確認出来ます。

エディター側でブロックが幅一杯に広がるようになった

コンテンツ幅の検討

エディター側のブロック幅に適切なコンテンツ幅を持たせるために、まずは最適なコンテンツ幅は何pxかを検討します。

ここで問題になってくるのは、投稿タイプやページによってデフォルトのコンテンツ幅を変えたい場合です。例えば、投稿ページは幅960px、固定ページは幅1200px、特定のページ・セクションは幅1400pxなどです。そして theme.json で指定出来るデフォルトのコンテンツ幅は1つのみであり、エディター側のコンテンツ幅を投稿タイプ別に変更するには CSS を書くしかありません。

ここでは、エンドユーザー (クライアント) が頻繁に更新するのは通常の投稿であり、この投稿においてフロントエンドとエディター側のコンテンツ幅を一致させる事を優先させるため、投稿のコンテンツ幅 (960px)をデフォルトとします。固定ページは、コンテンツ幅 (1200px) を幅広とみなし、幅広に設定したグループブロックで各ブロックをラップすれば実現出来ます。また theme.json を導入すると、グループブロックの中のブロック幅を任意の値に変更出来るので、その他イレギュラーなコンテンツ幅を持つページについては、これで対応する事とします。

{
    "settings": {
        "layout": {
            "contentSize": "960px",
            "wideSize": "1200px"
        }
    }
}

以下のように、デフォルトのコンテンツ幅960pxに加え、1200px / 1400pxを再現出来ている事が分かります。イレギュラーな幅である1400pxについては、親のグループブロックを全幅にした上で、中のコンテンツ幅を指定している所がポイントです。

親のグループブロックを全幅にした上で、中のコンテンツ幅を指定している

ただしこれだけでは、もちろんフロントエンド側には反映されません。以下のように、明示的に幅を指定した1400px以外のブロックは親ブロック (.entry-content) の幅一杯に広がっています。

明示的に幅を指定した1400px以外のブロックは親ブロック (.entry-content) の幅一杯に広がっている

これを解決するために、コンテンツを全体をラップしている要素 (.entry-content) に幅を指定するのではなく、その直下のブロックに最大幅を指定する、というアプローチを取ります。このアプローチは、エディター側でコアが出力する CSS と基本的には一致しており、Twenty Twenty One でも同様のアプローチが取られています (実際には、より細かい指定が行われていますが)。

値の指定には、theme.jsonlayout を指定した事で自動出力されている --wp--style--global--content-size--wp--style--global--wide--size という CSS 変数を利用します。

/* 幅広・全幅ブロック以外にデフォルトコンテンツ幅を指定 */
.entry-content > *:not(.alignwide):not(.alignfull) {
    max-width: var(--wp--style--global--content-size);
}
​
/* 幅広ブロックに幅広コンテンツ幅を指定 */
.entry-content > *.alignwide {
    max-width: var(--wp--style--global--wide-size);
}
​
/* 全幅ブロックを除いて左右にオートマージンを付与する */
.entry-content > *:not(.alignfull) {
    margin-right: auto;
    margin-left: auto;
}

ポイントは、全幅ブロックにはスタイルを一切当てていないという事です。親要素 (.entry-content) には幅を指定していないので、子に max-width を指定しなければ結果的に全幅になる、という事です。

以下のように、各ブロックの幅がエディター側と一致した事を確認出来ます。さらに、theme.json を導入した事で、body タグの margin / padding が0にリセットされている事もポイントです。

theme.json を導入した事で、body タグの margin / padding が0にリセットされている

コンテンツの左右に余白を追加する

現状では、画面幅を狭めた時に、以下のようにコンテンツの左右に余白がありません。

コンテンツの左右に余白がない

そこで、theme.json でガター用の CSS 変数を定義し、それをまずはエディター側に適用します。

{
    "settings": {
        "custom": {
            "gutter": "1rem"
        }
    }
}
body {
	padding-left: var(--wp--custom--gutter);
	padding-right: var(--wp--custom--gutter);
}

ただし、全幅のブロックには左右にガターを持たせたくないため、同じ値を左右のマイナスマージンとして指定します。

.alignfull {
	margin-left: calc( var(--wp--custom--gutter) * -1);
	margin-right: calc( var(--wp--custom--gutter) * -1);
}

すると、全幅のブロックを除いて、左右に余白が出来ている事を確認出来ます。

全幅のブロックを除いて、左右に余白が出来ている

同様のアプローチを、フロントエンド側にも適用します。

.entry-content {
	padding-left: var(--wp--custom--gutter);
	padding-right: var(--wp--custom--gutter);
}

.entry-content > *.alignfull {
	margin-left: calc( var(--wp--custom--gutter) * -1);
	margin-right: calc( var(--wp--custom--gutter) * -1);
}
全幅のブロックを除いて、左右に余白が出来ている(フロントエンド)

なおブロックテーマにおいては、同じような事を実現するための useRootPaddingAwareAlignments というプロパティがあるので、興味がある方は調べてみて下さい。

ブロック間のマージン設定

現状は、ブロック間の上下マージンは定義されておらず、要素によってブラウザデフォルトのマージンが当たっているだけです。

要素によってブラウザデフォルトのマージンが当たっている

ここで、ブロック間に統一したマージンを持たせるため、theme.json のルートレベルで blockGap をオプトインし、ブロック間のマージンを 1.5rem に変更してみます。

{
	"settings": {
		"spacing": {
			"blockGap": true
		}
	},
	"styles": {
		"spacing": {
			"blockGap": "1.5rem"
		}
	}
}

見やすいように背景色を付けていますが、以下のルールでマージンが適用されている事が分かります。

  • is-layout-flow 直下の要素の上下マージンを0にする
  • is-layout-flow 直下の初めの要素を除いて、上マージンに blockGap の値を適用する
ルールに基づいてマージンが適用されている

さらに、グループブロックのバリエーションである「行」ブロックやカラムブロック等のような Flex レイアウトで、gap でブロック間の余白を取っているブロックについても同じ値が使われているため、縦横で統一した余白を確保出来る事が分かります。

gap でブロック間の余白を取っているブロックについても同じ値が使われている

ですがフロントエンド側では、.entry-content 直下の要素の上下マージンに、theme.json で指定した blockGap が適用されていない事が分かります。

.entry-content 直下の要素の上下マージンに、theme.json で指定した blockGap が適用されていない

これは、エディターと同様の構造を再現するために、.entry-content.is-layout-flow クラスを付与する事で解消出来ます。

<div class="entry-content is-layout-flow">
	<?php the_content(); ?>
</div>
.entry-content に .is-layout-flow クラスを付与する事で解消

ブロック個別のマージン設定

よくあるケースとして、「特定のブロックだけ上下マージンをデフォルト値から変えたい」というものです。

例えば、見出し (h2)のデフォルトの上マージンだけを 1.5rem から 3rem に変えるために、以下のようなスタイルを定義したとします。

{
	"styles": {
		"elements": {
			"h2": {
				"spacing": {
					"margin": {
						"top": "3rem"
					}
				}
			}
		}
	}
}

残念ながら上記スタイルは、ブロック間のデフォルトマージンにより出力されるセレクタより詳細度が低いため適用されません。

見出し (h2)のデフォルトの上マージンが適用されない

この問題は、Gutenberg プロジェクトでも issue として報告されています。

ここで考えられるアプローチは、以下4つです。

  1. ルートレベルでのマージン (blockGap) を無効化し、ブロック間のマージンは自前の CSS でコントロールする
  2. ルートレベルでのマージン (blockGap) を無効化し、ブロック間のマージンは theme.json で全てブロック個別に定義する
  3. ブロックサポートのマージンを使って、コンテンツの中で個別に上書きする
  4. より詳細度の高いセレクタを使った CSS で上書きする

1つ目の方法は、blockGap の定義が出力するスタイルを CSS で全て定義する事が大変である事や、また同様のレイアウトを実現するためには、結局そこまで詳細度を低くする事が出来ないという問題があります。

2つ目の方法は、全てのブロックにマージンを設定する事が大変である事はもちろんですが、今後新しいブロックが増えるたびにマージンの定義を増やさなければならない、という手間が発生します。

3つ目の方法は、投稿を作成する度に必要なブロックに対して全て個別にマージンを設定する必要があり、現実的ではありません。

そのため、4つ目の方法の「より詳細度の高いセレクタを使った CSS で上書きする」を採用します。

今回は以下のように、.is-layout-flow 直下の最初の要素を除いて、見出しの上マージンを 3rem で 上書きします。

.is-layout-flow > * + .wp-block-heading {
	margin-top: 3rem;
}
.is-layout-flow > * + h1,
.is-layout-flow > * + h2,
.is-layout-flow > * + h3,
.is-layout-flow > * + h4,
.is-layout-flow > * + h5,
.is-layout-flow > * + h6 {
	margin-top: 3rem;
}

ブロックサポートの設計

以下の記事に見られるように、各ブロックのデザインツールの一貫性を高めるために、ブロックサポートがどんどん追加されており、WordPress 6.2ではさらに多くのサポートが追加される予定です。

これらのブロックサポートを、そのサイトの要件に合わせて theme.json でコントロールするにあたり、考えられるアプローチは以下のどちらかです。

  • settings.appearanceTools で全てのブロックサポートを一括オプトインし、ルートレベルまたはブロックレベルで不要なものは個別にオプトアウトする
  • settings.appearanceToolsは使用せず、必要なブロックサポートのみをルートレベルまたはブロックレベルでオプトイン / オプトアウトしていく

不特定多数のユーザー向けに公開したり汎用性を高めるのであれば、利用出来るブロックサポートは出来る限り提供したいはずなので、1つ目のアプローチが最適だと思います。逆に、受託開発における個別最適化されたテーマでは、「コンテンツを構築するにあたり、使用する可能性があるブロックサポートのみを UI として表示させる」という方針とし、2つ目のアプローチを採用します。

今回はまず、ルートレベルで以下の対応を行う事とします。

  • マージン・パディングは、特に固定ページでのレイアウトにおいて重要であるためオプトインする
  • タイポグラフィパネルでは line-height をオプトインし、その他使用される可能性が低いサポートはオプトアウトする
{
	"settings": {
		"spacing": {
			"margin": true,
			"padding": true
		},
		"typography": {
			"lineHeight": true,
			"letterSpacing": false,
			"textDecoration": false,
			"textTransform": false,
			"dropCap": false,
			"fontStyle": false
		}
	}
}

これにより、例えば段落ブロックであれば、サイドバーの UI は以下のように変わります (パネルは全て開いた状態に変更)。

サイドバーの UIの変化

これに加えて、サイトの要件にあわせてルートレベルでのオプトイン・オプトアウト、settings.blocks.core.XXXX プロパティを利用したブロックレベルでのオプトイン・オプトアウトを行っていきます。

具体的には、以下のような例です。

  • 見出しブロックだけは letter-spacing をサポートしたいため、settings.blocks.core/heading.typography.letterSpacingtrue にする
  • カラムブロック間の余白は変更してほしくないため、settings.blocks.core/columns.spacing.blockGapfalse にする
  • グループブロックは全てのブロックサポートをオプトインしたいため、settings.blocks.core/group.appearanceToolstrue にする

ポイントは、settings.appearanceTools で一括オプトインしていなかったとしても、各プロパティはデフォルトで true のものもあるため、明示的に false を指定してオプトアウトしなければならないプロパティもある、という点です。

タイポグラフィ

フォントサイズのバリエーション

まず、必要なバリエーションを以下2つのレイヤーに分類します。

  1. エディター側で設定出来るべきバリエーション
  2. エディター側で設定出来なくてもよいが、テーマを構成する上で必要なバリエーション

1つ目は、いわるゆる PHP で行っている add_theme_support( 'editor-font-sizes', array() )theme.json 上で定義するという事になります。2つ目は CSS 変数として定義し、theme.json の他のプロパティや、PHP ページテンプレートをスタイリングする時の CSS から参照出来るようにします。

1つ目のエディター側のバリエーション設計の参考として、Twenty Twenty Two の theme.json を見てみます。エディター側では small / medium / large / x-large の4種類が、CSS 変数としてはより大きいサイズとして huge / gigantic / colossal の3種類が定義されています。

Twenty Twenty Three では、全てのフォントサイズバリエーションがエディター側で使用出来るものとして定義されており、さらに WordPress6.1からは導入された Fluid Typography が使用されています。

もう1つ重要な事は、エディター側で設定出来るバリエーションが5個以下であった場合、プルダウンではなくボタングループとして表示され、WordPress6.1からは表示ラベルがSサイズ始まり~XXLまでのTシャツサイズに固定される、という点です。

そのため、例えばフォントサイズのバリエーションの最小サイズのスラッグを xs とし、かつバリエーション数が5つ以下の場合、以下のようにラベルが食い違ってしまいます。

フォントサイズラベルの食い違い

バリエーションが6個以上の場合は、以下のようにプルダウンとして表示され、ラベルの食い違いも発生しません。

プルダウンとして表示される

ここまでを踏まえて、以下の観点から最適なバリエーションを探っていきます。

  • 使用したいフォントサイズバリエーションはいくつあるか
  • それらのうち、エディター側で設定出来るべきバリエーションはいくつあるか
  • ボタングループ形式・ドロップダウン形式のどちらの UI を使用したいか
  • Fluid Typography を利用するかどうか

今回は、以下のようなアプローチを取ってみます。

  • フォントサイズ選択の UI はボタングループ形式を維持したいため、エディター側のバリエーションは5つまでとする。
  • Twenty Twenty Three と Twenty Twenty Two の設計をミックスし、エディター側のフォントサイズは全て Fluid Typography で定義し、それ以外のバリエーションは CSS 変数として定義する
  • フォントサイズバリエーションから生成された CSS 変数を使って、そのまま各見出しレベルにも適用できるようにする
レイヤースラッグサイズ (min ~ max)対応する見出し
エディターsmall0.875rem (14px) ~ 1rem (16px)
エディターmedium1rem (16px) ~ 1.125rem (18px)H5 / H6 (サイトデフォルトサイズ)
エディターlarge1.125rem (18px) ~ 1.5rem (24px)H4
エディターx-large1.25rem (20px) ~ 1.875rem (30px)H3
エディターxx-large1.5rem (24px) ~ 2.25rem (36px)
CSS 変数huge2rem (32px) ~ 3rem (48px)H2
CSS 変数gigantic2.5rem (40px) ~ 3.75rem (60px)H1
{
	"settings": {
		"typography": {
			"fluid": true,
			"fontSizes": [
				{
					"fluid": {
						"min": "0.875rem",
						"max": "1rem"
					},
					"size": "1rem",
					"slug": "small"
				},
				{
					"fluid": {
						"min": "1rem",
						"max": "1.125rem"
					},
					"size": "1.125rem",
					"slug": "medium"
				},
				{
					"fluid": {
						"min": "1.125rem",
						"max": "1.25rem"
					},
					"size": "1.25rem",
					"slug": "large"
				},
				{
					"fluid": {
						"min": "1.25rem",
						"max": "1.5rem"
					},
					"size": "1.5rem",
					"slug": "x-large"
				},
				{
					"fluid": {
						"min": "1.375rem",
						"max": "1.75rem"
					},
					"size": "1.75rem",
					"slug": "xx-large"
				}
			]
		},
		"custom": {
			"font-size": {
				"huge": "clamp(1.5rem, 2vw + 1rem, 2rem);",
				"gigantic": "clamp(1.75rem, 3vw + 1rem, 2.5rem);"
			}
		}
	}
}

CSS 変数で定義するフォントサイズについては Fluid Typography の機能は利用出来ませんので、以下のようなサイトを使用して、適切な clamp 関数を生成します。

全てのバリエーションを仮に適用した場合、画面幅によって以下のように伸縮します。

画面幅によってフォントサイズが伸縮

フォントサイズ

生成された CSS 変数から、サイト全体のフォントサイズと、各見出しのフォントサイズを定義します。サイト全体のフォントサイズは medium とし、 h4 から h1 にかけては段階的にサイズを大きくしていきます。

{
	"styles": {
		"typography": {
			"fontSize": "var(--wp--preset--font-size--medium)"
		},
		"elements": {
			"h1": {
				"typography": {
					"fontSize": "var(--wp--custom--font-size--gigantic)"
				}
			},
			"h2": {
				"typography": {
					"fontSize": "var(--wp--preset--font-size--huge)"
				}
			},
			"h3": {
				"typography": {
					"fontSize": "var(--wp--preset--font-size--x-large)"
				}
			},
			"h4": {
				"typography": {
					"fontSize": "var(--wp--preset--font-size--large)"
				}
			},
			"h5": {
				"typography": {
					"fontSize": "var(--wp--preset--font-size--medium)"
				}
			},
			"h6": {
				"typography": {
					"fontSize": "var(--wp--preset--font-size--medium)"
				}
			}
		}
	}
}
生成された CSS 変数から、サイト全体のフォントサイズと、各見出しのフォントサイズを定義

line-height

Twenty Twenty Three では必要なプロパティに値が直書きされており、Twenty Twenty Two では CSS 変数を定義しているという違いがあります。

ここでは Twenty Twenty Two のアプローチを採用し、サイト全体の値と、大きい見出しレベル向けに少し小さい値の2つを CSS 変数として定義し、適用します。

{
	"settings": {
		"custom": {
			"line-height": {
				"small": 1.4,
				"normal": 1.6
			}
		}
	},
	"styles": {
		"typography": {
			"lineHeight": "var(--wp--custom--line-height--normal)"
		},
		"elements": {
			"h1": {
				"typography": {
					"lineHeight": "var(--wp--custom--line-height--small)"
				}
			},
			"h2": {
				"typography": {
				"lineHeight": "var(--wp--custom--line-height--small)"
				}
			},
			"h3": {
				"typography": {
					"lineHeight": "var(--wp--custom--line-height--small)"
				}
			}
		}
	}
}

フォントファミリー

ここでは、サイト全体のフォントを Noto Sans JP Regular とし、見出し等のブラウザデフォルトで font-weight: bold; である要素には Noto Sans JP Bold を適用してみます。

Google Fonts 等のプロバイダが提供しているフォントの URL を直接指定しても良いのですが、Twenty Twenty ThreeTwenty Twenty Two が行っているように、ローカルでホストしたものを読み込みます (ローカルでホストする理由として GDPR の問題もあると思いますが、現状どのような結論に至っているのかは私は把握出来ていません)。さらに容量を減らすために、Kite さん (@ixkaito) が公開されている「Noto Sans JP サブセット」から、必要なフォントファイルをテーマにバンドルして読み込みます。

FOIT(Flash of Invisible Text)と FOUT(Flash of Unstyled Text)についてどのように対処するかが問題ですが、ここでは Twenty Twenty Three にならい、font-display: block; とします。

{
	"settings": {
		"typography": {
			"fontFamilies": [
				{
					"fontFace": [
						{
							"fontDisplay": "block",
							"fontFamily": "Noto Sans JP",
							"fontStretch": "normal",
							"fontStyle": "normal",
							"fontWeight": "400",
							"src": [ "file:./assets/font/NotoSansJP-Regular.woff2" ]
						},
						{
							"fontDisplay": "block",
							"fontFamily": "Noto Sans JP",
							"fontStretch": "normal",
							"fontStyle": "normal",
							"fontWeight": "700",
							"src": [ "file:./assets/font/NotoSansJP-Bold.woff2" ]
						}
					],
					"fontFamily": "Noto Sans JP",
					"name": "Noto Sans JP",
					"slug": "noto-sans-jp"
				}
			]
		}
	}
}
フォントファミリーの適用

カラー

カラーパレットのバリエーション

フォントサイズのバリエーションと同様、必要なバリエーションを以下2つのレイヤーに分類します。

  1. エディター側で設定出来るべきバリエーション
  2. エディター側で設定出来なくてもよいが、デザイン上必要なバリエーション

ここでは1つ目のバリエーションとして、Twenty Twenty Three の1つのグローバルスタイル (Marigold) の定義をそのまま使用します。CSS 変数として定義するバリエーションは、ページテンプレート上のボーダー・背景等のスタイリングのために3種類のグレーカラーが必要であるとして、それらを定義します。また、デフォルトパレットはコンテンツ作成時に使用する事がないとし、オプトアウトします。そして、定義したカラーバリエーションのうち2つを、ルートレベルのテキストカラー・背景色に指定します。

{
	"settings": {
		"color": {
			"defaultPalette": false,
			"palette": [
				{
					"color": "#F6F2EC",
					"name": "Base",
					"slug": "base"
				},
				{
					"color": "#21251F",
					"name": "Contrast",
					"slug": "contrast"
				},
				{
					"color": "#5B4460",
					"name": "Primary",
					"slug": "primary"
				},
				{
					"color": "#FCC263",
					"name": "Secondary",
					"slug": "secondary"
				},
				{
					"color": "#E7A1A9",
					"name": "Tertiary",
					"slug": "tertiary"
				}
			]
		},
		"custom": {
			"color": {
				"gray-light": "#cccccc",
				"gray": "#999999",
				"gray-dark": "#666666"
			}
		}
    },
	"styles": {
		"color": {
			"background": "var(--wp--preset--color--base)",
			"text": "var(--wp--preset--color--contrast)"
		}
	}
}

エディター側のカラーパレットは、以下のように変わります。

エディター側のカラーパレットの変化

これに加えて、必要に応じてグラデーションパレットやデュオトーンパレットを定義します。Twenty Twenty Two が行っているように、カラーパレットの定義から生成された CSS 変数の組み合わせをバリエーションとして用意するのも良いと思います。

スペーシングプリセット

ここで言う「スペーシング」とは、パディング・マージン・ブロックギャップの3つの事を指します。theme.json でいずれかのサポートをオプトインしている場合、そのサポートに対応したブロックのサイドバーに UI が表示されます。

サイドバーUI

WordPress 6.0までは数値を直接入力出来るだけでしたが、WordPress6.1からは、上記のように目盛り付きのスライダーとして表示されます。

theme.json でこのプリセットを定義していない場合、ゼロ値を除いて、デフォルトでは以下7つのバリエーションが CSS 変数として出力され、スライダーを操作した時に対応する CSS 変数の値がスタイルとして出力されます。

  1. --wp--preset--spacing--20: 0.44rem;
  2. --wp--preset--spacing--30: 0.67rem;
  3. --wp--preset--spacing--40: 1rem;
  4. --wp--preset--spacing--50: 1.5rem;
  5. --wp--preset--spacing--60: 2.25rem;
  6. --wp--preset--spacing--70: 3.38rem;
  7. --wp--preset--spacing--80: 5.06rem;

また、バリエーション数が8以上の時は、スライダー形式ではなくプルダウンとして表示されます。

例えば、テーマで余白の取り方を 0.5rem の倍数で統一したいので、 0.5rem はじまり、0.5rem 刻みでバリエーションを定義するとします。このスライダー UI を活かしたいとした場合、設定出来る最大数は7つまでなので、0.5rem3.5rem までの範囲をスライダーでカバー出来る事になります。

このように、テーマの余白ルールにあわせたバリエーションを定義する事で、わざわざ数値を手入力しなくても、マウス等でスライダーを操作するだけで、ルールにあった余白を適用する事が出来ます。

ですが、スライダーで同じ値を適用したとしても、モバイルレイアウトではより小さい値を適用したい、というケースが考えられます。

ここではもう一歩進んで、「値には、固定値だけでなく clamp 関数なども使用出来る」という仕様を活かして、画面幅によって伸縮する「レスポンシブスペーシング」として定義してみます。

このアプローチは、Twenty Twenty Three でも見る事が出来ます。

伸縮率をどう設定するかはそのテーマの設計によりますが、ここでは単純に、「デスクトップレイアウトに対して、モバイルレイアウトでは半分のサイズにする」というルールを採用します。そして、より広い範囲をスライダーでカバーできるようにするために、以下の2つのルールを加えてみます。

  • 0.5rem はモバイルで半分にならなくてもよいため、バリエーションから除外する (スライダーを使わず数値を手入力する)
  • 値が大きくなるにつれ、刻む値も大きくしていく

以上のルールから生成されるバリエーションの一例は、以下となります。

  1. 1rem
  2. 1.5rem
  3. 2rem
  4. 3rem
  5. 4rem
  6. 6rem
  7. 8rem

この値を clamp 関数で半分にするために、今回は Fluid Typography Calculator を使い、Min Viewport は 375px、Max Viewport は 960px として値を生成しました。最終的に、theme.json での定義は以下となります。

{
	"settings": {
		"spacing": {
			"spacingScale": {
				"steps": 0
			},
			"spacingSizes": [
				{
					"size": "clamp(0.5rem, 0.2727272727272727rem + 0.9696969696969697vw, 1rem)",
					"slug": "20",
					"name": "2"
				},
				{
					"size": "clamp(0.75rem, 0.4090909090909091rem + 1.4545454545454546vw, 1.5rem)",
					"slug": "30",
					"name": "3"
				},
				{
					"size": "clamp(1rem, 0.5454545454545454rem + 1.9393939393939394vw, 2rem)",
					"slug": "40",
					"name": "4"
				},
				{
					"size": "clamp(1.5rem, 0.8181818181818182rem + 2.909090909090909vw, 3rem)",
					"slug": "50",
					"name": "5"
				},
				{
					"size": "clamp(2rem, 1.0909090909090908rem + 3.878787878787879vw, 4rem)",
					"slug": "60",
					"name": "6"
				},
				{
					"size": "clamp(3rem, 1.6363636363636365rem + 5.818181818181818vw, 6rem)",
					"slug": "70",
					"name": "7"
				},
				{
					"size": "clamp(4rem, 2.1818181818181817rem + 7.757575757575758vw, 8rem)",
					"slug": "80",
					"name": "8"
				}
			]
		}
	}
}

折返しを無効にしたカラムブロックのギャップに、上記スペーシングプリセットをそれぞれ適用した場合、以下のように画面幅により余白が伸縮します。

画面幅により余白が伸縮

settings.spacing.spacingScale.steps をゼロにしているのは、spacingSizes で定義している以外のデフォルトの CSS 変数 (--wp--preset--spacing--{20~80}) を出力させないためです (上記の定義の場合は、全てのデフォルト CSS 変数7つを同じスラッグで上書きしているので問題ありません) 。

今回定義したバリエーションはあくまで一例ですが、デザインルールにあわせて、刻み方を変える・伸縮率を変えるなど、様々な設計が考えられると思います。

要素レベル・ブロックレベルのスタイリング

ここまでで、主にサイト全体の設計とスタイリングを行いましたが、このセクションでは、要素レベルのスタイリング例としてリンク要素を、ブロックレベルでのスタイリング例として見出しブロック・ボタンブロックを挙げてみたいと思います。

リンク要素のスタイリング

ここでは、デフォルトのテキストカラーのみ変更していますが、Twenty Twenty Three のように、疑似クラスにもスタイルを当てても良いと思います。

{
	"styles": {
		"elements": {
			"link": {
				"color": {
					"text": "var(--wp--preset--color--secondary)"
				}
			}
		}
	}
}
リンク要素のスタイリング

なおこの要素スタイルは、コンテンツ外のリンク要素にも適用されるため、それらについては必要に応じて CSS で上書きしていきます (ブロックテーマであれば、ページは全てブロックで構成されているため、theme.json だけで上書き出来ます)。

見出しブロックのスタイリング

見出しブロックには、見出しレベルに応じたフォントサイズは既に適用されていますが、それ以外のスタイルを加えてみます。

スタイリングする見出しは h2 / h3 / h4 とし、それぞれ以下のような方針を取ってみます。

  • h2: 背景色をプライマリカラーとして持ち、テキストカラーをベースカラーにする
  • h3: プライマリカラーで下ボーダーを引く
  • h4: テキストカラーにプライマリカラーを適用する

全てのカラーの値には、これまでの theme.json 上での定義から出力された CSS 変数を使用します。パディングには、「スペーシングプリセット」の定義から出力された CSS 変数を使っても良いと思います。

{
	"styles": {
		"elements": {
			"h2": {
				"color": {
					"text": "var(--wp--preset--color--base)",
					"background": "var(--wp--preset--color--primary)"
				},
				"spacing": {
					"padding": {
						"left": "0.5em",
						"right": "0.5em",
						"top": "0.25em",
						"bottom": "0.25em"
					}
				}
			},
			"h3": {
				"border": {
					"bottom": {
						"color": "var(--wp--preset--color--primary)",
						"width": "4px",
						"style": "solid"
					}
				},
				"spacing": {
					"padding": {
						"bottom": "0.25em"
					}
				}
			},
			"h4": {
				"color": {
					"text": "var(--wp--preset--color--primary)"
				}
			}
		}
	}
}
見出しブロックのスタイリング

ボタンブロックのスタイリング

アプローチは見出しブロックと同じですが、WordPress6.1 から導入された疑似クラスシャドウも適用してみます。さらに、ボタン間の余白を blockGap で少し狭めてみます。リンク要素と同様に、疑似クラスごとのスタイルを当てても良いと思います。

{
	"styles": {
		"blocks": {
			"core/buttons": {
				"spacing": {
					"blockGap": "1rem"
				}
			},
			"core/button": {
				"color": {
					"text": "var(--wp--preset--color--contrast)",
					"background": "var(--wp--preset--color--tertiary)"
				},
				"border": {
					"radius": "8px"
				},
				"typography": {
					"lineHeight": "var(--wp--custom--line-height--small)"
				},
				"shadow": "2px 2px 4px var(--wp--preset--color--primary)"
			}
		}
	}
}
ボタンブロックのスタイリング

セレクタのネスト

例えば、「特定のブロック (要素) の中の特定のブロック (要素) にスタイルを当てたい」というケースですが、考えられる4つの組み合わせのうち、利用できるのは 「ブロック > 要素」のネストのみです。

(可) グループブロックの中のリンク要素にスタイルを当てる:

{
	"styles": {
		"blocks": {
			"core/group": {
				"elements": {
					"link": {}
				}
			}
		}
	}
}

(不可) 特定のブロックの中の特定のブロックにスタイルを当てる:

{
	"styles": {
		"blocks": {
			"core/group": {
				"blocks": {
					"core/paragraph": {}
				}
			}
		}
	}
}

(不可) 特定の要素の中の特定の要素にスタイルを当てる:

{
	"styles": {
		"elements": {
			"heading": {
				"elements": {
					"link": {}
				}
			}
		}
	}
}

(不可) 特定の要素の中の特定のブロックにスタイルを当てる (この順番のネストが必要なケースは無いはずですが):

{
	"styles": {
		"elements": {
			"link": {
				"blocks": {
					"core/paragraph": {}
				}
			}
		}
	}
}

追加アプローチ

ここまでは、theme.json を中心としたコンテンツエリアの設計の話がメインでしたが、このセクションでは、もう少し踏み込んだいくつかのアプローチを紹介します。いくつかの項目は、theme.json を持たない従来のクラシックテーマ開発でも既に行われていたり、応用出来るものだと思います。

box-sizing の適用

エディター側・フロントエンド側とも、ブロックの box-sizing は指定されていない (=初期値の content-box) ため、 paddingborder を持つブロックの場合、要素の幅が他のブロックと揃いません。

box-sizing の適用

これは意図的なものであり、ブロックのうち .wp-block- ベースのクラス名を持たない要素に対しては、box-sizing: border-box は付与されません。一方、.wp-block- ベースのクラス名を持つブロックに関しては、スペーシングサポートが追加されると同時に、box-sizing: border-box がブロック単位で追加される事になっています。

多くの場合、リセット CSS として全ての要素にbox-sizing: border-box を適用する事が多いと思いますが、このプロパティは theme.json では設定出来ないため、フロントエンド・エディター側とも CSS で指定します。

フロントエンド側では、多くのケースではサイト全体に適用したいと思うので、適用範囲は.entry-content に限っていません。

*,
*::before,
*::after {
	box-sizing: border-box;
}

エディター側では、ブロックのみに限っています。

.wp-block {
	box-sizing: border-box;
}

Float レイアウトの問題

コンテンツ幅の検討」に記載した通り、今回の設計では、コンテンツを全体をラップしている要素 (.entry-content) に幅を指定するのではなく、その直下のブロックに最大幅を指定する、というアプローチを取っています。

ここで問題となるのが、ブロックを左寄せまたは右寄せした場合、つまり float: {left|right} が適用される時です。

例えば画像を左寄せにすると、画面幅が大きい時に、ブロックが期待するコンテンツ幅から飛び出してしまいます。

Float レイアウトの問題

これは、今回のテーマ設計に限ったものではなく、ブロックテーマである Twenty Twenty Three や Twenty Twenty Two でも発生します。

この問題の根本的な背景として、theme.json を持つテーマと持たないテーマ では、左右寄せした時にブロックのマークアップが異なるという仕様に起因しています。

例えば画像ブロックの場合、マークアップが以下のように異なります (説明に不要な要素・属性は簡略化しています)。

<!--
フロントエンド
-->
    
<!-- theme.json を持つテーマ -->
<figure class="wp-block-image alignleft">
	<img src="" class="wp-image-XXX">
</figure>
    
<!-- theme.json を持たないテーマ -->
<div class="wp-block-image">
	<figure class="alignleft">
		<img src="" class="wp-image-XXX">
	</figure>
</div>

<!--
エディター
-->

<!-- theme.json を持つテーマ -->
<figure class="alignleft wp-block-image" data-type="core/image">
	<div class="components-resizable-box__container">
		<img src="">
	<div>
	<figcaption class="wp-element-caption"></figcaption>
</figure>

<!-- theme.json を持たないテーマ -->
<div class="wp-block" data-align="left">
	<figure class="wp-block-image" data-type="core/image">
		<div class="components-resizable-box__container">
			<img src="">
		</div>
		<figcaption class="wp-element-caption"></figcaption>
	</figure>
</div>

theme.json を持たないテーマでは、ブロックが div タグで囲まれており、その div タグがコンテンツ幅を持っているため、問題が起こらないという事になります。同じコンテンツ設計を持っている Twenty Twenty One でこの問題が発生しないのは、そのためです。

調べた限りでは、従来のコンテンツ幅を維持した float レイアウトを実現したい場合は、左 / 右寄せした要素と回り込ませたい要素をグループブロックで囲む、という方法を取るしかなさそうです。

左 / 右寄せした要素と回り込ませたい要素をグループブロックで囲む

CSS の追加

ここまでの内容で、見出しブロックの上マージンを除いて、ブロック個別の CSS は書いていません。ブロックのレイアウトやスタイルは、 theme.json やブロックサポートで出来る限り実現すべきだという考えに基づいているからです。

ですが現実的には、theme.json やブロックサポートでは対応出来ない箇所も多々あり、それらをカバーするために CSS を書く必要が出てきます。

例えば「メディアとテキスト」ブロックであれば、以下のような要望は、例えブロックテーマであったとしても CSS を書かない限り実現出来ません。

  • テキストコンテンツ部分の左右 padding (8%) を変更したい
  • 子ブロックの上下マージンがブラウザデフォルトのマージンで取られているので、theme.json で定義した blockGap を反映させたい
「メディアとテキスト」ブロックにCSSを追加

この2つを解決するためのコード例は以下となります。

/* メディアとテキスト間の余白を、theme.json で指定した blockGap の値にする */
.wp-block-media-text {
	gap: var(--wp--style--block-gap);
}

/* テキストコンテンツ部分のデフォルトの左右 padding を打ち消す */
.wp-block-media-text .wp-block-media-text__content {
	padding-left: 0;
	padding-right: 0;
}

/* テキストコンテンツ直下のブロック上下マージンをリセットする */
.wp-block-media-text .wp-block-media-text__content > * {
	margin-block-start: 0;
	margin-block-end: 0;
}

/* テキストコンテンツ直下の最初のブロックを除いて、上マージンを付与する */
.wp-block-media-text .wp-block-media-text__content > * + * {
	margin-block-start: var(--wp--style--block-gap);
	margin-block-end: 0;
}

このブロック以外に、 レイアウト・スタイルをコントロールするために CSS を書くべき可能性のあるケースは多数ありますが、以下がその例です。多くの場合は、ブロックの中に要素が複数あり、それらを theme.json やブロックサポートではコントロール出来ない場合です。

  • 「テーブル」ブロックのセルのボーダー色・テキスト左右揃え・パディングが変更出来ない
  • 「プルクオート」ブロックの中の blockQuote 要素にデフォルトマージンが当たっている
  • 「最近のコメント」ブロックの中の各要素
  • 「カバー」ブロックの子ブロック間の余白

ただし、これらのスタイリングはこれまでのクラシックテーマでも当然のように行われてきたはずですので、これらはプロジェクト個別の要件に合わせて対応していけばよいと思います。

ブロックスタイルの追加

こちらもハイブリッドテーマに限ったものではありませんが、例えば「下ボーダーを持つ h3 要素を、固定ページではフラットな状態で使いたい」というケースです。

「見出しブロックのスタイリング」で対応した通り、この下ボーダーはtheme.json で定義したものですが、見出しブロック自体は現在ボーダーをサポートしていないため、ブロックサイドバーから打ち消す事が出来ません。

ブロックスタイルの追加

また、ブロックサイドバーから全てのスタイルをコントロール出来たとしても、多くのデフォルトスタイルをブロック単位で毎回打ち消していくのは手間がかかります。

そこで、theme.json で定義したスタイルをリセットしたいブロックに限っては、フラットな状態であるブロックスタイルを定義します。

function my_theme_register_block_styles() {
	register_block_style(
		'core/heading',
		array(
			'name'  => 'flat',
			'label' => esc_html__( 'Flat', 'mytheme' ),
		)
	);
}
add_action( 'init', 'my_theme_register_block_styles' );
h3.is-style-flat {
	padding: 0;
	border-bottom: none;
}
フラットな状態であるブロックスタイルを定義

このブロックスタイルは、あくまでも theme.json で定義したブロックのデフォルトスタイルを打ち消すものですが、もちろん従来のアプローチで行われているように、ブロックのスタイル・外観にバリエーションを持たせるためのブロックスタイルも必要に応じて追加していきます。

theme.json で定義したスタイルをコンテンツ外に適用する

これまでに指定した要素レベル (見出し、リンク) のスタイルは、セレクタも要素名であるため、コンテンツ外の同じ要素にも適用されます。ここでは、button 要素も theme.json 上でスタイルを定義出来る事を利用して、ボタンブロックに対して適用したスタイルを、コンテンツ外のボタンブロックではない button 要素にも適用してみます。

まず、styles.blocks.core/button の定義を、styles.elements.button に移します。

{
	"styles": {
		"elements": {
			"button": {
				"color": {
					"text": "var(--wp--preset--color--contrast)",
					"background": "var(--wp--preset--color--tertiary)"
				},
				"border": {
					"radius": "8px"
				},
				"typography": {
					"lineHeight": "var(--wp--custom--line-height--small)"
				},
				"shadow": "2px 2px 4px var(--wp--preset--color--primary)"
			}
		}
	}
}

そして、この定義から出力されているスタイルのセレクタに .wp-element-button が使われている事を利用して、コンテンツ外のヘッダーにこのクラスを持った button 要素を追加してみます。

<header class="site-header">
	Site Header
	<button class="wp-element-button">Push Me</button>
</header>

theme.json で定義したボタンスタイルが、ヘッダーに設置したボタンにも適用されている事が確認出来ます。

theme.json で定義したボタンスタイルが、ヘッダーに設置したボタンにも適用されている

このように、コンテンツ内のブロック (要素) とコンテンツ外の要素に、theme.json を介して共通のスタイルを適用する事が出来ます。

WordPress6.1において、指定出来る要素と生成されるセレクタの一覧は以下です。

  • button: .wp-element-button, .wp-block-button__link
  • caption: .wp-element-caption, .wp-block-XXX figcaption
  • cite: cite
  • heading: h1, h2, h3, h4, h5, h6

テンプレートパーツエディターの導入

WordPress6.1で導入された、クラシックテーマでも利用できるテンプレートパーツエディターを使って、コンテンツ外の任意のエリアをブロックエディターで編集出来るようにする、というアプローチです。

同様の事は、ウィジェットブロックエディターを利用すれば実現出来ますが、ウィジェットブロックエディターと比べると以下4つのメリットがあると考えます。

  • デフォルトコンテンツをテーマ側で定義出来る
  • コンテンツを編集した後に、テーマ側で定義したデフォルトコンテンツにリセット出来る
  • コードエディターを使用出来る
  • テンプレートパーツ毎にエディターインスタンスが分かれている

ここでは、以下のような要件を実現するために、テンプレートパーツエディターを導入するとします。

  • 投稿のコンテンツ下部に共通の Call to Action エリアを設けたい
  • 時期により、テキストやリンク先を変更出来るようにしたい

まずは、テンプレートパーツエディター機能をオプトインします。

function my_theme_setup() {
	add_theme_support( 'block-template-parts' );
}
add_action( 'after_setup_theme', 'my_theme_setup' );

機能をオプトインするだけでテンプレートパーツメニューにはアクセスできますが、テンプレートパーツが見つからないと表示されるだけで、ブロックテーマのように新しくテンプレートパーツを追加する事も出来ません。

テンプレートパーツメニュー

これは、クラシックテーマで例え管理画面上でテンプレートパーツを新規追加出来たとしても、そのテンプレートパーツをフロントエンドで表示させるためには、結局 PHP テンプレート内で呼び出すコードを明示的に書く必要があるからです。つまり、あらかじめテーマが持っているテンプレートパーツの編集のみを行えるという事になります。一方ブロックテーマであれば、テンプレートエディター上でテンプレートパーツを自由に挿入出来るので、管理画面上からもテンプレートパーツを新規追加出来る、という事になります。

そのため、まずはデフォルトコンテンツとなる HTML をテンプレートパーツファイルに書くために、通常の投稿エディター上でテンプレートパーツのコンテンツを作ります。その HTML を、テーマディレクトリの parts/cta.html に書き込みます。

テンプレートパーツのコンテンツ
<!-- wp:group {"align":"full","style":{"color":{"background":"#ffffff"},"spacing":{"padding":{"top":"3rem","bottom":"3rem","right":"1rem","left":"1rem"}}},"layout":{"type":"constrained"}} -->
<div class="wp-block-group alignfull has-background" style="background-color:#ffffff;padding-top:3rem;padding-right:1rem;padding-bottom:3rem;padding-left:1rem"><!-- wp:paragraph {"align":"center","fontSize":"x-large"} -->
<p class="has-text-align-center has-x-large-font-size"><strong>GET IN TOUCH</strong></p>
<!-- /wp:paragraph -->

<!-- wp:paragraph -->
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
<!-- /wp:paragraph -->

<!-- wp:buttons {"layout":{"type":"flex","justifyContent":"center","orientation":"horizontal","flexWrap":"nowrap"}} -->
<div class="wp-block-buttons"><!-- wp:button {"width":50} -->
<div class="wp-block-button has-custom-width wp-block-button__width-50"><a class="wp-block-button__link wp-element-button">Contact us</a></div>
<!-- /wp:button --></div>
<!-- /wp:buttons --></div>
<!-- /wp:group -->

ここまでの対応で、テンプレートパーツエディター上でコンテンツを編集出来るようになります。

テンプレートパーツエディター上でコンテンツを編集出来る

そして最後に、PHP ページテンプレートにこのテンプレートパーツを呼び出す処理を書きます。メインのコンテンツエリア同様、.entry-content.is-layout-flow クラスを付与しているdivタグで囲んでいる所がポイントです。このクラスが無いと、「ブロック間のマージン設定」「ブロック個別のマージン設定」で行った対応がテンプレートパーツエリアに反映されません。

また、メインコンテンツエリアと区別してスタイリングしたい時のために、.parts-cta のような別のクラスを追加するのも良いかもしれません。

</main>

<div class="entry-content is-layout-flow">
	<?php block_template_part( 'cta' ); ?>
</div>
メインコンテンツエリアとテンプレートパーツエリア

理論的には、コンテンツ外のエリアを全てテンプレートパーツエディター化する、という事も出来ます。ただしここまでやる必要があるなら、もはやハイブリッドテーマでは無くブロックテーマでの開発を検討した方が良いかもしれません。

まとめ

ここまで、ハイブリッドテーマ構築のためのいくつかのアプローチを提案しましたが、実際のプロジェクトではこの通りにスムーズに進むことは少なく、イレギュラーな対応やカスタマイズが求められる事がほとんどだと思います。ですが、それにどう対処するかがエンジニアとしての腕の見せ所だと思っていますので、ぜひベストなアプローチを探ってみていただければと思います。また、特に theme.json の設計のアイデアについては、今後のブロックテーマ開発にもそのまま生かせるのではないかと思います。

最後に、この記事で作成された最終的なテーマのソース一式を記載しておきます (assets ディレクトリに配置したフォントファイルは除く)。

​theme.json
{
	"$schema": "https://schemas.wp.org/trunk/theme.json",
	"version": 2,
	"settings": {
		"layout": {
			"contentSize": "960px",
			"wideSize": "1200px"
		},
		"spacing": {
			"blockGap": true,
			"margin": true,
			"padding": true,
			"spacingScale": {
				"steps": 0
			},
			"spacingSizes": [
				{
					"size": "clamp(0.75rem, 0.4090909090909091rem + 1.4545454545454546vw, 1.5rem)",
					"slug": "30",
					"name": "3"
				},
				{
					"size": "clamp(1rem, 0.5454545454545454rem + 1.9393939393939394vw, 2rem)",
					"slug": "40",
					"name": "4"
				},
				{
					"size": "clamp(1.5rem, 0.8181818181818182rem + 2.909090909090909vw, 3rem)",
					"slug": "50",
					"name": "5"
				},
				{
					"size": "clamp(2rem, 1.0909090909090908rem + 3.878787878787879vw, 4rem)",
					"slug": "60",
					"name": "6"
				},
				{
					"size": "clamp(3rem, 1.6363636363636365rem + 5.818181818181818vw, 6rem)",
					"slug": "70",
					"name": "7"
				},
				{
					"size": "clamp(4rem, 2.1818181818181817rem + 7.757575757575758vw, 8rem)",
					"slug": "80",
					"name": "8"
				}
			]
		},
		"color": {
			"defaultPalette": false,
			"palette": [
				{
					"color": "#F6F2EC",
					"name": "Base",
					"slug": "base"
				},
				{
					"color": "#21251F",
					"name": "Contrast",
					"slug": "contrast"
				},
				{
					"color": "#5B4460",
					"name": "Primary",
					"slug": "primary"
				},
				{
					"color": "#FCC263",
					"name": "Secondary",
					"slug": "secondary"
				},
				{
					"color": "#E7A1A9",
					"name": "Tertiary",
					"slug": "tertiary"
				}
			]
		},
		"typography": {
			"lineHeight": true,
			"letterSpacing": false,
			"textDecoration": false,
			"textTransform": false,
			"dropCap": false,
			"fontStyle": false,
			"fluid": true,
			"fontSizes": [
				{
					"fluid": {
						"min": "0.875rem",
						"max": "1rem"
					},
					"size": "1rem",
					"slug": "small"
				},
				{
					"fluid": {
						"min": "1rem",
						"max": "1.125rem"
					},
					"size": "1.125rem",
					"slug": "medium"
				},
				{
					"fluid": {
						"min": "1.125rem",
						"max": "1.5rem"
					},
					"size": "1.5rem",
					"slug": "large"
				},
				{
					"fluid": {
						"min": "1.25rem",
						"max": "1.875rem"
					},
					"size": "1.875rem",
					"slug": "x-large"
				},
				{
					"fluid": {
						"min": "1.5rem",
						"max": "2.25rem"
					},
					"size": "2.25rem",
					"slug": "xx-large"
				}
			],
			"fontFamilies": [
				{
					"fontFace": [
						{
							"fontDisplay": "block",
							"fontFamily": "Noto Sans JP",
							"fontStretch": "normal",
							"fontStyle": "normal",
							"fontWeight": "400",
							"src": [ "file:./assets/font/NotoSansJP-Regular.woff2" ]
						},
						{
							"fontDisplay": "block",
							"fontFamily": "Noto Sans JP",
							"fontStretch": "normal",
							"fontStyle": "normal",
							"fontWeight": "700",
							"src": [ "file:./assets/font/NotoSansJP-Bold.woff2" ]
						}
					],
					"fontFamily": "Noto Sans JP",
					"name": "Noto Sans JP",
					"slug": "noto-sans-jp"
				}
			]
		},
		"custom": {
			"gutter": "1rem",
			"font-size": {
				"huge": "clamp(2rem, 2vw + 1rem, 3rem);",
				"gigantic": "clamp(2.5rem, 3vw + 1rem, 3.75rem);"
			},
			"line-height": {
				"small": 1.4,
				"normal": 1.6
			},
			"color": {
				"gray-light": "#cccccc",
				"gray": "#999999",
				"gray-dark": "#666666"
			}
		}
	},
	"styles": {
		"color": {
			"background": "var(--wp--preset--color--base)",
			"text": "var(--wp--preset--color--contrast)"
		},
		"spacing": {
			"blockGap": "1.5rem"
		},
		"typography": {
			"fontSize": "var(--wp--preset--font-size--medium)",
			"lineHeight": "var(--wp--custom--line-height--normal)",
			"fontFamily": "var(--wp--preset--font-family--noto-sans-jp)"
		},
		"elements": {
			"link": {
				"color": {
					"text": "var(--wp--preset--color--primary)"
				}
			},
			"h1": {
				"typography": {
					"fontSize": "var(--wp--custom--font-size--gigantic)",
					"lineHeight": "var(--wp--custom--line-height--small)"
				}
			},
			"h2": {
				"typography": {
					"fontSize": "var(--wp--preset--font-size--huge)",
					"lineHeight": "var(--wp--custom--line-height--small)"
				},
				"color": {
					"text": "var(--wp--preset--color--base)",
					"background": "var(--wp--preset--color--primary)"
				},
				"spacing": {
					"padding": {
						"left": "0.5em",
						"right": "0.5em",
						"top": "0.25em",
						"bottom": "0.25em"
					}
				}
			},
			"h3": {
				"typography": {
					"fontSize": "var(--wp--preset--font-size--x-large)",
					"lineHeight": "var(--wp--custom--line-height--small)"
				},
				"border": {
					"bottom": {
						"color": "var(--wp--preset--color--primary)",
						"width": "4px",
						"style": "solid"
					}
				},
				"spacing": {
					"padding": {
						"bottom": "0.25em"
					}
				}
			},
			"h4": {
				"typography": {
					"fontSize": "var(--wp--preset--font-size--large)"
				},
				"color": {
					"text": "var(--wp--preset--color--primary)"
				}
			},
			"h5": {
				"typography": {
					"fontSize": "var(--wp--preset--font-size--medium)"
				}
			},
			"h6": {
				"typography": {
					"fontSize": "var(--wp--preset--font-size--medium)"
				}
			},
			"button": {
				"color": {
					"text": "var(--wp--preset--color--contrast)",
					"background": "var(--wp--preset--color--tertiary)"
				},
				"border": {
					"radius": "8px"
				},
				"typography": {
					"lineHeight": "var(--wp--custom--line-height--small)"
				},
				"shadow": "2px 2px 4px var(--wp--preset--color--primary)"
			}
		},
		"blocks": {
			"core/buttons": {
				"spacing": {
					"blockGap": "1rem"
				}
			}
		}
	}
}

​style.css
/*
Theme Name: My Theme
*/

*,
*::before,
*::after {
	box-sizing: border-box;
}

.site-header,
.site-footer {
	background-color: #ddd;
	text-align: center;
	padding: 1rem;
}

.site-main {
	padding-top: 3rem;
	padding-bottom: 3rem;
}

.page-title {
	text-align: center;
	font-size: 2rem;
	margin-top: 0;
	margin-bottom: 3rem;
}

.entry-content > *:not(.alignwide):not(.alignfull) {
	max-width: var(--wp--style--global--content-size);
}

.entry-content > *.alignwide {
	max-width: var(--wp--style--global--wide-size);
}

.entry-content > *:not(.alignfull) {
	margin-right: auto;
	margin-left: auto;
}

.entry-content {
	padding-left: var(--wp--custom--gutter);
	padding-right: var(--wp--custom--gutter);
}

.entry-content > *.alignfull {
	margin-left: calc( var(--wp--custom--gutter) * -1);
	margin-right: calc( var(--wp--custom--gutter) * -1);
}

.is-layout-flow > * + h1,
.is-layout-flow > * + h2,
.is-layout-flow > * + h3,
.is-layout-flow > * + h4,
.is-layout-flow > * + h5,
.is-layout-flow > * + h6 {
	margin-top: 3rem;
}

.wp-block-media-text {
	gap: var(--wp--style--block-gap);
}

.wp-block-media-text .wp-block-media-text__content {
	padding-left: 0;
	padding-right: 0;
}

.wp-block-media-text .wp-block-media-text__content > * {
	margin-block-start: 0;
	margin-block-end: 0;
}

.wp-block-media-text .wp-block-media-text__content > * + * {
	margin-block-start: var(--wp--style--block-gap);
	margin-block-end: 0;
}

h3.is-style-flat {
	padding: 0;
	border-bottom: none;
}

​editor-style.css
body {
	padding-left: var(--wp--custom--gutter);
	padding-right: var(--wp--custom--gutter);
}

.alignfull {
	margin-left: calc( var(--wp--custom--gutter) * -1);
	margin-right: calc( var(--wp--custom--gutter) * -1);
}

.is-layout-flow > * + .wp-block-heading {
	margin-top: 3rem;
}

.wp-block {
	box-sizing: border-box;
}

.wp-block-media-text {
	gap: var(--wp--style--block-gap);
}

.wp-block-media-text .wp-block-media-text__content {
	padding-left: 0;
	padding-right: 0;
}

.wp-block-media-text .wp-block-media-text__content > * {
	margin-block-start: 0;
	margin-block-end: 0;
}

.wp-block-media-text .wp-block-media-text__content > * + * {
	margin-block-start: var(--wp--style--block-gap);
	margin-block-end: 0;
}

h3.is-style-flat {
	padding: 0;
	border-bottom: none;
}
index.php
<!doctype html>
<html <?php language_attributes(); ?>>
<head>
	<meta charset="<?php bloginfo( 'charset' ); ?>" />
	<meta name="viewport" content="width=device-width, initial-scale=1" />
	<?php wp_head(); ?>
</head>

<body <?php body_class(); ?>>

<header class="site-header">
	Site Header
</header>

<main class="site-main">
	<h1 class="page-title"><?php the_title(); ?></h1>
	<?php
	if ( have_posts() ) :
		while ( have_posts() ) :
			the_post();
			?>
			<div class="entry-content is-layout-flow">
				<?php the_content(); ?>
			</div>
		<?php endwhile; ?>
	<?php endif; ?>
</main>

<div class="entry-content is-layout-flow">
	<?php block_template_part( 'cta' ); ?>
</div>

<footer class="site-footer">
	Site Footer
</footer>

<?php wp_footer(); ?>

</body>
</html>
​functions.php
<?php
function my_theme_scripts() {
	wp_enqueue_style(
		'my-theme-style',
		get_template_directory_uri() . '/style.css',
		array(),
		wp_get_theme()->get( 'Version' )
	);
}
add_action( 'wp_enqueue_scripts', 'my_theme_scripts' );

function my_theme_setup() {
	add_theme_support( 'editor-styles' );
	add_editor_style( 'editor-style.css' );
	add_theme_support( 'block-template-parts' );
}
add_action( 'after_setup_theme', 'my_theme_setup' );

function my_theme_register_block_styles() {
	register_block_style(
		'core/heading',
		array(
			'name'  => 'flat',
			'label' => esc_html__( 'Flat', 'mytheme' ),
		)
	);
}
add_action( 'init', 'my_theme_register_block_styles' );
​parts/cta.html
<!-- wp:group {"align":"full","style":{"color":{"background":"#ffffff"},"spacing":{"padding":{"top":"3rem","bottom":"3rem","right":"1rem","left":"1rem"}}},"layout":{"type":"constrained"}} -->
<div class="wp-block-group alignfull has-background" style="background-color:#ffffff;padding-top:3rem;padding-right:1rem;padding-bottom:3rem;padding-left:1rem"><!-- wp:paragraph {"align":"center","fontSize":"x-large"} -->
<p class="has-text-align-center has-x-large-font-size"><strong>GET IN TOUCH</strong></p>
<!-- /wp:paragraph -->

<!-- wp:paragraph -->
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
<!-- /wp:paragraph -->

<!-- wp:buttons {"layout":{"type":"flex","justifyContent":"center","orientation":"horizontal","flexWrap":"nowrap"}} -->
<div class="wp-block-buttons"><!-- wp:button {"width":50} -->
<div class="wp-block-button has-custom-width wp-block-button__width-50"><a class="wp-block-button__link wp-element-button">Contact us</a></div>
<!-- /wp:button --></div>
<!-- /wp:buttons --></div>
<!-- /wp:group -->