🤯

【WordPress】投稿タイトルにHTMLタグを使う検証をしてみた

2024/04/23に公開

WordPressの投稿タイトルにはsubタグやsupタグ、イタリックなどが使えない(HTMLタグを書いたらそのまま出力されてしまう)

ぱんくずタイトルだったりRSSのタイトルだったりに使われるので、HTMLタグが除去されるのは正しい挙動だと思います。………が!太字やイタリックはともかく、subとかsupを使いたいっていうケースがそこそこあり、なんか方法はないだろうか、とちょっといろいろ試してみました。

1.そもそもの投稿タイトルに書式フォーマットを有効化できる?

https://github.com/WordPress/gutenberg/blob/trunk/packages/block-library/src/post-title/edit.js#L94-L104

投稿タイトルはPlainTextというコンポーネントを使って実装されていて、このコンポーネント自体が書式フォーマットをサポートしていないようです。

https://github.com/WordPress/gutenberg/tree/trunk/packages/block-editor/src/components/plain-text

ここをそもそもRichTextに変えないとフォーマットをつけることができなさそう?と思い、このあたりは断念。もしかしたらフィルターフック等でどうにかできるのかもしれませんが、少なくとも私のスキルでは無理。

2.サイトで表示するためだけのタイトルブロックを作る

アーカイブ等でも使えるよう、カスタムフィールドに格納する方法を検討します。
でも、ただカスタムフィールドを用意するだけでは入力の二度手間があります。個人的に運用コストが多少でも増えるのはあまり許容できません。

そこで、

  • 投稿タイトルと同じものをブロックに入れる
    • ただし、一度ブロックを編集したら同期は切る
  • ブロック側では書式フォーマットを使える
  • それをカスタムフィールドにも格納する

という方法を試してみることにしました。幸い、post-title ブロックで投稿タイトルが同期して変わる挙動は確認していたので、投稿タイトルの内容をブロックに入れることはさほど難しくないだろうなとは予測していました。

create-blockで空のカスタムブロックを用意する

create-block についてはこちらを参照してください。

https://ja.wordpress.org/team/handbook/block-editor/reference-guides/packages/packages-create-block/

空のブロックが作れるのでお手軽にカスタムブロックが作り始められます。

block.json を用意する

block.json
{
	"$schema": "https://schemas.wp.org/trunk/block.json",
	"apiVersion": 3,
	"name": "chiilog-block/expanded-post-title",
	"version": "0.1.0",
	"title": "Expanded Post Title",
	"category": "text",
	"icon": "smiley",
	"description": "Example block scaffolded with Create Block tool.",
	"example": {},
	"supports": {
		"html": false,
		"multiple": false
	},
	"attributes": {
		"title": {
			"type": "string",
			"default": ""
		}
	},
	"usesContext": [ "postId", "postType" ],
	"textdomain": "expanded-post-title",
	"editorScript": "file:./index.js",
	"editorStyle": "file:./index.css",
	"style": "file:./style-index.css",
	"render": "file:./render.php"
}

block.json はこんな感じで用意します。

"usesContext": [ "postId", "postType" ],

投稿のIDと投稿タイプを使うので、usesContextを使って取得します。
表示自体はダイナミックブロックで行うので、render.phpも用意。

edit.tsx (edit.js) を用意する

edit.tsx
type BlockAttributes = {
	title: string;
};

export default function Edit( {
	attributes: { title },
	setAttributes,
	context: { postType, postId },
}: BlockEditProps< BlockAttributes > ) {
    const blockProps = useBlockProps();

    return (
        <div { ...blockProps }>
            <RichText
                value={ title }
                onChange={ ( value ) => { console.log( value ) } }
            />
        </div>
    );
}

基本形はこんな感じ。なお、インポート類は記述から省略しています。
あとから色々コードを仕込んでいくので、私は大体いつもこんな感じで一旦なんかしたらコンソールログ出るみたいな形で用意しています。

ブロックを編集したら値を更新する

ひとまず、onChangetitleに入力値が入るようにします。

edit.tsx
type BlockAttributes = {
	title: string;
};

