This commit is contained in:
Javier Casares 2026-08-10 14:03:36 +00:00
commit 3bb9ed33e1
12 changed files with 1762 additions and 409 deletions

View file

@ -0,0 +1,359 @@
<?php
/**
* Term Meta Class
*
* Provides per-term Open Graph fields (title, image, description) on the
* category and tag edit pages (term.php), and exposes the stored image to the
* taxonomy archive image resolution via the `robotstxt_og_taxonomy_image`
* filter.
*
* @package ROBOTSTXT_OG
* @since 1.2.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Class Robotstxt_OG_Term_Meta
*
* @since 1.2.0
*/
class Robotstxt_OG_Term_Meta {
/**
* Term meta key for the custom OG title.
*
* @since 1.2.0
* @var string
*/
public const META_TITLE = '_og_title';
/**
* Term meta key for the custom OG image URL.
*
* @since 1.2.0
* @var string
*/
public const META_IMAGE = '_og_image';
/**
* Term meta key for the custom OG description.
*
* @since 1.2.0
* @var string
*/
public const META_DESCRIPTION = '_og_description';
/**
* Term meta key for the custom OG image alt text.
*
* @since 1.2.0
* @var string
*/
public const META_IMAGE_ALT = '_og_image_alt';
/**
* Nonce action for the term edit form.
*
* @since 1.2.0
* @var string
*/
public const NONCE_ACTION = 'robotstxt_og_term_meta';
/**
* Nonce field name for the term edit form.
*
* @since 1.2.0
* @var string
*/
public const NONCE_NAME = 'robotstxt_og_term_meta_nonce';
/**
* Initialize hooks.
*
* @since 1.2.0
*
* @return void
*/
public function init(): void {
add_action( 'init', array( $this, 'register_term_meta' ) );
add_filter( 'robotstxt_og_taxonomy_image', array( $this, 'filter_taxonomy_image' ), 10, 2 );
if ( ! is_admin() ) {
return;
}
foreach ( $this->get_supported_taxonomies() as $taxonomy ) {
add_action( "{$taxonomy}_edit_form_fields", array( $this, 'render_term_fields' ) );
add_action(
"edited_{$taxonomy}",
function ( $term_id ) use ( $taxonomy ): void {
$this->save_term_meta( (int) $term_id, $taxonomy );
}
);
}
}
/**
* Get the list of taxonomies the OG fields are exposed on.
*
* @since 1.2.0
*
* @return string[] Taxonomy slugs.
*/
protected function get_supported_taxonomies(): array {
$taxonomies = array( 'category', 'post_tag' );
/**
* Filter the taxonomies that expose per-term Open Graph fields.
*
* @since 1.2.0
*
* @param string[] $taxonomies Taxonomy slugs.
*/
return apply_filters( 'robotstxt_og_term_meta_taxonomies', $taxonomies );
}
/**
* Register term meta for REST API / block editor access.
*
* @since 1.2.0
*
* @return void
*/
public function register_term_meta(): void {
$fields = array(
self::META_TITLE => 'sanitize_text_field',
self::META_IMAGE => 'sanitize_url',
self::META_IMAGE_ALT => 'sanitize_text_field',
self::META_DESCRIPTION => 'sanitize_textarea_field',
);
foreach ( $this->get_supported_taxonomies() as $taxonomy ) {
$cap = $this->get_edit_cap( $taxonomy );
foreach ( $fields as $key => $sanitize ) {
register_term_meta(
$taxonomy,
$key,
array(
'type' => 'string',
'single' => true,
'sanitize_callback' => $sanitize,
'auth_callback' => static function () use ( $cap ) {
return current_user_can( $cap );
},
'show_in_rest' => true,
)
);
}
}
}
/**
* Resolve the edit capability for a taxonomy.
*
* @since 1.2.0
*
* @param string $taxonomy Taxonomy slug.
* @return string Capability name.
*/
protected function get_edit_cap( string $taxonomy ): string {
$tax_obj = get_taxonomy( $taxonomy );
if ( $tax_obj && isset( $tax_obj->cap->edit_terms ) && '' !== $tax_obj->cap->edit_terms ) {
return $tax_obj->cap->edit_terms;
}
return 'manage_categories';
}
/**
* Supply the stored term image URL during taxonomy archive resolution.
*
* Hooked to `robotstxt_og_taxonomy_image`. The resolver still validates the
* URL and resolves a compatible format, so a raw stored value is fine.
*
* @since 1.2.0
*
* @param string $url Default URL (empty string).
* @param int $term_id Term ID.
* @return string
*/
public function filter_taxonomy_image( string $url, $term_id ): string {
$term_id = (int) $term_id;
if ( $term_id <= 0 ) {
return $url;
}
$stored = get_term_meta( $term_id, self::META_IMAGE, true );
$stored = is_string( $stored ) ? $stored : '';
return '' !== $stored ? $stored : $url;
}
/**
* Render the OG fields on the term edit page.
*
* Fires inside the existing term-edit form-table, so rows are emitted
* directly (no wrapping table).
*
* @since 1.2.0
*
* @param WP_Term $term The term being edited.
* @return void
*/
public function render_term_fields( WP_Term $term ): void {
wp_nonce_field( self::NONCE_ACTION, self::NONCE_NAME );
$title = $this->get_meta_string( (int) $term->term_id, self::META_TITLE );
$image = $this->get_meta_string( (int) $term->term_id, self::META_IMAGE );
$alt = $this->get_meta_string( (int) $term->term_id, self::META_IMAGE_ALT );
$desc = $this->get_meta_string( (int) $term->term_id, self::META_DESCRIPTION );
?>
<tr class="form-field robotstxt-og-term-row">
<th scope="row">
<label for="robotstxt_og_term_title"><?php esc_html_e( 'Custom OG Title', 'robotstxt-og' ); ?></label>
</th>
<td>
<input
type="text"
id="robotstxt_og_term_title"
name="robotstxt_og_term_title"
value="<?php echo esc_attr( $title ); ?>"
class="large-text"
/>
<p class="description">
<?php esc_html_e( 'Overrides og:title for this term archive. Leave blank to use the term name.', 'robotstxt-og' ); ?>
</p>
</td>
</tr>
<tr class="form-field robotstxt-og-term-row">
<th scope="row">
<label for="robotstxt_og_term_image"><?php esc_html_e( 'OG Image URL', 'robotstxt-og' ); ?></label>
</th>
<td>
<input
type="url"
id="robotstxt_og_term_image"
name="robotstxt_og_term_image"
value="<?php echo esc_attr( $image ); ?>"
class="large-text"
/>
<p class="description">
<?php esc_html_e( 'Absolute HTTPS URL for og:image on this term archive. JPEG or PNG recommended.', 'robotstxt-og' ); ?>
</p>
</td>
</tr>
<tr class="form-field robotstxt-og-term-row">
<th scope="row">
<label for="robotstxt_og_term_image_alt"><?php esc_html_e( 'OG Image Alt', 'robotstxt-og' ); ?></label>
</th>
<td>
<input
type="text"
id="robotstxt_og_term_image_alt"
name="robotstxt_og_term_image_alt"
value="<?php echo esc_attr( $alt ); ?>"
class="large-text"
/>
<p class="description">
<?php esc_html_e( 'Alt text for the term archive og:image. Leave blank to use the media library alt text.', 'robotstxt-og' ); ?>
</p>
</td>
</tr>
<tr class="form-field robotstxt-og-term-row">
<th scope="row">
<label for="robotstxt_og_term_description"><?php esc_html_e( 'Custom OG Description', 'robotstxt-og' ); ?></label>
</th>
<td>
<textarea
id="robotstxt_og_term_description"
name="robotstxt_og_term_description"
class="large-text"
rows="3"
><?php echo esc_textarea( $desc ); ?></textarea>
<p class="description">
<?php esc_html_e( 'Overrides og:description for this term archive. Leave blank to use the term description.', 'robotstxt-og' ); ?>
</p>
</td>
</tr>
<?php
}
/**
* Save the term OG fields.
*
* @since 1.2.0
*
* @param int $term_id Term ID.
* @param string $taxonomy Taxonomy slug.
* @return void
*/
public function save_term_meta( int $term_id, string $taxonomy ): void {
$nonce_raw = filter_input( INPUT_POST, self::NONCE_NAME, FILTER_SANITIZE_FULL_SPECIAL_CHARS );
$nonce = $nonce_raw ? sanitize_text_field( wp_unslash( $nonce_raw ) ) : '';
if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
return;
}
if ( ! current_user_can( $this->get_edit_cap( $taxonomy ) ) ) {
return;
}
$title_raw = filter_input( INPUT_POST, 'robotstxt_og_term_title', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
$title = $title_raw ? sanitize_text_field( wp_unslash( $title_raw ) ) : '';
$image_raw = filter_input( INPUT_POST, 'robotstxt_og_term_image', FILTER_UNSAFE_RAW );
$image = $image_raw ? sanitize_url( wp_unslash( $image_raw ) ) : '';
$alt_raw = filter_input( INPUT_POST, 'robotstxt_og_term_image_alt', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
$alt = $alt_raw ? sanitize_text_field( wp_unslash( $alt_raw ) ) : '';
$desc_raw = filter_input( INPUT_POST, 'robotstxt_og_term_description', FILTER_UNSAFE_RAW );
$desc = $desc_raw ? sanitize_textarea_field( wp_unslash( $desc_raw ) ) : '';
$this->store_meta( $term_id, self::META_TITLE, $title );
$this->store_meta( $term_id, self::META_IMAGE, $image );
$this->store_meta( $term_id, self::META_IMAGE_ALT, $alt );
$this->store_meta( $term_id, self::META_DESCRIPTION, $desc );
}
/**
* Store (or delete) a single term meta value.
*
* @since 1.2.0
*
* @param int $term_id Term ID.
* @param string $key Meta key.
* @param string $value Sanitized value.
* @return void
*/
protected function store_meta( int $term_id, string $key, string $value ): void {
if ( '' === trim( $value ) ) {
delete_term_meta( $term_id, $key );
return;
}
update_term_meta( $term_id, $key, $value );
}
/**
* Read a term meta value as a string.
*
* @since 1.2.0
*
* @param int $term_id Term ID.
* @param string $key Meta key.
* @return string
*/
private function get_meta_string( int $term_id, string $key ): string {
$raw = get_term_meta( $term_id, $key, true );
return is_string( $raw ) ? $raw : '';
}
}