tools/wordpress-term-meta-generator
WordPress Term Meta Generator
Add custom fields to a taxonomy's term screens: the Add Term form, the Edit Term form, a sanitized save routine, registered meta keys for the REST API, and a column in the terms list.
snippet.php
<?php
/**
* Term meta for the category taxonomy
*
* Generated by wp-skills.com
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class WP_Skills_Term_Meta_Category {
const TAXONOMY = 'category';
/**
* Both halves of the nonce come from here, so the field that is written and
* the key that is verified can never drift apart.
*/
const NONCE_ACTION = 'category_term_meta';
const NONCE_NAME = 'category_term_meta_nonce';
private $meta_fields = array(
array(
'label' => 'Subtitle',
'id' => 'category_subtitle',
'type' => 'text',
'description' => 'Shown under the term name on archive pages.',
'default' => '',
)
);
public function __construct() {
/*
* Two render hooks rather than one, because the two screens are not the
* same shape: the Add Term form is a stack of <div class="form-field">
* blocks, while the Edit Term form is inside core's
* <table class="form-table"> and needs <tr> rows. Reusing one callback
* for both — the common shortcut — produces a table row loose in a div
* on one screen and a bare div inside a table on the other.
*/
add_action( self::TAXONOMY . '_add_form_fields', array( $this, 'render_add_fields' ) );
add_action( self::TAXONOMY . '_edit_form_fields', array( $this, 'render_edit_fields' ), 10, 2 );
add_action( 'created_' . self::TAXONOMY, array( $this, 'save_fields' ) );
add_action( 'edited_' . self::TAXONOMY, array( $this, 'save_fields' ) );
add_action( 'init', array( $this, 'register_meta_keys' ) );
add_filter( 'manage_edit-' . self::TAXONOMY . '_columns', array( $this, 'add_columns' ) );
add_filter( 'manage_' . self::TAXONOMY . '_custom_column', array( $this, 'render_column' ), 10, 3 );
}
public function render_add_fields( $taxonomy ) {
wp_nonce_field( self::NONCE_ACTION, self::NONCE_NAME );
foreach ( $this->meta_fields as $meta_field ) {
$meta_value = isset( $meta_field['default'] ) ? $meta_field['default'] : '';
printf(
'<div class="form-field term-%1$s-wrap"><label for="%1$s">%2$s</label>%3$s%4$s</div>',
esc_attr( $meta_field['id'] ),
esc_html( $meta_field['label'] ),
$this->render_field( $meta_field, $meta_value ),
$this->render_description( $meta_field )
);
}
}
public function render_edit_fields( $term, $taxonomy ) {
// The hook fires inside core's <table class="form-table">, so the nonce
// needs a row of its own: a bare hidden input between rows is not valid
// table markup and the parser moves it out of the table. 'hidden' is a
// core admin class.
echo '<tr class="hidden"><td colspan="2">';
wp_nonce_field( self::NONCE_ACTION, self::NONCE_NAME );
echo '</td></tr>';
foreach ( $this->meta_fields as $meta_field ) {
$meta_value = $this->stored_value( $term->term_id, $meta_field );
// A radio group has no single input to point at, so it gets a plain
// label rather than one referencing an id nothing carries.
$label_for = 'radio' === $meta_field['type']
? ''
: ' for="' . esc_attr( $meta_field['id'] ) . '"';
printf(
'<tr class="form-field term-%1$s-wrap"><th scope="row"><label%2$s>%3$s</label></th><td>%4$s%5$s</td></tr>',
esc_attr( $meta_field['id'] ),
$label_for,
esc_html( $meta_field['label'] ),
$this->render_field( $meta_field, $meta_value ),
$this->render_description( $meta_field )
);
}
}
/**
* The stored value, or the default when nothing has been stored.
*
* An unset key reads back as '' and a saved unchecked box also reads back as
* '0'. Testing for '' rather than empty() is what tells those apart, so the
* default cannot re-check a box the user just cleared.
*/
private function stored_value( $term_id, $meta_field ) {
$meta_value = get_term_meta( $term_id, $meta_field['id'], true );
if ( '' === $meta_value && isset( $meta_field['default'] ) ) {
return $meta_field['default'];
}
return $meta_value;
}
private function render_description( $meta_field ) {
if ( '' === $meta_field['description'] ) {
return '';
}
return sprintf(
'<p class="description">%s</p>',
esc_html( $meta_field['description'] )
);
}
private function render_field( $meta_field, $meta_value ) {
switch ( $meta_field['type'] ) {
default:
return sprintf(
'<input class="regular-text" id="%1$s" name="%1$s" type="%2$s" value="%3$s">',
esc_attr( $meta_field['id'] ),
esc_attr( $meta_field['type'] ),
esc_attr( $meta_value )
);
}
}
/**
* Saves the fields for a term that has just been created or edited.
*
* created_{$taxonomy} and edited_{$taxonomy} fire for every term written
* anywhere — wp_insert_term() in a migration, an importer, the REST API —
* not only for a submission of these two forms. The nonce check is what
* makes that safe: a term created outside the form posts no nonce, so this
* returns before it can overwrite meta with values nobody submitted.
*/
public function save_fields( $term_id ) {
if ( ! isset( $_POST[ self::NONCE_NAME ] ) ) {
return;
}
if ( ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST[ self::NONCE_NAME ] ) ), self::NONCE_ACTION ) ) {
return;
}
// The nonce proves the request came from the term form. It does not
// prove this user is allowed to edit this term.
if ( ! current_user_can( 'edit_term', $term_id ) ) {
return;
}
foreach ( $this->meta_fields as $meta_field ) {
$key = $meta_field['id'];
// An unchecked box posts nothing at all, so its absence is the
// value. Skipping it would leave the previous '1' in place forever.
if ( 'checkbox' === $meta_field['type'] ) {
update_term_meta( $term_id, $key, isset( $_POST[ $key ] ) ? '1' : '0' );
continue;
}
if ( ! isset( $_POST[ $key ] ) ) {
continue;
}
// wp_unslash first: WordPress adds slashes to everything in $_POST,
// so without it an apostrophe gains a backslash on every save.
$value = $this->sanitize_field( $meta_field, wp_unslash( $_POST[ $key ] ) );
update_term_meta( $term_id, $key, $value );
}
}
/**
* WordPress stores whatever it is handed, so this is the only point where
* the value is constrained.
*/
private function sanitize_field( $meta_field, $value ) {
switch ( $meta_field['type'] ) {
case 'email':
return sanitize_email( $value );
case 'url':
return sanitize_url( $value );
case 'number':
return is_numeric( $value ) ? $value + 0 : '';
case 'color':
// Returns null for anything that is not a hex colour.
return (string) sanitize_hex_color( $value );
case 'textarea':
return sanitize_textarea_field( $value );
case 'checkbox':
return '1' === (string) $value ? '1' : '0';
case 'select':
case 'radio':
// Nothing stops a crafted request from posting a value that was
// never offered, so the option list doubles as the allow-list.
$allowed = array_map( 'strval', array_keys( $this->field_options( $meta_field ) ) );
return in_array( (string) $value, $allowed, true ) ? $value : '';
default:
return sanitize_text_field( $value );
}
}
/**
* Registers each key so WordPress knows its type and sanitizer.
*
* 'show_in_rest' only exposes the meta on a taxonomy that is itself in the
* REST API. A taxonomy registered with 'show_in_rest' => false has no route
* to expose it on, and the key simply never appears.
*/
public function register_meta_keys() {
foreach ( $this->meta_fields as $meta_field ) {
register_term_meta(
self::TAXONOMY,
$meta_field['id'],
array(
'type' => isset( $meta_field['rest_type'] ) ? $meta_field['rest_type'] : 'string',
'description' => $meta_field['description'],
'single' => true,
'show_in_rest' => true,
'sanitize_callback' => array( $this, 'sanitize_meta_value' ),
'auth_callback' => array( $this, 'can_edit_terms' ),
)
);
}
}
/**
* The sanitizer WordPress calls for any writer of these keys.
*
* register_meta() hands the callback the value and the key but not the
* field, so the field is looked up here before the type-specific rules in
* sanitize_field() can be applied.
*/
public function sanitize_meta_value( $value, $meta_key ) {
foreach ( $this->meta_fields as $meta_field ) {
if ( $meta_field['id'] === $meta_key ) {
return $this->sanitize_field( $meta_field, $value );
}
}
return sanitize_text_field( $value );
}
/**
* Who may write these keys over the REST API.
*
* 'manage_categories' is the capability the default taxonomy arguments map
* 'edit_term' onto, so this matches what the admin screens already allow.
*/
public function can_edit_terms() {
return current_user_can( 'manage_categories' );
}
public function add_columns( $columns ) {
foreach ( $this->meta_fields as $meta_field ) {
$columns[ $meta_field['id'] ] = $meta_field['label'];
}
return $columns;
}
/**
* Returns the cell content; it must not echo. See the note on the filter in
* the constructor.
*/
public function render_column( $content, $column_name, $term_id ) {
foreach ( $this->meta_fields as $meta_field ) {
if ( $meta_field['id'] !== $column_name ) {
continue;
}
$value = get_term_meta( $term_id, $meta_field['id'], true );
if ( '' === $value ) {
return '—';
}
return 'checkbox' === $meta_field['type']
? ( '1' === $value ? esc_html__( 'Yes', 'text-domain' ) : esc_html__( 'No', 'text-domain' ) )
: esc_html( $value );
}
return $content;
}
}
new WP_Skills_Term_Meta_Category();