export default function Edit( {
	attributes: { title },
	setAttributes,
	context: { postType, postId },
}: BlockEditProps< BlockAttributes > ) {
    const blockProps = useBlockProps();

+	/**
+	 * titleを入力値で更新する
+	 *
+	 * @param value
+	 */
+	const updateTitle = ( value: string ) => {
+		setAttributes( {
+			title: value
+		} );
+	};

    return (
        <div { ...blockProps }>
            <RichText
                value={ title }
-                onChange={ ( value ) => { console.log( value ) } }
+                onChange={ ( value ) => {
+                    updateTitle( value );
+                } }
            />
        </div>
    );
}

これでブロックでテキストを編集すると値が更新されるようになりました。

ついでに、テキスト配置や見出しレベルを変えられるようにします。
block.json のattributesを追加します。

block.json
"attributes": {
    "title": {
        "type": "string",
        "default": ""
    },
+    "level": {
+        "type": "number",
+        "default": 2
+    },
+    "textAlign": {
+        "type": "string"
+    }
}

edit.tsx にテキスト配置と見出しレベルを変えるための記述を追加します。

edit.tsx
type BlockAttributes = {
	title: string;
+	level: number;
+	textAlign: string;
};

export default function Edit( {
-	attributes: { title },
+	attributes: { title, level, textAlign },
	setAttributes,
	context: { postType, postId },
}: BlockEditProps< BlockAttributes > ) {
-    const blockProps = useBlockProps();
+    const blockProps = useBlockProps( {
+		className: classnames( {
+			[ `has-text-align-${ textAlign }` ]: textAlign,
+		} ),
+	} );

+    const TagName = level === 0 ? 'p' : `h${ level }`;
+    const blockEditingMode = useBlockEditingMode();

	/**
	 * titleを入力値で更新する
	 *
	 * @param value
	 */
	const updateTitle = ( value: string ) => {
		setAttributes( {
			title: value
		} );
	};

	return (
		<>
+			{ blockEditingMode === 'default' && (
+				<BlockControls group="block">
+					<HeadingLevelDropdown
+						value={ level }
+						onChange={ ( newLevel: number ) =>
+							setAttributes( { level: newLevel } )
+						}
+					/>
+					<AlignmentControl
+						value={ textAlign }
+						onChange={ ( newAlign: string ) => {
+							setAttributes( { textAlign: newAlign } );
+						} }
+					/>
+				</BlockControls>
+			) }
			<TagName { ...blockProps }>
				<RichText
					value={ title }
					onChange={ ( value ) => {
						updateTitle( value );
					} }
				/>
			</TagName>
		</>
	);
}

useBlockEditingMode は投稿タイトルブロックの方でも使われていたので、こちらでも入れてみました。

useBlockEditingMode

https://developer.wordpress.org/block-editor/reference-guides/packages/packages-block-editor/#useblockeditingmode

default 固定で使うのならいらないのかなーという気もしつつ。
これでテキスト配置と見出しレベルの変更もできるようになりました。

HeadingLevelDropdown

https://developer.wordpress.org/block-editor/reference-guides/packages/packages-block-editor/#headingleveldropdown

AlignmentControl

https://developer.wordpress.org/block-editor/reference-guides/packages/packages-block-editor/#alignmentcontrol

投稿タイトルをデフォルト値として扱う

このままだと、ただRichTextを入れられるブロックを用意しただけです。
初期値として投稿タイトルを入れます。

edit.tsx
export default function Edit( {
	attributes: { title, level, textAlign },
	setAttributes,
	context: { postType, postId },
}: BlockEditProps< BlockAttributes > ) {
	const blockProps = useBlockProps( {
		className: classnames( {
			[ `has-text-align-${ textAlign }` ]: textAlign,
		} ),
	} );
	const TagName = level === 0 ? 'p' : `h${ level }`;
    const blockEditingMode = useBlockEditingMode();

+	const [ rawTitle = '' ] = useEntityProp(
+		'postType',
+		// @ts-ignore
+		postType,
+		'title',
+		postId
+	);

+	useEffect( () => {
+		/**
+		 * ブロックが空の場合はtitleに投稿タイトルを入れる
+		 */
+		if ( title === '' ) {
+			setAttributes( {
+				title: rawTitle,
+			} );
+		}
+	}, [ rawTitle ] );

	...
}

useEntityProp を使って投稿タイトルを取得します。初期値はblock.json で空を指定しているので、titleには投稿タイトルがセットされるという感じです。

しかし、この状態だと一度titleに投稿タイトルが入ったら、もう同期してくれません。投稿タイトルを変更してもtitleは最初に入った値のままというのが確認できます。
私が今回実装したいのは、整理すれば 「titleを自分で入力するまでは投稿タイトルが入るカスタムブロック」 です。

なので、userEdited というattributesを追加して、ブロックを編集したかどうかを検知することにしました。

block.json
"attributes": {
    "title": {
        "type": "string",
        "default": ""
    },
    "level": {
        "type": "number",
        "default": 2
    },
+    "userEdited": {
+        "type": "boolean",
+        "default": false
+    },
    "textAlign": {
        "type": "string"
    }
}
edit.tsx
export default function Edit( {
-	attributes: { title, level, textAlign },
+	attributes: { title, level, userEdited, textAlign },
	setAttributes,
	context: { postType, postId },
}: BlockEditProps< BlockAttributes > ) {
	const blockProps = useBlockProps( {
		className: classnames( {
			[ `has-text-align-${ textAlign }` ]: textAlign,
		} ),
	} );
	const TagName = level === 0 ? 'p' : `h${ level }`;
	const blockEditingMode = useBlockEditingMode();

    const [ rawTitle = '' ] = useEntityProp(
		'postType',
		// @ts-ignore
		postType,
		'title',
		postId
	);

	useEffect( () => {
-		/**
-		 * ブロックが空の場合はtitleに投稿タイトルを入れる
-		 */
-		if ( title === '' ) {
-			setAttributes( {
-				title: rawTitle,
-			} );
-		}
+		/**
+		 * ブロックを編集していない場合はtitleに投稿タイトルを入れる
+		 */
+		if ( ! userEdited ) {
+			setAttributes( {
+				title: rawTitle,
+			} );
+		}
	}, [ rawTitle ] );

    ...
}

このコードで、title というattributesの中身は

  • ブロックを配置した時点で、投稿タイトルと同じものが初期値として入力される
  • ブロックを編集するまでは投稿タイトルを編集しても、編集後の値が入力される
  • ブロックを編集したあとは、独自のテキストを持つブロックになる。投稿タイトルを編集しても同期されない

という振る舞いをするようになりました。

タイトルをカスタムフィールドに格納する

投稿詳細でのみ使うのなら、上記までのコードで十分です。ただ、アーカイブでも表示させる場合はカスタムフィールドの値を表示させる仕組みを取る必要があります。

まあ…「続きを読む」までのブロックは一覧でも詳細でも同じものを使うようなデザインであれば、アーカイブで投稿本文を配置すればそれで済む話ではあります。必ず続きを読むブロックを配置してくださいね、という運用ルールを作る、もしくは投稿テンプレートを作っておく必要はあると思いますが……。

話がそれました。カスタムフィールドに格納するために、格納するためのカスタムフィールドを登録します。

expanded-post-title.php
function chiilog_block_expanded_post_title_register_post_meta() {
	register_meta( 'post', 'expanded_post_title', array(
		'type' => 'string',
		'single' => true,
		'show_in_rest' => true,
	) );
}
add_action( 'init', 'chiilog_block_expanded_post_title_register_post_meta' );

詳細やアーカイブでは、投稿タイトルの代わりにこの expanded_post_title というカスタムフィールドを表示させます。

カスタムフィールドを登録したので、このカスタムフィールドに値をセットします。

edit.tsx
export default function Edit( {
	attributes: { title, level, userEdited, textAlign },
	setAttributes,
	context: { postType, postId },
}: BlockEditProps< BlockAttributes > ) {
	const blockProps = useBlockProps( {
		className: classnames( {
			[ `has-text-align-${ textAlign }` ]: textAlign,
		} ),
	} );
	const TagName = level === 0 ? 'p' : `h${ level }`;
	const blockEditingMode = useBlockEditingMode();

+	const [ meta, setMeta ] = useEntityProp(
+		'postType',
+		// @ts-ignore
+		postType,
+		'meta',
+		postId
+	);

	const [ rawTitle = '' ] = useEntityProp(
		'postType',
		// @ts-ignore
		postType,
		'title',
		postId
	);
	useEffect( () => {
		/**
		 * ブロックを編集していない場合はtitleとカスタムフィールドに投稿タイトルを入れる
		 */
		if ( ! userEdited ) {
			setAttributes( {
				title: rawTitle,
			} );
+			setMeta( {
+				...meta,
+				expanded_post_title: rawTitle,
+			} );
		}
	}, [ rawTitle ] );

	/**
	 * titleとカスタムフィールドを入力値で更新する。
	 * 入力したというフラグをつける。
	 *
	 * @param value
	 */
	const updateTitle = ( value: string ) => {
		setAttributes( {
			title: value,
			userEdited: true,
		} );
+		setMeta( {
+			...meta,
+			expanded_post_title: value,
+		} );
	};

	...
}

これで、

  • 投稿タイトルが変更されたらカスタムフィールドにも同じ値を入れる
  • ブロックの内容が変更されたらその値をカスタムフィールドに入れる

という実装もできました。

ついでに設定パネルにカスタムフィールドの入力欄もつけておきます。

expanded-post-title.php
add_action( 'enqueue_block_editor_assets', function () {
	$asset_file = include plugin_dir_path( __FILE__ ) . 'build/metabox.tsx.asset.php';

	wp_enqueue_script(
		'expanded-post-title-metabox',
		plugins_url( 'build/metabox.tsx.js', __FILE__ ),
		$asset_file['dependencies'],
		$asset_file['version'],
		true
	);
} );
metabox.tsx
import { useSelect } from '@wordpress/data';
import { useEntityProp } from '@wordpress/core-data';
import { PluginDocumentSettingPanel } from '@wordpress/edit-post';
import { TextareaControl } from '@wordpress/components';
import { registerPlugin } from '@wordpress/plugins';

const Render = () => {
	const postType = useSelect( ( select ) => {
		// @ts-ignore
		return select( 'core/editor' ).getCurrentPostType();
	}, [] );
	const [ meta, setMeta ] = useEntityProp( 'postType', postType, 'meta' );

	return (
		<PluginDocumentSettingPanel
			name="expanded-post-title-post-meta-panel"
			title="Post Meta Panel"
		>
			<TextareaControl
				label="Post Meta"
				value={ meta?.expanded_post_title || '' }
				onChange={ ( value ) => {
					setMeta( { ...meta, expanded_post_title: value } );
				} }
			/>
		</PluginDocumentSettingPanel>
	);
};

registerPlugin( 'expanded-post-title-post-meta', {
	render: Render,
	icon: () => null,
} );

これで編集画面側の実装は完了しました。あとは、render.phpの中身を書けば完了です。

表示するためのrender.phpを準備

render.php はPHPのWordPressのタグを使って書くので、結構馴染み深いと思います。

render.php
<?php
/**
 * @var array $attributes The block attributes.
 * @var string $content The block content.
 * @var WP_Block $block The block object.
 *
 * @see https://github.com/WordPress/gutenberg/blob/trunk/docs/reference-guides/block-api/block-metadata.md#render
 */

$tagName = $attributes['level'] === 0 ? 'p' : 'h' . $attributes['level'];

$classes = array();
if ( isset( $attributes['textAlign'] ) ) {
	$classes[] = 'has-text-align-' . $attributes['textAlign'];
}

// expanded_post_title が空だった場合は、通常のタイトルを表示する
$title = get_post_meta( $block->context['postId'], 'expanded_post_title', true );
if ( ! $title ) {
	$title = get_the_title( $block->context['postId'] );
}

?>
<<?php echo $tagName; ?> <?php echo get_block_wrapper_attributes( array( 'class' => implode( ' ', $classes ) ) ); ?>>
	<?php echo wp_kses_post( $title ); ?>
</<?php echo $tagName; ?>>

念の為カスタムフィールドが空だったときは投稿タイトルを入れるようにしています。
ここまでで大体できたのですが、アーカイブテンプレートに配置し、どれかを選択すると全て同じ値が表示される(管理画面だけの問題)状況に遭遇しました。
なので、次はこれを解消します。

アーカイブテンプレート上の表示バグを潰す

ここで参考にした post-title ブロックをもう一度見てみます。

https://github.com/WordPress/gutenberg/blob/trunk/packages/block-library/src/post-title/edit.js#L31C2-L52

まさにこれですね。

useCanEditEntity may trigger an OPTIONS request to the REST API via the canUser resolver. However, when the Post Title is a descendant of a Query Loop block, the title cannot be edited. In order to avoid these unnecessary requests, we call the hook without the proper data, resulting in returning early without making them.

DeepLで翻訳掛けるとこう。

useCanEditEntity は、canUser リゾルバを介して REST API への OPTIONS リクエストをトリガする可能性があります。しかし、Post TitleがQuery Loopブロックの子孫である場合、タイトルを編集することはできません。このような不要なリクエストを回避するために、適切なデータなしでフックを呼び出し、結果的にリクエストを行わずに早めに返すようにしています。

このカスタムフィールドのブロックも、クエリーループの子孫として配置されたときは変更できないようにすればよさそうですね。

block.json
- "usesContext": [ "postId", "postType" ],
+ "usesContext": [ "postId", "postType", "queryId" ],

usesContext にはqueryIdを追加します。

edit.tsxでのコードは同じものを拝借……。

edit.tsx
export default function Edit( {
	attributes: { title, level, userEdited, textAlign },
	setAttributes,
-	context: { postType, postId, queryId },
+	context: { postType, postId },
}: BlockEditProps< BlockAttributes > ) {
	const blockProps = useBlockProps( {
		className: classnames( {
			[ `has-text-align-${ textAlign }` ]: textAlign,
		} ),
	} );
	const TagName = level === 0 ? 'p' : `h${ level }`;
	const blockEditingMode = useBlockEditingMode();
+	const isDescendentOfQueryLoop = Number.isFinite( queryId );
+	const userCanEdit = useSelect(
+		( select ) => {
+			/**
+			 * useCanEditEntity may trigger an OPTIONS request to the REST API
+			 * via the canUser resolver. However, when the Post Title is a
+			 * descendant of a Query Loop block, the title cannot be edited. In
+			 * order to avoid these unnecessary requests, we call the hook
+			 * without the proper data, resulting in returning early without
+			 * making them.
+			 */
+			if ( isDescendentOfQueryLoop ) {
+				return false;
+			}
+			return select( coreStore ).canUserEditEntityRecord(
+				'postType',
+				// @ts-ignore
+				postType,
+				postId
+			);
+		},
+		[ isDescendentOfQueryLoop, postType, postId ]
+	);

	const [ meta, setMeta ] = useEntityProp(
		'postType',
		// @ts-ignore
		postType,
		'meta',
		postId
	);

	const [ rawTitle = '' ] = useEntityProp(
		'postType',
		// @ts-ignore
		postType,
		'title',
		postId
	);
	useEffect( () => {
		/**
		 * ブロックを編集していない場合はtitleとカスタムフィールドに投稿タイトルを入れる
		 */
		if ( ! userEdited ) {
			setAttributes( {
				title: rawTitle,
			} );
			setMeta( {
				...meta,
				expanded_post_title: rawTitle,
			} );
		}
	}, [ rawTitle ] );

	/**
	 * titleとカスタムフィールドを入力値で更新する。
	 * 入力したというフラグをつける。
	 *
	 * @param value
	 */
	const updateTitle = ( value: string ) => {
		setAttributes( {
			title: value,
			userEdited: true,
		} );
		setMeta( {
			...meta,
			expanded_post_title: value,
		} );
	};

	return (
		<>
			{ blockEditingMode === 'default' && (
				<BlockControls group="block">
					<HeadingLevelDropdown
						value={ level }
						onChange={ ( newLevel: number ) =>
							setAttributes( { level: newLevel } )
						}
					/>
					<AlignmentControl
						value={ textAlign }
						onChange={ ( newAlign: string ) => {
							setAttributes( { textAlign: newAlign } );
						} }
					/>
				</BlockControls>
			) }
-			<TagName { ...blockProps }>
-				<RichText
-					value={ title }
-					onChange={ ( value ) => {
-						updateTitle( value );
-					} }
-				/>
-			</TagName>
+			{userCanEdit ? (
+				<TagName { ...blockProps }>
+					<RichText
+						value={ title }
+						onChange={ ( value ) => {
+							updateTitle( value );
+						} }
+					/>
+				</TagName>
+			) : (
+				<TagName
+					{ ...blockProps }
+					// @ts-ignore
+					dangerouslySetInnerHTML={ {
+						__html: meta.expanded_post_title
+							? meta.expanded_post_title
+							: rawTitle,
+					} }
+				/>
+			)}

		</>
	);
}

これでいいだろう!って思ったんですが、変わらずアーカイブテンプレートでは表示バグが。
なんでだろうなとコンソールログしつつ解析してると、meta.expanded_post_titleがHTMLタグつきのものが入ったあとで投稿タイトルが入ってる……。

それなら、表示時点の問題ではなくuserEdited がそもそもの問題を引き起こしてそう。というアタリをつけ、useEffectの中も変更。

edit.tsx
export default function Edit( {
	attributes: { title, level, userEdited, textAlign },
	setAttributes,
	context: { postType, postId, queryId },
}: BlockEditProps< BlockAttributes > ) {
	const blockProps = useBlockProps( {
		className: classnames( {
			[ `has-text-align-${ textAlign }` ]: textAlign,
		} ),
	} );
	const TagName = level === 0 ? 'p' : `h${ level }`;
	const blockEditingMode = useBlockEditingMode();
	const isDescendentOfQueryLoop = Number.isFinite( queryId );
	const userCanEdit = useSelect(
		( select ) => {
			/**
			 * useCanEditEntity may trigger an OPTIONS request to the REST API
			 * via the canUser resolver. However, when the Post Title is a
			 * descendant of a Query Loop block, the title cannot be edited. In
			 * order to avoid these unnecessary requests, we call the hook
			 * without the proper data, resulting in returning early without
			 * making them.
			 */
			if ( isDescendentOfQueryLoop ) {
				return false;
			}
			return select( coreStore ).canUserEditEntityRecord(
				'postType',
				// @ts-ignore
				postType,
				postId
			);
		},
		[ isDescendentOfQueryLoop, postType, postId ]
	);

	const [ meta, setMeta ] = useEntityProp(
		'postType',
		// @ts-ignore
		postType,
		'meta',
		postId
	);

	const [ rawTitle = '' ] = useEntityProp(
		'postType',
		// @ts-ignore
		postType,
		'title',
		postId
	);
	useEffect( () => {
+		if ( ! isDescendentOfQueryLoop ) {
			/**
			 * ブロックを編集していない場合はtitleとカスタムフィールドに投稿タイトルを入れる
			 */
			if ( ! userEdited ) {
				setAttributes( {
					title: rawTitle,
				} );
				setMeta( {
					...meta,
					expanded_post_title: rawTitle,
				} );
			}
+		}
	}, [ rawTitle ] );

	...
}

クエリーループ内のときは処理しないようにしました。これで、アーカイブテンプレートを編集するときも勝手に値が変わって見えることはありません。

ここまでのコードは以下にあります。フルでコードを見てみたい方はどうぞ。

https://github.com/chiilog/expanded-post-title

作ってみての感想

あんまり入力の二度手間は減らせなかった気がします。カスタムフィールドを使わずにできたらそれが一番いいんだけどなあ、とずーっと思いながら書きました。

とは言え、ブロックの練習として考えるのならかなりいい題材にはなりました。
投稿タイトルとの同期、カスタムフィールドへの保存、クエリーループ内での表示調整……単純なように見えて色々と書くものが多かったです。
半分私の実装方法こんなんですよーみたいな感じになりましたが、いつもこんな感じでひとつひとつ問題解決しながらコード書いてます。(※もともとjQueryしか書けなかった勢のため、今でもJSが得意なわけではない)

調べてる最中のメモはこちら。

https://zenn.dev/chiilog/scraps/1b6f91600490a3

株式会社HAMWORKS

Discussion